opnsense-portal/docs/superpowers/plans/2026-08-14-vpn-config-portal.md
Carsten Abele bb84607bf8 docs: PRD und Implementierungsplan für das VPN-Konfig-Portal
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
2026-08-14 09:00:51 +02:00

271 KiB
Raw Blame History

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

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:

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
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
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:

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:

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
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:

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

go get gopkg.in/yaml.v3@latest
  • Step 2: Failing test schreiben

internal/config/load_test.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
// 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
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
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:

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
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:

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
// 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
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.localDC=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:

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
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 165535", 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:

	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
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 = "<unknown>"
  • Step 1: Failing test für Event-Serialisierung und Logger schreiben

internal/audit/logger_test.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
// 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 = "<unknown>"

// 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
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:

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
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
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:

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

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
// 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
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
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

go get github.com/go-ldap/ldap/v3@latest
  • Step 2: Failing test schreiben

internal/auth/ad_test.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
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
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:

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
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:

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
// 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:

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, "<html><body>Login</body></html>")
	})
	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
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
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: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
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:

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
// 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
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:

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
// 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
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:

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
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
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) stringRemoteAddr 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:

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
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
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:

{{define "layout"}}<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{{.Brand.Title}}</title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<header class="topbar">
  <div class="brand">
    {{if .Brand.HasLogo}}<img src="/assets/logo" alt="" class="logo">{{end}}
    <span class="brand-title">{{.Brand.Title}}</span>
  </div>
  {{if .User}}
  <nav class="topnav">
    <a href="/anleitungen">{{t "guides_link"}}</a>
    <span class="user">{{.User}}</span>
    <form method="post" action="/logout" class="inline">
      <input type="hidden" name="csrf_token" value="{{.CSRF}}">
      <button type="submit" class="linkbutton">{{t "logout"}}</button>
    </form>
  </nav>
  {{end}}
</header>

<main>
  {{if .Flash}}<div class="flash flash-{{.FlashKind}}" role="status">{{.Flash}}</div>{{end}}
  {{block "content" .}}{{end}}
</main>

<footer class="footer">
  <span>{{t "footer_version"}} {{.Brand.Version}}</span>
</footer>
</body>
</html>{{end}}

internal/web/templates/login.html:

{{define "content"}}
<h1>{{t "login_title"}}</h1>
<form method="post" action="/login" class="card form" autocomplete="on">
  <label for="username">{{t "login_username"}}</label>
  <input type="text" id="username" name="username" required autofocus
         autocomplete="username" autocapitalize="none" spellcheck="false"
         aria-describedby="username-hint">
  <small id="username-hint">{{t "login_username_hint"}}</small>

  <label for="password">{{t "login_password"}}</label>
  <input type="password" id="password" name="password" required autocomplete="current-password">
  <div class="toggle-wrap" hidden id="toggle-wrap">
    <input type="checkbox" id="show-password">
    <label for="show-password">{{t "login_show_password"}}</label>
  </div>

  <button type="submit">{{t "login_submit"}}</button>
</form>
<script src="/assets/app.js" defer></script>
{{end}}

internal/web/templates/overview.html:

{{define "content"}}
<h1>{{t "overview_title"}}</h1>
{{if .Data.Entries}}
<p>{{t "overview_intro"}}</p>
<ul class="cards">
  {{range .Data.Entries}}
  <li class="card">
    <h2>{{.InstanceName}}</h2>
    <dl>
      <dt>{{t "common_name"}}</dt><dd>{{.CommonName}}</dd>
      {{if .ValidUntil}}<dt>{{t "valid_until"}}</dt><dd>{{.ValidUntil}}</dd>{{end}}
    </dl>
    {{if .ExpiryWarning}}<p class="warn">{{.ExpiryWarning}}</p>{{end}}
    <div class="actions">
      {{range .Formats}}
      <form method="post" action="/download" class="inline">
        <input type="hidden" name="csrf_token" value="{{$.CSRF}}">
        <input type="hidden" name="cert" value="{{.Token}}">
        <input type="hidden" name="format" value="{{.Format}}">
        <button type="submit">{{.Label}}</button>
      </form>
      {{end}}
    </div>
  </li>
  {{end}}
</ul>
<p class="logout-hint" role="note">{{t "logout_reminder"}}</p>
{{else}}
<div class="card empty">
  <p>{{t "overview_no_certs"}}</p>
  {{if .Data.SupportContact}}<p>{{.Data.SupportHint}}</p>{{end}}
</div>
{{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:

{{define "content"}}
<h1>{{t "guides_title"}}</h1>
<p>{{t "guides_intro"}}</p>
{{range .Data.Guides}}
<section class="card{{if .Suggested}} suggested{{end}}">
  <h2>{{.Platform}}</h2>
  <p class="client">{{.Client}}</p>
  <ol>{{range .Steps}}<li>{{.}}</li>{{end}}</ol>
</section>
{{end}}
{{end}}

internal/web/templates/error.html:

{{define "content"}}
<h1>{{.Data.Title}}</h1>
<div class="card error">
  <p>{{.Data.Message}}</p>
  {{if .Data.ShowLoginLink}}<p><a href="/login">{{t "login_submit"}}</a></p>{{end}}
</div>
{{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:

// 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:

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{
		"<!DOCTYPE html>", `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: `<script>alert(1)</script>`,
			Data:  errorPageData{Title: "T", Message: `"><img src=x onerror=alert(1)>`},
		})
	body := rec.Body.String()
	if strings.Contains(body, "<script>alert(1)</script>") {
		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
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
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
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:

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

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
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) stringvpn-<instanz>-<user>.ovpn, auf [a-z0-9._-] reduziert
  • Step 1: Failing test für die CertSource schreiben

internal/portal/certsource_test.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
// 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:

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, `<a href="/download`) {
		t.Error("Downloads dürfen keine GET-Links sein")
	}
	if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
		t.Errorf("Cache-Control = %q", cc)
	}
}

func TestOverviewShowsExpiryWarning(t *testing.T) {
	certs := &certsWithExport{entries: []CertEntry{
		{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller",
			ValidTo: time.Now().AddDate(0, 0, 12)},
	}}
	srv, _, cookie, _ := loggedInServer(t, certs)
	body := getWithCookie(srv.Handler(), "/", cookie).Body.String()
	if !strings.Contains(body, "12 Tagen") {
		t.Errorf("Ablaufwarnung fehlt: %s", body)
	}
}

func TestOverviewNoCertsShowsSupportContact(t *testing.T) {
	srv, rec, cookie, _ := loggedInServer(t, &certsWithExport{})
	out := getWithCookie(srv.Handler(), "/", cookie)

	body := out.Body.String()
	if !strings.Contains(body, S["overview_no_certs"]) {
		t.Error("Meldung für fehlende Zertifikate fehlt")
	}
	if !strings.Contains(body, "it-helpdesk@firma.de") {
		t.Error("support_contact fehlt")
	}
	ev, ok := rec.find(audit.EventNoCertFound)
	if !ok {
		t.Fatal("no_cert_found fehlt im Audit-Log")
	}
	if ev.User != "mmueller" {
		t.Errorf("Audit-Event = %+v", ev)
	}
}

func TestOverviewBackendDownShowsServiceError(t *testing.T) {
	certs := &certsWithExport{listErr: ErrBackendUnavailable}
	srv, rec, cookie, _ := loggedInServer(t, certs)
	out := getWithCookie(srv.Handler(), "/", cookie)

	if out.Code != http.StatusServiceUnavailable {
		t.Fatalf("Code = %d, want 503", out.Code)
	}
	body := out.Body.String()
	if !strings.Contains(body, S["error_service_unavailable"]) {
		t.Error("Dienstfehlerseite fehlt")
	}
	if strings.Contains(body, S["overview_no_certs"]) {
		t.Error("Firewall-Ausfall darf nicht wie \"kein Zertifikat\" aussehen")
	}
	if _, ok := rec.find(audit.EventOPNsenseUnreachable); !ok {
		t.Error("opnsense_unreachable fehlt im Audit-Log")
	}
}

func TestDownloadStreamsWithHardenedHeaders(t *testing.T) {
	certs := &certsWithExport{entries: []CertEntry{
		{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller"},
	}}
	srv, rec, cookie, sess := loggedInServer(t, certs)

	out := postForm(srv.Handler(), "/download", url.Values{
		"csrf_token": {sess.CSRF}, "cert": {"1:a1"}, "format": {"ovpn"},
	}, cookie)

	if out.Code != http.StatusOK {
		t.Fatalf("Code = %d, body: %s", out.Code, out.Body.String())
	}
	cd := out.Header().Get("Content-Disposition")
	if !strings.Contains(cd, `attachment; filename="vpn-homeoffice-mmueller.ovpn"`) {
		t.Errorf("Content-Disposition = %q", cd)
	}
	if cc := out.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
		t.Errorf("Cache-Control = %q", cc)
	}
	if !strings.Contains(out.Body.String(), "remote fw01 1194") {
		t.Errorf("Body = %q", out.Body.String())
	}
	ev, ok := rec.find(audit.EventConfigDownload)
	if !ok {
		t.Fatal("config_download fehlt im Audit-Log")
	}
	if ev.User != "mmueller" || ev.VPNInstance != "VPN Homeoffice" ||
		ev.Format != "ovpn" || ev.CertCN != "mmueller" || ev.CertExpiry != "2027-03-01" {
		t.Errorf("Audit-Event unvollständig: %+v", ev)
	}
}

func TestDownloadRequiresCSRF(t *testing.T) {
	certs := &certsWithExport{entries: []CertEntry{{Token: "1:a1", InstanceName: "X"}}}
	srv, _, cookie, _ := loggedInServer(t, certs)

	out := postForm(srv.Handler(), "/download", url.Values{
		"csrf_token": {"falsch"}, "cert": {"1:a1"}, "format": {"ovpn"},
	}, cookie)

	if out.Code != http.StatusForbidden {
		t.Fatalf("Code = %d, want 403", out.Code)
	}
	if certs.exportCall != 0 {
		t.Error("ohne gültiges CSRF-Token darf kein Export ausgelöst werden")
	}
}

func TestDownloadGETIsNotRouted(t *testing.T) {
	certs := &certsWithExport{entries: []CertEntry{{Token: "1:a1"}}}
	srv, _, cookie, _ := loggedInServer(t, certs)
	out := getWithCookie(srv.Handler(), "/download?cert=1:a1&format=ovpn", cookie)
	if out.Code == http.StatusOK {
		t.Fatal("Downloads per GET dürfen nicht möglich sein")
	}
}

func TestForeignCertificateIsDenied(t *testing.T) {
	certs := &certsWithExport{
		entries:   []CertEntry{{Token: "1:a1", InstanceName: "X"}},
		exportErr: ErrCertNotOwned,
	}
	srv, rec, cookie, sess := loggedInServer(t, certs)

	out := postForm(srv.Handler(), "/download", url.Values{
		"csrf_token": {sess.CSRF}, "cert": {"9:fremd"}, "format": {"ovpn"},
	}, cookie)

	if out.Code != http.StatusForbidden {
		t.Fatalf("Code = %d, want 403", out.Code)
	}
	ev, ok := rec.find(audit.EventDownloadDenied)
	if !ok {
		t.Fatal("download_denied fehlt im Audit-Log")
	}
	if ev.User != "mmueller" {
		t.Errorf("Audit-Event = %+v", ev)
	}
}

func TestDownloadRejectsMalformedToken(t *testing.T) {
	certs := &certsWithExport{entries: []CertEntry{{Token: "1:a1"}}}
	srv, _, cookie, sess := loggedInServer(t, certs)
	for _, bad := range []string{"", "keinDoppelpunkt", "a:b:c"} {
		out := postForm(srv.Handler(), "/download", url.Values{
			"csrf_token": {sess.CSRF}, "cert": {bad}, "format": {"ovpn"},
		}, cookie)
		if out.Code != http.StatusForbidden && out.Code != http.StatusBadRequest {
			t.Errorf("Token %q ergab Code %d", bad, out.Code)
		}
	}
	if certs.exportCall != 0 {
		t.Error("kaputte Tokens dürfen keinen Export auslösen")
	}
}

func TestDownloadRejectsUnknownFormat(t *testing.T) {
	certs := &certsWithExport{entries: []CertEntry{{Token: "1:a1"}}}
	srv, _, cookie, sess := loggedInServer(t, certs)
	out := postForm(srv.Handler(), "/download", url.Values{
		"csrf_token": {sess.CSRF}, "cert": {"1:a1"}, "format": {"exe"},
	}, cookie)
	if out.Code == http.StatusOK {
		t.Fatal("unbekanntes Format muss abgelehnt werden")
	}
	if certs.exportCall != 0 {
		t.Error("unbekanntes Format darf keinen Export auslösen")
	}
}
  • Step 6: Test laufen lassen, Fehlschlag bestätigen

Run: go test ./internal/web/ -run 'TestOverview|TestDownload|TestForeign' -v Expected: FAIL — die Stub-Handler liefern 501

  • Step 7: Stubs aus server.go entfernen und handlers_certs.go schreiben

Die vier Stub-Funktionen am Ende von server.go löschen (handleHealth und handleGuides bleiben vorerst, sie werden in Task 15 ersetzt — nur handleOverview und handleDownload entfernen).

internal/web/handlers_certs.go:

package web

import (
	"errors"
	"fmt"
	"io"
	"net/http"
	"strings"
	"time"

	"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
)

// expiryWarningDays ist die Schwelle, ab der vor dem Ablauf gewarnt wird.
const expiryWarningDays = 30

// downloadFormats sind die in der Oberfläche angebotenen Formate.
var downloadFormats = []struct {
	Format   string
	LabelKey string
}{
	{"ovpn", "download_ovpn"},
	{"viscosity", "download_viscosity"},
}

// overviewData füllt overview.html.
type overviewData struct {
	Entries        []overviewEntry
	SupportContact string
	SupportHint    string
}

type overviewEntry struct {
	InstanceName  string
	CommonName    string
	ValidUntil    string
	ExpiryWarning string
	Formats       []overviewFormat
}

type overviewFormat struct {
	Token  string
	Format string
	Label  string
}

func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request, sess *Session) {
	user := sess.Identity.Username
	entries, err := s.d.Certs.EntriesFor(r.Context(), user)
	if err != nil {
		if errors.Is(err, ErrBackendUnavailable) {
			s.log(audit.Event{
				Event:   audit.EventOPNsenseUnreachable,
				User:    user,
				SrcIP:   ClientIP(r),
				Session: audit.ShortSession(sess.Token),
			})
		}
		s.renderError(w, r, http.StatusServiceUnavailable, sess,
			"error_service_unavailable", "error_service_unavailable_body")
		return
	}

	if len(entries) == 0 {
		s.log(audit.Event{
			Event:   audit.EventNoCertFound,
			User:    user,
			SrcIP:   ClientIP(r),
			Session: audit.ShortSession(sess.Token),
			Pattern: s.pattern(user),
		})
	}

	data := overviewData{SupportContact: s.d.SupportContact}
	if s.d.SupportContact != "" {
		data.SupportHint = fmt.Sprintf(T("overview_no_certs_contact"), s.d.SupportContact)
	}
	now := s.d.Clock()
	for _, e := range entries {
		oe := overviewEntry{InstanceName: e.InstanceName, CommonName: e.CommonName}
		if !e.ValidTo.IsZero() {
			oe.ValidUntil = e.ValidTo.Format("02.01.2006")
			if days := int(e.ValidTo.Sub(now).Hours() / 24); days < expiryWarningDays {
				if days <= 0 {
					oe.ExpiryWarning = T("overview_expired_today")
				} else {
					oe.ExpiryWarning = fmt.Sprintf(T("overview_expiry_warning"), days)
				}
			}
		}
		for _, f := range downloadFormats {
			oe.Formats = append(oe.Formats, overviewFormat{
				Token: e.Token, Format: f.Format, Label: T(f.LabelKey)})
		}
		data.Entries = append(data.Entries, oe)
	}

	s.renderPage(w, r, http.StatusOK, "overview", sess, PageData{Data: data})
}

// pattern liefert die angewendete Zuordnungsregel für das Audit-Log, sofern
// die CertSource sie kennt.
func (s *Server) pattern(username string) string {
	if p, ok := s.d.Certs.(interface{ Pattern(string) string }); ok {
		return p.Pattern(username)
	}
	return ""
}

func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request, sess *Session) {
	user := sess.Identity.Username
	ip := ClientIP(r)
	short := audit.ShortSession(sess.Token)

	deny := func(detail string) {
		s.log(audit.Event{Event: audit.EventDownloadDenied, User: user, SrcIP: ip,
			Session: short, Detail: detail})
		s.renderError(w, r, http.StatusForbidden, sess, "error_forbidden", "error_forbidden_body")
	}

	if err := r.ParseForm(); err != nil {
		deny("formular_unlesbar")
		return
	}
	if !sess.ValidCSRF(r.PostFormValue("csrf_token")) {
		s.renderError(w, r, http.StatusForbidden, sess, "error_forbidden", "error_csrf")
		return
	}

	vpnID, refID, ok := parseCertToken(r.PostFormValue("cert"))
	if !ok {
		deny("token_ungueltig")
		return
	}
	format := r.PostFormValue("format")
	if !isKnownFormat(format) {
		deny("format_unbekannt")
		return
	}

	// Serverseitige Autorisierung: die CertSource prüft live, ob das
	// Zertifikat dem angemeldeten Benutzer gehört und nutzbar ist.
	stream, err := s.d.Certs.Export(r.Context(), user, vpnID, refID, format)
	if err != nil {
		if errors.Is(err, ErrCertNotOwned) {
			deny("nicht_zugeordnet")
			return
		}
		if errors.Is(err, ErrBackendUnavailable) {
			s.log(audit.Event{Event: audit.EventOPNsenseUnreachable, User: user,
				SrcIP: ip, Session: short})
		}
		s.renderError(w, r, http.StatusServiceUnavailable, sess,
			"error_service_unavailable", "error_service_unavailable_body")
		return
	}
	defer stream.Body.Close()

	expiry := ""
	if !stream.ValidTo.IsZero() {
		expiry = stream.ValidTo.Format("2006-01-02")
	}
	s.log(audit.Event{
		Event:       audit.EventConfigDownload,
		User:        user,
		SrcIP:       ip,
		Session:     short,
		VPNInstance: stream.InstanceName,
		CertCN:      stream.CommonName,
		CertExpiry:  expiry,
		Format:      format,
	})

	NoStore(w)
	w.Header().Set("Content-Type", stream.ContentType)
	w.Header().Set("Content-Disposition",
		fmt.Sprintf(`attachment; filename=%q`, stream.Filename))
	w.Header().Set("X-Content-Type-Options", "nosniff")
	w.WriteHeader(http.StatusOK)

	// Durchstreamen — nichts auf Platte, nichts vollständig im Speicher.
	if _, err := io.Copy(w, stream.Body); err != nil {
		// Der Header ist raus; mehr als Protokollieren bleibt nicht.
		s.log(audit.Event{Event: audit.EventOPNsenseUnreachable, User: user,
			SrcIP: ip, Session: short, Detail: "abbruch_beim_streamen"})
	}
}

// parseCertToken zerlegt den Auswahl-Token aus dem Formular.
func parseCertToken(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
}

func isKnownFormat(f string) bool {
	for _, d := range downloadFormats {
		if d.Format == f {
			return true
		}
	}
	return false
}

var _ = time.Now // Platzhalter entfernen, falls time anderweitig genutzt wird

Die letzte Zeile streichen und den time-Import entfernen, falls time nicht mehr gebraucht wird.

  • Step 8: Tests laufen lassen

Run: go test ./internal/web/ ./internal/portal/ -v Expected: PASS

  • Step 9: Commit
git add internal/portal internal/web
git commit -m "feat(portal): Zertifikatsübersicht und serverseitig autorisierter Streaming-Download"

Task 15: Anleitungen, Healthz und Startup-Ereignis

Files:

  • Create: internal/web/handlers_static.go
  • Create: internal/web/guides.go
  • Test: internal/web/handlers_static_test.go
  • Modify: internal/web/server.go (Stubs handleGuides/handleHealth entfernen)

Interfaces:

  • Consumes: HealthChecker, HealthReport (Task 13)

  • Produces:

    • type Guide struct { Platform, Client string; Steps []string; Suggested bool }
    • func Guides(userAgent string) []Guide — sortiert die zum User-Agent passende Plattform nach vorn und markiert sie als Suggested; filtert nie
    • func (s *Server) handleGuides(...), func (s *Server) handleHealth(...)
    • /healthz antwortet ohne Auth mit JSON {"ok":true,"opnsense":true,"directory":true,"sessions":3}, Status 200 bei ok, sonst 503
  • Step 1: Failing test schreiben

internal/web/handlers_static_test.go:

package web

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

type fakeHealth struct{ rep HealthReport }

func (f *fakeHealth) Check(ctx context.Context) HealthReport { return f.rep }

func TestGuidesOrderPutsDetectedPlatformFirst(t *testing.T) {
	const winUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
	g := Guides(winUA)
	if len(g) < 4 {
		t.Fatalf("es müssen Anleitungen für alle Plattformen existieren, got %d", len(g))
	}
	if !strings.Contains(g[0].Platform, "Windows") || !g[0].Suggested {
		t.Errorf("erste Anleitung = %+v, want Windows und Suggested", g[0])
	}
	// Kein Filter: alle Plattformen bleiben erreichbar.
	platforms := map[string]bool{}
	for _, x := range g {
		platforms[x.Platform] = true
	}
	for _, want := range []string{"Windows", "macOS", "iOS", "Android"} {
		var found bool
		for p := range platforms {
			if strings.Contains(p, want) {
				found = true
			}
		}
		if !found {
			t.Errorf("Anleitung für %s fehlt", want)
		}
	}
}

func TestGuidesDetectsOtherPlatforms(t *testing.T) {
	cases := map[string]string{
		"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)": "macOS",
		"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)": "iOS",
		"Mozilla/5.0 (Linux; Android 14; Pixel 8)":               "Android",
	}
	for ua, want := range cases {
		g := Guides(ua)
		if !strings.Contains(g[0].Platform, want) {
			t.Errorf("UA %q -> %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
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
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
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:

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
// 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
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
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
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:

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
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
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:

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: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:

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:

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:

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:

	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
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 24, CLI aus Task 1/16/17

  • Produces: lauffähiges Release-Archiv über make release

  • Step 1: systemd-Unit schreiben

deploy/vpnportal.service:

[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:

# 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:

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. Zertifikatszuordnungcn_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 <benutzer>; „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 Quelltextmake 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:

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:

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
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 533account_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:

// 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:

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.