443 Commits

Author SHA1 Message Date
renovate a28d3bbc33 chore(deps): update postgres:18.4 docker digest to 29ee7bb 2026-06-12 22:20:51 +00:00
Micha 4ab6dcefd2 fix: protect ha onboarding with authelia 2026-06-12 21:52:45 +02:00
Micha c24b792808 fix: allow home hairpin during ha onboarding 2026-06-12 21:51:34 +02:00
Micha 25a4ada891 fix: guard home assistant onboarding 2026-06-12 21:50:15 +02:00
Micha 6e6005aefd feat: add smart home foundation 2026-06-12 20:51:18 +02:00
Micha ad438a07b3 fix: allow mosquitto config ownership setup 2026-06-12 20:45:32 +02:00
Micha ce6f5c72dd feat: add smart home runtime foundation 2026-06-12 20:38:03 +02:00
Micha 630ee8dd90 ops: glance server-stats balken kraeftiger (13px, innenschatten)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:00:38 +02:00
Micha b1ca9ef19c ops: glance server-stats balken - rund, gradient, glow, warnfarbe ab 85%
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:57:36 +02:00
Micha 1c949d3fcc ops: glance internet-widget - bytes/s nach mbit/s umrechnen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:55:05 +02:00
Micha cfa6c01768 ops: glance komodo/immich widgets - stat-leisten mit trennlinien, pills, gradient-bars
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:24:12 +02:00
Micha 3474d53ce5 ops: glance borg-widget fix - alter via promql berechnen statt now.Unix im template
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:05:48 +02:00
Micha ca81b959cc ops: glance borg-backup-widget via prometheus + synthwave/matrix presets
- glance zusaetzlich in monitoring_net (nur lesende Prometheus-Query, kein neuer Listener)
- Borg-Widget: Backup-Alter aus homelab_borg_last_completed_timestamp_seconds, Status aus homelab_borg_last_success
- Theme-Presets synthwave und matrix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:01:32 +02:00
Micha 23764dff38 ops: glance farbschema entlilat - neutraler grund, akzente blau/cyan/amber/gruen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:44:00 +02:00
Micha 3c4a48d7e5 ops: glance neon-ops v2 - rotierende akzentfarben, gradient-zahlen, animierte header
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:30:38 +02:00
Micha c0a39f5dfc ops: glance neon-ops look - card styling, glows, sattere theme-farben
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:27:30 +02:00
Micha a1d7b6e433 ops: glance layout verdichtet - internet kombiniert, container-tabs, mealie/commit-fixes
- Home rechte Spalte: Internet+Speed in einem Widget, DNS-und-VPN-Monitor entfernt, Container-Listen als Tab-Gruppe
- Infrastructure: Container-Listen als Tab-Gruppe, Mealie-Statistik auf /api/admin/about/statistics (404-Fix)
- Commit-Widgets: toRelativeTime als span-Attribut, nur erste Commit-Zeile

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:47:25 +02:00
Micha 45f43da659 ops: glance speedtest widget - ookla raw data fallbacks (data.data.*)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:44:18 +02:00
Micha 290cb8949e ops: glance dashboard v2 - split config, stack widgets, releases page
- Config per $include aufgeteilt (glance.yml -> pages/home/infrastructure/ops, containers-map zentral)
- Neue Widgets: Komodo Stacks, Gitea GitOps, Paperless, Mealie, Scrutiny Disk Health, Wetter, To-do
- Neue Seite Ops und Releases (releases-Widget fuer gepinnte Images, RSS, Commit-Log)
- Homelab-Status in Tab-Gruppen Core/Apps/Ops, Speedtest-Widget mit ehrlichem Leerzustand
- Theme-Presets (Catppuccin, Gruvbox, Light) + custom.css via Assets-Mount
- Compose: 5 neue read-only Token-ENVs, Doku in SECRETS_MAP/MASTER_TODO nachgezogen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:06:42 +02:00
Micha d933d3cee8 ops: refine komodo stack hygiene check
- Hash drift now requires actual file changes inside the stack's
  compose-dir between deployed_hash and latest_hash. Komodo's
  deployed_hash bumps only on redeploy while latest_hash tracks master
  HEAD, which produced six false-positive "Pending Update" warnings
  for stacks whose own files never changed.
- Add EXPECTED_NOT_IN_KOMODO env (default: hermes-agent) for compose
  files intentionally not Komodo-managed (work-in-progress, build/dev
  compose).

End-to-end run on host: 0 critical, 0 warnings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-12 13:23:52 +02:00
Micha baedf9f932 docs: record komodo-stack-hygiene-weekly activation
Cron registered in /boot/config/plugins/user.scripts and live in
/etc/cron.d/root after update_cron. First scheduled run: Sun 05:00.
End-to-end smoke test on host: 6 warnings, 0 critical.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-12 12:57:06 +02:00
Micha b387757e87 ops: add komodo stack hygiene posture-check
Catches the failure class that let immich_new slip through: stacks
without a configured repo, project_missing, hash drift, and repo
compose files without a matching Komodo stack. Dry-run on host found
6 honest warnings, 0 critical. Wrapper as Unraid User Script for
weekly cadence is tracked in MASTER_TODO.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-12 12:51:07 +02:00
Micha 3eedbcbe16 docs: record immich stack cleanup 2026-06-12 08:24:27 +02:00
Micha 9033724b15 docs: record host DNS fallback as active
eth0 DNS server 2 = 192.168.178.1 (FRITZ!Box) is set as failover behind
AdGuard. Mark the komodo-bulk-deploy-dns runbook immediate measure as
implemented. Closes the AdGuard SPOF for Docker image pulls.
Ref: docs/homelab-optimierung.md recommendation 3a.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:26:22 +02:00
Micha aae176f1b7 docs: record Hetzner Storage Box automatic snapshots as active
Daily snapshots at 05:30 UTC (after the 04:30 local Borg run), 7 days
retention, snapshot directory visible for single-file restore via
.zfs/snapshot/. Closes the ransomware/misuse gap left open by the
explicit decision against Borg append-only (2026-06-01).
Ref: docs/homelab-optimierung.md recommendation 2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:25:01 +02:00
Micha c7590e6603 fix(immich): pin server and ML to v2.7.5 instead of mutable release tag
Digests unchanged (verified against GHCR manifest API: release ==
v2.7.5 for both images). Renovate now produces visible version PRs
instead of silent digest bumps that hide major version jumps.
Ref: docs/homelab-optimierung.md recommendation 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:02:26 +02:00
Micha 3e486b95f6 docs: add pdf cleanup and quarterly doc gardening to MASTER_TODO
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:55:15 +02:00
Micha 08b4be7a5d docs: add AGENTS.md entry point for non-Claude AI agents
Codex CLI auto-reads AGENTS.md; file only points to AI_CONTEXT,
architecture master, workflow and the binding doc rules - no duplicated
content (one fact, one home).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:50:53 +02:00
Micha a4f4696b0d docs: anchor documentation rules, rebuild index, archive proposal
- REPO_MAP.md: replace Arbeitsregel with 8 binding documentation rules
  (one fact one home, done leaves the working copy, file types, header
  convention, quarterly gardening)
- WORKFLOW.md Dokumentationspflicht and CLAUDE.md aligned to the rules
- docs/README.md index rebuilt for the consolidated state
- H drive docs merged into ops/h-drive-nearline/README.md (scheduled
  task + no-MIR rule added); docs/H_DRIVE_NEARLINE_PULL.md removed
- implemented proposal archived to docs/archive/2026/

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:14:11 +02:00
Micha 1fcdb68221 docs: consolidate restore documentation into ops/restore-tests
- merge RESTORE_HANDBOOK.md into ops/restore-tests/README.md (single
  operations doc; restore status lives only in RESTORE_MATRIX maturity
  table)
- RESTORE_MATRIX.md: extract embedded runbook drafts (261 -> 141 lines);
  unraid-flash and tailscale stubs become ops/restore-tests runbooks,
  adguard/redis checklists superseded by validated scripts
- delete six historical pre-first-run *-plan.md files (runbook + script
  are the source of truth since the validated first runs)
- SERVICES_RECOVERY: drop completed task table; DISASTER_RECOVERY:
  point related docs and section 11 to MASTER_TODO/schedule

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:11:16 +02:00
Micha 489a429316 docs: single status list - dissolve audit restliste, slim AI context
- MASTER_TODO.md is now the only status list: parked decisions point to
  DECISIONS.md, done log capped at 5 condensed entries
- delete AUDIT_2026-05-25_TODO.md (open items and parked decisions fully
  covered by MASTER_TODO/DECISIONS)
- AI_CONTEXT.md: drop duplicated status block, keep rules and pointers
- EXTERNAL_DEPENDENCIES.md: condense review log to recent entries
- fix references in DR_WORKSTATION_SETUP, EXTERNAL_OPERATOR_RUNBOOK,
  STORAGE_LAYOUT, REPO_MAP, docs/README

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:08:43 +02:00
Micha 513f41b852 docs: introduce DECISIONS.md decision register, slim architecture master
- new docs/DECISIONS.md (ADR-light): decisions migrated from master
  section 13, MASTER_TODO parked items, hardware inventory and audit
  restliste into one chronological register
- HOMELAB_ARCHITECTURE_MASTER_V2.md: section 13 replaced by pointer,
  section 9 condensed (502 -> 372 lines, target picture only)
- ROLLBACK.md: drop rollback recipes for already removed services
  (uptime-kuma, grafana/influx legacy, stirling/glance bootstrap notes)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:06:18 +02:00
Micha c80b51f585 docs: introduce docs/archive, remove finished sprint boards and generated report
- docs/archive/2026/ with index README: DR tabletop drill, workstation
  audits, HA/Ecowitt draft, pre-Borg backup audit, finished windows
  reinstall project docs
- delete weekend sprint boards (content preserved in MASTER_TODO done log
  and git history)
- untrack generated ops/policy-checks/last-report.md and gitignore it
- fix references (CLAUDE.md, docs/README.md, ops/windows-reinstall/README.md)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:02:57 +02:00
Micha 42ed59a4d7 docs: commit pending status updates from 2026-06-06 sprint wrap-up
Preserves uncommitted working-copy updates (Veeam recovery test done,
BitLocker decision, ACL rollout, freshness negative test) before the
documentation consolidation restructures these files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:00:25 +02:00
Micha 58c3324557 docs: add homelab documentation optimization proposal
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 06:36:53 +02:00
Micha d48d473942 docs: add homelab optimization assessment
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:40:05 +02:00
Micha e80e5dd49f renovate: komodo-Stack (inline-managed) aus Tracking nehmen
Der komodo-Stack wird in Komodo inline (file_contents) verwaltet, nicht aus dem Repo deployed. Renovate-PRs darauf wirken zur Laufzeit nicht und erzeugen Git-Komodo-Scheinsicherheit. Daher: ops/komodo/** in ignorePaths, mongo-Digest auf den real laufenden Stand zurueckgesetzt, Inline-Ausnahme in docs/RENOVATE.md und im Compose-Header dokumentiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:34:52 +02:00
Micha 3c339474a7 Merge pull request 'chore(deps): update mongo:8.0.23 docker digest to 73ee318' (#13) from renovate/mongo-8.0.23 into master
Reviewed-on: #13
2026-06-10 18:27:13 +00:00
Micha c79afdfab0 Merge pull request 'chore(deps): update docker.n8n.io/n8nio/n8n docker tag to v2.26.2' (#17) from renovate/docker.n8n.io-n8nio-n8n-2.x into master
Reviewed-on: #17
2026-06-10 18:24:23 +00:00
Micha 8172793c68 Merge pull request 'chore(deps): update nextcloud docker tag to v33.0.5' (#16) from renovate/nextcloud-33.x into master
Reviewed-on: #16
2026-06-10 18:19:56 +00:00
Micha 8e46440944 Merge pull request 'chore(deps): update shaanmajid/unbound:1.25.1 docker digest to f140db0' (#14) from renovate/shaanmajid-unbound-1.25.1 into master
Reviewed-on: #14
2026-06-10 18:13:58 +00:00
Micha dfe1dc1c99 Merge pull request 'chore(deps): update traefik:v3.7 docker digest to fcdef59' (#15) from renovate/traefik-v3.7 into master
Reviewed-on: #15
2026-06-10 18:06:09 +00:00
Micha 4007da3302 docs: Runbook fuer Komodo-Bulk-Deploy-DNS-Ausfall
Bulk-Renovate-Merge loest parallele Komodo-Deploys aus; Image-Pulls scheitern mit DNS connection refused, weil AdGuard (einziger Host-Resolver) im selben Batch recreated wird. Runbook haelt Symptom, Ursache, Sofortmassnahme (Unraid DNS2) und Betriebsregel fest. Verweis in REPO_MAP ergaenzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 19:52:34 +02:00
Micha 9836ea3c4f Merge pull request 'chore(deps): update minor-and-patch-updates' (#12) from renovate/minor-patch-updates into master
Reviewed-on: #12
2026-06-10 14:41:25 +00:00
renovate 803f84b3af chore(deps): update docker.n8n.io/n8nio/n8n docker tag to v2.26.2 2026-06-10 14:32:41 +00:00
renovate d05ca63545 chore(deps): update nextcloud docker tag to v33.0.5 2026-06-10 14:32:09 +00:00
renovate 9847baf327 chore(deps): update minor-and-patch-updates 2026-06-10 14:32:08 +00:00
renovate 8ec5bc55d9 chore(deps): update traefik:v3.7 docker digest to fcdef59 2026-06-10 14:31:35 +00:00
renovate 9c844074e0 chore(deps): update shaanmajid/unbound:1.25.1 docker digest to f140db0 2026-06-10 14:31:33 +00:00
Micha c126b71852 renovate: Kritische Kerninfra aus minor-patch-Sammel-PR ausgliedern
Traefik (Public-Entrypoint), Unbound (DNS), n8n und Nextcloud bekommen eigene PRs statt im gruppierten minor-and-patch-updates-PR zu landen. Erzwingt kontrollierten, einzeln reviewbaren Merge pro kritischem Dienst (WORKFLOW.md: keine mehreren kritischen Dienste gleichzeitig migrieren).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 16:22:12 +02:00
Micha e89b88a513 report: Noise-Eskalations-Befreiung fuer dauerhaft-laute Benign-Patterns
Bekannte Noise-Patterns, die strukturell sehr laut sind (Unraid-mdadm-Spam
~5760/Tag, Fritz!Box-SOA ~1170/Tag), hielten den Report-Status dauerhaft
auf >= WARNUNG ueber noise_threshold_exceeded und entwerteten damit die
Ampel - das System konnte nie OK erreichen, egal wie gesund.

Neue Datei noise-escalation-exempt.patterns listet solche Patterns. Sie
zaehlen weiterhin als Noise und erscheinen in der Breakdown-Tabelle (jetzt
mit Hinweis-Spalte "eskalations-befreit"), zaehlen aber nicht mehr zu
noise_threshold_exceeded. Neue/unerwartete laute Patterns eskalieren
weiterhin zur WARNUNG. Jede Befreiung traegt Begruendung + Recheck.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:15:10 +02:00
Micha 8bb250220b immich-ml: --no-control-socket gegen gunicorn-25.1.0 Worker-Hang
Ersetzt den wirkungslosen LD_PRELOAD-Versuch durch den dokumentierten
Root-Cause-Fix. immich_machine_learning blieb dauerhaft unhealthy: der
gunicorn-Worker haengt nach "Control socket listening" in futex und
erreicht nie "Application startup complete" (/ping -> Timeout). Ursache ist
der in gunicorn 25.1.0 neu eingefuehrte, fehlerhafte Control-Socket
(bestaetigt: gunicorn#3510, immich#27228, Regression seit Immich 2.6).

GUNICORN_CMD_ARGS=--no-control-socket deaktiviert das Feature. immich-ml
startet gunicorn als Subprozess (python -m gunicorn), der GUNICORN_CMD_ARGS
aus der Env liest und anhaengt; das Flag --no-control-socket
(config: control_socket_disable) ist in diesem gunicorn-Build als gueltig
verifiziert. Reversibel; bei gefixter Immich-Release wieder entfernen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:36:01 +02:00
Micha 2f64aee109 report: unhealthy-Container namentlich + Image-Age-Allowlist
Zwei Verbesserungen am Daily Operations Report, ausgeloest durch den
versteckten immich_machine_learning-Ausfall (lief 2,3 Tage unhealthy,
weil der Report nur "unhealthy=1" zaehlte, ohne Name/Grund):

1. collect_container_state: neue Sektion "Unhealthy Container" listet jeden
   unhealthy Container mit FailingStreak und letztem Healthcheck-Output.
   So ist sofort sichtbar WELCHER Container und WARUM.

2. collect_image_freshness: neue Image-Age-Allowlist
   (image-age-allow.patterns). Bewusst gepinnte, aber aktuelle/empfohlene
   Images (immich_postgres = exakt Immichs Pin; blackbox-exporter v0.28.0 =
   latest) werden mit Recheck-Datum von der Ueberalterungs-Warnung
   ausgenommen. Nach Ablauf des Recheck-Datums greift die Ausnahme nicht
   mehr -> erzwingt Neubewertung statt stillen Alterns. Top-10-Tabelle hat
   jetzt eine Hinweis-Spalte (ueberaltert / bewusst gepinnt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:08:44 +02:00
Micha ed55b88ec1 immich-ml: LD_PRELOAD leeren gegen gunicorn-25.1.0-Fork-Deadlock
immich_machine_learning haengt seit dem 7.6. unhealthy: der gunicorn-Worker
bleibt nach "Control socket listening" in futex_do_wait stehen und erreicht
nie "Application startup complete" (/ping -> ConnectTimeout/ReadTimeout).
Kein OOM (22 GB frei), kein Disk-I/O-Wait, laeuft als root, Socket wird
erstellt - klassischer Fork-Deadlock von mimalloc (LD_PRELOAD) im geforkten
Worker unter gunicorn 25.1.0.

mimalloc per LD_PRELOAD="" deaktiviert. Reine Allocator-Optimierung,
funktional unkritisch, reversibel. Bekannte Upstream-Regression seit
Immich 2.6 (immich#27228, #22317) ohne offiziellen Fix; Restart und
force-recreate sind dort als wirkungslos dokumentiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:02:27 +02:00
Micha ce747f687f ops-report: cert-dedup, blackbox-DNS auf AdGuard, neue Noise-Patterns
Behebt drei Befunde aus dem Operations-Report 2026-06-10:

- daily-status-report.sh: Zertifikate werden vor der Auswertung pro
  Domain-Set dedupliziert; nur das laengstlaufende Cert zaehlt. Traefik
  haelt waehrend der Erneuerung altes + neues Cert in acme.json, was
  bisher eine falsche KRITISCH-Warnung (traefik.kaleschke.info 5 Tage)
  ausloeste, obwohl das neue Cert 65 Tage Restlaufzeit hat.

- monitoring/blackbox-exporter: DNS von 1.1.1.1/8.8.8.8 auf AdGuard
  (172.23.0.3 via dns_net) umgestellt. Externe Resolver lieferten die
  WAN-IP, was Hairpin-NAT-Timeouts (9,5s) bei Probes von cloud/glances
  verursachte (662 Fehler/Tag).

- log-noise.patterns: Fritz!Box-SOA-Fehler (AdGuard, RFC-1035-Verstoss)
  und fehlendes grafana-amazonprometheus-datasource-Plugin als bekanntes
  Rauschen klassifiziert (~1800 Zeilen/Tag).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:06:52 +02:00
renovate cf11b4d75b chore(deps): update mongo:8.0.23 docker digest to 73ee318 2026-06-10 04:21:10 +00:00
Micha 796901ec6b docs(network): Post-Upgrade Posture-Recheck Unraid 7.3.1 + AdGuard/libvirt-:53-Vorfall
Nach Major-Upgrade 7.2.4 -> 7.3.1 read-only Host-Listener gegen dokumentierte
Annahmen geprueft: alle Ausnahmen intakt (InfluxDB 127.0.0.1:8181, AdGuard nur
Tailscale, Gitea-SSH 222 LAN/TS, Traefik einziger 80/443-Owner, libvirt :53 weg).
Docker-Socket-Lage festgehalten (nur komodo-periphery RW; Traefik C-3 ro, kein Regress).
AdGuard-Boot-Race (libvirt-Default-Netz belegte :53 vor AdGuard) + Fix dokumentiert;
Dauerfix-Empfehlung VM-Manager aus. SSH-Haertung nach Upgrade verifiziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:26:59 +02:00
Micha de7b714b4d docs(network): SSH-Host-Haertung dokumentieren (key-only root, upgrade-sichere Persistenz)
Host gehaertet 2026-06-07: PermitRootLogin prohibit-password,
PasswordAuthentication no, KbdInteractiveAuthentication no; PubkeyAuthentication yes.
Persistenz upgrade-sicher via idempotentem /boot/config/ssh-harden.sh aus
/boot/config/go (sshd -t vor HUP-Reload, Syslog-Selbst-Verifikation).
Manueller Post-Upgrade-Check und Rollback dokumentiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 11:02:07 +02:00
Micha 8045e22873 authelia-oidc: Immich+Nextcloud bis Onboarding geparkt; aktive Phase abgeschlossen
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:18:54 +02:00
Micha 52f8c2adcb posture-check: Tailscale-Docker aus CRITICAL_CONTAINERS entfernen (Container abgebaut)
Verhindert Dauer-ntfy-Alarm fuer den entfernten userspace-Docker-Tailscale. Natives Tailscale-Plugin ist kein Container und wird hier bewusst nicht geprueft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:15:07 +02:00
Micha 0ddae675a8 plex: add web redirect for public route 2026-06-06 13:45:47 +02:00
Micha 7ce8e948cd plex: route host network service via traefik file 2026-06-06 13:44:22 +02:00
Micha 2a87220862 plex: expose via traefik domain 2026-06-06 13:41:39 +02:00
Micha f2d4cad566 paperless: Authelia OIDC SSO additiv (allauth, extra_hosts)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 13:41:16 +02:00
Micha e7370e4820 authelia-oidc: Mealie erledigt + extra_hosts-Gotcha dokumentieren
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 13:37:34 +02:00
Micha dc26eb313c mealie: extra_hosts auth.kaleschke.info -> Host-IP fuer OIDC-Erreichbarkeit
Mealie-Container konnte auth.kaleschke.info nicht aufloesen/erreichen (httpx.ConnectTimeout beim OIDC-Discovery). extra_hosts-Muster wie Komodo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 13:34:09 +02:00
Micha dc7cbfa6cd mealie: Authelia OIDC SSO additiv (lokaler Login bleibt)
OIDC_AUTH_ENABLED + Authelia-Provider, Secret via ${MEALIE_OIDC_CLIENT_SECRET} (Stack-ENV). Kein Auto-Redirect, Self-Signup an. Authelia-Client mealie (one_factor) host-seitig angelegt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 13:29:01 +02:00
Micha cf9ca59eb1 docs: close baerchen veeam recovery test 2026-06-06 13:27:31 +02:00
Micha d2a9c3b8cb docs: record baerchen veeam recovery usb boot 2026-06-06 13:25:53 +02:00
Micha 0177350e64 docs: close guest iot network setup 2026-06-06 13:23:35 +02:00
Micha 2f3a029098 authelia-oidc: Grafana-Proof als erledigt dokumentieren + Secret eintragen
- SECRETS_MAP: grafana_oidc_client_secret (Datei + __FILE, Hash in Authelia-Host-Config)
- AUTHELIA_OIDC_PLAN: Stufe 1 (Grafana) als erledigt markiert
- MASTER_TODO: OIDC-Proof verifiziert, naechster Schritt Familien-Apps

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 13:17:29 +02:00
Micha a4c79d9d81 docs: record guest iot preflight 2026-06-06 13:14:07 +02:00
Micha 18a90fbb4b ops: add guest iot network preflight 2026-06-06 13:13:01 +02:00
Micha 30f076c85a monitoring/grafana: OIDC-SSO via Authelia (Stufe-1-Proof)
- generic_oauth gegen Authelia (client_id grafana, PKCE, client_secret via __FILE aus /mnt/user/appdata/secrets/grafana_oidc_client_secret)
- Traefik-Middleware authelia@file entfernt -> OIDC ist jetzt die Auth; lokaler Grafana-Admin bleibt Fallback
- Authelia-Client wurde host-seitig angelegt (Secret nur als Host-Datei + Hash in Authelia-Config)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 13:11:00 +02:00
Micha 6e65f81503 docs: record restore freshness negative alert test 2026-06-06 13:04:42 +02:00
Micha 6123584a02 ops: make freshness negative ntfy call robust 2026-06-06 13:03:05 +02:00
Micha c33e29016b ops: add restore freshness negative alert test 2026-06-06 13:02:14 +02:00
Micha 2628a0c795 authelia-oidc: Plan + Runbook fuer app-uebergreifendes SSO
- docs/AUTHELIA_OIDC_PLAN.md: v4.39-Client-Schema, Endpoints, Secret-Erzeugung, Rollout-Reihenfolge (Grafana-Proof zuerst, dann Familien-Apps), Grafana-Schritt-fuer-Schritt
- MASTER_TODO: OIDC-Punkt auf Plan verweisen, naechster Schritt Grafana-Proof
- README: Doku-Index ergaenzt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:58:38 +02:00
Micha c7eed6bdad todo: Authelia Rest-2FA als komplett erledigt markieren (Host-Merge + 2FA-Login verifiziert)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:55:23 +02:00
Micha 6c61ad3860 authelia: Repo-Baseline an Host-2FA-Endzustand angleichen
Expliziten 2FA-Block auf files/scrutiny reduziert (borg/code sind via
Catch-all *.kaleschke.info=two_factor weiterhin 2FA). Damit matcht die
Repo-access_control-Sektion den Host-Stand -> authelia-diff.sh wird clean,
sobald der Host-Repo-Mirror auf diesen Commit gezogen ist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:53:20 +02:00
Micha 2d1b541847 todo: offene Operator-Entscheidungen abschliessen; Authelia alle UIs auf 2FA
- BitLocker baerchen: bewusst deaktiviert
- Veeam Storage Encryption: bewusst unverschluesselt
- Stromverbrauch: bewusst ohne Messung (geschlossen)
- Nextcloud 2FA: geparkt bis OIDC die App-Login-Ebene erreicht
- Authelia: Catch-all *.kaleschke.info one_factor -> two_factor (Repo-Baseline; Host-Merge + restart + authelia-diff.sh als aktiver Schritt offen)
- Authelia OIDC und Gast-/IoT-Netz als aktive Bloecke aufgenommen
- MASTER_TODO: Operator-Entscheidung-Sektion ohne offene Punkte

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:32:52 +02:00
Micha c3491eb382 tailscale: auf natives Plugin konsolidieren, redundanten Docker-Stack entfernen, ACL-Haertung dokumentieren
- host-services/tailscale/ (userspace-only Docker-Stack) entfernt; Komodo stop/destroy durch Operator, danach git rm
- Glance-Widget Tailscale-Docker entfernt
- HOMELAB_ARCHITECTURE/SERVICE_CATALOG/DISASTER_RECOVERY/CLAUDE/RESTORE_MATRIX: tailscale als natives Unraid-Plugin dokumentiert; Restore-State-Pfad korrigiert auf /boot/config/plugins/tailscale/state (Flash-Backup)
- NETWORK_INVENTORY: restriktive tag-basierte grants-ACL (2026-06-06; tag:server/tag:operator, tag:family vorbereitet) und Subnet-Router-Befund dokumentiert

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 10:58:59 +02:00
Micha 023ee63687 docs: close dr workstation kit 2026-06-06 10:11:17 +02:00
Micha 3a263a4846 docs: update dr workstation readiness 2026-06-06 09:17:23 +02:00
Micha 68d3ace598 ops: add dr workstation readiness check 2026-06-06 08:40:31 +02:00
Micha 0ef98a23e1 docs: close baerchen app license checks 2026-06-06 08:31:17 +02:00
Micha 6353da47c5 ops: add baerchen app license readiness check 2026-06-06 08:27:10 +02:00
Micha 207f49f001 docs: retire home assistant influx todo 2026-06-06 08:22:27 +02:00
Micha a687d9b73e docs: record redis restore test 2026-06-06 08:11:03 +02:00
Micha e3459c76d0 fix: use redis pre-cutover restore artifact 2026-06-06 08:08:52 +02:00
Micha 254eb81496 ops: add redis restore test 2026-06-06 08:07:11 +02:00
Micha 9a6d7123ce docs: record adguard restore test 2026-06-06 08:03:53 +02:00
Micha 151d253aff ops: add adguard restore test 2026-06-06 08:01:27 +02:00
Micha dda6021116 docs: record tailscale acl plan and watcher activation 2026-06-05 23:27:40 +02:00
Micha 2f3d184a3b ops: prepare docker critical events watcher 2026-06-05 22:25:23 +02:00
Micha bc3ecad45a backup: windows image baseline for baerchen 2026-06-05 22:19:27 +02:00
Micha 88a42f3f78 audit: read-only system audit 2026-06-05
Windows-Host baerchen (frisch aufgesetzt) und Laufwerksstruktur geprüft.
Rohdaten unter audit/raw/, Bericht unter docs/audit/system-audit-2026-06-05.md.
Homelab-Server-Abschnitt ausstehend (SSH-Bestätigung fehlt).
2026-06-05 09:01:27 +02:00
Micha af2c6ee533 docs: record final games partition state 2026-06-04 17:37:11 +02:00
Micha f382c25696 docs: record post reboot boot check 2026-06-04 17:30:20 +02:00
Micha d710a506e8 docs: record boot cleanup execution 2026-06-04 17:26:55 +02:00
Micha 2ea65e906d docs: add boot cleanup plan 2026-06-04 15:06:58 +02:00
Micha 2d438cf02b docs: add drive restructure review follow-up 2026-06-04 14:40:42 +02:00
Micha 7ba10c893b docs: document drive restructure status 2026-06-04 14:25:10 +02:00
Micha fb948ac951 docs: add windows postdelta handoff 2026-06-04 11:51:22 +02:00
Micha 9ca6e47472 docs(dr): wsl2 + borg setup-runbook fuer den gaming-pc
Schritt-fuer-Schritt Runbook fuer den letzten verbleibenden P1-Operator-
Punkt: WSL2 + Borg-Client + SSH-Keys + Quartals-Smoke-Skript auf dem
Operator-Gaming-PC einrichten.

7 Schritte, ~30-60 Min einmaliger Aufwand. Inhalt:
- WSL2 Ubuntu installieren
- borgbackup installieren
- Hetzner-DR-Key aus offline-USB nach ~/.ssh kopieren
- borg list Smoke gegen Hetzner Storage Box
- GitHub-Deploy-Key analog
- dr-smoke.sh Quartals-Skript ablegen
- Bestaetigung in EXTERNAL_DEPENDENCIES und AUDIT-Restliste nachziehen

Troubleshooting-Sektion fuer die haeufigsten Stolpersteine
(WSL-Update, Key-Permissions, Port-23-Block, HTTPS-vs-SSH-URL).

REPO_MAP.md um Verweis auf das neue Runbook ergaenzt.

Wenn dieses Runbook abgearbeitet ist, sind alle vier Bare-Metal-DR-Pillars
produktionsreif.
2026-06-03 20:32:27 +02:00
Micha 38fa8c5dd5 docs(restore): nextcloud restore-test erfolgreich (2026-06-03)
Tier-2-Restore-Tests sind damit komplett belegt.

Verlauf:
- Lauf 1 (commit pre-fix): Borg-Extract+pg_restore ok, HTTP 503 wegen
  OC_Util.php:486 chmod-Fehlschlag auf shfs/FUSE
- Lauf 2 (commit 53c34dc, check_data_directory_permissions: false):
  HTTP 503 wegen fehlender .ncdata-Marker-Datei
- Lauf 3 (commit ba87719, .ncdata-Marker): SUCCESS

Endresultat:
- HTTP 200 von /status.php
- occ status maintenance: false
- 126 Tabellen in der wiederhergestellten DB
- Quelle: hetzner_borg_appdata_critical, Archiv
  Taegliche-Sicherung-2026-06-03T04:30:41.432
- Report: /mnt/user/backups/restore-reports/nextcloud-2026-06-03.md

Doku-Updates:
- RESTORE_MATRIX.md: Nextcloud-Zeile auf "2026-06-03 / quartalsweise"
  gezogen, Nextcloud aus "Naechste Restore-Test-Kandidaten" entfernt
- AUDIT_2026-05-25_TODO.md: Backlog-P1 und Operator-P1 beide auf
  "erledigt 2026-06-03"
- DR_DRILL_2026-06-03.md Folge-Iteration: X-1 als erledigt markiert

Restliche P1-Operator-Aufgabe: WSL2+Borg-Client auf DR-Workstation.
2026-06-03 19:35:43 +02:00
Micha ba87719de3 fix(restore): nextcloud-test create .ncdata marker in test data dir
Zweiter Lauf am 2026-06-03 ergab nach dem ersten Fix (config-Permissions)
einen neuen Fehler: HTTP 503 mit "Your data directory is invalid. Ensure
there is a file called .ncdata in the root of the data directory."

Hintergrund: Nextcloud prueft beim HTTP-Request eine Marker-Datei `.ncdata`
mit festem Inhalt im Datenverzeichnis. Produktiv liegt der Marker unter
/mnt/user/documents/nextcloud-data/.ncdata. Der Smoke-Test mountet diesen
Pfad bewusst nicht, also war das Test-data-Verzeichnis leer und Nextcloud
hat den Marker vermisst.

Fix: Marker vor dem Container-Start anlegen. Die anderen Tier-2-Tests
(Paperless, Mealie, Mail-Archiver) brauchten so etwas nicht, weil ihre
Apps keine entsprechende Validierungs-Pruefung haben.
2026-06-03 19:30:58 +02:00
Micha 53c34dca0e fix(restore): nextcloud-test disable check_data_directory_permissions
Erster Lauf am 2026-06-03 lief sauber durch alle Phasen (Borg-Extract,
pg_restore, Container alle gesund), schlug aber im HTTP-Smoke mit 503 fehl.
Ursache (aus dem preserved /mnt/user/backups/restore-lab/_failed/...):
- OC_Util.php:486 prueft die Permissions der data-Dir
- Skript hatte chmod -R a+rwX gesetzt (0777, letzte Stelle 7)
- Nextcloud versucht selbst chmod(0770) als www-data im Container
- Unraids shfs/FUSE lehnt chmod von Non-Root ab
- Nextcloud meldet "data directory readable by other people" -> 503

Fix: in der gepatchten config.php zusaetzlich
'check_data_directory_permissions' => false setzen. Nextcloud bietet
das in OC_Util:480 explizit als Opt-out an, fuer den isolierten Smoke
mit Wegwerf-Daten ist das vertretbar (kein Public, kein Traefik).
Produktiv bleibt der Check natuerlich an.

Patching erfolgt im bestehenden PHP-Injection-Block; idempotent (laeuft
keine Aenderung wenn beide Keys schon im config.php sind). Fallback-
sed-Pfad fuer Hosts ohne php ebenfalls erweitert.
2026-06-03 19:23:08 +02:00
Micha 7d87698715 docs(dr): Hetzner Storage Box DR-SSH-Key offline gesichert (2026-06-03)
Dritte der vier P1-Operator-Aufgaben aus dem DR-Tabletop teil-erledigt.
Die SSH-Schicht der DR-Workstation steht; verbleibend ist die
WSL2+Borg-Installation auf dem Gaming-PC.

Was passiert ist:
- ed25519-Keypair `dr-hetzner-2026-06-03` (Passphrase-frei) lokal erzeugt
- Public Key per `install-ssh-key` auf der Hetzner Storage Box autorisiert
- Smoke `ssh -p23 ... ls` passwortlos erfolgreich, vier Borg-Repos
  sichtbar (`backup`, `backup2`, `hetzner_borg_appdata`,
  `hetzner_borg_appdata_critical`)
- Private Key offline neben KOMODO_*-Notiz und GitHub-Deploy-Key gelegt
- Arbeitsplatz-Kopie nach USB-Transfer geloescht

EXTERNAL_DEPENDENCIES.md:
- DR-Workstation-Kit-Tabelle: SSH-Key-Zeile auf "offline gesichert"
- Review-Zeile 2026-06-03 erweitert mit Smoke-Ergebnis

AUDIT_2026-05-25_TODO.md:
- P1-Eintrag DR-Workstation umformuliert: SSH-Key ist erledigt,
  Verbleibend ist nur noch WSL2 + Borg-Client-Installation
- Eintrag unter "Zuletzt geschlossen" mit Wirkung

Stand der DR-Bare-Metal-Pillars:
1. KOMODO_*-Notiz offline                                       erledigt
2. GitHub-Mirror Read-Only Deploy-Key offline                   erledigt
3. Hetzner Storage Box DR-SSH-Key offline                       erledigt
4. WSL2 + Borg-Client auf DR-Workstation installiert            offen
5. Nextcloud-Restore-Test als letzte Tier-2-Luecke schliessen   offen
2026-06-03 19:10:01 +02:00
Micha c47639ecf4 docs(host): Fix Common Problems Plugin deinstalliert (2026-06-03)
Befund: Drei `grep -R ... /usr/local/emhttp`-Prozesse aus einem FCP-Daily-
Scan-Run hingen seit ~7 Tagen in einem Symlink-Loop. Unraids
`/usr/local/emhttp/mnt` ist ein Symlink nach `/mnt` (mehrere TB Array);
GNU `grep -R` dereferenziert Symlinks, also walking die FCP-Scan-Greps
effektiv das gesamte Array. 3 Cores dauerhaft 100 %, IOWAIT-Peaks 55 %,
USB-Flash unter Dauer-IO, Load 14.6 auf 12 Cores.

Massnahme: `plugin remove fix.common.problems.plg`. Cron, Plugin-Dir
und /tmp-Reste sauber. Load von 14.6 auf 1.08 (1-min) gefallen.

Entscheidung: FCP wird bewusst nicht reinstalliert. Begruendung im
Architektur-Master Sektion 13. Verbleibende Risiken decken Scrutiny,
Monitoring, Posture-Check und Critical-Events-Watcher bereits ab.

Repo-Aenderungen:
- HOMELAB_ARCHITECTURE_MASTER_V2.md Sektion 13: vollstaendiger
  Entscheidungs-Log-Eintrag mit Ursache, Massnahme, Begruendung
- AUDIT_2026-05-25_TODO.md "Zuletzt geschlossen": Kurzfassung

Host-Aenderung wurde via SSH durchgefuehrt (read+remove), keine
Compose-/Container-Aenderungen.
2026-06-03 16:29:33 +02:00
Micha b158f9d871 docs(dr): GitHub-Mirror Read-Only Deploy-Key gesichert (2026-06-03)
Zweite der vier P1-Operator-Aufgaben aus dem DR-Tabletop erledigt.

Was passiert ist:
- SSH-Keypair `dr-readonly-2026-06-03` (ed25519, Passphrase-frei) erzeugt
- Public Key in GitHub Repo Settings -> Deploy Keys ohne Write-Access
  hinterlegt (Title `DR Read-Only 2026-06-03`)
- Smoke `git ls-remote git@github.com:michaelkaleschke-spec/homelab-infra.git`
  erfolgreich (HEAD `d947c7f` matched origin/master)
- Private Key offline neben die KOMODO_*-Notiz gelegt
- Arbeitsplatz-Kopie auf dem Operator-PC nach USB-Transfer geloescht

EXTERNAL_DEPENDENCIES.md:
- GitHub-Mirror-Zeile von "noch nicht angelegt" auf "offline gesichert"
  gezogen, inkl. Deploy-Key-Bezeichnung und Smoke-Bestaetigung
- DR-Workstation-Kit-Tabelle: Quartals-Smoke-Befehl mit konkretem
  GIT_SSH_COMMAND-Aufruf dokumentiert
- Review-Zeile 2026-06-03 erweitert

AUDIT_2026-05-25_TODO.md:
- P1-Read-PAT-Eintrag aus offenen Punkten entfernt
- Eintrag unter "Zuletzt geschlossen" mit Wirkung

Zwei P1-Operator-Aufgaben bleiben offen: DR-Workstation-Setup,
Nextcloud-Restore-Test.
2026-06-03 16:13:29 +02:00
Micha d947c7f066 docs(dr): KOMODO_*-Notiz offline gesichert (Operator-Bestaetigung 2026-06-03)
DR-Tabletop-Followup: erste der vier P1-Operator-Aufgaben erledigt.

EXTERNAL_DEPENDENCIES.md:
- KOMODO_*-Notiz-Zeile von "noch nicht angelegt" auf "offline gesichert
  (Operator-Bestaetigung)" gezogen, mit Hinweis auf die Quelle der Werte
  (Self-Stack-.env unter /mnt/user/services/stacks/komodo bzw. die
  Drift-Recovery-Kopie vom 2026-05-04)
- DR-Workstation-Kit-Tabelle: Offline-Kopie-Status entsprechend aktualisiert
- Review-Zeile 2026-06-03 mit Bestaetigung ergaenzt

AUDIT_2026-05-25_TODO.md:
- P1-KOMODO_*-Notiz aus den offenen Punkten entfernt
- Eintrag unter "Zuletzt geschlossen" mit Quellenpfad und Wirkung

Drei P1-Operator-Aufgaben bleiben offen: GitHub-Read-PAT,
DR-Workstation-Setup, Nextcloud-Restore-Test.
2026-06-03 16:05:27 +02:00
Micha 9edd6c24e6 docs(dr): tabletop-folge - DR.md + EXTERNAL_DEPENDENCIES haerten
Reine Doku-Fixes nach DR-Tabletop 2026-06-03 und Operator-Antworten auf
vier offene Fragen.

DISASTER_RECOVERY.md:
- Abschnitt 3 Voraussetzungen: Operator-DR-Workstation als Pflichtposten
- Phase 0: privater GitHub-Mirror, Read-PAT/Deploy-Key, expliziter Repo-
  Bootstrap-Pfad Workstation -> Unraid
- Abschnitt 6.1: homelab_smtp_password.txt, n8n_encryption_key.txt,
  monitoring/influxdb/filebrowser Secrets nachgezogen
- Neuer Abschnitt 7.3: Borg-Extract ohne borg-ui (DR-Workstation oder
  docker run borgbackup/borg), Passphrase-Eingabe interaktiv
- Phase 4 neue Stufe 0 "Docker-Grundlage": docker network create
  frontend_net/backend_net/monitoring_net + dynamic/ Pre-Check
- Phase 4 Stufe 1: LE-Staging-Hinweis bei verlorenem acme.json
- Phase 4 Stufe 3 "Wichtige Stolperfallen": KOMODO_*-Quelle, Mongo-
  Datadir/Secret-Mismatch, extra_hosts-IP, Stack-ENV-Wiederherstellung
- Phase 5.3: App-DB-Verifikation per docker logs

EXTERNAL_DEPENDENCIES.md:
- GitHub-Mirror als privat klargestellt + Read-PAT/Deploy-Key Pflicht
- Operator-DR-Workstation als kritische Abhaengigkeit
- KOMODO_*-Notiz und GitHub-Read-PAT als noch nicht angelegt erfasst
- Hetzner-Maintenance-Key offline bestaetigt (Operator-Antwort 2026-06-03)
- Neuer Abschnitt "DR-Workstation Bare-Metal-Kit" mit konkretem Inhalt

AUDIT_2026-05-25_TODO.md:
- Vier neue P1-Operator-Aufgaben: KOMODO_*-Notiz, Read-PAT, DR-Workstation-
  Setup, Nextcloud-Restore-Test scharf laufen lassen

DR_DRILL_2026-06-03.md:
- Folge-Iteration-Tabelle: welcher Finding wo adressiert wurde

Operator-Aufgaben (nicht delegierbar) sind als P1 markiert. Nichts in
Runtime/Compose beruehrt, kein Container gestartet.
2026-06-03 16:00:00 +02:00
Micha 7a513e9fc8 docs(dr): tabletop drill 2026-06-03 - findings against DISASTER_RECOVERY
Kalter Lesetest gegen das Bare-Metal-Szenario aus DR.md Phase 0 bis 5,
mit referenzierten Runbooks (SERVICES_RECOVERY, RESTORE_MATRIX,
SECRETS_MAP, RESTORE_HANDBOOK, EXTERNAL_DEPENDENCIES) und Compose-Ankern
(ops/komodo, traefik).

23 Findings mit Severity, Repo-Datei + Zeile, Fix-Vorschlag pro Punkt.
1x CRITICAL (Unraid-Flash-Restore ohne laufenden Host), 11x HIGH, 8x MED,
3x LOW.

Schwerpunkte:
- Bare-Metal-Operator-Workstation als DR-Voraussetzung nicht dokumentiert
- Henne-Ei: KOMODO_* externe Notiz vs. Vaultwarden-Reihenfolge
- Externe Docker-Netze fehlen in DR.md Phase 4 Stufe 1
- borg-ui-Container als impliziter Borg-Client im Bare-Metal-Bootstrap
- Nextcloud-Restore-Skript ist da, ist aber noch nie real gelaufen (X-1)

Keine produktiven Pfade beruehrt, kein Container gestartet, keine
Skripte ausgefuehrt - reiner Doku-Drill.
2026-06-03 15:48:44 +02:00
Micha 4b96d13510 security(authelia): borg-ui und code-server auf two_factor heben
Beide UIs haben effektiv Host-/Backup-Zugriff (Borg-Restore-Scope inkl.
/local/secrets, code-server mit Workspace-Mounts). Bisher liefen sie ueber
die catch-all-Regel mit nur one_factor. Files und Scrutiny waren bereits
two_factor; die Liste wird konsistent gezogen.

Wirkung erst nach manuellem Host-Merge (Ausnahme laut docs/WORKFLOW.md):
1. /mnt/user/appdata/authelia/config/configuration.yml mergen
2. docker restart authelia
3. Smoke-Test auf einer der vier 2FA-Domains
4. services/authelia-diff.sh muss exit 0 liefern

Audit-Restliste nachgezogen: Tier-1-Operator-2FA geschlossen, restliche
geparkte Auth-Themen (OIDC, CrowdSec, Nextcloud-2FA) bewusst weiter offen
mit aktualisierter Begruendung.
2026-06-03 15:03:15 +02:00
Micha 642eb88b40 docs(restore): traefik restore successful - 11 of 12 tests green
Traefik-Restore am 2026-06-03 erfolgreich: dynamic/ (2 Files) +
letsencrypt/acme.json (426K) aus Borg, File-Provider-Boot, /ping 200.
Erster Versuch, kein shfs-Problem.

11 von 12 Restore-Tests sind jetzt gruen. Einzig Nextcloud bleibt
blockiert durch Unraids shfs-chmod-Inkompatibilitaet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 14:45:27 +02:00
Micha dd494046ce feat(restore): traefik restore smoke test
Borg-Extract von dynamic/ und letsencrypt/, Traefik startet mit
File-Provider gegen restaurierte Config, /ping Health antwortet.

Bewusst kein docker.sock (wuerde produktive Container discovern),
kein CF-Token (keine DNS-Challenge), keine produktiven Ports.
acme.json-Existenz und -Groesse wird geprueft, TLS-Validitaet nicht.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 14:42:56 +02:00
Micha 16d3b8f2fa docs(restore): mailarchiver restore successful, update matrix and backlog
Mail-Archiver-Restore am 2026-06-03 erfolgreich: Data-Protection-Keys
aus Borg + 645M pg_restore + HTTP 200. Erster Versuch, kein shfs-Problem.

10 von 12 Restore-Tests sind jetzt gruen. Verbleibend: Nextcloud
(blockiert/shfs-chmod) und Traefik (komplex, niedrigere Prio).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 14:08:28 +02:00
Micha a9b232195d feat(restore): mail-archiver restore smoke test
Borg-Extract der Data-Protection-Keys + pg_restore des 645M
mailarchiver-Dumps in isoliertes Test-Postgres + Container-Boot +
HTTP-Smoke. Wegwerf-DB-Connection und Auth-Password, kein produktiver
Stack-ENV, kein Authelia-ForwardAuth im Smoke.

Machbarkeit vorab verifiziert: Dump vorhanden, App-Image gepinnt,
Data-Protection-Keys im Borg, .NET-App hat kein shfs-chmod-Problem.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 14:01:47 +02:00
Micha 5ee4a158d6 docs(restore): mealie restore successful, update matrix and backlog
Mealie-Restore-Test am 2026-06-03 erfolgreich: Borg-Data + pg_restore
+ HTTP 200, 3 Rezepte im Test-DB-Check. Erster Versuch, kein
shfs-Problem (Mealie startet als root, kein chmod auf User Shares).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 13:54:05 +02:00
Micha 86435d4091 feat(restore): mealie restore test + freshness check negativ-test fix
Mealie-Restore-Test: Borg-Extract der App-Daten + pg_restore in
isoliertes Test-Postgres + Mealie-Boot + HTTP /api/app/about Smoke.
Machbarkeit vorab verifiziert (kein shfs-chmod-Problem, Mealie laeuft
als root und switcht intern auf PUID 99).

Freshness-Check: pg_header_ok() Docker-Fallback lieferte bei korruptem
Dump return 2 (unchecked) statt return 1 (invalid). Negativ-Test am
2026-06-03 bewiesen: korrupter mealie.dump wird jetzt als
DUMP_HEADER_INVALID erkannt (Critical, Exit 1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 13:49:51 +02:00
Micha 5e52316fab fix(restore): freshness check pg_header_ok returns 1 on corrupt dump
Negativ-Test 2026-06-03: korrupter mealie.dump wurde nicht erkannt,
weil der Docker-Fallback-Pfad nach gescheitertem pg_restore --list
zu return 2 (unchecked) durchfiel statt return 1 (invalid).

Fix: explizites if/else statt &&-Kette, damit fehlgeschlagene
Header-Validierung return 1 liefert und als DUMP_HEADER_INVALID
in den Critical-Zaehler geht.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 13:47:08 +02:00
Micha 8a4df239fa fix(restore): pin komodo test mongo to 8.0.23 matching production
Produktive Mongo ist 8.0.23, Test-Composes pinnten noch 7.0.32.
Eliminiert die Cross-Version-Warnung beim mongorestore.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 13:44:58 +02:00
Micha 893b34a585 docs(restore): shared pg cluster drill successful, all 5 DBs restored
Shared PostgreSQL 18 Cluster Restore Drill am 2026-06-03 erfolgreich:
Globals + 5 per-DB Custom-Format-Dumps, 290 Tabellen gesamt,
data_checksums=on. Alle P1-Backlog-Punkte sind damit erledigt.

Ergebnis pro DB:
- paperless:    72 Tabellen
- mailarchiver:  1 Tabelle
- authelia:     25 Tabellen
- nextcloud:   126 Tabellen
- mealie:       66 Tabellen

Mailarchiver-Bootstrap-Rollenkonflikt wurde wie dokumentiert toleriert.
Lauf dauerte ~14 Minuten (mailarchiver.dump = 645M).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 13:17:35 +02:00
Micha d1f9491b24 feat(restore): shared postgresql 18 cluster restore drill
Kompletter Restore-Drill fuer den Shared-PostgreSQL-18-Cluster:
globals (Rollen) + 5 per-DB Custom-Format-Dumps (paperless,
mailarchiver, authelia, nextcloud, mealie).

Bekannter mailarchiver-Bootstrap-Rollenkonflikt wird toleriert.
Authelia/Nextcloud/Mealie-Dumps als optional markiert.
Tabellen-Count pro DB als fachlicher Sanity-Check.

Machbarkeit vorab verifiziert: alle Dumps auf Host vorhanden,
pg_restore im postgres:18.4-Image verfuegbar, Postgres auf shfs
bewiesen durch bestehende Tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 13:02:16 +02:00
Micha 14de2f4801 docs(restore): komodo mongo restore successful, update matrix and backlog
Komodo-Mongo-Daten-Restore am 2026-06-03 erfolgreich: mongorestore
von komodo-mongo.archive.gz in Wegwerf-Mongo, 86904 Dokumente
(inkl. 32 Stack-Definitionen). Damit ist die kanonische Quelle fuer
KOMODO_*-Stack-ENV-Werte im DR-Fall als wiederherstellbar belegt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:25:32 +02:00
Micha 90d1595285 fix(restore): komodo mongo restore own compose to avoid container name collision
Zweiter Lauf scheiterte mit Auth-Failure weil der Container-Name
restoretest-komodo-mongo mit dem alten Bootstrap-Test kollidierte
(stale Datadir auf shfs mit anderen Credentials).

Fix: eigenes Compose mit eigenem Container-Namen
(restoretest-komodo-mongorestore) und eigenem Project-Name, damit
keine Namenskollision mit dem bestehenden Bootstrap-Test entsteht.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:23:17 +02:00
Micha c1985e177b fix(restore): komodo mongorestore --noIndexRestore for auth compat
Erstlauf 2026-06-03: 86904 Dokumente (inkl. 32 Stack-Dokumente)
erfolgreich restored, aber Exit 1 weil der Index-Rebuild mit
"Command createIndexes requires authentication" scheitert (Test-User
hat keine dbAdmin-Rolle).

Fix: --noIndexRestore. Fuer den Smoke-Zweck (Stack-Definitionen lesbar,
KOMODO_*-ENV-Werte rekonstruierbar) reicht das. Indexe werden bei einem
echten Komodo-Restart ohnehin neu aufgebaut.

Nebenbefund: produktive Mongo ist 8.0.23, Test-Compose pinnt 7.0.32.
Cross-Version-Warning ist fuer den Lesetest harmlos, aber der
Bootstrap-Compose-Pin sollte separat auf 8.0 nachgezogen werden.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:20:53 +02:00
Micha a244f2d677 feat(restore): komodo mongo data restore test
Neuer Test: mongorestore von komodo-mongo.archive.gz in eine frische
Wegwerf-Mongo. Beweist, dass die Stack-Definitionen und damit die
KOMODO_*-Stack-ENV-Werte aus dem Dump rekonstruiert werden koennen
(kanonische Quelle laut docs/DISASTER_RECOVERY.md 6.2.1).

Machbarkeit vorab verifiziert: Dump 6.0M auf Host vorhanden,
mongorestore im mongo:7.0.32-Image verfuegbar, shfs-Write funktioniert.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:18:39 +02:00
Micha ef032f2dde docs(restore): document nextcloud shfs-chmod blocker
Nextcloud-Restore-Test Erstlauf 2026-06-03 nach 5 Iterationen als
strukturell blockiert durch Unraid shfs/FUSE eingestuft.

Ursache: Nextcloud 33 fuehrt zur Laufzeit chmod() auf Dateien unter
/var/www/html aus (OC_Util.php#486). Auf Unraids FUSE/shfs User Shares
ist chmod nicht moeglich - weder vom Host (chown ignoriert) noch aus dem
Container (Operation not permitted), auch nicht ohne no-new-privileges.
In Produktion funktioniert Nextcloud, weil die Daten dort auf einem
Cache-Drive (XFS/BTRFS direkt) statt ueber shfs liegen.

Scaffold (Skript + Compose) bleibt im Repo als Ausgangspunkt fuer die
Loesung. Drei Optionen dokumentiert:
a) Restore-Lab auf Cache-Drive
b) Docker-Volumes statt Bind-Mounts
c) tmpfs + rsync

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:14:39 +02:00
Micha 6fec64d0a1 fix(restore): nextcloud dump from host path instead of borg extract
Erstlauf 2026-06-03: borg_extract fuer den Nextcloud-Dump scheiterte
still (Pfad local/borg-dumps/latest/nextcloud.dump existiert im
Archiv moeglicherweise unter einem anderen Prefix). Der Dump liegt
taeglich frisch auf dem Host unter /mnt/user/backups/borg/dumps/latest/
und wird von dort in Borg gesichert - der Smoke-Wert ist identisch.

HTML (App-Code + config) kommt weiterhin aus dem Borg-Archiv.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:03:45 +02:00
Micha 5d1ae68705 fix(restore): nextcloud permissions on unraid shfs (no-new-privileges removal)
Zweiter Erstlauf 2026-06-03 scheiterte weiterhin mit 503, obwohl
config.php korrekt gepatcht war.

Ursache: Unraid's FUSE/shfs-Dateisystem auf User-Shares ignoriert
chown -R 33:33 still — Dateien bleiben bei sshd:sshd. Der
Nextcloud-Entrypoint versucht intern chmod/chown auf /var/www/html und
/var/www/html/data, was mit no-new-privileges:true blockiert wird.

Fix:
- no-new-privileges vom restoretest-nextcloud Container entfernt,
  damit der Entrypoint Rechte im Container selbst setzen kann
  (Test-Postgres und Test-Redis behalten no-new-privileges)
- Host-seitiger chown durch chmod a+rwX ersetzt (funktioniert auf shfs)
- Vertretbar im isolierten Smoke-Kontext (127.0.0.1, Wegwerf-Daten,
  kein Traefik)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 10:55:56 +02:00
Micha 2913e1005f fix(restore): nextcloud chown 33:33 for www-data after borg extract
Erstlauf 2026-06-03 scheiterte mit dauerhaft 503. config.php-Patching
(Redis-Host + trusted_domains) war korrekt, aber Nextcloud konnte die
restaurierten Dateien nicht lesen/schreiben: "chmod(): Operation not
permitted at OC_Util.php#486".

Ursache: Borg-Extract ueber den borg-ui Container legt Dateien mit dem
borg-ui-User (sshd o.ae.) an. Nextcloud im Container laeuft als
www-data (UID 33). Mit no-new-privileges:true scheitert jeder chmod/
chown-Versuch im Container.

Fix: chown -R 33:33 auf html/ und data/ nach dem Extract, bevor der
Nextcloud-Container startet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 10:44:12 +02:00
Micha 6f0e6f0d5a fix(restore): nextcloud config.php patching for redis host and trusted_domains
Erstlauf 2026-06-03 scheiterte mit 503: Redis-Host war noch auf dem
produktiven 'nextcloud-redis' statt 'restoretest-nextcloud-redis', und
trusted_domains enthielt kein 127.0.0.1 (Nextcloud blockt mit
"Access through untrusted domain").

Ursache: das sed-Pattern fuer Redis versuchte den ganzen Array-Block
einzeilig zu ersetzen, traf aber das PHP-Mehrzeilenformat nicht. Und
das trusted_domains-sed fand das Schliessmuster nicht zuverlaessig.

Fix:
- Redis-Host separat per sed patchen (nur den 'host'-Wert im Block)
- trusted_domains per PHP-CLI rewrite (robuster als sed auf PHP-Arrays)
- Fallback auf sed fuer Hosts ohne php

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 10:34:30 +02:00
Micha f473fbaa8b feat(restore): nextcloud restore smoke test scaffold
Nextcloud-Restore-Test nach dem Muster der anderen Restore-Smokes:
- Borg-Extract von html (App-Code + config.php) und nextcloud.dump
- pg_restore in isoliertes Test-Postgres (mit Retry-Schleife)
- config.php wird im Restore-Lab auf Test-DB-Credentials gepatcht
  (produktive Secrets werden nicht gemountet)
- Nextcloud startet gegen restaurierte Daten + Test-Redis
- Smoke prueft HTTP /status.php und occ status (maintenance mode)
- Produktive Nutzdaten unter /mnt/user/documents/nextcloud-data
  werden bewusst NICHT gemountet (zu gross fuer regelmaessigen Smoke)

Erster Lauf steht aus und braucht Operator-Freigabe auf dem Host.

Dispatcher und ntfy-Wrapper um Nextcloud erweitert.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 10:05:10 +02:00
Micha c922d1f241 docs(restore): finalize audit - handbook update, reifegrad matrix, backlog
Schliesst das Restore-Skills-Audit 2026-06-02/03 ab:

- RESTORE_HANDBOOK.md auf Stand 2026-06-03: alle 6 verifizierten Tests
  (Vaultwarden, Gitea, Paperless, Immich, Authelia, Komodo-Bootstrap)
  dokumentiert, Frequenz-Tabelle aktualisiert, Betriebsmodus auf V1+
  (mit ntfy), Schnellstart um Immich/Authelia/Komodo ergaenzt,
  Report-Aufbewahrungsregel dokumentiert, Ausbaustufen priorisiert.

- RESTORE_MATRIX.md: neue Sektion "Restore-Test-Reifegrad" mit
  Uebersichtstabelle (pro Dienst: Tier, letzter Test, Typ, naechster
  Lauf) und priorisierter Kandidatenliste fuer fehlende Tests.

- Gitea-Restore: SSH-Check im Report korrekt als "TCP connect only"
  benannt statt "SSH port open" (war Audit-Finding M3).

- AUDIT_2026-05-25_TODO.md: Restore-Audit-Backlog ergaenzt mit den
  verbleibenden 8 offenen Punkten (Nextcloud, Shared PG18, Komodo-Mongo,
  Mailarchiver, Mealie, Traefik, Negativ-Test, E2E-DR-Drill).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 09:31:19 +02:00
Micha ba3ef8fcfc docs(restore): mark authelia smoke successful and schedule 2026-06-03 08:55:04 +02:00
Micha 52fc007123 fix(restore): authelia smoke without dump-restore, drop bogus env, disable ntp
Erstlauf 2026-06-03 hat einen by-design-Konflikt offengelegt: pg_restore des
produktiven postgresql17-authelia.dump in eine Test-Instanz mit Wegwerf
AUTHELIA_STORAGE_ENCRYPTION_KEY scheitert im Authelia-Startup-Check mit
"the configured encryption key does not appear to be valid for this database".
Productive Storage-Werte werden mit dem produktiven Key verschluesselt; ein
Wegwerf-Key kann sie nicht entschluesseln. Smoke ist deshalb explizit auf
Config-Restore + Boot reduziert, nicht Daten-Decrypt.

Zwei Nebenbefunde aus demselben Lauf:
- AUTHELIA__SERVER__ADDRESS (Doppel-Underscore) wurde von Authelia 4.39
  abgelehnt ("configuration environment variable not expected"). ENV
  entfernt; server.address kommt eh aus der generierten configuration.yml.
- ntp-Startup-Check schlug fehl ("Could not determine the clock offset
  ... lookup time.cloudflare.com on 127.0.0.1:53: server misbehaving"),
  weil das isolierte Test-Compose-Netz keinen DNS-Resolver fuer NTP hat.
  Neuer Test-Config-Block setzt ntp.disable_startup_check: true.

Doku nachgezogen (Plan + Runbook): Encryption-Key-Konflikt ist explizit
als "nicht Teil dieses Smokes" dokumentiert; Fehler-Matrix hat Eintraege
fuer Doppel-Underscore-ENV und NTP-Lookup.

Frische des produktiven authelia-Dumps wird unveraendert ueber
check-restore-freshness.sh ueberwacht; Daten-Decrypt-Drill ist eine eigene
DR-Aufgabe mit kontrollierter Schluessel-Verwendung.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 08:27:40 +02:00
Micha 8d71dfb9ad fix(restore): authelia smoke default_policy two_factor (rules-less)
Authelia 4.39 verlangt: ohne access_control.rules muss default_policy
'two_factor' oder 'one_factor' sein. 'bypass' war nur historisch zulaessig,
mit 4.39 schlaegt config validate fehl mit "'default_policy' option
'bypass' is invalid: when no rules are specified it must be 'two_factor'
or 'one_factor'". /api/health ist public und laeuft nicht durch
access_control - die Smoke-Semantik bleibt unveraendert.

Beobachtet im Erstlauf 2026-06-03 nach Refactor auf Minimal-Testkonfig
(Commits 541c7be..440000c). Mit diesem Fix sollte 'authelia config
validate' durchlaufen; HTTP /api/health-Smoke ist der Folgeschritt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 08:09:35 +02:00
Micha 440000c085 fix(restore): generate minimal authelia smoke config 2026-06-03 08:04:59 +02:00
Micha cacf77bfb0 fix(restore): avoid authelia smtp env in smoke test 2026-06-03 08:01:10 +02:00
Micha cd4dd178ed fix(restore): isolate authelia runtime config mount 2026-06-03 07:57:57 +02:00
Micha 541c7be853 fix(restore): generate sanitized authelia test config 2026-06-03 07:43:57 +02:00
Micha b1ae9f3c26 fix(restore): harden restore checks and add authelia smoke scaffold 2026-06-03 07:39:05 +02:00
Micha e2624796f0 fix: set vaultwarden DNS resolvers 2026-06-02 20:05:55 +02:00
Micha 9f63e6e3bc docs: archive rollback volumes after burn-in 2026-06-02 19:55:02 +02:00
Micha 8eb367f0b5 revert: remove social-to-mealie-plus stack 2026-06-02 19:44:35 +02:00
Micha 745761f518 feat: add social-to-mealie-plus stack 2026-06-02 19:17:59 +02:00
Micha ac637d30fb docs: record n8n encryption key restore source 2026-06-02 06:47:00 +02:00
Micha b0a6244e21 apps: pin super-productivity and n8n image digests 2026-06-02 06:44:03 +02:00
Micha 4fb17a09e6 apps: add n8n + mail-to-gitea-issue workflow (n8n.kaleschke.info) 2026-06-02 06:28:01 +02:00
Micha be5c68751f apps: add super-productivity stack (sp.kaleschke.info, Authelia) 2026-06-02 06:27:00 +02:00
Micha 3bfd065326 Update Scrutiny image digest 2026-06-01 16:42:31 +02:00
Micha eeebeec804 Switch Paperless GPT to OpenAI API 2026-06-01 16:18:58 +02:00
Micha 55fdb13532 Enable Vaultwarden SMTP invites 2026-06-01 15:52:31 +02:00
Micha 8709fe8239 Focus family onboarding on core apps 2026-06-01 15:25:48 +02:00
Micha 89114b1b12 Record append-only operator decision 2026-06-01 15:16:56 +02:00
Micha 3da19421d0 Document hetzner account hygiene 2026-06-01 15:09:37 +02:00
Micha 16e661be87 Document fritzbox config backup 2026-06-01 14:19:13 +02:00
Micha 12c05376d0 Close fritzbox service window docs 2026-06-01 13:02:03 +02:00
Micha dfd0ccbb9a Refine external IPv6 operator check 2026-06-01 12:51:16 +02:00
Micha ae5d4aedfc Prepare external operator checks 2026-06-01 12:48:00 +02:00
Micha 479eb291c4 Prepare final homelab cleanup gates 2026-06-01 12:19:17 +02:00
Micha c3222e800b Validate backup follow-up and harden nearline pull 2026-06-01 08:27:52 +02:00
Micha 4e34582008 Trim documentation to active runbooks 2026-05-31 23:26:12 +02:00
Micha ab8bfea7c8 Close documented backup follow-ups 2026-05-31 23:07:34 +02:00
Micha 92562dfc9c Archive stale documentation 2026-05-31 22:53:10 +02:00
Micha c9c8f9e7ce docs: add post migration burn-in check 2026-05-31 21:45:58 +02:00
Micha 1d98945a67 fix: make restore test scripts executable 2026-05-31 21:44:59 +02:00
Micha 9ffcb4e92e fix: dump active grafana database 2026-05-31 21:41:23 +02:00
Micha 99a0bfd60e docs: record grafana 13 renovate closure 2026-05-31 21:35:52 +02:00
Micha e835dfd6ed fix: let grafana read host secrets 2026-05-31 21:33:09 +02:00
Micha 6e928b6944 chore: harden grafana 13 provisioning 2026-05-31 21:31:58 +02:00
Micha 60015c1e2c chore: upgrade grafana to 13 2026-05-31 21:28:59 +02:00
Micha e1afd08bf3 docs: record closed renovate migration prs 2026-05-31 21:25:45 +02:00
Micha 268df30a13 chore: finish postgres redis stateful migrations 2026-05-31 20:32:25 +02:00
Micha 80a5ad24a2 Document closure of Mongo 8 PR 2026-05-31 14:34:46 +02:00
Micha 28406ae22b Constrain Komodo Mongo Renovate track 2026-05-31 14:33:19 +02:00
Micha 7b6c03b433 Document Komodo Mongo 8 upgrade 2026-05-31 14:31:47 +02:00
Micha 59b93924fb Update Komodo Mongo to 8.0 2026-05-31 14:23:30 +02:00
Micha aecf3b2807 Document Renovate cron follow-up 2026-05-31 13:26:40 +02:00
Micha 8e820ea155 Document Prometheus drift alert reload 2026-05-31 13:19:26 +02:00
Micha 16a266cd79 Add GitOps runtime image drift alert 2026-05-31 13:17:45 +02:00
Micha 69ad9d1d3c Document Renovate PR merge rollout 2026-05-31 13:04:06 +02:00
Micha 96fcacc6f7 Merge Renovate PR #5 postgres 17.10 update
# Conflicts:
#	apps/mealie/docker-compose.yml
#	apps/nextcloud/docker-compose.yml
#	infra/postgresql17/docker-compose.yml
2026-05-31 12:54:50 +02:00
Micha 076676d9b3 Merge Renovate PR #4 mongo 7.0.34 update
# Conflicts:
#	ops/komodo/docker-compose.yml
2026-05-31 12:50:12 +02:00
Micha dde441915a Merge Renovate PR #3 minor and patch updates 2026-05-31 12:43:58 +02:00
Micha db1fa7c3f0 Merge Renovate PR #2 postgres digest update 2026-05-31 12:37:55 +02:00
Micha b8b0af9e27 Merge Renovate PR #1 mongo digest update 2026-05-31 12:36:53 +02:00
Micha 4867d632d2 Document Gitea workspace drift repair 2026-05-31 12:27:07 +02:00
renovate 90ef6374a5 chore(deps): update minor-and-patch-updates 2026-05-31 10:20:19 +00:00
Micha e6a0e9fea4 Document Komodo 5xx client root cause 2026-05-31 11:26:40 +02:00
Micha 10ef703a4e docs: Codex-Prompt fuer Komodo-5xx Root-Cause-Suche
Selbst-enthaltener Stafettenstab nach Glance-Ausschluss (130s-Stop-Test):
Polling-Rate unveraendert mit Glance down. Restkandidaten dokumentiert
(Posture-Check, Periphery, Komodo-Self-Check, LAN-Geraet) plus konkrete
Testreihenfolge und Fix-Erwartung.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 10:56:58 +02:00
Micha 0c08d68d2b monitoring: HomelabPrometheusTargetDown + HomelabDiskCritical
Schliesst die zwei in ALERT_RULES.md identifizierten Hoch-Luecken:
- up==0 (5m) als critical in neuer Gruppe homelab-meta — Scrape-Targets
  (node-exporter/cadvisor/blackbox/traefik) sind nicht laenger stille
  Ausfaelle.
- Disk-Critical bei >95% (5m) als critical, zusaetzlich zum bestehenden
  Warning bei >85% — fuer DB/appdata/Cache-Schreibblockaden.

ALERT_RULES.md Tabellen und Status-Abschnitt aktualisiert.
Wird wirksam nach Prometheus-Reload via Komodo-Redeploy des monitoring-Stacks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 22:17:51 +02:00
Micha 73120869a7 docs: zentrale ALERT_RULES.md + Luecken-Analyse
Nachschlagetabelle aller Prometheus-Alarmregeln (Trigger/Schwelle/Severity/
Aktion) plus Bewertung der Abdeckung. Identifiziert zwei echte blinde Flecke
(kein up==0 Target-Down, kein Disk-Critical-Tier) mit fertigem PromQL als
Empfehlung. Cross-Ref aus ALERTING_MAP.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 16:36:45 +02:00
Micha 1503239881 Strategische Bewertung: sharpen banner + add 2026-05-30 status appendix
The original 2026-05-23 baseline was kept as a historical anchor but
the banner was too soft about how much of the concrete content is
already addressed. Reading the document standalone could mislead it
as a current TODO list.

Two changes, original text untouched:

1. Banner now explicitly says the document is mostly outdated,
   not to be read as a TODO list, and that the per-finding status
   lives in an appendix.

2. New "Status-Anhang 2026-05-30" at the end maps every concrete,
   actionable finding to its current state (erledigt / geparkt /
   entschieden nicht / offen / teilweise), grouped by the original
   sections (Block 1-8) and by the Top-5 lists and Phase-1-to-4
   roadmap.

Summary of what the appendix shows:
- Top 5 sofort: 5/5 erledigt
- Quick Wins: 6/7 erledigt, 1 geparkt
- Phase 1: 4/6 erledigt, 1 geparkt, 1 wartend
- Phase 2: 2/5 erledigt, 2 geparkt, 1 offen
- Phase 3: 1 entschieden-nicht, 1 teilweise, 3 offen
- Auth-Block (F-04/13/14/18): fully parked

Original "Schulnote 2-" no longer reflects reality; new note would
land at 1- to 2 but is not the point.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 12:48:36 +02:00
Micha 5c211faf87 Promote Codex notes to tracked docs with status banners
The three notes from 2026-05-23 had been sitting untracked in docs/
for a week. Variante A from today's review: keep them in docs/ with
explicit status banners and reference them from REPO_MAP.md, so they
stop being silent roommates and become discoverable.

- docs/STRATEGISCHE_BEWERTUNG_2026-05-23.md: historical baseline that
  kicked off the 2026-05-25 audit cycle. Permanent audit anchor and
  "where we stood on 2026-05-23" snapshot. Do not edit further.
- docs/CODEX_KONSOLIDIERUNG_2026-05-23.md: first Codex prompt for the
  audit cycle, content worked through; kept as a Codex-prompt
  template for future consolidation sweeps.
- docs/CODEX_JELLYFIN_REMOVAL_2026-05-23.md: Codex removal pattern,
  task executed 2026-05-25; kept as a template for future stack
  removals (Hermes review 2026-07-25, possibly BentoPDF / paperless-gpt
  follow-ups).

REPO_MAP.md "Wichtige Dokumente" now lists all three with one-line
purpose plus the F-19 prep doc committed earlier today.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 12:43:12 +02:00
Micha f2923aac62 F-19 prep: document mem-limits baseline plan (no compose changes)
ops/policy-checks/mem-limits-baseline.md captures the deliberate
"not today" decision for memory limits plus the plan for when it
becomes relevant:

- Phase 1: 7 days of hourly docker stats snapshots
- Phase 2: derive Tier-1 peak per container
- Phase 3: set limits at peak * 1.5 with documented floors
  (Postgres 1G, Mongo 1G, Redis 256M, etc.)
- Phase 4: roll out smallest-risk containers first, observe 24h
  between stages
- Phase 5: Tier-2 only after a concrete trigger event

Next trigger: family invitation out + 4 weeks stable use, or
first real OOM event in docker-critical-events.sh, or a sudden
Immich/Nextcloud load spike where host swap becomes visible.

Today's policy check is clean (0 Critical, 1 documented Warning
on influxdb3-core user 0, 13 documented Info findings on host
ports / privileged exceptions / latest+digest tags).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 11:58:54 +02:00
Micha 67ec40b762 Docs sweep: reflect Komodo bootstrap first run + clean stale "still open" notes
Six files had outdated status notes that the F-09 first run on
2026-05-30 made wrong:

- ops/restore-tests/komodo-bootstrap-runbook.md: "Erster echter Lauf
  steht noch aus" -> first run confirmed
- ops/restore-tests/komodo-bootstrap-plan.md: "Noch offen vor dem
  ersten echten Lauf" section -> "Bestaetigte Laeufe" table with
  the --what-if and --keep-data runs
- ops/restore-tests/immich-runbook.md: status note still said
  "Erster echter Lauf steht noch aus" although the Immich first run
  was 2026-05-27; correcting in the same sweep
- docs/AUDIT_2026-05-25_TODO.md: Sprint 2 entry on Komodo bootstrap
  path no longer carries the "Trockenlauf-Skript bleibt als offene
  Folgeaufgabe" tail
- docs/SERVICES_RECOVERY.md: replaced the "Trockenlauf-Idee (Doku-only,
  nicht ausgefuehrt)" section with the confirmed repo-script flow and
  marked the two "Naechste Aufgaben" rows about the dry-run as done
- docs/RESTORE_DRILL_ROUTINE.md: Q2 2026 DR-Sanity-Check entry now
  splits Komodo-Bootstrap-Pfad (done) from the two still-open items
  (Gitea bundles, secrets inventory)

No behavior change, only documentation consistency.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 11:18:37 +02:00
Micha abf7137aea F-09 Rest: Komodo bootstrap dry-run first real execution
Result on host: SUCCESS, all 5 smoke checks green.
- docker compose config valid
- Test-Mongo healthy in ~6s
- Mongo authenticated ping ok (Test-Creds)
- Komodo Core HTTP 200 on 127.0.0.1:19120
- Test-Periphery container state running

Production komodo-{mongo,core,periphery} and /mnt/user/appdata/komodo/
were not touched; test ran in isolated project restoretest-komodo with
disposable datadir under /mnt/user/backups/restore-lab/komodo/.
Report at /mnt/user/backups/restore-reports/komodo-bootstrap-2026-05-30.md.

Operator-click pattern preserved: SSH to root@kallilabcore is an action
class that requires explicit instruction per CLAUDE.md; the auto-mode
classifier correctly blocked a non-destructive SSH probe. Operator ran
the command via the Unraid web terminal.

ops/komodo/docker-compose.yml is now demonstrably viable as the recovery
anchor for the bootstrap stages in docs/SERVICES_RECOVERY.md, not just
assumed viable. Image digests (mongo:7.0.32, komodo-core:2,
komodo-periphery:2) and Mongo auth schema verified.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 11:14:20 +02:00
Micha 8095ab8b5d F-10: automated Authelia repo<->host drift check
New services/authelia-diff.sh compares the access_control: section of the
repo baseline against the live host configuration.yml. OIDC clients,
identity providers, and secret values stay out of scope by design.
Exit codes: 0 ok, 1 drift, 2 file missing, 3 section missing, 4 tool missing.

posture-check.sh gains check_authelia_config_drift, which calls the diff
script and reports drift as warning (not critical). SKIP_AUTHELIA_DRIFT=1
opts out; AUTHELIA_DIFF_SCRIPT overrides the path.

WORKFLOW.md gets a dedicated "Ausnahme: Authelia configuration.yml" section
analogous to the Traefik dynamic-config exception, with the mandatory
repo->host merge workflow and the env-variable contract.

Smoke-tested locally: identical files rc=0, ACL change rc=1 with proper
unified diff, non-ACL change (session.default_redirection_url) correctly
ignored.

Operator follow-up: set up a read-only repo mirror at
/mnt/user/services/homelab-infra/ so the check finds a current baseline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 09:52:16 +02:00
Micha 3bd35434d6 Renovate live: first run produced 5 PRs + dashboard
Setup-Pfad final geworden, vier Reparaturen unterwegs:

1. EAI_AGAIN: Container kann git.kaleschke.info nicht aufloesen ->
   --add-host (analog zur Komodo-extra_hosts)
2. Token-Sichtbarkeit in ps/inspect -> --env-file mit 0600 tempfile
3. EACCES auf State-Mount: Renovate-Image laeuft als uid 12021 ->
   chmod 0777 auf /mnt/user/services/renovate/state
4. "Repository does not permit pull or push": Renovate-Source-
   Code (lib/modules/platform/gitea/index.ts) prueft hardcoded
   repo.permissions.push aus der Gitea-API. Mein initialer
   SQL-INSERT in die collaboration-Tabelle hatte den Gitea-
   In-Memory-Permission-Cache nicht aktualisiert; Operator-
   UI-Klick "Entfernen + neu hinzufuegen" loeste den Cache-
   Refresh.

Konfigurations-Trennung:
- renovate.json (Repo): nur Repo-Settings (extends, packageRules,
  ignorePaths, manager file patterns, labels)
- ops/renovate/bot-config.js: Bot-Settings (platform, endpoint,
  autodiscover=false, repositories=[Micha/homelab-infra],
  Concurrent-Limits)

Bot-Felder in renovate.json fuehren zu "Repository is forbidden,
status: disabled" weil Renovate die Repo-Config nicht als Bot-
Config wertet.

Erstlauf am 2026-05-29: 5 PRs, 1 Dependency-Dashboard, 8 Branches.
Komodo-Major bleibt durch packageRule deaktiviert wie erwartet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 20:34:32 +02:00
renovate b38b5e2db3 chore(deps): update postgres docker tag to v17.10 2026-05-29 18:30:57 +00:00
renovate 75afde5935 chore(deps): update mongo docker tag to v7.0.34 2026-05-29 18:30:55 +00:00
renovate 70b1ffa190 chore(deps): update postgres:17.9 docker digest to 2a0d0fe 2026-05-29 18:30:12 +00:00
renovate 11a91d8a1e chore(deps): update mongo:7.0.32 docker digest to 8d727b3 2026-05-29 18:30:08 +00:00
Micha ad9267c66a Split renovate config: repo config in renovate.json, bot config in ops/
Renovate liest die repo-eigene renovate.json als REPO-Config, nicht
als BOT-Config. Bot-spezifische Felder (platform, endpoint,
repositories, autodiscover, gitAuthor, prHourlyLimit, ...) gehoeren
nicht hinein und werden als "this repo is forbidden / disabled"
fehlinterpretiert.

Saubere Trennung:
- renovate.json (Repo-Root): nur extends, packageRules,
  ignorePaths, manager file patterns, labels, rangeStrategy
- ops/renovate/bot-config.js: Plattform, Endpoint, Username,
  gitAuthor, autodiscover=false, repositories=[Micha/homelab-infra],
  Concurrent-/Hourly-Limits

bot-config.js statt config.json, weil Renovate Module-exports als
config-file akzeptiert (offizielle Variante).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 20:20:00 +02:00
Micha 489958af18 Use explicit repository list instead of autodiscover
Gitea's /api/v1/user/repos (which Renovate calls during autodiscover)
returns repos where the user is owner or org member, but NOT
collaborator-only repos. Our renovate service account has write
collaborator access on Micha/homelab-infra but no own/org repos,
so autodiscover yielded an empty list.

Switching to explicit "repositories": ["Micha/homelab-infra"] is
the pragmatic fix for a homelab with one repo to scan; avoids
having to create an org just for one service account.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 20:13:54 +02:00
Micha c16d62a04a Remove schedule:weekly, modernize docker-compose file pattern
Renovate 41 migriert schedule:weekly auf einen 5am-Monday-only
Lauf - das verhindert beim manuellen Erstlauf jede PR. Wir wollen
dass Renovate bei jedem User-Script-Tick (alle 6h) tatsaechlich
scannt; die Quartals-/Wochen-Rhythmik regeln wir ueber den Cron.

Auch docker-compose.fileMatch ist in Renovate 41 deprecated;
Renovate migriert es zur Laufzeit auf managerFilePatterns mit
regex-Slash-Wrapping. Wir uebernehmen die migrierte Form direkt,
damit die WARN "Config needs migrating" verschwindet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 20:10:53 +02:00
Micha bdae014bff Harden renovate runner: env-file, add-host, explicit DNS
Drei Issues beim Erstlauf gefunden und gefixt:

1. EAI_AGAIN: Renovate-Container konnte git.kaleschke.info nicht
   aufloesen. Analog zu Komodos extra_hosts mappen wir den Hostname
   per --add-host auf 192.168.178.58 (LAN-IP des Unraid-Hosts).
   Zusaetzlich --dns 1.1.1.1/8.8.8.8 fuer externe Image-Registries.

2. Token-Leak in ps und docker inspect: -e RENOVATE_TOKEN=... macht
   den Wert in Process-Listing sichtbar. Stattdessen --env-file mit
   einem 0600 tempfile unter $RENOVATE_STATE_DIR/.env, das nach dem
   Lauf via shred bzw. rm geloescht wird.

3. Doppelter rc=$? Block plus return innerhalb einer {}-Subshell
   waren Tot-Code; aufgeraeumt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 20:04:24 +02:00
Micha 30aa696e61 Prepare Renovate bot against Gitea (F-12) + doc sweep
renovate.json: gitea platform, autodiscover Micha/*, group rules
(major separate, minor+patch+digest grouped, stateful tier-1
individual, komodo-major disabled), pin range strategy, no
automerge, dependency dashboard enabled.

ops/renovate/run-renovate.sh: one-shot docker run wrapper that
reads the Gitea PAT from /mnt/user/appdata/secrets/renovate_token.txt,
runs renovate/renovate:41, logs into /mnt/user/services/renovate/logs/.

docs/RENOVATE.md: 5-step operator setup (Gitea service account,
PAT, token file, first run, six-hourly user script). Explicit
no-automerge stance with notfall-stop checklist.

Cross-doc sweep: SECRETS_MAP entry for renovate_token.txt,
REPO_MAP entry for RENOVATE.md, AUDIT_2026-05-25_TODO new
Sprint 8 with F-15, F-07, F-09 rest, F-12 status, MIGRATION_LOG
captures the four-block sprint in one entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:29:20 +02:00
Micha e4b0db2af6 Add Komodo bootstrap dry-run scaffold (F-09 rest)
Mirror of the Immich restore-test pattern for the Komodo bootstrap
anchor. Brings up a throwaway komodo-mongo + komodo-core +
komodo-periphery under project restoretest-komodo, isolated from
production:

- same image digests as production (mongo:7.0.32, komodo-core:2,
  komodo-periphery:2) to prove compose-level bootstrap compatibility
- restore-lab paths under /mnt/user/backups/restore-lab/komodo
- 127.0.0.1:19120 only, no LAN bind, no Traefik, no Authelia
- test periphery runs WITHOUT docker.sock mount and WITHOUT
  /mnt/user/services mount; cannot manage productive containers
- KOMODO_* secrets are throwaway placeholders hardcoded in the test
  compose; productive secrets never enter this path

Smoke test: compose config valid, mongo healthy, mongo auth-ping
with test creds, komodo-core HTTP 200/302/303/401, periphery
container running. Report under restore-reports/komodo-bootstrap-*.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:25:41 +02:00
Micha 1a4929f9ef Pin monitoring stack images by digest
Reads live RepoDigests of each running monitoring container and
freezes the compose to the exact image manifest. Brings the
monitoring stack to the same digest-pin discipline as the
stateful tier-1 services. influxdb3-core was already pinned.

Affected: prometheus, alertmanager, alertmanager-ntfy-bridge,
blackbox-exporter, loki, promtail, grafana, node-exporter,
cadvisor (plus a second python:3.13-alpine for the bootstrap
dashboard importer).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:23:03 +02:00
Micha 2c0076c6a6 Fix vaultwarden + authelia healthcheck commands
Vaultwarden image ships curl, not wget. Switched the CMD-SHELL
test from wget --spider to curl -fsS.

Authelia 4.39.x removed the "helper health-check" subcommand;
use the /api/health endpoint via wget instead (verified inside
the running container).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:14:27 +02:00
Micha 7da64ff316 Add healthcheck to Authelia (authelia helper health-check)
Authelia ships its own health-check binary subcommand since 4.37+.
Avoids needing wget/curl in the container.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:09:51 +02:00
Micha 12b63531d1 Add healthcheck to Traefik (ping endpoint)
Enable --ping=true and use traefik healthcheck --ping. Lightweight
binary call inside the container, no extra tooling needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:09:51 +02:00
Micha 3daea94982 Add healthcheck to Gitea (/api/healthz)
Gitea exposes /api/healthz unauthenticated. 60s start_period
because Gitea sqlite migration on cold start can take a while.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:09:51 +02:00
Micha 0ca29069c7 Add healthcheck to Vaultwarden (/alive)
Vaultwarden exposes /alive for liveness. wget --spider, 30s
interval, 30s start_period.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:09:50 +02:00
Micha eedb08316d Add healthcheck to Redis (redis-cli ping with auth)
Tier-1 health visibility for the shared Redis. Uses redis-cli with
the password from the mounted secret, fails on anything but PONG.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:09:50 +02:00
Micha 54a7a0e783 Add healthcheck to postgresql17 (pg_isready)
Tier-1 health visibility for shared Postgres cluster. pg_isready
against the admin DB; 30s interval, 30s start_period.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:09:50 +02:00
Micha c677ef0515 Add service removal checklist after stale Borg source finding
Befund vom 2026-05-29: HomelabBorgLastJobCompletedWithWarnings
zuendete vier Tage in Folge mit Borg-Exit-Code 107. Ursache im
Logfile: /local/appdata/homepage wurde am 25.05. entfernt, aber
in der Borg-UI-Source-Liste blieb der Eintrag drin und Borg
warnte taeglich BackupFileNotFoundError. Backups selbst waren
nicht gefaehrdet (alle 23 anderen Quellen sauber archiviert).

Operator hat den Eintrag in der Borg-UI manuell entfernt;
Source-Liste jetzt 23 statt 24, naechster Lauf 2026-05-30 sollte
wieder completed ohne Warning sein.

Erkenntnis: bei Stack-Removal wurde die Borg-Source-Liste nicht
mit-aufgeraeumt. WORKFLOW.md um neuen Abschnitt "Service-Removal-
Checkliste" erweitert mit 9 Pflichtschritten inklusive
Borg-UI-Source-Bereinigung als Schritt 8.

Positiv: die am 2026-05-27 scharfgeschaltete Alert-Pipeline
(Cron Textfile -> node-exporter -> Prometheus -> Alertmanager
-> ntfy-Bridge) hat den Drift binnen 24 h sichtbar gemacht.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:01:45 +02:00
Micha 2b60a58753 Activate H drive nearline pull as daily scheduled task
Windows Scheduled Task "KalliLab H Drive Nearline Pull" auf dem
Operator-Windows-PC registriert: taeglich 05:30 nach dem Borg-
Dump-Fenster. RunLevel Limited, StartWhenAvailable, Akku-OK,
Execution-Time-Limit 2h. Naechster Lauf 2026-05-29 05:30.

Repo-Snippet in H_DRIVE_NEARLINE_PULL.md korrigiert: PowerShell-
Enum-Wert ist Limited, nicht LeastPrivilege (alter Snippet haette
beim ersten Register-ScheduledTask einen Parameter-Binding-Fehler
geworfen). Status auf "produktiv" gesetzt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:25:26 +02:00
Micha 7d64248710 Decide against second offsite, keep paperless-gpt and BentoPDF
Operator-Entscheidungen 2026-05-28:

- F-03 zweites Off-site: bewusst NICHT umgesetzt. 3-2-1 ist mit
  Live + lokalem Borg + Hetzner + H:/-Nearline erfuellt; ein
  zweites Off-site deckt nur den Fall "Hetzner-Account verloren"
  ab, Aufwand unverhaeltnismaessig fuer Familien-Homelab.
  Stattdessen drei Folge-TODOs zur Haertung der bestehenden
  Topologie. Hetzner-2FA bewusst ohne (Operator-Praeferenz,
  analog USV-Risiko-Akzeptanz), durch starkes Passwort +
  Backup-Zahlungsweg + Login-Mails ersetzt. Borg-Append-Only-
  Befund: Repo laeuft im Mode 'full', custom_flags leer; Setup
  waere server-seitig in Hetzner-authorized_keys (Folge-Sprint).
  Review-Trigger in OFFSITE_BACKUP_OPTIONS.md dokumentiert.

- paperless-gpt: behalten bis Paperless-NGX 3.0 (erwartete
  native KI-Features). Aktuell 0 Traefik-Zugriffe in 7 Tagen,
  Resource-Footprint 34 MB RAM.

- BentoPDF: behalten als situatives Tool. 0 Traefik-Zugriffe,
  4 MB RAM. Begruendungs-Anker im SERVICE_CATALOG.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:19:53 +02:00
Micha edcb34c3f3 Record Plex reclaim and lock to LAN/Tailscale-only
Operator-Befund beim F-17-Versuch: Plex-Server war seit 18.05.
unclaimed (Preferences.xml ohne PlexOnline*) und Library-Sections
leer. Filmdateien unter /mnt/user/media/* blieben unangetastet.

Reclaim als Xeridos via inline PLEX_CLAIM-Env beim docker compose
force-recreate. Token nirgendwo persistiert (kein .env, kein Repo,
keine Komodo-Stack-ENV); zweiter Recreate ohne Token, damit
docker inspect-Snapshot sauber bleibt.

Endstand: PlexOnlineUsername Xeridos, PlexOnlineHome 1,
PublishServerOnPlexOnlineKey 0 (Remote Access aus). Bibliotheken
operator-seitig wieder eingerichtet (/data/movies 1.4 TB,
/data/Heimatfilme 300 GB). Plex bleibt LAN/Tailscale-only,
konsistent zur FRITZBox-Bereinigung vom selben Tag.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:06:36 +02:00
Micha 19604e0114 Record FRITZBox WAN cleanup (80/tcp out, VONETS UPnP off)
Operator umgesetzt 2026-05-28:
- 80/tcp aus FRITZBox-UI entfernt; Mobilfunk-validiert: http
  liefert Timeout, https weiter erreichbar.
- 222/tcp bleibt bewusst nicht eingerichtet (Tailscale-only-
  Linie). MASTER Sektion 10 entsprechend praezisiert.
- UPnP-Selbstfreigabe-Recht fuer PC-192-168-178-71 deaktiviert.
  Identifiziert als VONETS-WiFi-Bridge (vermutlich SolarEdge-
  Wechselrichter). SolarEdge-Cloud-Sync ist outbound und
  braucht keine UPnP.

Aktiver WAN-Endstand: ausschliesslich 443/tcp -> 192.168.178.58.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:32:10 +02:00
Micha 3c71a66c55 Document monitoring alerts, bundle cron and H/ pull live status
- AUDIT_2026-05-25_TODO: Borg-Stale, Cert-Expiry, Container-Down
  Alerts auf "erledigt" (Cron */5 textfile exporter live,
  Prometheus reload mit 14 Regeln); Gitea-Bundle-Cron auf "erledigt"
  (User-Script gitea-bundle-mirror-6h aktiv, Bundles 644);
  H:/ Nearline-Pull auf "erledigt (Pull live, Scheduled Task offen)"
  mit Zaehlerstaenden 19 Borg-Dumps + 10 Bundle-Files.

- MIGRATION_LOG: neuer Eintrag fasst die drei zusammenhaengenden
  Live-Aktivierungen zusammen, inkl. Befund-Ursprung (Permission-
  Drift), Reparaturen und expliziter Ausklammerung der nicht
  angefassten Themen (Auth, Hermes, USV, FRITZ!Box, Plex).

- H_DRIVE_NEARLINE_PULL: Erstlauf-Befund mit Permission-Issues
  und nachgezogenem Stand; Erwartungs-Liste auf real geliefertes
  Set angepasst; Flash-Config explizit Out-of-Scope.

- pull-critical-backups.ps1: Live-Robocopy-Output an Out-Null,
  damit der Markdown-Report nicht von Robocopy-Strings zerlegt
  wird (PowerShell-Pipeline-Quirk im foreach).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:48:04 +02:00
Micha 24d0d90670 Make dump output 0644 by default, exclude flash config from H pull
pre-backup-dumps.sh: atomic_write nimmt jetzt einen optionalen
mode-Parameter (Default 0644). Damit sind alle DB-/SQLite-/BoltDB-
/Mongo-Dumps konsistent 0644 und vom Nearline-Pull lesbar. Die
sensible unraid-flash-config-Familie (.tar.gz, .sha256, .manifest)
ruft explizit mit mode 600 auf und bleibt damit Operator-only.
Loest das Permission-Problem fuer filebrowser.bolt.dump (Source
ist 0640) im naechsten regulaeren Dump-Lauf.

pull-critical-backups.ps1: Jobs koennen ExcludeFiles ueber /XF
mitliefern. borg-dumps-latest schliesst die unraid-flash-config-
Artefakte aus, weil sie bewusst 0600 bleiben sollen und sonst den
Lauf abbrechen lassen. Restore-Quelle fuer Flash-Config bleibt
das Hetzner-Borg-Repo.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:44:50 +02:00
Micha 0ae44bd797 Write Prometheus textfile and Gitea bundles world-readable
node-exporter runs as nobody:65534 inside its container and was
hitting node_textfile_scrape_error 1 on homelab.prom, because the
file was 0600 root:root (mktemp default). Set it to 0644 right
before the atomic mv. Bundle inhaltsidentisch zum Git-Repo, ohne
Secrets (.gitignore-abgedeckt) und nicht sensibler als die
uebrigen /mnt/user/backups/borg/dumps/latest/*.dump-Files, die
ebenfalls 0644 sind. So funktioniert auch der Nearline-Pull-Workflow
ueber SMB (docs/H_DRIVE_NEARLINE_PULL.md).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:41:07 +02:00
Micha 0723eccca1 Sync repo map, audit TODO and migration log
Pull repo map up to include FAMILY_VIEW_DASHBOARD,
RESTORE_DRILL_ROUTINE, IMMICH_RESTORE_TEST, FRITZBOX_PORT_
CORRECTION_PLAN and OFFSITE_BACKUP_OPTIONS.

Mark Sprint 2 'Komodo bootstrap', Sprint 3 'Family-View',
Sprint 4 'Family onboarding' and Sprint 7 'Quarterly restore drill'
as done with explicit completion notes. Carry the FRITZBox UPnP
finding forward; tag the second offsite item as decision-pending.

Add two doc-only migration log entries for the bootstrap/family/
onboarding/drill sprint and for the FRITZBox/offsite preparation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:21:37 +02:00
Micha 3bfecdd291 Add FRITZBox correction plan and offsite options
Two operator decision documents, doc-only, no live action:

- docs/FRITZBOX_PORT_CORRECTION_PLAN.md prepares the three open
  router items: remove 80/tcp (no HTTP-01 in use), do not add
  222/tcp while Tailscale remains the operator path, deactivate
  the UPnP self-exposure from PC-192-168-178-71. Every step waits
  for operator go.

- docs/OFFSITE_BACKUP_OPTIONS.md compares rsync.net, BorgBase EU2
  and rotating cold disk for a second offsite target. Recommends
  rsync.net or cold disk; BorgBase EU2 is explicitly not
  recommended because it does not separate the provider risk.
  No provider booked, no costs triggered.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:21:03 +02:00
Micha c4fd4154db Document quarterly restore drill routine
New docs/RESTORE_DRILL_ROUTINE.md introduces a three-stage model:
weekly freshness check, monthly/bimonthly mini-restores, quarterly
DR sanity check. Tracks confirmed mini-restores (Vaultwarden, Gitea,
Paperless 2026-05-07; Immich 2026-05-27) and rotates services by
quarter Q1-Q4. Includes ten-point DR sanity check and abort rules
that point at the drift runbook. No host schedule is created; the
existing ops/restore-tests/schedule.md now references this routine
as the source for quarterly assignment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:15:43 +02:00
Micha dddb33d900 Finalize family onboarding before invitation
Set status to final pre-invitation, soften the 2FA section to
app-specific 2FA (no SSO promise while Authelia-OIDC stays parked),
add a 'bewusst nicht versprochen' block (no single sign-on, no
24/7 SLA, no hotline support, no data sharing), and refine the
2FA loss guidance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:06:38 +02:00
Micha 8eac93c1a5 Add Family-View dashboard specification
New docs/FAMILY_VIEW_DASHBOARD.md specifies the homelab-family-view
Grafana dashboard: 8 panels covering endpoints up, Borg freshness,
cert days, critical containers, disk usage, endpoint table, cert
table and container status. Includes PromQL queries, thresholds,
layout grid, datasource references, build order and smoke test.
Dashboard JSON is intentionally not created yet because the
Borg-stale / cert-expiry / container-down metrics from Sprint 3
are still pending.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:06:30 +02:00
Micha cfa02ce627 Document Komodo bootstrap in linear stages
Add explicit stages A-F to docs/SERVICES_RECOVERY.md: host/docker
baseline, repo source, secrets order, Komodo start, web/GitOps
validation, tier stack rollout. Recovery anchor is ops/komodo/
docker-compose.yml; the self-stack is explicitly not the anchor.
Link DISASTER_RECOVERY Phase 4 stage 3 to the new bootstrap section
and the stack-env-only secrets section in SECRETS_MAP.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:01:20 +02:00
Micha 52414c47be Record Immich restore test success 2026-05-27 18:38:14 +02:00
Micha a8c440d4da Read Immich v2 restore counts 2026-05-27 18:33:29 +02:00
Micha 12cf8fb728 Prepare Immich restore upload markers 2026-05-27 18:29:53 +02:00
Micha 5b0782a8fa Harden Immich restore smoke checks 2026-05-27 18:25:30 +02:00
Micha a805f03481 Retry Immich restore during Postgres startup 2026-05-27 18:18:55 +02:00
Micha 4feecf4a8e Make Immich restore database creation idempotent 2026-05-27 18:16:25 +02:00
Micha 2e84700326 Make Immich restore test create database 2026-05-27 18:14:40 +02:00
Micha 8a19c45485 Use Borg known_hosts in restore tests 2026-05-27 18:12:48 +02:00
Micha 6a445094bd Record FRITZBox port exposure drift 2026-05-27 18:06:43 +02:00
Micha fc59e35c57 Record alert metrics host smoke 2026-05-27 06:40:31 +02:00
Micha 8e111d1e04 Prepare monitoring alert rules 2026-05-27 06:38:57 +02:00
Micha 85a0eb4c3a Activate storage layout documentation 2026-05-27 06:31:03 +02:00
Micha 38c3d87722 Prepare H drive nearline pull 2026-05-27 06:25:47 +02:00
Micha c5d231a0db Prepare Immich restore smoke test 2026-05-26 21:33:01 +02:00
Micha 48099fb48d Update audit follow-up documentation 2026-05-26 20:24:50 +02:00
Micha 5c5ca2fcec Fix Gitea bundle mirror host run 2026-05-26 20:16:19 +02:00
Micha 3b438324dc Record UPS risk acceptance 2026-05-26 19:57:00 +02:00
Micha 0625594443 Record offline Borg passphrase backup 2026-05-26 19:53:08 +02:00
Micha 5936a4d9c1 Add Gitea bundle recovery script 2026-05-26 19:50:50 +02:00
Micha f77a69a0b2 Record audit baseline tag 2026-05-26 19:44:50 +02:00
Micha f73cf48e41 Document external recovery dependencies 2026-05-26 19:44:14 +02:00
Micha eea2697ca1 Triage policy check warnings 2026-05-26 19:42:01 +02:00
Micha a3d77d7529 Document hardware capacity baseline 2026-05-26 19:39:42 +02:00
Micha 02a50e1a58 Record Komodo metadata fix 2026-05-26 19:32:26 +02:00
Micha 267e76059a Clean up Komodo webhook drift 2026-05-26 19:29:51 +02:00
Micha 9d4fee02ca Record next audit handoff 2026-05-26 15:39:41 +02:00
Micha 24ebcaa3c7 Document Nextcloud webhook refresh 2026-05-26 15:34:43 +02:00
Micha 45bae13aa0 Remove legacy monitoring stacks 2026-05-26 15:27:37 +02:00
Micha ff5991cec8 Document AdGuard webhook diagnosis 2026-05-26 15:15:34 +02:00
Micha 5b6e7b8b66 Record AdGuard Tailscale validation 2026-05-26 15:00:35 +02:00
Micha 5cb401797d Bind AdGuard admin to Tailscale 2026-05-26 14:55:49 +02:00
Micha 1d0cba92bd Record Unraid flash backup live evidence 2026-05-25 19:49:38 +02:00
Micha 9353a9fc44 Fix Borg preflight freshness dump path 2026-05-25 19:44:22 +02:00
Micha d50b11784d Add Unraid flash config to Borg preflight 2026-05-25 19:36:16 +02:00
Micha 09eeac51e1 Record legacy grafana hook disablement 2026-05-25 16:51:14 +02:00
Micha 565940b9ef Record monitoring migration completion 2026-05-25 16:47:03 +02:00
Micha b6bbca43ad Replace Uptime Kuma with monitoring checks 2026-05-25 16:37:46 +02:00
Micha 388e57e385 Document AdGuard LAN admin decision 2026-05-25 16:27:03 +02:00
Micha 0c2bb8484a Record Homepage live removal evidence 2026-05-25 14:52:18 +02:00
Micha a7797fd02e Consolidate dashboard on Glance 2026-05-25 14:44:46 +02:00
Micha bac927bbcc Record Jellyfin live removal evidence 2026-05-25 12:15:08 +02:00
Micha add8b71ea9 Remove Jellyfin from homelab target state 2026-05-25 11:57:00 +02:00
Micha e21e89e51b Document Borg passphrase host secret 2026-05-25 11:38:03 +02:00
Micha 4e4684b616 Document external GitHub mirror 2026-05-25 11:27:28 +02:00
Micha 84030956ac Fix Gitea external DNS for GitHub mirror 2026-05-25 11:17:31 +02:00
Micha 17fe8073bb Allow GitHub mirror target for Gitea 2026-05-25 10:56:04 +02:00
Micha 9f32ba72c1 Make audit final runtime wording stable 2026-05-25 07:37:28 +02:00
Micha e9a7f79025 Clarify audit doc-only deployment state 2026-05-25 07:36:03 +02:00
Micha 43727151df Refresh final audit live status 2026-05-25 07:34:08 +02:00
Micha 66ee10cb55 Clarify Disk1 parity follow-up 2026-05-25 06:17:13 +02:00
Micha ab68900216 Complete Disk1 phase 2 migration 2026-05-25 06:13:50 +02:00
Micha 8f56c6edcd Document Disk1 phase 2 backup readiness 2026-05-24 13:07:45 +02:00
Micha 8e400fb3c3 Finalize homelab audit end state 2026-05-23 11:29:08 +02:00
Micha cd650b19ac Close Gitea signup, dedup posture-check alerts, extend Borg scope
Operational hardening across several services after live incident
analysis between 2026-05-18 and 2026-05-20:

- Gitea: disable public registration and OpenID signup/signin to
  stop the external POST / 5xx bursts that triggered availability
  alerts. New repo-wide policy requires every productive
  Micha/homelab-infra Komodo stack to ship with an active
  Gitea->Komodo webhook on the current stack ID (documented in
  CLAUDE.md, AI_CONTEXT.md, WORKFLOW.md).
- posture-check: extract the Disk1 fstype check into its own
  function so the documented Disk1 NTFS exception no longer raises
  ntfy warnings, skip POSIX inode checks on NTFS, and dedup ntfy
  alerts via a fingerprint state file with ALERT_REPEAT_SECONDS
  (default 24h). Repeat-spam on the same cause now suppressed.
- docker-critical-events: parse the event JSON for container name,
  action, exit code and signal; drop `die exit=0` events (clean
  stops); ship a structured ntfy message instead of the raw event
  line.
- Borg UI: mount /mnt/user/services into the backup container as
  /local/services:ro and include homelab-infra, stacks and
  posture-check in all-important-sources.txt. RESTORE_MATRIX and
  DISASTER_RECOVERY updated accordingly.
- Unraid user scripts: document the new
  homelab-operations-report-daily cron job and the SMTP password
  file it expects on the host.
- MIGRATION_LOG: capture the four live events from this window -
  Gitea 5xx burst + signup closure, Komodo webhook reconciliation,
  posture-check host-version verification, Borg scope extension,
  and Traefik 5xx alert detuning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 11:05:35 +02:00
Codex af231dd4e8 Fix zero-count noise pattern handling 2026-05-23 11:03:02 +02:00
Micha 428223d2e4 Mark posture report scripts executable 2026-05-23 11:00:40 +02:00
Micha b6d3ed4832 Tune homelab availability alerts 2026-05-23 10:58:12 +02:00
Micha 9e7bebbd3c Add daily operations report with hardened log-noise filtering
Brings the previously untracked daily-status-report.sh and
send-operations-report-mail.sh into the repo, plus a refactor of the
log-noise pipeline:

- New helper services/posture-check/lib/normalize-noise-patterns.sh
  strips comments, empty lines and trailing whitespace from
  log-noise.patterns before grep -f sees it. A stray empty line in
  the pattern file would otherwise have made grep -Eaif match every
  hit and silently wipe the log highlights.
- log-noise.patterns is now documented per-pattern (Why / Re-check).
  The Vaultwarden pattern is split: token/session noise stays as
  noise; DNS/Connect/Resolve/reqwest/hyper errors are removed from
  the noise set so real network signals stay visible.
- collect_log_highlights now reports a per-container and per-pattern
  noise breakdown (Top N) and an escalation flag when any pattern
  exceeds NOISE_ESCALATION_THRESHOLD (default 500). The flag is fed
  into derive_report_status and the management summary.
- New shell tests under services/posture-check/tests/ verify the
  normalize helper handles comments, empty lines, whitespace-only
  lines, and that unknown error lines remain in the attention set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 10:41:33 +02:00
Micha b7cbbe51de Fix Jellyfin external DNS 2026-05-18 20:29:18 +02:00
Micha 71ac18b21c Fix Jellyfin native auth routing 2026-05-18 13:43:41 +02:00
Micha 90f270be96 Fix Jellyfin config permissions 2026-05-18 13:21:30 +02:00
Micha e28f8dabec Add Jellyfin media server stack 2026-05-18 13:09:32 +02:00
Micha edfec5b66d Add Plex media server stack 2026-05-18 13:09:27 +02:00
Micha 59bec9ac77 Fix Glance live widget data sources 2026-05-18 09:35:53 +02:00
Micha 9f86da708a Add Glance live network widgets 2026-05-18 08:31:57 +02:00
Micha d6170211c4 Refine Glance network widgets 2026-05-18 08:13:13 +02:00
Micha fb681086f3 Restyle Glance dashboard layout 2026-05-18 08:03:59 +02:00
Micha 5b101f3b3d Keep only verified Glance community widget 2026-05-17 18:20:56 +02:00
Micha 669efbd57e Fix Glance Speedtest subrequest headers 2026-05-17 18:18:14 +02:00
Micha 2dd5590a2a Polish Glance community widgets 2026-05-17 18:16:05 +02:00
Micha 175cd6951f Add Glance community homelab widgets 2026-05-17 18:07:57 +02:00
Micha aeb7573b03 Remove noisy Glance dashboard widgets 2026-05-17 17:08:10 +02:00
Micha 215f44b962 Fix Glance monitor health checks 2026-05-17 17:05:23 +02:00
Micha 6ce625f77a Fix Glance socket proxy image tag 2026-05-17 16:59:29 +02:00
Micha c3c8060ddf Add Glance homelab dashboard stack 2026-05-17 16:51:43 +02:00
Micha 29eaf8001f Normalize ntfy alert routing 2026-05-17 14:57:45 +02:00
Micha db7dc3f2af Add ntfy alert delivery for monitoring 2026-05-17 11:34:19 +02:00
Micha c748236886 Prune monitoring dashboard imports 2026-05-17 11:30:00 +02:00
Micha 8aa850df40 Set Grafana DNS resolvers 2026-05-17 11:26:27 +02:00
Micha 2c4854f628 Accept protected HTTP checks in blackbox 2026-05-17 11:25:35 +02:00
Micha b7050812d4 Fix blackbox DNS resolution 2026-05-17 11:24:20 +02:00
Micha c95fa601f0 Add monitoring replacement baseline 2026-05-17 11:22:38 +02:00
Micha 0c308ff352 Preserve InfluxDB data in monitoring stack 2026-05-17 10:47:57 +02:00
Micha 53216e50c1 Fix monitoring InfluxDB volume permissions 2026-05-17 10:45:32 +02:00
Micha b7dfdad621 Consolidate monitoring target stack 2026-05-17 10:41:29 +02:00
Micha 61625a7a1c ops: keep monitoring importer running for komodo 2026-05-16 22:39:09 +02:00
Micha 6e28ea94d2 ops: wire monitoring stack to traefik metrics 2026-05-16 22:10:43 +02:00
Micha 58eb53a6a8 ops: add monitoring compose stack 2026-05-16 21:59:20 +02:00
Micha d345d770c2 docs: add homelab audit report 2026-05-16 21:51:48 +02:00
Micha 2e136d9060 Update current homelab rest list 2026-05-16 20:30:31 +02:00
Micha 6ca829ec45 Document Unraid automation schedules 2026-05-16 20:11:19 +02:00
Micha ef3b546d30 Align documentation consistency fixes 2026-05-16 20:04:46 +02:00
Micha 6f684fb4e3 Document Unraid native log rotation 2026-05-16 19:31:54 +02:00
Micha 0adddb6533 Add Unraid automation script templates 2026-05-16 14:34:35 +02:00
Micha 162421e537 Harden Gitea webhooks and Docker log limits 2026-05-16 13:34:45 +02:00
Micha bf30240217 Remove Loki image-internal healthcheck 2026-05-16 13:32:02 +02:00
Micha 5f7940aa01 Tune Loki host bootstrap settings 2026-05-16 13:31:08 +02:00
Micha a5add937f8 Add Loki Alloy logging baseline 2026-05-16 13:26:49 +02:00
Micha 5ada1ad153 Treat Filebrowser state as file-backed dump 2026-05-16 13:16:01 +02:00
Micha ead7e1e17d Fallback SQLite dumps to host paths 2026-05-16 13:14:10 +02:00
Micha 14e9c0963d Allow posture warnings before Borg 2026-05-16 13:12:47 +02:00
Micha 23262cd7b9 Allow Disk1 NTFS posture transition 2026-05-16 13:12:19 +02:00
Micha 878ad2d5f1 Harden backup and posture checks 2026-05-16 13:04:22 +02:00
Micha 12a87ad342 Clean up SQLite dump temp files on failure 2026-05-16 12:03:10 +02:00
Micha 0e7e639df4 Correct Filebrowser backup state 2026-05-16 11:59:57 +02:00
Micha 18df2d155d Add consistent Borg database dumps 2026-05-16 11:49:36 +02:00
Micha fa177155e6 Document final restore service secrets 2026-05-16 10:55:42 +02:00
Micha 11c863c8aa Set explicit DNS for Grafana 2026-05-16 10:41:06 +02:00
Micha dd9f677779 Use LSIO file secret for code-server password 2026-05-16 10:37:25 +02:00
Micha a9e62ee8e5 Document restore exceptions and layout fixes 2026-05-16 07:43:46 +02:00
Micha 4e7d313a20 Pin restored auth stack image tags 2026-05-15 18:18:32 +02:00
Micha 57ea7507a7 Remove Backrest and WD backup references 2026-05-15 16:57:42 +02:00
Micha 1e3e019f28 Update STORAGE_LAYOUT.draft.md
docs: storage-layout v1.3 als bindendes Architektur-Dokument

Erstfassung des verbindlichen Storage-Layouts nach NTFS-Cache-Vorfall
2026-05-11. Definiert Pool-/Share-Konfiguration, Appdata-Struktur,
Backup-Architektur (Borg only, kein Backrest, kein WD-MyBookLive),
Posture-Check-Pflichten, Hard Rules und Migrationspfade.

Operator-Entscheidungen v1.0 -> v1.3 eingearbeitet. Sechs der elf
Open Items entschieden, eines deferred (Disk-Groessen via Posture-Check),
drei als Folge-Aufgaben (Retention, Schwellen-Kalibrierung,
RESTORE_MATRIX-Klassifikation).

Refs: Incident 2026-05-11 NTFS-Cache-Korruption
2026-05-15 16:10:55 +02:00
Micha 54fd0c3347 diverse Änderungen 2026-05-15 16:05:34 +02:00
Micha d7e1eb33ba Improve restore job ntfy timeout and output 2026-05-07 11:34:50 +02:00
Micha 008ab9bc4a Add ntfy wrapper for restore jobs 2026-05-07 11:26:15 +02:00
Micha 7ff7284f6b Add host-ready restore automation scripts 2026-05-07 11:20:03 +02:00
Micha d20b687211 Add restore handbook and Unraid job guide 2026-05-07 11:11:36 +02:00
Micha 16416d964f Add restore test automation scaffolding 2026-05-07 11:07:46 +02:00
Micha 2cc39c73f6 Add validated Paperless restore test pattern 2026-05-07 11:01:27 +02:00
Micha d351b1cac8 Add validated Gitea restore test pattern 2026-05-07 10:00:58 +02:00
Micha df4d335907 Document validated Vaultwarden restore pattern 2026-05-07 09:39:29 +02:00
Micha 7161da00b3 hermes infos
hermes infos
2026-05-06 20:26:29 +02:00
Micha aded9a9cbc update
update
2026-05-06 20:22:14 +02:00
Micha 5cc0a4dadb update
update
2026-05-06 20:18:25 +02:00
Micha 84020346bc hermes update
hermes next level
2026-05-06 20:13:48 +02:00
Micha 1dc1c1ef17 Add restore test scaffolding for Vaultwarden 2026-05-06 20:13:30 +02:00
Micha 7c50e69b44 Add manual repo policy checks
Add manual repo policy checks
2026-05-06 19:36:01 +02:00
Micha 0aa8138bdd hermes update
hermes update
2026-05-06 19:13:52 +02:00
Micha bdef0afcb9 Add AI handoff summary 2026-05-06 19:07:45 +02:00
Micha e0e12f1173 Document stale Komodo webhook cleanup 2026-05-06 18:55:56 +02:00
Micha 403b5fa77c Clarify Komodo webhook secret handling 2026-05-06 18:53:47 +02:00
Micha 9b4d37ca81 Split Komodo webhook secret 2026-05-06 18:50:14 +02:00
Micha 014e51fd67 Configure Authelia GMX SMTP notifier 2026-05-06 18:41:24 +02:00
Micha f94a55e093 Protect mail archiver and document Hermes restore 2026-05-06 18:23:01 +02:00
Micha 8f3c03f396 Fix invalid image digest pins 2026-05-05 21:02:24 +02:00
Micha bdba76cebc Clean up compose metadata and placeholders 2026-05-05 20:16:48 +02:00
Micha 78b9a6e362 Pin versioned app image digests 2026-05-05 20:13:49 +02:00
Micha 374a3198ed Document ops exceptions and Hermes route 2026-05-05 19:42:42 +02:00
Micha 986d8dd3f5 Pin stateful service image digests 2026-05-05 19:33:28 +02:00
Micha 1acd4c6830 docs(borg): backup scope mit nextcloud, grafana, influxdb, hermes, backrest, bentopdf abgeglichen; portainer altlast entfernt; offene decisions explizit gemacht
docs(borg): backup scope mit nextcloud, grafana, influxdb, hermes, backrest, bentopdf abgeglichen; portainer altlast entfernt; offene decisions explizit gemacht
2026-05-04 20:43:48 +02:00
Micha d6e686ae80 Document Authelia host ACL merge 2026-05-04 20:27:07 +02:00
Micha b1fbb310fb Document Komodo self-stack drift recovery 2026-05-04 20:21:23 +02:00
Micha f858da484b Clarify Authelia config source and ACLs
Clarify Authelia config source and ACLs
2026-05-04 19:57:45 +02:00
Micha 197454931f Ignore local env files and template Hermes stack env
Ignore local env files and template Hermes stack env
2026-05-04 19:54:28 +02:00
Micha bcb2bf81a8 Document Authelia without Redis session backend
Document Authelia without Redis session backend
2026-05-04 19:51:44 +02:00
Micha b45c406975 Claude ready
Claude ready
2026-05-04 15:42:48 +02:00
Micha 821fe99807 Document GitOps drift recovery and InfluxDB LAN access 2026-05-04 15:23:49 +02:00
Micha 7268f4ce1e Fix InfluxDB LAN port publishing 2026-05-04 14:59:42 +02:00
Micha 86f1a582c9 Fix Komodo periphery stack workspace access 2026-05-04 14:49:47 +02:00
Micha e3f7ed15cf Revert "Update docker-compose.yml"
This reverts commit 2b1f95ed09.
2026-05-04 13:54:38 +02:00
Micha 2b1f95ed09 Update docker-compose.yml 2026-05-04 13:47:51 +02:00
Micha ebea1789a6 Update docker-compose.yml 2026-05-04 13:40:23 +02:00
Micha 2f7b1a0aa2 Prepare Home Assistant weather export to InfluxDB
Prepare Home Assistant weather export to InfluxDB
2026-05-04 13:29:06 +02:00
Micha 4e1d608949 Update docker-compose.yml 2026-05-04 10:11:15 +02:00
Micha fe13609292 Prepare Komodo v2 upgrade 2026-05-04 10:10:10 +02:00
Micha f280f63eb1 Prepare Komodo v2 upgrade 2026-05-04 09:31:23 +02:00
Micha 0780d1eae1 Use host path for Grafana provisioning
Use host path for Grafana provisioning
2026-04-30 11:41:14 +02:00
Micha c86203b1e5 Inline Grafana secret entrypoint
Inline Grafana secret entrypoint
2026-04-30 11:34:32 +02:00
Micha c632a850f3 Fix Grafana InfluxDB startup permissions
Fix Grafana InfluxDB startup permissions
2026-04-30 11:32:51 +02:00
Micha c736aadf1e Use file secret for Grafana InfluxDB token
Use file secret for Grafana InfluxDB token
2026-04-30 11:27:06 +02:00
Micha 209aceca0d Allow BentoPDF nginx runtime writes
Allow BentoPDF nginx runtime writes
2026-04-30 11:14:02 +02:00
Micha 239adbbd79 Update docker-compose.yml 2026-04-30 11:10:28 +02:00
Micha 8a43914d05 Prepare BentoPDF and Grafana InfluxDB stacks
Prepare BentoPDF and Grafana InfluxDB stacks
2026-04-30 10:29:53 +02:00
Micha f3dd51de14 update
update
2026-04-24 09:12:46 +02:00
Micha 12beac55b1 update
update
2026-04-24 08:17:52 +02:00
Micha 6fe6044101 Update docker-compose.yml 2026-04-21 21:03:06 +02:00
Micha 2b82049ea8 Update docker-compose.yml 2026-04-21 20:52:08 +02:00
Micha 6da1489ca4 update 2026-04-21 20:50:26 +02:00
Micha fbec171cb5 Update docker-compose.yml 2026-04-21 20:18:13 +02:00
Micha 1791caf9a7 Update docker-compose.yml 2026-04-21 19:53:53 +02:00
Micha 4e721a8ac9 Update docker-compose.yml 2026-04-21 19:42:42 +02:00
Micha aebb5776a8 Update docker-compose.yml 2026-04-21 19:40:19 +02:00
Micha b4d6af940f Update docker-compose.yml 2026-04-21 19:36:33 +02:00
Micha 081c4c0249 Merge branch 'master' of https://git.kaleschke.info/Micha/homelab-infra 2026-04-21 19:27:17 +02:00
Micha 94493d57de repair
repair
2026-04-21 19:27:15 +02:00
Micha e21c9b736e update
update
2026-04-21 19:10:28 +02:00
Micha 34674406d5 ops/hermes-agent/stack.env aktualisiert 2026-04-21 17:09:22 +00:00
Micha fc38fb2ab6 hermes installation
hermes
2026-04-20 19:10:28 +02:00
Micha edf5b4fda8 repair
repair
2026-04-20 18:27:09 +02:00
Micha e03e769c67 update adguard
update
2026-04-20 09:31:51 +02:00
Micha fbdb017c08 Add Nextcloud and Stirling PDF with repo-aligned networking and docs
Add Nextcloud and Stirling PDF with repo-aligned networking and docs
2026-04-19 20:16:13 +02:00
Micha 054e674d55 update
update
2026-04-19 18:42:56 +02:00
Micha f7927b95f4 update
update
2026-04-19 14:38:58 +02:00
Micha 57ddb56ec9 repair
repair
2026-04-19 10:18:12 +02:00
Micha 0648201f79 gpt repair
gpt repair
2026-04-19 10:07:12 +02:00
Micha fc2793f2b8 korrektur
korrektur
2026-04-19 09:58:50 +02:00
Micha 317c56b8de recovery
recovery plan
2026-04-18 10:05:53 +02:00
Micha 0e68ce489f Erklärung Paperless
Paperless Erklärung
2026-04-17 13:15:20 +02:00
Micha 85a8d0c2f2 Protect Traefik dashboard with Authelia
Protect Traefik dashboard with Authelia
2026-04-17 13:11:43 +02:00
Micha 718305cb98 Update Doku
Update Docu
2026-04-17 11:29:38 +02:00
Micha cbb4dfed3d Lock remaining running images to current digests
Lock remaining running images to current digests
2026-04-17 11:00:47 +02:00
Micha 5a46134737 Lock mutable image tags to current running digests
Lock mutable image tags to current running digests
2026-04-17 08:28:19 +02:00
Micha 0a9b2d88bf optimizing
optimierungen
2026-04-17 08:15:09 +02:00
Micha 96d9015867 Harden code-server and move Redis password to secret file
Harden code-server and move Redis password to secret file
2026-04-17 07:56:29 +02:00
Micha 0f95e61c6f dashboard
entfernt dashboard
2026-04-15 16:09:31 +02:00
Micha bbdf2ffb60 updates
Repo sauber machen
2026-04-15 13:40:03 +02:00
Micha 326c744e95 update
update
2026-04-15 13:13:59 +02:00
Micha d362a9ab4c Aktualisierung
Aktualisierung meiner Doku
2026-04-15 12:21:02 +02:00
Micha 736aef160e Test 2026-04-15 10:53:20 +02:00
Micha b998e88863 Document final borg backup rollout status 2026-04-15 10:30:41 +02:00
Micha 4eea231d24 Remove Firefly and Semaphore from homelab 2026-04-15 10:24:42 +02:00
Micha 8b8e96e32f Move borg dump staging to backup share 2026-04-15 09:43:20 +02:00
Micha d888d7394b apps/paperless/docker-compose.yml aktualisiert 2026-04-15 07:35:28 +00:00
Micha c7f0962ba0 Use shared postgres admin for borg dumps 2026-04-13 19:12:53 +02:00
Micha f87e993034 ops/borg-ui/docker-compose.yml aktualisiert 2026-04-12 17:31:34 +00:00
Micha ef8e8ccd76 ops/borg-ui/docker-compose.yml aktualisiert 2026-04-12 17:30:41 +00:00
Micha be479407fe Add borg backup scope and database dump workflow 2026-04-12 19:03:47 +02:00
Micha 29a0585753 Extend borg-ui mounts for immich and gitea 2026-04-12 18:27:09 +02:00
316 changed files with 28365 additions and 5036 deletions
+6
View File
@@ -0,0 +1,6 @@
*.sh text eol=lf
*.ps1 text eol=crlf
*.md text
*.json text
*.yml text
*.yaml text
+35
View File
@@ -0,0 +1,35 @@
# 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
# Generated reports
ops/policy-checks/last-report.md
# Local/editor noise
.DS_Store
Thumbs.db
*.tmp
*.log
.serena/
.claude/settings.local.json
memory/
+20
View File
@@ -0,0 +1,20 @@
# Agent Context - Homelab Infra
Typ: Einstieg/Index · Stand: 2026-06-11 · Status: aktiv
Einstiegspunkt fuer KI-Agenten (Codex, Gemini u. a.; Claude nutzt zusaetzlich
`CLAUDE.md`). Kein eigener Inhalt - nur Pflichtpfade.
## Vor jeder Arbeit lesen
1. `docs/AI_CONTEXT.md` - Systembild, harte Regeln, Ausnahmen-Kurzliste
2. `HOMELAB_ARCHITECTURE_MASTER_V2.md` - Architektur-Zielbild
3. `docs/WORKFLOW.md` - verbindlicher GitOps-/No-Drift-Ablauf
4. die betroffene `docker-compose.yml` bzw. das betroffene Runbook (Index: `docs/README.md`)
## Nicht verhandelbar
- Keine Secret-Werte lesen, zitieren oder schreiben - nur Namen und Pfade.
- Keine Deployments, Host-Hotfixes oder Docker-Schreibbefehle ohne ausdrueckliche Anweisung.
- Doku-Regeln aus `docs/REPO_MAP.md` einhalten: ein Fakt, ein Zuhause. Status nur in `docs/MASTER_TODO.md`, Entscheidungen nur in `docs/DECISIONS.md`.
- Bei Drift oder zwei fehlgeschlagenen Reparaturversuchen: stoppen, `docs/GITOPS_DRIFT_RUNBOOK.md`.
+131
View File
@@ -0,0 +1,131 @@
# Claude Code Context - Homelab Infra
Stand: 2026-06-11
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`
- Architektur-/Betriebsentscheidungen mit Begruendung: `docs/DECISIONS.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.
- Doku-Regeln aus `docs/REPO_MAP.md` einhalten: ein Fakt, ein Zuhause. Status nur in `docs/MASTER_TODO.md`, Entscheidungen nur in `docs/DECISIONS.md`, Erledigtes verlaesst die Arbeitskopie.
- 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.
+144 -266
View File
@@ -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 16 abgeschlossen **Stand:** 2026-06-11 | **Aktueller Schwerpunkt:** GitOps / Doku-Synchronisierung / Reproduzierbare Deployments
--- ---
@@ -16,11 +16,11 @@
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 AF)](#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)
13. [Betriebserfahrungen und Entscheidungs-Log](#13-betriebserfahrungen-und-entscheidungs-log) 13. [Betriebserfahrungen und Entscheidungs-Log (ausgelagert)](#13-betriebserfahrungen-und-entscheidungs-log-ausgelagert)
--- ---
@@ -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, borg-ui und beszel sind standardmäßig **Tailscale-only** oder hinter Traefik **mit zentraler Middleware** abgesichert. filebrowser, scrutiny, code-server, Traefik-Dashboard und borg-ui sind standardmaessig **Tailscale-only** oder hinter Traefik **mit zentraler Middleware** abgesichert. `Komodo` ist die dokumentierte Ausnahme und bleibt bewusst bei nativer Authentifizierung ohne pauschal vorgeschaltete ForwardAuth-Middleware.
### P5 — Compose-first ### 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,13 @@ 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 |
| `smarthome_net` | bridge, `internal: true` | interne Smart-Home-Kommunikation zwischen Home Assistant, Mosquitto, spaeter Zigbee2MQTT/ESPHome | vorbereitet |
| `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 +104,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, borg-ui, 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 +120,17 @@ 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)
└── smarthome_net (HA, Mosquitto, spaeter Zigbee2MQTT/ESPHome)
Host-Sonderfälle Host-Sonderfälle
├── tailscale ├── tailscale
── Plex-Media-Server ── Plex-Media-Server
└── beszel-agent
``` ```
--- ---
@@ -136,21 +146,30 @@ 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)
- `homeassistant` — home.kaleschke.info (Traefik, native Home-Assistant-Auth)
### 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)
- `backrest` — Traefik + Middleware
- `borg-ui` — borg.kaleschke.info (Middleware) - `borg-ui` — borg.kaleschke.info (Middleware)
- `glance` — glance.kaleschke.info (Middleware)
- `paperless-gpt` — paperless-gpt.kaleschke.info (Middleware)
- `mail-archiver` — mail.kaleschke.info (Middleware + App-Auth)
- `glances` — glances.kaleschke.info (Middleware)
- `speedtest-tracker` — speedtest.kaleschke.info (Middleware)
- `bentopdf` — pdf.kaleschke.info (Middleware)
- `monitoring-grafana` — monitoring.kaleschke.info (Middleware)
- `hermes-dashboard` — hermes.kaleschke.info (Middleware)
- `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:
@@ -159,6 +178,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
@@ -166,10 +187,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
@@ -219,20 +240,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
@@ -240,48 +259,62 @@ 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 | — |
| `smarthome-mosquitto` | ✅ vorbereitet | `smarthome_net` | intern `1883`, kein Host-Port in Phase 1 | MQTT-Datenbus fuer Home Assistant, spaeter ESPHome und Zigbee2MQTT; Passwortdatei und ACLs in `/mnt/user/appdata/mosquitto/config` | LAN-Port erst in ESPHome-Phase mit ACLs/per-Device-Usern |
### 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 |
| `homeassistant` | ✅ vorbereitet | `frontend_net`, `smarthome_net` | Traefik via `home.kaleschke.info`, native HA-Auth | Home Assistant Container im GitOps-Stack `smart-home/`; kein HAOS, kein Supervised; Fach-YAML kommt aus `smart-home-kalli`, `.storage` bleibt in `/mnt/user/appdata/homeassistant` | Deploy, Onboarding, Restore-Probe, Cloud-Integrationen |
| `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 |
| `borg-ui` | 🔄 | `frontend_net` | Traefik + Middleware | Git-Stack für Borg/BorgBase-Backups; Borg UI bündelt Borg-CLI im Container | BorgBase-SSH-Key hinterlegen, erstes Repo initialisieren, Quell-Mounts bei Bedarf gezielt erweitern | | `borg-ui` | | `frontend_net` | Traefik + Middleware | produktiver Borg-/Restore-Dienst; `/local/secrets` ist bewusst Teil des Restore-Scopes | BorgBase-Repo und Key laufend pflegen |
| `mail-archiver` | ✅ | `frontend_net`, `backend_net` | intern | IMAP-Abruf + DB-Zugang, kein öffentlicher Zugang | — | | `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
@@ -293,13 +326,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 |
--- ---
@@ -317,9 +356,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
@@ -328,163 +367,77 @@ 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 AF) ## 9. Historische Migration (abgeschlossen)
**Letzte Aktualisierung:** 2026-03-29
### Block A — Quick Wins ✅ ABGESCHLOSSEN
```text
[x] restart: unless-stopped für alle Container gesetzt
[x] vaultwarden ADMIN_TOKEN-Doppelpräfix korrigiert
[x] backrest DNS-Hardcoding entfernt
[x] leere Netzwerke entfernt: br0, immich_net, kopia_default, netbox_default, diun_default
[x] anonyme/verwaiste Volumes bereinigt
[x] scanopy komplett entfernt (3 Container + 2 Volumes + Netz)
[x] binhex-official-pihole entfernt → ersetzt durch AdGuard Home + Unbound
```
### Block B — Kritische Kernmigrationen ✅ ABGESCHLOSSEN
```text
[x] vaultwarden - frontend_net, Host-Port entfernt, ADMIN_TOKEN_FILE, Traefik aktiv
[x] postgresql17 - Port 5432 entfernt, nur backend_net, POSTGRES_PASSWORD_FILE
[x] mealie-postgres - aus frontend_net raus, nur mealie_mealie_internal
```
### Block C — Frontend-Stack finalisieren ✅ ABGESCHLOSSEN
```text
[x] ntfy - Git-Stack - ntfy.kaleschke.info - Traefik aktiv
[x] paperless-ngx - traefik.enable=true - paperless.kaleschke.info - Port entfernt - tls=true
[x] Paperless-AI - traefik.enable=true - aktiv
[x] PortainerCE - traefik.enable=true - Middleware aktiv - direkte Ports entfernt
[x] UptimeKuma - traefik.enable=true - uptime.kaleschke.info - Port entfernt - Middleware aktiv
[x] filebrowser - frontend_net - traefik.enable=true - files.kaleschke.info - Port entfernt - Middleware aktiv
[x] scrutiny - frontend_net - traefik.enable=true - scrutiny.kaleschke.info - Git-Stack
[x] gitea - traefik.enable=true - git.kaleschke.info - SSH-Port 222 bleibt (Ausnahme dokumentiert)
[x] backrest - traefik.docker.network=frontend_net korrigiert (war backend_net — Routing-Bug)
[x] Traefik File-Provider bereinigt - immich.yml, gitea.yml, mealie.yml, scrutiny.yml, vaultwarden.yml.bak gelöscht
[x] immich Bad Gateway behoben - Traefik nutzt jetzt immich@docker statt immich@file
[x] AdGuard Home - Git-Stack - dns_net + frontend_net - Port 53 (DNS) + 3000 (Admin)
[x] beszel - Git-Stack - frontend_net - beszel.kaleschke.info - Traefik aktiv
[ ] beszel - Admin-Middleware (dashboard-auth@file) ergänzen
```
### Block D — Dockerman-Container in Git-Stacks
```text
[x] vaultwarden ✅
[x] postgresql17 ✅
[x] mail-archiver ✅
[x] scrutiny ✅
[x] filebrowser ✅
[x] tailscale ✅
[x] AdGuard Home ✅
[x] beszel ✅
[x] ntfy ✅
[x] homepage ✅
[ ] Plex-Media-Server (Sprint 5)
```
### Block E — Secrets-Migration
```text
[x] vaultwarden → ADMIN_TOKEN_FILE ✅
[x] postgresql17 → POSTGRES_PASSWORD_FILE ✅
[x] mail-archiver → Stack ENV (${MAILARCHIVER_AUTH_PASSWORD}) ✅
[x] mealie → Stack ENV (kein _FILE-Support) ✅
[x] mealie-postgres → Stack ENV (kein _FILE-Support) ✅
[x] paperless-ngx → Stack ENV (${PAPERLESS_DBPASS}) ✅
[x] code-server → PASSWORD_FILE ✅
[x] immich_server → Stack ENV (${IMMICH_DB_PASSWORD}) ✅
[x] immich_postgres → POSTGRES_PASSWORD_FILE ✅
[ ] immich_redis → anonymes Volume → named volume
```
### Block F — Feinschliff / Hardening
```text
[x] immich_default - internal: true gesetzt (2026-03-29)
[x] PortainerCE - abgeschaltet (Sprint 5, 2026-03-29)
[ ] immich_redis - anonymes Volume → named volume in Compose
[ ] immich_server - anonymes Volume prüfen und benennen
[ ] backrest - /mnt/user doppelt gemountet (ro + rw) - rw-Mount auf konkrete Pfade einschränken
[ ] filebrowser - /mnt/user:/srv ist sehr breit - auf /mnt/user/documents:/srv einschränken wenn möglich
[ ] Redis - optional named volume
[ ] scrutiny - später prüfen, ob privileged reduziert werden kann
[ ] tailscale - TS_USERSPACE/privileged bereinigen wenn möglich
[ ] beszel - Admin-Middleware (dashboard-auth@file) ergänzen
[ ] AdGuard Home - Admin-Port 3000 per Traefik + Middleware absichern (aktuell direkter Port)
```
### Block G — Authelia SSO/2FA (Sprint 7)
```text
[x] security/authelia/docker-compose.yml im Repo (2026-03-29)
[x] security/authelia/configuration.yml im Repo (2026-03-29)
[x] traefik/dynamic/middlewares.yml - authelia ForwardAuth Middleware ergänzt (2026-03-29)
[ ] NAS: Secrets anlegen (jwt_secret, session_secret, storage_encryption_key, postgres_password)
[ ] NAS: Authelia PostgreSQL-User und -Datenbank anlegen
[ ] NAS: /mnt/user/appdata/authelia/config/configuration.yml aus Repo übernehmen
[ ] NAS: users_database.yml mit gehashten Passwörtern anlegen
[ ] NAS: DNS-Eintrag auth.kaleschke.info in AdGuard setzen
[ ] Komodo: Stack security/authelia deployen
[ ] Services schrittweise mit authelia@docker Middleware absichern
```
---
Die Blockmigration aus der Portainer-/Dockerman-Phase ist abgeschlossen: Traefik laeuft labelbasiert ohne File-Provider-Service-Routen, Komodo ist alleiniger Stack-Manager, Portainer CE ist entfernt, Borg/Dumps/Restore-Tests sind produktiv. Entscheidungen und Hintergruende stehen in `docs/DECISIONS.md`; die Sprint-Historie liegt in Git.
## 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 schreibt spaeter Langzeitdaten. Nach der HA-Container-Entscheidung muss der Writer-Pfad in der Influx-Phase explizit gewaehlt werden: entweder LAN-Bind via `INFLUXDB_BIND_IP` oder gezieltes gemeinsames internes Netz. Keine Traefik-Route, Zugriff nur ueber Token; 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. |
| `homeassistant` | Traefik ohne Authelia, Fach-YAML aus separatem Repo | Home Assistant bringt eigene Auth, mobile Apps, Webhooks und Integrationsfluesse mit. Der Container haengt in `frontend_net` fuer Traefik und in `smarthome_net` fuer MQTT/Zigbee2MQTT/ESPHome. `.storage` und Secrets bleiben in Appdata und werden per Borg gesichert, nicht versioniert. |
| `Ecowitt` | spaetere HTTP-Ausnahme offen | Ecowitt kann nur HTTP. Wegen globalem Traefik-HTTP-Redirect wird in Phase 2 entschieden, ob Traefik eine selektive Webhook-Ausnahme bekommt oder ob ein LAN-only HA-Port `8123` als dokumentierte Host-Port-Ausnahme noetig wird. |
--- ---
## 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 |
| Sprint 8 | `borg-ui` Git-Stack + BorgBase Offsite-Backup-Workflow | 🔄 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
@@ -502,93 +455,18 @@ Damit ist sofort klar:
--- ---
## 13. Betriebserfahrungen und Entscheidungs-Log ## 13. Betriebserfahrungen und Entscheidungs-Log (ausgelagert)
### Traefik — Wechsel zu reinen Docker-Labels (2026-03-28) Architektur- und Betriebsentscheidungen werden seit 2026-06-11 zentral in
Die statischen File-Provider-Konfigurationen in `/mnt/user/appdata/traefik/dynamic/` wurden vollständig bereinigt: `docs/DECISIONS.md` gefuehrt (ADR-light: Entscheidung, Kontext, Review-Trigger).
- **Gelöscht:** `immich.yml`, `gitea.yml`, `mealie.yml`, `scrutiny.yml`, `vaultwarden.yml.bak` Dieses Dokument haelt nur noch das Zielbild. Neue Entscheidungen werden dort
- **Verbleibend (notwendig):** `middlewares.yml`, `tls.yml`, `dashboards.yml` eingetragen; hier aendert sich nur etwas, wenn das Zielbild selbst betroffen
ist (Netze, Zugangsmodell, Ausnahmen in Sektion 10).
**Hintergrund:** Die alten File-Provider-Configs haben `@file`-Routen mit `@docker`-Routen konkurrieren lassen. In Traefik v3 gewinnt der File-Provider und hat z.B. Immich auf die falsche IP geroutet (Bad Gateway). Nach Löschung läuft Traefik ausschließlich auf Docker-Labels.
**Regel:** Neue Dienste ausschließlich via Docker Compose Labels konfigurieren. Keine neuen `.yml`-Dateien im `dynamic/`-Verzeichnis für Service-Routen anlegen.
### Komodo — Ablösung von Portainer als Stack-Manager (2026-03-28)
Komodo ist nun der primäre GitOps-Stack-Manager:
- **Komodo Core** läuft als Docker-Stack (`ops/komodo/docker-compose.yml`)
- **Komodo Periphery** läuft auf dem Unraid-Host für direktes Server-Management
- Stacks werden via Gitea synchronisiert und über Komodo deployed
- Portainer CE läuft noch als Legacy-UI und wird in Sprint 5 abgeschaltet
**Vorteil gegenüber Portainer:** Sauberer GitOps-Flow ohne Web-Editor; alle Stack-Änderungen laufen über Git.
### AdGuard Home — Ablösung von Pi-hole (2026-03-28)
`binhex-official-pihole` wurde entfernt und durch `AdGuard Home` + `unbound` ersetzt:
- AdGuard läuft als Git-Stack (`host-services/Adguard/docker-compose.yml`)
- Netzwerke: `dns_net` (feste IP 172.23.0.3) + `frontend_net`
- Port 53 (DNS) direkt gebunden — dokumentierte Ausnahme
- Port 3000 (Admin-UI) direkt gebunden — Traefik-Absicherung ausstehend (Block F)
- `unbound` läuft weiterhin als Upstream-Resolver in `dns_net`
### diun — Entfernung (2026-03-28)
`diun` (Docker Image Update Notifier) wurde deinstalliert:
- Stack gelöscht
- Orphan-Netzwerk `diun_diun_default` bereinigt
- Repo-Eintrag `infra/diun/` aus Git entfernt
Update-Monitoring kann über Komodo's eingebaute Update-Notifications abgedeckt werden.
### ntfy — Push-Notifications (Git-Stack)
`ntfy` läuft als Git-Stack (`apps/ntfy/docker-compose.yml`):
- `ntfy.kaleschke.info` via Traefik
- `NTFY_UPSTREAM_BASE_URL: https://ntfy.sh` für mobile Push-Notifications
- `NTFY_BEHIND_PROXY: true` korrekt gesetzt
### immich_default — internal: true gesetzt (2026-03-29)
`immich_default` wurde von `external: true` auf ein Compose-verwaltetes internes Netz umgestellt:
- **Vorher:** `external: true` (manuell erstellt, falsche Labels `com.docker.compose.network=default`)
- **Nachher:** Compose-managed, `internal: true`, `driver: bridge`, korrekte Labels
- Durchgeführt via: manuelles `docker stop` der Containers → `docker network rm immich_default` → Komodo Redeploy
- Ergebnis: alle Immich-Container (`immich_postgres`, `immich_redis`, `immich_machine_learning`) sind jetzt vom Internet isoliert; nur `immich_server` hat zusätzlich `frontend_net` für Traefik
### Secrets in Komodo / Portainer Stacks
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.
### Borg UI / BorgBase (2026-04-12)
- `borg-ui` läuft als Admin-Dienst in `ops/borg-ui/docker-compose.yml`
- nur `frontend_net`, weil Web-UI + externer SSH-Zugang zu BorgBase benötigt werden
- keine direkten Host-Ports; Zugriff ausschließlich via Traefik + Middleware über `borg.kaleschke.info`
- Mounts bewusst klein gehalten: `/mnt/user/appdata` read-only als erste Backup-Quelle, separates Restore-Ziel unter `/mnt/user/appdata/borg-ui/restore`
- kein separater Borg-CLI-Container nötig, da Borg UI die Borg-CLI bereits im Container mitbringt
| Container | `_FILE` Support |
|---|---|
| Vaultwarden | ✅ ja |
| PostgreSQL | ✅ ja |
| code-server | ✅ ja (`PASSWORD_FILE`) |
| Immich Postgres | ✅ ja (`POSTGRES_PASSWORD_FILE`) |
| Mealie | ❌ nein → Stack ENV |
| paperless-ngx | ❌ nein für DB-Pass → Stack ENV |
### ddns-updater — Netz-Ausnahme
Bleibt bewusst in `frontend_net` statt `backend_net`, weil `backend_net` `internal: true` ist und ddns-updater die Cloudflare-API erreichen muss.
### mail-archiver — Hybrid-Dienst
Benötigt `backend_net` (PostgreSQL) + `frontend_net` (IMAP-Abruf von GMX/Gmail). Kein reiner Backend-Dienst. Kein öffentlicher Traefik-Zugang.
### Netzwerk-Standard für Apps mit Datenbanken
- App → `frontend_net` + internes Netzwerk
- Datenbank → nur internes Netzwerk (`internal: true`)
Beispiel (Mealie): `mealie``frontend_net` + `mealie_mealie_internal`, `mealie-postgres` → nur `mealie_mealie_internal`.
--- ---
## Schlussformel ## Schlussformel
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`.
+194
View File
@@ -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
+59 -31
View File
@@ -1,53 +1,81 @@
# 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 14) abgeschlossen. Komodo ist primärer Stack-Manager. - Offene Punkte stehen ausschliesslich in `docs/MASTER_TODO.md`; Entscheidungen mit Begruendung in `docs/DECISIONS.md`.
- Komodo ist der primaere und einzige produktive Stack-Manager.
> ⚠️ Portainer CE läuft noch als Legacy-UI wird in Sprint 5 abgeschaltet. - Komodo bleibt bewusst bei nativer Authentifizierung; zentrale Traefik-Auth wird dort nicht pauschal vorgeschaltet.
- Portainer CE ist abgeschaltet und kein Teil des aktiven Betriebs mehr.
- Glance ist das aktive produktive Homelab-Dashboard.
- Traefik `dynamic/` bleibt eine dokumentierte manuelle Host-Sync-Ausnahme ausserhalb des normalen Komodo-Deployments.
- Mutable Image-Tags sind auf die aktuell laufenden Digests eingefroren; echte Versions-Upgrades erfolgen bewusst separat.
- Disaster-Recovery und dienstspezifische Restore-Quellen sind in `docs/DISASTER_RECOVERY.md` und `docs/RESTORE_MATRIX.md` beschrieben.
- Recovery-kritische Services-Pfade wie Gitea-Repositories, Komodo-Workspaces und Host-Automation sind in `docs/SERVICES_RECOVERY.md` beschrieben.
- Hardware-, Netzwerk-, Provider- und Capacity-Inventare sind als operative Audit-Dokumente unter `docs/HARDWARE_INVENTORY.md`, `docs/NETWORK_INVENTORY.md`, `docs/EXTERNAL_DEPENDENCIES.md` und `docs/CAPACITY_AND_LIFECYCLE.md` vorbereitet.
- Der verbindliche Detailablauf steht in `docs/WORKFLOW.md`.
- 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.
+34
View File
@@ -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
-4
View File
@@ -1,4 +0,0 @@
backend/.env
backend/app/__pycache__
backend/app/**/*.pyc
assets/.DS_Store
-23
View File
@@ -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=
-19
View File
@@ -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"]
-22
View File
@@ -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 };
}
-40
View File
@@ -1,40 +0,0 @@
import { fetchDashboardData } from "./api.js";
import { getState, subscribe, updateData } from "./state.js";
import { renderHeader } from "./renderers/header.js";
import { renderStats } from "./renderers/stats.js";
import { renderStorage } from "./renderers/storage.js";
import { renderServices } from "./renderers/services.js";
import { renderNetworkHealth } from "./renderers/network-health.js";
import { renderQuickAccess } from "./renderers/quick-access.js";
import { renderHomeAssistant } from "./renderers/home-assistant.js";
import { renderUptimeKuma } from "./renderers/uptime-kuma.js";
import { renderImmich } from "./renderers/immich.js";
import { renderBackrest } from "./renderers/backrest.js";
function render(state) {
renderHeader(state);
renderStats(state);
renderStorage(state);
renderServices(state);
renderNetworkHealth(state);
renderHomeAssistant(state);
renderUptimeKuma(state);
renderImmich(state);
renderBackrest(state);
renderQuickAccess();
}
subscribe(render);
async function refresh() {
try {
const data = await fetchDashboardData();
updateData(data);
} catch (err) {
console.error("Dashboard fetch error:", err);
}
}
render(getState());
refresh();
setInterval(refresh, (getState().overview?.refresh_hint_seconds ?? 20) * 1000);
@@ -1,50 +0,0 @@
export function renderBackrest(state) {
const d = state.backrest || {};
const online = d.source_status === "online";
const pill = document.getElementById("backrest-pill");
if (pill) {
pill.textContent = online ? "ONLINE" : "OFFLINE";
pill.className = "status-pill " + (online ? "pill-online" : "pill-offline");
}
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
if (online) {
set("backrest-repos", d.repo_count ?? 0);
set("backrest-last", fmtAge(d.last_backup_age_hours));
set("backrest-errors", d.error_count ?? 0);
} else {
set("backrest-repos", "—");
set("backrest-last", "—");
set("backrest-errors", "—");
}
// Color last backup age warn if > 26h
const lastEl = document.getElementById("backrest-last");
if (lastEl && online) {
const age = d.last_backup_age_hours;
lastEl.style.color = age !== null && age > 26 ? "var(--clr-warn)" : "";
}
// Color errors warn if any
const errEl = document.getElementById("backrest-errors");
if (errEl && online) {
errEl.style.color = (d.error_count ?? 0) > 0 ? "var(--clr-warn)" : "";
}
// Status dot
const dot = document.getElementById("backrest-status-dot");
if (dot) {
const s = d.last_backup_status || "unknown";
dot.className = "status-dot dot-" + (s === "ok" ? "ok" : s === "error" ? "err" : "unk");
dot.title = s;
}
}
function fmtAge(hours) {
if (hours === null || hours === undefined) return "—";
if (hours < 1) return `${Math.round(hours * 60)}m ago`;
if (hours < 24) return `${Math.round(hours)}h ago`;
return `${Math.round(hours / 24)}d ago`;
}
@@ -1,20 +0,0 @@
export function renderHeader(state) {
const overview = state.overview || {};
const status = overview.overall_status || "offline";
const dot = document.getElementById("overall-dot");
if (dot) {
dot.style.background = status === "online" ? "var(--teal)" : status === "degraded" ? "var(--yellow)" : "var(--red)";
dot.style.boxShadow = status === "online" ? "0 0 8px var(--teal-glow)" : "";
}
const txt = document.getElementById("overall-status-text");
if (txt) txt.textContent = status.toUpperCase();
const upd = document.getElementById("last-updated");
if (upd && overview.generated_at) {
const d = new Date(overview.generated_at);
const pad = n => String(n).padStart(2, "0");
upd.textContent = `updated ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
}
@@ -1,29 +0,0 @@
export function renderHomeAssistant(state) {
const d = state.home_assistant || {};
const online = d.status === "online";
// pill
const pill = document.getElementById("ha-pill");
if (pill) {
pill.textContent = online ? "ONLINE" : "OFFLINE";
pill.className = "status-pill " + (online ? "pill-online" : "pill-offline");
}
// stat blocks
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
set("ha-lights", online ? `${d.lights_on ?? 0}/${d.lights_total ?? 0}` : "—");
set("ha-climate", online ? (d.climate_active ?? 0) : "—");
set("ha-doors", online ? (d.doors_open ?? 0) : "—");
set("ha-alerts", online ? (d.alerts ?? 0) : "—");
// version subtitle
const ver = document.getElementById("ha-version");
if (ver) ver.textContent = d.version ? `v${d.version}` : "";
// alerts highlight
const alertsEl = document.getElementById("ha-alerts");
if (alertsEl) {
alertsEl.style.color = d.alerts > 0 ? "var(--clr-warn)" : "";
}
}
@@ -1,28 +0,0 @@
export function renderImmich(state) {
const d = state.immich || {};
const online = d.source_status === "online";
const pill = document.getElementById("immich-pill");
if (pill) {
pill.textContent = online ? "ONLINE" : "OFFLINE";
pill.className = "status-pill " + (online ? "pill-online" : "pill-offline");
}
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
if (online) {
set("immich-photos", fmtNum(d.photos ?? 0));
set("immich-videos", fmtNum(d.videos ?? 0));
set("immich-storage", `${(d.storage_gb ?? 0).toFixed(1)} GB`);
} else {
set("immich-photos", "—");
set("immich-videos", "—");
set("immich-storage", "—");
}
}
function fmtNum(n) {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
return String(n);
}
@@ -1,69 +0,0 @@
export function renderNetworkHealth(state) {
renderAdGuard(state.adguard || {});
renderScrutiny(state.scrutiny || {});
}
function renderAdGuard(data) {
const online = data.source_status === "online";
setPill("adguard-pill", online ? "ONLINE" : "OFFLINE", online ? "pill-online" : "pill-offline");
setText("adguard-total", online ? fmtCompact(data.total_queries) : "\u2014");
setText("adguard-blocked", online ? fmtCompact(data.blocked_queries) : "\u2014");
setText("adguard-blocked-pct", online ? `${Math.round(data.blocked_percent ?? 0)}%` : "\u2014");
setText("adguard-latency", online ? `${Math.round(data.avg_processing_ms ?? 0)}ms` : "\u2014");
const fill = document.getElementById("adguard-bar-fill");
if (fill) {
fill.style.width = `${online ? Math.min(data.blocked_percent ?? 0, 100) : 0}%`;
}
}
function renderScrutiny(data) {
const online = data.source_status === "online";
setPill("scrutiny-pill", online ? "ONLINE" : "OFFLINE", online ? "pill-online" : "pill-offline");
const total = data.total_count ?? 0;
const failed = data.failed_count ?? 0;
const passed = Math.max(total - failed, 0);
setText("scrutiny-total", online ? total : "\u2014");
setText("scrutiny-passed", online ? passed : "\u2014");
setText("scrutiny-failed", online ? failed : "\u2014");
const list = document.getElementById("scrutiny-list");
if (!list) return;
const devices = Array.isArray(data.devices) ? data.devices.slice(0, 3) : [];
if (!online || devices.length === 0) {
list.innerHTML = `<div class="scrutiny-offline">\u2014 ${online ? "no disks" : "offline"}</div>`;
return;
}
list.innerHTML = `<div class="scrutiny-strip">${devices.map((device) => {
const status = device.status || "unknown";
const cls = status === "passed" ? "ok" : status === "failed" ? "fail" : "unk";
const token = status === "passed" ? "OK" : status === "failed" ? "ER" : "--";
const name = device.name || device.device || "disk";
return `<span class="scrutiny-chip ${cls}"><strong>${token}</strong>${name}</span>`;
}).join("")}</div>`;
}
function setText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
function setPill(id, label, cls) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = label;
el.className = `status-pill ${cls}`;
}
function fmtCompact(value) {
const num = Number(value ?? 0);
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 1_000) return `${Math.round(num / 1_000)}K`;
return `${num}`;
}
@@ -1,29 +0,0 @@
const QUICK_LINKS = [
{ label: "Home Assistant", icon: "🏠", url: "https://ha.kaleschke.info" },
{ label: "Komodo", icon: "🦎", url: "https://komodo.kaleschke.info" },
{ label: "Uptime Kuma", icon: "📡", url: "https://uptime.kaleschke.info" },
{ label: "Beszel", icon: "📊", url: "https://beszel.kaleschke.info" },
{ label: "Firefly III", icon: "🦋", url: "https://firefly.kaleschke.info" },
{ label: "Paperless", icon: "📄", url: "https://paperless.kaleschke.info" },
{ label: "Mealie", icon: "🍽️", url: "https://mealie.kaleschke.info" },
{ label: "Immich", icon: "🖼️", url: "https://immich.kaleschke.info" },
{ label: "Gitea", icon: "🐙", url: "https://git.kaleschke.info" },
{ label: "Code Server", icon: "💻", url: "https://code.kaleschke.info" },
{ label: "FileBrowser", icon: "📁", url: "https://files.kaleschke.info" },
{ label: "Backrest", icon: "💾", url: "https://backrest.kaleschke.info" },
{ label: "Vaultwarden", icon: "🔐", url: "https://vault.kaleschke.info" },
{ label: "AdGuard", icon: "🛡️", url: "https://adguard.kaleschke.info" },
{ label: "Traefik", icon: "🔀", url: "https://traefik.kaleschke.info" },
{ label: "Scrutiny", icon: "🔍", url: "https://scrutiny.kaleschke.info" },
];
export function renderQuickAccess() {
const grid = document.getElementById("quick-access-grid");
if (!grid) return;
grid.innerHTML = QUICK_LINKS.map(({ label, icon, url }) => `
<a class="quick-tile" href="${url}" target="_blank" rel="noopener noreferrer">
<span class="quick-tile-icon">${icon}</span>
<span class="quick-tile-label">${label}</span>
</a>
`).join("");
}
@@ -1,24 +0,0 @@
export function renderServices(state) {
const services = state.services || {};
const summary = services.summary || {};
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
set("svc-online", summary.online ?? "—");
set("svc-degraded", summary.degraded ?? "—");
set("svc-offline", summary.offline ?? "—");
set("svc-total", summary.total ?? "—");
const pill = document.getElementById("services-pill");
if (pill) {
const s = summary.overall_status || "offline";
pill.textContent = s.toUpperCase();
pill.className = "status-pill " + (s === "online" ? "pill-online" : s === "degraded" ? "pill-degraded" : "pill-offline");
}
// Colour counts
const degEl = document.getElementById("svc-degraded");
if (degEl) degEl.className = "stat-num" + ((summary.degraded ?? 0) > 0 ? " warn" : "");
const offEl = document.getElementById("svc-offline");
if (offEl) offEl.className = "stat-num" + ((summary.offline ?? 0) > 0 ? " danger" : "");
}
@@ -1,49 +0,0 @@
export function renderStats(state) {
const sys = state.system || {};
const overview = state.overview || {};
const cpu = sys.cpu || {};
const mem = sys.memory || {};
const net = sys.network || {};
const host = sys.host || {};
const docker = overview.docker || state.services?.docker || {};
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
// CPU
set("cpu-percent", cpu.usage_percent != null ? `${cpu.usage_percent.toFixed(1)}%` : "—");
set("cpu-cores", cpu.cores ?? "—");
set("cpu-load", cpu.load_5 != null ? cpu.load_5.toFixed(2) : "—");
// Colour CPU
const cpuEl = document.getElementById("cpu-percent");
if (cpuEl && cpu.usage_percent != null) {
cpuEl.className = "stat-num" + (cpu.usage_percent > 85 ? " danger" : cpu.usage_percent > 65 ? " warn" : "");
}
// Memory
set("ram-percent", mem.usage_percent != null ? `${mem.usage_percent.toFixed(1)}%` : "—");
set("ram-used", mem.used_gb != null ? mem.used_gb.toFixed(1) : "—");
set("ram-total", mem.total_gb != null ? mem.total_gb.toFixed(0) : "—");
const ramEl = document.getElementById("ram-percent");
if (ramEl && mem.usage_percent != null) {
ramEl.className = "stat-num" + (mem.usage_percent > 85 ? " danger" : mem.usage_percent > 65 ? " warn" : "");
}
// Network
set("net-rx", net.rx_mbps != null ? net.rx_mbps.toFixed(1) : "—");
set("net-tx", net.tx_mbps != null ? net.tx_mbps.toFixed(1) : "—");
// Host
const upDays = host.uptime_seconds != null ? Math.floor(host.uptime_seconds / 86400) : null;
set("uptime-days", upDays != null ? upDays : "—");
set("host-platform", host.platform ? host.platform.slice(0, 5).toUpperCase() : "—");
// Docker
set("docker-running", docker.running ?? "—");
set("docker-stopped", docker.stopped ?? "—");
set("docker-total", docker.total ?? "—");
const stoppedEl = document.getElementById("docker-stopped");
if (stoppedEl) stoppedEl.className = "stat-num" + ((docker.stopped ?? 0) > 0 ? " warn" : " dim");
}
@@ -1,86 +0,0 @@
export function renderStorage(state) {
const storage = state.storage || {};
const grid = document.getElementById("storage-grid");
if (!grid) return;
const root = storage.root || storage.disks?.[0] || null;
const disks = storage.disks || [];
const rootPct = root?.usage_percent ?? 0;
const rootTone = pickTone(rootPct);
const warningCount = disks.filter((disk) => between(disk.usage_percent, 70, 85)).length;
const criticalCount = disks.filter((disk) => (disk.usage_percent ?? 0) > 85).length;
const highest = disks.length
? disks.reduce((current, disk) => ((disk.usage_percent ?? 0) > (current.usage_percent ?? 0) ? disk : current), disks[0])
: null;
const matrixPill = criticalCount > 0 ? "pill-offline" : warningCount > 0 ? "pill-degraded" : "pill-online";
const strip = disks.length
? disks.slice(0, 4).map((disk) => {
const pct = disk.usage_percent ?? 0;
return `<span class="storage-chip" style="color:${pickColor(pct)}">${disk.name || disk.mount} ${pct.toFixed(0)}%</span>`;
}).join("")
: '<span class="storage-chip">No disk data</span>';
grid.innerHTML = `
<div class="card service-card storage-card storage-primary">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon icon-storage"><span class="icon-glyph glyph-storage"></span></span>
<span class="service-name">ROOT STORAGE</span>
</div>
<span class="status-pill ${statusPill(root?.status)}">${(root?.status || "stable").toUpperCase()}</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num ${rootTone}">${root ? `${rootPct.toFixed(1)}%` : "\u2014"}</div><div class="stat-label">Usage</div></div>
<div class="stat-block"><div class="stat-num dim">${root ? fmtNum(root.used_gb) : "\u2014"}</div><div class="stat-label">Used GB</div></div>
<div class="stat-block"><div class="stat-num dim">${root ? fmtNum(root.free_gb) : "\u2014"}</div><div class="stat-label">Free GB</div></div>
</div>
<div class="progress-wrap">
<div class="progress-meta"><span>${root?.mount || "/"}</span><span>${root ? `${rootPct.toFixed(1)}%` : "0%"}</span></div>
<div class="progress-bar"><div class="progress-fill ${rootTone}" style="width:${Math.min(100, rootPct)}%"></div></div>
</div>
</div>
<div class="card service-card storage-card storage-matrix-card">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon icon-matrix"><span class="icon-glyph glyph-matrix"></span></span>
<span class="service-name">DISK MATRIX</span>
</div>
<span class="status-pill ${matrixPill}">${disks.length}</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num">${disks.length || "\u2014"}</div><div class="stat-label">Volumes</div></div>
<div class="stat-block"><div class="stat-num warn">${warningCount}</div><div class="stat-label">Warning</div></div>
<div class="stat-block"><div class="stat-num danger">${criticalCount}</div><div class="stat-label">Critical</div></div>
<div class="stat-block"><div class="stat-num ${pickTone(highest?.usage_percent ?? 0)}">${highest ? `${(highest.usage_percent ?? 0).toFixed(0)}%` : "\u2014"}</div><div class="stat-label">Peak</div></div>
</div>
<div class="service-footer"><span>Mounted Volumes</span><div class="storage-strip">${strip}</div></div>
</div>
`;
}
function fmtNum(value) {
return value == null ? "\u2014" : Number(value).toFixed(1);
}
function between(value, min, max) {
const num = value ?? 0;
return num > min && num <= max;
}
function pickTone(pct) {
if (pct > 85) return "danger";
if (pct > 70) return "warn";
return "";
}
function pickColor(pct) {
if (pct > 85) return "var(--red)";
if (pct > 70) return "var(--yellow)";
return "var(--teal-bright)";
}
function statusPill(status) {
if (status === "critical") return "pill-offline";
if (status === "warning") return "pill-degraded";
return "pill-online";
}
@@ -1,53 +0,0 @@
export function renderUptimeKuma(state) {
const d = state.uptime_kuma || {};
const online = d.source_status === "online";
const pill = document.getElementById("uk-pill");
if (pill) {
pill.textContent = online ? "ONLINE" : "OFFLINE";
pill.className = "status-pill " + (online ? "pill-online" : "pill-offline");
}
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
set("uk-up", online ? (d.monitors_up ?? 0) : "—");
set("uk-down", online ? (d.monitors_down ?? 0) : "—");
const total = (d.monitors_up ?? 0) + (d.monitors_down ?? 0);
const pct = total > 0 ? Math.round((d.monitors_up / total) * 100) : (online ? 100 : 0);
set("uk-uptime", online ? `${pct}%` : "—");
// down monitors list
const downList = document.getElementById("uk-down-list");
if (downList) {
const downs = (d.monitors || []).filter(m => m.status === "offline");
if (!online || downs.length === 0) {
downList.innerHTML = "";
} else {
downList.innerHTML = downs.map(m =>
`<span class="uk-down-name">▼ ${m.name}</span>`
).join("");
}
}
// uptime bars per monitor (top 6 by name)
const barsContainer = document.getElementById("uk-bars");
if (barsContainer) {
const monitors = (d.monitors || []).slice(0, 6);
if (!online || monitors.length === 0) {
barsContainer.innerHTML = '<span class="widget-offline-msg">—</span>';
} else {
barsContainer.innerHTML = monitors.map(m => {
const beats = m.heartbeats && m.heartbeats.length
? m.heartbeats
: (m.status === "online" ? Array(20).fill(1) : Array(20).fill(0));
const segments = beats.slice(-20).map(b =>
`<span class="hb-seg ${b ? "hb-up" : "hb-down"}"></span>`
).join("");
return `
<div class="uk-monitor-row">
<span class="uk-monitor-name">${m.name}</span>
<span class="uk-bar">${segments}</span>
</div>`;
}).join("");
}
}
}
-60
View File
@@ -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
View File
@@ -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,
)
-111
View File
@@ -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
-79
View File
@@ -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",
)
-93
View File
@@ -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
-4
View File
@@ -1,4 +0,0 @@
fastapi==0.116.1
uvicorn[standard]==0.35.0
pydantic-settings==2.10.1
httpx==0.28.1
-955
View File
@@ -1,955 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KalliLab Control Panel</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;700&family=Share+Tech+Mono&family=Exo+2:wght@300;400;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #060b09;
--bg2: #0a1210;
--card: rgba(8, 18, 15, 0.88);
--card-border: rgba(0, 220, 140, 0.18);
--card-hover: rgba(0, 220, 140, 0.28);
--teal: #00dc8c;
--teal-dim: #009e65;
--teal-bright: #00ffaa;
--teal-glow: rgba(0, 220, 140, 0.4);
--text: #b8d4cc;
--text-dim: #5a8a7a;
--text-bright: #d8f0e8;
--clr-warn: #ff4466;
--red: #ff4466;
--yellow: #ffcc44;
--blue: #44aaff;
--graph: #00cc88;
--font-display: 'Orbitron', monospace;
--font-mono: 'Share Tech Mono', monospace;
--font-body: 'Exo 2', sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
min-height: 100vh;
overflow-x: hidden;
position: relative;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(0, 220, 140, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 220, 140, 0.025) 1px, transparent 1px);
background-size: 40px 40px;
z-index: 0;
pointer-events: none;
}
@keyframes scanline {
0% { top: -5%; }
100% { top: 105%; }
}
.scanline {
position: fixed;
left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(0,220,140,0.06), rgba(0,220,140,0.10), rgba(0,220,140,0.06), transparent);
z-index: 1;
animation: scanline 8s linear infinite;
pointer-events: none;
}
.wrapper {
position: relative;
z-index: 2;
max-width: 1340px;
margin: 0 auto;
padding: 0 10px 32px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0 8px;
border-bottom: 1px solid var(--card-border);
margin-bottom: 10px;
gap: 12px;
}
.header-logo { display: flex; align-items: center; gap: 10px; }
.logo-text {
font-family: var(--font-display);
font-size: 16px;
font-weight: 700;
color: var(--teal-bright);
text-shadow: 0 0 20px var(--teal-glow);
letter-spacing: 2px;
}
.logo-sub {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
letter-spacing: 2px;
text-transform: uppercase;
}
.header-center { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.overall-status { font-family: var(--font-mono); font-size: 10px; letter-spacing: 2px; color: var(--teal-dim); }
.status-indicator {
display: flex; align-items: center; gap: 6px;
font-family: var(--font-display); font-size: 11px;
color: var(--teal-bright); text-shadow: 0 0 10px var(--teal-glow);
}
.status-dot-main { width: 8px; height: 8px; border-radius: 50%; background: var(--teal); box-shadow: 0 0 8px var(--teal-glow); }
.header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
.clock {
font-family: var(--font-display); font-size: 24px; font-weight: 700;
color: var(--teal-bright); text-shadow: 0 0 20px var(--teal-glow), 0 0 40px rgba(0,220,140,0.2);
letter-spacing: 2px;
}
.date-str { font-family: var(--font-mono); font-size: 10px; color: var(--text-dim); text-align: right; }
#last-updated { font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); text-align: right; }
.section-header {
display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
font-family: var(--font-display); font-size: 8px; font-weight: 600;
letter-spacing: 3px; text-transform: uppercase; color: var(--teal-dim);
}
.section-header::after { content: ''; flex: 1; height: 1px; background: linear-gradient(90deg, var(--card-border), transparent); }
.widget-row { display: grid; gap: 8px; margin-bottom: 8px; }
.row-5 { grid-template-columns: repeat(5, 1fr); }
.row-4 { grid-template-columns: repeat(4, 1fr); }
.row-3 { grid-template-columns: repeat(3, 1fr); }
.row-2 { grid-template-columns: repeat(2, 1fr); }
.row-2-1 { grid-template-columns: 2fr 1fr; }
.row-1-2 { grid-template-columns: 1fr 2fr; }
.row-3-2 { grid-template-columns: 3fr 2fr; }
.card {
background: rgba(6, 14, 11, 0.78);
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 8px 10px;
transition: border-color 0.2s, box-shadow 0.2s;
backdrop-filter: blur(14px) saturate(1.4);
-webkit-backdrop-filter: blur(14px) saturate(1.4);
}
.card:hover { border-color: rgba(0,220,140,0.32); box-shadow: 0 0 16px rgba(0,220,140,0.07), inset 0 0 20px rgba(0,220,140,0.02); }
.card-title {
font-family: var(--font-display); font-size: 8px; font-weight: 600;
letter-spacing: 2px; text-transform: uppercase; color: var(--teal-dim);
margin-bottom: 6px; display: flex; align-items: center; justify-content: space-between; gap: 6px;
}
.card-title-left { display: flex; align-items: center; gap: 6px; }
.card-title .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--teal); box-shadow: 0 0 5px var(--teal-glow); flex-shrink: 0; }
.stats-grid { display: flex; justify-content: space-around; gap: 6px; flex-wrap: wrap; }
.stat-block { text-align: center; min-width: 40px; }
.stat-num { font-family: var(--font-display); font-size: 17px; font-weight: 700; color: var(--teal-bright); text-shadow: 0 0 10px var(--teal-glow); line-height: 1.1; }
.stat-num.dim { color: var(--teal-dim); text-shadow: none; font-size: 14px; }
.stat-num.warn { color: var(--yellow); text-shadow: 0 0 10px rgba(255,204,68,0.4); }
.stat-num.danger { color: var(--red); text-shadow: 0 0 10px rgba(255,68,102,0.4); }
.stat-num.blue { color: var(--blue); text-shadow: 0 0 10px rgba(68,170,255,0.4); }
.stat-label { font-family: var(--font-mono); font-size: 8px; color: var(--text-dim); letter-spacing: 1px; text-transform: uppercase; margin-top: 1px; }
.status-pill { font-family: var(--font-mono); font-size: 7px; letter-spacing: 1px; padding: 1px 5px; border-radius: 3px; font-weight: 700; }
.pill-online { background: rgba(0,220,140,0.12); color: var(--teal); border: 1px solid rgba(0,220,140,0.3); }
.pill-offline { background: rgba(255,68,102,0.1); color: var(--red); border: 1px solid rgba(255,68,102,0.25); }
.pill-degraded { background: rgba(255,204,68,0.1); color: var(--yellow); border: 1px solid rgba(255,204,68,0.25); }
.progress-wrap { margin-top: 5px; }
.progress-label { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); margin-bottom: 2px; }
.progress-bar { height: 3px; background: rgba(0,220,140,0.1); border-radius: 2px; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--teal-dim), var(--teal-bright)); border-radius: 2px; box-shadow: 0 0 5px var(--teal-glow); transition: width 1s ease; }
.progress-fill.warn { background: linear-gradient(90deg, #cc8800, var(--yellow)); }
.progress-fill.danger { background: linear-gradient(90deg, #cc2244, var(--red)); }
.service-header { display: flex; align-items: center; gap: 7px; margin-bottom: 7px; }
.service-icon { width: 24px; height: 24px; border-radius: 5px; display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; }
.service-name { font-family: var(--font-display); font-size: 9px; font-weight: 600; color: var(--text-bright); letter-spacing: 1px; flex: 1; }
.service-version { font-family: var(--font-mono); font-size: 8px; color: var(--text-dim); }
.sys-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px 10px; font-family: var(--font-mono); font-size: 10px; }
.sys-row { display: flex; justify-content: space-between; padding: 1px 0; }
.sys-key { color: var(--text-dim); }
.sys-val { color: var(--teal); }
.disk-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 3px; }
.disk-name { font-family: var(--font-display); font-size: 9px; color: var(--text-bright); letter-spacing: 1px; }
.disk-usage { font-family: var(--font-mono); font-size: 9px; color: var(--teal); }
.disk-sub { font-family: var(--font-mono); font-size: 8px; color: var(--text-dim); margin-bottom: 4px; }
.scrutiny-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-family: var(--font-mono); font-size: 9px; border-bottom: 1px solid rgba(0,220,140,0.05); }
.scrutiny-row:last-child { border-bottom: none; }
.disk-icon { font-size: 10px; font-weight: bold; width: 12px; text-align: center; }
.disk-ok { color: var(--teal); }
.disk-fail { color: var(--red); }
.disk-unk { color: var(--text-dim); }
.disk-name-col { flex: 1; color: var(--text-bright); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.disk-model { color: var(--text-dim); font-size: 8px; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.disk-temp { color: var(--teal-dim); font-size: 8px; white-space: nowrap; }
.scrutiny-offline { font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); padding: 4px 0; }
.uk-monitor-row { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
.uk-monitor-name { font-family: var(--font-mono); font-size: 8px; color: var(--text-dim); min-width: 70px; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.uk-bar { display: flex; gap: 1px; flex: 1; }
.hb-seg { height: 7px; flex: 1; border-radius: 1px; }
.hb-up { background: var(--teal); box-shadow: 0 0 3px var(--teal-glow); opacity: 0.85; }
.hb-down { background: var(--red); box-shadow: 0 0 3px rgba(255,68,102,0.4); opacity: 0.85; }
.uk-down-name { display: block; font-family: var(--font-mono); font-size: 8px; color: var(--red); padding: 1px 0; }
.status-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.dot-ok { background: var(--teal); box-shadow: 0 0 5px var(--teal-glow); }
.dot-err { background: var(--red); box-shadow: 0 0 5px rgba(255,68,102,0.4); }
.dot-unk { background: var(--text-dim); }
.adguard-bar-wrap { margin-top: 5px; }
.adguard-bar { height: 3px; background: rgba(0,220,140,0.1); border-radius: 2px; overflow: hidden; position: relative; }
.adguard-bar-fill { height: 100%; background: linear-gradient(90deg, var(--teal-dim), var(--teal-bright)); border-radius: 2px; box-shadow: 0 0 5px var(--teal-glow); width: 0%; transition: width 1s ease; }
.net-health-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
#quick-access-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 6px; }
.quick-tile { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 6px 7px; background: rgba(6, 14, 11, 0.72); border: 1px solid var(--card-border); border-radius: 7px; cursor: pointer; transition: all 0.18s; text-decoration: none; color: var(--text); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); }
.quick-tile:hover { border-color: rgba(0,220,140,0.4); background: rgba(0,220,140,0.05); transform: translateY(-2px); box-shadow: 0 4px 18px rgba(0,220,140,0.10); }
.quick-tile-icon { font-size: 18px; line-height: 1; }
.quick-tile-label { font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); text-align: center; line-height: 1.2; }
.quick-tile:hover .quick-tile-label { color: var(--teal); }
.docker-row { display: flex; gap: 6px; font-family: var(--font-mono); font-size: 9px; margin-top: 4px; flex-wrap: wrap; }
.docker-chip { padding: 2px 6px; border-radius: 3px; background: rgba(0,220,140,0.07); border: 1px solid rgba(0,220,140,0.15); color: var(--teal-dim); }
.docker-chip.running { color: var(--teal); border-color: rgba(0,220,140,0.3); }
.docker-chip.stopped { color: var(--yellow); border-color: rgba(255,204,68,0.3); background: rgba(255,204,68,0.06); }
.docker-chip.unhealthy { color: var(--red); border-color: rgba(255,68,102,0.3); background: rgba(255,68,102,0.06); }
@media (max-width: 1100px) {
.row-5 { grid-template-columns: repeat(3, 1fr); }
.row-4 { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 780px) {
.row-5, .row-4, .row-3 { grid-template-columns: repeat(2, 1fr); }
.row-2, .row-2-1, .row-1-2, .row-3-2 { grid-template-columns: 1fr; }
.net-health-grid { grid-template-columns: 1fr; }
#quick-access-grid { grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); }
}
/* Density / reference lock overrides */
.wrapper { max-width: 1640px; padding: 8px 12px 20px; }
.header { padding: 10px 0 10px; margin-bottom: 8px; gap: 16px; }
.logo-text { font-size: 18px; letter-spacing: 2px; }
.logo-sub, .overall-status, .date-str, #last-updated { font-size: 10px; }
.clock { font-size: 46px; }
.section-header { margin-bottom: 5px; font-size: 9px; letter-spacing: 3px; }
.widget-row { gap: 6px; margin-bottom: 6px; }
.row-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.card {
max-height: 120px;
min-height: 118px;
height: 118px;
padding: 8px 10px 8px;
border-radius: 10px;
background: linear-gradient(180deg, rgba(7, 17, 14, 0.84), rgba(5, 11, 9, 0.72));
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03), 0 10px 28px rgba(0,0,0,0.22), 0 0 18px rgba(0,220,140,0.05);
overflow: hidden;
}
.card-title { margin-bottom: 6px; min-height: 26px; }
.card-title-left { gap: 7px; }
.card-title .dot { width: 6px; height: 6px; }
.stats-grid {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
align-items: end;
justify-content: stretch;
gap: 10px;
flex-wrap: nowrap;
}
.stat-block {
min-width: 0;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
}
.stat-num {
font-size: 22px;
line-height: 0.95;
color: var(--teal-bright);
text-shadow: 0 0 12px var(--teal-glow);
}
.stat-num.dim { font-size: 18px; color: var(--text-bright); }
.stat-label { font-size: 8px; margin-top: 2px; }
#cpu-percent, #ram-percent, #net-rx, #uptime-days, #docker-running { font-size: 30px; }
.service-icon {
width: 28px;
height: 28px;
border-radius: 8px;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--icon-color, var(--teal-bright));
background:
radial-gradient(circle at 30% 30%, rgba(255,255,255,0.16), transparent 45%),
linear-gradient(180deg, rgba(16, 36, 29, 0.96), rgba(7, 14, 11, 0.92));
border: 1px solid rgba(255,255,255,0.06);
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.02),
0 0 16px rgba(0,220,140,0.08);
}
.service-icon::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: inset 0 0 14px rgba(0,0,0,0.18);
pointer-events: none;
}
.icon-glyph {
width: 15px;
height: 15px;
display: block;
position: relative;
color: inherit;
opacity: 0.96;
}
.icon-cpu { --icon-color: #82ffbf; }
.icon-memory { --icon-color: #98ffbf; }
.icon-network { --icon-color: #7dc8ff; }
.icon-host { --icon-color: #95ffbf; }
.icon-docker { --icon-color: #81ffc8; }
.icon-storage { --icon-color: #7effb6; }
.icon-matrix { --icon-color: #7fc9ff; }
.icon-scrutiny { --icon-color: #7effb0; }
.icon-ha { --icon-color: #8ea6ff; }
.icon-kuma { --icon-color: #89ffaf; }
.icon-immich { --icon-color: #ffd84f; }
.icon-backrest { --icon-color: #74d7ff; }
.icon-adguard { --icon-color: #66f0ba; }
.icon-services { --icon-color: #8affbe; }
.glyph-cpu {
border: 1.6px solid currentColor;
border-radius: 3px;
}
.glyph-cpu::before {
content: "";
position: absolute;
inset: 3px;
border: 1.4px solid currentColor;
border-radius: 2px;
opacity: 0.92;
}
.glyph-cpu::after {
content: "";
position: absolute;
inset: -3px;
background:
linear-gradient(currentColor,currentColor) 2px 1px / 1px 3px no-repeat,
linear-gradient(currentColor,currentColor) 7px 1px / 1px 3px no-repeat,
linear-gradient(currentColor,currentColor) 12px 1px / 1px 3px no-repeat,
linear-gradient(currentColor,currentColor) 2px calc(100% - 1px) / 1px 3px no-repeat,
linear-gradient(currentColor,currentColor) 7px calc(100% - 1px) / 1px 3px no-repeat,
linear-gradient(currentColor,currentColor) 12px calc(100% - 1px) / 1px 3px no-repeat,
linear-gradient(currentColor,currentColor) 1px 2px / 3px 1px no-repeat,
linear-gradient(currentColor,currentColor) 1px 7px / 3px 1px no-repeat,
linear-gradient(currentColor,currentColor) calc(100% - 1px) 2px / 3px 1px no-repeat,
linear-gradient(currentColor,currentColor) calc(100% - 1px) 7px / 3px 1px no-repeat;
opacity: 0.78;
}
.glyph-memory {
background:
linear-gradient(currentColor,currentColor) 2px 9px / 2px 4px no-repeat,
linear-gradient(currentColor,currentColor) 6px 6px / 2px 7px no-repeat,
linear-gradient(currentColor,currentColor) 10px 3px / 2px 10px no-repeat;
}
.glyph-memory::before {
content: "";
position: absolute;
inset: 1px;
border: 1.4px solid rgba(152,255,191,0.34);
border-radius: 3px;
}
.glyph-network {
background:
radial-gradient(circle at 2px 11px, currentColor 0 2px, transparent 2.4px),
radial-gradient(circle at 13px 11px, currentColor 0 2px, transparent 2.4px),
radial-gradient(circle at 7.5px 3px, currentColor 0 2px, transparent 2.4px),
linear-gradient(currentColor,currentColor) 7px 4px / 1.4px 7px no-repeat,
linear-gradient(32deg, transparent 44%, currentColor 45% 55%, transparent 56%) 2px 5px / 10px 7px no-repeat,
linear-gradient(-32deg, transparent 44%, currentColor 45% 55%, transparent 56%) 4px 5px / 10px 7px no-repeat;
}
.glyph-host {
border: 1.6px solid currentColor;
border-radius: 3px;
}
.glyph-host::before {
content: "";
position: absolute;
left: 3px;
right: 3px;
bottom: -2px;
height: 1.6px;
background: currentColor;
}
.glyph-host::after {
content: "";
position: absolute;
left: 5px;
right: 5px;
bottom: -5px;
height: 1.6px;
background: currentColor;
border-radius: 999px;
}
.glyph-docker {
background:
linear-gradient(currentColor,currentColor) 1px 3px / 4px 4px no-repeat,
linear-gradient(currentColor,currentColor) 6px 3px / 4px 4px no-repeat,
linear-gradient(currentColor,currentColor) 11px 3px / 4px 4px no-repeat,
linear-gradient(currentColor,currentColor) 6px 8px / 4px 4px no-repeat,
linear-gradient(currentColor,currentColor) 1px 12px / 14px 1.5px no-repeat;
border-radius: 3px;
}
.glyph-storage {
background:
linear-gradient(currentColor,currentColor) 1px 5px / 13px 1.6px no-repeat,
linear-gradient(currentColor,currentColor) 1px 8px / 13px 1.6px no-repeat,
linear-gradient(currentColor,currentColor) 1px 11px / 13px 1.6px no-repeat;
}
.glyph-storage::before {
content: "";
position: absolute;
inset: 1px 1px 2px;
border: 1.4px solid rgba(126,255,182,0.36);
border-radius: 4px;
}
.glyph-matrix {
background:
linear-gradient(currentColor,currentColor) 1px 1px / 5px 5px no-repeat,
linear-gradient(currentColor,currentColor) 9px 1px / 5px 5px no-repeat,
linear-gradient(currentColor,currentColor) 1px 9px / 5px 5px no-repeat,
linear-gradient(currentColor,currentColor) 9px 9px / 5px 5px no-repeat;
opacity: 0.95;
}
.glyph-scrutiny {
border: 1.6px solid currentColor;
border-radius: 50%;
}
.glyph-scrutiny::before {
content: "";
position: absolute;
inset: 3px;
border: 1.4px solid currentColor;
border-radius: 50%;
}
.glyph-scrutiny::after {
content: "";
position: absolute;
left: 7px;
top: 1px;
width: 1.4px;
height: 13px;
background: currentColor;
box-shadow: -6px 6px 0 -5px currentColor, 6px 6px 0 -5px currentColor;
opacity: 0.9;
}
.glyph-home {
background:
linear-gradient(-35deg, transparent 45%, currentColor 46% 56%, transparent 57%) 0 1px / 8px 7px no-repeat,
linear-gradient(35deg, transparent 45%, currentColor 46% 56%, transparent 57%) 7px 1px / 8px 7px no-repeat,
linear-gradient(currentColor,currentColor) 3px 7px / 9px 6px no-repeat;
}
.glyph-kuma {
background:
linear-gradient(currentColor,currentColor) 1px 8px / 3px 1.5px no-repeat,
linear-gradient(55deg, transparent 43%, currentColor 44% 56%, transparent 57%) 3px 6px / 4px 5px no-repeat,
linear-gradient(-55deg, transparent 43%, currentColor 44% 56%, transparent 57%) 6px 4px / 4px 7px no-repeat,
linear-gradient(55deg, transparent 43%, currentColor 44% 56%, transparent 57%) 9px 6px / 4px 5px no-repeat,
linear-gradient(currentColor,currentColor) 12px 8px / 3px 1.5px no-repeat;
}
.glyph-image {
border: 1.5px solid currentColor;
border-radius: 3px;
}
.glyph-image::before {
content: "";
position: absolute;
left: 2px;
right: 2px;
bottom: 2px;
height: 5px;
background:
linear-gradient(140deg, transparent 35%, currentColor 36% 48%, transparent 49%) 0 0 / 8px 5px no-repeat,
linear-gradient(45deg, transparent 32%, currentColor 33% 45%, transparent 46%) 5px 0 / 8px 5px no-repeat;
}
.glyph-image::after {
content: "";
position: absolute;
right: 2px;
top: 2px;
width: 3px;
height: 3px;
border-radius: 50%;
background: currentColor;
}
.glyph-backrest {
border: 1.5px solid currentColor;
border-radius: 3px;
}
.glyph-backrest::before {
content: "";
position: absolute;
left: 2px;
right: 2px;
top: 3px;
height: 2px;
background: currentColor;
box-shadow: 0 4px 0 currentColor;
}
.glyph-backrest::after {
content: "";
position: absolute;
left: 5px;
right: 5px;
bottom: -2px;
height: 1.5px;
background: currentColor;
}
.glyph-shield {
background:
linear-gradient(currentColor,currentColor) 7px 2px / 1.8px 9px no-repeat;
clip-path: polygon(50% 0%, 88% 16%, 88% 50%, 50% 100%, 12% 50%, 12% 16%);
background-color: transparent;
border: 1.5px solid currentColor;
}
.glyph-services {
background:
radial-gradient(circle at 2px 7px, currentColor 0 2px, transparent 2.4px),
radial-gradient(circle at 13px 2px, currentColor 0 2px, transparent 2.4px),
radial-gradient(circle at 13px 12px, currentColor 0 2px, transparent 2.4px),
linear-gradient(currentColor,currentColor) 4px 6px / 7px 1.4px no-repeat,
linear-gradient(currentColor,currentColor) 10px 4px / 1.4px 6px no-repeat;
}
.service-name { font-size: 10px; letter-spacing: 1.4px; }
.status-pill {
width: 10px;
height: 10px;
min-width: 10px;
border-radius: 999px;
padding: 0;
font-size: 0;
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 12px rgba(0,0,0,0.18);
}
.pill-online { box-shadow: 0 0 10px rgba(0,220,140,0.32); }
.pill-degraded { box-shadow: 0 0 10px rgba(255,204,68,0.28); }
.pill-offline { box-shadow: 0 0 10px rgba(255,68,102,0.28); }
.progress-wrap {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.progress-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-family: var(--font-mono);
font-size: 8px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.8px;
}
.progress-bar { height: 4px; border-radius: 999px; background: rgba(0,220,140,0.08); }
.mini-graph {
display: flex;
align-items: end;
gap: 3px;
height: 16px;
}
.mini-bar {
flex: 1;
min-width: 0;
border-radius: 2px 2px 0 0;
background: linear-gradient(180deg, rgba(127,255,199,0.9), rgba(0,220,140,0.22));
box-shadow: 0 0 8px rgba(0,220,140,0.12);
}
.mini-bar.warn { background: linear-gradient(180deg, rgba(255,204,68,0.95), rgba(255,204,68,0.24)); }
.mini-bar.danger { background: linear-gradient(180deg, rgba(255,68,102,0.95), rgba(255,68,102,0.24)); }
.mini-bar.blue { background: linear-gradient(180deg, rgba(68,170,255,0.95), rgba(68,170,255,0.24)); }
.services-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
margin-bottom: 6px;
}
.service-card { display: flex; flex-direction: column; justify-content: space-between; }
.service-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 4px;
font-family: var(--font-mono);
font-size: 8px;
color: var(--text-dim);
text-transform: uppercase;
}
.micro-strip { display: flex; gap: 3px; min-height: 8px; align-items: center; }
.micro-seg { width: 7px; height: 7px; border-radius: 999px; background: rgba(255,255,255,0.1); }
.micro-seg.up { background: var(--teal); box-shadow: 0 0 8px rgba(0,220,140,0.22); }
.micro-seg.down { background: var(--red); box-shadow: 0 0 8px rgba(255,68,102,0.22); }
.micro-seg.warn { background: var(--yellow); box-shadow: 0 0 8px rgba(255,204,68,0.22); }
#quick-access-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 6px;
}
.quick-tile {
min-height: 74px;
padding: 8px 10px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 9px;
text-align: left;
}
.quick-tile-icon {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font-size: 11px;
font-family: var(--font-display);
background: linear-gradient(135deg, var(--teal-bright), var(--teal));
color: #04110d;
}
.quick-tile-copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.quick-tile-label { font-size: 9px; color: var(--text-bright); }
.quick-tile-meta {
font-family: var(--font-mono);
font-size: 7px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1px;
}
.quick-tile-icon-ha { background: linear-gradient(135deg, #6cb8ff, #9e8dff); }
.quick-tile-icon-komodo { background: linear-gradient(135deg, #00e2b3, #68c7ff); }
.quick-tile-icon-kuma { background: linear-gradient(135deg, #00d98a, #7fffc7); }
.quick-tile-icon-beszel { background: linear-gradient(135deg, #53f1b4, #a9ffd8); }
.quick-tile-icon-firefly { background: linear-gradient(135deg, #ffb54d, #ffd66f); }
.quick-tile-icon-paperless { background: linear-gradient(135deg, #89ffdc, #46cfa0); }
.quick-tile-icon-mealie { background: linear-gradient(135deg, #7ec2ff, #f6ff8c); }
.quick-tile-icon-immich { background: linear-gradient(135deg, #ffd15c, #ff9d4d); }
.quick-tile-icon-gitea { background: linear-gradient(135deg, #9cff87, #3cd675); }
.quick-tile-icon-code { background: linear-gradient(135deg, #5cc4ff, #5f92ff); }
.quick-tile-icon-files { background: linear-gradient(135deg, #6ec8ff, #9be1ff); }
.quick-tile-icon-backrest { background: linear-gradient(135deg, #8bb4ff, #6fd8ff); }
.quick-tile-icon-vault { background: linear-gradient(135deg, #ffe173, #ffaa5c); }
.quick-tile-icon-adguard { background: linear-gradient(135deg, #57ffaa, #57d8ff); }
.quick-tile-icon-traefik { background: linear-gradient(135deg, #8f9fff, #6cc4ff); }
.quick-tile-icon-scrutiny { background: linear-gradient(135deg, #9cffcf, #61ffaa); }
.storage-layout {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
margin-bottom: 6px;
align-items: stretch;
}
.storage-layout > div:first-child { display: contents; }
#storage-grid { display: contents; }
.scrutiny-row { padding: 1px 0; border-bottom: none; gap: 6px; font-size: 8px; }
.scrutiny-offline { font-size: 8px; padding: 2px 0; }
.scrutiny-strip,
.storage-strip {
display: flex;
gap: 4px;
flex-wrap: wrap;
overflow: hidden;
margin-top: 4px;
}
.scrutiny-chip,
.storage-chip {
font-family: var(--font-mono);
font-size: 7px;
color: var(--text-dim);
padding: 2px 5px;
border-radius: 999px;
border: 1px solid rgba(0,220,140,0.12);
background: rgba(255,255,255,0.02);
white-space: nowrap;
}
.scrutiny-chip.ok { color: var(--teal-bright); border-color: rgba(0,220,140,0.22); }
.scrutiny-chip.fail { color: var(--red); border-color: rgba(255,68,102,0.24); }
.scrutiny-chip.unk { color: var(--text-dim); border-color: rgba(255,255,255,0.08); }
.scrutiny-chip strong {
color: currentColor;
font-family: var(--font-display);
font-size: 7px;
letter-spacing: 0.6px;
margin-right: 4px;
}
.storage-matrix-card .stats-grid { margin-bottom: 6px; }
.storage-matrix-card .service-footer { margin-top: auto; }
.system-card .card-title,
.service-card .card-title,
.storage-card .card-title { align-items: center; }
@media (max-width: 1360px) {
.services-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
#quick-access-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.row-5 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 960px) {
.services-grid, #quick-access-grid, #storage-grid, .storage-layout, .row-5 { grid-template-columns: 1fr; }
.card { max-height: none; }
.stats-grid { grid-auto-flow: row; grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>
</head>
<body>
<div class="scanline"></div>
<div class="wrapper">
<!-- HEADER -->
<header class="header" id="header">
<div class="header-logo">
<div>
<div class="logo-text">KALLILAB</div>
<div class="logo-sub">Control Panel</div>
</div>
</div>
<div class="header-center">
<div class="overall-status">SYSTEM STATUS</div>
<div class="status-indicator">
<span class="status-dot-main" id="overall-dot"></span>
<span id="overall-status-text">LOADING</span>
</div>
</div>
<div class="header-right">
<div class="clock" id="clock">--:--:--</div>
<div class="date-str" id="date-str">---</div>
<div id="last-updated">never updated</div>
</div>
</header>
<!-- SYSTEM STATS ROW -->
<div class="section-header"><span>&#x2B21;</span> SYSTEM</div>
<div class="widget-row row-5" id="stats-row" style="margin-bottom:8px;">
<div class="card service-card system-card">
<div class="card-title">
<div class="card-title-left"><span class="service-icon icon-cpu"><span class="icon-glyph glyph-cpu"></span></span><span class="service-name">CPU</span></div>
<span class="status-pill pill-online">OK</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="cpu-percent">&#x2014;</div><div class="stat-label">Usage %</div></div>
<div class="stat-block"><div class="stat-num dim" id="cpu-cores">&#x2014;</div><div class="stat-label">Cores</div></div>
<div class="stat-block"><div class="stat-num dim" id="cpu-load">&#x2014;</div><div class="stat-label">Load 5m</div></div>
</div>
<div class="progress-wrap">
<div class="progress-meta"><span>Compute Load</span><span id="cpu-progress-label">0%</span></div>
<div class="progress-bar"><div class="progress-fill" id="cpu-progress"></div></div>
<div class="mini-graph" id="cpu-graph"></div>
</div>
</div>
<div class="card service-card system-card">
<div class="card-title">
<div class="card-title-left"><span class="service-icon icon-memory"><span class="icon-glyph glyph-memory"></span></span><span class="service-name">MEMORY</span></div>
<span class="status-pill pill-online">OK</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="ram-percent">&#x2014;</div><div class="stat-label">Usage %</div></div>
<div class="stat-block"><div class="stat-num dim" id="ram-used">&#x2014;</div><div class="stat-label">Used GB</div></div>
<div class="stat-block"><div class="stat-num dim" id="ram-total">&#x2014;</div><div class="stat-label">Total GB</div></div>
</div>
<div class="progress-wrap">
<div class="progress-meta"><span>Memory Pool</span><span id="ram-progress-label">0%</span></div>
<div class="progress-bar"><div class="progress-fill" id="ram-progress"></div></div>
<div class="mini-graph" id="ram-graph"></div>
</div>
</div>
<div class="card service-card system-card">
<div class="card-title">
<div class="card-title-left"><span class="service-icon icon-network"><span class="icon-glyph glyph-network"></span></span><span class="service-name">NETWORK</span></div>
<span class="status-pill pill-online">OK</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="net-rx">&#x2014;</div><div class="stat-label">&#x2193; Mbps</div></div>
<div class="stat-block"><div class="stat-num" id="net-tx">&#x2014;</div><div class="stat-label">&#x2191; Mbps</div></div>
</div>
<div class="progress-wrap">
<div class="progress-meta"><span>Traffic Flow</span><span id="net-progress-label">0 Mbps</span></div>
<div class="progress-bar"><div class="progress-fill" id="net-progress"></div></div>
<div class="mini-graph" id="net-graph"></div>
</div>
</div>
<div class="card service-card system-card">
<div class="card-title">
<div class="card-title-left"><span class="service-icon icon-host"><span class="icon-glyph glyph-host"></span></span><span class="service-name">HOST</span></div>
<span class="status-pill pill-online">OK</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="uptime-days">&#x2014;</div><div class="stat-label">Uptime d</div></div>
<div class="stat-block"><div class="stat-num dim" id="host-platform">&#x2014;</div><div class="stat-label">OS</div></div>
</div>
<div class="progress-wrap">
<div class="progress-meta"><span>Host Runtime</span><span id="host-progress-label">&#x2014;</span></div>
<div class="progress-bar"><div class="progress-fill" id="host-progress"></div></div>
<div class="mini-graph" id="host-graph"></div>
</div>
</div>
<div class="card service-card system-card">
<div class="card-title">
<div class="card-title-left"><span class="service-icon icon-docker"><span class="icon-glyph glyph-docker"></span></span><span class="service-name">DOCKER</span></div>
<span class="status-pill pill-online">OK</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="docker-running">&#x2014;</div><div class="stat-label">Running</div></div>
<div class="stat-block"><div class="stat-num dim" id="docker-stopped">&#x2014;</div><div class="stat-label">Stopped</div></div>
<div class="stat-block"><div class="stat-num dim" id="docker-total">&#x2014;</div><div class="stat-label">Total</div></div>
</div>
<div class="progress-wrap">
<div class="progress-meta"><span>Runtime Surface</span><span id="docker-progress-label">0%</span></div>
<div class="progress-bar"><div class="progress-fill" id="docker-progress"></div></div>
<div class="mini-graph" id="docker-graph"></div>
</div>
</div>
</div>
<!-- STORAGE + SCRUTINY ROW -->
<div class="section-header"><span>&#x2B21;</span> STORAGE &amp; HEALTH</div>
<div class="storage-layout">
<div>
<div class="widget-row row-3" id="storage-grid">
<!-- Disk cards injected by renderer -->
</div>
</div>
<div class="card service-card">
<div class="card-title">
<div class="card-title-left"><span class="service-icon icon-scrutiny"><span class="icon-glyph glyph-scrutiny"></span></span><span class="service-name">SCRUTINY</span></div>
<span class="status-pill pill-offline" id="scrutiny-pill">OFFLINE</span>
</div>
<div class="stats-grid" style="margin-bottom:5px;">
<div class="stat-block"><div class="stat-num" id="scrutiny-total">&#x2014;</div><div class="stat-label">Disks</div></div>
<div class="stat-block"><div class="stat-num" id="scrutiny-passed">&#x2014;</div><div class="stat-label">Passed</div></div>
<div class="stat-block"><div class="stat-num danger" id="scrutiny-failed">&#x2014;</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>&#x2B21;</span> SERVICES</div>
<div class="services-grid">
<div class="card service-card">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon icon-ha"><span class="icon-glyph glyph-home"></span></span>
<span class="service-name">HOME ASSISTANT</span>
</div>
<span class="status-pill pill-offline" id="ha-pill">OFFLINE</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="ha-lights">&#x2014;</div><div class="stat-label">Lights</div></div>
<div class="stat-block"><div class="stat-num" id="ha-climate">&#x2014;</div><div class="stat-label">Climate</div></div>
<div class="stat-block"><div class="stat-num" id="ha-doors">&#x2014;</div><div class="stat-label">Doors</div></div>
<div class="stat-block"><div class="stat-num danger" id="ha-alerts">&#x2014;</div><div class="stat-label">Alerts</div></div>
</div>
<div class="service-footer"><span id="ha-version">Core automation hub</span><span></span></div>
</div>
<div class="card service-card">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon icon-kuma"><span class="icon-glyph glyph-kuma"></span></span>
<span class="service-name">UPTIME KUMA</span>
</div>
<span class="status-pill pill-offline" id="uk-pill">OFFLINE</span>
</div>
<div class="stats-grid" style="margin-bottom:5px;">
<div class="stat-block"><div class="stat-num" id="uk-up">&#x2014;</div><div class="stat-label">Up</div></div>
<div class="stat-block"><div class="stat-num danger" id="uk-down">&#x2014;</div><div class="stat-label">Down</div></div>
<div class="stat-block"><div class="stat-num warn" id="uk-paused">&#x2014;</div><div class="stat-label">Paused</div></div>
<div class="stat-block"><div class="stat-num" id="uk-uptime">&#x2014;</div><div class="stat-label">24h %</div></div>
</div>
<div class="service-footer"><span id="uk-footer">No monitor data</span><div class="micro-strip" id="uk-bars"></div></div>
</div>
<div class="card service-card">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon icon-immich"><span class="icon-glyph glyph-image"></span></span>
<span class="service-name">IMMICH</span>
</div>
<span class="status-pill pill-offline" id="immich-pill">OFFLINE</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="immich-photos">&#x2014;</div><div class="stat-label">Photos</div></div>
<div class="stat-block"><div class="stat-num" id="immich-videos">&#x2014;</div><div class="stat-label">Videos</div></div>
<div class="stat-block"><div class="stat-num dim" id="immich-storage">&#x2014;</div><div class="stat-label">Storage</div></div>
</div>
</div>
<div class="card service-card">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon icon-backrest"><span class="icon-glyph glyph-backrest"></span></span>
<span class="service-name">BACKREST</span>
<span class="status-dot dot-unk" id="backrest-status-dot" title="unknown"></span>
</div>
<span class="status-pill pill-offline" id="backrest-pill">OFFLINE</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num dim" id="backrest-last">&#x2014;</div><div class="stat-label">Last Backup</div></div>
<div class="stat-block"><div class="stat-num" id="backrest-repos">&#x2014;</div><div class="stat-label">Repos</div></div>
<div class="stat-block"><div class="stat-num danger" id="backrest-errors">&#x2014;</div><div class="stat-label">Errors</div></div>
</div>
</div>
<div class="card service-card">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon icon-adguard"><span class="icon-glyph glyph-shield"></span></span>
<span class="service-name">ADGUARD DNS</span>
</div>
<span class="status-pill pill-offline" id="adguard-pill">OFFLINE</span>
</div>
<div class="stats-grid" style="margin-bottom:5px;">
<div class="stat-block"><div class="stat-num" id="adguard-total">&#x2014;</div><div class="stat-label">Queries</div></div>
<div class="stat-block"><div class="stat-num" id="adguard-blocked">&#x2014;</div><div class="stat-label">Blocked</div></div>
<div class="stat-block"><div class="stat-num dim" id="adguard-blocked-pct">&#x2014;</div><div class="stat-label">Block %</div></div>
<div class="stat-block"><div class="stat-num dim" id="adguard-latency">&#x2014;</div><div class="stat-label">Latency</div></div>
</div>
<div class="adguard-bar-wrap"><div class="adguard-bar"><div class="adguard-bar-fill" id="adguard-bar-fill"></div></div></div>
</div>
<div class="card service-card">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon icon-services"><span class="icon-glyph glyph-services"></span></span>
<span class="service-name">SERVICES OVERVIEW</span>
</div>
<span class="status-pill pill-offline" id="services-pill">&#x2014;</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="svc-online">&#x2014;</div><div class="stat-label">Online</div></div>
<div class="stat-block"><div class="stat-num warn" id="svc-degraded">&#x2014;</div><div class="stat-label">Degraded</div></div>
<div class="stat-block"><div class="stat-num danger" id="svc-offline">&#x2014;</div><div class="stat-label">Offline</div></div>
<div class="stat-block"><div class="stat-num dim" id="svc-total">&#x2014;</div><div class="stat-label">Total</div></div>
</div>
</div>
</div>
<!-- QUICK ACCESS -->
<div class="section-header"><span>&#x2B21;</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>
-54
View File
@@ -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
-24
View File
@@ -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
-4
View File
@@ -1,4 +0,0 @@
MYSQL_RANDOM_ROOT_PASSWORD=yes
MYSQL_DATABASE=firefly
MYSQL_USER=firefly
MYSQL_PASSWORD=firefly
-16
View File
@@ -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
-8
View File
@@ -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
-67
View File
@@ -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
-42
View File
@@ -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
View File
+19 -9
View File
@@ -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:v2.7.5@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:v2.7.5@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
View File
+3 -3
View File
@@ -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
View File
+22 -7
View File
@@ -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:29ee7bb30d804447dc9a91fd0d74322ae1dc3a4072cc6346f70a5ed6e783b565
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
+51
View File
@@ -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
+284
View File
@@ -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": []
}
+84
View File
@@ -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:29ee7bb30d804447dc9a91fd0d74322ae1dc3a4072cc6346f70a5ed6e783b565
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 -1
View File
@@ -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:
+13 -10
View File
@@ -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
View File
+23 -3
View File
@@ -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
+2 -2
View File
@@ -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
+22
View File
@@ -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]
+52
View File
@@ -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
+63
View File
@@ -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.
+58
View File
@@ -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.
+66
View File
@@ -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]
+45
View File
@@ -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)
View File
+16 -2
View File
@@ -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"
+53
View File
@@ -0,0 +1,53 @@
# AI Context
Typ: Einstieg/Index · Stand: 2026-06-11 · Status: aktiv
Kurzer Kontext fuer KI-Agenten. Nicht als Ersatz fuer die echten Runbooks lesen.
Diese Datei enthaelt bewusst **keinen** Arbeitsstand mehr — Status nur in
`docs/MASTER_TODO.md`, Entscheidungen nur in `docs/DECISIONS.md`.
## 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`
6. bei "warum ist das so?"-Fragen `docs/DECISIONS.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.
- Doku-Regel: ein Fakt hat genau ein Zuhause; verlinken statt kopieren (`docs/REPO_MAP.md`).
## Bekannte Ausnahmen
Autoritativ: `HOMELAB_ARCHITECTURE_MASTER_V2.md` §10. Kurzliste:
- 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: natives Unraid-Plugin (nicht repo-verwaltet); Plex: Host-Netz
- Scrutiny: privileged; Komodo/Periphery: Docker-Socket
- InfluxDB 3 Core: `127.0.0.1:8181`, Root-User-Ausnahme dokumentiert
## Arbeitsstand
- Offene Punkte: `docs/MASTER_TODO.md` (einzige Statusliste)
- Entscheidungen und Begruendungen: `docs/DECISIONS.md`
- Belege/Reports: `/mnt/user/backups/restore-reports/` auf dem Host
+54
View File
@@ -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`.
+186
View File
@@ -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.
+98
View File
@@ -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 `ops/h-drive-nearline/README.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` |
+169
View File
@@ -0,0 +1,169 @@
# Entscheidungs-Register (ADR-light)
Typ: Entscheidung · Stand: 2026-06-11 · Status: aktiv
Zentrales Register fuer Architektur- und Betriebsentscheidungen. Neueste oben.
Jeder Eintrag: Entscheidung, Kontext, ggf. Alternativen und Review-Trigger.
Lange Incident-Erzaehlungen gehoeren nicht hierher, sondern in den Commit bzw.
Host-Report; hier steht das Destillat. Vorher lebten diese Eintraege verstreut
in `HOMELAB_ARCHITECTURE_MASTER_V2.md` §13, `docs/MASTER_TODO.md` (Geparkt),
`docs/HARDWARE_INVENTORY.md` und der Audit-Restliste.
---
## 2026-06-12 - Home Assistant als Container im GitOps-Stack
**Entscheidung:** Home Assistant laeuft neu als `homeassistant` Container im
Stack `smart-home/`, nicht als HAOS-VM und nicht als Supervised-Installation.
Mosquitto laeuft als eigener Container im selben Stack; Zigbee2MQTT und ESPHome
werden spaeter ebenfalls als eigenstaendige Container ergaenzt. HA haengt in
`frontend_net` fuer Traefik und in `smarthome_net` fuer MQTT/Zigbee2MQTT/ESPHome.
Das Fachrepo `smart-home-kalli` liefert versionierte HA-YAML-Dateien read-only;
`.storage`, `secrets.yaml` und Integrations-State bleiben in
`/mnt/user/appdata/homeassistant`.
**Kontext:** Das fruehere HAOS-VM-Setup ging bei einem Crash ohne brauchbares
Backup verloren. Das Homelab betreibt produktive Dienste inzwischen ueber
Gitea, Komodo, Compose, Renovate und Borg. HA Container passt in dieses
Betriebsmodell und vermeidet eine zweite Update-/Backup-Welt. Supervised ist
kein Zielpfad mehr; HAOS bleibt die Alternative, falls Add-on-Komfort,
Matter/Thread/HomeKit-Discovery oder Host-nahe HA-Funktionen wichtiger werden
als GitOps-Konformitaet.
**Review-Trigger:** Viele mDNS-/SSDP-abhaengige lokale Integrationen
(HomeKit, Cast, Matter/Thread), Bedarf an HA-Add-ons als Betriebsstandard,
oder wiederholte Probleme durch Bridge-Netzwerkbetrieb.
## 2026-06-12 - Ecowitt-Ingress bleibt bewusste Phase-2-Entscheidung
**Entscheidung:** In Phase 1 wird kein Host-Port `8123` fuer Home Assistant
veroeffentlicht. Ecowitt wird spaeter entweder ueber eine gezielte
Traefik-HTTP-Ausnahme fuer den Webhook-Pfad angebunden oder, falls der globale
HTTP-zu-HTTPS-EntryPoint-Redirect nicht sauber selektiv abloesbar ist, ueber
einen dokumentierten LAN-only Host-Port `8123`.
**Kontext:** Ecowitt kann nur HTTP und kein HTTPS. Traefik hat aktuell einen
globalen `web` -> `websecure` Redirect auf EntryPoint-Ebene. Ein normaler
HTTP-Router kann diese Regel voraussichtlich nicht umgehen, ohne Traefik selbst
umzubauen. Deshalb wird die Entscheidung nicht vorgezogen.
**Review-Trigger:** Start der Ecowitt-/InfluxDB-Phase oder Umbau der Traefik
HTTP-Redirect-Architektur.
## 2026-06-11 — Host-DNS-Fallback aktiv (AdGuard-SPOF entschaerft)
**Entscheidung:** Unraid-Host nutzt `eth0` DNS server 1 = `192.168.178.58` (AdGuard) und **DNS server 2 = `192.168.178.1`** (FRITZ!Box) als Failover.
**Kontext:** AdGuard war einziger LAN-Resolver; ein Recreate hat 2026-06 einen Bulk-Deploy zerlegt, weil Docker-Pulls am eigenen DNS-Container scheiterten. Der Fallback bleibt nur passiv aktiv (Go-Resolver springt erst bei Socket-Fehler weiter), der Filter wirkt im Normalbetrieb unveraendert. `options rotate` ist nicht gesetzt. Umsetzung der Empfehlung 3a aus dem Optimierungs-Assessment vom 2026-06-10. Runbook: `docs/runbooks/komodo-bulk-deploy-dns.md`.
**Review-Trigger:** Wenn AdGuard durch eine andere Filter-Loesung ersetzt wird oder ein zweiter Host-Resolver verfuegbar ist.
## 2026-06-11 — Hetzner Storage Box: automatische Snapshots aktiv
**Entscheidung:** Automatische Snapshots auf der Hetzner Storage Box (BX11, `u565255.your-storagebox.de`) sind aktiv: taeglich um 05:30 UTC (nach dem Borg-Lauf 04:30 lokal), Retention 7 Tage, Snapshot-Verzeichnis sichtbar fuer Einzeldatei-Restore via `.zfs/snapshot/`.
**Kontext:** Borg `append-only` ist bewusst nicht umgesetzt (siehe Eintrag 2026-06-01); damit war ein kompromittierter Host bisher in der Lage, auch das Off-site-Backup zu loeschen. Storage-Box-Snapshots sind host-seitig nicht loeschbar und im BX11-Tarif inklusive. Kosten: 0 EUR zusaetzlich. Umsetzung der Empfehlung 2 aus dem Optimierungs-Assessment vom 2026-06-10.
**Review-Trigger:** Hetzner-Quota-Druck (aktuell 65 GB / 1 TB - viel Luft) oder Aenderung der Backup-Strategie.
## 2026-06-11 — Doku-Konsolidierung: ein Fakt, ein Zuhause
**Entscheidung:** Die Dokumentation wird nach `docs/archive/2026/homelab-doku-optimierung-2026-06-11.md` konsolidiert: `MASTER_TODO.md` ist die einzige Statusliste, dieses Register die einzige Entscheidungssammlung, `docs/archive/` nimmt abgeschlossene Snapshots auf, Erledigtes verlaesst die Arbeitskopie. Keine Ordner-Restruktur des Bestands.
**Kontext:** 74 Markdown-Dateien / ~9.400 Zeilen; einzelne Sachverhalte waren an 69 Stellen dokumentiert; vier parallele Statuslisten.
**Review-Trigger:** Quartals-Gaertnern (siehe `docs/REPO_MAP.md` Doku-Regeln).
## 2026-06-06 — baerchen: BitLocker und Veeam Storage Encryption bewusst aus
**Entscheidung:** BitLocker bleibt auf allen Laufwerken deaktiviert; Veeam Storage Encryption bleibt aus (`StorageEncryptionEnabled=False`).
**Kontext:** Recovery laeuft ueber das Veeam-Image auf dem lokalen SMB-Share; kein Key-Management-Aufwand, Restrisiko physischer Diebstahl akzeptiert.
**Review-Trigger:** Off-host-Auslagerung des Windows-Images oder geaendertes Risikoprofil. Runbook: `ops/windows-reinstall/docs/windows-image-backup-baseline.md`.
## 2026-06-06 — Tailscale: natives Unraid-Plugin kanonisch, restriktive ACL
**Entscheidung:** Tailscale laeuft ausschliesslich als natives Unraid-Plugin (`tailscale.plg`, Subnet-Router, State im Flash-Backup); der redundante userspace-Docker-Stack `host-services/tailscale/` wurde entfernt. Tailnet-ACL ist tag-basiert restriktiv (`tag:server`/`tag:operator`, `tag:family` schlafend), Default-Allow entfernt.
**Kontext:** Zwei parallele `tailscaled`-Instanzen; nur die Plugin-Instanz routet. Details: `docs/NETWORK_INVENTORY.md`.
**Review-Trigger:** Erstes reales Familiengeraet (Familien-Dienste in ACL konkretisieren).
## 2026-06-06 — Authelia: 2FA-Catch-all aktiv, OIDC-Rollout gestaffelt
**Entscheidung:** Catch-all `*.kaleschke.info` -> `two_factor` in Repo- und Host-Config. OIDC-SSO wird app-weise ausgerollt (live: Grafana, Mealie; deployed: Paperless). Immich- und Nextcloud-OIDC sowie Nextcloud-Operator-TOTP sind geparkt, bis Familien-Accounts existieren.
**Kontext:** Nur der Operator hat aktuell einen Authelia-Account; Familien-SSO-Nutzen entsteht erst mit dem Onboarding. Runbook: `docs/AUTHELIA_OIDC_PLAN.md`.
**Review-Trigger:** Family-Onboarding erreicht die App-Login-Ebene.
## 2026-06-05 — USV geparkt, Cold-Backup Hetzner-only, kein Strom-Monitoring
**Entscheidung:** Keine USV-Anschaffung dieses Quartal (Power-Loss bewusst akzeptiert). Off-site bleibt allein Hetzner-Borg, keine zweite rotierende Cold-Kopie. Stromverbrauch wird nicht gemessen (kein Messgeraet, kein Beschaffungs-Todo).
**Review-Trigger:** USV: Q3-Review ab 2026-07-01, Hardware-Upgrade oder realer Stromausfall mit Datenfolge. Cold-Backup: Hetzner-Probleme oder stark wachsender Datenwert. Strom: nur bei Anschaffung eines Messgeraets.
## 2026-06-03 — Fix Common Problems Plugin entfernt, keine Neuinstallation
**Entscheidung:** FCP wurde deinstalliert und wird bewusst nicht wieder installiert.
**Kontext:** Ein FCP-Scan hing 7 Tage in einem `grep -R`-Symlink-Loop ueber das gesamte Array (3 Cores 100 %, IOWAIT bis 55 %, Load 14.6 -> 1.08 nach Entfernung). Die abgedeckten Risiken uebernehmen Scrutiny, Monitoring-Stack, Posture-Check und Critical-Events-Watcher.
**Review-Trigger:** keiner; Entscheidung ist final.
## 2026-06-01 — Borg append-only auf Hetzner nicht umgesetzt
**Entscheidung:** Kein append-only/forced-command auf der Storage Box.
**Kontext:** Der forced-command-Test brach die Key-Auth und musste per Passwort-Recovery zurueckgesetzt werden; Nutzen/Betriebsrisiko-Verhaeltnis unguenstig. Kompensation (Storage-Box-Snapshots) siehe `docs/homelab-optimierung.md` Empfehlung 2.
**Review-Trigger:** Hetzner bietet robusteren Mechanismus, oder Ransomware-Risikoprofil aendert sich.
## 2026-05-28 — Plex: Reclaim, Traefik-Route ohne ForwardAuth, kein Remote Access
**Entscheidung:** Plex-Server ist als Operator-Konto geclaimt; externer Zugriff laeuft ausschliesslich ueber Traefik/443 (`plex.kaleschke.info`, File-Provider-Ausnahme wegen Host-Netz), Plex Remote Access und WAN-Port 32400 bleiben aus, keine Authelia-ForwardAuth (native Plex-Auth).
**Kontext:** Preferences waren nach dem Mai-Crash jungfraeulich; Claim-Token wurde nur als Shell-Inline-ENV genutzt, nie persistiert. Details: `docs/SERVICE_CATALOG.md`, `HOMELAB_ARCHITECTURE_MASTER_V2.md` §10.
## 2026-05-28 — Gitea-SSH (222) bleibt ohne WAN-Freigabe
**Entscheidung:** Port 222 wird nicht in der FRITZ!Box freigegeben.
**Kontext:** Tailscale ist der Operator-Pfad, der GitHub-Mirror deckt DR-Bootstrap ab, SSH-Brute-Force-Vektor extern vermeiden.
## 2026-05-28 — paperless-gpt und BentoPDF bleiben aktiv
**Entscheidung:** Beide Container bleiben trotz geringer Nutzung. paperless-gpt-Abloese wird erst mit Paperless-NGX 3.0 (eigene KI-Features) neu bewertet; BentoPDF ist situatives Tool mit vernachlaessigbarem Footprint und ersetzt Stirling-PDF.
**Review-Trigger:** Paperless-NGX-3.0-Release.
## 2026-05-26 — AdGuard-Admin nur auf Tailscale-IP, ohne Traefik/2FA
**Entscheidung:** Admin-UI bleibt auf `100.80.98.33:8082` (Tailscale-only) gebunden; bewusst keine Traefik-/2FA-Umstellung. DNS-Port 53 bleibt direkte Host-Port-Ausnahme.
**Review-Trigger:** Aenderung des Tailnet-Zugangsmodells.
## 2026-05-25 — Ein Dienst pro Funktion: Jellyfin, Homepage, Uptime-Kuma entfernt
**Entscheidung:** Plex ist der einzige Medienserver, Glance das einzige Dashboard, Blackbox-Exporter + Prometheus-Alerts + Grafana ersetzen Uptime-Kuma.
**Kontext:** Doppelte Dienste = doppelte Pflege/Attack-Surface. Removal-Checkliste: `docs/WORKFLOW.md`.
## 2026-05-17 — Monitoring-/Logging-Baseline
**Entscheidung:** `monitoring/` ist der einzige Observability-Stack (Prometheus, Loki, Promtail, Grafana, Exporter, InfluxDB 3 Core). Loki intern ohne Route, Promtail mit read-only Docker-Socket, Loki-Daten sind Diagnosematerial mit Retention, keine Restore-Quelle. Alte Pfade `ops/loki`/`ops/grafana-influxdb` sind entfernt (Rollback nur via Git-Historie).
## 2026-05-05 — Stateful Digest-Pinning und Versionspolitik
**Entscheidung:** Tier-1-/stateful Dienste laufen mit sprechendem Versions-Tag plus Digest (z. B. `postgres:17.x@sha256:...`); mutable Tags wurden 2026-04-17 auf laufende Digests eingefroren. Digest-Pinning ist Reproduzierbarkeit, kein Upgrade-Mechanismus; echte Upgrades sind eigene Aenderungsbloecke. Renovate (live seit 2026-05-29) liefert PRs, kein Auto-Merge.
**Review-Trigger:** Mutable-Tag-Restbestand siehe `docs/homelab-optimierung.md` Empfehlung 1.
## 2026-05-04 — Authelia ohne Redis-Session-Backend
**Entscheidung:** Authelia nutzt PostgreSQL fuer Storage, aber kein Redis-Session-Backend; nach Restart werden Sessions neu aufgebaut.
**Kontext:** Haelt den Tier-1-Auth-Pfad einfach. `infra/redis` ist faktisch nur Paperless-Cache; Konsolidierung nach `apps/paperless/` bleibt denkbar, unpriorisiert.
## 2026-05-04 — Komodo-Self-Stack: Reconcile-Regel nach Drift
**Entscheidung:** Der Komodo-Self-Stack laeuft aus `/mnt/user/services/stacks/komodo/compose.yaml` (Quelle: `ops/komodo/docker-compose.yml`). Bei Self-Stack-Drift kein pauschales `docker compose up -d`, wenn der Dry-run `komodo-mongo` recreaten wuerde; Core/Periphery gezielt mit `--no-deps` neu erstellen, Mongo unangetastet lassen.
**Kontext:** Drift-Recovery 2026-05-04 (Repair-YAMLs aus `/tmp`); Sicherungen unter `/mnt/user/appdata/komodo/_drift_backup_2026-05-04/`.
## 2026-04-19 — Nextcloud als klassischer Stack, nicht AIO; native Auth
**Entscheidung:** Nextcloud laeuft als App + eigene PostgreSQL + eigene Redis (kein AIO), ohne zentrale ForwardAuth (Browser-/Client-/WebDAV-Flows brauchen native Auth).
## 2026-04-12 — Borg-Scope enthaelt bewusst /local/secrets
**Entscheidung:** Borg sichert ausgewaehltes Secret-Material (`/local/secrets`) als Teil der DR-Strategie; `borg-ui` hat dafuer breite, bewusste Mounts. Dumps statt Raw-DB-Pfade sind der primaere Restore-Weg.
**Kontext:** `ops/borg-ui/BACKUP_SCOPE.md`.
## 2026-03-28/29 — GitOps-Fundament
**Entscheidung:** Komodo ersetzt Portainer als alleiniger Stack-Manager (Docker-Socket-Ausnahme, native Auth ohne pauschale ForwardAuth wegen Webhooks/`/ws/periphery`). Traefik routet ausschliesslich ueber Docker-Labels; File-Provider nur fuer `middlewares.yml`, `tls.yml`, `dashboards.yml` (+ dokumentierte `plex.yml`-Ausnahme). AdGuard Home + Unbound ersetzen Pi-hole.
**Kontext:** Konkurrierende `@file`-/`@docker`-Router hatten Fehlrouting verursacht; Regel: keine neuen Service-Routen im File-Provider.
## Aelteres / Sonderfaelle
- **Paperless Stack-ENV-Ausnahme:** `PAPERLESS_DBPASS`/`PAPERLESS_REDIS` bleiben Komodo-Stack-ENV (kein `_FILE`-Support im Image); Konsequenzen fuer DR siehe `docs/DISASTER_RECOVERY.md` Phase 2.
- **ddns-updater in `frontend_net`:** braucht Cloudflare-API; `backend_net` ist internal.
- **mail-archiver Hybrid:** `frontend_net` (IMAP) + `backend_net` (DB), App-Auth zusaetzlich zu Authelia.
- Vollstaendige technische Ausnahmen-Liste mit Begruendung: `HOMELAB_ARCHITECTURE_MASTER_V2.md` §10 (bleibt dort autoritativ).
+590
View File
@@ -0,0 +1,590 @@
# 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
- `ops/restore-tests/README.md` - Restore-Test-Betrieb und Werkzeuge
- `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. Laufende Vorbereitung
Offene Punkte werden in `docs/MASTER_TODO.md` gefuehrt. Daueraufgaben:
- Unraid-Flash-Artefakt regelmaessig pruefen (`ops/maintenance/check-unraid-flash-backup.sh`)
- Offline-Kopien (Borg-Passphrase, KOMODO_*-Notiz, DR-Keys) bei Reviews nur auf Auffindbarkeit pruefen, nie Werte dokumentieren
- `komodo-mongo`-Dump nach Major-Upgrades gezielt kontrollieren
- Restore-Drills nach Kadenz aus `ops/restore-tests/schedule.md` rotieren
---
## 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.
+225
View File
@@ -0,0 +1,225 @@
# 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 |
```
Falls der Punkt noch als offen in `docs/MASTER_TODO.md` steht, dort in den Kurzlog uebernehmen.
---
## 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.
+101
View File
@@ -0,0 +1,101 @@
# 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 bis 2026-06-03 | Baseline und Haertung abgeschlossen: externe Abhaengigkeiten dokumentiert; FRITZ!Box-WAN auf 443/tcp bereinigt, Remote-Dienste aus, Konfig-Backup in Vaultwarden; Hetzner-Account-Hygiene (2FA, Recovery Key offline); KOMODO_*-Notiz und GitHub-Read-Deploy-Key offline gesichert. Detailhistorie in Git. | Keine Folgeaktion |
| 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 |
+143
View File
@@ -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. Nur das Ergebnis dokumentieren: Datum/Befund im Review-Log von `docs/EXTERNAL_DEPENDENCIES.md`.
## 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