From fbfff90b380b69aa63123780f3f27bdf1b0956e0 Mon Sep 17 00:00:00 2001 From: Carsten Abele Date: Fri, 14 Aug 2026 09:03:31 +0200 Subject: [PATCH 01/10] feat: CLI-Skeleton mit version/serve/check und Makefile Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68 --- .gitignore | 4 ++++ Makefile | 44 ++++++++++++++++++++++++++++++++++++ cmd/vpnportal/buildinfo.go | 14 ++++++++++++ cmd/vpnportal/check.go | 17 ++++++++++++++ cmd/vpnportal/main.go | 46 ++++++++++++++++++++++++++++++++++++++ cmd/vpnportal/main_test.go | 43 +++++++++++++++++++++++++++++++++++ cmd/vpnportal/serve.go | 25 +++++++++++++++++++++ go.mod | 3 +++ 8 files changed, 196 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 cmd/vpnportal/buildinfo.go create mode 100644 cmd/vpnportal/check.go create mode 100644 cmd/vpnportal/main.go create mode 100644 cmd/vpnportal/main_test.go create mode 100644 cmd/vpnportal/serve.go create mode 100644 go.mod diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8741b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/dist/ +/vpnportal +*.log +.DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b2e37f4 --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +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) + +# sha256sum gibt es auf macOS nicht; shasum ist dort das Äquivalent. +SHA256 := $(shell command -v sha256sum >/dev/null 2>&1 && echo sha256sum || echo "shasum -a 256") + +export CGO_ENABLED := 0 + +.PHONY: all build test race vet lint release clean + +all: vet test build + +build: + go build -trimpath -ldflags '$(LDFLAGS)' -o dist/$(BINARY) $(PKG) + +test: + go test ./... + +race: + go test -race ./... + +vet: + go vet ./... + +lint: vet + @test -z "$$(gofmt -l . | tee /dev/stderr)" || (echo "gofmt-Fehler: bitte 'gofmt -w .' ausführen" && exit 1) + +release: clean + @set -e; for arch in amd64 arm64; do \ + echo "==> linux/$$arch"; \ + mkdir -p dist/linux-$$arch; \ + GOOS=linux GOARCH=$$arch go build -trimpath -ldflags '$(LDFLAGS)' \ + -o dist/linux-$$arch/$(BINARY) $(PKG); \ + 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 && $(SHA256) *.tar.gz > SHA256SUMS + +clean: + rm -rf dist diff --git a/cmd/vpnportal/buildinfo.go b/cmd/vpnportal/buildinfo.go new file mode 100644 index 0000000..1db3a45 --- /dev/null +++ b/cmd/vpnportal/buildinfo.go @@ -0,0 +1,14 @@ +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) +} diff --git a/cmd/vpnportal/check.go b/cmd/vpnportal/check.go new file mode 100644 index 0000000..ec6b1a1 --- /dev/null +++ b/cmd/vpnportal/check.go @@ -0,0 +1,17 @@ +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 +} diff --git a/cmd/vpnportal/main.go b/cmd/vpnportal/main.go new file mode 100644 index 0000000..842bed6 --- /dev/null +++ b/cmd/vpnportal/main.go @@ -0,0 +1,46 @@ +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 + } +} diff --git a/cmd/vpnportal/main_test.go b/cmd/vpnportal/main_test.go new file mode 100644 index 0000000..fe02445 --- /dev/null +++ b/cmd/vpnportal/main_test.go @@ -0,0 +1,43 @@ +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()) + } +} diff --git a/cmd/vpnportal/serve.go b/cmd/vpnportal/serve.go new file mode 100644 index 0000000..bcc9983 --- /dev/null +++ b/cmd/vpnportal/serve.go @@ -0,0 +1,25 @@ +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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..591fea0 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.ravensburg.dev/cabele/opnsense-portal + +go 1.26.5 From b47ee11ba41adc4ddda596496aa3ce5b55491f64 Mon Sep 17 00:00:00 2001 From: Carsten Abele Date: Fri, 14 Aug 2026 09:06:43 +0200 Subject: [PATCH 02/10] feat(config): Strict-YAML, Secret-Dateien, Env-Overrides, Dateirechte und Validierung Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68 --- go.mod | 2 + go.sum | 3 + internal/config/config.go | 109 +++++++++++++++++++++++ internal/config/load.go | 116 +++++++++++++++++++++++++ internal/config/load_test.go | 87 +++++++++++++++++++ internal/config/perms.go | 40 +++++++++ internal/config/perms_test.go | 65 ++++++++++++++ internal/config/secrets_test.go | 96 +++++++++++++++++++++ internal/config/validate.go | 144 +++++++++++++++++++++++++++++++ internal/config/validate_test.go | 115 ++++++++++++++++++++++++ 10 files changed, 777 insertions(+) create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/load.go create mode 100644 internal/config/load_test.go create mode 100644 internal/config/perms.go create mode 100644 internal/config/perms_test.go create mode 100644 internal/config/secrets_test.go create mode 100644 internal/config/validate.go create mode 100644 internal/config/validate_test.go diff --git a/go.mod b/go.mod index 591fea0..967871b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module git.ravensburg.dev/cabele/opnsense-portal go 1.26.5 + +require gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4bc0337 --- /dev/null +++ b/go.sum @@ -0,0 +1,3 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..52f381a --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,109 @@ +// 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, + }, + } +} diff --git a/internal/config/load.go b/internal/config/load.go new file mode 100644 index 0000000..dfb112b --- /dev/null +++ b/internal/config/load.go @@ -0,0 +1,116 @@ +package config + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +// Namen der Umgebungsvariablen, die Secrets aus der Konfiguration überschreiben. +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 + +// 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 +} + +// 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 + } + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("Konfiguration %s ist ungültig:\n%w", path, err) + } + return cfg, nil +} + +// secretSlot beschreibt ein Secret mit seinen drei Bezugsquellen. +type secretSlot struct { + name string // YAML-Feldname für Fehlermeldungen + inline *string + file string + envName string +} + +// resolveSecrets wendet die Rangfolge Env > *_file > Inline an. +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 +} diff --git a/internal/config/load_test.go b/internal/config/load_test.go new file mode 100644 index 0000000..a56c64b --- /dev/null +++ b/internal/config/load_test.go @@ -0,0 +1,87 @@ +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") + } +} diff --git a/internal/config/perms.go b/internal/config/perms.go new file mode 100644 index 0000000..183fc94 --- /dev/null +++ b/internal/config/perms.go @@ -0,0 +1,40 @@ +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 +} diff --git a/internal/config/perms_test.go b/internal/config/perms_test.go new file mode 100644 index 0000000..c12aad4 --- /dev/null +++ b/internal/config/perms_test.go @@ -0,0 +1,65 @@ +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) + } +} diff --git a/internal/config/secrets_test.go b/internal/config/secrets_test.go new file mode 100644 index 0000000..014749d --- /dev/null +++ b/internal/config/secrets_test.go @@ -0,0 +1,96 @@ +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) + } +} diff --git a/internal/config/validate.go b/internal/config/validate.go new file mode 100644 index 0000000..4575a77 --- /dev/null +++ b/internal/config/validate.go @@ -0,0 +1,144 @@ +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, +// damit der Betreiber nicht Fehler für Fehler nachbessern muss. +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 == "" && c.AD.Domain != "" { + if _, err := BaseDNFromDomain(c.AD.Domain); err != nil { + 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 != "": + // Der Platzhalter wird für den Kompiliertest durch einen harmlosen + // Literalwert ersetzt; zur Laufzeit steht dort der quotierte Benutzername. + if _, err := regexp.Compile(strings.ReplaceAll(rx, UsernamePlaceholder, "x")); 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...) +} diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go new file mode 100644 index 0000000..d4c3051 --- /dev/null +++ b/internal/config/validate_test.go @@ -0,0 +1,115 @@ +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") + } +} From 2eb69bdcfed9340909033e242d4d1bce23d2cc1f Mon Sep 17 00:00:00 2001 From: Carsten Abele Date: Fri, 14 Aug 2026 09:08:19 +0200 Subject: [PATCH 03/10] =?UTF-8?q?feat(audit):=20JSONL-Logger=20mit=20Gr?= =?UTF-8?q?=C3=B6=C3=9Fenrotation,=20gzip=20und=20SIGHUP-Reopen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68 --- internal/audit/event.go | 60 +++++++++++++ internal/audit/logger.go | 77 ++++++++++++++++ internal/audit/logger_test.go | 124 ++++++++++++++++++++++++++ internal/audit/rotate.go | 159 ++++++++++++++++++++++++++++++++++ internal/audit/rotate_test.go | 106 +++++++++++++++++++++++ 5 files changed, 526 insertions(+) create mode 100644 internal/audit/event.go create mode 100644 internal/audit/logger.go create mode 100644 internal/audit/logger_test.go create mode 100644 internal/audit/rotate.go create mode 100644 internal/audit/rotate_test.go diff --git a/internal/audit/event.go b/internal/audit/event.go new file mode 100644 index 0000000..ec7b443 --- /dev/null +++ b/internal/audit/event.go @@ -0,0 +1,60 @@ +// Package audit schreibt strukturierte Audit-Ereignisse als JSON Lines. +package audit + +import ( + "crypto/sha256" + "encoding/hex" +) + +// Audit-Ereignisse. +const ( + EventLoginSuccess = "login_success" + EventLoginFailed = "login_failed" + EventLogout = "logout" + EventSessionExpired = "session_expired" + EventConfigDownload = "config_download" + EventDownloadDenied = "download_denied" +) + +// Betriebsereignisse. +const ( + EventNoCertFound = "no_cert_found" + EventOPNsenseUnreachable = "opnsense_unreachable" + EventLDAPFailover = "ldap_failover" + EventRateLimited = "rate_limited" + EventStartup = "startup" +) + +// UnknownUser ersetzt den eingegebenen Namen, wenn der Benutzer im AD nicht +// gefunden wurde. Damit landet ein versehentlich ins Username-Feld getipptes +// Passwort nie im Klartext im Log. +const UnknownUser = "" + +// Event ist eine Zeile im Audit-Log. Leere Felder werden nicht serialisiert. +type Event struct { + TS string `json:"ts"` + Event string `json:"event"` + User string `json:"user,omitempty"` + SrcIP string `json:"src_ip,omitempty"` + Session string `json:"session,omitempty"` + Reason string `json:"reason,omitempty"` + VPNInstance string `json:"vpn_instance,omitempty"` + CertCN string `json:"cert_cn,omitempty"` + CertExpiry string `json:"cert_expiry,omitempty"` + Format string `json:"format,omitempty"` + Pattern string `json:"pattern,omitempty"` + Server string `json:"server,omitempty"` + Version string `json:"version,omitempty"` + ConfigSum string `json:"config_sum,omitempty"` + Detail string `json:"detail,omitempty"` +} + +// ShortSession liefert eine kurze, nicht umkehrbare Korrelations-ID. +// Der Session-Token selbst darf niemals ins Log gelangen. +func ShortSession(token string) string { + if token == "" { + return "" + } + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:])[:4] +} diff --git a/internal/audit/logger.go b/internal/audit/logger.go new file mode 100644 index 0000000..36905ae --- /dev/null +++ b/internal/audit/logger.go @@ -0,0 +1,77 @@ +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 und liefert einen Logger. +// Ein fehlendes Verzeichnis wird angelegt. +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 laufenden 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() +} diff --git a/internal/audit/logger_test.go b/internal/audit/logger_test.go new file mode 100644 index 0000000..396d4ab --- /dev/null +++ b/internal/audit/logger_test.go @@ -0,0 +1,124 @@ +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") + } +} diff --git a/internal/audit/rotate.go b/internal/audit/rotate.go new file mode 100644 index 0000000..8318644 --- /dev/null +++ b/internal/audit/rotate.go @@ -0,0 +1,159 @@ +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:] + } +} diff --git a/internal/audit/rotate_test.go b/internal/audit/rotate_test.go new file mode 100644 index 0000000..af6efaf --- /dev/null +++ b/internal/audit/rotate_test.go @@ -0,0 +1,106 @@ +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() > 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) + } +} From 99ee8758cc8cd55727347d4120de7e630524d131 Mon Sep 17 00:00:00 2001 From: Carsten Abele Date: Fri, 14 Aug 2026 09:11:38 +0200 Subject: [PATCH 04/10] =?UTF-8?q?feat(auth):=20AD-Authenticator=20mit=20Zw?= =?UTF-8?q?ei-Schritt-Bind,=20DC-Failover=20und=20verschachtelter=20Gruppe?= =?UTF-8?q?npr=C3=BCfung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68 --- go.mod | 9 +- go.sum | 10 ++ internal/auth/ad.go | 330 +++++++++++++++++++++++++++++++++++ internal/auth/ad_test.go | 296 +++++++++++++++++++++++++++++++ internal/auth/addata.go | 47 +++++ internal/auth/addata_test.go | 72 ++++++++ internal/auth/auth.go | 67 +++++++ internal/auth/dial.go | 78 +++++++++ 8 files changed, 908 insertions(+), 1 deletion(-) create mode 100644 internal/auth/ad.go create mode 100644 internal/auth/ad_test.go create mode 100644 internal/auth/addata.go create mode 100644 internal/auth/addata_test.go create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/dial.go diff --git a/go.mod b/go.mod index 967871b..0ede722 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,11 @@ module git.ravensburg.dev/cabele/opnsense-portal go 1.26.5 -require gopkg.in/yaml.v3 v3.0.1 // indirect +require ( + github.com/Azure/go-ntlmssp v0.1.1 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect + github.com/go-ldap/ldap/v3 v3.4.14 // indirect + github.com/google/uuid v1.6.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index 4bc0337..309dba2 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,13 @@ +github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= +github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= +github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ= +github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs= +github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/ad.go b/internal/auth/ad.go new file mode 100644 index 0000000..7d28c8a --- /dev/null +++ b/internal/auth/ad.go @@ -0,0 +1,330 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "sync/atomic" + "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 + +// FailoverHook wird gerufen, wenn ein Domain Controller übersprungen wird. +type FailoverHook func(server string, err error) + +// 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 kann auch nachträglich über SetFailoverHook gesetzt werden. + OnFailover FailoverHook +} + +// AD authentifiziert gegen Active Directory. +type AD struct { + opts Options + + // failover wird atomar gehalten, weil der Hook erst nach dem Öffnen des + // Audit-Logs gesetzt wird, withConn ihn aber sperrfrei lesen muss. + failover atomic.Pointer[FailoverHook] + + 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) + } + a := &AD{opts: opts} + if opts.OnFailover != nil { + a.SetFailoverHook(opts.OnFailover) + } + return a, nil +} + +// SetFailoverHook setzt den Failover-Rückruf, auch noch nach dem Start. +// Das ist nötig, weil der Audit-Logger erst nach der Startprüfung geöffnet wird. +func (a *AD) SetFailoverHook(fn FailoverHook) { + if fn == nil { + a.failover.Store(nil) + return + } + a.failover.Store(&fn) +} + +func (a *AD) notifyFailover(server string, err error) { + if p := a.failover.Load(); p != nil { + (*p)(server, err) + } +} + +// 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. +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 { + lastErr = fmt.Errorf("%s: %w", server, err) + a.notifyFailover(server, err) + continue + } + if err := c.Bind(a.opts.BindUser, a.opts.BindPassword); err != nil { + c.Close() + // Ein falsches Dienstkonto-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) + } + 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 + // auf einer eigenen Verbindung, 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 + a.notifyFailover(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 +} + +var _ Authenticator = (*AD)(nil) diff --git a/internal/auth/ad_test.go b/internal/auth/ad_test.go new file mode 100644 index 0000000..7c41272 --- /dev/null +++ b/internal/auth/ad_test.go @@ -0,0 +1,296 @@ +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 { + 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) + // Echtes AD vergleicht sAMAccountName ohne Rücksicht auf Groß-/Kleinschreibung; + // der Fake muss das nachbilden, sonst testet er strenger als die Wirklichkeit. + filter := strings.ToLower(req.Filter) + for needle, entries := range f.entries { + if strings.Contains(filter, strings.ToLower(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 +} + +// dialSequence liefert eine Dial-Funktion, die die übergebenen Verbindungen +// der Reihe nach ausgibt und danach bei der letzten bleibt. +func dialSequence(conns []*fakeConn) func(context.Context, string) (conn, error) { + i := 0 + return 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 + } +} + +func baseOptions() Options { + return 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, + } +} + +func newAD(t *testing.T, conns ...*fakeConn) *AD { + t.Helper() + opts := baseOptions() + opts.Dial = dialSequence(conns) + a, err := NewAD(opts) + if err != nil { + t.Fatalf("NewAD: %v", err) + } + return a +} + +func TestAuthenticateSuccessCanonicalisesUsername(t *testing.T) { + c := &fakeConn{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(strings.ToLower(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 + + opts := baseOptions() + opts.OnFailover = func(server string, err error) { failedOver = append(failedOver, server) } + opts.Dial = dialSequence([]*fakeConn{dead, alive}) + a, err := NewAD(opts) + 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") + } +} + +func TestSetFailoverHookIsUsed(t *testing.T) { + dead := &fakeConn{dialErr: errors.New("connection refused")} + alive := &fakeConn{entries: stdEntries(true)} + + opts := baseOptions() + opts.Dial = dialSequence([]*fakeConn{dead, alive}) + a, err := NewAD(opts) + if err != nil { + t.Fatal(err) + } + var seen []string + a.SetFailoverHook(func(server string, err error) { seen = append(seen, server) }) + if _, err := a.Authenticate(context.Background(), "mmueller", "geheim"); err != nil { + t.Fatal(err) + } + if len(seen) == 0 || seen[0] != "dc01.firma.local" { + t.Fatalf("Failover-Hook = %v", seen) + } +} + +func TestServiceBindFailureIsNotMaskedByFailover(t *testing.T) { + // Ein falsches Dienstkonto-Passwort ist ein Konfigurationsfehler. + // Es darf nicht als "nächster DC probieren" durchgehen. + c := &fakeConn{ + entries: stdEntries(true), + bindErr: map[string]error{"svc@firma.local": errors.New("LDAP Result Code 49")}, + } + a := newAD(t, c) + _, err := a.Authenticate(context.Background(), "mmueller", "geheim") + if err == nil { + t.Fatal("fehlerhafter Service-Bind muss zum Fehler führen") + } + if !strings.Contains(err.Error(), "Service-Bind") { + t.Errorf("Fehler muss den Service-Bind benennen: %v", err) + } +} diff --git a/internal/auth/addata.go b/internal/auth/addata.go new file mode 100644 index 0000000..00237af --- /dev/null +++ b/internal/auth/addata.go @@ -0,0 +1,47 @@ +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 +// und den Benutzer über den Rate-Limiter aussperren. +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 +} diff --git a/internal/auth/addata_test.go b/internal/auth/addata_test.go new file mode 100644 index 0000000..8f8ad9d --- /dev/null +++ b/internal/auth/addata_test.go @@ -0,0 +1,72 @@ +package auth + +import ( + "errors" + "fmt" + "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(fmt.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") + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..7aa5634 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,67 @@ +// 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" + "strings" +) + +// 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 } diff --git a/internal/auth/dial.go b/internal/auth/dial.go new file mode 100644 index 0000000..7edbf9f --- /dev/null +++ b/internal/auth/dial.go @@ -0,0 +1,78 @@ +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 — +// für LDAP gibt es bewusst keine Insecure-Option. +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 + } +} From e49882b8a89e83ae1f00fc303441aec386391856 Mon Sep 17 00:00:00 2001 From: Carsten Abele Date: Fri, 14 Aug 2026 09:15:53 +0200 Subject: [PATCH 05/10] feat(opnsense,certmatch): read-only Export-Client mit Streaming und CN-Zuordnung Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68 --- docs/opnsense-api.md | 102 +++++++++++ internal/certmatch/match.go | 122 ++++++++++++++ internal/certmatch/match_test.go | 141 ++++++++++++++++ internal/opnsense/client.go | 170 +++++++++++++++++++ internal/opnsense/client_test.go | 234 ++++++++++++++++++++++++++ internal/opnsense/export.go | 144 ++++++++++++++++ internal/opnsense/integration_test.go | 133 +++++++++++++++ internal/opnsense/types.go | 161 ++++++++++++++++++ internal/opnsense/types_test.go | 73 ++++++++ 9 files changed, 1280 insertions(+) create mode 100644 docs/opnsense-api.md create mode 100644 internal/certmatch/match.go create mode 100644 internal/certmatch/match_test.go create mode 100644 internal/opnsense/client.go create mode 100644 internal/opnsense/client_test.go create mode 100644 internal/opnsense/export.go create mode 100644 internal/opnsense/integration_test.go create mode 100644 internal/opnsense/types.go create mode 100644 internal/opnsense/types_test.go diff --git a/docs/opnsense-api.md b/docs/opnsense-api.md new file mode 100644 index 0000000..ae4a2e2 --- /dev/null +++ b/docs/opnsense-api.md @@ -0,0 +1,102 @@ +# OPNsense-API: genutzte Endpunkte und Annahmen + +Das Portal spricht ausschließlich das Plugin **`os-openvpn-client-export`** an +und ausschließlich lesend. + +> **Diese Annahmen sind mit `go test -tags integration ./internal/opnsense/` +> gegen eine echte Instanz zu bestätigen, bevor v1 ausgeliefert wird.** +> Die exakten Feldnamen und Antwortformen wechseln zwischen OPNsense-Versionen; +> der Client ist deshalb bewusst tolerant gebaut. + +## Genutzte Endpunkte + +| Methode | Pfad | Zweck | +|---|---|---| +| `GET` | `/api/openvpn/export/providers` | Liste der exportierbaren VPN-Instanzen | +| `GET` | `/api/openvpn/export/accounts/{vpnid}` | Zertifikate einer Instanz | +| `GET` | `/api/openvpn/export/download/{vpnid}/{format}/{certref}` | Konfiguration herunterladen | + +Alle drei sind `GET`. Das Portal ruft **keinen** schreibenden Endpunkt auf und +übergibt **keine** Exportoptionen — die auf der Firewall hinterlegten +Einstellungen sind die Quelle der Wahrheit. + +## Authentifizierung + +HTTP Basic Auth mit API-Key als Benutzername und API-Secret als Passwort. +Der API-Benutzer braucht ausschließlich das Privileg +**„VPN: OpenVPN Client Export"** — niemals einen Admin-Key. + +Bei ungültigem Key antwortet OPNsense je nach Version mit `401` oder mit +`200` und einer HTML-Loginseite. Der Client behandelt beides als +`ErrUnauthorized`; die HTML-Erkennung prüft sowohl den `Content-Type` als +auch ein führendes `<` im Body. + +## Akzeptierte Feldnamen + +Der Client akzeptiert mehrere Schreibweisen und nimmt den ersten +nicht-leeren Treffer (`internal/opnsense/types.go`, `rawAccount`): + +| Bedeutung | Akzeptierte JSON-Felder | +|---|---| +| Common Name | `commonName`, `common_name` | +| Beschreibung | `description`, `descr` | +| Ablaufdatum | `validTo`, `valid_to`, `validto` | +| Revoziert | `isRevoked`, `revoked`, `is_revoked` | +| VPN-ID (Provider) | `vpnid`, sonst der Map-Schlüssel | +| Instanzname | `name`, `description`, sonst `VPN ` | + +**Revoziert** gilt, sobald *eines* der drei Felder wahr meldet — im Zweifel +restriktiv. Als „wahr" zählen `true`, `1`, `"1"`, `"true"`, `"yes"`, `"on"`. + +## Akzeptierte Datumsformate + +`time.RFC3339`, `2006-01-02T15:04:05`, `2006-01-02 15:04:05`, `2006-01-02`, +`Jan _2 15:04:05 2006 MST` (OpenSSL-Stil), `060102150405Z` (ASN.1 UTCTime) +sowie Unix-Zeitstempel als Zahl. + +Ein **nicht auswertbares oder fehlendes** Ablaufdatum führt *nicht* zum +Ausschluss des Zertifikats — die Revocation-Prüfung bleibt maßgeblich, und ein +unbekanntes Datum darf keine gültige Konfiguration blockieren. Der +Integrationstest `TestIntegrationAccountsExpiryIsParsed` schlägt fehl, wenn +*kein* Zertifikat ein auswertbares Datum liefert; das ist das Signal, die +Layout-Liste zu ergänzen. + +## Antwortformen des Downloads + +Zwei Varianten sind implementiert und werden am `Content-Type` unterschieden: + +1. **Rohdaten** (`application/octet-stream` o. ä.) — wird direkt + durchgestreamt, nichts wird gepuffert oder auf Platte geschrieben. + Der Dateiname kommt aus `Content-Disposition`. +2. **Base64 in JSON** (`application/json`) — Feld `content` oder `data`, + optional `filename`. Lässt sich der Inhalt nicht Base64-dekodieren, wird + er als Klartext behandelt. + +## Unterstützte Exportformate + +Das Portal bietet genau zwei Formate an: `ovpn` (inline `.ovpn`) und +`viscosity`. Weitere Formate sind bewusst nicht wählbar. + +## Mindestversion + +**Beim ersten erfolgreichen Integrationstestlauf hier eintragen:** + +- OPNsense: _(zu ermitteln)_ +- `os-openvpn-client-export`: _(zu ermitteln)_ + +## Integrationstests ausführen + +```bash +export OPNSENSE_URL="https://fw01.firma.local" +export OPNSENSE_KEY="..." +export OPNSENSE_SECRET="..." +export OPNSENSE_CA="/pfad/zur/firma-ca.pem" # optional +export OPNSENSE_REVOKED_CN="testuser-revoked" # optional, für den Revocation-Test +export OPNSENSE_VPNID="1" # optional, für den Export-Test +export OPNSENSE_CERT_REF="abc123" # optional, für den Export-Test + +go test -tags integration ./internal/opnsense/ -v +``` + +Ohne gesetzte Variablen überspringen sich die Tests selbst; sie laufen +deshalb nie versehentlich in der normalen Testsuite mit. diff --git a/internal/certmatch/match.go b/internal/certmatch/match.go new file mode 100644 index 0000000..63fd520 --- /dev/null +++ b/internal/certmatch/match.go @@ -0,0 +1,122 @@ +// 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 und nicht erst bei der ersten Anmeldung. + if _, err := regexp.Compile("(?i)" + 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 +} diff --git a/internal/certmatch/match_test.go b/internal/certmatch/match_test.go new file mode 100644 index 0000000..dfba065 --- /dev/null +++ b/internal/certmatch/match_test.go @@ -0,0 +1,141 @@ +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) + } +} diff --git a/internal/opnsense/client.go b/internal/opnsense/client.go new file mode 100644 index 0000000..250bef3 --- /dev/null +++ b/internal/opnsense/client.go @@ -0,0 +1,170 @@ +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 { + // Nur für Tests; serve gibt bei jedem Start eine Warnung aus. + 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 strings.HasPrefix(strings.TrimSpace(string(raw)), "<") { + 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 +} diff --git a/internal/opnsense/client_test.go b/internal/opnsense/client_test.go new file mode 100644 index 0000000..fd596cd --- /dev/null +++ b/internal/opnsense/client_test.go @@ -0,0 +1,234 @@ +package opnsense + +import ( + "context" + "encoding/base64" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func newTestClient(t *testing.T, h http.HandlerFunc) (*Client, *httptest.Server) { + t.Helper() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + c, err := New(Options{ + BaseURL: srv.URL, + APIKey: "KEY", + APISecret: "SECRET", + Timeout: 2 * time.Second, + HTTPClient: srv.Client(), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + return c, srv +} + +func TestProvidersSendsBasicAuthAndParsesMap(t *testing.T) { + var gotPath string + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + user, pass, ok := r.BasicAuth() + if !ok || user != "KEY" || pass != "SECRET" { + t.Errorf("BasicAuth = %q/%q ok=%v", user, pass, ok) + } + io.WriteString(w, `{ + "1": {"vpnid":"1","name":"VPN Homeoffice"}, + "2": {"vpnid":"2","name":"VPN Aussendienst"} + }`) + }) + + ps, err := c.Providers(context.Background()) + if err != nil { + t.Fatalf("Providers: %v", err) + } + if gotPath != "/api/openvpn/export/providers" { + t.Errorf("Pfad = %q", gotPath) + } + if len(ps) != 2 { + t.Fatalf("got %d Provider, want 2", len(ps)) + } + // Stabile Sortierung nach VPNID, damit die UI-Reihenfolge deterministisch ist. + if ps[0].VPNID != "1" || ps[0].Name != "VPN Homeoffice" { + t.Errorf("ps[0] = %+v", ps[0]) + } +} + +func TestProvidersFallsBackToMapKeyAsVPNID(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, `{"7": {"name":"VPN Sieben"}}`) + }) + ps, err := c.Providers(context.Background()) + if err != nil { + t.Fatalf("Providers: %v", err) + } + if len(ps) != 1 || ps[0].VPNID != "7" { + t.Fatalf("ps = %+v, VPNID muss aus dem Map-Schlüssel kommen", ps) + } +} + +func TestAccountsParsesRevokedAndExpiry(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/accounts/1") { + t.Errorf("Pfad = %q", r.URL.Path) + } + io.WriteString(w, `{ + "abc123": {"commonName":"mmueller","description":"Max","validTo":"2027-03-01","isRevoked":"0"}, + "def456": {"commonName":"jdoe","validTo":"2027-03-01","isRevoked":"1"} + }`) + }) + + accs, err := c.Accounts(context.Background(), "1") + if err != nil { + t.Fatalf("Accounts: %v", err) + } + if len(accs) != 2 { + t.Fatalf("got %d Accounts, want 2", len(accs)) + } + byCN := map[string]Account{} + for _, a := range accs { + byCN[a.CommonName] = a + } + if byCN["mmueller"].RefID != "abc123" || byCN["mmueller"].Revoked { + t.Errorf("mmueller = %+v", byCN["mmueller"]) + } + if !byCN["jdoe"].Revoked { + t.Error("jdoe muss als revoziert erkannt werden") + } + if byCN["mmueller"].ValidTo.Year() != 2027 { + t.Errorf("ValidTo = %v", byCN["mmueller"].ValidTo) + } +} + +func TestExportStreamsRawBody(t *testing.T) { + const cfg = "client\nremote fw01.firma.local 1194\n" + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", `attachment; filename="mmueller.ovpn"`) + io.WriteString(w, cfg) + }) + + res, err := c.Export(context.Background(), "1", "abc123", FormatOVPN) + if err != nil { + t.Fatalf("Export: %v", err) + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + if string(body) != cfg { + t.Errorf("Body = %q", body) + } + if res.Filename != "mmueller.ovpn" { + t.Errorf("Filename = %q", res.Filename) + } +} + +func TestExportDecodesBase64JSONBody(t *testing.T) { + const cfg = "client\nremote fw01 1194\n" + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"status":"ok","filename":"x.ovpn","content":"`+ + base64.StdEncoding.EncodeToString([]byte(cfg))+`"}`) + }) + + res, err := c.Export(context.Background(), "1", "abc123", FormatOVPN) + if err != nil { + t.Fatalf("Export: %v", err) + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + if string(body) != cfg { + t.Errorf("Body = %q, want dekodierte Konfiguration", body) + } + if res.Filename != "x.ovpn" { + t.Errorf("Filename = %q", res.Filename) + } +} + +func TestUnauthorizedAndForbiddenAreDistinct(t *testing.T) { + for status, want := range map[int]error{ + http.StatusUnauthorized: ErrUnauthorized, + http.StatusForbidden: ErrForbidden, + } { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + }) + _, err := c.Providers(context.Background()) + if !errors.Is(err, want) { + t.Errorf("Status %d: err = %v, want %v", status, err, want) + } + } +} + +func TestUnreachableIsWrapped(t *testing.T) { + c, srv := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {}) + srv.Close() // Server abschalten, um Verbindungsfehler zu erzwingen + _, err := c.Providers(context.Background()) + if !errors.Is(err, ErrUnreachable) { + t.Fatalf("err = %v, want ErrUnreachable", err) + } +} + +func TestHTMLLoginPageIsRejected(t *testing.T) { + // OPNsense liefert bei ungültigem Key manchmal 200 mit HTML-Loginseite. + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + io.WriteString(w, "Login") + }) + if _, err := c.Providers(context.Background()); err == nil { + t.Fatal("HTML-Antwort muss als Fehler erkannt werden") + } +} + +func TestMissingPluginIsNamedInError(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + _, err := c.Providers(context.Background()) + if err == nil || !strings.Contains(err.Error(), "os-openvpn-client-export") { + t.Fatalf("404 muss auf das fehlende Plugin hinweisen, got: %v", err) + } +} + +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 TestNewValidatesOptions(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") + } + if _, err := New(Options{BaseURL: "https://fw", APIKey: "", APISecret: "s"}); err == nil { + t.Fatal("fehlender API-Key muss abgelehnt werden") + } +} + +func TestExportRejectsEmptyIdentifiers(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("bei leeren Bezeichnern darf kein Request abgehen") + }) + if _, err := c.Export(context.Background(), "", "abc", FormatOVPN); err == nil { + t.Error("leere vpnid muss abgelehnt werden") + } + if _, err := c.Export(context.Background(), "1", "", FormatOVPN); err == nil { + t.Error("leere Zertifikatsreferenz muss abgelehnt werden") + } +} diff --git a/internal/opnsense/export.go b/internal/opnsense/export.go new file mode 100644 index 0000000..c43f107 --- /dev/null +++ b/internal/opnsense/export.go @@ -0,0 +1,144 @@ +package opnsense + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mime" + "net/url" + "sort" + "strings" +) + +// API-Pfade des Plugins os-openvpn-client-export. Alle drei sind GET und +// damit read-only — das Portal schreibt niemals auf die Firewall. +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 auf der +// Firewall muss ohne Verzögerung 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 auf der Firewall +// hinterlegten 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"] +} diff --git a/internal/opnsense/integration_test.go b/internal/opnsense/integration_test.go new file mode 100644 index 0000000..f28cd98 --- /dev/null +++ b/internal/opnsense/integration_test.go @@ -0,0 +1,133 @@ +//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" + "io" + "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) + } + } +} + +// TestIntegrationAccountsExpiryIsParsed bestätigt, dass das Ablaufdatum in +// einem der unterstützten Formate ankommt und nicht stillschweigend leer bleibt. +func TestIntegrationAccountsExpiryIsParsed(t *testing.T) { + c := integrationClient(t) + ps, err := c.Providers(context.Background()) + if err != nil { + t.Fatal(err) + } + var seen, withDate int + 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 { + seen++ + if a.CommonName == "" { + t.Errorf("Zertifikat %s ohne CommonName — Feldname prüfen: %+v", a.RefID, a) + } + if !a.ValidTo.IsZero() { + withDate++ + } + } + } + if seen == 0 { + t.Skip("keine Zertifikate auf der Testinstanz") + } + if withDate == 0 { + t.Fatalf("kein einziges der %d Zertifikate hat ein auswertbares Ablaufdatum — "+ + "flexTimeLayouts bzw. die Feldnamen in rawAccount müssen ergänzt werden", seen) + } +} + +// 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) + } + } +} diff --git a/internal/opnsense/types.go b/internal/opnsense/types.go new file mode 100644 index 0000000..4004e96 --- /dev/null +++ b/internal/opnsense/types.go @@ -0,0 +1,161 @@ +// 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, + // Meldet irgendeines der bekannten Felder eine Revozierung, gilt das + // Zertifikat als revoziert — im Zweifel restriktiv. + Revoked: bool(r.Revoked) || bool(r.Revoked2) || bool(r.Revoked3), + } +} diff --git a/internal/opnsense/types_test.go b/internal/opnsense/types_test.go new file mode 100644 index 0000000..824ab3c --- /dev/null +++ b/internal/opnsense/types_test.go @@ -0,0 +1,73 @@ +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") + } +} From bb2bc223b93b5243b20578da95e8666a97ca87de Mon Sep 17 00:00:00 2001 From: Carsten Abele Date: Fri, 14 Aug 2026 09:20:38 +0200 Subject: [PATCH 06/10] feat(web): Sessions, Rate-Limiting, Security-Header, Templates und eingebettete Assets Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68 --- internal/web/assets/app.js | 14 ++ internal/web/assets/style.css | 242 +++++++++++++++++++++++++++ internal/web/ratelimit.go | 139 +++++++++++++++ internal/web/ratelimit_test.go | 139 +++++++++++++++ internal/web/render.go | 92 ++++++++++ internal/web/render_test.go | 89 ++++++++++ internal/web/security.go | 46 +++++ internal/web/security_test.go | 90 ++++++++++ internal/web/session.go | 135 +++++++++++++++ internal/web/session_test.go | 130 ++++++++++++++ internal/web/strings.go | 65 +++++++ internal/web/templates/error.html | 8 + internal/web/templates/guides.html | 12 ++ internal/web/templates/layout.html | 37 ++++ internal/web/templates/login.html | 20 +++ internal/web/templates/overview.html | 34 ++++ 16 files changed, 1292 insertions(+) create mode 100644 internal/web/assets/app.js create mode 100644 internal/web/assets/style.css create mode 100644 internal/web/ratelimit.go create mode 100644 internal/web/ratelimit_test.go create mode 100644 internal/web/render.go create mode 100644 internal/web/render_test.go create mode 100644 internal/web/security.go create mode 100644 internal/web/security_test.go create mode 100644 internal/web/session.go create mode 100644 internal/web/session_test.go create mode 100644 internal/web/strings.go create mode 100644 internal/web/templates/error.html create mode 100644 internal/web/templates/guides.html create mode 100644 internal/web/templates/layout.html create mode 100644 internal/web/templates/login.html create mode 100644 internal/web/templates/overview.html diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js new file mode 100644 index 0000000..b1dda4e --- /dev/null +++ b/internal/web/assets/app.js @@ -0,0 +1,14 @@ +// 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"; + }); +})(); diff --git a/internal/web/assets/style.css b/internal/web/assets/style.css new file mode 100644 index 0000000..39adbf9 --- /dev/null +++ b/internal/web/assets/style.css @@ -0,0 +1,242 @@ +/* VPN-Portal — schlichtes, neutrales Layout. + Bewusst zurückhaltend: das Portal wird pro Kunde mit eigenem Titel und Logo + ausgeliefert und soll sich nicht gegen dessen Erscheinungsbild stellen. + Keine externen Ressourcen (Schriften, Bilder) — die CSP verbietet sie. */ + +:root { + --bg: #f4f5f7; + --surface: #ffffff; + --text: #1c1f23; + --text-muted: #5a6472; + --border: #d3d8de; + --accent: #1f5f9e; + --accent-text: #ffffff; + --accent-hover: #17497a; + --error-bg: #fdeaea; + --error-border: #d34d4d; + --error-text: #8a1f1f; + --info-bg: #e8f1fa; + --info-border: #4a86c4; + --info-text: #14456f; + --warn-bg: #fdf3e2; + --warn-border: #d08b1e; + --warn-text: #7a4d05; + --radius: 6px; + --focus: #0b6bcb; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #16191d; + --surface: #1f242a; + --text: #e8ebef; + --text-muted: #9aa5b1; + --border: #39424c; + --accent: #4a90d9; + --accent-text: #0d1013; + --accent-hover: #6aa8e6; + --error-bg: #3a1f1f; + --error-border: #c95a5a; + --error-text: #f3c3c3; + --info-bg: #1c2d3d; + --info-border: #4a86c4; + --info-text: #bcd8f2; + --warn-bg: #3a2f18; + --warn-border: #d08b1e; + --warn-text: #f0d9a8; + --focus: #7db6f0; + } +} + +*, *::before, *::after { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 1rem; + line-height: 1.5; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* ---------- Kopfzeile ---------- */ + +.topbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem 1rem; + padding: 0.75rem 1.25rem; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.brand { display: flex; align-items: center; gap: 0.75rem; min-width: 0; } +.brand-title { font-weight: 600; font-size: 1.05rem; } +.logo { max-height: 2.25rem; max-width: 12rem; width: auto; height: auto; } + +.topnav { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem 1rem; +} +.topnav .user { color: var(--text-muted); font-size: 0.9rem; } + +/* ---------- Inhalt ---------- */ + +main { + flex: 1; + width: 100%; + max-width: 46rem; + margin: 0 auto; + padding: 1.5rem 1.25rem 3rem; +} + +h1 { font-size: 1.5rem; margin: 0 0 1rem; } +h2 { font-size: 1.15rem; margin: 0 0 0.5rem; } +p { margin: 0 0 1rem; } +p:last-child { margin-bottom: 0; } + +a { color: var(--accent); } +a:hover { color: var(--accent-hover); } + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + margin-bottom: 1rem; +} + +.cards { list-style: none; margin: 0; padding: 0; } + +.card dl { + display: grid; + grid-template-columns: minmax(6rem, auto) 1fr; + gap: 0.25rem 1rem; + margin: 0 0 1rem; +} +.card dt { color: var(--text-muted); font-size: 0.9rem; } +.card dd { margin: 0; overflow-wrap: anywhere; } + +.card .client { color: var(--text-muted); margin-bottom: 0.75rem; } +.card ol { margin: 0; padding-left: 1.25rem; } +.card ol li { margin-bottom: 0.4rem; } + +.suggested { border-color: var(--info-border); } +.badge { + display: inline-block; + font-size: 0.75rem; + font-weight: 400; + vertical-align: middle; + padding: 0.15rem 0.5rem; + border-radius: 999px; + background: var(--info-bg); + color: var(--info-text); + border: 1px solid var(--info-border); +} + +/* ---------- Meldungen ---------- */ + +.flash { + padding: 0.85rem 1rem; + border-radius: var(--radius); + border: 1px solid; + margin-bottom: 1.25rem; +} +.flash-error { background: var(--error-bg); border-color: var(--error-border); color: var(--error-text); } +.flash-info { background: var(--info-bg); border-color: var(--info-border); color: var(--info-text); } + +.warn { + background: var(--warn-bg); + border-left: 3px solid var(--warn-border); + color: var(--warn-text); + padding: 0.6rem 0.85rem; + border-radius: 0 var(--radius) var(--radius) 0; +} + +.logout-hint { color: var(--text-muted); font-size: 0.9rem; } + +/* ---------- Formulare ---------- */ + +.form { display: flex; flex-direction: column; } +.form label { font-weight: 500; margin-bottom: 0.3rem; } +.form small { color: var(--text-muted); margin: 0.3rem 0 1rem; } + +input[type="text"], input[type="password"] { + font: inherit; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.6rem 0.7rem; + width: 100%; + min-height: 2.75rem; +} + +.toggle-wrap { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 0 1.25rem; +} +.toggle-wrap label { font-weight: 400; margin: 0; } +.toggle-wrap input { min-height: auto; width: auto; } + +button { + font: inherit; + font-weight: 500; + min-height: 2.75rem; + padding: 0.55rem 1.1rem; + border: 1px solid var(--accent); + border-radius: var(--radius); + background: var(--accent); + color: var(--accent-text); + cursor: pointer; +} +button:hover { background: var(--accent-hover); border-color: var(--accent-hover); } + +button.secondary { + background: transparent; + color: var(--accent); + border-color: var(--border); + min-height: 2.25rem; + padding: 0.35rem 0.8rem; +} +button.secondary:hover { background: var(--info-bg); border-color: var(--accent); } + +.inline { display: inline; margin: 0; } + +.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; } +.actions .inline { display: block; } + +:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 2px; +} + +/* ---------- Fußzeile ---------- */ + +.footer { + padding: 1rem 1.25rem; + border-top: 1px solid var(--border); + color: var(--text-muted); + font-size: 0.85rem; + text-align: center; +} + +/* ---------- Schmale Bildschirme ---------- */ + +@media (max-width: 40rem) { + .topbar { flex-direction: column; align-items: flex-start; } + .topnav { width: 100%; } + .actions { flex-direction: column; align-items: stretch; } + .actions button { width: 100%; } + .card dl { grid-template-columns: 1fr; gap: 0; } + .card dt { margin-top: 0.5rem; } +} diff --git a/internal/web/ratelimit.go b/internal/web/ratelimit.go new file mode 100644 index 0000000..7799c00 --- /dev/null +++ b/internal/web/ratelimit.go @@ -0,0 +1,139 @@ +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 und den NAT-Fall. +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. +// Die ersten freeAttempts Versuche sind frei: bei freeAttempts=3 sind die +// Versuche 1 bis 3 ungebremst, ab dem vierten wird gewartet. +func backoff(failures int) time.Duration { + if failures < freeAttempts { + return 0 + } + exp := failures - freeAttempts + 1 + 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) +} diff --git a/internal/web/ratelimit_test.go b/internal/web/ratelimit_test.go new file mode 100644 index 0000000..6104109 --- /dev/null +++ b/internal/web/ratelimit_test.go @@ -0,0 +1,139 @@ +package web + +import ( + "fmt" + "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(fmt.Sprintf("user%d", i), "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", fmt.Sprintf("10.0.1.%d", i)) + } + if _, ok := l.Allow("mmueller", "192.168.99.99"); ok { + t.Fatal("der Benutzer muss auch von einer neuen IP gebremst werden") + } +} + +func TestUsernameIsCaseInsensitive(t *testing.T) { + l := NewLimiter(nil) + for i := 0; i < 6; i++ { + l.RecordFailure("MMueller", "10.0.0.1") + } + // Andere Schreibweise darf den Zähler nicht umgehen. + if _, ok := l.Allow("mmueller", "192.168.5.5"); ok { + t.Fatal("Schreibweise des Benutzernamens darf das Limit nicht umgehen") + } +} + +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) + } +} diff --git a/internal/web/render.go b/internal/web/render.go new file mode 100644 index 0000000..52399fe --- /dev/null +++ b/internal/web/render.go @@ -0,0 +1,92 @@ +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 + ShowOverviewLink bool +} + +// Renderer hält die beim Start 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, +// damit ein Tippfehler im Template nicht erst im Betrieb auffällt. +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_internal"), 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_internal"), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + w.Write(buf.Bytes()) +} diff --git a/internal/web/render_test.go b/internal/web/render_test.go new file mode 100644 index 0000000..f7011ea --- /dev/null +++ b/internal/web/render_test.go @@ -0,0 +1,89 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func testRenderer(t *testing.T) *Renderer { + t.Helper() + rn, err := NewRenderer(Brand{Title: "VPN-Portal Firma GmbH", Version: "1.2.3", + SupportContact: "it@firma.de"}) + if err != nil { + t.Fatalf("NewRenderer: %v", err) + } + return rn +} + +func TestRenderLoginPage(t *testing.T) { + rn := testRenderer(t) + rec := httptest.NewRecorder() + rn.Render(rec, httptest.NewRequest(http.MethodGet, "/login", nil), http.StatusOK, + "login", PageData{Brand: rn.brand}) + + if rec.Code != http.StatusOK { + t.Fatalf("Code = %d", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{ + "", `lang="de"`, "VPN-Portal Firma GmbH", + S["login_submit"], S["login_username_hint"], "1.2.3", + `name="password"`, `type="password"`, + } { + if !strings.Contains(body, want) { + t.Errorf("Login-Seite enthält %q nicht", want) + } + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("Content-Type = %q", ct) + } +} + +func TestRenderEscapesUserContent(t *testing.T) { + rn := testRenderer(t) + rec := httptest.NewRecorder() + rn.Render(rec, httptest.NewRequest(http.MethodGet, "/", nil), http.StatusOK, + "error", PageData{ + Brand: rn.brand, + Flash: ``, + Data: errorPageData{Title: "T", Message: `">`}, + }) + body := rec.Body.String() + if strings.Contains(body, "") { + t.Error("Flash-Text wurde nicht escapt") + } + if strings.Contains(body, "onerror=alert(1)>") { + t.Error("Fehlermeldung wurde nicht escapt") + } +} + +func TestRenderSetsStatusCode(t *testing.T) { + rn := testRenderer(t) + rec := httptest.NewRecorder() + rn.Render(rec, httptest.NewRequest(http.MethodGet, "/", nil), http.StatusServiceUnavailable, + "error", PageData{Brand: rn.brand, Data: errorPageData{Title: "x", Message: "y"}}) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("Code = %d, want 503", rec.Code) + } +} + +func TestAllTemplatesParse(t *testing.T) { + rn := testRenderer(t) + for _, page := range pageNames { + if rn.pages[page] == nil { + t.Errorf("Template %q wurde nicht geladen", page) + } + } +} + +func TestRenderUnknownPageDoesNotPanic(t *testing.T) { + rn := testRenderer(t) + rec := httptest.NewRecorder() + rn.Render(rec, httptest.NewRequest(http.MethodGet, "/", nil), http.StatusOK, + "gibtsnicht", PageData{Brand: rn.brand}) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("Code = %d, want 500", rec.Code) + } +} diff --git a/internal/web/security.go b/internal/web/security.go new file mode 100644 index 0000000..c88c04c --- /dev/null +++ b/internal/web/security.go @@ -0,0 +1,46 @@ +package web + +import ( + "net" + "net/http" + "strings" +) + +// contentSecurityPolicy ist so restriktiv möglich, weil sämtliche Assets +// eingebettet und gleich-origin ausgeliefert werden. Es gibt keine externen +// Ressourcen und kein Inline-Script. +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 +} diff --git a/internal/web/security_test.go b/internal/web/security_test.go new file mode 100644 index 0000000..5a9871e --- /dev/null +++ b/internal/web/security_test.go @@ -0,0 +1,90 @@ +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") + } +} diff --git a/internal/web/session.go b/internal/web/session.go new file mode 100644 index 0000000..80e1de0 --- /dev/null +++ b/internal/web/session.go @@ -0,0 +1,135 @@ +// 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 — das ist beabsichtigt. +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 durch Aktivität 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) + } + } +} diff --git a/internal/web/session_test.go b/internal/web/session_test.go new file mode 100644 index 0000000..179d5d2 --- /dev/null +++ b/internal/web/session_test.go @@ -0,0 +1,130 @@ +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 ausreichende 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) + st := NewSessionStore(10*time.Minute, func() time.Time { return now }) + + 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 TestTTLIsAbsoluteNotSliding(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 }) + sess, _ := st.Create(testIdentity()) + + // Regelmäßige Zugriffe dürfen die Sitzung nicht verlängern. + for i := 0; i < 5; i++ { + now = now.Add(2 * time.Minute) + st.Get(sess.Token) + } + if _, ok := st.Get(sess.Token); ok { + t.Fatal("die TTL ist absolut; Aktivität darf sie nicht verlängern") + } +} + +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") + } +} diff --git a/internal/web/strings.go b/internal/web/strings.go new file mode 100644 index 0000000..d58b234 --- /dev/null +++ b/internal/web/strings.go @@ -0,0 +1,65 @@ +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. +// Kein deutschsprachiger Text gehört direkt in einen Handler. +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_not_found_body": "Die aufgerufene Adresse gibt es nicht.", + "error_csrf": "Die Anfrage ist abgelaufen oder ungültig. Bitte versuchen Sie es erneut.", + "error_internal": "Unerwarteter Fehler", + "error_internal_body": "Es ist ein interner Fehler aufgetreten. 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", + "guides_suggested": "Passt vermutlich zu Ihrem Gerät", + + "back_to_overview": "Zurück zur Übersicht", + "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 +} diff --git a/internal/web/templates/error.html b/internal/web/templates/error.html new file mode 100644 index 0000000..6a0f722 --- /dev/null +++ b/internal/web/templates/error.html @@ -0,0 +1,8 @@ +{{define "content"}} +

{{.Data.Title}}

+
+

{{.Data.Message}}

+ {{if .Data.ShowLoginLink}}

{{t "login_submit"}}

{{end}} + {{if .Data.ShowOverviewLink}}

{{t "back_to_overview"}}

{{end}} +
+{{end}} diff --git a/internal/web/templates/guides.html b/internal/web/templates/guides.html new file mode 100644 index 0000000..ee27955 --- /dev/null +++ b/internal/web/templates/guides.html @@ -0,0 +1,12 @@ +{{define "content"}} +

{{t "guides_title"}}

+

{{t "guides_intro"}}

+{{range .Data.Guides}} +
+

{{.Platform}}{{if .Suggested}} {{t "guides_suggested"}}{{end}}

+

{{.Client}}

+
    {{range .Steps}}
  1. {{.}}
  2. {{end}}
+
+{{end}} +

{{t "back_to_overview"}}

+{{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html new file mode 100644 index 0000000..0e6ee4e --- /dev/null +++ b/internal/web/templates/layout.html @@ -0,0 +1,37 @@ +{{define "layout"}} + + + + + +{{.Brand.Title}} + + + +
+
+ {{if .Brand.HasLogo}}{{end}} + {{.Brand.Title}} +
+ {{if .User}} + + {{end}} +
+ +
+ {{if .Flash}}
{{.Flash}}
{{end}} + {{block "content" .}}{{end}} +
+ +
+ {{t "footer_version"}} {{.Brand.Version}} +
+ +{{end}} diff --git a/internal/web/templates/login.html b/internal/web/templates/login.html new file mode 100644 index 0000000..2b6e875 --- /dev/null +++ b/internal/web/templates/login.html @@ -0,0 +1,20 @@ +{{define "content"}} +

{{t "login_title"}}

+
+ + + {{t "login_username_hint"}} + + + + + + +
+ +{{end}} diff --git a/internal/web/templates/overview.html b/internal/web/templates/overview.html new file mode 100644 index 0000000..a670a20 --- /dev/null +++ b/internal/web/templates/overview.html @@ -0,0 +1,34 @@ +{{define "content"}} +

{{t "overview_title"}}

+{{if .Data.Entries}} +

{{t "overview_intro"}}

+
    + {{range .Data.Entries}} +
  • +

    {{.InstanceName}}

    +
    +
    {{t "common_name"}}
    {{.CommonName}}
    + {{if .ValidUntil}}
    {{t "valid_until"}}
    {{.ValidUntil}}
    {{end}} +
    + {{if .ExpiryWarning}}

    {{.ExpiryWarning}}

    {{end}} +
    + {{range .Formats}} +
    + + + + +
    + {{end}} +
    +
  • + {{end}} +
+

{{t "logout_reminder"}}

+{{else}} +
+

{{t "overview_no_certs"}}

+ {{if .Data.SupportHint}}

{{.Data.SupportHint}}

{{end}} +
+{{end}} +{{end}} From eb89d78ce2eaa58b04468528eb1ecc838d805282 Mon Sep 17 00:00:00 2001 From: Carsten Abele Date: Fri, 14 Aug 2026 09:25:52 +0200 Subject: [PATCH 07/10] =?UTF-8?q?feat(web):=20Router,=20Login/Logout,=20?= =?UTF-8?q?=C3=9Cbersicht,=20CSRF-gesch=C3=BCtzter=20Download,=20Anleitung?= =?UTF-8?q?en=20und=20/healthz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68 --- internal/web/guides.go | 97 +++++++ internal/web/handlers_certs.go | 208 +++++++++++++++ internal/web/handlers_certs_test.go | 305 ++++++++++++++++++++++ internal/web/handlers_login.go | 183 ++++++++++++++ internal/web/handlers_login_test.go | 363 +++++++++++++++++++++++++++ internal/web/handlers_static.go | 35 +++ internal/web/handlers_static_test.go | 193 ++++++++++++++ internal/web/server.go | 234 +++++++++++++++++ 8 files changed, 1618 insertions(+) create mode 100644 internal/web/guides.go create mode 100644 internal/web/handlers_certs.go create mode 100644 internal/web/handlers_certs_test.go create mode 100644 internal/web/handlers_login.go create mode 100644 internal/web/handlers_login_test.go create mode 100644 internal/web/handlers_static.go create mode 100644 internal/web/handlers_static_test.go create mode 100644 internal/web/server.go diff --git a/internal/web/guides.go b/internal/web/guides.go new file mode 100644 index 0000000..50a5fa6 --- /dev/null +++ b/internal/web/guides.go @@ -0,0 +1,97 @@ +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“ und dann „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“, dann „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. +// iOS wird vor macOS geprüft, weil iPad-User-Agents „Mac OS X“ enthalten können. +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 — die +// User-Agent-Erkennung ist zu unzuverlässig, um Inhalte zu verstecken. +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 +} diff --git a/internal/web/handlers_certs.go b/internal/web/handlers_certs.go new file mode 100644 index 0000000..7969a78 --- /dev/null +++ b/internal/web/handlers_certs.go @@ -0,0 +1,208 @@ +package web + +import ( + "errors" + "fmt" + "io" + "math" + "net/http" + "strings" + + "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. +// Alle weiteren Exporteinstellungen kommen von der Firewall. +var downloadFormats = []struct { + Format string + LabelKey string +}{ + {"ovpn", "download_ovpn"}, + {"viscosity", "download_viscosity"}, +} + +// overviewData füllt overview.html. +type overviewData struct { + Entries []overviewEntry + 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), + }) + } + // Bewusst eigene Seite: "Firewall weg" ist etwas anderes als + // "kein Zertifikat vorhanden". + 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), + }) + } + + var data overviewData + 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") + // Aufrunden: ein Zertifikat mit 11 Tagen und 23 Stunden Restlaufzeit + // als "11 Tage" anzuzeigen wäre für den Benutzer irreführend. + if days := int(math.Ceil(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.WriteHeader(http.StatusOK) + + // Durchstreamen — nichts auf Platte, nichts vollständig im Speicher. + if _, err := io.Copy(w, stream.Body); err != nil { + // Der Header ist bereits 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 +} diff --git a/internal/web/handlers_certs_test.go b/internal/web/handlers_certs_test.go new file mode 100644 index 0000000..0fadc4b --- /dev/null +++ b/internal/web/handlers_certs_test.go @@ -0,0 +1,305 @@ +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 ist eine CertSource mit funktionierendem Export. +type certsWithExport struct { + entries []CertEntry + listErr error + exportErr error + exportCall int + lastArgs [4]string +} + +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++ + c.lastArgs = [4]string{username, vpnID, refID, format} + 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 +} + +// Pattern macht certsWithExport zur Quelle des Audit-Feldes pattern. +func (c *certsWithExport) Pattern(username string) string { return username } + +// loggedInServer liefert Server, Audit-Mitschrift, 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) + if cookie == nil { + t.Fatal("Anmeldung lieferte kein Session-Cookie") + } + 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" + if c != nil { + r.AddCookie(c) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, r) + return rec +} + +func TestOverviewListsCertificates(t *testing.T) { + certs := &certsWithExport{entries: []CertEntry{ + {Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller", + ValidTo: time.Now().AddDate(1, 0, 0)}, + {Token: "2:b1", InstanceName: "VPN Aussendienst", CommonName: "mmueller", + ValidTo: time.Now().AddDate(1, 0, 0)}, + }} + srv, _, cookie, _ := loggedInServer(t, certs) + rec := getWithCookie(srv.Handler(), "/", cookie) + + if rec.Code != http.StatusOK { + t.Fatalf("Code = %d", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{"VPN Homeoffice", "VPN Aussendienst", + S["download_ovpn"], S["download_viscosity"], `name="csrf_token"`} { + if !strings.Contains(body, want) { + t.Errorf("Übersicht enthält %q nicht", want) + } + } + if strings.Contains(body, ` %q, want %q zuerst", ua, g[0].Platform, want) + } + } + // Unbekannter User-Agent: Reihenfolge stabil, nichts als Vorschlag markiert. + g := Guides("irgendwas") + if g[0].Suggested { + t.Error("bei unbekanntem User-Agent darf nichts vorgeschlagen werden") + } +} + +func TestGuidesNeverLosesEntries(t *testing.T) { + for _, ua := range []string{"", "Windows", "iPhone", "Android", "Macintosh", "unbekannt"} { + if got, want := len(Guides(ua)), len(baseGuides); got != want { + t.Errorf("UA %q liefert %d Anleitungen, want %d — es darf nie gefiltert werden", ua, got, want) + } + } +} + +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 TestHealthzNeedsNoAuthAndLeaksNoDetails(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("/healthz muss ohne Anmeldung antworten, Code = %d", rec.Code) + } + body := strings.ToLower(rec.Body.String()) + for _, forbidden := range []string{"firma.local", "dc01", "https://", "password", "secret"} { + if strings.Contains(body, forbidden) { + t.Errorf("/healthz verrät %q: %s", forbidden, rec.Body.String()) + } + } +} + +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) + } + } +} + +func TestAssetPathTraversalIsRejected(t *testing.T) { + srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}}) + for _, p := range []string{"/assets/../server.go", "/assets/%2e%2e%2fserver.go", "/assets/"} { + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, p, nil)) + if rec.Code == http.StatusOK && strings.Contains(rec.Body.String(), "package web") { + t.Errorf("%s hat Quelltext ausgeliefert", p) + } + } +} + +func TestLogoIsNotServedWhenUnconfigured(t *testing.T) { + srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}}) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/assets/logo", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("ohne logo_file muss /assets/logo 404 liefern, Code = %d", rec.Code) + } +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..6080006 --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,234 @@ +package web + +import ( + "context" + "errors" + "io" + "io/fs" + "net/http" + "path" + "strings" + "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. +type CertSource interface { + EntriesFor(ctx context.Context, username string) ([]CertEntry, error) + // Export prüft die Zuordnung serverseitig erneut und liefert sonst + // ErrCertNotOwned. Die Auswahl in der Oberfläche ist keine Autorisierung. + 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 + // MinLoginDuration gleicht die Antwortzeit der Anmeldung an. + // 0 = Vorgabewert; Tests setzen sie klein. + MinLoginDuration time.Duration +} + +// Server bündelt Routing und Handler. +type Server struct { + d Deps +} + +// defaultMinLoginDuration verhindert, dass die Laufzeit verrät, ob ein Konto +// existiert. +const defaultMinLoginDuration = 400 * time.Millisecond + +// NewServer prüft die Abhängigkeiten. +func NewServer(d Deps) (*Server, error) { + switch { + case d.Auth == nil: + return nil, errors.New("web: Authenticator fehlt") + case d.Certs == nil: + return nil, errors.New("web: CertSource fehlt") + case d.Audit == nil: + return nil, errors.New("web: Audit-Senke fehlt") + case d.Sessions == nil: + return nil, errors.New("web: SessionStore fehlt") + case d.Limiter == nil: + return nil, errors.New("web: Limiter fehlt") + case 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 + } + if d.MinLoginDuration <= 0 { + d.MinLoginDuration = defaultMinLoginDuration + } + 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), + ShowOverviewLink: sess != nil, + }, + }) +} + +// 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") + // Verteidigung in der Tiefe: embed.FS erlaubt ohnehin kein Traversal, + // aber ein Pfadtrenner im Namen hat hier nichts zu suchen. + if name == "" || name != path.Base(name) || strings.HasPrefix(name, ".") { + http.NotFound(w, r) + return + } + 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 path.Ext(name) { + case ".css": + w.Header().Set("Content-Type", "text/css; charset=utf-8") + case ".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_body") +} From 7e23df9fec06bd5d0dda935e71e24765f890696a Mon Sep 17 00:00:00 2001 From: Carsten Abele Date: Fri, 14 Aug 2026 09:27:31 +0200 Subject: [PATCH 08/10] feat(portal): Zertifikatssicht mit serverseitig autorisiertem Streaming-Export Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68 --- internal/portal/certsource.go | 190 ++++++++++++++++++ internal/portal/certsource_test.go | 296 +++++++++++++++++++++++++++++ 2 files changed, 486 insertions(+) create mode 100644 internal/portal/certsource.go create mode 100644 internal/portal/certsource_test.go diff --git a/internal/portal/certsource.go b/internal/portal/certsource.go new file mode 100644 index 0000000..6884e19 --- /dev/null +++ b/internal/portal/certsource.go @@ -0,0 +1,190 @@ +// Package portal verbindet Firewall-Client und Zuordnungsregeln zu der +// Sicht, die das Webportal auf die Zertifikate eines Benutzers hat. +package portal + +import ( + "context" + "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 ohne + // Verzögerung 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: zwischen +// Anzeige und Download kann ein Zertifikat revoziert worden sein. +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{ + // Bewusst der selbst gebaute Name: der Dateiname der Firewall ist + // nicht kontrolliert und könnte den Content-Disposition-Header + // aufbrechen. + 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) diff --git a/internal/portal/certsource_test.go b/internal/portal/certsource_test.go new file mode 100644 index 0000000..bacabef --- /dev/null +++ b/internal/portal/certsource_test.go @@ -0,0 +1,296 @@ +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 baseNow = time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + +// newSourceAt baut eine Source mit einer verstellbaren Uhr. +func newSourceAt(t *testing.T, fw *fakeFW, now *time.Time) *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 *now }) +} + +func newSource(t *testing.T, fw *fakeFW) *Source { + t.Helper() + now := baseNow + return newSourceAt(t, fw, &now) +} + +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: baseNow.AddDate(1, 0, 0)}, + {RefID: "a2", CommonName: "jdoe", ValidTo: baseNow.AddDate(1, 0, 0)}, + }, + "2": { + {RefID: "b1", CommonName: "mmueller", ValidTo: baseNow.AddDate(0, 0, 10)}, + {RefID: "b2", CommonName: "mmueller", ValidTo: baseNow.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() + now := baseNow + src := newSourceAt(t, fw, &now) + + src.EntriesFor(context.Background(), "mmueller") + src.EntriesFor(context.Background(), "mmueller") + if fw.providerCalls != 1 { + t.Fatalf("providerCalls = %d, want 1 (TTL-Cache)", fw.providerCalls) + } + now = now.Add(3 * time.Minute) + 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) + } + if fw.exportCalls != 0 { + t.Error("revoziertes Zertifikat darf keinen Export auslösen") + } +} + +func TestExportRejectsRevocationBetweenListingAndDownload(t *testing.T) { + fw := standardFW() + src := newSource(t, fw) + + // Erst auflisten: a1 ist dabei. + entries, err := src.EntriesFor(context.Background(), "mmueller") + if err != nil { + t.Fatal(err) + } + var token string + for _, e := range entries { + if strings.HasSuffix(e.Token, ":a1") { + token = e.Token + } + } + if token == "" { + t.Fatal("a1 muss zunächst gelistet sein") + } + + // Jetzt wird a1 auf der Firewall revoziert — der Download muss scheitern, + // obwohl der Benutzer den Eintrag noch auf der Seite sieht. + fw.accounts["1"][0].Revoked = true + + _, err = src.Export(context.Background(), "mmueller", "1", "a1", opnsense.FormatOVPN) + if !errors.Is(err, web.ErrCertNotOwned) { + t.Fatalf("err = %v, want ErrCertNotOwned — Revocation muss sofort greifen", err) + } + if fw.exportCalls != 0 { + t.Error("nach Revocation darf kein Export mehr ausgelöst werden") + } +} + +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 TestExportUsesOwnFilenameNotFirewalls(t *testing.T) { + // Der Dateiname der Firewall wird bewusst nicht übernommen: er ist + // nicht kontrolliert und könnte den Content-Disposition-Header aufbrechen. + src := newSource(t, standardFW()) + st, err := src.Export(context.Background(), "mmueller", "1", "a1", opnsense.FormatOVPN) + if err != nil { + t.Fatal(err) + } + defer st.Body.Close() + if st.Filename == "von-der-firewall.ovpn" { + t.Error("der von der Firewall gelieferte Dateiname darf nicht übernommen werden") + } + if st.Filename != "vpn-vpn-homeoffice-mmueller.ovpn" { + t.Errorf("Filename = %q", st.Filename) + } +} + +func TestExportRejectsUnknownFormat(t *testing.T) { + fw := standardFW() + src := newSource(t, fw) + if _, err := src.Export(context.Background(), "mmueller", "1", "a1", "beliebig"); err == nil { + t.Fatal("unbekanntes Format muss abgelehnt werden") + } + if fw.exportCalls != 0 { + t.Error("unbekanntes Format darf keinen Export auslösen") + } +} + +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 TestPatternIsExposedForAudit(t *testing.T) { + src := newSource(t, standardFW()) + if got := src.Pattern("mmueller"); got != "mmueller" { + t.Errorf("Pattern = %q", got) + } +} + +func TestSafeFilename(t *testing.T) { + cases := map[string]string{ + "VPN Homeoffice": "vpn-vpn-homeoffice-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) + } + } + if got := SafeFilename("VPN A", "u", opnsense.FormatViscosity); !strings.HasSuffix(got, ".visc.zip") { + t.Errorf("Viscosity-Endung fehlt: %q", got) + } + // Kein Pfadtrenner, keine Anführungszeichen, kein CR/LF im Ergebnis — + // sonst ließe sich der Content-Disposition-Header aufbrechen. + 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) + } + } + if got := SafeFilename("", "", opnsense.FormatOVPN); got == "" || strings.Contains(got, "--") { + t.Errorf("leere Eingaben ergeben %q", got) + } +} From 2d223c58232e3ffdc122860be7162a3889ab54fc Mon Sep 17 00:00:00 2001 From: Carsten Abele Date: Fri, 14 Aug 2026 09:34:22 +0200 Subject: [PATCH 09/10] feat(check,serve): Startvalidierung, HTTPS-Server, Signal-Handling und Versionscheck Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68 --- cmd/vpnportal/check.go | 71 +++++++- cmd/vpnportal/health.go | 62 +++++++ cmd/vpnportal/serve.go | 217 ++++++++++++++++++++++++- cmd/vpnportal/serve_test.go | 82 ++++++++++ cmd/vpnportal/uidemo_test.go | 94 +++++++++++ cmd/vpnportal/updatecheck.go | 61 +++++++ cmd/vpnportal/updatecheck_test.go | 78 +++++++++ internal/check/check.go | 239 +++++++++++++++++++++++++++ internal/check/check_test.go | 260 ++++++++++++++++++++++++++++++ 9 files changed, 1158 insertions(+), 6 deletions(-) create mode 100644 cmd/vpnportal/health.go create mode 100644 cmd/vpnportal/serve_test.go create mode 100644 cmd/vpnportal/uidemo_test.go create mode 100644 cmd/vpnportal/updatecheck.go create mode 100644 cmd/vpnportal/updatecheck_test.go create mode 100644 internal/check/check.go create mode 100644 internal/check/check_test.go diff --git a/cmd/vpnportal/check.go b/cmd/vpnportal/check.go index ec6b1a1..a662224 100644 --- a/cmd/vpnportal/check.go +++ b/cmd/vpnportal/check.go @@ -1,17 +1,80 @@ 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 Benutzer durchspielen") + 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 } - _ = testAuth - fmt.Fprintf(stderr, "Fehler: config %q kann noch nicht geladen werden (nicht implementiert)\n", *path) - return 1 + + cfg, err := config.Load(*path, nil) + if err != nil { + // Ohne lesbare Konfiguration lässt sich nichts weiter prüfen. + fmt.Fprintf(stdout, "✗ Konfiguration\n %v\n", err) + return 1 + } + + fw, ad, err := buildBackends(cfg) + if err != nil { + fmt.Fprintf(stdout, "✗ Initialisierung\n %v\n", err) + 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 } diff --git a/cmd/vpnportal/health.go b/cmd/vpnportal/health.go new file mode 100644 index 0000000..6ad4361 --- /dev/null +++ b/cmd/vpnportal/health.go @@ -0,0 +1,62 @@ +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() + + var 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) diff --git a/cmd/vpnportal/serve.go b/cmd/vpnportal/serve.go index bcc9983..318c5ac 100644 --- a/cmd/vpnportal/serve.go +++ b/cmd/vpnportal/serve.go @@ -1,13 +1,34 @@ 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: die Liste der +// VPN-Instanzen. Zertifikate werden nie gecacht. +const providerCacheTTL = 5 * time.Minute + func configFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) { fs := flag.NewFlagSet(name, flag.ContinueOnError) fs.SetOutput(stderr) @@ -15,11 +36,203 @@ func configFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) { 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 } - fmt.Fprintf(stderr, "Fehler: config %q kann noch nicht geladen werden (nicht implementiert)\n", *path) - return 1 + + 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 + } + + // Startprüfung: 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() + + // Der Failover-Hook kann erst jetzt gesetzt werden — vorher gab es noch + // kein Audit-Log, in das er hätte schreiben können. + ad.SetFailoverHook(func(server string, err error) { + auditLog.Log(audit.Event{Event: audit.EventLDAPFailover, Server: server}) + }) + + 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, + }) + + if msg := checkForUpdate(context.Background(), cfg.Portal.UpdateCheck, releaseURL, version, nil); msg != "" { + fmt.Fprintln(stdout, msg) + } + + 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, + } + + // 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) + defer signal.Stop(shutdown) + 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 + } } diff --git a/cmd/vpnportal/serve_test.go b/cmd/vpnportal/serve_test.go new file mode 100644 index 0000000..692c3d5 --- /dev/null +++ b/cmd/vpnportal/serve_test.go @@ -0,0 +1,82 @@ +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 TestCheckRejectsUnknownConfigField(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "config.yaml") + os.WriteFile(p, []byte("portal:\n tls_cert_typo: \"x\"\n"), 0o600) + os.Chmod(p, 0o600) + + var out, errOut bytes.Buffer + code := runCheck([]string{"--config", p}, &out, &errOut) + if code == 0 { + t.Fatal("Tippfehler in der Config muss zum Abbruch führen") + } + combined := out.String() + errOut.String() + if !strings.Contains(combined, "tls_cert_typo") { + t.Errorf("das unbekannte Feld muss genannt werden: %q", combined) + } +} + +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") + } +} diff --git a/cmd/vpnportal/uidemo_test.go b/cmd/vpnportal/uidemo_test.go new file mode 100644 index 0000000..56f3e72 --- /dev/null +++ b/cmd/vpnportal/uidemo_test.go @@ -0,0 +1,94 @@ +//go:build uidemo + +// 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 begutachtet +// werden kann. Aufruf: +// +// go test -tags uidemo ./cmd/vpnportal/ -run TestUIDemo -v -timeout 0 +// +// Anmeldung mit beliebigem Benutzernamen und Passwort. +// Browser erlauben Secure-Cookies auf 127.0.0.1, deshalb funktioniert der +// gesamte Ablauf auch ohne TLS. +package main + +import ( + "context" + "io" + "net/http" + "os" + "strings" + "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, 2, 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 &web.ExportStream{ + Filename: "vpn-demo-mmueller.ovpn", + ContentType: "application/x-openvpn-profile", + Body: io.NopCloser(strings.NewReader("client\nremote fw01.testfirma.local 1194\n")), + InstanceName: "VPN Homeoffice", + CommonName: "mmueller", + ValidTo: time.Now().AddDate(1, 0, 0), + }, nil +} + +func (demoCerts) Pattern(user string) string { return user } + +// leereCerts zeigt den Zustand "kein Zertifikat hinterlegt". +type leereCerts struct{ demoCerts } + +func (leereCerts) EntriesFor(ctx context.Context, user string) ([]web.CertEntry, error) { + return nil, nil +} + +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) + } + // Mit VPNPORTAL_UIDEMO_EMPTY=1 den Zustand "kein Zertifikat" begutachten. + var certs web.CertSource = demoCerts{} + if os.Getenv("VPNPORTAL_UIDEMO_EMPTY") != "" { + certs = leereCerts{} + } + srv, err := web.NewServer(web.Deps{ + Auth: demoAuth{}, Certs: certs, Audit: demoAudit{t}, + Sessions: web.NewSessionStore(30*time.Minute, nil), + Limiter: web.NewLimiter(nil), Renderer: rn, + SupportContact: "it-helpdesk@testfirma.de", + SessionTTL: 30 * time.Minute, + MinLoginDuration: time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + t.Log("Oberfläche unter http://127.0.0.1:8080 — mit Strg-C beenden") + if err := http.ListenAndServe("127.0.0.1:8080", srv.Handler()); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/vpnportal/updatecheck.go b/cmd/vpnportal/updatecheck.go new file mode 100644 index 0000000..17ea954 --- /dev/null +++ b/cmd/vpnportal/updatecheck.go @@ -0,0 +1,61 @@ +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 sich die veröffentlichte Version unterscheidet. +// 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 "" + } + // Reiner Gleichheitsvergleich, keine Semver-Ordnung: das Portal meldet nur, + // dass sich die veröffentlichte Version unterscheidet, und urteilt nicht + // selbst über Versionsordnungen. + latest := strings.TrimPrefix(strings.TrimSpace(release.TagName), "v") + if latest == "" || latest == strings.TrimPrefix(strings.TrimSpace(current), "v") { + return "" + } + return fmt.Sprintf("Hinweis: Version %s ist verfügbar (installiert: %s).", latest, current) +} diff --git a/cmd/vpnportal/updatecheck_test.go b/cmd/vpnportal/updatecheck_test.go new file mode 100644 index 0000000..bad5109 --- /dev/null +++ b/cmd/vpnportal/updatecheck_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "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("abweichende Version muss gemeldet werden") + } + if !strings.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() + // Auch mit "v"-Präfix auf der einen und ohne auf der anderen Seite. + 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 TestUpdateCheckIgnoresGarbage(t *testing.T) { + for _, body := range []string{`nicht json`, `{}`, `{"tag_name":""}`, `{"tag_name":" "}`} { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, body) + })) + if msg := checkForUpdate(context.Background(), true, srv.URL, "1.2.0", srv.Client()); msg != "" { + t.Errorf("Antwort %q darf nichts melden, got %q", body, msg) + } + srv.Close() + } +} diff --git a/internal/check/check.go b/internal/check/check.go new file mode 100644 index 0000000..ba39c42 --- /dev/null +++ b/internal/check/check.go @@ -0,0 +1,239 @@ +// 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. +// Eine falsche Systemzeit würde abgelaufene Zertifikate falsch bewerten. +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 in +// handlungsleitende Meldungen. +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 + } + 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 fmt.Sprintf("DN: %s\nsAMAccountName: %s\nMitglied in %s: ja", + res.DN, res.SAMAccountName, c.Cfg.AD.VPNGroup), nil +} diff --git a/internal/check/check_test.go b/internal/check/check_test.go new file mode 100644 index 0000000..039324d --- /dev/null +++ b/internal/check/check_test.go @@ -0,0 +1,260 @@ +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 report(t *testing.T, rep *Report) string { + t.Helper() + var b strings.Builder + if _, err := rep.WriteTo(&b); err != nil { + t.Fatal(err) + } + return b.String() +} + +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() { + t.Fatalf("alle Prüfungen sollten bestehen:\n%s", report(t, rep)) + } + out := report(t, rep) + for _, want := range []string{"Konfiguration", "Dateirechte", "OPNsense", "Verzeichnisdienst", "Systemzeit"} { + if !strings.Contains(out, want) { + t.Errorf("Prüfpunkt %q fehlt:\n%s", want, out) + } + } +} + +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") + } + out := report(t, rep) + 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(), "") + out := report(t, rep) + if !strings.Contains(out, "Client Export") { + t.Errorf("fehlende Berechtigung muss den nötigen Privilegnamen nennen:\n%s", out) + } +} + +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") + } + if out := report(t, rep); !strings.Contains(out, "dc01.firma.local") { + t.Errorf("betroffener DC muss genannt werden:\n%s", out) + } +} + +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") + } + if out := report(t, rep); !strings.Contains(out, "chmod") { + t.Errorf("Reparaturbefehl muss genannt werden:\n%s", out) + } +} + +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 TestMissingLogDirectoryIsDetected(t *testing.T) { + c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"}) + c.Cfg.Logging.AuditLog = "/gibt/es/nicht/audit.log" + rep := c.Run(context.Background(), "") + if rep.OK() { + t.Fatal("fehlendes Log-Verzeichnis 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(), "") + out := report(t, rep) + 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() { + t.Fatalf("30 Sekunden Abweichung sind unkritisch:\n%s", report(t, rep)) + } +} + +func TestClockCheckSkippedWhenFirewallDown(t *testing.T) { + // Ohne Antwort der Firewall lässt sich die Uhr nicht vergleichen; das darf + // nicht als eigenständiger Zeitfehler dastehen. + c := newChecker(t, &fakePinger{err: opnsense.ErrUnreachable}, &fakeDir{dn: "CN=x"}) + rep := c.Run(context.Background(), "") + out := report(t, rep) + if !strings.Contains(out, "nicht prüfbar") { + t.Errorf("Zeitprüfung muss als nicht prüfbar ausgewiesen werden:\n%s", out) + } +} + +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") + + out := report(t, rep) + 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") + } + if out := report(t, rep); !strings.Contains(out, "NEIN") { + t.Errorf("das Ergebnis muss deutlich benannt werden:\n%s", out) + } +} + +func TestTestAuthSkippedWhenEmpty(t *testing.T) { + c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"}) + rep := c.Run(context.Background(), "") + if strings.Contains(report(t, rep), "Testanmeldung") { + t.Error("ohne --test-auth darf kein Testanmelde-Punkt erscheinen") + } +} + +func TestReportOutputFormat(t *testing.T) { + rep := &Report{Results: []Result{ + {Name: "Konfiguration", OK: true}, + {Name: "Dateirechte", OK: false, Detail: "config.yaml ist 0644"}, + }} + out := report(t, rep) + 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) + } + if rep.OK() { + t.Error("ein ✗ muss OK() falsch machen") + } +} + +func TestInvalidConfigIsReported(t *testing.T) { + c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"}) + c.Cfg.AD.Servers = []string{"10.1.1.10"} // IP statt Hostname + rep := c.Run(context.Background(), "") + if rep.OK() { + t.Fatal("ungültige Konfiguration muss auffallen") + } + if out := report(t, rep); !strings.Contains(out, "10.1.1.10") { + t.Errorf("der beanstandete Wert muss genannt werden:\n%s", out) + } +} From 4b287f9b1b069047443846350a6900c03787f624 Mon Sep 17 00:00:00 2001 From: Carsten Abele Date: Fri, 14 Aug 2026 09:37:45 +0200 Subject: [PATCH 10/10] =?UTF-8?q?docs:=20Installationsanleitung,=20geh?= =?UTF-8?q?=C3=A4rtete=20systemd-Unit=20und=20Beispielkonfiguration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68 --- README.md | 422 ++++++++++++++++++++++++++++++++ deploy/config.example.yaml | 121 +++++++++ deploy/vpnportal.service | 65 +++++ internal/config/example_test.go | 25 ++ 4 files changed, 633 insertions(+) create mode 100644 README.md create mode 100644 deploy/config.example.yaml create mode 100644 deploy/vpnportal.service create mode 100644 internal/config/example_test.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f786e8 --- /dev/null +++ b/README.md @@ -0,0 +1,422 @@ +# vpnportal — VPN-Konfig-Portal für OPNsense + +Self-Service-Webportal, über das Mitarbeitende ihre persönliche +OpenVPN-Client-Konfiguration von einer OPNsense-Firewall herunterladen. +Anmeldung ausschließlich gegen das Active Directory des Kunden. + +Das Portal ist ein reiner **Read-only-Verteiler**: Es erstellt, ändert oder +widerruft keine Zertifikate und schreibt nichts auf die Firewall. Ein einzelnes +statisch gelinktes Binary plus eine YAML-Datei, keine Datenbank. + +--- + +## Voraussetzungen + +| | | +|---|---| +| Betriebssystem | Linux, `amd64` oder `arm64` (Raspberry Pi) | +| Firewall | OPNsense mit Plugin `os-openvpn-client-export` (siehe `docs/opnsense-api.md`) | +| Verzeichnis | Active Directory mit LDAPS (636) oder StartTLS (389) | +| Zeit | NTP ist **Pflicht** — ohne korrekte Uhr werden Zertifikatslaufzeiten falsch bewertet | +| TLS | Serverzertifikat für das Portal (eigene CA genügt) | + +Nicht unterstützt: Windows, Multi-Domain-Forests, mehrere OPNsense-Instanzen +pro Portal. + +--- + +## Installation in unter 30 Minuten + +### 1. OPNsense vorbereiten + +1. Plugin `os-openvpn-client-export` installieren + (*System → Firmware → Plugins*). +2. Gruppe anlegen (*System → Access → Groups*), z. B. `vpnportal-api`, und ihr + **ausschließlich** das Privileg **„VPN: OpenVPN Client Export"** zuweisen. +3. Benutzer anlegen (*System → Access → Users*), z. B. `svc-vpnportal`, dieser + Gruppe zuordnen, kein Shell-Zugang. +4. Für diesen Benutzer einen API-Key erzeugen; Key und Secret notieren. + +> **Niemals einen Admin-Key verwenden.** Das Portal braucht genau ein Privileg. +> `vpnportal check` meldet ausdrücklich, wenn die Berechtigung fehlt. + +### 2. Active Directory vorbereiten + +1. Unprivilegierten Dienstbenutzer anlegen, z. B. + `svc-vpnportal@firma.local`. Lesezugriff genügt; keine besonderen Rechte. +2. Sicherheitsgruppe für den VPN-Zugang anlegen oder benennen, z. B. + `VPN-Users`. Verschachtelte Gruppen werden aufgelöst. +3. Sicherstellen, dass die Domain Controller **über ihre Hostnamen** + erreichbar sind — das LDAPS-Zertifikat wird gegen den Hostnamen geprüft. + IP-Adressen in `ad.servers` werden abgelehnt. +4. Wird eine interne CA verwendet, deren Zertifikat als PEM bereitstellen. + +### 3. Portal installieren + +```bash +# Archiv entpacken +tar -xzf vpnportal--linux-amd64.tar.gz -C /tmp/vpnportal +install -m 0755 /tmp/vpnportal/vpnportal /usr/local/bin/vpnportal + +# Konfiguration anlegen +install -d -m 0750 /etc/vpnportal +install -m 0640 /tmp/vpnportal/config.example.yaml /etc/vpnportal/config.yaml +$EDITOR /etc/vpnportal/config.yaml + +# TLS-Material ablegen +install -m 0644 portal.crt /etc/vpnportal/portal.crt +install -m 0600 portal.key /etc/vpnportal/portal.key +``` + +Dateirechte werden **erzwungen** — das Portal startet nicht, wenn sie zu weit +gefasst sind: + +| Datei | Höchstens | +|---|---| +| `config.yaml` | `0640` | +| Secret-Dateien | `0640` | +| `portal.key` | `0600` | +| Log-Verzeichnis | `0750` | + +### 4. Prüfen + +```bash +vpnportal check --config /etc/vpnportal/config.yaml +``` + +Gibt eine Checkliste aus und benennt jeden Fehler konkret: + +``` +✓ Konfiguration + gelesen und plausibel +✓ Dateirechte + Konfiguration, Secrets und Log-Verzeichnis sind ausreichend geschützt +✗ OPNsense-Verbindung + der API-Benutzer hat keine Berechtigung — auf der OPNsense das Privileg + "VPN: OpenVPN Client Export" zuweisen +``` + +Einen konkreten Benutzer durchspielen (ohne dessen Passwort): + +```bash +vpnportal check --test-auth mmueller +``` + +### 5. Als Dienst einrichten + +```bash +install -m 0644 /tmp/vpnportal/vpnportal.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable --now vpnportal +systemctl status vpnportal +``` + +Die mitgelieferte Unit ist gehärtet (`DynamicUser`, `ProtectSystem=strict`, +leeres `CapabilityBoundingSet`, `SystemCallFilter`). Wer einen festen +Systembenutzer bevorzugt, ersetzt `DynamicUser=yes` durch: + +```ini +User=vpnportal +Group=vpnportal +``` + +und legt den Benutzer mit +`useradd --system --no-create-home --shell /usr/sbin/nologin vpnportal` an. + +--- + +## Secrets + +Jedes Secret lässt sich auf drei Wegen setzen. **Rangfolge: Umgebungsvariable +> `*_file` > Wert in der Konfigurationsdatei.** + +| Secret | Feld | Datei-Variante | Umgebungsvariable | +|---|---|---|---| +| OPNsense-Key | `opnsense.api_key` | `api_key_file` | `VPNPORTAL_OPNSENSE_API_KEY` | +| OPNsense-Secret | `opnsense.api_secret` | `api_secret_file` | `VPNPORTAL_OPNSENSE_API_SECRET` | +| AD-Passwort | `ad.bind_password` | `bind_password_file` | `VPNPORTAL_AD_BIND_PASSWORD` | + +Werden Feld und `*_file` gleichzeitig gesetzt, bricht der Start mit einer +Fehlermeldung ab — stillschweigendes Übergehen wäre gefährlicher. + +Mit systemd empfiehlt sich `LoadCredential` (schon in der Unit vorbereitet): + +```bash +install -d -m 0700 /etc/vpnportal/secrets +printf '%s' 'DAS-AD-PASSWORT' > /etc/vpnportal/secrets/ad-bind-password +chmod 600 /etc/vpnportal/secrets/ad-bind-password +``` + +In der `config.yaml` dann: + +```yaml +ad: + bind_password_file: "/run/credentials/vpnportal.service/ad-bind-password" +``` + +Die Konfiguration wird bewusst **nicht** verschlüsselt: der Schlüssel läge +daneben, das wäre Sicherheitstheater. Schutz sind Dateirechte und die +Härtung der Unit. + +--- + +## Zertifikatszuordnung + +Welches Firewall-Zertifikat zu welchem Benutzer gehört, entscheidet +`matching`. `{username}` ist der **aus dem AD gelesene** `sAMAccountName` in +Kleinschreibung — nie die Eingabe des Benutzers. Der Vergleich ignoriert +Groß- und Kleinschreibung. + +```yaml +matching: + cn_pattern: "{username}" # CN == sAMAccountName (Standard) + # cn_pattern: "{username}@firma.de" # CN ist eine E-Mail-Adresse +``` + +Für Bestandsumgebungen, die sich nicht in ein Template fassen lassen, gibt es +`cn_regex` als Notnagel. Er schließt `cn_pattern` aus: + +```yaml +matching: + cn_regex: "^(vpn-)?{username}(-[0-9]+)?$" +``` + +`{username}` wird vor dem Kompilieren quotiert eingesetzt, ein Benutzername +mit Sonderzeichen kann das Muster also nicht verändern. + +> **Vorsicht:** Eine zu weit gefasste Regex kann fremde Zertifikate zuordnen. +> Nach jeder Änderung mit `check --test-auth` und einem Testbenutzer prüfen. + +Zusätzlich zur Zuordnung gilt immer: **revozierte und abgelaufene Zertifikate +werden nie ausgeliefert.** Die Zertifikatsliste wird bei jeder Anfrage frisch +von der Firewall geholt, eine Revozierung greift also sofort. + +--- + +## Betrieb + +### Monitoring + +`GET /healthz` antwortet ohne Anmeldung und ohne sensible Details: + +```json +{"ok":true,"opnsense":true,"directory":true,"sessions":3} +``` + +HTTP 200 wenn `ok`, sonst 503. Ergebnisse werden 30 Sekunden gecacht, häufiges +Abfragen belastet die Backends also nicht. + +Für Zabbix/CheckMK genügt der HTTP-Status; wer feiner prüfen will, wertet +`opnsense` und `directory` einzeln aus. + +### Logs lesen + +Alles geht parallel nach stdout (systemd-Journal) und in die Audit-Datei: + +```bash +journalctl -u vpnportal -f + +# Wer hat wann welche Konfiguration bezogen? +jq -r 'select(.event=="config_download") | "\(.ts) \(.user) \(.vpn_instance)"' \ + /var/log/vpnportal/audit.log + +# Fehlanmeldungen einer IP zählen +jq -r 'select(.event=="login_failed") | .src_ip' /var/log/vpnportal/audit.log \ + | sort | uniq -c | sort -rn +``` + +### Neustart + +Ein Neustart beendet **alle** Sitzungen — Sessions liegen ausschließlich im +RAM. Das ist beabsichtigt; weitere Daten gehen dabei nicht verloren. + +--- + +## Audit-Log + +JSON Lines, eine Zeile pro Ereignis, Zeitstempel RFC 3339 mit Zeitzone: + +```json +{"ts":"2026-08-14T09:32:11+02:00","event":"login_success","user":"mmueller","src_ip":"10.1.20.34","session":"a3f9"} +{"ts":"2026-08-14T09:32:40+02:00","event":"config_download","user":"mmueller","src_ip":"10.1.20.34","session":"a3f9","vpn_instance":"VPN Homeoffice","cert_cn":"mmueller","cert_expiry":"2027-03-01","format":"ovpn"} +``` + +**Audit-Ereignisse:** `login_success`, `login_failed` (mit `reason`), `logout`, +`session_expired`, `config_download`, `download_denied` + +**Betriebsereignisse:** `no_cert_found`, `opnsense_unreachable`, +`ldap_failover`, `rate_limited`, `startup` + +**`reason` bei `login_failed`:** `invalid_credentials`, `account_disabled`, +`account_locked`, `password_expired`, `password_change_required`, +`not_in_vpn_group`, `user_not_found`, `backend_unavailable` + +### Was niemals im Log steht + +- **Passwörter.** Wird versehentlich ein Passwort ins Benutzernamenfeld + getippt, erscheint statt des Namens `""` — der Klartextname wird + nur protokolliert, wenn der Benutzer im AD tatsächlich gefunden wurde. +- Konfigurationsinhalte, Zertifikate, private Schlüssel. +- Vollständige Session-Tokens (nur eine 4-stellige Hash-Korrelations-ID). +- Vollständige CN-Listen der Firewall (nur auf `debug`-Level, das im + Normalbetrieb aus bleibt). + +### Rotation und Weiterleitung + +Die Rotation ist eingebaut und größenbasiert (`max_size_mb`, `max_backups`, +`compress`) — das schont SD-Karten. Wer stattdessen externes `logrotate` +nutzt, schickt danach ein `SIGHUP`: + +``` +/var/log/vpnportal/audit.log { + daily + rotate 90 + compress + postrotate + systemctl kill -s HUP vpnportal + endscript +} +``` + +> **Für revisionssichere Aufbewahrung das Log an ein zentrales Logsystem +> weiterleiten** (über das Journal oder Syslog). Empfohlene Aufbewahrung: +> 90 Tage, kundenseitig anpassbar. + +Das Log enthält personenbezogene Daten und ist entsprechend zu schützen. Die +Datei wird mit `0600` angelegt, das Verzeichnis darf höchstens `0750` haben. + +--- + +## Raspberry Pi + +- **SSD statt SD-Karte.** Das Portal schreibt zwar nur das Audit-Log, aber + SD-Karten sterben trotzdem. +- **Gerät in einen abschließbaren Netzwerkschrank.** Das Speichermedium trägt + die Zugangsdaten zu Firewall und Verzeichnis. +- **NTP prüfen** (`timedatectl status`). `vpnportal check` vergleicht die + Systemzeit mit der Firewall und warnt ab zwei Minuten Abweichung. + +--- + +## Fehlersuche + +| Symptom | Wahrscheinliche Ursache | Kommando | +|---|---|---| +| Start bricht mit `chmod`-Hinweis ab | Datei- oder Verzeichnisrechte zu weit | Der Hinweis nennt den nötigen Befehl | +| Start bricht mit unbekanntem Feld ab | Tippfehler in der `config.yaml` | Die Meldung nennt das Feld | +| Niemand kann sich anmelden | Dienstkonto, Gruppe oder DC-Erreichbarkeit | `vpnportal check` | +| Ein einzelner Benutzer nicht | Gruppenmitgliedschaft oder Schreibweise | `vpnportal check --test-auth ` | +| „Keine VPN-Konfiguration hinterlegt" | `cn_pattern` passt nicht zum CN | Audit-Feld `pattern` im Ereignis `no_cert_found` lesen | +| „Dienst vorübergehend nicht verfügbar" | Firewall nicht erreichbar oder CA falsch | `vpnportal check` | +| Anmeldung dauert lang und schlägt fehl | Erster DC tot, Failover greift | Ereignis `ldap_failover` im Log | +| „Zu viele Anmeldeversuche" | Rate-Limit greift | Ereignis `rate_limited`; die Wartezeit läuft von allein ab | + +Für die Fehlersuche lässt sich `logging.level` vorübergehend auf `debug` +setzen — dann werden auch die verglichenen CNs protokolliert. **Im +Normalbetrieb wieder auf `info` zurückstellen.** + +### Passwort abgelaufen + +Das Portal kann keine Passwörter ändern. Ist ein AD-Passwort abgelaufen +(AD-Code 532), sieht der Benutzer als einzige Ausnahme eine konkrete Meldung +und muss das Passwort an seinem Arbeitsplatz ändern. Verlangt das AD eine +Passwortänderung bei der nächsten Anmeldung (Code 773), erscheint die +generische Meldung — dieser Fall lässt sich am Portal nicht unterscheiden +kommunizieren, ohne Kontoinformationen preiszugeben. + +--- + +## Bekannte Grenzen + +- **Kein WireGuard** (für v2 geplant, braucht ein eigenes Schlüsselkonzept). +- **Kein Self-Service für Zertifikate** — Erstellen, Erneuern und Widerrufen + bleiben auf der Firewall. +- **Keine Admin-Oberfläche.** Logs per SSH/Monitoring, `/healthz` für + externe Überwachung. +- **Keine Mandantenfähigkeit**, eine OPNsense-Instanz pro Portal. +- **Single-Domain.** Referrals werden ignoriert, Multi-Domain-Forests sind + nicht unterstützt. +- **Kein RADIUS und keine lokalen Portalbenutzer.** +- **Nur Deutsch.** Die Textstruktur ist für i18n vorbereitet, aber nur + Deutsch ist befüllt. + +--- + +## Update + +```bash +systemctl stop vpnportal +install -m 0755 vpnportal /usr/local/bin/vpnportal +vpnportal check --config /etc/vpnportal/config.yaml +systemctl start vpnportal +``` + +Sitzungen gehen dabei verloren, das ist beabsichtigt. + +Es gibt **kein Auto-Update**. `portal.update_check` ist standardmäßig +**aus**; eingeschaltet baut das Portal beim Start eine ausgehende +HTTPS-Verbindung zur Release-URL auf und meldet abweichende Versionen im +Log. Es lädt nichts herunter und installiert nichts. + +--- + +## Bauen aus dem Quelltext + +Voraussetzung: Go 1.26 oder neuer. Keine CGO-Abhängigkeiten, kein npm, +kein Build-Step für das Frontend — Templates und Assets liegen per `go:embed` +im Binary. + +```bash +make test # alle Tests +make race # Tests mit Race-Detector +make lint # go vet + gofmt +make build # Binary nach dist/ +make release # Archive für linux-amd64 und linux-arm64 inkl. SHA256SUMS +``` + +### Integrationstests gegen eine echte OPNsense + +Die Feldnamen der Export-API sind versionsabhängig. Vor einer Auslieferung +sind die in `docs/opnsense-api.md` dokumentierten Annahmen einmal gegen eine +echte Instanz zu bestätigen: + +```bash +export OPNSENSE_URL="https://fw01.firma.local" +export OPNSENSE_KEY="..." OPNSENSE_SECRET="..." +export OPNSENSE_REVOKED_CN="testuser-revoked" # optional +go test -tags integration ./internal/opnsense/ -v +``` + +Ohne gesetzte Variablen überspringen sich diese Tests selbst. + +### Oberfläche ohne Backends ansehen + +```bash +go test -tags uidemo ./cmd/vpnportal/ -run TestUIDemo -v -timeout 0 +# http://127.0.0.1:8080 — Anmeldung mit beliebigen Daten +# VPNPORTAL_UIDEMO_EMPTY=1 zeigt den Zustand "kein Zertifikat" +``` + +--- + +## Sicherheitsdesign in Kürze + +- HTTPS-Pflicht am Portal; TLS-Verifikation gegen Firewall und Verzeichnis + immer aktiv. Die Insecure-Option existiert nur für die Firewall und warnt + bei jedem Start; für LDAP gibt es sie gar nicht. +- LDAP-Injection-Schutz durch konsequentes `ldap.EscapeFilter`. +- CSRF-Token auf allen zustandsändernden Anfragen **einschließlich Downloads**. + Downloads sind ausschließlich `POST`, es gibt keine GET-Download-Links. +- **Jeder Download wird serverseitig neu autorisiert.** Die Auswahl in der + Oberfläche ist keine Berechtigung: zwischen Anzeige und Klick kann ein + Zertifikat revoziert worden sein. +- Konfigurationen werden gestreamt, nie zwischengespeichert; alle Seiten nach + der Anmeldung tragen `Cache-Control: no-store`. +- Rate-Limiting pro Benutzername **und** pro Quell-IP mit exponentiellem + Backoff, gedeckelt bei fünf Minuten. Bewusst **kein harter Lockout** — + sonst könnte ein Dritter Kollegen dauerhaft aussperren. +- Keine Benutzer-Enumeration: gleiche Meldung und angeglichene Antwortzeit für + „Benutzer existiert nicht" und „Passwort falsch". +- Security-Header auf jeder Antwort: HSTS, `nosniff`, `no-referrer`, + `X-Frame-Options: DENY` und eine CSP ohne jedes `unsafe-*`. +- Die Oberfläche ist ohne JavaScript vollständig bedienbar. diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml new file mode 100644 index 0000000..45f3cb7 --- /dev/null +++ b/deploy/config.example.yaml @@ -0,0 +1,121 @@ +# Beispielkonfiguration für das VPN-Konfig-Portal. +# +# 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. +# +# Die Datei wird strikt gelesen: ein Tippfehler in einem Feldnamen führt zum +# Startabbruch mit klarer Meldung. Prüfen mit: +# vpnportal check --config /etc/vpnportal/config.yaml + +portal: + # Adresse, auf der das Portal lauscht. HTTPS ist Pflicht. + listen: "0.0.0.0:8443" + + # Serverzertifikat und privater Schlüssel des Portals. + # Der Schlüssel muss 0600 sein, sonst startet das Portal nicht. + tls_cert: "/etc/vpnportal/portal.crt" + tls_key: "/etc/vpnportal/portal.key" + + # Lebensdauer einer Anmeldung. Absolut, nicht verlängerbar. Default: 10m + session_ttl: "10m" + + # White-Label: Titel im Browser-Tab und in der Kopfzeile. + title: "VPN-Portal Firma GmbH" + + # Optionales Logo in der Kopfzeile (PNG oder SVG). + # logo_file: "/etc/vpnportal/logo.png" + + # Wird angezeigt, wenn für einen Benutzer kein Zertifikat hinterlegt ist. + support_contact: "it-helpdesk@firma.de" + + # Optionaler Versionscheck. Default: false. + # Eingeschaltet baut das Portal beim Start eine ausgehende HTTPS-Verbindung + # zur Release-URL auf ("telefoniert nach Hause"). Read-only, kein Auto-Update. + # update_check: false + +opnsense: + url: "https://fw01.firma.local" + + # API-Zugangsdaten eines dedizierten Benutzers, der AUSSCHLIESSLICH das + # Privileg "VPN: OpenVPN Client Export" hat. Niemals einen Admin-Key. + api_key: "BITTE-ERSETZEN" + api_secret: "BITTE-ERSETZEN" + # alternativ als Datei (kompatibel mit systemd LoadCredential, Ansible Vault): + # api_key_file: "/run/credentials/vpnportal.service/opnsense-api-key" + # api_secret_file: "/run/credentials/vpnportal.service/opnsense-api-secret" + # oder als Umgebungsvariable: + # VPNPORTAL_OPNSENSE_API_KEY, VPNPORTAL_OPNSENSE_API_SECRET + # Rangfolge: Umgebungsvariable > *_file > Wert in dieser Datei. + + # Interne CA, falls die Firewall ein eigenes Zertifikat verwendet. + # ca_file: "/etc/vpnportal/firma-ca.pem" + + # NUR für Tests: deaktiviert die TLS-Prüfung zur Firewall. + # Erzeugt bei jedem Start eine Warnung. Für LDAP gibt es das bewusst nicht. + # insecure_skip_verify: false + +ad: + # AD-Domäne. Der Base-DN wird daraus abgeleitet (DC=firma,DC=local). + domain: "firma.local" + + # Optionaler Override, z. B. um auf eine OU einzuschränken. + # base_dn: "OU=Mitarbeiter,DC=firma,DC=local" + + # Domain Controller mit Failover in dieser Reihenfolge. + # HOSTNAMEN, keine IP-Adressen: das LDAPS-Zertifikat wird gegen den + # Hostnamen geprüft. IP-Adressen werden abgelehnt. + servers: + - "dc01.firma.local" + - "dc02.firma.local" + + # LDAPS ist Default. Für StartTLS auf 389: + # port: 389 + # tls_mode: "starttls" + # port: 636 + # tls_mode: "ldaps" + + # Unprivilegierter Domain-Benutzer genügt — er muss nur lesen dürfen. + bind_user: "svc-vpnportal@firma.local" + bind_password: "BITTE-ERSETZEN" + # alternativ: bind_password_file oder VPNPORTAL_AD_BIND_PASSWORD + # bind_password_file: "/run/credentials/vpnportal.service/ad-bind-password" + + # Nur Mitglieder dieser Gruppe erhalten Zugang. Verschachtelte Gruppen + # werden aufgelöst. Der Gruppen-DN wird beim Start ermittelt und gecacht. + vpn_group: "VPN-Users" + + # Interne CA für LDAPS. Für LDAP gibt es keine Insecure-Option. + # ca_file: "/etc/vpnportal/firma-ca.pem" + + # Zeitlimit pro Domain Controller, danach wird der nächste versucht. + # timeout: "8s" + +matching: + # Wie der Zertifikats-CN dem Benutzer zugeordnet wird. + # {username} ist der aus dem AD gelesene sAMAccountName in Kleinschreibung. + # Der Vergleich ignoriert Groß-/Kleinschreibung. + cn_pattern: "{username}" + # Beispiel für Bestandsumgebungen mit E-Mail-artigen CNs: + # cn_pattern: "{username}@firma.de" + + # Notnagel für Bestandsumgebungen, die sich nicht in ein Template fassen + # lassen. Schließt cn_pattern aus. {username} wird vor dem Kompilieren + # quotiert eingesetzt. + # ACHTUNG: eine zu weit gefasste Regex kann fremde Zertifikate zuordnen. + # cn_regex: "^(vpn-)?{username}(-[0-9]+)?$" + +logging: + # debug, info, warn, error. Im Normalbetrieb NICHT debug: auf debug werden + # die verglichenen CNs protokolliert. + level: "info" + + # Das Audit-Log enthält personenbezogene Daten. Verzeichnis höchstens 0750, + # Datei wird mit 0600 angelegt. + audit_log: "/var/log/vpnportal/audit.log" + + # Eingebaute größenbasierte Rotation (schont SD-Karten). + max_size_mb: 50 + max_backups: 5 + compress: true diff --git a/deploy/vpnportal.service b/deploy/vpnportal.service new file mode 100644 index 0000000..7953135 --- /dev/null +++ b/deploy/vpnportal.service @@ -0,0 +1,65 @@ +[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. Das Portal hält keinen Zustand +# auf der Platte außer dem Audit-Log, deshalb genügt DynamicUser. +# Alternativ einen festen Systembenutzer anlegen und User=/Group= setzen — +# siehe README, Abschnitt "Installation". +DynamicUser=yes + +# Audit-Log: systemd legt /var/log/vpnportal an und übergibt es dem Dienst. +# In der config.yaml dann: audit_log: "/var/log/vpnportal/audit.log" +LogsDirectory=vpnportal +LogsDirectoryMode=0750 + +# Secrets werden nicht als Umgebungsvariablen übergeben, sondern als Dateien +# unter $CREDENTIALS_DIRECTORY. In der config.yaml dann z. B.: +# ad: +# bind_password_file: "/run/credentials/vpnportal.service/ad-bind-password" +# opnsense: +# api_secret_file: "/run/credentials/vpnportal.service/opnsense-api-secret" +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 +ProcSubset=pid +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +RestrictAddressFamilies=AF_INET AF_INET6 +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged @resources +CapabilityBoundingSet= +ReadOnlyPaths=/etc/vpnportal +UMask=0077 + +# Soll das Portal direkt auf 443 lauschen, stattdessen: +# AmbientCapabilities=CAP_NET_BIND_SERVICE +# CapabilityBoundingSet=CAP_NET_BIND_SERVICE +# Besser: auf 8443 lauschen lassen und davor per nftables umleiten. + +[Install] +WantedBy=multi-user.target diff --git a/internal/config/example_test.go b/internal/config/example_test.go new file mode 100644 index 0000000..d428317 --- /dev/null +++ b/internal/config/example_test.go @@ -0,0 +1,25 @@ +package config + +import ( + "os" + "testing" +) + +// Die ausgelieferte Beispielkonfiguration muss immer parsen und validieren. +// Sonst laufen Kunden beim ersten Start in einen Fehler — und der Strict-Parser +// würde jeden Tippfehler in der Vorlage gnadenlos melden. +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) + } +}