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 <cursoragent@cursor.com>
This commit is contained in:
Frank Schwenk
2026-06-11 22:37:06 +02:00
parent 54d29d3afa
commit 659154c93a
9 changed files with 335 additions and 18 deletions
+2
View File
@@ -12,6 +12,8 @@ FTP_USER=
FTP_PASSWORD= FTP_PASSWORD=
# Relativer Pfad zum Document Root (in Plesk prüfen, typisch httpdocs) # Relativer Pfad zum Document Root (in Plesk prüfen, typisch httpdocs)
FTP_REMOTE_PATH=httpdocs FTP_REMOTE_PATH=httpdocs
# Ausserhalb Document Root — nicht öffentlich (config, .env, data)
FTP_PRIVATE_PATH=private
# --- Domain & Server --- # --- Domain & Server ---
DOMAIN=0012.de DOMAIN=0012.de
+20
View File
@@ -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. 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) ## E-Mail (Plesk)
| | | | | |
+5 -2
View File
@@ -56,7 +56,10 @@ Der Webspace sieht boka **von außen** — interne Metriken brauchen das Push-Sk
│ ├── index.php # Status-Beacon Landing │ ├── index.php # Status-Beacon Landing
│ ├── monitor.php # Cron: Pull-Checks │ ├── monitor.php # Cron: Pull-Checks
│ └── ingest.php # Empfang Push von boka │ └── 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 └── todo/ # Aufgabenliste
``` ```
@@ -67,6 +70,6 @@ Offene Arbeiten: [`todo/README.md`](todo/README.md)
## Schnellstart (wenn eingerichtet) ## Schnellstart (wenn eingerichtet)
1. `.env.example` → `.env` kopieren und ausfüllen 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 * * * *`) 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`) 4. Auf boka: Push-Skript + Crontab (siehe `todo/04-monitoring-push-boka.md`)
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
/**
* Load configuration from private/.env (outside document root).
* Called from httpdocs/*.php via require on private/config/bootstrap.php.
*/
function bootstrap(): array
{
static $config = null;
if ($config !== null) {
return $config;
}
$envFile = dirname(__DIR__) . '/.env';
$config = loadEnvFile($envFile);
return $config;
}
function env(string $key, ?string $default = null): ?string
{
$value = bootstrap()[$key] ?? null;
return ($value === null || $value === '') ? $default : $value;
}
function loadEnvFile(string $path): array
{
if (!is_readable($path)) {
return [];
}
$vars = [];
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
if (!str_contains($line, '=')) {
continue;
}
[$key, $value] = explode('=', $line, 2);
$vars[trim($key)] = trim($value);
}
return $vars;
}
function privateDataDir(): string
{
return dirname(__DIR__) . '/data';
}
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/private/config/bootstrap.php';
$domain = env('DOMAIN', '0012.de');
$bokaIp = env('BOKA_IP', '89.163.231.180');
$checksFile = privateDataDir() . '/last-checks.json';
$checks = is_readable($checksFile)
? json_decode((string) file_get_contents($checksFile), true)
: null;
$lastRun = is_array($checks) && isset($checks['updated_at'])
? $checks['updated_at']
: null;
header('Content-Type: text/html; charset=utf-8');
header('X-Robots-Tag: noindex');
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($domain, ENT_QUOTES, 'UTF-8') ?> · external observer</title>
<style>
:root {
--bg: #0d1117;
--text: #c9d1d9;
--muted: #8b949e;
--accent: #58a6ff;
--warn: #d29922;
--ok: #3fb950;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: ui-monospace, "Cascadia Code", "JetBrains Mono", monospace;
line-height: 1.5;
padding: 2rem 1.25rem 3rem;
}
main { max-width: 42rem; margin: 0 auto; }
h1 { font-size: 1.25rem; font-weight: 600; margin: 0 0 0.25rem; letter-spacing: 0.04em; }
.tagline { color: var(--muted); margin: 0 0 2rem; font-size: 0.9rem; }
.panel {
border: 1px solid #30363d;
border-radius: 8px;
padding: 1.25rem 1.5rem;
background: #161b22;
}
.status { color: var(--warn); margin: 0 0 0.75rem; }
.status.ok { color: var(--ok); }
.meta { color: var(--muted); font-size: 0.85rem; margin: 1.5rem 0 0; }
code { color: var(--accent); }
</style>
</head>
<body>
<main>
<h1>0012</h1>
<p class="tagline">external observer node · watching from outside</p>
<div class="panel">
<?php if ($lastRun): ?>
<p class="status ok">Monitoring active — last check <?= htmlspecialchars($lastRun, ENT_QUOTES, 'UTF-8') ?></p>
<?php else: ?>
<p class="status">Monitoring not initialized — awaiting first cron run</p>
<?php endif; ?>
<p class="meta">
Node <code><?= htmlspecialchars($domain, ENT_QUOTES, 'UTF-8') ?></code>
· independent of boka (<code><?= htmlspecialchars($bokaIp, ENT_QUOTES, 'UTF-8') ?></code>)
</p>
</div>
</main>
</body>
</html>
Binary file not shown.
+132
View File
@@ -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()
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
exec python3 "$(dirname "$0")/deploy.py" "$@"
+33 -16
View File
@@ -1,6 +1,7 @@
# 01 — Bootstrap, `.env`, Deployment # 01 — Bootstrap, `.env`, Deployment
**Priorität:** high **Priorität:** high
**Status:** erledigt (2026-06-11)
**Abhängigkeiten:** keine **Abhängigkeiten:** keine
**Blockiert:** 02, 03, 04, 05, 06 **Blockiert:** 02, 03, 04, 05, 06
@@ -10,22 +11,38 @@ Lokales Repo mit dem Webspace verbinden und eine deploybare Grundstruktur anlege
## Checkliste ## Checkliste
- [ ] `.env.example` nach `.env` kopieren - [x] `.env.example` nach `.env` kopieren
- [ ] Plesk-User/Passwort, FTP, später SMTP in `.env` eintragen (nicht committen) - [x] Plesk-User/Passwort, FTP in `.env` eintragen (nicht committen)
- [ ] In Plesk Document Root prüfen (typisch `httpdocs/`) - [x] Document Root: `httpdocs/` (Plesk-Standard)
- [ ] PHP-Version notieren (Cron-Screenshot: 8.5.x) - [x] PHP-Version: 8.5.x (Plesk Cron)
- [ ] Verzeichnis `public/` als Quelle für `httpdocs/` festlegen - [x] Verzeichnis `public/` als Quelle für `httpdocs/`
- [ ] Erstes Test-Deployment: `public/index.php` mit `phpinfo()` oder „hello“ — danach wieder entfernen - [x] Erstes Deployment: `public/index.php` (Status-Beacon Platzhalter)
- [ ] HTTPS für `0012.de` prüfen (Let's Encrypt in Plesk) - [x] HTTPS Let's Encrypt in Plesk (gültig bis 2026-09-09)
- [ ] Optional: Deploy-Skript (`scripts/deploy.sh` rsync/lftp) — nur wenn gewünscht - [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 ## Akzeptanzkriterien
- FTP-Login funktioniert - [x] FTP-Login funktioniert (deploy.sh)
- `https://0012.de/` liefert eine Testseite - [x] `https://0012.de/` — Landing Page über TLS
- `.env` liegt lokal, ist in `.gitignore` - [x] `.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)