From 0e5876f1a968e706a05190556fffc7a7e41482c9 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:48:49 +0200 Subject: [PATCH] Add fail-closed WireGuard container gateway --- README.md | 7 +- compose.yaml | 42 +++++++++- config/install.env.example | 12 ++- docs/ARCHITECTURE.md | 35 ++++---- docs/DISASTER_RECOVERY.md | 4 +- docs/INSTALLATION.md | 36 ++++---- docs/OPERATIONS.md | 4 +- docs/RECOVERY_REQUIREMENTS.md | 8 +- docs/REMOTE_SITE_CHECKLIST.md | 13 ++- docs/SECURITY.md | 17 ++-- docs/WIREGUARD_HOME_PEER.md | 82 +++++++++---------- install.sh | 58 +++++++++++-- platform/docker/wireguard-gateway/Dockerfile | 13 +++ .../docker/wireguard-gateway/entrypoint.sh | 68 +++++++++++++++ .../docker/wireguard-gateway/healthcheck.sh | 9 ++ platform/host/mike-ai-container-vpn-guard | 52 ++++++++++++ .../host/mike-ai-container-vpn-guard.service | 14 ++++ 17 files changed, 365 insertions(+), 109 deletions(-) create mode 100644 platform/docker/wireguard-gateway/Dockerfile create mode 100644 platform/docker/wireguard-gateway/entrypoint.sh create mode 100644 platform/docker/wireguard-gateway/healthcheck.sh create mode 100644 platform/host/mike-ai-container-vpn-guard create mode 100644 platform/host/mike-ai-container-vpn-guard.service diff --git a/README.md b/README.md index e01d6df..580a221 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ WireGuard-Isolation. - SearXNG/Web-MCP ohne externen API-Schlüssel - zentrale MCP-Werkzeugebene: getrennte Container für Web, HA, ARR, Unraid und Sandbox, gemeinsam nutzbar durch Open WebUI und andere Clients -- KI-Dienste ausschließlich über die WireGuard-Adresse erreichbar +- KI-Dienste ausschließlich über den containerisierten WireGuard-Gateway erreichbar - KI-Ausgangsverkehr über das Heimnetz, bei Tunnelausfall fail-closed - keine Secrets, Chats, Logs oder Modelldateien im Repository @@ -37,7 +37,7 @@ editor config/install.env sudo ./install.sh --config config/install.env ``` -Installiert werden Docker CE, NVIDIA Container Toolkit, WireGuard, der +Installiert werden Docker CE, NVIDIA Container Toolkit, WireGuard-Werkzeuge, der gepinnt gebaute llama.cpp-Server, die Modelle und der komplette Compose-Stack. Bei einer erstmaligen NVIDIA-Treiberinstallation fordert das Skript einen Neustart an; danach wird derselbe Befehl erneut ausgeführt. @@ -98,7 +98,8 @@ router/ OpenAI-kompatibler Profile Router - API-, Controller-, WebUI- und WireGuard-Schlüssel entstehen erst am Host. - Nur der kleine Profile Controller sieht den Docker-Socket. - llama.cpp veröffentlicht weder Port noch WebUI. -- Ein Blackhole-Fallback verhindert Traffic-Leaks bei WireGuard-Ausfall. +- Quellrouting ohne alternative Route verhindert Traffic-Leaks bei + WireGuard-Ausfall (fail-closed). - Das Uni-Netz und das Heimnetz dürfen diesen Host nicht als Transit benutzen. Die Profilwerte wurden auf RTX 5080 und RTX 3060 vermessen und bilden die diff --git a/compose.yaml b/compose.yaml index 059b9b3..da4ec68 100644 --- a/compose.yaml +++ b/compose.yaml @@ -27,6 +27,37 @@ x-llama-common: &llama-common start_period: 30s services: + wireguard-gateway: + build: ./platform/docker/wireguard-gateway + image: mike-ai/wireguard-gateway:local + container_name: mike-ai-wireguard-gateway + restart: unless-stopped + cap_add: [NET_ADMIN] + devices: + - /dev/net/tun:/dev/net/tun + sysctls: + net.ipv4.ip_forward: "1" + net.ipv4.conf.all.src_valid_mark: "1" + net.ipv6.conf.all.forwarding: "1" + read_only: true + tmpfs: + - /run:size=16m,mode=0755 + - /tmp:size=16m,mode=1777 + volumes: + - "${WIREGUARD_CONFIG_FILE:-/etc/mike-ai/wireguard/fritz-athena.conf}:/run/secrets/fritz-athena.conf:ro" + networks: + frontend: + ipv4_address: 172.30.10.254 + tools-egress: + ipv4_address: 172.30.50.254 + security_opt: ["no-new-privileges:true"] + healthcheck: + test: [CMD, /usr/local/sbin/mike-ai-wireguard-healthcheck] + interval: 10s + timeout: 3s + retries: 12 + start_period: 10s + llama-fast: <<: *llama-common container_name: mike-ai-llama-fast @@ -416,8 +447,6 @@ services: TTS_VOICES: alloy TTS_DEFAULT_VOICE: alloy ENABLE_STT: "false" - ports: - - "${AI_BIND_ADDRESS:-127.0.0.1}:8081:8081" networks: [frontend, control, inference] security_opt: ["no-new-privileges:true"] cap_drop: [ALL] @@ -433,6 +462,8 @@ services: retries: 24 start_period: 5s depends_on: + wireguard-gateway: + condition: service_healthy profile-controller: condition: service_healthy piper: @@ -550,11 +581,11 @@ services: [{"url":"http://mike-ai-mcp-web:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"web-local","name":"Web (öffentlich, read-only)","description":"Für aktuelle öffentliche Internetdaten, Quellenprüfung, GitHub/Hugging Face und Produktsuche. Nicht für Home Assistant, Medienverwaltung oder NAS-Diagnose."}},{"url":"http://mike-ai-mcp-homeassistant:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"homeassistant-local","name":"Home Assistant (lokal)","description":"Nur für Home-Assistant-Entitäten, Zustände, Historie, Automationen, Dashboards und HA-Diagnose. Nicht für Unraid, Sonarr/Radarr oder allgemeine Websuche."}},{"url":"http://mike-ai-mcp-arr:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"arr-local","name":"Sonarr und Radarr (lokal)","description":"Nur für verwaltete Serien/Filme, fehlende Episoden, Queue und Suche über konfigurierte Indexer. Keine allgemeine Websuche; Schreibaktionen benötigen Vorschau und Freigabe."}},{"url":"http://mike-ai-mcp-unraid-official:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"unraid-readonly-local","name":"Unraid (Systemdiagnose)","description":"Nur für Unraid-Host, Array, Datenträger, Docker-Container, Shares, Netzwerk, UPS und Systemlogs. Nicht für Home Assistant oder Medieninhalte; Standardzugriff read-only."}}] DO_NOT_TRACK: "true" SCARF_NO_ANALYTICS: "true" - ports: - - "${AI_BIND_ADDRESS:-127.0.0.1}:8080:8080" dns: ["${AI_DNS:-1.1.1.1}"] networks: [frontend, tools] depends_on: + wireguard-gateway: + condition: service_healthy router: condition: service_healthy security_opt: ["no-new-privileges:true"] @@ -575,6 +606,9 @@ networks: tools: external: true name: mike-ai-tools + tools-egress: + external: true + name: mike-ai-tools-egress volumes: open-webui-data: diff --git a/config/install.env.example b/config/install.env.example index 6488c48..e69d63c 100644 --- a/config/install.env.example +++ b/config/install.env.example @@ -3,7 +3,6 @@ AI_HOSTNAME=ki-host ADMIN_USER=mike -AI_BIND_ADDRESS=10.77.0.2 MODEL_DIR=/srv/mike-ai/models # Installing a new NVIDIA driver can require one reboot. In that case this @@ -23,9 +22,16 @@ FLUX_MODEL_DIR=/data/models/FLUX.2-klein-4B ENABLE_HARDWARE_WATCHDOG=true PRIMARY_NETWORK_INTERFACE=enp7s0 WAKE_ON_LAN_INTERFACE=enp7s0 +SSH_KEY_ONLY=true -# WireGuard client. The home peer must route 10.77.0.2/32 back to this host. -WIREGUARD_ENABLE=true +# WireGuard terminates in a dedicated Docker gateway. The Fritzbox export is a +# root-only deployment secret and must never be committed. +WIREGUARD_MODE=container +WIREGUARD_CONFIG_FILE=/etc/mike-ai/wireguard/fritz-athena.conf +WIREGUARD_ENABLE=false +# The values below are only used by the legacy host-terminated mode. The +# default container mode takes address, peer and routes from the Fritzbox file. +AI_BIND_ADDRESS=10.77.0.2 WG_INTERFACE=wg0 WG_ADDRESS=10.77.0.2/32 WG_HOME_SUBNET=192.168.1.0/24 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a4343d8..43f4a7a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -3,18 +3,18 @@ ## Grundsatz Der Host läuft auf Debian 13. Das Betriebssystem darf im Universitätsnetz -administrierbar bleiben; die KI-Plattform wird ausschließlich an die -WireGuard-Adresse gebunden. KI-Container erreichen Heimnetz und Internet über -den Heim-WireGuard-Peer. Bei Tunnelausfall verhindert eine Blackhole-Route den -unbeabsichtigten Rückfall auf das Universitätsgateway. +administrierbar bleiben. Ein eigener Docker-Gateway beendet WireGuard und ist +der einzige von außen erreichbare Einstieg in die KI-Plattform. KI-Container +erreichen Heimnetz und Internet über die Fritzbox; Quellrouting zum Gateway +verhindert bei Tunnelausfall einen Rückfall auf das Universitätsgateway. ```text Heimnetz / VPN-Clients | WireGuard | - 10.77.0.2:8080 Open WebUI - 10.77.0.2:8081 Profile Router API + :8080 Open WebUI + :8081 Profile Router API | Docker-intern +-- Profile Controller -- Docker Socket (feste Allowlist) @@ -35,8 +35,9 @@ Heimnetz / VPN-Clients | Komponente | Außen erreichbar | Aufgabe | |---|---|---| -| Open WebUI | nur WireGuard, Port 8080 | Chat-Oberfläche | -| Profile Router | nur WireGuard, Port 8081 | OpenAI-API und Profilwahl | +| WireGuard Gateway | VPN-Adresse, Port 8080/8081 | Tunnel und eng begrenzte TCP-Proxys | +| Open WebUI | nur Docker-intern | Chat-Oberfläche | +| Profile Router | nur Docker-intern | OpenAI-API und Profilwahl | | Profile Controller | nein | startet ausschließlich vier bekannte Profile | | llama.cpp Profile | nein | Inferenz, Tool Calling, integrierte Vision | | Piper | nein | lokale deutsche Text-to-Speech-Ausgabe | @@ -79,15 +80,19 @@ auf der 3060. Ultra bleibt für maximalen Kontext bewusst text-only. ## Netzwerk - Docker-Netze liegen ausschließlich unter `172.30.0.0/16`. -- Open WebUI und Router binden an `AI_BIND_ADDRESS`, die WireGuard-IP. -- Quellrouting schickt KI-Container in Tabelle 51820 über WireGuard. -- Eine Blackhole-Default-Route bleibt als Fail-Closed-Fallback bestehen. -- Firewallregeln gestatten aus dem VPN nur die beiden veröffentlichten Ports. +- Open WebUI und Router besitzen keine Docker-Host-Portfreigabe. +- Der Gateway lauscht in seinem eigenen Namespace auf der Fritz-VPN-IP und + leitet nur 8080/8081 zu den internen Diensten weiter. +- Quellrouting schickt `172.30.10.0/24` und `172.30.50.0/24` zum Gateway; + Regeln für `172.30.0.0/16` bewahren rein internen Docker-Verkehr. +- Die verschlüsselten äußeren Gateway-Pakete sind eng von diesen Quellregeln + ausgenommen und verlassen Athena über die normale Standortverbindung. +- Bleibt die Gateway-Adresse aus, existiert keine alternative Route für die + Anwendungscontainer (fail-closed). - Der Host routet weder Universitätsverkehr ins Heimnetz noch Heimverkehr ins Universitätsnetz. -- Das Heimnetz muss die Rückroute zur WireGuard-Adresse kennen. Soll auch der - Internetzugang der KI über zuhause laufen, braucht der Heim-Peer zusätzlich - IP-Forwarding und NAT ins Heim-WAN. +- Adressen, Heimrouten, Full-Tunnel und Keepalive stammen aus dem root-only + Fritzbox-Clientexport; der Debian-Host übernimmt dessen Default-Route nicht. ## Optionale Erweiterungen diff --git a/docs/DISASTER_RECOVERY.md b/docs/DISASTER_RECOVERY.md index 1e554d1..3ba2da3 100644 --- a/docs/DISASTER_RECOVERY.md +++ b/docs/DISASTER_RECOVERY.md @@ -81,7 +81,9 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden. ## Phase F – Sicherheitsprüfung -- [ ] Port 8080 von normalen Clients nicht erreichbar +- [ ] Port 8080/8081 an der physischen Hostadresse nicht erreichbar +- [ ] beide Ports über die Fritz-VPN-Adresse erreichbar +- [ ] gestopptes WireGuard-Gateway blockiert Container-Egress - [ ] Hilfsports nur localhost - [ ] Router nur aus erlaubtem Netz erreichbar - [ ] Dienste laufen mit minimalen Rechten diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 75af184..87aea61 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -11,8 +11,8 @@ startet den Stack. 1. Die Universität muss den ausgehenden WireGuard-Tunnel erlauben. 2. Heimnetz, Universitätsnetz und Docker-Netz dürfen sich nicht überschneiden. -3. Der WireGuard-Heim-Peer braucht eine feste öffentliche Adresse oder DNS. -4. Für KI-Internetzugang über zuhause: Forwarding und NAT am Heim-Peer. +3. In der Fritzbox eine Konfiguration für **einen einzelnen Client** exportieren. +4. Der Fritzbox-Zugang muss Heimnetz und gewünschten Internetverkehr erlauben. 5. Das private Repository muss auf dem neuen Host lesbar sein. ## Debian installieren @@ -32,10 +32,16 @@ chmod 600 config/install.env editor config/install.env ``` -Mindestens `ADMIN_USER`, `AI_BIND_ADDRESS`, `WG_ADDRESS`, -`WG_PEER_PUBLIC_KEY`, `WG_ENDPOINT` und `WG_HOME_SUBNET` anpassen. Private -WireGuard-, Router- und WebUI-Schlüssel werden lokal erzeugt und nur unter -`/etc/mike-ai` gespeichert. +Mindestens `ADMIN_USER`, Netzwerkschnittstellen, GPU-Zuordnung und Modellwerte +prüfen. Die Fritzbox-Datei vor dem Start root-only ablegen: + +```bash +sudo install -d -m 700 /etc/mike-ai/wireguard +sudo install -m 600 fritz-athena.conf /etc/mike-ai/wireguard/fritz-athena.conf +``` + +Router- und WebUI-Schlüssel werden lokal erzeugt und nur unter `/etc/mike-ai` +gespeichert. Der Installer gibt keine privaten WireGuard-Werte aus. ## Installation starten @@ -50,16 +56,7 @@ Beim ersten Stackstart lädt der interne Piper-Container die konfigurierte deutsche Stimme in sein persistentes Volume. Dadurch kann seine erste Bereitschaft je nach Internetverbindung etwas länger dauern. -Der Installer zeigt nur den öffentlichen WireGuard-Schlüssel. Diesen am -Heim-Peer eintragen: - -```ini -[Peer] -PublicKey = -AllowedIPs = 10.77.0.2/32 -``` - -Erst wenn der Tunnel steht, kann der Bootstrap fortfahren. API-Schlüssel +Der Compose-Start wartet auf einen aktuellen WireGuard-Handshake. API-Schlüssel werden nicht ausgegeben. Sie liegen root-only unter `/etc/mike-ai`. ## Ergebnis und Abnahme @@ -69,7 +66,8 @@ werden nicht ausgegeben. Sie liegen root-only unter `/etc/mike-ai`. - llama.cpp-WebUI: absichtlich deaktiviert und nicht veröffentlicht ```bash -sudo systemctl status wg-quick@wg0 mike-ai-network-guard +sudo systemctl status mike-ai-container-vpn-guard +sudo docker inspect -f '{{.State.Health.Status}}' mike-ai-wireguard-gateway sudo docker compose --env-file /etc/mike-ai/stack.env \ -f /opt/mike-ai/stack/compose.yaml ps curl http://:8081/health @@ -91,8 +89,8 @@ curl -fsS http://127.0.0.1:8081/v1/audio/speech \ -o /tmp/athena-piper-test.mp3 ``` -Zusätzlich prüfen: Uni-LAN sieht keine KI-Ports; Heimnetz erreicht beide; -gestopptes WireGuard lässt KI-Container nicht ins Internet; jeder Profilwechsel +Zusätzlich prüfen: Standort-LAN sieht keine KI-Ports; Heimnetz erreicht beide; +gestopptes VPN-Gateway lässt KI-Container nicht ins Internet; jeder Profilwechsel startet exakt einen llama-Container; Text, Tool Call, Bild und Sprachausgabe funktionieren. Nach dem ersten Anlegen des OpenWebUI-Administrators werden Filter, Quick diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index de5fd38..d676b3a 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -103,7 +103,7 @@ erfolgreich. Medium ist das Start- und Standardprofil. Clients verbinden sich mit: ```text -http://HOST:8081/v1 +http://:8081/v1 ``` Als API-Key verwenden sie den Inhalt von `/etc/mike-ai/router-api-key` über @@ -120,7 +120,7 @@ Vision, Bildgenerierung, STT und TTS umgehen. - `GET /ready`: Router und Textmodell sind einsatzbereit - `GET /status`: authentifizierter Detailstatus, Profil, Upstream, aktive Jobs - `GET /v1/models`: virtuelle Modelle -- llama.cpp-Metriken: Port 8080, nur im administrativen Netz freigeben +- llama.cpp-Metriken: ausschließlich Docker-intern abfragen - systemd-Journal: nur Metadaten und Fehler prüfen; keine Promptinhalte sammeln ## Upgrade-Regel diff --git a/docs/RECOVERY_REQUIREMENTS.md b/docs/RECOVERY_REQUIREMENTS.md index d02c2e6..859ceb3 100644 --- a/docs/RECOVERY_REQUIREMENTS.md +++ b/docs/RECOVERY_REQUIREMENTS.md @@ -114,13 +114,17 @@ Zielmatrix: | Port | Zugriff | |---:|---| | 22 | nur Administration | -| 8080 | localhost | -| 8081 | vertrauenswürdiges LAN/VPN | +| 8080 | ausschließlich WireGuard-Gateway (Open WebUI) | +| 8081 | ausschließlich WireGuard-Gateway (Router) | | 8084 | localhost | | 8085 | localhost | | 8000 | localhost | | 5240 | optional nur Administration | +Open WebUI und Router selbst besitzen keine Host-Portfreigaben. Zusätzlich zum +Repository muss die verschlüsselt gesicherte Fritzbox-Clientdatei als +`/etc/mike-ai/wireguard/fritz-athena.conf` (0600) wiederhergestellt werden. + ## 6. Speicherlayout – offen Vor dem Neuaufbau festlegen: diff --git a/docs/REMOTE_SITE_CHECKLIST.md b/docs/REMOTE_SITE_CHECKLIST.md index 2636e07..7e53472 100644 --- a/docs/REMOTE_SITE_CHECKLIST.md +++ b/docs/REMOTE_SITE_CHECKLIST.md @@ -29,11 +29,12 @@ müssen aktiviert sein. Vor dem Transport prüfen: ```bash -systemctl is-enabled ssh docker wg-quick@wg0 -systemctl is-active ssh docker wg-quick@wg0 +systemctl is-enabled ssh docker mike-ai-container-vpn-guard +systemctl is-active ssh docker mike-ai-container-vpn-guard ethtool enp7s0 | grep Wake-on systemctl show -p RuntimeWatchdogUSec -wg show +docker inspect -f '{{.State.Health.Status}}' mike-ai-wireguard-gateway +docker exec mike-ai-wireguard-gateway wg show wg0 latest-handshakes ``` Erwartet werden `enabled`, `active`, `Wake-on: g`, ein Watchdog-Wert von einer @@ -43,6 +44,9 @@ Minute sowie ein aktueller WireGuard-Handshake. - Der physische Anschluss bezieht seine Adresse per DHCP; im Standortnetz muss dafür eine Freigabe bzw. Registrierung existieren. +- SSH bleibt auf der Standort-Schnittstelle erreichbar, akzeptiert aber nur + Public-Key-Anmeldungen. Vor dem Transport muss der Schlüsselzugriff getestet + werden. - Der WireGuard-Tunnel muss **vor dem Transport** erfolgreich aufgebaut und von zuhause erreichbar getestet sein. - Für WireGuard braucht Athena keine eingehende Portfreigabe am Standort: Der @@ -59,7 +63,8 @@ Minute sowie ein aktueller WireGuard-Handshake. 2. Rechner sauber herunterfahren und per Wake-on-LAN einschalten. 3. Netzspannung bei laufendem Rechner trennen, 30 Sekunden warten, wieder einschalten: Athena bootet automatisch vollständig hoch. -4. WireGuard aus- und wieder einschalten und Fail-closed-Routing prüfen. +4. `mike-ai-wireguard-gateway` stoppen: Container-Egress muss scheitern; + Gateway wieder starten und aktuellen Handshake prüfen. 5. Von zuhause aus ausschließlich über die spätere VPN-Adresse zugreifen. Ohne erfolgreich getesteten WireGuard-Tunnel und die UEFI-Stromoptionen gilt der diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 493456c..3667d2a 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -2,17 +2,18 @@ ## Netzgrenze -- KI-Ports binden ausschließlich an die WireGuard-IP. -- Docker-Netze `172.30.0.0/16` verwenden eine eigene Routingtabelle. +- Open WebUI und Router veröffentlichen keinerlei Host-Ports. +- Ein dedizierter WireGuard-Container stellt auf seiner VPN-Adresse nur 8080 + und 8081 bereit. +- Die egressfähigen Docker-Netze verwenden eigene Routingtabellen. - Heimnetz- und optionaler Internetverkehr laufen über WireGuard. -- Eine Blackhole-Default-Route verhindert Fail-open bei Tunnelverlust. -- `DOCKER-USER` erlaubt nur etablierte Verbindungen, KI→WireGuard und - WireGuard→Open-WebUI/Router. +- Die Tabellen zeigen ausschließlich zum Gateway-Container und besitzen keine + Route über das Standortgateway. Das ist der Fail-Closed-Mechanismus. - Der Host ist kein Router zwischen Universitäts- und Heimnetz. -Docker-publizierte Ports können gewöhnliche Host-Firewallregeln umgehen. -Darum setzt der Installer seine Regeln ausdrücklich in `DOCKER-USER` und -verlässt sich nicht allein auf UFW. +Da keine KI-Ports publiziert werden, kann Docker die Host-Firewall an dieser +Stelle nicht umgehen. SSH bleibt davon getrennt und schlüsselbasiert auf der +physischen Schnittstelle erreichbar. ## Containergrenzen diff --git a/docs/WIREGUARD_HOME_PEER.md b/docs/WIREGUARD_HOME_PEER.md index 888ac29..4ae9c1a 100644 --- a/docs/WIREGUARD_HOME_PEER.md +++ b/docs/WIREGUARD_HOME_PEER.md @@ -1,55 +1,53 @@ -# WireGuard-Heimseite +# WireGuard über die Fritzbox -Der Installer kann nur den KI-Host konfigurieren. Einmalig muss der vorhandene -WireGuard-Router im Heimnetz den neuen Peer kennen. Ohne diesen externen Schritt -kann kein automatisches Skript auf dem Uni-Host den Tunnel fertigstellen. +## Gewählter Betriebsmodus -## Peer ergänzen +Athena verwendet die Fritzbox-Konfiguration für **einen einzelnen +WireGuard-Client**. Die unveränderte Exportdatei wird als root-only Secret nach -Beispiel mit KI-WireGuard-Adresse `10.77.0.2/32`: - -```ini -[Peer] -PublicKey = -AllowedIPs = 10.77.0.2/32 +```text +/etc/mike-ai/wireguard/fritz-athena.conf ``` -Das Heimgerät muss das LAN `192.168.1.0/24` zum Tunnel routen können. Geräte im -Heimnetz benötigen entweder eine Route für `10.77.0.2/32` über den -WireGuard-Router oder der Router maskiert den VPN-Verkehr passend. +kopiert (`chmod 600`). Sie gehört weder ins Git-Repository noch in Backups ohne +Verschlüsselung. Eine LAN-zu-LAN-Konfiguration ist für diesen Host nicht nötig. -## KI-Internetzugang über zuhause +Der Tunnel endet im Container `mike-ai-wireguard-gateway`. Nur dieser Container +erhält `NET_ADMIN` und `/dev/net/tun`. Open WebUI und Router veröffentlichen +keine Host-Ports; der Gateway stellt ausschließlich Port 8080 und 8081 auf der +von der Fritzbox zugeteilten VPN-Adresse bereit. -Wenn `WG_ROUTE_AI_INTERNET=true` gesetzt ist, muss der Heim-Peer IPv4-Forwarding -und NAT ins WAN erlauben. Das wird auf dem Heimrouter eingerichtet, nicht auf -dem Universitätsnetz. Beispielprinzip für nftables: +## Split der Verantwortlichkeiten -```nft -table inet mike_ai { - chain forward { - type filter hook forward priority 0; policy accept; - iifname "wg0" ip saddr 10.77.0.2 accept - oifname "wg0" ip daddr 10.77.0.2 ct state established,related accept - } -} +- Debian, Paketverwaltung und SSH benutzen die normale Standortverbindung. +- Die Docker-Netze `frontend` und `tools-egress` werden per Quellrouting zum + WireGuard-Gateway geschickt. +- Interner Docker-Verkehr bleibt lokal und durchquert den Tunnel nicht. +- Der Fritzbox-Export darf `0.0.0.0/0` und `::/0` enthalten. Das ändert **nicht** + die Default-Route des Debian-Hosts, sondern nur die des Gateway-Namespace. +- Fällt WireGuard aus, bleibt die Quellroute auf den dann unerreichbaren + Gateway zeigen: Anwendungscontainer fallen geschlossen aus. -table ip mike_ai_nat { - chain postrouting { - type nat hook postrouting priority 100; policy accept; - ip saddr 10.77.0.2 oifname "" masquerade - } -} +## Kontrolle ohne Geheimnisse auszugeben + +```bash +docker inspect -f '{{.State.Health.Status}}' mike-ai-wireguard-gateway +docker exec mike-ai-wireguard-gateway wg show wg0 latest-handshakes +systemctl status mike-ai-container-vpn-guard ``` -Die tatsächlichen Interface-Namen und die bestehende Firewall des Heimrouters -gehen vor. Regeln nicht blind neben eine bereits verwaltete Firewall kopieren. +Der Healthcheck verlangt einen Handshake, der jünger als drei Minuten ist. +Open WebUI liegt unter `http://:8080`, der Router unter +`http://:8081`. An der physischen Standortadresse dürfen beide Ports +nicht antworten. -## Sicherheitsprüfung +## Getestetes Verhalten am 22. August 2026 -1. Vom Heimnetz `10.77.0.2` erreichen. -2. Open WebUI auf `10.77.0.2:8080` erreichen. -3. Aus dem Universitäts-LAN Port 8080/8081 nicht erreichen. -4. `wg0` am KI-Host stoppen: KI-Container dürfen nun weder Heimnetz noch - Internet erreichen. -5. Der Debian-Host selbst darf weiterhin nur die ausdrücklich gewünschte - Administration über das Uni-LAN anbieten. +- Fritzbox-Handshake und Datenverkehr in beide Richtungen: erfolgreich +- Open WebUI und Router über die VPN-Adresse: HTTP 200 +- dieselben Ports über die physische Hostadresse: geschlossen +- VPN-Gateway gestoppt: ausgehender Open-WebUI-Test blockiert (fail-closed) +- Gateway erneut gestartet: automatischer aktueller Handshake + +Die Exportdatei muss Bestandteil des verschlüsselten Disaster-Recovery-Satzes +sein. Ohne sie kann ein neuer Host den Heimtunnel nicht rekonstruieren. diff --git a/install.sh b/install.sh index c603529..ea3c02e 100755 --- a/install.sh +++ b/install.sh @@ -38,7 +38,7 @@ fi # shellcheck disable=SC1090 source "$CONFIG" -required=(AI_HOSTNAME ADMIN_USER AI_BIND_ADDRESS MODEL_DIR FAST_MODEL_FILE +required=(AI_HOSTNAME ADMIN_USER MODEL_DIR FAST_MODEL_FILE FAST_MODEL_URL FAST_MODEL_SHA256 MEDIUM_MODEL_FILE MEDIUM_MODEL_URL MEDIUM_MODEL_SHA256 LARGE_MODEL_FILE LARGE_MODEL_URL LARGE_MODEL_SHA256 ULTRA_MODEL_FILE ULTRA_MODEL_URL ULTRA_MODEL_SHA256 @@ -47,6 +47,9 @@ required=(AI_HOSTNAME ADMIN_USER AI_BIND_ADDRESS MODEL_DIR FAST_MODEL_FILE for name in "${required[@]}"; do [[ -n "${!name:-}" ]] || die "Pflichtwert $name fehlt." done +if [[ ${WIREGUARD_MODE:-container} != container ]]; then + [[ -n ${AI_BIND_ADDRESS:-} ]] || die "Pflichtwert AI_BIND_ADDRESS fehlt." +fi source /etc/os-release [[ ${ID:-} == debian ]] || die "Unterstützt wird Debian, gefunden: ${ID:-unbekannt}." @@ -111,6 +114,19 @@ EOF fi } +setup_ssh_hardening() { + [[ ${SSH_KEY_ONLY:-true} == true ]] || return 0 + log "SSH auf Schlüsselanmeldung beschränken" + install -d -m 0755 /etc/ssh/sshd_config.d + cat >/etc/ssh/sshd_config.d/20-athena-key-only.conf <<'EOF' +PasswordAuthentication no +KbdInteractiveAuthentication no +PubkeyAuthentication yes +EOF + sshd -t + systemctl reload ssh +} + install_docker() { log "Docker CE aus dem offiziellen Repository installieren" install -m 0755 -d /etc/apt/keyrings @@ -188,6 +204,7 @@ install_nvidia() { } setup_wireguard() { + [[ ${WIREGUARD_MODE:-container} != container ]] || return 0 [[ ${WIREGUARD_ENABLE:-false} == true ]] || return 0 for name in WG_INTERFACE WG_ADDRESS WG_HOME_SUBNET WG_PEER_PUBLIC_KEY WG_PEER_ENDPOINT; do [[ -n "${!name:-}" && ${!name} != REPLACE_* ]] || die "WireGuard-Wert $name fehlt." @@ -240,8 +257,9 @@ install_stack_files() { chmod 0640 "$searx" cat >$SECRETS_DIR/stack.env </usr/local/sbin/mike-ai-network-guard </dev/null || true)" = "healthy" ]; do @@ -438,6 +472,7 @@ PY hostnamectl set-hostname "$AI_HOSTNAME" install_base_packages setup_remote_recovery +setup_ssh_hardening install_docker install_nvidia setup_wireguard @@ -447,11 +482,22 @@ install_routing_guard build_and_start log "Installation abgeschlossen" +if [[ ${WIREGUARD_MODE:-container} == container ]]; then + vpn_address=$(awk -F= ' + /^[[:space:]]*Address[[:space:]]*=/ { + value=$2; gsub(/[[:space:]]/, "", value); split(value, addresses, ",") + for (i in addresses) if (addresses[i] !~ /:/) { sub(/\/.*/, "", addresses[i]); print addresses[i]; exit } + } + ' "${WIREGUARD_CONFIG_FILE:-/etc/mike-ai/wireguard/fritz-athena.conf}") +else + vpn_address=$AI_BIND_ADDRESS +fi cat <&2; exit 1; } +install -d -m 0700 /run/wireguard + +# Fritzbox exports global DNS directives and wg-quick hooks. DNS is assigned +# per application container by Docker; executable hooks are deliberately not +# accepted from a secret file. All cryptographic values remain untouched. +awk ' + /^[[:space:]]*(DNS|Table|PreUp|PostUp|PreDown|PostDown|SaveConfig)[[:space:]]*=/ { next } + /^\[Interface\][[:space:]]*$/ { print; print "Table = off"; next } + { print } +' "$SOURCE" >"$RUNTIME" +chmod 0600 "$RUNTIME" + +# Docker deliberately keeps /proc/sys read-only inside this narrowly +# privileged container. Table=off prevents wg-quick from trying to modify +# global policy-routing sysctls; the two required routes are installed below. +physical_default=$(ip -4 route show default | head -n 1) +physical_gateway=$(printf '%s\n' "$physical_default" | awk '{for (i=1; i<=NF; i++) if ($i == "via") print $(i+1)}') +physical_device=$(printf '%s\n' "$physical_default" | awk '{for (i=1; i<=NF; i++) if ($i == "dev") print $(i+1)}') + +wg-quick up "$RUNTIME" + +# Keep the encrypted peer itself reachable over Docker's physical network, +# then make the tunnel the namespace default. Connected Docker routes remain +# intact for the reverse proxies and internal service discovery. +endpoint=$(wg show wg0 endpoints | awk 'NR == 1 { print $2 }') +case "$endpoint" in + \[*\]:*) endpoint_ip=${endpoint#\[}; endpoint_ip=${endpoint_ip%%\]*} ;; + *:*) endpoint_ip=${endpoint%:*} ;; + *) endpoint_ip= ;; +esac + +if [ -n "$endpoint_ip" ] && [ -n "$physical_gateway" ] && [ -n "$physical_device" ]; then + case "$endpoint_ip" in + *:*) : ;; # Docker gateway networks are intentionally IPv4-only. + *) ip -4 route replace "$endpoint_ip/32" via "$physical_gateway" dev "$physical_device" ;; + esac +fi +ip -4 route replace default dev wg0 +ip -6 route replace default dev wg0 2>/dev/null || true + +cleanup() { + kill "${proxy_ui_pid:-}" "${proxy_router_pid:-}" 2>/dev/null || true + wg-quick down "$RUNTIME" 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +# Forward only explicitly selected Docker networks into the tunnel. The +# gateway itself is the only container that receives NET_ADMIN. +iptables -P FORWARD DROP +iptables -A FORWARD -o wg0 -j ACCEPT +iptables -A FORWARD -i wg0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT +iptables -t nat -A POSTROUTING -o wg0 -j MASQUERADE + +# Nothing is published on the physical host. These listeners exist only in +# the WireGuard container namespace and forward VPN clients to internal names. +socat TCP-LISTEN:8080,bind=0.0.0.0,reuseaddr,fork TCP:open-webui:8080 & +proxy_ui_pid=$! +socat TCP-LISTEN:8081,bind=0.0.0.0,reuseaddr,fork TCP:router:8081 & +proxy_router_pid=$! + +wait "$proxy_ui_pid" diff --git a/platform/docker/wireguard-gateway/healthcheck.sh b/platform/docker/wireguard-gateway/healthcheck.sh new file mode 100644 index 0000000..0a849f0 --- /dev/null +++ b/platform/docker/wireguard-gateway/healthcheck.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu +wg show wg0 >/dev/null +ip link show wg0 | grep -q 'state UNKNOWN\|state UP' +latest=$(wg show wg0 latest-handshakes | cut -f2 | head -n 1) +now=$(date +%s) +[ "${latest:-0}" -gt 0 ] +[ $((now - latest)) -lt 180 ] +kill -0 1 diff --git a/platform/host/mike-ai-container-vpn-guard b/platform/host/mike-ai-container-vpn-guard new file mode 100644 index 0000000..ef60e09 --- /dev/null +++ b/platform/host/mike-ai-container-vpn-guard @@ -0,0 +1,52 @@ +#!/bin/sh +set -eu + +network_bridge() { + id=$(docker network inspect -f '{{.Id}}' "$1") + printf 'br-%.12s\n' "$id" +} + +wait_network() { + count=0 + until docker network inspect "$1" >/dev/null 2>&1; do + count=$((count + 1)) + [ "$count" -lt 60 ] || return 1 + sleep 2 + done +} + +install_rule() { + network=$1 subnet=$2 gateway=$3 table=$4 priority=$5 + bridge=$(network_bridge "$network") + ip rule del from "$subnet" table "$table" priority "$priority" 2>/dev/null || true + ip rule add from "$subnet" table "$table" priority "$priority" + # A fresh host has no FIB object for the custom table yet; iproute2 returns + # an error in that perfectly normal case. + ip route flush table "$table" 2>/dev/null || true + ip route add "$subnet" dev "$bridge" scope link table "$table" + ip route add default via "$gateway" dev "$bridge" table "$table" +} + +wait_network mike-ai_frontend +wait_network mike-ai-tools-egress + +# Preserve all east/west Docker communication before source-policy routing. +ip rule del to 172.30.0.0/16 lookup main priority 11000 2>/dev/null || true +ip rule add to 172.30.0.0/16 lookup main priority 11000 + +# The gateway's encrypted outer packets must leave through the host's normal +# uplink. Without these narrow exceptions they would match the source rules +# below and be routed straight back into the gateway (a routing loop). +ip rule del from 172.30.10.254/32 lookup main priority 11010 2>/dev/null || true +ip rule add from 172.30.10.254/32 lookup main priority 11010 +ip rule del from 172.30.50.254/32 lookup main priority 11011 2>/dev/null || true +ip rule add from 172.30.50.254/32 lookup main priority 11011 + +install_rule mike-ai_frontend 172.30.10.0/24 172.30.10.254 51821 12010 +install_rule mike-ai-tools-egress 172.30.50.0/24 172.30.50.254 51825 12050 + +# Drop any route/conntrack decisions learned before the policy rules existed. +# Compose will wait for the gateway healthcheck before exposing dependants. +if [ "$(docker inspect -f '{{.State.Running}}' mike-ai-wireguard-gateway 2>/dev/null || true)" = true ]; then + docker restart mike-ai-wireguard-gateway >/dev/null +fi diff --git a/platform/host/mike-ai-container-vpn-guard.service b/platform/host/mike-ai-container-vpn-guard.service new file mode 100644 index 0000000..1fdcac7 --- /dev/null +++ b/platform/host/mike-ai-container-vpn-guard.service @@ -0,0 +1,14 @@ +[Unit] +Description=Route Mike AI Docker egress through the WireGuard gateway container +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/mike-ai-container-vpn-guard +RemainAfterExit=yes +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target