package config import ( "errors" "fmt" "io" "io/fs" "os" "strings" "gopkg.in/yaml.v3" ) // Namen der Umgebungsvariablen, die Secrets aus der Konfiguration überschreiben. const ( EnvAPIKey = "VPNPORTAL_OPNSENSE_API_KEY" EnvAPISecret = "VPNPORTAL_OPNSENSE_API_SECRET" EnvBindPassword = "VPNPORTAL_AD_BIND_PASSWORD" ) // maxConfigMode ist die weiteste erlaubte Berechtigung für Config- und // Secret-Dateien: Eigentümer lesen/schreiben, Gruppe lesen, Welt nichts. const maxConfigMode fs.FileMode = 0o640 // Parse liest YAML strikt in eine mit Defaults vorbelegte Config. // Unbekannte Felder führen zum Fehler. func Parse(r io.Reader) (*Config, error) { cfg := Defaults() dec := yaml.NewDecoder(r) dec.KnownFields(true) if err := dec.Decode(cfg); err != nil { if errors.Is(err, io.EOF) { return nil, errors.New("Konfigurationsdatei ist leer") } return nil, fmt.Errorf("Konfiguration konnte nicht gelesen werden: %w", err) } // Ein zweiter Decode-Aufruf muss EOF liefern; sonst enthält die Datei // mehrere YAML-Dokumente, was wir nicht unterstützen. var extra yaml.Node if err := dec.Decode(&extra); !errors.Is(err, io.EOF) { return nil, errors.New("Konfigurationsdatei darf nur ein YAML-Dokument enthalten") } return cfg, nil } // Load liest die Konfigurationsdatei, erzwingt Dateirechte, löst // *_file-Secrets auf und wendet Env-Overrides an. // env wird injiziert, damit Tests ohne Prozess-Umgebung auskommen. func Load(path string, env func(string) string) (*Config, error) { if env == nil { env = os.Getenv } if err := CheckFileMode(path, maxConfigMode); err != nil { return nil, err } f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("Konfigurationsdatei %s: %w", path, err) } defer f.Close() cfg, err := Parse(f) if err != nil { return nil, fmt.Errorf("%s: %w", path, err) } if err := resolveSecrets(cfg, env); err != nil { return nil, err } if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("Konfiguration %s ist ungültig:\n%w", path, err) } return cfg, nil } // secretSlot beschreibt ein Secret mit seinen drei Bezugsquellen. type secretSlot struct { name string // YAML-Feldname für Fehlermeldungen inline *string file string envName string } // resolveSecrets wendet die Rangfolge Env > *_file > Inline an. func resolveSecrets(cfg *Config, env func(string) string) error { slots := []secretSlot{ {"opnsense.api_key", &cfg.OPNsense.APIKey, cfg.OPNsense.APIKeyFile, EnvAPIKey}, {"opnsense.api_secret", &cfg.OPNsense.APISecret, cfg.OPNsense.APISecretFile, EnvAPISecret}, {"ad.bind_password", &cfg.AD.BindPassword, cfg.AD.BindPasswordFile, EnvBindPassword}, } for _, s := range slots { if v := env(s.envName); v != "" { *s.inline = v continue } if s.file == "" { continue } if *s.inline != "" { return fmt.Errorf("%s und %s_file sind gleichzeitig gesetzt — bitte nur eines verwenden", s.name, s.name) } if err := CheckFileMode(s.file, maxConfigMode); err != nil { return fmt.Errorf("Secret-Datei für %s: %w", s.name, err) } raw, err := os.ReadFile(s.file) if err != nil { return fmt.Errorf("Secret-Datei für %s: %w", s.name, err) } value := strings.TrimSpace(string(raw)) if value == "" { return fmt.Errorf("Secret-Datei %s für %s ist leer", s.file, s.name) } *s.inline = value } return nil }