//! 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());
}
}