From 18a90fbb4bd9e8baccafb1b88c74f53486cd55a7 Mon Sep 17 00:00:00 2001 From: Micha Date: Sat, 6 Jun 2026 13:13:01 +0200 Subject: [PATCH] ops: add guest iot network preflight --- docs/GUEST_IOT_NETWORK.md | 99 ++++++++++++++ docs/MASTER_TODO.md | 2 +- docs/NETWORK_INVENTORY.md | 2 +- docs/README.md | 1 + ops/maintenance/check-guest-iot-isolation.ps1 | 127 ++++++++++++++++++ ops/maintenance/check-guest-iot-preflight.sh | 90 +++++++++++++ 6 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 docs/GUEST_IOT_NETWORK.md create mode 100644 ops/maintenance/check-guest-iot-isolation.ps1 create mode 100755 ops/maintenance/check-guest-iot-preflight.sh diff --git a/docs/GUEST_IOT_NETWORK.md b/docs/GUEST_IOT_NETWORK.md new file mode 100644 index 0000000..539349d --- /dev/null +++ b/docs/GUEST_IOT_NETWORK.md @@ -0,0 +1,99 @@ +# Guest / IoT Network Runbook + +Stand: 2026-06-06 + +Dieses Runbook beschreibt den sicheren Weg, das FRITZ!Box-Gastnetz zu aktivieren, +ohne versehentlich Homelab-Admin-Ports aus dem Gastsegment erreichbar zu machen. + +## Zielbild + +- Normales LAN bleibt `192.168.178.0/24`. +- Kallilabcore bleibt im normalen LAN unter `192.168.178.58`. +- FRITZ!Box-Gast-WLAN darf Internetzugang haben, aber keinen Zugriff auf + `192.168.178.0/24`. +- Homelab-Admin-Pfade bleiben Operator-only: + - Tailscale fuer Admin-Zugriff + - Authelia/2FA fuer geschuetzte Web-UIs + - keine LAN-Admin-Ports aus dem Gastnetz + +## Vorbedingungen + +Vor dem Einschalten des Gast-WLANs muessen diese Preflights gruen sein: + +```powershell +G:\Gitea_Clone\homelab-infra\ops\maintenance\check-guest-iot-isolation.ps1 -Mode LanPreflight +``` + +Erwartung im normalen LAN: + +- `192.168.178.58:8082` ist blockiert (AdGuard Admin nur Tailscale). +- `192.168.178.58:8181` ist blockiert (InfluxDB nicht LAN-exponiert). +- `192.168.178.58:80`, `443`, `222` koennen im normalen LAN erreichbar sein. + +Auf Unraid zusaetzlich: + +```bash +/mnt/user/services/homelab-infra/ops/maintenance/check-guest-iot-preflight.sh +``` + +## FRITZ!Box Schritte + +In der FRITZ!Box UI: + +1. `WLAN -> Gastzugang` oeffnen. +2. `Gastzugang aktiv` einschalten. +3. WPA2/WPA3-Verschluesselung aktiv lassen. +4. Eigenen Gast-SSID-Namen setzen, z. B. `Fritzi-Gast`. +5. Starkes Passwort setzen und in Vaultwarden ablegen. +6. Option `Geraete im Gastnetz duerfen miteinander kommunizieren` deaktiviert + lassen, sofern nicht bewusst gebraucht. +7. Option fuer Zugriff auf das Heimnetz / private Netzwerk deaktiviert lassen. +8. Gastzugang speichern. + +Wichtig: Die genaue FRITZ!OS-8.25-UI-Beschriftung kann leicht variieren. Der +entscheidende Punkt ist: Gastgeraete duerfen keinen Zugriff auf das Heimnetz +haben. + +## Verifikation + +Ein Handy oder Laptop mit dem Gast-WLAN verbinden, dann auf diesem Geraet testen: + +```powershell +G:\Gitea_Clone\homelab-infra\ops\maintenance\check-guest-iot-isolation.ps1 -Mode Guest +``` + +Erwartung aus dem Gast-WLAN: + +- `192.168.178.58:80` blockiert +- `192.168.178.58:443` blockiert +- `192.168.178.58:222` blockiert +- `192.168.178.58:8082` blockiert +- `192.168.178.58:8181` blockiert +- `192.168.178.1:80` blockiert oder nur Gast-Gateway-Ansicht + +Wenn der Test `Risk count: 0` meldet, ist die Isolation fuer die getesteten +Homelab-Admin-Pfade ausreichend. + +## Betrieb + +- Familien-/Gaestegeraete kommen ins Gast-WLAN, wenn sie keinen direkten Zugriff + auf LAN-Geraete brauchen. +- Homelab-Apps fuer Familie laufen perspektivisch ueber HTTPS/OIDC, nicht ueber + direkten LAN-Zugriff. +- Geraete, die lokale Discovery brauchen (z. B. manche Smart-TV/Plex-Szenarien), + bleiben im normalen LAN oder bekommen eine separate bewusste Entscheidung. + +## Rollback + +Wenn nach Aktivierung etwas Unerwartetes passiert: + +1. FRITZ!Box: `WLAN -> Gastzugang` oeffnen. +2. Gastzugang deaktivieren. +3. Speichern. +4. Normalen LAN-Zugriff pruefen: + ```powershell + G:\Gitea_Clone\homelab-infra\ops\maintenance\check-guest-iot-isolation.ps1 -Mode LanPreflight + ``` + +Es werden durch dieses Runbook keine Docker-Stacks, Secrets oder produktiven +Appdaten veraendert. diff --git a/docs/MASTER_TODO.md b/docs/MASTER_TODO.md index 9024c12..f7bf38b 100644 --- a/docs/MASTER_TODO.md +++ b/docs/MASTER_TODO.md @@ -27,7 +27,7 @@ Host-/Entscheidungsaufgaben beim **Operator**. | Restore-Test Unraid OS Flash (Stick-Boot) | Operator | Artefakt-Validierung am 2026-06-05 erledigt (`ops/maintenance/check-unraid-flash-backup.sh`, sha256 OK, 8 Kern-Configs). **Verbleibt:** physischer Ersatzstick-Boot-Test, wenn ein Wegwerf-Stick bereitliegt | `docs/RESTORE_MATRIX.md` Abschnitt "Unraid OS Flash" | | Restore-Test Tailscale | Operator | Runbook-Stub abarbeiten: State-Validierung + Reconnect nur auf Wegwerf-Host/VM, danach Geraet in Tailscale-Admin entfernen | `docs/RESTORE_MATRIX.md` Abschnitt "Tailscale" | | Authelia OIDC fuer Apps | Operator/Claude | **Plan + Runbook erstellt 2026-06-06** (`docs/AUTHELIA_OIDC_PLAN.md`): v4.39-Client-Schema, Issuer/Endpoints, Secret-Erzeugung, Rollout-Reihenfolge. **Naechster konkreter Schritt:** Stufe-1-Proof **Grafana** (`monitoring`) ausrollen — Authelia-Client + `GF_AUTH_GENERIC_OAUTH_*`; lokaler Grafana-Admin bleibt Fallback (kein Lockout). Danach Familien-Apps (Immich/Nextcloud/Mealie/Paperless) | `docs/AUTHELIA_OIDC_PLAN.md`, `security/authelia/configuration.yml` | -| Gast-/IoT-Netz einrichten | Operator | Entscheidung 2026-06-06: **aktivieren/planen.** Reihenfolge: (1) **zuerst** LAN-Admin-Ports (`192.168.178.58:8082` AdGuard, weitere) per FRITZ!Box-Netzwerkfilter/Kindersicherung gegen das Gastsegment sperren, (2) dann Gast-WLAN/IoT in der FRITZ!Box aktivieren, (3) Trennung verifizieren (Gastgeraet darf LAN-Admin nicht erreichen) | `docs/NETWORK_INVENTORY.md` | +| Gast-/IoT-Netz einrichten | Operator/Codex | **Preflight erledigt, Operator-UI-Schritt offen.** Runbook `docs/GUEST_IOT_NETWORK.md` und Checks vorhanden. LAN-Preflight von `baerchen` gruen (`8082`/`8181` blockiert). Naechster Schritt: FRITZ!Box-Gastzugang aktivieren, Heimnetz-Zugriff im Gastnetz deaktiviert lassen, danach von einem Gast-WLAN-Geraet `ops/maintenance/check-guest-iot-isolation.ps1 -Mode Guest` ausfuehren | `docs/GUEST_IOT_NETWORK.md`, `docs/NETWORK_INVENTORY.md` | --- diff --git a/docs/NETWORK_INVENTORY.md b/docs/NETWORK_INVENTORY.md index cf5f33f..445048f 100644 --- a/docs/NETWORK_INVENTORY.md +++ b/docs/NETWORK_INVENTORY.md @@ -294,7 +294,7 @@ docker network inspect backend_net | jq '.[0].Internal' | FRITZ!Box-Portfreigaben mit Repo-Soll abgleichen | **erledigt 2026-06-01** | Bereinigt: `80/tcp` entfernt (Cloudflare-DNS-Challenge ersetzt HTTP-01; Mobilfunk-Test bestaetigt Timeout auf `http://`, `https://` weiter ok). `222/tcp` bleibt bewusst nicht eingerichtet (Tailscale-only-Linie). UPnP-Selbstfreigaben sind aus. Aktiver Soll-Stand: ausschliesslich `443/tcp -> 192.168.178.58`. | | FRITZ!Box-Dienste aus dem Internet | **erledigt 2026-06-01** | `Internet -> Freigaben -> FRITZ!Box-Dienste`: HTTPS-Zugriff auf die FRITZ!Box aus dem Internet aus; FTP/FTPS auf Speichermedien aus. | | FRITZ!OS Update und Konfig-Backup | **erledigt 2026-06-01** | TR-064 meldet `154.08.25`; Konfig-Export liegt extern/off-system in Vaultwarden, Kennwort und Datei bleiben ausserhalb des Repos. | -| Gast-/IoT-Zugriff auf Admin-Ports | **Entscheidungspunkt: kein Gast-/IoT-Netz aktivieren, solange nicht gebraucht** | Aktuell entschaerft, weil Gast-WLAN inaktiv ist und kein IoT-VLAN existiert. Risiko entsteht erst bei Aktivierung. Harte Vorbedingung fuer eine spaetere Aktivierung: **vor** dem Einschalten von Gast-WLAN/IoT muessen `192.168.178.58:8082` (AdGuard-Admin, ohnehin Tailscale-gebunden), `192.168.178.58:8181` (InfluxDB, bereits `127.0.0.1`-bound) und alle weiteren LAN-Admin-Ports per FRITZ!Box-Netzwerkfilter/Kindersicherung gegen das Gastsegment gesperrt sein. Bis dahin bewusst kein Gastnetz. | +| Gast-/IoT-Zugriff auf Admin-Ports | **Preflight vorbereitet 2026-06-06** | Runbook `docs/GUEST_IOT_NETWORK.md` und Checks `ops/maintenance/check-guest-iot-isolation.ps1` sowie `ops/maintenance/check-guest-iot-preflight.sh` vorhanden. LAN-Preflight von `baerchen` am 2026-06-06 gruen: `192.168.178.58:8082` und `:8181` blockiert. Naechster Schritt: FRITZ!Box-Gastzugang aktivieren, Heimnetz-Zugriff deaktiviert lassen, danach `check-guest-iot-isolation.ps1 -Mode Guest` aus dem Gast-WLAN fahren. | | IPv6 Exposure | technisch und per UI entschaerft | Public DNS liefert keine AAAA-Records fuer `*.kaleschke.info`; Host hat keine globale Provider-IPv6. TR-064 meldet IPv6-Firewall aktiv und Pinholes grundsaetzlich erlaubt; FRITZ!Box-UI zeigt keine aktiven IPv6-Freigaben, keine Admin-/SSH-Freigaben. | | WAN-Ausfallschutz | **geparkt: spaeter evaluieren** (Operator-Entscheidung 2026-06-05) | Mobilfunk-Stick-Failover an FRITZ!Box bleibt vorerst inaktiv. Folgen sind bewusst akzeptiert: Internet-Ausfall = ACME/DDNS pausieren, lokale Apps laufen weiter. Review-Trigger: haeufigere oder laengere DSL-Ausfaelle, oder wenn externer Remote-Zugang (statt nur lokalem Betrieb) geschaeftskritisch wird. Erst dann Mobilfunk-Failover technisch bewerten. | | Home Assistant InfluxDB Bind | validiert 2026-05-31 | `docker-proxy` bindet `127.0.0.1:8181`; keine LAN-Exposure. Wenn Home Assistant nicht lokal auf dem Host schreibt, braucht das eine bewusste Bind-Aenderung. | diff --git a/docs/README.md b/docs/README.md index e09d566..7519af8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,7 @@ Diese Datei trennt aktive Betriebsdokumentation von historischer Arbeitsdoku. Ne | `AUTHELIA_OIDC_PLAN.md` | Plan & Runbook fuer app-uebergreifendes SSO via Authelia OIDC | | `HARDWARE_INVENTORY.md` | Host-, Disk-, SMART-, USV- und Power-Baseline | | `NETWORK_INVENTORY.md` | Router, DNS, Tailscale, Portfreigaben und Netzthemen | +| `GUEST_IOT_NETWORK.md` | Sicherer Ablauf fuer FRITZ!Box-Gastnetz / IoT-Isolation | | `EXTERNAL_DEPENDENCIES.md` | Provider, Konten und externe Abhaengigkeiten | | `EXTERNAL_OPERATOR_RUNBOOK.md` | Hetzner-/Borg-/FRITZ!Box-Betreibercheck | | `CAPACITY_AND_LIFECYCLE.md` | Kapazitaet, Wachstum und Upgrade-Trigger | diff --git a/ops/maintenance/check-guest-iot-isolation.ps1 b/ops/maintenance/check-guest-iot-isolation.ps1 new file mode 100644 index 0000000..755a353 --- /dev/null +++ b/ops/maintenance/check-guest-iot-isolation.ps1 @@ -0,0 +1,127 @@ +param( + [string]$HostLanIp = "192.168.178.58", + [string]$FritzBoxIp = "192.168.178.1", + [ValidateSet("LanPreflight", "Guest")] + [string]$Mode = "LanPreflight", + [string]$ReportPath = "" +) + +$ErrorActionPreference = "Stop" + +function Test-TcpPort { + param( + [string]$RemoteHost, + [int]$Port, + [int]$TimeoutMs = 1500 + ) + + $client = [System.Net.Sockets.TcpClient]::new() + try { + $async = $client.BeginConnect($RemoteHost, $Port, $null, $null) + $ok = $async.AsyncWaitHandle.WaitOne($TimeoutMs, $false) + if (-not $ok) { + return $false + } + $client.EndConnect($async) + return $true + } catch { + return $false + } finally { + $client.Close() + } +} + +function Add-Result { + param( + [System.Collections.Generic.List[object]]$Results, + [string]$Name, + [string]$Target, + [bool]$Reachable, + [string]$ExpectedGuest, + [string]$Risk + ) + + $Results.Add([pscustomobject]@{ + Name = $Name + Target = $Target + Reachable = $Reachable + ExpectedFromGuest = $ExpectedGuest + RiskIfReachableFromGuest = $Risk + }) +} + +$adapters = Get-NetIPConfiguration | + Where-Object { $_.IPv4Address -and $_.NetAdapter.Status -eq "Up" } | + Select-Object InterfaceAlias, + @{Name="IPv4";Expression={$_.IPv4Address.IPAddress -join ", "}}, + @{Name="Gateway";Expression={$_.IPv4DefaultGateway.NextHop -join ", "}}, + @{Name="DnsServer";Expression={$_.DNSServer.ServerAddresses -join ", "}} + +$results = [System.Collections.Generic.List[object]]::new() + +Add-Result $results "Unraid HTTP/LAN" "${HostLanIp}:80" (Test-TcpPort $HostLanIp 80) "blocked" "Guest can reach LAN web entrypoint directly" +Add-Result $results "Unraid HTTPS/LAN" "${HostLanIp}:443" (Test-TcpPort $HostLanIp 443) "blocked" "Guest can reach LAN HTTPS entrypoint directly" +Add-Result $results "Gitea SSH/LAN" "${HostLanIp}:222" (Test-TcpPort $HostLanIp 222) "blocked" "Guest can reach Git SSH" +Add-Result $results "AdGuard Admin/LAN" "${HostLanIp}:8082" (Test-TcpPort $HostLanIp 8082) "blocked" "Guest can reach AdGuard admin UI" +Add-Result $results "InfluxDB LAN" "${HostLanIp}:8181" (Test-TcpPort $HostLanIp 8181) "blocked" "Guest can reach InfluxDB writer endpoint" +Add-Result $results "FRITZ!Box LAN UI" "${FritzBoxIp}:80" (Test-TcpPort $FritzBoxIp 80) "blocked-or-guest-gateway-only" "Guest can reach main router UI" + +$risk = if ($Mode -eq "Guest") { + $results | Where-Object { + $_.ExpectedFromGuest -like "blocked*" -and $_.Reachable + } +} else { + $results | Where-Object { + $_.Name -in @("AdGuard Admin/LAN", "InfluxDB LAN") -and $_.Reachable + } +} + +$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" +$lines = [System.Collections.Generic.List[string]]::new() +$lines.Add("# Guest/IoT Isolation Check") +$lines.Add("") +$lines.Add("Timestamp: $timestamp") +$lines.Add("Mode: $Mode") +$lines.Add("Host LAN IP: $HostLanIp") +$lines.Add("FRITZ!Box IP: $FritzBoxIp") +$lines.Add("Risk count: $($risk.Count)") +$lines.Add("") +$lines.Add("## Active Network Adapters") +$lines.Add("") +$lines.Add("| Interface | IPv4 | Gateway | DNS |") +$lines.Add("|---|---|---|---|") +foreach ($adapter in $adapters) { + $lines.Add("| $($adapter.InterfaceAlias) | $($adapter.IPv4) | $($adapter.Gateway) | $($adapter.DnsServer) |") +} +$lines.Add("") +$lines.Add("## Port Tests") +$lines.Add("") +$lines.Add("| Name | Target | Reachable | Expected from guest Wi-Fi | Risk if reachable from guest |") +$lines.Add("|---|---|---:|---|---|") +foreach ($result in $results) { + $lines.Add("| $($result.Name) | $($result.Target) | $($result.Reachable) | $($result.ExpectedFromGuest) | $($result.RiskIfReachableFromGuest) |") +} +$lines.Add("") +$lines.Add("## Interpretation") +$lines.Add("") +$lines.Add("- `LanPreflight`: reachable `80/443/222` can be normal; `8082` and `8181` should still be blocked.") +$lines.Add("- `Guest`: all listed LAN targets should be blocked. Public domains may still work via the internet path.") +$lines.Add("- A non-zero risk count means the selected mode failed.") + +$text = $lines -join [Environment]::NewLine + +if ($ReportPath) { + $parent = Split-Path -Parent $ReportPath + if ($parent) { + New-Item -ItemType Directory -Force -Path $parent | Out-Null + } + Set-Content -Path $ReportPath -Value $text -Encoding UTF8 +} + +Write-Output $text + +if ($risk.Count -gt 0) { + exit 2 +} + +exit 0 diff --git a/ops/maintenance/check-guest-iot-preflight.sh b/ops/maintenance/check-guest-iot-preflight.sh new file mode 100755 index 0000000..0bbeafb --- /dev/null +++ b/ops/maintenance/check-guest-iot-preflight.sh @@ -0,0 +1,90 @@ +#!/bin/bash +set -euo pipefail + +HOST_LAN_IP="${HOST_LAN_IP:-192.168.178.58}" +TAILSCALE_IP="${TAILSCALE_IP:-100.80.98.33}" +FRITZBOX_TR064_URL="${FRITZBOX_TR064_URL:-http://192.168.178.1:49000/tr64desc.xml}" +REPORT_ROOT="${REPORT_ROOT:-/mnt/user/backups/restore-reports}" +STAMP="$(date +%F-%H%M%S)" +REPORT_FILE="$REPORT_ROOT/guest-iot-preflight-$STAMP.md" + +mkdir -p "$REPORT_ROOT" + +tcp_check() { + local host="$1" + local port="$2" + timeout 2 bash -c "cat < /dev/null > /dev/tcp/$host/$port" >/dev/null 2>&1 +} + +result_row() { + local name="$1" + local target="$2" + local expectation="$3" + local status="$4" + printf '| %s | `%s` | %s | %s |\n' "$name" "$target" "$status" "$expectation" +} + +{ + echo "# Guest/IoT Preflight" + echo + echo "Timestamp: $(date '+%F %T')" + echo "Scope: host-side read-only checks before enabling FRITZ!Box guest/IoT network" + echo + echo "## FRITZ!Box TR-064" + echo + if curl -fsS --max-time 5 "$FRITZBOX_TR064_URL" >/tmp/guest-iot-fritzbox-tr064.xml 2>/dev/null; then + model="$(grep -o '[^<]*' /tmp/guest-iot-fritzbox-tr064.xml | head -n1 | sed 's///')" + echo "- TR-064 descriptor reachable: yes" + echo "- Model: ${model:-unknown}" + else + echo "- TR-064 descriptor reachable: no" + fi + rm -f /tmp/guest-iot-fritzbox-tr064.xml + echo + echo "## Host listeners" + echo + echo '```text' + ss -ltnp | sort -k4 | grep -E ':(53|80|443|222|8082|8181)[[:space:]]' || true + echo '```' + echo + echo "## Port reachability from host namespace" + echo + echo "| Check | Target | Status | Expectation |" + echo "|---|---|---|---|" + + for port in 80 443 222 53; do + if tcp_check "$HOST_LAN_IP" "$port"; then + result_row "LAN service" "$HOST_LAN_IP:$port" "may be reachable from normal LAN; must be blocked from guest Wi-Fi" "reachable" + else + result_row "LAN service" "$HOST_LAN_IP:$port" "not reachable from host namespace or UDP-only" "blocked" + fi + done + + if tcp_check "$HOST_LAN_IP" 8082; then + result_row "AdGuard Admin via LAN IP" "$HOST_LAN_IP:8082" "should be blocked" "reachable" + else + result_row "AdGuard Admin via LAN IP" "$HOST_LAN_IP:8082" "should be blocked" "blocked" + fi + + if tcp_check "$TAILSCALE_IP" 8082; then + result_row "AdGuard Admin via Tailscale IP" "$TAILSCALE_IP:8082" "operator path should work" "reachable" + else + result_row "AdGuard Admin via Tailscale IP" "$TAILSCALE_IP:8082" "operator path should work" "blocked" + fi + + if tcp_check "$HOST_LAN_IP" 8181; then + result_row "InfluxDB via LAN IP" "$HOST_LAN_IP:8181" "should be blocked unless HA LAN writer is reintroduced" "reachable" + else + result_row "InfluxDB via LAN IP" "$HOST_LAN_IP:8181" "should be blocked unless HA LAN writer is reintroduced" "blocked" + fi + echo + echo "## Next operator step" + echo + echo "Enable FRITZ!Box guest Wi-Fi only after confirming LAN isolation is active. Then connect a phone/laptop to guest Wi-Fi and run:" + echo + echo '```powershell' + echo 'G:\Gitea_Clone\homelab-infra\ops\maintenance\check-guest-iot-isolation.ps1' + echo '```' +} | tee "$REPORT_FILE" + +echo "Guest/IoT preflight report: $REPORT_FILE"