Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f487b35b59 | |||
| dab32cc86b | |||
| 85ab829805 | |||
| 226bbe1bf7 | |||
| 9b18b11f32 | |||
| 2440fbf974 | |||
| d07ba3c5c5 | |||
| e5dca0e9c0 | |||
| c007d5ebdc | |||
| 69f183d356 |
@@ -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=
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ server.env
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
/data/
|
||||||
|
|||||||
+35
@@ -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
@@ -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
@@ -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 > 85 %, unhealthy > 0, Backup > 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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}",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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);
|
||||||
@@ -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));
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
RewriteEngine On
|
||||||
|
RewriteRule ^status\.json$ status.php [L]
|
||||||
@@ -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>
|
||||||
@@ -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";
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 < 14 Tage
|
- [x] SSL-Ablauf: Warnung wenn < 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
|
||||||
|
|
||||||
|
|||||||
@@ -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` > 15 Min → Alarm „boka silent“
|
- [x] In `monitor.php`: wenn `last_push` > 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 > 25h alt (wenn Backups existieren)
|
- [x] Backup-Heartbeat-Feld `backup_last_success` — Alarm wenn > 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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user