From d30d43df3a438486920f177726e9392d526533c7 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Mon, 1 Jun 2026 18:12:12 +0200 Subject: [PATCH] Backend als Windows-Dienst registrierbar (SCM, wie Mail-Client) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Backend kann jetzt — analog zum Mail-Client — als Windows-Dienst laufen. - main() refaktoriert: App-Logik in run_app(shutdown, service_mode); eigene tokio-Runtime statt #[tokio::main]. Windows startet zuerst den SCM-Dispatcher, fällt bei interaktivem Start auf Konsolenmodus zurück (--console erzwingt ihn). - src/service.rs (windows-only): SCM-Integration via windows-service-Crate, Stop/Shutdown-Handler, Running/Stopped-Status. Setzt das Arbeitsverzeichnis aufs EXE-Verzeichnis (Dienst startet sonst in System32), damit config.toml/ data/logs daneben liegen. Fallback-Log bei Boot-Fehler. - Graceful Shutdown: GSD-Lizenz-Freigabe in den Serve-Wrapper gezogen (greift in beiden Modi); Stop-Trigger ist das übergebene shutdown-Future. - Logging: Konsolenmodus → stderr (wie bisher); Dienst-Modus → rollende Tagesdatei (tracing-appender) unter [logging] dir (Default logs/). - install-service.ps1 / uninstall-service.ps1 (Dienst "Holzleitner Backend"). - README: Windows-Dienst-Abschnitt; .gitignore: /logs + Fatal-Log. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 + Cargo.lock | 139 +++++++++++++++++++++++++++--- Cargo.toml | 3 + README.md | 35 ++++++++ config.example.toml | 4 + crates/api/Cargo.toml | 6 ++ crates/api/src/config.rs | 11 +++ crates/api/src/main.rs | 177 +++++++++++++++++++++++++++----------- crates/api/src/service.rs | 139 ++++++++++++++++++++++++++++++ install-service.ps1 | 86 ++++++++++++++++++ uninstall-service.ps1 | 20 +++++ 11 files changed, 564 insertions(+), 60 deletions(-) create mode 100644 crates/api/src/service.rs create mode 100644 install-service.ps1 create mode 100644 uninstall-service.ps1 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 + Send + 'static, + service_mode: bool, +) -> anyhow::Result<()> { + // Config ZUERST laden — der Log-Filter steht in `config.toml` // (`[logging] filter`), also brauchen wir die Config, bevor der - // Subscriber initialisiert wird. Fehler hier werden von anyhow auch - // ohne aktiven Subscriber sichtbar (stderr). + // Subscriber initialisiert wird. let cfg = config::load().context("config laden fehlgeschlagen")?; - tracing_subscriber::fmt() - // Auf stderr loggen statt stdout: stderr ist unbuffered und erscheint - // damit sofort — auch wenn die Ausgabe in eine Datei/Pipe/ein - // IDE-Terminal läuft (stdout wäre dort blockgepuffert → Logs würden - // erst verspätet/gar nicht sichtbar). - .with_writer(std::io::stderr) - .with_env_filter( - // `RUST_LOG`-Env hat Vorrang (Ad-hoc-Debugging ohne Datei-Edit), - // sonst der Filter aus `config.toml` (`[logging] filter`). - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| cfg.logging.filter.clone().into()), - ) - .init(); + // `RUST_LOG`-Env hat Vorrang (Ad-hoc-Debugging ohne Datei-Edit), sonst der + // Filter aus `config.toml`. + let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| cfg.logging.filter.clone().into()); + + // Hält den non-blocking-Writer am Leben (flush beim Drop). Nur im + // Dienst-Modus belegt; im Konsolenmodus `None` (stderr braucht keinen Guard). + let _log_guard: Option; + + if service_mode { + // Dienst hat keine Konsole → in eine rollende Tagesdatei loggen, damit + // die Logs nicht verloren gehen. Verzeichnis relativ zum CWD (= EXE-Dir, + // vom Dienst gesetzt) bzw. absolut, falls so konfiguriert. + let appender = tracing_appender::rolling::daily(&cfg.logging.dir, "holzleitner-backend.log"); + let (writer, guard) = tracing_appender::non_blocking(appender); + _log_guard = Some(guard); + tracing_subscriber::fmt() + .with_writer(writer) + .with_ansi(false) // keine Farbcodes in der Logdatei + .with_env_filter(env_filter) + .init(); + } else { + // Konsole: stderr ist unbuffered und erscheint sofort — auch wenn die + // Ausgabe in eine Datei/Pipe/ein IDE-Terminal läuft. + _log_guard = None; + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter(env_filter) + .init(); + } tracing::info!("starting up"); // Vollständige (secret-maskierte) Konfig-Übersicht beim Start — damit @@ -494,38 +592,17 @@ async fn main() -> anyhow::Result<()> { let listener = tokio::net::TcpListener::bind(addr).await?; tracing::info!("server läuft auf http://{}", addr); - // Graceful Shutdown: bei Ctrl-C / SIGTERM die GSD-Lizenz aktiv freigeben, - // damit der Seat nicht bis zum Session-Ablauf geblockt bleibt. + // Graceful Shutdown: auf den `shutdown`-Trigger warten (Ctrl-C/SIGTERM im + // Konsolen-, SCM-Stop im Dienst-Modus), dann die GSD-Lizenz aktiv + // freigeben, damit der Seat nicht bis zum Session-Ablauf geblockt bleibt. + let graceful = async move { + shutdown.await; + tracing::info!("shutdown signal empfangen — gebe GSD-Lizenz frei"); + gsd_service.release_license().await; + }; axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal(gsd_service)) + .with_graceful_shutdown(graceful) .await?; Ok(()) } - -/// Wartet auf Ctrl-C bzw. SIGTERM und gibt davor die GSD-Lizenz frei. -async fn shutdown_signal(gsd_service: Arc) { - 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 => {}, - } - - tracing::info!("shutdown signal empfangen — gebe GSD-Lizenz frei"); - gsd_service.release_license().await; -} diff --git a/crates/api/src/service.rs b/crates/api/src/service.rs new file mode 100644 index 0000000..1c2e9d6 --- /dev/null +++ b/crates/api/src/service.rs @@ -0,0 +1,139 @@ +//! Windows-Dienst-Integration (Service Control Manager). Nur unter Windows +//! kompiliert (`#[cfg(windows)] mod service` in `main.rs`). +//! +//! Ablauf: `run()` übergibt den Prozess an den SCM. Der SCM ruft `service_main` +//! in einem eigenen Thread; dort registrieren wir einen Control-Handler +//! (Stop/Shutdown), melden „Running", starten eine eigene tokio-Runtime und +//! lassen `crate::run_app` laufen, bis der Handler ein Stop-Signal schickt. +//! +//! Das Arbeitsverzeichnis wird auf das EXE-Verzeichnis gesetzt, weil ein Dienst +//! sonst in `C:\Windows\System32` startet — damit landen `config.toml`, +//! `data/` und `logs/` neben der EXE (so wie das Install-Skript es erwartet). + +use std::ffi::OsString; +use std::path::Path; +use std::sync::mpsc; +use std::time::Duration; + +use windows_service::service::{ + ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, ServiceType, +}; +use windows_service::service_control_handler::{self, ServiceControlHandlerResult}; +use windows_service::{define_windows_service, service_dispatcher}; + +/// Interner Dienst-Name (Schlüssel). **Muss** mit dem `-ServiceName` im +/// Install-Skript (`install-service.ps1`) übereinstimmen. Der Anzeigename +/// „Holzleitner Backend" wird separat beim Registrieren gesetzt. +const SERVICE_NAME: &str = "HolzleitnerBackend"; +const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS; + +define_windows_service!(ffi_service_main, service_main); + +/// Übergibt den Prozess an den SCM. Gibt `Err` zurück, wenn der Prozess NICHT +/// vom SCM gestartet wurde (interaktiver Start) — der Aufrufer fällt dann in +/// den Konsolenmodus zurück. +pub fn run() -> windows_service::Result<()> { + service_dispatcher::start(SERVICE_NAME, ffi_service_main) +} + +fn service_main(_args: Vec) { + let _ = run_service(); +} + +fn run_service() -> windows_service::Result<()> { + // Arbeitsverzeichnis aufs EXE-Verzeichnis setzen (Dienst startet sonst in + // System32) — damit config.toml/data/logs neben der EXE aufgelöst werden. + if let Some(dir) = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(Path::to_path_buf)) + { + let _ = std::env::set_current_dir(&dir); + } + + // Kanal: Control-Handler -> Loop. Stop/Shutdown sendet ein () hinein. + let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>(); + + let handler = move |control| -> ServiceControlHandlerResult { + match control { + ServiceControl::Stop | ServiceControl::Shutdown => { + let _ = shutdown_tx.send(()); + ServiceControlHandlerResult::NoError + } + ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, + _ => ServiceControlHandlerResult::NotImplemented, + } + }; + + let status_handle = service_control_handler::register(SERVICE_NAME, handler)?; + + // „Running" melden (akzeptiert Stop + System-Shutdown). + status_handle.set_service_status(ServiceStatus { + service_type: SERVICE_TYPE, + current_state: ServiceState::Running, + controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN, + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + + // Eigene tokio-Runtime im Dienst-Thread (kein #[tokio::main]). + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("tokio runtime"); + let result = rt.block_on(async move { + // mpsc::recv ist blockierend → in spawn_blocking auslagern und als + // Future an run_app übergeben. + let shutdown = async move { + let _ = tokio::task::spawn_blocking(move || { + let _ = shutdown_rx.recv(); + }) + .await; + }; + crate::run_app(shutdown, true).await + }); + + // Boot-/Laufzeitfehler: vor der Logger-Initialisierung gibt es im Dienst + // keine Spur — daher in eine Fallback-Datei neben die EXE schreiben. + let exit_code = match result { + Ok(()) => ServiceExitCode::Win32(0), + Err(e) => { + fallback_log(&format!("run_app fehlgeschlagen: {e:#}")); + ServiceExitCode::Win32(1) + } + }; + + // „Stopped" melden. + status_handle.set_service_status(ServiceStatus { + service_type: SERVICE_TYPE, + current_state: ServiceState::Stopped, + controls_accepted: ServiceControlAccept::empty(), + exit_code, + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + + Ok(()) +} + +/// Notfall-Logging, wenn der reguläre Logger (noch) nicht steht — schreibt nach +/// `\holzleitner-backend-fatal.log`. Im Dienst-Modus die einzige Spur, +/// falls schon das Laden von `config.toml` scheitert. +fn fallback_log(msg: &str) { + let file = crate::exe_dir().join("holzleitner-backend-fatal.log"); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&file) + { + use std::io::Write; + let _ = writeln!( + f, + "[{}] {}", + chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), + msg + ); + } +} diff --git a/install-service.ps1 b/install-service.ps1 new file mode 100644 index 0000000..44734f9 --- /dev/null +++ b/install-service.ps1 @@ -0,0 +1,86 @@ +#Requires -RunAsAdministrator +<# +.SYNOPSIS + Registriert das Holzleitner-Backend als Windows-Dienst "Holzleitner Backend". + +.DESCRIPTION + Muss als Administrator ausgeführt werden. Erwartet die kompilierte EXE + (Default: neben diesem Skript) und eine config.toml IM SELBEN Verzeichnis + wie die EXE (der Dienst setzt sein Arbeitsverzeichnis auf das EXE-Verzeichnis, + daher werden config.toml, data/ und logs/ relativ zur EXE aufgelöst). + + Release-Build erzeugen: cargo build --release -p holzleitner-api + Die EXE liegt dann unter target\release\holzleitner-server.exe. + +.PARAMETER ExePath + Pfad zu holzleitner-server.exe. Default: neben diesem Skript. + +.PARAMETER Credential + Optionales Dienstkonto. Ohne Angabe läuft der Dienst als LocalSystem. Für + Zugriff auf Postgres/Keycloak/ERPframe-MSSQL/GSD im Netzwerk ist oft ein + echtes Domänen-/Benutzerkonto nötig. Das Konto braucht das Recht + "Als Dienst anmelden". + +.EXAMPLE + .\install-service.ps1 + +.EXAMPLE + .\install-service.ps1 -ExePath "C:\HolzleitnerBackend\holzleitner-server.exe" -Credential (Get-Credential) +#> +[CmdletBinding()] +param( + [string]$ExePath, + [string]$ServiceName = "HolzleitnerBackend", + [string]$DisplayName = "Holzleitner Backend", + [pscredential]$Credential +) +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +if (-not $ExePath) { $ExePath = Join-Path $ScriptDir 'holzleitner-server.exe' } +if (-not (Test-Path $ExePath)) { + throw "EXE nicht gefunden: $ExePath (zuerst 'cargo build --release -p holzleitner-api' und die EXE aus target\release\ neben dieses Skript kopieren)" +} +$ExePath = (Resolve-Path $ExePath).Path +$ExeDir = Split-Path $ExePath -Parent + +if (-not (Test-Path (Join-Path $ExeDir 'config.toml'))) { + Write-Warning "config.toml fehlt neben der EXE ($ExeDir) - der Dienst startet sonst nicht (cp config.example.toml config.toml)." +} + +# Vorhandenen Dienst stoppen & entfernen (Neuinstallation) +$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if ($existing) { + Write-Host "Dienst '$ServiceName' existiert bereits - stoppe & entferne..." + if ($existing.Status -ne 'Stopped') { Stop-Service -Name $ServiceName -Force } + & sc.exe delete $ServiceName | Out-Null + Start-Sleep -Seconds 2 +} + +# binPath in doppelten Quotes (Pfad kann Leerzeichen enthalten) +$bin = "`"$ExePath`"" + +$params = @{ + Name = $ServiceName + DisplayName = $DisplayName + BinaryPathName = $bin + StartupType = 'Automatic' + Description = 'HTTP-Backend der Holzleitner-Lieferservice-App (REST-API, ERP-Sync, Lieferreports). Bindet standardmaessig auf den in config.toml gesetzten Port.' +} +if ($Credential) { $params['Credential'] = $Credential } + +Write-Host "Registriere Dienst '$DisplayName' ($ServiceName) -> $ExePath" +New-Service @params | Out-Null + +# Auto-Restart bei Absturz: 3x mit 60s Abstand, Fehlerzaehler nach 1 Tag zuruecksetzen +& sc.exe failure $ServiceName reset= 86400 actions= restart/60000/restart/60000/restart/60000 | Out-Null +# Verzoegerter Autostart (startet nach den System-Diensten, z. B. nach der DB) +& sc.exe config $ServiceName start= delayed-auto | Out-Null + +Write-Host "Starte Dienst..." +Start-Service -Name $ServiceName +Start-Sleep -Seconds 1 +Get-Service -Name $ServiceName | Format-List Name, DisplayName, Status, StartType +Write-Host "Config: $(Join-Path $ExeDir 'config.toml')" +Write-Host "Logs: $(Join-Path $ExeDir 'logs')" +Write-Host "Fertig." diff --git a/uninstall-service.ps1 b/uninstall-service.ps1 new file mode 100644 index 0000000..4deb900 --- /dev/null +++ b/uninstall-service.ps1 @@ -0,0 +1,20 @@ +#Requires -RunAsAdministrator +<# +.SYNOPSIS + Stoppt und entfernt den Dienst "Holzleitner Backend". +#> +[CmdletBinding()] +param( + [string]$ServiceName = "HolzleitnerBackend" +) +$ErrorActionPreference = 'Stop' + +$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if (-not $svc) { Write-Host "Dienst '$ServiceName' existiert nicht."; return } + +if ($svc.Status -ne 'Stopped') { + Write-Host "Stoppe Dienst..." + Stop-Service -Name $ServiceName -Force +} +& sc.exe delete $ServiceName | Out-Null +Write-Host "Dienst '$ServiceName' entfernt."