Files
infrastructure/docs/GNAI-006-SEC_VPN_Implementation-Log.md
T

12 KiB
Raw Blame History

GNAI-006-SEC – Implementation-Log

Bezug: GNAI-006-SEC_VPN.md Session: 2026-09-09 (Agent-Session, Arbeit per SSH auf LXC 105) Letzte Aktualisierung: 2026-09-09 17:15 – Fritzbox-Portforward ✅; DNS-Korrektur (A-Record beim Domain-DNS-Anbieter, nicht NPM); WAN-IP 93.208.161.126 verifiziert; O1/O2 aktualisiert Zweck: Nachvollziehbare Dokumentation der Umsetzungsschritte, Abweichungen vom Draft, technischen Befunde und Fehlerbehebungen.


1. erledigte Schritte

1.1 SSH-Zugriff & Admin-User (GNAI-004-SEC)

  • admin (uid 1000) mit SSH-Key-Zugang; sudoers: admin ALL=(ALL) NOPASSWD: ALL
  • sshd-Härtung-Drop-in /etc/ssh/sshd_config.d/99-security.conf (PermitRootLogin no, PasswordAuthentication no, KbdInteractiveAuthentication no, PubkeyAuthentication yes, PermitEmptyPasswords no)
  • Verifiziert: Key-Login, sudo passwortlos
  • /root/.ssh/authorized_keys existierte nie → kein root-Key-Rückbau nötig
  • Lokal: ~/.ssh/config-Alias lxc → 10.0.0.15, User admin, dedizierter Key ~/.ssh/id_ed25519_lxc, StrictHostKeyChecking accept-new

1.2 nftables & IP-Forwarding

  • /etc/nftables.conf geschrieben (via sudo tee) und geladen (sudo /usr/sbin/nft -f /etc/nftables.conf)
    • INPUT: policy drop, lo accept, invalid drop, established/related accept, ICMP-Erreichbarkeit, UDP 51820 öffentlich, tcp 22/51821 nur aus 10.0.0.0/24, Rate-limited Drop-Log nft-input-dropped:
    • FORWARD: policy drop, iif wg0 → 10.0.0.0/24 new accept (Split-Tunnel), Reverse-Route-Regel, drop
    • OUTPUT: policy accept
  • net.ipv4.ip_forward=1 aktiv, persistent via /etc/sysctl.d/99-ipforward.conf
  • Verifiziert: SSH nach nftables-Load weiterhin erreichbar; Regeln per nft list ruleset kontrolliert

1.3 wg-easy Quadlet

  • podman 5.4.2 installiert; TUN-Device c 10:200 vorhanden (vorhanden seit Podman-Setup, lxc.mount.entry in 105.conf bereits gesetzt)
  • /opt/wg-easy/data (Volume-Target) + /opt/wg-easy/environment (chmod 600) angelegt
  • /etc/containers/systemd/wg-easy.container → generierte Unit: wg-easy.service (Podman streicht die .container-Endung beim Unit-Namen)
  • systemctl daemon-reload && systemctl restart wg-easy.service
  • Ergebnis: wg0 up, 10.8.0.1/24, ListenPort 51820; UI http://10.0.0.15:51821 → HTTP 200 (localhost & LAN-IP)
  • ENV im Container verifiziert (podman inspect): WG_HOST=vpn.ghostnet.selfhost.eu, WG_PORT=51820, WG_DEFAULT_ADDRESS=10.8.0.x, WG_DEFAULT_DNS=10.0.0.1, bcrypt-PASSWORD_HASH vorhanden

1.4 Peers

Beide Peers über die wg-easy-API erstellt (Details Sektion 3):

Peer Adresse Status
Laptop-FullAdmin 10.8.0.2 aktiv, PreSharedKey, in wg0.conf
Android-LLMAdmin 10.8.0.3 aktiv, PreSharedKey, in wg0.conf

Server-Config /opt/wg-easy/data/wg0.conf: Server 10.8.0.1/24, ListenPort 51820, beide Peers mit PresharedKey, serverseitig AllowedIPs = <peer-ip>/32 (wg-easy-Hardcode, siehe Sektion 3.3).


2. Abweichungen vom ursprünglichen Draft

# Draft Umsetzung Begründung
A1 Hostname wireguard-gateway Hostname wireguard Konvention: kurze, eindeutige Hostnames (entspricht GNAI-003-Bezeichnung „WireGuard LXC“)
A2 PASSWORD_HASH=DEIN_ARGON2_HASH bcrypt-Hash (Rounds 12) wg-easy verifiziert per bcryptjs – Argon2 wird nicht unterstützt
A3 Quadlet mit inline Environment=-Zeilen EnvironmentFile=/opt/wg-easy/environment $-Zeichen im bcrypt-Hash ($2a$12$…) führen beim inline Environment= zu escaping-Problem (Hash ging beim ersten Try verloren, siehe Fehler-Log F3). EnvironmentFile ist deterministisch
A4 Testmatrix „Validiert“ Testmatrix „Ausstehend“ Penetrationstests über Mobilfunk wurden in dieser Session noch nicht durchgeführt (warten auf Step 5/6: Fritzbox-Forward + DynDNS-Subdomain)
A5 nftables-Schritt fehlte im Draft Neue Step 2 (nftables + ip_forward) Strikte INPUT policy drop ist Doktrinen-Anforderung (GNAI-004-SEC); wg-easy erzeugt eigene (breitere) Regeln, die nicht ausreichen
A6 Peers „über Web-UI manuell“ Peers via REST-API UI-Flow in Session nicht durchlaufen; API liefert deterministisches Ergebnis (Endpoint-Dokumentation siehe Sektion 3.1)
A7 PostUp-Regeln wg-easy nicht erwähnt Dokumentiert: redundant, kein Konflikt wg-easy-Default-PostUp setzt iptables-MASQUERADE/FORWARD/INPUT-ACCEPT-Regeln. Mit der nftables-Config redundant; iptables-FORWARD-ACCEPT ist breiter, die maßgeblichen nftables-Forward-Rules bleiben bestehen. Optional: WG_POST_UP/WG_PRE_DOWN über Env auf Leerzeichen setzen – Entscheidung offen

3. Technische Befunde wg-easy

3.1 API-Referenz (aus ghcr.io/wg-easy/wg-easy:latest, verifiziert in Session)

Aktion Endpoint Body Hinweis
Login POST /api/session { "password": "…" } Setzt connect.sid-Cookie; POST / gibt 405 zurück
Client anlegen POST /api/wireguard/client { "name": "…" } Nur name wird akzeptiert; Address wird aus WG_DEFAULT_ADDRESS (10.8.0.x) sequenziell zugewiesen (Server .1, erste Clients .2, .3, …)
Address ändern PUT /api/wireguard/client/:clientId/address { "address": "10.8.0.x" } clientId = UUID (aus GET /api/wireguard/client)
Name ändern PUT /api/wireguard/client/:clientId/name { "name": "…" }
Enable / Disable POST /api/wireguard/client/:clientId/enable / disable –
Client-Config (download) GET /api/wireguard/client/:clientId/configuration – clientId = UUID, nicht Name (Name → 404)
Config neu generieren POST /api/wireguard (ALLOW_CONFIG_CHANGE=true) – Schreibt wg0.conf + wendet wg an

Client-UUIDs (LXC): Laptop-FullAdmin = 2e9ed910-36dc-4fbd-8694-8d904670ea19, Android-LLMAdmin = 2009acd2-19cf-4e5e-a93a-7b05eb05fc12.

3.2 Passwort-Handling

  • PASSWORD_HASH wird mit bcryptjs (Salt-Rounds 12, bcrypt-Format $2a$12$…) verifiziert.
  • UI-Login: POST /api/session mit Klartext-Passwort (im internen Netz, LAN-only freigeschaltet).

3.3 allowedIPs-Limitation (wichtigster Befund)

  • Serverseitig: __saveConfig() in /app/lib/WireGuard.js (Container) generiert pro Peer AllowedIPs = ${client.address}/32 – hartkodiert. Das Feld allowedIPs im internen wg0.json existiert, wird vom Create-Endpoint aber nie befüllt (stets None).
  • Konsequenz: Es gibt keinen API-Path, um serverseitige AllowedIPs freier zu konfigurieren (z. B. ein Peer aus mehreren VPN-IPs).
  • Architektonische Bewertung: Für unser Modell ist das serverseitige /32-Verhalten korrekt und gewünscht: Ein Peer darf nur Traffic aus seiner eigenen VPN-IP senden. Das eigentliche Routing (Split-Tunneling) ist clientseitig bestimmt – die AllowedIPs im installierten Client-Profil entscheiden, welche Zielnetzwerke über den Tunnel laufen.
  • Clientseitig (gelöst 2026-09-09): getClientConfiguration() in /app/lib/WireGuard.js generiert in den herunterladbaren Profilen AllowedIPs = ${WG_ALLOWED_IPS} – eine globale Env-Variable (kein per-Client-Override). Default: 0.0.0.0/0, ::/0 = Full-Tunnel (alle Traffic durch VPN) – nicht gewünscht.
  • Aktion: WG_ALLOWED_IPS in /opt/wg-easy/environment ergänzt (Backup environment.bak.<ts>), wg-easy.service neu gestartet, Env im Container verifiziert:
    WG_ALLOWED_IPS=10.0.0.0/24,10.8.0.0/24,10.0.0.2/32,10.0.0.10/32,10.0.0.90/32
    
  • Android-Limitierung: WireGuard für Android akzeptiert in AllowedIPs nur /32 (keine /24-CIDRs). Die /32-Einträge (Fritzbox .1, Proxmox .2, NPM .10, LLM-Server .90) existieren daher explizit für Android-LLMAdmin; Laptop-FullAdmin nutzt die /24-Netzwerke. Pflege-Konsequenz: Neue LAN-Hosts müssen beide Varianten erhalten (CIDR + /32), danach wg-easy.service neu starten.
  • End-to-End-Verifikation (O4) bleibt offen: heruntergeladene Profile prüfen (AllowedIPs + Endpoint), dann Routing-Tests auf den Endgeräten.

4. Fehler-Log & Behebungen

# Fehler Ursache Behebung
F1 bash: /etc/nftables.conf: Permission denied, nft: command not found admin-User ohne root; nft nicht im non-login-Shell-PATH sudo tee /etc/nftables.conf, sudo /usr/sbin/nft … (volle Pfade)
F2 Failed to start wg-easy.container.service: Unit not found Quadlet generiert Unit wg-easy.service (ohne .container) sudo systemctl start wg-easy.service
F3 PASSWORD_HASH fehlte nach Container-Start in podman inspect Environment=PASSWORD_HASH=\$2a\$12\$… – Inline-Escaping in Quadlet schlug fehl, Hash ging verloren /opt/wg-easy/environment (chmod 600) mit echten $-Zeichen + EnvironmentFile=; nach Restart verifiziert: Hash vorhanden
F4 POST / → 405 Method Not Allowed beim UI-Login Falscher Endpoint POST /api/session mit {"password": "…"} → 200 + Set-Cookie: connect.sid
F5 GET /api/wireguard/client/Laptop-FullAdmin/configuration → 404 Endpoint erwartet UUID, nicht Client-Namen GET /api/wireguard/client/2e9ed910-…/configuration
F6 allowedIPs aus Create-Request nicht persistiert (wg0.json: None) Create-Endpoint extrahiert ausschließlich {name}; kein Endpoint für allowedIPs; serverseitig hardcodiert address/32 Sektion 3.3 – Bewertung: serverseitig korrekt; clientseitige Routing-Verifikation offen (Step 8 im SOP)
F7 sudo wg: command not found (ältere Session) wg-Tool nicht installiert / PATH Status via cat /proc/net/dev + wg0-Config geprüft; wg-easy verwaltet das Interface selbst
F8 sysctl nicht verfügbar (ältere Session) Paket sysctl-utils/PATH cat /proc/sys/net/ipv4/ip_forward; persistenz via /etc/sysctl.d/99-ipforward.conf

5. Offene Punkte & nächste Schritte

# Punkt Typ Blockiert durch
O1 Fritzbox 7530: Portforward UDP 51820 → 10.0.0.15 ✅ erledigt (2026-09-09) E2E-Check mit O4/O5
O2 DNS für vpn.ghostnet.selfhost.eu (Korrektur: nicht NPM/selfhost.eu-Portal, sondern Domain-DNS-Anbieter) ✅ erledigt (2026-09-09): Auflösung 93.208.161.126 verifiziert; bestehende Wildcard-DynDNS *.ghostnet.selfhost.eu war bereits ausreichend, expliziter A-Record redundant; DynDNS vorhanden → Statisch-Check entfällt –
O3 Client-Profile herunterladen (Laptop + Android), AllowedIPs (aus WG_ALLOWED_IPS) + Endpoint vpn.ghostnet.selfhost.eu:51820 prüfen Agent + User O1, O2
O4 Split-Tunnel-Routing end-zu-end verifizieren (Step 8 SOP; Konfig steht, s. 3.3) Agent + User O3
O5 Penetrationstest Mobilfunk (Testmatrix Sektion 4 SOP) gemeinsam O1, O2, O3
O6 Entscheidung: wg-easy-PostUp-iptables-Regeln via WG_POST_UP/WG_PRE_DOWN deaktivieren (hygiene) oder belassen (redundant, kein Konflikt) Agent O5 (nach Tests)
O7 Projekt-Kontext in MEMORY.md / Memory-Store persistieren Agent –

Abhängigkeits-Kette: O1+O2 → O3 → O4 → O5 → (O6)


6. Artefakt-Index (LXC 105, /etc/… & /opt/…)

Pfad Zweck Status
/etc/nftables.conf Perimeter-Firewall (strict INPUT drop) aktiv
/etc/sysctl.d/99-ipforward.conf net.ipv4.ip_forward=1 persist aktiv
/etc/containers/systemd/wg-easy.container Quadlet → wg-easy.service aktiv
/opt/wg-easy/environment wg-easy ENV (inkl. bcrypt-PASSWORD_HASH, chmod 600) aktiv
/opt/wg-easy/data/wg0.conf WireGuard-Server-Config (wg-easy-generiert) aktiv, wg0 up
/opt/wg-easy/data/wg0.json wg-easy interner State (Clients, UUIDs) aktiv
/etc/ssh/sshd_config.d/99-security.conf SSH-Härtung (GNAI-004-SEC) aktiv
/etc/sudoers.d/admin admin NOPASSWD sudo aktiv
Lokal: ~/.ssh/config Alias lxc (User admin, dedizierter Key) gesetzt

Secrets (wg-easy-UI-Passwort/Hash, PreSharedKeys, PrivateKeys) sind nicht in diesem Repo dokumentiert und liegen ausschließlich auf dem LXC bzw. in den clientseitigen Profilen.