15 KiB
Standard Operating Procedure & Technical Architecture Specification
Dokumenten-ID: GNAI-006-SEC
Titel: Homelab / Infrastructure – WireGuard VPN Gateway (LXC) Setup & Access Control
Version: 1.4
Status: In Umsetzung (Schritt 1–7 fertig, Step 5/6 ✅ User, Step 8/9 offen)
Vorgänger / Referenzen: GNAI-003-INFRA, GNAI-004-SEC
Client-Dokus (Benutzeranleitung):
- GNAI-006-SEC_VPN_Clients_Linux.md – Linux (KDE Plasma, z. B. CachyOS)
- GNAI-006-SEC_VPN_Clients_Windows11.md – Windows 11
Ä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
- VPN-IP:
-
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)
- VPN-IP:
Hinweis wg-easy / AllowedIPs (Umsetzung 2026-09-09): Die serverseitige
AllowedIPs-Eintragung wird von wg-easy hartkodiert alsclient.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-VariableWG_ALLOWED_IPSgeneriert (/app/lib/WireGuard.js,getClientConfiguration()). Default von wg-easy wäre0.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
AllowedIPsnur/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 inwg0.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 überWG_POST_UP/WG_PRE_DOWNleerbaren lassen – Entscheidung offen.
3. SSH-Härtung & Admin-User (gemäß GNAI-004-SEC) ✅
- User
admin(uid 1000), sudoersadmin ALL=(ALL) NOPASSWD: ALL PermitRootLogin no– SSH-Zugriff ausschließlich überadminmit 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):
- bcrypt statt Argon2: wg-easy verifiziert das UI-Passwort mit bcryptjs (Salt-Rounds 12) über
PASSWORD_HASH. Argon2-Hashes werden nicht akzeptiert. - Passwort-Rotation 2026-09-09: Das initial generierte UI-Passwort wurde nach der Session nicht übergeben → rotiert (neuer bcrypt-Hash im Environment-File, Login-API HTTP 200 verifiziert). Klartext liegt ausschließlich in 1Password (Eintrag
ghostnet/wg-easy-ui); auf dem LXC und im Repo existiert nur der Einweg-Hash. - 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=. - Unit-Name: Quadlet generiert
wg-easy.service(nichtwg-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/environmentauf 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:51820prü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.1viaWG_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.90muss antworten;ping 10.0.0.2(Proxmox) muss durch Firewall/DROP-Regeln fehlschlagen - Laptop (Full Admin):
sshauf10.0.0.90und Proxmox GUIhttps://10.0.0.2:8006müssen funktionieren - Zusätzlich: WAN-Ingress ohne VPN –
51821(wg-easy UI) und22(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 |
| 1.4 | 2026-09-09 | DNS ✅ (Wildcard-DynDNS war bereits ausreichend; Redundanz-A-Record harmlos, Auflösung verifiziert); zwei Client-Einrichtungsdocs ergänzt (Linux/KDE, Windows 11) und im Header referenziert |