# 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"}}

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