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:
@@ -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
@@ -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)
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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.
Executable
+132
@@ -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()
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
exec python3 "$(dirname "$0")/deploy.py" "$@"
|
||||||
@@ -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)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user