Compare commits
411 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af4b7015ee | |||
| d48d473942 | |||
| e80e5dd49f | |||
| 3c339474a7 | |||
| c79afdfab0 | |||
| 8172793c68 | |||
| 8e46440944 | |||
| dfe1dc1c99 | |||
| 4007da3302 | |||
| 9836ea3c4f | |||
| 803f84b3af | |||
| d05ca63545 | |||
| 9847baf327 | |||
| 8ec5bc55d9 | |||
| 9c844074e0 | |||
| c126b71852 | |||
| e89b88a513 | |||
| 8bb250220b | |||
| 2f64aee109 | |||
| ed55b88ec1 | |||
| ce747f687f | |||
| cf11b4d75b | |||
| 796901ec6b | |||
| de7b714b4d | |||
| 8045e22873 | |||
| 52f8c2adcb | |||
| 0ddae675a8 | |||
| 7ce8e948cd | |||
| 2a87220862 | |||
| f2d4cad566 | |||
| e7370e4820 | |||
| dc26eb313c | |||
| dc7cbfa6cd | |||
| cf9ca59eb1 | |||
| d2a9c3b8cb | |||
| 0177350e64 | |||
| 2f3a029098 | |||
| a4c79d9d81 | |||
| 18a90fbb4b | |||
| 30f076c85a | |||
| 6e65f81503 | |||
| 6123584a02 | |||
| c33e29016b | |||
| 2628a0c795 | |||
| c7eed6bdad | |||
| 6c61ad3860 | |||
| 2d1b541847 | |||
| c3491eb382 | |||
| 023ee63687 | |||
| 3a263a4846 | |||
| 68d3ace598 | |||
| 0ef98a23e1 | |||
| 6353da47c5 | |||
| 207f49f001 | |||
| a687d9b73e | |||
| e3459c76d0 | |||
| 254eb81496 | |||
| 9a6d7123ce | |||
| 151d253aff | |||
| dda6021116 | |||
| 2f3d184a3b | |||
| bc3ecad45a | |||
| 88a42f3f78 | |||
| af2c6ee533 | |||
| f382c25696 | |||
| d710a506e8 | |||
| 2ea65e906d | |||
| 2d438cf02b | |||
| 7ba10c893b | |||
| fb948ac951 | |||
| 9ca6e47472 | |||
| 38fa8c5dd5 | |||
| ba87719de3 | |||
| 53c34dca0e | |||
| 7d87698715 | |||
| c47639ecf4 | |||
| b158f9d871 | |||
| d947c7f066 | |||
| 9edd6c24e6 | |||
| 7a513e9fc8 | |||
| 4b96d13510 | |||
| 642eb88b40 | |||
| dd494046ce | |||
| 16d3b8f2fa | |||
| a9b232195d | |||
| 5ee4a158d6 | |||
| 86435d4091 | |||
| 5e52316fab | |||
| 8a4df239fa | |||
| 893b34a585 | |||
| d1f9491b24 | |||
| 14de2f4801 | |||
| 90d1595285 | |||
| c1985e177b | |||
| a244f2d677 | |||
| ef032f2dde | |||
| 6fec64d0a1 | |||
| 5d1ae68705 | |||
| 2913e1005f | |||
| 6f0e6f0d5a | |||
| f473fbaa8b | |||
| c922d1f241 | |||
| ba3ef8fcfc | |||
| 52fc007123 | |||
| 8d71dfb9ad | |||
| 440000c085 | |||
| cacf77bfb0 | |||
| cd4dd178ed | |||
| 541c7be853 | |||
| b1ae9f3c26 | |||
| e2624796f0 | |||
| 9f63e6e3bc | |||
| 8eb367f0b5 | |||
| 745761f518 | |||
| ac637d30fb | |||
| b0a6244e21 | |||
| 4fb17a09e6 | |||
| be5c68751f | |||
| 3bfd065326 | |||
| eeebeec804 | |||
| 55fdb13532 | |||
| 8709fe8239 | |||
| 89114b1b12 | |||
| 3da19421d0 | |||
| 16e661be87 | |||
| 12c05376d0 | |||
| dfd0ccbb9a | |||
| ae5d4aedfc | |||
| 479eb291c4 | |||
| c3222e800b | |||
| 4e34582008 | |||
| ab8bfea7c8 | |||
| 92562dfc9c | |||
| c9c8f9e7ce | |||
| 1d98945a67 | |||
| 9ffcb4e92e | |||
| 99a0bfd60e | |||
| e835dfd6ed | |||
| 6e928b6944 | |||
| 60015c1e2c | |||
| e1afd08bf3 | |||
| 268df30a13 | |||
| 80a5ad24a2 | |||
| 28406ae22b | |||
| 7b6c03b433 | |||
| 59b93924fb | |||
| aecf3b2807 | |||
| 8e820ea155 | |||
| 16a266cd79 | |||
| 69ad9d1d3c | |||
| 96fcacc6f7 | |||
| 076676d9b3 | |||
| dde441915a | |||
| db1fa7c3f0 | |||
| b8b0af9e27 | |||
| 4867d632d2 | |||
| 90ef6374a5 | |||
| e6a0e9fea4 | |||
| 10ef703a4e | |||
| 0c08d68d2b | |||
| 73120869a7 | |||
| 1503239881 | |||
| 5c211faf87 | |||
| f2923aac62 | |||
| 67ec40b762 | |||
| abf7137aea | |||
| 8095ab8b5d | |||
| 3bd35434d6 | |||
| b38b5e2db3 | |||
| 75afde5935 | |||
| 70b1ffa190 | |||
| 11a91d8a1e | |||
| ad9267c66a | |||
| 489958af18 | |||
| c16d62a04a | |||
| bdae014bff | |||
| 30aa696e61 | |||
| e4b0db2af6 | |||
| 1a4929f9ef | |||
| 2c0076c6a6 | |||
| 7da64ff316 | |||
| 12b63531d1 | |||
| 3daea94982 | |||
| 0ca29069c7 | |||
| eedb08316d | |||
| 54a7a0e783 | |||
| c677ef0515 | |||
| 2b60a58753 | |||
| 7d64248710 | |||
| edcb34c3f3 | |||
| 19604e0114 | |||
| 3c71a66c55 | |||
| 24d0d90670 | |||
| 0ae44bd797 | |||
| 0723eccca1 | |||
| 3bfecdd291 | |||
| c4fd4154db | |||
| dddb33d900 | |||
| 8eac93c1a5 | |||
| cfa02ce627 | |||
| 52414c47be | |||
| a8c440d4da | |||
| 12cf8fb728 | |||
| 5b0782a8fa | |||
| a805f03481 | |||
| 4feecf4a8e | |||
| 2e84700326 | |||
| 8a19c45485 | |||
| 6a445094bd | |||
| fc59e35c57 | |||
| 8e111d1e04 | |||
| 85a0eb4c3a | |||
| 38c3d87722 | |||
| c5d231a0db | |||
| 48099fb48d | |||
| 5c5ca2fcec | |||
| 3b438324dc | |||
| 0625594443 | |||
| 5936a4d9c1 | |||
| 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 | |||
| 96984ca0de | |||
| e8af468f1e |
@@ -0,0 +1,6 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
|
*.ps1 text eol=crlf
|
||||||
|
*.md text
|
||||||
|
*.json text
|
||||||
|
*.yml text
|
||||||
|
*.yaml text
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# 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/
|
||||||
|
.claude/settings.local.json
|
||||||
|
memory/
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# 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/README.md`
|
||||||
|
4. `docs/REPO_MAP.md`
|
||||||
|
5. `docs/SERVICE_CATALOG.md`
|
||||||
|
6. 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`: natives Unraid-Plugin (`tailscale.plg`, Interface `tailscale1`), Subnet-Router fuers LAN; nicht repo-/Komodo-verwaltet. Der frueher repo-verwaltete userspace-Docker-Stack `host-services/tailscale/` wurde am 2026-06-06 entfernt.
|
||||||
|
- `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.
|
||||||
+242
-184
@@ -3,7 +3,7 @@
|
|||||||
> **Single Source of Truth** für Docker-Netzwerkarchitektur, Sicherheitsregeln, Zielbild und Migration des Kallilabcore-Homelabs.
|
> **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.
|
> **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-06-02 | **Aktueller Schwerpunkt:** GitOps / Doku-Synchronisierung / Reproduzierbare Deployments
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
6. [Einordnungsschema für neue Container](#6-einordnungsschema-für-neue-container)
|
6. [Einordnungsschema für neue Container](#6-einordnungsschema-für-neue-container)
|
||||||
7. [Container-Zielbild (vollständig)](#7-container-zielbild-vollständig)
|
7. [Container-Zielbild (vollständig)](#7-container-zielbild-vollständig)
|
||||||
8. [Traefik-Label-Standard](#8-traefik-label-standard)
|
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)
|
10. [Bekannte Ausnahmen und Begründungen](#10-bekannte-ausnahmen-und-begründungen)
|
||||||
11. [Projektorganisation und Arbeitsmodus](#11-projektorganisation-und-arbeitsmodus)
|
11. [Projektorganisation und Arbeitsmodus](#11-projektorganisation-und-arbeitsmodus)
|
||||||
12. [Nutzung mit KI / Kontext-Regel](#12-nutzung-mit-ki--kontext-regel)
|
12. [Nutzung mit KI / Kontext-Regel](#12-nutzung-mit-ki--kontext-regel)
|
||||||
@@ -30,14 +30,14 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Host-OS | Unraid |
|
| Host-OS | Unraid |
|
||||||
| Hostname | Kallilabcore |
|
| 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) |
|
| VPN / Remote-Zugang | Tailscale (`tailscale`, host-Netz, Git-Stack) |
|
||||||
| DNS-Stack | AdGuard Home (`dns_net` + `frontend_net`) → Unbound (`dns_net`) |
|
| DNS-Stack | AdGuard Home (`dns_net` + `frontend_net`) → Unbound (`dns_net`) |
|
||||||
| Basis-Domain | `kaleschke.info` |
|
| Basis-Domain | `kaleschke.info` |
|
||||||
| TLS | Let's Encrypt via Cloudflare DNS Challenge |
|
| TLS | Let's Encrypt via Cloudflare DNS Challenge |
|
||||||
| Certresolver | `le` |
|
| Certresolver | `le` |
|
||||||
| Compose-Standard | Komodo (GitOps, Stack aus Gitea) |
|
| 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/` |
|
| Homelab-Compose-Pfad | `/mnt/user/services/homelab/` |
|
||||||
| Secrets-Pfad | `/mnt/user/appdata/secrets/` |
|
| Secrets-Pfad | `/mnt/user/appdata/secrets/` |
|
||||||
| Grundsatz | Keine neuen Dockerman-Einzelcontainer |
|
| Grundsatz | Keine neuen Dockerman-Einzelcontainer |
|
||||||
@@ -47,26 +47,26 @@
|
|||||||
## 2. Architektur-Prinzipien
|
## 2. Architektur-Prinzipien
|
||||||
|
|
||||||
### P1 — Traefik ist der einzige öffentliche HTTP(S)-Einstiegspunkt
|
### 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
|
### P2 — Das Setup bleibt bewusst einfach: `frontend_net` + `backend_net` + app-interne Netze
|
||||||
- `frontend_net` = Proxy-/Web-Netz
|
- `frontend_net` = Proxy-/Web-Netz
|
||||||
- `backend_net` = intern für DB/Cache/App-Kommunikation
|
- `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`
|
### 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.
|
Postgres, Redis und ähnliche Dienste laufen ausschließlich in `backend_net` oder einem eigenen internen Compose-Netz.
|
||||||
|
|
||||||
### P4 — Admin-UIs sind nicht öffentlich
|
### P4 — Admin-UIs sind nicht öffentlich
|
||||||
Komodo, filebrowser, scrutiny, UptimeKuma, code-server, Traefik-Dashboard, backrest 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
|
### P5 — Compose-first
|
||||||
Alle produktiven Container werden als Compose verwaltet. Bestehende Dockerman-/Ad-hoc-Container werden schrittweise migriert.
|
Alle produktiven Container werden als Compose verwaltet. Bestehende Dockerman-/Ad-hoc-Container werden schrittweise migriert.
|
||||||
|
|
||||||
### P6 — Secrets nie im Klartext
|
### 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
|
### P7 — `restart: unless-stopped` ist Pflichtstandard
|
||||||
Jeder produktive Container nutzt `restart: unless-stopped`, außer eine Ausnahme ist dokumentiert.
|
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
|
### P8 — Least Privilege
|
||||||
- `security_opt: ["no-new-privileges:true"]` standardmäßig ergänzen
|
- `security_opt: ["no-new-privileges:true"]` standardmäßig ergänzen
|
||||||
- `privileged: true` nur mit dokumentierter Begründung
|
- `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 |
|
| `frontend_net` | bridge, external | einziges Traefik-/Web-Netz | Standard |
|
||||||
| `backend_net` | bridge, `internal: true` | interne App-/DB-/Cache-Kommunikation | Standard |
|
| `backend_net` | bridge, `internal: true` | interne App-/DB-/Cache-Kommunikation | Standard |
|
||||||
| `dns_net` | bridge | Resolver-Schicht: AdGuard Home + Unbound | bleibt |
|
| `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 |
|
| `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 |
|
| `host` | host | nur für echte Sonderfälle | begründet |
|
||||||
|
|
||||||
### 3.2 Finales Diagramm (vereinfacht)
|
### 3.2 Finales Diagramm (vereinfacht)
|
||||||
@@ -99,9 +103,10 @@ Internet
|
|||||||
traefik (80/443)
|
traefik (80/443)
|
||||||
│
|
│
|
||||||
└── frontend_net
|
└── frontend_net
|
||||||
├── öffentliche Apps (vaultwarden, mealie, paperless, immich, gitea, ntfy, homepage)
|
├── öffentliche Apps (vaultwarden, mealie, paperless, immich, gitea, ntfy, mail-archiver, nextcloud)
|
||||||
├── Admin-UIs mit Middleware (komodo, uptime-kuma, filebrowser, scrutiny, code-server, backrest, beszel)
|
├── geschützte UIs mit Middleware (glance, paperless-gpt, filebrowser, scrutiny, code-server, borg-ui, glances, speedtest, bentopdf, monitoring-grafana)
|
||||||
└── Hybrid-Dienste mit Internetbedarf (mail-archiver, ddns-updater)
|
├── Admin-UI mit nativer Auth (komodo)
|
||||||
|
└── Dienste mit Internetbedarf ohne öffentliche UI (ddns-updater)
|
||||||
|
|
||||||
backend_net (internal: true)
|
backend_net (internal: true)
|
||||||
├── postgresql17
|
├── postgresql17
|
||||||
@@ -114,13 +119,16 @@ dns_net
|
|||||||
└── unbound
|
└── unbound
|
||||||
|
|
||||||
App-interne Netze
|
App-interne Netze
|
||||||
├── mealie_mealie_internal (internal: true) ✅
|
├── mealie_internal (internal: true) ✅
|
||||||
└── immich_default (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
|
Host-Sonderfälle
|
||||||
├── tailscale
|
├── tailscale
|
||||||
├── Plex-Media-Server
|
└── Plex-Media-Server
|
||||||
└── beszel-agent
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -136,20 +144,29 @@ Diese Dienste sind über echte `*.kaleschke.info`-Domains erreichbar:
|
|||||||
- `ntfy` — ntfy.kaleschke.info
|
- `ntfy` — ntfy.kaleschke.info
|
||||||
- `gitea` (Web) — git.kaleschke.info
|
- `gitea` (Web) — git.kaleschke.info
|
||||||
- `immich_server` — immich.kaleschke.info
|
- `immich_server` — immich.kaleschke.info
|
||||||
- `homepage` — homepage.kaleschke.info
|
- `nextcloud` — cloud.kaleschke.info
|
||||||
|
- `plex` — plex.kaleschke.info (Traefik, native Plex-Auth; Plex Remote Access/Port 32400 bleibt aus)
|
||||||
|
|
||||||
### 4.2 Nicht öffentlich / nur Tailscale oder Traefik + Middleware
|
### 4.2 Nicht öffentlich / nur Tailscale oder Traefik + Middleware
|
||||||
Diese Dienste sind **keine Public Apps**:
|
Diese Dienste sind **keine Public Apps**:
|
||||||
|
|
||||||
- `Komodo` — komodo.kaleschke.info (Middleware)
|
- `Komodo` — komodo.kaleschke.info (Traefik, aber bewusst ohne zentrale Middleware; native Auth bleibt aktiv)
|
||||||
- `UptimeKuma` — uptime.kaleschke.info (Middleware)
|
|
||||||
- `filebrowser` — files.kaleschke.info (Middleware)
|
- `filebrowser` — files.kaleschke.info (Middleware)
|
||||||
- `scrutiny` — scrutiny.kaleschke.info (Middleware)
|
- `scrutiny` — scrutiny.kaleschke.info (Middleware)
|
||||||
- `code-server` — Traefik + Middleware
|
- `code-server` — Traefik + Middleware
|
||||||
- `beszel` — beszel.kaleschke.info (Middleware ausstehend)
|
- `borg-ui` — borg.kaleschke.info (Middleware)
|
||||||
- `backrest` — Traefik + 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)
|
||||||
|
- `super-productivity` — sp.kaleschke.info (Middleware)
|
||||||
|
- `n8n` — n8n.kaleschke.info (Traefik ohne pauschale Middleware, native Auth + Webhook-Ausnahme analog Komodo)
|
||||||
- `Traefik-Dashboard`
|
- `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
|
### 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:
|
Wenn ein Dienst im `frontend_net` hängt, heißt das **nicht automatisch öffentlich**. Admin-Dienste dürfen im `frontend_net` liegen, wenn:
|
||||||
@@ -158,6 +175,8 @@ Wenn ein Dienst im `frontend_net` hängt, heißt das **nicht automatisch öffent
|
|||||||
- keine direkten Host-Ports bestehen
|
- keine direkten Host-Ports bestehen
|
||||||
- Zugriff durch Tailscale bzw. Auth begrenzt ist
|
- 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
|
## 5. Globale Sicherheitsregeln
|
||||||
@@ -165,10 +184,10 @@ Wenn ein Dienst im `frontend_net` hängt, heißt das **nicht automatisch öffent
|
|||||||
1. Keine produktiven Dienste im Docker-Default-`bridge`
|
1. Keine produktiven Dienste im Docker-Default-`bridge`
|
||||||
2. Keine direkten Host-Ports für Web-UIs außer dokumentierte Ausnahmen
|
2. Keine direkten Host-Ports für Web-UIs außer dokumentierte Ausnahmen
|
||||||
3. `restart: unless-stopped` als Standard
|
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
|
5. `no-new-privileges:true` ergänzen, wo praktikabel
|
||||||
6. `traefik.docker.network=frontend_net` immer explizit setzen
|
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
|
8. Placeholder-Domains (`yourdomain.tld`) sind verboten
|
||||||
9. `privileged: true` nur mit Begründung
|
9. `privileged: true` nur mit Begründung
|
||||||
10. Volume-Mounts so klein und so read-only wie möglich
|
10. Volume-Mounts so klein und so read-only wie möglich
|
||||||
@@ -218,20 +237,18 @@ Legende Status:
|
|||||||
|
|
||||||
| Container | Status | Soll-Netz(e) | Finaler Zugang | Finaler Sollzustand | Offene Punkte |
|
| Container | Status | Soll-Netz(e) | Finaler Zugang | Finaler Sollzustand | Offene Punkte |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| `traefik` | ✅ | `frontend_net`, `backend_net` | öffentlich 80/443 | zentraler Ingress, 100% Docker-Labels | — |
|
| `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 3000 Admin (LAN) | DNS-Server + Upstream zu unbound; kein Traefik (DNS-Sonderfall) | Admin-Port per Traefik + Middleware absichern (Block F) |
|
| `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 | — |
|
| `unbound` | ✅ | `dns_net` | intern | Upstream-Resolver für AdGuard, isoliert | — |
|
||||||
| `ddns-updater` | ✅ | `frontend_net` | intern | Cloudflare DNS API; bleibt in `frontend_net` | Dokumentierte Ausnahme |
|
| `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 |
|
| `tailscale` | ✅ | `host` | VPN-Zugang / Subnet-Router | **Natives Unraid-Plugin** (`tailscale.plg`, Interface `tailscale1`, State `/boot/config/plugins/tailscale/state`) — **nicht** repo-/Komodo-verwaltet | Subnet-Router fuer `192.168.178.0/24`; der redundante userspace-Docker-Stack `host-services/tailscale/` wurde am 2026-06-06 entfernt |
|
||||||
| `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` | — |
|
|
||||||
|
|
||||||
### 7.2 Sicherheit / Identity
|
### 7.2 Sicherheit / Identity
|
||||||
|
|
||||||
| Container | Status | Soll-Netz(e) | Finaler Zugang | Finaler Sollzustand | Offene Punkte |
|
| Container | Status | Soll-Netz(e) | Finaler Zugang | Finaler Sollzustand | Offene Punkte |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| `vaultwarden` | ✅ | `frontend_net` | Traefik | kein Host-Port, `ADMIN_TOKEN_FILE` | — |
|
| `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
|
### 7.3 Datenbanken / Caches
|
||||||
|
|
||||||
@@ -239,47 +256,60 @@ Legende Status:
|
|||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| `postgresql17` | ✅ | `backend_net` | intern | kein Host-Port, `POSTGRES_PASSWORD_FILE` | — |
|
| `postgresql17` | ✅ | `backend_net` | intern | kein Host-Port, `POSTGRES_PASSWORD_FILE` | — |
|
||||||
| `Redis` | ✅ | `backend_net` | intern | intern-only Cache | optional named volume |
|
| `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_postgres` | ✅ | `immich_default` | intern | intern-only | — |
|
||||||
| `immich_redis` | ⏳ | `immich_default` | intern | intern-only | anonymes Volume → named volume |
|
| `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 |
|
| Container | Status | Soll-Netz(e) | Finaler Zugang | Finaler Sollzustand | Offene Punkte |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| `paperless-ngx` | ✅ | `frontend_net`, `backend_net` | Traefik | aktiv via `paperless.kaleschke.info` | — |
|
| `paperless-ngx` | ✅ | `frontend_net`, `backend_net` | Traefik | aktiv via `paperless.kaleschke.info` | — |
|
||||||
| `Paperless-AI` | ✅ | `frontend_net` | Traefik | aktiv | — |
|
| `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_mealie_internal` | Traefik | sauber getrennte App/DB-Struktur | — |
|
| `mealie` | ✅ | `frontend_net`, `mealie_internal` | Traefik | sauber getrennte App/DB-Struktur | — |
|
||||||
| `ntfy` | ✅ | `frontend_net` | Traefik | aktiv via `ntfy.kaleschke.info`, Git-Stack | — |
|
| `ntfy` | ✅ | `frontend_net` | Traefik | aktiv via `ntfy.kaleschke.info`, Git-Stack | — |
|
||||||
| `gitea` | ✅ | `frontend_net` | Traefik + SSH-Port 222 | Web via Traefik, SSH direkt gebunden | — |
|
| `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_server` | ✅ | `immich_default`, `frontend_net` | Traefik | aktiv via `immich.kaleschke.info` | — |
|
||||||
| `immich_machine_learning` | ✅ | `immich_default` | intern | bleibt intern | — |
|
| `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` | Traefik via `plex.kaleschke.info` + Plex native Auth; LAN direkt `:32400` | Compose-Stack unter `host-services/plex/`; Host-Netz bleibt fuer Discovery / Plex GDM dokumentierte Ausnahme; Traefik routet per File-Provider-Ausnahme auf `http://192.168.178.58:32400`, weil Docker-Labels Host-Netz-Container aus Traefik heraus auf `127.0.0.1` routen wuerden; kein direkter WAN-Port 32400 und Plex Remote Access bleibt aus; Server geclaimt von `Xeridos`; Smart-TVs (Schlafzimmer, Wohnzimmer) ueber WLAN-LAN per mDNS | — |
|
||||||
|
| `super-productivity` | ✅ vorbereitet | `frontend_net` | Traefik + Middleware | Persoenliche Task-PWA des Operators; Issues kommen aus Gitea `Micha/mails` via n8n-Mail-Workflow | Deploy + Webhook + DNS-Eintrag offen |
|
||||||
|
| `n8n` | ✅ vorbereitet | `frontend_net` | Traefik, native Auth (keine pauschale Authelia) | Workflow-Automation; erster Workflow: GMX-Mail -> OpenAI-Extraktion -> Gitea-Issue in `Micha/mails`; `N8N_ENCRYPTION_KEY` ist Stack-ENV-Pflichtsecret | Deploy + Webhook + Owner-Setup offen |
|
||||||
|
|
||||||
### 7.5 Admin / Operations
|
### 7.5 Admin / Operations
|
||||||
|
|
||||||
| Container | Status | Soll-Netz(e) | Finaler Zugang | Finaler Sollzustand | Offene Punkte |
|
| 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 | — |
|
| `code-server` | ✅ | `frontend_net` | Traefik + Middleware | `PASSWORD_FILE` aktiv | — |
|
||||||
| `PortainerCE` | ⚠️ Legacy | `frontend_net` | Traefik + Middleware | wird durch Komodo abgelöst | abschalten Sprint 5 |
|
| `PortainerCE` | ❌ entfernt | - | - | 2026-03-29 abgeschaltet | historisch; nicht mehr deployen |
|
||||||
| `filebrowser` | ✅ | `frontend_net` | Traefik + Middleware | aktiv via `files.kaleschke.info` | Mounts einschränken (Block F) |
|
| `filebrowser` | ✅ | `frontend_net` | Traefik + Middleware | aktiv via `files.kaleschke.info` | Appdata-Breitmount entfernt; nur Documents/Photos/Projekte plus eigener App-State |
|
||||||
| `mail-archiver` | ✅ | `frontend_net`, `backend_net` | intern | IMAP-Abruf + DB-Zugang, kein öffentlicher Zugang | — |
|
| `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
|
### 7.6 Monitoring / Status
|
||||||
|
|
||||||
| Container | Status | Soll-Netz(e) | Finaler Zugang | Finaler Sollzustand | Offene Punkte |
|
| 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 |
|
| `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) |
|
| `speedtest-tracker` | ✅ | `frontend_net` | Traefik + Middleware | aktiv via `speedtest.kaleschke.info` | — |
|
||||||
| `beszel-agent` | ✅ | `host` | intern | System-Monitoring, Socket-Zugriff auf Host | — |
|
| `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 |
|
| Container | Status | Ziel |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Plex-Media-Server` | ⏳ Dockerman | Compose-Migration, `host`-Netz bleibt (Discovery) |
|
| — | — | Plex ist nicht mehr direkt offen: der Dienst ist als Repo-Compose-Stack unter `host-services/plex/` dokumentiert; `host`-Netz bleibt als Discovery-Ausnahme. Externer Zugriff laeuft ausschliesslich ueber Traefik/443 auf `plex.kaleschke.info`; keine direkte 32400-WAN-Freigabe. Technisch nutzt Plex als einzige Host-Netz-Route `traefik/dynamic/plex.yml`, weil Docker-Labels fuer `network_mode: host` in Traefik auf `127.0.0.1:32400` zeigen. |
|
||||||
| `PortainerCE` | ⚠️ Legacy | abschalten nach vollständiger Komodo-Übernahme |
|
|
||||||
|
|
||||||
### 7.8 Entfernte Container
|
### 7.8 Entfernte Container
|
||||||
|
|
||||||
@@ -291,13 +321,19 @@ Legende Status:
|
|||||||
| `diun` | 2026-03-28 | Update-Monitoring via Komodo; Stack + Netz `diun_diun_default` + Repo-Eintrag entfernt |
|
| `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 |
|
| `binhex-official-pihole` | 2026-03-28 | ersetzt durch AdGuard Home + Unbound |
|
||||||
| `gotify` | 2026-03-28 | nicht mehr aktiv; Push-Notifications via ntfy abgedeckt |
|
| `gotify` | 2026-03-28 | nicht mehr aktiv; Push-Notifications via ntfy abgedeckt |
|
||||||
| `Dozzle` | 2026-03-28 | nicht mehr aktiv; Log-Monitoring via Komodo/beszel |
|
| `Dozzle` | 2026-03-28 | nicht mehr aktiv |
|
||||||
| `dashdot` | 2026-03-28 | nicht mehr aktiv; System-Monitoring via beszel |
|
| `dashdot` | 2026-03-28 | nicht mehr aktiv |
|
||||||
| `netdata` | 2026-03-28 | nicht mehr aktiv; System-Monitoring via beszel |
|
| `netdata` | 2026-03-28 | nicht mehr aktiv |
|
||||||
| `Glances` | 2026-03-28 | nicht mehr aktiv |
|
|
||||||
| `netalertx` | 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 |
|
| `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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -315,9 +351,9 @@ labels:
|
|||||||
- traefik.http.services.<name>.loadbalancer.server.port=<interner-port>
|
- traefik.http.services.<name>.loadbalancer.server.port=<interner-port>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Zusatz für Admin-Dienste
|
### Zusatz fuer Admin-Dienste (Standard)
|
||||||
```yaml
|
```yaml
|
||||||
- traefik.http.routers.<name>.middlewares=dashboard-auth@file,secure-headers@file
|
- traefik.http.routers.<name>.middlewares=authelia@file,secure-headers@file
|
||||||
```
|
```
|
||||||
|
|
||||||
### Regeln
|
### Regeln
|
||||||
@@ -326,162 +362,91 @@ labels:
|
|||||||
- certresolver immer `le`
|
- certresolver immer `le`
|
||||||
- `tls=true` immer explizit setzen
|
- `tls=true` immer explizit setzen
|
||||||
- wenn Traefik aktiv ist, werden direkte Host-Ports entfernt
|
- 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
|
- **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
|
Dieser Abschnitt dient nur noch als **historischer Vermerk**:
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Block B — Kritische Kernmigrationen ✅ ABGESCHLOSSEN
|
- Traefik läuft labelbasiert ohne Service-Routen im File-Provider.
|
||||||
```text
|
- Komodo ist der einzige aktive Stack-Manager.
|
||||||
[x] vaultwarden - frontend_net, Host-Port entfernt, ADMIN_TOKEN_FILE, Traefik aktiv
|
- Portainer CE ist entfernt.
|
||||||
[x] postgresql17 - Port 5432 entfernt, nur backend_net, POSTGRES_PASSWORD_FILE
|
- Borg/Borg UI, Dump-Automatisierung und Restore-Test sind produktiv eingeführt.
|
||||||
[x] mealie-postgres - aus frontend_net raus, nur mealie_mealie_internal
|
- Frühere Sprint-/Block-Checklisten werden hier **nicht mehr operativ gepflegt**.
|
||||||
```
|
|
||||||
|
|
||||||
### Block C — Frontend-Stack finalisieren ✅ ABGESCHLOSSEN
|
Für den laufenden Betrieb gilt stattdessen:
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Block D — Dockerman-Container in Git-Stacks
|
- Zielbild und Architektur in diesem Dokument
|
||||||
```text
|
- Git-/Komodo-Ablauf in `docs/WORKFLOW.md`
|
||||||
[x] vaultwarden ✅
|
- fachliche Änderungen in der jeweils betroffenen Stack-Doku
|
||||||
[x] postgresql17 ✅
|
- Entscheidungen und besondere Umstellungen im Entscheidungs-Log unten
|
||||||
[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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Bekannte Ausnahmen und Begründungen
|
## 10. Bekannte Ausnahmen und Begründungen
|
||||||
|
|
||||||
| Container | Ausnahme | Begründung |
|
| Container | Ausnahme | Begründung |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `traefik` | Host-Ports 80/443 | zentraler Reverse Proxy |
|
| `traefik` | Host-Ports 80/443 | zentraler Reverse Proxy |
|
||||||
| `tailscale` | `host` | VPN-Zugang; Umstellung nur kontrolliert 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 + Port 3000 Admin | DNS benötigt direkten Port 53; kein HTTP-Proxy für DNS möglich |
|
| `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 |
|
| `Plex-Media-Server` | `host` | Discovery / mDNS / Plex GDM |
|
||||||
| `scrutiny` | `privileged: true` | SMART-Datenzugriff auf Laufwerke |
|
| `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 |
|
| `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 |
|
||||||
| `gitea` | SSH-Port 222 direkt gebunden | Git-SSH-Zugang; kein HTTP-Proxy für SSH möglich |
|
| `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 (LAN/Tailscale) | Git-SSH-Zugang; kein HTTP-Proxy für SSH möglich. Bewusst **nicht** in FRITZ!Box-WAN freigegeben (Operator-Entscheidung 2026-05-28): Tailscale ist Operator-Pfad, GitHub-Mirror deckt DR-Bootstrap ab, SSH-Brute-Force-Vektor extern vermeiden. |
|
||||||
| `ddns-updater` | bleibt in `frontend_net` statt `backend_net` | braucht Cloudflare-API-Zugang; `backend_net` ist `internal: true` |
|
| `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 |
|
| `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 |
|
||||||
|
| `n8n` | keine pauschale Authelia-Middleware | Webhook-Endpunkte (`/webhook/*`, `/webhook-test/*`) muessen ohne ForwardAuth erreichbar bleiben; n8n bringt eigene Owner-/Login-Auth mit (analog Komodo/Nextcloud) |
|
||||||
|
| `plex` | Traefik ohne Authelia, File-Provider-Ausnahme trotz Host-Netz | Plex bringt native Konto-/Client-Auth mit; vorgeschaltete ForwardAuth wuerde Plex Web, Apps und Client-Flows stoeren. Docker-Labels sind fuer diesen Host-Netz-Container ungeeignet, weil Traefik sonst `127.0.0.1:32400` nutzt; daher `traefik/dynamic/plex.yml` mit Ziel `192.168.178.58:32400`. Route nur ueber Traefik/443 (`plex.kaleschke.info`), direkter Plex-WAN-Port 32400 und Plex Remote Access bleiben deaktiviert. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Projektorganisation und Arbeitsmodus
|
## 11. Projektorganisation und Arbeitsmodus
|
||||||
|
|
||||||
### 11.1 Unser Arbeitsprinzip
|
### 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
|
### 11.2 Operativer Ablauf
|
||||||
|
1. Zielbild prüfen
|
||||||
| Sprint | Inhalt | Status |
|
2. lokal synchronisieren
|
||||||
|---|---|---|
|
3. gezielt ändern
|
||||||
| Sprint 1 | Quick Wins + `vaultwarden` | ✅ Abgeschlossen |
|
4. Commit + Push
|
||||||
| Sprint 2 | `postgresql17` + `diun/gotify` | ✅ Abgeschlossen |
|
5. Komodo-Webhook und Ergebnis prüfen
|
||||||
| Sprint 3 | `mealie` / `mealie-postgres` + `mail-archiver` | ✅ Abgeschlossen |
|
6. Dokumentation nachziehen
|
||||||
| 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 |
|
|
||||||
|
|
||||||
### 11.3 Regel für jede Änderung
|
### 11.3 Regel für jede Änderung
|
||||||
1. Zielbild in diesem Dokument prüfen
|
1. Zielbild in diesem Dokument prüfen
|
||||||
2. nur den aktuellen Block anfassen
|
2. nur den betroffenen Bereich anfassen
|
||||||
3. Compose-Datei ändern
|
3. Änderung lokal vorbereiten
|
||||||
4. deployen
|
4. nach Gitea pushen
|
||||||
5. testen
|
5. automatische Reaktion von Komodo beachten
|
||||||
6. dokumentieren / abhaken
|
6. testen
|
||||||
7. erst dann nächster Schritt
|
7. dokumentieren
|
||||||
|
|
||||||
### 11.4 Source-of-Truth-Hierarchie
|
### 11.4 Source-of-Truth-Hierarchie
|
||||||
1. **Dieses Dokument**
|
1. **Gitea Online (`origin/master`)**
|
||||||
2. Compose-Dateien im Git-Repo
|
2. lokaler Clone / GitHub Desktop
|
||||||
3. operative Checklisten
|
3. Compose-Dateien im Git-Repo
|
||||||
4. ad-hoc Notizen / Chat
|
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
|
## 12. Nutzung mit KI / Kontext-Regel
|
||||||
|
|
||||||
@@ -501,6 +466,46 @@ Damit ist sofort klar:
|
|||||||
|
|
||||||
## 13. Betriebserfahrungen und Entscheidungs-Log
|
## 13. Betriebserfahrungen und Entscheidungs-Log
|
||||||
|
|
||||||
|
### Fix Common Problems Plugin entfernt (2026-06-03)
|
||||||
|
|
||||||
|
Befund: Drei `grep -R ... /usr/local/emhttp`-Prozesse liefen seit ~7 Tagen durchgehend mit je 100 % CPU (TIME+ 177-179 h). Status `R`, von PID 1 adoptierte Zombies einer laengst beendeten Fix-Common-Problems-(FCP)-Scan-Session. Folge: konstante Load 14.6 auf 12 Cores, IOWAIT-Peaks bis 55 %, USB-Flash unter Dauer-IO.
|
||||||
|
|
||||||
|
Ursache: Unraids `/usr/local/emhttp` enthaelt Symlinks `mnt -> /mnt` (mehrere TB Array) und `boot -> /boot` (USB-Flash). GNU `grep -R` dereferenziert Symlinks rekursiv. Ein FCP-Scan-Schritt (`/etc/cron.daily/fix.common.problems.sh -> scripts/scan.php`) hat dadurch effektiv die gesamte Array-Struktur gegrept und ist beim ersten Treffer-Loop haengen geblieben. Der Lock `/tmp/fix.common.problems/scanRunning` war vom 2026-06-03 04:40 - jeder weitere Daily-Cron-Run wuerde dasselbe Verhalten reproduzieren.
|
||||||
|
|
||||||
|
Massnahme: FCP-Plugin per `plugin remove fix.common.problems.plg` deinstalliert. Cron-Eintrag, Plugin-Verzeichnis und `/tmp`-Reste sauber. Load fiel innerhalb Minuten auf 1.08 (1-min).
|
||||||
|
|
||||||
|
Entscheidung: FCP wird bewusst **nicht** wieder installiert. Begruendung:
|
||||||
|
|
||||||
|
- Restliche Risiken werden bereits ueber andere Wege abgedeckt: Scrutiny (Laufwerks-SMART), Monitoring-Stack (Container-Health, Prometheus-Alerts, Blackbox), Posture-Check (Filesystem-/Drift-/Authelia-Audit), Critical-Events-Watcher (`services/posture-check/docker-critical-events.sh`).
|
||||||
|
- FCP ist ein externes Community-Plugin und nicht Teil der Repo-managed GitOps-Welt; Verhalten haengt von einer Online-Templates-Datei ab.
|
||||||
|
- Ein einmaliges Hang-up reicht, um die Flash-Drive 7 Tage lang zu thrashen - das Verhaeltnis Nutzen/Risiko ist negativ.
|
||||||
|
|
||||||
|
Folgen fuer Doku: Eintrag in `docs/AUDIT_2026-05-25_TODO.md` unter "Zuletzt geschlossen"; FCP taucht nicht mehr als Voraussetzung in DR/Monitoring-Pfaden auf, da es nie produktiv referenziert war.
|
||||||
|
|
||||||
|
### Plex Server Reclaim und LAN-only-Profil (2026-05-28)
|
||||||
|
|
||||||
|
Befund: Die `Preferences.xml` des Plex-Servers war seit dem 18.05.2026 13:18 jungfraeulich (391 Bytes, ohne `PlexOnlineMail`/`PlexOnlineUsername`/`PlexOnlineToken`). Der Server war damit nicht mit einem Plex.tv-Account geclaimt, obwohl die Smart-TVs ueber LAN-Discovery (mDNS/Plex-GDM) weiter funktionierten. Beim Login als `Xeridos` ueber `app.plex.tv` meldete der Server "Keine Berechtigung", weil kein Owner registriert war. Zusaetzlich war die `library_sections`-Konfiguration leer (Backups vom 19./22./28.05. ebenfalls ~370 KB statt MBs/GBs); die Bibliotheks-Konfiguration war seit dem 18.05. weg, die Filmdateien unter `/mnt/user/media/*` blieben aber intakt (~833 Verzeichnisse, davon `movies/` 1.4 TB und `Heimatfilme/` 300 GB).
|
||||||
|
|
||||||
|
Reclaim:
|
||||||
|
|
||||||
|
- Operator-Claim-Token via `https://www.plex.tv/claim` als `Xeridos` erzeugt.
|
||||||
|
- Plex-Container per `PLEX_CLAIM=claim-... docker compose up -d --force-recreate plex` am Host-Pfad `/mnt/user/services/stacks/plex/host-services/plex` neu erstellt. Token wurde **nur** als Shell-Inline-ENV mitgegeben, **nicht** in eine `.env`-Datei, **nicht** in die Compose, **nicht** in die Komodo-Stack-ENV geschrieben.
|
||||||
|
- Nach Erfolg: zweiter `docker compose up -d --force-recreate plex` ohne `PLEX_CLAIM`, damit der verbrauchte Token nicht im `docker inspect`-ENV-Snapshot persistiert.
|
||||||
|
- Bash-History defensiv geleert.
|
||||||
|
|
||||||
|
Endstand:
|
||||||
|
|
||||||
|
- `PlexOnlineUsername="Xeridos"`, `PlexOnlineMail="michideheld@gmx.de"`, `PlexOnlineHome="1"`.
|
||||||
|
- Bibliotheken neu angelegt via Plex-Web → Verwalte Mediatheken → `/data/movies`, `/data/Heimatfilme` etc.
|
||||||
|
- `PublishServerOnPlexOnlineKey="0"` (Remote Access deaktiviert), Plex-Relay aus.
|
||||||
|
- 2026-06-06: Externer Komfortzugriff ueber `https://plex.kaleschke.info` via Traefik ergaenzt. Das ist **kein** Plex-Remote-Access und keine direkte FRITZ!Box-Freigabe auf `32400`; Plex bleibt hinter Traefik/443 und nutzt native Plex-Auth.
|
||||||
|
|
||||||
|
Konsequenzen fuer Doku/Betrieb:
|
||||||
|
|
||||||
|
- Plex-Home-Familien-Profil ("Familie") muss bei Bedarf neu eingeladen werden; war ohnehin nicht aktiv genutzt.
|
||||||
|
- Watch-State aus der Zeit vor dem 18.05. ist nicht recoverbar; Filme/Serien laufen bei Wiederaufruf bei 00:00 los.
|
||||||
|
- `host-services/plex/docker-compose.yml` enthaelt weiter `PLEX_CLAIM: ${PLEX_CLAIM:-}`, damit ein zukuenftiger Reclaim ohne Repo-Aenderung moeglich ist.
|
||||||
|
|
||||||
### Traefik — Wechsel zu reinen Docker-Labels (2026-03-28)
|
### Traefik — Wechsel zu reinen Docker-Labels (2026-03-28)
|
||||||
Die statischen File-Provider-Konfigurationen in `/mnt/user/appdata/traefik/dynamic/` wurden vollständig bereinigt:
|
Die statischen File-Provider-Konfigurationen in `/mnt/user/appdata/traefik/dynamic/` wurden vollständig bereinigt:
|
||||||
- **Gelöscht:** `immich.yml`, `gitea.yml`, `mealie.yml`, `scrutiny.yml`, `vaultwarden.yml.bak`
|
- **Gelöscht:** `immich.yml`, `gitea.yml`, `mealie.yml`, `scrutiny.yml`, `vaultwarden.yml.bak`
|
||||||
@@ -515,16 +520,25 @@ Komodo ist nun der primäre GitOps-Stack-Manager:
|
|||||||
- **Komodo Core** läuft als Docker-Stack (`ops/komodo/docker-compose.yml`)
|
- **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
|
- **Komodo Periphery** läuft auf dem Unraid-Host für direktes Server-Management
|
||||||
- Stacks werden via Gitea synchronisiert und über Komodo deployed
|
- 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)
|
### AdGuard Home — Ablösung von Pi-hole (2026-03-28)
|
||||||
`binhex-official-pihole` wurde entfernt und durch `AdGuard Home` + `unbound` ersetzt:
|
`binhex-official-pihole` wurde entfernt und durch `AdGuard Home` + `unbound` ersetzt:
|
||||||
- AdGuard läuft als Git-Stack (`host-services/Adguard/docker-compose.yml`)
|
- AdGuard läuft als Git-Stack (`host-services/Adguard/docker-compose.yml`)
|
||||||
- Netzwerke: `dns_net` (feste IP 172.23.0.3) + `frontend_net`
|
- Netzwerke: `dns_net` (feste IP 172.23.0.3) + `frontend_net`
|
||||||
- Port 53 (DNS) direkt gebunden — dokumentierte Ausnahme
|
- 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`
|
- `unbound` läuft weiterhin als Upstream-Resolver in `dns_net`
|
||||||
|
|
||||||
### diun — Entfernung (2026-03-28)
|
### diun — Entfernung (2026-03-28)
|
||||||
@@ -548,31 +562,75 @@ 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
|
- 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
|
- 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.
|
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.
|
**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`
|
||||||
|
- 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 |
|
| Container | `_FILE` Support |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Vaultwarden | ✅ ja |
|
| Vaultwarden | ✅ ja |
|
||||||
| PostgreSQL | ✅ ja |
|
| PostgreSQL | ✅ ja |
|
||||||
| code-server | ✅ ja (`PASSWORD_FILE`) |
|
| code-server | ✅ ja (`PASSWORD_FILE`) |
|
||||||
| Immich Postgres | ✅ ja (`POSTGRES_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 |
|
| 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` ist historisch als "shared Cache" angelegt, wird aber faktisch nur von Paperless als App-Cache genutzt. Immich, Nextcloud und Mealie betreiben jeweils eigene Redis-Instanzen in ihren App-internen Netzen; Authelia laeuft bewusst ohne Redis. Eine spaetere Konsolidierung in `apps/paperless/` (analog zu Mealie/Immich/Nextcloud) bleibt fachlich denkbar, ist aber kein priorisierter Schritt.
|
||||||
|
|
||||||
### ddns-updater — Netz-Ausnahme
|
### 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.
|
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
|
### 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
|
### Netzwerk-Standard für Apps mit Datenbanken
|
||||||
- App → `frontend_net` + internes Netzwerk
|
- App → `frontend_net` + internes Netzwerk
|
||||||
- Datenbank → nur internes Netzwerk (`internal: true`)
|
- 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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -581,4 +639,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.
|
Dieses Dokument ist keine lose Notiz, sondern das **operative Masterdokument** für die Docker- und Zugriffsarchitektur des Homelabs.
|
||||||
|
|
||||||
**Zielbild in einem Satz:**
|
**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,80 @@
|
|||||||
# Homelab Infrastructure (KalliLab CORE)
|
# 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
|
1. `HOMELAB_ARCHITECTURE_MASTER_V2.md`
|
||||||
2. 👉 docs/WORKFLOW.md
|
2. `docs/WORKFLOW.md`
|
||||||
|
3. `docs/README.md`
|
||||||
|
|
||||||
|
Bei Restore-, Host-Ausfall- oder Wiederanlauf-Fragen zusaetzlich:
|
||||||
|
|
||||||
|
4. `docs/DISASTER_RECOVERY.md`
|
||||||
|
5. `docs/RESTORE_MATRIX.md`
|
||||||
|
6. `docs/SERVICES_RECOVERY.md`
|
||||||
|
|
||||||
|
Bei Hardware-, Netzwerk-, Provider- oder Kapazitaetsfragen zusaetzlich:
|
||||||
|
|
||||||
|
7. `docs/HARDWARE_INVENTORY.md`
|
||||||
|
8. `docs/NETWORK_INVENTORY.md`
|
||||||
|
9. `docs/EXTERNAL_DEPENDENCIES.md`
|
||||||
|
10. `docs/CAPACITY_AND_LIFECYCLE.md`
|
||||||
|
|
||||||
## Architektur
|
## Architektur
|
||||||
|
|
||||||
- Host: Unraid
|
- Host: Unraid
|
||||||
- Container: Docker (Compose)
|
- Container: Docker Compose
|
||||||
- Reverse Proxy: Traefik v3 (100% Docker-Labels, kein File-Provider mehr)
|
- Reverse Proxy: Traefik v3 (Service-Routing via Docker-Labels, File-Provider nur fuer zentrale Dynamic-Config)
|
||||||
- Zugriff: Tailscale (VPN)
|
- Zugriff: Tailscale (VPN)
|
||||||
- DNS: AdGuard Home + Unbound
|
- DNS: AdGuard Home + Unbound
|
||||||
- GitOps: Gitea + Komodo (Stack-Manager)
|
- GitOps: Gitea + Komodo
|
||||||
|
|
||||||
## Grundprinzipien
|
## Grundprinzipien
|
||||||
|
|
||||||
- Alle Änderungen erfolgen über Git (Komodo deployed automatisch aus Gitea)
|
- Gitea Online ist der operative Sollzustand.
|
||||||
- Keine produktiven Container außerhalb von Compose
|
- Der lokale Clone ist die Arbeitskopie.
|
||||||
- Traefik ist der einzige öffentliche Einstiegspunkt
|
- Komodo deployed automatisch aus Gitea und ist kein Bearbeitungsort.
|
||||||
- Admin-Dienste sind nicht öffentlich erreichbar (nur via VPN oder Auth)
|
- Keine produktiven Container ausserhalb von Compose.
|
||||||
- Secrets werden niemals im Repository gespeichert
|
- Traefik ist der einzige oeffentliche Einstiegspunkt.
|
||||||
|
- Secrets werden niemals im Repository gespeichert.
|
||||||
|
|
||||||
## Repository-Struktur
|
## Repository-Struktur
|
||||||
|
|
||||||
- `core/` → Basisdienste (Gitea)
|
- `core/` -> Basisdienste (Gitea)
|
||||||
- `security/` → sicherheitskritische Dienste (Vaultwarden)
|
- `security/` -> sicherheitskritische Dienste
|
||||||
- `infra/` → Datenbanken & technische Services (PostgreSQL, Redis, DDNS-Updater)
|
- `infra/` -> Datenbanken und technische Services
|
||||||
- `apps/` → Anwendungen (Immich, Paperless, Mealie, Homepage, ...)
|
- `apps/` -> Anwendungen
|
||||||
- `ops/` → Monitoring & Tools (Komodo, Scrutiny, Uptime-Kuma, Backrest, ...)
|
- `ops/` -> operative Tools
|
||||||
- `host-services/` → Dienste mit Host-Netz (AdGuard, Beszel, Tailscale, ...)
|
- `monitoring/` -> zentraler Observability-Stack
|
||||||
- `traefik/` → Reverse Proxy Konfiguration
|
- `host-services/` -> Dienste mit Host-Netz
|
||||||
- `docs/` → Dokumentation & Prozesse
|
- `traefik/` -> Reverse Proxy Konfiguration
|
||||||
- `env/` → Beispiel-Umgebungsvariablen
|
- `docs/` -> Dokumentation und Prozesse
|
||||||
|
- `env/` -> Beispiel-Umgebungsvariablen
|
||||||
|
|
||||||
## Workflow
|
## Kurz-Workflow
|
||||||
|
|
||||||
1. Änderung im Repository (Git)
|
1. In GitHub Desktop `Fetch origin`.
|
||||||
2. Commit & Push nach Gitea
|
2. Wenn noetig `Pull origin`.
|
||||||
3. Komodo deployed automatisch (GitOps)
|
3. Lokal aendern.
|
||||||
4. Testen
|
4. Commit erstellen.
|
||||||
5. Dokumentation aktualisieren
|
5. `Push origin`.
|
||||||
|
6. Komodo-Webhook und Ergebnis pruefen.
|
||||||
|
7. Doku bei Bedarf aktualisieren.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
GitOps-Migration (Sprint 1–4) abgeschlossen. Komodo ist primärer Stack-Manager.
|
- 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 läuft noch als Legacy-UI – wird in Sprint 5 abgeschaltet.
|
- 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`.
|
||||||
|
- Der Doku-Index mit aktiven und archivierten Dokumenten steht in `docs/README.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.5@sha256:2d867aacb8ab5b196d00ee86944b1899d09d72df355384c5e15cf974737963a0
|
||||||
|
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,84 +0,0 @@
|
|||||||
export function renderNetworkHealth(state) {
|
|
||||||
_renderAdGuard(state.adguard || {});
|
|
||||||
_renderScrutiny(state.scrutiny || {});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _renderAdGuard(d) {
|
|
||||||
const online = d.source_status === "online";
|
|
||||||
|
|
||||||
const pill = document.getElementById("adguard-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("adguard-total", fmtK(d.total_queries));
|
|
||||||
set("adguard-blocked", fmtK(d.blocked_queries));
|
|
||||||
set("adguard-blocked-pct", `${d.blocked_percent ?? 0}%`);
|
|
||||||
set("adguard-latency", `${d.avg_processing_ms ?? 0}ms`);
|
|
||||||
|
|
||||||
const bar = document.getElementById("adguard-bar-fill");
|
|
||||||
if (bar) bar.style.width = `${Math.min(d.blocked_percent ?? 0, 100)}%`;
|
|
||||||
} else {
|
|
||||||
["adguard-total", "adguard-blocked", "adguard-blocked-pct", "adguard-latency"].forEach(id => set(id, "—"));
|
|
||||||
const bar = document.getElementById("adguard-bar-fill");
|
|
||||||
if (bar) bar.style.width = "0%";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _renderScrutiny(d) {
|
|
||||||
const online = d.source_status === "online";
|
|
||||||
|
|
||||||
const pill = document.getElementById("scrutiny-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; };
|
|
||||||
if (online) {
|
|
||||||
set("scrutiny-total", d.total_count ?? 0);
|
|
||||||
set("scrutiny-failed", d.failed_count ?? 0);
|
|
||||||
const passedEl = document.getElementById("scrutiny-passed");
|
|
||||||
if (passedEl) {
|
|
||||||
passedEl.textContent = (d.total_count ?? 0) - (d.failed_count ?? 0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
set("scrutiny-total", "—");
|
|
||||||
set("scrutiny-failed", "—");
|
|
||||||
set("scrutiny-passed", "—");
|
|
||||||
}
|
|
||||||
|
|
||||||
// disk list
|
|
||||||
const list = document.getElementById("scrutiny-list");
|
|
||||||
if (!list) return;
|
|
||||||
|
|
||||||
if (!online || !d.devices || d.devices.length === 0) {
|
|
||||||
list.innerHTML = `<div class="scrutiny-offline">— ${online ? "no devices" : "offline"}</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
list.innerHTML = d.devices.map(dev => {
|
|
||||||
const ok = dev.status === "passed";
|
|
||||||
const icon = ok ? "✓" : dev.status === "failed" ? "✗" : "?";
|
|
||||||
const cls = ok ? "disk-ok" : dev.status === "failed" ? "disk-fail" : "disk-unk";
|
|
||||||
const temp = dev.temperature != null ? `<span class="disk-temp">${dev.temperature}°C</span>` : "";
|
|
||||||
return `
|
|
||||||
<div class="scrutiny-row">
|
|
||||||
<span class="disk-icon ${cls}">${icon}</span>
|
|
||||||
<span class="disk-name">${dev.name}</span>
|
|
||||||
<span class="disk-model">${dev.model}</span>
|
|
||||||
${temp}
|
|
||||||
</div>`;
|
|
||||||
}).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtK(n) {
|
|
||||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
|
||||||
if (n >= 1_000) return (n / 1_000).toFixed(0) + "K";
|
|
||||||
return String(n ?? 0);
|
|
||||||
}
|
|
||||||
@@ -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,28 +0,0 @@
|
|||||||
export function renderStorage(state) {
|
|
||||||
const storage = state.storage || {};
|
|
||||||
const grid = document.getElementById("storage-grid");
|
|
||||||
if (!grid) return;
|
|
||||||
|
|
||||||
const disks = storage.disks || [];
|
|
||||||
if (!disks.length) {
|
|
||||||
grid.innerHTML = '<div class="card" style="opacity:0.5; font-family:var(--font-mono); font-size:10px; color:var(--text-dim); padding:12px;">No disk data</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
grid.innerHTML = disks.map(disk => {
|
|
||||||
const pct = disk.usage_percent ?? 0;
|
|
||||||
const fillClass = pct > 85 ? "danger" : pct > 70 ? "warn" : "";
|
|
||||||
const statusColor = disk.status === "critical" ? "var(--red)" : disk.status === "warning" ? "var(--yellow)" : "var(--teal)";
|
|
||||||
return `
|
|
||||||
<div class="card">
|
|
||||||
<div class="disk-header">
|
|
||||||
<span class="disk-name">${disk.name || disk.mount}</span>
|
|
||||||
<span class="disk-usage" style="color:${statusColor}">${pct.toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="disk-sub">${disk.mount} · ${disk.used_gb?.toFixed(1)}/${disk.total_gb?.toFixed(1)} GB</div>
|
|
||||||
<div class="progress-bar">
|
|
||||||
<div class="progress-fill ${fillClass}" style="width:${Math.min(pct,100)}%"></div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join("");
|
|
||||||
}
|
|
||||||
@@ -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,431 +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)); }
|
|
||||||
}
|
|
||||||
</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">
|
|
||||||
<div class="card-title"><span class="dot"></span>CPU</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>
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-title"><span class="dot"></span>MEMORY</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>
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-title"><span class="dot"></span>NETWORK</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>
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-title"><span class="dot"></span>HOST</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>
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-title"><span class="dot"></span>DOCKER</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- STORAGE + SCRUTINY ROW -->
|
|
||||||
<div class="section-header"><span>⬡</span> STORAGE & HEALTH</div>
|
|
||||||
<div style="display:grid; grid-template-columns: 1fr 280px; gap:8px; margin-bottom:8px;">
|
|
||||||
<div>
|
|
||||||
<div class="widget-row row-3" id="storage-grid">
|
|
||||||
<!-- Disk cards injected by renderer -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-title">
|
|
||||||
<div class="card-title-left"><span class="dot"></span>SCRUTINY</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="widget-row row-3" style="margin-bottom:8px;">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-title">
|
|
||||||
<div class="card-title-left">
|
|
||||||
<span class="service-icon" style="background:rgba(65,105,225,0.15);">🏠</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 style="margin-top:4px; font-family:var(--font-mono); font-size:8px; color:var(--text-dim);" id="ha-version"></div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-title">
|
|
||||||
<div class="card-title-left">
|
|
||||||
<span class="service-icon" style="background:rgba(0,220,140,0.12);">🐻</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" id="uk-uptime">—</div><div class="stat-label">24h %</div></div>
|
|
||||||
</div>
|
|
||||||
<div id="uk-down-list"></div>
|
|
||||||
<div id="uk-bars"></div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-title">
|
|
||||||
<div class="card-title-left">
|
|
||||||
<span class="service-icon" style="background:rgba(255,204,68,0.12);">📷</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>
|
|
||||||
|
|
||||||
<!-- SERVICE WIDGETS ROW 2 -->
|
|
||||||
<div class="widget-row row-3" style="margin-bottom:8px;">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-title">
|
|
||||||
<div class="card-title-left">
|
|
||||||
<span class="service-icon" style="background:rgba(68,170,255,0.12);">💾</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">
|
|
||||||
<div class="card-title">
|
|
||||||
<div class="card-title-left">
|
|
||||||
<span class="service-icon" style="background:rgba(0,220,140,0.12);">🛡️</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">
|
|
||||||
<div class="card-title">
|
|
||||||
<div class="card-title-left"><span class="dot"></span>SERVICES OVERVIEW</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:
|
services:
|
||||||
immich-server:
|
immich-server:
|
||||||
container_name: 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
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
@@ -14,10 +12,10 @@ services:
|
|||||||
DB_PASSWORD: ${IMMICH_DB_PASSWORD}
|
DB_PASSWORD: ${IMMICH_DB_PASSWORD}
|
||||||
DB_DATABASE_NAME: immich
|
DB_DATABASE_NAME: immich
|
||||||
REDIS_HOSTNAME: redis
|
REDIS_HOSTNAME: redis
|
||||||
|
TZ: Europe/Berlin
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/user/photos/immich:/usr/src/app/upload
|
- /mnt/user/photos/immich:/usr/src/app/upload
|
||||||
- /mnt/user/photos/family_archive:/usr/src/app/external
|
- /mnt/user/photos/family_archive:/usr/src/app/external
|
||||||
- /etc/localtime:/etc/localtime:ro
|
|
||||||
networks:
|
networks:
|
||||||
- immich_default
|
- immich_default
|
||||||
- frontend_net
|
- frontend_net
|
||||||
@@ -34,8 +32,19 @@ services:
|
|||||||
|
|
||||||
immich-machine-learning:
|
immich-machine-learning:
|
||||||
container_name: 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
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# Workaround fuer gunicorn-25.1.0-Control-Socket-Bug: der Worker haengt
|
||||||
|
# nach "Control socket listening at /usr/src/gunicorn.ctl" und erreicht
|
||||||
|
# nie "Application startup complete" -> Container bleibt dauerhaft
|
||||||
|
# unhealthy, ML (Gesichtserkennung/CLIP/Smart-Search) ist tot.
|
||||||
|
# --no-control-socket deaktiviert das fehlerhafte Feature. immich-ml
|
||||||
|
# startet gunicorn als Subprozess, der GUNICORN_CMD_ARGS aus der Env
|
||||||
|
# liest und anhaengt. Bestaetigte Upstream-Regression seit Immich 2.6
|
||||||
|
# (immich#27228, gunicorn#3510). Re-check: bei Immich-Update, das
|
||||||
|
# gunicorn auf >25.1.0/<25.1.0 mit Fix bringt, wieder entfernen.
|
||||||
|
GUNICORN_CMD_ARGS: "--no-control-socket"
|
||||||
volumes:
|
volumes:
|
||||||
- model-cache:/cache
|
- model-cache:/cache
|
||||||
networks:
|
networks:
|
||||||
@@ -45,7 +54,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: redis:7
|
image: redis:8.8.0-alpine@sha256:09160599abd229764c0fb44cb6be640294e1d360a54b19985ab4843dcf2d90f1
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- immich_default
|
- immich_default
|
||||||
@@ -54,14 +63,15 @@ services:
|
|||||||
|
|
||||||
database:
|
database:
|
||||||
container_name: immich_postgres
|
container_name: immich_postgres
|
||||||
image: tensorchord/pgvecto-rs:pg14-v0.2.0
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
|
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
|
||||||
POSTGRES_USER: immich
|
POSTGRES_USER: immich
|
||||||
POSTGRES_DB: immich
|
POSTGRES_DB: immich
|
||||||
|
shm_size: 128mb
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/user/appdata/immich_postgres:/var/lib/postgresql/data
|
- /mnt/user/appdata/immich_postgres_vectorchord:/var/lib/postgresql/data
|
||||||
- /mnt/user/appdata/secrets/immich_postgres_password.txt:/run/secrets/postgres_password:ro
|
- /mnt/user/appdata/secrets/immich_postgres_password.txt:/run/secrets/postgres_password:ro
|
||||||
networks:
|
networks:
|
||||||
- immich_default
|
- immich_default
|
||||||
@@ -77,4 +87,4 @@ networks:
|
|||||||
internal: true
|
internal: true
|
||||||
driver: bridge
|
driver: bridge
|
||||||
frontend_net:
|
frontend_net:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
version: "3.9"
|
|
||||||
services:
|
services:
|
||||||
mail-archiver:
|
mail-archiver:
|
||||||
image: s1t5/mailarchiver
|
image: s1t5/mailarchiver@sha256:4ea7ecc47ad1dd2c523b85c3967574b61e39def1b6fd26edf874e21733c4018c
|
||||||
container_name: mail-archiver
|
container_name: mail-archiver
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -25,9 +24,10 @@ services:
|
|||||||
- "traefik.http.routers.mail-archiver.entrypoints=websecure"
|
- "traefik.http.routers.mail-archiver.entrypoints=websecure"
|
||||||
- "traefik.http.routers.mail-archiver.tls=true"
|
- "traefik.http.routers.mail-archiver.tls=true"
|
||||||
- "traefik.http.routers.mail-archiver.tls.certresolver=le"
|
- "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"
|
- "traefik.http.services.mail-archiver.loadbalancer.server.port=5000"
|
||||||
networks:
|
networks:
|
||||||
backend_net:
|
backend_net:
|
||||||
external: true
|
external: true
|
||||||
frontend_net:
|
frontend_net:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
services:
|
services:
|
||||||
mealie:
|
mealie:
|
||||||
image: ghcr.io/mealie-recipes/mealie:v3.12.0
|
image: ghcr.io/mealie-recipes/mealie:v3.19.2@sha256:f68e959bf66f4f458893ea58facac71690fe6f2ac7a31466b5cecb41b4e99c02
|
||||||
container_name: mealie
|
container_name: mealie
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# OIDC: Authelia ueber Host-LAN-IP -> Traefik erreichbar (Container-DNS loest
|
||||||
|
# auth.kaleschke.info sonst nicht; gleiches Muster wie Komodo. SNI bleibt der
|
||||||
|
# Hostname, Let's-Encrypt-Cert validiert weiter.
|
||||||
|
extra_hosts:
|
||||||
|
- "auth.kaleschke.info:192.168.178.58"
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
TZ: Europe/Berlin
|
TZ: Europe/Berlin
|
||||||
ALLOW_SIGNUP: "false"
|
ALLOW_SIGNUP: "false"
|
||||||
@@ -14,13 +20,22 @@ services:
|
|||||||
POSTGRES_SERVER: mealie-postgres
|
POSTGRES_SERVER: mealie-postgres
|
||||||
POSTGRES_DB: mealie
|
POSTGRES_DB: mealie
|
||||||
POSTGRES_USER: mealie
|
POSTGRES_USER: mealie
|
||||||
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
|
POSTGRES_PASSWORD: ${MEALIE_POSTGRES_PASSWORD}
|
||||||
|
|
||||||
BASE_URL: https://mealie.kaleschke.info
|
BASE_URL: https://mealie.kaleschke.info
|
||||||
|
|
||||||
|
# --- Authelia OIDC SSO (additiv, 2026-06-06; lokaler Login bleibt) ---
|
||||||
|
OIDC_AUTH_ENABLED: "true"
|
||||||
|
OIDC_PROVIDER_NAME: Authelia
|
||||||
|
OIDC_CONFIGURATION_URL: https://auth.kaleschke.info/.well-known/openid-configuration
|
||||||
|
OIDC_CLIENT_ID: mealie
|
||||||
|
OIDC_CLIENT_SECRET: ${MEALIE_OIDC_CLIENT_SECRET}
|
||||||
|
OIDC_SIGNUP_ENABLED: "true"
|
||||||
|
OIDC_AUTO_REDIRECT: "false"
|
||||||
|
OIDC_REMEMBER_ME: "true"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/user/appdata/mealie/data:/app/data
|
- /mnt/user/appdata/mealie/data:/app/data
|
||||||
- /mnt/user/appdata/secrets/mealie_postgres_password.txt:/run/secrets/postgres_password:ro
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
- frontend_net
|
- frontend_net
|
||||||
@@ -39,7 +54,7 @@ services:
|
|||||||
- traefik.http.services.mealie.loadbalancer.server.port=9000
|
- traefik.http.services.mealie.loadbalancer.server.port=9000
|
||||||
|
|
||||||
mealie-postgres:
|
mealie-postgres:
|
||||||
image: postgres:17
|
image: postgres:18.4@sha256:8ff36f3c66371cba71d20ceedccfc3de9669a68737607888c4ef0af93abe8e39
|
||||||
container_name: mealie-postgres
|
container_name: mealie-postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
@@ -48,10 +63,10 @@ services:
|
|||||||
POSTGRES_USER: mealie
|
POSTGRES_USER: mealie
|
||||||
POSTGRES_DB: mealie
|
POSTGRES_DB: mealie
|
||||||
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
|
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
|
||||||
PGDATA: /var/lib/postgresql/data
|
PGDATA: /var/lib/postgresql/18/docker
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/user/appdata/mealie/postgres:/var/lib/postgresql/data
|
- /mnt/user/appdata/mealie/postgres18:/var/lib/postgresql
|
||||||
- /mnt/user/appdata/secrets/mealie_postgres_password.txt:/run/secrets/postgres_password:ro
|
- /mnt/user/appdata/secrets/mealie_postgres_password.txt:/run/secrets/postgres_password:ro
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
@@ -66,4 +81,4 @@ networks:
|
|||||||
|
|
||||||
mealie_internal:
|
mealie_internal:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
internal: true
|
internal: true
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
services:
|
||||||
|
n8n:
|
||||||
|
image: docker.n8n.io/n8nio/n8n:2.26.2@sha256:61ba01bc5e39304bbc928c9dbecd938c3a5cc1331b68affba6a34d0f654c43d9
|
||||||
|
container_name: n8n
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
dns:
|
||||||
|
- 1.1.1.1
|
||||||
|
- 8.8.8.8
|
||||||
|
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
GENERIC_TIMEZONE: Europe/Berlin
|
||||||
|
|
||||||
|
N8N_HOST: n8n.kaleschke.info
|
||||||
|
N8N_PORT: "5678"
|
||||||
|
N8N_PROTOCOL: https
|
||||||
|
N8N_EDITOR_BASE_URL: https://n8n.kaleschke.info/
|
||||||
|
WEBHOOK_URL: https://n8n.kaleschke.info/
|
||||||
|
N8N_PROXY_HOPS: "1"
|
||||||
|
|
||||||
|
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
|
||||||
|
|
||||||
|
N8N_DIAGNOSTICS_ENABLED: "false"
|
||||||
|
N8N_PERSONALIZATION_ENABLED: "false"
|
||||||
|
N8N_HIRING_BANNER_ENABLED: "false"
|
||||||
|
N8N_RUNNERS_ENABLED: "true"
|
||||||
|
N8N_BLOCK_ENV_ACCESS_IN_NODE: "true"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- /mnt/user/appdata/n8n/data:/home/node/.n8n
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- frontend_net
|
||||||
|
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=frontend_net"
|
||||||
|
- "traefik.http.routers.n8n.rule=Host(`n8n.kaleschke.info`)"
|
||||||
|
- "traefik.http.routers.n8n.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.n8n.tls=true"
|
||||||
|
- "traefik.http.routers.n8n.tls.certresolver=le"
|
||||||
|
- "traefik.http.routers.n8n.middlewares=secure-headers@file"
|
||||||
|
- "traefik.http.services.n8n.loadbalancer.server.port=5678"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
frontend_net:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
{
|
||||||
|
"name": "GMX -> OpenAI -> Gitea Issue (Super Productivity)",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"pollTimes": {
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"mode": "everyMinute"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"format": "simple",
|
||||||
|
"options": {
|
||||||
|
"customEmailConfig": "[\"UNSEEN\"]",
|
||||||
|
"forceReconnect": 15
|
||||||
|
},
|
||||||
|
"postProcessAction": "read"
|
||||||
|
},
|
||||||
|
"id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"name": "IMAP: GMX UNSEEN",
|
||||||
|
"type": "n8n-nodes-base.emailReadImap",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
240,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"imap": {
|
||||||
|
"id": "REPLACE_GMX_IMAP_CRED_ID",
|
||||||
|
"name": "GMX IMAP"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"assignments": {
|
||||||
|
"assignments": [
|
||||||
|
{
|
||||||
|
"id": "a1",
|
||||||
|
"name": "from",
|
||||||
|
"value": "={{ $json.from }}",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a2",
|
||||||
|
"name": "subject",
|
||||||
|
"value": "={{ $json.subject }}",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a3",
|
||||||
|
"name": "date",
|
||||||
|
"value": "={{ $json.date }}",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a4",
|
||||||
|
"name": "messageId",
|
||||||
|
"value": "={{ $json.messageId || $json['message-id'] || '' }}",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a5",
|
||||||
|
"name": "text",
|
||||||
|
"value": "={{ ($json.text || $json.textPlain || '').toString().slice(0, 8000) }}",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "22222222-2222-2222-2222-222222222222",
|
||||||
|
"name": "Extract mail fields",
|
||||||
|
"type": "n8n-nodes-base.set",
|
||||||
|
"typeVersion": 3.4,
|
||||||
|
"position": [
|
||||||
|
460,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.openai.com/v1/chat/completions",
|
||||||
|
"authentication": "genericCredentialType",
|
||||||
|
"genericAuthType": "httpHeaderAuth",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"model\": \"gpt-4o-mini\",\n \"temperature\": 0.2,\n \"response_format\": {\n \"type\": \"json_schema\",\n \"json_schema\": {\n \"name\": \"issue_extraction\",\n \"strict\": true,\n \"schema\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"required\": [\"title\", \"body_md\", \"priority\", \"due_date\", \"category\"],\n \"properties\": {\n \"title\": { \"type\": \"string\", \"maxLength\": 80 },\n \"body_md\": { \"type\": \"string\" },\n \"priority\": { \"type\": \"string\", \"enum\": [\"niedrig\", \"normal\", \"hoch\"] },\n \"due_date\": { \"type\": [\"string\", \"null\"], \"description\": \"ISO YYYY-MM-DD oder null\" },\n \"category\": { \"type\": \"string\" }\n }\n }\n }\n },\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"Du extrahierst aus einer E-Mail eine Aufgabe fuer ein Issue-Tracking-System. Antworte ausschliesslich gemaess JSON-Schema. Sprache: Deutsch.\\n- title: imperativ, max. 80 Zeichen, ohne abschliessenden Punkt.\\n- body_md: 2 bis 6 Saetze. Was ist zu tun, warum, bis wann. Keine Begruessungen.\\n- priority: niedrig | normal | hoch.\\n- due_date: ISO YYYY-MM-DD wenn aus Mail ableitbar, sonst null.\\n- category: kurzes Schlagwort (rechnung, termin, technik, familie, sonstiges, ...).\"\n },\n {\n \"role\": \"user\",\n \"content\": {{ JSON.stringify('Absender: ' + $json.from + '\\nDatum: ' + $json.date + '\\nBetreff: ' + $json.subject + '\\n\\nMailtext:\\n' + $json.text) }}\n }\n ]\n}",
|
||||||
|
"options": {
|
||||||
|
"timeout": 60000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "33333333-3333-3333-3333-333333333333",
|
||||||
|
"name": "OpenAI: extract issue",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
680,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"httpHeaderAuth": {
|
||||||
|
"id": "REPLACE_OPENAI_HEADER_AUTH_CRED_ID",
|
||||||
|
"name": "OpenAI Bearer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"assignments": {
|
||||||
|
"assignments": [
|
||||||
|
{
|
||||||
|
"id": "b1",
|
||||||
|
"name": "extracted",
|
||||||
|
"value": "={{ JSON.parse($json.choices[0].message.content) }}",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b2",
|
||||||
|
"name": "mail",
|
||||||
|
"value": "={{ $('Extract mail fields').item.json }}",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "44444444-4444-4444-4444-444444444444",
|
||||||
|
"name": "Parse OpenAI JSON",
|
||||||
|
"type": "n8n-nodes-base.set",
|
||||||
|
"typeVersion": 3.4,
|
||||||
|
"position": [
|
||||||
|
900,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"assignments": {
|
||||||
|
"assignments": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"name": "title",
|
||||||
|
"value": "={{ ($json.extracted.priority === 'hoch' ? '[P1] ' : '') + $json.extracted.title }}",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c2",
|
||||||
|
"name": "body",
|
||||||
|
"value": "={{ $json.extracted.body_md + '\\n\\n---\\n**Kategorie:** ' + $json.extracted.category + '\\n**Prioritaet:** ' + $json.extracted.priority + ($json.extracted.due_date ? '\\n**Faellig:** ' + $json.extracted.due_date : '') + '\\n**Quelle:** Mail von ' + $json.mail.from + ' (' + $json.mail.date + ')\\n**Betreff:** ' + $json.mail.subject + '\\n**Message-ID:** ' + $json.mail.messageId }}",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "55555555-5555-5555-5555-555555555555",
|
||||||
|
"name": "Build issue payload",
|
||||||
|
"type": "n8n-nodes-base.set",
|
||||||
|
"typeVersion": 3.4,
|
||||||
|
"position": [
|
||||||
|
1120,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://git.kaleschke.info/api/v1/repos/Micha/mails/issues",
|
||||||
|
"authentication": "genericCredentialType",
|
||||||
|
"genericAuthType": "httpHeaderAuth",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"title\": {{ JSON.stringify($json.title) }},\n \"body\": {{ JSON.stringify($json.body) }},\n \"assignees\": [\"Micha\"]\n}",
|
||||||
|
"options": {
|
||||||
|
"timeout": 30000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "66666666-6666-6666-6666-666666666666",
|
||||||
|
"name": "Gitea: create issue",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1340,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"httpHeaderAuth": {
|
||||||
|
"id": "REPLACE_GITEA_HEADER_AUTH_CRED_ID",
|
||||||
|
"name": "Gitea Token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"IMAP: GMX UNSEEN": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Extract mail fields",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Extract mail fields": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "OpenAI: extract issue",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"OpenAI: extract issue": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse OpenAI JSON",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse OpenAI JSON": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Build issue payload",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Build issue payload": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Gitea: create issue",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active": false,
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"instanceId": "homelab-n8n"
|
||||||
|
},
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
services:
|
||||||
|
nextcloud:
|
||||||
|
image: nextcloud:33.0.5-apache@sha256:56bdc45109067500fd0832fa64832b7c77a167d9394cbf5f0f4b59740b94194d
|
||||||
|
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:18.4@sha256:8ff36f3c66371cba71d20ceedccfc3de9669a68737607888c4ef0af93abe8e39
|
||||||
|
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/18/docker
|
||||||
|
volumes:
|
||||||
|
- /mnt/user/appdata/nextcloud/postgres18:/var/lib/postgresql
|
||||||
|
- /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:8.8.0-alpine@sha256:09160599abd229764c0fb44cb6be640294e1d360a54b19985ab4843dcf2d90f1
|
||||||
|
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:
|
services:
|
||||||
ntfy:
|
ntfy:
|
||||||
image: binwiederhier/ntfy:latest
|
image: binwiederhier/ntfy@sha256:f8a9b104313b87cc24ae4f775f39e6328205b57dff6ede3eaf098a91e5d79f59
|
||||||
container_name: ntfy
|
container_name: ntfy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
dns:
|
dns:
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
paperless-gpt:
|
paperless-gpt:
|
||||||
image: icereed/paperless-gpt:latest
|
image: icereed/paperless-gpt:v0.25.1@sha256:c0ce6186028911101a2cfe68353f14a9dbb2653596f3f1cff94de4b6db3114ff
|
||||||
container_name: paperless-gpt
|
container_name: paperless-gpt
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
security_opt:
|
security_opt:
|
||||||
@@ -17,20 +15,25 @@ services:
|
|||||||
PAPERLESS_API_TOKEN: "${PAPERLESS_API_TOKEN}"
|
PAPERLESS_API_TOKEN: "${PAPERLESS_API_TOKEN}"
|
||||||
MANUAL_TAG: "paperless-gpt"
|
MANUAL_TAG: "paperless-gpt"
|
||||||
AUTO_TAG: "paperless-gpt-auto"
|
AUTO_TAG: "paperless-gpt-auto"
|
||||||
LLM_PROVIDER: "ollama"
|
LLM_PROVIDER: "openai"
|
||||||
LLM_MODEL: "cnshenyang/qwen3-nothink:14b"
|
LLM_MODEL: "gpt-5.4-mini"
|
||||||
OLLAMA_HOST: "http://192.168.178.103:11434"
|
OPENAI_API_KEY: "${OPENAI_API_KEY}"
|
||||||
|
OPENAI_BASE_URL: "https://api.openai.com/v1"
|
||||||
|
TOKEN_LIMIT: "12000"
|
||||||
|
LLM_REQUESTS_PER_MINUTE: "30"
|
||||||
LLM_LANGUAGE: "German"
|
LLM_LANGUAGE: "German"
|
||||||
OCR_PROVIDER: "llm"
|
OCR_PROVIDER: "llm"
|
||||||
VISION_LLM_PROVIDER: "ollama"
|
VISION_LLM_PROVIDER: "openai"
|
||||||
VISION_LLM_MODEL: "cnshenyang/qwen3-nothink:14b"
|
VISION_LLM_MODEL: "gpt-5.4-mini"
|
||||||
|
VISION_LLM_TEMPERATURE: "1.0"
|
||||||
|
VISION_LLM_REQUESTS_PER_MINUTE: "20"
|
||||||
OCR_PROCESS_MODE: "image"
|
OCR_PROCESS_MODE: "image"
|
||||||
CREATE_NEW_TAGS: "true"
|
CREATE_NEW_TAGS: "true"
|
||||||
AUTO_GENERATE_TITLE: "true"
|
AUTO_GENERATE_TITLE: "true"
|
||||||
AUTO_GENERATE_TAGS: "true"
|
AUTO_GENERATE_TAGS: "true"
|
||||||
AUTO_GENERATE_CORRESPONDENTS: "true"
|
AUTO_GENERATE_CORRESPONDENTS: "true"
|
||||||
AUTO_GENERATE_DOCUMENT_TYPE: "true"
|
AUTO_GENERATE_DOCUMENT_TYPE: "true"
|
||||||
LOG_LEVEL: "debug"
|
LOG_LEVEL: "info"
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/user/appdata/paperless-gpt/data:/app/data
|
- /mnt/user/appdata/paperless-gpt/data:/app/data
|
||||||
- /mnt/user/appdata/paperless-gpt/prompts:/app/prompts
|
- /mnt/user/appdata/paperless-gpt/prompts:/app/prompts
|
||||||
@@ -48,4 +51,4 @@ services:
|
|||||||
|
|
||||||
networks:
|
networks:
|
||||||
frontend_net:
|
frontend_net:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
version: "3.9"
|
|
||||||
services:
|
services:
|
||||||
paperless:
|
paperless:
|
||||||
image: ghcr.io/paperless-ngx/paperless-ngx:2.20.10
|
image: ghcr.io/paperless-ngx/paperless-ngx:2.20.15@sha256:6c86cad803970ea782683a8e80e7403444c5bf3cf70de63b4d3c8e87500db92f
|
||||||
container_name: paperless-ngx
|
container_name: paperless-ngx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# OIDC: Authelia ueber Host-LAN-IP -> Traefik erreichbar (Container-DNS sonst nicht)
|
||||||
|
extra_hosts:
|
||||||
|
- "auth.kaleschke.info:192.168.178.58"
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
environment:
|
environment:
|
||||||
@@ -17,6 +19,24 @@ services:
|
|||||||
- PAPERLESS_TIME_ZONE=Europe/Berlin
|
- PAPERLESS_TIME_ZONE=Europe/Berlin
|
||||||
- PAPERLESS_OCR_LANGUAGE=deu+eng
|
- PAPERLESS_OCR_LANGUAGE=deu+eng
|
||||||
- PAPERLESS_URL=https://paperless.kaleschke.info
|
- PAPERLESS_URL=https://paperless.kaleschke.info
|
||||||
|
|
||||||
|
# --- Authelia OIDC SSO (additiv, 2026-06-06; lokaler Login bleibt) ---
|
||||||
|
- PAPERLESS_APPS=allauth.socialaccount.providers.openid_connect
|
||||||
|
- PAPERLESS_SOCIAL_AUTO_SIGNUP=true
|
||||||
|
- 'PAPERLESS_SOCIALACCOUNT_PROVIDERS={"openid_connect":{"OAUTH_PKCE_ENABLED":true,"APPS":[{"provider_id":"authelia","name":"Authelia","client_id":"paperless","secret":"${PAPERLESS_OIDC_SECRET}","settings":{"server_url":"https://auth.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:
|
volumes:
|
||||||
- /mnt/user/documents/scans_inbox:/usr/src/paperless/consume
|
- /mnt/user/documents/scans_inbox:/usr/src/paperless/consume
|
||||||
- /mnt/user/appdata/paperless-ngx/data:/usr/src/paperless/data
|
- /mnt/user/appdata/paperless-ngx/data:/usr/src/paperless/data
|
||||||
@@ -33,7 +53,7 @@ services:
|
|||||||
- "traefik.http.routers.paperless.tls=true"
|
- "traefik.http.routers.paperless.tls=true"
|
||||||
- "traefik.http.routers.paperless.tls.certresolver=le"
|
- "traefik.http.routers.paperless.tls.certresolver=le"
|
||||||
- "traefik.http.services.paperless.loadbalancer.server.port=8000"
|
- "traefik.http.services.paperless.loadbalancer.server.port=8000"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
frontend_net:
|
frontend_net:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
services:
|
||||||
|
super-productivity:
|
||||||
|
image: johannesjo/super-productivity:v18.9.1@sha256:773760107344e739f4c29409f7842db66a1b167d50eb2c40248cb5b5b328652e
|
||||||
|
container_name: super-productivity
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- frontend_net
|
||||||
|
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=frontend_net"
|
||||||
|
- "traefik.http.routers.super-productivity.rule=Host(`sp.kaleschke.info`)"
|
||||||
|
- "traefik.http.routers.super-productivity.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.super-productivity.tls=true"
|
||||||
|
- "traefik.http.routers.super-productivity.tls.certresolver=le"
|
||||||
|
- "traefik.http.routers.super-productivity.middlewares=authelia@file,secure-headers@file"
|
||||||
|
- "traefik.http.services.super-productivity.loadbalancer.server.port=80"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
frontend_net:
|
||||||
|
external: true
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
unbound:
|
unbound:
|
||||||
image: shaanmajid/unbound:latest
|
image: shaanmajid/unbound:1.25.1@sha256:f140db02a005904802bf5840093e95e675321aa060a00426fdffc2a3ac2eeb6b
|
||||||
container_name: unbound
|
container_name: unbound
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
@@ -13,4 +13,4 @@ services:
|
|||||||
|
|
||||||
networks:
|
networks:
|
||||||
dns_net:
|
dns_net:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
### VOLUMES ###
|
||||||
|
DriveLetter Label FS Size_GB Free_GB Health
|
||||||
|
C (kein Label) NTFS 166.9 59.5 Healthy
|
||||||
|
D Daten-Projekte NTFS 167.7 148.6 Healthy
|
||||||
|
E Games NTFS 930.6 714.9 Healthy
|
||||||
|
G M2 SSD NTFS 930.9 877.5 Healthy
|
||||||
|
H Externe HDD NTFS 7452.0 3801.3 Healthy
|
||||||
|
(kein BW) Recovery x5 NTFS diverse diverse Healthy
|
||||||
|
|
||||||
|
### DISKS ###
|
||||||
|
Disk 0 INTEL SSDSC2BW180A3L SATA 167.68 GB GPT Healthy Serial: CVCV3105053K180EGN
|
||||||
|
Disk 1 INTEL SSDSC2BW180A3L SATA 167.68 GB GPT Healthy Serial: CVCV311302TH180EGN
|
||||||
|
Disk 2 Samsung SSD 980 PRO 1TB NVMe 931.51 GB GPT Healthy
|
||||||
|
Disk 3 WDC WDS100T2B0C NVMe 931.51 GB GPT Healthy
|
||||||
|
Disk 4 asmedia ASM235 USB 7.28 TB GPT Healthy
|
||||||
|
|
||||||
|
### PARTITIONS ###
|
||||||
|
Disk 0: [Reserved 16MB] [C: 166.87 GB Basic] [Recovery 809 MB]
|
||||||
|
Disk 1: [Reserved 15.98 MB] [D: 167.66 GB Basic]
|
||||||
|
Disk 2: [Reserved 15.98 MB] [E: 930.63 GB Basic] [Recovery 885 MB] <- F: ist weg
|
||||||
|
Disk 3: [System 100 MB] [Reserved 16 MB] [G: 930.89 GB Basic] [Recovery 524 MB]
|
||||||
|
Disk 4: [Reserved 15.98 MB] [H: 7.28 TB Basic]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
### D:\ TOP-LEVEL ###
|
||||||
|
00_Inbox Directory 2026-06-04
|
||||||
|
10_Dokumente Directory 2026-06-04
|
||||||
|
11_Bilder Directory 2026-06-04 [ReadOnly-Attribut gesetzt]
|
||||||
|
12_Videos Directory 2026-06-04
|
||||||
|
13_Musik Directory 2026-06-04
|
||||||
|
14_Downloads Directory 2026-06-04
|
||||||
|
20_Projekte Directory 2026-06-04
|
||||||
|
30_Finanzen Directory 2026-06-04
|
||||||
|
90_Archiv Directory 2026-06-04
|
||||||
|
Micha Directory 2026-06-05 [Altquelle, noch vorhanden]
|
||||||
|
WSL Directory 2026-06-04 [nicht in Soll-Doku]
|
||||||
|
DumpStack.log File
|
||||||
|
|
||||||
|
### D:\Micha INHALT ###
|
||||||
|
Videos Directory 2026-06-05 [1 Datei, 0 MB - fast leer]
|
||||||
|
(alle anderen Unterordner weg)
|
||||||
|
|
||||||
|
### D:\00_Inbox INHALT ###
|
||||||
|
Desktop Directory 2026-06-05 [ReadOnly - das ist das Known-Folder-Ziel!]
|
||||||
|
|
||||||
|
### E:\ TOP-LEVEL ###
|
||||||
|
BattleNet Directory 2026-06-04 [SOLL]
|
||||||
|
EA Directory 2026-06-04 [SOLL]
|
||||||
|
EpicGames Directory 2026-06-04 [SOLL]
|
||||||
|
Riot Directory 2026-06-04 [SOLL]
|
||||||
|
Steam Directory 2026-06-05 [SOLL]
|
||||||
|
Ubisoft Directory 2026-06-04 [SOLL]
|
||||||
|
_Standalone FEHLT! [SOLL laut Doku]
|
||||||
|
|
||||||
|
### G:\ TOP-LEVEL ###
|
||||||
|
Apps Directory 2026-06-04 [nicht in Soll-Doku]
|
||||||
|
Gitea_Clone Directory 2026-04-15 [nicht in Soll-Doku - bewusst, homelab-infra]
|
||||||
|
repos Directory 2026-06-05 [SOLL]
|
||||||
|
Tools Directory 2026-06-05 [SOLL - Doku schreibt 'tools' lowercase, NTFS case-insensitive]
|
||||||
|
Workspace Directory 2026-06-04 [nicht in Soll-Doku]
|
||||||
|
|
||||||
|
### KNOWN FOLDER REDIRECTS (Ist) ###
|
||||||
|
Desktop -> D:\00_Inbox\Desktop [ABWEICHUNG! Soll: D:\Micha\Desktop]
|
||||||
|
Documents -> D:\10_Dokumente [OK]
|
||||||
|
Downloads -> D:\14_Downloads [OK]
|
||||||
|
Pictures -> D:\11_Bilder [OK]
|
||||||
|
Music -> D:\13_Musik [OK]
|
||||||
|
Videos -> D:\12_Videos [OK]
|
||||||
|
|
||||||
|
### DOPPELBESTAND D:\Micha\* vs D:\NN_* ###
|
||||||
|
D:\Micha\Dokumente : NICHT VORHANDEN | D:\10_Dokumente : 4011 Dateien, 595 MB
|
||||||
|
D:\Micha\Bilder : NICHT VORHANDEN | D:\11_Bilder : 7789 Dateien, 12367 MB
|
||||||
|
D:\Micha\Videos : 1 Datei, 0 MB | D:\12_Videos : 1 Datei, 0 MB
|
||||||
|
D:\Micha\Musik : NICHT VORHANDEN | D:\13_Musik : 0 Dateien
|
||||||
|
D:\Micha\Downloads : NICHT VORHANDEN | D:\14_Downloads : 2186 Dateien, 2211 MB
|
||||||
|
D:\Micha\Finanzen : NICHT VORHANDEN | D:\30_Finanzen : 126 Dateien, 123 MB
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
### OS BASELINE ###
|
||||||
|
Caption: Microsoft Windows 11 Pro
|
||||||
|
Build: 26200
|
||||||
|
Version: 10.0.26200
|
||||||
|
Architecture: 64-Bit
|
||||||
|
InstallDate: 2026-05-10 13:11:27
|
||||||
|
LastBoot: 2026-06-05 07:57:08
|
||||||
|
Uptime: 0.04 Tage (~1 Stunde zum Audit-Zeitpunkt)
|
||||||
|
Manufacturer: Micro-Star International Co., Ltd.
|
||||||
|
Model: MS-7D32
|
||||||
|
RAM: 31.79 GB
|
||||||
|
CPU: Intel Core i5-14600KF, 14 Cores, 20 Threads, 3500 MHz
|
||||||
|
|
||||||
|
### AKTIVIERUNG ###
|
||||||
|
Name: Windows(R), Professional edition
|
||||||
|
LicenseStatus: 1 (Aktiv)
|
||||||
|
Channel: OEM_DM
|
||||||
|
|
||||||
|
### AUSSTEHENDE UPDATES ###
|
||||||
|
Windows Update pending: 0
|
||||||
|
Reboot pending: Nein
|
||||||
|
|
||||||
|
### DEFENDER ###
|
||||||
|
AMProductVersion: 4.18.26040.7
|
||||||
|
AMServiceEnabled: True
|
||||||
|
AntivirusEnabled: True
|
||||||
|
AntispywareEnabled: True
|
||||||
|
RealTimeProtection: True
|
||||||
|
TamperProtection: True
|
||||||
|
SignatureAge: 0 Tage (aktuell)
|
||||||
|
Exclusions: KEIN ADMIN -> nicht lesbar
|
||||||
|
ASR Rules: KEIN ADMIN -> nicht lesbar (Get-MpPreference liefert leer)
|
||||||
|
|
||||||
|
### FIREWALL ###
|
||||||
|
Domain: Enabled, DefaultInboundAction: NotConfigured, DefaultOutboundAction: NotConfigured
|
||||||
|
Private: Enabled, DefaultInboundAction: NotConfigured, DefaultOutboundAction: NotConfigured
|
||||||
|
Public: Enabled, DefaultInboundAction: NotConfigured, DefaultOutboundAction: NotConfigured
|
||||||
|
HINWEIS: NotConfigured = Windows-Default (eingehend blockieren, ausgehend erlauben)
|
||||||
|
|
||||||
|
### BITLOCKER ###
|
||||||
|
KEIN ADMIN -> Get-BitLockerVolume verweigert (Access Denied). Status unbekannt.
|
||||||
|
|
||||||
|
### SECURE BOOT ###
|
||||||
|
KEIN ADMIN -> Confirm-SecureBootUEFI verweigert. Status unbekannt.
|
||||||
|
|
||||||
|
### TPM ###
|
||||||
|
KEIN ADMIN -> Get-Tpm liefert alle Felder leer. Status unbekannt.
|
||||||
|
|
||||||
|
### UAC ###
|
||||||
|
EnableLUA: 1 (aktiv)
|
||||||
|
ConsentPromptBehaviorAdmin: 5 (Nachfrage mit UI, ohne Secure Desktop laut Wert, aber...)
|
||||||
|
PromptOnSecureDesktop: 1 (Secure Desktop ist AN - Standard-Konfiguration korrekt)
|
||||||
|
|
||||||
|
### LOKALE ADMINS ###
|
||||||
|
Gruppe Administratoren: Administrator, michi
|
||||||
|
|
||||||
|
### BCD ###
|
||||||
|
KEIN ADMIN -> bcdedit /enum verweigert.
|
||||||
|
Letzte bekannte Aussage (Doku boot-cleanup-plan): Keine partition=F: Referenz nach Cleanup + Neustarttest.
|
||||||
|
|
||||||
|
### WinRE ###
|
||||||
|
KEIN ADMIN -> reagentc /info verweigert.
|
||||||
|
Letzte bekannte Aussage (Doku): WinRE Disabled.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
### NETZWERK-ADAPTER (UP) ###
|
||||||
|
Ethernet Intel I225-V MAC: 04-7C-16-53-04-E4 1 Gbps
|
||||||
|
Tailscale Tunnel 100 Gbps (virtuell)
|
||||||
|
vEthernet WSL (Hyper-V) MAC: 00-15-5D-F3-5F-C9 10 Gbps (virtuell)
|
||||||
|
|
||||||
|
### IP-ADRESSEN ###
|
||||||
|
Ethernet: 192.168.178.103/24
|
||||||
|
Tailscale: 100.78.133.37/32
|
||||||
|
WSL bridge: 172.26.80.1/20
|
||||||
|
(WLAN, Bluetooth etc.: APIPA 169.254.x.x - nicht konfiguriert/inaktiv)
|
||||||
|
|
||||||
|
### DNS ###
|
||||||
|
Ethernet DNS: 192.168.178.58 (= Kallilabcore AdGuard Home)
|
||||||
|
WLAN DNS: 192.168.178.58
|
||||||
|
|
||||||
|
### TAILSCALE STATUS ###
|
||||||
|
100.78.133.37 baerchen-1 (dieser Rechner) online
|
||||||
|
100.105.203.21 baerchen (alter Rechner) offline, last seen 20h ago
|
||||||
|
100.73.83.55 iphone-14 iOS online
|
||||||
|
100.112.0.90 kallilab-core linux online
|
||||||
|
100.80.98.33 kallilabcore linux active; direct 192.168.178.58:49917
|
||||||
|
|
||||||
|
### LAUSCHENDE TCP-PORTS ###
|
||||||
|
Port Adresse Prozess Bemerkung
|
||||||
|
135 0.0.0.0/:: svchost RPC Endpoint Mapper
|
||||||
|
139 192.168.178.103 System NetBIOS
|
||||||
|
445 :: System SMB
|
||||||
|
3000 ::1/:: wslrelay / docker Docker / WSL lokal
|
||||||
|
5040 0.0.0.0 svchost WS-Discovery (WDAS)
|
||||||
|
5357 :: System WSD HTTP
|
||||||
|
7680 :: svchost WUDO (Delivery Optimization)
|
||||||
|
11434 127.0.0.1 ollama Ollama API (lokal)
|
||||||
|
22885 127.0.0.1 Battle.net lokal
|
||||||
|
26822 127.0.0.1 MSI.TerminalServer MSI Center
|
||||||
|
27036 0.0.0.0 steam Steam Remote Play (0.0.0.0 - offen!)
|
||||||
|
27060 127.0.0.1 steam Steam lokal
|
||||||
|
32683 127.0.0.1 MSI.CentralServer MSI Center
|
||||||
|
33683 127.0.0.1 MSI.CentralServer MSI Center
|
||||||
|
38810 fd7a:... tailscaled
|
||||||
|
49553 100.78.133.37 tailscaled
|
||||||
|
50123 127.0.0.1 iCUE Corsair lokal
|
||||||
|
51037 127.0.0.1 RazerAppEngine
|
||||||
|
55316 127.0.0.1 RazerAppEngine
|
||||||
|
59686 127.0.0.1 steam
|
||||||
|
60999 127.0.0.1 Agent Claude Code
|
||||||
|
|
||||||
|
### SSH ###
|
||||||
|
~\.ssh\config: LEER (keine Host-Eintraege)
|
||||||
|
~\.ssh\id_ed25519: vorhanden (411 Bytes, erstellt 2026-04-04)
|
||||||
|
~\.ssh\id_ed25519.pub: vorhanden (97 Bytes)
|
||||||
|
~\.ssh\known_hosts: vorhanden (4719 Bytes, zuletzt 2026-06-04)
|
||||||
|
~\.ssh\known_hosts.old + .pre-port222-Backup: vorhanden
|
||||||
|
|
||||||
|
KEY PERMISSIONS id_ed25519:
|
||||||
|
NT-AUTORITAET\SYSTEM FullControl Allow
|
||||||
|
VORDEFINIERT\Administratoren FullControl Allow
|
||||||
|
baerchen\michi FullControl Allow
|
||||||
|
BEFUND: Zu viele Berechtigungen - Admins-Gruppe hat FullControl auf Private Key.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
### DEV TOOLCHAIN ###
|
||||||
|
git: 2.54.0.windows.1
|
||||||
|
python: 3.13.13
|
||||||
|
node: 24.16.0 (LTS)
|
||||||
|
go: 1.26.4 windows/amd64
|
||||||
|
|
||||||
|
### GIT CONFIG ###
|
||||||
|
user.name: michaelkaleschke-spec
|
||||||
|
user.email: michaelkaleschke@googlemail.com
|
||||||
|
commit.gpgsign: nicht gesetzt (Commits nicht signiert)
|
||||||
|
|
||||||
|
### WSL ###
|
||||||
|
Ubuntu Stopped Version 2
|
||||||
|
docker-desktop Running Version 2
|
||||||
|
|
||||||
|
### DOCKER CONTEXTS ###
|
||||||
|
default npipe:////./pipe/docker_engine (nicht aktiv)
|
||||||
|
desktop-linux* npipe:////./pipe/dockerDesktopLinuxEngine (aktiv)
|
||||||
|
|
||||||
|
### KUBECTL ###
|
||||||
|
Keine Contexts konfiguriert.
|
||||||
|
|
||||||
|
### WINGET INVENTAR (158 Pakete, Auswahl) ###
|
||||||
|
CPUID CPU-Z MSI 2.20.1
|
||||||
|
CPUID HWMonitor 1.63
|
||||||
|
CrystalDiskInfo 9.9.1
|
||||||
|
Docker Desktop 4.76.0
|
||||||
|
Git 2.54.0
|
||||||
|
AusweisApp 2.5.1
|
||||||
|
Node.js LTS 24.16.0
|
||||||
|
Corsair iCUE5 5.46.67
|
||||||
|
NVIDIA App 11.0.7.247 / Treiber 610.47
|
||||||
|
WISO Steuer 2026 33.07.3410
|
||||||
|
Go 1.26.4
|
||||||
|
Microsoft Edge 148.0.3967.96
|
||||||
|
Microsoft OneDrive 23.038 (Update verfuegbar: 26.078)
|
||||||
|
RivaTuner Statistics Server 7.3.7
|
||||||
|
Razer Synapse 4.0.683
|
||||||
|
Steam 2.10.91.91
|
||||||
|
Banking4 Home
|
||||||
|
Battle.net / Hearthstone / Overwatch / World of Warcraft
|
||||||
|
Microsoft 365 16.0.20026.20140
|
||||||
|
|
||||||
|
### AUTOSTART ###
|
||||||
|
HKCU\Run:
|
||||||
|
BraveSoftware Update -> BraveUpdateCore.exe
|
||||||
|
Steam -> E:\Steam\steam.exe -silent
|
||||||
|
RazerAppEngine -> Synapse autoStart
|
||||||
|
Docker Desktop -> Docker Desktop.exe
|
||||||
|
|
||||||
|
HKLM\Run:
|
||||||
|
SecurityHealth -> SecurityHealthSystray.exe
|
||||||
|
Corsair iCUE5 -> iCUE Launcher.exe --autorun
|
||||||
|
RtkAudUService -> Realtek Audio Service
|
||||||
|
|
||||||
|
Startup-Ordner (User): Ollama.lnk
|
||||||
|
Startup-Ordner (Alle): Tailscale.lnk
|
||||||
|
|
||||||
|
### GEPLANTE TASKS (nicht-Microsoft, aktiv) ###
|
||||||
|
OneDrive Reporting Task
|
||||||
|
OneDrive Startup Task
|
||||||
|
OneDrive Per-Machine Standalone Update Task
|
||||||
|
PostponeDeviceSetupToast
|
||||||
|
BraveSoftwareUpdateTask (2x User-Varianten)
|
||||||
|
NVIDIA App SelfUpdate
|
||||||
|
SoftLanding\CreativeManagementTask [UNBEKANNT - pruefen]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
### HARDWARE ###
|
||||||
|
CPU: Intel Core i5-14600KF, 14 Cores / 20 Threads, 3500 MHz Base
|
||||||
|
RAM: 31.79 GB
|
||||||
|
MB: MSI MS-7D32
|
||||||
|
Energieplan: Ausbalanciert (381b4222) - aktiv
|
||||||
|
Verfuegbare Plaene: Ausbalanciert, Ultimative Leistung, Hoechstleistung, Energiesparmodus
|
||||||
|
|
||||||
|
### PHYSICAL DISKS (SMART) ###
|
||||||
|
INTEL SSDSC2BW180A3L SSD Healthy OK (Disk 0, C:)
|
||||||
|
INTEL SSDSC2BW180A3L SSD Healthy OK (Disk 1, D:)
|
||||||
|
Samsung SSD 980 PRO 1TB SSD Healthy OK (Disk 2, E:)
|
||||||
|
WDC WDS100T2B0C SSD Healthy OK (Disk 3, G:)
|
||||||
|
asmedia ASM235 Unspecified Healthy OK (Disk 4, H:)
|
||||||
|
Get-StorageReliabilityCounter: keine Ausgabe (Wear-Daten nicht via WMI verfuegbar - typisch fuer SATA SSDs und USB)
|
||||||
|
|
||||||
|
### GERAETE MIT STATUS "Unknown" (PnP) ###
|
||||||
|
MyBookLiveDuo (SoftwareDevice) - Netzwerkgeraet, nicht angebunden - erwartet
|
||||||
|
HID-Tastatur (Keyboard) - ghosted device - harmlos
|
||||||
|
Dell S2722DGM (DP) (Monitor) - Display-Enumeration Artefakt
|
||||||
|
Generic Monitor x2 - Display-Enumeration Artefakt
|
||||||
|
[LG] webOS TV OLED65G48LW x2 - Netzwerkgeraet, nicht lokal - erwartet
|
||||||
|
Standard-Volumeschattenkopie x3 - VSS Snapshots - erwartet
|
||||||
|
KEINE echten Fehlercodes (kein gelbes Ausrufezeichen).
|
||||||
|
|
||||||
|
### EVENT LOG FEHLER seit Installation (2026-05-10) ###
|
||||||
|
ID 20 (70x): Defender KB4052623 Installation fehlgeschlagen (0x80240016)
|
||||||
|
-> Timing-Problem bei Update-Kaskade, harmlos wenn aktuell
|
||||||
|
ID 10010 (15x): DCOM Server-Timeout {3E11DF0F-...}
|
||||||
|
-> bekanntes Windows-Hintergrundrauschen, harmlos
|
||||||
|
ID 7000 (3x): Steam Client Service Start fehlgeschlagen
|
||||||
|
-> Steam war beim Boot noch nicht bereit, harmlos
|
||||||
|
ID 7023 (3x): Windows Modules Installer beendet mit Fehler
|
||||||
|
-> Update-Installationsabbrueche, pruefbar nach Analyse der Zeitstempel
|
||||||
|
ID 6008 (2x): Unerwartetes Herunterfahren am 2026-05-19 13:56:56
|
||||||
|
-> Einmaliger Vorfall (BSOD oder Stromausfall) kurz nach Installation
|
||||||
|
ID 7034 (2x): MSI Center Service unerwartet beendet
|
||||||
|
-> bekannte Instabilitaet MSI Center, harmlos wenn kein Datenverlust
|
||||||
|
ID 7043 (1x): Dienst konnte nicht gestoppt werden
|
||||||
|
ID 1012 (3x): unbekannte ID - weitere Analyse noetig
|
||||||
|
ID 36 (2x): unbekannte ID - weitere Analyse noetig
|
||||||
|
|
||||||
|
### CRASH DUMPS ###
|
||||||
|
C:\Windows\Minidump: nicht vorhanden
|
||||||
|
C:\Windows\MEMORY.DMP: nicht vorhanden
|
||||||
|
Bewertung: kein BSOD-Dump vorhanden (ggf. Dump-Einstellung "automatisch neu starten" ohne Dump-Schreiben)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
gitea:
|
gitea:
|
||||||
image: docker.gitea.com/gitea:1.25.4
|
image: docker.gitea.com/gitea:1.26.2@sha256:7d13848af12645600a5f9d93ee2560daa9c6fa6b5b859b7bff3a5e1c0b661031
|
||||||
container_name: gitea
|
container_name: gitea
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
security_opt:
|
security_opt:
|
||||||
@@ -11,13 +11,27 @@ services:
|
|||||||
- GITEA__server__DOMAIN=git.kaleschke.info
|
- GITEA__server__DOMAIN=git.kaleschke.info
|
||||||
- GITEA__server__ROOT_URL=https://git.kaleschke.info/
|
- GITEA__server__ROOT_URL=https://git.kaleschke.info/
|
||||||
- GITEA__database__DB_TYPE=sqlite3
|
- 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:
|
volumes:
|
||||||
- /mnt/user/services/gitea/data:/data
|
- /mnt/user/services/gitea/data:/data
|
||||||
|
dns:
|
||||||
|
- 1.1.1.1
|
||||||
|
- 8.8.8.8
|
||||||
ports:
|
ports:
|
||||||
- "222:22"
|
- "222:22"
|
||||||
networks:
|
networks:
|
||||||
- frontend_net
|
- frontend_net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/healthz || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 60s
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.docker.network=frontend_net"
|
- "traefik.docker.network=frontend_net"
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# AI Context
|
||||||
|
|
||||||
|
Stand: 2026-06-05
|
||||||
|
|
||||||
|
Kurzer Kontext fuer KI-Agenten. Nicht als Ersatz fuer die echten Runbooks lesen.
|
||||||
|
|
||||||
|
## Systembild
|
||||||
|
|
||||||
|
- Host: Unraid `Kallilabcore`
|
||||||
|
- Betriebsmodell: GitOps mit Gitea `origin/master` als Sollzustand
|
||||||
|
- Deploy: Komodo zieht aus Gitea und startet Compose-Stacks
|
||||||
|
- Ingress: Traefik; WAN-seitig bewusst nur `443/tcp`
|
||||||
|
- Secrets: nie im Repo, meist unter `/mnt/user/appdata/secrets/`
|
||||||
|
- Backup: Borg plus host-seitige Dumps; Hetzner ist Offsite, H:/ ist lokale Nearline-Kopie
|
||||||
|
|
||||||
|
## Vor jeder Aenderung lesen
|
||||||
|
|
||||||
|
1. `HOMELAB_ARCHITECTURE_MASTER_V2.md`
|
||||||
|
2. `docs/WORKFLOW.md`
|
||||||
|
3. betroffene Compose-Datei
|
||||||
|
4. bei Service-Fragen `docs/SERVICE_CATALOG.md`
|
||||||
|
5. bei Restore/DR `docs/DISASTER_RECOVERY.md` und `docs/RESTORE_MATRIX.md`
|
||||||
|
|
||||||
|
## Harte Regeln
|
||||||
|
|
||||||
|
- Keine Secrets zitieren oder ins Repo schreiben.
|
||||||
|
- Keine produktiven Host-Hotfixes ohne Repo-Abgleich.
|
||||||
|
- Datenbanken nie ins `frontend_net`.
|
||||||
|
- Direkte Host-Ports sind Ausnahme.
|
||||||
|
- Traefik dynamic config und Authelia Host-Config sind manuelle Sync-Ausnahmen.
|
||||||
|
- Bei Drift zuerst Git, Gitea, Komodo Workspace, Docker Runtime und Host getrennt pruefen.
|
||||||
|
- Nach zwei fehlgeschlagenen Reparaturversuchen stoppen und `docs/GITOPS_DRIFT_RUNBOOK.md` nutzen.
|
||||||
|
|
||||||
|
## Bekannte Ausnahmen
|
||||||
|
|
||||||
|
- Traefik: Host-Ports 80/443, WAN-Freigabe nur 443
|
||||||
|
- Gitea: SSH auf Host-Port 222, keine WAN-Freigabe
|
||||||
|
- AdGuard: DNS 53 direkt; Admin nur auf Tailscale-IP `100.80.98.33:8082`
|
||||||
|
- Tailscale und Plex: Host-Netz
|
||||||
|
- Scrutiny: privileged
|
||||||
|
- Komodo/Periphery: Docker-Socket-Zugriff
|
||||||
|
- InfluxDB 3 Core: `127.0.0.1:8181`, Root-User-Ausnahme dokumentiert
|
||||||
|
|
||||||
|
## Aktuelle Restpunkte
|
||||||
|
|
||||||
|
Authoritativ: `docs/MASTER_TODO.md`.
|
||||||
|
|
||||||
|
Kurzfassung:
|
||||||
|
|
||||||
|
- Auth-/OIDC-/CrowdSec-/Hermes-Themen bewusst geparkt
|
||||||
|
- Wochenend-Sprint 2026-06-05: `docs/WEEKEND_EXECUTION_PLAN_2026-06-05.md`
|
||||||
|
und `docs/WEEKEND_STATUS_2026-06-05.md`
|
||||||
|
|
||||||
|
Letzte Bestaetigung:
|
||||||
|
|
||||||
|
- Windows-Image `baerchen`: Veeam Agent Free Job `baerchen-c-image` auf
|
||||||
|
`\\kallilabcore\backups\windows-images\baerchen`, erster Full-Backup-Lauf
|
||||||
|
2026-06-05 erfolgreich, GUI-Wert 53,8 GB, Dauer 0:11:31. Recovery-USB ist
|
||||||
|
erstellt; Boot-/SMB-/Restore-Point-Test ohne Restore ist noch offen.
|
||||||
|
- Veeam Storage Encryption ist beim ersten Full-Lauf nicht aktiv
|
||||||
|
(`StorageEncryptionEnabled=False`); nachtraegliche Aktivierung ist eine
|
||||||
|
Operator-Entscheidung, weil sie Passwort- und Restore-Prozess aendert.
|
||||||
|
- BitLocker fuer `baerchen` ist bewusst nicht aktiviert und bleibt
|
||||||
|
Operator-Entscheidung.
|
||||||
|
- Tailscale-Inventar 2026-06-05 real gemessen: `Kallilabcore`
|
||||||
|
`100.80.98.33`, IPv6 `fd7a:115c:a1e0::2c01:62b2`, kein Exit Node, aber
|
||||||
|
aktiver Subnet Router fuer `192.168.178.0/24`. Dadurch ist die Tailnet-ACL
|
||||||
|
sicherheitsrelevant; Entscheidung Default-Allow vs tag-basierte ACL offen.
|
||||||
|
- Unraid-Flash-Backup-Artefaktpruefung: `ops/maintenance/check-unraid-flash-backup.sh`
|
||||||
|
prueft Artefakt, SHA256, Alter und Kern-Configs. Test 2026-06-05 gegen Host
|
||||||
|
erfolgreich laut `docs/MASTER_TODO.md`.
|
||||||
|
- Borg-Nachlauf 2026-06-01 erfolgreich: Archiv `Taegliche-Sicherung-2026-06-01T04:30:26.913`, Freshness Critical 0 / Warnings 0.
|
||||||
|
- H:/ Nearline-Pull 2026-06-01 repariert: Borg-Dumps werden kuratiert kopiert, Gitea-Bundles aktuell.
|
||||||
|
- Family-Status-Dashboard liegt als `monitoring/grafana/dashboards/family-status.json` im Repo.
|
||||||
|
- Alt-Volumes nach PG18/VectorChord-Burn-in sind seit 2026-06-02 reversibel archiviert unter `/mnt/user/appdata/_archive/pg18-immich-rollback-volumes-20260602`; die alten Originalpfade sind nicht mehr aktiv gemountet.
|
||||||
|
- Family-Onboarding ist auf drei Nutzungsziele fokussiert: Vaultwarden, Immich und Mealie; praktischer Ablauf in `docs/FAMILY_ONBOARDING.md`.
|
||||||
|
- Externer Betreibercheck: `ops/maintenance/check-external-operator.sh`; FRITZ!Box 7590 meldet FRITZ!OS `154.08.25`, DNS fuer Public Apps hat keine AAAA-Records, Host hat keine globale Provider-IPv6.
|
||||||
|
- FRITZ!Box-UI 2026-06-01: Remote-HTTPS auf FRITZ!Box-UI aus, FTP/FTPS auf Speichermedien aus, WAN-Freigabe nur `443/tcp`, keine aktive IPv6-Freigabe sichtbar, UPnP-Selbstfreigaben aus.
|
||||||
|
- FRITZ!Box-Konfig-Backup 2026-06-01 extern/off-system in Vaultwarden abgelegt; Datei und Kennwort bleiben ausserhalb des Repos.
|
||||||
|
- Hetzner-Account-Hygiene 2026-06-01 erledigt: 2FA aktiv, Recovery Key offline gedruckt, Zahlung ok; Storage Box SSH-only, Maintenance-Key in Vaultwarden. Append-only forced-command brach Key-Auth und wurde per Passwort-Recovery zurueckgesetzt; Operator-Entscheidung: fuer dieses Homelab bewusst nicht umsetzen.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Alert Rules
|
||||||
|
|
||||||
|
Stand: 2026-06-05
|
||||||
|
|
||||||
|
Diese Datei beschreibt die produktiven Alarmwege und wichtigsten Regeln. Die
|
||||||
|
Konfiguration selbst liegt in `monitoring/prometheus/alerts.yml` und in den
|
||||||
|
Skripten unter `services/posture-check/`.
|
||||||
|
|
||||||
|
## Alarmwege
|
||||||
|
|
||||||
|
| Weg | Quelle | Ziel |
|
||||||
|
|---|---|---|
|
||||||
|
| Prometheus / Alertmanager | `monitoring/prometheus/alerts.yml` | ntfy `homelab-alerts` |
|
||||||
|
| Posture Check | `services/posture-check/posture-check.sh` | ntfy `homelab-alerts` |
|
||||||
|
| Cert / Token Check | `services/posture-check/cert-token-check.sh` | ntfy `homelab-alerts` |
|
||||||
|
| Compose Runtime Drift | `services/posture-check/compose-runtime-drift.sh` | ntfy `homelab-alerts` |
|
||||||
|
| Docker Critical Events | `services/posture-check/docker-critical-events.sh` | ntfy `homelab-alerts` |
|
||||||
|
| Borg Pre-Hook | `ops/borg-ui/scripts/pre-borg.sh` | ntfy `homelab-alerts` |
|
||||||
|
| Restore Jobs | `ops/restore-tests/run-restore-job-with-ntfy.sh` | Fehler `homelab-alerts`, Erfolg `homelab-info` |
|
||||||
|
|
||||||
|
## Prometheus-Regeln
|
||||||
|
|
||||||
|
| Alarm | Ausloeser | Severity | Aktion |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `HomelabExternalConnectivityDown` | mindestens 5 HTTP-Ziele down | warning | WAN/DNS/Provider pruefen, nicht jede Domain einzeln jagen |
|
||||||
|
| `HomelabEndpointDown` | einzelnes HTTP-Ziel down | critical | Dienst, Traefik-Route und Backend pruefen |
|
||||||
|
| `HomelabEndpointSlow` | Endpoint >5s | warning | Dienstlast oder Backend-Latenz pruefen |
|
||||||
|
| `HomelabCertificateExpiresSoon` | Cert <21 Tage | warning | ACME/Traefik-Renewal beobachten |
|
||||||
|
| `HomelabCertificateExpiresCritical` | Cert <=7 Tage | critical | Renewal sofort pruefen |
|
||||||
|
| `HomelabDiskAlmostFull` | Filesystem >85% | warning | Platz schaffen oder Schwelle pruefen |
|
||||||
|
| `HomelabDiskCritical` | Filesystem >95% | critical | Sofort Platz schaffen |
|
||||||
|
| `HomelabHighMemoryUsage` | MemAvailable <10% | warning | Speicherfresser identifizieren |
|
||||||
|
| `HomelabTraefik5xx` | >=5 5xx je Service in 5 Minuten | warning | betroffenes Backend pruefen |
|
||||||
|
| `HomelabTextfileExporterStale` | Textfile-Exporter >2h alt | warning | Host-Cron pruefen |
|
||||||
|
| `HomelabBorgMetricsMissing` | Borg-Metrik fehlt | critical | Textfile-Exporter oder Borg-UI pruefen |
|
||||||
|
| `HomelabBorgBackupStale` | letztes Borg-Backup >30h | warning | Backup-Lauf nachholen/pruefen |
|
||||||
|
| `HomelabBorgLastJobFailed` | letzter Borg-Job fehlgeschlagen | critical | Borg-UI-Job-Log pruefen |
|
||||||
|
| `HomelabBorgLastJobCompletedWithWarnings` | letzter Borg-Job mit Warnungen | warning | Warnung im Borg-UI-Job lesen |
|
||||||
|
| `HomelabCriticalContainerDown` | kritischer Container fehlt | critical | Komodo/Docker-Status pruefen |
|
||||||
|
| `HomelabPrometheusTargetDown` | Scrape-Ziel down | critical | node-exporter/cadvisor/blackbox/traefik pruefen |
|
||||||
|
|
||||||
|
Die Liste der ueberwachten Critical-Container steht in
|
||||||
|
`services/posture-check/export-prometheus-textfile.sh`.
|
||||||
|
|
||||||
|
## Bekannte Luecken
|
||||||
|
|
||||||
|
- Kein externer Dead-Man's-Switch fuer Prometheus/ntfy-Bridge. Optional spaeter
|
||||||
|
ueber Uptime-Kuma Push-Monitor oder Healthchecks.io.
|
||||||
|
- Kein Inode-Alarm. Bei Paperless/Immich spaeter sinnvoll, aber aktuell kein
|
||||||
|
dokumentierter Vorfall.
|
||||||
|
- Container-Memory-Limits werden erst nach realen Peak-Daten gesetzt; OOM/kill
|
||||||
|
wird ueber `docker-critical-events.sh` gemeldet, sobald der Host-Watcher per
|
||||||
|
Unraid User Script aktiviert ist. Start/Stop/Status/Smoke laufen ueber
|
||||||
|
`services/posture-check/docker-critical-events-supervisor.sh`.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Audit-Restliste 2026-05-25
|
||||||
|
|
||||||
|
Status: **kompakte Restliste**. Die erledigten Sprint-Tabellen und langen
|
||||||
|
Audit-Snapshots wurden aus der Arbeitskopie entfernt; Detailhistorie liegt in Git.
|
||||||
|
|
||||||
|
Letzter Sync mit `docs/MASTER_TODO.md`: 2026-06-05. Offene Punkte sind deckungsgleich;
|
||||||
|
neue Restore-Runbook-Stubs (Unraid Flash / AdGuard / Tailscale / Redis 8) wurden
|
||||||
|
in `docs/RESTORE_MATRIX.md` ergaenzt.
|
||||||
|
|
||||||
|
## Aktuell offene Punkte
|
||||||
|
|
||||||
|
| Prioritaet | Punkt | Naechster Schritt |
|
||||||
|
|---|---|---|
|
||||||
|
| P2 | Family-Onboarding praktisch starten | Fokus: Vaultwarden als Passwortbasis, Immich-Mobile-Backup auf jedem Handy, Mealie mit erstem Rezept/Einkaufsliste; Ablauf steht in `docs/FAMILY_ONBOARDING.md` |
|
||||||
|
|
||||||
|
## Restore-Audit Backlog (Stand 2026-06-03)
|
||||||
|
|
||||||
|
Ergebnis des Restore-Skills-Audits (Session 2026-06-02/03). Die kritischen Bugfixes (Cron-OR-Semantik, ntfy-Race, Cleanup-Trap, Pfad-Inkonsistenz, Vaultwarden-Token, Paperless-Retry, Header-Validierung, Authelia-Test) sind erledigt und committed. Die folgenden Punkte sind bewusst offener Backlog:
|
||||||
|
|
||||||
|
| Prioritaet | Punkt | Status | Naechster Schritt |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P1 | Nextcloud-Restore-Test | **erledigt 2026-06-03** | Borg-Extract + pg_restore (126 Tabellen) + HTTP 200 + `occ status maintenance:false`. Quelle: `hetzner_borg_appdata_critical`, Archiv `Taegliche-Sicherung-2026-06-03T04:30:41.432`. Zwei Skript-Bugs im Zuge des Laufs gefixt (`check_data_directory_permissions: false` patchen, `.ncdata`-Marker anlegen). Report `/mnt/user/backups/restore-reports/nextcloud-2026-06-03.md`. |
|
||||||
|
| P1 | Shared PostgreSQL 18 Cluster Restore Drill | **erledigt 2026-06-03** | Globals + 5 DBs (paperless 72t, mailarchiver 1t, authelia 25t, nextcloud 126t, mealie 66t), `data_checksums=on`, Report `/mnt/user/backups/restore-reports/shared-pg-cluster-2026-06-03.md` |
|
||||||
|
| P1 | Komodo-Mongo-Daten-Restore | **erledigt 2026-06-03** | 86904 Dokumente erfolgreich restored, Report `/mnt/user/backups/restore-reports/komodo-mongo-restore-2026-06-03.md`. Nebenbefund: Dump von Mongo 8.0.23, Test auf 7.0.32 — Cross-Version-Warning, fuer Lesetest harmlos |
|
||||||
|
| P2 | Mailarchiver-Restore-Test | **erledigt 2026-06-03** | Data-Protection-Keys + 645M pg_restore + HTTP 200. Report `/mnt/user/backups/restore-reports/mailarchiver-2026-06-03.md` |
|
||||||
|
| P2 | Mealie-Restore-Test | **erledigt 2026-06-03** | Borg-Data + pg_restore + HTTP 200, 3 Rezepte. Report `/mnt/user/backups/restore-reports/mealie-2026-06-03.md` |
|
||||||
|
| P2 | Traefik-Restore-Test | **erledigt 2026-06-03** | dynamic/ + letsencrypt/ aus Borg, File-Provider + Ping 200. CF-Token bewusst nicht im Smoke. Report `/mnt/user/backups/restore-reports/traefik-2026-06-03.md` |
|
||||||
|
| P3 | Negativ-Test fuer Frische-Check | offen | Einmal pro Quartal bewusst kaputten Dump einfuettern und pruefen ob `homelab-alerts` feuert |
|
||||||
|
| P3 | End-to-end-DR-Drill | offen | Komplett-Bootstrap Phase 1-5 auf einem Wegwerf-Host; realistisch nur mit zweiter Hardware |
|
||||||
|
|
||||||
|
## Bewusst geparkt
|
||||||
|
|
||||||
|
| Punkt | Entscheidung |
|
||||||
|
|---|---|
|
||||||
|
| Authelia 2FA fuer Operator-UIs (Rest) | Tier-1-Operator-UIs sind 2026-06-03 auf `two_factor` gehoben (`files`, `scrutiny`, `borg`, `code`). Restliche Admin-UIs (`monitoring`, `glances`, `glance`, `speedtest`, `paperless-gpt`, `pdf`, `mail`, `hermes`, `sp`) bleiben bewusst auf `one_factor`, bis die finale Auth-Policy steht. |
|
||||||
|
| Authelia OIDC fuer Apps | Geparkt bis klare Familien-/SSO-Entscheidung |
|
||||||
|
| CrowdSec vor Traefik | Bewusst nicht umgesetzt: einzige WAN-Tuer ist `443/tcp`, Operator-Pfad ist Tailscale, Authelia-`regulation:` deckt Auth-Brute-Force ab. Neu bewerten bei breiterer Attack Surface. |
|
||||||
|
| Nextcloud 2FA/Brute-Force-Haertung | UI-Schritt fuer Operator-Account (`twofactor_totp` aktivieren) bleibt offen. App-weite Familien-Policy gemeinsam mit OIDC entscheiden. |
|
||||||
|
| Hermes-Agent | NAS-Stack bleibt deaktiviert; Review-Deadline 2026-07-25 |
|
||||||
|
| USV | Anschaffung verschoben; Power-Loss-Risiko bewusst akzeptiert |
|
||||||
|
| Zweites Off-site-Ziel | Bewusst nicht umgesetzt; neu bewerten bei Hetzner-Problemen, stark wachsendem Datenwert oder geaenderter Betreiber-Praeferenz |
|
||||||
|
| Borg `append-only` auf Hetzner | Operator-Entscheidung 2026-06-01: nicht umgesetzt. Der forced-command-Test auf der Storage Box brach Key-Auth und wurde per Passwort-Recovery zurueckgesetzt; Nutzen steht fuer dieses Homelab nicht im Verhaeltnis zum Betriebsrisiko. |
|
||||||
|
|
||||||
|
## Zuletzt geschlossen
|
||||||
|
|
||||||
|
- DR-Workstation Bare-Metal-Kit abgeschlossen (2026-06-06): WSL2 Ubuntu 24.04, SSH/Git, Borg 1.2.8, DR-Key-Arbeitskopien `~/.ssh/dr-readonly` und `~/.ssh/dr-hetzner`, `~/dr-smoke.sh`. Finaler Operator-Smoke erfolgreich: GitHub HEAD `3a263a4...`, Hetzner Storage Box Repos sichtbar (`backup`, `backup2`, `hetzner_borg_appdata`, `hetzner_borg_appdata_critical`), Borg-Repo `hetzner_borg_appdata_critical` gelesen, Repository ID `5dd9b949...`, encrypted `Yes (repokey)`, `DR-Smoke OK (2026-06-06 10:05:30)`. Borg-Passphrase wurde nur interaktiv eingegeben und nicht gespeichert.
|
||||||
|
- Nextcloud-Restore-Test 2026-06-03 erfolgreich (Tier-2 damit komplett belegt). Drei Laeufe noetig: Lauf 1 schlug an `chmod()` der data-Dir auf shfs fehl (`OC_Util.php:486`), Lauf 2 an fehlender `.ncdata`-Marker-Datei, Lauf 3 sauber durch. Beide Bug-Fixes ins Skript `ops/restore-tests/nextcloud-restore-test.sh` integriert. Endresultat: HTTP 200 auf `/status.php`, `occ status` ok, 126 Tabellen in der DB. Source: `hetzner_borg_appdata_critical`, Archiv `Taegliche-Sicherung-2026-06-03T04:30:41.432`. Report unter `/mnt/user/backups/restore-reports/nextcloud-2026-06-03.md`.
|
||||||
|
- Hetzner Storage Box DR-SSH-Key `dr-hetzner-2026-06-03` (ed25519, Passphrase-frei) angelegt: Pubkey via `install-ssh-key` auf der Storage Box autorisiert, passwortloser Login erfolgreich (Borg-Repos `backup`, `backup2`, `hetzner_borg_appdata`, `hetzner_borg_appdata_critical` sichtbar), Private-Key offline neben KOMODO_*-Notiz und GitHub-Deploy-Key abgelegt, Arbeitsplatz-Kopie geloescht. Damit ist Bare-Metal-Borg-Zugang von der DR-Workstation moeglich, sobald WSL2+Borg installiert sind.
|
||||||
|
- Fix Common Problems Plugin (FCP) 2026-06-03 deinstalliert. Befund: drei `grep -R ... /usr/local/emhttp`-Prozesse aus einem FCP-Daily-Scan hingen seit ~7 Tagen in einem Symlink-Loop (`/usr/local/emhttp/mnt -> /mnt`, gesamte Array). 3 Cores dauerhaft 100 %, IOWAIT bis 55 %, USB-Flash unter Dauer-IO. Plugin via `plugin remove` entfernt, Cron + /tmp-Reste sauber, Load von 14.6 auf 1.08 gefallen. FCP wird bewusst nicht wieder installiert (Begruendung siehe `HOMELAB_ARCHITECTURE_MASTER_V2.md` Sektion 13). Bekannte Risiken decken Scrutiny, Monitoring, Posture-Check und Critical-Events-Watcher bereits ab.
|
||||||
|
- GitHub-Mirror Read-Only Deploy-Key `DR Read-Only 2026-06-03` (ed25519, Passphrase-frei) angelegt: GitHub Repo Settings -> Deploy Keys ohne Write-Access, Smoke `git ls-remote` erfolgreich (HEAD `d947c7f` = master), Private-Key offline neben der KOMODO_*-Notiz abgelegt, Arbeitsplatz-Kopie nach USB-Transfer geloescht. Damit ist der DR-Read-Pfad zum privaten Mirror ohne Operator-Browser-Login moeglich.
|
||||||
|
- KOMODO_*-Notiz offline gesichert (Operator-Bestaetigung 2026-06-03). Quelle bleibt host-seitige `.env` unter `/mnt/user/services/stacks/komodo/.env` bzw. die Drift-Recovery-Kopie unter `/mnt/user/appdata/secrets/_komodo_stack_env_recovery_2026-05-04.env`. Damit ist der Bare-Metal-Komodo-Bootstrap ohne Vaultwarden moeglich. Eintrag in `docs/EXTERNAL_DEPENDENCIES.md` Reviews und Pflichtbestandteil im DR-Workstation-Kit nachgezogen.
|
||||||
|
- DR-Tabletop 2026-06-03 durchgelaufen, Findings in `docs/DR_DRILL_2026-06-03.md` (23 Befunde: 1 CRITICAL, 11 HIGH, 8 MED, 3 LOW). Reine Doku-Fixes in DR.md (Phase 0 Mirror-Klarstellung, neue Phase 4 Stufe 0 Docker-Netze, LE-Staging-Hinweis, Komodo-Stolperfallen, App-DB-Verify in Phase 5) und in `EXTERNAL_DEPENDENCIES.md` (DR-Workstation-Kit, KOMODO_*-Notiz und GitHub-Read-PAT als offene Bootstrap-Bloecke) sind im selben Aenderungsblock erledigt. Operator-Aufgaben (Notiz/PAT/WSL-Setup) wandern als P1 in die offenen Punkte.
|
||||||
|
- Authelia ACL: `borg.kaleschke.info` und `code.kaleschke.info` 2026-06-03 in den `two_factor`-Block der Repo-Baseline aufgenommen. Beide UIs haben effektiv Host-/Backup-Zugriff (Borg-Restore-Scope inkl. `/local/secrets`, code-server mit Workspaces). Wirkung erst nach manuellem Merge in `/mnt/user/appdata/authelia/config/configuration.yml`, `docker restart authelia` und Smoke-Test auf einer der vier 2FA-Domains; `services/authelia-diff.sh` muss `exit 0` liefern. TOTP-Enrollment des Operator-Accounts ist Voraussetzung, sonst Login-Sperre.
|
||||||
|
- Alt-Volumes nach Burn-in freigegeben und reversibel archiviert: Shared PG17, Mealie PG17, Nextcloud PG17 und Immich pgvecto.rs liegen seit 2026-06-02 unter `/mnt/user/appdata/_archive/pg18-immich-rollback-volumes-20260602`; Manifest auf dem Host: `/mnt/user/appdata/_archive/pg18-immich-rollback-volumes-20260602/MANIFEST.txt`. Keine harte Loeschung, keine aktiven Container-Mounts auf die alten Pfade.
|
||||||
|
- Externer Betreibercheck vorbereitet: `docs/EXTERNAL_OPERATOR_RUNBOOK.md` und `ops/maintenance/check-external-operator.sh`; Live-Baseline am 2026-06-01: FRITZ!OS `154.08.25`, keine Public-AAAA-Records fuer `*.kaleschke.info`, Host ohne globale Provider-IPv6, WAN `443/tcp` offen und `80/tcp`/`222/tcp` geschlossen.
|
||||||
|
- FRITZ!Box-Servicefenster UI-seitig abgeschlossen: FRITZ!Box-Dienste aus dem Internet sind aus (HTTPS auf FRITZ!Box-UI, FTP/FTPS auf Speichermedien), aktive WAN-Freigabe bleibt nur `443/tcp -> 192.168.178.58`, keine aktive IPv6-Freigabe sichtbar, UPnP-Selbstfreigaben aus.
|
||||||
|
- FRITZ!Box-Konfig-Backup exportiert und extern/off-system in Vaultwarden abgelegt: `Einstellungen_FRITZ.Box_7590_154.08.25_01.06.26_1318.export`; Kennwort und Datei bleiben ausserhalb des Repos.
|
||||||
|
- Hetzner-Account-Hygiene erledigt: externe Kontakt-/Rechnungs-Mail bestaetigt, Zahlung ok, 2FA mit Google Authenticator aktiv, Recovery Key offline ausgedruckt.
|
||||||
|
- Hetzner Storage Box geprueft: `storage-box-1`, `u565255.your-storagebox.de`, SSH-Port `23`, SSH aktiv, SMB/WebDAV aus, 64,94 GB / 1 TB belegt; Borg-UI-Key und separater Maintenance-Key funktionieren wieder nach Passwort-Recovery. Borg `append-only` ist bewusst nicht umgesetzt.
|
||||||
|
- Family-View Dashboard ist repo-seitig gebaut: `monitoring/grafana/dashboards/family-status.json` zeigt Family-App-Uptime, Backup-Alter, TLS-Restlaufzeit, Critical-Container und Image-Drift.
|
||||||
|
- Borg-Nachlauf nach dem 2026-05-31-Sprint ist belegt: Archiv `Taegliche-Sicherung-2026-06-01T04:30:26.913`, 101669 Dateien, `rc=0`; Freshness-Check am 2026-06-01: Critical 0, Warnings 0.
|
||||||
|
- H:/ Nearline-Pull am 2026-06-01 repariert und manuell validiert: kuratierte Borg-Dumps Exit 0, Gitea-Bundles Exit 1 (Robocopy-Erfolg mit Kopien), Report `nearline-pull-2026-06-01-082553.md`.
|
||||||
|
- Immich-, Paperless-, Gitea- und Vaultwarden-Restore-Pfade sind belegt.
|
||||||
|
- H:/ Nearline-Pull laeuft seit 2026-05-28 als Windows Scheduled Task.
|
||||||
|
- FRITZ!Box-Portfreigaben sind bereinigt: WAN-seitig bleibt `443/tcp`.
|
||||||
|
- InfluxDB 3 Core ist effektiv nur auf `127.0.0.1:8181` gebunden.
|
||||||
|
- Renovate ist produktiv, Major-Updates werden bewusst manuell entschieden.
|
||||||
|
- Policy-Check bleibt ohne Criticals; bekannte Root-Ausnahmen sind dokumentiert.
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# Authelia OIDC fuer Apps - Plan & Runbook
|
||||||
|
|
||||||
|
Stand: 2026-06-06. Authelia-Version: **v4.39.20**.
|
||||||
|
|
||||||
|
Ziel: App-uebergreifendes Single-Sign-On ueber Authelia als OpenID-Connect-Provider
|
||||||
|
(`https://auth.kaleschke.info`). Statt pro App eigener Logins meldet man sich einmal
|
||||||
|
bei Authelia an (inkl. 2FA) und wird per OIDC an die App durchgereicht.
|
||||||
|
|
||||||
|
> **Status:** aktives Runbook. Grafana und Mealie sind seit 2026-06-06 live
|
||||||
|
> und per Login-Smoke verifiziert. Der weitere Rollout bleibt additiv: lokale
|
||||||
|
> App-Logins bleiben als Fallback aktiv.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Grundregeln (wichtig)
|
||||||
|
|
||||||
|
- **Secrets gehoeren nie ins Repo.** OIDC-Client-Secrets (Klartext und pbkdf2-Hash)
|
||||||
|
liegen ausschliesslich in der Host-Config `/mnt/user/appdata/authelia/config/configuration.yml`
|
||||||
|
(Hash) und im jeweiligen App-Stack (Klartext, via Komodo Stack-ENV / Secret-Datei),
|
||||||
|
plus optional Vaultwarden. Dieses Dokument enthaelt nur Schema und Variablennamen.
|
||||||
|
- **OIDC-Clients leben host-seitig**, wie der bestehende `beszel`-Client. Die Repo-Baseline
|
||||||
|
`security/authelia/configuration.yml` haelt nur die nicht-geheime Struktur
|
||||||
|
(`access_control` etc.); `services/authelia-diff.sh` vergleicht standardmaessig nur
|
||||||
|
`access_control`, OIDC-Clients auf dem Host loesen also keinen Drift-Alarm aus.
|
||||||
|
- **Issuer/Endpoints** (Authelia OIDC):
|
||||||
|
- Issuer: `https://auth.kaleschke.info`
|
||||||
|
- Authorization: `https://auth.kaleschke.info/api/oidc/authorization`
|
||||||
|
- Token: `https://auth.kaleschke.info/api/oidc/token`
|
||||||
|
- Userinfo: `https://auth.kaleschke.info/api/oidc/userinfo`
|
||||||
|
- JWKS: `https://auth.kaleschke.info/jwks.json`
|
||||||
|
- Discovery: `https://auth.kaleschke.info/.well-known/openid-configuration`
|
||||||
|
- **PKCE an, wo moeglich** (`require_pkce: true`, `S256`), wie beim Beszel-Client.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client-Schema (Authelia v4.39, gespiegelt vom bestehenden `beszel`-Client)
|
||||||
|
|
||||||
|
Pro App ein Block unter `identity_providers.oidc.clients` in der **Host-Config**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
identity_providers:
|
||||||
|
oidc:
|
||||||
|
clients:
|
||||||
|
- client_id: '<app>'
|
||||||
|
client_name: '<App-Name>'
|
||||||
|
client_secret: '<pbkdf2-sha512-Hash - NUR auf dem Host>'
|
||||||
|
public: false
|
||||||
|
authorization_policy: 'two_factor' # admin-Apps: two_factor; Familien-Apps: s.u.
|
||||||
|
require_pkce: true
|
||||||
|
pkce_challenge_method: 'S256'
|
||||||
|
redirect_uris:
|
||||||
|
- 'https://<app>.kaleschke.info/<oidc-callback-pfad>'
|
||||||
|
scopes:
|
||||||
|
- 'openid'
|
||||||
|
- 'profile'
|
||||||
|
- 'email'
|
||||||
|
- 'groups'
|
||||||
|
response_types:
|
||||||
|
- 'code'
|
||||||
|
grant_types:
|
||||||
|
- 'authorization_code'
|
||||||
|
token_endpoint_auth_method: 'client_secret_basic'
|
||||||
|
userinfo_signed_response_alg: 'none'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client-Secret erzeugen (auf dem Host)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec authelia authelia crypto hash generate pbkdf2 \
|
||||||
|
--variant sha512 --random --random.length 72 --random.charset rfc3986
|
||||||
|
```
|
||||||
|
|
||||||
|
- Ausgabe: **Random Password** (Klartext) + **Digest** (pbkdf2-Hash).
|
||||||
|
- **Hash** -> Host-Config `client_secret`.
|
||||||
|
- **Klartext** -> App-Stack (Komodo Stack-ENV/Secret) + optional Vaultwarden.
|
||||||
|
- Klartext **nicht** ins Repo, nicht in Logs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reihenfolge / Rollout
|
||||||
|
|
||||||
|
| Stufe | App | Domain | OIDC-Support | Policy | Risiko | Begruendung |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **1 (Proof) ERLEDIGT 2026-06-06** | Grafana (monitoring) | `monitoring.kaleschke.info` | nativ (`generic_oauth`) | `two_factor` | niedrig | **Live + Login verifiziert.** Authelia-Client `grafana` (host), Secret als Datei `/mnt/user/appdata/secrets/grafana_oidc_client_secret` via `__FILE`, ForwardAuth-Middleware durch OIDC ersetzt, lokaler Admin bleibt Fallback |
|
||||||
|
| 2 | Immich | `immich.kaleschke.info` | nativ (Admin-UI/Config-File) | s. u. (Familie) | mittel | **GEPARKT bis Onboarding (Entscheidung 2026-06-06):** nur `micha` hat Authelia-Account, Familien-SSO-Nutzen entsteht erst mit Familien-Accounts; Immich ist mobil-lastig (hoechste Stoeranfaelligkeit) und braucht UI/Config-File. Erst nach Onboarding gezielt. Runbook bereit. |
|
||||||
|
| 3 | Nextcloud | `cloud.kaleschke.info` | App `user_oidc` (+occ) | s. u. | mittel | **GEPARKT bis Onboarding (Entscheidung 2026-06-06):** wie Immich; braucht `user_oidc`-App-Install + `occ`. Lokaler Login bleibt. Erst nach Onboarding. Runbook bereit. |
|
||||||
|
| **4 ERLEDIGT 2026-06-06** | Mealie | `mealie.kaleschke.info` | nativ | `one_factor` | niedrig | **Live + Login verifiziert.** OIDC-Env additiv (lokaler Login bleibt), Secret als Stack-ENV `${MEALIE_OIDC_CLIENT_SECRET}`, `extra_hosts` noetig (s. Gotchas) |
|
||||||
|
| 5 | Paperless-ngx | `paperless.kaleschke.info` | `django-allauth` (Umgebungsvariablen) | `two_factor` | mittel | dokumentenlastig, Operator-nah |
|
||||||
|
|
||||||
|
**Nicht OIDC:** Vaultwarden hat kein Standard-Endnutzer-OIDC (SSO ist Enterprise/Bitwarden-Feature) -> bleibt eigener Login. ntfy bleibt wie gehabt.
|
||||||
|
|
||||||
|
### Policy Familien-Apps
|
||||||
|
|
||||||
|
- Admin-Apps (Grafana, Paperless): `authorization_policy: two_factor`.
|
||||||
|
- Familien-Apps (Immich, Nextcloud, Mealie): Start mit `one_factor` und lokalen
|
||||||
|
App-Logins als Fallback. 2FA fuer Familie erst spaeter, sobald TOTP-Enrollment
|
||||||
|
pro Person eingerichtet ist; sonst entsteht unnoetiges Lockout-Risiko.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stufe 1 konkret: Grafana (empfohlener Erststart)
|
||||||
|
|
||||||
|
### A) Authelia (Host) - Client anlegen
|
||||||
|
1. Secret erzeugen (Befehl oben). Klartext + Hash notieren.
|
||||||
|
2. In `/mnt/user/appdata/authelia/config/configuration.yml` unter
|
||||||
|
`identity_providers.oidc.clients` neuen Block einfuegen:
|
||||||
|
```yaml
|
||||||
|
- client_id: 'grafana'
|
||||||
|
client_name: 'Grafana'
|
||||||
|
client_secret: '<HASH>'
|
||||||
|
public: false
|
||||||
|
authorization_policy: 'two_factor'
|
||||||
|
require_pkce: true
|
||||||
|
pkce_challenge_method: 'S256'
|
||||||
|
redirect_uris:
|
||||||
|
- 'https://monitoring.kaleschke.info/login/generic_oauth'
|
||||||
|
scopes: ['openid', 'profile', 'email', 'groups']
|
||||||
|
response_types: ['code']
|
||||||
|
grant_types: ['authorization_code']
|
||||||
|
token_endpoint_auth_method: 'client_secret_basic'
|
||||||
|
userinfo_signed_response_alg: 'none'
|
||||||
|
```
|
||||||
|
3. `docker restart authelia`, Health + Log pruefen (`Startup complete`, keine Fehler).
|
||||||
|
|
||||||
|
### B) Grafana (Komodo Stack-ENV) - generic_oauth
|
||||||
|
Im `monitoring`-Stack (Grafana) setzen (Klartext-Secret aus Schritt A):
|
||||||
|
```
|
||||||
|
GF_AUTH_GENERIC_OAUTH_ENABLED=true
|
||||||
|
GF_AUTH_GENERIC_OAUTH_NAME=Authelia
|
||||||
|
GF_AUTH_GENERIC_OAUTH_CLIENT_ID=grafana
|
||||||
|
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET=<KLARTEXT-SECRET>
|
||||||
|
GF_AUTH_GENERIC_OAUTH_SCOPES=openid profile email groups
|
||||||
|
GF_AUTH_GENERIC_OAUTH_AUTH_URL=https://auth.kaleschke.info/api/oidc/authorization
|
||||||
|
GF_AUTH_GENERIC_OAUTH_TOKEN_URL=https://auth.kaleschke.info/api/oidc/token
|
||||||
|
GF_AUTH_GENERIC_OAUTH_API_URL=https://auth.kaleschke.info/api/oidc/userinfo
|
||||||
|
GF_AUTH_GENERIC_OAUTH_USE_PKCE=true
|
||||||
|
GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=true
|
||||||
|
# optional Rollen-Mapping ueber groups:
|
||||||
|
# GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH=contains(groups[*], 'admins') && 'Admin' || 'Viewer'
|
||||||
|
```
|
||||||
|
- `GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET` als Stack-ENV-only (kein `_FILE`-Support) -> in
|
||||||
|
`docs/SECRETS_MAP.md` als `grafana_oidc_client_secret` (Stack-ENV) nachziehen.
|
||||||
|
|
||||||
|
### C) Test + Rollback
|
||||||
|
- Test: `monitoring.kaleschke.info` -> "Sign in with Authelia" -> Authelia-Login (2FA) -> zurueck in Grafana, eingeloggt.
|
||||||
|
- **Fallback bleibt:** lokaler Grafana-Admin-Login (`/login`) ist weiter aktiv -> kein Lockout.
|
||||||
|
- Rollback: `GF_AUTH_GENERIC_OAUTH_ENABLED=false` (Grafana redeploy) und/oder Client-Block in Authelia entfernen + `docker restart authelia`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Doku-Nachzug bei jedem neuen Client
|
||||||
|
|
||||||
|
- `docs/SECRETS_MAP.md`: pro App `<app>_oidc_client_secret` (Stack-ENV) + Hinweis "Hash in Authelia-Host-Config".
|
||||||
|
- `docs/SERVICE_CATALOG.md`: App-Zeile um "OIDC via Authelia" ergaenzen.
|
||||||
|
- Dieses Dokument: Rollout-Tabelle abhaken.
|
||||||
|
- `docs/MASTER_TODO.md`: Fortschritt im OIDC-Punkt nachziehen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gotchas (aus dem realen Rollout 2026-06-06)
|
||||||
|
|
||||||
|
- **`extra_hosts` ist Pflicht fuer App-Container, die selbst zu Authelia connecten**
|
||||||
|
(OIDC-Discovery/Token sind Server-zu-Server): Der App-Container loest
|
||||||
|
`auth.kaleschke.info` per Docker-DNS oft nicht auf -> `httpx.ConnectTimeout` /
|
||||||
|
500 beim OAuth-Start. Fix wie Komodo:
|
||||||
|
```yaml
|
||||||
|
extra_hosts:
|
||||||
|
- "auth.kaleschke.info:192.168.178.58"
|
||||||
|
```
|
||||||
|
Cert validiert weiter (SNI/Hostname bleibt gleich, nur die IP wird gemappt).
|
||||||
|
Gilt fuer Mealie (bestaetigt) und sehr wahrscheinlich Paperless/Immich/Nextcloud.
|
||||||
|
- **Additiv heisst additiv:** OIDC als zusaetzlichen Login aktivieren, lokalen
|
||||||
|
Login NICHT abschalten, `AUTO_REDIRECT`/Force-OIDC aus -> kein Lockout.
|
||||||
|
- **Account-Linking per E-Mail:** Apps verknuepfen den OIDC-User i. d. R. per
|
||||||
|
E-Mail-Claim. Stimmt die Authelia-E-Mail mit dem App-Account, wird verknuepft;
|
||||||
|
sonst legt die App (bei aktivem Signup) einen neuen User an.
|
||||||
|
- **Secret-Mechanik je App verschieden:** Grafana `__FILE` (Docker-Secret),
|
||||||
|
Mealie Stack-ENV `${...}`. Hash immer in der Authelia-Host-Config, Klartext nie ins Repo.
|
||||||
|
|
||||||
|
## Spaetere Feinschliffe vor breitem Rollout
|
||||||
|
|
||||||
|
1. Gruppen/Rollen-Mapping: braucht es Authelia-Gruppen (z. B. `admins`, `family`) fuer
|
||||||
|
App-Rollen (Grafana Admin/Viewer, Nextcloud-Gruppen)? Wenn ja, in der Authelia
|
||||||
|
User-Datenbank Gruppen pflegen.
|
||||||
|
2. Familien-2FA spaeter neu bewerten, nachdem echte Familien-Accounts in Authelia
|
||||||
|
angelegt und TOTP pro Person verstanden ist.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Capacity and Lifecycle - KalliLab CORE
|
||||||
|
|
||||||
|
Status: Initiale Capacity-Baseline 2026-05-26; H:/-Nearline-Pull seit 2026-05-28 produktiv; zweites Off-site/Cold-Storage bewusst nicht umgesetzt.
|
||||||
|
|
||||||
|
## 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 | extern / Storage Box | nicht repo-seitig gemessen | nicht repo-seitig gemessen | Borg-Stale-Alert + Account-Review | einziges echtes Off-site-Ziel |
|
||||||
|
| Externe Cold-Platte | nicht vorhanden | - | - | Review nur bei Trigger | bewusst nicht beschafft; zweites Off-site erst bei Hetzner-Problemen, stark wachsendem Datenwert oder geaenderter Betreiber-Praeferenz |
|
||||||
|
|
||||||
|
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 | aktuell klein, kann durch Familiennutzung stark wachsen | Datenwachstum und Quotas koennen spaeter relevant werden | Quota/Backup bei Familien-Onboarding 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 | Risiko ist per Operator-Entscheidung 2026-05-26 bewusst akzeptiert; bei Stromausfaellen/Datenkorruption neu bewerten |
|
||||||
|
|
||||||
|
## H:/ als zusaetzliches lokales Backup-Ziel
|
||||||
|
|
||||||
|
`H:/` ist **keine echte Offsite-/Airgap-Kopie und kein Ersatz fuer Hetzner**. Es ist aber sinnvoll als zweite lokale Nearline-Kopie fuer kritische Restore-Quellen (Borg-Dumps, Repo-Bundles, Flash-Backup) und als Freeze-Sicherung vor strukturellen Eingriffen.
|
||||||
|
|
||||||
|
| Nutzung | Umsetzung | Hinweis |
|
||||||
|
|---|---|---|
|
||||||
|
| Pull von `/mnt/user/backups/borg/dumps/latest` auf H:/ | Windows Scheduled Task per `robocopy` | keine CIFS-Hard-Mounts auf Unraid |
|
||||||
|
| Pull der Gitea-Bundles aus `/mnt/user/backups/git-bundles/gitea` | identisch | Bundles sind klein und schnell synchronisiert |
|
||||||
|
| Pull des Unraid-Flash-Artefakts `unraid-flash-config.tar.gz` | bewusst nicht im H:/ Scope | Restore-Quelle bleibt Hetzner-Borg; Flash-Config wie Secret behandeln |
|
||||||
|
|
||||||
|
Der konkrete Pull-Pfad ist in `docs/H_DRIVE_NEARLINE_PULL.md` und `ops/h-drive-nearline/pull-critical-backups.ps1` produktiv. Der Windows Scheduled Task `KalliLab H Drive Nearline Pull` laeuft seit 2026-05-28 taeglich 05:30.
|
||||||
|
|
||||||
|
| Abgrenzung | Bewertung | Begruendung |
|
||||||
|
|---|---|---|
|
||||||
|
| **Nicht** als Ersatz fuer Hetzner-Off-site | bewusst | 3-2-1 ist mit Hetzner als einzigem Off-site erfuellt; H:/ reduziert nur lokale Restore-Abhaengigkeit |
|
||||||
|
| **Nicht** als zweites Borg-Repo am Unraid | bewusst | dauerhafte CIFS-Verbindung im Borg-Lauf verletzt Hard Rule aus `docs/STORAGE_LAYOUT.md` |
|
||||||
|
|
||||||
|
### Kapazitaets-Eintrag
|
||||||
|
|
||||||
|
| Bereich | Groesse | Belegt | Schwellwert | Bewertung |
|
||||||
|
|---|---:|---:|---:|---|
|
||||||
|
| H:/ (Windows-Arbeitsplatz, `Externe HDD`) | 8.0T | 3.91T belegt / 4.10T frei | Review wenn > 70 % | NTFS, `Healthy`; Pull-Ziel fuer Borg-Dumps und Gitea-Bundles |
|
||||||
|
|
||||||
|
### Naechste Schritte
|
||||||
|
|
||||||
|
- Task-Lauf quartalsweise gegen Reports unter `H:\kallilab-nearline-backups\_reports` pruefen.
|
||||||
|
- Review-Intervall: quartalsweise. Bei jeder grossen Strukturaenderung Freeze-Pull manuell ausloesen.
|
||||||
|
|
||||||
|
## Restore-Zeitziele
|
||||||
|
|
||||||
|
| Tier | Beispiel | Zielzeit | Status |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| Tier 0 | Repo, Secrets, Traefik, DNS | 2-4 h | Zielwert, per DR-Sanity-Check bestaetigen |
|
||||||
|
| Tier 1 | Gitea, Vaultwarden, Paperless, Immich | 4-8 h | Zielwert, einzelne Restore-Tests vorhanden |
|
||||||
|
| Tier 2 | Nextcloud, Mealie, Monitoring | < 24 h | Zielwert, Restore-Pfade dokumentiert |
|
||||||
|
| Tier 3 | Komfort-/Ops-Tools | Best effort / rebuildbar | Zielwert, keine harte SLA |
|
||||||
|
|
||||||
|
## Review-Log
|
||||||
|
|
||||||
|
| Datum | Befund | Entscheidung |
|
||||||
|
|---|---|---|
|
||||||
|
| 2026-05-26 | Cache 6 %, Array/User-Shares 33 %, lokale Backups 2.2G; keine validierte USV-Abschaltung | Capacity gruen; USV wird aktuell nicht angeschafft, Power-Loss-Risiko bewusst akzeptiert; zweites Off-site/Cold-Storage bewusst nicht umgesetzt |
|
||||||
|
| 2026-05-26 | H:/ als dauerhaft verbundenes Windows-Laufwerk evaluiert | als zweite lokale Nearline-Kopie und Freeze-Sicherung sinnvoll; nicht als Offsite-Ersatz und nicht als Borg-CIFS-Hard-Mount am Unraid |
|
||||||
|
| 2026-05-26 | H:/ Kapazitaet erfasst: 8.0T NTFS, 3.91T belegt, 4.10T frei, `Healthy` | genug Reserve fuer Nearline-Pull der kritischen Restore-Artefakte |
|
||||||
|
| 2026-05-27 | H:/ Pull-Workflow vorbereitet | SMB-Quelle `\\192.168.178.58\backups` erreichbar; PowerShell-Skript und Runbook erstellt |
|
||||||
|
| 2026-05-28 | H:/ Pull-Workflow produktiv | Windows Scheduled Task `KalliLab H Drive Nearline Pull` taeglich 05:30 aktiv |
|
||||||
|
| 2026-06-01 | H:/ Pull nach Redis-/Major-Cutover-Artefakten gehaertet | Borg-Dumps-Job kopiert nur kuratierte Pflichtdateien; manueller Kontrolllauf erzeugte Report `nearline-pull-2026-06-01-082553.md` |
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
# 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; fuer Bare-Metal-DR zusaetzlich Read-Only-PAT/Deploy-Key offline im DR-Kit |
|
||||||
|
| Operator-DR-Workstation | Gaming-PC mit aktuellem Repo-Clone, WSL2 + Borg-Client, SSH-Key fuer Hetzner Storage Box, Offline-Kopie Borg-Passphrase; Bestandteile siehe `docs/EXTERNAL_DEPENDENCIES.md` Abschnitt "DR-Workstation Bare-Metal-Kit" |
|
||||||
|
| 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 Offline-Hinterlegung vom Operator am 2026-05-26 bestaetigt |
|
||||||
|
| 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` (privat, Read-PAT/Deploy-Key noetig — siehe `docs/EXTERNAL_DEPENDENCIES.md` Abschnitt "DR-Workstation Bare-Metal-Kit")
|
||||||
|
- lokaler Bare-Clone auf der Operator-DR-Workstation (Standardweg)
|
||||||
|
- normaler lokaler Arbeits-Clone auf der Operator-DR-Workstation
|
||||||
|
|
||||||
|
Operativer Pfad fuer den Repo auf den frisch installierten Unraid-Host:
|
||||||
|
|
||||||
|
1. Operator-DR-Workstation holt den aktuellen Clone (lokaler Stand oder per `git clone` aus dem GitHub-Mirror mit dem offline gesicherten Read-PAT/Deploy-Key).
|
||||||
|
2. Kopie via USB, SMB oder `rsync ueber SSH/Tailscale` nach `/mnt/user/services/homelab-infra/` auf dem Unraid-Host.
|
||||||
|
3. Stand pruefen: `git -C /mnt/user/services/homelab-infra log --oneline -1` zeigt einen plausibel aktuellen Commit.
|
||||||
|
|
||||||
|
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`
|
||||||
|
- `homelab_smtp_password.txt`
|
||||||
|
- `n8n_encryption_key.txt`
|
||||||
|
- `monitoring_grafana_admin_password.txt`
|
||||||
|
- `monitoring_grafana_influxdb_token.txt`
|
||||||
|
- `influxdb3_admin_token.json`
|
||||||
|
- `filebrowser_admin_password.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`
|
||||||
|
|
||||||
|
Zusaetzlich rebuildbar (keine kritische Recovery-Quelle, koennen aus Provider-/App-UIs neu erzeugt werden):
|
||||||
|
|
||||||
|
- `GLANCE_IMMICH_API_KEY`, `GLANCE_ADGUARD_USERNAME`, `GLANCE_ADGUARD_PASSWORD`, `GLANCE_SPEEDTEST_API_KEY` fuer `glance` Community-/Live-Widgets
|
||||||
|
|
||||||
|
### 6.2.1 Restore-Quellen fuer Stack-ENV-Werte
|
||||||
|
|
||||||
|
Stack-ENV-Werte liegen **nicht im Repo** und **nicht als Datei-Secret** unter `/mnt/user/appdata/secrets/`. Sie sind nur an drei Stellen erreichbar; bei Recovery in dieser Reihenfolge pruefen:
|
||||||
|
|
||||||
|
1. **Komodo-Mongo-Dump** `komodo-mongo.archive.gz` unter `/mnt/user/backups/borg/dumps/latest/`. Solange Komodo selbst noch nicht laeuft, ist der Mongo-Dump die kanonische Quelle. Restore in eine Test-Mongo-Instanz, anschliessend Werte aus der `stack`-Collection lesen. **Niemals** Werte in andere Dokumente kopieren.
|
||||||
|
2. **Vaultwarden** Eintrag "Komodo Stack ENV / KalliLab CORE" (bzw. der entsprechende Eintrag pro Stack). Voraussetzung: Vaultwarden ist bereits restauriert (`docs/RESTORE_MATRIX.md`).
|
||||||
|
3. **Externe Operator-Notiz** (versiegelter Umschlag, Bankschliessfach, oder analoge Sicherung neben der Borg-Passphrase). Nur als Notfall-Quelle, wenn weder Komodo-Mongo noch Vaultwarden verfuegbar sind.
|
||||||
|
|
||||||
|
**Reihenfolge-Konsequenz fuer den Bootstrap-Pfad in Phase 4 (Stufe 4 weiter unten):**
|
||||||
|
|
||||||
|
- Vor dem Start von `apps/paperless/`, `apps/immich/`, `apps/mail-archiver/` und `ops/speedtest/` muessen die jeweiligen Stack-ENV-Werte in Komodo wieder hinterlegt sein.
|
||||||
|
- Wenn `komodo-mongo.archive.gz` frisch ist, koennen die Werte beim Komodo-Restart aus dem Dump zurueckgespielt werden, ohne dass jemand sie sieht.
|
||||||
|
- Wenn Vaultwarden vor Komodo restauriert wird (was hier nicht der Standardweg ist), kann auch von dort gelesen werden.
|
||||||
|
|
||||||
|
**Paperless ist die wichtigste bewusste Ausnahme:** `PAPERLESS_DBPASS` und `PAPERLESS_REDIS` sind seit der Hardening-Phase bewusst Stack-ENV (Paperless unterstuetzt `_FILE` fuer DB-Pass nicht). Ein Komodo-Mongo-Dump-Verlust ist daher fuer Paperless gleichbedeutend mit Re-Initialisierung der App-DB; in diesem Fall hilft nur ein Restore aus Vaultwarden oder externer Notiz.
|
||||||
|
|
||||||
|
**Regel:** Konkrete Werte werden **nirgendwo** im Repo, in Logs, in Doku-Kommentaren oder in ntfy-Meldungen wiedergegeben. Auch dieses Dokument haelt nur Variablennamen, Quellen und Reihenfolge fest, keine Werte.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 7.3 Borg-Extract ohne `borg-ui`-Container
|
||||||
|
|
||||||
|
Im Bare-Metal-Fall ist `borg-ui` selbst kalt. Der initiale Borg-Extract laeuft deshalb nicht ueber den Container, sondern wahlweise ueber:
|
||||||
|
|
||||||
|
1. **Operator-DR-Workstation** (Standardweg) - WSL2 + `borgbackup` extrahieren gezielt nach `/mnt/user/backups/restore-lab/...` oder per `rsync`/SMB auf den Unraid-Host.
|
||||||
|
2. **Native Docker-Variante auf Unraid** - `docker run --rm -e BORG_PASSPHRASE=... -v /mnt/user/backups/restore-lab:/restore -v ~/.ssh:/root/.ssh:ro borgbackup/borg:1.4 ...`.
|
||||||
|
|
||||||
|
Erst nach Stufe 5 Phase 4 ist `borg-ui` produktiv und uebernimmt den weiteren Betrieb. Die Borg-Passphrase wird interaktiv aus der Offline-Sicherung eingegeben, nicht in Skripte/Tickets kopiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Phase 4 - Bootstrap-Reihenfolge der Stacks
|
||||||
|
|
||||||
|
**Nie alle Stacks gleichzeitig starten.**
|
||||||
|
|
||||||
|
### Stufe 0 - Docker-Grundlage
|
||||||
|
|
||||||
|
Vor dem ersten `docker compose up` muss sichergestellt sein:
|
||||||
|
|
||||||
|
1. `docker info` antwortet ohne Fehler.
|
||||||
|
2. Externe Docker-Netze existieren. Wenn nicht vorhanden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network create --driver bridge frontend_net
|
||||||
|
docker network create --driver bridge --internal backend_net
|
||||||
|
docker network create --driver bridge monitoring_net
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Pfad `/mnt/user/appdata/traefik/dynamic/` enthaelt `middlewares.yml`, `tls.yml`, `dashboards.yml` (Sonderregel siehe Sektion 10). Ohne diese Dateien startet Traefik ohne Middleware-Definitionen und alle Authelia-geschuetzten Routen brechen still.
|
||||||
|
|
||||||
|
Erfolgskriterium: `docker network ls` zeigt `frontend_net`, `backend_net`, `monitoring_net`; Traefik-`dynamic/`-Dateien sind vorhanden und valide.
|
||||||
|
|
||||||
|
### Stufe 1 - Netz und Zugang
|
||||||
|
|
||||||
|
1. `traefik/`
|
||||||
|
2. `host-services/Adguard/`
|
||||||
|
|
||||||
|
> **Tailscale-Hinweis:** Tailscale laeuft als **natives Unraid-Plugin**
|
||||||
|
> (`tailscale.plg`, Interface `tailscale1`, State `/boot/config/plugins/tailscale/state`,
|
||||||
|
> im Flash-Backup gesichert) und ist der Subnet-Router fuer `192.168.178.0/24`.
|
||||||
|
> Es ist **kein** Compose-/Komodo-Stack mehr und kommt mit dem Host hoch — daher
|
||||||
|
> nicht in dieser Bootstrap-Liste. Der frueher hier gelistete Docker-Stack
|
||||||
|
> `host-services/tailscale/` (userspace-only, redundant) wurde am 2026-06-06
|
||||||
|
> entfernt (siehe `docs/NETWORK_INVENTORY.md`).
|
||||||
|
|
||||||
|
**LE-Rate-Limit-Vorsicht:** Wenn `/mnt/user/appdata/traefik/letsencrypt/acme.json` verloren oder unklar ist, zuerst gegen Let's Encrypt Staging ausstellen lassen (`--certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory`). Erst nach gruenem Smoke wieder auf Production-CA. Hintergrund: 50 Zertifikate pro Domain pro Woche reicht bei einem hektischen Wiederanlauf nicht, wenn man die Sub-Domains mehrfach hochzieht.
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
|
||||||
|
- Web-Einstieg funktioniert
|
||||||
|
- DNS/Resolver-Basis ist da
|
||||||
|
- Remote-Zugang ist wieder verfuegbar
|
||||||
|
|
||||||
|
### Stufe 2 - Gemeinsame Backends und Identity
|
||||||
|
|
||||||
|
4. `infra/postgresql17/` (PostgreSQL 18 runtime, historischer Stack-Name bleibt fuer Service-DNS stabil)
|
||||||
|
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 verfuegbar als App-Cache fuer Paperless (`infra/redis` ist historisch als "shared" angelegt, wird faktisch nur von Paperless genutzt)
|
||||||
|
- Git-Zugriff wiederhergestellt
|
||||||
|
|
||||||
|
### Stufe 3 - Deploy-System
|
||||||
|
|
||||||
|
8. `ops/komodo/` - **Kaltstart-Anker, kein Auto-Deploy**
|
||||||
|
|
||||||
|
Komodo wird in dieser Stufe bewusst **nicht** ueber Gitea-Webhook deployed. Der vollstaendige Bootstrap-Pfad ist in `docs/SERVICES_RECOVERY.md` Abschnitt "Komodo Bootstrap" als lineare Stufen A-F dokumentiert. Hier in der DR-Reihenfolge gilt der Einstiegspunkt:
|
||||||
|
|
||||||
|
- Recovery-Anker ist `ops/komodo/docker-compose.yml` aus dem Repo (lokaler Clone, GitHub-Mirror oder `homelab-infra.bundle`-Restore).
|
||||||
|
- Komodo-Stack-ENV-Werte (`KOMODO_*`) sind Stack-ENV-only und werden aus Vaultwarden oder externer Notiz wiederhergestellt (siehe `docs/SECRETS_MAP.md` Abschnitt "Stack-ENV-only Secrets - Restore-Wege").
|
||||||
|
- Erst nach erfolgreicher Validierung der Komodo-Web-UI und Periphery-Verbindung werden in den naechsten Stufen die produktiven Stacks aufgenommen.
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
|
||||||
|
- Komodo Core und Mongo laufen
|
||||||
|
- Periphery verbindet sich wieder
|
||||||
|
- Stacks koennen wieder aus Git konsumiert werden
|
||||||
|
|
||||||
|
**Wichtige Stolperfallen in Stufe 3:**
|
||||||
|
|
||||||
|
- **KOMODO_*-Werte sind nicht aus dem eigenen Mongo-Dump rekonstruierbar.** Pflichtquelle im Bare-Metal: offline gesicherte Operator-Notiz (Status 2026-06-03: noch nicht angelegt, siehe `docs/EXTERNAL_DEPENDENCIES.md` und Audit-Restliste). Vaultwarden ist erst in Stufe 4 verfuegbar.
|
||||||
|
- **Mongo-Datadir und `komodo_mongo_password.txt` muessen aus demselben Snapshot stammen.** Bei Mismatch akzeptiert Mongo den Login nicht und der Stack startet nicht. Auswege: entweder die zur Datadir passende Secret-Datei aus dem gleichen Borg-Stand restaurieren, oder Datadir leeren, neu initialisieren und Daten via `mongorestore --archive --gzip` aus `komodo-mongo.archive.gz` einspielen (Drill belegt 2026-06-03).
|
||||||
|
- **`extra_hosts: git.kaleschke.info:192.168.178.58`** in `ops/komodo/docker-compose.yml` ist hardgecodet. Bei geaenderter Host-LAN-IP auf der Recovery-Hardware den Wert vor `compose up` anpassen, sonst kann Komodo-Core das interne Gitea nicht erreichen.
|
||||||
|
- **Stack-ENV-Werte fuer Apps in Stufe 4** (Paperless/Immich/Mailarchiver/Speedtest) sind in Stufe 3 noch leer. Zwei Wege: (a) optionaler `mongorestore` aus `komodo-mongo.archive.gz` direkt nach Komodo-Start, dann sind alle Stack-ENVs zurueck; (b) Werte manuell in der Komodo-UI eintragen, sobald Vaultwarden in Stufe 4 verfuegbar ist (was Paperless/Immich/Mailarchiver hinter Vaultwarden zwingt, nicht parallel).
|
||||||
|
|
||||||
|
### 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
|
||||||
|
- Pro App: `docker logs <container>` zeigt keine `password authentication failed`-, `FATAL: role does not exist`- oder `Connection refused`-Eintraege (verifiziert, dass Stack-ENV-Werte und DB-Rollen passen)
|
||||||
|
|
||||||
|
### 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 config validate` 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.
|
||||||
|
|
||||||
|
Beim PostgreSQL-Restore beachten:
|
||||||
|
|
||||||
|
- vor einem produktiven Dump `occ maintenance:mode --on` setzen
|
||||||
|
- die produktive DB-Rolle kann von `POSTGRES_USER` abweichen; aktuell nutzt Nextcloud laut `config.php` die Rolle `oc_admin`
|
||||||
|
- nach Restore und erfolgreichem `occ status` den Wartungsmodus mit `occ maintenance:mode --off` beenden
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### Redis 8 Restore / Rollback
|
||||||
|
|
||||||
|
Redis-Instanzen laufen auf der 8.x-Schiene. Vor Major-Upgrades wird `redis-cli SAVE` ausgefuehrt und der jeweilige Datenpfad kopiert.
|
||||||
|
|
||||||
|
Aktive Pfade und Besonderheiten:
|
||||||
|
|
||||||
|
- Shared Redis: `/mnt/user/appdata/redis`, Passwort aus `redis_password.txt`, AOF aktiv.
|
||||||
|
- Nextcloud Redis: `/mnt/user/appdata/nextcloud/redis`, ohne Redis-Passwort, Snapshot-Persistenz.
|
||||||
|
- Immich Redis: cache/queue-only ohne bind-mounted Datenpfad; Restore-Wahrheit ist Immich Postgres + Foto-Dateien, nicht Redis.
|
||||||
|
|
||||||
|
Rollback:
|
||||||
|
|
||||||
|
1. Abhaengige App stoppen.
|
||||||
|
2. Redis stoppen.
|
||||||
|
3. Compose auf das vorherige Redis-7.4-Image zuruecksetzen.
|
||||||
|
4. Bei Shared/Nextcloud den vor dem Cutover kopierten Datenpfad zurueckkopieren.
|
||||||
|
5. Redis und App starten, `redis-cli INFO server` und App-Smoke pruefen.
|
||||||
|
|
||||||
|
### PostgreSQL 18 Major-Upgrade / Rollback
|
||||||
|
|
||||||
|
Produktive PostgreSQL-18-Cluster verwenden das Docker-Image-Layout mit Host-Mount auf `/var/lib/postgresql` und `PGDATA=/var/lib/postgresql/18/docker`.
|
||||||
|
|
||||||
|
Aktive Datenpfade:
|
||||||
|
|
||||||
|
- Shared PostgreSQL: `/mnt/user/appdata/postgresql18`
|
||||||
|
- Mealie PostgreSQL: `/mnt/user/appdata/mealie/postgres18`
|
||||||
|
- Nextcloud PostgreSQL: `/mnt/user/appdata/nextcloud/postgres18`
|
||||||
|
|
||||||
|
Rollback-Altstaende wurden nach Burn-in am 2026-06-02 reversibel archiviert:
|
||||||
|
|
||||||
|
- Shared PostgreSQL 17: `/mnt/user/appdata/_archive/pg18-immich-rollback-volumes-20260602/postgresql17`
|
||||||
|
- Mealie PostgreSQL 17: `/mnt/user/appdata/_archive/pg18-immich-rollback-volumes-20260602/mealie-postgres17`
|
||||||
|
- Nextcloud PostgreSQL 17: `/mnt/user/appdata/_archive/pg18-immich-rollback-volumes-20260602/nextcloud-postgres17`
|
||||||
|
|
||||||
|
Restore-Reihenfolge fuer den Shared-Cluster:
|
||||||
|
|
||||||
|
1. Frischen PostgreSQL-18-Cluster starten.
|
||||||
|
2. Globals aus `pg_dumpall --globals-only` einspielen.
|
||||||
|
3. Den bekannten Bootstrap-Konflikt fuer `CREATE ROLE mailarchiver;` gezielt tolerieren bzw. herausfiltern, danach `ALTER ROLE mailarchiver ...` dennoch einspielen.
|
||||||
|
4. Datenbanken anlegen und Custom-Format-Dumps mit `pg_restore` einspielen.
|
||||||
|
5. Restore-Logs auf echte `ERROR`, `FATAL` und `PANIC` pruefen.
|
||||||
|
|
||||||
|
Immich ist bewusst nicht Teil dieses PostgreSQL-18-Laufs: Die produktive DB bleibt auf PostgreSQL 14 und nutzt das Immich-Postgres-Image mit VectorChord/pgvector. VectorChord-Backups brauchen zum Restore ein Image mit VectorChord; der alte pgvecto.rs-Datenpfad ist als Rollback-Altstand unter `/mnt/user/appdata/_archive/pg18-immich-rollback-volumes-20260602/immich-postgres-pgvecto-rs` archiviert.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### Windows-Workstation `baerchen`
|
||||||
|
|
||||||
|
`baerchen` ist die Operator-Workstation und haelt den lokalen Clone unter
|
||||||
|
`G:\Gitea_Clone\homelab-infra`. Fuer einen schnellen Windows-Bare-Metal-Restore
|
||||||
|
existiert ein Veeam-Agent-Image-Workflow.
|
||||||
|
|
||||||
|
Wichtige Pfade und Artefakte:
|
||||||
|
|
||||||
|
- Runbook: `ops/windows-reinstall/docs/windows-image-backup-baseline.md`
|
||||||
|
- Backup-Ziel: `\\kallilabcore\backups\windows-images\baerchen`
|
||||||
|
- Host-Pfad: `/mnt/user/backups/windows-images/baerchen/`
|
||||||
|
- Recovery-Medium: USB-Stick `VEEAMRE`, beschriftet
|
||||||
|
`baerchen Veeam Recovery - 2026-06-05`
|
||||||
|
- Veeam Job: `baerchen-c-image`
|
||||||
|
- Veeam Storage Encryption: erster Full-Lauf 2026-06-05 laut Job-Log
|
||||||
|
unverschluesselt (`StorageEncryptionEnabled=False`); falls spaeter aktiviert,
|
||||||
|
Passwort in Vaultwarden Secure Note `Veeam baerchen backup encryption password`
|
||||||
|
sichern
|
||||||
|
|
||||||
|
Restore-Kurzpfad:
|
||||||
|
|
||||||
|
1. Von `VEEAMRE` booten.
|
||||||
|
2. SMB-Ziel `\\kallilabcore\backups\windows-images\baerchen` oeffnen.
|
||||||
|
3. Mit bestehendem SMB-User `micha` authentifizieren.
|
||||||
|
4. Restore Point auswaehlen.
|
||||||
|
5. Falls der Restore Point verschluesselt ist: Veeam-Encryption-Passwort aus
|
||||||
|
Vaultwarden eingeben.
|
||||||
|
6. Bare-Metal-Restore nur auf die Windows-Systemdisk ausfuehren.
|
||||||
|
|
||||||
|
BitLocker ist am 2026-06-05 bewusst noch nicht aktiv. Falls BitLocker spaeter
|
||||||
|
aktiviert wird, muss der Recovery-Key vor dem naechsten Restore-Drill in
|
||||||
|
Vaultwarden, unter `D:\30_Finanzen\BitLocker-RecoveryKey-baerchen-<DATUM>.txt`
|
||||||
|
und physisch ausserhalb des Rechners abgelegt sein.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Offene Vorbereitungs-To-dos
|
||||||
|
|
||||||
|
- Unraid-USB-/Flash-Backup regelmaessig ueber `unraid-flash-config.tar.gz` und optional Unraid Connect pruefen
|
||||||
|
- Borg-Passphrase ist laut Operator-Bestaetigung vom 2026-05-26 extern/offline hinterlegt; bei Reviews nur Existenz/Lesbarkeit der Offline-Kopie pruefen, nie den Wert dokumentieren
|
||||||
|
- 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
|
||||||
|
- `baerchen` Recovery-USB-Boot-/SMB-Test nach erfolgreichem erstem Full-Lauf
|
||||||
|
verifizieren
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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,504 @@
|
|||||||
|
# DR Tabletop Drill - 2026-06-03
|
||||||
|
|
||||||
|
Trockenlauf gegen `docs/DISASTER_RECOVERY.md` Phase 0 bis 5 plus referenzierte
|
||||||
|
Runbooks (`SERVICES_RECOVERY.md`, `RESTORE_MATRIX.md`, `SECRETS_MAP.md`,
|
||||||
|
`RESTORE_HANDBOOK.md`, `EXTERNAL_DEPENDENCIES.md`).
|
||||||
|
|
||||||
|
Szenario: Bare-Metal-Ausfall. Unraid-Host und alle lokalen Festplatten sind
|
||||||
|
weg. Operator hat: Laptop, Hetzner-Account, Vaultwarden-Export, Repo-Doku.
|
||||||
|
Soft-Recovery (Host laeuft, Appdata futsch) ist eine Teilmenge dieser
|
||||||
|
Findings.
|
||||||
|
|
||||||
|
Methode: kalter Lesetest. Kein Container gestartet, keine Skripte
|
||||||
|
ausgefuehrt. Jeder Befund ist mit Repo-Datei und Zeile belegt. Spekulative
|
||||||
|
"vielleicht unklar"-Befunde sind weggelassen.
|
||||||
|
|
||||||
|
Severity:
|
||||||
|
|
||||||
|
- **CRITICAL** - blockiert Wiederanlauf, ohne Workaround nicht loesbar
|
||||||
|
- **HIGH** - blockiert eine Phase, Workaround moeglich aber undokumentiert
|
||||||
|
- **MED** - kostet Zeit oder fuehrt zu vermeidbarem Fehler
|
||||||
|
- **LOW** - Konsistenz / Stil
|
||||||
|
|
||||||
|
## Zusammenfassung
|
||||||
|
|
||||||
|
| ID | Phase | Severity | Thema |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P0-1 | 0 | HIGH | Brueckenpfad Windows-Clone -> frischer Unraid-Host fehlt |
|
||||||
|
| P0-2 | 0 | HIGH | GitHub-Mirror-Zugang im DR ist nicht eigenstaendig dokumentiert |
|
||||||
|
| P1-1 | 1 | CRITICAL | Unraid-Flash-Restore: kein dokumentierter Extract-Pfad ohne laufenden Host |
|
||||||
|
| P1-2 | 1 | MED | Unraid-OS-Flash-Restore-Test laut Matrix nie real getestet |
|
||||||
|
| P2-1 | 2 | HIGH | KOMODO_* externe Operator-Notiz ist Pflichtquelle, Existenz nicht verifizierbar |
|
||||||
|
| P2-2 | 2 | HIGH | DR.md Phase 4 vs. SERVICES_RECOVERY.md Bootstrap-Reihenfolge widerspruechlich |
|
||||||
|
| P2-3 | 2 | MED | `homelab_smtp_password.txt` fehlt in DR.md Phase 2.6.1 |
|
||||||
|
| P2-4 | 2 | MED | `n8n_encryption_key.txt` fehlt in DR.md Phase 2.6.1 |
|
||||||
|
| P2-5 | 2 | LOW | Monitoring-/Filebrowser-Secrets fehlen in DR.md Phase 2.6.1 |
|
||||||
|
| P3-1 | 3 | HIGH | Borg-Client ohne `borg-ui`-Container ist nicht dokumentiert |
|
||||||
|
| P3-2 | 3 | HIGH | Borg-Passphrase-Bootstrap aus Offline-Sicherung nicht als expliziter Schritt |
|
||||||
|
| P3-3 | 3 | MED | Hetzner-Maintenance-Key aus Vaultwarden ist Henne-Ei im Bare-Metal |
|
||||||
|
| P4-1 | 4 | HIGH | Externe Docker-Netze in DR.md Phase 4 Stufe 1 nicht erwaehnt |
|
||||||
|
| P4-2 | 4 | HIGH | Cloudflare-LE-Rate-Limit-Risiko bei verlorenem `letsencrypt`-State |
|
||||||
|
| P4-3 | 4 | MED | `traefik/dynamic/*` als Phase-4-Pre-Check fehlt in der Reihenfolge |
|
||||||
|
| P4-4 | 4 | HIGH | Authelia "frische Postgres ohne Dump"-Pfad nicht beschrieben |
|
||||||
|
| P4-5 | 4 | LOW | Gitea in Stufe 2 hinter Postgres ist faktisch nicht noetig (SQLite) |
|
||||||
|
| P4-6 | 4 | HIGH | Komodo-Mongo Passwort-Lockout-Risiko bei restauriertem Datadir |
|
||||||
|
| P4-7 | 4 | MED | Komodo `extra_hosts` mit hardgecodeter LAN-IP bricht bei IP-Wechsel |
|
||||||
|
| P4-8 | 4 | HIGH | Stack-ENV-Wiederherstellung in Komodo praktisch nur manueller UI-Eintrag |
|
||||||
|
| P5-1 | 5 | LOW | Smoke-Tests in Phase 5 weniger streng als RESTORE_MATRIX |
|
||||||
|
| P5-2 | 5 | MED | Kein Verifikationspunkt fuer App-zu-DB-Verbindung nach Stack-ENV-Restore |
|
||||||
|
| X-1 | uebergreifend | HIGH | Nextcloud-Restore-Skript ist da, aber noch nie real ausgefuehrt |
|
||||||
|
|
||||||
|
## Phase 0 - Repo-Zugang
|
||||||
|
|
||||||
|
### P0-1 (HIGH) - Brueckenpfad Windows-Clone -> frischer Unraid fehlt
|
||||||
|
|
||||||
|
`docs/DISASTER_RECOVERY.md:88-93` listet als Repo-Quellen: GitHub-Mirror,
|
||||||
|
lokaler Bare-Clone, lokaler Arbeits-Clone. `SERVICES_RECOVERY.md:67-68`
|
||||||
|
nennt den lokalen Operator-Clone unter `G:\Gitea_Clone\homelab-infra\` als
|
||||||
|
Vorzug.
|
||||||
|
|
||||||
|
Luecke: der Pfad "wie kommt der Windows-Clone auf einen frisch installierten
|
||||||
|
Unraid-Host" ist nicht beschrieben. Implizit: SMB-Share, USB-Stick, scp ueber
|
||||||
|
LAN. Aber auf einem frisch aufgesetzten Unraid existiert noch keine
|
||||||
|
funktionierende SMB-Konfiguration; SSH-Key vom Operator-PC ist nicht
|
||||||
|
vorbereitet.
|
||||||
|
|
||||||
|
Vorschlag: Zwei Saetze in `DISASTER_RECOVERY.md` Phase 0 ergaenzen, wie der
|
||||||
|
Operator-Clone konkret zum Host kommt (USB-Stick + `mkdir -p
|
||||||
|
/mnt/user/services/homelab-infra && rsync -a` aus Operator-Windows-PC, oder
|
||||||
|
direkt vom GitHub-Mirror per `git clone https://github.com/...` auf dem
|
||||||
|
Unraid-Host).
|
||||||
|
|
||||||
|
### P0-2 (HIGH) - GitHub-Mirror-Zugang im DR
|
||||||
|
|
||||||
|
`SECRETS_MAP.md:42` sagt, der GitHub-Push-Mirror-PAT liegt in den
|
||||||
|
Gitea-Mirror-Settings persistent unter `/mnt/user/services/gitea/data`.
|
||||||
|
`EXTERNAL_DEPENDENCIES.md:18` nennt den Mirror als `michaelkaleschke-spec/
|
||||||
|
homelab-infra` und betont "privater" Push-Mirror.
|
||||||
|
|
||||||
|
Luecke: Wenn der Mirror **privat** ist, scheitert ein anonymer `git clone`
|
||||||
|
im DR-Bootstrap. Es gibt keine dokumentierte Notfall-Quelle fuer einen
|
||||||
|
Read-PAT/SSH-Key, der lokal beim Operator (nicht in Gitea, nicht im Repo)
|
||||||
|
verfuegbar ist.
|
||||||
|
|
||||||
|
Vorschlag in `EXTERNAL_DEPENDENCIES.md`: entweder explizit dokumentieren,
|
||||||
|
dass der Mirror lesend `Public` ist (DR-fit), oder einen Read-PAT in der
|
||||||
|
Vaultwarden-/Offline-Notiz neben der Borg-Passphrase als Bootstrap-Voraussetzung
|
||||||
|
benennen.
|
||||||
|
|
||||||
|
## Phase 1 - Unraid und Shares
|
||||||
|
|
||||||
|
### P1-1 (CRITICAL) - Unraid-Flash-Restore ohne laufenden Host
|
||||||
|
|
||||||
|
`docs/DISASTER_RECOVERY.md:107` sagt: "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`."
|
||||||
|
|
||||||
|
Henne-Ei: der Pfad ist auf den verlorenen Shares oder auf Hetzner. Hetzner-
|
||||||
|
Zugriff braucht einen funktionierenden Linux-Host mit Borg-Client und
|
||||||
|
Passphrase. Im Bare-Metal-Fall ist genau das nicht da. RESTORE_MATRIX.md
|
||||||
|
Tier 1 Zeile `Unraid OS Flash` (`docs/RESTORE_MATRIX.md:29`) sagt nur "Unraid
|
||||||
|
USB Flash Creator / neuer Boot-Stick" - das beschreibt die Stick-Erzeugung,
|
||||||
|
nicht den Extract des Borg-Artefakts.
|
||||||
|
|
||||||
|
Operativ: Operator braucht einen Laptop mit Borg-Client + Passphrase +
|
||||||
|
SSH-Key fuer die Hetzner-Storage-Box. Das ist eine **separat zu pflegende
|
||||||
|
Operator-Workstation-Voraussetzung** und ist in keinem Repo-Dokument als
|
||||||
|
DR-Vorbedingung gelistet.
|
||||||
|
|
||||||
|
Vorschlag: In `EXTERNAL_DEPENDENCIES.md` oder `DISASTER_RECOVERY.md`
|
||||||
|
Abschnitt 3 als Pflichtposten aufnehmen: "Operator-Laptop mit installiertem
|
||||||
|
Borg-Client, SSH-Key fuer Hetzner und Zugriff auf die offline gesicherte
|
||||||
|
Passphrase". Inklusive Test, dass der Operator den Extract tatsaechlich
|
||||||
|
durchfuehren kann.
|
||||||
|
|
||||||
|
### P1-2 (MED) - Unraid-OS-Flash-Restore-Test nie gelaufen
|
||||||
|
|
||||||
|
`docs/RESTORE_MATRIX.md:140` Spalte "Letzter Restore-Test" fuer Unraid OS
|
||||||
|
Flash: `-` (kein Test). Das ist die Grundlage fuer Phase 1 und ist nie als
|
||||||
|
Smoke verifiziert. Empfehlung: einmaliger Test, der die Tar-Archiv-Struktur
|
||||||
|
gegen die erwarteten Flash-Pfade prueft (kein echter Boot-Test noetig).
|
||||||
|
|
||||||
|
## Phase 2 - Secrets und Stack-ENV
|
||||||
|
|
||||||
|
### P2-1 (HIGH) - KOMODO_* externe Operator-Notiz als Pflichtquelle
|
||||||
|
|
||||||
|
`docs/SECRETS_MAP.md:132,138-143` macht den Komodo-Sonderfall klar: die
|
||||||
|
KOMODO_*-Secrets sind aus dem eigenen Mongo-Dump nicht rekonstruierbar,
|
||||||
|
solange Komodo nicht laeuft. Quellen: Vaultwarden ODER externe Notiz.
|
||||||
|
|
||||||
|
Im Bare-Metal-Fall ist Vaultwarden in DR.md Phase 4 Stufe 4, Komodo in
|
||||||
|
Phase 4 Stufe 3. Damit ist die **externe Operator-Notiz** die einzige
|
||||||
|
Pflichtquelle in der Reihenfolge.
|
||||||
|
|
||||||
|
Luecke: ob diese Notiz wirklich existiert und die 5 Werte
|
||||||
|
(KOMODO_SECRET_KEY, KOMODO_WEBHOOK_SECRET, KOMODO_JWT_SECRET,
|
||||||
|
KOMODO_MONGO_PASSWORD, KOMODO_PERIPHERY_PASSKEY) enthaelt, ist in keinem
|
||||||
|
Repo-Dokument bestaetigt. Die Borg-Passphrase ist als "Operator-Bestaetigung
|
||||||
|
2026-05-26" dokumentiert; eine analoge Bestaetigung fuer die KOMODO_*-Notiz
|
||||||
|
fehlt.
|
||||||
|
|
||||||
|
Vorschlag: gleiche Form wie Borg-Passphrase - eine Zeile in
|
||||||
|
`EXTERNAL_DEPENDENCIES.md` "Komodo-Stack-ENV-Notiz offline gesichert,
|
||||||
|
Operator-Bestaetigung YYYY-MM-DD".
|
||||||
|
|
||||||
|
### P2-2 (HIGH) - Reihenfolgen-Inkonsistenz DR vs. SERVICES_RECOVERY
|
||||||
|
|
||||||
|
`docs/SERVICES_RECOVERY.md:102` (Stufe C, Komodo-Bootstrap): "Vaultwarden
|
||||||
|
(sobald restauriert), externe Operator-Notiz, oder Komodo-Mongo-Dump (nur
|
||||||
|
wenn Mongo separat bereits gestartet ...)".
|
||||||
|
|
||||||
|
`docs/DISASTER_RECOVERY.md:247-301` (Phase 4): Stufe 3 = Komodo, Stufe 4 =
|
||||||
|
Vaultwarden.
|
||||||
|
|
||||||
|
Wenn ein Leser sich an DR.md Phase 4 haelt, ist Vaultwarden nach Komodo
|
||||||
|
fertig. Aber SERVICES_RECOVERY.md Stufe C setzt Vaultwarden als optionale
|
||||||
|
Vorab-Quelle voraus. Ohne externe Notiz heisst das praktisch: Komodo kann
|
||||||
|
nicht starten. Die Konsequenz steht nirgendwo explizit in DR.md.
|
||||||
|
|
||||||
|
Vorschlag: In `DISASTER_RECOVERY.md` Phase 4 Stufe 3 einen Hinweisblock
|
||||||
|
ergaenzen: "KOMODO_*-Werte muessen vor Stufe 3 aus externer Notiz oder
|
||||||
|
einer in Stufe 2 voraus gezogenen Vaultwarden-Instanz vorliegen. Default-
|
||||||
|
Pfad: externe Notiz."
|
||||||
|
|
||||||
|
### P2-3 (MED) - `homelab_smtp_password.txt` fehlt in DR.md 6.1
|
||||||
|
|
||||||
|
`docs/SECRETS_MAP.md:20` listet `/mnt/user/appdata/secrets/
|
||||||
|
homelab_smtp_password.txt` fuer Vaultwarden-SMTP. In `DISASTER_RECOVERY.md`
|
||||||
|
Abschnitt 6.1 (`docs/DISASTER_RECOVERY.md:136-151`) ist sie nicht
|
||||||
|
aufgefuehrt. Vaultwarden startet ohne, kann aber keine Einladungs-/
|
||||||
|
Benachrichtigungs-Mails versenden. Klein, aber unsichtbarer Folgefehler im
|
||||||
|
Familien-Onboarding-Pfad.
|
||||||
|
|
||||||
|
### P2-4 (MED) - `n8n_encryption_key.txt` fehlt in DR.md 6.1
|
||||||
|
|
||||||
|
`docs/SECRETS_MAP.md:58` listet `/mnt/user/appdata/secrets/
|
||||||
|
n8n_encryption_key.txt`. In DR.md 6.1 fehlt sie komplett.
|
||||||
|
`SECRETS_MAP.md:135` macht die Folgen explizit: "Bei Verlust aller
|
||||||
|
Quellen: n8n startet, aber alle gespeicherten Credentials sind unbrauchbar".
|
||||||
|
Da n8n den GMX-Mail-Workflow fuer das Gitea-`Micha/mails`-Repo betreibt,
|
||||||
|
ist das ein direkter Workflow-Ausfall.
|
||||||
|
|
||||||
|
### P2-5 (LOW) - Monitoring-/Filebrowser-Secrets fehlen in DR.md 6.1
|
||||||
|
|
||||||
|
`docs/SECRETS_MAP.md:53-55`: `influxdb3_admin_token.json`,
|
||||||
|
`monitoring_grafana_admin_password.txt`,
|
||||||
|
`monitoring_grafana_influxdb_token.txt` sowie
|
||||||
|
`filebrowser_admin_password.txt` sind nicht in DR.md 6.1. Tier-3-Apps,
|
||||||
|
Folge ist nur ein UI-Initialisierungs-Schritt nach Wiederanlauf. Keine
|
||||||
|
Critical-Konsequenz, aber Inkonsistenz.
|
||||||
|
|
||||||
|
## Phase 3 - Borg-Extract
|
||||||
|
|
||||||
|
### P3-1 (HIGH) - Borg-Client ohne `borg-ui`-Container
|
||||||
|
|
||||||
|
`docs/RESTORE_HANDBOOK.md:30-33` sagt explizit: "Borg-Zugriff laeuft ueber
|
||||||
|
den vorhandenen `borg-ui`-Container".
|
||||||
|
|
||||||
|
Im Bare-Metal-Fall ist `borg-ui` selbst kalt (Tier 3, DR.md Phase 4 Stufe 5).
|
||||||
|
Es gibt keinen dokumentierten Pfad, wie der erste Borg-Extract ohne diesen
|
||||||
|
Container laeuft. Implizite Optionen: nativer Borg auf Unraid (Plugin),
|
||||||
|
`docker run --rm borgbackup/borg`, oder Operator-Laptop. Keine davon ist
|
||||||
|
benannt.
|
||||||
|
|
||||||
|
Vorschlag: In `RESTORE_HANDBOOK.md` Abschnitt 2 einen "Bare-Metal-Vorlauf"
|
||||||
|
ergaenzen, der den initialen Borg-Extract ohne borg-ui-Container
|
||||||
|
beschreibt - z. B. `docker run --rm -v
|
||||||
|
/mnt/user/backups/restore-lab:/restore borgbackup/borg ...`.
|
||||||
|
|
||||||
|
### P3-2 (HIGH) - Borg-Passphrase-Bootstrap nicht als expliziter Schritt
|
||||||
|
|
||||||
|
`docs/DISASTER_RECOVERY.md:68`: "Host-Secret-Datei vorhanden und fuer
|
||||||
|
Borg-Zugriff verifiziert; externe Offline-Hinterlegung vom Operator am
|
||||||
|
2026-05-26 bestaetigt."
|
||||||
|
|
||||||
|
Praktisch heisst das: im Bare-Metal-Fall liest der Operator die Passphrase
|
||||||
|
aus einem analogen Medium und tippt sie in den Borg-Client. Das ist ein
|
||||||
|
**Bootstrap-Schritt**, der nicht als Schritt dokumentiert ist. Er steckt
|
||||||
|
implizit in "extern bestaetigt".
|
||||||
|
|
||||||
|
Vorschlag: Ein nummerierter Bullet in `DISASTER_RECOVERY.md` Phase 3 ("Wenn
|
||||||
|
echte Daten aus Borg benoetigt werden"): "Schritt 1: Borg-Passphrase aus
|
||||||
|
Offline-Sicherung beschaffen. Wert wird nicht in Skripte oder Tickets
|
||||||
|
kopiert; nur in den interaktiven Borg-Aufruf eingegeben."
|
||||||
|
|
||||||
|
### P3-3 (MED) - Hetzner-Maintenance-Key im Bare-Metal
|
||||||
|
|
||||||
|
`docs/EXTERNAL_DEPENDENCIES.md:17`: "Maintenance-Key liegt in Vaultwarden".
|
||||||
|
|
||||||
|
Im Bare-Metal-Bootstrap ist Vaultwarden Phase 4 Stufe 4. Damit ist der Key
|
||||||
|
fuer die initiale Phase-3-Hetzner-Verbindung nicht zugaenglich. Implizit
|
||||||
|
muss er ebenfalls offline gesichert sein (analog Borg-Passphrase).
|
||||||
|
|
||||||
|
Vorschlag: gleiche Form wie Borg-Passphrase - eine Operator-Bestaetigung
|
||||||
|
in `EXTERNAL_DEPENDENCIES.md`, dass der Hetzner-SSH-Key auch ausserhalb von
|
||||||
|
Vaultwarden offline verfuegbar ist. Sonst ist die "Vaultwarden"-Aussage
|
||||||
|
fuer Bare-Metal eine Falle.
|
||||||
|
|
||||||
|
## Phase 4 - Bootstrap-Reihenfolge
|
||||||
|
|
||||||
|
### P4-1 (HIGH) - Externe Docker-Netze in DR.md Phase 4 Stufe 1 nicht erwaehnt
|
||||||
|
|
||||||
|
`docs/SERVICES_RECOVERY.md:82-84` Stufe A schreibt explizit: "Externe
|
||||||
|
Docker-Netze existieren oder werden erzeugt (`frontend_net`, `backend_net`).
|
||||||
|
Wenn nicht vorhanden: `docker network create --driver bridge frontend_net`
|
||||||
|
bzw. `... --internal backend_net`."
|
||||||
|
|
||||||
|
`docs/DISASTER_RECOVERY.md:252-260` Phase 4 Stufe 1 nennt nur Traefik,
|
||||||
|
AdGuard, Tailscale. Kein Hinweis auf externe Netze.
|
||||||
|
|
||||||
|
`traefik/docker-compose.yml:70-76` deklariert `frontend_net`, `backend_net`,
|
||||||
|
`monitoring_net` als `external: true`. Ohne vorab erstellte Netze scheitert
|
||||||
|
der erste `docker compose up` mit "network frontend_net not found".
|
||||||
|
|
||||||
|
Vorschlag: In `DISASTER_RECOVERY.md` Phase 4 vor Stufe 1 einen Vorlauf
|
||||||
|
"Stufe 0 - Docker-Grundlage" einfuegen, der die Netzwerk-Erzeugung wie in
|
||||||
|
`SERVICES_RECOVERY.md` Stufe A explizit listet.
|
||||||
|
|
||||||
|
### P4-2 (HIGH) - Cloudflare-LE-Rate-Limit-Risiko
|
||||||
|
|
||||||
|
`docs/RESTORE_MATRIX.md:30` markiert `letsencrypt` korrekt als
|
||||||
|
Restore-relevant. `docs/DISASTER_RECOVERY.md:240` listet
|
||||||
|
`/mnt/user/appdata/traefik/letsencrypt` ebenfalls als kritischen
|
||||||
|
Borg-Restore-Pfad.
|
||||||
|
|
||||||
|
Luecke: kein Hinweis auf den Praxisfall "LE-State verloren, frischer
|
||||||
|
Acme-Run". Let's Encrypt hat ein Rate-Limit von 50 Zertifikaten/Domain/
|
||||||
|
Woche und 5 Duplicate-Zertifikate/Woche. Bei einer Multi-Sub-Domain-
|
||||||
|
Konstellation wie `*.kaleschke.info` (15+ Hostnames) ist das beim
|
||||||
|
hektischen DR-Bootstrap erreichbar.
|
||||||
|
|
||||||
|
Vorschlag: In `DISASTER_RECOVERY.md` Phase 4 Stufe 1 einen Hinweis: "Bei
|
||||||
|
verlorenem oder unklarem `acme.json` zuerst gegen
|
||||||
|
`acme-staging-v02.api.letsencrypt.org` ausstellen lassen, erst nach
|
||||||
|
gruenem Smoke auf Production-CA umschalten." Ist eine Praesentations-
|
||||||
|
Aenderung in den Compose-Args, kein neuer Code.
|
||||||
|
|
||||||
|
### P4-3 (MED) - `traefik/dynamic/*` als Pre-Check fehlt
|
||||||
|
|
||||||
|
`docs/DISASTER_RECOVERY.md:357-365` Sektion 10 beschreibt die manuelle
|
||||||
|
Sonderregel fuer `traefik/dynamic/*`. Korrekt.
|
||||||
|
|
||||||
|
`docs/DISASTER_RECOVERY.md:252-260` Phase 4 Stufe 1 verweist nicht auf
|
||||||
|
diese Sonderregel. Wer der Reihenfolge folgt und Sektion 10 nicht liest,
|
||||||
|
startet Traefik ohne Middlewares - alle 2FA-Routen brechen still.
|
||||||
|
|
||||||
|
Vorschlag: Cross-Reference in Phase 4 Stufe 1: "Vor `docker compose up
|
||||||
|
traefik` pruefen, dass `/mnt/user/appdata/traefik/dynamic/middlewares.yml`,
|
||||||
|
`tls.yml`, `dashboards.yml` vorhanden sind (Sonderregel Sektion 10)."
|
||||||
|
|
||||||
|
### P4-4 (HIGH) - Authelia "frische Postgres ohne Dump"-Pfad fehlt
|
||||||
|
|
||||||
|
`docs/DISASTER_RECOVERY.md:267-275` Phase 4 Stufe 2 startet Postgres und
|
||||||
|
Authelia. Authelia erwartet eine Rolle `authelia` mit dem Passwort aus
|
||||||
|
`authelia_postgres_password.txt`. Im Restore-Pfad mit `pg_dumpall --globals-
|
||||||
|
only` ist die Rolle abgedeckt.
|
||||||
|
|
||||||
|
Bei einem **fresh-start** (keine alten Daten, nur Container hochfahren) ist
|
||||||
|
die Rolle nicht da. Postgres-Image legt sie nicht automatisch an. Authelia
|
||||||
|
schlaegt mit "FATAL: role authelia does not exist" fehl.
|
||||||
|
|
||||||
|
Luecke: Der Initialisierungspfad fuer eine frische Postgres ohne
|
||||||
|
pg_dumpall ist in der Doku nicht beschrieben. Im echten DR mit Borg ist
|
||||||
|
das unwahrscheinlich, aber im Soft-Recovery oder Migrations-Drill schon.
|
||||||
|
|
||||||
|
Vorschlag: In `DISASTER_RECOVERY.md` Phase 4 Stufe 2 eine optionale
|
||||||
|
Anweisung: "Falls Postgres frisch ist (kein Dump-Restore), `infra/
|
||||||
|
postgresql17/init/`-Skripte oder manuelle `CREATE ROLE`/`CREATE DATABASE`-
|
||||||
|
Schritte ergaenzen."
|
||||||
|
|
||||||
|
### P4-5 (LOW) - Gitea nach Postgres ist faktisch unnoetig
|
||||||
|
|
||||||
|
`docs/DISASTER_RECOVERY.md:267-275` Phase 4 Stufe 2 ordnet Gitea hinter
|
||||||
|
Postgres ein. Gitea nutzt SQLite (`gitea.sqlite.dump`), nicht den shared
|
||||||
|
Postgres. Reihenfolge ist nicht falsch, aber irrefuehrend. Nicht kritisch.
|
||||||
|
|
||||||
|
### P4-6 (HIGH) - Komodo-Mongo Passwort-Lockout-Risiko
|
||||||
|
|
||||||
|
`ops/komodo/docker-compose.yml:18-20` zeigt: `komodo-mongo` initialisiert
|
||||||
|
sich bei leerem Datadir mit `MONGO_INITDB_ROOT_PASSWORD_FILE` aus
|
||||||
|
`/mnt/user/appdata/secrets/komodo_mongo_password.txt`.
|
||||||
|
|
||||||
|
Restore-Fall: Datadir aus Borg restauriert, Secret-Datei aus Borg
|
||||||
|
restauriert - beide aus demselben Snapshot. OK.
|
||||||
|
|
||||||
|
Riskanter Fall: Datadir aus Borg, aber Secret-Datei aus einer anderen
|
||||||
|
(neueren oder aelteren) Quelle. Mongo akzeptiert den Login nicht, Komodo
|
||||||
|
laeuft nicht. Lockout. Doku erwaehnt diesen Pin-Punkt nicht.
|
||||||
|
|
||||||
|
Vorschlag: Hinweis in `DISASTER_RECOVERY.md` Phase 4 Stufe 3: "Mongo-
|
||||||
|
Datadir und `komodo_mongo_password.txt` muessen aus demselben Snapshot
|
||||||
|
kommen. Bei Mismatch: leeren Datadir und Re-Init, dann Daten aus
|
||||||
|
`komodo-mongo.archive.gz` per `mongorestore`."
|
||||||
|
|
||||||
|
### P4-7 (MED) - Hardgecodete LAN-IP in `extra_hosts`
|
||||||
|
|
||||||
|
`ops/komodo/docker-compose.yml:50` und `:101` haben:
|
||||||
|
`"git.kaleschke.info:192.168.178.58"`.
|
||||||
|
|
||||||
|
Bare-Metal-Recovery auf anderer Hardware oder veraenderter LAN-IP fuehrt
|
||||||
|
zu stummem Fehler: Komodo-Core kann Gitea nicht ueber den Override
|
||||||
|
erreichen, faellt auf AdGuard-DNS zurueck (wenn der schon laeuft) oder
|
||||||
|
scheitert.
|
||||||
|
|
||||||
|
Vorschlag: kurzer Hinweis in `DISASTER_RECOVERY.md` Phase 4 Stufe 3: "Bei
|
||||||
|
geaenderter Host-LAN-IP `extra_hosts`-Werte in `ops/komodo/docker-compose.
|
||||||
|
yml` vor `compose up` anpassen oder ueber `.env` parametrisieren."
|
||||||
|
|
||||||
|
### P4-8 (HIGH) - Stack-ENV-Wiederherstellung praktisch manuell
|
||||||
|
|
||||||
|
`docs/DISASTER_RECOVERY.md:188-195` sagt: "Wenn `komodo-mongo.archive.gz`
|
||||||
|
frisch ist, koennen die Werte beim Komodo-Restart aus dem Dump
|
||||||
|
zurueckgespielt werden, ohne dass jemand sie sieht."
|
||||||
|
|
||||||
|
`docs/RESTORE_HANDBOOK.md:73-74` und `docs/AUDIT_2026-05-25_TODO.md:20`
|
||||||
|
machen den Daten-Mongo-Restore als "erledigt 2026-06-03" sichtbar - aber
|
||||||
|
NICHT als Teil des DR-Bootstraps. Komodo-Bootstrap im Trockenlauf benutzt
|
||||||
|
Wegwerf-Werte.
|
||||||
|
|
||||||
|
Praktisch heisst das: Im DR-Bootstrap (Phase 4 Stufe 3) startet Komodo
|
||||||
|
**ohne** den Mongo-Daten-Restore. Die `KOMODO_*` kommen aus externer
|
||||||
|
Notiz. Aber die Stack-ENVs fuer `paperless`/`immich`/`mail-archiver`/
|
||||||
|
`speedtest` (PAPERLESS_DBPASS etc.) **muessen vor Stufe 4** wieder in
|
||||||
|
Komodo eingetragen sein. Wenn der Mongo-Daten-Restore nicht direkt nach
|
||||||
|
Komodo-Start passiert, gehen diese Werte manuell in die Komodo-UI.
|
||||||
|
|
||||||
|
Vorschlag: Klarstellung in `DISASTER_RECOVERY.md` Phase 4 zwischen Stufe
|
||||||
|
3 und Stufe 4: "Optionaler Mongo-Daten-Restore aus `komodo-mongo.archive.
|
||||||
|
gz` per `ops/restore-tests/komodo-mongo-restore-test.sh`-Muster - dann
|
||||||
|
sind alle Stack-ENVs zurueck. Alternativ: Stack-ENVs manuell in Komodo-
|
||||||
|
UI eintragen, Quelle Vaultwarden (sobald Stufe 4 Vaultwarden laeuft -
|
||||||
|
Henne-Ei mit Paperless: Paperless-Start dann erst nach Vaultwarden, nicht
|
||||||
|
parallel)."
|
||||||
|
|
||||||
|
## Phase 5 - Verifikation
|
||||||
|
|
||||||
|
### P5-1 (LOW) - Smoke-Tests in DR.md weniger streng als Matrix
|
||||||
|
|
||||||
|
`docs/DISASTER_RECOVERY.md:337-345` Phase 5.3 sagt z. B. "Vaultwarden
|
||||||
|
startet und ist erreichbar". `docs/RESTORE_MATRIX.md:39` sagt: "Login-
|
||||||
|
Seite erreichbar, Tresor-Daten sichtbar". Das zweite ist faktisch der
|
||||||
|
echte Smoke-Test.
|
||||||
|
|
||||||
|
Geschmackssache, kein Bug. Empfehlung: DR.md auf die Matrix-Smokes
|
||||||
|
verweisen statt eigene Kurzversion.
|
||||||
|
|
||||||
|
### P5-2 (MED) - Kein Verifikationspunkt App-zu-DB-Verbindung
|
||||||
|
|
||||||
|
`docs/DISASTER_RECOVERY.md:337-345` prueft App-Start, nicht DB-Auth-
|
||||||
|
Erfolg. Bei falschem `PAPERLESS_DBPASS`-Stack-ENV startet Paperless
|
||||||
|
moeglicherweise mit Error-Log und ist via Traefik nicht antwortend - aber
|
||||||
|
das fehlt als Pruefpunkt.
|
||||||
|
|
||||||
|
Vorschlag: Phase 5.3 ergaenzen: "Pro App: `docker logs <app>` zeigt keine
|
||||||
|
`password authentication failed`/`FATAL: role does not exist`-Eintraege."
|
||||||
|
|
||||||
|
## Uebergreifende Findings
|
||||||
|
|
||||||
|
### X-1 (HIGH) - Nextcloud-Restore-Skript existiert, ist aber ungetestet
|
||||||
|
|
||||||
|
`ops/restore-tests/nextcloud-restore-test.sh` und
|
||||||
|
`ops/restore-tests/nextcloud-compose.test.yml` existieren im Repo.
|
||||||
|
`docs/RESTORE_MATRIX.md:147` Spalte "Letzter Restore-Test" fuer Nextcloud:
|
||||||
|
`-`, naechster Lauf `**hoechste Prio**`. `docs/AUDIT_2026-05-25_TODO.md:18`
|
||||||
|
fuehrt es als P1 "offen".
|
||||||
|
|
||||||
|
Damit ist der echte Tabletop-Gewinn: der Test ist nicht "noch zu bauen",
|
||||||
|
sondern "noch nie ausgefuehrt". Ein `bash /mnt/user/services/homelab-
|
||||||
|
infra/ops/restore-tests/nextcloud-restore-test.sh` schliesst die letzte
|
||||||
|
Tier-2-Luecke.
|
||||||
|
|
||||||
|
## Nicht-Findings
|
||||||
|
|
||||||
|
Was ich gepruft und als sauber verifiziert habe:
|
||||||
|
|
||||||
|
- Referenzierte Skripte existieren alle: `pre-backup-dumps.sh`,
|
||||||
|
`gitea-bundle-mirror.sh`, `run-restore-checks.sh`,
|
||||||
|
`komodo-bootstrap-test.sh`, `posture-check.sh`, alle Restore-Test-
|
||||||
|
Skripte fuer Tier-1 und Tier-2.
|
||||||
|
- Pfadverweise zwischen DR.md, RESTORE_MATRIX.md, SECRETS_MAP.md,
|
||||||
|
SERVICES_RECOVERY.md sind konsistent (Borg-Dumps unter `/mnt/user/
|
||||||
|
backups/borg/dumps/latest`, Secrets unter `/mnt/user/appdata/secrets`).
|
||||||
|
- Drift-Erkennung Authelia (`services/authelia-diff.sh`) ist in
|
||||||
|
`posture-check` integriert (`WORKFLOW.md:292`).
|
||||||
|
- GitHub-Mirror-Pfad und Gitea-Bundle-Mirror als Repo-Bootstrap-Quellen
|
||||||
|
sind dreifach abgesichert (lokaler Clone, GitHub, Bundle).
|
||||||
|
- Tier-1-Postgres-Restore-Drill ist 2026-06-03 erfolgreich gelaufen
|
||||||
|
(`AUDIT_2026-05-25_TODO.md:19`).
|
||||||
|
- `ops/komodo/docker-compose.yml` ist als Recovery-Anker getestet
|
||||||
|
(`SERVICES_RECOVERY.md:142-166`).
|
||||||
|
- Borg-Passphrase und Hetzner-Account-Hygiene sind Operator-bestaetigt
|
||||||
|
(`AUDIT_2026-05-25_TODO.md:46-47`).
|
||||||
|
|
||||||
|
## Vorschlag fuer Reihenfolge der Folge-Arbeit
|
||||||
|
|
||||||
|
1. **CRITICAL P1-1 zuerst** - Operator-Laptop-Voraussetzung als
|
||||||
|
DR-Pflichtposten dokumentieren. Eine Dokuzeile.
|
||||||
|
2. **HIGH P0-2 + P3-3** - klaeren, ob GitHub-Mirror lesend public ist und
|
||||||
|
wo der Hetzner-Maintenance-Key offline liegt. Zwei Dokuzeilen oder
|
||||||
|
eine echte Setup-Entscheidung.
|
||||||
|
3. **HIGH P2-1** - Operator-Bestaetigung "KOMODO_*-Notiz offline
|
||||||
|
gesichert YYYY-MM-DD" in `EXTERNAL_DEPENDENCIES.md` ergaenzen (sobald
|
||||||
|
real angelegt).
|
||||||
|
4. **HIGH P4-1 + P4-2** - Vorlauf "Stufe 0 - Docker-Grundlage" und
|
||||||
|
LE-Staging-Hinweis in DR.md Phase 4 einfuegen. Etwa 10 Zeilen Doku.
|
||||||
|
5. **HIGH X-1** - `nextcloud-restore-test.sh` einmal scharf ausfuehren.
|
||||||
|
Vermutlich ein Vormittag inklusive Report-Review.
|
||||||
|
6. **HIGH P2-2 + P4-8** - Reihenfolgen-Konsistenz Komodo/Vaultwarden in
|
||||||
|
DR.md eindeutig aufloesen.
|
||||||
|
7. Rest in der Reihenfolge der Tabelle.
|
||||||
|
|
||||||
|
Punkte 1-4 sind reine Doku-Arbeit, keine Compose-/Runtime-Aenderung.
|
||||||
|
Punkt 5 ist ein echter Restore-Lauf mit Report. Punkt 6 ist die
|
||||||
|
substanziellste Doku-Aenderung in DR.md.
|
||||||
|
|
||||||
|
## Folge-Iteration 2026-06-03 (Doku-Fixes im selben Aenderungsblock)
|
||||||
|
|
||||||
|
Direkt nach dem Drill und nach Operator-Antworten auf vier offene Fragen wurden folgende Findings im Repo adressiert. Operator-Aufgaben, die ich nicht selbst tun kann, sind als P1 in `docs/AUDIT_2026-05-25_TODO.md` aufgenommen.
|
||||||
|
|
||||||
|
| ID | Massnahme |
|
||||||
|
|---|---|
|
||||||
|
| P0-1 | DR.md Phase 0 ergaenzt um "Operativer Pfad fuer den Repo auf den frisch installierten Unraid-Host" (USB/SMB/rsync); DR.md Abschnitt 3 mit Zeile "Operator-DR-Workstation"; `EXTERNAL_DEPENDENCIES.md` neuer Abschnitt "DR-Workstation Bare-Metal-Kit" |
|
||||||
|
| P0-2 | `EXTERNAL_DEPENDENCIES.md` GitHub-Mirror-Zeile praezisiert (privat, Read-PAT/Deploy-Key Pflicht); DR.md Phase 0 verweist explizit darauf; offene Operator-Aufgabe in Audit-Restliste |
|
||||||
|
| P1-1 | Operator-DR-Workstation als Voraussetzung in DR.md Abschnitt 3 und in `EXTERNAL_DEPENDENCIES.md`; konkrete Pflichtbestandteile (WSL2, Borg, SSH-Key) gelistet |
|
||||||
|
| P1-2 | Bleibt offen als P3-Test in Restore-Backlog (kein Doku-Fix moeglich) |
|
||||||
|
| P2-1 | KOMODO_*-Notiz als kritische Secret-Zeile in `EXTERNAL_DEPENDENCIES.md` mit Status "noch nicht angelegt"; Operator-Aufgabe in Audit-Restliste |
|
||||||
|
| P2-2 | DR.md Phase 4 Stufe 3 ergaenzt um expliziten Hinweis "KOMODO_* aus externer Notiz oder voraus gezogener Vaultwarden" |
|
||||||
|
| P2-3 | DR.md Abschnitt 6.1 um `homelab_smtp_password.txt` erweitert |
|
||||||
|
| P2-4 | DR.md Abschnitt 6.1 um `n8n_encryption_key.txt` erweitert |
|
||||||
|
| P2-5 | DR.md Abschnitt 6.1 um Monitoring-Grafana/InfluxDB-/Filebrowser-Secrets erweitert |
|
||||||
|
| P3-1 | DR.md neuer Abschnitt 7.3 "Borg-Extract ohne `borg-ui`-Container" mit DR-Workstation- und Docker-Variante |
|
||||||
|
| P3-2 | DR.md Abschnitt 7.3 nennt Passphrase-Eingabe explizit als interaktiven Bootstrap-Schritt |
|
||||||
|
| P3-3 | `EXTERNAL_DEPENDENCIES.md` Review-Zeile 2026-06-03: Hetzner-Maintenance-Key auch offline bestaetigt |
|
||||||
|
| P4-1 | DR.md Phase 4 neue Stufe 0 "Docker-Grundlage" mit `docker network create` Befehlen |
|
||||||
|
| P4-2 | DR.md Phase 4 Stufe 1 LE-Staging-Hinweis bei verlorenem `acme.json` |
|
||||||
|
| P4-3 | DR.md Phase 4 Stufe 0 nennt `traefik/dynamic/*` als Pre-Check |
|
||||||
|
| P4-4 | Wird mit fresh-Postgres-Initialisierungsskripten ohne Doku-Aenderung nicht sinnvoll abgedeckt; bleibt als Doku-Hinweis offen, ist im realen Restore-Pfad mit `pg_dumpall --globals-only` abgedeckt |
|
||||||
|
| P4-5 | LOW, nicht angepasst (Reihenfolge nicht falsch, nur irrefuehrend) |
|
||||||
|
| P4-6 | DR.md Phase 4 Stufe 3 "Wichtige Stolperfallen": Mongo-Datadir/Secret-Mismatch und Re-Init-Pfad |
|
||||||
|
| P4-7 | DR.md Phase 4 Stufe 3 "Wichtige Stolperfallen": `extra_hosts`-Anpassung bei IP-Wechsel |
|
||||||
|
| P4-8 | DR.md Phase 4 Stufe 3 "Wichtige Stolperfallen": Stack-ENV-Wiederherstellung per `mongorestore` oder manuell |
|
||||||
|
| P5-1 | LOW, nicht angepasst |
|
||||||
|
| P5-2 | DR.md Phase 5.3 um `docker logs`-Verifikation der App-zu-DB-Verbindung erweitert |
|
||||||
|
| X-1 | **erledigt 2026-06-03**: Nextcloud-Restore-Test scharf gelaufen, drei Iterationen (zwei Skript-Bugs gefixt), Endresultat SUCCESS mit HTTP 200, occ status ok, 126 DB-Tabellen. Damit ist Tier-2 vollstaendig belegt. |
|
||||||
|
|
||||||
|
Nicht angefasst: P1-2 (kein Doku-Fix moeglich), P4-4 (im echten Restore-Pfad ohnehin abgedeckt), P4-5 und P5-1 (LOW). Die offenen Operator-Aufgaben (KOMODO_*-Notiz, Read-PAT, DR-Workstation, Nextcloud-Restore) stehen jetzt in `docs/AUDIT_2026-05-25_TODO.md` als P1.
|
||||||
|
|
||||||
|
## Reproduktion dieses Drills
|
||||||
|
|
||||||
|
```text
|
||||||
|
Methode: kalter Lesetest gegen
|
||||||
|
- docs/DISASTER_RECOVERY.md
|
||||||
|
- docs/RESTORE_MATRIX.md
|
||||||
|
- docs/SECRETS_MAP.md
|
||||||
|
- docs/SERVICES_RECOVERY.md
|
||||||
|
- docs/RESTORE_HANDBOOK.md
|
||||||
|
- docs/EXTERNAL_DEPENDENCIES.md
|
||||||
|
- ops/komodo/docker-compose.yml
|
||||||
|
- traefik/docker-compose.yml
|
||||||
|
Verifizierte Skript-Existenz: ops/borg-ui/scripts/*, ops/restore-tests/*,
|
||||||
|
services/posture-check/*
|
||||||
|
Kein Container gestartet, kein Skript ausgefuehrt, keine produktiven
|
||||||
|
Pfade beruehrt.
|
||||||
|
```
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# DR-Workstation Setup-Runbook
|
||||||
|
|
||||||
|
Stand: 2026-06-03
|
||||||
|
|
||||||
|
Konkrete Schritte, um den Operator-Gaming-PC als DR-Workstation einzurichten. Der Endzustand ist in `docs/EXTERNAL_DEPENDENCIES.md` Abschnitt "DR-Workstation Bare-Metal-Kit" beschrieben; dieses Dokument ist der Weg dahin.
|
||||||
|
|
||||||
|
Vorbedingung: Repo-Clone unter `G:\Gitea_Clone\homelab-infra`, Hetzner-DR-SSH-Key und GitHub-Deploy-Key liegen offline auf USB.
|
||||||
|
|
||||||
|
Aufwand: einmalig ~30-60 Min interaktiv.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 1 - WSL2 + Ubuntu installieren (~15 Min)
|
||||||
|
|
||||||
|
PowerShell als **Administrator** oeffnen:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl --install -d Ubuntu
|
||||||
|
```
|
||||||
|
|
||||||
|
- Bei "Virtualization nicht aktiviert"-Fehler: BIOS rein, Intel VT-x / AMD-V einschalten, neu starten, Befehl wiederholen.
|
||||||
|
- Nach Install: Ubuntu startet automatisch und fragt nach Username + Passwort. Username egal (z. B. `dr`), Passwort merken (wird fuer `sudo` gebraucht).
|
||||||
|
- Reboot kann noetig sein - PowerShell sagt es.
|
||||||
|
|
||||||
|
Verifikation in Ubuntu (oeffnet sich automatisch):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lsb_release -a
|
||||||
|
uname -r
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartet: `Ubuntu 24.04 LTS`, Kernel beginnt mit `5.x` oder `6.x` und enthaelt `microsoft-standard-WSL2`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 2 - Borg-Client installieren (~3 Min)
|
||||||
|
|
||||||
|
In der Ubuntu-Shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y borgbackup openssh-client
|
||||||
|
borg --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartet: `borg 1.2.x` oder `1.4.x`. Beides reicht fuer das produktive Borg-Repo auf Hetzner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 3 - Hetzner-DR-SSH-Key in WSL ablegen (~5 Min)
|
||||||
|
|
||||||
|
Wichtig: der Private-Key liegt offline auf USB. Fuer die Workstation-Routine wird er auf das WSL-Filesystem kopiert - **das ist die Arbeitskopie**, nicht die Offline-Sicherung. Wenn die WSL kaputtgeht, kommt der Key zurueck vom USB; das Offline-Original bleibt unangetastet.
|
||||||
|
|
||||||
|
USB einstecken. In WSL kopieren (Pfad anpassen je nach Laufwerksbuchstabe):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
cp /mnt/<USB-Buchstabe>/dr-hetzner-2026-06-03/dr-hetzner ~/.ssh/dr-hetzner
|
||||||
|
chmod 600 ~/.ssh/dr-hetzner
|
||||||
|
```
|
||||||
|
|
||||||
|
`<USB-Buchstabe>` ist meistens `e`, `f` oder `g` - Windows-Laufwerke werden in WSL unter `/mnt/<buchstabe>` gemountet.
|
||||||
|
|
||||||
|
Smoke-Test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i ~/.ssh/dr-hetzner -o IdentitiesOnly=yes -p 23 \
|
||||||
|
u565255@u565255.your-storagebox.de "ls"
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartet: vier Verzeichnisse (`backup`, `backup2`, `hetzner_borg_appdata`, `hetzner_borg_appdata_critical`), exit 0, kein Passwort-Prompt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 4 - Borg-Passphrase eingeben und `borg list` testen (~5 Min)
|
||||||
|
|
||||||
|
Borg verlangt die Passphrase beim ersten Repo-Zugriff. Die liegt offline gesichert (Operator-Bestaetigung 2026-05-26).
|
||||||
|
|
||||||
|
Einmaliger Smoke gegen das wichtige Repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export BORG_RSH="ssh -i ~/.ssh/dr-hetzner -o IdentitiesOnly=yes -p 23"
|
||||||
|
borg list ssh://u565255@u565255.your-storagebox.de/./hetzner_borg_appdata_critical
|
||||||
|
```
|
||||||
|
|
||||||
|
Borg fragt nach der Passphrase. Eingeben (sie wird nicht angezeigt, das ist normal).
|
||||||
|
|
||||||
|
Erwartet: Liste mit Archiv-Namen, jeder im Stil `Taegliche-Sicherung-YYYY-MM-DDTHH:MM:SS.xxx`. Wenn ja: Borg-Schicht funktioniert.
|
||||||
|
|
||||||
|
**Wert wird nirgendwo gespeichert.** `BORG_PASSPHRASE`-Env-Variable wird **nicht** dauerhaft gesetzt; Passphrase wird im Notfall immer interaktiv eingegeben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 5 - GitHub-Deploy-Key in WSL ablegen (~3 Min)
|
||||||
|
|
||||||
|
Gleiches Muster wie Hetzner-Key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp /mnt/<USB-Buchstabe>/dr-readonly-2026-06-03/dr-readonly ~/.ssh/dr-readonly
|
||||||
|
chmod 600 ~/.ssh/dr-readonly
|
||||||
|
```
|
||||||
|
|
||||||
|
Smoke-Test gegen den privaten GitHub-Mirror:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/dr-readonly -o IdentitiesOnly=yes" \
|
||||||
|
git ls-remote git@github.com:michaelkaleschke-spec/homelab-infra.git | head -3
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartet: HEAD und mindestens ein `refs/heads/master`-Eintrag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 6 - Quartals-Smoke als Skript ablegen (~5 Min)
|
||||||
|
|
||||||
|
Damit der "ich pruefe das vierteljaehrlich"-Schritt zur Routine wird, ein kleines Skript ins WSL-Home:
|
||||||
|
|
||||||
|
Stand 2026-06-06: Das Skript liegt zusaetzlich versioniert unter
|
||||||
|
`ops/maintenance/dr-workstation-smoke.sh` und wurde auf `baerchen` bereits nach
|
||||||
|
`~/dr-smoke.sh` in die Ubuntu-WSL kopiert. Borg 1.2.8 ist installiert, die
|
||||||
|
DR-Key-Arbeitskopien liegen unter `~/.ssh/dr-readonly` und
|
||||||
|
`~/.ssh/dr-hetzner`, GitHub-Read-Smoke und Hetzner-SSH-Smoke sind erfolgreich.
|
||||||
|
Der finale Borg-Smoke via `bash ~/dr-smoke.sh` wurde am 2026-06-06 ebenfalls
|
||||||
|
erfolgreich gefahren (`DR-Smoke OK (2026-06-06 10:05:30)`). Die Borg-Passphrase
|
||||||
|
wurde nur interaktiv eingegeben und nicht gespeichert.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > ~/dr-smoke.sh <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
# DR-Workstation Quartals-Smoke
|
||||||
|
# Pruefen: GitHub-Read, Hetzner-SSH, Borg-Repo-Erreichbarkeit
|
||||||
|
# Passphrase wird interaktiv abgefragt - Skript speichert keinen Wert.
|
||||||
|
set -e
|
||||||
|
echo "=== GitHub Deploy-Key ==="
|
||||||
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/dr-readonly -o IdentitiesOnly=yes" \
|
||||||
|
git ls-remote git@github.com:michaelkaleschke-spec/homelab-infra.git \
|
||||||
|
| head -1
|
||||||
|
echo
|
||||||
|
echo "=== Hetzner SSH-Login ==="
|
||||||
|
ssh -i ~/.ssh/dr-hetzner -o IdentitiesOnly=yes -p 23 \
|
||||||
|
u565255@u565255.your-storagebox.de "ls" | head -5
|
||||||
|
echo
|
||||||
|
echo "=== Borg-Repo (Passphrase wird abgefragt) ==="
|
||||||
|
export BORG_RSH="ssh -i ~/.ssh/dr-hetzner -o IdentitiesOnly=yes -p 23"
|
||||||
|
borg info ssh://u565255@u565255.your-storagebox.de/./hetzner_borg_appdata_critical | head -10
|
||||||
|
echo
|
||||||
|
echo "DR-Smoke OK ($(date '+%F %T'))"
|
||||||
|
EOF
|
||||||
|
chmod +x ~/dr-smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Aufrufen mit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ~/dr-smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Termin im Kalender: einmal pro Quartal, ~5 Min Aufwand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 7 - Eintrag in EXTERNAL_DEPENDENCIES Review nachziehen
|
||||||
|
|
||||||
|
Nach erfolgreicher Einrichtung im Repo dokumentieren. In `docs/EXTERNAL_DEPENDENCIES.md` unter "Review":
|
||||||
|
|
||||||
|
```
|
||||||
|
| 2026-06-XX | DR-Workstation produktiv: WSL2 Ubuntu auf Gaming-PC, borgbackup installiert, Hetzner-DR-Key und GitHub-Deploy-Key in ~/.ssh, Quartals-Smoke-Skript ~/dr-smoke.sh. Bare-Metal-DR-Pillars sind damit alle vier produktionsreif. | Quartalsweise Smoke laufen lassen |
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit-Restliste analog: in `docs/AUDIT_2026-05-25_TODO.md` den P1 "DR-Workstation Bare-Metal-Kit: WSL2 + Borg-Client installieren" auf erledigt setzen und unter "Zuletzt geschlossen" einen Eintrag mit Smoke-Ergebnis machen.
|
||||||
|
|
||||||
|
Wenn ich (Claude) am Tag der Einrichtung mit SSH-Zugang dabei bin, ziehe ich das nach. Sonst per `git add docs/EXTERNAL_DEPENDENCIES.md docs/AUDIT_2026-05-25_TODO.md && git commit && git push`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### `wsl --install` schlaegt fehl mit "WSL 2 requires an update"
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl --update
|
||||||
|
wsl --shutdown
|
||||||
|
wsl --install -d Ubuntu
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hetzner-SSH fragt nach Passwort statt Key-Login zu akzeptieren
|
||||||
|
|
||||||
|
Permissions des Keys pruefen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la ~/.ssh/dr-hetzner
|
||||||
|
```
|
||||||
|
|
||||||
|
Muss `-rw-------` (also `600`) sein. Wenn anders:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod 600 ~/.ssh/dr-hetzner
|
||||||
|
```
|
||||||
|
|
||||||
|
Bei weiterhin Passwort-Prompt: Pubkey-Inhalt gegen das authorized_keys-Format der Storage Box pruefen (sollte `ssh-ed25519 AAAA...` ohne Leerzeilen sein).
|
||||||
|
|
||||||
|
### `borg list` haengt oder schlaegt mit "Connection refused" fehl
|
||||||
|
|
||||||
|
Port 23 explizit pruefen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nc -vz u565255.your-storagebox.de 23
|
||||||
|
```
|
||||||
|
|
||||||
|
Wenn das fehlschlaegt: Hetzner-Status-Page pruefen, sonst SSH-Verbindung an sich blockiert (Firewall, ISP).
|
||||||
|
|
||||||
|
### GitHub-Pull fragt nach Username/Passwort
|
||||||
|
|
||||||
|
Stelle sicher dass die URL `git@github.com:...` ist (SSH), nicht `https://github.com/...`. Bei HTTPS wuerde GitHub Username/PAT verlangen, was wir bewusst nicht eingerichtet haben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was nach diesem Runbook gilt
|
||||||
|
|
||||||
|
Mit allen Schritten erledigt ist der vierte Bare-Metal-DR-Pillar zu (siehe `docs/EXTERNAL_DEPENDENCIES.md`). Der DR-Workstation-Status ist dann:
|
||||||
|
|
||||||
|
- WSL2 Ubuntu installiert
|
||||||
|
- borgbackup einsatzbereit
|
||||||
|
- SSH-Keys (Hetzner, GitHub) in `~/.ssh/`
|
||||||
|
- Quartals-Smoke-Skript laeuft
|
||||||
|
|
||||||
|
Damit ist im Bare-Metal-Fall der Pfad "Unraid tot -> Gaming-PC nimmt die DR-Arbeit auf" tatsaechlich gangbar, nicht nur in Doku theoretisch.
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# External Dependencies - KalliLab CORE
|
||||||
|
|
||||||
|
Status: Betreiber-Baseline 2026-06-01; Account-Recovery, Schluessel und Besitznachweise bleiben ausserhalb des Repos.
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---:|---|---|---|
|
||||||
|
| Telekom DSL | Internet-Uplink | hoch | Public Apps, ACME, DDNS, Hetzner-Off-site und Tailscale-Initial-Verbindung fallen aus | Telekom-Kundenkonto | Kein WAN-Failover am Standort eingerichtet (FRITZ!Box-Ausfallschutz inaktiv); lokale LAN-Dienste laufen weiter; Hotspot-Behelf nur fuer Operator-Arbeit, nicht fuer Public Apps |
|
||||||
|
| FRITZ!Box 7590 | Router, DHCP, Telefonie, WAN | hoch | LAN ohne DHCP/Routing; auch lokale Inter-Subnet-Kommunikation kann brechen | Operator-Login auf `192.168.178.1` | FRITZ!Box-Konfig-Backup vom 2026-06-01 liegt extern/off-system in Vaultwarden; Reset-Pin und Account-Pfad bereithalten; Remote-HTTPS/FTP/FTPS aus dem Internet sind aus |
|
||||||
|
| 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 | Borg-Passphrase ist offline gesichert; Hetzner 2FA/Recovery/Zahlung sind bestaetigt; Storage Box ist SSH-only, Maintenance-Key liegt in Vaultwarden; Borg `append-only` wird per Operator-Entscheidung nicht umgesetzt |
|
||||||
|
| GitHub Mirror | Externer Repo-Mirror `michaelkaleschke-spec/homelab-infra` (privat) | mittel/hoch | Gitea-Verlust abfederbar, aber Bare-Metal-Bootstrap braucht Read-Zugang (PAT oder SSH-Deploy-Key); ohne diesen ist der Mirror im DR nicht klonbar | GitHub-Konto; Push-PAT liegt in Gitea-Mirror-Settings; **Read-PAT/Deploy-Key fuer DR muss zusaetzlich offline im DR-Kit liegen** | Mirror-Status regelmaessig pruefen; lokalen Clone als zweite Kopie behalten; Read-PAT mit Scope `repo:read` separat erzeugen und im DR-Kit ablegen |
|
||||||
|
| 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, Vaultwarden-Einladungen, Ops-Report-Mail | mittel | Mail-Notifier und Vaultwarden-Einladungen fallen aus; Login selbst nicht zwingend | GMX-Konto; SMTP-Secrets liegen hostseitig | ntfy/zweiter SMTP als Fallback pruefen |
|
||||||
|
| OpenAI API | Paperless-GPT LLM und Vision-OCR | mittel | Automatische Dokument-Titel, Tags, Korrespondenten und LLM-OCR fallen aus; Paperless selbst laeuft weiter | OpenAI-Projekt/API-Key ausserhalb Repo | Key in Vaultwarden/Komodo sichern, bei Offenlegung rotieren; Kosten/Usage im OpenAI-Projekt beobachten |
|
||||||
|
| 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 | Plex native Auth, Claim und Client-Zugriff ueber `plex.kaleschke.info` | mittel | Plex-Web/App-Login und Clients koennen ausfallen; LAN-Medienpfade bleiben lokal | Plex-Konto ausserhalb Repo; `PLEX_CLAIM` nur fuer Setup | Plex Remote Access bleibt aus; externer Zugriff laeuft ueber Traefik/443. 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 |
|
||||||
|
| Operator-DR-Workstation | Bare-Metal-Recovery-Arbeitsplatz (Gaming-PC Windows, lokaler Repo-Clone `G:\Gitea_Clone\homelab-infra`) | kritisch | Ohne Workstation kein Borg-Extract, kein Hetzner-Zugriff, kein Repo-Bootstrap; der Unraid-Host ist im Bare-Metal-Fall gerade weg | Operator-PC, WSL2 + Borg-Client, SSH-Key fuer Hetzner Storage Box, Offline-Kopie der Borg-Passphrase | Setup als bewusste DR-Vorbedingung pflegen (siehe Abschnitt "DR-Workstation Bare-Metal-Kit") |
|
||||||
|
|
||||||
|
## 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 | Offline gesichert, Operator-Bestaetigung 2026-05-26 |
|
||||||
|
| 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, Vaultwarden-Einladungen, Ops-Report-Mail | In Host-Secrets, 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 | Account 2FA aktiv, Recovery Key offline gedruckt, Zahlungsweg ok; Maintenance-Key und Storage-Box-Passwort in Vaultwarden |
|
||||||
|
| OpenAI API Key | Paperless-GPT GPT-Zugriff | Als Stack ENV / Vaultwarden-Eintrag sichern; bei Verdacht auf Leak rotieren |
|
||||||
|
| KOMODO_* Stack-ENV-Notiz | Offline-Sicherung der 5 Komodo-Werte (`KOMODO_SECRET_KEY`, `KOMODO_WEBHOOK_SECRET`, `KOMODO_JWT_SECRET`, `KOMODO_MONGO_PASSWORD`, `KOMODO_PERIPHERY_PASSKEY`) | **Status 2026-06-03: offline gesichert (Operator-Bestaetigung)**. Quelle der Werte ist die host-seitige Self-Stack-`.env` (`/mnt/user/services/stacks/komodo/.env`) bzw. die Drift-Recovery-Kopie unter `/mnt/user/appdata/secrets/_komodo_stack_env_recovery_2026-05-04.env`. Nicht im Repo, nicht in ntfy, nicht in Logs |
|
||||||
|
| GitHub-Mirror Read-Only Deploy-Key | DR-Read-Zugang zum privaten Mirror `michaelkaleschke-spec/homelab-infra` | **Status 2026-06-03: offline gesichert (Operator-Bestaetigung).** SSH-Deploy-Key `dr-readonly-2026-06-03` (ed25519, Passphrase-frei), Title in GitHub Repo Settings -> Deploy Keys: `DR Read-Only 2026-06-03`, Write-Access bewusst deaktiviert. Private Key liegt offline neben der KOMODO_*-Notiz. Smoke `git ls-remote` am 2026-06-03 erfolgreich. |
|
||||||
|
|
||||||
|
## DR-Workstation Bare-Metal-Kit
|
||||||
|
|
||||||
|
Der Operator-Gaming-PC ist im Bare-Metal-Fall die einzige Stelle, von der aus Recovery starten kann. Folgende Bestandteile gehoeren zum minimalen DR-Kit auf diesem Rechner:
|
||||||
|
|
||||||
|
| Bestandteil | Zweck | Pruefen |
|
||||||
|
|---|---|---|
|
||||||
|
| Repo-Clone `G:\Gitea_Clone\homelab-infra` (master, gefetcht) | Recovery-Anker fuer `ops/komodo/docker-compose.yml`, Restore-Skripte | `git -C G:\Gitea_Clone\homelab-infra log --oneline -1` plausibel aktuell |
|
||||||
|
| Read-Zugang zum privaten GitHub-Mirror | Fallback, falls lokaler Clone defekt | SSH-Deploy-Key `dr-readonly-2026-06-03` (ed25519, Passphrase-frei) offline im DR-Kit, ein Test-Clone pro Quartal mit `GIT_SSH_COMMAND="ssh -i <pfad-zum-key> -o IdentitiesOnly=yes" git ls-remote git@github.com:michaelkaleschke-spec/homelab-infra.git` |
|
||||||
|
| WSL2 mit Borg-Client (`apt install borgbackup`) | Borg-Extract von Hetzner Storage Box ohne laufenden Unraid-Host | `borg --version` antwortet; ein `borg list` gegen Hetzner-Repo laeuft |
|
||||||
|
| SSH-Key fuer Hetzner Storage Box | Login auf `u565255.your-storagebox.de:23` | **Status 2026-06-03: ed25519-DR-Key `dr-hetzner-2026-06-03` offline gesichert.** Pubkey via `install-ssh-key` auf der Storage Box autorisiert, passwortloser Login erfolgreich, `ls` zeigt vier Borg-Repos (`backup`, `backup2`, `hetzner_borg_appdata`, `hetzner_borg_appdata_critical`). Private Key liegt offline neben KOMODO_*-Notiz und GitHub-Deploy-Key |
|
||||||
|
| Offline-Kopie Borg-Passphrase | Entschluesselung des Borg-Repos | Operator-Bestaetigung 2026-05-26; bei Reviews nur Auffindbarkeit pruefen |
|
||||||
|
| Offline-Kopie KOMODO_* Stack-ENV | Komodo-Bootstrap ohne Vaultwarden | **Status 2026-06-03: offline gesichert (Operator-Bestaetigung)** |
|
||||||
|
| Vaultwarden Master-Passwort offline | Zugriff auf Vaultwarden-Export im DR | Operator-Wissen, ggf. analog gesichert |
|
||||||
|
|
||||||
|
Operative Regel: Die DR-Workstation wird nicht als Test-/Spiel-PC betrachtet. WSL und das DR-Kit duerfen nicht unbemerkt unbrauchbar werden. Quartalsweise minimaler Trockenlauf: `borg list <hetzner-repo>` muss antworten und der Repo-Clone muss fetchbar bleiben.
|
||||||
|
|
||||||
|
## Ausfall-Szenarien
|
||||||
|
|
||||||
|
### Hetzner Storage Box nicht erreichbar
|
||||||
|
|
||||||
|
- Lokales Borg-Repo und aktuelle Dumps pruefen.
|
||||||
|
- Keine destruktiven Host-Aenderungen starten, solange Off-site unklar ist.
|
||||||
|
- H:/ Nearline-Pull als schnelle lokale Zweitkopie fuer kritische Restore-Artefakte nutzen.
|
||||||
|
- Zweites Off-site-Ziel nur neu bewerten bei Hetzner-Problemen, stark wachsendem Datenwert oder geaenderter Betreiber-Praeferenz.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### Telekom-DSL / FRITZ!Box gestoert
|
||||||
|
|
||||||
|
- Lokale LAN-Apps (Plex, AdGuard-DNS, lokales Borg-Dump-Repository) bleiben verfuegbar, solange Host und Switch laufen.
|
||||||
|
- Tailscale-Sessions, die bereits stehen, koennen ueber DERP/Relays kurzzeitig weiterlaufen; neue Verbindungen koennen ausfallen.
|
||||||
|
- ACME-/DDNS-/Hetzner-Backup-Laeufe pausieren bis WAN zurueck ist.
|
||||||
|
- FRITZ!OS ist am 2026-06-01 auf 8.25 (`154.08.25`) beobachtet; weitere Updates nur in einem geplanten Service-Fenster einspielen, weil Reboot WAN/Tailscale-Aufbau unterbricht.
|
||||||
|
|
||||||
|
### 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. Borg-Passphrase ist laut Operator offline gesichert. | Account-Besitz, 2FA-Recovery-Codes und Zahlungswege extern bestaetigen |
|
||||||
|
| 2026-05-26 | Telekom-DSL und FRITZ!Box 7590 (damals FRITZ!OS 8.21) als WAN-/Router-Abhaengigkeit aufgenommen; Ausfallschutz nicht eingerichtet | FRITZ!OS-Update am 2026-06-01 als `154.08.25` beobachtet |
|
||||||
|
| 2026-05-28 | FRITZ!Box-Portfreigaben bereinigt: aktiv bleibt nur `443/tcp`; `80/tcp` entfernt, `222/tcp` bewusst nicht angelegt; UPnP-Recht fuer VONETS-Bridge deaktiviert | IPv6-/Dienste-Review am 2026-06-01 nachgezogen |
|
||||||
|
| 2026-06-01 | Externer Betreibercheck vorbereitet: `docs/EXTERNAL_OPERATOR_RUNBOOK.md` und `ops/maintenance/check-external-operator.sh`; FRITZ!Box meldet per TR-064 FRITZ!OS `154.08.25`, Public DNS hat keine AAAA-Records, Host hat keine globale Provider-IPv6 | Account-Hygiene am 2026-06-01 nachgezogen |
|
||||||
|
| 2026-06-01 | FRITZ!Box-UI gegengeprueft und Konfig-Backup extern/off-system in Vaultwarden abgelegt; Remote-HTTPS auf FRITZ!Box-UI aus, FTP/FTPS auf Speichermedien aus, nur `443/tcp -> 192.168.178.58`, keine aktive IPv6-Freigabe sichtbar, UPnP-Selbstfreigaben aus | Bei naechstem Router-Update erneut exportieren |
|
||||||
|
| 2026-06-01 | Hetzner-Account-Hygiene erledigt: externe Mail ok, Zahlung ok, 2FA aktiv, Recovery Key offline gedruckt. Storage Box: SSH aktiv, SMB/WebDAV aus, Maintenance-Key in Vaultwarden, Borg-Repo-Zugriff nach Recovery geprueft. Borg `append-only` wird bewusst nicht umgesetzt. | Keine Folgeaktion |
|
||||||
|
| 2026-06-03 | Hetzner Storage Box Maintenance-Key zusaetzlich offline gesichert bestaetigt (Operator-Antwort im DR-Tabletop 2026-06-03). Damit ist der Hetzner-Zugang im Bare-Metal-Fall ohne Vaultwarden moeglich. | Keine Folgeaktion |
|
||||||
|
| 2026-06-03 | DR-Tabletop ergibt drei offene Bootstrap-Bloecke: KOMODO_*-Notiz nicht offline, GitHub-Mirror-Read-PAT/Deploy-Key nicht angelegt, DR-Workstation nicht als DR-Kit konfiguriert. Details in `docs/DR_DRILL_2026-06-03.md` und Folge-Tasks in `docs/AUDIT_2026-05-25_TODO.md`. | KOMODO_*-Notiz erzeugen, Read-PAT erzeugen, WSL2+Borg auf Gaming-PC einrichten |
|
||||||
|
| 2026-06-03 | KOMODO_*-Notiz offline gesichert (Operator-Bestaetigung im DR-Tabletop-Followup). Quelle bleibt host-seitige `.env` (`/mnt/user/services/stacks/komodo/.env`) bzw. Drift-Recovery-Kopie vom 2026-05-04. Bare-Metal-Komodo-Bootstrap ist damit ohne Vaultwarden moeglich. | Restliche P1-Operator-Aufgaben: GitHub-Read-PAT, DR-Workstation-Setup, Nextcloud-Restore-Test |
|
||||||
|
| 2026-06-03 | GitHub-Mirror Read-Only Deploy-Key `DR Read-Only 2026-06-03` (ed25519, Passphrase-frei) erzeugt, in GitHub Repo Settings ohne Write-Access hinterlegt, Smoke `git ls-remote` erfolgreich (`d947c7f` matched master HEAD), Private-Key offline neben KOMODO_*-Notiz abgelegt, Arbeitsplatz-Kopie geloescht. | Restliche P1-Operator-Aufgaben: DR-Workstation-Setup, Nextcloud-Restore-Test |
|
||||||
|
| 2026-06-03 | Hetzner Storage Box DR-SSH-Key `dr-hetzner-2026-06-03` (ed25519, Passphrase-frei) erzeugt, via `install-ssh-key` auf Storage Box `u565255.your-storagebox.de:23` autorisiert, passwortloser Login erfolgreich (Borg-Repos sichtbar), Private-Key offline neben KOMODO_*-Notiz und GitHub-Deploy-Key abgelegt, Arbeitsplatz-Kopie geloescht. Bare-Metal-Borg-Restore von der DR-Workstation ist damit moeglich, sobald WSL2 + Borg-Client installiert sind. | Restliche P1-Operator-Aufgaben: WSL2 + Borg-Client auf DR-Workstation installieren, Nextcloud-Restore-Test |
|
||||||
|
| 2026-06-06 | DR-Workstation produktiv: WSL2 Ubuntu 24.04 vorhanden, SSH/Git und Borg 1.2.8 in WSL vorhanden, DR-Key-Arbeitskopien unter `~/.ssh/dr-readonly` und `~/.ssh/dr-hetzner`, GitHub-Read-Smoke und Hetzner-SSH-Smoke erfolgreich, `ops/maintenance/dr-workstation-smoke.sh` nach `~/dr-smoke.sh` kopiert. Finaler Operator-Smoke erfolgreich: GitHub HEAD `3a263a4...`, Hetzner Storage Box Repos sichtbar, Borg-Repo `hetzner_borg_appdata_critical` gelesen, Repository ID `5dd9b949...`, encrypted `Yes (repokey)`, `DR-Smoke OK (2026-06-06 10:05:30)`. | Quartalsweise `bash ~/dr-smoke.sh`; Borg-Passphrase weiterhin nur interaktiv eingeben und nicht speichern |
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# External Operator Runbook
|
||||||
|
|
||||||
|
Stand: 2026-06-01
|
||||||
|
|
||||||
|
Dieses Runbook schliesst die Betreiber-Aufgaben, die nicht vollstaendig aus dem
|
||||||
|
Repo automatisierbar sind: Hetzner-Account-Hygiene, Borg-Append-Only und
|
||||||
|
FRITZ!Box-Servicefenster. Keine Secret-Werte ins Repo schreiben.
|
||||||
|
|
||||||
|
## 1. Vorher pruefen
|
||||||
|
|
||||||
|
Auf dem Unraid-Host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash /mnt/user/services/homelab-infra/ops/maintenance/check-external-operator.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwarteter Stand vom 2026-06-01:
|
||||||
|
|
||||||
|
- FRITZ!Box 7590 meldet FRITZ!OS `154.08.25`.
|
||||||
|
- FRITZ!Box IPv6-Firewall meldet `FirewallEnabled=1`; `InboundPinholeAllowed=1` bedeutet, dass IPv6-Freigaben technisch moeglich sind und in der UI gegengeprueft werden muessen.
|
||||||
|
- Public DNS fuer `*.kaleschke.info` liefert A-Records auf `217.249.115.154`, keine AAAA-Records.
|
||||||
|
- Host hat keine globale Provider-IPv6-Adresse; sichtbar ist nur Tailscale-IPv6 `fd7a:115c:a1e0::2c01:62b2`.
|
||||||
|
- WAN-Smoke gegen die Public-IP: `443/tcp` offen, `80/tcp` und `222/tcp` geschlossen.
|
||||||
|
- FRITZ!Box-UI-Gegencheck vom 2026-06-01: Remote-HTTPS auf die FRITZ!Box ist aus, FTP/FTPS auf Speichermedien ist aus, nur `443/tcp -> 192.168.178.58` ist als WAN-Freigabe sichtbar, keine aktive IPv6-Freigabe sichtbar, UPnP-Selbstfreigaben aus.
|
||||||
|
- FRITZ!Box-Konfig-Backup vom 2026-06-01 ist extern/off-system in Vaultwarden abgelegt; Datei und Kennwort nicht ins Repo schreiben.
|
||||||
|
- Borg UI nutzt `borg 1.4.x`; Repository `appdata-critical` liegt auf Hetzner Storage Box `ssh://...your-storagebox.de:23/./hetzner_borg_appdata_critical`.
|
||||||
|
- Hetzner-Account-Hygiene vom 2026-06-01: 2FA aktiv, Recovery Key offline gedruckt, Zahlung ok.
|
||||||
|
- Storage Box vom 2026-06-01: SSH aktiv, SMB/WebDAV aus, separater Maintenance-Key in Vaultwarden, produktiver Borg-UI-Key und Maintenance-Key nach Passwort-Recovery getestet.
|
||||||
|
- Restore-Freshness: `Critical 0`, `Warnings 0`.
|
||||||
|
|
||||||
|
## 2. Hetzner Account-Hygiene
|
||||||
|
|
||||||
|
Im Hetzner-/Storage-Box-Konto pruefen und extern/off-system dokumentieren:
|
||||||
|
|
||||||
|
| Punkt | Soll |
|
||||||
|
|---|---|
|
||||||
|
| Passwort | stark, eindeutig, im Passwortmanager |
|
||||||
|
| 2FA | aktiv, Recovery Key offline auffindbar |
|
||||||
|
| Kontakt-E-Mail | aktuell und ohne Homelab-Abhaengigkeit erreichbar |
|
||||||
|
| Zahlungsweg | gueltig, Fallback bekannt |
|
||||||
|
| Storage Box | Produkt, Benutzer und Rechnungsstatus sichtbar |
|
||||||
|
| SSH/SFTP/WebDAV/SMB | nur benoetigte Protokolle aktiv |
|
||||||
|
| Recovery | Kundennummer, Login-Pfad und Support-Pfad extern notiert |
|
||||||
|
|
||||||
|
Im Repo nur das Datum der Bestaetigung dokumentieren, nie Zugangsdaten.
|
||||||
|
|
||||||
|
## 3. Borg Append-Only
|
||||||
|
|
||||||
|
Status: **bewusst nicht umgesetzt**.
|
||||||
|
|
||||||
|
Ziel der Haertung waere gewesen: Der produktive Backup-Client darf neue Archive
|
||||||
|
schreiben, aber nicht normal prune/delete/compact als unbeschraenkter Client
|
||||||
|
ausfuehren.
|
||||||
|
|
||||||
|
Hetzner dokumentiert Borg-Zugriff auf Storage Boxen inklusive `--remote-path`
|
||||||
|
fuer Borg-Versionen; fuer Borg 1.4 wird `--remote-path=borg-1.4` empfohlen.
|
||||||
|
Hetzner bestaetigt auch, dass append-only moeglich ist. Borg selbst setzt
|
||||||
|
append-only pro SSH-Key typischerweise ueber einen forced command in
|
||||||
|
`authorized_keys` um.
|
||||||
|
|
||||||
|
Getestetes Zielmodell, aber **nicht auf der produktiven Storage Box aktiv**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
command="borg-1.4 serve --append-only --restrict-to-repository /home/hetzner_borg_appdata_critical",restrict ssh-ed25519 <backup-public-key> borg-ui-append-only
|
||||||
|
ssh-ed25519 <maintenance-public-key> borg-maintenance
|
||||||
|
```
|
||||||
|
|
||||||
|
Hinweise:
|
||||||
|
|
||||||
|
- Stand 2026-06-01: Ein forced-command-Versuch auf der produktiven
|
||||||
|
Storage-Box-`authorized_keys` brach die Key-Authentifizierung. Recovery
|
||||||
|
erfolgte per Storage-Box-Passwort und Upload einer bereinigten
|
||||||
|
`authorized_keys` mit Borg-UI-Key und Maintenance-Key.
|
||||||
|
- Operator-Entscheidung 2026-06-01: Append-only wird fuer dieses Homelab nicht
|
||||||
|
umgesetzt. Der zusaetzliche Schutz steht hier nicht im Verhaeltnis zum
|
||||||
|
Betriebsrisiko und zur Komplexitaet.
|
||||||
|
- Pfad auf der Storage Box vor dem Eintragen pruefen. Bei Hetzner werden Pfade
|
||||||
|
im Borg-Repo haeufig relativ als `./repo-name` verwendet; in
|
||||||
|
`authorized_keys` muss der serverseitige Pfad zur Storage-Box-Home-Struktur
|
||||||
|
passen.
|
||||||
|
- Der produktive Borg-UI-Key bleibt bewusst uneingeschraenkt, damit die
|
||||||
|
produktiven Backups laufen.
|
||||||
|
- Ein separater Maintenance-Key bleibt fuer bewusste Retention/Prune/Compact
|
||||||
|
noetig und liegt in Vaultwarden; lokale temporare Key-Dateien wurden geloescht.
|
||||||
|
- Append-only verhindert nicht, dass ein kompromittierter Client Archive als
|
||||||
|
geloescht markiert; es verhindert die unmittelbare physische Entfernung.
|
||||||
|
Nach einem Vorfall keine unbeschraenkte Schreiboperation ausfuehren, bevor
|
||||||
|
die Borg-Transaktionen bewertet wurden.
|
||||||
|
|
||||||
|
Nach Aenderung:
|
||||||
|
|
||||||
|
1. Einen regulaeren Borg-Lauf abwarten oder manuell starten.
|
||||||
|
2. `check-external-operator.sh` ausfuehren.
|
||||||
|
3. In `docs/AUDIT_2026-05-25_TODO.md` nur das Ergebnis dokumentieren.
|
||||||
|
|
||||||
|
## 4. FRITZ!Box-Servicefenster
|
||||||
|
|
||||||
|
Vor dem Fenster:
|
||||||
|
|
||||||
|
1. Familie informieren: Internet/Telefonie koennen kurz weg sein.
|
||||||
|
2. Aktuellen Repo-Stand und Borg-Freshness pruefen.
|
||||||
|
3. FRITZ!Box-Konfig exportieren: `System -> Sicherung -> Sichern`.
|
||||||
|
4. Sicherungsdatei nicht ins Repo legen; im Passwortmanager/off-system ablegen.
|
||||||
|
|
||||||
|
In der FRITZ!Box:
|
||||||
|
|
||||||
|
| Bereich | Soll |
|
||||||
|
|---|---|
|
||||||
|
| `System -> Update` | FRITZ!OS aktuell; am 2026-06-01 per TR-064 `154.08.25` beobachtet |
|
||||||
|
| `Internet -> Freigaben -> Portfreigaben` | nur `443/tcp -> 192.168.178.58:443` |
|
||||||
|
| `Internet -> Freigaben -> FRITZ!Box-Dienste` | Remote-HTTPS auf FRITZ!Box-UI aus; FTP/FTPS auf Speichermedien aus |
|
||||||
|
| IPv6-Portfreigaben | keine aktiven Freigaben; insbesondere kein `222/tcp`, kein Admin-Port |
|
||||||
|
| Selbststaendige Portfreigaben/UPnP | fuer `Kallilabcore` aus; neue Geraete nur bewusst erlauben |
|
||||||
|
| Gastnetz | bleibt aus, solange keine Gastnetz-Policy gepflegt wird |
|
||||||
|
| Ausfallschutz | bewusst aus; nur neu bewerten, wenn ein Mobilfunk-Fallback gewuenscht ist |
|
||||||
|
|
||||||
|
Nach dem Fenster:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash /mnt/user/services/homelab-infra/ops/maintenance/check-external-operator.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann in `docs/NETWORK_INVENTORY.md` aktualisieren:
|
||||||
|
|
||||||
|
- FRITZ!OS-Version
|
||||||
|
- IPv6-Status
|
||||||
|
- aktive Portfreigaben
|
||||||
|
- FRITZ!Box-Dienste aus dem Internet
|
||||||
|
- Datum der Konfig-Sicherung
|
||||||
|
|
||||||
|
## Quellen
|
||||||
|
|
||||||
|
- Hetzner Docs: Storage Box Zugriff mit SSH/rsync/BorgBackup, inklusive
|
||||||
|
Borg-Versionen, `--remote-path` und Append-Only-Hinweis:
|
||||||
|
<https://docs.hetzner.com/storage/storage-box/access/access-ssh-rsync-borg/>
|
||||||
|
- BorgBackup Docs: `borg serve --append-only` und forced commands in
|
||||||
|
`authorized_keys`:
|
||||||
|
<https://borgbackup.readthedocs.io/en/stable/deployment/pull-backup.html>
|
||||||
|
- AVM FRITZ!Box Hilfe: IPv6-Portfreigaben werden separat verwaltet; eingehende
|
||||||
|
Zugriffe sind standardmaessig nicht offen:
|
||||||
|
<https://help.avm.de/fritzbox.php?hardware=145&language=en&oem=avme&set=009&topic=hilfe_internet_freigabe_ipv6>
|
||||||
|
- AVM FRITZ!Box Hilfe: Sicherung der FRITZ!Box-Einstellungen:
|
||||||
|
<https://help.avm.de/fritzbox.php?hardware=145&language=en&oem=avme&set=009&topic=hilfe_system_export>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user