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
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...)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue