1 Commits

Author SHA1 Message Date
renovate f59243027c chore(deps): update minor-and-patch-updates 2026-06-09 16:20:18 +00:00
14 changed files with 26 additions and 540 deletions
-11
View File
@@ -34,17 +34,6 @@ services:
container_name: immich_machine_learning container_name: immich_machine_learning
image: ghcr.io/immich-app/immich-machine-learning:release@sha256:a2501141440f10516d329fdfba2c68082e19eb9ba6016c061ac80d23beadf7f3 image: ghcr.io/immich-app/immich-machine-learning:release@sha256:a2501141440f10516d329fdfba2c68082e19eb9ba6016c061ac80d23beadf7f3
restart: unless-stopped restart: unless-stopped
environment:
# Workaround fuer gunicorn-25.1.0-Control-Socket-Bug: der Worker haengt
# nach "Control socket listening at /usr/src/gunicorn.ctl" und erreicht
# nie "Application startup complete" -> Container bleibt dauerhaft
# unhealthy, ML (Gesichtserkennung/CLIP/Smart-Search) ist tot.
# --no-control-socket deaktiviert das fehlerhafte Feature. immich-ml
# startet gunicorn als Subprozess, der GUNICORN_CMD_ARGS aus der Env
# liest und anhaengt. Bestaetigte Upstream-Regression seit Immich 2.6
# (immich#27228, gunicorn#3510). Re-check: bei Immich-Update, das
# gunicorn auf >25.1.0/<25.1.0 mit Fix bringt, wieder entfernen.
GUNICORN_CMD_ARGS: "--no-control-socket"
volumes: volumes:
- model-cache:/cache - model-cache:/cache
networks: networks:
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
n8n: n8n:
image: docker.n8n.io/n8nio/n8n:2.26.2@sha256:61ba01bc5e39304bbc928c9dbecd938c3a5cc1331b68affba6a34d0f654c43d9 image: docker.n8n.io/n8nio/n8n:2.26.1@sha256:1e6a06e20e78ca62e39ecad02d42ffbef4e36f23ea0b18938b8c65b8c58a9fa0
container_name: n8n container_name: n8n
restart: unless-stopped restart: unless-stopped
+1 -9
View File
@@ -93,15 +93,7 @@ Script: bash /mnt/user/services/homelab-infra/ops/renovate/run-renovate.sh
| Schedule | `extends ["schedule:weekly"]` | Renovate-Engine prueft, aber PRs/Updates folgen Wochen-Profilen wo sinnvoll | | Schedule | `extends ["schedule:weekly"]` | Renovate-Engine prueft, aber PRs/Updates folgen Wochen-Profilen wo sinnvoll |
| Dependency Dashboard | aktiv | Gitea-Issue, die alle ausstehenden Updates auflistet | | Dependency Dashboard | aktiv | Gitea-Issue, die alle ausstehenden Updates auflistet |
| Onboarding-PR | `onboarding: false` | Keine `Configure Renovate`-Onboarding-PR; wir nutzen die Repo-`renovate.json` direkt | | Onboarding-PR | `onboarding: false` | Keine `Configure Renovate`-Onboarding-PR; wir nutzen die Repo-`renovate.json` direkt |
| Ignore-Pfade | `_archive`, `ops/grafana-influxdb`, `ops/loki`, `ops/komodo` | Renovate scant alte/abgeloeste Stacks nicht; `ops/komodo` ist bewusst raus (siehe unten) | | Ignore-Pfade | `_archive`, `ops/grafana-influxdb`, `ops/loki` | Renovate scant alte/abgeloeste Stacks nicht |
## Ausnahme: komodo-Stack ist inline-verwaltet, nicht git-deployed
Der `komodo`-Stack (Komodo-Core/Mongo/Periphery, Datei `ops/komodo/docker-compose.yml`) wird **nicht aus diesem Repo deployed**. In Komodo ist der Stack als **inline `file_contents`** (UI-defined) gespeichert (`repo` leer, `files_on_host=false`, `has_inline_file_contents=true`) und hat bewusst `webhook_enabled=false`, damit Komodo sich nicht selbst per Webhook recreated (Bootstrap-/Henne-Ei-Fall).
Konsequenz: Ein Renovate-PR auf `ops/komodo/docker-compose.yml` wirkt zur Laufzeit **nicht** (Komodo deployt aus seiner Inline-Definition) und erzeugt nur Git↔Komodo-Scheinsicherheit. Deshalb steht `ops/komodo/**` in `ignorePaths`. Die Repo-Datei bleibt als Doku/Spiegel und traegt den aktuell real laufenden Digest.
Befund-Datum 2026-06-10: Renovate-PR #13 (mongo-8.0.23 Digest-Refresh) wurde gemergt, wirkte aber nicht; der Digest wurde im Repo auf den laufenden Stand zurueckgesetzt und der Pfad ausgenommen. Echte Updates des komodo-Stacks laufen bis auf Weiteres manuell ueber Komodo (Inline-Compose anpassen) bzw. spaeter via Migration auf git-backed (eigener Aenderungsblock).
## Aktueller Betriebsstand ## Aktueller Betriebsstand
-1
View File
@@ -35,7 +35,6 @@ Details gilt immer die betroffene Compose-Datei oder das jeweilige Runbook.
| `docs/GITOPS_DRIFT_RUNBOOK.md` | Git/Gitea/Komodo/Docker/Host-Drift | | `docs/GITOPS_DRIFT_RUNBOOK.md` | Git/Gitea/Komodo/Docker/Host-Drift |
| `docs/AUDIT_2026-05-25_TODO.md` | aktuelle Restliste | | `docs/AUDIT_2026-05-25_TODO.md` | aktuelle Restliste |
| `docs/DR_WORKSTATION_SETUP.md` | Schritt-fuer-Schritt-Runbook fuer den DR-Gaming-PC (WSL2 + Borg-Client + SSH-Keys) | | `docs/DR_WORKSTATION_SETUP.md` | Schritt-fuer-Schritt-Runbook fuer den DR-Gaming-PC (WSL2 + Borg-Client + SSH-Keys) |
| `docs/runbooks/komodo-bulk-deploy-dns.md` | Bulk-Deploy-Pulls scheitern an DNS, wenn AdGuard im selben Batch recreated wird |
## Wichtige Skripte ## Wichtige Skripte
-228
View File
@@ -1,228 +0,0 @@
# Homelab-Optimierung — Assessment 2026-06-10
Read-only-Analyse des Repos (Stand `master`, lokale Arbeitskopie 2026-06-10).
Keine produktiven Änderungen durchgeführt. Alle Empfehlungen sind Vorschläge
mit Rollback-Plan; nichts wurde deployed.
## Executive Summary
Das KalliLab-CORE-Homelab ist für ein Ein-Host-Setup ungewöhnlich reif:
GitOps mit Gitea+Komodo, sauberes Netzmodell (frontend/backend/app-intern),
Authelia mit 2FA-Catch-all, belegte Restore-Drills für alle Tier-1/2-Dienste,
Off-site-Borg nach Hetzner, DR-Workstation-Kit, Monitoring mit Prometheus/
Loki/Grafana/Alertmanager→ntfy. Die Doku-Disziplin ist das eigentliche Asset.
Die größten realen Lücken liegen nicht in der Architektur, sondern in der
**Container-Betriebsebene**: 20 von 30 Stacks haben keinen Healthcheck, kein
einziger Container hat Memory-/CPU-Limits, und mehrere Images laufen auf
mutablen Tags (`release`, `latest`, `:2`), bei denen Renovate-Digest-Bumps
faktisch unkontrollierte Versionssprünge sind — am kritischsten bei Immich.
Dazu kommen zwei strukturelle Risiken: **AdGuard ist DNS-SPOF ohne Fallback**
(hat bereits einen Teil-Deploy-Ausfall verursacht) und **Borg-Backups sind
vom Host aus löschbar** (append-only bewusst abgelehnt, aber die kostenlose
Kompensation — Hetzner-Storage-Box-Snapshots — ist nicht aktiviert).
## Gesamtbewertung
| Bereich | Note | Begründung |
|---|---|---|
| Architektur | **sehr gut** | klares Netzmodell, dokumentierte Ausnahmen, ein Ingress, Compose-first konsequent |
| Netzwerk/DNS/Proxy | **gut, ein SPOF** | Traefik v3 labelbasiert sauber; AdGuard+Unbound ohne zweiten Resolver — bekannter Vorfall (Bulk-Deploy-DNS-Ausfall, `docs/runbooks/komodo-bulk-deploy-dns.md`) |
| Container-Betrieb | **mittel** | 10/30 Stacks mit Healthcheck, 0 Ressourcen-Limits, mutable Tags hinter Digests versteckt |
| Storage/Backups | **sehr gut** | Borg→Hetzner, Dumps, H:/-Nearline, Restore-Drills mit Reports belegt; offen: Backup-Löschschutz |
| Security/Secrets | **gut** | `_FILE`/Stack-ENV konsequent, 2FA-Catch-all, WAN nur 443/tcp; `no-new-privileges` nur in 10/30 Stacks trotz P8-Pflichtregel |
| Monitoring/Alerting | **gut** | Prometheus/Blackbox/Loki/ntfy-Kette steht; Monitoring-Stack selbst hat keine Healthchecks und überwacht sich nicht selbst |
| Automatisierung/IaC | **sehr gut** | Komodo-Webhooks, Renovate, Posture-Check, Critical-Events-Watcher; manuelle Sync-Ausnahmen (traefik/dynamic, Authelia-Config) sind dokumentiert, aber fehleranfällig |
| Ausfallsicherheit | **bewusst begrenzt** | Ein Host, keine USV (geparkt Q3/2026), kein WAN-Failover — als akzeptiertes Risiko dokumentiert, das ist legitim |
| Strom/Kosten | **keine Daten** | keine Verbrauchsmessung im Repo sichtbar — siehe offene Fragen |
## Top 10 Verbesserungen nach Mehrwert
### 1. Immich vom `release`-Tag auf Versions-Tag pinnen
- **Beobachtung:** `apps/immich/docker-compose.yml:4` nutzt `immich-server:release@sha256:...` (ebenso ML). Renovate aktualisiert Digests — beim `release`-Tag ist ein "Digest-Update" in Wahrheit ein Major-/Minor-Versionssprung, ohne dass es im PR-Titel sichtbar wird. Immich ist berüchtigt für Breaking Changes zwischen Minors.
- **Warum relevant:** Ein gemergter "harmloser" Digest-PR kann Immich unangekündigt auf eine inkompatible Version heben (DB-Migrationen, ML-Modell-Wechsel).
- **Änderung:** Tag auf die konkret laufende Version umstellen (z. B. `immich-server:v2.x.y@sha256:<aktueller Digest>`), gleiche Vorgehensweise wie bei Mealie/Paperless. Laufende Version ermitteln: `docker exec immich_server cat /usr/src/app/package.json | grep version` oder Immich-UI → Version.
- **Verifikation:** Renovate erzeugt danach Versions-PRs statt stiller Digest-PRs; `docker inspect immich_server --format '{{.Config.Image}}'` zeigt den Versionstag.
- **Rollback:** Commit revert; Digest bleibt identisch, kein Redeploy-Zwang.
- **Nebenwirkungen:** keine zur Laufzeit (Digest unverändert). | Nutzen: **hoch** | Risiko: niedrig | Aufwand: klein | sofort
- Gleiches Muster prüfen für: `komodo:2`, `ddns-updater:latest`, `scrutiny:latest-omnibus`, `glances:latest-full` sowie tag-lose digest-only Images (`mail-archiver`, `borg-ui`, `ntfy` — Version im Compose unsichtbar).
### 2. Hetzner-Storage-Box-Snapshots als Ransomware-/Fehlbedienungsschutz aktivieren
- **Beobachtung:** Borg `append-only` wurde am 2026-06-01 bewusst verworfen (forced-command brach Key-Auth). Damit kann jeder mit dem Borg-Key (Host, borg-ui-Container mit `/local/secrets`-Mount) Archive **löschen** — ein kompromittierter Host vernichtet auch das Off-site-Backup.
- **Warum relevant:** Das ist die einzige verbliebene Lücke in einer sonst sehr guten Backup-Kette.
- **Änderung:** In der Hetzner-Robot-Konsole automatische Snapshots der Storage Box aktivieren (z. B. täglich, 714 Tage Retention). Snapshots sind host-seitig nicht löschbar und im Storage-Box-Preis enthalten.
- **Verifikation:** Robot-Konsole zeigt Snapshot-Liste; nach 2 Tagen: zwei Snapshots vorhanden. Restore-Probe: einzelne Datei aus Snapshot über das Snapshot-Verzeichnis lesen.
- **Rollback:** Snapshots deaktivieren — rein additiv, keine Auswirkung auf Borg.
- **Nebenwirkungen:** Snapshots zählen ggf. anteilig aufs Quota (aktuell 65 GB / 1 TB — viel Luft). | Nutzen: **sehr hoch** | Risiko: niedrig | Aufwand: klein (<30 min) | sofort
### 3. DNS-Fallback gegen den AdGuard-SPOF
- **Beobachtung:** AdGuard ist einziger LAN-Resolver. Der dokumentierte Vorfall (Bulk-Deploy: AdGuard-Recreate → Host ohne DNS → Komodo-Pulls scheitern) ist genau dieses Muster; das Runbook behandelt nur das Symptom.
- **Warum relevant:** Jeder AdGuard-Ausfall (Update, OOM, Disk) nimmt LAN + Host-DNS gleichzeitig mit — auch die Reparaturfähigkeit (Image-Pulls!) hängt daran.
- **Änderung (gestuft):**
- a) Host-Ebene: zweiten Nameserver (z. B. `1.1.1.1`) in der Unraid-Netzwerkkonfig als Fallback hinter `192.168.178.58` eintragen. Damit kann der Host immer Images pullen.
- b) LAN-Ebene: in der FRITZ!Box als zweiten lokalen DNS die FRITZ!Box selbst (oder einen Public DNS) hinterlegen — bewusster Trade-off: bei AdGuard-Down kein Ad-Blocking statt kein Internet.
- **Verifikation:** `docker stop adguard` im Wartungsfenster → `nslookup gitea.com` auf dem Host funktioniert weiterhin; danach `docker start adguard`.
- **Rollback:** Nameserver-Eintrag entfernen.
- **Nebenwirkungen:** DNS-Anfragen können am Filter vorbeilaufen, solange AdGuard down ist (gewollt); Fallback-Resolver sieht dann Anfragen (Privacy-Abwägung). | Nutzen: **hoch** | Risiko: niedrig | Aufwand: klein | diese Woche
### 4. Healthchecks für die App-Stacks nachrüsten
- **Beobachtung:** Nur 10 von 30 Compose-Dateien definieren Healthchecks (traefik, gitea, vaultwarden, authelia, postgresql17, redis, komodo, bentopdf, glances, hermes). **Ohne:** Nextcloud (App+DB+Redis), Immich (alle 4), Paperless, Mealie, Mail-Archiver, n8n, AdGuard, Unbound und der komplette Monitoring-Stack (11 Services).
- **Warum relevant:** Ohne Healthcheck meldet Docker "Up", auch wenn die App hängt; der Critical-Events-Watcher sieht nur `die`/`oom`, keine Hänger. Prometheus-Blackbox prüft nur HTTP-Routen von außen.
- **Änderung:** Pro Stack einen minimalen Healthcheck ergänzen, priorisiert: Nextcloud (`curl -f http://localhost/status.php`), Paperless, Mealie, n8n, Unbound (`drill @127.0.0.1 cloudflare.com` bzw. `unbound-control status`), AdGuard. Stackweise deployen, nicht als Bulk (siehe DNS-Runbook!).
- **Verifikation:** `docker ps --format '{{.Names}} {{.Status}}'` zeigt `(healthy)`; cAdvisor/Glance zeigen Health-Status.
- **Rollback:** Healthcheck-Block entfernen, Redeploy — kein Datenrisiko.
- **Nebenwirkungen:** Falsch kalibrierte Checks (zu kurze `start_period`) können Flapping erzeugen; konservativ starten (`interval: 60s`, `retries: 5`). | Nutzen: **hoch** | Risiko: niedrig | Aufwand: mittel | diesen Monat
### 5. Memory-Limits für die größten Verbraucher
- **Beobachtung:** Kein einziger Service hat `mem_limit`/`deploy.resources`. Auf einem Ein-Host-System konkurrieren ~50 Container; ein Speicherleck (Immich-ML, Nextcloud-PHP, Loki) kann den Host-OOM-Killer auslösen, der dann beliebige Tier-1-Container trifft (Postgres!).
- **Warum relevant:** Der OOM-Killer wählt nach Score, nicht nach Wichtigkeit. Limits machen den Blast-Radius deterministisch: die fehlerhafte App stirbt, nicht die Datenbank.
- **Änderung:** Erst messen: `docker stats --no-stream --format '{{.Name}}\t{{.MemUsage}}'` über ein paar Tage (oder cAdvisor-Dashboard `container_memory_working_set_bytes`). Dann Limits = Peak × 1,5 für die Top-5-Verbraucher (typisch: immich-ml, nextcloud, paperless, plex, prometheus) setzen.
- **Verifikation:** `docker inspect <c> --format '{{.HostConfig.Memory}}'`; Grafana-Panel Memory vs. Limit; keine neuen `oom`-Events im Critical-Events-Log.
- **Rollback:** Limit-Zeilen entfernen, Redeploy.
- **Nebenwirkungen:** Zu knappe Limits OOM-killen die App selbst — deshalb messen statt raten, und Limits nur bei unkritischen Apps zuerst. | Nutzen: **hoch** | Risiko: mittel | Aufwand: mittel | diesen Monat
### 6. `no-new-privileges` flächendeckend gemäß P8
- **Beobachtung:** Architektur-Regel P8 verlangt `no-new-privileges:true` standardmäßig; gesetzt ist es nur in 10 von 30 Stacks. Es fehlt u. a. bei allen Apps mit WAN-Exposition (Nextcloud, Immich, Paperless, Mealie, ntfy, n8n).
- **Warum relevant:** Billige Defense-in-Depth gegen Privilege-Escalation nach App-Kompromittierung — genau bei den exponierten Diensten am wertvollsten. Aktuell: dokumentierte Regel ≠ gelebter Stand (Policy-Drift).
- **Änderung:** `security_opt: ["no-new-privileges:true"]` in die fehlenden Stacks, stackweise mit Smoke-Test. Vorsicht bei Images mit s6/sudo-Setup (LSIO-Images wie speedtest/code-server haben es teils schon — prüfen) und bei Plex (Host-Netz, zuerst testen).
- **Verifikation:** `docker inspect <c> --format '{{.HostConfig.SecurityOpt}}'`; Posture-/Policy-Check erweitern, damit Drift künftig alarmiert.
- **Rollback:** Zeile entfernen, Redeploy.
- **Nebenwirkungen:** Container, die intern setuid brauchen (selten: einige Init-Systeme), starten nicht — fällt im Smoke-Test sofort auf. | Nutzen: mittel | Risiko: niedrig | Aufwand: mittel | diesen Monat
### 7. traefik/dynamic-Sync automatisieren statt manuell
- **Beobachtung:** `traefik/dynamic/*` (middlewares, tls, dashboards, plex) wird laut dokumentierter Ausnahme **manuell** auf den Host synchronisiert. Das ist die klassische Quelle für "Repo sagt A, Host macht B" — besonders heikel, weil hier Auth-Middlewares definiert sind.
- **Warum relevant:** Ein vergessener Sync nach einer Middleware-Änderung kann unbemerkt eine Schutzschicht im Live-Zustand alt lassen; auffallen würde es erst beim Audit.
- **Änderung:** Kleines Sync-Skript analog `services/authelia-diff.sh`: Repo-Spiegel `/mnt/user/services/homelab-infra/traefik/dynamic/` per `rsync --checksum --dry-run` gegen `/mnt/user/appdata/traefik/dynamic/` diffen; Diff ≠ leer → ntfy-Warnung über den bestehenden Posture-Check. (Stufe 2 optional: automatisch syncen; erst nur alarmieren.)
- **Verifikation:** Testweise eine Whitespace-Änderung im Repo-Spiegel → Posture-Check meldet `traefik_dynamic_drift`.
- **Rollback:** Check aus dem Posture-Skript entfernen; rein lesend, kein Produktionsrisiko.
- **Nebenwirkungen:** keine (read-only Check). | Nutzen: mittel | Risiko: niedrig | Aufwand: klein | diese Woche
### 8. Watchdog für den Monitoring-Stack selbst (Dead-Man's-Switch)
- **Beobachtung:** Die Alert-Kette ist Prometheus → Alertmanager → Bridge → ntfy. Fällt ein Glied (oder der ganze Monitoring-Stack) aus, kommen schlicht **keine** Alerts mehr — Stille ist nicht von "alles gut" unterscheidbar. Kein Healthcheck im Monitoring-Compose.
- **Warum relevant:** Das Monitoring überwacht alles außer sich selbst.
- **Änderung:** Dauerhaft feuernde `Watchdog`-Alert-Rule in `monitoring/prometheus/alerts.yml` + externen Heartbeat-Empfänger: einfachste Variante ist healthchecks.io (free) — Alertmanager-Route schickt den Watchdog alle 5 min an die Heartbeat-URL; bleibt er aus, alarmiert healthchecks.io per Mail/Push von außen.
- **Verifikation:** `docker stop monitoring-prometheus` im Wartungsfenster → externe Benachrichtigung nach ~10 min; danach Start.
- **Rollback:** Rule + Route entfernen.
- **Nebenwirkungen:** neue (kleine) externe Abhängigkeit — in `docs/EXTERNAL_DEPENDENCIES.md` eintragen. | Nutzen: **hoch** | Risiko: niedrig | Aufwand: klein | diese Woche
### 9. Lokale Arbeitskopie sauber halten (GitOps-Hygiene)
- **Beobachtung:** Die lokale Arbeitskopie hat aktuell 6 modifizierte Dateien und 2 untracked Artefakte (u. a. `docs/KalliLab_CORE_Audit_2026-06-06.pdf`, `ops/h-drive-nearline/README.md`), die nicht committed sind. Bei "Gitea = Quelle der Wahrheit" ist eine dauerhaft schmutzige Arbeitskopie ein Drift-Risiko (Änderungen gehen bei Pull-Konflikten verloren oder landen versehentlich in fremden Commits).
- **Warum relevant:** Genau die Drift-Klasse, vor der `docs/GITOPS_DRIFT_RUNBOOK.md` warnt — nur auf Ebene 2 (lokaler Clone) statt Ebene 4.
- **Änderung:** Modifizierte Doku-Dateien reviewen und committen oder verwerfen; PDF entweder committen (wenn es Referenz ist) oder in `.gitignore`/außerhalb des Repos ablegen; `ops/h-drive-nearline/README.md` committen.
- **Verifikation:** `git status` zeigt clean tree (bis auf bewusste Arbeit).
- **Rollback:** n/a (Aufräumarbeit). | Nutzen: mittel | Risiko: niedrig | Aufwand: klein (<30 min) | sofort
### 10. Doku-Drift-Fixes (klein, aber Vertrauensbasis)
- **Beobachtung:** `HOMELAB_ARCHITECTURE_MASTER_V2.md` nennt "Redis-Caches auf `redis:7.4-alpine` vereinheitlicht" — real laufen alle auf `redis:8.8.0-alpine`. Ebenso "PostgreSQL 17"-Pfade/Servicenamen bei PG 18 (letzteres ist dokumentiert bewusst, ersteres nicht).
- **Warum relevant:** Das Masterdokument ist laut eigener Regel die erste Lesepflicht für jeden (auch KI-)Eingriff; veraltete Fakten dort erzeugen falsche Entscheidungen.
- **Änderung:** Redis-Abschnitt in Sektion 13 auf 8.8 aktualisieren; bei Gelegenheit einen Mini-Check ins Posture-/Audit-Ritual: "stimmen Versionsangaben im Master noch?"
- **Verifikation:** `grep -n "7.4-alpine" HOMELAB_ARCHITECTURE_MASTER_V2.md` → leer.
- **Rollback:** trivial (Doku). | Nutzen: niedrigmittel | Risiko: keiner | Aufwand: klein | sofort
## Top 5 Risiken (zuerst entschärfen)
1. **Löschbare Off-site-Backups** — Host-Kompromittierung oder ein falscher `borg delete` vernichtet auch Hetzner. → Empfehlung 2 (Snapshots). Bis dahin ist das DR-Konzept gegen Ransomware unvollständig.
2. **DNS-SPOF AdGuard** — bereits einmal real eingetreten (Teil-Deploy 2026-06); betrifft auch die Selbstheilungsfähigkeit (Image-Pulls). → Empfehlung 3.
3. **Verdeckte Versionssprünge via `release`/`latest`-Digest-Bumps** — v. a. Immich (DB-Migrationen!). → Empfehlung 1.
4. **OOM-Kaskade ohne Limits** — ein Leck in einer Tier-3-App kann Postgres killen. → Empfehlung 5. (Der Critical-Events-Watcher meldet das nur, verhindert es nicht.)
5. **Blinde Alert-Kette** — Monitoring-Ausfall = Stille statt Alarm. → Empfehlung 8.
Bewusst akzeptierte Risiken (USV geparkt, ein Host, kein WAN-Failover, kein
zweites Off-site-Ziel) sind dokumentiert und werden hier nicht erneut
aufgemacht — die Entscheidungen sind nachvollziehbar.
## Quick Wins unter 30 Minuten
| Quick Win | Wirkung | Kommando/Weg |
|---|---|---|
| Hetzner-Snapshots aktivieren | Backup-Löschschutz | Robot-Konsole → Storage Box → Snapshots (Empf. 2) |
| Host-DNS-Fallback eintragen | Selbstheilung bei AdGuard-Down | Unraid Settings → Network → DNS 2 = `1.1.1.1` (Empf. 3a) |
| Arbeitskopie aufräumen | GitOps-Hygiene | `git status`, committen/verwerfen (Empf. 9) |
| Redis-Doku-Drift fixen | Master-Doku wieder korrekt | Sektion 13 editieren (Empf. 10) |
| Memory-Baseline ziehen | Grundlage für Limits | `docker stats --no-stream` auf dem Host, Output archivieren |
| Watchdog-Rule anlegen | Vorbereitung Dead-Man's-Switch | `alerts.yml` + healthchecks.io-Account (Empf. 8) |
## 30-Tage-Optimierungsplan
**Woche 1 — Risiko-Entschärfung (alles klein):**
Hetzner-Snapshots (Empf. 2) · Host-DNS-Fallback + Stop/Start-Test (Empf. 3a) ·
Immich-Tag-Pinning (Empf. 1) · Arbeitskopie aufräumen (Empf. 9) ·
Memory-Baseline starten.
**Woche 2 — Beobachtbarkeit:**
Dead-Man's-Switch produktiv (Empf. 8) · traefik/dynamic-Drift-Check in den
Posture-Check (Empf. 7) · Healthchecks für Nextcloud, Paperless, Mealie, n8n
(Empf. 4, stackweise).
**Woche 3 — Hardening:**
`no-new-privileges` für alle WAN-exponierten Apps (Empf. 6) · Healthchecks
für AdGuard/Unbound/Monitoring-Kern · restliche Mutable-Tag-Kandidaten pinnen
(komodo, scrutiny, glances, ddns-updater, tag-lose digest-only Images).
**Woche 4 — Stabilität:**
Memory-Limits aus der Baseline für die Top-5-Verbraucher (Empf. 5) ·
FRITZ!Box-DNS-Fallback-Entscheidung (Empf. 3b) · Doku nachziehen
(Master Sektion 13, SERVICE_CATALOG, dieses Dokument abhaken).
## Größere Projekte mit hohem Nutzen (später)
- **End-to-end-DR-Drill** sobald zweite Hardware existiert (bereits geplant,
bleibt der wertvollste offene Beweis).
- **Strom-/Kostentransparenz:** smarte Steckdose mit Messfunktion (z. B.
Shelly Plug S) vor den Unraid-Host, Werte via Home Assistant → InfluxDB 3 →
Grafana. Erst messen, dann ggf. optimieren (Spindown-Policy, CPU-Governor).
Messbarkeit: W-Dauerlast und kWh/Monat als Grafana-Panel.
- **USV-Review Q3/2026** wie geparkt — nach Strommessung lässt sich die
USV-Dimensionierung direkt ableiten.
- **Renovate-Policy verfeinern:** Digest-PRs für mutable Tags entweder
abschalten oder mit Warn-Label versehen, damit Befund 1 strukturell nicht
zurückkommt.
## Konkrete Verifikationskommandos (Sammlung, alle read-only)
```bash
# Health-Status aller Container
docker ps --format '{{.Names}}\t{{.Status}}' | sort
# Memory-Baseline
docker stats --no-stream --format '{{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}' | sort -k3 -hr | head -15
# Welche Container ohne no-new-privileges laufen
docker ps -q | xargs docker inspect --format '{{.Name}} {{.HostConfig.SecurityOpt}}' | grep -v no-new-privileges
# Effektive Image-Referenzen (mutable Tags erkennen)
docker ps --format '{{.Names}}\t{{.Image}}' | grep -E 'latest|release|:2$|:[0-9]+$'
# DNS-Fallback-Test (Wartungsfenster!)
docker stop adguard && nslookup gitea.com && docker start adguard
# Borg-Snapshot-Gegenprobe (nach Aktivierung, von der Storage Box)
ssh -p 23 u565255@u565255.your-storagebox.de ls .snapshots/ 2>/dev/null || echo "via Robot-Konsole prüfen"
```
## Rollback-Hinweise (generell)
- Jede Compose-Änderung: Revert-Commit nach Gitea pushen → Komodo deployed
den Vorzustand; Datenpfade bleiben unberührt (alle Empfehlungen hier sind
config-only, keine Daten-/Volume-Migrationen).
- Healthchecks/Limits/security_opt: Zeilen entfernen + Redeploy genügt.
- Host-DNS/FRITZ!Box-Einträge: Eintrag löschen, sofort wirksam.
- Hetzner-Snapshots und Dead-Man's-Switch sind rein additiv.
- Nichts in diesem Dokument erfordert `push --force`, History-Rewrite oder
Löschoperationen auf Datenpfaden.
## Offene Fragen an den Operator
1. **Strom:** Gibt es eine Messung des Host-Verbrauchs (W idle/last)? Ohne
Zahl ist der Bereich Kosten/Strom nicht bewertbar. → Shelly/Messsteckdose?
2. **RAM-Ausstattung des Hosts:** Wie viel RAM hat Kallilabcore gesamt und
wie ist die aktuelle Auslastung (`free -h`)? Bestimmt, wie aggressiv
Memory-Limits sinnvoll sind.
3. **Renovate-Verhalten gewollt?** Sollen Digest-Bumps auf `release`/`latest`
weiter automatisch als PRs kommen, oder ist die Pinning-Strategie aus
Empfehlung 1 die gewünschte Linie für alle Stacks?
4. **healthchecks.io o. ä. als externe Abhängigkeit akzeptabel?** Alternativ
ginge ein ntfy-basierter Heartbeat von einem zweiten Gerät (z. B. dem
Gaming-PC per Scheduled Task) — null neue Cloud-Abhängigkeit.
5. **FRITZ!Box-DNS-Fallback (3b):** Filterlücke bei AdGuard-Down akzeptieren
oder lieber nur den Host-Fallback (3a) umsetzen?
-58
View File
@@ -1,58 +0,0 @@
# Runbook: Komodo Bulk-Deploy schlaegt mit DNS `connection refused` fehl
Stand: 2026-06-10 · Typ: Runbook / ADR-light · Status: Sofortmassnahme empfohlen, noch nicht umgesetzt
## Symptom
Ein Bulk-Merge (z. B. Renovate-Sammel-PR) loest gleichzeitig viele Komodo-Stack-Webhooks aus. Komodo startet parallele `DeployStack`. Nur ein Teil der Stacks deployt, der Rest bleibt auf dem alten Image. In der Deploy-Stufe **Compose Pull** stehen Fehler wie:
```
Get "https://registry-1.docker.io/v2/": dial tcp: lookup registry-1.docker.io
on 192.168.178.58:53: read udp ...->192.168.178.58:53: read: connection refused
```
Manuelles Re-Deploy der betroffenen Stacks danach funktioniert (AdGuard ist dann wieder oben).
## Ursache
Der Host nutzt **AdGuard Home als einzigen Resolver** (`/etc/resolv.conf` = nur `nameserver 192.168.178.58`, keine `/etc/docker/daemon.json`). AdGuard laeuft selbst als Container auf dem Host und bindet `0.0.0.0:53`. Wird der `adguard`-Stack im selben Batch neu deployt, faellt Port 53 fuer Sekunden aus. Alle parallelen `docker compose pull` der anderen Stacks koennen `registry-1.docker.io` dann nicht aufloesen -> `connection refused` -> Deploy `success=false`.
Es ist **kein** Webhook-, Auth- oder Docker-Hub-Rate-Limit-Problem: Webhooks authentifizieren sauber, `webhook_enabled=true`, Fehlerbild ist `connection refused` auf den eigenen DNS-Port direkt nach AdGuard-Recreate. Fuer den Pull-Pfad zaehlt der Docker-Daemon/Go-Resolver (iteriert ueber die `resolv.conf`-Server und springt bei Socket-Fehlern zum naechsten), nicht der glibc-Client.
## Sofortmassnahme (Schicht 1)
Unraid -> Settings -> Network Settings -> `eth0`:
- DNS server 1: `192.168.178.58` (AdGuard, bleibt)
- **DNS server 2: `192.168.178.1`** (FritzBox) -> Apply
Damit ueberleben Registry-Pulls einen kurzen AdGuard-Ausfall via Resolver-Failover. Im Normalbetrieb wird weiter DNS1 (AdGuard) genutzt, der Filter bleibt aktiv.
Pruefen / Bedingungen:
- **Kein `options rotate`** in `/etc/resolv.conf` (sonst dauerhafter Filter-Bypass). Aktuell nicht gesetzt; nach Apply erneut pruefen.
- Router muss oeffentliche Namen **selbst** aufloesen und nicht intern an AdGuard zurueckleiten.
- Hinweis zur Verifikation: Ein `nslookup registry-1.docker.io 192.168.178.1` bei laufendem AdGuard ist ein gutes Signal, aber **kein letzter Beweis**. Wasserdicht: AdGuard kurz stoppen und `dig @192.168.178.1 registry-1.docker.io`, oder FritzBox-Upstream / AdGuard-Querylog pruefen.
Rollback: DNS server 2 leeren + Apply.
## Betriebsregel (Schicht 2)
- **AdGuard und Unbound nicht gemeinsam mit abhaengigen Stacks im Bulk deployen.** DNS-Infrastruktur immer separat / einzeln deployen, nicht waehrend 20+ parallele Pulls laufen.
- Renovate-PRs gestaffelt mergen (eine Etappe pro Deploy) statt Sammel-Merge. Deckt dieses Problem fuer den Normalbetrieb bereits ab.
## Spaeter optional
- Komodo-Deploys serialisieren: statt vieler paralleler Stack-Webhooks eine **Procedure** (sequenzielle Stages) oder **Resource Sync** mit `after`-Ordering. Trifft die Ursache direkter, ist aber ein groesserer Umbau und **kein Renovate-Blocker**.
- Host-DNS vom AdGuard-Container entkoppeln (AdGuard eigene IP via macvlan, Host-Resolver auf Router/Unbound), damit `:53` am Host nicht exklusiv am Container-Lifecycle haengt.
## Verworfen
- `/etc/docker/daemon.json` mit `"dns": [...]`: wirkt nur fuer Container-DNS, nicht fuer Daemon-eigene Image-Pulls.
- AdGuard `network_mode: host`: beim Recreate ist der DNS-Prozess trotzdem weg; macht aus dem Single Point of Failure keinen HA-Resolver.
## Referenzen
- Diagnose-Zugriff: SSH `root@192.168.178.58`; Komodo-Mongo (`docker exec komodo-mongo`, DB `komodo`, Collections `Stack`/`Update`); Gitea SQLite `/data/gitea/gitea.db` (Tabelle `webhook`, `repo_id=3`).
- Verwandt: `docs/WORKFLOW.md` (DNS-Regeln fuer Container), `docs/GITOPS_DRIFT_RUNBOOK.md`.
</content>
+2 -7
View File
@@ -66,18 +66,15 @@ services:
image: prom/blackbox-exporter:v0.28.0@sha256:e753ff9f3fc458d02cca5eddab5a77e1c175eee484a8925ac7d524f04366c2fc image: prom/blackbox-exporter:v0.28.0@sha256:e753ff9f3fc458d02cca5eddab5a77e1c175eee484a8925ac7d524f04366c2fc
container_name: monitoring-blackbox-exporter container_name: monitoring-blackbox-exporter
restart: unless-stopped restart: unless-stopped
# Use AdGuard so *.kaleschke.info resolves to the internal Traefik IP.
# External resolvers (1.1.1.1/8.8.8.8) return the public WAN IP, which
# causes hairpin-NAT timeouts when probing from inside the Docker network.
dns: dns:
- 172.23.0.3 - 1.1.1.1
- 8.8.8.8
command: command:
- --config.file=/etc/blackbox_exporter/blackbox.yml - --config.file=/etc/blackbox_exporter/blackbox.yml
volumes: volumes:
- ./blackbox/blackbox.yml:/etc/blackbox_exporter/blackbox.yml:ro - ./blackbox/blackbox.yml:/etc/blackbox_exporter/blackbox.yml:ro
networks: networks:
- monitoring_net - monitoring_net
- dns_net
expose: expose:
- "9115" - "9115"
security_opt: security_opt:
@@ -370,8 +367,6 @@ networks:
driver: bridge driver: bridge
frontend_net: frontend_net:
external: true external: true
dns_net:
external: true
volumes: volumes:
prometheus_data: prometheus_data:
-5
View File
@@ -2,11 +2,6 @@ services:
# ────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────
# MongoDB Datenbank fuer Komodo Core # MongoDB Datenbank fuer Komodo Core
# Netz: komodo_net (internal: true) niemals frontend_net # Netz: komodo_net (internal: true) niemals frontend_net
# ACHTUNG: Dieser Stack wird NICHT aus diesem Repo deployed. Der komodo-Stack
# ist in Komodo inline (file_contents) verwaltet (Bootstrap-/Self-Stack).
# Diese Datei ist nur Doku/Spiegel; Aenderungen hier wirken NICHT zur Laufzeit.
# ops/komodo/** ist in renovate.json ignorePaths. Siehe docs/RENOVATE.md.
# Digest = aktuell real laufender Stand (kein Renovate-Auto-Update).
# ────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────
komodo-mongo: komodo-mongo:
image: mongo:8.0.23@sha256:44aa79ae28ff80b56fe58681b66cda9336706df408a5175a6c04988aa54610d3 image: mongo:8.0.23@sha256:44aa79ae28ff80b56fe58681b66cda9336706df408a5175a6c04988aa54610d3
+1 -15
View File
@@ -38,19 +38,6 @@
"automerge": false, "automerge": false,
"labels": ["dependencies", "minor-patch"] "labels": ["dependencies", "minor-patch"]
}, },
{
"description": "Kritische Kerninfra (Traefik=Public-Entrypoint, Unbound=DNS, n8n, Nextcloud): nicht im Sammel-PR, eigene einzeln reviewbare PRs, kein Auto-Merge",
"matchManagers": ["docker-compose", "dockerfile"],
"matchPackageNames": [
"traefik",
"shaanmajid/unbound",
"docker.n8n.io/n8nio/n8n",
"nextcloud"
],
"groupName": null,
"automerge": false,
"labels": ["dependencies", "core-critical"]
},
{ {
"description": "Stateful Tier-1 (Postgres, Mongo, Redis): keine Auto-Group, einzelne PRs, kein Auto-Merge", "description": "Stateful Tier-1 (Postgres, Mongo, Redis): keine Auto-Group, einzelne PRs, kein Auto-Merge",
"matchPackageNames": [ "matchPackageNames": [
@@ -112,7 +99,6 @@
"ignorePaths": [ "ignorePaths": [
"**/_archive/**", "**/_archive/**",
"ops/grafana-influxdb/**", "ops/grafana-influxdb/**",
"ops/loki/**", "ops/loki/**"
"ops/komodo/**"
] ]
} }
+18 -125
View File
@@ -11,7 +11,6 @@ SINCE="${SINCE:-24h}"
MAX_LOG_LINES="${MAX_LOG_LINES:-80}" MAX_LOG_LINES="${MAX_LOG_LINES:-80}"
CERT_MAX_ROWS="${CERT_MAX_ROWS:-12}" CERT_MAX_ROWS="${CERT_MAX_ROWS:-12}"
IMAGE_AGE_WARN_DAYS="${IMAGE_AGE_WARN_DAYS:-180}" IMAGE_AGE_WARN_DAYS="${IMAGE_AGE_WARN_DAYS:-180}"
IMAGE_AGE_ALLOW_FILE="${IMAGE_AGE_ALLOW_FILE:-/mnt/user/services/homelab-infra/services/posture-check/image-age-allow.patterns}"
LOG_VOLUME_TOP_N="${LOG_VOLUME_TOP_N:-10}" LOG_VOLUME_TOP_N="${LOG_VOLUME_TOP_N:-10}"
DISK_USAGE_WARN_PCT="${DISK_USAGE_WARN_PCT:-85}" DISK_USAGE_WARN_PCT="${DISK_USAGE_WARN_PCT:-85}"
CERT_WARN_DAYS="${CERT_WARN_DAYS:-21}" CERT_WARN_DAYS="${CERT_WARN_DAYS:-21}"
@@ -29,7 +28,6 @@ TRAEFIK_ACME_PATH="${TRAEFIK_ACME_PATH:-/mnt/user/appdata/traefik/letsencrypt/ac
NOISE_PATTERNS_FILE="${NOISE_PATTERNS_FILE:-/mnt/user/services/homelab-infra/services/posture-check/log-noise.patterns}" NOISE_PATTERNS_FILE="${NOISE_PATTERNS_FILE:-/mnt/user/services/homelab-infra/services/posture-check/log-noise.patterns}"
NORMALIZE_NOISE_SCRIPT="${NORMALIZE_NOISE_SCRIPT:-/mnt/user/services/homelab-infra/services/posture-check/lib/normalize-noise-patterns.sh}" NORMALIZE_NOISE_SCRIPT="${NORMALIZE_NOISE_SCRIPT:-/mnt/user/services/homelab-infra/services/posture-check/lib/normalize-noise-patterns.sh}"
NOISE_ESCALATION_THRESHOLD="${NOISE_ESCALATION_THRESHOLD:-500}" NOISE_ESCALATION_THRESHOLD="${NOISE_ESCALATION_THRESHOLD:-500}"
NOISE_ESCALATION_EXEMPT_FILE="${NOISE_ESCALATION_EXEMPT_FILE:-/mnt/user/services/homelab-infra/services/posture-check/noise-escalation-exempt.patterns}"
NOISE_BREAKDOWN_TOP_N="${NOISE_BREAKDOWN_TOP_N:-10}" NOISE_BREAKDOWN_TOP_N="${NOISE_BREAKDOWN_TOP_N:-10}"
POSTURE_CHECK_FILE="${POSTURE_CHECK_FILE:-/mnt/user/services/posture-check/last.json}" POSTURE_CHECK_FILE="${POSTURE_CHECK_FILE:-/mnt/user/services/posture-check/last.json}"
LOCK_FILE="${LOCK_FILE:-/tmp/homelab-daily-report.lock}" LOCK_FILE="${LOCK_FILE:-/tmp/homelab-daily-report.lock}"
@@ -461,10 +459,6 @@ with open("/acme.json", "r", encoding="utf-8") as handle:
data = json.load(handle) data = json.load(handle)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
# Deduplicate: for each unique set of domains keep only the longest-lived cert.
# Traefik stores both the old and the newly-issued cert in acme.json during
# the renewal window, which would otherwise produce a false warning.
best = {} # frozenset(domains) -> (days, expire_date_iso, names)
for resolver in data.values(): for resolver in data.values():
for cert in resolver.get("Certificates", []): for cert in resolver.get("Certificates", []):
domain = cert.get("domain", {}).get("main") or "-" domain = cert.get("domain", {}).get("main") or "-"
@@ -480,11 +474,7 @@ for resolver in data.values():
not_after = datetime.strptime(decoded["notAfter"], "%b %d %H:%M:%S %Y %Z").replace(tzinfo=timezone.utc) not_after = datetime.strptime(decoded["notAfter"], "%b %d %H:%M:%S %Y %Z").replace(tzinfo=timezone.utc)
days = (not_after - now).days days = (not_after - now).days
names = ", ".join([domain, *sans]) names = ", ".join([domain, *sans])
key = frozenset([domain, *sans]) print(f"{days}\t{not_after.date().isoformat()}\t{names}")
if key not in best or days > best[key][0]:
best[key] = (days, not_after.date().isoformat(), names)
for days, expires, names in best.values():
print(f"{days}\t{expires}\t{names}")
PY PY
then then
if [ ! -s "$cert_file" ]; then if [ ! -s "$cert_file" ]; then
@@ -583,36 +573,13 @@ collect_image_freshness() {
local image_file="$TMP_DIR/images.tsv" local image_file="$TMP_DIR/images.tsv"
local image_warnings=0 local image_warnings=0
local image_allowed=0
local now_epoch local now_epoch
: > "$image_file" : > "$image_file"
now_epoch="$(date +%s)" now_epoch="$(date +%s)"
# Parse the image-age allowlist: container deliberately pinned to a stable or
# upstream-recommended image. Each entry carries a recheck date; once that
# date has passed the suppression lapses, so a pin gets re-reviewed instead
# of silently aging forever.
local allow_file="$TMP_DIR/image-allow.tsv"
: > "$allow_file"
if [ -f "$IMAGE_AGE_ALLOW_FILE" ]; then
while IFS= read -r line; do
line="${line%%#*}"
line="$(printf '%s' "$line" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
[ -n "$line" ] || continue
local a_name a_date a_epoch
a_name="$(printf '%s' "$line" | awk '{ print $1 }')"
a_date="$(printf '%s' "$line" | awk '{ print $2 }')"
[ -n "$a_name" ] && [ -n "$a_date" ] || continue
a_epoch="$(date -d "$a_date" +%s 2>/dev/null || echo 0)"
if [ "$a_epoch" -ge "$now_epoch" ]; then
printf '%s\t%s\n' "$a_name" "$a_date" >> "$allow_file"
fi
done < "$IMAGE_AGE_ALLOW_FILE"
fi
while IFS= read -r name; do while IFS= read -r name; do
[ -n "$name" ] || continue [ -n "$name" ] || continue
local image_id created_iso created_epoch age_days image_tag note recheck local image_id created_iso created_epoch age_days image_tag
image_id="$(docker inspect --format '{{.Image}}' "$name" 2>/dev/null || true)" image_id="$(docker inspect --format '{{.Image}}' "$name" 2>/dev/null || true)"
[ -n "$image_id" ] || continue [ -n "$image_id" ] || continue
created_iso="$(docker image inspect --format '{{.Created}}' "$image_id" 2>/dev/null || true)" created_iso="$(docker image inspect --format '{{.Created}}' "$image_id" 2>/dev/null || true)"
@@ -621,46 +588,33 @@ collect_image_freshness() {
created_epoch="$(date -d "$created_iso" +%s 2>/dev/null || echo 0)" created_epoch="$(date -d "$created_iso" +%s 2>/dev/null || echo 0)"
[ "$created_epoch" -gt 0 ] || continue [ "$created_epoch" -gt 0 ] || continue
age_days=$(( (now_epoch - created_epoch) / 86400 )) age_days=$(( (now_epoch - created_epoch) / 86400 ))
note="" printf '%d\t%s\t%s\n' "$age_days" "$name" "$image_tag" >> "$image_file"
if [ "$age_days" -ge "$IMAGE_AGE_WARN_DAYS" ]; then if [ "$age_days" -ge "$IMAGE_AGE_WARN_DAYS" ]; then
recheck="$(awk -F '\t' -v n="$name" '$1 == n { print $2; found = 1 } END { exit !found }' "$allow_file" || true)"
if [ -n "$recheck" ]; then
note="bewusst gepinnt (recheck $recheck)"
image_allowed=$((image_allowed + 1))
else
note="ueberaltert"
image_warnings=$((image_warnings + 1)) image_warnings=$((image_warnings + 1))
fi fi
fi
printf '%d\t%s\t%s\t%s\n' "$age_days" "$name" "$image_tag" "$note" >> "$image_file"
done < <(docker ps --format '{{.Names}}') done < <(docker ps --format '{{.Names}}')
set_summary "image_warnings" "$image_warnings" set_summary "image_warnings" "$image_warnings"
set_summary "image_allowed" "$image_allowed"
if [ ! -s "$image_file" ]; then if [ ! -s "$image_file" ]; then
append "- Keine Image-Daten verfuegbar." append "- Keine Image-Daten verfuegbar."
record_section_error "images" "Keine Image-Daten ermittelt" record_section_error "images" "Keine Image-Daten ermittelt"
else else
append "- Schwelle Warnung: Image aelter als $IMAGE_AGE_WARN_DAYS Tage" append "- Schwelle Warnung: Image aelter als $IMAGE_AGE_WARN_DAYS Tage"
append "- Container mit ueberaltertem Image (gewarnt): $image_warnings" append "- Container mit Image >= $IMAGE_AGE_WARN_DAYS Tage: $image_warnings"
append "- Davon bewusst gepinnt (von Warnung ausgenommen): $image_allowed"
append "- Allowlist-Quelle: \`$IMAGE_AGE_ALLOW_FILE\`"
append "" append ""
append "### Aelteste Images (Top 10)" append "### Aelteste Images (Top 10)"
append "" append ""
append "| Alter Tage | Container | Image | Hinweis |" append "| Alter Tage | Container | Image |"
append "|---:|---|---|---|" append "|---:|---|---|"
sort -nr "$image_file" | head -n 10 | while IFS="$(printf '\t')" read -r age name img note; do sort -nr "$image_file" | head -n 10 | while IFS="$(printf '\t')" read -r age name img; do
append "| $age | $name | $img | ${note:-} |" append "| $age | $name | $img |"
done done
append "" append ""
if [ "$image_warnings" -eq 0 ] && [ "$image_allowed" -eq 0 ]; then if [ "$image_warnings" -eq 0 ]; then
append "Bewertung: Keine Container mit ueberalterten Images. CVE-Hygiene aus dieser Sicht ok." append "Bewertung: Keine Container mit ueberalterten Images. CVE-Hygiene aus dieser Sicht ok."
elif [ "$image_warnings" -eq 0 ]; then
append "Bewertung: Keine ungeprueft ueberalterten Images. $image_allowed Container sind bewusst gepinnt und mit Recheck-Datum dokumentiert."
else else
append "Bewertung: $image_warnings Container nutzen ueberalterte Images (nicht in der Allowlist). Update-Pipeline und CVE-Status pruefen." append "Bewertung: $image_warnings Container nutzen Images aelter als $IMAGE_AGE_WARN_DAYS Tage. Update-Pipeline und CVE-Status pruefen."
fi fi
fi fi
append "" append ""
@@ -701,31 +655,6 @@ collect_container_events() {
collect_container_state() { collect_container_state() {
append "## Container-Zustand" append "## Container-Zustand"
append "" append ""
append "### Unhealthy Container"
local unhealthy_file="$TMP_DIR/unhealthy.log"
docker ps --filter health=unhealthy --format '{{.Names}}' > "$unhealthy_file"
if [ ! -s "$unhealthy_file" ]; then
append "- Keine."
else
append "| Container | FailingStreak | Letzter Healthcheck |"
append "|---|---:|---|"
while IFS= read -r name; do
[ -n "$name" ] || continue
local streak hc
streak="$(docker inspect "$name" --format '{{.State.Health.FailingStreak}}' 2>/dev/null || echo '?')"
# Letzten nicht-leeren Health-Log-Eintrag holen, einzeilig machen und
# Pipe-Zeichen escapen, damit die Markdown-Tabelle nicht bricht.
hc="$(docker inspect "$name" --format '{{range .State.Health.Log}}exit={{.ExitCode}} out={{.Output}}~~~{{end}}' 2>/dev/null \
| tr '\n' ' ' \
| awk -F '~~~' '{ for (i = NF - 1; i >= 1; i--) { if ($i != "") { print $i; break } } }' \
| sed -E 's/[[:space:]]+/ /g; s/\|/\\|/g' \
| cut -c1-160)"
append "| \`$name\` | ${streak:-?} | ${hc:-(kein Output)} |"
done < "$unhealthy_file"
fi
append ""
append "### Nicht laufende Container" append "### Nicht laufende Container"
local stopped_file="$TMP_DIR/stopped.log" local stopped_file="$TMP_DIR/stopped.log"
docker ps -a --filter status=exited --filter status=dead --filter status=created --format '{{.Names}}\t{{.Status}}' > "$stopped_file" docker ps -a --filter status=exited --filter status=dead --filter status=created --format '{{.Names}}\t{{.Status}}' > "$stopped_file"
@@ -881,35 +810,12 @@ collect_log_highlights() {
fi fi
fi fi
# Escalation-exempt patterns: known noise that is also permanently very loud # Threshold escalation: how many patterns produced more than the threshold?
# (e.g. Unraid mdadm parse spam). Without this, such a pattern would keep the local noise_threshold_exceeded=0
# report stuck at >= WARNUNG forever and devalue the OK/WARNUNG/KRITISCH
# signal. Exempt patterns are still counted/shown as noise, but do NOT count
# toward noise_threshold_exceeded. New/unexpected loud patterns still escalate.
local noise_exempt="$TMP_DIR/noise-escalation-exempt.normalized"
: > "$noise_exempt"
if [ -f "$NOISE_ESCALATION_EXEMPT_FILE" ]; then
grep -Ev '^[[:space:]]*(#|$)' "$NOISE_ESCALATION_EXEMPT_FILE" 2>/dev/null \
| sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' \
| grep -v '^$' > "$noise_exempt" || : > "$noise_exempt"
fi
# Threshold escalation: how many NON-exempt patterns exceeded the threshold?
local noise_threshold_exceeded=0 noise_threshold_exempt=0
if [ -s "$noise_by_pattern" ]; then if [ -s "$noise_by_pattern" ]; then
noise_threshold_exceeded="$(awk -F '\t' -v t="$NOISE_ESCALATION_THRESHOLD" ' noise_threshold_exceeded="$(awk -v t="$NOISE_ESCALATION_THRESHOLD" '$1 > t { n++ } END { print n + 0 }' "$noise_by_pattern")"
NR == FNR { exempt[$0] = 1; next }
$1 > t && !($2 in exempt) { n++ }
END { print n + 0 }
' "$noise_exempt" "$noise_by_pattern")"
noise_threshold_exempt="$(awk -F '\t' -v t="$NOISE_ESCALATION_THRESHOLD" '
NR == FNR { exempt[$0] = 1; next }
$1 > t && ($2 in exempt) { n++ }
END { print n + 0 }
' "$noise_exempt" "$noise_by_pattern")"
fi fi
set_summary "noise_threshold_exceeded" "$noise_threshold_exceeded" set_summary "noise_threshold_exceeded" "$noise_threshold_exceeded"
set_summary "noise_threshold_exempt" "$noise_threshold_exempt"
local hit_count attention_count known_noise_count local hit_count attention_count known_noise_count
hit_count="$(count_lines < "$hits")" hit_count="$(count_lines < "$hits")"
@@ -930,9 +836,6 @@ collect_log_highlights() {
if [ "$noise_threshold_exceeded" -gt 0 ]; then if [ "$noise_threshold_exceeded" -gt 0 ]; then
append "- WARNUNG: $noise_threshold_exceeded Pattern ueberschreit(en) die Schwelle - bitte pruefen ob noch wirklich Noise." append "- WARNUNG: $noise_threshold_exceeded Pattern ueberschreit(en) die Schwelle - bitte pruefen ob noch wirklich Noise."
fi fi
if [ "${noise_threshold_exempt:-0}" -gt 0 ]; then
append "- Hinweis: $noise_threshold_exempt laute(s) Pattern ist/sind als bewusst eskalations-befreit markiert (siehe \`$NOISE_ESCALATION_EXEMPT_FILE\`) und loesen keine WARNUNG aus."
fi
append "" append ""
if [ "$attention_count" -eq 0 ]; then if [ "$attention_count" -eq 0 ]; then
@@ -982,32 +885,22 @@ collect_log_highlights() {
if [ -s "$noise_by_pattern" ]; then if [ -s "$noise_by_pattern" ]; then
append "#### Pattern mit den meisten Treffern" append "#### Pattern mit den meisten Treffern"
append "" append ""
append "| Pattern | Anzahl | Hinweis |" append "| Pattern | Anzahl |"
append "|---|---:|---|" append "|---|---:|"
head -n "$NOISE_BREAKDOWN_TOP_N" "$noise_by_pattern" \ head -n "$NOISE_BREAKDOWN_TOP_N" "$noise_by_pattern" \
| while IFS="$(printf '\t')" read -r cnt pat; do | while IFS="$(printf '\t')" read -r cnt pat; do
local short="$pat" note="" local short="$pat"
# Mark patterns that are deliberately exempt from escalation.
if [ -s "$noise_exempt" ] && grep -Fxq -- "$pat" "$noise_exempt"; then
if [ "$cnt" -gt "$NOISE_ESCALATION_THRESHOLD" ]; then
note="eskalations-befreit"
fi
elif [ "$cnt" -gt "$NOISE_ESCALATION_THRESHOLD" ]; then
note="ueber Schwelle"
fi
if [ "${#short}" -gt 80 ]; then if [ "${#short}" -gt 80 ]; then
short="${short:0:77}..." short="${short:0:77}..."
fi fi
# Escape pipe characters that would break the markdown table. # Escape pipe characters that would break the markdown table.
short="${short//|/\\|}" short="${short//|/\\|}"
append "| \`$short\` | $cnt | $note |" append "| \`$short\` | $cnt |"
done done
append "" append ""
fi fi
if [ "$noise_threshold_exceeded" -gt 0 ]; then if [ "$noise_threshold_exceeded" -gt 0 ]; then
append "Bewertung: $noise_threshold_exceeded nicht-befreite(s) Pattern ueberschreit(en) die Eskalations-Schwelle ($NOISE_ESCALATION_THRESHOLD). Bitte pruefen, ob die als Noise eingeordneten Meldungen noch fachlich Noise sind oder ob sich ein echter Vorfall darunter versteckt." append "Bewertung: $noise_threshold_exceeded Pattern ueberschreit(en) die Eskalations-Schwelle ($NOISE_ESCALATION_THRESHOLD). Bitte pruefen, ob die als Noise eingeordneten Meldungen noch fachlich Noise sind oder ob sich ein echter Vorfall darunter versteckt."
elif [ "${noise_threshold_exempt:-0}" -gt 0 ]; then
append "Bewertung: Kein nicht-befreites Pattern ueberschreitet die Eskalations-Schwelle ($NOISE_ESCALATION_THRESHOLD). $noise_threshold_exempt lautes Pattern ist bewusst eskalations-befreit und mit Begruendung dokumentiert."
else else
append "Bewertung: Kein Pattern ueberschreitet die Eskalations-Schwelle ($NOISE_ESCALATION_THRESHOLD)." append "Bewertung: Kein Pattern ueberschreitet die Eskalations-Schwelle ($NOISE_ESCALATION_THRESHOLD)."
fi fi
@@ -1,30 +0,0 @@
# image-age-allow.patterns - Daily Operations Report
#
# Container, die bewusst auf einem aelteren, aber aktuellen/empfohlenen Image
# gepinnt sind, sollen nicht jeden Tag als "Image ueberaltert" warnen.
#
# Format pro Zeile:
# <container-name> <YYYY-MM-DD recheck> # Begruendung
#
# - Spalte 1: exakter Container-Name (docker ps {{.Names}}).
# - Spalte 2: Recheck-Datum. NACH diesem Datum greift die Ausnahme NICHT
# mehr und der Container taucht wieder als Warnung auf -> erzwingt eine
# menschliche Neubewertung statt stillen Alterns.
# - Alles nach '#' ist Kommentar. Leerzeilen werden ignoriert.
#
# Eine Ausnahme heisst NICHT "Image egal", sondern "am Datum X erneut pruefen,
# ob es noch die empfohlene/aktuelle Version ist".
#
# Last reviewed: 2026-06-10
# immich_postgres: exakt das von Immich offiziell empfohlene, per Digest
# gepinnte DB-Image (14-vectorchord0.4.3-pgvectors0.2.0). Immichs eigene
# docker-compose auf main pinnt am 2026-06-10 denselben Tag inkl. identischem
# Digest. Kein Update, solange Immich nichts Neueres empfiehlt.
# Re-check: ob Immich ein neueres Postgres-Image empfiehlt.
immich_postgres 2026-09-10
# monitoring-blackbox-exporter: v0.28.0 ist am 2026-06-10 die NEUESTE Release
# (Dez 2025). Das Image-Alter ist nur Build-Alter, keine veraltete Version.
# Re-check: ob eine blackbox_exporter-Version > v0.28.0 erschienen ist.
monitoring-blackbox-exporter 2026-09-10
+1 -16
View File
@@ -18,7 +18,7 @@
# Removing a pattern: replace with a fresh attention example in the next # Removing a pattern: replace with a fresh attention example in the next
# daily report and consult before reintroducing. # daily report and consult before reintroducing.
# #
# Last reviewed: 2026-06-10 # Last reviewed: 2026-05-21
# Loki internal query cancellations / scheduler chatter. # Loki internal query cancellations / scheduler chatter.
# Why: Loki cancels internal queries continuously when downstream Promtails # Why: Loki cancels internal queries continuously when downstream Promtails
@@ -72,18 +72,3 @@ authelia.*Request timeout occurred.*status_code=408
# noise becomes overwhelming, add a *narrow* pattern restricted to # noise becomes overwhelming, add a *narrow* pattern restricted to
# push contexts only (e.g. `vaultwarden.*push.*(ResolveError|...)`). # push contexts only (e.g. `vaultwarden.*push.*(ResolveError|...)`).
vaultwarden.*(Token has expired|Invalid refresh token|Failed to decode.*refresh_token|POST /identity/connect/token => 401 Unauthorized) vaultwarden.*(Token has expired|Invalid refresh token|Failed to decode.*refresh_token|POST /identity/connect/token => 401 Unauthorized)
# AdGuard: Fritz!Box sends malformed SOA queries for myfritz.net / myfritz.link.
# Why: AVM Fritz!Box devices send multi-question DNS SOA queries that violate
# RFC 1035 ("only 1 question allowed"). AdGuard rejects them with an error
# but they have no operational impact.
# Re-check: if the same error appears for non-AVM domains, or if rate spikes
# well above 1000/day without a Fritz!Box reboot explaining it.
adguard.*bad question section.*only 1 question allowed
# Grafana: usage-stats collector looks for the Amazon Prometheus plugin, which
# is not installed in this setup. The error is emitted once per stats cycle.
# Why: GF_PLUGINS_PREINSTALL_DISABLED=true keeps the plugin list minimal;
# this lookup is harmless and does not affect any dashboard.
# Re-check: only if Amazon Prometheus is added as a datasource.
monitoring-grafana.*grafana-amazonprometheus-datasource not found
@@ -1,32 +0,0 @@
# noise-escalation-exempt.patterns - Daily Operations Report
#
# Pattern, die als Rauschen bekannt UND dauerhaft sehr laut sind, sollen die
# Eskalations-Schwelle (NOISE_ESCALATION_THRESHOLD) nicht in eine WARNUNG
# uebersetzen. Ohne diese Ausnahme haengt der Report-Status strukturell auf
# >= WARNUNG fest (z. B. mdadm-Noise auf Unraid feuert dauerhaft > 5000/Tag),
# was die OK/WARNUNG/KRITISCH-Ampel entwertet.
#
# Wirkung: Ein hier gelistetes Pattern wird weiterhin als Noise gezaehlt und
# in der Breakdown-Tabelle gezeigt (mit Markierung "eskalations-befreit"),
# zaehlt aber NICHT mehr zu noise_threshold_exceeded. Neue/unerwartete laute
# Patterns loesen weiterhin eine WARNUNG aus.
#
# Format:
# - Exakte Pattern-Zeile wie in log-noise.patterns (nach Normalisierung:
# getrimmt, ohne Kommentar). Muss zeichengenau dem Eintrag entsprechen.
# - Zeilen mit '#' sind Kommentare, Leerzeilen werden ignoriert.
#
# Eine Befreiung heisst NICHT "ignorieren", sondern "Volumen ist als Noise
# akzeptiert; nur die ESKALATION ist abgeschaltet".
#
# Last reviewed: 2026-06-10
# node-exporter kann /proc/mdstat auf Unraid nicht parsen (eigener Array-
# Treiber, kein Linux-mdadm). Dauerhaft > 5000/Tag, rein kosmetisch.
# Re-check: nur bei Migration auf echtes mdadm-RAID.
monitoring-node-exporter.*mdadm.*Cannot parse /host/proc/mdstat
# Fritz!Box sendet RFC-1035-widrige Multi-Question-SOA-Queries fuer
# myfritz.net/myfritz.link; AdGuard lehnt sie ab. ~1000+/Tag, kein Impact.
# Re-check: falls derselbe Fehler fuer Nicht-AVM-Domains auftaucht.
adguard.*bad question section.*only 1 question allowed
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
traefik: traefik:
image: traefik:v3.7@sha256:d6858791f9e74df44ca4014166647c41cdc2abd3bf2a71b832ca4e1c6a91b257 image: traefik:v3.7@sha256:fcdef599e6259359833dd2e1d49f9e964f66825d69bd3dd468f51102ce013d03
container_name: traefik container_name: traefik
restart: unless-stopped restart: unless-stopped
security_opt: security_opt: