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
This commit is contained in:
parent
fbfff90b38
commit
b47ee11ba4
10 changed files with 777 additions and 0 deletions
116
internal/config/load.go
Normal file
116
internal/config/load.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue