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

18 KiB
Raw Permalink Blame History

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)).

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.
{"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):
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