opnsense-portal/internal/config/load.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

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
}