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