Enterprise-Backup-, Recovery-, Verification-, Security- und Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme. Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes geht in keine Bewertung als "gut" ein. Umfang (Phasen 0-23): - Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll, Katalogaufbau allein aus den Manifesten — ohne Datenbank - Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck - Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell) - Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive - Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit - Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center, Ransomware-Heuristik (meldet, handelt nie) - Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing - Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository - Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64 Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs. Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md und docs/release-candidate.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
721 lines
28 KiB
Go
721 lines
28 KiB
Go
// Package config lädt die Laufzeitkonfiguration ausschließlich aus Umgebungsvariablen.
|
||
//
|
||
// Grundsätze (PROMPT.md §98, §119, §120):
|
||
// - Keine hardcoded Credentials und keine hardcoded Produktions-URLs.
|
||
// - Secrets besitzen niemals einen Default-Wert; fehlen sie, startet der Dienst nicht (Fail Secure).
|
||
// - Sicherheitsrelevante Schalter sind standardmäßig aktiv (Secure by Default).
|
||
package config
|
||
|
||
import (
|
||
"encoding/base64"
|
||
"errors"
|
||
"fmt"
|
||
"net/url"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// envPrefix ist das gemeinsame Präfix aller Syncova-Umgebungsvariablen.
|
||
// Es verhindert Kollisionen mit Fremdsoftware auf demselben Host.
|
||
const envPrefix = "SYNCOVA_"
|
||
|
||
// Environment beschreibt die Betriebsumgebung eines Dienstes.
|
||
// Der Wert steuert, ob Demo-/Entwicklungsverhalten überhaupt zulässig ist (PROMPT.md §139).
|
||
type Environment string
|
||
|
||
const (
|
||
// EnvironmentDevelopment ist die lokale Entwicklungsumgebung.
|
||
EnvironmentDevelopment Environment = "development"
|
||
// EnvironmentTest ist die Umgebung für automatisierte Tests.
|
||
EnvironmentTest Environment = "test"
|
||
// EnvironmentDemo ist eine Vorführumgebung mit zulässigen Demo-Daten.
|
||
EnvironmentDemo Environment = "demo"
|
||
// EnvironmentProduction ist der produktive Betrieb; hier sind Demo-Daten verboten.
|
||
EnvironmentProduction Environment = "production"
|
||
)
|
||
|
||
// AllowsDemoData meldet, ob in dieser Umgebung Demo-Daten angezeigt werden dürfen.
|
||
// In der Produktion ist das ausgeschlossen (PROMPT.md §139).
|
||
func (environment Environment) AllowsDemoData() bool {
|
||
switch environment {
|
||
case EnvironmentDevelopment, EnvironmentTest, EnvironmentDemo:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// IsProduction meldet, ob es sich um den produktiven Betrieb handelt.
|
||
func (environment Environment) IsProduction() bool {
|
||
return environment == EnvironmentProduction
|
||
}
|
||
|
||
// Config bündelt die vollständige Konfiguration eines Syncova-Dienstes.
|
||
type Config struct {
|
||
// Environment bestimmt umgebungsabhängiges Verhalten (Demo-Daten, Log-Format).
|
||
Environment Environment
|
||
// ServiceName benennt den Dienst in Logs und Metriken.
|
||
ServiceName string
|
||
// HTTP enthält die Einstellungen des öffentlichen API-Servers.
|
||
HTTP HTTPConfig
|
||
// Database enthält die Verbindungsparameter der Control-Plane-Datenbank.
|
||
Database DatabaseConfig
|
||
// Logging steuert Detailgrad und Format der strukturierten Logs.
|
||
Logging LoggingConfig
|
||
// Auth steuert Anmeldung, Sitzungsdauer und Brute-Force-Schutz.
|
||
Auth AuthConfig
|
||
// Encryption hält die Schlüssel für die verschlüsselte Ablage von Secrets.
|
||
Encryption EncryptionConfig
|
||
// Hardening bündelt die Schutzmaßnahmen aus Phase 19.
|
||
Hardening HardeningConfig
|
||
}
|
||
|
||
// HardeningConfig steuert die Schutzmaßnahmen gegen Missbrauch der Anlage
|
||
// (SYNCOVA_IMPLEMENTATION_PLAN.md §21).
|
||
type HardeningConfig struct {
|
||
// AllowInternalNotificationTargets erlaubt Meldeziele im eigenen Netz.
|
||
//
|
||
// Standardmäßig aus: Ein Webhook auf 169.254.169.254 erreicht den
|
||
// Metadatendienst der Cloud, einer auf 127.0.0.1 die eigene Datenbank.
|
||
// Wer einen Meldedienst im eigenen Netz betreibt, schaltet das
|
||
// ausdrücklich frei — und weiß dann, was er tut.
|
||
AllowInternalNotificationTargets bool
|
||
// RestoreAllowedRoots begrenzt die Ziele einer Wiederherstellung.
|
||
//
|
||
// Leer bedeutet: alles außer den Systemverzeichnissen des laufenden
|
||
// Systems. Ist die Liste gesetzt, gilt ausschließlich sie.
|
||
//
|
||
// Ohne diese Grenze könnte jemand mit dem Recht, Backups zurückzuschreiben,
|
||
// nach /etc/cron.d oder in eine authorized_keys schreiben — eine
|
||
// Rechteausweitung von der Anwendung auf das Betriebssystem.
|
||
RestoreAllowedRoots []string
|
||
}
|
||
|
||
// AuthConfig steuert Anmeldung und Sitzungsverwaltung (PROMPT.md §41–§43).
|
||
type AuthConfig struct {
|
||
// AccessTokenTTL ist die Lebensdauer eines Zugriffstokens.
|
||
// Sie ist bewusst kurz, damit ein entwendetes Token schnell wertlos wird.
|
||
AccessTokenTTL time.Duration
|
||
// RefreshTokenTTL ist die Lebensdauer eines Erneuerungstokens.
|
||
RefreshTokenTTL time.Duration
|
||
// MFAChallengeTTL begrenzt die Zeit zwischen Passwort und zweitem Faktor.
|
||
MFAChallengeTTL time.Duration
|
||
// MaxFailedLoginAttempts ist die Anzahl Fehlversuche bis zur Kontosperre.
|
||
MaxFailedLoginAttempts int
|
||
// LockoutDuration ist die Dauer der Kontosperre nach zu vielen Fehlversuchen.
|
||
LockoutDuration time.Duration
|
||
// MaxMFAAttempts ist die Anzahl Fehlversuche je MFA-Herausforderung.
|
||
MaxMFAAttempts int
|
||
// RequireMFAForPrivilegedUsers erzwingt einen zweiten Faktor für Konten mit
|
||
// weitreichenden Rechten (PROMPT.md §43).
|
||
RequireMFAForPrivilegedUsers bool
|
||
}
|
||
|
||
// EncryptionConfig hält das Schlüsselmaterial für die Ablage von Secrets.
|
||
type EncryptionConfig struct {
|
||
// keysByVersion bildet Schlüsselversionen auf Schlüsselmaterial ab.
|
||
// Das Feld ist nicht exportiert, damit Schlüssel nicht versehentlich
|
||
// serialisiert oder geloggt werden.
|
||
keysByVersion map[string][]byte
|
||
// CurrentKeyVersion ist die Version, mit der neu verschlüsselt wird.
|
||
CurrentKeyVersion string
|
||
}
|
||
|
||
// Keys liefert das Schlüsselmaterial für den Aufbau des Secret Stores.
|
||
//
|
||
// Der Rückgabewert ist geheim und darf niemals geloggt oder ausgegeben werden.
|
||
func (encryptionConfig EncryptionConfig) Keys() map[string][]byte {
|
||
return encryptionConfig.keysByVersion
|
||
}
|
||
|
||
// HTTPConfig beschreibt den öffentlichen HTTP-Server der API.
|
||
type HTTPConfig struct {
|
||
// ListenAddress ist die Bind-Adresse im Format host:port.
|
||
ListenAddress string
|
||
// ReadHeaderTimeout begrenzt die Zeit zum Lesen der Request-Header und wehrt Slowloris-Angriffe ab.
|
||
ReadHeaderTimeout time.Duration
|
||
// ReadTimeout begrenzt die Gesamtdauer des Request-Lesens.
|
||
ReadTimeout time.Duration
|
||
// WriteTimeout begrenzt die Gesamtdauer des Antwort-Schreibens.
|
||
WriteTimeout time.Duration
|
||
// IdleTimeout begrenzt die Lebensdauer inaktiver Keep-Alive-Verbindungen.
|
||
IdleTimeout time.Duration
|
||
// ShutdownTimeout ist die Frist für das geordnete Beenden laufender Requests.
|
||
ShutdownTimeout time.Duration
|
||
// MaxRequestBodyBytes begrenzt die Größe eines Request-Bodys (PROMPT.md §45).
|
||
MaxRequestBodyBytes int64
|
||
// AllowedOrigins listet die für CORS zugelassenen Herkünfte; leer bedeutet: kein Cross-Origin-Zugriff.
|
||
AllowedOrigins []string
|
||
// TLSCertificateFile ist die Zertifikatsdatei; leer bedeutet Klartext-HTTP.
|
||
//
|
||
// Ohne TLS wandern Anmeldedaten und Sitzungstokens im Klartext durch das
|
||
// Netz. Der Dienst lauscht deshalb standardmäßig nur auf 127.0.0.1: So
|
||
// bleibt eine unverschlüsselte Konfiguration auf den Rechner beschränkt,
|
||
// auf dem sie eingerichtet wurde (der übliche Betrieb hinter einem
|
||
// Reverse Proxy, der die Verschlüsselung übernimmt).
|
||
TLSCertificateFile string
|
||
// TLSPrivateKeyFile ist der zugehörige private Schlüssel.
|
||
TLSPrivateKeyFile string
|
||
// RequestsPerMinute begrenzt die Anfragen je Absender; 0 schaltet ab.
|
||
//
|
||
// Der Login hat seine eigene, strengere Grenze. Diese hier schützt die
|
||
// übrige API — insbesondere die teuren Endpunkte: Ein Bericht erzeugt ein
|
||
// PDF, eine Vorabprüfung liest jeden Block eines Backups.
|
||
RequestsPerMinute int
|
||
}
|
||
|
||
// DatabaseConfig beschreibt die Verbindung zur PostgreSQL-Control-Plane-Datenbank.
|
||
// Sie enthält niemals Backup-Nutzdaten (SYNCOVA_DATABASE.md §1).
|
||
type DatabaseConfig struct {
|
||
// Host ist der Datenbank-Hostname.
|
||
Host string
|
||
// Port ist der TCP-Port des Datenbankservers.
|
||
Port int
|
||
// Name ist der Name der Datenbank.
|
||
Name string
|
||
// User ist der Datenbankbenutzer.
|
||
User string
|
||
// password ist das Datenbankpasswort. Es ist bewusst nicht exportiert,
|
||
// damit es nicht versehentlich serialisiert oder geloggt wird.
|
||
password string
|
||
// SSLMode ist der TLS-Modus der Verbindung (PROMPT.md §12: Encryption in Transit).
|
||
SSLMode string
|
||
// MaxOpenConnections begrenzt die Anzahl gleichzeitiger Verbindungen (PROMPT.md §62).
|
||
MaxOpenConnections int32
|
||
// MaxConnectionIdleTime gibt an, wann eine unbenutzte Verbindung geschlossen wird.
|
||
MaxConnectionIdleTime time.Duration
|
||
// ConnectTimeout begrenzt die Dauer eines Verbindungsaufbaus.
|
||
ConnectTimeout time.Duration
|
||
}
|
||
|
||
// LoggingConfig steuert das strukturierte Logging (PROMPT.md §49).
|
||
type LoggingConfig struct {
|
||
// Level ist der minimale Log-Level (debug, info, warn, error).
|
||
Level string
|
||
// Format ist das Ausgabeformat ("json" für Maschinen, "text" für lokale Entwicklung).
|
||
Format string
|
||
}
|
||
|
||
// ConnectionString baut die PostgreSQL-DSN.
|
||
//
|
||
// Der Rückgabewert enthält das Passwort im Klartext und darf deshalb niemals
|
||
// geloggt, in Fehlermeldungen aufgenommen oder über die API ausgegeben werden
|
||
// (PROMPT.md §12).
|
||
func (databaseConfig DatabaseConfig) ConnectionString() string {
|
||
// url.UserPassword maskiert Sonderzeichen in Benutzername und Passwort korrekt.
|
||
connectionURL := url.URL{
|
||
Scheme: "postgres",
|
||
User: url.UserPassword(databaseConfig.User, databaseConfig.password),
|
||
Host: fmt.Sprintf("%s:%d", databaseConfig.Host, databaseConfig.Port),
|
||
Path: "/" + databaseConfig.Name,
|
||
}
|
||
|
||
queryParameters := url.Values{}
|
||
queryParameters.Set("sslmode", databaseConfig.SSLMode)
|
||
queryParameters.Set("connect_timeout", strconv.Itoa(int(databaseConfig.ConnectTimeout.Seconds())))
|
||
connectionURL.RawQuery = queryParameters.Encode()
|
||
|
||
return connectionURL.String()
|
||
}
|
||
|
||
// RedactedConnectionString liefert eine Fassung der DSN ohne Passwort.
|
||
// Nur diese Variante darf in Logs und Fehlermeldungen erscheinen.
|
||
func (databaseConfig DatabaseConfig) RedactedConnectionString() string {
|
||
return fmt.Sprintf("postgres://%s:***@%s:%d/%s?sslmode=%s",
|
||
databaseConfig.User,
|
||
databaseConfig.Host,
|
||
databaseConfig.Port,
|
||
databaseConfig.Name,
|
||
databaseConfig.SSLMode,
|
||
)
|
||
}
|
||
|
||
// Load liest die Konfiguration aus der Umgebung und validiert sie vollständig.
|
||
//
|
||
// Fehlende oder unplausible Werte führen zu einem Fehler statt zu einem stillen
|
||
// Default — ein Dienst darf nie mit unklarer Sicherheitslage starten (PROMPT.md §140).
|
||
func Load(serviceName string) (Config, error) {
|
||
// validationErrors sammelt alle Probleme, damit der Betreiber sie in einem
|
||
// Durchgang beheben kann statt Fehler einzeln zu entdecken.
|
||
var validationErrors []error
|
||
|
||
environment, environmentError := loadEnvironment()
|
||
if environmentError != nil {
|
||
validationErrors = append(validationErrors, environmentError)
|
||
}
|
||
|
||
databaseConfig, databaseErrors := loadDatabaseConfig()
|
||
validationErrors = append(validationErrors, databaseErrors...)
|
||
|
||
httpConfig, httpErrors := loadHTTPConfig(environment)
|
||
validationErrors = append(validationErrors, httpErrors...)
|
||
|
||
loggingConfig, loggingErrors := loadLoggingConfig(environment)
|
||
validationErrors = append(validationErrors, loggingErrors...)
|
||
|
||
authConfig, authErrors := loadAuthConfig()
|
||
validationErrors = append(validationErrors, authErrors...)
|
||
|
||
encryptionConfig, encryptionErrors := loadEncryptionConfig()
|
||
validationErrors = append(validationErrors, encryptionErrors...)
|
||
|
||
if len(validationErrors) > 0 {
|
||
return Config{}, fmt.Errorf("konfiguration ungültig: %w", errors.Join(validationErrors...))
|
||
}
|
||
|
||
return Config{
|
||
Environment: environment,
|
||
ServiceName: serviceName,
|
||
HTTP: httpConfig,
|
||
Database: databaseConfig,
|
||
Logging: loggingConfig,
|
||
Auth: authConfig,
|
||
Encryption: encryptionConfig,
|
||
Hardening: loadHardeningConfig(),
|
||
}, nil
|
||
}
|
||
|
||
// loadHardeningConfig liest die Schutzmaßnahmen aus der Umgebung.
|
||
//
|
||
// Beide Werte sind so gewählt, dass die sichere Einstellung die Vorgabe ist:
|
||
// Wer nichts konfiguriert, bekommt den Schutz. Ein Schalter, den man setzen
|
||
// muss, um sicher zu sein, wird vergessen.
|
||
func loadHardeningConfig() HardeningConfig {
|
||
return HardeningConfig{
|
||
AllowInternalNotificationTargets: lookupBool("ALLOW_INTERNAL_NOTIFICATION_TARGETS", false),
|
||
RestoreAllowedRoots: lookupStringSlice("RESTORE_ALLOWED_ROOTS"),
|
||
}
|
||
}
|
||
|
||
// loadAuthConfig liest die Parameter für Anmeldung und Sitzungen.
|
||
func loadAuthConfig() (AuthConfig, []error) {
|
||
var validationErrors []error
|
||
|
||
// Kurze Zugriffstoken begrenzen den Schaden eines Diebstahls.
|
||
accessTokenTTL, accessTTLError := lookupDuration("AUTH_ACCESS_TOKEN_TTL", 15*time.Minute)
|
||
if accessTTLError != nil {
|
||
validationErrors = append(validationErrors, accessTTLError)
|
||
}
|
||
|
||
refreshTokenTTL, refreshTTLError := lookupDuration("AUTH_REFRESH_TOKEN_TTL", 12*time.Hour)
|
||
if refreshTTLError != nil {
|
||
validationErrors = append(validationErrors, refreshTTLError)
|
||
}
|
||
|
||
// Eine offene MFA-Herausforderung ist ein halb geöffnetes Tor und läuft
|
||
// deshalb schnell ab.
|
||
mfaChallengeTTL, mfaTTLError := lookupDuration("AUTH_MFA_CHALLENGE_TTL", 5*time.Minute)
|
||
if mfaTTLError != nil {
|
||
validationErrors = append(validationErrors, mfaTTLError)
|
||
}
|
||
|
||
maxFailedLoginAttempts, failedLoginsError := lookupInt("AUTH_MAX_FAILED_LOGIN_ATTEMPTS", 5)
|
||
if failedLoginsError != nil {
|
||
validationErrors = append(validationErrors, failedLoginsError)
|
||
}
|
||
|
||
lockoutDuration, lockoutError := lookupDuration("AUTH_LOCKOUT_DURATION", 15*time.Minute)
|
||
if lockoutError != nil {
|
||
validationErrors = append(validationErrors, lockoutError)
|
||
}
|
||
|
||
maxMFAAttempts, mfaAttemptsError := lookupInt("AUTH_MAX_MFA_ATTEMPTS", 5)
|
||
if mfaAttemptsError != nil {
|
||
validationErrors = append(validationErrors, mfaAttemptsError)
|
||
}
|
||
|
||
// Ein Zugriffstoken, das länger gilt als sein Erneuerungstoken, ergibt keinen Sinn.
|
||
if accessTTLError == nil && refreshTTLError == nil && accessTokenTTL > refreshTokenTTL {
|
||
validationErrors = append(validationErrors, fmt.Errorf(
|
||
"%sAUTH_ACCESS_TOKEN_TTL (%s) darf nicht länger sein als %sAUTH_REFRESH_TOKEN_TTL (%s)",
|
||
envPrefix, accessTokenTTL, envPrefix, refreshTokenTTL))
|
||
}
|
||
|
||
return AuthConfig{
|
||
AccessTokenTTL: accessTokenTTL,
|
||
RefreshTokenTTL: refreshTokenTTL,
|
||
MFAChallengeTTL: mfaChallengeTTL,
|
||
MaxFailedLoginAttempts: maxFailedLoginAttempts,
|
||
LockoutDuration: lockoutDuration,
|
||
MaxMFAAttempts: maxMFAAttempts,
|
||
RequireMFAForPrivilegedUsers: lookupBool("AUTH_REQUIRE_MFA_FOR_PRIVILEGED_USERS", true),
|
||
}, validationErrors
|
||
}
|
||
|
||
// loadEncryptionConfig liest das Schlüsselmaterial für die Secret-Ablage.
|
||
func loadEncryptionConfig() (EncryptionConfig, []error) {
|
||
// Ohne Schlüssel liessen sich MFA-Secrets und später Repository-Zugangsdaten
|
||
// nicht verschlüsselt ablegen. Es gibt deshalb bewusst keinen Standardwert.
|
||
rawKeySet := os.Getenv(envPrefix + "ENCRYPTION_KEYS")
|
||
if strings.TrimSpace(rawKeySet) == "" {
|
||
return EncryptionConfig{}, []error{fmt.Errorf(
|
||
"%sENCRYPTION_KEYS: erforderlich (Format: v1:<base64-32-byte>). "+
|
||
"Einen neuen Schlüssel erzeugt 'make generate-key'", envPrefix)}
|
||
}
|
||
|
||
parsedKeys, parseError := parseEncryptionKeySet(rawKeySet)
|
||
if parseError != nil {
|
||
return EncryptionConfig{}, []error{parseError}
|
||
}
|
||
|
||
// Ohne ausdrückliche Angabe wird verschlüsselt, wenn genau ein Schlüssel
|
||
// vorliegt; bei mehreren muss die aktuelle Version benannt werden.
|
||
currentKeyVersion := lookupString("ENCRYPTION_CURRENT_KEY", "")
|
||
if currentKeyVersion == "" {
|
||
if len(parsedKeys) != 1 {
|
||
return EncryptionConfig{}, []error{fmt.Errorf(
|
||
"%sENCRYPTION_CURRENT_KEY: erforderlich, sobald mehrere Schlüssel hinterlegt sind", envPrefix)}
|
||
}
|
||
|
||
for keyVersion := range parsedKeys {
|
||
currentKeyVersion = keyVersion
|
||
}
|
||
}
|
||
|
||
if _, hasCurrentKey := parsedKeys[currentKeyVersion]; !hasCurrentKey {
|
||
return EncryptionConfig{}, []error{fmt.Errorf(
|
||
"%sENCRYPTION_CURRENT_KEY: die Version %q kommt in %sENCRYPTION_KEYS nicht vor",
|
||
envPrefix, currentKeyVersion, envPrefix)}
|
||
}
|
||
|
||
return EncryptionConfig{keysByVersion: parsedKeys, CurrentKeyVersion: currentKeyVersion}, nil
|
||
}
|
||
|
||
// parseEncryptionKeySet zerlegt die Schlüsselliste aus der Umgebung.
|
||
//
|
||
// Die Prüfung liegt bewusst hier und nicht im crypto-Paket, damit die
|
||
// Konfiguration vollständig validiert ist, bevor irgendein Dienst startet.
|
||
func parseEncryptionKeySet(rawKeySet string) (map[string][]byte, error) {
|
||
// Ein AES-256-Schlüssel ist genau 32 Byte lang.
|
||
const requiredKeyLength = 32
|
||
|
||
parsedKeys := make(map[string][]byte)
|
||
|
||
for _, keyEntry := range strings.Split(rawKeySet, ",") {
|
||
trimmedEntry := strings.TrimSpace(keyEntry)
|
||
if trimmedEntry == "" {
|
||
continue
|
||
}
|
||
|
||
keyVersion, encodedKey, hasSeparator := strings.Cut(trimmedEntry, ":")
|
||
if !hasSeparator || strings.TrimSpace(keyVersion) == "" {
|
||
return nil, fmt.Errorf("%sENCRYPTION_KEYS: jeder Eintrag muss die Form version:base64schlüssel haben", envPrefix)
|
||
}
|
||
|
||
keyMaterial, decodeError := base64.StdEncoding.DecodeString(strings.TrimSpace(encodedKey))
|
||
if decodeError != nil {
|
||
// Der Wert selbst wird nicht ausgegeben: er ist ein Geheimnis.
|
||
return nil, fmt.Errorf("%sENCRYPTION_KEYS: der Schlüssel der Version %q ist kein gültiges base64",
|
||
envPrefix, strings.TrimSpace(keyVersion))
|
||
}
|
||
|
||
if len(keyMaterial) != requiredKeyLength {
|
||
return nil, fmt.Errorf("%sENCRYPTION_KEYS: der Schlüssel der Version %q hat %d Byte, erforderlich sind %d",
|
||
envPrefix, strings.TrimSpace(keyVersion), len(keyMaterial), requiredKeyLength)
|
||
}
|
||
|
||
parsedKeys[strings.TrimSpace(keyVersion)] = keyMaterial
|
||
}
|
||
|
||
if len(parsedKeys) == 0 {
|
||
return nil, fmt.Errorf("%sENCRYPTION_KEYS: es wurde kein gültiger Schlüssel gefunden", envPrefix)
|
||
}
|
||
|
||
return parsedKeys, nil
|
||
}
|
||
|
||
// lookupBool liest einen Schalter mit Rückfallwert.
|
||
//
|
||
// Unbekannte Werte liefern den Rückfallwert: bei sicherheitsrelevanten Schaltern
|
||
// ist das der sichere Zustand, nicht der abgeschaltete.
|
||
func lookupBool(variableName string, defaultValue bool) bool {
|
||
rawValue := strings.ToLower(lookupString(variableName, ""))
|
||
switch rawValue {
|
||
case "true", "1", "yes", "ja":
|
||
return true
|
||
case "false", "0", "no", "nein":
|
||
return false
|
||
default:
|
||
return defaultValue
|
||
}
|
||
}
|
||
|
||
// loadEnvironment liest und prüft die Betriebsumgebung.
|
||
func loadEnvironment() (Environment, error) {
|
||
// Ohne explizite Angabe wird Entwicklung angenommen: Produktion muss bewusst
|
||
// gewählt werden, damit niemand versehentlich Demo-Verhalten produktiv nutzt.
|
||
rawEnvironment := lookupString("ENV", string(EnvironmentDevelopment))
|
||
|
||
environment := Environment(strings.ToLower(strings.TrimSpace(rawEnvironment)))
|
||
switch environment {
|
||
case EnvironmentDevelopment, EnvironmentTest, EnvironmentDemo, EnvironmentProduction:
|
||
return environment, nil
|
||
default:
|
||
return "", fmt.Errorf("%sENV: unbekannte Umgebung %q (erlaubt: development, test, demo, production)", envPrefix, rawEnvironment)
|
||
}
|
||
}
|
||
|
||
// loadDatabaseConfig liest die Datenbankparameter und gibt alle Validierungsfehler zurück.
|
||
func loadDatabaseConfig() (DatabaseConfig, []error) {
|
||
var validationErrors []error
|
||
|
||
databasePort, portError := lookupInt("DB_PORT", 5432)
|
||
if portError != nil {
|
||
validationErrors = append(validationErrors, portError)
|
||
}
|
||
|
||
maxOpenConnections, connectionsError := lookupInt("DB_MAX_OPEN_CONNECTIONS", 20)
|
||
if connectionsError != nil {
|
||
validationErrors = append(validationErrors, connectionsError)
|
||
}
|
||
|
||
connectTimeout, connectTimeoutError := lookupDuration("DB_CONNECT_TIMEOUT", 10*time.Second)
|
||
if connectTimeoutError != nil {
|
||
validationErrors = append(validationErrors, connectTimeoutError)
|
||
}
|
||
|
||
idleTime, idleTimeError := lookupDuration("DB_MAX_CONNECTION_IDLE_TIME", 5*time.Minute)
|
||
if idleTimeError != nil {
|
||
validationErrors = append(validationErrors, idleTimeError)
|
||
}
|
||
|
||
// Das Passwort besitzt bewusst keinen Default. Ein leeres Passwort wäre eine
|
||
// stillschweigend unsichere Konfiguration.
|
||
databasePassword := os.Getenv(envPrefix + "DB_PASSWORD")
|
||
if databasePassword == "" {
|
||
validationErrors = append(validationErrors, fmt.Errorf("%sDB_PASSWORD: erforderlich, es gibt bewusst keinen Standardwert", envPrefix))
|
||
}
|
||
|
||
databaseUser := lookupString("DB_USER", "")
|
||
if databaseUser == "" {
|
||
validationErrors = append(validationErrors, fmt.Errorf("%sDB_USER: erforderlich", envPrefix))
|
||
}
|
||
|
||
databaseName := lookupString("DB_NAME", "")
|
||
if databaseName == "" {
|
||
validationErrors = append(validationErrors, fmt.Errorf("%sDB_NAME: erforderlich", envPrefix))
|
||
}
|
||
|
||
sslMode := lookupString("DB_SSLMODE", "prefer")
|
||
if validSSLModeError := validateSSLMode(sslMode); validSSLModeError != nil {
|
||
validationErrors = append(validationErrors, validSSLModeError)
|
||
}
|
||
|
||
return DatabaseConfig{
|
||
Host: lookupString("DB_HOST", "127.0.0.1"),
|
||
Port: databasePort,
|
||
Name: databaseName,
|
||
User: databaseUser,
|
||
password: databasePassword,
|
||
SSLMode: sslMode,
|
||
MaxOpenConnections: int32(maxOpenConnections),
|
||
MaxConnectionIdleTime: idleTime,
|
||
ConnectTimeout: connectTimeout,
|
||
}, validationErrors
|
||
}
|
||
|
||
// validateSSLMode prüft den TLS-Modus der Datenbankverbindung.
|
||
func validateSSLMode(sslMode string) error {
|
||
// Die Liste entspricht den von libpq unterstützten Modi.
|
||
switch sslMode {
|
||
case "disable", "allow", "prefer", "require", "verify-ca", "verify-full":
|
||
return nil
|
||
default:
|
||
return fmt.Errorf("%sDB_SSLMODE: unbekannter Wert %q", envPrefix, sslMode)
|
||
}
|
||
}
|
||
|
||
// loadHTTPConfig liest die HTTP-Server-Parameter.
|
||
func loadHTTPConfig(environment Environment) (HTTPConfig, []error) {
|
||
var validationErrors []error
|
||
|
||
readHeaderTimeout, readHeaderTimeoutError := lookupDuration("HTTP_READ_HEADER_TIMEOUT", 10*time.Second)
|
||
if readHeaderTimeoutError != nil {
|
||
validationErrors = append(validationErrors, readHeaderTimeoutError)
|
||
}
|
||
|
||
readTimeout, readTimeoutError := lookupDuration("HTTP_READ_TIMEOUT", 30*time.Second)
|
||
if readTimeoutError != nil {
|
||
validationErrors = append(validationErrors, readTimeoutError)
|
||
}
|
||
|
||
writeTimeout, writeTimeoutError := lookupDuration("HTTP_WRITE_TIMEOUT", 60*time.Second)
|
||
if writeTimeoutError != nil {
|
||
validationErrors = append(validationErrors, writeTimeoutError)
|
||
}
|
||
|
||
idleTimeout, idleTimeoutError := lookupDuration("HTTP_IDLE_TIMEOUT", 120*time.Second)
|
||
if idleTimeoutError != nil {
|
||
validationErrors = append(validationErrors, idleTimeoutError)
|
||
}
|
||
|
||
shutdownTimeout, shutdownTimeoutError := lookupDuration("HTTP_SHUTDOWN_TIMEOUT", 20*time.Second)
|
||
if shutdownTimeoutError != nil {
|
||
validationErrors = append(validationErrors, shutdownTimeoutError)
|
||
}
|
||
|
||
// 1 MiB reicht für Konfigurations-Requests weit aus; Backup-Nutzdaten laufen
|
||
// niemals über diese JSON-API.
|
||
maxRequestBodyBytes, bodyLimitError := lookupInt("HTTP_MAX_REQUEST_BODY_BYTES", 1<<20)
|
||
if bodyLimitError != nil {
|
||
validationErrors = append(validationErrors, bodyLimitError)
|
||
}
|
||
|
||
// CORS ist standardmäßig leer: Cross-Origin-Zugriff muss ausdrücklich erlaubt werden.
|
||
allowedOrigins := lookupStringSlice("HTTP_ALLOWED_ORIGINS")
|
||
if originsError := validateAllowedOrigins(allowedOrigins, environment); originsError != nil {
|
||
validationErrors = append(validationErrors, originsError)
|
||
}
|
||
|
||
// 600 Anfragen je Minute: Eine Oberfläche mit offener Übersicht kommt auf
|
||
// wenige je Sekunde. Wer diese Grenze reisst, ruft nicht mehr ab, sondern
|
||
// misst aus.
|
||
requestsPerMinute, rateLimitError := lookupInt("HTTP_REQUESTS_PER_MINUTE", 600)
|
||
if rateLimitError != nil {
|
||
validationErrors = append(validationErrors, rateLimitError)
|
||
}
|
||
|
||
tlsCertificateFile := lookupString("HTTP_TLS_CERT_FILE", "")
|
||
tlsPrivateKeyFile := lookupString("HTTP_TLS_KEY_FILE", "")
|
||
|
||
// Eine halbe TLS-Konfiguration ist gefährlicher als keine: Der Dienst
|
||
// startete im Klartext, obwohl der Betreiber Verschlüsselung eingerichtet
|
||
// zu haben glaubt.
|
||
if (tlsCertificateFile == "") != (tlsPrivateKeyFile == "") {
|
||
validationErrors = append(validationErrors,
|
||
fmt.Errorf("HTTP_TLS_CERT_FILE und HTTP_TLS_KEY_FILE müssen gemeinsam gesetzt werden"))
|
||
}
|
||
|
||
return HTTPConfig{
|
||
ListenAddress: lookupString("HTTP_LISTEN_ADDRESS", "127.0.0.1:8080"),
|
||
TLSCertificateFile: tlsCertificateFile,
|
||
TLSPrivateKeyFile: tlsPrivateKeyFile,
|
||
RequestsPerMinute: requestsPerMinute,
|
||
ReadHeaderTimeout: readHeaderTimeout,
|
||
ReadTimeout: readTimeout,
|
||
WriteTimeout: writeTimeout,
|
||
IdleTimeout: idleTimeout,
|
||
ShutdownTimeout: shutdownTimeout,
|
||
MaxRequestBodyBytes: int64(maxRequestBodyBytes),
|
||
AllowedOrigins: allowedOrigins,
|
||
}, validationErrors
|
||
}
|
||
|
||
// validateAllowedOrigins verhindert eine unsichere CORS-Konfiguration.
|
||
func validateAllowedOrigins(allowedOrigins []string, environment Environment) error {
|
||
for _, allowedOrigin := range allowedOrigins {
|
||
// Ein Wildcard-Origin würde jede fremde Seite auf die API zugreifen lassen.
|
||
if allowedOrigin == "*" {
|
||
return fmt.Errorf("%sHTTP_ALLOWED_ORIGINS: %q ist nicht zulässig, Herkünfte müssen explizit benannt werden", envPrefix, allowedOrigin)
|
||
}
|
||
|
||
parsedOrigin, parseError := url.Parse(allowedOrigin)
|
||
if parseError != nil || parsedOrigin.Scheme == "" || parsedOrigin.Host == "" {
|
||
return fmt.Errorf("%sHTTP_ALLOWED_ORIGINS: %q ist keine gültige Herkunft (erwartet z. B. https://syncova.example.local)", envPrefix, allowedOrigin)
|
||
}
|
||
|
||
// Unverschlüsselte Herkünfte sind nur lokal vertretbar.
|
||
if environment.IsProduction() && parsedOrigin.Scheme != "https" {
|
||
return fmt.Errorf("%sHTTP_ALLOWED_ORIGINS: %q verwendet kein HTTPS und ist in der Produktion nicht zulässig", envPrefix, allowedOrigin)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// loadLoggingConfig liest die Logging-Parameter.
|
||
func loadLoggingConfig(environment Environment) (LoggingConfig, []error) {
|
||
var validationErrors []error
|
||
|
||
logLevel := strings.ToLower(lookupString("LOG_LEVEL", "info"))
|
||
switch logLevel {
|
||
case "debug", "info", "warn", "error":
|
||
default:
|
||
validationErrors = append(validationErrors, fmt.Errorf("%sLOG_LEVEL: unbekannter Wert %q (erlaubt: debug, info, warn, error)", envPrefix, logLevel))
|
||
}
|
||
|
||
// Maschinenlesbares JSON ist der Standard; nur lokal ist Text bequemer.
|
||
defaultLogFormat := "json"
|
||
if environment == EnvironmentDevelopment {
|
||
defaultLogFormat = "text"
|
||
}
|
||
|
||
logFormat := strings.ToLower(lookupString("LOG_FORMAT", defaultLogFormat))
|
||
switch logFormat {
|
||
case "json", "text":
|
||
default:
|
||
validationErrors = append(validationErrors, fmt.Errorf("%sLOG_FORMAT: unbekannter Wert %q (erlaubt: json, text)", envPrefix, logFormat))
|
||
}
|
||
|
||
return LoggingConfig{Level: logLevel, Format: logFormat}, validationErrors
|
||
}
|
||
|
||
// lookupString liest eine Zeichenketten-Variable mit Rückfallwert.
|
||
func lookupString(variableName string, defaultValue string) string {
|
||
rawValue, isPresent := os.LookupEnv(envPrefix + variableName)
|
||
if !isPresent || strings.TrimSpace(rawValue) == "" {
|
||
return defaultValue
|
||
}
|
||
|
||
return strings.TrimSpace(rawValue)
|
||
}
|
||
|
||
// lookupStringSlice liest eine kommaseparierte Liste und entfernt Leereinträge.
|
||
func lookupStringSlice(variableName string) []string {
|
||
rawValue := lookupString(variableName, "")
|
||
if rawValue == "" {
|
||
return nil
|
||
}
|
||
|
||
var listEntries []string
|
||
for _, rawEntry := range strings.Split(rawValue, ",") {
|
||
trimmedEntry := strings.TrimSpace(rawEntry)
|
||
if trimmedEntry != "" {
|
||
listEntries = append(listEntries, trimmedEntry)
|
||
}
|
||
}
|
||
|
||
return listEntries
|
||
}
|
||
|
||
// lookupInt liest eine positive Ganzzahl mit Rückfallwert.
|
||
func lookupInt(variableName string, defaultValue int) (int, error) {
|
||
rawValue := lookupString(variableName, "")
|
||
if rawValue == "" {
|
||
return defaultValue, nil
|
||
}
|
||
|
||
parsedValue, parseError := strconv.Atoi(rawValue)
|
||
if parseError != nil {
|
||
return 0, fmt.Errorf("%s%s: %q ist keine ganze Zahl", envPrefix, variableName, rawValue)
|
||
}
|
||
|
||
// Null oder negative Werte würden Limits stillschweigend aufheben.
|
||
if parsedValue <= 0 {
|
||
return 0, fmt.Errorf("%s%s: %d muss größer als 0 sein", envPrefix, variableName, parsedValue)
|
||
}
|
||
|
||
return parsedValue, nil
|
||
}
|
||
|
||
// lookupDuration liest eine Zeitdauer (z. B. "30s", "5m") mit Rückfallwert.
|
||
func lookupDuration(variableName string, defaultValue time.Duration) (time.Duration, error) {
|
||
rawValue := lookupString(variableName, "")
|
||
if rawValue == "" {
|
||
return defaultValue, nil
|
||
}
|
||
|
||
parsedDuration, parseError := time.ParseDuration(rawValue)
|
||
if parseError != nil {
|
||
return 0, fmt.Errorf("%s%s: %q ist keine gültige Zeitdauer (z. B. 30s, 5m)", envPrefix, variableName, rawValue)
|
||
}
|
||
|
||
if parsedDuration <= 0 {
|
||
return 0, fmt.Errorf("%s%s: %s muss größer als 0 sein", envPrefix, variableName, parsedDuration)
|
||
}
|
||
|
||
return parsedDuration, nil
|
||
}
|