syncova-backup/packages/platform/config/config.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
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>
2026-08-17 09:10:54 +02:00

721 lines
28 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}