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

9001 lines
271 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# VPN-Konfig-Portal für OPNsense — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ein statisch gelinktes Go-Single-Binary, mit dem AD-authentifizierte Endnutzer ihre persönliche OpenVPN-Konfiguration read-only von einer OPNsense-Firewall herunterladen.
**Architecture:** Server-Side-Rendering-Webportal ohne Datenbank. Sessions im RAM, alle Assets und Templates per `go:embed` im Binary. Drei austauschbare Kernbausteine hinter schmalen Interfaces: `auth.Authenticator` (AD/LDAP), `opnsense.Client` (Export-API) und `audit.Logger` (JSONL). Alle Tests laufen offline gegen `httptest`-Server und einen LDAP-Fake.
**Tech Stack:** Go 1.26, `github.com/go-ldap/ldap/v3`, `gopkg.in/yaml.v3` (strict), `html/template`, `net/http` (stdlib-Router mit Methoden-Patterns), keine CGO-Abhängigkeiten, kein npm, kein Build-Step.
## Global Constraints
Diese Vorgaben gelten für **jede** Task, auch wenn sie dort nicht wiederholt werden:
- Modulpfad: `git.ravensburg.dev/cabele/opnsense-portal`. Binary-Name: `vpnportal`.
- `CGO_ENABLED=0`, statisch gelinkt. Keine Dependency, die CGO erzwingt. Targets: `linux/amd64` und `linux/arm64`.
- Kein SPA-Framework, kein npm, kein Build-Step. JavaScript optional; jede Seite muss ohne JS voll bedienbar sein.
- UI-Sprache Deutsch. **Kein** deutschsprachiger String direkt im Handler — alle Texte über die zentrale Map `web.S` (i18n-vorbereitet, nur DE befüllt).
- Read-only gegenüber OPNsense: ausschließlich `GET`-Export-Endpunkte, niemals schreibende Aufrufe.
- Nie auf Platte persistieren: Zertifikate und Configs werden gestreamt, nicht gepuffert.
- Audit-Log ist JSON Lines, ein Event pro Zeile, Zeitstempel RFC 3339 **mit Zeitzone**.
- Verbotene Log-Inhalte: Passwörter, Config-Inhalte, Zertifikate, private Keys, vollständige Session-Tokens, CN-Listen auf Info-Level, Klartext-Usernamen unbekannter User.
- TLS-Verify ist immer an. `insecure_skip_verify` existiert **nur** für OPNsense und loggt bei jedem Start eine Warnung. Für LDAP gibt es keine Insecure-Option.
- Jede Secret-Option hat eine `*_file`-Variante und ein `VPNPORTAL_*`-Env-Override.
- Go-Fehler werden gewrappt (`fmt.Errorf("...: %w", err)`), niemals verschluckt.
- Tests: `go test ./...` muss nach jeder Task grün sein. Kein Netzwerkzugriff in Tests.
- Commits: konventionelle Prefixes (`feat:`, `test:`, `fix:`, `chore:`, `docs:`), ein Commit pro Task-Ende.
## Assumptions to Verify (explizit offen)
Die exakten Feldnamen und Response-Shapes der OPNsense-`os-openvpn-client-export`-API sind versionsabhängig und konnten offline nicht verifiziert werden. Der Client wird deshalb **tolerant** gebaut (mehrere akzeptierte Feldnamen, mehrere akzeptierte Datums- und Boolean-Repräsentationen) und die Annahmen werden in `docs/opnsense-api.md` dokumentiert. Task 8 legt build-getaggte Integrationstest-Skeletons (`//go:build integration`) an, die gegen eine echte Instanz laufen und diese Annahmen bestätigen müssen, bevor v1 ausgeliefert wird.
## File Structure
| Datei | Verantwortung |
|---|---|
| `cmd/vpnportal/main.go` | CLI-Dispatch `serve` / `check` / `version`, Exit-Codes |
| `cmd/vpnportal/buildinfo.go` | `version`, `commit`, `date` via ldflags |
| `cmd/vpnportal/serve.go` | Verdrahtung aller Komponenten, HTTPS-Server, Signal-Handling |
| `cmd/vpnportal/check.go` | `check`-Kommando inkl. `--test-auth` |
| `internal/config/config.go` | Config-Structs, Defaults |
| `internal/config/load.go` | YAML-Strict-Parsing, `*_file`-Auflösung, Env-Overrides |
| `internal/config/perms.go` | Erzwingen der Dateirechte (Config, Secrets, Log-Verzeichnis) |
| `internal/config/validate.go` | Semantische Validierung, Base-DN-Ableitung |
| `internal/audit/event.go` | `Event`-Struct, Event-Namen, Reason-Konstanten |
| `internal/audit/logger.go` | JSONL-Writer, stdout-Tee, Session-ID-Kürzung |
| `internal/audit/rotate.go` | Größenbasierte Rotation, gzip, SIGHUP-Reopen |
| `internal/auth/auth.go` | `Authenticator`-Interface, `Identity`, `Error` mit `Reason` |
| `internal/auth/addata.go` | Mapping AD-Error-49-`data`-Codes → `Reason` |
| `internal/auth/ad.go` | AD-Implementierung: Zwei-Schritt-Bind, Gruppenprüfung |
| `internal/auth/dial.go` | DC-Failover-Dialer (LDAPS/StartTLS, Timeouts) |
| `internal/opnsense/client.go` | HTTP-Client, Basic-Auth mit Key/Secret, CA/TLS |
| `internal/opnsense/export.go` | `Providers()`, `Accounts()`, `Export()` (streaming) |
| `internal/opnsense/types.go` | Tolerante DTOs, flexible Bool-/Datums-Decoder |
| `internal/certmatch/match.go` | CN-Pattern/Regex-Matching, Revoked-/Expiry-Filter |
| `internal/web/server.go` | Router, Middleware-Kette, Handler-Dependencies |
| `internal/web/security.go` | Security-Header, `no-store`, Real-IP-Ermittlung |
| `internal/web/session.go` | RAM-Session-Store mit TTL, CSRF-Token |
| `internal/web/ratelimit.go` | Exponentieller Backoff pro User und pro IP |
| `internal/web/strings.go` | Zentrale DE-String-Map `S` |
| `internal/web/render.go` | Template-Parsing (`go:embed`), Render-Helper |
| `internal/web/handlers_login.go` | `GET/POST /login`, `POST /logout` |
| `internal/web/handlers_certs.go` | `GET /`, `POST /download` |
| `internal/web/handlers_static.go` | `GET /anleitungen`, `GET /healthz`, Assets |
| `internal/web/templates/*.html` | Layout, Login, Übersicht, Anleitungen, Fehlerseite |
| `internal/web/assets/style.css`, `app.js` | Eingebettete Assets, keine externen Ressourcen |
| `deploy/vpnportal.service` | Gehärtete systemd-Unit |
| `deploy/config.example.yaml` | Beispiel-Config, alle Optionen auskommentiert |
| `Makefile` | `build`, `test`, `lint`, `release`, `vet` |
| `README.md`, `docs/opnsense-api.md` | Installations- und Betriebsdoku, API-Annahmen |
---
## Task 1: Projekt-Skeleton und `version`-Kommando
**Files:**
- Create: `go.mod`, `.gitignore`, `Makefile`
- Create: `cmd/vpnportal/main.go`, `cmd/vpnportal/buildinfo.go`
- Test: `cmd/vpnportal/main_test.go`
**Interfaces:**
- Consumes: nichts
- Produces: `func run(args []string, stdout, stderr io.Writer) int` — testbarer CLI-Einstieg; `main()` ruft nur `os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))`. Buildinfo-Variablen `version`, `commit`, `date` (Package-Level `var`, per `-ldflags -X` gesetzt).
- [ ] **Step 1: Modul initialisieren und `.gitignore` anlegen**
```bash
cd /Users/cabele/claudeprojects/opnsense-portal
go mod init git.ravensburg.dev/cabele/opnsense-portal
printf '/dist/\n/vpnportal\n*.log\n.DS_Store\n' > .gitignore
```
- [ ] **Step 2: Failing test schreiben**
`cmd/vpnportal/main_test.go`:
```go
package main
import (
"bytes"
"strings"
"testing"
)
func TestRunVersion(t *testing.T) {
var out, errOut bytes.Buffer
code := run([]string{"version"}, &out, &errOut)
if code != 0 {
t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, errOut.String())
}
got := out.String()
for _, want := range []string{"vpnportal", "commit", "built"} {
if !strings.Contains(got, want) {
t.Errorf("output %q missing %q", got, want)
}
}
}
func TestRunUnknownCommand(t *testing.T) {
var out, errOut bytes.Buffer
if code := run([]string{"frobnicate"}, &out, &errOut); code != 2 {
t.Fatalf("exit code = %d, want 2", code)
}
if !strings.Contains(errOut.String(), "frobnicate") {
t.Errorf("stderr should name the unknown command, got %q", errOut.String())
}
}
func TestRunNoArgsDefaultsToServe(t *testing.T) {
var out, errOut bytes.Buffer
// serve ohne --config muss mit Fehler abbrechen, nicht mit "unknown command"
code := run(nil, &out, &errOut)
if code == 2 {
t.Fatalf("no args must default to serve, not unknown-command")
}
if !strings.Contains(errOut.String(), "config") {
t.Errorf("stderr should complain about missing config, got %q", errOut.String())
}
}
```
- [ ] **Step 3: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./cmd/vpnportal/ -run TestRun -v`
Expected: FAIL — `undefined: run`
- [ ] **Step 4: `buildinfo.go` schreiben**
```go
package main
import "fmt"
// Per -ldflags gesetzt; siehe Makefile.
var (
version = "dev"
commit = "none"
date = "unknown"
)
func versionString() string {
return fmt.Sprintf("vpnportal %s (commit %s, built %s)", version, commit, date)
}
```
- [ ] **Step 5: `main.go` schreiben**
```go
package main
import (
"fmt"
"io"
"os"
)
const usage = `vpnportal — VPN-Konfig-Portal für OPNsense
Aufruf:
vpnportal serve [--config PFAD] Portal starten (Default)
vpnportal check [--config PFAD] [--test-auth BENUTZER]
vpnportal version
Optionen:
--config PFAD Pfad zur Konfigurationsdatei (Default: /etc/vpnportal/config.yaml)
`
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
func run(args []string, stdout, stderr io.Writer) int {
cmd := "serve"
rest := args
if len(args) > 0 && len(args[0]) > 0 && args[0][0] != '-' {
cmd, rest = args[0], args[1:]
}
switch cmd {
case "version":
fmt.Fprintln(stdout, versionString())
return 0
case "serve":
return runServe(rest, stdout, stderr)
case "check":
return runCheck(rest, stdout, stderr)
case "help", "-h", "--help":
fmt.Fprint(stdout, usage)
return 0
default:
fmt.Fprintf(stderr, "Unbekanntes Kommando: %s\n\n%s", cmd, usage)
return 2
}
}
```
- [ ] **Step 6: Minimale `serve.go` und `check.go` anlegen**
`cmd/vpnportal/serve.go`:
```go
package main
import (
"flag"
"fmt"
"io"
)
const defaultConfigPath = "/etc/vpnportal/config.yaml"
func configFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(stderr)
path := fs.String("config", defaultConfigPath, "Pfad zur Konfigurationsdatei")
return fs, path
}
func runServe(args []string, stdout, stderr io.Writer) int {
fs, path := configFlags("serve", stderr)
if err := fs.Parse(args); err != nil {
return 2
}
fmt.Fprintf(stderr, "Fehler: config %q kann noch nicht geladen werden (nicht implementiert)\n", *path)
return 1
}
```
`cmd/vpnportal/check.go`:
```go
package main
import (
"fmt"
"io"
)
func runCheck(args []string, stdout, stderr io.Writer) int {
fs, path := configFlags("check", stderr)
testAuth := fs.String("test-auth", "", "Suche und Gruppenprüfung für einen Benutzer durchspielen")
if err := fs.Parse(args); err != nil {
return 2
}
_ = testAuth
fmt.Fprintf(stderr, "Fehler: config %q kann noch nicht geladen werden (nicht implementiert)\n", *path)
return 1
}
```
- [ ] **Step 7: Tests laufen lassen**
Run: `go test ./cmd/vpnportal/ -v`
Expected: PASS (alle drei Tests)
- [ ] **Step 8: Makefile schreiben**
```makefile
BINARY := vpnportal
PKG := git.ravensburg.dev/cabele/opnsense-portal/cmd/vpnportal
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)
export CGO_ENABLED := 0
.PHONY: all build test vet lint release clean
all: vet test build
build:
go build -trimpath -ldflags '$(LDFLAGS)' -o dist/$(BINARY) $(PKG)
test:
go test ./...
vet:
go vet ./...
lint: vet
gofmt -l . | tee /dev/stderr | (! read)
release: clean
@set -e; for arch in amd64 arm64; do \
echo "==> linux/$$arch"; \
GOOS=linux GOARCH=$$arch go build -trimpath -ldflags '$(LDFLAGS)' \
-o dist/linux-$$arch/$(BINARY) $(PKG); \
mkdir -p dist/linux-$$arch; \
cp deploy/config.example.yaml deploy/vpnportal.service README.md dist/linux-$$arch/; \
tar -czf dist/$(BINARY)-$(VERSION)-linux-$$arch.tar.gz -C dist/linux-$$arch .; \
done
cd dist && sha256sum *.tar.gz > SHA256SUMS
clean:
rm -rf dist
```
- [ ] **Step 9: Build für beide Zielarchitekturen verifizieren**
Run:
```bash
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /dev/null ./cmd/vpnportal
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o /dev/null ./cmd/vpnportal
make test
```
Expected: beide Builds ohne Ausgabe, Tests PASS
- [ ] **Step 10: Commit**
```bash
git add -A
git commit -m "feat: CLI-Skeleton mit version/serve/check und Makefile"
```
---
## Task 2: Config-Structs und YAML-Strict-Parsing
**Files:**
- Create: `internal/config/config.go`, `internal/config/load.go`
- Test: `internal/config/load_test.go`
- Modify: `go.mod` (Dependency `gopkg.in/yaml.v3`)
**Interfaces:**
- Consumes: nichts
- Produces:
- `type Config struct { Portal PortalConfig; OPNsense OPNsenseConfig; AD ADConfig; Matching MatchingConfig; Logging LoggingConfig }`
- `type Duration time.Duration` mit `UnmarshalYAML` für Strings wie `"10m"`
- `func Parse(r io.Reader) (*Config, error)` — strict, mit Defaults vorbelegt
- Defaults: `portal.listen="0.0.0.0:8443"`, `portal.session_ttl=10m`, `ad.port=636`, `ad.tls_mode="ldaps"`, `matching.cn_pattern="{username}"`, `logging.level="info"`, `logging.max_size_mb=50`, `logging.max_backups=5`, `logging.compress=true`
- [ ] **Step 1: Dependency hinzufügen**
```bash
go get gopkg.in/yaml.v3@latest
```
- [ ] **Step 2: Failing test schreiben**
`internal/config/load_test.go`:
```go
package config
import (
"strings"
"testing"
"time"
)
const minimalYAML = `
portal:
tls_cert: "/etc/vpnportal/portal.crt"
tls_key: "/etc/vpnportal/portal.key"
opnsense:
url: "https://fw01.firma.local"
api_key: "KEY"
api_secret: "SECRET"
ad:
domain: "firma.local"
servers: ["dc01.firma.local"]
bind_user: "svc@firma.local"
bind_password: "PW"
vpn_group: "VPN-Users"
logging:
audit_log: "/var/log/vpnportal/audit.log"
`
func TestParseAppliesDefaults(t *testing.T) {
cfg, err := Parse(strings.NewReader(minimalYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if cfg.Portal.Listen != "0.0.0.0:8443" {
t.Errorf("Listen = %q, want default 0.0.0.0:8443", cfg.Portal.Listen)
}
if time.Duration(cfg.Portal.SessionTTL) != 10*time.Minute {
t.Errorf("SessionTTL = %v, want 10m", time.Duration(cfg.Portal.SessionTTL))
}
if cfg.AD.Port != 636 {
t.Errorf("AD.Port = %d, want 636", cfg.AD.Port)
}
if cfg.Matching.CNPattern != "{username}" {
t.Errorf("CNPattern = %q, want {username}", cfg.Matching.CNPattern)
}
if cfg.Logging.MaxSizeMB != 50 || cfg.Logging.MaxBackups != 5 || !cfg.Logging.Compress {
t.Errorf("logging defaults wrong: %+v", cfg.Logging)
}
if cfg.Portal.UpdateCheck {
t.Error("UpdateCheck must default to false")
}
}
func TestParseRejectsUnknownField(t *testing.T) {
_, err := Parse(strings.NewReader(minimalYAML + "\nportal_typo:\n listen: \"x\"\n"))
if err == nil {
t.Fatal("unbekanntes Feld muss abgelehnt werden")
}
if !strings.Contains(err.Error(), "portal_typo") {
t.Errorf("Fehler muss das unbekannte Feld nennen, got: %v", err)
}
}
func TestParseRejectsUnknownNestedField(t *testing.T) {
_, err := Parse(strings.NewReader(strings.Replace(minimalYAML,
" vpn_group: \"VPN-Users\"", " vpn_group: \"VPN-Users\"\n vpngroup: \"typo\"", 1)))
if err == nil {
t.Fatal("unbekanntes verschachteltes Feld muss abgelehnt werden")
}
}
func TestParseSessionTTLOverride(t *testing.T) {
cfg, err := Parse(strings.NewReader(strings.Replace(minimalYAML,
"portal:", "portal:\n session_ttl: \"90s\"", 1)))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if time.Duration(cfg.Portal.SessionTTL) != 90*time.Second {
t.Errorf("SessionTTL = %v, want 90s", time.Duration(cfg.Portal.SessionTTL))
}
}
func TestParseRejectsBadDuration(t *testing.T) {
_, err := Parse(strings.NewReader(strings.Replace(minimalYAML,
"portal:", "portal:\n session_ttl: \"zehn Minuten\"", 1)))
if err == nil {
t.Fatal("ungültige Dauer muss abgelehnt werden")
}
}
```
- [ ] **Step 3: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./internal/config/ -v`
Expected: FAIL — `undefined: Parse`
- [ ] **Step 4: `config.go` schreiben**
```go
// Package config lädt und validiert die Portal-Konfiguration.
package config
import (
"fmt"
"time"
"gopkg.in/yaml.v3"
)
// Duration erlaubt Dauerangaben als String ("10m") in YAML.
type Duration time.Duration
func (d *Duration) UnmarshalYAML(node *yaml.Node) error {
var s string
if err := node.Decode(&s); err != nil {
return fmt.Errorf("Dauer muss eine Zeichenkette sein (z. B. \"10m\"): %w", err)
}
parsed, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("ungültige Dauer %q (erwartet z. B. \"10m\", \"90s\"): %w", s, err)
}
if parsed <= 0 {
return fmt.Errorf("Dauer %q muss positiv sein", s)
}
*d = Duration(parsed)
return nil
}
func (d Duration) String() string { return time.Duration(d).String() }
type Config struct {
Portal PortalConfig `yaml:"portal"`
OPNsense OPNsenseConfig `yaml:"opnsense"`
AD ADConfig `yaml:"ad"`
Matching MatchingConfig `yaml:"matching"`
Logging LoggingConfig `yaml:"logging"`
}
type PortalConfig struct {
Listen string `yaml:"listen"`
TLSCert string `yaml:"tls_cert"`
TLSKey string `yaml:"tls_key"`
SessionTTL Duration `yaml:"session_ttl"`
Title string `yaml:"title"`
LogoFile string `yaml:"logo_file"`
SupportContact string `yaml:"support_contact"`
UpdateCheck bool `yaml:"update_check"`
}
type OPNsenseConfig struct {
URL string `yaml:"url"`
APIKey string `yaml:"api_key"`
APIKeyFile string `yaml:"api_key_file"`
APISecret string `yaml:"api_secret"`
APISecretFile string `yaml:"api_secret_file"`
CAFile string `yaml:"ca_file"`
InsecureSkipVerify bool `yaml:"insecure_skip_verify"`
}
type ADConfig struct {
Domain string `yaml:"domain"`
BaseDN string `yaml:"base_dn"`
Servers []string `yaml:"servers"`
Port int `yaml:"port"`
TLSMode string `yaml:"tls_mode"` // "ldaps" (Default) oder "starttls"
BindUser string `yaml:"bind_user"`
BindPassword string `yaml:"bind_password"`
BindPasswordFile string `yaml:"bind_password_file"`
VPNGroup string `yaml:"vpn_group"`
CAFile string `yaml:"ca_file"`
Timeout Duration `yaml:"timeout"`
}
type MatchingConfig struct {
CNPattern string `yaml:"cn_pattern"`
CNRegex string `yaml:"cn_regex"`
}
type LoggingConfig struct {
Level string `yaml:"level"`
AuditLog string `yaml:"audit_log"`
MaxSizeMB int `yaml:"max_size_mb"`
MaxBackups int `yaml:"max_backups"`
Compress bool `yaml:"compress"`
}
// Defaults liefert eine Config mit allen Vorgabewerten.
func Defaults() *Config {
return &Config{
Portal: PortalConfig{
Listen: "0.0.0.0:8443",
SessionTTL: Duration(10 * time.Minute),
Title: "VPN-Portal",
},
AD: ADConfig{
Port: 636,
TLSMode: "ldaps",
Timeout: Duration(8 * time.Second),
},
Matching: MatchingConfig{CNPattern: "{username}"},
Logging: LoggingConfig{
Level: "info",
MaxSizeMB: 50,
MaxBackups: 5,
Compress: true,
},
}
}
```
Anmerkung zu `Compress`: der Default ist `true`, YAML `compress: false` überschreibt ihn korrekt, weil in ein vorbelegtes Struct hineindekodiert wird.
- [ ] **Step 5: `load.go` mit `Parse` schreiben**
```go
package config
import (
"errors"
"fmt"
"io"
"gopkg.in/yaml.v3"
)
// Parse liest YAML strikt in eine mit Defaults vorbelegte Config.
// Unbekannte Felder führen zum Fehler.
func Parse(r io.Reader) (*Config, error) {
cfg := Defaults()
dec := yaml.NewDecoder(r)
dec.KnownFields(true)
if err := dec.Decode(cfg); err != nil {
if errors.Is(err, io.EOF) {
return nil, errors.New("Konfigurationsdatei ist leer")
}
return nil, fmt.Errorf("Konfiguration konnte nicht gelesen werden: %w", err)
}
// Ein zweiter Decode-Aufruf muss EOF liefern; sonst enthält die Datei
// mehrere YAML-Dokumente, was wir nicht unterstützen.
var extra yaml.Node
if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
return nil, errors.New("Konfigurationsdatei darf nur ein YAML-Dokument enthalten")
}
return cfg, nil
}
```
- [ ] **Step 6: Tests laufen lassen**
Run: `go test ./internal/config/ -v`
Expected: PASS (5 Tests)
- [ ] **Step 7: Commit**
```bash
git add go.mod go.sum internal/config
git commit -m "feat(config): YAML-Strict-Parsing mit Defaults"
```
---
## Task 3: Secrets, Env-Overrides und erzwungene Dateirechte
**Files:**
- Create: `internal/config/perms.go`
- Modify: `internal/config/load.go` (Funktion `Load`)
- Test: `internal/config/perms_test.go`, `internal/config/secrets_test.go`
**Interfaces:**
- Consumes: `Parse`, `Config` aus Task 2
- Produces:
- `func Load(path string, env func(string) string) (*Config, error)` — liest Datei, prüft Rechte, parst, löst `*_file` auf, wendet Env-Overrides an. `env` ist injizierbar (`os.Getenv` in Produktion).
- `func CheckFileMode(path string, maxMode fs.FileMode) error` — Fehler, wenn Bits jenseits von `maxMode` gesetzt sind.
- `func CheckDirMode(path string, maxMode fs.FileMode) error`
- Env-Namen: `VPNPORTAL_OPNSENSE_API_KEY`, `VPNPORTAL_OPNSENSE_API_SECRET`, `VPNPORTAL_AD_BIND_PASSWORD`
- Rangfolge: Env-Var > `*_file` > Inline-Wert in YAML
- [ ] **Step 1: Failing tests für Dateirechte schreiben**
`internal/config/perms_test.go`:
```go
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func writeFileMode(t *testing.T, dir, name string, mode os.FileMode, content string) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(content), mode); err != nil {
t.Fatal(err)
}
if err := os.Chmod(p, mode); err != nil { // umask umgehen
t.Fatal(err)
}
return p
}
func TestCheckFileModeAcceptsTightPermissions(t *testing.T) {
dir := t.TempDir()
for _, mode := range []os.FileMode{0o600, 0o640, 0o400} {
p := writeFileMode(t, dir, "s.txt", mode, "x")
if err := CheckFileMode(p, 0o640); err != nil {
t.Errorf("mode %#o should be accepted: %v", mode, err)
}
}
}
func TestCheckFileModeRejectsLoosePermissions(t *testing.T) {
dir := t.TempDir()
for _, mode := range []os.FileMode{0o644, 0o660, 0o604, 0o777} {
p := writeFileMode(t, dir, "s.txt", mode, "x")
err := CheckFileMode(p, 0o640)
if err == nil {
t.Errorf("mode %#o must be rejected", mode)
continue
}
if !strings.Contains(err.Error(), "chmod") {
t.Errorf("Fehler sollte den Reparaturbefehl nennen, got: %v", err)
}
}
}
func TestCheckDirModeRejectsWorldWritable(t *testing.T) {
dir := t.TempDir()
sub := filepath.Join(dir, "logs")
if err := os.Mkdir(sub, 0o777); err != nil {
t.Fatal(err)
}
if err := os.Chmod(sub, 0o777); err != nil {
t.Fatal(err)
}
if err := CheckDirMode(sub, 0o750); err == nil {
t.Fatal("0777-Verzeichnis muss abgelehnt werden")
}
if err := os.Chmod(sub, 0o750); err != nil {
t.Fatal(err)
}
if err := CheckDirMode(sub, 0o750); err != nil {
t.Errorf("0750 sollte akzeptiert werden: %v", err)
}
}
```
- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./internal/config/ -run TestCheck -v`
Expected: FAIL — `undefined: CheckFileMode`
- [ ] **Step 3: `perms.go` schreiben**
```go
package config
import (
"fmt"
"io/fs"
"os"
)
// CheckFileMode bricht ab, wenn die Datei mehr Rechte trägt als maxMode.
func CheckFileMode(path string, maxMode fs.FileMode) error {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("Datei %s nicht lesbar: %w", path, err)
}
if info.IsDir() {
return fmt.Errorf("%s ist ein Verzeichnis, erwartet wurde eine Datei", path)
}
return checkMode(path, info.Mode().Perm(), maxMode)
}
// CheckDirMode bricht ab, wenn das Verzeichnis mehr Rechte trägt als maxMode.
func CheckDirMode(path string, maxMode fs.FileMode) error {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("Verzeichnis %s nicht lesbar: %w", path, err)
}
if !info.IsDir() {
return fmt.Errorf("%s ist kein Verzeichnis", path)
}
return checkMode(path, info.Mode().Perm(), maxMode)
}
func checkMode(path string, actual, maxMode fs.FileMode) error {
if extra := actual &^ maxMode; extra != 0 {
return fmt.Errorf(
"%s hat zu weite Dateirechte %#o (erlaubt höchstens %#o). Beheben mit: chmod %#o %s",
path, actual, maxMode, maxMode, path)
}
return nil
}
```
- [ ] **Step 4: Tests laufen lassen**
Run: `go test ./internal/config/ -run TestCheck -v`
Expected: PASS
- [ ] **Step 5: Failing tests für Secrets und Env-Overrides schreiben**
`internal/config/secrets_test.go`:
```go
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func noEnv(string) string { return "" }
func writeConfig(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Chmod(p, 0o600); err != nil {
t.Fatal(err)
}
return p
}
func TestLoadRejectsWorldReadableConfig(t *testing.T) {
p := writeConfig(t, minimalYAML)
if err := os.Chmod(p, 0o644); err != nil {
t.Fatal(err)
}
if _, err := Load(p, noEnv); err == nil {
t.Fatal("0644-Config muss den Start abbrechen")
}
}
func TestLoadResolvesSecretFiles(t *testing.T) {
dir := t.TempDir()
secret := filepath.Join(dir, "apisecret")
if err := os.WriteFile(secret, []byte(" FROM-FILE\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Chmod(secret, 0o600); err != nil {
t.Fatal(err)
}
body := strings.Replace(minimalYAML,
` api_secret: "SECRET"`, ` api_secret_file: "`+secret+`"`, 1)
cfg, err := Load(writeConfig(t, body), noEnv)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.OPNsense.APISecret != "FROM-FILE" {
t.Errorf("APISecret = %q, want FROM-FILE (getrimmt)", cfg.OPNsense.APISecret)
}
}
func TestLoadRejectsLooseSecretFile(t *testing.T) {
dir := t.TempDir()
secret := filepath.Join(dir, "apisecret")
if err := os.WriteFile(secret, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chmod(secret, 0o644); err != nil {
t.Fatal(err)
}
body := strings.Replace(minimalYAML,
` api_secret: "SECRET"`, ` api_secret_file: "`+secret+`"`, 1)
if _, err := Load(writeConfig(t, body), noEnv); err == nil {
t.Fatal("Secret-Datei mit 0644 muss abgelehnt werden")
}
}
func TestLoadEnvOverridesEverything(t *testing.T) {
env := map[string]string{
"VPNPORTAL_OPNSENSE_API_SECRET": "FROM-ENV",
"VPNPORTAL_AD_BIND_PASSWORD": "ENV-PW",
}
cfg, err := Load(writeConfig(t, minimalYAML), func(k string) string { return env[k] })
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.OPNsense.APISecret != "FROM-ENV" {
t.Errorf("APISecret = %q, want FROM-ENV", cfg.OPNsense.APISecret)
}
if cfg.AD.BindPassword != "ENV-PW" {
t.Errorf("BindPassword = %q, want ENV-PW", cfg.AD.BindPassword)
}
}
func TestLoadRejectsBothInlineAndFile(t *testing.T) {
body := strings.Replace(minimalYAML,
` api_secret: "SECRET"`,
" api_secret: \"SECRET\"\n api_secret_file: \"/tmp/x\"", 1)
_, err := Load(writeConfig(t, body), noEnv)
if err == nil || !strings.Contains(err.Error(), "api_secret") {
t.Fatalf("gleichzeitig gesetzte api_secret/api_secret_file müssen abgelehnt werden, got: %v", err)
}
}
```
- [ ] **Step 6: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./internal/config/ -run TestLoad -v`
Expected: FAIL — `undefined: Load`
- [ ] **Step 7: `Load` in `load.go` ergänzen**
```go
// Am Kopf von load.go zusätzlich importieren: "os", "strings"
const (
EnvAPIKey = "VPNPORTAL_OPNSENSE_API_KEY"
EnvAPISecret = "VPNPORTAL_OPNSENSE_API_SECRET"
EnvBindPassword = "VPNPORTAL_AD_BIND_PASSWORD"
)
// maxConfigMode ist die weiteste erlaubte Berechtigung für Config- und
// Secret-Dateien: Eigentümer lesen/schreiben, Gruppe lesen, Welt nichts.
const maxConfigMode fs.FileMode = 0o640
// Load liest die Konfigurationsdatei, erzwingt Dateirechte, löst
// *_file-Secrets auf und wendet Env-Overrides an.
// env wird injiziert, damit Tests ohne Prozess-Umgebung auskommen.
func Load(path string, env func(string) string) (*Config, error) {
if env == nil {
env = os.Getenv
}
if err := CheckFileMode(path, maxConfigMode); err != nil {
return nil, err
}
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("Konfigurationsdatei %s: %w", path, err)
}
defer f.Close()
cfg, err := Parse(f)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if err := resolveSecrets(cfg, env); err != nil {
return nil, err
}
return cfg, nil
}
type secretSlot struct {
name string // YAML-Feldname für Fehlermeldungen
inline *string
file string
envName string
}
func resolveSecrets(cfg *Config, env func(string) string) error {
slots := []secretSlot{
{"opnsense.api_key", &cfg.OPNsense.APIKey, cfg.OPNsense.APIKeyFile, EnvAPIKey},
{"opnsense.api_secret", &cfg.OPNsense.APISecret, cfg.OPNsense.APISecretFile, EnvAPISecret},
{"ad.bind_password", &cfg.AD.BindPassword, cfg.AD.BindPasswordFile, EnvBindPassword},
}
for _, s := range slots {
if v := env(s.envName); v != "" {
*s.inline = v
continue
}
if s.file == "" {
continue
}
if *s.inline != "" {
return fmt.Errorf("%s und %s_file sind gleichzeitig gesetzt — bitte nur eines verwenden",
s.name, s.name)
}
if err := CheckFileMode(s.file, maxConfigMode); err != nil {
return fmt.Errorf("Secret-Datei für %s: %w", s.name, err)
}
raw, err := os.ReadFile(s.file)
if err != nil {
return fmt.Errorf("Secret-Datei für %s: %w", s.name, err)
}
value := strings.TrimSpace(string(raw))
if value == "" {
return fmt.Errorf("Secret-Datei %s für %s ist leer", s.file, s.name)
}
*s.inline = value
}
return nil
}
```
`"io/fs"` muss in `load.go` importiert werden.
- [ ] **Step 8: Tests laufen lassen**
Run: `go test ./internal/config/ -v`
Expected: PASS (alle Tests aus Task 2 und 3)
- [ ] **Step 9: Commit**
```bash
git add internal/config
git commit -m "feat(config): Secret-Dateien, Env-Overrides und erzwungene Dateirechte"
```
---
## Task 4: Semantische Validierung und Base-DN-Ableitung
**Files:**
- Create: `internal/config/validate.go`
- Test: `internal/config/validate_test.go`
- Modify: `internal/config/load.go` (Validate am Ende von `Load` aufrufen)
**Interfaces:**
- Consumes: `Config` aus Task 2
- Produces:
- `func (c *Config) Validate() error` — sammelt **alle** Fehler und gibt sie gebündelt aus (`errors.Join`)
- `func BaseDNFromDomain(domain string) (string, error)``firma.local``DC=firma,DC=local`
- `func (c *Config) EffectiveBaseDN() string`
- Regeln: `tls_cert`/`tls_key` Pflicht; `opnsense.url` muss `https://` sein; `ad.servers` nicht leer und **keine IP-Adressen**; `tls_mode` ∈ {`ldaps`,`starttls`}; `cn_pattern` und `cn_regex` schließen sich aus; `cn_regex` muss kompilierbar sein; `cn_pattern` muss `{username}` enthalten; `logging.level` ∈ {`debug`,`info`,`warn`,`error`}; `audit_log` Pflicht
- [ ] **Step 1: Failing test schreiben**
`internal/config/validate_test.go`:
```go
package config
import (
"strings"
"testing"
)
func validCfg(t *testing.T) *Config {
t.Helper()
cfg, err := Parse(strings.NewReader(minimalYAML))
if err != nil {
t.Fatal(err)
}
return cfg
}
func TestBaseDNFromDomain(t *testing.T) {
cases := map[string]string{
"firma.local": "DC=firma,DC=local",
"ad.firma.example": "DC=ad,DC=firma,DC=example",
"FIRMA.LOCAL": "DC=FIRMA,DC=LOCAL",
}
for in, want := range cases {
got, err := BaseDNFromDomain(in)
if err != nil {
t.Errorf("BaseDNFromDomain(%q): %v", in, err)
continue
}
if got != want {
t.Errorf("BaseDNFromDomain(%q) = %q, want %q", in, got, want)
}
}
if _, err := BaseDNFromDomain("firmalocal"); err == nil {
t.Error("Domain ohne Punkt muss abgelehnt werden")
}
if _, err := BaseDNFromDomain(""); err == nil {
t.Error("leere Domain muss abgelehnt werden")
}
}
func TestEffectiveBaseDNPrefersOverride(t *testing.T) {
cfg := validCfg(t)
cfg.AD.BaseDN = "OU=Users,DC=firma,DC=local"
if got := cfg.EffectiveBaseDN(); got != "OU=Users,DC=firma,DC=local" {
t.Errorf("Override muss gewinnen, got %q", got)
}
cfg.AD.BaseDN = ""
if got := cfg.EffectiveBaseDN(); got != "DC=firma,DC=local" {
t.Errorf("Ableitung falsch, got %q", got)
}
}
func TestValidateAcceptsMinimalConfig(t *testing.T) {
if err := validCfg(t).Validate(); err != nil {
t.Fatalf("minimale Config sollte gültig sein: %v", err)
}
}
func TestValidateRejectsIPServers(t *testing.T) {
cfg := validCfg(t)
cfg.AD.Servers = []string{"10.1.1.10"}
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "10.1.1.10") {
t.Fatalf("IP-Adresse als DC muss abgelehnt werden, got: %v", err)
}
}
func TestValidateRejectsHTTPOPNsense(t *testing.T) {
cfg := validCfg(t)
cfg.OPNsense.URL = "http://fw01.firma.local"
if err := cfg.Validate(); err == nil {
t.Fatal("http:// muss abgelehnt werden")
}
}
func TestValidateRejectsPatternAndRegexTogether(t *testing.T) {
cfg := validCfg(t)
cfg.Matching.CNRegex = "^.*$"
if err := cfg.Validate(); err == nil {
t.Fatal("cn_pattern und cn_regex gleichzeitig muss abgelehnt werden")
}
}
func TestValidateRejectsBadRegex(t *testing.T) {
cfg := validCfg(t)
cfg.Matching.CNPattern = ""
cfg.Matching.CNRegex = "([unbalanced"
if err := cfg.Validate(); err == nil {
t.Fatal("unkompilierbare Regex muss abgelehnt werden")
}
}
func TestValidateCollectsMultipleErrors(t *testing.T) {
cfg := validCfg(t)
cfg.Portal.TLSCert = ""
cfg.Portal.TLSKey = ""
cfg.AD.VPNGroup = ""
err := cfg.Validate()
if err == nil {
t.Fatal("mehrere Fehler erwartet")
}
for _, want := range []string{"tls_cert", "tls_key", "vpn_group"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("Sammelfehler muss %q nennen, got: %v", want, err)
}
}
}
func TestValidateRejectsPatternWithoutPlaceholder(t *testing.T) {
cfg := validCfg(t)
cfg.Matching.CNPattern = "fester-cn"
if err := cfg.Validate(); err == nil {
t.Fatal("cn_pattern ohne {username} muss abgelehnt werden")
}
}
```
- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./internal/config/ -run 'TestValidate|TestBaseDN|TestEffective' -v`
Expected: FAIL — `undefined: BaseDNFromDomain`
- [ ] **Step 3: `validate.go` schreiben**
```go
package config
import (
"errors"
"fmt"
"net"
"net/url"
"regexp"
"slices"
"strings"
)
// UsernamePlaceholder ist der Platzhalter in matching.cn_pattern.
const UsernamePlaceholder = "{username}"
// BaseDNFromDomain leitet DC=firma,DC=local aus firma.local ab.
func BaseDNFromDomain(domain string) (string, error) {
domain = strings.Trim(strings.TrimSpace(domain), ".")
if domain == "" {
return "", errors.New("ad.domain ist leer")
}
parts := strings.Split(domain, ".")
if len(parts) < 2 {
return "", fmt.Errorf("ad.domain %q sieht nicht wie eine AD-Domäne aus (erwartet z. B. firma.local); "+
"andernfalls ad.base_dn explizit setzen", domain)
}
dcs := make([]string, len(parts))
for i, p := range parts {
if p == "" {
return "", fmt.Errorf("ad.domain %q enthält leere Labels", domain)
}
dcs[i] = "DC=" + p
}
return strings.Join(dcs, ","), nil
}
// EffectiveBaseDN liefert den Override oder die Ableitung aus der Domäne.
func (c *Config) EffectiveBaseDN() string {
if strings.TrimSpace(c.AD.BaseDN) != "" {
return strings.TrimSpace(c.AD.BaseDN)
}
dn, err := BaseDNFromDomain(c.AD.Domain)
if err != nil {
return ""
}
return dn
}
// Validate prüft die Konfiguration semantisch und sammelt alle Fehler.
func (c *Config) Validate() error {
var errs []error
req := func(value, name string) {
if strings.TrimSpace(value) == "" {
errs = append(errs, fmt.Errorf("%s ist erforderlich", name))
}
}
req(c.Portal.Listen, "portal.listen")
req(c.Portal.TLSCert, "portal.tls_cert")
req(c.Portal.TLSKey, "portal.tls_key")
if _, _, err := net.SplitHostPort(c.Portal.Listen); c.Portal.Listen != "" && err != nil {
errs = append(errs, fmt.Errorf("portal.listen %q ist keine gültige Adresse (erwartet HOST:PORT): %w",
c.Portal.Listen, err))
}
req(c.OPNsense.URL, "opnsense.url")
if c.OPNsense.URL != "" {
u, err := url.Parse(c.OPNsense.URL)
switch {
case err != nil:
errs = append(errs, fmt.Errorf("opnsense.url %q ist keine gültige URL: %w", c.OPNsense.URL, err))
case u.Scheme != "https":
errs = append(errs, fmt.Errorf("opnsense.url muss mit https:// beginnen, hat aber Schema %q", u.Scheme))
case u.Host == "":
errs = append(errs, errors.New("opnsense.url enthält keinen Host"))
}
}
req(c.OPNsense.APIKey, "opnsense.api_key (oder api_key_file / "+EnvAPIKey+")")
req(c.OPNsense.APISecret, "opnsense.api_secret (oder api_secret_file / "+EnvAPISecret+")")
req(c.AD.Domain, "ad.domain")
req(c.AD.BindUser, "ad.bind_user")
req(c.AD.BindPassword, "ad.bind_password (oder bind_password_file / "+EnvBindPassword+")")
req(c.AD.VPNGroup, "ad.vpn_group")
if len(c.AD.Servers) == 0 {
errs = append(errs, errors.New("ad.servers muss mindestens einen Domain Controller enthalten"))
}
for _, s := range c.AD.Servers {
s = strings.TrimSpace(s)
if s == "" {
errs = append(errs, errors.New("ad.servers enthält einen leeren Eintrag"))
continue
}
if net.ParseIP(s) != nil {
errs = append(errs, fmt.Errorf(
"ad.servers: %q ist eine IP-Adresse — es sind Hostnamen erforderlich, "+
"weil das LDAPS-Zertifikat gegen den Hostnamen geprüft wird", s))
}
}
if !slices.Contains([]string{"ldaps", "starttls"}, c.AD.TLSMode) {
errs = append(errs, fmt.Errorf("ad.tls_mode %q ist ungültig (erlaubt: ldaps, starttls)", c.AD.TLSMode))
}
if c.AD.Port <= 0 || c.AD.Port > 65535 {
errs = append(errs, fmt.Errorf("ad.port %d liegt außerhalb 165535", c.AD.Port))
}
if c.AD.BaseDN == "" {
if _, err := BaseDNFromDomain(c.AD.Domain); err != nil && c.AD.Domain != "" {
errs = append(errs, err)
}
}
pattern, rx := strings.TrimSpace(c.Matching.CNPattern), strings.TrimSpace(c.Matching.CNRegex)
switch {
case pattern != "" && rx != "":
errs = append(errs, errors.New(
"matching.cn_pattern und matching.cn_regex schließen sich aus — bitte nur eines setzen"))
case pattern == "" && rx == "":
errs = append(errs, errors.New("matching.cn_pattern oder matching.cn_regex muss gesetzt sein"))
case pattern != "" && !strings.Contains(pattern, UsernamePlaceholder):
errs = append(errs, fmt.Errorf("matching.cn_pattern %q enthält keinen %s-Platzhalter",
pattern, UsernamePlaceholder))
case rx != "":
if _, err := regexp.Compile(rx); err != nil {
errs = append(errs, fmt.Errorf("matching.cn_regex ist nicht kompilierbar: %w", err))
}
}
if !slices.Contains([]string{"debug", "info", "warn", "error"}, c.Logging.Level) {
errs = append(errs, fmt.Errorf("logging.level %q ist ungültig (erlaubt: debug, info, warn, error)",
c.Logging.Level))
}
req(c.Logging.AuditLog, "logging.audit_log")
if c.Logging.MaxSizeMB <= 0 {
errs = append(errs, fmt.Errorf("logging.max_size_mb muss positiv sein, ist %d", c.Logging.MaxSizeMB))
}
if c.Logging.MaxBackups < 0 {
errs = append(errs, fmt.Errorf("logging.max_backups darf nicht negativ sein, ist %d", c.Logging.MaxBackups))
}
return errors.Join(errs...)
}
```
- [ ] **Step 4: `Validate` in `Load` aufrufen**
In `load.go`, direkt vor `return cfg, nil`:
```go
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("Konfiguration %s ist ungültig:\n%w", path, err)
}
```
- [ ] **Step 5: Tests laufen lassen**
Run: `go test ./internal/config/ -v`
Expected: PASS. Falls `TestLoadResolvesSecretFiles` o. ä. jetzt fehlschlägt, weil `minimalYAML` unvollständig ist: `minimalYAML` ist bewusst vollständig genug — es muss ohne Änderung validieren.
- [ ] **Step 6: Commit**
```bash
git add internal/config
git commit -m "feat(config): semantische Validierung mit Sammelfehlern und Base-DN-Ableitung"
```
---
## Task 5: Audit-Logger (JSONL, Rotation, SIGHUP, stdout-Tee)
**Files:**
- Create: `internal/audit/event.go`, `internal/audit/logger.go`, `internal/audit/rotate.go`
- Test: `internal/audit/logger_test.go`, `internal/audit/rotate_test.go`
**Interfaces:**
- Consumes: nichts (bewusst dependency-frei, damit jedes Package loggen kann)
- Produces:
- Event-Konstanten: `EventLoginSuccess = "login_success"`, `EventLoginFailed`, `EventLogout`, `EventSessionExpired`, `EventConfigDownload`, `EventDownloadDenied`, `EventNoCertFound`, `EventOPNsenseUnreachable`, `EventLDAPFailover`, `EventRateLimited`, `EventStartup`
- `type Event struct { TS string; Event string; User string; SrcIP string; Session string; Reason string; VPNInstance string; CertCN string; CertExpiry string; Format string; Pattern string; Version string; ConfigSum string; Detail string }` — alle optionalen Felder mit `json:",omitempty"`
- `func New(path string, opts Options) (*Logger, error)`; `type Options struct { MaxSizeMB, MaxBackups int; Compress bool; Stdout io.Writer; Clock func() time.Time }`
- `func (l *Logger) Log(e Event)` — füllt `TS` automatisch, ist goroutine-sicher
- `func (l *Logger) Reopen() error` — für SIGHUP
- `func (l *Logger) Close() error`
- `func ShortSession(token string) string` — erste 4 Hex-Zeichen eines SHA-256 über den Token, **nie** der Token selbst
- `const UnknownUser = "<unknown>"`
- [ ] **Step 1: Failing test für Event-Serialisierung und Logger schreiben**
`internal/audit/logger_test.go`:
```go
package audit
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func newTestLogger(t *testing.T, opts Options) (*Logger, string, *bytes.Buffer) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "audit.log")
var stdout bytes.Buffer
opts.Stdout = &stdout
if opts.MaxSizeMB == 0 {
opts.MaxSizeMB = 50
}
lg, err := New(path, opts)
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(func() { lg.Close() })
return lg, path, &stdout
}
func readLines(t *testing.T, path string) []map[string]any {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var out []map[string]any
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
if line == "" {
continue
}
var m map[string]any
if err := json.Unmarshal([]byte(line), &m); err != nil {
t.Fatalf("Zeile ist kein gültiges JSON: %q: %v", line, err)
}
out = append(out, m)
}
return out
}
func TestLogWritesJSONLWithTimezone(t *testing.T) {
fixed := time.Date(2026, 8, 14, 9, 32, 11, 0, time.FixedZone("CEST", 2*3600))
lg, path, stdout := newTestLogger(t, Options{Clock: func() time.Time { return fixed }})
lg.Log(Event{Event: EventLoginSuccess, User: "mmueller", SrcIP: "10.1.20.34", Session: "a3f9"})
if err := lg.Close(); err != nil {
t.Fatal(err)
}
lines := readLines(t, path)
if len(lines) != 1 {
t.Fatalf("got %d Zeilen, want 1", len(lines))
}
got := lines[0]
if got["ts"] != "2026-08-14T09:32:11+02:00" {
t.Errorf("ts = %v, want RFC3339 mit Zeitzone", got["ts"])
}
if got["event"] != EventLoginSuccess || got["user"] != "mmueller" {
t.Errorf("Feldwerte falsch: %v", got)
}
if _, present := got["reason"]; present {
t.Errorf("leere Felder dürfen nicht serialisiert werden: %v", got)
}
if !strings.Contains(stdout.String(), EventLoginSuccess) {
t.Error("Event muss zusätzlich nach stdout gehen")
}
}
func TestLogIsConcurrencySafe(t *testing.T) {
lg, path, _ := newTestLogger(t, Options{})
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
lg.Log(Event{Event: EventLoginFailed, User: UnknownUser, SrcIP: "10.0.0.1"})
}()
}
wg.Wait()
if err := lg.Close(); err != nil {
t.Fatal(err)
}
if n := len(readLines(t, path)); n != 50 {
t.Fatalf("got %d Zeilen, want 50 (kein Interleaving erlaubt)", n)
}
}
func TestNewCreatesFileWithTightPermissions(t *testing.T) {
_, path, _ := newTestLogger(t, Options{})
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if perm := info.Mode().Perm(); perm&0o077 != 0 {
t.Errorf("Audit-Log hat Rechte %#o — Gruppe/Welt dürfen nicht zugreifen", perm)
}
}
func TestShortSessionNeverLeaksToken(t *testing.T) {
token := "0123456789abcdef0123456789abcdef"
short := ShortSession(token)
if len(short) != 4 {
t.Errorf("ShortSession = %q, want 4 Zeichen", short)
}
if strings.Contains(token, short) {
t.Errorf("gekürzte ID %q ist ein Präfix des Tokens — muss gehasht sein", short)
}
if ShortSession(token) != short {
t.Error("ShortSession muss deterministisch sein")
}
if ShortSession("") != "" {
t.Error("leerer Token muss leere ID liefern")
}
}
```
- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./internal/audit/ -v`
Expected: FAIL — `undefined: New`
- [ ] **Step 3: `event.go` schreiben**
```go
// Package audit schreibt strukturierte Audit-Ereignisse als JSON Lines.
package audit
import (
"crypto/sha256"
"encoding/hex"
)
// Audit-Ereignisse.
const (
EventLoginSuccess = "login_success"
EventLoginFailed = "login_failed"
EventLogout = "logout"
EventSessionExpired = "session_expired"
EventConfigDownload = "config_download"
EventDownloadDenied = "download_denied"
)
// Betriebsereignisse.
const (
EventNoCertFound = "no_cert_found"
EventOPNsenseUnreachable = "opnsense_unreachable"
EventLDAPFailover = "ldap_failover"
EventRateLimited = "rate_limited"
EventStartup = "startup"
)
// UnknownUser ersetzt den eingegebenen Namen, wenn der Benutzer im AD nicht
// gefunden wurde. Damit landet ein versehentlich ins Username-Feld getipptes
// Passwort nie im Klartext im Log.
const UnknownUser = "<unknown>"
// Event ist eine Zeile im Audit-Log. Leere Felder werden nicht serialisiert.
type Event struct {
TS string `json:"ts"`
Event string `json:"event"`
User string `json:"user,omitempty"`
SrcIP string `json:"src_ip,omitempty"`
Session string `json:"session,omitempty"`
Reason string `json:"reason,omitempty"`
VPNInstance string `json:"vpn_instance,omitempty"`
CertCN string `json:"cert_cn,omitempty"`
CertExpiry string `json:"cert_expiry,omitempty"`
Format string `json:"format,omitempty"`
Pattern string `json:"pattern,omitempty"`
Server string `json:"server,omitempty"`
Version string `json:"version,omitempty"`
ConfigSum string `json:"config_sum,omitempty"`
Detail string `json:"detail,omitempty"`
}
// ShortSession liefert eine kurze, nicht umkehrbare Korrelations-ID.
// Der Session-Token selbst darf niemals ins Log gelangen.
func ShortSession(token string) string {
if token == "" {
return ""
}
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])[:4]
}
```
- [ ] **Step 4: `logger.go` schreiben**
```go
package audit
import (
"encoding/json"
"fmt"
"io"
"sync"
"time"
)
// Options steuert Rotation und Nebenausgabe.
type Options struct {
MaxSizeMB int
MaxBackups int
Compress bool
// Stdout erhält eine Kopie jeder Zeile (systemd-Journal). Nil = keine Kopie.
Stdout io.Writer
// Clock ist injizierbar für Tests. Nil = time.Now.
Clock func() time.Time
}
// Logger schreibt Events zeilenweise als JSON.
type Logger struct {
mu sync.Mutex
file *rotatingFile
stdout io.Writer
clock func() time.Time
}
// New öffnet das Audit-Log (Verzeichnis muss existieren) und liefert einen Logger.
func New(path string, opts Options) (*Logger, error) {
if opts.Clock == nil {
opts.Clock = time.Now
}
rf, err := openRotating(path, opts)
if err != nil {
return nil, err
}
return &Logger{file: rf, stdout: opts.Stdout, clock: opts.Clock}, nil
}
// Log schreibt ein Ereignis. Fehler beim Schreiben werden nach stdout gemeldet,
// dürfen den Request aber nie scheitern lassen.
func (l *Logger) Log(e Event) {
if e.TS == "" {
e.TS = l.clock().Format(time.RFC3339)
}
line, err := json.Marshal(e)
if err != nil {
return // Event enthält nur Strings; kann praktisch nicht passieren
}
line = append(line, '\n')
l.mu.Lock()
defer l.mu.Unlock()
if _, err := l.file.Write(line); err != nil && l.stdout != nil {
fmt.Fprintf(l.stdout, "{\"ts\":%q,\"event\":\"audit_write_failed\",\"detail\":%q}\n",
e.TS, err.Error())
}
if l.stdout != nil {
l.stdout.Write(line)
}
}
// Reopen schließt die aktuelle Datei und öffnet sie neu (SIGHUP/logrotate).
func (l *Logger) Reopen() error {
l.mu.Lock()
defer l.mu.Unlock()
return l.file.reopen()
}
func (l *Logger) Close() error {
l.mu.Lock()
defer l.mu.Unlock()
return l.file.Close()
}
```
- [ ] **Step 5: Failing test für Rotation schreiben**
`internal/audit/rotate_test.go`:
```go
package audit
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRotationCreatesBackupAndTruncates(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.log")
rf, err := openRotating(path, Options{MaxSizeMB: 1, MaxBackups: 2})
if err != nil {
t.Fatal(err)
}
defer rf.Close()
rf.maxBytes = 200 // Rotationsschwelle für den Test verkleinern
line := []byte(strings.Repeat("x", 60) + "\n")
for i := 0; i < 10; i++ {
if _, err := rf.Write(line); err != nil {
t.Fatal(err)
}
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
var backups int
for _, e := range entries {
if e.Name() != "audit.log" && strings.HasPrefix(e.Name(), "audit.log.") {
backups++
}
}
if backups == 0 {
t.Fatal("es muss mindestens eine rotierte Datei geben")
}
if backups > 2 {
t.Fatalf("MaxBackups=2 überschritten: %d Backups", backups)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Size() > int64(rf.maxBytes) {
t.Fatalf("aktive Datei ist %d Bytes groß, Schwelle ist %d", info.Size(), rf.maxBytes)
}
}
func TestRotationCompresses(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.log")
rf, err := openRotating(path, Options{MaxSizeMB: 1, MaxBackups: 3, Compress: true})
if err != nil {
t.Fatal(err)
}
defer rf.Close()
rf.maxBytes = 100
for i := 0; i < 6; i++ {
if _, err := rf.Write([]byte(strings.Repeat("y", 60) + "\n")); err != nil {
t.Fatal(err)
}
}
entries, _ := os.ReadDir(dir)
var gz int
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".gz") {
gz++
}
}
if gz == 0 {
t.Fatal("bei Compress=true müssen rotierte Dateien .gz sein")
}
}
func TestReopenRecreatesDeletedFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.log")
rf, err := openRotating(path, Options{MaxSizeMB: 1})
if err != nil {
t.Fatal(err)
}
defer rf.Close()
if _, err := rf.Write([]byte("erste\n")); err != nil {
t.Fatal(err)
}
if err := os.Rename(path, path+".moved"); err != nil {
t.Fatal(err)
}
if err := rf.reopen(); err != nil {
t.Fatal(err)
}
if _, err := rf.Write([]byte("zweite\n")); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("nach reopen muss die Datei wieder existieren: %v", err)
}
if string(raw) != "zweite\n" {
t.Errorf("neue Datei enthält %q, want \"zweite\\n\"", raw)
}
}
```
- [ ] **Step 6: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./internal/audit/ -run TestRotation -v`
Expected: FAIL — `undefined: openRotating`
- [ ] **Step 7: `rotate.go` schreiben**
```go
package audit
import (
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// logFileMode: nur der Eigentümer darf lesen und schreiben — das Log enthält
// personenbezogene Daten.
const logFileMode os.FileMode = 0o600
// rotatingFile ist ein größenrotierender Writer ohne externe Dependency.
// Nicht selbst gesperrt — der Aufrufer (Logger) hält das Mutex.
type rotatingFile struct {
path string
maxBytes int64
maxBackups int
compress bool
f *os.File
size int64
}
func openRotating(path string, opts Options) (*rotatingFile, error) {
if opts.MaxSizeMB <= 0 {
opts.MaxSizeMB = 50
}
rf := &rotatingFile{
path: path,
maxBytes: int64(opts.MaxSizeMB) * 1024 * 1024,
maxBackups: opts.MaxBackups,
compress: opts.Compress,
}
if err := rf.open(); err != nil {
return nil, err
}
return rf, nil
}
func (r *rotatingFile) open() error {
if err := os.MkdirAll(filepath.Dir(r.path), 0o750); err != nil {
return fmt.Errorf("Log-Verzeichnis %s: %w", filepath.Dir(r.path), err)
}
f, err := os.OpenFile(r.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, logFileMode)
if err != nil {
return fmt.Errorf("Audit-Log %s: %w", r.path, err)
}
info, err := f.Stat()
if err != nil {
f.Close()
return fmt.Errorf("Audit-Log %s: %w", r.path, err)
}
r.f, r.size = f, info.Size()
return nil
}
func (r *rotatingFile) Write(p []byte) (int, error) {
if r.size+int64(len(p)) > r.maxBytes && r.size > 0 {
if err := r.rotate(); err != nil {
return 0, err
}
}
n, err := r.f.Write(p)
r.size += int64(n)
return n, err
}
// reopen schließt und öffnet die Datei neu (SIGHUP nach externem logrotate).
func (r *rotatingFile) reopen() error {
if r.f != nil {
r.f.Close()
}
return r.open()
}
func (r *rotatingFile) Close() error {
if r.f == nil {
return nil
}
err := r.f.Close()
r.f = nil
return err
}
func (r *rotatingFile) rotate() error {
if err := r.f.Close(); err != nil {
return err
}
backup := fmt.Sprintf("%s.%s", r.path, time.Now().UTC().Format("20060102T150405.000"))
if err := os.Rename(r.path, backup); err != nil {
return fmt.Errorf("Rotation von %s: %w", r.path, err)
}
if r.compress {
if gzPath, err := gzipFile(backup); err == nil {
os.Remove(backup)
backup = gzPath
}
}
if err := r.open(); err != nil {
return err
}
r.pruneBackups()
return nil
}
func gzipFile(path string) (string, error) {
in, err := os.Open(path)
if err != nil {
return "", err
}
defer in.Close()
out, err := os.OpenFile(path+".gz", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, logFileMode)
if err != nil {
return "", err
}
defer out.Close()
zw := gzip.NewWriter(out)
if _, err := io.Copy(zw, in); err != nil {
return "", err
}
if err := zw.Close(); err != nil {
return "", err
}
return path + ".gz", nil
}
// pruneBackups löscht die ältesten rotierten Dateien über maxBackups hinaus.
func (r *rotatingFile) pruneBackups() {
if r.maxBackups <= 0 {
return
}
dir, base := filepath.Split(r.path)
if dir == "" {
dir = "."
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
var backups []string
for _, e := range entries {
name := e.Name()
if name != base && strings.HasPrefix(name, base+".") {
backups = append(backups, name)
}
}
// Die Zeitstempel im Namen sind lexikografisch sortierbar.
sort.Strings(backups)
for len(backups) > r.maxBackups {
os.Remove(filepath.Join(dir, backups[0]))
backups = backups[1:]
}
}
```
- [ ] **Step 8: Tests laufen lassen**
Run: `go test ./internal/audit/ -v`
Expected: PASS (alle Tests)
- [ ] **Step 9: Commit**
```bash
git add internal/audit
git commit -m "feat(audit): JSONL-Logger mit Größenrotation, gzip und SIGHUP-Reopen"
```
---
## Task 6: Auth-Interface, Identity und AD-Fehlercode-Mapping
**Files:**
- Create: `internal/auth/auth.go`, `internal/auth/addata.go`
- Test: `internal/auth/addata_test.go`
**Interfaces:**
- Consumes: `audit`-Reason-Semantik (nur als String-Werte, kein Import)
- Produces:
- `type Identity struct { Username string; Groups []string }`
- `type Authenticator interface { Authenticate(ctx context.Context, username, password string) (*Identity, error) }`
- `type Error struct { Reason string; Err error }` mit `Error() string`, `Unwrap() error`
- Reason-Konstanten: `ReasonInvalidCredentials = "invalid_credentials"`, `ReasonAccountDisabled`, `ReasonAccountLocked`, `ReasonPasswordExpired`, `ReasonPasswordChangeRequired`, `ReasonNotInVPNGroup`, `ReasonUserNotFound`, `ReasonBackendUnavailable`
- `func ReasonFromLDAPError(err error) string` — parst `data 52e` etc. aus der LDAP-Fehlermeldung
- `func (e *Error) UserVisible() bool` — nur `ReasonPasswordExpired` liefert `true` (einzige nicht-generische Meldung)
- [ ] **Step 1: Failing test schreiben**
`internal/auth/addata_test.go`:
```go
package auth
import (
"errors"
"testing"
)
func TestReasonFromLDAPError(t *testing.T) {
// Formulierung wie sie AD in LDAP-Result-Code 49 liefert.
const tmpl = "LDAP Result Code 49 \"Invalid Credentials\": 80090308: LdapErr: " +
"DSID-0C0903A9, comment: AcceptSecurityContext error, data %s, v4563"
cases := map[string]string{
"52e": ReasonInvalidCredentials,
"533": ReasonAccountDisabled,
"775": ReasonAccountLocked,
"532": ReasonPasswordExpired,
"773": ReasonPasswordChangeRequired,
"525": ReasonInvalidCredentials, // user not found -> generisch, keine Enumeration
"701": ReasonAccountDisabled, // account expired
"999": ReasonInvalidCredentials, // unbekannter Code -> generisch
}
for code, want := range cases {
err := errors.New(sprintf(tmpl, code))
if got := ReasonFromLDAPError(err); got != want {
t.Errorf("data %s: got %q, want %q", code, got, want)
}
}
}
func TestReasonFromLDAPErrorIsCaseInsensitive(t *testing.T) {
err := errors.New("AcceptSecurityContext error, DATA 52E, v4563")
if got := ReasonFromLDAPError(err); got != ReasonInvalidCredentials {
t.Errorf("got %q, want %q", got, ReasonInvalidCredentials)
}
}
func TestReasonFromLDAPErrorWithoutDataCode(t *testing.T) {
if got := ReasonFromLDAPError(errors.New("connection refused")); got != ReasonBackendUnavailable {
t.Errorf("got %q, want %q", got, ReasonBackendUnavailable)
}
if got := ReasonFromLDAPError(nil); got != "" {
t.Errorf("nil-Fehler muss leeren Reason liefern, got %q", got)
}
}
func TestErrorUserVisibleOnlyForExpiredPassword(t *testing.T) {
if !(&Error{Reason: ReasonPasswordExpired}).UserVisible() {
t.Error("abgelaufenes Passwort ist die einzige spezifische Meldung")
}
for _, r := range []string{
ReasonInvalidCredentials, ReasonAccountDisabled, ReasonAccountLocked,
ReasonNotInVPNGroup, ReasonPasswordChangeRequired, ReasonBackendUnavailable,
} {
if (&Error{Reason: r}).UserVisible() {
t.Errorf("Reason %q darf keine spezifische Meldung erzeugen", r)
}
}
}
func TestErrorUnwraps(t *testing.T) {
inner := errors.New("boom")
err := &Error{Reason: ReasonBackendUnavailable, Err: inner}
if !errors.Is(err, inner) {
t.Error("Error muss den inneren Fehler durchreichen")
}
var authErr *Error
if !errors.As(error(err), &authErr) {
t.Error("errors.As muss *Error finden")
}
}
```
Hilfsfunktion am Ende derselben Datei (vermeidet einen `fmt`-Import-Konflikt in Tests):
```go
func sprintf(format string, a ...any) string { return fmt.Sprintf(format, a...) }
```
und `"fmt"` zum Import-Block der Testdatei hinzufügen.
- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./internal/auth/ -v`
Expected: FAIL — `undefined: ReasonFromLDAPError`
- [ ] **Step 3: `auth.go` schreiben**
```go
// Package auth kapselt die Benutzerauthentifizierung hinter einem schmalen
// Interface, damit später weitere Backends (OIDC/Entra ID) ergänzt werden können.
package auth
import (
"context"
"fmt"
)
// Identity ist die kanonische Identität eines authentifizierten Benutzers.
type Identity struct {
// Username ist der aus dem Verzeichnis gelesene sAMAccountName in
// Kleinschreibung — niemals die Benutzereingabe.
Username string
// Groups enthält die Gruppen, die für Rollenentscheidungen relevant sind.
// v1 füllt hier nur die VPN-Gruppe; das Feld hält den Weg zu einer
// späteren VPN-Portal-Admins-Gruppe offen.
Groups []string
}
// HasGroup prüft Gruppenzugehörigkeit ohne Rücksicht auf Groß-/Kleinschreibung.
func (i *Identity) HasGroup(name string) bool {
for _, g := range i.Groups {
if strings.EqualFold(g, name) {
return true
}
}
return false
}
// Authenticator prüft Zugangsdaten und liefert eine kanonische Identität.
type Authenticator interface {
Authenticate(ctx context.Context, username, password string) (*Identity, error)
}
// Reason-Codes landen unverändert im Audit-Log.
const (
ReasonInvalidCredentials = "invalid_credentials"
ReasonAccountDisabled = "account_disabled"
ReasonAccountLocked = "account_locked"
ReasonPasswordExpired = "password_expired"
ReasonPasswordChangeRequired = "password_change_required"
ReasonNotInVPNGroup = "not_in_vpn_group"
ReasonUserNotFound = "user_not_found"
ReasonBackendUnavailable = "backend_unavailable"
)
// Error trägt den Audit-Reason und den technischen Ursprungsfehler.
type Error struct {
Reason string
Err error
}
func (e *Error) Error() string {
if e.Err == nil {
return e.Reason
}
return fmt.Sprintf("%s: %v", e.Reason, e.Err)
}
func (e *Error) Unwrap() error { return e.Err }
// UserVisible meldet, ob dem Benutzer eine spezifische statt der generischen
// Fehlermeldung gezeigt werden darf. Nur das abgelaufene Passwort ist eine
// Ausnahme — alles andere wäre ein Enumerationsorakel.
func (e *Error) UserVisible() bool { return e.Reason == ReasonPasswordExpired }
```
`"strings"` gehört in den Import-Block.
- [ ] **Step 4: `addata.go` schreiben**
```go
package auth
import (
"regexp"
"strings"
)
// dataCodeRe findet den AD-spezifischen data-Code im Text eines
// LDAP-Result-Code-49-Fehlers, z. B. "... error, data 52e, v4563".
var dataCodeRe = regexp.MustCompile(`(?i)\bdata\s+([0-9a-f]{3,4})\b`)
// dataCodeReasons bildet AD-data-Codes auf Audit-Reasons ab.
// 525 (kein solcher User) wird bewusst wie ein falsches Passwort behandelt,
// damit die Fehlermeldung keine Benutzerexistenz verrät.
var dataCodeReasons = map[string]string{
"525": ReasonInvalidCredentials,
"52e": ReasonInvalidCredentials,
"530": ReasonInvalidCredentials, // Anmeldung außerhalb erlaubter Zeiten
"531": ReasonInvalidCredentials, // Anmeldung an dieser Station nicht erlaubt
"532": ReasonPasswordExpired,
"533": ReasonAccountDisabled,
"701": ReasonAccountDisabled, // Konto abgelaufen
"773": ReasonPasswordChangeRequired,
"775": ReasonAccountLocked,
}
// ReasonFromLDAPError leitet den Audit-Reason aus einem LDAP-Bind-Fehler ab.
// Fehler ohne erkennbaren data-Code gelten als Backend-Problem, nicht als
// falsches Passwort — sonst würde ein DC-Ausfall als Fehlanmeldung gezählt.
func ReasonFromLDAPError(err error) string {
if err == nil {
return ""
}
msg := err.Error()
if m := dataCodeRe.FindStringSubmatch(msg); m != nil {
if reason, ok := dataCodeReasons[strings.ToLower(m[1])]; ok {
return reason
}
// data-Code vorhanden, aber unbekannt: es war eine echte Ablehnung.
return ReasonInvalidCredentials
}
if strings.Contains(msg, "Result Code 49") || strings.Contains(msg, "Invalid Credentials") {
return ReasonInvalidCredentials
}
return ReasonBackendUnavailable
}
```
- [ ] **Step 5: Tests laufen lassen**
Run: `go test ./internal/auth/ -v`
Expected: PASS (5 Tests)
- [ ] **Step 6: Commit**
```bash
git add internal/auth
git commit -m "feat(auth): Authenticator-Interface, Identity und AD-Fehlercode-Mapping"
```
---
## Task 7: AD-Authenticator mit Zwei-Schritt-Bind, Failover und Gruppenprüfung
**Files:**
- Create: `internal/auth/ad.go`, `internal/auth/dial.go`
- Test: `internal/auth/ad_test.go`
- Modify: `go.mod` (Dependency `github.com/go-ldap/ldap/v3`)
**Interfaces:**
- Consumes: `Identity`, `Error`, Reason-Konstanten, `ReasonFromLDAPError` (Task 6)
- Produces:
- `type conn interface { Bind(dn, pw string) error; Search(*ldap.SearchRequest) (*ldap.SearchResult, error); Close() error }` — package-privat, macht den Authenticator ohne LDAP-Server testbar
- `type Options struct { Servers []string; Port int; TLSMode string; BaseDN, BindUser, BindPassword, VPNGroup, CAFile string; Timeout time.Duration; Dial func(ctx context.Context, server string) (conn, error); OnFailover func(server string, err error) }`
- `type AD struct { ... }`; `func NewAD(opts Options) (*AD, error)`
- `func (a *AD) Authenticate(ctx context.Context, username, password string) (*Identity, error)` — erfüllt `Authenticator`
- `func (a *AD) ResolveGroupDN(ctx context.Context) (string, error)` — beim Start aufgerufen, Ergebnis gecacht
- `func (a *AD) Lookup(ctx context.Context, username string) (*LookupResult, error)`; `type LookupResult struct { DN, SAMAccountName string; InVPNGroup bool }` — für `check --test-auth`
- `func (a *AD) GroupDN() string`
- Konstante `MatchingRuleInChain = "1.2.840.113556.1.4.1941"`
- [ ] **Step 1: Dependency hinzufügen**
```bash
go get github.com/go-ldap/ldap/v3@latest
```
- [ ] **Step 2: Failing test schreiben**
`internal/auth/ad_test.go`:
```go
package auth
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/go-ldap/ldap/v3"
)
// fakeConn ist ein LDAP-Server-Ersatz. bindErr bildet Bind-Ergebnisse pro
// DN ab; searches protokolliert alle Filter für Injection-Assertions.
type fakeConn struct {
server string
bindErr map[string]error
entries map[string][]*ldap.Entry // Filter-Substring -> Ergebnis
searches []string
closed bool
dialErr error
}
func (f *fakeConn) Bind(dn, pw string) error {
if err, ok := f.bindErr[dn+"|"+pw]; ok {
return err
}
if err, ok := f.bindErr[dn]; ok {
return err
}
return nil
}
func (f *fakeConn) Search(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
f.searches = append(f.searches, req.Filter)
for needle, entries := range f.entries {
if strings.Contains(req.Filter, needle) {
return &ldap.SearchResult{Entries: entries}, nil
}
}
return &ldap.SearchResult{}, nil
}
func (f *fakeConn) Close() error { f.closed = true; return nil }
func entry(dn string, attrs map[string][]string) *ldap.Entry {
e := &ldap.Entry{DN: dn}
for name, vals := range attrs {
e.Attributes = append(e.Attributes, &ldap.EntryAttribute{Name: name, Values: vals})
}
return e
}
const (
userDN = "CN=Max Mueller,OU=Users,DC=firma,DC=local"
groupDN = "CN=VPN-Users,OU=Groups,DC=firma,DC=local"
)
// stdEntries liefert die Standardantworten: Gruppen-Auflösung, User-Suche,
// Mitgliedschaftsprüfung.
func stdEntries(inGroup bool) map[string][]*ldap.Entry {
m := map[string][]*ldap.Entry{
"objectClass=group": {entry(groupDN, nil)},
"sAMAccountName=mmueller)(userPrincipalName": {
entry(userDN, map[string][]string{"sAMAccountName": {"MMueller"}}),
},
}
if inGroup {
m["1.2.840.113556.1.4.1941"] = []*ldap.Entry{entry(userDN, nil)}
}
return m
}
func newAD(t *testing.T, conns ...*fakeConn) (*AD, []*fakeConn) {
t.Helper()
i := 0
opts := Options{
Servers: []string{"dc01.firma.local", "dc02.firma.local"},
Port: 636,
TLSMode: "ldaps",
BaseDN: "DC=firma,DC=local",
BindUser: "svc@firma.local",
BindPassword: "svc-pw",
VPNGroup: "VPN-Users",
Timeout: time.Second,
Dial: func(ctx context.Context, server string) (conn, error) {
c := conns[min(i, len(conns)-1)]
i++
if c.dialErr != nil {
return nil, c.dialErr
}
return c, nil
},
}
a, err := NewAD(opts)
if err != nil {
t.Fatalf("NewAD: %v", err)
}
return a, conns
}
func TestAuthenticateSuccessCanonicalisesUsername(t *testing.T) {
c := &fakeConn{server: "dc01", entries: stdEntries(true)}
a, _ := newAD(t, c)
id, err := a.Authenticate(context.Background(), " MMueller ", "geheim")
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
// Kanonisch = sAMAccountName aus dem Verzeichnis, lowercase.
if id.Username != "mmueller" {
t.Errorf("Username = %q, want mmueller", id.Username)
}
if !id.HasGroup("VPN-Users") {
t.Errorf("Groups = %v, muss die VPN-Gruppe enthalten", id.Groups)
}
}
func TestAuthenticateRejectsNonMember(t *testing.T) {
c := &fakeConn{entries: stdEntries(false)}
a, _ := newAD(t, c)
_, err := a.Authenticate(context.Background(), "mmueller", "geheim")
var ae *Error
if !errors.As(err, &ae) || ae.Reason != ReasonNotInVPNGroup {
t.Fatalf("err = %v, want Reason %q", err, ReasonNotInVPNGroup)
}
}
func TestAuthenticateMapsBindFailure(t *testing.T) {
c := &fakeConn{entries: stdEntries(true)}
c.bindErr = map[string]error{
userDN + "|falsch": errors.New(
"LDAP Result Code 49 \"Invalid Credentials\": AcceptSecurityContext error, data 532, v4563"),
}
a, _ := newAD(t, c)
_, err := a.Authenticate(context.Background(), "mmueller", "falsch")
var ae *Error
if !errors.As(err, &ae) || ae.Reason != ReasonPasswordExpired {
t.Fatalf("err = %v, want Reason %q", err, ReasonPasswordExpired)
}
}
func TestAuthenticateUnknownUserReportsGenericReason(t *testing.T) {
c := &fakeConn{entries: map[string][]*ldap.Entry{"objectClass=group": {entry(groupDN, nil)}}}
a, _ := newAD(t, c)
_, err := a.Authenticate(context.Background(), "gibtsnicht", "egal")
var ae *Error
if !errors.As(err, &ae) || ae.Reason != ReasonUserNotFound {
t.Fatalf("err = %v, want Reason %q", err, ReasonUserNotFound)
}
if ae.UserVisible() {
t.Error("unbekannter Benutzer darf keine spezifische Meldung erzeugen")
}
}
func TestAuthenticateRejectsEmptyPassword(t *testing.T) {
c := &fakeConn{entries: stdEntries(true)}
a, _ := newAD(t, c)
// Leeres Passwort wäre ein anonymer Bind und würde fälschlich gelingen.
_, err := a.Authenticate(context.Background(), "mmueller", "")
var ae *Error
if !errors.As(err, &ae) || ae.Reason != ReasonInvalidCredentials {
t.Fatalf("err = %v, want Reason %q", err, ReasonInvalidCredentials)
}
for _, f := range c.searches {
if strings.Contains(f, "mmueller") {
t.Error("bei leerem Passwort darf gar keine Suche stattfinden")
}
}
}
func TestAuthenticateEscapesFilterInput(t *testing.T) {
c := &fakeConn{entries: stdEntries(true)}
a, _ := newAD(t, c)
_, _ = a.Authenticate(context.Background(), "evil)(objectClass=*", "pw")
for _, f := range c.searches {
if strings.Contains(f, "evil)(objectClass=*") {
t.Fatalf("unescapte Benutzereingabe im Filter: %q", f)
}
}
var found bool
for _, f := range c.searches {
if strings.Contains(f, `\29\28`) { // ")(" escaped
found = true
}
}
if !found {
t.Errorf("Eingabe wurde nicht escapt; Filter: %v", c.searches)
}
}
func TestFailoverToSecondDC(t *testing.T) {
dead := &fakeConn{dialErr: errors.New("connection refused")}
alive := &fakeConn{entries: stdEntries(true)}
var failedOver []string
i := 0
conns := []*fakeConn{dead, alive}
a, err := NewAD(Options{
Servers: []string{"dc01.firma.local", "dc02.firma.local"},
Port: 636,
TLSMode: "ldaps",
BaseDN: "DC=firma,DC=local",
BindUser: "svc@firma.local",
BindPassword: "svc-pw",
VPNGroup: "VPN-Users",
Timeout: time.Second,
OnFailover: func(server string, err error) { failedOver = append(failedOver, server) },
Dial: func(ctx context.Context, server string) (conn, error) {
c := conns[min(i, len(conns)-1)]
i++
if c.dialErr != nil {
return nil, c.dialErr
}
return c, nil
},
})
if err != nil {
t.Fatal(err)
}
if _, err := a.Authenticate(context.Background(), "mmueller", "geheim"); err != nil {
t.Fatalf("Failover muss gelingen: %v", err)
}
if len(failedOver) == 0 || failedOver[0] != "dc01.firma.local" {
t.Errorf("OnFailover = %v, want dc01.firma.local", failedOver)
}
}
func TestAllDCsDownIsBackendUnavailable(t *testing.T) {
dead := &fakeConn{dialErr: errors.New("connection refused")}
a, _ := newAD(t, dead)
_, err := a.Authenticate(context.Background(), "mmueller", "geheim")
var ae *Error
if !errors.As(err, &ae) || ae.Reason != ReasonBackendUnavailable {
t.Fatalf("err = %v, want Reason %q", err, ReasonBackendUnavailable)
}
}
func TestLookupForTestAuth(t *testing.T) {
c := &fakeConn{entries: stdEntries(true)}
a, _ := newAD(t, c)
res, err := a.Lookup(context.Background(), "MMueller")
if err != nil {
t.Fatalf("Lookup: %v", err)
}
if res.DN != userDN || res.SAMAccountName != "mmueller" || !res.InVPNGroup {
t.Errorf("Lookup = %+v", res)
}
}
func TestConnectionsAreAlwaysClosed(t *testing.T) {
c := &fakeConn{entries: stdEntries(true)}
a, _ := newAD(t, c)
if _, err := a.Authenticate(context.Background(), "mmueller", "geheim"); err != nil {
t.Fatal(err)
}
if !c.closed {
t.Error("Verbindung muss geschlossen werden")
}
}
```
- [ ] **Step 3: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./internal/auth/ -run 'TestAuthenticate|TestFailover|TestLookup|TestAll|TestConnections' -v`
Expected: FAIL — `undefined: NewAD`
- [ ] **Step 4: `dial.go` schreiben**
```go
package auth
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"os"
"strconv"
"time"
"github.com/go-ldap/ldap/v3"
)
// conn ist der Ausschnitt der LDAP-Verbindung, den der Authenticator braucht.
// Das Interface existiert, damit Tests ohne echten Verzeichnisdienst laufen.
type conn interface {
Bind(username, password string) error
Search(req *ldap.SearchRequest) (*ldap.SearchResult, error)
Close() error
}
// ldapConn adaptiert *ldap.Conn an conn (Close hat dort keine Fehlerrückgabe).
type ldapConn struct{ *ldap.Conn }
func (c ldapConn) Close() error { c.Conn.Close(); return nil }
// tlsConfigFor baut die TLS-Konfiguration; Verifikation ist immer aktiv.
func tlsConfigFor(server, caFile string) (*tls.Config, error) {
cfg := &tls.Config{ServerName: server, MinVersion: tls.VersionTLS12}
if caFile == "" {
return cfg, nil
}
pem, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("ad.ca_file %s: %w", caFile, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("ad.ca_file %s enthält kein gültiges PEM-Zertifikat", caFile)
}
cfg.RootCAs = pool
return cfg, nil
}
// realDialer erzeugt die Dial-Funktion für den Produktivbetrieb.
func realDialer(port int, tlsMode, caFile string, timeout time.Duration) func(context.Context, string) (conn, error) {
return func(ctx context.Context, server string) (conn, error) {
tlsCfg, err := tlsConfigFor(server, caFile)
if err != nil {
return nil, err
}
addr := net.JoinHostPort(server, strconv.Itoa(port))
dialer := &net.Dialer{Timeout: timeout}
var c *ldap.Conn
if tlsMode == "starttls" {
c, err = ldap.DialURL("ldap://"+addr, ldap.DialWithDialer(dialer))
if err != nil {
return nil, fmt.Errorf("Verbindung zu %s: %w", addr, err)
}
if err := c.StartTLS(tlsCfg); err != nil {
c.Close()
return nil, fmt.Errorf("StartTLS zu %s: %w", addr, err)
}
} else {
c, err = ldap.DialURL("ldaps://"+addr,
ldap.DialWithDialer(dialer), ldap.DialWithTLSConfig(tlsCfg))
if err != nil {
return nil, fmt.Errorf("LDAPS-Verbindung zu %s: %w", addr, err)
}
}
c.SetTimeout(timeout)
return ldapConn{c}, nil
}
}
```
- [ ] **Step 5: `ad.go` schreiben**
```go
package auth
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/go-ldap/ldap/v3"
)
// MatchingRuleInChain ist die AD-Erweiterung LDAP_MATCHING_RULE_IN_CHAIN.
// Sie löst verschachtelte Gruppenmitgliedschaften in einer einzigen Abfrage auf.
const MatchingRuleInChain = "1.2.840.113556.1.4.1941"
const defaultTimeout = 8 * time.Second
// Options konfiguriert den AD-Authenticator.
type Options struct {
Servers []string
Port int
TLSMode string // "ldaps" oder "starttls"
BaseDN string
BindUser string
BindPassword string
VPNGroup string
CAFile string
Timeout time.Duration
// Dial ist injizierbar; nil = echte LDAP-Verbindung.
Dial func(ctx context.Context, server string) (conn, error)
// OnFailover wird gerufen, wenn ein DC übersprungen wird.
OnFailover func(server string, err error)
}
// AD authentifiziert gegen Active Directory.
type AD struct {
opts Options
mu sync.RWMutex
groupDN string
}
// NewAD prüft die Optionen und liefert den Authenticator.
// Der Gruppen-DN wird erst durch ResolveGroupDN aufgelöst und danach gecacht.
func NewAD(opts Options) (*AD, error) {
if len(opts.Servers) == 0 {
return nil, errors.New("ad: keine Domain Controller konfiguriert")
}
if opts.BindUser == "" || opts.BindPassword == "" {
return nil, errors.New("ad: Service-Account (bind_user/bind_password) fehlt")
}
if opts.BaseDN == "" {
return nil, errors.New("ad: Base-DN ist leer")
}
if opts.VPNGroup == "" {
return nil, errors.New("ad: vpn_group fehlt")
}
if opts.Timeout <= 0 {
opts.Timeout = defaultTimeout
}
if opts.Port == 0 {
opts.Port = 636
}
if opts.Dial == nil {
opts.Dial = realDialer(opts.Port, opts.TLSMode, opts.CAFile, opts.Timeout)
}
return &AD{opts: opts}, nil
}
// GroupDN liefert den zwischengespeicherten Gruppen-DN.
func (a *AD) GroupDN() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.groupDN
}
// withConn verbindet sich zum ersten erreichbaren DC, bindet den
// Service-Account und ruft fn. Der Fehler des letzten DC wird durchgereicht.
func (a *AD) withConn(ctx context.Context, fn func(c conn) error) error {
var lastErr error
for _, server := range a.opts.Servers {
c, err := a.opts.Dial(ctx, server)
if err == nil {
err = c.Bind(a.opts.BindUser, a.opts.BindPassword)
if err != nil {
c.Close()
// Falsches Service-Passwort ist ein Konfigurationsfehler und
// wird nicht durch Failover kaschiert.
return fmt.Errorf("Service-Bind an %s fehlgeschlagen: %w", server, err)
}
defer c.Close()
return fn(c)
}
lastErr = fmt.Errorf("%s: %w", server, err)
if a.opts.OnFailover != nil {
a.opts.OnFailover(server, err)
}
}
return fmt.Errorf("kein Domain Controller erreichbar: %w", lastErr)
}
// searchOne führt eine Suche aus und liefert höchstens einen Eintrag.
func searchOne(c conn, baseDN, filter string, attrs []string, timeout time.Duration) (*ldap.Entry, error) {
req := ldap.NewSearchRequest(
baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
2, int(timeout.Seconds()), false, filter, attrs, nil)
res, err := c.Search(req)
if err != nil {
// Referrals in Single-Domain-Umgebungen ignorieren.
if ldap.IsErrorWithCode(err, ldap.LDAPResultReferral) {
return nil, nil
}
return nil, err
}
if len(res.Entries) == 0 {
return nil, nil
}
return res.Entries[0], nil
}
// resolveGroupDN sucht den DN der VPN-Gruppe über ihren sAMAccountName oder CN.
func (a *AD) resolveGroupDN(c conn) (string, error) {
name := ldap.EscapeFilter(a.opts.VPNGroup)
filter := fmt.Sprintf("(&(objectClass=group)(|(sAMAccountName=%s)(cn=%s)))", name, name)
e, err := searchOne(c, a.opts.BaseDN, filter, []string{"distinguishedName"}, a.opts.Timeout)
if err != nil {
return "", fmt.Errorf("Suche nach Gruppe %q: %w", a.opts.VPNGroup, err)
}
if e == nil {
return "", fmt.Errorf("Gruppe %q wurde unter %s nicht gefunden", a.opts.VPNGroup, a.opts.BaseDN)
}
return e.DN, nil
}
// ResolveGroupDN löst den Gruppen-DN auf und legt ihn im Cache ab.
// Wird beim Start und von check aufgerufen.
func (a *AD) ResolveGroupDN(ctx context.Context) (string, error) {
var dn string
err := a.withConn(ctx, func(c conn) error {
var err error
dn, err = a.resolveGroupDN(c)
return err
})
if err != nil {
return "", err
}
a.mu.Lock()
a.groupDN = dn
a.mu.Unlock()
return dn, nil
}
// groupDNOrResolve liefert den gecachten DN oder löst ihn nach.
func (a *AD) groupDNOrResolve(c conn) (string, error) {
if dn := a.GroupDN(); dn != "" {
return dn, nil
}
dn, err := a.resolveGroupDN(c)
if err != nil {
return "", err
}
a.mu.Lock()
a.groupDN = dn
a.mu.Unlock()
return dn, nil
}
// findUser sucht den Benutzer über sAMAccountName oder userPrincipalName.
func (a *AD) findUser(c conn, input string) (*ldap.Entry, error) {
esc := ldap.EscapeFilter(input)
filter := fmt.Sprintf("(&(objectClass=user)(|(sAMAccountName=%s)(userPrincipalName=%s)))", esc, esc)
return searchOne(c, a.opts.BaseDN, filter, []string{"sAMAccountName", "distinguishedName"}, a.opts.Timeout)
}
// inGroup prüft die Mitgliedschaft inkl. verschachtelter Gruppen.
func (a *AD) inGroup(c conn, sam, groupDN string) (bool, error) {
filter := fmt.Sprintf("(&(sAMAccountName=%s)(memberOf:%s:=%s))",
ldap.EscapeFilter(sam), MatchingRuleInChain, ldap.EscapeFilter(groupDN))
e, err := searchOne(c, a.opts.BaseDN, filter, []string{"distinguishedName"}, a.opts.Timeout)
if err != nil {
return false, fmt.Errorf("Gruppenprüfung für %q: %w", sam, err)
}
return e != nil, nil
}
// canonicalName liest sAMAccountName aus dem Verzeichnis, niemals aus der Eingabe.
func canonicalName(e *ldap.Entry) string {
return strings.ToLower(strings.TrimSpace(e.GetAttributeValue("sAMAccountName")))
}
// Authenticate führt den Zwei-Schritt-Bind aus und prüft die Gruppenmitgliedschaft.
func (a *AD) Authenticate(ctx context.Context, username, password string) (*Identity, error) {
username = strings.TrimSpace(username)
// Ein leeres Passwort würde als anonymer Bind durchgehen — hart ablehnen,
// bevor überhaupt gesucht wird.
if username == "" || password == "" {
return nil, &Error{Reason: ReasonInvalidCredentials,
Err: errors.New("Benutzername oder Passwort war leer")}
}
var id *Identity
err := a.withConn(ctx, func(c conn) error {
groupDN, err := a.groupDNOrResolve(c)
if err != nil {
return err
}
userEntry, err := a.findUser(c, username)
if err != nil {
return err
}
if userEntry == nil {
return &Error{Reason: ReasonUserNotFound,
Err: errors.New("kein Verzeichniseintrag zur Eingabe gefunden")}
}
// Schritt 2: Bind mit dem gefundenen DN und dem eingegebenen Passwort.
// Dafür wird eine eigene Verbindung benutzt, damit die
// Service-Verbindung gebunden bleibt.
if err := a.bindAs(ctx, userEntry.DN, password); err != nil {
return &Error{Reason: ReasonFromLDAPError(err), Err: err}
}
sam := canonicalName(userEntry)
if sam == "" {
return &Error{Reason: ReasonUserNotFound,
Err: fmt.Errorf("Eintrag %s hat keinen sAMAccountName", userEntry.DN)}
}
ok, err := a.inGroup(c, sam, groupDN)
if err != nil {
return err
}
if !ok {
return &Error{Reason: ReasonNotInVPNGroup,
Err: fmt.Errorf("%s ist nicht Mitglied von %s", sam, a.opts.VPNGroup)}
}
id = &Identity{Username: sam, Groups: []string{a.opts.VPNGroup}}
return nil
})
if err != nil {
var ae *Error
if errors.As(err, &ae) {
return nil, ae
}
return nil, &Error{Reason: ReasonBackendUnavailable, Err: err}
}
return id, nil
}
// bindAs öffnet eine frische Verbindung und bindet als der angegebene DN.
func (a *AD) bindAs(ctx context.Context, dn, password string) error {
var lastErr error
for _, server := range a.opts.Servers {
c, err := a.opts.Dial(ctx, server)
if err != nil {
lastErr = err
if a.opts.OnFailover != nil {
a.opts.OnFailover(server, err)
}
continue
}
err = c.Bind(dn, password)
c.Close()
return err
}
return fmt.Errorf("kein Domain Controller für Benutzer-Bind erreichbar: %w", lastErr)
}
// LookupResult ist das Ergebnis von check --test-auth.
type LookupResult struct {
DN string
SAMAccountName string
InVPNGroup bool
}
// Lookup spielt Suche und Gruppenprüfung ohne Passwort-Bind durch.
func (a *AD) Lookup(ctx context.Context, username string) (*LookupResult, error) {
var res *LookupResult
err := a.withConn(ctx, func(c conn) error {
groupDN, err := a.groupDNOrResolve(c)
if err != nil {
return err
}
e, err := a.findUser(c, strings.TrimSpace(username))
if err != nil {
return err
}
if e == nil {
return fmt.Errorf("Benutzer %q wurde unter %s nicht gefunden", username, a.opts.BaseDN)
}
sam := canonicalName(e)
ok, err := a.inGroup(c, sam, groupDN)
if err != nil {
return err
}
res = &LookupResult{DN: e.DN, SAMAccountName: sam, InVPNGroup: ok}
return nil
})
return res, err
}
```
- [ ] **Step 6: Compile-Time-Check ergänzen**
Am Ende von `ad.go`:
```go
var _ Authenticator = (*AD)(nil)
```
- [ ] **Step 7: Tests laufen lassen**
Run: `go test ./internal/auth/ -v`
Expected: PASS (alle Tests aus Task 6 und 7)
Hinweis für den Fall, dass `TestAuthenticateEscapesFilterInput` fehlschlägt: `ldap.EscapeFilter` kodiert `(`, `)` und `*` als `\28`, `\29`, `\2a`. Der Test prüft auf `\29\28` (die Folge `)(`). Sollte die Bibliothek Großbuchstaben liefern, ist die Assertion auf `strings.ToLower(f)` anzuwenden — der Escape-Mechanismus selbst bleibt unverändert.
- [ ] **Step 8: Commit**
```bash
git add go.mod go.sum internal/auth
git commit -m "feat(auth): AD-Authenticator mit Zwei-Schritt-Bind, DC-Failover und verschachtelter Gruppenprüfung"
```
---
## Task 8: OPNsense-Export-Client
**Files:**
- Create: `internal/opnsense/client.go`, `internal/opnsense/types.go`, `internal/opnsense/export.go`
- Create: `docs/opnsense-api.md`
- Test: `internal/opnsense/client_test.go`, `internal/opnsense/types_test.go`
- Test (build-tagged, läuft nicht in CI): `internal/opnsense/integration_test.go`
**Interfaces:**
- Consumes: nichts
- Produces:
- `type Provider struct { VPNID, Name string }`
- `type Account struct { CommonName, Description, RefID string; ValidTo time.Time; Revoked bool }`
- `type Client struct { ... }`; `func New(opts Options) (*Client, error)`; `type Options struct { BaseURL, APIKey, APISecret, CAFile string; InsecureSkipVerify bool; Timeout time.Duration; HTTPClient *http.Client }`
- `func (c *Client) Providers(ctx context.Context) ([]Provider, error)` — Ergebnis wird vom Aufrufer gecacht, nicht hier
- `func (c *Client) Accounts(ctx context.Context, vpnID string) ([]Account, error)`**nie** gecacht
- `func (c *Client) Export(ctx context.Context, vpnID, certRefID, format string) (*ExportResult, error)`; `type ExportResult struct { Filename string; ContentType string; Body io.ReadCloser }` — Aufrufer schließt `Body` und streamt
- `func (c *Client) Ping(ctx context.Context) (serverTime time.Time, err error)` — für `check`
- `var ErrUnauthorized`, `var ErrForbidden`, `var ErrUnreachable`
- Formate: `FormatOVPN = "ovpn"` (inline `.ovpn`), `FormatViscosity = "viscosity"`
- [ ] **Step 1: Failing test für tolerantes Decoding schreiben**
`internal/opnsense/types_test.go`:
```go
package opnsense
import (
"encoding/json"
"testing"
"time"
)
func TestFlexBoolAcceptsAPIVariants(t *testing.T) {
cases := map[string]bool{
`true`: true, `false`: false,
`1`: true, `0`: false,
`"1"`: true, `"0"`: false,
`"true"`: true, `"false"`: false,
`"yes"`: true, `"no"`: false,
`""`: false, `null`: false,
}
for raw, want := range cases {
var b flexBool
if err := json.Unmarshal([]byte(raw), &b); err != nil {
t.Errorf("Unmarshal(%s): %v", raw, err)
continue
}
if bool(b) != want {
t.Errorf("Unmarshal(%s) = %v, want %v", raw, bool(b), want)
}
}
}
func TestFlexTimeAcceptsAPIVariants(t *testing.T) {
want := time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC)
for _, raw := range []string{
`"2027-03-01"`,
`"2027-03-01T00:00:00Z"`,
`"Mar 1 00:00:00 2027 GMT"`,
} {
var ft flexTime
if err := json.Unmarshal([]byte(raw), &ft); err != nil {
t.Errorf("Unmarshal(%s): %v", raw, err)
continue
}
if !ft.Time.Equal(want) {
t.Errorf("Unmarshal(%s) = %v, want %v", raw, ft.Time, want)
}
}
var empty flexTime
if err := json.Unmarshal([]byte(`""`), &empty); err != nil || !empty.Time.IsZero() {
t.Errorf("leerer String muss Nullzeit ergeben, got %v (%v)", empty.Time, err)
}
}
func TestAccountIsUsable(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
valid := Account{CommonName: "mmueller", ValidTo: now.AddDate(0, 6, 0)}
if !valid.IsUsable(now) {
t.Error("gültiges Zertifikat muss nutzbar sein")
}
revoked := valid
revoked.Revoked = true
if revoked.IsUsable(now) {
t.Error("revoziertes Zertifikat darf nie nutzbar sein")
}
expired := Account{CommonName: "mmueller", ValidTo: now.AddDate(0, 0, -1)}
if expired.IsUsable(now) {
t.Error("abgelaufenes Zertifikat darf nie nutzbar sein")
}
// Fehlendes Ablaufdatum: als unbekannt behandeln, aber nicht blockieren —
// die Revocation-Prüfung bleibt maßgeblich.
noDate := Account{CommonName: "mmueller"}
if !noDate.IsUsable(now) {
t.Error("fehlendes Ablaufdatum darf nicht zum Ausschluss führen")
}
}
```
- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./internal/opnsense/ -v`
Expected: FAIL — `undefined: flexBool`
- [ ] **Step 3: `types.go` schreiben**
```go
// Package opnsense spricht die read-only Export-API des Plugins
// os-openvpn-client-export an.
package opnsense
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// Exportformate, die das Portal anbietet. Alle weiteren Einstellungen kommen
// aus der Firewall-Konfiguration — das Portal überschreibt nichts.
const (
FormatOVPN = "ovpn"
FormatViscosity = "viscosity"
)
// flexBool akzeptiert die verschiedenen Boolean-Darstellungen der API
// (true, 1, "1", "yes"). Die exakte Variante ist versionsabhängig.
type flexBool bool
func (b *flexBool) UnmarshalJSON(data []byte) error {
s := strings.Trim(strings.TrimSpace(string(data)), `"`)
switch strings.ToLower(s) {
case "1", "true", "yes", "on":
*b = true
case "", "0", "false", "no", "off", "null":
*b = false
default:
return fmt.Errorf("unerwarteter Boolean-Wert %q", s)
}
return nil
}
// flexTimeLayouts deckt die von OPNsense beobachteten Datumsformate ab.
var flexTimeLayouts = []string{
time.RFC3339,
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
"2006-01-02",
"Jan _2 15:04:05 2006 MST",
"060102150405Z", // ASN.1 UTCTime
}
// flexTime parst Ablaufdaten in mehreren Formaten.
type flexTime struct{ Time time.Time }
func (t *flexTime) UnmarshalJSON(data []byte) error {
var raw any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
switch v := raw.(type) {
case nil:
return nil
case float64: // Unix-Zeitstempel
t.Time = time.Unix(int64(v), 0).UTC()
return nil
case string:
s := strings.TrimSpace(v)
if s == "" {
return nil
}
for _, layout := range flexTimeLayouts {
if parsed, err := time.Parse(layout, s); err == nil {
t.Time = parsed.UTC()
return nil
}
}
return fmt.Errorf("unbekanntes Datumsformat %q", s)
default:
return fmt.Errorf("unerwarteter Datumstyp %T", raw)
}
}
// Provider ist eine exportierbare OpenVPN-Instanz der Firewall.
type Provider struct {
VPNID string
Name string
}
// Account ist ein exportierbares Client-Zertifikat.
type Account struct {
RefID string
CommonName string
Description string
ValidTo time.Time
Revoked bool
}
// IsUsable meldet, ob das Zertifikat ausgeliefert werden darf.
// Ein fehlendes Ablaufdatum blockiert nicht — die Revocation-Prüfung greift
// weiterhin, und ein unbekanntes Datum darf keine gültige Config verhindern.
func (a Account) IsUsable(now time.Time) bool {
if a.Revoked {
return false
}
if !a.ValidTo.IsZero() && !a.ValidTo.After(now) {
return false
}
return true
}
// DaysUntilExpiry liefert die Restlaufzeit in Tagen; -1 bei unbekanntem Datum.
func (a Account) DaysUntilExpiry(now time.Time) int {
if a.ValidTo.IsZero() {
return -1
}
return int(a.ValidTo.Sub(now).Hours() / 24)
}
// rawProvider und rawAccount bilden die API-Antworten ab. Mehrere Feldnamen
// werden akzeptiert, weil die Benennung zwischen OPNsense-Versionen wechselt
// (siehe docs/opnsense-api.md).
type rawProvider struct {
VPNID string `json:"vpnid"`
Name string `json:"name"`
Descr string `json:"description"`
}
type rawAccount struct {
CommonName string `json:"commonName"`
CommonName2 string `json:"common_name"`
Description string `json:"description"`
Descr string `json:"descr"`
ValidTo flexTime `json:"validTo"`
ValidTo2 flexTime `json:"valid_to"`
ValidTo3 flexTime `json:"validto"`
Revoked flexBool `json:"isRevoked"`
Revoked2 flexBool `json:"revoked"`
Revoked3 flexBool `json:"is_revoked"`
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
func (r rawAccount) toAccount(refID string) Account {
validTo := r.ValidTo.Time
if validTo.IsZero() {
validTo = r.ValidTo2.Time
}
if validTo.IsZero() {
validTo = r.ValidTo3.Time
}
return Account{
RefID: refID,
CommonName: firstNonEmpty(r.CommonName, r.CommonName2),
Description: firstNonEmpty(r.Description, r.Descr),
ValidTo: validTo,
Revoked: bool(r.Revoked) || bool(r.Revoked2) || bool(r.Revoked3),
}
}
```
- [ ] **Step 4: Tests laufen lassen**
Run: `go test ./internal/opnsense/ -v`
Expected: PASS (3 Tests)
- [ ] **Step 5: Failing test für den HTTP-Client schreiben**
`internal/opnsense/client_test.go`:
```go
package opnsense
import (
"context"
"encoding/base64"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func newTestClient(t *testing.T, h http.HandlerFunc) (*Client, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
c, err := New(Options{
BaseURL: srv.URL,
APIKey: "KEY",
APISecret: "SECRET",
Timeout: 2 * time.Second,
HTTPClient: srv.Client(),
})
if err != nil {
t.Fatalf("New: %v", err)
}
return c, srv
}
func TestProvidersSendsBasicAuthAndParsesMap(t *testing.T) {
var gotPath string
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
user, pass, ok := r.BasicAuth()
if !ok || user != "KEY" || pass != "SECRET" {
t.Errorf("BasicAuth = %q/%q ok=%v", user, pass, ok)
}
io.WriteString(w, `{
"1": {"vpnid":"1","name":"VPN Homeoffice"},
"2": {"vpnid":"2","name":"VPN Aussendienst"}
}`)
})
ps, err := c.Providers(context.Background())
if err != nil {
t.Fatalf("Providers: %v", err)
}
if gotPath != "/api/openvpn/export/providers" {
t.Errorf("Pfad = %q", gotPath)
}
if len(ps) != 2 {
t.Fatalf("got %d Provider, want 2", len(ps))
}
// Stabile Sortierung nach VPNID, damit die UI-Reihenfolge deterministisch ist.
if ps[0].VPNID != "1" || ps[0].Name != "VPN Homeoffice" {
t.Errorf("ps[0] = %+v", ps[0])
}
}
func TestProvidersFallsBackToMapKeyAsVPNID(t *testing.T) {
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, `{"7": {"name":"VPN Sieben"}}`)
})
ps, err := c.Providers(context.Background())
if err != nil {
t.Fatalf("Providers: %v", err)
}
if len(ps) != 1 || ps[0].VPNID != "7" {
t.Fatalf("ps = %+v, VPNID muss aus dem Map-Schlüssel kommen", ps)
}
}
func TestAccountsParsesRevokedAndExpiry(t *testing.T) {
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/accounts/1") {
t.Errorf("Pfad = %q", r.URL.Path)
}
io.WriteString(w, `{
"abc123": {"commonName":"mmueller","description":"Max","validTo":"2027-03-01","isRevoked":"0"},
"def456": {"commonName":"jdoe","validTo":"2027-03-01","isRevoked":"1"}
}`)
})
accs, err := c.Accounts(context.Background(), "1")
if err != nil {
t.Fatalf("Accounts: %v", err)
}
if len(accs) != 2 {
t.Fatalf("got %d Accounts, want 2", len(accs))
}
byCN := map[string]Account{}
for _, a := range accs {
byCN[a.CommonName] = a
}
if byCN["mmueller"].RefID != "abc123" || byCN["mmueller"].Revoked {
t.Errorf("mmueller = %+v", byCN["mmueller"])
}
if !byCN["jdoe"].Revoked {
t.Error("jdoe muss als revoziert erkannt werden")
}
if byCN["mmueller"].ValidTo.Year() != 2027 {
t.Errorf("ValidTo = %v", byCN["mmueller"].ValidTo)
}
}
func TestExportStreamsRawBody(t *testing.T) {
const cfg = "client\nremote fw01.firma.local 1194\n"
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", `attachment; filename="mmueller.ovpn"`)
io.WriteString(w, cfg)
})
res, err := c.Export(context.Background(), "1", "abc123", FormatOVPN)
if err != nil {
t.Fatalf("Export: %v", err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if string(body) != cfg {
t.Errorf("Body = %q", body)
}
if res.Filename != "mmueller.ovpn" {
t.Errorf("Filename = %q", res.Filename)
}
}
func TestExportDecodesBase64JSONBody(t *testing.T) {
const cfg = "client\nremote fw01 1194\n"
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"status":"ok","filename":"x.ovpn","content":"`+
base64.StdEncoding.EncodeToString([]byte(cfg))+`"}`)
})
res, err := c.Export(context.Background(), "1", "abc123", FormatOVPN)
if err != nil {
t.Fatalf("Export: %v", err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if string(body) != cfg {
t.Errorf("Body = %q, want dekodierte Konfiguration", body)
}
if res.Filename != "x.ovpn" {
t.Errorf("Filename = %q", res.Filename)
}
}
func TestUnauthorizedAndForbiddenAreDistinct(t *testing.T) {
for status, want := range map[int]error{
http.StatusUnauthorized: ErrUnauthorized,
http.StatusForbidden: ErrForbidden,
} {
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
})
_, err := c.Providers(context.Background())
if !errors.Is(err, want) {
t.Errorf("Status %d: err = %v, want %v", status, err, want)
}
}
}
func TestUnreachableIsWrapped(t *testing.T) {
c, srv := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {})
srv.Close() // Server abschalten, um Verbindungsfehler zu erzwingen
_, err := c.Providers(context.Background())
if !errors.Is(err, ErrUnreachable) {
t.Fatalf("err = %v, want ErrUnreachable", err)
}
}
func TestHTMLLoginPageIsRejected(t *testing.T) {
// OPNsense liefert bei ungültigem Key manchmal 200 mit HTML-Loginseite.
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
io.WriteString(w, "<html><body>Login</body></html>")
})
if _, err := c.Providers(context.Background()); err == nil {
t.Fatal("HTML-Antwort muss als Fehler erkannt werden")
}
}
func TestPingReturnsServerTime(t *testing.T) {
want := time.Date(2026, 8, 14, 7, 32, 11, 0, time.UTC)
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Date", want.Format(http.TimeFormat))
io.WriteString(w, `{}`)
})
got, err := c.Ping(context.Background())
if err != nil {
t.Fatalf("Ping: %v", err)
}
if !got.Equal(want) {
t.Errorf("Ping-Zeit = %v, want %v", got, want)
}
}
func TestClientRejectsInsecureWithoutExplicitOptIn(t *testing.T) {
if _, err := New(Options{BaseURL: "https://fw", APIKey: "k", APISecret: "s"}); err != nil {
t.Fatalf("Standardfall muss funktionieren: %v", err)
}
if _, err := New(Options{BaseURL: "", APIKey: "k", APISecret: "s"}); err == nil {
t.Fatal("leere BaseURL muss abgelehnt werden")
}
}
```
- [ ] **Step 6: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./internal/opnsense/ -run 'TestProviders|TestAccounts|TestExport|TestUn|TestHTML|TestPing|TestClient' -v`
Expected: FAIL — `undefined: New`
- [ ] **Step 7: `client.go` schreiben**
```go
package opnsense
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// Sentinel-Fehler, damit Aufrufer Ursachen unterscheiden können.
var (
ErrUnauthorized = errors.New("OPNsense: Zugangsdaten abgelehnt (API-Key/Secret prüfen)")
ErrForbidden = errors.New("OPNsense: keine Berechtigung (API-User braucht \"VPN: OpenVPN Client Export\")")
ErrUnreachable = errors.New("OPNsense nicht erreichbar")
ErrUnexpected = errors.New("OPNsense: unerwartete Antwort")
)
// maxJSONBytes begrenzt JSON-Antworten; Exportdaten werden gestreamt und
// unterliegen dieser Grenze nicht.
const maxJSONBytes = 8 << 20
// Options konfiguriert den Client.
type Options struct {
BaseURL string
APIKey string
APISecret string
CAFile string
InsecureSkipVerify bool
Timeout time.Duration
// HTTPClient überschreibt den intern gebauten Client (Tests).
HTTPClient *http.Client
}
// Client ist ein read-only Client für die OPNsense-Export-API.
type Client struct {
baseURL string
key string
secret string
http *http.Client
}
// New baut den Client und die TLS-Konfiguration.
func New(opts Options) (*Client, error) {
if strings.TrimSpace(opts.BaseURL) == "" {
return nil, errors.New("opnsense: url fehlt")
}
if _, err := url.Parse(opts.BaseURL); err != nil {
return nil, fmt.Errorf("opnsense: url ist ungültig: %w", err)
}
if opts.APIKey == "" || opts.APISecret == "" {
return nil, errors.New("opnsense: api_key und api_secret sind erforderlich")
}
if opts.Timeout <= 0 {
opts.Timeout = 15 * time.Second
}
httpClient := opts.HTTPClient
if httpClient == nil {
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
if opts.InsecureSkipVerify {
tlsCfg.InsecureSkipVerify = true
}
if opts.CAFile != "" {
pem, err := os.ReadFile(opts.CAFile)
if err != nil {
return nil, fmt.Errorf("opnsense.ca_file %s: %w", opts.CAFile, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("opnsense.ca_file %s enthält kein gültiges PEM-Zertifikat", opts.CAFile)
}
tlsCfg.RootCAs = pool
}
httpClient = &http.Client{
Timeout: opts.Timeout,
Transport: &http.Transport{TLSClientConfig: tlsCfg, ForceAttemptHTTP2: true},
}
}
return &Client{
baseURL: strings.TrimRight(opts.BaseURL, "/"),
key: opts.APIKey,
secret: opts.APISecret,
http: httpClient,
}, nil
}
// get führt einen authentifizierten GET aus und normalisiert Fehlerstatus.
// Der Aufrufer muss den Body schließen.
func (c *Client) get(ctx context.Context, path string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(c.key, c.secret)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %s: %v", ErrUnreachable, path, err)
}
switch resp.StatusCode {
case http.StatusOK:
return resp, nil
case http.StatusUnauthorized:
resp.Body.Close()
return nil, fmt.Errorf("%w (%s)", ErrUnauthorized, path)
case http.StatusForbidden:
resp.Body.Close()
return nil, fmt.Errorf("%w (%s)", ErrForbidden, path)
case http.StatusNotFound:
resp.Body.Close()
return nil, fmt.Errorf("%w: Endpunkt %s existiert nicht — ist das Plugin "+
"os-openvpn-client-export installiert?", ErrUnexpected, path)
default:
resp.Body.Close()
return nil, fmt.Errorf("%w: %s antwortete mit HTTP %d", ErrUnexpected, path, resp.StatusCode)
}
}
// getJSON liest eine JSON-Antwort und weist HTML-Loginseiten zurück.
func (c *Client) getJSON(ctx context.Context, path string, into any) error {
resp, err := c.get(ctx, path)
if err != nil {
return err
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") {
return fmt.Errorf("%w: %s lieferte HTML statt JSON — meist ein ungültiger API-Key", ErrUnauthorized, path)
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxJSONBytes))
if err != nil {
return fmt.Errorf("%w: %s: %v", ErrUnreachable, path, err)
}
if bytes := strings.TrimSpace(string(raw)); strings.HasPrefix(bytes, "<") {
return fmt.Errorf("%w: %s lieferte HTML statt JSON — meist ein ungültiger API-Key", ErrUnauthorized, path)
}
if err := json.Unmarshal(raw, into); err != nil {
return fmt.Errorf("%w: %s lieferte kein verwertbares JSON: %v", ErrUnexpected, path, err)
}
return nil
}
// Ping prüft Erreichbarkeit und Berechtigung und liefert die Serverzeit aus
// dem Date-Header (Grundlage der NTP-Plausibilitätsprüfung in check).
func (c *Client) Ping(ctx context.Context) (time.Time, error) {
resp, err := c.get(ctx, pathProviders)
if err != nil {
return time.Time{}, err
}
defer resp.Body.Close()
io.Copy(io.Discard, io.LimitReader(resp.Body, maxJSONBytes))
if d := resp.Header.Get("Date"); d != "" {
if t, err := http.ParseTime(d); err == nil {
return t.UTC(), nil
}
}
return time.Time{}, nil
}
```
- [ ] **Step 8: `export.go` schreiben**
```go
package opnsense
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"sort"
"strings"
)
// API-Pfade des Plugins os-openvpn-client-export.
const (
pathProviders = "/api/openvpn/export/providers"
pathAccounts = "/api/openvpn/export/accounts/"
pathDownload = "/api/openvpn/export/download/"
)
// Providers listet die exportierbaren OpenVPN-Instanzen.
// Das Ergebnis darf vom Aufrufer kurz gecacht werden (Minuten).
func (c *Client) Providers(ctx context.Context) ([]Provider, error) {
var raw map[string]rawProvider
if err := c.getJSON(ctx, pathProviders, &raw); err != nil {
return nil, err
}
out := make([]Provider, 0, len(raw))
for key, rp := range raw {
id := rp.VPNID
if id == "" {
id = key // ältere Versionen führen die vpnid nur als Map-Schlüssel
}
name := firstNonEmpty(rp.Name, rp.Descr, "VPN "+id)
out = append(out, Provider{VPNID: id, Name: name})
}
sort.Slice(out, func(i, j int) bool { return out[i].VPNID < out[j].VPNID })
return out, nil
}
// Accounts listet die Zertifikate einer Instanz.
// Diese Antwort darf NIEMALS gecacht werden — eine Revozierung muss sofort greifen.
func (c *Client) Accounts(ctx context.Context, vpnID string) ([]Account, error) {
if strings.TrimSpace(vpnID) == "" {
return nil, fmt.Errorf("%w: leere vpnid", ErrUnexpected)
}
var raw map[string]rawAccount
if err := c.getJSON(ctx, pathAccounts+url.PathEscape(vpnID), &raw); err != nil {
return nil, err
}
out := make([]Account, 0, len(raw))
for refID, ra := range raw {
out = append(out, ra.toAccount(refID))
}
sort.Slice(out, func(i, j int) bool { return out[i].RefID < out[j].RefID })
return out, nil
}
// ExportResult trägt den Datenstrom der Konfiguration.
// Der Aufrufer muss Body schließen und darf ihn nicht auf Platte zwischenspeichern.
type ExportResult struct {
Filename string
ContentType string
Body io.ReadCloser
}
// jsonExport ist die Antwortform älterer Plugin-Versionen: Base64 im JSON.
type jsonExport struct {
Status string `json:"status"`
Filename string `json:"filename"`
Content string `json:"content"`
Data string `json:"data"`
Message string `json:"message"`
}
// Export lädt eine Client-Konfiguration und liefert sie als Stream.
// Es werden keine Exportoptionen übergeben — die Firewall-Einstellungen sind
// die Quelle der Wahrheit.
func (c *Client) Export(ctx context.Context, vpnID, certRefID, format string) (*ExportResult, error) {
if strings.TrimSpace(vpnID) == "" || strings.TrimSpace(certRefID) == "" {
return nil, fmt.Errorf("%w: vpnid oder Zertifikatsreferenz fehlt", ErrUnexpected)
}
path := fmt.Sprintf("%s%s/%s/%s", pathDownload,
url.PathEscape(vpnID), url.PathEscape(format), url.PathEscape(certRefID))
resp, err := c.get(ctx, path)
if err != nil {
return nil, err
}
ct := resp.Header.Get("Content-Type")
filename := filenameFromDisposition(resp.Header.Get("Content-Disposition"))
// JSON-Variante: Inhalt steckt Base64-kodiert in der Antwort.
if strings.Contains(ct, "application/json") {
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxJSONBytes))
if err != nil {
return nil, fmt.Errorf("%w: Export konnte nicht gelesen werden: %v", ErrUnreachable, err)
}
var je jsonExport
if err := json.Unmarshal(raw, &je); err != nil {
return nil, fmt.Errorf("%w: Export lieferte kein verwertbares JSON: %v", ErrUnexpected, err)
}
payload := firstNonEmpty(je.Content, je.Data)
if payload == "" {
return nil, fmt.Errorf("%w: Export ohne Inhalt (status %q, message %q)",
ErrUnexpected, je.Status, je.Message)
}
decoded, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
// Manche Versionen liefern den Klartext direkt.
decoded = []byte(payload)
}
return &ExportResult{
Filename: firstNonEmpty(je.Filename, filename),
ContentType: "application/x-openvpn-profile",
Body: io.NopCloser(bytes.NewReader(decoded)),
}, nil
}
// Rohvariante: direkt durchstreamen, nichts puffern.
return &ExportResult{
Filename: filename,
ContentType: firstNonEmpty(ct, "application/octet-stream"),
Body: resp.Body,
}, nil
}
// filenameFromDisposition liest den Dateinamen aus dem Content-Disposition-Header.
func filenameFromDisposition(v string) string {
if v == "" {
return ""
}
_, params, err := mime.ParseMediaType(v)
if err != nil {
return ""
}
return params["filename"]
}
var _ = http.StatusOK // hält den http-Import für künftige Erweiterungen sichtbar
```
Die letzte Zeile entfällt, falls `http` bereits anderweitig verwendet wird — `go vet` weist ungenutzte Importe ohnehin aus; dann den Import streichen statt den Dummy zu behalten.
- [ ] **Step 9: Tests laufen lassen**
Run: `go test ./internal/opnsense/ -v`
Expected: PASS. Falls `TestExportStreamsRawBody` beim Pfad scheitert: der Test prüft den Pfad nicht, nur den Inhalt — er muss durchlaufen.
- [ ] **Step 10: Integrationstest-Skeleton anlegen**
`internal/opnsense/integration_test.go`:
```go
//go:build integration
// Diese Tests laufen nur mit `go test -tags integration ./internal/opnsense/`
// gegen eine echte OPNsense-Testinstanz. Sie bestätigen die in
// docs/opnsense-api.md dokumentierten Annahmen über Feldnamen und Formate.
package opnsense
import (
"context"
"os"
"testing"
"time"
)
func integrationClient(t *testing.T) *Client {
t.Helper()
base, key, secret := os.Getenv("OPNSENSE_URL"), os.Getenv("OPNSENSE_KEY"), os.Getenv("OPNSENSE_SECRET")
if base == "" || key == "" || secret == "" {
t.Skip("OPNSENSE_URL/OPNSENSE_KEY/OPNSENSE_SECRET nicht gesetzt")
}
c, err := New(Options{BaseURL: base, APIKey: key, APISecret: secret,
CAFile: os.Getenv("OPNSENSE_CA"), Timeout: 20 * time.Second})
if err != nil {
t.Fatal(err)
}
return c
}
// TestIntegrationProviders bestätigt Feldnamen und Map-Form der Provider-Antwort.
func TestIntegrationProviders(t *testing.T) {
ps, err := integrationClient(t).Providers(context.Background())
if err != nil {
t.Fatalf("Providers: %v", err)
}
if len(ps) == 0 {
t.Fatal("keine Provider — mindestens eine OpenVPN-Instanz muss exportierbar sein")
}
for _, p := range ps {
if p.VPNID == "" || p.Name == "" {
t.Errorf("unvollständiger Provider: %+v", p)
}
}
}
// TestIntegrationAccountsRevokedField bestätigt, dass revozierte Zertifikate
// als solche erkennbar sind. Voraussetzung: auf der Testinstanz existiert ein
// revoziertes Zertifikat mit CN aus OPNSENSE_REVOKED_CN.
func TestIntegrationAccountsRevokedField(t *testing.T) {
wantCN := os.Getenv("OPNSENSE_REVOKED_CN")
if wantCN == "" {
t.Skip("OPNSENSE_REVOKED_CN nicht gesetzt")
}
c := integrationClient(t)
ps, err := c.Providers(context.Background())
if err != nil {
t.Fatal(err)
}
for _, p := range ps {
accs, err := c.Accounts(context.Background(), p.VPNID)
if err != nil {
t.Fatalf("Accounts(%s): %v", p.VPNID, err)
}
for _, a := range accs {
if a.CommonName == wantCN {
if !a.Revoked {
t.Fatalf("Zertifikat %q wird nicht als revoziert gemeldet: %+v", wantCN, a)
}
if a.IsUsable(time.Now()) {
t.Fatalf("revoziertes Zertifikat %q gilt als nutzbar", wantCN)
}
return
}
}
}
t.Fatalf("CN %q auf keiner Instanz gefunden", wantCN)
}
// TestIntegrationExportFormats bestätigt, dass beide angebotenen Formate
// nicht-leere Konfigurationen liefern.
func TestIntegrationExportFormats(t *testing.T) {
refID := os.Getenv("OPNSENSE_CERT_REF")
vpnID := os.Getenv("OPNSENSE_VPNID")
if refID == "" || vpnID == "" {
t.Skip("OPNSENSE_CERT_REF/OPNSENSE_VPNID nicht gesetzt")
}
c := integrationClient(t)
for _, format := range []string{FormatOVPN, FormatViscosity} {
res, err := c.Export(context.Background(), vpnID, refID, format)
if err != nil {
t.Errorf("Export(%s): %v", format, err)
continue
}
n, _ := io.Copy(io.Discard, res.Body)
res.Body.Close()
if n == 0 {
t.Errorf("Export(%s) lieferte 0 Bytes", format)
}
}
}
```
`"io"` gehört in den Import-Block dieser Datei.
- [ ] **Step 11: API-Annahmen dokumentieren**
`docs/opnsense-api.md` anlegen mit: der Liste der drei genutzten Endpunkte (`providers`, `accounts/{vpnid}`, `download/{vpnid}/{format}/{certref}`), der Feststellung, dass alle drei `GET` und damit read-only sind, der Tabelle der akzeptierten Feldnamen (`commonName`/`common_name`, `validTo`/`valid_to`/`validto`, `isRevoked`/`revoked`/`is_revoked`), der Liste akzeptierter Datumsformate, dem Hinweis auf die beiden möglichen Antwortformen des Downloads (Rohdaten vs. Base64-in-JSON), der geforderten OPNsense-Mindestversion (beim ersten Integrationstestlauf eintragen) und dem Satz: „Diese Annahmen sind mit `go test -tags integration ./internal/opnsense/` gegen eine echte Instanz zu bestätigen, bevor v1 ausgeliefert wird."
- [ ] **Step 12: Kompilierbarkeit des Integrationstests prüfen**
Run: `go vet -tags integration ./internal/opnsense/ && go test ./internal/opnsense/ -v`
Expected: `vet` ohne Befund, Tests PASS
- [ ] **Step 13: Commit**
```bash
git add internal/opnsense docs/opnsense-api.md
git commit -m "feat(opnsense): read-only Export-Client mit tolerantem Decoding und Streaming-Download"
```
---
## Task 9: Zertifikatszuordnung (CN-Matching und Filterung)
**Files:**
- Create: `internal/certmatch/match.go`
- Test: `internal/certmatch/match_test.go`
**Interfaces:**
- Consumes: `opnsense.Account`, `opnsense.Provider` (Task 8)
- Produces:
- `type Matcher struct { ... }`; `func NewMatcher(pattern, regex string) (*Matcher, error)`
- `func (m *Matcher) Matches(cn, username string) bool` — Vergleich case-insensitive
- `func (m *Matcher) Describe(username string) string` — für Audit-Feld `pattern`
- `type Entry struct { Provider opnsense.Provider; Account opnsense.Account }`
- `func (m *Matcher) Filter(username string, provider opnsense.Provider, accounts []opnsense.Account, now time.Time) []Entry` — matcht CN **und** filtert revoked/abgelaufen
- `func (e Entry) Token() string` — stabiler, undurchsichtiger Bezeichner `vpnid:refid` für die UI-Auswahl (die Autorisierung erfolgt trotzdem serverseitig neu)
- `func ParseToken(s string) (vpnID, refID string, ok bool)`
- [ ] **Step 1: Failing test schreiben**
`internal/certmatch/match_test.go`:
```go
package certmatch
import (
"testing"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
)
func TestPatternMatching(t *testing.T) {
m, err := NewMatcher("{username}", "")
if err != nil {
t.Fatal(err)
}
if !m.Matches("mmueller", "mmueller") {
t.Error("identischer CN muss matchen")
}
if !m.Matches("MMueller", "mmueller") {
t.Error("Vergleich muss Groß-/Kleinschreibung ignorieren")
}
if m.Matches("jdoe", "mmueller") {
t.Error("fremder CN darf nicht matchen")
}
if m.Matches("mmueller2", "mmueller") {
t.Error("Präfix-Treffer darf nicht als Match gelten")
}
if m.Matches("", "mmueller") || m.Matches("mmueller", "") {
t.Error("leere Werte dürfen nie matchen")
}
}
func TestPatternWithSuffix(t *testing.T) {
m, err := NewMatcher("{username}@firma.de", "")
if err != nil {
t.Fatal(err)
}
if !m.Matches("mmueller@firma.de", "mmueller") {
t.Error("Template mit Suffix muss matchen")
}
if m.Matches("mmueller", "mmueller") {
t.Error("CN ohne Suffix darf bei diesem Template nicht matchen")
}
}
func TestRegexMatching(t *testing.T) {
// Regex mit {username}-Platzhalter: wird vor dem Kompilieren ersetzt und
// dabei quotiert, damit Sonderzeichen im Namen nicht zur Injection werden.
m, err := NewMatcher("", `^(vpn-)?{username}(-\d+)?$`)
if err != nil {
t.Fatal(err)
}
for _, cn := range []string{"mmueller", "vpn-mmueller", "mmueller-2"} {
if !m.Matches(cn, "mmueller") {
t.Errorf("CN %q muss matchen", cn)
}
}
for _, cn := range []string{"jdoe", "mmuellerX", "vpn-jdoe"} {
if m.Matches(cn, "mmueller") {
t.Errorf("CN %q darf nicht matchen", cn)
}
}
}
func TestRegexQuotesUsername(t *testing.T) {
m, err := NewMatcher("", `^{username}$`)
if err != nil {
t.Fatal(err)
}
// Ein Benutzername mit Regex-Metazeichen darf nicht als Muster wirken.
if m.Matches("mmueller", "m.*") {
t.Error("Benutzername muss vor dem Einsetzen quotiert werden")
}
if !m.Matches("m.*", "m.*") {
t.Error("literaler Vergleich muss weiterhin funktionieren")
}
}
func TestNewMatcherValidation(t *testing.T) {
if _, err := NewMatcher("", ""); err == nil {
t.Error("weder Pattern noch Regex muss abgelehnt werden")
}
if _, err := NewMatcher("{username}", "^x$"); err == nil {
t.Error("beides gleichzeitig muss abgelehnt werden")
}
if _, err := NewMatcher("", "([unbalanced"); err == nil {
t.Error("unkompilierbare Regex muss abgelehnt werden")
}
if _, err := NewMatcher("kein-platzhalter", ""); err == nil {
t.Error("Pattern ohne {username} muss abgelehnt werden")
}
}
func TestFilterExcludesRevokedAndExpired(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
m, _ := NewMatcher("{username}", "")
prov := opnsense.Provider{VPNID: "1", Name: "VPN Homeoffice"}
accounts := []opnsense.Account{
{RefID: "ok", CommonName: "mmueller", ValidTo: now.AddDate(1, 0, 0)},
{RefID: "revoked", CommonName: "mmueller", ValidTo: now.AddDate(1, 0, 0), Revoked: true},
{RefID: "expired", CommonName: "mmueller", ValidTo: now.AddDate(0, 0, -1)},
{RefID: "fremd", CommonName: "jdoe", ValidTo: now.AddDate(1, 0, 0)},
}
got := m.Filter("mmueller", prov, accounts, now)
if len(got) != 1 {
t.Fatalf("got %d Treffer, want 1: %+v", len(got), got)
}
if got[0].Account.RefID != "ok" {
t.Errorf("falscher Treffer: %+v", got[0])
}
if got[0].Provider.Name != "VPN Homeoffice" {
t.Errorf("Provider muss durchgereicht werden: %+v", got[0].Provider)
}
}
func TestTokenRoundTrip(t *testing.T) {
e := Entry{
Provider: opnsense.Provider{VPNID: "1"},
Account: opnsense.Account{RefID: "abc123"},
}
vpnID, refID, ok := ParseToken(e.Token())
if !ok || vpnID != "1" || refID != "abc123" {
t.Fatalf("ParseToken(%q) = %q,%q,%v", e.Token(), vpnID, refID, ok)
}
for _, bad := range []string{"", "nurEins", "a:b:c", ":x", "x:"} {
if _, _, ok := ParseToken(bad); ok {
t.Errorf("ParseToken(%q) darf nicht gelingen", bad)
}
}
}
func TestDescribeForAudit(t *testing.T) {
m, _ := NewMatcher("{username}@firma.de", "")
if got := m.Describe("mmueller"); got != "mmueller@firma.de" {
t.Errorf("Describe = %q", got)
}
rx, _ := NewMatcher("", `^{username}$`)
if got := rx.Describe("mmueller"); got != `regex:^mmueller$` {
t.Errorf("Describe = %q", got)
}
}
```
- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./internal/certmatch/ -v`
Expected: FAIL — `undefined: NewMatcher`
- [ ] **Step 3: `match.go` schreiben**
```go
// Package certmatch ordnet Firewall-Zertifikate einem Portalbenutzer zu.
package certmatch
import (
"errors"
"fmt"
"regexp"
"strings"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
)
// Placeholder ist der Platzhalter für den kanonischen Benutzernamen.
const Placeholder = "{username}"
// Matcher entscheidet, ob ein Zertifikats-CN zu einem Benutzer gehört.
// Entweder Template- oder Regex-Modus, nie beides.
type Matcher struct {
pattern string
regex string
}
// NewMatcher baut den Matcher aus der Konfiguration.
func NewMatcher(pattern, regex string) (*Matcher, error) {
pattern, regex = strings.TrimSpace(pattern), strings.TrimSpace(regex)
switch {
case pattern == "" && regex == "":
return nil, errors.New("certmatch: cn_pattern oder cn_regex muss gesetzt sein")
case pattern != "" && regex != "":
return nil, errors.New("certmatch: cn_pattern und cn_regex schließen sich aus")
case pattern != "":
if !strings.Contains(pattern, Placeholder) {
return nil, fmt.Errorf("certmatch: cn_pattern %q enthält keinen %s-Platzhalter", pattern, Placeholder)
}
return &Matcher{pattern: pattern}, nil
default:
// Probeweise kompilieren, damit Konfigurationsfehler beim Start auffallen.
if _, err := regexp.Compile(strings.ReplaceAll(regex, Placeholder, "x")); err != nil {
return nil, fmt.Errorf("certmatch: cn_regex ist nicht kompilierbar: %w", err)
}
return &Matcher{regex: regex}, nil
}
}
// expand setzt den Benutzernamen in Template bzw. Regex ein.
// Im Regex-Modus wird der Name quotiert, damit Metazeichen im Namen
// nicht als Muster wirken.
func (m *Matcher) expand(username string) string {
if m.regex != "" {
return strings.ReplaceAll(m.regex, Placeholder, regexp.QuoteMeta(username))
}
return strings.ReplaceAll(m.pattern, Placeholder, username)
}
// Matches vergleicht einen Zertifikats-CN mit dem Benutzernamen.
func (m *Matcher) Matches(cn, username string) bool {
cn, username = strings.TrimSpace(cn), strings.TrimSpace(username)
if cn == "" || username == "" {
return false
}
if m.regex != "" {
// (?i) macht den Vergleich unabhängig von der Schreibweise, analog zum
// Template-Modus. Kompilierfehler wurden in NewMatcher ausgeschlossen.
re, err := regexp.Compile("(?i)" + m.expand(username))
if err != nil {
return false
}
return re.MatchString(cn)
}
return strings.EqualFold(cn, m.expand(username))
}
// Describe liefert die für diesen Benutzer angewendete Regel — geht als Feld
// pattern ins Audit-Log, damit no_cert_found nachvollziehbar bleibt.
func (m *Matcher) Describe(username string) string {
if m.regex != "" {
return "regex:" + m.expand(username)
}
return m.expand(username)
}
// Entry ist ein für den Benutzer freigegebenes Zertifikat samt VPN-Instanz.
type Entry struct {
Provider opnsense.Provider
Account opnsense.Account
}
// Token ist der undurchsichtige Bezeichner für die Auswahl in der UI.
// Er ist ausdrücklich KEINE Autorisierung — vor jedem Download wird die
// Zuordnung serverseitig neu geprüft.
func (e Entry) Token() string {
return e.Provider.VPNID + ":" + e.Account.RefID
}
// ParseToken zerlegt einen Token wieder in seine Bestandteile.
func ParseToken(s string) (vpnID, refID string, ok bool) {
vpnID, refID, found := strings.Cut(s, ":")
if !found || vpnID == "" || refID == "" || strings.Contains(refID, ":") {
return "", "", false
}
return vpnID, refID, true
}
// Filter liefert alle Zertifikate einer Instanz, die dem Benutzer gehören,
// nicht revoziert und nicht abgelaufen sind.
func (m *Matcher) Filter(username string, provider opnsense.Provider,
accounts []opnsense.Account, now time.Time) []Entry {
var out []Entry
for _, a := range accounts {
if !m.Matches(a.CommonName, username) {
continue
}
if !a.IsUsable(now) {
continue
}
out = append(out, Entry{Provider: provider, Account: a})
}
return out
}
```
- [ ] **Step 4: Tests laufen lassen**
Run: `go test ./internal/certmatch/ -v`
Expected: PASS (8 Tests)
- [ ] **Step 5: Commit**
```bash
git add internal/certmatch
git commit -m "feat(certmatch): CN-Zuordnung per Template oder Regex mit Revoked-/Ablauf-Filter"
```
---
## Task 10: Session-Store mit CSRF-Token
**Files:**
- Create: `internal/web/session.go`
- Test: `internal/web/session_test.go`
**Interfaces:**
- Consumes: `auth.Identity` (Task 6)
- Produces:
- `type Session struct { Token string; CSRF string; Identity *auth.Identity; Created, Expires time.Time }`
- `type SessionStore struct { ... }`; `func NewSessionStore(ttl time.Duration, clock func() time.Time) *SessionStore`
- `func (s *SessionStore) Create(id *auth.Identity) (*Session, error)`
- `func (s *SessionStore) Get(token string) (*Session, bool)` — abgelaufene Sessions werden entfernt und liefern `false`
- `func (s *SessionStore) Destroy(token string)`
- `func (s *SessionStore) Count() int`
- `func (s *SessionStore) GC()` — entfernt abgelaufene Einträge
- `const SessionCookieName = "vpnportal_session"`
- `func (s *Session) ValidCSRF(token string) bool` — konstante Laufzeit
- [ ] **Step 1: Failing test schreiben**
`internal/web/session_test.go`:
```go
package web
import (
"testing"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
)
func testIdentity() *auth.Identity {
return &auth.Identity{Username: "mmueller", Groups: []string{"VPN-Users"}}
}
func TestCreateAndGetSession(t *testing.T) {
st := NewSessionStore(10*time.Minute, nil)
sess, err := st.Create(testIdentity())
if err != nil {
t.Fatalf("Create: %v", err)
}
if len(sess.Token) < 32 {
t.Errorf("Token %q ist zu kurz für 128 Bit Entropie", sess.Token)
}
if sess.CSRF == "" || sess.CSRF == sess.Token {
t.Error("CSRF-Token muss existieren und vom Session-Token verschieden sein")
}
got, ok := st.Get(sess.Token)
if !ok || got.Identity.Username != "mmueller" {
t.Fatalf("Get = %+v, ok=%v", got, ok)
}
}
func TestTokensAreUnique(t *testing.T) {
st := NewSessionStore(time.Minute, nil)
seen := map[string]bool{}
for i := 0; i < 200; i++ {
s, err := st.Create(testIdentity())
if err != nil {
t.Fatal(err)
}
if seen[s.Token] {
t.Fatalf("doppelter Token %q", s.Token)
}
seen[s.Token] = true
}
}
func TestSessionExpires(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
clock := func() time.Time { return now }
st := NewSessionStore(10*time.Minute, func() time.Time { return clock() })
sess, _ := st.Create(testIdentity())
now = now.Add(9 * time.Minute)
if _, ok := st.Get(sess.Token); !ok {
t.Fatal("Session darf nach 9 von 10 Minuten nicht abgelaufen sein")
}
now = now.Add(2 * time.Minute)
if _, ok := st.Get(sess.Token); ok {
t.Fatal("Session muss nach Ablauf der TTL ungültig sein")
}
if st.Count() != 0 {
t.Errorf("abgelaufene Session muss entfernt werden, Count = %d", st.Count())
}
}
func TestDestroyRemovesSession(t *testing.T) {
st := NewSessionStore(time.Minute, nil)
sess, _ := st.Create(testIdentity())
st.Destroy(sess.Token)
if _, ok := st.Get(sess.Token); ok {
t.Fatal("zerstörte Session darf nicht mehr auffindbar sein")
}
st.Destroy("existiert-nicht") // darf nicht paniken
}
func TestGetRejectsUnknownAndEmptyToken(t *testing.T) {
st := NewSessionStore(time.Minute, nil)
if _, ok := st.Get(""); ok {
t.Error("leerer Token darf nie gültig sein")
}
if _, ok := st.Get("beliebig"); ok {
t.Error("unbekannter Token darf nie gültig sein")
}
}
func TestValidCSRF(t *testing.T) {
st := NewSessionStore(time.Minute, nil)
sess, _ := st.Create(testIdentity())
if !sess.ValidCSRF(sess.CSRF) {
t.Error("eigenes CSRF-Token muss akzeptiert werden")
}
for _, bad := range []string{"", "falsch", sess.CSRF + "x", sess.CSRF[:len(sess.CSRF)-1]} {
if sess.ValidCSRF(bad) {
t.Errorf("CSRF-Token %q darf nicht akzeptiert werden", bad)
}
}
}
func TestGCRemovesOnlyExpired(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
st := NewSessionStore(10*time.Minute, func() time.Time { return now })
old, _ := st.Create(testIdentity())
now = now.Add(11 * time.Minute)
fresh, _ := st.Create(testIdentity())
st.GC()
if st.Count() != 1 {
t.Fatalf("Count = %d, want 1", st.Count())
}
if _, ok := st.Get(old.Token); ok {
t.Error("alte Session muss weg sein")
}
if _, ok := st.Get(fresh.Token); !ok {
t.Error("frische Session muss bleiben")
}
}
```
- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./internal/web/ -v`
Expected: FAIL — `undefined: NewSessionStore`
- [ ] **Step 3: `session.go` schreiben**
```go
// Package web enthält HTTP-Handler, Templates, Sessions und Schutzmechanismen.
package web
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
"sync"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
)
// SessionCookieName ist der Name des Session-Cookies.
const SessionCookieName = "vpnportal_session"
// tokenBytes ergibt nach Base64-URL-Kodierung 43 Zeichen bei 256 Bit Entropie.
const tokenBytes = 32
// Session ist ein angemeldeter Benutzer. Sessions existieren ausschließlich im
// RAM; ein Neustart meldet alle Benutzer ab.
type Session struct {
Token string
CSRF string
Identity *auth.Identity
Created time.Time
Expires time.Time
}
// ValidCSRF vergleicht in konstanter Zeit.
func (s *Session) ValidCSRF(token string) bool {
if token == "" || s.CSRF == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(s.CSRF), []byte(token)) == 1
}
// SessionStore hält Sessions im Speicher.
type SessionStore struct {
ttl time.Duration
clock func() time.Time
mu sync.Mutex
sessions map[string]*Session
}
// NewSessionStore erzeugt den Store. clock ist injizierbar; nil = time.Now.
func NewSessionStore(ttl time.Duration, clock func() time.Time) *SessionStore {
if clock == nil {
clock = time.Now
}
if ttl <= 0 {
ttl = 10 * time.Minute
}
return &SessionStore{ttl: ttl, clock: clock, sessions: make(map[string]*Session)}
}
// randomToken liefert einen kryptografisch zufälligen, URL-sicheren Token.
func randomToken() (string, error) {
buf := make([]byte, tokenBytes)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("Zufallszahlengenerator nicht verfügbar: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// Create legt eine neue Session an.
func (s *SessionStore) Create(id *auth.Identity) (*Session, error) {
token, err := randomToken()
if err != nil {
return nil, err
}
csrf, err := randomToken()
if err != nil {
return nil, err
}
now := s.clock()
sess := &Session{
Token: token,
CSRF: csrf,
Identity: id,
Created: now,
Expires: now.Add(s.ttl),
}
s.mu.Lock()
s.sessions[token] = sess
s.mu.Unlock()
return sess, nil
}
// Get liefert eine gültige Session. Abgelaufene Sessions werden entfernt.
// Die TTL ist absolut und wird bewusst nicht verlängert.
func (s *SessionStore) Get(token string) (*Session, bool) {
if token == "" {
return nil, false
}
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.sessions[token]
if !ok {
return nil, false
}
if !s.clock().Before(sess.Expires) {
delete(s.sessions, token)
return nil, false
}
return sess, true
}
// Destroy meldet eine Session ab.
func (s *SessionStore) Destroy(token string) {
s.mu.Lock()
delete(s.sessions, token)
s.mu.Unlock()
}
// Count liefert die Zahl gespeicherter Sessions (Tests, /healthz).
func (s *SessionStore) Count() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.sessions)
}
// GC entfernt abgelaufene Sessions; wird periodisch aufgerufen.
func (s *SessionStore) GC() {
now := s.clock()
s.mu.Lock()
defer s.mu.Unlock()
for token, sess := range s.sessions {
if !now.Before(sess.Expires) {
delete(s.sessions, token)
}
}
}
```
- [ ] **Step 4: Tests laufen lassen**
Run: `go test ./internal/web/ -v`
Expected: PASS (7 Tests)
- [ ] **Step 5: Commit**
```bash
git add internal/web/session.go internal/web/session_test.go
git commit -m "feat(web): RAM-Session-Store mit absoluter TTL und CSRF-Token"
```
---
## Task 11: Rate-Limiting mit exponentiellem Backoff
**Files:**
- Create: `internal/web/ratelimit.go`
- Test: `internal/web/ratelimit_test.go`
**Interfaces:**
- Consumes: nichts
- Produces:
- `type Limiter struct { ... }`; `func NewLimiter(clock func() time.Time) *Limiter`
- `func (l *Limiter) Allow(username, ip string) (retryAfter time.Duration, ok bool)` — prüft **beide** Schlüssel, ohne zu zählen
- `func (l *Limiter) RecordFailure(username, ip string)`
- `func (l *Limiter) RecordSuccess(username, ip string)` — setzt beide Zähler zurück
- `func (l *Limiter) GC()`
- Backoff-Kurve: die ersten `freeAttempts = 3` Versuche sind frei; danach `2^(n-3)` Sekunden, gedeckelt bei `maxBackoff = 5m`. Kein harter Lockout.
- [ ] **Step 1: Failing test schreiben**
`internal/web/ratelimit_test.go`:
```go
package web
import (
"testing"
"time"
)
func TestLimiterAllowsFirstAttempts(t *testing.T) {
l := NewLimiter(nil)
for i := 0; i < 3; i++ {
if _, ok := l.Allow("mmueller", "10.0.0.1"); !ok {
t.Fatalf("Versuch %d muss erlaubt sein", i+1)
}
l.RecordFailure("mmueller", "10.0.0.1")
}
if _, ok := l.Allow("mmueller", "10.0.0.1"); ok {
t.Fatal("nach 3 Fehlversuchen muss gebremst werden")
}
}
func TestBackoffGrowsAndExpires(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
l := NewLimiter(func() time.Time { return now })
for i := 0; i < 4; i++ {
l.RecordFailure("mmueller", "10.0.0.1")
}
wait1, ok := l.Allow("mmueller", "10.0.0.1")
if ok {
t.Fatal("nach 4 Fehlversuchen muss gebremst werden")
}
if wait1 <= 0 {
t.Fatalf("retryAfter = %v, muss positiv sein", wait1)
}
// Wartezeit absitzen -> wieder erlaubt.
now = now.Add(wait1 + time.Second)
if _, ok := l.Allow("mmueller", "10.0.0.1"); !ok {
t.Fatal("nach Ablauf der Wartezeit muss wieder erlaubt sein")
}
// Weiterer Fehlversuch -> längere Wartezeit als vorher.
l.RecordFailure("mmueller", "10.0.0.1")
wait2, ok := l.Allow("mmueller", "10.0.0.1")
if ok {
t.Fatal("erneuter Fehlversuch muss wieder bremsen")
}
if wait2 <= wait1 {
t.Errorf("Backoff muss wachsen: %v -> %v", wait1, wait2)
}
}
func TestBackoffIsCapped(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
l := NewLimiter(func() time.Time { return now })
for i := 0; i < 40; i++ {
l.RecordFailure("mmueller", "10.0.0.1")
}
wait, ok := l.Allow("mmueller", "10.0.0.1")
if ok {
t.Fatal("muss gebremst sein")
}
if wait > maxBackoff {
t.Errorf("retryAfter = %v, Deckel ist %v", wait, maxBackoff)
}
}
func TestIPLimitProtectsAgainstUserSpray(t *testing.T) {
l := NewLimiter(nil)
// Angreifer probiert viele verschiedene Benutzernamen von einer IP.
for i := 0; i < 12; i++ {
l.RecordFailure(string(rune('a'+i))+"user", "10.0.0.9")
}
if _, ok := l.Allow("nochNieProbiert", "10.0.0.9"); ok {
t.Fatal("die IP muss trotz wechselnder Benutzernamen gebremst werden")
}
}
func TestUserLimitProtectsAcrossIPs(t *testing.T) {
l := NewLimiter(nil)
// Verteilter Angriff auf einen Benutzer von vielen IPs.
for i := 0; i < 12; i++ {
l.RecordFailure("mmueller", "10.0.1."+string(rune('0'+i%10)))
}
if _, ok := l.Allow("mmueller", "192.168.99.99"); ok {
t.Fatal("der Benutzer muss auch von einer neuen IP gebremst werden")
}
}
func TestSuccessResetsBothCounters(t *testing.T) {
l := NewLimiter(nil)
for i := 0; i < 5; i++ {
l.RecordFailure("mmueller", "10.0.0.1")
}
if _, ok := l.Allow("mmueller", "10.0.0.1"); ok {
t.Fatal("Vorbedingung: muss gebremst sein")
}
l.RecordSuccess("mmueller", "10.0.0.1")
if _, ok := l.Allow("mmueller", "10.0.0.1"); !ok {
t.Fatal("erfolgreiche Anmeldung muss die Zähler zurücksetzen")
}
}
func TestNoHardLockout(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
l := NewLimiter(func() time.Time { return now })
for i := 0; i < 100; i++ {
l.RecordFailure("opfer", "10.0.0.1")
}
// Nach dem gedeckelten Backoff darf der Benutzer wieder anklopfen —
// sonst könnte ein Dritter Kollegen dauerhaft aussperren.
now = now.Add(maxBackoff + time.Second)
if _, ok := l.Allow("opfer", "10.0.0.1"); !ok {
t.Fatal("es darf keinen dauerhaften Lockout geben")
}
}
func TestGCDropsIdleEntries(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
l := NewLimiter(func() time.Time { return now })
l.RecordFailure("mmueller", "10.0.0.1")
now = now.Add(2 * entryTTL)
l.GC()
if n := l.size(); n != 0 {
t.Fatalf("GC muss veraltete Einträge entfernen, size = %d", n)
}
}
```
- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./internal/web/ -run 'TestLimiter|TestBackoff|TestIPLimit|TestUserLimit|TestSuccess|TestNoHard|TestGCDrops' -v`
Expected: FAIL — `undefined: NewLimiter`
- [ ] **Step 3: `ratelimit.go` schreiben**
```go
package web
import (
"math"
"strings"
"sync"
"time"
)
const (
// freeAttempts sind die Fehlversuche ohne Wartezeit — genug für Tippfehler.
freeAttempts = 3
// maxBackoff deckelt die Wartezeit. Ein harter Lockout wäre selbst eine
// Denial-of-Service-Möglichkeit gegen Kollegen.
maxBackoff = 5 * time.Minute
// entryTTL räumt ruhende Zähler ab.
entryTTL = 1 * time.Hour
)
type limitEntry struct {
failures int
lastFail time.Time
}
// Limiter bremst Anmeldeversuche pro Benutzername und pro Quell-IP.
// Beide Schlüssel werden geführt: die IP fängt Passwort-Spraying ab, der
// Benutzername verteilte Angriffe hinter NAT.
type Limiter struct {
clock func() time.Time
mu sync.Mutex
entries map[string]*limitEntry
}
func NewLimiter(clock func() time.Time) *Limiter {
if clock == nil {
clock = time.Now
}
return &Limiter{clock: clock, entries: make(map[string]*limitEntry)}
}
// backoff berechnet die Wartezeit nach n Fehlversuchen.
func backoff(failures int) time.Duration {
if failures <= freeAttempts {
return 0
}
exp := failures - freeAttempts
if exp > 20 { // schützt vor Overflow in math.Pow
return maxBackoff
}
d := time.Duration(math.Pow(2, float64(exp))) * time.Second
if d > maxBackoff {
return maxBackoff
}
return d
}
// keys liefert die beiden Zählerschlüssel. Benutzernamen werden
// normalisiert, damit "MMueller" und "mmueller" derselbe Zähler sind.
func keys(username, ip string) []string {
var out []string
if u := strings.ToLower(strings.TrimSpace(username)); u != "" {
out = append(out, "u:"+u)
}
if ip != "" {
out = append(out, "i:"+ip)
}
return out
}
// Allow meldet, ob ein Versuch erlaubt ist, und andernfalls die Restwartezeit.
// Allow zählt nicht mit — dafür ist RecordFailure zuständig.
func (l *Limiter) Allow(username, ip string) (time.Duration, bool) {
now := l.clock()
l.mu.Lock()
defer l.mu.Unlock()
var worst time.Duration
for _, k := range keys(username, ip) {
e, ok := l.entries[k]
if !ok {
continue
}
wait := backoff(e.failures) - now.Sub(e.lastFail)
if wait > worst {
worst = wait
}
}
if worst > 0 {
return worst, false
}
return 0, true
}
// RecordFailure erhöht beide Zähler.
func (l *Limiter) RecordFailure(username, ip string) {
now := l.clock()
l.mu.Lock()
defer l.mu.Unlock()
for _, k := range keys(username, ip) {
e, ok := l.entries[k]
if !ok {
e = &limitEntry{}
l.entries[k] = e
}
e.failures++
e.lastFail = now
}
}
// RecordSuccess setzt beide Zähler zurück.
func (l *Limiter) RecordSuccess(username, ip string) {
l.mu.Lock()
defer l.mu.Unlock()
for _, k := range keys(username, ip) {
delete(l.entries, k)
}
}
// GC entfernt Zähler, die lange nicht mehr angefasst wurden.
func (l *Limiter) GC() {
now := l.clock()
l.mu.Lock()
defer l.mu.Unlock()
for k, e := range l.entries {
if now.Sub(e.lastFail) > entryTTL {
delete(l.entries, k)
}
}
}
// size ist ein Testhelfer.
func (l *Limiter) size() int {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.entries)
}
```
- [ ] **Step 4: Tests laufen lassen**
Run: `go test ./internal/web/ -v`
Expected: PASS (alle Session- und Limiter-Tests)
- [ ] **Step 5: Commit**
```bash
git add internal/web/ratelimit.go internal/web/ratelimit_test.go
git commit -m "feat(web): Rate-Limiting pro Benutzer und IP mit gedeckeltem exponentiellem Backoff"
```
---
## Task 12: Strings, Security-Header und Template-Rendering
**Files:**
- Create: `internal/web/strings.go`, `internal/web/security.go`, `internal/web/render.go`
- Create: `internal/web/templates/layout.html`, `login.html`, `overview.html`, `guides.html`, `error.html`
- Create: `internal/web/assets/style.css`, `internal/web/assets/app.js`
- Test: `internal/web/security_test.go`, `internal/web/render_test.go`
**Interfaces:**
- Consumes: nichts
- Produces:
- `var S = map[string]string{...}` — alle deutschen Texte, Schlüssel in `snake_case`
- `func T(key string) string` — liefert `S[key]`, bei fehlendem Schlüssel den Schlüssel selbst (nie leer)
- `func SecurityHeaders(next http.Handler) http.Handler` — setzt HSTS, `nosniff`, CSP, `Referrer-Policy`, `X-Frame-Options`
- `func NoStore(w http.ResponseWriter)``Cache-Control: no-store`, `Pragma: no-cache`
- `func ClientIP(r *http.Request) string``RemoteAddr` ohne Port; **kein** Vertrauen in `X-Forwarded-For`
- `type Renderer struct { ... }`; `func NewRenderer(brand Brand) (*Renderer, error)`
- `type Brand struct { Title, LogoPath, SupportContact, Version string; HasLogo bool }`
- `func (rn *Renderer) Render(w http.ResponseWriter, r *http.Request, status int, page string, data PageData)`
- `func (rn *Renderer) Brand() Brand`
- `type PageData struct { Brand Brand; Flash string; FlashKind string; CSRF string; User string; Data any }`
- `var AssetsFS embed.FS` (Assets), `var templateFS embed.FS`
- [ ] **Step 1: Failing test für Header und Strings schreiben**
`internal/web/security_test.go`:
```go
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSecurityHeaders(t *testing.T) {
h := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
want := map[string]string{
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"X-Frame-Options": "DENY",
}
for k, v := range want {
if got := rec.Header().Get(k); got != v {
t.Errorf("%s = %q, want %q", k, got, v)
}
}
if hsts := rec.Header().Get("Strict-Transport-Security"); !strings.Contains(hsts, "max-age=") {
t.Errorf("Strict-Transport-Security = %q", hsts)
}
csp := rec.Header().Get("Content-Security-Policy")
for _, directive := range []string{"default-src 'self'", "frame-ancestors 'none'", "form-action 'self'"} {
if !strings.Contains(csp, directive) {
t.Errorf("CSP %q fehlt %q", csp, directive)
}
}
if strings.Contains(csp, "unsafe-inline") || strings.Contains(csp, "unsafe-eval") {
t.Errorf("CSP darf kein unsafe-* enthalten: %q", csp)
}
}
func TestNoStore(t *testing.T) {
rec := httptest.NewRecorder()
NoStore(rec)
if got := rec.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
t.Errorf("Cache-Control = %q", got)
}
}
func TestClientIPIgnoresForwardedHeader(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "10.1.20.34:51234"
r.Header.Set("X-Forwarded-For", "1.2.3.4")
// Das Portal terminiert TLS selbst; ein gefälschter Header darf das
// Audit-Log nicht verfälschen.
if got := ClientIP(r); got != "10.1.20.34" {
t.Errorf("ClientIP = %q, want 10.1.20.34", got)
}
}
func TestClientIPHandlesIPv6AndMalformed(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "[2001:db8::1]:443"
if got := ClientIP(r); got != "2001:db8::1" {
t.Errorf("ClientIP = %q", got)
}
r.RemoteAddr = "kaputt"
if got := ClientIP(r); got != "kaputt" {
t.Errorf("ClientIP = %q, malformed muss unverändert durchgereicht werden", got)
}
}
func TestStringsAreComplete(t *testing.T) {
required := []string{
"login_title", "login_username", "login_password", "login_submit",
"login_username_hint", "error_generic_login", "error_password_expired",
"error_rate_limited", "error_session_expired", "overview_title",
"overview_no_certs", "overview_expiry_warning", "download_ovpn",
"download_viscosity", "logout", "guides_title", "error_service_unavailable",
"error_forbidden", "logout_reminder", "valid_until", "vpn_instance",
}
for _, k := range required {
v, ok := S[k]
if !ok || strings.TrimSpace(v) == "" {
t.Errorf("String %q fehlt oder ist leer", k)
}
}
if T("gibt_es_nicht") != "gibt_es_nicht" {
t.Error("T muss bei unbekanntem Schlüssel den Schlüssel liefern, nie einen leeren String")
}
}
```
- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./internal/web/ -run 'TestSecurity|TestNoStore|TestClientIP|TestStrings' -v`
Expected: FAIL — `undefined: SecurityHeaders`
- [ ] **Step 3: `strings.go` schreiben**
```go
package web
// S enthält alle Texte der Oberfläche. Die Map-Struktur ist bewusst
// vorbereitet für spätere Mehrsprachigkeit; v1 füllt nur Deutsch.
var S = map[string]string{
"login_title": "Anmeldung",
"login_username": "Benutzername",
"login_password": "Passwort",
"login_submit": "Anmelden",
"login_username_hint": "z. B. mmueller oder m.mueller@firma.de",
"login_show_password": "Passwort anzeigen",
"error_generic_login": "Anmeldung fehlgeschlagen. Bitte prüfen Sie Benutzername und Passwort.",
"error_password_expired": "Ihr Passwort ist abgelaufen. Bitte ändern Sie es zuerst an Ihrem Arbeitsplatz.",
"error_rate_limited": "Zu viele Anmeldeversuche. Bitte versuchen Sie es in %s erneut.",
"error_session_expired": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
"error_service_unavailable": "Dienst vorübergehend nicht verfügbar",
"error_service_unavailable_body": "Die Firewall ist derzeit nicht erreichbar. " +
"Bitte versuchen Sie es in einigen Minuten erneut.",
"error_forbidden": "Zugriff verweigert",
"error_forbidden_body": "Diese Anfrage ist nicht zulässig. Bitte starten Sie den Download erneut über die Übersicht.",
"error_not_found": "Seite nicht gefunden",
"error_csrf": "Die Anfrage ist abgelaufen oder ungültig. Bitte versuchen Sie es erneut.",
"overview_title": "Ihre VPN-Konfigurationen",
"overview_intro": "Laden Sie hier Ihre persönliche OpenVPN-Konfiguration herunter.",
"overview_no_certs": "Für Ihr Benutzerkonto ist derzeit keine VPN-Konfiguration hinterlegt.",
"overview_no_certs_contact": "Bitte wenden Sie sich an %s.",
"overview_expiry_warning": "Achtung: Dieses Zertifikat läuft in %d Tagen ab.",
"overview_expired_today": "Achtung: Dieses Zertifikat läuft heute ab.",
"vpn_instance": "VPN-Verbindung",
"common_name": "Zertifikat",
"valid_until": "Gültig bis",
"download_ovpn": "OpenVPN-Datei (.ovpn)",
"download_viscosity": "Viscosity-Paket",
"logout": "Abmelden",
"logout_reminder": "Bitte melden Sie sich nach dem Herunterladen ab.",
"logout_done": "Sie wurden abgemeldet.",
"guides_title": "Anleitungen",
"guides_intro": "Kurzanleitungen zum Einrichten der VPN-Verbindung auf Ihrem Gerät.",
"guides_link": "Anleitungen",
"footer_version": "Version",
}
// T liefert den Text zu einem Schlüssel. Fehlt der Schlüssel, wird er selbst
// zurückgegeben — eine leere Fläche in der Oberfläche wäre schlimmer.
func T(key string) string {
if v, ok := S[key]; ok {
return v
}
return key
}
```
- [ ] **Step 4: `security.go` schreiben**
```go
package web
import (
"net"
"net/http"
"strings"
)
// contentSecurityPolicy ist restriktiv möglich, weil sämtliche Assets
// eingebettet und gleich-origin ausgeliefert werden.
const contentSecurityPolicy = "default-src 'self'; " +
"script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; " +
"connect-src 'none'; object-src 'none'; base-uri 'none'; " +
"form-action 'self'; frame-ancestors 'none'"
// SecurityHeaders setzt die Sicherheits-Header auf jede Antwort.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Frame-Options", "DENY")
h.Set("Content-Security-Policy", contentSecurityPolicy)
next.ServeHTTP(w, r)
})
}
// NoStore verhindert jegliches Zwischenspeichern.
func NoStore(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
}
// ClientIP liefert die Quell-IP der Verbindung. X-Forwarded-For wird bewusst
// ignoriert: das Portal terminiert TLS selbst, ein gesetzter Header wäre
// fälschbar und würde das Audit-Log entwerten.
func ClientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return strings.TrimSpace(r.RemoteAddr)
}
return host
}
```
- [ ] **Step 5: Tests laufen lassen**
Run: `go test ./internal/web/ -run 'TestSecurity|TestNoStore|TestClientIP|TestStrings' -v`
Expected: PASS
- [ ] **Step 6: Templates anlegen**
`internal/web/templates/layout.html`:
```html
{{define "layout"}}<!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`:
```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`:
```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`:
```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`:
```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`:
```js
// Optionale Bequemlichkeit: Passwort-Anzeigen-Schalter.
// Die Anmeldung funktioniert vollständig ohne JavaScript; der Schalter ist
// im Markup als hidden angelegt und wird erst hier eingeblendet.
(function () {
"use strict";
var wrap = document.getElementById("toggle-wrap");
var toggle = document.getElementById("show-password");
var field = document.getElementById("password");
if (!wrap || !toggle || !field) return;
wrap.hidden = false;
toggle.addEventListener("change", function () {
field.type = toggle.checked ? "text" : "password";
});
})();
```
- [ ] **Step 8: Failing test für den Renderer schreiben**
`internal/web/render_test.go`:
```go
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func testRenderer(t *testing.T) *Renderer {
t.Helper()
rn, err := NewRenderer(Brand{Title: "VPN-Portal Firma GmbH", Version: "1.2.3",
SupportContact: "it@firma.de"})
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
return rn
}
func TestRenderLoginPage(t *testing.T) {
rn := testRenderer(t)
rec := httptest.NewRecorder()
rn.Render(rec, httptest.NewRequest(http.MethodGet, "/login", nil), http.StatusOK,
"login", PageData{Brand: rn.brand})
if rec.Code != http.StatusOK {
t.Fatalf("Code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"<!DOCTYPE html>", `lang="de"`, "VPN-Portal Firma GmbH",
S["login_submit"], S["login_username_hint"], "1.2.3",
`name="password"`, `type="password"`,
} {
if !strings.Contains(body, want) {
t.Errorf("Login-Seite enthält %q nicht", want)
}
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("Content-Type = %q", ct)
}
}
func TestRenderEscapesUserContent(t *testing.T) {
rn := testRenderer(t)
rec := httptest.NewRecorder()
rn.Render(rec, httptest.NewRequest(http.MethodGet, "/", nil), http.StatusOK,
"error", PageData{
Brand: rn.brand,
Flash: `<script>alert(1)</script>`,
Data: errorPageData{Title: "T", Message: `"><img src=x onerror=alert(1)>`},
})
body := rec.Body.String()
if strings.Contains(body, "<script>alert(1)</script>") {
t.Error("Flash-Text wurde nicht escapt")
}
if strings.Contains(body, "onerror=alert(1)>") {
t.Error("Fehlermeldung wurde nicht escapt")
}
}
func TestRenderSetsStatusCode(t *testing.T) {
rn := testRenderer(t)
rec := httptest.NewRecorder()
rn.Render(rec, httptest.NewRequest(http.MethodGet, "/", nil), http.StatusServiceUnavailable,
"error", PageData{Brand: rn.brand, Data: errorPageData{Title: "x", Message: "y"}})
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("Code = %d, want 503", rec.Code)
}
}
func TestAllTemplatesParse(t *testing.T) {
rn := testRenderer(t)
for _, page := range []string{"login", "overview", "guides", "error"} {
if rn.pages[page] == nil {
t.Errorf("Template %q wurde nicht geladen", page)
}
}
}
```
- [ ] **Step 9: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./internal/web/ -run TestRender -v`
Expected: FAIL — `undefined: NewRenderer`
- [ ] **Step 10: `render.go` schreiben**
```go
package web
import (
"bytes"
"embed"
"fmt"
"html/template"
"net/http"
)
//go:embed templates/*.html
var templateFS embed.FS
// AssetsFS enthält CSS und JavaScript; wird vom Asset-Handler ausgeliefert.
//
//go:embed assets
var AssetsFS embed.FS
// Brand bündelt die White-Label-Angaben.
type Brand struct {
Title string
SupportContact string
Version string
HasLogo bool
}
// PageData ist das Wurzelobjekt jedes Templates.
type PageData struct {
Brand Brand
Flash string
FlashKind string // "error" oder "info"
CSRF string
User string
Data any
}
// errorPageData füllt error.html.
type errorPageData struct {
Title string
Message string
ShowLoginLink bool
}
// Renderer hält die geparsten Templates.
type Renderer struct {
brand Brand
pages map[string]*template.Template
}
// pageNames sind die Inhaltstemplates, die jeweils mit dem Layout kombiniert werden.
var pageNames = []string{"login", "overview", "guides", "error"}
// NewRenderer parst alle Templates beim Start; ein Fehler bricht den Start ab.
func NewRenderer(brand Brand) (*Renderer, error) {
funcs := template.FuncMap{"t": T}
pages := make(map[string]*template.Template, len(pageNames))
for _, name := range pageNames {
tmpl, err := template.New("layout.html").Funcs(funcs).
ParseFS(templateFS, "templates/layout.html", "templates/"+name+".html")
if err != nil {
return nil, fmt.Errorf("Template %s: %w", name, err)
}
pages[name] = tmpl
}
return &Renderer{brand: brand, pages: pages}, nil
}
// Brand liefert die White-Label-Angaben für Handler.
func (rn *Renderer) Brand() Brand { return rn.brand }
// Render schreibt eine Seite. Es wird zuerst in einen Puffer gerendert, damit
// ein Template-Fehler nicht zu einer halb geschriebenen Antwort führt.
func (rn *Renderer) Render(w http.ResponseWriter, r *http.Request, status int, page string, data PageData) {
tmpl, ok := rn.pages[page]
if !ok {
http.Error(w, T("error_service_unavailable"), http.StatusInternalServerError)
return
}
if data.Brand.Title == "" {
data.Brand = rn.brand
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil {
http.Error(w, T("error_service_unavailable"), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
w.Write(buf.Bytes())
}
```
Anpassung im Test: `PageData` statt `any` als Parametertyp — der Test in Step 8 übergibt bereits `PageData`, und der Zugriff auf `rn.brand` funktioniert, weil Test und Code im selben Package liegen.
- [ ] **Step 11: Tests laufen lassen**
Run: `go test ./internal/web/ -v`
Expected: PASS
- [ ] **Step 12: Manuelle Sichtprüfung der gerenderten Seite**
```bash
cat > /tmp/render_dump_test.go <<'EOF'
EOF
go test ./internal/web/ -run TestRenderLoginPage -v
```
Expected: PASS. Die Seite wird in Task 17 im laufenden Server visuell geprüft.
- [ ] **Step 13: Commit**
```bash
git add internal/web
git commit -m "feat(web): Templates, eingebettete Assets, Security-Header und zentrale Textmap"
```
---
## Task 13: Server, Router und Login-Handler
**Files:**
- Create: `internal/web/server.go`, `internal/web/handlers_login.go`
- Test: `internal/web/handlers_login_test.go`
**Interfaces:**
- Consumes: `auth.Authenticator`, `auth.Error` (Task 6/7), `audit.Logger` (Task 5), `SessionStore` (Task 10), `Limiter` (Task 11), `Renderer` (Task 12)
- Produces:
- `type Deps struct { Auth auth.Authenticator; Certs CertSource; Audit AuditSink; Sessions *SessionStore; Limiter *Limiter; Renderer *Renderer; Clock func() time.Time; LogoPath string; SupportContact string; Health HealthChecker }`
- `type AuditSink interface { Log(audit.Event) }` — erlaubt Test-Doubles
- `type Server struct { ... }`; `func NewServer(d Deps) (*Server, error)`; `func (s *Server) Handler() http.Handler`
- `func (s *Server) requireSession(next func(http.ResponseWriter, *http.Request, *Session)) http.HandlerFunc`
- Routen: `GET /login`, `POST /login`, `POST /logout`, `GET /`, `POST /download`, `GET /anleitungen`, `GET /healthz`, `GET /assets/{file}`
- Cookie: `HttpOnly`, `Secure`, `SameSite=Strict`, `Path=/`, `MaxAge` = Session-TTL
- [ ] **Step 1: Failing test schreiben**
`internal/web/handlers_login_test.go`:
```go
package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
)
// fakeAuth ist ein Authenticator-Double.
type fakeAuth struct {
id *auth.Identity
err error
// calls zählt Aufrufe, um Rate-Limiting-Verhalten zu prüfen.
calls int
}
func (f *fakeAuth) Authenticate(ctx context.Context, u, p string) (*auth.Identity, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.id, nil
}
// recordAudit sammelt Events für Assertions.
type recordAudit struct{ events []audit.Event }
func (r *recordAudit) Log(e audit.Event) { r.events = append(r.events, e) }
func (r *recordAudit) find(name string) (audit.Event, bool) {
for _, e := range r.events {
if e.Event == name {
return e, true
}
}
return audit.Event{}, false
}
// fakeCerts ist eine CertSource ohne Firewall.
type fakeCerts struct {
entries []CertEntry
err error
}
func (f *fakeCerts) EntriesFor(ctx context.Context, username string) ([]CertEntry, error) {
return f.entries, f.err
}
func (f *fakeCerts) Export(ctx context.Context, username, vpnID, refID, format string) (*ExportStream, error) {
return nil, ErrCertNotOwned
}
func newTestServer(t *testing.T, d Deps) (*Server, *recordAudit) {
t.Helper()
rec := &recordAudit{}
if d.Audit == nil {
d.Audit = rec
} else if r, ok := d.Audit.(*recordAudit); ok {
rec = r
}
if d.Sessions == nil {
d.Sessions = NewSessionStore(10*time.Minute, d.Clock)
}
if d.Limiter == nil {
d.Limiter = NewLimiter(d.Clock)
}
if d.Certs == nil {
d.Certs = &fakeCerts{}
}
if d.Renderer == nil {
rn, err := NewRenderer(Brand{Title: "Testportal", Version: "test"})
if err != nil {
t.Fatal(err)
}
d.Renderer = rn
}
srv, err := NewServer(d)
if err != nil {
t.Fatalf("NewServer: %v", err)
}
return srv, rec
}
func postForm(h http.Handler, path string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.RemoteAddr = "10.1.20.34:5000"
for _, c := range cookies {
r.AddCookie(c)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
return rec
}
func sessionCookie(rec *httptest.ResponseRecorder) *http.Cookie {
for _, c := range rec.Result().Cookies() {
if c.Name == SessionCookieName {
return c
}
}
return nil
}
func TestLoginGETRendersForm(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
if rec.Code != http.StatusOK {
t.Fatalf("Code = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), S["login_submit"]) {
t.Error("Anmeldeformular fehlt")
}
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control = %q", cc)
}
}
func TestLoginSuccessSetsHardenedCookieAndRedirects(t *testing.T) {
fa := &fakeAuth{id: &auth.Identity{Username: "mmueller", Groups: []string{"VPN-Users"}}}
srv, rec := newTestServer(t, Deps{Auth: fa})
resp := postForm(srv.Handler(), "/login",
url.Values{"username": {"MMueller"}, "password": {"geheim"}})
if resp.Code != http.StatusSeeOther {
t.Fatalf("Code = %d, want 303", resp.Code)
}
if loc := resp.Header().Get("Location"); loc != "/" {
t.Errorf("Location = %q", loc)
}
c := sessionCookie(resp)
if c == nil {
t.Fatal("Session-Cookie fehlt")
}
if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteStrictMode || c.Path != "/" {
t.Errorf("Cookie nicht gehärtet: %+v", c)
}
ev, ok := rec.find(audit.EventLoginSuccess)
if !ok {
t.Fatal("login_success fehlt im Audit-Log")
}
if ev.User != "mmueller" || ev.SrcIP != "10.1.20.34" || ev.Session == "" {
t.Errorf("Audit-Event unvollständig: %+v", ev)
}
if strings.Contains(ev.Session, c.Value) {
t.Error("Session-Feld darf keinen Teil des echten Tokens enthalten")
}
}
func TestLoginFailureShowsGenericMessage(t *testing.T) {
fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonAccountDisabled}}
srv, rec := newTestServer(t, Deps{Auth: fa})
resp := postForm(srv.Handler(), "/login",
url.Values{"username": {"mmueller"}, "password": {"x"}})
if resp.Code != http.StatusUnauthorized {
t.Fatalf("Code = %d, want 401", resp.Code)
}
body := resp.Body.String()
if !strings.Contains(body, S["error_generic_login"]) {
t.Error("generische Fehlermeldung fehlt")
}
if strings.Contains(body, "deaktiviert") || strings.Contains(body, auth.ReasonAccountDisabled) {
t.Error("der Grund darf dem Benutzer nicht verraten werden")
}
ev, ok := rec.find(audit.EventLoginFailed)
if !ok || ev.Reason != auth.ReasonAccountDisabled {
t.Errorf("Audit-Reason = %+v", ev)
}
}
func TestExpiredPasswordIsTheOnlySpecificMessage(t *testing.T) {
fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonPasswordExpired}}
srv, _ := newTestServer(t, Deps{Auth: fa})
resp := postForm(srv.Handler(), "/login",
url.Values{"username": {"mmueller"}, "password": {"x"}})
if !strings.Contains(resp.Body.String(), S["error_password_expired"]) {
t.Error("bei abgelaufenem Passwort muss die spezifische Meldung erscheinen")
}
}
func TestUnknownUserIsNeverLoggedInClear(t *testing.T) {
fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonUserNotFound}}
srv, rec := newTestServer(t, Deps{Auth: fa})
// Klassischer Unfall: Passwort im Benutzernamenfeld.
secret := "MeinGeheimesPasswort123"
postForm(srv.Handler(), "/login", url.Values{"username": {secret}, "password": {"x"}})
ev, ok := rec.find(audit.EventLoginFailed)
if !ok {
t.Fatal("login_failed fehlt")
}
if ev.User != audit.UnknownUser {
t.Errorf("User = %q, want %q", ev.User, audit.UnknownUser)
}
for _, e := range rec.events {
if strings.Contains(e.User+e.Detail+e.Reason, secret) {
t.Fatalf("Eingabe aus dem Benutzerfeld ist im Log gelandet: %+v", e)
}
}
}
func TestPasswordNeverAppearsInAnyEvent(t *testing.T) {
fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonInvalidCredentials}}
srv, rec := newTestServer(t, Deps{Auth: fa})
const pw = "Sup3rGeheim!"
postForm(srv.Handler(), "/login", url.Values{"username": {"mmueller"}, "password": {pw}})
for _, e := range rec.events {
if strings.Contains(e.User+e.Detail+e.Reason+e.Format+e.Pattern, pw) {
t.Fatalf("Passwort im Audit-Log: %+v", e)
}
}
}
func TestRateLimitBlocksAndLogs(t *testing.T) {
fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonInvalidCredentials}}
srv, rec := newTestServer(t, Deps{Auth: fa})
form := url.Values{"username": {"mmueller"}, "password": {"falsch"}}
for i := 0; i < 4; i++ {
postForm(srv.Handler(), "/login", form)
}
before := fa.calls
resp := postForm(srv.Handler(), "/login", form)
if resp.Code != http.StatusTooManyRequests {
t.Fatalf("Code = %d, want 429", resp.Code)
}
if fa.calls != before {
t.Error("bei Rate-Limit darf das Verzeichnis nicht mehr befragt werden")
}
if resp.Header().Get("Retry-After") == "" {
t.Error("Retry-After-Header fehlt")
}
if _, ok := rec.find(audit.EventRateLimited); !ok {
t.Error("rate_limited fehlt im Audit-Log")
}
}
func TestLogoutDestroysSession(t *testing.T) {
fa := &fakeAuth{id: &auth.Identity{Username: "mmueller"}}
store := NewSessionStore(10*time.Minute, nil)
srv, rec := newTestServer(t, Deps{Auth: fa, Sessions: store})
login := postForm(srv.Handler(), "/login", url.Values{"username": {"m"}, "password": {"p"}})
cookie := sessionCookie(login)
sess, ok := store.Get(cookie.Value)
if !ok {
t.Fatal("Session muss existieren")
}
out := postForm(srv.Handler(), "/logout", url.Values{"csrf_token": {sess.CSRF}}, cookie)
if out.Code != http.StatusSeeOther {
t.Fatalf("Code = %d, want 303", out.Code)
}
if _, ok := store.Get(cookie.Value); ok {
t.Fatal("Session muss zerstört sein")
}
if _, ok := rec.find(audit.EventLogout); !ok {
t.Error("logout fehlt im Audit-Log")
}
}
func TestLogoutRequiresCSRF(t *testing.T) {
fa := &fakeAuth{id: &auth.Identity{Username: "mmueller"}}
store := NewSessionStore(10*time.Minute, nil)
srv, _ := newTestServer(t, Deps{Auth: fa, Sessions: store})
login := postForm(srv.Handler(), "/login", url.Values{"username": {"m"}, "password": {"p"}})
cookie := sessionCookie(login)
out := postForm(srv.Handler(), "/logout", url.Values{"csrf_token": {"falsch"}}, cookie)
if out.Code != http.StatusForbidden {
t.Fatalf("Code = %d, want 403", out.Code)
}
if _, ok := store.Get(cookie.Value); !ok {
t.Error("bei CSRF-Fehler darf die Session nicht zerstört werden")
}
}
func TestProtectedPageRedirectsWithoutSession(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusSeeOther {
t.Fatalf("Code = %d, want 303", rec.Code)
}
if loc := rec.Header().Get("Location"); !strings.HasPrefix(loc, "/login") {
t.Errorf("Location = %q", loc)
}
}
func TestExpiredSessionRedirectsWithHint(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
clock := func() time.Time { return now }
store := NewSessionStore(10*time.Minute, clock)
fa := &fakeAuth{id: &auth.Identity{Username: "mmueller"}}
srv, rec := newTestServer(t, Deps{Auth: fa, Sessions: store, Clock: clock})
login := postForm(srv.Handler(), "/login", url.Values{"username": {"m"}, "password": {"p"}})
cookie := sessionCookie(login)
now = now.Add(11 * time.Minute)
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(cookie)
out := httptest.NewRecorder()
srv.Handler().ServeHTTP(out, r)
if out.Code != http.StatusSeeOther {
t.Fatalf("Code = %d, want 303", out.Code)
}
if loc := out.Header().Get("Location"); !strings.Contains(loc, "expired") {
t.Errorf("Location = %q, muss den Ablaufhinweis tragen", loc)
}
if _, ok := rec.find(audit.EventSessionExpired); !ok {
t.Error("session_expired fehlt im Audit-Log")
}
}
func TestLoginPageShowsExpiredHint(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login?expired=1", nil))
if !strings.Contains(rec.Body.String(), S["error_session_expired"]) {
t.Error("Ablaufhinweis fehlt auf der Anmeldeseite")
}
}
```
- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./internal/web/ -run 'TestLogin|TestLogout|TestProtected|TestExpired|TestRateLimit|TestUnknownUser|TestPassword' -v`
Expected: FAIL — `undefined: NewServer`
- [ ] **Step 3: `server.go` schreiben**
```go
package web
import (
"context"
"errors"
"io"
"io/fs"
"net/http"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
)
// AuditSink ist die Log-Senke; als Interface, damit Tests mitschreiben können.
type AuditSink interface {
Log(audit.Event)
}
// CertEntry ist ein für den Benutzer freigegebenes Zertifikat, aufbereitet
// für die Oberfläche.
type CertEntry struct {
Token string
InstanceName string
CommonName string
ValidTo time.Time
}
// ExportStream ist eine zum Download bereite Konfiguration.
// Body muss vom Aufrufer geschlossen werden.
type ExportStream struct {
Filename string
ContentType string
Body io.ReadCloser
InstanceName string
CommonName string
ValidTo time.Time
}
// ErrCertNotOwned signalisiert, dass das angeforderte Zertifikat nicht zum
// angemeldeten Benutzer gehört.
var ErrCertNotOwned = errors.New("Zertifikat gehört nicht zum angemeldeten Benutzer")
// ErrBackendUnavailable signalisiert eine nicht erreichbare Firewall.
var ErrBackendUnavailable = errors.New("Firewall nicht erreichbar")
// CertSource liefert die Zertifikate eines Benutzers und exportiert sie.
// Die Implementierung liegt in Task 14; das Interface hält die Handler testbar.
type CertSource interface {
EntriesFor(ctx context.Context, username string) ([]CertEntry, error)
// Export prüft die Zuordnung serverseitig erneut und liefert sonst ErrCertNotOwned.
Export(ctx context.Context, username, vpnID, refID, format string) (*ExportStream, error)
}
// HealthChecker liefert den Zustand der Abhängigkeiten für /healthz.
type HealthChecker interface {
Check(ctx context.Context) HealthReport
}
// HealthReport ist die Antwort von /healthz — bewusst ohne sensible Details.
type HealthReport struct {
OK bool `json:"ok"`
OPNsense bool `json:"opnsense"`
Directory bool `json:"directory"`
Sessions int `json:"sessions"`
}
// Deps sind die Abhängigkeiten des Servers.
type Deps struct {
Auth auth.Authenticator
Certs CertSource
Audit AuditSink
Sessions *SessionStore
Limiter *Limiter
Renderer *Renderer
Health HealthChecker
Clock func() time.Time
LogoPath string
SupportContact string
SessionTTL time.Duration
}
// Server bündelt Routing und Handler.
type Server struct {
d Deps
}
// NewServer prüft die Abhängigkeiten.
func NewServer(d Deps) (*Server, error) {
if d.Auth == nil {
return nil, errors.New("web: Authenticator fehlt")
}
if d.Certs == nil {
return nil, errors.New("web: CertSource fehlt")
}
if d.Audit == nil {
return nil, errors.New("web: Audit-Senke fehlt")
}
if d.Sessions == nil {
return nil, errors.New("web: SessionStore fehlt")
}
if d.Limiter == nil {
return nil, errors.New("web: Limiter fehlt")
}
if d.Renderer == nil {
return nil, errors.New("web: Renderer fehlt")
}
if d.Clock == nil {
d.Clock = time.Now
}
if d.SessionTTL <= 0 {
d.SessionTTL = 10 * time.Minute
}
return &Server{d: d}, nil
}
// Handler baut den Router samt Middleware.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLoginSubmit)
mux.HandleFunc("POST /logout", s.requireSession(s.handleLogout))
mux.HandleFunc("GET /{$}", s.requireSession(s.handleOverview))
mux.HandleFunc("POST /download", s.requireSession(s.handleDownload))
mux.HandleFunc("GET /anleitungen", s.requireSession(s.handleGuides))
mux.HandleFunc("GET /healthz", s.handleHealth)
mux.HandleFunc("GET /assets/{file}", s.handleAsset)
mux.HandleFunc("/", s.handleNotFound)
return SecurityHeaders(mux)
}
// log schreibt ein Audit-Ereignis.
func (s *Server) log(e audit.Event) { s.d.Audit.Log(e) }
// renderPage füllt die Standardfelder und rendert.
func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, status int,
page string, sess *Session, data PageData) {
NoStore(w)
data.Brand = s.d.Renderer.Brand()
data.Brand.HasLogo = s.d.LogoPath != ""
if sess != nil {
data.CSRF = sess.CSRF
data.User = sess.Identity.Username
}
s.d.Renderer.Render(w, r, status, page, data)
}
// renderError zeigt die Fehlerseite.
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int,
sess *Session, titleKey, messageKey string) {
s.renderPage(w, r, status, "error", sess, PageData{
Data: errorPageData{Title: T(titleKey), Message: T(messageKey)},
})
}
// requireSession schützt eine Route. Ohne gültige Session wird auf /login
// umgeleitet; ein abgelaufenes Cookie erzeugt zusätzlich ein Audit-Ereignis.
func (s *Server) requireSession(next func(http.ResponseWriter, *http.Request, *Session)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
NoStore(w)
cookie, err := r.Cookie(SessionCookieName)
if err != nil || cookie.Value == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
sess, ok := s.d.Sessions.Get(cookie.Value)
if !ok {
s.clearCookie(w)
s.log(audit.Event{
Event: audit.EventSessionExpired,
SrcIP: ClientIP(r),
Session: audit.ShortSession(cookie.Value),
})
http.Redirect(w, r, "/login?expired=1", http.StatusSeeOther)
return
}
next(w, r, sess)
}
}
// handleAsset liefert die eingebetteten Assets und das optionale Logo.
func (s *Server) handleAsset(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("file")
if name == "logo" {
if s.d.LogoPath == "" {
http.NotFound(w, r)
return
}
// Assets dürfen zwischengespeichert werden; sie enthalten keine
// personenbezogenen Daten.
w.Header().Set("Cache-Control", "public, max-age=3600")
http.ServeFile(w, r, s.d.LogoPath)
return
}
data, err := fs.ReadFile(AssetsFS, "assets/"+name)
if err != nil {
http.NotFound(w, r)
return
}
switch {
case len(name) > 4 && name[len(name)-4:] == ".css":
w.Header().Set("Content-Type", "text/css; charset=utf-8")
case len(name) > 3 && name[len(name)-3:] == ".js":
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
}
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(data)
}
// handleNotFound zeigt eine schlichte 404-Seite.
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, http.StatusNotFound, nil, "error_not_found", "error_not_found")
}
```
- [ ] **Step 4: `handlers_login.go` schreiben**
```go
package web
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
)
// minLoginDuration gleicht die Antwortzeit von „Benutzer existiert nicht" und
// „Passwort falsch" an, damit die Laufzeit kein Enumerationsorakel wird.
const minLoginDuration = 400 * time.Millisecond
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
NoStore(w)
// Eine bereits gültige Session überspringt das Formular.
if c, err := r.Cookie(SessionCookieName); err == nil {
if _, ok := s.d.Sessions.Get(c.Value); ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
}
var data PageData
switch {
case r.URL.Query().Get("expired") != "":
data.Flash, data.FlashKind = T("error_session_expired"), "info"
case r.URL.Query().Get("abgemeldet") != "":
data.Flash, data.FlashKind = T("logout_done"), "info"
}
s.renderPage(w, r, http.StatusOK, "login", nil, data)
}
// loginFailed rendert die Anmeldeseite mit generischer Meldung.
func (s *Server) loginFailed(w http.ResponseWriter, r *http.Request, status int, message string) {
s.renderPage(w, r, status, "login", nil, PageData{Flash: message, FlashKind: "error"})
}
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
NoStore(w)
started := s.d.Clock()
ip := ClientIP(r)
if err := r.ParseForm(); err != nil {
s.loginFailed(w, r, http.StatusBadRequest, T("error_generic_login"))
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
// Rate-Limit vor jedem Verzeichniszugriff prüfen.
if wait, ok := s.d.Limiter.Allow(username, ip); !ok {
s.log(audit.Event{Event: audit.EventRateLimited, User: auditUser(username, false),
SrcIP: ip, Detail: wait.Round(time.Second).String()})
w.Header().Set("Retry-After", strconv.Itoa(int(wait.Round(time.Second).Seconds())))
s.loginFailed(w, r, http.StatusTooManyRequests,
fmt.Sprintf(T("error_rate_limited"), humanDuration(wait)))
return
}
id, err := s.d.Auth.Authenticate(r.Context(), username, password)
s.equalizeTiming(started)
if err != nil {
s.d.Limiter.RecordFailure(username, ip)
reason, known := reasonAndKnownUser(err)
s.log(audit.Event{
Event: audit.EventLoginFailed,
User: auditUser(username, known),
SrcIP: ip,
Reason: reason,
})
message := T("error_generic_login")
if ae, ok := err.(*auth.Error); ok && ae.UserVisible() {
message = T("error_password_expired")
}
s.loginFailed(w, r, http.StatusUnauthorized, message)
return
}
s.d.Limiter.RecordSuccess(username, ip)
sess, err := s.d.Sessions.Create(id)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, nil,
"error_service_unavailable", "error_service_unavailable_body")
return
}
s.setCookie(w, sess)
s.log(audit.Event{
Event: audit.EventLoginSuccess,
User: id.Username,
SrcIP: ip,
Session: audit.ShortSession(sess.Token),
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess *Session) {
if err := r.ParseForm(); err != nil || !sess.ValidCSRF(r.PostFormValue("csrf_token")) {
s.renderError(w, r, http.StatusForbidden, sess, "error_forbidden", "error_csrf")
return
}
s.d.Sessions.Destroy(sess.Token)
s.clearCookie(w)
s.log(audit.Event{
Event: audit.EventLogout,
User: sess.Identity.Username,
SrcIP: ClientIP(r),
Session: audit.ShortSession(sess.Token),
})
http.Redirect(w, r, "/login?abgemeldet=1", http.StatusSeeOther)
}
// setCookie setzt das gehärtete Session-Cookie.
func (s *Server) setCookie(w http.ResponseWriter, sess *Session) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: sess.Token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(s.d.SessionTTL.Seconds()),
})
}
func (s *Server) clearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName, Value: "", Path: "/",
HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: -1,
})
}
// equalizeTiming hält die Antwortzeit auf einem Mindestwert.
func (s *Server) equalizeTiming(started time.Time) {
if elapsed := time.Since(started); elapsed < minLoginDuration {
time.Sleep(minLoginDuration - elapsed)
}
}
// reasonAndKnownUser liefert den Audit-Reason und ob der Benutzer im
// Verzeichnis existiert. Nur dann darf sein Name im Klartext geloggt werden.
func reasonAndKnownUser(err error) (reason string, known bool) {
ae, ok := err.(*auth.Error)
if !ok {
return auth.ReasonBackendUnavailable, false
}
switch ae.Reason {
case auth.ReasonUserNotFound, auth.ReasonBackendUnavailable, "":
return ae.Reason, false
default:
// Der Benutzer wurde im Verzeichnis gefunden; sein Name ist ein
// echter Kontoname und kein versehentlich eingegebenes Passwort.
return ae.Reason, true
}
}
// auditUser entscheidet, ob der eingegebene Name im Log erscheinen darf.
func auditUser(input string, known bool) string {
if !known {
return audit.UnknownUser
}
return strings.ToLower(input)
}
// humanDuration formatiert Wartezeiten deutsch lesbar.
func humanDuration(d time.Duration) string {
d = d.Round(time.Second)
if d < time.Minute {
return fmt.Sprintf("%d Sekunden", int(d.Seconds()))
}
minutes := int(d.Minutes())
if minutes == 1 {
return "einer Minute"
}
return fmt.Sprintf("%d Minuten", minutes)
}
```
- [ ] **Step 5: Fehlende Handler als Stubs anlegen, damit das Package kompiliert**
Vorläufig ans Ende von `server.go` (werden in Task 14/15 ersetzt):
```go
func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request, sess *Session) {
s.renderError(w, r, http.StatusNotImplemented, sess,
"error_service_unavailable", "error_service_unavailable_body")
}
func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request, sess *Session) {
s.renderError(w, r, http.StatusNotImplemented, sess,
"error_service_unavailable", "error_service_unavailable_body")
}
func (s *Server) handleGuides(w http.ResponseWriter, r *http.Request, sess *Session) {
s.renderError(w, r, http.StatusNotImplemented, sess,
"error_service_unavailable", "error_service_unavailable_body")
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}
```
- [ ] **Step 6: Tests laufen lassen**
Run: `go test ./internal/web/ -v`
Expected: PASS. Läuft wegen `minLoginDuration` spürbar langsamer — akzeptabel.
Wenn die Laufzeit stört: `minLoginDuration` in `Deps` konfigurierbar machen und in Tests auf `time.Millisecond` setzen. Empfehlung: erst so lassen, damit das Timing-Verhalten wirklich getestet wird.
- [ ] **Step 7: Commit**
```bash
git add internal/web
git commit -m "feat(web): Router, Session-Middleware und Login-/Logout-Handler"
```
---
## Task 14: CertSource — Übersicht und autorisierter Download
**Files:**
- Create: `internal/portal/certsource.go`
- Create: `internal/web/handlers_certs.go`
- Test: `internal/portal/certsource_test.go`, `internal/web/handlers_certs_test.go`
- Modify: `internal/web/server.go` (Stubs `handleOverview`/`handleDownload` entfernen)
**Interfaces:**
- Consumes: `opnsense.Client` (Task 8), `certmatch.Matcher` (Task 9), `web.CertSource`/`CertEntry`/`ExportStream` (Task 13)
- Produces:
- `type Source struct { ... }`; `func NewSource(cl OPNsense, m *certmatch.Matcher, providerTTL time.Duration, clock func() time.Time) *Source`
- `type OPNsense interface { Providers(ctx) ([]opnsense.Provider, error); Accounts(ctx, vpnID) ([]opnsense.Account, error); Export(ctx, vpnID, refID, format) (*opnsense.ExportResult, error) }` — Interface, damit Tests ohne HTTP auskommen
- `func (s *Source) EntriesFor(ctx, username) ([]web.CertEntry, error)`
- `func (s *Source) Export(ctx, username, vpnID, refID, format) (*web.ExportStream, error)`**prüft die Zuordnung live neu**
- Provider-Liste wird für `providerTTL` gecacht; Accounts **niemals**
- `func SafeFilename(instance, username, format string) string``vpn-<instanz>-<user>.ovpn`, auf `[a-z0-9._-]` reduziert
- [ ] **Step 1: Failing test für die CertSource schreiben**
`internal/portal/certsource_test.go`:
```go
package portal
import (
"context"
"errors"
"io"
"strings"
"testing"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/certmatch"
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
"git.ravensburg.dev/cabele/opnsense-portal/internal/web"
)
// fakeFW zählt Aufrufe, damit Caching-Verhalten prüfbar wird.
type fakeFW struct {
providers []opnsense.Provider
accounts map[string][]opnsense.Account
providerCalls int
accountCalls int
exportCalls int
lastExportArgs [3]string
err error
}
func (f *fakeFW) Providers(ctx context.Context) ([]opnsense.Provider, error) {
f.providerCalls++
return f.providers, f.err
}
func (f *fakeFW) Accounts(ctx context.Context, vpnID string) ([]opnsense.Account, error) {
f.accountCalls++
if f.err != nil {
return nil, f.err
}
return f.accounts[vpnID], nil
}
func (f *fakeFW) Export(ctx context.Context, vpnID, refID, format string) (*opnsense.ExportResult, error) {
f.exportCalls++
f.lastExportArgs = [3]string{vpnID, refID, format}
return &opnsense.ExportResult{
Filename: "von-der-firewall.ovpn",
ContentType: "application/x-openvpn-profile",
Body: io.NopCloser(strings.NewReader("client\n")),
}, nil
}
var testNow = time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
func newSource(t *testing.T, fw *fakeFW) *Source {
t.Helper()
m, err := certmatch.NewMatcher("{username}", "")
if err != nil {
t.Fatal(err)
}
return NewSource(fw, m, 2*time.Minute, func() time.Time { return testNow })
}
func standardFW() *fakeFW {
return &fakeFW{
providers: []opnsense.Provider{
{VPNID: "1", Name: "VPN Homeoffice"},
{VPNID: "2", Name: "VPN Aussendienst"},
},
accounts: map[string][]opnsense.Account{
"1": {
{RefID: "a1", CommonName: "mmueller", ValidTo: testNow.AddDate(1, 0, 0)},
{RefID: "a2", CommonName: "jdoe", ValidTo: testNow.AddDate(1, 0, 0)},
},
"2": {
{RefID: "b1", CommonName: "mmueller", ValidTo: testNow.AddDate(0, 0, 10)},
{RefID: "b2", CommonName: "mmueller", ValidTo: testNow.AddDate(1, 0, 0), Revoked: true},
},
},
}
}
func TestEntriesForCollectsAcrossInstances(t *testing.T) {
src := newSource(t, standardFW())
entries, err := src.EntriesFor(context.Background(), "mmueller")
if err != nil {
t.Fatalf("EntriesFor: %v", err)
}
if len(entries) != 2 {
t.Fatalf("got %d Einträge, want 2: %+v", len(entries), entries)
}
names := map[string]bool{}
for _, e := range entries {
names[e.InstanceName] = true
if e.CommonName != "mmueller" {
t.Errorf("fremder CN in der Liste: %+v", e)
}
}
if !names["VPN Homeoffice"] || !names["VPN Aussendienst"] {
t.Errorf("Instanznamen fehlen: %+v", entries)
}
}
func TestEntriesForExcludesRevokedAndForeign(t *testing.T) {
src := newSource(t, standardFW())
entries, _ := src.EntriesFor(context.Background(), "mmueller")
for _, e := range entries {
if strings.Contains(e.Token, "b2") {
t.Error("revoziertes Zertifikat darf nicht erscheinen")
}
if strings.Contains(e.Token, "a2") {
t.Error("fremdes Zertifikat darf nicht erscheinen")
}
}
}
func TestAccountsAreNeverCached(t *testing.T) {
fw := standardFW()
src := newSource(t, fw)
src.EntriesFor(context.Background(), "mmueller")
first := fw.accountCalls
src.EntriesFor(context.Background(), "mmueller")
if fw.accountCalls <= first {
t.Fatal("Accounts müssen bei jeder Anfrage frisch geholt werden (Revocation muss sofort greifen)")
}
}
func TestProvidersAreCachedForTTL(t *testing.T) {
fw := standardFW()
src := newSource(t, fw)
src.EntriesFor(context.Background(), "mmueller")
src.EntriesFor(context.Background(), "mmueller")
if fw.providerCalls != 1 {
t.Fatalf("providerCalls = %d, want 1 (TTL-Cache)", fw.providerCalls)
}
testNow = testNow.Add(3 * time.Minute)
defer func() { testNow = time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) }()
src.EntriesFor(context.Background(), "mmueller")
if fw.providerCalls != 2 {
t.Fatalf("providerCalls = %d, want 2 (Cache abgelaufen)", fw.providerCalls)
}
}
func TestExportRevalidatesOwnership(t *testing.T) {
fw := standardFW()
src := newSource(t, fw)
// jdoes Zertifikat a2 gehört nicht zu mmueller.
_, err := src.Export(context.Background(), "mmueller", "1", "a2", opnsense.FormatOVPN)
if !errors.Is(err, web.ErrCertNotOwned) {
t.Fatalf("err = %v, want ErrCertNotOwned", err)
}
if fw.exportCalls != 0 {
t.Error("bei fehlender Zuordnung darf kein Export ausgelöst werden")
}
}
func TestExportRejectsRevokedEvenIfPreviouslyListed(t *testing.T) {
fw := standardFW()
src := newSource(t, fw)
// b2 gehört mmueller, ist aber revoziert.
_, err := src.Export(context.Background(), "mmueller", "2", "b2", opnsense.FormatOVPN)
if !errors.Is(err, web.ErrCertNotOwned) {
t.Fatalf("err = %v, want ErrCertNotOwned", err)
}
}
func TestExportSucceedsForOwnedCert(t *testing.T) {
fw := standardFW()
src := newSource(t, fw)
st, err := src.Export(context.Background(), "mmueller", "1", "a1", opnsense.FormatOVPN)
if err != nil {
t.Fatalf("Export: %v", err)
}
defer st.Body.Close()
if fw.lastExportArgs != [3]string{"1", "a1", opnsense.FormatOVPN} {
t.Errorf("Export-Argumente = %v", fw.lastExportArgs)
}
if st.InstanceName != "VPN Homeoffice" || st.CommonName != "mmueller" {
t.Errorf("Metadaten fehlen: %+v", st)
}
body, _ := io.ReadAll(st.Body)
if string(body) != "client\n" {
t.Errorf("Body = %q", body)
}
}
func TestExportRejectsUnknownFormat(t *testing.T) {
src := newSource(t, standardFW())
if _, err := src.Export(context.Background(), "mmueller", "1", "a1", "beliebig"); err == nil {
t.Fatal("unbekanntes Format muss abgelehnt werden")
}
}
func TestBackendErrorIsWrapped(t *testing.T) {
fw := standardFW()
fw.err = opnsense.ErrUnreachable
src := newSource(t, fw)
if _, err := src.EntriesFor(context.Background(), "mmueller"); !errors.Is(err, web.ErrBackendUnavailable) {
t.Fatalf("err = %v, want ErrBackendUnavailable", err)
}
}
func TestSafeFilename(t *testing.T) {
cases := map[string]string{
"VPN Homeoffice": "vpn-vpn-homeoffice-mmueller.ovpn",
"VPN/../etc": "vpn-vpn-.-.-etc-mmueller.ovpn",
"Außendienst": "vpn-au-endienst-mmueller.ovpn",
}
for instance, want := range cases {
if got := SafeFilename(instance, "mmueller", opnsense.FormatOVPN); got != want {
t.Errorf("SafeFilename(%q) = %q, want %q", instance, got, want)
}
}
// Kein Pfadtrenner, keine Anführungszeichen, kein CR/LF im Ergebnis.
dirty := SafeFilename("a\"/b\\c\r\nd", "u", opnsense.FormatOVPN)
for _, bad := range []string{"/", "\\", "\"", "\r", "\n"} {
if strings.Contains(dirty, bad) {
t.Errorf("Dateiname %q enthält %q", dirty, bad)
}
}
}
```
Hinweis: Die Erwartungen in `TestSafeFilename` legen die Normalisierung fest — jedes Zeichen außerhalb `[a-z0-9._-]` wird zu `-`, Mehrfach-`-` werden zusammengefasst nicht ersetzt (die Erwartung `vpn-vpn-.-.-etc-mmueller.ovpn` bildet genau das ab). Weicht die Implementierung ab, sind die Erwartungen an das tatsächliche, dokumentierte Verhalten anzupassen — nicht umgekehrt aufzuweichen.
- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./internal/portal/ -v`
Expected: FAIL — `undefined: NewSource`
- [ ] **Step 3: `certsource.go` schreiben**
```go
// Package portal verbindet Firewall-Client und Zuordnungsregeln zu der
// Sicht, die das Webportal auf die Zertifikate eines Benutzers hat.
package portal
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/certmatch"
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
"git.ravensburg.dev/cabele/opnsense-portal/internal/web"
)
// OPNsense ist der benötigte Ausschnitt des Firewall-Clients.
type OPNsense interface {
Providers(ctx context.Context) ([]opnsense.Provider, error)
Accounts(ctx context.Context, vpnID string) ([]opnsense.Account, error)
Export(ctx context.Context, vpnID, refID, format string) (*opnsense.ExportResult, error)
}
// allowedFormats sind die einzigen Formate, die das Portal anbietet.
var allowedFormats = map[string]bool{
opnsense.FormatOVPN: true,
opnsense.FormatViscosity: true,
}
// Source liefert die Zertifikatssicht eines Benutzers.
type Source struct {
fw OPNsense
match *certmatch.Matcher
clock func() time.Time
providerTTL time.Duration
mu sync.Mutex
providers []opnsense.Provider
providersAt time.Time
}
// NewSource baut die Quelle. providerTTL cacht ausschließlich die Liste der
// VPN-Instanzen; Zertifikate werden grundsätzlich live geholt.
func NewSource(fw OPNsense, m *certmatch.Matcher, providerTTL time.Duration, clock func() time.Time) *Source {
if clock == nil {
clock = time.Now
}
return &Source{fw: fw, match: m, clock: clock, providerTTL: providerTTL}
}
// Pattern liefert die für einen Benutzer angewendete Regel (Audit-Feld).
func (s *Source) Pattern(username string) string { return s.match.Describe(username) }
// listProviders liefert die VPN-Instanzen aus dem Cache oder frisch.
func (s *Source) listProviders(ctx context.Context) ([]opnsense.Provider, error) {
now := s.clock()
s.mu.Lock()
if s.providers != nil && now.Sub(s.providersAt) < s.providerTTL {
cached := s.providers
s.mu.Unlock()
return cached, nil
}
s.mu.Unlock()
ps, err := s.fw.Providers(ctx)
if err != nil {
return nil, wrapBackend(err)
}
s.mu.Lock()
s.providers, s.providersAt = ps, now
s.mu.Unlock()
return ps, nil
}
// wrapBackend übersetzt Client-Fehler in die Portal-Semantik.
func wrapBackend(err error) error {
return fmt.Errorf("%w: %v", web.ErrBackendUnavailable, err)
}
// matchingEntries sammelt alle passenden, nutzbaren Zertifikate.
// Wird von EntriesFor und von Export benutzt — Export prüft damit live neu.
func (s *Source) matchingEntries(ctx context.Context, username string) ([]certmatch.Entry, error) {
providers, err := s.listProviders(ctx)
if err != nil {
return nil, err
}
now := s.clock()
var out []certmatch.Entry
for _, p := range providers {
// Accounts NIE cachen: eine Revozierung auf der Firewall muss sofort greifen.
accounts, err := s.fw.Accounts(ctx, p.VPNID)
if err != nil {
return nil, wrapBackend(err)
}
out = append(out, s.match.Filter(username, p, accounts, now)...)
}
return out, nil
}
// EntriesFor liefert die Zertifikate des Benutzers für die Übersicht.
func (s *Source) EntriesFor(ctx context.Context, username string) ([]web.CertEntry, error) {
entries, err := s.matchingEntries(ctx, username)
if err != nil {
return nil, err
}
out := make([]web.CertEntry, 0, len(entries))
for _, e := range entries {
out = append(out, web.CertEntry{
Token: e.Token(),
InstanceName: e.Provider.Name,
CommonName: e.Account.CommonName,
ValidTo: e.Account.ValidTo,
})
}
return out, nil
}
// Export prüft die Zuordnung serverseitig erneut und streamt dann.
// Die Auswahl in der Oberfläche ist ausdrücklich keine Autorisierung.
func (s *Source) Export(ctx context.Context, username, vpnID, refID, format string) (*web.ExportStream, error) {
if !allowedFormats[format] {
return nil, fmt.Errorf("unbekanntes Exportformat %q", format)
}
entries, err := s.matchingEntries(ctx, username)
if err != nil {
return nil, err
}
var found *certmatch.Entry
for i := range entries {
if entries[i].Provider.VPNID == vpnID && entries[i].Account.RefID == refID {
found = &entries[i]
break
}
}
if found == nil {
return nil, web.ErrCertNotOwned
}
res, err := s.fw.Export(ctx, vpnID, refID, format)
if err != nil {
return nil, wrapBackend(err)
}
return &web.ExportStream{
Filename: SafeFilename(found.Provider.Name, username, format),
ContentType: res.ContentType,
Body: res.Body,
InstanceName: found.Provider.Name,
CommonName: found.Account.CommonName,
ValidTo: found.Account.ValidTo,
}, nil
}
// extensionFor liefert die Dateiendung je Format.
func extensionFor(format string) string {
if format == opnsense.FormatViscosity {
return ".visc.zip"
}
return ".ovpn"
}
// SafeFilename baut einen Dateinamen, der weder Pfadtrenner noch Zeichen
// enthält, die den Content-Disposition-Header aufbrechen könnten.
func SafeFilename(instance, username, format string) string {
return "vpn-" + sanitize(instance) + "-" + sanitize(username) + extensionFor(format)
}
// sanitize reduziert auf [a-z0-9._-]; alles andere wird zu '-'.
func sanitize(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(strings.TrimSpace(s)) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
b.WriteRune(r)
default:
b.WriteRune('-')
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "vpn"
}
return out
}
var _ web.CertSource = (*Source)(nil)
var _ = errors.Is // hält errors im Import, falls oben nur indirekt genutzt
```
Die letzte Zeile entfernen, falls `errors` bereits verwendet wird.
- [ ] **Step 4: Tests laufen lassen**
Run: `go test ./internal/portal/ -v`
Expected: PASS. Falls `TestSafeFilename` bei `"Außendienst"` abweicht: `ß` ist ein Rune außerhalb `[a-z]` und wird zu `-`, also `au-endienst` — die Erwartung im Test ist korrekt.
- [ ] **Step 5: Failing test für die Handler schreiben**
`internal/web/handlers_certs_test.go`:
```go
package web
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
)
// certsWithExport erweitert fakeCerts um einen funktionierenden Export.
type certsWithExport struct {
entries []CertEntry
listErr error
exportErr error
exportCall int
}
func (c *certsWithExport) EntriesFor(ctx context.Context, username string) ([]CertEntry, error) {
return c.entries, c.listErr
}
func (c *certsWithExport) Export(ctx context.Context, username, vpnID, refID, format string) (*ExportStream, error) {
c.exportCall++
if c.exportErr != nil {
return nil, c.exportErr
}
return &ExportStream{
Filename: "vpn-homeoffice-mmueller.ovpn",
ContentType: "application/x-openvpn-profile",
Body: io.NopCloser(strings.NewReader("client\nremote fw01 1194\n")),
InstanceName: "VPN Homeoffice",
CommonName: "mmueller",
ValidTo: time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC),
}, nil
}
// loggedInServer liefert Server, Cookie und Session eines angemeldeten Benutzers.
func loggedInServer(t *testing.T, certs CertSource) (*Server, *recordAudit, *http.Cookie, *Session) {
t.Helper()
store := NewSessionStore(10*time.Minute, nil)
srv, rec := newTestServer(t, Deps{
Auth: &fakeAuth{id: &auth.Identity{Username: "mmueller", Groups: []string{"VPN-Users"}}},
Certs: certs,
Sessions: store,
SupportContact: "it-helpdesk@firma.de",
})
login := postForm(srv.Handler(), "/login", url.Values{"username": {"m"}, "password": {"p"}})
cookie := sessionCookie(login)
sess, ok := store.Get(cookie.Value)
if !ok {
t.Fatal("Anmeldung fehlgeschlagen")
}
return srv, rec, cookie, sess
}
func getWithCookie(h http.Handler, path string, c *http.Cookie) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodGet, path, nil)
r.RemoteAddr = "10.1.20.34:5000"
r.AddCookie(c)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
return rec
}
func TestOverviewListsCertificates(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{
{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller",
ValidTo: time.Now().AddDate(1, 0, 0)},
{Token: "2:b1", InstanceName: "VPN Aussendienst", CommonName: "mmueller",
ValidTo: time.Now().AddDate(1, 0, 0)},
}}
srv, _, cookie, _ := loggedInServer(t, certs)
rec := getWithCookie(srv.Handler(), "/", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("Code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"VPN Homeoffice", "VPN Aussendienst",
S["download_ovpn"], S["download_viscosity"], `name="csrf_token"`} {
if !strings.Contains(body, want) {
t.Errorf("Übersicht enthält %q nicht", want)
}
}
if strings.Contains(body, `<a href="/download`) {
t.Error("Downloads dürfen keine GET-Links sein")
}
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control = %q", cc)
}
}
func TestOverviewShowsExpiryWarning(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{
{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller",
ValidTo: time.Now().AddDate(0, 0, 12)},
}}
srv, _, cookie, _ := loggedInServer(t, certs)
body := getWithCookie(srv.Handler(), "/", cookie).Body.String()
if !strings.Contains(body, "12 Tagen") {
t.Errorf("Ablaufwarnung fehlt: %s", body)
}
}
func TestOverviewNoCertsShowsSupportContact(t *testing.T) {
srv, rec, cookie, _ := loggedInServer(t, &certsWithExport{})
out := getWithCookie(srv.Handler(), "/", cookie)
body := out.Body.String()
if !strings.Contains(body, S["overview_no_certs"]) {
t.Error("Meldung für fehlende Zertifikate fehlt")
}
if !strings.Contains(body, "it-helpdesk@firma.de") {
t.Error("support_contact fehlt")
}
ev, ok := rec.find(audit.EventNoCertFound)
if !ok {
t.Fatal("no_cert_found fehlt im Audit-Log")
}
if ev.User != "mmueller" {
t.Errorf("Audit-Event = %+v", ev)
}
}
func TestOverviewBackendDownShowsServiceError(t *testing.T) {
certs := &certsWithExport{listErr: ErrBackendUnavailable}
srv, rec, cookie, _ := loggedInServer(t, certs)
out := getWithCookie(srv.Handler(), "/", cookie)
if out.Code != http.StatusServiceUnavailable {
t.Fatalf("Code = %d, want 503", out.Code)
}
body := out.Body.String()
if !strings.Contains(body, S["error_service_unavailable"]) {
t.Error("Dienstfehlerseite fehlt")
}
if strings.Contains(body, S["overview_no_certs"]) {
t.Error("Firewall-Ausfall darf nicht wie \"kein Zertifikat\" aussehen")
}
if _, ok := rec.find(audit.EventOPNsenseUnreachable); !ok {
t.Error("opnsense_unreachable fehlt im Audit-Log")
}
}
func TestDownloadStreamsWithHardenedHeaders(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{
{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller"},
}}
srv, rec, cookie, sess := loggedInServer(t, certs)
out := postForm(srv.Handler(), "/download", url.Values{
"csrf_token": {sess.CSRF}, "cert": {"1:a1"}, "format": {"ovpn"},
}, cookie)
if out.Code != http.StatusOK {
t.Fatalf("Code = %d, body: %s", out.Code, out.Body.String())
}
cd := out.Header().Get("Content-Disposition")
if !strings.Contains(cd, `attachment; filename="vpn-homeoffice-mmueller.ovpn"`) {
t.Errorf("Content-Disposition = %q", cd)
}
if cc := out.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control = %q", cc)
}
if !strings.Contains(out.Body.String(), "remote fw01 1194") {
t.Errorf("Body = %q", out.Body.String())
}
ev, ok := rec.find(audit.EventConfigDownload)
if !ok {
t.Fatal("config_download fehlt im Audit-Log")
}
if ev.User != "mmueller" || ev.VPNInstance != "VPN Homeoffice" ||
ev.Format != "ovpn" || ev.CertCN != "mmueller" || ev.CertExpiry != "2027-03-01" {
t.Errorf("Audit-Event unvollständig: %+v", ev)
}
}
func TestDownloadRequiresCSRF(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{{Token: "1:a1", InstanceName: "X"}}}
srv, _, cookie, _ := loggedInServer(t, certs)
out := postForm(srv.Handler(), "/download", url.Values{
"csrf_token": {"falsch"}, "cert": {"1:a1"}, "format": {"ovpn"},
}, cookie)
if out.Code != http.StatusForbidden {
t.Fatalf("Code = %d, want 403", out.Code)
}
if certs.exportCall != 0 {
t.Error("ohne gültiges CSRF-Token darf kein Export ausgelöst werden")
}
}
func TestDownloadGETIsNotRouted(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{{Token: "1:a1"}}}
srv, _, cookie, _ := loggedInServer(t, certs)
out := getWithCookie(srv.Handler(), "/download?cert=1:a1&format=ovpn", cookie)
if out.Code == http.StatusOK {
t.Fatal("Downloads per GET dürfen nicht möglich sein")
}
}
func TestForeignCertificateIsDenied(t *testing.T) {
certs := &certsWithExport{
entries: []CertEntry{{Token: "1:a1", InstanceName: "X"}},
exportErr: ErrCertNotOwned,
}
srv, rec, cookie, sess := loggedInServer(t, certs)
out := postForm(srv.Handler(), "/download", url.Values{
"csrf_token": {sess.CSRF}, "cert": {"9:fremd"}, "format": {"ovpn"},
}, cookie)
if out.Code != http.StatusForbidden {
t.Fatalf("Code = %d, want 403", out.Code)
}
ev, ok := rec.find(audit.EventDownloadDenied)
if !ok {
t.Fatal("download_denied fehlt im Audit-Log")
}
if ev.User != "mmueller" {
t.Errorf("Audit-Event = %+v", ev)
}
}
func TestDownloadRejectsMalformedToken(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{{Token: "1:a1"}}}
srv, _, cookie, sess := loggedInServer(t, certs)
for _, bad := range []string{"", "keinDoppelpunkt", "a:b:c"} {
out := postForm(srv.Handler(), "/download", url.Values{
"csrf_token": {sess.CSRF}, "cert": {bad}, "format": {"ovpn"},
}, cookie)
if out.Code != http.StatusForbidden && out.Code != http.StatusBadRequest {
t.Errorf("Token %q ergab Code %d", bad, out.Code)
}
}
if certs.exportCall != 0 {
t.Error("kaputte Tokens dürfen keinen Export auslösen")
}
}
func TestDownloadRejectsUnknownFormat(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{{Token: "1:a1"}}}
srv, _, cookie, sess := loggedInServer(t, certs)
out := postForm(srv.Handler(), "/download", url.Values{
"csrf_token": {sess.CSRF}, "cert": {"1:a1"}, "format": {"exe"},
}, cookie)
if out.Code == http.StatusOK {
t.Fatal("unbekanntes Format muss abgelehnt werden")
}
if certs.exportCall != 0 {
t.Error("unbekanntes Format darf keinen Export auslösen")
}
}
```
- [ ] **Step 6: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./internal/web/ -run 'TestOverview|TestDownload|TestForeign' -v`
Expected: FAIL — die Stub-Handler liefern 501
- [ ] **Step 7: Stubs aus `server.go` entfernen und `handlers_certs.go` schreiben**
Die vier Stub-Funktionen am Ende von `server.go` löschen (`handleHealth` und `handleGuides` bleiben vorerst, sie werden in Task 15 ersetzt — nur `handleOverview` und `handleDownload` entfernen).
`internal/web/handlers_certs.go`:
```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**
```bash
git add internal/portal internal/web
git commit -m "feat(portal): Zertifikatsübersicht und serverseitig autorisierter Streaming-Download"
```
---
## Task 15: Anleitungen, Healthz und Startup-Ereignis
**Files:**
- Create: `internal/web/handlers_static.go`
- Create: `internal/web/guides.go`
- Test: `internal/web/handlers_static_test.go`
- Modify: `internal/web/server.go` (Stubs `handleGuides`/`handleHealth` entfernen)
**Interfaces:**
- Consumes: `HealthChecker`, `HealthReport` (Task 13)
- Produces:
- `type Guide struct { Platform, Client string; Steps []string; Suggested bool }`
- `func Guides(userAgent string) []Guide` — sortiert die zum User-Agent passende Plattform nach vorn und markiert sie als `Suggested`; filtert **nie**
- `func (s *Server) handleGuides(...)`, `func (s *Server) handleHealth(...)`
- `/healthz` antwortet ohne Auth mit JSON `{"ok":true,"opnsense":true,"directory":true,"sessions":3}`, Status 200 bei `ok`, sonst 503
- [ ] **Step 1: Failing test schreiben**
`internal/web/handlers_static_test.go`:
```go
package web
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type fakeHealth struct{ rep HealthReport }
func (f *fakeHealth) Check(ctx context.Context) HealthReport { return f.rep }
func TestGuidesOrderPutsDetectedPlatformFirst(t *testing.T) {
const winUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
g := Guides(winUA)
if len(g) < 4 {
t.Fatalf("es müssen Anleitungen für alle Plattformen existieren, got %d", len(g))
}
if !strings.Contains(g[0].Platform, "Windows") || !g[0].Suggested {
t.Errorf("erste Anleitung = %+v, want Windows und Suggested", g[0])
}
// Kein Filter: alle Plattformen bleiben erreichbar.
platforms := map[string]bool{}
for _, x := range g {
platforms[x.Platform] = true
}
for _, want := range []string{"Windows", "macOS", "iOS", "Android"} {
var found bool
for p := range platforms {
if strings.Contains(p, want) {
found = true
}
}
if !found {
t.Errorf("Anleitung für %s fehlt", want)
}
}
}
func TestGuidesDetectsOtherPlatforms(t *testing.T) {
cases := map[string]string{
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)": "macOS",
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)": "iOS",
"Mozilla/5.0 (Linux; Android 14; Pixel 8)": "Android",
}
for ua, want := range cases {
g := Guides(ua)
if !strings.Contains(g[0].Platform, want) {
t.Errorf("UA %q -> %q, want %q zuerst", ua, g[0].Platform, want)
}
}
// Unbekannter User-Agent: Reihenfolge stabil, nichts als Vorschlag markiert.
g := Guides("irgendwas")
if g[0].Suggested {
t.Error("bei unbekanntem User-Agent darf nichts vorgeschlagen werden")
}
}
func TestGuidesPageRequiresSession(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/anleitungen", nil))
if rec.Code != http.StatusSeeOther {
t.Fatalf("Code = %d, want 303", rec.Code)
}
}
func TestGuidesPageRenders(t *testing.T) {
srv, _, cookie, _ := loggedInServer(t, &certsWithExport{})
rec := getWithCookie(srv.Handler(), "/anleitungen", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("Code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{S["guides_title"], "OpenVPN", "Tunnelblick", "OpenVPN Connect"} {
if !strings.Contains(body, want) {
t.Errorf("Anleitungsseite enthält %q nicht", want)
}
}
}
func TestHealthzHealthy(t *testing.T) {
srv, _ := newTestServer(t, Deps{
Auth: &fakeAuth{},
Health: &fakeHealth{rep: HealthReport{OK: true, OPNsense: true, Directory: true}},
})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("Code = %d, want 200", rec.Code)
}
var got HealthReport
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("Antwort ist kein JSON: %v (%s)", err, rec.Body.String())
}
if !got.OK || !got.OPNsense || !got.Directory {
t.Errorf("Report = %+v", got)
}
}
func TestHealthzUnhealthyReturns503(t *testing.T) {
srv, _ := newTestServer(t, Deps{
Auth: &fakeAuth{},
Health: &fakeHealth{rep: HealthReport{OK: false, OPNsense: false, Directory: true}},
})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("Code = %d, want 503", rec.Code)
}
}
func TestHealthzLeaksNoDetails(t *testing.T) {
srv, _ := newTestServer(t, Deps{
Auth: &fakeAuth{},
Health: &fakeHealth{rep: HealthReport{OK: true, OPNsense: true, Directory: true, Sessions: 2}},
})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
body := rec.Body.String()
for _, forbidden := range []string{"firma.local", "dc01", "https://", "api", "password"} {
if strings.Contains(strings.ToLower(body), forbidden) {
t.Errorf("/healthz verrät %q: %s", forbidden, body)
}
}
}
func TestHealthzWithoutCheckerStillAnswers(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("Code = %d — ohne Checker gilt das Portal selbst als gesund", rec.Code)
}
}
func TestUnknownPathReturns404(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/gibtsnicht", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("Code = %d, want 404", rec.Code)
}
}
func TestAssetsAreServed(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
for path, wantType := range map[string]string{
"/assets/style.css": "text/css",
"/assets/app.js": "text/javascript",
} {
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Errorf("%s: Code = %d", path, rec.Code)
continue
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, wantType) {
t.Errorf("%s: Content-Type = %q, want %q", path, ct, wantType)
}
}
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/assets/../server.go", nil))
if rec.Code == http.StatusOK {
t.Error("Pfad-Traversal darf nicht möglich sein")
}
}
```
- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./internal/web/ -run 'TestGuides|TestHealthz|TestAssets|TestUnknownPath' -v`
Expected: FAIL — `undefined: Guides`
- [ ] **Step 3: `guides.go` schreiben**
```go
package web
import "strings"
// Guide ist eine Kurzanleitung für eine Plattform.
type Guide struct {
Platform string
Client string
Steps []string
Suggested bool
}
// baseGuides ist die feste Reihenfolge aller Anleitungen.
var baseGuides = []Guide{
{
Platform: "Windows",
Client: "OpenVPN Community Client",
Steps: []string{
"Laden Sie den OpenVPN Community Client von openvpn.net herunter und installieren Sie ihn.",
"Laden Sie hier im Portal Ihre .ovpn-Datei herunter.",
"Klicken Sie mit der rechten Maustaste auf das OpenVPN-Symbol im Infobereich und wählen Sie „Datei importieren“.",
"Wählen Sie die heruntergeladene .ovpn-Datei aus.",
"Klicken Sie erneut auf das Symbol und wählen Sie „Verbinden“.",
},
},
{
Platform: "macOS",
Client: "Tunnelblick oder Viscosity",
Steps: []string{
"Installieren Sie Tunnelblick (kostenfrei) oder Viscosity.",
"Laden Sie hier im Portal Ihre Konfiguration herunter — für Viscosity das Viscosity-Paket, für Tunnelblick die .ovpn-Datei.",
"Öffnen Sie die heruntergeladene Datei per Doppelklick; die Konfiguration wird importiert.",
"Starten Sie die Verbindung über das Symbol in der Menüleiste.",
},
},
{
Platform: "iOS / iPadOS",
Client: "OpenVPN Connect",
Steps: []string{
"Installieren Sie „OpenVPN Connect“ aus dem App Store.",
"Laden Sie hier im Portal Ihre .ovpn-Datei herunter.",
"Öffnen Sie die Datei aus der Dateien-App heraus und wählen Sie „Teilen“ → „OpenVPN Connect“.",
"Bestätigen Sie den Import mit „Add“ und erlauben Sie die VPN-Konfiguration.",
},
},
{
Platform: "Android",
Client: "OpenVPN Connect",
Steps: []string{
"Installieren Sie „OpenVPN Connect“ aus dem Google Play Store.",
"Laden Sie hier im Portal Ihre .ovpn-Datei herunter.",
"Öffnen Sie OpenVPN Connect, wählen Sie „Import“ → „File“ und dann die heruntergeladene Datei.",
"Bestätigen Sie den Import und erlauben Sie die VPN-Verbindung.",
},
},
}
// detectPlatform rät die Plattform aus dem User-Agent.
// Das Ergebnis ist ausdrücklich nur ein Vorschlag, nie ein Filter.
func detectPlatform(userAgent string) string {
ua := strings.ToLower(userAgent)
switch {
case strings.Contains(ua, "iphone"), strings.Contains(ua, "ipad"), strings.Contains(ua, "ipod"):
return "iOS / iPadOS"
case strings.Contains(ua, "android"):
return "Android"
case strings.Contains(ua, "windows"):
return "Windows"
case strings.Contains(ua, "mac os x"), strings.Contains(ua, "macintosh"):
return "macOS"
default:
return ""
}
}
// Guides liefert alle Anleitungen; die erkannte Plattform steht vorn und ist
// als Vorschlag markiert. Es wird niemals etwas ausgeblendet.
func Guides(userAgent string) []Guide {
detected := detectPlatform(userAgent)
out := make([]Guide, 0, len(baseGuides))
if detected != "" {
for _, g := range baseGuides {
if g.Platform == detected {
g.Suggested = true
out = append(out, g)
}
}
}
for _, g := range baseGuides {
if g.Platform != detected {
out = append(out, g)
}
}
return out
}
```
Reihenfolge-Hinweis: iOS wird vor Android geprüft, weil manche iPad-User-Agents „Macintosh“ enthalten; macOS steht deshalb am Ende der Kette.
- [ ] **Step 4: `handlers_static.go` schreiben**
```go
package web
import (
"encoding/json"
"net/http"
)
// guidesData füllt guides.html.
type guidesData struct {
Guides []Guide
}
func (s *Server) handleGuides(w http.ResponseWriter, r *http.Request, sess *Session) {
s.renderPage(w, r, http.StatusOK, "guides", sess,
PageData{Data: guidesData{Guides: Guides(r.UserAgent())}})
}
// handleHealth beantwortet Monitoring-Anfragen ohne Authentifizierung und
// ohne sensible Details — nur boolesche Zustände und die Sessionzahl.
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
report := HealthReport{OK: true, OPNsense: true, Directory: true}
if s.d.Health != nil {
report = s.d.Health.Check(r.Context())
}
report.Sessions = s.d.Sessions.Count()
status := http.StatusOK
if !report.OK {
status = http.StatusServiceUnavailable
}
NoStore(w)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
json.NewEncoder(w).Encode(report)
}
```
Die Stubs `handleGuides` und `handleHealth` am Ende von `server.go` löschen.
- [ ] **Step 5: Tests laufen lassen**
Run: `go test ./internal/web/ -v`
Expected: PASS
Hinweis zu `TestAssetsAreServed`: Der Pfad `/assets/../server.go` wird bereits von `net/http` normalisiert und landet auf `/server.go` → 404 über `handleNotFound`. Zusätzlich schützt `fs.ReadFile` über `embed.FS`, das keine Traversal erlaubt.
- [ ] **Step 6: Commit**
```bash
git add internal/web
git commit -m "feat(web): Anleitungsseite mit User-Agent-Vorschlag und /healthz für Monitoring"
```
---
## Task 16: `vpnportal check` — Startup-Validierung
**Files:**
- Create: `internal/check/check.go`
- Test: `internal/check/check_test.go`
- Modify: `cmd/vpnportal/check.go`
**Interfaces:**
- Consumes: `config.Config`, `config.CheckFileMode`/`CheckDirMode`, `opnsense.Client`, `auth.AD`
- Produces:
- `type Result struct { Name string; OK bool; Detail string }`
- `type Report struct { Results []Result }`; `func (r *Report) OK() bool`; `func (r *Report) WriteTo(w io.Writer) (int64, error)` — Zeilen `✓ Name` / `✗ Name — Detail`
- `type Checker struct { Cfg *config.Config; ConfigPath string; FW Pinger; Dir DirectoryChecker; Now func() time.Time }`
- `type Pinger interface { Ping(ctx context.Context) (time.Time, error) }`
- `type DirectoryChecker interface { ResolveGroupDN(ctx context.Context) (string, error); Lookup(ctx context.Context, user string) (*auth.LookupResult, error) }`
- `func (c *Checker) Run(ctx context.Context, testAuthUser string) *Report`
- Prüfpunkte in dieser Reihenfolge: `Konfiguration`, `Dateirechte`, `OPNsense-Verbindung`, `Verzeichnisdienst`, `Systemzeit`, optional `Testanmeldung`
- `maxClockSkew = 2 * time.Minute`
- [ ] **Step 1: Failing test schreiben**
`internal/check/check_test.go`:
```go
package check
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
"git.ravensburg.dev/cabele/opnsense-portal/internal/config"
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
)
type fakePinger struct {
at time.Time
err error
}
func (f *fakePinger) Ping(ctx context.Context) (time.Time, error) { return f.at, f.err }
type fakeDir struct {
dn string
dnErr error
lookup *auth.LookupResult
lookErr error
}
func (f *fakeDir) ResolveGroupDN(ctx context.Context) (string, error) { return f.dn, f.dnErr }
func (f *fakeDir) Lookup(ctx context.Context, u string) (*auth.LookupResult, error) {
return f.lookup, f.lookErr
}
var checkNow = time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
func newChecker(t *testing.T, fw Pinger, dir DirectoryChecker) *Checker {
t.Helper()
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "config.yaml")
if err := os.WriteFile(cfgPath, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
os.Chmod(cfgPath, 0o600)
logDir := filepath.Join(tmp, "log")
os.MkdirAll(logDir, 0o750)
os.Chmod(logDir, 0o750)
cfg := config.Defaults()
cfg.Portal.TLSCert = filepath.Join(tmp, "portal.crt")
cfg.Portal.TLSKey = filepath.Join(tmp, "portal.key")
os.WriteFile(cfg.Portal.TLSCert, []byte("cert"), 0o644)
os.WriteFile(cfg.Portal.TLSKey, []byte("key"), 0o600)
os.Chmod(cfg.Portal.TLSKey, 0o600)
cfg.OPNsense.URL = "https://fw01.firma.local"
cfg.OPNsense.APIKey, cfg.OPNsense.APISecret = "k", "s"
cfg.AD.Domain = "firma.local"
cfg.AD.Servers = []string{"dc01.firma.local"}
cfg.AD.BindUser, cfg.AD.BindPassword = "svc@firma.local", "pw"
cfg.AD.VPNGroup = "VPN-Users"
cfg.Logging.AuditLog = filepath.Join(logDir, "audit.log")
return &Checker{Cfg: cfg, ConfigPath: cfgPath, FW: fw, Dir: dir,
Now: func() time.Time { return checkNow }}
}
func TestAllChecksPass(t *testing.T) {
c := newChecker(t, &fakePinger{at: checkNow},
&fakeDir{dn: "CN=VPN-Users,DC=firma,DC=local"})
rep := c.Run(context.Background(), "")
if !rep.OK() {
var b strings.Builder
rep.WriteTo(&b)
t.Fatalf("alle Prüfungen sollten bestehen:\n%s", b.String())
}
for _, want := range []string{"Konfiguration", "Dateirechte", "OPNsense", "Verzeichnisdienst", "Systemzeit"} {
var found bool
for _, r := range rep.Results {
if strings.Contains(r.Name, want) {
found = true
}
}
if !found {
t.Errorf("Prüfpunkt %q fehlt", want)
}
}
}
func TestFailedAPISecretIsNamed(t *testing.T) {
c := newChecker(t, &fakePinger{err: opnsense.ErrUnauthorized},
&fakeDir{dn: "CN=VPN-Users,DC=firma,DC=local"})
rep := c.Run(context.Background(), "")
if rep.OK() {
t.Fatal("falsches Secret muss auffallen")
}
var b strings.Builder
rep.WriteTo(&b)
out := b.String()
if !strings.Contains(out, "✗") {
t.Errorf("Ausgabe muss ein ✗ enthalten:\n%s", out)
}
if !strings.Contains(strings.ToLower(out), "zugangsdaten") {
t.Errorf("Ursache muss benannt werden:\n%s", out)
}
}
func TestMissingAPIPermissionIsDistinguished(t *testing.T) {
c := newChecker(t, &fakePinger{err: opnsense.ErrForbidden},
&fakeDir{dn: "CN=x"})
rep := c.Run(context.Background(), "")
var b strings.Builder
rep.WriteTo(&b)
if !strings.Contains(b.String(), "Client Export") {
t.Errorf("fehlende Berechtigung muss den nötigen Privilegnamen nennen:\n%s", b.String())
}
}
func TestUnreachableDCIsNamed(t *testing.T) {
c := newChecker(t, &fakePinger{at: checkNow},
&fakeDir{dnErr: errors.New("dc01.firma.local: connection refused")})
rep := c.Run(context.Background(), "")
if rep.OK() {
t.Fatal("nicht erreichbarer DC muss auffallen")
}
var b strings.Builder
rep.WriteTo(&b)
if !strings.Contains(b.String(), "dc01.firma.local") {
t.Errorf("betroffener DC muss genannt werden:\n%s", b.String())
}
}
func TestLooseConfigPermissionsAreDetected(t *testing.T) {
c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"})
os.Chmod(c.ConfigPath, 0o644)
rep := c.Run(context.Background(), "")
if rep.OK() {
t.Fatal("0644-Config muss auffallen")
}
var b strings.Builder
rep.WriteTo(&b)
if !strings.Contains(b.String(), "chmod") {
t.Errorf("Reparaturbefehl muss genannt werden:\n%s", b.String())
}
}
func TestLooseTLSKeyIsDetected(t *testing.T) {
c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"})
os.Chmod(c.Cfg.Portal.TLSKey, 0o644)
rep := c.Run(context.Background(), "")
if rep.OK() {
t.Fatal("world-readable TLS-Key muss auffallen")
}
}
func TestClockSkewWarns(t *testing.T) {
c := newChecker(t, &fakePinger{at: checkNow.Add(5 * time.Minute)}, &fakeDir{dn: "CN=x"})
rep := c.Run(context.Background(), "")
var b strings.Builder
rep.WriteTo(&b)
out := b.String()
if !strings.Contains(out, "NTP") {
t.Errorf("Zeitabweichung muss auf NTP hinweisen:\n%s", out)
}
if rep.OK() {
t.Error("Zeitabweichung über der Schwelle muss als Fehler zählen")
}
}
func TestSmallClockSkewIsFine(t *testing.T) {
c := newChecker(t, &fakePinger{at: checkNow.Add(30 * time.Second)},
&fakeDir{dn: "CN=VPN-Users,DC=firma,DC=local"})
if rep := c.Run(context.Background(), ""); !rep.OK() {
var b strings.Builder
rep.WriteTo(&b)
t.Fatalf("30 Sekunden Abweichung sind unkritisch:\n%s", b.String())
}
}
func TestTestAuthShowsLookupResult(t *testing.T) {
dir := &fakeDir{
dn: "CN=VPN-Users,DC=firma,DC=local",
lookup: &auth.LookupResult{
DN: "CN=Max Mueller,OU=Users,DC=firma,DC=local", SAMAccountName: "mmueller", InVPNGroup: true},
}
c := newChecker(t, &fakePinger{at: checkNow}, dir)
rep := c.Run(context.Background(), "mmueller")
var b strings.Builder
rep.WriteTo(&b)
out := b.String()
for _, want := range []string{"CN=Max Mueller", "mmueller", "VPN-Users"} {
if !strings.Contains(out, want) {
t.Errorf("Ausgabe von --test-auth enthält %q nicht:\n%s", want, out)
}
}
}
func TestTestAuthReportsMissingGroupMembership(t *testing.T) {
dir := &fakeDir{
dn: "CN=VPN-Users,DC=firma,DC=local",
lookup: &auth.LookupResult{DN: "CN=J Doe", SAMAccountName: "jdoe", InVPNGroup: false},
}
c := newChecker(t, &fakePinger{at: checkNow}, dir)
rep := c.Run(context.Background(), "jdoe")
if rep.OK() {
t.Fatal("fehlende Gruppenmitgliedschaft muss als Fehler gelten")
}
}
func TestReportOutputFormat(t *testing.T) {
rep := &Report{Results: []Result{
{Name: "Konfiguration", OK: true},
{Name: "Dateirechte", OK: false, Detail: "config.yaml ist 0644"},
}}
var b strings.Builder
if _, err := rep.WriteTo(&b); err != nil {
t.Fatal(err)
}
out := b.String()
if !strings.Contains(out, "✓ Konfiguration") {
t.Errorf("Erfolgszeile falsch:\n%s", out)
}
if !strings.Contains(out, "✗ Dateirechte") || !strings.Contains(out, "config.yaml ist 0644") {
t.Errorf("Fehlerzeile falsch:\n%s", out)
}
}
```
- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./internal/check/ -v`
Expected: FAIL — `undefined: Checker`
- [ ] **Step 3: `check.go` schreiben**
```go
// Package check prüft beim Start und über das check-Kommando die gesamte
// Wirkungskette: Konfiguration, Dateirechte, Firewall, Verzeichnis, Uhrzeit.
package check
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
"git.ravensburg.dev/cabele/opnsense-portal/internal/config"
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
)
// maxClockSkew ist die tolerierte Abweichung zur Firewall-Uhrzeit.
const maxClockSkew = 2 * time.Minute
// Result ist das Ergebnis eines Prüfpunkts.
type Result struct {
Name string
OK bool
Detail string
}
// Report bündelt alle Prüfergebnisse.
type Report struct {
Results []Result
}
// OK meldet, ob alle Prüfungen bestanden wurden.
func (r *Report) OK() bool {
for _, res := range r.Results {
if !res.OK {
return false
}
}
return true
}
// WriteTo gibt die Checkliste aus.
func (r *Report) WriteTo(w io.Writer) (int64, error) {
var total int64
for _, res := range r.Results {
mark := "✓"
if !res.OK {
mark = "✗"
}
line := fmt.Sprintf("%s %s", mark, res.Name)
if res.Detail != "" {
line += "\n " + strings.ReplaceAll(res.Detail, "\n", "\n ")
}
n, err := io.WriteString(w, line+"\n")
total += int64(n)
if err != nil {
return total, err
}
}
return total, nil
}
// Pinger ist der benötigte Ausschnitt des Firewall-Clients.
type Pinger interface {
Ping(ctx context.Context) (time.Time, error)
}
// DirectoryChecker ist der benötigte Ausschnitt des AD-Authenticators.
type DirectoryChecker interface {
ResolveGroupDN(ctx context.Context) (string, error)
Lookup(ctx context.Context, username string) (*auth.LookupResult, error)
}
// Checker führt die Prüfungen aus.
type Checker struct {
Cfg *config.Config
ConfigPath string
FW Pinger
Dir DirectoryChecker
Now func() time.Time
}
// Run führt alle Prüfungen aus. testAuthUser leer = keine Testanmeldung.
func (c *Checker) Run(ctx context.Context, testAuthUser string) *Report {
now := c.Now
if now == nil {
now = time.Now
}
rep := &Report{}
add := func(name string, err error, detail string) {
if err != nil {
rep.Results = append(rep.Results, Result{Name: name, OK: false, Detail: err.Error()})
return
}
rep.Results = append(rep.Results, Result{Name: name, OK: true, Detail: detail})
}
// 1. Konfiguration
add("Konfiguration", c.Cfg.Validate(), "gelesen und plausibel")
// 2. Dateirechte
add("Dateirechte", c.checkPermissions(), "Konfiguration, Secrets und Log-Verzeichnis sind ausreichend geschützt")
// 3. OPNsense
serverTime, fwErr := c.checkFirewall(ctx)
add("OPNsense-Verbindung", fwErr, "Export-API erreichbar, Key und Berechtigung in Ordnung")
// 4. Verzeichnisdienst
groupDN, dirErr := c.checkDirectory(ctx)
add("Verzeichnisdienst", dirErr, "Anmeldung des Dienstkontos erfolgreich, Gruppe aufgelöst: "+groupDN)
// 5. Systemzeit (nur sinnvoll, wenn die Firewall geantwortet hat)
add("Systemzeit", checkClock(now(), serverTime, fwErr), "Abweichung zur Firewall unterhalb der Schwelle")
// 6. Optionale Testanmeldung
if testAuthUser != "" {
detail, err := c.checkTestAuth(ctx, testAuthUser)
add("Testanmeldung "+testAuthUser, err, detail)
}
return rep
}
// checkPermissions prüft Config, Secret-Dateien, TLS-Key und Log-Verzeichnis.
func (c *Checker) checkPermissions() error {
var errs []error
if c.ConfigPath != "" {
if err := config.CheckFileMode(c.ConfigPath, 0o640); err != nil {
errs = append(errs, err)
}
}
for _, p := range []string{
c.Cfg.OPNsense.APIKeyFile, c.Cfg.OPNsense.APISecretFile, c.Cfg.AD.BindPasswordFile,
} {
if p == "" {
continue
}
if err := config.CheckFileMode(p, 0o640); err != nil {
errs = append(errs, err)
}
}
// Der private TLS-Schlüssel darf niemandem außer dem Eigentümer offenstehen.
if c.Cfg.Portal.TLSKey != "" {
if err := config.CheckFileMode(c.Cfg.Portal.TLSKey, 0o600); err != nil {
errs = append(errs, err)
}
}
if c.Cfg.Logging.AuditLog != "" {
dir := filepath.Dir(c.Cfg.Logging.AuditLog)
if _, err := os.Stat(dir); err == nil {
if err := config.CheckDirMode(dir, 0o750); err != nil {
errs = append(errs, err)
}
} else {
errs = append(errs, fmt.Errorf("Log-Verzeichnis %s existiert nicht: %w", dir, err))
}
}
return errors.Join(errs...)
}
// checkFirewall ruft den Export-Endpunkt auf und übersetzt die Fehler.
func (c *Checker) checkFirewall(ctx context.Context) (time.Time, error) {
if c.FW == nil {
return time.Time{}, errors.New("kein Firewall-Client konfiguriert")
}
t, err := c.FW.Ping(ctx)
switch {
case err == nil:
return t, nil
case errors.Is(err, opnsense.ErrUnauthorized):
return time.Time{}, fmt.Errorf(
"die Zugangsdaten wurden abgelehnt — opnsense.api_key und api_secret prüfen (%v)", err)
case errors.Is(err, opnsense.ErrForbidden):
return time.Time{}, fmt.Errorf(
"der API-Benutzer hat keine Berechtigung — auf der OPNsense das Privileg "+
"\"VPN: OpenVPN Client Export\" zuweisen (%v)", err)
case errors.Is(err, opnsense.ErrUnreachable):
return time.Time{}, fmt.Errorf(
"die Firewall ist nicht erreichbar — URL, Netzwerkweg und CA prüfen (%v)", err)
default:
return time.Time{}, err
}
}
// checkDirectory bindet den Service-Account und löst die Gruppe auf.
func (c *Checker) checkDirectory(ctx context.Context) (string, error) {
if c.Dir == nil {
return "", errors.New("kein Verzeichnis-Client konfiguriert")
}
dn, err := c.Dir.ResolveGroupDN(ctx)
if err != nil {
return "", fmt.Errorf("Verzeichnisdienst nicht nutzbar: %w", err)
}
if dn == "" {
return "", fmt.Errorf("Gruppe %q konnte nicht aufgelöst werden", c.Cfg.AD.VPNGroup)
}
return dn, nil
}
// checkClock vergleicht lokale Zeit und Firewall-Zeit.
func checkClock(local, remote time.Time, fwErr error) error {
if fwErr != nil {
return errors.New("nicht prüfbar, weil die Firewall nicht geantwortet hat")
}
if remote.IsZero() {
return nil // Die Firewall hat keinen auswertbaren Date-Header geliefert.
}
skew := local.Sub(remote)
if skew < 0 {
skew = -skew
}
if skew > maxClockSkew {
return fmt.Errorf(
"die Systemzeit weicht um %s von der Firewall ab — bitte NTP einrichten "+
"(abgelaufene Zertifikate würden sonst falsch bewertet)", skew.Round(time.Second))
}
return nil
}
// checkTestAuth spielt Suche und Gruppenprüfung ohne Passwort durch.
func (c *Checker) checkTestAuth(ctx context.Context, username string) (string, error) {
res, err := c.Dir.Lookup(ctx, username)
if err != nil {
return "", err
}
detail := fmt.Sprintf("DN: %s\nsAMAccountName: %s\nMitglied in %s: ja",
res.DN, res.SAMAccountName, c.Cfg.AD.VPNGroup)
if !res.InVPNGroup {
return "", fmt.Errorf("DN: %s\nsAMAccountName: %s\nMitglied in %s: NEIN — "+
"dieser Benutzer würde abgewiesen", res.DN, res.SAMAccountName, c.Cfg.AD.VPNGroup)
}
return detail, nil
}
```
- [ ] **Step 4: Tests laufen lassen**
Run: `go test ./internal/check/ -v`
Expected: PASS
- [ ] **Step 5: `cmd/vpnportal/check.go` verdrahten**
```go
package main
import (
"context"
"fmt"
"io"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
"git.ravensburg.dev/cabele/opnsense-portal/internal/check"
"git.ravensburg.dev/cabele/opnsense-portal/internal/config"
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
)
func runCheck(args []string, stdout, stderr io.Writer) int {
fs, path := configFlags("check", stderr)
testAuth := fs.String("test-auth", "",
"Suche und Gruppenprüfung für einen konkreten Benutzer durchspielen (ohne Passwort)")
if err := fs.Parse(args); err != nil {
return 2
}
cfg, err := config.Load(*path, nil)
if err != nil {
// Ohne lesbare Konfiguration lässt sich nichts weiter prüfen.
fmt.Fprintf(stderr, "✗ Konfiguration\n %v\n", err)
return 1
}
fw, ad, buildErr := buildBackends(cfg)
if buildErr != nil {
fmt.Fprintf(stderr, "✗ Initialisierung\n %v\n", buildErr)
return 1
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
c := &check.Checker{Cfg: cfg, ConfigPath: *path, FW: fw, Dir: ad}
report := c.Run(ctx, *testAuth)
report.WriteTo(stdout)
if !report.OK() {
fmt.Fprintln(stdout, "\nEs sind Fehler aufgetreten. Das Portal würde nicht korrekt arbeiten.")
return 1
}
fmt.Fprintln(stdout, "\nAlle Prüfungen bestanden.")
return 0
}
// buildBackends erzeugt Firewall- und Verzeichnis-Client aus der Konfiguration.
// Wird von serve und check gemeinsam genutzt.
func buildBackends(cfg *config.Config) (*opnsense.Client, *auth.AD, error) {
fw, err := opnsense.New(opnsense.Options{
BaseURL: cfg.OPNsense.URL,
APIKey: cfg.OPNsense.APIKey,
APISecret: cfg.OPNsense.APISecret,
CAFile: cfg.OPNsense.CAFile,
InsecureSkipVerify: cfg.OPNsense.InsecureSkipVerify,
Timeout: 20 * time.Second,
})
if err != nil {
return nil, nil, err
}
ad, err := auth.NewAD(auth.Options{
Servers: cfg.AD.Servers,
Port: cfg.AD.Port,
TLSMode: cfg.AD.TLSMode,
BaseDN: cfg.EffectiveBaseDN(),
BindUser: cfg.AD.BindUser,
BindPassword: cfg.AD.BindPassword,
VPNGroup: cfg.AD.VPNGroup,
CAFile: cfg.AD.CAFile,
Timeout: time.Duration(cfg.AD.Timeout),
})
if err != nil {
return nil, nil, err
}
return fw, ad, nil
}
```
- [ ] **Step 6: Build und Tests prüfen**
Run: `go build ./... && go test ./... `
Expected: Build ohne Ausgabe, alle Tests PASS
- [ ] **Step 7: `check` gegen eine unbrauchbare Konfiguration ausprobieren**
```bash
mkdir -p /tmp/vpncheck && printf 'portal:\n tls_cert_typo: "x"\n' > /tmp/vpncheck/config.yaml
chmod 600 /tmp/vpncheck/config.yaml
go run ./cmd/vpnportal check --config /tmp/vpncheck/config.yaml; echo "Exit: $?"
```
Expected: Ausgabe nennt `tls_cert_typo` als unbekanntes Feld, Exit-Code 1
- [ ] **Step 8: Commit**
```bash
git add internal/check cmd/vpnportal
git commit -m "feat(check): Startup-Validierung mit Checkliste und --test-auth"
```
---
## Task 17: `serve` — Verdrahtung, HTTPS-Server und Signal-Handling
**Files:**
- Modify: `cmd/vpnportal/serve.go`
- Create: `cmd/vpnportal/health.go`
- Test: `cmd/vpnportal/serve_test.go`
**Interfaces:**
- Consumes: alles Bisherige
- Produces:
- `func runServe(args []string, stdout, stderr io.Writer) int`
- `type healthChecker struct { fw check.Pinger; dir web.HealthDirectory; clock func() time.Time }` mit Ergebnis-Cache von 30 s, damit Monitoring die Backends nicht flutet
- `func newTLSConfig(certFile, keyFile string) (*tls.Config, error)` — TLS 1.2+, moderne Cipher-Reihenfolge
- Startverhalten: Config laden → `check` ausführen → bei Fehlern abbrechen → Audit `startup` mit Version und Config-Prüfsumme → HTTPS-Server starten
- Signale: `SIGHUP` → Audit-Log neu öffnen; `SIGINT`/`SIGTERM` → geordneter Shutdown mit 10 s Frist
- Hintergrund: Session- und Limiter-GC alle 60 s
- [ ] **Step 1: Failing test schreiben**
`cmd/vpnportal/serve_test.go`:
```go
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestServeRejectsMissingConfig(t *testing.T) {
var out, errOut bytes.Buffer
code := runServe([]string{"--config", "/gibt/es/nicht.yaml"}, &out, &errOut)
if code == 0 {
t.Fatal("fehlende Config muss zum Abbruch führen")
}
if !strings.Contains(errOut.String(), "nicht.yaml") {
t.Errorf("Fehlermeldung muss den Pfad nennen: %q", errOut.String())
}
}
func TestServeRejectsLooseConfigPermissions(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.yaml")
os.WriteFile(p, []byte("portal:\n listen: \"127.0.0.1:0\"\n"), 0o644)
os.Chmod(p, 0o644)
var out, errOut bytes.Buffer
if code := runServe([]string{"--config", p}, &out, &errOut); code == 0 {
t.Fatal("zu offene Dateirechte müssen den Start verhindern")
}
if !strings.Contains(errOut.String(), "chmod") {
t.Errorf("Fehlermeldung sollte den Reparaturbefehl nennen: %q", errOut.String())
}
}
func TestConfigChecksumIsStable(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.yaml")
os.WriteFile(p, []byte("inhalt"), 0o600)
os.Chmod(p, 0o600)
a, err := configChecksum(p)
if err != nil {
t.Fatal(err)
}
b, _ := configChecksum(p)
if a != b || a == "" {
t.Fatalf("Prüfsumme instabil: %q vs %q", a, b)
}
if len(a) != 12 {
t.Errorf("Prüfsumme sollte gekürzt sein (12 Zeichen), ist %d", len(a))
}
os.WriteFile(p, []byte("anderer inhalt"), 0o600)
if c, _ := configChecksum(p); c == a {
t.Error("geänderte Datei muss eine andere Prüfsumme ergeben")
}
}
func TestNewTLSConfigRejectsMissingFiles(t *testing.T) {
if _, err := newTLSConfig("/nix.crt", "/nix.key"); err == nil {
t.Fatal("fehlende Zertifikatsdateien müssen abgelehnt werden")
}
}
```
- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**
Run: `go test ./cmd/vpnportal/ -v`
Expected: FAIL — `undefined: configChecksum`
- [ ] **Step 3: `health.go` schreiben**
```go
package main
import (
"context"
"sync"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/check"
"git.ravensburg.dev/cabele/opnsense-portal/internal/web"
)
// healthCacheTTL verhindert, dass häufige Monitoring-Abfragen Firewall und
// Verzeichnis belasten.
const healthCacheTTL = 30 * time.Second
// directoryPinger ist der für /healthz benötigte Ausschnitt des AD-Clients.
type directoryPinger interface {
ResolveGroupDN(ctx context.Context) (string, error)
}
// healthChecker beantwortet /healthz mit kurz gecachten Ergebnissen.
type healthChecker struct {
fw check.Pinger
dir directoryPinger
clock func() time.Time
mu sync.Mutex
cached web.HealthReport
cachedAt time.Time
}
func newHealthChecker(fw check.Pinger, dir directoryPinger) *healthChecker {
return &healthChecker{fw: fw, dir: dir, clock: time.Now}
}
func (h *healthChecker) Check(ctx context.Context) web.HealthReport {
now := h.clock()
h.mu.Lock()
if !h.cachedAt.IsZero() && now.Sub(h.cachedAt) < healthCacheTTL {
rep := h.cached
h.mu.Unlock()
return rep
}
h.mu.Unlock()
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
rep := web.HealthReport{}
_, fwErr := h.fw.Ping(ctx)
rep.OPNsense = fwErr == nil
_, dirErr := h.dir.ResolveGroupDN(ctx)
rep.Directory = dirErr == nil
rep.OK = rep.OPNsense && rep.Directory
h.mu.Lock()
h.cached, h.cachedAt = rep, now
h.mu.Unlock()
return rep
}
var _ web.HealthChecker = (*healthChecker)(nil)
```
- [ ] **Step 4: `serve.go` schreiben**
```go
package main
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
"git.ravensburg.dev/cabele/opnsense-portal/internal/certmatch"
"git.ravensburg.dev/cabele/opnsense-portal/internal/check"
"git.ravensburg.dev/cabele/opnsense-portal/internal/config"
"git.ravensburg.dev/cabele/opnsense-portal/internal/portal"
"git.ravensburg.dev/cabele/opnsense-portal/internal/web"
)
const defaultConfigPath = "/etc/vpnportal/config.yaml"
// providerCacheTTL ist die einzige erlaubte Cache-Ausnahme (VPN-Instanzen).
const providerCacheTTL = 5 * time.Minute
func configFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(stderr)
path := fs.String("config", defaultConfigPath, "Pfad zur Konfigurationsdatei")
return fs, path
}
// configChecksum liefert eine gekürzte Prüfsumme der Konfigurationsdatei
// für das startup-Ereignis — sie erlaubt, Konfigurationsstände zu
// unterscheiden, ohne Inhalte zu protokollieren.
func configChecksum(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])[:12], nil
}
// newTLSConfig lädt Zertifikat und Schlüssel des Portals.
func newTLSConfig(certFile, keyFile string) (*tls.Config, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("TLS-Zertifikat/-Schlüssel konnten nicht geladen werden: %w", err)
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
}, nil
}
func runServe(args []string, stdout, stderr io.Writer) int {
fs, path := configFlags("serve", stderr)
if err := fs.Parse(args); err != nil {
return 2
}
cfg, err := config.Load(*path, nil)
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
if cfg.OPNsense.InsecureSkipVerify {
// Bewusst bei jedem Start, nicht nur einmalig.
fmt.Fprintln(stderr,
"WARNUNG: opnsense.insecure_skip_verify ist aktiv — "+
"die TLS-Verbindung zur Firewall wird NICHT geprüft. Nur für Tests verwenden.")
}
fw, ad, err := buildBackends(cfg)
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
// Startup-Validierung: das Portal startet nicht mit kaputter Umgebung.
startCtx, startCancel := context.WithTimeout(context.Background(), 60*time.Second)
report := (&check.Checker{Cfg: cfg, ConfigPath: *path, FW: fw, Dir: ad}).Run(startCtx, "")
startCancel()
report.WriteTo(stdout)
if !report.OK() {
fmt.Fprintln(stderr, "\nStart abgebrochen: die Startprüfung ist fehlgeschlagen.")
return 1
}
auditLog, err := audit.New(cfg.Logging.AuditLog, audit.Options{
MaxSizeMB: cfg.Logging.MaxSizeMB,
MaxBackups: cfg.Logging.MaxBackups,
Compress: cfg.Logging.Compress,
Stdout: stdout,
})
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
defer auditLog.Close()
matcher, err := certmatch.NewMatcher(cfg.Matching.CNPattern, cfg.Matching.CNRegex)
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
certs := portal.NewSource(fw, matcher, providerCacheTTL, time.Now)
renderer, err := web.NewRenderer(web.Brand{
Title: cfg.Portal.Title,
SupportContact: cfg.Portal.SupportContact,
Version: version,
})
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
sessions := web.NewSessionStore(time.Duration(cfg.Portal.SessionTTL), time.Now)
limiter := web.NewLimiter(time.Now)
srv, err := web.NewServer(web.Deps{
Auth: ad,
Certs: certs,
Audit: auditLog,
Sessions: sessions,
Limiter: limiter,
Renderer: renderer,
Health: newHealthChecker(fw, ad),
Clock: time.Now,
LogoPath: cfg.Portal.LogoFile,
SupportContact: cfg.Portal.SupportContact,
SessionTTL: time.Duration(cfg.Portal.SessionTTL),
})
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
tlsCfg, err := newTLSConfig(cfg.Portal.TLSCert, cfg.Portal.TLSKey)
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
sum, _ := configChecksum(*path)
auditLog.Log(audit.Event{
Event: audit.EventStartup, Version: versionString(), ConfigSum: sum,
Detail: "listen=" + cfg.Portal.Listen,
})
httpSrv := &http.Server{
Addr: cfg.Portal.Listen,
Handler: srv.Handler(),
TLSConfig: tlsCfg,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
// WriteTimeout bleibt großzügig, weil Downloads gestreamt werden.
WriteTimeout: 5 * time.Minute,
IdleTimeout: 60 * time.Second,
ErrorLog: nil,
}
// Hintergrundaufräumen für Sessions und Rate-Limiter.
stopGC := make(chan struct{})
go func() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
sessions.GC()
limiter.GC()
case <-stopGC:
return
}
}
}()
defer close(stopGC)
// SIGHUP öffnet das Audit-Log neu (logrotate-Umgebungen).
hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
defer signal.Stop(hup)
go func() {
for range hup {
if err := auditLog.Reopen(); err != nil {
fmt.Fprintf(stderr, "Audit-Log konnte nicht neu geöffnet werden: %v\n", err)
}
}
}()
// Geordnetes Herunterfahren.
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM)
errCh := make(chan error, 1)
go func() {
fmt.Fprintf(stdout, "%s lauscht auf https://%s\n", versionString(), cfg.Portal.Listen)
errCh <- httpSrv.ListenAndServeTLS("", "")
}()
select {
case err := <-errCh:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
fmt.Fprintf(stderr, "Fehler: Server beendet: %v\n", err)
return 1
}
return 0
case sig := <-shutdown:
fmt.Fprintf(stdout, "Signal %s empfangen, fahre herunter…\n", sig)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpSrv.Shutdown(ctx); err != nil {
fmt.Fprintf(stderr, "Herunterfahren nicht sauber beendet: %v\n", err)
return 1
}
return 0
}
}
```
Hinweis: `auditLog` erfüllt `web.AuditSink`, weil `*audit.Logger` die Methode `Log(audit.Event)` hat.
- [ ] **Step 5: Tests laufen lassen**
Run: `go test ./... `
Expected: PASS
- [ ] **Step 6: Ende-zu-Ende-Sichtprüfung im Browser vorbereiten**
Selbstsigniertes Zertifikat und Minimal-Config erzeugen; das Portal startet erst, wenn AD und OPNsense antworten, deshalb wird hier nur der Fehlerpfad geprüft:
```bash
mkdir -p /tmp/vpnportal-dev/log
openssl req -x509 -newkey rsa:2048 -nodes -days 30 \
-keyout /tmp/vpnportal-dev/portal.key -out /tmp/vpnportal-dev/portal.crt \
-subj "/CN=localhost" 2>/dev/null
chmod 600 /tmp/vpnportal-dev/portal.key
cat > /tmp/vpnportal-dev/config.yaml <<'EOF'
portal:
listen: "127.0.0.1:8443"
tls_cert: "/tmp/vpnportal-dev/portal.crt"
tls_key: "/tmp/vpnportal-dev/portal.key"
title: "VPN-Portal Testfirma"
support_contact: "it-helpdesk@testfirma.de"
opnsense:
url: "https://fw01.testfirma.local"
api_key: "testkey"
api_secret: "testsecret"
ad:
domain: "testfirma.local"
servers: ["dc01.testfirma.local"]
bind_user: "svc-vpnportal@testfirma.local"
bind_password: "testpw"
vpn_group: "VPN-Users"
logging:
audit_log: "/tmp/vpnportal-dev/log/audit.log"
EOF
chmod 600 /tmp/vpnportal-dev/config.yaml
go run ./cmd/vpnportal serve --config /tmp/vpnportal-dev/config.yaml; echo "Exit: $?"
```
Expected: Die Startprüfung meldet `✗ OPNsense-Verbindung` und `✗ Verzeichnisdienst` mit konkreten Meldungen, Exit-Code 1. Genau dieses Verhalten ist gewollt: das Portal startet nicht mit kaputter Umgebung.
- [ ] **Step 7: Oberfläche isoliert im Browser prüfen**
Damit die Optik ohne AD/Firewall begutachtet werden kann, einen Testserver mit Fakes starten. Datei `cmd/vpnportal/uidemo_test.go` anlegen:
```go
//go:build uidemo
package main
// Dieser Test startet das Portal mit Attrappen für AD und Firewall auf
// http://127.0.0.1:8080, damit die Oberfläche ohne echte Backends geprüft
// werden kann. Aufruf:
// go test -tags uidemo ./cmd/vpnportal/ -run TestUIDemo -v -timeout 0
// Anmeldung mit beliebigem Benutzernamen und Passwort.
import (
"context"
"net/http"
"testing"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
"git.ravensburg.dev/cabele/opnsense-portal/internal/web"
)
type demoAuth struct{}
func (demoAuth) Authenticate(ctx context.Context, u, p string) (*auth.Identity, error) {
return &auth.Identity{Username: "mmueller", Groups: []string{"VPN-Users"}}, nil
}
type demoCerts struct{}
func (demoCerts) EntriesFor(ctx context.Context, user string) ([]web.CertEntry, error) {
return []web.CertEntry{
{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller",
ValidTo: time.Now().AddDate(1, 0, 0)},
{Token: "2:b1", InstanceName: "VPN Außendienst", CommonName: "mmueller",
ValidTo: time.Now().AddDate(0, 0, 12)},
}, nil
}
func (demoCerts) Export(ctx context.Context, user, vpnID, refID, format string) (*web.ExportStream, error) {
return nil, web.ErrCertNotOwned
}
type demoAudit struct{ t *testing.T }
func (d demoAudit) Log(e audit.Event) { d.t.Logf("audit: %+v", e) }
func TestUIDemo(t *testing.T) {
rn, err := web.NewRenderer(web.Brand{Title: "VPN-Portal Testfirma", Version: "uidemo"})
if err != nil {
t.Fatal(err)
}
srv, err := web.NewServer(web.Deps{
Auth: demoAuth{}, Certs: demoCerts{}, Audit: demoAudit{t},
Sessions: web.NewSessionStore(10*time.Minute, nil),
Limiter: web.NewLimiter(nil), Renderer: rn,
SupportContact: "it-helpdesk@testfirma.de", SessionTTL: 10 * time.Minute,
})
if err != nil {
t.Fatal(err)
}
t.Log("Oberfläche unter http://127.0.0.1:8080 — mit Strg-C beenden")
// Achtung: Das Cookie ist Secure; für die reine Optikprüfung reicht die
// Login-Seite. Für den angemeldeten Bereich das Zertifikat aus Step 6
// verwenden und https nutzen.
http.ListenAndServe("127.0.0.1:8080", srv.Handler())
}
```
Ausführen und die Login-Seite im Browser ansehen:
```bash
go test -tags uidemo ./cmd/vpnportal/ -run TestUIDemo -v -timeout 0
```
Expected: Login-Seite mit Titel, Formular, Fußzeile; Layout bricht auf Handybreite sauber um. Anschließend mit Strg-C beenden.
- [ ] **Step 8: Optionalen Versionscheck implementieren**
PRD §10 verlangt einen abschaltbaren, read-only Versionscheck mit Default **aus**.
Zuerst der Test, `cmd/vpnportal/updatecheck_test.go`:
```go
package main
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestUpdateCheckDisabledMakesNoRequest(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
}))
defer srv.Close()
if msg := checkForUpdate(context.Background(), false, srv.URL, "1.0.0", srv.Client()); msg != "" {
t.Errorf("bei update_check=false darf nichts gemeldet werden, got %q", msg)
}
if called {
t.Fatal("bei update_check=false darf kein Netzwerkzugriff stattfinden")
}
}
func TestUpdateCheckReportsNewerVersion(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, `{"tag_name":"v1.4.0"}`)
}))
defer srv.Close()
msg := checkForUpdate(context.Background(), true, srv.URL, "1.2.0", srv.Client())
if msg == "" {
t.Fatal("neuere Version muss gemeldet werden")
}
if !contains(msg, "1.4.0") {
t.Errorf("Meldung nennt die neue Version nicht: %q", msg)
}
}
func TestUpdateCheckSilentWhenCurrent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, `{"tag_name":"v1.2.0"}`)
}))
defer srv.Close()
if msg := checkForUpdate(context.Background(), true, srv.URL, "1.2.0", srv.Client()); msg != "" {
t.Errorf("bei aktueller Version darf nichts gemeldet werden, got %q", msg)
}
}
func TestUpdateCheckSurvivesUnreachableEndpoint(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
url := srv.URL
client := srv.Client()
srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Ein nicht erreichbarer Endpunkt darf den Start niemals stören.
if msg := checkForUpdate(ctx, true, url, "1.2.0", client); msg != "" {
t.Errorf("bei Fehler darf nichts gemeldet werden, got %q", msg)
}
}
func contains(haystack, needle string) bool {
return len(haystack) >= len(needle) && (func() bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
})()
}
```
Run: `go test ./cmd/vpnportal/ -run TestUpdateCheck -v`
Expected: FAIL — `undefined: checkForUpdate`
Dann `cmd/vpnportal/updatecheck.go`:
```go
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// releaseURL ist der Endpunkt des Versionschecks. Er wird nur abgefragt,
// wenn portal.update_check ausdrücklich eingeschaltet ist (Default: aus).
const releaseURL = "https://git.ravensburg.dev/api/v1/repos/cabele/opnsense-portal/releases/latest"
// checkForUpdate fragt read-only die neueste Version ab und liefert einen
// Hinweistext, falls eine neuere existiert. Jeder Fehler führt zu einer
// leeren Rückgabe — der Check darf den Start unter keinen Umständen stören.
func checkForUpdate(ctx context.Context, enabled bool, url, current string, client *http.Client) string {
if !enabled {
return ""
}
if client == nil {
client = &http.Client{Timeout: 5 * time.Second}
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return ""
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "vpnportal/"+current)
resp, err := client.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
var release struct {
TagName string `json:"tag_name"`
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil || json.Unmarshal(raw, &release) != nil {
return ""
}
latest := strings.TrimPrefix(strings.TrimSpace(release.TagName), "v")
if latest == "" || latest == strings.TrimPrefix(current, "v") {
return ""
}
return fmt.Sprintf("Hinweis: Version %s ist verfügbar (installiert: %s).", latest, current)
}
```
In `runServe` direkt nach dem `startup`-Audit-Ereignis einhängen:
```go
if msg := checkForUpdate(context.Background(), cfg.Portal.UpdateCheck, releaseURL, version, nil); msg != "" {
fmt.Fprintln(stdout, msg)
}
```
Run: `go test ./cmd/vpnportal/ -v`
Expected: PASS
Der Vergleich ist bewusst ein reiner Gleichheitstest, keine Semver-Ordnung: das Portal soll melden, dass sich die veröffentlichte Version unterscheidet, nicht selbst über Versionsordnungen urteilen. Im README ist transparent zu machen, dass dieser Check eine ausgehende HTTPS-Verbindung öffnet und deshalb standardmäßig aus ist.
- [ ] **Step 9: `go vet` über alle Build-Tags**
Run: `go vet ./... && go vet -tags integration ./... && go vet -tags uidemo ./...`
Expected: kein Befund
- [ ] **Step 10: Commit**
```bash
git add cmd/vpnportal
git commit -m "feat(serve): Verdrahtung, HTTPS-Server, Startprüfung, SIGHUP und geordneter Shutdown"
```
---
## Task 18: Deployment-Artefakte und Dokumentation
**Files:**
- Create: `deploy/vpnportal.service`, `deploy/config.example.yaml`
- Create: `README.md`
- Modify: `Makefile` (Release-Ziel prüft, dass die Artefakte existieren)
**Interfaces:**
- Consumes: alle Konfigurationsfelder aus Task 24, CLI aus Task 1/16/17
- Produces: lauffähiges Release-Archiv über `make release`
- [ ] **Step 1: systemd-Unit schreiben**
`deploy/vpnportal.service`:
```ini
[Unit]
Description=VPN-Konfig-Portal für OPNsense
Documentation=file:/usr/share/doc/vpnportal/README.md
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/vpnportal serve --config /etc/vpnportal/config.yaml
Restart=on-failure
RestartSec=5s
# Dedizierter, zustandsloser Dienstbenutzer. Alternativ einen festen
# Systembenutzer anlegen und User=/Group= setzen — siehe README.
DynamicUser=yes
# Für das Audit-Log; systemd legt /var/log/vpnportal an und übergibt es dem Dienst.
LogsDirectory=vpnportal
LogsDirectoryMode=0750
# Secrets werden nicht als Umgebungsvariablen übergeben, sondern als Dateien
# unter $CREDENTIALS_DIRECTORY. In der config.yaml dann z. B.:
# bind_password_file: "/run/credentials/vpnportal.service/ad-bind-password"
LoadCredential=ad-bind-password:/etc/vpnportal/secrets/ad-bind-password
LoadCredential=opnsense-api-secret:/etc/vpnportal/secrets/opnsense-api-secret
# Härtung
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
NoNewPrivileges=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectProc=invisible
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
RestrictAddressFamilies=AF_INET AF_INET6
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
CapabilityBoundingSet=
# Falls das Portal direkt auf 443 lauschen soll, stattdessen:
# AmbientCapabilities=CAP_NET_BIND_SERVICE
# CapabilityBoundingSet=CAP_NET_BIND_SERVICE
ReadOnlyPaths=/etc/vpnportal
UMask=0077
[Install]
WantedBy=multi-user.target
```
- [ ] **Step 2: Beispiel-Konfiguration schreiben**
`deploy/config.example.yaml`: die vollständige Konfiguration aus der PRD, mit auskommentierten Optionalfeldern und einem Kommentar pro Feld. Pflichtfelder unkommentiert mit Platzhalterwerten (`https://fw01.firma.local`, `firma.local`, …); alle Secrets als Platzhalter `"BITTE-ERSETZEN"` **mit** danebenstehendem `# alternativ: *_file` -Hinweis. Der Kopf der Datei trägt den Hinweis:
```yaml
# Diese Datei enthält Zugangsdaten. Rechte setzen:
# chown root:vpnportal /etc/vpnportal/config.yaml && chmod 640 /etc/vpnportal/config.yaml
# Das Portal startet nicht, wenn die Rechte weiter gefasst sind.
```
- [ ] **Step 3: Prüfen, dass die Beispiel-Konfiguration parst**
Ein Test stellt sicher, dass die ausgelieferte Beispieldatei mit dem Strict-Parser zusammenpasst — `config/example_test.go`:
```go
package config
import (
"os"
"testing"
)
// Die ausgelieferte Beispielkonfiguration muss immer parsen und validieren.
// Sonst laufen Kunden beim ersten Start in einen Fehler.
func TestExampleConfigParsesAndValidates(t *testing.T) {
f, err := os.Open("../../deploy/config.example.yaml")
if err != nil {
t.Fatalf("Beispielkonfiguration fehlt: %v", err)
}
defer f.Close()
cfg, err := Parse(f)
if err != nil {
t.Fatalf("Beispielkonfiguration parst nicht: %v", err)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Beispielkonfiguration ist nicht gültig: %v", err)
}
}
```
Run: `go test ./internal/config/ -run TestExampleConfig -v`
Expected: PASS (notfalls die Beispieldatei korrigieren, nicht den Test)
- [ ] **Step 4: README schreiben**
`README.md` mit diesen Abschnitten:
1. **Was das Portal tut** — Ein-Absatz-Beschreibung, ausdrücklich: read-only, erstellt und widerruft keine Zertifikate.
2. **Voraussetzungen** — Linux amd64 oder arm64; OPNsense mit Plugin `os-openvpn-client-export` (Mindestversion aus `docs/opnsense-api.md`); Active Directory mit LDAPS; NTP zwingend; TLS-Zertifikat für das Portal.
3. **Installation in unter 30 Minuten** — nummerierte Schritte: Archiv entpacken, Binary nach `/usr/local/bin`, `/etc/vpnportal` anlegen, Beispiel-Config kopieren und anpassen, Rechte setzen (`chmod 640`, `chmod 600` für den TLS-Key), `vpnportal check` ausführen, systemd-Unit installieren, `systemctl enable --now vpnportal`.
4. **OPNsense vorbereiten** — Benutzer anlegen, Gruppe mit **ausschließlich** dem Privileg „VPN: OpenVPN Client Export", API-Key erzeugen, keinen Admin-Key verwenden.
5. **Active Directory vorbereiten** — unprivilegierter Dienstbenutzer genügt; Gruppe `VPN-Users`; Hostnamen statt IP-Adressen in `ad.servers`, weil das LDAPS-Zertifikat gegen den Hostnamen geprüft wird; interne CA über `ca_file`.
6. **Secrets** — die drei `*_file`-Varianten, die drei `VPNPORTAL_*`-Variablen, Rangfolge Env > Datei > Inline, `LoadCredential`-Beispiel aus der Unit.
7. **Zertifikatszuordnung**`cn_pattern` mit `{username}`, Beispiel `{username}@firma.de`, `cn_regex` als Notnagel für Bestandsumgebungen inklusive Warnung, dass er zu weit gefasst sein kann.
8. **Betrieb**`/healthz` für Zabbix/CheckMK mit Beispielantwort; Log-Auswertung per `journalctl -u vpnportal` und über die JSONL-Datei; `jq`-Einzeiler für Downloads eines Benutzers.
9. **Audit-Log** — Ereignisliste, Beispielzeilen, Hinweis: „Für revisionssichere Aufbewahrung das Log an ein zentrales Logsystem weiterleiten." Empfohlene Aufbewahrung 90 Tage, kundenseitig anpassbar. Hinweis, dass das Log personenbezogene Daten enthält und entsprechend zu schützen ist.
10. **Rotation** — eingebaut und größenbasiert; alternativ externes logrotate mit `SIGHUP` (`systemctl reload` bzw. `kill -HUP`).
11. **Raspberry Pi** — SSD statt SD-Karte empfohlen; Gerät in einen abschließbaren Netzwerkschrank, weil das Speichermedium Zugangsdaten trägt; NTP prüfen.
12. **Fehlersuche** — Tabelle: Symptom → wahrscheinliche Ursache → Kommando. Mindestens: „Anmeldung schlägt für alle fehl" → Dienstkonto/Gruppe → `vpnportal check`; „einzelner Benutzer kann sich nicht anmelden" → `vpnportal check --test-auth <benutzer>`; „kein Zertifikat gefunden" → `cn_pattern` prüfen, Audit-Feld `pattern` lesen; „Dienst vorübergehend nicht verfügbar" → Firewall oder CA; „Start bricht mit chmod-Hinweis ab" → Dateirechte.
13. **Passwortänderung** — nicht im Portal möglich; abgelaufene Passwörter am Arbeitsplatz ändern (AD-Code 532 zeigt eine eigene Meldung, 773 die generische).
14. **Bekannte Grenzen** — kein WireGuard (v2), kein Self-Service für Zertifikate, keine Admin-Oberfläche, keine Mandantenfähigkeit, Single-Domain (keine Multi-Domain-Forests), kein offizieller Windows-Support.
15. **Update** — Binary austauschen, `systemctl restart`; Sessions gehen dabei verloren, das ist beabsichtigt. `portal.update_check` ist standardmäßig **aus** und telefoniert eingeschaltet nach Hause.
16. **Bauen aus dem Quelltext**`make test`, `make build`, `make release`; Integrationstests mit `go test -tags integration ./internal/opnsense/` und den benötigten Umgebungsvariablen.
- [ ] **Step 5: Release-Bau prüfen**
Run:
```bash
make release
ls -la dist/
tar -tzf dist/vpnportal-*-linux-arm64.tar.gz
```
Expected: Zwei Archive (amd64, arm64), `SHA256SUMS`, und jedes Archiv enthält Binary, `config.example.yaml`, `vpnportal.service` und `README.md`.
Falls `sha256sum` auf macOS fehlt, im Makefile auf `shasum -a 256` ausweichen bzw. `command -v sha256sum || shasum -a 256` verwenden.
- [ ] **Step 6: Statisches Linken verifizieren**
Run:
```bash
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o /tmp/vpnportal-arm64 ./cmd/vpnportal
file /tmp/vpnportal-arm64
go version -m /tmp/vpnportal-arm64 | head -20
```
Expected: `ELF 64-bit LSB executable, ARM aarch64, ... statically linked`
- [ ] **Step 7: Gesamttest**
Run: `make vet && make test && go test -race ./...`
Expected: alles PASS. Der `-race`-Lauf ist wichtig, weil Session-Store, Limiter, Audit-Logger und Provider-Cache gemeinsam genutzten Zustand halten.
- [ ] **Step 8: Commit und Push**
```bash
git add deploy README.md Makefile internal/config
git commit -m "docs: Installationsanleitung, gehärtete systemd-Unit und Beispielkonfiguration"
git push -u origin main
```
---
## Akzeptanzkriterien — Zuordnung zu den Tasks
| PRD-Kriterium | Abgedeckt durch |
|---|---|
| 1. Anmeldung + funktionierende `.ovpn` | Task 7, 8, 14 (`TestExportSucceedsForOwnedCert`, `TestDownloadStreamsWithHardenedHeaders`) |
| 2. Ohne Gruppenmitgliedschaft abgewiesen, `not_in_vpn_group` im Log | Task 7 (`TestAuthenticateRejectsNonMember`), Task 13 (`TestLoginFailureShowsGenericMessage`) |
| 3. Revoziertes Zertifikat sofort weg | Task 14 (`TestAccountsAreNeverCached`, `TestExportRejectsRevokedEvenIfPreviouslyListed`) |
| 4. Deaktivierter Account kann sich nicht anmelden | Task 6 (`data 533``account_disabled`), Task 7 |
| 5. Fremdes Zertifikat → 403 + `download_denied` | Task 14 (`TestExportRevalidatesOwnership`, `TestForeignCertificateIsDenied`) |
| 6. `check` erkennt Secret, Berechtigung, DC, CA, Rechte, Tippfehler | Task 16 (alle Tests), Task 2 (`TestParseRejectsUnknownField`) |
| 7. DC-Ausfall → Failover, `ldap_failover` im Log | Task 7 (`TestFailoverToSecondDC`), Task 17 (`OnFailover` verdrahtet Audit-Event) |
| 8. Passwort im Username-Feld erscheint nirgends im Log | Task 13 (`TestUnknownUserIsNeverLoggedInClear`, `TestPasswordNeverAppearsInAnyEvent`) |
| 9. `no-store`, POST + CSRF | Task 12, 14 (`TestDownloadStreamsWithHardenedHeaders`, `TestDownloadRequiresCSRF`, `TestDownloadGETIsNotRouted`) |
| 10. Identisch auf amd64 und arm64 | Task 1 (Step 9), Task 18 (Step 6) |
| 11. Neustart beendet alle Sessions | Task 10 (Store ausschließlich im RAM) |
| 12. UI ohne JavaScript bedienbar | Task 12 (Formulare ohne JS, Toggle nur als Zusatz), Task 13 (`TestLoginGETRendersForm`) |
Ergänzung zu Kriterium 7: In Task 17 muss `auth.Options.OnFailover` beim Bauen des AD-Clients mit einer Funktion belegt werden, die `audit.EventLDAPFailover` mit dem Servernamen loggt. Da `buildBackends` in Task 16 noch ohne Audit-Logger arbeitet (der Logger wird erst danach geöffnet), ist in Task 17 nach dem Öffnen des Audit-Logs `ad.SetFailoverHook(func(server string, err error) { auditLog.Log(audit.Event{Event: audit.EventLDAPFailover, Server: server}) })` aufzurufen. Dafür in `internal/auth/ad.go` ergänzen:
```go
// SetFailoverHook setzt den Failover-Rückruf nachträglich. Das ist nötig,
// weil der Audit-Logger erst nach der Startprüfung geöffnet wird.
func (a *AD) SetFailoverHook(fn func(server string, err error)) {
a.mu.Lock()
a.opts.OnFailover = fn
a.mu.Unlock()
}
```
Der Zugriff auf `a.opts.OnFailover` in `withConn` und `bindAs` ist entsprechend über `a.mu.RLock()` abzusichern — oder einfacher: `OnFailover` in ein eigenes, mit `atomic.Pointer` gehaltenes Feld auslegen. Empfehlung: eigenes Feld `failover atomic.Pointer[func(string, error)]`, damit `withConn` ohne Sperre auskommt. Ein Test dafür in `internal/auth/ad_test.go`:
```go
func TestSetFailoverHookIsUsed(t *testing.T) {
dead := &fakeConn{dialErr: errors.New("connection refused")}
alive := &fakeConn{entries: stdEntries(true)}
i := 0
conns := []*fakeConn{dead, alive}
a, err := NewAD(Options{
Servers: []string{"dc01.firma.local", "dc02.firma.local"}, Port: 636,
TLSMode: "ldaps", BaseDN: "DC=firma,DC=local",
BindUser: "svc@firma.local", BindPassword: "svc-pw", VPNGroup: "VPN-Users",
Timeout: time.Second,
Dial: func(ctx context.Context, server string) (conn, error) {
c := conns[min(i, len(conns)-1)]
i++
if c.dialErr != nil {
return nil, c.dialErr
}
return c, nil
},
})
if err != nil {
t.Fatal(err)
}
var seen []string
a.SetFailoverHook(func(server string, err error) { seen = append(seen, server) })
a.Authenticate(context.Background(), "mmueller", "geheim")
if len(seen) == 0 || seen[0] != "dc01.firma.local" {
t.Fatalf("Failover-Hook = %v", seen)
}
}
```
Dieser Test gehört ans Ende von Task 7; die `SetFailoverHook`-Implementierung ebenfalls. Beim Ausführen des Plans also Task 7 um diesen Schritt erweitern.