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

147 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.