opnsense-portal/internal/config/validate.go
Carsten Abele b47ee11ba4 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
2026-08-14 09:06:43 +02:00

144 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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