Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
144 lines
5.1 KiB
Go
144 lines
5.1 KiB
Go
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...)
|
||
}
|