From b47ee11ba41adc4ddda596496aa3ce5b55491f64 Mon Sep 17 00:00:00 2001 From: Carsten Abele Date: Fri, 14 Aug 2026 09:06:43 +0200 Subject: [PATCH] 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") + } +}