//! Konfiguration aus einer `config.toml`-Datei. //! //! [`load`] liest die TOML-Datei (Pfad via `HOLZLEITNER_CONFIG`-Env //! überschreibbar, sonst `config.toml` im Arbeitsverzeichnis) und //! deserialisiert sie über `serde` in [`Config`]. Die Struktur ist nach //! Bereichen in TOML-Sections gruppiert (`[server]`, `[database]`, //! `[keycloak]`, …); fehlende optionale Sections/Felder fallen auf die //! `#[serde(default = …)]`-Werte zurück. //! //! Die Vorlage liegt in `config.example.toml`; für lokale Entwicklung //! `cp config.example.toml config.toml` und Werte anpassen. Die echte //! `config.toml` enthält Secrets und gehört nicht in Git. use std::path::PathBuf; use chrono::NaiveDate; use serde::Deserialize; /// Env-Variable, mit der der Pfad zur Config-Datei überschrieben werden /// kann (z. B. im Deployment). Ohne sie wird `config.toml` im /// Arbeitsverzeichnis erwartet. const CONFIG_PATH_ENV: &str = "HOLZLEITNER_CONFIG"; const DEFAULT_CONFIG_PATH: &str = "config.toml"; #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { pub server: ServerConfig, pub database: DatabaseConfig, #[allow(dead_code)] // wird in der Keycloak-Phase verdrahtet pub keycloak: KeycloakConfig, pub gsd: GsdConfig, #[serde(default)] pub signature: SignatureConfig, #[serde(default)] pub attachment: AttachmentConfig, #[serde(default)] pub report: ReportConfig, #[serde(default)] pub erp: ErpConfig, #[serde(default)] pub import: ImportConfig, /// DEV-ONLY-Schalter (`today_override`, `sync_enabled`). #[serde(default)] pub dev: DevConfig, /// Admin-API-Key-Gate auf `/admin`. #[serde(default)] pub admin: AdminConfig, /// Log-Filter (Override via `RUST_LOG`-Env hat Vorrang, siehe main.rs). #[serde(default)] pub logging: LoggingConfig, } impl Config { /// Loggt eine Zusammenfassung der geladenen Konfiguration beim Start. /// Secrets (Passwörter, Client-Secrets, Hashes) werden **maskiert** — /// nur „gesetzt/leer" ist sichtbar, nie der Klartext. Hilft, beim /// Binary-Start auf einen Blick Fehlkonfigurationen zu erkennen (z. B. /// einen aktiven `today_override`). pub fn log_summary(&self) { tracing::info!("════════════ Konfiguration (Start) ════════════"); tracing::info!(host = %self.server.host, port = self.server.port, "cfg.server"); tracing::info!( url = %mask_db_url(&self.database.url), max_connections = self.database.max_connections, "cfg.database" ); tracing::info!( issuer_url = %self.keycloak.issuer_url, audience = %self.keycloak.audience, realm = %self.keycloak.realm, admin_url = %display_or_unset(&self.keycloak.admin_url), provisioning_enabled = self.keycloak.provisioning_enabled, provisioner_client_id = %display_or_unset(&self.keycloak.provisioner_client_id), provisioner_client_secret = %mask(&self.keycloak.provisioner_client_secret), driver_role = %self.keycloak.driver_role, driver_default_password = %mask(&self.keycloak.driver_default_password), jwks_cache_ttl_seconds = self.keycloak.jwks_cache_ttl_seconds, "cfg.keycloak" ); tracing::info!( host = %display_or_unset(&self.erp.host), port = self.erp.port, database = %display_or_unset(&self.erp.database), user = %display_or_unset(&self.erp.user), password = %mask(&self.erp.password), trust_cert = self.erp.trust_cert, writeback_enabled = self.erp.writeback_enabled, "cfg.erp" ); tracing::info!( enabled = self.import.enabled, cron = %self.import.cron, date_offset_days = self.import.date_offset_days, "cfg.import (scheduler)" ); tracing::info!( rest_url = %self.gsd.rest_url, user = %self.gsd.user, app_key = %mask(&self.gsd.app_key), password_md5 = %mask(&self.gsd.password_md5), app_names = ?self.gsd.app_names, "cfg.gsd" ); tracing::info!(storage_dir = %self.signature.storage_dir, "cfg.signature"); tracing::info!(storage_dir = %self.attachment.storage_dir, "cfg.attachment (Bild-Notizen lokal)"); tracing::info!( storage_dir = %self.report.storage_dir, upload_enabled = self.report.upload_enabled, retry_cron = %self.report.retry_cron, "cfg.report (PDF-Report → DOCUframe)" ); if self.report.upload_enabled { tracing::warn!("report.upload_enabled=true: Reports werden an DOCUframe übertragen + lokale Dateien danach gelöscht"); } match &self.dev.today_override { Some(date) => tracing::warn!( today_override = %date, "cfg.DEV: dev.today_override AKTIV — 'heute' ist überschrieben! \ In Produktion entfernen." ), None => tracing::info!("cfg.dev.today_override = (echte Uhr)"), } if self.dev.sync_enabled { tracing::warn!( "cfg.DEV: dev.sync_enabled AKTIV — ungeschützter Endpoint \ POST /dev/resync löscht Postgres-Tourdaten! In Produktion aus." ); } tracing::info!( admin_api_key = %mask(&self.admin.api_key), "cfg.admin (X-Admin-Api-Key Gate auf /admin)" ); if self.admin.api_key.is_empty() { tracing::warn!( "cfg.admin: admin.api_key LEER — alle /admin-Endpunkte sind \ GESPERRT (fail-closed). Zum Aktivieren admin.api_key setzen." ); } tracing::info!("═══════════════════════════════════════════════"); } } /// Maskiert ein Secret: zeigt nur, ob gesetzt — nie den Klartext. fn mask(secret: &str) -> &'static str { if secret.is_empty() { "(leer)" } else { "***gesetzt***" } } /// Zeigt den Wert oder `(leer)` für optionale, nicht-geheime Felder. fn display_or_unset(value: &str) -> String { if value.is_empty() { "(leer)".to_string() } else { value.to_string() } } /// Maskiert das Passwort in einer DB-URL: `scheme://user:pass@host` → /// `scheme://user:***@host`. Lässt alles andere unverändert. fn mask_db_url(url: &str) -> String { let Some(scheme_end) = url.find("://") else { return url.to_string(); }; let (scheme, rest) = url.split_at(scheme_end + 3); let Some(at) = rest.find('@') else { return url.to_string(); }; let (creds, host) = rest.split_at(at); // host beginnt mit '@' let Some(colon) = creds.find(':') else { return url.to_string(); }; let user = &creds[..colon]; format!("{scheme}{user}:***{host}") } /// Lese-Zugang zur ERPframe-MS-SQL-DB (für den täglichen Touren-Pull). /// Alle Felder optional defaultet — der Server bootet auch ohne ERP-Config, /// solange `import.enabled = false` (Default) ist. #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct ErpConfig { #[serde(default)] pub host: String, #[serde(default = "default_mssql_port")] pub port: u16, #[serde(default)] pub database: String, #[serde(default)] pub user: String, #[serde(default)] pub password: String, /// Selbstsigniertes Server-Zertifikat akzeptieren (Intranet-DB). #[serde(default = "default_true")] pub trust_cert: bool, /// ERP-**Rückschreiben** beim Lieferabschluss aktiv? Default `false` — /// dann bleibt der Abschluss rein lokal (Dev/Seed ohne ERP-Beleg). #[serde(default)] pub writeback_enabled: bool, } impl Default for ErpConfig { fn default() -> Self { Self { host: String::new(), port: default_mssql_port(), database: String::new(), user: String::new(), password: String::new(), trust_cert: default_true(), writeback_enabled: false, } } } fn default_mssql_port() -> u16 { 1433 } fn default_true() -> bool { true } /// Steuert den geplanten ERP-Import. #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct ImportConfig { /// Default `false` — der Scheduler startet nur, wenn explizit aktiviert. #[serde(default)] pub enabled: bool, /// Cron-Ausdruck (tokio-cron-scheduler, 6-stellig inkl. Sekunden). /// Default: täglich 03:00:00. #[serde(default = "default_import_cron")] pub cron: String, /// Versatz in Tagen relativ zu „heute" für das Ziel-Tourdatum. /// Default `1` = morgen. #[serde(default = "default_import_offset")] pub date_offset_days: i64, } impl Default for ImportConfig { fn default() -> Self { Self { enabled: false, cron: default_import_cron(), date_offset_days: default_import_offset(), } } } fn default_import_cron() -> String { "0 0 3 * * *".to_string() } fn default_import_offset() -> i64 { 1 } /// Lokaler Speicher für Unterschriften-PNGs (Kunde + Fahrer). Bewusst NICHT /// DOCUframe — die Bilder bleiben auf der Backend-Maschine. #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct SignatureConfig { /// Basis-Verzeichnis für die PNG-Dateien (wird beim Start angelegt). #[serde(default = "default_signature_dir")] pub storage_dir: String, } impl Default for SignatureConfig { fn default() -> Self { Self { storage_dir: default_signature_dir(), } } } fn default_signature_dir() -> String { "./data/signatures".to_string() } /// Lokaler Speicher für hochgeladene Bild-Notizen. Statt DOCUframe landen die /// Bilder pro Belegnummer in einem Unterordner (`//…`). #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct AttachmentConfig { /// Basis-Verzeichnis (wird beim Start angelegt). #[serde(default = "default_attachment_dir")] pub storage_dir: String, } impl Default for AttachmentConfig { fn default() -> Self { Self { storage_dir: default_attachment_dir(), } } } fn default_attachment_dir() -> String { "./data/attachments".to_string() } /// Lokaler (temporärer) Speicher für die PDF-Lieferreports. Später wird der /// Blob direkt an ein DOCUframe-Makro gesendet (Sink-Stub vorhanden). #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct ReportConfig { /// Basis-Verzeichnis (wird beim Start angelegt). #[serde(default = "default_report_dir")] pub storage_dir: String, /// Schaltet die DOCUframe-Übertragung des Reports beim Abschluss + den /// Retry-Cron frei. Aus = nur lokale Ablage (Dev / DOCUframe nicht erreichbar). #[serde(default)] pub upload_enabled: bool, /// Cron-Ausdruck (6-stellig, mit Sekunden) für den Retry der offenen /// Report-Jobs. Default: alle 5 Minuten. #[serde(default = "default_report_retry_cron")] pub retry_cron: String, } impl Default for ReportConfig { fn default() -> Self { Self { storage_dir: default_report_dir(), upload_enabled: false, retry_cron: default_report_retry_cron(), } } } fn default_report_dir() -> String { "./data/reports".to_string() } fn default_report_retry_cron() -> String { "0 */5 * * * *".to_string() } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct ServerConfig { pub host: String, pub port: u16, } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct DatabaseConfig { pub url: String, #[serde(default = "default_max_connections")] pub max_connections: u32, } fn default_max_connections() -> u32 { 10 } /// Felder werden in der Keycloak-Phase angefasst — bis dahin Dead-Code- /// Warnings unterdrücken. #[allow(dead_code)] #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct KeycloakConfig { pub issuer_url: String, pub audience: String, #[serde(default = "default_jwks_cache_ttl")] pub jwks_cache_ttl_seconds: u64, // --- Provisioning via Admin-API (ERP-Sync legt Fahrer-Konten an) ------ /// Basis-URL der Keycloak-Instanz **ohne** `/realms/...`, z. B. /// `http://localhost:8080`. Leer ⇒ Provisionierung nicht möglich. #[serde(default)] pub admin_url: String, /// Realm-Name. Default `holzleitner`. #[serde(default = "default_realm")] pub realm: String, /// Service-Account-Client (confidential) für die Admin-API. #[serde(default)] pub provisioner_client_id: String, #[serde(default)] pub provisioner_client_secret: String, /// Temporäres Default-Passwort für neu angelegte Fahrer-Konten (muss beim /// ersten Login geändert werden). #[serde(default = "default_driver_password")] pub driver_default_password: String, /// Realm-Rolle, die jedem Fahrer zugewiesen wird. Default `driver`. #[serde(default = "default_driver_role")] pub driver_role: String, /// Konto-Provisionierung beim Sync aktiv? Default `false`. #[serde(default)] pub provisioning_enabled: bool, } fn default_jwks_cache_ttl() -> u64 { 3600 } fn default_realm() -> String { "holzleitner".to_string() } fn default_driver_password() -> String { "Holzleitner-Start1!".to_string() } fn default_driver_role() -> String { "driver".to_string() } /// Anbindung an die GSD/DOCUframe-REST-API (für Datei-Uploads). /// /// Login läuft über einen technischen Service-Account; `password_md5` ist /// bereits der MD5-Hash des Passworts (GSD erwartet `pass` als MD5) — so /// liegt kein Klartext-Secret in der Konfiguration. #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct GsdConfig { /// Basis-URL der GSD-REST-API (ohne abschließenden Slash), z. B. /// `http://192.168.1.9:8334`. pub rest_url: String, pub app_key: String, pub user: String, /// MD5-Hash des Service-Account-Passworts. pub password_md5: String, /// App-Namen als TOML-Array, z. B. `["GSD-RestApi"]`. pub app_names: Vec, } /// DEV-ONLY-Schalter. Beide Felder defaulten auf „aus". #[derive(Debug, Clone, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct DevConfig { /// Überschreibt das serverseitige „heute" für `GET /me/tours/today`. /// Als **quotierter** String `"YYYY-MM-DD"` angeben — ein bloßes /// `2026-06-01` würde TOML als nativen Datums-Typ lesen, den chrono /// nicht direkt akzeptiert. `None`/weglassen = echte Uhr. Nur zum /// Testen mit historischen/importierten Touren. #[serde(default)] pub today_override: Option, /// Schaltet den ungeschützten Dev-Resync-Endpoint (`POST /dev/resync`) /// frei, der Postgres platt macht und neu importiert. Default `false` /// → Endpoint wird gar nicht erst gemountet. #[serde(default)] pub sync_enabled: bool, } /// Admin-API-Key-Gate auf `/admin`. #[derive(Debug, Clone, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct AdminConfig { /// Statischer API-Key für die `/admin`-Endpunkte (Maschinen-Zugang per /// Header `X-Admin-Api-Key`, KEIN Keycloak/JWT). **Leer ⇒ alle /// Admin-Routen sind gesperrt** (fail-closed). In Produktion einen /// hochentropischen Zufallswert setzen, nie committen. #[serde(default)] pub api_key: String, } /// Logging-Filter (tracing-subscriber `EnvFilter`-Syntax). #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct LoggingConfig { /// Default-Filter. Die `RUST_LOG`-Env-Variable hat Vorrang (siehe /// `main.rs`), damit Ad-hoc-Debugging ohne Datei-Edit möglich bleibt. #[serde(default = "default_log_filter")] pub filter: String, } impl Default for LoggingConfig { fn default() -> Self { Self { filter: default_log_filter(), } } } fn default_log_filter() -> String { // WICHTIG: Der Binary-Crate heißt `holzleitner_server` (vom `[[bin]] // name`), NICHT `holzleitner_api` — sonst werden alle Logs aus // main.rs/config.rs (Startup + Config-Übersicht) herausgefiltert. "holzleitner_server=info,holzleitner_api=info,\ holzleitner_application=info,holzleitner_infrastructure=info,\ tower_http=info" .to_string() } /// Pfad zur Config-Datei: `HOLZLEITNER_CONFIG`-Env, sonst `config.toml`. fn config_path() -> PathBuf { match std::env::var(CONFIG_PATH_ENV) { Ok(p) if !p.trim().is_empty() => PathBuf::from(p.trim()), _ => PathBuf::from(DEFAULT_CONFIG_PATH), } } /// Lädt die Konfiguration aus der TOML-Datei. /// /// Reihenfolge: /// 1. Pfad bestimmen (`HOLZLEITNER_CONFIG`-Env oder `config.toml`). /// 2. Datei lesen und mit `toml`/`serde` in [`Config`] deserialisieren. /// Fehlende optionale Sections/Felder fallen auf die `serde`-Defaults. pub fn load() -> Result { let path = config_path(); let raw = std::fs::read_to_string(&path).map_err(|source| ConfigError::Read { path: path.clone(), source, })?; parse(&raw).map_err(|source| ConfigError::Parse { path, source }) } /// Deserialisiert TOML-Text in [`Config`]. Ausgelagert, damit er in Tests /// ohne Dateizugriff aufgerufen werden kann. fn parse(raw: &str) -> Result { toml::from_str::(raw) } #[derive(Debug, thiserror::Error)] pub enum ConfigError { // PathBuf implementiert kein Display → Debug-Format `{path:?}`. #[error("config-Datei {path:?} konnte nicht gelesen werden: {source}")] Read { path: PathBuf, #[source] source: std::io::Error, }, #[error("config-Datei {path:?} ist ungültig: {source}")] Parse { path: PathBuf, #[source] source: toml::de::Error, }, } #[cfg(test)] mod tests { use super::*; /// Minimal-Config mit nur den Pflicht-Sections — optionale Sections /// fallen auf Defaults zurück. #[test] fn parses_minimal_config() { let raw = r#" [server] host = "0.0.0.0" port = 3000 [database] url = "postgres://u:p@localhost/db" [keycloak] issuer_url = "http://localhost:8080/realms/holzleitner" audience = "holzleitner-api" [gsd] rest_url = "http://localhost:8334" app_key = "k" user = "u" password_md5 = "abc" app_names = ["GSD-RestApi"] "#; let cfg = parse(raw).expect("minimal config should parse"); assert_eq!(cfg.server.port, 3000); assert_eq!(cfg.database.max_connections, 10); // default assert_eq!(cfg.report.storage_dir, "./data/reports"); // default section assert!(!cfg.dev.sync_enabled); // default assert!(cfg.dev.today_override.is_none()); assert!(cfg.admin.api_key.is_empty()); assert_eq!(cfg.erp.port, 1433); // default } /// `today_override` wird als `YYYY-MM-DD` geparst. #[test] fn parses_today_override_and_arrays() { let raw = r#" [server] host = "0.0.0.0" port = 3000 [database] url = "postgres://u:p@localhost/db" [keycloak] issuer_url = "iss" audience = "aud" [gsd] rest_url = "http://localhost:8334" app_key = "k" user = "u" password_md5 = "abc" app_names = ["A", "B"] [dev] today_override = "2026-06-01" sync_enabled = true "#; let cfg = parse(raw).expect("config should parse"); assert_eq!( cfg.dev.today_override, Some(NaiveDate::from_ymd_opt(2026, 6, 1).unwrap()) ); assert!(cfg.dev.sync_enabled); assert_eq!(cfg.gsd.app_names, vec!["A".to_string(), "B".to_string()]); } /// Unbekannte Schlüssel werden abgewiesen — schützt vor Tippfehlern. #[test] fn rejects_unknown_keys() { let raw = r#" [server] host = "0.0.0.0" port = 3000 bogus = true [database] url = "postgres://u:p@localhost/db" [keycloak] issuer_url = "iss" audience = "aud" [gsd] rest_url = "r" app_key = "k" user = "u" password_md5 = "abc" app_names = [] "#; assert!(parse(raw).is_err()); } }