Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
18 KiB
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,
/healthzfü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-amd64undlinux-arm64(Raspberry Pi), beide aus derselben CI-Pipeline. - Deployment: Ein Binary + eine YAML-Config. Lieferumfang des Release-Archivs:
- Binary
- Beispiel-
config.yamlmit 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)).
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 systemdLoadCredential, Ansible Vault, Docker Secrets). - Env-Var-Overrides:
VPNPORTAL_AD_BIND_PASSWORD,VPNPORTAL_OPNSENSE_API_SECRETetc. - 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)
- Service-Account bindet, sucht den User:
(&(objectClass=user)(|(sAMAccountName=%s)(userPrincipalName=%s)))— User-Eingabe zwingend mitldap.EscapeFilterescapen (LDAP-Injection). - Bind mit gefundenem DN und eingegebenem Passwort → Erfolg = authentifiziert.
- 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>)) - Kanonische Identität:
sAMAccountNameaus 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ätereVPN-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_limitedins 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;
checkprüft die erwarteten Endpunkte.
Flow pro Anfrage (nach Login)
GET /api/openvpn/export/providers— Liste der exportierbaren VPN-Instanzen. Einzige erlaubte Cache-Ausnahme: wenige Minuten TTL.- Pro Instanz
GET /api/openvpn/export/accounts/{vpnid}— Zertifikate mit CN. Immer live, kein Cache (Revocation muss sofort greifen). - 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).
- CN matcht
- Treffer anzeigen; bei mehreren Zertifikaten Auswahl mit VPN-Instanzname + Ablaufdatum.
- 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-Eventno_cert_foundmit 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:
- Config parsen, unbekannte Felder ablehnen.
- Dateirechte von Config, Secret-Dateien, Log-Verzeichnis prüfen.
- TLS-Handshake zur OPNsense; Test-Call auf Export-Endpoint (prüft Key, Secret und Berechtigung).
- LDAPS zu jedem DC, Bind mit Service-Account, Gruppen-DN auflösen.
- Plausibilitätsprüfung Systemzeit (z. B.
Date-Header der OPNsense-Antwort; >2 min Abweichung → NTP-Warnung). - 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:embedim 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
- Login: Username (Hint: „mmueller oder m.mueller@firma.de"), Passwort. Konfigurierbarer Titel + Logo. Generische Fehlermeldung (Ausnahme: Passwort abgelaufen). Versionsnummer dezent im Footer.
- Ü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.
- 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.
- 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-storeauf 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.
{"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(mitreason, 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_failedmit 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, restriktiveContent-Security-Policy(SSR ohne externe Ressourcen macht das trivial),Referrer-Policy: no-referrer.
12. Akzeptanzkriterien (Auswahl)
- User in
VPN-Usersmit gültigem Zertifikat (CN = sAMAccountName) kann sich anmelden und erhält eine funktionierende.ovpn. - User mit korrektem AD-Passwort, aber ohne Gruppenmitgliedschaft, wird mit generischer Meldung abgewiesen; Log enthält
not_in_vpn_group. - Ein auf der OPNsense revoziertes Zertifikat erscheint sofort (ohne Neustart, ohne Cache-Verzögerung) nicht mehr im Portal.
- Deaktivierter AD-Account kann sich nicht anmelden.
- Manipulierte Download-Anfrage auf ein fremdes Zertifikat → 403 +
download_deniedim Log. vpnportal checkerkennt und benennt: falsches API-Secret, fehlende API-Berechtigung, nicht erreichbaren DC, falsche CA, zu offene Config-Dateirechte, unbekannte Config-Felder (Tippfehler).- Ausfall von dc01 → Login funktioniert via dc02; Log enthält
ldap_failover. - Passwort versehentlich ins Username-Feld eingegeben → erscheint nirgends im Log.
- Download-Antworten tragen
Cache-Control: no-store; Download nur per POST mit gültigem CSRF-Token. - Binary läuft identisch konfiguriert auf amd64-VM und Raspberry Pi (arm64).
- Portal-Neustart: alle Sessions beendet, keine weiteren Datenverluste.
- 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-Checkinternal/auth/—Authenticator-Interface + AD-Implementierunginternal/opnsense/— API-Client (providers, accounts, export)internal/web/— Handler, Templates (embed), Sessions, CSRF, Rate-Limitinginternal/audit/— JSONL-Logger, Rotation
Authenticator-Interface schmal halten (Vorbereitung OIDC/Entra als mögliches v1.x):
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-Adminsist im Datenmodell vorbereitet) - v2: WireGuard-Support (clientseitige Key-Erzeugung, eigenes Konzept nötig); ggf. mehrere Export-Quellen pro Portal (Zweigstellen-Szenario), explizit kein Mandantenmodell