Add fail-closed WireGuard container gateway

This commit is contained in:
Mikei386
2026-08-22 20:48:49 +02:00
parent 2f3bdde8b0
commit 0e5876f1a9
17 changed files with 365 additions and 109 deletions
+4 -3
View File
@@ -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
+38 -4
View File
@@ -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:
+9 -3
View File
@@ -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
+20 -15
View File
@@ -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
<Fritz-VPN-IP>:8080 Open WebUI
<Fritz-VPN-IP>: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
+3 -1
View File
@@ -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
+17 -19
View File
@@ -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 = <AUSGABE-DES-INSTALLERS>
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://<WIREGUARD-IP>: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
+2 -2
View File
@@ -103,7 +103,7 @@ erfolgreich. Medium ist das Start- und Standardprofil.
Clients verbinden sich mit:
```text
http://HOST:8081/v1
http://<FRITZ-VPN-IP>: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
+6 -2
View File
@@ -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:
+9 -4
View File
@@ -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
+9 -8
View File
@@ -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
+40 -42
View File
@@ -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 = <PUBLIC-KEY-DES-KI-HOSTS>
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 "<HEIM-WAN-INTERFACE>" 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://<VPN-IP>:8080`, der Router unter
`http://<VPN-IP>: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.
+52 -6
View File
@@ -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 <<EOF
AI_BIND_ADDRESS=$AI_BIND_ADDRESS
AI_BIND_ADDRESS=${AI_BIND_ADDRESS:-127.0.0.1}
MODEL_DIR=$MODEL_DIR
WIREGUARD_CONFIG_FILE=${WIREGUARD_CONFIG_FILE:-/etc/mike-ai/wireguard/fritz-athena.conf}
ROUTER_API_KEY=$(<$SECRETS_DIR/router-api-key)
CONTROLLER_TOKEN=$(<$SECRETS_DIR/controller-token)
WEBUI_SECRET_KEY=$(<$SECRETS_DIR/webui-secret)
@@ -316,6 +334,18 @@ EOF
}
install_routing_guard() {
if [[ ${WIREGUARD_MODE:-container} == container ]]; then
log "Container-WireGuard-Routing installieren"
[[ -s ${WIREGUARD_CONFIG_FILE:-/etc/mike-ai/wireguard/fritz-athena.conf} ]] || \
die "Fritzbox-WireGuard-Datei fehlt: ${WIREGUARD_CONFIG_FILE:-/etc/mike-ai/wireguard/fritz-athena.conf}"
install -m 0755 "$ROOT_DIR/platform/host/mike-ai-container-vpn-guard" \
/usr/local/sbin/mike-ai-container-vpn-guard
install -m 0644 "$ROOT_DIR/platform/host/mike-ai-container-vpn-guard.service" \
/etc/systemd/system/mike-ai-container-vpn-guard.service
systemctl daemon-reload
systemctl enable mike-ai-container-vpn-guard.service
return 0
fi
[[ ${WIREGUARD_ENABLE:-false} == true ]] || return 0
log "KI-Container auf WireGuard routen und Uni-Netz als Transit sperren"
cat >/usr/local/sbin/mike-ai-network-guard <<EOF
@@ -392,6 +422,10 @@ build_and_start() {
docker compose --env-file "$SECRETS_DIR/stack.env" up -d --build \
profile-controller router open-webui
if [[ ${WIREGUARD_MODE:-container} == container ]]; then
systemctl restart mike-ai-container-vpn-guard.service
fi
log "Auf gesunden Router warten"
local deadline=$((SECONDS + 180))
until [ "$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' mike-ai-router 2>/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 <<EOF
OpenWebUI: http://${AI_BIND_ADDRESS}:8080
Router-API: http://${AI_BIND_ADDRESS}:8081
OpenWebUI: http://${vpn_address}:8080
Router-API: http://${vpn_address}:8081
Die geheimen Schlüssel liegen ausschließlich unter $SECRETS_DIR (0600).
Konfiguriere auf der Heimseite für den KI-Peer mindestens die Rückroute
${WG_ADDRESS:-10.77.0.2/32}; bei Internet-Routing zusätzlich NAT/Forwarding.
Im Container-Modus stammen Peer, Adresse und Heimrouten vollständig aus dem
root-only Fritzbox-Export. Die physischen Host-Adressen veröffentlichen keine
KI-Ports.
EOF
@@ -0,0 +1,13 @@
FROM debian:13-slim
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates iproute2 iptables procps socat wireguard-tools \
&& rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /usr/local/sbin/mike-ai-wireguard-entrypoint
COPY healthcheck.sh /usr/local/sbin/mike-ai-wireguard-healthcheck
RUN chmod 0755 /usr/local/sbin/mike-ai-wireguard-entrypoint \
/usr/local/sbin/mike-ai-wireguard-healthcheck
ENTRYPOINT ["/usr/local/sbin/mike-ai-wireguard-entrypoint"]
@@ -0,0 +1,68 @@
#!/bin/sh
set -eu
SOURCE=${WG_CONFIG_SOURCE:-/run/secrets/fritz-athena.conf}
RUNTIME=/run/wireguard/wg0.conf
[ -s "$SOURCE" ] || { echo "WireGuard configuration is missing" >&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"
@@ -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
+52
View File
@@ -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
@@ -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