mirror of
https://github.com/telemt/telemt.git
synced 2026-07-07 20:40:15 +03:00
Add bounded file logging rotation and retention #832
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
+118
-1
@@ -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();
|
||||
|
||||
@@ -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")]
|
||||
|
||||
Reference in New Issue
Block a user