Compare commits
190 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f77a69a0b2 | |||
| f73cf48e41 | |||
| eea2697ca1 | |||
| a3d77d7529 | |||
| 02a50e1a58 | |||
| 267e76059a | |||
| 9d4fee02ca | |||
| 24ebcaa3c7 | |||
| 45bae13aa0 | |||
| ff5991cec8 | |||
| 5b6e7b8b66 | |||
| 5cb401797d | |||
| 1d0cba92bd | |||
| 9353a9fc44 | |||
| d50b11784d | |||
| 09eeac51e1 | |||
| 565940b9ef | |||
| b6bbca43ad | |||
| 388e57e385 | |||
| 0c2bb8484a | |||
| a7797fd02e | |||
| bac927bbcc | |||
| add8b71ea9 | |||
| e21e89e51b | |||
| 4e4684b616 | |||
| 84030956ac | |||
| 17fe8073bb | |||
| 9f32ba72c1 | |||
| e9a7f79025 | |||
| 43727151df | |||
| 66ee10cb55 | |||
| ab68900216 | |||
| 8f56c6edcd | |||
| 8e400fb3c3 | |||
| cd650b19ac | |||
| af231dd4e8 | |||
| 428223d2e4 | |||
| b6d3ed4832 | |||
| 9e7bebbd3c | |||
| b7cbbe51de | |||
| 71ac18b21c | |||
| 90f270be96 | |||
| e28f8dabec | |||
| edfec5b66d | |||
| 59bec9ac77 | |||
| 9f86da708a | |||
| d6170211c4 | |||
| fb681086f3 | |||
| 5b101f3b3d | |||
| 669efbd57e | |||
| 2dd5590a2a | |||
| 175cd6951f | |||
| aeb7573b03 | |||
| 215f44b962 | |||
| 6ce625f77a | |||
| c3c8060ddf | |||
| 29eaf8001f | |||
| db7dc3f2af | |||
| c748236886 | |||
| 8aa850df40 | |||
| 2c4854f628 | |||
| b7050812d4 | |||
| c95fa601f0 | |||
| 0c308ff352 | |||
| 53216e50c1 | |||
| b7dfdad621 | |||
| 61625a7a1c | |||
| 6e28ea94d2 | |||
| 58eb53a6a8 | |||
| d345d770c2 | |||
| 2e136d9060 | |||
| 6ca829ec45 | |||
| ef3b546d30 | |||
| 6f684fb4e3 | |||
| 0adddb6533 | |||
| 162421e537 | |||
| bf30240217 | |||
| 5f7940aa01 | |||
| a5add937f8 | |||
| 5ada1ad153 | |||
| ead7e1e17d | |||
| 14e9c0963d | |||
| 23262cd7b9 | |||
| 878ad2d5f1 | |||
| 12a87ad342 | |||
| 0e7e639df4 | |||
| 18df2d155d | |||
| fa177155e6 | |||
| 11c863c8aa | |||
| dd9f677779 | |||
| a9e62ee8e5 | |||
| 4e7d313a20 | |||
| 57ea7507a7 | |||
| 1e3e019f28 | |||
| 54fd0c3347 | |||
| d7e1eb33ba | |||
| 008ab9bc4a | |||
| 7ff7284f6b | |||
| d20b687211 | |||
| 16416d964f | |||
| 2cc39c73f6 | |||
| d351b1cac8 | |||
| df4d335907 | |||
| 7161da00b3 | |||
| aded9a9cbc | |||
| 5cc0a4dadb | |||
| 84020346bc | |||
| 1dc1c1ef17 | |||
| 7c50e69b44 | |||
| 0aa8138bdd | |||
| bdef0afcb9 | |||
| e0e12f1173 | |||
| 403b5fa77c | |||
| 9b4d37ca81 | |||
| 014e51fd67 | |||
| f94a55e093 | |||
| 8f3c03f396 | |||
| bdba76cebc | |||
| 78b9a6e362 | |||
| 374a3198ed | |||
| 986d8dd3f5 | |||
| 1acd4c6830 | |||
| d6e686ae80 | |||
| b1fbb310fb | |||
| f858da484b | |||
| 197454931f | |||
| bcb2bf81a8 | |||
| b45c406975 | |||
| 821fe99807 | |||
| 7268f4ce1e | |||
| 86f1a582c9 | |||
| e3f7ed15cf | |||
| 2b1f95ed09 | |||
| ebea1789a6 | |||
| 2f7b1a0aa2 | |||
| 4e1d608949 | |||
| fe13609292 | |||
| f280f63eb1 | |||
| 0780d1eae1 | |||
| c86203b1e5 | |||
| c632a850f3 | |||
| c736aadf1e | |||
| 209aceca0d | |||
| 239adbbd79 | |||
| 8a43914d05 | |||
| f3dd51de14 | |||
| 12beac55b1 | |||
| 6fe6044101 | |||
| 2b82049ea8 | |||
| 6da1489ca4 | |||
| fbec171cb5 | |||
| 1791caf9a7 | |||
| 4e721a8ac9 | |||
| aebb5776a8 | |||
| b4d6af940f | |||
| 081c4c0249 | |||
| 94493d57de | |||
| e21c9b736e | |||
| 34674406d5 | |||
| fc38fb2ab6 | |||
| edf5b4fda8 | |||
| e03e769c67 | |||
| fbdb017c08 | |||
| 054e674d55 | |||
| f7927b95f4 | |||
| 57ddb56ec9 | |||
| 0648201f79 | |||
| fc2793f2b8 | |||
| 317c56b8de | |||
| 0e68ce489f | |||
| 85a8d0c2f2 | |||
| 718305cb98 | |||
| cbb4dfed3d | |||
| 5a46134737 | |||
| 0a9b2d88bf | |||
| 96d9015867 | |||
| 0f95e61c6f | |||
| bbdf2ffb60 | |||
| 326c744e95 | |||
| d362a9ab4c | |||
| 736aef160e | |||
| b998e88863 | |||
| 4eea231d24 | |||
| 8b8e96e32f | |||
| d888d7394b | |||
| c7f0962ba0 | |||
| f87e993034 | |||
| ef8e8ccd76 | |||
| be479407fe | |||
| 29a0585753 |
+30
@@ -0,0 +1,30 @@
|
||||
# Local environment and stack values
|
||||
.env
|
||||
*.env
|
||||
!*.env.example
|
||||
**/stack.env
|
||||
!**/stack.env.example
|
||||
|
||||
# Secrets and certificate material
|
||||
**/secrets/
|
||||
**/letsencrypt/
|
||||
**/acme.json
|
||||
**/*.key
|
||||
**/*.pem
|
||||
**/*.crt
|
||||
|
||||
# Backup, dump, and archive artifacts
|
||||
**/*.dump
|
||||
**/*.sql.gz
|
||||
**/*.archive.gz
|
||||
**/*.tar
|
||||
**/*.tar.gz
|
||||
**/*.tgz
|
||||
**/*.zip
|
||||
|
||||
# Local/editor noise
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.tmp
|
||||
*.log
|
||||
.serena/
|
||||
@@ -0,0 +1,129 @@
|
||||
# Claude Code Context - Homelab Infra
|
||||
|
||||
Stand: 2026-05-04
|
||||
|
||||
Dieses Repository ist die GitOps-Quelle fuer das KalliLab CORE Homelab auf einem Unraid-Host. Es verwaltet Docker-Compose-Stacks fuer Core-Dienste, Security, Infrastruktur, Apps, Operations-Tools, Host-nahe Dienste und Traefik. Gitea Online ist die operative Quelle der Wahrheit; Komodo konsumiert den Git-Stand und deployed daraus.
|
||||
|
||||
## Vor jeder Aenderung lesen
|
||||
|
||||
Claude muss vor jeder fachlichen oder technischen Aenderung mindestens diese Dateien lesen:
|
||||
|
||||
1. `HOMELAB_ARCHITECTURE_MASTER_V2.md`
|
||||
2. `docs/WORKFLOW.md`
|
||||
3. `docs/REPO_MAP.md`
|
||||
4. `docs/SERVICE_CATALOG.md`
|
||||
5. die betroffene `docker-compose.yml`
|
||||
|
||||
Zusaetzlich je nach Thema:
|
||||
|
||||
- Restore / Host-Ausfall: `docs/DISASTER_RECOVERY.md` und `docs/RESTORE_MATRIX.md`
|
||||
- Rollback: `docs/ROLLBACK.md`
|
||||
- Secrets: `docs/SECRETS_MAP.md`
|
||||
- GitOps-/Komodo-/Runtime-Drift: `docs/GITOPS_DRIFT_RUNBOOK.md`
|
||||
- Gesamtbild fuer KI-Agenten: `docs/AI_CONTEXT.md`
|
||||
- Home Assistant / Ecowitt / InfluxDB: `docs/HOME_ASSISTANT_INFLUXDB_ECOWITT.md`
|
||||
|
||||
## Projektbeschreibung
|
||||
|
||||
- Host: Unraid, Hostname `Kallilabcore`
|
||||
- Domain: `kaleschke.info`
|
||||
- Reverse Proxy: Traefik v3
|
||||
- DNS: AdGuard Home + Unbound
|
||||
- VPN/Remote: Tailscale
|
||||
- Git: Gitea unter `git.kaleschke.info`
|
||||
- Deployment: Komodo als primaerer und einziger produktiver Stack-Manager
|
||||
- Lokale Arbeitskopie: Windows/GitHub Desktop
|
||||
- Persistenz: ueberwiegend `/mnt/user/appdata`, Nutzdaten in `/mnt/user/documents`, `/mnt/user/photos`, GitOps/Gitea in `/mnt/user/services`
|
||||
|
||||
## Architekturprinzipien
|
||||
|
||||
- Traefik ist der einzige oeffentliche HTTP(S)-Einstiegspunkt.
|
||||
- Service-Routing erfolgt per Docker-Labels, nicht ueber neue Traefik File-Provider-Service-Routen.
|
||||
- `frontend_net` ist das Web-/Proxy-Netz.
|
||||
- `backend_net` ist `internal: true` fuer Datenbanken, Redis und interne Backends.
|
||||
- App-interne Netze sind erlaubt, wenn sie eine App und ihre Datenbank/Worker sauber isolieren.
|
||||
- Datenbanken und Caches duerfen nicht im `frontend_net` liegen.
|
||||
- Direkte Host-Ports sind nur mit dokumentierter Ausnahme erlaubt.
|
||||
- Komodo bleibt bewusst ohne pauschale zentrale Authelia-ForwardAuth-Middleware.
|
||||
- Secrets gehoeren niemals ins Git-Repository.
|
||||
- Produktive Container sollen als Compose/Git-Stack verwaltet werden.
|
||||
|
||||
## GitOps-Regeln
|
||||
|
||||
Quelle der Wahrheit:
|
||||
|
||||
1. Gitea `origin/master`
|
||||
2. lokaler Clone
|
||||
3. Komodo Stack Workspace
|
||||
4. laufende Docker-Container
|
||||
5. Host-Zustand
|
||||
|
||||
Standard-Workflow:
|
||||
|
||||
1. Lokal synchronisieren (`Fetch`, ggf. `Pull`)
|
||||
2. Betroffene Docs und Compose-Datei lesen
|
||||
3. Zielzustand und Rollback klaeren
|
||||
4. Lokal minimal aendern
|
||||
5. Validieren
|
||||
6. Commit und Push durch den Benutzer oder nach expliziter Freigabe
|
||||
7. Komodo-Deploy/Runtime pruefen
|
||||
8. Dokumentation nachziehen
|
||||
|
||||
Neue produktive Komodo-Stacks aus `Micha/homelab-infra` brauchen verpflichtend einen aktiven Gitea->Komodo-Webhook auf die aktuelle Stack-ID. Ausnahmen muessen im selben Aenderungsblock dokumentiert werden.
|
||||
|
||||
Wenn Drift vermutet wird, nicht raten. Erst die Pflichtmatrix in `docs/GITOPS_DRIFT_RUNBOOK.md` abarbeiten.
|
||||
|
||||
## Sicherheitsregeln
|
||||
|
||||
- Secret-Werte niemals ausgeben. Wenn Werte auftauchen: redakten.
|
||||
- Nur Secret-Namen, Env-Key-Namen und Pfade dokumentieren.
|
||||
- Keine produktiven `.env`- oder Stack-Env-Werte zitieren.
|
||||
- Keine Compose-Aenderung ohne vorherigen Architektur-/Workflow-Abgleich.
|
||||
- Keine Deployments, Host-Hotfixes oder Docker-Schreibbefehle ohne ausdrueckliche Anweisung.
|
||||
- Keine direkten Host-Ports fuer Web-UIs, ausser dokumentierte Ausnahmen.
|
||||
- `privileged: true`, Docker-Socket und Host-Netz nur als dokumentierte Ausnahme.
|
||||
- Traefik dynamic config unter `traefik/dynamic/` wird nicht automatisch von Komodo deployed und muss bei Aenderungen manuell auf den Host synchronisiert werden.
|
||||
|
||||
## Bekannte dokumentierte Ausnahmen
|
||||
|
||||
- `traefik`: Host-Ports 80/443
|
||||
- `gitea`: SSH-Port 222
|
||||
- `AdGuard Home`: DNS-Port 53 und LAN-Admin-Port 8082
|
||||
- `tailscale`: `network_mode: host`
|
||||
- `Plex-Media-Server`: historischer Host-Netz-Sonderfall, nicht als Repo-Stack enthalten
|
||||
- `scrutiny`: `privileged: true` fuer SMART/Laufwerkszugriff
|
||||
- `Komodo`: Docker-Socket und native Auth ohne pauschale ForwardAuth
|
||||
- `traefik/dynamic/*`: manuelle Host-Sync-Ausnahme
|
||||
- `influxdb3-core`: LAN-only Host-Port 8181 fuer Home Assistant Writer, keine Traefik-Route, nicht im `frontend_net`
|
||||
|
||||
## No-Go-Regeln
|
||||
|
||||
- Keine produktiven Aenderungen direkt in Komodo.
|
||||
- Keine `docker run`-Ad-hoc-Container als Dauerzustand.
|
||||
- Keine Compose-Dateien, Secrets oder Host-Konfigurationen stillschweigend aendern.
|
||||
- Keine Deployments ausloesen, wenn der Benutzer nur Analyse oder Dokumentation verlangt.
|
||||
- Kein `push --force` auf `master` als Standard-Rollback.
|
||||
- Keine History-Rewrites ohne ausdrueckliche Freigabe.
|
||||
- Keine Loesch-, Reset- oder Migrationsbefehle ohne klaren Zielzustand und Rollback.
|
||||
- Keine neuen `latest`-/mutable Image-Tags ohne bewusste Versionsentscheidung.
|
||||
|
||||
## Rollback-Erwartungen
|
||||
|
||||
Jede Aenderung braucht vor dem Deploy eine klare Rueckkehrstrategie:
|
||||
|
||||
- letzter bekannter funktionierender Git-Stand
|
||||
- betroffener Stack und Persistenzpfade
|
||||
- ob Datenpfade unveraendert bleiben
|
||||
- ob Secrets/ENV betroffen sind
|
||||
- konkrete Smoke-Tests nach Rollback
|
||||
|
||||
Standard-Rollback ist ein Ruecknahme-Commit oder gezielte Rueckaenderung mit Push nach Gitea. Produktive Datenpfade unter `/mnt/user/appdata`, `/mnt/user/documents`, `/mnt/user/photos`, `/mnt/user/services` und `/mnt/user/backups` nicht blind loeschen.
|
||||
|
||||
## Arbeitsweise fuer Claude
|
||||
|
||||
- Erst lesen, dann handeln.
|
||||
- Bei Unsicherheit Zustand messen, nicht erraten.
|
||||
- Aenderungen klein halten und nur den betroffenen Bereich anfassen.
|
||||
- Bestehende Doku und Repo-Konventionen bevorzugen.
|
||||
- Bei Secret-/Runtime-/Komodo-Fragen besonders konservativ sein.
|
||||
- Wenn zwei Reparaturversuche nicht funktionieren: stoppen, Pflichtmatrix ausfuellen, eine abweichende Ebene benennen.
|
||||
+187
-186
@@ -3,7 +3,7 @@
|
||||
> **Single Source of Truth** für Docker-Netzwerkarchitektur, Sicherheitsregeln, Zielbild und Migration des Kallilabcore-Homelabs.
|
||||
> **Arbeitsregel für KI-Assistenten:** Dieses Dokument immer zuerst lesen, bevor Fragen zu Containern, Netzwerken, Traefik, Tailscale, Migration oder Security beantwortet werden.
|
||||
|
||||
**Stand:** 2026-03-29 | **Aktueller Sprint:** 7 (Authelia SSO/2FA) — Sprints 1–6 abgeschlossen
|
||||
**Stand:** 2026-05-23 | **Aktueller Schwerpunkt:** GitOps / Doku-Synchronisierung / Reproduzierbare Deployments
|
||||
|
||||
---
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
6. [Einordnungsschema für neue Container](#6-einordnungsschema-für-neue-container)
|
||||
7. [Container-Zielbild (vollständig)](#7-container-zielbild-vollständig)
|
||||
8. [Traefik-Label-Standard](#8-traefik-label-standard)
|
||||
9. [Migrationsstrategie (Blöcke A–F)](#9-migrationsstrategie-blöcke-af)
|
||||
9. [Historische Migration (abgeschlossen)](#9-historische-migration-abgeschlossen)
|
||||
10. [Bekannte Ausnahmen und Begründungen](#10-bekannte-ausnahmen-und-begründungen)
|
||||
11. [Projektorganisation und Arbeitsmodus](#11-projektorganisation-und-arbeitsmodus)
|
||||
12. [Nutzung mit KI / Kontext-Regel](#12-nutzung-mit-ki--kontext-regel)
|
||||
@@ -30,14 +30,14 @@
|
||||
|---|---|
|
||||
| Host-OS | Unraid |
|
||||
| Hostname | Kallilabcore |
|
||||
| Reverse Proxy | Traefik v3 (100% Docker-Labels, kein File-Provider) |
|
||||
| Reverse Proxy | Traefik v3 (Service-Routing via Docker-Labels, File-Provider fuer Middlewares, TLS und Dashboards) |
|
||||
| VPN / Remote-Zugang | Tailscale (`tailscale`, host-Netz, Git-Stack) |
|
||||
| DNS-Stack | AdGuard Home (`dns_net` + `frontend_net`) → Unbound (`dns_net`) |
|
||||
| Basis-Domain | `kaleschke.info` |
|
||||
| TLS | Let's Encrypt via Cloudflare DNS Challenge |
|
||||
| Certresolver | `le` |
|
||||
| Compose-Standard | Komodo (GitOps, Stack aus Gitea) |
|
||||
| Legacy | Portainer CE (in Ablösung durch Komodo, Sprint 5) |
|
||||
| Legacy | Portainer CE entfernt; Komodo ist alleiniger Stack-Manager |
|
||||
| Homelab-Compose-Pfad | `/mnt/user/services/homelab/` |
|
||||
| Secrets-Pfad | `/mnt/user/appdata/secrets/` |
|
||||
| Grundsatz | Keine neuen Dockerman-Einzelcontainer |
|
||||
@@ -47,26 +47,26 @@
|
||||
## 2. Architektur-Prinzipien
|
||||
|
||||
### P1 — Traefik ist der einzige öffentliche HTTP(S)-Einstiegspunkt
|
||||
Kein Webdienst veröffentlicht finale direkte Host-Ports außer `traefik` selbst. Begründete Ausnahmen: `gitea`-SSH (Port 222), `AdGuard Home` (Port 53/DNS + 3000/Admin), `Tailscale`, `Plex-Media-Server`.
|
||||
Kein Webdienst veröffentlicht finale direkte Host-Ports außer `traefik` selbst. Begründete Ausnahmen: `gitea`-SSH (Port 222), `AdGuard Home` (Port 53/DNS direkt; Admin 8082 nur auf Tailscale-IP `100.80.98.33`), `Tailscale`, `Plex-Media-Server` und `monitoring-influxdb3-core` Port 8181 als LAN-only Writer-Endpunkt fuer Home Assistant.
|
||||
|
||||
### P2 — Das Setup bleibt bewusst einfach: `frontend_net` + `backend_net` + app-interne Netze
|
||||
- `frontend_net` = Proxy-/Web-Netz
|
||||
- `backend_net` = intern für DB/Cache/App-Kommunikation
|
||||
- zusätzliche Netze nur app-intern, wenn technisch nötig (`mealie_mealie_internal`, `immich_default`, `dns_net`)
|
||||
- zusätzliche Netze nur app-intern, wenn technisch nötig (`mealie_internal`, `immich_default`, `dns_net`)
|
||||
|
||||
Es gibt **keine künstlichen globalen Zusatznetze** wie `admin_net`, `monitoring_net` oder `media_net`.
|
||||
Es gibt **keine künstlichen globalen Zusatznetze** wie `admin_net` oder `media_net`. `monitoring_net` ist die dokumentierte Ausnahme fuer den zentralen Observability-Stack.
|
||||
|
||||
### P3 — Datenbanken gehören nie ins `frontend_net`
|
||||
Postgres, Redis und ähnliche Dienste laufen ausschließlich in `backend_net` oder einem eigenen internen Compose-Netz.
|
||||
|
||||
### P4 — Admin-UIs sind nicht öffentlich
|
||||
Komodo, filebrowser, scrutiny, UptimeKuma, code-server, Traefik-Dashboard, backrest, borg-ui und beszel sind standardmäßig **Tailscale-only** oder hinter Traefik **mit zentraler Middleware** abgesichert.
|
||||
filebrowser, scrutiny, code-server, Traefik-Dashboard und borg-ui sind standardmaessig **Tailscale-only** oder hinter Traefik **mit zentraler Middleware** abgesichert. `Komodo` ist die dokumentierte Ausnahme und bleibt bewusst bei nativer Authentifizierung ohne pauschal vorgeschaltete ForwardAuth-Middleware.
|
||||
|
||||
### P5 — Compose-first
|
||||
Alle produktiven Container werden als Compose verwaltet. Bestehende Dockerman-/Ad-hoc-Container werden schrittweise migriert.
|
||||
|
||||
### P6 — Secrets nie im Klartext
|
||||
Passwörter, Tokens und API-Keys gehören in Secret-Dateien unter `/mnt/user/appdata/secrets/` oder als Komodo/Portainer Environment Variables mit `${VARIABLE}` in der Compose.
|
||||
Passwörter, Tokens und API-Keys gehören in Secret-Dateien unter `/mnt/user/appdata/secrets/` oder als Komodo Stack Environment Variables mit `${VARIABLE}` in der Compose.
|
||||
|
||||
### P7 — `restart: unless-stopped` ist Pflichtstandard
|
||||
Jeder produktive Container nutzt `restart: unless-stopped`, außer eine Ausnahme ist dokumentiert.
|
||||
@@ -74,7 +74,7 @@ Jeder produktive Container nutzt `restart: unless-stopped`, außer eine Ausnahme
|
||||
### P8 — Least Privilege
|
||||
- `security_opt: ["no-new-privileges:true"]` standardmäßig ergänzen
|
||||
- `privileged: true` nur mit dokumentierter Begründung
|
||||
- Docker-Socket standardmäßig vorsichtig behandeln; **Komodo/PortainerCE sind dokumentierte Ausnahmen**
|
||||
- Docker-Socket standardmäßig vorsichtig behandeln; **Komodo ist dokumentierte Ausnahme**
|
||||
|
||||
---
|
||||
|
||||
@@ -87,8 +87,12 @@ Jeder produktive Container nutzt `restart: unless-stopped`, außer eine Ausnahme
|
||||
| `frontend_net` | bridge, external | einziges Traefik-/Web-Netz | Standard |
|
||||
| `backend_net` | bridge, `internal: true` | interne App-/DB-/Cache-Kommunikation | Standard |
|
||||
| `dns_net` | bridge | Resolver-Schicht: AdGuard Home + Unbound | bleibt |
|
||||
| `mealie_mealie_internal` | bridge, `internal: true` | internes Netz nur für `mealie` + `mealie-postgres` | ✅ umgesetzt |
|
||||
| `mealie_internal` | bridge, `internal: true` | internes Netz nur für `mealie` + `mealie-postgres` | ✅ umgesetzt |
|
||||
| `immich_default` | Compose-intern, `internal: true` | internes Immich-Netz | ✅ umgesetzt |
|
||||
| `nextcloud_internal` | bridge, `internal: true` | internes Netz nur fuer `nextcloud` + `nextcloud-postgres` + `nextcloud-redis` | ✅ vorbereitet |
|
||||
| `monitoring_net` | Compose-intern, bridge | zentraler Observability-Stack fuer Prometheus, Loki, Grafana, Promtail, Exporter und InfluxDB | Zielzustand |
|
||||
| `monitoring_influx_lan` | Compose-intern, bridge | nicht-oeffentliches Zusatznetz nur fuer Docker Host-Port-Publishing von InfluxDB 8181 | Zielzustand |
|
||||
| `glance_socket_net` | Compose-intern, `internal: true` | interner Zugriff von Glance auf den Docker-Socket-Proxy | umgesetzt |
|
||||
| `host` | host | nur für echte Sonderfälle | begründet |
|
||||
|
||||
### 3.2 Finales Diagramm (vereinfacht)
|
||||
@@ -99,9 +103,10 @@ Internet
|
||||
traefik (80/443)
|
||||
│
|
||||
└── frontend_net
|
||||
├── öffentliche Apps (vaultwarden, mealie, paperless, immich, gitea, ntfy, homepage)
|
||||
├── Admin-UIs mit Middleware (komodo, uptime-kuma, filebrowser, scrutiny, code-server, backrest, borg-ui, beszel)
|
||||
└── Hybrid-Dienste mit Internetbedarf (mail-archiver, ddns-updater)
|
||||
├── öffentliche Apps (vaultwarden, mealie, paperless, immich, gitea, ntfy, mail-archiver, nextcloud)
|
||||
├── geschützte UIs mit Middleware (glance, paperless-gpt, filebrowser, scrutiny, code-server, borg-ui, glances, speedtest, bentopdf, monitoring-grafana)
|
||||
├── Admin-UI mit nativer Auth (komodo)
|
||||
└── Dienste mit Internetbedarf ohne öffentliche UI (ddns-updater)
|
||||
|
||||
backend_net (internal: true)
|
||||
├── postgresql17
|
||||
@@ -114,13 +119,16 @@ dns_net
|
||||
└── unbound
|
||||
|
||||
App-interne Netze
|
||||
├── mealie_mealie_internal (internal: true) ✅
|
||||
└── immich_default (internal: true) ✅
|
||||
├── mealie_internal (internal: true) ✅
|
||||
├── immich_default (internal: true) ✅
|
||||
├── nextcloud_internal (internal: true) ✅
|
||||
├── monitoring_net (zentraler Observability-Stack)
|
||||
└── monitoring_influx_lan (Bridge fuer LAN-Port-Publishing, keine Traefik-Route)
|
||||
|
||||
Host-Sonderfälle
|
||||
├── tailscale
|
||||
├── Plex-Media-Server
|
||||
└── beszel-agent
|
||||
└── Plex-Media-Server
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
@@ -136,21 +144,26 @@ Diese Dienste sind über echte `*.kaleschke.info`-Domains erreichbar:
|
||||
- `ntfy` — ntfy.kaleschke.info
|
||||
- `gitea` (Web) — git.kaleschke.info
|
||||
- `immich_server` — immich.kaleschke.info
|
||||
- `homepage` — homepage.kaleschke.info
|
||||
- `nextcloud` — cloud.kaleschke.info
|
||||
|
||||
### 4.2 Nicht öffentlich / nur Tailscale oder Traefik + Middleware
|
||||
Diese Dienste sind **keine Public Apps**:
|
||||
|
||||
- `Komodo` — komodo.kaleschke.info (Middleware)
|
||||
- `UptimeKuma` — uptime.kaleschke.info (Middleware)
|
||||
- `Komodo` — komodo.kaleschke.info (Traefik, aber bewusst ohne zentrale Middleware; native Auth bleibt aktiv)
|
||||
- `filebrowser` — files.kaleschke.info (Middleware)
|
||||
- `scrutiny` — scrutiny.kaleschke.info (Middleware)
|
||||
- `code-server` — Traefik + Middleware
|
||||
- `beszel` — beszel.kaleschke.info (Middleware ausstehend)
|
||||
- `backrest` — Traefik + Middleware
|
||||
- `borg-ui` — borg.kaleschke.info (Middleware)
|
||||
- `glance` — glance.kaleschke.info (Middleware)
|
||||
- `paperless-gpt` — paperless-gpt.kaleschke.info (Middleware)
|
||||
- `mail-archiver` — mail.kaleschke.info (Middleware + App-Auth)
|
||||
- `glances` — glances.kaleschke.info (Middleware)
|
||||
- `speedtest-tracker` — speedtest.kaleschke.info (Middleware)
|
||||
- `bentopdf` — pdf.kaleschke.info (Middleware)
|
||||
- `monitoring-grafana` — monitoring.kaleschke.info (Middleware)
|
||||
- `hermes-dashboard` — hermes.kaleschke.info (Middleware)
|
||||
- `Traefik-Dashboard`
|
||||
- `AdGuard Home` — Port 3000 direkt (kein Traefik, nur LAN-Zugang)
|
||||
- `AdGuard Home` — Admin-UI auf Port 8082 (`80` im Container), kein Traefik, nur Tailscale-IP `100.80.98.33`; 2026-05-26 bewusst keine 2FA-/Traefik-Umstellung
|
||||
|
||||
### 4.3 Regel
|
||||
Wenn ein Dienst im `frontend_net` hängt, heißt das **nicht automatisch öffentlich**. Admin-Dienste dürfen im `frontend_net` liegen, wenn:
|
||||
@@ -159,6 +172,8 @@ Wenn ein Dienst im `frontend_net` hängt, heißt das **nicht automatisch öffent
|
||||
- keine direkten Host-Ports bestehen
|
||||
- Zugriff durch Tailscale bzw. Auth begrenzt ist
|
||||
|
||||
`Komodo` ist hiervon die dokumentierte Ausnahme: Traefik ja, aber keine pauschale ForwardAuth-Middleware, damit Webhooks, API und Periphery-Kommunikation nicht versehentlich beeintraechtigt werden.
|
||||
|
||||
---
|
||||
|
||||
## 5. Globale Sicherheitsregeln
|
||||
@@ -166,10 +181,10 @@ Wenn ein Dienst im `frontend_net` hängt, heißt das **nicht automatisch öffent
|
||||
1. Keine produktiven Dienste im Docker-Default-`bridge`
|
||||
2. Keine direkten Host-Ports für Web-UIs außer dokumentierte Ausnahmen
|
||||
3. `restart: unless-stopped` als Standard
|
||||
4. Secrets als Datei / `_FILE` oder Komodo/Portainer Environment Variables mit `${VAR}`
|
||||
4. Secrets als Datei / `_FILE` oder Komodo Stack Environment Variables mit `${VAR}`
|
||||
5. `no-new-privileges:true` ergänzen, wo praktikabel
|
||||
6. `traefik.docker.network=frontend_net` immer explizit setzen
|
||||
7. Admin-Dienste immer mit `dashboard-auth@file,secure-headers@file`
|
||||
7. Admin- und interne Web-Dienste standardmaessig mit zentraler Middleware absichern (`authelia@file,secure-headers@file` oder dokumentierte Ausnahme)
|
||||
8. Placeholder-Domains (`yourdomain.tld`) sind verboten
|
||||
9. `privileged: true` nur mit Begründung
|
||||
10. Volume-Mounts so klein und so read-only wie möglich
|
||||
@@ -219,20 +234,18 @@ Legende Status:
|
||||
|
||||
| Container | Status | Soll-Netz(e) | Finaler Zugang | Finaler Sollzustand | Offene Punkte |
|
||||
|---|---|---|---|---|---|
|
||||
| `traefik` | ✅ | `frontend_net`, `backend_net` | öffentlich 80/443 | zentraler Ingress, 100% Docker-Labels | — |
|
||||
| `AdGuard Home` | ✅ | `dns_net` (172.23.0.3), `frontend_net` | Port 53 DNS direkt, Port 3000 Admin (LAN) | DNS-Server + Upstream zu unbound; kein Traefik (DNS-Sonderfall) | Admin-Port per Traefik + Middleware absichern (Block F) |
|
||||
| `traefik` | ✅ | `frontend_net`, `backend_net` | öffentlich 80/443 | zentraler Ingress, Service-Routing via Docker-Labels | — |
|
||||
| `AdGuard Home` | ✅ | `dns_net` (172.23.0.3), `frontend_net` | Port 53 DNS direkt, Port 8082 Admin nur auf Tailscale-IP `100.80.98.33` | DNS-Server + Upstream zu unbound; kein Traefik fuer Admin-UI | Admin-Port bleibt bewusst ohne Traefik/2FA, aber nicht mehr auf allen LAN-Interfaces |
|
||||
| `unbound` | ✅ | `dns_net` | intern | Upstream-Resolver für AdGuard, isoliert | — |
|
||||
| `ddns-updater` | ✅ | `frontend_net` | intern | Cloudflare DNS API; bleibt in `frontend_net` | Dokumentierte Ausnahme |
|
||||
| `tailscale` | ✅ | `host` | VPN-Zugang | Git-Stack (`host-services/tailscale/`) | `TS_USERSPACE`/`privileged` später prüfen |
|
||||
| `backrest` | ✅ | `frontend_net`, `backend_net` | Traefik + Middleware | `traefik.docker.network=frontend_net` korrigiert | Breite Mounts straffen (Block F) |
|
||||
| `homepage` | ✅ | `frontend_net` | Traefik | öffentliche Startseite via `homepage.kaleschke.info` | — |
|
||||
| `tailscale` | ✅ | `host` | VPN-Zugang | Git-Stack (`host-services/tailscale/`) | nutzt `NET_ADMIN`, `NET_RAW` und `/dev/net/tun` als dokumentierte VPN-Ausnahme |
|
||||
|
||||
### 7.2 Sicherheit / Identity
|
||||
|
||||
| Container | Status | Soll-Netz(e) | Finaler Zugang | Finaler Sollzustand | Offene Punkte |
|
||||
|---|---|---|---|---|---|
|
||||
| `vaultwarden` | ✅ | `frontend_net` | Traefik | kein Host-Port, `ADMIN_TOKEN_FILE` | — |
|
||||
| `authelia` | 🔄 | `frontend_net`, `backend_net` | Traefik via `auth.kaleschke.info` | ForwardAuth-Provider, Secrets via `_FILE`, PostgreSQL + Redis Shared | NAS-seitige Einrichtung ausstehend (Secrets, DB, Users, DNS) |
|
||||
| `authelia` | ✅ | `frontend_net`, `backend_net` | Traefik via `auth.kaleschke.info` | aktiver ForwardAuth-Provider, Secrets via `_FILE`, PostgreSQL Storage; bewusst ohne Redis-Session-Backend | — |
|
||||
|
||||
### 7.3 Datenbanken / Caches
|
||||
|
||||
@@ -240,48 +253,58 @@ Legende Status:
|
||||
|---|---|---|---|---|---|
|
||||
| `postgresql17` | ✅ | `backend_net` | intern | kein Host-Port, `POSTGRES_PASSWORD_FILE` | — |
|
||||
| `Redis` | ✅ | `backend_net` | intern | intern-only Cache | optional named volume |
|
||||
| `mealie-postgres` | ✅ | `mealie_mealie_internal` | intern | isoliert, nie `frontend_net` | — |
|
||||
| `mealie-postgres` | ✅ | `mealie_internal` | intern | isoliert, nie `frontend_net` | — |
|
||||
| `immich_postgres` | ✅ | `immich_default` | intern | intern-only | — |
|
||||
| `immich_redis` | ⏳ | `immich_default` | intern | intern-only | anonymes Volume → named volume |
|
||||
| `nextcloud-postgres` | ✅ | `nextcloud_internal` | intern | app-eigene Nextcloud-Datenbank mit `_FILE`-Secret | — |
|
||||
| `nextcloud-redis` | ✅ | `nextcloud_internal` | intern | app-eigener Cache fuer File Locking / Sessions | — |
|
||||
|
||||
### 7.4 Öffentliche Apps
|
||||
### 7.4 Produktive Apps
|
||||
|
||||
| Container | Status | Soll-Netz(e) | Finaler Zugang | Finaler Sollzustand | Offene Punkte |
|
||||
|---|---|---|---|---|---|
|
||||
| `paperless-ngx` | ✅ | `frontend_net`, `backend_net` | Traefik | aktiv via `paperless.kaleschke.info` | — |
|
||||
| `Paperless-AI` | ✅ | `frontend_net` | Traefik | aktiv | — |
|
||||
| `mealie` | ✅ | `frontend_net`, `mealie_mealie_internal` | Traefik | sauber getrennte App/DB-Struktur | — |
|
||||
| `mail-archiver` | ✅ | `frontend_net`, `backend_net` | Traefik + Middleware | aktiv via `mail.kaleschke.info`; IMAP-Abruf + DB-Zugang; App-eigene Auth bleibt zusaetzliche Schutzschicht | — |
|
||||
| `mealie` | ✅ | `frontend_net`, `mealie_internal` | Traefik | sauber getrennte App/DB-Struktur | — |
|
||||
| `ntfy` | ✅ | `frontend_net` | Traefik | aktiv via `ntfy.kaleschke.info`, Git-Stack | — |
|
||||
| `gitea` | ✅ | `frontend_net` | Traefik + SSH-Port 222 | Web via Traefik, SSH direkt gebunden | — |
|
||||
| `immich_server` | ✅ | `immich_default`, `frontend_net` | Traefik | aktiv via `immich.kaleschke.info` | — |
|
||||
| `immich_machine_learning` | ✅ | `immich_default` | intern | bleibt intern | — |
|
||||
| `nextcloud` | ✅ | `frontend_net`, `nextcloud_internal` | Traefik | aktiv via `cloud.kaleschke.info`, nativer Nextcloud-Login, WebDAV/CardDAV faehig | CalDAV/CardDAV-Redirect via Traefik-Labels |
|
||||
| `plex` | ✅ | `host` | Plex native / Host-Netz | Compose-Stack unter `host-services/plex/`; Host-Netz bleibt fuer Discovery / Plex GDM dokumentierte Ausnahme | — |
|
||||
|
||||
### 7.5 Admin / Operations
|
||||
|
||||
| Container | Status | Soll-Netz(e) | Finaler Zugang | Finaler Sollzustand | Offene Punkte |
|
||||
|---|---|---|---|---|---|
|
||||
| `komodo` | ✅ | `frontend_net` | Traefik + Middleware | primärer GitOps-Stack-Manager | — |
|
||||
| `komodo` | ✅ | `frontend_net` | Traefik, native Auth | primaerer GitOps-Stack-Manager | bewusste Ausnahme: keine pauschale ForwardAuth-Middleware vor UI/API/Webhooks/Periphery |
|
||||
| `code-server` | ✅ | `frontend_net` | Traefik + Middleware | `PASSWORD_FILE` aktiv | — |
|
||||
| `PortainerCE` | ⚠️ Legacy | `frontend_net` | Traefik + Middleware | wird durch Komodo abgelöst | abschalten Sprint 5 |
|
||||
| `filebrowser` | ✅ | `frontend_net` | Traefik + Middleware | aktiv via `files.kaleschke.info` | Mounts einschränken (Block F) |
|
||||
| `borg-ui` | 🔄 | `frontend_net` | Traefik + Middleware | Git-Stack für Borg/BorgBase-Backups; Borg UI bündelt Borg-CLI im Container | BorgBase-SSH-Key hinterlegen, erstes Repo initialisieren, Quell-Mounts bei Bedarf gezielt erweitern |
|
||||
| `mail-archiver` | ✅ | `frontend_net`, `backend_net` | intern | IMAP-Abruf + DB-Zugang, kein öffentlicher Zugang | — |
|
||||
| `PortainerCE` | ❌ entfernt | - | - | 2026-03-29 abgeschaltet | historisch; nicht mehr deployen |
|
||||
| `filebrowser` | ✅ | `frontend_net` | Traefik + Middleware | aktiv via `files.kaleschke.info` | Appdata-Breitmount entfernt; nur Documents/Photos/Projekte plus eigener App-State |
|
||||
| `borg-ui` | ✅ | `frontend_net` | Traefik + Middleware | produktiver Borg-/Restore-Dienst; `/local/secrets` ist bewusst Teil des Restore-Scopes | BorgBase-Repo und Key laufend pflegen |
|
||||
| `paperless-gpt` | ✅ | `frontend_net` | Traefik + Middleware | aktiv via `paperless-gpt.kaleschke.info` | — |
|
||||
| `bentopdf` | ✅ vorbereitet | `frontend_net` | Traefik + Middleware | PDF-Tooling via `pdf.kaleschke.info`; browserseitige Verarbeitung, COOP/COEP fuer Office-Konvertierung | Deploy und fachliche Abnahme offen |
|
||||
| `hermes-dashboard` | ✅ | `frontend_net`, `hermes_net` | Traefik + Middleware | aktiv via `hermes.kaleschke.info`; Dashboard bindet intern mit `--insecure` auf `0.0.0.0`, externe Absicherung ueber Authelia | — |
|
||||
|
||||
### 7.6 Monitoring / Status
|
||||
|
||||
| Container | Status | Soll-Netz(e) | Finaler Zugang | Finaler Sollzustand | Offene Punkte |
|
||||
|---|---|---|---|---|---|
|
||||
| `UptimeKuma` | ✅ | `frontend_net` | Traefik + Middleware | aktiv via `uptime.kaleschke.info` | — |
|
||||
| `glance` | ✅ | `frontend_net`, `glance_socket_net` | Traefik + Middleware | einziges Homelab-Dashboard via `glance.kaleschke.info`; Docker-Status nur ueber internen Socket-Proxy | — |
|
||||
| `glances` | ✅ | `frontend_net` | Traefik + Middleware | aktiv via `glances.kaleschke.info` | — |
|
||||
| `scrutiny` | ✅ | `frontend_net` | Traefik + Middleware | aktiv via `scrutiny.kaleschke.info`, Git-Stack | `privileged` später prüfen |
|
||||
| `beszel` | ✅ | `frontend_net` | Traefik | aktiv via `beszel.kaleschke.info`, Git-Stack | Admin-Middleware ergänzen (Block F) |
|
||||
| `beszel-agent` | ✅ | `host` | intern | System-Monitoring, Socket-Zugriff auf Host | — |
|
||||
| `speedtest-tracker` | ✅ | `frontend_net` | Traefik + Middleware | aktiv via `speedtest.kaleschke.info` | — |
|
||||
| `monitoring-grafana` | ✅ | `frontend_net`, `monitoring_net` | Traefik + Middleware | zentrale UI via `monitoring.kaleschke.info`; Datasources fuer Prometheus, Loki und InfluxDB | — |
|
||||
| `monitoring-influxdb3-core` | ✅ | `monitoring_net`, `monitoring_influx_lan` + LAN-Bind | LAN-Port nur fuer interne Writer | InfluxDB 3 Core fuer Home-Assistant-/Ecowitt-Langzeitdaten; keine Traefik-/Public-Freigabe; Port 8181 nur via `INFLUXDB_BIND_IP` | HA-Write-Token und Sensor-Export finalisieren |
|
||||
| `monitoring-loki` | ✅ | `monitoring_net` | intern | interner Container-Logspeicher ohne Public Route; Monitoring-Grafana greift ueber Loki-Datasource zu | Retention/Storage beobachten |
|
||||
| `monitoring-promtail` | ✅ | `monitoring_net` | intern | Docker-Log-Collector mit read-only Docker-Socket-Ausnahme; schreibt nach Loki | Socket-Ausnahme regelmaessig pruefen |
|
||||
| `grafana` / `influxdb3-core` / `loki` / `alloy` | entfernt | - | abgeloest | alte Docker-Runtime frei von Altcontainern; Compose-Pfade am 2026-05-26 aus aktivem Repo entfernt | Rollback nur ueber Git-Historie |
|
||||
|
||||
### 7.7 Sprint 5 — noch zu migrieren / abzuschalten
|
||||
### 7.7 Noch offene Sonderfälle
|
||||
|
||||
| Container | Status | Ziel |
|
||||
|---|---|---|
|
||||
| `Plex-Media-Server` | ⏳ Dockerman | Compose-Migration, `host`-Netz bleibt (Discovery) |
|
||||
| `PortainerCE` | ⚠️ Legacy | abschalten nach vollständiger Komodo-Übernahme |
|
||||
| — | — | Plex ist nicht mehr offen: der Dienst ist als Repo-Compose-Stack unter `host-services/plex/` dokumentiert; `host`-Netz bleibt als Discovery-Ausnahme. |
|
||||
|
||||
### 7.8 Entfernte Container
|
||||
|
||||
@@ -293,13 +316,19 @@ Legende Status:
|
||||
| `diun` | 2026-03-28 | Update-Monitoring via Komodo; Stack + Netz `diun_diun_default` + Repo-Eintrag entfernt |
|
||||
| `binhex-official-pihole` | 2026-03-28 | ersetzt durch AdGuard Home + Unbound |
|
||||
| `gotify` | 2026-03-28 | nicht mehr aktiv; Push-Notifications via ntfy abgedeckt |
|
||||
| `Dozzle` | 2026-03-28 | nicht mehr aktiv; Log-Monitoring via Komodo/beszel |
|
||||
| `dashdot` | 2026-03-28 | nicht mehr aktiv; System-Monitoring via beszel |
|
||||
| `netdata` | 2026-03-28 | nicht mehr aktiv; System-Monitoring via beszel |
|
||||
| `Glances` | 2026-03-28 | nicht mehr aktiv |
|
||||
| `Dozzle` | 2026-03-28 | nicht mehr aktiv |
|
||||
| `dashdot` | 2026-03-28 | nicht mehr aktiv |
|
||||
| `netdata` | 2026-03-28 | nicht mehr aktiv |
|
||||
| `netalertx` | 2026-03-28 | nicht mehr aktiv |
|
||||
| `luckyBackup` | 2026-03-28 | nicht mehr aktiv; Backup via backrest |
|
||||
| `luckyBackup` | 2026-03-28 | nicht mehr aktiv; Backup via Borg |
|
||||
| `backrest` | 2026-05-15 | entfernt; Borg ist die alleinige Backup-Technologie, WD MyBookLive ist kein Backup-Ziel mehr |
|
||||
| `Stash` | 2026-03-28 | nicht mehr aktiv |
|
||||
| `PortainerCE` | 2026-03-29 | abgeschaltet; Komodo ist alleiniger Stack-Manager |
|
||||
| `beszel` | nicht dokumentiert | bereits entfernt; nicht mehr Teil des Zielbilds |
|
||||
| `beszel-agent` | nicht dokumentiert | bereits entfernt; nicht mehr Teil des Zielbilds |
|
||||
| `jellyfin` | 2026-05-25 | doppelter Medienserver neben Plex; Plex bleibt einziger Medienserver |
|
||||
| `homepage` | 2026-05-25 | doppeltes Dashboard neben Glance; Glance bleibt einziges Homelab-Dashboard |
|
||||
| `uptime-kuma` | 2026-05-25 | durch `monitoring-blackbox-exporter`, Prometheus-Alerts und `monitoring-grafana` ersetzt |
|
||||
|
||||
---
|
||||
|
||||
@@ -317,9 +346,9 @@ labels:
|
||||
- traefik.http.services.<name>.loadbalancer.server.port=<interner-port>
|
||||
```
|
||||
|
||||
### Zusatz für Admin-Dienste
|
||||
### Zusatz fuer Admin-Dienste (Standard)
|
||||
```yaml
|
||||
- traefik.http.routers.<name>.middlewares=dashboard-auth@file,secure-headers@file
|
||||
- traefik.http.routers.<name>.middlewares=authelia@file,secure-headers@file
|
||||
```
|
||||
|
||||
### Regeln
|
||||
@@ -328,163 +357,89 @@ labels:
|
||||
- certresolver immer `le`
|
||||
- `tls=true` immer explizit setzen
|
||||
- wenn Traefik aktiv ist, werden direkte Host-Ports entfernt
|
||||
- Admin-Dienste niemals ohne Middleware veröffentlichen
|
||||
- Admin-Dienste standardmaessig nicht ohne Middleware veroeffentlichen
|
||||
- Das Traefik-Dashboard nutzt ebenfalls `authelia@file`; dokumentierte Ausnahmen wie `Komodo` bleiben moeglich
|
||||
- **File-Provider nur noch für:** `middlewares.yml`, `tls.yml`, `dashboards.yml` — keine Service-Routen mehr via File-Provider
|
||||
- dokumentierte Ausnahmen muessen in Abschnitt 10 begruendet werden
|
||||
|
||||
---
|
||||
|
||||
## 9. Migrationsstrategie (Blöcke A–F)
|
||||
## 9. Historische Migration (abgeschlossen)
|
||||
|
||||
**Letzte Aktualisierung:** 2026-03-29
|
||||
Die frühere Blockmigration aus der Portainer-/Dockerman-Phase ist fachlich abgeschlossen.
|
||||
|
||||
### Block A — Quick Wins ✅ ABGESCHLOSSEN
|
||||
```text
|
||||
[x] restart: unless-stopped für alle Container gesetzt
|
||||
[x] vaultwarden ADMIN_TOKEN-Doppelpräfix korrigiert
|
||||
[x] backrest DNS-Hardcoding entfernt
|
||||
[x] leere Netzwerke entfernt: br0, immich_net, kopia_default, netbox_default, diun_default
|
||||
[x] anonyme/verwaiste Volumes bereinigt
|
||||
[x] scanopy komplett entfernt (3 Container + 2 Volumes + Netz)
|
||||
[x] binhex-official-pihole entfernt → ersetzt durch AdGuard Home + Unbound
|
||||
```
|
||||
Dieser Abschnitt dient nur noch als **historischer Vermerk**:
|
||||
|
||||
### Block B — Kritische Kernmigrationen ✅ ABGESCHLOSSEN
|
||||
```text
|
||||
[x] vaultwarden - frontend_net, Host-Port entfernt, ADMIN_TOKEN_FILE, Traefik aktiv
|
||||
[x] postgresql17 - Port 5432 entfernt, nur backend_net, POSTGRES_PASSWORD_FILE
|
||||
[x] mealie-postgres - aus frontend_net raus, nur mealie_mealie_internal
|
||||
```
|
||||
- Traefik läuft labelbasiert ohne Service-Routen im File-Provider.
|
||||
- Komodo ist der einzige aktive Stack-Manager.
|
||||
- Portainer CE ist entfernt.
|
||||
- Borg/Borg UI, Dump-Automatisierung und Restore-Test sind produktiv eingeführt.
|
||||
- Frühere Sprint-/Block-Checklisten werden hier **nicht mehr operativ gepflegt**.
|
||||
|
||||
### Block C — Frontend-Stack finalisieren ✅ ABGESCHLOSSEN
|
||||
```text
|
||||
[x] ntfy - Git-Stack - ntfy.kaleschke.info - Traefik aktiv
|
||||
[x] paperless-ngx - traefik.enable=true - paperless.kaleschke.info - Port entfernt - tls=true
|
||||
[x] Paperless-AI - traefik.enable=true - aktiv
|
||||
[x] PortainerCE - traefik.enable=true - Middleware aktiv - direkte Ports entfernt
|
||||
[x] UptimeKuma - traefik.enable=true - uptime.kaleschke.info - Port entfernt - Middleware aktiv
|
||||
[x] filebrowser - frontend_net - traefik.enable=true - files.kaleschke.info - Port entfernt - Middleware aktiv
|
||||
[x] scrutiny - frontend_net - traefik.enable=true - scrutiny.kaleschke.info - Git-Stack
|
||||
[x] gitea - traefik.enable=true - git.kaleschke.info - SSH-Port 222 bleibt (Ausnahme dokumentiert)
|
||||
[x] backrest - traefik.docker.network=frontend_net korrigiert (war backend_net — Routing-Bug)
|
||||
[x] Traefik File-Provider bereinigt - immich.yml, gitea.yml, mealie.yml, scrutiny.yml, vaultwarden.yml.bak gelöscht
|
||||
[x] immich Bad Gateway behoben - Traefik nutzt jetzt immich@docker statt immich@file
|
||||
[x] AdGuard Home - Git-Stack - dns_net + frontend_net - Port 53 (DNS) + 3000 (Admin)
|
||||
[x] beszel - Git-Stack - frontend_net - beszel.kaleschke.info - Traefik aktiv
|
||||
[ ] beszel - Admin-Middleware (dashboard-auth@file) ergänzen
|
||||
```
|
||||
Für den laufenden Betrieb gilt stattdessen:
|
||||
|
||||
### Block D — Dockerman-Container in Git-Stacks
|
||||
```text
|
||||
[x] vaultwarden ✅
|
||||
[x] postgresql17 ✅
|
||||
[x] mail-archiver ✅
|
||||
[x] scrutiny ✅
|
||||
[x] filebrowser ✅
|
||||
[x] tailscale ✅
|
||||
[x] AdGuard Home ✅
|
||||
[x] beszel ✅
|
||||
[x] ntfy ✅
|
||||
[x] homepage ✅
|
||||
[ ] Plex-Media-Server (Sprint 5)
|
||||
```
|
||||
|
||||
### Block E — Secrets-Migration
|
||||
```text
|
||||
[x] vaultwarden → ADMIN_TOKEN_FILE ✅
|
||||
[x] postgresql17 → POSTGRES_PASSWORD_FILE ✅
|
||||
[x] mail-archiver → Stack ENV (${MAILARCHIVER_AUTH_PASSWORD}) ✅
|
||||
[x] mealie → Stack ENV (kein _FILE-Support) ✅
|
||||
[x] mealie-postgres → Stack ENV (kein _FILE-Support) ✅
|
||||
[x] paperless-ngx → Stack ENV (${PAPERLESS_DBPASS}) ✅
|
||||
[x] code-server → PASSWORD_FILE ✅
|
||||
[x] immich_server → Stack ENV (${IMMICH_DB_PASSWORD}) ✅
|
||||
[x] immich_postgres → POSTGRES_PASSWORD_FILE ✅
|
||||
[ ] immich_redis → anonymes Volume → named volume
|
||||
```
|
||||
|
||||
### Block F — Feinschliff / Hardening
|
||||
```text
|
||||
[x] immich_default - internal: true gesetzt (2026-03-29)
|
||||
[x] PortainerCE - abgeschaltet (Sprint 5, 2026-03-29)
|
||||
[ ] immich_redis - anonymes Volume → named volume in Compose
|
||||
[ ] immich_server - anonymes Volume prüfen und benennen
|
||||
[ ] backrest - /mnt/user doppelt gemountet (ro + rw) - rw-Mount auf konkrete Pfade einschränken
|
||||
[ ] filebrowser - /mnt/user:/srv ist sehr breit - auf /mnt/user/documents:/srv einschränken wenn möglich
|
||||
[ ] Redis - optional named volume
|
||||
[ ] scrutiny - später prüfen, ob privileged reduziert werden kann
|
||||
[ ] tailscale - TS_USERSPACE/privileged bereinigen wenn möglich
|
||||
[ ] beszel - Admin-Middleware (dashboard-auth@file) ergänzen
|
||||
[ ] AdGuard Home - Admin-Port 3000 per Traefik + Middleware absichern (aktuell direkter Port)
|
||||
```
|
||||
|
||||
### Block G — Authelia SSO/2FA (Sprint 7)
|
||||
```text
|
||||
[x] security/authelia/docker-compose.yml im Repo (2026-03-29)
|
||||
[x] security/authelia/configuration.yml im Repo (2026-03-29)
|
||||
[x] traefik/dynamic/middlewares.yml - authelia ForwardAuth Middleware ergänzt (2026-03-29)
|
||||
[ ] NAS: Secrets anlegen (jwt_secret, session_secret, storage_encryption_key, postgres_password)
|
||||
[ ] NAS: Authelia PostgreSQL-User und -Datenbank anlegen
|
||||
[ ] NAS: /mnt/user/appdata/authelia/config/configuration.yml aus Repo übernehmen
|
||||
[ ] NAS: users_database.yml mit gehashten Passwörtern anlegen
|
||||
[ ] NAS: DNS-Eintrag auth.kaleschke.info in AdGuard setzen
|
||||
[ ] Komodo: Stack security/authelia deployen
|
||||
[ ] Services schrittweise mit authelia@docker Middleware absichern
|
||||
```
|
||||
|
||||
---
|
||||
- Zielbild und Architektur in diesem Dokument
|
||||
- Git-/Komodo-Ablauf in `docs/WORKFLOW.md`
|
||||
- fachliche Änderungen in der jeweils betroffenen Stack-Doku
|
||||
- Entscheidungen und besondere Umstellungen im Entscheidungs-Log unten
|
||||
|
||||
## 10. Bekannte Ausnahmen und Begründungen
|
||||
|
||||
| Container | Ausnahme | Begründung |
|
||||
|---|---|---|
|
||||
| `traefik` | Host-Ports 80/443 | zentraler Reverse Proxy |
|
||||
| `tailscale` | `host` | VPN-Zugang; Umstellung nur kontrolliert möglich |
|
||||
| `AdGuard Home` | Port 53 (TCP/UDP) direkt + Port 3000 Admin | DNS benötigt direkten Port 53; kein HTTP-Proxy für DNS möglich |
|
||||
| `tailscale` | `host`, `NET_ADMIN`, `NET_RAW`, `/dev/net/tun` | VPN-Zugang benoetigt Kernel-Netzwerkfunktionen; Umstellung nur kontrolliert moeglich |
|
||||
| `AdGuard Home` | Port 53 (TCP/UDP) direkt + `100.80.98.33:8082` auf Container-Port 80 | DNS benoetigt direkten Port 53; Admin-Port 8082 bleibt bewusst ohne Traefik/2FA, aber nur via Tailscale |
|
||||
| `Plex-Media-Server` | `host` | Discovery / mDNS / Plex GDM |
|
||||
| `scrutiny` | `privileged: true` | SMART-Datenzugriff auf Laufwerke |
|
||||
| `beszel-agent` | `host` | direkter Host-Zugriff für System-Monitoring nötig |
|
||||
| `Komodo` | Docker-Socket Zugriff | Stack-Deployments benötigen Socket |
|
||||
| `PortainerCE` | Docker-Socket | Legacy-UI; wird durch Komodo abgelöst |
|
||||
| `glance-docker-socket-proxy` | Docker-Socket read-only | Glance benoetigt Containerstatus; Zugriff wird ueber einen internen Socket-Proxy auf lesende Docker-API-Endpunkte begrenzt und nicht ins `frontend_net` gelegt |
|
||||
| `Komodo` | keine pauschale zentrale Middleware | Webhooks (`/listener`), API und Periphery-WebSocket (`/ws/periphery`) sollen nicht durch vorgeschaltete ForwardAuth gebrochen werden |
|
||||
| `gitea` | SSH-Port 222 direkt gebunden | Git-SSH-Zugang; kein HTTP-Proxy für SSH möglich |
|
||||
| `ddns-updater` | bleibt in `frontend_net` statt `backend_net` | braucht Cloudflare-API-Zugang; `backend_net` ist `internal: true` |
|
||||
| `mail-archiver` | `frontend_net` + `backend_net` | braucht Internetzugang für IMAP-Abruf (GMX, Gmail) und DB-Zugang |
|
||||
| `traefik/dynamic/*` | manueller Host-Sync trotz GitOps | File-Provider bleibt bewusst fuer `middlewares.yml`, `tls.yml` und `dashboards.yml`; Komodo deployed diese Dateien nicht automatisch |
|
||||
| `nextcloud` | keine zentrale ForwardAuth-Middleware | Nextcloud bringt eigene Auth, Clients und WebDAV/CardDAV-Endpunkte mit; Traefik bleibt Reverse Proxy, Auth bleibt app-nativ |
|
||||
| `monitoring-influxdb3-core` | Host-Port 8181 auf LAN-IP; `user: "0"` | Home Assistant laeuft in einer VM ausserhalb des Compose-Netzes und muss Metriken schreiben koennen; keine Traefik-Route, kein `frontend_net`, Zugriff nur ueber Token und LAN-IP `INFLUXDB_BIND_IP`; InfluxDB 3 Core benoetigt im aktuellen Container-Setup Root-Rechte fuer den lokalen Object-Store-Pfad im named volume |
|
||||
| `monitoring-promtail` | Docker-Socket read-only | Docker-Log-Discovery fuer Loki; keine Schreibrechte, keine Appdaten-Persistenz ueber den Socket |
|
||||
|
||||
---
|
||||
|
||||
## 11. Projektorganisation und Arbeitsmodus
|
||||
|
||||
### 11.1 Unser Arbeitsprinzip
|
||||
Dieses Projekt wird **blockweise** umgesetzt, nicht wild containerweise.
|
||||
Dieses Projekt wird heute nicht mehr sprintweise im Dokument gesteuert, sondern über einen stabilen GitOps-Betrieb.
|
||||
|
||||
### 11.2 Reihenfolge der Umsetzung
|
||||
|
||||
| Sprint | Inhalt | Status |
|
||||
|---|---|---|
|
||||
| Sprint 1 | Quick Wins + `vaultwarden` | ✅ Abgeschlossen |
|
||||
| Sprint 2 | `postgresql17` + `diun/gotify` | ✅ Abgeschlossen |
|
||||
| Sprint 3 | `mealie` / `mealie-postgres` + `mail-archiver` | ✅ Abgeschlossen |
|
||||
| Sprint 4 | Frontend-Stack (`paperless`, `PortainerCE`, `Dozzle`, `dashdot`, `scrutiny`, `filebrowser`, `gitea`, `UptimeKuma`, `ntfy`, `beszel`) + Traefik File-Provider Bereinigung + Komodo Einführung + AdGuard Home Migration + Pi-hole Ablösung | ✅ Abgeschlossen |
|
||||
| Sprint 5 | `Plex-Media-Server` Compose-Migration + `PortainerCE` abschalten | ✅ Abgeschlossen |
|
||||
| Sprint 6 | Hardening / Secrets / Volumes / Sonderfälle (`immich_default` ✅, Volumes, Mounts, AdGuard Traefik) | ✅ Abgeschlossen |
|
||||
| Sprint 7 | `Authelia` SSO/2FA: Compose + Config im Repo, Traefik ForwardAuth Middleware, NAS-seitige Einrichtung | 🔄 In Bearbeitung |
|
||||
| Sprint 8 | `borg-ui` Git-Stack + BorgBase Offsite-Backup-Workflow | 🔄 In Bearbeitung |
|
||||
### 11.2 Operativer Ablauf
|
||||
1. Zielbild prüfen
|
||||
2. lokal synchronisieren
|
||||
3. gezielt ändern
|
||||
4. Commit + Push
|
||||
5. Komodo-Webhook und Ergebnis prüfen
|
||||
6. Dokumentation nachziehen
|
||||
|
||||
### 11.3 Regel für jede Änderung
|
||||
1. Zielbild in diesem Dokument prüfen
|
||||
2. nur den aktuellen Block anfassen
|
||||
3. Compose-Datei ändern
|
||||
4. deployen
|
||||
5. testen
|
||||
6. dokumentieren / abhaken
|
||||
7. erst dann nächster Schritt
|
||||
2. nur den betroffenen Bereich anfassen
|
||||
3. Änderung lokal vorbereiten
|
||||
4. nach Gitea pushen
|
||||
5. automatische Reaktion von Komodo beachten
|
||||
6. testen
|
||||
7. dokumentieren
|
||||
|
||||
### 11.4 Source-of-Truth-Hierarchie
|
||||
1. **Dieses Dokument**
|
||||
2. Compose-Dateien im Git-Repo
|
||||
3. operative Checklisten
|
||||
4. ad-hoc Notizen / Chat
|
||||
1. **Gitea Online (`origin/master`)**
|
||||
2. lokaler Clone / GitHub Desktop
|
||||
3. Compose-Dateien im Git-Repo
|
||||
4. Komodo als Deploy-Consumer
|
||||
5. operative Checklisten und Notizen
|
||||
|
||||
---
|
||||
### 11.5 Operativer Git-Workflow
|
||||
- Gitea Online ist der verbindliche Sollzustand.
|
||||
- Lokal wird standardmäßig über GitHub Desktop gearbeitet.
|
||||
- Komodo deployt aus Gitea und ist kein Bearbeitungsort.
|
||||
- Webhooks sind aktiv: Ein Push kann unmittelbar einen Komodo-Deploy auslösen.
|
||||
- Wenn online in Gitea editiert wurde, muss vor der nächsten lokalen Änderung zuerst `Fetch origin` und danach `Pull origin` erfolgen.
|
||||
|
||||
## 12. Nutzung mit KI / Kontext-Regel
|
||||
|
||||
@@ -518,16 +473,25 @@ Komodo ist nun der primäre GitOps-Stack-Manager:
|
||||
- **Komodo Core** läuft als Docker-Stack (`ops/komodo/docker-compose.yml`)
|
||||
- **Komodo Periphery** läuft auf dem Unraid-Host für direktes Server-Management
|
||||
- Stacks werden via Gitea synchronisiert und über Komodo deployed
|
||||
- Portainer CE läuft noch als Legacy-UI und wird in Sprint 5 abgeschaltet
|
||||
- Portainer CE ist abgeschaltet; Komodo ist der alleinige aktive Stack-Manager
|
||||
|
||||
**Vorteil gegenüber Portainer:** Sauberer GitOps-Flow ohne Web-Editor; alle Stack-Änderungen laufen über Git.
|
||||
**Betriebsregel:** Alle Stack-Änderungen laufen über Git; Komodo konsumiert nur den Stand aus Gitea.
|
||||
|
||||
**Zugangsregel:** Komodo bleibt bewusst bei nativer Authentifizierung ohne pauschal vorgeschaltete ForwardAuth-Middleware vor dem gesamten Router. Hintergrund sind die gemischten UI-, API-, Webhook- und Periphery-Endpunkte unter derselben Domain.
|
||||
|
||||
### Komodo Self-Stack Drift-Recovery (2026-05-04)
|
||||
- Befund: `komodo-core` und `komodo-periphery` liefen aus temporaeren `/tmp/*repair.yml`-Dateien, waehrend `komodo-mongo` auf den fehlenden persistenten Pfad `/mnt/user/services/stacks/komodo/compose.yaml` verwies.
|
||||
- Recovery: Repair-YAMLs und Runtime-ENV wurden unter `/mnt/user/appdata/komodo/_drift_backup_2026-05-04/` gesichert; eine zusaetzliche Recovery-ENV liegt unter `/mnt/user/appdata/secrets/_komodo_stack_env_recovery_2026-05-04.env` und ist als temporaeres Tier-1-Secret-Material zu behandeln.
|
||||
- Der persistente Self-Stack wurde unter `/mnt/user/services/stacks/komodo/compose.yaml` aus `ops/komodo/docker-compose.yml` wiederhergestellt. Die hostseitige `.env` bleibt ausserhalb von Git.
|
||||
- Reconcile-Regel: Bei Self-Stack-Drift keinen pauschalen `docker compose up -d` ausfuehren, wenn der Dry-run `komodo-mongo` recreaten wuerde. Core und Periphery koennen gezielt mit `--no-deps` neu erstellt werden, Mongo bleibt dabei unangetastet.
|
||||
- Ergebnis: Alle drei Komodo-Container zeigen wieder auf `/mnt/user/services/stacks/komodo/compose.yaml`; Mongo blieb waehrend der Rueckfuehrung healthy.
|
||||
|
||||
### AdGuard Home — Ablösung von Pi-hole (2026-03-28)
|
||||
`binhex-official-pihole` wurde entfernt und durch `AdGuard Home` + `unbound` ersetzt:
|
||||
- AdGuard läuft als Git-Stack (`host-services/Adguard/docker-compose.yml`)
|
||||
- Netzwerke: `dns_net` (feste IP 172.23.0.3) + `frontend_net`
|
||||
- Port 53 (DNS) direkt gebunden — dokumentierte Ausnahme
|
||||
- Port 3000 (Admin-UI) direkt gebunden — Traefik-Absicherung ausstehend (Block F)
|
||||
- Admin-UI direkt gebunden via Tailscale-IP `100.80.98.33:8082` auf Container-Port 80 — 2026-05-26 bewusst als einfache Operator-Entscheidung ohne Traefik-/2FA-Umstellung
|
||||
- `unbound` läuft weiterhin als Upstream-Resolver in `dns_net`
|
||||
|
||||
### diun — Entfernung (2026-03-28)
|
||||
@@ -551,16 +515,18 @@ Update-Monitoring kann über Komodo's eingebaute Update-Notifications abgedeckt
|
||||
- Durchgeführt via: manuelles `docker stop` der Containers → `docker network rm immich_default` → Komodo Redeploy
|
||||
- Ergebnis: alle Immich-Container (`immich_postgres`, `immich_redis`, `immich_machine_learning`) sind jetzt vom Internet isoliert; nur `immich_server` hat zusätzlich `frontend_net` für Traefik
|
||||
|
||||
### Secrets in Komodo / Portainer Stacks
|
||||
### Secrets in Komodo Stacks
|
||||
Host-Pfade in `env_file` (z.B. `/mnt/...`) sind in Git-Stacks nicht verfügbar. Standardlösung: Stack Environment Variables + `${VARIABLE_NAME}` in der Compose.
|
||||
|
||||
**Regel:** Wenn `_FILE` nicht unterstützt wird → Stack Environment Variable. Kein Secret im Git.
|
||||
|
||||
**Bewusste Ausnahme:** `paperless-ngx` bleibt fuer `PAPERLESS_DBPASS` und `PAPERLESS_REDIS` vorerst bei Stack Environment Variables. Eine Umstellung auf `_FILE` ist fachlich denkbar, wird aber nicht gegen den aktuell stabilen Produktionsstand erzwungen.
|
||||
|
||||
### Borg UI / BorgBase (2026-04-12)
|
||||
- `borg-ui` läuft als Admin-Dienst in `ops/borg-ui/docker-compose.yml`
|
||||
- nur `frontend_net`, weil Web-UI + externer SSH-Zugang zu BorgBase benötigt werden
|
||||
- keine direkten Host-Ports; Zugriff ausschließlich via Traefik + Middleware über `borg.kaleschke.info`
|
||||
- Mounts bewusst klein gehalten: `/mnt/user/appdata` read-only als erste Backup-Quelle, separates Restore-Ziel unter `/mnt/user/appdata/borg-ui/restore`
|
||||
- breite Restore-/Backup-Mounts bewusst gesetzt; inklusive `/local/secrets` fuer Disaster Recovery, separates Restore-Ziel unter `/mnt/user/appdata/borg-ui/restore`
|
||||
- kein separater Borg-CLI-Container nötig, da Borg UI die Borg-CLI bereits im Container mitbringt
|
||||
|
||||
| Container | `_FILE` Support |
|
||||
@@ -569,20 +535,55 @@ Host-Pfade in `env_file` (z.B. `/mnt/...`) sind in Git-Stacks nicht verfügbar.
|
||||
| PostgreSQL | ✅ ja |
|
||||
| code-server | ✅ ja (`PASSWORD_FILE`) |
|
||||
| Immich Postgres | ✅ ja (`POSTGRES_PASSWORD_FILE`) |
|
||||
| Mealie | ❌ nein → Stack ENV |
|
||||
| Mealie | ✅ ja (`POSTGRES_PASSWORD_FILE`) |
|
||||
| paperless-ngx | ❌ nein für DB-Pass → Stack ENV |
|
||||
|
||||
### Reproduzierbare Deployments (2026-04-17)
|
||||
Mutable Tags wie `latest`, `stable`, `release` oder reine Major-Tags wurden auf die **aktuell laufenden Digests** eingefroren. Das ist bewusst **kein Upgrade-Mechanismus**, sondern dient dazu, den heute funktionierenden Laufzeitstand exakt im Repo festzuhalten. Echte Versions-Upgrades bleiben ein eigener, geplanter Schritt.
|
||||
|
||||
### Stateful Digest-Pinning (2026-05-05, ergaenzt 2026-05-16)
|
||||
- Tier-1/stateful Basisdienste werden bevorzugt mit sprechendem Minor-/Patch-Tag plus Digest gepinnt, z. B. `postgres:17.9@sha256:...` oder `mongo:7.0.32@sha256:...`.
|
||||
- Redis-Caches sind seit dem Hardening-Sprint 2026-05-16 auf `redis:7.4-alpine@sha256:...` vereinheitlicht. Updates erfolgen bewusst stackweise mit Smoke-Test.
|
||||
- Bereits versionierte Apps koennen optional spaeter ebenfalls Digests erhalten; dieser Schritt ist getrennt vom Datenhalter-Pinning.
|
||||
|
||||
### Nextcloud und Stirling-PDF (2026-04-19)
|
||||
- `nextcloud` wird bewusst **nicht** als AIO-Stack gebaut, sondern als klassischer Docker-Microservice-Stack mit eigenem PostgreSQL und eigenem Redis. Das passt besser zum bestehenden GitOps-/Compose-Modell des Repos.
|
||||
- `nextcloud` bleibt bei nativer App-Authentifizierung ohne zentrale ForwardAuth-Middleware vor dem Router, damit Browser-Login, Desktop-/Mobile-Clients sowie WebDAV/CardDAV sauber funktionieren.
|
||||
- `stirling-pdf` wird als geschuetzter Tool-Stack hinter `authelia@file,secure-headers@file` betrieben; die interne Stirling-Login-Funktion bleibt deaktiviert, um Doppel-Login zu vermeiden.
|
||||
|
||||
### BentoPDF und Monitoring-Zielstack (2026-04-30, aktualisiert 2026-05-17)
|
||||
- `bentopdf` ersetzt repo-seitig `stirling-pdf` auf der bestehenden Domain `pdf.kaleschke.info`, bleibt aber bis zum bewussten Komodo-Deploy nur vorbereitet.
|
||||
- BentoPDF benoetigt fuer Office-Konvertierung die Cross-Origin-Isolation-Header `Cross-Origin-Opener-Policy: same-origin` und `Cross-Origin-Embedder-Policy: require-corp`; diese werden per Traefik-Docker-Middleware gesetzt.
|
||||
- `monitoring/` ist der zentrale Zielstack fuer Prometheus, Loki, Promtail, Grafana, node-exporter, cAdvisor und InfluxDB 3 Core.
|
||||
- `monitoring-grafana` wird als geschuetztes Monitoring-UI unter `monitoring.kaleschke.info` betrieben.
|
||||
- `monitoring-influxdb3-core` bleibt ohne Traefik-/Public-Route; fuer interne Writer wie Home Assistant kann Port `8181` per `INFLUXDB_BIND_IP` auf eine LAN-Adresse gebunden werden.
|
||||
- Fuer dieses Port-Publishing nutzt `monitoring-influxdb3-core` zusaetzlich `monitoring_influx_lan`. Das ist keine Public-App-Freigabe und ersetzt nicht die Token-Authentifizierung.
|
||||
- InfluxDB 3 Core nutzt einen festen Versionstag statt `latest`, weil der InfluxDB-`latest`-Tag versionsstrategisch im Umbruch ist.
|
||||
- Die alten Pfade `ops/grafana-influxdb` und `ops/loki` wurden am 2026-05-26 aus dem aktiven Repo entfernt; `monitoring/` ist der einzige Observability-Zielstack.
|
||||
- Uptime Kuma wurde nach erfolgreichem Blackbox-/Grafana-Smoke-Test entfernt; `monitoring/` ist die Quelle fuer HTTP-Erreichbarkeit und Alerts.
|
||||
|
||||
### Monitoring-Logging-Baseline (2026-05-17)
|
||||
- `monitoring-loki` laeuft intern auf `monitoring_net`, ohne Traefik-Route und ohne Host-Port.
|
||||
- `monitoring-promtail` sammelt Docker-Logs ueber `/var/run/docker.sock:ro` und `/var/lib/docker/containers:ro` und schreibt sie an Loki.
|
||||
- `monitoring-grafana` bekommt provisionierte Datasources fuer Prometheus, Loki und InfluxDB 3 Core.
|
||||
- Loki-Logdaten sind Diagnosematerial mit begrenzter Retention, keine primaere Restore-Quelle.
|
||||
|
||||
### Authelia ohne Redis-Session-Backend (2026-05-04)
|
||||
- Authelia nutzt PostgreSQL fuer persistente Storage-Daten, aber bewusst kein Redis-Session-Backend.
|
||||
- Das haelt den Tier-1-Auth-Pfad einfacher; nach einem Authelia-Restart muessen aktive Sessions neu aufgebaut werden.
|
||||
- `infra/redis` bleibt shared Cache fuer Dienste wie Paperless, ist aber keine Authelia-Abhaengigkeit.
|
||||
|
||||
### ddns-updater — Netz-Ausnahme
|
||||
Bleibt bewusst in `frontend_net` statt `backend_net`, weil `backend_net` `internal: true` ist und ddns-updater die Cloudflare-API erreichen muss.
|
||||
|
||||
### mail-archiver — Hybrid-Dienst
|
||||
Benötigt `backend_net` (PostgreSQL) + `frontend_net` (IMAP-Abruf von GMX/Gmail). Kein reiner Backend-Dienst. Kein öffentlicher Traefik-Zugang.
|
||||
Benötigt `backend_net` (PostgreSQL) + `frontend_net` (IMAP-Abruf von GMX/Gmail). Kein reiner Backend-Dienst. Die Web-UI ist via Traefik unter `mail.kaleschke.info` erreichbar und wird durch `authelia@file,secure-headers@file` plus App-eigene Auth geschuetzt.
|
||||
|
||||
### Netzwerk-Standard für Apps mit Datenbanken
|
||||
- App → `frontend_net` + internes Netzwerk
|
||||
- Datenbank → nur internes Netzwerk (`internal: true`)
|
||||
|
||||
Beispiel (Mealie): `mealie` → `frontend_net` + `mealie_mealie_internal`, `mealie-postgres` → nur `mealie_mealie_internal`.
|
||||
Beispiel (Mealie): `mealie` → `frontend_net` + `mealie_internal`, `mealie-postgres` → nur `mealie_internal`.
|
||||
|
||||
---
|
||||
|
||||
@@ -591,4 +592,4 @@ Beispiel (Mealie): `mealie` → `frontend_net` + `mealie_mealie_internal`, `meal
|
||||
Dieses Dokument ist keine lose Notiz, sondern das **operative Masterdokument** für die Docker- und Zugriffsarchitektur des Homelabs.
|
||||
|
||||
**Zielbild in einem Satz:**
|
||||
`frontend_net` für alle Web-UIs und Dienste mit Internetbedarf, `backend_net` für interne Backends, app-interne Netze nur wenn technisch nötig, Tailscale für Remote-Admin-Zugriff, Traefik als einziger Web-Einstieg (100% Docker-Labels), Komodo als GitOps-Stack-Manager, AdGuard Home + Unbound für DNS, keine produktiven `bridge`-Container mehr.
|
||||
`frontend_net` für Web-UIs und Dienste mit Internetbedarf, `backend_net` für interne Backends, app-interne Netze nur wenn technisch nötig, Tailscale für Remote-Admin-Zugriff, Traefik als einziger Web-Einstieg (Service-Routing via Docker-Labels, File-Provider nur für zentrale Dynamic-Config), Komodo als GitOps-Stack-Manager, AdGuard Home + Unbound für DNS, keine produktiven Container im Docker-Default-`bridge`.
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
%PDF-1.4
|
||||
%“Œ‹ž ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 6 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 14 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Author (Claude Sonnet - Read-Only Audit) /CreationDate (D:20260505184207+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260505184207+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (KalliLab CORE GitOps & Design Review) /Title (Homelab Audit 2026-05-05) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Count 7 /Kids [ 4 0 R 5 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2097
|
||||
>>
|
||||
stream
|
||||
Gau0D?#SK;&q0MXR&@eM6CQ)Y-0_n7c:=A":+[7c/cl`O30H1o89me\Y]50j4\LnOR7a`2j]I9!jmLEa3BK#Y6U;2GUA6lT'>;,WZaYO>9@n4t^?sDB7s]6)EcY[t8&S<PmQ5I%fHn3jag!kuHs;8q90jQMDAYnW:2#DL#,I[L5G-S'&4N`t[VN=^A:($B6db@H,<N]bDLZ=]Od*`OpI>kBU">9Pi3Q_H'40>YGBOoKan)>^gPr=,TcFu>imo56nB;F9hpVEZiik_UZingCYo=(.mFW/VV!Bn/IRpb<I8$O1bN77nZKQ_Ajp]./FfHp3*EQE>JDV/brKXO82VNm0"^lY-3taP^<AB?HN^#dS[mdk[$5nKW!$oe>#8Tk`0/1l;1UbAu7cW0+Qe^_bh8h9jg)=DuRa6,^2._GQ^H+RtW#KI@N#'gN:ZG9gkit_,^E]EK-_^!.Fh&sc-bA56/TSG^>K(e^JE-G-3.$qX!GUK2NOue3',O5?YKmI:8H5O.XP!#&J0_+:R:l8=oY)`SF)%A#[oi`Eqf;OGXqR(LH1;$=,.*%GbC:M.B89n8GSEP3^Yn6/mml/Y^9]]R_Vb_Df<<58i?C;aT\AcRqMoQ]m/_)R)EY<FRRqbo4"c_>2<?pN<Lke_rne!DLP0n\%$T\QF.OQjckurs,ht4Em\MZE4Z`^u6U;;Ta7ct5R"a`cXSM6+4'1n)KX)q3`c`'8&I'+I%dNcmo"W*<51]la?GhV9I.'G)+6L\_;e"&`ignY_kb'*HaSiVim_e;DG^KhLhA!=j.OpR<.-3<t>Wl&V[qUh%c?NSqR@5_,gsiF'$!cSQ3lt?dE)i2=Ws*8Y4L(J*9?k"A(Q-sd[OtPTTJi4\8X6u;V]/pc\L^aCF-aM8'^IE0<P@P-8h)s"gEOg5.*,>pTK5k_<TGGO?icPqq<u**O'A`E'I'JLl7)&$U^>@(=4[9GKjqj</m?bih((GJ(:mn+4f:4gJ)G,Q*DE;]VIlAZUP?V6<3r=i*l(gA._u0[XZ<Fi2,2rBEWZUe(L1!P]<S43'T(dK>qJ'8$-nV2_k<+_Q]tSCHN\`rTZ8oAoYRWY@.)3rX[Q/hCW[<^/K;c4SFS!Wmcc6R:1\!X9K@O(Zcma=bD\=(idpD!*o>ks;KF2"EGc8s"FIU#GbM"Hj5?)$bu*(QlaoPdCt,PnC+RB>PM,LZBs_N@&bZOiDg5P&>4\bg"<`Tt*%T9:+W&WNQe%"".:`VZ$%D7H>r;SCB_B>0rc-?^]$#2,c1&7V-ZqCfOj9X&.9jm_8H=]PY4%PBiZa1i9U=E\p/j@aKO*J_GIq*mhl]/>Rl(70rHlX1P`JHIr;T9m\(B'5A?jah7jLTO%35b@H`_T^Y.rT4O^.YX2RU#'[&m$r?&Kjo1t-6(XH4,jU>0*WHD+iD?M4Uc-Di71XEL&/SRV,F@Rmk9?Kp,UjlreF2/9F:\fF?AIOmWkW#)2[E9'i4Ho<GSrY9Yiq&K]&X-(*`X\V.cmnQf'$soa-Xd&Ob7/?GJ!6)nf_qf)jMJ!Wc>:Hb>frEn2E0)U*JFGYdMUe5h+*XpY.)&+l<Pr:@]]lGakJuCo4GN=7$][G1$_=)0r8_osc'HqVdRhEVF.OCtYO`UJ_a-DZ,V,um8Y.Kc0`UKi_rlisTF6h(C!$30R4:294m;lEj9kmg7fCU'!:P&b7f`:ppcuN`TM3f?`*%*'qVHOfn5:i7h`X3e^"-\dG7jB^l:s/P#&%5*Dbs`!0#$L7I39eRRYk%jf+U6?m4ZUUgI!FD'kQ^e`GUF-hX&G4DSd8SFp8<Z9+D4_-/<I^h$Gm&gL//4q^IbB_^Q#k.9'lr.1H)&De+nHp/>E'_[O/VBS7S`JWM)bEX0E&+n5G]Z"%"mM>ds<_9`]RQ4CV=&>h1*_SbgEK]t2E"[Ag%&7VTrfH(-TT/%n:$19+X7/hE='mW9^'CU3*+N!*L!Vc,."64Fjr7fNj6f3j3^%o)E9P`.:$[sMNGb3@fDPhfMj8_t9RTQr$F'[jOR/WX4gA#3bYnY1rNMfX01)\@%Y"aCop'Z1A[XKfdXLY9:[U&0*.SFE2:9@0\cZ=2;BM#ttLJIgRls+?t~>endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1616
|
||||
>>
|
||||
stream
|
||||
Gatn%l#59H'YqKHYB]?1[)*<]Yq]Bq-,PeM3QN*tlE2V1!M7Oeiu'm!QR:ENIc"O"U$7])8e-6m]]8ahnXrH<hY-u^!4YSYD!6H56^iSXEa+W3_]V,<GiCXAaDZjlH@Db\mp3[CGX39jINiKIlih:Z$3CJpThF\qn7+Cj7BHhP6MBPP/(o]d)d:X[SQn)^lj*7AOo9*mdKXW&>o:+La7"6aqglAER20af`J!1$C^.Rm1&qR9WWE@[G,kd*i&CVUBnl9W)Ua*XI:Lgph##6M6VSV)+,'WuV?q9qi#`7$$'").0g)f#AJeUq(?m.*3c<"4r7\R^ftnV"nZ_W^?b!3p,e-n#6j`BcQ%mE"Ja%Y^0V9U[rXN`Yqecs%gQVj5qEQG,[B1K'Ybp+[G-'Z*fCV+dGOQV3K8r_3>?Z*-`p**cOfnqTk?I'!lg<8%QjuX<"6Y[^K3H,]6(otKI(jF4Pq(9M_8NSR$3n!6^dJJ:[AjRuL81Ud0Vl5)k^<P6a<a$\G'YMQQNfYnVm`6$?>Mna^s0FK\Ps8q:BZB5TNpt+._1(pdMc3,4[SdX8>5e$+SBqB0uK8-agDPLECpG!PYujSdnQ%RqZc[_PO9\:,h$;N)rD0h^D_&O9#l.>Bg3IEdqSC@p0p.uC-Zu"m>Z&5_>CUEV_]29pS6SQg?YWllg\NgDFTsa2][lrVfYSn_lC"1S`<2u;VGeL<Y918$kk77J^/M"RMhrm?)>4;RG[pbaEW`AEUL?$AC!&86Ye9M#/XQE7C=oK'?WA"9)*@/]*C.Id"Sa]jsX4q0q_N02IBq(9nuqFTXdnLikt)W>hOa7<-l:&nPR^5(D$l4c7^D]aj#'93e'Aq6rcDQ5];d9oD;Gud8_:?69RTUWYtWXMm^?u6DASM4>RieK7`qh;TY>).pt0-9[V`MW?lu7+V^>Cf-]cVM-Qh#'4P)PK?$Sc3+(c<WO()-WN!-#h3r(]3_n-MpL@BpDZ3]Ng:FMa`a(WlRKC*Ka>q69)7:+?Q0.4;Wn-;PUiI_uS@.LO\3M6XDTF'D(o9o["OZ5'a(QO=Dj2d7EJ&RK1eLomS17"2CdD:@HfpmLoH+VMgMXYOi6brbd%Wa<`.W-s[qN-NVeF!OmASS/+M$9eQi^Gga,T+S9K*#"hc8SV.eoUfj&+9Y^s]]j/Ta#]6l,acKQ+"dV3*H&7BnN/b7,34cNE!t+Wc+pcs_#u#Mr*7!E*D\W>2*_A@U\\9`bEeJs9@!BO3MDTl"/8X+,\SD.j(de4Z`#'P`=s8s8IGQeMj.>H?1KAsXTnf622.Ai,/6-'7(`cq[<G+UWs88V02_gCZ?6nj7f1bfVMCfHo<*B2@EZ/u[<&>PEP\BB7HRW\%O40r*oPYQ&hoCcbZ^EA!J2V9s,HI\#>DP$='Ao2j1<c35-2EE5/JWEI4d3uc:8<1j%(>Eb)pblmU*WJr-=HR!LD["Gk=F/o)<bQO[g.%P_7epb;!m6[G10Y'dDS."TujAW1g>F?cEc5_>d"Uif+kb2diV\XqScrl(l1<Qk=MU>;`:bRfT%G*(:0S@#=EC88G\7m/&P*u/;!7\pNBS"MF9GccJXj(@K+AMQKp24_lJT+U(m-@s]-iX57.N;C~>endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2759
|
||||
>>
|
||||
stream
|
||||
GauHLCNJ5g(B*Z.EMq7rDT8LJIUm&7<jq)AlSs&6[@Zbr`WM'j`/4[t5-3DkrU5(5;DOLeQC?(L:+Ut+^jVc#EBo]rr0lp>1a>%#e*2QXVFBO49Lj;?r14,QUYh)R,Od8A-/Gpp#P5OgfV\%70fp"M*142&3jqb#kj"9$a9m%DnJmSs:@Xeu[Bi<9m`MZLG$j(5jUZ2l4o(jr`<*BRcLl94ZpKrXbLY48j,B#%=dg]+fn@XhLBGug1=(3&@BY]SMCc[91rI;I+)(R&NSd9LXa?ZXdFDdEHJ>j922uiYEO%YFm>6SfH5QWh_-pbk$jV)e>c<FDBf3=.3K(7KOZp1%s%X[$JqeZ@CSO!dTBidl,3kQp,EfUodBhQBqM(mQ[c%;kEZ3N2+S8J)klu_#^d#^H/bj&WT&Z:sI+S4_OlM.A&;1&g.?1S1:'I6H<Uci%\,A2*Q7be^R'm'l-f]>s>pNEJ:?S-"&eQF!<c5IW>JD,!p%to9)t_<aLIe0foD)trTi,eXp#hd#./++@:b?9::TJ$GBlr%7>Yj\GHW_A"OLm5\[t)"(1".gD%i0(I!u\[7>&6#BS#sf@10GLLRRV?ViI[d,G\!c\j*ScL>c:(ZAKq.:M2ElaN*fi>WLls&]V%iY9]m-bG`n1sHo`o2luuO/l04:"I$s+mC\NlG+He+(B6:uQ.2K7H:;')M/Q59aMBY0pbje+%[BOR1YBfeGS^<6A.7L>V_^U4%:0/<cOY\V)#gb%M+r!2A;!YM">U$k[2QCi/FiK)6"oZl`[HY?R%nMf@mnStHqgRb'e!]0'OAc$35b[taDgF$M5^_#i+<EjFi+tS]ER3Ak/q7(;a8&Xq6K5Bng\)UDIAoO/G&-`lGN>jjLW*aYjkGc;/JhSOOD/pWcgtt@QX&j_MC6"Pflj;UBYH-X)GKRbG%?s5Jc:Z$TNEB3]^!QW8pK`lq7:@EeS`=jC<k:]mI>$<?h.ojG4]mf??dBf.ab,Qe,!"/G0c.L)pb)afX@X2T&m\"pFQ/p"4CG%S`>`aK?3_t4$'u:pX*,I&Q-MCHc!+OA]nY1.Mr!t@G,>elu*T(QQ<gXQCsM1jMafn^B!^s@+Nad_U>U^.j>XSlbEY8iprS5$mtq!U^,EI"&I9\E/D",AhQ:Q,=r\X=@E&AfQqAHaE8bODhFY7RD<K!JPOmd^h>l^2V=G5W0mqH2Hs).B!BZnS#.6oVOCe?J!g^bC:f5LbcV3<oT*??CF&:pPmk0Wh*#Q?Q6q(S\P380ZC<D9?J+"(h/p3:V!Im,Rt.-bo^R1T9dl=*[J9M!LlSRn7ZN1d^k8#b,uuIo(<fCtG/Gcd.CfE-F[G]m82rd=p&C5T3_ibms2J#VP,\TLq(kFh6-neo%kJgc,1JOF%-'%!HD;m'`fa;2`H7\6JdUU\_/VtY'!GU%o#-LJ=M%/!.-2:djK.F:9I;2VQck!oL6c5^Q7t.gY!WHb=P)<L@mO:p=8INFOt//pSqoAXL3X5[3+isP6;bmkEmR.9:Kk[7+*!J7Nk:skM/nj3]G(3EEaCrg`J-q?oWI'FL?Fn)57`JZ]q<<2%sP_7kl,bX567=/1?bAM)=*lOFVF$&Q+AfP!0?VOnkMc$6(3;c`@R:.6-j\.X.#d!)e^4P.Q1RsC=cctK"73o03V<#heVsX)GQ>THLk8R8GbL1Jj71i>3-mU=;`Vq/]kA5(k3p`/DFhX(Q?DVU'Y=\3.cHV3XrBkXBXH[hJS,<7'Rf9c#NL=7B)jjSAMjM'Y@i2r6mDqSjEBra,k`U2U=:'mPr?*FiHk8TK'jjADD@Q4XdZ(Dc&D.b%=p(A6ni7%saGYYk"p-TNo:[nd5&]lpO-t7Q9A>"0Ct@oM,Gbs,%`JIFjZ`T-FLcMKsS2^=.ga#2g58!gu0E4'%ZJ]Vt>4jKa'/9LQsjoFE"6:3.!pZW!P;T<]rg7B*1FmXJ<J72>/f;W?Q#Wb^DuBRd!o72!We3_bKi(ppV1<qj(I*9aC@:n/9r$=MHQjfOXI)!VG6Tga=)f2Y5h@oeqS4DR4a2"k&s%_^]aV<Y61%?iPMj:p3?4i3h&O[h8]Z&`-H<HqU+O%CTiWL!\K!j4]7JtXs\&Z9TZcF2M;`[A/$b_d4a5p3ReI;.PUKjP3c)^)gK%Ed^uMmmeVHZRrVICLAh?/FFE5E#g0_nl+dhuC<:hM`PZ#QOYH!A:n@*Z$BfRj%Gc0+,F,3lH]Tl4VcM5,gIQd_ehLBG&VPq(:FoaZRlcg5&N4TM$p$h-FaHr2FsqJOGFY"bsI>(dJGDOLUF",1Gk;"sR7%!nO#aj[)_:.1:Z*^+?g]_\N$G"ECJ4Veaf`nlQ[V-&P6:b&Y?D<lE8)YgHY:.3PNhYMY_oN%\%Rpk$rqkf;)'mBC;rj\`2R33Xb<[]RakcS/Y1N8uLSLhChL.s-#]r^8Wh*?]i\=>.(@4ZPF0]@#6Ie,`8#'eEsKWVg"X33>F<cd6WGR:M`Z\s0>i46*sOAt))ja)'N-$>;Ko/nghr!!k,B<".$IkaGc;3O$;1@ap;`d"dEW9Y9=RL8.q[9Bp_OgYuF\)FXq#UCbI]#F6<'g7XtA-#_++8R@/7"%aV99Yi'p8IO%UEBW-r'PDSha]E^"g&N]TPbD:8Qe1&<X\^fqKZ,k-m>?ph+6r5eF1=)<UFf8ln)Nq6=8gTl=+0H?(OEI.V@M1;a'\p-7o[r4-#l4e0L&_VM5]VM2ZoR,neRmt?JiKj>FT(M'@_q#q&ZpKOB9a5^t?$L%Nait^R7$&]`~>endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2447
|
||||
>>
|
||||
stream
|
||||
Gatm<?$"c/&q0MXfU4oKm?f`F-+QgWA#F5BF_.tkF'h%k#YBhC5UA^sf(@#?"-B7Qe;6#aS2cNT&D4=9cImo!o'j]/s,M/pM]nWsqr@u=S<+i'&Ii[CC3]<iM<f;JTG*OT#JCE3]0R/-@>Ba)M8[;;5BPp6$n:V`0%M<_FPSWCU4`kKPFkV:c2i(CfPKAZp3#EBHr9bs0jf**4Q&0G`JL)VJt>co+E["B_joS5@Tb$qfn_7&#$9m40.Q-&IdL49ckL^jicm3Wr^ap[2#PH8f>.85M!^CR6m'FbmR^2ZBMQt+7XMH"I6S0jMr:fr6sbOf&R<ZL"b':g2W--/*b=uui;SZ&X+CP'Tq!KqAZMi\9Vu$frs-MJn!F5P@Zt-XL8!3"#u%J55pqP<El/fdpJi=WL$!2Mc^lFT?uWcnF(--h$SQO,dY,q%9eQbQ!H=Ha.1&RuX/@,@omG@5%W;8=Pt^G[^q!96Qb$Q4kDB49EOjd0pDX-gD8:'CLnefT^?>5'+DQCL]s&=N*LBh0o,D)2"ZE]KS12p7a^/i6btBK9B-YBQPY-L.(PPJ^\J?i#UNt7gEp#"6j`i]HY)j=YdZ('#X246`QWMM"rF,a"i?PV/>_@M*:9*iCU_qFbbo&1B!dj@&0#XCN-tgM(n3W`[)rV2_PFP`dSj;8FOBb[9Ur[O(cH4V,4%!iC>R?6,gG5S;BM[A7M+aJOPj9M'\]D"bg]56XT2dV<A8:@6E!4*L-&E8Ne-3n<l[YPH#toIZi#Y_HBO>%tT%e,Jl9e'7/G^*=N4[mgJeCddTuaI%6lhm(*udZ2'IA"2g[!jcN1R`u6/:3Yi*4rb;\4[=+AQC&F"_cALaf5XqP;9(CB5>m\cu5hS<j'$&U43@@/Rt/F:h4%Ydb@b;=?2YhE*p>q(s]R/``sRP;@mbU1DM)B?m<92S'n$S"cXrX"E610m:-FN,L?TG;B#uUc=p)$Wj8_:o[?'b$WH:#1@c$H%P0.QXWpZBF#.s.AU<A87;W!Yu"g&>Z1hYb_2[i_;-N*@BGuIBm4DCqn>5@d,!moCVb0VF2sq)I.Q@a)VT/p"%nSEVL0P_1cilR7<A&k-$,b;cUP!dWWo13F4KK!`._30hOaMs+pB7TBi/8f8?h(e/l,&u/@*bho:h+B2+;^E-_&0ecrdq,Tskj5a(&DZBaM)n/#6IqXYX6^,"k_tZo"2f)U$;++F&5mB=316cDa6Xf(_C[PPQh1j#Jc_Z-!l_jR-fT2:tA&dlEC&Yl(KH?)TqgWM36u$sK,=)gS9-Of;D]:p..Pf%alV\Z@`?X\Z/Na(SE:T5E+oh&6LKrLr0#RTF/.i"R4\/W#?c`.YU3GRLdD9K1IK`.)jIV\qB9B>\_V27S>5!RoY*Q8DZg%`kO):l,VkTN;irTiVpKO'X!n-1W$0SdV-IZ>PGYX=T\F+e)ML?b'f0:[*(g<VZ[fMS_OK$@BggQ]:mTe+@3NfUr6K/@fH6FKLZ,UQIgP!Nf5D2C=cn/&#p$7K)79)Qt+OZ?.^ekrWA7/],"<]^B%B<h+N>=2a5O^<lADXDoL/k=V&W&4(p^g/@I'C%J,0[5L9+T`BXb,L*0timO[8Ru`4$nE/^/)7*%a-=Xh]44Te#U+fm^LIBWm5)--8.hm1$O=]`<O(8hn-DAJ"VQ08N$sh0g*4E(3[HsnnKpFO;\/8pXPK?;ChZ8l+UKJ</".K%V[qPK78Th&q<@_e9d8$f[+`\pK`4/2u)SE:JUp[tIFFc&U8hV&(cO;V`lJA]XjBPYEm!S(,TKZ*n$a4a.QQbhRqN*%J6EZVtr]jg2("L74fjX8!%"g]G`'sB%<%p6NMt3pBhPdnjko%eMF8D64brG52KHB0"cm[&X(5'7jdgS/Abk-.[&c)Mf\%;."$\E8D/@IO0lZJ?jGo85#*E01XbkHWq`[V$1G#78,WW#._#._DWSC/*X1su!tP8,P)<:/D:CT^As13::)raOiR<48XtC_0YpFoT@5KU],Z(i1$e)\F"V6qjW_)^oFtnIf^FnsOP`LiRB\;m'f*0k4;jO:$4+4ir^G7jD"8;N2N*$CO(NDLCL\M/_#emIW7g>,,igo1bE-(OUPfKp[[npE+o7NqQif\js)rS%Oc$.<%<(#%nn["P@f<)#qNR`Sf)q4hr.3dS-"I@dJVS*nb700GSgCAeif5ANm4$,$]URjK3PMCH7AiiI3$;EPP7U=-!m%mlp/aiFWME2st9ASM6CC7(gU&Bt]f'_g(82Z1u-+NlIh(Ggr2/^![0#A*VP!nBl2eCQpnd1C8mYnRY]%.pY_`r)]\ZSF0p"5>&]Bq*%FNX)@iRQt;7b?&uB>/0XY05ENk<+G)C'o9"`DF:JK+I9N;Z_h[42ca\SE]jCe%GAbs\-K:J*R3Ck-ei^CP>Q',Aq'aL8fMg\@lF_Zm9s1SpR-$;:R%b+"LS&Be3h?0j5J-1m~>endstream
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1691
|
||||
>>
|
||||
stream
|
||||
Gau0DgN)%,&:N/3m,Xk(Ak\!\8<@q+3ht>AOuc?S99Y.F_'19^i^MhF:,DkD/gq;W>rddBg%O')/\$-;(4-%5JrSZpq)qLS?I*$W#6i7X('/Q6"1&O-AiBKH,.9.lk5q>.E6qCJ_^Y>Q=H#"_fVb7L%d2aS0#;N/Qqt4:*!(bFkgcVgbePI+%NTBG&sqo;0RnfUC80\iFGnEZ6j,'6i%n,sR'RK5?H=CK5KI%gqfME6/9mMZ.RA,RLN*LAB#:iZr:q(>?XWS"Vq]XH!2?*B,'r[_@A^#].g>l:HY11pnML.K_9Yi.@lp%o_$))lP\mY\-lH3\ZK1I+msj8*?,2G'kLMIcP4*NraXldE^W'`k&I$oaXChq#F5+T$T\YFl*XDY#4C-iW%e?rO^lTr<K]>q>H+W1+Jg1?Vqgjet!`KNB1P,&l#p'I9jD+kL'_Sc*]NL=_ce$@dWedGFH`RRUMCK5R`XD_5Wm4Y1)DE0siPb<hQNIVE&rL;B4U2jN`1t0Q$iQPMP!IZkO&%q6gHL<[g@5k8(3G\LI7Ro'h]&!Zidf*Dlsr/9m-Nf@0/D;(F:aiE4*g4M9YHujLQUC[.S0C7$;u1@"o\B''Sk2aq2+\@R+#*1rJ``"4lE"rHGG6:h'bB[]+)0U*QB+\:`&5u)a1<6<L:DXFu-SO9(ulXr\i&tY*LPo-/>Nt\h#AQQ^L1N$fge:36tY`aD@R$&m]prVQSKae5b"@P^oIallk2i0dn[FXGk=pA_"VUW-HC^dHn2%MrFo]UkI@*`6XQOLpU((V$6>_U^W?C6l(m,@eCkiPB\7FD)f3LYN8*10Cgq1F(fi&PHteN3@]9-2(V^sTm.ZM<<iW4_(%)F[5shAa0rh*,-KooQ=Jqtn:ca7[=!^tF9t1l(60?Db&E8#(r"`_iB(&8WWW_i?P/RZJb=A&&G8bplU2bcNQJA"->aZWdq6Q@b_l3`<p-bnhWD*L@9qr&]rL'RW3bu=];J$d5/X6l<!HE$'$lnER*Q;/4(atRh"8lk-':(8Q%D`na?"p\.^gPNA<l-Dfbke.FnjUJ5)CKVgBP@Pnl2g0$G.`)cS2:diI-U+;$(?.AS%R.a'!T`l?+3r=?=5"csI,qVm:qX$"d;qT#/0iDa:G7e!8(^c%1"1mFaH:#fkrcR.aP;Wd0cZU"OfL^!`O6MTIu[b=jP;8%6@5&Ya-g2TH$f9]G]/_ZRl'Zga"$aJ<^2'B8_0EYM87gSgHJI-HVDrDV@iiX*h>S<S)_W"XD[a@Ko`r'$%%qV1g]W(U-\^9CraSZ'AWZ8a;q9C.:2L$J\2F%/6UV.VV0^sg\Y<NiOh4C<:cJOem31B.:eTA0)$7r)!mKiGYIVl_kIScDnB[6B.0C!"."H2Qs\[UckN&`q;Zf[EU%D51Fs/!3$-RV%F$gmiO-.Z\[rD=a>@Tnh@Q]ZQ.)ijl[O]Desgd3DXRK?TaM$EO8ujlnfZYu`aJQ?TjuM"REN^,?c`'Y]N^&"B2\[j/cKhHX0Yh.rT?osIJ;>I/J)K9,EV^tt/C!pT$1^'dZqikq[=QUbCgoNc.=/q%6WF(s+5`.3n,NQW3Td&(ff!=WhR/q)3qF(s+5]E&FX)OEO9ThXS,!uT`K/inNVc.:te;"u%:0u+nm#gNF$e!l-O1plL-DP,/Tdhn%kf(&UARl]^/KJVTQl`oafN;a"A"#*F~>endstream
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2670
|
||||
>>
|
||||
stream
|
||||
Gau0DD,]4L')p1[kh<TYA1MgS^RM8#5[Xj;+[]kSak]ss-o70%Zt%:cZ5j_Kp:kN=IV4EE$*sa9)G0nuW;BH$#T3>tW;dYO^tTFWS.QkjKF&2o[ip,0PORM>"5WHPdrRc%AmjZ3DV>l24:o[<NgkH+l&KU`huo(#lWc,lh6L>@"[O=-q*7M0_:'DR3b[U>N[;H5qsDNZ,o&A#`]D/jh7sF)pnjl<R:-;TQVj3WpX(R_9P5$ci&o>!MSnI(AgNh!l"Ys*hle1#'uHT>]#MER7LV-d[,,iIZ)0Nm,mAK8BEoHq[pf&i"j_h;2Jgm$`W^Zr'!o#`7\a(To(9Zdqu?/Z&hp)M>asnjqG7o<`8cF5+7V$hM6>M3lPZkn>D@o!+K*Vu4'_D2o[E>Ydmj?G.ko@HbB%N]_!G@eB*Pd`ke8/63(RJ<ijUCV]qL%U`/adR^lg=mYT];>86=en)!2b[#(30)Q50W='jjiK)*37!=>!\nd;n=;a<F:O]YLef`fk\67FG&^2UPAtpPA-cL@b(Pl@E_q543ah]A7$:gNI"S5pr'CEo/O<Gj]IkNr:2cXf\1T-'PH[m?$[QF`\n]h<NN#\b9&_7rljmqp]SK?RMZq^W4"FTa3N8)(1&QbUMp/Jm"l^ZX8(p'a9Y!);W$EncR_@4EIae1+Mr?ds-nK(^s*[9*$f./#h]/Wh=d=]<RD>):#-mdoeYlA#PZpZfG?9p24t-068<#>*>P\rKjh/MK8*K/SS&tOAo1%q33CuZKO181:`0ta)IN+V,f=,WWN!0abitM=oaU!Ws;*k_CeB*m?lA(=KI7]pnh[c#`D[rYjpLi,ei*oA0Od8Q$>h&g;]F3"KN>$^C%/%aCBllJt[V699q7ONKAa7c4dXf&/9]fp#XO_4,+1sQtTP59>A-n!DfofMNoJ^pAtiqFT[Z3?Z8'B)>^4!@^0BLWAs1id&pIjVt;UYe>-OknH[!#5o?F%Y,[Zkid>$CPa;``>l]".pBZjtZiJbb:b?of&9X_nWQ6bj(l-dT*b',1F*/JJ0)giuP]2L6aCpuMmcB%8+"NeWoJOkW6I<$R5I,Z2[1K6[LJXmhNsLl.-_5Z1e;=1diq1L-Mc9"*&i&L,04DR,N0e[A]b=6Un`\4$:X\P&e$k-6Kk8-K:W?1\Z'/##.GkS0VepqA"<C"EHcDqHHpru=\BM9AcJ^Rm?01lcX()7Gjr#.G?YGeu*_OWuXo_2G9lDJ@fe!q#;<da%j)I:5*:;0GF()U]6XuZEY3gC!jE:+]m2;WnEgo*uIsla&WjKE;dA635lNin<`Te)q$FpMqX@+l;4VZDFr4lts7NUJN?5GDX5lqk]nk@(6Sr8c\n[+?*rDi]Ti7#eY=b\m5NM&!VNY%)B\B@=?>t9,TU1%X0q^,kbZ7WpqCZ)uh>d<r.hXFLfYp\3*:T&3in5p](#CD*$P<!!+Wau$6]?$]meW?ob'i.Fq"`1S7?GuL%QWLC''ht-Y=Y:Bd]Af^:[;TG6DT3$4?bj$(];tit'fJR!AB6;"Tn5_%=0]K"0WPti2:WR?EeTN'QeE?l#;BGGS/3&k>SI+6>#k^>,!eTpNI@gE@5$C/E*;''aJ!EPq`_HV0Ld-'K8"q(:C*+(hf-a)Kulf&0I;M1'R"Q8.3*M0fYe5sE7dlf,CedYRj'(5jKP3G7ooDu&am^E[l;,E=^]tdB?._E'1#I8Kf#M%]>r"`c2&jMF>mmr5<eah?kZ>F"-^fDIJX)`dXu/JhsuA-Gs_`gHbm3ikr6pK;X')/43EH:%i2$*ndsR63q`0]#^iCci6>[p/4Xilq`)"2KOJKe$Lo?Me4N35"=j)?+7GIbJ_[GmL5*:s^'"T3[&(k9C3hqSq)iqL5+Q$:bU4dE8bgV]r3,q/*oC3trptn35[(#f9.].bB'@/icli\`i:%FB8A'AR/&!V:MZR:SHL&F[8i<R4rjekL<Io4:Jcq%5kH\G0/'?"DUGc_H=<lF^MLLFF9n?@IQ8G@q(K%'$P%'lf7q*ms?&\t!?d^3_)XaQ*(j*:+N>.DP4AU51jsqGF=6(6:>Xl30md"*'%Su1Lb4l)4d4eq/YfSIsZ`G5=1hh)XK[(;&4U]m2f+=]X=Q:I<-G5:+[90I5FB/44>0_pV<qoO-rScp1P\=$I@,T'lS#:N\oe,JUNOXR;LLY-Ed'6^GU1]G*33QMI=<a>FKd``[:uE%fMJcK-j\*&#U6:-;kGj#B4C%&lEN*?ECJer#!X240W3R^A/0?qF[R>SfifD9!9\MP6gHZjDS7t3KchiFNq7N8JP)E!Iq1ACXP?_W2=1=V%Ig[&)J#M::>VsP\8\ReO;nC=iUQI`78t@92rt7tVSq@O9H%KTSQ;M#\5)Q[)ISMV$oD;C_nWZVteIG9!P+]fA&[/XBTq@e9?Ms4;qh_Fn?YZE2YJ(c74NXp(?;U#G.Oi7]<6.1ar6k?37p&86eu3gl+`0))L"*M=S<pa`2GTAQ>-/G,N8j)Z\rfRZ.bUD6O$U.S%`I`BLpO:;+$;@<l*i#nTHu4mDXO-<p:e#FU]6HX4<6o;>Oo2\N_0WKTXI"+7CRu^SEd8WMZe<`%Jg5jamK<@@qIomIC_D+gmf@>a7#qp<%&d5:<kN0a[u9NCa.!7'\Jm;kFd7`gZONFm-0m-]f[?XF8s;hHMdNT_4R4A,6%~>endstream
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1010
|
||||
>>
|
||||
stream
|
||||
Gatm8?$"IS'Re<2\AM6RY\Hc/jK0`a['[W3Qs#0c%&:bmM85:dZJ;b0rqO0Mghpf:^r&3.A3&)KkFQau3/$q__sc:ZS/-)A)[A!gW.7AhK&R-_MTMJ((1rCo@piR&&cL$D,e6UbD'kQ,B3_`%OqQb:B!a/kOYI_";$%6$H8ZY&"f*^;U%8k_`Nm8:ISBTR5c"p;A/?UEa[OJu'9WflKn6!l<S.(>Xl/<qq&'<ShAE>.HIA)4K*XmYKJD:YU%f&8ZTd03h?+DfOZ9V4Z1pN2!jtNhpOI^e7egI2%N,,4$rIj=>U#N@>]?t.2FgaODf,I)R.oM-VK4H3lR#^jFoAFX]s?`@oljs/*F^3^@?XA=.DqC-T_Fn14ARs74(b21`</0n9m[4q55A#.Z;9ZI*1an]["\>/U*+A<]fmn-(EYnbQ,TjkbM`?<oQYO6%F14hd;$:EW^@"IV^c9.nV++1md'KZQ:G5=m_tCZ$)$r+3fA1WPeh,VhPG>$6%dn"b(-2`:XM8R.frYMG.k"8[OjrG:4UA28E5%'*a]JVq\k0.QALEfn"l]bOD,?(r'2L;P=UWFM*[jdOoc`8np!ijs)5WqEJ(YrUkdeL<ClEpLI"4990+#/96s"\_MkL#6"`jc?!X5+Ba9a:7b+.@]^(gRoOud$H],Jk)01V'hV`]Q&E_i<2u(q,l_Z1bpXc79*kHM8;Wc<!=i$>9ZlVE+s5q-GX"0VC2lbH*7^l8*FR+Fq3$E`[>Y*-IOo8ZRaKfddW>rf-Xa)d4)=6eeD!Ym=;3J7:b;5U:YkTkDE>n3Hb-@uhN!Hd[rMe-_-gqNNc3N[4<5hha]+/2IQLs^s:@2><f6Y-tgC$V1#JEpqc<`&gK<(VNer@Z1Ek=C`")TQdHN,V>@(=WD(jt[.:05sGGM]_+3F"J>.2uqmFSF'US+RS>_7[*\)r0gN32*'9f@^U:\7lolN>_S@JSU$)pk*f9gie+pK-eqeW$WrP4.u<sD5^'.LCFWTIfM[j]L)~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 22
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000536 00000 n
|
||||
0000000741 00000 n
|
||||
0000000856 00000 n
|
||||
0000001061 00000 n
|
||||
0000001266 00000 n
|
||||
0000001471 00000 n
|
||||
0000001677 00000 n
|
||||
0000001883 00000 n
|
||||
0000001953 00000 n
|
||||
0000002284 00000 n
|
||||
0000002382 00000 n
|
||||
0000004571 00000 n
|
||||
0000006279 00000 n
|
||||
0000009130 00000 n
|
||||
0000011669 00000 n
|
||||
0000013452 00000 n
|
||||
0000016214 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<d46a1b7909dbc491a240e4a50a35fd17><d46a1b7909dbc491a240e4a50a35fd17>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 13 0 R
|
||||
/Root 12 0 R
|
||||
/Size 22
|
||||
>>
|
||||
startxref
|
||||
17316
|
||||
%%EOF
|
||||
@@ -1,53 +1,78 @@
|
||||
# Homelab Infrastructure (KalliLab CORE)
|
||||
|
||||
Dieses Repository ist die zentrale Quelle ("Single Source of Truth") für die komplette Infrastruktur meines Homelabs.
|
||||
Dieses Repository ist die zentrale Quelle ("Single Source of Truth") fuer die komplette Infrastruktur meines Homelabs.
|
||||
|
||||
## 🚨 WICHTIG – Einstieg
|
||||
## WICHTIG - Einstieg
|
||||
|
||||
Vor jeder Änderung lesen:
|
||||
Vor jeder Aenderung lesen:
|
||||
|
||||
1. 👉 HOMELAB_ARCHITECTURE_MASTER_V2.md
|
||||
2. 👉 docs/WORKFLOW.md
|
||||
1. `HOMELAB_ARCHITECTURE_MASTER_V2.md`
|
||||
2. `docs/WORKFLOW.md`
|
||||
|
||||
Bei Restore-, Host-Ausfall- oder Wiederanlauf-Fragen zusaetzlich:
|
||||
|
||||
3. `docs/DISASTER_RECOVERY.md`
|
||||
4. `docs/RESTORE_MATRIX.md`
|
||||
5. `docs/SERVICES_RECOVERY.md`
|
||||
|
||||
Bei Hardware-, Netzwerk-, Provider- oder Kapazitaetsfragen zusaetzlich:
|
||||
|
||||
6. `docs/HARDWARE_INVENTORY.md`
|
||||
7. `docs/NETWORK_INVENTORY.md`
|
||||
8. `docs/EXTERNAL_DEPENDENCIES.md`
|
||||
9. `docs/CAPACITY_AND_LIFECYCLE.md`
|
||||
|
||||
## Architektur
|
||||
|
||||
- Host: Unraid
|
||||
- Container: Docker (Compose)
|
||||
- Reverse Proxy: Traefik v3 (100% Docker-Labels, kein File-Provider mehr)
|
||||
- Container: Docker Compose
|
||||
- Reverse Proxy: Traefik v3 (Service-Routing via Docker-Labels, File-Provider nur fuer zentrale Dynamic-Config)
|
||||
- Zugriff: Tailscale (VPN)
|
||||
- DNS: AdGuard Home + Unbound
|
||||
- GitOps: Gitea + Komodo (Stack-Manager)
|
||||
- GitOps: Gitea + Komodo
|
||||
|
||||
## Grundprinzipien
|
||||
|
||||
- Alle Änderungen erfolgen über Git (Komodo deployed automatisch aus Gitea)
|
||||
- Keine produktiven Container außerhalb von Compose
|
||||
- Traefik ist der einzige öffentliche Einstiegspunkt
|
||||
- Admin-Dienste sind nicht öffentlich erreichbar (nur via VPN oder Auth)
|
||||
- Secrets werden niemals im Repository gespeichert
|
||||
- Gitea Online ist der operative Sollzustand.
|
||||
- Der lokale Clone ist die Arbeitskopie.
|
||||
- Komodo deployed automatisch aus Gitea und ist kein Bearbeitungsort.
|
||||
- Keine produktiven Container ausserhalb von Compose.
|
||||
- Traefik ist der einzige oeffentliche Einstiegspunkt.
|
||||
- Secrets werden niemals im Repository gespeichert.
|
||||
|
||||
## Repository-Struktur
|
||||
|
||||
- `core/` → Basisdienste (Gitea)
|
||||
- `security/` → sicherheitskritische Dienste (Vaultwarden)
|
||||
- `infra/` → Datenbanken & technische Services (PostgreSQL, Redis, DDNS-Updater)
|
||||
- `apps/` → Anwendungen (Immich, Paperless, Mealie, Homepage, ...)
|
||||
- `ops/` → Monitoring & Tools (Komodo, Scrutiny, Uptime-Kuma, Backrest, ...)
|
||||
- `host-services/` → Dienste mit Host-Netz (AdGuard, Beszel, Tailscale, ...)
|
||||
- `traefik/` → Reverse Proxy Konfiguration
|
||||
- `docs/` → Dokumentation & Prozesse
|
||||
- `env/` → Beispiel-Umgebungsvariablen
|
||||
- `core/` -> Basisdienste (Gitea)
|
||||
- `security/` -> sicherheitskritische Dienste
|
||||
- `infra/` -> Datenbanken und technische Services
|
||||
- `apps/` -> Anwendungen
|
||||
- `ops/` -> operative Tools
|
||||
- `monitoring/` -> zentraler Observability-Stack
|
||||
- `host-services/` -> Dienste mit Host-Netz
|
||||
- `traefik/` -> Reverse Proxy Konfiguration
|
||||
- `docs/` -> Dokumentation und Prozesse
|
||||
- `env/` -> Beispiel-Umgebungsvariablen
|
||||
|
||||
## Workflow
|
||||
## Kurz-Workflow
|
||||
|
||||
1. Änderung im Repository (Git)
|
||||
2. Commit & Push nach Gitea
|
||||
3. Komodo deployed automatisch (GitOps)
|
||||
4. Testen
|
||||
5. Dokumentation aktualisieren
|
||||
1. In GitHub Desktop `Fetch origin`.
|
||||
2. Wenn noetig `Pull origin`.
|
||||
3. Lokal aendern.
|
||||
4. Commit erstellen.
|
||||
5. `Push origin`.
|
||||
6. Komodo-Webhook und Ergebnis pruefen.
|
||||
7. Doku bei Bedarf aktualisieren.
|
||||
|
||||
## Status
|
||||
|
||||
GitOps-Migration (Sprint 1–4) abgeschlossen. Komodo ist primärer Stack-Manager.
|
||||
|
||||
> ⚠️ Portainer CE läuft noch als Legacy-UI – wird in Sprint 5 abgeschaltet.
|
||||
- Komodo ist der primaere und einzige produktive Stack-Manager.
|
||||
- Komodo bleibt bewusst bei nativer Authentifizierung; zentrale Traefik-Auth wird dort nicht pauschal vorgeschaltet.
|
||||
- Portainer CE ist abgeschaltet und kein Teil des aktiven Betriebs mehr.
|
||||
- Glance ist das aktive produktive Homelab-Dashboard.
|
||||
- Traefik `dynamic/` bleibt eine dokumentierte manuelle Host-Sync-Ausnahme ausserhalb des normalen Komodo-Deployments.
|
||||
- Mutable Image-Tags sind auf die aktuell laufenden Digests eingefroren; echte Versions-Upgrades erfolgen bewusst separat.
|
||||
- Disaster-Recovery und dienstspezifische Restore-Quellen sind in `docs/DISASTER_RECOVERY.md` und `docs/RESTORE_MATRIX.md` beschrieben.
|
||||
- Recovery-kritische Services-Pfade wie Gitea-Repositories, Komodo-Workspaces und Host-Automation sind in `docs/SERVICES_RECOVERY.md` beschrieben.
|
||||
- Hardware-, Netzwerk-, Provider- und Capacity-Inventare sind als operative Audit-Dokumente unter `docs/HARDWARE_INVENTORY.md`, `docs/NETWORK_INVENTORY.md`, `docs/EXTERNAL_DEPENDENCIES.md` und `docs/CAPACITY_AND_LIFECYCLE.md` vorbereitet.
|
||||
- Der verbindliche Detailablauf steht in `docs/WORKFLOW.md`.
|
||||
- `nextcloud`, `bentopdf` und `monitoring` folgen dem dokumentierten Netz-/Secret-/Traefik-Modell; der zentrale Monitoring-Stack buendelt Prometheus, Loki, Promtail, Grafana und InfluxDB 3 Core.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
services:
|
||||
bentopdf:
|
||||
image: bentopdfteam/bentopdf:2.8.4@sha256:f54b9ed9c56b767e0098b525468206689b666323c2b500b9686c3cf41cdfa348
|
||||
container_name: bentopdf
|
||||
restart: unless-stopped
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,nodev,mode=1777
|
||||
- /var/cache/nginx:rw,noexec,nosuid,nodev,uid=101,gid=101,mode=0755
|
||||
- /var/run:rw,noexec,nosuid,nodev,uid=101,gid=101,mode=0755
|
||||
networks:
|
||||
- frontend_net
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=frontend_net
|
||||
- traefik.http.routers.bentopdf.rule=Host(`pdf.kaleschke.info`)
|
||||
- traefik.http.routers.bentopdf.entrypoints=websecure
|
||||
- traefik.http.routers.bentopdf.tls=true
|
||||
- traefik.http.routers.bentopdf.tls.certresolver=le
|
||||
- traefik.http.routers.bentopdf.middlewares=authelia@file,secure-headers@file,bentopdf-coi@docker
|
||||
- traefik.http.middlewares.bentopdf-coi.headers.customresponseheaders.Cross-Origin-Opener-Policy=same-origin
|
||||
- traefik.http.middlewares.bentopdf-coi.headers.customresponseheaders.Cross-Origin-Embedder-Policy=require-corp
|
||||
- traefik.http.services.bentopdf.loadbalancer.server.port=8080
|
||||
|
||||
networks:
|
||||
frontend_net:
|
||||
external: true
|
||||
@@ -1,4 +0,0 @@
|
||||
backend/.env
|
||||
backend/app/__pycache__
|
||||
backend/app/**/*.pyc
|
||||
assets/.DS_Store
|
||||
@@ -1,23 +0,0 @@
|
||||
DASHBOARD_IMAGE=
|
||||
APP_ENV=production
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8000
|
||||
APP_LOG_LEVEL=INFO
|
||||
APP_TIMEZONE=Europe/Berlin
|
||||
APP_NAME=Homelab Dashboard API
|
||||
APP_VERSION=0.1.0
|
||||
CORS_ALLOW_ORIGINS=["https://dashboard.kaleschke.info"]
|
||||
REQUEST_TIMEOUT_SECONDS=5.0
|
||||
CACHE_TTL_OVERVIEW_SECONDS=15
|
||||
CACHE_TTL_SYSTEM_SECONDS=15
|
||||
CACHE_TTL_SERVICES_SECONDS=15
|
||||
CACHE_TTL_STORAGE_SECONDS=30
|
||||
BESZEL_BASE_URL=http://beszel:8090
|
||||
BESZEL_ADMIN_EMAIL=
|
||||
BESZEL_ADMIN_PASSWORD=
|
||||
UPTIME_KUMA_BASE_URL=http://uptime-kuma:3001
|
||||
UPTIME_KUMA_USERNAME=
|
||||
UPTIME_KUMA_PASSWORD=
|
||||
HOME_ASSISTANT_BASE_URL=http://192.168.178.50:8123
|
||||
HOME_ASSISTANT_TOKEN=
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
COPY backend/requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/app ./app
|
||||
WORKDIR /app
|
||||
COPY dashboard.html ./dashboard.html
|
||||
COPY assets ./assets
|
||||
|
||||
WORKDIR /app/backend
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -1,22 +0,0 @@
|
||||
async function fetchJson(path) {
|
||||
const res = await fetch(path);
|
||||
if (!res.ok) throw new Error(path + " HTTP " + res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchDashboardData() {
|
||||
const [overview, system, services, storage, adguard, scrutiny, immich, backrest, home_assistant, uptime_kuma] =
|
||||
await Promise.all([
|
||||
fetchJson("/api/overview"),
|
||||
fetchJson("/api/system"),
|
||||
fetchJson("/api/services"),
|
||||
fetchJson("/api/storage"),
|
||||
fetchJson("/api/adguard"),
|
||||
fetchJson("/api/scrutiny"),
|
||||
fetchJson("/api/immich"),
|
||||
fetchJson("/api/backrest"),
|
||||
fetchJson("/api/home_assistant"),
|
||||
fetchJson("/api/uptime_kuma"),
|
||||
]);
|
||||
return { overview, system, services, storage, adguard, scrutiny, immich, backrest, home_assistant, uptime_kuma };
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { fetchDashboardData } from "./api.js";
|
||||
import { getState, subscribe, updateData } from "./state.js";
|
||||
import { renderHeader } from "./renderers/header.js";
|
||||
import { renderStats } from "./renderers/stats.js";
|
||||
import { renderStorage } from "./renderers/storage.js";
|
||||
import { renderServices } from "./renderers/services.js";
|
||||
import { renderNetworkHealth } from "./renderers/network-health.js";
|
||||
import { renderQuickAccess } from "./renderers/quick-access.js";
|
||||
import { renderHomeAssistant } from "./renderers/home-assistant.js";
|
||||
import { renderUptimeKuma } from "./renderers/uptime-kuma.js";
|
||||
import { renderImmich } from "./renderers/immich.js";
|
||||
import { renderBackrest } from "./renderers/backrest.js";
|
||||
|
||||
function render(state) {
|
||||
renderHeader(state);
|
||||
renderStats(state);
|
||||
renderStorage(state);
|
||||
renderServices(state);
|
||||
renderNetworkHealth(state);
|
||||
renderHomeAssistant(state);
|
||||
renderUptimeKuma(state);
|
||||
renderImmich(state);
|
||||
renderBackrest(state);
|
||||
renderQuickAccess();
|
||||
}
|
||||
|
||||
subscribe(render);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const data = await fetchDashboardData();
|
||||
updateData(data);
|
||||
} catch (err) {
|
||||
console.error("Dashboard fetch error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
render(getState());
|
||||
refresh();
|
||||
setInterval(refresh, (getState().overview?.refresh_hint_seconds ?? 20) * 1000);
|
||||
@@ -1,50 +0,0 @@
|
||||
export function renderBackrest(state) {
|
||||
const d = state.backrest || {};
|
||||
const online = d.source_status === "online";
|
||||
|
||||
const pill = document.getElementById("backrest-pill");
|
||||
if (pill) {
|
||||
pill.textContent = online ? "ONLINE" : "OFFLINE";
|
||||
pill.className = "status-pill " + (online ? "pill-online" : "pill-offline");
|
||||
}
|
||||
|
||||
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
|
||||
|
||||
if (online) {
|
||||
set("backrest-repos", d.repo_count ?? 0);
|
||||
set("backrest-last", fmtAge(d.last_backup_age_hours));
|
||||
set("backrest-errors", d.error_count ?? 0);
|
||||
} else {
|
||||
set("backrest-repos", "—");
|
||||
set("backrest-last", "—");
|
||||
set("backrest-errors", "—");
|
||||
}
|
||||
|
||||
// Color last backup age warn if > 26h
|
||||
const lastEl = document.getElementById("backrest-last");
|
||||
if (lastEl && online) {
|
||||
const age = d.last_backup_age_hours;
|
||||
lastEl.style.color = age !== null && age > 26 ? "var(--clr-warn)" : "";
|
||||
}
|
||||
|
||||
// Color errors warn if any
|
||||
const errEl = document.getElementById("backrest-errors");
|
||||
if (errEl && online) {
|
||||
errEl.style.color = (d.error_count ?? 0) > 0 ? "var(--clr-warn)" : "";
|
||||
}
|
||||
|
||||
// Status dot
|
||||
const dot = document.getElementById("backrest-status-dot");
|
||||
if (dot) {
|
||||
const s = d.last_backup_status || "unknown";
|
||||
dot.className = "status-dot dot-" + (s === "ok" ? "ok" : s === "error" ? "err" : "unk");
|
||||
dot.title = s;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtAge(hours) {
|
||||
if (hours === null || hours === undefined) return "—";
|
||||
if (hours < 1) return `${Math.round(hours * 60)}m ago`;
|
||||
if (hours < 24) return `${Math.round(hours)}h ago`;
|
||||
return `${Math.round(hours / 24)}d ago`;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
export function renderHeader(state) {
|
||||
const overview = state.overview || {};
|
||||
const status = overview.overall_status || "offline";
|
||||
|
||||
const dot = document.getElementById("overall-dot");
|
||||
if (dot) {
|
||||
dot.style.background = status === "online" ? "var(--teal)" : status === "degraded" ? "var(--yellow)" : "var(--red)";
|
||||
dot.style.boxShadow = status === "online" ? "0 0 8px var(--teal-glow)" : "";
|
||||
}
|
||||
|
||||
const txt = document.getElementById("overall-status-text");
|
||||
if (txt) txt.textContent = status.toUpperCase();
|
||||
|
||||
const upd = document.getElementById("last-updated");
|
||||
if (upd && overview.generated_at) {
|
||||
const d = new Date(overview.generated_at);
|
||||
const pad = n => String(n).padStart(2, "0");
|
||||
upd.textContent = `updated ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
export function renderHomeAssistant(state) {
|
||||
const d = state.home_assistant || {};
|
||||
const online = d.status === "online";
|
||||
|
||||
// pill
|
||||
const pill = document.getElementById("ha-pill");
|
||||
if (pill) {
|
||||
pill.textContent = online ? "ONLINE" : "OFFLINE";
|
||||
pill.className = "status-pill " + (online ? "pill-online" : "pill-offline");
|
||||
}
|
||||
|
||||
// stat blocks
|
||||
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
|
||||
|
||||
set("ha-lights", online ? `${d.lights_on ?? 0}/${d.lights_total ?? 0}` : "—");
|
||||
set("ha-climate", online ? (d.climate_active ?? 0) : "—");
|
||||
set("ha-doors", online ? (d.doors_open ?? 0) : "—");
|
||||
set("ha-alerts", online ? (d.alerts ?? 0) : "—");
|
||||
|
||||
// version subtitle
|
||||
const ver = document.getElementById("ha-version");
|
||||
if (ver) ver.textContent = d.version ? `v${d.version}` : "";
|
||||
|
||||
// alerts highlight
|
||||
const alertsEl = document.getElementById("ha-alerts");
|
||||
if (alertsEl) {
|
||||
alertsEl.style.color = d.alerts > 0 ? "var(--clr-warn)" : "";
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
export function renderImmich(state) {
|
||||
const d = state.immich || {};
|
||||
const online = d.source_status === "online";
|
||||
|
||||
const pill = document.getElementById("immich-pill");
|
||||
if (pill) {
|
||||
pill.textContent = online ? "ONLINE" : "OFFLINE";
|
||||
pill.className = "status-pill " + (online ? "pill-online" : "pill-offline");
|
||||
}
|
||||
|
||||
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
|
||||
|
||||
if (online) {
|
||||
set("immich-photos", fmtNum(d.photos ?? 0));
|
||||
set("immich-videos", fmtNum(d.videos ?? 0));
|
||||
set("immich-storage", `${(d.storage_gb ?? 0).toFixed(1)} GB`);
|
||||
} else {
|
||||
set("immich-photos", "—");
|
||||
set("immich-videos", "—");
|
||||
set("immich-storage", "—");
|
||||
}
|
||||
}
|
||||
|
||||
function fmtNum(n) {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
|
||||
return String(n);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
export function renderNetworkHealth(state) {
|
||||
renderAdGuard(state.adguard || {});
|
||||
renderScrutiny(state.scrutiny || {});
|
||||
}
|
||||
|
||||
function renderAdGuard(data) {
|
||||
const online = data.source_status === "online";
|
||||
setPill("adguard-pill", online ? "ONLINE" : "OFFLINE", online ? "pill-online" : "pill-offline");
|
||||
|
||||
setText("adguard-total", online ? fmtCompact(data.total_queries) : "\u2014");
|
||||
setText("adguard-blocked", online ? fmtCompact(data.blocked_queries) : "\u2014");
|
||||
setText("adguard-blocked-pct", online ? `${Math.round(data.blocked_percent ?? 0)}%` : "\u2014");
|
||||
setText("adguard-latency", online ? `${Math.round(data.avg_processing_ms ?? 0)}ms` : "\u2014");
|
||||
|
||||
const fill = document.getElementById("adguard-bar-fill");
|
||||
if (fill) {
|
||||
fill.style.width = `${online ? Math.min(data.blocked_percent ?? 0, 100) : 0}%`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderScrutiny(data) {
|
||||
const online = data.source_status === "online";
|
||||
setPill("scrutiny-pill", online ? "ONLINE" : "OFFLINE", online ? "pill-online" : "pill-offline");
|
||||
|
||||
const total = data.total_count ?? 0;
|
||||
const failed = data.failed_count ?? 0;
|
||||
const passed = Math.max(total - failed, 0);
|
||||
|
||||
setText("scrutiny-total", online ? total : "\u2014");
|
||||
setText("scrutiny-passed", online ? passed : "\u2014");
|
||||
setText("scrutiny-failed", online ? failed : "\u2014");
|
||||
|
||||
const list = document.getElementById("scrutiny-list");
|
||||
if (!list) return;
|
||||
|
||||
const devices = Array.isArray(data.devices) ? data.devices.slice(0, 3) : [];
|
||||
if (!online || devices.length === 0) {
|
||||
list.innerHTML = `<div class="scrutiny-offline">\u2014 ${online ? "no disks" : "offline"}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = `<div class="scrutiny-strip">${devices.map((device) => {
|
||||
const status = device.status || "unknown";
|
||||
const cls = status === "passed" ? "ok" : status === "failed" ? "fail" : "unk";
|
||||
const token = status === "passed" ? "OK" : status === "failed" ? "ER" : "--";
|
||||
const name = device.name || device.device || "disk";
|
||||
|
||||
return `<span class="scrutiny-chip ${cls}"><strong>${token}</strong>${name}</span>`;
|
||||
}).join("")}</div>`;
|
||||
}
|
||||
|
||||
function setText(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
}
|
||||
|
||||
function setPill(id, label, cls) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.textContent = label;
|
||||
el.className = `status-pill ${cls}`;
|
||||
}
|
||||
|
||||
function fmtCompact(value) {
|
||||
const num = Number(value ?? 0);
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
||||
if (num >= 1_000) return `${Math.round(num / 1_000)}K`;
|
||||
return `${num}`;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
const QUICK_LINKS = [
|
||||
{ label: "Home Assistant", icon: "🏠", url: "https://ha.kaleschke.info" },
|
||||
{ label: "Komodo", icon: "🦎", url: "https://komodo.kaleschke.info" },
|
||||
{ label: "Uptime Kuma", icon: "📡", url: "https://uptime.kaleschke.info" },
|
||||
{ label: "Beszel", icon: "📊", url: "https://beszel.kaleschke.info" },
|
||||
{ label: "Firefly III", icon: "🦋", url: "https://firefly.kaleschke.info" },
|
||||
{ label: "Paperless", icon: "📄", url: "https://paperless.kaleschke.info" },
|
||||
{ label: "Mealie", icon: "🍽️", url: "https://mealie.kaleschke.info" },
|
||||
{ label: "Immich", icon: "🖼️", url: "https://immich.kaleschke.info" },
|
||||
{ label: "Gitea", icon: "🐙", url: "https://git.kaleschke.info" },
|
||||
{ label: "Code Server", icon: "💻", url: "https://code.kaleschke.info" },
|
||||
{ label: "FileBrowser", icon: "📁", url: "https://files.kaleschke.info" },
|
||||
{ label: "Backrest", icon: "💾", url: "https://backrest.kaleschke.info" },
|
||||
{ label: "Vaultwarden", icon: "🔐", url: "https://vault.kaleschke.info" },
|
||||
{ label: "AdGuard", icon: "🛡️", url: "https://adguard.kaleschke.info" },
|
||||
{ label: "Traefik", icon: "🔀", url: "https://traefik.kaleschke.info" },
|
||||
{ label: "Scrutiny", icon: "🔍", url: "https://scrutiny.kaleschke.info" },
|
||||
];
|
||||
|
||||
export function renderQuickAccess() {
|
||||
const grid = document.getElementById("quick-access-grid");
|
||||
if (!grid) return;
|
||||
grid.innerHTML = QUICK_LINKS.map(({ label, icon, url }) => `
|
||||
<a class="quick-tile" href="${url}" target="_blank" rel="noopener noreferrer">
|
||||
<span class="quick-tile-icon">${icon}</span>
|
||||
<span class="quick-tile-label">${label}</span>
|
||||
</a>
|
||||
`).join("");
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
export function renderServices(state) {
|
||||
const services = state.services || {};
|
||||
const summary = services.summary || {};
|
||||
|
||||
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
|
||||
|
||||
set("svc-online", summary.online ?? "—");
|
||||
set("svc-degraded", summary.degraded ?? "—");
|
||||
set("svc-offline", summary.offline ?? "—");
|
||||
set("svc-total", summary.total ?? "—");
|
||||
|
||||
const pill = document.getElementById("services-pill");
|
||||
if (pill) {
|
||||
const s = summary.overall_status || "offline";
|
||||
pill.textContent = s.toUpperCase();
|
||||
pill.className = "status-pill " + (s === "online" ? "pill-online" : s === "degraded" ? "pill-degraded" : "pill-offline");
|
||||
}
|
||||
|
||||
// Colour counts
|
||||
const degEl = document.getElementById("svc-degraded");
|
||||
if (degEl) degEl.className = "stat-num" + ((summary.degraded ?? 0) > 0 ? " warn" : "");
|
||||
const offEl = document.getElementById("svc-offline");
|
||||
if (offEl) offEl.className = "stat-num" + ((summary.offline ?? 0) > 0 ? " danger" : "");
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
export function renderStats(state) {
|
||||
const sys = state.system || {};
|
||||
const overview = state.overview || {};
|
||||
const cpu = sys.cpu || {};
|
||||
const mem = sys.memory || {};
|
||||
const net = sys.network || {};
|
||||
const host = sys.host || {};
|
||||
const docker = overview.docker || state.services?.docker || {};
|
||||
|
||||
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
|
||||
|
||||
// CPU
|
||||
set("cpu-percent", cpu.usage_percent != null ? `${cpu.usage_percent.toFixed(1)}%` : "—");
|
||||
set("cpu-cores", cpu.cores ?? "—");
|
||||
set("cpu-load", cpu.load_5 != null ? cpu.load_5.toFixed(2) : "—");
|
||||
|
||||
// Colour CPU
|
||||
const cpuEl = document.getElementById("cpu-percent");
|
||||
if (cpuEl && cpu.usage_percent != null) {
|
||||
cpuEl.className = "stat-num" + (cpu.usage_percent > 85 ? " danger" : cpu.usage_percent > 65 ? " warn" : "");
|
||||
}
|
||||
|
||||
// Memory
|
||||
set("ram-percent", mem.usage_percent != null ? `${mem.usage_percent.toFixed(1)}%` : "—");
|
||||
set("ram-used", mem.used_gb != null ? mem.used_gb.toFixed(1) : "—");
|
||||
set("ram-total", mem.total_gb != null ? mem.total_gb.toFixed(0) : "—");
|
||||
|
||||
const ramEl = document.getElementById("ram-percent");
|
||||
if (ramEl && mem.usage_percent != null) {
|
||||
ramEl.className = "stat-num" + (mem.usage_percent > 85 ? " danger" : mem.usage_percent > 65 ? " warn" : "");
|
||||
}
|
||||
|
||||
// Network
|
||||
set("net-rx", net.rx_mbps != null ? net.rx_mbps.toFixed(1) : "—");
|
||||
set("net-tx", net.tx_mbps != null ? net.tx_mbps.toFixed(1) : "—");
|
||||
|
||||
// Host
|
||||
const upDays = host.uptime_seconds != null ? Math.floor(host.uptime_seconds / 86400) : null;
|
||||
set("uptime-days", upDays != null ? upDays : "—");
|
||||
set("host-platform", host.platform ? host.platform.slice(0, 5).toUpperCase() : "—");
|
||||
|
||||
// Docker
|
||||
set("docker-running", docker.running ?? "—");
|
||||
set("docker-stopped", docker.stopped ?? "—");
|
||||
set("docker-total", docker.total ?? "—");
|
||||
|
||||
const stoppedEl = document.getElementById("docker-stopped");
|
||||
if (stoppedEl) stoppedEl.className = "stat-num" + ((docker.stopped ?? 0) > 0 ? " warn" : " dim");
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
export function renderStorage(state) {
|
||||
const storage = state.storage || {};
|
||||
const grid = document.getElementById("storage-grid");
|
||||
if (!grid) return;
|
||||
|
||||
const root = storage.root || storage.disks?.[0] || null;
|
||||
const disks = storage.disks || [];
|
||||
const rootPct = root?.usage_percent ?? 0;
|
||||
const rootTone = pickTone(rootPct);
|
||||
const warningCount = disks.filter((disk) => between(disk.usage_percent, 70, 85)).length;
|
||||
const criticalCount = disks.filter((disk) => (disk.usage_percent ?? 0) > 85).length;
|
||||
const highest = disks.length
|
||||
? disks.reduce((current, disk) => ((disk.usage_percent ?? 0) > (current.usage_percent ?? 0) ? disk : current), disks[0])
|
||||
: null;
|
||||
const matrixPill = criticalCount > 0 ? "pill-offline" : warningCount > 0 ? "pill-degraded" : "pill-online";
|
||||
const strip = disks.length
|
||||
? disks.slice(0, 4).map((disk) => {
|
||||
const pct = disk.usage_percent ?? 0;
|
||||
return `<span class="storage-chip" style="color:${pickColor(pct)}">${disk.name || disk.mount} ${pct.toFixed(0)}%</span>`;
|
||||
}).join("")
|
||||
: '<span class="storage-chip">No disk data</span>';
|
||||
|
||||
grid.innerHTML = `
|
||||
<div class="card service-card storage-card storage-primary">
|
||||
<div class="card-title">
|
||||
<div class="card-title-left">
|
||||
<span class="service-icon icon-storage"><span class="icon-glyph glyph-storage"></span></span>
|
||||
<span class="service-name">ROOT STORAGE</span>
|
||||
</div>
|
||||
<span class="status-pill ${statusPill(root?.status)}">${(root?.status || "stable").toUpperCase()}</span>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-block"><div class="stat-num ${rootTone}">${root ? `${rootPct.toFixed(1)}%` : "\u2014"}</div><div class="stat-label">Usage</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim">${root ? fmtNum(root.used_gb) : "\u2014"}</div><div class="stat-label">Used GB</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim">${root ? fmtNum(root.free_gb) : "\u2014"}</div><div class="stat-label">Free GB</div></div>
|
||||
</div>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-meta"><span>${root?.mount || "/"}</span><span>${root ? `${rootPct.toFixed(1)}%` : "0%"}</span></div>
|
||||
<div class="progress-bar"><div class="progress-fill ${rootTone}" style="width:${Math.min(100, rootPct)}%"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card service-card storage-card storage-matrix-card">
|
||||
<div class="card-title">
|
||||
<div class="card-title-left">
|
||||
<span class="service-icon icon-matrix"><span class="icon-glyph glyph-matrix"></span></span>
|
||||
<span class="service-name">DISK MATRIX</span>
|
||||
</div>
|
||||
<span class="status-pill ${matrixPill}">${disks.length}</span>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-block"><div class="stat-num">${disks.length || "\u2014"}</div><div class="stat-label">Volumes</div></div>
|
||||
<div class="stat-block"><div class="stat-num warn">${warningCount}</div><div class="stat-label">Warning</div></div>
|
||||
<div class="stat-block"><div class="stat-num danger">${criticalCount}</div><div class="stat-label">Critical</div></div>
|
||||
<div class="stat-block"><div class="stat-num ${pickTone(highest?.usage_percent ?? 0)}">${highest ? `${(highest.usage_percent ?? 0).toFixed(0)}%` : "\u2014"}</div><div class="stat-label">Peak</div></div>
|
||||
</div>
|
||||
<div class="service-footer"><span>Mounted Volumes</span><div class="storage-strip">${strip}</div></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function fmtNum(value) {
|
||||
return value == null ? "\u2014" : Number(value).toFixed(1);
|
||||
}
|
||||
|
||||
function between(value, min, max) {
|
||||
const num = value ?? 0;
|
||||
return num > min && num <= max;
|
||||
}
|
||||
|
||||
function pickTone(pct) {
|
||||
if (pct > 85) return "danger";
|
||||
if (pct > 70) return "warn";
|
||||
return "";
|
||||
}
|
||||
|
||||
function pickColor(pct) {
|
||||
if (pct > 85) return "var(--red)";
|
||||
if (pct > 70) return "var(--yellow)";
|
||||
return "var(--teal-bright)";
|
||||
}
|
||||
|
||||
function statusPill(status) {
|
||||
if (status === "critical") return "pill-offline";
|
||||
if (status === "warning") return "pill-degraded";
|
||||
return "pill-online";
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
export function renderUptimeKuma(state) {
|
||||
const d = state.uptime_kuma || {};
|
||||
const online = d.source_status === "online";
|
||||
|
||||
const pill = document.getElementById("uk-pill");
|
||||
if (pill) {
|
||||
pill.textContent = online ? "ONLINE" : "OFFLINE";
|
||||
pill.className = "status-pill " + (online ? "pill-online" : "pill-offline");
|
||||
}
|
||||
|
||||
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
|
||||
set("uk-up", online ? (d.monitors_up ?? 0) : "—");
|
||||
set("uk-down", online ? (d.monitors_down ?? 0) : "—");
|
||||
const total = (d.monitors_up ?? 0) + (d.monitors_down ?? 0);
|
||||
const pct = total > 0 ? Math.round((d.monitors_up / total) * 100) : (online ? 100 : 0);
|
||||
set("uk-uptime", online ? `${pct}%` : "—");
|
||||
|
||||
// down monitors list
|
||||
const downList = document.getElementById("uk-down-list");
|
||||
if (downList) {
|
||||
const downs = (d.monitors || []).filter(m => m.status === "offline");
|
||||
if (!online || downs.length === 0) {
|
||||
downList.innerHTML = "";
|
||||
} else {
|
||||
downList.innerHTML = downs.map(m =>
|
||||
`<span class="uk-down-name">▼ ${m.name}</span>`
|
||||
).join("");
|
||||
}
|
||||
}
|
||||
|
||||
// uptime bars per monitor (top 6 by name)
|
||||
const barsContainer = document.getElementById("uk-bars");
|
||||
if (barsContainer) {
|
||||
const monitors = (d.monitors || []).slice(0, 6);
|
||||
if (!online || monitors.length === 0) {
|
||||
barsContainer.innerHTML = '<span class="widget-offline-msg">—</span>';
|
||||
} else {
|
||||
barsContainer.innerHTML = monitors.map(m => {
|
||||
const beats = m.heartbeats && m.heartbeats.length
|
||||
? m.heartbeats
|
||||
: (m.status === "online" ? Array(20).fill(1) : Array(20).fill(0));
|
||||
const segments = beats.slice(-20).map(b =>
|
||||
`<span class="hb-seg ${b ? "hb-up" : "hb-down"}"></span>`
|
||||
).join("");
|
||||
return `
|
||||
<div class="uk-monitor-row">
|
||||
<span class="uk-monitor-name">${m.name}</span>
|
||||
<span class="uk-bar">${segments}</span>
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
const DEFAULT_DATA = {
|
||||
overview: {
|
||||
generated_at: new Date().toISOString(),
|
||||
overall_status: "online",
|
||||
refresh_hint_seconds: 20,
|
||||
services: { online: 8, degraded: 2, offline: 1, total: 11 },
|
||||
docker: { running: 18, stopped: 2, unhealthy: 1, total: 20, source_status: "online" },
|
||||
system: {
|
||||
cpu_percent: 23,
|
||||
ram_percent: 61,
|
||||
root_storage_percent: 49,
|
||||
network_rx_mbps: 12.4,
|
||||
network_tx_mbps: 3.1,
|
||||
uptime_seconds: 864000,
|
||||
},
|
||||
home_assistant: {
|
||||
status: "online",
|
||||
label: "Home Assistant",
|
||||
version: "2026.3.4",
|
||||
response_time_ms: 142,
|
||||
last_checked: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
system: {
|
||||
generated_at: new Date().toISOString(),
|
||||
source: { name: "beszel", status: "online", host_name: "nas", agent_name: "beszel-agent" },
|
||||
cpu: { usage_percent: 23, cores: 8, load_1: 0.8, load_5: 0.6, load_15: 0.5 },
|
||||
memory: { used_gb: 12.4, total_gb: 32.0, available_gb: 19.6, usage_percent: 38.7 },
|
||||
network: { primary_interface: "eth0", rx_mbps: 12.4, tx_mbps: 3.1 },
|
||||
host: { uptime_seconds: 864000, platform: "linux", kernel: "6.1.0" },
|
||||
},
|
||||
storage: {
|
||||
generated_at: new Date().toISOString(),
|
||||
summary: { total_used_gb: 2048, total_size_gb: 8192, total_free_gb: 6144, overall_usage_percent: 25 },
|
||||
disks: [],
|
||||
},
|
||||
services: {
|
||||
generated_at: new Date().toISOString(),
|
||||
summary: { overall_status: "online", total: 11, online: 8, degraded: 2, offline: 1 },
|
||||
docker: { running: 18, stopped: 2, unhealthy: 1, total: 20, source_status: "online" },
|
||||
uptime_kuma: { monitors_up: 8, monitors_down: 1, source_status: "online" },
|
||||
items: [],
|
||||
},
|
||||
adguard: { source_name: "adguard", source_status: "offline", total_queries: 0, blocked_queries: 0, blocked_percent: 0, avg_processing_ms: 0 },
|
||||
scrutiny: { source_name: "scrutiny", source_status: "offline", overall_status: "offline", devices: [], failed_count: 0, total_count: 0 },
|
||||
immich: { source_name: "immich", source_status: "offline", photos: 0, videos: 0, storage_gb: 0 },
|
||||
backrest: { source_name: "backrest", source_status: "offline", repo_count: 0, last_backup_age_hours: null, last_backup_status: "unknown", error_count: 0 },
|
||||
home_assistant: { source_name: "home_assistant", status: "offline", version: null, lights_on: 0, lights_total: 0, climate_active: 0, doors_open: 0, alerts: 0 },
|
||||
uptime_kuma: { source_name: "uptime_kuma", source_status: "offline", monitors_up: 0, monitors_down: 0, monitors_paused: 0, total: 0, monitors: [] },
|
||||
};
|
||||
|
||||
let _state = structuredClone(DEFAULT_DATA);
|
||||
const _subscribers = [];
|
||||
|
||||
export function getState() { return _state; }
|
||||
export function subscribe(fn) { _subscribers.push(fn); }
|
||||
export function updateData(partial) {
|
||||
_state = { ..._state, ...partial };
|
||||
_subscribers.forEach((fn) => fn(_state));
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
"""Homelab dashboard backend package."""
|
||||
@@ -1 +0,0 @@
|
||||
"""External system clients."""
|
||||
@@ -1,55 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.clients.base import BaseHTTPClient
|
||||
from app.config import Settings
|
||||
from app.models.sources import AdGuardSnapshot
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdGuardClient(BaseHTTPClient):
|
||||
"""
|
||||
Reads DNS statistics from AdGuard Home's /control/stats endpoint.
|
||||
Requires Basic Auth (username + password).
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
super().__init__(settings, "adguard", settings.adguard_base_url)
|
||||
|
||||
async def fetch_stats(self) -> AdGuardSnapshot:
|
||||
snapshot = AdGuardSnapshot()
|
||||
if not self.base_url:
|
||||
logger.info("adguard skipped: base URL missing")
|
||||
return snapshot
|
||||
|
||||
if not self.settings.adguard_username or not self.settings.adguard_password:
|
||||
logger.info("adguard skipped: no credentials configured")
|
||||
return snapshot
|
||||
|
||||
data = await self._request_json(
|
||||
"GET",
|
||||
"/control/stats",
|
||||
auth=(self.settings.adguard_username, self.settings.adguard_password),
|
||||
)
|
||||
|
||||
if data is None:
|
||||
logger.warning("adguard: empty or failed response")
|
||||
return snapshot
|
||||
|
||||
total = int(data.get("num_dns_queries") or 0)
|
||||
blocked = int(data.get("num_blocked_filtering") or 0)
|
||||
avg_ms = round(float(data.get("avg_processing_time") or 0.0) * 1000, 2)
|
||||
blocked_pct = round((blocked / total * 100), 1) if total > 0 else 0.0
|
||||
|
||||
result = AdGuardSnapshot(
|
||||
source_status="online",
|
||||
total_queries=total,
|
||||
blocked_queries=blocked,
|
||||
blocked_percent=blocked_pct,
|
||||
avg_processing_ms=avg_ms,
|
||||
)
|
||||
logger.info("adguard stats: %s", result.model_dump())
|
||||
return result
|
||||
@@ -1,82 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.clients.base import BaseHTTPClient
|
||||
from app.config import Settings
|
||||
from app.models.sources import BackrestSnapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BackrestClient(BaseHTTPClient):
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
super().__init__(settings, "backrest", settings.backrest_base_url)
|
||||
|
||||
def _auth(self) -> tuple[str, str] | None:
|
||||
if self.settings.backrest_username and self.settings.backrest_password:
|
||||
return (self.settings.backrest_username, self.settings.backrest_password)
|
||||
return None
|
||||
|
||||
async def fetch_status(self) -> BackrestSnapshot:
|
||||
snapshot = BackrestSnapshot()
|
||||
if not self.base_url:
|
||||
logger.info("backrest skipped: base URL missing")
|
||||
return snapshot
|
||||
|
||||
auth = self._auth()
|
||||
|
||||
# Get config (repo list) via Connect-RPC
|
||||
data = await self._request_json(
|
||||
"POST", "/v1.Backrest/GetConfig",
|
||||
json={},
|
||||
auth=auth,
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
return snapshot
|
||||
|
||||
repos = data.get("repos") or []
|
||||
repo_count = len(repos) if isinstance(repos, list) else 0
|
||||
|
||||
# Get recent operations via Connect-RPC
|
||||
last_backup_age_hours: float | None = None
|
||||
error_count = 0
|
||||
last_backup_status = "unknown"
|
||||
|
||||
ops_data = await self._request_json(
|
||||
"POST", "/v1.Backrest/GetOperations",
|
||||
json={"lastN": 20},
|
||||
auth=auth,
|
||||
)
|
||||
if isinstance(ops_data, dict):
|
||||
ops = ops_data.get("operations") or []
|
||||
backup_ops = [
|
||||
op for op in ops
|
||||
if isinstance(op, dict) and op.get("backupOp") is not None
|
||||
]
|
||||
error_ops = [op for op in backup_ops if op.get("status") == "STATUS_ERROR"]
|
||||
error_count = len(error_ops)
|
||||
|
||||
if backup_ops:
|
||||
latest = backup_ops[0]
|
||||
ts = latest.get("unixTimeEndMs")
|
||||
if ts:
|
||||
ended = datetime.fromtimestamp(int(ts) / 1000, tz=timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
last_backup_age_hours = round((now - ended).total_seconds() / 3600, 1)
|
||||
status_str = latest.get("status", "")
|
||||
if status_str == "STATUS_SUCCESS":
|
||||
last_backup_status = "ok"
|
||||
elif status_str == "STATUS_ERROR":
|
||||
last_backup_status = "error"
|
||||
else:
|
||||
last_backup_status = "unknown"
|
||||
|
||||
return BackrestSnapshot(
|
||||
source_status="online",
|
||||
repo_count=repo_count,
|
||||
last_backup_age_hours=last_backup_age_hours,
|
||||
last_backup_status=last_backup_status,
|
||||
error_count=error_count,
|
||||
)
|
||||
@@ -1,111 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseHTTPClient:
|
||||
def __init__(self, settings: Settings, name: str, base_url: str | None) -> None:
|
||||
self.settings = settings
|
||||
self.name = name
|
||||
self.base_url = str(base_url).rstrip("/") if base_url else None
|
||||
|
||||
async def _request_json(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
auth: tuple[str, str] | None = None,
|
||||
json: Any | None = None,
|
||||
) -> Any | None:
|
||||
response = await self._request(
|
||||
method,
|
||||
path,
|
||||
headers=headers,
|
||||
params=params,
|
||||
auth=auth,
|
||||
json=json,
|
||||
)
|
||||
if response is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
logger.warning("%s returned non-JSON payload for %s", self.name, path)
|
||||
return None
|
||||
|
||||
async def _request_text(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
auth: tuple[str, str] | None = None,
|
||||
) -> str | None:
|
||||
response = await self._request(
|
||||
method,
|
||||
path,
|
||||
headers=headers,
|
||||
params=params,
|
||||
auth=auth,
|
||||
)
|
||||
if response is None:
|
||||
return None
|
||||
return response.text
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
auth: tuple[str, str] | None = None,
|
||||
json: Any | None = None,
|
||||
) -> httpx.Response | None:
|
||||
if not self.base_url:
|
||||
logger.info("%s client skipped because base URL is not configured", self.name)
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}/{path.lstrip('/')}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=self.settings.request_timeout_seconds,
|
||||
trust_env=False,
|
||||
) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
auth=auth,
|
||||
json=json,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("%s request timed out: %s %s", self.name, method, url)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.warning(
|
||||
"%s request failed with status %s for %s %s",
|
||||
self.name,
|
||||
exc.response.status_code,
|
||||
method,
|
||||
url,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("%s request error for %s %s: %s", self.name, method, url, exc)
|
||||
|
||||
return None
|
||||
@@ -1,431 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.clients.base import BaseHTTPClient
|
||||
from app.config import Settings
|
||||
from app.models.sources import (
|
||||
BeszelDiskMetric,
|
||||
BeszelSystemSnapshot,
|
||||
DockerContainerSummary,
|
||||
DockerSnapshot,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BeszelClient(BaseHTTPClient):
|
||||
"""
|
||||
Beszel exposes a PocketBase-backed REST API. The exact record schema may
|
||||
change between Beszel releases, so this client intentionally normalizes
|
||||
multiple likely field layouts into a stable internal snapshot.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
super().__init__(settings, "beszel", settings.beszel_base_url)
|
||||
self._admin_jwt: str | None = None
|
||||
self._admin_jwt_expires_at: datetime | None = None
|
||||
|
||||
async def fetch_system_snapshot(self) -> BeszelSystemSnapshot:
|
||||
snapshot = BeszelSystemSnapshot()
|
||||
if not self.base_url:
|
||||
logger.info("beszel skipped: base URL missing")
|
||||
return snapshot
|
||||
|
||||
headers = await self._build_auth_headers()
|
||||
if headers is None:
|
||||
logger.warning("beszel skipped: no usable auth method configured")
|
||||
return snapshot
|
||||
|
||||
payload = await self._request_json(
|
||||
"GET",
|
||||
"/api/collections/system_stats/records",
|
||||
headers=headers,
|
||||
params={"page": 1, "perPage": 1, "sort": "-created"},
|
||||
)
|
||||
if not payload:
|
||||
logger.warning("beszel returned empty payload")
|
||||
return snapshot
|
||||
|
||||
logger.info("beszel raw payload: %s", payload)
|
||||
|
||||
items = payload.get("items") if isinstance(payload, dict) else None
|
||||
if not items:
|
||||
logger.warning("beszel returned no system_stats records")
|
||||
return snapshot
|
||||
|
||||
record = items[0]
|
||||
details = await self._fetch_system_details(headers, record)
|
||||
normalized = self._normalize_snapshot(record, details)
|
||||
logger.info("beszel normalized snapshot: %s", normalized.model_dump())
|
||||
return normalized
|
||||
|
||||
async def fetch_container_snapshot(self) -> DockerSnapshot:
|
||||
snapshot = DockerSnapshot()
|
||||
if not self.base_url:
|
||||
return snapshot
|
||||
|
||||
headers = await self._build_auth_headers()
|
||||
if headers is None:
|
||||
return snapshot
|
||||
|
||||
payload = await self._request_json(
|
||||
"GET",
|
||||
"/api/collections/containers/records",
|
||||
headers=headers,
|
||||
params={"page": 1, "perPage": 200, "sort": "-updated"},
|
||||
)
|
||||
logger.info("beszel raw containers payload: %s", payload)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
logger.warning("beszel containers mapping: payload is not a dict")
|
||||
return snapshot
|
||||
items = payload.get("items")
|
||||
if not isinstance(items, list):
|
||||
logger.warning("beszel containers mapping: items missing or not a list")
|
||||
return snapshot
|
||||
if not items:
|
||||
logger.warning("beszel containers mapping: no container records returned")
|
||||
return snapshot
|
||||
|
||||
containers: list[DockerContainerSummary] = []
|
||||
running = 0
|
||||
stopped = 0
|
||||
unhealthy = 0
|
||||
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
state = self._normalize_container_state(item)
|
||||
if state == "running":
|
||||
running += 1
|
||||
elif state == "unhealthy":
|
||||
unhealthy += 1
|
||||
else:
|
||||
stopped += 1
|
||||
|
||||
containers.append(
|
||||
DockerContainerSummary(
|
||||
id=str(item.get("id") or ""),
|
||||
name=str(item.get("name") or item.get("container") or item.get("service") or "unknown"),
|
||||
state=state,
|
||||
status_text=str(item.get("status") or item.get("state") or "unknown"),
|
||||
image=str(item.get("image") or ""),
|
||||
health=str(item.get("health") or item.get("health_status") or "").lower() or None,
|
||||
)
|
||||
)
|
||||
|
||||
normalized = DockerSnapshot(
|
||||
source_status="online",
|
||||
running=running,
|
||||
stopped=stopped,
|
||||
unhealthy=unhealthy,
|
||||
total=len(containers),
|
||||
containers=containers,
|
||||
)
|
||||
logger.info("beszel normalized containers snapshot: %s", normalized.model_dump())
|
||||
return normalized
|
||||
|
||||
def _normalize_snapshot(self, record: dict[str, Any], details: dict[str, Any] | None) -> BeszelSystemSnapshot:
|
||||
stats = self._coerce_mapping(record.get("stats") or {})
|
||||
details = self._coerce_mapping(details or {})
|
||||
details_payload = self._coerce_mapping(
|
||||
details.get("details")
|
||||
or details.get("stats")
|
||||
or details.get("info")
|
||||
or details.get("data")
|
||||
or {}
|
||||
)
|
||||
expanded_system = self._coerce_mapping(self._coerce_mapping(record.get("expand")).get("system"))
|
||||
|
||||
memory_used_gb = self._as_float(stats.get("m"))
|
||||
memory_total_gb = self._as_float(stats.get("m"))
|
||||
if stats.get("mp"):
|
||||
memory_total_gb = round(memory_used_gb / (self._as_float(stats.get("mp")) / 100), 1) if self._as_float(stats.get("mp")) else memory_used_gb
|
||||
memory_available_gb = max(round(memory_total_gb - memory_used_gb, 1), 0.0)
|
||||
network_pair = stats.get("b") if isinstance(stats.get("b"), list) else []
|
||||
disks = self._normalize_disks(
|
||||
details_payload.get("disks")
|
||||
or details_payload.get("disk")
|
||||
or details_payload.get("mounts")
|
||||
or details.get("disks")
|
||||
or details.get("disk")
|
||||
or details.get("mounts")
|
||||
or []
|
||||
)
|
||||
if not disks and self._as_float(stats.get("dp")) > 0:
|
||||
disk_pct = self._as_float(stats.get("dp"))
|
||||
disk_used = self._as_float(stats.get("du"))
|
||||
disk_total = round(disk_used / (disk_pct / 100), 1) if disk_pct > 0 else 0.0
|
||||
disk_free = round(max(disk_total - disk_used, 0.0), 1)
|
||||
logger.info("beszel storage fallback: using dp=%.1f du=%.1f from stats", disk_pct, disk_used)
|
||||
disks = [BeszelDiskMetric(
|
||||
name="rootfs",
|
||||
mount="/",
|
||||
used_gb=disk_used,
|
||||
total_gb=disk_total,
|
||||
free_gb=disk_free,
|
||||
usage_percent=disk_pct,
|
||||
)]
|
||||
if not disks:
|
||||
logger.info("beszel storage unsupported: no disks/mounts in payload")
|
||||
|
||||
return BeszelSystemSnapshot(
|
||||
source_status="online",
|
||||
host_name=str(
|
||||
details_payload.get("hostname")
|
||||
or details_payload.get("host")
|
||||
or details.get("hostname")
|
||||
or details.get("host")
|
||||
or expanded_system.get("name")
|
||||
or record.get("system_name")
|
||||
or record.get("name")
|
||||
or "unknown"
|
||||
),
|
||||
agent_name="beszel-agent",
|
||||
cpu_usage_percent=self._as_float(stats.get("cpu")),
|
||||
cpu_cores=len(stats.get("cpus")) if isinstance(stats.get("cpus"), list) else 0,
|
||||
load_1=0.0,
|
||||
load_5=0.0,
|
||||
load_15=0.0,
|
||||
memory_used_gb=memory_used_gb,
|
||||
memory_total_gb=memory_total_gb,
|
||||
memory_available_gb=memory_available_gb,
|
||||
memory_usage_percent=self._as_float(stats.get("mp")),
|
||||
primary_interface=str(
|
||||
details_payload.get("primary_interface")
|
||||
or details_payload.get("interface")
|
||||
or details.get("primary_interface")
|
||||
or "primary"
|
||||
),
|
||||
network_rx_mbps=self._network_value_to_mbps(network_pair[0] if len(network_pair) > 0 else 0),
|
||||
network_tx_mbps=self._network_value_to_mbps(network_pair[1] if len(network_pair) > 1 else 0),
|
||||
uptime_seconds=self._minutes_to_seconds(stats.get("d")),
|
||||
platform=str(
|
||||
details_payload.get("platform")
|
||||
or details_payload.get("os")
|
||||
or details.get("platform")
|
||||
or "unknown"
|
||||
),
|
||||
kernel=str(details_payload.get("kernel") or details.get("kernel") or "unknown"),
|
||||
disks=disks,
|
||||
)
|
||||
|
||||
async def _fetch_system_details(
|
||||
self,
|
||||
headers: dict[str, str],
|
||||
stats_record: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
system_id = stats_record.get("system")
|
||||
params: dict[str, Any] = {
|
||||
"page": 1,
|
||||
"perPage": 100,
|
||||
"sort": "-created",
|
||||
}
|
||||
|
||||
payload = await self._request_json(
|
||||
"GET",
|
||||
"/api/collections/system_details/records",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
logger.info("beszel raw details payload: %s", payload)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
logger.warning("beszel system_details mapping: payload is not a dict")
|
||||
return None
|
||||
items = payload.get("items")
|
||||
if isinstance(items, list) and items:
|
||||
if system_id:
|
||||
for item in items:
|
||||
if isinstance(item, dict) and str(item.get("system") or "") == str(system_id):
|
||||
return item
|
||||
return items[0]
|
||||
logger.warning("beszel system_details mapping: no matching detail records returned")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_container_state(item: dict[str, Any]) -> str:
|
||||
raw = " ".join(
|
||||
str(item.get(key) or "")
|
||||
for key in ("status", "state", "health", "health_status")
|
||||
).lower()
|
||||
if "unhealthy" in raw or "degraded" in raw:
|
||||
return "unhealthy"
|
||||
if any(token in raw for token in ("running", "up", "healthy", "active")):
|
||||
return "running"
|
||||
if raw.strip():
|
||||
return "stopped"
|
||||
return "unknown"
|
||||
|
||||
async def _build_auth_headers(self) -> dict[str, str] | None:
|
||||
admin_headers = await self._get_admin_auth_headers()
|
||||
if admin_headers:
|
||||
return admin_headers
|
||||
if self.settings.beszel_api_token:
|
||||
return {"Authorization": self.settings.beszel_api_token}
|
||||
return None
|
||||
|
||||
async def _get_admin_auth_headers(self) -> dict[str, str] | None:
|
||||
if not self.settings.beszel_admin_email or not self.settings.beszel_admin_password:
|
||||
return None
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
if self._admin_jwt and self._admin_jwt_expires_at and now < self._admin_jwt_expires_at:
|
||||
return {"Authorization": self._admin_jwt}
|
||||
|
||||
auth_payload = await self._request_json(
|
||||
"POST",
|
||||
"/api/collections/_superusers/auth-with-password",
|
||||
headers={"Content-Type": "application/json"},
|
||||
params=None,
|
||||
)
|
||||
if auth_payload is None:
|
||||
auth_payload = await self._request_json_with_body(
|
||||
"POST",
|
||||
"/api/collections/_superusers/auth-with-password",
|
||||
json_body={
|
||||
"identity": self.settings.beszel_admin_email,
|
||||
"password": self.settings.beszel_admin_password,
|
||||
},
|
||||
)
|
||||
|
||||
if not isinstance(auth_payload, dict):
|
||||
logger.warning("beszel admin auth failed: no payload")
|
||||
return None
|
||||
|
||||
token = auth_payload.get("token")
|
||||
if not token:
|
||||
logger.warning("beszel admin auth failed: token missing")
|
||||
return None
|
||||
|
||||
self._admin_jwt = str(token)
|
||||
self._admin_jwt_expires_at = now + timedelta(minutes=30)
|
||||
logger.info("beszel admin auth succeeded")
|
||||
return {"Authorization": self._admin_jwt}
|
||||
|
||||
async def _request_json_with_body(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: dict[str, Any],
|
||||
) -> Any | None:
|
||||
if not self.base_url:
|
||||
return None
|
||||
|
||||
import httpx
|
||||
|
||||
url = f"{self.base_url}/{path.lstrip('/')}"
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=self.settings.request_timeout_seconds,
|
||||
trust_env=False,
|
||||
) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
json=json_body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("beszel auth request timed out: %s %s", method, url)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.warning("beszel auth request failed with status %s for %s %s", exc.response.status_code, method, url)
|
||||
try:
|
||||
logger.info("beszel auth error payload: %s", exc.response.json())
|
||||
except ValueError:
|
||||
logger.info("beszel auth error text: %s", exc.response.text)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("beszel auth request error for %s %s: %s", method, url, exc)
|
||||
return None
|
||||
|
||||
def _normalize_disks(self, raw_disks: Any) -> list[BeszelDiskMetric]:
|
||||
if isinstance(raw_disks, dict):
|
||||
raw_disks = [
|
||||
{"mount": key, **(value if isinstance(value, dict) else {"used": value})}
|
||||
for key, value in raw_disks.items()
|
||||
]
|
||||
disks: list[BeszelDiskMetric] = []
|
||||
if not isinstance(raw_disks, list):
|
||||
return disks
|
||||
|
||||
for item in raw_disks:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
total_gb = self._bytes_to_gb(item.get("total") or item.get("total_bytes"))
|
||||
used_gb = self._bytes_to_gb(item.get("used") or item.get("used_bytes"))
|
||||
free_gb = self._bytes_to_gb(item.get("free") or item.get("free_bytes"))
|
||||
usage_percent = self._as_float(item.get("usage_percent") or item.get("percent"))
|
||||
if not total_gb and used_gb and free_gb:
|
||||
total_gb = round(used_gb + free_gb, 1)
|
||||
|
||||
disks.append(
|
||||
BeszelDiskMetric(
|
||||
name=str(item.get("name") or item.get("device") or item.get("mount") or "disk"),
|
||||
mount=str(item.get("mount") or item.get("path") or "/"),
|
||||
used_gb=used_gb,
|
||||
total_gb=total_gb,
|
||||
free_gb=free_gb,
|
||||
usage_percent=usage_percent,
|
||||
)
|
||||
)
|
||||
return disks
|
||||
|
||||
@staticmethod
|
||||
def _coerce_mapping(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
@staticmethod
|
||||
def _as_float(value: Any) -> float:
|
||||
try:
|
||||
return round(float(value or 0), 1)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def _as_int(value: Any) -> int:
|
||||
try:
|
||||
return int(float(value or 0))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def _bytes_to_gb(cls, value: Any) -> float:
|
||||
if value in (None, ""):
|
||||
return 0.0
|
||||
try:
|
||||
return round(float(value) / (1024 ** 3), 1)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@classmethod
|
||||
def _bytes_per_second_to_mbps(cls, value: Any) -> float:
|
||||
if value in (None, ""):
|
||||
return 0.0
|
||||
try:
|
||||
return round((float(value) * 8) / 1_000_000, 1)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@classmethod
|
||||
def _network_value_to_mbps(cls, value: Any) -> float:
|
||||
try:
|
||||
numeric = float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
if numeric <= 0:
|
||||
return 0.0
|
||||
return round((numeric * 8) / 1_000_000, 1)
|
||||
|
||||
@classmethod
|
||||
def _minutes_to_seconds(cls, value: Any) -> int:
|
||||
try:
|
||||
return int(float(value or 0) * 60)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
@@ -1,103 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.clients.beszel_client import BeszelClient
|
||||
from app.clients.base import BaseHTTPClient
|
||||
from app.config import Settings
|
||||
from app.models.sources import DockerContainerSummary, DockerSnapshot
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DockerProxyClient(BaseHTTPClient):
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
super().__init__(settings, "docker-proxy", settings.docker_proxy_base_url)
|
||||
self.beszel_client = BeszelClient(settings)
|
||||
|
||||
async def fetch_containers(self) -> DockerSnapshot:
|
||||
snapshot = DockerSnapshot()
|
||||
payload = await self._request_json("GET", "/containers/json", params={"all": "true"})
|
||||
if not isinstance(payload, list):
|
||||
logger.warning("docker proxy returned non-list payload: %s", payload)
|
||||
fallback = await self.beszel_client.fetch_container_snapshot()
|
||||
if fallback.source_status == "online":
|
||||
logger.info("docker proxy fallback to beszel containers succeeded")
|
||||
return fallback
|
||||
logger.warning(
|
||||
"docker integration unavailable: docker proxy unreachable and beszel container fallback returned no usable data"
|
||||
)
|
||||
return snapshot
|
||||
|
||||
logger.info("docker proxy raw payload count: %s", len(payload))
|
||||
logger.info("docker proxy raw payload sample: %s", payload[:3])
|
||||
|
||||
containers: list[DockerContainerSummary] = []
|
||||
running = 0
|
||||
stopped = 0
|
||||
unhealthy = 0
|
||||
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
state = self._normalize_state(item)
|
||||
if state == "running":
|
||||
running += 1
|
||||
elif state == "unhealthy":
|
||||
unhealthy += 1
|
||||
else:
|
||||
stopped += 1
|
||||
|
||||
containers.append(
|
||||
DockerContainerSummary(
|
||||
id=str(item.get("Id") or item.get("ID") or ""),
|
||||
name=self._normalize_name(item.get("Names")),
|
||||
state=state,
|
||||
status_text=str(item.get("Status") or item.get("State") or "unknown"),
|
||||
image=str(item.get("Image") or ""),
|
||||
health=self._extract_health(item),
|
||||
)
|
||||
)
|
||||
|
||||
normalized = DockerSnapshot(
|
||||
source_status="online",
|
||||
running=running,
|
||||
stopped=stopped,
|
||||
unhealthy=unhealthy,
|
||||
total=len(containers),
|
||||
containers=containers,
|
||||
)
|
||||
logger.info("docker proxy normalized snapshot: %s", normalized.model_dump())
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize_name(names: object) -> str:
|
||||
if isinstance(names, list) and names:
|
||||
return str(names[0]).lstrip("/")
|
||||
return "unknown"
|
||||
|
||||
@classmethod
|
||||
def _normalize_state(cls, item: dict) -> str:
|
||||
status_text = str(item.get("Status") or "").lower()
|
||||
state = str(item.get("State") or "").lower()
|
||||
health = cls._extract_health(item)
|
||||
|
||||
if health == "unhealthy" or "unhealthy" in status_text:
|
||||
return "unhealthy"
|
||||
if state == "running":
|
||||
return "running"
|
||||
if state:
|
||||
return "stopped"
|
||||
return "unknown"
|
||||
|
||||
@staticmethod
|
||||
def _extract_health(item: dict) -> str | None:
|
||||
if isinstance(item.get("Health"), str):
|
||||
return str(item["Health"]).lower()
|
||||
if isinstance(item.get("State"), dict):
|
||||
health = item["State"].get("Health")
|
||||
if isinstance(health, dict):
|
||||
return str(health.get("Status") or "").lower() or None
|
||||
return None
|
||||
@@ -1,83 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
from time import perf_counter
|
||||
|
||||
from app.clients.base import BaseHTTPClient
|
||||
from app.config import Settings
|
||||
from app.models.sources import HomeAssistantSnapshot
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HomeAssistantClient(BaseHTTPClient):
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
super().__init__(settings, "home-assistant", settings.home_assistant_base_url)
|
||||
|
||||
async def fetch_status(self) -> HomeAssistantSnapshot:
|
||||
snapshot = HomeAssistantSnapshot()
|
||||
if not self.base_url or not self.settings.home_assistant_token:
|
||||
logger.info("home assistant skipped: base URL or token missing")
|
||||
return snapshot
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.settings.home_assistant_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
started_at = perf_counter()
|
||||
api_info = await self._request_json("GET", "/api/", headers=headers)
|
||||
logger.info("home assistant raw /api/ response: %s", api_info)
|
||||
|
||||
elapsed_ms = int((perf_counter() - started_at) * 1000)
|
||||
config = await self._request_json("GET", "/api/config", headers=headers)
|
||||
logger.info("home assistant raw /api/config response: %s", config)
|
||||
|
||||
version = None
|
||||
if isinstance(config, dict):
|
||||
version = config.get("version")
|
||||
|
||||
# Fetch entity states for widget counts
|
||||
lights_on = 0
|
||||
lights_total = 0
|
||||
climate_active = 0
|
||||
doors_open = 0
|
||||
alerts = 0
|
||||
|
||||
try:
|
||||
states = await self._request_json("GET", "/api/states", headers=headers)
|
||||
if isinstance(states, list):
|
||||
for entity in states:
|
||||
eid = entity.get("entity_id", "")
|
||||
state = entity.get("state", "")
|
||||
if eid.startswith("light."):
|
||||
lights_total += 1
|
||||
if state == "on":
|
||||
lights_on += 1
|
||||
elif eid.startswith("climate."):
|
||||
if state not in ("off", "unavailable", "unknown"):
|
||||
climate_active += 1
|
||||
elif eid.startswith("binary_sensor.") and "door" in eid:
|
||||
if state == "on":
|
||||
doors_open += 1
|
||||
elif eid.startswith("persistent_notification."):
|
||||
if state == "notifying":
|
||||
alerts += 1
|
||||
except Exception as exc:
|
||||
logger.warning("home assistant fetch states failed: %s", exc)
|
||||
|
||||
normalized = HomeAssistantSnapshot(
|
||||
status="online",
|
||||
version=str(version) if version else None,
|
||||
response_time_ms=elapsed_ms,
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
lights_on=lights_on,
|
||||
lights_total=lights_total,
|
||||
climate_active=climate_active,
|
||||
doors_open=doors_open,
|
||||
alerts=alerts,
|
||||
)
|
||||
logger.info("home assistant normalized snapshot: %s", normalized.model_dump())
|
||||
return normalized
|
||||
@@ -1,45 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.clients.base import BaseHTTPClient
|
||||
from app.config import Settings
|
||||
from app.models.sources import ImmichSnapshot
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImmichClient(BaseHTTPClient):
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
super().__init__(settings, "immich", settings.immich_base_url)
|
||||
|
||||
async def fetch_stats(self) -> ImmichSnapshot:
|
||||
snapshot = ImmichSnapshot()
|
||||
if not self.base_url or not self.settings.immich_api_key:
|
||||
logger.info("immich skipped: base URL or API key missing")
|
||||
return snapshot
|
||||
|
||||
headers = {"x-api-key": self.settings.immich_api_key}
|
||||
|
||||
try:
|
||||
data = await self._request_json("GET", "/api/server/statistics", headers=headers)
|
||||
except Exception as exc:
|
||||
logger.warning("immich fetch_stats failed: %s", exc)
|
||||
return snapshot
|
||||
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("immich unexpected response type: %s", type(data))
|
||||
return snapshot
|
||||
|
||||
photos = int(data.get("photos", 0))
|
||||
videos = int(data.get("videos", 0))
|
||||
usage_bytes = int(data.get("usage", 0))
|
||||
usage_gb = round(usage_bytes / (1024 ** 3), 1)
|
||||
|
||||
return ImmichSnapshot(
|
||||
source_status="online",
|
||||
photos=photos,
|
||||
videos=videos,
|
||||
storage_gb=usage_gb,
|
||||
)
|
||||
@@ -1,79 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.clients.base import BaseHTTPClient
|
||||
from app.config import Settings
|
||||
from app.models.sources import ScrutinyDevice, ScrutinySnapshot
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScrutinyClient(BaseHTTPClient):
|
||||
"""
|
||||
Reads SMART disk health data from Scrutiny's /api/summary endpoint.
|
||||
No authentication required.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
super().__init__(settings, "scrutiny", settings.scrutiny_base_url)
|
||||
|
||||
async def fetch_summary(self) -> ScrutinySnapshot:
|
||||
snapshot = ScrutinySnapshot()
|
||||
if not self.base_url:
|
||||
logger.info("scrutiny skipped: base URL missing")
|
||||
return snapshot
|
||||
|
||||
data = await self._request_json("GET", "/api/summary")
|
||||
|
||||
if data is None:
|
||||
logger.warning("scrutiny: empty or failed response")
|
||||
return snapshot
|
||||
|
||||
devices = self._parse_devices(data)
|
||||
failed = sum(1 for d in devices if d.status == "failed")
|
||||
overall: str = "online" if failed == 0 and len(devices) > 0 else ("offline" if failed > 0 else "offline")
|
||||
|
||||
result = ScrutinySnapshot(
|
||||
source_status="online",
|
||||
devices=devices,
|
||||
overall_status=overall,
|
||||
failed_count=failed,
|
||||
total_count=len(devices),
|
||||
)
|
||||
logger.info("scrutiny summary: %s devices, %s failed", len(devices), failed)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _parse_devices(data: dict) -> list[ScrutinyDevice]:
|
||||
devices: list[ScrutinyDevice] = []
|
||||
summary: dict = (data.get("data") or {}).get("summary") or {}
|
||||
|
||||
for device_path, device_data in summary.items():
|
||||
device_info: dict = device_data.get("device") or {}
|
||||
smart_data = device_data.get("smart") or []
|
||||
|
||||
name = device_info.get("device_name") or device_path.split("/")[-1]
|
||||
model = device_info.get("model_name") or "Unknown"
|
||||
|
||||
if isinstance(smart_data, dict):
|
||||
# smart is a dict keyed by timestamp strings; grab the most recent value
|
||||
latest_smart = smart_data[max(smart_data)] if smart_data else {}
|
||||
elif isinstance(smart_data, list):
|
||||
latest_smart = smart_data[-1] if smart_data else {}
|
||||
else:
|
||||
latest_smart = {}
|
||||
if not isinstance(latest_smart, dict):
|
||||
latest_smart = {}
|
||||
status_code = latest_smart.get("Status", -1)
|
||||
if status_code == 0:
|
||||
status = "passed"
|
||||
elif status_code > 0:
|
||||
status = "failed"
|
||||
else:
|
||||
status = "unknown"
|
||||
|
||||
devices.append(ScrutinyDevice(name=name, model=model, status=status))
|
||||
|
||||
return sorted(devices, key=lambda d: d.name)
|
||||
@@ -1,190 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
from app.clients.base import BaseHTTPClient
|
||||
from app.config import Settings
|
||||
from app.models.sources import UptimeKumaMonitor, UptimeKumaSnapshot
|
||||
|
||||
METRIC_LINE_RE = re.compile(r'^(?P<name>[a-zA-Z_:][a-zA-Z0-9_:]*){(?P<labels>[^}]*)}s+(?P<value>.+)$')
|
||||
LABEL_RE = re.compile(r'(w+)="((?:[^"\\]|\\.)*)"')
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UptimeKumaClient(BaseHTTPClient):
|
||||
"""
|
||||
Reads Uptime Kuma monitor status from the /metrics endpoint.
|
||||
|
||||
Auth: once an API key exists in Uptime Kuma, username/password Basic Auth
|
||||
is permanently disabled. The correct format is HTTP Basic Auth with an
|
||||
empty username and the API key as the password: auth=("", api_key).
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
super().__init__(settings, "uptime-kuma", settings.uptime_kuma_base_url)
|
||||
|
||||
async def fetch_monitors(self) -> UptimeKumaSnapshot:
|
||||
snapshot = UptimeKumaSnapshot()
|
||||
if not self.base_url:
|
||||
logger.info("uptime kuma skipped: no base URL configured")
|
||||
return snapshot
|
||||
|
||||
raw_metrics: str | None = None
|
||||
|
||||
# Primary: API key as Basic Auth password (empty username)
|
||||
if self.settings.uptime_kuma_api_key:
|
||||
raw_metrics = await self._request_metrics_with_mode(
|
||||
"api-key",
|
||||
auth=("", self.settings.uptime_kuma_api_key),
|
||||
)
|
||||
|
||||
# Fallback: regular username/password (only works if no API keys exist)
|
||||
if raw_metrics is None and self.settings.uptime_kuma_username and self.settings.uptime_kuma_password:
|
||||
raw_metrics = await self._request_metrics_with_mode(
|
||||
"basic-user",
|
||||
auth=(self.settings.uptime_kuma_username, self.settings.uptime_kuma_password),
|
||||
)
|
||||
|
||||
if raw_metrics is None and not (
|
||||
self.settings.uptime_kuma_api_key
|
||||
or (self.settings.uptime_kuma_username and self.settings.uptime_kuma_password)
|
||||
):
|
||||
logger.info("uptime kuma skipped: no usable metrics auth configured")
|
||||
return snapshot
|
||||
|
||||
if not raw_metrics:
|
||||
logger.warning("uptime kuma returned empty metrics payload or metrics auth failed")
|
||||
return snapshot
|
||||
|
||||
logger.info("uptime kuma raw metrics first 40 lines: %s", raw_metrics.splitlines()[:40])
|
||||
monitors = self._parse_metrics(raw_metrics)
|
||||
up = sum(1 for monitor in monitors if monitor.status == "online")
|
||||
down = sum(1 for monitor in monitors if monitor.status == "offline")
|
||||
paused = sum(1 for monitor in monitors if monitor.status == "degraded")
|
||||
normalized = UptimeKumaSnapshot(
|
||||
source_status="online",
|
||||
monitors_up=up,
|
||||
monitors_down=down,
|
||||
monitors_paused=paused,
|
||||
total=len(monitors),
|
||||
monitors=monitors,
|
||||
)
|
||||
logger.info("uptime kuma normalized snapshot: %s", normalized.model_dump())
|
||||
return normalized
|
||||
|
||||
async def _request_metrics_with_mode(
|
||||
self,
|
||||
mode: str,
|
||||
*,
|
||||
auth: tuple[str, str] | None = None,
|
||||
) -> str | None:
|
||||
if not self.base_url:
|
||||
return None
|
||||
url = f"{self.base_url}/metrics"
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=self.settings.request_timeout_seconds,
|
||||
trust_env=False,
|
||||
) as client:
|
||||
response = await client.request("GET", url, auth=auth)
|
||||
if response.status_code == 200 and response.text:
|
||||
logger.info("uptime kuma metrics auth succeeded via %s", mode)
|
||||
return response.text
|
||||
if response.status_code in (401, 403):
|
||||
logger.warning(
|
||||
"uptime kuma metrics auth failed via %s with status %s",
|
||||
mode,
|
||||
response.status_code,
|
||||
)
|
||||
return None
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("uptime kuma metrics request timed out via %s", mode)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("uptime kuma metrics request error via %s: %s", mode, exc)
|
||||
return None
|
||||
|
||||
def _parse_metrics(self, payload: str) -> list[UptimeKumaMonitor]:
|
||||
status_by_id: dict[str, UptimeKumaMonitor] = {}
|
||||
for line in payload.splitlines():
|
||||
parsed = self._parse_metric_line(line)
|
||||
if parsed is None:
|
||||
continue
|
||||
metric_name, labels, raw_value = parsed
|
||||
monitor_id = labels.get("monitor_id") or labels.get("id") or labels.get("monitor") or labels.get("monitor_name")
|
||||
monitor_name = labels.get("monitor_name") or labels.get("name")
|
||||
if not monitor_id or not monitor_name:
|
||||
continue
|
||||
if monitor_id not in status_by_id:
|
||||
status_by_id[monitor_id] = UptimeKumaMonitor(
|
||||
id=self._as_int(monitor_id),
|
||||
name=monitor_name,
|
||||
)
|
||||
monitor = status_by_id[monitor_id]
|
||||
if metric_name == "monitor_status":
|
||||
status_code = self._as_int_from_float(raw_value)
|
||||
if status_code == 1:
|
||||
monitor.status = "online"
|
||||
elif status_code == 3:
|
||||
monitor.status = "degraded"
|
||||
else:
|
||||
monitor.status = "offline"
|
||||
elif metric_name == "monitor_response_time":
|
||||
latency = self._as_float(raw_value)
|
||||
monitor.latency_ms = int(latency) if latency >= 0 else None
|
||||
elif metric_name == "monitor_uptime":
|
||||
duration = labels.get("duration", "")
|
||||
if duration == "24":
|
||||
uptime = self._as_float(raw_value)
|
||||
if 0.0 <= uptime <= 1.0:
|
||||
monitor.uptime_24h = round(uptime * 100, 1)
|
||||
|
||||
# Build synthetic heartbeat bar (20 segments) from uptime_24h
|
||||
for monitor in status_by_id.values():
|
||||
if not monitor.heartbeats:
|
||||
if monitor.uptime_24h >= 99.9:
|
||||
monitor.heartbeats = [1] * 20
|
||||
elif monitor.uptime_24h <= 0.1:
|
||||
monitor.heartbeats = [0] * 20
|
||||
else:
|
||||
green_count = round(monitor.uptime_24h / 100 * 20)
|
||||
monitor.heartbeats = [0] * (20 - green_count) + [1] * green_count
|
||||
|
||||
return list(status_by_id.values())
|
||||
|
||||
@staticmethod
|
||||
def _parse_metric_line(line: str) -> tuple[str, dict[str, str], str] | None:
|
||||
if not line or line.startswith("#"):
|
||||
return None
|
||||
match = METRIC_LINE_RE.match(line.strip())
|
||||
if not match:
|
||||
return None
|
||||
labels = {
|
||||
key: value.encode("utf-8").decode("unicode_escape")
|
||||
for key, value in LABEL_RE.findall(match.group("labels"))
|
||||
}
|
||||
return match.group("name"), labels, match.group("value")
|
||||
|
||||
@staticmethod
|
||||
def _as_float(value: str) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return -1.0
|
||||
|
||||
@staticmethod
|
||||
def _as_int(value: str) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _as_int_from_float(value: str) -> int:
|
||||
try:
|
||||
return int(float(value))
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
@@ -1,79 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, HttpUrl
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
BACKEND_ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||
ENV_FILE_PATH = BACKEND_ROOT_DIR / ".env"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=ENV_FILE_PATH,
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
app_env: Literal["development", "production", "test"] = "development"
|
||||
app_host: str = "0.0.0.0"
|
||||
app_port: int = 8000
|
||||
app_log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
|
||||
app_timezone: str = "Europe/Berlin"
|
||||
app_name: str = "Homelab Dashboard API"
|
||||
app_version: str = "0.1.0"
|
||||
app_root_dir: Path = Path(__file__).resolve().parents[2]
|
||||
|
||||
cors_allow_origins: list[str] = Field(default_factory=lambda: ["http://localhost:3000"])
|
||||
|
||||
cache_ttl_overview_seconds: int = Field(default=20, ge=1)
|
||||
cache_ttl_system_seconds: int = Field(default=10, ge=1)
|
||||
cache_ttl_services_seconds: int = Field(default=15, ge=1)
|
||||
cache_ttl_storage_seconds: int = Field(default=30, ge=1)
|
||||
|
||||
request_timeout_seconds: float = Field(default=5.0, gt=0)
|
||||
|
||||
beszel_base_url: HttpUrl | None = None
|
||||
beszel_api_token: str | None = None
|
||||
beszel_admin_email: str | None = None
|
||||
beszel_admin_password: str | None = None
|
||||
|
||||
docker_proxy_base_url: HttpUrl | None = None
|
||||
|
||||
uptime_kuma_base_url: HttpUrl | None = None
|
||||
uptime_kuma_api_key: str | None = None
|
||||
uptime_kuma_username: str | None = None
|
||||
uptime_kuma_password: str | None = None
|
||||
|
||||
home_assistant_base_url: HttpUrl | None = None
|
||||
home_assistant_token: str | None = None
|
||||
|
||||
adguard_base_url: HttpUrl | None = None
|
||||
adguard_username: str | None = None
|
||||
adguard_password: str | None = None
|
||||
|
||||
scrutiny_base_url: HttpUrl | None = None
|
||||
|
||||
immich_base_url: HttpUrl | None = None
|
||||
immich_api_key: str | None = None
|
||||
|
||||
backrest_base_url: HttpUrl | None = None
|
||||
backrest_username: str | None = None
|
||||
backrest_password: str | None = None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
def configure_logging(level: str) -> None:
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, level.upper(), logging.INFO),
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
)
|
||||
@@ -1,93 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.config import configure_logging, get_settings
|
||||
from app.routes.adguard import router as adguard_router
|
||||
from app.routes.backrest import router as backrest_router
|
||||
from app.routes.home_assistant import router as home_assistant_router
|
||||
from app.routes.immich import router as immich_router
|
||||
from app.routes.overview import router as overview_router
|
||||
from app.routes.scrutiny import router as scrutiny_router
|
||||
from app.routes.services import router as services_router
|
||||
from app.routes.storage import router as storage_router
|
||||
from app.routes.system import router as system_router
|
||||
from app.routes.uptime_kuma import router as uptime_kuma_router
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
configure_logging(settings.app_log_level)
|
||||
logger.info("Starting %s v%s in %s mode", settings.app_name, settings.app_version, settings.app_env)
|
||||
logger.info(
|
||||
"Config loaded: HOME_ASSISTANT_BASE_URL=%s HOME_ASSISTANT_TOKEN_SET=%s BESZEL_BASE_URL=%s DOCKER_PROXY_BASE_URL=%s UPTIME_KUMA_BASE_URL=%s IMMICH_BASE_URL=%s BACKREST_BASE_URL=%s",
|
||||
bool(settings.home_assistant_base_url),
|
||||
bool(settings.home_assistant_token),
|
||||
bool(settings.beszel_base_url),
|
||||
bool(settings.docker_proxy_base_url),
|
||||
bool(settings.uptime_kuma_base_url),
|
||||
bool(settings.immich_base_url),
|
||||
bool(settings.backrest_base_url),
|
||||
)
|
||||
yield
|
||||
logger.info("Stopping %s", settings.app_name)
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version=settings.app_version,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_allow_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(overview_router)
|
||||
app.include_router(system_router)
|
||||
app.include_router(services_router)
|
||||
app.include_router(storage_router)
|
||||
app.include_router(adguard_router)
|
||||
app.include_router(scrutiny_router)
|
||||
app.include_router(immich_router)
|
||||
app.include_router(backrest_router)
|
||||
app.include_router(home_assistant_router)
|
||||
app.include_router(uptime_kuma_router)
|
||||
|
||||
assets_dir = settings.app_root_dir / "assets"
|
||||
dashboard_file = settings.app_root_dir / "dashboard.html"
|
||||
|
||||
if assets_dir.exists():
|
||||
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||||
|
||||
|
||||
@app.get("/health", tags=["health"])
|
||||
async def health() -> dict[str, str]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": settings.app_name,
|
||||
"version": settings.app_version,
|
||||
"environment": settings.app_env,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def dashboard() -> FileResponse:
|
||||
return FileResponse(dashboard_file)
|
||||
@@ -1 +0,0 @@
|
||||
"""Pydantic models for API and domain data."""
|
||||
@@ -1,23 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
SourceStatus = Literal["online", "offline", "unsupported"]
|
||||
OverallStatus = Literal["online", "degraded", "offline"]
|
||||
HealthStatus = Literal["healthy", "warning", "offline"]
|
||||
DiskStatus = Literal["online", "warning", "critical", "offline"]
|
||||
ServiceKind = Literal["core", "service"]
|
||||
ServiceSource = Literal["home_assistant", "uptime_kuma", "docker", "manual"]
|
||||
DockerContainerState = Literal["running", "stopped", "unhealthy", "unknown"]
|
||||
|
||||
|
||||
class APIModel(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
|
||||
class TimestampedResponse(APIModel):
|
||||
generated_at: datetime
|
||||
@@ -1,44 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models.common import APIModel, OverallStatus, SourceStatus, TimestampedResponse
|
||||
|
||||
|
||||
class OverviewServicesSummary(APIModel):
|
||||
online: int
|
||||
degraded: int
|
||||
offline: int
|
||||
total: int
|
||||
|
||||
|
||||
class OverviewDockerSummary(APIModel):
|
||||
running: int
|
||||
stopped: int
|
||||
unhealthy: int
|
||||
total: int
|
||||
source_status: SourceStatus
|
||||
|
||||
|
||||
class OverviewSystemSummary(APIModel):
|
||||
cpu_percent: float
|
||||
ram_percent: float
|
||||
root_storage_percent: float
|
||||
network_rx_mbps: float
|
||||
network_tx_mbps: float
|
||||
uptime_seconds: int
|
||||
|
||||
|
||||
class OverviewHomeAssistantSummary(APIModel):
|
||||
status: SourceStatus
|
||||
label: str
|
||||
version: str | None = None
|
||||
response_time_ms: int | None = None
|
||||
last_checked: str | None = None
|
||||
|
||||
|
||||
class OverviewResponse(TimestampedResponse):
|
||||
overall_status: OverallStatus
|
||||
refresh_hint_seconds: int
|
||||
services: OverviewServicesSummary
|
||||
docker: OverviewDockerSummary
|
||||
system: OverviewSystemSummary
|
||||
home_assistant: OverviewHomeAssistantSummary
|
||||
@@ -1,52 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models.common import (
|
||||
APIModel,
|
||||
DockerContainerState,
|
||||
HealthStatus,
|
||||
OverallStatus,
|
||||
ServiceKind,
|
||||
ServiceSource,
|
||||
SourceStatus,
|
||||
TimestampedResponse,
|
||||
)
|
||||
|
||||
|
||||
class ServicesDockerSummary(APIModel):
|
||||
running: int
|
||||
stopped: int
|
||||
unhealthy: int
|
||||
total: int
|
||||
source_status: SourceStatus
|
||||
|
||||
|
||||
class ServicesUptimeKumaSummary(APIModel):
|
||||
monitors_up: int
|
||||
monitors_down: int
|
||||
monitors_paused: int
|
||||
total: int
|
||||
source_status: SourceStatus
|
||||
|
||||
|
||||
class ServicesSummary(APIModel):
|
||||
overall_status: OverallStatus
|
||||
docker: ServicesDockerSummary
|
||||
uptime_kuma: ServicesUptimeKumaSummary
|
||||
|
||||
|
||||
class ServiceItem(APIModel):
|
||||
id: str
|
||||
name: str
|
||||
kind: ServiceKind
|
||||
status: OverallStatus
|
||||
health: HealthStatus
|
||||
latency_ms: int | None = None
|
||||
docker_state: DockerContainerState
|
||||
url: str | None = None
|
||||
source: ServiceSource
|
||||
last_checked: str | None = None
|
||||
|
||||
|
||||
class ServicesResponse(TimestampedResponse):
|
||||
summary: ServicesSummary
|
||||
services: list[ServiceItem]
|
||||
@@ -1,132 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.models.common import APIModel, DockerContainerState, OverallStatus, SourceStatus
|
||||
|
||||
|
||||
class BeszelDiskMetric(APIModel):
|
||||
name: str
|
||||
mount: str
|
||||
used_gb: float
|
||||
total_gb: float
|
||||
free_gb: float
|
||||
usage_percent: float
|
||||
|
||||
|
||||
class BeszelSystemSnapshot(APIModel):
|
||||
source_name: str = "beszel"
|
||||
source_status: SourceStatus = "offline"
|
||||
host_name: str = "unknown"
|
||||
agent_name: str = "beszel-agent"
|
||||
cpu_usage_percent: float = 0.0
|
||||
cpu_cores: int = 0
|
||||
load_1: float = 0.0
|
||||
load_5: float = 0.0
|
||||
load_15: float = 0.0
|
||||
memory_used_gb: float = 0.0
|
||||
memory_total_gb: float = 0.0
|
||||
memory_available_gb: float = 0.0
|
||||
memory_usage_percent: float = 0.0
|
||||
primary_interface: str = "unknown"
|
||||
network_rx_mbps: float = 0.0
|
||||
network_tx_mbps: float = 0.0
|
||||
uptime_seconds: int = 0
|
||||
platform: str = "unknown"
|
||||
kernel: str = "unknown"
|
||||
disks: list[BeszelDiskMetric] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DockerContainerSummary(APIModel):
|
||||
id: str
|
||||
name: str
|
||||
state: DockerContainerState
|
||||
status_text: str
|
||||
image: str
|
||||
health: str | None = None
|
||||
|
||||
|
||||
class DockerSnapshot(APIModel):
|
||||
source_name: str = "docker"
|
||||
source_status: SourceStatus = "offline"
|
||||
containers: list[DockerContainerSummary] = Field(default_factory=list)
|
||||
running: int = 0
|
||||
stopped: int = 0
|
||||
unhealthy: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class UptimeKumaMonitor(APIModel):
|
||||
id: int
|
||||
name: str
|
||||
status: str = "unknown" # "online" | "offline" | "degraded" | "unknown"
|
||||
uptime_24h: float = 0.0
|
||||
heartbeats: list[int] = Field(default_factory=list) # 1=up, 0=down, last 20
|
||||
|
||||
|
||||
class UptimeKumaSnapshot(APIModel):
|
||||
source_name: str = "uptime_kuma"
|
||||
source_status: SourceStatus = "offline"
|
||||
monitors_up: int = 0
|
||||
monitors_down: int = 0
|
||||
monitors_paused: int = 0
|
||||
total: int = 0
|
||||
monitors: list[UptimeKumaMonitor] = Field(default_factory=list)
|
||||
|
||||
|
||||
class HomeAssistantSnapshot(APIModel):
|
||||
source_name: str = "home_assistant"
|
||||
status: SourceStatus = "offline"
|
||||
label: str = "Home Assistant"
|
||||
version: str | None = None
|
||||
response_time_ms: int | None = None
|
||||
last_checked: datetime | None = None
|
||||
lights_on: int = 0
|
||||
lights_total: int = 0
|
||||
climate_active: int = 0
|
||||
doors_open: int = 0
|
||||
alerts: int = 0
|
||||
|
||||
|
||||
class AdGuardSnapshot(APIModel):
|
||||
source_name: str = "adguard"
|
||||
source_status: SourceStatus = "offline"
|
||||
total_queries: int = 0
|
||||
blocked_queries: int = 0
|
||||
blocked_percent: float = 0.0
|
||||
avg_processing_ms: float = 0.0
|
||||
|
||||
|
||||
class ScrutinyDevice(APIModel):
|
||||
name: str
|
||||
model: str
|
||||
status: str = "unknown" # "passed" | "failed" | "unknown"
|
||||
temperature: int | None = None
|
||||
|
||||
|
||||
class ScrutinySnapshot(APIModel):
|
||||
source_name: str = "scrutiny"
|
||||
source_status: SourceStatus = "offline"
|
||||
overall_status: str = "offline"
|
||||
devices: list[ScrutinyDevice] = Field(default_factory=list)
|
||||
failed_count: int = 0
|
||||
total_count: int = 0
|
||||
|
||||
|
||||
class ImmichSnapshot(APIModel):
|
||||
source_name: str = "immich"
|
||||
source_status: SourceStatus = "offline"
|
||||
photos: int = 0
|
||||
videos: int = 0
|
||||
storage_gb: float = 0.0
|
||||
|
||||
|
||||
class BackrestSnapshot(APIModel):
|
||||
source_name: str = "backrest"
|
||||
source_status: SourceStatus = "offline"
|
||||
repo_count: int = 0
|
||||
last_backup_age_hours: float | None = None
|
||||
last_backup_status: str = "unknown" # "ok" | "error" | "unknown"
|
||||
error_count: int = 0
|
||||
@@ -1,27 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models.common import APIModel, DiskStatus, OverallStatus, SourceStatus, TimestampedResponse
|
||||
|
||||
|
||||
class StorageSummary(APIModel):
|
||||
overall_status: OverallStatus
|
||||
source_status: SourceStatus
|
||||
critical_disks: int
|
||||
warning_disks: int
|
||||
total_disks: int
|
||||
|
||||
|
||||
class StorageDisk(APIModel):
|
||||
name: str
|
||||
mount: str
|
||||
used_gb: float
|
||||
total_gb: float
|
||||
free_gb: float
|
||||
usage_percent: float
|
||||
status: DiskStatus
|
||||
|
||||
|
||||
class StorageResponse(TimestampedResponse):
|
||||
summary: StorageSummary
|
||||
root: StorageDisk
|
||||
disks: list[StorageDisk]
|
||||
@@ -1,45 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models.common import APIModel, SourceStatus, TimestampedResponse
|
||||
|
||||
|
||||
class SystemSource(APIModel):
|
||||
name: str
|
||||
status: SourceStatus
|
||||
host_name: str
|
||||
agent_name: str
|
||||
|
||||
|
||||
class SystemCPU(APIModel):
|
||||
usage_percent: float
|
||||
cores: int
|
||||
load_1: float
|
||||
load_5: float
|
||||
load_15: float
|
||||
|
||||
|
||||
class SystemMemory(APIModel):
|
||||
used_gb: float
|
||||
total_gb: float
|
||||
available_gb: float
|
||||
usage_percent: float
|
||||
|
||||
|
||||
class SystemNetwork(APIModel):
|
||||
primary_interface: str
|
||||
rx_mbps: float
|
||||
tx_mbps: float
|
||||
|
||||
|
||||
class SystemHost(APIModel):
|
||||
uptime_seconds: int
|
||||
platform: str
|
||||
kernel: str
|
||||
|
||||
|
||||
class SystemResponse(TimestampedResponse):
|
||||
source: SystemSource
|
||||
cpu: SystemCPU
|
||||
memory: SystemMemory
|
||||
network: SystemNetwork
|
||||
host: SystemHost
|
||||
@@ -1 +0,0 @@
|
||||
"""API route modules."""
|
||||
@@ -1,16 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.models.sources import AdGuardSnapshot
|
||||
from app.services.aggregator import AggregatorService, get_aggregator_service
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["adguard"])
|
||||
|
||||
|
||||
@router.get("/adguard", response_model=AdGuardSnapshot)
|
||||
async def get_adguard(
|
||||
aggregator: AggregatorService = Depends(get_aggregator_service),
|
||||
) -> AdGuardSnapshot:
|
||||
return await aggregator.get_adguard()
|
||||
@@ -1,16 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.models.sources import BackrestSnapshot
|
||||
from app.services.aggregator import AggregatorService, get_aggregator_service
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["backrest"])
|
||||
|
||||
|
||||
@router.get("/backrest", response_model=BackrestSnapshot)
|
||||
async def get_backrest(
|
||||
aggregator: AggregatorService = Depends(get_aggregator_service),
|
||||
) -> BackrestSnapshot:
|
||||
return await aggregator.get_backrest()
|
||||
@@ -1,16 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.models.sources import HomeAssistantSnapshot
|
||||
from app.services.aggregator import AggregatorService, get_aggregator_service
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["home_assistant"])
|
||||
|
||||
|
||||
@router.get("/home_assistant", response_model=HomeAssistantSnapshot)
|
||||
async def get_home_assistant(
|
||||
aggregator: AggregatorService = Depends(get_aggregator_service),
|
||||
) -> HomeAssistantSnapshot:
|
||||
return await aggregator.get_home_assistant()
|
||||
@@ -1,16 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.models.sources import ImmichSnapshot
|
||||
from app.services.aggregator import AggregatorService, get_aggregator_service
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["immich"])
|
||||
|
||||
|
||||
@router.get("/immich", response_model=ImmichSnapshot)
|
||||
async def get_immich(
|
||||
aggregator: AggregatorService = Depends(get_aggregator_service),
|
||||
) -> ImmichSnapshot:
|
||||
return await aggregator.get_immich()
|
||||
@@ -1,16 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.models.overview import OverviewResponse
|
||||
from app.services.aggregator import AggregatorService, get_aggregator_service
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["overview"])
|
||||
|
||||
|
||||
@router.get("/overview", response_model=OverviewResponse)
|
||||
async def get_overview(
|
||||
aggregator: AggregatorService = Depends(get_aggregator_service),
|
||||
) -> OverviewResponse:
|
||||
return await aggregator.get_overview()
|
||||
@@ -1,16 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.models.sources import ScrutinySnapshot
|
||||
from app.services.aggregator import AggregatorService, get_aggregator_service
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["scrutiny"])
|
||||
|
||||
|
||||
@router.get("/scrutiny", response_model=ScrutinySnapshot)
|
||||
async def get_scrutiny(
|
||||
aggregator: AggregatorService = Depends(get_aggregator_service),
|
||||
) -> ScrutinySnapshot:
|
||||
return await aggregator.get_scrutiny()
|
||||
@@ -1,16 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.models.services import ServicesResponse
|
||||
from app.services.aggregator import AggregatorService, get_aggregator_service
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["services"])
|
||||
|
||||
|
||||
@router.get("/services", response_model=ServicesResponse)
|
||||
async def get_services(
|
||||
aggregator: AggregatorService = Depends(get_aggregator_service),
|
||||
) -> ServicesResponse:
|
||||
return await aggregator.get_services()
|
||||
@@ -1,16 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.models.storage import StorageResponse
|
||||
from app.services.aggregator import AggregatorService, get_aggregator_service
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["storage"])
|
||||
|
||||
|
||||
@router.get("/storage", response_model=StorageResponse)
|
||||
async def get_storage(
|
||||
aggregator: AggregatorService = Depends(get_aggregator_service),
|
||||
) -> StorageResponse:
|
||||
return await aggregator.get_storage()
|
||||
@@ -1,16 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.models.system import SystemResponse
|
||||
from app.services.aggregator import AggregatorService, get_aggregator_service
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["system"])
|
||||
|
||||
|
||||
@router.get("/system", response_model=SystemResponse)
|
||||
async def get_system(
|
||||
aggregator: AggregatorService = Depends(get_aggregator_service),
|
||||
) -> SystemResponse:
|
||||
return await aggregator.get_system()
|
||||
@@ -1,16 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.models.sources import UptimeKumaSnapshot
|
||||
from app.services.aggregator import AggregatorService, get_aggregator_service
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["uptime_kuma"])
|
||||
|
||||
|
||||
@router.get("/uptime_kuma", response_model=UptimeKumaSnapshot)
|
||||
async def get_uptime_kuma(
|
||||
aggregator: AggregatorService = Depends(get_aggregator_service),
|
||||
) -> UptimeKumaSnapshot:
|
||||
return await aggregator.get_uptime_kuma()
|
||||
@@ -1 +0,0 @@
|
||||
"""Application services."""
|
||||
@@ -1,429 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
from typing import Iterable
|
||||
|
||||
from app.clients.adguard_client import AdGuardClient
|
||||
from app.clients.backrest_client import BackrestClient
|
||||
from app.clients.beszel_client import BeszelClient
|
||||
from app.clients.docker_proxy_client import DockerProxyClient
|
||||
from app.clients.home_assistant_client import HomeAssistantClient
|
||||
from app.clients.immich_client import ImmichClient
|
||||
from app.clients.scrutiny_client import ScrutinyClient
|
||||
from app.clients.uptime_kuma_client import UptimeKumaClient
|
||||
from app.config import Settings, get_settings
|
||||
from app.models.common import DiskStatus, HealthStatus, OverallStatus
|
||||
from app.models.overview import (
|
||||
OverviewDockerSummary,
|
||||
OverviewHomeAssistantSummary,
|
||||
OverviewResponse,
|
||||
OverviewServicesSummary,
|
||||
OverviewSystemSummary,
|
||||
)
|
||||
from app.models.services import (
|
||||
ServiceItem,
|
||||
ServicesDockerSummary,
|
||||
ServicesResponse,
|
||||
ServicesSummary,
|
||||
ServicesUptimeKumaSummary,
|
||||
)
|
||||
from app.models.sources import (
|
||||
AdGuardSnapshot,
|
||||
BackrestSnapshot,
|
||||
BeszelDiskMetric,
|
||||
BeszelSystemSnapshot,
|
||||
DockerSnapshot,
|
||||
HomeAssistantSnapshot,
|
||||
ImmichSnapshot,
|
||||
ScrutinySnapshot,
|
||||
UptimeKumaMonitor,
|
||||
UptimeKumaSnapshot,
|
||||
)
|
||||
from app.models.storage import StorageDisk, StorageResponse, StorageSummary
|
||||
from app.models.system import (
|
||||
SystemCPU,
|
||||
SystemHost,
|
||||
SystemMemory,
|
||||
SystemNetwork,
|
||||
SystemResponse,
|
||||
SystemSource,
|
||||
)
|
||||
from app.services.cache import TTLCacheService
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AggregatorService:
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
cache: TTLCacheService,
|
||||
beszel_client: BeszelClient,
|
||||
docker_client: DockerProxyClient,
|
||||
uptime_kuma_client: UptimeKumaClient,
|
||||
home_assistant_client: HomeAssistantClient,
|
||||
adguard_client: AdGuardClient,
|
||||
scrutiny_client: ScrutinyClient,
|
||||
immich_client: ImmichClient,
|
||||
backrest_client: BackrestClient,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.cache = cache
|
||||
self.beszel_client = beszel_client
|
||||
self.docker_client = docker_client
|
||||
self.uptime_kuma_client = uptime_kuma_client
|
||||
self.home_assistant_client = home_assistant_client
|
||||
self.adguard_client = adguard_client
|
||||
self.scrutiny_client = scrutiny_client
|
||||
self.immich_client = immich_client
|
||||
self.backrest_client = backrest_client
|
||||
|
||||
async def get_system(self) -> SystemResponse:
|
||||
return await self.cache.get_or_load(
|
||||
"system",
|
||||
self.settings.cache_ttl_system_seconds,
|
||||
self._build_system,
|
||||
)
|
||||
|
||||
async def get_storage(self) -> StorageResponse:
|
||||
return await self.cache.get_or_load(
|
||||
"storage",
|
||||
self.settings.cache_ttl_storage_seconds,
|
||||
self._build_storage,
|
||||
)
|
||||
|
||||
async def get_services(self) -> ServicesResponse:
|
||||
return await self.cache.get_or_load(
|
||||
"services",
|
||||
self.settings.cache_ttl_services_seconds,
|
||||
self._build_services,
|
||||
)
|
||||
|
||||
async def get_adguard(self) -> AdGuardSnapshot:
|
||||
return await self.cache.get_or_load(
|
||||
"adguard",
|
||||
self.settings.cache_ttl_services_seconds,
|
||||
self.adguard_client.fetch_stats,
|
||||
)
|
||||
|
||||
async def get_scrutiny(self) -> ScrutinySnapshot:
|
||||
return await self.cache.get_or_load(
|
||||
"scrutiny",
|
||||
self.settings.cache_ttl_storage_seconds,
|
||||
self.scrutiny_client.fetch_summary,
|
||||
)
|
||||
|
||||
async def get_immich(self) -> ImmichSnapshot:
|
||||
return await self.cache.get_or_load(
|
||||
"immich",
|
||||
self.settings.cache_ttl_services_seconds,
|
||||
self.immich_client.fetch_stats,
|
||||
)
|
||||
|
||||
async def get_backrest(self) -> BackrestSnapshot:
|
||||
return await self.cache.get_or_load(
|
||||
"backrest",
|
||||
self.settings.cache_ttl_services_seconds,
|
||||
self.backrest_client.fetch_status,
|
||||
)
|
||||
|
||||
async def get_home_assistant(self) -> HomeAssistantSnapshot:
|
||||
return await self.cache.get_or_load(
|
||||
"home_assistant",
|
||||
self.settings.cache_ttl_services_seconds,
|
||||
self.home_assistant_client.fetch_status,
|
||||
)
|
||||
|
||||
async def get_uptime_kuma(self) -> UptimeKumaSnapshot:
|
||||
return await self.cache.get_or_load(
|
||||
"uptime_kuma",
|
||||
self.settings.cache_ttl_services_seconds,
|
||||
self.uptime_kuma_client.fetch_monitors,
|
||||
)
|
||||
|
||||
async def get_overview(self) -> OverviewResponse:
|
||||
return await self.cache.get_or_load(
|
||||
"overview",
|
||||
self.settings.cache_ttl_overview_seconds,
|
||||
self._build_overview,
|
||||
)
|
||||
|
||||
async def _build_system(self) -> SystemResponse:
|
||||
snapshot = await self.beszel_client.fetch_system_snapshot()
|
||||
now = datetime.now(timezone.utc)
|
||||
return SystemResponse(
|
||||
generated_at=now,
|
||||
source=SystemSource(
|
||||
name=snapshot.source_name,
|
||||
status=snapshot.source_status,
|
||||
host_name=snapshot.host_name,
|
||||
agent_name=snapshot.agent_name,
|
||||
),
|
||||
cpu=SystemCPU(
|
||||
usage_percent=snapshot.cpu_usage_percent,
|
||||
cores=snapshot.cpu_cores,
|
||||
load_1=snapshot.load_1,
|
||||
load_5=snapshot.load_5,
|
||||
load_15=snapshot.load_15,
|
||||
),
|
||||
memory=SystemMemory(
|
||||
used_gb=snapshot.memory_used_gb,
|
||||
total_gb=snapshot.memory_total_gb,
|
||||
available_gb=snapshot.memory_available_gb,
|
||||
usage_percent=snapshot.memory_usage_percent,
|
||||
),
|
||||
network=SystemNetwork(
|
||||
primary_interface=snapshot.primary_interface,
|
||||
rx_mbps=snapshot.network_rx_mbps,
|
||||
tx_mbps=snapshot.network_tx_mbps,
|
||||
),
|
||||
host=SystemHost(
|
||||
uptime_seconds=snapshot.uptime_seconds,
|
||||
platform=snapshot.platform,
|
||||
kernel=snapshot.kernel,
|
||||
),
|
||||
)
|
||||
|
||||
async def _build_storage(self) -> StorageResponse:
|
||||
snapshot = await self.beszel_client.fetch_system_snapshot()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
disks = [self._map_disk(d, snapshot.source_status) for d in snapshot.disks]
|
||||
storage_source_status = snapshot.source_status
|
||||
if snapshot.source_status == "online" and not disks:
|
||||
storage_source_status = "unsupported"
|
||||
|
||||
if disks:
|
||||
root = next((d for d in disks if d.mount == "/"), disks[0])
|
||||
else:
|
||||
root = StorageDisk(
|
||||
name="rootfs",
|
||||
mount="/",
|
||||
used_gb=0.0,
|
||||
total_gb=0.0,
|
||||
free_gb=0.0,
|
||||
usage_percent=0.0,
|
||||
status="offline" if snapshot.source_status == "offline" else "online",
|
||||
)
|
||||
|
||||
critical_count = sum(1 for d in disks if d.status == "critical")
|
||||
warning_count = sum(1 for d in disks if d.status == "warning")
|
||||
overall_status: OverallStatus = (
|
||||
"offline" if snapshot.source_status == "offline"
|
||||
else ("degraded" if critical_count or warning_count else "online")
|
||||
)
|
||||
|
||||
return StorageResponse(
|
||||
generated_at=now,
|
||||
summary=StorageSummary(
|
||||
overall_status=overall_status,
|
||||
source_status=storage_source_status,
|
||||
critical_disks=critical_count,
|
||||
warning_disks=warning_count,
|
||||
total_disks=len(disks),
|
||||
),
|
||||
root=root,
|
||||
disks=disks,
|
||||
)
|
||||
|
||||
async def _build_services(self) -> ServicesResponse:
|
||||
docker_snap, uk_snap = await asyncio.gather(
|
||||
self.docker_client.fetch_containers(),
|
||||
self.uptime_kuma_client.fetch_monitors(),
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
monitor_by_name = {
|
||||
self._normalize_identifier(m.name): m for m in uk_snap.monitors
|
||||
}
|
||||
docker_by_name = {
|
||||
self._normalize_identifier(c.name): c for c in docker_snap.containers
|
||||
}
|
||||
|
||||
items: list[ServiceItem] = []
|
||||
merged_names = sorted(set(docker_by_name) | set(monitor_by_name))
|
||||
for norm in merged_names:
|
||||
container = docker_by_name.get(norm)
|
||||
monitor = monitor_by_name.get(norm)
|
||||
status = self._resolve_overall_status(
|
||||
container.state if container else "unknown", monitor
|
||||
)
|
||||
items.append(ServiceItem(
|
||||
id=norm,
|
||||
name=monitor.name if monitor else container.name,
|
||||
kind="service",
|
||||
status=status,
|
||||
health=self._status_to_health(status),
|
||||
latency_ms=monitor.latency_ms if monitor else None,
|
||||
docker_state=container.state if container else "unknown",
|
||||
url=None,
|
||||
source="uptime_kuma" if monitor else "docker",
|
||||
last_checked=now.isoformat(),
|
||||
))
|
||||
|
||||
statuses = [i.status for i in items]
|
||||
overall = self._aggregate_statuses(statuses)
|
||||
|
||||
return ServicesResponse(
|
||||
generated_at=now,
|
||||
summary=ServicesSummary(
|
||||
overall_status=overall,
|
||||
docker=ServicesDockerSummary(
|
||||
running=docker_snap.running,
|
||||
stopped=docker_snap.stopped,
|
||||
unhealthy=docker_snap.unhealthy,
|
||||
total=docker_snap.total,
|
||||
source_status=docker_snap.source_status,
|
||||
),
|
||||
uptime_kuma=ServicesUptimeKumaSummary(
|
||||
monitors_up=uk_snap.monitors_up,
|
||||
monitors_down=uk_snap.monitors_down,
|
||||
monitors_paused=uk_snap.monitors_paused,
|
||||
total=uk_snap.total,
|
||||
source_status=uk_snap.source_status,
|
||||
),
|
||||
),
|
||||
services=items,
|
||||
)
|
||||
|
||||
async def _build_overview(self) -> OverviewResponse:
|
||||
system_snap, docker_snap, uk_snap, ha_snap = await asyncio.gather(
|
||||
self.beszel_client.fetch_system_snapshot(),
|
||||
self.docker_client.fetch_containers(),
|
||||
self.uptime_kuma_client.fetch_monitors(),
|
||||
self.home_assistant_client.fetch_status(),
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
statuses: list[OverallStatus] = []
|
||||
for container in docker_snap.containers:
|
||||
name_lower = self._normalize_identifier(container.name)
|
||||
monitor = next(
|
||||
(m for m in uk_snap.monitors if self._normalize_identifier(m.name) == name_lower),
|
||||
None,
|
||||
)
|
||||
statuses.append(self._resolve_overall_status(container.state, monitor))
|
||||
|
||||
overall = self._aggregate_statuses(statuses)
|
||||
|
||||
return OverviewResponse(
|
||||
generated_at=now,
|
||||
overall_status=overall,
|
||||
refresh_hint_seconds=self.settings.cache_ttl_overview_seconds,
|
||||
services=OverviewServicesSummary(
|
||||
online=sum(1 for s in statuses if s == "online"),
|
||||
degraded=sum(1 for s in statuses if s == "degraded"),
|
||||
offline=sum(1 for s in statuses if s == "offline"),
|
||||
total=len(statuses),
|
||||
),
|
||||
docker=OverviewDockerSummary(
|
||||
running=docker_snap.running,
|
||||
stopped=docker_snap.stopped,
|
||||
unhealthy=docker_snap.unhealthy,
|
||||
total=docker_snap.total,
|
||||
source_status=docker_snap.source_status,
|
||||
),
|
||||
system=OverviewSystemSummary(
|
||||
cpu_percent=system_snap.cpu_usage_percent,
|
||||
ram_percent=system_snap.memory_usage_percent,
|
||||
root_storage_percent=system_snap.disks[0].usage_percent if system_snap.disks else 0.0,
|
||||
network_rx_mbps=system_snap.network_rx_mbps,
|
||||
network_tx_mbps=system_snap.network_tx_mbps,
|
||||
uptime_seconds=system_snap.uptime_seconds,
|
||||
),
|
||||
home_assistant=OverviewHomeAssistantSummary(
|
||||
status=ha_snap.status,
|
||||
label=ha_snap.label,
|
||||
version=ha_snap.version,
|
||||
response_time_ms=ha_snap.response_time_ms,
|
||||
last_checked=ha_snap.last_checked.isoformat() if ha_snap.last_checked else None,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_identifier(value: str) -> str:
|
||||
return "".join(ch.lower() for ch in value if ch.isalnum())
|
||||
|
||||
@staticmethod
|
||||
def _resolve_overall_status(
|
||||
docker_state: str,
|
||||
monitor: UptimeKumaMonitor | None,
|
||||
) -> OverallStatus:
|
||||
if monitor:
|
||||
if monitor.status == "offline":
|
||||
return "offline"
|
||||
if monitor.status == "degraded":
|
||||
return "degraded"
|
||||
if docker_state == "unhealthy":
|
||||
return "degraded"
|
||||
if docker_state == "stopped":
|
||||
return "offline"
|
||||
if docker_state == "running":
|
||||
return "online"
|
||||
return "offline" if monitor is None else monitor.status
|
||||
|
||||
@staticmethod
|
||||
def _status_to_health(status: OverallStatus) -> HealthStatus:
|
||||
if status == "online":
|
||||
return "healthy"
|
||||
if status == "degraded":
|
||||
return "warning"
|
||||
return "offline"
|
||||
|
||||
def _map_disk(self, disk: BeszelDiskMetric, source_status: str) -> StorageDisk:
|
||||
if source_status == "offline":
|
||||
status: DiskStatus = "offline"
|
||||
elif disk.usage_percent >= 90:
|
||||
status = "critical"
|
||||
elif disk.usage_percent >= 75:
|
||||
status = "warning"
|
||||
else:
|
||||
status = "online"
|
||||
|
||||
return StorageDisk(
|
||||
name=disk.name,
|
||||
mount=disk.mount,
|
||||
used_gb=disk.used_gb,
|
||||
total_gb=disk.total_gb,
|
||||
free_gb=disk.free_gb,
|
||||
usage_percent=disk.usage_percent,
|
||||
status=status,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _aggregate_statuses(statuses: Iterable[OverallStatus]) -> OverallStatus:
|
||||
normalized = list(statuses)
|
||||
if not normalized:
|
||||
return "offline"
|
||||
if any(s == "offline" for s in normalized):
|
||||
return "degraded" if any(s == "online" for s in normalized) else "offline"
|
||||
if any(s in {"degraded", "warning", "critical"} for s in normalized):
|
||||
return "degraded"
|
||||
return "online"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_cache_service() -> TTLCacheService:
|
||||
return TTLCacheService()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_aggregator_service() -> AggregatorService:
|
||||
settings = get_settings()
|
||||
cache = get_cache_service()
|
||||
return AggregatorService(
|
||||
settings=settings,
|
||||
cache=cache,
|
||||
beszel_client=BeszelClient(settings),
|
||||
docker_client=DockerProxyClient(settings),
|
||||
uptime_kuma_client=UptimeKumaClient(settings),
|
||||
home_assistant_client=HomeAssistantClient(settings),
|
||||
adguard_client=AdGuardClient(settings),
|
||||
scrutiny_client=ScrutinyClient(settings),
|
||||
immich_client=ImmichClient(settings),
|
||||
backrest_client=BackrestClient(settings),
|
||||
)
|
||||
@@ -1,60 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Awaitable, Callable, Generic, TypeVar
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CacheEntry(Generic[T]):
|
||||
value: T
|
||||
expires_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TTLCacheService:
|
||||
def __init__(self) -> None:
|
||||
self._entries: dict[str, CacheEntry[object]] = {}
|
||||
self._locks: dict[str, asyncio.Lock] = {}
|
||||
|
||||
async def get_or_load(
|
||||
self,
|
||||
key: str,
|
||||
ttl_seconds: int,
|
||||
loader: Callable[[], Awaitable[T]],
|
||||
) -> T:
|
||||
entry = self._entries.get(key)
|
||||
if entry and not self._is_expired(entry):
|
||||
return entry.value # type: ignore[return-value]
|
||||
|
||||
lock = self._locks.setdefault(key, asyncio.Lock())
|
||||
async with lock:
|
||||
entry = self._entries.get(key)
|
||||
if entry and not self._is_expired(entry):
|
||||
return entry.value # type: ignore[return-value]
|
||||
|
||||
try:
|
||||
value = await loader()
|
||||
now = datetime.now(timezone.utc)
|
||||
self._entries[key] = CacheEntry(
|
||||
value=value,
|
||||
expires_at=now + timedelta(seconds=ttl_seconds),
|
||||
updated_at=now,
|
||||
)
|
||||
return value
|
||||
except Exception as exc:
|
||||
if entry:
|
||||
logger.warning("Cache loader failed for %s, serving stale data: %s", key, exc)
|
||||
return entry.value # type: ignore[return-value]
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _is_expired(entry: CacheEntry[object]) -> bool:
|
||||
return datetime.now(timezone.utc) >= entry.expires_at
|
||||
@@ -1,4 +0,0 @@
|
||||
fastapi==0.116.1
|
||||
uvicorn[standard]==0.35.0
|
||||
pydantic-settings==2.10.1
|
||||
httpx==0.28.1
|
||||
@@ -1,955 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KalliLab Control Panel</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;700&family=Share+Tech+Mono&family=Exo+2:wght@300;400;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #060b09;
|
||||
--bg2: #0a1210;
|
||||
--card: rgba(8, 18, 15, 0.88);
|
||||
--card-border: rgba(0, 220, 140, 0.18);
|
||||
--card-hover: rgba(0, 220, 140, 0.28);
|
||||
--teal: #00dc8c;
|
||||
--teal-dim: #009e65;
|
||||
--teal-bright: #00ffaa;
|
||||
--teal-glow: rgba(0, 220, 140, 0.4);
|
||||
--text: #b8d4cc;
|
||||
--text-dim: #5a8a7a;
|
||||
--text-bright: #d8f0e8;
|
||||
--clr-warn: #ff4466;
|
||||
--red: #ff4466;
|
||||
--yellow: #ffcc44;
|
||||
--blue: #44aaff;
|
||||
--graph: #00cc88;
|
||||
--font-display: 'Orbitron', monospace;
|
||||
--font-mono: 'Share Tech Mono', monospace;
|
||||
--font-body: 'Exo 2', sans-serif;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 220, 140, 0.025) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 220, 140, 0.025) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes scanline {
|
||||
0% { top: -5%; }
|
||||
100% { top: 105%; }
|
||||
}
|
||||
.scanline {
|
||||
position: fixed;
|
||||
left: 0; right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, rgba(0,220,140,0.06), rgba(0,220,140,0.10), rgba(0,220,140,0.06), transparent);
|
||||
z-index: 1;
|
||||
animation: scanline 8s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wrapper {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 1340px;
|
||||
margin: 0 auto;
|
||||
padding: 0 10px 32px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0 8px;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
margin-bottom: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
.header-logo { display: flex; align-items: center; gap: 10px; }
|
||||
.logo-text {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--teal-bright);
|
||||
text-shadow: 0 0 20px var(--teal-glow);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.logo-sub {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.header-center { display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||||
.overall-status { font-family: var(--font-mono); font-size: 10px; letter-spacing: 2px; color: var(--teal-dim); }
|
||||
.status-indicator {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-family: var(--font-display); font-size: 11px;
|
||||
color: var(--teal-bright); text-shadow: 0 0 10px var(--teal-glow);
|
||||
}
|
||||
.status-dot-main { width: 8px; height: 8px; border-radius: 50%; background: var(--teal); box-shadow: 0 0 8px var(--teal-glow); }
|
||||
.header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
|
||||
.clock {
|
||||
font-family: var(--font-display); font-size: 24px; font-weight: 700;
|
||||
color: var(--teal-bright); text-shadow: 0 0 20px var(--teal-glow), 0 0 40px rgba(0,220,140,0.2);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.date-str { font-family: var(--font-mono); font-size: 10px; color: var(--text-dim); text-align: right; }
|
||||
#last-updated { font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); text-align: right; }
|
||||
.section-header {
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
|
||||
font-family: var(--font-display); font-size: 8px; font-weight: 600;
|
||||
letter-spacing: 3px; text-transform: uppercase; color: var(--teal-dim);
|
||||
}
|
||||
.section-header::after { content: ''; flex: 1; height: 1px; background: linear-gradient(90deg, var(--card-border), transparent); }
|
||||
.widget-row { display: grid; gap: 8px; margin-bottom: 8px; }
|
||||
.row-5 { grid-template-columns: repeat(5, 1fr); }
|
||||
.row-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
.row-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.row-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.row-2-1 { grid-template-columns: 2fr 1fr; }
|
||||
.row-1-2 { grid-template-columns: 1fr 2fr; }
|
||||
.row-3-2 { grid-template-columns: 3fr 2fr; }
|
||||
.card {
|
||||
background: rgba(6, 14, 11, 0.78);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
backdrop-filter: blur(14px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(1.4);
|
||||
}
|
||||
.card:hover { border-color: rgba(0,220,140,0.32); box-shadow: 0 0 16px rgba(0,220,140,0.07), inset 0 0 20px rgba(0,220,140,0.02); }
|
||||
.card-title {
|
||||
font-family: var(--font-display); font-size: 8px; font-weight: 600;
|
||||
letter-spacing: 2px; text-transform: uppercase; color: var(--teal-dim);
|
||||
margin-bottom: 6px; display: flex; align-items: center; justify-content: space-between; gap: 6px;
|
||||
}
|
||||
.card-title-left { display: flex; align-items: center; gap: 6px; }
|
||||
.card-title .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--teal); box-shadow: 0 0 5px var(--teal-glow); flex-shrink: 0; }
|
||||
.stats-grid { display: flex; justify-content: space-around; gap: 6px; flex-wrap: wrap; }
|
||||
.stat-block { text-align: center; min-width: 40px; }
|
||||
.stat-num { font-family: var(--font-display); font-size: 17px; font-weight: 700; color: var(--teal-bright); text-shadow: 0 0 10px var(--teal-glow); line-height: 1.1; }
|
||||
.stat-num.dim { color: var(--teal-dim); text-shadow: none; font-size: 14px; }
|
||||
.stat-num.warn { color: var(--yellow); text-shadow: 0 0 10px rgba(255,204,68,0.4); }
|
||||
.stat-num.danger { color: var(--red); text-shadow: 0 0 10px rgba(255,68,102,0.4); }
|
||||
.stat-num.blue { color: var(--blue); text-shadow: 0 0 10px rgba(68,170,255,0.4); }
|
||||
.stat-label { font-family: var(--font-mono); font-size: 8px; color: var(--text-dim); letter-spacing: 1px; text-transform: uppercase; margin-top: 1px; }
|
||||
.status-pill { font-family: var(--font-mono); font-size: 7px; letter-spacing: 1px; padding: 1px 5px; border-radius: 3px; font-weight: 700; }
|
||||
.pill-online { background: rgba(0,220,140,0.12); color: var(--teal); border: 1px solid rgba(0,220,140,0.3); }
|
||||
.pill-offline { background: rgba(255,68,102,0.1); color: var(--red); border: 1px solid rgba(255,68,102,0.25); }
|
||||
.pill-degraded { background: rgba(255,204,68,0.1); color: var(--yellow); border: 1px solid rgba(255,204,68,0.25); }
|
||||
.progress-wrap { margin-top: 5px; }
|
||||
.progress-label { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); margin-bottom: 2px; }
|
||||
.progress-bar { height: 3px; background: rgba(0,220,140,0.1); border-radius: 2px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--teal-dim), var(--teal-bright)); border-radius: 2px; box-shadow: 0 0 5px var(--teal-glow); transition: width 1s ease; }
|
||||
.progress-fill.warn { background: linear-gradient(90deg, #cc8800, var(--yellow)); }
|
||||
.progress-fill.danger { background: linear-gradient(90deg, #cc2244, var(--red)); }
|
||||
.service-header { display: flex; align-items: center; gap: 7px; margin-bottom: 7px; }
|
||||
.service-icon { width: 24px; height: 24px; border-radius: 5px; display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; }
|
||||
.service-name { font-family: var(--font-display); font-size: 9px; font-weight: 600; color: var(--text-bright); letter-spacing: 1px; flex: 1; }
|
||||
.service-version { font-family: var(--font-mono); font-size: 8px; color: var(--text-dim); }
|
||||
.sys-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px 10px; font-family: var(--font-mono); font-size: 10px; }
|
||||
.sys-row { display: flex; justify-content: space-between; padding: 1px 0; }
|
||||
.sys-key { color: var(--text-dim); }
|
||||
.sys-val { color: var(--teal); }
|
||||
.disk-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 3px; }
|
||||
.disk-name { font-family: var(--font-display); font-size: 9px; color: var(--text-bright); letter-spacing: 1px; }
|
||||
.disk-usage { font-family: var(--font-mono); font-size: 9px; color: var(--teal); }
|
||||
.disk-sub { font-family: var(--font-mono); font-size: 8px; color: var(--text-dim); margin-bottom: 4px; }
|
||||
.scrutiny-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-family: var(--font-mono); font-size: 9px; border-bottom: 1px solid rgba(0,220,140,0.05); }
|
||||
.scrutiny-row:last-child { border-bottom: none; }
|
||||
.disk-icon { font-size: 10px; font-weight: bold; width: 12px; text-align: center; }
|
||||
.disk-ok { color: var(--teal); }
|
||||
.disk-fail { color: var(--red); }
|
||||
.disk-unk { color: var(--text-dim); }
|
||||
.disk-name-col { flex: 1; color: var(--text-bright); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.disk-model { color: var(--text-dim); font-size: 8px; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.disk-temp { color: var(--teal-dim); font-size: 8px; white-space: nowrap; }
|
||||
.scrutiny-offline { font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); padding: 4px 0; }
|
||||
.uk-monitor-row { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
|
||||
.uk-monitor-name { font-family: var(--font-mono); font-size: 8px; color: var(--text-dim); min-width: 70px; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.uk-bar { display: flex; gap: 1px; flex: 1; }
|
||||
.hb-seg { height: 7px; flex: 1; border-radius: 1px; }
|
||||
.hb-up { background: var(--teal); box-shadow: 0 0 3px var(--teal-glow); opacity: 0.85; }
|
||||
.hb-down { background: var(--red); box-shadow: 0 0 3px rgba(255,68,102,0.4); opacity: 0.85; }
|
||||
.uk-down-name { display: block; font-family: var(--font-mono); font-size: 8px; color: var(--red); padding: 1px 0; }
|
||||
.status-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot-ok { background: var(--teal); box-shadow: 0 0 5px var(--teal-glow); }
|
||||
.dot-err { background: var(--red); box-shadow: 0 0 5px rgba(255,68,102,0.4); }
|
||||
.dot-unk { background: var(--text-dim); }
|
||||
.adguard-bar-wrap { margin-top: 5px; }
|
||||
.adguard-bar { height: 3px; background: rgba(0,220,140,0.1); border-radius: 2px; overflow: hidden; position: relative; }
|
||||
.adguard-bar-fill { height: 100%; background: linear-gradient(90deg, var(--teal-dim), var(--teal-bright)); border-radius: 2px; box-shadow: 0 0 5px var(--teal-glow); width: 0%; transition: width 1s ease; }
|
||||
.net-health-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
#quick-access-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 6px; }
|
||||
.quick-tile { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 6px 7px; background: rgba(6, 14, 11, 0.72); border: 1px solid var(--card-border); border-radius: 7px; cursor: pointer; transition: all 0.18s; text-decoration: none; color: var(--text); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); }
|
||||
.quick-tile:hover { border-color: rgba(0,220,140,0.4); background: rgba(0,220,140,0.05); transform: translateY(-2px); box-shadow: 0 4px 18px rgba(0,220,140,0.10); }
|
||||
.quick-tile-icon { font-size: 18px; line-height: 1; }
|
||||
.quick-tile-label { font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); text-align: center; line-height: 1.2; }
|
||||
.quick-tile:hover .quick-tile-label { color: var(--teal); }
|
||||
.docker-row { display: flex; gap: 6px; font-family: var(--font-mono); font-size: 9px; margin-top: 4px; flex-wrap: wrap; }
|
||||
.docker-chip { padding: 2px 6px; border-radius: 3px; background: rgba(0,220,140,0.07); border: 1px solid rgba(0,220,140,0.15); color: var(--teal-dim); }
|
||||
.docker-chip.running { color: var(--teal); border-color: rgba(0,220,140,0.3); }
|
||||
.docker-chip.stopped { color: var(--yellow); border-color: rgba(255,204,68,0.3); background: rgba(255,204,68,0.06); }
|
||||
.docker-chip.unhealthy { color: var(--red); border-color: rgba(255,68,102,0.3); background: rgba(255,68,102,0.06); }
|
||||
@media (max-width: 1100px) {
|
||||
.row-5 { grid-template-columns: repeat(3, 1fr); }
|
||||
.row-4 { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 780px) {
|
||||
.row-5, .row-4, .row-3 { grid-template-columns: repeat(2, 1fr); }
|
||||
.row-2, .row-2-1, .row-1-2, .row-3-2 { grid-template-columns: 1fr; }
|
||||
.net-health-grid { grid-template-columns: 1fr; }
|
||||
#quick-access-grid { grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); }
|
||||
}
|
||||
|
||||
/* Density / reference lock overrides */
|
||||
.wrapper { max-width: 1640px; padding: 8px 12px 20px; }
|
||||
.header { padding: 10px 0 10px; margin-bottom: 8px; gap: 16px; }
|
||||
.logo-text { font-size: 18px; letter-spacing: 2px; }
|
||||
.logo-sub, .overall-status, .date-str, #last-updated { font-size: 10px; }
|
||||
.clock { font-size: 46px; }
|
||||
.section-header { margin-bottom: 5px; font-size: 9px; letter-spacing: 3px; }
|
||||
.widget-row { gap: 6px; margin-bottom: 6px; }
|
||||
.row-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.card {
|
||||
max-height: 120px;
|
||||
min-height: 118px;
|
||||
height: 118px;
|
||||
padding: 8px 10px 8px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, rgba(7, 17, 14, 0.84), rgba(5, 11, 9, 0.72));
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03), 0 10px 28px rgba(0,0,0,0.22), 0 0 18px rgba(0,220,140,0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-title { margin-bottom: 6px; min-height: 26px; }
|
||||
.card-title-left { gap: 7px; }
|
||||
.card-title .dot { width: 6px; height: 6px; }
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(0, 1fr);
|
||||
align-items: end;
|
||||
justify-content: stretch;
|
||||
gap: 10px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.stat-block {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.stat-num {
|
||||
font-size: 22px;
|
||||
line-height: 0.95;
|
||||
color: var(--teal-bright);
|
||||
text-shadow: 0 0 12px var(--teal-glow);
|
||||
}
|
||||
.stat-num.dim { font-size: 18px; color: var(--text-bright); }
|
||||
.stat-label { font-size: 8px; margin-top: 2px; }
|
||||
#cpu-percent, #ram-percent, #net-rx, #uptime-days, #docker-running { font-size: 30px; }
|
||||
.service-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--icon-color, var(--teal-bright));
|
||||
background:
|
||||
radial-gradient(circle at 30% 30%, rgba(255,255,255,0.16), transparent 45%),
|
||||
linear-gradient(180deg, rgba(16, 36, 29, 0.96), rgba(7, 14, 11, 0.92));
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255,255,255,0.02),
|
||||
0 0 16px rgba(0,220,140,0.08);
|
||||
}
|
||||
.service-icon::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
box-shadow: inset 0 0 14px rgba(0,0,0,0.18);
|
||||
pointer-events: none;
|
||||
}
|
||||
.icon-glyph {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
display: block;
|
||||
position: relative;
|
||||
color: inherit;
|
||||
opacity: 0.96;
|
||||
}
|
||||
.icon-cpu { --icon-color: #82ffbf; }
|
||||
.icon-memory { --icon-color: #98ffbf; }
|
||||
.icon-network { --icon-color: #7dc8ff; }
|
||||
.icon-host { --icon-color: #95ffbf; }
|
||||
.icon-docker { --icon-color: #81ffc8; }
|
||||
.icon-storage { --icon-color: #7effb6; }
|
||||
.icon-matrix { --icon-color: #7fc9ff; }
|
||||
.icon-scrutiny { --icon-color: #7effb0; }
|
||||
.icon-ha { --icon-color: #8ea6ff; }
|
||||
.icon-kuma { --icon-color: #89ffaf; }
|
||||
.icon-immich { --icon-color: #ffd84f; }
|
||||
.icon-backrest { --icon-color: #74d7ff; }
|
||||
.icon-adguard { --icon-color: #66f0ba; }
|
||||
.icon-services { --icon-color: #8affbe; }
|
||||
.glyph-cpu {
|
||||
border: 1.6px solid currentColor;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.glyph-cpu::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
border: 1.4px solid currentColor;
|
||||
border-radius: 2px;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.glyph-cpu::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
background:
|
||||
linear-gradient(currentColor,currentColor) 2px 1px / 1px 3px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 7px 1px / 1px 3px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 12px 1px / 1px 3px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 2px calc(100% - 1px) / 1px 3px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 7px calc(100% - 1px) / 1px 3px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 12px calc(100% - 1px) / 1px 3px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 1px 2px / 3px 1px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 1px 7px / 3px 1px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) calc(100% - 1px) 2px / 3px 1px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) calc(100% - 1px) 7px / 3px 1px no-repeat;
|
||||
opacity: 0.78;
|
||||
}
|
||||
.glyph-memory {
|
||||
background:
|
||||
linear-gradient(currentColor,currentColor) 2px 9px / 2px 4px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 6px 6px / 2px 7px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 10px 3px / 2px 10px no-repeat;
|
||||
}
|
||||
.glyph-memory::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 1px;
|
||||
border: 1.4px solid rgba(152,255,191,0.34);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.glyph-network {
|
||||
background:
|
||||
radial-gradient(circle at 2px 11px, currentColor 0 2px, transparent 2.4px),
|
||||
radial-gradient(circle at 13px 11px, currentColor 0 2px, transparent 2.4px),
|
||||
radial-gradient(circle at 7.5px 3px, currentColor 0 2px, transparent 2.4px),
|
||||
linear-gradient(currentColor,currentColor) 7px 4px / 1.4px 7px no-repeat,
|
||||
linear-gradient(32deg, transparent 44%, currentColor 45% 55%, transparent 56%) 2px 5px / 10px 7px no-repeat,
|
||||
linear-gradient(-32deg, transparent 44%, currentColor 45% 55%, transparent 56%) 4px 5px / 10px 7px no-repeat;
|
||||
}
|
||||
.glyph-host {
|
||||
border: 1.6px solid currentColor;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.glyph-host::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
right: 3px;
|
||||
bottom: -2px;
|
||||
height: 1.6px;
|
||||
background: currentColor;
|
||||
}
|
||||
.glyph-host::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
bottom: -5px;
|
||||
height: 1.6px;
|
||||
background: currentColor;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.glyph-docker {
|
||||
background:
|
||||
linear-gradient(currentColor,currentColor) 1px 3px / 4px 4px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 6px 3px / 4px 4px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 11px 3px / 4px 4px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 6px 8px / 4px 4px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 1px 12px / 14px 1.5px no-repeat;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.glyph-storage {
|
||||
background:
|
||||
linear-gradient(currentColor,currentColor) 1px 5px / 13px 1.6px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 1px 8px / 13px 1.6px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 1px 11px / 13px 1.6px no-repeat;
|
||||
}
|
||||
.glyph-storage::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 1px 1px 2px;
|
||||
border: 1.4px solid rgba(126,255,182,0.36);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.glyph-matrix {
|
||||
background:
|
||||
linear-gradient(currentColor,currentColor) 1px 1px / 5px 5px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 9px 1px / 5px 5px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 1px 9px / 5px 5px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 9px 9px / 5px 5px no-repeat;
|
||||
opacity: 0.95;
|
||||
}
|
||||
.glyph-scrutiny {
|
||||
border: 1.6px solid currentColor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.glyph-scrutiny::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
border: 1.4px solid currentColor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.glyph-scrutiny::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 1px;
|
||||
width: 1.4px;
|
||||
height: 13px;
|
||||
background: currentColor;
|
||||
box-shadow: -6px 6px 0 -5px currentColor, 6px 6px 0 -5px currentColor;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.glyph-home {
|
||||
background:
|
||||
linear-gradient(-35deg, transparent 45%, currentColor 46% 56%, transparent 57%) 0 1px / 8px 7px no-repeat,
|
||||
linear-gradient(35deg, transparent 45%, currentColor 46% 56%, transparent 57%) 7px 1px / 8px 7px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 3px 7px / 9px 6px no-repeat;
|
||||
}
|
||||
.glyph-kuma {
|
||||
background:
|
||||
linear-gradient(currentColor,currentColor) 1px 8px / 3px 1.5px no-repeat,
|
||||
linear-gradient(55deg, transparent 43%, currentColor 44% 56%, transparent 57%) 3px 6px / 4px 5px no-repeat,
|
||||
linear-gradient(-55deg, transparent 43%, currentColor 44% 56%, transparent 57%) 6px 4px / 4px 7px no-repeat,
|
||||
linear-gradient(55deg, transparent 43%, currentColor 44% 56%, transparent 57%) 9px 6px / 4px 5px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 12px 8px / 3px 1.5px no-repeat;
|
||||
}
|
||||
.glyph-image {
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.glyph-image::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
height: 5px;
|
||||
background:
|
||||
linear-gradient(140deg, transparent 35%, currentColor 36% 48%, transparent 49%) 0 0 / 8px 5px no-repeat,
|
||||
linear-gradient(45deg, transparent 32%, currentColor 33% 45%, transparent 46%) 5px 0 / 8px 5px no-repeat;
|
||||
}
|
||||
.glyph-image::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 2px;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.glyph-backrest {
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.glyph-backrest::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
top: 3px;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
box-shadow: 0 4px 0 currentColor;
|
||||
}
|
||||
.glyph-backrest::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
bottom: -2px;
|
||||
height: 1.5px;
|
||||
background: currentColor;
|
||||
}
|
||||
.glyph-shield {
|
||||
background:
|
||||
linear-gradient(currentColor,currentColor) 7px 2px / 1.8px 9px no-repeat;
|
||||
clip-path: polygon(50% 0%, 88% 16%, 88% 50%, 50% 100%, 12% 50%, 12% 16%);
|
||||
background-color: transparent;
|
||||
border: 1.5px solid currentColor;
|
||||
}
|
||||
.glyph-services {
|
||||
background:
|
||||
radial-gradient(circle at 2px 7px, currentColor 0 2px, transparent 2.4px),
|
||||
radial-gradient(circle at 13px 2px, currentColor 0 2px, transparent 2.4px),
|
||||
radial-gradient(circle at 13px 12px, currentColor 0 2px, transparent 2.4px),
|
||||
linear-gradient(currentColor,currentColor) 4px 6px / 7px 1.4px no-repeat,
|
||||
linear-gradient(currentColor,currentColor) 10px 4px / 1.4px 6px no-repeat;
|
||||
}
|
||||
.service-name { font-size: 10px; letter-spacing: 1.4px; }
|
||||
.status-pill {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
min-width: 10px;
|
||||
border-radius: 999px;
|
||||
padding: 0;
|
||||
font-size: 0;
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 12px rgba(0,0,0,0.18);
|
||||
}
|
||||
.pill-online { box-shadow: 0 0 10px rgba(0,220,140,0.32); }
|
||||
.pill-degraded { box-shadow: 0 0 10px rgba(255,204,68,0.28); }
|
||||
.pill-offline { box-shadow: 0 0 10px rgba(255,68,102,0.28); }
|
||||
.progress-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.progress-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
.progress-bar { height: 4px; border-radius: 999px; background: rgba(0,220,140,0.08); }
|
||||
.mini-graph {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 3px;
|
||||
height: 16px;
|
||||
}
|
||||
.mini-bar {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-radius: 2px 2px 0 0;
|
||||
background: linear-gradient(180deg, rgba(127,255,199,0.9), rgba(0,220,140,0.22));
|
||||
box-shadow: 0 0 8px rgba(0,220,140,0.12);
|
||||
}
|
||||
.mini-bar.warn { background: linear-gradient(180deg, rgba(255,204,68,0.95), rgba(255,204,68,0.24)); }
|
||||
.mini-bar.danger { background: linear-gradient(180deg, rgba(255,68,102,0.95), rgba(255,68,102,0.24)); }
|
||||
.mini-bar.blue { background: linear-gradient(180deg, rgba(68,170,255,0.95), rgba(68,170,255,0.24)); }
|
||||
.services-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.service-card { display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.service-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.micro-strip { display: flex; gap: 3px; min-height: 8px; align-items: center; }
|
||||
.micro-seg { width: 7px; height: 7px; border-radius: 999px; background: rgba(255,255,255,0.1); }
|
||||
.micro-seg.up { background: var(--teal); box-shadow: 0 0 8px rgba(0,220,140,0.22); }
|
||||
.micro-seg.down { background: var(--red); box-shadow: 0 0 8px rgba(255,68,102,0.22); }
|
||||
.micro-seg.warn { background: var(--yellow); box-shadow: 0 0 8px rgba(255,204,68,0.22); }
|
||||
#quick-access-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.quick-tile {
|
||||
min-height: 74px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 9px;
|
||||
text-align: left;
|
||||
}
|
||||
.quick-tile-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-display);
|
||||
background: linear-gradient(135deg, var(--teal-bright), var(--teal));
|
||||
color: #04110d;
|
||||
}
|
||||
.quick-tile-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.quick-tile-label { font-size: 9px; color: var(--text-bright); }
|
||||
.quick-tile-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 7px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.quick-tile-icon-ha { background: linear-gradient(135deg, #6cb8ff, #9e8dff); }
|
||||
.quick-tile-icon-komodo { background: linear-gradient(135deg, #00e2b3, #68c7ff); }
|
||||
.quick-tile-icon-kuma { background: linear-gradient(135deg, #00d98a, #7fffc7); }
|
||||
.quick-tile-icon-beszel { background: linear-gradient(135deg, #53f1b4, #a9ffd8); }
|
||||
.quick-tile-icon-firefly { background: linear-gradient(135deg, #ffb54d, #ffd66f); }
|
||||
.quick-tile-icon-paperless { background: linear-gradient(135deg, #89ffdc, #46cfa0); }
|
||||
.quick-tile-icon-mealie { background: linear-gradient(135deg, #7ec2ff, #f6ff8c); }
|
||||
.quick-tile-icon-immich { background: linear-gradient(135deg, #ffd15c, #ff9d4d); }
|
||||
.quick-tile-icon-gitea { background: linear-gradient(135deg, #9cff87, #3cd675); }
|
||||
.quick-tile-icon-code { background: linear-gradient(135deg, #5cc4ff, #5f92ff); }
|
||||
.quick-tile-icon-files { background: linear-gradient(135deg, #6ec8ff, #9be1ff); }
|
||||
.quick-tile-icon-backrest { background: linear-gradient(135deg, #8bb4ff, #6fd8ff); }
|
||||
.quick-tile-icon-vault { background: linear-gradient(135deg, #ffe173, #ffaa5c); }
|
||||
.quick-tile-icon-adguard { background: linear-gradient(135deg, #57ffaa, #57d8ff); }
|
||||
.quick-tile-icon-traefik { background: linear-gradient(135deg, #8f9fff, #6cc4ff); }
|
||||
.quick-tile-icon-scrutiny { background: linear-gradient(135deg, #9cffcf, #61ffaa); }
|
||||
.storage-layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.storage-layout > div:first-child { display: contents; }
|
||||
#storage-grid { display: contents; }
|
||||
.scrutiny-row { padding: 1px 0; border-bottom: none; gap: 6px; font-size: 8px; }
|
||||
.scrutiny-offline { font-size: 8px; padding: 2px 0; }
|
||||
.scrutiny-strip,
|
||||
.storage-strip {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.scrutiny-chip,
|
||||
.storage-chip {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 7px;
|
||||
color: var(--text-dim);
|
||||
padding: 2px 5px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0,220,140,0.12);
|
||||
background: rgba(255,255,255,0.02);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.scrutiny-chip.ok { color: var(--teal-bright); border-color: rgba(0,220,140,0.22); }
|
||||
.scrutiny-chip.fail { color: var(--red); border-color: rgba(255,68,102,0.24); }
|
||||
.scrutiny-chip.unk { color: var(--text-dim); border-color: rgba(255,255,255,0.08); }
|
||||
.scrutiny-chip strong {
|
||||
color: currentColor;
|
||||
font-family: var(--font-display);
|
||||
font-size: 7px;
|
||||
letter-spacing: 0.6px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.storage-matrix-card .stats-grid { margin-bottom: 6px; }
|
||||
.storage-matrix-card .service-footer { margin-top: auto; }
|
||||
.system-card .card-title,
|
||||
.service-card .card-title,
|
||||
.storage-card .card-title { align-items: center; }
|
||||
@media (max-width: 1360px) {
|
||||
.services-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
#quick-access-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.row-5 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.services-grid, #quick-access-grid, #storage-grid, .storage-layout, .row-5 { grid-template-columns: 1fr; }
|
||||
.card { max-height: none; }
|
||||
.stats-grid { grid-auto-flow: row; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="scanline"></div>
|
||||
<div class="wrapper">
|
||||
|
||||
<!-- HEADER -->
|
||||
<header class="header" id="header">
|
||||
<div class="header-logo">
|
||||
<div>
|
||||
<div class="logo-text">KALLILAB</div>
|
||||
<div class="logo-sub">Control Panel</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<div class="overall-status">SYSTEM STATUS</div>
|
||||
<div class="status-indicator">
|
||||
<span class="status-dot-main" id="overall-dot"></span>
|
||||
<span id="overall-status-text">LOADING</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="clock" id="clock">--:--:--</div>
|
||||
<div class="date-str" id="date-str">---</div>
|
||||
<div id="last-updated">never updated</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- SYSTEM STATS ROW -->
|
||||
<div class="section-header"><span>⬡</span> SYSTEM</div>
|
||||
<div class="widget-row row-5" id="stats-row" style="margin-bottom:8px;">
|
||||
<div class="card service-card system-card">
|
||||
<div class="card-title">
|
||||
<div class="card-title-left"><span class="service-icon icon-cpu"><span class="icon-glyph glyph-cpu"></span></span><span class="service-name">CPU</span></div>
|
||||
<span class="status-pill pill-online">OK</span>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-block"><div class="stat-num" id="cpu-percent">—</div><div class="stat-label">Usage %</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="cpu-cores">—</div><div class="stat-label">Cores</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="cpu-load">—</div><div class="stat-label">Load 5m</div></div>
|
||||
</div>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-meta"><span>Compute Load</span><span id="cpu-progress-label">0%</span></div>
|
||||
<div class="progress-bar"><div class="progress-fill" id="cpu-progress"></div></div>
|
||||
<div class="mini-graph" id="cpu-graph"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card service-card system-card">
|
||||
<div class="card-title">
|
||||
<div class="card-title-left"><span class="service-icon icon-memory"><span class="icon-glyph glyph-memory"></span></span><span class="service-name">MEMORY</span></div>
|
||||
<span class="status-pill pill-online">OK</span>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-block"><div class="stat-num" id="ram-percent">—</div><div class="stat-label">Usage %</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="ram-used">—</div><div class="stat-label">Used GB</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="ram-total">—</div><div class="stat-label">Total GB</div></div>
|
||||
</div>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-meta"><span>Memory Pool</span><span id="ram-progress-label">0%</span></div>
|
||||
<div class="progress-bar"><div class="progress-fill" id="ram-progress"></div></div>
|
||||
<div class="mini-graph" id="ram-graph"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card service-card system-card">
|
||||
<div class="card-title">
|
||||
<div class="card-title-left"><span class="service-icon icon-network"><span class="icon-glyph glyph-network"></span></span><span class="service-name">NETWORK</span></div>
|
||||
<span class="status-pill pill-online">OK</span>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-block"><div class="stat-num" id="net-rx">—</div><div class="stat-label">↓ Mbps</div></div>
|
||||
<div class="stat-block"><div class="stat-num" id="net-tx">—</div><div class="stat-label">↑ Mbps</div></div>
|
||||
</div>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-meta"><span>Traffic Flow</span><span id="net-progress-label">0 Mbps</span></div>
|
||||
<div class="progress-bar"><div class="progress-fill" id="net-progress"></div></div>
|
||||
<div class="mini-graph" id="net-graph"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card service-card system-card">
|
||||
<div class="card-title">
|
||||
<div class="card-title-left"><span class="service-icon icon-host"><span class="icon-glyph glyph-host"></span></span><span class="service-name">HOST</span></div>
|
||||
<span class="status-pill pill-online">OK</span>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-block"><div class="stat-num" id="uptime-days">—</div><div class="stat-label">Uptime d</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="host-platform">—</div><div class="stat-label">OS</div></div>
|
||||
</div>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-meta"><span>Host Runtime</span><span id="host-progress-label">—</span></div>
|
||||
<div class="progress-bar"><div class="progress-fill" id="host-progress"></div></div>
|
||||
<div class="mini-graph" id="host-graph"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card service-card system-card">
|
||||
<div class="card-title">
|
||||
<div class="card-title-left"><span class="service-icon icon-docker"><span class="icon-glyph glyph-docker"></span></span><span class="service-name">DOCKER</span></div>
|
||||
<span class="status-pill pill-online">OK</span>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-block"><div class="stat-num" id="docker-running">—</div><div class="stat-label">Running</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="docker-stopped">—</div><div class="stat-label">Stopped</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="docker-total">—</div><div class="stat-label">Total</div></div>
|
||||
</div>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-meta"><span>Runtime Surface</span><span id="docker-progress-label">0%</span></div>
|
||||
<div class="progress-bar"><div class="progress-fill" id="docker-progress"></div></div>
|
||||
<div class="mini-graph" id="docker-graph"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STORAGE + SCRUTINY ROW -->
|
||||
<div class="section-header"><span>⬡</span> STORAGE & HEALTH</div>
|
||||
<div class="storage-layout">
|
||||
<div>
|
||||
<div class="widget-row row-3" id="storage-grid">
|
||||
<!-- Disk cards injected by renderer -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="card service-card">
|
||||
<div class="card-title">
|
||||
<div class="card-title-left"><span class="service-icon icon-scrutiny"><span class="icon-glyph glyph-scrutiny"></span></span><span class="service-name">SCRUTINY</span></div>
|
||||
<span class="status-pill pill-offline" id="scrutiny-pill">OFFLINE</span>
|
||||
</div>
|
||||
<div class="stats-grid" style="margin-bottom:5px;">
|
||||
<div class="stat-block"><div class="stat-num" id="scrutiny-total">—</div><div class="stat-label">Disks</div></div>
|
||||
<div class="stat-block"><div class="stat-num" id="scrutiny-passed">—</div><div class="stat-label">Passed</div></div>
|
||||
<div class="stat-block"><div class="stat-num danger" id="scrutiny-failed">—</div><div class="stat-label">Failed</div></div>
|
||||
</div>
|
||||
<div id="scrutiny-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SERVICE WIDGETS ROW 1 -->
|
||||
<div class="section-header"><span>⬡</span> SERVICES</div>
|
||||
<div class="services-grid">
|
||||
<div class="card service-card">
|
||||
<div class="card-title">
|
||||
<div class="card-title-left">
|
||||
<span class="service-icon icon-ha"><span class="icon-glyph glyph-home"></span></span>
|
||||
<span class="service-name">HOME ASSISTANT</span>
|
||||
</div>
|
||||
<span class="status-pill pill-offline" id="ha-pill">OFFLINE</span>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-block"><div class="stat-num" id="ha-lights">—</div><div class="stat-label">Lights</div></div>
|
||||
<div class="stat-block"><div class="stat-num" id="ha-climate">—</div><div class="stat-label">Climate</div></div>
|
||||
<div class="stat-block"><div class="stat-num" id="ha-doors">—</div><div class="stat-label">Doors</div></div>
|
||||
<div class="stat-block"><div class="stat-num danger" id="ha-alerts">—</div><div class="stat-label">Alerts</div></div>
|
||||
</div>
|
||||
<div class="service-footer"><span id="ha-version">Core automation hub</span><span></span></div>
|
||||
</div>
|
||||
<div class="card service-card">
|
||||
<div class="card-title">
|
||||
<div class="card-title-left">
|
||||
<span class="service-icon icon-kuma"><span class="icon-glyph glyph-kuma"></span></span>
|
||||
<span class="service-name">UPTIME KUMA</span>
|
||||
</div>
|
||||
<span class="status-pill pill-offline" id="uk-pill">OFFLINE</span>
|
||||
</div>
|
||||
<div class="stats-grid" style="margin-bottom:5px;">
|
||||
<div class="stat-block"><div class="stat-num" id="uk-up">—</div><div class="stat-label">Up</div></div>
|
||||
<div class="stat-block"><div class="stat-num danger" id="uk-down">—</div><div class="stat-label">Down</div></div>
|
||||
<div class="stat-block"><div class="stat-num warn" id="uk-paused">—</div><div class="stat-label">Paused</div></div>
|
||||
<div class="stat-block"><div class="stat-num" id="uk-uptime">—</div><div class="stat-label">24h %</div></div>
|
||||
</div>
|
||||
<div class="service-footer"><span id="uk-footer">No monitor data</span><div class="micro-strip" id="uk-bars"></div></div>
|
||||
</div>
|
||||
<div class="card service-card">
|
||||
<div class="card-title">
|
||||
<div class="card-title-left">
|
||||
<span class="service-icon icon-immich"><span class="icon-glyph glyph-image"></span></span>
|
||||
<span class="service-name">IMMICH</span>
|
||||
</div>
|
||||
<span class="status-pill pill-offline" id="immich-pill">OFFLINE</span>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-block"><div class="stat-num" id="immich-photos">—</div><div class="stat-label">Photos</div></div>
|
||||
<div class="stat-block"><div class="stat-num" id="immich-videos">—</div><div class="stat-label">Videos</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="immich-storage">—</div><div class="stat-label">Storage</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card service-card">
|
||||
<div class="card-title">
|
||||
<div class="card-title-left">
|
||||
<span class="service-icon icon-backrest"><span class="icon-glyph glyph-backrest"></span></span>
|
||||
<span class="service-name">BACKREST</span>
|
||||
<span class="status-dot dot-unk" id="backrest-status-dot" title="unknown"></span>
|
||||
</div>
|
||||
<span class="status-pill pill-offline" id="backrest-pill">OFFLINE</span>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-block"><div class="stat-num dim" id="backrest-last">—</div><div class="stat-label">Last Backup</div></div>
|
||||
<div class="stat-block"><div class="stat-num" id="backrest-repos">—</div><div class="stat-label">Repos</div></div>
|
||||
<div class="stat-block"><div class="stat-num danger" id="backrest-errors">—</div><div class="stat-label">Errors</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card service-card">
|
||||
<div class="card-title">
|
||||
<div class="card-title-left">
|
||||
<span class="service-icon icon-adguard"><span class="icon-glyph glyph-shield"></span></span>
|
||||
<span class="service-name">ADGUARD DNS</span>
|
||||
</div>
|
||||
<span class="status-pill pill-offline" id="adguard-pill">OFFLINE</span>
|
||||
</div>
|
||||
<div class="stats-grid" style="margin-bottom:5px;">
|
||||
<div class="stat-block"><div class="stat-num" id="adguard-total">—</div><div class="stat-label">Queries</div></div>
|
||||
<div class="stat-block"><div class="stat-num" id="adguard-blocked">—</div><div class="stat-label">Blocked</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="adguard-blocked-pct">—</div><div class="stat-label">Block %</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="adguard-latency">—</div><div class="stat-label">Latency</div></div>
|
||||
</div>
|
||||
<div class="adguard-bar-wrap"><div class="adguard-bar"><div class="adguard-bar-fill" id="adguard-bar-fill"></div></div></div>
|
||||
</div>
|
||||
<div class="card service-card">
|
||||
<div class="card-title">
|
||||
<div class="card-title-left">
|
||||
<span class="service-icon icon-services"><span class="icon-glyph glyph-services"></span></span>
|
||||
<span class="service-name">SERVICES OVERVIEW</span>
|
||||
</div>
|
||||
<span class="status-pill pill-offline" id="services-pill">—</span>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-block"><div class="stat-num" id="svc-online">—</div><div class="stat-label">Online</div></div>
|
||||
<div class="stat-block"><div class="stat-num warn" id="svc-degraded">—</div><div class="stat-label">Degraded</div></div>
|
||||
<div class="stat-block"><div class="stat-num danger" id="svc-offline">—</div><div class="stat-label">Offline</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="svc-total">—</div><div class="stat-label">Total</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QUICK ACCESS -->
|
||||
<div class="section-header"><span>⬡</span> QUICK ACCESS</div>
|
||||
<div id="quick-access-grid"></div>
|
||||
|
||||
</div><!-- /wrapper -->
|
||||
|
||||
<script type="module" src="/assets/js/app.js"></script>
|
||||
<script>
|
||||
// Clock
|
||||
function updateClock() {
|
||||
const now = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
document.getElementById('clock').textContent =
|
||||
`${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
||||
document.getElementById('date-str').textContent =
|
||||
now.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,54 +0,0 @@
|
||||
services:
|
||||
dashboard:
|
||||
image: ${DASHBOARD_IMAGE}
|
||||
container_name: kallilab-dashboard
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
APP_ENV: production
|
||||
APP_HOST: 0.0.0.0
|
||||
APP_PORT: 8000
|
||||
APP_LOG_LEVEL: INFO
|
||||
APP_TIMEZONE: Europe/Berlin
|
||||
APP_NAME: Homelab Dashboard API
|
||||
APP_VERSION: 0.1.0
|
||||
CORS_ALLOW_ORIGINS: '["https://dashboard.kaleschke.info"]'
|
||||
REQUEST_TIMEOUT_SECONDS: 5.0
|
||||
CACHE_TTL_OVERVIEW_SECONDS: 15
|
||||
CACHE_TTL_SYSTEM_SECONDS: 15
|
||||
CACHE_TTL_SERVICES_SECONDS: 15
|
||||
CACHE_TTL_STORAGE_SECONDS: 30
|
||||
BESZEL_BASE_URL: http://beszel:8090
|
||||
BESZEL_ADMIN_EMAIL: ${BESZEL_ADMIN_EMAIL}
|
||||
BESZEL_ADMIN_PASSWORD: ${BESZEL_ADMIN_PASSWORD}
|
||||
UPTIME_KUMA_BASE_URL: http://uptime-kuma:3001
|
||||
UPTIME_KUMA_API_KEY: ${UPTIME_KUMA_API_KEY}
|
||||
UPTIME_KUMA_USERNAME: ${UPTIME_KUMA_USERNAME}
|
||||
UPTIME_KUMA_PASSWORD: ${UPTIME_KUMA_PASSWORD}
|
||||
HOME_ASSISTANT_BASE_URL: ${HOME_ASSISTANT_BASE_URL}
|
||||
HOME_ASSISTANT_TOKEN: ${HOME_ASSISTANT_TOKEN}
|
||||
ADGUARD_BASE_URL: http://adguard:80
|
||||
ADGUARD_USERNAME: ${ADGUARD_USERNAME}
|
||||
ADGUARD_PASSWORD: ${ADGUARD_PASSWORD}
|
||||
SCRUTINY_BASE_URL: http://scrutiny:8080
|
||||
IMMICH_BASE_URL: http://immich_server:2283
|
||||
IMMICH_API_KEY: ${IMMICH_API_KEY}
|
||||
BACKREST_BASE_URL: http://backrest:9898
|
||||
BACKREST_USERNAME: ${BACKREST_USERNAME}
|
||||
BACKREST_PASSWORD: ${BACKREST_PASSWORD}
|
||||
networks:
|
||||
- frontend_net
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=frontend_net
|
||||
- traefik.http.routers.dashboard.rule=Host(`dashboard.kaleschke.info`)
|
||||
- traefik.http.routers.dashboard.entrypoints=websecure
|
||||
- traefik.http.routers.dashboard.tls=true
|
||||
- traefik.http.routers.dashboard.tls.certresolver=le
|
||||
- traefik.http.routers.dashboard.middlewares=authelia@file,secure-headers@file
|
||||
- traefik.http.services.dashboard.loadbalancer.server.port=8000
|
||||
|
||||
networks:
|
||||
frontend_net:
|
||||
external: true
|
||||
File diff suppressed because one or more lines are too long
@@ -1,24 +0,0 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
firefly-fints:
|
||||
image: benkl/firefly-iii-fints-importer:latest
|
||||
container_name: firefly-fints
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- /mnt/user/appdata/firefly-fints:/data
|
||||
networks:
|
||||
- frontend_net
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
ports:
|
||||
- "8091:8080"
|
||||
dns:
|
||||
- 1.1.1.1
|
||||
- 8.8.8.8
|
||||
|
||||
networks:
|
||||
frontend_net:
|
||||
external: true
|
||||
@@ -1,4 +0,0 @@
|
||||
MYSQL_RANDOM_ROOT_PASSWORD=yes
|
||||
MYSQL_DATABASE=firefly
|
||||
MYSQL_USER=firefly
|
||||
MYSQL_PASSWORD=firefly
|
||||
@@ -1,16 +0,0 @@
|
||||
APP_KEY=base64:ZHr3GRFkH9jEJ6TtoD6pEEsLHEfRViqqxSV6G7Zsba8=
|
||||
APP_URL=https://firefly.kaleschke.info
|
||||
|
||||
DB_HOST=firefly-db
|
||||
DB_PORT=3306
|
||||
DB_CONNECTION=mysql
|
||||
DB_DATABASE=firefly
|
||||
DB_USERNAME=firefly
|
||||
DB_PASSWORD=firefly
|
||||
|
||||
TRUSTED_PROXIES=**
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
LOG_CHANNEL=stack
|
||||
|
||||
TZ=Europe/Berlin
|
||||
@@ -1,8 +0,0 @@
|
||||
TZ=Europe/Berlin
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
LOG_CHANNEL=stack
|
||||
|
||||
TRUSTED_PROXIES=**
|
||||
FIREFLY_III_URL=http://firefly-app:8080
|
||||
VANITY_URL=https://firefly.kaleschke.info
|
||||
@@ -1,67 +0,0 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
firefly-db:
|
||||
image: mariadb:10.11
|
||||
container_name: firefly-db
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .db.env
|
||||
volumes:
|
||||
- /mnt/user/appdata/firefly/db:/var/lib/mysql
|
||||
networks:
|
||||
- backend_net
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
firefly-app:
|
||||
image: fireflyiii/core:latest
|
||||
container_name: firefly-app
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- firefly-db
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- /mnt/user/appdata/firefly/upload:/var/www/html/storage/upload
|
||||
networks:
|
||||
- frontend_net
|
||||
- backend_net
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=frontend_net"
|
||||
- "traefik.http.routers.firefly.rule=Host(`firefly.kaleschke.info`)"
|
||||
- "traefik.http.routers.firefly.entrypoints=websecure"
|
||||
- "traefik.http.routers.firefly.tls=true"
|
||||
- "traefik.http.routers.firefly.tls.certresolver=le"
|
||||
- "traefik.http.services.firefly.loadbalancer.server.port=8080"
|
||||
|
||||
firefly-importer:
|
||||
image: fireflyiii/data-importer:latest
|
||||
container_name: firefly-importer
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- firefly-app
|
||||
env_file:
|
||||
- .env
|
||||
- .importer.env
|
||||
networks:
|
||||
- frontend_net
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=frontend_net"
|
||||
- "traefik.http.routers.firefly-importer.rule=Host(`import.firefly.kaleschke.info`)"
|
||||
- "traefik.http.routers.firefly-importer.entrypoints=websecure"
|
||||
- "traefik.http.routers.firefly-importer.tls=true"
|
||||
- "traefik.http.routers.firefly-importer.tls.certresolver=le"
|
||||
- "traefik.http.services.firefly-importer.loadbalancer.server.port=8080"
|
||||
|
||||
networks:
|
||||
frontend_net:
|
||||
external: true
|
||||
backend_net:
|
||||
external: true
|
||||
@@ -1,42 +0,0 @@
|
||||
services:
|
||||
homepage:
|
||||
image: ghcr.io/gethomepage/homepage:latest
|
||||
container_name: homepage
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
HOMEPAGE_ALLOWED_HOSTS: home.kaleschke.info
|
||||
HOMEPAGE_VAR_GITEA_TOKEN: ${HOMEPAGE_VAR_GITEA_TOKEN}
|
||||
HOMEPAGE_VAR_ADGUARD_USERNAME: ${HOMEPAGE_VAR_ADGUARD_USERNAME}
|
||||
HOMEPAGE_VAR_ADGUARD_PASSWORD: ${HOMEPAGE_VAR_ADGUARD_PASSWORD}
|
||||
HOMEPAGE_VAR_KOMODO_API_KEY: ${HOMEPAGE_VAR_KOMODO_API_KEY}
|
||||
HOMEPAGE_VAR_KOMODO_API_SECRET: ${HOMEPAGE_VAR_KOMODO_API_SECRET}
|
||||
HOMEPAGE_VAR_BACKREST_USERNAME: ${HOMEPAGE_VAR_BACKREST_USERNAME}
|
||||
HOMEPAGE_VAR_BACKREST_PASSWORD: ${HOMEPAGE_VAR_BACKREST_PASSWORD}
|
||||
HOMEPAGE_VAR_SPEEDTEST_API_KEY: ${HOMEPAGE_VAR_SPEEDTEST_API_KEY}
|
||||
HOMEPAGE_VAR_PAPERLESS_TOKEN: ${HOMEPAGE_VAR_PAPERLESS_TOKEN}
|
||||
HOMEPAGE_VAR_FILEBROWSER_USERNAME: ${HOMEPAGE_VAR_FILEBROWSER_USERNAME}
|
||||
HOMEPAGE_VAR_FILEBROWSER_PASSWORD: ${HOMEPAGE_VAR_FILEBROWSER_PASSWORD}
|
||||
HOMEPAGE_VAR_IMMICH_API_KEY: ${HOMEPAGE_VAR_IMMICH_API_KEY}
|
||||
HOMEPAGE_VAR_MEALIE_TOKEN: ${HOMEPAGE_VAR_MEALIE_TOKEN}
|
||||
HOMEPAGE_VAR_UPTIME_SLUG: ${HOMEPAGE_VAR_UPTIME_SLUG}
|
||||
volumes:
|
||||
- /mnt/user/appdata/homepage:/app/config
|
||||
- /mnt/user/appdata/homepage/images:/app/public/images
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- frontend_net
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=frontend_net
|
||||
- traefik.http.routers.homepage.rule=Host(`home.kaleschke.info`)
|
||||
- traefik.http.routers.homepage.entrypoints=websecure
|
||||
- traefik.http.routers.homepage.tls=true
|
||||
- traefik.http.routers.homepage.tls.certresolver=le
|
||||
- traefik.http.routers.homepage.middlewares=authelia@file,secure-headers@file
|
||||
- traefik.http.services.homepage.loadbalancer.server.port=3000
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
networks:
|
||||
frontend_net:
|
||||
external: true
|
||||
@@ -1,9 +1,7 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
immich-server:
|
||||
container_name: immich_server
|
||||
image: ghcr.io/immich-app/immich-server:release
|
||||
image: ghcr.io/immich-app/immich-server:release@sha256:c15bff75068effb03f4355997d03dc7e0fc58720c2b54ad6f7f10d1bc57efaa5
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- redis
|
||||
@@ -14,10 +12,10 @@ services:
|
||||
DB_PASSWORD: ${IMMICH_DB_PASSWORD}
|
||||
DB_DATABASE_NAME: immich
|
||||
REDIS_HOSTNAME: redis
|
||||
TZ: Europe/Berlin
|
||||
volumes:
|
||||
- /mnt/user/photos/immich:/usr/src/app/upload
|
||||
- /mnt/user/photos/family_archive:/usr/src/app/external
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
networks:
|
||||
- immich_default
|
||||
- frontend_net
|
||||
@@ -34,7 +32,7 @@ services:
|
||||
|
||||
immich-machine-learning:
|
||||
container_name: immich_machine_learning
|
||||
image: ghcr.io/immich-app/immich-machine-learning:release
|
||||
image: ghcr.io/immich-app/immich-machine-learning:release@sha256:a2501141440f10516d329fdfba2c68082e19eb9ba6016c061ac80d23beadf7f3
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- model-cache:/cache
|
||||
@@ -45,7 +43,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:7
|
||||
image: redis:7.4-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- immich_default
|
||||
@@ -54,7 +52,7 @@ services:
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
image: tensorchord/pgvecto-rs:pg14-v0.2.0
|
||||
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
|
||||
@@ -77,4 +75,4 @@ networks:
|
||||
internal: true
|
||||
driver: bridge
|
||||
frontend_net:
|
||||
external: true
|
||||
external: true
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
mail-archiver:
|
||||
image: s1t5/mailarchiver
|
||||
image: s1t5/mailarchiver@sha256:94d7525db56b13154a14203f8fb7b53fac034f28a914c32da9d2e426b49328ed
|
||||
container_name: mail-archiver
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@@ -25,9 +24,10 @@ services:
|
||||
- "traefik.http.routers.mail-archiver.entrypoints=websecure"
|
||||
- "traefik.http.routers.mail-archiver.tls=true"
|
||||
- "traefik.http.routers.mail-archiver.tls.certresolver=le"
|
||||
- "traefik.http.routers.mail-archiver.middlewares=authelia@file,secure-headers@file"
|
||||
- "traefik.http.services.mail-archiver.loadbalancer.server.port=5000"
|
||||
networks:
|
||||
backend_net:
|
||||
external: true
|
||||
frontend_net:
|
||||
external: true
|
||||
external: true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.12.0
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.12.0@sha256:8d962f611390a1cca667eed32a29e9467e9c01c523e2db3ad00f667372067f9d
|
||||
container_name: mealie
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -14,13 +14,12 @@ services:
|
||||
POSTGRES_SERVER: mealie-postgres
|
||||
POSTGRES_DB: mealie
|
||||
POSTGRES_USER: mealie
|
||||
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
|
||||
POSTGRES_PASSWORD: ${MEALIE_POSTGRES_PASSWORD}
|
||||
|
||||
BASE_URL: https://mealie.kaleschke.info
|
||||
|
||||
volumes:
|
||||
- /mnt/user/appdata/mealie/data:/app/data
|
||||
- /mnt/user/appdata/secrets/mealie_postgres_password.txt:/run/secrets/postgres_password:ro
|
||||
|
||||
networks:
|
||||
- frontend_net
|
||||
@@ -39,7 +38,7 @@ services:
|
||||
- traefik.http.services.mealie.loadbalancer.server.port=9000
|
||||
|
||||
mealie-postgres:
|
||||
image: postgres:17
|
||||
image: postgres:17.9@sha256:5b96f1a16bd9768b060dd2ffe55cb6225c4d9ef4d214a8b21eb08134869a97e4
|
||||
container_name: mealie-postgres
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -66,4 +65,4 @@ networks:
|
||||
|
||||
mealie_internal:
|
||||
driver: bridge
|
||||
internal: true
|
||||
internal: true
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
services:
|
||||
nextcloud:
|
||||
image: nextcloud:33.0.2-apache@sha256:39b2ba219271a22851f8409a7b1295d5892aba1696d9193500311c02e60591a4
|
||||
container_name: nextcloud
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- nextcloud-postgres
|
||||
- nextcloud-redis
|
||||
environment:
|
||||
TZ: Europe/Berlin
|
||||
POSTGRES_HOST: nextcloud-postgres
|
||||
POSTGRES_DB: nextcloud
|
||||
POSTGRES_USER: nextcloud
|
||||
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
|
||||
REDIS_HOST: nextcloud-redis
|
||||
NEXTCLOUD_ADMIN_USER_FILE: /run/secrets/admin_user
|
||||
NEXTCLOUD_ADMIN_PASSWORD_FILE: /run/secrets/admin_password
|
||||
NEXTCLOUD_DATA_DIR: /var/www/html/data
|
||||
NEXTCLOUD_TRUSTED_DOMAINS: cloud.kaleschke.info
|
||||
TRUSTED_PROXIES: 172.16.0.0/12
|
||||
OVERWRITEHOST: cloud.kaleschke.info
|
||||
OVERWRITEPROTOCOL: https
|
||||
OVERWRITECLIURL: https://cloud.kaleschke.info
|
||||
volumes:
|
||||
- /mnt/user/appdata/nextcloud/html:/var/www/html
|
||||
- /mnt/user/documents/nextcloud-data:/var/www/html/data
|
||||
- /mnt/user/appdata/secrets/nextcloud_postgres_password.txt:/run/secrets/postgres_password:ro
|
||||
- /mnt/user/appdata/secrets/nextcloud_admin_user.txt:/run/secrets/admin_user:ro
|
||||
- /mnt/user/appdata/secrets/nextcloud_admin_password.txt:/run/secrets/admin_password:ro
|
||||
networks:
|
||||
- frontend_net
|
||||
- nextcloud_internal
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=frontend_net"
|
||||
- "traefik.http.routers.nextcloud.rule=Host(`cloud.kaleschke.info`)"
|
||||
- "traefik.http.routers.nextcloud.entrypoints=websecure"
|
||||
- "traefik.http.routers.nextcloud.tls=true"
|
||||
- "traefik.http.routers.nextcloud.tls.certresolver=le"
|
||||
- "traefik.http.routers.nextcloud.middlewares=nextcloud-redirectregex"
|
||||
- "traefik.http.middlewares.nextcloud-redirectregex.redirectregex.permanent=true"
|
||||
- "traefik.http.middlewares.nextcloud-redirectregex.redirectregex.regex=https://(.*)/.well-known/(?:card|cal)dav"
|
||||
- "traefik.http.middlewares.nextcloud-redirectregex.redirectregex.replacement=https://$${1}/remote.php/dav"
|
||||
- "traefik.http.services.nextcloud.loadbalancer.server.port=80"
|
||||
|
||||
nextcloud-postgres:
|
||||
image: postgres:17.9@sha256:5b96f1a16bd9768b060dd2ffe55cb6225c4d9ef4d214a8b21eb08134869a97e4
|
||||
container_name: nextcloud-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: Europe/Berlin
|
||||
POSTGRES_DB: nextcloud
|
||||
POSTGRES_USER: nextcloud
|
||||
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
|
||||
PGDATA: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- /mnt/user/appdata/nextcloud/postgres:/var/lib/postgresql/data
|
||||
- /mnt/user/appdata/secrets/nextcloud_postgres_password.txt:/run/secrets/postgres_password:ro
|
||||
networks:
|
||||
- nextcloud_internal
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
nextcloud-redis:
|
||||
image: redis:7.4-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99
|
||||
container_name: nextcloud-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --save 60 1 --loglevel warning
|
||||
volumes:
|
||||
- /mnt/user/appdata/nextcloud/redis:/data
|
||||
networks:
|
||||
- nextcloud_internal
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
networks:
|
||||
frontend_net:
|
||||
external: true
|
||||
|
||||
nextcloud_internal:
|
||||
driver: bridge
|
||||
internal: true
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
ntfy:
|
||||
image: binwiederhier/ntfy:latest
|
||||
image: binwiederhier/ntfy@sha256:2b9e12d56a538f4402da51328eeca02696c4b207ab7fbe031c27e51a22ca9b86
|
||||
container_name: ntfy
|
||||
restart: unless-stopped
|
||||
dns:
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
paperless-gpt:
|
||||
image: icereed/paperless-gpt:latest
|
||||
image: icereed/paperless-gpt:v0.24.0@sha256:15bad5d455b98f21bb7b5d6615f56871ff67a8bb379dc0dd7ba411f4633071a6
|
||||
container_name: paperless-gpt
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
@@ -18,19 +16,21 @@ services:
|
||||
MANUAL_TAG: "paperless-gpt"
|
||||
AUTO_TAG: "paperless-gpt-auto"
|
||||
LLM_PROVIDER: "ollama"
|
||||
LLM_MODEL: "cnshenyang/qwen3-nothink:14b"
|
||||
LLM_MODEL: "qwen3:8b"
|
||||
OLLAMA_HOST: "http://192.168.178.103:11434"
|
||||
OLLAMA_CONTEXT_LENGTH: "4096"
|
||||
TOKEN_LIMIT: "1500"
|
||||
LLM_LANGUAGE: "German"
|
||||
OCR_PROVIDER: "llm"
|
||||
VISION_LLM_PROVIDER: "ollama"
|
||||
VISION_LLM_MODEL: "cnshenyang/qwen3-nothink:14b"
|
||||
VISION_LLM_MODEL: "minicpm-v:latest"
|
||||
OCR_PROCESS_MODE: "image"
|
||||
CREATE_NEW_TAGS: "true"
|
||||
AUTO_GENERATE_TITLE: "true"
|
||||
AUTO_GENERATE_TAGS: "true"
|
||||
AUTO_GENERATE_CORRESPONDENTS: "true"
|
||||
AUTO_GENERATE_DOCUMENT_TYPE: "true"
|
||||
LOG_LEVEL: "debug"
|
||||
LOG_LEVEL: "info"
|
||||
volumes:
|
||||
- /mnt/user/appdata/paperless-gpt/data:/app/data
|
||||
- /mnt/user/appdata/paperless-gpt/prompts:/app/prompts
|
||||
@@ -48,4 +48,4 @@ services:
|
||||
|
||||
networks:
|
||||
frontend_net:
|
||||
external: true
|
||||
external: true
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
paperless:
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:2.20.10
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:2.20.10@sha256:07a0b4ba01ce377c82a0636e16c0c3d931fde5b7e9304de6601986cc42d9b6e6
|
||||
container_name: paperless-ngx
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
@@ -17,6 +16,19 @@ services:
|
||||
- PAPERLESS_TIME_ZONE=Europe/Berlin
|
||||
- PAPERLESS_OCR_LANGUAGE=deu+eng
|
||||
- PAPERLESS_URL=https://paperless.kaleschke.info
|
||||
|
||||
# Barcode / ASN
|
||||
- PAPERLESS_CONSUMER_ENABLE_BARCODES=1
|
||||
- PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE=1
|
||||
- PAPERLESS_CONSUMER_ASN_BARCODE_PREFIX=ASN
|
||||
|
||||
# Erkennung robuster für kleine Labels
|
||||
- PAPERLESS_CONSUMER_BARCODE_DPI=600
|
||||
- PAPERLESS_CONSUMER_BARCODE_UPSCALE=1.5
|
||||
|
||||
# Optional: alle Seiten prüfen
|
||||
- PAPERLESS_CONSUMER_BARCODE_MAX_PAGES=0
|
||||
|
||||
volumes:
|
||||
- /mnt/user/documents/scans_inbox:/usr/src/paperless/consume
|
||||
- /mnt/user/appdata/paperless-ngx/data:/usr/src/paperless/data
|
||||
@@ -33,7 +45,7 @@ services:
|
||||
- "traefik.http.routers.paperless.tls=true"
|
||||
- "traefik.http.routers.paperless.tls.certresolver=le"
|
||||
- "traefik.http.services.paperless.loadbalancer.server.port=8000"
|
||||
|
||||
|
||||
networks:
|
||||
frontend_net:
|
||||
external: true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
unbound:
|
||||
image: shaanmajid/unbound:latest
|
||||
image: shaanmajid/unbound:1.24.2@sha256:d278b71c592b2555cc802911bb0757a6a24f4a8ad7f5848720296c04876eeb63
|
||||
container_name: unbound
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
@@ -13,4 +13,4 @@ services:
|
||||
|
||||
networks:
|
||||
dns_net:
|
||||
external: true
|
||||
external: true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
gitea:
|
||||
image: docker.gitea.com/gitea:1.25.4
|
||||
image: docker.gitea.com/gitea:1.25.4@sha256:17d18218be2dad1f8ed402a4f906989505c90ab8b66ee9befcecfb5d470133e7
|
||||
container_name: gitea
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
@@ -11,9 +11,17 @@ services:
|
||||
- GITEA__server__DOMAIN=git.kaleschke.info
|
||||
- GITEA__server__ROOT_URL=https://git.kaleschke.info/
|
||||
- GITEA__database__DB_TYPE=sqlite3
|
||||
- GITEA__webhook__ALLOWED_HOST_LIST=*
|
||||
- GITEA__service__DISABLE_REGISTRATION=true
|
||||
- GITEA__service__REGISTER_EMAIL_CONFIRM=true
|
||||
- GITEA__openid__ENABLE_OPENID_SIGNIN=false
|
||||
- GITEA__openid__ENABLE_OPENID_SIGNUP=false
|
||||
- GITEA__migrations__ALLOWED_DOMAINS=github.com
|
||||
- GITEA__webhook__ALLOWED_HOST_LIST=komodo-core,localhost,127.0.0.1,192.168.178.0/24
|
||||
volumes:
|
||||
- /mnt/user/services/gitea/data:/data
|
||||
dns:
|
||||
- 1.1.1.1
|
||||
- 8.8.8.8
|
||||
ports:
|
||||
- "222:22"
|
||||
networks:
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
# AI Context
|
||||
|
||||
Stand: 2026-05-04
|
||||
|
||||
Diese Datei ist fuer KI-Agenten gedacht, die das Homelab-Repo schnell verstehen muessen. Sie ersetzt nicht die Detaildokumente, sondern fasst Zielbild, Betriebsmodell und Risiken zusammen.
|
||||
|
||||
## Kurzfassung
|
||||
|
||||
Dieses Repository beschreibt ein Unraid-basiertes Homelab namens `Kallilabcore`. Der Betrieb folgt GitOps: Gitea `origin/master` ist die Quelle der Wahrheit, der lokale Clone ist Arbeitskopie, Komodo deployed aus Gitea, Docker Runtime und Host sind Ergebnis, nicht Bearbeitungsort.
|
||||
|
||||
Traefik ist der zentrale Web-Einstieg fuer HTTP(S). Admin-/Ops-UIs liegen entweder hinter Authelia/secure headers oder sind als Ausnahme dokumentiert. Secrets liegen ausserhalb des Repos auf dem Host, meistens unter `/mnt/user/appdata/secrets/`.
|
||||
|
||||
## Zielbild des Homelabs
|
||||
|
||||
- stabile Compose-first Infrastruktur
|
||||
- keine produktiven Dockerman-/Ad-hoc-Container als Dauerzustand
|
||||
- Traefik als einziger oeffentlicher Web-Eingang
|
||||
- GitOps ueber Gitea + Komodo
|
||||
- klare Trennung von Web-Netz, Backend-Netz und app-internen Netzen
|
||||
- saubere Backup-/Restore-Faehigkeit ueber Borg, Dumps und dokumentierte Pfade
|
||||
- keine stillen Host-Hotfixes ohne Repo-/Doku-Abgleich
|
||||
|
||||
## Architekturblocke
|
||||
|
||||
### Ingress / Netzwerk
|
||||
|
||||
- `traefik` nimmt 80/443 entgegen.
|
||||
- Docker-Labels definieren Service-Routing.
|
||||
- `traefik/dynamic/*` bleibt nur fuer Middlewares, TLS und Dashboards.
|
||||
- Neue Service-Routen gehoeren nicht in den File-Provider.
|
||||
|
||||
### DNS / Remote
|
||||
|
||||
- AdGuard Home beantwortet LAN-DNS und nutzt Unbound als Upstream.
|
||||
- Tailscale stellt Remote-Zugang bereit und nutzt `network_mode: host`.
|
||||
|
||||
### GitOps
|
||||
|
||||
- Gitea hostet das Repo unter `git.kaleschke.info`.
|
||||
- Komodo ist Stack-Manager und Deploy-Consumer.
|
||||
- Komodo Periphery braucht Docker-Socket und `/mnt/user/services` Mount, um Stacks reproduzierbar zu deployen.
|
||||
- Neue produktive Komodo-Stacks aus `Micha/homelab-infra` muessen einen aktiven Gitea->Komodo-Webhook auf die aktuelle Stack-ID haben; Ausnahmen wie deaktivierte/pausierte Stacks muessen dokumentiert werden.
|
||||
- Der `komodo`-Self-Stack ist eine dokumentierte Ausnahme ohne aktiven Gitea-Webhook; Bootstrap/Recovery laeuft ueber `docs/SERVICES_RECOVERY.md`.
|
||||
|
||||
### Identity / Security
|
||||
|
||||
- Authelia stellt ForwardAuth fuer viele Admin-UIs bereit.
|
||||
- Authelia nutzt GMX SMTP fuer Identity-/2FA-Benachrichtigungen; Passwort liegt als Host-Secret `authelia_smtp_password.txt`.
|
||||
- Vaultwarden ist ein separater Passwort-Tresor.
|
||||
- Komodo ist bewusst nicht pauschal hinter Authelia, weil UI, API, Webhooks und Periphery-WebSocket sonst leicht gebrochen werden koennen.
|
||||
- Komodo-Compose, Komodo-Secrets und Komodo-Runtime nur gemeinsam mit dem Betreiber aendern; `KOMODO_WEBHOOK_SECRET` ist bewusst getrennt von `KOMODO_SECRET_KEY`. Gitea-Webhooks nicht pauschal vereinheitlichen: einzelne Komodo-Stacks koennen eigene per-Stack-Webhook-Secrets haben.
|
||||
|
||||
### Apps
|
||||
|
||||
Wichtige Apps sind Paperless, Immich, Mealie, Mail Archiver, Nextcloud, ntfy, Vaultwarden und Gitea. Admin-/Ops-Tools sind u. a. Glance, Komodo, Borg UI, Filebrowser, code-server, Glances, Scrutiny, Speedtest, Monitoring Grafana und Hermes Agent.
|
||||
|
||||
### Hermes Agent — Architektur und Ops-Monitor
|
||||
|
||||
Hermes laeuft nach Model C (siehe `ops/hermes-agent/README.md`):
|
||||
|
||||
- `hermes-gateway` als Docker-Container auf dem Unraid-Host, intern auf `hermes_net:8642`
|
||||
- Terminal-Befehle werden via SSH auf eine dedizierte Linux-VM ausgefuehrt
|
||||
- VM-IP: `192.168.178.143`, SSH-User: `hermes`
|
||||
- Repo-Clone auf der VM: `/srv/hermes-workspace/homelab-infra/`
|
||||
|
||||
**Fuer KI-Agenten wichtig:** Das Hermes-Terminal laeuft auf der VM, nicht auf dem Unraid-Host.
|
||||
`/mnt/user/...`-Pfade sind von der VM aus nicht direkt erreichbar.
|
||||
Docker-CLI ist auf der VM nicht installiert — fuer Homelab-Checks wird `check_health.py` verwendet.
|
||||
|
||||
**Ops-Monitor (homelab-ops-monitor):**
|
||||
|
||||
- Skill: `ops/hermes-agent/skills/homelab-ops-monitor.md`
|
||||
- Script: `ops/hermes-agent/scripts/check_health.py` — prueft Services via HTTP, keine externen Deps
|
||||
- Wissensbasis: `ops/hermes-agent/services.json` — maschinenlesbare Ableitung aus `docs/SERVICE_CATALOG.md`
|
||||
- Check-Strategie: HTTP GET fuer URL-basierte Services, interne Services (DBs, Redis) als `"internal"` markiert
|
||||
- ntfy-Topic fuer Alerts: `homelab-alerts` auf `https://ntfy.kaleschke.info`
|
||||
|
||||
Nach Aenderungen an `services.json` oder `check_health.py`: `git pull` auf der VM ausfuehren.
|
||||
- Mail Archiver ist ein Hybrid-Dienst mit `frontend_net` fuer IMAP/Traefik und `backend_net` fuer PostgreSQL; die Web-UI liegt hinter Authelia und behaelt zusaetzlich App-eigene Auth.
|
||||
|
||||
### Monitoring / Metriken
|
||||
|
||||
- Zielzustand ist ein zentraler Stack `monitoring/` unter `https://monitoring.kaleschke.info`.
|
||||
- `monitoring-grafana` ist die zentrale UI fuer Prometheus, Loki und InfluxDB 3 Core.
|
||||
- `monitoring-prometheus` sammelt Infrastruktur-Metriken; `monitoring-loki` + `monitoring-promtail` sammeln Docker-Logs.
|
||||
- `monitoring-influxdb3-core` ist nicht public und nicht im `frontend_net`.
|
||||
- Home Assistant schreibt ueber LAN-only Port 8181 nach InfluxDB, gebunden ueber `INFLUXDB_BIND_IP`.
|
||||
- Ein `401 Unauthorized` von InfluxDB ohne Token ist beim Reachability-Test ein Erfolgssignal.
|
||||
- Die frueheren Altstaende `ops/loki` und `ops/grafana-influxdb` wurden aus dem aktiven Repo entfernt; fuer Monitoring immer `monitoring/` verwenden, Rollback nur ueber Git-Historie.
|
||||
- Uptime Kuma ist entfernt; HTTP-Verfuegbarkeit laeuft ueber Blackbox Exporter, Prometheus-Alerts und `Homelab / Availability` in Monitoring Grafana.
|
||||
|
||||
## Deployment-Logik
|
||||
|
||||
Normalfall:
|
||||
|
||||
1. lokaler Clone synchronisieren
|
||||
2. betroffene Dokumente und Compose-Datei lesen
|
||||
3. minimal aendern
|
||||
4. lokal validieren
|
||||
5. committen und pushen
|
||||
6. Komodo-Webhook / Deploy beobachten
|
||||
7. Runtime testen
|
||||
8. Doku aktualisieren
|
||||
|
||||
Wichtig: Komodo-Web-Editor ist nicht der Bearbeitungsort. Wenn Komodo und Git voneinander abweichen, zuerst Git und Komodo Workspace pruefen, nicht live herumprobieren.
|
||||
|
||||
Beim Anlegen neuer produktiver Stacks ist der Gitea->Komodo-Webhook Pflicht. Nach dem Anlegen muss ein Test-Push oder Test-Delivery zeigen, dass Gitea die aktuelle Komodo-Stack-ID erreicht.
|
||||
|
||||
## Netzwerkmodell
|
||||
|
||||
| Netzwerk | Bedeutung |
|
||||
|---|---|
|
||||
| `frontend_net` | Web-/Proxy-Netz fuer Traefik-geroutete Dienste und Dienste mit Internetbedarf |
|
||||
| `backend_net` | internes Netz fuer shared PostgreSQL, Redis und Backends |
|
||||
| `dns_net` | AdGuard + Unbound |
|
||||
| app-interne Netze | Isolation von App + DB/Cache, z. B. Immich, Mealie, Nextcloud, Monitoring |
|
||||
| `host` | nur dokumentierte Sonderfaelle wie Tailscale/Plex |
|
||||
|
||||
Regeln:
|
||||
|
||||
- Datenbanken nie ins `frontend_net`.
|
||||
- Admin-UIs nur mit Traefik + Middleware oder dokumentierter Ausnahme.
|
||||
- Direkte Host-Ports sind Ausnahme, nicht Default.
|
||||
- Runtime-Netznamen koennen Compose-Projektpraefixe bekommen, z. B. `monitoring_monitoring_influx_lan`.
|
||||
|
||||
## Security-Modell
|
||||
|
||||
- Secrets nie ins Git.
|
||||
- Werte niemals zitieren, auch nicht aus `.env`, Stack ENV, Logs oder Screenshots.
|
||||
- Secret-Dateien bevorzugt unter `/mnt/user/appdata/secrets/`.
|
||||
- `_FILE`-Varianten bevorzugen, falls Image sie unterstuetzt.
|
||||
- Wenn `_FILE` nicht unterstuetzt wird, Komodo Stack Environment Variables verwenden.
|
||||
- Docker-Socket, `privileged: true`, Host-Netz und breite Mounts sind nur mit dokumentierter Begruendung akzeptabel.
|
||||
|
||||
Bekannte Ausnahmen:
|
||||
|
||||
- Traefik: 80/443
|
||||
- Gitea: SSH 222
|
||||
- AdGuard: DNS 53 direkt; Admin 8082 ist bewusst ohne Traefik/2FA, aber auf Tailscale-IP `100.80.98.33` begrenzt
|
||||
- Tailscale: Host-Netz, `NET_ADMIN`, `NET_RAW`, `/dev/net/tun`
|
||||
- Scrutiny: privileged
|
||||
- Komodo: Docker-Socket, native Auth
|
||||
- InfluxDB: LAN-only 8181 fuer Home Assistant Writer
|
||||
- `monitoring-influxdb3-core`: `user: "0"` als dokumentierte Host-Appdata-Permissions-Ausnahme
|
||||
- Traefik dynamic config: manueller Host-Sync
|
||||
|
||||
## Backup- und Restore-Modell
|
||||
|
||||
Borg sichert kritische Appdaten, Secrets, Traefik-State und Dump-Artefakte. Datenbank-Restore soll bevorzugt ueber Dumps laufen, nicht ueber rohe Live-DB-Verzeichnisse.
|
||||
|
||||
Wichtige Pfade:
|
||||
|
||||
- `/mnt/user/appdata`
|
||||
- `/mnt/user/appdata/secrets`
|
||||
- `/mnt/user/services`
|
||||
- `/mnt/user/documents`
|
||||
- `/mnt/user/photos`
|
||||
- `/mnt/user/backups/borg/dumps/latest`
|
||||
|
||||
Dump-Skript:
|
||||
|
||||
- `ops/borg-ui/scripts/pre-backup-dumps.sh`
|
||||
- soll auf dem Unraid Host laufen
|
||||
- soll nicht als Borg-UI Inline-Hook behandelt werden, solange die Architektur nicht bewusst geaendert wird
|
||||
|
||||
Disaster Recovery folgt einer Bootstrap-Reihenfolge:
|
||||
|
||||
1. Traefik, AdGuard, Tailscale
|
||||
2. PostgreSQL, Authelia, Redis, Gitea
|
||||
3. Komodo
|
||||
4. kritische Apps
|
||||
5. restliche Apps/Ops inklusive Hermes Agent
|
||||
|
||||
## Typische Arbeitsweise im Repo
|
||||
|
||||
- Fuer Fragen zuerst `HOMELAB_ARCHITECTURE_MASTER_V2.md` lesen.
|
||||
- Fuer operative Aenderungen `docs/WORKFLOW.md` lesen.
|
||||
- Fuer Service-Details `docs/SERVICE_CATALOG.md` und die Compose-Datei lesen.
|
||||
- Fuer Drift `docs/GITOPS_DRIFT_RUNBOOK.md` nutzen.
|
||||
- Fuer Rollback `docs/ROLLBACK.md` nutzen.
|
||||
- Fuer Restore `docs/DISASTER_RECOVERY.md` und `docs/RESTORE_MATRIX.md` nutzen.
|
||||
|
||||
KI-Agenten sollen konservativ arbeiten: keine indirekten Live-Aenderungen, keine Deployments, keine Commits, keine Host-Schreibbefehle, wenn der Benutzer nur Analyse oder Doku verlangt.
|
||||
|
||||
## Bekannte Risiken und Altlasten
|
||||
|
||||
- Traefik dynamic config muss manuell auf den Host synchronisiert werden; Komodo deployed diese Dateien nicht automatisch.
|
||||
- `backend_net` und app-interne Netze muessen bei Runtime-Problemen live geprueft werden, weil Compose-Projektpraefixe Netznamen veraendern koennen.
|
||||
- Authelia `configuration.yml` ist Repo-Baseline fuer nicht geheime Einstellungen, wird aber nicht automatisch von Komodo auf den Host kopiert; die produktive Host-Datei kann OIDC-/Secret-Konfiguration enthalten. Bei Auth-Aenderungen Repo-Baseline, Host-Config und Compose-Middlewares pruefen und nicht blind ueberschreiben.
|
||||
- Authelia nutzt PostgreSQL, aber bewusst kein Redis-Session-Backend; Redis ist kein Authelia-Bootstrap-Blocker.
|
||||
- Authelia-Notifier ist SMTP; bei Auth-Aenderungen Host-Config backupen, `authelia validate-config` ausfuehren und erst danach neu starten.
|
||||
- `paperless-ngx` nutzt fuer DB/Redis bewusst Stack ENV statt `_FILE`.
|
||||
- `glance-docker-socket-proxy`, `glances` und `komodo-periphery` nutzen Docker-/Socket-Zugriff; Zugriff bewusst behandeln.
|
||||
- `borg-ui` und `filebrowser` haben breite Mounts; bei Hardening nicht ad hoc, sondern gezielt vorgehen.
|
||||
- `scrutiny` ist privilegiert und hat Device-Mounts.
|
||||
- `Plex-Media-Server` ist im Architekturziel als Host-Sonderfall dokumentiert, aber nicht als Repo-Compose-Stack enthalten.
|
||||
- Echte `stack.env`- und `.env`-Dateien gehoeren nicht ins Repo; fuer Hermes liegt nur `ops/hermes-agent/stack.env.example` im Git.
|
||||
- Einige Images nutzen mutable Tag plus Digest. Das friert den aktuellen Digest ein, ist aber kein automatisches Upgrade-Modell.
|
||||
- Stateful Images werden bevorzugt als Minor-/Patch-Tag plus Digest gepinnt; Redis-Caches bleiben bewusst ungedigestet.
|
||||
|
||||
## Arbeitsregel bei Unsicherheit
|
||||
|
||||
Nicht raten. Erst diese Reihenfolge:
|
||||
|
||||
1. Repo-Doku lesen
|
||||
2. Compose-Datei lesen
|
||||
3. Git-Stand pruefen
|
||||
4. Komodo Workspace pruefen
|
||||
5. Docker Runtime pruefen
|
||||
6. Host-Listener / echten Request pruefen
|
||||
7. genau eine abweichende Ebene benennen
|
||||
|
||||
Wenn zwei Reparaturversuche scheitern: keine weiteren Schreibbefehle, Pflichtmatrix aus `docs/GITOPS_DRIFT_RUNBOOK.md` ausfuellen.
|
||||
@@ -0,0 +1,80 @@
|
||||
# AI Handoff 2026-05-06
|
||||
|
||||
Kompakte Quelle fuer einen neuen Chat. Ziel: nicht das ganze Repo neu auditieren, sondern mit dem bekannten Stand weiterarbeiten.
|
||||
|
||||
## Aktueller Stand
|
||||
|
||||
- Repo: `G:\Gitea_Clone\homelab-infra`
|
||||
- Remote: `https://git.kaleschke.info/Micha/homelab-infra.git`
|
||||
- Branch: `master`
|
||||
- Letzter bekannter Commit: `e0e12f1 Document stale Komodo webhook cleanup`
|
||||
- Unraid-Host: `ssh root@192.168.178.58`
|
||||
- Push-Befehl, der zuverlaessig funktioniert: `git -C "G:\Gitea_Clone\homelab-infra" push origin master`
|
||||
- Nicht anfassen ohne explizite Freigabe: untracked `Homelab_Audit_2026-05-05.pdf` und untracked `ops/hermes-agent/services.yaml`.
|
||||
|
||||
## Audit-Arbeit Erledigt
|
||||
|
||||
- K1: ungueltige Digests fuer Authelia, ntfy und borg-ui korrigiert und smoke-getestet.
|
||||
- K2: Authelia nutzt bewusst kein Redis; Doku entsprechend korrigiert.
|
||||
- K3/M1/M2 alt: Authelia Repo-Baseline geklaert, Homepage/Komodo ACL-Drift bereinigt.
|
||||
- M3a/M3b: Digest-Pinning fuer stateful/Tier-1 und weitere versionierte Apps umgesetzt; Redis-Caches bewusst ohne Digest, Nextcloud bewusst offen.
|
||||
- M5/N5: `.gitignore` eingefuehrt, Hermes `stack.env` zu `stack.env.example`.
|
||||
- M6/M7/M8: Hermes-Domain, Grafana/Influx `user: "0"` und Tailscale-Capabilities dokumentiert.
|
||||
- M9: Backup Scope / Restore Matrix erledigt.
|
||||
- N-Aufraeumen: alte Compose-`version:` Felder, leere Env-Beispiele und `.keep`-Platzhalter bereinigt.
|
||||
- Mail Archiver: `mail.kaleschke.info` liegt hinter `authelia@file,secure-headers@file`; Smoke-Test war 302 zu Authelia.
|
||||
- Hermes: Restore/DR-Doku ergaenzt.
|
||||
- Authelia SMTP: GMX SMTP eingerichtet, validiert, deployed und smoke-getestet.
|
||||
- M10: `KOMODO_WEBHOOK_SECRET` ist von `KOMODO_SECRET_KEY` getrennt.
|
||||
|
||||
## Wichtige Runtime-Details
|
||||
|
||||
### Authelia SMTP
|
||||
|
||||
- Adresse: `submission://mail.gmx.net:587`
|
||||
- Mailkonto: `michideheld@gmx.de`
|
||||
- SMTP-Passwort liegt nur auf dem Host: `/mnt/user/appdata/secrets/authelia_smtp_password.txt`
|
||||
- Host-Config wurde vor Umstellung gesichert: `/mnt/user/appdata/authelia/config/configuration.yml.bak-20260506-smtp`
|
||||
- Authelia-Compose nutzt explizite DNS-Server, weil der SMTP-Startup-Check externe Namen aufloesen muss.
|
||||
- Nach Deploy war `authelia` healthy; `auth.kaleschke.info` antwortete 200, geschuetzte Routen 302 zu Authelia.
|
||||
|
||||
### Komodo / M10
|
||||
|
||||
- Komodo-Runtime nur gemeinsam mit dem Betreiber aendern.
|
||||
- `KOMODO_SECRET_KEY` wurde nicht geaendert.
|
||||
- `KOMODO_WEBHOOK_SECRET` wurde geaendert und ist jetzt eigener 64-Zeichen-Wert.
|
||||
- Neuer Wert liegt nur auf dem Host in `/mnt/user/services/stacks/komodo/.env`.
|
||||
- Komodo Compose auf Host: `/mnt/user/services/stacks/komodo/compose.yaml`.
|
||||
- Backups vom M10-Sprint:
|
||||
- `/mnt/user/appdata/komodo/_m10_backup_20260506-184838`
|
||||
- `/mnt/user/services/gitea/data/gitea/_m10_backup_20260506-184838/gitea.db.bak`
|
||||
- `komodo-core` wurde gezielt recreated.
|
||||
- `komodo-mongo` wurde nicht neu gestartet.
|
||||
- `komodo-periphery` lief durch und meldete sich wieder am Core-Websocket an.
|
||||
- Gitea-Komodo-Webhooks: 29 aktive Hooks, 29 zuletzt erfolgreich, 0 aktiv fehlgeschlagen.
|
||||
- Ein stale Gitea-Webhook auf eine nicht mehr existierende Komodo-Stack-ID wurde deaktiviert, nicht geloescht.
|
||||
- Eine Warnung `request branch does not match expected` ist ein Branch-Filter-Skip, kein Secret-/Auth-Fehler.
|
||||
- Fuer neue Gitea-Webhooks im Standardfall den globalen `KOMODO_WEBHOOK_SECRET` aus der Komodo-Host-`.env` nutzen, ausser Komodo zeigt fuer den Stack explizit ein eigenes per-Stack-Secret.
|
||||
|
||||
## Sicherheitsregeln Fuer Weitere Arbeit
|
||||
|
||||
- Keine Secret-Werte im Chat oder Git ausgeben.
|
||||
- Bei Host-Pruefungen nur SET/MISSING, Laengen und Pfade zeigen.
|
||||
- Komodo-Compose, Komodo-Secrets und Komodo-Runtime nur bewusst und kleinschrittig aendern.
|
||||
- Bei jedem Deploy pro Stack smoke-testen; nicht mehrere kritische Stacks parallel veraendern.
|
||||
- Untracked Dateien nicht automatisch committen.
|
||||
- Bei Authelia-Aenderungen: Host-Config sichern, `authelia validate-config` ausfuehren, dann erst neu starten.
|
||||
- Bei Komodo-Aenderungen: Gitea-Webhooks und Komodo-Core-Secret-Seite zusammen betrachten.
|
||||
|
||||
## Naechste Sinnvolle Next-Level-Themen
|
||||
|
||||
- Grafana/Influx rootless betreiben statt `user: "0"`; eigener Sprint wegen Volume-Rechten.
|
||||
- Restore-Test fuer Vaultwarden und Paperless dokumentiert durchfuehren.
|
||||
- Komodo Periphery von Legacy-Passkey auf Public-Key-Modell haerten.
|
||||
- Monitoring/Alerting reifer machen: externe Alarme, Restore-Test-Reminder, Backup-Erfolg sichtbar.
|
||||
- Gitea/Komodo Webhook-Landschaft weiter aufraeumen und per-Stack-Secret-Strategie dokumentieren.
|
||||
- DR-Test fuer `backend_net`/externe Docker-Netze explizit aufnehmen.
|
||||
|
||||
## Startprompt Fuer Neuen Chat
|
||||
|
||||
Bitte zuerst `docs/AI_HANDOFF_2026-05-06.md` lesen und als aktuelle Arbeitsquelle verwenden. Nicht das ganze Repo neu auditieren, ausser ich fordere es an. Beachte besonders: Komodo nur gemeinsam und kleinschrittig aendern, keine Secret-Werte ausgeben, untracked PDF und `ops/hermes-agent/services.yaml` nicht anfassen. Wir starten jetzt mit Next-Level-Hardening.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Alerting Map
|
||||
|
||||
Stand: 2026-05-23
|
||||
|
||||
Ziel: Alle problemrelevanten Homelab-Meldungen landen auf einem Handy-Topic.
|
||||
|
||||
## ntfy Topics
|
||||
|
||||
| Topic | Zweck |
|
||||
|---|---|
|
||||
| `homelab-alerts` | Alles, was Aufmerksamkeit braucht: Prometheus, Docker-Events, Posture, Zertifikate/Token, Compose-Drift, Borg-Pre-Hook-Fehler und Restore-Fehler |
|
||||
| `homelab-info` | Optionale Erfolgsmeldungen, z. B. erfolgreiche Restore-Testlaeufe |
|
||||
|
||||
## Sender
|
||||
|
||||
| Sender | Pfad | Problem-Topic | Hinweis |
|
||||
|---|---|---|---|
|
||||
| Prometheus / Alertmanager | `monitoring/alertmanager/alertmanager.yml`, `monitoring/alertmanager-ntfy-bridge/bridge.py` | `homelab-alerts` | Zentrale Monitoring-Alerts via Bridge |
|
||||
| Posture Check | `services/posture-check/posture-check.sh` | `homelab-alerts` | Warning und Critical gehen auf dasselbe Handy-Topic |
|
||||
| Cert / Token Check | `services/posture-check/cert-token-check.sh` | `homelab-alerts` | Prueft produktive HTTPS-Domains und Cloudflare Token |
|
||||
| Compose Runtime Drift | `services/posture-check/compose-runtime-drift.sh` | `homelab-alerts` | Meldet Abweichungen zwischen Repo-Compose und Runtime-Image |
|
||||
| Docker Critical Events | `services/posture-check/docker-critical-events.sh` | `homelab-alerts` | Meldet Docker `die`, `oom` und `kill` Events |
|
||||
| Borg Pre-Hook | `ops/borg-ui/scripts/pre-borg.sh` | `homelab-alerts` | Meldet Fehler vor Borg, z. B. Posture-, Dump- oder Restore-Freshness-Fehler |
|
||||
| Restore Jobs | `ops/restore-tests/run-restore-job-with-ntfy.sh` | `homelab-alerts` | Erfolg geht an `homelab-info`, Fehler immer an `homelab-alerts` |
|
||||
|
||||
## Konvention
|
||||
|
||||
- `NTFY_BASE_URL` zeigt standardmaessig auf `https://ntfy.kaleschke.info`.
|
||||
- Neue Problem-Alerts sollen `homelab-alerts` nutzen.
|
||||
- Erfolgsmeldungen sind optional und sollen nicht in `homelab-alerts` landen, ausser sie sind bewusst als Lebenszeichen gewuenscht.
|
||||
- Blackbox-Endpoint-Alerts sollen bekannte WAN-/Provider-Sammelausfaelle zusammenfassen, damit kurze DSL-Reconnects keine ntfy-Flut pro Domain erzeugen.
|
||||
@@ -0,0 +1,369 @@
|
||||
# Homelab Audit - 2026-05-23
|
||||
|
||||
Stand: 2026-05-23, repo-basiert. Erstellt nach `docs/WORKFLOW.md` und `docs/GITOPS_DRIFT_RUNBOOK.md`. Quellebasis: `origin/master` plus lokaler Clone, ohne Schreibbefehle, ohne Deploy.
|
||||
|
||||
Dieser Audit ist eine punktuelle Sollzustands-Bewertung, kein Live-Status. Die Live-Verifikations-Schritte stehen am Ende in Abschnitt 9; alle dortigen Outputs ersetzen Vermutungen durch Messwerte.
|
||||
|
||||
## 0. Executive Summary
|
||||
|
||||
Ampel-Bewertung pro Bereich:
|
||||
|
||||
| Bereich | Ampel | Kernaussage |
|
||||
|---|---|---|
|
||||
| GitOps-Konsistenz (lokal/Gitea) | 🟡 | Lokaler Clone ist **1 Commit voraus** auf `master` (`cd650b1`, Haertungs-Commit). Bis zum Push existiert dieser Stand nur lokal, nicht in Gitea — bei einem Clone-Verlust ist er weg. |
|
||||
| GitOps-Konsistenz (Working Tree) | 🟢* | Keine echten Inhaltsaenderungen offen. Die 47 "modified files" aus `git status` im Linux-Mount sind voraussichtlich CRLF/LF-Mount-Artefakte (durch `git diff -w --stat` auf Stichprobe bestaetigt leer). Bitte am Windows-Host gegenpruefen. |
|
||||
| Hardening-Sprint (Mai 2026) | 🟢 | Alle vier Post-Restore-Sprint-Items sind im Repo umgesetzt (Filebrowser-Mounts, Authelia Argon2id, Gitea Webhook-Allowlist, Backup-Dump-Konsistenz). |
|
||||
| Backup/Restore-Readiness | 🟢 | `pre-backup-dumps.sh` deckt alle relevanten SQLite/PostgreSQL/Mongo-Quellen ab. Borg-UI-Scope umfasst `/mnt/user/services`, `homelab-infra`, `stacks`, `posture-check`. Live-Frische ist offen (Abschnitt 9). |
|
||||
| Monitoring-Migration | 🟡 | `monitoring/` Stack im Repo komplett, aber Live-Deploy laut `docs/NEXT_SPRINT_TODO_2026-05-16.md` noch ausstehend. Alte Stacks `ops/grafana-influxdb` und `ops/loki` sollen erst nach Live-Smoke-Test gestoppt werden. |
|
||||
| Doku-Drift Repo vs. Master-Doku | 🟠 | `apps/jellyfin/`, `host-services/plex/` und einige andere existieren produktiv als Compose-Stacks, sind aber in `HOMELAB_ARCHITECTURE_MASTER_V2.md`, `docs/SERVICE_CATALOG.md` und `docs/REPO_MAP.md` **nicht aufgefuehrt**. Authelia-ACL kennt `jellyfin.kaleschke.info` als bypass, im Masterdoku-Hostkatalog steht es nicht. |
|
||||
| Repo-Hygiene | 🟡 | 8 leere Verzeichnisse im Working Tree (siehe 4.3). `.serena/` ist untracked und nicht in `.gitignore`. Drei `ops/windows-reinstall/*.ps1` sind untracked. |
|
||||
| Bekannte Ausnahmen | 🟢 | Alle Ausnahmen aus `HOMELAB_ARCHITECTURE_MASTER_V2.md` Abschnitt 10 sind weiterhin dokumentiert und durch den Policy-Check abgedeckt (0 Critical, 4 Warnings, 9 Info – alles dokumentierte Ausnahmen). |
|
||||
|
||||
**Kernfazit:** Das Homelab ist sehr nah an der "Endstufe". Es gibt keine kritischen Befunde. Die einzige Pflichtaktion vor dem naechsten geplanten Schritt ist der **Push des lokalen Commits `cd650b1` nach Gitea**, damit `origin/master` wieder die Quelle der Wahrheit ist. Danach sind nur noch zwei priorisierte Pakete offen: Monitoring-Stack live finalisieren und Doku auf den Stand der neuen Stacks (Jellyfin/Plex/...) nachziehen.
|
||||
|
||||
---
|
||||
|
||||
## 1. Methodik und Quellen
|
||||
|
||||
Diese Audit-Quellen wurden gelesen (repo-seitig):
|
||||
|
||||
- `HOMELAB_ARCHITECTURE_MASTER_V2.md`
|
||||
- `docs/WORKFLOW.md`
|
||||
- `docs/REPO_MAP.md`
|
||||
- `docs/SERVICE_CATALOG.md`
|
||||
- `docs/RESTORE_MATRIX.md`
|
||||
- `docs/GITOPS_DRIFT_RUNBOOK.md`
|
||||
- `docs/NEXT_SPRINT_TODO_2026-05-16.md`
|
||||
- `ops/borg-ui/scripts/pre-backup-dumps.sh`
|
||||
- `ops/borg-ui/docker-compose.yml`
|
||||
- `ops/borg-ui/all-important-sources.txt`
|
||||
- `ops/filebrowser/docker-compose.yml`
|
||||
- `security/authelia/configuration.yml`
|
||||
- `core/gitea/docker-compose.yml`
|
||||
- `apps/jellyfin/docker-compose.yml`
|
||||
- `host-services/plex/docker-compose.yml`
|
||||
- `ops/policy-checks/last-report.md`
|
||||
|
||||
Schreibbefehle: keine. Deploys: keine. Containerlaufzeit, Komodo-Webhook-Status, Borg-Lauf-Frische und Host-Listener wurden bewusst nicht angetastet — dafuer steht der Live-Daten-Block in Abschnitt 9.
|
||||
|
||||
---
|
||||
|
||||
## 2. Schicht A — GitOps und Konsistenz
|
||||
|
||||
### 2.1 Lokaler Clone vs. `origin/master`
|
||||
|
||||
```
|
||||
## master...origin/master [ahead 1]
|
||||
HEAD = cd650b19ac057a1b74ac63503e5dba50eaf5b8ea
|
||||
origin/master = af231dd4e835b19005cc0842509199d480af00d9
|
||||
```
|
||||
|
||||
- **Befund:** Lokaler Clone ist 1 Commit voraus.
|
||||
- **Commit:** `cd650b1 Close Gitea signup, dedup posture-check alerts, extend Borg scope` (Sat May 23 11:01:24 2026 +0200).
|
||||
- **Inhalt** (laut Commit-Message und betroffenen Dateien):
|
||||
- Gitea: `DISABLE_REGISTRATION=true`, `ENABLE_OPENID_SIGNIN=false`, `ENABLE_OPENID_SIGNUP=false`
|
||||
- Repo-Pflicht-Doku ergaenzt: Komodo-Stack-Webhook-Pflicht in `CLAUDE.md`, `AI_CONTEXT.md`, `WORKFLOW.md`
|
||||
- `posture-check.sh`: Disk1-NTFS-Funktion ausgelagert, Inode-Check auf NTFS uebersprungen, ntfy-Dedup via Fingerprint-State + `ALERT_REPEAT_SECONDS`
|
||||
- `docker-critical-events.sh`: JSON-Parsing, `die exit=0` gefiltert, strukturierte ntfy-Message
|
||||
- `borg-ui`: `/mnt/user/services` als `/local/services:ro` gemountet, `all-important-sources.txt` ergaenzt
|
||||
- Unraid User Scripts dokumentiert (daily report)
|
||||
- `MIGRATION_LOG.md`, `RESTORE_MATRIX.md`, `DISASTER_RECOVERY.md` aktualisiert
|
||||
- **Risiko:** Bei Verlust des Windows-Clones (Reinstall, Diskcrash) ist dieser Stand verloren, weil er nicht in Gitea liegt. Komodo deployt ausserdem aus Gitea und kennt diese Aenderungen noch nicht.
|
||||
- **Empfohlene Aktion (Pflicht vor weiterer Arbeit):** In GitHub Desktop `Push origin` ausfuehren. Danach Komodo-Reaktion fuer die betroffenen Stacks (`gitea`, `borg-ui`) pruefen und Smoke-Tests laufen lassen.
|
||||
|
||||
### 2.2 Working-Tree-Status
|
||||
|
||||
```
|
||||
$ git status --short
|
||||
M CLAUDE.md
|
||||
M HOMELAB_ARCHITECTURE_MASTER_V2.md
|
||||
M apps/homepage/docker-compose.yml
|
||||
...
|
||||
(47 Dateien als modified gemeldet, plus 4 Untracked)
|
||||
```
|
||||
|
||||
- **Bewertung:** Die 47 "modified files" sind mit hoher Wahrscheinlichkeit **Mount-Artefakte** durch CRLF/LF zwischen Windows-Clone und Linux-Mount. Stichprobe `git diff -w --stat CLAUDE.md HOMELAB_ARCHITECTURE_MASTER_V2.md` lieferte leer — d. h. keine inhaltlichen Diffs.
|
||||
- **Aktion:** Bitte am Windows-Host in GitHub Desktop `git status --short` ausfuehren. Wenn dort der Tree leer ist (nur die 4 Untracked), gibt es keinen echten Working-Tree-Drift. Wenn dort echte Diffs erscheinen, hier bitte zurueckmelden — dann ist das ein eigener Befund.
|
||||
- **Optional (nicht Pflicht):** `.gitattributes` mit `* text=auto eol=lf` haerten, damit dieser Mount-Effekt fuer KI-Audits aus dem Weg geht. Das ist ein eigener kleiner Commit, kein Audit-Output.
|
||||
|
||||
### 2.3 Untracked Files
|
||||
|
||||
```
|
||||
?? .serena/
|
||||
?? ops/windows-reinstall/backup-delta-after-2026-05-07.ps1
|
||||
?? ops/windows-reinstall/cleanup-dualboot-bcd.ps1
|
||||
?? ops/windows-reinstall/repair-disk0-boot-to-new-windows.ps1
|
||||
```
|
||||
|
||||
- `.serena/` ist das Working-Directory des Serena Code-Search-Tools. Hat eigene `.gitignore` intern, aber das `.serena/`-Verzeichnis selbst ist nicht in der Repo-`.gitignore`.
|
||||
- **Aktion (klein):** `.serena/` in `.gitignore` aufnehmen, damit es nicht versehentlich committet wird.
|
||||
- Die drei PowerShell-Scripts unter `ops/windows-reinstall/` sind Windows-Reinstall-Helfer. Entscheidung offen: ins Repo aufnehmen (mit Kontextkommentar warum sie dort liegen) oder lokal halten und in `.gitignore` aufnehmen. Vorschlag: aufnehmen, weil `ops/` der dokumentierte Ort fuer Ops-Skripte ist.
|
||||
|
||||
### 2.4 Letzte Commit-Historie (Top 10)
|
||||
|
||||
```
|
||||
cd650b1 Close Gitea signup, dedup posture-check alerts, extend Borg scope [LOKAL, NICHT GEPUSHT]
|
||||
af231dd Fix zero-count noise pattern handling
|
||||
428223d Mark posture report scripts executable
|
||||
b6d3ed4 Tune homelab availability alerts
|
||||
9e7bebb Add daily operations report with hardened log-noise filtering
|
||||
b7cbbe5 Fix Jellyfin external DNS
|
||||
71ac18b Fix Jellyfin native auth routing
|
||||
90f270b Fix Jellyfin config permissions
|
||||
e28f8da Add Jellyfin media server stack
|
||||
edfec5b Add Plex media server stack
|
||||
```
|
||||
|
||||
- Die letzten Tage waren sichtbar: Jellyfin/Plex hinzugefuegt, Availability-Alerts feinjustiert, Posture-Check-Skripte produktiv gemacht, dann der grosse Haertungs-Commit gestern (2026-05-23 11:01).
|
||||
|
||||
### 2.5 Compose-Inventar vs. Doku
|
||||
|
||||
Repo hat folgende Compose-Stacks, die in den Doku-Quellen (`HOMELAB_ARCHITECTURE_MASTER_V2.md`, `docs/SERVICE_CATALOG.md`, `docs/REPO_MAP.md`) **nicht oder nur teilweise** aufgefuehrt sind:
|
||||
|
||||
| Stack | Status im Repo | Status in Master-Doku |
|
||||
|---|---|---|
|
||||
| `apps/jellyfin/docker-compose.yml` | produktiv vorhanden, gepinnt `jellyfin:10.11.8@sha256:...`, Traefik `jellyfin.kaleschke.info`, `secure-headers@file`, native Auth, `/mnt/user/media:ro` + `/mnt/user/photos:ro` | **fehlt** in 7.4 Apps; Authelia-ACL kennt aber bereits `jellyfin.kaleschke.info` als bypass — Doku hinkt hinterher |
|
||||
| `host-services/plex/docker-compose.yml` | produktiv vorhanden, gepinnt `plexinc/pms-docker:1.43.1.10611-1e34174b1@sha256:...`, `network_mode: host`, `/mnt/user/media:ro` + `/mnt/user/photos:ro` | Master-Doku sagt explizit "Plex-Media-Server ist historischer Host-Sonderfall, nicht als Repo-Compose-Stack enthalten" — **das stimmt nicht mehr**, Plex ist jetzt ein Repo-Compose-Stack |
|
||||
| `host-services/docker/` | leeres Verzeichnis | nicht erwaehnt |
|
||||
| `infra/dns/` | leeres Verzeichnis | nicht erwaehnt |
|
||||
| `ops/Semaphore/` | Skripten/Playbooks aber kein Compose | nicht erwaehnt |
|
||||
| `ops/backrest/` | leeres Verzeichnis (Stack laut Master-Doku am 2026-05-15 entfernt) | korrekt als entfernt dokumentiert; Verzeichnis sollte leer bleiben oder weg |
|
||||
| `apps/firefly/`, `apps/firefly-fints/` | leere Verzeichnisse | nicht erwaehnt |
|
||||
| `apps/stirling-pdf/` | leeres Verzeichnis (durch `bentopdf` abgeloest) | korrekt als abgeloest dokumentiert |
|
||||
|
||||
- **Aktion (Doku-Synchronisierung):** `HOMELAB_ARCHITECTURE_MASTER_V2.md` Abschnitt 7 (Container-Zielbild), `docs/SERVICE_CATALOG.md` und `docs/REPO_MAP.md` um Jellyfin und Plex erweitern. Plex-Doku im Master umschreiben: nicht mehr "historisch ausserhalb Repo", sondern "Compose-Stack mit `network_mode: host` als VPN-Discovery-Ausnahme".
|
||||
- **Aktion (Repo-Hygiene):** Die leeren Verzeichnisse `apps/firefly`, `apps/firefly-fints`, `apps/stirling-pdf`, `host-services/docker`, `infra/dns`, `ops/backrest`, `ops/grafana-influxdb/scripts`, `ops/Semaphore/playbooks`, `ops/Semaphore/Scripts` aufraeumen — Master-Doku sagt: "Leere `.keep`-Platzhalter wurden entfernt; neue Verzeichnisse sollen erst mit konkretem Inhalt ins Repo." Diese Verzeichnisse verletzen diese Regel passiv.
|
||||
|
||||
### 2.6 Image-Pinning
|
||||
|
||||
Lt. `docs/NEXT_SPRINT_TODO_2026-05-16.md` sind diese Stacks noch nicht voll versioniert gepinnt:
|
||||
- `ddns-updater` — `latest...@sha256`
|
||||
- `glances` — `latest-full@sha256`
|
||||
- `scrutiny` — `latest-omnibus@sha256`
|
||||
|
||||
Das ist bewusst dokumentiert und kein Audit-Befund.
|
||||
|
||||
---
|
||||
|
||||
## 3. Schicht B — Hardening-Sprint 2026-05 (Sitrep)
|
||||
|
||||
Dies war der Sprint, der nach dem 2026-05 Restore explizit gesetzt wurde. Stand im Repo:
|
||||
|
||||
| Sprint-Item | Stand 2026-05-16 (Plan) | Stand 2026-05-23 (Repo) | Beleg |
|
||||
|---|---|---|---|
|
||||
| **(1) Backup-Konsistenz** — `dump_sqlite_container` fuer Gitea/Vaultwarden/Uptime-Kuma/Speedtest/Filebrowser + `pg_dump` Nextcloud | offen | ✅ erledigt | `ops/borg-ui/scripts/pre-backup-dumps.sh` Z. 97–139 (`dump_sqlite_container`), Z. 253–258 (Nextcloud `pg_dump`), Z. 261–264 (alle SQLite-Container mit Host-Fallback), Z. 267 (Filebrowser BoltDB). Borg-Scope erweitert um `/mnt/user/services` (Borg-UI Compose Z. 26 + `all-important-sources.txt` Z. 23–25). |
|
||||
| **(2) Filebrowser entschaerfen** — `/mnt/user/appdata:/srv/appdata` weg, gezielte RW-Subpfade | offen | ✅ erledigt | `ops/filebrowser/docker-compose.yml` Z. 11–16. Keine Appdata-Mounts mehr. Nur noch `/mnt/user/documents`, `/mnt/user/photos`, `/mnt/user/projekte` als Datenmounts plus eigener `/database` und `/config`. |
|
||||
| **(3) Authelia Argon2id haerten** — iterations 3, memory 65536, parallelism 4 | offen | ✅ erledigt | `security/authelia/configuration.yml` Z. 17–25. Exakt die geplanten Parameter sind aktiv. |
|
||||
| **(4) Gitea Webhook-Allowlist** — `ALLOWED_HOST_LIST=*` einschraenken | offen | ✅ erledigt | `core/gitea/docker-compose.yml` Z. 18: `GITEA__webhook__ALLOWED_HOST_LIST=komodo-core,localhost,127.0.0.1,192.168.178.0/24`. Zusatz aus heutigem Commit: Public Registration und OpenID-Signup/Signin sind deaktiviert. |
|
||||
|
||||
Alle vier Items sind **im Repo abgeschlossen**. Live-Wirksamkeit haengt am Komodo-Deploy aus Gitea — und genau da haengt aktuell der ungepushte Commit `cd650b1` davor (siehe 2.1). Solange er nicht in Gitea ist, ist insbesondere die Gitea-Signup-Schliessung im Live-Stand nicht garantiert.
|
||||
|
||||
**Bewusst nicht angefasste Liste (Operator-Entscheidung 2026-05-16) ist weiterhin gueltig:**
|
||||
- Hermes — bleibt VM-seitig offen, NAS-Stack bewusst nicht starten
|
||||
- Disk1 NTFS — Phase-2-Migration nach Plan
|
||||
- Komodo native Auth ohne ForwardAuth
|
||||
- Grafana/Influxdb3-core `user: "0"`
|
||||
- Image-Pinning-Vereinheitlichung (`:latest@sha256:`) fuer ddns-updater/glances/scrutiny
|
||||
|
||||
---
|
||||
|
||||
## 4. Schicht C — "Endstufe?"-Bewertung
|
||||
|
||||
### 4.1 Backup/Restore-Readiness
|
||||
|
||||
- **Dump-Coverage:** `pre-backup-dumps.sh` deckt 14 Quellen ab: PostgreSQL-Globals + 3 Shared-DBs + 3 dedizierte Postgres (mealie, immich, nextcloud) + 4 SQLite (gitea, vaultwarden, uptime-kuma, speedtest-tracker) + Filebrowser-BoltDB + Borg-UI + Grafana + Komodo-Mongo. Deckt 1:1 die Restore-Matrix-Eintraege ab.
|
||||
- **Borg-Scope:** `all-important-sources.txt` enthaelt 27 Eintraege inkl. neuer `services/homelab-infra`, `services/stacks`, `services/posture-check` und `secrets`.
|
||||
- **Restore-Validierungen:** Laut `docs/RESTORE_MATRIX.md` sind am 2026-05-07 Mini-Restores fuer `gitea`, `vaultwarden` und `paperless` validiert worden — dokumentierter Stand.
|
||||
- **Live offen:** Wann lief der letzte Borg-Lauf? Sind alle Dumps unter `/mnt/user/backups/borg/dumps/latest` frischer als 24h? Siehe Live-Checkliste 9.4.
|
||||
|
||||
### 4.2 Monitoring-Migration
|
||||
|
||||
- Repo-Zielzustand `monitoring/docker-compose.yml` (337 Zeilen Compose) existiert mit Prometheus, Alertmanager, ntfy-Bridge, Blackbox-Exporter, Loki, Promtail, Grafana, node-exporter, cAdvisor, InfluxDB3 Core.
|
||||
- Provisioning unter `monitoring/grafana/provisioning/` und `monitoring/prometheus/`, `monitoring/loki/`, `monitoring/promtail/`, `monitoring/alertmanager/`, `monitoring/blackbox/` vollstaendig vorhanden.
|
||||
- Alte Stacks `ops/grafana-influxdb/` und `ops/loki/` bewusst noch im Repo (dokumentierter Altstand, Rollback-Referenz).
|
||||
- **Live offen:** Ist `monitoring` schon als Komodo-Stack deployed? Laufen die Container? Sind die Secret-Dateien `monitoring_grafana_admin_password.txt`, `monitoring_grafana_influxdb_token.txt`, `influxdb3_admin_token.json` auf dem Host? Siehe Live-Checkliste 9.5.
|
||||
|
||||
### 4.3 Repo-Hygiene
|
||||
|
||||
| Befund | Schwere | Aktion |
|
||||
|---|---|---|
|
||||
| 8 leere Verzeichnisse (`apps/firefly`, `apps/firefly-fints`, `apps/stirling-pdf`, `host-services/docker`, `infra/dns`, `ops/backrest`, `ops/grafana-influxdb/scripts`, `ops/Semaphore/playbooks`, `ops/Semaphore/Scripts`) | klein | Aufraeumen, danach committen |
|
||||
| `.serena/` untracked, nicht in `.gitignore` | klein | `.serena/` zu `.gitignore` hinzufuegen |
|
||||
| 3 `ops/windows-reinstall/*.ps1` untracked | klein | Entscheidung treffen: ins Repo oder ignorieren |
|
||||
|
||||
### 4.4 Bekannte dokumentierte Ausnahmen
|
||||
|
||||
Aus `HOMELAB_ARCHITECTURE_MASTER_V2.md` Abschnitt 10 — alle weiterhin gueltig und durch den Policy-Check abgedeckt (`ops/policy-checks/last-report.md` 0 Critical):
|
||||
|
||||
- Traefik 80/443
|
||||
- Tailscale Host-Netz + Capabilities
|
||||
- AdGuard Port 53 + 8082 (Admin-Port LAN-only, dokumentiert; **offener Punkt im Master:** "Traefik-Absicherung ausstehend (Block F)" — bewusst spaeter)
|
||||
- Plex Host-Netz (aber Master-Doku-Eintrag jetzt falsch, siehe 2.5)
|
||||
- Scrutiny `privileged: true`
|
||||
- Komodo Docker-Socket + keine pauschale Middleware
|
||||
- glance-docker-socket-proxy Read-only Socket
|
||||
- Gitea SSH 222
|
||||
- ddns-updater `frontend_net`
|
||||
- mail-archiver Hybrid-Netze
|
||||
- `traefik/dynamic/*` manueller Host-Sync
|
||||
- nextcloud native Auth
|
||||
- monitoring-influxdb3-core LAN 8181 + `user: "0"`
|
||||
- monitoring-promtail Docker-Socket read-only
|
||||
|
||||
Keine ungeplanten neuen Ausnahmen.
|
||||
|
||||
### 4.5 Endstufen-Definition
|
||||
|
||||
"Endstufe" ist erreicht, wenn alle folgenden Punkte gruen sind:
|
||||
|
||||
1. **Gitea = Quelle der Wahrheit** — kein lokaler Commit ohne Push 🟡 (heute: `cd650b1` ungepusht)
|
||||
2. **Hardening-Sprint im Repo abgeschlossen** 🟢
|
||||
3. **Backup-Konsistenz live verifiziert (Borg laeuft, Dumps frisch)** ❓ Live
|
||||
4. **Monitoring-Stack live, alte Altstaende gestoppt** 🟡
|
||||
5. **Doku synchron mit Repo (Jellyfin/Plex, leere Verzeichnisse, ...)** 🟠
|
||||
6. **Policy-Check 0 Critical** 🟢 (4 Warnings sind dokumentierte Ausnahmen)
|
||||
7. **Restore-Lab gepflegt (`mail-archiver` als naechste Uebung empfohlen)** 🟡 dokumentiert offen
|
||||
|
||||
Sechs der sieben Punkte sind in Reichweite ohne neue Architekturentscheidungen. Punkt 3 und 4 brauchen Live-Daten (Abschnitt 9). Punkt 1 ist 1 Push entfernt.
|
||||
|
||||
---
|
||||
|
||||
## 5. Priorisierte Restliste
|
||||
|
||||
| Prio | Aktion | Begruendung | Aufwand |
|
||||
|---|---|---|---|
|
||||
| **P0** | `cd650b1` nach Gitea pushen | GitOps-Quelle-der-Wahrheit, Voraussetzung fuer alles weitere | 30 Sekunden |
|
||||
| **P0** | Live-Daten aus Abschnitt 9 einholen | Ohne Live-Frische ist Endstufen-Bewertung unvollstaendig | 5 Minuten |
|
||||
| **P1** | Monitoring-Stack live finalisieren (Secrets pruefen, deployen, Smoke-Test, alte Altstaende stoppen) | Aus `docs/NEXT_SPRINT_TODO_2026-05-16.md` der naechste produktive Schritt | 1–2 Stunden mit Tests |
|
||||
| **P2** | Doku-Drift schliessen: Jellyfin und Plex in `HOMELAB_ARCHITECTURE_MASTER_V2.md` 7.4 + 7.1, `docs/SERVICE_CATALOG.md`, `docs/REPO_MAP.md` ergaenzen; Plex-Eintrag in Abschnitt 7.7 "noch offene Sonderfaelle" entfernen (ist umgesetzt) | Doku ist Source of Truth fuer KI-Audits und Nachfolge | 30 Minuten |
|
||||
| **P2** | Home Assistant -> InfluxDB final testen, HA-Dashboard in `monitoring-grafana` anlegen | aus NEXT_SPRINT_TODO | 1–2 Stunden |
|
||||
| **P3** | Repo-Hygiene: 8 leere Verzeichnisse loeschen, `.serena/` in `.gitignore`, Entscheidung zu `ops/windows-reinstall/*.ps1` | minor, aber dokumentiert | 15 Minuten |
|
||||
| **P3** | Naechster Restore-Lab-Lauf: `mail-archiver` (empfohlen in `RESTORE_MATRIX.md`) | Restore-Routine ueben, bevor sie gebraucht wird | 1 Stunde |
|
||||
| **P4** | `.gitattributes` mit `* text=auto eol=lf` hinzufuegen, um CRLF/LF-Mount-Effekte bei KI-Audits zu vermeiden | klein, kosmetisch fuer kuenftige Audits | 5 Minuten |
|
||||
| **bleibt** | Hermes VM-Seite, Disk1-NTFS Phase 2, AdGuard Admin-Port hinter Traefik (Block F), Image-Pinning ddns/glances/scrutiny | bewusste Operator-Entscheidung, kein Audit-Beduerfnis | nicht jetzt |
|
||||
|
||||
---
|
||||
|
||||
## 6. Was bewusst NICHT angetastet wurde (Audit-Verzicht)
|
||||
|
||||
Konsistent mit der bekannten Nicht-Anfassen-Liste:
|
||||
|
||||
- Hermes (VM-seitig offen)
|
||||
- Disk1 NTFS (Phase-2-Migration nach Plan)
|
||||
- Komodo native Auth ohne ForwardAuth
|
||||
- Grafana / influxdb3-core `user: "0"` Uebergangsausnahme
|
||||
- Image-Pinning-Vereinheitlichung fuer ddns-updater/glances/scrutiny
|
||||
|
||||
---
|
||||
|
||||
## 7. Risiken und Drift-Indikatoren
|
||||
|
||||
| Risiko | Wahrscheinlichkeit | Wirkung | Migitation |
|
||||
|---|---|---|---|
|
||||
| Lokaler Clone-Verlust (Disk, Reinstall) bevor `cd650b1` gepusht wurde | gering, aber real (Du bist im Reinstall-Kontext, siehe `ops/windows-reinstall/`!) | Verlust von Gitea-Signup-Closure und Posture-Check-Verbesserungen | **Sofort pushen** |
|
||||
| Komodo deployt aus Gitea, `gitea` und `borg-ui` laufen aktuell ohne die heutigen Verbesserungen | mittel | Gitea Signup steht noch offen, Borg-Scope umfasst `/mnt/user/services` noch nicht | Push + Komodo-Reaktion pruefen |
|
||||
| 47 vermeintlich modified files koennten doch echte Diffs sein, wenn der Windows-Host etwas anderes zeigt | gering | falsche Audit-Aussage | Punkt 9.1 auf dem Windows-Host pruefen |
|
||||
| Doku-Drift wird groesser, wenn weitere Stacks ohne Doku-Update hinzukommen | mittel | KI-Audits und Onboarding leiden | P2-Doku-Sync nicht aufschieben |
|
||||
| Monitoring-Stack-Migration unfertig, alter und neuer Stack koennten parallel werden | mittel | Doppelte Metric-/Log-Pipeline, Verwirrung bei Diagnose | Live-Status klaeren bevor Deploy |
|
||||
|
||||
---
|
||||
|
||||
## 8. Sources of Truth — Schnellzugriff
|
||||
|
||||
- Operative Quelle der Wahrheit: Gitea `origin/master` (https://git.kaleschke.info/Micha/homelab-infra)
|
||||
- Architektur-Master: `HOMELAB_ARCHITECTURE_MASTER_V2.md`
|
||||
- Workflow / GitOps-Regeln: `docs/WORKFLOW.md`
|
||||
- Drift-Runbook: `docs/GITOPS_DRIFT_RUNBOOK.md`
|
||||
- Restore-Quellen: `docs/RESTORE_MATRIX.md`, `docs/DISASTER_RECOVERY.md`
|
||||
- Letzter Policy-Check: `ops/policy-checks/last-report.md` (0 Critical)
|
||||
- Letzte Sprint-Restliste: `docs/NEXT_SPRINT_TODO_2026-05-16.md`
|
||||
|
||||
---
|
||||
|
||||
## 9. Live-Daten-Checkliste — bitte ausfuehren und zurueckspielen
|
||||
|
||||
Fuehre die folgenden Bloecke am Unraid-Host (per SSH oder Web-Terminal) und am Windows-Host (Git Bash / PowerShell in `G:\Gitea_Clone\homelab-infra`) aus und pastiere die Outputs zurueck. Ich integriere sie dann in diesen Report.
|
||||
|
||||
### 9.1 Windows-Host: Echter Working-Tree-Status
|
||||
|
||||
```powershell
|
||||
cd G:\Gitea_Clone\homelab-infra
|
||||
git status --short
|
||||
git log origin/master..HEAD --oneline
|
||||
```
|
||||
|
||||
Erwartet: Working tree leer (oder nur die 4 Untracked). `cd650b1` als einziger lokaler Commit.
|
||||
|
||||
### 9.2 Unraid-Host: Gitea online
|
||||
|
||||
```bash
|
||||
curl -sI --max-time 5 https://git.kaleschke.info/ | head -5
|
||||
docker exec gitea sh -lc 'gitea --version'
|
||||
```
|
||||
|
||||
Erwartet: `HTTP/2 200` (oder ein Auth-Code, der den Erreichbarkeitstest erfuellt). Gitea-Version stimmt mit Image-Tag `1.25.4` ueberein.
|
||||
|
||||
### 9.3 Unraid-Host: Komodo-Webhook-Status
|
||||
|
||||
In Komodo UI fuer jeden produktiven Stack aus `Micha/homelab-infra` pruefen:
|
||||
- `webhook_enabled: true`
|
||||
- Gitea-Hook auf `http://komodo-core:9120/listener/github/stack/<stack-id>/deploy` aktiv
|
||||
- `last_status` der letzten Webhook-Delivery in Gitea (Repository -> Settings -> Webhooks)
|
||||
|
||||
Pflicht-Stacks zum Pruefen: `traefik`, `gitea`, `authelia`, `vaultwarden`, `postgresql17`, `redis`, `paperless-ngx`, `immich`, `nextcloud`, `mealie`, `mail-archiver`, `ntfy`, `homepage`, `paperless-gpt`, `borg-ui`, `filebrowser`, `code-server`, `uptime-kuma`, `glance`, `glances`, `scrutiny`, `speedtest-tracker`, `bentopdf`, `ddns-updater`, `komodo`, `jellyfin`, `plex`, `adguard`, `tailscale`, `monitoring`, `hermes-agent` (sofern produktiv).
|
||||
|
||||
Bei Stacks **ohne** aktiven Webhook bitte den Grund vermerken (dokumentierte Ausnahme oder Nachholbedarf).
|
||||
|
||||
### 9.4 Unraid-Host: Borg-Lauf-Frische und Dump-Coverage
|
||||
|
||||
```bash
|
||||
ls -lah /mnt/user/backups/borg/dumps/latest/
|
||||
stat -c '%y %n' /mnt/user/backups/borg/dumps/latest/*.dump /mnt/user/backups/borg/dumps/latest/*.sql /mnt/user/backups/borg/dumps/latest/*.archive.gz /mnt/user/backups/borg/dumps/latest/*.sqlite 2>/dev/null | sort
|
||||
docker exec borg-ui borg list --short 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Erwartet: Alle 14 Artefakte aus 4.1 sind vorhanden, mtime juenger als 24h. Borg-Archive-Liste zeigt regelmaessige Laeufe.
|
||||
|
||||
### 9.5 Unraid-Host: Monitoring-Stack live?
|
||||
|
||||
```bash
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep monitoring
|
||||
ls -la /mnt/user/appdata/secrets/ | grep -E 'monitoring|influxdb3'
|
||||
curl -sI --max-time 5 https://monitoring.kaleschke.info/ | head -5
|
||||
ss -ltnp 2>/dev/null | grep -E ':8181|:9090|:3100' | head -10
|
||||
```
|
||||
|
||||
Erwartet: Entweder alle `monitoring-*` Container laufen (dann ist 4.2 🟢) oder gar nicht (dann ist 4.2 🟡 wie aktuell bewertet). Halb-Zustand ist ein Audit-Befund.
|
||||
|
||||
### 9.6 Unraid-Host: GitOps-Pflichtmatrix Spot-Check fuer einen Stack
|
||||
|
||||
Beispiel `gitea` (weil der heutige Commit ihn betrifft):
|
||||
|
||||
```bash
|
||||
cd /mnt/user/services/stacks/gitea
|
||||
git rev-parse --short HEAD
|
||||
git status -sb
|
||||
docker inspect gitea --format '{{.Image}}'
|
||||
docker exec gitea sh -lc 'env | grep -E "ALLOWED_HOST_LIST|DISABLE_REGISTRATION|ENABLE_OPENID"'
|
||||
```
|
||||
|
||||
Erwartet: Komodo-Workspace `HEAD` zeigt entweder auf `cd650b1` (wenn Push schon erfolgt + Komodo deployed) oder auf `af231dd` (vor dem Push). ENV-Vars in der Live-Runtime spiegeln den Commit, der Komodo zuletzt deployed hat.
|
||||
|
||||
### 9.7 Unraid-Host: Host-Listener-Spot-Check
|
||||
|
||||
```bash
|
||||
ss -ltnp 2>/dev/null | grep -E ':80|:443|:53|:222|:8082|:8181' | sort
|
||||
```
|
||||
|
||||
Erwartet exakt die dokumentierten Ausnahmen aus `HOMELAB_ARCHITECTURE_MASTER_V2.md` Abschnitt 10. Andere Listener = Befund.
|
||||
|
||||
---
|
||||
|
||||
## 10. Nachhalte-Vorschlag
|
||||
|
||||
Wenn Du moechtest, halte ich diesen Audit in zwei Schritten zu Ende:
|
||||
|
||||
1. Du fuehrst Abschnitt 9 aus und pastierst die Outputs zurueck.
|
||||
2. Ich aktualisiere diesen Report mit den Live-Ergebnissen, ergaenze die Ampel und schliesse die offenen 🟡/🟠 Punkte oder benenne sie als echte Restliste.
|
||||
|
||||
Bis dahin gilt der Stand dieses Reports als Repo-Audit, nicht als Endstufen-Zertifikat.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Homelab Audit Final - 2026-05-23
|
||||
|
||||
Stand: 2026-05-25 07:33 CEST. Ergebnis nach Push, Live-Messung, Doku-Sync, Repo-Hygiene und erneuter Live-Nachmessung.
|
||||
|
||||
| Punkt | Ampel | Beleg |
|
||||
|---|---|---|
|
||||
| P0 `cd650b1` nach Gitea pushen | gruen | Push `af231dd..cd650b1 master -> master`; produktiver Runtime-Stand `66ee10c` enthaelt `cd650b1`. Die abschliessenden Audit-Doku-Commits liegen in Gitea; der runtime-relevante Stack-Inhalt fuer `gitea`, `borg-ui` und `monitoring` ist seit `66ee10c` unveraendert. |
|
||||
| P0 Live-Daten ablegen | gruen | `docs/AUDIT_2026-05-23_LIVE.md` angelegt, keine Secret-Werte dokumentiert. |
|
||||
| P1 Monitoring live / Altstaende down | gruen | 10 `monitoring-*` Container laufen, `0` unhealthy, `0` starting, `0` stopped; alte `grafana`/`influxdb3-core`/`loki`/`alloy` Container sind nicht vorhanden. `monitoring.kaleschke.info` liefert 302 zu Authelia, Prometheus ist bereit, Loki `/ready` liefert `ready`. |
|
||||
| P1 Jellyfin/Plex Doku | gruen | `HOMELAB_ARCHITECTURE_MASTER_V2.md`, `docs/SERVICE_CATALOG.md` und `docs/REPO_MAP.md` ergaenzt; Plex ist als Repo-Compose-Stack dokumentiert, nicht mehr als nicht migrierter Dockerman-Sonderfall. |
|
||||
| P2 Borg-Frische | gruen | Borg-UI DB: letzter Backup-Job `completed`, Archiv `Taegliche-Sicherung-2026-05-25T05:52:44.157`, `nfiles=100221`; 15 kanonische Dump-/Archive-Artefakte von 2026-05-25 06:09/06:10 CEST und damit juenger als 24 h. |
|
||||
| P3 Repo-Hygiene | gruen | `.serena/` in `.gitignore`; leere Verzeichnisse entfernt; `ops/windows-reinstall/*.ps1` bewusst ins Repo aufgenommen. |
|
||||
| Policy-Check | gruen | `ops/policy-checks/check_repo.ps1`: 0 Critical, dokumentierte Warnings zu Plex Host-Netz, InfluxDB/Grafana `user: "0"` und bekannten mutable-tag-Ausnahmen. |
|
||||
|
||||
## Bewusst offen
|
||||
|
||||
- Keine offenen Punkte aus der Audit-Restliste.
|
||||
- Nicht Teil des Audits: Nach Disk1 Phase 2 laeuft seit 2026-05-25 eine nicht korrigierende Parity-Pruefung (`NOCORRECT`) im Hintergrund; Zwischenstand der Nachmessung: `mdResyncAction=check P`, `mdResyncCorr=0`.
|
||||
|
||||
## Schlussbewertung
|
||||
|
||||
Das Homelab ist fuer die Audit-Restliste in der Endstufe. Es gibt keine kritischen Repo-, GitOps- oder Live-Befunde aus diesem Audit. Monitoring ist produktiv und ready, alte Altstaende sind down, Backups und Dumps sind frisch.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Homelab Audit Live-Daten - 2026-05-23
|
||||
|
||||
Stand: 2026-05-23 11:27 CEST. Quelle: lokaler Windows-Clone und SSH auf `Kallilabcore`. Secret-Werte wurden nicht ausgelesen oder redaktiert; dokumentiert sind nur Status, Dateinamen, Modi, Env-Key-Namen und nicht geheime Bool-/Host-Werte.
|
||||
|
||||
## 9.1 Windows-Host / Git
|
||||
|
||||
- `git status --short` nach dem initialen Push: keine tracked Modifikationen, untracked waren `.serena/`, `docs/AUDIT_2026-05-23.md`, `docs/CODEX_ENDSTUFE_PROMPT_2026-05-23.md` und drei `ops/windows-reinstall/*.ps1`.
|
||||
- `cd650b1` wurde nach `origin/master` gepusht: `af231dd..cd650b1 master -> master`.
|
||||
|
||||
## 9.2 Gitea online
|
||||
|
||||
- `curl -sI https://git.kaleschke.info/`: `HTTP/2 200`.
|
||||
- `docker exec gitea gitea --version`: `1.25.4`.
|
||||
- Signup-Smoke: `/user/sign_up` meldet Registration disabled.
|
||||
|
||||
## 9.3 Komodo / Workspace-Reaktion
|
||||
|
||||
| Stack | Workspace HEAD | Status | Live-Beleg |
|
||||
|---|---:|---|---|
|
||||
| `gitea` | `cd650b1` | `## master...origin/master` | Container neu gestartet, Env-Keys aktiv |
|
||||
| `borg-ui` | `cd650b1` | `## master...origin/master` | Container healthy, `/mnt/user/services -> /local/services` gemountet |
|
||||
| `monitoring` | `cd650b1` | `## master...origin/master`, untracked Host-Backup `monitoring/prometheus/alerts.yml.bak-20260520-incident` | Stack laeuft seit 4 Tagen |
|
||||
|
||||
Gitea Live-Env ohne Secrets:
|
||||
|
||||
```text
|
||||
GITEA__service__DISABLE_REGISTRATION=true
|
||||
GITEA__openid__ENABLE_OPENID_SIGNIN=false
|
||||
GITEA__openid__ENABLE_OPENID_SIGNUP=false
|
||||
GITEA__webhook__ALLOWED_HOST_LIST=komodo-core,localhost,127.0.0.1,192.168.178.0/24
|
||||
```
|
||||
|
||||
## 9.4 Borg-Lauf-Frische und Dump-Coverage
|
||||
|
||||
- Borg UI DB: Repository `appdata-critical`, `last_backup=2026-05-23 02:30:12 UTC`, `archive_count=30`, letzter Job `completed`, `nfiles=100056`.
|
||||
- Schedule: `Taegliche Sicherung` enabled, letzter Lauf `2026-05-23 02:30:11 UTC`, naechster Lauf `2026-05-24 02:30:00 UTC`.
|
||||
- Hinweis: `docker exec borg-ui borg list --short` funktioniert ohne Repository-Parameter/Env nicht (`Invalid location format: ""`). Der Borg-UI-Status wurde deshalb ueber die lokale Borg-UI-Datenbank ohne Secret-Spalten ausgewertet.
|
||||
|
||||
Frische Artefakte in `/mnt/user/backups/borg/dumps/latest`:
|
||||
|
||||
```text
|
||||
2026-05-23 04:00 postgresql17-globals.sql
|
||||
2026-05-23 04:00 postgresql17-mailarchiver.dump
|
||||
2026-05-23 04:00 postgresql17-paperless.dump
|
||||
2026-05-23 04:01 postgresql17-authelia.dump
|
||||
2026-05-23 04:01 mealie.dump
|
||||
2026-05-23 04:01 gitea.sqlite.dump
|
||||
2026-05-23 04:01 immich.dump
|
||||
2026-05-23 04:01 nextcloud.dump
|
||||
2026-05-23 04:01 uptime-kuma.sqlite.dump
|
||||
2026-05-23 04:01 vaultwarden.sqlite.dump
|
||||
2026-05-23 04:01 speedtest-tracker.sqlite.dump
|
||||
2026-05-23 04:01 filebrowser.bolt.dump
|
||||
2026-05-23 04:01 borg-ui.sqlite
|
||||
2026-05-23 04:01 grafana.sqlite
|
||||
2026-05-23 04:01 komodo-mongo.archive.gz
|
||||
```
|
||||
|
||||
Bewertung: 15 aktuelle Dump-/Archive-Artefakte sind juenger als 24 h. Aeltere nackte `.sqlite`-Dateien vom 2026-05-16 liegen noch im Verzeichnis, sind aber Legacy-Kopien ohne `.dump`-Suffix und nicht Teil der aktuellen Dump-Serie.
|
||||
|
||||
## 9.5 Monitoring-Stack
|
||||
|
||||
- Aktive Container: `monitoring-grafana`, `monitoring-promtail`, `monitoring-prometheus`, `monitoring-cadvisor`, `monitoring-influxdb3-core`, `monitoring-loki`, `monitoring-blackbox-exporter`, `monitoring-alertmanager-ntfy-bridge`, `monitoring-alertmanager`, `monitoring-node-exporter`.
|
||||
- Alte Altcontainer `grafana`, `influxdb3-core`, `loki`, `alloy`: nicht vorhanden in `docker ps -a`.
|
||||
- Secret-Dateien vorhanden und mode `600`: `monitoring_grafana_admin_password.txt`, `monitoring_grafana_influxdb_token.txt`, `influxdb3_admin_token.json`.
|
||||
- `https://monitoring.kaleschke.info/`: `HTTP/2 302` zu Authelia, wie erwartet.
|
||||
- Host-Listener fuer Monitoring: nur `127.0.0.1:8181`; keine Host-Listener fuer `:9090` oder `:3100`.
|
||||
- Prometheus readiness: `Prometheus Server is Ready.`
|
||||
- Grafana health: `database=ok`, Version `12.4.3`.
|
||||
- Loki: Container laeuft und API/metrics liefert `loki_build_info`; `/ready` liefert aktuell `503 Service Unavailable`, waehrend Query-/Metrics-Endpunkte 200-Zaehler zeigen. Kein Reparaturversuch gestartet, weil der produktive Logpfad nachweislich Daten annimmt und die Stop-Regel keine blinden Eingriffe erlaubt.
|
||||
|
||||
## 9.6 GitOps Spot-Check Gitea
|
||||
|
||||
- `/mnt/user/services/stacks/gitea`: `cd650b1`, `## master...origin/master`.
|
||||
- Docker-Image: `docker.gitea.com/gitea:1.25.4`.
|
||||
- Live-ENV spiegelt `cd650b1` fuer Registrierung, OpenID und Webhook-Allowlist.
|
||||
|
||||
## 9.7 Host-Listener
|
||||
|
||||
Dokumentierte Listener gefunden:
|
||||
|
||||
- `:80`, `:443` Traefik
|
||||
- `:53` AdGuard DNS
|
||||
- `:222` Gitea SSH
|
||||
- `:8082` AdGuard Admin
|
||||
- `127.0.0.1:8181` Monitoring InfluxDB
|
||||
|
||||
Zusaetzlich sichtbar: mehrere `wsdd2` Listener auf `:5355` je Interface. Das ist Host-/Unraid-Service-Discovery, kein Compose-Webdienst und kein GitOps-Stack-Port.
|
||||
@@ -0,0 +1,895 @@
|
||||
# Homelab-Audit KalliLab CORE
|
||||
|
||||
Stand: 2026-05-25
|
||||
Methode: Repo-basierter Audit auf `master` (lokaler Clone). Keine Live-Messung gegen den Host. Querverweise auf Audit-Live-Daten aus `docs/AUDIT_2026-05-23_LIVE.md`, wo verfuegbar.
|
||||
Auftrag: externer, kritischer Audit-Blick zusaetzlich zur internen `docs/STRATEGISCHE_BEWERTUNG_2026-05-23.md`.
|
||||
|
||||
## Wichtige Vorbemerkung
|
||||
|
||||
Es gibt seit dem 23.05. eine fundierte interne Bewertung (`docs/STRATEGISCHE_BEWERTUNG_2026-05-23.md`) und eine konsolidierte Hausaufgabenliste (`docs/CODEX_KONSOLIDIERUNG_2026-05-23.md`). Davon wurden seit dem 25.05. bereits umgesetzt:
|
||||
|
||||
- Jellyfin entfernt (MASTER 7.8)
|
||||
- Homepage entfernt (MASTER 7.8)
|
||||
- Uptime-Kuma entfernt (MASTER 7.8, SECRETS_MAP 29)
|
||||
- Monitoring-Stack produktiv (AUDIT_FINAL 9), Altstaende-Container down
|
||||
- Disk1 NTFS -> XFS Phase 2 abgeschlossen am 2026-05-25 (STORAGE_LAYOUT 3)
|
||||
- Unraid-Flash-Backup live (`unraid-flash-config.tar.gz` im Borg-Lauf)
|
||||
- GitHub-Push-Mirror `michaelkaleschke-spec/homelab-infra` aktiv (DR 10, MASTER 7.1)
|
||||
|
||||
Diese geloesten Punkte werden hier nicht wiederholt. Dieser Audit konzentriert sich auf das, was nach dem 23.05.-Sprint **noch offen ist** und auf das, was die strategische Bewertung **nicht oder nur kurz angerissen** hat.
|
||||
|
||||
---
|
||||
|
||||
# Phase 1: Repo-Inventar
|
||||
|
||||
## Ordnerstruktur (Ist-Zustand)
|
||||
|
||||
```
|
||||
homelab-infra/
|
||||
├── apps/ 9 Stacks (bentopdf, immich, mail-archiver, mealie, nextcloud, ntfy, paperless, paperless-gpt, unbound)
|
||||
├── core/ 1 Stack (gitea)
|
||||
├── docs/ 28 Markdown-Dokumente
|
||||
├── env/ 2 *.example
|
||||
├── host-services/ 3 Stacks (Adguard, plex, tailscale)
|
||||
├── infra/ 3 Stacks (ddns-updater, postgresql17, redis)
|
||||
├── monitoring/ 1 Compose mit 10 Services + Provisioning
|
||||
├── ops/ 17 Verzeichnisse (Semaphore, borg-ui, code-server, filebrowser, glance, glances, grafana-influxdb [Altstand], hermes-agent, komodo, loki [Altstand], policy-checks, restore-tests, scrutiny, speedtest, uptime-kuma [Altrest], windows-reinstall)
|
||||
├── security/ 2 Stacks (authelia, vaultwarden)
|
||||
├── services/ 1 posture-check (Host-Skripte)
|
||||
└── traefik/ 1 Compose + dynamic/ (3 Files)
|
||||
```
|
||||
|
||||
**Inventar-Befund:**
|
||||
|
||||
- ~30 Compose-Dateien, 1 zentraler Compose-Multi-Service (`monitoring/`).
|
||||
- 29 Composes wurden vom Policy-Checker validiert (`ops/policy-checks/last-report.md`): **0 Critical, 4 Warnings, 9 Info**.
|
||||
- Doku-Dichte ist hoch (REPO_MAP, SERVICE_CATALOG, RESTORE_MATRIX, DISASTER_RECOVERY, SECRETS_MAP, WORKFLOW, STORAGE_LAYOUT, GITOPS_DRIFT_RUNBOOK, ALERTING_MAP).
|
||||
- Restore-Tests sind als echte Scripts versioniert (`ops/restore-tests/`). Ueberdurchschnittlich.
|
||||
|
||||
## Gut dokumentierte Bereiche (Belegt)
|
||||
|
||||
| Bereich | Quelle |
|
||||
|---|---|
|
||||
| Architektur, Netze, Ausnahmen | `HOMELAB_ARCHITECTURE_MASTER_V2.md` |
|
||||
| GitOps-Workflow, Drift | `docs/WORKFLOW.md`, `docs/GITOPS_DRIFT_RUNBOOK.md` |
|
||||
| Backup-Scope, Restore-Wege, Tier-Modell | `docs/RESTORE_MATRIX.md`, `docs/DISASTER_RECOVERY.md`, `ops/borg-ui/BACKUP_SCOPE.md` |
|
||||
| Storage / Cache-Policy / FS / Posture | `docs/STORAGE_LAYOUT.draft.md` |
|
||||
| Secret-Inventur | `docs/SECRETS_MAP.md` |
|
||||
| Alert-Pfade | `docs/ALERTING_MAP.md` |
|
||||
|
||||
## Luecken / Unklar (vermutet bzw. Rueckfrage noetig)
|
||||
|
||||
| Luecke | Warum es ein Audit-Loch ist |
|
||||
|---|---|
|
||||
| `docs/STORAGE_LAYOUT.draft.md` ist `Draft 1.3`, nicht `Active` | Mehrere Hard Rules (12 Constitution) gelten formal noch nicht. Hard Rule 11 (kein Stack ohne Restore-Pfad in RESTORE_MATRIX) wird heute schon eingehalten — also nur Formal-Luecke. |
|
||||
| `docs/SERVICES_RECOVERY.md` ist als verbindlich angekuendigt (STORAGE_LAYOUT 4), aber nicht im Repo | Konkrete Mirror-Mechanik fuer `services/gitea/git/repositories/` ≤ 6 h ist nirgends spezifiziert. |
|
||||
| Hardware-Inventar: kein zentrales Dokument | Keine Stelle im Repo nennt CPU-Modell, RAM-Groesse, NIC-Speed, Mainboard, Parity-Disk-Groessen — nur "Samsung 970 EVO Plus 2 TB" steht in STORAGE_LAYOUT 3. |
|
||||
| USV: keine Erwaehnung | Keine Datei nennt eine USV. Unklar, ob vorhanden. |
|
||||
| Familien-/User-Onboarding-Doku | Keine Doku, die deine Frau/Kinder lesen muessten ("So loggst du dich in Nextcloud ein"). Aktuell ist alles Operator-Doku. |
|
||||
|
||||
## Fuer den Audit besonders wichtige Dateien (verwendet)
|
||||
|
||||
- `HOMELAB_ARCHITECTURE_MASTER_V2.md` — komplett
|
||||
- `docs/WORKFLOW.md`, `docs/REPO_MAP.md`, `docs/SERVICE_CATALOG.md` — komplett
|
||||
- `docs/DISASTER_RECOVERY.md`, `docs/RESTORE_MATRIX.md`, `docs/SECRETS_MAP.md` — komplett
|
||||
- `docs/STORAGE_LAYOUT.draft.md`, `docs/STRATEGISCHE_BEWERTUNG_2026-05-23.md` — komplett
|
||||
- `docs/AUDIT_2026-05-23_LIVE.md`, `docs/AUDIT_2026-05-23_FINAL.md`
|
||||
- `ops/policy-checks/last-report.md`
|
||||
- `monitoring/docker-compose.yml`, `monitoring/prometheus/alerts.yml`
|
||||
- `traefik/docker-compose.yml`, `traefik/dynamic/middlewares.yml`
|
||||
- `security/authelia/configuration.yml`, `security/authelia/docker-compose.yml`
|
||||
- `apps/paperless/docker-compose.yml`, `apps/immich/docker-compose.yml`, `apps/nextcloud/docker-compose.yml`
|
||||
- `host-services/Adguard/docker-compose.yml`
|
||||
|
||||
---
|
||||
|
||||
# A. Executive Summary
|
||||
|
||||
**Was schon stark ist:**
|
||||
|
||||
- GitOps-Disziplin: Gitea als Sollzustand, Komodo als Consumer, dokumentierter Drift-Runbook, Stop-Regel ("zwei Fehlversuche -> Pflichtmatrix"). Seltene Reife.
|
||||
- Backup-Architektur: Pre-Backup-Dumps + Borg + Restore-Tests mit echtem Smoke-Test-Kriterium ("Container startet ≠ Erfolg"). 15 frische Dumps < 24 h alt (`AUDIT_LIVE 9.4`).
|
||||
- Architektur-Klarheit: `frontend_net` / `backend_net` / app-interne Netze, keine Sammelnetze, dokumentierte Ausnahmen.
|
||||
- Image-Pinning: Tier-1-Stateful mit `<minor>@sha256:...`. Konsequent durchgezogen.
|
||||
- Secrets-Hygiene: Keine Secret-Werte im Repo, `_FILE`-Mounts + Komodo Stack ENV, explizit dokumentierte Ausnahmen.
|
||||
- Policy-as-Code: `check_repo.ps1` mit 0 Critical und sauber dokumentierten Exceptions.
|
||||
|
||||
**Was kritisch ist:**
|
||||
|
||||
- **AdGuard Admin-Port 8082 ohne Authelia/2FA am LAN gebunden** (`host-services/Adguard/docker-compose.yml:16`) — dokumentierte "Operator-Entscheidung" 2026-05-25. Im Heim-LAN tolerierbar, mit IoT/Gaeste-WLAN potenziell ein Pfad zur DNS-Manipulation. Niedrigster Aufwand: Bind nur auf Tailscale-Interface.
|
||||
- **Authelia ACL: 2FA nur fuer `files.kaleschke.info` und `scrutiny.kaleschke.info`** (`security/authelia/configuration.yml:44-48`). Borg-UI, Code-Server, Filebrowser, Glance — alles nur `one_factor`. Bei Pwd-Kompromittierung des Operator-Accounts ist Borg-UI + Code-Server der direkteste Pfad zur Datenexfiltration.
|
||||
- **Authelia-Repo-Baseline ↔ Host-Config-Drift "by design"** (`docs/REPO_MAP.md:48`, `SERVICE_CATALOG 23`). User-DB, OIDC-Clients und Secrets sind hostseitig, Manual-Merge-Pflicht. Stelle, an der Drift mit Anlauf passiert.
|
||||
- **Komodo Self-Bootstrap-Problem ist nur dokumentiert, nicht geloest** (MASTER 13: Self-Stack Drift-Recovery 2026-05-04). Bei Recovery vom kalten Host musst du Komodo aus `compose.yaml` neu erzeugen — dafuer brauchst du die `.env` mit `KOMODO_*`-Secrets, die nur auf Host und ggf. Vaultwarden liegen.
|
||||
- **Backup-Off-Site-Diversitaet:** BorgBase Hetzner ist Single-Provider; Borg-Passphrase analog gesichert ist als TODO markiert (`docs/DISASTER_RECOVERY.md:64,401`). Wenn Hetzner-Account verloren geht, ist das halbe DR-Versprechen weg.
|
||||
|
||||
**Was unnoetig komplex ist:**
|
||||
|
||||
- Drei dokumentierte Monitoring-/Logging-Pfade gleichzeitig im Repo: `ops/grafana-influxdb` (Altstand), `ops/loki` (Altstand), `monitoring/` (Ziel). Die Altstaende sind als Container down, aber **Verzeichnisse noch im Repo** — Doppelpflege-Risiko. Der versprochene Repo-Cleanup (`git rm`) fehlt.
|
||||
- Hermes-Agent: NAS-Stack bewusst deaktiviert ("VM-seitig offen"), aber Stack-Verzeichnis und Compose mit Dashboard-Domain bleiben im Repo. Mehr "Schwebezustand" als operativer Wert.
|
||||
- BentoPDF: "vorbereitet", noch nie produktiv abgenommen (`SERVICE_CATALOG 52`, `MASTER 7.5`).
|
||||
- `infra/redis` ist als shared Cache deklariert, wird de facto nur von Paperless genutzt (Authelia nicht, Immich/Nextcloud/Mealie haben eigene Redis). Das "shared" stimmt im Repo nicht mit der Realitaet ueberein.
|
||||
|
||||
**Groesster Hebel:**
|
||||
|
||||
**Authelia OIDC-Provider aktivieren** — wenn Nextcloud, Immich, Grafana (und perspektivisch Mealie via OIDC-Bridge) per SSO laufen, gewinnst du gleichzeitig:
|
||||
|
||||
- (a) Familien-Onboarding-Komfort (ein Login),
|
||||
- (b) zentrale Brute-Force-Regulation und Audit,
|
||||
- (c) Voraussetzung fuer sinnvolles CrowdSec/Fail2Ban,
|
||||
- (d) zentrale 2FA-Pflicht statt App-by-App.
|
||||
|
||||
Das ist ein Sprint, nicht ein Quartal, und macht aus deinem "Admin-Authelia" ein echtes Identity-System.
|
||||
|
||||
**Was ein erfahrener Homelabber sofort aendern wuerde:**
|
||||
|
||||
1. AdGuard-Admin-Port nur auf Tailscale-Interface binden (5 Min, Compose-Edit).
|
||||
2. Borg-Passphrase auf Papier in Bankschliessfach (15 Min, off-system).
|
||||
3. `scrutiny` und `ddns-updater` `no-new-privileges` Warning aufraeumen (10 Min) — kosmetisch, aber Policy-Check sollte clean sein.
|
||||
4. Altstaende `ops/grafana-influxdb/` und `ops/loki/` aus Repo entfernen (Backup-Branch dann `git rm`).
|
||||
5. Renovate-Bot gegen Gitea einrichten — beendet manuelle Digest-Pflicht.
|
||||
|
||||
---
|
||||
|
||||
# B. Scorecard (1 = exzellent, 10 = ungenuegend)
|
||||
|
||||
| Bereich | Note | Begruendung |
|
||||
|---|---:|---|
|
||||
| Hardware | **nicht bewertbar** | Keine Inventar-Doku im Repo. Nur Cache-NVMe genannt. Siehe Phase H. |
|
||||
| Ordnerstruktur | **2** | Klare Trennung apps/infra/ops/security/core; konsistente Namenskonvention (mit Migrationsplan in STORAGE_LAYOUT 6). Kleinerer Haenger: `host-services/Adguard/` mit Grossbuchstabe. |
|
||||
| Storage | **3** (Repo-Stand) / **2** (mit STORAGE_LAYOUT Active) | Cache-XFS, Disk1-XFS jetzt erreicht. Pfad-Disziplin via `/mnt/user/...`. Posture-Check etabliert. Note durch Draft-Status und fehlendes `SERVICES_RECOVERY.md` gedrueckt. |
|
||||
| Docker-Architektur | **2** | Netzmodell klar, Healthchecks fehlen grossflaechig, `latest@sha256` als bewusster Kompromiss bei einigen Images dokumentiert. Keine Memory-Limits. |
|
||||
| Reverse Proxy / Zugriff | **2** | DNS-Challenge, Wildcard-faehig, Authelia ForwardAuth, dynamic config sauber getrennt. Manuelle Host-Sync-Ausnahme ist pragmatisch. |
|
||||
| Security | **3** | Solides Fundament (Authelia Argon2id, no-new-privileges-Standard, Webhook-Allowlist, `cloudflare_dns_api_token` als Docker Secret), aber: nur 2 Domains mit 2FA, AdGuard-Admin direkt am LAN, kein WAF/Bouncer, Authelia Regulation 5-Min-Ban ist gentil. |
|
||||
| Netzwerk / DNS | **2** | AdGuard + Unbound + Tailscale-Trias ist Best-of-Class-Homelab. FritzBox als Router nicht im Repo dokumentiert, daher Note nicht 1. |
|
||||
| Backup | **2** | Borg, Pre-Dumps, Tier-Modell, dokumentierter Scope. Punkt-Abzug: Single-Provider Off-Site, Passphrase nicht analog, Komodo-Mongo-Dump-Verifikation nicht im Auto-Cron. |
|
||||
| Restore-Faehigkeit | **2** | RESTORE_MATRIX mit Smoke-Test je Dienst, Restore-Test-Schedule + Validierungen fuer Vaultwarden/Gitea/Paperless dokumentiert. Punkt-Abzug: Immich-Restore noch nie geuebt — groesster Datentopf. |
|
||||
| GitOps | **1-2** | Webhook-Pflicht fuer neue Stacks, Source-of-Truth-Hierarchie, Drift-Runbook. Self-Bootstrap-Problem von Komodo zieht von 1 auf 2. |
|
||||
| Monitoring | **3** | Stack produktiv, aber Altstaende noch im Repo, Family-View-Dashboard fehlt, Alerts (`alerts.yml`) sehr knapp (5 Regeln), keine Cert-Expiry-Alert auf Prometheus-Ebene (Cert-Token-Check laeuft separat). |
|
||||
| Dokumentation | **1** | Aussergewoehnlich. SERVICE_CATALOG ist Gold. Einziger Punkt: kein End-User-/Familien-Onboarding. |
|
||||
| Automatisierung | **3** | Borg-Dumps automatisiert, posture-check Host-Cron, Alert->ntfy-Pipe. Aber: keine CI gegen Repo, kein Renovate, kein automatisches Image-Update-Tracking. |
|
||||
| Wartbarkeit | **2** | Doku traegt; Sprintpflege-Disziplin sichtbar (MIGRATION_LOG, AUDIT_FINAL). Risiko: Authelia-Drift, Hermes-Schwebezustand. |
|
||||
| Nerd-Faktor | **2-** | Komodo + Borg-UI + ntfy-Bridge + Posture-Check + Restore-Lab + Hermes-Experiment + Push-Mirror + Digest-Pinning. Liegt zwischen "Solider Senior" und "Spielwiese halten lernen". |
|
||||
|
||||
**Gesamteindruck: 2 (gut).** Strukturell weit ueber durchschnittlichem Homelab; konkrete Luecken sind klar benennbar und nicht systemisch.
|
||||
|
||||
---
|
||||
|
||||
# C. Top-20 Findings
|
||||
|
||||
> Format: priorisiert nach Risiko-zu-Aufwand-Hebel. Jeder Eintrag hat Fundstelle, Empfehlung und Prio.
|
||||
|
||||
### F-01 · AdGuard-Admin am LAN ohne Auth
|
||||
|
||||
- **Kategorie:** Security / Zugriff
|
||||
- **Fundstelle:** `host-services/Adguard/docker-compose.yml:16`, `MASTER 10`, `docs/SERVICE_CATALOG.md:14`
|
||||
- **Beobachtung:** Port `8082:80` direkt auf alle Interfaces. Bewusste Operator-Entscheidung 2026-05-25.
|
||||
- **Risiko:** Jedes Geraet im LAN kann DNS-Filterregeln, Upstream und Logging manipulieren. IoT-Kompromittierung oder Gast-WLAN -> DNS-Hijack moeglich.
|
||||
- **Best Practice:** Admin-UIs nicht im LAN ohne Auth. Entweder hinter Traefik+Authelia mit `two_factor` oder Bind auf Tailscale-Interface (z. B. `100.x.y.z:8082:80`).
|
||||
- **Empfehlung:** Schritt 1 — Bind auf Tailscale-IP (S, 5 Min). Schritt 2 — optional spaeter Traefik-Route hinter Authelia.
|
||||
- **Prioritaet:** Sollte zeitnah
|
||||
- **Aufwand:** S
|
||||
- **Validierung:** `ss -ltnp | grep :8082` zeigt nur Tailscale-IP; LAN-Browser-Zugriff schlaegt fehl.
|
||||
- **Rollback:** Compose-Diff zurueck, Komodo redeploy.
|
||||
|
||||
### F-02 · Borg-Passphrase nicht analog gesichert
|
||||
|
||||
- **Kategorie:** Backup / DR
|
||||
- **Fundstelle:** `docs/DISASTER_RECOVERY.md:64,401`, `docs/SECRETS_MAP.md:48`
|
||||
- **Beobachtung:** `borg_repo_passphrase.txt` liegt im Host-Filesystem unter `/mnt/user/appdata/secrets/`. Doku weist explizit darauf hin, dass eine externe analoge Sicherung Operator-Aufgabe ist.
|
||||
- **Risiko:** Wenn Unraid-Host und ggf. Vaultwarden gleichzeitig defekt sind, ist das verschluesselte Borg-Repo bei Hetzner nutzlos.
|
||||
- **Empfehlung:** Auf Papier ausdrucken, in Bankschliessfach oder bei vertrauter Person versiegelt. Zusaetzlich in Vaultwarden hinterlegen (aber Vaultwarden hilft nicht, wenn es selbst restauriert werden muss).
|
||||
- **Prioritaet:** Muss sofort
|
||||
- **Aufwand:** S
|
||||
- **Validierung:** Du kannst den Wert ohne Host wiederherstellen.
|
||||
|
||||
### F-03 · Single-Provider Off-Site Backup
|
||||
|
||||
- **Kategorie:** Backup
|
||||
- **Fundstelle:** `ops/borg-ui/BACKUP_SCOPE.md`, `docs/RESTORE_MATRIX.md:77-96`, `STORAGE_LAYOUT 8.1`
|
||||
- **Beobachtung:** Hetzner Storage Box als alleiniges Off-Site-Borg-Ziel. STORAGE_LAYOUT 8.1 sieht zusaetzlich lokales Borg-Repo auf `/mnt/user/backups/borg/` vor (gleicher Host) und eine externe Wechselplatte (manuell rotiert).
|
||||
- **Risiko:** Hetzner-Account-Verlust (Payment-Issue, Account-Hack, Provider-Outage) = halbes 3-2-1.
|
||||
- **Best Practice:** Zweites Off-Site-Ziel mit unterschiedlichem Provider oder Cold-Wechselplatte mit fester Rotationskadenz.
|
||||
- **Empfehlung:** (a) Wechselplatten-Rotation in fester Kadenz dokumentieren (zwei Platten, monatlicher Tausch). Oder (b) zweites Borg-Repo bei rsync.net / BorgBase EU2 / privatem 2. Standort.
|
||||
- **Prioritaet:** Sollte zeitnah
|
||||
- **Aufwand:** M
|
||||
- **Validierung:** `borg list` gegen beide Repos, beide < 7 Tage alt.
|
||||
|
||||
### F-04 · Authelia 2FA-Pflicht zu schmal
|
||||
|
||||
- **Kategorie:** Security
|
||||
- **Fundstelle:** `security/authelia/configuration.yml:44-53`
|
||||
- **Beobachtung:** Nur `files.kaleschke.info` und `scrutiny.kaleschke.info` sind `two_factor`. Tier-1-Operator-UIs wie Borg-UI, Code-Server, Filebrowser (zweite Route?), Komodo (eigene Auth), Glance, Grafana laufen mit `one_factor`.
|
||||
- **Risiko:** Operator-Passwort-Kompromittierung (Phishing, Keylogger, Browser-Save-Leak) gibt ohne 2FA Vollzugriff auf Code-Server (Repo + Workspace), Borg-UI (Restore-Pfade), Filebrowser (Documents/Photos).
|
||||
- **Empfehlung:** ACL erweitern: `two_factor` fuer `borg.kaleschke.info`, `code.kaleschke.info`, `files.kaleschke.info` (schon), `glance.kaleschke.info` (debattierbar), `traefik.kaleschke.info`. Komodo bleibt Ausnahme.
|
||||
- **Prioritaet:** Muss sofort
|
||||
- **Aufwand:** S
|
||||
- **Validierung:** Nach Login auf `borg.kaleschke.info` wird 2FA-Prompt erzwungen.
|
||||
- **Rollback:** ACL-Block zurueck.
|
||||
|
||||
### F-05 · Repo-Altstaende `ops/grafana-influxdb/` und `ops/loki/` nicht entfernt
|
||||
|
||||
- **Kategorie:** Wartbarkeit / GitOps
|
||||
- **Fundstelle:** Repo-Wurzeln `ops/grafana-influxdb/`, `ops/loki/`; `MASTER 7.6`, `SERVICE_CATALOG 68-70,80-81`, `AUDIT_FINAL 9`
|
||||
- **Beobachtung:** Container down, aber Compose-Dateien + Provisioning bleiben im Repo. Doku referenziert beide gleichzeitig. Risiko: jemand (zukuenftiges Ich, KI) deployt versehentlich den Altstand.
|
||||
- **Empfehlung:** `git rm` der beiden Verzeichnisse, Tag `pre-monitoring-cleanup` fuer Rollback, MIGRATION_LOG-Eintrag.
|
||||
- **Prioritaet:** Sollte zeitnah
|
||||
- **Aufwand:** S
|
||||
- **Validierung:** `policy-checks` laeuft clean, Repo enthaelt nur noch `monitoring/`.
|
||||
|
||||
### F-06 · Hermes-Agent im Schwebezustand
|
||||
|
||||
- **Kategorie:** App-Landschaft / Wartbarkeit
|
||||
- **Fundstelle:** `ops/hermes-agent/docker-compose.yml`, `MASTER 7.5`, `SERVICE_CATALOG 82-83`
|
||||
- **Beobachtung:** "NAS-Stack bewusst deaktiviert" wegen offener VM-Seite. Dashboard-Domain (`hermes.kaleschke.info`) + Authelia-ACL + Secret-Pfade dokumentiert.
|
||||
- **Risiko:** Schleichender Verfall — in 6 Monaten verstehst du Model-C nicht mehr ohne `ops/hermes-agent/README.md`. Bei jeder Authelia-/Compose-Aenderung musst du Hermes mitpruefen, obwohl es nichts tut.
|
||||
- **Empfehlung:** Operator-Entscheidung mit 60-Tage-Deadline ehrlich treffen. Wenn nicht produktiv bis 2026-07-25: `git rm ops/hermes-agent/`, Domain aus DNS, ACL-Eintrag raus.
|
||||
- **Prioritaet:** Sollte zeitnah (Entscheidung)
|
||||
- **Aufwand:** S (Entfernen) / L (echte Produktiv-Aktivierung)
|
||||
- **Validierung:** Entweder Smoke-Test auf `hermes.kaleschke.info` mit funktionalem Use-Case-Beleg, oder Repo-clean.
|
||||
|
||||
### F-07 · Monitoring-Stack ohne Digest-Pin
|
||||
|
||||
- **Kategorie:** Reproduzierbarkeit / GitOps
|
||||
- **Fundstelle:** `monitoring/docker-compose.yml:3,28,66,84,100,118,276,296`
|
||||
- **Beobachtung:** Prometheus, Alertmanager, Blackbox, Loki, Promtail, Grafana, node-exporter, cAdvisor sind alle nur per Tag gepinnt (`prom/prometheus:v3.7.3`, `grafana/grafana:12.4.3`, ...). Nur `influxdb3-core` hat `@sha256:`. Das widerspricht der Image-Versionierungs-Disziplin der Tier-1-Stateful-Dienste.
|
||||
- **Risiko:** Wenn upstream einen Tag erneut pushed (Versionsdrift, Supply Chain), wird beim Rebuild ein anderer Container deployed — gerade Monitoring sollte stabil sein.
|
||||
- **Empfehlung:** Beim naechsten Komodo-Redeploy aktuellen Digest auslesen und einpinnen. Vorbereitung fuer Renovate (F-12).
|
||||
- **Prioritaet:** Nice to have
|
||||
- **Aufwand:** S
|
||||
- **Validierung:** `grep '@sha256' monitoring/docker-compose.yml` listet alle 10 Services.
|
||||
|
||||
### F-08 · Alert-Regeln zu duenn
|
||||
|
||||
- **Kategorie:** Monitoring
|
||||
- **Fundstelle:** `monitoring/prometheus/alerts.yml`
|
||||
- **Beobachtung:** Exakt 5 Regeln: ExternalConnectivityDown, EndpointDown, EndpointSlow, DiskAlmostFull, MemoryHighUsage, Traefik5xx. Es fehlen:
|
||||
- Borg-Lauf-Frische (ueber `node_exporter` textfile collector oder Pushgateway).
|
||||
- Zertifikatslaufzeit (Blackbox kann `probe_ssl_earliest_cert_expiry`, aber keine Alert-Regel dafuer).
|
||||
- Container-down-Alert (cAdvisor liefert `container_last_seen`).
|
||||
- PostgreSQL-Connection-Saturation.
|
||||
- Loki ingestion-rate / log-volume spike.
|
||||
- InfluxDB-Disk-Pressure.
|
||||
- Backup-Job-Failure.
|
||||
- **Risiko:** Du siehst Probleme nicht, bevor sie weh tun. Cert-Expiry und Borg-Stale sind die schmerzhaftesten Blind-Spots.
|
||||
- **Empfehlung:** Mindestens zwei Regeln nachziehen: `BorgArchiveStale` (>30 h, Pushgateway oder textfile) und `TLSCertExpiryNear` (<14 Tage). Rest als Folge-Sprint.
|
||||
- **Prioritaet:** Sollte zeitnah
|
||||
- **Aufwand:** M
|
||||
- **Validierung:** Alerts feuern in Test-Bedingung (Borg-Dump-File touch -d backwards).
|
||||
|
||||
### F-09 · Komodo-Self-Bootstrap-Problem
|
||||
|
||||
- **Kategorie:** GitOps / DR
|
||||
- **Fundstelle:** `MASTER 13: Komodo Self-Stack Drift-Recovery 2026-05-04`
|
||||
- **Beobachtung:** Du hattest schon einen Drift-Vorfall (Komodo-Core ran aus `/tmp/*repair.yml`, Mongo-Pfad fehlte). Recovery-ENV liegt als "temporaeres Tier-1-Secret-Material" unter `/mnt/user/appdata/secrets/_komodo_stack_env_recovery_2026-05-04.env` (Doku-Stand).
|
||||
- **Risiko:** Bei Totalausfall musst du Komodo aus Compose-Datei wiederbeleben, dafuer brauchst du die Stack-ENV mit `KOMODO_SECRET_KEY`, `KOMODO_MONGO_PASSWORD`, `KOMODO_PERIPHERY_PASSKEY` etc., die nur als Komodo Stack ENV existieren. Klassisches Henne-Ei.
|
||||
- **Empfehlung:** Komodo-Self-Stack aus Komodos eigener Verwaltung herausnehmen und als handgepflegten `docker compose`-Service in `services/komodo-bootstrap/` halten. Stack-ENV als versiegelte Datei unter `/mnt/user/appdata/secrets/` mit deterministischem Restore-Pfad in RESTORE_MATRIX.
|
||||
- **Prioritaet:** Sollte zeitnah
|
||||
- **Aufwand:** M
|
||||
- **Validierung:** Komodo-Stack-Datei lebt im Repo unter `services/`, nicht in Komodos eigener Workspace-Sicht.
|
||||
|
||||
### F-10 · Authelia Repo↔Host Drift "by design"
|
||||
|
||||
- **Kategorie:** GitOps / Security
|
||||
- **Fundstelle:** `docs/REPO_MAP.md:48`, `security/authelia/configuration.yml`, `SERVICE_CATALOG 23`
|
||||
- **Beobachtung:** Repo enthaelt Baseline ohne Secrets, OIDC, Users-DB. Manuelles Merge auf den Host noetig. Es gibt keine automatische Konsistenz-Pruefung.
|
||||
- **Risiko:** Repo-Aenderung (z. B. neue ACL-Regel) wird gepusht, aber nie auf den Host gemerged -> Drift, Authelia hinkt der Wahrheit hinterher.
|
||||
- **Empfehlung:** Symmetrisch zum Traefik-Dynamic-Workflow (manueller Sync explizit als Pflicht in WORKFLOW.md). Zusaetzlich ein einfaches Diff-Script `services/authelia-diff.sh`, das `diff` zwischen Repo-Baseline und Host-Datei zeigt, und das im posture-check als Warning auftaucht, wenn die ACL-Sektion differiert.
|
||||
- **Prioritaet:** Sollte zeitnah
|
||||
- **Aufwand:** S
|
||||
- **Validierung:** Script laeuft, posture-check kennt einen neuen Check `authelia_config_drift`.
|
||||
|
||||
### F-11 · Immich-Restore noch nie geuebt
|
||||
|
||||
- **Kategorie:** Backup / Restore
|
||||
- **Fundstelle:** `docs/RESTORE_MATRIX.md:49`, Restore-Test-Schedule
|
||||
- **Beobachtung:** Vaultwarden / Gitea / Paperless haben Mini-Restore-Tests (2026-05-07). Immich nicht. Immich ist der groesste Datentopf (Familien-Fotos).
|
||||
- **Risiko:** Silent Corruption in Postgres-pgvecto-rs-Daten bemerkst du erst beim Restore-Versuch.
|
||||
- **Empfehlung:** Eigener Sprint: Immich-Restore-Test gegen `/mnt/user/backups/restore-lab/immich/` mit Sub-Set der `immich.dump` und einem Foto-Sample. Smoke-Test = "10 Fotos im Browser sichtbar nach Restore".
|
||||
- **Prioritaet:** Sollte zeitnah
|
||||
- **Aufwand:** M
|
||||
- **Validierung:** Report unter `/mnt/user/backups/restore-reports/immich-<datum>.json`.
|
||||
|
||||
### F-12 · Keine Image-Update-Automatik (Renovate o. ae.)
|
||||
|
||||
- **Kategorie:** Wartbarkeit
|
||||
- **Fundstelle:** Repo-weit; `docs/WORKFLOW.md:282-288` (Image-Versionierung)
|
||||
- **Beobachtung:** Digest-Pinning ist konsequent, aber rein manuell. Bei ~30 Images bedeutet das, du musst monatlich fuer Patch-Updates manuell Digests auslesen — oder es bleibt liegen.
|
||||
- **Risiko:** CVE-Patches werden nicht eingespielt, weil "der laufende Stand ist stabil".
|
||||
- **Empfehlung:** Renovate Bot (self-hosted, gegen Gitea), Gitea-Actions-Runner. Renovate oeffnet PRs fuer Patch-/Minor-Updates; Major-Updates werden mit Labels separiert.
|
||||
- **Prioritaet:** Sollte zeitnah (oder Nice to have, je nach Schmerz)
|
||||
- **Aufwand:** M (Initial-Setup ist substantiell)
|
||||
- **Validierung:** Renovate hat erste PRs in Gitea geoeffnet, du mergst eines davon kontrolliert.
|
||||
|
||||
### F-13 · Keine OIDC-SSO fuer User-Apps
|
||||
|
||||
- **Kategorie:** Security / UX
|
||||
- **Fundstelle:** `security/authelia/configuration.yml`, `docs/SECRETS_MAP.md`
|
||||
- **Beobachtung:** Authelia kann OIDC, ist aber nur als ForwardAuth konfiguriert. Nextcloud, Immich, Grafana, Mealie laufen mit eigenen User-DBs.
|
||||
- **Risiko:** N getrennte Passwortspeicher, N getrennte 2FA-Setups, keine zentrale Sperrung bei Account-Kompromittierung. Familie hat keinen einfachen Onboarding-Pfad.
|
||||
- **Empfehlung:** OIDC-Provider in Authelia aktivieren, Nextcloud (via Plugin), Immich (nativer OIDC-Support), Grafana (nativer OIDC-Support) als Clients konfigurieren. Vaultwarden via OIDC-Bridge nur, wenn der Aufwand klar mehrwertig ist — sonst bewusst auslassen.
|
||||
- **Prioritaet:** Sollte zeitnah (groesster Hebel laut Executive)
|
||||
- **Aufwand:** L
|
||||
- **Validierung:** Familienkonto kann sich mit einem Login bei Nextcloud + Immich + Grafana anmelden.
|
||||
|
||||
### F-14 · Kein WAF / Bouncer vor oeffentlichen Apps
|
||||
|
||||
- **Kategorie:** Security
|
||||
- **Fundstelle:** `traefik/docker-compose.yml`, oeffentliche Hosts in `docs/REPO_MAP.md:127-152`
|
||||
- **Beobachtung:** Sechs oeffentliche Apps mit nativer Auth (vault, paperless, mealie, ntfy, git, immich, cloud) ohne IP-Bouncer. Authelia-Regulation greift nur fuer die ForwardAuth-Pfade; Apps mit eigener Auth bekommen den vollen Traffic.
|
||||
- **Risiko:** Credential-Stuffing-Bot-Wellen treffen die App selbst (Nextcloud, Immich) — Logs sind im Loki, aber kein Sperr-Mechanismus.
|
||||
- **Empfehlung:** CrowdSec als Bouncer fuer Traefik (`crowdsecurity/traefik-bouncer`). Nutzt Loki/Logs fuer Erkennung, sperrt IPs auf Traefik-Ebene, bevor sie die Apps treffen.
|
||||
- **Prioritaet:** Sollte zeitnah
|
||||
- **Aufwand:** M
|
||||
- **Validierung:** CrowdSec-Dashboard zeigt erste Sperren; Test-Brute-Force gegen `nextcloud.kaleschke.info` wird bei N Versuchen geblockt.
|
||||
|
||||
### F-15 · Healthchecks fehlen grossflaechig
|
||||
|
||||
- **Kategorie:** Docker / Operations
|
||||
- **Fundstelle:** Spot-checks: `apps/paperless/docker-compose.yml`, `apps/immich/docker-compose.yml`, `security/authelia/docker-compose.yml`, `traefik/docker-compose.yml` — keiner hat `healthcheck:`-Block.
|
||||
- **Beobachtung:** Restart-Policy ist ueberall `unless-stopped`, aber ohne Healthcheck kann Docker keinen Crash-Loop bei "Container laeuft, aber App tot" erkennen.
|
||||
- **Risiko:** Bei Soft-Failure (Postgres-Connection-Pool tot, Authelia haengt im Storage-Connect) merkst du nichts, weil Container "running" bleibt.
|
||||
- **Empfehlung:** Fuer Tier-1 (Traefik `wget /ping`, Authelia `/api/health`, PostgreSQL `pg_isready`, Komodo `wget /api/healthcheck`) Healthchecks ergaenzen. Fuer Tier-2 schrittweise.
|
||||
- **Prioritaet:** Sollte zeitnah
|
||||
- **Aufwand:** M (pro Stack 5–15 Min)
|
||||
- **Validierung:** `docker ps` zeigt `(healthy)` neben den Tier-1-Containern.
|
||||
|
||||
### F-16 · `infra/redis` als "shared" deklariert, faktisch nur Paperless
|
||||
|
||||
- **Kategorie:** Architektur-Konsistenz
|
||||
- **Fundstelle:** `infra/redis/docker-compose.yml`, `docs/SERVICE_CATALOG.md:31` ("shared Redis Cache")
|
||||
- **Beobachtung:** Immich, Nextcloud, Mealie haben jeweils eigene Redis-Instanzen. Authelia nutzt bewusst kein Redis (MASTER 13). Paperless nutzt es laut Compose. Effektiv "Paperless-Redis im Frack des shared-Caches".
|
||||
- **Risiko:** Niedrig. Aber: Wenn du `infra/redis` fuer etwas anderes wegnimmst, denkst du, es kostet Paperless was — und das waere der Fall.
|
||||
- **Empfehlung:** Doku-Update: SERVICE_CATALOG 31 praezisieren ("dediziertes Redis fuer Paperless; andere Stacks haben eigene Redis-Instanzen"). Architektur bleibt, nur Etikett ehrlich machen. Alternativ: in `apps/paperless/` als App-internes Netz konsolidieren wie Mealie.
|
||||
- **Prioritaet:** Nice to have
|
||||
- **Aufwand:** S (Doku) / M (Architektur)
|
||||
|
||||
### F-17 · Plex bleibt als Host-Net-Stack
|
||||
|
||||
- **Kategorie:** Security / Architektur
|
||||
- **Fundstelle:** `host-services/plex/docker-compose.yml`, `MASTER 7.4`
|
||||
- **Beobachtung:** Plex laeuft als Host-Net wegen Discovery/GDM. Dokumentierte Ausnahme.
|
||||
- **Risiko:** Plex hat hoehere Angriffsoberflaeche als Apps mit Traefik. Plex-Login wurde mehrfach Ziel von Account-Uebernahmen (Plex.tv-Auth-Issues 2024/25). Bei Plex.tv-Kompromittierung greift Authelia nicht — Plex authentifiziert gegen Plex.tv.
|
||||
- **Empfehlung:** Plex bewusst beibehalten (Doku stuetzt), aber: (a) "Remote Access" in Plex-UI deaktivieren, wenn nur lokal/Tailscale genutzt. (b) Plex-Server nicht in `frontend_net` (waere schaedlich) — bleibt Host-Net, korrekt.
|
||||
- **Prioritaet:** Nice to have
|
||||
- **Aufwand:** S
|
||||
- **Validierung:** Plex `Remote Access` UI zeigt "disabled".
|
||||
|
||||
### F-18 · Nextcloud ohne ForwardAuth, ohne dedizierte Brute-Force-Doku
|
||||
|
||||
- **Kategorie:** Security
|
||||
- **Fundstelle:** `apps/nextcloud/docker-compose.yml`, `MASTER 13: Nextcloud-Entscheidung`
|
||||
- **Beobachtung:** Bewusste Ausnahme (WebDAV/CardDAV). In Nextcloud selbst sind Brute-Force-Schutz, 2FA-Pflicht und App-Passwords konfigurierbar, aber nicht im Repo dokumentiert.
|
||||
- **Risiko:** Familien-Konto mit schwachem Passwort + Nextcloud oeffentlich = direkter Pfad zu Dokumenten/Fotos.
|
||||
- **Empfehlung:** `apps/nextcloud/POST_INSTALL.md` mit Pflicht-Checkliste: Brute-Force-Plugin aktiv, 2FA-Provider TOTP installiert, Admin-Account hat 2FA, "Enforce 2FA for admin group" gesetzt. Optional: `OCC`-Befehle als Skript in `services/nextcloud-policy/`.
|
||||
- **Prioritaet:** Sollte zeitnah
|
||||
- **Aufwand:** S
|
||||
- **Validierung:** Test-Login ohne 2FA als Admin schlaegt fehl.
|
||||
|
||||
### F-19 · Keine Container-Memory-Limits
|
||||
|
||||
- **Kategorie:** Docker / Hardware-Schutz
|
||||
- **Fundstelle:** Spot-checks aller Composes
|
||||
- **Beobachtung:** Keine `mem_limit:` oder `deploy.resources.limits` Sektion in Tier-1- oder Tier-2-Stacks.
|
||||
- **Risiko:** Bei Image-Bug oder Memory-Leak (z. B. Immich-ML, Paperless-OCR-Loop) kann ein Container den Host in OOM treiben. Posture-Check + Docker-Critical-Events sehen das nachher, aber praeventiver waere Container-Limit + Docker-OOM-Kill fuer den richtigen Prozess.
|
||||
- **Empfehlung:** Fuer Tier-1 (Postgres, Authelia, Traefik, Komodo) sanfte Limits setzen (z. B. Postgres 2 GB, Authelia 256 MB, Traefik 256 MB). Fuer Immich-ML-Container ein hartes Limit, das Verhungern verhindert.
|
||||
- **Prioritaet:** Nice to have
|
||||
- **Aufwand:** M
|
||||
- **Validierung:** `docker stats` zeigt `MEM USAGE / LIMIT` ungleich `unlimited`.
|
||||
|
||||
### F-20 · Paperless-DBPass weiter als Stack-ENV (dokumentierte Ausnahme)
|
||||
|
||||
- **Kategorie:** Secrets
|
||||
- **Fundstelle:** `MASTER 13: Secrets in Komodo Stacks`, `docs/SECRETS_MAP.md:25`
|
||||
- **Beobachtung:** Paperless unterstuetzt `_FILE` nicht fuer DB-Pass. Bewusste Ausnahme.
|
||||
- **Risiko:** Stack-ENV liegt in Komodo-DB (Mongo), nicht im Repo. Bei Komodo-Mongo-Backup-Luecke fehlt das Passwort beim Restore.
|
||||
- **Empfehlung:** Erweiterung der Disaster-Recovery-Doku: explizite Liste aller "Stack-ENV-only"-Secrets mit Zeiger, dass `komodo-mongo.archive.gz` fuer Restore zwingend ist, oder die ENV manuell vorgehalten werden muss (in Vaultwarden + externer Notiz).
|
||||
- **Prioritaet:** Nice to have
|
||||
- **Aufwand:** S
|
||||
- **Validierung:** DR-Doc Abschnitt "Stack-ENV-Werte" referenziert konkrete Restore-Pfade.
|
||||
|
||||
---
|
||||
|
||||
# D. Risiko-Matrix
|
||||
|
||||
| Risiko | Bereich | Wahrscheinlichkeit | Auswirkung | Prioritaet | Massnahme |
|
||||
|---|---|---|---|---|---|
|
||||
| Borg-Passphrase weg -> Restore unmoeglich | Backup | niedrig | katastrophal | P0 | F-02 analoge Sicherung |
|
||||
| Hetzner-Account-Verlust -> halbes 3-2-1 | Backup | niedrig-mittel | hoch | P0/P1 | F-03 Zweitziel |
|
||||
| AdGuard-Admin-Manipulation aus LAN | Security | niedrig | hoch (DNS-Hijack) | P0 | F-01 Bind auf Tailscale |
|
||||
| Operator-Pwd-Leak -> 2FA fehlt fuer Borg-UI/Code-Server | Security | mittel | hoch | P0 | F-04 2FA-ACL erweitern |
|
||||
| Komodo-Self-Bootstrap-Failure nach Totalausfall | DR | niedrig | hoch | P1 | F-09 Bootstrap-Datei in `services/` |
|
||||
| Authelia Repo↔Host Drift unbemerkt | GitOps/Security | mittel | mittel | P1 | F-10 Diff-Check |
|
||||
| Immich Silent Corruption -> kein Restore-Test belegt | Backup | niedrig | sehr hoch (Familien-Fotos) | P1 | F-11 Restore-Test |
|
||||
| Cert-Expiry unbemerkt -> Public Apps down | Operations | niedrig | mittel | P1 | F-08 Alert-Regel |
|
||||
| Nextcloud Brute-Force ohne Bouncer | Security | mittel | mittel-hoch | P1 | F-14 CrowdSec / F-18 Nextcloud-Haerten |
|
||||
| Image-Update-Stillstand -> CVE bleibt | Security | mittel | mittel | P1 | F-12 Renovate |
|
||||
| Hermes-Wartungsschuld | Wartbarkeit | hoch | niedrig | P1 | F-06 Entscheidung |
|
||||
| Repo-Altstaende ueberleben -> Doppel-Deploy | GitOps | mittel | niedrig | P1 | F-05 Cleanup |
|
||||
| OOM durch unlimitierte Container | Hardware | niedrig | mittel | P2 | F-19 mem_limit |
|
||||
| Healthcheck-Luecke -> Soft-Failure stumm | Operations | mittel | niedrig | P2 | F-15 Healthchecks |
|
||||
| Monitoring-Stack ohne Digest-Pin | Reproduzierbarkeit | niedrig | niedrig | P2 | F-07 Digests + Renovate |
|
||||
| Hardware-SPOF (kein zweiter Host) | Hardware | niedrig | sehr hoch | P3 | Cold-Standby / 2. Host |
|
||||
|
||||
---
|
||||
|
||||
# E. Zielarchitektur (realistisch fuer privates Homelab)
|
||||
|
||||
**Hardware**
|
||||
|
||||
- 1× Unraid-Host (bestehend) als Production. CPU mit AVX2/AVX-512 fuer Immich-ML. ≥ 32 GB RAM (fuer 2× Postgres + Immich-ML + Loki/Prometheus + 2 VMs).
|
||||
- 2× NVMe als BTRFS-RAID1-Cache, sobald Cache-Auslastung > 70 % (STORAGE_LAYOUT 15.3).
|
||||
- Parity-Disk ≥ groesste Daten-Disk.
|
||||
- USV mit USB-Steuerung (NUT-faehig: APC Back-UPS RS 700+, Eaton 3S, CyberPower CP1500EPFCLCD). Direkter Shutdown bei Power-Loss.
|
||||
- Optional: zweiter alter Mini-PC oder NUC als Cold-Standby mit Tailscale, der den letzten Komodo-Bootstrap + Gitea-Mirror tragen kann.
|
||||
|
||||
**Netzwerk**
|
||||
|
||||
- FritzBox (bestehend) als Router + NAT.
|
||||
- VLANs nur wenn IoT-WLAN dazukommt (FritzBox-Gast-WLAN reicht fuer Anfang).
|
||||
- DNS: AdGuard -> Unbound (bestehend). Admin-UI nur Tailscale.
|
||||
- Tailscale (bestehend): Operator-Pfad. Subnet-Router optional fuer LAN-Devices ueber Tailscale.
|
||||
|
||||
**Storage**
|
||||
|
||||
- Cache `only` fuer `appdata`, `system`, `domains` (bestehend STORAGE_LAYOUT 4).
|
||||
- Disk1 (XFS) fuer `services`, `documents`, `photos`, `backups`, `media`, `finance`, `projekte`.
|
||||
- Externe Wechselplatte (XFS) fuer Cold-Off-Site mit fester monatlicher Rotation.
|
||||
|
||||
**Ordnerstruktur (Repo)**
|
||||
|
||||
- Beibehalten. Nur Cleanup von Altstaenden (F-05). Naming `kebab-case`-Migration aus STORAGE_LAYOUT 6 schrittweise.
|
||||
|
||||
**Docker**
|
||||
|
||||
- Compose-only via Komodo (bestehend).
|
||||
- Digest-Pin fuer alle Images (F-07).
|
||||
- Healthchecks fuer Tier-1 (F-15).
|
||||
- Mem-Limits fuer Tier-1 + Immich-ML (F-19).
|
||||
- App-interne Netze fuer Stack-Isolation (bestehend).
|
||||
|
||||
**Reverse Proxy**
|
||||
|
||||
- Traefik v3 (bestehend), DNS-Challenge, Wildcard.
|
||||
- Dynamic Config nur fuer Middlewares, TLS, Dashboard (bestehend).
|
||||
- CrowdSec-Bouncer (F-14) fuer oeffentliche Apps.
|
||||
|
||||
**Auth**
|
||||
|
||||
- Authelia als ForwardAuth **und** OIDC-Provider (F-13).
|
||||
- Nextcloud, Immich, Grafana via OIDC.
|
||||
- 2FA-Pflicht fuer alle Operator-Dienste (F-04).
|
||||
- Komodo bewusste Ausnahme (bestehend).
|
||||
|
||||
**Backup**
|
||||
|
||||
- Borg lokal (`/mnt/user/backups/borg/`) + Borg Hetzner + Wechselplatte.
|
||||
- Pre-Dump-Hooks (bestehend).
|
||||
- Borg-Passphrase off-system analog (F-02).
|
||||
- Restore-Tests automatisiert (F-11 Immich, dann andere via CI).
|
||||
|
||||
**Monitoring**
|
||||
|
||||
- `monitoring/`-Stack als alleinige Quelle. Altstaende raus (F-05).
|
||||
- Family-View-Dashboard in Grafana (alles gruen, Backup-Frische, Cert-Tage).
|
||||
- Alerts ausgebaut (F-08).
|
||||
- Posture-Check + Docker-Critical-Events -> ntfy `homelab-alerts` (bestehend).
|
||||
|
||||
**Dokumentation**
|
||||
|
||||
- Aktuelle Doku-Tiefe halten.
|
||||
- `SERVICES_RECOVERY.md` und `STORAGE_LAYOUT.md` (Active) finalisieren.
|
||||
- Familien-/User-Onboarding-Doku als eigenes kleines Dokument.
|
||||
|
||||
**GitOps**
|
||||
|
||||
- Gitea + Komodo (bestehend).
|
||||
- GitHub-Push-Mirror (umgesetzt, bestaetigt durch MASTER 7.1).
|
||||
- Renovate-Bot gegen Gitea (F-12).
|
||||
- Optional: Staging-Branch + zweites Komodo-Ziel in Tailscale-VM (Phase 3).
|
||||
|
||||
**Restore**
|
||||
|
||||
- RESTORE_MATRIX bleibt fuehrend.
|
||||
- Restore-Lab unter `/mnt/user/backups/restore-lab/` (bestehend).
|
||||
- Immich-Restore als Luecke schliessen (F-11).
|
||||
- Komodo-Self-Bootstrap raus aus Komodo (F-09).
|
||||
|
||||
---
|
||||
|
||||
# F. Priorisierte Massnahmenliste
|
||||
|
||||
| # | Aufgabe | Warum | Kategorie | Prio | Aufwand | Risiko (bei Nicht-Tun) | Mehrwert | Abhaengigkeiten | Validierung |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| 1 | Borg-Passphrase analog sichern | DR-SPOF schliessen | Backup | P0 | S | katastrophal | DR-Sicherheit | — | Wert ohne Host abrufbar |
|
||||
| 2 | AdGuard-Admin auf Tailscale-IP binden | LAN-Angriffsflaeche | Security | P0 | S | hoch | LAN-IoT-Haertung | — | `ss -ltnp` zeigt nur Tailscale |
|
||||
| 3 | 2FA-ACL erweitern (borg, code, files, traefik) | Operator-Pwd-Leak | Security | P0 | S | hoch | 2FA-Coverage | Authelia-TOTP-Setup | Login erzwingt 2FA |
|
||||
| 4 | Altstaende `ops/grafana-influxdb`+`ops/loki` `git rm` | Repo-Hygiene, kein Re-Deploy | GitOps | P0 | S | niedrig | Klarheit | Tag setzen | Policy-Check clean |
|
||||
| 5 | Hermes 60-Tage-Deadline | Wartungsschuld | App | P1 | S/L | mittel | Komplexitaet raus | Operator-Entscheidung | Entweder produktiv oder weg |
|
||||
| 6 | Immich-Restore-Test einrichten | Groesster Datentopf ungeprueft | Backup | P1 | M | hoch | Restore-Vertrauen | Restore-Lab-Pfad | Smoke-Test-Report |
|
||||
| 7 | Renovate-Bot in Gitea | manuelle Digest-Pflege | Wartung | P1 | M | mittel | Update-Hygiene | Gitea-Runner | erste PR offen |
|
||||
| 8 | Alert-Regeln (Borg-Frische, Cert-Expiry) | Blind-Spot Operations | Monitoring | P1 | M | mittel | echte Alerts | Pushgateway o. textfile | Alert in Test |
|
||||
| 9 | Family-View-Dashboard Grafana | Morgens 30 s Check | Monitoring | P1 | M | niedrig | Uebersicht | Datasources stehen | Dashboard funktioniert |
|
||||
| 10 | Komodo-Self-Bootstrap als `services/komodo-bootstrap/` | Henne-Ei-Problem | GitOps/DR | P1 | M | mittel | sauberer Recovery-Pfad | Komodo-Stack-Doku | Bootstrap aus Repo allein moeglich |
|
||||
| 11 | Authelia-Drift-Diff-Check in posture-check | Repo↔Host Drift | GitOps | P1 | S | mittel | Drift-Detektion | posture-check-Erweiterung | neuer Check sichtbar |
|
||||
| 12 | Healthchecks Tier-1 | Soft-Failure-Erkennung | Docker | P1 | M | niedrig | Self-Healing-Trigger | — | `docker ps` zeigt `healthy` |
|
||||
| 13 | CrowdSec-Bouncer vor Traefik | oeffentliche Apps schuetzen | Security | P1 | M | mittel | Brute-Force-Stop | Traefik-Middleware | Test-IP wird geblockt |
|
||||
| 14 | Nextcloud-Haertung dokumentieren | Public App + native Auth | Security | P1 | S | mittel | App-Haertung | Plugin-Install | 2FA-erzwingt |
|
||||
| 15 | Authelia OIDC-Provider + Nextcloud/Immich/Grafana | SSO, Familien-Onboarding | Security/UX | P2 | L | niedrig | hoher Mehrwert | Authelia-OIDC-Setup | SSO-Login funktioniert |
|
||||
| 16 | Immich-Smartphone-Auto-Backup fuer Familie | Killer-App fuer Familie | App | P2 | S | niedrig | hoher Mehrwert | — | Familien-Foto in Immich |
|
||||
| 17 | Monitoring-Stack Digests + Renovate-Pin | Reproduzierbarkeit | GitOps | P2 | S | niedrig | konsistent | Renovate optional | `@sha256` an allen Images |
|
||||
| 18 | Mem-Limits Tier-1 + Immich-ML | OOM-Schutz | Hardware/Docker | P2 | M | niedrig | Schutz | — | `docker stats` zeigt Limits |
|
||||
| 19 | Off-Site-Zweitziel (rsync.net o. Wechselplatte) | Single-Provider | Backup | P2 | M | mittel | 3-2-1 echt | Borg-Config | beide Repos < 7d |
|
||||
| 20 | Staging-Branch + 2. Komodo-Ziel | Risiko-Aenderung testbar | GitOps | P3 | L | niedrig | Reife | 2. VM/Host | Deploy auf staging klappt |
|
||||
|
||||
---
|
||||
|
||||
# G. Refactoring-Plan (Sprints)
|
||||
|
||||
## Sprint 0 — Sicherheitsnetz und Ist-Zustand sichern (1 Tag)
|
||||
|
||||
- **Ziel:** Du kannst danach im schlimmsten Fall alles, was du jetzt aenderst, sicher zurueckrollen.
|
||||
- **Aufgaben:**
|
||||
- Git-Tag `audit-2026-05-25-baseline` auf `master` setzen und nach Gitea + GitHub-Mirror pushen.
|
||||
- Borg-Lauf manuell ausloesen ("freshen up"), Erfolg im Log dokumentieren.
|
||||
- Aktuellen Komodo-Mongo-Dump verifizieren (`mongorestore --dry-run`).
|
||||
- `docs/MIGRATION_LOG.md` Eintrag "Audit-Sprint-Start".
|
||||
- **Erfolgskriterium:** Tag pushed, Borg-Lauf gruen, Mongo-Dump verifiziert.
|
||||
- **Validierung:** `git fetch && git tag | grep audit-2026-05-25-baseline` und `ls /mnt/user/backups/borg/dumps/latest/` zeigt fresh.
|
||||
- **Rollback:** N/A (rein additiv).
|
||||
- **Risiko bei Nichtumsetzung:** Keine Notbremse fuer Sprint 1.
|
||||
|
||||
## Sprint 1 — Offensichtliche Risiken entschaerfen (1 Woche)
|
||||
|
||||
- **Ziel:** P0-Risiken weg, Repo-Hygiene wieder gruen.
|
||||
- **Aufgaben (in dieser Reihenfolge):**
|
||||
1. F-02 Borg-Passphrase analog sichern (off-system, kein Code-Change).
|
||||
2. F-01 AdGuard-Admin-Port auf Tailscale-IP — Edit `host-services/Adguard/docker-compose.yml:16`.
|
||||
3. F-04 Authelia ACL erweitern (`two_factor` fuer borg, code, files, traefik) — Edit `security/authelia/configuration.yml` + Host-Sync.
|
||||
4. F-05 Altstaende `ops/grafana-influxdb/`, `ops/loki/` entfernen — `git rm`, MIGRATION_LOG.
|
||||
5. Policy-Check-Warnings `SEC001` (ddns-updater, scrutiny) aufraeumen.
|
||||
- **Erfolgskriterium:** Policy-Check 0 Warnings fuer SEC001, AdGuard-Admin nur via Tailscale, 2FA-Login auf borg.kaleschke.info.
|
||||
- **Validierung:** Policy-Check-Report; Browser-Test mit/ohne 2FA-Cookie.
|
||||
- **Rollback:** Commit-Revert pro Block.
|
||||
|
||||
## Sprint 2 — GitOps-Robustheit (1–2 Wochen)
|
||||
|
||||
- **Ziel:** Self-Bootstrap-Problem entschaerft, Drift-Detektion automatisiert.
|
||||
- **Aufgaben:**
|
||||
1. F-09 Komodo-Bootstrap-Compose nach `services/komodo-bootstrap/` extrahieren + dokumentierter Standalone-Restore-Pfad.
|
||||
2. F-10 Authelia-Drift-Diff in posture-check ergaenzen.
|
||||
3. F-11 Immich-Restore-Test einrichten (analog zu vaultwarden/gitea/paperless).
|
||||
4. F-06 Hermes-Entscheidung mit 60-Tage-Deadline schriftlich.
|
||||
- **Erfolgskriterium:** Komodo laesst sich aus Repo allein wiederherstellen. Posture-Check zeigt `authelia_config_drift: false`. Immich-Restore-Report unter `/mnt/user/backups/restore-reports/`.
|
||||
- **Validierung:** Trockenversuch (Komodo-Container stoppen, `docker compose up -d` aus `services/komodo-bootstrap/`).
|
||||
- **Rollback:** Bootstrap-Verzeichnis loeschen, Komodo-Self-Stack wie vorher.
|
||||
|
||||
## Sprint 3 — Backup & Restore belastbar machen (2–3 Wochen)
|
||||
|
||||
- **Ziel:** 3-2-1 echt, Restore-Tests breiter, Stack-ENV im DR-Pfad.
|
||||
- **Aufgaben:**
|
||||
1. F-03 Zweitziel: Wechselplatten-Rotation dokumentieren ODER zweites Borg-Repo (rsync.net / BorgBase EU2).
|
||||
2. F-20 Stack-ENV-Liste in DR-Doc explizit machen (Restore-Reihenfolge).
|
||||
3. Borg-Verifikation Cron fuer `borg check --repository-only` weekly (STORAGE_LAYOUT 8.4).
|
||||
4. Quartalsweise End-to-End-Restore-Drill in Schedule aufnehmen.
|
||||
- **Erfolgskriterium:** Beide Off-Site-Ziele < 7 Tage alt; DR-Doc enthaelt "ENV-Restore-Reihenfolge".
|
||||
- **Validierung:** `borg list` gegen beide Repos.
|
||||
|
||||
## Sprint 4 — Monitoring & Alerting ausbauen (2 Wochen)
|
||||
|
||||
- **Ziel:** Sichtbarkeit auf das, was wirklich weh tut.
|
||||
- **Aufgaben:**
|
||||
1. F-08 Alert-Regeln: `BorgArchiveStale`, `TLSCertExpiryNear`, `ContainerDown`, `PostgresConnSaturation`.
|
||||
2. F-15 Healthchecks fuer Traefik, Authelia, Postgres, Komodo, Gitea.
|
||||
3. F-07 Digest-Pin in `monitoring/docker-compose.yml`.
|
||||
4. Family-View-Dashboard in Grafana (1 Panel: Service-Up, 1 Panel: Backup-Frische, 1 Panel: Cert-Tage, 1 Panel: Disk-Fuellung).
|
||||
- **Erfolgskriterium:** Family-View zeigt alles gruen; Cert-Alert feuert in Test (Datum vorgespult).
|
||||
- **Validierung:** Dashboard sichtbar unter `monitoring.kaleschke.info/d/family-view`.
|
||||
|
||||
## Sprint 5 — Auth-Konsolidierung & Frontdoor-Haertung (3–4 Wochen)
|
||||
|
||||
- **Ziel:** SSO fuer die Familie, Brute-Force-Bouncer vor oeffentlichen Apps.
|
||||
- **Aufgaben:**
|
||||
1. F-13 Authelia OIDC-Provider aktivieren.
|
||||
2. Nextcloud OIDC-Plugin + Test-Login.
|
||||
3. Immich OIDC + Test-Login.
|
||||
4. Grafana OIDC + Test-Login.
|
||||
5. F-14 CrowdSec-Bouncer vor Traefik.
|
||||
6. F-18 Nextcloud-Haertung-Dokument + 2FA-Pflicht.
|
||||
- **Erfolgskriterium:** Familien-Konto loggt sich mit einem Login bei drei Apps ein; CrowdSec sperrt Test-IP nach N fehlerhaften Versuchen.
|
||||
- **Validierung:** OIDC-Sequenz im Browser ohne Eingabe-Wiederholung; CrowdSec-Dashboard zeigt Sperre.
|
||||
|
||||
## Sprint 6 — Automatisierung und Nerd-Level (laufend)
|
||||
|
||||
- **Ziel:** Image-Update-Pipeline, optional Staging.
|
||||
- **Aufgaben:**
|
||||
1. F-12 Renovate-Bot gegen Gitea.
|
||||
2. F-19 Mem-Limits Tier-1.
|
||||
3. Restore-Test-CI via Gitea Actions (P3).
|
||||
4. Optional: Staging-Branch + zweites Komodo-Ziel in Tailscale-VM (P3).
|
||||
5. Optional: Firefly III / Actual Budget fuer `/mnt/user/finance`.
|
||||
- **Erfolgskriterium:** Renovate-PRs erscheinen woechentlich; mindestens ein automatisches Patch-Update gemerged.
|
||||
|
||||
---
|
||||
|
||||
# H. Fehlende Informationen
|
||||
|
||||
> Nur, was den Audit konkret schaerfer machen wuerde.
|
||||
|
||||
| Frage | Warum | Bereich | Kommando / Datei |
|
||||
|---|---|---|---|
|
||||
| CPU-Modell, RAM-Groesse, Mainboard | Hardware-Bewertung, OOM-Risiko, Immich-ML-Eignung, AVX-Verfuegbarkeit | Hardware | `cat /proc/cpuinfo \| grep -E 'model name\|flags'`, `free -h`, `dmidecode -t baseboard \| head -20` |
|
||||
| USV vorhanden? Modell? | DR-Beurteilung Power-Loss, Shutdown-Pfad | Hardware/DR | physische Sichtpruefung; `apcaccess` falls APC mit NUT |
|
||||
| Stromverbrauch idle / Last | Betriebskosten, Sizing | Hardware | Smartmeter / Tibber-API |
|
||||
| NIC-Speed (1 GbE? 2.5 GbE?) | Backup-Durchsatz, Plex-Streaming | Netzwerk | `ip -br link`, `ethtool eth0` |
|
||||
| Disk-Inventar (Anzahl, Modelle, Alter) | Storage-Health, Replacement-Plan | Storage | `lsblk -o NAME,SIZE,MODEL,SERIAL`, Scrutiny-UI |
|
||||
| Aktueller Cache-Fuellstand | Wann zweite NVMe? | Storage | `df -h /mnt/cache` |
|
||||
| FritzBox-Modell + Firmware | Net-Sicherheit, VLAN-Faehigkeit | Netzwerk | FritzBox-UI / `fritzconnection` |
|
||||
| Tatsaechlich genutzte Plex- vs. ungenutzte App | Konsolidierungs-Belege | App-Landschaft | Plex-Server-Logs, ggf. Glances-Container-CPU pro Stack |
|
||||
| Existiert `/mnt/user/finance/`-Share schon? | Ist Firefly-Vorbereitung trivial? | Storage | `ls /mnt/user/finance/` |
|
||||
| Authelia Live-User-DB-Tiefe (Anzahl User, 2FA-Status) | 2FA-Coverage-Bewertung | Security | `cat /mnt/user/appdata/authelia/config/users_database.yml` (nur Strukturansicht, keine Hashes hier zitieren) |
|
||||
| Komodo-Mongo-Dump letzter Integrity-Check | F-09-Vorbereitung | Backup | `mongorestore --dry-run --archive=komodo-mongo.archive.gz --gzip` |
|
||||
| Aktuelle Cert-Restlaufzeit | F-08-Test-Vorbereitung | Operations | `openssl s_client -connect git.kaleschke.info:443 -servername git.kaleschke.info < /dev/null \| openssl x509 -noout -dates` |
|
||||
|
||||
---
|
||||
|
||||
# I. Pruefkommandos (Linux / Unraid / Docker / Windows)
|
||||
|
||||
> Strukturiert nach Bereich. Sicher zum Ausfuehren am Host.
|
||||
|
||||
### Hardware
|
||||
|
||||
```bash
|
||||
# CPU + Flags (AVX fuer Immich-ML)
|
||||
cat /proc/cpuinfo | awk '/model name|flags/ {print; if(/flags/) exit}'
|
||||
|
||||
# RAM
|
||||
free -h
|
||||
dmidecode -t memory | grep -E "Size|Speed" | head -20
|
||||
|
||||
# Mainboard
|
||||
dmidecode -t baseboard | head -20
|
||||
|
||||
# PCI / SATA / NVMe
|
||||
lspci
|
||||
nvme list
|
||||
lsblk -o NAME,SIZE,MODEL,SERIAL,FSTYPE,MOUNTPOINT,VENDOR
|
||||
|
||||
# SMART
|
||||
smartctl -a /dev/nvme0n1 | head -40
|
||||
smartctl -a /dev/sdb | head -40
|
||||
|
||||
# Stromverbrauch (Unraid Plugin oder ipmitool falls IPMI)
|
||||
sensors | head -30
|
||||
```
|
||||
|
||||
### Filesystem / Storage / Mounts
|
||||
|
||||
```bash
|
||||
# Filesystem-Typen (Hard Rule 12.1)
|
||||
findmnt -no FSTYPE /mnt/cache /mnt/disk1 /boot
|
||||
mount | grep -E "ntfs3|fuseblk" # darf leer sein
|
||||
|
||||
# Share-Settings
|
||||
ls -la /boot/config/shares/
|
||||
|
||||
# Cache-Fuellstand
|
||||
df -h /mnt/cache /mnt/disk1 /mnt/user
|
||||
du -sh /mnt/user/appdata/* | sort -hr | head -20
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# Container-Inventur
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | sort
|
||||
docker ps -a --filter "status=exited" --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
|
||||
|
||||
# Netzwerke
|
||||
docker network ls
|
||||
docker network inspect frontend_net | jq '.[0].Containers | keys'
|
||||
docker network inspect backend_net | jq '.[0].Internal'
|
||||
|
||||
# Volumes ohne Container (Waisen)
|
||||
docker volume ls -qf dangling=true
|
||||
|
||||
# Effektive Ports
|
||||
ss -ltnp | sort -k4
|
||||
|
||||
# Healthchecks
|
||||
docker ps --format "{{.Names}}\t{{.Status}}" | grep -E "healthy|unhealthy|starting"
|
||||
```
|
||||
|
||||
### Security
|
||||
|
||||
```bash
|
||||
# Privileged-Container und Docker-Socket-Mounts
|
||||
for c in $(docker ps -q); do
|
||||
docker inspect "$c" --format '{{.Name}}: priv={{.HostConfig.Privileged}}; sock={{range .HostConfig.Binds}}{{println .}}{{end}}'
|
||||
done | grep -E "priv=true|docker.sock"
|
||||
|
||||
# Direkte Host-Ports
|
||||
docker ps --format "{{.Names}}: {{.Ports}}" | grep -v "^[^:]*: $"
|
||||
|
||||
# Secret-Datei-Rechte
|
||||
ls -la /mnt/user/appdata/secrets/
|
||||
stat -c "%a %n" /mnt/user/appdata/secrets/*.txt
|
||||
```
|
||||
|
||||
### Backup / Restore
|
||||
|
||||
```bash
|
||||
# Borg-Frische
|
||||
ls -lat /mnt/user/backups/borg/dumps/latest/ | head
|
||||
find /mnt/user/backups/borg/dumps/latest -mmin +1440 -type f # aelter 24h
|
||||
|
||||
# Borg-Repo (Passphrase per File)
|
||||
export BORG_PASSPHRASE=$(cat /mnt/user/appdata/secrets/borg_repo_passphrase.txt)
|
||||
borg list ssh://... --short | tail -5
|
||||
borg info ssh://... ::Taegliche-Sicherung-2026-05-25T05:52:44.157
|
||||
|
||||
# Posture-Check
|
||||
cat /mnt/user/services/posture-check/last.json | jq '.warning_count, .critical_count'
|
||||
```
|
||||
|
||||
### Netzwerk / DNS
|
||||
|
||||
```bash
|
||||
# Tailscale
|
||||
tailscale status
|
||||
|
||||
# DNS auf AdGuard testen
|
||||
dig @127.0.0.1 git.kaleschke.info
|
||||
dig @127.0.0.1 example.com # Unbound-Recursion
|
||||
|
||||
# Cert-Restlaufzeit
|
||||
for h in vault git immich cloud paperless mealie ntfy; do
|
||||
echo -n "$h.kaleschke.info: "
|
||||
openssl s_client -connect ${h}.kaleschke.info:443 -servername ${h}.kaleschke.info </dev/null 2>/dev/null \
|
||||
| openssl x509 -noout -dates 2>/dev/null
|
||||
done
|
||||
```
|
||||
|
||||
### GitOps-Konsistenz
|
||||
|
||||
```bash
|
||||
# Komodo Stack-Workspace vs. Repo
|
||||
cd /mnt/user/services/stacks/<stackname>
|
||||
git rev-parse HEAD
|
||||
git status --short
|
||||
|
||||
# Webhook-Liveness
|
||||
docker logs komodo-core 2>&1 | grep -i "webhook\|deploy" | tail -20
|
||||
```
|
||||
|
||||
### Windows (lokal)
|
||||
|
||||
```powershell
|
||||
# Repo-Status
|
||||
git status --short
|
||||
git fetch origin
|
||||
git log origin/master..HEAD --oneline # ungepushte Commits
|
||||
git log HEAD..origin/master --oneline # ungepullte Commits
|
||||
|
||||
# Policy-Check
|
||||
.\ops\policy-checks\check_repo.ps1
|
||||
|
||||
# Restore-Freshness
|
||||
.\ops\restore-tests\check-restore-freshness.ps1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# J. Community- / Best-Practice-Abgleich
|
||||
|
||||
> Nur fuer die Architekturentscheidungen, bei denen der Markt eindeutig ist oder Gegenpositionen relevant sind.
|
||||
|
||||
| Entscheidung | Markt-Best-Practice | Stuetzende Quellen | Bewertung |
|
||||
|---|---|---|---|
|
||||
| Traefik mit Docker-Labels statt File-Provider | Standard in Selfhosted (siehe `awesome-selfhosted-docker`, Smarthome-Beginner Templates) | Traefik-Doc v3 docs.traefik.io/providers/docker | passt zu MASTER 13 (Wechsel 2026-03-28) |
|
||||
| DNS-Challenge mit Cloudflare statt HTTP-01 | Standard fuer Wildcard und reduzierte Angriffsflaeche | acme.sh / lego docs | passt, korrekt |
|
||||
| Authelia ForwardAuth statt Authentik | Authelia ist leichtgewichtiger, Authentik maechtiger; beide valide | r/selfhosted-Konsens 2024-25 | Authelia richtig fuer Single-Family-Setup |
|
||||
| Authelia ohne Redis-Session-Backend | Markt-Standard ist mit Redis; deine Vereinfachung ist begruendet (MASTER 13 2026-05-04) | Authelia-Doc | Trade-off klar; Bewertung: vertretbar fuer Homelab |
|
||||
| Komodo statt Portainer/Dockge | Komodo ist neuer (2024), Dockge etabliert, Portainer kommerziell | Selfh.st 2025 Comparison | Komodo legitim, mehr GitOps-nativ als Dockge |
|
||||
| Borg statt Restic/Kopia | Borg ist klassische Wahl fuer Linux-Backup mit Deduplikation; Kopia/Restic gewinnen mit Multi-Backend | r/datahoarder, ServeTheHome 2024 | Borg passt zu Unraid-Stack; bewusste Vereinfachung |
|
||||
| Glance statt Homepage als Single-Dashboard | beide auf Augenhoehe; Homepage etablierter, Glance moderner, schneller, mit Live-Widgets | github.com/glanceapp/glance vs. github.com/gethomepage/homepage | Glance legitim; Bewertung: deine Wahl ist verteidigbar |
|
||||
| Immich nicht hinter Authelia ForwardAuth | offizielle Immich-Doku raet bei nativer App-Auth davon ab, weil Sync-Clients OIDC oder direkte Auth brauchen | immich.app/docs/administration/reverse-proxy | korrekt; OIDC spaeter (F-13) ist der Weg |
|
||||
| Nextcloud klassisch statt AIO | NC-AIO ist offizielle Empfehlung fuer Neuaufbau, klassisch hat mehr Flexibilitaet fuer GitOps | NC-Blog 2024 | bewusste Ausnahme MASTER 13; vertretbar, da GitOps-Anbindung wichtiger |
|
||||
| Single-Host + Borg statt Proxmox-Cluster + ZFS-Send | fuer Familien-Homelab ist Cluster Overkill | r/homelab, LTT-Forum | Single-Host korrekt; Cold-Standby (Sprint 6) ist die richtige naechste Stufe |
|
||||
| AdGuard + Unbound statt Pi-hole | aequivalent; AdGuard hat moderne UI, Unbound recursion | gowri/networkchuck Tutorials 2024 | passt |
|
||||
| Posture-Check als Skript statt Goss/InSpec | fuer Single-Host pragmatisch | Goss ist maechtiger, aber Overkill | Skript-Loesung legitim |
|
||||
| Renovate gegen Gitea | mehrere Erfahrungsberichte in Gitea-Issues + Renovate-Docs | docs.renovatebot.com/modules/platform/gitea/ | Standard-Pfad |
|
||||
| CrowdSec vor Traefik | starker Trend 2024-25 in Selfhosted-Community | crowdsec.net/blog, Marius-Hosting-Tutorials | sinnvolle Haertung |
|
||||
|
||||
**Gegenpositionen, die du kennen solltest:**
|
||||
|
||||
- **"Authelia OIDC ist kompliziert, lieber Authentik."** Korrekt, wenn du auch B2B-SAML brauchst. Fuer reine Familien-OIDC ist Authelia leichter wartbar.
|
||||
- **"CrowdSec laedt zentrale Reputation-Listen -> Privacy-Bedenken."** Stimmt, du kannst Local-Only-Mode fahren. Fuer Homelab egal.
|
||||
- **"Renovate-Bot erzeugt Laerm."** Mit Group/Schedule-Rules zaehmbar. Wert > Laerm.
|
||||
- **"Komodo ist zu jung."** Gegenargument: du benutzt es seit Q1/2026 produktiv, Major-Inzidenz war beherrschbar. Der Wechsel zurueck zu Portainer/Dockge waere hoehere Kosten als der Reifegrad-Nachteil.
|
||||
|
||||
---
|
||||
|
||||
# K. Endziel — "Nerd-Level Homelab"
|
||||
|
||||
So sieht dein Homelab aus, wenn es wirklich auf Senior-Level ist:
|
||||
|
||||
**Betrieb im Alltag**
|
||||
|
||||
- Morgens 30 Sekunden auf `monitoring.kaleschke.info`: Family-View zeigt 7 Tier-1-Services gruen, Backup-Job in der Nacht hat 100 % Files erfasst, alle Certs > 30 Tage, Disk < 80 %.
|
||||
- Push-Benachrichtigung auf dem Handy nur, wenn wirklich etwas brennt (Posture-Check critical, Borg > 30 h, Endpoint down ≥ 8 Min, Cert < 14 Tage).
|
||||
- Familienmitglieder loggen sich mit einem Login bei Nextcloud, Immich, Mealie ein. 2FA per TOTP-App, kein App-by-App-Passwortzettel mehr.
|
||||
|
||||
**Updates**
|
||||
|
||||
- Renovate oeffnet woechentlich 5–10 Pull-Requests in Gitea fuer Patch-Versionen. Du siehst sie im Web-UI, pruefst Release Notes, klickst Merge. Komodo deployt automatisch via Webhook. Smoke-Test in der naechsten Glance-Seite.
|
||||
- Major-Updates kommen separat mit Label `major`, behandelst du in einem geplanten Slot mit Restore-Snapshot davor.
|
||||
|
||||
**Backups**
|
||||
|
||||
- Borg lokal alle 6 h, Borg Hetzner taeglich, Wechselplatte monatlich. Borg-Passphrase auf Papier im Bankschliessfach. Alle drei Ziele juenger als 36 h Alert-Schwelle.
|
||||
- Pre-Dump-Hooks erzeugen 15 konsistente Dump-Artefakte pro Lauf, automatische Posture-Check-Vor-Hook bricht Backup ab, wenn FS oder Mount sich veraendert haben.
|
||||
|
||||
**Restore**
|
||||
|
||||
- Monatlicher Mini-Restore-Test fuer Vaultwarden/Gitea/Paperless/Immich nach `/mnt/user/backups/restore-lab/<dienst>/` laeuft automatisiert per Gitea Actions, Erfolgs-Report landet als Datei + ntfy-Info.
|
||||
- Quartalsweise End-to-End-Drill: ein kompletter Stack restauriert, App startet, Smoke-Test passt, Doku validiert. Komodo-Bootstrap-Pfad ist getestet.
|
||||
- Im Ernstfall folgst du `docs/DISASTER_RECOVERY.md` Phase 0–5 und bist nach < 8 h wieder im Vollbetrieb. Repo-Bootstrap aus GitHub-Mirror, Stacks in Stufen 1–5, Verifikation pro Stufe.
|
||||
|
||||
**Monitoring**
|
||||
|
||||
- Prometheus + Loki + Grafana + Alertmanager-ntfy-Bridge. ~15 Alert-Regeln, alle in `alerts.yml` versioniert.
|
||||
- Family-View, Host-Overview, Containers+Logs, Traefik-Standalone, Backup-Frische, Cert-Tage. Sechs Dashboards mehr braucht keine Familie.
|
||||
- Loki sammelt 30 Tage Logs aus allen Containern via Promtail. cAdvisor + node-exporter liefern Container- und Host-Metriken. Blackbox testet oeffentliche Endpoints alle 60 s.
|
||||
|
||||
**Security**
|
||||
|
||||
- Authelia OIDC fuer Nextcloud, Immich, Grafana, Mealie. ForwardAuth fuer Operator-UIs mit 2FA-Pflicht ab Tier-2.
|
||||
- CrowdSec sperrt Brute-Force-IPs auf Traefik-Ebene bevor sie die Apps treffen.
|
||||
- AdGuard-Admin nur via Tailscale. Operator-Pfad ausschliesslich Tailscale.
|
||||
- Authelia-Repo-Baseline und Host-Config sind per Diff-Check im posture-check abgesichert.
|
||||
- Secrets-Mounts mode 600, Borg-Passphrase analog.
|
||||
|
||||
**Dokumentation**
|
||||
|
||||
- SERVICE_CATALOG, RESTORE_MATRIX, DISASTER_RECOVERY, STORAGE_LAYOUT, SECRETS_MAP, WORKFLOW, GITOPS_DRIFT_RUNBOOK, ALERTING_MAP, HOMELAB_ARCHITECTURE_MASTER bleiben aktuelle Single-Source-of-Truth.
|
||||
- `services/komodo-bootstrap/` loest das Henne-Ei. `SERVICES_RECOVERY.md` ist final, nicht Draft.
|
||||
- Familien-Onboarding-Doku als Markdown: "So nutzt du Nextcloud-Web", "So aktivierst du Immich-Foto-Backup auf dem Handy", "So loggst du dich neu per 2FA ein".
|
||||
|
||||
**Taegliche Nutzung**
|
||||
|
||||
- Familie scannt Briefe per ASN-Barcode in `scans_inbox/`, Paperless tagged via paperless-gpt automatisch, alles durchsuchbar.
|
||||
- Immich erfasst Smartphone-Fotos aller Familienmitglieder automatisch, Familie blaettert per Web/App, Tagging per ML.
|
||||
- Nextcloud traegt Kalender, Kontakte, geteilte Familienordner per WebDAV/CardDAV — kein Google/Apple-Lock-In.
|
||||
- Mealie speichert Rezepte, Einkaufsliste auf dem Handy.
|
||||
- Vaultwarden ist der einzige Passwort-Tresor der Familie; Familien-Organisation aktiv.
|
||||
- Plex streamt Heim-Medien an alle Endgeraete.
|
||||
- ntfy schickt dir Vorfaelle aufs Handy — und sonst nichts.
|
||||
- Optional: Firefly III fuer die Familien-Finanzen, Ecowitt-Wetter-Dashboard, Home-Assistant-Automationen fuer Strom-Eigenverbrauch.
|
||||
|
||||
**Was bewusst weggelassen ist**
|
||||
|
||||
- Kein Kubernetes. Komodo + Compose reicht.
|
||||
- Kein zweiter Medienserver neben Plex (Jellyfin-Entscheidung 2026-05-25).
|
||||
- Kein zweites Dashboard neben Glance (Homepage-Entscheidung 2026-05-25).
|
||||
- Kein Uptime-Kuma neben Blackbox (Entscheidung 2026-05-25).
|
||||
- Kein Hermes-Agent, wenn er bis 2026-07-25 keinen klaren Alltagsnutzen liefert.
|
||||
- Kein BentoPDF/paperless-gpt 24/7, wenn nicht aktiv genutzt.
|
||||
- Kein Self-Stack-Komodo (durch `services/komodo-bootstrap/` ersetzt).
|
||||
|
||||
---
|
||||
|
||||
## Schlussbemerkung
|
||||
|
||||
Das Setup ist naeher an Senior-Reife als an Bastel-Niveau. Der groesste Hebel der naechsten drei Monate ist **Konsolidieren statt erweitern** (Hermes-Entscheidung, Altstaende raus, Auth-SSO, Off-Site-Diversitaet), kombiniert mit der einen Aktivierung, die das Setup vom Operator-Tool zum **Familien-Tool** macht: Immich-Smartphone-Backup fuer alle.
|
||||
|
||||
Das vorhandene 2026-05-23-Audit hat die richtigen Sprintziele bereits identifiziert. Diese externe Audit-Sicht ergaenzt:
|
||||
|
||||
- **2FA-Pflicht auf Tier-1-Operator-UIs** (F-04) — fehlt in der Bewertung 2026-05-23 in dieser Klarheit.
|
||||
- **Healthcheck-Luecke** (F-15) und **fehlende Mem-Limits** (F-19) — operative Detail-Findings, die in der strategischen Bewertung nicht auftauchen.
|
||||
- **Komodo-Self-Bootstrap als konkreter Code-Vorschlag** (F-09) statt nur als Risiko-Erwaehnung.
|
||||
- **Authelia-Drift-Detection automatisieren** (F-10) statt nur "manuell merge".
|
||||
- **Monitoring-Stack ohne Digest-Pin** (F-07) — Inkonsistenz mit der eigenen Image-Pinning-Disziplin.
|
||||
- **`infra/redis` ist faktisch nicht shared** (F-16) — Etikett-Realitaet-Drift.
|
||||
- **Alert-Regeln deutlich zu duenn** (F-08) — Sichtbarkeitsluecken bei Cert/Borg/Container-Down.
|
||||
|
||||
Wenn Sprint 1–3 in 4–6 Wochen sitzen, bist du auf einer 1-Note. Wenn dann Sprint 4–5 in weiteren 6–8 Wochen kommen, hat die Familie ein echtes Self-Hosting-System, kein "Container-Sammlung im Keller". Das ist der Unterschied, den der Audit-Auftrag adressiert.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Audit TODO 2026-05-25
|
||||
|
||||
Quelle: `docs/AUDIT_2026-05-25.md`
|
||||
|
||||
Status: Arbeitsliste fuer die Umsetzung. Authelia-2FA/OIDC bleibt bewusst spaet, weil die Ziel-Policy noch nicht final entschieden ist.
|
||||
|
||||
## Leitplanken
|
||||
|
||||
- Keine Authelia-2FA-ACL-Aenderungen in den ersten Sprints.
|
||||
- Keine Live-riskanten Bind-/Port-Aenderungen ohne vorher erfasste Host-Werte, insbesondere Tailscale-IP.
|
||||
- Erst Inventar und Baseline, dann Aenderungen.
|
||||
- Jede produktive Aenderung bekommt Validierung und Rollback-Hinweis.
|
||||
|
||||
## Naechster Startpunkt 2026-05-26
|
||||
|
||||
Kontext bewusst gesichert, bevor weitere Live-Aenderungen passieren:
|
||||
|
||||
1. USV-Entscheidung treffen: aktuell ist keine funktionierende USV-Abschaltung nachgewiesen.
|
||||
2. Gitea-Bundle-/Mirror-Mechanik und Borg-Passphrase-Offsite-Sicherung entscheiden.
|
||||
3. Authelia 2FA/OIDC weiterhin nicht anfassen; das bleibt bewusst der letzte Block.
|
||||
|
||||
## Sprint 0 - Inventar und Baseline
|
||||
|
||||
| Status | Aufgabe | Ergebnis |
|
||||
|---|---|---|
|
||||
| erledigt | Hardware-Inventar ausfuellen | CPU, RAM, Mainboard, BIOS, NIC, Controller, Disks, SMART und Capacity-Baseline erfasst; USV ist als nicht validiert dokumentiert |
|
||||
| in Arbeit | Netzwerk-Inventar ausfuellen | Host-IP, Gateway, Tailscale-IP und AdGuard-Bind erfasst; Router-/VLAN-Details offen |
|
||||
| erledigt (Baseline) | Externe Abhaengigkeiten dokumentieren | `docs/EXTERNAL_DEPENDENCIES.md` enthaelt bekannte Provider, Kritikalitaet, Ausfallplaene; Account-Recovery-Codes/Zahlungswege bleiben Off-Repo-Operatorcheck |
|
||||
| erledigt (Baseline) | Services-Recovery-Pfade beschreiben | `docs/SERVICES_RECOVERY.md` enthaelt Gitea-/Komodo-/Secrets-Sonderpfade; Gitea-Bundle-/Mirror-Mechanik bleibt als Umsetzungsentscheidung offen |
|
||||
| erledigt | Baseline-Tag setzen | `audit-2026-05-25-baseline` ist lokal und remote vorhanden |
|
||||
| erledigt | Policy-Check neu ausfuehren | SEC001-Warnings aus altem Report sind nicht mehr aktuell |
|
||||
|
||||
## Sprint 1 - Nicht-kontroverse Sicherheits- und Repo-Hygiene
|
||||
|
||||
| Status | Aufgabe | Ergebnis |
|
||||
|---|---|---|
|
||||
| offen | Borg-Passphrase analog sichern | Passphrase ist ohne Host/Vaultwarden wiederherstellbar |
|
||||
| erledigt (repo) | AdGuard Admin-Bind vorbereiten | Tailscale-IP `100.80.98.33` erfasst, Compose-Soll geaendert |
|
||||
| erledigt | AdGuard Admin-Port auf Tailscale-IP binden | Live validiert: `ss -ltnp` zeigt `100.80.98.33:8082`, DNS auf Port 53 funktioniert, LAN-Zugriff auf `192.168.178.58:8082` schlaegt fehl |
|
||||
| erledigt | Alte Monitoring-Verzeichnisse entfernen | `ops/grafana-influxdb/` und `ops/loki/` sind aus dem aktiven Repo entfernt; Rollback erfolgt ueber Git-Historie |
|
||||
| erledigt | Komodo/Gitea-Restdrift bereinigen | alter Komodo-Stack `grafana` ist inert und ohne Repo-Pfad/Webhook; Gitea-Hook `35` und `komodo`-Self-Hook `11` sind inaktiv; aktive Gitea-Hooks haben keine Fehlstatus |
|
||||
| erledigt | Policy-Warnings triagieren | Plex Host-Netz und digest-gepinnte mutable Tags sind dokumentierte Info-Ausnahmen; `monitoring-influxdb3-core` als Root-Ausnahme bleibt bewusst als Warning sichtbar |
|
||||
|
||||
## Sprint 2 - Storage und Recovery verbindlich machen
|
||||
|
||||
| Status | Aufgabe | Ergebnis |
|
||||
|---|---|---|
|
||||
| offen | `docs/STORAGE_LAYOUT.draft.md` finalisieren | Datei wird als `docs/STORAGE_LAYOUT.md` Active gefuehrt |
|
||||
| offen | Disk- und Share-TBDs eintragen | Modelle, Groessen, Seriennummern, Filesysteme und Cache-Settings sind dokumentiert |
|
||||
| offen | Gitea-Repo-Mirror-Mechanik definieren | Mirror fuer `/mnt/user/services/gitea/git/repositories/` mit Frequenz <= 6 h ist spezifiziert |
|
||||
| offen | Komodo-Bootstrap-Pfad beschreiben | Kaltstart ohne laufendes Komodo ist dokumentiert |
|
||||
| offen | Immich-Restore-Test planen | Testumfang, Datenpfade und Smoke-Test-Kriterium stehen fest |
|
||||
|
||||
## Sprint 3 - Restore und Monitoring
|
||||
|
||||
| Status | Aufgabe | Ergebnis |
|
||||
|---|---|---|
|
||||
| offen | Immich-Restore-Test implementieren | Restore-Report landet unter `/mnt/user/backups/restore-reports/` |
|
||||
| offen | Borg-Stale-Alert bauen | Alarm feuert, wenn Borg-Archiv zu alt ist |
|
||||
| offen | TLS-Cert-Expiry-Alert bauen | Alarm feuert bei Restlaufzeit unter Schwellwert |
|
||||
| offen | Container-Down-Alert bauen | Unerwartet fehlende Container werden sichtbar |
|
||||
| offen | Family-View Dashboard definieren | Uptime, Backup-Frische, Cert-Tage, Disk-Fuellung auf einer Seite |
|
||||
|
||||
## Sprint 4 - Familien- und Betriebsdoku
|
||||
|
||||
| Status | Aufgabe | Ergebnis |
|
||||
|---|---|---|
|
||||
| offen | Familien-Onboarding schreiben | Nextcloud, Immich, Vaultwarden, 2FA-Verlust, Ausfallverhalten kurz erklaert |
|
||||
| erledigt (Baseline) | Capacity-/Lifecycle-Review erstellen | Cache 6 %, Array/User-Shares 33 %, lokale Backups 2.2G; externe Backup-/Cold-Storage-Groessen bleiben offen |
|
||||
| offen | USV-Test oder USV-Entscheidung | Power-Loss-Verhalten ist bekannt und dokumentiert |
|
||||
|
||||
## Sprint 5 - Auth und Frontdoor, bewusst zuletzt
|
||||
|
||||
| Status | Aufgabe | Ergebnis |
|
||||
|---|---|---|
|
||||
| geparkt | Authelia 2FA fuer Operator-UIs erweitern | Erst nach finaler Policy-Entscheidung |
|
||||
| geparkt | Authelia OIDC fuer Apps pruefen | Erst nach Familien-/Client-Auswirkungsanalyse |
|
||||
| geparkt | CrowdSec vor Traefik pruefen | Nach stabiler Auth-/Monitoring-Basis |
|
||||
|
||||
## Offene Host-Werte
|
||||
|
||||
Diese Werte muessen am Unraid-Host erhoben werden, bevor die entsprechenden Aenderungen sauber umgesetzt werden:
|
||||
|
||||
```bash
|
||||
hostname
|
||||
cat /proc/cpuinfo | awk '/model name|flags/ {print; if(/flags/) exit}'
|
||||
free -h
|
||||
dmidecode -t baseboard | head -30
|
||||
ip -br link
|
||||
tailscale ip -4
|
||||
lsblk -o NAME,SIZE,MODEL,SERIAL,FSTYPE,MOUNTPOINT,VENDOR
|
||||
df -h /mnt/cache /mnt/disk1 /mnt/user
|
||||
smartctl -a /dev/nvme0n1 | head -80
|
||||
smartctl -a /dev/sdb | head -80
|
||||
```
|
||||
@@ -0,0 +1,80 @@
|
||||
# Audit Report - KalliLab CORE
|
||||
Datum: 2026-05-16
|
||||
Gepruefte Compose-Dateien: 30
|
||||
|
||||
## Kritische Befunde
|
||||
|
||||
Keine kritischen Befunde im Repo-Sollzustand gefunden.
|
||||
|
||||
- Keine Datenbank und kein Cache haengt in `frontend_net`.
|
||||
- Keine produktive Compose-Datei ohne explizites `networks:`-Feld gefunden.
|
||||
- Keine produktive `.env`- oder `stack.env`-Datei ohne `.example`-Suffix im Repository gefunden.
|
||||
- Keine Klartext-Passwoerter, Tokens oder API-Keys in Compose-`environment:`-Bloecken gefunden.
|
||||
|
||||
## Mittlere Befunde
|
||||
|
||||
- Docker-Socket ausserhalb der im Audit-Prompt genannten Allowlist: `traefik` mountet `/var/run/docker.sock:/var/run/docker.sock:ro` in `traefik/docker-compose.yml:34`. Der Socket ist fuer den Docker-Provider fachlich nachvollziehbar, aber in `HOMELAB_ARCHITECTURE_MASTER_V2.md` Abschnitt 10 nicht explizit als Docker-Socket-Ausnahme aufgefuehrt.
|
||||
- Docker-Socket ausserhalb der im Audit-Prompt genannten Allowlist: `alloy` mountet `/var/run/docker.sock:/var/run/docker.sock:ro` in `ops/loki/docker-compose.yml:26`. [AUSNAHME - dokumentiert] in `HOMELAB_ARCHITECTURE_MASTER_V2.md` Abschnitt 10 als `alloy` Docker-Socket read-only, aber nicht in der Prompt-Allowlist genannt.
|
||||
- `komodo-periphery` haengt ohne Web-UI im `frontend_net` in `ops/komodo/docker-compose.yml:92-94`. Die Compose-Datei dokumentiert an `ops/komodo/docker-compose.yml:77-79` den Grund als Git-Zugriff auf internes Gitea und Docker-Agent-Sonderfall. [AUSNAHME - lokal in Compose dokumentiert]
|
||||
- `hermes_net` ist ein app-internes Netz ohne `internal: true`: `ops/hermes-agent/docker-compose.yml:91-93`. Das ist nicht Teil der explizit geforderten `internal: true`-Liste, aber strukturell auffaellig, weil Gateway und Dashboard intern ueber dieses Netz sprechen.
|
||||
- `grafana` nutzt zusaetzlich `backend_net` in `ops/grafana-influxdb/docker-compose.yml:27-30`, obwohl die Architektur das Grafana/Influx-Paar primaer als `frontend_net` + `grafana_influx_internal` beschreibt. Grund ist vermutlich Loki-Datasource-Zugriff; `docs/REPO_MAP.md` nennt `backend_net` fuer Grafana-Loki-Datasource. [AUSNAHME - dokumentiert]
|
||||
- `paperless-gpt` verwendet `PAPERLESS_API_TOKEN` als Stack-ENV in `apps/paperless-gpt/docker-compose.yml:15`, taucht aber in `docs/SECRETS_MAP.md` nicht als eigener aktiver Secret-Eintrag auf.
|
||||
|
||||
## Hinweise
|
||||
|
||||
- Direkte Host-Ports sind nur bei dokumentierten Ausnahmen vorhanden:
|
||||
- [AUSNAHME - dokumentiert] `traefik`: `80:80`, `443:443` in `traefik/docker-compose.yml:30-32`.
|
||||
- [AUSNAHME - dokumentiert] `gitea`: `222:22` in `core/gitea/docker-compose.yml:17-18`.
|
||||
- [AUSNAHME - dokumentiert] `adguard`: `53:53/tcp`, `53:53/udp`, `8082:80` in `host-services/Adguard/docker-compose.yml:13-16`.
|
||||
- [AUSNAHME - dokumentiert] `influxdb3-core`: `${INFLUXDB_BIND_IP:-127.0.0.1}:8181:8181` in `ops/grafana-influxdb/docker-compose.yml:54-55`.
|
||||
- `backend_net` wird in allen Compose-Dateien als `external: true` referenziert, z. B. `infra/postgresql17/docker-compose.yml:25-26`, `infra/redis/docker-compose.yml:22-23`, `traefik/docker-compose.yml:59-60`. Ob das externe Docker-Netz live wirklich `internal: true` ist, ist aus dem Repo allein nicht verifizierbar.
|
||||
- `grafana_influx_lan` hat bewusst kein `internal: true`: `ops/grafana-influxdb/docker-compose.yml:88-89`. [AUSNAHME - dokumentiert]
|
||||
- Mutable Tags sind mit Digests gepinnt, aber semantisch weiterhin mutable:
|
||||
- `immich-server`: `release@sha256` in `apps/immich/docker-compose.yml:4`.
|
||||
- `immich-machine-learning`: `release@sha256` in `apps/immich/docker-compose.yml:35`.
|
||||
- `tailscale`: `stable@sha256` in `host-services/tailscale/docker-compose.yml:3`.
|
||||
- `ddns-updater`: `latest@sha256` in `infra/ddns-updater/docker-compose.yml:3`.
|
||||
- `komodo-core`: Major-Tag `2@sha256` in `ops/komodo/docker-compose.yml:36`.
|
||||
- `komodo-periphery`: Major-Tag `2@sha256` in `ops/komodo/docker-compose.yml:82`.
|
||||
- `uptime-kuma`: Major-Tag `1@sha256` in `ops/uptime-kuma/docker-compose.yml:3`.
|
||||
- Reines `image: name:latest` ohne Digest wurde in produktiven Compose-Dateien nicht gefunden.
|
||||
- Alle Services haben `restart: unless-stopped`.
|
||||
- Alle Services haben `security_opt: no-new-privileges:true`.
|
||||
- [AUSNAHME - dokumentiert] `scrutiny` nutzt `privileged: true` in `ops/scrutiny/docker-compose.yml:6`.
|
||||
- [AUSNAHME - dokumentiert] `tailscale` nutzt `network_mode: host` in `host-services/tailscale/docker-compose.yml:6`.
|
||||
|
||||
## Dokumentations-Abweichungen
|
||||
|
||||
- Service-Namen in Compose vs. `docs/SERVICE_CATALOG.md` weichen bei Immich ab: Compose nutzt `immich-server`, `immich-machine-learning`, `database`, `redis` in `apps/immich/docker-compose.yml:2`, `:33`, `:44`, `:53`; der Katalog dokumentiert `immich_server`, `immich_machine_learning`, `immich_postgres`, `immich_redis`.
|
||||
- Service-Name in Compose vs. `docs/SERVICE_CATALOG.md` weicht bei Paperless ab: Compose nutzt `paperless` in `apps/paperless/docker-compose.yml:2`; der Katalog dokumentiert `paperless-ngx`.
|
||||
- Service-Name in Compose vs. `docs/SERVICE_CATALOG.md` weicht bei Redis ab: Compose nutzt `redis` in `infra/redis/docker-compose.yml:2`; der Katalog dokumentiert `Redis`.
|
||||
- Traefik-Host bei Hermes ist im Compose variabel: `Host(`${HERMES_DASHBOARD_HOST}`)` in `ops/hermes-agent/docker-compose.yml:81`; `docs/REPO_MAP.md` dokumentiert konkret `hermes.kaleschke.info`. Das ist plausibel, aber maschinell nicht eindeutig abgleichbar.
|
||||
- `docs/REPO_MAP.md` Volumes-Tabelle ist fuer mehrere Mounts nur zusammenfassend, nicht pfadgenau. Beispiele mit Compose-Pfaden, die dort nicht wortgleich auftauchen:
|
||||
- `/mnt/user/appdata/unbound/config` in `apps/unbound/docker-compose.yml:7`.
|
||||
- `/mnt/user/appdata/ddns-updater` in `infra/ddns-updater/docker-compose.yml:17`.
|
||||
- `/mnt/user/appdata/filebrowser/database` und `/mnt/user/appdata/filebrowser/config` in `ops/filebrowser/docker-compose.yml:15-16`.
|
||||
- `/mnt/user/appdata/borg-ui/restore` in `ops/borg-ui/docker-compose.yml:27`.
|
||||
- Netzwerke in Compose sind alle in `docs/REPO_MAP.md` aufgefuehrt.
|
||||
- Traefik-Hosts aus Compose sind in `docs/REPO_MAP.md` aufgefuehrt; einzige Besonderheit ist der variable Hermes-Host.
|
||||
- `.example`-Dateien haben passende Gegenspieler in `.gitignore`: `.env`, `*.env`, `!*.env.example`, `**/stack.env`, `!**/stack.env.example`.
|
||||
- Keine `.keep`-Platzhalter-Dateien gefunden.
|
||||
|
||||
## Offene Architektur-Punkte: aktueller Stand
|
||||
|
||||
- `immich_redis`: weiterhin kein named volume. Compose-Service `redis` in `apps/immich/docker-compose.yml:44-50` hat aktuell gar keinen `volumes:`-Block. Architekturpunkt bleibt offen.
|
||||
- `AdGuard Home`: Admin-Port ist weiterhin direkt veroeffentlicht: `8082:80` in `host-services/Adguard/docker-compose.yml:15`; keine Traefik-Labels vorhanden. Block F bleibt offen.
|
||||
- `filebrowser`: Appdata-Breitmount ist entfernt; aktuelle Mounts sind `/mnt/user/documents`, `/mnt/user/photos`, `/mnt/user/projekte` plus eigene DB/Config in `ops/filebrowser/docker-compose.yml:12-16`. Langfristiges Hardening bleibt moeglich, aber der groesste alte Breitmount ist erledigt.
|
||||
- `bentopdf`: Compose ist vorhanden und Traefik-abgesichert in `apps/bentopdf/docker-compose.yml:2-27`. Runtime-Deploy und fachliche Abnahme koennen aus dem Repo allein nicht bestaetigt werden; Architekturpunkt bleibt als Live-Pruefung offen.
|
||||
- `grafana` und `influxdb3-core`: laufen weiterhin als `user: "0"` in `ops/grafana-influxdb/docker-compose.yml:6` und `ops/grafana-influxdb/docker-compose.yml:53`. [AUSNAHME - dokumentiert]
|
||||
- `plex-media-server`: keine Compose-Datei im Repo gefunden; bleibt Dockerman-/Host-Sonderfall laut Architektur.
|
||||
|
||||
## Bestanden
|
||||
|
||||
- 30 produktive `docker-compose.yml` wurden geprueft.
|
||||
- Keine Datenbanken oder Caches im `frontend_net`: `postgresql17` nur `backend_net` in `infra/postgresql17/docker-compose.yml:18`; shared `redis` nur `backend_net` in `infra/redis/docker-compose.yml:15`; `mealie-postgres` nur `mealie_internal` in `apps/mealie/docker-compose.yml:56`; Immich `database` und `redis` nur `immich_default` in `apps/immich/docker-compose.yml:48` und `:64`; Nextcloud DB/Redis nur `nextcloud_internal` in `apps/nextcloud/docker-compose.yml:61` und `:73`; `komodo-mongo` nur `komodo_net` in `ops/komodo/docker-compose.yml:16`.
|
||||
- App-interne Pflichtnetze sind korrekt `internal: true`: `immich_default` in `apps/immich/docker-compose.yml:73-75`, `mealie_internal` in `apps/mealie/docker-compose.yml:66-68`, `nextcloud_internal` in `apps/nextcloud/docker-compose.yml:82-84`, `grafana_influx_internal` in `ops/grafana-influxdb/docker-compose.yml:90-91`.
|
||||
- `frontend_net` und `backend_net` werden in Compose-Dateien als `external: true` referenziert.
|
||||
- Alle Services mit `traefik.enable=true` setzen explizit `traefik.docker.network=frontend_net`, `entrypoints=websecure`, `tls=true` und `tls.certresolver=le`.
|
||||
- Kein Traefik-Label mit `yourdomain.tld` gefunden.
|
||||
- Admin-/Ops-Router haben Middleware `authelia@file,secure-headers@file`, u. a. `homepage` in `apps/homepage/docker-compose.yml:31`, `filebrowser` in `ops/filebrowser/docker-compose.yml:27`, `scrutiny` in `ops/scrutiny/docker-compose.yml:32`, `grafana` in `ops/grafana-influxdb/docker-compose.yml:46`. [AUSNAHME - dokumentiert] `komodo-core` hat keine ForwardAuth-Middleware in `ops/komodo/docker-compose.yml:64-71`; [AUSNAHME - dokumentiert] `nextcloud` nutzt native Auth und nur Redirect-Middleware in `apps/nextcloud/docker-compose.yml:42-46`.
|
||||
- Web-UIs ohne Traefik-Labels wurden nur als dokumentierte Sonderfaelle gefunden: `adguard` mit LAN-Admin-Port in `host-services/Adguard/docker-compose.yml:13-16`; `ddns-updater` hat keine Web-UI und braucht Internet in `infra/ddns-updater/docker-compose.yml:8`; `komodo-periphery` ist Agent ohne Traefik-Route in `ops/komodo/docker-compose.yml:81-103`.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Capacity and Lifecycle - KalliLab CORE
|
||||
|
||||
Status: Initiale Capacity-Baseline 2026-05-26; externe Backup-/Cold-Storage-Groessen offen.
|
||||
|
||||
## Zweck
|
||||
|
||||
Dieses Dokument haelt Wachstum, Schwellenwerte und Upgrade-Trigger fest. Es verhindert, dass Storage-, RAM- oder Backup-Entscheidungen erst dann getroffen werden, wenn der Host bereits unter Druck steht.
|
||||
|
||||
## Aktuelle Kapazitaet
|
||||
|
||||
| Bereich | Groesse | Belegt | Frei | Schwellwert | Bewertung |
|
||||
|---|---:|---:|---:|---:|---|
|
||||
| Cache | 1.9T | 97G | 1.8T | 70 % Planung / 85 % Aktion | gruen, 6 % belegt |
|
||||
| Disk1 / Array | 5.5T | 1.8T | 3.7T | 80 % Planung / 90 % Aktion | gruen, 33 % belegt |
|
||||
| User Shares gesamt | 5.5T | 1.8T | 3.7T | 80 % Planung / 90 % Aktion | gruen, entspricht aktuell Disk1 |
|
||||
| Backups lokal | 5.5T geteilter Array-Space | 2.2G unter `/mnt/user/backups` | 3.7T Share-frei | Review bei Borg-/Dump-Wachstum | lokal nicht unabhaengig vom Array |
|
||||
| Hetzner Borg | TBD | TBD | TBD | TBD | TBD |
|
||||
| Externe Cold-Platte | TBD | TBD | TBD | TBD | TBD |
|
||||
|
||||
Pruefkommando:
|
||||
|
||||
```bash
|
||||
df -h /mnt/cache /mnt/disk1 /mnt/user
|
||||
du -sh /mnt/user/appdata/* | sort -hr | head -30
|
||||
du -sh /mnt/user/documents /mnt/user/photos /mnt/user/media /mnt/user/backups 2>/dev/null
|
||||
```
|
||||
|
||||
## Wachstumsbereiche
|
||||
|
||||
| Bereich | Erwartetes Wachstum | Risiko | Naechste Aktion |
|
||||
|---|---|---|---|
|
||||
| Medien | aktuell ca. 1.7T | groesster Speicherblock | Array-Erweiterung vor 80 % planen |
|
||||
| Immich Fotos/Videos | aktuell ca. 23G | hoechster privater Datentopf | Restore-Test priorisieren |
|
||||
| Paperless/Dokumente | aktuell ca. 199M im Documents-Share | wichtig, moderates Wachstum | Restore-Test existiert, Share-Wachstum beobachten |
|
||||
| Nextcloud | TBD | Familiennutzung kann stark wachsen | Quota/Backup pruefen |
|
||||
| Monitoring/Loki | begrenzt durch Retention | Retention kann Disk fuellen | Retention und Volume-Groesse bei Reviews pruefen |
|
||||
| Borg Dumps | aktuell ca. 2.2G lokale Backups | Retention und Excludes pruefen | Borg-Stale + Groessenprofil |
|
||||
|
||||
## Upgrade-Trigger
|
||||
|
||||
| Trigger | Massnahme |
|
||||
|---|---|
|
||||
| Cache dauerhaft >70 % | Zweite NVMe oder Appdata-Verteilung planen |
|
||||
| Cache >85 % | Sofortmassnahme, keine grossen Deployments |
|
||||
| Disk1 >80 % | Array-Erweiterung planen |
|
||||
| Disk1 >90 % | Keine neuen grossen Datenimporte, Erweiterung priorisieren |
|
||||
| RAM >90 % ueber 10 Minuten regelmaessig | RAM-Ausbau oder Service-Limits pruefen |
|
||||
| Borg-Laufzeit deutlich steigend | Scope, Netzwerk und Ziel pruefen |
|
||||
| SMART-Warnung | Ersatz planen, Restore-/Backup-Frische pruefen |
|
||||
| Keine USV-Abschaltung | USV anschaffen/anschliessen oder Power-Loss-Risiko bewusst akzeptieren |
|
||||
|
||||
## Restore-Zeitziele
|
||||
|
||||
| Tier | Beispiel | Zielzeit | Status |
|
||||
|---|---|---:|---|
|
||||
| Tier 0 | Repo, Secrets, Traefik, DNS | TBD | offen |
|
||||
| Tier 1 | Gitea, Vaultwarden, Paperless, Immich | TBD | offen |
|
||||
| Tier 2 | Nextcloud, Mealie, Monitoring | TBD | offen |
|
||||
| Tier 3 | Komfort-/Ops-Tools | TBD | offen |
|
||||
|
||||
## Review-Log
|
||||
|
||||
| Datum | Befund | Entscheidung |
|
||||
|---|---|---|
|
||||
| 2026-05-26 | Cache 6 %, Array/User-Shares 33 %, lokale Backups 2.2G; keine validierte USV-Abschaltung | Capacity gruen; naechste operative Risiken sind USV-Entscheidung und externe Backup-/Cold-Storage-Groessen |
|
||||
@@ -0,0 +1,32 @@
|
||||
# Codex-Prompt: KalliLab Endstufe
|
||||
|
||||
Du hast Vollzugriff auf `G:\Gitea_Clone\homelab-infra`, Gitea-Push, Komodo, und SSH auf Unraid `Kallilabcore`.
|
||||
|
||||
## Lies zuerst
|
||||
1. `CLAUDE.md`
|
||||
2. `docs/AUDIT_2026-05-23.md` — dort steht die komplette Restliste
|
||||
|
||||
## Auftrag
|
||||
Den Audit von oben verifizieren und die offenen Punkte abarbeiten, bis das Homelab in der Endstufe ist. Reihenfolge:
|
||||
|
||||
1. **P0** — Lokalen Commit `cd650b1` nach Gitea pushen, danach Komodo-Reaktion fuer `gitea` und `borg-ui` pruefen.
|
||||
2. **P0** — Live-Daten aus Audit-Abschnitt 9 messen und in `docs/AUDIT_2026-05-23_LIVE.md` ablegen (Secrets redacten).
|
||||
3. **P1** — Monitoring-Stack (`monitoring/`) live deployen, alte `ops/grafana-influxdb` und `ops/loki` `down` (nicht loeschen).
|
||||
4. **P1** — Jellyfin und Plex in `HOMELAB_ARCHITECTURE_MASTER_V2.md`, `docs/SERVICE_CATALOG.md`, `docs/REPO_MAP.md` nachtragen. Plex-Eintrag "nicht als Repo-Stack enthalten" korrigieren.
|
||||
5. **P2** — Borg-Lauf-Frische pruefen, ggf. neuen Lauf ausloesen, alle 14 Dump-Artefakte juenger als 24 h.
|
||||
6. **P3** — Repo-Hygiene: 8 leere Verzeichnisse weg, `.serena/` in `.gitignore`, Entscheidung zu `ops/windows-reinstall/*.ps1`.
|
||||
|
||||
## Regeln (aus CLAUDE.md, nicht verhandelbar)
|
||||
- Secrets nie im Klartext ausgeben.
|
||||
- Keine Aenderungen direkt in Komodo, nur ueber Git → Push → Komodo.
|
||||
- Kein `push --force`, kein blindes Loeschen von `/mnt/user/{appdata,documents,photos,services,backups}`.
|
||||
- Working-Tree-Status nur aus `git status --short` ableiten, nie aus `git diff` ueber Linux-Mount.
|
||||
- Traefik dynamic config wird nicht von Komodo deployed — Aenderungen dort manuell auf `/mnt/user/appdata/traefik/dynamic/` syncen.
|
||||
- Nicht anfassen: Hermes, Disk1 NTFS Phase 2, Komodo-Auth, Grafana/InfluxDB `user: "0"`, Image-Pinning ddns/glances/scrutiny.
|
||||
- Wenn zwei Reparaturversuche scheitern: stoppen, Drift-Runbook Pflichtmatrix, Operator fragen.
|
||||
|
||||
## Arbeitsmodus pro Block
|
||||
Lesen → minimal aendern → `ops/policy-checks/check_repo.ps1` lokal → Commit → Push → Komodo-Reaktion + Smoke-Test → eine Zeile in `docs/MIGRATION_LOG.md`.
|
||||
|
||||
## Fertig
|
||||
Wenn alles abgearbeitet ist (oder ein Punkt bewusst offen bleibt): `docs/AUDIT_2026-05-23_FINAL.md` schreiben mit Ampel + konkretem Beleg pro Punkt, committen, pushen, kurz an mich melden.
|
||||
@@ -0,0 +1,421 @@
|
||||
# Disaster Recovery - KalliLab CORE
|
||||
|
||||
Dieses Dokument beschreibt den **kontrollierten Wiederanlauf nach einem Totalausfall des Unraid-Hosts**.
|
||||
|
||||
Es ist bewusst **repo-genau** fuer dieses Homelab geschrieben und ersetzt keine Backup-Dokumentation im engeren Sinn.
|
||||
|
||||
Verwandte Dokumente:
|
||||
|
||||
- `docs/ROLLBACK.md` - Rueckweg bei Fehlern im laufenden GitOps-Betrieb
|
||||
- `docs/RESTORE_MATRIX.md` - Restore-Quellen und Verifikationsregeln pro Dienst
|
||||
- `docs/RESTORE_HANDBOOK.md` - praktische Restore-Betriebsanleitung
|
||||
- `docs/SERVICES_RECOVERY.md` - Recovery-kritische `/mnt/user/services`-Pfade, Gitea-Mirror und Komodo-Bootstrap
|
||||
- `docs/EXTERNAL_DEPENDENCIES.md` - externe Provider/Konten und Ausfall-Szenarien
|
||||
- `ops/borg-ui/BACKUP_SCOPE.md` - Zielbild des Borg-Scopes
|
||||
|
||||
---
|
||||
|
||||
## 1. Ziel und Geltungsbereich
|
||||
|
||||
Dieses Runbook behandelt primaer den Fall:
|
||||
|
||||
- Unraid-Host war ausgefallen oder musste neu aufgesetzt werden
|
||||
- Array / Shares sind wieder verfuegbar
|
||||
- Daten auf den Festplatten sind grundsaetzlich noch lesbar oder koennen aus Borg wiederhergestellt werden
|
||||
|
||||
Es behandelt **nicht** im Detail:
|
||||
|
||||
- den Rueckweg einzelner Fehl-Deployments im laufenden Betrieb
|
||||
- eine spontane Live-Reparatur an einem einzelnen Service
|
||||
- einen kompletten Datenverlust ohne verfuegbares Borg- oder Share-Material
|
||||
|
||||
---
|
||||
|
||||
## 2. Zwei verschiedene Ernstfaelle
|
||||
|
||||
Diese beiden Szenarien muessen sauber getrennt werden:
|
||||
|
||||
### A. Host-/Systemausfall, aber Daten auf den Shares sind noch da
|
||||
|
||||
Das ist der wahrscheinlichere und einfachere Fall.
|
||||
|
||||
Dann muessen in vielen Bereichen **keine Daten aus Borg extrahiert** werden. Es reicht oft:
|
||||
|
||||
1. Unraid sauber neu aufzusetzen
|
||||
2. Shares wieder einzubinden
|
||||
3. Repo / Secrets / Laufzeitpfade zu pruefen
|
||||
4. Stacks in der richtigen Reihenfolge wieder hochzufahren
|
||||
|
||||
### B. Daten muessen wirklich aus Borg wiederhergestellt werden
|
||||
|
||||
Das ist der schwerere Fall.
|
||||
|
||||
Dann muessen gezielt die in `docs/RESTORE_MATRIX.md` beschriebenen Pfade, Dumps und Secrets aus Borg oder anderen Restore-Quellen zurueckgeholt werden.
|
||||
|
||||
**Merksatz:** Erst klaeren, ob wir nur den Host wiederbeleben oder echte Nutz-/App-Daten aus Backups zurueckholen muessen.
|
||||
|
||||
---
|
||||
|
||||
## 3. Voraussetzungen vor einem Ernstfall
|
||||
|
||||
Diese Punkte sollten **vor** einem echten Ausfall geklaert sein:
|
||||
|
||||
| Thema | Sollzustand |
|
||||
|---|---|
|
||||
| Repo-Zugang ausserhalb von Gitea | privater GitHub-Push-Mirror `michaelkaleschke-spec/homelab-infra` und lokaler aktueller Clone vorhanden |
|
||||
| Unraid USB-/Flash-Backup | `unraid-flash-config.tar.gz` wird vor Borg unter `/mnt/user/backups/borg/dumps/latest` erzeugt und nach Hetzner/Borg gesichert; Unraid-Connect-Cloud-Backup optional zusaetzlich |
|
||||
| Borg-Ziel | nicht nur lokal auf demselben Ausfallpfad |
|
||||
| Borg-Passphrase | Host-Secret-Datei vorhanden und fuer Borg-Zugriff verifiziert; externe analoge Hinterlegung bleibt Operator-Aufgabe |
|
||||
| Secrets-Dateien | ueber Borg bzw. Restore-Quellen abgedeckt |
|
||||
| Komodo Stack ENV-Werte | extern dokumentiert, z. B. Vaultwarden |
|
||||
| Services-Recovery | `docs/SERVICES_RECOVERY.md` gepflegt, insbesondere Gitea-Repo-Mirror und Komodo-Bootstrap |
|
||||
| Hardware-/Netzwerkdaten | `docs/HARDWARE_INVENTORY.md` und `docs/NETWORK_INVENTORY.md` mit echten Werten gefuellt |
|
||||
| Restore-Smoke-Tests | fuer mindestens 1-2 kritische Dienste nachgewiesen |
|
||||
|
||||
**Wichtig:** Dieses Dokument ist nur so gut wie die Vorbereitung ausserhalb des Repos.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 0 - Repo-Zugang sicherstellen
|
||||
|
||||
Nach einem Totalausfall kann es sein, dass `gitea` selbst noch nicht laeuft.
|
||||
|
||||
Deshalb gilt:
|
||||
|
||||
1. Wenn moeglich, Repo ueber einen externen Mirror oder einen lokalen aktuellen Clone holen.
|
||||
2. Nur wenn `gitea` bereits wieder verfuegbar ist, direkt aus `git.kaleschke.info` klonen.
|
||||
|
||||
Verfuegbare Wege:
|
||||
|
||||
- externer Push-Mirror: `https://github.com/michaelkaleschke-spec/homelab-infra`
|
||||
- lokaler Bare-Clone auf dem PC
|
||||
- normaler lokaler Arbeits-Clone auf dem PC
|
||||
|
||||
Wenn **weder GitHub-Mirror noch lokaler Repo-Clone** verfuegbar sind, ist `services/gitea/data` selbst ein kritischer Restore-Pfad.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 1 - Unraid und Shares wiederherstellen
|
||||
|
||||
### 5.1 Unraid-Grundzustand
|
||||
|
||||
1. Unraid USB/Flash wiederherstellen oder neu aufsetzen
|
||||
2. Lizenz / Device-Zuordnung sauber herstellen
|
||||
3. Array wieder zuweisen und starten
|
||||
4. Grundlegende Shares pruefen
|
||||
|
||||
Primaere lokale/off-site Restore-Quelle fuer die bestehende Flash-Konfiguration ist das Borg-Artefakt `unraid-flash-config.tar.gz` aus `/mnt/user/backups/borg/dumps/latest`. Dieses Archiv enthaelt `/boot/config` und muss wie Secret-Material behandelt werden.
|
||||
|
||||
### 5.2 Erwartete Shares / Pfade
|
||||
|
||||
Mindestens diese Pfade muessen wieder verfuegbar sein:
|
||||
|
||||
- `/mnt/user/appdata`
|
||||
- `/mnt/user/backups`
|
||||
- `/mnt/user/services`
|
||||
- `/mnt/user/documents`
|
||||
- `/mnt/user/photos`
|
||||
|
||||
Je nach Dienst zusaetzlich:
|
||||
|
||||
- `/mnt/user/finance`
|
||||
- `/mnt/remotes/...` fuer externe Backup-Ziele
|
||||
|
||||
Wenn diese Pfade **nicht** sauber da sind, keine Stacks starten.
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase 2 - Secrets und kritische Restore-Pfade pruefen
|
||||
|
||||
Bevor produktive Dienste hochfahren, muessen die wichtigsten Secret- und Konfigurationspfade verifiziert werden.
|
||||
|
||||
### 6.1 Zentrale Secrets
|
||||
|
||||
Erwartete Basis unter `/mnt/user/appdata/secrets/`:
|
||||
|
||||
- `authelia_jwt_secret.txt`
|
||||
- `authelia_postgres_password.txt`
|
||||
- `authelia_session_secret.txt`
|
||||
- `authelia_smtp_password.txt`
|
||||
- `authelia_storage_encryption_key.txt`
|
||||
- `immich_postgres_password.txt`
|
||||
- `komodo_mongo_password.txt`
|
||||
- `mealie_postgres_password.txt`
|
||||
- `nextcloud_admin_password.txt`
|
||||
- `nextcloud_admin_user.txt`
|
||||
- `nextcloud_postgres_password.txt`
|
||||
- `postgres_password.txt`
|
||||
- `redis_password.txt`
|
||||
- `borg_repo_passphrase.txt`
|
||||
- `vaultwarden_admin_token.txt`
|
||||
- `hermes_runner_id_ed25519`
|
||||
|
||||
Weitere relevante Secret-Pfade:
|
||||
|
||||
- `/mnt/user/appdata/traefik/secrets/cloudflare_dns_api_token`
|
||||
- `/mnt/user/appdata/code-server/secrets/password`
|
||||
|
||||
### 6.2 Stack-ENV-Werte ausserhalb von Datei-Secrets
|
||||
|
||||
Diese Werte sind vor dem Start der betroffenen Dienste zu pruefen bzw. wieder in Komodo zu hinterlegen:
|
||||
|
||||
- `PAPERLESS_DBPASS`
|
||||
- `PAPERLESS_REDIS`
|
||||
- `IMMICH_DB_PASSWORD`
|
||||
- `MAILARCHIVER_DB_CONNECTION`
|
||||
- `MAILARCHIVER_AUTH_PASSWORD`
|
||||
- `HERMES_DASHBOARD_HOST`
|
||||
- Hermes Host-`.env` fuer Provider-/API-/Home-Assistant-Tokens
|
||||
- `KOMODO_SECRET_KEY`
|
||||
- `KOMODO_WEBHOOK_SECRET`
|
||||
- `KOMODO_JWT_SECRET`
|
||||
- `KOMODO_MONGO_PASSWORD`
|
||||
- `KOMODO_PERIPHERY_PASSKEY`
|
||||
- `APP_KEY` und `ADMIN_PASSWORD` fuer `speedtest-tracker`
|
||||
|
||||
### 6.3 Rechte
|
||||
|
||||
Nach einem Restore oder manuellem Rueckkopieren:
|
||||
|
||||
- Secret-Dateien wieder auf sinnvolle restriktive Rechte setzen
|
||||
- keine produktiven Dienste starten, solange offensichtliche Secret-Dateien fehlen
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase 3 - Was wirklich aus Borg kommen muss
|
||||
|
||||
Nicht alles muss in jedem Fall aus Borg zurueckgespielt werden.
|
||||
|
||||
### 7.1 Typischer Host-Ausfall ohne Datenverlust
|
||||
|
||||
In diesem Fall meist **kein kompletter Borg-Extract** notwendig.
|
||||
|
||||
Stattdessen:
|
||||
|
||||
1. Shares und Pfade pruefen
|
||||
2. Secrets pruefen
|
||||
3. Dumps unter `/mnt/user/backups/borg/dumps/latest` pruefen
|
||||
4. Stacks kontrolliert hochfahren
|
||||
|
||||
### 7.2 Wenn echte Daten aus Borg benoetigt werden
|
||||
|
||||
Dann nur gezielt die in `docs/RESTORE_MATRIX.md` beschriebenen Pfade wiederherstellen.
|
||||
|
||||
Besonders kritisch:
|
||||
|
||||
- `/mnt/user/appdata/secrets`
|
||||
- `/mnt/user/appdata/traefik`
|
||||
- `/mnt/user/services/homelab-infra`
|
||||
- `/mnt/user/services/stacks`
|
||||
- `/mnt/user/services/posture-check`
|
||||
- Details zu `/mnt/user/services/` und Komodo/Gitea-Bootstrap stehen in `docs/SERVICES_RECOVERY.md`
|
||||
- `/mnt/user/services/gitea/data`
|
||||
- `/mnt/user/appdata/authelia/config`
|
||||
- `/mnt/user/appdata/komodo/core`
|
||||
- `/mnt/user/appdata/komodo/periphery`
|
||||
- `/mnt/user/backups/borg/dumps/latest`
|
||||
- `/mnt/user/backups/borg/dumps/latest/unraid-flash-config.tar.gz`
|
||||
- dienstspezifische App- und Nutzdatenpfade
|
||||
|
||||
**Nicht blind alles extrahieren**, wenn nur einzelne Pfade oder Dienste betroffen sind.
|
||||
|
||||
---
|
||||
|
||||
## 8. Phase 4 - Bootstrap-Reihenfolge der Stacks
|
||||
|
||||
**Nie alle Stacks gleichzeitig starten.**
|
||||
|
||||
### Stufe 1 - Netz und Zugang
|
||||
|
||||
1. `traefik/`
|
||||
2. `host-services/Adguard/`
|
||||
3. `host-services/tailscale/`
|
||||
|
||||
Ziel:
|
||||
|
||||
- Web-Einstieg funktioniert
|
||||
- DNS/Resolver-Basis ist da
|
||||
- Remote-Zugang ist wieder verfuegbar
|
||||
|
||||
### Stufe 2 - Gemeinsame Backends und Identity
|
||||
|
||||
4. `infra/postgresql17/`
|
||||
5. `security/authelia/`
|
||||
6. `infra/redis/`
|
||||
7. `core/gitea/`
|
||||
|
||||
Ziel:
|
||||
|
||||
- gemeinsame DB verfuegbar
|
||||
- zentrale Auth laeuft; Authelia nutzt bewusst kein Redis-Session-Backend
|
||||
- Authelia SMTP-Notifier kann GMX erreichen
|
||||
- Redis als shared Cache fuer abhaengige Apps verfuegbar
|
||||
- Git-Zugriff wiederhergestellt
|
||||
|
||||
### Stufe 3 - Deploy-System
|
||||
|
||||
8. `ops/komodo/`
|
||||
|
||||
Ziel:
|
||||
|
||||
- Komodo Core und Mongo laufen
|
||||
- Periphery verbindet sich wieder
|
||||
- Stacks koennen wieder aus Git konsumiert werden
|
||||
|
||||
### Stufe 4 - Kritische Anwendungen
|
||||
|
||||
9. `security/vaultwarden/`
|
||||
10. `apps/paperless/`
|
||||
11. `apps/immich/`
|
||||
12. `apps/mealie/`
|
||||
13. `apps/mail-archiver/`
|
||||
14. `apps/nextcloud/`
|
||||
|
||||
### Stufe 5 - Restliche Apps und Ops
|
||||
|
||||
15. `apps/ntfy/`
|
||||
16. `apps/paperless-gpt/`
|
||||
17. `apps/bentopdf/`
|
||||
18. `ops/glance/`
|
||||
19. `ops/borg-ui/`
|
||||
20. `ops/filebrowser/`
|
||||
21. `ops/glances/`
|
||||
22. `ops/scrutiny/`
|
||||
23. `ops/speedtest/`
|
||||
24. `monitoring/`
|
||||
25. `ops/hermes-agent/`
|
||||
26. `infra/ddns-updater/`
|
||||
|
||||
**Regel:** Nach jeder Stufe kurz pruefen, bevor die naechste beginnt.
|
||||
|
||||
---
|
||||
|
||||
## 9. Phase 5 - Verifikation nach dem Wiederanlauf
|
||||
|
||||
### 9.1 Fundament
|
||||
|
||||
- `traefik.kaleschke.info` erreichbar
|
||||
- Authelia-Login funktioniert
|
||||
- AdGuard beantwortet DNS-Anfragen
|
||||
- Tailscale ist verbunden
|
||||
|
||||
### 9.2 GitOps
|
||||
|
||||
- `git.kaleschke.info` erreichbar
|
||||
- Repo vorhanden
|
||||
- `komodo.kaleschke.info` erreichbar
|
||||
- Periphery verbunden
|
||||
|
||||
### 9.3 Kritische Apps
|
||||
|
||||
- Vaultwarden startet und ist erreichbar
|
||||
- Paperless startet und sieht Dokumente
|
||||
- Immich startet und sieht Medien
|
||||
- Mealie startet
|
||||
- Mail-Archiver startet
|
||||
- Nextcloud startet und sieht Dateien
|
||||
|
||||
### 9.4 Backup-/Beobachtungsebene
|
||||
|
||||
- Borg UI startet und kennt sein Repo noch
|
||||
- aktuelle Dump-Artefakte sind vorhanden
|
||||
- Glance / Monitoring / ntfy sind wieder da
|
||||
- Hermes Gateway und Dashboard starten; `hermes.kaleschke.info` leitet anonym zu Authelia weiter
|
||||
|
||||
---
|
||||
|
||||
## 10. Bekannte Sonderregeln
|
||||
|
||||
### Traefik `dynamic/`
|
||||
|
||||
`traefik/dynamic/*` bleibt eine dokumentierte manuelle Ausnahme.
|
||||
|
||||
Das bedeutet:
|
||||
|
||||
- Git allein reicht hier nicht
|
||||
- die Dateien unter `/mnt/user/appdata/traefik/dynamic/` muessen real vorhanden sein
|
||||
- nach einem Restore oder Neuaufbau diesen Pfad explizit pruefen
|
||||
|
||||
### `paperless-ngx`
|
||||
|
||||
`paperless-ngx` bleibt bewusst bei Stack Environment Variables fuer:
|
||||
|
||||
- `PAPERLESS_DBPASS`
|
||||
- `PAPERLESS_REDIS`
|
||||
|
||||
Nach einem Komodo-Neuaufbau muessen diese Werte vor dem Start des Stacks wieder gesetzt sein.
|
||||
|
||||
### `authelia`
|
||||
|
||||
Authelia nutzt GMX SMTP fuer Identity-/2FA-Benachrichtigungen.
|
||||
|
||||
Vor dem Start muessen vorhanden sein:
|
||||
|
||||
- `/mnt/user/appdata/secrets/authelia_smtp_password.txt`
|
||||
- SMTP-Zugang fuer `michideheld@gmx.de`
|
||||
|
||||
Beim Smoke-Test muss `authelia validate-config` erfolgreich sein; der SMTP-Startup-Check darf den Start nicht blockieren.
|
||||
|
||||
### `nextcloud`
|
||||
|
||||
`nextcloud` ist bewusst kein AIO-Stack, sondern ein klassischer App-/PostgreSQL-/Redis-Stack.
|
||||
|
||||
Vor dem Start muessen vorhanden sein:
|
||||
|
||||
- `/mnt/user/appdata/secrets/nextcloud_admin_user.txt`
|
||||
- `/mnt/user/appdata/secrets/nextcloud_admin_password.txt`
|
||||
- `/mnt/user/appdata/secrets/nextcloud_postgres_password.txt`
|
||||
|
||||
Zusaetzlich muss der Nutzdatenpfad `/mnt/user/documents/nextcloud-data` erreichbar sein.
|
||||
|
||||
### Borg-Dumps
|
||||
|
||||
Die Dump-Erzeugung ist host-seitig gedacht, nicht als Borg-UI-Inline-Hook.
|
||||
|
||||
Relevant:
|
||||
|
||||
- Dump-Ziel: `/mnt/user/backups/borg/dumps/latest`
|
||||
- Skript: `ops/borg-ui/scripts/pre-backup-dumps.sh`
|
||||
- Unraid-Flash-Artefakt: `unraid-flash-config.tar.gz` plus `.sha256` und Manifest im selben Zielpfad
|
||||
|
||||
### Hermes Agent
|
||||
|
||||
Hermes nutzt einen lokalen Build und hostseitige Runtime-Daten.
|
||||
|
||||
Vor dem Start muessen vorhanden sein:
|
||||
|
||||
- `/mnt/user/appdata/hermes-agent/data`
|
||||
- `/mnt/user/appdata/hermes-agent/ssh`
|
||||
- `/mnt/user/appdata/secrets/hermes_runner_id_ed25519`
|
||||
- die hostseitige Hermes `.env` mit Provider-/API-/Home-Assistant-Tokens
|
||||
|
||||
Smoke-Test: `hermes-gateway` healthcheck ist gruen, `hermes.kaleschke.info` leitet fuer anonyme Requests zu Authelia weiter.
|
||||
|
||||
### Gitea
|
||||
|
||||
`Micha/homelab-infra` wird als privater GitHub-Push-Mirror gespiegelt. Dieser Mirror ist der bevorzugte Repo-Bootstrap, falls Gitea selbst nach einem Ausfall noch nicht laeuft. Wenn weder GitHub-Mirror noch lokaler Clone verfuegbar sind, ist `services/gitea/data` selbst Teil des kritischen Wiederanlaufs.
|
||||
|
||||
---
|
||||
|
||||
## 11. Offene Vorbereitungs-To-dos
|
||||
|
||||
- Unraid-USB-/Flash-Backup regelmaessig ueber `unraid-flash-config.tar.gz` und optional Unraid Connect pruefen
|
||||
- Borg-Passphrase aus `/mnt/user/appdata/secrets/borg_repo_passphrase.txt` extern analog sicher hinterlegen
|
||||
- Komodo Stack-ENV-Werte zentral ausserhalb von Komodo dokumentieren
|
||||
- regelmaessige automatisierte Restore-Smoke-Tests fuer Vaultwarden, Gitea und Paperless etablieren
|
||||
- `komodo-mongo`-Dump nach Major-Upgrades gezielt kontrollieren
|
||||
|
||||
---
|
||||
|
||||
## 12. Kurzform fuer den Ernstfall
|
||||
|
||||
Wenn es schnell gehen muss:
|
||||
|
||||
1. Unraid und Shares sauber wiederherstellen
|
||||
2. Repo-Zugang sichern
|
||||
3. Secrets und Stack-ENV-Werte pruefen
|
||||
4. Stacks in der festgelegten Reihenfolge hochfahren
|
||||
5. Kritische Apps testen
|
||||
6. Nur bei echtem Datenbedarf gezielt aus Borg wiederherstellen
|
||||
|
||||
Dieses Dokument soll **Ruhe in den Ablauf bringen**, nicht Hektik erzeugen.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Disk1 Phase 2 - NTFS to XFS Migration
|
||||
|
||||
Stand: 2026-05-25 06:15 CEST. Ziel erreicht: `/mnt/disk1` wurde von `ntfs3` auf XFS migriert, ohne produktive Compose-Pfade zu aendern. Container nutzen weiter `/mnt/user/...`.
|
||||
|
||||
## Preflight
|
||||
|
||||
| Check | Ergebnis |
|
||||
|---|---|
|
||||
| Disk1 Mount | `/dev/md1p1` auf `/mnt/disk1`, `ntfs3`, 5.5T, 1.7T genutzt, 3.8T frei |
|
||||
| Cache Mount | `/dev/nvme0n1p1` auf `/mnt/cache`, `xfs`, 1.9T, 100G genutzt |
|
||||
| H: Backup-Ziel | `H:\`, Label `Externe HDD`, NTFS, 8T, 5.96T frei, healthy |
|
||||
| Compose-Binds | Keine Treffer fuer direkte `/mnt/disk1`, `/mnt/cache`, `/mnt/disks`, `/mnt/remotes` Binds |
|
||||
| SMB-Zugriff | `\\Kallilabcore\services`, `documents`, `photos`, `media` erreichbar |
|
||||
|
||||
## Zu sichernde Shares
|
||||
|
||||
| Share | Groesse laut Preflight |
|
||||
|---|---:|
|
||||
| `services` | 451M |
|
||||
| `documents` | 196M |
|
||||
| `photos` | 23G |
|
||||
| `backups` | 2.2G |
|
||||
| `media` | 1.7T |
|
||||
| `finance` | 0 |
|
||||
| `projekte` | 92K |
|
||||
|
||||
Zusaetzliche Disk1-Top-Level-Pfade: `scripts` 3.3M, `isos` 0, `System Volume Information` 0.
|
||||
|
||||
## Backup-Strategie
|
||||
|
||||
- Zielroot: `H:\kallilab-recovery\disk1-phase2-2026-05-23`.
|
||||
- Kritischer Linux-/GitOps-Pfad `services` wird zusaetzlich als Tar-Archiv ueber SSH gesichert, damit Linux-Metadaten erhalten bleiben.
|
||||
- User-Shares werden per SMB/Robocopy kopiert, mit Logs und anschliessender Zaehler-/Groessenverifikation.
|
||||
- Keine produktiven Datenpfade werden geloescht.
|
||||
|
||||
## Gates
|
||||
|
||||
1. Backup komplett und verifiziert.
|
||||
2. Dienste-Freeze vorbereitet und letzte Dumps frisch.
|
||||
3. Direkt vor Format/Array-Prozedur: Operator-Bestaetigung im konkreten Moment.
|
||||
|
||||
## Backup-Ergebnis
|
||||
|
||||
Stand: 2026-05-24 13:07 CEST.
|
||||
|
||||
| Bereich | Sicherungsart | Ergebnis |
|
||||
|---|---|---|
|
||||
| `media` | Robocopy/SMB nach `H:\kallilab-recovery\disk1-phase2-2026-05-23\shares\media` | 2722 Dateien, 1677.11 GiB, Manifestvergleich: 0 missing, 0 size mismatch, 0 extra |
|
||||
| `services` | Host-Tar auf Unraid Cache, danach binaer per `scp` nach H: | `services.tar`, 0.441 GiB, `tar -tf` gueltig |
|
||||
| `documents` | Host-Tar auf Unraid Cache, danach binaer per `scp` nach H: | `documents.tar`, 0.192 GiB, `tar -tf` gueltig |
|
||||
| `photos` | Host-Tar auf Unraid Cache, danach binaer per `scp` nach H: | `photos.tar`, 22.876 GiB, `tar -tf` gueltig |
|
||||
| `backups` | Host-Tar auf Unraid Cache, danach binaer per `scp` nach H: | `backups.tar`, 2.099 GiB, `tar -tf` gueltig |
|
||||
| `finance` | Host-Tar auf Unraid Cache, danach binaer per `scp` nach H: | `finance.tar`, leerer Share, `tar -tf` gueltig |
|
||||
| `projekte` | Host-Tar auf Unraid Cache, danach binaer per `scp` nach H: | `projekte.tar`, klein, `tar -tf` gueltig |
|
||||
| Disk1-Extras `scripts`, `isos` | Host-Tar auf Unraid Cache, danach binaer per `scp` nach H: | `disk1-extra.tar`, 0.003 GiB, `tar -tf` gueltig |
|
||||
|
||||
Hinweis: Erste Tar-Versuche per PowerShell-Redirect wurden verworfen, weil PowerShell den binaeren SSH-Stream als UTF-16 geschrieben hatte. Die ungueltige `media.tar` und unvollstaendige SMB-Teilkopien fuer `services`/`documents` wurden vom Backup-Ziel entfernt, damit nur verwertbare Sicherungen uebrig bleiben.
|
||||
|
||||
## Abschluss
|
||||
|
||||
Stand: 2026-05-25 06:15 CEST.
|
||||
|
||||
| Check | Ergebnis |
|
||||
|---|---|
|
||||
| Freeze-Dumps | `pre-backup-dumps.sh` vor Format ausgefuehrt; nach Wiederanlauf erneut erfolgreich, 15 kanonische Dump-Artefakte juenger als 24 h |
|
||||
| Disk1 Filesystem | `/dev/md1p1` auf `/mnt/disk1`, `xfs`, 5.5T, 1.8T genutzt, 3.7T frei |
|
||||
| Restore `media` | 2722 Dateien, 1,800,782,188,226 Bytes; finaler Manifestvergleich: 0 missing, 0 size mismatch, 0 extra |
|
||||
| Restore Tar-Shares | `services`, `documents`, `photos`, `backups`, `finance`, `projekte` und Disk1-Extras aus H:-Freeze-Archiven nach `/mnt/disk1` extrahiert |
|
||||
| Docker/Services | 49 Container laufend, 0 stopped, 0 unhealthy, 0 starting |
|
||||
| Smoke-Tests | `git.kaleschke.info` 200, `komodo.kaleschke.info` 200, `borg.kaleschke.info` 302, `jellyfin.kaleschke.info` 302, `monitoring.kaleschke.info` 302 |
|
||||
| Service-Mounts | Gitea SSH `:222` offen; Jellyfin und Plex sehen `/media`; Prometheus readiness ok |
|
||||
| Backup-Lauf | Borg-UI Repository `appdata-critical`, letzter Job `completed`, Archiv `Taegliche-Sicherung-2026-05-25T05:52:44.157`, `nfiles=100221` |
|
||||
| Temp-Cleanup | `/mnt/cache/disk1-phase2-tmp/*.tar` nach H:-Verifikation geloescht; Cache weiter XFS mit ca. 1.7T frei |
|
||||
|
||||
Hinweis zum Docker-Wiederanlauf: Nach dem manuellen Docker-Start liefen die Container, aber Healthcheck-Execs scheiterten wegen `dockerd` mit `XDG_RUNTIME_DIR=/run/user/0`. Ein gezielter Docker-Neustart mit unsetztem `XDG_RUNTIME_DIR` behob den Runtime-Fehler; danach wurden alle Healthchecks gruen. `monitoring-prometheus` war durch den geplanten Docker-Stop sauber beendet und wurde als bestehender Container wieder gestartet.
|
||||
|
||||
Offener Nachlauf: Die Array-Parity-Anzeige zeigte nach Abschluss weiter `mdNumDisabled=1`, `mdNumInvalid=1` und `mdResyncAction=check P`, waehrend beide beteiligten Devices `rdevStatus=DISK_OK` und `rdevNumErrors=0` melden. Parity-Zustand separat in Unraid pruefen und keinen Parity-/Disk-Slot ohne Operator-Entscheid aendern.
|
||||
@@ -0,0 +1,68 @@
|
||||
# External Dependencies - KalliLab CORE
|
||||
|
||||
Status: Initiale Betreiber-Baseline 2026-05-26; konkrete Account-Recovery-Codes und Besitznachweise muessen ausserhalb des Repos bestaetigt werden.
|
||||
|
||||
## Zweck
|
||||
|
||||
Dieses Dokument beschreibt externe Anbieter und Konten, von denen Betrieb, Recovery oder Zugriff abhaengen. Ziel ist, im Ausfallfall nicht erst suchen zu muessen, welcher Provider welches Teilproblem verursacht.
|
||||
|
||||
## Abhaengigkeiten
|
||||
|
||||
| Anbieter / System | Zweck | Kritikalitaet | Recovery-Auswirkung | Zugang / Besitz | Notfallplan |
|
||||
|---|---|---:|---|---|---|
|
||||
| Domain-Registrar | Besitz `kaleschke.info` | hoch | Ohne Domain brechen Public URLs/TLS-Erneuerung | Operator-Konto ausserhalb Repo, konkreten Registrar im Account pruefen | Registrar-Zugang, 2FA-Recovery und Zahlungsweg analog/off-system sichern |
|
||||
| Cloudflare DNS | Authoritative DNS, ACME DNS-Challenge, DDNS | hoch | Neue Zertifikate/DNS-Aenderungen blockiert | Cloudflare-Konto; API-Token liegt als Host-Secret | API-Token rotierbar halten, Account-Recovery und Zone-Besitz pruefen |
|
||||
| Hetzner Storage Box | Off-site Borg Backup | kritisch | Restore aus Off-site ggf. nicht moeglich | Hetzner-Konto / Storage-Box-Zugang ausserhalb Repo | Zweites Off-site-Ziel oder Cold-Platte etablieren; Borg-Passphrase extern sichern |
|
||||
| GitHub Mirror | Externer Repo-Mirror `michaelkaleschke-spec/homelab-infra` | mittel/hoch | Gitea-Verlust abfederbar, Repo-Bootstrap bleibt moeglich | GitHub-Konto; PAT liegt in Gitea-Mirror-Settings, nicht im Repo | Mirror-Status regelmaessig pruefen; lokalen Clone als zweite Kopie behalten |
|
||||
| Tailscale | Remote-/Operator-Zugang | hoch | Remote-Zugriff erschwert, lokale Bedienung bleibt | Tailnet-Konto; Node `Kallilabcore`, IPv4 `100.80.98.33` | Break-glass per LAN und physischem Zugriff; Tailnet-Recovery-Codes sichern |
|
||||
| GMX SMTP | Authelia Notifier | mittel | Mail-Notifier faellt aus, Login selbst nicht zwingend | GMX-Konto; SMTP-Secret liegt hostseitig | ntfy/zweiter SMTP als Fallback pruefen |
|
||||
| Let's Encrypt | TLS-Zertifikate | hoch | Cert-Erneuerung faellt aus | automatisch via Traefik und Cloudflare DNS-Challenge | Cert-Expiry Alert einrichten; Cloudflare-Token und Traefik-Storage pruefen |
|
||||
| Container Registries | Image Pulls von Docker Hub, GHCR, LSCR, Gitea Registry u. a. | mittel | Redeploy/Update blockiert | ueberwiegend oeffentlich; keine produktiven Registry-Tokens im Repo | Gepinnte Digests und lokale Runtime helfen kurzfristig; Updates geplant und einzeln deployen |
|
||||
| Plex Konto/Remote Access | Plex native Auth, ggf. Remote Access und Claim | mittel | Plex-Clients/Remote-Funktionen koennen ausfallen | Plex-Konto ausserhalb Repo; `PLEX_CLAIM` nur fuer Setup | LAN-Medienpfade bleiben lokal; Konto-Recovery separat sichern |
|
||||
| Mobile Push | ntfy und ggf. mobile Plattform-Pushes | niedrig/mittel | Alerts erreichen Mobilgeraete ggf. nicht | App-/Device-seitig | Kritische Alerts zusaetzlich in Grafana/Glance sichtbar halten |
|
||||
|
||||
## Kritische Secrets ausserhalb des Repos
|
||||
|
||||
Authoritativ ist `docs/SECRETS_MAP.md`. Diese Liste markiert nur externe Abhaengigkeiten.
|
||||
|
||||
| Secret | Zweck | Recovery-Hinweis |
|
||||
|---|---|---|
|
||||
| Borg Passphrase | Entschluesselung Borg-Repos | Muss analog/off-system vorhanden sein |
|
||||
| Cloudflare DNS API Token | ACME DNS-Challenge | Token-Rotation und Scope pruefen |
|
||||
| GitHub Mirror Token | Push-Mirror | In Gitea/GitHub verwaltet, nicht im Repo |
|
||||
| Tailscale Account Recovery | Tailnet-Zugang | Account-2FA/Recovery Codes sichern |
|
||||
| SMTP Passwort | Authelia Mail | In Host-Secret, Fallback pruefen |
|
||||
| Domain-Registrar Recovery | Domain-Besitz und Zahlung | Account, 2FA und Zahlungsweg ausserhalb des Homelabs sichern |
|
||||
| Hetzner Storage Box Zugang | Off-site Backup-Ziel | SSH-/Web-Zugang und Zahlungsweg extern sichern |
|
||||
|
||||
## Ausfall-Szenarien
|
||||
|
||||
### Hetzner Storage Box nicht erreichbar
|
||||
|
||||
- Lokales Borg-Repo und aktuelle Dumps pruefen.
|
||||
- Keine destruktiven Host-Aenderungen starten, solange Off-site unklar ist.
|
||||
- Zweites Off-site-Ziel oder Cold-Platte als Folgeaufgabe umsetzen.
|
||||
|
||||
### Cloudflare Account/DNS gestoert
|
||||
|
||||
- Bestehende Zertifikate laufen bis Ablauf weiter.
|
||||
- Keine Domain-/ACME-Aenderungen moeglich.
|
||||
- Tailscale/LAN-Zugang als Break-glass nutzen.
|
||||
|
||||
### Tailscale gestoert
|
||||
|
||||
- Lokalen LAN-Zugang nutzen.
|
||||
- Direkte Admin-Ports nur gemaess dokumentierten Ausnahmen verwenden.
|
||||
- AdGuard-Admin-Bind muss so geplant werden, dass ein lokaler Break-glass-Weg bekannt ist.
|
||||
- Seit 2026-05-26 ist AdGuard Admin nur ueber `100.80.98.33:8082` gebunden; bei Tailnet-Ausfall ist lokaler Host-/Compose-Zugriff der Break-glass-Weg.
|
||||
|
||||
### Domain verloren oder Registrar-Zugriff verloren
|
||||
|
||||
- Gitea/GitHub Mirror und lokale IP/Tailscale-Pfade fuer Recovery nutzen.
|
||||
- Neue Domain waere separater Migrationsfall fuer Traefik, Authelia, App-URLs und Clients.
|
||||
|
||||
## Review
|
||||
|
||||
| Datum | Ergebnis | Naechste Aktion |
|
||||
|---|---|---|
|
||||
| 2026-05-26 | Bekannte externe Abhaengigkeiten aus Repo-/Betriebsdoku dokumentiert; keine Secret-Werte aufgenommen | Account-Besitz, 2FA-Recovery-Codes, Zahlungswege und Borg-Passphrase extern bestaetigen |
|
||||
@@ -0,0 +1,38 @@
|
||||
# Family Onboarding - KalliLab CORE
|
||||
|
||||
Status: Entwurf. Zielgruppe sind Familienmitglieder, nicht Operatoren.
|
||||
|
||||
## Zweck
|
||||
|
||||
Diese Datei soll spaeter kurz und alltagstauglich erklaeren, wie die wichtigsten Dienste genutzt werden und was bei Problemen zu tun ist. Keine Restore-Matrix, keine Docker-Begriffe.
|
||||
|
||||
## Dienste
|
||||
|
||||
| Dienst | URL | Zweck | Konto / Login | Notiz |
|
||||
|---|---|---|---|---|
|
||||
| Nextcloud | `https://cloud.kaleschke.info` | Dateien, Kalender, Kontakte | TBD | Mobile App/WebDAV/CardDAV |
|
||||
| Immich | `https://immich.kaleschke.info` | Fotos und Smartphone-Backup | TBD | Backup-App pro Handy |
|
||||
| Vaultwarden | `https://vault.kaleschke.info` | Passwoerter | TBD | Familien-Organisation pruefen |
|
||||
| Mealie | `https://mealie.kaleschke.info` | Rezepte und Einkauf | TBD | TBD |
|
||||
| Paperless | `https://paperless.kaleschke.info` | Dokumente | TBD | Scan-/Inbox-Prozess beschreiben |
|
||||
| Plex | intern/App | Medien | TBD | TBD |
|
||||
|
||||
## Was tun bei Problemen?
|
||||
|
||||
| Situation | Verhalten |
|
||||
|---|---|
|
||||
| Webseite nicht erreichbar | 10 Minuten warten, dann Operator informieren |
|
||||
| Passwort vergessen | Operator informieren, nicht selbst neue Konten anlegen |
|
||||
| Handy-Foto-Backup stoppt | App oeffnen, WLAN/Batteriesparmodus pruefen, Operator informieren |
|
||||
| 2FA verloren | Operator informieren; Recovery-Prozess wird separat festgelegt |
|
||||
| Warnmeldung vom Browser | Nicht weiterklicken, Screenshot machen, Operator informieren |
|
||||
|
||||
## Offene Inhalte
|
||||
|
||||
| Status | Aufgabe |
|
||||
|---|---|
|
||||
| offen | Pro Dienst kurze Schritt-fuer-Schritt-Anleitung schreiben |
|
||||
| offen | Konto-/2FA-Policy final entscheiden |
|
||||
| offen | Immich Mobile Backup fuer alle Geraete testen |
|
||||
| offen | Vaultwarden Familienorganisation pruefen |
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# GitOps Drift Runbook
|
||||
|
||||
Dieses Runbook ist fuer Faelle, in denen Gitea, lokaler Clone, Komodo Workspace und Docker Runtime nicht sichtbar denselben Stand haben.
|
||||
|
||||
## Ziel
|
||||
|
||||
Vor jeder Reparatur muss klar sein, welche Ebene vom Sollzustand abweicht:
|
||||
|
||||
1. Lokaler Clone
|
||||
2. Gitea `origin/master`
|
||||
3. Komodo Stack Workspace auf dem Host
|
||||
4. Laufender Docker-Container
|
||||
5. Host-Netzwerklistener
|
||||
|
||||
Nicht mehrere Ebenen gleichzeitig reparieren. Erst messen, dann genau eine Abweichung beheben.
|
||||
|
||||
## Pflichtmatrix
|
||||
|
||||
### 1. Lokaler Clone
|
||||
|
||||
```bash
|
||||
git status -sb
|
||||
git rev-parse HEAD
|
||||
git rev-parse origin/master
|
||||
git ls-remote https://git.kaleschke.info/Micha/homelab-infra.git refs/heads/master
|
||||
```
|
||||
|
||||
Alle Hashes muessen gleich sein, bevor Komodo oder Runtime bewertet werden.
|
||||
|
||||
### 2. Komodo Workspace
|
||||
|
||||
Auf dem Unraid-Host im Stack-Workspace:
|
||||
|
||||
```bash
|
||||
cd /mnt/user/services/stacks/<stack-name>
|
||||
git rev-parse --short HEAD
|
||||
git status -sb
|
||||
```
|
||||
|
||||
Bei Drift:
|
||||
|
||||
```bash
|
||||
git fetch --all --prune
|
||||
git reset --hard origin/master
|
||||
```
|
||||
|
||||
Erst danach deployen.
|
||||
|
||||
### 3. Docker Runtime
|
||||
|
||||
```bash
|
||||
docker inspect <container> --format '{{.Created}}'
|
||||
docker inspect <container> --format '{{json .NetworkSettings.Networks}}'
|
||||
docker inspect <container> --format '{{json .NetworkSettings.Ports}}'
|
||||
docker inspect <container> --format '{{json .HostConfig.PortBindings}}'
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep <container-or-service>
|
||||
```
|
||||
|
||||
Wichtig: `HostConfig.PortBindings` ist nur die Container-Spezifikation. Entscheidend fuer einen aktiven Host-Port sind `NetworkSettings.Ports`, `docker ps` und ein echter Listener.
|
||||
|
||||
### 4. Host-Port / Listener
|
||||
|
||||
```bash
|
||||
ss -ltnp | grep <port>
|
||||
curl -i --max-time 5 http://<host-ip>:<port>/
|
||||
```
|
||||
|
||||
Ein `401 Unauthorized` ist bei geschuetzten APIs oft ein Erfolg: Dienst ist erreichbar, Auth fehlt nur beim Testrequest.
|
||||
|
||||
## Komodo/Periphery Checks
|
||||
|
||||
Wenn Komodo Stacks nicht aus Gitea deployen kann:
|
||||
|
||||
```bash
|
||||
docker inspect komodo-periphery --format '{{range .Mounts}}{{println .Source "->" .Destination}}{{end}}'
|
||||
docker inspect komodo-periphery --format '{{range $k,$v := .NetworkSettings.Networks}}{{println $k}}{{end}}'
|
||||
docker exec komodo-periphery sh -lc 'getent hosts git.kaleschke.info'
|
||||
docker exec komodo-periphery sh -lc 'wget -S -O- -T 5 --no-check-certificate https://git.kaleschke.info 2>&1 | head -40'
|
||||
```
|
||||
|
||||
Sollzustand:
|
||||
|
||||
- `/mnt/user/services -> /mnt/user/services` ist gemountet.
|
||||
- `komodo_net` und `frontend_net` sind verbunden.
|
||||
- `git.kaleschke.info` loest auf `192.168.178.58` auf.
|
||||
- HTTPS zu Gitea antwortet.
|
||||
|
||||
## InfluxDB LAN-Port Beispiel
|
||||
|
||||
Soll fuer Home Assistant:
|
||||
|
||||
```bash
|
||||
cd /mnt/user/services/stacks/monitoring
|
||||
git fetch --all --prune
|
||||
git reset --hard origin/master
|
||||
docker compose --env-file .env -p monitoring -f monitoring/docker-compose.yml up -d --force-recreate --no-deps influxdb3-core
|
||||
```
|
||||
|
||||
Danach pruefen:
|
||||
|
||||
```bash
|
||||
docker network ls | grep -E "monitoring|influx"
|
||||
docker inspect monitoring-influxdb3-core --format '{{json .NetworkSettings.Networks}}'
|
||||
docker inspect monitoring-influxdb3-core --format '{{json .NetworkSettings.Ports}}'
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep influx
|
||||
ss -ltnp | grep 8181
|
||||
curl -i --max-time 5 http://192.168.178.58:8181/
|
||||
```
|
||||
|
||||
Erwartung:
|
||||
|
||||
- Komodo Workspace `HEAD` entspricht `origin/master`.
|
||||
- `monitoring-influxdb3-core` haengt an `monitoring_monitoring_net` und `monitoring_monitoring_influx_lan`.
|
||||
- Docker zeigt `192.168.178.58:8181->8181/tcp`.
|
||||
- `ss` zeigt `docker-proxy` auf `192.168.178.58:8181`.
|
||||
- `curl` bekommt `401 Unauthorized` von InfluxDB.
|
||||
|
||||
Hinweis: Im Compose-File heissen die Netze `monitoring_net` und `monitoring_influx_lan`. Durch den Compose-Projektnamen `monitoring` koennen daraus zur Laufzeit Docker-Netze mit Projektpraefix werden.
|
||||
|
||||
## Stop-Regel
|
||||
|
||||
Wenn zwei Reparaturversuche nicht zum erwarteten Ergebnis fuehren:
|
||||
|
||||
1. Keine weiteren Schreibbefehle.
|
||||
2. Pflichtmatrix ausfuellen.
|
||||
3. Genau eine abweichende Ebene benennen.
|
||||
4. Erst danach einen neuen Fix ausfuehren.
|
||||
@@ -0,0 +1,187 @@
|
||||
# Hardware Inventory - KalliLab CORE
|
||||
|
||||
Status: Hardware-Baseline erfasst; USV/Power-Loss bleibt offene Betreiberentscheidung.
|
||||
Host: `Kallilabcore`
|
||||
Letzte Pruefung: 2026-05-26
|
||||
Naechster Review: 2026-08-26
|
||||
|
||||
## Zweck
|
||||
|
||||
Dieses Dokument beschreibt die physische Basis des Homelabs. Es ist die Grundlage fuer Capacity Planning, Restore-Zeit, Ersatzteilplanung, USV-Verhalten und Entscheidungen wie Immich-ML, Plex-Transcoding oder Storage-Erweiterung.
|
||||
|
||||
## Host
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Hostname | Kallilabcore |
|
||||
| Standort | Heim-LAN, physischer Standort TBD |
|
||||
| Betriebssystem | Unraid |
|
||||
| Unraid-Version | 7.2.4 |
|
||||
| Rolle | Single-Host Homelab, Docker Compose via Komodo |
|
||||
| Boot-Medium | Samsung Flash Drive, 59.8G, FAT32 |
|
||||
| Flash-Backup | In Borg-Scope aufgenommen, siehe `docs/MIGRATION_LOG.md` |
|
||||
|
||||
## CPU
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Modell | 12th Gen Intel(R) Core(TM) i5-12400F |
|
||||
| Kerne / Threads | 6 Kerne / 12 Threads |
|
||||
| Architektur | x86_64 |
|
||||
| Relevante Flags | AVX, AVX2, FMA, AES, VT-x vorhanden; kein AVX-512 |
|
||||
| iGPU / Quick Sync | Nein, `F`-CPU ohne iGPU |
|
||||
|
||||
Pruefkommando:
|
||||
|
||||
```bash
|
||||
cat /proc/cpuinfo | awk '/model name|flags/ {print; if(/flags/) exit}'
|
||||
lscpu
|
||||
```
|
||||
|
||||
## RAM
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Gesamt | 31 GiB |
|
||||
| Belegt im Normalbetrieb | ca. 7.9 GiB genutzt, ca. 23 GiB verfuegbar |
|
||||
| Slots / Ausbau | 4x 8 GB DDR4 belegt, gemischte Module |
|
||||
| Module | Crucial CT8G4DFS8266.C8FE, Crucial CT8G4DFS8213.C8FDD1, 2x G.Skill F4-3600C17-8GVK |
|
||||
| Konfigurierter Takt | 2133 MT/s |
|
||||
| ECC | Nein |
|
||||
|
||||
Pruefkommando:
|
||||
|
||||
```bash
|
||||
free -h
|
||||
dmidecode -t memory | grep -E "Size|Speed|Locator|Type" | head -40
|
||||
```
|
||||
|
||||
## Mainboard und Controller
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Mainboard | Gigabyte Technology Co., Ltd. B760M DS3H DDR4 |
|
||||
| BIOS/Firmware | American Megatrends International F21, Release 2025-06-19 |
|
||||
| SATA/HBA Controller | Intel Raptor Lake SATA AHCI Controller, onboard |
|
||||
| NVMe Controller | Samsung SM981/PM981/PM983 NVMe Controller |
|
||||
| NVMe Slots | mindestens 1 belegt |
|
||||
|
||||
Pruefkommando:
|
||||
|
||||
```bash
|
||||
dmidecode -t baseboard | head -30
|
||||
lspci
|
||||
```
|
||||
|
||||
## Netzwerk-Hardware
|
||||
|
||||
| Interface | Speed | Rolle | Bemerkung |
|
||||
|---|---:|---|---|
|
||||
| eth0 / bond0 / br0 | 1 Gbit/s full duplex | LAN | Realtek RTL8125 2.5GbE Controller, Link aktuell 1G; Host-IP `192.168.178.58/24`, Gateway `192.168.178.1` |
|
||||
| tailscale1 | virtuell | VPN | Tailscale IPv4 `100.80.98.33` |
|
||||
|
||||
Pruefkommando:
|
||||
|
||||
```bash
|
||||
ip -br link
|
||||
ethtool <interface>
|
||||
tailscale ip -4
|
||||
```
|
||||
|
||||
## Storage
|
||||
|
||||
| Slot | Device | Modell | Seriennummer | Groesse | Filesystem | Rolle | Health |
|
||||
|---|---|---|---|---:|---|---|---|
|
||||
| Cache | `nvme0n1p1` | Samsung SSD 970 EVO Plus 2TB | `S4J4NM0W609649H` | 1.8T | XFS | Appdata/system/domains | SMART passed |
|
||||
| Disk1 | `md1p1` / physisch `sdc` | WDC WD60EFAX-68JH4N1 | `WD-WX32D90PC0V0` | 5.5T | XFS auf md1p1 | Array-Daten | SMART passed |
|
||||
| Parity | physisch `sdb` | TOSHIBA HDWG480 | `2460A03VFA3H` | 7.3T | n/a | Parity | SMART passed |
|
||||
| Boot | `sda1` | Samsung Flash Drive | `0375125090000587` | 59.8G | FAT32 | Unraid Boot | aktiv |
|
||||
| Cold Backup | TBD | TBD | TBD | TBD | TBD | Externe Rotation | offen |
|
||||
|
||||
Pruefkommando:
|
||||
|
||||
```bash
|
||||
lsblk -o NAME,SIZE,MODEL,SERIAL,FSTYPE,MOUNTPOINT,VENDOR
|
||||
findmnt -no FSTYPE /mnt/cache /mnt/disk1 /boot
|
||||
df -h /mnt/cache /mnt/disk1 /mnt/user
|
||||
```
|
||||
|
||||
## SMART / Health
|
||||
|
||||
| Device | Letzter Check | Kritische Werte | Bewertung |
|
||||
|---|---|---|---|
|
||||
| /dev/nvme0n1 | 2026-05-26 | Critical Warning `0x00`, Percentage Used `0%`, Media Errors `0`, Power On Hours `370`, Written `5.87 TB` | gut |
|
||||
| /dev/sdb | 2026-05-26 | Reallocated `0`, Pending `0`, Uncorrectable `0`, CRC `1`, Power On Hours `8971` | gut, CRC-Wert beobachten |
|
||||
| /dev/sdc | 2026-05-26 | Reallocated `0`, Pending `0`, Uncorrectable `0`, CRC `0`, Power On Hours `14174` | gut |
|
||||
|
||||
Pruefkommando:
|
||||
|
||||
```bash
|
||||
smartctl -a /dev/nvme0n1
|
||||
smartctl -a /dev/sdb
|
||||
smartctl -a /dev/sdc
|
||||
```
|
||||
|
||||
## USV / Power Loss
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| USV vorhanden | Nicht validiert / keine erkannte USV |
|
||||
| Modell | Kein APC/Eaton/CyberPower-Geraet per `lsusb` erkannt |
|
||||
| Verbindung | `apcupsd` ist auf USB vorkonfiguriert, aber kein passendes USB-USV-Geraet sichtbar |
|
||||
| Software | `apcaccess` vorhanden; `apcupsd` laeuft nicht, `localhost:3551` liefert Connection refused |
|
||||
| Konfigurierte Schwellen | `BATTERYLEVEL 5`, `MINUTES 3`, `TIMEOUT 0`, aber inaktiv solange `apcupsd` nicht laeuft |
|
||||
| Laufzeit im Idle | Nicht messbar |
|
||||
| Letzter Shutdown-Test | Nicht durchgefuehrt |
|
||||
|
||||
Bewertung:
|
||||
|
||||
- Aktueller Befund 2026-05-26: keine funktionierende USV-Absicherung nachgewiesen.
|
||||
- `apcupsd` ist zwar auf dem System vorhanden, aber nicht aktiv.
|
||||
- Power-Loss bleibt damit ein bewusst offenes Risiko fuer Docker-/DB-State und laufende Writes.
|
||||
- Naechste Entscheidung: echte USV anschliessen und Shutdown testen oder Risiko bewusst akzeptieren und dokumentieren.
|
||||
|
||||
## Stromverbrauch
|
||||
|
||||
| Zustand | Verbrauch | Messmethode | Datum |
|
||||
|---|---:|---|---|
|
||||
| Idle | TBD | externes Messgeraet erforderlich | TBD |
|
||||
| Normalbetrieb | TBD | externes Messgeraet erforderlich | TBD |
|
||||
| Backup-Lauf | TBD | externes Messgeraet erforderlich | TBD |
|
||||
| Last | TBD | externes Messgeraet erforderlich | TBD |
|
||||
|
||||
## Ersatzteil- und Lifecycle-Plan
|
||||
|
||||
| Komponente | Trigger | Massnahme |
|
||||
|---|---|---|
|
||||
| Cache-NVMe | >70 % Fuellstand oder SMART-Warnung | Zweite NVMe / Pool-Entscheidung; aktuell 6 % belegt |
|
||||
| Disk1 | >80 % Fuellstand oder SMART-Warnung | Array-Erweiterung / Ersatz; aktuell 33 % belegt |
|
||||
| Parity | Kleiner als neue groesste Datenplatte | Parity-Upgrade vor Datenplatten-Upgrade |
|
||||
| Boot-USB | Lesefehler oder Alter TBD | Flash-Backup verifizieren, Ersatzstick vorbereiten |
|
||||
| RAM | Swap/OOM oder Immich/Nextcloud-Druck | Ausbau planen |
|
||||
| USV | keine funktionierende USV-Abschaltung | USV anschaffen/anschliessen oder Risiko schriftlich akzeptieren |
|
||||
|
||||
## Audit-Kommandos
|
||||
|
||||
```bash
|
||||
hostname
|
||||
uname -a
|
||||
cat /etc/unraid-version 2>/dev/null || true
|
||||
lscpu
|
||||
free -h
|
||||
dmidecode -t baseboard | head -30
|
||||
dmidecode -t bios -t system -t baseboard
|
||||
dmidecode -t memory | grep -E "Size|Speed|Locator|Type" | head -40
|
||||
lspci | egrep -i 'sata|ahci|raid|nvme|ethernet|network'
|
||||
ip -br link
|
||||
ethtool eth0
|
||||
tailscale ip -4
|
||||
lsblk -o NAME,SIZE,MODEL,SERIAL,FSTYPE,MOUNTPOINT,VENDOR
|
||||
df -Th /mnt/cache /mnt/disk1 /mnt/user /boot
|
||||
smartctl -a /dev/nvme0n1 | head -100
|
||||
smartctl -a /dev/sdb | head -100
|
||||
smartctl -a /dev/sdc | head -100
|
||||
apcaccess status
|
||||
/etc/rc.d/rc.apcupsd status
|
||||
lsusb
|
||||
```
|
||||
@@ -0,0 +1,138 @@
|
||||
# Home Assistant -> InfluxDB 3 -> Grafana
|
||||
|
||||
Ziel: Home Assistant schreibt ausgewaehlte Ecowitt- und Energiesensoren nach InfluxDB 3 Core. Grafana bleibt das Langzeit-Dashboard, Home Assistant bleibt die Automationszentrale.
|
||||
|
||||
## Live-Stand 2026-05-04
|
||||
|
||||
- Home Assistant ist per SSH unter `192.168.178.50:22222` erreichbar.
|
||||
- `ha core check` ist erfolgreich.
|
||||
- InfluxDB 3 Core ist von Home Assistant aus unter `http://192.168.178.58:8181/` erreichbar; `401 Unauthorized` ohne Token ist der erwartete Reachability-Test.
|
||||
- In `/homeassistant/configuration.yaml` ist noch kein `influxdb:`-Block aktiv.
|
||||
- In `/homeassistant/secrets.yaml` ist noch kein `influxdb3_homeassistant_token` eingetragen.
|
||||
- In der Home-Assistant-Entity-Registry ist noch keine Ecowitt-Integration sichtbar; aktuell existiert nur `weather.forecast_home`.
|
||||
|
||||
## 1. InfluxDB fuer Home Assistant erreichbar machen
|
||||
|
||||
Der Stack haelt InfluxDB bewusst ohne Traefik-Route. Fuer Home Assistant wird nur der HTTP-Port `8181` auf einer internen LAN-Adresse veroeffentlicht.
|
||||
|
||||
Im Zielzustand in Komodo/Stack-Environment fuer `monitoring` setzen:
|
||||
|
||||
```env
|
||||
INFLUXDB_BIND_IP=192.168.178.58
|
||||
```
|
||||
|
||||
`192.168.178.58` ist die LAN-IP des Docker-Hosts, auf dem `monitoring-influxdb3-core` laeuft. Nicht `0.0.0.0` verwenden, wenn es nicht notwendig ist.
|
||||
|
||||
Danach den Stack neu deployen und von Home Assistant aus pruefen:
|
||||
|
||||
```bash
|
||||
curl -i --max-time 5 http://192.168.178.58:8181/
|
||||
```
|
||||
|
||||
Erwartetes Ergebnis ohne Token: `401 Unauthorized`. Das bestaetigt, dass der LAN-Port erreichbar ist und Authentifizierung aktiv bleibt.
|
||||
|
||||
## 2. Token fuer Home Assistant
|
||||
|
||||
InfluxDB 3 Core unterstuetzt aktuell Admin- und Named-Admin-Tokens. Einen eigenen Named-Admin-Token fuer Home Assistant verwenden, damit der Token getrennt vom initialen Operator-/Admin-Token rotiert werden kann.
|
||||
|
||||
Den Token in Home Assistant eintragen:
|
||||
|
||||
```yaml
|
||||
# /homeassistant/secrets.yaml
|
||||
influxdb3_homeassistant_token: "apiv3_REPLACE_WITH_HOME_ASSISTANT_TOKEN"
|
||||
```
|
||||
|
||||
Token niemals ins Git-Repository schreiben.
|
||||
|
||||
## 3. Ecowitt in Home Assistant anbinden
|
||||
|
||||
Die offizielle Ecowitt-Integration ist eine lokale Push-Integration. Home Assistant erzeugt einen HTTP-Webhook; die Wetterstation sendet danach ihre Daten aktiv an Home Assistant.
|
||||
|
||||
1. In Home Assistant `Einstellungen -> Geraete & Dienste -> Integration hinzufuegen -> Ecowitt` oeffnen.
|
||||
2. Die angezeigten Werte fuer Server/IP, Pfad und Port notieren.
|
||||
3. Im Ecowitt-Gateway entweder per App oder Web-UI unter `Weather Services -> Customized` eintragen:
|
||||
- Customized: `Enable`
|
||||
- Protocol Type Same As: `Ecowitt`
|
||||
- Server/IP, Path und Port exakt wie in Home Assistant angezeigt
|
||||
4. Wichtig: Ecowitt kann kein HTTPS. Den lokalen HTTP-Endpunkt von Home Assistant verwenden, nicht eine reine HTTPS-/Traefik-URL.
|
||||
5. Nach dem ersten Push pruefen, ob neue `sensor.*`- und ggf. `binary_sensor.*`-Entities in Home Assistant auftauchen.
|
||||
|
||||
## 4. Home Assistant fuer InfluxDB konfigurieren
|
||||
|
||||
Minimaler Startblock fuer `/homeassistant/configuration.yaml`:
|
||||
|
||||
```yaml
|
||||
influxdb:
|
||||
api_version: 2
|
||||
ssl: false
|
||||
host: 192.168.178.58
|
||||
port: 8181
|
||||
token: !secret influxdb3_homeassistant_token
|
||||
organization: homelab
|
||||
bucket: homelab
|
||||
max_retries: 3
|
||||
default_measurement: state
|
||||
include:
|
||||
entity_globs:
|
||||
- sensor.*ecowitt*
|
||||
- sensor.*gw*
|
||||
- sensor.netzbezug_live
|
||||
- sensor.ins_netz_live
|
||||
- sensor.ins_haus_live
|
||||
- sensor.pv_ueberschuss_live
|
||||
- sensor.batterie_soc_live
|
||||
- sensor.wallbox_leistung_live
|
||||
- sensor.pv_produktion_heute
|
||||
- sensor.wallbox_energie_heute
|
||||
```
|
||||
|
||||
Die `sensor.*ecowitt*`- und `sensor.*gw*`-Regeln sind nur Startpunkte. Nach dem ersten Ecowitt-Push die echten Entity-IDs aus Home Assistant auslesen und die Liste enger machen.
|
||||
|
||||
Nach dem Edit:
|
||||
|
||||
```bash
|
||||
ha core check
|
||||
ha core restart
|
||||
```
|
||||
|
||||
## 5. Grafana Smoke-Test
|
||||
|
||||
In `https://monitoring.kaleschke.info` mit der bestehenden Datenquelle `InfluxDB 3 Core` eine SQL-Abfrage testen:
|
||||
|
||||
```sql
|
||||
SHOW TABLES
|
||||
```
|
||||
|
||||
Danach eine konkrete Tabelle pruefen. Home Assistant schreibt Measurements je nach Konfiguration und Entity-Typ; typische Tabellen sind `state`, `degC`, `%`, `W`, `kWh` oder aehnliche Measurement-Namen.
|
||||
|
||||
Beispiel fuer die ersten Werte:
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM state
|
||||
ORDER BY time DESC
|
||||
LIMIT 20
|
||||
```
|
||||
|
||||
## 6. Dashboard-Kandidaten
|
||||
|
||||
Sinnvolle Panels fuer das erste Wetter-Dashboard:
|
||||
|
||||
- Aussentemperatur und Luftfeuchte
|
||||
- Luftdruck
|
||||
- Windgeschwindigkeit und Boeen
|
||||
- Regenrate und Regen heute
|
||||
- UV und Solarstrahlung
|
||||
- Batterielevel der Ecowitt-Sensoren
|
||||
- PV-Ueberschuss vs. Wetter
|
||||
- Wallbox-Leistung vs. PV-Ueberschuss
|
||||
|
||||
## 7. Erste Automationen
|
||||
|
||||
Home Assistant bleibt fuer Reaktionen zustaendig:
|
||||
|
||||
- Regen startet -> Dachfenster/Fenster pruefen
|
||||
- Windboee ueber Grenzwert -> Markise/Rolllaeden schuetzen
|
||||
- Frostwarnung -> Garten/Wasser/Auto-Hinweis
|
||||
- Genug Regen heute -> Bewaesserung ueberspringen
|
||||
- Ecowitt-Batterie niedrig -> ntfy/HA-Benachrichtigung
|
||||
+345
-117
@@ -1,127 +1,355 @@
|
||||
# Migration Log — Homelab GitOps
|
||||
# Migration Log - Homelab GitOps
|
||||
|
||||
Dieses Dokument verfolgt den Fortschritt der Migration hin zu einem vollständigen GitOps-Setup (Gitea + Komodo).
|
||||
Dieses Dokument ist nur noch ein historischer Verlauf. Der aktuelle operative Ablauf steht in `docs/WORKFLOW.md`, das Zielbild in `HOMELAB_ARCHITECTURE_MASTER_V2.md`.
|
||||
|
||||
## Status-Legende
|
||||
- ⏳ = geplant
|
||||
- 🔄 = in Bearbeitung
|
||||
- ✅ = abgeschlossen
|
||||
- ⚠️ = Problem / prüfen
|
||||
## Aktueller Endstand
|
||||
|
||||
- Gitea Online ist der verbindliche Sollzustand.
|
||||
- Komodo ist der einzige produktive Stack-Manager.
|
||||
- Portainer CE ist entfernt.
|
||||
- Firefly, Firefly-Fints und Semaphore sind entfernt.
|
||||
- `monitoring/` ist der einzige aktive Observability-Stack; alte Repo-Pfade `ops/grafana-influxdb` und `ops/loki` sind entfernt.
|
||||
- Borg UI ist produktiv, Dump-Automatisierung laeuft host-seitig und ein Restore-Smoke-Test wurde erfolgreich durchgefuehrt.
|
||||
- GitHub Desktop ist der bevorzugte lokale Workflow fuer `Fetch`, `Pull`, `Commit` und `Push`.
|
||||
- Mutable Image-Tags sind auf die aktuell laufenden Digests eingefroren.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 1 — Quick Wins + Vaultwarden ✅
|
||||
## Historische Meilensteine
|
||||
|
||||
| Service | Status | Ergebnis |
|
||||
|---|---|---|
|
||||
| Vaultwarden | ✅ | Git-Stack, Traefik, `ADMIN_TOKEN_FILE`, Port entfernt |
|
||||
| PostgreSQL 17 | ✅ | `backend_net`, Port entfernt, `POSTGRES_PASSWORD_FILE` |
|
||||
### 2026-05-26 - Audit-Baseline-Tag gesetzt
|
||||
|
||||
- Der Stand nach Hardware-/Capacity-Baseline, Policy-Triage und Recovery-Doku wurde als `audit-2026-05-25-baseline` markiert und nach Gitea gepusht.
|
||||
|
||||
### 2026-05-26 - Externe Abhaengigkeiten und Services-Recovery baseline dokumentiert
|
||||
|
||||
- `docs/EXTERNAL_DEPENDENCIES.md` von Template auf Betreiber-Baseline angehoben: Domain, Cloudflare, Hetzner, GitHub-Mirror, Tailscale, GMX, Let's Encrypt, Registries, Plex und mobile Push-Pfade sind mit Ausfallwirkung und Notfallplan dokumentiert.
|
||||
- `docs/SERVICES_RECOVERY.md` finalisiert den Komodo-Bootstrap-Anker: `ops/komodo/docker-compose.yml` bleibt verbindlich; der `komodo`-Self-Stack hat keinen aktiven Gitea-Webhook und ist nicht der Recovery-Anker.
|
||||
- Offene Off-Repo-Betreiberchecks bleiben Account-Besitz, 2FA-Recovery-Codes, Zahlungswege, Borg-Passphrase-Hinterlegung und Gitea-Bundle-/Mirror-Mechanik.
|
||||
|
||||
### 2026-05-26 - Policy-Warnings triagiert
|
||||
|
||||
- Plex `network_mode: host` wurde in den Policy-Ausnahmen als dokumentierte Discovery-Ausnahme erfasst.
|
||||
- Mutable Tags bei `ddns-updater`, `glances` und `scrutiny` bleiben wegen vorhandener SHA256-Digests reproduzierbar gepinnt und werden im Policy-Report als Info-Ausnahmen sichtbar gehalten.
|
||||
- `monitoring-influxdb3-core` bleibt als dokumentierte `user: "0"`-Ausnahme bewusst eine Warning, damit der Hardening-Punkt nicht aus dem Blick faellt.
|
||||
|
||||
### 2026-05-26 - Hardware-/Capacity-Baseline abgeschlossen
|
||||
|
||||
- Hardware-Inventar auf Host-Befund aktualisiert: BIOS AMI F21 vom 2025-06-19, Intel Raptor Lake SATA AHCI, Samsung NVMe Controller und Realtek RTL8125 2.5GbE mit aktuellem 1G-Link.
|
||||
- RAM-Baseline dokumentiert: 4x 8 GB DDR4 ohne ECC, gemischte Module, aktuell 2133 MT/s konfiguriert.
|
||||
- Capacity-Baseline dokumentiert: Cache 1.9T mit 97G genutzt (6 %), Disk1/User-Shares 5.5T mit 1.8T genutzt (33 %), lokale Backups 2.2G unter `/mnt/user/backups`.
|
||||
- USV-Befund dokumentiert: `apcupsd` ist vorhanden und auf USB vorkonfiguriert, laeuft aber nicht; `apcaccess status` liefert Connection refused und `lsusb` zeigt keine erkannte USV. Power-Loss bleibt damit eine offene Betreiberentscheidung.
|
||||
|
||||
### 2026-05-26 - Komodo/Gitea-Restdrift bereinigt
|
||||
|
||||
- Der alte Komodo-Stack `grafana` wurde als historischer Altstand inert gemacht: keine Repo-Dateipfade, kein Webhook, keine alte Stack-ENV, keine `missing_files`/`remote_errors`. Rollback bleibt Git-Historie, nicht der alte Komodo-Stack.
|
||||
- Der Gitea-Hook `35` fuer den alten `grafana`-Stack bleibt inaktiv. Der nicht sinnvolle `komodo`-Self-Hook `11` wurde deaktiviert, weil Komodo selbst nicht per Gitea-Webhook auf `master` deployed wird.
|
||||
- Ein kurz sichtbarer Komodo-DB-Typfehler durch `updated_at` als Float wurde im selben Kontrollfenster auf nativen Mongo `Long` korrigiert; danach traten keine neuen `invalid type`-Fehler mehr auf.
|
||||
- Nach der Bereinigung: aktive Gitea-Komodo-Hooks haben `0` Fehlstatus; `komodo-core`, `komodo-periphery`, `komodo-mongo`, `nextcloud` und die aktuellen `monitoring-*` Container laufen weiter.
|
||||
|
||||
### 2026-05-26 - Monitoring-Altstaende aus aktivem Repo entfernt
|
||||
|
||||
- Die abgeloesten Pfade `ops/grafana-influxdb` und `ops/loki` wurden per `git rm` aus dem aktiven Repo entfernt. `monitoring/` bleibt der einzige Observability-Zielstack.
|
||||
- Live-Check vor dem Cleanup: nur `monitoring-grafana`, `monitoring-promtail`, `monitoring-influxdb3-core` und `monitoring-loki` laufen; alte Container `grafana`, `influxdb3-core`, `loki` und `alloy` sind nicht vorhanden.
|
||||
- Rollback erfolgt bei Bedarf ueber Git-Historie, nicht ueber parallel gepflegte Compose-Verzeichnisse.
|
||||
- Im selben GitOps-Kontrollfenster wurde Gitea-Webhook `35` fuer den alten `grafana`-Rollback-Stack als inaktiv bestaetigt. Der aktive Nextcloud-Hook `36` hatte einen Signaturfehler; sein Secret wurde ohne Ausgabe des Werts aus der Komodo-Stack-Konfiguration zurueck nach Gitea synchronisiert.
|
||||
|
||||
### 2026-05-26 - AdGuard Admin-Port auf Tailscale-Soll begrenzt
|
||||
|
||||
- Host-Audit per SSH gegen `Kallilabcore` durchgefuehrt: Tailscale IPv4 ist `100.80.98.33`, LAN-IP ist `192.168.178.58/24`, Gateway `192.168.178.1`.
|
||||
- Repo-Soll fuer `host-services/Adguard/docker-compose.yml` geaendert: DNS `53/tcp+udp` bleibt unveraendert, die Admin-UI bindet nun auf `100.80.98.33:8082:80`.
|
||||
- Architektur, Service-Katalog, Repo-Map, Netzwerk-Inventar und AI-Kontext wurden an das neue Modell angepasst: keine Traefik-/Authelia-2FA-Umstellung, aber keine LAN-weite Admin-Bindung mehr.
|
||||
- Live-Deploy wurde nach Fast-Forward des AdGuard-Workspaces auf `5cb4017` mit `docker compose -p adguard ... up -d` ausgefuehrt. Validierung erfolgreich: `ss -ltnp` zeigt `100.80.98.33:8082`, DNS via `@127.0.0.1` und `@192.168.178.58` funktioniert, `http://100.80.98.33:8082/` liefert HTTP 302, `http://192.168.178.58:8082/` ist nicht mehr erreichbar.
|
||||
- Nachpruefung des GitOps-Pfads: Gitea-Hook `1` zeigt auf Komodo-Stack `69c7b9e26b77cd827811b9d0` und lieferte HTTP 200. Komodo-Deploys fuer AdGuard scheiterten zunaechst im `Git pull` einmal an `.git/index.lock` und danach an `fatal: Cannot rebase onto multiple branches`; der Workspace wurde aufgeraeumt und steht sauber auf `origin/master`.
|
||||
|
||||
### 2026-05-26 - Audit-Umsetzung vorbereitet
|
||||
|
||||
- Aus `docs/AUDIT_2026-05-25.md` wurde `docs/AUDIT_2026-05-25_TODO.md` als operative Arbeitsliste abgeleitet. Authelia-2FA/OIDC bleibt bewusst geparkt und wird erst nach finaler Policy-Entscheidung umgesetzt.
|
||||
- Neue Inventar- und Betriebsdokumente angelegt: `docs/HARDWARE_INVENTORY.md`, `docs/NETWORK_INVENTORY.md`, `docs/EXTERNAL_DEPENDENCIES.md`, `docs/CAPACITY_AND_LIFECYCLE.md` und `docs/FAMILY_ONBOARDING.md`.
|
||||
- `docs/SERVICES_RECOVERY.md` beschreibt initial die recovery-kritischen `/mnt/user/services`-Pfade, Gitea-Repo-Mirror-Optionen, Komodo-Bootstrap und Secret-Recovery-Reihenfolge.
|
||||
- Policy-Check lokal erneut ausgefuehrt: die alten SEC001-Warnings fuer `ddns-updater` und `scrutiny` sind nicht mehr aktuell; verbleibende Warnings betreffen Host-Netz-/User-/Image-Tag-Themen und Altstaende.
|
||||
|
||||
### 2026-05-25 - Unraid Flash-Backup in Borg-Scope aufgenommen
|
||||
|
||||
- `pre-backup-dumps.sh` erzeugt zusaetzlich zu den DB-Dumps ein sensibles `unraid-flash-config.tar.gz` aus `/boot/config` inklusive SHA256 und Manifest unter `/mnt/user/backups/borg/dumps/latest`.
|
||||
- Da `/local/borg-dumps` bereits Teil des Borg-Scopes ist, wird das Flash-Konfigurationsartefakt mit dem bestehenden Hetzner/Borg-Backup historisiert. Downloadbare Plugin-Paketarchive unter `/boot/config/plugins/*/` werden aus dem Artefakt ausgeschlossen; Restore-relevante Konfiguration bleibt enthalten.
|
||||
- Live-Erstlauf erfolgreich: `pre-borg.sh` lieferte `critical_count=0`, Freshness `Critical: 0`, `unraid-flash-config.tar.gz` ist 297 KiB gross, `0600 root:root`, SHA256-Pruefung `OK`, 356 Archiv-Eintraege, Manifest fuer Unraid `7.2.4`. Der Borg-UI-Job `Taegliche Sicherung` ist aktiv und umfasst `/local/borg-dumps`; der naechste planmaessige Hetzner-Lauf nimmt das neue Flash-Artefakt mit. Der Host-Repo-Clone unter `/mnt/user/services/homelab-infra` wurde wegen eines fehlgeschlagenen Fast-Forward-Checkouts frisch geklont; der vorherige Stand liegt archiviert unter `/mnt/user/services/_archive/homelab-infra-pre-refresh-20260525-194209`.
|
||||
|
||||
### 2026-05-25 - Monitoring-Zielstack finalisiert und Uptime Kuma entfernt
|
||||
|
||||
- `monitoring` und `glance` wurden auf Commit `b6bbca4` deployed; Komodo zeigt fuer beide `latest_hash` = `deployed_hash` = `b6bbca4` ohne `remote_errors`. Die zehn `monitoring-*` Container laufen, `monitoring.kaleschke.info` und `glance.kaleschke.info` leiten anonym zu Authelia, Prometheus ist ready und Loki `/ready` liefert `ready`.
|
||||
- Alte Monitoring-Altcontainer `grafana`, `influxdb3-core`, `loki` und `alloy` sind in Docker nicht vorhanden; `ops/grafana-influxdb` und `ops/loki` bleiben nur als Rollback-/Migrationsreferenz im Repo. Der noch aktive Gitea-Hook `35` des alten `grafana`-Rollback-Stacks wurde deaktiviert, damit zukuenftige Pushes den Altstand nicht reaktivieren.
|
||||
- Uptime Kuma wurde durch Blackbox/Prometheus/Grafana ersetzt: aktive Blackbox-Zielliste enthaelt 19 HTTPS-Ziele, `uptime.kaleschke.info` ist dort nicht mehr enthalten und liefert nach Stack-Removal 404. Der Komodo-Stack `uptime-kuma` wurde gestoppt/destroyed/geloescht, Gitea-Webhook `23` deaktiviert, Appdata nach `/mnt/user/appdata/_archive/uptime-kuma-removed-2026-05-25` und der alte Stack-Workspace nach `/mnt/user/services/stacks/_archive/uptime-kuma-removed-2026-05-25` verschoben.
|
||||
- Authelia-Hostconfig wurde mit Backup `configuration.yml.pre-uptime-removal-20260525-164343.bak` um den toten `uptime.kaleschke.info`-Eintrag bereinigt, validiert und neu gestartet. Prometheus wurde wegen eines `Stale NFS file handle` auf der gebundenen Konfigurationsdatei per Komodo-Restart neu gemountet.
|
||||
|
||||
### 2026-05-25 - AdGuard Admin-Port bewusst LAN-direkt belassen
|
||||
|
||||
- Strategische Option `adguard.kaleschke.info` hinter Traefik/Authelia-2FA wurde bewertet, aber vom Operator bewusst verworfen, weil der Betriebsweg einfach bleiben soll. AdGuard bleibt als dokumentierte Ausnahme mit DNS `53/tcp+udp` und Admin `8082:80` LAN-direkt; keine Live-Aenderung an AdGuard, Authelia oder Traefik wurde vorgenommen.
|
||||
|
||||
### 2026-05-25 - Borg-Passphrase Host-Secret verifiziert
|
||||
|
||||
- Erwartete Host-Secret-Datei `/mnt/user/appdata/secrets/borg_repo_passphrase.txt` aus der bestehenden Borg-UI-Repo-Konfiguration erzeugt, mit `root:root` und Modus `600` gesichert und per `borg info` gegen das Hetzner-Borg-Repo verifiziert. Analoge Offline-Hinterlegung bleibt bewusste Operator-Aufgabe; Secret-Wert wurde nicht ausgegeben oder dokumentiert.
|
||||
|
||||
### 2026-05-25 - Dashboard auf Glance konsolidiert
|
||||
|
||||
- Glance bleibt das einzige Homelab-Dashboard; Homepage wurde aus dem Zielbild entfernt. Authelia-Default-Redirect, Monitoring-Blackbox-Ziele, Cert-Check-Domains und Glance-Konfiguration zeigen nicht mehr auf `home.kaleschke.info`; Homepage wurde via Komodo-API gestoppt/destroyed, der Komodo-Stack geloescht, der alte Gitea-Webhook deaktiviert und Appdata nach `/mnt/user/appdata/_archive/homepage-removed-2026-05-25` verschoben.
|
||||
|
||||
### 2026-05-25 - Jellyfin aus Zielbild entfernt
|
||||
|
||||
- Plex-Smoke-Test erfolgreich (`/identity` HTTP 200, Container healthy, `/data/movies` und `/photos` sichtbar). Jellyfin wurde repo-seitig entfernt, aus Authelia-Baseline und Zielbild-Doku ausgetragen, via Komodo-API gestoppt/destroyed, der Komodo-Stack geloescht und Appdata nach `/mnt/user/appdata/_archive/jellyfin-removed-2026-05-25` verschoben; Plex bleibt einziger Medienserver.
|
||||
|
||||
### 2026-05-25 - Externer Repo-Mirror eingerichtet
|
||||
|
||||
- Gitea erlaubt fuer Repo-Migrationen und Mirror-Targets gezielt `github.com` und nutzt explizite externe DNS-Resolver. `Micha/homelab-infra` spiegelt nun als privater GitHub-Push-Mirror nach `michaelkaleschke-spec/homelab-infra`; erster manueller Sync erfolgreich, Gitea `push_mirror.last_error` leer. Token-Werte bleiben ausschliesslich in Gitea/GitHub und werden nicht dokumentiert.
|
||||
|
||||
### 2026-05-25 - Audit-Final nachgemessen
|
||||
|
||||
- Audit-Restliste erneut live geprueft: runtime-relevanter Stack-Inhalt fuer `gitea`, `borg-ui` und `monitoring` seit `66ee10c` unveraendert; abschliessende Audit-Doku-Commits liegen in Gitea; Monitoring inklusive Loki `/ready` gruen; Borg-Job und 15 kanonische Dump-Artefakte frisch; `docs/AUDIT_2026-05-23_FINAL.md` auf den Live-Stand aktualisiert.
|
||||
|
||||
### 2026-05-25 - Disk1 Phase 2 abgeschlossen
|
||||
|
||||
- Disk1 wurde nach H:-Freeze-Backup und finalem Service-Freeze von NTFS/`ntfs3` auf XFS migriert.
|
||||
- Restore verifiziert: `media` final 2722 Dateien und 1,800,782,188,226 Bytes mit 0 missing/extra/size mismatch; Tar-Shares und Disk1-Extras aus den H:-Freeze-Archiven wiederhergestellt.
|
||||
- Docker/Services nach XDG-Runtime-Fix wieder stabil: 49 Container laufend, 0 stopped, 0 unhealthy, 0 starting; Gitea, Komodo, Borg, Jellyfin und Monitoring per Smoke-Test erreichbar.
|
||||
- Borg-UI meldet den letzten Backup-Job `completed`; `pre-backup-dumps.sh` wurde nach Wiederanlauf erneut ausgefuehrt und 15 kanonische Dump-Artefakte sind juenger als 24 h.
|
||||
- `posture-check` erwartet Disk1 nun standardmaessig als XFS (`ALLOW_DISK1_NTFS=0`).
|
||||
|
||||
### 2026-05-23 - Audit-Endstufe verifiziert
|
||||
|
||||
- Lokalen Hardening-Commit `cd650b1` nach Gitea gepusht; Komodo-Workspaces fuer `gitea`, `borg-ui` und `monitoring` stehen auf `cd650b1`.
|
||||
- Live-Audit in `docs/AUDIT_2026-05-23_LIVE.md` dokumentiert: Gitea-Registration geschlossen, Borg-Dumps frisch, Monitoring-Stack aktiv, alte Grafana/Loki-Altcontainer nicht mehr vorhanden.
|
||||
- Jellyfin und Plex in Architektur, Service-Katalog und Repo-Map nachgetragen; Plex ist jetzt als Repo-Compose-Stack mit dokumentierter Host-Netz-Ausnahme gefuehrt.
|
||||
- Repo-Hygiene abgeschlossen: `.serena/` ignoriert, leere Verzeichnisse entfernt, Windows-Reinstall-Helfer unter `ops/windows-reinstall/` bewusst versioniert.
|
||||
|
||||
### 2026-05-20 - Gitea 5xx-Bursts untersucht und Signup geschlossen
|
||||
|
||||
- Live-Befund zu `HomelabTraefik5xx`: kurze externe `POST /`-Bursts auf `gitea@docker` von `103.153.183.69` und `103.153.183.73`, jeweils HTTP 500 in unter 10 ms; normale Gitea-Checks und Git-Reads liefen parallel mit HTTP 200.
|
||||
- Keine Hinweise auf erfolgreichen Zugriff: Gitea-Container ohne Restart/OOM, nur User `micha`, keine neuen User der letzten 30 Tage, keine neuen Repos, SSH-Keys oder Access-Tokens im Untersuchungsfenster.
|
||||
- Live-Prometheus lief noch mit der alten Regel `rate(...[5m]) > 0`; die bereits im Repo vorbereitete Regel `increase(...[5m]) >= 5` wurde auf den Live-Mount kopiert und per Prometheus-Reload aktiviert.
|
||||
- Gitea-Registrierung und OpenID-Signup wurden geschlossen: `DISABLE_REGISTRATION=true`, `REGISTER_EMAIL_CONFIRM=true`, `ENABLE_OPENID_SIGNIN=false`, `ENABLE_OPENID_SIGNUP=false`; Signup-Seite zeigt danach "Registration is disabled", OpenID-Login liefert 403.
|
||||
|
||||
### 2026-05-18 - Komodo Webhooks vollstaendig abgeglichen
|
||||
|
||||
- Live-Befund auf `Kallilabcore`: Komodo hatte fuer mehrere aktuelle Stacks `webhook_enabled: true`, aber Gitea enthielt noch nicht fuer alle aktuellen Stack-IDs aktive Webhooks.
|
||||
- In der Gitea-Datenbank wurden aktive Webhooks fuer `monitoring` (`6a08d5297707b0930ab95c72`), `glance` (`6a09d7347707b0930ab96eae`), `grafana` (`69f31ecdf65eb72b757c497d`) und `nextcloud` (`69e519085fd5e8bc51f121f0`) nach dem bestehenden Komodo-Hook-Muster angelegt.
|
||||
- Stale aktive Gitea-Hooks auf nicht mehr vorhandene bzw. alte Komodo-Stack-IDs wurden deaktiviert.
|
||||
- Abgleich danach: 30 aktive Gitea-Komodo-Hooks fuer 30 Komodo-Stacks mit aktiviertem Webhook; `hermes` bleibt in Komodo bewusst `webhook_enabled: false`.
|
||||
- Netzwerkpfad aus dem `gitea`-Container zu `komodo-core:9120` wurde erfolgreich verifiziert; `last_status=0` fuer neue Hooks bleibt bis zum ersten Push erwartbar.
|
||||
|
||||
### 2026-05-19 - Posture-Check Host-Version verifiziert
|
||||
|
||||
- Ursache fuer wiederholte ntfy-Warnings war nicht mehr die Repo-Logik allein, sondern dass auf dem Unraid-Host noch die alte Skriptversion unter `/mnt/user/services/homelab-infra/services/posture-check/posture-check.sh` ausgefuehrt wurde.
|
||||
- Host-Skript wurde mit Backup ersetzt und mit `SEND_NTFY=0` direkt auf dem Host verifiziert.
|
||||
- Ergebnis des echten Host-Laufs: `status: ok`, `critical_count: 0`, `warning_count: 0`.
|
||||
- Betriebsregel daraus: Bei Host-User-Scripts nach Repo-Aenderungen immer den tatsaechlich ausgefuehrten Host-Pfad und den Live-Output pruefen.
|
||||
|
||||
### 2026-05-19 - Borg-Scope fuer GitOps Host Automation erweitert
|
||||
|
||||
- Nach den Gitea-/Komodo-Webhook- und Posture-Check-Aenderungen wurde der Backup-Scope um Host-GitOps-Pfade erweitert.
|
||||
- Borg UI mountet kuenftig `/mnt/user/services` read-only als `/local/services`.
|
||||
- In `all-important-sources.txt` wurden `/local/services/homelab-infra`, `/local/services/stacks` und `/local/services/posture-check` aufgenommen.
|
||||
- `pre-backup-dumps.sh` wurde auf dem Host ausgefuehrt; frische Dumps fuer `gitea.sqlite.dump` und `komodo-mongo.archive.gz` liegen unter `/mnt/user/backups/borg/dumps/latest`.
|
||||
- Wirksam wird der neue `/local/services`-Mount nach Redeploy/Recreate des `borg-ui`-Stacks.
|
||||
|
||||
### 2026-05-19 - Traefik-5xx Alert entstoert
|
||||
|
||||
- `HomelabTraefik5xx` hatte auf einzelne 5xx-Antworten reagiert, weil die Regel `rate(...[5m]) > 0` nutzte.
|
||||
- Live-Befund fuer `gitea@docker`: zwei kurze `POST /` mit HTTP 500 von einer externen IP, danach durchgehend erfolgreiche Gitea-Checks; kein Container-Restart.
|
||||
- Prometheus-Regel auf `increase(...[5m]) >= 5` geaendert, damit einzelne externe Fehlrequests keinen ntfy-Alarm ausloesen.
|
||||
|
||||
### 2026-05-17 - Glance Homelab-Dashboard vorbereitet
|
||||
|
||||
- `ops/glance` als geschuetztes Homelab-Dashboard unter `glance.kaleschke.info` vorbereitet.
|
||||
- Glance zeigt HTTP-Monitore fuer Core, Apps und Ops, Docker-Containergruppen, Host-Snapshot und Bookmarks.
|
||||
- Docker-Status laeuft nicht ueber einen direkten Socket-Mount in Glance, sondern ueber `glance-docker-socket-proxy` auf einem internen `glance_socket_net`.
|
||||
- Die HTTP-Monitore nutzen oeffentliche URLs als Klickziel und interne `check-url`-Endpunkte auf `frontend_net`, damit Glance nicht vom externen Hairpin-/Auth-Pfad abhaengt.
|
||||
- Das Immich Community-Widget wurde ergaenzt. Der API-Zugriff nutzt eine interne Service-URL und ein Stack-ENV-Token. Paperless, Scrutiny und Speedtest bleiben Kandidaten fuer einen spaeteren Widget-Pass, sobald die konkrete API-Ausgabe im Glance-Kontext sauber verifiziert ist.
|
||||
- Das Dashboard-Layout wurde an `ginesjunior11/glance-dashboard-config` angelehnt: dunkleres blaues Theme, Zeitfortschrittsgruppe, farbige Dashboard-Icons, dichter `Homelab Status`, Server-Stats im Hauptbereich und eine zweite Seite `Infrastructure and Media`. Die rechte Home-Spalte zeigt WAN-Infos aus Speedtest Tracker, Speedtest-Livewerte, AdGuard-DNS-Stats, DNS/Ingress-Monitore und eine separate Netzwerk-Containergruppe.
|
||||
|
||||
### 2026-05-17 - Monitoring-Zielstack konsolidiert
|
||||
|
||||
- `monitoring/` als zentraler Observability-Zielstack fuer Prometheus, Loki, Promtail, Grafana, node-exporter, cAdvisor und InfluxDB 3 Core vorbereitet.
|
||||
- `monitoring-grafana` nutzt den Repo-Standard `authelia@file,secure-headers@file` und Secrets per Datei statt Klartext-Stack-ENV.
|
||||
- `monitoring-influxdb3-core` uebernimmt den LAN-only Writer-Endpunkt fuer Home Assistant (`8181` via `INFLUXDB_BIND_IP`).
|
||||
- `ops/loki` und `ops/grafana-influxdb` sind abgeloeste Altstaende und bleiben nur als Rollback-/Migrationsreferenz im Repo.
|
||||
|
||||
### 2026-05-07 - Vaultwarden Restore-Test praktisch verifiziert
|
||||
|
||||
- Erster echter Vaultwarden-Mini-Restore gegen das produktive Borg-Repo `hetzner_borg_appdata_critical` erfolgreich durchgefuehrt.
|
||||
- Restore lief isoliert nach `/mnt/user/backups/restore-lab/vaultwarden`, nicht gegen produktive Pfade.
|
||||
- Testinstanz `restoretest-vaultwarden` wurde lokal auf `127.0.0.1:18080` gestartet; HTTP 200 und Login-Seite wurden erfolgreich bestaetigt.
|
||||
- Report wurde unter `/mnt/user/backups/restore-reports/vaultwarden-2026-05-07.md` geschrieben.
|
||||
- Fuer den praktischen Restore-Pfad wurden zwei hostseitige Voraussetzungen sichtbar und umgesetzt:
|
||||
- `known_hosts` fuer das Hetzner-Ziel im `borg-ui`-Container
|
||||
- Host-Secret-Datei `/mnt/user/appdata/secrets/borg_repo_passphrase.txt` fuer kuenftige Restore-Tests
|
||||
- Testdaten unter `/mnt/user/backups/restore-lab/vaultwarden/data` wurden nach erfolgreichem Lauf wieder bereinigt.
|
||||
|
||||
### 2026-05-07 - Gitea Restore-Test praktisch verifiziert
|
||||
|
||||
- Erster echter Gitea-Mini-Restore gegen das produktive Borg-Repo `hetzner_borg_appdata_critical` erfolgreich durchgefuehrt.
|
||||
- Restore lief isoliert nach `/mnt/user/backups/restore-lab/gitea`, nicht gegen produktive Pfade.
|
||||
- Testinstanz `restoretest-gitea` wurde lokal auf `127.0.0.1:13000` und `127.0.0.1:12222` gestartet.
|
||||
- HTTP 200, HTML-Titel und lokaler SSH-Port wurden erfolgreich bestaetigt.
|
||||
- Report wurde unter `/mnt/user/backups/restore-reports/gitea-2026-05-07.md` geschrieben.
|
||||
- Testdaten unter `/mnt/user/backups/restore-lab/gitea/data` wurden nach erfolgreichem Lauf wieder bereinigt.
|
||||
|
||||
### 2026-05-07 - Paperless Restore-Test praktisch verifiziert
|
||||
|
||||
- Erster echter Paperless-Mini-Restore gegen das produktive Borg-Repo `hetzner_borg_appdata_critical` erfolgreich durchgefuehrt.
|
||||
- Restore umfasste sowohl die Dateipfade als auch `postgresql17-paperless.dump` aus dem Borg-Archiv.
|
||||
- Testinstanzen `restoretest-paperless`, `restoretest-paperless-postgres` und `restoretest-paperless-redis` liefen isoliert ohne Traefik.
|
||||
- Login-Seite war lokal auf `127.0.0.1:18120` erreichbar.
|
||||
- Der Dump-Import in Test-Postgres war erfolgreich; die Test-Datenbank enthielt `25` Dokumente.
|
||||
- Report wurde unter `/mnt/user/backups/restore-reports/paperless-2026-05-07.md` geschrieben.
|
||||
- Testdaten unter `/mnt/user/backups/restore-lab/paperless` wurden nach erfolgreichem Lauf wieder bereinigt.
|
||||
|
||||
### 2026-05-06 - Komodo Webhook Secret getrennt
|
||||
|
||||
- `KOMODO_WEBHOOK_SECRET` von `KOMODO_SECRET_KEY` getrennt und als eigene Stack-ENV-Variable dokumentiert.
|
||||
- Gitea-Komodo-Webhooks mit bisherigem Core-Secret wurden auf den neuen `KOMODO_WEBHOOK_SECRET` umgestellt; bereits individuelle per-Stack-Webhook-Secrets wurden beibehalten.
|
||||
- Host-`.env`, persistente Komodo-Compose und Gitea-Webhooks wurden als ein gemeinsamer Runtime-Schritt behandelt, damit Auto-Deploys nicht auseinanderlaufen.
|
||||
- Ein stale Gitea-Webhook auf eine nicht mehr vorhandene Komodo-Stack-ID wurde deaktiviert, nicht geloescht.
|
||||
|
||||
### 2026-05-06 - Authelia GMX SMTP Notifier
|
||||
|
||||
- Authelia-Notifier von Filesystem-Log auf GMX SMTP (`submission://mail.gmx.net:587`) umgestellt.
|
||||
- SMTP-Passwort bleibt ausserhalb des Repos unter `/mnt/user/appdata/secrets/authelia_smtp_password.txt`.
|
||||
- Authelia-Compose erhaelt explizite DNS-Server, weil der SMTP-Startup-Check externe Namen wie `mail.gmx.net` aufloesen muss.
|
||||
- Repo-Baseline und Host-Config muessen bei Auth-Aenderungen weiter bewusst gemerged und vor Restart validiert werden.
|
||||
|
||||
### 2026-05-06 - Hermes DR und Mail-Archiver Authelia
|
||||
|
||||
- Hermes Agent in `docs/RESTORE_MATRIX.md` und `docs/DISASTER_RECOVERY.md` mit Restore-Pfaden, Secret-/ENV-Hinweisen und Smoke-Test ergaenzt.
|
||||
- Mail-Archiver Web-UI hinter `authelia@file,secure-headers@file` gelegt; App-eigene Auth bleibt als zweite Schutzschicht bestehen.
|
||||
- M10/Komodo blieb unveraendert.
|
||||
|
||||
### 2026-05-05 - N-Aufraeum-Sprint
|
||||
|
||||
- Obsolete Compose-Top-Level-Felder `version: "3.9"` aus Immich, Mail Archiver und Paperless entfernt.
|
||||
- Leere `env/domains.env.example` und `env/global.env.example` mit nicht geheimen Beispielwerten gefuellt.
|
||||
- Veraltete `.keep`-Platzhalter aus Verzeichnissen mit echten Compose-/Repo-Inhalten sowie zwei reine Geister-Verzeichnisse (`host-services/plex`, `infra/dns`) entfernt.
|
||||
|
||||
### 2026-05-16 - Backup-Konsistenz und erster Hardening-Schnitt
|
||||
|
||||
- SQLite-Dumps fuer Gitea, Vaultwarden, Speedtest Tracker und Filebrowser werden containerseitig als `*.sqlite.dump` erzeugt und per Freshness-Check geprueft; Uptime Kuma wurde am 2026-05-25 aus dem aktiven Dump-Scope entfernt.
|
||||
- `nextcloud.dump` und die Nextcloud-Userdaten sind als Option A im Borg-Scope dokumentiert.
|
||||
- Filebrowser mountet keine breite `/mnt/user/appdata`-Flaeche mehr, sondern nur noch Documents, Photos, Projekte sowie eigenen App-State.
|
||||
- Authelia Argon2id-Parameter in der Repo-Baseline auf `iterations: 3`, `memory: 65536`, `parallelism: 4` gesetzt; produktive Host-Config muss kontrolliert gemerged und mit Test-User validiert werden.
|
||||
- Redis-Caches wurden auf `redis:7.4-alpine@sha256:...` vereinheitlicht; Nextcloud wurde mit Registry-validiertem Digest gepinnt.
|
||||
- Eindeutig aufloesbare `latest@sha256`-Images wurden auf konkrete Tags umgestellt: Homepage `v1.12.3`, code-server `4.116.0`, Filebrowser `v2.63.2`, Speedtest Tracker `1.13.12`.
|
||||
|
||||
### 2026-05-05 - M3b versionierte App-Images digest-gepinnt
|
||||
|
||||
- Versionierte Nicht-Komodo-Images fuer BentoPDF, Mealie, Paperless, Paperless-GPT, AdGuard Home, Grafana, InfluxDB 3 Core und Traefik auf die am Host laufenden, manifest-validierten Digests gepinnt.
|
||||
- `nextcloud:33.0.2-apache` wurde bewusst nicht in diesem Schritt gepinnt, weil der lokal gelistete Digest nicht als Registry-Manifest fuer `tag@sha256` validierbar war.
|
||||
- Redis-Caches und Komodo/M10 blieben unveraendert.
|
||||
|
||||
### 2026-05-05 - M6/M7/M8 Doku-Konsolidierung
|
||||
|
||||
- `hermes.kaleschke.info` als produktive Hermes-Dashboard-Route hinter Traefik + Authelia in Architektur, Repo-Map und Service-Katalog ergaenzt.
|
||||
- `grafana` und `influxdb3-core` laufen weiterhin als `user: "0"`; das wurde als Host-Appdata-Permissions-Ausnahme dokumentiert und nicht nebenbei geaendert.
|
||||
- Tailscale-Ausnahme um `NET_ADMIN`, `NET_RAW` und `/dev/net/tun` ergaenzt.
|
||||
- Komodo-Secret-/Webhook-Themen wurden bewusst nicht geaendert; Komodo-Aenderungen erfolgen nur gemeinsam mit dem Betreiber.
|
||||
|
||||
### 2026-05-05 - M3a stateful Digest-Pinning
|
||||
|
||||
- PostgreSQL 17 Datenhalter auf `postgres:17.9@sha256:5b96f1a16bd9768b060dd2ffe55cb6225c4d9ef4d214a8b21eb08134869a97e4` gepinnt (`postgresql17`, `mealie-postgres`, `nextcloud-postgres`).
|
||||
- Immich pgvector-Postgres auf `tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52` gepinnt.
|
||||
- Komodo Mongo auf `mongo:7.0.32@sha256:32979a1189dfdc44da3f5ed40d910495f5ad8f6f7f77556646f890a30b2d3f56` sowie Komodo Core/Periphery und Gitea auf die am Host laufenden Digests gepinnt.
|
||||
- Redis-Caches wurden am 2026-05-16 auf `redis:7.4-alpine@sha256:...` vereinheitlicht; Redeploys erfolgen stackweise mit Smoke-Test, nicht parallel.
|
||||
|
||||
### 2026-05-04 - Komodo Self-Stack Drift auf persistenten Pfad zurueckgefuehrt
|
||||
|
||||
- Drift-Befund: `komodo-core` und `komodo-periphery` liefen aus `/tmp/komodo-core-repair.yml` bzw. `/tmp/komodo-periphery-repair.yml`; `komodo-mongo` verwies auf `/mnt/user/services/stacks/komodo/compose.yaml`, obwohl dieser Pfad fehlte.
|
||||
- Vor Eingriff wurden die Repair-Dateien und zugehoerigen `/tmp/*.env`-Dateien unter `/mnt/user/appdata/komodo/_drift_backup_2026-05-04/` gesichert.
|
||||
- Zusaetzlich wurde eine geschuetzte Recovery-ENV unter `/mnt/user/appdata/secrets/_komodo_stack_env_recovery_2026-05-04.env` abgelegt; diese Datei enthaelt Tier-1-Secret-Material und ist kein Dauerzustand.
|
||||
- Vor dem Reconcile wurde das host-seitige Dump-Skript ausgefuehrt; `komodo-mongo.archive.gz` wurde frisch unter `/mnt/user/backups/borg/dumps/latest/` erzeugt.
|
||||
- Persistenter Self-Stack wurde unter `/mnt/user/services/stacks/komodo/compose.yaml` aus `ops/komodo/docker-compose.yml` wiederhergestellt; `.env` wurde hostseitig aus der bestehenden Runtime-ENV abgeleitet.
|
||||
- Der vollstaendige Dry-run haette auch `komodo-mongo` recreated und wurde daher nicht ausgefuehrt. Stattdessen wurden nur `komodo-core` und `komodo-periphery` gezielt mit `--no-deps --force-recreate` aus dem persistenten Pfad neu erstellt; `komodo-mongo` blieb unveraendert healthy.
|
||||
- Smoke-Tests: `docker compose ls` zeigt fuer `komodo` nur noch `/mnt/user/services/stacks/komodo/compose.yaml`, Mongo pingt `{ ok: 1 }`, `https://komodo.kaleschke.info` liefert HTTP 200, und Periphery meldet sich am Core an.
|
||||
- Die `/tmp/*repair.yml`-Dateien bleiben vorerst als Altlast erhalten und duerfen erst nach stabiler Laufzeit bewusst entfernt oder ins Drift-Backup verschoben werden.
|
||||
|
||||
### 2026-05-04 - Authelia ACL-Drift hostseitig gemerged
|
||||
|
||||
- Die produktive Authelia-Config ist groesser als die Repo-Datei, weil sie hostseitige OIDC-/Secret-Konfiguration enthaelt. Die Repo-Datei wurde daher als nicht geheime Baseline eingeordnet und nicht blind auf den Host kopiert.
|
||||
- Host-Backup vor Aenderung: `/mnt/user/appdata/authelia/config/configuration.yml.bak-20260504-acl-sync`.
|
||||
- Minimaler Host-Merge: `homepage.kaleschke.info` wurde aus der bypass-Liste entfernt, `komodo.kaleschke.info` aus der 2FA-Liste entfernt, und `default_redirection_url` wurde auf `https://home.kaleschke.info` gesetzt.
|
||||
- `authelia validate-config` war erfolgreich; Authelia wurde neu gestartet und war danach healthy.
|
||||
- Smoke-Tests: `home.kaleschke.info` liefert fuer anonyme Requests eine Authelia-Weiterleitung, `komodo.kaleschke.info` bleibt ueber native Komodo-Auth erreichbar.
|
||||
|
||||
### 2026-05-04 - Home Assistant InfluxDB LAN-Port und Drift-Runbook
|
||||
|
||||
- `influxdb3-core` fuer Home-Assistant-Writer auf LAN-Port `8181` vorbereitet und deployed.
|
||||
- InfluxDB bleibt ohne Traefik-/Public-Route und haengt nicht im `frontend_net`.
|
||||
- Fuer aktives Docker Host-Port-Publishing wurde zusaetzlich zum internen `grafana_influx_internal` das Compose-Netz `grafana_influx_lan` ergaenzt.
|
||||
- Komodo Periphery dauerhaft um `/mnt/user/services:/mnt/user/services` und `frontend_net` ergaenzt, damit Stack-Workspaces und Gitea-Zugriff reproduzierbar funktionieren.
|
||||
- `docs/GITOPS_DRIFT_RUNBOOK.md` angelegt, um lokale Git-Kopie, Gitea, Komodo Workspace, Docker Runtime und Host-Listener getrennt zu pruefen.
|
||||
|
||||
### 2026-03-28 - GitOps-Konsolidierung
|
||||
|
||||
- Komodo als primaeren Stack-Manager eingefuehrt.
|
||||
- Portainer aus dem Zielbild herausgenommen.
|
||||
- Traefik auf 100% Docker-Labels konsolidiert.
|
||||
- `diun` entfernt; Update-Monitoring wird ueber Komodo abgedeckt.
|
||||
|
||||
### 2026-03-29 - Portainer abgeschaltet
|
||||
|
||||
- Portainer CE aus dem produktiven Betrieb entfernt.
|
||||
- Komodo als alleinigen Stack-Manager festgezogen.
|
||||
|
||||
### 2026-04-13 bis 2026-04-15 - Borg-Rollout abgeschlossen
|
||||
|
||||
- `critical_infra` erfolgreich nach Borg gesichert.
|
||||
- Pre-Backup-Dumps host-seitig ueber Unraid User Scripts etabliert.
|
||||
- Dump-Zielpfad auf `/mnt/user/backups/borg/dumps` umgestellt.
|
||||
- Restore-Smoke-Test fuer `postgresql17-globals.sql` und `gitea.db` erfolgreich nachgewiesen.
|
||||
- Monitoring fuer Borg war historisch ueber `ntfy` und Uptime Kuma eingerichtet; seit 2026-05-25 ersetzt durch `ntfy`, Blackbox/Prometheus und Monitoring Grafana.
|
||||
|
||||
### 2026-04-15 - Repo- und Betriebsbereinigung
|
||||
|
||||
- Firefly, Firefly-Fints und Semaphore aus Repo und Homelab entfernt.
|
||||
- GitHub Desktop als Standard-Workflow fuer den lokalen Sync festgelegt.
|
||||
|
||||
### 2026-04-17 - Sicherheits- und Doku-Abgleich
|
||||
|
||||
- `code-server` hinter `authelia@file,secure-headers@file` abgesichert.
|
||||
- Traefik-Dashboard von `dashboard-auth@file` auf `authelia@file,secure-headers@file` umgestellt; BasicAuth-Hash aus dem Repo entfernt.
|
||||
- Redis von Klartext in der Compose auf Secret-Datei unter `/mnt/user/appdata/secrets/redis_password.txt` umgestellt.
|
||||
- Redis-Passwort bewusst **nicht** rotiert; Live-Passwort bleibt vorerst unveraendert.
|
||||
- `mail-archiver` in der Architektur-Doku an den realen Traefik-Betrieb angepasst.
|
||||
- `paperless-gpt` von `LOG_LEVEL=debug` auf `info` umgestellt.
|
||||
- `speedtest-tracker` von `APP_DEBUG=true` auf `false` umgestellt.
|
||||
- Mutable Image-Tags fuer produktive Stacks auf die aktuell laufenden Digests eingefroren, um Deployments reproduzierbar zu machen.
|
||||
- `paperless-ngx` bleibt fuer `PAPERLESS_DBPASS` und `PAPERLESS_REDIS` vorerst bewusst bei Stack Environment Variables; keine Live-Migration auf `_FILE`, solange der aktuelle Stand stabil laeuft.
|
||||
- Disaster-Recovery-Runbook und Restore-Matrix fuer den Totalausfall-/Wiederanlauf-Fall neu dokumentiert.
|
||||
|
||||
### 2026-04-19 - paperless-gpt Digest-Pin zurueckgenommen
|
||||
|
||||
- Der fuer `paperless-gpt` eingetragene Digest war syntaktisch ungueltig (63 statt 64 Hex-Zeichen) und wurde daher wieder auf `icereed/paperless-gpt:latest` zurueckgesetzt.
|
||||
- Diese Ruecknahme ist bewusst eng auf einen einzelnen defekten Pin begrenzt und aendert keine anderen Digest-Festschreibungen.
|
||||
- Die zwischenzeitlichen OCR-/Versions-Experimente fuer `paperless-gpt` wurden wieder auf den einfachen vorherigen Stand zurueckgenommen (`icereed/paperless-gpt:latest`, `VISION_LLM_MODEL=cnshenyang/qwen3-nothink:14b`), um den letzten bekannten Alltagszustand wiederherzustellen.
|
||||
|
||||
### 2026-04-19 - Nextcloud und Stirling-PDF vorbereitet
|
||||
|
||||
- `apps/nextcloud/docker-compose.yml` als offizieller Docker-Microservice-Stack mit `nextcloud:apache`, eigener PostgreSQL-Datenbank und eigenem Redis vorbereitet.
|
||||
- Nextcloud folgt dem Repo-Standard `frontend_net` + app-internes Netz, nutzt `_FILE`-Secrets fuer Admin- und DB-Passwort und ist bewusst **nicht** hinter zentraler ForwardAuth, damit WebDAV/CardDAV und native Clients sauber funktionieren.
|
||||
- `apps/stirling-pdf/docker-compose.yml` als geschuetzter Tool-Stack hinter `authelia@file,secure-headers@file` vorbereitet.
|
||||
- Stirling-PDF nutzt persistente Pfade fuer `/configs`, `/logs`, `/pipeline`, `/customFiles` und `/usr/share/tessdata`; interne Stirling-Login-Funktion bleibt zugunsten des zentralen Traefik-/Authelia-Zugangs deaktiviert.
|
||||
|
||||
### 2026-04-30 - BentoPDF und Grafana/InfluxDB vorbereitet
|
||||
|
||||
- `stirling-pdf` repo-seitig durch `bentopdf` ersetzt; Domain `pdf.kaleschke.info` bleibt erhalten.
|
||||
- BentoPDF laeuft als geschuetztes browserseitiges PDF-Tool hinter `authelia@file,secure-headers@file` und setzt zusaetzlich COOP/COEP-Header fuer SharedArrayBuffer-basierte Office-Konvertierung.
|
||||
- `ops/grafana-influxdb` als neuer Monitoring-Stack vorbereitet und spaeter in Betrieb genommen.
|
||||
- Grafana laeuft hinter Traefik + Authelia unter `grafana.kaleschke.info`.
|
||||
- InfluxDB 3 Core bleibt ohne Public Route und wird ueber eine provisionierte Grafana-Datenquelle angebunden.
|
||||
- Secrets fuer Grafana-Admin-Passwort, InfluxDB-Admin-Token und Grafana-Datasource-Token sind als Host-Dateien unter `/mnt/user/appdata/secrets/` dokumentiert.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 2 — postgresql17 + diun/gotify ✅
|
||||
## Dauerhafte Learnings
|
||||
|
||||
| Service | Status | Ergebnis |
|
||||
|---|---|---|
|
||||
| postgresql17 | ✅ | Git-Stack abgeschlossen |
|
||||
| gotify | ✅ | `GOTIFY_DEFAULTUSER_PASS_FILE`, Traefik aktiv |
|
||||
| diun | ✅ | **Entfernt** (2026-03-28) — Update-Monitoring via Komodo |
|
||||
|
||||
---
|
||||
|
||||
## Sprint 3 — mealie + mail-archiver ✅
|
||||
|
||||
| Service | Status | Ergebnis |
|
||||
|---|---|---|
|
||||
| mealie | ✅ | internes Netz (`mealie_internal`), Port entfernt, Traefik aktiv |
|
||||
| mealie-postgres | ✅ | nur internes Netz, isoliert |
|
||||
| mail-archiver | ✅ | `frontend_net` + `backend_net` (Hybrid), Portainer ENV |
|
||||
|
||||
---
|
||||
|
||||
## Sprint 4 — Frontend-Stack + Traefik Cleanup + Komodo ✅
|
||||
|
||||
| Service | Status | Ergebnis |
|
||||
|---|---|---|
|
||||
| paperless-ngx | ✅ | Traefik aktiv, tls=true, Port entfernt |
|
||||
| Paperless-AI | ✅ | Traefik aktiv |
|
||||
| PortainerCE | ✅ | Traefik + Middleware, direkte Ports entfernt — **Legacy**, wird in Sprint 5 abgeschaltet |
|
||||
| Dozzle | ✅ | Traefik + Middleware, direkte Ports entfernt |
|
||||
| dashdot | ✅ | Traefik + Middleware, direkte Ports entfernt |
|
||||
| scrutiny | ✅ | `frontend_net`, Traefik + Middleware, als Git-Stack migriert |
|
||||
| filebrowser | ✅ | `frontend_net`, Traefik + Middleware, Port entfernt |
|
||||
| gitea | ✅ | Traefik aktiv, SSH-Port 222 bleibt (dokumentierte Ausnahme) |
|
||||
| UptimeKuma | ✅ | Traefik aktiv, Port entfernt, Middleware aktiv |
|
||||
| backrest | ✅ | `traefik.docker.network=frontend_net` korrigiert (war `backend_net`) |
|
||||
| **Komodo** | ✅ | **Eingeführt als primärer GitOps-Stack-Manager** |
|
||||
| **Traefik File-Provider** | ✅ | `immich.yml`, `gitea.yml`, `mealie.yml`, `scrutiny.yml`, `vaultwarden.yml.bak` gelöscht — Traefik läuft jetzt 100% auf Docker-Labels |
|
||||
| **immich Bad Gateway** | ✅ | Traefik nutzt jetzt `immich@docker` statt `immich@file` |
|
||||
|
||||
---
|
||||
|
||||
## Sprint 5 — Compose-Migration Dockerman-Container 🔄 In Bearbeitung
|
||||
|
||||
| Service | Status | Ziel |
|
||||
|---|---|---|
|
||||
| luckyBackup | ⏳ | `frontend_net`, Traefik, Middleware, Port entfernen |
|
||||
| Stash | ⏳ | Compose-Migration |
|
||||
| Tailscale-Docker | ⏳ | Compose-Migration, `host`-Netz bleibt (dokumentiert) |
|
||||
| netdata | ⏳ | Compose-Migration, leere CLAIM-Vars entfernen |
|
||||
| Plex-Media-Server | ⏳ | Compose-Migration, `host`-Netz bleibt (Discovery) |
|
||||
| Pi-hole | ⏳ | zuletzt konsolidieren |
|
||||
| **PortainerCE** | ⏳ | **abschalten** nach vollständiger Komodo-Übernahme |
|
||||
|
||||
---
|
||||
|
||||
## Sprint 6 — Hardening / Secrets / Volumes ⏳ Offen
|
||||
|
||||
| Aufgabe | Status |
|
||||
|---|---|
|
||||
| `immich_default` — `internal: true` setzen | ⏳ |
|
||||
| `immich_redis` — anonymes Volume → named volume | ⏳ |
|
||||
| `immich_server` — anonymes Volume prüfen | ⏳ |
|
||||
| `backrest` — rw-Mount auf konkrete Pfade einschränken | ⏳ |
|
||||
| `filebrowser` — Mount-Pfad einschränken | ⏳ |
|
||||
| Redis — named volume | ⏳ |
|
||||
|
||||
---
|
||||
|
||||
## Sprint 8 — Borg UI / BorgBase 🔄 In Bearbeitung
|
||||
|
||||
| Service | Status | Ergebnis |
|
||||
|---|---|---|
|
||||
| Borg UI | 🔄 | Git-Stack unter `ops/borg-ui/` angelegt; Traefik + Middleware, keine Host-Ports, minimale Source-Mounts (`/mnt/user/appdata` read-only) |
|
||||
| BorgBase-Anbindung | ⏳ | Nach Deploy: SSH-Key aus Borg UI in BorgBase hinterlegen, Repo anlegen, `repokey-blake2` initialisieren, Job/Schedule im UI setzen |
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Entscheidungen & Learnings
|
||||
|
||||
### Komodo ersetzt Portainer (2026-03-28)
|
||||
Komodo ist der primäre GitOps-Stack-Manager. Stacks werden via Gitea synchronisiert und über Komodo deployed. Portainer CE läuft noch als Legacy-UI.
|
||||
|
||||
### Traefik File-Provider Cleanup (2026-03-28)
|
||||
Statische File-Provider-Configs hatten `@file`-Routen, die mit `@docker`-Routen konkurrierten. In Traefik v3 gewinnt der File-Provider → Immich wurde auf falsche IP geroutet (Bad Gateway). Nach Löschen der statischen Configs läuft alles über Docker-Labels.
|
||||
|
||||
### diun entfernt (2026-03-28)
|
||||
Update-Monitoring kann über Komodo's eingebaute Update-Notifications abgedeckt werden.
|
||||
|
||||
### Portainer + Git + env_file
|
||||
Host-Pfade (`/mnt/...`) sind in Portainer/Komodo Git-Stacks nicht verfügbar → `env_file` ungeeignet. Lösung: Stack Environment Variables mit `${VARIABLE}` in der Compose.
|
||||
|
||||
### `_FILE` Support ist nicht universell
|
||||
| Container | `_FILE` Support |
|
||||
|---|---|
|
||||
| Vaultwarden | ✅ ja |
|
||||
| PostgreSQL | ✅ ja |
|
||||
| Gotify | ✅ ja |
|
||||
| code-server | ✅ ja |
|
||||
| Mealie | ❌ nein → Stack ENV |
|
||||
| paperless-ngx | ❌ nein für DB-Pass → Stack ENV |
|
||||
|
||||
---
|
||||
|
||||
## Notizen
|
||||
- Keine Migration ohne Test
|
||||
- Immer nur einen Service gleichzeitig
|
||||
- Rollback muss jederzeit möglich sein
|
||||
- Kein Live-Editing in Komodo; Git gewinnt immer gegen manuelle Drift.
|
||||
- Webhooks koennen nach einem Push sofort einen Deploy ausloesen.
|
||||
- Rollback soll bevorzugt ueber saubere Git-Commits und bekannte Good States erfolgen, nicht ueber History-Rewrites auf `master`.
|
||||
- Doku soll Endzustaende beschreiben, nicht veraltete Zwischenstaende konservieren.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Network Inventory - KalliLab CORE
|
||||
|
||||
Status: Initialer Host-Audit erfasst, Router-/VLAN-Details offen.
|
||||
Letzte Pruefung: 2026-05-26
|
||||
|
||||
## Zweck
|
||||
|
||||
Dieses Dokument beschreibt Router, DNS, Tailscale, Portfreigaben und Netztrennung. Es ergaenzt das Architektur-Zielbild in `HOMELAB_ARCHITECTURE_MASTER_V2.md` um konkrete Hardware- und Betriebswerte.
|
||||
|
||||
## Internet und Router
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Anschluss / Provider | TBD |
|
||||
| Router-Modell | TBD |
|
||||
| Firmware | TBD |
|
||||
| Router-IP | 192.168.178.1 |
|
||||
| DHCP-Server | vermutlich Router, zu pruefen |
|
||||
| Lokales Subnetz | 192.168.178.0/24 |
|
||||
| IPv6 aktiv | TBD |
|
||||
| DynDNS / DDNS | Cloudflare via `ddns-updater`, Details TBD |
|
||||
|
||||
## DNS
|
||||
|
||||
| Komponente | Rolle | Adresse | Bemerkung |
|
||||
|---|---|---|---|
|
||||
| AdGuard Home | LAN DNS / Filter | Host `192.168.178.58`, Docker `172.23.0.3` | DNS auf Port 53; Admin soll nur via Tailscale-IP `100.80.98.33:8082` erreichbar sein |
|
||||
| Unbound | Rekursiver Resolver | Docker `dns_net` | Upstream fuer AdGuard |
|
||||
| Cloudflare | Authoritative DNS | extern | DNS-Challenge fuer TLS |
|
||||
| Router | DHCP DNS-Verteilung | TBD | Muss auf AdGuard zeigen, falls so betrieben |
|
||||
|
||||
## Tailscale
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Node-Name | Kallilabcore |
|
||||
| Tailscale IPv4 | 100.80.98.33 |
|
||||
| Tailscale IPv6 | TBD |
|
||||
| Exit Node | TBD |
|
||||
| Subnet Router | TBD |
|
||||
| ACL-Policy extern dokumentiert | TBD |
|
||||
|
||||
Pruefkommando:
|
||||
|
||||
```bash
|
||||
tailscale status
|
||||
tailscale ip -4
|
||||
tailscale ip -6
|
||||
```
|
||||
|
||||
## Portfreigaben und Exposure
|
||||
|
||||
| Port | Ziel | Zweck | Bewertung |
|
||||
|---:|---|---|---|
|
||||
| 80/tcp | Traefik | HTTP->HTTPS / ACME | erwartet |
|
||||
| 443/tcp | Traefik | HTTPS | erwartet |
|
||||
| 222/tcp | Gitea SSH | Git SSH | dokumentierte Ausnahme |
|
||||
| 53/tcp+udp | AdGuard | DNS | dokumentierte Ausnahme |
|
||||
| 8082/tcp | AdGuard Admin | Admin UI | Repo-Soll: nur `100.80.98.33:8082`, DNS-Port 53 unveraendert |
|
||||
| 8181/tcp | InfluxDB 3 Core | LAN Writer fuer Home Assistant | LAN-only, Bind-IP pruefen |
|
||||
|
||||
Pruefkommando:
|
||||
|
||||
```bash
|
||||
ss -ltnp | sort -k4
|
||||
docker ps --format "{{.Names}}: {{.Ports}}" | sort
|
||||
```
|
||||
|
||||
## Netztrennung
|
||||
|
||||
| Netz | Status | Bemerkung |
|
||||
|---|---|---|
|
||||
| LAN | 192.168.178.0/24 | Hauptnetz, Host `192.168.178.58` |
|
||||
| Gast-WLAN | TBD | Zugriff auf AdGuard Admin muss ausgeschlossen sein |
|
||||
| IoT-Netz | TBD | Zugriff auf AdGuard Admin muss ausgeschlossen sein |
|
||||
| Tailscale | aktiv | Operator-Zugang, Host-IP `100.80.98.33` |
|
||||
| VLANs | TBD | Router-/Switch-Faehigkeit pruefen |
|
||||
|
||||
## Docker-Netze
|
||||
|
||||
Authoritativ ist `HOMELAB_ARCHITECTURE_MASTER_V2.md`. Dieses Inventar haelt nur den Laufzeit-Snapshot fest.
|
||||
|
||||
| Docker-Netz | Zweck | Erwartung |
|
||||
|---|---|---|
|
||||
| frontend_net | Traefik/Web | external bridge |
|
||||
| backend_net | DB/Cache intern | internal bridge |
|
||||
| dns_net | AdGuard/Unbound | bridge |
|
||||
| monitoring_net | Observability | compose-intern |
|
||||
| app-interne Netze | Stack-isoliert | nur wenn technisch noetig |
|
||||
|
||||
Pruefkommando:
|
||||
|
||||
```bash
|
||||
docker network ls
|
||||
docker network inspect frontend_net | jq '.[0].Containers | keys'
|
||||
docker network inspect backend_net | jq '.[0].Internal'
|
||||
```
|
||||
|
||||
## Offene Entscheidungen
|
||||
|
||||
| Thema | Status | Naechster Schritt |
|
||||
|---|---|---|
|
||||
| AdGuard Admin nur via Tailscale | live validiert 2026-05-26 | Compose bindet Admin-Port auf `100.80.98.33:8082`; DNS auf Port 53 funktioniert, LAN-Zugriff auf `192.168.178.58:8082` schlaegt fehl |
|
||||
| Gast-/IoT-Zugriff auf Admin-Ports | offen | Router-Regeln pruefen |
|
||||
| IPv6 Exposure | offen | Router und Traefik/Cloudflare pruefen |
|
||||
| Home Assistant InfluxDB Bind | offen | Effektive Listener-Adresse pruefen |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user