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:
Carsten Abele 2026-08-14 09:06:43 +02:00
parent fbfff90b38
commit b47ee11ba4
10 changed files with 777 additions and 0 deletions

144
internal/config/validate.go Normal file
View 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 165535", 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...)
}