12 KiB
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,
sudopasswortlos /root/.ssh/authorized_keysexistierte nie → kein root-Key-Rückbau nötig- Lokal:
~/.ssh/config-Aliaslxc→10.0.0.15, Useradmin, dedizierter Key~/.ssh/id_ed25519_lxc,StrictHostKeyChecking accept-new
1.2 nftables & IP-Forwarding
/etc/nftables.confgeschrieben (viasudo 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/51821nur aus10.0.0.0/24, Rate-limited Drop-Lognft-input-dropped: - FORWARD: policy drop,
iif wg0 → 10.0.0.0/24new accept (Split-Tunnel), Reverse-Route-Regel, drop - OUTPUT: policy accept
- INPUT: policy drop, lo accept, invalid drop, established/related accept, ICMP-Erreichbarkeit, UDP 51820 öffentlich,
net.ipv4.ip_forward=1aktiv, persistent via/etc/sysctl.d/99-ipforward.conf- Verifiziert: SSH nach nftables-Load weiterhin erreichbar; Regeln per
nft list rulesetkontrolliert
1.3 wg-easy Quadlet
podman5.4.2 installiert; TUN-Devicec 10:200vorhanden (vorhanden seit Podman-Setup,lxc.mount.entryin 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:
wg0up,10.8.0.1/24,ListenPort 51820; UIhttp://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_HASHvorhanden
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_HASHwird mit bcryptjs (Salt-Rounds 12, bcrypt-Format$2a$12$…) verifiziert.- UI-Login:
POST /api/sessionmit Klartext-Passwort (im internen Netz, LAN-only freigeschaltet).
3.3 allowedIPs-Limitation (wichtigster Befund)
- Serverseitig:
__saveConfig()in/app/lib/WireGuard.js(Container) generiert pro PeerAllowedIPs = ${client.address}/32– hartkodiert. Das FeldallowedIPsim internenwg0.jsonexistiert, wird vom Create-Endpoint aber nie befüllt (stetsNone). - 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.jsgeneriert in den herunterladbaren ProfilenAllowedIPs = ${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_IPSin/opt/wg-easy/environmentergänzt (Backupenvironment.bak.<ts>),wg-easy.serviceneu 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
AllowedIPsnur/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), danachwg-easy.serviceneu 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. | PW | wg-easy-UI-Passwort initial generiert, Klartext nach Session nicht verfügbar (bcrypt=Einweg-Hash) | ✅ rotiert 2026-09-09 18:40: neuer bcrypt-Hash im Env-File, API-Login 200; Klartext nur in 1Password (ghostnet/wg-easy-ui) |