Add bounded file logging rotation and retention #832

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-06-24 00:16:02 +03:00
parent 7e5a1841b1
commit 87c82c2a63
8 changed files with 1031 additions and 110 deletions
+80
View File
@@ -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<String, String or String[]>` | `{}` | `✘` |
| [`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]
+118 -1
View File
@@ -114,6 +114,7 @@ fn normalize_exclusive_mask_target(target: &str, field: &str) -> Result<String>
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<UnknownConfigKe
&["general", "links"],
LINKS_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["logging"],
LOGGING_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
@@ -998,6 +1015,24 @@ fn sanitize_ad_tag(ad_tag: &mut Option<String>) {
}
}
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();
+80
View File
@@ -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<Self> {
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<String>,
/// 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")]
+222 -101
View File
@@ -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<LogCliDestination>,
path: Option<String>,
rotation: Option<LogRotation>,
max_size_bytes: Option<u64>,
max_files: Option<usize>,
max_age_secs: Option<u64>,
}
/// 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<LogCliOptions, String> {
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, String> {
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<String> = vec![];
assert!(matches!(
parse_log_destination(&args),
LogDestination::Stderr
));
}
fn parse_u64_cli_value(flag: &str, value: &str) -> Result<u64, String> {
value
.parse::<u64>()
.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<usize, String> {
value
.parse::<usize>()
.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<LogDestination, String> {
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),
},
})
}
}
}
+396
View File
@@ -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<Utc>,
file: Option<File>,
now: Box<dyn Fn() -> DateTime<Utc> + Send + Sync>,
}
impl BoundedFileAppender {
pub(crate) fn new(options: FileLogOptions) -> io::Result<Self> {
Self::with_now(options, Box::new(Utc::now))
}
fn with_now(
options: FileLogOptions,
now: Box<dyn Fn() -> DateTime<Utc> + Send + Sync>,
) -> io::Result<Self> {
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(&current_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<Utc> {
(self.now)()
}
fn refresh_active_path(&mut self, now: &DateTime<Utc>) -> io::Result<bool> {
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<Utc>) -> 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<Utc>) -> 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<Utc>) -> 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<Utc>) {
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<usize> {
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<Utc>,
) -> 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<Utc>) -> 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<Utc>) -> 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<Utc> {
DateTime::<Utc>::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<PathBuf> {
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());
}
}
+100
View File
@@ -0,0 +1,100 @@
use super::*;
#[test]
fn test_parse_log_cli_options_default() {
let args: Vec<String> = 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
);
}
+26 -7
View File
@@ -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<PathBuf>,
pub silent: bool,
pub log_level: Option<String>,
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<String> = 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 <PATH> Log to file (default: stderr)");
eprintln!(" --log-file-daily <PATH> Log to file with daily rotation");
eprintln!(" --log-rotation <MODE> 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!();
+9 -1
View File
@@ -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