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
+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")]