192 Commits

Author SHA1 Message Date
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
Micha 96984ca0de Finish merge and update borg-ui backup mounts 2026-04-12 17:49:44 +02:00
Micha e8af468f1e Refine dashboard card icons and storage layout 2026-04-12 17:49:44 +02:00
234 changed files with 18067 additions and 4325 deletions
+30
View File
@@ -0,0 +1,30 @@
# Local environment and stack values
.env
*.env
!*.env.example
**/stack.env
!**/stack.env.example
# Secrets and certificate material
**/secrets/
**/letsencrypt/
**/acme.json
**/*.key
**/*.pem
**/*.crt
# Backup, dump, and archive artifacts
**/*.dump
**/*.sql.gz
**/*.archive.gz
**/*.tar
**/*.tar.gz
**/*.tgz
**/*.zip
# Local/editor noise
.DS_Store
Thumbs.db
*.tmp
*.log
.serena/
+129
View File
@@ -0,0 +1,129 @@
# Claude Code Context - Homelab Infra
Stand: 2026-05-04
Dieses Repository ist die GitOps-Quelle fuer das KalliLab CORE Homelab auf einem Unraid-Host. Es verwaltet Docker-Compose-Stacks fuer Core-Dienste, Security, Infrastruktur, Apps, Operations-Tools, Host-nahe Dienste und Traefik. Gitea Online ist die operative Quelle der Wahrheit; Komodo konsumiert den Git-Stand und deployed daraus.
## Vor jeder Aenderung lesen
Claude muss vor jeder fachlichen oder technischen Aenderung mindestens diese Dateien lesen:
1. `HOMELAB_ARCHITECTURE_MASTER_V2.md`
2. `docs/WORKFLOW.md`
3. `docs/REPO_MAP.md`
4. `docs/SERVICE_CATALOG.md`
5. die betroffene `docker-compose.yml`
Zusaetzlich je nach Thema:
- Restore / Host-Ausfall: `docs/DISASTER_RECOVERY.md` und `docs/RESTORE_MATRIX.md`
- Rollback: `docs/ROLLBACK.md`
- Secrets: `docs/SECRETS_MAP.md`
- GitOps-/Komodo-/Runtime-Drift: `docs/GITOPS_DRIFT_RUNBOOK.md`
- Gesamtbild fuer KI-Agenten: `docs/AI_CONTEXT.md`
- Home Assistant / Ecowitt / InfluxDB: `docs/HOME_ASSISTANT_INFLUXDB_ECOWITT.md`
## Projektbeschreibung
- Host: Unraid, Hostname `Kallilabcore`
- Domain: `kaleschke.info`
- Reverse Proxy: Traefik v3
- DNS: AdGuard Home + Unbound
- VPN/Remote: Tailscale
- Git: Gitea unter `git.kaleschke.info`
- Deployment: Komodo als primaerer und einziger produktiver Stack-Manager
- Lokale Arbeitskopie: Windows/GitHub Desktop
- Persistenz: ueberwiegend `/mnt/user/appdata`, Nutzdaten in `/mnt/user/documents`, `/mnt/user/photos`, GitOps/Gitea in `/mnt/user/services`
## Architekturprinzipien
- Traefik ist der einzige oeffentliche HTTP(S)-Einstiegspunkt.
- Service-Routing erfolgt per Docker-Labels, nicht ueber neue Traefik File-Provider-Service-Routen.
- `frontend_net` ist das Web-/Proxy-Netz.
- `backend_net` ist `internal: true` fuer Datenbanken, Redis und interne Backends.
- App-interne Netze sind erlaubt, wenn sie eine App und ihre Datenbank/Worker sauber isolieren.
- Datenbanken und Caches duerfen nicht im `frontend_net` liegen.
- Direkte Host-Ports sind nur mit dokumentierter Ausnahme erlaubt.
- Komodo bleibt bewusst ohne pauschale zentrale Authelia-ForwardAuth-Middleware.
- Secrets gehoeren niemals ins Git-Repository.
- Produktive Container sollen als Compose/Git-Stack verwaltet werden.
## GitOps-Regeln
Quelle der Wahrheit:
1. Gitea `origin/master`
2. lokaler Clone
3. Komodo Stack Workspace
4. laufende Docker-Container
5. Host-Zustand
Standard-Workflow:
1. Lokal synchronisieren (`Fetch`, ggf. `Pull`)
2. Betroffene Docs und Compose-Datei lesen
3. Zielzustand und Rollback klaeren
4. Lokal minimal aendern
5. Validieren
6. Commit und Push durch den Benutzer oder nach expliziter Freigabe
7. Komodo-Deploy/Runtime pruefen
8. Dokumentation nachziehen
Neue produktive Komodo-Stacks aus `Micha/homelab-infra` brauchen verpflichtend einen aktiven Gitea->Komodo-Webhook auf die aktuelle Stack-ID. Ausnahmen muessen im selben Aenderungsblock dokumentiert werden.
Wenn Drift vermutet wird, nicht raten. Erst die Pflichtmatrix in `docs/GITOPS_DRIFT_RUNBOOK.md` abarbeiten.
## Sicherheitsregeln
- Secret-Werte niemals ausgeben. Wenn Werte auftauchen: redakten.
- Nur Secret-Namen, Env-Key-Namen und Pfade dokumentieren.
- Keine produktiven `.env`- oder Stack-Env-Werte zitieren.
- Keine Compose-Aenderung ohne vorherigen Architektur-/Workflow-Abgleich.
- Keine Deployments, Host-Hotfixes oder Docker-Schreibbefehle ohne ausdrueckliche Anweisung.
- Keine direkten Host-Ports fuer Web-UIs, ausser dokumentierte Ausnahmen.
- `privileged: true`, Docker-Socket und Host-Netz nur als dokumentierte Ausnahme.
- Traefik dynamic config unter `traefik/dynamic/` wird nicht automatisch von Komodo deployed und muss bei Aenderungen manuell auf den Host synchronisiert werden.
## Bekannte dokumentierte Ausnahmen
- `traefik`: Host-Ports 80/443
- `gitea`: SSH-Port 222
- `AdGuard Home`: DNS-Port 53 und LAN-Admin-Port 8082
- `tailscale`: `network_mode: host`
- `Plex-Media-Server`: historischer Host-Netz-Sonderfall, nicht als Repo-Stack enthalten
- `scrutiny`: `privileged: true` fuer SMART/Laufwerkszugriff
- `Komodo`: Docker-Socket und native Auth ohne pauschale ForwardAuth
- `traefik/dynamic/*`: manuelle Host-Sync-Ausnahme
- `influxdb3-core`: LAN-only Host-Port 8181 fuer Home Assistant Writer, keine Traefik-Route, nicht im `frontend_net`
## No-Go-Regeln
- Keine produktiven Aenderungen direkt in Komodo.
- Keine `docker run`-Ad-hoc-Container als Dauerzustand.
- Keine Compose-Dateien, Secrets oder Host-Konfigurationen stillschweigend aendern.
- Keine Deployments ausloesen, wenn der Benutzer nur Analyse oder Dokumentation verlangt.
- Kein `push --force` auf `master` als Standard-Rollback.
- Keine History-Rewrites ohne ausdrueckliche Freigabe.
- Keine Loesch-, Reset- oder Migrationsbefehle ohne klaren Zielzustand und Rollback.
- Keine neuen `latest`-/mutable Image-Tags ohne bewusste Versionsentscheidung.
## Rollback-Erwartungen
Jede Aenderung braucht vor dem Deploy eine klare Rueckkehrstrategie:
- letzter bekannter funktionierender Git-Stand
- betroffener Stack und Persistenzpfade
- ob Datenpfade unveraendert bleiben
- ob Secrets/ENV betroffen sind
- konkrete Smoke-Tests nach Rollback
Standard-Rollback ist ein Ruecknahme-Commit oder gezielte Rueckaenderung mit Push nach Gitea. Produktive Datenpfade unter `/mnt/user/appdata`, `/mnt/user/documents`, `/mnt/user/photos`, `/mnt/user/services` und `/mnt/user/backups` nicht blind loeschen.
## Arbeitsweise fuer Claude
- Erst lesen, dann handeln.
- Bei Unsicherheit Zustand messen, nicht erraten.
- Aenderungen klein halten und nur den betroffenen Bereich anfassen.
- Bestehende Doku und Repo-Konventionen bevorzugen.
- Bei Secret-/Runtime-/Komodo-Fragen besonders konservativ sein.
- Wenn zwei Reparaturversuche nicht funktionieren: stoppen, Pflichtmatrix ausfuellen, eine abweichende Ebene benennen.
+194 -183
View File
@@ -3,7 +3,7 @@
> **Single Source of Truth** für Docker-Netzwerkarchitektur, Sicherheitsregeln, Zielbild und Migration des Kallilabcore-Homelabs.
> **Arbeitsregel für KI-Assistenten:** Dieses Dokument immer zuerst lesen, bevor Fragen zu Containern, Netzwerken, Traefik, Tailscale, Migration oder Security beantwortet werden.
**Stand:** 2026-03-29 | **Aktueller Sprint:** 7 (Authelia SSO/2FA) — Sprints 16 abgeschlossen
**Stand:** 2026-05-23 | **Aktueller Schwerpunkt:** GitOps / Doku-Synchronisierung / Reproduzierbare Deployments
---
@@ -16,7 +16,7 @@
6. [Einordnungsschema für neue Container](#6-einordnungsschema-für-neue-container)
7. [Container-Zielbild (vollständig)](#7-container-zielbild-vollständig)
8. [Traefik-Label-Standard](#8-traefik-label-standard)
9. [Migrationsstrategie (Blöcke 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)
11. [Projektorganisation und Arbeitsmodus](#11-projektorganisation-und-arbeitsmodus)
12. [Nutzung mit KI / Kontext-Regel](#12-nutzung-mit-ki--kontext-regel)
@@ -30,14 +30,14 @@
|---|---|
| Host-OS | Unraid |
| Hostname | Kallilabcore |
| Reverse Proxy | Traefik v3 (100% Docker-Labels, kein File-Provider) |
| Reverse Proxy | Traefik v3 (Service-Routing via Docker-Labels, File-Provider fuer Middlewares, TLS und Dashboards) |
| VPN / Remote-Zugang | Tailscale (`tailscale`, host-Netz, Git-Stack) |
| DNS-Stack | AdGuard Home (`dns_net` + `frontend_net`) → Unbound (`dns_net`) |
| Basis-Domain | `kaleschke.info` |
| TLS | Let's Encrypt via Cloudflare DNS Challenge |
| Certresolver | `le` |
| Compose-Standard | Komodo (GitOps, Stack aus Gitea) |
| Legacy | Portainer CE (in Ablösung durch Komodo, Sprint 5) |
| Legacy | Portainer CE entfernt; Komodo ist alleiniger Stack-Manager |
| Homelab-Compose-Pfad | `/mnt/user/services/homelab/` |
| Secrets-Pfad | `/mnt/user/appdata/secrets/` |
| Grundsatz | Keine neuen Dockerman-Einzelcontainer |
@@ -47,26 +47,26 @@
## 2. Architektur-Prinzipien
### P1 — Traefik ist der einzige öffentliche HTTP(S)-Einstiegspunkt
Kein Webdienst veröffentlicht finale direkte Host-Ports außer `traefik` selbst. Begründete Ausnahmen: `gitea`-SSH (Port 222), `AdGuard Home` (Port 53/DNS + 3000/Admin), `Tailscale`, `Plex-Media-Server`.
Kein Webdienst veröffentlicht finale direkte Host-Ports außer `traefik` selbst. Begründete Ausnahmen: `gitea`-SSH (Port 222), `AdGuard Home` (Port 53/DNS direkt; Admin 8082 nur auf Tailscale-IP `100.80.98.33`), `Tailscale`, `Plex-Media-Server` und `monitoring-influxdb3-core` Port 8181 als LAN-only Writer-Endpunkt fuer Home Assistant.
### P2 — Das Setup bleibt bewusst einfach: `frontend_net` + `backend_net` + app-interne Netze
- `frontend_net` = Proxy-/Web-Netz
- `backend_net` = intern für DB/Cache/App-Kommunikation
- zusätzliche Netze nur app-intern, wenn technisch nötig (`mealie_mealie_internal`, `immich_default`, `dns_net`)
- zusätzliche Netze nur app-intern, wenn technisch nötig (`mealie_internal`, `immich_default`, `dns_net`)
Es gibt **keine künstlichen globalen Zusatznetze** wie `admin_net`, `monitoring_net` oder `media_net`.
Es gibt **keine künstlichen globalen Zusatznetze** wie `admin_net` oder `media_net`. `monitoring_net` ist die dokumentierte Ausnahme fuer den zentralen Observability-Stack.
### P3 — Datenbanken gehören nie ins `frontend_net`
Postgres, Redis und ähnliche Dienste laufen ausschließlich in `backend_net` oder einem eigenen internen Compose-Netz.
### P4 — Admin-UIs sind nicht öffentlich
Komodo, filebrowser, scrutiny, UptimeKuma, code-server, Traefik-Dashboard, backrest und beszel sind standardmäßig **Tailscale-only** oder hinter Traefik **mit zentraler Middleware** abgesichert.
filebrowser, scrutiny, code-server, Traefik-Dashboard und borg-ui sind standardmaessig **Tailscale-only** oder hinter Traefik **mit zentraler Middleware** abgesichert. `Komodo` ist die dokumentierte Ausnahme und bleibt bewusst bei nativer Authentifizierung ohne pauschal vorgeschaltete ForwardAuth-Middleware.
### P5 — Compose-first
Alle produktiven Container werden als Compose verwaltet. Bestehende Dockerman-/Ad-hoc-Container werden schrittweise migriert.
### P6 — Secrets nie im Klartext
Passwörter, Tokens und API-Keys gehören in Secret-Dateien unter `/mnt/user/appdata/secrets/` oder als Komodo/Portainer Environment Variables mit `${VARIABLE}` in der Compose.
Passwörter, Tokens und API-Keys gehören in Secret-Dateien unter `/mnt/user/appdata/secrets/` oder als Komodo Stack Environment Variables mit `${VARIABLE}` in der Compose.
### P7 — `restart: unless-stopped` ist Pflichtstandard
Jeder produktive Container nutzt `restart: unless-stopped`, außer eine Ausnahme ist dokumentiert.
@@ -74,7 +74,7 @@ Jeder produktive Container nutzt `restart: unless-stopped`, außer eine Ausnahme
### P8 — Least Privilege
- `security_opt: ["no-new-privileges:true"]` standardmäßig ergänzen
- `privileged: true` nur mit dokumentierter Begründung
- Docker-Socket standardmäßig vorsichtig behandeln; **Komodo/PortainerCE sind dokumentierte Ausnahmen**
- Docker-Socket standardmäßig vorsichtig behandeln; **Komodo ist dokumentierte Ausnahme**
---
@@ -87,8 +87,12 @@ Jeder produktive Container nutzt `restart: unless-stopped`, außer eine Ausnahme
| `frontend_net` | bridge, external | einziges Traefik-/Web-Netz | Standard |
| `backend_net` | bridge, `internal: true` | interne App-/DB-/Cache-Kommunikation | Standard |
| `dns_net` | bridge | Resolver-Schicht: AdGuard Home + Unbound | bleibt |
| `mealie_mealie_internal` | bridge, `internal: true` | internes Netz nur für `mealie` + `mealie-postgres` | ✅ umgesetzt |
| `mealie_internal` | bridge, `internal: true` | internes Netz nur für `mealie` + `mealie-postgres` | ✅ umgesetzt |
| `immich_default` | Compose-intern, `internal: true` | internes Immich-Netz | ✅ umgesetzt |
| `nextcloud_internal` | bridge, `internal: true` | internes Netz nur fuer `nextcloud` + `nextcloud-postgres` + `nextcloud-redis` | ✅ vorbereitet |
| `monitoring_net` | Compose-intern, bridge | zentraler Observability-Stack fuer Prometheus, Loki, Grafana, Promtail, Exporter und InfluxDB | Zielzustand |
| `monitoring_influx_lan` | Compose-intern, bridge | nicht-oeffentliches Zusatznetz nur fuer Docker Host-Port-Publishing von InfluxDB 8181 | Zielzustand |
| `glance_socket_net` | Compose-intern, `internal: true` | interner Zugriff von Glance auf den Docker-Socket-Proxy | umgesetzt |
| `host` | host | nur für echte Sonderfälle | begründet |
### 3.2 Finales Diagramm (vereinfacht)
@@ -99,9 +103,10 @@ Internet
traefik (80/443)
└── frontend_net
├── öffentliche Apps (vaultwarden, mealie, paperless, immich, gitea, ntfy, homepage)
├── Admin-UIs mit Middleware (komodo, uptime-kuma, filebrowser, scrutiny, code-server, backrest, beszel)
── Hybrid-Dienste mit Internetbedarf (mail-archiver, ddns-updater)
├── öffentliche Apps (vaultwarden, mealie, paperless, immich, gitea, ntfy, mail-archiver, nextcloud)
├── geschützte UIs mit Middleware (glance, paperless-gpt, filebrowser, scrutiny, code-server, borg-ui, glances, speedtest, bentopdf, monitoring-grafana)
── Admin-UI mit nativer Auth (komodo)
└── Dienste mit Internetbedarf ohne öffentliche UI (ddns-updater)
backend_net (internal: true)
├── postgresql17
@@ -114,13 +119,16 @@ dns_net
└── unbound
App-interne Netze
├── mealie_mealie_internal (internal: true) ✅
── immich_default (internal: true) ✅
├── mealie_internal (internal: true) ✅
── immich_default (internal: true) ✅
├── nextcloud_internal (internal: true) ✅
├── monitoring_net (zentraler Observability-Stack)
└── monitoring_influx_lan (Bridge fuer LAN-Port-Publishing, keine Traefik-Route)
Host-Sonderfälle
├── tailscale
── Plex-Media-Server
└── beszel-agent
── Plex-Media-Server
```
---
@@ -136,20 +144,26 @@ Diese Dienste sind über echte `*.kaleschke.info`-Domains erreichbar:
- `ntfy` — ntfy.kaleschke.info
- `gitea` (Web) — git.kaleschke.info
- `immich_server` — immich.kaleschke.info
- `homepage` — homepage.kaleschke.info
- `nextcloud` — cloud.kaleschke.info
### 4.2 Nicht öffentlich / nur Tailscale oder Traefik + Middleware
Diese Dienste sind **keine Public Apps**:
- `Komodo` — komodo.kaleschke.info (Middleware)
- `UptimeKuma` — uptime.kaleschke.info (Middleware)
- `Komodo` — komodo.kaleschke.info (Traefik, aber bewusst ohne zentrale Middleware; native Auth bleibt aktiv)
- `filebrowser` — files.kaleschke.info (Middleware)
- `scrutiny` — scrutiny.kaleschke.info (Middleware)
- `code-server` — Traefik + Middleware
- `beszel` — beszel.kaleschke.info (Middleware ausstehend)
- `backrest` — Traefik + Middleware
- `borg-ui` — borg.kaleschke.info (Middleware)
- `glance` — glance.kaleschke.info (Middleware)
- `paperless-gpt` — paperless-gpt.kaleschke.info (Middleware)
- `mail-archiver` — mail.kaleschke.info (Middleware + App-Auth)
- `glances` — glances.kaleschke.info (Middleware)
- `speedtest-tracker` — speedtest.kaleschke.info (Middleware)
- `bentopdf` — pdf.kaleschke.info (Middleware)
- `monitoring-grafana` — monitoring.kaleschke.info (Middleware)
- `hermes-dashboard` — hermes.kaleschke.info (Middleware)
- `Traefik-Dashboard`
- `AdGuard Home`Port 3000 direkt (kein Traefik, nur LAN-Zugang)
- `AdGuard Home`Admin-UI auf Port 8082 (`80` im Container), kein Traefik, nur Tailscale-IP `100.80.98.33`; 2026-05-26 bewusst keine 2FA-/Traefik-Umstellung
### 4.3 Regel
Wenn ein Dienst im `frontend_net` hängt, heißt das **nicht automatisch öffentlich**. Admin-Dienste dürfen im `frontend_net` liegen, wenn:
@@ -158,6 +172,8 @@ Wenn ein Dienst im `frontend_net` hängt, heißt das **nicht automatisch öffent
- keine direkten Host-Ports bestehen
- Zugriff durch Tailscale bzw. Auth begrenzt ist
`Komodo` ist hiervon die dokumentierte Ausnahme: Traefik ja, aber keine pauschale ForwardAuth-Middleware, damit Webhooks, API und Periphery-Kommunikation nicht versehentlich beeintraechtigt werden.
---
## 5. Globale Sicherheitsregeln
@@ -165,10 +181,10 @@ Wenn ein Dienst im `frontend_net` hängt, heißt das **nicht automatisch öffent
1. Keine produktiven Dienste im Docker-Default-`bridge`
2. Keine direkten Host-Ports für Web-UIs außer dokumentierte Ausnahmen
3. `restart: unless-stopped` als Standard
4. Secrets als Datei / `_FILE` oder Komodo/Portainer Environment Variables mit `${VAR}`
4. Secrets als Datei / `_FILE` oder Komodo Stack Environment Variables mit `${VAR}`
5. `no-new-privileges:true` ergänzen, wo praktikabel
6. `traefik.docker.network=frontend_net` immer explizit setzen
7. Admin-Dienste immer mit `dashboard-auth@file,secure-headers@file`
7. Admin- und interne Web-Dienste standardmaessig mit zentraler Middleware absichern (`authelia@file,secure-headers@file` oder dokumentierte Ausnahme)
8. Placeholder-Domains (`yourdomain.tld`) sind verboten
9. `privileged: true` nur mit Begründung
10. Volume-Mounts so klein und so read-only wie möglich
@@ -218,20 +234,18 @@ Legende Status:
| Container | Status | Soll-Netz(e) | Finaler Zugang | Finaler Sollzustand | Offene Punkte |
|---|---|---|---|---|---|
| `traefik` | ✅ | `frontend_net`, `backend_net` | öffentlich 80/443 | zentraler Ingress, 100% Docker-Labels | — |
| `AdGuard Home` | ✅ | `dns_net` (172.23.0.3), `frontend_net` | Port 53 DNS direkt, Port 3000 Admin (LAN) | DNS-Server + Upstream zu unbound; kein Traefik (DNS-Sonderfall) | Admin-Port per Traefik + Middleware absichern (Block F) |
| `traefik` | ✅ | `frontend_net`, `backend_net` | öffentlich 80/443 | zentraler Ingress, Service-Routing via Docker-Labels | — |
| `AdGuard Home` | ✅ | `dns_net` (172.23.0.3), `frontend_net` | Port 53 DNS direkt, Port 8082 Admin nur auf Tailscale-IP `100.80.98.33` | DNS-Server + Upstream zu unbound; kein Traefik fuer Admin-UI | Admin-Port bleibt bewusst ohne Traefik/2FA, aber nicht mehr auf allen LAN-Interfaces |
| `unbound` | ✅ | `dns_net` | intern | Upstream-Resolver für AdGuard, isoliert | — |
| `ddns-updater` | ✅ | `frontend_net` | intern | Cloudflare DNS API; bleibt in `frontend_net` | Dokumentierte Ausnahme |
| `tailscale` | ✅ | `host` | VPN-Zugang | Git-Stack (`host-services/tailscale/`) | `TS_USERSPACE`/`privileged` später prüfen |
| `backrest` | ✅ | `frontend_net`, `backend_net` | Traefik + Middleware | `traefik.docker.network=frontend_net` korrigiert | Breite Mounts straffen (Block F) |
| `homepage` | ✅ | `frontend_net` | Traefik | öffentliche Startseite via `homepage.kaleschke.info` | — |
| `tailscale` | ✅ | `host` | VPN-Zugang | Git-Stack (`host-services/tailscale/`) | nutzt `NET_ADMIN`, `NET_RAW` und `/dev/net/tun` als dokumentierte VPN-Ausnahme |
### 7.2 Sicherheit / Identity
| Container | Status | Soll-Netz(e) | Finaler Zugang | Finaler Sollzustand | Offene Punkte |
|---|---|---|---|---|---|
| `vaultwarden` | ✅ | `frontend_net` | Traefik | kein Host-Port, `ADMIN_TOKEN_FILE` | — |
| `authelia` | 🔄 | `frontend_net`, `backend_net` | Traefik via `auth.kaleschke.info` | ForwardAuth-Provider, Secrets via `_FILE`, PostgreSQL + Redis Shared | NAS-seitige Einrichtung ausstehend (Secrets, DB, Users, DNS) |
| `authelia` | | `frontend_net`, `backend_net` | Traefik via `auth.kaleschke.info` | aktiver ForwardAuth-Provider, Secrets via `_FILE`, PostgreSQL Storage; bewusst ohne Redis-Session-Backend | — |
### 7.3 Datenbanken / Caches
@@ -239,47 +253,58 @@ Legende Status:
|---|---|---|---|---|---|
| `postgresql17` | ✅ | `backend_net` | intern | kein Host-Port, `POSTGRES_PASSWORD_FILE` | — |
| `Redis` | ✅ | `backend_net` | intern | intern-only Cache | optional named volume |
| `mealie-postgres` | ✅ | `mealie_mealie_internal` | intern | isoliert, nie `frontend_net` | — |
| `mealie-postgres` | ✅ | `mealie_internal` | intern | isoliert, nie `frontend_net` | — |
| `immich_postgres` | ✅ | `immich_default` | intern | intern-only | — |
| `immich_redis` | ⏳ | `immich_default` | intern | intern-only | anonymes Volume → named volume |
| `nextcloud-postgres` | ✅ | `nextcloud_internal` | intern | app-eigene Nextcloud-Datenbank mit `_FILE`-Secret | — |
| `nextcloud-redis` | ✅ | `nextcloud_internal` | intern | app-eigener Cache fuer File Locking / Sessions | — |
### 7.4 Öffentliche Apps
### 7.4 Produktive Apps
| Container | Status | Soll-Netz(e) | Finaler Zugang | Finaler Sollzustand | Offene Punkte |
|---|---|---|---|---|---|
| `paperless-ngx` | ✅ | `frontend_net`, `backend_net` | Traefik | aktiv via `paperless.kaleschke.info` | — |
| `Paperless-AI` | ✅ | `frontend_net` | Traefik | aktiv | — |
| `mealie` | ✅ | `frontend_net`, `mealie_mealie_internal` | Traefik | sauber getrennte App/DB-Struktur | — |
| `mail-archiver` | ✅ | `frontend_net`, `backend_net` | Traefik + Middleware | aktiv via `mail.kaleschke.info`; IMAP-Abruf + DB-Zugang; App-eigene Auth bleibt zusaetzliche Schutzschicht | — |
| `mealie` | ✅ | `frontend_net`, `mealie_internal` | Traefik | sauber getrennte App/DB-Struktur | — |
| `ntfy` | ✅ | `frontend_net` | Traefik | aktiv via `ntfy.kaleschke.info`, Git-Stack | — |
| `gitea` | ✅ | `frontend_net` | Traefik + SSH-Port 222 | Web via Traefik, SSH direkt gebunden | — |
| `immich_server` | ✅ | `immich_default`, `frontend_net` | Traefik | aktiv via `immich.kaleschke.info` | — |
| `immich_machine_learning` | ✅ | `immich_default` | intern | bleibt intern | — |
| `nextcloud` | ✅ | `frontend_net`, `nextcloud_internal` | Traefik | aktiv via `cloud.kaleschke.info`, nativer Nextcloud-Login, WebDAV/CardDAV faehig | CalDAV/CardDAV-Redirect via Traefik-Labels |
| `plex` | ✅ | `host` | Plex native / Host-Netz | Compose-Stack unter `host-services/plex/`; Host-Netz bleibt fuer Discovery / Plex GDM dokumentierte Ausnahme | — |
### 7.5 Admin / Operations
| Container | Status | Soll-Netz(e) | Finaler Zugang | Finaler Sollzustand | Offene Punkte |
|---|---|---|---|---|---|
| `komodo` | ✅ | `frontend_net` | Traefik + Middleware | primärer GitOps-Stack-Manager | |
| `komodo` | ✅ | `frontend_net` | Traefik, native Auth | primaerer GitOps-Stack-Manager | bewusste Ausnahme: keine pauschale ForwardAuth-Middleware vor UI/API/Webhooks/Periphery |
| `code-server` | ✅ | `frontend_net` | Traefik + Middleware | `PASSWORD_FILE` aktiv | — |
| `PortainerCE` | ⚠️ Legacy | `frontend_net` | Traefik + Middleware | wird durch Komodo abgelöst | abschalten Sprint 5 |
| `filebrowser` | ✅ | `frontend_net` | Traefik + Middleware | aktiv via `files.kaleschke.info` | Mounts einschränken (Block F) |
| `mail-archiver` | ✅ | `frontend_net`, `backend_net` | intern | IMAP-Abruf + DB-Zugang, kein öffentlicher Zugang | — |
| `PortainerCE` | ❌ entfernt | - | - | 2026-03-29 abgeschaltet | historisch; nicht mehr deployen |
| `filebrowser` | ✅ | `frontend_net` | Traefik + Middleware | aktiv via `files.kaleschke.info` | Appdata-Breitmount entfernt; nur Documents/Photos/Projekte plus eigener App-State |
| `borg-ui` | ✅ | `frontend_net` | Traefik + Middleware | produktiver Borg-/Restore-Dienst; `/local/secrets` ist bewusst Teil des Restore-Scopes | BorgBase-Repo und Key laufend pflegen |
| `paperless-gpt` | ✅ | `frontend_net` | Traefik + Middleware | aktiv via `paperless-gpt.kaleschke.info` | — |
| `bentopdf` | ✅ vorbereitet | `frontend_net` | Traefik + Middleware | PDF-Tooling via `pdf.kaleschke.info`; browserseitige Verarbeitung, COOP/COEP fuer Office-Konvertierung | Deploy und fachliche Abnahme offen |
| `hermes-dashboard` | ✅ | `frontend_net`, `hermes_net` | Traefik + Middleware | aktiv via `hermes.kaleschke.info`; Dashboard bindet intern mit `--insecure` auf `0.0.0.0`, externe Absicherung ueber Authelia | — |
### 7.6 Monitoring / Status
| Container | Status | Soll-Netz(e) | Finaler Zugang | Finaler Sollzustand | Offene Punkte |
|---|---|---|---|---|---|
| `UptimeKuma` | ✅ | `frontend_net` | Traefik + Middleware | aktiv via `uptime.kaleschke.info` | — |
| `glance` | ✅ | `frontend_net`, `glance_socket_net` | Traefik + Middleware | einziges Homelab-Dashboard via `glance.kaleschke.info`; Docker-Status nur ueber internen Socket-Proxy | — |
| `glances` | ✅ | `frontend_net` | Traefik + Middleware | aktiv via `glances.kaleschke.info` | — |
| `scrutiny` | ✅ | `frontend_net` | Traefik + Middleware | aktiv via `scrutiny.kaleschke.info`, Git-Stack | `privileged` später prüfen |
| `beszel` | ✅ | `frontend_net` | Traefik | aktiv via `beszel.kaleschke.info`, Git-Stack | Admin-Middleware ergänzen (Block F) |
| `beszel-agent` | ✅ | `host` | intern | System-Monitoring, Socket-Zugriff auf Host | — |
| `speedtest-tracker` | ✅ | `frontend_net` | Traefik + Middleware | aktiv via `speedtest.kaleschke.info` | — |
| `monitoring-grafana` | ✅ | `frontend_net`, `monitoring_net` | Traefik + Middleware | zentrale UI via `monitoring.kaleschke.info`; Datasources fuer Prometheus, Loki und InfluxDB | — |
| `monitoring-influxdb3-core` | ✅ | `monitoring_net`, `monitoring_influx_lan` + LAN-Bind | LAN-Port nur fuer interne Writer | InfluxDB 3 Core fuer Home-Assistant-/Ecowitt-Langzeitdaten; keine Traefik-/Public-Freigabe; Port 8181 nur via `INFLUXDB_BIND_IP` | HA-Write-Token und Sensor-Export finalisieren |
| `monitoring-loki` | ✅ | `monitoring_net` | intern | interner Container-Logspeicher ohne Public Route; Monitoring-Grafana greift ueber Loki-Datasource zu | Retention/Storage beobachten |
| `monitoring-promtail` | ✅ | `monitoring_net` | intern | Docker-Log-Collector mit read-only Docker-Socket-Ausnahme; schreibt nach Loki | Socket-Ausnahme regelmaessig pruefen |
| `grafana` / `influxdb3-core` / `loki` / `alloy` | entfernt | - | abgeloest | alte Docker-Runtime frei von Altcontainern; Compose-Pfade am 2026-05-26 aus aktivem Repo entfernt | Rollback nur ueber Git-Historie |
### 7.7 Sprint 5 — noch zu migrieren / abzuschalten
### 7.7 Noch offene Sonderfälle
| Container | Status | Ziel |
|---|---|---|
| `Plex-Media-Server` | ⏳ Dockerman | Compose-Migration, `host`-Netz bleibt (Discovery) |
| `PortainerCE` | ⚠️ Legacy | abschalten nach vollständiger Komodo-Übernahme |
| — | — | Plex ist nicht mehr offen: der Dienst ist als Repo-Compose-Stack unter `host-services/plex/` dokumentiert; `host`-Netz bleibt als Discovery-Ausnahme. |
### 7.8 Entfernte Container
@@ -291,13 +316,19 @@ Legende Status:
| `diun` | 2026-03-28 | Update-Monitoring via Komodo; Stack + Netz `diun_diun_default` + Repo-Eintrag entfernt |
| `binhex-official-pihole` | 2026-03-28 | ersetzt durch AdGuard Home + Unbound |
| `gotify` | 2026-03-28 | nicht mehr aktiv; Push-Notifications via ntfy abgedeckt |
| `Dozzle` | 2026-03-28 | nicht mehr aktiv; Log-Monitoring via Komodo/beszel |
| `dashdot` | 2026-03-28 | nicht mehr aktiv; System-Monitoring via beszel |
| `netdata` | 2026-03-28 | nicht mehr aktiv; System-Monitoring via beszel |
| `Glances` | 2026-03-28 | nicht mehr aktiv |
| `Dozzle` | 2026-03-28 | nicht mehr aktiv |
| `dashdot` | 2026-03-28 | nicht mehr aktiv |
| `netdata` | 2026-03-28 | nicht mehr aktiv |
| `netalertx` | 2026-03-28 | nicht mehr aktiv |
| `luckyBackup` | 2026-03-28 | nicht mehr aktiv; Backup via backrest |
| `luckyBackup` | 2026-03-28 | nicht mehr aktiv; Backup via Borg |
| `backrest` | 2026-05-15 | entfernt; Borg ist die alleinige Backup-Technologie, WD MyBookLive ist kein Backup-Ziel mehr |
| `Stash` | 2026-03-28 | nicht mehr aktiv |
| `PortainerCE` | 2026-03-29 | abgeschaltet; Komodo ist alleiniger Stack-Manager |
| `beszel` | nicht dokumentiert | bereits entfernt; nicht mehr Teil des Zielbilds |
| `beszel-agent` | nicht dokumentiert | bereits entfernt; nicht mehr Teil des Zielbilds |
| `jellyfin` | 2026-05-25 | doppelter Medienserver neben Plex; Plex bleibt einziger Medienserver |
| `homepage` | 2026-05-25 | doppeltes Dashboard neben Glance; Glance bleibt einziges Homelab-Dashboard |
| `uptime-kuma` | 2026-05-25 | durch `monitoring-blackbox-exporter`, Prometheus-Alerts und `monitoring-grafana` ersetzt |
---
@@ -315,9 +346,9 @@ labels:
- traefik.http.services.<name>.loadbalancer.server.port=<interner-port>
```
### Zusatz für Admin-Dienste
### Zusatz fuer Admin-Dienste (Standard)
```yaml
- traefik.http.routers.<name>.middlewares=dashboard-auth@file,secure-headers@file
- traefik.http.routers.<name>.middlewares=authelia@file,secure-headers@file
```
### Regeln
@@ -326,162 +357,89 @@ labels:
- certresolver immer `le`
- `tls=true` immer explizit setzen
- wenn Traefik aktiv ist, werden direkte Host-Ports entfernt
- Admin-Dienste niemals ohne Middleware veröffentlichen
- Admin-Dienste standardmaessig nicht ohne Middleware veroeffentlichen
- Das Traefik-Dashboard nutzt ebenfalls `authelia@file`; dokumentierte Ausnahmen wie `Komodo` bleiben moeglich
- **File-Provider nur noch für:** `middlewares.yml`, `tls.yml`, `dashboards.yml` — keine Service-Routen mehr via File-Provider
- dokumentierte Ausnahmen muessen in Abschnitt 10 begruendet werden
---
## 9. Migrationsstrategie (Blöcke AF)
## 9. Historische Migration (abgeschlossen)
**Letzte Aktualisierung:** 2026-03-29
Die frühere Blockmigration aus der Portainer-/Dockerman-Phase ist fachlich abgeschlossen.
### Block A — Quick Wins ✅ ABGESCHLOSSEN
```text
[x] restart: unless-stopped für alle Container gesetzt
[x] vaultwarden ADMIN_TOKEN-Doppelpräfix korrigiert
[x] backrest DNS-Hardcoding entfernt
[x] leere Netzwerke entfernt: br0, immich_net, kopia_default, netbox_default, diun_default
[x] anonyme/verwaiste Volumes bereinigt
[x] scanopy komplett entfernt (3 Container + 2 Volumes + Netz)
[x] binhex-official-pihole entfernt → ersetzt durch AdGuard Home + Unbound
```
Dieser Abschnitt dient nur noch als **historischer Vermerk**:
### Block B — Kritische Kernmigrationen ✅ ABGESCHLOSSEN
```text
[x] vaultwarden - frontend_net, Host-Port entfernt, ADMIN_TOKEN_FILE, Traefik aktiv
[x] postgresql17 - Port 5432 entfernt, nur backend_net, POSTGRES_PASSWORD_FILE
[x] mealie-postgres - aus frontend_net raus, nur mealie_mealie_internal
```
- Traefik läuft labelbasiert ohne Service-Routen im File-Provider.
- Komodo ist der einzige aktive Stack-Manager.
- Portainer CE ist entfernt.
- Borg/Borg UI, Dump-Automatisierung und Restore-Test sind produktiv eingeführt.
- Frühere Sprint-/Block-Checklisten werden hier **nicht mehr operativ gepflegt**.
### Block C — Frontend-Stack finalisieren ✅ ABGESCHLOSSEN
```text
[x] ntfy - Git-Stack - ntfy.kaleschke.info - Traefik aktiv
[x] paperless-ngx - traefik.enable=true - paperless.kaleschke.info - Port entfernt - tls=true
[x] Paperless-AI - traefik.enable=true - aktiv
[x] PortainerCE - traefik.enable=true - Middleware aktiv - direkte Ports entfernt
[x] UptimeKuma - traefik.enable=true - uptime.kaleschke.info - Port entfernt - Middleware aktiv
[x] filebrowser - frontend_net - traefik.enable=true - files.kaleschke.info - Port entfernt - Middleware aktiv
[x] scrutiny - frontend_net - traefik.enable=true - scrutiny.kaleschke.info - Git-Stack
[x] gitea - traefik.enable=true - git.kaleschke.info - SSH-Port 222 bleibt (Ausnahme dokumentiert)
[x] backrest - traefik.docker.network=frontend_net korrigiert (war backend_net — Routing-Bug)
[x] Traefik File-Provider bereinigt - immich.yml, gitea.yml, mealie.yml, scrutiny.yml, vaultwarden.yml.bak gelöscht
[x] immich Bad Gateway behoben - Traefik nutzt jetzt immich@docker statt immich@file
[x] AdGuard Home - Git-Stack - dns_net + frontend_net - Port 53 (DNS) + 3000 (Admin)
[x] beszel - Git-Stack - frontend_net - beszel.kaleschke.info - Traefik aktiv
[ ] beszel - Admin-Middleware (dashboard-auth@file) ergänzen
```
Für den laufenden Betrieb gilt stattdessen:
### Block D — Dockerman-Container in Git-Stacks
```text
[x] vaultwarden ✅
[x] postgresql17 ✅
[x] mail-archiver ✅
[x] scrutiny ✅
[x] filebrowser ✅
[x] tailscale ✅
[x] AdGuard Home ✅
[x] beszel ✅
[x] ntfy ✅
[x] homepage ✅
[ ] Plex-Media-Server (Sprint 5)
```
### Block E — Secrets-Migration
```text
[x] vaultwarden → ADMIN_TOKEN_FILE ✅
[x] postgresql17 → POSTGRES_PASSWORD_FILE ✅
[x] mail-archiver → Stack ENV (${MAILARCHIVER_AUTH_PASSWORD}) ✅
[x] mealie → Stack ENV (kein _FILE-Support) ✅
[x] mealie-postgres → Stack ENV (kein _FILE-Support) ✅
[x] paperless-ngx → Stack ENV (${PAPERLESS_DBPASS}) ✅
[x] code-server → PASSWORD_FILE ✅
[x] immich_server → Stack ENV (${IMMICH_DB_PASSWORD}) ✅
[x] immich_postgres → POSTGRES_PASSWORD_FILE ✅
[ ] immich_redis → anonymes Volume → named volume
```
### Block F — Feinschliff / Hardening
```text
[x] immich_default - internal: true gesetzt (2026-03-29)
[x] PortainerCE - abgeschaltet (Sprint 5, 2026-03-29)
[ ] immich_redis - anonymes Volume → named volume in Compose
[ ] immich_server - anonymes Volume prüfen und benennen
[ ] backrest - /mnt/user doppelt gemountet (ro + rw) - rw-Mount auf konkrete Pfade einschränken
[ ] filebrowser - /mnt/user:/srv ist sehr breit - auf /mnt/user/documents:/srv einschränken wenn möglich
[ ] Redis - optional named volume
[ ] scrutiny - später prüfen, ob privileged reduziert werden kann
[ ] tailscale - TS_USERSPACE/privileged bereinigen wenn möglich
[ ] beszel - Admin-Middleware (dashboard-auth@file) ergänzen
[ ] AdGuard Home - Admin-Port 3000 per Traefik + Middleware absichern (aktuell direkter Port)
```
### Block G — Authelia SSO/2FA (Sprint 7)
```text
[x] security/authelia/docker-compose.yml im Repo (2026-03-29)
[x] security/authelia/configuration.yml im Repo (2026-03-29)
[x] traefik/dynamic/middlewares.yml - authelia ForwardAuth Middleware ergänzt (2026-03-29)
[ ] NAS: Secrets anlegen (jwt_secret, session_secret, storage_encryption_key, postgres_password)
[ ] NAS: Authelia PostgreSQL-User und -Datenbank anlegen
[ ] NAS: /mnt/user/appdata/authelia/config/configuration.yml aus Repo übernehmen
[ ] NAS: users_database.yml mit gehashten Passwörtern anlegen
[ ] NAS: DNS-Eintrag auth.kaleschke.info in AdGuard setzen
[ ] Komodo: Stack security/authelia deployen
[ ] Services schrittweise mit authelia@docker Middleware absichern
```
---
- Zielbild und Architektur in diesem Dokument
- Git-/Komodo-Ablauf in `docs/WORKFLOW.md`
- fachliche Änderungen in der jeweils betroffenen Stack-Doku
- Entscheidungen und besondere Umstellungen im Entscheidungs-Log unten
## 10. Bekannte Ausnahmen und Begründungen
| Container | Ausnahme | Begründung |
|---|---|---|
| `traefik` | Host-Ports 80/443 | zentraler Reverse Proxy |
| `tailscale` | `host` | VPN-Zugang; Umstellung nur kontrolliert möglich |
| `AdGuard Home` | Port 53 (TCP/UDP) direkt + Port 3000 Admin | DNS benötigt direkten Port 53; kein HTTP-Proxy für DNS möglich |
| `tailscale` | `host`, `NET_ADMIN`, `NET_RAW`, `/dev/net/tun` | VPN-Zugang benoetigt Kernel-Netzwerkfunktionen; Umstellung nur kontrolliert moeglich |
| `AdGuard Home` | Port 53 (TCP/UDP) direkt + `100.80.98.33:8082` auf Container-Port 80 | DNS benoetigt direkten Port 53; Admin-Port 8082 bleibt bewusst ohne Traefik/2FA, aber nur via Tailscale |
| `Plex-Media-Server` | `host` | Discovery / mDNS / Plex GDM |
| `scrutiny` | `privileged: true` | SMART-Datenzugriff auf Laufwerke |
| `beszel-agent` | `host` | direkter Host-Zugriff für System-Monitoring nötig |
| `Komodo` | Docker-Socket Zugriff | Stack-Deployments benötigen Socket |
| `PortainerCE` | Docker-Socket | Legacy-UI; wird durch Komodo abgelöst |
| `glance-docker-socket-proxy` | Docker-Socket read-only | Glance benoetigt Containerstatus; Zugriff wird ueber einen internen Socket-Proxy auf lesende Docker-API-Endpunkte begrenzt und nicht ins `frontend_net` gelegt |
| `Komodo` | keine pauschale zentrale Middleware | Webhooks (`/listener`), API und Periphery-WebSocket (`/ws/periphery`) sollen nicht durch vorgeschaltete ForwardAuth gebrochen werden |
| `gitea` | SSH-Port 222 direkt gebunden | Git-SSH-Zugang; kein HTTP-Proxy für SSH möglich |
| `ddns-updater` | bleibt in `frontend_net` statt `backend_net` | braucht Cloudflare-API-Zugang; `backend_net` ist `internal: true` |
| `mail-archiver` | `frontend_net` + `backend_net` | braucht Internetzugang für IMAP-Abruf (GMX, Gmail) und DB-Zugang |
| `traefik/dynamic/*` | manueller Host-Sync trotz GitOps | File-Provider bleibt bewusst fuer `middlewares.yml`, `tls.yml` und `dashboards.yml`; Komodo deployed diese Dateien nicht automatisch |
| `nextcloud` | keine zentrale ForwardAuth-Middleware | Nextcloud bringt eigene Auth, Clients und WebDAV/CardDAV-Endpunkte mit; Traefik bleibt Reverse Proxy, Auth bleibt app-nativ |
| `monitoring-influxdb3-core` | Host-Port 8181 auf LAN-IP; `user: "0"` | Home Assistant laeuft in einer VM ausserhalb des Compose-Netzes und muss Metriken schreiben koennen; keine Traefik-Route, kein `frontend_net`, Zugriff nur ueber Token und LAN-IP `INFLUXDB_BIND_IP`; InfluxDB 3 Core benoetigt im aktuellen Container-Setup Root-Rechte fuer den lokalen Object-Store-Pfad im named volume |
| `monitoring-promtail` | Docker-Socket read-only | Docker-Log-Discovery fuer Loki; keine Schreibrechte, keine Appdaten-Persistenz ueber den Socket |
---
## 11. Projektorganisation und Arbeitsmodus
### 11.1 Unser Arbeitsprinzip
Dieses Projekt wird **blockweise** umgesetzt, nicht wild containerweise.
Dieses Projekt wird heute nicht mehr sprintweise im Dokument gesteuert, sondern über einen stabilen GitOps-Betrieb.
### 11.2 Reihenfolge der Umsetzung
| Sprint | Inhalt | Status |
|---|---|---|
| Sprint 1 | Quick Wins + `vaultwarden` | ✅ Abgeschlossen |
| Sprint 2 | `postgresql17` + `diun/gotify` | ✅ Abgeschlossen |
| Sprint 3 | `mealie` / `mealie-postgres` + `mail-archiver` | ✅ Abgeschlossen |
| Sprint 4 | Frontend-Stack (`paperless`, `PortainerCE`, `Dozzle`, `dashdot`, `scrutiny`, `filebrowser`, `gitea`, `UptimeKuma`, `ntfy`, `beszel`) + Traefik File-Provider Bereinigung + Komodo Einführung + AdGuard Home Migration + Pi-hole Ablösung | ✅ Abgeschlossen |
| Sprint 5 | `Plex-Media-Server` Compose-Migration + `PortainerCE` abschalten | ✅ Abgeschlossen |
| Sprint 6 | Hardening / Secrets / Volumes / Sonderfälle (`immich_default` ✅, Volumes, Mounts, AdGuard Traefik) | ✅ Abgeschlossen |
| Sprint 7 | `Authelia` SSO/2FA: Compose + Config im Repo, Traefik ForwardAuth Middleware, NAS-seitige Einrichtung | 🔄 In Bearbeitung |
### 11.2 Operativer Ablauf
1. Zielbild prüfen
2. lokal synchronisieren
3. gezielt ändern
4. Commit + Push
5. Komodo-Webhook und Ergebnis prüfen
6. Dokumentation nachziehen
### 11.3 Regel für jede Änderung
1. Zielbild in diesem Dokument prüfen
2. nur den aktuellen Block anfassen
3. Compose-Datei ändern
4. deployen
5. testen
6. dokumentieren / abhaken
7. erst dann nächster Schritt
2. nur den betroffenen Bereich anfassen
3. Änderung lokal vorbereiten
4. nach Gitea pushen
5. automatische Reaktion von Komodo beachten
6. testen
7. dokumentieren
### 11.4 Source-of-Truth-Hierarchie
1. **Dieses Dokument**
2. Compose-Dateien im Git-Repo
3. operative Checklisten
4. ad-hoc Notizen / Chat
1. **Gitea Online (`origin/master`)**
2. lokaler Clone / GitHub Desktop
3. Compose-Dateien im Git-Repo
4. Komodo als Deploy-Consumer
5. operative Checklisten und Notizen
---
### 11.5 Operativer Git-Workflow
- Gitea Online ist der verbindliche Sollzustand.
- Lokal wird standardmäßig über GitHub Desktop gearbeitet.
- Komodo deployt aus Gitea und ist kein Bearbeitungsort.
- Webhooks sind aktiv: Ein Push kann unmittelbar einen Komodo-Deploy auslösen.
- Wenn online in Gitea editiert wurde, muss vor der nächsten lokalen Änderung zuerst `Fetch origin` und danach `Pull origin` erfolgen.
## 12. Nutzung mit KI / Kontext-Regel
@@ -515,16 +473,25 @@ Komodo ist nun der primäre GitOps-Stack-Manager:
- **Komodo Core** läuft als Docker-Stack (`ops/komodo/docker-compose.yml`)
- **Komodo Periphery** läuft auf dem Unraid-Host für direktes Server-Management
- Stacks werden via Gitea synchronisiert und über Komodo deployed
- Portainer CE läuft noch als Legacy-UI und wird in Sprint 5 abgeschaltet
- Portainer CE ist abgeschaltet; Komodo ist der alleinige aktive Stack-Manager
**Vorteil gegenüber Portainer:** Sauberer GitOps-Flow ohne Web-Editor; alle Stack-Änderungen laufen über Git.
**Betriebsregel:** Alle Stack-Änderungen laufen über Git; Komodo konsumiert nur den Stand aus Gitea.
**Zugangsregel:** Komodo bleibt bewusst bei nativer Authentifizierung ohne pauschal vorgeschaltete ForwardAuth-Middleware vor dem gesamten Router. Hintergrund sind die gemischten UI-, API-, Webhook- und Periphery-Endpunkte unter derselben Domain.
### Komodo Self-Stack Drift-Recovery (2026-05-04)
- Befund: `komodo-core` und `komodo-periphery` liefen aus temporaeren `/tmp/*repair.yml`-Dateien, waehrend `komodo-mongo` auf den fehlenden persistenten Pfad `/mnt/user/services/stacks/komodo/compose.yaml` verwies.
- Recovery: Repair-YAMLs und Runtime-ENV wurden unter `/mnt/user/appdata/komodo/_drift_backup_2026-05-04/` gesichert; eine zusaetzliche Recovery-ENV liegt unter `/mnt/user/appdata/secrets/_komodo_stack_env_recovery_2026-05-04.env` und ist als temporaeres Tier-1-Secret-Material zu behandeln.
- Der persistente Self-Stack wurde unter `/mnt/user/services/stacks/komodo/compose.yaml` aus `ops/komodo/docker-compose.yml` wiederhergestellt. Die hostseitige `.env` bleibt ausserhalb von Git.
- Reconcile-Regel: Bei Self-Stack-Drift keinen pauschalen `docker compose up -d` ausfuehren, wenn der Dry-run `komodo-mongo` recreaten wuerde. Core und Periphery koennen gezielt mit `--no-deps` neu erstellt werden, Mongo bleibt dabei unangetastet.
- Ergebnis: Alle drei Komodo-Container zeigen wieder auf `/mnt/user/services/stacks/komodo/compose.yaml`; Mongo blieb waehrend der Rueckfuehrung healthy.
### AdGuard Home — Ablösung von Pi-hole (2026-03-28)
`binhex-official-pihole` wurde entfernt und durch `AdGuard Home` + `unbound` ersetzt:
- AdGuard läuft als Git-Stack (`host-services/Adguard/docker-compose.yml`)
- Netzwerke: `dns_net` (feste IP 172.23.0.3) + `frontend_net`
- Port 53 (DNS) direkt gebunden — dokumentierte Ausnahme
- Port 3000 (Admin-UI) direkt gebunden — Traefik-Absicherung ausstehend (Block F)
- Admin-UI direkt gebunden via Tailscale-IP `100.80.98.33:8082` auf Container-Port 80 — 2026-05-26 bewusst als einfache Operator-Entscheidung ohne Traefik-/2FA-Umstellung
- `unbound` läuft weiterhin als Upstream-Resolver in `dns_net`
### diun — Entfernung (2026-03-28)
@@ -548,31 +515,75 @@ Update-Monitoring kann über Komodo's eingebaute Update-Notifications abgedeckt
- Durchgeführt via: manuelles `docker stop` der Containers → `docker network rm immich_default` → Komodo Redeploy
- Ergebnis: alle Immich-Container (`immich_postgres`, `immich_redis`, `immich_machine_learning`) sind jetzt vom Internet isoliert; nur `immich_server` hat zusätzlich `frontend_net` für Traefik
### Secrets in Komodo / Portainer Stacks
### Secrets in Komodo Stacks
Host-Pfade in `env_file` (z.B. `/mnt/...`) sind in Git-Stacks nicht verfügbar. Standardlösung: Stack Environment Variables + `${VARIABLE_NAME}` in der Compose.
**Regel:** Wenn `_FILE` nicht unterstützt wird → Stack Environment Variable. Kein Secret im Git.
**Bewusste Ausnahme:** `paperless-ngx` bleibt fuer `PAPERLESS_DBPASS` und `PAPERLESS_REDIS` vorerst bei Stack Environment Variables. Eine Umstellung auf `_FILE` ist fachlich denkbar, wird aber nicht gegen den aktuell stabilen Produktionsstand erzwungen.
### Borg UI / BorgBase (2026-04-12)
- `borg-ui` läuft als Admin-Dienst in `ops/borg-ui/docker-compose.yml`
- nur `frontend_net`, weil Web-UI + externer SSH-Zugang zu BorgBase benötigt werden
- keine direkten Host-Ports; Zugriff ausschließlich via Traefik + Middleware über `borg.kaleschke.info`
- breite Restore-/Backup-Mounts bewusst gesetzt; inklusive `/local/secrets` fuer Disaster Recovery, separates Restore-Ziel unter `/mnt/user/appdata/borg-ui/restore`
- kein separater Borg-CLI-Container nötig, da Borg UI die Borg-CLI bereits im Container mitbringt
| Container | `_FILE` Support |
|---|---|
| Vaultwarden | ✅ ja |
| PostgreSQL | ✅ ja |
| code-server | ✅ ja (`PASSWORD_FILE`) |
| Immich Postgres | ✅ ja (`POSTGRES_PASSWORD_FILE`) |
| Mealie | ❌ nein → Stack ENV |
| Mealie | ✅ ja (`POSTGRES_PASSWORD_FILE`) |
| paperless-ngx | ❌ nein für DB-Pass → Stack ENV |
### Reproduzierbare Deployments (2026-04-17)
Mutable Tags wie `latest`, `stable`, `release` oder reine Major-Tags wurden auf die **aktuell laufenden Digests** eingefroren. Das ist bewusst **kein Upgrade-Mechanismus**, sondern dient dazu, den heute funktionierenden Laufzeitstand exakt im Repo festzuhalten. Echte Versions-Upgrades bleiben ein eigener, geplanter Schritt.
### Stateful Digest-Pinning (2026-05-05, ergaenzt 2026-05-16)
- Tier-1/stateful Basisdienste werden bevorzugt mit sprechendem Minor-/Patch-Tag plus Digest gepinnt, z. B. `postgres:17.9@sha256:...` oder `mongo:7.0.32@sha256:...`.
- Redis-Caches sind seit dem Hardening-Sprint 2026-05-16 auf `redis:7.4-alpine@sha256:...` vereinheitlicht. Updates erfolgen bewusst stackweise mit Smoke-Test.
- Bereits versionierte Apps koennen optional spaeter ebenfalls Digests erhalten; dieser Schritt ist getrennt vom Datenhalter-Pinning.
### Nextcloud und Stirling-PDF (2026-04-19)
- `nextcloud` wird bewusst **nicht** als AIO-Stack gebaut, sondern als klassischer Docker-Microservice-Stack mit eigenem PostgreSQL und eigenem Redis. Das passt besser zum bestehenden GitOps-/Compose-Modell des Repos.
- `nextcloud` bleibt bei nativer App-Authentifizierung ohne zentrale ForwardAuth-Middleware vor dem Router, damit Browser-Login, Desktop-/Mobile-Clients sowie WebDAV/CardDAV sauber funktionieren.
- `stirling-pdf` wird als geschuetzter Tool-Stack hinter `authelia@file,secure-headers@file` betrieben; die interne Stirling-Login-Funktion bleibt deaktiviert, um Doppel-Login zu vermeiden.
### BentoPDF und Monitoring-Zielstack (2026-04-30, aktualisiert 2026-05-17)
- `bentopdf` ersetzt repo-seitig `stirling-pdf` auf der bestehenden Domain `pdf.kaleschke.info`, bleibt aber bis zum bewussten Komodo-Deploy nur vorbereitet.
- BentoPDF benoetigt fuer Office-Konvertierung die Cross-Origin-Isolation-Header `Cross-Origin-Opener-Policy: same-origin` und `Cross-Origin-Embedder-Policy: require-corp`; diese werden per Traefik-Docker-Middleware gesetzt.
- `monitoring/` ist der zentrale Zielstack fuer Prometheus, Loki, Promtail, Grafana, node-exporter, cAdvisor und InfluxDB 3 Core.
- `monitoring-grafana` wird als geschuetztes Monitoring-UI unter `monitoring.kaleschke.info` betrieben.
- `monitoring-influxdb3-core` bleibt ohne Traefik-/Public-Route; fuer interne Writer wie Home Assistant kann Port `8181` per `INFLUXDB_BIND_IP` auf eine LAN-Adresse gebunden werden.
- Fuer dieses Port-Publishing nutzt `monitoring-influxdb3-core` zusaetzlich `monitoring_influx_lan`. Das ist keine Public-App-Freigabe und ersetzt nicht die Token-Authentifizierung.
- InfluxDB 3 Core nutzt einen festen Versionstag statt `latest`, weil der InfluxDB-`latest`-Tag versionsstrategisch im Umbruch ist.
- Die alten Pfade `ops/grafana-influxdb` und `ops/loki` wurden am 2026-05-26 aus dem aktiven Repo entfernt; `monitoring/` ist der einzige Observability-Zielstack.
- Uptime Kuma wurde nach erfolgreichem Blackbox-/Grafana-Smoke-Test entfernt; `monitoring/` ist die Quelle fuer HTTP-Erreichbarkeit und Alerts.
### Monitoring-Logging-Baseline (2026-05-17)
- `monitoring-loki` laeuft intern auf `monitoring_net`, ohne Traefik-Route und ohne Host-Port.
- `monitoring-promtail` sammelt Docker-Logs ueber `/var/run/docker.sock:ro` und `/var/lib/docker/containers:ro` und schreibt sie an Loki.
- `monitoring-grafana` bekommt provisionierte Datasources fuer Prometheus, Loki und InfluxDB 3 Core.
- Loki-Logdaten sind Diagnosematerial mit begrenzter Retention, keine primaere Restore-Quelle.
### Authelia ohne Redis-Session-Backend (2026-05-04)
- Authelia nutzt PostgreSQL fuer persistente Storage-Daten, aber bewusst kein Redis-Session-Backend.
- Das haelt den Tier-1-Auth-Pfad einfacher; nach einem Authelia-Restart muessen aktive Sessions neu aufgebaut werden.
- `infra/redis` bleibt shared Cache fuer Dienste wie Paperless, ist aber keine Authelia-Abhaengigkeit.
### ddns-updater — Netz-Ausnahme
Bleibt bewusst in `frontend_net` statt `backend_net`, weil `backend_net` `internal: true` ist und ddns-updater die Cloudflare-API erreichen muss.
### mail-archiver — Hybrid-Dienst
Benötigt `backend_net` (PostgreSQL) + `frontend_net` (IMAP-Abruf von GMX/Gmail). Kein reiner Backend-Dienst. Kein öffentlicher Traefik-Zugang.
Benötigt `backend_net` (PostgreSQL) + `frontend_net` (IMAP-Abruf von GMX/Gmail). Kein reiner Backend-Dienst. Die Web-UI ist via Traefik unter `mail.kaleschke.info` erreichbar und wird durch `authelia@file,secure-headers@file` plus App-eigene Auth geschuetzt.
### Netzwerk-Standard für Apps mit Datenbanken
- App → `frontend_net` + internes Netzwerk
- Datenbank → nur internes Netzwerk (`internal: true`)
Beispiel (Mealie): `mealie``frontend_net` + `mealie_mealie_internal`, `mealie-postgres` → nur `mealie_mealie_internal`.
Beispiel (Mealie): `mealie``frontend_net` + `mealie_internal`, `mealie-postgres` → nur `mealie_internal`.
---
@@ -581,4 +592,4 @@ Beispiel (Mealie): `mealie` → `frontend_net` + `mealie_mealie_internal`, `meal
Dieses Dokument ist keine lose Notiz, sondern das **operative Masterdokument** für die Docker- und Zugriffsarchitektur des Homelabs.
**Zielbild in einem Satz:**
`frontend_net` für alle Web-UIs und Dienste mit Internetbedarf, `backend_net` für interne Backends, app-interne Netze nur wenn technisch nötig, Tailscale für Remote-Admin-Zugriff, Traefik als einziger Web-Einstieg (100% Docker-Labels), Komodo als GitOps-Stack-Manager, AdGuard Home + Unbound für DNS, keine produktiven `bridge`-Container mehr.
`frontend_net` für Web-UIs und Dienste mit Internetbedarf, `backend_net` für interne Backends, app-interne Netze nur wenn technisch nötig, Tailscale für Remote-Admin-Zugriff, Traefik als einziger Web-Einstieg (Service-Routing via Docker-Labels, File-Provider nur für zentrale Dynamic-Config), Komodo als GitOps-Stack-Manager, AdGuard Home + Unbound für DNS, keine produktiven Container im Docker-Default-`bridge`.
+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
+56 -31
View File
@@ -1,53 +1,78 @@
# Homelab Infrastructure (KalliLab CORE)
Dieses Repository ist die zentrale Quelle ("Single Source of Truth") für die komplette Infrastruktur meines Homelabs.
Dieses Repository ist die zentrale Quelle ("Single Source of Truth") fuer die komplette Infrastruktur meines Homelabs.
## 🚨 WICHTIG Einstieg
## WICHTIG - Einstieg
Vor jeder Änderung lesen:
Vor jeder Aenderung lesen:
1. 👉 HOMELAB_ARCHITECTURE_MASTER_V2.md
2. 👉 docs/WORKFLOW.md
1. `HOMELAB_ARCHITECTURE_MASTER_V2.md`
2. `docs/WORKFLOW.md`
Bei Restore-, Host-Ausfall- oder Wiederanlauf-Fragen zusaetzlich:
3. `docs/DISASTER_RECOVERY.md`
4. `docs/RESTORE_MATRIX.md`
5. `docs/SERVICES_RECOVERY.md`
Bei Hardware-, Netzwerk-, Provider- oder Kapazitaetsfragen zusaetzlich:
6. `docs/HARDWARE_INVENTORY.md`
7. `docs/NETWORK_INVENTORY.md`
8. `docs/EXTERNAL_DEPENDENCIES.md`
9. `docs/CAPACITY_AND_LIFECYCLE.md`
## Architektur
- Host: Unraid
- Container: Docker (Compose)
- Reverse Proxy: Traefik v3 (100% Docker-Labels, kein File-Provider mehr)
- Container: Docker Compose
- Reverse Proxy: Traefik v3 (Service-Routing via Docker-Labels, File-Provider nur fuer zentrale Dynamic-Config)
- Zugriff: Tailscale (VPN)
- DNS: AdGuard Home + Unbound
- GitOps: Gitea + Komodo (Stack-Manager)
- GitOps: Gitea + Komodo
## Grundprinzipien
- Alle Änderungen erfolgen über Git (Komodo deployed automatisch aus Gitea)
- Keine produktiven Container außerhalb von Compose
- Traefik ist der einzige öffentliche Einstiegspunkt
- Admin-Dienste sind nicht öffentlich erreichbar (nur via VPN oder Auth)
- Secrets werden niemals im Repository gespeichert
- Gitea Online ist der operative Sollzustand.
- Der lokale Clone ist die Arbeitskopie.
- Komodo deployed automatisch aus Gitea und ist kein Bearbeitungsort.
- Keine produktiven Container ausserhalb von Compose.
- Traefik ist der einzige oeffentliche Einstiegspunkt.
- Secrets werden niemals im Repository gespeichert.
## Repository-Struktur
- `core/` Basisdienste (Gitea)
- `security/` sicherheitskritische Dienste (Vaultwarden)
- `infra/` Datenbanken & technische Services (PostgreSQL, Redis, DDNS-Updater)
- `apps/` Anwendungen (Immich, Paperless, Mealie, Homepage, ...)
- `ops/` → Monitoring & Tools (Komodo, Scrutiny, Uptime-Kuma, Backrest, ...)
- `host-services/` → Dienste mit Host-Netz (AdGuard, Beszel, Tailscale, ...)
- `traefik/` → Reverse Proxy Konfiguration
- `docs/` → Dokumentation & Prozesse
- `env/` → Beispiel-Umgebungsvariablen
- `core/` -> Basisdienste (Gitea)
- `security/` -> sicherheitskritische Dienste
- `infra/` -> Datenbanken und technische Services
- `apps/` -> Anwendungen
- `ops/` -> operative Tools
- `monitoring/` -> zentraler Observability-Stack
- `host-services/` -> Dienste mit Host-Netz
- `traefik/` -> Reverse Proxy Konfiguration
- `docs/` -> Dokumentation und Prozesse
- `env/` -> Beispiel-Umgebungsvariablen
## Workflow
## Kurz-Workflow
1. Änderung im Repository (Git)
2. Commit & Push nach Gitea
3. Komodo deployed automatisch (GitOps)
4. Testen
5. Dokumentation aktualisieren
1. In GitHub Desktop `Fetch origin`.
2. Wenn noetig `Pull origin`.
3. Lokal aendern.
4. Commit erstellen.
5. `Push origin`.
6. Komodo-Webhook und Ergebnis pruefen.
7. Doku bei Bedarf aktualisieren.
## Status
GitOps-Migration (Sprint 14) abgeschlossen. Komodo ist primärer Stack-Manager.
> ⚠️ Portainer CE läuft noch als Legacy-UI wird in Sprint 5 abgeschaltet.
- Komodo ist der primaere und einzige produktive Stack-Manager.
- Komodo bleibt bewusst bei nativer Authentifizierung; zentrale Traefik-Auth wird dort nicht pauschal vorgeschaltet.
- Portainer CE ist abgeschaltet und kein Teil des aktiven Betriebs mehr.
- Glance ist das aktive produktive Homelab-Dashboard.
- Traefik `dynamic/` bleibt eine dokumentierte manuelle Host-Sync-Ausnahme ausserhalb des normalen Komodo-Deployments.
- Mutable Image-Tags sind auf die aktuell laufenden Digests eingefroren; echte Versions-Upgrades erfolgen bewusst separat.
- Disaster-Recovery und dienstspezifische Restore-Quellen sind in `docs/DISASTER_RECOVERY.md` und `docs/RESTORE_MATRIX.md` beschrieben.
- Recovery-kritische Services-Pfade wie Gitea-Repositories, Komodo-Workspaces und Host-Automation sind in `docs/SERVICES_RECOVERY.md` beschrieben.
- Hardware-, Netzwerk-, Provider- und Capacity-Inventare sind als operative Audit-Dokumente unter `docs/HARDWARE_INVENTORY.md`, `docs/NETWORK_INVENTORY.md`, `docs/EXTERNAL_DEPENDENCIES.md` und `docs/CAPACITY_AND_LIFECYCLE.md` vorbereitet.
- Der verbindliche Detailablauf steht in `docs/WORKFLOW.md`.
- `nextcloud`, `bentopdf` und `monitoring` folgen dem dokumentierten Netz-/Secret-/Traefik-Modell; der zentrale Monitoring-Stack buendelt Prometheus, Loki, Promtail, Grafana und InfluxDB 3 Core.
+34
View File
@@ -0,0 +1,34 @@
services:
bentopdf:
image: bentopdfteam/bentopdf:2.8.4@sha256:f54b9ed9c56b767e0098b525468206689b666323c2b500b9686c3cf41cdfa348
container_name: bentopdf
restart: unless-stopped
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,mode=1777
- /var/cache/nginx:rw,noexec,nosuid,nodev,uid=101,gid=101,mode=0755
- /var/run:rw,noexec,nosuid,nodev,uid=101,gid=101,mode=0755
networks:
- frontend_net
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
labels:
- traefik.enable=true
- traefik.docker.network=frontend_net
- traefik.http.routers.bentopdf.rule=Host(`pdf.kaleschke.info`)
- traefik.http.routers.bentopdf.entrypoints=websecure
- traefik.http.routers.bentopdf.tls=true
- traefik.http.routers.bentopdf.tls.certresolver=le
- traefik.http.routers.bentopdf.middlewares=authelia@file,secure-headers@file,bentopdf-coi@docker
- traefik.http.middlewares.bentopdf-coi.headers.customresponseheaders.Cross-Origin-Opener-Policy=same-origin
- traefik.http.middlewares.bentopdf-coi.headers.customresponseheaders.Cross-Origin-Embedder-Policy=require-corp
- traefik.http.services.bentopdf.loadbalancer.server.port=8080
networks:
frontend_net:
external: true
-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,84 +0,0 @@
export function renderNetworkHealth(state) {
_renderAdGuard(state.adguard || {});
_renderScrutiny(state.scrutiny || {});
}
function _renderAdGuard(d) {
const online = d.source_status === "online";
const pill = document.getElementById("adguard-pill");
if (pill) {
pill.textContent = online ? "ONLINE" : "OFFLINE";
pill.className = "status-pill " + (online ? "pill-online" : "pill-offline");
}
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
if (online) {
set("adguard-total", fmtK(d.total_queries));
set("adguard-blocked", fmtK(d.blocked_queries));
set("adguard-blocked-pct", `${d.blocked_percent ?? 0}%`);
set("adguard-latency", `${d.avg_processing_ms ?? 0}ms`);
const bar = document.getElementById("adguard-bar-fill");
if (bar) bar.style.width = `${Math.min(d.blocked_percent ?? 0, 100)}%`;
} else {
["adguard-total", "adguard-blocked", "adguard-blocked-pct", "adguard-latency"].forEach(id => set(id, "—"));
const bar = document.getElementById("adguard-bar-fill");
if (bar) bar.style.width = "0%";
}
}
function _renderScrutiny(d) {
const online = d.source_status === "online";
const pill = document.getElementById("scrutiny-pill");
if (pill) {
pill.textContent = online ? "ONLINE" : "OFFLINE";
pill.className = "status-pill " + (online ? "pill-online" : "pill-offline");
}
// stat blocks
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
if (online) {
set("scrutiny-total", d.total_count ?? 0);
set("scrutiny-failed", d.failed_count ?? 0);
const passedEl = document.getElementById("scrutiny-passed");
if (passedEl) {
passedEl.textContent = (d.total_count ?? 0) - (d.failed_count ?? 0);
}
} else {
set("scrutiny-total", "—");
set("scrutiny-failed", "—");
set("scrutiny-passed", "—");
}
// disk list
const list = document.getElementById("scrutiny-list");
if (!list) return;
if (!online || !d.devices || d.devices.length === 0) {
list.innerHTML = `<div class="scrutiny-offline">— ${online ? "no devices" : "offline"}</div>`;
return;
}
list.innerHTML = d.devices.map(dev => {
const ok = dev.status === "passed";
const icon = ok ? "✓" : dev.status === "failed" ? "✗" : "?";
const cls = ok ? "disk-ok" : dev.status === "failed" ? "disk-fail" : "disk-unk";
const temp = dev.temperature != null ? `<span class="disk-temp">${dev.temperature}°C</span>` : "";
return `
<div class="scrutiny-row">
<span class="disk-icon ${cls}">${icon}</span>
<span class="disk-name">${dev.name}</span>
<span class="disk-model">${dev.model}</span>
${temp}
</div>`;
}).join("");
}
function fmtK(n) {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
if (n >= 1_000) return (n / 1_000).toFixed(0) + "K";
return String(n ?? 0);
}
@@ -1,29 +0,0 @@
const QUICK_LINKS = [
{ label: "Home Assistant", icon: "🏠", url: "https://ha.kaleschke.info" },
{ label: "Komodo", icon: "🦎", url: "https://komodo.kaleschke.info" },
{ label: "Uptime Kuma", icon: "📡", url: "https://uptime.kaleschke.info" },
{ label: "Beszel", icon: "📊", url: "https://beszel.kaleschke.info" },
{ label: "Firefly III", icon: "🦋", url: "https://firefly.kaleschke.info" },
{ label: "Paperless", icon: "📄", url: "https://paperless.kaleschke.info" },
{ label: "Mealie", icon: "🍽️", url: "https://mealie.kaleschke.info" },
{ label: "Immich", icon: "🖼️", url: "https://immich.kaleschke.info" },
{ label: "Gitea", icon: "🐙", url: "https://git.kaleschke.info" },
{ label: "Code Server", icon: "💻", url: "https://code.kaleschke.info" },
{ label: "FileBrowser", icon: "📁", url: "https://files.kaleschke.info" },
{ label: "Backrest", icon: "💾", url: "https://backrest.kaleschke.info" },
{ label: "Vaultwarden", icon: "🔐", url: "https://vault.kaleschke.info" },
{ label: "AdGuard", icon: "🛡️", url: "https://adguard.kaleschke.info" },
{ label: "Traefik", icon: "🔀", url: "https://traefik.kaleschke.info" },
{ label: "Scrutiny", icon: "🔍", url: "https://scrutiny.kaleschke.info" },
];
export function renderQuickAccess() {
const grid = document.getElementById("quick-access-grid");
if (!grid) return;
grid.innerHTML = QUICK_LINKS.map(({ label, icon, url }) => `
<a class="quick-tile" href="${url}" target="_blank" rel="noopener noreferrer">
<span class="quick-tile-icon">${icon}</span>
<span class="quick-tile-label">${label}</span>
</a>
`).join("");
}
@@ -1,24 +0,0 @@
export function renderServices(state) {
const services = state.services || {};
const summary = services.summary || {};
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
set("svc-online", summary.online ?? "—");
set("svc-degraded", summary.degraded ?? "—");
set("svc-offline", summary.offline ?? "—");
set("svc-total", summary.total ?? "—");
const pill = document.getElementById("services-pill");
if (pill) {
const s = summary.overall_status || "offline";
pill.textContent = s.toUpperCase();
pill.className = "status-pill " + (s === "online" ? "pill-online" : s === "degraded" ? "pill-degraded" : "pill-offline");
}
// Colour counts
const degEl = document.getElementById("svc-degraded");
if (degEl) degEl.className = "stat-num" + ((summary.degraded ?? 0) > 0 ? " warn" : "");
const offEl = document.getElementById("svc-offline");
if (offEl) offEl.className = "stat-num" + ((summary.offline ?? 0) > 0 ? " danger" : "");
}
@@ -1,49 +0,0 @@
export function renderStats(state) {
const sys = state.system || {};
const overview = state.overview || {};
const cpu = sys.cpu || {};
const mem = sys.memory || {};
const net = sys.network || {};
const host = sys.host || {};
const docker = overview.docker || state.services?.docker || {};
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
// CPU
set("cpu-percent", cpu.usage_percent != null ? `${cpu.usage_percent.toFixed(1)}%` : "—");
set("cpu-cores", cpu.cores ?? "—");
set("cpu-load", cpu.load_5 != null ? cpu.load_5.toFixed(2) : "—");
// Colour CPU
const cpuEl = document.getElementById("cpu-percent");
if (cpuEl && cpu.usage_percent != null) {
cpuEl.className = "stat-num" + (cpu.usage_percent > 85 ? " danger" : cpu.usage_percent > 65 ? " warn" : "");
}
// Memory
set("ram-percent", mem.usage_percent != null ? `${mem.usage_percent.toFixed(1)}%` : "—");
set("ram-used", mem.used_gb != null ? mem.used_gb.toFixed(1) : "—");
set("ram-total", mem.total_gb != null ? mem.total_gb.toFixed(0) : "—");
const ramEl = document.getElementById("ram-percent");
if (ramEl && mem.usage_percent != null) {
ramEl.className = "stat-num" + (mem.usage_percent > 85 ? " danger" : mem.usage_percent > 65 ? " warn" : "");
}
// Network
set("net-rx", net.rx_mbps != null ? net.rx_mbps.toFixed(1) : "—");
set("net-tx", net.tx_mbps != null ? net.tx_mbps.toFixed(1) : "—");
// Host
const upDays = host.uptime_seconds != null ? Math.floor(host.uptime_seconds / 86400) : null;
set("uptime-days", upDays != null ? upDays : "—");
set("host-platform", host.platform ? host.platform.slice(0, 5).toUpperCase() : "—");
// Docker
set("docker-running", docker.running ?? "—");
set("docker-stopped", docker.stopped ?? "—");
set("docker-total", docker.total ?? "—");
const stoppedEl = document.getElementById("docker-stopped");
if (stoppedEl) stoppedEl.className = "stat-num" + ((docker.stopped ?? 0) > 0 ? " warn" : " dim");
}
@@ -1,28 +0,0 @@
export function renderStorage(state) {
const storage = state.storage || {};
const grid = document.getElementById("storage-grid");
if (!grid) return;
const disks = storage.disks || [];
if (!disks.length) {
grid.innerHTML = '<div class="card" style="opacity:0.5; font-family:var(--font-mono); font-size:10px; color:var(--text-dim); padding:12px;">No disk data</div>';
return;
}
grid.innerHTML = disks.map(disk => {
const pct = disk.usage_percent ?? 0;
const fillClass = pct > 85 ? "danger" : pct > 70 ? "warn" : "";
const statusColor = disk.status === "critical" ? "var(--red)" : disk.status === "warning" ? "var(--yellow)" : "var(--teal)";
return `
<div class="card">
<div class="disk-header">
<span class="disk-name">${disk.name || disk.mount}</span>
<span class="disk-usage" style="color:${statusColor}">${pct.toFixed(1)}%</span>
</div>
<div class="disk-sub">${disk.mount} · ${disk.used_gb?.toFixed(1)}/${disk.total_gb?.toFixed(1)} GB</div>
<div class="progress-bar">
<div class="progress-fill ${fillClass}" style="width:${Math.min(pct,100)}%"></div>
</div>
</div>`;
}).join("");
}
@@ -1,53 +0,0 @@
export function renderUptimeKuma(state) {
const d = state.uptime_kuma || {};
const online = d.source_status === "online";
const pill = document.getElementById("uk-pill");
if (pill) {
pill.textContent = online ? "ONLINE" : "OFFLINE";
pill.className = "status-pill " + (online ? "pill-online" : "pill-offline");
}
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
set("uk-up", online ? (d.monitors_up ?? 0) : "—");
set("uk-down", online ? (d.monitors_down ?? 0) : "—");
const total = (d.monitors_up ?? 0) + (d.monitors_down ?? 0);
const pct = total > 0 ? Math.round((d.monitors_up / total) * 100) : (online ? 100 : 0);
set("uk-uptime", online ? `${pct}%` : "—");
// down monitors list
const downList = document.getElementById("uk-down-list");
if (downList) {
const downs = (d.monitors || []).filter(m => m.status === "offline");
if (!online || downs.length === 0) {
downList.innerHTML = "";
} else {
downList.innerHTML = downs.map(m =>
`<span class="uk-down-name">▼ ${m.name}</span>`
).join("");
}
}
// uptime bars per monitor (top 6 by name)
const barsContainer = document.getElementById("uk-bars");
if (barsContainer) {
const monitors = (d.monitors || []).slice(0, 6);
if (!online || monitors.length === 0) {
barsContainer.innerHTML = '<span class="widget-offline-msg">—</span>';
} else {
barsContainer.innerHTML = monitors.map(m => {
const beats = m.heartbeats && m.heartbeats.length
? m.heartbeats
: (m.status === "online" ? Array(20).fill(1) : Array(20).fill(0));
const segments = beats.slice(-20).map(b =>
`<span class="hb-seg ${b ? "hb-up" : "hb-down"}"></span>`
).join("");
return `
<div class="uk-monitor-row">
<span class="uk-monitor-name">${m.name}</span>
<span class="uk-bar">${segments}</span>
</div>`;
}).join("");
}
}
}
-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
-431
View File
@@ -1,431 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KalliLab Control Panel</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;700&family=Share+Tech+Mono&family=Exo+2:wght@300;400;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #060b09;
--bg2: #0a1210;
--card: rgba(8, 18, 15, 0.88);
--card-border: rgba(0, 220, 140, 0.18);
--card-hover: rgba(0, 220, 140, 0.28);
--teal: #00dc8c;
--teal-dim: #009e65;
--teal-bright: #00ffaa;
--teal-glow: rgba(0, 220, 140, 0.4);
--text: #b8d4cc;
--text-dim: #5a8a7a;
--text-bright: #d8f0e8;
--clr-warn: #ff4466;
--red: #ff4466;
--yellow: #ffcc44;
--blue: #44aaff;
--graph: #00cc88;
--font-display: 'Orbitron', monospace;
--font-mono: 'Share Tech Mono', monospace;
--font-body: 'Exo 2', sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
min-height: 100vh;
overflow-x: hidden;
position: relative;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(0, 220, 140, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 220, 140, 0.025) 1px, transparent 1px);
background-size: 40px 40px;
z-index: 0;
pointer-events: none;
}
@keyframes scanline {
0% { top: -5%; }
100% { top: 105%; }
}
.scanline {
position: fixed;
left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(0,220,140,0.06), rgba(0,220,140,0.10), rgba(0,220,140,0.06), transparent);
z-index: 1;
animation: scanline 8s linear infinite;
pointer-events: none;
}
.wrapper {
position: relative;
z-index: 2;
max-width: 1340px;
margin: 0 auto;
padding: 0 10px 32px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0 8px;
border-bottom: 1px solid var(--card-border);
margin-bottom: 10px;
gap: 12px;
}
.header-logo { display: flex; align-items: center; gap: 10px; }
.logo-text {
font-family: var(--font-display);
font-size: 16px;
font-weight: 700;
color: var(--teal-bright);
text-shadow: 0 0 20px var(--teal-glow);
letter-spacing: 2px;
}
.logo-sub {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
letter-spacing: 2px;
text-transform: uppercase;
}
.header-center { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.overall-status { font-family: var(--font-mono); font-size: 10px; letter-spacing: 2px; color: var(--teal-dim); }
.status-indicator {
display: flex; align-items: center; gap: 6px;
font-family: var(--font-display); font-size: 11px;
color: var(--teal-bright); text-shadow: 0 0 10px var(--teal-glow);
}
.status-dot-main { width: 8px; height: 8px; border-radius: 50%; background: var(--teal); box-shadow: 0 0 8px var(--teal-glow); }
.header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
.clock {
font-family: var(--font-display); font-size: 24px; font-weight: 700;
color: var(--teal-bright); text-shadow: 0 0 20px var(--teal-glow), 0 0 40px rgba(0,220,140,0.2);
letter-spacing: 2px;
}
.date-str { font-family: var(--font-mono); font-size: 10px; color: var(--text-dim); text-align: right; }
#last-updated { font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); text-align: right; }
.section-header {
display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
font-family: var(--font-display); font-size: 8px; font-weight: 600;
letter-spacing: 3px; text-transform: uppercase; color: var(--teal-dim);
}
.section-header::after { content: ''; flex: 1; height: 1px; background: linear-gradient(90deg, var(--card-border), transparent); }
.widget-row { display: grid; gap: 8px; margin-bottom: 8px; }
.row-5 { grid-template-columns: repeat(5, 1fr); }
.row-4 { grid-template-columns: repeat(4, 1fr); }
.row-3 { grid-template-columns: repeat(3, 1fr); }
.row-2 { grid-template-columns: repeat(2, 1fr); }
.row-2-1 { grid-template-columns: 2fr 1fr; }
.row-1-2 { grid-template-columns: 1fr 2fr; }
.row-3-2 { grid-template-columns: 3fr 2fr; }
.card {
background: rgba(6, 14, 11, 0.78);
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 8px 10px;
transition: border-color 0.2s, box-shadow 0.2s;
backdrop-filter: blur(14px) saturate(1.4);
-webkit-backdrop-filter: blur(14px) saturate(1.4);
}
.card:hover { border-color: rgba(0,220,140,0.32); box-shadow: 0 0 16px rgba(0,220,140,0.07), inset 0 0 20px rgba(0,220,140,0.02); }
.card-title {
font-family: var(--font-display); font-size: 8px; font-weight: 600;
letter-spacing: 2px; text-transform: uppercase; color: var(--teal-dim);
margin-bottom: 6px; display: flex; align-items: center; justify-content: space-between; gap: 6px;
}
.card-title-left { display: flex; align-items: center; gap: 6px; }
.card-title .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--teal); box-shadow: 0 0 5px var(--teal-glow); flex-shrink: 0; }
.stats-grid { display: flex; justify-content: space-around; gap: 6px; flex-wrap: wrap; }
.stat-block { text-align: center; min-width: 40px; }
.stat-num { font-family: var(--font-display); font-size: 17px; font-weight: 700; color: var(--teal-bright); text-shadow: 0 0 10px var(--teal-glow); line-height: 1.1; }
.stat-num.dim { color: var(--teal-dim); text-shadow: none; font-size: 14px; }
.stat-num.warn { color: var(--yellow); text-shadow: 0 0 10px rgba(255,204,68,0.4); }
.stat-num.danger { color: var(--red); text-shadow: 0 0 10px rgba(255,68,102,0.4); }
.stat-num.blue { color: var(--blue); text-shadow: 0 0 10px rgba(68,170,255,0.4); }
.stat-label { font-family: var(--font-mono); font-size: 8px; color: var(--text-dim); letter-spacing: 1px; text-transform: uppercase; margin-top: 1px; }
.status-pill { font-family: var(--font-mono); font-size: 7px; letter-spacing: 1px; padding: 1px 5px; border-radius: 3px; font-weight: 700; }
.pill-online { background: rgba(0,220,140,0.12); color: var(--teal); border: 1px solid rgba(0,220,140,0.3); }
.pill-offline { background: rgba(255,68,102,0.1); color: var(--red); border: 1px solid rgba(255,68,102,0.25); }
.pill-degraded { background: rgba(255,204,68,0.1); color: var(--yellow); border: 1px solid rgba(255,204,68,0.25); }
.progress-wrap { margin-top: 5px; }
.progress-label { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); margin-bottom: 2px; }
.progress-bar { height: 3px; background: rgba(0,220,140,0.1); border-radius: 2px; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--teal-dim), var(--teal-bright)); border-radius: 2px; box-shadow: 0 0 5px var(--teal-glow); transition: width 1s ease; }
.progress-fill.warn { background: linear-gradient(90deg, #cc8800, var(--yellow)); }
.progress-fill.danger { background: linear-gradient(90deg, #cc2244, var(--red)); }
.service-header { display: flex; align-items: center; gap: 7px; margin-bottom: 7px; }
.service-icon { width: 24px; height: 24px; border-radius: 5px; display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; }
.service-name { font-family: var(--font-display); font-size: 9px; font-weight: 600; color: var(--text-bright); letter-spacing: 1px; flex: 1; }
.service-version { font-family: var(--font-mono); font-size: 8px; color: var(--text-dim); }
.sys-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px 10px; font-family: var(--font-mono); font-size: 10px; }
.sys-row { display: flex; justify-content: space-between; padding: 1px 0; }
.sys-key { color: var(--text-dim); }
.sys-val { color: var(--teal); }
.disk-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 3px; }
.disk-name { font-family: var(--font-display); font-size: 9px; color: var(--text-bright); letter-spacing: 1px; }
.disk-usage { font-family: var(--font-mono); font-size: 9px; color: var(--teal); }
.disk-sub { font-family: var(--font-mono); font-size: 8px; color: var(--text-dim); margin-bottom: 4px; }
.scrutiny-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-family: var(--font-mono); font-size: 9px; border-bottom: 1px solid rgba(0,220,140,0.05); }
.scrutiny-row:last-child { border-bottom: none; }
.disk-icon { font-size: 10px; font-weight: bold; width: 12px; text-align: center; }
.disk-ok { color: var(--teal); }
.disk-fail { color: var(--red); }
.disk-unk { color: var(--text-dim); }
.disk-name-col { flex: 1; color: var(--text-bright); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.disk-model { color: var(--text-dim); font-size: 8px; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.disk-temp { color: var(--teal-dim); font-size: 8px; white-space: nowrap; }
.scrutiny-offline { font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); padding: 4px 0; }
.uk-monitor-row { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
.uk-monitor-name { font-family: var(--font-mono); font-size: 8px; color: var(--text-dim); min-width: 70px; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.uk-bar { display: flex; gap: 1px; flex: 1; }
.hb-seg { height: 7px; flex: 1; border-radius: 1px; }
.hb-up { background: var(--teal); box-shadow: 0 0 3px var(--teal-glow); opacity: 0.85; }
.hb-down { background: var(--red); box-shadow: 0 0 3px rgba(255,68,102,0.4); opacity: 0.85; }
.uk-down-name { display: block; font-family: var(--font-mono); font-size: 8px; color: var(--red); padding: 1px 0; }
.status-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.dot-ok { background: var(--teal); box-shadow: 0 0 5px var(--teal-glow); }
.dot-err { background: var(--red); box-shadow: 0 0 5px rgba(255,68,102,0.4); }
.dot-unk { background: var(--text-dim); }
.adguard-bar-wrap { margin-top: 5px; }
.adguard-bar { height: 3px; background: rgba(0,220,140,0.1); border-radius: 2px; overflow: hidden; position: relative; }
.adguard-bar-fill { height: 100%; background: linear-gradient(90deg, var(--teal-dim), var(--teal-bright)); border-radius: 2px; box-shadow: 0 0 5px var(--teal-glow); width: 0%; transition: width 1s ease; }
.net-health-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
#quick-access-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 6px; }
.quick-tile { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 6px 7px; background: rgba(6, 14, 11, 0.72); border: 1px solid var(--card-border); border-radius: 7px; cursor: pointer; transition: all 0.18s; text-decoration: none; color: var(--text); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); }
.quick-tile:hover { border-color: rgba(0,220,140,0.4); background: rgba(0,220,140,0.05); transform: translateY(-2px); box-shadow: 0 4px 18px rgba(0,220,140,0.10); }
.quick-tile-icon { font-size: 18px; line-height: 1; }
.quick-tile-label { font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); text-align: center; line-height: 1.2; }
.quick-tile:hover .quick-tile-label { color: var(--teal); }
.docker-row { display: flex; gap: 6px; font-family: var(--font-mono); font-size: 9px; margin-top: 4px; flex-wrap: wrap; }
.docker-chip { padding: 2px 6px; border-radius: 3px; background: rgba(0,220,140,0.07); border: 1px solid rgba(0,220,140,0.15); color: var(--teal-dim); }
.docker-chip.running { color: var(--teal); border-color: rgba(0,220,140,0.3); }
.docker-chip.stopped { color: var(--yellow); border-color: rgba(255,204,68,0.3); background: rgba(255,204,68,0.06); }
.docker-chip.unhealthy { color: var(--red); border-color: rgba(255,68,102,0.3); background: rgba(255,68,102,0.06); }
@media (max-width: 1100px) {
.row-5 { grid-template-columns: repeat(3, 1fr); }
.row-4 { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 780px) {
.row-5, .row-4, .row-3 { grid-template-columns: repeat(2, 1fr); }
.row-2, .row-2-1, .row-1-2, .row-3-2 { grid-template-columns: 1fr; }
.net-health-grid { grid-template-columns: 1fr; }
#quick-access-grid { grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); }
}
</style>
</head>
<body>
<div class="scanline"></div>
<div class="wrapper">
<!-- HEADER -->
<header class="header" id="header">
<div class="header-logo">
<div>
<div class="logo-text">KALLILAB</div>
<div class="logo-sub">Control Panel</div>
</div>
</div>
<div class="header-center">
<div class="overall-status">SYSTEM STATUS</div>
<div class="status-indicator">
<span class="status-dot-main" id="overall-dot"></span>
<span id="overall-status-text">LOADING</span>
</div>
</div>
<div class="header-right">
<div class="clock" id="clock">--:--:--</div>
<div class="date-str" id="date-str">---</div>
<div id="last-updated">never updated</div>
</div>
</header>
<!-- SYSTEM STATS ROW -->
<div class="section-header"><span>&#x2B21;</span> SYSTEM</div>
<div class="widget-row row-5" id="stats-row" style="margin-bottom:8px;">
<div class="card">
<div class="card-title"><span class="dot"></span>CPU</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="cpu-percent">&#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>
<div class="card">
<div class="card-title"><span class="dot"></span>MEMORY</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="ram-percent">&#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>
<div class="card">
<div class="card-title"><span class="dot"></span>NETWORK</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="net-rx">&#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>
<div class="card">
<div class="card-title"><span class="dot"></span>HOST</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="uptime-days">&#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>
<div class="card">
<div class="card-title"><span class="dot"></span>DOCKER</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="docker-running">&#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>
</div>
<!-- STORAGE + SCRUTINY ROW -->
<div class="section-header"><span>&#x2B21;</span> STORAGE &amp; HEALTH</div>
<div style="display:grid; grid-template-columns: 1fr 280px; gap:8px; margin-bottom:8px;">
<div>
<div class="widget-row row-3" id="storage-grid">
<!-- Disk cards injected by renderer -->
</div>
</div>
<div class="card">
<div class="card-title">
<div class="card-title-left"><span class="dot"></span>SCRUTINY</div>
<span class="status-pill pill-offline" id="scrutiny-pill">OFFLINE</span>
</div>
<div class="stats-grid" style="margin-bottom:5px;">
<div class="stat-block"><div class="stat-num" id="scrutiny-total">&#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="widget-row row-3" style="margin-bottom:8px;">
<div class="card">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon" style="background:rgba(65,105,225,0.15);">&#x1F3E0;</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 style="margin-top:4px; font-family:var(--font-mono); font-size:8px; color:var(--text-dim);" id="ha-version"></div>
</div>
<div class="card">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon" style="background:rgba(0,220,140,0.12);">&#x1F43B;</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" id="uk-uptime">&#x2014;</div><div class="stat-label">24h %</div></div>
</div>
<div id="uk-down-list"></div>
<div id="uk-bars"></div>
</div>
<div class="card">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon" style="background:rgba(255,204,68,0.12);">&#x1F4F7;</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>
<!-- SERVICE WIDGETS ROW 2 -->
<div class="widget-row row-3" style="margin-bottom:8px;">
<div class="card">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon" style="background:rgba(68,170,255,0.12);">&#x1F4BE;</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">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon" style="background:rgba(0,220,140,0.12);">&#x1F6E1;&#xFE0F;</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">
<div class="card-title">
<div class="card-title-left"><span class="dot"></span>SERVICES OVERVIEW</div>
<span class="status-pill pill-offline" id="services-pill">&#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
+6 -8
View File
@@ -1,9 +1,7 @@
version: "3.9"
services:
immich-server:
container_name: immich_server
image: ghcr.io/immich-app/immich-server:release
image: ghcr.io/immich-app/immich-server:release@sha256:c15bff75068effb03f4355997d03dc7e0fc58720c2b54ad6f7f10d1bc57efaa5
restart: unless-stopped
depends_on:
- redis
@@ -14,10 +12,10 @@ services:
DB_PASSWORD: ${IMMICH_DB_PASSWORD}
DB_DATABASE_NAME: immich
REDIS_HOSTNAME: redis
TZ: Europe/Berlin
volumes:
- /mnt/user/photos/immich:/usr/src/app/upload
- /mnt/user/photos/family_archive:/usr/src/app/external
- /etc/localtime:/etc/localtime:ro
networks:
- immich_default
- frontend_net
@@ -34,7 +32,7 @@ services:
immich-machine-learning:
container_name: immich_machine_learning
image: ghcr.io/immich-app/immich-machine-learning:release
image: ghcr.io/immich-app/immich-machine-learning:release@sha256:a2501141440f10516d329fdfba2c68082e19eb9ba6016c061ac80d23beadf7f3
restart: unless-stopped
volumes:
- model-cache:/cache
@@ -45,7 +43,7 @@ services:
redis:
container_name: immich_redis
image: redis:7
image: redis:7.4-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99
restart: unless-stopped
networks:
- immich_default
@@ -54,7 +52,7 @@ services:
database:
container_name: immich_postgres
image: tensorchord/pgvecto-rs:pg14-v0.2.0
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
restart: unless-stopped
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
@@ -77,4 +75,4 @@ networks:
internal: true
driver: bridge
frontend_net:
external: true
external: true
View File
+3 -3
View File
@@ -1,7 +1,6 @@
version: "3.9"
services:
mail-archiver:
image: s1t5/mailarchiver
image: s1t5/mailarchiver@sha256:94d7525db56b13154a14203f8fb7b53fac034f28a914c32da9d2e426b49328ed
container_name: mail-archiver
restart: unless-stopped
environment:
@@ -25,9 +24,10 @@ services:
- "traefik.http.routers.mail-archiver.entrypoints=websecure"
- "traefik.http.routers.mail-archiver.tls=true"
- "traefik.http.routers.mail-archiver.tls.certresolver=le"
- "traefik.http.routers.mail-archiver.middlewares=authelia@file,secure-headers@file"
- "traefik.http.services.mail-archiver.loadbalancer.server.port=5000"
networks:
backend_net:
external: true
frontend_net:
external: true
external: true
View File
+4 -5
View File
@@ -1,6 +1,6 @@
services:
mealie:
image: ghcr.io/mealie-recipes/mealie:v3.12.0
image: ghcr.io/mealie-recipes/mealie:v3.12.0@sha256:8d962f611390a1cca667eed32a29e9467e9c01c523e2db3ad00f667372067f9d
container_name: mealie
restart: unless-stopped
@@ -14,13 +14,12 @@ services:
POSTGRES_SERVER: mealie-postgres
POSTGRES_DB: mealie
POSTGRES_USER: mealie
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
POSTGRES_PASSWORD: ${MEALIE_POSTGRES_PASSWORD}
BASE_URL: https://mealie.kaleschke.info
volumes:
- /mnt/user/appdata/mealie/data:/app/data
- /mnt/user/appdata/secrets/mealie_postgres_password.txt:/run/secrets/postgres_password:ro
networks:
- frontend_net
@@ -39,7 +38,7 @@ services:
- traefik.http.services.mealie.loadbalancer.server.port=9000
mealie-postgres:
image: postgres:17
image: postgres:17.9@sha256:5b96f1a16bd9768b060dd2ffe55cb6225c4d9ef4d214a8b21eb08134869a97e4
container_name: mealie-postgres
restart: unless-stopped
@@ -66,4 +65,4 @@ networks:
mealie_internal:
driver: bridge
internal: true
internal: true
+84
View File
@@ -0,0 +1,84 @@
services:
nextcloud:
image: nextcloud:33.0.2-apache@sha256:39b2ba219271a22851f8409a7b1295d5892aba1696d9193500311c02e60591a4
container_name: nextcloud
restart: unless-stopped
depends_on:
- nextcloud-postgres
- nextcloud-redis
environment:
TZ: Europe/Berlin
POSTGRES_HOST: nextcloud-postgres
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
REDIS_HOST: nextcloud-redis
NEXTCLOUD_ADMIN_USER_FILE: /run/secrets/admin_user
NEXTCLOUD_ADMIN_PASSWORD_FILE: /run/secrets/admin_password
NEXTCLOUD_DATA_DIR: /var/www/html/data
NEXTCLOUD_TRUSTED_DOMAINS: cloud.kaleschke.info
TRUSTED_PROXIES: 172.16.0.0/12
OVERWRITEHOST: cloud.kaleschke.info
OVERWRITEPROTOCOL: https
OVERWRITECLIURL: https://cloud.kaleschke.info
volumes:
- /mnt/user/appdata/nextcloud/html:/var/www/html
- /mnt/user/documents/nextcloud-data:/var/www/html/data
- /mnt/user/appdata/secrets/nextcloud_postgres_password.txt:/run/secrets/postgres_password:ro
- /mnt/user/appdata/secrets/nextcloud_admin_user.txt:/run/secrets/admin_user:ro
- /mnt/user/appdata/secrets/nextcloud_admin_password.txt:/run/secrets/admin_password:ro
networks:
- frontend_net
- nextcloud_internal
security_opt:
- no-new-privileges:true
labels:
- "traefik.enable=true"
- "traefik.docker.network=frontend_net"
- "traefik.http.routers.nextcloud.rule=Host(`cloud.kaleschke.info`)"
- "traefik.http.routers.nextcloud.entrypoints=websecure"
- "traefik.http.routers.nextcloud.tls=true"
- "traefik.http.routers.nextcloud.tls.certresolver=le"
- "traefik.http.routers.nextcloud.middlewares=nextcloud-redirectregex"
- "traefik.http.middlewares.nextcloud-redirectregex.redirectregex.permanent=true"
- "traefik.http.middlewares.nextcloud-redirectregex.redirectregex.regex=https://(.*)/.well-known/(?:card|cal)dav"
- "traefik.http.middlewares.nextcloud-redirectregex.redirectregex.replacement=https://$${1}/remote.php/dav"
- "traefik.http.services.nextcloud.loadbalancer.server.port=80"
nextcloud-postgres:
image: postgres:17.9@sha256:5b96f1a16bd9768b060dd2ffe55cb6225c4d9ef4d214a8b21eb08134869a97e4
container_name: nextcloud-postgres
restart: unless-stopped
environment:
TZ: Europe/Berlin
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
PGDATA: /var/lib/postgresql/data
volumes:
- /mnt/user/appdata/nextcloud/postgres:/var/lib/postgresql/data
- /mnt/user/appdata/secrets/nextcloud_postgres_password.txt:/run/secrets/postgres_password:ro
networks:
- nextcloud_internal
security_opt:
- no-new-privileges:true
nextcloud-redis:
image: redis:7.4-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99
container_name: nextcloud-redis
restart: unless-stopped
command: redis-server --save 60 1 --loglevel warning
volumes:
- /mnt/user/appdata/nextcloud/redis:/data
networks:
- nextcloud_internal
security_opt:
- no-new-privileges:true
networks:
frontend_net:
external: true
nextcloud_internal:
driver: bridge
internal: true
+1 -1
View File
@@ -1,6 +1,6 @@
services:
ntfy:
image: binwiederhier/ntfy:latest
image: binwiederhier/ntfy@sha256:2b9e12d56a538f4402da51328eeca02696c4b207ab7fbe031c27e51a22ca9b86
container_name: ntfy
restart: unless-stopped
dns:
+7 -7
View File
@@ -1,8 +1,6 @@
version: '3.8'
services:
paperless-gpt:
image: icereed/paperless-gpt:latest
image: icereed/paperless-gpt:v0.24.0@sha256:15bad5d455b98f21bb7b5d6615f56871ff67a8bb379dc0dd7ba411f4633071a6
container_name: paperless-gpt
restart: unless-stopped
security_opt:
@@ -18,19 +16,21 @@ services:
MANUAL_TAG: "paperless-gpt"
AUTO_TAG: "paperless-gpt-auto"
LLM_PROVIDER: "ollama"
LLM_MODEL: "cnshenyang/qwen3-nothink:14b"
LLM_MODEL: "qwen3:8b"
OLLAMA_HOST: "http://192.168.178.103:11434"
OLLAMA_CONTEXT_LENGTH: "4096"
TOKEN_LIMIT: "1500"
LLM_LANGUAGE: "German"
OCR_PROVIDER: "llm"
VISION_LLM_PROVIDER: "ollama"
VISION_LLM_MODEL: "cnshenyang/qwen3-nothink:14b"
VISION_LLM_MODEL: "minicpm-v:latest"
OCR_PROCESS_MODE: "image"
CREATE_NEW_TAGS: "true"
AUTO_GENERATE_TITLE: "true"
AUTO_GENERATE_TAGS: "true"
AUTO_GENERATE_CORRESPONDENTS: "true"
AUTO_GENERATE_DOCUMENT_TYPE: "true"
LOG_LEVEL: "debug"
LOG_LEVEL: "info"
volumes:
- /mnt/user/appdata/paperless-gpt/data:/app/data
- /mnt/user/appdata/paperless-gpt/prompts:/app/prompts
@@ -48,4 +48,4 @@ services:
networks:
frontend_net:
external: true
external: true
View File
+15 -3
View File
@@ -1,7 +1,6 @@
version: "3.9"
services:
paperless:
image: ghcr.io/paperless-ngx/paperless-ngx:2.20.10
image: ghcr.io/paperless-ngx/paperless-ngx:2.20.10@sha256:07a0b4ba01ce377c82a0636e16c0c3d931fde5b7e9304de6601986cc42d9b6e6
container_name: paperless-ngx
restart: unless-stopped
security_opt:
@@ -17,6 +16,19 @@ services:
- PAPERLESS_TIME_ZONE=Europe/Berlin
- PAPERLESS_OCR_LANGUAGE=deu+eng
- PAPERLESS_URL=https://paperless.kaleschke.info
# Barcode / ASN
- PAPERLESS_CONSUMER_ENABLE_BARCODES=1
- PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE=1
- PAPERLESS_CONSUMER_ASN_BARCODE_PREFIX=ASN
# Erkennung robuster für kleine Labels
- PAPERLESS_CONSUMER_BARCODE_DPI=600
- PAPERLESS_CONSUMER_BARCODE_UPSCALE=1.5
# Optional: alle Seiten prüfen
- PAPERLESS_CONSUMER_BARCODE_MAX_PAGES=0
volumes:
- /mnt/user/documents/scans_inbox:/usr/src/paperless/consume
- /mnt/user/appdata/paperless-ngx/data:/usr/src/paperless/data
@@ -33,7 +45,7 @@ services:
- "traefik.http.routers.paperless.tls=true"
- "traefik.http.routers.paperless.tls.certresolver=le"
- "traefik.http.services.paperless.loadbalancer.server.port=8000"
networks:
frontend_net:
external: true
+2 -2
View File
@@ -1,6 +1,6 @@
services:
unbound:
image: shaanmajid/unbound:latest
image: shaanmajid/unbound:1.24.2@sha256:d278b71c592b2555cc802911bb0757a6a24f4a8ad7f5848720296c04876eeb63
container_name: unbound
restart: unless-stopped
volumes:
@@ -13,4 +13,4 @@ services:
networks:
dns_net:
external: true
external: true
View File
+10 -2
View File
@@ -1,6 +1,6 @@
services:
gitea:
image: docker.gitea.com/gitea:1.25.4
image: docker.gitea.com/gitea:1.25.4@sha256:17d18218be2dad1f8ed402a4f906989505c90ab8b66ee9befcecfb5d470133e7
container_name: gitea
restart: unless-stopped
security_opt:
@@ -11,9 +11,17 @@ services:
- GITEA__server__DOMAIN=git.kaleschke.info
- GITEA__server__ROOT_URL=https://git.kaleschke.info/
- GITEA__database__DB_TYPE=sqlite3
- GITEA__webhook__ALLOWED_HOST_LIST=*
- GITEA__service__DISABLE_REGISTRATION=true
- GITEA__service__REGISTER_EMAIL_CONFIRM=true
- GITEA__openid__ENABLE_OPENID_SIGNIN=false
- GITEA__openid__ENABLE_OPENID_SIGNUP=false
- GITEA__migrations__ALLOWED_DOMAINS=github.com
- GITEA__webhook__ALLOWED_HOST_LIST=komodo-core,localhost,127.0.0.1,192.168.178.0/24
volumes:
- /mnt/user/services/gitea/data:/data
dns:
- 1.1.1.1
- 8.8.8.8
ports:
- "222:22"
networks:
+213
View File
@@ -0,0 +1,213 @@
# AI Context
Stand: 2026-05-04
Diese Datei ist fuer KI-Agenten gedacht, die das Homelab-Repo schnell verstehen muessen. Sie ersetzt nicht die Detaildokumente, sondern fasst Zielbild, Betriebsmodell und Risiken zusammen.
## Kurzfassung
Dieses Repository beschreibt ein Unraid-basiertes Homelab namens `Kallilabcore`. Der Betrieb folgt GitOps: Gitea `origin/master` ist die Quelle der Wahrheit, der lokale Clone ist Arbeitskopie, Komodo deployed aus Gitea, Docker Runtime und Host sind Ergebnis, nicht Bearbeitungsort.
Traefik ist der zentrale Web-Einstieg fuer HTTP(S). Admin-/Ops-UIs liegen entweder hinter Authelia/secure headers oder sind als Ausnahme dokumentiert. Secrets liegen ausserhalb des Repos auf dem Host, meistens unter `/mnt/user/appdata/secrets/`.
## Zielbild des Homelabs
- stabile Compose-first Infrastruktur
- keine produktiven Dockerman-/Ad-hoc-Container als Dauerzustand
- Traefik als einziger oeffentlicher Web-Eingang
- GitOps ueber Gitea + Komodo
- klare Trennung von Web-Netz, Backend-Netz und app-internen Netzen
- saubere Backup-/Restore-Faehigkeit ueber Borg, Dumps und dokumentierte Pfade
- keine stillen Host-Hotfixes ohne Repo-/Doku-Abgleich
## Architekturblocke
### Ingress / Netzwerk
- `traefik` nimmt 80/443 entgegen.
- Docker-Labels definieren Service-Routing.
- `traefik/dynamic/*` bleibt nur fuer Middlewares, TLS und Dashboards.
- Neue Service-Routen gehoeren nicht in den File-Provider.
### DNS / Remote
- AdGuard Home beantwortet LAN-DNS und nutzt Unbound als Upstream.
- Tailscale stellt Remote-Zugang bereit und nutzt `network_mode: host`.
### GitOps
- Gitea hostet das Repo unter `git.kaleschke.info`.
- Komodo ist Stack-Manager und Deploy-Consumer.
- Komodo Periphery braucht Docker-Socket und `/mnt/user/services` Mount, um Stacks reproduzierbar zu deployen.
- Neue produktive Komodo-Stacks aus `Micha/homelab-infra` muessen einen aktiven Gitea->Komodo-Webhook auf die aktuelle Stack-ID haben; Ausnahmen wie deaktivierte/pausierte Stacks muessen dokumentiert werden.
- Der `komodo`-Self-Stack ist eine dokumentierte Ausnahme ohne aktiven Gitea-Webhook; Bootstrap/Recovery laeuft ueber `docs/SERVICES_RECOVERY.md`.
### Identity / Security
- Authelia stellt ForwardAuth fuer viele Admin-UIs bereit.
- Authelia nutzt GMX SMTP fuer Identity-/2FA-Benachrichtigungen; Passwort liegt als Host-Secret `authelia_smtp_password.txt`.
- Vaultwarden ist ein separater Passwort-Tresor.
- Komodo ist bewusst nicht pauschal hinter Authelia, weil UI, API, Webhooks und Periphery-WebSocket sonst leicht gebrochen werden koennen.
- Komodo-Compose, Komodo-Secrets und Komodo-Runtime nur gemeinsam mit dem Betreiber aendern; `KOMODO_WEBHOOK_SECRET` ist bewusst getrennt von `KOMODO_SECRET_KEY`. Gitea-Webhooks nicht pauschal vereinheitlichen: einzelne Komodo-Stacks koennen eigene per-Stack-Webhook-Secrets haben.
### Apps
Wichtige Apps sind Paperless, Immich, Mealie, Mail Archiver, Nextcloud, ntfy, Vaultwarden und Gitea. Admin-/Ops-Tools sind u. a. Glance, Komodo, Borg UI, Filebrowser, code-server, Glances, Scrutiny, Speedtest, Monitoring Grafana und Hermes Agent.
### Hermes Agent — Architektur und Ops-Monitor
Hermes laeuft nach Model C (siehe `ops/hermes-agent/README.md`):
- `hermes-gateway` als Docker-Container auf dem Unraid-Host, intern auf `hermes_net:8642`
- Terminal-Befehle werden via SSH auf eine dedizierte Linux-VM ausgefuehrt
- VM-IP: `192.168.178.143`, SSH-User: `hermes`
- Repo-Clone auf der VM: `/srv/hermes-workspace/homelab-infra/`
**Fuer KI-Agenten wichtig:** Das Hermes-Terminal laeuft auf der VM, nicht auf dem Unraid-Host.
`/mnt/user/...`-Pfade sind von der VM aus nicht direkt erreichbar.
Docker-CLI ist auf der VM nicht installiert — fuer Homelab-Checks wird `check_health.py` verwendet.
**Ops-Monitor (homelab-ops-monitor):**
- Skill: `ops/hermes-agent/skills/homelab-ops-monitor.md`
- Script: `ops/hermes-agent/scripts/check_health.py` — prueft Services via HTTP, keine externen Deps
- Wissensbasis: `ops/hermes-agent/services.json` — maschinenlesbare Ableitung aus `docs/SERVICE_CATALOG.md`
- Check-Strategie: HTTP GET fuer URL-basierte Services, interne Services (DBs, Redis) als `"internal"` markiert
- ntfy-Topic fuer Alerts: `homelab-alerts` auf `https://ntfy.kaleschke.info`
Nach Aenderungen an `services.json` oder `check_health.py`: `git pull` auf der VM ausfuehren.
- Mail Archiver ist ein Hybrid-Dienst mit `frontend_net` fuer IMAP/Traefik und `backend_net` fuer PostgreSQL; die Web-UI liegt hinter Authelia und behaelt zusaetzlich App-eigene Auth.
### Monitoring / Metriken
- Zielzustand ist ein zentraler Stack `monitoring/` unter `https://monitoring.kaleschke.info`.
- `monitoring-grafana` ist die zentrale UI fuer Prometheus, Loki und InfluxDB 3 Core.
- `monitoring-prometheus` sammelt Infrastruktur-Metriken; `monitoring-loki` + `monitoring-promtail` sammeln Docker-Logs.
- `monitoring-influxdb3-core` ist nicht public und nicht im `frontend_net`.
- Home Assistant schreibt ueber LAN-only Port 8181 nach InfluxDB, gebunden ueber `INFLUXDB_BIND_IP`.
- Ein `401 Unauthorized` von InfluxDB ohne Token ist beim Reachability-Test ein Erfolgssignal.
- Die frueheren Altstaende `ops/loki` und `ops/grafana-influxdb` wurden aus dem aktiven Repo entfernt; fuer Monitoring immer `monitoring/` verwenden, Rollback nur ueber Git-Historie.
- Uptime Kuma ist entfernt; HTTP-Verfuegbarkeit laeuft ueber Blackbox Exporter, Prometheus-Alerts und `Homelab / Availability` in Monitoring Grafana.
## Deployment-Logik
Normalfall:
1. lokaler Clone synchronisieren
2. betroffene Dokumente und Compose-Datei lesen
3. minimal aendern
4. lokal validieren
5. committen und pushen
6. Komodo-Webhook / Deploy beobachten
7. Runtime testen
8. Doku aktualisieren
Wichtig: Komodo-Web-Editor ist nicht der Bearbeitungsort. Wenn Komodo und Git voneinander abweichen, zuerst Git und Komodo Workspace pruefen, nicht live herumprobieren.
Beim Anlegen neuer produktiver Stacks ist der Gitea->Komodo-Webhook Pflicht. Nach dem Anlegen muss ein Test-Push oder Test-Delivery zeigen, dass Gitea die aktuelle Komodo-Stack-ID erreicht.
## Netzwerkmodell
| Netzwerk | Bedeutung |
|---|---|
| `frontend_net` | Web-/Proxy-Netz fuer Traefik-geroutete Dienste und Dienste mit Internetbedarf |
| `backend_net` | internes Netz fuer shared PostgreSQL, Redis und Backends |
| `dns_net` | AdGuard + Unbound |
| app-interne Netze | Isolation von App + DB/Cache, z. B. Immich, Mealie, Nextcloud, Monitoring |
| `host` | nur dokumentierte Sonderfaelle wie Tailscale/Plex |
Regeln:
- Datenbanken nie ins `frontend_net`.
- Admin-UIs nur mit Traefik + Middleware oder dokumentierter Ausnahme.
- Direkte Host-Ports sind Ausnahme, nicht Default.
- Runtime-Netznamen koennen Compose-Projektpraefixe bekommen, z. B. `monitoring_monitoring_influx_lan`.
## Security-Modell
- Secrets nie ins Git.
- Werte niemals zitieren, auch nicht aus `.env`, Stack ENV, Logs oder Screenshots.
- Secret-Dateien bevorzugt unter `/mnt/user/appdata/secrets/`.
- `_FILE`-Varianten bevorzugen, falls Image sie unterstuetzt.
- Wenn `_FILE` nicht unterstuetzt wird, Komodo Stack Environment Variables verwenden.
- Docker-Socket, `privileged: true`, Host-Netz und breite Mounts sind nur mit dokumentierter Begruendung akzeptabel.
Bekannte Ausnahmen:
- Traefik: 80/443
- Gitea: SSH 222
- AdGuard: DNS 53 direkt; Admin 8082 ist bewusst ohne Traefik/2FA, aber auf Tailscale-IP `100.80.98.33` begrenzt
- Tailscale: Host-Netz, `NET_ADMIN`, `NET_RAW`, `/dev/net/tun`
- Scrutiny: privileged
- Komodo: Docker-Socket, native Auth
- InfluxDB: LAN-only 8181 fuer Home Assistant Writer
- `monitoring-influxdb3-core`: `user: "0"` als dokumentierte Host-Appdata-Permissions-Ausnahme
- Traefik dynamic config: manueller Host-Sync
## Backup- und Restore-Modell
Borg sichert kritische Appdaten, Secrets, Traefik-State und Dump-Artefakte. Datenbank-Restore soll bevorzugt ueber Dumps laufen, nicht ueber rohe Live-DB-Verzeichnisse.
Wichtige Pfade:
- `/mnt/user/appdata`
- `/mnt/user/appdata/secrets`
- `/mnt/user/services`
- `/mnt/user/documents`
- `/mnt/user/photos`
- `/mnt/user/backups/borg/dumps/latest`
Dump-Skript:
- `ops/borg-ui/scripts/pre-backup-dumps.sh`
- soll auf dem Unraid Host laufen
- soll nicht als Borg-UI Inline-Hook behandelt werden, solange die Architektur nicht bewusst geaendert wird
Disaster Recovery folgt einer Bootstrap-Reihenfolge:
1. Traefik, AdGuard, Tailscale
2. PostgreSQL, Authelia, Redis, Gitea
3. Komodo
4. kritische Apps
5. restliche Apps/Ops inklusive Hermes Agent
## Typische Arbeitsweise im Repo
- Fuer Fragen zuerst `HOMELAB_ARCHITECTURE_MASTER_V2.md` lesen.
- Fuer operative Aenderungen `docs/WORKFLOW.md` lesen.
- Fuer Service-Details `docs/SERVICE_CATALOG.md` und die Compose-Datei lesen.
- Fuer Drift `docs/GITOPS_DRIFT_RUNBOOK.md` nutzen.
- Fuer Rollback `docs/ROLLBACK.md` nutzen.
- Fuer Restore `docs/DISASTER_RECOVERY.md` und `docs/RESTORE_MATRIX.md` nutzen.
KI-Agenten sollen konservativ arbeiten: keine indirekten Live-Aenderungen, keine Deployments, keine Commits, keine Host-Schreibbefehle, wenn der Benutzer nur Analyse oder Doku verlangt.
## Bekannte Risiken und Altlasten
- Traefik dynamic config muss manuell auf den Host synchronisiert werden; Komodo deployed diese Dateien nicht automatisch.
- `backend_net` und app-interne Netze muessen bei Runtime-Problemen live geprueft werden, weil Compose-Projektpraefixe Netznamen veraendern koennen.
- Authelia `configuration.yml` ist Repo-Baseline fuer nicht geheime Einstellungen, wird aber nicht automatisch von Komodo auf den Host kopiert; die produktive Host-Datei kann OIDC-/Secret-Konfiguration enthalten. Bei Auth-Aenderungen Repo-Baseline, Host-Config und Compose-Middlewares pruefen und nicht blind ueberschreiben.
- Authelia nutzt PostgreSQL, aber bewusst kein Redis-Session-Backend; Redis ist kein Authelia-Bootstrap-Blocker.
- Authelia-Notifier ist SMTP; bei Auth-Aenderungen Host-Config backupen, `authelia validate-config` ausfuehren und erst danach neu starten.
- `paperless-ngx` nutzt fuer DB/Redis bewusst Stack ENV statt `_FILE`.
- `glance-docker-socket-proxy`, `glances` und `komodo-periphery` nutzen Docker-/Socket-Zugriff; Zugriff bewusst behandeln.
- `borg-ui` und `filebrowser` haben breite Mounts; bei Hardening nicht ad hoc, sondern gezielt vorgehen.
- `scrutiny` ist privilegiert und hat Device-Mounts.
- `Plex-Media-Server` ist im Architekturziel als Host-Sonderfall dokumentiert, aber nicht als Repo-Compose-Stack enthalten.
- Echte `stack.env`- und `.env`-Dateien gehoeren nicht ins Repo; fuer Hermes liegt nur `ops/hermes-agent/stack.env.example` im Git.
- Einige Images nutzen mutable Tag plus Digest. Das friert den aktuellen Digest ein, ist aber kein automatisches Upgrade-Modell.
- Stateful Images werden bevorzugt als Minor-/Patch-Tag plus Digest gepinnt; Redis-Caches bleiben bewusst ungedigestet.
## Arbeitsregel bei Unsicherheit
Nicht raten. Erst diese Reihenfolge:
1. Repo-Doku lesen
2. Compose-Datei lesen
3. Git-Stand pruefen
4. Komodo Workspace pruefen
5. Docker Runtime pruefen
6. Host-Listener / echten Request pruefen
7. genau eine abweichende Ebene benennen
Wenn zwei Reparaturversuche scheitern: keine weiteren Schreibbefehle, Pflichtmatrix aus `docs/GITOPS_DRIFT_RUNBOOK.md` ausfuellen.
+80
View File
@@ -0,0 +1,80 @@
# AI Handoff 2026-05-06
Kompakte Quelle fuer einen neuen Chat. Ziel: nicht das ganze Repo neu auditieren, sondern mit dem bekannten Stand weiterarbeiten.
## Aktueller Stand
- Repo: `G:\Gitea_Clone\homelab-infra`
- Remote: `https://git.kaleschke.info/Micha/homelab-infra.git`
- Branch: `master`
- Letzter bekannter Commit: `e0e12f1 Document stale Komodo webhook cleanup`
- Unraid-Host: `ssh root@192.168.178.58`
- Push-Befehl, der zuverlaessig funktioniert: `git -C "G:\Gitea_Clone\homelab-infra" push origin master`
- Nicht anfassen ohne explizite Freigabe: untracked `Homelab_Audit_2026-05-05.pdf` und untracked `ops/hermes-agent/services.yaml`.
## Audit-Arbeit Erledigt
- K1: ungueltige Digests fuer Authelia, ntfy und borg-ui korrigiert und smoke-getestet.
- K2: Authelia nutzt bewusst kein Redis; Doku entsprechend korrigiert.
- K3/M1/M2 alt: Authelia Repo-Baseline geklaert, Homepage/Komodo ACL-Drift bereinigt.
- M3a/M3b: Digest-Pinning fuer stateful/Tier-1 und weitere versionierte Apps umgesetzt; Redis-Caches bewusst ohne Digest, Nextcloud bewusst offen.
- M5/N5: `.gitignore` eingefuehrt, Hermes `stack.env` zu `stack.env.example`.
- M6/M7/M8: Hermes-Domain, Grafana/Influx `user: "0"` und Tailscale-Capabilities dokumentiert.
- M9: Backup Scope / Restore Matrix erledigt.
- N-Aufraeumen: alte Compose-`version:` Felder, leere Env-Beispiele und `.keep`-Platzhalter bereinigt.
- Mail Archiver: `mail.kaleschke.info` liegt hinter `authelia@file,secure-headers@file`; Smoke-Test war 302 zu Authelia.
- Hermes: Restore/DR-Doku ergaenzt.
- Authelia SMTP: GMX SMTP eingerichtet, validiert, deployed und smoke-getestet.
- M10: `KOMODO_WEBHOOK_SECRET` ist von `KOMODO_SECRET_KEY` getrennt.
## Wichtige Runtime-Details
### Authelia SMTP
- Adresse: `submission://mail.gmx.net:587`
- Mailkonto: `michideheld@gmx.de`
- SMTP-Passwort liegt nur auf dem Host: `/mnt/user/appdata/secrets/authelia_smtp_password.txt`
- Host-Config wurde vor Umstellung gesichert: `/mnt/user/appdata/authelia/config/configuration.yml.bak-20260506-smtp`
- Authelia-Compose nutzt explizite DNS-Server, weil der SMTP-Startup-Check externe Namen aufloesen muss.
- Nach Deploy war `authelia` healthy; `auth.kaleschke.info` antwortete 200, geschuetzte Routen 302 zu Authelia.
### Komodo / M10
- Komodo-Runtime nur gemeinsam mit dem Betreiber aendern.
- `KOMODO_SECRET_KEY` wurde nicht geaendert.
- `KOMODO_WEBHOOK_SECRET` wurde geaendert und ist jetzt eigener 64-Zeichen-Wert.
- Neuer Wert liegt nur auf dem Host in `/mnt/user/services/stacks/komodo/.env`.
- Komodo Compose auf Host: `/mnt/user/services/stacks/komodo/compose.yaml`.
- Backups vom M10-Sprint:
- `/mnt/user/appdata/komodo/_m10_backup_20260506-184838`
- `/mnt/user/services/gitea/data/gitea/_m10_backup_20260506-184838/gitea.db.bak`
- `komodo-core` wurde gezielt recreated.
- `komodo-mongo` wurde nicht neu gestartet.
- `komodo-periphery` lief durch und meldete sich wieder am Core-Websocket an.
- Gitea-Komodo-Webhooks: 29 aktive Hooks, 29 zuletzt erfolgreich, 0 aktiv fehlgeschlagen.
- Ein stale Gitea-Webhook auf eine nicht mehr existierende Komodo-Stack-ID wurde deaktiviert, nicht geloescht.
- Eine Warnung `request branch does not match expected` ist ein Branch-Filter-Skip, kein Secret-/Auth-Fehler.
- Fuer neue Gitea-Webhooks im Standardfall den globalen `KOMODO_WEBHOOK_SECRET` aus der Komodo-Host-`.env` nutzen, ausser Komodo zeigt fuer den Stack explizit ein eigenes per-Stack-Secret.
## Sicherheitsregeln Fuer Weitere Arbeit
- Keine Secret-Werte im Chat oder Git ausgeben.
- Bei Host-Pruefungen nur SET/MISSING, Laengen und Pfade zeigen.
- Komodo-Compose, Komodo-Secrets und Komodo-Runtime nur bewusst und kleinschrittig aendern.
- Bei jedem Deploy pro Stack smoke-testen; nicht mehrere kritische Stacks parallel veraendern.
- Untracked Dateien nicht automatisch committen.
- Bei Authelia-Aenderungen: Host-Config sichern, `authelia validate-config` ausfuehren, dann erst neu starten.
- Bei Komodo-Aenderungen: Gitea-Webhooks und Komodo-Core-Secret-Seite zusammen betrachten.
## Naechste Sinnvolle Next-Level-Themen
- Grafana/Influx rootless betreiben statt `user: "0"`; eigener Sprint wegen Volume-Rechten.
- Restore-Test fuer Vaultwarden und Paperless dokumentiert durchfuehren.
- Komodo Periphery von Legacy-Passkey auf Public-Key-Modell haerten.
- Monitoring/Alerting reifer machen: externe Alarme, Restore-Test-Reminder, Backup-Erfolg sichtbar.
- Gitea/Komodo Webhook-Landschaft weiter aufraeumen und per-Stack-Secret-Strategie dokumentieren.
- DR-Test fuer `backend_net`/externe Docker-Netze explizit aufnehmen.
## Startprompt Fuer Neuen Chat
Bitte zuerst `docs/AI_HANDOFF_2026-05-06.md` lesen und als aktuelle Arbeitsquelle verwenden. Nicht das ganze Repo neu auditieren, ausser ich fordere es an. Beachte besonders: Komodo nur gemeinsam und kleinschrittig aendern, keine Secret-Werte ausgeben, untracked PDF und `ops/hermes-agent/services.yaml` nicht anfassen. Wir starten jetzt mit Next-Level-Hardening.
+31
View File
@@ -0,0 +1,31 @@
# Alerting Map
Stand: 2026-05-23
Ziel: Alle problemrelevanten Homelab-Meldungen landen auf einem Handy-Topic.
## ntfy Topics
| Topic | Zweck |
|---|---|
| `homelab-alerts` | Alles, was Aufmerksamkeit braucht: Prometheus, Docker-Events, Posture, Zertifikate/Token, Compose-Drift, Borg-Pre-Hook-Fehler und Restore-Fehler |
| `homelab-info` | Optionale Erfolgsmeldungen, z. B. erfolgreiche Restore-Testlaeufe |
## Sender
| Sender | Pfad | Problem-Topic | Hinweis |
|---|---|---|---|
| Prometheus / Alertmanager | `monitoring/alertmanager/alertmanager.yml`, `monitoring/alertmanager-ntfy-bridge/bridge.py` | `homelab-alerts` | Zentrale Monitoring-Alerts via Bridge |
| Posture Check | `services/posture-check/posture-check.sh` | `homelab-alerts` | Warning und Critical gehen auf dasselbe Handy-Topic |
| Cert / Token Check | `services/posture-check/cert-token-check.sh` | `homelab-alerts` | Prueft produktive HTTPS-Domains und Cloudflare Token |
| Compose Runtime Drift | `services/posture-check/compose-runtime-drift.sh` | `homelab-alerts` | Meldet Abweichungen zwischen Repo-Compose und Runtime-Image |
| Docker Critical Events | `services/posture-check/docker-critical-events.sh` | `homelab-alerts` | Meldet Docker `die`, `oom` und `kill` Events |
| Borg Pre-Hook | `ops/borg-ui/scripts/pre-borg.sh` | `homelab-alerts` | Meldet Fehler vor Borg, z. B. Posture-, Dump- oder Restore-Freshness-Fehler |
| Restore Jobs | `ops/restore-tests/run-restore-job-with-ntfy.sh` | `homelab-alerts` | Erfolg geht an `homelab-info`, Fehler immer an `homelab-alerts` |
## Konvention
- `NTFY_BASE_URL` zeigt standardmaessig auf `https://ntfy.kaleschke.info`.
- Neue Problem-Alerts sollen `homelab-alerts` nutzen.
- Erfolgsmeldungen sind optional und sollen nicht in `homelab-alerts` landen, ausser sie sind bewusst als Lebenszeichen gewuenscht.
- Blackbox-Endpoint-Alerts sollen bekannte WAN-/Provider-Sammelausfaelle zusammenfassen, damit kurze DSL-Reconnects keine ntfy-Flut pro Domain erzeugen.
+369
View File
@@ -0,0 +1,369 @@
# Homelab Audit - 2026-05-23
Stand: 2026-05-23, repo-basiert. Erstellt nach `docs/WORKFLOW.md` und `docs/GITOPS_DRIFT_RUNBOOK.md`. Quellebasis: `origin/master` plus lokaler Clone, ohne Schreibbefehle, ohne Deploy.
Dieser Audit ist eine punktuelle Sollzustands-Bewertung, kein Live-Status. Die Live-Verifikations-Schritte stehen am Ende in Abschnitt 9; alle dortigen Outputs ersetzen Vermutungen durch Messwerte.
## 0. Executive Summary
Ampel-Bewertung pro Bereich:
| Bereich | Ampel | Kernaussage |
|---|---|---|
| GitOps-Konsistenz (lokal/Gitea) | 🟡 | Lokaler Clone ist **1 Commit voraus** auf `master` (`cd650b1`, Haertungs-Commit). Bis zum Push existiert dieser Stand nur lokal, nicht in Gitea — bei einem Clone-Verlust ist er weg. |
| GitOps-Konsistenz (Working Tree) | 🟢* | Keine echten Inhaltsaenderungen offen. Die 47 "modified files" aus `git status` im Linux-Mount sind voraussichtlich CRLF/LF-Mount-Artefakte (durch `git diff -w --stat` auf Stichprobe bestaetigt leer). Bitte am Windows-Host gegenpruefen. |
| Hardening-Sprint (Mai 2026) | 🟢 | Alle vier Post-Restore-Sprint-Items sind im Repo umgesetzt (Filebrowser-Mounts, Authelia Argon2id, Gitea Webhook-Allowlist, Backup-Dump-Konsistenz). |
| Backup/Restore-Readiness | 🟢 | `pre-backup-dumps.sh` deckt alle relevanten SQLite/PostgreSQL/Mongo-Quellen ab. Borg-UI-Scope umfasst `/mnt/user/services`, `homelab-infra`, `stacks`, `posture-check`. Live-Frische ist offen (Abschnitt 9). |
| Monitoring-Migration | 🟡 | `monitoring/` Stack im Repo komplett, aber Live-Deploy laut `docs/NEXT_SPRINT_TODO_2026-05-16.md` noch ausstehend. Alte Stacks `ops/grafana-influxdb` und `ops/loki` sollen erst nach Live-Smoke-Test gestoppt werden. |
| Doku-Drift Repo vs. Master-Doku | 🟠 | `apps/jellyfin/`, `host-services/plex/` und einige andere existieren produktiv als Compose-Stacks, sind aber in `HOMELAB_ARCHITECTURE_MASTER_V2.md`, `docs/SERVICE_CATALOG.md` und `docs/REPO_MAP.md` **nicht aufgefuehrt**. Authelia-ACL kennt `jellyfin.kaleschke.info` als bypass, im Masterdoku-Hostkatalog steht es nicht. |
| Repo-Hygiene | 🟡 | 8 leere Verzeichnisse im Working Tree (siehe 4.3). `.serena/` ist untracked und nicht in `.gitignore`. Drei `ops/windows-reinstall/*.ps1` sind untracked. |
| Bekannte Ausnahmen | 🟢 | Alle Ausnahmen aus `HOMELAB_ARCHITECTURE_MASTER_V2.md` Abschnitt 10 sind weiterhin dokumentiert und durch den Policy-Check abgedeckt (0 Critical, 4 Warnings, 9 Info alles dokumentierte Ausnahmen). |
**Kernfazit:** Das Homelab ist sehr nah an der "Endstufe". Es gibt keine kritischen Befunde. Die einzige Pflichtaktion vor dem naechsten geplanten Schritt ist der **Push des lokalen Commits `cd650b1` nach Gitea**, damit `origin/master` wieder die Quelle der Wahrheit ist. Danach sind nur noch zwei priorisierte Pakete offen: Monitoring-Stack live finalisieren und Doku auf den Stand der neuen Stacks (Jellyfin/Plex/...) nachziehen.
---
## 1. Methodik und Quellen
Diese Audit-Quellen wurden gelesen (repo-seitig):
- `HOMELAB_ARCHITECTURE_MASTER_V2.md`
- `docs/WORKFLOW.md`
- `docs/REPO_MAP.md`
- `docs/SERVICE_CATALOG.md`
- `docs/RESTORE_MATRIX.md`
- `docs/GITOPS_DRIFT_RUNBOOK.md`
- `docs/NEXT_SPRINT_TODO_2026-05-16.md`
- `ops/borg-ui/scripts/pre-backup-dumps.sh`
- `ops/borg-ui/docker-compose.yml`
- `ops/borg-ui/all-important-sources.txt`
- `ops/filebrowser/docker-compose.yml`
- `security/authelia/configuration.yml`
- `core/gitea/docker-compose.yml`
- `apps/jellyfin/docker-compose.yml`
- `host-services/plex/docker-compose.yml`
- `ops/policy-checks/last-report.md`
Schreibbefehle: keine. Deploys: keine. Containerlaufzeit, Komodo-Webhook-Status, Borg-Lauf-Frische und Host-Listener wurden bewusst nicht angetastet — dafuer steht der Live-Daten-Block in Abschnitt 9.
---
## 2. Schicht A — GitOps und Konsistenz
### 2.1 Lokaler Clone vs. `origin/master`
```
## master...origin/master [ahead 1]
HEAD = cd650b19ac057a1b74ac63503e5dba50eaf5b8ea
origin/master = af231dd4e835b19005cc0842509199d480af00d9
```
- **Befund:** Lokaler Clone ist 1 Commit voraus.
- **Commit:** `cd650b1 Close Gitea signup, dedup posture-check alerts, extend Borg scope` (Sat May 23 11:01:24 2026 +0200).
- **Inhalt** (laut Commit-Message und betroffenen Dateien):
- Gitea: `DISABLE_REGISTRATION=true`, `ENABLE_OPENID_SIGNIN=false`, `ENABLE_OPENID_SIGNUP=false`
- Repo-Pflicht-Doku ergaenzt: Komodo-Stack-Webhook-Pflicht in `CLAUDE.md`, `AI_CONTEXT.md`, `WORKFLOW.md`
- `posture-check.sh`: Disk1-NTFS-Funktion ausgelagert, Inode-Check auf NTFS uebersprungen, ntfy-Dedup via Fingerprint-State + `ALERT_REPEAT_SECONDS`
- `docker-critical-events.sh`: JSON-Parsing, `die exit=0` gefiltert, strukturierte ntfy-Message
- `borg-ui`: `/mnt/user/services` als `/local/services:ro` gemountet, `all-important-sources.txt` ergaenzt
- Unraid User Scripts dokumentiert (daily report)
- `MIGRATION_LOG.md`, `RESTORE_MATRIX.md`, `DISASTER_RECOVERY.md` aktualisiert
- **Risiko:** Bei Verlust des Windows-Clones (Reinstall, Diskcrash) ist dieser Stand verloren, weil er nicht in Gitea liegt. Komodo deployt ausserdem aus Gitea und kennt diese Aenderungen noch nicht.
- **Empfohlene Aktion (Pflicht vor weiterer Arbeit):** In GitHub Desktop `Push origin` ausfuehren. Danach Komodo-Reaktion fuer die betroffenen Stacks (`gitea`, `borg-ui`) pruefen und Smoke-Tests laufen lassen.
### 2.2 Working-Tree-Status
```
$ git status --short
M CLAUDE.md
M HOMELAB_ARCHITECTURE_MASTER_V2.md
M apps/homepage/docker-compose.yml
...
(47 Dateien als modified gemeldet, plus 4 Untracked)
```
- **Bewertung:** Die 47 "modified files" sind mit hoher Wahrscheinlichkeit **Mount-Artefakte** durch CRLF/LF zwischen Windows-Clone und Linux-Mount. Stichprobe `git diff -w --stat CLAUDE.md HOMELAB_ARCHITECTURE_MASTER_V2.md` lieferte leer — d. h. keine inhaltlichen Diffs.
- **Aktion:** Bitte am Windows-Host in GitHub Desktop `git status --short` ausfuehren. Wenn dort der Tree leer ist (nur die 4 Untracked), gibt es keinen echten Working-Tree-Drift. Wenn dort echte Diffs erscheinen, hier bitte zurueckmelden — dann ist das ein eigener Befund.
- **Optional (nicht Pflicht):** `.gitattributes` mit `* text=auto eol=lf` haerten, damit dieser Mount-Effekt fuer KI-Audits aus dem Weg geht. Das ist ein eigener kleiner Commit, kein Audit-Output.
### 2.3 Untracked Files
```
?? .serena/
?? ops/windows-reinstall/backup-delta-after-2026-05-07.ps1
?? ops/windows-reinstall/cleanup-dualboot-bcd.ps1
?? ops/windows-reinstall/repair-disk0-boot-to-new-windows.ps1
```
- `.serena/` ist das Working-Directory des Serena Code-Search-Tools. Hat eigene `.gitignore` intern, aber das `.serena/`-Verzeichnis selbst ist nicht in der Repo-`.gitignore`.
- **Aktion (klein):** `.serena/` in `.gitignore` aufnehmen, damit es nicht versehentlich committet wird.
- Die drei PowerShell-Scripts unter `ops/windows-reinstall/` sind Windows-Reinstall-Helfer. Entscheidung offen: ins Repo aufnehmen (mit Kontextkommentar warum sie dort liegen) oder lokal halten und in `.gitignore` aufnehmen. Vorschlag: aufnehmen, weil `ops/` der dokumentierte Ort fuer Ops-Skripte ist.
### 2.4 Letzte Commit-Historie (Top 10)
```
cd650b1 Close Gitea signup, dedup posture-check alerts, extend Borg scope [LOKAL, NICHT GEPUSHT]
af231dd Fix zero-count noise pattern handling
428223d Mark posture report scripts executable
b6d3ed4 Tune homelab availability alerts
9e7bebb Add daily operations report with hardened log-noise filtering
b7cbbe5 Fix Jellyfin external DNS
71ac18b Fix Jellyfin native auth routing
90f270b Fix Jellyfin config permissions
e28f8da Add Jellyfin media server stack
edfec5b Add Plex media server stack
```
- Die letzten Tage waren sichtbar: Jellyfin/Plex hinzugefuegt, Availability-Alerts feinjustiert, Posture-Check-Skripte produktiv gemacht, dann der grosse Haertungs-Commit gestern (2026-05-23 11:01).
### 2.5 Compose-Inventar vs. Doku
Repo hat folgende Compose-Stacks, die in den Doku-Quellen (`HOMELAB_ARCHITECTURE_MASTER_V2.md`, `docs/SERVICE_CATALOG.md`, `docs/REPO_MAP.md`) **nicht oder nur teilweise** aufgefuehrt sind:
| Stack | Status im Repo | Status in Master-Doku |
|---|---|---|
| `apps/jellyfin/docker-compose.yml` | produktiv vorhanden, gepinnt `jellyfin:10.11.8@sha256:...`, Traefik `jellyfin.kaleschke.info`, `secure-headers@file`, native Auth, `/mnt/user/media:ro` + `/mnt/user/photos:ro` | **fehlt** in 7.4 Apps; Authelia-ACL kennt aber bereits `jellyfin.kaleschke.info` als bypass — Doku hinkt hinterher |
| `host-services/plex/docker-compose.yml` | produktiv vorhanden, gepinnt `plexinc/pms-docker:1.43.1.10611-1e34174b1@sha256:...`, `network_mode: host`, `/mnt/user/media:ro` + `/mnt/user/photos:ro` | Master-Doku sagt explizit "Plex-Media-Server ist historischer Host-Sonderfall, nicht als Repo-Compose-Stack enthalten" — **das stimmt nicht mehr**, Plex ist jetzt ein Repo-Compose-Stack |
| `host-services/docker/` | leeres Verzeichnis | nicht erwaehnt |
| `infra/dns/` | leeres Verzeichnis | nicht erwaehnt |
| `ops/Semaphore/` | Skripten/Playbooks aber kein Compose | nicht erwaehnt |
| `ops/backrest/` | leeres Verzeichnis (Stack laut Master-Doku am 2026-05-15 entfernt) | korrekt als entfernt dokumentiert; Verzeichnis sollte leer bleiben oder weg |
| `apps/firefly/`, `apps/firefly-fints/` | leere Verzeichnisse | nicht erwaehnt |
| `apps/stirling-pdf/` | leeres Verzeichnis (durch `bentopdf` abgeloest) | korrekt als abgeloest dokumentiert |
- **Aktion (Doku-Synchronisierung):** `HOMELAB_ARCHITECTURE_MASTER_V2.md` Abschnitt 7 (Container-Zielbild), `docs/SERVICE_CATALOG.md` und `docs/REPO_MAP.md` um Jellyfin und Plex erweitern. Plex-Doku im Master umschreiben: nicht mehr "historisch ausserhalb Repo", sondern "Compose-Stack mit `network_mode: host` als VPN-Discovery-Ausnahme".
- **Aktion (Repo-Hygiene):** Die leeren Verzeichnisse `apps/firefly`, `apps/firefly-fints`, `apps/stirling-pdf`, `host-services/docker`, `infra/dns`, `ops/backrest`, `ops/grafana-influxdb/scripts`, `ops/Semaphore/playbooks`, `ops/Semaphore/Scripts` aufraeumen — Master-Doku sagt: "Leere `.keep`-Platzhalter wurden entfernt; neue Verzeichnisse sollen erst mit konkretem Inhalt ins Repo." Diese Verzeichnisse verletzen diese Regel passiv.
### 2.6 Image-Pinning
Lt. `docs/NEXT_SPRINT_TODO_2026-05-16.md` sind diese Stacks noch nicht voll versioniert gepinnt:
- `ddns-updater``latest...@sha256`
- `glances``latest-full@sha256`
- `scrutiny``latest-omnibus@sha256`
Das ist bewusst dokumentiert und kein Audit-Befund.
---
## 3. Schicht B — Hardening-Sprint 2026-05 (Sitrep)
Dies war der Sprint, der nach dem 2026-05 Restore explizit gesetzt wurde. Stand im Repo:
| Sprint-Item | Stand 2026-05-16 (Plan) | Stand 2026-05-23 (Repo) | Beleg |
|---|---|---|---|
| **(1) Backup-Konsistenz** — `dump_sqlite_container` fuer Gitea/Vaultwarden/Uptime-Kuma/Speedtest/Filebrowser + `pg_dump` Nextcloud | offen | ✅ erledigt | `ops/borg-ui/scripts/pre-backup-dumps.sh` Z. 97139 (`dump_sqlite_container`), Z. 253258 (Nextcloud `pg_dump`), Z. 261264 (alle SQLite-Container mit Host-Fallback), Z. 267 (Filebrowser BoltDB). Borg-Scope erweitert um `/mnt/user/services` (Borg-UI Compose Z. 26 + `all-important-sources.txt` Z. 2325). |
| **(2) Filebrowser entschaerfen** — `/mnt/user/appdata:/srv/appdata` weg, gezielte RW-Subpfade | offen | ✅ erledigt | `ops/filebrowser/docker-compose.yml` Z. 1116. Keine Appdata-Mounts mehr. Nur noch `/mnt/user/documents`, `/mnt/user/photos`, `/mnt/user/projekte` als Datenmounts plus eigener `/database` und `/config`. |
| **(3) Authelia Argon2id haerten** — iterations 3, memory 65536, parallelism 4 | offen | ✅ erledigt | `security/authelia/configuration.yml` Z. 1725. Exakt die geplanten Parameter sind aktiv. |
| **(4) Gitea Webhook-Allowlist** — `ALLOWED_HOST_LIST=*` einschraenken | offen | ✅ erledigt | `core/gitea/docker-compose.yml` Z. 18: `GITEA__webhook__ALLOWED_HOST_LIST=komodo-core,localhost,127.0.0.1,192.168.178.0/24`. Zusatz aus heutigem Commit: Public Registration und OpenID-Signup/Signin sind deaktiviert. |
Alle vier Items sind **im Repo abgeschlossen**. Live-Wirksamkeit haengt am Komodo-Deploy aus Gitea — und genau da haengt aktuell der ungepushte Commit `cd650b1` davor (siehe 2.1). Solange er nicht in Gitea ist, ist insbesondere die Gitea-Signup-Schliessung im Live-Stand nicht garantiert.
**Bewusst nicht angefasste Liste (Operator-Entscheidung 2026-05-16) ist weiterhin gueltig:**
- Hermes — bleibt VM-seitig offen, NAS-Stack bewusst nicht starten
- Disk1 NTFS — Phase-2-Migration nach Plan
- Komodo native Auth ohne ForwardAuth
- Grafana/Influxdb3-core `user: "0"`
- Image-Pinning-Vereinheitlichung (`:latest@sha256:`) fuer ddns-updater/glances/scrutiny
---
## 4. Schicht C — "Endstufe?"-Bewertung
### 4.1 Backup/Restore-Readiness
- **Dump-Coverage:** `pre-backup-dumps.sh` deckt 14 Quellen ab: PostgreSQL-Globals + 3 Shared-DBs + 3 dedizierte Postgres (mealie, immich, nextcloud) + 4 SQLite (gitea, vaultwarden, uptime-kuma, speedtest-tracker) + Filebrowser-BoltDB + Borg-UI + Grafana + Komodo-Mongo. Deckt 1:1 die Restore-Matrix-Eintraege ab.
- **Borg-Scope:** `all-important-sources.txt` enthaelt 27 Eintraege inkl. neuer `services/homelab-infra`, `services/stacks`, `services/posture-check` und `secrets`.
- **Restore-Validierungen:** Laut `docs/RESTORE_MATRIX.md` sind am 2026-05-07 Mini-Restores fuer `gitea`, `vaultwarden` und `paperless` validiert worden — dokumentierter Stand.
- **Live offen:** Wann lief der letzte Borg-Lauf? Sind alle Dumps unter `/mnt/user/backups/borg/dumps/latest` frischer als 24h? Siehe Live-Checkliste 9.4.
### 4.2 Monitoring-Migration
- Repo-Zielzustand `monitoring/docker-compose.yml` (337 Zeilen Compose) existiert mit Prometheus, Alertmanager, ntfy-Bridge, Blackbox-Exporter, Loki, Promtail, Grafana, node-exporter, cAdvisor, InfluxDB3 Core.
- Provisioning unter `monitoring/grafana/provisioning/` und `monitoring/prometheus/`, `monitoring/loki/`, `monitoring/promtail/`, `monitoring/alertmanager/`, `monitoring/blackbox/` vollstaendig vorhanden.
- Alte Stacks `ops/grafana-influxdb/` und `ops/loki/` bewusst noch im Repo (dokumentierter Altstand, Rollback-Referenz).
- **Live offen:** Ist `monitoring` schon als Komodo-Stack deployed? Laufen die Container? Sind die Secret-Dateien `monitoring_grafana_admin_password.txt`, `monitoring_grafana_influxdb_token.txt`, `influxdb3_admin_token.json` auf dem Host? Siehe Live-Checkliste 9.5.
### 4.3 Repo-Hygiene
| Befund | Schwere | Aktion |
|---|---|---|
| 8 leere Verzeichnisse (`apps/firefly`, `apps/firefly-fints`, `apps/stirling-pdf`, `host-services/docker`, `infra/dns`, `ops/backrest`, `ops/grafana-influxdb/scripts`, `ops/Semaphore/playbooks`, `ops/Semaphore/Scripts`) | klein | Aufraeumen, danach committen |
| `.serena/` untracked, nicht in `.gitignore` | klein | `.serena/` zu `.gitignore` hinzufuegen |
| 3 `ops/windows-reinstall/*.ps1` untracked | klein | Entscheidung treffen: ins Repo oder ignorieren |
### 4.4 Bekannte dokumentierte Ausnahmen
Aus `HOMELAB_ARCHITECTURE_MASTER_V2.md` Abschnitt 10 — alle weiterhin gueltig und durch den Policy-Check abgedeckt (`ops/policy-checks/last-report.md` 0 Critical):
- Traefik 80/443
- Tailscale Host-Netz + Capabilities
- AdGuard Port 53 + 8082 (Admin-Port LAN-only, dokumentiert; **offener Punkt im Master:** "Traefik-Absicherung ausstehend (Block F)" — bewusst spaeter)
- Plex Host-Netz (aber Master-Doku-Eintrag jetzt falsch, siehe 2.5)
- Scrutiny `privileged: true`
- Komodo Docker-Socket + keine pauschale Middleware
- glance-docker-socket-proxy Read-only Socket
- Gitea SSH 222
- ddns-updater `frontend_net`
- mail-archiver Hybrid-Netze
- `traefik/dynamic/*` manueller Host-Sync
- nextcloud native Auth
- monitoring-influxdb3-core LAN 8181 + `user: "0"`
- monitoring-promtail Docker-Socket read-only
Keine ungeplanten neuen Ausnahmen.
### 4.5 Endstufen-Definition
"Endstufe" ist erreicht, wenn alle folgenden Punkte gruen sind:
1. **Gitea = Quelle der Wahrheit** — kein lokaler Commit ohne Push 🟡 (heute: `cd650b1` ungepusht)
2. **Hardening-Sprint im Repo abgeschlossen** 🟢
3. **Backup-Konsistenz live verifiziert (Borg laeuft, Dumps frisch)** ❓ Live
4. **Monitoring-Stack live, alte Altstaende gestoppt** 🟡
5. **Doku synchron mit Repo (Jellyfin/Plex, leere Verzeichnisse, ...)** 🟠
6. **Policy-Check 0 Critical** 🟢 (4 Warnings sind dokumentierte Ausnahmen)
7. **Restore-Lab gepflegt (`mail-archiver` als naechste Uebung empfohlen)** 🟡 dokumentiert offen
Sechs der sieben Punkte sind in Reichweite ohne neue Architekturentscheidungen. Punkt 3 und 4 brauchen Live-Daten (Abschnitt 9). Punkt 1 ist 1 Push entfernt.
---
## 5. Priorisierte Restliste
| Prio | Aktion | Begruendung | Aufwand |
|---|---|---|---|
| **P0** | `cd650b1` nach Gitea pushen | GitOps-Quelle-der-Wahrheit, Voraussetzung fuer alles weitere | 30 Sekunden |
| **P0** | Live-Daten aus Abschnitt 9 einholen | Ohne Live-Frische ist Endstufen-Bewertung unvollstaendig | 5 Minuten |
| **P1** | Monitoring-Stack live finalisieren (Secrets pruefen, deployen, Smoke-Test, alte Altstaende stoppen) | Aus `docs/NEXT_SPRINT_TODO_2026-05-16.md` der naechste produktive Schritt | 12 Stunden mit Tests |
| **P2** | Doku-Drift schliessen: Jellyfin und Plex in `HOMELAB_ARCHITECTURE_MASTER_V2.md` 7.4 + 7.1, `docs/SERVICE_CATALOG.md`, `docs/REPO_MAP.md` ergaenzen; Plex-Eintrag in Abschnitt 7.7 "noch offene Sonderfaelle" entfernen (ist umgesetzt) | Doku ist Source of Truth fuer KI-Audits und Nachfolge | 30 Minuten |
| **P2** | Home Assistant -> InfluxDB final testen, HA-Dashboard in `monitoring-grafana` anlegen | aus NEXT_SPRINT_TODO | 12 Stunden |
| **P3** | Repo-Hygiene: 8 leere Verzeichnisse loeschen, `.serena/` in `.gitignore`, Entscheidung zu `ops/windows-reinstall/*.ps1` | minor, aber dokumentiert | 15 Minuten |
| **P3** | Naechster Restore-Lab-Lauf: `mail-archiver` (empfohlen in `RESTORE_MATRIX.md`) | Restore-Routine ueben, bevor sie gebraucht wird | 1 Stunde |
| **P4** | `.gitattributes` mit `* text=auto eol=lf` hinzufuegen, um CRLF/LF-Mount-Effekte bei KI-Audits zu vermeiden | klein, kosmetisch fuer kuenftige Audits | 5 Minuten |
| **bleibt** | Hermes VM-Seite, Disk1-NTFS Phase 2, AdGuard Admin-Port hinter Traefik (Block F), Image-Pinning ddns/glances/scrutiny | bewusste Operator-Entscheidung, kein Audit-Beduerfnis | nicht jetzt |
---
## 6. Was bewusst NICHT angetastet wurde (Audit-Verzicht)
Konsistent mit der bekannten Nicht-Anfassen-Liste:
- Hermes (VM-seitig offen)
- Disk1 NTFS (Phase-2-Migration nach Plan)
- Komodo native Auth ohne ForwardAuth
- Grafana / influxdb3-core `user: "0"` Uebergangsausnahme
- Image-Pinning-Vereinheitlichung fuer ddns-updater/glances/scrutiny
---
## 7. Risiken und Drift-Indikatoren
| Risiko | Wahrscheinlichkeit | Wirkung | Migitation |
|---|---|---|---|
| Lokaler Clone-Verlust (Disk, Reinstall) bevor `cd650b1` gepusht wurde | gering, aber real (Du bist im Reinstall-Kontext, siehe `ops/windows-reinstall/`!) | Verlust von Gitea-Signup-Closure und Posture-Check-Verbesserungen | **Sofort pushen** |
| Komodo deployt aus Gitea, `gitea` und `borg-ui` laufen aktuell ohne die heutigen Verbesserungen | mittel | Gitea Signup steht noch offen, Borg-Scope umfasst `/mnt/user/services` noch nicht | Push + Komodo-Reaktion pruefen |
| 47 vermeintlich modified files koennten doch echte Diffs sein, wenn der Windows-Host etwas anderes zeigt | gering | falsche Audit-Aussage | Punkt 9.1 auf dem Windows-Host pruefen |
| Doku-Drift wird groesser, wenn weitere Stacks ohne Doku-Update hinzukommen | mittel | KI-Audits und Onboarding leiden | P2-Doku-Sync nicht aufschieben |
| Monitoring-Stack-Migration unfertig, alter und neuer Stack koennten parallel werden | mittel | Doppelte Metric-/Log-Pipeline, Verwirrung bei Diagnose | Live-Status klaeren bevor Deploy |
---
## 8. Sources of Truth — Schnellzugriff
- Operative Quelle der Wahrheit: Gitea `origin/master` (https://git.kaleschke.info/Micha/homelab-infra)
- Architektur-Master: `HOMELAB_ARCHITECTURE_MASTER_V2.md`
- Workflow / GitOps-Regeln: `docs/WORKFLOW.md`
- Drift-Runbook: `docs/GITOPS_DRIFT_RUNBOOK.md`
- Restore-Quellen: `docs/RESTORE_MATRIX.md`, `docs/DISASTER_RECOVERY.md`
- Letzter Policy-Check: `ops/policy-checks/last-report.md` (0 Critical)
- Letzte Sprint-Restliste: `docs/NEXT_SPRINT_TODO_2026-05-16.md`
---
## 9. Live-Daten-Checkliste — bitte ausfuehren und zurueckspielen
Fuehre die folgenden Bloecke am Unraid-Host (per SSH oder Web-Terminal) und am Windows-Host (Git Bash / PowerShell in `G:\Gitea_Clone\homelab-infra`) aus und pastiere die Outputs zurueck. Ich integriere sie dann in diesen Report.
### 9.1 Windows-Host: Echter Working-Tree-Status
```powershell
cd G:\Gitea_Clone\homelab-infra
git status --short
git log origin/master..HEAD --oneline
```
Erwartet: Working tree leer (oder nur die 4 Untracked). `cd650b1` als einziger lokaler Commit.
### 9.2 Unraid-Host: Gitea online
```bash
curl -sI --max-time 5 https://git.kaleschke.info/ | head -5
docker exec gitea sh -lc 'gitea --version'
```
Erwartet: `HTTP/2 200` (oder ein Auth-Code, der den Erreichbarkeitstest erfuellt). Gitea-Version stimmt mit Image-Tag `1.25.4` ueberein.
### 9.3 Unraid-Host: Komodo-Webhook-Status
In Komodo UI fuer jeden produktiven Stack aus `Micha/homelab-infra` pruefen:
- `webhook_enabled: true`
- Gitea-Hook auf `http://komodo-core:9120/listener/github/stack/<stack-id>/deploy` aktiv
- `last_status` der letzten Webhook-Delivery in Gitea (Repository -> Settings -> Webhooks)
Pflicht-Stacks zum Pruefen: `traefik`, `gitea`, `authelia`, `vaultwarden`, `postgresql17`, `redis`, `paperless-ngx`, `immich`, `nextcloud`, `mealie`, `mail-archiver`, `ntfy`, `homepage`, `paperless-gpt`, `borg-ui`, `filebrowser`, `code-server`, `uptime-kuma`, `glance`, `glances`, `scrutiny`, `speedtest-tracker`, `bentopdf`, `ddns-updater`, `komodo`, `jellyfin`, `plex`, `adguard`, `tailscale`, `monitoring`, `hermes-agent` (sofern produktiv).
Bei Stacks **ohne** aktiven Webhook bitte den Grund vermerken (dokumentierte Ausnahme oder Nachholbedarf).
### 9.4 Unraid-Host: Borg-Lauf-Frische und Dump-Coverage
```bash
ls -lah /mnt/user/backups/borg/dumps/latest/
stat -c '%y %n' /mnt/user/backups/borg/dumps/latest/*.dump /mnt/user/backups/borg/dumps/latest/*.sql /mnt/user/backups/borg/dumps/latest/*.archive.gz /mnt/user/backups/borg/dumps/latest/*.sqlite 2>/dev/null | sort
docker exec borg-ui borg list --short 2>&1 | tail -10
```
Erwartet: Alle 14 Artefakte aus 4.1 sind vorhanden, mtime juenger als 24h. Borg-Archive-Liste zeigt regelmaessige Laeufe.
### 9.5 Unraid-Host: Monitoring-Stack live?
```bash
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep monitoring
ls -la /mnt/user/appdata/secrets/ | grep -E 'monitoring|influxdb3'
curl -sI --max-time 5 https://monitoring.kaleschke.info/ | head -5
ss -ltnp 2>/dev/null | grep -E ':8181|:9090|:3100' | head -10
```
Erwartet: Entweder alle `monitoring-*` Container laufen (dann ist 4.2 🟢) oder gar nicht (dann ist 4.2 🟡 wie aktuell bewertet). Halb-Zustand ist ein Audit-Befund.
### 9.6 Unraid-Host: GitOps-Pflichtmatrix Spot-Check fuer einen Stack
Beispiel `gitea` (weil der heutige Commit ihn betrifft):
```bash
cd /mnt/user/services/stacks/gitea
git rev-parse --short HEAD
git status -sb
docker inspect gitea --format '{{.Image}}'
docker exec gitea sh -lc 'env | grep -E "ALLOWED_HOST_LIST|DISABLE_REGISTRATION|ENABLE_OPENID"'
```
Erwartet: Komodo-Workspace `HEAD` zeigt entweder auf `cd650b1` (wenn Push schon erfolgt + Komodo deployed) oder auf `af231dd` (vor dem Push). ENV-Vars in der Live-Runtime spiegeln den Commit, der Komodo zuletzt deployed hat.
### 9.7 Unraid-Host: Host-Listener-Spot-Check
```bash
ss -ltnp 2>/dev/null | grep -E ':80|:443|:53|:222|:8082|:8181' | sort
```
Erwartet exakt die dokumentierten Ausnahmen aus `HOMELAB_ARCHITECTURE_MASTER_V2.md` Abschnitt 10. Andere Listener = Befund.
---
## 10. Nachhalte-Vorschlag
Wenn Du moechtest, halte ich diesen Audit in zwei Schritten zu Ende:
1. Du fuehrst Abschnitt 9 aus und pastierst die Outputs zurueck.
2. Ich aktualisiere diesen Report mit den Live-Ergebnissen, ergaenze die Ampel und schliesse die offenen 🟡/🟠 Punkte oder benenne sie als echte Restliste.
Bis dahin gilt der Stand dieses Reports als Repo-Audit, nicht als Endstufen-Zertifikat.
+22
View File
@@ -0,0 +1,22 @@
# Homelab Audit Final - 2026-05-23
Stand: 2026-05-25 07:33 CEST. Ergebnis nach Push, Live-Messung, Doku-Sync, Repo-Hygiene und erneuter Live-Nachmessung.
| Punkt | Ampel | Beleg |
|---|---|---|
| P0 `cd650b1` nach Gitea pushen | gruen | Push `af231dd..cd650b1 master -> master`; produktiver Runtime-Stand `66ee10c` enthaelt `cd650b1`. Die abschliessenden Audit-Doku-Commits liegen in Gitea; der runtime-relevante Stack-Inhalt fuer `gitea`, `borg-ui` und `monitoring` ist seit `66ee10c` unveraendert. |
| P0 Live-Daten ablegen | gruen | `docs/AUDIT_2026-05-23_LIVE.md` angelegt, keine Secret-Werte dokumentiert. |
| P1 Monitoring live / Altstaende down | gruen | 10 `monitoring-*` Container laufen, `0` unhealthy, `0` starting, `0` stopped; alte `grafana`/`influxdb3-core`/`loki`/`alloy` Container sind nicht vorhanden. `monitoring.kaleschke.info` liefert 302 zu Authelia, Prometheus ist bereit, Loki `/ready` liefert `ready`. |
| P1 Jellyfin/Plex Doku | gruen | `HOMELAB_ARCHITECTURE_MASTER_V2.md`, `docs/SERVICE_CATALOG.md` und `docs/REPO_MAP.md` ergaenzt; Plex ist als Repo-Compose-Stack dokumentiert, nicht mehr als nicht migrierter Dockerman-Sonderfall. |
| P2 Borg-Frische | gruen | Borg-UI DB: letzter Backup-Job `completed`, Archiv `Taegliche-Sicherung-2026-05-25T05:52:44.157`, `nfiles=100221`; 15 kanonische Dump-/Archive-Artefakte von 2026-05-25 06:09/06:10 CEST und damit juenger als 24 h. |
| P3 Repo-Hygiene | gruen | `.serena/` in `.gitignore`; leere Verzeichnisse entfernt; `ops/windows-reinstall/*.ps1` bewusst ins Repo aufgenommen. |
| Policy-Check | gruen | `ops/policy-checks/check_repo.ps1`: 0 Critical, dokumentierte Warnings zu Plex Host-Netz, InfluxDB/Grafana `user: "0"` und bekannten mutable-tag-Ausnahmen. |
## Bewusst offen
- Keine offenen Punkte aus der Audit-Restliste.
- Nicht Teil des Audits: Nach Disk1 Phase 2 laeuft seit 2026-05-25 eine nicht korrigierende Parity-Pruefung (`NOCORRECT`) im Hintergrund; Zwischenstand der Nachmessung: `mdResyncAction=check P`, `mdResyncCorr=0`.
## Schlussbewertung
Das Homelab ist fuer die Audit-Restliste in der Endstufe. Es gibt keine kritischen Repo-, GitOps- oder Live-Befunde aus diesem Audit. Monitoring ist produktiv und ready, alte Altstaende sind down, Backups und Dumps sind frisch.
+88
View File
@@ -0,0 +1,88 @@
# Homelab Audit Live-Daten - 2026-05-23
Stand: 2026-05-23 11:27 CEST. Quelle: lokaler Windows-Clone und SSH auf `Kallilabcore`. Secret-Werte wurden nicht ausgelesen oder redaktiert; dokumentiert sind nur Status, Dateinamen, Modi, Env-Key-Namen und nicht geheime Bool-/Host-Werte.
## 9.1 Windows-Host / Git
- `git status --short` nach dem initialen Push: keine tracked Modifikationen, untracked waren `.serena/`, `docs/AUDIT_2026-05-23.md`, `docs/CODEX_ENDSTUFE_PROMPT_2026-05-23.md` und drei `ops/windows-reinstall/*.ps1`.
- `cd650b1` wurde nach `origin/master` gepusht: `af231dd..cd650b1 master -> master`.
## 9.2 Gitea online
- `curl -sI https://git.kaleschke.info/`: `HTTP/2 200`.
- `docker exec gitea gitea --version`: `1.25.4`.
- Signup-Smoke: `/user/sign_up` meldet Registration disabled.
## 9.3 Komodo / Workspace-Reaktion
| Stack | Workspace HEAD | Status | Live-Beleg |
|---|---:|---|---|
| `gitea` | `cd650b1` | `## master...origin/master` | Container neu gestartet, Env-Keys aktiv |
| `borg-ui` | `cd650b1` | `## master...origin/master` | Container healthy, `/mnt/user/services -> /local/services` gemountet |
| `monitoring` | `cd650b1` | `## master...origin/master`, untracked Host-Backup `monitoring/prometheus/alerts.yml.bak-20260520-incident` | Stack laeuft seit 4 Tagen |
Gitea Live-Env ohne Secrets:
```text
GITEA__service__DISABLE_REGISTRATION=true
GITEA__openid__ENABLE_OPENID_SIGNIN=false
GITEA__openid__ENABLE_OPENID_SIGNUP=false
GITEA__webhook__ALLOWED_HOST_LIST=komodo-core,localhost,127.0.0.1,192.168.178.0/24
```
## 9.4 Borg-Lauf-Frische und Dump-Coverage
- Borg UI DB: Repository `appdata-critical`, `last_backup=2026-05-23 02:30:12 UTC`, `archive_count=30`, letzter Job `completed`, `nfiles=100056`.
- Schedule: `Taegliche Sicherung` enabled, letzter Lauf `2026-05-23 02:30:11 UTC`, naechster Lauf `2026-05-24 02:30:00 UTC`.
- Hinweis: `docker exec borg-ui borg list --short` funktioniert ohne Repository-Parameter/Env nicht (`Invalid location format: ""`). Der Borg-UI-Status wurde deshalb ueber die lokale Borg-UI-Datenbank ohne Secret-Spalten ausgewertet.
Frische Artefakte in `/mnt/user/backups/borg/dumps/latest`:
```text
2026-05-23 04:00 postgresql17-globals.sql
2026-05-23 04:00 postgresql17-mailarchiver.dump
2026-05-23 04:00 postgresql17-paperless.dump
2026-05-23 04:01 postgresql17-authelia.dump
2026-05-23 04:01 mealie.dump
2026-05-23 04:01 gitea.sqlite.dump
2026-05-23 04:01 immich.dump
2026-05-23 04:01 nextcloud.dump
2026-05-23 04:01 uptime-kuma.sqlite.dump
2026-05-23 04:01 vaultwarden.sqlite.dump
2026-05-23 04:01 speedtest-tracker.sqlite.dump
2026-05-23 04:01 filebrowser.bolt.dump
2026-05-23 04:01 borg-ui.sqlite
2026-05-23 04:01 grafana.sqlite
2026-05-23 04:01 komodo-mongo.archive.gz
```
Bewertung: 15 aktuelle Dump-/Archive-Artefakte sind juenger als 24 h. Aeltere nackte `.sqlite`-Dateien vom 2026-05-16 liegen noch im Verzeichnis, sind aber Legacy-Kopien ohne `.dump`-Suffix und nicht Teil der aktuellen Dump-Serie.
## 9.5 Monitoring-Stack
- Aktive Container: `monitoring-grafana`, `monitoring-promtail`, `monitoring-prometheus`, `monitoring-cadvisor`, `monitoring-influxdb3-core`, `monitoring-loki`, `monitoring-blackbox-exporter`, `monitoring-alertmanager-ntfy-bridge`, `monitoring-alertmanager`, `monitoring-node-exporter`.
- Alte Altcontainer `grafana`, `influxdb3-core`, `loki`, `alloy`: nicht vorhanden in `docker ps -a`.
- Secret-Dateien vorhanden und mode `600`: `monitoring_grafana_admin_password.txt`, `monitoring_grafana_influxdb_token.txt`, `influxdb3_admin_token.json`.
- `https://monitoring.kaleschke.info/`: `HTTP/2 302` zu Authelia, wie erwartet.
- Host-Listener fuer Monitoring: nur `127.0.0.1:8181`; keine Host-Listener fuer `:9090` oder `:3100`.
- Prometheus readiness: `Prometheus Server is Ready.`
- Grafana health: `database=ok`, Version `12.4.3`.
- Loki: Container laeuft und API/metrics liefert `loki_build_info`; `/ready` liefert aktuell `503 Service Unavailable`, waehrend Query-/Metrics-Endpunkte 200-Zaehler zeigen. Kein Reparaturversuch gestartet, weil der produktive Logpfad nachweislich Daten annimmt und die Stop-Regel keine blinden Eingriffe erlaubt.
## 9.6 GitOps Spot-Check Gitea
- `/mnt/user/services/stacks/gitea`: `cd650b1`, `## master...origin/master`.
- Docker-Image: `docker.gitea.com/gitea:1.25.4`.
- Live-ENV spiegelt `cd650b1` fuer Registrierung, OpenID und Webhook-Allowlist.
## 9.7 Host-Listener
Dokumentierte Listener gefunden:
- `:80`, `:443` Traefik
- `:53` AdGuard DNS
- `:222` Gitea SSH
- `:8082` AdGuard Admin
- `127.0.0.1:8181` Monitoring InfluxDB
Zusaetzlich sichtbar: mehrere `wsdd2` Listener auf `:5355` je Interface. Das ist Host-/Unraid-Service-Discovery, kein Compose-Webdienst und kein GitOps-Stack-Port.
+895
View File
@@ -0,0 +1,895 @@
# Homelab-Audit KalliLab CORE
Stand: 2026-05-25
Methode: Repo-basierter Audit auf `master` (lokaler Clone). Keine Live-Messung gegen den Host. Querverweise auf Audit-Live-Daten aus `docs/AUDIT_2026-05-23_LIVE.md`, wo verfuegbar.
Auftrag: externer, kritischer Audit-Blick zusaetzlich zur internen `docs/STRATEGISCHE_BEWERTUNG_2026-05-23.md`.
## Wichtige Vorbemerkung
Es gibt seit dem 23.05. eine fundierte interne Bewertung (`docs/STRATEGISCHE_BEWERTUNG_2026-05-23.md`) und eine konsolidierte Hausaufgabenliste (`docs/CODEX_KONSOLIDIERUNG_2026-05-23.md`). Davon wurden seit dem 25.05. bereits umgesetzt:
- Jellyfin entfernt (MASTER 7.8)
- Homepage entfernt (MASTER 7.8)
- Uptime-Kuma entfernt (MASTER 7.8, SECRETS_MAP 29)
- Monitoring-Stack produktiv (AUDIT_FINAL 9), Altstaende-Container down
- Disk1 NTFS -> XFS Phase 2 abgeschlossen am 2026-05-25 (STORAGE_LAYOUT 3)
- Unraid-Flash-Backup live (`unraid-flash-config.tar.gz` im Borg-Lauf)
- GitHub-Push-Mirror `michaelkaleschke-spec/homelab-infra` aktiv (DR 10, MASTER 7.1)
Diese geloesten Punkte werden hier nicht wiederholt. Dieser Audit konzentriert sich auf das, was nach dem 23.05.-Sprint **noch offen ist** und auf das, was die strategische Bewertung **nicht oder nur kurz angerissen** hat.
---
# Phase 1: Repo-Inventar
## Ordnerstruktur (Ist-Zustand)
```
homelab-infra/
├── apps/ 9 Stacks (bentopdf, immich, mail-archiver, mealie, nextcloud, ntfy, paperless, paperless-gpt, unbound)
├── core/ 1 Stack (gitea)
├── docs/ 28 Markdown-Dokumente
├── env/ 2 *.example
├── host-services/ 3 Stacks (Adguard, plex, tailscale)
├── infra/ 3 Stacks (ddns-updater, postgresql17, redis)
├── monitoring/ 1 Compose mit 10 Services + Provisioning
├── ops/ 17 Verzeichnisse (Semaphore, borg-ui, code-server, filebrowser, glance, glances, grafana-influxdb [Altstand], hermes-agent, komodo, loki [Altstand], policy-checks, restore-tests, scrutiny, speedtest, uptime-kuma [Altrest], windows-reinstall)
├── security/ 2 Stacks (authelia, vaultwarden)
├── services/ 1 posture-check (Host-Skripte)
└── traefik/ 1 Compose + dynamic/ (3 Files)
```
**Inventar-Befund:**
- ~30 Compose-Dateien, 1 zentraler Compose-Multi-Service (`monitoring/`).
- 29 Composes wurden vom Policy-Checker validiert (`ops/policy-checks/last-report.md`): **0 Critical, 4 Warnings, 9 Info**.
- Doku-Dichte ist hoch (REPO_MAP, SERVICE_CATALOG, RESTORE_MATRIX, DISASTER_RECOVERY, SECRETS_MAP, WORKFLOW, STORAGE_LAYOUT, GITOPS_DRIFT_RUNBOOK, ALERTING_MAP).
- Restore-Tests sind als echte Scripts versioniert (`ops/restore-tests/`). Ueberdurchschnittlich.
## Gut dokumentierte Bereiche (Belegt)
| Bereich | Quelle |
|---|---|
| Architektur, Netze, Ausnahmen | `HOMELAB_ARCHITECTURE_MASTER_V2.md` |
| GitOps-Workflow, Drift | `docs/WORKFLOW.md`, `docs/GITOPS_DRIFT_RUNBOOK.md` |
| Backup-Scope, Restore-Wege, Tier-Modell | `docs/RESTORE_MATRIX.md`, `docs/DISASTER_RECOVERY.md`, `ops/borg-ui/BACKUP_SCOPE.md` |
| Storage / Cache-Policy / FS / Posture | `docs/STORAGE_LAYOUT.draft.md` |
| Secret-Inventur | `docs/SECRETS_MAP.md` |
| Alert-Pfade | `docs/ALERTING_MAP.md` |
## Luecken / Unklar (vermutet bzw. Rueckfrage noetig)
| Luecke | Warum es ein Audit-Loch ist |
|---|---|
| `docs/STORAGE_LAYOUT.draft.md` ist `Draft 1.3`, nicht `Active` | Mehrere Hard Rules (12 Constitution) gelten formal noch nicht. Hard Rule 11 (kein Stack ohne Restore-Pfad in RESTORE_MATRIX) wird heute schon eingehalten — also nur Formal-Luecke. |
| `docs/SERVICES_RECOVERY.md` ist als verbindlich angekuendigt (STORAGE_LAYOUT 4), aber nicht im Repo | Konkrete Mirror-Mechanik fuer `services/gitea/git/repositories/` ≤ 6 h ist nirgends spezifiziert. |
| Hardware-Inventar: kein zentrales Dokument | Keine Stelle im Repo nennt CPU-Modell, RAM-Groesse, NIC-Speed, Mainboard, Parity-Disk-Groessen — nur "Samsung 970 EVO Plus 2 TB" steht in STORAGE_LAYOUT 3. |
| USV: keine Erwaehnung | Keine Datei nennt eine USV. Unklar, ob vorhanden. |
| Familien-/User-Onboarding-Doku | Keine Doku, die deine Frau/Kinder lesen muessten ("So loggst du dich in Nextcloud ein"). Aktuell ist alles Operator-Doku. |
## Fuer den Audit besonders wichtige Dateien (verwendet)
- `HOMELAB_ARCHITECTURE_MASTER_V2.md` — komplett
- `docs/WORKFLOW.md`, `docs/REPO_MAP.md`, `docs/SERVICE_CATALOG.md` — komplett
- `docs/DISASTER_RECOVERY.md`, `docs/RESTORE_MATRIX.md`, `docs/SECRETS_MAP.md` — komplett
- `docs/STORAGE_LAYOUT.draft.md`, `docs/STRATEGISCHE_BEWERTUNG_2026-05-23.md` — komplett
- `docs/AUDIT_2026-05-23_LIVE.md`, `docs/AUDIT_2026-05-23_FINAL.md`
- `ops/policy-checks/last-report.md`
- `monitoring/docker-compose.yml`, `monitoring/prometheus/alerts.yml`
- `traefik/docker-compose.yml`, `traefik/dynamic/middlewares.yml`
- `security/authelia/configuration.yml`, `security/authelia/docker-compose.yml`
- `apps/paperless/docker-compose.yml`, `apps/immich/docker-compose.yml`, `apps/nextcloud/docker-compose.yml`
- `host-services/Adguard/docker-compose.yml`
---
# A. Executive Summary
**Was schon stark ist:**
- GitOps-Disziplin: Gitea als Sollzustand, Komodo als Consumer, dokumentierter Drift-Runbook, Stop-Regel ("zwei Fehlversuche -> Pflichtmatrix"). Seltene Reife.
- Backup-Architektur: Pre-Backup-Dumps + Borg + Restore-Tests mit echtem Smoke-Test-Kriterium ("Container startet ≠ Erfolg"). 15 frische Dumps < 24 h alt (`AUDIT_LIVE 9.4`).
- Architektur-Klarheit: `frontend_net` / `backend_net` / app-interne Netze, keine Sammelnetze, dokumentierte Ausnahmen.
- Image-Pinning: Tier-1-Stateful mit `<minor>@sha256:...`. Konsequent durchgezogen.
- Secrets-Hygiene: Keine Secret-Werte im Repo, `_FILE`-Mounts + Komodo Stack ENV, explizit dokumentierte Ausnahmen.
- Policy-as-Code: `check_repo.ps1` mit 0 Critical und sauber dokumentierten Exceptions.
**Was kritisch ist:**
- **AdGuard Admin-Port 8082 ohne Authelia/2FA am LAN gebunden** (`host-services/Adguard/docker-compose.yml:16`) — dokumentierte "Operator-Entscheidung" 2026-05-25. Im Heim-LAN tolerierbar, mit IoT/Gaeste-WLAN potenziell ein Pfad zur DNS-Manipulation. Niedrigster Aufwand: Bind nur auf Tailscale-Interface.
- **Authelia ACL: 2FA nur fuer `files.kaleschke.info` und `scrutiny.kaleschke.info`** (`security/authelia/configuration.yml:44-48`). Borg-UI, Code-Server, Filebrowser, Glance — alles nur `one_factor`. Bei Pwd-Kompromittierung des Operator-Accounts ist Borg-UI + Code-Server der direkteste Pfad zur Datenexfiltration.
- **Authelia-Repo-Baseline ↔ Host-Config-Drift "by design"** (`docs/REPO_MAP.md:48`, `SERVICE_CATALOG 23`). User-DB, OIDC-Clients und Secrets sind hostseitig, Manual-Merge-Pflicht. Stelle, an der Drift mit Anlauf passiert.
- **Komodo Self-Bootstrap-Problem ist nur dokumentiert, nicht geloest** (MASTER 13: Self-Stack Drift-Recovery 2026-05-04). Bei Recovery vom kalten Host musst du Komodo aus `compose.yaml` neu erzeugen — dafuer brauchst du die `.env` mit `KOMODO_*`-Secrets, die nur auf Host und ggf. Vaultwarden liegen.
- **Backup-Off-Site-Diversitaet:** BorgBase Hetzner ist Single-Provider; Borg-Passphrase analog gesichert ist als TODO markiert (`docs/DISASTER_RECOVERY.md:64,401`). Wenn Hetzner-Account verloren geht, ist das halbe DR-Versprechen weg.
**Was unnoetig komplex ist:**
- Drei dokumentierte Monitoring-/Logging-Pfade gleichzeitig im Repo: `ops/grafana-influxdb` (Altstand), `ops/loki` (Altstand), `monitoring/` (Ziel). Die Altstaende sind als Container down, aber **Verzeichnisse noch im Repo** — Doppelpflege-Risiko. Der versprochene Repo-Cleanup (`git rm`) fehlt.
- Hermes-Agent: NAS-Stack bewusst deaktiviert ("VM-seitig offen"), aber Stack-Verzeichnis und Compose mit Dashboard-Domain bleiben im Repo. Mehr "Schwebezustand" als operativer Wert.
- BentoPDF: "vorbereitet", noch nie produktiv abgenommen (`SERVICE_CATALOG 52`, `MASTER 7.5`).
- `infra/redis` ist als shared Cache deklariert, wird de facto nur von Paperless genutzt (Authelia nicht, Immich/Nextcloud/Mealie haben eigene Redis). Das "shared" stimmt im Repo nicht mit der Realitaet ueberein.
**Groesster Hebel:**
**Authelia OIDC-Provider aktivieren** — wenn Nextcloud, Immich, Grafana (und perspektivisch Mealie via OIDC-Bridge) per SSO laufen, gewinnst du gleichzeitig:
- (a) Familien-Onboarding-Komfort (ein Login),
- (b) zentrale Brute-Force-Regulation und Audit,
- (c) Voraussetzung fuer sinnvolles CrowdSec/Fail2Ban,
- (d) zentrale 2FA-Pflicht statt App-by-App.
Das ist ein Sprint, nicht ein Quartal, und macht aus deinem "Admin-Authelia" ein echtes Identity-System.
**Was ein erfahrener Homelabber sofort aendern wuerde:**
1. AdGuard-Admin-Port nur auf Tailscale-Interface binden (5 Min, Compose-Edit).
2. Borg-Passphrase auf Papier in Bankschliessfach (15 Min, off-system).
3. `scrutiny` und `ddns-updater` `no-new-privileges` Warning aufraeumen (10 Min) — kosmetisch, aber Policy-Check sollte clean sein.
4. Altstaende `ops/grafana-influxdb/` und `ops/loki/` aus Repo entfernen (Backup-Branch dann `git rm`).
5. Renovate-Bot gegen Gitea einrichten — beendet manuelle Digest-Pflicht.
---
# B. Scorecard (1 = exzellent, 10 = ungenuegend)
| Bereich | Note | Begruendung |
|---|---:|---|
| Hardware | **nicht bewertbar** | Keine Inventar-Doku im Repo. Nur Cache-NVMe genannt. Siehe Phase H. |
| Ordnerstruktur | **2** | Klare Trennung apps/infra/ops/security/core; konsistente Namenskonvention (mit Migrationsplan in STORAGE_LAYOUT 6). Kleinerer Haenger: `host-services/Adguard/` mit Grossbuchstabe. |
| Storage | **3** (Repo-Stand) / **2** (mit STORAGE_LAYOUT Active) | Cache-XFS, Disk1-XFS jetzt erreicht. Pfad-Disziplin via `/mnt/user/...`. Posture-Check etabliert. Note durch Draft-Status und fehlendes `SERVICES_RECOVERY.md` gedrueckt. |
| Docker-Architektur | **2** | Netzmodell klar, Healthchecks fehlen grossflaechig, `latest@sha256` als bewusster Kompromiss bei einigen Images dokumentiert. Keine Memory-Limits. |
| Reverse Proxy / Zugriff | **2** | DNS-Challenge, Wildcard-faehig, Authelia ForwardAuth, dynamic config sauber getrennt. Manuelle Host-Sync-Ausnahme ist pragmatisch. |
| Security | **3** | Solides Fundament (Authelia Argon2id, no-new-privileges-Standard, Webhook-Allowlist, `cloudflare_dns_api_token` als Docker Secret), aber: nur 2 Domains mit 2FA, AdGuard-Admin direkt am LAN, kein WAF/Bouncer, Authelia Regulation 5-Min-Ban ist gentil. |
| Netzwerk / DNS | **2** | AdGuard + Unbound + Tailscale-Trias ist Best-of-Class-Homelab. FritzBox als Router nicht im Repo dokumentiert, daher Note nicht 1. |
| Backup | **2** | Borg, Pre-Dumps, Tier-Modell, dokumentierter Scope. Punkt-Abzug: Single-Provider Off-Site, Passphrase nicht analog, Komodo-Mongo-Dump-Verifikation nicht im Auto-Cron. |
| Restore-Faehigkeit | **2** | RESTORE_MATRIX mit Smoke-Test je Dienst, Restore-Test-Schedule + Validierungen fuer Vaultwarden/Gitea/Paperless dokumentiert. Punkt-Abzug: Immich-Restore noch nie geuebt — groesster Datentopf. |
| GitOps | **1-2** | Webhook-Pflicht fuer neue Stacks, Source-of-Truth-Hierarchie, Drift-Runbook. Self-Bootstrap-Problem von Komodo zieht von 1 auf 2. |
| Monitoring | **3** | Stack produktiv, aber Altstaende noch im Repo, Family-View-Dashboard fehlt, Alerts (`alerts.yml`) sehr knapp (5 Regeln), keine Cert-Expiry-Alert auf Prometheus-Ebene (Cert-Token-Check laeuft separat). |
| Dokumentation | **1** | Aussergewoehnlich. SERVICE_CATALOG ist Gold. Einziger Punkt: kein End-User-/Familien-Onboarding. |
| Automatisierung | **3** | Borg-Dumps automatisiert, posture-check Host-Cron, Alert->ntfy-Pipe. Aber: keine CI gegen Repo, kein Renovate, kein automatisches Image-Update-Tracking. |
| Wartbarkeit | **2** | Doku traegt; Sprintpflege-Disziplin sichtbar (MIGRATION_LOG, AUDIT_FINAL). Risiko: Authelia-Drift, Hermes-Schwebezustand. |
| Nerd-Faktor | **2-** | Komodo + Borg-UI + ntfy-Bridge + Posture-Check + Restore-Lab + Hermes-Experiment + Push-Mirror + Digest-Pinning. Liegt zwischen "Solider Senior" und "Spielwiese halten lernen". |
**Gesamteindruck: 2 (gut).** Strukturell weit ueber durchschnittlichem Homelab; konkrete Luecken sind klar benennbar und nicht systemisch.
---
# C. Top-20 Findings
> Format: priorisiert nach Risiko-zu-Aufwand-Hebel. Jeder Eintrag hat Fundstelle, Empfehlung und Prio.
### F-01 · AdGuard-Admin am LAN ohne Auth
- **Kategorie:** Security / Zugriff
- **Fundstelle:** `host-services/Adguard/docker-compose.yml:16`, `MASTER 10`, `docs/SERVICE_CATALOG.md:14`
- **Beobachtung:** Port `8082:80` direkt auf alle Interfaces. Bewusste Operator-Entscheidung 2026-05-25.
- **Risiko:** Jedes Geraet im LAN kann DNS-Filterregeln, Upstream und Logging manipulieren. IoT-Kompromittierung oder Gast-WLAN -> DNS-Hijack moeglich.
- **Best Practice:** Admin-UIs nicht im LAN ohne Auth. Entweder hinter Traefik+Authelia mit `two_factor` oder Bind auf Tailscale-Interface (z. B. `100.x.y.z:8082:80`).
- **Empfehlung:** Schritt 1 — Bind auf Tailscale-IP (S, 5 Min). Schritt 2 — optional spaeter Traefik-Route hinter Authelia.
- **Prioritaet:** Sollte zeitnah
- **Aufwand:** S
- **Validierung:** `ss -ltnp | grep :8082` zeigt nur Tailscale-IP; LAN-Browser-Zugriff schlaegt fehl.
- **Rollback:** Compose-Diff zurueck, Komodo redeploy.
### F-02 · Borg-Passphrase nicht analog gesichert
- **Kategorie:** Backup / DR
- **Fundstelle:** `docs/DISASTER_RECOVERY.md:64,401`, `docs/SECRETS_MAP.md:48`
- **Beobachtung:** `borg_repo_passphrase.txt` liegt im Host-Filesystem unter `/mnt/user/appdata/secrets/`. Doku weist explizit darauf hin, dass eine externe analoge Sicherung Operator-Aufgabe ist.
- **Risiko:** Wenn Unraid-Host und ggf. Vaultwarden gleichzeitig defekt sind, ist das verschluesselte Borg-Repo bei Hetzner nutzlos.
- **Empfehlung:** Auf Papier ausdrucken, in Bankschliessfach oder bei vertrauter Person versiegelt. Zusaetzlich in Vaultwarden hinterlegen (aber Vaultwarden hilft nicht, wenn es selbst restauriert werden muss).
- **Prioritaet:** Muss sofort
- **Aufwand:** S
- **Validierung:** Du kannst den Wert ohne Host wiederherstellen.
### F-03 · Single-Provider Off-Site Backup
- **Kategorie:** Backup
- **Fundstelle:** `ops/borg-ui/BACKUP_SCOPE.md`, `docs/RESTORE_MATRIX.md:77-96`, `STORAGE_LAYOUT 8.1`
- **Beobachtung:** Hetzner Storage Box als alleiniges Off-Site-Borg-Ziel. STORAGE_LAYOUT 8.1 sieht zusaetzlich lokales Borg-Repo auf `/mnt/user/backups/borg/` vor (gleicher Host) und eine externe Wechselplatte (manuell rotiert).
- **Risiko:** Hetzner-Account-Verlust (Payment-Issue, Account-Hack, Provider-Outage) = halbes 3-2-1.
- **Best Practice:** Zweites Off-Site-Ziel mit unterschiedlichem Provider oder Cold-Wechselplatte mit fester Rotationskadenz.
- **Empfehlung:** (a) Wechselplatten-Rotation in fester Kadenz dokumentieren (zwei Platten, monatlicher Tausch). Oder (b) zweites Borg-Repo bei rsync.net / BorgBase EU2 / privatem 2. Standort.
- **Prioritaet:** Sollte zeitnah
- **Aufwand:** M
- **Validierung:** `borg list` gegen beide Repos, beide < 7 Tage alt.
### F-04 · Authelia 2FA-Pflicht zu schmal
- **Kategorie:** Security
- **Fundstelle:** `security/authelia/configuration.yml:44-53`
- **Beobachtung:** Nur `files.kaleschke.info` und `scrutiny.kaleschke.info` sind `two_factor`. Tier-1-Operator-UIs wie Borg-UI, Code-Server, Filebrowser (zweite Route?), Komodo (eigene Auth), Glance, Grafana laufen mit `one_factor`.
- **Risiko:** Operator-Passwort-Kompromittierung (Phishing, Keylogger, Browser-Save-Leak) gibt ohne 2FA Vollzugriff auf Code-Server (Repo + Workspace), Borg-UI (Restore-Pfade), Filebrowser (Documents/Photos).
- **Empfehlung:** ACL erweitern: `two_factor` fuer `borg.kaleschke.info`, `code.kaleschke.info`, `files.kaleschke.info` (schon), `glance.kaleschke.info` (debattierbar), `traefik.kaleschke.info`. Komodo bleibt Ausnahme.
- **Prioritaet:** Muss sofort
- **Aufwand:** S
- **Validierung:** Nach Login auf `borg.kaleschke.info` wird 2FA-Prompt erzwungen.
- **Rollback:** ACL-Block zurueck.
### F-05 · Repo-Altstaende `ops/grafana-influxdb/` und `ops/loki/` nicht entfernt
- **Kategorie:** Wartbarkeit / GitOps
- **Fundstelle:** Repo-Wurzeln `ops/grafana-influxdb/`, `ops/loki/`; `MASTER 7.6`, `SERVICE_CATALOG 68-70,80-81`, `AUDIT_FINAL 9`
- **Beobachtung:** Container down, aber Compose-Dateien + Provisioning bleiben im Repo. Doku referenziert beide gleichzeitig. Risiko: jemand (zukuenftiges Ich, KI) deployt versehentlich den Altstand.
- **Empfehlung:** `git rm` der beiden Verzeichnisse, Tag `pre-monitoring-cleanup` fuer Rollback, MIGRATION_LOG-Eintrag.
- **Prioritaet:** Sollte zeitnah
- **Aufwand:** S
- **Validierung:** `policy-checks` laeuft clean, Repo enthaelt nur noch `monitoring/`.
### F-06 · Hermes-Agent im Schwebezustand
- **Kategorie:** App-Landschaft / Wartbarkeit
- **Fundstelle:** `ops/hermes-agent/docker-compose.yml`, `MASTER 7.5`, `SERVICE_CATALOG 82-83`
- **Beobachtung:** "NAS-Stack bewusst deaktiviert" wegen offener VM-Seite. Dashboard-Domain (`hermes.kaleschke.info`) + Authelia-ACL + Secret-Pfade dokumentiert.
- **Risiko:** Schleichender Verfall — in 6 Monaten verstehst du Model-C nicht mehr ohne `ops/hermes-agent/README.md`. Bei jeder Authelia-/Compose-Aenderung musst du Hermes mitpruefen, obwohl es nichts tut.
- **Empfehlung:** Operator-Entscheidung mit 60-Tage-Deadline ehrlich treffen. Wenn nicht produktiv bis 2026-07-25: `git rm ops/hermes-agent/`, Domain aus DNS, ACL-Eintrag raus.
- **Prioritaet:** Sollte zeitnah (Entscheidung)
- **Aufwand:** S (Entfernen) / L (echte Produktiv-Aktivierung)
- **Validierung:** Entweder Smoke-Test auf `hermes.kaleschke.info` mit funktionalem Use-Case-Beleg, oder Repo-clean.
### F-07 · Monitoring-Stack ohne Digest-Pin
- **Kategorie:** Reproduzierbarkeit / GitOps
- **Fundstelle:** `monitoring/docker-compose.yml:3,28,66,84,100,118,276,296`
- **Beobachtung:** Prometheus, Alertmanager, Blackbox, Loki, Promtail, Grafana, node-exporter, cAdvisor sind alle nur per Tag gepinnt (`prom/prometheus:v3.7.3`, `grafana/grafana:12.4.3`, ...). Nur `influxdb3-core` hat `@sha256:`. Das widerspricht der Image-Versionierungs-Disziplin der Tier-1-Stateful-Dienste.
- **Risiko:** Wenn upstream einen Tag erneut pushed (Versionsdrift, Supply Chain), wird beim Rebuild ein anderer Container deployed — gerade Monitoring sollte stabil sein.
- **Empfehlung:** Beim naechsten Komodo-Redeploy aktuellen Digest auslesen und einpinnen. Vorbereitung fuer Renovate (F-12).
- **Prioritaet:** Nice to have
- **Aufwand:** S
- **Validierung:** `grep '@sha256' monitoring/docker-compose.yml` listet alle 10 Services.
### F-08 · Alert-Regeln zu duenn
- **Kategorie:** Monitoring
- **Fundstelle:** `monitoring/prometheus/alerts.yml`
- **Beobachtung:** Exakt 5 Regeln: ExternalConnectivityDown, EndpointDown, EndpointSlow, DiskAlmostFull, MemoryHighUsage, Traefik5xx. Es fehlen:
- Borg-Lauf-Frische (ueber `node_exporter` textfile collector oder Pushgateway).
- Zertifikatslaufzeit (Blackbox kann `probe_ssl_earliest_cert_expiry`, aber keine Alert-Regel dafuer).
- Container-down-Alert (cAdvisor liefert `container_last_seen`).
- PostgreSQL-Connection-Saturation.
- Loki ingestion-rate / log-volume spike.
- InfluxDB-Disk-Pressure.
- Backup-Job-Failure.
- **Risiko:** Du siehst Probleme nicht, bevor sie weh tun. Cert-Expiry und Borg-Stale sind die schmerzhaftesten Blind-Spots.
- **Empfehlung:** Mindestens zwei Regeln nachziehen: `BorgArchiveStale` (>30 h, Pushgateway oder textfile) und `TLSCertExpiryNear` (<14 Tage). Rest als Folge-Sprint.
- **Prioritaet:** Sollte zeitnah
- **Aufwand:** M
- **Validierung:** Alerts feuern in Test-Bedingung (Borg-Dump-File touch -d backwards).
### F-09 · Komodo-Self-Bootstrap-Problem
- **Kategorie:** GitOps / DR
- **Fundstelle:** `MASTER 13: Komodo Self-Stack Drift-Recovery 2026-05-04`
- **Beobachtung:** Du hattest schon einen Drift-Vorfall (Komodo-Core ran aus `/tmp/*repair.yml`, Mongo-Pfad fehlte). Recovery-ENV liegt als "temporaeres Tier-1-Secret-Material" unter `/mnt/user/appdata/secrets/_komodo_stack_env_recovery_2026-05-04.env` (Doku-Stand).
- **Risiko:** Bei Totalausfall musst du Komodo aus Compose-Datei wiederbeleben, dafuer brauchst du die Stack-ENV mit `KOMODO_SECRET_KEY`, `KOMODO_MONGO_PASSWORD`, `KOMODO_PERIPHERY_PASSKEY` etc., die nur als Komodo Stack ENV existieren. Klassisches Henne-Ei.
- **Empfehlung:** Komodo-Self-Stack aus Komodos eigener Verwaltung herausnehmen und als handgepflegten `docker compose`-Service in `services/komodo-bootstrap/` halten. Stack-ENV als versiegelte Datei unter `/mnt/user/appdata/secrets/` mit deterministischem Restore-Pfad in RESTORE_MATRIX.
- **Prioritaet:** Sollte zeitnah
- **Aufwand:** M
- **Validierung:** Komodo-Stack-Datei lebt im Repo unter `services/`, nicht in Komodos eigener Workspace-Sicht.
### F-10 · Authelia Repo↔Host Drift "by design"
- **Kategorie:** GitOps / Security
- **Fundstelle:** `docs/REPO_MAP.md:48`, `security/authelia/configuration.yml`, `SERVICE_CATALOG 23`
- **Beobachtung:** Repo enthaelt Baseline ohne Secrets, OIDC, Users-DB. Manuelles Merge auf den Host noetig. Es gibt keine automatische Konsistenz-Pruefung.
- **Risiko:** Repo-Aenderung (z. B. neue ACL-Regel) wird gepusht, aber nie auf den Host gemerged -> Drift, Authelia hinkt der Wahrheit hinterher.
- **Empfehlung:** Symmetrisch zum Traefik-Dynamic-Workflow (manueller Sync explizit als Pflicht in WORKFLOW.md). Zusaetzlich ein einfaches Diff-Script `services/authelia-diff.sh`, das `diff` zwischen Repo-Baseline und Host-Datei zeigt, und das im posture-check als Warning auftaucht, wenn die ACL-Sektion differiert.
- **Prioritaet:** Sollte zeitnah
- **Aufwand:** S
- **Validierung:** Script laeuft, posture-check kennt einen neuen Check `authelia_config_drift`.
### F-11 · Immich-Restore noch nie geuebt
- **Kategorie:** Backup / Restore
- **Fundstelle:** `docs/RESTORE_MATRIX.md:49`, Restore-Test-Schedule
- **Beobachtung:** Vaultwarden / Gitea / Paperless haben Mini-Restore-Tests (2026-05-07). Immich nicht. Immich ist der groesste Datentopf (Familien-Fotos).
- **Risiko:** Silent Corruption in Postgres-pgvecto-rs-Daten bemerkst du erst beim Restore-Versuch.
- **Empfehlung:** Eigener Sprint: Immich-Restore-Test gegen `/mnt/user/backups/restore-lab/immich/` mit Sub-Set der `immich.dump` und einem Foto-Sample. Smoke-Test = "10 Fotos im Browser sichtbar nach Restore".
- **Prioritaet:** Sollte zeitnah
- **Aufwand:** M
- **Validierung:** Report unter `/mnt/user/backups/restore-reports/immich-<datum>.json`.
### F-12 · Keine Image-Update-Automatik (Renovate o. ae.)
- **Kategorie:** Wartbarkeit
- **Fundstelle:** Repo-weit; `docs/WORKFLOW.md:282-288` (Image-Versionierung)
- **Beobachtung:** Digest-Pinning ist konsequent, aber rein manuell. Bei ~30 Images bedeutet das, du musst monatlich fuer Patch-Updates manuell Digests auslesen — oder es bleibt liegen.
- **Risiko:** CVE-Patches werden nicht eingespielt, weil "der laufende Stand ist stabil".
- **Empfehlung:** Renovate Bot (self-hosted, gegen Gitea), Gitea-Actions-Runner. Renovate oeffnet PRs fuer Patch-/Minor-Updates; Major-Updates werden mit Labels separiert.
- **Prioritaet:** Sollte zeitnah (oder Nice to have, je nach Schmerz)
- **Aufwand:** M (Initial-Setup ist substantiell)
- **Validierung:** Renovate hat erste PRs in Gitea geoeffnet, du mergst eines davon kontrolliert.
### F-13 · Keine OIDC-SSO fuer User-Apps
- **Kategorie:** Security / UX
- **Fundstelle:** `security/authelia/configuration.yml`, `docs/SECRETS_MAP.md`
- **Beobachtung:** Authelia kann OIDC, ist aber nur als ForwardAuth konfiguriert. Nextcloud, Immich, Grafana, Mealie laufen mit eigenen User-DBs.
- **Risiko:** N getrennte Passwortspeicher, N getrennte 2FA-Setups, keine zentrale Sperrung bei Account-Kompromittierung. Familie hat keinen einfachen Onboarding-Pfad.
- **Empfehlung:** OIDC-Provider in Authelia aktivieren, Nextcloud (via Plugin), Immich (nativer OIDC-Support), Grafana (nativer OIDC-Support) als Clients konfigurieren. Vaultwarden via OIDC-Bridge nur, wenn der Aufwand klar mehrwertig ist — sonst bewusst auslassen.
- **Prioritaet:** Sollte zeitnah (groesster Hebel laut Executive)
- **Aufwand:** L
- **Validierung:** Familienkonto kann sich mit einem Login bei Nextcloud + Immich + Grafana anmelden.
### F-14 · Kein WAF / Bouncer vor oeffentlichen Apps
- **Kategorie:** Security
- **Fundstelle:** `traefik/docker-compose.yml`, oeffentliche Hosts in `docs/REPO_MAP.md:127-152`
- **Beobachtung:** Sechs oeffentliche Apps mit nativer Auth (vault, paperless, mealie, ntfy, git, immich, cloud) ohne IP-Bouncer. Authelia-Regulation greift nur fuer die ForwardAuth-Pfade; Apps mit eigener Auth bekommen den vollen Traffic.
- **Risiko:** Credential-Stuffing-Bot-Wellen treffen die App selbst (Nextcloud, Immich) — Logs sind im Loki, aber kein Sperr-Mechanismus.
- **Empfehlung:** CrowdSec als Bouncer fuer Traefik (`crowdsecurity/traefik-bouncer`). Nutzt Loki/Logs fuer Erkennung, sperrt IPs auf Traefik-Ebene, bevor sie die Apps treffen.
- **Prioritaet:** Sollte zeitnah
- **Aufwand:** M
- **Validierung:** CrowdSec-Dashboard zeigt erste Sperren; Test-Brute-Force gegen `nextcloud.kaleschke.info` wird bei N Versuchen geblockt.
### F-15 · Healthchecks fehlen grossflaechig
- **Kategorie:** Docker / Operations
- **Fundstelle:** Spot-checks: `apps/paperless/docker-compose.yml`, `apps/immich/docker-compose.yml`, `security/authelia/docker-compose.yml`, `traefik/docker-compose.yml` — keiner hat `healthcheck:`-Block.
- **Beobachtung:** Restart-Policy ist ueberall `unless-stopped`, aber ohne Healthcheck kann Docker keinen Crash-Loop bei "Container laeuft, aber App tot" erkennen.
- **Risiko:** Bei Soft-Failure (Postgres-Connection-Pool tot, Authelia haengt im Storage-Connect) merkst du nichts, weil Container "running" bleibt.
- **Empfehlung:** Fuer Tier-1 (Traefik `wget /ping`, Authelia `/api/health`, PostgreSQL `pg_isready`, Komodo `wget /api/healthcheck`) Healthchecks ergaenzen. Fuer Tier-2 schrittweise.
- **Prioritaet:** Sollte zeitnah
- **Aufwand:** M (pro Stack 515 Min)
- **Validierung:** `docker ps` zeigt `(healthy)` neben den Tier-1-Containern.
### F-16 · `infra/redis` als "shared" deklariert, faktisch nur Paperless
- **Kategorie:** Architektur-Konsistenz
- **Fundstelle:** `infra/redis/docker-compose.yml`, `docs/SERVICE_CATALOG.md:31` ("shared Redis Cache")
- **Beobachtung:** Immich, Nextcloud, Mealie haben jeweils eigene Redis-Instanzen. Authelia nutzt bewusst kein Redis (MASTER 13). Paperless nutzt es laut Compose. Effektiv "Paperless-Redis im Frack des shared-Caches".
- **Risiko:** Niedrig. Aber: Wenn du `infra/redis` fuer etwas anderes wegnimmst, denkst du, es kostet Paperless was — und das waere der Fall.
- **Empfehlung:** Doku-Update: SERVICE_CATALOG 31 praezisieren ("dediziertes Redis fuer Paperless; andere Stacks haben eigene Redis-Instanzen"). Architektur bleibt, nur Etikett ehrlich machen. Alternativ: in `apps/paperless/` als App-internes Netz konsolidieren wie Mealie.
- **Prioritaet:** Nice to have
- **Aufwand:** S (Doku) / M (Architektur)
### F-17 · Plex bleibt als Host-Net-Stack
- **Kategorie:** Security / Architektur
- **Fundstelle:** `host-services/plex/docker-compose.yml`, `MASTER 7.4`
- **Beobachtung:** Plex laeuft als Host-Net wegen Discovery/GDM. Dokumentierte Ausnahme.
- **Risiko:** Plex hat hoehere Angriffsoberflaeche als Apps mit Traefik. Plex-Login wurde mehrfach Ziel von Account-Uebernahmen (Plex.tv-Auth-Issues 2024/25). Bei Plex.tv-Kompromittierung greift Authelia nicht — Plex authentifiziert gegen Plex.tv.
- **Empfehlung:** Plex bewusst beibehalten (Doku stuetzt), aber: (a) "Remote Access" in Plex-UI deaktivieren, wenn nur lokal/Tailscale genutzt. (b) Plex-Server nicht in `frontend_net` (waere schaedlich) — bleibt Host-Net, korrekt.
- **Prioritaet:** Nice to have
- **Aufwand:** S
- **Validierung:** Plex `Remote Access` UI zeigt "disabled".
### F-18 · Nextcloud ohne ForwardAuth, ohne dedizierte Brute-Force-Doku
- **Kategorie:** Security
- **Fundstelle:** `apps/nextcloud/docker-compose.yml`, `MASTER 13: Nextcloud-Entscheidung`
- **Beobachtung:** Bewusste Ausnahme (WebDAV/CardDAV). In Nextcloud selbst sind Brute-Force-Schutz, 2FA-Pflicht und App-Passwords konfigurierbar, aber nicht im Repo dokumentiert.
- **Risiko:** Familien-Konto mit schwachem Passwort + Nextcloud oeffentlich = direkter Pfad zu Dokumenten/Fotos.
- **Empfehlung:** `apps/nextcloud/POST_INSTALL.md` mit Pflicht-Checkliste: Brute-Force-Plugin aktiv, 2FA-Provider TOTP installiert, Admin-Account hat 2FA, "Enforce 2FA for admin group" gesetzt. Optional: `OCC`-Befehle als Skript in `services/nextcloud-policy/`.
- **Prioritaet:** Sollte zeitnah
- **Aufwand:** S
- **Validierung:** Test-Login ohne 2FA als Admin schlaegt fehl.
### F-19 · Keine Container-Memory-Limits
- **Kategorie:** Docker / Hardware-Schutz
- **Fundstelle:** Spot-checks aller Composes
- **Beobachtung:** Keine `mem_limit:` oder `deploy.resources.limits` Sektion in Tier-1- oder Tier-2-Stacks.
- **Risiko:** Bei Image-Bug oder Memory-Leak (z. B. Immich-ML, Paperless-OCR-Loop) kann ein Container den Host in OOM treiben. Posture-Check + Docker-Critical-Events sehen das nachher, aber praeventiver waere Container-Limit + Docker-OOM-Kill fuer den richtigen Prozess.
- **Empfehlung:** Fuer Tier-1 (Postgres, Authelia, Traefik, Komodo) sanfte Limits setzen (z. B. Postgres 2 GB, Authelia 256 MB, Traefik 256 MB). Fuer Immich-ML-Container ein hartes Limit, das Verhungern verhindert.
- **Prioritaet:** Nice to have
- **Aufwand:** M
- **Validierung:** `docker stats` zeigt `MEM USAGE / LIMIT` ungleich `unlimited`.
### F-20 · Paperless-DBPass weiter als Stack-ENV (dokumentierte Ausnahme)
- **Kategorie:** Secrets
- **Fundstelle:** `MASTER 13: Secrets in Komodo Stacks`, `docs/SECRETS_MAP.md:25`
- **Beobachtung:** Paperless unterstuetzt `_FILE` nicht fuer DB-Pass. Bewusste Ausnahme.
- **Risiko:** Stack-ENV liegt in Komodo-DB (Mongo), nicht im Repo. Bei Komodo-Mongo-Backup-Luecke fehlt das Passwort beim Restore.
- **Empfehlung:** Erweiterung der Disaster-Recovery-Doku: explizite Liste aller "Stack-ENV-only"-Secrets mit Zeiger, dass `komodo-mongo.archive.gz` fuer Restore zwingend ist, oder die ENV manuell vorgehalten werden muss (in Vaultwarden + externer Notiz).
- **Prioritaet:** Nice to have
- **Aufwand:** S
- **Validierung:** DR-Doc Abschnitt "Stack-ENV-Werte" referenziert konkrete Restore-Pfade.
---
# D. Risiko-Matrix
| Risiko | Bereich | Wahrscheinlichkeit | Auswirkung | Prioritaet | Massnahme |
|---|---|---|---|---|---|
| Borg-Passphrase weg -> Restore unmoeglich | Backup | niedrig | katastrophal | P0 | F-02 analoge Sicherung |
| Hetzner-Account-Verlust -> halbes 3-2-1 | Backup | niedrig-mittel | hoch | P0/P1 | F-03 Zweitziel |
| AdGuard-Admin-Manipulation aus LAN | Security | niedrig | hoch (DNS-Hijack) | P0 | F-01 Bind auf Tailscale |
| Operator-Pwd-Leak -> 2FA fehlt fuer Borg-UI/Code-Server | Security | mittel | hoch | P0 | F-04 2FA-ACL erweitern |
| Komodo-Self-Bootstrap-Failure nach Totalausfall | DR | niedrig | hoch | P1 | F-09 Bootstrap-Datei in `services/` |
| Authelia Repo↔Host Drift unbemerkt | GitOps/Security | mittel | mittel | P1 | F-10 Diff-Check |
| Immich Silent Corruption -> kein Restore-Test belegt | Backup | niedrig | sehr hoch (Familien-Fotos) | P1 | F-11 Restore-Test |
| Cert-Expiry unbemerkt -> Public Apps down | Operations | niedrig | mittel | P1 | F-08 Alert-Regel |
| Nextcloud Brute-Force ohne Bouncer | Security | mittel | mittel-hoch | P1 | F-14 CrowdSec / F-18 Nextcloud-Haerten |
| Image-Update-Stillstand -> CVE bleibt | Security | mittel | mittel | P1 | F-12 Renovate |
| Hermes-Wartungsschuld | Wartbarkeit | hoch | niedrig | P1 | F-06 Entscheidung |
| Repo-Altstaende ueberleben -> Doppel-Deploy | GitOps | mittel | niedrig | P1 | F-05 Cleanup |
| OOM durch unlimitierte Container | Hardware | niedrig | mittel | P2 | F-19 mem_limit |
| Healthcheck-Luecke -> Soft-Failure stumm | Operations | mittel | niedrig | P2 | F-15 Healthchecks |
| Monitoring-Stack ohne Digest-Pin | Reproduzierbarkeit | niedrig | niedrig | P2 | F-07 Digests + Renovate |
| Hardware-SPOF (kein zweiter Host) | Hardware | niedrig | sehr hoch | P3 | Cold-Standby / 2. Host |
---
# E. Zielarchitektur (realistisch fuer privates Homelab)
**Hardware**
- 1× Unraid-Host (bestehend) als Production. CPU mit AVX2/AVX-512 fuer Immich-ML. ≥ 32 GB RAM (fuer 2× Postgres + Immich-ML + Loki/Prometheus + 2 VMs).
- 2× NVMe als BTRFS-RAID1-Cache, sobald Cache-Auslastung > 70 % (STORAGE_LAYOUT 15.3).
- Parity-Disk ≥ groesste Daten-Disk.
- USV mit USB-Steuerung (NUT-faehig: APC Back-UPS RS 700+, Eaton 3S, CyberPower CP1500EPFCLCD). Direkter Shutdown bei Power-Loss.
- Optional: zweiter alter Mini-PC oder NUC als Cold-Standby mit Tailscale, der den letzten Komodo-Bootstrap + Gitea-Mirror tragen kann.
**Netzwerk**
- FritzBox (bestehend) als Router + NAT.
- VLANs nur wenn IoT-WLAN dazukommt (FritzBox-Gast-WLAN reicht fuer Anfang).
- DNS: AdGuard -> Unbound (bestehend). Admin-UI nur Tailscale.
- Tailscale (bestehend): Operator-Pfad. Subnet-Router optional fuer LAN-Devices ueber Tailscale.
**Storage**
- Cache `only` fuer `appdata`, `system`, `domains` (bestehend STORAGE_LAYOUT 4).
- Disk1 (XFS) fuer `services`, `documents`, `photos`, `backups`, `media`, `finance`, `projekte`.
- Externe Wechselplatte (XFS) fuer Cold-Off-Site mit fester monatlicher Rotation.
**Ordnerstruktur (Repo)**
- Beibehalten. Nur Cleanup von Altstaenden (F-05). Naming `kebab-case`-Migration aus STORAGE_LAYOUT 6 schrittweise.
**Docker**
- Compose-only via Komodo (bestehend).
- Digest-Pin fuer alle Images (F-07).
- Healthchecks fuer Tier-1 (F-15).
- Mem-Limits fuer Tier-1 + Immich-ML (F-19).
- App-interne Netze fuer Stack-Isolation (bestehend).
**Reverse Proxy**
- Traefik v3 (bestehend), DNS-Challenge, Wildcard.
- Dynamic Config nur fuer Middlewares, TLS, Dashboard (bestehend).
- CrowdSec-Bouncer (F-14) fuer oeffentliche Apps.
**Auth**
- Authelia als ForwardAuth **und** OIDC-Provider (F-13).
- Nextcloud, Immich, Grafana via OIDC.
- 2FA-Pflicht fuer alle Operator-Dienste (F-04).
- Komodo bewusste Ausnahme (bestehend).
**Backup**
- Borg lokal (`/mnt/user/backups/borg/`) + Borg Hetzner + Wechselplatte.
- Pre-Dump-Hooks (bestehend).
- Borg-Passphrase off-system analog (F-02).
- Restore-Tests automatisiert (F-11 Immich, dann andere via CI).
**Monitoring**
- `monitoring/`-Stack als alleinige Quelle. Altstaende raus (F-05).
- Family-View-Dashboard in Grafana (alles gruen, Backup-Frische, Cert-Tage).
- Alerts ausgebaut (F-08).
- Posture-Check + Docker-Critical-Events -> ntfy `homelab-alerts` (bestehend).
**Dokumentation**
- Aktuelle Doku-Tiefe halten.
- `SERVICES_RECOVERY.md` und `STORAGE_LAYOUT.md` (Active) finalisieren.
- Familien-/User-Onboarding-Doku als eigenes kleines Dokument.
**GitOps**
- Gitea + Komodo (bestehend).
- GitHub-Push-Mirror (umgesetzt, bestaetigt durch MASTER 7.1).
- Renovate-Bot gegen Gitea (F-12).
- Optional: Staging-Branch + zweites Komodo-Ziel in Tailscale-VM (Phase 3).
**Restore**
- RESTORE_MATRIX bleibt fuehrend.
- Restore-Lab unter `/mnt/user/backups/restore-lab/` (bestehend).
- Immich-Restore als Luecke schliessen (F-11).
- Komodo-Self-Bootstrap raus aus Komodo (F-09).
---
# F. Priorisierte Massnahmenliste
| # | Aufgabe | Warum | Kategorie | Prio | Aufwand | Risiko (bei Nicht-Tun) | Mehrwert | Abhaengigkeiten | Validierung |
|---|---|---|---|---|---|---|---|---|---|
| 1 | Borg-Passphrase analog sichern | DR-SPOF schliessen | Backup | P0 | S | katastrophal | DR-Sicherheit | — | Wert ohne Host abrufbar |
| 2 | AdGuard-Admin auf Tailscale-IP binden | LAN-Angriffsflaeche | Security | P0 | S | hoch | LAN-IoT-Haertung | — | `ss -ltnp` zeigt nur Tailscale |
| 3 | 2FA-ACL erweitern (borg, code, files, traefik) | Operator-Pwd-Leak | Security | P0 | S | hoch | 2FA-Coverage | Authelia-TOTP-Setup | Login erzwingt 2FA |
| 4 | Altstaende `ops/grafana-influxdb`+`ops/loki` `git rm` | Repo-Hygiene, kein Re-Deploy | GitOps | P0 | S | niedrig | Klarheit | Tag setzen | Policy-Check clean |
| 5 | Hermes 60-Tage-Deadline | Wartungsschuld | App | P1 | S/L | mittel | Komplexitaet raus | Operator-Entscheidung | Entweder produktiv oder weg |
| 6 | Immich-Restore-Test einrichten | Groesster Datentopf ungeprueft | Backup | P1 | M | hoch | Restore-Vertrauen | Restore-Lab-Pfad | Smoke-Test-Report |
| 7 | Renovate-Bot in Gitea | manuelle Digest-Pflege | Wartung | P1 | M | mittel | Update-Hygiene | Gitea-Runner | erste PR offen |
| 8 | Alert-Regeln (Borg-Frische, Cert-Expiry) | Blind-Spot Operations | Monitoring | P1 | M | mittel | echte Alerts | Pushgateway o. textfile | Alert in Test |
| 9 | Family-View-Dashboard Grafana | Morgens 30 s Check | Monitoring | P1 | M | niedrig | Uebersicht | Datasources stehen | Dashboard funktioniert |
| 10 | Komodo-Self-Bootstrap als `services/komodo-bootstrap/` | Henne-Ei-Problem | GitOps/DR | P1 | M | mittel | sauberer Recovery-Pfad | Komodo-Stack-Doku | Bootstrap aus Repo allein moeglich |
| 11 | Authelia-Drift-Diff-Check in posture-check | Repo↔Host Drift | GitOps | P1 | S | mittel | Drift-Detektion | posture-check-Erweiterung | neuer Check sichtbar |
| 12 | Healthchecks Tier-1 | Soft-Failure-Erkennung | Docker | P1 | M | niedrig | Self-Healing-Trigger | — | `docker ps` zeigt `healthy` |
| 13 | CrowdSec-Bouncer vor Traefik | oeffentliche Apps schuetzen | Security | P1 | M | mittel | Brute-Force-Stop | Traefik-Middleware | Test-IP wird geblockt |
| 14 | Nextcloud-Haertung dokumentieren | Public App + native Auth | Security | P1 | S | mittel | App-Haertung | Plugin-Install | 2FA-erzwingt |
| 15 | Authelia OIDC-Provider + Nextcloud/Immich/Grafana | SSO, Familien-Onboarding | Security/UX | P2 | L | niedrig | hoher Mehrwert | Authelia-OIDC-Setup | SSO-Login funktioniert |
| 16 | Immich-Smartphone-Auto-Backup fuer Familie | Killer-App fuer Familie | App | P2 | S | niedrig | hoher Mehrwert | — | Familien-Foto in Immich |
| 17 | Monitoring-Stack Digests + Renovate-Pin | Reproduzierbarkeit | GitOps | P2 | S | niedrig | konsistent | Renovate optional | `@sha256` an allen Images |
| 18 | Mem-Limits Tier-1 + Immich-ML | OOM-Schutz | Hardware/Docker | P2 | M | niedrig | Schutz | — | `docker stats` zeigt Limits |
| 19 | Off-Site-Zweitziel (rsync.net o. Wechselplatte) | Single-Provider | Backup | P2 | M | mittel | 3-2-1 echt | Borg-Config | beide Repos < 7d |
| 20 | Staging-Branch + 2. Komodo-Ziel | Risiko-Aenderung testbar | GitOps | P3 | L | niedrig | Reife | 2. VM/Host | Deploy auf staging klappt |
---
# G. Refactoring-Plan (Sprints)
## Sprint 0 — Sicherheitsnetz und Ist-Zustand sichern (1 Tag)
- **Ziel:** Du kannst danach im schlimmsten Fall alles, was du jetzt aenderst, sicher zurueckrollen.
- **Aufgaben:**
- Git-Tag `audit-2026-05-25-baseline` auf `master` setzen und nach Gitea + GitHub-Mirror pushen.
- Borg-Lauf manuell ausloesen ("freshen up"), Erfolg im Log dokumentieren.
- Aktuellen Komodo-Mongo-Dump verifizieren (`mongorestore --dry-run`).
- `docs/MIGRATION_LOG.md` Eintrag "Audit-Sprint-Start".
- **Erfolgskriterium:** Tag pushed, Borg-Lauf gruen, Mongo-Dump verifiziert.
- **Validierung:** `git fetch && git tag | grep audit-2026-05-25-baseline` und `ls /mnt/user/backups/borg/dumps/latest/` zeigt fresh.
- **Rollback:** N/A (rein additiv).
- **Risiko bei Nichtumsetzung:** Keine Notbremse fuer Sprint 1.
## Sprint 1 — Offensichtliche Risiken entschaerfen (1 Woche)
- **Ziel:** P0-Risiken weg, Repo-Hygiene wieder gruen.
- **Aufgaben (in dieser Reihenfolge):**
1. F-02 Borg-Passphrase analog sichern (off-system, kein Code-Change).
2. F-01 AdGuard-Admin-Port auf Tailscale-IP — Edit `host-services/Adguard/docker-compose.yml:16`.
3. F-04 Authelia ACL erweitern (`two_factor` fuer borg, code, files, traefik) — Edit `security/authelia/configuration.yml` + Host-Sync.
4. F-05 Altstaende `ops/grafana-influxdb/`, `ops/loki/` entfernen — `git rm`, MIGRATION_LOG.
5. Policy-Check-Warnings `SEC001` (ddns-updater, scrutiny) aufraeumen.
- **Erfolgskriterium:** Policy-Check 0 Warnings fuer SEC001, AdGuard-Admin nur via Tailscale, 2FA-Login auf borg.kaleschke.info.
- **Validierung:** Policy-Check-Report; Browser-Test mit/ohne 2FA-Cookie.
- **Rollback:** Commit-Revert pro Block.
## Sprint 2 — GitOps-Robustheit (12 Wochen)
- **Ziel:** Self-Bootstrap-Problem entschaerft, Drift-Detektion automatisiert.
- **Aufgaben:**
1. F-09 Komodo-Bootstrap-Compose nach `services/komodo-bootstrap/` extrahieren + dokumentierter Standalone-Restore-Pfad.
2. F-10 Authelia-Drift-Diff in posture-check ergaenzen.
3. F-11 Immich-Restore-Test einrichten (analog zu vaultwarden/gitea/paperless).
4. F-06 Hermes-Entscheidung mit 60-Tage-Deadline schriftlich.
- **Erfolgskriterium:** Komodo laesst sich aus Repo allein wiederherstellen. Posture-Check zeigt `authelia_config_drift: false`. Immich-Restore-Report unter `/mnt/user/backups/restore-reports/`.
- **Validierung:** Trockenversuch (Komodo-Container stoppen, `docker compose up -d` aus `services/komodo-bootstrap/`).
- **Rollback:** Bootstrap-Verzeichnis loeschen, Komodo-Self-Stack wie vorher.
## Sprint 3 — Backup & Restore belastbar machen (23 Wochen)
- **Ziel:** 3-2-1 echt, Restore-Tests breiter, Stack-ENV im DR-Pfad.
- **Aufgaben:**
1. F-03 Zweitziel: Wechselplatten-Rotation dokumentieren ODER zweites Borg-Repo (rsync.net / BorgBase EU2).
2. F-20 Stack-ENV-Liste in DR-Doc explizit machen (Restore-Reihenfolge).
3. Borg-Verifikation Cron fuer `borg check --repository-only` weekly (STORAGE_LAYOUT 8.4).
4. Quartalsweise End-to-End-Restore-Drill in Schedule aufnehmen.
- **Erfolgskriterium:** Beide Off-Site-Ziele < 7 Tage alt; DR-Doc enthaelt "ENV-Restore-Reihenfolge".
- **Validierung:** `borg list` gegen beide Repos.
## Sprint 4 — Monitoring & Alerting ausbauen (2 Wochen)
- **Ziel:** Sichtbarkeit auf das, was wirklich weh tut.
- **Aufgaben:**
1. F-08 Alert-Regeln: `BorgArchiveStale`, `TLSCertExpiryNear`, `ContainerDown`, `PostgresConnSaturation`.
2. F-15 Healthchecks fuer Traefik, Authelia, Postgres, Komodo, Gitea.
3. F-07 Digest-Pin in `monitoring/docker-compose.yml`.
4. Family-View-Dashboard in Grafana (1 Panel: Service-Up, 1 Panel: Backup-Frische, 1 Panel: Cert-Tage, 1 Panel: Disk-Fuellung).
- **Erfolgskriterium:** Family-View zeigt alles gruen; Cert-Alert feuert in Test (Datum vorgespult).
- **Validierung:** Dashboard sichtbar unter `monitoring.kaleschke.info/d/family-view`.
## Sprint 5 — Auth-Konsolidierung & Frontdoor-Haertung (34 Wochen)
- **Ziel:** SSO fuer die Familie, Brute-Force-Bouncer vor oeffentlichen Apps.
- **Aufgaben:**
1. F-13 Authelia OIDC-Provider aktivieren.
2. Nextcloud OIDC-Plugin + Test-Login.
3. Immich OIDC + Test-Login.
4. Grafana OIDC + Test-Login.
5. F-14 CrowdSec-Bouncer vor Traefik.
6. F-18 Nextcloud-Haertung-Dokument + 2FA-Pflicht.
- **Erfolgskriterium:** Familien-Konto loggt sich mit einem Login bei drei Apps ein; CrowdSec sperrt Test-IP nach N fehlerhaften Versuchen.
- **Validierung:** OIDC-Sequenz im Browser ohne Eingabe-Wiederholung; CrowdSec-Dashboard zeigt Sperre.
## Sprint 6 — Automatisierung und Nerd-Level (laufend)
- **Ziel:** Image-Update-Pipeline, optional Staging.
- **Aufgaben:**
1. F-12 Renovate-Bot gegen Gitea.
2. F-19 Mem-Limits Tier-1.
3. Restore-Test-CI via Gitea Actions (P3).
4. Optional: Staging-Branch + zweites Komodo-Ziel in Tailscale-VM (P3).
5. Optional: Firefly III / Actual Budget fuer `/mnt/user/finance`.
- **Erfolgskriterium:** Renovate-PRs erscheinen woechentlich; mindestens ein automatisches Patch-Update gemerged.
---
# H. Fehlende Informationen
> Nur, was den Audit konkret schaerfer machen wuerde.
| Frage | Warum | Bereich | Kommando / Datei |
|---|---|---|---|
| CPU-Modell, RAM-Groesse, Mainboard | Hardware-Bewertung, OOM-Risiko, Immich-ML-Eignung, AVX-Verfuegbarkeit | Hardware | `cat /proc/cpuinfo \| grep -E 'model name\|flags'`, `free -h`, `dmidecode -t baseboard \| head -20` |
| USV vorhanden? Modell? | DR-Beurteilung Power-Loss, Shutdown-Pfad | Hardware/DR | physische Sichtpruefung; `apcaccess` falls APC mit NUT |
| Stromverbrauch idle / Last | Betriebskosten, Sizing | Hardware | Smartmeter / Tibber-API |
| NIC-Speed (1 GbE? 2.5 GbE?) | Backup-Durchsatz, Plex-Streaming | Netzwerk | `ip -br link`, `ethtool eth0` |
| Disk-Inventar (Anzahl, Modelle, Alter) | Storage-Health, Replacement-Plan | Storage | `lsblk -o NAME,SIZE,MODEL,SERIAL`, Scrutiny-UI |
| Aktueller Cache-Fuellstand | Wann zweite NVMe? | Storage | `df -h /mnt/cache` |
| FritzBox-Modell + Firmware | Net-Sicherheit, VLAN-Faehigkeit | Netzwerk | FritzBox-UI / `fritzconnection` |
| Tatsaechlich genutzte Plex- vs. ungenutzte App | Konsolidierungs-Belege | App-Landschaft | Plex-Server-Logs, ggf. Glances-Container-CPU pro Stack |
| Existiert `/mnt/user/finance/`-Share schon? | Ist Firefly-Vorbereitung trivial? | Storage | `ls /mnt/user/finance/` |
| Authelia Live-User-DB-Tiefe (Anzahl User, 2FA-Status) | 2FA-Coverage-Bewertung | Security | `cat /mnt/user/appdata/authelia/config/users_database.yml` (nur Strukturansicht, keine Hashes hier zitieren) |
| Komodo-Mongo-Dump letzter Integrity-Check | F-09-Vorbereitung | Backup | `mongorestore --dry-run --archive=komodo-mongo.archive.gz --gzip` |
| Aktuelle Cert-Restlaufzeit | F-08-Test-Vorbereitung | Operations | `openssl s_client -connect git.kaleschke.info:443 -servername git.kaleschke.info < /dev/null \| openssl x509 -noout -dates` |
---
# I. Pruefkommandos (Linux / Unraid / Docker / Windows)
> Strukturiert nach Bereich. Sicher zum Ausfuehren am Host.
### Hardware
```bash
# CPU + Flags (AVX fuer Immich-ML)
cat /proc/cpuinfo | awk '/model name|flags/ {print; if(/flags/) exit}'
# RAM
free -h
dmidecode -t memory | grep -E "Size|Speed" | head -20
# Mainboard
dmidecode -t baseboard | head -20
# PCI / SATA / NVMe
lspci
nvme list
lsblk -o NAME,SIZE,MODEL,SERIAL,FSTYPE,MOUNTPOINT,VENDOR
# SMART
smartctl -a /dev/nvme0n1 | head -40
smartctl -a /dev/sdb | head -40
# Stromverbrauch (Unraid Plugin oder ipmitool falls IPMI)
sensors | head -30
```
### Filesystem / Storage / Mounts
```bash
# Filesystem-Typen (Hard Rule 12.1)
findmnt -no FSTYPE /mnt/cache /mnt/disk1 /boot
mount | grep -E "ntfs3|fuseblk" # darf leer sein
# Share-Settings
ls -la /boot/config/shares/
# Cache-Fuellstand
df -h /mnt/cache /mnt/disk1 /mnt/user
du -sh /mnt/user/appdata/* | sort -hr | head -20
```
### Docker
```bash
# Container-Inventur
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | sort
docker ps -a --filter "status=exited" --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
# Netzwerke
docker network ls
docker network inspect frontend_net | jq '.[0].Containers | keys'
docker network inspect backend_net | jq '.[0].Internal'
# Volumes ohne Container (Waisen)
docker volume ls -qf dangling=true
# Effektive Ports
ss -ltnp | sort -k4
# Healthchecks
docker ps --format "{{.Names}}\t{{.Status}}" | grep -E "healthy|unhealthy|starting"
```
### Security
```bash
# Privileged-Container und Docker-Socket-Mounts
for c in $(docker ps -q); do
docker inspect "$c" --format '{{.Name}}: priv={{.HostConfig.Privileged}}; sock={{range .HostConfig.Binds}}{{println .}}{{end}}'
done | grep -E "priv=true|docker.sock"
# Direkte Host-Ports
docker ps --format "{{.Names}}: {{.Ports}}" | grep -v "^[^:]*: $"
# Secret-Datei-Rechte
ls -la /mnt/user/appdata/secrets/
stat -c "%a %n" /mnt/user/appdata/secrets/*.txt
```
### Backup / Restore
```bash
# Borg-Frische
ls -lat /mnt/user/backups/borg/dumps/latest/ | head
find /mnt/user/backups/borg/dumps/latest -mmin +1440 -type f # aelter 24h
# Borg-Repo (Passphrase per File)
export BORG_PASSPHRASE=$(cat /mnt/user/appdata/secrets/borg_repo_passphrase.txt)
borg list ssh://... --short | tail -5
borg info ssh://... ::Taegliche-Sicherung-2026-05-25T05:52:44.157
# Posture-Check
cat /mnt/user/services/posture-check/last.json | jq '.warning_count, .critical_count'
```
### Netzwerk / DNS
```bash
# Tailscale
tailscale status
# DNS auf AdGuard testen
dig @127.0.0.1 git.kaleschke.info
dig @127.0.0.1 example.com # Unbound-Recursion
# Cert-Restlaufzeit
for h in vault git immich cloud paperless mealie ntfy; do
echo -n "$h.kaleschke.info: "
openssl s_client -connect ${h}.kaleschke.info:443 -servername ${h}.kaleschke.info </dev/null 2>/dev/null \
| openssl x509 -noout -dates 2>/dev/null
done
```
### GitOps-Konsistenz
```bash
# Komodo Stack-Workspace vs. Repo
cd /mnt/user/services/stacks/<stackname>
git rev-parse HEAD
git status --short
# Webhook-Liveness
docker logs komodo-core 2>&1 | grep -i "webhook\|deploy" | tail -20
```
### Windows (lokal)
```powershell
# Repo-Status
git status --short
git fetch origin
git log origin/master..HEAD --oneline # ungepushte Commits
git log HEAD..origin/master --oneline # ungepullte Commits
# Policy-Check
.\ops\policy-checks\check_repo.ps1
# Restore-Freshness
.\ops\restore-tests\check-restore-freshness.ps1
```
---
# J. Community- / Best-Practice-Abgleich
> Nur fuer die Architekturentscheidungen, bei denen der Markt eindeutig ist oder Gegenpositionen relevant sind.
| Entscheidung | Markt-Best-Practice | Stuetzende Quellen | Bewertung |
|---|---|---|---|
| Traefik mit Docker-Labels statt File-Provider | Standard in Selfhosted (siehe `awesome-selfhosted-docker`, Smarthome-Beginner Templates) | Traefik-Doc v3 docs.traefik.io/providers/docker | passt zu MASTER 13 (Wechsel 2026-03-28) |
| DNS-Challenge mit Cloudflare statt HTTP-01 | Standard fuer Wildcard und reduzierte Angriffsflaeche | acme.sh / lego docs | passt, korrekt |
| Authelia ForwardAuth statt Authentik | Authelia ist leichtgewichtiger, Authentik maechtiger; beide valide | r/selfhosted-Konsens 2024-25 | Authelia richtig fuer Single-Family-Setup |
| Authelia ohne Redis-Session-Backend | Markt-Standard ist mit Redis; deine Vereinfachung ist begruendet (MASTER 13 2026-05-04) | Authelia-Doc | Trade-off klar; Bewertung: vertretbar fuer Homelab |
| Komodo statt Portainer/Dockge | Komodo ist neuer (2024), Dockge etabliert, Portainer kommerziell | Selfh.st 2025 Comparison | Komodo legitim, mehr GitOps-nativ als Dockge |
| Borg statt Restic/Kopia | Borg ist klassische Wahl fuer Linux-Backup mit Deduplikation; Kopia/Restic gewinnen mit Multi-Backend | r/datahoarder, ServeTheHome 2024 | Borg passt zu Unraid-Stack; bewusste Vereinfachung |
| Glance statt Homepage als Single-Dashboard | beide auf Augenhoehe; Homepage etablierter, Glance moderner, schneller, mit Live-Widgets | github.com/glanceapp/glance vs. github.com/gethomepage/homepage | Glance legitim; Bewertung: deine Wahl ist verteidigbar |
| Immich nicht hinter Authelia ForwardAuth | offizielle Immich-Doku raet bei nativer App-Auth davon ab, weil Sync-Clients OIDC oder direkte Auth brauchen | immich.app/docs/administration/reverse-proxy | korrekt; OIDC spaeter (F-13) ist der Weg |
| Nextcloud klassisch statt AIO | NC-AIO ist offizielle Empfehlung fuer Neuaufbau, klassisch hat mehr Flexibilitaet fuer GitOps | NC-Blog 2024 | bewusste Ausnahme MASTER 13; vertretbar, da GitOps-Anbindung wichtiger |
| Single-Host + Borg statt Proxmox-Cluster + ZFS-Send | fuer Familien-Homelab ist Cluster Overkill | r/homelab, LTT-Forum | Single-Host korrekt; Cold-Standby (Sprint 6) ist die richtige naechste Stufe |
| AdGuard + Unbound statt Pi-hole | aequivalent; AdGuard hat moderne UI, Unbound recursion | gowri/networkchuck Tutorials 2024 | passt |
| Posture-Check als Skript statt Goss/InSpec | fuer Single-Host pragmatisch | Goss ist maechtiger, aber Overkill | Skript-Loesung legitim |
| Renovate gegen Gitea | mehrere Erfahrungsberichte in Gitea-Issues + Renovate-Docs | docs.renovatebot.com/modules/platform/gitea/ | Standard-Pfad |
| CrowdSec vor Traefik | starker Trend 2024-25 in Selfhosted-Community | crowdsec.net/blog, Marius-Hosting-Tutorials | sinnvolle Haertung |
**Gegenpositionen, die du kennen solltest:**
- **"Authelia OIDC ist kompliziert, lieber Authentik."** Korrekt, wenn du auch B2B-SAML brauchst. Fuer reine Familien-OIDC ist Authelia leichter wartbar.
- **"CrowdSec laedt zentrale Reputation-Listen -> Privacy-Bedenken."** Stimmt, du kannst Local-Only-Mode fahren. Fuer Homelab egal.
- **"Renovate-Bot erzeugt Laerm."** Mit Group/Schedule-Rules zaehmbar. Wert > Laerm.
- **"Komodo ist zu jung."** Gegenargument: du benutzt es seit Q1/2026 produktiv, Major-Inzidenz war beherrschbar. Der Wechsel zurueck zu Portainer/Dockge waere hoehere Kosten als der Reifegrad-Nachteil.
---
# K. Endziel — "Nerd-Level Homelab"
So sieht dein Homelab aus, wenn es wirklich auf Senior-Level ist:
**Betrieb im Alltag**
- Morgens 30 Sekunden auf `monitoring.kaleschke.info`: Family-View zeigt 7 Tier-1-Services gruen, Backup-Job in der Nacht hat 100 % Files erfasst, alle Certs > 30 Tage, Disk < 80 %.
- Push-Benachrichtigung auf dem Handy nur, wenn wirklich etwas brennt (Posture-Check critical, Borg > 30 h, Endpoint down ≥ 8 Min, Cert < 14 Tage).
- Familienmitglieder loggen sich mit einem Login bei Nextcloud, Immich, Mealie ein. 2FA per TOTP-App, kein App-by-App-Passwortzettel mehr.
**Updates**
- Renovate oeffnet woechentlich 510 Pull-Requests in Gitea fuer Patch-Versionen. Du siehst sie im Web-UI, pruefst Release Notes, klickst Merge. Komodo deployt automatisch via Webhook. Smoke-Test in der naechsten Glance-Seite.
- Major-Updates kommen separat mit Label `major`, behandelst du in einem geplanten Slot mit Restore-Snapshot davor.
**Backups**
- Borg lokal alle 6 h, Borg Hetzner taeglich, Wechselplatte monatlich. Borg-Passphrase auf Papier im Bankschliessfach. Alle drei Ziele juenger als 36 h Alert-Schwelle.
- Pre-Dump-Hooks erzeugen 15 konsistente Dump-Artefakte pro Lauf, automatische Posture-Check-Vor-Hook bricht Backup ab, wenn FS oder Mount sich veraendert haben.
**Restore**
- Monatlicher Mini-Restore-Test fuer Vaultwarden/Gitea/Paperless/Immich nach `/mnt/user/backups/restore-lab/<dienst>/` laeuft automatisiert per Gitea Actions, Erfolgs-Report landet als Datei + ntfy-Info.
- Quartalsweise End-to-End-Drill: ein kompletter Stack restauriert, App startet, Smoke-Test passt, Doku validiert. Komodo-Bootstrap-Pfad ist getestet.
- Im Ernstfall folgst du `docs/DISASTER_RECOVERY.md` Phase 05 und bist nach < 8 h wieder im Vollbetrieb. Repo-Bootstrap aus GitHub-Mirror, Stacks in Stufen 15, Verifikation pro Stufe.
**Monitoring**
- Prometheus + Loki + Grafana + Alertmanager-ntfy-Bridge. ~15 Alert-Regeln, alle in `alerts.yml` versioniert.
- Family-View, Host-Overview, Containers+Logs, Traefik-Standalone, Backup-Frische, Cert-Tage. Sechs Dashboards mehr braucht keine Familie.
- Loki sammelt 30 Tage Logs aus allen Containern via Promtail. cAdvisor + node-exporter liefern Container- und Host-Metriken. Blackbox testet oeffentliche Endpoints alle 60 s.
**Security**
- Authelia OIDC fuer Nextcloud, Immich, Grafana, Mealie. ForwardAuth fuer Operator-UIs mit 2FA-Pflicht ab Tier-2.
- CrowdSec sperrt Brute-Force-IPs auf Traefik-Ebene bevor sie die Apps treffen.
- AdGuard-Admin nur via Tailscale. Operator-Pfad ausschliesslich Tailscale.
- Authelia-Repo-Baseline und Host-Config sind per Diff-Check im posture-check abgesichert.
- Secrets-Mounts mode 600, Borg-Passphrase analog.
**Dokumentation**
- SERVICE_CATALOG, RESTORE_MATRIX, DISASTER_RECOVERY, STORAGE_LAYOUT, SECRETS_MAP, WORKFLOW, GITOPS_DRIFT_RUNBOOK, ALERTING_MAP, HOMELAB_ARCHITECTURE_MASTER bleiben aktuelle Single-Source-of-Truth.
- `services/komodo-bootstrap/` loest das Henne-Ei. `SERVICES_RECOVERY.md` ist final, nicht Draft.
- Familien-Onboarding-Doku als Markdown: "So nutzt du Nextcloud-Web", "So aktivierst du Immich-Foto-Backup auf dem Handy", "So loggst du dich neu per 2FA ein".
**Taegliche Nutzung**
- Familie scannt Briefe per ASN-Barcode in `scans_inbox/`, Paperless tagged via paperless-gpt automatisch, alles durchsuchbar.
- Immich erfasst Smartphone-Fotos aller Familienmitglieder automatisch, Familie blaettert per Web/App, Tagging per ML.
- Nextcloud traegt Kalender, Kontakte, geteilte Familienordner per WebDAV/CardDAV — kein Google/Apple-Lock-In.
- Mealie speichert Rezepte, Einkaufsliste auf dem Handy.
- Vaultwarden ist der einzige Passwort-Tresor der Familie; Familien-Organisation aktiv.
- Plex streamt Heim-Medien an alle Endgeraete.
- ntfy schickt dir Vorfaelle aufs Handy — und sonst nichts.
- Optional: Firefly III fuer die Familien-Finanzen, Ecowitt-Wetter-Dashboard, Home-Assistant-Automationen fuer Strom-Eigenverbrauch.
**Was bewusst weggelassen ist**
- Kein Kubernetes. Komodo + Compose reicht.
- Kein zweiter Medienserver neben Plex (Jellyfin-Entscheidung 2026-05-25).
- Kein zweites Dashboard neben Glance (Homepage-Entscheidung 2026-05-25).
- Kein Uptime-Kuma neben Blackbox (Entscheidung 2026-05-25).
- Kein Hermes-Agent, wenn er bis 2026-07-25 keinen klaren Alltagsnutzen liefert.
- Kein BentoPDF/paperless-gpt 24/7, wenn nicht aktiv genutzt.
- Kein Self-Stack-Komodo (durch `services/komodo-bootstrap/` ersetzt).
---
## Schlussbemerkung
Das Setup ist naeher an Senior-Reife als an Bastel-Niveau. Der groesste Hebel der naechsten drei Monate ist **Konsolidieren statt erweitern** (Hermes-Entscheidung, Altstaende raus, Auth-SSO, Off-Site-Diversitaet), kombiniert mit der einen Aktivierung, die das Setup vom Operator-Tool zum **Familien-Tool** macht: Immich-Smartphone-Backup fuer alle.
Das vorhandene 2026-05-23-Audit hat die richtigen Sprintziele bereits identifiziert. Diese externe Audit-Sicht ergaenzt:
- **2FA-Pflicht auf Tier-1-Operator-UIs** (F-04) — fehlt in der Bewertung 2026-05-23 in dieser Klarheit.
- **Healthcheck-Luecke** (F-15) und **fehlende Mem-Limits** (F-19) — operative Detail-Findings, die in der strategischen Bewertung nicht auftauchen.
- **Komodo-Self-Bootstrap als konkreter Code-Vorschlag** (F-09) statt nur als Risiko-Erwaehnung.
- **Authelia-Drift-Detection automatisieren** (F-10) statt nur "manuell merge".
- **Monitoring-Stack ohne Digest-Pin** (F-07) — Inkonsistenz mit der eigenen Image-Pinning-Disziplin.
- **`infra/redis` ist faktisch nicht shared** (F-16) — Etikett-Realitaet-Drift.
- **Alert-Regeln deutlich zu duenn** (F-08) — Sichtbarkeitsluecken bei Cert/Borg/Container-Down.
Wenn Sprint 13 in 46 Wochen sitzen, bist du auf einer 1-Note. Wenn dann Sprint 45 in weiteren 68 Wochen kommen, hat die Familie ein echtes Self-Hosting-System, kein "Container-Sammlung im Keller". Das ist der Unterschied, den der Audit-Auftrag adressiert.
+95
View File
@@ -0,0 +1,95 @@
# Audit TODO 2026-05-25
Quelle: `docs/AUDIT_2026-05-25.md`
Status: Arbeitsliste fuer die Umsetzung. Authelia-2FA/OIDC bleibt bewusst spaet, weil die Ziel-Policy noch nicht final entschieden ist.
## Leitplanken
- Keine Authelia-2FA-ACL-Aenderungen in den ersten Sprints.
- Keine Live-riskanten Bind-/Port-Aenderungen ohne vorher erfasste Host-Werte, insbesondere Tailscale-IP.
- Erst Inventar und Baseline, dann Aenderungen.
- Jede produktive Aenderung bekommt Validierung und Rollback-Hinweis.
## Naechster Startpunkt 2026-05-26
Kontext bewusst gesichert, bevor weitere Live-Aenderungen passieren:
1. USV-Entscheidung treffen: aktuell ist keine funktionierende USV-Abschaltung nachgewiesen.
2. Gitea-Bundle-/Mirror-Mechanik und Borg-Passphrase-Offsite-Sicherung entscheiden.
3. Authelia 2FA/OIDC weiterhin nicht anfassen; das bleibt bewusst der letzte Block.
## Sprint 0 - Inventar und Baseline
| Status | Aufgabe | Ergebnis |
|---|---|---|
| erledigt | Hardware-Inventar ausfuellen | CPU, RAM, Mainboard, BIOS, NIC, Controller, Disks, SMART und Capacity-Baseline erfasst; USV ist als nicht validiert dokumentiert |
| in Arbeit | Netzwerk-Inventar ausfuellen | Host-IP, Gateway, Tailscale-IP und AdGuard-Bind erfasst; Router-/VLAN-Details offen |
| erledigt (Baseline) | Externe Abhaengigkeiten dokumentieren | `docs/EXTERNAL_DEPENDENCIES.md` enthaelt bekannte Provider, Kritikalitaet, Ausfallplaene; Account-Recovery-Codes/Zahlungswege bleiben Off-Repo-Operatorcheck |
| erledigt (Baseline) | Services-Recovery-Pfade beschreiben | `docs/SERVICES_RECOVERY.md` enthaelt Gitea-/Komodo-/Secrets-Sonderpfade; Gitea-Bundle-/Mirror-Mechanik bleibt als Umsetzungsentscheidung offen |
| erledigt | Baseline-Tag setzen | `audit-2026-05-25-baseline` ist lokal und remote vorhanden |
| erledigt | Policy-Check neu ausfuehren | SEC001-Warnings aus altem Report sind nicht mehr aktuell |
## Sprint 1 - Nicht-kontroverse Sicherheits- und Repo-Hygiene
| Status | Aufgabe | Ergebnis |
|---|---|---|
| offen | Borg-Passphrase analog sichern | Passphrase ist ohne Host/Vaultwarden wiederherstellbar |
| erledigt (repo) | AdGuard Admin-Bind vorbereiten | Tailscale-IP `100.80.98.33` erfasst, Compose-Soll geaendert |
| erledigt | AdGuard Admin-Port auf Tailscale-IP binden | Live validiert: `ss -ltnp` zeigt `100.80.98.33:8082`, DNS auf Port 53 funktioniert, LAN-Zugriff auf `192.168.178.58:8082` schlaegt fehl |
| erledigt | Alte Monitoring-Verzeichnisse entfernen | `ops/grafana-influxdb/` und `ops/loki/` sind aus dem aktiven Repo entfernt; Rollback erfolgt ueber Git-Historie |
| erledigt | Komodo/Gitea-Restdrift bereinigen | alter Komodo-Stack `grafana` ist inert und ohne Repo-Pfad/Webhook; Gitea-Hook `35` und `komodo`-Self-Hook `11` sind inaktiv; aktive Gitea-Hooks haben keine Fehlstatus |
| erledigt | Policy-Warnings triagieren | Plex Host-Netz und digest-gepinnte mutable Tags sind dokumentierte Info-Ausnahmen; `monitoring-influxdb3-core` als Root-Ausnahme bleibt bewusst als Warning sichtbar |
## Sprint 2 - Storage und Recovery verbindlich machen
| Status | Aufgabe | Ergebnis |
|---|---|---|
| offen | `docs/STORAGE_LAYOUT.draft.md` finalisieren | Datei wird als `docs/STORAGE_LAYOUT.md` Active gefuehrt |
| offen | Disk- und Share-TBDs eintragen | Modelle, Groessen, Seriennummern, Filesysteme und Cache-Settings sind dokumentiert |
| offen | Gitea-Repo-Mirror-Mechanik definieren | Mirror fuer `/mnt/user/services/gitea/git/repositories/` mit Frequenz <= 6 h ist spezifiziert |
| offen | Komodo-Bootstrap-Pfad beschreiben | Kaltstart ohne laufendes Komodo ist dokumentiert |
| offen | Immich-Restore-Test planen | Testumfang, Datenpfade und Smoke-Test-Kriterium stehen fest |
## Sprint 3 - Restore und Monitoring
| Status | Aufgabe | Ergebnis |
|---|---|---|
| offen | Immich-Restore-Test implementieren | Restore-Report landet unter `/mnt/user/backups/restore-reports/` |
| offen | Borg-Stale-Alert bauen | Alarm feuert, wenn Borg-Archiv zu alt ist |
| offen | TLS-Cert-Expiry-Alert bauen | Alarm feuert bei Restlaufzeit unter Schwellwert |
| offen | Container-Down-Alert bauen | Unerwartet fehlende Container werden sichtbar |
| offen | Family-View Dashboard definieren | Uptime, Backup-Frische, Cert-Tage, Disk-Fuellung auf einer Seite |
## Sprint 4 - Familien- und Betriebsdoku
| Status | Aufgabe | Ergebnis |
|---|---|---|
| offen | Familien-Onboarding schreiben | Nextcloud, Immich, Vaultwarden, 2FA-Verlust, Ausfallverhalten kurz erklaert |
| erledigt (Baseline) | Capacity-/Lifecycle-Review erstellen | Cache 6 %, Array/User-Shares 33 %, lokale Backups 2.2G; externe Backup-/Cold-Storage-Groessen bleiben offen |
| offen | USV-Test oder USV-Entscheidung | Power-Loss-Verhalten ist bekannt und dokumentiert |
## Sprint 5 - Auth und Frontdoor, bewusst zuletzt
| Status | Aufgabe | Ergebnis |
|---|---|---|
| geparkt | Authelia 2FA fuer Operator-UIs erweitern | Erst nach finaler Policy-Entscheidung |
| geparkt | Authelia OIDC fuer Apps pruefen | Erst nach Familien-/Client-Auswirkungsanalyse |
| geparkt | CrowdSec vor Traefik pruefen | Nach stabiler Auth-/Monitoring-Basis |
## Offene Host-Werte
Diese Werte muessen am Unraid-Host erhoben werden, bevor die entsprechenden Aenderungen sauber umgesetzt werden:
```bash
hostname
cat /proc/cpuinfo | awk '/model name|flags/ {print; if(/flags/) exit}'
free -h
dmidecode -t baseboard | head -30
ip -br link
tailscale ip -4
lsblk -o NAME,SIZE,MODEL,SERIAL,FSTYPE,MOUNTPOINT,VENDOR
df -h /mnt/cache /mnt/disk1 /mnt/user
smartctl -a /dev/nvme0n1 | head -80
smartctl -a /dev/sdb | head -80
```
+80
View File
@@ -0,0 +1,80 @@
# Audit Report - KalliLab CORE
Datum: 2026-05-16
Gepruefte Compose-Dateien: 30
## Kritische Befunde
Keine kritischen Befunde im Repo-Sollzustand gefunden.
- Keine Datenbank und kein Cache haengt in `frontend_net`.
- Keine produktive Compose-Datei ohne explizites `networks:`-Feld gefunden.
- Keine produktive `.env`- oder `stack.env`-Datei ohne `.example`-Suffix im Repository gefunden.
- Keine Klartext-Passwoerter, Tokens oder API-Keys in Compose-`environment:`-Bloecken gefunden.
## Mittlere Befunde
- Docker-Socket ausserhalb der im Audit-Prompt genannten Allowlist: `traefik` mountet `/var/run/docker.sock:/var/run/docker.sock:ro` in `traefik/docker-compose.yml:34`. Der Socket ist fuer den Docker-Provider fachlich nachvollziehbar, aber in `HOMELAB_ARCHITECTURE_MASTER_V2.md` Abschnitt 10 nicht explizit als Docker-Socket-Ausnahme aufgefuehrt.
- Docker-Socket ausserhalb der im Audit-Prompt genannten Allowlist: `alloy` mountet `/var/run/docker.sock:/var/run/docker.sock:ro` in `ops/loki/docker-compose.yml:26`. [AUSNAHME - dokumentiert] in `HOMELAB_ARCHITECTURE_MASTER_V2.md` Abschnitt 10 als `alloy` Docker-Socket read-only, aber nicht in der Prompt-Allowlist genannt.
- `komodo-periphery` haengt ohne Web-UI im `frontend_net` in `ops/komodo/docker-compose.yml:92-94`. Die Compose-Datei dokumentiert an `ops/komodo/docker-compose.yml:77-79` den Grund als Git-Zugriff auf internes Gitea und Docker-Agent-Sonderfall. [AUSNAHME - lokal in Compose dokumentiert]
- `hermes_net` ist ein app-internes Netz ohne `internal: true`: `ops/hermes-agent/docker-compose.yml:91-93`. Das ist nicht Teil der explizit geforderten `internal: true`-Liste, aber strukturell auffaellig, weil Gateway und Dashboard intern ueber dieses Netz sprechen.
- `grafana` nutzt zusaetzlich `backend_net` in `ops/grafana-influxdb/docker-compose.yml:27-30`, obwohl die Architektur das Grafana/Influx-Paar primaer als `frontend_net` + `grafana_influx_internal` beschreibt. Grund ist vermutlich Loki-Datasource-Zugriff; `docs/REPO_MAP.md` nennt `backend_net` fuer Grafana-Loki-Datasource. [AUSNAHME - dokumentiert]
- `paperless-gpt` verwendet `PAPERLESS_API_TOKEN` als Stack-ENV in `apps/paperless-gpt/docker-compose.yml:15`, taucht aber in `docs/SECRETS_MAP.md` nicht als eigener aktiver Secret-Eintrag auf.
## Hinweise
- Direkte Host-Ports sind nur bei dokumentierten Ausnahmen vorhanden:
- [AUSNAHME - dokumentiert] `traefik`: `80:80`, `443:443` in `traefik/docker-compose.yml:30-32`.
- [AUSNAHME - dokumentiert] `gitea`: `222:22` in `core/gitea/docker-compose.yml:17-18`.
- [AUSNAHME - dokumentiert] `adguard`: `53:53/tcp`, `53:53/udp`, `8082:80` in `host-services/Adguard/docker-compose.yml:13-16`.
- [AUSNAHME - dokumentiert] `influxdb3-core`: `${INFLUXDB_BIND_IP:-127.0.0.1}:8181:8181` in `ops/grafana-influxdb/docker-compose.yml:54-55`.
- `backend_net` wird in allen Compose-Dateien als `external: true` referenziert, z. B. `infra/postgresql17/docker-compose.yml:25-26`, `infra/redis/docker-compose.yml:22-23`, `traefik/docker-compose.yml:59-60`. Ob das externe Docker-Netz live wirklich `internal: true` ist, ist aus dem Repo allein nicht verifizierbar.
- `grafana_influx_lan` hat bewusst kein `internal: true`: `ops/grafana-influxdb/docker-compose.yml:88-89`. [AUSNAHME - dokumentiert]
- Mutable Tags sind mit Digests gepinnt, aber semantisch weiterhin mutable:
- `immich-server`: `release@sha256` in `apps/immich/docker-compose.yml:4`.
- `immich-machine-learning`: `release@sha256` in `apps/immich/docker-compose.yml:35`.
- `tailscale`: `stable@sha256` in `host-services/tailscale/docker-compose.yml:3`.
- `ddns-updater`: `latest@sha256` in `infra/ddns-updater/docker-compose.yml:3`.
- `komodo-core`: Major-Tag `2@sha256` in `ops/komodo/docker-compose.yml:36`.
- `komodo-periphery`: Major-Tag `2@sha256` in `ops/komodo/docker-compose.yml:82`.
- `uptime-kuma`: Major-Tag `1@sha256` in `ops/uptime-kuma/docker-compose.yml:3`.
- Reines `image: name:latest` ohne Digest wurde in produktiven Compose-Dateien nicht gefunden.
- Alle Services haben `restart: unless-stopped`.
- Alle Services haben `security_opt: no-new-privileges:true`.
- [AUSNAHME - dokumentiert] `scrutiny` nutzt `privileged: true` in `ops/scrutiny/docker-compose.yml:6`.
- [AUSNAHME - dokumentiert] `tailscale` nutzt `network_mode: host` in `host-services/tailscale/docker-compose.yml:6`.
## Dokumentations-Abweichungen
- Service-Namen in Compose vs. `docs/SERVICE_CATALOG.md` weichen bei Immich ab: Compose nutzt `immich-server`, `immich-machine-learning`, `database`, `redis` in `apps/immich/docker-compose.yml:2`, `:33`, `:44`, `:53`; der Katalog dokumentiert `immich_server`, `immich_machine_learning`, `immich_postgres`, `immich_redis`.
- Service-Name in Compose vs. `docs/SERVICE_CATALOG.md` weicht bei Paperless ab: Compose nutzt `paperless` in `apps/paperless/docker-compose.yml:2`; der Katalog dokumentiert `paperless-ngx`.
- Service-Name in Compose vs. `docs/SERVICE_CATALOG.md` weicht bei Redis ab: Compose nutzt `redis` in `infra/redis/docker-compose.yml:2`; der Katalog dokumentiert `Redis`.
- Traefik-Host bei Hermes ist im Compose variabel: `Host(`${HERMES_DASHBOARD_HOST}`)` in `ops/hermes-agent/docker-compose.yml:81`; `docs/REPO_MAP.md` dokumentiert konkret `hermes.kaleschke.info`. Das ist plausibel, aber maschinell nicht eindeutig abgleichbar.
- `docs/REPO_MAP.md` Volumes-Tabelle ist fuer mehrere Mounts nur zusammenfassend, nicht pfadgenau. Beispiele mit Compose-Pfaden, die dort nicht wortgleich auftauchen:
- `/mnt/user/appdata/unbound/config` in `apps/unbound/docker-compose.yml:7`.
- `/mnt/user/appdata/ddns-updater` in `infra/ddns-updater/docker-compose.yml:17`.
- `/mnt/user/appdata/filebrowser/database` und `/mnt/user/appdata/filebrowser/config` in `ops/filebrowser/docker-compose.yml:15-16`.
- `/mnt/user/appdata/borg-ui/restore` in `ops/borg-ui/docker-compose.yml:27`.
- Netzwerke in Compose sind alle in `docs/REPO_MAP.md` aufgefuehrt.
- Traefik-Hosts aus Compose sind in `docs/REPO_MAP.md` aufgefuehrt; einzige Besonderheit ist der variable Hermes-Host.
- `.example`-Dateien haben passende Gegenspieler in `.gitignore`: `.env`, `*.env`, `!*.env.example`, `**/stack.env`, `!**/stack.env.example`.
- Keine `.keep`-Platzhalter-Dateien gefunden.
## Offene Architektur-Punkte: aktueller Stand
- `immich_redis`: weiterhin kein named volume. Compose-Service `redis` in `apps/immich/docker-compose.yml:44-50` hat aktuell gar keinen `volumes:`-Block. Architekturpunkt bleibt offen.
- `AdGuard Home`: Admin-Port ist weiterhin direkt veroeffentlicht: `8082:80` in `host-services/Adguard/docker-compose.yml:15`; keine Traefik-Labels vorhanden. Block F bleibt offen.
- `filebrowser`: Appdata-Breitmount ist entfernt; aktuelle Mounts sind `/mnt/user/documents`, `/mnt/user/photos`, `/mnt/user/projekte` plus eigene DB/Config in `ops/filebrowser/docker-compose.yml:12-16`. Langfristiges Hardening bleibt moeglich, aber der groesste alte Breitmount ist erledigt.
- `bentopdf`: Compose ist vorhanden und Traefik-abgesichert in `apps/bentopdf/docker-compose.yml:2-27`. Runtime-Deploy und fachliche Abnahme koennen aus dem Repo allein nicht bestaetigt werden; Architekturpunkt bleibt als Live-Pruefung offen.
- `grafana` und `influxdb3-core`: laufen weiterhin als `user: "0"` in `ops/grafana-influxdb/docker-compose.yml:6` und `ops/grafana-influxdb/docker-compose.yml:53`. [AUSNAHME - dokumentiert]
- `plex-media-server`: keine Compose-Datei im Repo gefunden; bleibt Dockerman-/Host-Sonderfall laut Architektur.
## Bestanden
- 30 produktive `docker-compose.yml` wurden geprueft.
- Keine Datenbanken oder Caches im `frontend_net`: `postgresql17` nur `backend_net` in `infra/postgresql17/docker-compose.yml:18`; shared `redis` nur `backend_net` in `infra/redis/docker-compose.yml:15`; `mealie-postgres` nur `mealie_internal` in `apps/mealie/docker-compose.yml:56`; Immich `database` und `redis` nur `immich_default` in `apps/immich/docker-compose.yml:48` und `:64`; Nextcloud DB/Redis nur `nextcloud_internal` in `apps/nextcloud/docker-compose.yml:61` und `:73`; `komodo-mongo` nur `komodo_net` in `ops/komodo/docker-compose.yml:16`.
- App-interne Pflichtnetze sind korrekt `internal: true`: `immich_default` in `apps/immich/docker-compose.yml:73-75`, `mealie_internal` in `apps/mealie/docker-compose.yml:66-68`, `nextcloud_internal` in `apps/nextcloud/docker-compose.yml:82-84`, `grafana_influx_internal` in `ops/grafana-influxdb/docker-compose.yml:90-91`.
- `frontend_net` und `backend_net` werden in Compose-Dateien als `external: true` referenziert.
- Alle Services mit `traefik.enable=true` setzen explizit `traefik.docker.network=frontend_net`, `entrypoints=websecure`, `tls=true` und `tls.certresolver=le`.
- Kein Traefik-Label mit `yourdomain.tld` gefunden.
- Admin-/Ops-Router haben Middleware `authelia@file,secure-headers@file`, u. a. `homepage` in `apps/homepage/docker-compose.yml:31`, `filebrowser` in `ops/filebrowser/docker-compose.yml:27`, `scrutiny` in `ops/scrutiny/docker-compose.yml:32`, `grafana` in `ops/grafana-influxdb/docker-compose.yml:46`. [AUSNAHME - dokumentiert] `komodo-core` hat keine ForwardAuth-Middleware in `ops/komodo/docker-compose.yml:64-71`; [AUSNAHME - dokumentiert] `nextcloud` nutzt native Auth und nur Redirect-Middleware in `apps/nextcloud/docker-compose.yml:42-46`.
- Web-UIs ohne Traefik-Labels wurden nur als dokumentierte Sonderfaelle gefunden: `adguard` mit LAN-Admin-Port in `host-services/Adguard/docker-compose.yml:13-16`; `ddns-updater` hat keine Web-UI und braucht Internet in `infra/ddns-updater/docker-compose.yml:8`; `komodo-periphery` ist Agent ohne Traefik-Route in `ops/komodo/docker-compose.yml:81-103`.
+65
View File
@@ -0,0 +1,65 @@
# Capacity and Lifecycle - KalliLab CORE
Status: Initiale Capacity-Baseline 2026-05-26; externe Backup-/Cold-Storage-Groessen offen.
## Zweck
Dieses Dokument haelt Wachstum, Schwellenwerte und Upgrade-Trigger fest. Es verhindert, dass Storage-, RAM- oder Backup-Entscheidungen erst dann getroffen werden, wenn der Host bereits unter Druck steht.
## Aktuelle Kapazitaet
| Bereich | Groesse | Belegt | Frei | Schwellwert | Bewertung |
|---|---:|---:|---:|---:|---|
| Cache | 1.9T | 97G | 1.8T | 70 % Planung / 85 % Aktion | gruen, 6 % belegt |
| Disk1 / Array | 5.5T | 1.8T | 3.7T | 80 % Planung / 90 % Aktion | gruen, 33 % belegt |
| User Shares gesamt | 5.5T | 1.8T | 3.7T | 80 % Planung / 90 % Aktion | gruen, entspricht aktuell Disk1 |
| Backups lokal | 5.5T geteilter Array-Space | 2.2G unter `/mnt/user/backups` | 3.7T Share-frei | Review bei Borg-/Dump-Wachstum | lokal nicht unabhaengig vom Array |
| Hetzner Borg | TBD | TBD | TBD | TBD | TBD |
| Externe Cold-Platte | TBD | TBD | TBD | TBD | TBD |
Pruefkommando:
```bash
df -h /mnt/cache /mnt/disk1 /mnt/user
du -sh /mnt/user/appdata/* | sort -hr | head -30
du -sh /mnt/user/documents /mnt/user/photos /mnt/user/media /mnt/user/backups 2>/dev/null
```
## Wachstumsbereiche
| Bereich | Erwartetes Wachstum | Risiko | Naechste Aktion |
|---|---|---|---|
| Medien | aktuell ca. 1.7T | groesster Speicherblock | Array-Erweiterung vor 80 % planen |
| Immich Fotos/Videos | aktuell ca. 23G | hoechster privater Datentopf | Restore-Test priorisieren |
| Paperless/Dokumente | aktuell ca. 199M im Documents-Share | wichtig, moderates Wachstum | Restore-Test existiert, Share-Wachstum beobachten |
| Nextcloud | TBD | Familiennutzung kann stark wachsen | Quota/Backup pruefen |
| Monitoring/Loki | begrenzt durch Retention | Retention kann Disk fuellen | Retention und Volume-Groesse bei Reviews pruefen |
| Borg Dumps | aktuell ca. 2.2G lokale Backups | Retention und Excludes pruefen | Borg-Stale + Groessenprofil |
## Upgrade-Trigger
| Trigger | Massnahme |
|---|---|
| Cache dauerhaft >70 % | Zweite NVMe oder Appdata-Verteilung planen |
| Cache >85 % | Sofortmassnahme, keine grossen Deployments |
| Disk1 >80 % | Array-Erweiterung planen |
| Disk1 >90 % | Keine neuen grossen Datenimporte, Erweiterung priorisieren |
| RAM >90 % ueber 10 Minuten regelmaessig | RAM-Ausbau oder Service-Limits pruefen |
| Borg-Laufzeit deutlich steigend | Scope, Netzwerk und Ziel pruefen |
| SMART-Warnung | Ersatz planen, Restore-/Backup-Frische pruefen |
| Keine USV-Abschaltung | USV anschaffen/anschliessen oder Power-Loss-Risiko bewusst akzeptieren |
## Restore-Zeitziele
| Tier | Beispiel | Zielzeit | Status |
|---|---|---:|---|
| Tier 0 | Repo, Secrets, Traefik, DNS | TBD | offen |
| Tier 1 | Gitea, Vaultwarden, Paperless, Immich | TBD | offen |
| Tier 2 | Nextcloud, Mealie, Monitoring | TBD | offen |
| Tier 3 | Komfort-/Ops-Tools | TBD | offen |
## Review-Log
| Datum | Befund | Entscheidung |
|---|---|---|
| 2026-05-26 | Cache 6 %, Array/User-Shares 33 %, lokale Backups 2.2G; keine validierte USV-Abschaltung | Capacity gruen; naechste operative Risiken sind USV-Entscheidung und externe Backup-/Cold-Storage-Groessen |
+32
View File
@@ -0,0 +1,32 @@
# Codex-Prompt: KalliLab Endstufe
Du hast Vollzugriff auf `G:\Gitea_Clone\homelab-infra`, Gitea-Push, Komodo, und SSH auf Unraid `Kallilabcore`.
## Lies zuerst
1. `CLAUDE.md`
2. `docs/AUDIT_2026-05-23.md` — dort steht die komplette Restliste
## Auftrag
Den Audit von oben verifizieren und die offenen Punkte abarbeiten, bis das Homelab in der Endstufe ist. Reihenfolge:
1. **P0** — Lokalen Commit `cd650b1` nach Gitea pushen, danach Komodo-Reaktion fuer `gitea` und `borg-ui` pruefen.
2. **P0** — Live-Daten aus Audit-Abschnitt 9 messen und in `docs/AUDIT_2026-05-23_LIVE.md` ablegen (Secrets redacten).
3. **P1** — Monitoring-Stack (`monitoring/`) live deployen, alte `ops/grafana-influxdb` und `ops/loki` `down` (nicht loeschen).
4. **P1** — Jellyfin und Plex in `HOMELAB_ARCHITECTURE_MASTER_V2.md`, `docs/SERVICE_CATALOG.md`, `docs/REPO_MAP.md` nachtragen. Plex-Eintrag "nicht als Repo-Stack enthalten" korrigieren.
5. **P2** — Borg-Lauf-Frische pruefen, ggf. neuen Lauf ausloesen, alle 14 Dump-Artefakte juenger als 24 h.
6. **P3** — Repo-Hygiene: 8 leere Verzeichnisse weg, `.serena/` in `.gitignore`, Entscheidung zu `ops/windows-reinstall/*.ps1`.
## Regeln (aus CLAUDE.md, nicht verhandelbar)
- Secrets nie im Klartext ausgeben.
- Keine Aenderungen direkt in Komodo, nur ueber Git → Push → Komodo.
- Kein `push --force`, kein blindes Loeschen von `/mnt/user/{appdata,documents,photos,services,backups}`.
- Working-Tree-Status nur aus `git status --short` ableiten, nie aus `git diff` ueber Linux-Mount.
- Traefik dynamic config wird nicht von Komodo deployed — Aenderungen dort manuell auf `/mnt/user/appdata/traefik/dynamic/` syncen.
- Nicht anfassen: Hermes, Disk1 NTFS Phase 2, Komodo-Auth, Grafana/InfluxDB `user: "0"`, Image-Pinning ddns/glances/scrutiny.
- Wenn zwei Reparaturversuche scheitern: stoppen, Drift-Runbook Pflichtmatrix, Operator fragen.
## Arbeitsmodus pro Block
Lesen → minimal aendern → `ops/policy-checks/check_repo.ps1` lokal → Commit → Push → Komodo-Reaktion + Smoke-Test → eine Zeile in `docs/MIGRATION_LOG.md`.
## Fertig
Wenn alles abgearbeitet ist (oder ein Punkt bewusst offen bleibt): `docs/AUDIT_2026-05-23_FINAL.md` schreiben mit Ampel + konkretem Beleg pro Punkt, committen, pushen, kurz an mich melden.
+421
View File
@@ -0,0 +1,421 @@
# Disaster Recovery - KalliLab CORE
Dieses Dokument beschreibt den **kontrollierten Wiederanlauf nach einem Totalausfall des Unraid-Hosts**.
Es ist bewusst **repo-genau** fuer dieses Homelab geschrieben und ersetzt keine Backup-Dokumentation im engeren Sinn.
Verwandte Dokumente:
- `docs/ROLLBACK.md` - Rueckweg bei Fehlern im laufenden GitOps-Betrieb
- `docs/RESTORE_MATRIX.md` - Restore-Quellen und Verifikationsregeln pro Dienst
- `docs/RESTORE_HANDBOOK.md` - praktische Restore-Betriebsanleitung
- `docs/SERVICES_RECOVERY.md` - Recovery-kritische `/mnt/user/services`-Pfade, Gitea-Mirror und Komodo-Bootstrap
- `docs/EXTERNAL_DEPENDENCIES.md` - externe Provider/Konten und Ausfall-Szenarien
- `ops/borg-ui/BACKUP_SCOPE.md` - Zielbild des Borg-Scopes
---
## 1. Ziel und Geltungsbereich
Dieses Runbook behandelt primaer den Fall:
- Unraid-Host war ausgefallen oder musste neu aufgesetzt werden
- Array / Shares sind wieder verfuegbar
- Daten auf den Festplatten sind grundsaetzlich noch lesbar oder koennen aus Borg wiederhergestellt werden
Es behandelt **nicht** im Detail:
- den Rueckweg einzelner Fehl-Deployments im laufenden Betrieb
- eine spontane Live-Reparatur an einem einzelnen Service
- einen kompletten Datenverlust ohne verfuegbares Borg- oder Share-Material
---
## 2. Zwei verschiedene Ernstfaelle
Diese beiden Szenarien muessen sauber getrennt werden:
### A. Host-/Systemausfall, aber Daten auf den Shares sind noch da
Das ist der wahrscheinlichere und einfachere Fall.
Dann muessen in vielen Bereichen **keine Daten aus Borg extrahiert** werden. Es reicht oft:
1. Unraid sauber neu aufzusetzen
2. Shares wieder einzubinden
3. Repo / Secrets / Laufzeitpfade zu pruefen
4. Stacks in der richtigen Reihenfolge wieder hochzufahren
### B. Daten muessen wirklich aus Borg wiederhergestellt werden
Das ist der schwerere Fall.
Dann muessen gezielt die in `docs/RESTORE_MATRIX.md` beschriebenen Pfade, Dumps und Secrets aus Borg oder anderen Restore-Quellen zurueckgeholt werden.
**Merksatz:** Erst klaeren, ob wir nur den Host wiederbeleben oder echte Nutz-/App-Daten aus Backups zurueckholen muessen.
---
## 3. Voraussetzungen vor einem Ernstfall
Diese Punkte sollten **vor** einem echten Ausfall geklaert sein:
| Thema | Sollzustand |
|---|---|
| Repo-Zugang ausserhalb von Gitea | privater GitHub-Push-Mirror `michaelkaleschke-spec/homelab-infra` und lokaler aktueller Clone vorhanden |
| Unraid USB-/Flash-Backup | `unraid-flash-config.tar.gz` wird vor Borg unter `/mnt/user/backups/borg/dumps/latest` erzeugt und nach Hetzner/Borg gesichert; Unraid-Connect-Cloud-Backup optional zusaetzlich |
| Borg-Ziel | nicht nur lokal auf demselben Ausfallpfad |
| Borg-Passphrase | Host-Secret-Datei vorhanden und fuer Borg-Zugriff verifiziert; externe analoge Hinterlegung bleibt Operator-Aufgabe |
| Secrets-Dateien | ueber Borg bzw. Restore-Quellen abgedeckt |
| Komodo Stack ENV-Werte | extern dokumentiert, z. B. Vaultwarden |
| Services-Recovery | `docs/SERVICES_RECOVERY.md` gepflegt, insbesondere Gitea-Repo-Mirror und Komodo-Bootstrap |
| Hardware-/Netzwerkdaten | `docs/HARDWARE_INVENTORY.md` und `docs/NETWORK_INVENTORY.md` mit echten Werten gefuellt |
| Restore-Smoke-Tests | fuer mindestens 1-2 kritische Dienste nachgewiesen |
**Wichtig:** Dieses Dokument ist nur so gut wie die Vorbereitung ausserhalb des Repos.
---
## 4. Phase 0 - Repo-Zugang sicherstellen
Nach einem Totalausfall kann es sein, dass `gitea` selbst noch nicht laeuft.
Deshalb gilt:
1. Wenn moeglich, Repo ueber einen externen Mirror oder einen lokalen aktuellen Clone holen.
2. Nur wenn `gitea` bereits wieder verfuegbar ist, direkt aus `git.kaleschke.info` klonen.
Verfuegbare Wege:
- externer Push-Mirror: `https://github.com/michaelkaleschke-spec/homelab-infra`
- lokaler Bare-Clone auf dem PC
- normaler lokaler Arbeits-Clone auf dem PC
Wenn **weder GitHub-Mirror noch lokaler Repo-Clone** verfuegbar sind, ist `services/gitea/data` selbst ein kritischer Restore-Pfad.
---
## 5. Phase 1 - Unraid und Shares wiederherstellen
### 5.1 Unraid-Grundzustand
1. Unraid USB/Flash wiederherstellen oder neu aufsetzen
2. Lizenz / Device-Zuordnung sauber herstellen
3. Array wieder zuweisen und starten
4. Grundlegende Shares pruefen
Primaere lokale/off-site Restore-Quelle fuer die bestehende Flash-Konfiguration ist das Borg-Artefakt `unraid-flash-config.tar.gz` aus `/mnt/user/backups/borg/dumps/latest`. Dieses Archiv enthaelt `/boot/config` und muss wie Secret-Material behandelt werden.
### 5.2 Erwartete Shares / Pfade
Mindestens diese Pfade muessen wieder verfuegbar sein:
- `/mnt/user/appdata`
- `/mnt/user/backups`
- `/mnt/user/services`
- `/mnt/user/documents`
- `/mnt/user/photos`
Je nach Dienst zusaetzlich:
- `/mnt/user/finance`
- `/mnt/remotes/...` fuer externe Backup-Ziele
Wenn diese Pfade **nicht** sauber da sind, keine Stacks starten.
---
## 6. Phase 2 - Secrets und kritische Restore-Pfade pruefen
Bevor produktive Dienste hochfahren, muessen die wichtigsten Secret- und Konfigurationspfade verifiziert werden.
### 6.1 Zentrale Secrets
Erwartete Basis unter `/mnt/user/appdata/secrets/`:
- `authelia_jwt_secret.txt`
- `authelia_postgres_password.txt`
- `authelia_session_secret.txt`
- `authelia_smtp_password.txt`
- `authelia_storage_encryption_key.txt`
- `immich_postgres_password.txt`
- `komodo_mongo_password.txt`
- `mealie_postgres_password.txt`
- `nextcloud_admin_password.txt`
- `nextcloud_admin_user.txt`
- `nextcloud_postgres_password.txt`
- `postgres_password.txt`
- `redis_password.txt`
- `borg_repo_passphrase.txt`
- `vaultwarden_admin_token.txt`
- `hermes_runner_id_ed25519`
Weitere relevante Secret-Pfade:
- `/mnt/user/appdata/traefik/secrets/cloudflare_dns_api_token`
- `/mnt/user/appdata/code-server/secrets/password`
### 6.2 Stack-ENV-Werte ausserhalb von Datei-Secrets
Diese Werte sind vor dem Start der betroffenen Dienste zu pruefen bzw. wieder in Komodo zu hinterlegen:
- `PAPERLESS_DBPASS`
- `PAPERLESS_REDIS`
- `IMMICH_DB_PASSWORD`
- `MAILARCHIVER_DB_CONNECTION`
- `MAILARCHIVER_AUTH_PASSWORD`
- `HERMES_DASHBOARD_HOST`
- Hermes Host-`.env` fuer Provider-/API-/Home-Assistant-Tokens
- `KOMODO_SECRET_KEY`
- `KOMODO_WEBHOOK_SECRET`
- `KOMODO_JWT_SECRET`
- `KOMODO_MONGO_PASSWORD`
- `KOMODO_PERIPHERY_PASSKEY`
- `APP_KEY` und `ADMIN_PASSWORD` fuer `speedtest-tracker`
### 6.3 Rechte
Nach einem Restore oder manuellem Rueckkopieren:
- Secret-Dateien wieder auf sinnvolle restriktive Rechte setzen
- keine produktiven Dienste starten, solange offensichtliche Secret-Dateien fehlen
---
## 7. Phase 3 - Was wirklich aus Borg kommen muss
Nicht alles muss in jedem Fall aus Borg zurueckgespielt werden.
### 7.1 Typischer Host-Ausfall ohne Datenverlust
In diesem Fall meist **kein kompletter Borg-Extract** notwendig.
Stattdessen:
1. Shares und Pfade pruefen
2. Secrets pruefen
3. Dumps unter `/mnt/user/backups/borg/dumps/latest` pruefen
4. Stacks kontrolliert hochfahren
### 7.2 Wenn echte Daten aus Borg benoetigt werden
Dann nur gezielt die in `docs/RESTORE_MATRIX.md` beschriebenen Pfade wiederherstellen.
Besonders kritisch:
- `/mnt/user/appdata/secrets`
- `/mnt/user/appdata/traefik`
- `/mnt/user/services/homelab-infra`
- `/mnt/user/services/stacks`
- `/mnt/user/services/posture-check`
- Details zu `/mnt/user/services/` und Komodo/Gitea-Bootstrap stehen in `docs/SERVICES_RECOVERY.md`
- `/mnt/user/services/gitea/data`
- `/mnt/user/appdata/authelia/config`
- `/mnt/user/appdata/komodo/core`
- `/mnt/user/appdata/komodo/periphery`
- `/mnt/user/backups/borg/dumps/latest`
- `/mnt/user/backups/borg/dumps/latest/unraid-flash-config.tar.gz`
- dienstspezifische App- und Nutzdatenpfade
**Nicht blind alles extrahieren**, wenn nur einzelne Pfade oder Dienste betroffen sind.
---
## 8. Phase 4 - Bootstrap-Reihenfolge der Stacks
**Nie alle Stacks gleichzeitig starten.**
### Stufe 1 - Netz und Zugang
1. `traefik/`
2. `host-services/Adguard/`
3. `host-services/tailscale/`
Ziel:
- Web-Einstieg funktioniert
- DNS/Resolver-Basis ist da
- Remote-Zugang ist wieder verfuegbar
### Stufe 2 - Gemeinsame Backends und Identity
4. `infra/postgresql17/`
5. `security/authelia/`
6. `infra/redis/`
7. `core/gitea/`
Ziel:
- gemeinsame DB verfuegbar
- zentrale Auth laeuft; Authelia nutzt bewusst kein Redis-Session-Backend
- Authelia SMTP-Notifier kann GMX erreichen
- Redis als shared Cache fuer abhaengige Apps verfuegbar
- Git-Zugriff wiederhergestellt
### Stufe 3 - Deploy-System
8. `ops/komodo/`
Ziel:
- Komodo Core und Mongo laufen
- Periphery verbindet sich wieder
- Stacks koennen wieder aus Git konsumiert werden
### Stufe 4 - Kritische Anwendungen
9. `security/vaultwarden/`
10. `apps/paperless/`
11. `apps/immich/`
12. `apps/mealie/`
13. `apps/mail-archiver/`
14. `apps/nextcloud/`
### Stufe 5 - Restliche Apps und Ops
15. `apps/ntfy/`
16. `apps/paperless-gpt/`
17. `apps/bentopdf/`
18. `ops/glance/`
19. `ops/borg-ui/`
20. `ops/filebrowser/`
21. `ops/glances/`
22. `ops/scrutiny/`
23. `ops/speedtest/`
24. `monitoring/`
25. `ops/hermes-agent/`
26. `infra/ddns-updater/`
**Regel:** Nach jeder Stufe kurz pruefen, bevor die naechste beginnt.
---
## 9. Phase 5 - Verifikation nach dem Wiederanlauf
### 9.1 Fundament
- `traefik.kaleschke.info` erreichbar
- Authelia-Login funktioniert
- AdGuard beantwortet DNS-Anfragen
- Tailscale ist verbunden
### 9.2 GitOps
- `git.kaleschke.info` erreichbar
- Repo vorhanden
- `komodo.kaleschke.info` erreichbar
- Periphery verbunden
### 9.3 Kritische Apps
- Vaultwarden startet und ist erreichbar
- Paperless startet und sieht Dokumente
- Immich startet und sieht Medien
- Mealie startet
- Mail-Archiver startet
- Nextcloud startet und sieht Dateien
### 9.4 Backup-/Beobachtungsebene
- Borg UI startet und kennt sein Repo noch
- aktuelle Dump-Artefakte sind vorhanden
- Glance / Monitoring / ntfy sind wieder da
- Hermes Gateway und Dashboard starten; `hermes.kaleschke.info` leitet anonym zu Authelia weiter
---
## 10. Bekannte Sonderregeln
### Traefik `dynamic/`
`traefik/dynamic/*` bleibt eine dokumentierte manuelle Ausnahme.
Das bedeutet:
- Git allein reicht hier nicht
- die Dateien unter `/mnt/user/appdata/traefik/dynamic/` muessen real vorhanden sein
- nach einem Restore oder Neuaufbau diesen Pfad explizit pruefen
### `paperless-ngx`
`paperless-ngx` bleibt bewusst bei Stack Environment Variables fuer:
- `PAPERLESS_DBPASS`
- `PAPERLESS_REDIS`
Nach einem Komodo-Neuaufbau muessen diese Werte vor dem Start des Stacks wieder gesetzt sein.
### `authelia`
Authelia nutzt GMX SMTP fuer Identity-/2FA-Benachrichtigungen.
Vor dem Start muessen vorhanden sein:
- `/mnt/user/appdata/secrets/authelia_smtp_password.txt`
- SMTP-Zugang fuer `michideheld@gmx.de`
Beim Smoke-Test muss `authelia validate-config` erfolgreich sein; der SMTP-Startup-Check darf den Start nicht blockieren.
### `nextcloud`
`nextcloud` ist bewusst kein AIO-Stack, sondern ein klassischer App-/PostgreSQL-/Redis-Stack.
Vor dem Start muessen vorhanden sein:
- `/mnt/user/appdata/secrets/nextcloud_admin_user.txt`
- `/mnt/user/appdata/secrets/nextcloud_admin_password.txt`
- `/mnt/user/appdata/secrets/nextcloud_postgres_password.txt`
Zusaetzlich muss der Nutzdatenpfad `/mnt/user/documents/nextcloud-data` erreichbar sein.
### Borg-Dumps
Die Dump-Erzeugung ist host-seitig gedacht, nicht als Borg-UI-Inline-Hook.
Relevant:
- Dump-Ziel: `/mnt/user/backups/borg/dumps/latest`
- Skript: `ops/borg-ui/scripts/pre-backup-dumps.sh`
- Unraid-Flash-Artefakt: `unraid-flash-config.tar.gz` plus `.sha256` und Manifest im selben Zielpfad
### Hermes Agent
Hermes nutzt einen lokalen Build und hostseitige Runtime-Daten.
Vor dem Start muessen vorhanden sein:
- `/mnt/user/appdata/hermes-agent/data`
- `/mnt/user/appdata/hermes-agent/ssh`
- `/mnt/user/appdata/secrets/hermes_runner_id_ed25519`
- die hostseitige Hermes `.env` mit Provider-/API-/Home-Assistant-Tokens
Smoke-Test: `hermes-gateway` healthcheck ist gruen, `hermes.kaleschke.info` leitet fuer anonyme Requests zu Authelia weiter.
### Gitea
`Micha/homelab-infra` wird als privater GitHub-Push-Mirror gespiegelt. Dieser Mirror ist der bevorzugte Repo-Bootstrap, falls Gitea selbst nach einem Ausfall noch nicht laeuft. Wenn weder GitHub-Mirror noch lokaler Clone verfuegbar sind, ist `services/gitea/data` selbst Teil des kritischen Wiederanlaufs.
---
## 11. Offene Vorbereitungs-To-dos
- Unraid-USB-/Flash-Backup regelmaessig ueber `unraid-flash-config.tar.gz` und optional Unraid Connect pruefen
- Borg-Passphrase aus `/mnt/user/appdata/secrets/borg_repo_passphrase.txt` extern analog sicher hinterlegen
- Komodo Stack-ENV-Werte zentral ausserhalb von Komodo dokumentieren
- regelmaessige automatisierte Restore-Smoke-Tests fuer Vaultwarden, Gitea und Paperless etablieren
- `komodo-mongo`-Dump nach Major-Upgrades gezielt kontrollieren
---
## 12. Kurzform fuer den Ernstfall
Wenn es schnell gehen muss:
1. Unraid und Shares sauber wiederherstellen
2. Repo-Zugang sichern
3. Secrets und Stack-ENV-Werte pruefen
4. Stacks in der festgelegten Reihenfolge hochfahren
5. Kritische Apps testen
6. Nur bei echtem Datenbedarf gezielt aus Borg wiederherstellen
Dieses Dokument soll **Ruhe in den Ablauf bringen**, nicht Hektik erzeugen.
+77
View File
@@ -0,0 +1,77 @@
# Disk1 Phase 2 - NTFS to XFS Migration
Stand: 2026-05-25 06:15 CEST. Ziel erreicht: `/mnt/disk1` wurde von `ntfs3` auf XFS migriert, ohne produktive Compose-Pfade zu aendern. Container nutzen weiter `/mnt/user/...`.
## Preflight
| Check | Ergebnis |
|---|---|
| Disk1 Mount | `/dev/md1p1` auf `/mnt/disk1`, `ntfs3`, 5.5T, 1.7T genutzt, 3.8T frei |
| Cache Mount | `/dev/nvme0n1p1` auf `/mnt/cache`, `xfs`, 1.9T, 100G genutzt |
| H: Backup-Ziel | `H:\`, Label `Externe HDD`, NTFS, 8T, 5.96T frei, healthy |
| Compose-Binds | Keine Treffer fuer direkte `/mnt/disk1`, `/mnt/cache`, `/mnt/disks`, `/mnt/remotes` Binds |
| SMB-Zugriff | `\\Kallilabcore\services`, `documents`, `photos`, `media` erreichbar |
## Zu sichernde Shares
| Share | Groesse laut Preflight |
|---|---:|
| `services` | 451M |
| `documents` | 196M |
| `photos` | 23G |
| `backups` | 2.2G |
| `media` | 1.7T |
| `finance` | 0 |
| `projekte` | 92K |
Zusaetzliche Disk1-Top-Level-Pfade: `scripts` 3.3M, `isos` 0, `System Volume Information` 0.
## Backup-Strategie
- Zielroot: `H:\kallilab-recovery\disk1-phase2-2026-05-23`.
- Kritischer Linux-/GitOps-Pfad `services` wird zusaetzlich als Tar-Archiv ueber SSH gesichert, damit Linux-Metadaten erhalten bleiben.
- User-Shares werden per SMB/Robocopy kopiert, mit Logs und anschliessender Zaehler-/Groessenverifikation.
- Keine produktiven Datenpfade werden geloescht.
## Gates
1. Backup komplett und verifiziert.
2. Dienste-Freeze vorbereitet und letzte Dumps frisch.
3. Direkt vor Format/Array-Prozedur: Operator-Bestaetigung im konkreten Moment.
## Backup-Ergebnis
Stand: 2026-05-24 13:07 CEST.
| Bereich | Sicherungsart | Ergebnis |
|---|---|---|
| `media` | Robocopy/SMB nach `H:\kallilab-recovery\disk1-phase2-2026-05-23\shares\media` | 2722 Dateien, 1677.11 GiB, Manifestvergleich: 0 missing, 0 size mismatch, 0 extra |
| `services` | Host-Tar auf Unraid Cache, danach binaer per `scp` nach H: | `services.tar`, 0.441 GiB, `tar -tf` gueltig |
| `documents` | Host-Tar auf Unraid Cache, danach binaer per `scp` nach H: | `documents.tar`, 0.192 GiB, `tar -tf` gueltig |
| `photos` | Host-Tar auf Unraid Cache, danach binaer per `scp` nach H: | `photos.tar`, 22.876 GiB, `tar -tf` gueltig |
| `backups` | Host-Tar auf Unraid Cache, danach binaer per `scp` nach H: | `backups.tar`, 2.099 GiB, `tar -tf` gueltig |
| `finance` | Host-Tar auf Unraid Cache, danach binaer per `scp` nach H: | `finance.tar`, leerer Share, `tar -tf` gueltig |
| `projekte` | Host-Tar auf Unraid Cache, danach binaer per `scp` nach H: | `projekte.tar`, klein, `tar -tf` gueltig |
| Disk1-Extras `scripts`, `isos` | Host-Tar auf Unraid Cache, danach binaer per `scp` nach H: | `disk1-extra.tar`, 0.003 GiB, `tar -tf` gueltig |
Hinweis: Erste Tar-Versuche per PowerShell-Redirect wurden verworfen, weil PowerShell den binaeren SSH-Stream als UTF-16 geschrieben hatte. Die ungueltige `media.tar` und unvollstaendige SMB-Teilkopien fuer `services`/`documents` wurden vom Backup-Ziel entfernt, damit nur verwertbare Sicherungen uebrig bleiben.
## Abschluss
Stand: 2026-05-25 06:15 CEST.
| Check | Ergebnis |
|---|---|
| Freeze-Dumps | `pre-backup-dumps.sh` vor Format ausgefuehrt; nach Wiederanlauf erneut erfolgreich, 15 kanonische Dump-Artefakte juenger als 24 h |
| Disk1 Filesystem | `/dev/md1p1` auf `/mnt/disk1`, `xfs`, 5.5T, 1.8T genutzt, 3.7T frei |
| Restore `media` | 2722 Dateien, 1,800,782,188,226 Bytes; finaler Manifestvergleich: 0 missing, 0 size mismatch, 0 extra |
| Restore Tar-Shares | `services`, `documents`, `photos`, `backups`, `finance`, `projekte` und Disk1-Extras aus H:-Freeze-Archiven nach `/mnt/disk1` extrahiert |
| Docker/Services | 49 Container laufend, 0 stopped, 0 unhealthy, 0 starting |
| Smoke-Tests | `git.kaleschke.info` 200, `komodo.kaleschke.info` 200, `borg.kaleschke.info` 302, `jellyfin.kaleschke.info` 302, `monitoring.kaleschke.info` 302 |
| Service-Mounts | Gitea SSH `:222` offen; Jellyfin und Plex sehen `/media`; Prometheus readiness ok |
| Backup-Lauf | Borg-UI Repository `appdata-critical`, letzter Job `completed`, Archiv `Taegliche-Sicherung-2026-05-25T05:52:44.157`, `nfiles=100221` |
| Temp-Cleanup | `/mnt/cache/disk1-phase2-tmp/*.tar` nach H:-Verifikation geloescht; Cache weiter XFS mit ca. 1.7T frei |
Hinweis zum Docker-Wiederanlauf: Nach dem manuellen Docker-Start liefen die Container, aber Healthcheck-Execs scheiterten wegen `dockerd` mit `XDG_RUNTIME_DIR=/run/user/0`. Ein gezielter Docker-Neustart mit unsetztem `XDG_RUNTIME_DIR` behob den Runtime-Fehler; danach wurden alle Healthchecks gruen. `monitoring-prometheus` war durch den geplanten Docker-Stop sauber beendet und wurde als bestehender Container wieder gestartet.
Offener Nachlauf: Die Array-Parity-Anzeige zeigte nach Abschluss weiter `mdNumDisabled=1`, `mdNumInvalid=1` und `mdResyncAction=check P`, waehrend beide beteiligten Devices `rdevStatus=DISK_OK` und `rdevNumErrors=0` melden. Parity-Zustand separat in Unraid pruefen und keinen Parity-/Disk-Slot ohne Operator-Entscheid aendern.
+68
View File
@@ -0,0 +1,68 @@
# External Dependencies - KalliLab CORE
Status: Initiale Betreiber-Baseline 2026-05-26; konkrete Account-Recovery-Codes und Besitznachweise muessen ausserhalb des Repos bestaetigt werden.
## Zweck
Dieses Dokument beschreibt externe Anbieter und Konten, von denen Betrieb, Recovery oder Zugriff abhaengen. Ziel ist, im Ausfallfall nicht erst suchen zu muessen, welcher Provider welches Teilproblem verursacht.
## Abhaengigkeiten
| Anbieter / System | Zweck | Kritikalitaet | Recovery-Auswirkung | Zugang / Besitz | Notfallplan |
|---|---|---:|---|---|---|
| Domain-Registrar | Besitz `kaleschke.info` | hoch | Ohne Domain brechen Public URLs/TLS-Erneuerung | Operator-Konto ausserhalb Repo, konkreten Registrar im Account pruefen | Registrar-Zugang, 2FA-Recovery und Zahlungsweg analog/off-system sichern |
| Cloudflare DNS | Authoritative DNS, ACME DNS-Challenge, DDNS | hoch | Neue Zertifikate/DNS-Aenderungen blockiert | Cloudflare-Konto; API-Token liegt als Host-Secret | API-Token rotierbar halten, Account-Recovery und Zone-Besitz pruefen |
| Hetzner Storage Box | Off-site Borg Backup | kritisch | Restore aus Off-site ggf. nicht moeglich | Hetzner-Konto / Storage-Box-Zugang ausserhalb Repo | Zweites Off-site-Ziel oder Cold-Platte etablieren; Borg-Passphrase extern sichern |
| GitHub Mirror | Externer Repo-Mirror `michaelkaleschke-spec/homelab-infra` | mittel/hoch | Gitea-Verlust abfederbar, Repo-Bootstrap bleibt moeglich | GitHub-Konto; PAT liegt in Gitea-Mirror-Settings, nicht im Repo | Mirror-Status regelmaessig pruefen; lokalen Clone als zweite Kopie behalten |
| Tailscale | Remote-/Operator-Zugang | hoch | Remote-Zugriff erschwert, lokale Bedienung bleibt | Tailnet-Konto; Node `Kallilabcore`, IPv4 `100.80.98.33` | Break-glass per LAN und physischem Zugriff; Tailnet-Recovery-Codes sichern |
| GMX SMTP | Authelia Notifier | mittel | Mail-Notifier faellt aus, Login selbst nicht zwingend | GMX-Konto; SMTP-Secret liegt hostseitig | ntfy/zweiter SMTP als Fallback pruefen |
| Let's Encrypt | TLS-Zertifikate | hoch | Cert-Erneuerung faellt aus | automatisch via Traefik und Cloudflare DNS-Challenge | Cert-Expiry Alert einrichten; Cloudflare-Token und Traefik-Storage pruefen |
| Container Registries | Image Pulls von Docker Hub, GHCR, LSCR, Gitea Registry u. a. | mittel | Redeploy/Update blockiert | ueberwiegend oeffentlich; keine produktiven Registry-Tokens im Repo | Gepinnte Digests und lokale Runtime helfen kurzfristig; Updates geplant und einzeln deployen |
| Plex Konto/Remote Access | Plex native Auth, ggf. Remote Access und Claim | mittel | Plex-Clients/Remote-Funktionen koennen ausfallen | Plex-Konto ausserhalb Repo; `PLEX_CLAIM` nur fuer Setup | LAN-Medienpfade bleiben lokal; Konto-Recovery separat sichern |
| Mobile Push | ntfy und ggf. mobile Plattform-Pushes | niedrig/mittel | Alerts erreichen Mobilgeraete ggf. nicht | App-/Device-seitig | Kritische Alerts zusaetzlich in Grafana/Glance sichtbar halten |
## Kritische Secrets ausserhalb des Repos
Authoritativ ist `docs/SECRETS_MAP.md`. Diese Liste markiert nur externe Abhaengigkeiten.
| Secret | Zweck | Recovery-Hinweis |
|---|---|---|
| Borg Passphrase | Entschluesselung Borg-Repos | Muss analog/off-system vorhanden sein |
| Cloudflare DNS API Token | ACME DNS-Challenge | Token-Rotation und Scope pruefen |
| GitHub Mirror Token | Push-Mirror | In Gitea/GitHub verwaltet, nicht im Repo |
| Tailscale Account Recovery | Tailnet-Zugang | Account-2FA/Recovery Codes sichern |
| SMTP Passwort | Authelia Mail | In Host-Secret, Fallback pruefen |
| Domain-Registrar Recovery | Domain-Besitz und Zahlung | Account, 2FA und Zahlungsweg ausserhalb des Homelabs sichern |
| Hetzner Storage Box Zugang | Off-site Backup-Ziel | SSH-/Web-Zugang und Zahlungsweg extern sichern |
## Ausfall-Szenarien
### Hetzner Storage Box nicht erreichbar
- Lokales Borg-Repo und aktuelle Dumps pruefen.
- Keine destruktiven Host-Aenderungen starten, solange Off-site unklar ist.
- Zweites Off-site-Ziel oder Cold-Platte als Folgeaufgabe umsetzen.
### Cloudflare Account/DNS gestoert
- Bestehende Zertifikate laufen bis Ablauf weiter.
- Keine Domain-/ACME-Aenderungen moeglich.
- Tailscale/LAN-Zugang als Break-glass nutzen.
### Tailscale gestoert
- Lokalen LAN-Zugang nutzen.
- Direkte Admin-Ports nur gemaess dokumentierten Ausnahmen verwenden.
- AdGuard-Admin-Bind muss so geplant werden, dass ein lokaler Break-glass-Weg bekannt ist.
- Seit 2026-05-26 ist AdGuard Admin nur ueber `100.80.98.33:8082` gebunden; bei Tailnet-Ausfall ist lokaler Host-/Compose-Zugriff der Break-glass-Weg.
### Domain verloren oder Registrar-Zugriff verloren
- Gitea/GitHub Mirror und lokale IP/Tailscale-Pfade fuer Recovery nutzen.
- Neue Domain waere separater Migrationsfall fuer Traefik, Authelia, App-URLs und Clients.
## Review
| Datum | Ergebnis | Naechste Aktion |
|---|---|---|
| 2026-05-26 | Bekannte externe Abhaengigkeiten aus Repo-/Betriebsdoku dokumentiert; keine Secret-Werte aufgenommen | Account-Besitz, 2FA-Recovery-Codes, Zahlungswege und Borg-Passphrase extern bestaetigen |
+38
View File
@@ -0,0 +1,38 @@
# Family Onboarding - KalliLab CORE
Status: Entwurf. Zielgruppe sind Familienmitglieder, nicht Operatoren.
## Zweck
Diese Datei soll spaeter kurz und alltagstauglich erklaeren, wie die wichtigsten Dienste genutzt werden und was bei Problemen zu tun ist. Keine Restore-Matrix, keine Docker-Begriffe.
## Dienste
| Dienst | URL | Zweck | Konto / Login | Notiz |
|---|---|---|---|---|
| Nextcloud | `https://cloud.kaleschke.info` | Dateien, Kalender, Kontakte | TBD | Mobile App/WebDAV/CardDAV |
| Immich | `https://immich.kaleschke.info` | Fotos und Smartphone-Backup | TBD | Backup-App pro Handy |
| Vaultwarden | `https://vault.kaleschke.info` | Passwoerter | TBD | Familien-Organisation pruefen |
| Mealie | `https://mealie.kaleschke.info` | Rezepte und Einkauf | TBD | TBD |
| Paperless | `https://paperless.kaleschke.info` | Dokumente | TBD | Scan-/Inbox-Prozess beschreiben |
| Plex | intern/App | Medien | TBD | TBD |
## Was tun bei Problemen?
| Situation | Verhalten |
|---|---|
| Webseite nicht erreichbar | 10 Minuten warten, dann Operator informieren |
| Passwort vergessen | Operator informieren, nicht selbst neue Konten anlegen |
| Handy-Foto-Backup stoppt | App oeffnen, WLAN/Batteriesparmodus pruefen, Operator informieren |
| 2FA verloren | Operator informieren; Recovery-Prozess wird separat festgelegt |
| Warnmeldung vom Browser | Nicht weiterklicken, Screenshot machen, Operator informieren |
## Offene Inhalte
| Status | Aufgabe |
|---|---|
| offen | Pro Dienst kurze Schritt-fuer-Schritt-Anleitung schreiben |
| offen | Konto-/2FA-Policy final entscheiden |
| offen | Immich Mobile Backup fuer alle Geraete testen |
| offen | Vaultwarden Familienorganisation pruefen |
+127
View File
@@ -0,0 +1,127 @@
# GitOps Drift Runbook
Dieses Runbook ist fuer Faelle, in denen Gitea, lokaler Clone, Komodo Workspace und Docker Runtime nicht sichtbar denselben Stand haben.
## Ziel
Vor jeder Reparatur muss klar sein, welche Ebene vom Sollzustand abweicht:
1. Lokaler Clone
2. Gitea `origin/master`
3. Komodo Stack Workspace auf dem Host
4. Laufender Docker-Container
5. Host-Netzwerklistener
Nicht mehrere Ebenen gleichzeitig reparieren. Erst messen, dann genau eine Abweichung beheben.
## Pflichtmatrix
### 1. Lokaler Clone
```bash
git status -sb
git rev-parse HEAD
git rev-parse origin/master
git ls-remote https://git.kaleschke.info/Micha/homelab-infra.git refs/heads/master
```
Alle Hashes muessen gleich sein, bevor Komodo oder Runtime bewertet werden.
### 2. Komodo Workspace
Auf dem Unraid-Host im Stack-Workspace:
```bash
cd /mnt/user/services/stacks/<stack-name>
git rev-parse --short HEAD
git status -sb
```
Bei Drift:
```bash
git fetch --all --prune
git reset --hard origin/master
```
Erst danach deployen.
### 3. Docker Runtime
```bash
docker inspect <container> --format '{{.Created}}'
docker inspect <container> --format '{{json .NetworkSettings.Networks}}'
docker inspect <container> --format '{{json .NetworkSettings.Ports}}'
docker inspect <container> --format '{{json .HostConfig.PortBindings}}'
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep <container-or-service>
```
Wichtig: `HostConfig.PortBindings` ist nur die Container-Spezifikation. Entscheidend fuer einen aktiven Host-Port sind `NetworkSettings.Ports`, `docker ps` und ein echter Listener.
### 4. Host-Port / Listener
```bash
ss -ltnp | grep <port>
curl -i --max-time 5 http://<host-ip>:<port>/
```
Ein `401 Unauthorized` ist bei geschuetzten APIs oft ein Erfolg: Dienst ist erreichbar, Auth fehlt nur beim Testrequest.
## Komodo/Periphery Checks
Wenn Komodo Stacks nicht aus Gitea deployen kann:
```bash
docker inspect komodo-periphery --format '{{range .Mounts}}{{println .Source "->" .Destination}}{{end}}'
docker inspect komodo-periphery --format '{{range $k,$v := .NetworkSettings.Networks}}{{println $k}}{{end}}'
docker exec komodo-periphery sh -lc 'getent hosts git.kaleschke.info'
docker exec komodo-periphery sh -lc 'wget -S -O- -T 5 --no-check-certificate https://git.kaleschke.info 2>&1 | head -40'
```
Sollzustand:
- `/mnt/user/services -> /mnt/user/services` ist gemountet.
- `komodo_net` und `frontend_net` sind verbunden.
- `git.kaleschke.info` loest auf `192.168.178.58` auf.
- HTTPS zu Gitea antwortet.
## InfluxDB LAN-Port Beispiel
Soll fuer Home Assistant:
```bash
cd /mnt/user/services/stacks/monitoring
git fetch --all --prune
git reset --hard origin/master
docker compose --env-file .env -p monitoring -f monitoring/docker-compose.yml up -d --force-recreate --no-deps influxdb3-core
```
Danach pruefen:
```bash
docker network ls | grep -E "monitoring|influx"
docker inspect monitoring-influxdb3-core --format '{{json .NetworkSettings.Networks}}'
docker inspect monitoring-influxdb3-core --format '{{json .NetworkSettings.Ports}}'
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep influx
ss -ltnp | grep 8181
curl -i --max-time 5 http://192.168.178.58:8181/
```
Erwartung:
- Komodo Workspace `HEAD` entspricht `origin/master`.
- `monitoring-influxdb3-core` haengt an `monitoring_monitoring_net` und `monitoring_monitoring_influx_lan`.
- Docker zeigt `192.168.178.58:8181->8181/tcp`.
- `ss` zeigt `docker-proxy` auf `192.168.178.58:8181`.
- `curl` bekommt `401 Unauthorized` von InfluxDB.
Hinweis: Im Compose-File heissen die Netze `monitoring_net` und `monitoring_influx_lan`. Durch den Compose-Projektnamen `monitoring` koennen daraus zur Laufzeit Docker-Netze mit Projektpraefix werden.
## Stop-Regel
Wenn zwei Reparaturversuche nicht zum erwarteten Ergebnis fuehren:
1. Keine weiteren Schreibbefehle.
2. Pflichtmatrix ausfuellen.
3. Genau eine abweichende Ebene benennen.
4. Erst danach einen neuen Fix ausfuehren.
+187
View File
@@ -0,0 +1,187 @@
# Hardware Inventory - KalliLab CORE
Status: Hardware-Baseline erfasst; USV/Power-Loss bleibt offene Betreiberentscheidung.
Host: `Kallilabcore`
Letzte Pruefung: 2026-05-26
Naechster Review: 2026-08-26
## Zweck
Dieses Dokument beschreibt die physische Basis des Homelabs. Es ist die Grundlage fuer Capacity Planning, Restore-Zeit, Ersatzteilplanung, USV-Verhalten und Entscheidungen wie Immich-ML, Plex-Transcoding oder Storage-Erweiterung.
## Host
| Feld | Wert |
|---|---|
| Hostname | Kallilabcore |
| Standort | Heim-LAN, physischer Standort TBD |
| Betriebssystem | Unraid |
| Unraid-Version | 7.2.4 |
| Rolle | Single-Host Homelab, Docker Compose via Komodo |
| Boot-Medium | Samsung Flash Drive, 59.8G, FAT32 |
| Flash-Backup | In Borg-Scope aufgenommen, siehe `docs/MIGRATION_LOG.md` |
## CPU
| Feld | Wert |
|---|---|
| Modell | 12th Gen Intel(R) Core(TM) i5-12400F |
| Kerne / Threads | 6 Kerne / 12 Threads |
| Architektur | x86_64 |
| Relevante Flags | AVX, AVX2, FMA, AES, VT-x vorhanden; kein AVX-512 |
| iGPU / Quick Sync | Nein, `F`-CPU ohne iGPU |
Pruefkommando:
```bash
cat /proc/cpuinfo | awk '/model name|flags/ {print; if(/flags/) exit}'
lscpu
```
## RAM
| Feld | Wert |
|---|---|
| Gesamt | 31 GiB |
| Belegt im Normalbetrieb | ca. 7.9 GiB genutzt, ca. 23 GiB verfuegbar |
| Slots / Ausbau | 4x 8 GB DDR4 belegt, gemischte Module |
| Module | Crucial CT8G4DFS8266.C8FE, Crucial CT8G4DFS8213.C8FDD1, 2x G.Skill F4-3600C17-8GVK |
| Konfigurierter Takt | 2133 MT/s |
| ECC | Nein |
Pruefkommando:
```bash
free -h
dmidecode -t memory | grep -E "Size|Speed|Locator|Type" | head -40
```
## Mainboard und Controller
| Feld | Wert |
|---|---|
| Mainboard | Gigabyte Technology Co., Ltd. B760M DS3H DDR4 |
| BIOS/Firmware | American Megatrends International F21, Release 2025-06-19 |
| SATA/HBA Controller | Intel Raptor Lake SATA AHCI Controller, onboard |
| NVMe Controller | Samsung SM981/PM981/PM983 NVMe Controller |
| NVMe Slots | mindestens 1 belegt |
Pruefkommando:
```bash
dmidecode -t baseboard | head -30
lspci
```
## Netzwerk-Hardware
| Interface | Speed | Rolle | Bemerkung |
|---|---:|---|---|
| eth0 / bond0 / br0 | 1 Gbit/s full duplex | LAN | Realtek RTL8125 2.5GbE Controller, Link aktuell 1G; Host-IP `192.168.178.58/24`, Gateway `192.168.178.1` |
| tailscale1 | virtuell | VPN | Tailscale IPv4 `100.80.98.33` |
Pruefkommando:
```bash
ip -br link
ethtool <interface>
tailscale ip -4
```
## Storage
| Slot | Device | Modell | Seriennummer | Groesse | Filesystem | Rolle | Health |
|---|---|---|---|---:|---|---|---|
| Cache | `nvme0n1p1` | Samsung SSD 970 EVO Plus 2TB | `S4J4NM0W609649H` | 1.8T | XFS | Appdata/system/domains | SMART passed |
| Disk1 | `md1p1` / physisch `sdc` | WDC WD60EFAX-68JH4N1 | `WD-WX32D90PC0V0` | 5.5T | XFS auf md1p1 | Array-Daten | SMART passed |
| Parity | physisch `sdb` | TOSHIBA HDWG480 | `2460A03VFA3H` | 7.3T | n/a | Parity | SMART passed |
| Boot | `sda1` | Samsung Flash Drive | `0375125090000587` | 59.8G | FAT32 | Unraid Boot | aktiv |
| Cold Backup | TBD | TBD | TBD | TBD | TBD | Externe Rotation | offen |
Pruefkommando:
```bash
lsblk -o NAME,SIZE,MODEL,SERIAL,FSTYPE,MOUNTPOINT,VENDOR
findmnt -no FSTYPE /mnt/cache /mnt/disk1 /boot
df -h /mnt/cache /mnt/disk1 /mnt/user
```
## SMART / Health
| Device | Letzter Check | Kritische Werte | Bewertung |
|---|---|---|---|
| /dev/nvme0n1 | 2026-05-26 | Critical Warning `0x00`, Percentage Used `0%`, Media Errors `0`, Power On Hours `370`, Written `5.87 TB` | gut |
| /dev/sdb | 2026-05-26 | Reallocated `0`, Pending `0`, Uncorrectable `0`, CRC `1`, Power On Hours `8971` | gut, CRC-Wert beobachten |
| /dev/sdc | 2026-05-26 | Reallocated `0`, Pending `0`, Uncorrectable `0`, CRC `0`, Power On Hours `14174` | gut |
Pruefkommando:
```bash
smartctl -a /dev/nvme0n1
smartctl -a /dev/sdb
smartctl -a /dev/sdc
```
## USV / Power Loss
| Feld | Wert |
|---|---|
| USV vorhanden | Nicht validiert / keine erkannte USV |
| Modell | Kein APC/Eaton/CyberPower-Geraet per `lsusb` erkannt |
| Verbindung | `apcupsd` ist auf USB vorkonfiguriert, aber kein passendes USB-USV-Geraet sichtbar |
| Software | `apcaccess` vorhanden; `apcupsd` laeuft nicht, `localhost:3551` liefert Connection refused |
| Konfigurierte Schwellen | `BATTERYLEVEL 5`, `MINUTES 3`, `TIMEOUT 0`, aber inaktiv solange `apcupsd` nicht laeuft |
| Laufzeit im Idle | Nicht messbar |
| Letzter Shutdown-Test | Nicht durchgefuehrt |
Bewertung:
- Aktueller Befund 2026-05-26: keine funktionierende USV-Absicherung nachgewiesen.
- `apcupsd` ist zwar auf dem System vorhanden, aber nicht aktiv.
- Power-Loss bleibt damit ein bewusst offenes Risiko fuer Docker-/DB-State und laufende Writes.
- Naechste Entscheidung: echte USV anschliessen und Shutdown testen oder Risiko bewusst akzeptieren und dokumentieren.
## Stromverbrauch
| Zustand | Verbrauch | Messmethode | Datum |
|---|---:|---|---|
| Idle | TBD | externes Messgeraet erforderlich | TBD |
| Normalbetrieb | TBD | externes Messgeraet erforderlich | TBD |
| Backup-Lauf | TBD | externes Messgeraet erforderlich | TBD |
| Last | TBD | externes Messgeraet erforderlich | TBD |
## Ersatzteil- und Lifecycle-Plan
| Komponente | Trigger | Massnahme |
|---|---|---|
| Cache-NVMe | >70 % Fuellstand oder SMART-Warnung | Zweite NVMe / Pool-Entscheidung; aktuell 6 % belegt |
| Disk1 | >80 % Fuellstand oder SMART-Warnung | Array-Erweiterung / Ersatz; aktuell 33 % belegt |
| Parity | Kleiner als neue groesste Datenplatte | Parity-Upgrade vor Datenplatten-Upgrade |
| Boot-USB | Lesefehler oder Alter TBD | Flash-Backup verifizieren, Ersatzstick vorbereiten |
| RAM | Swap/OOM oder Immich/Nextcloud-Druck | Ausbau planen |
| USV | keine funktionierende USV-Abschaltung | USV anschaffen/anschliessen oder Risiko schriftlich akzeptieren |
## Audit-Kommandos
```bash
hostname
uname -a
cat /etc/unraid-version 2>/dev/null || true
lscpu
free -h
dmidecode -t baseboard | head -30
dmidecode -t bios -t system -t baseboard
dmidecode -t memory | grep -E "Size|Speed|Locator|Type" | head -40
lspci | egrep -i 'sata|ahci|raid|nvme|ethernet|network'
ip -br link
ethtool eth0
tailscale ip -4
lsblk -o NAME,SIZE,MODEL,SERIAL,FSTYPE,MOUNTPOINT,VENDOR
df -Th /mnt/cache /mnt/disk1 /mnt/user /boot
smartctl -a /dev/nvme0n1 | head -100
smartctl -a /dev/sdb | head -100
smartctl -a /dev/sdc | head -100
apcaccess status
/etc/rc.d/rc.apcupsd status
lsusb
```
+138
View File
@@ -0,0 +1,138 @@
# Home Assistant -> InfluxDB 3 -> Grafana
Ziel: Home Assistant schreibt ausgewaehlte Ecowitt- und Energiesensoren nach InfluxDB 3 Core. Grafana bleibt das Langzeit-Dashboard, Home Assistant bleibt die Automationszentrale.
## Live-Stand 2026-05-04
- Home Assistant ist per SSH unter `192.168.178.50:22222` erreichbar.
- `ha core check` ist erfolgreich.
- InfluxDB 3 Core ist von Home Assistant aus unter `http://192.168.178.58:8181/` erreichbar; `401 Unauthorized` ohne Token ist der erwartete Reachability-Test.
- In `/homeassistant/configuration.yaml` ist noch kein `influxdb:`-Block aktiv.
- In `/homeassistant/secrets.yaml` ist noch kein `influxdb3_homeassistant_token` eingetragen.
- In der Home-Assistant-Entity-Registry ist noch keine Ecowitt-Integration sichtbar; aktuell existiert nur `weather.forecast_home`.
## 1. InfluxDB fuer Home Assistant erreichbar machen
Der Stack haelt InfluxDB bewusst ohne Traefik-Route. Fuer Home Assistant wird nur der HTTP-Port `8181` auf einer internen LAN-Adresse veroeffentlicht.
Im Zielzustand in Komodo/Stack-Environment fuer `monitoring` setzen:
```env
INFLUXDB_BIND_IP=192.168.178.58
```
`192.168.178.58` ist die LAN-IP des Docker-Hosts, auf dem `monitoring-influxdb3-core` laeuft. Nicht `0.0.0.0` verwenden, wenn es nicht notwendig ist.
Danach den Stack neu deployen und von Home Assistant aus pruefen:
```bash
curl -i --max-time 5 http://192.168.178.58:8181/
```
Erwartetes Ergebnis ohne Token: `401 Unauthorized`. Das bestaetigt, dass der LAN-Port erreichbar ist und Authentifizierung aktiv bleibt.
## 2. Token fuer Home Assistant
InfluxDB 3 Core unterstuetzt aktuell Admin- und Named-Admin-Tokens. Einen eigenen Named-Admin-Token fuer Home Assistant verwenden, damit der Token getrennt vom initialen Operator-/Admin-Token rotiert werden kann.
Den Token in Home Assistant eintragen:
```yaml
# /homeassistant/secrets.yaml
influxdb3_homeassistant_token: "apiv3_REPLACE_WITH_HOME_ASSISTANT_TOKEN"
```
Token niemals ins Git-Repository schreiben.
## 3. Ecowitt in Home Assistant anbinden
Die offizielle Ecowitt-Integration ist eine lokale Push-Integration. Home Assistant erzeugt einen HTTP-Webhook; die Wetterstation sendet danach ihre Daten aktiv an Home Assistant.
1. In Home Assistant `Einstellungen -> Geraete & Dienste -> Integration hinzufuegen -> Ecowitt` oeffnen.
2. Die angezeigten Werte fuer Server/IP, Pfad und Port notieren.
3. Im Ecowitt-Gateway entweder per App oder Web-UI unter `Weather Services -> Customized` eintragen:
- Customized: `Enable`
- Protocol Type Same As: `Ecowitt`
- Server/IP, Path und Port exakt wie in Home Assistant angezeigt
4. Wichtig: Ecowitt kann kein HTTPS. Den lokalen HTTP-Endpunkt von Home Assistant verwenden, nicht eine reine HTTPS-/Traefik-URL.
5. Nach dem ersten Push pruefen, ob neue `sensor.*`- und ggf. `binary_sensor.*`-Entities in Home Assistant auftauchen.
## 4. Home Assistant fuer InfluxDB konfigurieren
Minimaler Startblock fuer `/homeassistant/configuration.yaml`:
```yaml
influxdb:
api_version: 2
ssl: false
host: 192.168.178.58
port: 8181
token: !secret influxdb3_homeassistant_token
organization: homelab
bucket: homelab
max_retries: 3
default_measurement: state
include:
entity_globs:
- sensor.*ecowitt*
- sensor.*gw*
- sensor.netzbezug_live
- sensor.ins_netz_live
- sensor.ins_haus_live
- sensor.pv_ueberschuss_live
- sensor.batterie_soc_live
- sensor.wallbox_leistung_live
- sensor.pv_produktion_heute
- sensor.wallbox_energie_heute
```
Die `sensor.*ecowitt*`- und `sensor.*gw*`-Regeln sind nur Startpunkte. Nach dem ersten Ecowitt-Push die echten Entity-IDs aus Home Assistant auslesen und die Liste enger machen.
Nach dem Edit:
```bash
ha core check
ha core restart
```
## 5. Grafana Smoke-Test
In `https://monitoring.kaleschke.info` mit der bestehenden Datenquelle `InfluxDB 3 Core` eine SQL-Abfrage testen:
```sql
SHOW TABLES
```
Danach eine konkrete Tabelle pruefen. Home Assistant schreibt Measurements je nach Konfiguration und Entity-Typ; typische Tabellen sind `state`, `degC`, `%`, `W`, `kWh` oder aehnliche Measurement-Namen.
Beispiel fuer die ersten Werte:
```sql
SELECT *
FROM state
ORDER BY time DESC
LIMIT 20
```
## 6. Dashboard-Kandidaten
Sinnvolle Panels fuer das erste Wetter-Dashboard:
- Aussentemperatur und Luftfeuchte
- Luftdruck
- Windgeschwindigkeit und Boeen
- Regenrate und Regen heute
- UV und Solarstrahlung
- Batterielevel der Ecowitt-Sensoren
- PV-Ueberschuss vs. Wetter
- Wallbox-Leistung vs. PV-Ueberschuss
## 7. Erste Automationen
Home Assistant bleibt fuer Reaktionen zustaendig:
- Regen startet -> Dachfenster/Fenster pruefen
- Windboee ueber Grenzwert -> Markise/Rolllaeden schuetzen
- Frostwarnung -> Garten/Wasser/Auto-Hinweis
- Genug Regen heute -> Bewaesserung ueberspringen
- Ecowitt-Batterie niedrig -> ntfy/HA-Benachrichtigung
+345 -108
View File
@@ -1,118 +1,355 @@
# Migration Log Homelab GitOps
# Migration Log - Homelab GitOps
Dieses Dokument verfolgt den Fortschritt der Migration hin zu einem vollständigen GitOps-Setup (Gitea + Komodo).
Dieses Dokument ist nur noch ein historischer Verlauf. Der aktuelle operative Ablauf steht in `docs/WORKFLOW.md`, das Zielbild in `HOMELAB_ARCHITECTURE_MASTER_V2.md`.
## Status-Legende
- ⏳ = geplant
- 🔄 = in Bearbeitung
- ✅ = abgeschlossen
- ⚠️ = Problem / prüfen
## Aktueller Endstand
- Gitea Online ist der verbindliche Sollzustand.
- Komodo ist der einzige produktive Stack-Manager.
- Portainer CE ist entfernt.
- Firefly, Firefly-Fints und Semaphore sind entfernt.
- `monitoring/` ist der einzige aktive Observability-Stack; alte Repo-Pfade `ops/grafana-influxdb` und `ops/loki` sind entfernt.
- Borg UI ist produktiv, Dump-Automatisierung laeuft host-seitig und ein Restore-Smoke-Test wurde erfolgreich durchgefuehrt.
- GitHub Desktop ist der bevorzugte lokale Workflow fuer `Fetch`, `Pull`, `Commit` und `Push`.
- Mutable Image-Tags sind auf die aktuell laufenden Digests eingefroren.
---
## Sprint 1 — Quick Wins + Vaultwarden ✅
## Historische Meilensteine
| Service | Status | Ergebnis |
|---|---|---|
| Vaultwarden | ✅ | Git-Stack, Traefik, `ADMIN_TOKEN_FILE`, Port entfernt |
| PostgreSQL 17 | ✅ | `backend_net`, Port entfernt, `POSTGRES_PASSWORD_FILE` |
### 2026-05-26 - Audit-Baseline-Tag gesetzt
- Der Stand nach Hardware-/Capacity-Baseline, Policy-Triage und Recovery-Doku wurde als `audit-2026-05-25-baseline` markiert und nach Gitea gepusht.
### 2026-05-26 - Externe Abhaengigkeiten und Services-Recovery baseline dokumentiert
- `docs/EXTERNAL_DEPENDENCIES.md` von Template auf Betreiber-Baseline angehoben: Domain, Cloudflare, Hetzner, GitHub-Mirror, Tailscale, GMX, Let's Encrypt, Registries, Plex und mobile Push-Pfade sind mit Ausfallwirkung und Notfallplan dokumentiert.
- `docs/SERVICES_RECOVERY.md` finalisiert den Komodo-Bootstrap-Anker: `ops/komodo/docker-compose.yml` bleibt verbindlich; der `komodo`-Self-Stack hat keinen aktiven Gitea-Webhook und ist nicht der Recovery-Anker.
- Offene Off-Repo-Betreiberchecks bleiben Account-Besitz, 2FA-Recovery-Codes, Zahlungswege, Borg-Passphrase-Hinterlegung und Gitea-Bundle-/Mirror-Mechanik.
### 2026-05-26 - Policy-Warnings triagiert
- Plex `network_mode: host` wurde in den Policy-Ausnahmen als dokumentierte Discovery-Ausnahme erfasst.
- Mutable Tags bei `ddns-updater`, `glances` und `scrutiny` bleiben wegen vorhandener SHA256-Digests reproduzierbar gepinnt und werden im Policy-Report als Info-Ausnahmen sichtbar gehalten.
- `monitoring-influxdb3-core` bleibt als dokumentierte `user: "0"`-Ausnahme bewusst eine Warning, damit der Hardening-Punkt nicht aus dem Blick faellt.
### 2026-05-26 - Hardware-/Capacity-Baseline abgeschlossen
- Hardware-Inventar auf Host-Befund aktualisiert: BIOS AMI F21 vom 2025-06-19, Intel Raptor Lake SATA AHCI, Samsung NVMe Controller und Realtek RTL8125 2.5GbE mit aktuellem 1G-Link.
- RAM-Baseline dokumentiert: 4x 8 GB DDR4 ohne ECC, gemischte Module, aktuell 2133 MT/s konfiguriert.
- Capacity-Baseline dokumentiert: Cache 1.9T mit 97G genutzt (6 %), Disk1/User-Shares 5.5T mit 1.8T genutzt (33 %), lokale Backups 2.2G unter `/mnt/user/backups`.
- USV-Befund dokumentiert: `apcupsd` ist vorhanden und auf USB vorkonfiguriert, laeuft aber nicht; `apcaccess status` liefert Connection refused und `lsusb` zeigt keine erkannte USV. Power-Loss bleibt damit eine offene Betreiberentscheidung.
### 2026-05-26 - Komodo/Gitea-Restdrift bereinigt
- Der alte Komodo-Stack `grafana` wurde als historischer Altstand inert gemacht: keine Repo-Dateipfade, kein Webhook, keine alte Stack-ENV, keine `missing_files`/`remote_errors`. Rollback bleibt Git-Historie, nicht der alte Komodo-Stack.
- Der Gitea-Hook `35` fuer den alten `grafana`-Stack bleibt inaktiv. Der nicht sinnvolle `komodo`-Self-Hook `11` wurde deaktiviert, weil Komodo selbst nicht per Gitea-Webhook auf `master` deployed wird.
- Ein kurz sichtbarer Komodo-DB-Typfehler durch `updated_at` als Float wurde im selben Kontrollfenster auf nativen Mongo `Long` korrigiert; danach traten keine neuen `invalid type`-Fehler mehr auf.
- Nach der Bereinigung: aktive Gitea-Komodo-Hooks haben `0` Fehlstatus; `komodo-core`, `komodo-periphery`, `komodo-mongo`, `nextcloud` und die aktuellen `monitoring-*` Container laufen weiter.
### 2026-05-26 - Monitoring-Altstaende aus aktivem Repo entfernt
- Die abgeloesten Pfade `ops/grafana-influxdb` und `ops/loki` wurden per `git rm` aus dem aktiven Repo entfernt. `monitoring/` bleibt der einzige Observability-Zielstack.
- Live-Check vor dem Cleanup: nur `monitoring-grafana`, `monitoring-promtail`, `monitoring-influxdb3-core` und `monitoring-loki` laufen; alte Container `grafana`, `influxdb3-core`, `loki` und `alloy` sind nicht vorhanden.
- Rollback erfolgt bei Bedarf ueber Git-Historie, nicht ueber parallel gepflegte Compose-Verzeichnisse.
- Im selben GitOps-Kontrollfenster wurde Gitea-Webhook `35` fuer den alten `grafana`-Rollback-Stack als inaktiv bestaetigt. Der aktive Nextcloud-Hook `36` hatte einen Signaturfehler; sein Secret wurde ohne Ausgabe des Werts aus der Komodo-Stack-Konfiguration zurueck nach Gitea synchronisiert.
### 2026-05-26 - AdGuard Admin-Port auf Tailscale-Soll begrenzt
- Host-Audit per SSH gegen `Kallilabcore` durchgefuehrt: Tailscale IPv4 ist `100.80.98.33`, LAN-IP ist `192.168.178.58/24`, Gateway `192.168.178.1`.
- Repo-Soll fuer `host-services/Adguard/docker-compose.yml` geaendert: DNS `53/tcp+udp` bleibt unveraendert, die Admin-UI bindet nun auf `100.80.98.33:8082:80`.
- Architektur, Service-Katalog, Repo-Map, Netzwerk-Inventar und AI-Kontext wurden an das neue Modell angepasst: keine Traefik-/Authelia-2FA-Umstellung, aber keine LAN-weite Admin-Bindung mehr.
- Live-Deploy wurde nach Fast-Forward des AdGuard-Workspaces auf `5cb4017` mit `docker compose -p adguard ... up -d` ausgefuehrt. Validierung erfolgreich: `ss -ltnp` zeigt `100.80.98.33:8082`, DNS via `@127.0.0.1` und `@192.168.178.58` funktioniert, `http://100.80.98.33:8082/` liefert HTTP 302, `http://192.168.178.58:8082/` ist nicht mehr erreichbar.
- Nachpruefung des GitOps-Pfads: Gitea-Hook `1` zeigt auf Komodo-Stack `69c7b9e26b77cd827811b9d0` und lieferte HTTP 200. Komodo-Deploys fuer AdGuard scheiterten zunaechst im `Git pull` einmal an `.git/index.lock` und danach an `fatal: Cannot rebase onto multiple branches`; der Workspace wurde aufgeraeumt und steht sauber auf `origin/master`.
### 2026-05-26 - Audit-Umsetzung vorbereitet
- Aus `docs/AUDIT_2026-05-25.md` wurde `docs/AUDIT_2026-05-25_TODO.md` als operative Arbeitsliste abgeleitet. Authelia-2FA/OIDC bleibt bewusst geparkt und wird erst nach finaler Policy-Entscheidung umgesetzt.
- Neue Inventar- und Betriebsdokumente angelegt: `docs/HARDWARE_INVENTORY.md`, `docs/NETWORK_INVENTORY.md`, `docs/EXTERNAL_DEPENDENCIES.md`, `docs/CAPACITY_AND_LIFECYCLE.md` und `docs/FAMILY_ONBOARDING.md`.
- `docs/SERVICES_RECOVERY.md` beschreibt initial die recovery-kritischen `/mnt/user/services`-Pfade, Gitea-Repo-Mirror-Optionen, Komodo-Bootstrap und Secret-Recovery-Reihenfolge.
- Policy-Check lokal erneut ausgefuehrt: die alten SEC001-Warnings fuer `ddns-updater` und `scrutiny` sind nicht mehr aktuell; verbleibende Warnings betreffen Host-Netz-/User-/Image-Tag-Themen und Altstaende.
### 2026-05-25 - Unraid Flash-Backup in Borg-Scope aufgenommen
- `pre-backup-dumps.sh` erzeugt zusaetzlich zu den DB-Dumps ein sensibles `unraid-flash-config.tar.gz` aus `/boot/config` inklusive SHA256 und Manifest unter `/mnt/user/backups/borg/dumps/latest`.
- Da `/local/borg-dumps` bereits Teil des Borg-Scopes ist, wird das Flash-Konfigurationsartefakt mit dem bestehenden Hetzner/Borg-Backup historisiert. Downloadbare Plugin-Paketarchive unter `/boot/config/plugins/*/` werden aus dem Artefakt ausgeschlossen; Restore-relevante Konfiguration bleibt enthalten.
- Live-Erstlauf erfolgreich: `pre-borg.sh` lieferte `critical_count=0`, Freshness `Critical: 0`, `unraid-flash-config.tar.gz` ist 297 KiB gross, `0600 root:root`, SHA256-Pruefung `OK`, 356 Archiv-Eintraege, Manifest fuer Unraid `7.2.4`. Der Borg-UI-Job `Taegliche Sicherung` ist aktiv und umfasst `/local/borg-dumps`; der naechste planmaessige Hetzner-Lauf nimmt das neue Flash-Artefakt mit. Der Host-Repo-Clone unter `/mnt/user/services/homelab-infra` wurde wegen eines fehlgeschlagenen Fast-Forward-Checkouts frisch geklont; der vorherige Stand liegt archiviert unter `/mnt/user/services/_archive/homelab-infra-pre-refresh-20260525-194209`.
### 2026-05-25 - Monitoring-Zielstack finalisiert und Uptime Kuma entfernt
- `monitoring` und `glance` wurden auf Commit `b6bbca4` deployed; Komodo zeigt fuer beide `latest_hash` = `deployed_hash` = `b6bbca4` ohne `remote_errors`. Die zehn `monitoring-*` Container laufen, `monitoring.kaleschke.info` und `glance.kaleschke.info` leiten anonym zu Authelia, Prometheus ist ready und Loki `/ready` liefert `ready`.
- Alte Monitoring-Altcontainer `grafana`, `influxdb3-core`, `loki` und `alloy` sind in Docker nicht vorhanden; `ops/grafana-influxdb` und `ops/loki` bleiben nur als Rollback-/Migrationsreferenz im Repo. Der noch aktive Gitea-Hook `35` des alten `grafana`-Rollback-Stacks wurde deaktiviert, damit zukuenftige Pushes den Altstand nicht reaktivieren.
- Uptime Kuma wurde durch Blackbox/Prometheus/Grafana ersetzt: aktive Blackbox-Zielliste enthaelt 19 HTTPS-Ziele, `uptime.kaleschke.info` ist dort nicht mehr enthalten und liefert nach Stack-Removal 404. Der Komodo-Stack `uptime-kuma` wurde gestoppt/destroyed/geloescht, Gitea-Webhook `23` deaktiviert, Appdata nach `/mnt/user/appdata/_archive/uptime-kuma-removed-2026-05-25` und der alte Stack-Workspace nach `/mnt/user/services/stacks/_archive/uptime-kuma-removed-2026-05-25` verschoben.
- Authelia-Hostconfig wurde mit Backup `configuration.yml.pre-uptime-removal-20260525-164343.bak` um den toten `uptime.kaleschke.info`-Eintrag bereinigt, validiert und neu gestartet. Prometheus wurde wegen eines `Stale NFS file handle` auf der gebundenen Konfigurationsdatei per Komodo-Restart neu gemountet.
### 2026-05-25 - AdGuard Admin-Port bewusst LAN-direkt belassen
- Strategische Option `adguard.kaleschke.info` hinter Traefik/Authelia-2FA wurde bewertet, aber vom Operator bewusst verworfen, weil der Betriebsweg einfach bleiben soll. AdGuard bleibt als dokumentierte Ausnahme mit DNS `53/tcp+udp` und Admin `8082:80` LAN-direkt; keine Live-Aenderung an AdGuard, Authelia oder Traefik wurde vorgenommen.
### 2026-05-25 - Borg-Passphrase Host-Secret verifiziert
- Erwartete Host-Secret-Datei `/mnt/user/appdata/secrets/borg_repo_passphrase.txt` aus der bestehenden Borg-UI-Repo-Konfiguration erzeugt, mit `root:root` und Modus `600` gesichert und per `borg info` gegen das Hetzner-Borg-Repo verifiziert. Analoge Offline-Hinterlegung bleibt bewusste Operator-Aufgabe; Secret-Wert wurde nicht ausgegeben oder dokumentiert.
### 2026-05-25 - Dashboard auf Glance konsolidiert
- Glance bleibt das einzige Homelab-Dashboard; Homepage wurde aus dem Zielbild entfernt. Authelia-Default-Redirect, Monitoring-Blackbox-Ziele, Cert-Check-Domains und Glance-Konfiguration zeigen nicht mehr auf `home.kaleschke.info`; Homepage wurde via Komodo-API gestoppt/destroyed, der Komodo-Stack geloescht, der alte Gitea-Webhook deaktiviert und Appdata nach `/mnt/user/appdata/_archive/homepage-removed-2026-05-25` verschoben.
### 2026-05-25 - Jellyfin aus Zielbild entfernt
- Plex-Smoke-Test erfolgreich (`/identity` HTTP 200, Container healthy, `/data/movies` und `/photos` sichtbar). Jellyfin wurde repo-seitig entfernt, aus Authelia-Baseline und Zielbild-Doku ausgetragen, via Komodo-API gestoppt/destroyed, der Komodo-Stack geloescht und Appdata nach `/mnt/user/appdata/_archive/jellyfin-removed-2026-05-25` verschoben; Plex bleibt einziger Medienserver.
### 2026-05-25 - Externer Repo-Mirror eingerichtet
- Gitea erlaubt fuer Repo-Migrationen und Mirror-Targets gezielt `github.com` und nutzt explizite externe DNS-Resolver. `Micha/homelab-infra` spiegelt nun als privater GitHub-Push-Mirror nach `michaelkaleschke-spec/homelab-infra`; erster manueller Sync erfolgreich, Gitea `push_mirror.last_error` leer. Token-Werte bleiben ausschliesslich in Gitea/GitHub und werden nicht dokumentiert.
### 2026-05-25 - Audit-Final nachgemessen
- Audit-Restliste erneut live geprueft: runtime-relevanter Stack-Inhalt fuer `gitea`, `borg-ui` und `monitoring` seit `66ee10c` unveraendert; abschliessende Audit-Doku-Commits liegen in Gitea; Monitoring inklusive Loki `/ready` gruen; Borg-Job und 15 kanonische Dump-Artefakte frisch; `docs/AUDIT_2026-05-23_FINAL.md` auf den Live-Stand aktualisiert.
### 2026-05-25 - Disk1 Phase 2 abgeschlossen
- Disk1 wurde nach H:-Freeze-Backup und finalem Service-Freeze von NTFS/`ntfs3` auf XFS migriert.
- Restore verifiziert: `media` final 2722 Dateien und 1,800,782,188,226 Bytes mit 0 missing/extra/size mismatch; Tar-Shares und Disk1-Extras aus den H:-Freeze-Archiven wiederhergestellt.
- Docker/Services nach XDG-Runtime-Fix wieder stabil: 49 Container laufend, 0 stopped, 0 unhealthy, 0 starting; Gitea, Komodo, Borg, Jellyfin und Monitoring per Smoke-Test erreichbar.
- Borg-UI meldet den letzten Backup-Job `completed`; `pre-backup-dumps.sh` wurde nach Wiederanlauf erneut ausgefuehrt und 15 kanonische Dump-Artefakte sind juenger als 24 h.
- `posture-check` erwartet Disk1 nun standardmaessig als XFS (`ALLOW_DISK1_NTFS=0`).
### 2026-05-23 - Audit-Endstufe verifiziert
- Lokalen Hardening-Commit `cd650b1` nach Gitea gepusht; Komodo-Workspaces fuer `gitea`, `borg-ui` und `monitoring` stehen auf `cd650b1`.
- Live-Audit in `docs/AUDIT_2026-05-23_LIVE.md` dokumentiert: Gitea-Registration geschlossen, Borg-Dumps frisch, Monitoring-Stack aktiv, alte Grafana/Loki-Altcontainer nicht mehr vorhanden.
- Jellyfin und Plex in Architektur, Service-Katalog und Repo-Map nachgetragen; Plex ist jetzt als Repo-Compose-Stack mit dokumentierter Host-Netz-Ausnahme gefuehrt.
- Repo-Hygiene abgeschlossen: `.serena/` ignoriert, leere Verzeichnisse entfernt, Windows-Reinstall-Helfer unter `ops/windows-reinstall/` bewusst versioniert.
### 2026-05-20 - Gitea 5xx-Bursts untersucht und Signup geschlossen
- Live-Befund zu `HomelabTraefik5xx`: kurze externe `POST /`-Bursts auf `gitea@docker` von `103.153.183.69` und `103.153.183.73`, jeweils HTTP 500 in unter 10 ms; normale Gitea-Checks und Git-Reads liefen parallel mit HTTP 200.
- Keine Hinweise auf erfolgreichen Zugriff: Gitea-Container ohne Restart/OOM, nur User `micha`, keine neuen User der letzten 30 Tage, keine neuen Repos, SSH-Keys oder Access-Tokens im Untersuchungsfenster.
- Live-Prometheus lief noch mit der alten Regel `rate(...[5m]) > 0`; die bereits im Repo vorbereitete Regel `increase(...[5m]) >= 5` wurde auf den Live-Mount kopiert und per Prometheus-Reload aktiviert.
- Gitea-Registrierung und OpenID-Signup wurden geschlossen: `DISABLE_REGISTRATION=true`, `REGISTER_EMAIL_CONFIRM=true`, `ENABLE_OPENID_SIGNIN=false`, `ENABLE_OPENID_SIGNUP=false`; Signup-Seite zeigt danach "Registration is disabled", OpenID-Login liefert 403.
### 2026-05-18 - Komodo Webhooks vollstaendig abgeglichen
- Live-Befund auf `Kallilabcore`: Komodo hatte fuer mehrere aktuelle Stacks `webhook_enabled: true`, aber Gitea enthielt noch nicht fuer alle aktuellen Stack-IDs aktive Webhooks.
- In der Gitea-Datenbank wurden aktive Webhooks fuer `monitoring` (`6a08d5297707b0930ab95c72`), `glance` (`6a09d7347707b0930ab96eae`), `grafana` (`69f31ecdf65eb72b757c497d`) und `nextcloud` (`69e519085fd5e8bc51f121f0`) nach dem bestehenden Komodo-Hook-Muster angelegt.
- Stale aktive Gitea-Hooks auf nicht mehr vorhandene bzw. alte Komodo-Stack-IDs wurden deaktiviert.
- Abgleich danach: 30 aktive Gitea-Komodo-Hooks fuer 30 Komodo-Stacks mit aktiviertem Webhook; `hermes` bleibt in Komodo bewusst `webhook_enabled: false`.
- Netzwerkpfad aus dem `gitea`-Container zu `komodo-core:9120` wurde erfolgreich verifiziert; `last_status=0` fuer neue Hooks bleibt bis zum ersten Push erwartbar.
### 2026-05-19 - Posture-Check Host-Version verifiziert
- Ursache fuer wiederholte ntfy-Warnings war nicht mehr die Repo-Logik allein, sondern dass auf dem Unraid-Host noch die alte Skriptversion unter `/mnt/user/services/homelab-infra/services/posture-check/posture-check.sh` ausgefuehrt wurde.
- Host-Skript wurde mit Backup ersetzt und mit `SEND_NTFY=0` direkt auf dem Host verifiziert.
- Ergebnis des echten Host-Laufs: `status: ok`, `critical_count: 0`, `warning_count: 0`.
- Betriebsregel daraus: Bei Host-User-Scripts nach Repo-Aenderungen immer den tatsaechlich ausgefuehrten Host-Pfad und den Live-Output pruefen.
### 2026-05-19 - Borg-Scope fuer GitOps Host Automation erweitert
- Nach den Gitea-/Komodo-Webhook- und Posture-Check-Aenderungen wurde der Backup-Scope um Host-GitOps-Pfade erweitert.
- Borg UI mountet kuenftig `/mnt/user/services` read-only als `/local/services`.
- In `all-important-sources.txt` wurden `/local/services/homelab-infra`, `/local/services/stacks` und `/local/services/posture-check` aufgenommen.
- `pre-backup-dumps.sh` wurde auf dem Host ausgefuehrt; frische Dumps fuer `gitea.sqlite.dump` und `komodo-mongo.archive.gz` liegen unter `/mnt/user/backups/borg/dumps/latest`.
- Wirksam wird der neue `/local/services`-Mount nach Redeploy/Recreate des `borg-ui`-Stacks.
### 2026-05-19 - Traefik-5xx Alert entstoert
- `HomelabTraefik5xx` hatte auf einzelne 5xx-Antworten reagiert, weil die Regel `rate(...[5m]) > 0` nutzte.
- Live-Befund fuer `gitea@docker`: zwei kurze `POST /` mit HTTP 500 von einer externen IP, danach durchgehend erfolgreiche Gitea-Checks; kein Container-Restart.
- Prometheus-Regel auf `increase(...[5m]) >= 5` geaendert, damit einzelne externe Fehlrequests keinen ntfy-Alarm ausloesen.
### 2026-05-17 - Glance Homelab-Dashboard vorbereitet
- `ops/glance` als geschuetztes Homelab-Dashboard unter `glance.kaleschke.info` vorbereitet.
- Glance zeigt HTTP-Monitore fuer Core, Apps und Ops, Docker-Containergruppen, Host-Snapshot und Bookmarks.
- Docker-Status laeuft nicht ueber einen direkten Socket-Mount in Glance, sondern ueber `glance-docker-socket-proxy` auf einem internen `glance_socket_net`.
- Die HTTP-Monitore nutzen oeffentliche URLs als Klickziel und interne `check-url`-Endpunkte auf `frontend_net`, damit Glance nicht vom externen Hairpin-/Auth-Pfad abhaengt.
- Das Immich Community-Widget wurde ergaenzt. Der API-Zugriff nutzt eine interne Service-URL und ein Stack-ENV-Token. Paperless, Scrutiny und Speedtest bleiben Kandidaten fuer einen spaeteren Widget-Pass, sobald die konkrete API-Ausgabe im Glance-Kontext sauber verifiziert ist.
- Das Dashboard-Layout wurde an `ginesjunior11/glance-dashboard-config` angelehnt: dunkleres blaues Theme, Zeitfortschrittsgruppe, farbige Dashboard-Icons, dichter `Homelab Status`, Server-Stats im Hauptbereich und eine zweite Seite `Infrastructure and Media`. Die rechte Home-Spalte zeigt WAN-Infos aus Speedtest Tracker, Speedtest-Livewerte, AdGuard-DNS-Stats, DNS/Ingress-Monitore und eine separate Netzwerk-Containergruppe.
### 2026-05-17 - Monitoring-Zielstack konsolidiert
- `monitoring/` als zentraler Observability-Zielstack fuer Prometheus, Loki, Promtail, Grafana, node-exporter, cAdvisor und InfluxDB 3 Core vorbereitet.
- `monitoring-grafana` nutzt den Repo-Standard `authelia@file,secure-headers@file` und Secrets per Datei statt Klartext-Stack-ENV.
- `monitoring-influxdb3-core` uebernimmt den LAN-only Writer-Endpunkt fuer Home Assistant (`8181` via `INFLUXDB_BIND_IP`).
- `ops/loki` und `ops/grafana-influxdb` sind abgeloeste Altstaende und bleiben nur als Rollback-/Migrationsreferenz im Repo.
### 2026-05-07 - Vaultwarden Restore-Test praktisch verifiziert
- Erster echter Vaultwarden-Mini-Restore gegen das produktive Borg-Repo `hetzner_borg_appdata_critical` erfolgreich durchgefuehrt.
- Restore lief isoliert nach `/mnt/user/backups/restore-lab/vaultwarden`, nicht gegen produktive Pfade.
- Testinstanz `restoretest-vaultwarden` wurde lokal auf `127.0.0.1:18080` gestartet; HTTP 200 und Login-Seite wurden erfolgreich bestaetigt.
- Report wurde unter `/mnt/user/backups/restore-reports/vaultwarden-2026-05-07.md` geschrieben.
- Fuer den praktischen Restore-Pfad wurden zwei hostseitige Voraussetzungen sichtbar und umgesetzt:
- `known_hosts` fuer das Hetzner-Ziel im `borg-ui`-Container
- Host-Secret-Datei `/mnt/user/appdata/secrets/borg_repo_passphrase.txt` fuer kuenftige Restore-Tests
- Testdaten unter `/mnt/user/backups/restore-lab/vaultwarden/data` wurden nach erfolgreichem Lauf wieder bereinigt.
### 2026-05-07 - Gitea Restore-Test praktisch verifiziert
- Erster echter Gitea-Mini-Restore gegen das produktive Borg-Repo `hetzner_borg_appdata_critical` erfolgreich durchgefuehrt.
- Restore lief isoliert nach `/mnt/user/backups/restore-lab/gitea`, nicht gegen produktive Pfade.
- Testinstanz `restoretest-gitea` wurde lokal auf `127.0.0.1:13000` und `127.0.0.1:12222` gestartet.
- HTTP 200, HTML-Titel und lokaler SSH-Port wurden erfolgreich bestaetigt.
- Report wurde unter `/mnt/user/backups/restore-reports/gitea-2026-05-07.md` geschrieben.
- Testdaten unter `/mnt/user/backups/restore-lab/gitea/data` wurden nach erfolgreichem Lauf wieder bereinigt.
### 2026-05-07 - Paperless Restore-Test praktisch verifiziert
- Erster echter Paperless-Mini-Restore gegen das produktive Borg-Repo `hetzner_borg_appdata_critical` erfolgreich durchgefuehrt.
- Restore umfasste sowohl die Dateipfade als auch `postgresql17-paperless.dump` aus dem Borg-Archiv.
- Testinstanzen `restoretest-paperless`, `restoretest-paperless-postgres` und `restoretest-paperless-redis` liefen isoliert ohne Traefik.
- Login-Seite war lokal auf `127.0.0.1:18120` erreichbar.
- Der Dump-Import in Test-Postgres war erfolgreich; die Test-Datenbank enthielt `25` Dokumente.
- Report wurde unter `/mnt/user/backups/restore-reports/paperless-2026-05-07.md` geschrieben.
- Testdaten unter `/mnt/user/backups/restore-lab/paperless` wurden nach erfolgreichem Lauf wieder bereinigt.
### 2026-05-06 - Komodo Webhook Secret getrennt
- `KOMODO_WEBHOOK_SECRET` von `KOMODO_SECRET_KEY` getrennt und als eigene Stack-ENV-Variable dokumentiert.
- Gitea-Komodo-Webhooks mit bisherigem Core-Secret wurden auf den neuen `KOMODO_WEBHOOK_SECRET` umgestellt; bereits individuelle per-Stack-Webhook-Secrets wurden beibehalten.
- Host-`.env`, persistente Komodo-Compose und Gitea-Webhooks wurden als ein gemeinsamer Runtime-Schritt behandelt, damit Auto-Deploys nicht auseinanderlaufen.
- Ein stale Gitea-Webhook auf eine nicht mehr vorhandene Komodo-Stack-ID wurde deaktiviert, nicht geloescht.
### 2026-05-06 - Authelia GMX SMTP Notifier
- Authelia-Notifier von Filesystem-Log auf GMX SMTP (`submission://mail.gmx.net:587`) umgestellt.
- SMTP-Passwort bleibt ausserhalb des Repos unter `/mnt/user/appdata/secrets/authelia_smtp_password.txt`.
- Authelia-Compose erhaelt explizite DNS-Server, weil der SMTP-Startup-Check externe Namen wie `mail.gmx.net` aufloesen muss.
- Repo-Baseline und Host-Config muessen bei Auth-Aenderungen weiter bewusst gemerged und vor Restart validiert werden.
### 2026-05-06 - Hermes DR und Mail-Archiver Authelia
- Hermes Agent in `docs/RESTORE_MATRIX.md` und `docs/DISASTER_RECOVERY.md` mit Restore-Pfaden, Secret-/ENV-Hinweisen und Smoke-Test ergaenzt.
- Mail-Archiver Web-UI hinter `authelia@file,secure-headers@file` gelegt; App-eigene Auth bleibt als zweite Schutzschicht bestehen.
- M10/Komodo blieb unveraendert.
### 2026-05-05 - N-Aufraeum-Sprint
- Obsolete Compose-Top-Level-Felder `version: "3.9"` aus Immich, Mail Archiver und Paperless entfernt.
- Leere `env/domains.env.example` und `env/global.env.example` mit nicht geheimen Beispielwerten gefuellt.
- Veraltete `.keep`-Platzhalter aus Verzeichnissen mit echten Compose-/Repo-Inhalten sowie zwei reine Geister-Verzeichnisse (`host-services/plex`, `infra/dns`) entfernt.
### 2026-05-16 - Backup-Konsistenz und erster Hardening-Schnitt
- SQLite-Dumps fuer Gitea, Vaultwarden, Speedtest Tracker und Filebrowser werden containerseitig als `*.sqlite.dump` erzeugt und per Freshness-Check geprueft; Uptime Kuma wurde am 2026-05-25 aus dem aktiven Dump-Scope entfernt.
- `nextcloud.dump` und die Nextcloud-Userdaten sind als Option A im Borg-Scope dokumentiert.
- Filebrowser mountet keine breite `/mnt/user/appdata`-Flaeche mehr, sondern nur noch Documents, Photos, Projekte sowie eigenen App-State.
- Authelia Argon2id-Parameter in der Repo-Baseline auf `iterations: 3`, `memory: 65536`, `parallelism: 4` gesetzt; produktive Host-Config muss kontrolliert gemerged und mit Test-User validiert werden.
- Redis-Caches wurden auf `redis:7.4-alpine@sha256:...` vereinheitlicht; Nextcloud wurde mit Registry-validiertem Digest gepinnt.
- Eindeutig aufloesbare `latest@sha256`-Images wurden auf konkrete Tags umgestellt: Homepage `v1.12.3`, code-server `4.116.0`, Filebrowser `v2.63.2`, Speedtest Tracker `1.13.12`.
### 2026-05-05 - M3b versionierte App-Images digest-gepinnt
- Versionierte Nicht-Komodo-Images fuer BentoPDF, Mealie, Paperless, Paperless-GPT, AdGuard Home, Grafana, InfluxDB 3 Core und Traefik auf die am Host laufenden, manifest-validierten Digests gepinnt.
- `nextcloud:33.0.2-apache` wurde bewusst nicht in diesem Schritt gepinnt, weil der lokal gelistete Digest nicht als Registry-Manifest fuer `tag@sha256` validierbar war.
- Redis-Caches und Komodo/M10 blieben unveraendert.
### 2026-05-05 - M6/M7/M8 Doku-Konsolidierung
- `hermes.kaleschke.info` als produktive Hermes-Dashboard-Route hinter Traefik + Authelia in Architektur, Repo-Map und Service-Katalog ergaenzt.
- `grafana` und `influxdb3-core` laufen weiterhin als `user: "0"`; das wurde als Host-Appdata-Permissions-Ausnahme dokumentiert und nicht nebenbei geaendert.
- Tailscale-Ausnahme um `NET_ADMIN`, `NET_RAW` und `/dev/net/tun` ergaenzt.
- Komodo-Secret-/Webhook-Themen wurden bewusst nicht geaendert; Komodo-Aenderungen erfolgen nur gemeinsam mit dem Betreiber.
### 2026-05-05 - M3a stateful Digest-Pinning
- PostgreSQL 17 Datenhalter auf `postgres:17.9@sha256:5b96f1a16bd9768b060dd2ffe55cb6225c4d9ef4d214a8b21eb08134869a97e4` gepinnt (`postgresql17`, `mealie-postgres`, `nextcloud-postgres`).
- Immich pgvector-Postgres auf `tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52` gepinnt.
- Komodo Mongo auf `mongo:7.0.32@sha256:32979a1189dfdc44da3f5ed40d910495f5ad8f6f7f77556646f890a30b2d3f56` sowie Komodo Core/Periphery und Gitea auf die am Host laufenden Digests gepinnt.
- Redis-Caches wurden am 2026-05-16 auf `redis:7.4-alpine@sha256:...` vereinheitlicht; Redeploys erfolgen stackweise mit Smoke-Test, nicht parallel.
### 2026-05-04 - Komodo Self-Stack Drift auf persistenten Pfad zurueckgefuehrt
- Drift-Befund: `komodo-core` und `komodo-periphery` liefen aus `/tmp/komodo-core-repair.yml` bzw. `/tmp/komodo-periphery-repair.yml`; `komodo-mongo` verwies auf `/mnt/user/services/stacks/komodo/compose.yaml`, obwohl dieser Pfad fehlte.
- Vor Eingriff wurden die Repair-Dateien und zugehoerigen `/tmp/*.env`-Dateien unter `/mnt/user/appdata/komodo/_drift_backup_2026-05-04/` gesichert.
- Zusaetzlich wurde eine geschuetzte Recovery-ENV unter `/mnt/user/appdata/secrets/_komodo_stack_env_recovery_2026-05-04.env` abgelegt; diese Datei enthaelt Tier-1-Secret-Material und ist kein Dauerzustand.
- Vor dem Reconcile wurde das host-seitige Dump-Skript ausgefuehrt; `komodo-mongo.archive.gz` wurde frisch unter `/mnt/user/backups/borg/dumps/latest/` erzeugt.
- Persistenter Self-Stack wurde unter `/mnt/user/services/stacks/komodo/compose.yaml` aus `ops/komodo/docker-compose.yml` wiederhergestellt; `.env` wurde hostseitig aus der bestehenden Runtime-ENV abgeleitet.
- Der vollstaendige Dry-run haette auch `komodo-mongo` recreated und wurde daher nicht ausgefuehrt. Stattdessen wurden nur `komodo-core` und `komodo-periphery` gezielt mit `--no-deps --force-recreate` aus dem persistenten Pfad neu erstellt; `komodo-mongo` blieb unveraendert healthy.
- Smoke-Tests: `docker compose ls` zeigt fuer `komodo` nur noch `/mnt/user/services/stacks/komodo/compose.yaml`, Mongo pingt `{ ok: 1 }`, `https://komodo.kaleschke.info` liefert HTTP 200, und Periphery meldet sich am Core an.
- Die `/tmp/*repair.yml`-Dateien bleiben vorerst als Altlast erhalten und duerfen erst nach stabiler Laufzeit bewusst entfernt oder ins Drift-Backup verschoben werden.
### 2026-05-04 - Authelia ACL-Drift hostseitig gemerged
- Die produktive Authelia-Config ist groesser als die Repo-Datei, weil sie hostseitige OIDC-/Secret-Konfiguration enthaelt. Die Repo-Datei wurde daher als nicht geheime Baseline eingeordnet und nicht blind auf den Host kopiert.
- Host-Backup vor Aenderung: `/mnt/user/appdata/authelia/config/configuration.yml.bak-20260504-acl-sync`.
- Minimaler Host-Merge: `homepage.kaleschke.info` wurde aus der bypass-Liste entfernt, `komodo.kaleschke.info` aus der 2FA-Liste entfernt, und `default_redirection_url` wurde auf `https://home.kaleschke.info` gesetzt.
- `authelia validate-config` war erfolgreich; Authelia wurde neu gestartet und war danach healthy.
- Smoke-Tests: `home.kaleschke.info` liefert fuer anonyme Requests eine Authelia-Weiterleitung, `komodo.kaleschke.info` bleibt ueber native Komodo-Auth erreichbar.
### 2026-05-04 - Home Assistant InfluxDB LAN-Port und Drift-Runbook
- `influxdb3-core` fuer Home-Assistant-Writer auf LAN-Port `8181` vorbereitet und deployed.
- InfluxDB bleibt ohne Traefik-/Public-Route und haengt nicht im `frontend_net`.
- Fuer aktives Docker Host-Port-Publishing wurde zusaetzlich zum internen `grafana_influx_internal` das Compose-Netz `grafana_influx_lan` ergaenzt.
- Komodo Periphery dauerhaft um `/mnt/user/services:/mnt/user/services` und `frontend_net` ergaenzt, damit Stack-Workspaces und Gitea-Zugriff reproduzierbar funktionieren.
- `docs/GITOPS_DRIFT_RUNBOOK.md` angelegt, um lokale Git-Kopie, Gitea, Komodo Workspace, Docker Runtime und Host-Listener getrennt zu pruefen.
### 2026-03-28 - GitOps-Konsolidierung
- Komodo als primaeren Stack-Manager eingefuehrt.
- Portainer aus dem Zielbild herausgenommen.
- Traefik auf 100% Docker-Labels konsolidiert.
- `diun` entfernt; Update-Monitoring wird ueber Komodo abgedeckt.
### 2026-03-29 - Portainer abgeschaltet
- Portainer CE aus dem produktiven Betrieb entfernt.
- Komodo als alleinigen Stack-Manager festgezogen.
### 2026-04-13 bis 2026-04-15 - Borg-Rollout abgeschlossen
- `critical_infra` erfolgreich nach Borg gesichert.
- Pre-Backup-Dumps host-seitig ueber Unraid User Scripts etabliert.
- Dump-Zielpfad auf `/mnt/user/backups/borg/dumps` umgestellt.
- Restore-Smoke-Test fuer `postgresql17-globals.sql` und `gitea.db` erfolgreich nachgewiesen.
- Monitoring fuer Borg war historisch ueber `ntfy` und Uptime Kuma eingerichtet; seit 2026-05-25 ersetzt durch `ntfy`, Blackbox/Prometheus und Monitoring Grafana.
### 2026-04-15 - Repo- und Betriebsbereinigung
- Firefly, Firefly-Fints und Semaphore aus Repo und Homelab entfernt.
- GitHub Desktop als Standard-Workflow fuer den lokalen Sync festgelegt.
### 2026-04-17 - Sicherheits- und Doku-Abgleich
- `code-server` hinter `authelia@file,secure-headers@file` abgesichert.
- Traefik-Dashboard von `dashboard-auth@file` auf `authelia@file,secure-headers@file` umgestellt; BasicAuth-Hash aus dem Repo entfernt.
- Redis von Klartext in der Compose auf Secret-Datei unter `/mnt/user/appdata/secrets/redis_password.txt` umgestellt.
- Redis-Passwort bewusst **nicht** rotiert; Live-Passwort bleibt vorerst unveraendert.
- `mail-archiver` in der Architektur-Doku an den realen Traefik-Betrieb angepasst.
- `paperless-gpt` von `LOG_LEVEL=debug` auf `info` umgestellt.
- `speedtest-tracker` von `APP_DEBUG=true` auf `false` umgestellt.
- Mutable Image-Tags fuer produktive Stacks auf die aktuell laufenden Digests eingefroren, um Deployments reproduzierbar zu machen.
- `paperless-ngx` bleibt fuer `PAPERLESS_DBPASS` und `PAPERLESS_REDIS` vorerst bewusst bei Stack Environment Variables; keine Live-Migration auf `_FILE`, solange der aktuelle Stand stabil laeuft.
- Disaster-Recovery-Runbook und Restore-Matrix fuer den Totalausfall-/Wiederanlauf-Fall neu dokumentiert.
### 2026-04-19 - paperless-gpt Digest-Pin zurueckgenommen
- Der fuer `paperless-gpt` eingetragene Digest war syntaktisch ungueltig (63 statt 64 Hex-Zeichen) und wurde daher wieder auf `icereed/paperless-gpt:latest` zurueckgesetzt.
- Diese Ruecknahme ist bewusst eng auf einen einzelnen defekten Pin begrenzt und aendert keine anderen Digest-Festschreibungen.
- Die zwischenzeitlichen OCR-/Versions-Experimente fuer `paperless-gpt` wurden wieder auf den einfachen vorherigen Stand zurueckgenommen (`icereed/paperless-gpt:latest`, `VISION_LLM_MODEL=cnshenyang/qwen3-nothink:14b`), um den letzten bekannten Alltagszustand wiederherzustellen.
### 2026-04-19 - Nextcloud und Stirling-PDF vorbereitet
- `apps/nextcloud/docker-compose.yml` als offizieller Docker-Microservice-Stack mit `nextcloud:apache`, eigener PostgreSQL-Datenbank und eigenem Redis vorbereitet.
- Nextcloud folgt dem Repo-Standard `frontend_net` + app-internes Netz, nutzt `_FILE`-Secrets fuer Admin- und DB-Passwort und ist bewusst **nicht** hinter zentraler ForwardAuth, damit WebDAV/CardDAV und native Clients sauber funktionieren.
- `apps/stirling-pdf/docker-compose.yml` als geschuetzter Tool-Stack hinter `authelia@file,secure-headers@file` vorbereitet.
- Stirling-PDF nutzt persistente Pfade fuer `/configs`, `/logs`, `/pipeline`, `/customFiles` und `/usr/share/tessdata`; interne Stirling-Login-Funktion bleibt zugunsten des zentralen Traefik-/Authelia-Zugangs deaktiviert.
### 2026-04-30 - BentoPDF und Grafana/InfluxDB vorbereitet
- `stirling-pdf` repo-seitig durch `bentopdf` ersetzt; Domain `pdf.kaleschke.info` bleibt erhalten.
- BentoPDF laeuft als geschuetztes browserseitiges PDF-Tool hinter `authelia@file,secure-headers@file` und setzt zusaetzlich COOP/COEP-Header fuer SharedArrayBuffer-basierte Office-Konvertierung.
- `ops/grafana-influxdb` als neuer Monitoring-Stack vorbereitet und spaeter in Betrieb genommen.
- Grafana laeuft hinter Traefik + Authelia unter `grafana.kaleschke.info`.
- InfluxDB 3 Core bleibt ohne Public Route und wird ueber eine provisionierte Grafana-Datenquelle angebunden.
- Secrets fuer Grafana-Admin-Passwort, InfluxDB-Admin-Token und Grafana-Datasource-Token sind als Host-Dateien unter `/mnt/user/appdata/secrets/` dokumentiert.
---
## Sprint 2 — postgresql17 + diun/gotify ✅
## Dauerhafte Learnings
| Service | Status | Ergebnis |
|---|---|---|
| postgresql17 | ✅ | Git-Stack abgeschlossen |
| gotify | ✅ | `GOTIFY_DEFAULTUSER_PASS_FILE`, Traefik aktiv |
| diun | ✅ | **Entfernt** (2026-03-28) — Update-Monitoring via Komodo |
---
## Sprint 3 — mealie + mail-archiver ✅
| Service | Status | Ergebnis |
|---|---|---|
| mealie | ✅ | internes Netz (`mealie_internal`), Port entfernt, Traefik aktiv |
| mealie-postgres | ✅ | nur internes Netz, isoliert |
| mail-archiver | ✅ | `frontend_net` + `backend_net` (Hybrid), Portainer ENV |
---
## Sprint 4 — Frontend-Stack + Traefik Cleanup + Komodo ✅
| Service | Status | Ergebnis |
|---|---|---|
| paperless-ngx | ✅ | Traefik aktiv, tls=true, Port entfernt |
| Paperless-AI | ✅ | Traefik aktiv |
| PortainerCE | ✅ | Traefik + Middleware, direkte Ports entfernt — **Legacy**, wird in Sprint 5 abgeschaltet |
| Dozzle | ✅ | Traefik + Middleware, direkte Ports entfernt |
| dashdot | ✅ | Traefik + Middleware, direkte Ports entfernt |
| scrutiny | ✅ | `frontend_net`, Traefik + Middleware, als Git-Stack migriert |
| filebrowser | ✅ | `frontend_net`, Traefik + Middleware, Port entfernt |
| gitea | ✅ | Traefik aktiv, SSH-Port 222 bleibt (dokumentierte Ausnahme) |
| UptimeKuma | ✅ | Traefik aktiv, Port entfernt, Middleware aktiv |
| backrest | ✅ | `traefik.docker.network=frontend_net` korrigiert (war `backend_net`) |
| **Komodo** | ✅ | **Eingeführt als primärer GitOps-Stack-Manager** |
| **Traefik File-Provider** | ✅ | `immich.yml`, `gitea.yml`, `mealie.yml`, `scrutiny.yml`, `vaultwarden.yml.bak` gelöscht — Traefik läuft jetzt 100% auf Docker-Labels |
| **immich Bad Gateway** | ✅ | Traefik nutzt jetzt `immich@docker` statt `immich@file` |
---
## Sprint 5 — Compose-Migration Dockerman-Container 🔄 In Bearbeitung
| Service | Status | Ziel |
|---|---|---|
| luckyBackup | ⏳ | `frontend_net`, Traefik, Middleware, Port entfernen |
| Stash | ⏳ | Compose-Migration |
| Tailscale-Docker | ⏳ | Compose-Migration, `host`-Netz bleibt (dokumentiert) |
| netdata | ⏳ | Compose-Migration, leere CLAIM-Vars entfernen |
| Plex-Media-Server | ⏳ | Compose-Migration, `host`-Netz bleibt (Discovery) |
| Pi-hole | ⏳ | zuletzt konsolidieren |
| **PortainerCE** | ⏳ | **abschalten** nach vollständiger Komodo-Übernahme |
---
## Sprint 6 — Hardening / Secrets / Volumes ⏳ Offen
| Aufgabe | Status |
|---|---|
| `immich_default``internal: true` setzen | ⏳ |
| `immich_redis` — anonymes Volume → named volume | ⏳ |
| `immich_server` — anonymes Volume prüfen | ⏳ |
| `backrest` — rw-Mount auf konkrete Pfade einschränken | ⏳ |
| `filebrowser` — Mount-Pfad einschränken | ⏳ |
| Redis — named volume | ⏳ |
---
## Wichtige Entscheidungen & Learnings
### Komodo ersetzt Portainer (2026-03-28)
Komodo ist der primäre GitOps-Stack-Manager. Stacks werden via Gitea synchronisiert und über Komodo deployed. Portainer CE läuft noch als Legacy-UI.
### Traefik File-Provider Cleanup (2026-03-28)
Statische File-Provider-Configs hatten `@file`-Routen, die mit `@docker`-Routen konkurrierten. In Traefik v3 gewinnt der File-Provider → Immich wurde auf falsche IP geroutet (Bad Gateway). Nach Löschen der statischen Configs läuft alles über Docker-Labels.
### diun entfernt (2026-03-28)
Update-Monitoring kann über Komodo's eingebaute Update-Notifications abgedeckt werden.
### Portainer + Git + env_file
Host-Pfade (`/mnt/...`) sind in Portainer/Komodo Git-Stacks nicht verfügbar → `env_file` ungeeignet. Lösung: Stack Environment Variables mit `${VARIABLE}` in der Compose.
### `_FILE` Support ist nicht universell
| Container | `_FILE` Support |
|---|---|
| Vaultwarden | ✅ ja |
| PostgreSQL | ✅ ja |
| Gotify | ✅ ja |
| code-server | ✅ ja |
| Mealie | ❌ nein → Stack ENV |
| paperless-ngx | ❌ nein für DB-Pass → Stack ENV |
---
## Notizen
- Keine Migration ohne Test
- Immer nur einen Service gleichzeitig
- Rollback muss jederzeit möglich sein
- Kein Live-Editing in Komodo; Git gewinnt immer gegen manuelle Drift.
- Webhooks koennen nach einem Push sofort einen Deploy ausloesen.
- Rollback soll bevorzugt ueber saubere Git-Commits und bekannte Good States erfolgen, nicht ueber History-Rewrites auf `master`.
- Doku soll Endzustaende beschreiben, nicht veraltete Zwischenstaende konservieren.
+106
View File
@@ -0,0 +1,106 @@
# Network Inventory - KalliLab CORE
Status: Initialer Host-Audit erfasst, Router-/VLAN-Details offen.
Letzte Pruefung: 2026-05-26
## Zweck
Dieses Dokument beschreibt Router, DNS, Tailscale, Portfreigaben und Netztrennung. Es ergaenzt das Architektur-Zielbild in `HOMELAB_ARCHITECTURE_MASTER_V2.md` um konkrete Hardware- und Betriebswerte.
## Internet und Router
| Feld | Wert |
|---|---|
| Anschluss / Provider | TBD |
| Router-Modell | TBD |
| Firmware | TBD |
| Router-IP | 192.168.178.1 |
| DHCP-Server | vermutlich Router, zu pruefen |
| Lokales Subnetz | 192.168.178.0/24 |
| IPv6 aktiv | TBD |
| DynDNS / DDNS | Cloudflare via `ddns-updater`, Details TBD |
## DNS
| Komponente | Rolle | Adresse | Bemerkung |
|---|---|---|---|
| AdGuard Home | LAN DNS / Filter | Host `192.168.178.58`, Docker `172.23.0.3` | DNS auf Port 53; Admin soll nur via Tailscale-IP `100.80.98.33:8082` erreichbar sein |
| Unbound | Rekursiver Resolver | Docker `dns_net` | Upstream fuer AdGuard |
| Cloudflare | Authoritative DNS | extern | DNS-Challenge fuer TLS |
| Router | DHCP DNS-Verteilung | TBD | Muss auf AdGuard zeigen, falls so betrieben |
## Tailscale
| Feld | Wert |
|---|---|
| Node-Name | Kallilabcore |
| Tailscale IPv4 | 100.80.98.33 |
| Tailscale IPv6 | TBD |
| Exit Node | TBD |
| Subnet Router | TBD |
| ACL-Policy extern dokumentiert | TBD |
Pruefkommando:
```bash
tailscale status
tailscale ip -4
tailscale ip -6
```
## Portfreigaben und Exposure
| Port | Ziel | Zweck | Bewertung |
|---:|---|---|---|
| 80/tcp | Traefik | HTTP->HTTPS / ACME | erwartet |
| 443/tcp | Traefik | HTTPS | erwartet |
| 222/tcp | Gitea SSH | Git SSH | dokumentierte Ausnahme |
| 53/tcp+udp | AdGuard | DNS | dokumentierte Ausnahme |
| 8082/tcp | AdGuard Admin | Admin UI | Repo-Soll: nur `100.80.98.33:8082`, DNS-Port 53 unveraendert |
| 8181/tcp | InfluxDB 3 Core | LAN Writer fuer Home Assistant | LAN-only, Bind-IP pruefen |
Pruefkommando:
```bash
ss -ltnp | sort -k4
docker ps --format "{{.Names}}: {{.Ports}}" | sort
```
## Netztrennung
| Netz | Status | Bemerkung |
|---|---|---|
| LAN | 192.168.178.0/24 | Hauptnetz, Host `192.168.178.58` |
| Gast-WLAN | TBD | Zugriff auf AdGuard Admin muss ausgeschlossen sein |
| IoT-Netz | TBD | Zugriff auf AdGuard Admin muss ausgeschlossen sein |
| Tailscale | aktiv | Operator-Zugang, Host-IP `100.80.98.33` |
| VLANs | TBD | Router-/Switch-Faehigkeit pruefen |
## Docker-Netze
Authoritativ ist `HOMELAB_ARCHITECTURE_MASTER_V2.md`. Dieses Inventar haelt nur den Laufzeit-Snapshot fest.
| Docker-Netz | Zweck | Erwartung |
|---|---|---|
| frontend_net | Traefik/Web | external bridge |
| backend_net | DB/Cache intern | internal bridge |
| dns_net | AdGuard/Unbound | bridge |
| monitoring_net | Observability | compose-intern |
| app-interne Netze | Stack-isoliert | nur wenn technisch noetig |
Pruefkommando:
```bash
docker network ls
docker network inspect frontend_net | jq '.[0].Containers | keys'
docker network inspect backend_net | jq '.[0].Internal'
```
## Offene Entscheidungen
| Thema | Status | Naechster Schritt |
|---|---|---|
| AdGuard Admin nur via Tailscale | live validiert 2026-05-26 | Compose bindet Admin-Port auf `100.80.98.33:8082`; DNS auf Port 53 funktioniert, LAN-Zugriff auf `192.168.178.58:8082` schlaegt fehl |
| Gast-/IoT-Zugriff auf Admin-Ports | offen | Router-Regeln pruefen |
| IPv6 Exposure | offen | Router und Traefik/Cloudflare pruefen |
| Home Assistant InfluxDB Bind | offen | Effektive Listener-Adresse pruefen |

Some files were not shown because too many files have changed in this diff Show More