Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
9001 lines
271 KiB
Markdown
9001 lines
271 KiB
Markdown
# VPN-Konfig-Portal für OPNsense — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Ein statisch gelinktes Go-Single-Binary, mit dem AD-authentifizierte Endnutzer ihre persönliche OpenVPN-Konfiguration read-only von einer OPNsense-Firewall herunterladen.
|
||
|
||
**Architecture:** Server-Side-Rendering-Webportal ohne Datenbank. Sessions im RAM, alle Assets und Templates per `go:embed` im Binary. Drei austauschbare Kernbausteine hinter schmalen Interfaces: `auth.Authenticator` (AD/LDAP), `opnsense.Client` (Export-API) und `audit.Logger` (JSONL). Alle Tests laufen offline gegen `httptest`-Server und einen LDAP-Fake.
|
||
|
||
**Tech Stack:** Go 1.26, `github.com/go-ldap/ldap/v3`, `gopkg.in/yaml.v3` (strict), `html/template`, `net/http` (stdlib-Router mit Methoden-Patterns), keine CGO-Abhängigkeiten, kein npm, kein Build-Step.
|
||
|
||
## Global Constraints
|
||
|
||
Diese Vorgaben gelten für **jede** Task, auch wenn sie dort nicht wiederholt werden:
|
||
|
||
- Modulpfad: `git.ravensburg.dev/cabele/opnsense-portal`. Binary-Name: `vpnportal`.
|
||
- `CGO_ENABLED=0`, statisch gelinkt. Keine Dependency, die CGO erzwingt. Targets: `linux/amd64` und `linux/arm64`.
|
||
- Kein SPA-Framework, kein npm, kein Build-Step. JavaScript optional; jede Seite muss ohne JS voll bedienbar sein.
|
||
- UI-Sprache Deutsch. **Kein** deutschsprachiger String direkt im Handler — alle Texte über die zentrale Map `web.S` (i18n-vorbereitet, nur DE befüllt).
|
||
- Read-only gegenüber OPNsense: ausschließlich `GET`-Export-Endpunkte, niemals schreibende Aufrufe.
|
||
- Nie auf Platte persistieren: Zertifikate und Configs werden gestreamt, nicht gepuffert.
|
||
- Audit-Log ist JSON Lines, ein Event pro Zeile, Zeitstempel RFC 3339 **mit Zeitzone**.
|
||
- Verbotene Log-Inhalte: Passwörter, Config-Inhalte, Zertifikate, private Keys, vollständige Session-Tokens, CN-Listen auf Info-Level, Klartext-Usernamen unbekannter User.
|
||
- TLS-Verify ist immer an. `insecure_skip_verify` existiert **nur** für OPNsense und loggt bei jedem Start eine Warnung. Für LDAP gibt es keine Insecure-Option.
|
||
- Jede Secret-Option hat eine `*_file`-Variante und ein `VPNPORTAL_*`-Env-Override.
|
||
- Go-Fehler werden gewrappt (`fmt.Errorf("...: %w", err)`), niemals verschluckt.
|
||
- Tests: `go test ./...` muss nach jeder Task grün sein. Kein Netzwerkzugriff in Tests.
|
||
- Commits: konventionelle Prefixes (`feat:`, `test:`, `fix:`, `chore:`, `docs:`), ein Commit pro Task-Ende.
|
||
|
||
## Assumptions to Verify (explizit offen)
|
||
|
||
Die exakten Feldnamen und Response-Shapes der OPNsense-`os-openvpn-client-export`-API sind versionsabhängig und konnten offline nicht verifiziert werden. Der Client wird deshalb **tolerant** gebaut (mehrere akzeptierte Feldnamen, mehrere akzeptierte Datums- und Boolean-Repräsentationen) und die Annahmen werden in `docs/opnsense-api.md` dokumentiert. Task 8 legt build-getaggte Integrationstest-Skeletons (`//go:build integration`) an, die gegen eine echte Instanz laufen und diese Annahmen bestätigen müssen, bevor v1 ausgeliefert wird.
|
||
|
||
## File Structure
|
||
|
||
| Datei | Verantwortung |
|
||
|---|---|
|
||
| `cmd/vpnportal/main.go` | CLI-Dispatch `serve` / `check` / `version`, Exit-Codes |
|
||
| `cmd/vpnportal/buildinfo.go` | `version`, `commit`, `date` via ldflags |
|
||
| `cmd/vpnportal/serve.go` | Verdrahtung aller Komponenten, HTTPS-Server, Signal-Handling |
|
||
| `cmd/vpnportal/check.go` | `check`-Kommando inkl. `--test-auth` |
|
||
| `internal/config/config.go` | Config-Structs, Defaults |
|
||
| `internal/config/load.go` | YAML-Strict-Parsing, `*_file`-Auflösung, Env-Overrides |
|
||
| `internal/config/perms.go` | Erzwingen der Dateirechte (Config, Secrets, Log-Verzeichnis) |
|
||
| `internal/config/validate.go` | Semantische Validierung, Base-DN-Ableitung |
|
||
| `internal/audit/event.go` | `Event`-Struct, Event-Namen, Reason-Konstanten |
|
||
| `internal/audit/logger.go` | JSONL-Writer, stdout-Tee, Session-ID-Kürzung |
|
||
| `internal/audit/rotate.go` | Größenbasierte Rotation, gzip, SIGHUP-Reopen |
|
||
| `internal/auth/auth.go` | `Authenticator`-Interface, `Identity`, `Error` mit `Reason` |
|
||
| `internal/auth/addata.go` | Mapping AD-Error-49-`data`-Codes → `Reason` |
|
||
| `internal/auth/ad.go` | AD-Implementierung: Zwei-Schritt-Bind, Gruppenprüfung |
|
||
| `internal/auth/dial.go` | DC-Failover-Dialer (LDAPS/StartTLS, Timeouts) |
|
||
| `internal/opnsense/client.go` | HTTP-Client, Basic-Auth mit Key/Secret, CA/TLS |
|
||
| `internal/opnsense/export.go` | `Providers()`, `Accounts()`, `Export()` (streaming) |
|
||
| `internal/opnsense/types.go` | Tolerante DTOs, flexible Bool-/Datums-Decoder |
|
||
| `internal/certmatch/match.go` | CN-Pattern/Regex-Matching, Revoked-/Expiry-Filter |
|
||
| `internal/web/server.go` | Router, Middleware-Kette, Handler-Dependencies |
|
||
| `internal/web/security.go` | Security-Header, `no-store`, Real-IP-Ermittlung |
|
||
| `internal/web/session.go` | RAM-Session-Store mit TTL, CSRF-Token |
|
||
| `internal/web/ratelimit.go` | Exponentieller Backoff pro User und pro IP |
|
||
| `internal/web/strings.go` | Zentrale DE-String-Map `S` |
|
||
| `internal/web/render.go` | Template-Parsing (`go:embed`), Render-Helper |
|
||
| `internal/web/handlers_login.go` | `GET/POST /login`, `POST /logout` |
|
||
| `internal/web/handlers_certs.go` | `GET /`, `POST /download` |
|
||
| `internal/web/handlers_static.go` | `GET /anleitungen`, `GET /healthz`, Assets |
|
||
| `internal/web/templates/*.html` | Layout, Login, Übersicht, Anleitungen, Fehlerseite |
|
||
| `internal/web/assets/style.css`, `app.js` | Eingebettete Assets, keine externen Ressourcen |
|
||
| `deploy/vpnportal.service` | Gehärtete systemd-Unit |
|
||
| `deploy/config.example.yaml` | Beispiel-Config, alle Optionen auskommentiert |
|
||
| `Makefile` | `build`, `test`, `lint`, `release`, `vet` |
|
||
| `README.md`, `docs/opnsense-api.md` | Installations- und Betriebsdoku, API-Annahmen |
|
||
|
||
---
|
||
|
||
## Task 1: Projekt-Skeleton und `version`-Kommando
|
||
|
||
**Files:**
|
||
- Create: `go.mod`, `.gitignore`, `Makefile`
|
||
- Create: `cmd/vpnportal/main.go`, `cmd/vpnportal/buildinfo.go`
|
||
- Test: `cmd/vpnportal/main_test.go`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nichts
|
||
- Produces: `func run(args []string, stdout, stderr io.Writer) int` — testbarer CLI-Einstieg; `main()` ruft nur `os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))`. Buildinfo-Variablen `version`, `commit`, `date` (Package-Level `var`, per `-ldflags -X` gesetzt).
|
||
|
||
- [ ] **Step 1: Modul initialisieren und `.gitignore` anlegen**
|
||
|
||
```bash
|
||
cd /Users/cabele/claudeprojects/opnsense-portal
|
||
go mod init git.ravensburg.dev/cabele/opnsense-portal
|
||
printf '/dist/\n/vpnportal\n*.log\n.DS_Store\n' > .gitignore
|
||
```
|
||
|
||
- [ ] **Step 2: Failing test schreiben**
|
||
|
||
`cmd/vpnportal/main_test.go`:
|
||
|
||
```go
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func TestRunVersion(t *testing.T) {
|
||
var out, errOut bytes.Buffer
|
||
code := run([]string{"version"}, &out, &errOut)
|
||
if code != 0 {
|
||
t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, errOut.String())
|
||
}
|
||
got := out.String()
|
||
for _, want := range []string{"vpnportal", "commit", "built"} {
|
||
if !strings.Contains(got, want) {
|
||
t.Errorf("output %q missing %q", got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestRunUnknownCommand(t *testing.T) {
|
||
var out, errOut bytes.Buffer
|
||
if code := run([]string{"frobnicate"}, &out, &errOut); code != 2 {
|
||
t.Fatalf("exit code = %d, want 2", code)
|
||
}
|
||
if !strings.Contains(errOut.String(), "frobnicate") {
|
||
t.Errorf("stderr should name the unknown command, got %q", errOut.String())
|
||
}
|
||
}
|
||
|
||
func TestRunNoArgsDefaultsToServe(t *testing.T) {
|
||
var out, errOut bytes.Buffer
|
||
// serve ohne --config muss mit Fehler abbrechen, nicht mit "unknown command"
|
||
code := run(nil, &out, &errOut)
|
||
if code == 2 {
|
||
t.Fatalf("no args must default to serve, not unknown-command")
|
||
}
|
||
if !strings.Contains(errOut.String(), "config") {
|
||
t.Errorf("stderr should complain about missing config, got %q", errOut.String())
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Test laufen lassen, Fehlschlag bestätigen**
|
||
|
||
Run: `go test ./cmd/vpnportal/ -run TestRun -v`
|
||
Expected: FAIL — `undefined: run`
|
||
|
||
- [ ] **Step 4: `buildinfo.go` schreiben**
|
||
|
||
```go
|
||
package main
|
||
|
||
import "fmt"
|
||
|
||
// Per -ldflags gesetzt; siehe Makefile.
|
||
var (
|
||
version = "dev"
|
||
commit = "none"
|
||
date = "unknown"
|
||
)
|
||
|
||
func versionString() string {
|
||
return fmt.Sprintf("vpnportal %s (commit %s, built %s)", version, commit, date)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: `main.go` schreiben**
|
||
|
||
```go
|
||
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
)
|
||
|
||
const usage = `vpnportal — VPN-Konfig-Portal für OPNsense
|
||
|
||
Aufruf:
|
||
vpnportal serve [--config PFAD] Portal starten (Default)
|
||
vpnportal check [--config PFAD] [--test-auth BENUTZER]
|
||
vpnportal version
|
||
|
||
Optionen:
|
||
--config PFAD Pfad zur Konfigurationsdatei (Default: /etc/vpnportal/config.yaml)
|
||
`
|
||
|
||
func main() {
|
||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||
}
|
||
|
||
func run(args []string, stdout, stderr io.Writer) int {
|
||
cmd := "serve"
|
||
rest := args
|
||
if len(args) > 0 && len(args[0]) > 0 && args[0][0] != '-' {
|
||
cmd, rest = args[0], args[1:]
|
||
}
|
||
|
||
switch cmd {
|
||
case "version":
|
||
fmt.Fprintln(stdout, versionString())
|
||
return 0
|
||
case "serve":
|
||
return runServe(rest, stdout, stderr)
|
||
case "check":
|
||
return runCheck(rest, stdout, stderr)
|
||
case "help", "-h", "--help":
|
||
fmt.Fprint(stdout, usage)
|
||
return 0
|
||
default:
|
||
fmt.Fprintf(stderr, "Unbekanntes Kommando: %s\n\n%s", cmd, usage)
|
||
return 2
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Minimale `serve.go` und `check.go` anlegen**
|
||
|
||
`cmd/vpnportal/serve.go`:
|
||
|
||
```go
|
||
package main
|
||
|
||
import (
|
||
"flag"
|
||
"fmt"
|
||
"io"
|
||
)
|
||
|
||
const defaultConfigPath = "/etc/vpnportal/config.yaml"
|
||
|
||
func configFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) {
|
||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||
fs.SetOutput(stderr)
|
||
path := fs.String("config", defaultConfigPath, "Pfad zur Konfigurationsdatei")
|
||
return fs, path
|
||
}
|
||
|
||
func runServe(args []string, stdout, stderr io.Writer) int {
|
||
fs, path := configFlags("serve", stderr)
|
||
if err := fs.Parse(args); err != nil {
|
||
return 2
|
||
}
|
||
fmt.Fprintf(stderr, "Fehler: config %q kann noch nicht geladen werden (nicht implementiert)\n", *path)
|
||
return 1
|
||
}
|
||
```
|
||
|
||
`cmd/vpnportal/check.go`:
|
||
|
||
```go
|
||
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"io"
|
||
)
|
||
|
||
func runCheck(args []string, stdout, stderr io.Writer) int {
|
||
fs, path := configFlags("check", stderr)
|
||
testAuth := fs.String("test-auth", "", "Suche und Gruppenprüfung für einen Benutzer durchspielen")
|
||
if err := fs.Parse(args); err != nil {
|
||
return 2
|
||
}
|
||
_ = testAuth
|
||
fmt.Fprintf(stderr, "Fehler: config %q kann noch nicht geladen werden (nicht implementiert)\n", *path)
|
||
return 1
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Tests laufen lassen**
|
||
|
||
Run: `go test ./cmd/vpnportal/ -v`
|
||
Expected: PASS (alle drei Tests)
|
||
|
||
- [ ] **Step 8: Makefile schreiben**
|
||
|
||
```makefile
|
||
BINARY := vpnportal
|
||
PKG := git.ravensburg.dev/cabele/opnsense-portal/cmd/vpnportal
|
||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
|
||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)
|
||
|
||
export CGO_ENABLED := 0
|
||
|
||
.PHONY: all build test vet lint release clean
|
||
|
||
all: vet test build
|
||
|
||
build:
|
||
go build -trimpath -ldflags '$(LDFLAGS)' -o dist/$(BINARY) $(PKG)
|
||
|
||
test:
|
||
go test ./...
|
||
|
||
vet:
|
||
go vet ./...
|
||
|
||
lint: vet
|
||
gofmt -l . | tee /dev/stderr | (! read)
|
||
|
||
release: clean
|
||
@set -e; for arch in amd64 arm64; do \
|
||
echo "==> linux/$$arch"; \
|
||
GOOS=linux GOARCH=$$arch go build -trimpath -ldflags '$(LDFLAGS)' \
|
||
-o dist/linux-$$arch/$(BINARY) $(PKG); \
|
||
mkdir -p dist/linux-$$arch; \
|
||
cp deploy/config.example.yaml deploy/vpnportal.service README.md dist/linux-$$arch/; \
|
||
tar -czf dist/$(BINARY)-$(VERSION)-linux-$$arch.tar.gz -C dist/linux-$$arch .; \
|
||
done
|
||
cd dist && sha256sum *.tar.gz > SHA256SUMS
|
||
|
||
clean:
|
||
rm -rf dist
|
||
```
|
||
|
||
- [ ] **Step 9: Build für beide Zielarchitekturen verifizieren**
|
||
|
||
Run:
|
||
```bash
|
||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /dev/null ./cmd/vpnportal
|
||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o /dev/null ./cmd/vpnportal
|
||
make test
|
||
```
|
||
Expected: beide Builds ohne Ausgabe, Tests PASS
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: CLI-Skeleton mit version/serve/check und Makefile"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Config-Structs und YAML-Strict-Parsing
|
||
|
||
**Files:**
|
||
- Create: `internal/config/config.go`, `internal/config/load.go`
|
||
- Test: `internal/config/load_test.go`
|
||
- Modify: `go.mod` (Dependency `gopkg.in/yaml.v3`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: nichts
|
||
- Produces:
|
||
- `type Config struct { Portal PortalConfig; OPNsense OPNsenseConfig; AD ADConfig; Matching MatchingConfig; Logging LoggingConfig }`
|
||
- `type Duration time.Duration` mit `UnmarshalYAML` für Strings wie `"10m"`
|
||
- `func Parse(r io.Reader) (*Config, error)` — strict, mit Defaults vorbelegt
|
||
- Defaults: `portal.listen="0.0.0.0:8443"`, `portal.session_ttl=10m`, `ad.port=636`, `ad.tls_mode="ldaps"`, `matching.cn_pattern="{username}"`, `logging.level="info"`, `logging.max_size_mb=50`, `logging.max_backups=5`, `logging.compress=true`
|
||
|
||
- [ ] **Step 1: Dependency hinzufügen**
|
||
|
||
```bash
|
||
go get gopkg.in/yaml.v3@latest
|
||
```
|
||
|
||
- [ ] **Step 2: Failing test schreiben**
|
||
|
||
`internal/config/load_test.go`:
|
||
|
||
```go
|
||
package config
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
const minimalYAML = `
|
||
portal:
|
||
tls_cert: "/etc/vpnportal/portal.crt"
|
||
tls_key: "/etc/vpnportal/portal.key"
|
||
opnsense:
|
||
url: "https://fw01.firma.local"
|
||
api_key: "KEY"
|
||
api_secret: "SECRET"
|
||
ad:
|
||
domain: "firma.local"
|
||
servers: ["dc01.firma.local"]
|
||
bind_user: "svc@firma.local"
|
||
bind_password: "PW"
|
||
vpn_group: "VPN-Users"
|
||
logging:
|
||
audit_log: "/var/log/vpnportal/audit.log"
|
||
`
|
||
|
||
func TestParseAppliesDefaults(t *testing.T) {
|
||
cfg, err := Parse(strings.NewReader(minimalYAML))
|
||
if err != nil {
|
||
t.Fatalf("Parse: %v", err)
|
||
}
|
||
if cfg.Portal.Listen != "0.0.0.0:8443" {
|
||
t.Errorf("Listen = %q, want default 0.0.0.0:8443", cfg.Portal.Listen)
|
||
}
|
||
if time.Duration(cfg.Portal.SessionTTL) != 10*time.Minute {
|
||
t.Errorf("SessionTTL = %v, want 10m", time.Duration(cfg.Portal.SessionTTL))
|
||
}
|
||
if cfg.AD.Port != 636 {
|
||
t.Errorf("AD.Port = %d, want 636", cfg.AD.Port)
|
||
}
|
||
if cfg.Matching.CNPattern != "{username}" {
|
||
t.Errorf("CNPattern = %q, want {username}", cfg.Matching.CNPattern)
|
||
}
|
||
if cfg.Logging.MaxSizeMB != 50 || cfg.Logging.MaxBackups != 5 || !cfg.Logging.Compress {
|
||
t.Errorf("logging defaults wrong: %+v", cfg.Logging)
|
||
}
|
||
if cfg.Portal.UpdateCheck {
|
||
t.Error("UpdateCheck must default to false")
|
||
}
|
||
}
|
||
|
||
func TestParseRejectsUnknownField(t *testing.T) {
|
||
_, err := Parse(strings.NewReader(minimalYAML + "\nportal_typo:\n listen: \"x\"\n"))
|
||
if err == nil {
|
||
t.Fatal("unbekanntes Feld muss abgelehnt werden")
|
||
}
|
||
if !strings.Contains(err.Error(), "portal_typo") {
|
||
t.Errorf("Fehler muss das unbekannte Feld nennen, got: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestParseRejectsUnknownNestedField(t *testing.T) {
|
||
_, err := Parse(strings.NewReader(strings.Replace(minimalYAML,
|
||
" vpn_group: \"VPN-Users\"", " vpn_group: \"VPN-Users\"\n vpngroup: \"typo\"", 1)))
|
||
if err == nil {
|
||
t.Fatal("unbekanntes verschachteltes Feld muss abgelehnt werden")
|
||
}
|
||
}
|
||
|
||
func TestParseSessionTTLOverride(t *testing.T) {
|
||
cfg, err := Parse(strings.NewReader(strings.Replace(minimalYAML,
|
||
"portal:", "portal:\n session_ttl: \"90s\"", 1)))
|
||
if err != nil {
|
||
t.Fatalf("Parse: %v", err)
|
||
}
|
||
if time.Duration(cfg.Portal.SessionTTL) != 90*time.Second {
|
||
t.Errorf("SessionTTL = %v, want 90s", time.Duration(cfg.Portal.SessionTTL))
|
||
}
|
||
}
|
||
|
||
func TestParseRejectsBadDuration(t *testing.T) {
|
||
_, err := Parse(strings.NewReader(strings.Replace(minimalYAML,
|
||
"portal:", "portal:\n session_ttl: \"zehn Minuten\"", 1)))
|
||
if err == nil {
|
||
t.Fatal("ungültige Dauer muss abgelehnt werden")
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Test laufen lassen, Fehlschlag bestätigen**
|
||
|
||
Run: `go test ./internal/config/ -v`
|
||
Expected: FAIL — `undefined: Parse`
|
||
|
||
- [ ] **Step 4: `config.go` schreiben**
|
||
|
||
```go
|
||
// Package config lädt und validiert die Portal-Konfiguration.
|
||
package config
|
||
|
||
import (
|
||
"fmt"
|
||
"time"
|
||
|
||
"gopkg.in/yaml.v3"
|
||
)
|
||
|
||
// Duration erlaubt Dauerangaben als String ("10m") in YAML.
|
||
type Duration time.Duration
|
||
|
||
func (d *Duration) UnmarshalYAML(node *yaml.Node) error {
|
||
var s string
|
||
if err := node.Decode(&s); err != nil {
|
||
return fmt.Errorf("Dauer muss eine Zeichenkette sein (z. B. \"10m\"): %w", err)
|
||
}
|
||
parsed, err := time.ParseDuration(s)
|
||
if err != nil {
|
||
return fmt.Errorf("ungültige Dauer %q (erwartet z. B. \"10m\", \"90s\"): %w", s, err)
|
||
}
|
||
if parsed <= 0 {
|
||
return fmt.Errorf("Dauer %q muss positiv sein", s)
|
||
}
|
||
*d = Duration(parsed)
|
||
return nil
|
||
}
|
||
|
||
func (d Duration) String() string { return time.Duration(d).String() }
|
||
|
||
type Config struct {
|
||
Portal PortalConfig `yaml:"portal"`
|
||
OPNsense OPNsenseConfig `yaml:"opnsense"`
|
||
AD ADConfig `yaml:"ad"`
|
||
Matching MatchingConfig `yaml:"matching"`
|
||
Logging LoggingConfig `yaml:"logging"`
|
||
}
|
||
|
||
type PortalConfig struct {
|
||
Listen string `yaml:"listen"`
|
||
TLSCert string `yaml:"tls_cert"`
|
||
TLSKey string `yaml:"tls_key"`
|
||
SessionTTL Duration `yaml:"session_ttl"`
|
||
Title string `yaml:"title"`
|
||
LogoFile string `yaml:"logo_file"`
|
||
SupportContact string `yaml:"support_contact"`
|
||
UpdateCheck bool `yaml:"update_check"`
|
||
}
|
||
|
||
type OPNsenseConfig struct {
|
||
URL string `yaml:"url"`
|
||
APIKey string `yaml:"api_key"`
|
||
APIKeyFile string `yaml:"api_key_file"`
|
||
APISecret string `yaml:"api_secret"`
|
||
APISecretFile string `yaml:"api_secret_file"`
|
||
CAFile string `yaml:"ca_file"`
|
||
InsecureSkipVerify bool `yaml:"insecure_skip_verify"`
|
||
}
|
||
|
||
type ADConfig struct {
|
||
Domain string `yaml:"domain"`
|
||
BaseDN string `yaml:"base_dn"`
|
||
Servers []string `yaml:"servers"`
|
||
Port int `yaml:"port"`
|
||
TLSMode string `yaml:"tls_mode"` // "ldaps" (Default) oder "starttls"
|
||
BindUser string `yaml:"bind_user"`
|
||
BindPassword string `yaml:"bind_password"`
|
||
BindPasswordFile string `yaml:"bind_password_file"`
|
||
VPNGroup string `yaml:"vpn_group"`
|
||
CAFile string `yaml:"ca_file"`
|
||
Timeout Duration `yaml:"timeout"`
|
||
}
|
||
|
||
type MatchingConfig struct {
|
||
CNPattern string `yaml:"cn_pattern"`
|
||
CNRegex string `yaml:"cn_regex"`
|
||
}
|
||
|
||
type LoggingConfig struct {
|
||
Level string `yaml:"level"`
|
||
AuditLog string `yaml:"audit_log"`
|
||
MaxSizeMB int `yaml:"max_size_mb"`
|
||
MaxBackups int `yaml:"max_backups"`
|
||
Compress bool `yaml:"compress"`
|
||
}
|
||
|
||
// Defaults liefert eine Config mit allen Vorgabewerten.
|
||
func Defaults() *Config {
|
||
return &Config{
|
||
Portal: PortalConfig{
|
||
Listen: "0.0.0.0:8443",
|
||
SessionTTL: Duration(10 * time.Minute),
|
||
Title: "VPN-Portal",
|
||
},
|
||
AD: ADConfig{
|
||
Port: 636,
|
||
TLSMode: "ldaps",
|
||
Timeout: Duration(8 * time.Second),
|
||
},
|
||
Matching: MatchingConfig{CNPattern: "{username}"},
|
||
Logging: LoggingConfig{
|
||
Level: "info",
|
||
MaxSizeMB: 50,
|
||
MaxBackups: 5,
|
||
Compress: true,
|
||
},
|
||
}
|
||
}
|
||
```
|
||
|
||
Anmerkung zu `Compress`: der Default ist `true`, YAML `compress: false` überschreibt ihn korrekt, weil in ein vorbelegtes Struct hineindekodiert wird.
|
||
|
||
- [ ] **Step 5: `load.go` mit `Parse` schreiben**
|
||
|
||
```go
|
||
package config
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
|
||
"gopkg.in/yaml.v3"
|
||
)
|
||
|
||
// Parse liest YAML strikt in eine mit Defaults vorbelegte Config.
|
||
// Unbekannte Felder führen zum Fehler.
|
||
func Parse(r io.Reader) (*Config, error) {
|
||
cfg := Defaults()
|
||
dec := yaml.NewDecoder(r)
|
||
dec.KnownFields(true)
|
||
if err := dec.Decode(cfg); err != nil {
|
||
if errors.Is(err, io.EOF) {
|
||
return nil, errors.New("Konfigurationsdatei ist leer")
|
||
}
|
||
return nil, fmt.Errorf("Konfiguration konnte nicht gelesen werden: %w", err)
|
||
}
|
||
// Ein zweiter Decode-Aufruf muss EOF liefern; sonst enthält die Datei
|
||
// mehrere YAML-Dokumente, was wir nicht unterstützen.
|
||
var extra yaml.Node
|
||
if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
|
||
return nil, errors.New("Konfigurationsdatei darf nur ein YAML-Dokument enthalten")
|
||
}
|
||
return cfg, nil
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Tests laufen lassen**
|
||
|
||
Run: `go test ./internal/config/ -v`
|
||
Expected: PASS (5 Tests)
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add go.mod go.sum internal/config
|
||
git commit -m "feat(config): YAML-Strict-Parsing mit Defaults"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Secrets, Env-Overrides und erzwungene Dateirechte
|
||
|
||
**Files:**
|
||
- Create: `internal/config/perms.go`
|
||
- Modify: `internal/config/load.go` (Funktion `Load`)
|
||
- Test: `internal/config/perms_test.go`, `internal/config/secrets_test.go`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Parse`, `Config` aus Task 2
|
||
- Produces:
|
||
- `func Load(path string, env func(string) string) (*Config, error)` — liest Datei, prüft Rechte, parst, löst `*_file` auf, wendet Env-Overrides an. `env` ist injizierbar (`os.Getenv` in Produktion).
|
||
- `func CheckFileMode(path string, maxMode fs.FileMode) error` — Fehler, wenn Bits jenseits von `maxMode` gesetzt sind.
|
||
- `func CheckDirMode(path string, maxMode fs.FileMode) error`
|
||
- Env-Namen: `VPNPORTAL_OPNSENSE_API_KEY`, `VPNPORTAL_OPNSENSE_API_SECRET`, `VPNPORTAL_AD_BIND_PASSWORD`
|
||
- Rangfolge: Env-Var > `*_file` > Inline-Wert in YAML
|
||
|
||
- [ ] **Step 1: Failing tests für Dateirechte schreiben**
|
||
|
||
`internal/config/perms_test.go`:
|
||
|
||
```go
|
||
package config
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func writeFileMode(t *testing.T, dir, name string, mode os.FileMode, content string) string {
|
||
t.Helper()
|
||
p := filepath.Join(dir, name)
|
||
if err := os.WriteFile(p, []byte(content), mode); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.Chmod(p, mode); err != nil { // umask umgehen
|
||
t.Fatal(err)
|
||
}
|
||
return p
|
||
}
|
||
|
||
func TestCheckFileModeAcceptsTightPermissions(t *testing.T) {
|
||
dir := t.TempDir()
|
||
for _, mode := range []os.FileMode{0o600, 0o640, 0o400} {
|
||
p := writeFileMode(t, dir, "s.txt", mode, "x")
|
||
if err := CheckFileMode(p, 0o640); err != nil {
|
||
t.Errorf("mode %#o should be accepted: %v", mode, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestCheckFileModeRejectsLoosePermissions(t *testing.T) {
|
||
dir := t.TempDir()
|
||
for _, mode := range []os.FileMode{0o644, 0o660, 0o604, 0o777} {
|
||
p := writeFileMode(t, dir, "s.txt", mode, "x")
|
||
err := CheckFileMode(p, 0o640)
|
||
if err == nil {
|
||
t.Errorf("mode %#o must be rejected", mode)
|
||
continue
|
||
}
|
||
if !strings.Contains(err.Error(), "chmod") {
|
||
t.Errorf("Fehler sollte den Reparaturbefehl nennen, got: %v", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestCheckDirModeRejectsWorldWritable(t *testing.T) {
|
||
dir := t.TempDir()
|
||
sub := filepath.Join(dir, "logs")
|
||
if err := os.Mkdir(sub, 0o777); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.Chmod(sub, 0o777); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := CheckDirMode(sub, 0o750); err == nil {
|
||
t.Fatal("0777-Verzeichnis muss abgelehnt werden")
|
||
}
|
||
if err := os.Chmod(sub, 0o750); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := CheckDirMode(sub, 0o750); err != nil {
|
||
t.Errorf("0750 sollte akzeptiert werden: %v", err)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**
|
||
|
||
Run: `go test ./internal/config/ -run TestCheck -v`
|
||
Expected: FAIL — `undefined: CheckFileMode`
|
||
|
||
- [ ] **Step 3: `perms.go` schreiben**
|
||
|
||
```go
|
||
package config
|
||
|
||
import (
|
||
"fmt"
|
||
"io/fs"
|
||
"os"
|
||
)
|
||
|
||
// CheckFileMode bricht ab, wenn die Datei mehr Rechte trägt als maxMode.
|
||
func CheckFileMode(path string, maxMode fs.FileMode) error {
|
||
info, err := os.Stat(path)
|
||
if err != nil {
|
||
return fmt.Errorf("Datei %s nicht lesbar: %w", path, err)
|
||
}
|
||
if info.IsDir() {
|
||
return fmt.Errorf("%s ist ein Verzeichnis, erwartet wurde eine Datei", path)
|
||
}
|
||
return checkMode(path, info.Mode().Perm(), maxMode)
|
||
}
|
||
|
||
// CheckDirMode bricht ab, wenn das Verzeichnis mehr Rechte trägt als maxMode.
|
||
func CheckDirMode(path string, maxMode fs.FileMode) error {
|
||
info, err := os.Stat(path)
|
||
if err != nil {
|
||
return fmt.Errorf("Verzeichnis %s nicht lesbar: %w", path, err)
|
||
}
|
||
if !info.IsDir() {
|
||
return fmt.Errorf("%s ist kein Verzeichnis", path)
|
||
}
|
||
return checkMode(path, info.Mode().Perm(), maxMode)
|
||
}
|
||
|
||
func checkMode(path string, actual, maxMode fs.FileMode) error {
|
||
if extra := actual &^ maxMode; extra != 0 {
|
||
return fmt.Errorf(
|
||
"%s hat zu weite Dateirechte %#o (erlaubt höchstens %#o). Beheben mit: chmod %#o %s",
|
||
path, actual, maxMode, maxMode, path)
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Tests laufen lassen**
|
||
|
||
Run: `go test ./internal/config/ -run TestCheck -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Failing tests für Secrets und Env-Overrides schreiben**
|
||
|
||
`internal/config/secrets_test.go`:
|
||
|
||
```go
|
||
package config
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func noEnv(string) string { return "" }
|
||
|
||
func writeConfig(t *testing.T, body string) string {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
p := filepath.Join(dir, "config.yaml")
|
||
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.Chmod(p, 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return p
|
||
}
|
||
|
||
func TestLoadRejectsWorldReadableConfig(t *testing.T) {
|
||
p := writeConfig(t, minimalYAML)
|
||
if err := os.Chmod(p, 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := Load(p, noEnv); err == nil {
|
||
t.Fatal("0644-Config muss den Start abbrechen")
|
||
}
|
||
}
|
||
|
||
func TestLoadResolvesSecretFiles(t *testing.T) {
|
||
dir := t.TempDir()
|
||
secret := filepath.Join(dir, "apisecret")
|
||
if err := os.WriteFile(secret, []byte(" FROM-FILE\n"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.Chmod(secret, 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
body := strings.Replace(minimalYAML,
|
||
` api_secret: "SECRET"`, ` api_secret_file: "`+secret+`"`, 1)
|
||
cfg, err := Load(writeConfig(t, body), noEnv)
|
||
if err != nil {
|
||
t.Fatalf("Load: %v", err)
|
||
}
|
||
if cfg.OPNsense.APISecret != "FROM-FILE" {
|
||
t.Errorf("APISecret = %q, want FROM-FILE (getrimmt)", cfg.OPNsense.APISecret)
|
||
}
|
||
}
|
||
|
||
func TestLoadRejectsLooseSecretFile(t *testing.T) {
|
||
dir := t.TempDir()
|
||
secret := filepath.Join(dir, "apisecret")
|
||
if err := os.WriteFile(secret, []byte("x"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.Chmod(secret, 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
body := strings.Replace(minimalYAML,
|
||
` api_secret: "SECRET"`, ` api_secret_file: "`+secret+`"`, 1)
|
||
if _, err := Load(writeConfig(t, body), noEnv); err == nil {
|
||
t.Fatal("Secret-Datei mit 0644 muss abgelehnt werden")
|
||
}
|
||
}
|
||
|
||
func TestLoadEnvOverridesEverything(t *testing.T) {
|
||
env := map[string]string{
|
||
"VPNPORTAL_OPNSENSE_API_SECRET": "FROM-ENV",
|
||
"VPNPORTAL_AD_BIND_PASSWORD": "ENV-PW",
|
||
}
|
||
cfg, err := Load(writeConfig(t, minimalYAML), func(k string) string { return env[k] })
|
||
if err != nil {
|
||
t.Fatalf("Load: %v", err)
|
||
}
|
||
if cfg.OPNsense.APISecret != "FROM-ENV" {
|
||
t.Errorf("APISecret = %q, want FROM-ENV", cfg.OPNsense.APISecret)
|
||
}
|
||
if cfg.AD.BindPassword != "ENV-PW" {
|
||
t.Errorf("BindPassword = %q, want ENV-PW", cfg.AD.BindPassword)
|
||
}
|
||
}
|
||
|
||
func TestLoadRejectsBothInlineAndFile(t *testing.T) {
|
||
body := strings.Replace(minimalYAML,
|
||
` api_secret: "SECRET"`,
|
||
" api_secret: \"SECRET\"\n api_secret_file: \"/tmp/x\"", 1)
|
||
_, err := Load(writeConfig(t, body), noEnv)
|
||
if err == nil || !strings.Contains(err.Error(), "api_secret") {
|
||
t.Fatalf("gleichzeitig gesetzte api_secret/api_secret_file müssen abgelehnt werden, got: %v", err)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Test laufen lassen, Fehlschlag bestätigen**
|
||
|
||
Run: `go test ./internal/config/ -run TestLoad -v`
|
||
Expected: FAIL — `undefined: Load`
|
||
|
||
- [ ] **Step 7: `Load` in `load.go` ergänzen**
|
||
|
||
```go
|
||
// Am Kopf von load.go zusätzlich importieren: "os", "strings"
|
||
|
||
const (
|
||
EnvAPIKey = "VPNPORTAL_OPNSENSE_API_KEY"
|
||
EnvAPISecret = "VPNPORTAL_OPNSENSE_API_SECRET"
|
||
EnvBindPassword = "VPNPORTAL_AD_BIND_PASSWORD"
|
||
)
|
||
|
||
// maxConfigMode ist die weiteste erlaubte Berechtigung für Config- und
|
||
// Secret-Dateien: Eigentümer lesen/schreiben, Gruppe lesen, Welt nichts.
|
||
const maxConfigMode fs.FileMode = 0o640
|
||
|
||
// Load liest die Konfigurationsdatei, erzwingt Dateirechte, löst
|
||
// *_file-Secrets auf und wendet Env-Overrides an.
|
||
// env wird injiziert, damit Tests ohne Prozess-Umgebung auskommen.
|
||
func Load(path string, env func(string) string) (*Config, error) {
|
||
if env == nil {
|
||
env = os.Getenv
|
||
}
|
||
if err := CheckFileMode(path, maxConfigMode); err != nil {
|
||
return nil, err
|
||
}
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("Konfigurationsdatei %s: %w", path, err)
|
||
}
|
||
defer f.Close()
|
||
|
||
cfg, err := Parse(f)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%s: %w", path, err)
|
||
}
|
||
if err := resolveSecrets(cfg, env); err != nil {
|
||
return nil, err
|
||
}
|
||
return cfg, nil
|
||
}
|
||
|
||
type secretSlot struct {
|
||
name string // YAML-Feldname für Fehlermeldungen
|
||
inline *string
|
||
file string
|
||
envName string
|
||
}
|
||
|
||
func resolveSecrets(cfg *Config, env func(string) string) error {
|
||
slots := []secretSlot{
|
||
{"opnsense.api_key", &cfg.OPNsense.APIKey, cfg.OPNsense.APIKeyFile, EnvAPIKey},
|
||
{"opnsense.api_secret", &cfg.OPNsense.APISecret, cfg.OPNsense.APISecretFile, EnvAPISecret},
|
||
{"ad.bind_password", &cfg.AD.BindPassword, cfg.AD.BindPasswordFile, EnvBindPassword},
|
||
}
|
||
for _, s := range slots {
|
||
if v := env(s.envName); v != "" {
|
||
*s.inline = v
|
||
continue
|
||
}
|
||
if s.file == "" {
|
||
continue
|
||
}
|
||
if *s.inline != "" {
|
||
return fmt.Errorf("%s und %s_file sind gleichzeitig gesetzt — bitte nur eines verwenden",
|
||
s.name, s.name)
|
||
}
|
||
if err := CheckFileMode(s.file, maxConfigMode); err != nil {
|
||
return fmt.Errorf("Secret-Datei für %s: %w", s.name, err)
|
||
}
|
||
raw, err := os.ReadFile(s.file)
|
||
if err != nil {
|
||
return fmt.Errorf("Secret-Datei für %s: %w", s.name, err)
|
||
}
|
||
value := strings.TrimSpace(string(raw))
|
||
if value == "" {
|
||
return fmt.Errorf("Secret-Datei %s für %s ist leer", s.file, s.name)
|
||
}
|
||
*s.inline = value
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
`"io/fs"` muss in `load.go` importiert werden.
|
||
|
||
- [ ] **Step 8: Tests laufen lassen**
|
||
|
||
Run: `go test ./internal/config/ -v`
|
||
Expected: PASS (alle Tests aus Task 2 und 3)
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add internal/config
|
||
git commit -m "feat(config): Secret-Dateien, Env-Overrides und erzwungene Dateirechte"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Semantische Validierung und Base-DN-Ableitung
|
||
|
||
**Files:**
|
||
- Create: `internal/config/validate.go`
|
||
- Test: `internal/config/validate_test.go`
|
||
- Modify: `internal/config/load.go` (Validate am Ende von `Load` aufrufen)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Config` aus Task 2
|
||
- Produces:
|
||
- `func (c *Config) Validate() error` — sammelt **alle** Fehler und gibt sie gebündelt aus (`errors.Join`)
|
||
- `func BaseDNFromDomain(domain string) (string, error)` — `firma.local` → `DC=firma,DC=local`
|
||
- `func (c *Config) EffectiveBaseDN() string`
|
||
- Regeln: `tls_cert`/`tls_key` Pflicht; `opnsense.url` muss `https://` sein; `ad.servers` nicht leer und **keine IP-Adressen**; `tls_mode` ∈ {`ldaps`,`starttls`}; `cn_pattern` und `cn_regex` schließen sich aus; `cn_regex` muss kompilierbar sein; `cn_pattern` muss `{username}` enthalten; `logging.level` ∈ {`debug`,`info`,`warn`,`error`}; `audit_log` Pflicht
|
||
|
||
- [ ] **Step 1: Failing test schreiben**
|
||
|
||
`internal/config/validate_test.go`:
|
||
|
||
```go
|
||
package config
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func validCfg(t *testing.T) *Config {
|
||
t.Helper()
|
||
cfg, err := Parse(strings.NewReader(minimalYAML))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return cfg
|
||
}
|
||
|
||
func TestBaseDNFromDomain(t *testing.T) {
|
||
cases := map[string]string{
|
||
"firma.local": "DC=firma,DC=local",
|
||
"ad.firma.example": "DC=ad,DC=firma,DC=example",
|
||
"FIRMA.LOCAL": "DC=FIRMA,DC=LOCAL",
|
||
}
|
||
for in, want := range cases {
|
||
got, err := BaseDNFromDomain(in)
|
||
if err != nil {
|
||
t.Errorf("BaseDNFromDomain(%q): %v", in, err)
|
||
continue
|
||
}
|
||
if got != want {
|
||
t.Errorf("BaseDNFromDomain(%q) = %q, want %q", in, got, want)
|
||
}
|
||
}
|
||
if _, err := BaseDNFromDomain("firmalocal"); err == nil {
|
||
t.Error("Domain ohne Punkt muss abgelehnt werden")
|
||
}
|
||
if _, err := BaseDNFromDomain(""); err == nil {
|
||
t.Error("leere Domain muss abgelehnt werden")
|
||
}
|
||
}
|
||
|
||
func TestEffectiveBaseDNPrefersOverride(t *testing.T) {
|
||
cfg := validCfg(t)
|
||
cfg.AD.BaseDN = "OU=Users,DC=firma,DC=local"
|
||
if got := cfg.EffectiveBaseDN(); got != "OU=Users,DC=firma,DC=local" {
|
||
t.Errorf("Override muss gewinnen, got %q", got)
|
||
}
|
||
cfg.AD.BaseDN = ""
|
||
if got := cfg.EffectiveBaseDN(); got != "DC=firma,DC=local" {
|
||
t.Errorf("Ableitung falsch, got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestValidateAcceptsMinimalConfig(t *testing.T) {
|
||
if err := validCfg(t).Validate(); err != nil {
|
||
t.Fatalf("minimale Config sollte gültig sein: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestValidateRejectsIPServers(t *testing.T) {
|
||
cfg := validCfg(t)
|
||
cfg.AD.Servers = []string{"10.1.1.10"}
|
||
err := cfg.Validate()
|
||
if err == nil || !strings.Contains(err.Error(), "10.1.1.10") {
|
||
t.Fatalf("IP-Adresse als DC muss abgelehnt werden, got: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestValidateRejectsHTTPOPNsense(t *testing.T) {
|
||
cfg := validCfg(t)
|
||
cfg.OPNsense.URL = "http://fw01.firma.local"
|
||
if err := cfg.Validate(); err == nil {
|
||
t.Fatal("http:// muss abgelehnt werden")
|
||
}
|
||
}
|
||
|
||
func TestValidateRejectsPatternAndRegexTogether(t *testing.T) {
|
||
cfg := validCfg(t)
|
||
cfg.Matching.CNRegex = "^.*$"
|
||
if err := cfg.Validate(); err == nil {
|
||
t.Fatal("cn_pattern und cn_regex gleichzeitig muss abgelehnt werden")
|
||
}
|
||
}
|
||
|
||
func TestValidateRejectsBadRegex(t *testing.T) {
|
||
cfg := validCfg(t)
|
||
cfg.Matching.CNPattern = ""
|
||
cfg.Matching.CNRegex = "([unbalanced"
|
||
if err := cfg.Validate(); err == nil {
|
||
t.Fatal("unkompilierbare Regex muss abgelehnt werden")
|
||
}
|
||
}
|
||
|
||
func TestValidateCollectsMultipleErrors(t *testing.T) {
|
||
cfg := validCfg(t)
|
||
cfg.Portal.TLSCert = ""
|
||
cfg.Portal.TLSKey = ""
|
||
cfg.AD.VPNGroup = ""
|
||
err := cfg.Validate()
|
||
if err == nil {
|
||
t.Fatal("mehrere Fehler erwartet")
|
||
}
|
||
for _, want := range []string{"tls_cert", "tls_key", "vpn_group"} {
|
||
if !strings.Contains(err.Error(), want) {
|
||
t.Errorf("Sammelfehler muss %q nennen, got: %v", want, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestValidateRejectsPatternWithoutPlaceholder(t *testing.T) {
|
||
cfg := validCfg(t)
|
||
cfg.Matching.CNPattern = "fester-cn"
|
||
if err := cfg.Validate(); err == nil {
|
||
t.Fatal("cn_pattern ohne {username} muss abgelehnt werden")
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**
|
||
|
||
Run: `go test ./internal/config/ -run 'TestValidate|TestBaseDN|TestEffective' -v`
|
||
Expected: FAIL — `undefined: BaseDNFromDomain`
|
||
|
||
- [ ] **Step 3: `validate.go` schreiben**
|
||
|
||
```go
|
||
package config
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"net"
|
||
"net/url"
|
||
"regexp"
|
||
"slices"
|
||
"strings"
|
||
)
|
||
|
||
// UsernamePlaceholder ist der Platzhalter in matching.cn_pattern.
|
||
const UsernamePlaceholder = "{username}"
|
||
|
||
// BaseDNFromDomain leitet DC=firma,DC=local aus firma.local ab.
|
||
func BaseDNFromDomain(domain string) (string, error) {
|
||
domain = strings.Trim(strings.TrimSpace(domain), ".")
|
||
if domain == "" {
|
||
return "", errors.New("ad.domain ist leer")
|
||
}
|
||
parts := strings.Split(domain, ".")
|
||
if len(parts) < 2 {
|
||
return "", fmt.Errorf("ad.domain %q sieht nicht wie eine AD-Domäne aus (erwartet z. B. firma.local); "+
|
||
"andernfalls ad.base_dn explizit setzen", domain)
|
||
}
|
||
dcs := make([]string, len(parts))
|
||
for i, p := range parts {
|
||
if p == "" {
|
||
return "", fmt.Errorf("ad.domain %q enthält leere Labels", domain)
|
||
}
|
||
dcs[i] = "DC=" + p
|
||
}
|
||
return strings.Join(dcs, ","), nil
|
||
}
|
||
|
||
// EffectiveBaseDN liefert den Override oder die Ableitung aus der Domäne.
|
||
func (c *Config) EffectiveBaseDN() string {
|
||
if strings.TrimSpace(c.AD.BaseDN) != "" {
|
||
return strings.TrimSpace(c.AD.BaseDN)
|
||
}
|
||
dn, err := BaseDNFromDomain(c.AD.Domain)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return dn
|
||
}
|
||
|
||
// Validate prüft die Konfiguration semantisch und sammelt alle Fehler.
|
||
func (c *Config) Validate() error {
|
||
var errs []error
|
||
req := func(value, name string) {
|
||
if strings.TrimSpace(value) == "" {
|
||
errs = append(errs, fmt.Errorf("%s ist erforderlich", name))
|
||
}
|
||
}
|
||
|
||
req(c.Portal.Listen, "portal.listen")
|
||
req(c.Portal.TLSCert, "portal.tls_cert")
|
||
req(c.Portal.TLSKey, "portal.tls_key")
|
||
if _, _, err := net.SplitHostPort(c.Portal.Listen); c.Portal.Listen != "" && err != nil {
|
||
errs = append(errs, fmt.Errorf("portal.listen %q ist keine gültige Adresse (erwartet HOST:PORT): %w",
|
||
c.Portal.Listen, err))
|
||
}
|
||
|
||
req(c.OPNsense.URL, "opnsense.url")
|
||
if c.OPNsense.URL != "" {
|
||
u, err := url.Parse(c.OPNsense.URL)
|
||
switch {
|
||
case err != nil:
|
||
errs = append(errs, fmt.Errorf("opnsense.url %q ist keine gültige URL: %w", c.OPNsense.URL, err))
|
||
case u.Scheme != "https":
|
||
errs = append(errs, fmt.Errorf("opnsense.url muss mit https:// beginnen, hat aber Schema %q", u.Scheme))
|
||
case u.Host == "":
|
||
errs = append(errs, errors.New("opnsense.url enthält keinen Host"))
|
||
}
|
||
}
|
||
req(c.OPNsense.APIKey, "opnsense.api_key (oder api_key_file / "+EnvAPIKey+")")
|
||
req(c.OPNsense.APISecret, "opnsense.api_secret (oder api_secret_file / "+EnvAPISecret+")")
|
||
|
||
req(c.AD.Domain, "ad.domain")
|
||
req(c.AD.BindUser, "ad.bind_user")
|
||
req(c.AD.BindPassword, "ad.bind_password (oder bind_password_file / "+EnvBindPassword+")")
|
||
req(c.AD.VPNGroup, "ad.vpn_group")
|
||
if len(c.AD.Servers) == 0 {
|
||
errs = append(errs, errors.New("ad.servers muss mindestens einen Domain Controller enthalten"))
|
||
}
|
||
for _, s := range c.AD.Servers {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
errs = append(errs, errors.New("ad.servers enthält einen leeren Eintrag"))
|
||
continue
|
||
}
|
||
if net.ParseIP(s) != nil {
|
||
errs = append(errs, fmt.Errorf(
|
||
"ad.servers: %q ist eine IP-Adresse — es sind Hostnamen erforderlich, "+
|
||
"weil das LDAPS-Zertifikat gegen den Hostnamen geprüft wird", s))
|
||
}
|
||
}
|
||
if !slices.Contains([]string{"ldaps", "starttls"}, c.AD.TLSMode) {
|
||
errs = append(errs, fmt.Errorf("ad.tls_mode %q ist ungültig (erlaubt: ldaps, starttls)", c.AD.TLSMode))
|
||
}
|
||
if c.AD.Port <= 0 || c.AD.Port > 65535 {
|
||
errs = append(errs, fmt.Errorf("ad.port %d liegt außerhalb 1–65535", c.AD.Port))
|
||
}
|
||
if c.AD.BaseDN == "" {
|
||
if _, err := BaseDNFromDomain(c.AD.Domain); err != nil && c.AD.Domain != "" {
|
||
errs = append(errs, err)
|
||
}
|
||
}
|
||
|
||
pattern, rx := strings.TrimSpace(c.Matching.CNPattern), strings.TrimSpace(c.Matching.CNRegex)
|
||
switch {
|
||
case pattern != "" && rx != "":
|
||
errs = append(errs, errors.New(
|
||
"matching.cn_pattern und matching.cn_regex schließen sich aus — bitte nur eines setzen"))
|
||
case pattern == "" && rx == "":
|
||
errs = append(errs, errors.New("matching.cn_pattern oder matching.cn_regex muss gesetzt sein"))
|
||
case pattern != "" && !strings.Contains(pattern, UsernamePlaceholder):
|
||
errs = append(errs, fmt.Errorf("matching.cn_pattern %q enthält keinen %s-Platzhalter",
|
||
pattern, UsernamePlaceholder))
|
||
case rx != "":
|
||
if _, err := regexp.Compile(rx); err != nil {
|
||
errs = append(errs, fmt.Errorf("matching.cn_regex ist nicht kompilierbar: %w", err))
|
||
}
|
||
}
|
||
|
||
if !slices.Contains([]string{"debug", "info", "warn", "error"}, c.Logging.Level) {
|
||
errs = append(errs, fmt.Errorf("logging.level %q ist ungültig (erlaubt: debug, info, warn, error)",
|
||
c.Logging.Level))
|
||
}
|
||
req(c.Logging.AuditLog, "logging.audit_log")
|
||
if c.Logging.MaxSizeMB <= 0 {
|
||
errs = append(errs, fmt.Errorf("logging.max_size_mb muss positiv sein, ist %d", c.Logging.MaxSizeMB))
|
||
}
|
||
if c.Logging.MaxBackups < 0 {
|
||
errs = append(errs, fmt.Errorf("logging.max_backups darf nicht negativ sein, ist %d", c.Logging.MaxBackups))
|
||
}
|
||
|
||
return errors.Join(errs...)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: `Validate` in `Load` aufrufen**
|
||
|
||
In `load.go`, direkt vor `return cfg, nil`:
|
||
|
||
```go
|
||
if err := cfg.Validate(); err != nil {
|
||
return nil, fmt.Errorf("Konfiguration %s ist ungültig:\n%w", path, err)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Tests laufen lassen**
|
||
|
||
Run: `go test ./internal/config/ -v`
|
||
Expected: PASS. Falls `TestLoadResolvesSecretFiles` o. ä. jetzt fehlschlägt, weil `minimalYAML` unvollständig ist: `minimalYAML` ist bewusst vollständig genug — es muss ohne Änderung validieren.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add internal/config
|
||
git commit -m "feat(config): semantische Validierung mit Sammelfehlern und Base-DN-Ableitung"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Audit-Logger (JSONL, Rotation, SIGHUP, stdout-Tee)
|
||
|
||
**Files:**
|
||
- Create: `internal/audit/event.go`, `internal/audit/logger.go`, `internal/audit/rotate.go`
|
||
- Test: `internal/audit/logger_test.go`, `internal/audit/rotate_test.go`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nichts (bewusst dependency-frei, damit jedes Package loggen kann)
|
||
- Produces:
|
||
- Event-Konstanten: `EventLoginSuccess = "login_success"`, `EventLoginFailed`, `EventLogout`, `EventSessionExpired`, `EventConfigDownload`, `EventDownloadDenied`, `EventNoCertFound`, `EventOPNsenseUnreachable`, `EventLDAPFailover`, `EventRateLimited`, `EventStartup`
|
||
- `type Event struct { TS string; Event string; User string; SrcIP string; Session string; Reason string; VPNInstance string; CertCN string; CertExpiry string; Format string; Pattern string; Version string; ConfigSum string; Detail string }` — alle optionalen Felder mit `json:",omitempty"`
|
||
- `func New(path string, opts Options) (*Logger, error)`; `type Options struct { MaxSizeMB, MaxBackups int; Compress bool; Stdout io.Writer; Clock func() time.Time }`
|
||
- `func (l *Logger) Log(e Event)` — füllt `TS` automatisch, ist goroutine-sicher
|
||
- `func (l *Logger) Reopen() error` — für SIGHUP
|
||
- `func (l *Logger) Close() error`
|
||
- `func ShortSession(token string) string` — erste 4 Hex-Zeichen eines SHA-256 über den Token, **nie** der Token selbst
|
||
- `const UnknownUser = "<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 2–4, CLI aus Task 1/16/17
|
||
- Produces: lauffähiges Release-Archiv über `make release`
|
||
|
||
- [ ] **Step 1: systemd-Unit schreiben**
|
||
|
||
`deploy/vpnportal.service`:
|
||
|
||
```ini
|
||
[Unit]
|
||
Description=VPN-Konfig-Portal für OPNsense
|
||
Documentation=file:/usr/share/doc/vpnportal/README.md
|
||
After=network-online.target
|
||
Wants=network-online.target
|
||
|
||
[Service]
|
||
Type=simple
|
||
ExecStart=/usr/local/bin/vpnportal serve --config /etc/vpnportal/config.yaml
|
||
Restart=on-failure
|
||
RestartSec=5s
|
||
|
||
# Dedizierter, zustandsloser Dienstbenutzer. Alternativ einen festen
|
||
# Systembenutzer anlegen und User=/Group= setzen — siehe README.
|
||
DynamicUser=yes
|
||
# Für das Audit-Log; systemd legt /var/log/vpnportal an und übergibt es dem Dienst.
|
||
LogsDirectory=vpnportal
|
||
LogsDirectoryMode=0750
|
||
|
||
# Secrets werden nicht als Umgebungsvariablen übergeben, sondern als Dateien
|
||
# unter $CREDENTIALS_DIRECTORY. In der config.yaml dann z. B.:
|
||
# bind_password_file: "/run/credentials/vpnportal.service/ad-bind-password"
|
||
LoadCredential=ad-bind-password:/etc/vpnportal/secrets/ad-bind-password
|
||
LoadCredential=opnsense-api-secret:/etc/vpnportal/secrets/opnsense-api-secret
|
||
|
||
# Härtung
|
||
ProtectSystem=strict
|
||
ProtectHome=yes
|
||
PrivateTmp=yes
|
||
PrivateDevices=yes
|
||
NoNewPrivileges=yes
|
||
ProtectKernelTunables=yes
|
||
ProtectKernelModules=yes
|
||
ProtectKernelLogs=yes
|
||
ProtectControlGroups=yes
|
||
ProtectClock=yes
|
||
ProtectProc=invisible
|
||
RestrictNamespaces=yes
|
||
RestrictRealtime=yes
|
||
RestrictSUIDSGID=yes
|
||
RestrictAddressFamilies=AF_INET AF_INET6
|
||
LockPersonality=yes
|
||
MemoryDenyWriteExecute=yes
|
||
SystemCallArchitectures=native
|
||
SystemCallFilter=@system-service
|
||
SystemCallFilter=~@privileged @resources
|
||
CapabilityBoundingSet=
|
||
# Falls das Portal direkt auf 443 lauschen soll, stattdessen:
|
||
# AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||
# CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||
ReadOnlyPaths=/etc/vpnportal
|
||
UMask=0077
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
```
|
||
|
||
- [ ] **Step 2: Beispiel-Konfiguration schreiben**
|
||
|
||
`deploy/config.example.yaml`: die vollständige Konfiguration aus der PRD, mit auskommentierten Optionalfeldern und einem Kommentar pro Feld. Pflichtfelder unkommentiert mit Platzhalterwerten (`https://fw01.firma.local`, `firma.local`, …); alle Secrets als Platzhalter `"BITTE-ERSETZEN"` **mit** danebenstehendem `# alternativ: *_file` -Hinweis. Der Kopf der Datei trägt den Hinweis:
|
||
|
||
```yaml
|
||
# Diese Datei enthält Zugangsdaten. Rechte setzen:
|
||
# chown root:vpnportal /etc/vpnportal/config.yaml && chmod 640 /etc/vpnportal/config.yaml
|
||
# Das Portal startet nicht, wenn die Rechte weiter gefasst sind.
|
||
```
|
||
|
||
- [ ] **Step 3: Prüfen, dass die Beispiel-Konfiguration parst**
|
||
|
||
Ein Test stellt sicher, dass die ausgelieferte Beispieldatei mit dem Strict-Parser zusammenpasst — `config/example_test.go`:
|
||
|
||
```go
|
||
package config
|
||
|
||
import (
|
||
"os"
|
||
"testing"
|
||
)
|
||
|
||
// Die ausgelieferte Beispielkonfiguration muss immer parsen und validieren.
|
||
// Sonst laufen Kunden beim ersten Start in einen Fehler.
|
||
func TestExampleConfigParsesAndValidates(t *testing.T) {
|
||
f, err := os.Open("../../deploy/config.example.yaml")
|
||
if err != nil {
|
||
t.Fatalf("Beispielkonfiguration fehlt: %v", err)
|
||
}
|
||
defer f.Close()
|
||
|
||
cfg, err := Parse(f)
|
||
if err != nil {
|
||
t.Fatalf("Beispielkonfiguration parst nicht: %v", err)
|
||
}
|
||
if err := cfg.Validate(); err != nil {
|
||
t.Fatalf("Beispielkonfiguration ist nicht gültig: %v", err)
|
||
}
|
||
}
|
||
```
|
||
|
||
Run: `go test ./internal/config/ -run TestExampleConfig -v`
|
||
Expected: PASS (notfalls die Beispieldatei korrigieren, nicht den Test)
|
||
|
||
- [ ] **Step 4: README schreiben**
|
||
|
||
`README.md` mit diesen Abschnitten:
|
||
|
||
1. **Was das Portal tut** — Ein-Absatz-Beschreibung, ausdrücklich: read-only, erstellt und widerruft keine Zertifikate.
|
||
2. **Voraussetzungen** — Linux amd64 oder arm64; OPNsense mit Plugin `os-openvpn-client-export` (Mindestversion aus `docs/opnsense-api.md`); Active Directory mit LDAPS; NTP zwingend; TLS-Zertifikat für das Portal.
|
||
3. **Installation in unter 30 Minuten** — nummerierte Schritte: Archiv entpacken, Binary nach `/usr/local/bin`, `/etc/vpnportal` anlegen, Beispiel-Config kopieren und anpassen, Rechte setzen (`chmod 640`, `chmod 600` für den TLS-Key), `vpnportal check` ausführen, systemd-Unit installieren, `systemctl enable --now vpnportal`.
|
||
4. **OPNsense vorbereiten** — Benutzer anlegen, Gruppe mit **ausschließlich** dem Privileg „VPN: OpenVPN Client Export", API-Key erzeugen, keinen Admin-Key verwenden.
|
||
5. **Active Directory vorbereiten** — unprivilegierter Dienstbenutzer genügt; Gruppe `VPN-Users`; Hostnamen statt IP-Adressen in `ad.servers`, weil das LDAPS-Zertifikat gegen den Hostnamen geprüft wird; interne CA über `ca_file`.
|
||
6. **Secrets** — die drei `*_file`-Varianten, die drei `VPNPORTAL_*`-Variablen, Rangfolge Env > Datei > Inline, `LoadCredential`-Beispiel aus der Unit.
|
||
7. **Zertifikatszuordnung** — `cn_pattern` mit `{username}`, Beispiel `{username}@firma.de`, `cn_regex` als Notnagel für Bestandsumgebungen inklusive Warnung, dass er zu weit gefasst sein kann.
|
||
8. **Betrieb** — `/healthz` für Zabbix/CheckMK mit Beispielantwort; Log-Auswertung per `journalctl -u vpnportal` und über die JSONL-Datei; `jq`-Einzeiler für Downloads eines Benutzers.
|
||
9. **Audit-Log** — Ereignisliste, Beispielzeilen, Hinweis: „Für revisionssichere Aufbewahrung das Log an ein zentrales Logsystem weiterleiten." Empfohlene Aufbewahrung 90 Tage, kundenseitig anpassbar. Hinweis, dass das Log personenbezogene Daten enthält und entsprechend zu schützen ist.
|
||
10. **Rotation** — eingebaut und größenbasiert; alternativ externes logrotate mit `SIGHUP` (`systemctl reload` bzw. `kill -HUP`).
|
||
11. **Raspberry Pi** — SSD statt SD-Karte empfohlen; Gerät in einen abschließbaren Netzwerkschrank, weil das Speichermedium Zugangsdaten trägt; NTP prüfen.
|
||
12. **Fehlersuche** — Tabelle: Symptom → wahrscheinliche Ursache → Kommando. Mindestens: „Anmeldung schlägt für alle fehl" → Dienstkonto/Gruppe → `vpnportal check`; „einzelner Benutzer kann sich nicht anmelden" → `vpnportal check --test-auth <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.
|