# 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:=))` 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 5–10 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 `-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--.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: ""` + 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