147 lines
12 KiB
Markdown
147 lines
12 KiB
Markdown
# 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.
|