diff --git a/.gitignore b/.gitignore
index 6d67170..735318d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,10 @@ config.toml
# Kundendaten, gehört NICHT ins Repo.
/data
+# Logdateien (Dienst-Modus schreibt rollende Tageslogs hierhin) + Fatal-Log
+/logs
+holzleitner-backend-fatal.log
+
# Lokale Tool-/Agent-Artefakte
/.claude
diff --git a/Cargo.lock b/Cargo.lock
index c98da33..82cd6a4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -256,6 +256,12 @@ version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
[[package]]
name = "bitflags"
version = "2.11.1"
@@ -453,6 +459,15 @@ dependencies = [
"chrono",
]
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
+dependencies = [
+ "crossbeam-utils",
+]
+
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -962,10 +977,12 @@ dependencies = [
"tower",
"tower-http",
"tracing",
+ "tracing-appender",
"tracing-subscriber",
"utoipa",
"utoipa-swagger-ui",
"uuid",
+ "windows-service",
]
[[package]]
@@ -1439,7 +1456,7 @@ version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [
- "bitflags",
+ "bitflags 2.11.1",
"libc",
"plain",
"redox_syscall 0.7.5",
@@ -1880,7 +1897,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
- "bitflags",
+ "bitflags 2.11.1",
"crc32fast",
"fdeflate",
"flate2",
@@ -2208,7 +2225,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
- "bitflags",
+ "bitflags 2.11.1",
]
[[package]]
@@ -2217,7 +2234,7 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b"
dependencies = [
- "bitflags",
+ "bitflags 2.11.1",
]
[[package]]
@@ -2499,7 +2516,7 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
- "bitflags",
+ "bitflags 2.11.1",
"core-foundation",
"core-foundation-sys",
"libc",
@@ -2822,7 +2839,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64 0.22.1",
- "bitflags",
+ "bitflags 2.11.1",
"byteorder",
"bytes",
"chrono",
@@ -2866,7 +2883,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64 0.22.1",
- "bitflags",
+ "bitflags 2.11.1",
"byteorder",
"chrono",
"crc",
@@ -2946,6 +2963,12 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+[[package]]
+name = "symlink"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
+
[[package]]
name = "syn"
version = "2.0.117"
@@ -3275,7 +3298,7 @@ version = "0.6.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
dependencies = [
- "bitflags",
+ "bitflags 2.11.1",
"bytes",
"futures-util",
"http",
@@ -3312,6 +3335,19 @@ dependencies = [
"tracing-core",
]
+[[package]]
+name = "tracing-appender"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
+dependencies = [
+ "crossbeam-channel",
+ "symlink",
+ "thiserror 2.0.18",
+ "time",
+ "tracing-subscriber",
+]
+
[[package]]
name = "tracing-attributes"
version = "0.1.31"
@@ -3659,7 +3695,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
- "bitflags",
+ "bitflags 2.11.1",
"hashbrown 0.15.5",
"indexmap",
"semver",
@@ -3719,6 +3755,12 @@ dependencies = [
"wasite",
]
+[[package]]
+name = "widestring"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
+
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -3778,6 +3820,17 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-service"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd9db37ecb5b13762d95468a2fc6009d4b2c62801243223aabd44fca13ad13c8"
+dependencies = [
+ "bitflags 1.3.2",
+ "widestring",
+ "windows-sys 0.45.0",
+]
+
[[package]]
name = "windows-strings"
version = "0.5.1"
@@ -3787,6 +3840,15 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets 0.42.2",
+]
+
[[package]]
name = "windows-sys"
version = "0.48.0"
@@ -3823,6 +3885,21 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-targets"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+dependencies = [
+ "windows_aarch64_gnullvm 0.42.2",
+ "windows_aarch64_msvc 0.42.2",
+ "windows_i686_gnu 0.42.2",
+ "windows_i686_msvc 0.42.2",
+ "windows_x86_64_gnu 0.42.2",
+ "windows_x86_64_gnullvm 0.42.2",
+ "windows_x86_64_msvc 0.42.2",
+]
+
[[package]]
name = "windows-targets"
version = "0.48.5"
@@ -3871,6 +3948,12 @@ dependencies = [
"windows_x86_64_msvc 0.53.1",
]
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
@@ -3889,6 +3972,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
@@ -3907,6 +3996,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+[[package]]
+name = "windows_i686_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
@@ -3937,6 +4032,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+[[package]]
+name = "windows_i686_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
@@ -3955,6 +4056,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
@@ -3973,6 +4080,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
@@ -3991,6 +4104,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
@@ -4082,7 +4201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
- "bitflags",
+ "bitflags 2.11.1",
"indexmap",
"log",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index 263f5a7..8b3fd84 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -27,6 +27,7 @@ tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "cors"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+tracing-appender = "0.2"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "macros"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] }
# MSSQL (ERPframe) — nativer async-Treiber. Kein Pool: der ERP-Pull läuft
@@ -37,6 +38,8 @@ tokio-cron-scheduler = "0.13"
jsonwebtoken = "9"
toml = "0.8"
anyhow = "1"
+# Windows-Dienst-Integration (SCM). Nur unter Windows kompiliert.
+windows-service = "0.6"
sha2 = "0.10"
imagesize = "0.13"
utoipa = { version = "5", features = ["axum_extras", "chrono", "uuid"] }
diff --git a/README.md b/README.md
index 8a1d9a5..7e2f879 100644
--- a/README.md
+++ b/README.md
@@ -112,3 +112,38 @@ touch migrations/0002_tour.sql
`tracing` + `tracing-subscriber` mit `EnvFilter`. Der Default-Filter steht
in `config.toml` unter `[logging] filter`; die Env-Variable `RUST_LOG` hat
Vorrang (Ad-hoc-Debugging ohne Datei-Edit, z. B. `RUST_LOG=debug cargo run`).
+
+Im **Konsolenmodus** geht alles auf `stderr`. Im **Windows-Dienst-Modus**
+(keine Konsole) wird stattdessen in rollende Tages-Logdateien unter
+`[logging] dir` (Default `logs/`, relativ zur EXE) geschrieben.
+
+## Windows-Dienst
+
+Das Backend kann als Windows-Dienst laufen (gleiches Muster wie der
+Mail-Client). Beim Start versucht die EXE zuerst, sich beim Service Control
+Manager zu registrieren; gelingt das nicht (interaktiver Start), fällt sie in
+den Konsolenmodus zurück. `--console` (bzw. `-c`) erzwingt den Konsolenmodus.
+
+Der Dienst setzt sein Arbeitsverzeichnis auf das EXE-Verzeichnis — `config.toml`,
+`data/` und `logs/` müssen also **neben der EXE** liegen.
+
+```powershell
+# 1) Release-Build (auf dem Zielsystem oder per Cross-Build)
+cargo build --release -p holzleitner-api
+
+# 2) target\release\holzleitner-server.exe + config.toml + die *.ps1-Skripte
+# in ein Installationsverzeichnis kopieren, z. B. C:\HolzleitnerBackend\
+
+# 3) Als Administrator registrieren (Dienst "Holzleitner Backend")
+.\install-service.ps1
+# Optional mit Dienstkonto (DB-/Netzwerkzugriff):
+.\install-service.ps1 -Credential (Get-Credential)
+
+# Entfernen
+.\uninstall-service.ps1
+```
+
+Der Dienst startet verzögert-automatisch (nach den System-/DB-Diensten) und
+wird bei Absturz 3× im Abstand von 60 s neu gestartet. Interner Dienst-Name:
+`HolzleitnerBackend`. Boot-Fehler **vor** der Logger-Initialisierung (z. B.
+fehlende `config.toml`) landen in `holzleitner-backend-fatal.log` neben der EXE.
diff --git a/config.example.toml b/config.example.toml
index fc0bce3..3cc4a2a 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -88,5 +88,9 @@ api_key = ""
# --- Logging --------------------------------------------------------------
# RUST_LOG-Env hat Vorrang. Binary-Crate heißt `holzleitner_server`.
+# `dir`: nur im Windows-Dienst-Modus relevant — dort wird statt auf stderr in
+# rollende Tages-Logdateien
/holzleitner-backend.log. geschrieben
+# (relativ zum EXE-Verzeichnis). Im Konsolenmodus geht alles auf stderr.
[logging]
filter = "holzleitner_server=info,holzleitner_api=info,holzleitner_application=info,holzleitner_infrastructure=info,tower_http=info"
+dir = "logs"
diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml
index ae05953..f70b6cd 100644
--- a/crates/api/Cargo.toml
+++ b/crates/api/Cargo.toml
@@ -21,6 +21,7 @@ tower.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
+tracing-appender.workspace = true
serde.workspace = true
serde_json.workspace = true
uuid.workspace = true
@@ -30,3 +31,8 @@ anyhow.workspace = true
toml.workspace = true
sqlx.workspace = true
tokio-cron-scheduler.workspace = true
+
+# Windows-Dienst-Integration (SCM). Nur unter Windows kompiliert; auf
+# anderen Plattformen (z. B. Mac für den Kompiliertest) ausgeblendet.
+[target.'cfg(windows)'.dependencies]
+windows-service.workspace = true
diff --git a/crates/api/src/config.rs b/crates/api/src/config.rs
index a94a74f..00f6f4e 100644
--- a/crates/api/src/config.rs
+++ b/crates/api/src/config.rs
@@ -462,16 +462,27 @@ pub struct LoggingConfig {
/// `main.rs`), damit Ad-hoc-Debugging ohne Datei-Edit möglich bleibt.
#[serde(default = "default_log_filter")]
pub filter: String,
+ /// Verzeichnis für die rollenden Tages-Logdateien **im Dienst-Modus**
+ /// (im Konsolenmodus wird auf stderr geloggt). Relativ ⇒ zum
+ /// Arbeitsverzeichnis (im Dienst = EXE-Verzeichnis), oder absolut.
+ /// Default `logs`.
+ #[serde(default = "default_log_dir")]
+ pub dir: String,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
filter: default_log_filter(),
+ dir: default_log_dir(),
}
}
}
+fn default_log_dir() -> String {
+ "logs".to_string()
+}
+
fn default_log_filter() -> String {
// WICHTIG: Der Binary-Crate heißt `holzleitner_server` (vom `[[bin]]
// name`), NICHT `holzleitner_api` — sonst werden alle Logs aus
diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs
index f08c01e..5bdf893 100644
--- a/crates/api/src/main.rs
+++ b/crates/api/src/main.rs
@@ -1,8 +1,13 @@
//! Holzleitner-API — HTTP-Layer und Composition Root.
//!
-//! Bootstrap-Reihenfolge:
+//! Läuft entweder als **Windows-Dienst** (vom SCM gestartet) oder im
+//! **Konsolenmodus** (interaktiv / Linux / Mac). Die eigentliche App steckt
+//! in [`run_app`], die ein `shutdown`-Future als Stop-Trigger bekommt
+//! (Ctrl-C/SIGTERM im Konsolenmodus, SCM-Stop im Dienst).
+//!
+//! Bootstrap-Reihenfolge (in [`run_app`]):
//! 1. Konfiguration aus `config.toml` laden (liefert u. a. den Log-Filter)
-//! 2. Tracing/Logging initialisieren
+//! 2. Tracing/Logging initialisieren (Konsole → stderr, Dienst → Datei)
//! 3. Postgres-Pool aufbauen und Migrations ausführen
//! 4. Keycloak-AuthService instanziieren
//! 5. Use Cases zusammenstellen und in `AppState` packen
@@ -14,9 +19,12 @@ mod extractors;
mod middleware;
mod openapi;
mod routes;
+#[cfg(windows)]
+mod service;
mod state;
use std::net::SocketAddr;
+use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
@@ -64,27 +72,117 @@ use crate::middleware::{admin_api_key_middleware, jwt_middleware};
use crate::openapi::ApiDoc;
use crate::state::AppState;
-#[tokio::main]
-async fn main() -> anyhow::Result<()> {
- // Config ZUERST laden — der Log-Filter steht jetzt in `config.toml`
+/// Verzeichnis der laufenden EXE. Wichtig im Dienst-Modus: ein Windows-Dienst
+/// startet mit Arbeitsverzeichnis `C:\Windows\System32`; damit `config.toml`,
+/// `data/` und `logs/` neben der EXE liegen, setzt der Dienst sein CWD auf
+/// dieses Verzeichnis (siehe `service::run`). Nur vom Windows-Dienst-Modul
+/// genutzt — auf anderen Plattformen daher als dead-code erlaubt.
+#[cfg_attr(not(windows), allow(dead_code))]
+pub(crate) fn exe_dir() -> PathBuf {
+ std::env::current_exe()
+ .ok()
+ .and_then(|p| p.parent().map(|d| d.to_path_buf()))
+ .unwrap_or_else(|| PathBuf::from("."))
+}
+
+fn main() -> anyhow::Result<()> {
+ // Unter Windows: standardmäßig versuchen, als Dienst zu starten. Wird die
+ // EXE interaktiv gestartet (Konsole/Doppelklick), scheitert der
+ // SCM-Dispatcher (nicht vom SCM gestartet) → Konsolenmodus. `--console`
+ // erzwingt den Konsolenmodus direkt.
+ #[cfg(windows)]
+ {
+ let console = std::env::args().any(|a| a == "--console" || a == "-c");
+ if !console {
+ match service::run() {
+ Ok(()) => return Ok(()), // lief als Dienst, sauber beendet
+ Err(_e) => { /* interaktiv gestartet → Konsole */ }
+ }
+ }
+ }
+
+ run_console()
+}
+
+/// Konsolenmodus: eigene tokio-Runtime, Stop via Ctrl-C/SIGTERM. Logs → stderr.
+fn run_console() -> anyhow::Result<()> {
+ let rt = tokio::runtime::Builder::new_multi_thread()
+ .enable_all()
+ .build()
+ .context("tokio runtime")?;
+ rt.block_on(run_app(console_shutdown(), false))
+}
+
+/// Stop-Signal für den Konsolenmodus: Ctrl-C oder (auf Unix) SIGTERM.
+async fn console_shutdown() {
+ let ctrl_c = async {
+ tokio::signal::ctrl_c()
+ .await
+ .expect("Ctrl-C-Handler konnte nicht installiert werden");
+ };
+
+ #[cfg(unix)]
+ let terminate = async {
+ tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
+ .expect("SIGTERM-Handler konnte nicht installiert werden")
+ .recv()
+ .await;
+ };
+ #[cfg(not(unix))]
+ let terminate = std::future::pending::<()>();
+
+ tokio::select! {
+ _ = ctrl_c => {},
+ _ = terminate => {},
+ }
+}
+
+/// Die eigentliche App. `shutdown` ist der Stop-Trigger (Ctrl-C im Konsolen-,
+/// SCM-Stop im Dienst-Modus); `service_mode` steuert das Log-Ziel
+/// (Konsole → stderr, Dienst → rollende Tagesdatei in `[logging] dir`).
+///
+/// Gibt `Err` zurück, wenn der Bootstrap fehlschlägt — im Dienst-Modus fängt
+/// der Aufrufer (`service::run_service`) das ab und schreibt ein Fallback-Log,
+/// weil ein Boot-Fehler vor der Logger-Initialisierung sonst spurlos wäre.
+pub(crate) async fn run_app(
+ shutdown: impl std::future::Future