opnsense-portal/PRD.md
Carsten Abele bb84607bf8 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
2026-08-14 09:00:51 +02:00

293 lines
18 KiB
Markdown
Raw Permalink 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.

# 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