Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
271 KiB
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/amd64undlinux/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_verifyexistiert 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 einVPNPORTAL_*-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 nuros.Exit(run(os.Args[1:], os.Stdout, os.Stderr)). Buildinfo-Variablenversion,commit,date(Package-Levelvar, per-ldflags -Xgesetzt). -
Step 1: Modul initialisieren und
.gitignoreanlegen
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.goschreiben
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.goschreiben
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.goundcheck.goanlegen
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(Dependencygopkg.in/yaml.v3)
Interfaces:
-
Consumes: nichts
-
Produces:
type Config struct { Portal PortalConfig; OPNsense OPNsenseConfig; AD ADConfig; Matching MatchingConfig; Logging LoggingConfig }type Duration time.DurationmitUnmarshalYAMLfü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.goschreiben
// 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.gomitParseschreiben
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(FunktionLoad) - Test:
internal/config/perms_test.go,internal/config/secrets_test.go
Interfaces:
-
Consumes:
Parse,Configaus Task 2 -
Produces:
func Load(path string, env func(string) string) (*Config, error)— liest Datei, prüft Rechte, parst, löst*_fileauf, wendet Env-Overrides an.envist injizierbar (os.Getenvin Produktion).func CheckFileMode(path string, maxMode fs.FileMode) error— Fehler, wenn Bits jenseits vonmaxModegesetzt 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.goschreiben
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:
Loadinload.goergä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 vonLoadaufrufen)
Interfaces:
-
Consumes:
Configaus Task 2 -
Produces:
func (c *Config) Validate() error— sammelt alle Fehler und gibt sie gebündelt aus (errors.Join)func BaseDNFromDomain(domain string) (string, error)—firma.local→DC=firma,DC=localfunc (c *Config) EffectiveBaseDN() string- Regeln:
tls_cert/tls_keyPflicht;opnsense.urlmusshttps://sein;ad.serversnicht leer und keine IP-Adressen;tls_mode∈ {ldaps,starttls};cn_patternundcn_regexschließen sich aus;cn_regexmuss kompilierbar sein;cn_patternmuss{username}enthalten;logging.level∈ {debug,info,warn,error};audit_logPflicht
-
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.goschreiben
package config
import (
"errors"
"fmt"
"net"
"net/url"
"regexp"
"slices"
"strings"
)
// UsernamePlaceholder ist der Platzhalter in matching.cn_pattern.
const UsernamePlaceholder = "{username}"
// BaseDNFromDomain leitet DC=firma,DC=local aus firma.local ab.
func BaseDNFromDomain(domain string) (string, error) {
domain = strings.Trim(strings.TrimSpace(domain), ".")
if domain == "" {
return "", errors.New("ad.domain ist leer")
}
parts := strings.Split(domain, ".")
if len(parts) < 2 {
return "", fmt.Errorf("ad.domain %q sieht nicht wie eine AD-Domäne aus (erwartet z. B. firma.local); "+
"andernfalls ad.base_dn explizit setzen", domain)
}
dcs := make([]string, len(parts))
for i, p := range parts {
if p == "" {
return "", fmt.Errorf("ad.domain %q enthält leere Labels", domain)
}
dcs[i] = "DC=" + p
}
return strings.Join(dcs, ","), nil
}
// EffectiveBaseDN liefert den Override oder die Ableitung aus der Domäne.
func (c *Config) EffectiveBaseDN() string {
if strings.TrimSpace(c.AD.BaseDN) != "" {
return strings.TrimSpace(c.AD.BaseDN)
}
dn, err := BaseDNFromDomain(c.AD.Domain)
if err != nil {
return ""
}
return dn
}
// Validate prüft die Konfiguration semantisch und sammelt alle Fehler.
func (c *Config) Validate() error {
var errs []error
req := func(value, name string) {
if strings.TrimSpace(value) == "" {
errs = append(errs, fmt.Errorf("%s ist erforderlich", name))
}
}
req(c.Portal.Listen, "portal.listen")
req(c.Portal.TLSCert, "portal.tls_cert")
req(c.Portal.TLSKey, "portal.tls_key")
if _, _, err := net.SplitHostPort(c.Portal.Listen); c.Portal.Listen != "" && err != nil {
errs = append(errs, fmt.Errorf("portal.listen %q ist keine gültige Adresse (erwartet HOST:PORT): %w",
c.Portal.Listen, err))
}
req(c.OPNsense.URL, "opnsense.url")
if c.OPNsense.URL != "" {
u, err := url.Parse(c.OPNsense.URL)
switch {
case err != nil:
errs = append(errs, fmt.Errorf("opnsense.url %q ist keine gültige URL: %w", c.OPNsense.URL, err))
case u.Scheme != "https":
errs = append(errs, fmt.Errorf("opnsense.url muss mit https:// beginnen, hat aber Schema %q", u.Scheme))
case u.Host == "":
errs = append(errs, errors.New("opnsense.url enthält keinen Host"))
}
}
req(c.OPNsense.APIKey, "opnsense.api_key (oder api_key_file / "+EnvAPIKey+")")
req(c.OPNsense.APISecret, "opnsense.api_secret (oder api_secret_file / "+EnvAPISecret+")")
req(c.AD.Domain, "ad.domain")
req(c.AD.BindUser, "ad.bind_user")
req(c.AD.BindPassword, "ad.bind_password (oder bind_password_file / "+EnvBindPassword+")")
req(c.AD.VPNGroup, "ad.vpn_group")
if len(c.AD.Servers) == 0 {
errs = append(errs, errors.New("ad.servers muss mindestens einen Domain Controller enthalten"))
}
for _, s := range c.AD.Servers {
s = strings.TrimSpace(s)
if s == "" {
errs = append(errs, errors.New("ad.servers enthält einen leeren Eintrag"))
continue
}
if net.ParseIP(s) != nil {
errs = append(errs, fmt.Errorf(
"ad.servers: %q ist eine IP-Adresse — es sind Hostnamen erforderlich, "+
"weil das LDAPS-Zertifikat gegen den Hostnamen geprüft wird", s))
}
}
if !slices.Contains([]string{"ldaps", "starttls"}, c.AD.TLSMode) {
errs = append(errs, fmt.Errorf("ad.tls_mode %q ist ungültig (erlaubt: ldaps, starttls)", c.AD.TLSMode))
}
if c.AD.Port <= 0 || c.AD.Port > 65535 {
errs = append(errs, fmt.Errorf("ad.port %d liegt außerhalb 1–65535", c.AD.Port))
}
if c.AD.BaseDN == "" {
if _, err := BaseDNFromDomain(c.AD.Domain); err != nil && c.AD.Domain != "" {
errs = append(errs, err)
}
}
pattern, rx := strings.TrimSpace(c.Matching.CNPattern), strings.TrimSpace(c.Matching.CNRegex)
switch {
case pattern != "" && rx != "":
errs = append(errs, errors.New(
"matching.cn_pattern und matching.cn_regex schließen sich aus — bitte nur eines setzen"))
case pattern == "" && rx == "":
errs = append(errs, errors.New("matching.cn_pattern oder matching.cn_regex muss gesetzt sein"))
case pattern != "" && !strings.Contains(pattern, UsernamePlaceholder):
errs = append(errs, fmt.Errorf("matching.cn_pattern %q enthält keinen %s-Platzhalter",
pattern, UsernamePlaceholder))
case rx != "":
if _, err := regexp.Compile(rx); err != nil {
errs = append(errs, fmt.Errorf("matching.cn_regex ist nicht kompilierbar: %w", err))
}
}
if !slices.Contains([]string{"debug", "info", "warn", "error"}, c.Logging.Level) {
errs = append(errs, fmt.Errorf("logging.level %q ist ungültig (erlaubt: debug, info, warn, error)",
c.Logging.Level))
}
req(c.Logging.AuditLog, "logging.audit_log")
if c.Logging.MaxSizeMB <= 0 {
errs = append(errs, fmt.Errorf("logging.max_size_mb muss positiv sein, ist %d", c.Logging.MaxSizeMB))
}
if c.Logging.MaxBackups < 0 {
errs = append(errs, fmt.Errorf("logging.max_backups darf nicht negativ sein, ist %d", c.Logging.MaxBackups))
}
return errors.Join(errs...)
}
- Step 4:
ValidateinLoadaufrufen
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 mitjson:",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ülltTSautomatisch, ist goroutine-sicherfunc (l *Logger) Reopen() error— für SIGHUPfunc (l *Logger) Close() errorfunc ShortSession(token string) string— erste 4 Hex-Zeichen eines SHA-256 über den Token, nie der Token selbstconst UnknownUser = "<unknown>"
- Event-Konstanten:
-
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.goschreiben
// 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.goschreiben
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.goschreiben
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 }mitError() string,Unwrap() error- Reason-Konstanten:
ReasonInvalidCredentials = "invalid_credentials",ReasonAccountDisabled,ReasonAccountLocked,ReasonPasswordExpired,ReasonPasswordChangeRequired,ReasonNotInVPNGroup,ReasonUserNotFound,ReasonBackendUnavailable func ReasonFromLDAPError(err error) string— parstdata 52eetc. aus der LDAP-Fehlermeldungfunc (e *Error) UserVisible() bool— nurReasonPasswordExpiredlieferttrue(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.goschreiben
// 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.goschreiben
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(Dependencygithub.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 testbartype 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ülltAuthenticatorfunc (a *AD) ResolveGroupDN(ctx context.Context) (string, error)— beim Start aufgerufen, Ergebnis gecachtfunc (a *AD) Lookup(ctx context.Context, username string) (*LookupResult, error);type LookupResult struct { DN, SAMAccountName string; InVPNGroup bool }— fürcheck --test-authfunc (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.goschreiben
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.goschreiben
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 hierfunc (c *Client) Accounts(ctx context.Context, vpnID string) ([]Account, error)— nie gecachtfunc (c *Client) Export(ctx context.Context, vpnID, certRefID, format string) (*ExportResult, error);type ExportResult struct { Filename string; ContentType string; Body io.ReadCloser }— Aufrufer schließtBodyund streamtfunc (c *Client) Ping(ctx context.Context) (serverTime time.Time, err error)— fürcheckvar 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.goschreiben
// 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.goschreiben
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.goschreiben
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-insensitivefunc (m *Matcher) Describe(username string) string— für Audit-Feldpatterntype 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/abgelaufenfunc (e Entry) Token() string— stabiler, undurchsichtiger Bezeichnervpnid:refidfü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.goschreiben
// 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) *SessionStorefunc (s *SessionStore) Create(id *auth.Identity) (*Session, error)func (s *SessionStore) Get(token string) (*Session, bool)— abgelaufene Sessions werden entfernt und liefernfalsefunc (s *SessionStore) Destroy(token string)func (s *SessionStore) Count() intfunc (s *SessionStore) GC()— entfernt abgelaufene Einträgeconst 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.goschreiben
// 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) *Limiterfunc (l *Limiter) Allow(username, ip string) (retryAfter time.Duration, ok bool)— prüft beide Schlüssel, ohne zu zählenfunc (l *Limiter) RecordFailure(username, ip string)func (l *Limiter) RecordSuccess(username, ip string)— setzt beide Zähler zurückfunc (l *Limiter) GC()- Backoff-Kurve: die ersten
freeAttempts = 3Versuche sind frei; danach2^(n-3)Sekunden, gedeckelt beimaxBackoff = 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.goschreiben
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 insnake_casefunc T(key string) string— liefertS[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-Optionsfunc NoStore(w http.ResponseWriter)—Cache-Control: no-store,Pragma: no-cachefunc ClientIP(r *http.Request) string—RemoteAddrohne Port; kein Vertrauen inX-Forwarded-Fortype 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() Brandtype 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.goschreiben
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.goschreiben
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.goschreiben
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-Doublestype Server struct { ... };func NewServer(d Deps) (*Server, error);func (s *Server) Handler() http.Handlerfunc (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.goschreiben
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.goschreiben
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(StubshandleOverview/handleDownloadentfernen)
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) *Sourcetype 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 auskommenfunc (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
providerTTLgecacht; Accounts niemals func SafeFilename(instance, username, format string) string—vpn-<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.goschreiben
// 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.goentfernen undhandlers_certs.goschreiben
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(StubshandleGuides/handleHealthentfernen)
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 alsSuggested; filtert niefunc (s *Server) handleGuides(...),func (s *Server) handleHealth(...)/healthzantwortet ohne Auth mit JSON{"ok":true,"opnsense":true,"directory":true,"sessions":3}, Status 200 beiok, 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.goschreiben
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.goschreiben
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 — Detailtype 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, optionalTestanmeldung 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.goschreiben
// 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.goverdrahten
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:
checkgegen 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) inttype healthChecker struct { fw check.Pinger; dir web.HealthDirectory; clock func() time.Time }mit Ergebnis-Cache von 30 s, damit Monitoring die Backends nicht flutetfunc newTLSConfig(certFile, keyFile string) (*tls.Config, error)— TLS 1.2+, moderne Cipher-Reihenfolge- Startverhalten: Config laden →
checkausführen → bei Fehlern abbrechen → Auditstartupmit 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.goschreiben
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.goschreiben
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 2–4, 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:
- Was das Portal tut — Ein-Absatz-Beschreibung, ausdrücklich: read-only, erstellt und widerruft keine Zertifikate.
- Voraussetzungen — Linux amd64 oder arm64; OPNsense mit Plugin
os-openvpn-client-export(Mindestversion ausdocs/opnsense-api.md); Active Directory mit LDAPS; NTP zwingend; TLS-Zertifikat für das Portal. - Installation in unter 30 Minuten — nummerierte Schritte: Archiv entpacken, Binary nach
/usr/local/bin,/etc/vpnportalanlegen, Beispiel-Config kopieren und anpassen, Rechte setzen (chmod 640,chmod 600für den TLS-Key),vpnportal checkausführen, systemd-Unit installieren,systemctl enable --now vpnportal. - OPNsense vorbereiten — Benutzer anlegen, Gruppe mit ausschließlich dem Privileg „VPN: OpenVPN Client Export", API-Key erzeugen, keinen Admin-Key verwenden.
- Active Directory vorbereiten — unprivilegierter Dienstbenutzer genügt; Gruppe
VPN-Users; Hostnamen statt IP-Adressen inad.servers, weil das LDAPS-Zertifikat gegen den Hostnamen geprüft wird; interne CA überca_file. - Secrets — die drei
*_file-Varianten, die dreiVPNPORTAL_*-Variablen, Rangfolge Env > Datei > Inline,LoadCredential-Beispiel aus der Unit. - Zertifikatszuordnung —
cn_patternmit{username}, Beispiel{username}@firma.de,cn_regexals Notnagel für Bestandsumgebungen inklusive Warnung, dass er zu weit gefasst sein kann. - Betrieb —
/healthzfür Zabbix/CheckMK mit Beispielantwort; Log-Auswertung perjournalctl -u vpnportalund über die JSONL-Datei;jq-Einzeiler für Downloads eines Benutzers. - 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.
- Rotation — eingebaut und größenbasiert; alternativ externes logrotate mit
SIGHUP(systemctl reloadbzw.kill -HUP). - Raspberry Pi — SSD statt SD-Karte empfohlen; Gerät in einen abschließbaren Netzwerkschrank, weil das Speichermedium Zugangsdaten trägt; NTP prüfen.
- 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_patternprüfen, Audit-Feldpatternlesen; „Dienst vorübergehend nicht verfügbar" → Firewall oder CA; „Start bricht mit chmod-Hinweis ab" → Dateirechte. - Passwortänderung — nicht im Portal möglich; abgelaufene Passwörter am Arbeitsplatz ändern (AD-Code 532 zeigt eine eigene Meldung, 773 die generische).
- 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.
- Update — Binary austauschen,
systemctl restart; Sessions gehen dabei verloren, das ist beabsichtigt.portal.update_checkist standardmäßig aus und telefoniert eingeschaltet nach Hause. - Bauen aus dem Quelltext —
make test,make build,make release; Integrationstests mitgo 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 533 → account_disabled), Task 7 |
5. Fremdes Zertifikat → 403 + download_denied |
Task 14 (TestExportRevalidatesOwnership, TestForeignCertificateIsDenied) |
6. check erkennt Secret, Berechtigung, DC, CA, Rechte, Tippfehler |
Task 16 (alle Tests), Task 2 (TestParseRejectsUnknownField) |
7. DC-Ausfall → Failover, ldap_failover im Log |
Task 7 (TestFailoverToSecondDC), Task 17 (OnFailover verdrahtet Audit-Event) |
| 8. Passwort im Username-Feld erscheint nirgends im Log | Task 13 (TestUnknownUserIsNeverLoggedInClear, TestPasswordNeverAppearsInAnyEvent) |
9. no-store, POST + CSRF |
Task 12, 14 (TestDownloadStreamsWithHardenedHeaders, TestDownloadRequiresCSRF, TestDownloadGETIsNotRouted) |
| 10. Identisch auf amd64 und arm64 | Task 1 (Step 9), Task 18 (Step 6) |
| 11. Neustart beendet alle Sessions | Task 10 (Store ausschließlich im RAM) |
| 12. UI ohne JavaScript bedienbar | Task 12 (Formulare ohne JS, Toggle nur als Zusatz), Task 13 (TestLoginGETRendersForm) |
Ergänzung zu Kriterium 7: In Task 17 muss auth.Options.OnFailover beim Bauen des AD-Clients mit einer Funktion belegt werden, die audit.EventLDAPFailover mit dem Servernamen loggt. Da buildBackends in Task 16 noch ohne Audit-Logger arbeitet (der Logger wird erst danach geöffnet), ist in Task 17 nach dem Öffnen des Audit-Logs ad.SetFailoverHook(func(server string, err error) { auditLog.Log(audit.Event{Event: audit.EventLDAPFailover, Server: server}) }) aufzurufen. Dafür in internal/auth/ad.go ergänzen:
// 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.