docs: PRD und Implementierungsplan für das VPN-Konfig-Portal

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
Carsten Abele 2026-08-14 09:00:51 +02:00
commit bb84607bf8
2 changed files with 9294 additions and 0 deletions

293
PRD.md Normal file
View file

@ -0,0 +1,293 @@
# PRD: VPN-Konfig-Portal für OPNsense
## 1. Überblick
Self-Service-Webportal, über das Endnutzer ihre persönliche OpenVPN-Client-Konfiguration von einer OPNsense-Firewall beziehen. Das Portal läuft als statisches Single-Binary (Go) auf einer Linux-VM oder einem Raspberry Pi im Kundennetz (on-premise, eine Installation pro Kunde). Authentifizierung erfolgt ausschließlich gegen das Active Directory des Kunden. Das Portal ist ein reiner **Read-only-Verteiler**: Es erstellt, ändert oder widerruft keine Zertifikate und schreibt nichts auf die Firewall.
**Zielgruppe Endnutzer:** Mitarbeitende des Kunden (deutschsprachig).
**Zielgruppe Betreiber:** IT-Admins des Kunden bzw. der Dienstleister, der das Portal installiert.
## 2. Ziele / Nicht-Ziele
### Ziele (v1)
- Endnutzer melden sich mit ihren AD-Zugangsdaten an und laden ihre OpenVPN-Konfiguration(en) herunter.
- Nur Mitglieder einer konfigurierbaren AD-Gruppe erhalten Zugang.
- Revozierte und abgelaufene Zertifikate werden niemals ausgeliefert.
- Vollständiges, strukturiertes Audit-Log (wer hat wann welche Config bezogen).
- Inbetriebnahme in unter 30 Minuten durch `check`-Kommando und mitgelieferte systemd-Unit.
### Nicht-Ziele (bewusst out of scope für v1)
- **WireGuard** (geplant für v2, anderes Key-Modell)
- Zertifikats-Self-Service (Erstellen, Erneuern, Revozieren)
- Admin-Web-UI (Logs per SSH/Monitoring, `/healthz` für externes Monitoring)
- RADIUS- oder lokale Portal-User (Kundschaft ist ausschließlich AD)
- Multi-Tenancy / mehrere OPNsense-Instanzen pro Portal
- Offizieller Windows-Support (Linux-only; Windows-Cross-Build allenfalls experimentell)
- Passwortänderung im Portal
- Export-Overrides per API — es gelten immer die auf der OPNsense hinterlegten Export-Einstellungen
- Mehrsprachigkeit (UI rein Deutsch; `strings`-Map-Struktur trotzdem anlegen, damit i18n später nachrüstbar ist)
- Auto-Update (nur optionaler, abschaltbarer Read-only-Versionscheck)
## 3. Architektur & Plattform
- **Sprache:** Go, `CGO_ENABLED=0`, statisch gelinkt. Keine Libraries mit CGO-Zwang.
- **Targets:** `linux-amd64` und `linux-arm64` (Raspberry Pi), beide aus derselben CI-Pipeline.
- **Deployment:** Ein Binary + eine YAML-Config. Lieferumfang des Release-Archivs:
- Binary
- Beispiel-`config.yaml` mit auskommentierten Optionen
- systemd-Unit (gehärtet: dedizierter User oder `DynamicUser=`, `ProtectSystem=strict`, `LoadCredential=` für Secrets)
- README/Installationsanleitung
- **State:** Sessions ausschließlich im RAM (Neustart = alle abgemeldet, akzeptiert). Keine Datenbank. Disaster Recovery = Config-Datei + Binary.
- **Schreiblast minimal halten** (SD-Karten auf dem Pi): nur Audit-Log wird geschrieben, Rotation mit Größenlimit, kein Debug-Level im Normalbetrieb.
- **Releases:** signierte Binaries, Versionsnummer sichtbar via `vpnportal version`, im Startup-Log und im Footer der Login-Seite.
### CLI-Kommandos
- `vpnportal serve` — Portal starten (Default)
- `vpnportal check` — komplette Kette testen (siehe 7. Startup-Validierung)
- `vpnportal version` — Version, Commit, Build-Datum
## 4. Konfiguration
Format: **YAML**, Strict-Parsing (unbekannte Felder = Startabbruch mit klarer Fehlermeldung, `yaml.KnownFields(true)`).
```yaml
portal:
listen: "0.0.0.0:8443"
tls_cert: "/etc/vpnportal/portal.crt" # HTTPS ist Pflicht
tls_key: "/etc/vpnportal/portal.key"
session_ttl: "10m" # Default: 10m
title: "VPN-Portal Firma GmbH" # White-Label
logo_file: "/etc/vpnportal/logo.png" # optional
support_contact: "it-helpdesk@firma.de" # wird bei "kein Zertifikat" angezeigt
update_check: false # optionaler Read-only-Versionscheck, Default aus
opnsense:
url: "https://fw01.firma.local"
api_key: "..." # alternativ: api_key_file
api_secret: "..." # alternativ: api_secret_file
ca_file: "/etc/vpnportal/firma-ca.pem" # optional
# insecure_skip_verify: true # nur für Tests; loggt bei jedem Start eine Warnung
ad:
domain: "firma.local" # Base-DN wird abgeleitet (DC=firma,DC=local)
# base_dn: "..." # optionaler Override
servers: # Hostnamen, keine IPs (LDAPS-Zertifikatsprüfung!)
- "dc01.firma.local"
- "dc02.firma.local"
# port: 636 # LDAPS ist Default; StartTLS auf 389 als Option
bind_user: "svc-vpnportal@firma.local" # unprivilegierter Domain-User genügt
bind_password: "..." # alternativ: bind_password_file
vpn_group: "VPN-Users" # Gruppen-DN wird beim Start aufgelöst und gecacht
ca_file: "/etc/vpnportal/firma-ca.pem"
matching:
cn_pattern: "{username}" # Default: CN == sAMAccountName (lowercase)
# Templates wie "{username}@firma.de" möglich
# cn_regex: "..." # Notnagel für Bestandsumgebungen
logging:
level: "info"
audit_log: "/var/log/vpnportal/audit.log"
max_size_mb: 50 # eingebaute Rotation
max_backups: 5
compress: true
```
### Secrets-Handling
- Jede Secret-Option zusätzlich als `*_file`-Variante (kompatibel mit systemd `LoadCredential`, Ansible Vault, Docker Secrets).
- Env-Var-Overrides: `VPNPORTAL_AD_BIND_PASSWORD`, `VPNPORTAL_OPNSENSE_API_SECRET` etc.
- **Startabbruch**, wenn die Config-Datei group-/world-readable ist (mehr als 0640). Gleiches gilt für Secret-Dateien und das Audit-Log-Verzeichnis.
- Keine verschlüsselte Config (Key läge daneben = Sicherheitstheater). Schutz = Dateirechte + Doku.
## 5. Authentifizierung (Active Directory)
### Login-Flow (Zwei-Schritt-Bind)
1. Service-Account bindet, sucht den User:
`(&(objectClass=user)(|(sAMAccountName=%s)(userPrincipalName=%s)))`
— User-Eingabe zwingend mit `ldap.EscapeFilter` escapen (LDAP-Injection).
2. Bind mit gefundenem DN und eingegebenem Passwort → Erfolg = authentifiziert.
3. Auf der Service-Verbindung: Gruppenprüfung inkl. verschachtelter Gruppen per Matching-Rule-Filter:
`(&(sAMAccountName=%s)(memberOf:1.2.840.113556.1.4.1941:=<Gruppen-DN>))`
4. Kanonische Identität: **`sAMAccountName` aus dem Verzeichnis gelesen** (nie die User-Eingabe), **lowercase**.
### Anforderungen
- Library: `github.com/go-ldap/ldap/v3`.
- LDAPS (636) Default, StartTLS (389) als Option. TLS-Verify immer an; interne CA per `ca_file`. Keine Insecure-Option für LDAP.
- DC-Liste mit Failover; Dial-/Request-Timeout 510 s, dann nächster DC.
- Referrals ignorieren (Single-Domain; Multi-Domain-Forest dokumentiert nicht unterstützt).
- Gruppen-DN beim Start aus dem konfigurierten Namen auflösen und cachen.
- Rollen-Vorbereitung im Datenmodell: `Identity{Username string; Groups []string}` — eine spätere `VPN-Portal-Admins`-Gruppe muss ohne Umbau möglich sein.
### Fehlerbehandlung (AD Error 49, `data`-Codes)
| data | Bedeutung | Anzeige an User | Audit-Log `reason` |
|---|---|---|---|
| 52e | Falsche Credentials | generisch: „Anmeldung fehlgeschlagen" | `invalid_credentials` |
| 533 | Account deaktiviert | generisch | `account_disabled` |
| 775 | Account gesperrt (Lockout) | generisch | `account_locked` |
| 532 | Passwort abgelaufen | **Ausnahme:** „Ihr Passwort ist abgelaufen" | `password_expired` |
| 773 | Passwortänderung erforderlich | generisch; Fall in Doku beschreiben | `password_change_required` |
| — | Nicht in VPN-Gruppe | generisch | `not_in_vpn_group` |
- Identische Fehlermeldung und möglichst gleiche Antwortzeit für „User existiert nicht" vs. „Passwort falsch" (kein User-Enumeration-Orakel).
### Brute-Force-Schutz
- Rate-Limiting pro Username **und** pro Quell-IP (NAT-Fall).
- Exponentieller Backoff statt hartem Lockout (kein Aussperren von Kollegen durch Dritte).
- Ereignis `rate_limited` ins Audit-Log.
## 6. OPNsense-Anbindung
### Grundsätze
- Dedizierter API-User auf der OPNsense mit **ausschließlich** dem Export-Privileg („VPN: OpenVPN Client Export"). Niemals Admin-Key.
- Read-only: Portal nutzt nur die Export-API, keine schreibenden Endpunkte.
- **Keine Export-Overrides:** Format-/Optionseinstellungen der Firewall sind die Quelle der Wahrheit. Das Portal bietet dem User nur die Formatwahl an (inline `.ovpn`, Viscosity), alles Weitere konfiguriert der Admin auf der OPNsense.
- Minimal unterstützte OPNsense-Version pinnen und dokumentieren; `check` prüft die erwarteten Endpunkte.
### Flow pro Anfrage (nach Login)
1. `GET /api/openvpn/export/providers` — Liste der exportierbaren VPN-Instanzen. Einzige erlaubte Cache-Ausnahme: wenige Minuten TTL.
2. Pro Instanz `GET /api/openvpn/export/accounts/{vpnid}` — Zertifikate mit CN. **Immer live, kein Cache** (Revocation muss sofort greifen).
3. Filter:
- CN matcht `cn_pattern` (bzw. `cn_regex`) gegen den kanonischen Username.
- Zertifikat ist **nicht revoked** und **nicht abgelaufen** (Ablauf zusätzlich selbst gegen Systemzeit prüfen — Feldverhalten der API pro Version in Integrationstests verifizieren).
4. Treffer anzeigen; bei mehreren Zertifikaten Auswahl mit VPN-Instanzname + Ablaufdatum.
5. Download: Export-Endpoint aufrufen, Datei **durchstreamen** — niemals auf Platte zwischenspeichern, kein Caching.
### Autorisierung des Downloads
- **Serverseitige Prüfung zwingend:** Vor jedem Download erneut verifizieren, dass das angeforderte Zertifikat zum eingeloggten User gehört (UI-Auswahl ist keine Autorisierung). Verstoß → HTTP 403 + Audit-Event `download_denied`.
### Fehlerfälle
- OPNsense nicht erreichbar → eigene Fehlerseite („Dienst vorübergehend nicht verfügbar"), klar getrennt vom Fall „kein Zertifikat".
- Kein passendes Zertifikat → handlungsleitende Meldung mit `support_contact`; Audit-Event `no_cert_found` mit Username und angewendetem Pattern. Die verglichenen CNs nur auf Debug-Level loggen.
## 7. Startup-Validierung (`vpnportal check`)
Läuft beim Start und als eigenständiges Kommando; gibt eine Checkliste aus:
1. Config parsen, unbekannte Felder ablehnen.
2. Dateirechte von Config, Secret-Dateien, Log-Verzeichnis prüfen.
3. TLS-Handshake zur OPNsense; Test-Call auf Export-Endpoint (prüft Key, Secret **und** Berechtigung).
4. LDAPS zu jedem DC, Bind mit Service-Account, Gruppen-DN auflösen.
5. Plausibilitätsprüfung Systemzeit (z. B. `Date`-Header der OPNsense-Antwort; >2 min Abweichung → NTP-Warnung).
6. Ausgabe: `✓ / ✗` pro Punkt mit konkreter Fehlermeldung (bei TLS-Fehlern Zertifikatskette ausgeben).
`--test-auth <user>`-Option: spielt Suche + Gruppenprüfung für einen konkreten User durch (ohne Passwort-Bind), zeigt gefundenen DN, sAMAccountName und Gruppenergebnis.
## 8. Web-UI
### Technologie
- Server-Side Rendering mit Go `html/template`. **Kein SPA-Framework, kein npm, kein Build-Step.**
- Alle Assets (CSS, Logo, Font) per `go:embed` im Binary.
- JavaScript optional und minimal (z. B. Passwort-anzeigen-Toggle); UI muss ohne JS voll funktionieren.
- Sprache: Deutsch. Strings über zentrale `strings`-Map (i18n-vorbereitet, nur DE befüllt).
### Seiten
1. **Login:** Username (Hint: „mmueller oder m.mueller@firma.de"), Passwort. Konfigurierbarer Titel + Logo. Generische Fehlermeldung (Ausnahme: Passwort abgelaufen). Versionsnummer dezent im Footer.
2. **Übersicht (nach Login):** Pro Zertifikat eine Karte: VPN-Instanzname (von der Firewall), CN, Gültig-bis, Download-Buttons pro Format. Drei Zustände:
- genau ein Zertifikat → direkt Download-Buttons
- mehrere → Liste, Unterscheidung über Instanzname + Ablaufdatum
- keines → Meldung mit `support_contact`
- Hinweis „Zertifikat läuft in X Tagen ab" bei < 30 Tagen Restlaufzeit.
3. **Anleitungen:** Statische Kurzanleitungen pro Plattform (Windows: OpenVPN Community Client; macOS: Tunnelblick/Viscosity; iOS/Android: OpenVPN Connect). OS-Erkennung per User-Agent nur als Vorschlag, nie als Filter.
4. **Fehlerseite:** „Dienst vorübergehend nicht verfügbar" (OPNsense down) — strikt getrennt von „kein Zertifikat".
### Downloads
- **POST mit CSRF-Token** (Token in Session, Hidden-Field, Vergleich) — keine GET-Download-Links (History-/Proxy-/Prefetch-Leak).
- Header: `Content-Disposition: attachment; filename="vpn-<instanz>-<user>.ovpn"`, `Cache-Control: no-store`.
- `Cache-Control: no-store` auf **allen** Seiten nach Login.
### Sessions
- TTL **10 Minuten**, serverseitig, im RAM. Cookie: `HttpOnly`, `Secure`, `SameSite=Strict`.
- Sichtbarer Logout-Button; nach Download aktive Abmelde-Aufforderung.
- Session-Ablauf → Redirect auf Login mit Hinweis „Sitzung abgelaufen".
## 9. Audit-Logging
### Format
- **JSON Lines**, eine Zeile pro Ereignis. Zeitstempel RFC 3339 **mit Zeitzone**.
- Gekürzte Session-ID zur Korrelation von Login und Downloads.
```json
{"ts":"2026-08-14T09:32:11+02:00","event":"login_success","user":"mmueller","src_ip":"10.1.20.34","session":"a3f9"}
{"ts":"2026-08-14T09:32:40+02:00","event":"config_download","user":"mmueller","src_ip":"10.1.20.34","session":"a3f9","vpn_instance":"VPN Homeoffice","cert_cn":"mmueller","cert_expiry":"2027-03-01","format":"ovpn"}
```
### Ereignisse
- Audit: `login_success`, `login_failed` (mit `reason`, siehe Tabelle oben), `logout`, `session_expired`, `config_download`, `download_denied`
- Betrieb: `no_cert_found`, `opnsense_unreachable`, `ldap_failover`, `rate_limited`, `startup` (mit Version + Config-Checksumme)
### Verbotene Log-Inhalte
- **Passwörter — niemals.** Schutz gegen „Passwort im Username-Feld": Bei `login_failed` mit **unbekanntem** User den eingegebenen Namen nicht im Klartext loggen (`user: "<unknown>"` + IP genügt für Brute-Force-Erkennung). Klartext-Username nur bei im AD gefundenen Usern.
- Config-Inhalte, Zertifikate, private Keys.
- Session-Tokens in voller Länge.
- Vollständige CN-Listen der Firewall auf Info-Level (nur Debug; Debug im Normalbetrieb aus).
### Rotation & Weiterleitung
- Eingebaute größenbasierte Rotation mit Kompression (Default; SD-Karten-schonend).
- SIGHUP-Handling für logrotate-Umgebungen.
- Parallel alles nach stdout (systemd-Journal) → Kunde kann per Journal/Syslog zentral weiterleiten.
- Doku-Satz: „Für revisionssichere Aufbewahrung Log an zentrales Logsystem weiterleiten." Empfehlung Aufbewahrung: 90 Tage (kundenseitig anpassbar).
- Log-Dateien mit restriktiven Rechten (enthalten personenbezogene Daten).
## 10. Betrieb & Monitoring
- `GET /healthz` (ohne Auth, ohne sensible Details): Status Portal, OPNsense-Erreichbarkeit, DC-Erreichbarkeit — für Zabbix/CheckMK etc.
- Kein Auto-Update. Optionaler, per Config abschaltbarer Read-only-Versionscheck gegen die Release-URL (Default: aus; in Doku transparent machen, dass er nach Hause telefoniert).
- Doku-Hinweise: Pi in abschließbaren Netzwerkschrank (SD-Karte trägt Secrets), SSD statt SD-Karte empfehlen, NTP-Pflicht.
## 11. Sicherheitsanforderungen (Zusammenfassung)
- HTTPS-Pflicht am Portal; TLS-Verify gegen OPNsense und AD immer an (interne CA konfigurierbar; OPNsense-Insecure-Option nur mit permanenter Start-Warnung, für LDAP gar nicht).
- LDAP-Injection-Schutz (`ldap.EscapeFilter`).
- CSRF-Schutz auf allen zustandsändernden Requests inkl. Downloads.
- Serverseitige Autorisierung jedes Downloads (Cert gehört zum User).
- Kein Persistieren von Configs/Zertifikaten auf dem Portal (Streaming only, `no-store`).
- Rate-Limiting + Backoff; keine User-Enumeration über Fehlermeldungen oder Timing.
- Config-/Secret-/Log-Dateirechte werden erzwungen (Startabbruch).
- Security-Header: `Strict-Transport-Security`, `X-Content-Type-Options: nosniff`, restriktive `Content-Security-Policy` (SSR ohne externe Ressourcen macht das trivial), `Referrer-Policy: no-referrer`.
## 12. Akzeptanzkriterien (Auswahl)
1. User in `VPN-Users` mit gültigem Zertifikat (CN = sAMAccountName) kann sich anmelden und erhält eine funktionierende `.ovpn`.
2. User mit korrektem AD-Passwort, aber ohne Gruppenmitgliedschaft, wird mit generischer Meldung abgewiesen; Log enthält `not_in_vpn_group`.
3. Ein auf der OPNsense revoziertes Zertifikat erscheint **sofort** (ohne Neustart, ohne Cache-Verzögerung) nicht mehr im Portal.
4. Deaktivierter AD-Account kann sich nicht anmelden.
5. Manipulierte Download-Anfrage auf ein fremdes Zertifikat → 403 + `download_denied` im Log.
6. `vpnportal check` erkennt und benennt: falsches API-Secret, fehlende API-Berechtigung, nicht erreichbaren DC, falsche CA, zu offene Config-Dateirechte, unbekannte Config-Felder (Tippfehler).
7. Ausfall von dc01 → Login funktioniert via dc02; Log enthält `ldap_failover`.
8. Passwort versehentlich ins Username-Feld eingegeben → erscheint nirgends im Log.
9. Download-Antworten tragen `Cache-Control: no-store`; Download nur per POST mit gültigem CSRF-Token.
10. Binary läuft identisch konfiguriert auf amd64-VM und Raspberry Pi (arm64).
11. Portal-Neustart: alle Sessions beendet, keine weiteren Datenverluste.
12. UI ist ohne JavaScript vollständig bedienbar.
## 13. Implementierungshinweise
- LDAP: `github.com/go-ldap/ldap/v3`
- YAML: Strict-Parsing (`KnownFields(true)` / `DisallowUnknownFields`)
- Keine CGO-Dependencies (Cross-Build arm64)
- Vorgeschlagene Package-Struktur:
- `cmd/vpnportal/` — main, CLI (serve/check/version)
- `internal/config/` — Laden, Validierung, Dateirechte-Check
- `internal/auth/``Authenticator`-Interface + AD-Implementierung
- `internal/opnsense/` — API-Client (providers, accounts, export)
- `internal/web/` — Handler, Templates (embed), Sessions, CSRF, Rate-Limiting
- `internal/audit/` — JSONL-Logger, Rotation
- `Authenticator`-Interface schmal halten (Vorbereitung OIDC/Entra als mögliches v1.x):
```go
type Authenticator interface {
Authenticate(ctx context.Context, username, password string) (*Identity, error)
}
type Identity struct {
Username string // kanonisiert: sAMAccountName, lowercase
Groups []string
}
```
- Integrationstests gegen eine echte OPNsense-Testinstanz für: Feldverhalten revozierter/abgelaufener Zertifikate, Export-Formate, API-Versionsdrift.
## 14. Roadmap-Notizen (nicht v1)
- **v1.x:** OIDC/Entra ID als zweites Auth-Backend; ggf. Admin-Ansicht (Gruppe `VPN-Portal-Admins` ist im Datenmodell vorbereitet)
- **v2:** WireGuard-Support (clientseitige Key-Erzeugung, eigenes Konzept nötig); ggf. mehrere Export-Quellen pro Portal (Zweigstellen-Szenario), explizit **kein** Mandantenmodell