commit bb84607bf87a4284a6f24a1083f890bffb2ed4f5 Author: Carsten Abele Date: Fri Aug 14 09:00:51 2026 +0200 docs: PRD und Implementierungsplan für das VPN-Konfig-Portal Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68 diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..1807259 --- /dev/null +++ b/PRD.md @@ -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:=))` +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 diff --git a/docs/superpowers/plans/2026-08-14-vpn-config-portal.md b/docs/superpowers/plans/2026-08-14-vpn-config-portal.md new file mode 100644 index 0000000..ba178ef --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-vpn-config-portal.md @@ -0,0 +1,9001 @@ +# VPN-Konfig-Portal für OPNsense — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ein statisch gelinktes Go-Single-Binary, mit dem AD-authentifizierte Endnutzer ihre persönliche OpenVPN-Konfiguration read-only von einer OPNsense-Firewall herunterladen. + +**Architecture:** Server-Side-Rendering-Webportal ohne Datenbank. Sessions im RAM, alle Assets und Templates per `go:embed` im Binary. Drei austauschbare Kernbausteine hinter schmalen Interfaces: `auth.Authenticator` (AD/LDAP), `opnsense.Client` (Export-API) und `audit.Logger` (JSONL). Alle Tests laufen offline gegen `httptest`-Server und einen LDAP-Fake. + +**Tech Stack:** Go 1.26, `github.com/go-ldap/ldap/v3`, `gopkg.in/yaml.v3` (strict), `html/template`, `net/http` (stdlib-Router mit Methoden-Patterns), keine CGO-Abhängigkeiten, kein npm, kein Build-Step. + +## Global Constraints + +Diese Vorgaben gelten für **jede** Task, auch wenn sie dort nicht wiederholt werden: + +- Modulpfad: `git.ravensburg.dev/cabele/opnsense-portal`. Binary-Name: `vpnportal`. +- `CGO_ENABLED=0`, statisch gelinkt. Keine Dependency, die CGO erzwingt. Targets: `linux/amd64` und `linux/arm64`. +- Kein SPA-Framework, kein npm, kein Build-Step. JavaScript optional; jede Seite muss ohne JS voll bedienbar sein. +- UI-Sprache Deutsch. **Kein** deutschsprachiger String direkt im Handler — alle Texte über die zentrale Map `web.S` (i18n-vorbereitet, nur DE befüllt). +- Read-only gegenüber OPNsense: ausschließlich `GET`-Export-Endpunkte, niemals schreibende Aufrufe. +- Nie auf Platte persistieren: Zertifikate und Configs werden gestreamt, nicht gepuffert. +- Audit-Log ist JSON Lines, ein Event pro Zeile, Zeitstempel RFC 3339 **mit Zeitzone**. +- Verbotene Log-Inhalte: Passwörter, Config-Inhalte, Zertifikate, private Keys, vollständige Session-Tokens, CN-Listen auf Info-Level, Klartext-Usernamen unbekannter User. +- TLS-Verify ist immer an. `insecure_skip_verify` existiert **nur** für OPNsense und loggt bei jedem Start eine Warnung. Für LDAP gibt es keine Insecure-Option. +- Jede Secret-Option hat eine `*_file`-Variante und ein `VPNPORTAL_*`-Env-Override. +- Go-Fehler werden gewrappt (`fmt.Errorf("...: %w", err)`), niemals verschluckt. +- Tests: `go test ./...` muss nach jeder Task grün sein. Kein Netzwerkzugriff in Tests. +- Commits: konventionelle Prefixes (`feat:`, `test:`, `fix:`, `chore:`, `docs:`), ein Commit pro Task-Ende. + +## Assumptions to Verify (explizit offen) + +Die exakten Feldnamen und Response-Shapes der OPNsense-`os-openvpn-client-export`-API sind versionsabhängig und konnten offline nicht verifiziert werden. Der Client wird deshalb **tolerant** gebaut (mehrere akzeptierte Feldnamen, mehrere akzeptierte Datums- und Boolean-Repräsentationen) und die Annahmen werden in `docs/opnsense-api.md` dokumentiert. Task 8 legt build-getaggte Integrationstest-Skeletons (`//go:build integration`) an, die gegen eine echte Instanz laufen und diese Annahmen bestätigen müssen, bevor v1 ausgeliefert wird. + +## File Structure + +| Datei | Verantwortung | +|---|---| +| `cmd/vpnportal/main.go` | CLI-Dispatch `serve` / `check` / `version`, Exit-Codes | +| `cmd/vpnportal/buildinfo.go` | `version`, `commit`, `date` via ldflags | +| `cmd/vpnportal/serve.go` | Verdrahtung aller Komponenten, HTTPS-Server, Signal-Handling | +| `cmd/vpnportal/check.go` | `check`-Kommando inkl. `--test-auth` | +| `internal/config/config.go` | Config-Structs, Defaults | +| `internal/config/load.go` | YAML-Strict-Parsing, `*_file`-Auflösung, Env-Overrides | +| `internal/config/perms.go` | Erzwingen der Dateirechte (Config, Secrets, Log-Verzeichnis) | +| `internal/config/validate.go` | Semantische Validierung, Base-DN-Ableitung | +| `internal/audit/event.go` | `Event`-Struct, Event-Namen, Reason-Konstanten | +| `internal/audit/logger.go` | JSONL-Writer, stdout-Tee, Session-ID-Kürzung | +| `internal/audit/rotate.go` | Größenbasierte Rotation, gzip, SIGHUP-Reopen | +| `internal/auth/auth.go` | `Authenticator`-Interface, `Identity`, `Error` mit `Reason` | +| `internal/auth/addata.go` | Mapping AD-Error-49-`data`-Codes → `Reason` | +| `internal/auth/ad.go` | AD-Implementierung: Zwei-Schritt-Bind, Gruppenprüfung | +| `internal/auth/dial.go` | DC-Failover-Dialer (LDAPS/StartTLS, Timeouts) | +| `internal/opnsense/client.go` | HTTP-Client, Basic-Auth mit Key/Secret, CA/TLS | +| `internal/opnsense/export.go` | `Providers()`, `Accounts()`, `Export()` (streaming) | +| `internal/opnsense/types.go` | Tolerante DTOs, flexible Bool-/Datums-Decoder | +| `internal/certmatch/match.go` | CN-Pattern/Regex-Matching, Revoked-/Expiry-Filter | +| `internal/web/server.go` | Router, Middleware-Kette, Handler-Dependencies | +| `internal/web/security.go` | Security-Header, `no-store`, Real-IP-Ermittlung | +| `internal/web/session.go` | RAM-Session-Store mit TTL, CSRF-Token | +| `internal/web/ratelimit.go` | Exponentieller Backoff pro User und pro IP | +| `internal/web/strings.go` | Zentrale DE-String-Map `S` | +| `internal/web/render.go` | Template-Parsing (`go:embed`), Render-Helper | +| `internal/web/handlers_login.go` | `GET/POST /login`, `POST /logout` | +| `internal/web/handlers_certs.go` | `GET /`, `POST /download` | +| `internal/web/handlers_static.go` | `GET /anleitungen`, `GET /healthz`, Assets | +| `internal/web/templates/*.html` | Layout, Login, Übersicht, Anleitungen, Fehlerseite | +| `internal/web/assets/style.css`, `app.js` | Eingebettete Assets, keine externen Ressourcen | +| `deploy/vpnportal.service` | Gehärtete systemd-Unit | +| `deploy/config.example.yaml` | Beispiel-Config, alle Optionen auskommentiert | +| `Makefile` | `build`, `test`, `lint`, `release`, `vet` | +| `README.md`, `docs/opnsense-api.md` | Installations- und Betriebsdoku, API-Annahmen | + +--- + +## Task 1: Projekt-Skeleton und `version`-Kommando + +**Files:** +- Create: `go.mod`, `.gitignore`, `Makefile` +- Create: `cmd/vpnportal/main.go`, `cmd/vpnportal/buildinfo.go` +- Test: `cmd/vpnportal/main_test.go` + +**Interfaces:** +- Consumes: nichts +- Produces: `func run(args []string, stdout, stderr io.Writer) int` — testbarer CLI-Einstieg; `main()` ruft nur `os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))`. Buildinfo-Variablen `version`, `commit`, `date` (Package-Level `var`, per `-ldflags -X` gesetzt). + +- [ ] **Step 1: Modul initialisieren und `.gitignore` anlegen** + +```bash +cd /Users/cabele/claudeprojects/opnsense-portal +go mod init git.ravensburg.dev/cabele/opnsense-portal +printf '/dist/\n/vpnportal\n*.log\n.DS_Store\n' > .gitignore +``` + +- [ ] **Step 2: Failing test schreiben** + +`cmd/vpnportal/main_test.go`: + +```go +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestRunVersion(t *testing.T) { + var out, errOut bytes.Buffer + code := run([]string{"version"}, &out, &errOut) + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, errOut.String()) + } + got := out.String() + for _, want := range []string{"vpnportal", "commit", "built"} { + if !strings.Contains(got, want) { + t.Errorf("output %q missing %q", got, want) + } + } +} + +func TestRunUnknownCommand(t *testing.T) { + var out, errOut bytes.Buffer + if code := run([]string{"frobnicate"}, &out, &errOut); code != 2 { + t.Fatalf("exit code = %d, want 2", code) + } + if !strings.Contains(errOut.String(), "frobnicate") { + t.Errorf("stderr should name the unknown command, got %q", errOut.String()) + } +} + +func TestRunNoArgsDefaultsToServe(t *testing.T) { + var out, errOut bytes.Buffer + // serve ohne --config muss mit Fehler abbrechen, nicht mit "unknown command" + code := run(nil, &out, &errOut) + if code == 2 { + t.Fatalf("no args must default to serve, not unknown-command") + } + if !strings.Contains(errOut.String(), "config") { + t.Errorf("stderr should complain about missing config, got %q", errOut.String()) + } +} +``` + +- [ ] **Step 3: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./cmd/vpnportal/ -run TestRun -v` +Expected: FAIL — `undefined: run` + +- [ ] **Step 4: `buildinfo.go` schreiben** + +```go +package main + +import "fmt" + +// Per -ldflags gesetzt; siehe Makefile. +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +func versionString() string { + return fmt.Sprintf("vpnportal %s (commit %s, built %s)", version, commit, date) +} +``` + +- [ ] **Step 5: `main.go` schreiben** + +```go +package main + +import ( + "fmt" + "io" + "os" +) + +const usage = `vpnportal — VPN-Konfig-Portal für OPNsense + +Aufruf: + vpnportal serve [--config PFAD] Portal starten (Default) + vpnportal check [--config PFAD] [--test-auth BENUTZER] + vpnportal version + +Optionen: + --config PFAD Pfad zur Konfigurationsdatei (Default: /etc/vpnportal/config.yaml) +` + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, stdout, stderr io.Writer) int { + cmd := "serve" + rest := args + if len(args) > 0 && len(args[0]) > 0 && args[0][0] != '-' { + cmd, rest = args[0], args[1:] + } + + switch cmd { + case "version": + fmt.Fprintln(stdout, versionString()) + return 0 + case "serve": + return runServe(rest, stdout, stderr) + case "check": + return runCheck(rest, stdout, stderr) + case "help", "-h", "--help": + fmt.Fprint(stdout, usage) + return 0 + default: + fmt.Fprintf(stderr, "Unbekanntes Kommando: %s\n\n%s", cmd, usage) + return 2 + } +} +``` + +- [ ] **Step 6: Minimale `serve.go` und `check.go` anlegen** + +`cmd/vpnportal/serve.go`: + +```go +package main + +import ( + "flag" + "fmt" + "io" +) + +const defaultConfigPath = "/etc/vpnportal/config.yaml" + +func configFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(stderr) + path := fs.String("config", defaultConfigPath, "Pfad zur Konfigurationsdatei") + return fs, path +} + +func runServe(args []string, stdout, stderr io.Writer) int { + fs, path := configFlags("serve", stderr) + if err := fs.Parse(args); err != nil { + return 2 + } + fmt.Fprintf(stderr, "Fehler: config %q kann noch nicht geladen werden (nicht implementiert)\n", *path) + return 1 +} +``` + +`cmd/vpnportal/check.go`: + +```go +package main + +import ( + "fmt" + "io" +) + +func runCheck(args []string, stdout, stderr io.Writer) int { + fs, path := configFlags("check", stderr) + testAuth := fs.String("test-auth", "", "Suche und Gruppenprüfung für einen Benutzer durchspielen") + if err := fs.Parse(args); err != nil { + return 2 + } + _ = testAuth + fmt.Fprintf(stderr, "Fehler: config %q kann noch nicht geladen werden (nicht implementiert)\n", *path) + return 1 +} +``` + +- [ ] **Step 7: Tests laufen lassen** + +Run: `go test ./cmd/vpnportal/ -v` +Expected: PASS (alle drei Tests) + +- [ ] **Step 8: Makefile schreiben** + +```makefile +BINARY := vpnportal +PKG := git.ravensburg.dev/cabele/opnsense-portal/cmd/vpnportal +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) +DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE) + +export CGO_ENABLED := 0 + +.PHONY: all build test vet lint release clean + +all: vet test build + +build: + go build -trimpath -ldflags '$(LDFLAGS)' -o dist/$(BINARY) $(PKG) + +test: + go test ./... + +vet: + go vet ./... + +lint: vet + gofmt -l . | tee /dev/stderr | (! read) + +release: clean + @set -e; for arch in amd64 arm64; do \ + echo "==> linux/$$arch"; \ + GOOS=linux GOARCH=$$arch go build -trimpath -ldflags '$(LDFLAGS)' \ + -o dist/linux-$$arch/$(BINARY) $(PKG); \ + mkdir -p dist/linux-$$arch; \ + cp deploy/config.example.yaml deploy/vpnportal.service README.md dist/linux-$$arch/; \ + tar -czf dist/$(BINARY)-$(VERSION)-linux-$$arch.tar.gz -C dist/linux-$$arch .; \ + done + cd dist && sha256sum *.tar.gz > SHA256SUMS + +clean: + rm -rf dist +``` + +- [ ] **Step 9: Build für beide Zielarchitekturen verifizieren** + +Run: +```bash +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /dev/null ./cmd/vpnportal +CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o /dev/null ./cmd/vpnportal +make test +``` +Expected: beide Builds ohne Ausgabe, Tests PASS + +- [ ] **Step 10: Commit** + +```bash +git add -A +git commit -m "feat: CLI-Skeleton mit version/serve/check und Makefile" +``` + +--- + +## Task 2: Config-Structs und YAML-Strict-Parsing + +**Files:** +- Create: `internal/config/config.go`, `internal/config/load.go` +- Test: `internal/config/load_test.go` +- Modify: `go.mod` (Dependency `gopkg.in/yaml.v3`) + +**Interfaces:** +- Consumes: nichts +- Produces: + - `type Config struct { Portal PortalConfig; OPNsense OPNsenseConfig; AD ADConfig; Matching MatchingConfig; Logging LoggingConfig }` + - `type Duration time.Duration` mit `UnmarshalYAML` für Strings wie `"10m"` + - `func Parse(r io.Reader) (*Config, error)` — strict, mit Defaults vorbelegt + - Defaults: `portal.listen="0.0.0.0:8443"`, `portal.session_ttl=10m`, `ad.port=636`, `ad.tls_mode="ldaps"`, `matching.cn_pattern="{username}"`, `logging.level="info"`, `logging.max_size_mb=50`, `logging.max_backups=5`, `logging.compress=true` + +- [ ] **Step 1: Dependency hinzufügen** + +```bash +go get gopkg.in/yaml.v3@latest +``` + +- [ ] **Step 2: Failing test schreiben** + +`internal/config/load_test.go`: + +```go +package config + +import ( + "strings" + "testing" + "time" +) + +const minimalYAML = ` +portal: + tls_cert: "/etc/vpnportal/portal.crt" + tls_key: "/etc/vpnportal/portal.key" +opnsense: + url: "https://fw01.firma.local" + api_key: "KEY" + api_secret: "SECRET" +ad: + domain: "firma.local" + servers: ["dc01.firma.local"] + bind_user: "svc@firma.local" + bind_password: "PW" + vpn_group: "VPN-Users" +logging: + audit_log: "/var/log/vpnportal/audit.log" +` + +func TestParseAppliesDefaults(t *testing.T) { + cfg, err := Parse(strings.NewReader(minimalYAML)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if cfg.Portal.Listen != "0.0.0.0:8443" { + t.Errorf("Listen = %q, want default 0.0.0.0:8443", cfg.Portal.Listen) + } + if time.Duration(cfg.Portal.SessionTTL) != 10*time.Minute { + t.Errorf("SessionTTL = %v, want 10m", time.Duration(cfg.Portal.SessionTTL)) + } + if cfg.AD.Port != 636 { + t.Errorf("AD.Port = %d, want 636", cfg.AD.Port) + } + if cfg.Matching.CNPattern != "{username}" { + t.Errorf("CNPattern = %q, want {username}", cfg.Matching.CNPattern) + } + if cfg.Logging.MaxSizeMB != 50 || cfg.Logging.MaxBackups != 5 || !cfg.Logging.Compress { + t.Errorf("logging defaults wrong: %+v", cfg.Logging) + } + if cfg.Portal.UpdateCheck { + t.Error("UpdateCheck must default to false") + } +} + +func TestParseRejectsUnknownField(t *testing.T) { + _, err := Parse(strings.NewReader(minimalYAML + "\nportal_typo:\n listen: \"x\"\n")) + if err == nil { + t.Fatal("unbekanntes Feld muss abgelehnt werden") + } + if !strings.Contains(err.Error(), "portal_typo") { + t.Errorf("Fehler muss das unbekannte Feld nennen, got: %v", err) + } +} + +func TestParseRejectsUnknownNestedField(t *testing.T) { + _, err := Parse(strings.NewReader(strings.Replace(minimalYAML, + " vpn_group: \"VPN-Users\"", " vpn_group: \"VPN-Users\"\n vpngroup: \"typo\"", 1))) + if err == nil { + t.Fatal("unbekanntes verschachteltes Feld muss abgelehnt werden") + } +} + +func TestParseSessionTTLOverride(t *testing.T) { + cfg, err := Parse(strings.NewReader(strings.Replace(minimalYAML, + "portal:", "portal:\n session_ttl: \"90s\"", 1))) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if time.Duration(cfg.Portal.SessionTTL) != 90*time.Second { + t.Errorf("SessionTTL = %v, want 90s", time.Duration(cfg.Portal.SessionTTL)) + } +} + +func TestParseRejectsBadDuration(t *testing.T) { + _, err := Parse(strings.NewReader(strings.Replace(minimalYAML, + "portal:", "portal:\n session_ttl: \"zehn Minuten\"", 1))) + if err == nil { + t.Fatal("ungültige Dauer muss abgelehnt werden") + } +} +``` + +- [ ] **Step 3: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./internal/config/ -v` +Expected: FAIL — `undefined: Parse` + +- [ ] **Step 4: `config.go` schreiben** + +```go +// Package config lädt und validiert die Portal-Konfiguration. +package config + +import ( + "fmt" + "time" + + "gopkg.in/yaml.v3" +) + +// Duration erlaubt Dauerangaben als String ("10m") in YAML. +type Duration time.Duration + +func (d *Duration) UnmarshalYAML(node *yaml.Node) error { + var s string + if err := node.Decode(&s); err != nil { + return fmt.Errorf("Dauer muss eine Zeichenkette sein (z. B. \"10m\"): %w", err) + } + parsed, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("ungültige Dauer %q (erwartet z. B. \"10m\", \"90s\"): %w", s, err) + } + if parsed <= 0 { + return fmt.Errorf("Dauer %q muss positiv sein", s) + } + *d = Duration(parsed) + return nil +} + +func (d Duration) String() string { return time.Duration(d).String() } + +type Config struct { + Portal PortalConfig `yaml:"portal"` + OPNsense OPNsenseConfig `yaml:"opnsense"` + AD ADConfig `yaml:"ad"` + Matching MatchingConfig `yaml:"matching"` + Logging LoggingConfig `yaml:"logging"` +} + +type PortalConfig struct { + Listen string `yaml:"listen"` + TLSCert string `yaml:"tls_cert"` + TLSKey string `yaml:"tls_key"` + SessionTTL Duration `yaml:"session_ttl"` + Title string `yaml:"title"` + LogoFile string `yaml:"logo_file"` + SupportContact string `yaml:"support_contact"` + UpdateCheck bool `yaml:"update_check"` +} + +type OPNsenseConfig struct { + URL string `yaml:"url"` + APIKey string `yaml:"api_key"` + APIKeyFile string `yaml:"api_key_file"` + APISecret string `yaml:"api_secret"` + APISecretFile string `yaml:"api_secret_file"` + CAFile string `yaml:"ca_file"` + InsecureSkipVerify bool `yaml:"insecure_skip_verify"` +} + +type ADConfig struct { + Domain string `yaml:"domain"` + BaseDN string `yaml:"base_dn"` + Servers []string `yaml:"servers"` + Port int `yaml:"port"` + TLSMode string `yaml:"tls_mode"` // "ldaps" (Default) oder "starttls" + BindUser string `yaml:"bind_user"` + BindPassword string `yaml:"bind_password"` + BindPasswordFile string `yaml:"bind_password_file"` + VPNGroup string `yaml:"vpn_group"` + CAFile string `yaml:"ca_file"` + Timeout Duration `yaml:"timeout"` +} + +type MatchingConfig struct { + CNPattern string `yaml:"cn_pattern"` + CNRegex string `yaml:"cn_regex"` +} + +type LoggingConfig struct { + Level string `yaml:"level"` + AuditLog string `yaml:"audit_log"` + MaxSizeMB int `yaml:"max_size_mb"` + MaxBackups int `yaml:"max_backups"` + Compress bool `yaml:"compress"` +} + +// Defaults liefert eine Config mit allen Vorgabewerten. +func Defaults() *Config { + return &Config{ + Portal: PortalConfig{ + Listen: "0.0.0.0:8443", + SessionTTL: Duration(10 * time.Minute), + Title: "VPN-Portal", + }, + AD: ADConfig{ + Port: 636, + TLSMode: "ldaps", + Timeout: Duration(8 * time.Second), + }, + Matching: MatchingConfig{CNPattern: "{username}"}, + Logging: LoggingConfig{ + Level: "info", + MaxSizeMB: 50, + MaxBackups: 5, + Compress: true, + }, + } +} +``` + +Anmerkung zu `Compress`: der Default ist `true`, YAML `compress: false` überschreibt ihn korrekt, weil in ein vorbelegtes Struct hineindekodiert wird. + +- [ ] **Step 5: `load.go` mit `Parse` schreiben** + +```go +package config + +import ( + "errors" + "fmt" + "io" + + "gopkg.in/yaml.v3" +) + +// Parse liest YAML strikt in eine mit Defaults vorbelegte Config. +// Unbekannte Felder führen zum Fehler. +func Parse(r io.Reader) (*Config, error) { + cfg := Defaults() + dec := yaml.NewDecoder(r) + dec.KnownFields(true) + if err := dec.Decode(cfg); err != nil { + if errors.Is(err, io.EOF) { + return nil, errors.New("Konfigurationsdatei ist leer") + } + return nil, fmt.Errorf("Konfiguration konnte nicht gelesen werden: %w", err) + } + // Ein zweiter Decode-Aufruf muss EOF liefern; sonst enthält die Datei + // mehrere YAML-Dokumente, was wir nicht unterstützen. + var extra yaml.Node + if err := dec.Decode(&extra); !errors.Is(err, io.EOF) { + return nil, errors.New("Konfigurationsdatei darf nur ein YAML-Dokument enthalten") + } + return cfg, nil +} +``` + +- [ ] **Step 6: Tests laufen lassen** + +Run: `go test ./internal/config/ -v` +Expected: PASS (5 Tests) + +- [ ] **Step 7: Commit** + +```bash +git add go.mod go.sum internal/config +git commit -m "feat(config): YAML-Strict-Parsing mit Defaults" +``` + +--- + +## Task 3: Secrets, Env-Overrides und erzwungene Dateirechte + +**Files:** +- Create: `internal/config/perms.go` +- Modify: `internal/config/load.go` (Funktion `Load`) +- Test: `internal/config/perms_test.go`, `internal/config/secrets_test.go` + +**Interfaces:** +- Consumes: `Parse`, `Config` aus Task 2 +- Produces: + - `func Load(path string, env func(string) string) (*Config, error)` — liest Datei, prüft Rechte, parst, löst `*_file` auf, wendet Env-Overrides an. `env` ist injizierbar (`os.Getenv` in Produktion). + - `func CheckFileMode(path string, maxMode fs.FileMode) error` — Fehler, wenn Bits jenseits von `maxMode` gesetzt sind. + - `func CheckDirMode(path string, maxMode fs.FileMode) error` + - Env-Namen: `VPNPORTAL_OPNSENSE_API_KEY`, `VPNPORTAL_OPNSENSE_API_SECRET`, `VPNPORTAL_AD_BIND_PASSWORD` + - Rangfolge: Env-Var > `*_file` > Inline-Wert in YAML + +- [ ] **Step 1: Failing tests für Dateirechte schreiben** + +`internal/config/perms_test.go`: + +```go +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeFileMode(t *testing.T, dir, name string, mode os.FileMode, content string) string { + t.Helper() + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(content), mode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(p, mode); err != nil { // umask umgehen + t.Fatal(err) + } + return p +} + +func TestCheckFileModeAcceptsTightPermissions(t *testing.T) { + dir := t.TempDir() + for _, mode := range []os.FileMode{0o600, 0o640, 0o400} { + p := writeFileMode(t, dir, "s.txt", mode, "x") + if err := CheckFileMode(p, 0o640); err != nil { + t.Errorf("mode %#o should be accepted: %v", mode, err) + } + } +} + +func TestCheckFileModeRejectsLoosePermissions(t *testing.T) { + dir := t.TempDir() + for _, mode := range []os.FileMode{0o644, 0o660, 0o604, 0o777} { + p := writeFileMode(t, dir, "s.txt", mode, "x") + err := CheckFileMode(p, 0o640) + if err == nil { + t.Errorf("mode %#o must be rejected", mode) + continue + } + if !strings.Contains(err.Error(), "chmod") { + t.Errorf("Fehler sollte den Reparaturbefehl nennen, got: %v", err) + } + } +} + +func TestCheckDirModeRejectsWorldWritable(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "logs") + if err := os.Mkdir(sub, 0o777); err != nil { + t.Fatal(err) + } + if err := os.Chmod(sub, 0o777); err != nil { + t.Fatal(err) + } + if err := CheckDirMode(sub, 0o750); err == nil { + t.Fatal("0777-Verzeichnis muss abgelehnt werden") + } + if err := os.Chmod(sub, 0o750); err != nil { + t.Fatal(err) + } + if err := CheckDirMode(sub, 0o750); err != nil { + t.Errorf("0750 sollte akzeptiert werden: %v", err) + } +} +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./internal/config/ -run TestCheck -v` +Expected: FAIL — `undefined: CheckFileMode` + +- [ ] **Step 3: `perms.go` schreiben** + +```go +package config + +import ( + "fmt" + "io/fs" + "os" +) + +// CheckFileMode bricht ab, wenn die Datei mehr Rechte trägt als maxMode. +func CheckFileMode(path string, maxMode fs.FileMode) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("Datei %s nicht lesbar: %w", path, err) + } + if info.IsDir() { + return fmt.Errorf("%s ist ein Verzeichnis, erwartet wurde eine Datei", path) + } + return checkMode(path, info.Mode().Perm(), maxMode) +} + +// CheckDirMode bricht ab, wenn das Verzeichnis mehr Rechte trägt als maxMode. +func CheckDirMode(path string, maxMode fs.FileMode) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("Verzeichnis %s nicht lesbar: %w", path, err) + } + if !info.IsDir() { + return fmt.Errorf("%s ist kein Verzeichnis", path) + } + return checkMode(path, info.Mode().Perm(), maxMode) +} + +func checkMode(path string, actual, maxMode fs.FileMode) error { + if extra := actual &^ maxMode; extra != 0 { + return fmt.Errorf( + "%s hat zu weite Dateirechte %#o (erlaubt höchstens %#o). Beheben mit: chmod %#o %s", + path, actual, maxMode, maxMode, path) + } + return nil +} +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `go test ./internal/config/ -run TestCheck -v` +Expected: PASS + +- [ ] **Step 5: Failing tests für Secrets und Env-Overrides schreiben** + +`internal/config/secrets_test.go`: + +```go +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func noEnv(string) string { return "" } + +func writeConfig(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(p, 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func TestLoadRejectsWorldReadableConfig(t *testing.T) { + p := writeConfig(t, minimalYAML) + if err := os.Chmod(p, 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(p, noEnv); err == nil { + t.Fatal("0644-Config muss den Start abbrechen") + } +} + +func TestLoadResolvesSecretFiles(t *testing.T) { + dir := t.TempDir() + secret := filepath.Join(dir, "apisecret") + if err := os.WriteFile(secret, []byte(" FROM-FILE\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(secret, 0o600); err != nil { + t.Fatal(err) + } + body := strings.Replace(minimalYAML, + ` api_secret: "SECRET"`, ` api_secret_file: "`+secret+`"`, 1) + cfg, err := Load(writeConfig(t, body), noEnv) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.OPNsense.APISecret != "FROM-FILE" { + t.Errorf("APISecret = %q, want FROM-FILE (getrimmt)", cfg.OPNsense.APISecret) + } +} + +func TestLoadRejectsLooseSecretFile(t *testing.T) { + dir := t.TempDir() + secret := filepath.Join(dir, "apisecret") + if err := os.WriteFile(secret, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(secret, 0o644); err != nil { + t.Fatal(err) + } + body := strings.Replace(minimalYAML, + ` api_secret: "SECRET"`, ` api_secret_file: "`+secret+`"`, 1) + if _, err := Load(writeConfig(t, body), noEnv); err == nil { + t.Fatal("Secret-Datei mit 0644 muss abgelehnt werden") + } +} + +func TestLoadEnvOverridesEverything(t *testing.T) { + env := map[string]string{ + "VPNPORTAL_OPNSENSE_API_SECRET": "FROM-ENV", + "VPNPORTAL_AD_BIND_PASSWORD": "ENV-PW", + } + cfg, err := Load(writeConfig(t, minimalYAML), func(k string) string { return env[k] }) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.OPNsense.APISecret != "FROM-ENV" { + t.Errorf("APISecret = %q, want FROM-ENV", cfg.OPNsense.APISecret) + } + if cfg.AD.BindPassword != "ENV-PW" { + t.Errorf("BindPassword = %q, want ENV-PW", cfg.AD.BindPassword) + } +} + +func TestLoadRejectsBothInlineAndFile(t *testing.T) { + body := strings.Replace(minimalYAML, + ` api_secret: "SECRET"`, + " api_secret: \"SECRET\"\n api_secret_file: \"/tmp/x\"", 1) + _, err := Load(writeConfig(t, body), noEnv) + if err == nil || !strings.Contains(err.Error(), "api_secret") { + t.Fatalf("gleichzeitig gesetzte api_secret/api_secret_file müssen abgelehnt werden, got: %v", err) + } +} +``` + +- [ ] **Step 6: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./internal/config/ -run TestLoad -v` +Expected: FAIL — `undefined: Load` + +- [ ] **Step 7: `Load` in `load.go` ergänzen** + +```go +// Am Kopf von load.go zusätzlich importieren: "os", "strings" + +const ( + EnvAPIKey = "VPNPORTAL_OPNSENSE_API_KEY" + EnvAPISecret = "VPNPORTAL_OPNSENSE_API_SECRET" + EnvBindPassword = "VPNPORTAL_AD_BIND_PASSWORD" +) + +// maxConfigMode ist die weiteste erlaubte Berechtigung für Config- und +// Secret-Dateien: Eigentümer lesen/schreiben, Gruppe lesen, Welt nichts. +const maxConfigMode fs.FileMode = 0o640 + +// Load liest die Konfigurationsdatei, erzwingt Dateirechte, löst +// *_file-Secrets auf und wendet Env-Overrides an. +// env wird injiziert, damit Tests ohne Prozess-Umgebung auskommen. +func Load(path string, env func(string) string) (*Config, error) { + if env == nil { + env = os.Getenv + } + if err := CheckFileMode(path, maxConfigMode); err != nil { + return nil, err + } + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("Konfigurationsdatei %s: %w", path, err) + } + defer f.Close() + + cfg, err := Parse(f) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + if err := resolveSecrets(cfg, env); err != nil { + return nil, err + } + return cfg, nil +} + +type secretSlot struct { + name string // YAML-Feldname für Fehlermeldungen + inline *string + file string + envName string +} + +func resolveSecrets(cfg *Config, env func(string) string) error { + slots := []secretSlot{ + {"opnsense.api_key", &cfg.OPNsense.APIKey, cfg.OPNsense.APIKeyFile, EnvAPIKey}, + {"opnsense.api_secret", &cfg.OPNsense.APISecret, cfg.OPNsense.APISecretFile, EnvAPISecret}, + {"ad.bind_password", &cfg.AD.BindPassword, cfg.AD.BindPasswordFile, EnvBindPassword}, + } + for _, s := range slots { + if v := env(s.envName); v != "" { + *s.inline = v + continue + } + if s.file == "" { + continue + } + if *s.inline != "" { + return fmt.Errorf("%s und %s_file sind gleichzeitig gesetzt — bitte nur eines verwenden", + s.name, s.name) + } + if err := CheckFileMode(s.file, maxConfigMode); err != nil { + return fmt.Errorf("Secret-Datei für %s: %w", s.name, err) + } + raw, err := os.ReadFile(s.file) + if err != nil { + return fmt.Errorf("Secret-Datei für %s: %w", s.name, err) + } + value := strings.TrimSpace(string(raw)) + if value == "" { + return fmt.Errorf("Secret-Datei %s für %s ist leer", s.file, s.name) + } + *s.inline = value + } + return nil +} +``` + +`"io/fs"` muss in `load.go` importiert werden. + +- [ ] **Step 8: Tests laufen lassen** + +Run: `go test ./internal/config/ -v` +Expected: PASS (alle Tests aus Task 2 und 3) + +- [ ] **Step 9: Commit** + +```bash +git add internal/config +git commit -m "feat(config): Secret-Dateien, Env-Overrides und erzwungene Dateirechte" +``` + +--- + +## Task 4: Semantische Validierung und Base-DN-Ableitung + +**Files:** +- Create: `internal/config/validate.go` +- Test: `internal/config/validate_test.go` +- Modify: `internal/config/load.go` (Validate am Ende von `Load` aufrufen) + +**Interfaces:** +- Consumes: `Config` aus Task 2 +- Produces: + - `func (c *Config) Validate() error` — sammelt **alle** Fehler und gibt sie gebündelt aus (`errors.Join`) + - `func BaseDNFromDomain(domain string) (string, error)` — `firma.local` → `DC=firma,DC=local` + - `func (c *Config) EffectiveBaseDN() string` + - Regeln: `tls_cert`/`tls_key` Pflicht; `opnsense.url` muss `https://` sein; `ad.servers` nicht leer und **keine IP-Adressen**; `tls_mode` ∈ {`ldaps`,`starttls`}; `cn_pattern` und `cn_regex` schließen sich aus; `cn_regex` muss kompilierbar sein; `cn_pattern` muss `{username}` enthalten; `logging.level` ∈ {`debug`,`info`,`warn`,`error`}; `audit_log` Pflicht + +- [ ] **Step 1: Failing test schreiben** + +`internal/config/validate_test.go`: + +```go +package config + +import ( + "strings" + "testing" +) + +func validCfg(t *testing.T) *Config { + t.Helper() + cfg, err := Parse(strings.NewReader(minimalYAML)) + if err != nil { + t.Fatal(err) + } + return cfg +} + +func TestBaseDNFromDomain(t *testing.T) { + cases := map[string]string{ + "firma.local": "DC=firma,DC=local", + "ad.firma.example": "DC=ad,DC=firma,DC=example", + "FIRMA.LOCAL": "DC=FIRMA,DC=LOCAL", + } + for in, want := range cases { + got, err := BaseDNFromDomain(in) + if err != nil { + t.Errorf("BaseDNFromDomain(%q): %v", in, err) + continue + } + if got != want { + t.Errorf("BaseDNFromDomain(%q) = %q, want %q", in, got, want) + } + } + if _, err := BaseDNFromDomain("firmalocal"); err == nil { + t.Error("Domain ohne Punkt muss abgelehnt werden") + } + if _, err := BaseDNFromDomain(""); err == nil { + t.Error("leere Domain muss abgelehnt werden") + } +} + +func TestEffectiveBaseDNPrefersOverride(t *testing.T) { + cfg := validCfg(t) + cfg.AD.BaseDN = "OU=Users,DC=firma,DC=local" + if got := cfg.EffectiveBaseDN(); got != "OU=Users,DC=firma,DC=local" { + t.Errorf("Override muss gewinnen, got %q", got) + } + cfg.AD.BaseDN = "" + if got := cfg.EffectiveBaseDN(); got != "DC=firma,DC=local" { + t.Errorf("Ableitung falsch, got %q", got) + } +} + +func TestValidateAcceptsMinimalConfig(t *testing.T) { + if err := validCfg(t).Validate(); err != nil { + t.Fatalf("minimale Config sollte gültig sein: %v", err) + } +} + +func TestValidateRejectsIPServers(t *testing.T) { + cfg := validCfg(t) + cfg.AD.Servers = []string{"10.1.1.10"} + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "10.1.1.10") { + t.Fatalf("IP-Adresse als DC muss abgelehnt werden, got: %v", err) + } +} + +func TestValidateRejectsHTTPOPNsense(t *testing.T) { + cfg := validCfg(t) + cfg.OPNsense.URL = "http://fw01.firma.local" + if err := cfg.Validate(); err == nil { + t.Fatal("http:// muss abgelehnt werden") + } +} + +func TestValidateRejectsPatternAndRegexTogether(t *testing.T) { + cfg := validCfg(t) + cfg.Matching.CNRegex = "^.*$" + if err := cfg.Validate(); err == nil { + t.Fatal("cn_pattern und cn_regex gleichzeitig muss abgelehnt werden") + } +} + +func TestValidateRejectsBadRegex(t *testing.T) { + cfg := validCfg(t) + cfg.Matching.CNPattern = "" + cfg.Matching.CNRegex = "([unbalanced" + if err := cfg.Validate(); err == nil { + t.Fatal("unkompilierbare Regex muss abgelehnt werden") + } +} + +func TestValidateCollectsMultipleErrors(t *testing.T) { + cfg := validCfg(t) + cfg.Portal.TLSCert = "" + cfg.Portal.TLSKey = "" + cfg.AD.VPNGroup = "" + err := cfg.Validate() + if err == nil { + t.Fatal("mehrere Fehler erwartet") + } + for _, want := range []string{"tls_cert", "tls_key", "vpn_group"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("Sammelfehler muss %q nennen, got: %v", want, err) + } + } +} + +func TestValidateRejectsPatternWithoutPlaceholder(t *testing.T) { + cfg := validCfg(t) + cfg.Matching.CNPattern = "fester-cn" + if err := cfg.Validate(); err == nil { + t.Fatal("cn_pattern ohne {username} muss abgelehnt werden") + } +} +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./internal/config/ -run 'TestValidate|TestBaseDN|TestEffective' -v` +Expected: FAIL — `undefined: BaseDNFromDomain` + +- [ ] **Step 3: `validate.go` schreiben** + +```go +package config + +import ( + "errors" + "fmt" + "net" + "net/url" + "regexp" + "slices" + "strings" +) + +// UsernamePlaceholder ist der Platzhalter in matching.cn_pattern. +const UsernamePlaceholder = "{username}" + +// BaseDNFromDomain leitet DC=firma,DC=local aus firma.local ab. +func BaseDNFromDomain(domain string) (string, error) { + domain = strings.Trim(strings.TrimSpace(domain), ".") + if domain == "" { + return "", errors.New("ad.domain ist leer") + } + parts := strings.Split(domain, ".") + if len(parts) < 2 { + return "", fmt.Errorf("ad.domain %q sieht nicht wie eine AD-Domäne aus (erwartet z. B. firma.local); "+ + "andernfalls ad.base_dn explizit setzen", domain) + } + dcs := make([]string, len(parts)) + for i, p := range parts { + if p == "" { + return "", fmt.Errorf("ad.domain %q enthält leere Labels", domain) + } + dcs[i] = "DC=" + p + } + return strings.Join(dcs, ","), nil +} + +// EffectiveBaseDN liefert den Override oder die Ableitung aus der Domäne. +func (c *Config) EffectiveBaseDN() string { + if strings.TrimSpace(c.AD.BaseDN) != "" { + return strings.TrimSpace(c.AD.BaseDN) + } + dn, err := BaseDNFromDomain(c.AD.Domain) + if err != nil { + return "" + } + return dn +} + +// Validate prüft die Konfiguration semantisch und sammelt alle Fehler. +func (c *Config) Validate() error { + var errs []error + req := func(value, name string) { + if strings.TrimSpace(value) == "" { + errs = append(errs, fmt.Errorf("%s ist erforderlich", name)) + } + } + + req(c.Portal.Listen, "portal.listen") + req(c.Portal.TLSCert, "portal.tls_cert") + req(c.Portal.TLSKey, "portal.tls_key") + if _, _, err := net.SplitHostPort(c.Portal.Listen); c.Portal.Listen != "" && err != nil { + errs = append(errs, fmt.Errorf("portal.listen %q ist keine gültige Adresse (erwartet HOST:PORT): %w", + c.Portal.Listen, err)) + } + + req(c.OPNsense.URL, "opnsense.url") + if c.OPNsense.URL != "" { + u, err := url.Parse(c.OPNsense.URL) + switch { + case err != nil: + errs = append(errs, fmt.Errorf("opnsense.url %q ist keine gültige URL: %w", c.OPNsense.URL, err)) + case u.Scheme != "https": + errs = append(errs, fmt.Errorf("opnsense.url muss mit https:// beginnen, hat aber Schema %q", u.Scheme)) + case u.Host == "": + errs = append(errs, errors.New("opnsense.url enthält keinen Host")) + } + } + req(c.OPNsense.APIKey, "opnsense.api_key (oder api_key_file / "+EnvAPIKey+")") + req(c.OPNsense.APISecret, "opnsense.api_secret (oder api_secret_file / "+EnvAPISecret+")") + + req(c.AD.Domain, "ad.domain") + req(c.AD.BindUser, "ad.bind_user") + req(c.AD.BindPassword, "ad.bind_password (oder bind_password_file / "+EnvBindPassword+")") + req(c.AD.VPNGroup, "ad.vpn_group") + if len(c.AD.Servers) == 0 { + errs = append(errs, errors.New("ad.servers muss mindestens einen Domain Controller enthalten")) + } + for _, s := range c.AD.Servers { + s = strings.TrimSpace(s) + if s == "" { + errs = append(errs, errors.New("ad.servers enthält einen leeren Eintrag")) + continue + } + if net.ParseIP(s) != nil { + errs = append(errs, fmt.Errorf( + "ad.servers: %q ist eine IP-Adresse — es sind Hostnamen erforderlich, "+ + "weil das LDAPS-Zertifikat gegen den Hostnamen geprüft wird", s)) + } + } + if !slices.Contains([]string{"ldaps", "starttls"}, c.AD.TLSMode) { + errs = append(errs, fmt.Errorf("ad.tls_mode %q ist ungültig (erlaubt: ldaps, starttls)", c.AD.TLSMode)) + } + if c.AD.Port <= 0 || c.AD.Port > 65535 { + errs = append(errs, fmt.Errorf("ad.port %d liegt außerhalb 1–65535", c.AD.Port)) + } + if c.AD.BaseDN == "" { + if _, err := BaseDNFromDomain(c.AD.Domain); err != nil && c.AD.Domain != "" { + errs = append(errs, err) + } + } + + pattern, rx := strings.TrimSpace(c.Matching.CNPattern), strings.TrimSpace(c.Matching.CNRegex) + switch { + case pattern != "" && rx != "": + errs = append(errs, errors.New( + "matching.cn_pattern und matching.cn_regex schließen sich aus — bitte nur eines setzen")) + case pattern == "" && rx == "": + errs = append(errs, errors.New("matching.cn_pattern oder matching.cn_regex muss gesetzt sein")) + case pattern != "" && !strings.Contains(pattern, UsernamePlaceholder): + errs = append(errs, fmt.Errorf("matching.cn_pattern %q enthält keinen %s-Platzhalter", + pattern, UsernamePlaceholder)) + case rx != "": + if _, err := regexp.Compile(rx); err != nil { + errs = append(errs, fmt.Errorf("matching.cn_regex ist nicht kompilierbar: %w", err)) + } + } + + if !slices.Contains([]string{"debug", "info", "warn", "error"}, c.Logging.Level) { + errs = append(errs, fmt.Errorf("logging.level %q ist ungültig (erlaubt: debug, info, warn, error)", + c.Logging.Level)) + } + req(c.Logging.AuditLog, "logging.audit_log") + if c.Logging.MaxSizeMB <= 0 { + errs = append(errs, fmt.Errorf("logging.max_size_mb muss positiv sein, ist %d", c.Logging.MaxSizeMB)) + } + if c.Logging.MaxBackups < 0 { + errs = append(errs, fmt.Errorf("logging.max_backups darf nicht negativ sein, ist %d", c.Logging.MaxBackups)) + } + + return errors.Join(errs...) +} +``` + +- [ ] **Step 4: `Validate` in `Load` aufrufen** + +In `load.go`, direkt vor `return cfg, nil`: + +```go + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("Konfiguration %s ist ungültig:\n%w", path, err) + } +``` + +- [ ] **Step 5: Tests laufen lassen** + +Run: `go test ./internal/config/ -v` +Expected: PASS. Falls `TestLoadResolvesSecretFiles` o. ä. jetzt fehlschlägt, weil `minimalYAML` unvollständig ist: `minimalYAML` ist bewusst vollständig genug — es muss ohne Änderung validieren. + +- [ ] **Step 6: Commit** + +```bash +git add internal/config +git commit -m "feat(config): semantische Validierung mit Sammelfehlern und Base-DN-Ableitung" +``` + +--- + +## Task 5: Audit-Logger (JSONL, Rotation, SIGHUP, stdout-Tee) + +**Files:** +- Create: `internal/audit/event.go`, `internal/audit/logger.go`, `internal/audit/rotate.go` +- Test: `internal/audit/logger_test.go`, `internal/audit/rotate_test.go` + +**Interfaces:** +- Consumes: nichts (bewusst dependency-frei, damit jedes Package loggen kann) +- Produces: + - Event-Konstanten: `EventLoginSuccess = "login_success"`, `EventLoginFailed`, `EventLogout`, `EventSessionExpired`, `EventConfigDownload`, `EventDownloadDenied`, `EventNoCertFound`, `EventOPNsenseUnreachable`, `EventLDAPFailover`, `EventRateLimited`, `EventStartup` + - `type Event struct { TS string; Event string; User string; SrcIP string; Session string; Reason string; VPNInstance string; CertCN string; CertExpiry string; Format string; Pattern string; Version string; ConfigSum string; Detail string }` — alle optionalen Felder mit `json:",omitempty"` + - `func New(path string, opts Options) (*Logger, error)`; `type Options struct { MaxSizeMB, MaxBackups int; Compress bool; Stdout io.Writer; Clock func() time.Time }` + - `func (l *Logger) Log(e Event)` — füllt `TS` automatisch, ist goroutine-sicher + - `func (l *Logger) Reopen() error` — für SIGHUP + - `func (l *Logger) Close() error` + - `func ShortSession(token string) string` — erste 4 Hex-Zeichen eines SHA-256 über den Token, **nie** der Token selbst + - `const UnknownUser = ""` + +- [ ] **Step 1: Failing test für Event-Serialisierung und Logger schreiben** + +`internal/audit/logger_test.go`: + +```go +package audit + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func newTestLogger(t *testing.T, opts Options) (*Logger, string, *bytes.Buffer) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "audit.log") + var stdout bytes.Buffer + opts.Stdout = &stdout + if opts.MaxSizeMB == 0 { + opts.MaxSizeMB = 50 + } + lg, err := New(path, opts) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(func() { lg.Close() }) + return lg, path, &stdout +} + +func readLines(t *testing.T, path string) []map[string]any { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var out []map[string]any + for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { + if line == "" { + continue + } + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Fatalf("Zeile ist kein gültiges JSON: %q: %v", line, err) + } + out = append(out, m) + } + return out +} + +func TestLogWritesJSONLWithTimezone(t *testing.T) { + fixed := time.Date(2026, 8, 14, 9, 32, 11, 0, time.FixedZone("CEST", 2*3600)) + lg, path, stdout := newTestLogger(t, Options{Clock: func() time.Time { return fixed }}) + + lg.Log(Event{Event: EventLoginSuccess, User: "mmueller", SrcIP: "10.1.20.34", Session: "a3f9"}) + if err := lg.Close(); err != nil { + t.Fatal(err) + } + + lines := readLines(t, path) + if len(lines) != 1 { + t.Fatalf("got %d Zeilen, want 1", len(lines)) + } + got := lines[0] + if got["ts"] != "2026-08-14T09:32:11+02:00" { + t.Errorf("ts = %v, want RFC3339 mit Zeitzone", got["ts"]) + } + if got["event"] != EventLoginSuccess || got["user"] != "mmueller" { + t.Errorf("Feldwerte falsch: %v", got) + } + if _, present := got["reason"]; present { + t.Errorf("leere Felder dürfen nicht serialisiert werden: %v", got) + } + if !strings.Contains(stdout.String(), EventLoginSuccess) { + t.Error("Event muss zusätzlich nach stdout gehen") + } +} + +func TestLogIsConcurrencySafe(t *testing.T) { + lg, path, _ := newTestLogger(t, Options{}) + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + lg.Log(Event{Event: EventLoginFailed, User: UnknownUser, SrcIP: "10.0.0.1"}) + }() + } + wg.Wait() + if err := lg.Close(); err != nil { + t.Fatal(err) + } + if n := len(readLines(t, path)); n != 50 { + t.Fatalf("got %d Zeilen, want 50 (kein Interleaving erlaubt)", n) + } +} + +func TestNewCreatesFileWithTightPermissions(t *testing.T) { + _, path, _ := newTestLogger(t, Options{}) + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm&0o077 != 0 { + t.Errorf("Audit-Log hat Rechte %#o — Gruppe/Welt dürfen nicht zugreifen", perm) + } +} + +func TestShortSessionNeverLeaksToken(t *testing.T) { + token := "0123456789abcdef0123456789abcdef" + short := ShortSession(token) + if len(short) != 4 { + t.Errorf("ShortSession = %q, want 4 Zeichen", short) + } + if strings.Contains(token, short) { + t.Errorf("gekürzte ID %q ist ein Präfix des Tokens — muss gehasht sein", short) + } + if ShortSession(token) != short { + t.Error("ShortSession muss deterministisch sein") + } + if ShortSession("") != "" { + t.Error("leerer Token muss leere ID liefern") + } +} +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./internal/audit/ -v` +Expected: FAIL — `undefined: New` + +- [ ] **Step 3: `event.go` schreiben** + +```go +// Package audit schreibt strukturierte Audit-Ereignisse als JSON Lines. +package audit + +import ( + "crypto/sha256" + "encoding/hex" +) + +// Audit-Ereignisse. +const ( + EventLoginSuccess = "login_success" + EventLoginFailed = "login_failed" + EventLogout = "logout" + EventSessionExpired = "session_expired" + EventConfigDownload = "config_download" + EventDownloadDenied = "download_denied" +) + +// Betriebsereignisse. +const ( + EventNoCertFound = "no_cert_found" + EventOPNsenseUnreachable = "opnsense_unreachable" + EventLDAPFailover = "ldap_failover" + EventRateLimited = "rate_limited" + EventStartup = "startup" +) + +// UnknownUser ersetzt den eingegebenen Namen, wenn der Benutzer im AD nicht +// gefunden wurde. Damit landet ein versehentlich ins Username-Feld getipptes +// Passwort nie im Klartext im Log. +const UnknownUser = "" + +// Event ist eine Zeile im Audit-Log. Leere Felder werden nicht serialisiert. +type Event struct { + TS string `json:"ts"` + Event string `json:"event"` + User string `json:"user,omitempty"` + SrcIP string `json:"src_ip,omitempty"` + Session string `json:"session,omitempty"` + Reason string `json:"reason,omitempty"` + VPNInstance string `json:"vpn_instance,omitempty"` + CertCN string `json:"cert_cn,omitempty"` + CertExpiry string `json:"cert_expiry,omitempty"` + Format string `json:"format,omitempty"` + Pattern string `json:"pattern,omitempty"` + Server string `json:"server,omitempty"` + Version string `json:"version,omitempty"` + ConfigSum string `json:"config_sum,omitempty"` + Detail string `json:"detail,omitempty"` +} + +// ShortSession liefert eine kurze, nicht umkehrbare Korrelations-ID. +// Der Session-Token selbst darf niemals ins Log gelangen. +func ShortSession(token string) string { + if token == "" { + return "" + } + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:])[:4] +} +``` + +- [ ] **Step 4: `logger.go` schreiben** + +```go +package audit + +import ( + "encoding/json" + "fmt" + "io" + "sync" + "time" +) + +// Options steuert Rotation und Nebenausgabe. +type Options struct { + MaxSizeMB int + MaxBackups int + Compress bool + // Stdout erhält eine Kopie jeder Zeile (systemd-Journal). Nil = keine Kopie. + Stdout io.Writer + // Clock ist injizierbar für Tests. Nil = time.Now. + Clock func() time.Time +} + +// Logger schreibt Events zeilenweise als JSON. +type Logger struct { + mu sync.Mutex + file *rotatingFile + stdout io.Writer + clock func() time.Time +} + +// New öffnet das Audit-Log (Verzeichnis muss existieren) und liefert einen Logger. +func New(path string, opts Options) (*Logger, error) { + if opts.Clock == nil { + opts.Clock = time.Now + } + rf, err := openRotating(path, opts) + if err != nil { + return nil, err + } + return &Logger{file: rf, stdout: opts.Stdout, clock: opts.Clock}, nil +} + +// Log schreibt ein Ereignis. Fehler beim Schreiben werden nach stdout gemeldet, +// dürfen den Request aber nie scheitern lassen. +func (l *Logger) Log(e Event) { + if e.TS == "" { + e.TS = l.clock().Format(time.RFC3339) + } + line, err := json.Marshal(e) + if err != nil { + return // Event enthält nur Strings; kann praktisch nicht passieren + } + line = append(line, '\n') + + l.mu.Lock() + defer l.mu.Unlock() + if _, err := l.file.Write(line); err != nil && l.stdout != nil { + fmt.Fprintf(l.stdout, "{\"ts\":%q,\"event\":\"audit_write_failed\",\"detail\":%q}\n", + e.TS, err.Error()) + } + if l.stdout != nil { + l.stdout.Write(line) + } +} + +// Reopen schließt die aktuelle Datei und öffnet sie neu (SIGHUP/logrotate). +func (l *Logger) Reopen() error { + l.mu.Lock() + defer l.mu.Unlock() + return l.file.reopen() +} + +func (l *Logger) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + return l.file.Close() +} +``` + +- [ ] **Step 5: Failing test für Rotation schreiben** + +`internal/audit/rotate_test.go`: + +```go +package audit + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRotationCreatesBackupAndTruncates(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.log") + rf, err := openRotating(path, Options{MaxSizeMB: 1, MaxBackups: 2}) + if err != nil { + t.Fatal(err) + } + defer rf.Close() + rf.maxBytes = 200 // Rotationsschwelle für den Test verkleinern + + line := []byte(strings.Repeat("x", 60) + "\n") + for i := 0; i < 10; i++ { + if _, err := rf.Write(line); err != nil { + t.Fatal(err) + } + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var backups int + for _, e := range entries { + if e.Name() != "audit.log" && strings.HasPrefix(e.Name(), "audit.log.") { + backups++ + } + } + if backups == 0 { + t.Fatal("es muss mindestens eine rotierte Datei geben") + } + if backups > 2 { + t.Fatalf("MaxBackups=2 überschritten: %d Backups", backups) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Size() > int64(rf.maxBytes) { + t.Fatalf("aktive Datei ist %d Bytes groß, Schwelle ist %d", info.Size(), rf.maxBytes) + } +} + +func TestRotationCompresses(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.log") + rf, err := openRotating(path, Options{MaxSizeMB: 1, MaxBackups: 3, Compress: true}) + if err != nil { + t.Fatal(err) + } + defer rf.Close() + rf.maxBytes = 100 + + for i := 0; i < 6; i++ { + if _, err := rf.Write([]byte(strings.Repeat("y", 60) + "\n")); err != nil { + t.Fatal(err) + } + } + entries, _ := os.ReadDir(dir) + var gz int + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".gz") { + gz++ + } + } + if gz == 0 { + t.Fatal("bei Compress=true müssen rotierte Dateien .gz sein") + } +} + +func TestReopenRecreatesDeletedFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.log") + rf, err := openRotating(path, Options{MaxSizeMB: 1}) + if err != nil { + t.Fatal(err) + } + defer rf.Close() + + if _, err := rf.Write([]byte("erste\n")); err != nil { + t.Fatal(err) + } + if err := os.Rename(path, path+".moved"); err != nil { + t.Fatal(err) + } + if err := rf.reopen(); err != nil { + t.Fatal(err) + } + if _, err := rf.Write([]byte("zweite\n")); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("nach reopen muss die Datei wieder existieren: %v", err) + } + if string(raw) != "zweite\n" { + t.Errorf("neue Datei enthält %q, want \"zweite\\n\"", raw) + } +} +``` + +- [ ] **Step 6: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./internal/audit/ -run TestRotation -v` +Expected: FAIL — `undefined: openRotating` + +- [ ] **Step 7: `rotate.go` schreiben** + +```go +package audit + +import ( + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// logFileMode: nur der Eigentümer darf lesen und schreiben — das Log enthält +// personenbezogene Daten. +const logFileMode os.FileMode = 0o600 + +// rotatingFile ist ein größenrotierender Writer ohne externe Dependency. +// Nicht selbst gesperrt — der Aufrufer (Logger) hält das Mutex. +type rotatingFile struct { + path string + maxBytes int64 + maxBackups int + compress bool + + f *os.File + size int64 +} + +func openRotating(path string, opts Options) (*rotatingFile, error) { + if opts.MaxSizeMB <= 0 { + opts.MaxSizeMB = 50 + } + rf := &rotatingFile{ + path: path, + maxBytes: int64(opts.MaxSizeMB) * 1024 * 1024, + maxBackups: opts.MaxBackups, + compress: opts.Compress, + } + if err := rf.open(); err != nil { + return nil, err + } + return rf, nil +} + +func (r *rotatingFile) open() error { + if err := os.MkdirAll(filepath.Dir(r.path), 0o750); err != nil { + return fmt.Errorf("Log-Verzeichnis %s: %w", filepath.Dir(r.path), err) + } + f, err := os.OpenFile(r.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, logFileMode) + if err != nil { + return fmt.Errorf("Audit-Log %s: %w", r.path, err) + } + info, err := f.Stat() + if err != nil { + f.Close() + return fmt.Errorf("Audit-Log %s: %w", r.path, err) + } + r.f, r.size = f, info.Size() + return nil +} + +func (r *rotatingFile) Write(p []byte) (int, error) { + if r.size+int64(len(p)) > r.maxBytes && r.size > 0 { + if err := r.rotate(); err != nil { + return 0, err + } + } + n, err := r.f.Write(p) + r.size += int64(n) + return n, err +} + +// reopen schließt und öffnet die Datei neu (SIGHUP nach externem logrotate). +func (r *rotatingFile) reopen() error { + if r.f != nil { + r.f.Close() + } + return r.open() +} + +func (r *rotatingFile) Close() error { + if r.f == nil { + return nil + } + err := r.f.Close() + r.f = nil + return err +} + +func (r *rotatingFile) rotate() error { + if err := r.f.Close(); err != nil { + return err + } + backup := fmt.Sprintf("%s.%s", r.path, time.Now().UTC().Format("20060102T150405.000")) + if err := os.Rename(r.path, backup); err != nil { + return fmt.Errorf("Rotation von %s: %w", r.path, err) + } + if r.compress { + if gzPath, err := gzipFile(backup); err == nil { + os.Remove(backup) + backup = gzPath + } + } + if err := r.open(); err != nil { + return err + } + r.pruneBackups() + return nil +} + +func gzipFile(path string) (string, error) { + in, err := os.Open(path) + if err != nil { + return "", err + } + defer in.Close() + out, err := os.OpenFile(path+".gz", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, logFileMode) + if err != nil { + return "", err + } + defer out.Close() + zw := gzip.NewWriter(out) + if _, err := io.Copy(zw, in); err != nil { + return "", err + } + if err := zw.Close(); err != nil { + return "", err + } + return path + ".gz", nil +} + +// pruneBackups löscht die ältesten rotierten Dateien über maxBackups hinaus. +func (r *rotatingFile) pruneBackups() { + if r.maxBackups <= 0 { + return + } + dir, base := filepath.Split(r.path) + if dir == "" { + dir = "." + } + entries, err := os.ReadDir(dir) + if err != nil { + return + } + var backups []string + for _, e := range entries { + name := e.Name() + if name != base && strings.HasPrefix(name, base+".") { + backups = append(backups, name) + } + } + // Die Zeitstempel im Namen sind lexikografisch sortierbar. + sort.Strings(backups) + for len(backups) > r.maxBackups { + os.Remove(filepath.Join(dir, backups[0])) + backups = backups[1:] + } +} +``` + +- [ ] **Step 8: Tests laufen lassen** + +Run: `go test ./internal/audit/ -v` +Expected: PASS (alle Tests) + +- [ ] **Step 9: Commit** + +```bash +git add internal/audit +git commit -m "feat(audit): JSONL-Logger mit Größenrotation, gzip und SIGHUP-Reopen" +``` + +--- + +## Task 6: Auth-Interface, Identity und AD-Fehlercode-Mapping + +**Files:** +- Create: `internal/auth/auth.go`, `internal/auth/addata.go` +- Test: `internal/auth/addata_test.go` + +**Interfaces:** +- Consumes: `audit`-Reason-Semantik (nur als String-Werte, kein Import) +- Produces: + - `type Identity struct { Username string; Groups []string }` + - `type Authenticator interface { Authenticate(ctx context.Context, username, password string) (*Identity, error) }` + - `type Error struct { Reason string; Err error }` mit `Error() string`, `Unwrap() error` + - Reason-Konstanten: `ReasonInvalidCredentials = "invalid_credentials"`, `ReasonAccountDisabled`, `ReasonAccountLocked`, `ReasonPasswordExpired`, `ReasonPasswordChangeRequired`, `ReasonNotInVPNGroup`, `ReasonUserNotFound`, `ReasonBackendUnavailable` + - `func ReasonFromLDAPError(err error) string` — parst `data 52e` etc. aus der LDAP-Fehlermeldung + - `func (e *Error) UserVisible() bool` — nur `ReasonPasswordExpired` liefert `true` (einzige nicht-generische Meldung) + +- [ ] **Step 1: Failing test schreiben** + +`internal/auth/addata_test.go`: + +```go +package auth + +import ( + "errors" + "testing" +) + +func TestReasonFromLDAPError(t *testing.T) { + // Formulierung wie sie AD in LDAP-Result-Code 49 liefert. + const tmpl = "LDAP Result Code 49 \"Invalid Credentials\": 80090308: LdapErr: " + + "DSID-0C0903A9, comment: AcceptSecurityContext error, data %s, v4563" + + cases := map[string]string{ + "52e": ReasonInvalidCredentials, + "533": ReasonAccountDisabled, + "775": ReasonAccountLocked, + "532": ReasonPasswordExpired, + "773": ReasonPasswordChangeRequired, + "525": ReasonInvalidCredentials, // user not found -> generisch, keine Enumeration + "701": ReasonAccountDisabled, // account expired + "999": ReasonInvalidCredentials, // unbekannter Code -> generisch + } + for code, want := range cases { + err := errors.New(sprintf(tmpl, code)) + if got := ReasonFromLDAPError(err); got != want { + t.Errorf("data %s: got %q, want %q", code, got, want) + } + } +} + +func TestReasonFromLDAPErrorIsCaseInsensitive(t *testing.T) { + err := errors.New("AcceptSecurityContext error, DATA 52E, v4563") + if got := ReasonFromLDAPError(err); got != ReasonInvalidCredentials { + t.Errorf("got %q, want %q", got, ReasonInvalidCredentials) + } +} + +func TestReasonFromLDAPErrorWithoutDataCode(t *testing.T) { + if got := ReasonFromLDAPError(errors.New("connection refused")); got != ReasonBackendUnavailable { + t.Errorf("got %q, want %q", got, ReasonBackendUnavailable) + } + if got := ReasonFromLDAPError(nil); got != "" { + t.Errorf("nil-Fehler muss leeren Reason liefern, got %q", got) + } +} + +func TestErrorUserVisibleOnlyForExpiredPassword(t *testing.T) { + if !(&Error{Reason: ReasonPasswordExpired}).UserVisible() { + t.Error("abgelaufenes Passwort ist die einzige spezifische Meldung") + } + for _, r := range []string{ + ReasonInvalidCredentials, ReasonAccountDisabled, ReasonAccountLocked, + ReasonNotInVPNGroup, ReasonPasswordChangeRequired, ReasonBackendUnavailable, + } { + if (&Error{Reason: r}).UserVisible() { + t.Errorf("Reason %q darf keine spezifische Meldung erzeugen", r) + } + } +} + +func TestErrorUnwraps(t *testing.T) { + inner := errors.New("boom") + err := &Error{Reason: ReasonBackendUnavailable, Err: inner} + if !errors.Is(err, inner) { + t.Error("Error muss den inneren Fehler durchreichen") + } + var authErr *Error + if !errors.As(error(err), &authErr) { + t.Error("errors.As muss *Error finden") + } +} +``` + +Hilfsfunktion am Ende derselben Datei (vermeidet einen `fmt`-Import-Konflikt in Tests): + +```go +func sprintf(format string, a ...any) string { return fmt.Sprintf(format, a...) } +``` + +und `"fmt"` zum Import-Block der Testdatei hinzufügen. + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./internal/auth/ -v` +Expected: FAIL — `undefined: ReasonFromLDAPError` + +- [ ] **Step 3: `auth.go` schreiben** + +```go +// Package auth kapselt die Benutzerauthentifizierung hinter einem schmalen +// Interface, damit später weitere Backends (OIDC/Entra ID) ergänzt werden können. +package auth + +import ( + "context" + "fmt" +) + +// Identity ist die kanonische Identität eines authentifizierten Benutzers. +type Identity struct { + // Username ist der aus dem Verzeichnis gelesene sAMAccountName in + // Kleinschreibung — niemals die Benutzereingabe. + Username string + // Groups enthält die Gruppen, die für Rollenentscheidungen relevant sind. + // v1 füllt hier nur die VPN-Gruppe; das Feld hält den Weg zu einer + // späteren VPN-Portal-Admins-Gruppe offen. + Groups []string +} + +// HasGroup prüft Gruppenzugehörigkeit ohne Rücksicht auf Groß-/Kleinschreibung. +func (i *Identity) HasGroup(name string) bool { + for _, g := range i.Groups { + if strings.EqualFold(g, name) { + return true + } + } + return false +} + +// Authenticator prüft Zugangsdaten und liefert eine kanonische Identität. +type Authenticator interface { + Authenticate(ctx context.Context, username, password string) (*Identity, error) +} + +// Reason-Codes landen unverändert im Audit-Log. +const ( + ReasonInvalidCredentials = "invalid_credentials" + ReasonAccountDisabled = "account_disabled" + ReasonAccountLocked = "account_locked" + ReasonPasswordExpired = "password_expired" + ReasonPasswordChangeRequired = "password_change_required" + ReasonNotInVPNGroup = "not_in_vpn_group" + ReasonUserNotFound = "user_not_found" + ReasonBackendUnavailable = "backend_unavailable" +) + +// Error trägt den Audit-Reason und den technischen Ursprungsfehler. +type Error struct { + Reason string + Err error +} + +func (e *Error) Error() string { + if e.Err == nil { + return e.Reason + } + return fmt.Sprintf("%s: %v", e.Reason, e.Err) +} + +func (e *Error) Unwrap() error { return e.Err } + +// UserVisible meldet, ob dem Benutzer eine spezifische statt der generischen +// Fehlermeldung gezeigt werden darf. Nur das abgelaufene Passwort ist eine +// Ausnahme — alles andere wäre ein Enumerationsorakel. +func (e *Error) UserVisible() bool { return e.Reason == ReasonPasswordExpired } +``` + +`"strings"` gehört in den Import-Block. + +- [ ] **Step 4: `addata.go` schreiben** + +```go +package auth + +import ( + "regexp" + "strings" +) + +// dataCodeRe findet den AD-spezifischen data-Code im Text eines +// LDAP-Result-Code-49-Fehlers, z. B. "... error, data 52e, v4563". +var dataCodeRe = regexp.MustCompile(`(?i)\bdata\s+([0-9a-f]{3,4})\b`) + +// dataCodeReasons bildet AD-data-Codes auf Audit-Reasons ab. +// 525 (kein solcher User) wird bewusst wie ein falsches Passwort behandelt, +// damit die Fehlermeldung keine Benutzerexistenz verrät. +var dataCodeReasons = map[string]string{ + "525": ReasonInvalidCredentials, + "52e": ReasonInvalidCredentials, + "530": ReasonInvalidCredentials, // Anmeldung außerhalb erlaubter Zeiten + "531": ReasonInvalidCredentials, // Anmeldung an dieser Station nicht erlaubt + "532": ReasonPasswordExpired, + "533": ReasonAccountDisabled, + "701": ReasonAccountDisabled, // Konto abgelaufen + "773": ReasonPasswordChangeRequired, + "775": ReasonAccountLocked, +} + +// ReasonFromLDAPError leitet den Audit-Reason aus einem LDAP-Bind-Fehler ab. +// Fehler ohne erkennbaren data-Code gelten als Backend-Problem, nicht als +// falsches Passwort — sonst würde ein DC-Ausfall als Fehlanmeldung gezählt. +func ReasonFromLDAPError(err error) string { + if err == nil { + return "" + } + msg := err.Error() + if m := dataCodeRe.FindStringSubmatch(msg); m != nil { + if reason, ok := dataCodeReasons[strings.ToLower(m[1])]; ok { + return reason + } + // data-Code vorhanden, aber unbekannt: es war eine echte Ablehnung. + return ReasonInvalidCredentials + } + if strings.Contains(msg, "Result Code 49") || strings.Contains(msg, "Invalid Credentials") { + return ReasonInvalidCredentials + } + return ReasonBackendUnavailable +} +``` + +- [ ] **Step 5: Tests laufen lassen** + +Run: `go test ./internal/auth/ -v` +Expected: PASS (5 Tests) + +- [ ] **Step 6: Commit** + +```bash +git add internal/auth +git commit -m "feat(auth): Authenticator-Interface, Identity und AD-Fehlercode-Mapping" +``` + +--- + +## Task 7: AD-Authenticator mit Zwei-Schritt-Bind, Failover und Gruppenprüfung + +**Files:** +- Create: `internal/auth/ad.go`, `internal/auth/dial.go` +- Test: `internal/auth/ad_test.go` +- Modify: `go.mod` (Dependency `github.com/go-ldap/ldap/v3`) + +**Interfaces:** +- Consumes: `Identity`, `Error`, Reason-Konstanten, `ReasonFromLDAPError` (Task 6) +- Produces: + - `type conn interface { Bind(dn, pw string) error; Search(*ldap.SearchRequest) (*ldap.SearchResult, error); Close() error }` — package-privat, macht den Authenticator ohne LDAP-Server testbar + - `type Options struct { Servers []string; Port int; TLSMode string; BaseDN, BindUser, BindPassword, VPNGroup, CAFile string; Timeout time.Duration; Dial func(ctx context.Context, server string) (conn, error); OnFailover func(server string, err error) }` + - `type AD struct { ... }`; `func NewAD(opts Options) (*AD, error)` + - `func (a *AD) Authenticate(ctx context.Context, username, password string) (*Identity, error)` — erfüllt `Authenticator` + - `func (a *AD) ResolveGroupDN(ctx context.Context) (string, error)` — beim Start aufgerufen, Ergebnis gecacht + - `func (a *AD) Lookup(ctx context.Context, username string) (*LookupResult, error)`; `type LookupResult struct { DN, SAMAccountName string; InVPNGroup bool }` — für `check --test-auth` + - `func (a *AD) GroupDN() string` + - Konstante `MatchingRuleInChain = "1.2.840.113556.1.4.1941"` + +- [ ] **Step 1: Dependency hinzufügen** + +```bash +go get github.com/go-ldap/ldap/v3@latest +``` + +- [ ] **Step 2: Failing test schreiben** + +`internal/auth/ad_test.go`: + +```go +package auth + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/go-ldap/ldap/v3" +) + +// fakeConn ist ein LDAP-Server-Ersatz. bindErr bildet Bind-Ergebnisse pro +// DN ab; searches protokolliert alle Filter für Injection-Assertions. +type fakeConn struct { + server string + bindErr map[string]error + entries map[string][]*ldap.Entry // Filter-Substring -> Ergebnis + searches []string + closed bool + dialErr error +} + +func (f *fakeConn) Bind(dn, pw string) error { + if err, ok := f.bindErr[dn+"|"+pw]; ok { + return err + } + if err, ok := f.bindErr[dn]; ok { + return err + } + return nil +} + +func (f *fakeConn) Search(req *ldap.SearchRequest) (*ldap.SearchResult, error) { + f.searches = append(f.searches, req.Filter) + for needle, entries := range f.entries { + if strings.Contains(req.Filter, needle) { + return &ldap.SearchResult{Entries: entries}, nil + } + } + return &ldap.SearchResult{}, nil +} + +func (f *fakeConn) Close() error { f.closed = true; return nil } + +func entry(dn string, attrs map[string][]string) *ldap.Entry { + e := &ldap.Entry{DN: dn} + for name, vals := range attrs { + e.Attributes = append(e.Attributes, &ldap.EntryAttribute{Name: name, Values: vals}) + } + return e +} + +const ( + userDN = "CN=Max Mueller,OU=Users,DC=firma,DC=local" + groupDN = "CN=VPN-Users,OU=Groups,DC=firma,DC=local" +) + +// stdEntries liefert die Standardantworten: Gruppen-Auflösung, User-Suche, +// Mitgliedschaftsprüfung. +func stdEntries(inGroup bool) map[string][]*ldap.Entry { + m := map[string][]*ldap.Entry{ + "objectClass=group": {entry(groupDN, nil)}, + "sAMAccountName=mmueller)(userPrincipalName": { + entry(userDN, map[string][]string{"sAMAccountName": {"MMueller"}}), + }, + } + if inGroup { + m["1.2.840.113556.1.4.1941"] = []*ldap.Entry{entry(userDN, nil)} + } + return m +} + +func newAD(t *testing.T, conns ...*fakeConn) (*AD, []*fakeConn) { + t.Helper() + i := 0 + opts := Options{ + Servers: []string{"dc01.firma.local", "dc02.firma.local"}, + Port: 636, + TLSMode: "ldaps", + BaseDN: "DC=firma,DC=local", + BindUser: "svc@firma.local", + BindPassword: "svc-pw", + VPNGroup: "VPN-Users", + Timeout: time.Second, + Dial: func(ctx context.Context, server string) (conn, error) { + c := conns[min(i, len(conns)-1)] + i++ + if c.dialErr != nil { + return nil, c.dialErr + } + return c, nil + }, + } + a, err := NewAD(opts) + if err != nil { + t.Fatalf("NewAD: %v", err) + } + return a, conns +} + +func TestAuthenticateSuccessCanonicalisesUsername(t *testing.T) { + c := &fakeConn{server: "dc01", entries: stdEntries(true)} + a, _ := newAD(t, c) + + id, err := a.Authenticate(context.Background(), " MMueller ", "geheim") + if err != nil { + t.Fatalf("Authenticate: %v", err) + } + // Kanonisch = sAMAccountName aus dem Verzeichnis, lowercase. + if id.Username != "mmueller" { + t.Errorf("Username = %q, want mmueller", id.Username) + } + if !id.HasGroup("VPN-Users") { + t.Errorf("Groups = %v, muss die VPN-Gruppe enthalten", id.Groups) + } +} + +func TestAuthenticateRejectsNonMember(t *testing.T) { + c := &fakeConn{entries: stdEntries(false)} + a, _ := newAD(t, c) + + _, err := a.Authenticate(context.Background(), "mmueller", "geheim") + var ae *Error + if !errors.As(err, &ae) || ae.Reason != ReasonNotInVPNGroup { + t.Fatalf("err = %v, want Reason %q", err, ReasonNotInVPNGroup) + } +} + +func TestAuthenticateMapsBindFailure(t *testing.T) { + c := &fakeConn{entries: stdEntries(true)} + c.bindErr = map[string]error{ + userDN + "|falsch": errors.New( + "LDAP Result Code 49 \"Invalid Credentials\": AcceptSecurityContext error, data 532, v4563"), + } + a, _ := newAD(t, c) + + _, err := a.Authenticate(context.Background(), "mmueller", "falsch") + var ae *Error + if !errors.As(err, &ae) || ae.Reason != ReasonPasswordExpired { + t.Fatalf("err = %v, want Reason %q", err, ReasonPasswordExpired) + } +} + +func TestAuthenticateUnknownUserReportsGenericReason(t *testing.T) { + c := &fakeConn{entries: map[string][]*ldap.Entry{"objectClass=group": {entry(groupDN, nil)}}} + a, _ := newAD(t, c) + + _, err := a.Authenticate(context.Background(), "gibtsnicht", "egal") + var ae *Error + if !errors.As(err, &ae) || ae.Reason != ReasonUserNotFound { + t.Fatalf("err = %v, want Reason %q", err, ReasonUserNotFound) + } + if ae.UserVisible() { + t.Error("unbekannter Benutzer darf keine spezifische Meldung erzeugen") + } +} + +func TestAuthenticateRejectsEmptyPassword(t *testing.T) { + c := &fakeConn{entries: stdEntries(true)} + a, _ := newAD(t, c) + // Leeres Passwort wäre ein anonymer Bind und würde fälschlich gelingen. + _, err := a.Authenticate(context.Background(), "mmueller", "") + var ae *Error + if !errors.As(err, &ae) || ae.Reason != ReasonInvalidCredentials { + t.Fatalf("err = %v, want Reason %q", err, ReasonInvalidCredentials) + } + for _, f := range c.searches { + if strings.Contains(f, "mmueller") { + t.Error("bei leerem Passwort darf gar keine Suche stattfinden") + } + } +} + +func TestAuthenticateEscapesFilterInput(t *testing.T) { + c := &fakeConn{entries: stdEntries(true)} + a, _ := newAD(t, c) + + _, _ = a.Authenticate(context.Background(), "evil)(objectClass=*", "pw") + for _, f := range c.searches { + if strings.Contains(f, "evil)(objectClass=*") { + t.Fatalf("unescapte Benutzereingabe im Filter: %q", f) + } + } + var found bool + for _, f := range c.searches { + if strings.Contains(f, `\29\28`) { // ")(" escaped + found = true + } + } + if !found { + t.Errorf("Eingabe wurde nicht escapt; Filter: %v", c.searches) + } +} + +func TestFailoverToSecondDC(t *testing.T) { + dead := &fakeConn{dialErr: errors.New("connection refused")} + alive := &fakeConn{entries: stdEntries(true)} + var failedOver []string + i := 0 + conns := []*fakeConn{dead, alive} + a, err := NewAD(Options{ + Servers: []string{"dc01.firma.local", "dc02.firma.local"}, + Port: 636, + TLSMode: "ldaps", + BaseDN: "DC=firma,DC=local", + BindUser: "svc@firma.local", + BindPassword: "svc-pw", + VPNGroup: "VPN-Users", + Timeout: time.Second, + OnFailover: func(server string, err error) { failedOver = append(failedOver, server) }, + Dial: func(ctx context.Context, server string) (conn, error) { + c := conns[min(i, len(conns)-1)] + i++ + if c.dialErr != nil { + return nil, c.dialErr + } + return c, nil + }, + }) + if err != nil { + t.Fatal(err) + } + if _, err := a.Authenticate(context.Background(), "mmueller", "geheim"); err != nil { + t.Fatalf("Failover muss gelingen: %v", err) + } + if len(failedOver) == 0 || failedOver[0] != "dc01.firma.local" { + t.Errorf("OnFailover = %v, want dc01.firma.local", failedOver) + } +} + +func TestAllDCsDownIsBackendUnavailable(t *testing.T) { + dead := &fakeConn{dialErr: errors.New("connection refused")} + a, _ := newAD(t, dead) + _, err := a.Authenticate(context.Background(), "mmueller", "geheim") + var ae *Error + if !errors.As(err, &ae) || ae.Reason != ReasonBackendUnavailable { + t.Fatalf("err = %v, want Reason %q", err, ReasonBackendUnavailable) + } +} + +func TestLookupForTestAuth(t *testing.T) { + c := &fakeConn{entries: stdEntries(true)} + a, _ := newAD(t, c) + res, err := a.Lookup(context.Background(), "MMueller") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if res.DN != userDN || res.SAMAccountName != "mmueller" || !res.InVPNGroup { + t.Errorf("Lookup = %+v", res) + } +} + +func TestConnectionsAreAlwaysClosed(t *testing.T) { + c := &fakeConn{entries: stdEntries(true)} + a, _ := newAD(t, c) + if _, err := a.Authenticate(context.Background(), "mmueller", "geheim"); err != nil { + t.Fatal(err) + } + if !c.closed { + t.Error("Verbindung muss geschlossen werden") + } +} +``` + +- [ ] **Step 3: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./internal/auth/ -run 'TestAuthenticate|TestFailover|TestLookup|TestAll|TestConnections' -v` +Expected: FAIL — `undefined: NewAD` + +- [ ] **Step 4: `dial.go` schreiben** + +```go +package auth + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "net" + "os" + "strconv" + "time" + + "github.com/go-ldap/ldap/v3" +) + +// conn ist der Ausschnitt der LDAP-Verbindung, den der Authenticator braucht. +// Das Interface existiert, damit Tests ohne echten Verzeichnisdienst laufen. +type conn interface { + Bind(username, password string) error + Search(req *ldap.SearchRequest) (*ldap.SearchResult, error) + Close() error +} + +// ldapConn adaptiert *ldap.Conn an conn (Close hat dort keine Fehlerrückgabe). +type ldapConn struct{ *ldap.Conn } + +func (c ldapConn) Close() error { c.Conn.Close(); return nil } + +// tlsConfigFor baut die TLS-Konfiguration; Verifikation ist immer aktiv. +func tlsConfigFor(server, caFile string) (*tls.Config, error) { + cfg := &tls.Config{ServerName: server, MinVersion: tls.VersionTLS12} + if caFile == "" { + return cfg, nil + } + pem, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("ad.ca_file %s: %w", caFile, err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("ad.ca_file %s enthält kein gültiges PEM-Zertifikat", caFile) + } + cfg.RootCAs = pool + return cfg, nil +} + +// realDialer erzeugt die Dial-Funktion für den Produktivbetrieb. +func realDialer(port int, tlsMode, caFile string, timeout time.Duration) func(context.Context, string) (conn, error) { + return func(ctx context.Context, server string) (conn, error) { + tlsCfg, err := tlsConfigFor(server, caFile) + if err != nil { + return nil, err + } + addr := net.JoinHostPort(server, strconv.Itoa(port)) + dialer := &net.Dialer{Timeout: timeout} + + var c *ldap.Conn + if tlsMode == "starttls" { + c, err = ldap.DialURL("ldap://"+addr, ldap.DialWithDialer(dialer)) + if err != nil { + return nil, fmt.Errorf("Verbindung zu %s: %w", addr, err) + } + if err := c.StartTLS(tlsCfg); err != nil { + c.Close() + return nil, fmt.Errorf("StartTLS zu %s: %w", addr, err) + } + } else { + c, err = ldap.DialURL("ldaps://"+addr, + ldap.DialWithDialer(dialer), ldap.DialWithTLSConfig(tlsCfg)) + if err != nil { + return nil, fmt.Errorf("LDAPS-Verbindung zu %s: %w", addr, err) + } + } + c.SetTimeout(timeout) + return ldapConn{c}, nil + } +} +``` + +- [ ] **Step 5: `ad.go` schreiben** + +```go +package auth + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/go-ldap/ldap/v3" +) + +// MatchingRuleInChain ist die AD-Erweiterung LDAP_MATCHING_RULE_IN_CHAIN. +// Sie löst verschachtelte Gruppenmitgliedschaften in einer einzigen Abfrage auf. +const MatchingRuleInChain = "1.2.840.113556.1.4.1941" + +const defaultTimeout = 8 * time.Second + +// Options konfiguriert den AD-Authenticator. +type Options struct { + Servers []string + Port int + TLSMode string // "ldaps" oder "starttls" + BaseDN string + BindUser string + BindPassword string + VPNGroup string + CAFile string + Timeout time.Duration + + // Dial ist injizierbar; nil = echte LDAP-Verbindung. + Dial func(ctx context.Context, server string) (conn, error) + // OnFailover wird gerufen, wenn ein DC übersprungen wird. + OnFailover func(server string, err error) +} + +// AD authentifiziert gegen Active Directory. +type AD struct { + opts Options + + mu sync.RWMutex + groupDN string +} + +// NewAD prüft die Optionen und liefert den Authenticator. +// Der Gruppen-DN wird erst durch ResolveGroupDN aufgelöst und danach gecacht. +func NewAD(opts Options) (*AD, error) { + if len(opts.Servers) == 0 { + return nil, errors.New("ad: keine Domain Controller konfiguriert") + } + if opts.BindUser == "" || opts.BindPassword == "" { + return nil, errors.New("ad: Service-Account (bind_user/bind_password) fehlt") + } + if opts.BaseDN == "" { + return nil, errors.New("ad: Base-DN ist leer") + } + if opts.VPNGroup == "" { + return nil, errors.New("ad: vpn_group fehlt") + } + if opts.Timeout <= 0 { + opts.Timeout = defaultTimeout + } + if opts.Port == 0 { + opts.Port = 636 + } + if opts.Dial == nil { + opts.Dial = realDialer(opts.Port, opts.TLSMode, opts.CAFile, opts.Timeout) + } + return &AD{opts: opts}, nil +} + +// GroupDN liefert den zwischengespeicherten Gruppen-DN. +func (a *AD) GroupDN() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.groupDN +} + +// withConn verbindet sich zum ersten erreichbaren DC, bindet den +// Service-Account und ruft fn. Der Fehler des letzten DC wird durchgereicht. +func (a *AD) withConn(ctx context.Context, fn func(c conn) error) error { + var lastErr error + for _, server := range a.opts.Servers { + c, err := a.opts.Dial(ctx, server) + if err == nil { + err = c.Bind(a.opts.BindUser, a.opts.BindPassword) + if err != nil { + c.Close() + // Falsches Service-Passwort ist ein Konfigurationsfehler und + // wird nicht durch Failover kaschiert. + return fmt.Errorf("Service-Bind an %s fehlgeschlagen: %w", server, err) + } + defer c.Close() + return fn(c) + } + lastErr = fmt.Errorf("%s: %w", server, err) + if a.opts.OnFailover != nil { + a.opts.OnFailover(server, err) + } + } + return fmt.Errorf("kein Domain Controller erreichbar: %w", lastErr) +} + +// searchOne führt eine Suche aus und liefert höchstens einen Eintrag. +func searchOne(c conn, baseDN, filter string, attrs []string, timeout time.Duration) (*ldap.Entry, error) { + req := ldap.NewSearchRequest( + baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, + 2, int(timeout.Seconds()), false, filter, attrs, nil) + res, err := c.Search(req) + if err != nil { + // Referrals in Single-Domain-Umgebungen ignorieren. + if ldap.IsErrorWithCode(err, ldap.LDAPResultReferral) { + return nil, nil + } + return nil, err + } + if len(res.Entries) == 0 { + return nil, nil + } + return res.Entries[0], nil +} + +// resolveGroupDN sucht den DN der VPN-Gruppe über ihren sAMAccountName oder CN. +func (a *AD) resolveGroupDN(c conn) (string, error) { + name := ldap.EscapeFilter(a.opts.VPNGroup) + filter := fmt.Sprintf("(&(objectClass=group)(|(sAMAccountName=%s)(cn=%s)))", name, name) + e, err := searchOne(c, a.opts.BaseDN, filter, []string{"distinguishedName"}, a.opts.Timeout) + if err != nil { + return "", fmt.Errorf("Suche nach Gruppe %q: %w", a.opts.VPNGroup, err) + } + if e == nil { + return "", fmt.Errorf("Gruppe %q wurde unter %s nicht gefunden", a.opts.VPNGroup, a.opts.BaseDN) + } + return e.DN, nil +} + +// ResolveGroupDN löst den Gruppen-DN auf und legt ihn im Cache ab. +// Wird beim Start und von check aufgerufen. +func (a *AD) ResolveGroupDN(ctx context.Context) (string, error) { + var dn string + err := a.withConn(ctx, func(c conn) error { + var err error + dn, err = a.resolveGroupDN(c) + return err + }) + if err != nil { + return "", err + } + a.mu.Lock() + a.groupDN = dn + a.mu.Unlock() + return dn, nil +} + +// groupDNOrResolve liefert den gecachten DN oder löst ihn nach. +func (a *AD) groupDNOrResolve(c conn) (string, error) { + if dn := a.GroupDN(); dn != "" { + return dn, nil + } + dn, err := a.resolveGroupDN(c) + if err != nil { + return "", err + } + a.mu.Lock() + a.groupDN = dn + a.mu.Unlock() + return dn, nil +} + +// findUser sucht den Benutzer über sAMAccountName oder userPrincipalName. +func (a *AD) findUser(c conn, input string) (*ldap.Entry, error) { + esc := ldap.EscapeFilter(input) + filter := fmt.Sprintf("(&(objectClass=user)(|(sAMAccountName=%s)(userPrincipalName=%s)))", esc, esc) + return searchOne(c, a.opts.BaseDN, filter, []string{"sAMAccountName", "distinguishedName"}, a.opts.Timeout) +} + +// inGroup prüft die Mitgliedschaft inkl. verschachtelter Gruppen. +func (a *AD) inGroup(c conn, sam, groupDN string) (bool, error) { + filter := fmt.Sprintf("(&(sAMAccountName=%s)(memberOf:%s:=%s))", + ldap.EscapeFilter(sam), MatchingRuleInChain, ldap.EscapeFilter(groupDN)) + e, err := searchOne(c, a.opts.BaseDN, filter, []string{"distinguishedName"}, a.opts.Timeout) + if err != nil { + return false, fmt.Errorf("Gruppenprüfung für %q: %w", sam, err) + } + return e != nil, nil +} + +// canonicalName liest sAMAccountName aus dem Verzeichnis, niemals aus der Eingabe. +func canonicalName(e *ldap.Entry) string { + return strings.ToLower(strings.TrimSpace(e.GetAttributeValue("sAMAccountName"))) +} + +// Authenticate führt den Zwei-Schritt-Bind aus und prüft die Gruppenmitgliedschaft. +func (a *AD) Authenticate(ctx context.Context, username, password string) (*Identity, error) { + username = strings.TrimSpace(username) + // Ein leeres Passwort würde als anonymer Bind durchgehen — hart ablehnen, + // bevor überhaupt gesucht wird. + if username == "" || password == "" { + return nil, &Error{Reason: ReasonInvalidCredentials, + Err: errors.New("Benutzername oder Passwort war leer")} + } + + var id *Identity + err := a.withConn(ctx, func(c conn) error { + groupDN, err := a.groupDNOrResolve(c) + if err != nil { + return err + } + userEntry, err := a.findUser(c, username) + if err != nil { + return err + } + if userEntry == nil { + return &Error{Reason: ReasonUserNotFound, + Err: errors.New("kein Verzeichniseintrag zur Eingabe gefunden")} + } + + // Schritt 2: Bind mit dem gefundenen DN und dem eingegebenen Passwort. + // Dafür wird eine eigene Verbindung benutzt, damit die + // Service-Verbindung gebunden bleibt. + if err := a.bindAs(ctx, userEntry.DN, password); err != nil { + return &Error{Reason: ReasonFromLDAPError(err), Err: err} + } + + sam := canonicalName(userEntry) + if sam == "" { + return &Error{Reason: ReasonUserNotFound, + Err: fmt.Errorf("Eintrag %s hat keinen sAMAccountName", userEntry.DN)} + } + + ok, err := a.inGroup(c, sam, groupDN) + if err != nil { + return err + } + if !ok { + return &Error{Reason: ReasonNotInVPNGroup, + Err: fmt.Errorf("%s ist nicht Mitglied von %s", sam, a.opts.VPNGroup)} + } + id = &Identity{Username: sam, Groups: []string{a.opts.VPNGroup}} + return nil + }) + if err != nil { + var ae *Error + if errors.As(err, &ae) { + return nil, ae + } + return nil, &Error{Reason: ReasonBackendUnavailable, Err: err} + } + return id, nil +} + +// bindAs öffnet eine frische Verbindung und bindet als der angegebene DN. +func (a *AD) bindAs(ctx context.Context, dn, password string) error { + var lastErr error + for _, server := range a.opts.Servers { + c, err := a.opts.Dial(ctx, server) + if err != nil { + lastErr = err + if a.opts.OnFailover != nil { + a.opts.OnFailover(server, err) + } + continue + } + err = c.Bind(dn, password) + c.Close() + return err + } + return fmt.Errorf("kein Domain Controller für Benutzer-Bind erreichbar: %w", lastErr) +} + +// LookupResult ist das Ergebnis von check --test-auth. +type LookupResult struct { + DN string + SAMAccountName string + InVPNGroup bool +} + +// Lookup spielt Suche und Gruppenprüfung ohne Passwort-Bind durch. +func (a *AD) Lookup(ctx context.Context, username string) (*LookupResult, error) { + var res *LookupResult + err := a.withConn(ctx, func(c conn) error { + groupDN, err := a.groupDNOrResolve(c) + if err != nil { + return err + } + e, err := a.findUser(c, strings.TrimSpace(username)) + if err != nil { + return err + } + if e == nil { + return fmt.Errorf("Benutzer %q wurde unter %s nicht gefunden", username, a.opts.BaseDN) + } + sam := canonicalName(e) + ok, err := a.inGroup(c, sam, groupDN) + if err != nil { + return err + } + res = &LookupResult{DN: e.DN, SAMAccountName: sam, InVPNGroup: ok} + return nil + }) + return res, err +} +``` + +- [ ] **Step 6: Compile-Time-Check ergänzen** + +Am Ende von `ad.go`: + +```go +var _ Authenticator = (*AD)(nil) +``` + +- [ ] **Step 7: Tests laufen lassen** + +Run: `go test ./internal/auth/ -v` +Expected: PASS (alle Tests aus Task 6 und 7) + +Hinweis für den Fall, dass `TestAuthenticateEscapesFilterInput` fehlschlägt: `ldap.EscapeFilter` kodiert `(`, `)` und `*` als `\28`, `\29`, `\2a`. Der Test prüft auf `\29\28` (die Folge `)(`). Sollte die Bibliothek Großbuchstaben liefern, ist die Assertion auf `strings.ToLower(f)` anzuwenden — der Escape-Mechanismus selbst bleibt unverändert. + +- [ ] **Step 8: Commit** + +```bash +git add go.mod go.sum internal/auth +git commit -m "feat(auth): AD-Authenticator mit Zwei-Schritt-Bind, DC-Failover und verschachtelter Gruppenprüfung" +``` + +--- + +## Task 8: OPNsense-Export-Client + +**Files:** +- Create: `internal/opnsense/client.go`, `internal/opnsense/types.go`, `internal/opnsense/export.go` +- Create: `docs/opnsense-api.md` +- Test: `internal/opnsense/client_test.go`, `internal/opnsense/types_test.go` +- Test (build-tagged, läuft nicht in CI): `internal/opnsense/integration_test.go` + +**Interfaces:** +- Consumes: nichts +- Produces: + - `type Provider struct { VPNID, Name string }` + - `type Account struct { CommonName, Description, RefID string; ValidTo time.Time; Revoked bool }` + - `type Client struct { ... }`; `func New(opts Options) (*Client, error)`; `type Options struct { BaseURL, APIKey, APISecret, CAFile string; InsecureSkipVerify bool; Timeout time.Duration; HTTPClient *http.Client }` + - `func (c *Client) Providers(ctx context.Context) ([]Provider, error)` — Ergebnis wird vom Aufrufer gecacht, nicht hier + - `func (c *Client) Accounts(ctx context.Context, vpnID string) ([]Account, error)` — **nie** gecacht + - `func (c *Client) Export(ctx context.Context, vpnID, certRefID, format string) (*ExportResult, error)`; `type ExportResult struct { Filename string; ContentType string; Body io.ReadCloser }` — Aufrufer schließt `Body` und streamt + - `func (c *Client) Ping(ctx context.Context) (serverTime time.Time, err error)` — für `check` + - `var ErrUnauthorized`, `var ErrForbidden`, `var ErrUnreachable` + - Formate: `FormatOVPN = "ovpn"` (inline `.ovpn`), `FormatViscosity = "viscosity"` + +- [ ] **Step 1: Failing test für tolerantes Decoding schreiben** + +`internal/opnsense/types_test.go`: + +```go +package opnsense + +import ( + "encoding/json" + "testing" + "time" +) + +func TestFlexBoolAcceptsAPIVariants(t *testing.T) { + cases := map[string]bool{ + `true`: true, `false`: false, + `1`: true, `0`: false, + `"1"`: true, `"0"`: false, + `"true"`: true, `"false"`: false, + `"yes"`: true, `"no"`: false, + `""`: false, `null`: false, + } + for raw, want := range cases { + var b flexBool + if err := json.Unmarshal([]byte(raw), &b); err != nil { + t.Errorf("Unmarshal(%s): %v", raw, err) + continue + } + if bool(b) != want { + t.Errorf("Unmarshal(%s) = %v, want %v", raw, bool(b), want) + } + } +} + +func TestFlexTimeAcceptsAPIVariants(t *testing.T) { + want := time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC) + for _, raw := range []string{ + `"2027-03-01"`, + `"2027-03-01T00:00:00Z"`, + `"Mar 1 00:00:00 2027 GMT"`, + } { + var ft flexTime + if err := json.Unmarshal([]byte(raw), &ft); err != nil { + t.Errorf("Unmarshal(%s): %v", raw, err) + continue + } + if !ft.Time.Equal(want) { + t.Errorf("Unmarshal(%s) = %v, want %v", raw, ft.Time, want) + } + } + var empty flexTime + if err := json.Unmarshal([]byte(`""`), &empty); err != nil || !empty.Time.IsZero() { + t.Errorf("leerer String muss Nullzeit ergeben, got %v (%v)", empty.Time, err) + } +} + +func TestAccountIsUsable(t *testing.T) { + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + valid := Account{CommonName: "mmueller", ValidTo: now.AddDate(0, 6, 0)} + if !valid.IsUsable(now) { + t.Error("gültiges Zertifikat muss nutzbar sein") + } + revoked := valid + revoked.Revoked = true + if revoked.IsUsable(now) { + t.Error("revoziertes Zertifikat darf nie nutzbar sein") + } + expired := Account{CommonName: "mmueller", ValidTo: now.AddDate(0, 0, -1)} + if expired.IsUsable(now) { + t.Error("abgelaufenes Zertifikat darf nie nutzbar sein") + } + // Fehlendes Ablaufdatum: als unbekannt behandeln, aber nicht blockieren — + // die Revocation-Prüfung bleibt maßgeblich. + noDate := Account{CommonName: "mmueller"} + if !noDate.IsUsable(now) { + t.Error("fehlendes Ablaufdatum darf nicht zum Ausschluss führen") + } +} +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./internal/opnsense/ -v` +Expected: FAIL — `undefined: flexBool` + +- [ ] **Step 3: `types.go` schreiben** + +```go +// Package opnsense spricht die read-only Export-API des Plugins +// os-openvpn-client-export an. +package opnsense + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +// Exportformate, die das Portal anbietet. Alle weiteren Einstellungen kommen +// aus der Firewall-Konfiguration — das Portal überschreibt nichts. +const ( + FormatOVPN = "ovpn" + FormatViscosity = "viscosity" +) + +// flexBool akzeptiert die verschiedenen Boolean-Darstellungen der API +// (true, 1, "1", "yes"). Die exakte Variante ist versionsabhängig. +type flexBool bool + +func (b *flexBool) UnmarshalJSON(data []byte) error { + s := strings.Trim(strings.TrimSpace(string(data)), `"`) + switch strings.ToLower(s) { + case "1", "true", "yes", "on": + *b = true + case "", "0", "false", "no", "off", "null": + *b = false + default: + return fmt.Errorf("unerwarteter Boolean-Wert %q", s) + } + return nil +} + +// flexTimeLayouts deckt die von OPNsense beobachteten Datumsformate ab. +var flexTimeLayouts = []string{ + time.RFC3339, + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + "2006-01-02", + "Jan _2 15:04:05 2006 MST", + "060102150405Z", // ASN.1 UTCTime +} + +// flexTime parst Ablaufdaten in mehreren Formaten. +type flexTime struct{ Time time.Time } + +func (t *flexTime) UnmarshalJSON(data []byte) error { + var raw any + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + switch v := raw.(type) { + case nil: + return nil + case float64: // Unix-Zeitstempel + t.Time = time.Unix(int64(v), 0).UTC() + return nil + case string: + s := strings.TrimSpace(v) + if s == "" { + return nil + } + for _, layout := range flexTimeLayouts { + if parsed, err := time.Parse(layout, s); err == nil { + t.Time = parsed.UTC() + return nil + } + } + return fmt.Errorf("unbekanntes Datumsformat %q", s) + default: + return fmt.Errorf("unerwarteter Datumstyp %T", raw) + } +} + +// Provider ist eine exportierbare OpenVPN-Instanz der Firewall. +type Provider struct { + VPNID string + Name string +} + +// Account ist ein exportierbares Client-Zertifikat. +type Account struct { + RefID string + CommonName string + Description string + ValidTo time.Time + Revoked bool +} + +// IsUsable meldet, ob das Zertifikat ausgeliefert werden darf. +// Ein fehlendes Ablaufdatum blockiert nicht — die Revocation-Prüfung greift +// weiterhin, und ein unbekanntes Datum darf keine gültige Config verhindern. +func (a Account) IsUsable(now time.Time) bool { + if a.Revoked { + return false + } + if !a.ValidTo.IsZero() && !a.ValidTo.After(now) { + return false + } + return true +} + +// DaysUntilExpiry liefert die Restlaufzeit in Tagen; -1 bei unbekanntem Datum. +func (a Account) DaysUntilExpiry(now time.Time) int { + if a.ValidTo.IsZero() { + return -1 + } + return int(a.ValidTo.Sub(now).Hours() / 24) +} + +// rawProvider und rawAccount bilden die API-Antworten ab. Mehrere Feldnamen +// werden akzeptiert, weil die Benennung zwischen OPNsense-Versionen wechselt +// (siehe docs/opnsense-api.md). +type rawProvider struct { + VPNID string `json:"vpnid"` + Name string `json:"name"` + Descr string `json:"description"` +} + +type rawAccount struct { + CommonName string `json:"commonName"` + CommonName2 string `json:"common_name"` + Description string `json:"description"` + Descr string `json:"descr"` + ValidTo flexTime `json:"validTo"` + ValidTo2 flexTime `json:"valid_to"` + ValidTo3 flexTime `json:"validto"` + Revoked flexBool `json:"isRevoked"` + Revoked2 flexBool `json:"revoked"` + Revoked3 flexBool `json:"is_revoked"` +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if s := strings.TrimSpace(v); s != "" { + return s + } + } + return "" +} + +func (r rawAccount) toAccount(refID string) Account { + validTo := r.ValidTo.Time + if validTo.IsZero() { + validTo = r.ValidTo2.Time + } + if validTo.IsZero() { + validTo = r.ValidTo3.Time + } + return Account{ + RefID: refID, + CommonName: firstNonEmpty(r.CommonName, r.CommonName2), + Description: firstNonEmpty(r.Description, r.Descr), + ValidTo: validTo, + Revoked: bool(r.Revoked) || bool(r.Revoked2) || bool(r.Revoked3), + } +} +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `go test ./internal/opnsense/ -v` +Expected: PASS (3 Tests) + +- [ ] **Step 5: Failing test für den HTTP-Client schreiben** + +`internal/opnsense/client_test.go`: + +```go +package opnsense + +import ( + "context" + "encoding/base64" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func newTestClient(t *testing.T, h http.HandlerFunc) (*Client, *httptest.Server) { + t.Helper() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + c, err := New(Options{ + BaseURL: srv.URL, + APIKey: "KEY", + APISecret: "SECRET", + Timeout: 2 * time.Second, + HTTPClient: srv.Client(), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + return c, srv +} + +func TestProvidersSendsBasicAuthAndParsesMap(t *testing.T) { + var gotPath string + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + user, pass, ok := r.BasicAuth() + if !ok || user != "KEY" || pass != "SECRET" { + t.Errorf("BasicAuth = %q/%q ok=%v", user, pass, ok) + } + io.WriteString(w, `{ + "1": {"vpnid":"1","name":"VPN Homeoffice"}, + "2": {"vpnid":"2","name":"VPN Aussendienst"} + }`) + }) + + ps, err := c.Providers(context.Background()) + if err != nil { + t.Fatalf("Providers: %v", err) + } + if gotPath != "/api/openvpn/export/providers" { + t.Errorf("Pfad = %q", gotPath) + } + if len(ps) != 2 { + t.Fatalf("got %d Provider, want 2", len(ps)) + } + // Stabile Sortierung nach VPNID, damit die UI-Reihenfolge deterministisch ist. + if ps[0].VPNID != "1" || ps[0].Name != "VPN Homeoffice" { + t.Errorf("ps[0] = %+v", ps[0]) + } +} + +func TestProvidersFallsBackToMapKeyAsVPNID(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, `{"7": {"name":"VPN Sieben"}}`) + }) + ps, err := c.Providers(context.Background()) + if err != nil { + t.Fatalf("Providers: %v", err) + } + if len(ps) != 1 || ps[0].VPNID != "7" { + t.Fatalf("ps = %+v, VPNID muss aus dem Map-Schlüssel kommen", ps) + } +} + +func TestAccountsParsesRevokedAndExpiry(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/accounts/1") { + t.Errorf("Pfad = %q", r.URL.Path) + } + io.WriteString(w, `{ + "abc123": {"commonName":"mmueller","description":"Max","validTo":"2027-03-01","isRevoked":"0"}, + "def456": {"commonName":"jdoe","validTo":"2027-03-01","isRevoked":"1"} + }`) + }) + + accs, err := c.Accounts(context.Background(), "1") + if err != nil { + t.Fatalf("Accounts: %v", err) + } + if len(accs) != 2 { + t.Fatalf("got %d Accounts, want 2", len(accs)) + } + byCN := map[string]Account{} + for _, a := range accs { + byCN[a.CommonName] = a + } + if byCN["mmueller"].RefID != "abc123" || byCN["mmueller"].Revoked { + t.Errorf("mmueller = %+v", byCN["mmueller"]) + } + if !byCN["jdoe"].Revoked { + t.Error("jdoe muss als revoziert erkannt werden") + } + if byCN["mmueller"].ValidTo.Year() != 2027 { + t.Errorf("ValidTo = %v", byCN["mmueller"].ValidTo) + } +} + +func TestExportStreamsRawBody(t *testing.T) { + const cfg = "client\nremote fw01.firma.local 1194\n" + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", `attachment; filename="mmueller.ovpn"`) + io.WriteString(w, cfg) + }) + + res, err := c.Export(context.Background(), "1", "abc123", FormatOVPN) + if err != nil { + t.Fatalf("Export: %v", err) + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + if string(body) != cfg { + t.Errorf("Body = %q", body) + } + if res.Filename != "mmueller.ovpn" { + t.Errorf("Filename = %q", res.Filename) + } +} + +func TestExportDecodesBase64JSONBody(t *testing.T) { + const cfg = "client\nremote fw01 1194\n" + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"status":"ok","filename":"x.ovpn","content":"`+ + base64.StdEncoding.EncodeToString([]byte(cfg))+`"}`) + }) + + res, err := c.Export(context.Background(), "1", "abc123", FormatOVPN) + if err != nil { + t.Fatalf("Export: %v", err) + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + if string(body) != cfg { + t.Errorf("Body = %q, want dekodierte Konfiguration", body) + } + if res.Filename != "x.ovpn" { + t.Errorf("Filename = %q", res.Filename) + } +} + +func TestUnauthorizedAndForbiddenAreDistinct(t *testing.T) { + for status, want := range map[int]error{ + http.StatusUnauthorized: ErrUnauthorized, + http.StatusForbidden: ErrForbidden, + } { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + }) + _, err := c.Providers(context.Background()) + if !errors.Is(err, want) { + t.Errorf("Status %d: err = %v, want %v", status, err, want) + } + } +} + +func TestUnreachableIsWrapped(t *testing.T) { + c, srv := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {}) + srv.Close() // Server abschalten, um Verbindungsfehler zu erzwingen + _, err := c.Providers(context.Background()) + if !errors.Is(err, ErrUnreachable) { + t.Fatalf("err = %v, want ErrUnreachable", err) + } +} + +func TestHTMLLoginPageIsRejected(t *testing.T) { + // OPNsense liefert bei ungültigem Key manchmal 200 mit HTML-Loginseite. + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + io.WriteString(w, "Login") + }) + if _, err := c.Providers(context.Background()); err == nil { + t.Fatal("HTML-Antwort muss als Fehler erkannt werden") + } +} + +func TestPingReturnsServerTime(t *testing.T) { + want := time.Date(2026, 8, 14, 7, 32, 11, 0, time.UTC) + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Date", want.Format(http.TimeFormat)) + io.WriteString(w, `{}`) + }) + got, err := c.Ping(context.Background()) + if err != nil { + t.Fatalf("Ping: %v", err) + } + if !got.Equal(want) { + t.Errorf("Ping-Zeit = %v, want %v", got, want) + } +} + +func TestClientRejectsInsecureWithoutExplicitOptIn(t *testing.T) { + if _, err := New(Options{BaseURL: "https://fw", APIKey: "k", APISecret: "s"}); err != nil { + t.Fatalf("Standardfall muss funktionieren: %v", err) + } + if _, err := New(Options{BaseURL: "", APIKey: "k", APISecret: "s"}); err == nil { + t.Fatal("leere BaseURL muss abgelehnt werden") + } +} +``` + +- [ ] **Step 6: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./internal/opnsense/ -run 'TestProviders|TestAccounts|TestExport|TestUn|TestHTML|TestPing|TestClient' -v` +Expected: FAIL — `undefined: New` + +- [ ] **Step 7: `client.go` schreiben** + +```go +package opnsense + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// Sentinel-Fehler, damit Aufrufer Ursachen unterscheiden können. +var ( + ErrUnauthorized = errors.New("OPNsense: Zugangsdaten abgelehnt (API-Key/Secret prüfen)") + ErrForbidden = errors.New("OPNsense: keine Berechtigung (API-User braucht \"VPN: OpenVPN Client Export\")") + ErrUnreachable = errors.New("OPNsense nicht erreichbar") + ErrUnexpected = errors.New("OPNsense: unerwartete Antwort") +) + +// maxJSONBytes begrenzt JSON-Antworten; Exportdaten werden gestreamt und +// unterliegen dieser Grenze nicht. +const maxJSONBytes = 8 << 20 + +// Options konfiguriert den Client. +type Options struct { + BaseURL string + APIKey string + APISecret string + CAFile string + InsecureSkipVerify bool + Timeout time.Duration + // HTTPClient überschreibt den intern gebauten Client (Tests). + HTTPClient *http.Client +} + +// Client ist ein read-only Client für die OPNsense-Export-API. +type Client struct { + baseURL string + key string + secret string + http *http.Client +} + +// New baut den Client und die TLS-Konfiguration. +func New(opts Options) (*Client, error) { + if strings.TrimSpace(opts.BaseURL) == "" { + return nil, errors.New("opnsense: url fehlt") + } + if _, err := url.Parse(opts.BaseURL); err != nil { + return nil, fmt.Errorf("opnsense: url ist ungültig: %w", err) + } + if opts.APIKey == "" || opts.APISecret == "" { + return nil, errors.New("opnsense: api_key und api_secret sind erforderlich") + } + if opts.Timeout <= 0 { + opts.Timeout = 15 * time.Second + } + + httpClient := opts.HTTPClient + if httpClient == nil { + tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12} + if opts.InsecureSkipVerify { + tlsCfg.InsecureSkipVerify = true + } + if opts.CAFile != "" { + pem, err := os.ReadFile(opts.CAFile) + if err != nil { + return nil, fmt.Errorf("opnsense.ca_file %s: %w", opts.CAFile, err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("opnsense.ca_file %s enthält kein gültiges PEM-Zertifikat", opts.CAFile) + } + tlsCfg.RootCAs = pool + } + httpClient = &http.Client{ + Timeout: opts.Timeout, + Transport: &http.Transport{TLSClientConfig: tlsCfg, ForceAttemptHTTP2: true}, + } + } + + return &Client{ + baseURL: strings.TrimRight(opts.BaseURL, "/"), + key: opts.APIKey, + secret: opts.APISecret, + http: httpClient, + }, nil +} + +// get führt einen authentifizierten GET aus und normalisiert Fehlerstatus. +// Der Aufrufer muss den Body schließen. +func (c *Client) get(ctx context.Context, path string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return nil, err + } + req.SetBasicAuth(c.key, c.secret) + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %s: %v", ErrUnreachable, path, err) + } + switch resp.StatusCode { + case http.StatusOK: + return resp, nil + case http.StatusUnauthorized: + resp.Body.Close() + return nil, fmt.Errorf("%w (%s)", ErrUnauthorized, path) + case http.StatusForbidden: + resp.Body.Close() + return nil, fmt.Errorf("%w (%s)", ErrForbidden, path) + case http.StatusNotFound: + resp.Body.Close() + return nil, fmt.Errorf("%w: Endpunkt %s existiert nicht — ist das Plugin "+ + "os-openvpn-client-export installiert?", ErrUnexpected, path) + default: + resp.Body.Close() + return nil, fmt.Errorf("%w: %s antwortete mit HTTP %d", ErrUnexpected, path, resp.StatusCode) + } +} + +// getJSON liest eine JSON-Antwort und weist HTML-Loginseiten zurück. +func (c *Client) getJSON(ctx context.Context, path string, into any) error { + resp, err := c.get(ctx, path) + if err != nil { + return err + } + defer resp.Body.Close() + + if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") { + return fmt.Errorf("%w: %s lieferte HTML statt JSON — meist ein ungültiger API-Key", ErrUnauthorized, path) + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxJSONBytes)) + if err != nil { + return fmt.Errorf("%w: %s: %v", ErrUnreachable, path, err) + } + if bytes := strings.TrimSpace(string(raw)); strings.HasPrefix(bytes, "<") { + return fmt.Errorf("%w: %s lieferte HTML statt JSON — meist ein ungültiger API-Key", ErrUnauthorized, path) + } + if err := json.Unmarshal(raw, into); err != nil { + return fmt.Errorf("%w: %s lieferte kein verwertbares JSON: %v", ErrUnexpected, path, err) + } + return nil +} + +// Ping prüft Erreichbarkeit und Berechtigung und liefert die Serverzeit aus +// dem Date-Header (Grundlage der NTP-Plausibilitätsprüfung in check). +func (c *Client) Ping(ctx context.Context) (time.Time, error) { + resp, err := c.get(ctx, pathProviders) + if err != nil { + return time.Time{}, err + } + defer resp.Body.Close() + io.Copy(io.Discard, io.LimitReader(resp.Body, maxJSONBytes)) + + if d := resp.Header.Get("Date"); d != "" { + if t, err := http.ParseTime(d); err == nil { + return t.UTC(), nil + } + } + return time.Time{}, nil +} +``` + +- [ ] **Step 8: `export.go` schreiben** + +```go +package opnsense + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "sort" + "strings" +) + +// API-Pfade des Plugins os-openvpn-client-export. +const ( + pathProviders = "/api/openvpn/export/providers" + pathAccounts = "/api/openvpn/export/accounts/" + pathDownload = "/api/openvpn/export/download/" +) + +// Providers listet die exportierbaren OpenVPN-Instanzen. +// Das Ergebnis darf vom Aufrufer kurz gecacht werden (Minuten). +func (c *Client) Providers(ctx context.Context) ([]Provider, error) { + var raw map[string]rawProvider + if err := c.getJSON(ctx, pathProviders, &raw); err != nil { + return nil, err + } + out := make([]Provider, 0, len(raw)) + for key, rp := range raw { + id := rp.VPNID + if id == "" { + id = key // ältere Versionen führen die vpnid nur als Map-Schlüssel + } + name := firstNonEmpty(rp.Name, rp.Descr, "VPN "+id) + out = append(out, Provider{VPNID: id, Name: name}) + } + sort.Slice(out, func(i, j int) bool { return out[i].VPNID < out[j].VPNID }) + return out, nil +} + +// Accounts listet die Zertifikate einer Instanz. +// Diese Antwort darf NIEMALS gecacht werden — eine Revozierung muss sofort greifen. +func (c *Client) Accounts(ctx context.Context, vpnID string) ([]Account, error) { + if strings.TrimSpace(vpnID) == "" { + return nil, fmt.Errorf("%w: leere vpnid", ErrUnexpected) + } + var raw map[string]rawAccount + if err := c.getJSON(ctx, pathAccounts+url.PathEscape(vpnID), &raw); err != nil { + return nil, err + } + out := make([]Account, 0, len(raw)) + for refID, ra := range raw { + out = append(out, ra.toAccount(refID)) + } + sort.Slice(out, func(i, j int) bool { return out[i].RefID < out[j].RefID }) + return out, nil +} + +// ExportResult trägt den Datenstrom der Konfiguration. +// Der Aufrufer muss Body schließen und darf ihn nicht auf Platte zwischenspeichern. +type ExportResult struct { + Filename string + ContentType string + Body io.ReadCloser +} + +// jsonExport ist die Antwortform älterer Plugin-Versionen: Base64 im JSON. +type jsonExport struct { + Status string `json:"status"` + Filename string `json:"filename"` + Content string `json:"content"` + Data string `json:"data"` + Message string `json:"message"` +} + +// Export lädt eine Client-Konfiguration und liefert sie als Stream. +// Es werden keine Exportoptionen übergeben — die Firewall-Einstellungen sind +// die Quelle der Wahrheit. +func (c *Client) Export(ctx context.Context, vpnID, certRefID, format string) (*ExportResult, error) { + if strings.TrimSpace(vpnID) == "" || strings.TrimSpace(certRefID) == "" { + return nil, fmt.Errorf("%w: vpnid oder Zertifikatsreferenz fehlt", ErrUnexpected) + } + path := fmt.Sprintf("%s%s/%s/%s", pathDownload, + url.PathEscape(vpnID), url.PathEscape(format), url.PathEscape(certRefID)) + + resp, err := c.get(ctx, path) + if err != nil { + return nil, err + } + + ct := resp.Header.Get("Content-Type") + filename := filenameFromDisposition(resp.Header.Get("Content-Disposition")) + + // JSON-Variante: Inhalt steckt Base64-kodiert in der Antwort. + if strings.Contains(ct, "application/json") { + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxJSONBytes)) + if err != nil { + return nil, fmt.Errorf("%w: Export konnte nicht gelesen werden: %v", ErrUnreachable, err) + } + var je jsonExport + if err := json.Unmarshal(raw, &je); err != nil { + return nil, fmt.Errorf("%w: Export lieferte kein verwertbares JSON: %v", ErrUnexpected, err) + } + payload := firstNonEmpty(je.Content, je.Data) + if payload == "" { + return nil, fmt.Errorf("%w: Export ohne Inhalt (status %q, message %q)", + ErrUnexpected, je.Status, je.Message) + } + decoded, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + // Manche Versionen liefern den Klartext direkt. + decoded = []byte(payload) + } + return &ExportResult{ + Filename: firstNonEmpty(je.Filename, filename), + ContentType: "application/x-openvpn-profile", + Body: io.NopCloser(bytes.NewReader(decoded)), + }, nil + } + + // Rohvariante: direkt durchstreamen, nichts puffern. + return &ExportResult{ + Filename: filename, + ContentType: firstNonEmpty(ct, "application/octet-stream"), + Body: resp.Body, + }, nil +} + +// filenameFromDisposition liest den Dateinamen aus dem Content-Disposition-Header. +func filenameFromDisposition(v string) string { + if v == "" { + return "" + } + _, params, err := mime.ParseMediaType(v) + if err != nil { + return "" + } + return params["filename"] +} + +var _ = http.StatusOK // hält den http-Import für künftige Erweiterungen sichtbar +``` + +Die letzte Zeile entfällt, falls `http` bereits anderweitig verwendet wird — `go vet` weist ungenutzte Importe ohnehin aus; dann den Import streichen statt den Dummy zu behalten. + +- [ ] **Step 9: Tests laufen lassen** + +Run: `go test ./internal/opnsense/ -v` +Expected: PASS. Falls `TestExportStreamsRawBody` beim Pfad scheitert: der Test prüft den Pfad nicht, nur den Inhalt — er muss durchlaufen. + +- [ ] **Step 10: Integrationstest-Skeleton anlegen** + +`internal/opnsense/integration_test.go`: + +```go +//go:build integration + +// Diese Tests laufen nur mit `go test -tags integration ./internal/opnsense/` +// gegen eine echte OPNsense-Testinstanz. Sie bestätigen die in +// docs/opnsense-api.md dokumentierten Annahmen über Feldnamen und Formate. +package opnsense + +import ( + "context" + "os" + "testing" + "time" +) + +func integrationClient(t *testing.T) *Client { + t.Helper() + base, key, secret := os.Getenv("OPNSENSE_URL"), os.Getenv("OPNSENSE_KEY"), os.Getenv("OPNSENSE_SECRET") + if base == "" || key == "" || secret == "" { + t.Skip("OPNSENSE_URL/OPNSENSE_KEY/OPNSENSE_SECRET nicht gesetzt") + } + c, err := New(Options{BaseURL: base, APIKey: key, APISecret: secret, + CAFile: os.Getenv("OPNSENSE_CA"), Timeout: 20 * time.Second}) + if err != nil { + t.Fatal(err) + } + return c +} + +// TestIntegrationProviders bestätigt Feldnamen und Map-Form der Provider-Antwort. +func TestIntegrationProviders(t *testing.T) { + ps, err := integrationClient(t).Providers(context.Background()) + if err != nil { + t.Fatalf("Providers: %v", err) + } + if len(ps) == 0 { + t.Fatal("keine Provider — mindestens eine OpenVPN-Instanz muss exportierbar sein") + } + for _, p := range ps { + if p.VPNID == "" || p.Name == "" { + t.Errorf("unvollständiger Provider: %+v", p) + } + } +} + +// TestIntegrationAccountsRevokedField bestätigt, dass revozierte Zertifikate +// als solche erkennbar sind. Voraussetzung: auf der Testinstanz existiert ein +// revoziertes Zertifikat mit CN aus OPNSENSE_REVOKED_CN. +func TestIntegrationAccountsRevokedField(t *testing.T) { + wantCN := os.Getenv("OPNSENSE_REVOKED_CN") + if wantCN == "" { + t.Skip("OPNSENSE_REVOKED_CN nicht gesetzt") + } + c := integrationClient(t) + ps, err := c.Providers(context.Background()) + if err != nil { + t.Fatal(err) + } + for _, p := range ps { + accs, err := c.Accounts(context.Background(), p.VPNID) + if err != nil { + t.Fatalf("Accounts(%s): %v", p.VPNID, err) + } + for _, a := range accs { + if a.CommonName == wantCN { + if !a.Revoked { + t.Fatalf("Zertifikat %q wird nicht als revoziert gemeldet: %+v", wantCN, a) + } + if a.IsUsable(time.Now()) { + t.Fatalf("revoziertes Zertifikat %q gilt als nutzbar", wantCN) + } + return + } + } + } + t.Fatalf("CN %q auf keiner Instanz gefunden", wantCN) +} + +// TestIntegrationExportFormats bestätigt, dass beide angebotenen Formate +// nicht-leere Konfigurationen liefern. +func TestIntegrationExportFormats(t *testing.T) { + refID := os.Getenv("OPNSENSE_CERT_REF") + vpnID := os.Getenv("OPNSENSE_VPNID") + if refID == "" || vpnID == "" { + t.Skip("OPNSENSE_CERT_REF/OPNSENSE_VPNID nicht gesetzt") + } + c := integrationClient(t) + for _, format := range []string{FormatOVPN, FormatViscosity} { + res, err := c.Export(context.Background(), vpnID, refID, format) + if err != nil { + t.Errorf("Export(%s): %v", format, err) + continue + } + n, _ := io.Copy(io.Discard, res.Body) + res.Body.Close() + if n == 0 { + t.Errorf("Export(%s) lieferte 0 Bytes", format) + } + } +} +``` + +`"io"` gehört in den Import-Block dieser Datei. + +- [ ] **Step 11: API-Annahmen dokumentieren** + +`docs/opnsense-api.md` anlegen mit: der Liste der drei genutzten Endpunkte (`providers`, `accounts/{vpnid}`, `download/{vpnid}/{format}/{certref}`), der Feststellung, dass alle drei `GET` und damit read-only sind, der Tabelle der akzeptierten Feldnamen (`commonName`/`common_name`, `validTo`/`valid_to`/`validto`, `isRevoked`/`revoked`/`is_revoked`), der Liste akzeptierter Datumsformate, dem Hinweis auf die beiden möglichen Antwortformen des Downloads (Rohdaten vs. Base64-in-JSON), der geforderten OPNsense-Mindestversion (beim ersten Integrationstestlauf eintragen) und dem Satz: „Diese Annahmen sind mit `go test -tags integration ./internal/opnsense/` gegen eine echte Instanz zu bestätigen, bevor v1 ausgeliefert wird." + +- [ ] **Step 12: Kompilierbarkeit des Integrationstests prüfen** + +Run: `go vet -tags integration ./internal/opnsense/ && go test ./internal/opnsense/ -v` +Expected: `vet` ohne Befund, Tests PASS + +- [ ] **Step 13: Commit** + +```bash +git add internal/opnsense docs/opnsense-api.md +git commit -m "feat(opnsense): read-only Export-Client mit tolerantem Decoding und Streaming-Download" +``` + +--- + +## Task 9: Zertifikatszuordnung (CN-Matching und Filterung) + +**Files:** +- Create: `internal/certmatch/match.go` +- Test: `internal/certmatch/match_test.go` + +**Interfaces:** +- Consumes: `opnsense.Account`, `opnsense.Provider` (Task 8) +- Produces: + - `type Matcher struct { ... }`; `func NewMatcher(pattern, regex string) (*Matcher, error)` + - `func (m *Matcher) Matches(cn, username string) bool` — Vergleich case-insensitive + - `func (m *Matcher) Describe(username string) string` — für Audit-Feld `pattern` + - `type Entry struct { Provider opnsense.Provider; Account opnsense.Account }` + - `func (m *Matcher) Filter(username string, provider opnsense.Provider, accounts []opnsense.Account, now time.Time) []Entry` — matcht CN **und** filtert revoked/abgelaufen + - `func (e Entry) Token() string` — stabiler, undurchsichtiger Bezeichner `vpnid:refid` für die UI-Auswahl (die Autorisierung erfolgt trotzdem serverseitig neu) + - `func ParseToken(s string) (vpnID, refID string, ok bool)` + +- [ ] **Step 1: Failing test schreiben** + +`internal/certmatch/match_test.go`: + +```go +package certmatch + +import ( + "testing" + "time" + + "git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense" +) + +func TestPatternMatching(t *testing.T) { + m, err := NewMatcher("{username}", "") + if err != nil { + t.Fatal(err) + } + if !m.Matches("mmueller", "mmueller") { + t.Error("identischer CN muss matchen") + } + if !m.Matches("MMueller", "mmueller") { + t.Error("Vergleich muss Groß-/Kleinschreibung ignorieren") + } + if m.Matches("jdoe", "mmueller") { + t.Error("fremder CN darf nicht matchen") + } + if m.Matches("mmueller2", "mmueller") { + t.Error("Präfix-Treffer darf nicht als Match gelten") + } + if m.Matches("", "mmueller") || m.Matches("mmueller", "") { + t.Error("leere Werte dürfen nie matchen") + } +} + +func TestPatternWithSuffix(t *testing.T) { + m, err := NewMatcher("{username}@firma.de", "") + if err != nil { + t.Fatal(err) + } + if !m.Matches("mmueller@firma.de", "mmueller") { + t.Error("Template mit Suffix muss matchen") + } + if m.Matches("mmueller", "mmueller") { + t.Error("CN ohne Suffix darf bei diesem Template nicht matchen") + } +} + +func TestRegexMatching(t *testing.T) { + // Regex mit {username}-Platzhalter: wird vor dem Kompilieren ersetzt und + // dabei quotiert, damit Sonderzeichen im Namen nicht zur Injection werden. + m, err := NewMatcher("", `^(vpn-)?{username}(-\d+)?$`) + if err != nil { + t.Fatal(err) + } + for _, cn := range []string{"mmueller", "vpn-mmueller", "mmueller-2"} { + if !m.Matches(cn, "mmueller") { + t.Errorf("CN %q muss matchen", cn) + } + } + for _, cn := range []string{"jdoe", "mmuellerX", "vpn-jdoe"} { + if m.Matches(cn, "mmueller") { + t.Errorf("CN %q darf nicht matchen", cn) + } + } +} + +func TestRegexQuotesUsername(t *testing.T) { + m, err := NewMatcher("", `^{username}$`) + if err != nil { + t.Fatal(err) + } + // Ein Benutzername mit Regex-Metazeichen darf nicht als Muster wirken. + if m.Matches("mmueller", "m.*") { + t.Error("Benutzername muss vor dem Einsetzen quotiert werden") + } + if !m.Matches("m.*", "m.*") { + t.Error("literaler Vergleich muss weiterhin funktionieren") + } +} + +func TestNewMatcherValidation(t *testing.T) { + if _, err := NewMatcher("", ""); err == nil { + t.Error("weder Pattern noch Regex muss abgelehnt werden") + } + if _, err := NewMatcher("{username}", "^x$"); err == nil { + t.Error("beides gleichzeitig muss abgelehnt werden") + } + if _, err := NewMatcher("", "([unbalanced"); err == nil { + t.Error("unkompilierbare Regex muss abgelehnt werden") + } + if _, err := NewMatcher("kein-platzhalter", ""); err == nil { + t.Error("Pattern ohne {username} muss abgelehnt werden") + } +} + +func TestFilterExcludesRevokedAndExpired(t *testing.T) { + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + m, _ := NewMatcher("{username}", "") + prov := opnsense.Provider{VPNID: "1", Name: "VPN Homeoffice"} + accounts := []opnsense.Account{ + {RefID: "ok", CommonName: "mmueller", ValidTo: now.AddDate(1, 0, 0)}, + {RefID: "revoked", CommonName: "mmueller", ValidTo: now.AddDate(1, 0, 0), Revoked: true}, + {RefID: "expired", CommonName: "mmueller", ValidTo: now.AddDate(0, 0, -1)}, + {RefID: "fremd", CommonName: "jdoe", ValidTo: now.AddDate(1, 0, 0)}, + } + + got := m.Filter("mmueller", prov, accounts, now) + if len(got) != 1 { + t.Fatalf("got %d Treffer, want 1: %+v", len(got), got) + } + if got[0].Account.RefID != "ok" { + t.Errorf("falscher Treffer: %+v", got[0]) + } + if got[0].Provider.Name != "VPN Homeoffice" { + t.Errorf("Provider muss durchgereicht werden: %+v", got[0].Provider) + } +} + +func TestTokenRoundTrip(t *testing.T) { + e := Entry{ + Provider: opnsense.Provider{VPNID: "1"}, + Account: opnsense.Account{RefID: "abc123"}, + } + vpnID, refID, ok := ParseToken(e.Token()) + if !ok || vpnID != "1" || refID != "abc123" { + t.Fatalf("ParseToken(%q) = %q,%q,%v", e.Token(), vpnID, refID, ok) + } + for _, bad := range []string{"", "nurEins", "a:b:c", ":x", "x:"} { + if _, _, ok := ParseToken(bad); ok { + t.Errorf("ParseToken(%q) darf nicht gelingen", bad) + } + } +} + +func TestDescribeForAudit(t *testing.T) { + m, _ := NewMatcher("{username}@firma.de", "") + if got := m.Describe("mmueller"); got != "mmueller@firma.de" { + t.Errorf("Describe = %q", got) + } + rx, _ := NewMatcher("", `^{username}$`) + if got := rx.Describe("mmueller"); got != `regex:^mmueller$` { + t.Errorf("Describe = %q", got) + } +} +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./internal/certmatch/ -v` +Expected: FAIL — `undefined: NewMatcher` + +- [ ] **Step 3: `match.go` schreiben** + +```go +// Package certmatch ordnet Firewall-Zertifikate einem Portalbenutzer zu. +package certmatch + +import ( + "errors" + "fmt" + "regexp" + "strings" + "time" + + "git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense" +) + +// Placeholder ist der Platzhalter für den kanonischen Benutzernamen. +const Placeholder = "{username}" + +// Matcher entscheidet, ob ein Zertifikats-CN zu einem Benutzer gehört. +// Entweder Template- oder Regex-Modus, nie beides. +type Matcher struct { + pattern string + regex string +} + +// NewMatcher baut den Matcher aus der Konfiguration. +func NewMatcher(pattern, regex string) (*Matcher, error) { + pattern, regex = strings.TrimSpace(pattern), strings.TrimSpace(regex) + switch { + case pattern == "" && regex == "": + return nil, errors.New("certmatch: cn_pattern oder cn_regex muss gesetzt sein") + case pattern != "" && regex != "": + return nil, errors.New("certmatch: cn_pattern und cn_regex schließen sich aus") + case pattern != "": + if !strings.Contains(pattern, Placeholder) { + return nil, fmt.Errorf("certmatch: cn_pattern %q enthält keinen %s-Platzhalter", pattern, Placeholder) + } + return &Matcher{pattern: pattern}, nil + default: + // Probeweise kompilieren, damit Konfigurationsfehler beim Start auffallen. + if _, err := regexp.Compile(strings.ReplaceAll(regex, Placeholder, "x")); err != nil { + return nil, fmt.Errorf("certmatch: cn_regex ist nicht kompilierbar: %w", err) + } + return &Matcher{regex: regex}, nil + } +} + +// expand setzt den Benutzernamen in Template bzw. Regex ein. +// Im Regex-Modus wird der Name quotiert, damit Metazeichen im Namen +// nicht als Muster wirken. +func (m *Matcher) expand(username string) string { + if m.regex != "" { + return strings.ReplaceAll(m.regex, Placeholder, regexp.QuoteMeta(username)) + } + return strings.ReplaceAll(m.pattern, Placeholder, username) +} + +// Matches vergleicht einen Zertifikats-CN mit dem Benutzernamen. +func (m *Matcher) Matches(cn, username string) bool { + cn, username = strings.TrimSpace(cn), strings.TrimSpace(username) + if cn == "" || username == "" { + return false + } + if m.regex != "" { + // (?i) macht den Vergleich unabhängig von der Schreibweise, analog zum + // Template-Modus. Kompilierfehler wurden in NewMatcher ausgeschlossen. + re, err := regexp.Compile("(?i)" + m.expand(username)) + if err != nil { + return false + } + return re.MatchString(cn) + } + return strings.EqualFold(cn, m.expand(username)) +} + +// Describe liefert die für diesen Benutzer angewendete Regel — geht als Feld +// pattern ins Audit-Log, damit no_cert_found nachvollziehbar bleibt. +func (m *Matcher) Describe(username string) string { + if m.regex != "" { + return "regex:" + m.expand(username) + } + return m.expand(username) +} + +// Entry ist ein für den Benutzer freigegebenes Zertifikat samt VPN-Instanz. +type Entry struct { + Provider opnsense.Provider + Account opnsense.Account +} + +// Token ist der undurchsichtige Bezeichner für die Auswahl in der UI. +// Er ist ausdrücklich KEINE Autorisierung — vor jedem Download wird die +// Zuordnung serverseitig neu geprüft. +func (e Entry) Token() string { + return e.Provider.VPNID + ":" + e.Account.RefID +} + +// ParseToken zerlegt einen Token wieder in seine Bestandteile. +func ParseToken(s string) (vpnID, refID string, ok bool) { + vpnID, refID, found := strings.Cut(s, ":") + if !found || vpnID == "" || refID == "" || strings.Contains(refID, ":") { + return "", "", false + } + return vpnID, refID, true +} + +// Filter liefert alle Zertifikate einer Instanz, die dem Benutzer gehören, +// nicht revoziert und nicht abgelaufen sind. +func (m *Matcher) Filter(username string, provider opnsense.Provider, + accounts []opnsense.Account, now time.Time) []Entry { + + var out []Entry + for _, a := range accounts { + if !m.Matches(a.CommonName, username) { + continue + } + if !a.IsUsable(now) { + continue + } + out = append(out, Entry{Provider: provider, Account: a}) + } + return out +} +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `go test ./internal/certmatch/ -v` +Expected: PASS (8 Tests) + +- [ ] **Step 5: Commit** + +```bash +git add internal/certmatch +git commit -m "feat(certmatch): CN-Zuordnung per Template oder Regex mit Revoked-/Ablauf-Filter" +``` + +--- + +## Task 10: Session-Store mit CSRF-Token + +**Files:** +- Create: `internal/web/session.go` +- Test: `internal/web/session_test.go` + +**Interfaces:** +- Consumes: `auth.Identity` (Task 6) +- Produces: + - `type Session struct { Token string; CSRF string; Identity *auth.Identity; Created, Expires time.Time }` + - `type SessionStore struct { ... }`; `func NewSessionStore(ttl time.Duration, clock func() time.Time) *SessionStore` + - `func (s *SessionStore) Create(id *auth.Identity) (*Session, error)` + - `func (s *SessionStore) Get(token string) (*Session, bool)` — abgelaufene Sessions werden entfernt und liefern `false` + - `func (s *SessionStore) Destroy(token string)` + - `func (s *SessionStore) Count() int` + - `func (s *SessionStore) GC()` — entfernt abgelaufene Einträge + - `const SessionCookieName = "vpnportal_session"` + - `func (s *Session) ValidCSRF(token string) bool` — konstante Laufzeit + +- [ ] **Step 1: Failing test schreiben** + +`internal/web/session_test.go`: + +```go +package web + +import ( + "testing" + "time" + + "git.ravensburg.dev/cabele/opnsense-portal/internal/auth" +) + +func testIdentity() *auth.Identity { + return &auth.Identity{Username: "mmueller", Groups: []string{"VPN-Users"}} +} + +func TestCreateAndGetSession(t *testing.T) { + st := NewSessionStore(10*time.Minute, nil) + sess, err := st.Create(testIdentity()) + if err != nil { + t.Fatalf("Create: %v", err) + } + if len(sess.Token) < 32 { + t.Errorf("Token %q ist zu kurz für 128 Bit Entropie", sess.Token) + } + if sess.CSRF == "" || sess.CSRF == sess.Token { + t.Error("CSRF-Token muss existieren und vom Session-Token verschieden sein") + } + got, ok := st.Get(sess.Token) + if !ok || got.Identity.Username != "mmueller" { + t.Fatalf("Get = %+v, ok=%v", got, ok) + } +} + +func TestTokensAreUnique(t *testing.T) { + st := NewSessionStore(time.Minute, nil) + seen := map[string]bool{} + for i := 0; i < 200; i++ { + s, err := st.Create(testIdentity()) + if err != nil { + t.Fatal(err) + } + if seen[s.Token] { + t.Fatalf("doppelter Token %q", s.Token) + } + seen[s.Token] = true + } +} + +func TestSessionExpires(t *testing.T) { + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + clock := func() time.Time { return now } + st := NewSessionStore(10*time.Minute, func() time.Time { return clock() }) + + sess, _ := st.Create(testIdentity()) + now = now.Add(9 * time.Minute) + if _, ok := st.Get(sess.Token); !ok { + t.Fatal("Session darf nach 9 von 10 Minuten nicht abgelaufen sein") + } + now = now.Add(2 * time.Minute) + if _, ok := st.Get(sess.Token); ok { + t.Fatal("Session muss nach Ablauf der TTL ungültig sein") + } + if st.Count() != 0 { + t.Errorf("abgelaufene Session muss entfernt werden, Count = %d", st.Count()) + } +} + +func TestDestroyRemovesSession(t *testing.T) { + st := NewSessionStore(time.Minute, nil) + sess, _ := st.Create(testIdentity()) + st.Destroy(sess.Token) + if _, ok := st.Get(sess.Token); ok { + t.Fatal("zerstörte Session darf nicht mehr auffindbar sein") + } + st.Destroy("existiert-nicht") // darf nicht paniken +} + +func TestGetRejectsUnknownAndEmptyToken(t *testing.T) { + st := NewSessionStore(time.Minute, nil) + if _, ok := st.Get(""); ok { + t.Error("leerer Token darf nie gültig sein") + } + if _, ok := st.Get("beliebig"); ok { + t.Error("unbekannter Token darf nie gültig sein") + } +} + +func TestValidCSRF(t *testing.T) { + st := NewSessionStore(time.Minute, nil) + sess, _ := st.Create(testIdentity()) + if !sess.ValidCSRF(sess.CSRF) { + t.Error("eigenes CSRF-Token muss akzeptiert werden") + } + for _, bad := range []string{"", "falsch", sess.CSRF + "x", sess.CSRF[:len(sess.CSRF)-1]} { + if sess.ValidCSRF(bad) { + t.Errorf("CSRF-Token %q darf nicht akzeptiert werden", bad) + } + } +} + +func TestGCRemovesOnlyExpired(t *testing.T) { + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + st := NewSessionStore(10*time.Minute, func() time.Time { return now }) + old, _ := st.Create(testIdentity()) + now = now.Add(11 * time.Minute) + fresh, _ := st.Create(testIdentity()) + + st.GC() + if st.Count() != 1 { + t.Fatalf("Count = %d, want 1", st.Count()) + } + if _, ok := st.Get(old.Token); ok { + t.Error("alte Session muss weg sein") + } + if _, ok := st.Get(fresh.Token); !ok { + t.Error("frische Session muss bleiben") + } +} +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./internal/web/ -v` +Expected: FAIL — `undefined: NewSessionStore` + +- [ ] **Step 3: `session.go` schreiben** + +```go +// Package web enthält HTTP-Handler, Templates, Sessions und Schutzmechanismen. +package web + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "sync" + "time" + + "git.ravensburg.dev/cabele/opnsense-portal/internal/auth" +) + +// SessionCookieName ist der Name des Session-Cookies. +const SessionCookieName = "vpnportal_session" + +// tokenBytes ergibt nach Base64-URL-Kodierung 43 Zeichen bei 256 Bit Entropie. +const tokenBytes = 32 + +// Session ist ein angemeldeter Benutzer. Sessions existieren ausschließlich im +// RAM; ein Neustart meldet alle Benutzer ab. +type Session struct { + Token string + CSRF string + Identity *auth.Identity + Created time.Time + Expires time.Time +} + +// ValidCSRF vergleicht in konstanter Zeit. +func (s *Session) ValidCSRF(token string) bool { + if token == "" || s.CSRF == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(s.CSRF), []byte(token)) == 1 +} + +// SessionStore hält Sessions im Speicher. +type SessionStore struct { + ttl time.Duration + clock func() time.Time + + mu sync.Mutex + sessions map[string]*Session +} + +// NewSessionStore erzeugt den Store. clock ist injizierbar; nil = time.Now. +func NewSessionStore(ttl time.Duration, clock func() time.Time) *SessionStore { + if clock == nil { + clock = time.Now + } + if ttl <= 0 { + ttl = 10 * time.Minute + } + return &SessionStore{ttl: ttl, clock: clock, sessions: make(map[string]*Session)} +} + +// randomToken liefert einen kryptografisch zufälligen, URL-sicheren Token. +func randomToken() (string, error) { + buf := make([]byte, tokenBytes) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("Zufallszahlengenerator nicht verfügbar: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +// Create legt eine neue Session an. +func (s *SessionStore) Create(id *auth.Identity) (*Session, error) { + token, err := randomToken() + if err != nil { + return nil, err + } + csrf, err := randomToken() + if err != nil { + return nil, err + } + now := s.clock() + sess := &Session{ + Token: token, + CSRF: csrf, + Identity: id, + Created: now, + Expires: now.Add(s.ttl), + } + s.mu.Lock() + s.sessions[token] = sess + s.mu.Unlock() + return sess, nil +} + +// Get liefert eine gültige Session. Abgelaufene Sessions werden entfernt. +// Die TTL ist absolut und wird bewusst nicht verlängert. +func (s *SessionStore) Get(token string) (*Session, bool) { + if token == "" { + return nil, false + } + s.mu.Lock() + defer s.mu.Unlock() + sess, ok := s.sessions[token] + if !ok { + return nil, false + } + if !s.clock().Before(sess.Expires) { + delete(s.sessions, token) + return nil, false + } + return sess, true +} + +// Destroy meldet eine Session ab. +func (s *SessionStore) Destroy(token string) { + s.mu.Lock() + delete(s.sessions, token) + s.mu.Unlock() +} + +// Count liefert die Zahl gespeicherter Sessions (Tests, /healthz). +func (s *SessionStore) Count() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.sessions) +} + +// GC entfernt abgelaufene Sessions; wird periodisch aufgerufen. +func (s *SessionStore) GC() { + now := s.clock() + s.mu.Lock() + defer s.mu.Unlock() + for token, sess := range s.sessions { + if !now.Before(sess.Expires) { + delete(s.sessions, token) + } + } +} +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `go test ./internal/web/ -v` +Expected: PASS (7 Tests) + +- [ ] **Step 5: Commit** + +```bash +git add internal/web/session.go internal/web/session_test.go +git commit -m "feat(web): RAM-Session-Store mit absoluter TTL und CSRF-Token" +``` + +--- + +## Task 11: Rate-Limiting mit exponentiellem Backoff + +**Files:** +- Create: `internal/web/ratelimit.go` +- Test: `internal/web/ratelimit_test.go` + +**Interfaces:** +- Consumes: nichts +- Produces: + - `type Limiter struct { ... }`; `func NewLimiter(clock func() time.Time) *Limiter` + - `func (l *Limiter) Allow(username, ip string) (retryAfter time.Duration, ok bool)` — prüft **beide** Schlüssel, ohne zu zählen + - `func (l *Limiter) RecordFailure(username, ip string)` + - `func (l *Limiter) RecordSuccess(username, ip string)` — setzt beide Zähler zurück + - `func (l *Limiter) GC()` + - Backoff-Kurve: die ersten `freeAttempts = 3` Versuche sind frei; danach `2^(n-3)` Sekunden, gedeckelt bei `maxBackoff = 5m`. Kein harter Lockout. + +- [ ] **Step 1: Failing test schreiben** + +`internal/web/ratelimit_test.go`: + +```go +package web + +import ( + "testing" + "time" +) + +func TestLimiterAllowsFirstAttempts(t *testing.T) { + l := NewLimiter(nil) + for i := 0; i < 3; i++ { + if _, ok := l.Allow("mmueller", "10.0.0.1"); !ok { + t.Fatalf("Versuch %d muss erlaubt sein", i+1) + } + l.RecordFailure("mmueller", "10.0.0.1") + } + if _, ok := l.Allow("mmueller", "10.0.0.1"); ok { + t.Fatal("nach 3 Fehlversuchen muss gebremst werden") + } +} + +func TestBackoffGrowsAndExpires(t *testing.T) { + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + l := NewLimiter(func() time.Time { return now }) + + for i := 0; i < 4; i++ { + l.RecordFailure("mmueller", "10.0.0.1") + } + wait1, ok := l.Allow("mmueller", "10.0.0.1") + if ok { + t.Fatal("nach 4 Fehlversuchen muss gebremst werden") + } + if wait1 <= 0 { + t.Fatalf("retryAfter = %v, muss positiv sein", wait1) + } + + // Wartezeit absitzen -> wieder erlaubt. + now = now.Add(wait1 + time.Second) + if _, ok := l.Allow("mmueller", "10.0.0.1"); !ok { + t.Fatal("nach Ablauf der Wartezeit muss wieder erlaubt sein") + } + + // Weiterer Fehlversuch -> längere Wartezeit als vorher. + l.RecordFailure("mmueller", "10.0.0.1") + wait2, ok := l.Allow("mmueller", "10.0.0.1") + if ok { + t.Fatal("erneuter Fehlversuch muss wieder bremsen") + } + if wait2 <= wait1 { + t.Errorf("Backoff muss wachsen: %v -> %v", wait1, wait2) + } +} + +func TestBackoffIsCapped(t *testing.T) { + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + l := NewLimiter(func() time.Time { return now }) + for i := 0; i < 40; i++ { + l.RecordFailure("mmueller", "10.0.0.1") + } + wait, ok := l.Allow("mmueller", "10.0.0.1") + if ok { + t.Fatal("muss gebremst sein") + } + if wait > maxBackoff { + t.Errorf("retryAfter = %v, Deckel ist %v", wait, maxBackoff) + } +} + +func TestIPLimitProtectsAgainstUserSpray(t *testing.T) { + l := NewLimiter(nil) + // Angreifer probiert viele verschiedene Benutzernamen von einer IP. + for i := 0; i < 12; i++ { + l.RecordFailure(string(rune('a'+i))+"user", "10.0.0.9") + } + if _, ok := l.Allow("nochNieProbiert", "10.0.0.9"); ok { + t.Fatal("die IP muss trotz wechselnder Benutzernamen gebremst werden") + } +} + +func TestUserLimitProtectsAcrossIPs(t *testing.T) { + l := NewLimiter(nil) + // Verteilter Angriff auf einen Benutzer von vielen IPs. + for i := 0; i < 12; i++ { + l.RecordFailure("mmueller", "10.0.1."+string(rune('0'+i%10))) + } + if _, ok := l.Allow("mmueller", "192.168.99.99"); ok { + t.Fatal("der Benutzer muss auch von einer neuen IP gebremst werden") + } +} + +func TestSuccessResetsBothCounters(t *testing.T) { + l := NewLimiter(nil) + for i := 0; i < 5; i++ { + l.RecordFailure("mmueller", "10.0.0.1") + } + if _, ok := l.Allow("mmueller", "10.0.0.1"); ok { + t.Fatal("Vorbedingung: muss gebremst sein") + } + l.RecordSuccess("mmueller", "10.0.0.1") + if _, ok := l.Allow("mmueller", "10.0.0.1"); !ok { + t.Fatal("erfolgreiche Anmeldung muss die Zähler zurücksetzen") + } +} + +func TestNoHardLockout(t *testing.T) { + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + l := NewLimiter(func() time.Time { return now }) + for i := 0; i < 100; i++ { + l.RecordFailure("opfer", "10.0.0.1") + } + // Nach dem gedeckelten Backoff darf der Benutzer wieder anklopfen — + // sonst könnte ein Dritter Kollegen dauerhaft aussperren. + now = now.Add(maxBackoff + time.Second) + if _, ok := l.Allow("opfer", "10.0.0.1"); !ok { + t.Fatal("es darf keinen dauerhaften Lockout geben") + } +} + +func TestGCDropsIdleEntries(t *testing.T) { + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + l := NewLimiter(func() time.Time { return now }) + l.RecordFailure("mmueller", "10.0.0.1") + now = now.Add(2 * entryTTL) + l.GC() + if n := l.size(); n != 0 { + t.Fatalf("GC muss veraltete Einträge entfernen, size = %d", n) + } +} +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./internal/web/ -run 'TestLimiter|TestBackoff|TestIPLimit|TestUserLimit|TestSuccess|TestNoHard|TestGCDrops' -v` +Expected: FAIL — `undefined: NewLimiter` + +- [ ] **Step 3: `ratelimit.go` schreiben** + +```go +package web + +import ( + "math" + "strings" + "sync" + "time" +) + +const ( + // freeAttempts sind die Fehlversuche ohne Wartezeit — genug für Tippfehler. + freeAttempts = 3 + // maxBackoff deckelt die Wartezeit. Ein harter Lockout wäre selbst eine + // Denial-of-Service-Möglichkeit gegen Kollegen. + maxBackoff = 5 * time.Minute + // entryTTL räumt ruhende Zähler ab. + entryTTL = 1 * time.Hour +) + +type limitEntry struct { + failures int + lastFail time.Time +} + +// Limiter bremst Anmeldeversuche pro Benutzername und pro Quell-IP. +// Beide Schlüssel werden geführt: die IP fängt Passwort-Spraying ab, der +// Benutzername verteilte Angriffe hinter NAT. +type Limiter struct { + clock func() time.Time + + mu sync.Mutex + entries map[string]*limitEntry +} + +func NewLimiter(clock func() time.Time) *Limiter { + if clock == nil { + clock = time.Now + } + return &Limiter{clock: clock, entries: make(map[string]*limitEntry)} +} + +// backoff berechnet die Wartezeit nach n Fehlversuchen. +func backoff(failures int) time.Duration { + if failures <= freeAttempts { + return 0 + } + exp := failures - freeAttempts + if exp > 20 { // schützt vor Overflow in math.Pow + return maxBackoff + } + d := time.Duration(math.Pow(2, float64(exp))) * time.Second + if d > maxBackoff { + return maxBackoff + } + return d +} + +// keys liefert die beiden Zählerschlüssel. Benutzernamen werden +// normalisiert, damit "MMueller" und "mmueller" derselbe Zähler sind. +func keys(username, ip string) []string { + var out []string + if u := strings.ToLower(strings.TrimSpace(username)); u != "" { + out = append(out, "u:"+u) + } + if ip != "" { + out = append(out, "i:"+ip) + } + return out +} + +// Allow meldet, ob ein Versuch erlaubt ist, und andernfalls die Restwartezeit. +// Allow zählt nicht mit — dafür ist RecordFailure zuständig. +func (l *Limiter) Allow(username, ip string) (time.Duration, bool) { + now := l.clock() + l.mu.Lock() + defer l.mu.Unlock() + + var worst time.Duration + for _, k := range keys(username, ip) { + e, ok := l.entries[k] + if !ok { + continue + } + wait := backoff(e.failures) - now.Sub(e.lastFail) + if wait > worst { + worst = wait + } + } + if worst > 0 { + return worst, false + } + return 0, true +} + +// RecordFailure erhöht beide Zähler. +func (l *Limiter) RecordFailure(username, ip string) { + now := l.clock() + l.mu.Lock() + defer l.mu.Unlock() + for _, k := range keys(username, ip) { + e, ok := l.entries[k] + if !ok { + e = &limitEntry{} + l.entries[k] = e + } + e.failures++ + e.lastFail = now + } +} + +// RecordSuccess setzt beide Zähler zurück. +func (l *Limiter) RecordSuccess(username, ip string) { + l.mu.Lock() + defer l.mu.Unlock() + for _, k := range keys(username, ip) { + delete(l.entries, k) + } +} + +// GC entfernt Zähler, die lange nicht mehr angefasst wurden. +func (l *Limiter) GC() { + now := l.clock() + l.mu.Lock() + defer l.mu.Unlock() + for k, e := range l.entries { + if now.Sub(e.lastFail) > entryTTL { + delete(l.entries, k) + } + } +} + +// size ist ein Testhelfer. +func (l *Limiter) size() int { + l.mu.Lock() + defer l.mu.Unlock() + return len(l.entries) +} +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `go test ./internal/web/ -v` +Expected: PASS (alle Session- und Limiter-Tests) + +- [ ] **Step 5: Commit** + +```bash +git add internal/web/ratelimit.go internal/web/ratelimit_test.go +git commit -m "feat(web): Rate-Limiting pro Benutzer und IP mit gedeckeltem exponentiellem Backoff" +``` + +--- + +## Task 12: Strings, Security-Header und Template-Rendering + +**Files:** +- Create: `internal/web/strings.go`, `internal/web/security.go`, `internal/web/render.go` +- Create: `internal/web/templates/layout.html`, `login.html`, `overview.html`, `guides.html`, `error.html` +- Create: `internal/web/assets/style.css`, `internal/web/assets/app.js` +- Test: `internal/web/security_test.go`, `internal/web/render_test.go` + +**Interfaces:** +- Consumes: nichts +- Produces: + - `var S = map[string]string{...}` — alle deutschen Texte, Schlüssel in `snake_case` + - `func T(key string) string` — liefert `S[key]`, bei fehlendem Schlüssel den Schlüssel selbst (nie leer) + - `func SecurityHeaders(next http.Handler) http.Handler` — setzt HSTS, `nosniff`, CSP, `Referrer-Policy`, `X-Frame-Options` + - `func NoStore(w http.ResponseWriter)` — `Cache-Control: no-store`, `Pragma: no-cache` + - `func ClientIP(r *http.Request) string` — `RemoteAddr` ohne Port; **kein** Vertrauen in `X-Forwarded-For` + - `type Renderer struct { ... }`; `func NewRenderer(brand Brand) (*Renderer, error)` + - `type Brand struct { Title, LogoPath, SupportContact, Version string; HasLogo bool }` + - `func (rn *Renderer) Render(w http.ResponseWriter, r *http.Request, status int, page string, data PageData)` + - `func (rn *Renderer) Brand() Brand` + - `type PageData struct { Brand Brand; Flash string; FlashKind string; CSRF string; User string; Data any }` + - `var AssetsFS embed.FS` (Assets), `var templateFS embed.FS` + +- [ ] **Step 1: Failing test für Header und Strings schreiben** + +`internal/web/security_test.go`: + +```go +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSecurityHeaders(t *testing.T) { + h := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + want := map[string]string{ + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "X-Frame-Options": "DENY", + } + for k, v := range want { + if got := rec.Header().Get(k); got != v { + t.Errorf("%s = %q, want %q", k, got, v) + } + } + if hsts := rec.Header().Get("Strict-Transport-Security"); !strings.Contains(hsts, "max-age=") { + t.Errorf("Strict-Transport-Security = %q", hsts) + } + csp := rec.Header().Get("Content-Security-Policy") + for _, directive := range []string{"default-src 'self'", "frame-ancestors 'none'", "form-action 'self'"} { + if !strings.Contains(csp, directive) { + t.Errorf("CSP %q fehlt %q", csp, directive) + } + } + if strings.Contains(csp, "unsafe-inline") || strings.Contains(csp, "unsafe-eval") { + t.Errorf("CSP darf kein unsafe-* enthalten: %q", csp) + } +} + +func TestNoStore(t *testing.T) { + rec := httptest.NewRecorder() + NoStore(rec) + if got := rec.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") { + t.Errorf("Cache-Control = %q", got) + } +} + +func TestClientIPIgnoresForwardedHeader(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = "10.1.20.34:51234" + r.Header.Set("X-Forwarded-For", "1.2.3.4") + // Das Portal terminiert TLS selbst; ein gefälschter Header darf das + // Audit-Log nicht verfälschen. + if got := ClientIP(r); got != "10.1.20.34" { + t.Errorf("ClientIP = %q, want 10.1.20.34", got) + } +} + +func TestClientIPHandlesIPv6AndMalformed(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = "[2001:db8::1]:443" + if got := ClientIP(r); got != "2001:db8::1" { + t.Errorf("ClientIP = %q", got) + } + r.RemoteAddr = "kaputt" + if got := ClientIP(r); got != "kaputt" { + t.Errorf("ClientIP = %q, malformed muss unverändert durchgereicht werden", got) + } +} + +func TestStringsAreComplete(t *testing.T) { + required := []string{ + "login_title", "login_username", "login_password", "login_submit", + "login_username_hint", "error_generic_login", "error_password_expired", + "error_rate_limited", "error_session_expired", "overview_title", + "overview_no_certs", "overview_expiry_warning", "download_ovpn", + "download_viscosity", "logout", "guides_title", "error_service_unavailable", + "error_forbidden", "logout_reminder", "valid_until", "vpn_instance", + } + for _, k := range required { + v, ok := S[k] + if !ok || strings.TrimSpace(v) == "" { + t.Errorf("String %q fehlt oder ist leer", k) + } + } + if T("gibt_es_nicht") != "gibt_es_nicht" { + t.Error("T muss bei unbekanntem Schlüssel den Schlüssel liefern, nie einen leeren String") + } +} +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./internal/web/ -run 'TestSecurity|TestNoStore|TestClientIP|TestStrings' -v` +Expected: FAIL — `undefined: SecurityHeaders` + +- [ ] **Step 3: `strings.go` schreiben** + +```go +package web + +// S enthält alle Texte der Oberfläche. Die Map-Struktur ist bewusst +// vorbereitet für spätere Mehrsprachigkeit; v1 füllt nur Deutsch. +var S = map[string]string{ + "login_title": "Anmeldung", + "login_username": "Benutzername", + "login_password": "Passwort", + "login_submit": "Anmelden", + "login_username_hint": "z. B. mmueller oder m.mueller@firma.de", + "login_show_password": "Passwort anzeigen", + + "error_generic_login": "Anmeldung fehlgeschlagen. Bitte prüfen Sie Benutzername und Passwort.", + "error_password_expired": "Ihr Passwort ist abgelaufen. Bitte ändern Sie es zuerst an Ihrem Arbeitsplatz.", + "error_rate_limited": "Zu viele Anmeldeversuche. Bitte versuchen Sie es in %s erneut.", + "error_session_expired": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", + "error_service_unavailable": "Dienst vorübergehend nicht verfügbar", + "error_service_unavailable_body": "Die Firewall ist derzeit nicht erreichbar. " + + "Bitte versuchen Sie es in einigen Minuten erneut.", + "error_forbidden": "Zugriff verweigert", + "error_forbidden_body": "Diese Anfrage ist nicht zulässig. Bitte starten Sie den Download erneut über die Übersicht.", + "error_not_found": "Seite nicht gefunden", + "error_csrf": "Die Anfrage ist abgelaufen oder ungültig. Bitte versuchen Sie es erneut.", + + "overview_title": "Ihre VPN-Konfigurationen", + "overview_intro": "Laden Sie hier Ihre persönliche OpenVPN-Konfiguration herunter.", + "overview_no_certs": "Für Ihr Benutzerkonto ist derzeit keine VPN-Konfiguration hinterlegt.", + "overview_no_certs_contact": "Bitte wenden Sie sich an %s.", + "overview_expiry_warning": "Achtung: Dieses Zertifikat läuft in %d Tagen ab.", + "overview_expired_today": "Achtung: Dieses Zertifikat läuft heute ab.", + + "vpn_instance": "VPN-Verbindung", + "common_name": "Zertifikat", + "valid_until": "Gültig bis", + + "download_ovpn": "OpenVPN-Datei (.ovpn)", + "download_viscosity": "Viscosity-Paket", + + "logout": "Abmelden", + "logout_reminder": "Bitte melden Sie sich nach dem Herunterladen ab.", + "logout_done": "Sie wurden abgemeldet.", + + "guides_title": "Anleitungen", + "guides_intro": "Kurzanleitungen zum Einrichten der VPN-Verbindung auf Ihrem Gerät.", + "guides_link": "Anleitungen", + + "footer_version": "Version", +} + +// T liefert den Text zu einem Schlüssel. Fehlt der Schlüssel, wird er selbst +// zurückgegeben — eine leere Fläche in der Oberfläche wäre schlimmer. +func T(key string) string { + if v, ok := S[key]; ok { + return v + } + return key +} +``` + +- [ ] **Step 4: `security.go` schreiben** + +```go +package web + +import ( + "net" + "net/http" + "strings" +) + +// contentSecurityPolicy ist restriktiv möglich, weil sämtliche Assets +// eingebettet und gleich-origin ausgeliefert werden. +const contentSecurityPolicy = "default-src 'self'; " + + "script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; " + + "connect-src 'none'; object-src 'none'; base-uri 'none'; " + + "form-action 'self'; frame-ancestors 'none'" + +// SecurityHeaders setzt die Sicherheits-Header auf jede Antwort. +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + h.Set("X-Frame-Options", "DENY") + h.Set("Content-Security-Policy", contentSecurityPolicy) + next.ServeHTTP(w, r) + }) +} + +// NoStore verhindert jegliches Zwischenspeichern. +func NoStore(w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") +} + +// ClientIP liefert die Quell-IP der Verbindung. X-Forwarded-For wird bewusst +// ignoriert: das Portal terminiert TLS selbst, ein gesetzter Header wäre +// fälschbar und würde das Audit-Log entwerten. +func ClientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return strings.TrimSpace(r.RemoteAddr) + } + return host +} +``` + +- [ ] **Step 5: Tests laufen lassen** + +Run: `go test ./internal/web/ -run 'TestSecurity|TestNoStore|TestClientIP|TestStrings' -v` +Expected: PASS + +- [ ] **Step 6: Templates anlegen** + +`internal/web/templates/layout.html`: + +```html +{{define "layout"}} + + + + + +{{.Brand.Title}} + + + +
+
+ {{if .Brand.HasLogo}}{{end}} + {{.Brand.Title}} +
+ {{if .User}} + + {{end}} +
+ +
+ {{if .Flash}}
{{.Flash}}
{{end}} + {{block "content" .}}{{end}} +
+ +
+ {{t "footer_version"}} {{.Brand.Version}} +
+ +{{end}} +``` + +`internal/web/templates/login.html`: + +```html +{{define "content"}} +

{{t "login_title"}}

+
+ + + {{t "login_username_hint"}} + + + + + + +
+ +{{end}} +``` + +`internal/web/templates/overview.html`: + +```html +{{define "content"}} +

{{t "overview_title"}}

+{{if .Data.Entries}} +

{{t "overview_intro"}}

+
    + {{range .Data.Entries}} +
  • +

    {{.InstanceName}}

    +
    +
    {{t "common_name"}}
    {{.CommonName}}
    + {{if .ValidUntil}}
    {{t "valid_until"}}
    {{.ValidUntil}}
    {{end}} +
    + {{if .ExpiryWarning}}

    {{.ExpiryWarning}}

    {{end}} +
    + {{range .Formats}} +
    + + + + +
    + {{end}} +
    +
  • + {{end}} +
+

{{t "logout_reminder"}}

+{{else}} +
+

{{t "overview_no_certs"}}

+ {{if .Data.SupportContact}}

{{.Data.SupportHint}}

{{end}} +
+{{end}} +{{end}} +``` + +Der Abmeldehinweis steht bewusst fest auf der Seite und wird nicht nach dem Download eingeblendet: Der Download ist ein POST, der eine Datei liefert — die Seite navigiert dabei nicht, ein „nach dem Download"-Zustand ist ohne JavaScript also gar nicht darstellbar. Der Text ist deshalb als Aufforderung formuliert („Bitte melden Sie sich nach dem Herunterladen ab.") statt als Rückmeldung. + +`internal/web/templates/guides.html`: + +```html +{{define "content"}} +

{{t "guides_title"}}

+

{{t "guides_intro"}}

+{{range .Data.Guides}} +
+

{{.Platform}}

+

{{.Client}}

+
    {{range .Steps}}
  1. {{.}}
  2. {{end}}
+
+{{end}} +{{end}} +``` + +`internal/web/templates/error.html`: + +```html +{{define "content"}} +

{{.Data.Title}}

+
+

{{.Data.Message}}

+ {{if .Data.ShowLoginLink}}

{{t "login_submit"}}

{{end}} +
+{{end}} +``` + +- [ ] **Step 7: Assets anlegen** + +`internal/web/assets/style.css` — schlichtes, systemschriftbasiertes Layout ohne externe Ressourcen. Enthält mindestens: CSS-Reset für `box-sizing`, ein zentriertes `main` mit `max-width: 46rem`, `.topbar` als Flex-Zeile, `.card` mit Rahmen und Innenabstand, `.flash-error` in Rot- und `.flash-info` in Blautönen, `.warn` in Orange, `button` mit ausreichend großer Klickfläche (min. 2.75rem Höhe), `:focus-visible` mit sichtbarem Fokusring, `prefers-color-scheme: dark`-Variante über CSS-Variablen, und eine `@media (max-width: 40rem)`-Regel, die `.topbar` und `.actions` umbricht. + +`internal/web/assets/app.js`: + +```js +// Optionale Bequemlichkeit: Passwort-Anzeigen-Schalter. +// Die Anmeldung funktioniert vollständig ohne JavaScript; der Schalter ist +// im Markup als hidden angelegt und wird erst hier eingeblendet. +(function () { + "use strict"; + var wrap = document.getElementById("toggle-wrap"); + var toggle = document.getElementById("show-password"); + var field = document.getElementById("password"); + if (!wrap || !toggle || !field) return; + wrap.hidden = false; + toggle.addEventListener("change", function () { + field.type = toggle.checked ? "text" : "password"; + }); +})(); +``` + +- [ ] **Step 8: Failing test für den Renderer schreiben** + +`internal/web/render_test.go`: + +```go +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func testRenderer(t *testing.T) *Renderer { + t.Helper() + rn, err := NewRenderer(Brand{Title: "VPN-Portal Firma GmbH", Version: "1.2.3", + SupportContact: "it@firma.de"}) + if err != nil { + t.Fatalf("NewRenderer: %v", err) + } + return rn +} + +func TestRenderLoginPage(t *testing.T) { + rn := testRenderer(t) + rec := httptest.NewRecorder() + rn.Render(rec, httptest.NewRequest(http.MethodGet, "/login", nil), http.StatusOK, + "login", PageData{Brand: rn.brand}) + + if rec.Code != http.StatusOK { + t.Fatalf("Code = %d", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{ + "", `lang="de"`, "VPN-Portal Firma GmbH", + S["login_submit"], S["login_username_hint"], "1.2.3", + `name="password"`, `type="password"`, + } { + if !strings.Contains(body, want) { + t.Errorf("Login-Seite enthält %q nicht", want) + } + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("Content-Type = %q", ct) + } +} + +func TestRenderEscapesUserContent(t *testing.T) { + rn := testRenderer(t) + rec := httptest.NewRecorder() + rn.Render(rec, httptest.NewRequest(http.MethodGet, "/", nil), http.StatusOK, + "error", PageData{ + Brand: rn.brand, + Flash: ``, + Data: errorPageData{Title: "T", Message: `">`}, + }) + body := rec.Body.String() + if strings.Contains(body, "") { + t.Error("Flash-Text wurde nicht escapt") + } + if strings.Contains(body, "onerror=alert(1)>") { + t.Error("Fehlermeldung wurde nicht escapt") + } +} + +func TestRenderSetsStatusCode(t *testing.T) { + rn := testRenderer(t) + rec := httptest.NewRecorder() + rn.Render(rec, httptest.NewRequest(http.MethodGet, "/", nil), http.StatusServiceUnavailable, + "error", PageData{Brand: rn.brand, Data: errorPageData{Title: "x", Message: "y"}}) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("Code = %d, want 503", rec.Code) + } +} + +func TestAllTemplatesParse(t *testing.T) { + rn := testRenderer(t) + for _, page := range []string{"login", "overview", "guides", "error"} { + if rn.pages[page] == nil { + t.Errorf("Template %q wurde nicht geladen", page) + } + } +} +``` + +- [ ] **Step 9: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./internal/web/ -run TestRender -v` +Expected: FAIL — `undefined: NewRenderer` + +- [ ] **Step 10: `render.go` schreiben** + +```go +package web + +import ( + "bytes" + "embed" + "fmt" + "html/template" + "net/http" +) + +//go:embed templates/*.html +var templateFS embed.FS + +// AssetsFS enthält CSS und JavaScript; wird vom Asset-Handler ausgeliefert. +// +//go:embed assets +var AssetsFS embed.FS + +// Brand bündelt die White-Label-Angaben. +type Brand struct { + Title string + SupportContact string + Version string + HasLogo bool +} + +// PageData ist das Wurzelobjekt jedes Templates. +type PageData struct { + Brand Brand + Flash string + FlashKind string // "error" oder "info" + CSRF string + User string + Data any +} + +// errorPageData füllt error.html. +type errorPageData struct { + Title string + Message string + ShowLoginLink bool +} + +// Renderer hält die geparsten Templates. +type Renderer struct { + brand Brand + pages map[string]*template.Template +} + +// pageNames sind die Inhaltstemplates, die jeweils mit dem Layout kombiniert werden. +var pageNames = []string{"login", "overview", "guides", "error"} + +// NewRenderer parst alle Templates beim Start; ein Fehler bricht den Start ab. +func NewRenderer(brand Brand) (*Renderer, error) { + funcs := template.FuncMap{"t": T} + pages := make(map[string]*template.Template, len(pageNames)) + for _, name := range pageNames { + tmpl, err := template.New("layout.html").Funcs(funcs). + ParseFS(templateFS, "templates/layout.html", "templates/"+name+".html") + if err != nil { + return nil, fmt.Errorf("Template %s: %w", name, err) + } + pages[name] = tmpl + } + return &Renderer{brand: brand, pages: pages}, nil +} + +// Brand liefert die White-Label-Angaben für Handler. +func (rn *Renderer) Brand() Brand { return rn.brand } + +// Render schreibt eine Seite. Es wird zuerst in einen Puffer gerendert, damit +// ein Template-Fehler nicht zu einer halb geschriebenen Antwort führt. +func (rn *Renderer) Render(w http.ResponseWriter, r *http.Request, status int, page string, data PageData) { + tmpl, ok := rn.pages[page] + if !ok { + http.Error(w, T("error_service_unavailable"), http.StatusInternalServerError) + return + } + if data.Brand.Title == "" { + data.Brand = rn.brand + } + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil { + http.Error(w, T("error_service_unavailable"), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + w.Write(buf.Bytes()) +} +``` + +Anpassung im Test: `PageData` statt `any` als Parametertyp — der Test in Step 8 übergibt bereits `PageData`, und der Zugriff auf `rn.brand` funktioniert, weil Test und Code im selben Package liegen. + +- [ ] **Step 11: Tests laufen lassen** + +Run: `go test ./internal/web/ -v` +Expected: PASS + +- [ ] **Step 12: Manuelle Sichtprüfung der gerenderten Seite** + +```bash +cat > /tmp/render_dump_test.go <<'EOF' +EOF +go test ./internal/web/ -run TestRenderLoginPage -v +``` +Expected: PASS. Die Seite wird in Task 17 im laufenden Server visuell geprüft. + +- [ ] **Step 13: Commit** + +```bash +git add internal/web +git commit -m "feat(web): Templates, eingebettete Assets, Security-Header und zentrale Textmap" +``` + +--- + +## Task 13: Server, Router und Login-Handler + +**Files:** +- Create: `internal/web/server.go`, `internal/web/handlers_login.go` +- Test: `internal/web/handlers_login_test.go` + +**Interfaces:** +- Consumes: `auth.Authenticator`, `auth.Error` (Task 6/7), `audit.Logger` (Task 5), `SessionStore` (Task 10), `Limiter` (Task 11), `Renderer` (Task 12) +- Produces: + - `type Deps struct { Auth auth.Authenticator; Certs CertSource; Audit AuditSink; Sessions *SessionStore; Limiter *Limiter; Renderer *Renderer; Clock func() time.Time; LogoPath string; SupportContact string; Health HealthChecker }` + - `type AuditSink interface { Log(audit.Event) }` — erlaubt Test-Doubles + - `type Server struct { ... }`; `func NewServer(d Deps) (*Server, error)`; `func (s *Server) Handler() http.Handler` + - `func (s *Server) requireSession(next func(http.ResponseWriter, *http.Request, *Session)) http.HandlerFunc` + - Routen: `GET /login`, `POST /login`, `POST /logout`, `GET /`, `POST /download`, `GET /anleitungen`, `GET /healthz`, `GET /assets/{file}` + - Cookie: `HttpOnly`, `Secure`, `SameSite=Strict`, `Path=/`, `MaxAge` = Session-TTL + +- [ ] **Step 1: Failing test schreiben** + +`internal/web/handlers_login_test.go`: + +```go +package web + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "git.ravensburg.dev/cabele/opnsense-portal/internal/audit" + "git.ravensburg.dev/cabele/opnsense-portal/internal/auth" +) + +// fakeAuth ist ein Authenticator-Double. +type fakeAuth struct { + id *auth.Identity + err error + // calls zählt Aufrufe, um Rate-Limiting-Verhalten zu prüfen. + calls int +} + +func (f *fakeAuth) Authenticate(ctx context.Context, u, p string) (*auth.Identity, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.id, nil +} + +// recordAudit sammelt Events für Assertions. +type recordAudit struct{ events []audit.Event } + +func (r *recordAudit) Log(e audit.Event) { r.events = append(r.events, e) } + +func (r *recordAudit) find(name string) (audit.Event, bool) { + for _, e := range r.events { + if e.Event == name { + return e, true + } + } + return audit.Event{}, false +} + +// fakeCerts ist eine CertSource ohne Firewall. +type fakeCerts struct { + entries []CertEntry + err error +} + +func (f *fakeCerts) EntriesFor(ctx context.Context, username string) ([]CertEntry, error) { + return f.entries, f.err +} + +func (f *fakeCerts) Export(ctx context.Context, username, vpnID, refID, format string) (*ExportStream, error) { + return nil, ErrCertNotOwned +} + +func newTestServer(t *testing.T, d Deps) (*Server, *recordAudit) { + t.Helper() + rec := &recordAudit{} + if d.Audit == nil { + d.Audit = rec + } else if r, ok := d.Audit.(*recordAudit); ok { + rec = r + } + if d.Sessions == nil { + d.Sessions = NewSessionStore(10*time.Minute, d.Clock) + } + if d.Limiter == nil { + d.Limiter = NewLimiter(d.Clock) + } + if d.Certs == nil { + d.Certs = &fakeCerts{} + } + if d.Renderer == nil { + rn, err := NewRenderer(Brand{Title: "Testportal", Version: "test"}) + if err != nil { + t.Fatal(err) + } + d.Renderer = rn + } + srv, err := NewServer(d) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + return srv, rec +} + +func postForm(h http.Handler, path string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.RemoteAddr = "10.1.20.34:5000" + for _, c := range cookies { + r.AddCookie(c) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, r) + return rec +} + +func sessionCookie(rec *httptest.ResponseRecorder) *http.Cookie { + for _, c := range rec.Result().Cookies() { + if c.Name == SessionCookieName { + return c + } + } + return nil +} + +func TestLoginGETRendersForm(t *testing.T) { + srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}}) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("Code = %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), S["login_submit"]) { + t.Error("Anmeldeformular fehlt") + } + if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") { + t.Errorf("Cache-Control = %q", cc) + } +} + +func TestLoginSuccessSetsHardenedCookieAndRedirects(t *testing.T) { + fa := &fakeAuth{id: &auth.Identity{Username: "mmueller", Groups: []string{"VPN-Users"}}} + srv, rec := newTestServer(t, Deps{Auth: fa}) + + resp := postForm(srv.Handler(), "/login", + url.Values{"username": {"MMueller"}, "password": {"geheim"}}) + + if resp.Code != http.StatusSeeOther { + t.Fatalf("Code = %d, want 303", resp.Code) + } + if loc := resp.Header().Get("Location"); loc != "/" { + t.Errorf("Location = %q", loc) + } + c := sessionCookie(resp) + if c == nil { + t.Fatal("Session-Cookie fehlt") + } + if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteStrictMode || c.Path != "/" { + t.Errorf("Cookie nicht gehärtet: %+v", c) + } + ev, ok := rec.find(audit.EventLoginSuccess) + if !ok { + t.Fatal("login_success fehlt im Audit-Log") + } + if ev.User != "mmueller" || ev.SrcIP != "10.1.20.34" || ev.Session == "" { + t.Errorf("Audit-Event unvollständig: %+v", ev) + } + if strings.Contains(ev.Session, c.Value) { + t.Error("Session-Feld darf keinen Teil des echten Tokens enthalten") + } +} + +func TestLoginFailureShowsGenericMessage(t *testing.T) { + fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonAccountDisabled}} + srv, rec := newTestServer(t, Deps{Auth: fa}) + + resp := postForm(srv.Handler(), "/login", + url.Values{"username": {"mmueller"}, "password": {"x"}}) + + if resp.Code != http.StatusUnauthorized { + t.Fatalf("Code = %d, want 401", resp.Code) + } + body := resp.Body.String() + if !strings.Contains(body, S["error_generic_login"]) { + t.Error("generische Fehlermeldung fehlt") + } + if strings.Contains(body, "deaktiviert") || strings.Contains(body, auth.ReasonAccountDisabled) { + t.Error("der Grund darf dem Benutzer nicht verraten werden") + } + ev, ok := rec.find(audit.EventLoginFailed) + if !ok || ev.Reason != auth.ReasonAccountDisabled { + t.Errorf("Audit-Reason = %+v", ev) + } +} + +func TestExpiredPasswordIsTheOnlySpecificMessage(t *testing.T) { + fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonPasswordExpired}} + srv, _ := newTestServer(t, Deps{Auth: fa}) + resp := postForm(srv.Handler(), "/login", + url.Values{"username": {"mmueller"}, "password": {"x"}}) + if !strings.Contains(resp.Body.String(), S["error_password_expired"]) { + t.Error("bei abgelaufenem Passwort muss die spezifische Meldung erscheinen") + } +} + +func TestUnknownUserIsNeverLoggedInClear(t *testing.T) { + fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonUserNotFound}} + srv, rec := newTestServer(t, Deps{Auth: fa}) + + // Klassischer Unfall: Passwort im Benutzernamenfeld. + secret := "MeinGeheimesPasswort123" + postForm(srv.Handler(), "/login", url.Values{"username": {secret}, "password": {"x"}}) + + ev, ok := rec.find(audit.EventLoginFailed) + if !ok { + t.Fatal("login_failed fehlt") + } + if ev.User != audit.UnknownUser { + t.Errorf("User = %q, want %q", ev.User, audit.UnknownUser) + } + for _, e := range rec.events { + if strings.Contains(e.User+e.Detail+e.Reason, secret) { + t.Fatalf("Eingabe aus dem Benutzerfeld ist im Log gelandet: %+v", e) + } + } +} + +func TestPasswordNeverAppearsInAnyEvent(t *testing.T) { + fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonInvalidCredentials}} + srv, rec := newTestServer(t, Deps{Auth: fa}) + const pw = "Sup3rGeheim!" + postForm(srv.Handler(), "/login", url.Values{"username": {"mmueller"}, "password": {pw}}) + for _, e := range rec.events { + if strings.Contains(e.User+e.Detail+e.Reason+e.Format+e.Pattern, pw) { + t.Fatalf("Passwort im Audit-Log: %+v", e) + } + } +} + +func TestRateLimitBlocksAndLogs(t *testing.T) { + fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonInvalidCredentials}} + srv, rec := newTestServer(t, Deps{Auth: fa}) + + form := url.Values{"username": {"mmueller"}, "password": {"falsch"}} + for i := 0; i < 4; i++ { + postForm(srv.Handler(), "/login", form) + } + before := fa.calls + resp := postForm(srv.Handler(), "/login", form) + + if resp.Code != http.StatusTooManyRequests { + t.Fatalf("Code = %d, want 429", resp.Code) + } + if fa.calls != before { + t.Error("bei Rate-Limit darf das Verzeichnis nicht mehr befragt werden") + } + if resp.Header().Get("Retry-After") == "" { + t.Error("Retry-After-Header fehlt") + } + if _, ok := rec.find(audit.EventRateLimited); !ok { + t.Error("rate_limited fehlt im Audit-Log") + } +} + +func TestLogoutDestroysSession(t *testing.T) { + fa := &fakeAuth{id: &auth.Identity{Username: "mmueller"}} + store := NewSessionStore(10*time.Minute, nil) + srv, rec := newTestServer(t, Deps{Auth: fa, Sessions: store}) + + login := postForm(srv.Handler(), "/login", url.Values{"username": {"m"}, "password": {"p"}}) + cookie := sessionCookie(login) + sess, ok := store.Get(cookie.Value) + if !ok { + t.Fatal("Session muss existieren") + } + + out := postForm(srv.Handler(), "/logout", url.Values{"csrf_token": {sess.CSRF}}, cookie) + if out.Code != http.StatusSeeOther { + t.Fatalf("Code = %d, want 303", out.Code) + } + if _, ok := store.Get(cookie.Value); ok { + t.Fatal("Session muss zerstört sein") + } + if _, ok := rec.find(audit.EventLogout); !ok { + t.Error("logout fehlt im Audit-Log") + } +} + +func TestLogoutRequiresCSRF(t *testing.T) { + fa := &fakeAuth{id: &auth.Identity{Username: "mmueller"}} + store := NewSessionStore(10*time.Minute, nil) + srv, _ := newTestServer(t, Deps{Auth: fa, Sessions: store}) + + login := postForm(srv.Handler(), "/login", url.Values{"username": {"m"}, "password": {"p"}}) + cookie := sessionCookie(login) + + out := postForm(srv.Handler(), "/logout", url.Values{"csrf_token": {"falsch"}}, cookie) + if out.Code != http.StatusForbidden { + t.Fatalf("Code = %d, want 403", out.Code) + } + if _, ok := store.Get(cookie.Value); !ok { + t.Error("bei CSRF-Fehler darf die Session nicht zerstört werden") + } +} + +func TestProtectedPageRedirectsWithoutSession(t *testing.T) { + srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}}) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusSeeOther { + t.Fatalf("Code = %d, want 303", rec.Code) + } + if loc := rec.Header().Get("Location"); !strings.HasPrefix(loc, "/login") { + t.Errorf("Location = %q", loc) + } +} + +func TestExpiredSessionRedirectsWithHint(t *testing.T) { + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + clock := func() time.Time { return now } + store := NewSessionStore(10*time.Minute, clock) + fa := &fakeAuth{id: &auth.Identity{Username: "mmueller"}} + srv, rec := newTestServer(t, Deps{Auth: fa, Sessions: store, Clock: clock}) + + login := postForm(srv.Handler(), "/login", url.Values{"username": {"m"}, "password": {"p"}}) + cookie := sessionCookie(login) + + now = now.Add(11 * time.Minute) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(cookie) + out := httptest.NewRecorder() + srv.Handler().ServeHTTP(out, r) + + if out.Code != http.StatusSeeOther { + t.Fatalf("Code = %d, want 303", out.Code) + } + if loc := out.Header().Get("Location"); !strings.Contains(loc, "expired") { + t.Errorf("Location = %q, muss den Ablaufhinweis tragen", loc) + } + if _, ok := rec.find(audit.EventSessionExpired); !ok { + t.Error("session_expired fehlt im Audit-Log") + } +} + +func TestLoginPageShowsExpiredHint(t *testing.T) { + srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}}) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login?expired=1", nil)) + if !strings.Contains(rec.Body.String(), S["error_session_expired"]) { + t.Error("Ablaufhinweis fehlt auf der Anmeldeseite") + } +} +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./internal/web/ -run 'TestLogin|TestLogout|TestProtected|TestExpired|TestRateLimit|TestUnknownUser|TestPassword' -v` +Expected: FAIL — `undefined: NewServer` + +- [ ] **Step 3: `server.go` schreiben** + +```go +package web + +import ( + "context" + "errors" + "io" + "io/fs" + "net/http" + "time" + + "git.ravensburg.dev/cabele/opnsense-portal/internal/audit" + "git.ravensburg.dev/cabele/opnsense-portal/internal/auth" +) + +// AuditSink ist die Log-Senke; als Interface, damit Tests mitschreiben können. +type AuditSink interface { + Log(audit.Event) +} + +// CertEntry ist ein für den Benutzer freigegebenes Zertifikat, aufbereitet +// für die Oberfläche. +type CertEntry struct { + Token string + InstanceName string + CommonName string + ValidTo time.Time +} + +// ExportStream ist eine zum Download bereite Konfiguration. +// Body muss vom Aufrufer geschlossen werden. +type ExportStream struct { + Filename string + ContentType string + Body io.ReadCloser + InstanceName string + CommonName string + ValidTo time.Time +} + +// ErrCertNotOwned signalisiert, dass das angeforderte Zertifikat nicht zum +// angemeldeten Benutzer gehört. +var ErrCertNotOwned = errors.New("Zertifikat gehört nicht zum angemeldeten Benutzer") + +// ErrBackendUnavailable signalisiert eine nicht erreichbare Firewall. +var ErrBackendUnavailable = errors.New("Firewall nicht erreichbar") + +// CertSource liefert die Zertifikate eines Benutzers und exportiert sie. +// Die Implementierung liegt in Task 14; das Interface hält die Handler testbar. +type CertSource interface { + EntriesFor(ctx context.Context, username string) ([]CertEntry, error) + // Export prüft die Zuordnung serverseitig erneut und liefert sonst ErrCertNotOwned. + Export(ctx context.Context, username, vpnID, refID, format string) (*ExportStream, error) +} + +// HealthChecker liefert den Zustand der Abhängigkeiten für /healthz. +type HealthChecker interface { + Check(ctx context.Context) HealthReport +} + +// HealthReport ist die Antwort von /healthz — bewusst ohne sensible Details. +type HealthReport struct { + OK bool `json:"ok"` + OPNsense bool `json:"opnsense"` + Directory bool `json:"directory"` + Sessions int `json:"sessions"` +} + +// Deps sind die Abhängigkeiten des Servers. +type Deps struct { + Auth auth.Authenticator + Certs CertSource + Audit AuditSink + Sessions *SessionStore + Limiter *Limiter + Renderer *Renderer + Health HealthChecker + Clock func() time.Time + LogoPath string + SupportContact string + SessionTTL time.Duration +} + +// Server bündelt Routing und Handler. +type Server struct { + d Deps +} + +// NewServer prüft die Abhängigkeiten. +func NewServer(d Deps) (*Server, error) { + if d.Auth == nil { + return nil, errors.New("web: Authenticator fehlt") + } + if d.Certs == nil { + return nil, errors.New("web: CertSource fehlt") + } + if d.Audit == nil { + return nil, errors.New("web: Audit-Senke fehlt") + } + if d.Sessions == nil { + return nil, errors.New("web: SessionStore fehlt") + } + if d.Limiter == nil { + return nil, errors.New("web: Limiter fehlt") + } + if d.Renderer == nil { + return nil, errors.New("web: Renderer fehlt") + } + if d.Clock == nil { + d.Clock = time.Now + } + if d.SessionTTL <= 0 { + d.SessionTTL = 10 * time.Minute + } + return &Server{d: d}, nil +} + +// Handler baut den Router samt Middleware. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /login", s.handleLoginForm) + mux.HandleFunc("POST /login", s.handleLoginSubmit) + mux.HandleFunc("POST /logout", s.requireSession(s.handleLogout)) + mux.HandleFunc("GET /{$}", s.requireSession(s.handleOverview)) + mux.HandleFunc("POST /download", s.requireSession(s.handleDownload)) + mux.HandleFunc("GET /anleitungen", s.requireSession(s.handleGuides)) + mux.HandleFunc("GET /healthz", s.handleHealth) + mux.HandleFunc("GET /assets/{file}", s.handleAsset) + mux.HandleFunc("/", s.handleNotFound) + + return SecurityHeaders(mux) +} + +// log schreibt ein Audit-Ereignis. +func (s *Server) log(e audit.Event) { s.d.Audit.Log(e) } + +// renderPage füllt die Standardfelder und rendert. +func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, status int, + page string, sess *Session, data PageData) { + + NoStore(w) + data.Brand = s.d.Renderer.Brand() + data.Brand.HasLogo = s.d.LogoPath != "" + if sess != nil { + data.CSRF = sess.CSRF + data.User = sess.Identity.Username + } + s.d.Renderer.Render(w, r, status, page, data) +} + +// renderError zeigt die Fehlerseite. +func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, + sess *Session, titleKey, messageKey string) { + + s.renderPage(w, r, status, "error", sess, PageData{ + Data: errorPageData{Title: T(titleKey), Message: T(messageKey)}, + }) +} + +// requireSession schützt eine Route. Ohne gültige Session wird auf /login +// umgeleitet; ein abgelaufenes Cookie erzeugt zusätzlich ein Audit-Ereignis. +func (s *Server) requireSession(next func(http.ResponseWriter, *http.Request, *Session)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + NoStore(w) + cookie, err := r.Cookie(SessionCookieName) + if err != nil || cookie.Value == "" { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + sess, ok := s.d.Sessions.Get(cookie.Value) + if !ok { + s.clearCookie(w) + s.log(audit.Event{ + Event: audit.EventSessionExpired, + SrcIP: ClientIP(r), + Session: audit.ShortSession(cookie.Value), + }) + http.Redirect(w, r, "/login?expired=1", http.StatusSeeOther) + return + } + next(w, r, sess) + } +} + +// handleAsset liefert die eingebetteten Assets und das optionale Logo. +func (s *Server) handleAsset(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("file") + if name == "logo" { + if s.d.LogoPath == "" { + http.NotFound(w, r) + return + } + // Assets dürfen zwischengespeichert werden; sie enthalten keine + // personenbezogenen Daten. + w.Header().Set("Cache-Control", "public, max-age=3600") + http.ServeFile(w, r, s.d.LogoPath) + return + } + data, err := fs.ReadFile(AssetsFS, "assets/"+name) + if err != nil { + http.NotFound(w, r) + return + } + switch { + case len(name) > 4 && name[len(name)-4:] == ".css": + w.Header().Set("Content-Type", "text/css; charset=utf-8") + case len(name) > 3 && name[len(name)-3:] == ".js": + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + } + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Write(data) +} + +// handleNotFound zeigt eine schlichte 404-Seite. +func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) { + s.renderError(w, r, http.StatusNotFound, nil, "error_not_found", "error_not_found") +} +``` + +- [ ] **Step 4: `handlers_login.go` schreiben** + +```go +package web + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "git.ravensburg.dev/cabele/opnsense-portal/internal/audit" + "git.ravensburg.dev/cabele/opnsense-portal/internal/auth" +) + +// minLoginDuration gleicht die Antwortzeit von „Benutzer existiert nicht" und +// „Passwort falsch" an, damit die Laufzeit kein Enumerationsorakel wird. +const minLoginDuration = 400 * time.Millisecond + +func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) { + NoStore(w) + // Eine bereits gültige Session überspringt das Formular. + if c, err := r.Cookie(SessionCookieName); err == nil { + if _, ok := s.d.Sessions.Get(c.Value); ok { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + } + var data PageData + switch { + case r.URL.Query().Get("expired") != "": + data.Flash, data.FlashKind = T("error_session_expired"), "info" + case r.URL.Query().Get("abgemeldet") != "": + data.Flash, data.FlashKind = T("logout_done"), "info" + } + s.renderPage(w, r, http.StatusOK, "login", nil, data) +} + +// loginFailed rendert die Anmeldeseite mit generischer Meldung. +func (s *Server) loginFailed(w http.ResponseWriter, r *http.Request, status int, message string) { + s.renderPage(w, r, status, "login", nil, PageData{Flash: message, FlashKind: "error"}) +} + +func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { + NoStore(w) + started := s.d.Clock() + ip := ClientIP(r) + + if err := r.ParseForm(); err != nil { + s.loginFailed(w, r, http.StatusBadRequest, T("error_generic_login")) + return + } + username := strings.TrimSpace(r.PostFormValue("username")) + password := r.PostFormValue("password") + + // Rate-Limit vor jedem Verzeichniszugriff prüfen. + if wait, ok := s.d.Limiter.Allow(username, ip); !ok { + s.log(audit.Event{Event: audit.EventRateLimited, User: auditUser(username, false), + SrcIP: ip, Detail: wait.Round(time.Second).String()}) + w.Header().Set("Retry-After", strconv.Itoa(int(wait.Round(time.Second).Seconds()))) + s.loginFailed(w, r, http.StatusTooManyRequests, + fmt.Sprintf(T("error_rate_limited"), humanDuration(wait))) + return + } + + id, err := s.d.Auth.Authenticate(r.Context(), username, password) + s.equalizeTiming(started) + + if err != nil { + s.d.Limiter.RecordFailure(username, ip) + reason, known := reasonAndKnownUser(err) + s.log(audit.Event{ + Event: audit.EventLoginFailed, + User: auditUser(username, known), + SrcIP: ip, + Reason: reason, + }) + message := T("error_generic_login") + if ae, ok := err.(*auth.Error); ok && ae.UserVisible() { + message = T("error_password_expired") + } + s.loginFailed(w, r, http.StatusUnauthorized, message) + return + } + + s.d.Limiter.RecordSuccess(username, ip) + sess, err := s.d.Sessions.Create(id) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, nil, + "error_service_unavailable", "error_service_unavailable_body") + return + } + s.setCookie(w, sess) + s.log(audit.Event{ + Event: audit.EventLoginSuccess, + User: id.Username, + SrcIP: ip, + Session: audit.ShortSession(sess.Token), + }) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess *Session) { + if err := r.ParseForm(); err != nil || !sess.ValidCSRF(r.PostFormValue("csrf_token")) { + s.renderError(w, r, http.StatusForbidden, sess, "error_forbidden", "error_csrf") + return + } + s.d.Sessions.Destroy(sess.Token) + s.clearCookie(w) + s.log(audit.Event{ + Event: audit.EventLogout, + User: sess.Identity.Username, + SrcIP: ClientIP(r), + Session: audit.ShortSession(sess.Token), + }) + http.Redirect(w, r, "/login?abgemeldet=1", http.StatusSeeOther) +} + +// setCookie setzt das gehärtete Session-Cookie. +func (s *Server) setCookie(w http.ResponseWriter, sess *Session) { + http.SetCookie(w, &http.Cookie{ + Name: SessionCookieName, + Value: sess.Token, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: int(s.d.SessionTTL.Seconds()), + }) +} + +func (s *Server) clearCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: SessionCookieName, Value: "", Path: "/", + HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: -1, + }) +} + +// equalizeTiming hält die Antwortzeit auf einem Mindestwert. +func (s *Server) equalizeTiming(started time.Time) { + if elapsed := time.Since(started); elapsed < minLoginDuration { + time.Sleep(minLoginDuration - elapsed) + } +} + +// reasonAndKnownUser liefert den Audit-Reason und ob der Benutzer im +// Verzeichnis existiert. Nur dann darf sein Name im Klartext geloggt werden. +func reasonAndKnownUser(err error) (reason string, known bool) { + ae, ok := err.(*auth.Error) + if !ok { + return auth.ReasonBackendUnavailable, false + } + switch ae.Reason { + case auth.ReasonUserNotFound, auth.ReasonBackendUnavailable, "": + return ae.Reason, false + default: + // Der Benutzer wurde im Verzeichnis gefunden; sein Name ist ein + // echter Kontoname und kein versehentlich eingegebenes Passwort. + return ae.Reason, true + } +} + +// auditUser entscheidet, ob der eingegebene Name im Log erscheinen darf. +func auditUser(input string, known bool) string { + if !known { + return audit.UnknownUser + } + return strings.ToLower(input) +} + +// humanDuration formatiert Wartezeiten deutsch lesbar. +func humanDuration(d time.Duration) string { + d = d.Round(time.Second) + if d < time.Minute { + return fmt.Sprintf("%d Sekunden", int(d.Seconds())) + } + minutes := int(d.Minutes()) + if minutes == 1 { + return "einer Minute" + } + return fmt.Sprintf("%d Minuten", minutes) +} +``` + +- [ ] **Step 5: Fehlende Handler als Stubs anlegen, damit das Package kompiliert** + +Vorläufig ans Ende von `server.go` (werden in Task 14/15 ersetzt): + +```go +func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request, sess *Session) { + s.renderError(w, r, http.StatusNotImplemented, sess, + "error_service_unavailable", "error_service_unavailable_body") +} + +func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request, sess *Session) { + s.renderError(w, r, http.StatusNotImplemented, sess, + "error_service_unavailable", "error_service_unavailable_body") +} + +func (s *Server) handleGuides(w http.ResponseWriter, r *http.Request, sess *Session) { + s.renderError(w, r, http.StatusNotImplemented, sess, + "error_service_unavailable", "error_service_unavailable_body") +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} +``` + +- [ ] **Step 6: Tests laufen lassen** + +Run: `go test ./internal/web/ -v` +Expected: PASS. Läuft wegen `minLoginDuration` spürbar langsamer — akzeptabel. + +Wenn die Laufzeit stört: `minLoginDuration` in `Deps` konfigurierbar machen und in Tests auf `time.Millisecond` setzen. Empfehlung: erst so lassen, damit das Timing-Verhalten wirklich getestet wird. + +- [ ] **Step 7: Commit** + +```bash +git add internal/web +git commit -m "feat(web): Router, Session-Middleware und Login-/Logout-Handler" +``` + +--- + +## Task 14: CertSource — Übersicht und autorisierter Download + +**Files:** +- Create: `internal/portal/certsource.go` +- Create: `internal/web/handlers_certs.go` +- Test: `internal/portal/certsource_test.go`, `internal/web/handlers_certs_test.go` +- Modify: `internal/web/server.go` (Stubs `handleOverview`/`handleDownload` entfernen) + +**Interfaces:** +- Consumes: `opnsense.Client` (Task 8), `certmatch.Matcher` (Task 9), `web.CertSource`/`CertEntry`/`ExportStream` (Task 13) +- Produces: + - `type Source struct { ... }`; `func NewSource(cl OPNsense, m *certmatch.Matcher, providerTTL time.Duration, clock func() time.Time) *Source` + - `type OPNsense interface { Providers(ctx) ([]opnsense.Provider, error); Accounts(ctx, vpnID) ([]opnsense.Account, error); Export(ctx, vpnID, refID, format) (*opnsense.ExportResult, error) }` — Interface, damit Tests ohne HTTP auskommen + - `func (s *Source) EntriesFor(ctx, username) ([]web.CertEntry, error)` + - `func (s *Source) Export(ctx, username, vpnID, refID, format) (*web.ExportStream, error)` — **prüft die Zuordnung live neu** + - Provider-Liste wird für `providerTTL` gecacht; Accounts **niemals** + - `func SafeFilename(instance, username, format string) string` — `vpn--.ovpn`, auf `[a-z0-9._-]` reduziert + +- [ ] **Step 1: Failing test für die CertSource schreiben** + +`internal/portal/certsource_test.go`: + +```go +package portal + +import ( + "context" + "errors" + "io" + "strings" + "testing" + "time" + + "git.ravensburg.dev/cabele/opnsense-portal/internal/certmatch" + "git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense" + "git.ravensburg.dev/cabele/opnsense-portal/internal/web" +) + +// fakeFW zählt Aufrufe, damit Caching-Verhalten prüfbar wird. +type fakeFW struct { + providers []opnsense.Provider + accounts map[string][]opnsense.Account + providerCalls int + accountCalls int + exportCalls int + lastExportArgs [3]string + err error +} + +func (f *fakeFW) Providers(ctx context.Context) ([]opnsense.Provider, error) { + f.providerCalls++ + return f.providers, f.err +} + +func (f *fakeFW) Accounts(ctx context.Context, vpnID string) ([]opnsense.Account, error) { + f.accountCalls++ + if f.err != nil { + return nil, f.err + } + return f.accounts[vpnID], nil +} + +func (f *fakeFW) Export(ctx context.Context, vpnID, refID, format string) (*opnsense.ExportResult, error) { + f.exportCalls++ + f.lastExportArgs = [3]string{vpnID, refID, format} + return &opnsense.ExportResult{ + Filename: "von-der-firewall.ovpn", + ContentType: "application/x-openvpn-profile", + Body: io.NopCloser(strings.NewReader("client\n")), + }, nil +} + +var testNow = time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + +func newSource(t *testing.T, fw *fakeFW) *Source { + t.Helper() + m, err := certmatch.NewMatcher("{username}", "") + if err != nil { + t.Fatal(err) + } + return NewSource(fw, m, 2*time.Minute, func() time.Time { return testNow }) +} + +func standardFW() *fakeFW { + return &fakeFW{ + providers: []opnsense.Provider{ + {VPNID: "1", Name: "VPN Homeoffice"}, + {VPNID: "2", Name: "VPN Aussendienst"}, + }, + accounts: map[string][]opnsense.Account{ + "1": { + {RefID: "a1", CommonName: "mmueller", ValidTo: testNow.AddDate(1, 0, 0)}, + {RefID: "a2", CommonName: "jdoe", ValidTo: testNow.AddDate(1, 0, 0)}, + }, + "2": { + {RefID: "b1", CommonName: "mmueller", ValidTo: testNow.AddDate(0, 0, 10)}, + {RefID: "b2", CommonName: "mmueller", ValidTo: testNow.AddDate(1, 0, 0), Revoked: true}, + }, + }, + } +} + +func TestEntriesForCollectsAcrossInstances(t *testing.T) { + src := newSource(t, standardFW()) + entries, err := src.EntriesFor(context.Background(), "mmueller") + if err != nil { + t.Fatalf("EntriesFor: %v", err) + } + if len(entries) != 2 { + t.Fatalf("got %d Einträge, want 2: %+v", len(entries), entries) + } + names := map[string]bool{} + for _, e := range entries { + names[e.InstanceName] = true + if e.CommonName != "mmueller" { + t.Errorf("fremder CN in der Liste: %+v", e) + } + } + if !names["VPN Homeoffice"] || !names["VPN Aussendienst"] { + t.Errorf("Instanznamen fehlen: %+v", entries) + } +} + +func TestEntriesForExcludesRevokedAndForeign(t *testing.T) { + src := newSource(t, standardFW()) + entries, _ := src.EntriesFor(context.Background(), "mmueller") + for _, e := range entries { + if strings.Contains(e.Token, "b2") { + t.Error("revoziertes Zertifikat darf nicht erscheinen") + } + if strings.Contains(e.Token, "a2") { + t.Error("fremdes Zertifikat darf nicht erscheinen") + } + } +} + +func TestAccountsAreNeverCached(t *testing.T) { + fw := standardFW() + src := newSource(t, fw) + src.EntriesFor(context.Background(), "mmueller") + first := fw.accountCalls + src.EntriesFor(context.Background(), "mmueller") + if fw.accountCalls <= first { + t.Fatal("Accounts müssen bei jeder Anfrage frisch geholt werden (Revocation muss sofort greifen)") + } +} + +func TestProvidersAreCachedForTTL(t *testing.T) { + fw := standardFW() + src := newSource(t, fw) + src.EntriesFor(context.Background(), "mmueller") + src.EntriesFor(context.Background(), "mmueller") + if fw.providerCalls != 1 { + t.Fatalf("providerCalls = %d, want 1 (TTL-Cache)", fw.providerCalls) + } + testNow = testNow.Add(3 * time.Minute) + defer func() { testNow = time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) }() + src.EntriesFor(context.Background(), "mmueller") + if fw.providerCalls != 2 { + t.Fatalf("providerCalls = %d, want 2 (Cache abgelaufen)", fw.providerCalls) + } +} + +func TestExportRevalidatesOwnership(t *testing.T) { + fw := standardFW() + src := newSource(t, fw) + + // jdoes Zertifikat a2 gehört nicht zu mmueller. + _, err := src.Export(context.Background(), "mmueller", "1", "a2", opnsense.FormatOVPN) + if !errors.Is(err, web.ErrCertNotOwned) { + t.Fatalf("err = %v, want ErrCertNotOwned", err) + } + if fw.exportCalls != 0 { + t.Error("bei fehlender Zuordnung darf kein Export ausgelöst werden") + } +} + +func TestExportRejectsRevokedEvenIfPreviouslyListed(t *testing.T) { + fw := standardFW() + src := newSource(t, fw) + // b2 gehört mmueller, ist aber revoziert. + _, err := src.Export(context.Background(), "mmueller", "2", "b2", opnsense.FormatOVPN) + if !errors.Is(err, web.ErrCertNotOwned) { + t.Fatalf("err = %v, want ErrCertNotOwned", err) + } +} + +func TestExportSucceedsForOwnedCert(t *testing.T) { + fw := standardFW() + src := newSource(t, fw) + st, err := src.Export(context.Background(), "mmueller", "1", "a1", opnsense.FormatOVPN) + if err != nil { + t.Fatalf("Export: %v", err) + } + defer st.Body.Close() + if fw.lastExportArgs != [3]string{"1", "a1", opnsense.FormatOVPN} { + t.Errorf("Export-Argumente = %v", fw.lastExportArgs) + } + if st.InstanceName != "VPN Homeoffice" || st.CommonName != "mmueller" { + t.Errorf("Metadaten fehlen: %+v", st) + } + body, _ := io.ReadAll(st.Body) + if string(body) != "client\n" { + t.Errorf("Body = %q", body) + } +} + +func TestExportRejectsUnknownFormat(t *testing.T) { + src := newSource(t, standardFW()) + if _, err := src.Export(context.Background(), "mmueller", "1", "a1", "beliebig"); err == nil { + t.Fatal("unbekanntes Format muss abgelehnt werden") + } +} + +func TestBackendErrorIsWrapped(t *testing.T) { + fw := standardFW() + fw.err = opnsense.ErrUnreachable + src := newSource(t, fw) + if _, err := src.EntriesFor(context.Background(), "mmueller"); !errors.Is(err, web.ErrBackendUnavailable) { + t.Fatalf("err = %v, want ErrBackendUnavailable", err) + } +} + +func TestSafeFilename(t *testing.T) { + cases := map[string]string{ + "VPN Homeoffice": "vpn-vpn-homeoffice-mmueller.ovpn", + "VPN/../etc": "vpn-vpn-.-.-etc-mmueller.ovpn", + "Außendienst": "vpn-au-endienst-mmueller.ovpn", + } + for instance, want := range cases { + if got := SafeFilename(instance, "mmueller", opnsense.FormatOVPN); got != want { + t.Errorf("SafeFilename(%q) = %q, want %q", instance, got, want) + } + } + // Kein Pfadtrenner, keine Anführungszeichen, kein CR/LF im Ergebnis. + dirty := SafeFilename("a\"/b\\c\r\nd", "u", opnsense.FormatOVPN) + for _, bad := range []string{"/", "\\", "\"", "\r", "\n"} { + if strings.Contains(dirty, bad) { + t.Errorf("Dateiname %q enthält %q", dirty, bad) + } + } +} +``` + +Hinweis: Die Erwartungen in `TestSafeFilename` legen die Normalisierung fest — jedes Zeichen außerhalb `[a-z0-9._-]` wird zu `-`, Mehrfach-`-` werden zusammengefasst nicht ersetzt (die Erwartung `vpn-vpn-.-.-etc-mmueller.ovpn` bildet genau das ab). Weicht die Implementierung ab, sind die Erwartungen an das tatsächliche, dokumentierte Verhalten anzupassen — nicht umgekehrt aufzuweichen. + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./internal/portal/ -v` +Expected: FAIL — `undefined: NewSource` + +- [ ] **Step 3: `certsource.go` schreiben** + +```go +// Package portal verbindet Firewall-Client und Zuordnungsregeln zu der +// Sicht, die das Webportal auf die Zertifikate eines Benutzers hat. +package portal + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "git.ravensburg.dev/cabele/opnsense-portal/internal/certmatch" + "git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense" + "git.ravensburg.dev/cabele/opnsense-portal/internal/web" +) + +// OPNsense ist der benötigte Ausschnitt des Firewall-Clients. +type OPNsense interface { + Providers(ctx context.Context) ([]opnsense.Provider, error) + Accounts(ctx context.Context, vpnID string) ([]opnsense.Account, error) + Export(ctx context.Context, vpnID, refID, format string) (*opnsense.ExportResult, error) +} + +// allowedFormats sind die einzigen Formate, die das Portal anbietet. +var allowedFormats = map[string]bool{ + opnsense.FormatOVPN: true, + opnsense.FormatViscosity: true, +} + +// Source liefert die Zertifikatssicht eines Benutzers. +type Source struct { + fw OPNsense + match *certmatch.Matcher + clock func() time.Time + + providerTTL time.Duration + mu sync.Mutex + providers []opnsense.Provider + providersAt time.Time +} + +// NewSource baut die Quelle. providerTTL cacht ausschließlich die Liste der +// VPN-Instanzen; Zertifikate werden grundsätzlich live geholt. +func NewSource(fw OPNsense, m *certmatch.Matcher, providerTTL time.Duration, clock func() time.Time) *Source { + if clock == nil { + clock = time.Now + } + return &Source{fw: fw, match: m, clock: clock, providerTTL: providerTTL} +} + +// Pattern liefert die für einen Benutzer angewendete Regel (Audit-Feld). +func (s *Source) Pattern(username string) string { return s.match.Describe(username) } + +// listProviders liefert die VPN-Instanzen aus dem Cache oder frisch. +func (s *Source) listProviders(ctx context.Context) ([]opnsense.Provider, error) { + now := s.clock() + s.mu.Lock() + if s.providers != nil && now.Sub(s.providersAt) < s.providerTTL { + cached := s.providers + s.mu.Unlock() + return cached, nil + } + s.mu.Unlock() + + ps, err := s.fw.Providers(ctx) + if err != nil { + return nil, wrapBackend(err) + } + s.mu.Lock() + s.providers, s.providersAt = ps, now + s.mu.Unlock() + return ps, nil +} + +// wrapBackend übersetzt Client-Fehler in die Portal-Semantik. +func wrapBackend(err error) error { + return fmt.Errorf("%w: %v", web.ErrBackendUnavailable, err) +} + +// matchingEntries sammelt alle passenden, nutzbaren Zertifikate. +// Wird von EntriesFor und von Export benutzt — Export prüft damit live neu. +func (s *Source) matchingEntries(ctx context.Context, username string) ([]certmatch.Entry, error) { + providers, err := s.listProviders(ctx) + if err != nil { + return nil, err + } + now := s.clock() + var out []certmatch.Entry + for _, p := range providers { + // Accounts NIE cachen: eine Revozierung auf der Firewall muss sofort greifen. + accounts, err := s.fw.Accounts(ctx, p.VPNID) + if err != nil { + return nil, wrapBackend(err) + } + out = append(out, s.match.Filter(username, p, accounts, now)...) + } + return out, nil +} + +// EntriesFor liefert die Zertifikate des Benutzers für die Übersicht. +func (s *Source) EntriesFor(ctx context.Context, username string) ([]web.CertEntry, error) { + entries, err := s.matchingEntries(ctx, username) + if err != nil { + return nil, err + } + out := make([]web.CertEntry, 0, len(entries)) + for _, e := range entries { + out = append(out, web.CertEntry{ + Token: e.Token(), + InstanceName: e.Provider.Name, + CommonName: e.Account.CommonName, + ValidTo: e.Account.ValidTo, + }) + } + return out, nil +} + +// Export prüft die Zuordnung serverseitig erneut und streamt dann. +// Die Auswahl in der Oberfläche ist ausdrücklich keine Autorisierung. +func (s *Source) Export(ctx context.Context, username, vpnID, refID, format string) (*web.ExportStream, error) { + if !allowedFormats[format] { + return nil, fmt.Errorf("unbekanntes Exportformat %q", format) + } + entries, err := s.matchingEntries(ctx, username) + if err != nil { + return nil, err + } + var found *certmatch.Entry + for i := range entries { + if entries[i].Provider.VPNID == vpnID && entries[i].Account.RefID == refID { + found = &entries[i] + break + } + } + if found == nil { + return nil, web.ErrCertNotOwned + } + + res, err := s.fw.Export(ctx, vpnID, refID, format) + if err != nil { + return nil, wrapBackend(err) + } + return &web.ExportStream{ + Filename: SafeFilename(found.Provider.Name, username, format), + ContentType: res.ContentType, + Body: res.Body, + InstanceName: found.Provider.Name, + CommonName: found.Account.CommonName, + ValidTo: found.Account.ValidTo, + }, nil +} + +// extensionFor liefert die Dateiendung je Format. +func extensionFor(format string) string { + if format == opnsense.FormatViscosity { + return ".visc.zip" + } + return ".ovpn" +} + +// SafeFilename baut einen Dateinamen, der weder Pfadtrenner noch Zeichen +// enthält, die den Content-Disposition-Header aufbrechen könnten. +func SafeFilename(instance, username, format string) string { + return "vpn-" + sanitize(instance) + "-" + sanitize(username) + extensionFor(format) +} + +// sanitize reduziert auf [a-z0-9._-]; alles andere wird zu '-'. +func sanitize(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(strings.TrimSpace(s)) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': + b.WriteRune(r) + default: + b.WriteRune('-') + } + } + out := strings.Trim(b.String(), "-") + if out == "" { + return "vpn" + } + return out +} + +var _ web.CertSource = (*Source)(nil) +var _ = errors.Is // hält errors im Import, falls oben nur indirekt genutzt +``` + +Die letzte Zeile entfernen, falls `errors` bereits verwendet wird. + +- [ ] **Step 4: Tests laufen lassen** + +Run: `go test ./internal/portal/ -v` +Expected: PASS. Falls `TestSafeFilename` bei `"Außendienst"` abweicht: `ß` ist ein Rune außerhalb `[a-z]` und wird zu `-`, also `au-endienst` — die Erwartung im Test ist korrekt. + +- [ ] **Step 5: Failing test für die Handler schreiben** + +`internal/web/handlers_certs_test.go`: + +```go +package web + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "git.ravensburg.dev/cabele/opnsense-portal/internal/audit" + "git.ravensburg.dev/cabele/opnsense-portal/internal/auth" +) + +// certsWithExport erweitert fakeCerts um einen funktionierenden Export. +type certsWithExport struct { + entries []CertEntry + listErr error + exportErr error + exportCall int +} + +func (c *certsWithExport) EntriesFor(ctx context.Context, username string) ([]CertEntry, error) { + return c.entries, c.listErr +} + +func (c *certsWithExport) Export(ctx context.Context, username, vpnID, refID, format string) (*ExportStream, error) { + c.exportCall++ + if c.exportErr != nil { + return nil, c.exportErr + } + return &ExportStream{ + Filename: "vpn-homeoffice-mmueller.ovpn", + ContentType: "application/x-openvpn-profile", + Body: io.NopCloser(strings.NewReader("client\nremote fw01 1194\n")), + InstanceName: "VPN Homeoffice", + CommonName: "mmueller", + ValidTo: time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC), + }, nil +} + +// loggedInServer liefert Server, Cookie und Session eines angemeldeten Benutzers. +func loggedInServer(t *testing.T, certs CertSource) (*Server, *recordAudit, *http.Cookie, *Session) { + t.Helper() + store := NewSessionStore(10*time.Minute, nil) + srv, rec := newTestServer(t, Deps{ + Auth: &fakeAuth{id: &auth.Identity{Username: "mmueller", Groups: []string{"VPN-Users"}}}, + Certs: certs, + Sessions: store, + SupportContact: "it-helpdesk@firma.de", + }) + login := postForm(srv.Handler(), "/login", url.Values{"username": {"m"}, "password": {"p"}}) + cookie := sessionCookie(login) + sess, ok := store.Get(cookie.Value) + if !ok { + t.Fatal("Anmeldung fehlgeschlagen") + } + return srv, rec, cookie, sess +} + +func getWithCookie(h http.Handler, path string, c *http.Cookie) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, path, nil) + r.RemoteAddr = "10.1.20.34:5000" + r.AddCookie(c) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, r) + return rec +} + +func TestOverviewListsCertificates(t *testing.T) { + certs := &certsWithExport{entries: []CertEntry{ + {Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller", + ValidTo: time.Now().AddDate(1, 0, 0)}, + {Token: "2:b1", InstanceName: "VPN Aussendienst", CommonName: "mmueller", + ValidTo: time.Now().AddDate(1, 0, 0)}, + }} + srv, _, cookie, _ := loggedInServer(t, certs) + rec := getWithCookie(srv.Handler(), "/", cookie) + + if rec.Code != http.StatusOK { + t.Fatalf("Code = %d", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{"VPN Homeoffice", "VPN Aussendienst", + S["download_ovpn"], S["download_viscosity"], `name="csrf_token"`} { + if !strings.Contains(body, want) { + t.Errorf("Übersicht enthält %q nicht", want) + } + } + if strings.Contains(body, ` %q, want %q zuerst", ua, g[0].Platform, want) + } + } + // Unbekannter User-Agent: Reihenfolge stabil, nichts als Vorschlag markiert. + g := Guides("irgendwas") + if g[0].Suggested { + t.Error("bei unbekanntem User-Agent darf nichts vorgeschlagen werden") + } +} + +func TestGuidesPageRequiresSession(t *testing.T) { + srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}}) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/anleitungen", nil)) + if rec.Code != http.StatusSeeOther { + t.Fatalf("Code = %d, want 303", rec.Code) + } +} + +func TestGuidesPageRenders(t *testing.T) { + srv, _, cookie, _ := loggedInServer(t, &certsWithExport{}) + rec := getWithCookie(srv.Handler(), "/anleitungen", cookie) + if rec.Code != http.StatusOK { + t.Fatalf("Code = %d", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{S["guides_title"], "OpenVPN", "Tunnelblick", "OpenVPN Connect"} { + if !strings.Contains(body, want) { + t.Errorf("Anleitungsseite enthält %q nicht", want) + } + } +} + +func TestHealthzHealthy(t *testing.T) { + srv, _ := newTestServer(t, Deps{ + Auth: &fakeAuth{}, + Health: &fakeHealth{rep: HealthReport{OK: true, OPNsense: true, Directory: true}}, + }) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("Code = %d, want 200", rec.Code) + } + var got HealthReport + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("Antwort ist kein JSON: %v (%s)", err, rec.Body.String()) + } + if !got.OK || !got.OPNsense || !got.Directory { + t.Errorf("Report = %+v", got) + } +} + +func TestHealthzUnhealthyReturns503(t *testing.T) { + srv, _ := newTestServer(t, Deps{ + Auth: &fakeAuth{}, + Health: &fakeHealth{rep: HealthReport{OK: false, OPNsense: false, Directory: true}}, + }) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("Code = %d, want 503", rec.Code) + } +} + +func TestHealthzLeaksNoDetails(t *testing.T) { + srv, _ := newTestServer(t, Deps{ + Auth: &fakeAuth{}, + Health: &fakeHealth{rep: HealthReport{OK: true, OPNsense: true, Directory: true, Sessions: 2}}, + }) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + body := rec.Body.String() + for _, forbidden := range []string{"firma.local", "dc01", "https://", "api", "password"} { + if strings.Contains(strings.ToLower(body), forbidden) { + t.Errorf("/healthz verrät %q: %s", forbidden, body) + } + } +} + +func TestHealthzWithoutCheckerStillAnswers(t *testing.T) { + srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}}) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("Code = %d — ohne Checker gilt das Portal selbst als gesund", rec.Code) + } +} + +func TestUnknownPathReturns404(t *testing.T) { + srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}}) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/gibtsnicht", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("Code = %d, want 404", rec.Code) + } +} + +func TestAssetsAreServed(t *testing.T) { + srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}}) + for path, wantType := range map[string]string{ + "/assets/style.css": "text/css", + "/assets/app.js": "text/javascript", + } { + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + if rec.Code != http.StatusOK { + t.Errorf("%s: Code = %d", path, rec.Code) + continue + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, wantType) { + t.Errorf("%s: Content-Type = %q, want %q", path, ct, wantType) + } + } + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/assets/../server.go", nil)) + if rec.Code == http.StatusOK { + t.Error("Pfad-Traversal darf nicht möglich sein") + } +} +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./internal/web/ -run 'TestGuides|TestHealthz|TestAssets|TestUnknownPath' -v` +Expected: FAIL — `undefined: Guides` + +- [ ] **Step 3: `guides.go` schreiben** + +```go +package web + +import "strings" + +// Guide ist eine Kurzanleitung für eine Plattform. +type Guide struct { + Platform string + Client string + Steps []string + Suggested bool +} + +// baseGuides ist die feste Reihenfolge aller Anleitungen. +var baseGuides = []Guide{ + { + Platform: "Windows", + Client: "OpenVPN Community Client", + Steps: []string{ + "Laden Sie den OpenVPN Community Client von openvpn.net herunter und installieren Sie ihn.", + "Laden Sie hier im Portal Ihre .ovpn-Datei herunter.", + "Klicken Sie mit der rechten Maustaste auf das OpenVPN-Symbol im Infobereich und wählen Sie „Datei importieren“.", + "Wählen Sie die heruntergeladene .ovpn-Datei aus.", + "Klicken Sie erneut auf das Symbol und wählen Sie „Verbinden“.", + }, + }, + { + Platform: "macOS", + Client: "Tunnelblick oder Viscosity", + Steps: []string{ + "Installieren Sie Tunnelblick (kostenfrei) oder Viscosity.", + "Laden Sie hier im Portal Ihre Konfiguration herunter — für Viscosity das Viscosity-Paket, für Tunnelblick die .ovpn-Datei.", + "Öffnen Sie die heruntergeladene Datei per Doppelklick; die Konfiguration wird importiert.", + "Starten Sie die Verbindung über das Symbol in der Menüleiste.", + }, + }, + { + Platform: "iOS / iPadOS", + Client: "OpenVPN Connect", + Steps: []string{ + "Installieren Sie „OpenVPN Connect“ aus dem App Store.", + "Laden Sie hier im Portal Ihre .ovpn-Datei herunter.", + "Öffnen Sie die Datei aus der Dateien-App heraus und wählen Sie „Teilen“ → „OpenVPN Connect“.", + "Bestätigen Sie den Import mit „Add“ und erlauben Sie die VPN-Konfiguration.", + }, + }, + { + Platform: "Android", + Client: "OpenVPN Connect", + Steps: []string{ + "Installieren Sie „OpenVPN Connect“ aus dem Google Play Store.", + "Laden Sie hier im Portal Ihre .ovpn-Datei herunter.", + "Öffnen Sie OpenVPN Connect, wählen Sie „Import“ → „File“ und dann die heruntergeladene Datei.", + "Bestätigen Sie den Import und erlauben Sie die VPN-Verbindung.", + }, + }, +} + +// detectPlatform rät die Plattform aus dem User-Agent. +// Das Ergebnis ist ausdrücklich nur ein Vorschlag, nie ein Filter. +func detectPlatform(userAgent string) string { + ua := strings.ToLower(userAgent) + switch { + case strings.Contains(ua, "iphone"), strings.Contains(ua, "ipad"), strings.Contains(ua, "ipod"): + return "iOS / iPadOS" + case strings.Contains(ua, "android"): + return "Android" + case strings.Contains(ua, "windows"): + return "Windows" + case strings.Contains(ua, "mac os x"), strings.Contains(ua, "macintosh"): + return "macOS" + default: + return "" + } +} + +// Guides liefert alle Anleitungen; die erkannte Plattform steht vorn und ist +// als Vorschlag markiert. Es wird niemals etwas ausgeblendet. +func Guides(userAgent string) []Guide { + detected := detectPlatform(userAgent) + out := make([]Guide, 0, len(baseGuides)) + if detected != "" { + for _, g := range baseGuides { + if g.Platform == detected { + g.Suggested = true + out = append(out, g) + } + } + } + for _, g := range baseGuides { + if g.Platform != detected { + out = append(out, g) + } + } + return out +} +``` + +Reihenfolge-Hinweis: iOS wird vor Android geprüft, weil manche iPad-User-Agents „Macintosh“ enthalten; macOS steht deshalb am Ende der Kette. + +- [ ] **Step 4: `handlers_static.go` schreiben** + +```go +package web + +import ( + "encoding/json" + "net/http" +) + +// guidesData füllt guides.html. +type guidesData struct { + Guides []Guide +} + +func (s *Server) handleGuides(w http.ResponseWriter, r *http.Request, sess *Session) { + s.renderPage(w, r, http.StatusOK, "guides", sess, + PageData{Data: guidesData{Guides: Guides(r.UserAgent())}}) +} + +// handleHealth beantwortet Monitoring-Anfragen ohne Authentifizierung und +// ohne sensible Details — nur boolesche Zustände und die Sessionzahl. +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + report := HealthReport{OK: true, OPNsense: true, Directory: true} + if s.d.Health != nil { + report = s.d.Health.Check(r.Context()) + } + report.Sessions = s.d.Sessions.Count() + + status := http.StatusOK + if !report.OK { + status = http.StatusServiceUnavailable + } + NoStore(w) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + json.NewEncoder(w).Encode(report) +} +``` + +Die Stubs `handleGuides` und `handleHealth` am Ende von `server.go` löschen. + +- [ ] **Step 5: Tests laufen lassen** + +Run: `go test ./internal/web/ -v` +Expected: PASS + +Hinweis zu `TestAssetsAreServed`: Der Pfad `/assets/../server.go` wird bereits von `net/http` normalisiert und landet auf `/server.go` → 404 über `handleNotFound`. Zusätzlich schützt `fs.ReadFile` über `embed.FS`, das keine Traversal erlaubt. + +- [ ] **Step 6: Commit** + +```bash +git add internal/web +git commit -m "feat(web): Anleitungsseite mit User-Agent-Vorschlag und /healthz für Monitoring" +``` + +--- + +## Task 16: `vpnportal check` — Startup-Validierung + +**Files:** +- Create: `internal/check/check.go` +- Test: `internal/check/check_test.go` +- Modify: `cmd/vpnportal/check.go` + +**Interfaces:** +- Consumes: `config.Config`, `config.CheckFileMode`/`CheckDirMode`, `opnsense.Client`, `auth.AD` +- Produces: + - `type Result struct { Name string; OK bool; Detail string }` + - `type Report struct { Results []Result }`; `func (r *Report) OK() bool`; `func (r *Report) WriteTo(w io.Writer) (int64, error)` — Zeilen `✓ Name` / `✗ Name — Detail` + - `type Checker struct { Cfg *config.Config; ConfigPath string; FW Pinger; Dir DirectoryChecker; Now func() time.Time }` + - `type Pinger interface { Ping(ctx context.Context) (time.Time, error) }` + - `type DirectoryChecker interface { ResolveGroupDN(ctx context.Context) (string, error); Lookup(ctx context.Context, user string) (*auth.LookupResult, error) }` + - `func (c *Checker) Run(ctx context.Context, testAuthUser string) *Report` + - Prüfpunkte in dieser Reihenfolge: `Konfiguration`, `Dateirechte`, `OPNsense-Verbindung`, `Verzeichnisdienst`, `Systemzeit`, optional `Testanmeldung` + - `maxClockSkew = 2 * time.Minute` + +- [ ] **Step 1: Failing test schreiben** + +`internal/check/check_test.go`: + +```go +package check + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.ravensburg.dev/cabele/opnsense-portal/internal/auth" + "git.ravensburg.dev/cabele/opnsense-portal/internal/config" + "git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense" +) + +type fakePinger struct { + at time.Time + err error +} + +func (f *fakePinger) Ping(ctx context.Context) (time.Time, error) { return f.at, f.err } + +type fakeDir struct { + dn string + dnErr error + lookup *auth.LookupResult + lookErr error +} + +func (f *fakeDir) ResolveGroupDN(ctx context.Context) (string, error) { return f.dn, f.dnErr } +func (f *fakeDir) Lookup(ctx context.Context, u string) (*auth.LookupResult, error) { + return f.lookup, f.lookErr +} + +var checkNow = time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + +func newChecker(t *testing.T, fw Pinger, dir DirectoryChecker) *Checker { + t.Helper() + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "config.yaml") + if err := os.WriteFile(cfgPath, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + os.Chmod(cfgPath, 0o600) + + logDir := filepath.Join(tmp, "log") + os.MkdirAll(logDir, 0o750) + os.Chmod(logDir, 0o750) + + cfg := config.Defaults() + cfg.Portal.TLSCert = filepath.Join(tmp, "portal.crt") + cfg.Portal.TLSKey = filepath.Join(tmp, "portal.key") + os.WriteFile(cfg.Portal.TLSCert, []byte("cert"), 0o644) + os.WriteFile(cfg.Portal.TLSKey, []byte("key"), 0o600) + os.Chmod(cfg.Portal.TLSKey, 0o600) + cfg.OPNsense.URL = "https://fw01.firma.local" + cfg.OPNsense.APIKey, cfg.OPNsense.APISecret = "k", "s" + cfg.AD.Domain = "firma.local" + cfg.AD.Servers = []string{"dc01.firma.local"} + cfg.AD.BindUser, cfg.AD.BindPassword = "svc@firma.local", "pw" + cfg.AD.VPNGroup = "VPN-Users" + cfg.Logging.AuditLog = filepath.Join(logDir, "audit.log") + + return &Checker{Cfg: cfg, ConfigPath: cfgPath, FW: fw, Dir: dir, + Now: func() time.Time { return checkNow }} +} + +func TestAllChecksPass(t *testing.T) { + c := newChecker(t, &fakePinger{at: checkNow}, + &fakeDir{dn: "CN=VPN-Users,DC=firma,DC=local"}) + rep := c.Run(context.Background(), "") + + if !rep.OK() { + var b strings.Builder + rep.WriteTo(&b) + t.Fatalf("alle Prüfungen sollten bestehen:\n%s", b.String()) + } + for _, want := range []string{"Konfiguration", "Dateirechte", "OPNsense", "Verzeichnisdienst", "Systemzeit"} { + var found bool + for _, r := range rep.Results { + if strings.Contains(r.Name, want) { + found = true + } + } + if !found { + t.Errorf("Prüfpunkt %q fehlt", want) + } + } +} + +func TestFailedAPISecretIsNamed(t *testing.T) { + c := newChecker(t, &fakePinger{err: opnsense.ErrUnauthorized}, + &fakeDir{dn: "CN=VPN-Users,DC=firma,DC=local"}) + rep := c.Run(context.Background(), "") + + if rep.OK() { + t.Fatal("falsches Secret muss auffallen") + } + var b strings.Builder + rep.WriteTo(&b) + out := b.String() + if !strings.Contains(out, "✗") { + t.Errorf("Ausgabe muss ein ✗ enthalten:\n%s", out) + } + if !strings.Contains(strings.ToLower(out), "zugangsdaten") { + t.Errorf("Ursache muss benannt werden:\n%s", out) + } +} + +func TestMissingAPIPermissionIsDistinguished(t *testing.T) { + c := newChecker(t, &fakePinger{err: opnsense.ErrForbidden}, + &fakeDir{dn: "CN=x"}) + rep := c.Run(context.Background(), "") + var b strings.Builder + rep.WriteTo(&b) + if !strings.Contains(b.String(), "Client Export") { + t.Errorf("fehlende Berechtigung muss den nötigen Privilegnamen nennen:\n%s", b.String()) + } +} + +func TestUnreachableDCIsNamed(t *testing.T) { + c := newChecker(t, &fakePinger{at: checkNow}, + &fakeDir{dnErr: errors.New("dc01.firma.local: connection refused")}) + rep := c.Run(context.Background(), "") + if rep.OK() { + t.Fatal("nicht erreichbarer DC muss auffallen") + } + var b strings.Builder + rep.WriteTo(&b) + if !strings.Contains(b.String(), "dc01.firma.local") { + t.Errorf("betroffener DC muss genannt werden:\n%s", b.String()) + } +} + +func TestLooseConfigPermissionsAreDetected(t *testing.T) { + c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"}) + os.Chmod(c.ConfigPath, 0o644) + rep := c.Run(context.Background(), "") + if rep.OK() { + t.Fatal("0644-Config muss auffallen") + } + var b strings.Builder + rep.WriteTo(&b) + if !strings.Contains(b.String(), "chmod") { + t.Errorf("Reparaturbefehl muss genannt werden:\n%s", b.String()) + } +} + +func TestLooseTLSKeyIsDetected(t *testing.T) { + c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"}) + os.Chmod(c.Cfg.Portal.TLSKey, 0o644) + rep := c.Run(context.Background(), "") + if rep.OK() { + t.Fatal("world-readable TLS-Key muss auffallen") + } +} + +func TestClockSkewWarns(t *testing.T) { + c := newChecker(t, &fakePinger{at: checkNow.Add(5 * time.Minute)}, &fakeDir{dn: "CN=x"}) + rep := c.Run(context.Background(), "") + var b strings.Builder + rep.WriteTo(&b) + out := b.String() + if !strings.Contains(out, "NTP") { + t.Errorf("Zeitabweichung muss auf NTP hinweisen:\n%s", out) + } + if rep.OK() { + t.Error("Zeitabweichung über der Schwelle muss als Fehler zählen") + } +} + +func TestSmallClockSkewIsFine(t *testing.T) { + c := newChecker(t, &fakePinger{at: checkNow.Add(30 * time.Second)}, + &fakeDir{dn: "CN=VPN-Users,DC=firma,DC=local"}) + if rep := c.Run(context.Background(), ""); !rep.OK() { + var b strings.Builder + rep.WriteTo(&b) + t.Fatalf("30 Sekunden Abweichung sind unkritisch:\n%s", b.String()) + } +} + +func TestTestAuthShowsLookupResult(t *testing.T) { + dir := &fakeDir{ + dn: "CN=VPN-Users,DC=firma,DC=local", + lookup: &auth.LookupResult{ + DN: "CN=Max Mueller,OU=Users,DC=firma,DC=local", SAMAccountName: "mmueller", InVPNGroup: true}, + } + c := newChecker(t, &fakePinger{at: checkNow}, dir) + rep := c.Run(context.Background(), "mmueller") + + var b strings.Builder + rep.WriteTo(&b) + out := b.String() + for _, want := range []string{"CN=Max Mueller", "mmueller", "VPN-Users"} { + if !strings.Contains(out, want) { + t.Errorf("Ausgabe von --test-auth enthält %q nicht:\n%s", want, out) + } + } +} + +func TestTestAuthReportsMissingGroupMembership(t *testing.T) { + dir := &fakeDir{ + dn: "CN=VPN-Users,DC=firma,DC=local", + lookup: &auth.LookupResult{DN: "CN=J Doe", SAMAccountName: "jdoe", InVPNGroup: false}, + } + c := newChecker(t, &fakePinger{at: checkNow}, dir) + rep := c.Run(context.Background(), "jdoe") + if rep.OK() { + t.Fatal("fehlende Gruppenmitgliedschaft muss als Fehler gelten") + } +} + +func TestReportOutputFormat(t *testing.T) { + rep := &Report{Results: []Result{ + {Name: "Konfiguration", OK: true}, + {Name: "Dateirechte", OK: false, Detail: "config.yaml ist 0644"}, + }} + var b strings.Builder + if _, err := rep.WriteTo(&b); err != nil { + t.Fatal(err) + } + out := b.String() + if !strings.Contains(out, "✓ Konfiguration") { + t.Errorf("Erfolgszeile falsch:\n%s", out) + } + if !strings.Contains(out, "✗ Dateirechte") || !strings.Contains(out, "config.yaml ist 0644") { + t.Errorf("Fehlerzeile falsch:\n%s", out) + } +} +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./internal/check/ -v` +Expected: FAIL — `undefined: Checker` + +- [ ] **Step 3: `check.go` schreiben** + +```go +// Package check prüft beim Start und über das check-Kommando die gesamte +// Wirkungskette: Konfiguration, Dateirechte, Firewall, Verzeichnis, Uhrzeit. +package check + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "git.ravensburg.dev/cabele/opnsense-portal/internal/auth" + "git.ravensburg.dev/cabele/opnsense-portal/internal/config" + "git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense" +) + +// maxClockSkew ist die tolerierte Abweichung zur Firewall-Uhrzeit. +const maxClockSkew = 2 * time.Minute + +// Result ist das Ergebnis eines Prüfpunkts. +type Result struct { + Name string + OK bool + Detail string +} + +// Report bündelt alle Prüfergebnisse. +type Report struct { + Results []Result +} + +// OK meldet, ob alle Prüfungen bestanden wurden. +func (r *Report) OK() bool { + for _, res := range r.Results { + if !res.OK { + return false + } + } + return true +} + +// WriteTo gibt die Checkliste aus. +func (r *Report) WriteTo(w io.Writer) (int64, error) { + var total int64 + for _, res := range r.Results { + mark := "✓" + if !res.OK { + mark = "✗" + } + line := fmt.Sprintf("%s %s", mark, res.Name) + if res.Detail != "" { + line += "\n " + strings.ReplaceAll(res.Detail, "\n", "\n ") + } + n, err := io.WriteString(w, line+"\n") + total += int64(n) + if err != nil { + return total, err + } + } + return total, nil +} + +// Pinger ist der benötigte Ausschnitt des Firewall-Clients. +type Pinger interface { + Ping(ctx context.Context) (time.Time, error) +} + +// DirectoryChecker ist der benötigte Ausschnitt des AD-Authenticators. +type DirectoryChecker interface { + ResolveGroupDN(ctx context.Context) (string, error) + Lookup(ctx context.Context, username string) (*auth.LookupResult, error) +} + +// Checker führt die Prüfungen aus. +type Checker struct { + Cfg *config.Config + ConfigPath string + FW Pinger + Dir DirectoryChecker + Now func() time.Time +} + +// Run führt alle Prüfungen aus. testAuthUser leer = keine Testanmeldung. +func (c *Checker) Run(ctx context.Context, testAuthUser string) *Report { + now := c.Now + if now == nil { + now = time.Now + } + rep := &Report{} + add := func(name string, err error, detail string) { + if err != nil { + rep.Results = append(rep.Results, Result{Name: name, OK: false, Detail: err.Error()}) + return + } + rep.Results = append(rep.Results, Result{Name: name, OK: true, Detail: detail}) + } + + // 1. Konfiguration + add("Konfiguration", c.Cfg.Validate(), "gelesen und plausibel") + + // 2. Dateirechte + add("Dateirechte", c.checkPermissions(), "Konfiguration, Secrets und Log-Verzeichnis sind ausreichend geschützt") + + // 3. OPNsense + serverTime, fwErr := c.checkFirewall(ctx) + add("OPNsense-Verbindung", fwErr, "Export-API erreichbar, Key und Berechtigung in Ordnung") + + // 4. Verzeichnisdienst + groupDN, dirErr := c.checkDirectory(ctx) + add("Verzeichnisdienst", dirErr, "Anmeldung des Dienstkontos erfolgreich, Gruppe aufgelöst: "+groupDN) + + // 5. Systemzeit (nur sinnvoll, wenn die Firewall geantwortet hat) + add("Systemzeit", checkClock(now(), serverTime, fwErr), "Abweichung zur Firewall unterhalb der Schwelle") + + // 6. Optionale Testanmeldung + if testAuthUser != "" { + detail, err := c.checkTestAuth(ctx, testAuthUser) + add("Testanmeldung "+testAuthUser, err, detail) + } + return rep +} + +// checkPermissions prüft Config, Secret-Dateien, TLS-Key und Log-Verzeichnis. +func (c *Checker) checkPermissions() error { + var errs []error + if c.ConfigPath != "" { + if err := config.CheckFileMode(c.ConfigPath, 0o640); err != nil { + errs = append(errs, err) + } + } + for _, p := range []string{ + c.Cfg.OPNsense.APIKeyFile, c.Cfg.OPNsense.APISecretFile, c.Cfg.AD.BindPasswordFile, + } { + if p == "" { + continue + } + if err := config.CheckFileMode(p, 0o640); err != nil { + errs = append(errs, err) + } + } + // Der private TLS-Schlüssel darf niemandem außer dem Eigentümer offenstehen. + if c.Cfg.Portal.TLSKey != "" { + if err := config.CheckFileMode(c.Cfg.Portal.TLSKey, 0o600); err != nil { + errs = append(errs, err) + } + } + if c.Cfg.Logging.AuditLog != "" { + dir := filepath.Dir(c.Cfg.Logging.AuditLog) + if _, err := os.Stat(dir); err == nil { + if err := config.CheckDirMode(dir, 0o750); err != nil { + errs = append(errs, err) + } + } else { + errs = append(errs, fmt.Errorf("Log-Verzeichnis %s existiert nicht: %w", dir, err)) + } + } + return errors.Join(errs...) +} + +// checkFirewall ruft den Export-Endpunkt auf und übersetzt die Fehler. +func (c *Checker) checkFirewall(ctx context.Context) (time.Time, error) { + if c.FW == nil { + return time.Time{}, errors.New("kein Firewall-Client konfiguriert") + } + t, err := c.FW.Ping(ctx) + switch { + case err == nil: + return t, nil + case errors.Is(err, opnsense.ErrUnauthorized): + return time.Time{}, fmt.Errorf( + "die Zugangsdaten wurden abgelehnt — opnsense.api_key und api_secret prüfen (%v)", err) + case errors.Is(err, opnsense.ErrForbidden): + return time.Time{}, fmt.Errorf( + "der API-Benutzer hat keine Berechtigung — auf der OPNsense das Privileg "+ + "\"VPN: OpenVPN Client Export\" zuweisen (%v)", err) + case errors.Is(err, opnsense.ErrUnreachable): + return time.Time{}, fmt.Errorf( + "die Firewall ist nicht erreichbar — URL, Netzwerkweg und CA prüfen (%v)", err) + default: + return time.Time{}, err + } +} + +// checkDirectory bindet den Service-Account und löst die Gruppe auf. +func (c *Checker) checkDirectory(ctx context.Context) (string, error) { + if c.Dir == nil { + return "", errors.New("kein Verzeichnis-Client konfiguriert") + } + dn, err := c.Dir.ResolveGroupDN(ctx) + if err != nil { + return "", fmt.Errorf("Verzeichnisdienst nicht nutzbar: %w", err) + } + if dn == "" { + return "", fmt.Errorf("Gruppe %q konnte nicht aufgelöst werden", c.Cfg.AD.VPNGroup) + } + return dn, nil +} + +// checkClock vergleicht lokale Zeit und Firewall-Zeit. +func checkClock(local, remote time.Time, fwErr error) error { + if fwErr != nil { + return errors.New("nicht prüfbar, weil die Firewall nicht geantwortet hat") + } + if remote.IsZero() { + return nil // Die Firewall hat keinen auswertbaren Date-Header geliefert. + } + skew := local.Sub(remote) + if skew < 0 { + skew = -skew + } + if skew > maxClockSkew { + return fmt.Errorf( + "die Systemzeit weicht um %s von der Firewall ab — bitte NTP einrichten "+ + "(abgelaufene Zertifikate würden sonst falsch bewertet)", skew.Round(time.Second)) + } + return nil +} + +// checkTestAuth spielt Suche und Gruppenprüfung ohne Passwort durch. +func (c *Checker) checkTestAuth(ctx context.Context, username string) (string, error) { + res, err := c.Dir.Lookup(ctx, username) + if err != nil { + return "", err + } + detail := fmt.Sprintf("DN: %s\nsAMAccountName: %s\nMitglied in %s: ja", + res.DN, res.SAMAccountName, c.Cfg.AD.VPNGroup) + if !res.InVPNGroup { + return "", fmt.Errorf("DN: %s\nsAMAccountName: %s\nMitglied in %s: NEIN — "+ + "dieser Benutzer würde abgewiesen", res.DN, res.SAMAccountName, c.Cfg.AD.VPNGroup) + } + return detail, nil +} +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `go test ./internal/check/ -v` +Expected: PASS + +- [ ] **Step 5: `cmd/vpnportal/check.go` verdrahten** + +```go +package main + +import ( + "context" + "fmt" + "io" + "time" + + "git.ravensburg.dev/cabele/opnsense-portal/internal/auth" + "git.ravensburg.dev/cabele/opnsense-portal/internal/check" + "git.ravensburg.dev/cabele/opnsense-portal/internal/config" + "git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense" +) + +func runCheck(args []string, stdout, stderr io.Writer) int { + fs, path := configFlags("check", stderr) + testAuth := fs.String("test-auth", "", + "Suche und Gruppenprüfung für einen konkreten Benutzer durchspielen (ohne Passwort)") + if err := fs.Parse(args); err != nil { + return 2 + } + + cfg, err := config.Load(*path, nil) + if err != nil { + // Ohne lesbare Konfiguration lässt sich nichts weiter prüfen. + fmt.Fprintf(stderr, "✗ Konfiguration\n %v\n", err) + return 1 + } + + fw, ad, buildErr := buildBackends(cfg) + if buildErr != nil { + fmt.Fprintf(stderr, "✗ Initialisierung\n %v\n", buildErr) + return 1 + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + c := &check.Checker{Cfg: cfg, ConfigPath: *path, FW: fw, Dir: ad} + report := c.Run(ctx, *testAuth) + report.WriteTo(stdout) + + if !report.OK() { + fmt.Fprintln(stdout, "\nEs sind Fehler aufgetreten. Das Portal würde nicht korrekt arbeiten.") + return 1 + } + fmt.Fprintln(stdout, "\nAlle Prüfungen bestanden.") + return 0 +} + +// buildBackends erzeugt Firewall- und Verzeichnis-Client aus der Konfiguration. +// Wird von serve und check gemeinsam genutzt. +func buildBackends(cfg *config.Config) (*opnsense.Client, *auth.AD, error) { + fw, err := opnsense.New(opnsense.Options{ + BaseURL: cfg.OPNsense.URL, + APIKey: cfg.OPNsense.APIKey, + APISecret: cfg.OPNsense.APISecret, + CAFile: cfg.OPNsense.CAFile, + InsecureSkipVerify: cfg.OPNsense.InsecureSkipVerify, + Timeout: 20 * time.Second, + }) + if err != nil { + return nil, nil, err + } + ad, err := auth.NewAD(auth.Options{ + Servers: cfg.AD.Servers, + Port: cfg.AD.Port, + TLSMode: cfg.AD.TLSMode, + BaseDN: cfg.EffectiveBaseDN(), + BindUser: cfg.AD.BindUser, + BindPassword: cfg.AD.BindPassword, + VPNGroup: cfg.AD.VPNGroup, + CAFile: cfg.AD.CAFile, + Timeout: time.Duration(cfg.AD.Timeout), + }) + if err != nil { + return nil, nil, err + } + return fw, ad, nil +} +``` + +- [ ] **Step 6: Build und Tests prüfen** + +Run: `go build ./... && go test ./... ` +Expected: Build ohne Ausgabe, alle Tests PASS + +- [ ] **Step 7: `check` gegen eine unbrauchbare Konfiguration ausprobieren** + +```bash +mkdir -p /tmp/vpncheck && printf 'portal:\n tls_cert_typo: "x"\n' > /tmp/vpncheck/config.yaml +chmod 600 /tmp/vpncheck/config.yaml +go run ./cmd/vpnportal check --config /tmp/vpncheck/config.yaml; echo "Exit: $?" +``` +Expected: Ausgabe nennt `tls_cert_typo` als unbekanntes Feld, Exit-Code 1 + +- [ ] **Step 8: Commit** + +```bash +git add internal/check cmd/vpnportal +git commit -m "feat(check): Startup-Validierung mit Checkliste und --test-auth" +``` + +--- + +## Task 17: `serve` — Verdrahtung, HTTPS-Server und Signal-Handling + +**Files:** +- Modify: `cmd/vpnportal/serve.go` +- Create: `cmd/vpnportal/health.go` +- Test: `cmd/vpnportal/serve_test.go` + +**Interfaces:** +- Consumes: alles Bisherige +- Produces: + - `func runServe(args []string, stdout, stderr io.Writer) int` + - `type healthChecker struct { fw check.Pinger; dir web.HealthDirectory; clock func() time.Time }` mit Ergebnis-Cache von 30 s, damit Monitoring die Backends nicht flutet + - `func newTLSConfig(certFile, keyFile string) (*tls.Config, error)` — TLS 1.2+, moderne Cipher-Reihenfolge + - Startverhalten: Config laden → `check` ausführen → bei Fehlern abbrechen → Audit `startup` mit Version und Config-Prüfsumme → HTTPS-Server starten + - Signale: `SIGHUP` → Audit-Log neu öffnen; `SIGINT`/`SIGTERM` → geordneter Shutdown mit 10 s Frist + - Hintergrund: Session- und Limiter-GC alle 60 s + +- [ ] **Step 1: Failing test schreiben** + +`cmd/vpnportal/serve_test.go`: + +```go +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestServeRejectsMissingConfig(t *testing.T) { + var out, errOut bytes.Buffer + code := runServe([]string{"--config", "/gibt/es/nicht.yaml"}, &out, &errOut) + if code == 0 { + t.Fatal("fehlende Config muss zum Abbruch führen") + } + if !strings.Contains(errOut.String(), "nicht.yaml") { + t.Errorf("Fehlermeldung muss den Pfad nennen: %q", errOut.String()) + } +} + +func TestServeRejectsLooseConfigPermissions(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "config.yaml") + os.WriteFile(p, []byte("portal:\n listen: \"127.0.0.1:0\"\n"), 0o644) + os.Chmod(p, 0o644) + + var out, errOut bytes.Buffer + if code := runServe([]string{"--config", p}, &out, &errOut); code == 0 { + t.Fatal("zu offene Dateirechte müssen den Start verhindern") + } + if !strings.Contains(errOut.String(), "chmod") { + t.Errorf("Fehlermeldung sollte den Reparaturbefehl nennen: %q", errOut.String()) + } +} + +func TestConfigChecksumIsStable(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "config.yaml") + os.WriteFile(p, []byte("inhalt"), 0o600) + os.Chmod(p, 0o600) + + a, err := configChecksum(p) + if err != nil { + t.Fatal(err) + } + b, _ := configChecksum(p) + if a != b || a == "" { + t.Fatalf("Prüfsumme instabil: %q vs %q", a, b) + } + if len(a) != 12 { + t.Errorf("Prüfsumme sollte gekürzt sein (12 Zeichen), ist %d", len(a)) + } + + os.WriteFile(p, []byte("anderer inhalt"), 0o600) + if c, _ := configChecksum(p); c == a { + t.Error("geänderte Datei muss eine andere Prüfsumme ergeben") + } +} + +func TestNewTLSConfigRejectsMissingFiles(t *testing.T) { + if _, err := newTLSConfig("/nix.crt", "/nix.key"); err == nil { + t.Fatal("fehlende Zertifikatsdateien müssen abgelehnt werden") + } +} +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `go test ./cmd/vpnportal/ -v` +Expected: FAIL — `undefined: configChecksum` + +- [ ] **Step 3: `health.go` schreiben** + +```go +package main + +import ( + "context" + "sync" + "time" + + "git.ravensburg.dev/cabele/opnsense-portal/internal/check" + "git.ravensburg.dev/cabele/opnsense-portal/internal/web" +) + +// healthCacheTTL verhindert, dass häufige Monitoring-Abfragen Firewall und +// Verzeichnis belasten. +const healthCacheTTL = 30 * time.Second + +// directoryPinger ist der für /healthz benötigte Ausschnitt des AD-Clients. +type directoryPinger interface { + ResolveGroupDN(ctx context.Context) (string, error) +} + +// healthChecker beantwortet /healthz mit kurz gecachten Ergebnissen. +type healthChecker struct { + fw check.Pinger + dir directoryPinger + clock func() time.Time + + mu sync.Mutex + cached web.HealthReport + cachedAt time.Time +} + +func newHealthChecker(fw check.Pinger, dir directoryPinger) *healthChecker { + return &healthChecker{fw: fw, dir: dir, clock: time.Now} +} + +func (h *healthChecker) Check(ctx context.Context) web.HealthReport { + now := h.clock() + h.mu.Lock() + if !h.cachedAt.IsZero() && now.Sub(h.cachedAt) < healthCacheTTL { + rep := h.cached + h.mu.Unlock() + return rep + } + h.mu.Unlock() + + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + rep := web.HealthReport{} + _, fwErr := h.fw.Ping(ctx) + rep.OPNsense = fwErr == nil + _, dirErr := h.dir.ResolveGroupDN(ctx) + rep.Directory = dirErr == nil + rep.OK = rep.OPNsense && rep.Directory + + h.mu.Lock() + h.cached, h.cachedAt = rep, now + h.mu.Unlock() + return rep +} + +var _ web.HealthChecker = (*healthChecker)(nil) +``` + +- [ ] **Step 4: `serve.go` schreiben** + +```go +package main + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "git.ravensburg.dev/cabele/opnsense-portal/internal/audit" + "git.ravensburg.dev/cabele/opnsense-portal/internal/certmatch" + "git.ravensburg.dev/cabele/opnsense-portal/internal/check" + "git.ravensburg.dev/cabele/opnsense-portal/internal/config" + "git.ravensburg.dev/cabele/opnsense-portal/internal/portal" + "git.ravensburg.dev/cabele/opnsense-portal/internal/web" +) + +const defaultConfigPath = "/etc/vpnportal/config.yaml" + +// providerCacheTTL ist die einzige erlaubte Cache-Ausnahme (VPN-Instanzen). +const providerCacheTTL = 5 * time.Minute + +func configFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(stderr) + path := fs.String("config", defaultConfigPath, "Pfad zur Konfigurationsdatei") + return fs, path +} + +// configChecksum liefert eine gekürzte Prüfsumme der Konfigurationsdatei +// für das startup-Ereignis — sie erlaubt, Konfigurationsstände zu +// unterscheiden, ohne Inhalte zu protokollieren. +func configChecksum(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:])[:12], nil +} + +// newTLSConfig lädt Zertifikat und Schlüssel des Portals. +func newTLSConfig(certFile, keyFile string) (*tls.Config, error) { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, fmt.Errorf("TLS-Zertifikat/-Schlüssel konnten nicht geladen werden: %w", err) + } + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256}, + }, nil +} + +func runServe(args []string, stdout, stderr io.Writer) int { + fs, path := configFlags("serve", stderr) + if err := fs.Parse(args); err != nil { + return 2 + } + + cfg, err := config.Load(*path, nil) + if err != nil { + fmt.Fprintf(stderr, "Fehler: %v\n", err) + return 1 + } + if cfg.OPNsense.InsecureSkipVerify { + // Bewusst bei jedem Start, nicht nur einmalig. + fmt.Fprintln(stderr, + "WARNUNG: opnsense.insecure_skip_verify ist aktiv — "+ + "die TLS-Verbindung zur Firewall wird NICHT geprüft. Nur für Tests verwenden.") + } + + fw, ad, err := buildBackends(cfg) + if err != nil { + fmt.Fprintf(stderr, "Fehler: %v\n", err) + return 1 + } + + // Startup-Validierung: das Portal startet nicht mit kaputter Umgebung. + startCtx, startCancel := context.WithTimeout(context.Background(), 60*time.Second) + report := (&check.Checker{Cfg: cfg, ConfigPath: *path, FW: fw, Dir: ad}).Run(startCtx, "") + startCancel() + report.WriteTo(stdout) + if !report.OK() { + fmt.Fprintln(stderr, "\nStart abgebrochen: die Startprüfung ist fehlgeschlagen.") + return 1 + } + + auditLog, err := audit.New(cfg.Logging.AuditLog, audit.Options{ + MaxSizeMB: cfg.Logging.MaxSizeMB, + MaxBackups: cfg.Logging.MaxBackups, + Compress: cfg.Logging.Compress, + Stdout: stdout, + }) + if err != nil { + fmt.Fprintf(stderr, "Fehler: %v\n", err) + return 1 + } + defer auditLog.Close() + + matcher, err := certmatch.NewMatcher(cfg.Matching.CNPattern, cfg.Matching.CNRegex) + if err != nil { + fmt.Fprintf(stderr, "Fehler: %v\n", err) + return 1 + } + certs := portal.NewSource(fw, matcher, providerCacheTTL, time.Now) + + renderer, err := web.NewRenderer(web.Brand{ + Title: cfg.Portal.Title, + SupportContact: cfg.Portal.SupportContact, + Version: version, + }) + if err != nil { + fmt.Fprintf(stderr, "Fehler: %v\n", err) + return 1 + } + + sessions := web.NewSessionStore(time.Duration(cfg.Portal.SessionTTL), time.Now) + limiter := web.NewLimiter(time.Now) + + srv, err := web.NewServer(web.Deps{ + Auth: ad, + Certs: certs, + Audit: auditLog, + Sessions: sessions, + Limiter: limiter, + Renderer: renderer, + Health: newHealthChecker(fw, ad), + Clock: time.Now, + LogoPath: cfg.Portal.LogoFile, + SupportContact: cfg.Portal.SupportContact, + SessionTTL: time.Duration(cfg.Portal.SessionTTL), + }) + if err != nil { + fmt.Fprintf(stderr, "Fehler: %v\n", err) + return 1 + } + + tlsCfg, err := newTLSConfig(cfg.Portal.TLSCert, cfg.Portal.TLSKey) + if err != nil { + fmt.Fprintf(stderr, "Fehler: %v\n", err) + return 1 + } + + sum, _ := configChecksum(*path) + auditLog.Log(audit.Event{ + Event: audit.EventStartup, Version: versionString(), ConfigSum: sum, + Detail: "listen=" + cfg.Portal.Listen, + }) + + httpSrv := &http.Server{ + Addr: cfg.Portal.Listen, + Handler: srv.Handler(), + TLSConfig: tlsCfg, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + // WriteTimeout bleibt großzügig, weil Downloads gestreamt werden. + WriteTimeout: 5 * time.Minute, + IdleTimeout: 60 * time.Second, + ErrorLog: nil, + } + + // Hintergrundaufräumen für Sessions und Rate-Limiter. + stopGC := make(chan struct{}) + go func() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + sessions.GC() + limiter.GC() + case <-stopGC: + return + } + } + }() + defer close(stopGC) + + // SIGHUP öffnet das Audit-Log neu (logrotate-Umgebungen). + hup := make(chan os.Signal, 1) + signal.Notify(hup, syscall.SIGHUP) + defer signal.Stop(hup) + go func() { + for range hup { + if err := auditLog.Reopen(); err != nil { + fmt.Fprintf(stderr, "Audit-Log konnte nicht neu geöffnet werden: %v\n", err) + } + } + }() + + // Geordnetes Herunterfahren. + shutdown := make(chan os.Signal, 1) + signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM) + errCh := make(chan error, 1) + + go func() { + fmt.Fprintf(stdout, "%s lauscht auf https://%s\n", versionString(), cfg.Portal.Listen) + errCh <- httpSrv.ListenAndServeTLS("", "") + }() + + select { + case err := <-errCh: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + fmt.Fprintf(stderr, "Fehler: Server beendet: %v\n", err) + return 1 + } + return 0 + case sig := <-shutdown: + fmt.Fprintf(stdout, "Signal %s empfangen, fahre herunter…\n", sig) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := httpSrv.Shutdown(ctx); err != nil { + fmt.Fprintf(stderr, "Herunterfahren nicht sauber beendet: %v\n", err) + return 1 + } + return 0 + } +} +``` + +Hinweis: `auditLog` erfüllt `web.AuditSink`, weil `*audit.Logger` die Methode `Log(audit.Event)` hat. + +- [ ] **Step 5: Tests laufen lassen** + +Run: `go test ./... ` +Expected: PASS + +- [ ] **Step 6: Ende-zu-Ende-Sichtprüfung im Browser vorbereiten** + +Selbstsigniertes Zertifikat und Minimal-Config erzeugen; das Portal startet erst, wenn AD und OPNsense antworten, deshalb wird hier nur der Fehlerpfad geprüft: + +```bash +mkdir -p /tmp/vpnportal-dev/log +openssl req -x509 -newkey rsa:2048 -nodes -days 30 \ + -keyout /tmp/vpnportal-dev/portal.key -out /tmp/vpnportal-dev/portal.crt \ + -subj "/CN=localhost" 2>/dev/null +chmod 600 /tmp/vpnportal-dev/portal.key +cat > /tmp/vpnportal-dev/config.yaml <<'EOF' +portal: + listen: "127.0.0.1:8443" + tls_cert: "/tmp/vpnportal-dev/portal.crt" + tls_key: "/tmp/vpnportal-dev/portal.key" + title: "VPN-Portal Testfirma" + support_contact: "it-helpdesk@testfirma.de" +opnsense: + url: "https://fw01.testfirma.local" + api_key: "testkey" + api_secret: "testsecret" +ad: + domain: "testfirma.local" + servers: ["dc01.testfirma.local"] + bind_user: "svc-vpnportal@testfirma.local" + bind_password: "testpw" + vpn_group: "VPN-Users" +logging: + audit_log: "/tmp/vpnportal-dev/log/audit.log" +EOF +chmod 600 /tmp/vpnportal-dev/config.yaml +go run ./cmd/vpnportal serve --config /tmp/vpnportal-dev/config.yaml; echo "Exit: $?" +``` +Expected: Die Startprüfung meldet `✗ OPNsense-Verbindung` und `✗ Verzeichnisdienst` mit konkreten Meldungen, Exit-Code 1. Genau dieses Verhalten ist gewollt: das Portal startet nicht mit kaputter Umgebung. + +- [ ] **Step 7: Oberfläche isoliert im Browser prüfen** + +Damit die Optik ohne AD/Firewall begutachtet werden kann, einen Testserver mit Fakes starten. Datei `cmd/vpnportal/uidemo_test.go` anlegen: + +```go +//go:build uidemo + +package main + +// Dieser Test startet das Portal mit Attrappen für AD und Firewall auf +// http://127.0.0.1:8080, damit die Oberfläche ohne echte Backends geprüft +// werden kann. Aufruf: +// go test -tags uidemo ./cmd/vpnportal/ -run TestUIDemo -v -timeout 0 +// Anmeldung mit beliebigem Benutzernamen und Passwort. + +import ( + "context" + "net/http" + "testing" + "time" + + "git.ravensburg.dev/cabele/opnsense-portal/internal/audit" + "git.ravensburg.dev/cabele/opnsense-portal/internal/auth" + "git.ravensburg.dev/cabele/opnsense-portal/internal/web" +) + +type demoAuth struct{} + +func (demoAuth) Authenticate(ctx context.Context, u, p string) (*auth.Identity, error) { + return &auth.Identity{Username: "mmueller", Groups: []string{"VPN-Users"}}, nil +} + +type demoCerts struct{} + +func (demoCerts) EntriesFor(ctx context.Context, user string) ([]web.CertEntry, error) { + return []web.CertEntry{ + {Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller", + ValidTo: time.Now().AddDate(1, 0, 0)}, + {Token: "2:b1", InstanceName: "VPN Außendienst", CommonName: "mmueller", + ValidTo: time.Now().AddDate(0, 0, 12)}, + }, nil +} + +func (demoCerts) Export(ctx context.Context, user, vpnID, refID, format string) (*web.ExportStream, error) { + return nil, web.ErrCertNotOwned +} + +type demoAudit struct{ t *testing.T } + +func (d demoAudit) Log(e audit.Event) { d.t.Logf("audit: %+v", e) } + +func TestUIDemo(t *testing.T) { + rn, err := web.NewRenderer(web.Brand{Title: "VPN-Portal Testfirma", Version: "uidemo"}) + if err != nil { + t.Fatal(err) + } + srv, err := web.NewServer(web.Deps{ + Auth: demoAuth{}, Certs: demoCerts{}, Audit: demoAudit{t}, + Sessions: web.NewSessionStore(10*time.Minute, nil), + Limiter: web.NewLimiter(nil), Renderer: rn, + SupportContact: "it-helpdesk@testfirma.de", SessionTTL: 10 * time.Minute, + }) + if err != nil { + t.Fatal(err) + } + t.Log("Oberfläche unter http://127.0.0.1:8080 — mit Strg-C beenden") + // Achtung: Das Cookie ist Secure; für die reine Optikprüfung reicht die + // Login-Seite. Für den angemeldeten Bereich das Zertifikat aus Step 6 + // verwenden und https nutzen. + http.ListenAndServe("127.0.0.1:8080", srv.Handler()) +} +``` + +Ausführen und die Login-Seite im Browser ansehen: + +```bash +go test -tags uidemo ./cmd/vpnportal/ -run TestUIDemo -v -timeout 0 +``` +Expected: Login-Seite mit Titel, Formular, Fußzeile; Layout bricht auf Handybreite sauber um. Anschließend mit Strg-C beenden. + +- [ ] **Step 8: Optionalen Versionscheck implementieren** + +PRD §10 verlangt einen abschaltbaren, read-only Versionscheck mit Default **aus**. + +Zuerst der Test, `cmd/vpnportal/updatecheck_test.go`: + +```go +package main + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestUpdateCheckDisabledMakesNoRequest(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + })) + defer srv.Close() + + if msg := checkForUpdate(context.Background(), false, srv.URL, "1.0.0", srv.Client()); msg != "" { + t.Errorf("bei update_check=false darf nichts gemeldet werden, got %q", msg) + } + if called { + t.Fatal("bei update_check=false darf kein Netzwerkzugriff stattfinden") + } +} + +func TestUpdateCheckReportsNewerVersion(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, `{"tag_name":"v1.4.0"}`) + })) + defer srv.Close() + + msg := checkForUpdate(context.Background(), true, srv.URL, "1.2.0", srv.Client()) + if msg == "" { + t.Fatal("neuere Version muss gemeldet werden") + } + if !contains(msg, "1.4.0") { + t.Errorf("Meldung nennt die neue Version nicht: %q", msg) + } +} + +func TestUpdateCheckSilentWhenCurrent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, `{"tag_name":"v1.2.0"}`) + })) + defer srv.Close() + if msg := checkForUpdate(context.Background(), true, srv.URL, "1.2.0", srv.Client()); msg != "" { + t.Errorf("bei aktueller Version darf nichts gemeldet werden, got %q", msg) + } +} + +func TestUpdateCheckSurvivesUnreachableEndpoint(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + url := srv.URL + client := srv.Client() + srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + // Ein nicht erreichbarer Endpunkt darf den Start niemals stören. + if msg := checkForUpdate(ctx, true, url, "1.2.0", client); msg != "" { + t.Errorf("bei Fehler darf nichts gemeldet werden, got %q", msg) + } +} + +func contains(haystack, needle string) bool { + return len(haystack) >= len(needle) && (func() bool { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false + })() +} +``` + +Run: `go test ./cmd/vpnportal/ -run TestUpdateCheck -v` +Expected: FAIL — `undefined: checkForUpdate` + +Dann `cmd/vpnportal/updatecheck.go`: + +```go +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// releaseURL ist der Endpunkt des Versionschecks. Er wird nur abgefragt, +// wenn portal.update_check ausdrücklich eingeschaltet ist (Default: aus). +const releaseURL = "https://git.ravensburg.dev/api/v1/repos/cabele/opnsense-portal/releases/latest" + +// checkForUpdate fragt read-only die neueste Version ab und liefert einen +// Hinweistext, falls eine neuere existiert. Jeder Fehler führt zu einer +// leeren Rückgabe — der Check darf den Start unter keinen Umständen stören. +func checkForUpdate(ctx context.Context, enabled bool, url, current string, client *http.Client) string { + if !enabled { + return "" + } + if client == nil { + client = &http.Client{Timeout: 5 * time.Second} + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "" + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "vpnportal/"+current) + + resp, err := client.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "" + } + var release struct { + TagName string `json:"tag_name"` + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil || json.Unmarshal(raw, &release) != nil { + return "" + } + latest := strings.TrimPrefix(strings.TrimSpace(release.TagName), "v") + if latest == "" || latest == strings.TrimPrefix(current, "v") { + return "" + } + return fmt.Sprintf("Hinweis: Version %s ist verfügbar (installiert: %s).", latest, current) +} +``` + +In `runServe` direkt nach dem `startup`-Audit-Ereignis einhängen: + +```go + if msg := checkForUpdate(context.Background(), cfg.Portal.UpdateCheck, releaseURL, version, nil); msg != "" { + fmt.Fprintln(stdout, msg) + } +``` + +Run: `go test ./cmd/vpnportal/ -v` +Expected: PASS + +Der Vergleich ist bewusst ein reiner Gleichheitstest, keine Semver-Ordnung: das Portal soll melden, dass sich die veröffentlichte Version unterscheidet, nicht selbst über Versionsordnungen urteilen. Im README ist transparent zu machen, dass dieser Check eine ausgehende HTTPS-Verbindung öffnet und deshalb standardmäßig aus ist. + +- [ ] **Step 9: `go vet` über alle Build-Tags** + +Run: `go vet ./... && go vet -tags integration ./... && go vet -tags uidemo ./...` +Expected: kein Befund + +- [ ] **Step 10: Commit** + +```bash +git add cmd/vpnportal +git commit -m "feat(serve): Verdrahtung, HTTPS-Server, Startprüfung, SIGHUP und geordneter Shutdown" +``` + +--- + +## Task 18: Deployment-Artefakte und Dokumentation + +**Files:** +- Create: `deploy/vpnportal.service`, `deploy/config.example.yaml` +- Create: `README.md` +- Modify: `Makefile` (Release-Ziel prüft, dass die Artefakte existieren) + +**Interfaces:** +- Consumes: alle Konfigurationsfelder aus Task 2–4, CLI aus Task 1/16/17 +- Produces: lauffähiges Release-Archiv über `make release` + +- [ ] **Step 1: systemd-Unit schreiben** + +`deploy/vpnportal.service`: + +```ini +[Unit] +Description=VPN-Konfig-Portal für OPNsense +Documentation=file:/usr/share/doc/vpnportal/README.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/vpnportal serve --config /etc/vpnportal/config.yaml +Restart=on-failure +RestartSec=5s + +# Dedizierter, zustandsloser Dienstbenutzer. Alternativ einen festen +# Systembenutzer anlegen und User=/Group= setzen — siehe README. +DynamicUser=yes +# Für das Audit-Log; systemd legt /var/log/vpnportal an und übergibt es dem Dienst. +LogsDirectory=vpnportal +LogsDirectoryMode=0750 + +# Secrets werden nicht als Umgebungsvariablen übergeben, sondern als Dateien +# unter $CREDENTIALS_DIRECTORY. In der config.yaml dann z. B.: +# bind_password_file: "/run/credentials/vpnportal.service/ad-bind-password" +LoadCredential=ad-bind-password:/etc/vpnportal/secrets/ad-bind-password +LoadCredential=opnsense-api-secret:/etc/vpnportal/secrets/opnsense-api-secret + +# Härtung +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +NoNewPrivileges=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectProc=invisible +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +RestrictAddressFamilies=AF_INET AF_INET6 +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged @resources +CapabilityBoundingSet= +# Falls das Portal direkt auf 443 lauschen soll, stattdessen: +# AmbientCapabilities=CAP_NET_BIND_SERVICE +# CapabilityBoundingSet=CAP_NET_BIND_SERVICE +ReadOnlyPaths=/etc/vpnportal +UMask=0077 + +[Install] +WantedBy=multi-user.target +``` + +- [ ] **Step 2: Beispiel-Konfiguration schreiben** + +`deploy/config.example.yaml`: die vollständige Konfiguration aus der PRD, mit auskommentierten Optionalfeldern und einem Kommentar pro Feld. Pflichtfelder unkommentiert mit Platzhalterwerten (`https://fw01.firma.local`, `firma.local`, …); alle Secrets als Platzhalter `"BITTE-ERSETZEN"` **mit** danebenstehendem `# alternativ: *_file` -Hinweis. Der Kopf der Datei trägt den Hinweis: + +```yaml +# Diese Datei enthält Zugangsdaten. Rechte setzen: +# chown root:vpnportal /etc/vpnportal/config.yaml && chmod 640 /etc/vpnportal/config.yaml +# Das Portal startet nicht, wenn die Rechte weiter gefasst sind. +``` + +- [ ] **Step 3: Prüfen, dass die Beispiel-Konfiguration parst** + +Ein Test stellt sicher, dass die ausgelieferte Beispieldatei mit dem Strict-Parser zusammenpasst — `config/example_test.go`: + +```go +package config + +import ( + "os" + "testing" +) + +// Die ausgelieferte Beispielkonfiguration muss immer parsen und validieren. +// Sonst laufen Kunden beim ersten Start in einen Fehler. +func TestExampleConfigParsesAndValidates(t *testing.T) { + f, err := os.Open("../../deploy/config.example.yaml") + if err != nil { + t.Fatalf("Beispielkonfiguration fehlt: %v", err) + } + defer f.Close() + + cfg, err := Parse(f) + if err != nil { + t.Fatalf("Beispielkonfiguration parst nicht: %v", err) + } + if err := cfg.Validate(); err != nil { + t.Fatalf("Beispielkonfiguration ist nicht gültig: %v", err) + } +} +``` + +Run: `go test ./internal/config/ -run TestExampleConfig -v` +Expected: PASS (notfalls die Beispieldatei korrigieren, nicht den Test) + +- [ ] **Step 4: README schreiben** + +`README.md` mit diesen Abschnitten: + +1. **Was das Portal tut** — Ein-Absatz-Beschreibung, ausdrücklich: read-only, erstellt und widerruft keine Zertifikate. +2. **Voraussetzungen** — Linux amd64 oder arm64; OPNsense mit Plugin `os-openvpn-client-export` (Mindestversion aus `docs/opnsense-api.md`); Active Directory mit LDAPS; NTP zwingend; TLS-Zertifikat für das Portal. +3. **Installation in unter 30 Minuten** — nummerierte Schritte: Archiv entpacken, Binary nach `/usr/local/bin`, `/etc/vpnportal` anlegen, Beispiel-Config kopieren und anpassen, Rechte setzen (`chmod 640`, `chmod 600` für den TLS-Key), `vpnportal check` ausführen, systemd-Unit installieren, `systemctl enable --now vpnportal`. +4. **OPNsense vorbereiten** — Benutzer anlegen, Gruppe mit **ausschließlich** dem Privileg „VPN: OpenVPN Client Export", API-Key erzeugen, keinen Admin-Key verwenden. +5. **Active Directory vorbereiten** — unprivilegierter Dienstbenutzer genügt; Gruppe `VPN-Users`; Hostnamen statt IP-Adressen in `ad.servers`, weil das LDAPS-Zertifikat gegen den Hostnamen geprüft wird; interne CA über `ca_file`. +6. **Secrets** — die drei `*_file`-Varianten, die drei `VPNPORTAL_*`-Variablen, Rangfolge Env > Datei > Inline, `LoadCredential`-Beispiel aus der Unit. +7. **Zertifikatszuordnung** — `cn_pattern` mit `{username}`, Beispiel `{username}@firma.de`, `cn_regex` als Notnagel für Bestandsumgebungen inklusive Warnung, dass er zu weit gefasst sein kann. +8. **Betrieb** — `/healthz` für Zabbix/CheckMK mit Beispielantwort; Log-Auswertung per `journalctl -u vpnportal` und über die JSONL-Datei; `jq`-Einzeiler für Downloads eines Benutzers. +9. **Audit-Log** — Ereignisliste, Beispielzeilen, Hinweis: „Für revisionssichere Aufbewahrung das Log an ein zentrales Logsystem weiterleiten." Empfohlene Aufbewahrung 90 Tage, kundenseitig anpassbar. Hinweis, dass das Log personenbezogene Daten enthält und entsprechend zu schützen ist. +10. **Rotation** — eingebaut und größenbasiert; alternativ externes logrotate mit `SIGHUP` (`systemctl reload` bzw. `kill -HUP`). +11. **Raspberry Pi** — SSD statt SD-Karte empfohlen; Gerät in einen abschließbaren Netzwerkschrank, weil das Speichermedium Zugangsdaten trägt; NTP prüfen. +12. **Fehlersuche** — Tabelle: Symptom → wahrscheinliche Ursache → Kommando. Mindestens: „Anmeldung schlägt für alle fehl" → Dienstkonto/Gruppe → `vpnportal check`; „einzelner Benutzer kann sich nicht anmelden" → `vpnportal check --test-auth `; „kein Zertifikat gefunden" → `cn_pattern` prüfen, Audit-Feld `pattern` lesen; „Dienst vorübergehend nicht verfügbar" → Firewall oder CA; „Start bricht mit chmod-Hinweis ab" → Dateirechte. +13. **Passwortänderung** — nicht im Portal möglich; abgelaufene Passwörter am Arbeitsplatz ändern (AD-Code 532 zeigt eine eigene Meldung, 773 die generische). +14. **Bekannte Grenzen** — kein WireGuard (v2), kein Self-Service für Zertifikate, keine Admin-Oberfläche, keine Mandantenfähigkeit, Single-Domain (keine Multi-Domain-Forests), kein offizieller Windows-Support. +15. **Update** — Binary austauschen, `systemctl restart`; Sessions gehen dabei verloren, das ist beabsichtigt. `portal.update_check` ist standardmäßig **aus** und telefoniert eingeschaltet nach Hause. +16. **Bauen aus dem Quelltext** — `make test`, `make build`, `make release`; Integrationstests mit `go test -tags integration ./internal/opnsense/` und den benötigten Umgebungsvariablen. + +- [ ] **Step 5: Release-Bau prüfen** + +Run: +```bash +make release +ls -la dist/ +tar -tzf dist/vpnportal-*-linux-arm64.tar.gz +``` +Expected: Zwei Archive (amd64, arm64), `SHA256SUMS`, und jedes Archiv enthält Binary, `config.example.yaml`, `vpnportal.service` und `README.md`. + +Falls `sha256sum` auf macOS fehlt, im Makefile auf `shasum -a 256` ausweichen bzw. `command -v sha256sum || shasum -a 256` verwenden. + +- [ ] **Step 6: Statisches Linken verifizieren** + +Run: +```bash +CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o /tmp/vpnportal-arm64 ./cmd/vpnportal +file /tmp/vpnportal-arm64 +go version -m /tmp/vpnportal-arm64 | head -20 +``` +Expected: `ELF 64-bit LSB executable, ARM aarch64, ... statically linked` + +- [ ] **Step 7: Gesamttest** + +Run: `make vet && make test && go test -race ./...` +Expected: alles PASS. Der `-race`-Lauf ist wichtig, weil Session-Store, Limiter, Audit-Logger und Provider-Cache gemeinsam genutzten Zustand halten. + +- [ ] **Step 8: Commit und Push** + +```bash +git add deploy README.md Makefile internal/config +git commit -m "docs: Installationsanleitung, gehärtete systemd-Unit und Beispielkonfiguration" +git push -u origin main +``` + +--- + +## Akzeptanzkriterien — Zuordnung zu den Tasks + +| PRD-Kriterium | Abgedeckt durch | +|---|---| +| 1. Anmeldung + funktionierende `.ovpn` | Task 7, 8, 14 (`TestExportSucceedsForOwnedCert`, `TestDownloadStreamsWithHardenedHeaders`) | +| 2. Ohne Gruppenmitgliedschaft abgewiesen, `not_in_vpn_group` im Log | Task 7 (`TestAuthenticateRejectsNonMember`), Task 13 (`TestLoginFailureShowsGenericMessage`) | +| 3. Revoziertes Zertifikat sofort weg | Task 14 (`TestAccountsAreNeverCached`, `TestExportRejectsRevokedEvenIfPreviouslyListed`) | +| 4. Deaktivierter Account kann sich nicht anmelden | Task 6 (`data 533` → `account_disabled`), Task 7 | +| 5. Fremdes Zertifikat → 403 + `download_denied` | Task 14 (`TestExportRevalidatesOwnership`, `TestForeignCertificateIsDenied`) | +| 6. `check` erkennt Secret, Berechtigung, DC, CA, Rechte, Tippfehler | Task 16 (alle Tests), Task 2 (`TestParseRejectsUnknownField`) | +| 7. DC-Ausfall → Failover, `ldap_failover` im Log | Task 7 (`TestFailoverToSecondDC`), Task 17 (`OnFailover` verdrahtet Audit-Event) | +| 8. Passwort im Username-Feld erscheint nirgends im Log | Task 13 (`TestUnknownUserIsNeverLoggedInClear`, `TestPasswordNeverAppearsInAnyEvent`) | +| 9. `no-store`, POST + CSRF | Task 12, 14 (`TestDownloadStreamsWithHardenedHeaders`, `TestDownloadRequiresCSRF`, `TestDownloadGETIsNotRouted`) | +| 10. Identisch auf amd64 und arm64 | Task 1 (Step 9), Task 18 (Step 6) | +| 11. Neustart beendet alle Sessions | Task 10 (Store ausschließlich im RAM) | +| 12. UI ohne JavaScript bedienbar | Task 12 (Formulare ohne JS, Toggle nur als Zusatz), Task 13 (`TestLoginGETRendersForm`) | + +Ergänzung zu Kriterium 7: In Task 17 muss `auth.Options.OnFailover` beim Bauen des AD-Clients mit einer Funktion belegt werden, die `audit.EventLDAPFailover` mit dem Servernamen loggt. Da `buildBackends` in Task 16 noch ohne Audit-Logger arbeitet (der Logger wird erst danach geöffnet), ist in Task 17 nach dem Öffnen des Audit-Logs `ad.SetFailoverHook(func(server string, err error) { auditLog.Log(audit.Event{Event: audit.EventLDAPFailover, Server: server}) })` aufzurufen. Dafür in `internal/auth/ad.go` ergänzen: + +```go +// SetFailoverHook setzt den Failover-Rückruf nachträglich. Das ist nötig, +// weil der Audit-Logger erst nach der Startprüfung geöffnet wird. +func (a *AD) SetFailoverHook(fn func(server string, err error)) { + a.mu.Lock() + a.opts.OnFailover = fn + a.mu.Unlock() +} +``` + +Der Zugriff auf `a.opts.OnFailover` in `withConn` und `bindAs` ist entsprechend über `a.mu.RLock()` abzusichern — oder einfacher: `OnFailover` in ein eigenes, mit `atomic.Pointer` gehaltenes Feld auslegen. Empfehlung: eigenes Feld `failover atomic.Pointer[func(string, error)]`, damit `withConn` ohne Sperre auskommt. Ein Test dafür in `internal/auth/ad_test.go`: + +```go +func TestSetFailoverHookIsUsed(t *testing.T) { + dead := &fakeConn{dialErr: errors.New("connection refused")} + alive := &fakeConn{entries: stdEntries(true)} + i := 0 + conns := []*fakeConn{dead, alive} + a, err := NewAD(Options{ + Servers: []string{"dc01.firma.local", "dc02.firma.local"}, Port: 636, + TLSMode: "ldaps", BaseDN: "DC=firma,DC=local", + BindUser: "svc@firma.local", BindPassword: "svc-pw", VPNGroup: "VPN-Users", + Timeout: time.Second, + Dial: func(ctx context.Context, server string) (conn, error) { + c := conns[min(i, len(conns)-1)] + i++ + if c.dialErr != nil { + return nil, c.dialErr + } + return c, nil + }, + }) + if err != nil { + t.Fatal(err) + } + var seen []string + a.SetFailoverHook(func(server string, err error) { seen = append(seen, server) }) + a.Authenticate(context.Background(), "mmueller", "geheim") + if len(seen) == 0 || seen[0] != "dc01.firma.local" { + t.Fatalf("Failover-Hook = %v", seen) + } +} +``` + +Dieser Test gehört ans Ende von Task 7; die `SetFailoverHook`-Implementierung ebenfalls. Beim Ausführen des Plans also Task 7 um diesen Schritt erweitern.