From 87c82c2a63bbd5f4a11eef2664fba823febfde4d Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:16:02 +0300 Subject: [PATCH] Add bounded file logging rotation and retention #832 Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- docs/Config_params/CONFIG_PARAMS.en.md | 80 +++++ src/config/load.rs | 119 +++++++- src/config/types.rs | 80 +++++ src/logging.rs | 323 +++++++++++++------- src/logging/file.rs | 396 +++++++++++++++++++++++++ src/logging/tests.rs | 100 +++++++ src/maestro/helpers.rs | 33 ++- src/maestro/mod.rs | 10 +- 8 files changed, 1031 insertions(+), 110 deletions(-) create mode 100644 src/logging/file.rs create mode 100644 src/logging/tests.rs diff --git a/docs/Config_params/CONFIG_PARAMS.en.md b/docs/Config_params/CONFIG_PARAMS.en.md index d51259b..d1fe111 100644 --- a/docs/Config_params/CONFIG_PARAMS.en.md +++ b/docs/Config_params/CONFIG_PARAMS.en.md @@ -14,6 +14,7 @@ This document lists all configuration keys accepted by `config.toml`. # Table of contents - [Top-level keys](#top-level-keys) + - [logging](#logging) - [general](#general) - [general.modes](#generalmodes) - [general.links](#generallinks) @@ -35,6 +36,7 @@ This document lists all configuration keys accepted by `config.toml`. | --- | ---- | ------- | ---------- | | [`include`](#include) | `String` (special directive) | — | `✔` | | [`show_link`](#show_link) | `"*"` or `String[]` | `[]` (`ShowLink::None`) | `✘` | +| [`logging`](#logging) | Table | default values | `✘` | | [`dc_overrides`](#dc_overrides) | `Map` | `{}` | `✘` | | [`default_dc`](#default_dc) | `u8` | — (effective fallback: `2` in ME routing) | `✘` | | [`beobachten`](#beobachten) | `bool` | `true` | `✘` | @@ -83,6 +85,84 @@ This document lists all configuration keys accepted by `config.toml`. default_dc = 2 ``` +# [logging] + +| Key | Type | Default | Hot-Reload | +| --- | ---- | ------- | ---------- | +| [`destination`](#loggingdestination) | `"stderr"` / `"syslog"` / `"file"` | `"stderr"` | `✘` | +| [`path`](#loggingpath) | `String` | — | `✘` | +| [`rotation`](#loggingrotation) | `"never"` / `"minutely"` / `"hourly"` / `"daily"` / `"weekly"` | `"never"` | `✘` | +| [`max_size_bytes`](#loggingmax_size_bytes) | `u64` | `0` | `✘` | +| [`max_files`](#loggingmax_files) | `usize` | `0` | `✘` | +| [`max_age_secs`](#loggingmax_age_secs) | `u64` | `0` | `✘` | + +## logging.destination + - **Constraints / validation**: Must be `stderr`, `syslog`, or `file`. `syslog` is supported only on Unix platforms. `file` requires `logging.path`. + - **Description**: Selects the runtime log destination. CLI flags override this value. + - **Example**: + + ```toml + [logging] + destination = "file" + path = "/var/log/telemt.log" + ``` +## logging.path + - **Constraints / validation**: Required when `logging.destination = "file"`; must not be empty. + - **Description**: File path used for file logging. With time rotation, the file name is used as the rolling prefix. + - **Example**: + + ```toml + [logging] + destination = "file" + path = "/var/log/telemt.log" + ``` +## logging.rotation + - **Constraints / validation**: Must be `never`, `minutely`, `hourly`, `daily`, or `weekly`. + - **Description**: Time-based file rotation interval. `weekly` rotates at the Sunday UTC boundary. `never` writes to the exact `logging.path` unless size rotation is enabled. + - **Example**: + + ```toml + [logging] + destination = "file" + path = "/var/log/telemt.log" + rotation = "daily" + ``` +## logging.max_size_bytes + - **Constraints / validation**: `0` disables size rotation. + - **Description**: Rotates file logs before writing the next record when the active file is non-empty and that record would exceed this byte limit. Records are written whole and are not split. + - **Example**: + + ```toml + [logging] + destination = "file" + path = "/var/log/telemt.log" + max_size_bytes = 104857600 + ``` +## logging.max_files + - **Constraints / validation**: `0` disables count-based retention. + - **Description**: Keeps at most this many matching file logs, counting the active file and rotated archives. The active file is never deleted by retention cleanup. + - **Example**: + + ```toml + [logging] + destination = "file" + path = "/var/log/telemt.log" + rotation = "daily" + max_files = 14 + ``` +## logging.max_age_secs + - **Constraints / validation**: `0` disables age-based retention. + - **Description**: Removes rotated file logs older than this many seconds based on file modification time. The active file is never deleted by retention cleanup. + - **Example**: + + ```toml + [logging] + destination = "file" + path = "/var/log/telemt.log" + rotation = "daily" + max_age_secs = 1209600 + ``` + # [general] diff --git a/src/config/load.rs b/src/config/load.rs index 568b619..016acda 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -114,6 +114,7 @@ fn normalize_exclusive_mask_target(target: &str, field: &str) -> Result const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[ "general", + "logging", "network", "server", "timeouts", @@ -459,6 +460,14 @@ const UPSTREAM_CONFIG_KEYS: &[&str] = &[ const PROXY_MODES_CONFIG_KEYS: &[&str] = &["classic", "secure", "tls"]; const TELEMETRY_CONFIG_KEYS: &[&str] = &["core_enabled", "user_enabled", "me_level"]; const LINKS_CONFIG_KEYS: &[&str] = &["show", "public_host", "public_port"]; +const LOGGING_CONFIG_KEYS: &[&str] = &[ + "destination", + "path", + "rotation", + "max_size_bytes", + "max_files", + "max_age_secs", +]; #[derive(Debug)] struct UnknownConfigKey { @@ -500,6 +509,7 @@ fn known_config_keys_for_suggestion() -> Vec<&'static str> { PROXY_MODES_CONFIG_KEYS, TELEMETRY_CONFIG_KEYS, LINKS_CONFIG_KEYS, + LOGGING_CONFIG_KEYS, ] { keys.extend_from_slice(group); } @@ -633,6 +643,13 @@ fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec) { } } +fn validate_logging_config(logging: &LoggingConfig) -> Result<()> { + if let Some(path) = logging.path.as_ref() + && path.trim().is_empty() + { + return Err(ProxyError::Config( + "logging.path cannot be empty when provided".to_string(), + )); + } + + if matches!(logging.destination, LoggingDestination::File) && logging.path.is_none() { + return Err(ProxyError::Config( + "logging.path must be set when logging.destination=\"file\"".to_string(), + )); + } + + Ok(()) +} + fn validate_upstreams(config: &ProxyConfig) -> Result<()> { let has_enabled_shadowsocks = config.upstreams.iter().any(|upstream| { upstream.enabled && matches!(upstream.upstream_type, UpstreamType::Shadowsocks { .. }) @@ -1058,13 +1093,17 @@ fn normalize_upstream_family_policy(config: &mut ProxyConfig) { } } -// ============= Main Config ============= +// Main runtime configuration loaded from TOML. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProxyConfig { #[serde(default)] pub general: GeneralConfig, + /// Runtime logging destination, rotation, and retention configuration. + #[serde(default)] + pub logging: LoggingConfig, + #[serde(default)] pub network: NetworkConfig, @@ -2288,6 +2327,7 @@ impl ProxyConfig { .entry("203".to_string()) .or_insert_with(|| vec!["91.105.192.100:443".to_string()]); + validate_logging_config(&config.logging)?; validate_upstreams(&config)?; config.rebuild_runtime_user_auth()?; @@ -2313,6 +2353,8 @@ impl ProxyConfig { return Err(ProxyError::Config("No users configured".to_string())); } + validate_logging_config(&self.logging)?; + if !self.general.modes.classic && !self.general.modes.secure && !self.general.modes.tls { return Err(ProxyError::Config("No modes enabled".to_string())); } @@ -2409,6 +2451,21 @@ mod tests { cfg } + fn load_config_error_from_temp_toml(toml: &str) -> String { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("telemt_load_cfg_error_{nonce}")); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.toml"); + std::fs::write(&path, toml).unwrap(); + let error = ProxyConfig::load(&path).unwrap_err().to_string(); + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir(dir); + error + } + #[test] fn serde_defaults_remain_unchanged_for_present_sections() { let toml = r#" @@ -2419,6 +2476,7 @@ mod tests { "#; let cfg: ProxyConfig = toml::from_str(toml).unwrap(); + assert_eq!(cfg.logging, LoggingConfig::default()); assert_eq!(cfg.network.ipv6, default_network_ipv6()); assert_eq!(cfg.network.stun_use, default_true()); assert_eq!(cfg.network.stun_tcp_fallback, default_stun_tcp_fallback()); @@ -2586,6 +2644,65 @@ mod tests { ); } + #[test] + fn logging_config_is_loaded_from_strict_config() { + let cfg = load_config_from_temp_toml( + r#" + [general] + config_strict = true + + [general.modes] + classic = false + secure = false + tls = true + + [logging] + destination = "file" + path = "/tmp/telemt.log" + rotation = "daily" + max_size_bytes = 1024 + max_files = 3 + max_age_secs = 60 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#, + ); + + assert_eq!(cfg.logging.destination, LoggingDestination::File); + assert_eq!(cfg.logging.path.as_deref(), Some("/tmp/telemt.log")); + assert_eq!(cfg.logging.rotation, LogRotation::Daily); + assert_eq!(cfg.logging.max_size_bytes, 1024); + assert_eq!(cfg.logging.max_files, 3); + assert_eq!(cfg.logging.max_age_secs, 60); + } + + #[test] + fn file_logging_requires_path() { + let error = load_config_error_from_temp_toml( + r#" + [general.modes] + classic = false + secure = false + tls = true + + [logging] + destination = "file" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#, + ); + + assert!(error.contains("logging.path must be set")); + } + #[test] fn impl_defaults_are_sourced_from_default_helpers() { let network = NetworkConfig::default(); diff --git a/src/config/types.rs b/src/config/types.rs index a00d12b..6d38882 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -63,6 +63,86 @@ impl std::fmt::Display for LogLevel { } } +/// Logging output destination. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum LoggingDestination { + /// Write logs to stderr. + #[default] + Stderr, + /// Write logs to syslog on Unix platforms. + Syslog, + /// Write logs to a file. + File, +} + +/// Time-based log rotation interval for file logging. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum LogRotation { + /// Do not rotate logs by time. + #[default] + Never, + /// Rotate once per minute. + Minutely, + /// Rotate once per hour. + Hourly, + /// Rotate once per day. + Daily, + /// Rotate once per week. + Weekly, +} + +impl LogRotation { + /// Parse a CLI rotation value. + pub fn from_cli_arg(value: &str) -> Option { + match value.to_ascii_lowercase().as_str() { + "never" | "none" | "off" => Some(Self::Never), + "minutely" | "minute" => Some(Self::Minutely), + "hourly" | "hour" => Some(Self::Hourly), + "daily" | "day" => Some(Self::Daily), + "weekly" | "week" => Some(Self::Weekly), + _ => None, + } + } +} + +/// File logging and retention settings. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LoggingConfig { + /// Effective logging destination. + #[serde(default)] + pub destination: LoggingDestination, + /// File path used when `destination = "file"`. + #[serde(default)] + pub path: Option, + /// Time rotation interval for file logs. + #[serde(default)] + pub rotation: LogRotation, + /// Maximum active log file size before rotating. `0` disables size rotation. + #[serde(default)] + pub max_size_bytes: u64, + /// Maximum number of matching log files to keep. `0` disables count retention. + #[serde(default)] + pub max_files: usize, + /// Maximum age for rotated log files in seconds. `0` disables age retention. + #[serde(default)] + pub max_age_secs: u64, +} + +impl Default for LoggingConfig { + fn default() -> Self { + Self { + destination: LoggingDestination::Stderr, + path: None, + rotation: LogRotation::Never, + max_size_bytes: 0, + max_files: 0, + max_age_secs: 0, + } + } +} + /// Middle-End telemetry verbosity level. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] diff --git a/src/logging.rs b/src/logging.rs index af9e2f7..08f1131 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -5,16 +5,41 @@ //! - syslog (Unix only, for traditional init systems) //! - file (with optional rotation) -#![allow(dead_code)] // Infrastructure module - used via CLI flags +// Infrastructure module used via CLI flags. +#![allow(dead_code)] use std::path::Path; +use crate::config::{LogRotation, LoggingConfig, LoggingDestination}; + use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::{EnvFilter, fmt, reload}; +// Submodules: +// - file: bounded file appender for size and retention controls. +mod file; + +#[cfg(test)] +mod tests; + +/// File logging and retention options resolved from config and CLI. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileLogOptions { + /// Log file path or rolling filename prefix path. + pub path: String, + /// Time rotation interval. + pub rotation: LogRotation, + /// Maximum active file size before size rotation. `0` disables it. + pub max_size_bytes: u64, + /// Maximum number of matching log files to keep. `0` disables it. + pub max_files: usize, + /// Maximum rotated file age in seconds. `0` disables it. + pub max_age_secs: u64, +} + /// Log destination configuration. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum LogDestination { /// Log to stderr (default, captured by systemd journald). #[default] @@ -24,12 +49,29 @@ pub enum LogDestination { Syslog, /// Log to a file with optional rotation. File { - path: String, - /// Rotate daily if true. - rotate_daily: bool, + /// Resolved file logging options. + options: FileLogOptions, }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LogCliDestination { + Stderr, + Syslog, + File, +} + +/// Logging-related CLI overrides. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LogCliOptions { + destination: Option, + path: Option, + rotation: Option, + max_size_bytes: Option, + max_files: Option, + max_age_secs: Option, +} + /// Logging options parsed from CLI/config. #[derive(Debug, Clone, Default)] pub struct LoggingOptions { @@ -101,23 +143,29 @@ pub fn init_logging( (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); + LogDestination::File { options } => { + let (non_blocking, guard) = if options.max_size_bytes > 0 + || options.max_files > 0 + || options.max_age_secs > 0 + { + let file_appender = file::BoundedFileAppender::new(options.clone()) + .expect("Failed to open log file"); + tracing_appender::non_blocking(file_appender) + } else if !matches!(options.rotation, LogRotation::Never) { + let path = Path::new(&options.path); + let dir = log_file_dir(path); + let prefix = log_file_name(path); + let file_appender = tracing_appender::rolling::RollingFileAppender::builder() + .rotation(to_tracing_rotation(options.rotation)) + .filename_prefix(prefix) + .build(dir) + .expect("Failed to open log file"); tracing_appender::non_blocking(file_appender) } else { let file = std::fs::OpenOptions::new() .create(true) .append(true) - .open(path) + .open(&options.path) .expect("Failed to open log file"); tracing_appender::non_blocking(file) }; @@ -137,6 +185,28 @@ pub fn init_logging( } } +fn log_file_dir(path: &Path) -> &Path { + path.parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) +} + +fn log_file_name(path: &Path) -> &str { + path.file_name() + .and_then(|s| s.to_str()) + .unwrap_or("telemt") +} + +fn to_tracing_rotation(rotation: LogRotation) -> tracing_appender::rolling::Rotation { + match rotation { + LogRotation::Never => tracing_appender::rolling::Rotation::NEVER, + LogRotation::Minutely => tracing_appender::rolling::Rotation::MINUTELY, + LogRotation::Hourly => tracing_appender::rolling::Rotation::HOURLY, + LogRotation::Daily => tracing_appender::rolling::Rotation::DAILY, + LogRotation::Weekly => tracing_appender::rolling::Rotation::WEEKLY, + } +} + /// Syslog writer for tracing. #[cfg(unix)] #[derive(Clone, Copy)] @@ -223,121 +293,172 @@ impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SyslogMakeWriter { } } -/// Parse log destination from CLI arguments. -pub fn parse_log_destination(args: &[String]) -> LogDestination { +/// Parse logging overrides from CLI arguments. +pub fn parse_log_cli_options(args: &[String]) -> Result { + let mut options = LogCliOptions::default(); let mut i = 0; while i < args.len() { match args[i].as_str() { #[cfg(unix)] "--syslog" => { - return LogDestination::Syslog; + options.destination = Some(LogCliDestination::Syslog); + } + #[cfg(not(unix))] + "--syslog" => { + options.destination = Some(LogCliDestination::Syslog); } "--log-file" => { i += 1; if i < args.len() { - return LogDestination::File { - path: args[i].clone(), - rotate_daily: false, - }; + options.destination = Some(LogCliDestination::File); + options.path = Some(args[i].clone()); + } else { + return Err("Missing value for --log-file".to_string()); } } s if s.starts_with("--log-file=") => { - return LogDestination::File { - path: s.trim_start_matches("--log-file=").to_string(), - rotate_daily: false, - }; + options.destination = Some(LogCliDestination::File); + options.path = Some(s.trim_start_matches("--log-file=").to_string()); } "--log-file-daily" => { i += 1; if i < args.len() { - return LogDestination::File { - path: args[i].clone(), - rotate_daily: true, - }; + options.destination = Some(LogCliDestination::File); + options.path = Some(args[i].clone()); + options.rotation = Some(LogRotation::Daily); + } else { + return Err("Missing value for --log-file-daily".to_string()); } } s if s.starts_with("--log-file-daily=") => { - return LogDestination::File { - path: s.trim_start_matches("--log-file-daily=").to_string(), - rotate_daily: true, - }; + options.destination = Some(LogCliDestination::File); + options.path = Some(s.trim_start_matches("--log-file-daily=").to_string()); + options.rotation = Some(LogRotation::Daily); + } + "--log-rotation" => { + i += 1; + if i < args.len() { + options.rotation = Some(parse_rotation_cli_value(&args[i])?); + } else { + return Err("Missing value for --log-rotation".to_string()); + } + } + s if s.starts_with("--log-rotation=") => { + options.rotation = Some(parse_rotation_cli_value( + s.trim_start_matches("--log-rotation="), + )?); + } + "--log-max-size-bytes" => { + i += 1; + if i < args.len() { + options.max_size_bytes = + Some(parse_u64_cli_value("--log-max-size-bytes", &args[i])?); + } else { + return Err("Missing value for --log-max-size-bytes".to_string()); + } + } + s if s.starts_with("--log-max-size-bytes=") => { + options.max_size_bytes = Some(parse_u64_cli_value( + "--log-max-size-bytes", + s.trim_start_matches("--log-max-size-bytes="), + )?); + } + "--log-max-files" => { + i += 1; + if i < args.len() { + options.max_files = Some(parse_usize_cli_value("--log-max-files", &args[i])?); + } else { + return Err("Missing value for --log-max-files".to_string()); + } + } + s if s.starts_with("--log-max-files=") => { + options.max_files = Some(parse_usize_cli_value( + "--log-max-files", + s.trim_start_matches("--log-max-files="), + )?); + } + "--log-max-age-secs" => { + i += 1; + if i < args.len() { + options.max_age_secs = + Some(parse_u64_cli_value("--log-max-age-secs", &args[i])?); + } else { + return Err("Missing value for --log-max-age-secs".to_string()); + } + } + s if s.starts_with("--log-max-age-secs=") => { + options.max_age_secs = Some(parse_u64_cli_value( + "--log-max-age-secs", + s.trim_start_matches("--log-max-age-secs="), + )?); } _ => {} } i += 1; } - LogDestination::Stderr + Ok(options) } -#[cfg(test)] -mod tests { - use super::*; +fn parse_rotation_cli_value(value: &str) -> Result { + LogRotation::from_cli_arg(value).ok_or_else(|| { + format!( + "Invalid --log-rotation value '{value}'. Expected never|minutely|hourly|daily|weekly" + ) + }) +} - #[test] - fn test_parse_log_destination_default() { - let args: Vec = vec![]; - assert!(matches!( - parse_log_destination(&args), - LogDestination::Stderr - )); - } +fn parse_u64_cli_value(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|_| format!("Invalid {flag} value '{value}'. Expected unsigned integer")) +} - #[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); +fn parse_usize_cli_value(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|_| format!("Invalid {flag} value '{value}'. Expected unsigned integer")) +} + +/// Resolve effective logging destination from config and CLI overrides. +pub fn resolve_log_destination( + config: &LoggingConfig, + cli: &LogCliOptions, +) -> Result { + let destination = cli.destination.unwrap_or(match config.destination { + LoggingDestination::Stderr => LogCliDestination::Stderr, + LoggingDestination::Syslog => LogCliDestination::Syslog, + LoggingDestination::File => LogCliDestination::File, + }); + + match destination { + LogCliDestination::Stderr => Ok(LogDestination::Stderr), + LogCliDestination::Syslog => { + #[cfg(unix)] + { + Ok(LogDestination::Syslog) } - _ => 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); + #[cfg(not(unix))] + { + Err("Syslog logging is only supported on Unix platforms".to_string()) } - _ => panic!("Expected File destination"), } - } + LogCliDestination::File => { + let path = cli.path.as_ref().or(config.path.as_ref()).ok_or_else(|| { + "logging.path or --log-file must be set when file logging is enabled".to_string() + })?; + if path.trim().is_empty() { + return Err("Log file path cannot be empty".to_string()); + } - #[cfg(unix)] - #[test] - fn test_parse_log_destination_syslog() { - let args = vec!["--syslog".to_string()]; - assert!(matches!( - parse_log_destination(&args), - LogDestination::Syslog - )); - } - - #[cfg(unix)] - #[test] - fn test_syslog_priority_for_level_mapping() { - assert_eq!( - syslog_priority_for_level(&tracing::Level::ERROR), - libc::LOG_ERR - ); - assert_eq!( - syslog_priority_for_level(&tracing::Level::WARN), - libc::LOG_WARNING - ); - assert_eq!( - syslog_priority_for_level(&tracing::Level::INFO), - libc::LOG_INFO - ); - assert_eq!( - syslog_priority_for_level(&tracing::Level::DEBUG), - libc::LOG_DEBUG - ); - assert_eq!( - syslog_priority_for_level(&tracing::Level::TRACE), - libc::LOG_DEBUG - ); + Ok(LogDestination::File { + options: FileLogOptions { + path: path.clone(), + rotation: cli.rotation.unwrap_or(config.rotation), + max_size_bytes: cli.max_size_bytes.unwrap_or(config.max_size_bytes), + max_files: cli.max_files.unwrap_or(config.max_files), + max_age_secs: cli.max_age_secs.unwrap_or(config.max_age_secs), + }, + }) + } } } diff --git a/src/logging/file.rs b/src/logging/file.rs new file mode 100644 index 0000000..437a857 --- /dev/null +++ b/src/logging/file.rs @@ -0,0 +1,396 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use chrono::{DateTime, Datelike, Duration as ChronoDuration, Utc}; + +use crate::config::LogRotation; + +use super::FileLogOptions; + +const CLEANUP_INTERVAL_SECS: i64 = 60; + +/// File appender with size rotation and local retention cleanup. +pub(crate) struct BoundedFileAppender { + options: FileLogOptions, + dir: PathBuf, + base_name: String, + current_path: PathBuf, + current_size: u64, + last_cleanup: DateTime, + file: Option, + now: Box DateTime + Send + Sync>, +} + +impl BoundedFileAppender { + pub(crate) fn new(options: FileLogOptions) -> io::Result { + Self::with_now(options, Box::new(Utc::now)) + } + + fn with_now( + options: FileLogOptions, + now: Box DateTime + Send + Sync>, + ) -> io::Result { + let path = Path::new(&options.path); + let dir = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); + let base_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("telemt") + .to_string(); + + let start = now(); + let current_path = active_path_for(&dir, &base_name, options.rotation, &start); + let (file, current_size) = open_append_file(¤t_path)?; + let mut appender = Self { + options, + dir, + base_name, + current_path, + current_size, + last_cleanup: start, + file: Some(file), + now, + }; + appender.cleanup(&start); + Ok(appender) + } + + fn now(&self) -> DateTime { + (self.now)() + } + + fn refresh_active_path(&mut self, now: &DateTime) -> io::Result { + let next_path = active_path_for(&self.dir, &self.base_name, self.options.rotation, now); + if next_path == self.current_path { + return Ok(false); + } + + self.close_current()?; + self.current_path = next_path; + self.open_current()?; + Ok(true) + } + + fn rotate_for_size(&mut self, now: &DateTime) -> io::Result<()> { + self.close_current()?; + if self.current_path.exists() { + let archive_path = self.archive_path(now); + fs::rename(&self.current_path, archive_path)?; + } + self.open_current() + } + + fn archive_path(&self, now: &DateTime) -> PathBuf { + let file_name = self + .current_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(&self.base_name); + let stamp = now.format("%Y%m%d%H%M%S"); + for seq in 0..1000 { + let candidate = self.dir.join(format!("{file_name}.{stamp}.{seq}")); + if !candidate.exists() { + return candidate; + } + } + self.dir.join(format!("{file_name}.{stamp}.overflow")) + } + + fn open_current(&mut self) -> io::Result<()> { + let (file, current_size) = open_append_file(&self.current_path)?; + self.file = Some(file); + self.current_size = current_size; + Ok(()) + } + + fn close_current(&mut self) -> io::Result<()> { + if let Some(mut file) = self.file.take() { + file.flush()?; + } + Ok(()) + } + + fn should_rotate_for_size(&self, incoming_len: usize) -> bool { + self.options.max_size_bytes > 0 + && self.current_size > 0 + && self.current_size.saturating_add(incoming_len as u64) > self.options.max_size_bytes + } + + fn cleanup_due(&self, now: &DateTime) -> bool { + self.options.max_age_secs > 0 + && now.signed_duration_since(self.last_cleanup) + >= ChronoDuration::seconds(CLEANUP_INTERVAL_SECS) + } + + fn cleanup(&mut self, now: &DateTime) { + self.last_cleanup = now.clone(); + let Ok(entries) = fs::read_dir(&self.dir) else { + return; + }; + + let mut candidates = Vec::new(); + let prefix = format!("{}.", self.base_name); + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_file() { + continue; + } + + let is_current = path == self.current_path; + let Some(name) = entry.file_name().to_str().map(|name| name.to_string()) else { + continue; + }; + if !is_current && !name.starts_with(&prefix) { + continue; + } + + let Ok(metadata) = entry.metadata() else { + continue; + }; + let modified = metadata.modified().unwrap_or(UNIX_EPOCH); + candidates.push(LogFileCandidate { + path, + modified, + is_current, + }); + } + + if self.options.max_age_secs > 0 { + let cutoff = system_time_from_utc(now) + .checked_sub(Duration::from_secs(self.options.max_age_secs)) + .unwrap_or(UNIX_EPOCH); + candidates.retain(|candidate| { + if candidate.is_current || candidate.modified >= cutoff { + true + } else { + let _ = fs::remove_file(&candidate.path); + false + } + }); + } + + if self.options.max_files > 0 && candidates.len() > self.options.max_files { + let mut archives: Vec<_> = candidates + .into_iter() + .filter(|candidate| !candidate.is_current) + .collect(); + archives.sort_by_key(|candidate| candidate.modified); + let mut total = archives.len() + 1; + for candidate in archives { + if total <= self.options.max_files { + break; + } + let _ = fs::remove_file(candidate.path); + total -= 1; + } + } + } +} + +impl Write for BoundedFileAppender { + fn write(&mut self, buf: &[u8]) -> io::Result { + let now = self.now(); + let rotated_by_time = self.refresh_active_path(&now)?; + if self.should_rotate_for_size(buf.len()) { + self.rotate_for_size(&now)?; + self.cleanup(&now); + } else if rotated_by_time || self.cleanup_due(&now) { + self.cleanup(&now); + } + + let Some(file) = self.file.as_mut() else { + return Err(io::Error::new( + io::ErrorKind::Other, + "bounded log file is not open", + )); + }; + file.write_all(buf)?; + self.current_size = self.current_size.saturating_add(buf.len() as u64); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + if let Some(file) = self.file.as_mut() { + file.flush() + } else { + Ok(()) + } + } +} + +struct LogFileCandidate { + path: PathBuf, + modified: SystemTime, + is_current: bool, +} + +fn open_append_file(path: &Path) -> io::Result<(File, u64)> { + let mut options = OpenOptions::new(); + options.create(true).append(true); + + let file = match options.open(path) { + Ok(file) => file, + Err(error) => { + let Some(parent) = path.parent().filter(|parent| !parent.as_os_str().is_empty()) else { + return Err(error); + }; + fs::create_dir_all(parent)?; + options.open(path)? + } + }; + let current_size = file.metadata()?.len(); + Ok((file, current_size)) +} + +fn active_path_for( + dir: &Path, + base_name: &str, + rotation: LogRotation, + now: &DateTime, +) -> PathBuf { + match rotation { + LogRotation::Never => dir.join(base_name), + LogRotation::Minutely + | LogRotation::Hourly + | LogRotation::Daily + | LogRotation::Weekly => dir.join(format!( + "{base_name}.{}", + period_suffix_for(rotation, now) + )), + } +} + +fn period_suffix_for(rotation: LogRotation, now: &DateTime) -> String { + match rotation { + LogRotation::Never | LogRotation::Daily => now.format("%Y-%m-%d").to_string(), + LogRotation::Hourly => now.format("%Y-%m-%d-%H").to_string(), + LogRotation::Minutely => now.format("%Y-%m-%d-%H-%M").to_string(), + LogRotation::Weekly => { + let days_since_sunday = now.weekday().num_days_from_sunday() as i64; + let week_start = now.date_naive() - ChronoDuration::days(days_since_sunday); + week_start.format("%Y-%m-%d").to_string() + } + } +} + +fn system_time_from_utc(now: &DateTime) -> SystemTime { + let duration = Duration::new(now.timestamp().unsigned_abs(), now.timestamp_subsec_nanos()); + if now.timestamp() >= 0 { + UNIX_EPOCH + duration + } else { + UNIX_EPOCH - duration + } +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use tempfile::tempdir; + + use super::*; + + fn fixed_now() -> DateTime { + DateTime::::from(UNIX_EPOCH + Duration::from_secs(10)) + } + + fn options(path: PathBuf) -> FileLogOptions { + FileLogOptions { + path: path.to_string_lossy().to_string(), + rotation: LogRotation::Never, + max_size_bytes: 0, + max_files: 0, + max_age_secs: 0, + } + } + + fn matching_logs(dir: &Path) -> Vec { + let mut files: Vec<_> = fs::read_dir(dir) + .unwrap() + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| name.starts_with("telemt.log")) + .unwrap_or(false) + }) + .collect(); + files.sort(); + files + } + + #[test] + fn size_rotation_keeps_latest_write_in_active_file() { + let dir = tempdir().unwrap(); + let path = dir.path().join("telemt.log"); + let mut options = options(path.clone()); + options.max_size_bytes = 6; + + let mut appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap(); + appender.write_all(b"abc\n").unwrap(); + appender.write_all(b"def\n").unwrap(); + appender.flush().unwrap(); + + assert_eq!(fs::read_to_string(path).unwrap(), "def\n"); + assert_eq!(matching_logs(dir.path()).len(), 2); + } + + #[test] + fn max_files_retention_removes_oldest_archives() { + let dir = tempdir().unwrap(); + let path = dir.path().join("telemt.log"); + let mut options = options(path); + options.max_size_bytes = 4; + options.max_files = 2; + + let mut appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap(); + for line in [b"aa\n", b"bb\n", b"cc\n", b"dd\n"] { + appender.write_all(line).unwrap(); + } + appender.flush().unwrap(); + + assert!(matching_logs(dir.path()).len() <= 2); + } + + #[cfg(unix)] + #[test] + fn max_age_retention_removes_old_archives() { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let dir = tempdir().unwrap(); + let path = dir.path().join("telemt.log"); + let old_archive = dir.path().join("telemt.log.20000101000000.0"); + fs::write(&old_archive, "old").unwrap(); + + let c_path = CString::new(old_archive.as_os_str().as_bytes()).unwrap(); + let times = [ + libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }, + libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }, + ]; + let rc = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) }; + assert_eq!(rc, 0); + + let mut options = options(path); + options.max_age_secs = 1; + let _appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap(); + + assert!(!old_archive.exists()); + } +} diff --git a/src/logging/tests.rs b/src/logging/tests.rs new file mode 100644 index 0000000..ae57470 --- /dev/null +++ b/src/logging/tests.rs @@ -0,0 +1,100 @@ +use super::*; + +#[test] +fn test_parse_log_cli_options_default() { + let args: Vec = vec![]; + let options = parse_log_cli_options(&args).unwrap(); + assert_eq!( + resolve_log_destination(&LoggingConfig::default(), &options).unwrap(), + LogDestination::Stderr + ); +} + +#[test] +fn test_parse_log_cli_options_file() { + let args = vec!["--log-file".to_string(), "/var/log/telemt.log".to_string()]; + let options = parse_log_cli_options(&args).unwrap(); + match resolve_log_destination(&LoggingConfig::default(), &options).unwrap() { + LogDestination::File { options } => { + assert_eq!(options.path, "/var/log/telemt.log"); + assert_eq!(options.rotation, LogRotation::Never); + } + _ => panic!("Expected File destination"), + } +} + +#[test] +fn test_parse_log_cli_options_file_daily() { + let args = vec!["--log-file-daily=/var/log/telemt".to_string()]; + let options = parse_log_cli_options(&args).unwrap(); + match resolve_log_destination(&LoggingConfig::default(), &options).unwrap() { + LogDestination::File { options } => { + assert_eq!(options.path, "/var/log/telemt"); + assert_eq!(options.rotation, LogRotation::Daily); + } + _ => panic!("Expected File destination"), + } +} + +#[test] +fn test_parse_log_cli_options_bounds() { + let args = vec![ + "--log-file=/var/log/telemt.log".to_string(), + "--log-rotation=hourly".to_string(), + "--log-max-size-bytes=1024".to_string(), + "--log-max-files=3".to_string(), + "--log-max-age-secs=60".to_string(), + ]; + let options = parse_log_cli_options(&args).unwrap(); + match resolve_log_destination(&LoggingConfig::default(), &options).unwrap() { + LogDestination::File { options } => { + assert_eq!(options.rotation, LogRotation::Hourly); + assert_eq!(options.max_size_bytes, 1024); + assert_eq!(options.max_files, 3); + assert_eq!(options.max_age_secs, 60); + } + _ => panic!("Expected File destination"), + } +} + +#[test] +fn test_parse_log_cli_options_rejects_bad_rotation() { + let args = vec!["--log-rotation=yearly".to_string()]; + assert!(parse_log_cli_options(&args).is_err()); +} + +#[cfg(unix)] +#[test] +fn test_parse_log_cli_options_syslog() { + let args = vec!["--syslog".to_string()]; + let options = parse_log_cli_options(&args).unwrap(); + assert_eq!( + resolve_log_destination(&LoggingConfig::default(), &options).unwrap(), + LogDestination::Syslog + ); +} + +#[cfg(unix)] +#[test] +fn test_syslog_priority_for_level_mapping() { + assert_eq!( + syslog_priority_for_level(&tracing::Level::ERROR), + libc::LOG_ERR + ); + assert_eq!( + syslog_priority_for_level(&tracing::Level::WARN), + libc::LOG_WARNING + ); + assert_eq!( + syslog_priority_for_level(&tracing::Level::INFO), + libc::LOG_INFO + ); + assert_eq!( + syslog_priority_for_level(&tracing::Level::DEBUG), + libc::LOG_DEBUG + ); + assert_eq!( + syslog_priority_for_level(&tracing::Level::TRACE), + libc::LOG_DEBUG + ); +} diff --git a/src/maestro/helpers.rs b/src/maestro/helpers.rs index 370d7a1..ba93792 100644 --- a/src/maestro/helpers.rs +++ b/src/maestro/helpers.rs @@ -9,7 +9,7 @@ use tracing::{debug, error, info, warn}; use crate::cli; use crate::config::ProxyConfig; -use crate::logging::LogDestination; +use crate::logging::LogCliOptions; use crate::transport::UpstreamManager; use crate::transport::middle_proxy::{ ProxyConfigData, fetch_proxy_config_with_raw_via_upstream, load_proxy_config_cache, @@ -113,7 +113,7 @@ pub(crate) struct CliArgs { pub data_path: Option, pub silent: bool, pub log_level: Option, - pub log_destination: LogDestination, + pub log_cli_options: LogCliOptions, } pub(crate) fn parse_cli() -> CliArgs { @@ -125,8 +125,13 @@ 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); + let log_cli_options = match crate::logging::parse_log_cli_options(&args) { + Ok(options) => options, + Err(error) => { + eprintln!("[telemt] {error}"); + std::process::exit(2); + } + }; // Check for --init first (handled before tokio) if let Some(init_opts) = cli::parse_init_args(&args) { @@ -184,7 +189,16 @@ pub(crate) fn parse_cli() -> CliArgs { i += 1; } s if s.starts_with("--log-file=") || s.starts_with("--log-file-daily=") => {} - #[cfg(unix)] + "--log-rotation" + | "--log-max-size-bytes" + | "--log-max-files" + | "--log-max-age-secs" => { + i += 1; + } + s if s.starts_with("--log-rotation=") + || s.starts_with("--log-max-size-bytes=") + || s.starts_with("--log-max-files=") + || s.starts_with("--log-max-age-secs=") => {} "--syslog" => {} "--help" | "-h" => { print_help(); @@ -198,7 +212,8 @@ pub(crate) fn parse_cli() -> CliArgs { "--daemon" | "-d" | "--foreground" | "-f" => {} s if s.starts_with("--pid-file") => { if !s.contains('=') { - i += 1; // skip value + // Skip the pid-file value consumed by daemon argument parsing. + i += 1; } } s if s.starts_with("--run-as-user") => { @@ -230,7 +245,7 @@ pub(crate) fn parse_cli() -> CliArgs { data_path, silent, log_level, - log_destination, + log_cli_options, } } @@ -260,6 +275,10 @@ fn print_help() { eprintln!("Logging options:"); eprintln!(" --log-file Log to file (default: stderr)"); eprintln!(" --log-file-daily Log to file with daily rotation"); + eprintln!(" --log-rotation never|minutely|hourly|daily|weekly"); + eprintln!(" --log-max-size-bytes N Rotate file logs when active file exceeds N bytes"); + eprintln!(" --log-max-files N Keep at most N matching file logs (0 disables)"); + eprintln!(" --log-max-age-secs N Remove rotated file logs older than N seconds"); #[cfg(unix)] eprintln!(" --syslog Log to syslog (Unix only)"); eprintln!(); diff --git a/src/maestro/mod.rs b/src/maestro/mod.rs index e5bb2f7..e7f349c 100644 --- a/src/maestro/mod.rs +++ b/src/maestro/mod.rs @@ -108,7 +108,7 @@ async fn run_telemt_core( 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 log_cli_options = cli_args.log_cli_options; let startup_cwd = match std::env::current_dir() { Ok(cwd) => cwd, Err(e) => { @@ -331,6 +331,14 @@ async fn run_telemt_core( }; let initial_filter_spec = runtime_tasks::log_filter_spec(has_rust_log, &effective_log_level); + let log_destination = + match crate::logging::resolve_log_destination(&config.logging, &log_cli_options) { + Ok(destination) => destination, + Err(error) => { + eprintln!("[telemt] {error}"); + std::process::exit(1); + } + }; let (filter_layer, filter_handle) = reload::Layer::new(EnvFilter::new(initial_filter_spec.clone())); startup_tracker