Files
infrastructure/docs/GNAI-006-SEC_VPN.md
T

14 KiB
Raw Blame History

Standard Operating Procedure & Technical Architecture Specification

Dokumenten-ID: GNAI-006-SEC

Titel: Homelab / Infrastructure – WireGuard VPN Gateway (LXC) Setup & Access Control

Version: 1.3

Status: In Umsetzung (Schritt 1–4 & 7 fertig, Step 5/6/9 offen)

Vorgänger / Referenzen: GNAI-003-INFRA, GNAI-004-SEC

Änderungsvorschlag Draft: Hostname, Testmatrix und PASSWORD_HASH-Format gemäß Umsetzung korrigiert (siehe Changelog)


1. Zweck & Geltungsbereich

Dieses Dokument beschreibt die verbindliche Einrichtung, Härtung und Verifikation des WireGuard-VPN-Gateways auf einem dedizierten Proxmox LXC-Container (CT 105, IP 10.0.0.15/24). Das System dient als primäre Management-Zugangsschicht für administrative Zugriffe aus dem Internet (z. B. via Mobilfunk) unter strikter Einhaltung des Sicherheits- und Rollenmodells aus GNAI-004-SEC.


2. System-Baseline & Netzwerkarchitektur

Parameter Wert
Container-ID CT 105
Hostname wireguard
Betriebssystem Debian GNU/Linux 13 (Trixie)
Proxmox VE 9.2.11
Unprivileged Ja (mit /dev/net/tun Passthrough, c 10:200)
IP-Adresse LXC 10.0.0.15/24 (Gateway: 10.0.0.1)
VPN Subnetz 10.8.0.0/24
Listening Port 51820/UDP (Portweiterleitung auf Fritzbox 7530 an 10.0.0.15)
DNS Subdomain vpn.ghostnet.selfhost.eu (A-Record beim Domain-DNS-Anbieter → WAN-IP) als WG_HOST
Container Engine Podman 5.4.2, Quadlet (/etc/containers/systemd/wg-easy.container)
VPN Management wg-easy (ghcr.io/wg-easy/wg-easy:latest), UI auf http://10.0.0.15:51821 (LAN-only)
Firewall nftables, strikte INPUT policy drop
Routing Split-Tunneling, ip_forward=1 persistent

Rollen- & Zugriffsmatrix (gemäß GNAI-004-SEC)

  • Full Admin Profil (Admin-Laptop):

    • VPN-IP: 10.8.0.2/32
    • AllowedIPs Client-Seitig: 10.0.0.0/24 (Split-Tunneling)
    • Erlaubte Ziele: Proxmox (10.0.0.2), LLM-Server (10.0.0.90), NPM Admin (10.0.0.10:81), Service-LXCs
  • LLM Admin Profil (Android-Mobilgerät):

    • VPN-IP: 10.8.0.3/32
    • AllowedIPs Client-Seitig: 10.0.0.90/32
    • Erlaubte Ziele: Exklusiv LLM-Server (10.0.0.90, z. B. SSH Port 22)

Hinweis wg-easy / AllowedIPs (Umsetzung 2026-09-09): Die serverseitige AllowedIPs-Eintragung wird von wg-easy hartkodiert als client.address/32 (/app/lib/WireGuard.js, __saveConfig()) – korrekt für diese Architektur (Peer kommt nur aus seiner eigenen VPN-IP). Das clientseitige Routing (Split-Tunneling) wird in den heruntergeladenen Profilen aus der Env-Variable WG_ALLOWED_IPS generiert (/app/lib/WireGuard.js, getClientConfiguration()). Default von wg-easy wäre 0.0.0.0/0, ::/0 (Full-Tunnel!) – umgestellt auf Split-Tunnel via /opt/wg-easy/environment:

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-Einträge (keine /24-CIDRs). Die zusätzlichen /32-Einträge (Fritzbox, Proxmox, NPM, LLM-Server) existieren daher ausschließlich für Android-LLMAdmin; Laptop-FullAdmin nutzt die /24-Netzwerke. Konsequenz: Neue LAN-Hosts müssen beide Einträge pflegen (CIDR + /32) – kein per-Client-Override in wg-easy möglich (eine Env-Variable für alle Profile).


3. Schritt-für-Schritt Einrichtung & Deployment

Statuslegenden: ✅ erledigt (2026-09-09, Agent-Session) · ⏳ offen · 👤 User-Aktion

1. LXC Container & TUN-Device auf Proxmox konfigurieren ✅

Unprivileged Debian 13 LXC (CT 105), IP 10.0.0.15/24. In /etc/pve/lxc/105.conf auf Proxmox:

lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file

Verifikation: ls -l /dev/net/tun im Container zeigt Character Device c 10 200. ✅ (vorhanden seit Podman-Setup)

2. Firewall (nftables) & IP-Forwarding ✅

Strikte INPUT policy drop, öffentlich nur WireGuard UDP 51820, Management-Ports nur aus dem LAN, Forward-Chain für Split-Tunneling via wg0.

/etc/sysctl.d/99-ipforward.conf:

net.ipv4.ip_forward=1

/etc/nftables.conf (geladen, SSH-Funktionalität nach Load verifiziert):

#!/usr/sbin/nft -f

flush ruleset

table inet filter {
	chain input {
		type filter hook input priority filter; policy drop;
		iifname "lo" accept
		ct state invalid drop
		ct state established,related accept
		icmp type { echo-request, echo-reply, destination-unreachable, time-exceeded } accept
		udp dport 51820 accept
		ip saddr 10.0.0.0/24 tcp dport { 22, 51821 } accept
		limit rate 4/second burst 8 packets log prefix "nft-input-dropped: "
		drop
	}
	chain forward {
		type filter hook forward priority filter; policy drop;
		ct state invalid drop
		ct state established,related accept
		iifname "wg0" ip daddr 10.0.0.0/24 ct state new accept
		oifname "wg0" ip saddr 10.0.0.0/24 ip daddr 10.8.0.0/24 ct state new accept
		drop
	}
	chain output {
		type filter hook output priority accept;
	}
}

Verifikation: ✅ SSH aus LAN erreichbar, WAN-Regeln per nft list ruleset kontrolliert, ip_forward=1 aktiv und persistent.

Hinweis: Die wg-easy-PostUp-Regeln in wg0.conf (iptables MASQUERADE/FORWARD/INPUT-ACCEPT für 51820) sind mit der nftables-Config redundant, kollidieren aber nicht (iptables-FORWARD-ACCEPT ist breiter als die nftables-Forward-Rules, die maßgeblich bleiben). Optional über WG_POST_UP/WG_PRE_DOWN leerbaren lassen – Entscheidung offen.

3. SSH-Härtung & Admin-User (gemäß GNAI-004-SEC) ✅

  • User admin (uid 1000), sudoers admin ALL=(ALL) NOPASSWD: ALL
  • PermitRootLogin no – SSH-Zugriff ausschließlich über admin mit SSH-Key
  • Drop-in /etc/ssh/sshd_config.d/99-security.conf:
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
PermitEmptyPasswords no

Verifikation: ✅ Key-Login als admin, sudo passwortlos, /root/.ssh/authorized_keys existierte nie (kein root-Key Cleanup nötig).

4. Podman Quadlet für wg-easy ✅

Installiert: podman 5.4.2.

Wichtige Umsetzungsentscheidungen (Abweichungen vom ursprünglichen Draft):

  1. bcrypt statt Argon2: wg-easy verifiziert das UI-Passwort mit bcryptjs (Salt-Rounds 12) über PASSWORD_HASH. Argon2-Hashes werden nicht akzeptiert.
  2. EnvironmentFile statt Environment=: Der bcrypt-Hash enthält $-Zeichen ($2a$12$…), die im Quadlet-Environment=-Format fehleranfällig escaped werden müssen. Sauberer Weg: EnvironmentFile=.
  3. Unit-Name: Quadlet generiert wg-easy.service (nicht wg-easy.container.service).

/opt/wg-easy/environment (chmod 600, Owner root):

WG_HOST=vpn.ghostnet.selfhost.eu
WG_PORT=51820
WG_DEFAULT_ADDRESS=10.8.0.x
WG_DEFAULT_DNS=10.0.0.1
ALLOW_CONFIG_CHANGE=true
PASSWORD_HASH=$2a$12$<60-zeichen- bcrypt-hash>

/etc/containers/systemd/wg-easy.container:

[Container]
Image=ghcr.io/wg-easy/wg-easy:latest
ContainerName=wg-easy
Network=host
AddCapability=NET_ADMIN
AddCapability=NET_RAW
EnvironmentFile=/opt/wg-easy/environment
Volume=/opt/wg-easy/data:/etc/wireguard:rw

[Service]
Restart=on-failure

[Install]
WantedBy=multi-user.target

Sicherheitshinweis: Der aktive bcrypt-Hash des wg-easy-UI-Passworts wird bewusst nicht in diesem Dokument abgelegt (Secrets nicht in Docs). Der aktuelle Wert liegt ausschließlich in /opt/wg-easy/environment auf dem LXC (chmod 600). Passwort-Rotation: neuen bcrypt-Hash (Rounds 12, z. B. npx bcryptjs -r 12 "<passwort>") generieren, Datei aktualisieren, systemctl restart wg-easy.service.

Verifikation: ✅ podman ps zeigt wg-easy mit host-Netzwerk; wg0 up mit 10.8.0.1/24, ListenPort 51820; UI http://10.0.0.15:51821 HTTP 200 (localhost & LAN-IP).

5. Perimeter-Portweiterleitung Fritzbox 7530 👤 (User) ✅

Portweiterleitung UDP 51820 → 10.0.0.15 auf der Fritzbox 7530 einrichten.

Verifikation: tcpdump -i eth0 udp port 51820 auf dem LXC während Handshake-Versuch aus dem WAN.

Status: ✅ erledigt (2026-09-09, User); End-to-End-Verifikation offen (Step 8/9).

6. DNS-Subdomain 👤 (User)

Korrektur 2026-09-09: Der A-Record gehört nicht in den Nginx Proxy Manager (NPM 10.0.0.10 – der verwaltet nur HTTP(S)-Proxy-Hosts, für UDP 51820 nutzlos) und nicht ins selfhost.eu-Web-Portal (Instanzen-Hosting, keine DNS-Zone). Die Zone ghostnet.selfhost.eu wird beim DNS-Anbieter/Registrar der Domain gepflegt – dort, wo jellyfin./nextcloud./… bereits existieren.

Eintrag anlegen: vpn → Typ A – IPv4 → Ziel WAN-IP (93.208.161.126 laut getent am 2026-09-09). DNS kann keine Ports tragen – der Port steht in der Client-Config (Endpoint = vpn.ghostnet.selfhost.eu:51820).

⚠️ WAN-IP-Check offen: Falls die WAN-IP dynamisch ist (Fritzbox-Übersicht → „statische IP“ prüfen), veraltet der A-Record bei IP-Wechsel → DynDNS nötig. Bestehende Subdomains laufen bereits auf 93.208.161.126.

Status: ✅ erledigt (2026-09-09). Verifikation: vpn.ghostnet.selfhost.eu → 93.208.161.126 (getent + nslookup, 17:22). Hinweis: expliziter A-Record war nicht nötig – eine bestehende Wildcard-DynDNS-Zuordnung *.ghostnet.selfhost.eu → WAN-IP lag bereits vor (exakter Eintrag schlägt vor Wildcard; gleicher Wert → harmlos, redundanter Eintrag kann optional entfernt werden). DynDNS vorhanden → Statisch-Check entfällt.

7. Peers erzeugen ✅

Peers über die wg-easy-API erstellt (UI unter http://10.0.0.15:51821, Login POST /api/session):

Peer VPN-IP Client-UUID Erlaubte Ziele (clientseitiges Routing)
Laptop-FullAdmin 10.8.0.2 2e9ed910-36dc-4fbd-8694-8d904670ea19 10.0.0.0/24, 10.8.0.0/24
Android-LLMAdmin 10.8.0.3 2009acd2-19cf-4e5e-a93a-7b05eb05fc12 10.0.0.90/32 (+ 10.8.0.0/24)

Server-Config wg0.conf enthält beide Peers mit PresharedKey; serverseitig AllowedIPs = 10.8.0.2/32 bzw. 10.8.0.3/32 (wg-easy-Hardcode, siehe Hinweis in Sektion 2).

Verifikation ausstehend: ⏳ heruntergeladene Client-Profile auf clientseitige AllowedIPs (aus WG_ALLOWED_IPS) und Endpoint vpn.ghostnet.selfhost.eu:51820 kontrollieren (abhängig von Step 5/6). Hinweis: WG_PERSISTENT_KEEPALIVE ist nicht gesetzt (Default 0) – bei NAT/Router-Wechsel-Problemen auf 25 setzen.

8. Split-Tunneling-Routing verifizieren ⏳ (Konfig gesetzt, End-to-End offen)

✅ Erledigt (2026-09-09): WG_ALLOWED_IPS in /opt/wg-easy/environment gesetzt (Split-Tunnel statt wg-easy-Default Full-Tunnel), wg-easy.service neu gestartet, Env im Container verifiziert.

⏳ Offen (abhängig von Step 5/6 + Endgerät):

  • Heruntergeladenes Profil Laptop-FullAdmin: AllowedIPs = 10.0.0.0/24,10.8.0.0/24,… + Endpoint vpn.ghostnet.selfhost.eu:51820 prüfen, LAN-Hosts durch VPN erreichbar, Internet-Traffic bleibt außerhalb des Tunnels
  • Android-LLMAdmin: Routing nur auf 10.0.0.90 (LLM-Server) + VPN-Subnetz; andere LAN-Hosts nicht über VPN erreichbar
  • DNS 10.0.0.1 via WG_DEFAULT_DNS (nur innerhalb Tunnelsolutions relevant – prüfen, ob Android DNS-Einstellung akzeptiert)

9. Penetrationstest & Ingress-Verifikation über Mobilfunk ⏳

Deaktiviere WLAN auf beiden Testgeräten (reiner 4G/5G-Pfad), VPN aktivieren:

  • Android (LLM Admin): ping 10.0.0.90 muss antworten; ping 10.0.0.2 (Proxmox) muss durch Firewall/DROP-Regeln fehlschlagen
  • Laptop (Full Admin): ssh auf 10.0.0.90 und Proxmox GUI https://10.0.0.2:8006 müssen funktionieren
  • Zusätzlich: WAN-Ingress ohne VPN – 51821 (wg-easy UI) und 22 (SSH) aus dem Internet nicht erreichbar

Status: offen, gemeinsam mit User durchführen


4. Penetrationsprüfungs- & Testmatrix

Test-Szenario Quellnetz Ziel-IP / Dienst Erwartetes Ergebnis Status
WAN Ingress UDP Scan Internet (Mobilfunk) WAN-IP:51820 UDP WireGuard Handshake kommt zustande Ausstehend
LLM Admin Isolation Mobilfunk (Android) 10.0.0.2:8006 (Proxmox) DROP / TIMEOUT Ausstehend
LLM Admin SSH Access Mobilfunk (Android) 10.0.0.90:22 (LLM Server) SUCCESS Ausstehend
Full Admin Access Mobilfunk (Laptop) 10.0.0.2:8006 (Proxmox) SUCCESS Ausstehend
Full Admin SSH Access Mobilfunk (Laptop) 10.0.0.90:22 (ghostnet.ki) SUCCESS Ausstehend
UI Ingress-Isolation Internet (Mobilfunk) WAN-IP:51821 (wg-easy UI) DROP / TIMEOUT Ausstehend
SSH Ingress-Isolation Internet (Mobilfunk) WAN-IP:22 (LXC) DROP / TIMEOUT Ausstehend

5. Umsetzungszustand (Stand: 2026-09-09)

# Schritt Status
1 LXC + TUN-Device ✅
2 nftables + ip_forward ✅
3 SSH-Härtung / admin-User ✅
4 wg-easy Quadlet + wg0 ✅
5 Fritzbox Portforward UDP 51820 ✅ User (2026-09-09; E2E-Check offen)
6 DNS vpn.ghostnet.selfhost.eu (via Wildcard-DynDNS + Redundanz-A-Record) ✅ User (2026-09-09, Auflösung verifiziert)
7 Peers Laptop-FullAdmin / Android-LLMAdmin ✅
8 Split-Tunnel-Konfig (WG_ALLOWED_IPS) ✅ (End-to-End-Verifikation ⏳ mit Step 9)
9 Penetrationstest Mobilfunk ⏳

Details & Entscheidungen: siehe GNAI-006-SEC_VPN_Implementation-Log.md.


Changelog

Version Datum Änderung
1.0 – Erster Draft
1.1 2026-09-09 Umsetzung dokumentiert: Hostname wireguard (nicht wireguard-gateway); Testmatrix auf „Ausstehend“ (keine Tests über Mobilfunk gelaufen); PASSWORD_HASH = bcrypt (wg-easy/bcryptjs), nicht Argon2; Quadlet mit EnvironmentFile=; nftables-Konfig & ip_forward dokumentiert; wg-easy-allowedIPs-Limitation vermerkt; Umsetzungszustand-Tabellen & Implementation-Log ergänzt
1.2 2026-09-09 Split-Tunneling konkretisiert: clientseitige AllowedIPs kommen aus Env-Variable WG_ALLOWED_IPS (Default 0.0.0.0/0 = Full-Tunnel!); WG_ALLOWED_IPS in /opt/wg-easy/environment gesetzt, Container neu gestartet, Env im Container verifiziert; Android-/24-Limitierung dokumentiert (nur /32-Einträge); Schritt 8 in „Konfig gesetzt / End-to-End offen“ zerlegt; WG_PERSISTENT_KEEPALIVE-Hinweis ergänzt
1.3 2026-09-09 DNS-Korrektur: A-Record gehört beim Domain-DNS-Anbieter (nicht NPM/selfhost.eu-Portal – NPM ist nur HTTP-Proxy); Fritzbox-Portforward ✅ erledigt; WAN-IP 93.208.161.126 per getent verifiziert; Statisch/DynDNS-Check als offen vermerkt