feat(config): Strict-YAML, Secret-Dateien, Env-Overrides, Dateirechte und Validierung
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
parent
fbfff90b38
commit
b47ee11ba4
10 changed files with 777 additions and 0 deletions
2
go.mod
2
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
|
||||
|
|
|
|||
3
go.sum
Normal file
3
go.sum
Normal file
|
|
@ -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=
|
||||
109
internal/config/config.go
Normal file
109
internal/config/config.go
Normal file
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
116
internal/config/load.go
Normal file
116
internal/config/load.go
Normal file
|
|
@ -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
|
||||
}
|
||||
87
internal/config/load_test.go
Normal file
87
internal/config/load_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
40
internal/config/perms.go
Normal file
40
internal/config/perms.go
Normal file
|
|
@ -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
|
||||
}
|
||||
65
internal/config/perms_test.go
Normal file
65
internal/config/perms_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
96
internal/config/secrets_test.go
Normal file
96
internal/config/secrets_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
144
internal/config/validate.go
Normal file
144
internal/config/validate.go
Normal file
|
|
@ -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...)
|
||||
}
|
||||
115
internal/config/validate_test.go
Normal file
115
internal/config/validate_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue