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...) }