diff --git a/Cargo.lock b/Cargo.lock index 8159a22..ef6a7c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2822,6 +2822,7 @@ dependencies = [ "tokio-util", "toml", "tracing", + "tracing-appender", "tracing-subscriber", "url", "webpki-roots", @@ -3148,6 +3149,18 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" diff --git a/Cargo.toml b/Cargo.toml index 04228a9..a5f1fec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ bytes = "1.9" thiserror = "2.0" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "0.2" parking_lot = "0.12" dashmap = "6.1" arc-swap = "1.7" diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..f372798 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,291 @@ +//! Logging configuration for telemt. +//! +//! Supports multiple log destinations: +//! - stderr (default, works with systemd journald) +//! - syslog (Unix only, for traditional init systems) +//! - file (with optional rotation) + +#![allow(dead_code)] // Infrastructure module - used via CLI flags + +use std::path::Path; + +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, fmt, reload}; + +/// Log destination configuration. +#[derive(Debug, Clone, Default)] +pub enum LogDestination { + /// Log to stderr (default, captured by systemd journald). + #[default] + Stderr, + /// Log to syslog (Unix only). + #[cfg(unix)] + Syslog, + /// Log to a file with optional rotation. + File { + path: String, + /// Rotate daily if true. + rotate_daily: bool, + }, +} + +/// Logging options parsed from CLI/config. +#[derive(Debug, Clone, Default)] +pub struct LoggingOptions { + /// Where to send logs. + pub destination: LogDestination, + /// Disable ANSI colors. + pub disable_colors: bool, +} + +/// Guard that must be held to keep file logging active. +/// When dropped, flushes and closes log files. +pub struct LoggingGuard { + _guard: Option, +} + +impl LoggingGuard { + fn new(guard: Option) -> Self { + Self { _guard: guard } + } + + /// Creates a no-op guard for stderr/syslog logging. + pub fn noop() -> Self { + Self { _guard: None } + } +} + +/// Initialize the tracing subscriber with the specified options. +/// +/// Returns a reload handle for dynamic log level changes and a guard +/// that must be kept alive for file logging. +pub fn init_logging( + opts: &LoggingOptions, + initial_filter: &str, +) -> (reload::Handle, LoggingGuard) { + let (filter_layer, filter_handle) = reload::Layer::new(EnvFilter::new(initial_filter)); + + match &opts.destination { + LogDestination::Stderr => { + let fmt_layer = fmt::Layer::default() + .with_ansi(!opts.disable_colors) + .with_target(true); + + tracing_subscriber::registry() + .with(filter_layer) + .with(fmt_layer) + .init(); + + (filter_handle, LoggingGuard::noop()) + } + + #[cfg(unix)] + LogDestination::Syslog => { + // Use a custom fmt layer that writes to syslog + let fmt_layer = fmt::Layer::default() + .with_ansi(false) + .with_target(true) + .with_writer(SyslogWriter::new); + + tracing_subscriber::registry() + .with(filter_layer) + .with(fmt_layer) + .init(); + + (filter_handle, LoggingGuard::noop()) + } + + LogDestination::File { path, rotate_daily } => { + let (non_blocking, guard) = if *rotate_daily { + // Extract directory and filename prefix + let path = Path::new(path); + let dir = path.parent().unwrap_or(Path::new("/var/log")); + let prefix = path.file_name() + .and_then(|s| s.to_str()) + .unwrap_or("telemt"); + + let file_appender = tracing_appender::rolling::daily(dir, prefix); + tracing_appender::non_blocking(file_appender) + } else { + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .expect("Failed to open log file"); + tracing_appender::non_blocking(file) + }; + + let fmt_layer = fmt::Layer::default() + .with_ansi(false) + .with_target(true) + .with_writer(non_blocking); + + tracing_subscriber::registry() + .with(filter_layer) + .with(fmt_layer) + .init(); + + (filter_handle, LoggingGuard::new(Some(guard))) + } + } +} + +/// Syslog writer for tracing. +#[cfg(unix)] +struct SyslogWriter { + _private: (), +} + +#[cfg(unix)] +impl SyslogWriter { + fn new() -> Self { + // Open syslog connection on first use + static INIT: std::sync::Once = std::sync::Once::new(); + INIT.call_once(|| { + unsafe { + // Open syslog with ident "telemt", LOG_PID, LOG_DAEMON facility + let ident = b"telemt\0".as_ptr() as *const libc::c_char; + libc::openlog(ident, libc::LOG_PID | libc::LOG_NDELAY, libc::LOG_DAEMON); + } + }); + Self { _private: () } + } +} + +#[cfg(unix)] +impl std::io::Write for SyslogWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + // Convert to C string, stripping newlines + let msg = String::from_utf8_lossy(buf); + let msg = msg.trim_end(); + + if msg.is_empty() { + return Ok(buf.len()); + } + + // Determine priority based on log level in the message + let priority = if msg.contains(" ERROR ") || msg.contains(" error ") { + libc::LOG_ERR + } else if msg.contains(" WARN ") || msg.contains(" warn ") { + libc::LOG_WARNING + } else if msg.contains(" INFO ") || msg.contains(" info ") { + libc::LOG_INFO + } else if msg.contains(" DEBUG ") || msg.contains(" debug ") { + libc::LOG_DEBUG + } else { + libc::LOG_INFO + }; + + // Write to syslog + let c_msg = std::ffi::CString::new(msg.as_bytes()) + .unwrap_or_else(|_| std::ffi::CString::new("(invalid utf8)").unwrap()); + + unsafe { + libc::syslog(priority, b"%s\0".as_ptr() as *const libc::c_char, c_msg.as_ptr()); + } + + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[cfg(unix)] +impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SyslogWriter { + type Writer = SyslogWriter; + + fn make_writer(&'a self) -> Self::Writer { + SyslogWriter::new() + } +} + +/// Parse log destination from CLI arguments. +pub fn parse_log_destination(args: &[String]) -> LogDestination { + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + #[cfg(unix)] + "--syslog" => { + return LogDestination::Syslog; + } + "--log-file" => { + i += 1; + if i < args.len() { + return LogDestination::File { + path: args[i].clone(), + rotate_daily: false, + }; + } + } + s if s.starts_with("--log-file=") => { + return LogDestination::File { + path: s.trim_start_matches("--log-file=").to_string(), + rotate_daily: false, + }; + } + "--log-file-daily" => { + i += 1; + if i < args.len() { + return LogDestination::File { + path: args[i].clone(), + rotate_daily: true, + }; + } + } + s if s.starts_with("--log-file-daily=") => { + return LogDestination::File { + path: s.trim_start_matches("--log-file-daily=").to_string(), + rotate_daily: true, + }; + } + _ => {} + } + i += 1; + } + LogDestination::Stderr +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_log_destination_default() { + let args: Vec = vec![]; + assert!(matches!(parse_log_destination(&args), LogDestination::Stderr)); + } + + #[test] + fn test_parse_log_destination_file() { + let args = vec!["--log-file".to_string(), "/var/log/telemt.log".to_string()]; + match parse_log_destination(&args) { + LogDestination::File { path, rotate_daily } => { + assert_eq!(path, "/var/log/telemt.log"); + assert!(!rotate_daily); + } + _ => panic!("Expected File destination"), + } + } + + #[test] + fn test_parse_log_destination_file_daily() { + let args = vec!["--log-file-daily=/var/log/telemt".to_string()]; + match parse_log_destination(&args) { + LogDestination::File { path, rotate_daily } => { + assert_eq!(path, "/var/log/telemt"); + assert!(rotate_daily); + } + _ => panic!("Expected File destination"), + } + } + + #[cfg(unix)] + #[test] + fn test_parse_log_destination_syslog() { + let args = vec!["--syslog".to_string()]; + assert!(matches!(parse_log_destination(&args), LogDestination::Syslog)); + } +} diff --git a/src/maestro/helpers.rs b/src/maestro/helpers.rs index 8caed2d..c376d0b 100644 --- a/src/maestro/helpers.rs +++ b/src/maestro/helpers.rs @@ -8,6 +8,7 @@ use tracing::{debug, error, info, warn}; use crate::cli; use crate::config::ProxyConfig; +use crate::logging::LogDestination; use crate::transport::middle_proxy::{ ProxyConfigData, fetch_proxy_config_with_raw, load_proxy_config_cache, save_proxy_config_cache, }; @@ -31,6 +32,7 @@ pub(crate) struct CliArgs { pub data_path: Option, pub silent: bool, pub log_level: Option, + pub log_destination: LogDestination, } pub(crate) fn parse_cli() -> CliArgs { @@ -41,6 +43,9 @@ pub(crate) fn parse_cli() -> CliArgs { let args: Vec = std::env::args().skip(1).collect(); + // Parse log destination + let log_destination = crate::logging::parse_log_destination(&args); + // Check for --init first (handled before tokio) if let Some(init_opts) = cli::parse_init_args(&args) { if let Err(e) = cli::run_init(init_opts) { @@ -124,6 +129,7 @@ pub(crate) fn parse_cli() -> CliArgs { data_path, silent, log_level, + log_destination, } } @@ -147,6 +153,12 @@ fn print_help() { eprintln!(" --help, -h Show this help"); eprintln!(" --version, -V Show version"); eprintln!(); + eprintln!("Logging options:"); + eprintln!(" --log-file Log to file (default: stderr)"); + eprintln!(" --log-file-daily Log to file with daily rotation"); + #[cfg(unix)] + eprintln!(" --syslog Log to syslog (Unix only)"); + eprintln!(); #[cfg(unix)] { eprintln!("Daemon options (Unix only):"); diff --git a/src/maestro/mod.rs b/src/maestro/mod.rs index b8eef7b..ecd7f6d 100644 --- a/src/maestro/mod.rs +++ b/src/maestro/mod.rs @@ -114,6 +114,7 @@ async fn run_inner( let data_path = cli_args.data_path; let cli_silent = cli_args.silent; let cli_log_level = cli_args.log_level; + let log_destination = cli_args.log_destination; let startup_cwd = match std::env::current_dir() { Ok(cwd) => cwd, Err(e) => { @@ -213,17 +214,43 @@ async fn run_inner( ) .await; - // Configure color output based on config - let fmt_layer = if config.general.disable_colors { - fmt::Layer::default().with_ansi(false) - } else { - fmt::Layer::default().with_ansi(true) - }; + // Initialize logging based on destination + let _logging_guard: Option; + match log_destination { + crate::logging::LogDestination::Stderr => { + // Default: log to stderr (works with systemd journald) + let fmt_layer = if config.general.disable_colors { + fmt::Layer::default().with_ansi(false) + } else { + fmt::Layer::default().with_ansi(true) + }; + tracing_subscriber::registry() + .with(filter_layer) + .with(fmt_layer) + .init(); + _logging_guard = None; + } + #[cfg(unix)] + crate::logging::LogDestination::Syslog => { + // Syslog: for OpenRC/FreeBSD + let logging_opts = crate::logging::LoggingOptions { + destination: log_destination, + disable_colors: true, + }; + let (_, guard) = crate::logging::init_logging(&logging_opts, "info"); + _logging_guard = Some(guard); + } + crate::logging::LogDestination::File { .. } => { + // File logging with optional rotation + let logging_opts = crate::logging::LoggingOptions { + destination: log_destination, + disable_colors: true, + }; + let (_, guard) = crate::logging::init_logging(&logging_opts, "info"); + _logging_guard = Some(guard); + } + } - tracing_subscriber::registry() - .with(filter_layer) - .with(fmt_layer) - .init(); startup_tracker .complete_component( COMPONENT_TRACING_INIT, diff --git a/src/main.rs b/src/main.rs index 0e872c5..26a10f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod crypto; mod daemon; mod error; mod ip_tracker; +mod logging; mod service; #[cfg(test)] #[path = "tests/ip_tracker_hotpath_adversarial_tests.rs"] diff --git a/src/service/mod.rs b/src/service/mod.rs index c0a6f83..160c36c 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -181,18 +181,20 @@ r#"#!/sbin/openrc-run description="{description}" command="{exe}" -command_args="--daemon --pid-file {pid_file} {config}" +command_args="--daemon --syslog --pid-file {pid_file} {config}" command_user="{user}:{group}" pidfile="{pid_file}" depend() {{ need net + use logger after firewall }} start_pre() {{ checkpath --directory --owner {user}:{group} --mode 0755 /var/run checkpath --directory --owner {user}:{group} --mode 0755 /var/lib/telemt + checkpath --directory --owner {user}:{group} --mode 0755 /var/log/telemt }} reload() {{ @@ -246,7 +248,7 @@ load_rc_config $name pidfile="${{telemt_pidfile}}" command="{exe}" -command_args="--daemon --pid-file ${{telemt_pidfile}} ${{telemt_config}}" +command_args="--daemon --syslog --pid-file ${{telemt_pidfile}} ${{telemt_config}}" start_precmd="telemt_prestart" reload_cmd="telemt_reload"