diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 000000000..8f14436d6 --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1,2 @@ +skip_calls = ["emit_to_syslog", "emit_to_event_log"] +exclude_re = ["impl Debug"] diff --git a/Cargo.lock b/Cargo.lock index 71ef7da54..c1bdc2a75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1240,6 +1240,10 @@ dependencies = [ "serde_json", "serde_urlencoded", "smol_str", + "sysevent", + "sysevent-codes", + "sysevent-syslog", + "sysevent-winevent", "sysinfo", "tap", "terminal-streamer", @@ -5936,6 +5940,34 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "sysevent" +version = "0.0.0" + +[[package]] +name = "sysevent-codes" +version = "0.0.0" +dependencies = [ + "sysevent", +] + +[[package]] +name = "sysevent-syslog" +version = "0.0.0" +dependencies = [ + "libc", + "proptest", + "sysevent", +] + +[[package]] +name = "sysevent-winevent" +version = "0.0.0" +dependencies = [ + "sysevent", + "windows-sys 0.52.0", +] + [[package]] name = "sysinfo" version = "0.35.2" @@ -6022,6 +6054,15 @@ dependencies = [ "transport", ] +[[package]] +name = "testsuite" +version = "0.0.0" +dependencies = [ + "sysevent", + "sysevent-syslog", + "sysevent-winevent", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index dbcbbd288..764b0af2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "devolutions-gateway", "devolutions-session", "jetsocat", + "testsuite", "tools/generate-openapi", ] default-members = [ @@ -36,6 +37,10 @@ strip = "symbols" tracing-appender = { git = "https://github.com/CBenoit/tracing.git", rev = "42097daf92e683cf18da7639ddccb056721a796c" } [workspace.lints.rust] +# Declare the custom cfgs. +unexpected_cfgs = { level = "warn", check-cfg = [ + 'cfg(build_profile, values("dev","release","production"))', +]} # == Safer unsafe == # unsafe_op_in_unsafe_fn = "warn" @@ -55,6 +60,9 @@ noop_method_call = "warn" unused_crate_dependencies = "warn" unused_macro_rules = "warn" +[workspace.dependencies] +proptest = "1.0" + [workspace.lints.clippy] # == Safer unsafe == # diff --git a/crates/proxy-socks/src/socks5.rs b/crates/proxy-socks/src/socks5.rs index 136d1f7ac..45621495f 100644 --- a/crates/proxy-socks/src/socks5.rs +++ b/crates/proxy-socks/src/socks5.rs @@ -276,10 +276,8 @@ impl From for Socks5FailureCode { match kind { io::ErrorKind::ConnectionRefused => Socks5FailureCode::ConnectionRefused, io::ErrorKind::TimedOut => Socks5FailureCode::TtlExpired, - #[cfg(feature = "nightly")] // https://github.com/rust-lang/rust/issues/86442 - std::io::ErrorKind::HostUnreachable => Socks5FailureCode::HostUnreachable, - #[cfg(feature = "nightly")] // https://github.com/rust-lang/rust/issues/86442 - std::io::ErrorKind::NetworkUnreachable => Socks5FailureCode::NetworkUnreachable, + io::ErrorKind::HostUnreachable => Socks5FailureCode::HostUnreachable, + io::ErrorKind::NetworkUnreachable => Socks5FailureCode::NetworkUnreachable, _ => Socks5FailureCode::GeneralSocksServerFailure, } } diff --git a/crates/sysevent-codes/Cargo.toml b/crates/sysevent-codes/Cargo.toml new file mode 100644 index 000000000..bda4e543d --- /dev/null +++ b/crates/sysevent-codes/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "sysevent-codes" +version = "0.0.0" +edition = "2024" +authors = ["Devolutions Inc. "] +license = "MIT OR Apache-2.0" +publish = false + +[lints] +workspace = true + +[dependencies] +sysevent.path = "../sysevent" diff --git a/crates/sysevent-codes/src/lib.rs b/crates/sysevent-codes/src/lib.rs new file mode 100644 index 000000000..7b71aa4f5 --- /dev/null +++ b/crates/sysevent-codes/src/lib.rs @@ -0,0 +1,400 @@ +use std::path::Path; +use sysevent::{Entry, Severity}; + +// 1000-1099 **Service/Lifecycle** + +/// Fired after `GatewayService::start()` +pub const SERVICE_STARTED: u32 = 1000; +/// Graceful stop received +pub const SERVICE_STOPPING: u32 = 1001; +/// Failed to init config +pub const CONFIG_INVALID: u32 = 1010; +/// Top-level start failure (often transient) +pub const START_FAILED: u32 = 1020; +/// A boot crash trace was persisted. +pub const BOOT_STACKTRACE_WRITTEN: u32 = 1030; + +pub fn service_started(version: impl ToString) -> Entry { + Entry::new("Service started") + .event_code(SERVICE_STARTED) + .severity(Severity::Info) + .field("version", version) +} + +pub fn service_stopping(reason: impl ToString) -> Entry { + Entry::new("Service stopping") + .event_code(SERVICE_STOPPING) + .severity(Severity::Info) + .field("reason", reason) +} + +pub fn config_invalid(error: impl std::fmt::Display, path: impl AsRef) -> Entry { + Entry::new("Configuration invalid") + .event_code(CONFIG_INVALID) + .severity(Severity::Critical) + .field("path", path.as_ref().display()) + .field("error_chain", format!("{error:#}")) + .field("reason_code", "invalid_config") +} + +pub fn start_failed(error: impl std::fmt::Display, cause: impl ToString) -> Entry { + Entry::new("Start failed") + .event_code(START_FAILED) + .severity(Severity::Error) + .field("cause", cause) // e.g. "bind", "dependency", "tls", "io" + .field("error_chain", format!("{error:#}")) +} + +pub fn boot_stacktrace_written(path: &Path) -> Entry { + Entry::new("Boot stacktrace written") + .event_code(BOOT_STACKTRACE_WRITTEN) + .severity(Severity::Warning) + .field("path", path.display()) +} + +// 2000-2099 **Listeners & Networking** + +/// Fires with listener start. +pub const LISTENER_STARTED: u32 = 2000; +/// Bind failure with OS error. +pub const LISTENER_BIND_FAILED: u32 = 2001; +/// Fires when listener stops. +pub const LISTENER_STOPPED: u32 = 2002; + +pub fn listener_started(address: impl ToString, proto: impl ToString) -> Entry { + Entry::new("Listener started") + .event_code(LISTENER_STARTED) + .severity(Severity::Info) + .field("address", address) + .field("proto", proto) // e.g. "tcp", "http", "socks5" +} + +pub fn listener_bind_failed(address: impl ToString, error: impl std::fmt::Display) -> Entry { + Entry::new("Listener bind failed") + .event_code(LISTENER_BIND_FAILED) + .severity(Severity::Error) + .field("address", address) + .field("error_chain", format!("{error:#}")) +} + +pub fn listener_stopped(address: impl ToString, reason: impl ToString) -> Entry { + Entry::new("Listener stopped") + .event_code(LISTENER_STOPPED) + .severity(Severity::Info) + .field("address", address) + .field("reason", reason) // "shutdown", "reload", "error" +} + +// 3000-3099 **TLS / Certificates** + +/// TLS configured (includes which source: file vs system store) +pub const TLS_CONFIGURED: u32 = 3000; +/// Strict verification off (compat mode). +pub const TLS_VERIFY_STRICT_DISABLED: u32 = 3001; +/// Missing SAN or EKU=serverAuth (reject). +pub const TLS_CERTIFICATE_REJECTED: u32 = 3002; +/// Thumbprint/subject; selection criteria. +pub const SYSTEM_CERT_SELECTED: u32 = 3003; +/// Key/cert load failure (path + error). +pub const TLS_KEY_LOAD_FAILED: u32 = 3004; +/// Name mismatch (CN/SAN vs host) +pub const TLS_CERTIFICATE_NAME_MISMATCH: u32 = 3005; +/// No suitable certificate found. +pub const TLS_NO_SUITABLE_CERTIFICATE: u32 = 3006; + +pub fn tls_configured(source: impl ToString) -> Entry { + Entry::new("TLS configured") + .event_code(TLS_CONFIGURED) + .severity(Severity::Info) + .field("source", source) // "file", "system_store" +} + +pub fn tls_verify_strict_disabled(mode: impl ToString) -> Entry { + Entry::new("TLS strict verification disabled") + .event_code(TLS_VERIFY_STRICT_DISABLED) + .severity(Severity::Notice) + .field("mode", mode) // e.g. "compat", "insecure-skip-verify" +} + +pub fn tls_certificate_rejected(subject: impl ToString, reason_code: impl ToString) -> Entry { + Entry::new("Certificate rejected") + .event_code(TLS_CERTIFICATE_REJECTED) + .severity(Severity::Error) + .field("subject", subject) + .field("reason_code", reason_code) // "missing_san", "eku_missing", ... +} + +pub fn tls_no_suitable_certificate(error: impl std::fmt::Display, issues: impl ToString) -> Entry { + Entry::new("No usable certificate found") + .event_code(TLS_NO_SUITABLE_CERTIFICATE) + .severity(Severity::Critical) + .field("error", format!("{error:#}")) + .field("issues", issues) +} + +pub fn system_cert_selected(thumbprint: impl ToString, subject: impl ToString) -> Entry { + Entry::new("System certificate selected") + .event_code(SYSTEM_CERT_SELECTED) + .severity(Severity::Info) + .field("thumbprint", thumbprint) + .field("subject", subject) +} + +pub fn tls_key_load_failed(path: impl AsRef, error: impl std::fmt::Display) -> Entry { + Entry::new("TLS key/cert load failed") + .event_code(TLS_KEY_LOAD_FAILED) + .severity(Severity::Error) + .field("path", path.as_ref().display()) + .field("error_chain", format!("{error:#}")) + .field("reason_code", "io_error") +} + +pub fn tls_certificate_name_mismatch(hostname: impl ToString, subject: impl ToString) -> Entry { + Entry::new("TLS certificate name mismatch") + .event_code(TLS_CERTIFICATE_NAME_MISMATCH) + .severity(Severity::Notice) + .field("hostname", hostname) + .field("subject", subject) + .field("reason_code", "name_mismatch") +} + +// 4000-4099 **Sessions, Tokens & Recording** + +pub const SESSION_OPENED: u32 = 4000; +pub const SESSION_CLOSED: u32 = 4001; +pub const TOKEN_PROVISIONED: u32 = 4010; +pub const TOKEN_REUSED: u32 = 4011; +pub const TOKEN_REUSE_LIMIT_EXCEEDED: u32 = 4012; +pub const RECORDING_STARTED: u32 = 4030; +pub const RECORDING_STOPPED: u32 = 4031; +pub const RECORDING_ERROR: u32 = 4032; + +pub fn session_opened( + protocol: impl ToString, + client_ip: impl ToString, + target: impl ToString, + token_id: impl ToString, +) -> Entry { + Entry::new("Session opened") + .event_code(SESSION_OPENED) + .severity(Severity::Info) + .field("protocol", protocol) // "RDP","SSH","VNC","JMUX",... + .field("client_ip", client_ip) + .field("target", target) + .field("token_id", token_id) +} + +pub fn session_closed( + duration_ms: u64, + bytes_tx: u64, + bytes_rx: u64, + outcome: impl ToString, // "ok","client_disconnect","timeout","denied","error" +) -> Entry { + Entry::new("Session closed") + .event_code(SESSION_CLOSED) + .severity(Severity::Info) + .field("duration_ms", duration_ms) + .field("bytes_tx", bytes_tx) + .field("bytes_rx", bytes_rx) + .field("outcome", outcome) +} + +pub fn token_provisioned(token_id: impl ToString) -> Entry { + Entry::new("Token provisioned") + .event_code(TOKEN_PROVISIONED) + .severity(Severity::Info) + .field("token_id", token_id) +} + +pub fn token_reused(token_id: impl ToString, reuse_count: u32) -> Entry { + Entry::new("Token reused") + .event_code(TOKEN_REUSED) + .severity(Severity::Info) + .field("token_id", token_id) + .field("reuse_count", reuse_count) +} + +pub fn token_reuse_limit_exceeded(token_id: impl ToString, limit: u32) -> Entry { + Entry::new("Token reuse limit exceeded") + .event_code(TOKEN_REUSE_LIMIT_EXCEEDED) + .severity(Severity::Warning) + .field("token_id", token_id) + .field("limit", limit) + .field("reason_code", "reuse_limit_exceeded") +} + +pub fn recording_started(destination: impl ToString) -> Entry { + Entry::new("Recording started") + .event_code(RECORDING_STARTED) + .severity(Severity::Info) + .field("destination", destination) +} + +pub fn recording_stopped(bytes: u64, files: u32) -> Entry { + Entry::new("Recording stopped") + .event_code(RECORDING_STOPPED) + .severity(Severity::Info) + .field("bytes", bytes) + .field("files", files) +} + +pub fn recording_error(path: impl AsRef, error: impl std::fmt::Display) -> Entry { + Entry::new("Recording error") + .event_code(RECORDING_ERROR) + .severity(Severity::Error) + .field("path", path.as_ref().display()) + .field("error_chain", format!("{error:#}")) +} + +// 5000-5099 **Authentication / Authorization** + +/// Signature/Expiry/Audience failure. +pub const JWT_REJECTED: u32 = 5001; +/// (Warning): unusual but accepted (near-expiry grace, unknown kid with fallback, oversized token). +pub const JWT_ANOMALY: u32 = 5002; +/// Rule evaluation result. +pub const AUTHORIZATION_DENIED: u32 = 5010; +/// (Info): interval_s, jwt_ok, jwt_rejected, denied, by_reason +pub const AUTH_SUMMARY: u32 = 5090; + +pub fn jwt_rejected( + reason_code: impl ToString, // "expired","bad_signature","aud_mismatch","not_before","unknown_kid",... + reason: impl ToString, // human-readable reason +) -> Entry { + Entry::new("JWT rejected") + .event_code(JWT_REJECTED) + .severity(Severity::Warning) + .field("reason_code", reason_code) + .field("reason", reason) +} + +pub fn jwt_anomaly( + issuer: impl ToString, + audience: impl ToString, + kid: impl ToString, + kind: impl ToString, // "near_expiry_grace","oversized_token","alg_unexpected","clock_skew" + detail: impl ToString, +) -> Entry { + Entry::new("JWT anomaly") + .event_code(JWT_ANOMALY) + .severity(Severity::Warning) + .field("issuer", issuer) + .field("audience", audience) + .field("kid", kid) + .field("kind", kind) + .field("detail", detail) +} + +pub fn authorization_denied( + subject: impl ToString, + action: impl ToString, + resource: impl ToString, + rule: impl ToString, +) -> Entry { + Entry::new("Authorization denied") + .event_code(AUTHORIZATION_DENIED) + .severity(Severity::Warning) + .field("subject", subject) + .field("action", action) + .field("resource", resource) + .field("rule", rule) + .field("reason_code", "permission_denied") +} + +/// Emit periodically; keep Event Log lightweight but SIEM-friendly. +pub fn auth_summary( + interval_s: u32, + jwt_ok: u64, + jwt_rejected: u64, + denied: u64, + by_reason_json: impl ToString, // e.g. compact JSON: {"expired":123,"bad_signature":4} +) -> Entry { + Entry::new("Auth summary") + .event_code(AUTH_SUMMARY) + .severity(Severity::Info) + .field("interval_s", interval_s) + .field("jwt_ok", jwt_ok) + .field("jwt_rejected", jwt_rejected) + .field("denied", denied) + .field("by_reason", by_reason_json) +} + +// 6000-6099 **Agent Integration** + +/// `DevolutionsSession.exe` started in session; include session id & kind (console/remote). +pub const USER_SESSION_PROCESS_STARTED: u32 = 6000; +/// Exit code; who triggered. +pub const USER_SESSION_PROCESS_TERMINATED: u32 = 6001; +pub const UPDATER_TASK_ENABLED: u32 = 6010; +pub const UPDATER_ERROR: u32 = 6011; +pub const PEDM_ENABLED: u32 = 6020; + +pub fn user_session_process_started(session_id: u32, kind: impl ToString, exe: impl ToString) -> Entry { + Entry::new("User session process started") + .event_code(USER_SESSION_PROCESS_STARTED) + .severity(Severity::Info) + .field("session_id", session_id) + .field("kind", kind) // "console","remote" + .field("exe", exe) +} + +pub fn user_session_process_terminated(session_id: u32, exit_code: i32, by: impl ToString) -> Entry { + Entry::new("User session process terminated") + .event_code(USER_SESSION_PROCESS_TERMINATED) + .severity(Severity::Info) + .field("session_id", session_id) + .field("exit_code", exit_code) + .field("by", by) // "user","service","timeout" +} + +pub fn updater_task_enabled() -> Entry { + Entry::new("Updater task enabled") + .event_code(UPDATER_TASK_ENABLED) + .severity(Severity::Info) +} + +pub fn updater_error(step: impl ToString, error: impl std::fmt::Display) -> Entry { + Entry::new("Updater error") + .event_code(UPDATER_ERROR) + .severity(Severity::Error) + .field("step", step) // "download","verify","apply","rollback" + .field("error_chain", format!("{error:#}")) +} + +pub fn pedm_enabled() -> Entry { + Entry::new("PEDM enabled") + .event_code(PEDM_ENABLED) + .severity(Severity::Info) +} + +// 7000-7099 **Health** + +pub const RECORDING_STORAGE_LOW: u32 = 7010; // (Warning): remaining_bytes, threshold_bytes + +pub fn recording_storage_low(remaining_bytes: u64, threshold_bytes: u64) -> Entry { + Entry::new("Recording storage low") + .event_code(RECORDING_STORAGE_LOW) + .severity(Severity::Warning) + .field("remaining_bytes", remaining_bytes) + .field("threshold_bytes", threshold_bytes) +} + +// 9000-9099 **Diagnostics** + +pub const DEBUG_OPTIONS_ENABLED: u32 = 9001; +pub const XMF_NOT_FOUND: u32 = 9002; + +pub fn debug_options_enabled(options: impl ToString) -> Entry { + Entry::new("Debug options enabled") + .event_code(DEBUG_OPTIONS_ENABLED) + .severity(Severity::Warning) // policy risk + .field("options", options) // e.g. "verbose,skip_tls_verify" +} + +pub fn xmf_not_found(path: impl AsRef, error: impl std::fmt::Display) -> Entry { + Entry::new("XMF not found") + .event_code(XMF_NOT_FOUND) + .severity(Severity::Warning) + .field("path", path.as_ref().display()) + .field("error_chain", format!("{error:#}")) +} diff --git a/crates/sysevent-syslog/Cargo.toml b/crates/sysevent-syslog/Cargo.toml new file mode 100644 index 000000000..c3cc0621b --- /dev/null +++ b/crates/sysevent-syslog/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "sysevent-syslog" +version = "0.0.0" +edition = "2024" +authors = ["Devolutions Inc. "] +description = "Syslog backend for system-wide critical event logging" +license = "MIT OR Apache-2.0" +repository = "https://github.com/Devolutions/devolutions-gateway" +publish = false + +[dependencies] +sysevent.path = "../sysevent" +libc = "0.2" + +[dev-dependencies] +proptest.workspace = true + +[lints] +workspace = true diff --git a/crates/sysevent-syslog/README.md b/crates/sysevent-syslog/README.md new file mode 100644 index 000000000..53d460e03 --- /dev/null +++ b/crates/sysevent-syslog/README.md @@ -0,0 +1,30 @@ +# sysevent-syslog + +Syslog backend for system-wide critical event logging. + +This crate provides Unix/Linux syslog implementation using standard libc calls. + +## Platform Requirements + +- Unix/Linux systems with syslog facilities +- Standard C library with openlog/syslog/closelog functions +- Thread-safe operation across concurrent emission calls + +## Examples + +```rust,no_run +use sysevent::{Entry, Severity, Facility, SystemEventSink}; +use sysevent_syslog::{Syslog, SyslogOptions}; + +let options = SyslogOptions::default() + .facility(Facility::Daemon) + .log_pid(true); + +let syslog = Syslog::new(c"myapp", options)?; + +let entry = Entry::new("Database connection failed") + .severity(Severity::Critical); + +syslog.emit(entry)?; +# Ok::<(), sysevent::SysEventError>(()) +``` diff --git a/crates/sysevent-syslog/examples/unix_syslog.rs b/crates/sysevent-syslog/examples/unix_syslog.rs new file mode 100644 index 000000000..5eb450d8a --- /dev/null +++ b/crates/sysevent-syslog/examples/unix_syslog.rs @@ -0,0 +1,94 @@ +#[cfg(windows)] +fn main() {} + +#[cfg(unix)] +fn main() -> Result<(), Box> { + use sysevent::{Entry, Facility, Severity, SystemEventSink}; + use sysevent_syslog::{Syslog, SyslogOptions}; + + println!("Unix Syslog Backend Example"); + println!("==========================="); + + // Configure syslog options. + let options = SyslogOptions::default() + .facility(Facility::Daemon) + .log_pid(true) + .no_delay(false) + .log_perror(false); + + // Create syslog backend. + let syslog = Syslog::new(c"dgw-unix-syslog-example", options)?; + + println!("Created syslog backend for 'system-wide-log-example'"); + + // Emit various severity levels. + let severities = [ + (Severity::Critical, "Critical system component failed"), + (Severity::Error, "Error processing user request"), + (Severity::Warning, "Deprecated API usage detected"), + (Severity::Notice, "Configuration reloaded successfully"), + (Severity::Info, "Service startup completed"), + (Severity::Debug, "Processing request ID: 12345"), + ]; + + for (severity, message) in severities { + let entry = Entry::new(message).severity(severity).facility(Facility::Daemon); + + match syslog.emit(entry) { + Ok(()) => println!("✓ Emitted {} message", format!("{:?}", severity).to_lowercase()), + Err(e) => println!( + "✗ Failed to emit {} message: {}", + format!("{:?}", severity).to_lowercase(), + e + ), + } + } + + // Emit structured data example. + let structured_entry = Entry::new("Database connection established") + .severity(Severity::Info) + .facility(Facility::Daemon) + .field("db_host", "localhost") + .field("db_port", 5432) + .field("connection_time_ms", 45); + + match syslog.emit(structured_entry) { + Ok(()) => println!("✓ Emitted structured data message"), + Err(e) => println!("✗ Failed to emit structured data message: {}", e), + } + + // Demonstrate event code usage. + let event_code_entry = Entry::new("Service degraded performance detected") + .severity(Severity::Warning) + .facility(Facility::Daemon) + .event_code(2001); + + match syslog.emit(event_code_entry) { + Ok(()) => println!("✓ Emitted message with event code"), + Err(e) => println!("✗ Failed to emit message with event code: {}", e), + } + + // Large message handling. + let large_message = format!("Large diagnostic data: {}", "x".repeat(2000)); + let large_entry = Entry::new(large_message) + .severity(Severity::Debug) + .facility(Facility::Daemon); + + match syslog.emit(large_entry) { + Ok(()) => println!("✓ Emitted large message (will be truncated if > 1024 bytes)"), + Err(e) => println!("✗ Failed to emit large message: {}", e), + } + + // Flush any pending messages. + match syslog.flush() { + Ok(()) => println!("✓ Flushed messages to syslog"), + Err(e) => println!("✗ Failed to flush: {}", e), + } + + println!("\nExample completed. Check your system logs with:"); + println!(" journalctl -t dgw-unix-syslog-example"); + println!(" tail -f /var/log/syslog | grep dgw-unix-syslog-example"); + println!(" tail -f /var/log/messages | grep dgw-unix-syslog-example"); + + Ok(()) +} diff --git a/crates/sysevent-syslog/proptest-regressions/lib.txt b/crates/sysevent-syslog/proptest-regressions/lib.txt new file mode 100644 index 000000000..a7ceae9c7 --- /dev/null +++ b/crates/sysevent-syslog/proptest-regressions/lib.txt @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc ea4464b07f5314df5952edfd9c57db52179bae699cc8586cf0ed15c119c8c263 # shrinks to input = "" +cc 1cf52a11910484a2549af63c667cc215a561e318fa2d72dd9f88057060229cfc # shrinks to input = "%" diff --git a/crates/sysevent-syslog/src/lib.rs b/crates/sysevent-syslog/src/lib.rs new file mode 100644 index 000000000..ee23e58b7 --- /dev/null +++ b/crates/sysevent-syslog/src/lib.rs @@ -0,0 +1,331 @@ +#![cfg_attr(all(doc, unix), doc = include_str!("../README.md"))] +#![cfg(unix)] + +use std::ffi::{CStr, CString}; + +use sysevent::{Entry, Facility, Severity, SysEventError, SystemEventSink}; + +/// Configuration options for syslog connection. +#[derive(Debug, Clone)] +pub struct SyslogOptions { + /// Default facility if entry doesn't specify one + pub default_facility: Facility, + /// Include PID in log messages (LOG_PID flag) + pub log_pid: bool, + /// Connect immediately vs on first message (LOG_NDELAY flag) + pub no_delay: bool, + /// Also log to stderr (LOG_PERROR flag) + pub log_perror: bool, +} + +impl Default for SyslogOptions { + fn default() -> Self { + Self { + default_facility: Facility::User, + log_pid: true, + no_delay: false, + log_perror: false, + } + } +} + +impl SyslogOptions { + /// Sets the default facility. + #[must_use] + pub fn facility(mut self, facility: Facility) -> Self { + self.default_facility = facility; + self + } + + /// Sets whether to include PID in messages. + #[must_use] + pub fn log_pid(mut self, enabled: bool) -> Self { + self.log_pid = enabled; + self + } + + /// Sets whether to connect immediately. + #[must_use] + pub fn no_delay(mut self, enabled: bool) -> Self { + self.no_delay = enabled; + self + } + + /// Sets whether to also log to stderr. + #[must_use] + pub fn log_perror(mut self, enabled: bool) -> Self { + self.log_perror = enabled; + self + } + + fn to_flags(&self) -> libc::c_int { + let mut flags = 0; + if self.log_pid { + flags |= libc::LOG_PID; + } + if self.no_delay { + flags |= libc::LOG_NDELAY; + } + if self.log_perror { + flags |= libc::LOG_PERROR; + } + flags + } +} + +/// Syslog backend implementation. +#[derive(Debug)] +pub struct Syslog { + options: SyslogOptions, +} + +impl Syslog { + /// Creates a new syslog backend with the specified application name and options. + /// + /// # Arguments + /// * `appname` - Application identifier string (e.g., c"myapp") + /// * `options` - Configuration options for syslog behavior + pub fn new(appname: &'static CStr, options: SyslogOptions) -> Result { + // SAFETY: + // - `openlog` is thread-safe. + // - The appname pointer remains valid for the lifetime of the Syslog instance ('static). + unsafe { + libc::openlog( + appname.as_ptr(), + options.to_flags(), + 0, // facility will be specified per-message + ); + } + + Ok(Self { options }) + } +} + +impl SystemEventSink for Syslog { + fn emit(&self, entry: Entry) -> Result<(), SysEventError> { + let facility = entry.facility.unwrap_or(self.options.default_facility); + let priority = i32::from(calculate_pri(facility, entry.severity)); + let message = format_syslog_message(entry); + emit_to_syslog(priority, &message) + } + + fn flush(&self) -> Result<(), SysEventError> { + // Syslog is synchronous by default - no buffering to flush. + Ok(()) + } +} + +impl Drop for Syslog { + fn drop(&mut self) { + // SAFETY: closelog is thread-safe and idempotent + unsafe { + libc::closelog(); + } + } +} + +fn emit_to_syslog(priority: i32, message: &str) -> Result<(), SysEventError> { + // Escape percent characters to prevent format string interpretation. + let escaped_message = escape_percent(message); + + // Truncate message to syslog limits (1024 bytes per RFC 3164). + let truncated = if escaped_message.len() > 1024 { + // Leave room for "..." + format!("{}...", &escaped_message[..1021]) + } else { + escaped_message + }; + + let c_message = CString::new(truncated) + .map_err(|_| SysEventError::Invalid("message contains null byte after escaping".to_owned()))?; + + // SAFETY: syslog is thread-safe, and we have valid C strings + // The format string "%s" is safe and the message has been escaped + unsafe { + libc::syslog(priority, c"%s".as_ptr(), c_message.as_ptr()); + } + + Ok(()) +} + +/// Utility function to safely escape percent characters for syslog messages. +/// +/// All `%` characters are converted to `%%` to prevent format string interpretation. +fn escape_percent(input: &str) -> String { + input.replace('%', "%%") +} + +/// Calculates the syslog PRI value from facility and severity. +/// +/// PRI = Facility * 8 + Severity +const fn calculate_pri(facility: Facility, severity: Severity) -> u16 { + (facility.as_u8() as u16) * 8 + (severity.as_u8() as u16) +} + +fn format_syslog_message(mut entry: Entry) -> String { + if let Some(event_code) = entry.event_code { + entry.fields.push(("event_code".to_owned(), event_code.to_string())); + } + + let mut s = if entry.message.is_empty() { + "Empty log message".to_owned() + } else { + entry.message + }; + + // Format message with structured data. + if !entry.fields.is_empty() { + let mut first = true; + s.push(' '); + s.push('['); + entry.fields.iter().for_each(|(k, v)| { + if first { + first = false; + } else { + s.push(','); + } + s.push_str(k); + s.push('='); + s.push_str(v); + }); + s.push(']'); + } + + s +} + +#[cfg(test)] +mod tests { + use super::*; + + use proptest::prelude::*; + use sysevent::Severity; + + #[test] + fn syslog_options_builder() { + let opts = SyslogOptions::default() + .facility(Facility::Daemon) + .log_pid(false) + .no_delay(true) + .log_perror(true); + + assert_eq!(opts.default_facility, Facility::Daemon); + assert!(!opts.log_pid); + assert!(opts.no_delay); + assert!(opts.log_perror); + } + + #[test] + fn syslog_options_flags() { + let opts = SyslogOptions::default().log_pid(true).no_delay(true).log_perror(true); + assert_eq!(opts.to_flags(), libc::LOG_PID | libc::LOG_NDELAY | libc::LOG_PERROR); + } + + #[test] + fn message_escaping_comprehensive() { + let test_cases = vec![ + ("no percent", "no percent"), + ("100% complete", "100%% complete"), + ("%%already escaped%%", "%%%%already escaped%%%%"), + ("%s format string", "%%s format string"), + ("multiple % symbols %d %s", "multiple %% symbols %%d %%s"), + ]; + + for (input, expected) in test_cases { + assert_eq!(escape_percent(input), expected); + } + } + + #[test] + fn all_facility_priority_combinations() { + let facilities = [ + Facility::User, + Facility::Daemon, + Facility::Authpriv, + Facility::Local0, + Facility::Local1, + Facility::Local2, + Facility::Local3, + Facility::Local4, + Facility::Local5, + Facility::Local6, + Facility::Local7, + ]; + let severities = [ + Severity::Critical, + Severity::Error, + Severity::Warning, + Severity::Notice, + Severity::Info, + Severity::Debug, + ]; + + for facility in &facilities { + for severity in &severities { + let priority = calculate_pri(*facility, *severity); + let expected = u16::from(facility.as_u8()) * 8 + u16::from(severity.as_u8()); + assert_eq!(priority, expected); + assert!(priority <= 191); // Max possible value + } + } + } + + #[test] + fn pri_calculation() { + assert_eq!(calculate_pri(Facility::Daemon, Severity::Warning), 28); + assert_eq!(calculate_pri(Facility::User, Severity::Error), 11); + assert_eq!(calculate_pri(Facility::Local0, Severity::Info), 134); + } + + #[test] + fn syslog_message_formatting() { + assert_eq!(format_syslog_message(Entry::new("msg")), "msg"); + assert_eq!(format_syslog_message(Entry::new("")), "Empty log message"); + assert_eq!( + format_syslog_message(Entry::new("msg").event_code(5)), + "msg [event_code=5]" + ); + assert_eq!( + format_syslog_message(Entry::new("msg").field("abc", 10).field("efg", 'a')), + "msg [abc=10,efg=a]" + ); + } + + proptest! { + #[test] + fn escape_percent_all_percent_doubled(input in ".*") { + let escaped = escape_percent(&input); + let percent_count_original = input.matches('%').count(); + let percent_count_escaped = escaped.matches('%').count(); + + // Every % becomes %%, so we should have double the count. + prop_assert_eq!(percent_count_escaped, percent_count_original * 2); + } + + #[test] + fn escape_percent_no_other_chars_change(input in "[^%]*") { + // String with no % characters should be unchanged. + let escaped = escape_percent(&input); + prop_assert_eq!(escaped, input); + } + + #[test] + fn escape_percent_roundtrip_invariant(input in ".*") { + let escaped = escape_percent(&input); + + // Check that all % are properly doubled. + let chars: Vec = escaped.chars().collect(); + let mut i = 0; + while i < chars.len() { + if chars[i] == '%' { + // Every % should be followed by another %. + prop_assert!(i + 1 < chars.len(), "trailing % found in escaped string: {}", escaped); + prop_assert_eq!(chars[i + 1], '%', "single % found in escaped string: {}", escaped); + i += 2; // Skip the %% pair + } else { + i += 1; + } + } + } + } +} diff --git a/crates/sysevent-winevent/Cargo.toml b/crates/sysevent-winevent/Cargo.toml new file mode 100644 index 000000000..029a8d7e5 --- /dev/null +++ b/crates/sysevent-winevent/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "sysevent-winevent" +version = "0.0.0" +edition = "2024" +authors = ["Devolutions Inc. "] +description = "Windows Event Log backend for system-wide critical event logging" +license = "MIT OR Apache-2.0" +repository = "https://github.com/Devolutions/devolutions-gateway" +publish = false + +[dependencies] +sysevent.path = "../sysevent" +windows-sys = { version = "0.52", features = [ + "Win32_System_EventLog", + "Win32_Foundation" +] } + +[lints] +workspace = true diff --git a/crates/sysevent-winevent/README.md b/crates/sysevent-winevent/README.md new file mode 100644 index 000000000..f80a0af5d --- /dev/null +++ b/crates/sysevent-winevent/README.md @@ -0,0 +1,30 @@ +# sysevent-winevent + +Windows Event Log backend for system-wide critical event logging. + +This crate provides Windows Event Log implementation using Win32 APIs. + +## Platform Requirements + +- Windows systems with Event Log service +- Appropriate permissions to write to event log +- Event source registration in Windows Registry (recommended) + +## Event Source Registration + +For proper operation, register the event source in the Registry: +`HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\EventLog\Application\{SourceName}` + +## Examples + +```rust,no_run +use sysevent::{Entry, Severity, SystemEventSink}; +use sysevent_winevent::WinEvent; + +let winevent = WinEvent::new("MyApplication")?; + +let entry = Entry::new("Service startup failed").severity(Severity::Critical); + +winevent.emit(entry)?; +# Ok::<(), sysevent::SysEventError>(()) +``` diff --git a/crates/sysevent-winevent/examples/win_eventlog.rs b/crates/sysevent-winevent/examples/win_eventlog.rs new file mode 100644 index 000000000..d0f66236b --- /dev/null +++ b/crates/sysevent-winevent/examples/win_eventlog.rs @@ -0,0 +1,107 @@ +#[cfg(unix)] +fn main() {} + +#[cfg(windows)] +fn main() -> Result<(), Box> { + use sysevent::{Entry, Severity, SystemEventSink}; + use sysevent_winevent::WinEvent; + + println!("Windows Event Log Backend Example"); + println!("=================================="); + + // Create Windows Event Log backend. + let winevent = WinEvent::new("DgwSystemWideLogExample")?; + + println!("Created Windows Event Log backend for 'DgwSystemWideLogExample'"); + println!("Note: For production use, register the event source in the Registry:"); + println!( + " HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Services\\EventLog\\Application\\DgwSystemWideLogExample" + ); + + // Emit various severity levels. + let severities = [ + (Severity::Critical, "Critical system component failed"), + (Severity::Error, "Error processing user request"), + (Severity::Warning, "Deprecated API usage detected"), + (Severity::Notice, "Configuration reloaded successfully"), + (Severity::Info, "Service startup completed"), + (Severity::Debug, "Processing request ID: 12345"), + ]; + + for (severity, message) in severities { + let entry = Entry::new(message).severity(severity); + + match winevent.emit(entry) { + Ok(()) => println!("✓ Emitted {} message", format!("{:?}", severity).to_lowercase()), + Err(e) => println!( + "✗ Failed to emit {} message: {}", + format!("{:?}", severity).to_lowercase(), + e + ), + } + } + + // Emit structured data example + let structured_entry = Entry::new("Database connection established") + .severity(Severity::Info) + .field("db_host", "localhost") + .field("db_port", 1433) + .field("connection_time_ms", 32); + + match winevent.emit(structured_entry) { + Ok(()) => println!("✓ Emitted structured data message"), + Err(e) => println!("✗ Failed to emit structured data message: {}", e), + } + + // Demonstrate event code usage + let event_code_entry = Entry::new("Service degraded performance detected") + .severity(Severity::Warning) + .event_code(2001); + + match winevent.emit(event_code_entry) { + Ok(()) => println!("✓ Emitted message with event code 2001"), + Err(e) => println!("✗ Failed to emit message with event code: {}", e), + } + + // Large message handling + let large_message = format!("Large diagnostic data: {}", "x".repeat(50000)); + let large_entry = Entry::new(large_message).severity(Severity::Debug); + + match winevent.emit(large_entry) { + Ok(()) => println!("✓ Emitted large message (will be truncated if > 31KB)"), + Err(e) => println!("✗ Failed to emit large message: {}", e), + } + + // Unicode message handling + let unicode_entry = + Entry::new("Unicode test: Hello, 世界! Здравствуй мир! مرحبا بالعالم!").severity(Severity::Info); + + match winevent.emit(unicode_entry) { + Ok(()) => println!("✓ Emitted Unicode message"), + Err(e) => println!("✗ Failed to emit Unicode message: {}", e), + } + + // Empty message handling + let empty_entry = Entry::new("").severity(Severity::Notice); + + match winevent.emit(empty_entry) { + Ok(()) => println!("✓ Emitted empty message (replaced with default text)"), + Err(e) => println!("✗ Failed to emit empty message: {}", e), + } + + // Flush any pending messages + match winevent.flush() { + Ok(()) => println!("✓ Flushed messages to Windows Event Log"), + Err(e) => println!("✗ Failed to flush: {}", e), + } + + println!("\nExample completed. Check the Windows Event Log:"); + println!(" 1. Open Event Viewer (eventvwr.exe)"); + println!(" 2. Navigate to Windows Logs > Application"); + println!(" 3. Look for events from source 'DgwSystemWideLogExample'"); + println!( + " 4. Or use PowerShell: Get-WinEvent -FilterHashtable @{{LogName='Application'; ProviderName='DgwSystemWideLogExample'}}" + ); + + Ok(()) +} diff --git a/crates/sysevent-winevent/src/lib.rs b/crates/sysevent-winevent/src/lib.rs new file mode 100644 index 000000000..3ed7b4d07 --- /dev/null +++ b/crates/sysevent-winevent/src/lib.rs @@ -0,0 +1,218 @@ +#![cfg_attr(all(doc, windows), doc = include_str!("../README.md"))] +#![cfg(windows)] + +use std::sync::Arc; + +use sysevent::{Entry, Severity, SysEventError, SystemEventSink}; +use windows_sys::Win32::System::EventLog; + +type EventLogHandle = windows_sys::Win32::Foundation::HANDLE; + +/// Windows Event Log backend implementation. +#[derive(Debug)] +pub struct WinEvent { + handle: Arc, +} + +impl WinEvent { + /// Creates a new Windows Event Log backend with the specified source name. + /// + /// The source name should match a registered event source in the Windows Registry + /// for proper message formatting and categorization. + pub fn new(source_name: &str) -> Result { + let source_name_utf16 = to_null_terminated_utf16(source_name.as_ref()); + + // SAFETY: Proper UTF-16, null-terminated string. + let handle = unsafe { EventLog::RegisterEventSourceW(std::ptr::null(), source_name_utf16.as_ptr()) }; + + if handle == windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE { + return Err(SysEventError::Platform(format!( + "failed to register event source '{source_name}'" + ))); + } + + Ok(Self { + handle: Arc::new(handle), + }) + } + + fn emit_to_event_log(&self, entry: Entry) -> Result<(), SysEventError> { + let event_type = severity_to_event_type(entry.severity); + let event_id = entry.event_code.unwrap_or(1000); // Default event ID + + let message = if entry.message.is_empty() { + "Empty log message".to_owned() + } else { + entry.message + }; + + // Windows Event Log limits has a 31,839 characters size limit. + // Defensively truncate when the message is big (31836 bytes of UTF-8). + let truncated_message = if message.len() > 31836 { + // Enough space should be available for "...". + let mut idx = 31836; + + // Ensure idx is on a char boundary. + loop { + if message.get(..idx).is_some() { + break; + } + idx -= 1; + } + + let mut s = message; + s.truncate(idx); + s.push_str("..."); + s + } else { + message + }; + + // Prepare strings for structured logging. + let mut string_ptrs = Vec::new(); // Will be actually used as the parameter of the FFI function. + let mut utf16_strings = Vec::new(); // Keep the UTF-16 strings alive. + + // Add the English message as the first parameter for debugging context. + let message_utf16 = to_null_terminated_utf16(&truncated_message); + string_ptrs.push(message_utf16.as_ptr()); + utf16_strings.push(message_utf16); + + // Pass field values as insertion strings. + // The .mc message template will format the complete message using %1, %2, etc, so the key is ignored. + for (_key, value) in &entry.fields { + let value_utf16 = to_null_terminated_utf16(value); + string_ptrs.push(value_utf16.as_ptr()); + utf16_strings.push(value_utf16); + } + + let num_strings = u16::try_from(string_ptrs.len()).expect("not too many fields"); + + // SAFETY: + // - handle is valid (checked at construction) + // - strings array is valid with correct count + // - binary data pointer is null with size 0 + let success = unsafe { + EventLog::ReportEventW( + *self.handle, + event_type, + 0, // category + event_id, + std::ptr::null_mut(), // user SID + num_strings, + 0, // binary data size + string_ptrs.as_ptr(), + std::ptr::null(), // binary data + ) + }; + + if success == 0 { + return Err(SysEventError::Platform( + "failed to report event to Windows Event Log: ReportEventW returned 0".to_owned(), + )); + } + + Ok(()) + } +} + +impl SystemEventSink for WinEvent { + fn emit(&self, entry: Entry) -> Result<(), SysEventError> { + self.emit_to_event_log(entry) + } + + fn flush(&self) -> Result<(), SysEventError> { + // Windows Event Log is synchronous by default - no buffering to flush. + Ok(()) + } +} + +impl Drop for WinEvent { + fn drop(&mut self) { + if *self.handle != windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE { + // SAFETY: DeregisterEventSource is thread-safe and idempotent. + unsafe { + EventLog::DeregisterEventSource(*self.handle); + } + } + } +} + +fn severity_to_event_type(severity: Severity) -> u16 { + match severity { + Severity::Critical => EventLog::EVENTLOG_ERROR_TYPE, + Severity::Error => EventLog::EVENTLOG_ERROR_TYPE, + Severity::Warning => EventLog::EVENTLOG_WARNING_TYPE, + Severity::Notice | Severity::Info | Severity::Debug => EventLog::EVENTLOG_INFORMATION_TYPE, + } +} + +/// Converts a null-terminated string to UTF-16 for Windows APIs. +fn to_null_terminated_utf16(input: &str) -> Vec { + input.encode_utf16().chain(std::iter::once(0)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn severity_to_event_type_mapping() { + assert_eq!( + severity_to_event_type(Severity::Critical), + EventLog::EVENTLOG_ERROR_TYPE + ); + assert_eq!(severity_to_event_type(Severity::Error), EventLog::EVENTLOG_ERROR_TYPE); + assert_eq!( + severity_to_event_type(Severity::Warning), + EventLog::EVENTLOG_WARNING_TYPE + ); + assert_eq!( + severity_to_event_type(Severity::Notice), + EventLog::EVENTLOG_INFORMATION_TYPE + ); + assert_eq!( + severity_to_event_type(Severity::Info), + EventLog::EVENTLOG_INFORMATION_TYPE + ); + assert_eq!( + severity_to_event_type(Severity::Debug), + EventLog::EVENTLOG_INFORMATION_TYPE + ); + } + + #[test] + fn severity_event_type_completeness() { + // Ensure all severity levels are properly mapped + let severities = [ + Severity::Critical, + Severity::Error, + Severity::Warning, + Severity::Notice, + Severity::Info, + Severity::Debug, + ]; + + for severity in severities { + let event_type = severity_to_event_type(severity); + assert!(event_type > 0, "Event type should be positive for {:?}", severity); + } + } + + #[test] + fn to_null_terminated_utf16_basic() { + let result = to_null_terminated_utf16("hello"); + assert_eq!(result, [104, 101, 108, 108, 111, 0]); + } + + #[test] + fn to_null_terminated_utf16_conversion_edge_cases() { + // Test empty string. + let empty = to_null_terminated_utf16(""); + assert_eq!(empty, [0]); // Just null terminator + + // Test Unicode characters. + let unicode = to_null_terminated_utf16("Hello 世界"); + assert!(unicode.len() > 1); + assert_eq!(unicode.last(), Some(&0)); // Null terminated + } +} diff --git a/crates/sysevent/Cargo.toml b/crates/sysevent/Cargo.toml new file mode 100644 index 000000000..59dfe3d7d --- /dev/null +++ b/crates/sysevent/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "sysevent" +version = "0.0.0" +edition = "2024" +authors = ["Devolutions Inc. "] +description = "Core API for system-wide critical event logging with pluggable backends" +license = "MIT OR Apache-2.0" +repository = "https://github.com/Devolutions/devolutions-gateway" +publish = false + +[lints] +workspace = true diff --git a/crates/sysevent/README.md b/crates/sysevent/README.md new file mode 100644 index 000000000..e8d4764db --- /dev/null +++ b/crates/sysevent/README.md @@ -0,0 +1,12 @@ +# sysevent + +Core API for system-wide critical event logging with pluggable backends. + +This crate provides synchronous, manual event emission for critical application events. +Designed for reliability over performance, with immediate error feedback and no background processing. + +## Security Considerations + +- Not signal-safe: avoid emission from signal handlers +- Input sanitization: null bytes and control chars filtered +- Resource limits: respects system-imposed message size limits diff --git a/crates/sysevent/src/lib.rs b/crates/sysevent/src/lib.rs new file mode 100644 index 000000000..14c224f29 --- /dev/null +++ b/crates/sysevent/src/lib.rs @@ -0,0 +1,200 @@ +#![cfg_attr(doc, doc = include_str!("../README.md"))] + +use std::time::SystemTime; + +/// Severity levels for log entries, mapped to standard syslog levels. +/// +/// ``` +///# use sysevent::Severity; +/// assert!(Severity::Critical < Severity::Warning); +/// assert!(Severity::Debug > Severity::Info); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(u8)] +pub enum Severity { + /// Critical conditions (2) + Critical = 2, + /// Error conditions (3) + Error = 3, + /// Warning conditions (4) + Warning = 4, + /// Normal but significant condition (5) + Notice = 5, + /// Informational messages (6) + Info = 6, + /// Debug-level messages (7) + Debug = 7, +} + +impl Severity { + /// Returns the numeric syslog severity level (0-7). + /// + /// ``` + ///# use sysevent::Severity; + /// assert_eq!(Severity::Critical.as_u8(), 2); + /// ``` + pub const fn as_u8(self) -> u8 { + self as u8 + } +} + +/// Syslog facility codes for categorizing log entries. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum Facility { + /// User-level messages (1) + User = 1, + /// System daemons (3) + Daemon = 3, + /// Security/authorization messages (10) + Authpriv = 10, + /// Local use facility 0 (16) + Local0 = 16, + /// Local use facility 1 (17) + Local1 = 17, + /// Local use facility 2 (18) + Local2 = 18, + /// Local use facility 3 (19) + Local3 = 19, + /// Local use facility 4 (20) + Local4 = 20, + /// Local use facility 5 (21) + Local5 = 21, + /// Local use facility 6 (22) + Local6 = 22, + /// Local use facility 7 (23) + Local7 = 23, +} + +impl Facility { + /// Returns the numeric syslog facility code. + /// + /// ``` + ///# use sysevent::Facility; + /// assert_eq!(Facility::User.as_u8(), 1); + /// assert_eq!(Facility::Local0.as_u8(), 16); + /// assert_eq!(Facility::Local7.as_u8(), 23); + /// ``` + pub const fn as_u8(self) -> u8 { + self as u8 + } +} + +/// A structured log entry containing event metadata and message. +#[derive(Debug, Clone)] +pub struct Entry { + /// Event occurrence time + pub timestamp: SystemTime, + /// Event severity level + pub severity: Severity, + /// Syslog facility (ignored on Windows) + pub facility: Option, + /// Platform-specific event code + pub event_code: Option, + /// Primary message text + pub message: String, + /// Structured key-value pairs + pub fields: Vec<(String, String)>, +} + +impl Entry { + /// Creates a new log entry with the given application name. + pub fn new(message: impl Into) -> Self { + Self { + timestamp: SystemTime::now(), + severity: Severity::Info, + facility: Some(Facility::User), + event_code: None, + message: message.into(), + fields: Vec::new(), + } + } + + /// Sets the severity level. + #[must_use] + pub fn severity(mut self, severity: Severity) -> Self { + self.severity = severity; + self + } + + /// Sets the syslog facility. + #[must_use] + pub fn facility(mut self, facility: Facility) -> Self { + self.facility = Some(facility); + self + } + + /// Sets the event code. + #[must_use] + pub fn event_code(mut self, code: u32) -> Self { + self.event_code = Some(code); + self + } + + /// Adds a structured field. + #[must_use] + pub fn field, V: ToString>(mut self, key: K, value: V) -> Self { + self.fields.push((key.into(), value.to_string())); + self + } +} + +/// Errors that can occur during event emission. +#[derive(Debug)] +pub enum SysEventError { + /// I/O operation failures + Io(std::io::Error), + /// OS-specific errors with description + Platform(String), + /// Input validation failures + Invalid(String), + /// System resource limits exceeded + ResourceExhausted, +} + +impl std::fmt::Display for SysEventError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SysEventError::Io(e) => write!(f, "I/O error: {e}"), + SysEventError::Platform(msg) => write!(f, "platform error: {msg}"), + SysEventError::Invalid(msg) => write!(f, "invalid input: {msg}"), + SysEventError::ResourceExhausted => write!(f, "system resource exhausted"), + } + } +} + +impl std::error::Error for SysEventError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + SysEventError::Io(e) => Some(e), + _ => None, + } + } +} + +impl From for SysEventError { + fn from(e: std::io::Error) -> Self { + SysEventError::Io(e) + } +} + +/// Object-safe trait for system event backends. +pub trait SystemEventSink: Send + Sync { + /// Emits a log entry, returning immediate success/failure status. + fn emit(&self, entry: Entry) -> Result<(), SysEventError>; + + /// Flushes any pending events to the underlying system. + fn flush(&self) -> Result<(), SysEventError>; +} + +pub struct NoopSink; + +impl SystemEventSink for NoopSink { + fn emit(&self, _: Entry) -> Result<(), SysEventError> { + Ok(()) + } + + fn flush(&self) -> Result<(), SysEventError> { + Ok(()) + } +} diff --git a/devolutions-gateway/Cargo.toml b/devolutions-gateway/Cargo.toml index 9d4bffee6..bbc3e99dd 100644 --- a/devolutions-gateway/Cargo.toml +++ b/devolutions-gateway/Cargo.toml @@ -32,6 +32,8 @@ network-scanner.path = "../crates/network-scanner" video-streamer.path = "../crates/video-streamer" terminal-streamer.path = "../crates/terminal-streamer" network-monitor.path = "../crates/network-monitor" +sysevent.path = "../crates/sysevent" +sysevent-codes.path = "../crates/sysevent-codes" ironrdp-pdu = { version = "0.5", features = ["std"] } ironrdp-core = { version = "0.1", features = ["std"] } ironrdp-rdcleanpath = "0.1" @@ -119,8 +121,12 @@ etherparse = "0.15" # For KDC proxy portpicker = "0.1" +[target.'cfg(unix)'.dependencies] +sysevent-syslog.path = "../crates/sysevent-syslog" + [target.'cfg(windows)'.dependencies] rustls-cng = { version = "0.5", default-features = false, features = ["logging", "tls12", "ring"] } +sysevent-winevent.path = "../crates/sysevent-winevent" [target.'cfg(windows)'.build-dependencies] embed-resource = "3.0" diff --git a/devolutions-gateway/build.rs b/devolutions-gateway/build.rs index df6d98db0..82ef0d4bd 100644 --- a/devolutions-gateway/build.rs +++ b/devolutions-gateway/build.rs @@ -1,6 +1,9 @@ fn main() { #[cfg(target_os = "windows")] win::embed_version_rc(); + + #[cfg(target_os = "windows")] + win::embed_devolutions_gateway_mc(); } #[cfg(target_os = "windows")] @@ -83,4 +86,95 @@ END"#, version_rc } + + pub(super) fn embed_devolutions_gateway_mc() { + use std::env; + use std::path::PathBuf; + use std::process::Command; + + // --- gate: only release builds ------------------------------------- + let profile = env::var("PROFILE").unwrap_or_default(); + if profile != "release" { + return; + } + + // --- gate: ignore with a warning when mc is not found -------------- + let mc_exe_path = match find_mc() { + Some(path) => path, + None => { + println!("cargo:warning=Did not find mc.exe"); + return; + } + }; + + // --- inputs/paths --------------------------------------------------- + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let mc_file = manifest_dir.join("devolutions-gateway.mc"); // adjust if stored elsewhere + + // Always tell Cargo to re-run if the .mc changes + println!("cargo:rerun-if-changed={}", mc_file.display()); + + // --- prepare OUT_DIR ------------------------------------------------ + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR not set")); + // We'll run mc.exe with current_dir = OUT_DIR so the generated .rc lands in OUT_DIR. + let rc_path = out_dir.join("devolutions-gateway.rc"); + + // --- run mc.exe ----------------------------------------------------- + // Requires Windows SDK tools in PATH (use a "x64 Native Tools Command Prompt for VS"). + // Flags: + // -u : Unicode + // -m : Generate message resource .bin files + // -h : header output dir (we put it in OUT_DIR; the header is unused by Rust) + // -r : message .bin output dir (OUT_DIR) + let status = Command::new(mc_exe_path) + .current_dir(&out_dir) + .arg("-um") + .arg("-h") + .arg(".") + .arg("-r") + .arg(".") + .arg(mc_file.canonicalize().expect("failed to canonicalize .mc path")) + .status() + .expect("failed to spawn mc.exe"); + if !status.success() { + panic!("mc.exe failed with status {status}"); + } + + // --- compile the generated .rc via embed-resource ------------------- + if !rc_path.exists() { + panic!("mc.exe did not produce expected .rc file at {}", rc_path.display()); + } + + // Compile/link the .rc into the final binary. + // This will call rc.exe under the hood. + embed_resource::compile(rc_path, embed_resource::NONE) + .manifest_required() + .unwrap(); + + // Optional: make Cargo re-run if locale bins change (paranoid but harmless) + // These are standard names emitted by mc.exe for EN/FR/DE in our .mc. + for loc in &["MSG00409.bin", "MSG0040c.bin", "MSG00407.bin"] { + let p = out_dir.join(loc); + println!("cargo:rerun-if-changed={}", p.display()); + } + } + + fn find_mc() -> Option { + if let Ok(sdk_bin) = env::var("WindowsSdkVerBinPath") { + let p = std::path::Path::new(&sdk_bin).join("mc.exe"); + if p.exists() { + return Some(p); + } + } + + if let Ok(sdk_dir) = env::var("WindowsSdkDir") { + // e.g. C:\Program Files (x86)\Windows Kits\10\ + let candidate = std::path::Path::new(&sdk_dir).join("bin").join("x64").join("mc.exe"); + if candidate.exists() { + return Some(candidate); + } + } + + None + } } diff --git a/devolutions-gateway/devolutions-gateway.mc b/devolutions-gateway/devolutions-gateway.mc new file mode 100644 index 000000000..4a9b99f8a --- /dev/null +++ b/devolutions-gateway/devolutions-gateway.mc @@ -0,0 +1,405 @@ +; ---------------------------------------------------------------------- +; Devolutions Gateway - Windows Event Log message definitions (.mc) +; English (0x409), French (0x40c), German (0x407) +; ---------------------------------------------------------------------- + +MessageIdTypedef=DWORD + +SeverityNames=( + Success=0x0:STATUS_SEVERITY_SUCCESS + Informational=0x1:STATUS_SEVERITY_INFORMATIONAL + Warning=0x2:STATUS_SEVERITY_WARNING + Error=0x3:STATUS_SEVERITY_ERROR +) + +FacilityNames=( + Application=0x0:FACILITY_APPLICATION +) + +LanguageNames=( + English=0x409:MSG00409 + French=0x40c:MSG0040c + German=0x407:MSG00407 +) + +; ====================================================================== +; 1000-1099 Service / Lifecycle +; ====================================================================== + +MessageId=1000 +SymbolicName=SERVICE_STARTED +Language=English +Service started. Context=%1 Version=%2 +Language=French +Service démarré. Contexte=%1 Version=%2 +Language=German +Dienst gestartet. Kontext=%1 Version=%2 +. + +MessageId=1001 +SymbolicName=SERVICE_STOPPING +Language=English +Service stopping. Context=%1 Reason=%2 +Language=French +Arrêt du service. Contexte=%1 Raison=%2 +Language=German +Dienst wird gestoppt. Kontext=%1 Grund=%2 +. + +MessageId=1010 +SymbolicName=CONFIG_INVALID +Language=English +Configuration invalid. Context=%1 Path=%2 Error=%3 Reason=%4 +Language=French +Configuration invalide. Contexte=%1 Chemin=%2 Erreur=%3 Raison=%4 +Language=German +Ungültige Konfiguration. Kontext=%1 Pfad=%2 Fehler=%3 Grund=%4 +. + +MessageId=1020 +SymbolicName=START_FAILED +Language=English +Start failed. Context=%1 Cause=%2 Error=%3 +Language=French +Échec du démarrage. Contexte=%1 Cause=%2 Erreur=%3 +Language=German +Start fehlgeschlagen. Kontext=%1 Ursache=%2 Fehler=%3 +. + +MessageId=1030 +SymbolicName=BOOT_STACKTRACE_WRITTEN +Language=English +Boot stacktrace written. Context=%1 Path=%2 +Language=French +Trace d’amorçage écrite. Contexte=%1 Chemin=%2 +Language=German +Boot-Stacktrace geschrieben. Kontext=%1 Pfad=%2 +. + +; ====================================================================== +; 2000-2099 Listeners & Networking +; ====================================================================== + +MessageId=2000 +SymbolicName=LISTENER_STARTED +Language=English +Listener started. Context=%1 Address=%2 Proto=%3 +Language=French +Écouteur démarré. Contexte=%1 Adresse=%2 Protocole=%3 +Language=German +Listener gestartet. Kontext=%1 Adresse=%2 Protokoll=%3 +. + +MessageId=2001 +SymbolicName=LISTENER_BIND_FAILED +Language=English +Listener bind failed. Context=%1 Address=%2 Error=%3 +Language=French +Échec de l’attachement de l’écouteur. Contexte=%1 Adresse=%2 Erreur=%3 +Language=German +Listener-Bind fehlgeschlagen. Kontext=%1 Adresse=%2 Fehler=%3 +. + +MessageId=2002 +SymbolicName=LISTENER_STOPPED +Language=English +Listener stopped. Context=%1 Address=%2 Reason=%3 +Language=French +Écouteur arrêté. Contexte=%1 Adresse=%2 Raison=%3 +Language=German +Listener gestoppt. Kontext=%1 Adresse=%2 Grund=%3 +. + +; ====================================================================== +; 3000-3099 TLS / Certificates +; ====================================================================== + +MessageId=3000 +SymbolicName=TLS_CONFIGURED +Language=English +TLS configured. Context=%1 Source=%2 +Language=French +TLS configuré. Contexte=%1 Source=%2 +Language=German +TLS konfiguriert. Kontext=%1 Quelle=%2 +. + +MessageId=3001 +SymbolicName=TLS_VERIFY_STRICT_DISABLED +Language=English +TLS strict verification disabled. Context=%1 Mode=%2 +Language=French +Vérification stricte TLS désactivée. Contexte=%1 Mode=%2 +Language=German +Strikte TLS-Überprüfung deaktiviert. Kontext=%1 Modus=%2 +. + +MessageId=3002 +SymbolicName=TLS_CERTIFICATE_REJECTED +Language=English +Certificate rejected. Context=%1 Subject=%2 Reason=%3 +Language=French +Certificat rejeté. Contexte=%1 Sujet=%2 Raison=%3 +Language=German +Zertifikat abgelehnt. Kontext=%1 Betreff=%2 Grund=%3 +. + +MessageId=3003 +SymbolicName=SYSTEM_CERT_SELECTED +Language=English +System certificate selected. Context=%1 Thumbprint=%2 Subject=%3 +Language=French +Certificat système sélectionné. Contexte=%1 Empreinte=%2 Sujet=%3 +Language=German +Systemzertifikat ausgewählt. Kontext=%1 Fingerabdruck=%2 Betreff=%3 +. + +MessageId=3004 +SymbolicName=TLS_KEY_LOAD_FAILED +Language=English +TLS key/cert load failed. Context=%1 Path=%2 Error=%3 Reason=%4 +Language=French +Échec du chargement de la clé/cert TLS. Contexte=%1 Chemin=%2 Erreur=%3 Raison=%4 +Language=German +TLS-Schlüssel/Zertifikat konnte nicht geladen werden. Kontext=%1 Pfad=%2 Fehler=%3 Grund=%4 +. + +MessageId=3005 +SymbolicName=TLS_CERTIFICATE_NAME_MISMATCH +Language=English +TLS certificate name mismatch. Context=%1 Hostname=%2 Subject=%3 Reason=%4 +Language=French +Nom du certificat TLS non concordant. Contexte=%1 Hôte=%2 Sujet=%3 Raison=%4 +Language=German +TLS-Zertifikat-Namen stimmt nicht überein. Kontext=%1 Hostname=%2 Betreff=%3 Grund=%4 +. + +MessageId=3006 +SymbolicName=TLS_NO_SUITABLE_CERTIFICATE +Language=English +No suitable certificate found. Context=%1 Error=%2 Issues=%3 +Language=French +Aucun certificat approprié trouvé. Contexte=%1 Erreur=%2 Problèmes=%3 +Language=German +Kein geeignetes Zertifikat gefunden. Kontext=%1 Fehler=%2 Probleme=%3 +. + +; ====================================================================== +; 4000-4099 Sessions, Tokens & Recording +; ====================================================================== + +MessageId=4000 +SymbolicName=SESSION_OPENED +Language=English +Session opened. Context=%1 Protocol=%2 Client=%3 Target=%4 TokenId=%5 +Language=French +Session ouverte. Contexte=%1 Protocole=%2 Client=%3 Cible=%4 Jeton=%5 +Language=German +Sitzung geöffnet. Kontext=%1 Protokoll=%2 Client=%3 Ziel=%4 Token=%5 +. + +MessageId=4001 +SymbolicName=SESSION_CLOSED +Language=English +Session closed. Context=%1 DurationMs=%2 BytesTx=%3 BytesRx=%4 Outcome=%5 +Language=French +Session fermée. Contexte=%1 DuréeMs=%2 OctetsTx=%3 OctetsRx=%4 Résultat=%5 +Language=German +Sitzung geschlossen. Kontext=%1 DauerMs=%2 BytesTx=%3 BytesRx=%4 Ergebnis=%5 +. + +MessageId=4010 +SymbolicName=TOKEN_PROVISIONED +Language=English +Token provisioned. Context=%1 TokenId=%2 +Language=French +Jeton provisionné. Contexte=%1 Jeton=%2 +Language=German +Token bereitgestellt. Kontext=%1 Token=%2 +. + +MessageId=4011 +SymbolicName=TOKEN_REUSED +Language=English +Token reused. Context=%1 TokenId=%2 ReuseCount=%3 +Language=French +Jeton réutilisé. Contexte=%1 Jeton=%2 Réutilisations=%3 +Language=German +Token wiederverwendet. Kontext=%1 Token=%2 Anzahl=%3 +. + +MessageId=4012 +SymbolicName=TOKEN_REUSE_LIMIT_EXCEEDED +Language=English +Token reuse limit exceeded. Context=%1 TokenId=%2 Limit=%3 Reason=%4 +Language=French +Limite de réutilisation du jeton dépassée. Contexte=%1 Jeton=%2 Limite=%3 Raison=%4 +Language=German +Token-Wiederverwendungsgrenze überschritten. Kontext=%1 Token=%2 Limit=%3 Grund=%4 +. + +MessageId=4030 +SymbolicName=RECORDING_STARTED +Language=English +Recording started. Context=%1 Destination=%2 +Language=French +Enregistrement démarré. Contexte=%1 Destination=%2 +Language=German +Aufnahme gestartet. Kontext=%1 Ziel=%2 +. + +MessageId=4031 +SymbolicName=RECORDING_STOPPED +Language=English +Recording stopped. Context=%1 Bytes=%2 Files=%3 +Language=French +Enregistrement arrêté. Contexte=%1 Octets=%2 Fichiers=%3 +Language=German +Aufnahme gestoppt. Kontext=%1 Bytes=%2 Dateien=%3 +. + +MessageId=4032 +SymbolicName=RECORDING_ERROR +Language=English +Recording error. Context=%1 Path=%2 Error=%3 +Language=French +Erreur d’enregistrement. Contexte=%1 Chemin=%2 Erreur=%3 +Language=German +Aufnahmefehler. Kontext=%1 Pfad=%2 Fehler=%3 +. + +; ====================================================================== +; 5000-5099 Authentication / Authorization +; ====================================================================== + +MessageId=5001 +SymbolicName=JWT_REJECTED +Language=English +JWT rejected. Context=%1 ReasonCode=%2 Reason=%3 +Language=French +JWT rejeté. Contexte=%1 CodeRaison=%2 Raison=%3 +Language=German +JWT abgelehnt. Kontext=%1 GrundCode=%2 Grund=%3 +. + +MessageId=5002 +SymbolicName=JWT_ANOMALY +Language=English +JWT anomaly. Context=%1 Issuer=%2 Audience=%3 Kid=%4 Kind=%5 Detail=%6 +Language=French +Anomalie JWT. Contexte=%1 Émetteur=%2 Audience=%3 Kid=%4 Type=%5 Détail=%6 +Language=German +JWT-Anomalie. Kontext=%1 Aussteller=%2 Audience=%3 Kid=%4 Typ=%5 Detail=%6 +. + +MessageId=5010 +SymbolicName=AUTHORIZATION_DENIED +Language=English +Authorization denied. Context=%1 Subject=%2 Action=%3 Resource=%4 Rule=%5 Reason=%6 +Language=French +Autorisation refusée. Contexte=%1 Sujet=%2 Action=%3 Ressource=%4 Règle=%5 Raison=%6 +Language=German +Autorisierung verweigert. Kontext=%1 Subjekt=%2 Aktion=%3 Ressource=%4 Regel=%5 Grund=%6 +. + +MessageId=5090 +SymbolicName=AUTH_SUMMARY +Language=English +Auth summary. Context=%1 IntervalSec=%2 JwtOk=%3 JwtRejected=%4 Denied=%5 ByReason=%6 +Language=French +Résumé d’auth. Contexte=%1 IntervalSec=%2 JwtOk=%3 JwtRejeté=%4 Refusé=%5 ParRaison=%6 +Language=German +Auth-Zusammenfassung. Kontext=%1 IntervallSek=%2 JwtOk=%3 JwtAbgelehnt=%4 Verweigert=%5 NachGrund=%6 +. + +; ====================================================================== +; 6000-6099 Agent Integration +; ====================================================================== + +MessageId=6000 +SymbolicName=USER_SESSION_PROCESS_STARTED +Language=English +User session process started. Context=%1 SessionId=%2 Kind=%3 Exe=%4 +Language=French +Processus de session utilisateur démarré. Contexte=%1 SessionId=%2 Type=%3 Exe=%4 +Language=German +Benutzersitzungsprozess gestartet. Kontext=%1 SessionId=%2 Typ=%3 Exe=%4 +. + +MessageId=6001 +SymbolicName=USER_SESSION_PROCESS_TERMINATED +Language=English +User session process terminated. Context=%1 SessionId=%2 ExitCode=%3 By=%4 +Language=French +Processus de session utilisateur terminé. Contexte=%1 SessionId=%2 CodeSortie=%3 Par=%4 +Language=German +Benutzersitzungsprozess beendet. Kontext=%1 SessionId=%2 ExitCode=%3 Durch=%4 +. + +MessageId=6010 +SymbolicName=UPDATER_TASK_ENABLED +Language=English +Updater task enabled. Context=%1 +Language=French +Tâche de mise à jour activée. Contexte=%1 +Language=German +Update-Aufgabe aktiviert. Kontext=%1 +. + +MessageId=6011 +SymbolicName=UPDATER_ERROR +Language=English +Updater error. Context=%1 Step=%2 Error=%3 +Language=French +Erreur de mise à jour. Contexte=%1 Étape=%2 Erreur=%3 +Language=German +Update-Fehler. Kontext=%1 Schritt=%2 Fehler=%3 +. + +MessageId=6020 +SymbolicName=PEDM_ENABLED +Language=English +PEDM enabled. Context=%1 +Language=French +PEDM activé. Contexte=%1 +Language=German +PEDM aktiviert. Kontext=%1 +. + +; ====================================================================== +; 7000-7099 Health +; ====================================================================== + +MessageId=7010 +SymbolicName=RECORDING_STORAGE_LOW +Language=English +Recording storage low. Context=%1 RemainingBytes=%2 ThresholdBytes=%3 +Language=French +Espace d’enregistrement faible. Contexte=%1 OctetsRestants=%2 Seuil=%3 +Language=German +Aufnahmespeicher niedrig. Kontext=%1 VerbleibendeBytes=%2 Schwelle=%3 +. + +; ====================================================================== +; 9000-9099 Diagnostics +; ====================================================================== + +MessageId=9001 +SymbolicName=DEBUG_OPTIONS_ENABLED +Language=English +Debug options enabled. Context=%1 Options=%2 +Language=French +Options de débogage activées. Contexte=%1 Options=%2 +Language=German +Debug-Optionen aktiviert. Kontext=%1 Optionen=%2 +. + +MessageId=9002 +SymbolicName=XMF_NOT_FOUND +Language=English +XMF not found. Context=%1 Path=%2 Error=%3 +Language=French +XMF introuvable. Contexte=%1 Chemin=%2 Erreur=%3 +Language=German +XMF nicht gefunden. Kontext=%1 Pfad=%2 Fehler=%3 +. diff --git a/devolutions-gateway/src/config.rs b/devolutions-gateway/src/config.rs index fd113e320..78c66bff2 100644 --- a/devolutions-gateway/src/config.rs +++ b/devolutions-gateway/src/config.rs @@ -15,6 +15,7 @@ use tokio_rustls::rustls::pki_types; use url::Url; use uuid::Uuid; +use crate::SYSTEM_LOGGER; use crate::credential::Password; use crate::listener::ListenerUrls; use crate::target_addr::TargetAddr; @@ -184,9 +185,12 @@ impl Conf { private_key, }; - Tls::init(cert_source, strict_checks) - .context("failed to initialize TLS configuration")? - .pipe(Some) + let tls = + Tls::init(cert_source, strict_checks).context("failed to initialize TLS configuration")?; + + let _ = SYSTEM_LOGGER.emit(sysevent_codes::tls_configured("filesystem")); + + Some(tls) } }, dto::CertSource::System => match conf_file.tls_certificate_subject_name.clone() { @@ -207,9 +211,12 @@ impl Conf { store_name, }; - Tls::init(cert_source, strict_checks) - .context("failed to initialize TLS configuration")? - .pipe(Some) + let tls = + Tls::init(cert_source, strict_checks).context("failed to initialize TLS configuration")?; + + let _ = SYSTEM_LOGGER.emit(sysevent_codes::tls_configured("system")); + + Some(tls) } }, }; diff --git a/devolutions-gateway/src/lib.rs b/devolutions-gateway/src/lib.rs index bc460ad77..104f16836 100644 --- a/devolutions-gateway/src/lib.rs +++ b/devolutions-gateway/src/lib.rs @@ -44,6 +44,9 @@ pub mod ws; use std::sync::Arc; +pub const SYSTEM_LOGGER: std::sync::LazyLock> = + std::sync::LazyLock::new(init_system_logger); + #[derive(Clone)] pub struct DgwState { pub conf_handle: config::ConfHandle, @@ -148,3 +151,24 @@ pub fn make_http_service(state: DgwState) -> axum::Router<()> { .layer(TimeoutLayer::new(Duration::from_secs(15))), ) } + +fn init_system_logger() -> Arc { + cfg_if::cfg_if! { + if #[cfg(all(not(debug_assertions), unix))] { + let options = sysevent_syslog::SyslogOptions::default() + .log_pid(true) + .facility(sysevent::Facility::Daemon); + match sysevent_syslog::Syslog::new(c"devolutions-gateway", options) { + Ok(syslog) => Arc::new(syslog), + Err(_) => Arc::new(sysevent::NoopSink), + } + } else if #[cfg(all(not(debug_assertions), windows))] { + match sysevent_winevent::WinEvent::new("Devolutions Gateway") { + Ok(winevent) => Arc::new(winevent), + Err(_) => Arc::new(sysevent::NoopSink), + } + } else { + Arc::new(sysevent::NoopSink) + } + } +} diff --git a/devolutions-gateway/src/main.rs b/devolutions-gateway/src/main.rs index b49437a7b..36bca2dee 100644 --- a/devolutions-gateway/src/main.rs +++ b/devolutions-gateway/src/main.rs @@ -33,6 +33,7 @@ use anyhow::Context; use ceviche::controller::{Controller, ControllerInterface, dispatch}; use ceviche::{Service, ServiceEvent}; use cfg_if::cfg_if; +use devolutions_gateway::SYSTEM_LOGGER; use devolutions_gateway::config::ConfHandle; use std::sync::mpsc; use tap::prelude::*; @@ -52,7 +53,7 @@ fn main() -> anyhow::Result<()> { let bootstacktrace_path = devolutions_gateway::config::get_data_dir().join("boot.stacktrace"); if let Err(write_error) = std::fs::write(&bootstacktrace_path, format!("{error:?}")) { - eprintln!("Failed to the boot stacktrace to {bootstacktrace_path}: {write_error}"); + eprintln!("Failed to write the boot stacktrace to {bootstacktrace_path}: {write_error}"); } }) } @@ -70,7 +71,7 @@ fn run() -> anyhow::Result<()> { if let Some(path) = args.next() { config_path = Some(path); } else { - return Err(anyhow::anyhow!("missing value for --config-path")); + anyhow::bail!("missing value for --config-path"); } } else { remaining_args.push(arg); @@ -198,9 +199,15 @@ fn gateway_service_main( _args: Vec, _standalone_mode: bool, ) -> u32 { - let Ok(conf_handle) = ConfHandle::init() else { - // At this point, the logger is not yet initialized. - return BAD_CONFIG_ERR_CODE; + let conf_handle = match ConfHandle::init() { + Ok(conf_handle) => conf_handle, + Err(error) => { + let _ = SYSTEM_LOGGER.emit(sysevent_codes::config_invalid( + &error, + devolutions_gateway::config::get_data_dir().join("gateway.json"), + )); + return BAD_CONFIG_ERR_CODE; + } }; let mut service = match GatewayService::load(conf_handle) { @@ -208,14 +215,19 @@ fn gateway_service_main( Err(error) => { // At this point, the logger may or may not be initialized. error!(error = format!("{error:#}"), "Failed to load service"); + let _ = SYSTEM_LOGGER.emit(sysevent_codes::start_failed(&error, "service_load")); return START_FAILED_ERR_CODE; } }; match service.start() { - Ok(()) => info!("{} service started", SERVICE_NAME), + Ok(()) => { + info!("{} service started", SERVICE_NAME); + let _ = SYSTEM_LOGGER.emit(sysevent_codes::service_started(env!("CARGO_PKG_VERSION"))); + } Err(error) => { error!(error = format!("{error:#}"), "Failed to start"); + let _ = SYSTEM_LOGGER.emit(sysevent_codes::start_failed(&error, "service_start")); return START_FAILED_ERR_CODE; } } @@ -232,6 +244,7 @@ fn gateway_service_main( } info!("{} service stopping", SERVICE_NAME); + let _ = SYSTEM_LOGGER.emit(sysevent_codes::service_stopping("received stop control code")); 0 } diff --git a/devolutions-gateway/src/middleware/auth.rs b/devolutions-gateway/src/middleware/auth.rs index f259413c2..c96462254 100644 --- a/devolutions-gateway/src/middleware/auth.rs +++ b/devolutions-gateway/src/middleware/auth.rs @@ -10,12 +10,12 @@ use axum_extra::TypedHeader; use axum_extra::headers::Authorization; use axum_extra::headers::authorization::Bearer; -use crate::DgwState; use crate::config::Conf; use crate::http::HttpError; use crate::recording::ActiveRecordings; use crate::session::DisconnectedInfo; use crate::token::{AccessTokenClaims, CurrentJrl, TokenCache, TokenValidator}; +use crate::{DgwState, SYSTEM_LOGGER}; struct AuthException { method: Method, @@ -150,7 +150,7 @@ pub async fn auth_middleware( let conf = conf_handle.get_conf(); - let access_token_claims = authenticate( + let result = authenticate( source_addr, token, &conf, @@ -158,8 +158,26 @@ pub async fn auth_middleware( &jrl, &recordings.active_recordings, disconnected_info, - ) - .map_err(HttpError::unauthorized().err())?; + ); + + let access_token_claims = match result { + Ok(access_token_claims) => access_token_claims, + Err(error) => { + match &error { + crate::token::TokenError::SignatureVerification { source, key } => { + let _ = SYSTEM_LOGGER.emit( + sysevent_codes::jwt_rejected("bad_signature", format!("{source:#}")).field("key", key), + ); + } + crate::token::TokenError::UnexpectedReplay { reason } => { + let _ = SYSTEM_LOGGER.emit(sysevent_codes::jwt_rejected("unexpected_replay", reason)); + } + _ => {} + } + + return Err(HttpError::unauthorized().err()(error)); + } + }; let mut request = Request::from_parts(parts, body); diff --git a/devolutions-gateway/src/service.rs b/devolutions-gateway/src/service.rs index 27b7cbb13..53ff0c593 100644 --- a/devolutions-gateway/src/service.rs +++ b/devolutions-gateway/src/service.rs @@ -10,7 +10,7 @@ use devolutions_gateway::recording::recording_message_channel; use devolutions_gateway::session::session_manager_channel; use devolutions_gateway::subscriber::subscriber_channel; use devolutions_gateway::token::{CurrentJrl, JrlTokenClaims}; -use devolutions_gateway::{DgwState, config}; +use devolutions_gateway::{DgwState, SYSTEM_LOGGER, config}; use devolutions_gateway_task::{ChildTask, ShutdownHandle, ShutdownSignal}; use devolutions_log::{self, LoggerGuard}; use parking_lot::Mutex; @@ -59,6 +59,7 @@ impl GatewayService { ?conf.debug, "**DEBUG OPTIONS ARE ENABLED, PLEASE DO NOT USE IN PRODUCTION**", ); + let _ = SYSTEM_LOGGER.emit(sysevent_codes::debug_options_enabled(format!("{:?}", conf.debug))); } if conf_file.tls_private_key_password.is_some() { @@ -70,6 +71,9 @@ impl GatewayService { if matches!(conf_file.tls_verify_strict, None | Some(false)) { warn!("TlsVerifyStrict option is absent or set to false. This may hide latent issues."); + let _ = SYSTEM_LOGGER.emit(sysevent_codes::tls_verify_strict_disabled( + "TlsVerifyStrict option is absent or set to false", + )); } if let Some((cert_subject_name, hostname)) = conf_file @@ -81,9 +85,13 @@ impl GatewayService { warn!( %hostname, %cert_subject_name, - "Hostname doesn’t match the TLS certificate subject name configured; \ + "Hostname doesn't match the TLS certificate subject name configured; \ not necessarily a problem if it is instead matched by an alternative subject name" - ) + ); + let _ = SYSTEM_LOGGER.emit(sysevent_codes::tls_certificate_name_mismatch( + hostname, + cert_subject_name, + )); } } @@ -100,11 +108,14 @@ impl GatewayService { match result { Ok(_) => info!(%path, "XMF native library loaded and installed"), - Err(error) => warn!( - %path, - %error, - "Failed to load XMF native library, features requiring video processing such as remuxing and shadowing are disabled" - ), + Err(error) => { + warn!( + %path, + %error, + "Failed to load XMF native library, features requiring video processing such as remuxing and shadowing are disabled" + ); + let _ = SYSTEM_LOGGER.emit(sysevent_codes::xmf_not_found(path, &error)); + } } } @@ -279,6 +290,15 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { .map(|listener| { GatewayListener::init_and_bind(listener, state.clone()) .with_context(|| format!("failed to initialize {}", listener.internal_url)) + .inspect(|_| { + let _ = SYSTEM_LOGGER.emit(sysevent_codes::listener_started( + &listener.internal_url, + listener.internal_url.scheme(), + )); + }) + .inspect_err(|error| { + let _ = SYSTEM_LOGGER.emit(sysevent_codes::listener_bind_failed(&listener.internal_url, error)); + }) }) .collect::>>() .context("failed to bind listener")? diff --git a/devolutions-gateway/src/tls.rs b/devolutions-gateway/src/tls.rs index ad7fd6ccf..ce6aba550 100644 --- a/devolutions-gateway/src/tls.rs +++ b/devolutions-gateway/src/tls.rs @@ -145,6 +145,7 @@ pub mod windows { use tokio_rustls::rustls::server::{ClientHello, ResolvesServerCert}; use tokio_rustls::rustls::sign::CertifiedKey; + use crate::SYSTEM_LOGGER; use crate::config::dto; use crate::tls::{CertIssues, check_certificate}; @@ -289,6 +290,13 @@ pub mod windows { issues = %report.issues, "Filtered out certificate because it has significant issues" ); + let _ = SYSTEM_LOGGER.emit( + sysevent_codes::tls_certificate_rejected( + report.subject, + report.issues.iter_names().next().expect("at least one issue").0, + ) + .severity(sysevent::Severity::Notice), + ); return None; } @@ -336,6 +344,9 @@ pub mod windows { }) .with_context(|| { format!("no usable certificate found in the system store; observed issues: {cert_issues}") + }) + .inspect_err(|error| { + let _ = SYSTEM_LOGGER.emit(sysevent_codes::tls_no_suitable_certificate(error, cert_issues)); })?; trace!(idx = context.idx, not_after = %context.not_after, key_algorithm_group = ?key.key().algorithm_group(), key_algorithm = ?key.key().algorithm(), "Selected certificate"); diff --git a/package/WindowsManaged/Program.cs b/package/WindowsManaged/Program.cs index 6d9952f35..ae170377f 100644 --- a/package/WindowsManaged/Program.cs +++ b/package/WindowsManaged/Program.cs @@ -353,6 +353,12 @@ static void Main() AttributesDefinition = "Type=string; Component:Permanent=yes", Win64 = project.Platform == Platform.x64, RegistryKeyAction = RegistryKeyAction.create, + }, + new (RegistryHive.LocalMachine, $"SYSTEM\\CurrentControlSet\\Services\\EventLog\\Application\\{Includes.PRODUCT_NAME}", "EventMessageFile", $"[{GatewayProperties.InstallDir}]{Includes.EXECUTABLE_NAME}") + { + AttributesDefinition = "Type=string", + Win64 = project.Platform == Platform.x64, + RegistryKeyAction = RegistryKeyAction.createAndRemoveOnUninstall, } }; project.Properties = GatewayProperties.Properties.Select(x => x.ToWixSharpProperty()).ToArray(); diff --git a/testsuite/Cargo.toml b/testsuite/Cargo.toml new file mode 100644 index 000000000..c45029b4a --- /dev/null +++ b/testsuite/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "testsuite" +version = "0.0.0" +edition = "2024" +description = "Devolutions Gateway Rust test suite" +publish = false +autotests = false + +[lib] +doctest = false +test = false + +[[test]] +name = "integration_tests" +path = "tests/main.rs" +harness = true + +[dependencies] +sysevent.path = "../crates/sysevent" + +[target.'cfg(unix)'.dependencies] +sysevent-syslog.path = "../crates/sysevent-syslog" + +[target.'cfg(windows)'.dependencies] +sysevent-winevent.path = "../crates/sysevent-winevent" + +[lints] +workspace = true diff --git a/testsuite/src/lib.rs b/testsuite/src/lib.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/testsuite/src/lib.rs @@ -0,0 +1 @@ + diff --git a/testsuite/tests/main.rs b/testsuite/tests/main.rs new file mode 100644 index 000000000..7106f89a9 --- /dev/null +++ b/testsuite/tests/main.rs @@ -0,0 +1 @@ +mod sysevent; diff --git a/testsuite/tests/sysevent/mod.rs b/testsuite/tests/sysevent/mod.rs new file mode 100644 index 000000000..1315fc9ae --- /dev/null +++ b/testsuite/tests/sysevent/mod.rs @@ -0,0 +1,4 @@ +//! Integration tests for system-wide logging with fake sink implementations + +mod syslog; +mod winevent; diff --git a/testsuite/tests/sysevent/syslog.rs b/testsuite/tests/sysevent/syslog.rs new file mode 100644 index 000000000..fae3049a8 --- /dev/null +++ b/testsuite/tests/sysevent/syslog.rs @@ -0,0 +1,65 @@ +//! Integration tests for syslog backend. + +#![cfg(unix)] + +use sysevent::{Entry, Facility, Severity, SystemEventSink}; +use sysevent_syslog::{Syslog, SyslogOptions}; + +#[test] +fn real_emission() { + let syslog = Syslog::new(c"dgw-tests", SyslogOptions::default()).expect("failed to create syslog"); + + let entry = Entry::new("Integration test message").severity(Severity::Info); + + syslog.emit(entry).expect("failed to emit message"); + syslog.flush().expect("failed to flush"); +} + +#[test] +fn structured_data() { + let syslog = Syslog::new(c"dgw-tests", SyslogOptions::default()).expect("failed to create syslog backend"); + + let entry = Entry::new("Structured data test") + .severity(Severity::Warning) + .facility(Facility::Local0) + .field("user_id", 12345) + .field("session_id", "abcdef") + .field("action", "test_operation"); + + syslog.emit(entry).expect("emit structured entry"); +} + +#[test] +fn large_message() { + let syslog = Syslog::new(c"dgw-tests", SyslogOptions::default()).expect("failed to create syslog"); + + let entry = Entry::new("x".repeat(1025)).severity(Severity::Info); + + syslog.emit(entry).expect("failed to emit message"); + syslog.flush().expect("failed to flush"); +} + +#[test] +fn concurrent_emission() { + use std::sync::Arc; + use std::thread; + + let syslog = Syslog::new(c"dgw-tests", SyslogOptions::default()).expect("failed to create syslog"); + let syslog = Arc::new(syslog); + + let handles: Vec<_> = (0..4) + .map(|i| { + let syslog = Arc::clone(&syslog); + + thread::spawn(move || { + syslog + .emit(Entry::new(format!("Concurrent message {i}")).severity(Severity::Info)) + .expect("emit"); + }) + }) + .collect(); + + for handle in handles { + handle.join().expect("thread should complete"); + } +} diff --git a/testsuite/tests/sysevent/winevent.rs b/testsuite/tests/sysevent/winevent.rs new file mode 100644 index 000000000..66c990afa --- /dev/null +++ b/testsuite/tests/sysevent/winevent.rs @@ -0,0 +1,68 @@ +//! Integration tests for WinEvent backend. + +#![cfg(windows)] + +use sysevent::{Entry, Severity, SystemEventSink}; +use sysevent_winevent::WinEvent; + +#[test] +fn real_emission() { + let winevent = WinEvent::new("Devolutions Gateway Tests").expect("failed to create Windows Event Log"); + + let entry = Entry::new("Integration test message") + .severity(Severity::Info) + .field("test_key", "test_value"); + + winevent.emit(entry).expect("failed to emit message"); + winevent.flush().expect("failed to flush"); +} + +#[test] +fn structured_data() { + let winevent = WinEvent::new("Devolutions Gateway Tests").expect("failed to create Windows Event Log"); + + let entry = Entry::new("Structured data test for Windows") + .severity(Severity::Error) + .event_code(2001) + .field("component", "authentication") + .field("error_code", "AUTH_FAILED") + .field("client_ip", "192.168.1.100"); + + winevent.emit(entry).expect("failed to emit message"); + winevent.flush().expect("failed to flush"); +} + +#[test] +fn concurrent_emission() { + use std::sync::Arc; + use std::thread; + + let winevent = WinEvent::new("Devolutions Gateway Tests").expect("failed to create Windows Event Log"); + let winevent = Arc::new(winevent); + + let handles: Vec<_> = (0..4) + .map(|i| { + let winevent = Arc::clone(&winevent); + + thread::spawn(move || { + winevent + .emit(Entry::new(format!("Concurrent message {i}")).severity(Severity::Info)) + .expect("emit"); + }) + }) + .collect(); + + for handle in handles { + handle.join().expect("thread should complete"); + } +} + +#[test] +fn large_message() { + let winevent = WinEvent::new("Devolutions Gateway Tests").expect("failed to create Windows Event Log"); + + let entry = Entry::new("x".repeat(32000)).severity(Severity::Info); + + winevent.emit(entry).expect("failed to emit message"); + winevent.flush().expect("failed to flush"); +}