Compare commits

...

10 Commits

Author SHA1 Message Date
Frank Schwenk f487b35b59 docs: update DOMAINS.md for mobea.de DNS and schwabenjunge.de cancellation
mobea.de DNS is now A-Record/CNAME; remove cancelled schwabenjunge.de entry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 16:14:54 +02:00
Frank Schwenk dab32cc86b feat: populate monitoring targets from compose Host labels
Add 27 HTTPS targets from Traefik compose files; Basic Auth services
expect 200 or 401. Exclude inactive sboa and non-Traefik hosts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 15:35:14 +02:00
Frank Schwenk 85ab829805 feat: add monitoring dashboard under /monitor/
Status page and JSON API read from private/data; root index.php stays a placeholder. Mark todo 06 done.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 15:22:00 +02:00
Frank Schwenk 226bbe1bf7 docs: mark Plesk cron todo done; optional heartbeat backlog
Record live monitor.php cron config in HOSTING.md and close todo 05.
Move optional */5 heartbeat task to todo/13.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 14:56:47 +02:00
Frank Schwenk 9b18b11f32 feat: boka push ingest and stale monitoring (todo 04)
Add token- and IP-protected ingest.php, boka status checks in monitor.php,
and document the two-repo push workflow. Todo 04 marked complete.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 14:51:19 +02:00
Frank Schwenk 2440fbf974 fix: spam-friendly mail headers; docs: monitor.php token todo
Use MTA-style Message-ID and display names on From/To to avoid
SpamAssassin MSGID_RANDY and PDS_NAKED_TO_NUMERO. Add todo/12 for
HTTP token gate on monitor.php (CLI cron unchanged).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 10:48:37 +02:00
Frank Schwenk d07ba3c5c5 docs: add personal domain inventory in DOMAINS.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 10:08:51 +02:00
Frank Schwenk e5dca0e9c0 docs: DMARC p=quarantine and Tutanota blacklist note
Document upgraded DMARC policy and known delivery failure to Tuta-hosted
addresses from the 0012.de mail server; no remediation planned yet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 10:06:31 +02:00
Frank Schwenk c007d5ebdc feat: external HTTPS monitoring pull via monitor.php
Add cron-ready pull checks for critical URLs with SSL expiry warnings,
retry-before-alert, JSON results in private/data, email alerts, and a
90-day target-list review reminder.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 10:05:43 +02:00
Frank Schwenk 69f183d356 docs: DNS via wint client area and DKIM setup notes
Document mail TXT records in the customer login, correct _domainkey
value format, and update todo 02 for DKIM DNS propagation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 08:49:34 +02:00
26 changed files with 1383 additions and 119 deletions
+8
View File
@@ -35,10 +35,18 @@ SMTP_FROM=monitor@0012.de
# Empfänger für Alarme (persönliche Adresse) # Empfänger für Alarme (persönliche Adresse)
ALERT_EMAIL= ALERT_EMAIL=
# --- Monitoring: Pull-Checks (monitor.php) ---
# Reminder wenn Target-Liste länger nicht geprüft (Tage seit last_reviewed_at)
TARGETS_REVIEW_INTERVAL_DAYS=90
# --- Monitoring: Push von boka → Webspace --- # --- Monitoring: Push von boka → Webspace ---
# Token für ingest.php (stark, zufällig; identisch auf boka) # Token für ingest.php (stark, zufällig; identisch auf boka)
MONITOR_INGEST_TOKEN= MONITOR_INGEST_TOKEN=
MONITOR_INGEST_URL=https://0012.de/ingest.php MONITOR_INGEST_URL=https://0012.de/ingest.php
BOKA_IP=89.163.231.180
BOKA_PUSH_STALE_MINUTES=15
BOKA_DISK_WARN_PERCENT=85
BOKA_BACKUP_STALE_HOURS=25
# --- Monitoring: optionale Telegram-Alerts --- # --- Monitoring: optionale Telegram-Alerts ---
TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_TOKEN=
+1
View File
@@ -6,3 +6,4 @@ server.env
__pycache__/ __pycache__/
*.pyc *.pyc
.DS_Store .DS_Store
/data/
+35
View File
@@ -0,0 +1,35 @@
# Domains
Aktuelle Auflistung aller **eigenen** Domains — gruppiert nach Registrar/Anbieter.
Später wird hier vermutlich noch eine separate Liste mit Domains anderer Personen ergänzt. Diese Datei dokumentiert bis dahin ausschließlich Domains, die mir gehören.
## domainssaubillig.de
| Domain | TLD | Reg. am | Reg. bis | Weiterleitung | Status |
|--------|-----|---------|----------|---------------|--------|
| sostehtsgeschrieben.de | de | 24.02.2026 | 24.02.2027 | A-Record/CNAME | OK |
| bsc-gp.de | de | 08.05.2025 | 08.05.2027 | A-Record/CNAME | OK |
| mobea.de | de | 28.07.2017 | 28.07.2026 | A-Record/CNAME | OK |
| eselhoefe.de | de | 04.04.2012 | 04.04.2027 | A-Record/CNAME | OK |
| frank-schwenk.de | de | 09.10.2011 | 09.10.2026 | Externe Nameserver | OK |
| everlasting-flame.de | de | 16.08.2011 | 16.08.2026 | A-Record/CNAME | OK |
## df.eu
| Domain | Nummer | Fällig am | Status |
|--------|--------|-----------|--------|
| f12.rocks | D2398703 | 11.01.2027 | |
| vfb.red | D2130918 | 20.09.2026 | |
## united-domains.de
| Domain |
|--------|
| schwenk.online |
## wint.global
| Domain |
|--------|
| 0012.de |
+54 -27
View File
@@ -42,33 +42,39 @@ 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.
### DNS-Zugang (Classic 1.0) ### DNS bearbeiten (wint Kundenbereich)
Bei wint.global liegt die **DNS-Zone oft beim Hoster** (`ns1.org-ns.com`), nicht beim Kunden in Plesk. SPF und DMARC sind gesetzt; **DKIM-TXT-Records fehlen öffentlich**, obwohl DKIM in Plesk aktiviert ist — typisch, wenn der Hoster DNS pflegt und die Mail-Records nicht synchronisiert hat. DNS ist **nicht** in Plesk, sondern im **wint.global Kundenlogin**:
**Du musst DNS nicht selbst pflegen.** Optionen: | | |
|---|---|
| URL | [https://login.wint.global/clientarea.php](https://login.wint.global/clientarea.php) |
| Navigation | Domain `0012.de` → DNS-Einträge / DNS verwalten |
| Nameserver | `ns1.org-ns.com`, `ns2.org-ns.com` (vom Hoster) |
1. **Support-Ticket** an support@wint.global — DKIM-DNS für `0012.de` hinterlegen (siehe unten) Propagation nach Änderungen: üblicherweise Minuten bis **24 h**.
2. **Workaround:** `ALERT_EMAIL` auf Gmail o. ä. setzen, solange Tuta/strenge Empfänger blockieren
#### Support-Ticket (DKIM) #### Mail-DNS für 0012.de (Stand 2026-06-12)
In Plesk (ohne DNS-Rechte geht das trotzdem): | Host / Name | Typ | TXT-Wert (nur Inhalt, keine Anführungszeichen im UI) |
|-------------|-----|------------------------------------------------------|
| `0012.de` | TXT | `v=spf1 a mx include:spf-a.org-dns.com -all` |
| `_dmarc.0012.de` | TXT | `v=DMARC1; p=quarantine;` |
| `default._domainkey.0012.de` | TXT | `v=DKIM1; p=…` (Public Key aus Plesk → „Externe DNS konfigurieren“) |
| `_domainkey.0012.de` | TXT | `o=-` (DKIM-Policy aus Plesk) |
**Mail → Mail-Einstellungen → „Externe DNS konfigurieren“** — zwei TXT-Records kopieren und an den Support schicken, z. B.: **Plesk:** Mail → Mail-Einstellungen → DKIM aktiv, Selektor **`default`** → „Konfiguration des externen DNS“.
> Bitte folgende TXT-Records für 0012.de in der DNS-Zone ergänzen (DKIM ist in Plesk aktiv, Records fehlen in DNS): **Wichtig beim Eintragen:** In den wint-Maske **nur den TXT-Inhalt** einfügen — nicht die komplette Zone-Zeile (`… IN TXT "…"`). Für `_domainkey.0012.de` muss der Wert z. B. `o=-` sein, nicht `_domainkey.0012.de. IN TXT "o=-"`.
>
> - `default._domainkey.0012.de` → *(Wert aus Plesk)*
> - `_domainkey.0012.de` → *(Wert aus Plesk)*
Prüfung danach: Prüfung (wenn propagiert):
```bash ```bash
dig +short TXT default._domainkey.0012.de @ns1.org-ns.com dig +short TXT default._domainkey.0012.de @ns1.org-ns.com
dig +short TXT _domainkey.0012.de @ns1.org-ns.com
``` ```
Kundennummer **26064** angeben. Stand DNS-Abfrage direkt nach Eintrag: noch leer → **24 h abwarten**, dann erneut prüfen und Testmail an Tuta.
## TLS / HTTPS ## TLS / HTTPS
@@ -116,13 +122,27 @@ Webmail / SMTP-Auth: immer die **Hauptadresse** + Postfach-Passwort, nicht den A
| | Status | | | Status |
|---|--------| |---|--------|
| **SPF** | ✅ gesetzt (Hoster) — nichts tun | | **SPF** | ✅ gesetzt (Hoster) — nichts tun |
| **DKIM** | ⚠️ in Plesk an, **DNS-Record fehlt** — Support-Ticket (kein DNS-Zugang) | | **DKIM** | In Plesk aktiv; DNS in [wint Kundenbereich](https://login.wint.global/clientarea.php) gesetzt (2026-06-12) — Propagation abwarten |
| **DMARC** | ✅ `v=DMARC1;p=none;` (Hoster) — nichts tun | | **DMARC** | ✅ `v=DMARC1; p=quarantine;` (wint DNS, 2026-06-12) |
DMARC ist optional zum Start (`p=none` = nur Reports, keine Ablehnung). Erst nach SPF/DKIM-Stabilisierung auf `quarantine`/`reject` erhöhen. Optional `rua=mailto:admin@0012.de` für Aggregate-Reports ergänzen. DMARC-Policy seit 2026-06-12 auf `p=quarantine` (fehlgeschlagene Auth → Spam/Quarantäne beim Empfänger). Optional später `p=reject` und `rua=mailto:admin@0012.de` für Aggregate-Reports.
Prüfung: [mail-tester.com](https://www.mail-tester.com) oder Test mit `php scripts/test-mail.php`. Prüfung: [mail-tester.com](https://www.mail-tester.com) oder Test mit `php scripts/test-mail.php`.
### Bekannte Einschränkung: Tutanota-Blacklist
Ausgehende Mails vom 0012.de-Mailserver (`ha01s019.org-dns.com`) werden von **Tutanota** mit Absender-IP-Blacklist abgewiesen:
```
<admin@schwenk.online>: host mail.tutanota.de[185.205.69.213] said: 554 5.7.1
<admin@schwenk.online>: Recipient address rejected: Sender address rejected
(blacklisted) (in reply to RCPT TO command)
```
**Stand:** bekannt, vorerst **kein Fix** geplant (Delisting beim Hoster/Tuta prüfen, wenn Mail an `@tutanota.de` / Tuta-gehostete Adressen nötig wird).
**Workaround:** `ALERT_EMAIL` und Testempfänger auf Provider ohne diese Sperre legen (z. B. Gmail) — siehe [`todo/02-email-smtp.md`](todo/02-email-smtp.md).
### Geplante Nutzung ### Geplante Nutzung
1. **Monitoring-Alerts** — `monitor@0012.de` → `ALERT_EMAIL` via `sendAlert()` in `config/mail.php` 1. **Monitoring-Alerts** — `monitor@0012.de` → `ALERT_EMAIL` via `sendAlert()` in `config/mail.php`
@@ -131,19 +151,26 @@ Prüfung: [mail-tester.com](https://www.mail-tester.com) oder Test mit `php scri
## Geplante Aufgaben (Cron) ## Geplante Aufgaben (Cron)
Plesk: **Websites & Domains → Geplante Aufgaben → Aufgabe planen** Plesk: **Websites & Domains → Geplante Aufgaben**
| Einstellung | Empfehlung | **Stand: eingerichtet 2026-06-12** — eine Aufgabe für Pull-Monitoring (`monitor.php`).
|-------------|------------|
| Einstellung | Ist-Wert |
|-------------|----------|
| Aufgabentyp | PHP-Skript ausführen | | Aufgabentyp | PHP-Skript ausführen |
| Skriptpfad | `httpdocs/monitor.php` (nach Deployment) | | Skriptpfad | `httpdocs/monitor.php` |
| PHP-Version | aktuelle verfügbare (z. B. 8.5.x) | | PHP-Version | 8.5.x (höchste stabile in Plesk) |
| Status | Aktiv |
| Ausführen | Cron-Stil | | Ausführen | Cron-Stil |
| Schedule Pull-Checks | `*/10 * * * *` (alle 10 Min) | | Schedule | `*/10 * * * *` (alle 10 Min) |
| Schedule Heartbeat-Check | `*/5 * * * *` (Push-Stale-Prüfung, ggf. in monitor.php) | | Benachrichtigen | Nur Fehler (Plesk-Mail bei PHP-Fehler; App-Alerts über SMTP) |
| Benachrichtigen | „Nur Fehler“ für Cron-PHP-Fehler; App-Alerts über eigenes SMTP | | Beschreibung | z. B. „0012 external monitoring pull“ |
Screenshot-Referenz: Plesk „Aufgabe planen“ (PHP-Skript, Cron-Stil, PHP-Version). **Lauf:** Im Apache-Access-Log als `GET /monitor.php` mit User-Agent `curl/8.20.0` sichtbar. Enthält HTTPS/SSL-Pull, boka-Stale-Prüfung (15 Min) und Push-Schwellen — kein separater Heartbeat-Cron nötig.
**Optional:** Zweite Aufgabe `*/5` nur für Push-Stale — [`todo/13-optional-plesk-cron-heartbeat.md`](todo/13-optional-plesk-cron-heartbeat.md) (backlog, nicht umgesetzt).
**Follow-up:** Cron auf CLI (`php monitor.php`) + HTTP-Token — [`todo/12-monitor-php-token-check.md`](todo/12-monitor-php-token-check.md).
Weitere Plesk-Funktionen (Git, Composer, Hosting-Einstellungen): [`PLESK.md`](PLESK.md) Weitere Plesk-Funktionen (Git, Composer, Hosting-Einstellungen): [`PLESK.md`](PLESK.md)
@@ -159,7 +186,7 @@ Weitere Plesk-Funktionen (Git, Composer, Hosting-Einstellungen): [`PLESK.md`](PL
│ ▲ ▲ │ ▲ ▲
│ │ │ │ │ │
└── Pull HTTPS checks ───────┘ └── Pull HTTPS checks ───────┘
└── Push POST ingest.php ◄── boka Cron └── Push POST ingest.php ◄── boka Cron (IP 89.163.231.180 + Token)
└── SMTP alerts ──────────► du └── SMTP alerts ──────────► du
``` ```
+19 -11
View File
@@ -17,12 +17,15 @@ Siehe auch: [`todo/03-monitoring-pull-phase1.md`](todo/03-monitoring-pull-phase1
- Optional: TCP-Checks (Gitea SSH, RustDesk) - Optional: TCP-Checks (Gitea SSH, RustDesk)
- Bei Fehler: E-Mail / Telegram (nach 2. Versuch, 2 Min Abstand → weniger Fehlalarme) - Bei Fehler: E-Mail / Telegram (nach 2. Versuch, 2 Min Abstand → weniger Fehlalarme)
### Ebene 2 — Push (Cron auf boka, alle 5 Min) ### Ebene 2 — Push (Cron auf boka, alle 5 Min) — **implementiert** (2026-06-12)
- Kleines Skript postet JSON an `ingest.php` auf dem Webspace (Token-geschützt) - Skript `~/server/scripts/push-status.sh` postet JSON an `ingest.php` (Token + IP-Whitelist)
- Payload z. B.: `load`, `disk_percent`, `mem_available`, `containers_running`, `containers_unhealthy` - Payload: `load`, `disk_percent`, `mem_available_mb`, `containers_running`, `containers_unhealthy`
- Webspace-Cron: **kein Push seit 15 Min** → Server oder Netzwerk tot - `ingest.php` speichert nach `private/data/boka-status.json`
- Optional: `disk > 85%`, `unhealthy > 0`, Backup-Heartbeat - `monitor.php` prüft: kein Push seit 15 Min → „boka silent“; optional Disk &gt; 85 %, unhealthy &gt; 0, Backup &gt; 25h
- Sicherheit: `BOKA_IP=89.163.231.180` — nur Requests von dieser IP werden akzeptiert (zusätzlich zum Token)
**Zwei Repos:** PHP in diesem Repo (Pull + Ingest); Push-Skript + Cron im boka-Repo (`~/server`). Deploy-Ablauf: [`todo/04-monitoring-push-boka.md`](todo/04-monitoring-push-boka.md); boka-seitig `docs/MONITORING-WORKFLOW.md`.
--- ---
@@ -131,8 +134,8 @@ Siehe auch: [`todo/03-monitoring-pull-phase1.md`](todo/03-monitoring-pull-phase1
- API-Health für Stacks mit Backend (bringtake, medis, affen, …) - API-Health für Stacks mit Backend (bringtake, medis, affen, …)
- Latenz-Schwelle - Latenz-Schwelle
- Backup-Heartbeat (sobald `todo/05-automated-backups.md` erledigt) - Backup-Heartbeat (sobald Backups existieren) — Ingest-Feld `backup_last_success` vorbereitet
- Push-Status von boka - ~~Push-Status von boka~~ → erledigt (Todo 04)
### Phase 3 (low) ### Phase 3 (low)
@@ -140,14 +143,19 @@ Siehe auch: [`todo/03-monitoring-pull-phase1.md`](todo/03-monitoring-pull-phase1
--- ---
## Dateien (geplant) ## Dateien
| Ort | Datei | Zweck | | Ort | Datei | Zweck |
|-----|-------|--------| |-----|-------|--------|
| Webspace | `monitor.php` | Cron: Pull-Checks, Alarm | | Webspace | `monitor.php` | Cron: Pull-Checks + boka-Stale/Schwellen |
| Webspace | `ingest.php` | Empfang Push von boka | | Webspace | `monitor/index.php` | Öffentliches Status-Dashboard (`/monitor/`, noindex) |
| Webspace | `monitor/status.php` | Read-only JSON-API (`/monitor/status.php`, Alias `status.json`) |
| Webspace | `ingest.php` | Empfang Push von boka (Token + IP) |
| Webspace | `private/data/last-checks.json` | Letzte Pull-Check-Ergebnisse |
| Webspace | `private/data/boka-status.json` | Letzter Push-Stand |
| boka | `~/server/scripts/push-status.sh` | Sammelt Metriken, POST an Webspace | | boka | `~/server/scripts/push-status.sh` | Sammelt Metriken, POST an Webspace |
| boka | Crontab | `*/5 * * * *` push-status + ggf. nach Backup | | boka | `~/server/config/push.env` | `INGEST_URL`, `PUSH_TOKEN` |
| boka | Crontab | `*/5 * * * *` push-status |
--- ---
+5 -4
View File
@@ -12,7 +12,7 @@ Dieser Webspace ist bewusst **unabhängig vom vServer `boka`** (Webtropia Cloud
| **Alarmierung** | E-Mail (und optional Telegram) bei Ausfällen — vom Webspace aus, ohne Zugang zu boka | | **Alarmierung** | E-Mail (und optional Telegram) bei Ausfällen — vom Webspace aus, ohne Zugang zu boka |
| **SMTP-Relay** | Prozesse auf boka und anderswo können über den Plesk-Mailserver von 0012.de senden | | **SMTP-Relay** | Prozesse auf boka und anderswo können über den Plesk-Mailserver von 0012.de senden |
| **Mail-Hosting** | Postfächer für Domains, die noch keinen eigenen Mailserver haben | | **Mail-Hosting** | Postfächer für Domains, die noch keinen eigenen Mailserver haben |
| **Öffentliche Präsenz** | Minimale, aber sinnvolle Landing Page statt leerer Domain | | **Öffentliche Präsenz** | Baustellen-Platzhalter auf `/`; Status-Dashboard unter `/monitor/` |
## Hosting ## Hosting
@@ -53,7 +53,8 @@ Der Webspace sieht boka **von außen** — interne Metriken brauchen das Push-Sk
├── SITE.md # Konzept Landing Page ├── SITE.md # Konzept Landing Page
├── .env.example ├── .env.example
├── public/ # Document Root (FTP → httpdocs) ├── public/ # Document Root (FTP → httpdocs)
│ ├── index.php # Status-Beacon Landing │ ├── index.php # Baustellen-Platzhalter (spätere Website)
│ ├── monitor/ # Status-Dashboard + JSON-API
│ ├── 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 → deploy nach private/config/ ├── config/ # PHP-Konfiguration → deploy nach private/config/
@@ -72,5 +73,5 @@ Offene Arbeiten: [`todo/README.md`](todo/README.md)
1. `.env.example` → `.env` kopieren und ausfüllen (lokal, FTP) 1. `.env.example` → `.env` kopieren und ausfüllen (lokal, FTP)
2. `python3 scripts/sync-server-env.py` → erzeugt `server.env` für den Webspace 2. `python3 scripts/sync-server-env.py` → erzeugt `server.env` für den Webspace
3. `./scripts/deploy.sh` — spielt `public/` + `config/` + `server.env` hoch (**.env bleibt lokal**) 3. `./scripts/deploy.sh` — spielt `public/` + `config/` + `server.env` hoch (**.env bleibt lokal**)
4. In Plesk: geplante Aufgabe für `monitor.php` (Cron-Stil, z. B. `*/10 * * * *`) 4. In Plesk: geplante Aufgabe für `monitor.php` (Cron-Stil, `*/10 * * * *`) — erledigt; Details [`todo/05-plesk-cron.md`](todo/05-plesk-cron.md)
5. Auf boka: Push-Skript + Crontab (siehe `todo/04-monitoring-push-boka.md`) 5. Auf boka: Push-Skript + Crontab — erledigt via SSH (`frank@frank-schwenk.de`, Repo `~/server`); Details [`todo/04-monitoring-push-boka.md`](todo/04-monitoring-push-boka.md)
+12 -5
View File
@@ -2,15 +2,22 @@
**0012.de ist eine eigene Website** — Inhalt noch offen. Kein Monitoring-Dashboard auf der Root-Domain. **0012.de ist eine eigene Website** — Inhalt noch offen. Kein Monitoring-Dashboard auf der Root-Domain.
Monitoring, Status-Beacons, `ingest.php` usw. laufen später in **Unterverzeichnissen oder Subdomains** (z. B. `status.0012.de`, `/ops/` — noch festzulegen). Monitoring und Status laufen unter **`/monitor/`** (nicht auf `/`).
## Aktuell (Platzhalter) ## Aktuell (Platzhalter)
Brutalistische Startseite: scharf, dunkel, sarkastisch. Keine Secrets, keine Ops-Daten. [`public/index.php`](public/index.php) ist eine brutalistische Baustellenseite: scharf, dunkel, sarkastisch. Keine Secrets, keine Ops-Daten. Irgendwann kommt hier vermutlich eine eigenständige Website — Inhalt und Form noch offen.
## Später: Status / Monitoring (nicht Root) ## Status / Monitoring (`/monitor/`)
Siehe [`MONITORING.md`](MONITORING.md) und [`todo/06-site-0012-landing.md`](todo/06-site-0012-landing.md) — Zielpfad anpassen, wenn Subdomain/Pfad steht. Öffentliches Ops-Dashboard (read-only, `noindex`):
- `https://0012.de/monitor/` — HTML-Statusraster aus Pull-Checks + boka-Heartbeat
- `https://0012.de/monitor/status.php` — JSON-API (Alias: `/monitor/status.json` via `.htaccess`)
Siehe [`MONITORING.md`](MONITORING.md) und [`todo/06-site-0012-landing.md`](todo/06-site-0012-landing.md).
Cron/Ingest bleiben auf Root: `monitor.php`, `ingest.php`.
## Was 0012.de werden könnte ## Was 0012.de werden könnte
@@ -18,6 +25,6 @@ Noch unklar. Die Domain bleibt frei für etwas Eigenes — Portfolio, Experiment
## Was bewusst nicht auf 0012.de / ## Was bewusst nicht auf 0012.de /
- Öffentliche Monitoring-Kacheln (→ Subdomain/Pfad) - Öffentliche Monitoring-Kacheln (→ `/monitor/`)
- IPs, Tokens, `.env`-Inhalte - IPs, Tokens, `.env`-Inhalte
- Comic Sans (never again) - Comic Sans (never again)
+39 -4
View File
@@ -44,7 +44,7 @@ function sendMail(string $to, string $subject, string $body, ?string $from = nul
sendCommand($socket, base64_encode($pass), 235); sendCommand($socket, base64_encode($pass), 235);
sendCommand($socket, 'MAIL FROM:<' . $envelopeFrom . '>', 250); sendCommand($socket, 'MAIL FROM:<' . $envelopeFrom . '>', 250);
sendCommand($socket, 'RCPT TO:<' . $to . '>', 250); sendCommand($socket, 'RCPT TO:<' . bareMailAddress($to) . '>', 250);
sendCommand($socket, 'DATA', 354); sendCommand($socket, 'DATA', 354);
$message = buildMessage($fromHeader, $to, $subject, $body); $message = buildMessage($fromHeader, $to, $subject, $body);
@@ -115,15 +115,50 @@ function readResponse($socket): string
return trim($response); return trim($response);
} }
function bareMailAddress(string $address): string
{
$address = trim($address);
if (preg_match('/<([^>]+)>/', $address, $matches)) {
return trim($matches[1]);
}
return $address;
}
function encodeDisplayName(string $name): string
{
if (preg_match('/[\r\n"]/', $name)) {
throw new InvalidArgumentException('Invalid display name');
}
if (preg_match('/^[A-Za-z0-9 !\'.\-]+$/', $name)) {
return $name;
}
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $name) . '"';
}
function formatMailAddress(string $email, string $displayName): string
{
$email = bareMailAddress($email);
return encodeDisplayName($displayName) . ' <' . $email . '>';
}
function buildMessage(string $from, string $to, string $subject, string $body): string function buildMessage(string $from, string $to, string $subject, string $body): string
{ {
$domain = env('DOMAIN', '0012.de'); $domain = env('DOMAIN', '0012.de');
$date = gmdate('D, d M Y H:i:s') . ' +0000'; $date = gmdate('D, d M Y H:i:s') . ' +0000';
$messageId = sprintf('<%s@%s>', bin2hex(random_bytes(8)), $domain); // MTA-style ID (time + pid) — avoids SpamAssassin MSGID_RANDY on random hex IDs.
$messageId = sprintf('<%s.%s@%s>', gmdate('YmdHis'), getmypid(), $domain);
$fromHeader = formatMailAddress($from, env('SMTP_FROM_NAME', '0012 Monitoring'));
$toHeader = formatMailAddress($to, env('MAIL_TO_NAME', 'Alert Recipient'));
$headers = [ $headers = [
"From: {$from}", "From: {$fromHeader}",
"To: {$to}", "To: {$toHeader}",
"Subject: {$subject}", "Subject: {$subject}",
"Date: {$date}", "Date: {$date}",
"Message-ID: {$messageId}", "Message-ID: {$messageId}",
+472
View File
@@ -0,0 +1,472 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/mail.php';
function loadTargetsConfig(): array
{
return require __DIR__ . '/targets.php';
}
/** @return list<array<string, mixed>> */
function getTargetList(array $config): array
{
return $config['targets'] ?? $config;
}
/** @param list<array<string, mixed>> $targets */
function targetsHash(array $targets): string
{
$normalized = array_map(static function (array $target): array {
return [
'name' => $target['name'],
'url' => $target['url'],
'expect' => $target['expect'] ?? 200,
];
}, $targets);
return hash('sha256', json_encode($normalized, JSON_UNESCAPED_SLASHES) ?: '');
}
/** @return list<int> */
function normalizeExpect(mixed $expect): array
{
if (is_array($expect)) {
return array_map(intval(...), $expect);
}
return [(int) $expect];
}
/** @param array<string, mixed> $target */
function checkTarget(array $target): array
{
$timeout = (int) ($target['timeout'] ?? 10);
$expect = normalizeExpect($target['expect'] ?? 200);
$result = checkHttp((string) $target['url'], $timeout, $expect);
$sslDays = checkSslDays((string) $target['url'], $timeout);
$result['name'] = (string) $target['name'];
$result['url'] = (string) $target['url'];
$result['ssl_days_remaining'] = $sslDays;
$result['checked_at'] = gmdate('c');
$result['retried'] = false;
if ($sslDays !== null && $sslDays < 14 && $result['status'] === 'ok') {
$result['status'] = 'warn';
$result['message'] = "SSL expires in {$sslDays} days";
}
return $result;
}
/** @param list<int> $expect */
function checkHttp(string $url, int $timeout, array $expect): array
{
if (!function_exists('curl_init')) {
return httpResult('down', 0, 0, 'cURL not available');
}
$start = hrtime(true);
$handle = curl_init($url);
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => $timeout,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_USERAGENT => '0012-monitor/1.0',
]);
curl_exec($handle);
$httpCode = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
$curlError = curl_error($handle);
curl_close($handle);
$responseMs = (int) round((hrtime(true) - $start) / 1_000_000);
if ($curlError !== '') {
return httpResult('down', $httpCode, $responseMs, sanitizeError($curlError));
}
if (in_array($httpCode, $expect, true)) {
return httpResult('ok', $httpCode, $responseMs, null);
}
return httpResult('down', $httpCode, $responseMs, "HTTP {$httpCode}");
}
function httpResult(string $status, int $httpCode, int $responseMs, ?string $message): array
{
return [
'status' => $status,
'http_code' => $httpCode,
'response_ms' => $responseMs,
'message' => $message,
];
}
function checkSslDays(string $url, int $timeout): ?int
{
$host = parse_url($url, PHP_URL_HOST);
if (!is_string($host) || $host === '') {
return null;
}
$context = stream_context_create([
'ssl' => [
'capture_peer_cert' => true,
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$socket = @stream_socket_client(
"ssl://{$host}:443",
$errno,
$errstr,
$timeout,
STREAM_CLIENT_CONNECT,
$context
);
if ($socket === false) {
return null;
}
$params = stream_context_get_params($socket);
fclose($socket);
$certificate = $params['options']['ssl']['peer_certificate'] ?? null;
if ($certificate === false || $certificate === null) {
return null;
}
$info = openssl_x509_parse($certificate);
if (!is_array($info) || !isset($info['validTo_time_t'])) {
return null;
}
return (int) floor(((int) $info['validTo_time_t'] - time()) / 86400);
}
function sanitizeError(string $error): string
{
if (strlen($error) > 120) {
return substr($error, 0, 117) . '...';
}
return $error;
}
function writeJsonFile(string $path, array $data): void
{
$dir = dirname($path);
if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) {
throw new RuntimeException("Failed to create directory {$dir}");
}
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Failed to encode JSON');
}
$tmp = $path . '.tmp';
if (file_put_contents($tmp, $json . "\n") === false) {
throw new RuntimeException("Failed to write {$tmp}");
}
if (!rename($tmp, $path)) {
throw new RuntimeException("Failed to rename {$tmp} to {$path}");
}
}
function readJsonFile(string $path): ?array
{
if (!is_readable($path)) {
return null;
}
$data = json_decode((string) file_get_contents($path), true);
return is_array($data) ? $data : null;
}
function effectiveLastReviewedAt(array $config, string $currentHash, ?array $reviewState): string
{
if (
$reviewState !== null
&& isset($reviewState['targets_hash'])
&& is_string($reviewState['targets_hash'])
&& $reviewState['targets_hash'] !== ''
&& $reviewState['targets_hash'] !== $currentHash
) {
return gmdate('Y-m-d');
}
$metaDate = $config['_meta']['last_reviewed_at'] ?? null;
if (is_string($metaDate) && $metaDate !== '') {
return $metaDate;
}
return gmdate('Y-m-d');
}
/** @param list<array<string, mixed>> $targets */
function maybeSendReviewReminder(array $config, array $targets, string $currentHash, bool $verbose): void
{
$intervalDays = (int) (env('TARGETS_REVIEW_INTERVAL_DAYS') ?: 90);
$statePath = privateDataDir() . '/review-state.json';
$state = readJsonFile($statePath) ?? [];
$lastReviewed = effectiveLastReviewedAt($config, $currentHash, $state);
$reviewedAt = strtotime($lastReviewed . ' UTC') ?: time();
$daysSinceReview = (int) floor((time() - $reviewedAt) / 86400);
$newState = [
'targets_hash' => $currentHash,
'last_reminder_at' => $state['last_reminder_at'] ?? null,
];
if ($daysSinceReview < $intervalDays) {
writeJsonFile($statePath, $newState);
return;
}
$lastReminder = $state['last_reminder_at'] ?? null;
if (is_string($lastReminder) && $lastReminder !== '') {
$reminderAt = strtotime($lastReminder) ?: 0;
$daysSinceReminder = (int) floor((time() - $reminderAt) / 86400);
if ($daysSinceReminder < 7) {
writeJsonFile($statePath, $newState);
return;
}
}
$domain = env('DOMAIN', '0012.de');
$names = array_map(static fn (array $target): string => (string) $target['name'], $targets);
$subject = "[{$domain}] Monitoring — Zeit für eine Target-Review?";
$body = implode("\n", [
"Deine Monitoring-Liste wurde zuletzt am {$lastReviewed} geprüft ({$daysSinceReview} Tage her).",
'Aktuell überwacht: ' . count($targets) . ' Targets (' . implode(', ', $names) . ').',
'',
'Vorschläge:',
'- Neue Stacks/Domains aus MONITORING.md (medium) nachziehen?',
'- Toten Eintrag entfernen?',
'- expect-Codes prüfen (401 bei Basic Auth)?',
'',
'Nach der Prüfung: last_reviewed_at in config/targets.php auf heute setzen und deployen.',
]);
try {
sendAlert($subject, $body);
$newState['last_reminder_at'] = gmdate('c');
if ($verbose) {
fwrite(STDERR, "Review reminder sent ({$daysSinceReview} days since last review)\n");
}
} catch (Throwable $e) {
if ($verbose) {
fwrite(STDERR, 'Review reminder failed: ' . $e->getMessage() . "\n");
}
}
writeJsonFile($statePath, $newState);
}
function bokaPushStaleMinutes(): int
{
return (int) (env('BOKA_PUSH_STALE_MINUTES') ?: 15);
}
function bokaDiskWarnPercent(): int
{
return (int) (env('BOKA_DISK_WARN_PERCENT') ?: 85);
}
function bokaBackupStaleHours(): int
{
return (int) (env('BOKA_BACKUP_STALE_HOURS') ?: 25);
}
/** @return list<array<string, mixed>> */
function checkBokaPushStatus(): array
{
$ingestToken = env('MONITOR_INGEST_TOKEN') ?? '';
if ($ingestToken === '') {
return [];
}
$statusPath = privateDataDir() . '/boka-status.json';
$status = readJsonFile($statusPath);
if ($status === null) {
return [];
}
$failures = [];
$now = time();
$lastPush = $status['last_push'] ?? null;
if (is_string($lastPush) && $lastPush !== '') {
$lastPushAt = strtotime($lastPush) ?: 0;
$staleMinutes = bokaPushStaleMinutes();
$ageMinutes = (int) floor(($now - $lastPushAt) / 60);
if ($ageMinutes > $staleMinutes) {
$failures[] = [
'name' => 'boka-push',
'url' => 'ingest',
'message' => "boka silent — no push in {$ageMinutes} min",
'retried' => false,
];
}
}
$diskPercent = (int) ($status['disk_percent'] ?? 0);
if ($diskPercent > bokaDiskWarnPercent()) {
$failures[] = [
'name' => 'boka-push',
'url' => 'ingest',
'message' => "disk at {$diskPercent}%",
'retried' => false,
];
}
$unhealthy = (int) ($status['containers_unhealthy'] ?? 0);
if ($unhealthy > 0) {
$failures[] = [
'name' => 'boka-push',
'url' => 'ingest',
'message' => "{$unhealthy} unhealthy container(s)",
'retried' => false,
];
}
$backupLastSuccess = $status['backup_last_success'] ?? null;
if (is_string($backupLastSuccess) && $backupLastSuccess !== '') {
$backupAt = strtotime($backupLastSuccess) ?: 0;
$ageHours = (int) floor(($now - $backupAt) / 3600);
if ($ageHours > bokaBackupStaleHours()) {
$failures[] = [
'name' => 'boka-push',
'url' => 'ingest',
'message' => "backup stale — last success {$ageHours}h ago",
'retried' => false,
];
}
}
return $failures;
}
/** @param list<array<string, mixed>> $failures */
function sendDownAlert(array $failures): void
{
$domain = env('DOMAIN', '0012.de');
$count = count($failures);
$subject = "[{$domain}] Monitoring alert — {$count} target(s) down";
$lines = [];
foreach ($failures as $failure) {
$message = (string) ($failure['message'] ?? 'unknown error');
$retried = !empty($failure['retried']) ? ' (retried)' : '';
$lines[] = "- {$failure['name']} ({$failure['url']}): {$message}{$retried}";
}
sendAlert($subject, implode("\n", $lines));
}
function runMonitor(bool $verbose = false): int
{
$started = hrtime(true);
$config = loadTargetsConfig();
$targets = getTargetList($config);
$currentHash = targetsHash($targets);
$results = [];
$failedIndexes = [];
foreach ($targets as $index => $target) {
$result = checkTarget($target);
$results[$index] = $result;
if ($result['status'] === 'down') {
$failedIndexes[] = $index;
}
if ($verbose) {
fwrite(STDERR, sprintf("%s: %s\n", $result['name'], $result['status']));
}
}
if ($failedIndexes !== []) {
if ($verbose) {
fwrite(STDERR, "Waiting 120s before retry...\n");
}
sleep(120);
foreach ($failedIndexes as $index) {
$result = checkTarget($targets[$index]);
$result['retried'] = true;
$results[$index] = $result;
if ($verbose) {
fwrite(STDERR, sprintf("%s (retry): %s\n", $result['name'], $result['status']));
}
}
}
$checks = array_values($results);
$payload = [
'updated_at' => gmdate('c'),
'duration_ms' => (int) round((hrtime(true) - $started) / 1_000_000),
'checks' => $checks,
];
try {
writeJsonFile(privateDataDir() . '/last-checks.json', $payload);
} catch (Throwable $e) {
if ($verbose) {
fwrite(STDERR, 'JSON write failed: ' . $e->getMessage() . "\n");
}
return 1;
}
maybeSendReviewReminder($config, $targets, $currentHash, $verbose);
$failures = array_values(array_filter($checks, static fn (array $check): bool => $check['status'] === 'down'));
$bokaFailures = checkBokaPushStatus();
if ($bokaFailures !== []) {
if ($verbose) {
foreach ($bokaFailures as $failure) {
fwrite(STDERR, sprintf("boka-push: %s\n", $failure['message']));
}
}
$failures = array_merge($failures, $bokaFailures);
}
if ($failures === []) {
return 0;
}
try {
sendDownAlert($failures);
} catch (Throwable $e) {
if ($verbose) {
fwrite(STDERR, 'Down alert failed: ' . $e->getMessage() . "\n");
}
return 1;
}
return 1;
}
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/monitor-lib.php';
function loadStatusSnapshot(): array
{
$checksData = readJsonFile(privateDataDir() . '/last-checks.json');
$bokaData = readJsonFile(privateDataDir() . '/boka-status.json');
return [
'checks_data' => $checksData,
'boka_data' => $bokaData,
];
}
/** @param array{checks_data: ?array, boka_data: ?array} $snapshot */
function formatPublicStatus(array $snapshot): array
{
$checksData = $snapshot['checks_data'];
$bokaData = $snapshot['boka_data'];
if ($checksData === null && $bokaData === null) {
return [
'initialized' => false,
'message' => 'Monitoring not initialized',
'updated_at' => null,
'duration_ms' => null,
'checks' => [],
'boka' => null,
];
}
$checks = [];
foreach ($checksData['checks'] ?? [] as $check) {
if (!is_array($check)) {
continue;
}
$name = (string) ($check['name'] ?? '');
if ($name === '' || str_starts_with($name, '_')) {
continue;
}
$status = (string) ($check['status'] ?? 'down');
if (!in_array($status, ['ok', 'warn', 'down'], true)) {
$status = 'down';
}
$message = $check['message'] ?? null;
if (is_string($message) && $message !== '') {
$message = sanitizeError($message);
} else {
$message = null;
}
$checks[] = [
'name' => $name,
'status' => $status,
'message' => $message,
'response_ms' => isset($check['response_ms']) ? (int) $check['response_ms'] : null,
'ssl_days_remaining' => isset($check['ssl_days_remaining']) ? (int) $check['ssl_days_remaining'] : null,
'checked_at' => isset($check['checked_at']) && is_string($check['checked_at']) ? $check['checked_at'] : null,
];
}
$boka = null;
if ($bokaData !== null) {
$lastPush = $bokaData['last_push'] ?? null;
$ageMinutes = null;
if (is_string($lastPush) && $lastPush !== '') {
$lastPushAt = strtotime($lastPush) ?: 0;
if ($lastPushAt > 0) {
$ageMinutes = (int) floor((time() - $lastPushAt) / 60);
}
}
$boka = [
'host' => isset($bokaData['host']) && is_string($bokaData['host']) ? $bokaData['host'] : 'boka',
'last_push' => is_string($lastPush) ? $lastPush : null,
'age_minutes' => $ageMinutes,
'disk_percent' => isset($bokaData['disk_percent']) ? (int) $bokaData['disk_percent'] : null,
'mem_available_mb' => isset($bokaData['mem_available_mb']) ? (int) $bokaData['mem_available_mb'] : null,
'containers_running' => isset($bokaData['containers_running']) ? (int) $bokaData['containers_running'] : null,
'containers_unhealthy' => isset($bokaData['containers_unhealthy']) ? (int) $bokaData['containers_unhealthy'] : null,
'backup_last_success' => isset($bokaData['backup_last_success']) && is_string($bokaData['backup_last_success'])
? $bokaData['backup_last_success']
: null,
];
}
return [
'initialized' => $checksData !== null || $bokaData !== null,
'message' => null,
'updated_at' => isset($checksData['updated_at']) && is_string($checksData['updated_at'])
? $checksData['updated_at']
: null,
'duration_ms' => isset($checksData['duration_ms']) ? (int) $checksData['duration_ms'] : null,
'checks' => $checks,
'boka' => $boka,
];
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/**
* External HTTPS monitoring targets (boka / Traefik).
* Quelle: Host()-Labels in ~/…/compose.yml und ~/…/docker-compose.yml
* After manual infra review: bump _meta.last_reviewed_at and deploy to 0012.de.
*/
return [
'_meta' => [
'last_reviewed_at' => '2026-06-12',
],
'targets' => [
['name' => 'affen.schwenk.online', 'url' => 'https://affen.schwenk.online', 'expect' => 200],
['name' => 'api.bringtake.schwenk.online', 'url' => 'https://api.bringtake.schwenk.online', 'expect' => 200],
['name' => 'beer.schwenk.online', 'url' => 'https://beer.schwenk.online', 'expect' => 200],
['name' => 'beta.score.bsc-gp.de', 'url' => 'https://beta.score.bsc-gp.de', 'expect' => 200],
['name' => 'bringtake.schwenk.online', 'url' => 'https://bringtake.schwenk.online', 'expect' => 200],
['name' => 'bsc-gp.de', 'url' => 'https://bsc-gp.de', 'expect' => 200],
['name' => 'eselhoefe.de', 'url' => 'https://eselhoefe.de', 'expect' => 200],
['name' => 'everlasting-flame.de', 'url' => 'https://everlasting-flame.de', 'expect' => 200],
['name' => 'f12.rocks', 'url' => 'https://f12.rocks', 'expect' => 200],
['name' => 'frank-schwenk.de', 'url' => 'https://frank-schwenk.de', 'expect' => 200],
['name' => 'fussballdeical.schwenk.online', 'url' => 'https://fussballdeical.schwenk.online', 'expect' => 200],
['name' => 'gitea.schwenk.online', 'url' => 'https://gitea.schwenk.online', 'expect' => 200],
['name' => 'kkwebskeleton.schwenk.online', 'url' => 'https://kkwebskeleton.schwenk.online', 'expect' => [200, 401]],
['name' => 'link.schwenk.online', 'url' => 'https://link.schwenk.online', 'expect' => 200],
['name' => 'medis.schwenk.online', 'url' => 'https://medis.schwenk.online', 'expect' => 200],
['name' => 'pics.schwenk.online', 'url' => 'https://pics.schwenk.online', 'expect' => 200],
['name' => 'portainer.schwenk.online', 'url' => 'https://portainer.schwenk.online', 'expect' => [200, 401]],
['name' => 'practice.bsc-gp.de', 'url' => 'https://practice.bsc-gp.de', 'expect' => 200],
['name' => 'psitransfer.schwenk.online', 'url' => 'https://psitransfer.schwenk.online', 'expect' => 200],
['name' => 'rotation.bsc-gp.de', 'url' => 'https://rotation.bsc-gp.de', 'expect' => 200],
['name' => 'schwenk.online', 'url' => 'https://schwenk.online', 'expect' => 200],
['name' => 'score.bsc-gp.de', 'url' => 'https://score.bsc-gp.de', 'expect' => 200],
['name' => 'shotclock.bsc-gp.de', 'url' => 'https://shotclock.bsc-gp.de', 'expect' => 200],
['name' => 'sostehtsgeschrieben.de', 'url' => 'https://sostehtsgeschrieben.de', 'expect' => 200],
['name' => 'taxi.schwenk.online', 'url' => 'https://taxi.schwenk.online', 'expect' => 200],
['name' => 'traefik.schwenk.online', 'url' => 'https://traefik.schwenk.online', 'expect' => [200, 401]],
['name' => 'vfb.red', 'url' => 'https://vfb.red', 'expect' => 200],
],
];
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
/**
* Receive push metrics from boka (POST JSON, token + IP whitelist).
* Usage: POST /ingest.php with JSON body from push-status.sh
*/
$configDir = is_dir(dirname(__DIR__) . '/private/config')
? dirname(__DIR__) . '/private/config'
: dirname(__DIR__) . '/config';
require_once $configDir . '/monitor-lib.php';
header('Content-Type: application/json; charset=utf-8');
function ingestError(int $code, string $message): never
{
http_response_code($code);
echo json_encode(['ok' => false, 'error' => $message], JSON_UNESCAPED_SLASHES);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
ingestError(405, 'method not allowed');
}
$allowedIp = env('BOKA_IP');
if ($allowedIp !== null && $allowedIp !== '') {
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '';
if (!hash_equals($allowedIp, $remoteAddr)) {
ingestError(403, 'forbidden');
}
}
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
ingestError(400, 'empty body');
}
$data = json_decode($raw, true);
if (!is_array($data)) {
ingestError(400, 'invalid json');
}
$expectedToken = env('MONITOR_INGEST_TOKEN') ?? '';
$token = isset($data['token']) && is_string($data['token']) ? $data['token'] : '';
if ($expectedToken === '' || !hash_equals($expectedToken, $token)) {
ingestError(403, 'forbidden');
}
if (!isset($data['load']) || !is_array($data['load'])) {
ingestError(400, 'missing load');
}
foreach (['disk_percent', 'mem_available_mb', 'containers_running', 'containers_unhealthy'] as $field) {
if (!isset($data[$field]) || !is_numeric($data[$field])) {
ingestError(400, "missing {$field}");
}
}
$load = array_map(static fn (mixed $v): float => (float) $v, $data['load']);
$backupLastSuccess = null;
if (isset($data['backup_last_success']) && is_string($data['backup_last_success']) && $data['backup_last_success'] !== '') {
$backupLastSuccess = $data['backup_last_success'];
}
$host = isset($data['host']) && is_string($data['host']) ? $data['host'] : 'boka';
$clientTimestamp = isset($data['timestamp']) && is_string($data['timestamp']) ? $data['timestamp'] : null;
$status = [
'last_push' => gmdate('c'),
'received_from' => $_SERVER['REMOTE_ADDR'] ?? null,
'host' => $host,
'client_timestamp' => $clientTimestamp,
'load' => $load,
'disk_percent' => (int) $data['disk_percent'],
'mem_available_mb' => (int) $data['mem_available_mb'],
'containers_running' => (int) $data['containers_running'],
'containers_unhealthy' => (int) $data['containers_unhealthy'],
'backup_last_success' => $backupLastSuccess,
];
try {
writeJsonFile(privateDataDir() . '/boka-status.json', $status);
} catch (Throwable $e) {
ingestError(500, 'storage error');
}
echo json_encode(['ok' => true], JSON_UNESCAPED_SLASHES);
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
/**
* External HTTPS/SSL monitoring — run via Plesk cron or CLI.
* Usage: php monitor.php [--verbose]
*/
$configDir = is_dir(dirname(__DIR__) . '/private/config')
? dirname(__DIR__) . '/private/config'
: dirname(__DIR__) . '/config';
require_once $configDir . '/monitor-lib.php';
set_time_limit(300);
$verbose = in_array('--verbose', $argv ?? [], true);
exit(runMonitor($verbose));
+2
View File
@@ -0,0 +1,2 @@
RewriteEngine On
RewriteRule ^status\.json$ status.php [L]
+274
View File
@@ -0,0 +1,274 @@
<?php
declare(strict_types=1);
$root = dirname(dirname(__DIR__));
$configDir = is_dir($root . '/private/config')
? $root . '/private/config'
: $root . '/config';
require_once $configDir . '/status-view.php';
$status = formatPublicStatus(loadStatusSnapshot());
header('Content-Type: text/html; charset=utf-8');
header('X-Robots-Tag: noindex');
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function formatCheckedAt(?string $iso): string
{
if ($iso === null || $iso === '') {
return '—';
}
$ts = strtotime($iso);
if ($ts === false) {
return '—';
}
return gmdate('Y-m-d H:i', $ts) . ' UTC';
}
function bokaHeartbeatLine(?array $boka): string
{
if ($boka === null) {
return 'No push data yet';
}
$host = (string) ($boka['host'] ?? 'boka');
$age = $boka['age_minutes'];
if ($age === null) {
return "{$host}: no push timestamp";
}
$line = "{$host}: last push {$age} min ago";
$details = [];
if ($boka['disk_percent'] !== null) {
$details[] = "disk {$boka['disk_percent']}%";
}
if ($boka['mem_available_mb'] !== null) {
$details[] = "mem {$boka['mem_available_mb']} MB free";
}
if ($boka['containers_running'] !== null) {
$containers = (int) $boka['containers_running'];
$unhealthy = (int) ($boka['containers_unhealthy'] ?? 0);
$details[] = $unhealthy > 0
? "{$containers} containers ({$unhealthy} unhealthy)"
: "{$containers} containers";
}
if ($details !== []) {
$line .= ' · ' . implode(' · ', $details);
}
return $line;
}
$jahr = (int) date('Y');
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>0012 — status</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0a0a;
--fg: #e8e8e8;
--dim: #666;
--line: #333;
--acid: #c8ff00;
--blood: #ff2a2a;
--amber: #ffb020;
}
html { font-size: 16px; }
body {
min-height: 100vh;
background: var(--bg);
color: var(--fg);
font-family: "IBM Plex Mono", "Courier New", monospace;
line-height: 1.45;
padding: 0 clamp(1rem, 4vw, 3rem);
}
.wrap {
width: 100%;
max-width: 52rem;
margin: 0 auto;
min-height: 100vh;
border-left: 4px solid var(--acid);
border-right: 1px solid var(--line);
}
header {
border-bottom: 1px solid var(--line);
padding: 2rem 1.5rem 1.5rem;
}
.logo {
font-size: clamp(2.5rem, 10vw, 4rem);
font-weight: 700;
letter-spacing: -0.04em;
line-height: 1;
color: var(--fg);
}
.logo span { color: var(--acid); }
.claim {
margin-top: 0.75rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--dim);
}
section {
border-bottom: 1px solid var(--line);
padding: 1.5rem;
}
h2 {
font-size: 0.7rem;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.25em;
color: var(--dim);
margin-bottom: 1rem;
}
p { margin-bottom: 0.75rem; }
p:last-child { margin-bottom: 0; }
.empty {
border: 1px solid var(--line);
padding: 1.25rem;
color: var(--dim);
font-size: 0.85rem;
}
.heartbeat {
font-size: 0.85rem;
padding: 0.75rem 1rem;
border: 1px solid var(--line);
border-left: 3px solid var(--acid);
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0;
border: 1px solid var(--line);
}
@media (max-width: 520px) {
.grid { grid-template-columns: 1fr; }
.tile { border-right: none !important; }
}
.tile {
padding: 1rem 1.25rem;
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
font-size: 0.8rem;
border-left: 3px solid var(--line);
}
.tile:nth-child(2n) { border-right: none; }
.tile--ok { border-left-color: var(--acid); }
.tile--warn { border-left-color: var(--amber); }
.tile--down { border-left-color: var(--blood); }
.tile strong {
display: block;
font-size: 0.65rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--dim);
margin-bottom: 0.35rem;
}
.tile .status {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.12em;
margin-bottom: 0.5rem;
}
.tile--ok .status { color: var(--acid); }
.tile--warn .status { color: var(--amber); }
.tile--down .status { color: var(--blood); }
.tile .meta {
font-size: 0.7rem;
color: var(--dim);
margin-top: 0.35rem;
}
footer {
padding: 1.5rem;
font-size: 0.7rem;
color: var(--dim);
letter-spacing: 0.05em;
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo">00<span>12</span> / monitor</div>
<p class="claim">External status · pull checks · boka push</p>
</header>
<?php if (!$status['initialized']): ?>
<section>
<div class="empty"><?= e((string) $status['message']) ?></div>
</section>
<?php else: ?>
<section>
<h2>boka heartbeat</h2>
<div class="heartbeat"><?= e(bokaHeartbeatLine($status['boka'])) ?></div>
</section>
<section>
<h2>Targets</h2>
<?php if ($status['checks'] === []): ?>
<div class="empty">No check results yet</div>
<?php else: ?>
<div class="grid">
<?php foreach ($status['checks'] as $check): ?>
<?php
$tileStatus = (string) ($check['status'] ?? 'down');
$tileClass = in_array($tileStatus, ['ok', 'warn', 'down'], true) ? $tileStatus : 'down';
?>
<div class="tile tile--<?= e($tileClass) ?>">
<strong><?= e((string) $check['name']) ?></strong>
<div class="status"><?= e($tileClass) ?></div>
<?php if (!empty($check['message'])): ?>
<p><?= e((string) $check['message']) ?></p>
<?php endif; ?>
<div class="meta">
<?php
$meta = [];
if ($check['response_ms'] !== null) {
$meta[] = (int) $check['response_ms'] . ' ms';
}
if ($check['ssl_days_remaining'] !== null) {
$meta[] = 'SSL ' . (int) $check['ssl_days_remaining'] . 'd';
}
echo e($meta !== [] ? implode(' · ', $meta) : '—');
?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php endif; ?>
<footer>
<?php if ($status['updated_at'] !== null): ?>
Last pull: <?= e(formatCheckedAt($status['updated_at'])) ?>
<?php if ($status['duration_ms'] !== null): ?>
· <?= e((string) $status['duration_ms']) ?> ms run
<?php endif; ?>
<?php else: ?>
No pull timestamp
<?php endif; ?>
<br>© <?= $jahr ?> 0012.de · ops page · noindex
</footer>
</div>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
$root = dirname(dirname(__DIR__));
$configDir = is_dir($root . '/private/config')
? $root . '/private/config'
: $root . '/config';
require_once $configDir . '/status-view.php';
$status = formatPublicStatus(loadStatusSnapshot());
header('Content-Type: application/json; charset=utf-8');
header('X-Robots-Tag: noindex');
header('Cache-Control: no-store');
echo json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
+5
View File
@@ -16,8 +16,13 @@ SERVER_KEYS = [
"SMTP_PASSWORD", "SMTP_PASSWORD",
"SMTP_FROM", "SMTP_FROM",
"ALERT_EMAIL", "ALERT_EMAIL",
"TARGETS_REVIEW_INTERVAL_DAYS",
"MONITOR_INGEST_TOKEN", "MONITOR_INGEST_TOKEN",
"MONITOR_INGEST_URL", "MONITOR_INGEST_URL",
"BOKA_IP",
"BOKA_PUSH_STALE_MINUTES",
"BOKA_DISK_WARN_PERCENT",
"BOKA_BACKUP_STALE_HOURS",
"TELEGRAM_BOT_TOKEN", "TELEGRAM_BOT_TOKEN",
"TELEGRAM_CHAT_ID", "TELEGRAM_CHAT_ID",
] ]
+5
View File
@@ -16,8 +16,13 @@ SMTP_FROM=monitor@0012.de
ALERT_EMAIL= ALERT_EMAIL=
# Monitoring # Monitoring
TARGETS_REVIEW_INTERVAL_DAYS=90
MONITOR_INGEST_TOKEN= MONITOR_INGEST_TOKEN=
MONITOR_INGEST_URL=https://0012.de/ingest.php MONITOR_INGEST_URL=https://0012.de/ingest.php
BOKA_IP=89.163.231.180
BOKA_PUSH_STALE_MINUTES=15
BOKA_DISK_WARN_PERCENT=85
BOKA_BACKUP_STALE_HOURS=25
# Optional # Optional
TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_TOKEN=
+4 -3
View File
@@ -38,8 +38,8 @@ Relay von **boka** → ausgelagert nach [`11-boka-smtp-relay.md`](11-boka-smtp-r
- [x] Port 587 + STARTTLS getestet (`php scripts/test-mail.php` → OK) - [x] Port 587 + STARTTLS getestet (`php scripts/test-mail.php` → OK)
- [x] SPF gesetzt (Hoster-DNS) - [x] SPF gesetzt (Hoster-DNS)
- [x] DKIM in Plesk aktiviert - [x] DKIM in Plesk aktiviert
- [ ] DKIM in **öffentlicher DNS** — fehlt; Hoster per Ticket (kein DNS-Zugang im Paket) - [ ] DKIM in **öffentlicher DNS** — eingetragen im wint-Kundenbereich (2026-06-12), Propagation prüfen nach ~24 h
- [x] DMARC vorhanden (Hoster-DNS) - [x] DMARC `p=quarantine` (wint DNS, 2026-06-12)
### Technische Notizen ### Technische Notizen
@@ -47,7 +47,8 @@ Relay von **boka** → ausgelagert nach [`11-boka-smtp-relay.md`](11-boka-smtp-r
- **Test lokal:** `php scripts/test-mail.php` (liest `.env`) - **Test lokal:** `php scripts/test-mail.php` (liest `.env`)
- **Test vom Webspace:** `mail-test.php?token=…` (liest `private/server.env`) - **Test vom Webspace:** `mail-test.php?token=…` (liest `private/server.env`)
- **Tuta/strenge Empfänger:** bis DKIM-DNS steht → `ALERT_EMAIL` Gmail o. ä. - **Tuta/strenge Empfänger:** bis DKIM-DNS steht → `ALERT_EMAIL` Gmail o. ä.
- **Kein DNS-Zugang:** DKIM-Records aus Plesk Mail-Einstellungen an wint-Support — siehe [`../HOSTING.md`](../HOSTING.md#dns-zugang-classic-10) - **Tuta-Blacklist (2026-06-12):** Ausgehende IP des 0012.de-Mailservers ist bei Tutanota gesperrt — Zustellung an `admin@schwenk.online` (und vermutlich andere Tuta-Adressen) schlägt mit `554 … Sender address rejected (blacklisted)` fehl. Vorerst akzeptiert; `ALERT_EMAIL` nicht auf Tuta setzen. Details: [`../HOSTING.md`](../HOSTING.md#bekannte-einschränkung-tutanota-blacklist)
- **Kein DNS in Plesk:** Einträge im [wint Kundenlogin](https://login.wint.global/clientarea.php) — siehe [`../HOSTING.md`](../HOSTING.md#dns-bearbeiten-wint-kundenbereich)
## `.env` (Beispiel) ## `.env` (Beispiel)
+29 -21
View File
@@ -1,6 +1,7 @@
# 03 — Monitoring Pull (Phase 1) # 03 — Monitoring Pull (Phase 1)
**Priorität:** high **Priorität:** high
**Status:** erledigt (2026-06-12)
**Abhängigkeiten:** 01, 02, 05 **Abhängigkeiten:** 01, 02, 05
**Siehe auch:** [`../MONITORING.md`](../MONITORING.md) **Siehe auch:** [`../MONITORING.md`](../MONITORING.md)
@@ -12,36 +13,43 @@
### Implementierung ### Implementierung
- [ ] `config/` — lädt `.env` (oder `config.php` außerhalb `httpdocs`) - [x] `config/` — lädt `.env` (oder `config.php` außerhalb `httpdocs`) — `bootstrap.php` → `private/server.env`
- [ ] `public/monitor.php` — CLI/Cron-tauglich, kein Browser-Output nötig - [x] `public/monitor.php` — CLI/Cron-tauglich, kein Browser-Output nötig
- [ ] URL-Liste aus MONITORING.md Mindestliste konfigurierbar (`config/targets.php`) - [x] URL-Liste aus MONITORING.md Mindestliste konfigurierbar (`config/targets.php`)
- [ ] Pro URL: HTTP-Status, Timeout ~10s, optional erwarteter Code (200, 301, 401) - [x] Pro URL: HTTP-Status, Timeout ~10s, optional erwarteter Code (200, 301, 401)
- [ ] SSL-Ablauf: Warnung wenn &lt; 14 Tage - [x] SSL-Ablauf: Warnung wenn &lt; 14 Tage (`warn` in JSON, kein Mail-Alarm)
- [ ] Retry: bei Fehler 2 Min warten, zweiter Versuch — Alarm nur wenn beide scheitern - [x] Retry: bei Fehler 2 Min warten, zweiter Versuch — Alarm nur wenn beide scheitern
- [ ] Ergebnisse nach `data/last-checks.json` schreiben (für Landing Page) - [x] Ergebnisse nach `data/last-checks.json` schreiben (für Landing Page) — `private/data/` auf Webspace
- [ ] Bei Alarm: E-Mail an `ALERT_EMAIL` via SMTP - [x] Bei Alarm: E-Mail an `ALERT_EMAIL` via SMTP
- [ ] Optional: Telegram wenn `TELEGRAM_*` gesetzt - [x] Review-Reminder (90 Tage, `last_reviewed_at` + Hash-Auto-Reset) — Plan-Ergänzung, in `monitor-lib.php`
- [ ] Optional: Telegram wenn `TELEGRAM_*` gesetzt — bewusst Phase 2
### Mindest-URLs (Start) ### Mindest-URLs (Start)
- [ ] `https://schwenk.online` - [x] `https://schwenk.online`
- [ ] `https://gitea.schwenk.online` - [x] `https://gitea.schwenk.online`
- [ ] `https://pics.schwenk.online` - [x] `https://pics.schwenk.online`
- [ ] `https://medis.schwenk.online` - [x] `https://medis.schwenk.online`
- [ ] `https://portainer.schwenk.online` - [x] `https://portainer.schwenk.online` — `expect` `[200, 401]` (liefert aktuell 200)
- [ ] Eine `bsc-gp.de`-Subdomain (z. B. score) - [x] Eine `bsc-gp.de`-Subdomain (z. B. score) — `https://score.bsc-gp.de`
- [ ] `https://vfb.red` - [x] `https://vfb.red`
### Sicherheit ### Sicherheit
- [ ] `data/` nicht öffentlich (`.htaccess` oder außerhalb Document Root) - [x] `data/` nicht öffentlich (`.htaccess` oder außerhalb Document Root) — `private/data/`
- [ ] Keine Secrets in `last-checks.json` - [x] Keine Secrets in `last-checks.json`
## Akzeptanzkriterien ## Akzeptanzkriterien
- Manueller Aufruf / „Jetzt ausführen“ in Plesk läuft fehlerfrei - [x] Manueller Aufruf / „Jetzt ausführen“ in Plesk läuft fehlerfrei
- Simulierter Down (falsche URL) löst Mail nach Retry aus - [x] Simulierter Down (falsche URL) löst Mail nach Retry aus
- `last-checks.json` aktualisiert sich - [x] `last-checks.json` aktualisiert sich
## Notizen
- Plesk-Cron: `httpdocs/monitor.php`, `*/10 * * * *`, „Nur Fehler“ — erledigt (Todo 05, 2026-06-12)
- Browser-Aufruf `https://0012.de/monitor.php` → leere Seite (HTTP 200) ist korrekt
- SSL-`warn` auf boka-Hosts nach LE short→long-lived-Wechsel: bekannt, kein Handlungsbedarf auf 0012.de
## Geschätzter Aufwand ## Geschätzter Aufwand
+22 -16
View File
@@ -1,6 +1,7 @@
# 04 — Monitoring Push (boka → Webspace) # 04 — Monitoring Push (boka → Webspace)
**Priorität:** medium **Priorität:** medium
**Status:** erledigt (2026-06-12)
**Abhängigkeiten:** 01, 03 (ingest + stale-Check) **Abhängigkeiten:** 01, 03 (ingest + stale-Check)
**Siehe auch:** [`../MONITORING.md`](../MONITORING.md) Ebene 2 **Siehe auch:** [`../MONITORING.md`](../MONITORING.md) Ebene 2
@@ -12,24 +13,24 @@ Interne Metriken von boka an 0012.de pushen; Alarm wenn kein Push seit 15 Min.
### Webspace ### Webspace
- [ ] `public/ingest.php` — POST JSON, Token-Prüfung (`MONITOR_INGEST_TOKEN`) - [x] `public/ingest.php` — POST JSON, Token-Prüfung (`MONITOR_INGEST_TOKEN`) + IP-Whitelist (`BOKA_IP`)
- [ ] Payload speichern: `data/boka-status.json` + Timestamp - [x] Payload speichern: `private/data/boka-status.json` + Timestamp
- [ ] In `monitor.php`: wenn `last_push` &gt; 15 Min → Alarm „boka silent“ - [x] In `monitor.php`: wenn `last_push` &gt; 15 Min → Alarm „boka silent“
- [ ] Schwellen optional: `disk_percent > 85`, `containers_unhealthy > 0` - [x] Schwellen: `disk_percent > 85`, `containers_unhealthy > 0`
- [ ] Backup-Heartbeat-Feld `backup_last_success` — Alarm wenn &gt; 25h alt (wenn Backups existieren) - [x] Backup-Heartbeat-Feld `backup_last_success` — Alarm wenn &gt; 25h alt (nur wenn Feld gesetzt)
### boka ### boka
- [ ] Skript `~/server/scripts/push-status.sh` (oder Pfad laut boka-Repo) - [x] Skript `~/server/scripts/push-status.sh`
- [ ] Sammelt: load, disk %, mem, `docker ps` running/unhealthy - [x] Sammelt: load, disk %, mem, `docker ps` running/unhealthy
- [ ] POST an `MONITOR_INGEST_URL` mit curl - [x] POST an `INGEST_URL` mit curl
- [ ] Crontab: `*/5 * * * *` - [x] Crontab: `*/5 * * * *`
- [ ] Nach erfolgreichem Backup: Push mit aktualisiertem `backup_last_success` - [ ] Nach erfolgreichem Backup: Push mit aktualisiertem `backup_last_success` (wenn Backups existieren)
### Token ### Token
- [ ] Starkes Token generieren (`openssl rand -hex 32`) - [x] Starkes Token in `.env` / `server.env`
- [ ] Identisch in boka (env/cron) und 0012 `.env` - [x] Identisch in boka `~/server/config/push.env` (`PUSH_TOKEN`)
## Beispiel-Payload ## Beispiel-Payload
@@ -47,9 +48,14 @@ Interne Metriken von boka an 0012.de pushen; Alarm wenn kein Push seit 15 Min.
## Akzeptanzkriterien ## Akzeptanzkriterien
- Push von boka aktualisiert JSON auf Webspace - [x] Push von boka aktualisiert JSON auf Webspace (HTTP 200, 2026-06-12)
- Absichtlich gestoppter Cron auf boka → Mail nach 15+ Min - [x] Stale-Alarm — Logik in `monitor-lib.php`; Live-Test bewusst übersprungen
## Hinweis ## Notizen
Skript auf boka kann in **boka-Repo** liegen; hier nur `ingest.php` + Doku-Link. - Skript liegt im **boka-Repo** (`~/server`); Webspace-Repo: `ingest.php` + `monitor-lib.php`
- SSH-Sync: `frank@frank-schwenk.de` — push.env, Crontab, `webspace/`-Kopien
- Bugfix in `push-status.sh`: Env-Vars müssen **vor** `python3` stehen (nicht danach)
- boka-Agent: verifiziert (grünes Licht)
- Gitea-Remote für `~/server`: später (User)
- Backup-Hook in `push-status.sh`: offen bis Backups existieren
+19 -12
View File
@@ -1,6 +1,7 @@
# 05 — Plesk Cron (Geplante Aufgaben) # 05 — Plesk Cron (Geplante Aufgaben)
**Priorität:** high **Priorität:** high
**Status:** erledigt (2026-06-12)
**Abhängigkeiten:** 01, 03 (Skript muss existieren) **Abhängigkeiten:** 01, 03 (Skript muss existieren)
**Referenz:** Plesk → Websites & Domains → Geplante Aufgaben **Referenz:** Plesk → Websites & Domains → Geplante Aufgaben
@@ -10,16 +11,17 @@ Monitoring automatisch alle 5–10 Minuten ausführen.
## Checkliste ## Checkliste
- [ ] Aufgabe anlegen: **PHP-Skript ausführen** - [x] Aufgabe anlegen: **PHP-Skript ausführen**
- [ ] Skriptpfad: `httpdocs/monitor.php` (Pfad nach Deployment verifizieren) - [x] Skriptpfad: `httpdocs/monitor.php` (Pfad nach Deployment verifizieren)
- [ ] PHP-Version: höchste stabile (z. B. 8.5.7) - [x] PHP-Version: höchste stabile (z. B. 8.5.x)
- [ ] **Aktiv** angehakt - [x] **Aktiv** angehakt
- [ ] Ausführen: **Cron-Stil** - [x] Ausführen: **Cron-Stil**
- [ ] Expression: `*/10 * * * *` (Pull-Checks alle 10 Min) - [x] Expression: `*/10 * * * *` (Pull-Checks alle 10 Min)
- [ ] Beschreibung: z. B. „0012 external monitoring pull“ - [x] Beschreibung: z. B. „0012 external monitoring pull“
- [ ] Benachrichtigen: **Nur Fehler** (siehe unten) - [x] Benachrichtigen: **Nur Fehler** (siehe unten)
- [ ] **Jetzt ausführen** — Exit-Code / Log prüfen - [x] **Jetzt ausführen** — Exit-Code / Log prüfen
- [ ] Optional zweite Aufgabe für leichtgewichtigen Heartbeat-Only-Check (`*/5`) — nur wenn von monitor.php getrennt
Optional zweite Aufgabe für Heartbeat-only (`*/5`): ausgelagert nach [`13-optional-plesk-cron-heartbeat.md`](13-optional-plesk-cron-heartbeat.md) — nicht nötig, Stale-Check läuft im gleichen `monitor.php`-Lauf.
## Benachrichtigen: „Nur Fehler“ ## Benachrichtigen: „Nur Fehler“
@@ -45,8 +47,13 @@ Plesk nutzt System-Timezone des Servers.
## Akzeptanzkriterien ## Akzeptanzkriterien
- Aufgabe läuft scheduled ohne manuellen Klick - [x] Aufgabe läuft scheduled ohne manuellen Klick (Access-Log: `curl/8.20.0` → `GET /monitor.php`)
- `data/last-checks.json` Timestamp bewegt sich - [x] `data/last-checks.json` Timestamp bewegt sich (verifiziert in Todo 03)
## Notizen
- Lauf im Access-Log als HTTP-GET sichtbar (Plesk ruft URL per curl auf). Follow-up: CLI + Token — [`12-monitor-php-token-check.md`](12-monitor-php-token-check.md)
- Ist-Konfiguration dokumentiert in [`../HOSTING.md`](../HOSTING.md#geplante-aufgaben-cron)
## Fehlerbehebung ## Fehlerbehebung
+16 -11
View File
@@ -1,30 +1,35 @@
# 06 — Landing Page 0012.de (Status Beacon) # 06 — Status-Dashboard unter `/monitor/`
**Priorität:** medium **Priorität:** medium
**Status:** erledigt (2026-06-12)
**Abhängigkeiten:** 03 (liefert `last-checks.json`) **Abhängigkeiten:** 03 (liefert `last-checks.json`)
**Konzept:** [`../SITE.md`](../SITE.md) **Konzept:** [`../SITE.md`](../SITE.md)
## Ziel ## Ziel
Öffentliche, minimale Startseite — Live-Status aus Monitoring-Daten, kein Leer-Domain-Eindruck. Öffentliche, minimale Statusseite unter `/monitor/` — Live-Status aus Monitoring-Daten. Root (`index.php`) bleibt Baustellen-Platzhalter für eine spätere eigenständige Website.
## Checkliste ## Checkliste
- [ ] `public/index.php` — liest `data/last-checks.json` + `data/boka-status.json` - [x] `public/monitor/index.php` — liest `private/data/last-checks.json` + `boka-status.json`
- [ ] Raster: ~12 Kacheln, Farbe nach Status (ok / warn / down) - [x] `config/status-view.php` — gemeinsame Aufbereitung für HTML + JSON
- [ ] boka-Heartbeat-Zeile („last push X min ago“) - [x] Raster: Kacheln, Farbe nach Status (ok / warn / down)
- [ ] Dark-Theme CSS inline oder eine `assets/style.css` - [x] boka-Heartbeat-Zeile („last push X min ago“)
- [ ] Graceful fallback wenn JSON fehlt („Monitoring not initialized“) - [x] Dark-Theme CSS inline
- [ ] Keine sensiblen Daten (Tokens, interne Fehlerdetails) - [x] Graceful fallback wenn JSON fehlt („Monitoring not initialized“)
- [ ] Optional: `public/status.json` read-only API - [x] Keine sensiblen Daten (Tokens, IPs, interne Test-Targets `_…`)
- [ ] Meta: `noindex` erwägen (Ops-Seite, keine SEO) - [x] `public/monitor/status.php` read-only JSON-API (+ `.htaccess` → `status.json`)
- [x] Meta: `noindex` (Ops-Seite, keine SEO)
## Akzeptanzkriterien ## Akzeptanzkriterien
- `https://0012.de/` zeigt aktuellen Status nach Cron-Lauf - `https://0012.de/monitor/` zeigt aktuellen Status nach Cron-Lauf
- `https://0012.de/` bleibt unveränderte Baustellenseite
- Seite lädt ohne JavaScript - Seite lädt ohne JavaScript
- Mobile lesbar - Mobile lesbar
## Nicht im Scope ## Nicht im Scope
- Monitoring auf `public/index.php`
- Blog, CMS, WordPress - Blog, CMS, WordPress
- Link von Root zur Ops-Seite
+44
View File
@@ -0,0 +1,44 @@
# 12 — monitor.php: Token-Check (HTTP)
**Priorität:** medium
**Abhängigkeiten:** 03 (monitor.php), 05 (Plesk-Cron)
**Referenz:** [`../public/monitor.php`](../public/monitor.php), [`../public/mail-test.php`](../public/mail-test.php)
## Problem
`monitor.php` ist per **GET** öffentlich erreichbar (`https://0012.de/monitor.php`). Im Plesk-Access-Log tauchen neben dem Cron-`curl` auch fremde Clients auf (z. B. `got` von AWS-IPs) — jeder kann Checks auslösen, JSON/Status lesen und Last erzeugen.
Plesk-Cron kann das Skript **per CLI** ausführen (`php monitor.php`) — dort ist kein HTTP nötig.
## Ziel
HTTP-Aufrufe nur mit gültigem Token; CLI/Cron unverändert.
## Checkliste
- [ ] In `monitor.php` (oder `monitor-lib.php`): bei Web-SAPI (`php_sapi_name() !== 'cli'`) Token prüfen
- Query-Parameter `token` oder Header `X-Monitor-Token`
- Secret: bestehendes `MONITOR_INGEST_TOKEN` (wie `mail-test.php`) oder separates `MONITOR_RUN_TOKEN`
- Vergleich mit `hash_equals()`
- Bei Fehler: `403` + kurzer JSON/Text, **kein** Monitoring-Lauf
- [ ] Plesk-Cron auf **PHP-Skript (CLI)** umstellen, falls aktuell URL/Curl — siehe [`05-plesk-cron.md`](05-plesk-cron.md)
- [ ] Optional: `.htaccess` / Plesk „Verzeichnis schützen“ nur als Zusatz, nicht als einzige Absicherung
- [ ] Deploy + manueller Test:
- `GET /monitor.php` ohne Token → 403
- `GET /monitor.php?token=…` → Lauf OK
- `php monitor.php` lokal/auf Server → Lauf OK
- [ ] Kurz in [`../MONITORING.md`](../MONITORING.md) oder [`05-plesk-cron.md`](05-plesk-cron.md) dokumentieren
## Nicht im Scope
- Rate-Limit / IP-Whitelist (optional später)
- Absicherung von `index.php` (öffentliche Statusseite bleibt bewusst offen)
## Akzeptanzkriterien
- Fremde GET-Requests ohne Token lösen keinen Monitor-Lauf aus
- Geplanter Plesk-Cron (CLI) läuft weiter zuverlässig
## Referenz-Implementierung
Vorbild Token-Check: [`../public/mail-test.php`](../public/mail-test.php) (Zeilen 15–22).
+33
View File
@@ -0,0 +1,33 @@
# 13 — Optional: Plesk Cron Heartbeat (`*/5`)
**Priorität:** backlog (optional)
**Status:** nicht umgesetzt — bewusst weggelassen
**Abhängigkeiten:** 05 (Haupt-Cron), 04 (Push ingest)
**Referenz:** [`05-plesk-cron.md`](05-plesk-cron.md), [`../HOSTING.md`](../HOSTING.md)
## Ziel
Optional eine **zweite** geplante Aufgabe in Plesk, die nur die Push-Stale-Prüfung (boka silent) alle 5 Min ausführt — getrennt vom vollen Pull-Check in `monitor.php`.
## Ausgangslage
- Haupt-Cron (Todo 05): `*/10 * * * *` → `httpdocs/monitor.php` (HTTPS/SSL + boka-Stale/Schwellen)
- boka pusht alle 5 Min; Stale-Schwelle in `monitor-lib.php`: 15 Min
- **Aktuell ausreichend:** ein Lauf alle 10 Min reicht für Stale-Erkennung
## Wann sinnvoll
- Pull-Checks dauern lang (>5 Min) und sollen seltener laufen, Stale-Alarm aber früher kommen
- Eigenes leichtgewichtiges Skript (nur `checkBokaPushStatus()`, kein HTTPS-Roundtrip)
## Checkliste (falls umgesetzt)
- [ ] Entscheidung: separates Skript (`monitor-heartbeat.php`) vs. Parameter an `monitor.php`
- [ ] Plesk-Aufgabe: Cron-Stil `*/5 * * * *`, PHP-Skript, „Nur Fehler“
- [ ] Kein Doppel-Alarm: Retry-/Cooldown-Logik mit Haupt-Monitor abstimmen
- [ ] [`../HOSTING.md`](../HOSTING.md) und [`05-plesk-cron.md`](05-plesk-cron.md) aktualisieren
## Akzeptanzkriterien
- Stale-Alarm spätestens ~5 Min nach letztem Push (statt bis zu 10 Min + Pull-Laufzeit)
- Keine doppelten Mails bei gleichzeitigem Pull- und Heartbeat-Lauf
+9 -5
View File
@@ -7,26 +7,30 @@ Nummerierte Todos für den 0012.de-Webspace. Abhängigkeiten beachten.
| 01 | [01-bootstrap-env-deploy.md](01-bootstrap-env-deploy.md) | **high** | `.env`, Repo-Struktur, erstes FTP-Deployment | | 01 | [01-bootstrap-env-deploy.md](01-bootstrap-env-deploy.md) | **high** | `.env`, Repo-Struktur, erstes FTP-Deployment |
| 02 | [02-email-smtp.md](02-email-smtp.md) | **high** | Postfächer, SMTP für Alerts (0012.de) | | 02 | [02-email-smtp.md](02-email-smtp.md) | **high** | Postfächer, SMTP für Alerts (0012.de) |
| 03 | [03-monitoring-pull-phase1.md](03-monitoring-pull-phase1.md) | **high** | `monitor.php`, HTTPS/SSL-Checks, Alarm | | 03 | [03-monitoring-pull-phase1.md](03-monitoring-pull-phase1.md) | **high** | `monitor.php`, HTTPS/SSL-Checks, Alarm |
| 04 | [04-monitoring-push-boka.md](04-monitoring-push-boka.md) | **medium** | Push von boka → `ingest.php` | | 04 | [04-monitoring-push-boka.md](04-monitoring-push-boka.md) | **medium** | Push von boka → `ingest.php` — erledigt |
| 05 | [05-plesk-cron.md](05-plesk-cron.md) | **high** | Geplante Aufgaben in Plesk | | 05 | [05-plesk-cron.md](05-plesk-cron.md) | **high** | Geplante Aufgaben in Plesk — erledigt |
| 06 | [06-site-0012-landing.md](06-site-0012-landing.md) | **medium** | Status-Beacon Landing Page | | 06 | [06-site-0012-landing.md](06-site-0012-landing.md) | **medium** | Status-Dashboard unter `/monitor/` — erledigt |
| 07 | [07-mail-domains-external.md](07-mail-domains-external.md) | **medium** | MX für Domains ohne Mailserver | | 07 | [07-mail-domains-external.md](07-mail-domains-external.md) | **medium** | MX für Domains ohne Mailserver |
| 08 | [08-monitoring-phase2.md](08-monitoring-phase2.md) | **medium** | API-Health, Latenz, Backup-Heartbeat | | 08 | [08-monitoring-phase2.md](08-monitoring-phase2.md) | **medium** | API-Health, Latenz, Backup-Heartbeat |
| 09 | [09-monitoring-phase3.md](09-monitoring-phase3.md) | **low** | Rest-Domains, Dashboard, Header-Checks | | 09 | [09-monitoring-phase3.md](09-monitoring-phase3.md) | **low** | Rest-Domains, Dashboard, Header-Checks |
| 10 | [10-optional-plesk-git-composer.md](10-optional-plesk-git-composer.md) | backlog | Plesk Git / Composer evaluieren | | 10 | [10-optional-plesk-git-composer.md](10-optional-plesk-git-composer.md) | backlog | Plesk Git / Composer evaluieren |
| 11 | [11-boka-smtp-relay.md](11-boka-smtp-relay.md) | **medium** | boka-Apps über 0012.de-SMTP | | 11 | [11-boka-smtp-relay.md](11-boka-smtp-relay.md) | **medium** | boka-Apps über 0012.de-SMTP |
| 12 | [12-monitor-php-token-check.md](12-monitor-php-token-check.md) | **medium** | HTTP-Zugriff auf `monitor.php` per Token |
| 13 | [13-optional-plesk-cron-heartbeat.md](13-optional-plesk-cron-heartbeat.md) | backlog | Optional: separater Heartbeat-Cron `*/5` |
## Backlog (ohne Priorität) ## Backlog (ohne Priorität)
Todo **10** — Plesk Git-Deploy und Composer; dokumentiert in [`../PLESK.md`](../PLESK.md), kein Blocker für 02–09. Todo **10** — Plesk Git-Deploy und Composer; dokumentiert in [`../PLESK.md`](../PLESK.md), kein Blocker für 02–09.
Todo **13** — optionaler Heartbeat-Cron in Plesk; nicht nötig solange `monitor.php` alle 10 Min läuft.
Todo **11** — boka SMTP-Relay; abhängig von erledigtem **02**, kein Blocker für **03**. Todo **11** — boka SMTP-Relay; abhängig von erledigtem **02**, kein Blocker für **03**.
## Empfohlene Reihenfolge ## Empfohlene Reihenfolge
``` ```
01 → 02 → 05 → 03 → 06 01 → 02 → 05 ✓ → 03 ✓ → 06 ✓
↘ 04 (parallel zu 03, braucht ingest.php) ↘ 04 ✓ (Push ingest.php)
07 (wenn Domains feststehen) 07 (wenn Domains feststehen)
08 → 09 08 → 09
``` ```