// 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:). "+ "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 }