From 659154c93abe694dbd451946057602349bace916 Mon Sep 17 00:00:00 2001 From: Frank Schwenk Date: Thu, 11 Jun 2026 22:37:06 +0200 Subject: [PATCH] feat: bootstrap webspace deploy and status-beacon landing page Add public/index.php, private config bootstrap, FTPS deploy script, and complete todo/01 including Let's Encrypt, HTTPS redirect, and HSTS docs. Co-authored-by: Cursor --- .env.example | 2 + HOSTING.md | 20 ++++ README.md | 7 +- config/bootstrap.php | 60 ++++++++++ public/index.php | 80 +++++++++++++ scripts/__pycache__/deploy.cpython-314.pyc | Bin 0 -> 9161 bytes scripts/deploy.py | 132 +++++++++++++++++++++ scripts/deploy.sh | 3 + todo/01-bootstrap-env-deploy.md | 49 +++++--- 9 files changed, 335 insertions(+), 18 deletions(-) create mode 100644 config/bootstrap.php create mode 100644 public/index.php create mode 100644 scripts/__pycache__/deploy.cpython-314.pyc create mode 100755 scripts/deploy.py create mode 100755 scripts/deploy.sh diff --git a/.env.example b/.env.example index 779bd6b..da70187 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,8 @@ FTP_USER= FTP_PASSWORD= # Relativer Pfad zum Document Root (in Plesk prüfen, typisch httpdocs) FTP_REMOTE_PATH=httpdocs +# Ausserhalb Document Root — nicht öffentlich (config, .env, data) +FTP_PRIVATE_PATH=private # --- Domain & Server --- DOMAIN=0012.de diff --git a/HOSTING.md b/HOSTING.md index 53e92a1..d65dae3 100644 --- a/HOSTING.md +++ b/HOSTING.md @@ -42,6 +42,26 @@ DNS-Einträge nur für Domains mit Bezug zum Paket oder über wint.global bestel Domain-Delegation kann nach Registration bis zu **24 Stunden** dauern. Bis dahin ggf. temporäre URL aus dem Kundenbereich nutzen. +## TLS / HTTPS + +Konfiguration in Plesk (Stand 2026-06-11): + +| | | +|---|---| +| Zertifikat | Let's Encrypt (`CN=0012.de`) | +| Gültig | 2026-06-11 — 2026-09-09 (Auto-Renewal in Plesk) | +| HTTP → HTTPS | 301 Permanent Redirect | +| HSTS | `Strict-Transport-Security: max-age=15768000; includeSubDomains` (~182 Tage) | + +Prüfung: + +```bash +curl -sI http://0012.de/ # → 301 Location: https://0012.de/ +curl -sI https://0012.de/ # → 200, HSTS-Header +``` + +**PHP (Web):** 8.3.31 (laut Response-Header). Cron-Aufgaben können abweichende Version wählen (z. B. 8.5.x). + ## E-Mail (Plesk) | | | diff --git a/README.md b/README.md index d48ffc7..840bb23 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,10 @@ Der Webspace sieht boka **von außen** — interne Metriken brauchen das Push-Sk │ ├── index.php # Status-Beacon Landing │ ├── monitor.php # Cron: Pull-Checks │ └── ingest.php # Empfang Push von boka -├── config/ # PHP-Konfiguration (lädt .env) +├── config/ # PHP-Konfiguration → deploy nach private/config/ +├── scripts/ +│ ├── deploy.sh # FTP-Deploy (ruft deploy.py auf) +│ └── deploy.py └── todo/ # Aufgabenliste ``` @@ -67,6 +70,6 @@ Offene Arbeiten: [`todo/README.md`](todo/README.md) ## Schnellstart (wenn eingerichtet) 1. `.env.example` → `.env` kopieren und ausfüllen -2. Dateien nach `httpdocs/` deployen (FTP/rsync) +2. `./scripts/deploy.sh` — spielt `public/` nach `httpdocs/`, Config und `.env` nach `private/` 3. In Plesk: geplante Aufgabe für `monitor.php` (Cron-Stil, z. B. `*/10 * * * *`) 4. Auf boka: Push-Skript + Crontab (siehe `todo/04-monitoring-push-boka.md`) diff --git a/config/bootstrap.php b/config/bootstrap.php new file mode 100644 index 0000000..fb707f0 --- /dev/null +++ b/config/bootstrap.php @@ -0,0 +1,60 @@ + + + + + + + <?= htmlspecialchars($domain, ENT_QUOTES, 'UTF-8') ?> · external observer + + + +
+

0012

+

external observer node · watching from outside

+ +
+ +

Monitoring active — last check

+ +

Monitoring not initialized — awaiting first cron run

+ +

+ Node + · independent of boka () +

+
+
+ + diff --git a/scripts/__pycache__/deploy.cpython-314.pyc b/scripts/__pycache__/deploy.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..43133518c3e5251b9cb9840d49f2220268d8c7c7 GIT binary patch literal 9161 zcmcgST~J$Bdgtn1Nf${7N&Fgvx%`A!7*OoBjcsfk0wK1sF}Wu4CX21e=o-rwl6>!# z4cbgrleW8%Kg*eHO{Pu5cD4<9*eNr8@$6HQoi@`q5orUtq1|*holf3x9ZwSXrQdh1 zt|SOfHr;6tqjSD{e!p|h_kY;sEVmIz|5Cr}a-@QgKj4RvSRG*Q)-Z&mh)7g&o`}qG z=9oz}H6d&|&Z;aeIhCWOSvAwrqFSJ2k6VxND&IuT@y+C1*P}UKO`gsC)7aBRW%a>$0pc$EB$$^lS6}2JQAA`;)x59@Pw~Zn21F$ zh9?0CMngik6ul;>G2wbRs&-FCVi$rD!6%GHB;|^5EgTev#zuYcJJaoDGU0wS&{PreqQtE zCg`SdahOKN%hW8gq$cF(eP<{&4zDFL}y~n3a$lZ+Gmm9r`rN|k$6?)UF9fpZu1Tg64B$|y8*P*ApH*WTuzYUYKmEYFd4$}`` zCZvq)Abti&$dcqwg%u?nofHy1&?x*q^*RuEe1;Nsw=^A`ibo_Qa7yS%@fX99h!Bnn z6S5SNqG~u8QM!}ngnF^-08kk(MJHk*m``I`HRgzy*Q{YB0AnPLRb~{8lcvL}Rwhfq zP(YQYRn4ZvBVjcXj!KGV24W4zp-K(Ps&YN7Uee0q0EUreycXF47-}qVoW|h*jlCkx zXiPveUkgSOl7h|c0wx*nO5-CjSOvPvP4G$S^)&!eWYu1A3tuT~)W7H=T>tv@`2^WX09H?CSlnAsVvY@Qg?)tEH*T;^k)w+haoNqW6lx*Fux$;>yZC>3jyw&?g?_V96 zWp5mxJO0veman^GC+@y=2XR!SPRK|q8w3Gvh4M)oMfkyjKs1XM9}72Wm65k(WJsBZ zOyl5i6gYHMmXZE7ZX_0!WF&PjBcoE#B>_Du%Fs01fep=&Ay>0Z?;Xt2=hv}V9H7P0 zAgwGS4ZUeXq+u!M?mq(n%+0vSQ>KKen4Tj;avj}r+Zvla9E3nIcn_7v7HO8o?Azl14n3KK7zbQ=r;G>=ShNJyGGg%K~O zv6GT2SHfQ9D#$eMax5J6a{3kE5o*j8%{ryCm%>3sdxt@qc3HZb2+Id9+mpYTTnxT_`Nq}Rt8cWWp2#?N|IX^nlWeB4ZDH)~r+@ME;`Qu9 zJ(;!x8TY}D_(OMC!dHC#Uo5HEqu?$7MP2_6?r$xSZ6Gj2hZ2}T*L*O6>6&H}aZ!r8 zZ@Z`h?_THz{q~)7)dtHHw1tbQyh9*M?^wrT;ROBKnxH6>uY;sUZ6T6QB=CGf*)B30 zYD`dL+EBx8C*#HdGFs3fg(}hvgnIrkXoGxPobz*|$V;2j+19=J9!&&#AWv;dhBjy- z;{}aUl=wKRl?bKS;Qee$77`jo(vF);<}i<$L9qh&} zoH=`190a9cRl`$KETJY{1F>jSnoy;XuoJB!Z?YT<;bIUJ1k@#JVI$s7L6v7vCB-8_ zU^?glYU%Hj6&nY8N4!=rYA6Y6R$OuHF*-lCxF_fAo@G}-?Uc=xrQ33RGs3nx z+slqMzC7a;u%^N@Yx`==?q0DsEZZBtAZD0>FHe`xPs~4+X*rm29?J2(IOpXTFV9yk z$T?@n#yRO!O*vkGsa~=D_qrWsxUZlZe6!!&-%8$Vtsbzl?;Ysp22AYxhZ*?zjmZr8 zpDrYNVG$X`GoeC4Zbj(3v<{jc1?j*s<#z~V>1WolSO9ec#W$q`2ZN||xBx(kY=3|? zDUu>Jiwxz!f)v=wk^~L}?r$JX1n3_68?^q8Nn?B?URGl#u7~8^u%>3CBo&BD@)YP) z+&I;#?YjA4hkgMy7La2xRUU*!y2}oV?7E3Hm*d4-=d5Y=RY&P%Gv;!QcdWBe-dB*h zUvO8GpI3LYzhb&K-p~KqN+{fsW|O6yDOYJj4Sv7JE>$Jhg?tolH2k#qj0~QQ}I4a%nK)&7cGWyFbSg?$S@oo zH6Xz7LjwX8I#~1In#MsVfLE}Ej4v7nO=K8k5spI|C>jJHzwy**8bv#6Qv&J%G?046 z$X1s4Si$t5J+{E1v_~Tueufk%P<|8^1JQ-F1hz8T&GKUaQ(}|{u$G;=5&{#1-tQ1r zFmdz`!J0;HX$R|Ar%ksMRT5x+l-&MF=s>Sj1t3M%xQaBJ)AN|y{Jx8Ue1mAh zqo>TjlVbZ|`vnbT(JJyjP`=(W-Hs`eKd6g|U_^JG!6u`uK;7x@$qUB-3#FT)@+B!} z9%b&(97Py@EpmE!8`uKB-vz$FQfG`(B1f}=?(5%llgG4`%`5^@8pg<$EW#KfFK!yg zi>GO{K)6N?L~06#O0lkJk;mEZ7>#b$rc^STE3wzX!vPn*;1Zu@4g8zPDf@WA@!iD! z?oE@!r&zGpdzk=}FeTVPA#MZCF&RaJd{YuxKZlmS-&|Z(yaP0MDoGuz=eiO`GaO=T`phn&QU_8LeSJc>pT3ix+&f8KbqYRtIY4^wI; z`9jisX6&>mj3n@u1mX8Ty(uJ6Iso*V;4RmzGQ?tn6Ov|$=w7lZrsxi_6=ZibI3;QP zg`grKBqJ~DVuFjsr6_vTG5JC`8kA?`eYhaddISPd*CbF-8YiAUJqB`xvc(7vJ&hTa z9|lv<1uT~!005yaM6n%(NnV=R6A&Q-R~y}Y`8+gIcK8PXz%=qSu6Q0=_B^x@S{%rF z9$E49EPHxxvsusK)bN_S@>cE5+FMOGn-;cb-Cd~@Yo4lG58r%v#nZCvX<6vadVHzj z|FpW-_-*s8zvVkV<15nxIlk`GGUtu@x%%|rO4+tt*|vE<>Qd*NbG~~1$MaK}+Rnv` zIey=lEV0#t2r}Ddhtm62D%+MT+kQEgt?bBnb}jB(+P+ktvGsk#9sSY@{a_|t-M(K} zGtglEO@n=)v+Vyu=9E#HLp3f^AKnF(J9vBFR|iA*k|??e{F0c_U55;%4it-nw79L% z;$4-teN*D_Fa?!3_vw8>7^#usByLZIDYLK!{R+E%N;Esl1BEXez6-BGUnWg^cX4 zS(KOzu8L-sF|aFppiTyL41uMf5{So?@H8cMuUY4uXR$qq8S;n50*fig!IV?-CSjgY zR9TYbOVCE|ZvuckqqHu2S{Hy#b^>p>YSJ@$XenE}>myeu1fGnz)ysXWo|-(59L##U zRy_NcJ^Qns1G58bj@o&4;mX3frRJr9rG1&!gC98#-L(>TSJ8qtE*LE`di4zygRPaQ zkD-TyJNX{Q;j_?PK~aoxBmw<>$Yw*d&=G;QLMmvz8qij$QWqIf+>|iXD>6p#HvRxp zPLGp|=AJyYg8RK?Ah^piH?zgu!S(|@}!zZflkR)7}E+}y@i6Fvc4@vr)6+9Fq z%euNk@=TYU$QwRj~V-_jUChYOR5?V#U>P8UAVA)R6OqQH#%4$p%Qscx#?|DDib?fSDSFzUmH==IjcQV}M0X(2lGiwRr%>S;&q61b zHI7b=6j6vB0Uv5k!+F+;UvubWKwSbwbLdB=e^F~mL81W%f+pQK;*bR8SD^k`_$j{z z3WhL5dG$(p+lS?CncXLH<-@6=RnE4;)h=_j^Y+D)nWLvOTy2gUT{U8*S^JLE;Ab|^ z>wD+he|{uuYfcT&NJjdZh5E(wIqvYM?kaRSGTS;9Ph{OaEAAu9?jyG+vhK%HCswQL zZk@e(Hq+=^+Lo>EU8(M0uI_)gHCsKD8u^SXo9)hV+tw;;mTN|Sxp#5r-yL0PIk?<% z@OEpir7v4ElC2y`o&2t5m}Pk0k$!$*e5qrp;r5dm+tD2N=vqm2cV>70yLB1ci5xeq z*E=({4=*G#wyqr4jWs9I&u41d7cS~GpIYrdd+uk?rJnnkcLI_bTSJa(T(h~;tuJ+^ z2G&hHjUCU%a{P`p^q&5@YJq>VX|V(Rqtfs5na;zvpU89!=J>~{x0Pw$y;PlP>dEm3 zjDD6k8yEXB9fxi|l-c=cj_-#k^K2NsF>t>4IsR4KSMb0Iz$>=<3KI7}?djk1#D4O@ ze$O$U)%ZXFZ#WR})@as10Ay$&pxFX}iwXFj1^ll96isppGmNgvPhp0dMYCc^240%< zI1S8$%n5_RCstySYv2XST>s~v=v+p?}OnR>hqd@lr@Ye3R cz(8IfVhHD$P3G8bA9MUqhu|@ftEZU#4-HS5r2qf` literal 0 HcmV?d00001 diff --git a/scripts/deploy.py b/scripts/deploy.py new file mode 100755 index 0000000..920c3a4 --- /dev/null +++ b/scripts/deploy.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Deploy public/, config/, and .env to wint.global / Plesk via FTP/FTPS.""" + +from __future__ import annotations + +import argparse +import ftplib +import os +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent + + +def load_env(path: Path) -> dict[str, str]: + if not path.is_file(): + sys.exit(f"Missing {path} — copy .env.example to .env and fill in credentials.") + + env: dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + key, _, value = line.partition("=") + env[key.strip()] = value.strip() + return env + + +def require_env(env: dict[str, str], *keys: str) -> None: + missing = [k for k in keys if not env.get(k)] + if missing: + sys.exit(f"Missing in .env: {', '.join(missing)}") + + +def connect(env: dict[str, str]) -> ftplib.FTP: + host = env["FTP_HOST"] + user = env["FTP_USER"] + password = env["FTP_PASSWORD"] + + try: + ftp = ftplib.FTP_TLS(host, timeout=60) + ftp.login(user, password) + ftp.prot_p() + print("Connected (FTPS)") + return ftp + except ftplib.all_errors as exc: + print(f"FTPS failed ({exc}), trying plain FTP …") + ftp = ftplib.FTP(host, timeout=60) + ftp.login(user, password) + print("Connected (FTP)") + return ftp + + +def cwd_to_root(ftp: ftplib.FTP) -> None: + try: + ftp.cwd("/") + except ftplib.error_perm: + pass + + +def ensure_remote_dir(ftp: ftplib.FTP, remote_dir: str) -> None: + cwd_to_root(ftp) + for part in [p for p in remote_dir.split("/") if p]: + try: + ftp.cwd(part) + except ftplib.error_perm: + ftp.mkd(part) + ftp.cwd(part) + + +def upload_file(ftp: ftplib.FTP, local: Path, remote_path: str) -> None: + remote_path = remote_path.replace("\\", "/").lstrip("/") + remote_dir = os.path.dirname(remote_path) + remote_name = os.path.basename(remote_path) + + if remote_dir: + ensure_remote_dir(ftp, remote_dir) + else: + cwd_to_root(ftp) + + with local.open("rb") as handle: + ftp.storbinary(f"STOR {remote_name}", handle) + + print(f" ↑ {local.relative_to(ROOT)} → {remote_path}") + + +def upload_tree(ftp: ftplib.FTP, local_dir: Path, remote_base: str) -> None: + remote_base = remote_base.strip("/") + for path in sorted(local_dir.rglob("*")): + if path.is_file(): + rel = path.relative_to(local_dir).as_posix() + upload_file(ftp, path, f"{remote_base}/{rel}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Deploy 0012.de webspace") + parser.add_argument("--dry-run", action="store_true", help="Show plan only") + args = parser.parse_args() + + env = load_env(ROOT / ".env") + require_env(env, "FTP_HOST", "FTP_USER", "FTP_PASSWORD") + + remote_web = env.get("FTP_REMOTE_PATH", "httpdocs").strip("/") + remote_private = env.get("FTP_PRIVATE_PATH", "private").strip("/") + + print("Deploy plan:") + public_files = sum(1 for p in (ROOT / "public").rglob("*") if p.is_file()) + config_files = sum(1 for p in (ROOT / "config").rglob("*") if p.is_file()) + print(f" public/ ({public_files} files) → {remote_web}/") + print(f" config/ ({config_files} files) → {remote_private}/config/") + print(f" .env → {remote_private}/.env") + print(f" (mkdir) {remote_private}/data/") + + if args.dry_run: + return + + print("\nConnecting …") + ftp = connect(env) + try: + print("Uploading …") + upload_tree(ftp, ROOT / "public", remote_web) + upload_tree(ftp, ROOT / "config", f"{remote_private}/config") + ensure_remote_dir(ftp, f"{remote_private}/data") + upload_file(ftp, ROOT / ".env", f"{remote_private}/.env") + print("\nDone.") + finally: + ftp.quit() + + +if __name__ == "__main__": + main() diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..a1453cb --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +exec python3 "$(dirname "$0")/deploy.py" "$@" diff --git a/todo/01-bootstrap-env-deploy.md b/todo/01-bootstrap-env-deploy.md index 1fc73ae..383b97d 100644 --- a/todo/01-bootstrap-env-deploy.md +++ b/todo/01-bootstrap-env-deploy.md @@ -1,6 +1,7 @@ # 01 — Bootstrap, `.env`, Deployment **Priorität:** high +**Status:** erledigt (2026-06-11) **Abhängigkeiten:** keine **Blockiert:** 02, 03, 04, 05, 06 @@ -10,22 +11,38 @@ Lokales Repo mit dem Webspace verbinden und eine deploybare Grundstruktur anlege ## Checkliste -- [ ] `.env.example` nach `.env` kopieren -- [ ] Plesk-User/Passwort, FTP, später SMTP in `.env` eintragen (nicht committen) -- [ ] In Plesk Document Root prüfen (typisch `httpdocs/`) -- [ ] PHP-Version notieren (Cron-Screenshot: 8.5.x) -- [ ] Verzeichnis `public/` als Quelle für `httpdocs/` festlegen -- [ ] Erstes Test-Deployment: `public/index.php` mit `phpinfo()` oder „hello“ — danach wieder entfernen -- [ ] HTTPS für `0012.de` prüfen (Let's Encrypt in Plesk) -- [ ] Optional: Deploy-Skript (`scripts/deploy.sh` rsync/lftp) — nur wenn gewünscht +- [x] `.env.example` nach `.env` kopieren +- [x] Plesk-User/Passwort, FTP in `.env` eintragen (nicht committen) +- [x] Document Root: `httpdocs/` (Plesk-Standard) +- [x] PHP-Version: 8.5.x (Plesk Cron) +- [x] Verzeichnis `public/` als Quelle für `httpdocs/` +- [x] Erstes Deployment: `public/index.php` (Status-Beacon Platzhalter) +- [x] HTTPS Let's Encrypt in Plesk (gültig bis 2026-09-09) +- [x] HTTP → HTTPS Redirect (301) +- [x] HSTS (`Strict-Transport-Security`, max-age 15768000, includeSubDomains) +- [x] Deploy-Skript: `scripts/deploy.sh` (FTPS/FTP via Python) + +## Server-Layout (nach Deploy) + +``` +0012.de/ +├── httpdocs/ ← public/ +│ └── index.php +└── private/ ← nicht öffentlich + ├── .env + ├── config/bootstrap.php + └── data/ ← später last-checks.json +``` + +## Deploy + +```bash +./scripts/deploy.sh # upload +./scripts/deploy.sh --dry-run +``` ## Akzeptanzkriterien -- FTP-Login funktioniert -- `https://0012.de/` liefert eine Testseite -- `.env` liegt lokal, ist in `.gitignore` - -## Notizen - -- Temporäre URL aus Kundenbereich nutzen, falls DNS noch propagiert -- Firewall: Port 8443 (Plesk), 21/22 (FTP/SFTP je nach Anbieter) +- [x] FTP-Login funktioniert (deploy.sh) +- [x] `https://0012.de/` — Landing Page über TLS +- [x] `.env` liegt lokal, ist in `.gitignore`