From 10b2d0aefb5ff796279452b31098379ba9c7e753 Mon Sep 17 00:00:00 2001 From: Crimson Date: Thu, 28 May 2026 12:01:08 +0500 Subject: [PATCH] Production sysctl_tuning hardening and upgrade-safe defaults --- docs/CONFIGURATION.md | 8 +- traffic_core/src/config.rs | 62 ++++++++++++++- traffic_core/src/firewall.rs | 4 + traffic_core/src/lock.rs | 6 ++ traffic_core/src/sysctl_tuning.rs | 126 +++++++++++++++++++++++++++--- 5 files changed, 191 insertions(+), 15 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 8ef3543..1f961db 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -117,7 +117,13 @@ swappiness = 1 - runtime apply: только `sysctl -w` и только для drifted keys; - на установке `.deb` модуль не применяется; - на старте daemon модуль применяется, если `enabled=true` и `apply_on_daemon_start=true`; -- `tcp_bbr = auto` не валит запуск при недоступном BBR (warning), `require` — валит. +- `tcp_bbr = auto` не валит запуск при недоступном BBR (warning), `require` — валит; +- host runtime tools: `modprobe` обязателен только для `tcp_bbr = "require"`. + +Совместимость обновлений: + +- если в существующем runtime-конфиге отсутствует блок `[sysctl_tuning]`, он не включается автоматически (`enabled=false` по default для missing block); +- для новых установок рекомендуется явный блок `[sysctl_tuning]` из примера, где `enabled=true`. Ограничения модуля: diff --git a/traffic_core/src/config.rs b/traffic_core/src/config.rs index 93f12a8..bebc259 100644 --- a/traffic_core/src/config.rs +++ b/traffic_core/src/config.rs @@ -26,11 +26,18 @@ fn default_input_policy() -> String { "drop".to_string() } +fn default_sysctl_tuning_for_missing_block() -> SysctlTuningConfig { + SysctlTuningConfig { + enabled: false, + ..SysctlTuningConfig::default() + } +} + #[derive(Debug, Clone, Deserialize)] pub struct Config { pub amnezia: AmneziaConfig, pub traffic: TrafficConfig, - #[serde(default)] + #[serde(default = "default_sysctl_tuning_for_missing_block")] pub sysctl_tuning: SysctlTuningConfig, pub host_firewall: HostFirewallConfig, pub anti_torrent: AntiTorrentConfig, @@ -296,7 +303,7 @@ impl Config { required.push("sysctl"); } if self.sysctl_tuning.enabled - && self.sysctl_tuning.tcp_bbr.trim().to_ascii_lowercase() != "off" + && self.sysctl_tuning.tcp_bbr.trim().to_ascii_lowercase() == "require" { required.push("modprobe"); } @@ -649,4 +656,55 @@ swappiness = 1 let parsed = toml::from_str::(raw); assert!(parsed.is_err()); } + + #[test] + fn missing_sysctl_tuning_block_defaults_to_disabled() { + let raw = r#" +[amnezia] +container_names = ["amnezia-awg2"] +interface_candidates = ["awg0"] +interface_patterns = ["awg*"] +peer_ipv4_cidr = "10.8.1.0/24" + +[traffic] +enabled = true +peer_limit = "70mbit" +root_rate = "1000mbit" +reconcile_interval_secs = 15 +container_ready_timeout_secs = 30 +class_start_id = 10 +ifb_name = "ifb0" + +[host_firewall] +enabled = true +ssh_port = 2323 +amnezia_port = 443 +amnezia_protocols = ["udp"] +allow_icmp = true +default_input_policy = "drop" + +[anti_torrent] +enabled = false +max_connections_per_peer = 100 +peer_ipv4_cidr = "10.8.1.0/24" +apply_in_amnezia_netns = false + +[reboot] +enabled = false +window_start_utc = "01:00" +window_end_utc = "02:00" + +[swap] +enabled = false +path = "/swapfile" +size_mb = 2048 +priority = 1 +swappiness = 1 +"#; + + let cfg = toml::from_str::(raw).expect("config parse"); + assert!(!cfg.sysctl_tuning.enabled); + assert!(cfg.sysctl_tuning.apply_on_daemon_start); + assert_eq!(cfg.sysctl_tuning.tcp_bbr, "auto"); + } } diff --git a/traffic_core/src/firewall.rs b/traffic_core/src/firewall.rs index c1f3306..f0d21a6 100644 --- a/traffic_core/src/firewall.rs +++ b/traffic_core/src/firewall.rs @@ -692,6 +692,7 @@ table inet awg2_host_firewall { class_start_id: 10, ifb_name: "ifb0".into(), }, + sysctl_tuning: crate::config::SysctlTuningConfig::default(), host_firewall: crate::config::HostFirewallConfig::default(), anti_torrent: crate::config::AntiTorrentConfig { enabled: false, @@ -753,6 +754,7 @@ table inet awg2_host_firewall { class_start_id: 10, ifb_name: "ifb0".into(), }, + sysctl_tuning: crate::config::SysctlTuningConfig::default(), host_firewall: crate::config::HostFirewallConfig::default(), anti_torrent: crate::config::AntiTorrentConfig { enabled: false, @@ -798,6 +800,7 @@ table inet awg2_host_firewall { class_start_id: 10, ifb_name: "ifb0".into(), }, + sysctl_tuning: crate::config::SysctlTuningConfig::default(), host_firewall: crate::config::HostFirewallConfig::default(), anti_torrent: crate::config::AntiTorrentConfig { enabled: false, @@ -855,6 +858,7 @@ table inet awg2_host_firewall { class_start_id: 10, ifb_name: "ifb0".into(), }, + sysctl_tuning: crate::config::SysctlTuningConfig::default(), host_firewall: crate::config::HostFirewallConfig::default(), anti_torrent: crate::config::AntiTorrentConfig { enabled: false, diff --git a/traffic_core/src/lock.rs b/traffic_core/src/lock.rs index 9f9d443..b796afd 100644 --- a/traffic_core/src/lock.rs +++ b/traffic_core/src/lock.rs @@ -71,6 +71,7 @@ impl Drop for FileLock { } } +#[cfg(unix)] fn is_stale_lock(path: &Path) -> anyhow::Result { let pid_raw = fs::read_to_string(path)?; let pid = match pid_raw.trim().parse::() { @@ -82,6 +83,11 @@ fn is_stale_lock(path: &Path) -> anyhow::Result { Ok(!Path::new(&proc_path).exists()) } +#[cfg(not(unix))] +fn is_stale_lock(_path: &Path) -> anyhow::Result { + Ok(false) +} + #[cfg(test)] mod tests { use super::*; diff --git a/traffic_core/src/sysctl_tuning.rs b/traffic_core/src/sysctl_tuning.rs index 774adca..1ef83f9 100644 --- a/traffic_core/src/sysctl_tuning.rs +++ b/traffic_core/src/sysctl_tuning.rs @@ -53,6 +53,10 @@ pub fn ensure_sysctl_tuning(cfg: &Config) -> anyhow::Result<()> { )?; let plan = build_render_plan(cfg, true)?; + for w in &plan.warnings { + tracing::warn!(target: "sysctl_tuning", "{w}"); + } + ensure_managed_or_absent(SYSCTL_CONF_PATH)?; write_atomic(SYSCTL_CONF_PATH, &plan.persisted)?; if let Some(v) = plan.desired.get("net.ipv4.ip_forward") { @@ -83,7 +87,25 @@ pub fn ensure_sysctl_tuning(cfg: &Config) -> anyhow::Result<()> { } pub fn inspect_sysctl_tuning(cfg: &Config) -> anyhow::Result { - let plan = build_render_plan(cfg, false)?; + let plan = match build_render_plan(cfg, false) { + Ok(plan) => plan, + Err(e) => { + return Ok(SysctlTuningStatus { + enabled: cfg.sysctl_tuning.enabled, + apply_on_daemon_start: cfg.sysctl_tuning.apply_on_daemon_start, + tcp_bbr_mode: cfg.sysctl_tuning.tcp_bbr.clone(), + conf_path: SYSCTL_CONF_PATH.to_string(), + persisted_present: Path::new(SYSCTL_CONF_PATH).exists(), + persisted_matches: false, + bbr_available: None, + bbr_active: None, + current: Vec::new(), + desired: Vec::new(), + drift_reason: Some(format!("render plan unavailable: {e}")), + warnings: vec![format!("render plan error: {e}")], + }); + } + }; let persisted_raw = fs::read_to_string(SYSCTL_CONF_PATH).ok(); let persisted_present = persisted_raw.is_some(); let persisted_matches = persisted_raw @@ -187,27 +209,48 @@ pub fn remove_managed_sysctl_file_if_disabled(cfg: &Config) -> anyhow::Result<() if cfg.sysctl_tuning.enabled { return Ok(()); } - let path = Path::new(SYSCTL_CONF_PATH); + + remove_managed_sysctl_file_at(Path::new(SYSCTL_CONF_PATH)) +} + +fn remove_managed_sysctl_file_at(path: &Path) -> anyhow::Result<()> { if !path.exists() { return Ok(()); } let raw = fs::read_to_string(path)?; let first = raw.lines().next().unwrap_or(""); if !first.contains(MANAGED_MARKER) { - anyhow::bail!("refusing to remove unmanaged sysctl file: {SYSCTL_CONF_PATH}"); + anyhow::bail!("refusing to remove unmanaged sysctl file: {}", path.display()); } fs::remove_file(path)?; Ok(()) } +fn ensure_managed_or_absent(path: &str) -> anyhow::Result<()> { + let p = Path::new(path); + if !p.exists() { + return Ok(()); + } + + let raw = fs::read_to_string(p)?; + let first = raw.lines().next().unwrap_or(""); + + if !first.contains(MANAGED_MARKER) { + anyhow::bail!("refusing to overwrite unmanaged sysctl file: {path}"); + } + + Ok(()) +} + fn build_render_plan(cfg: &Config, allow_modprobe: bool) -> anyhow::Result { let mut desired = BTreeMap::::new(); let mut warnings = Vec::::new(); if !cfg.sysctl_tuning.enabled { + let persisted = render_persisted(&desired); return Ok(RenderPlan { desired, - persisted: render_persisted(&desired), + persisted, bbr_available: None, bbr_active: None, warnings, @@ -331,13 +374,14 @@ fn write_atomic(path: &str, content: &str) -> anyhow::Result<()> { let tmp_path = format!("{path}.tmp"); let mut f = fs::File::create(&tmp_path)?; - f.write_all(content.as_bytes())?; - f.sync_all()?; - drop(f); #[cfg(unix)] fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o644))?; + f.write_all(content.as_bytes())?; + f.sync_all()?; + drop(f); + fs::rename(&tmp_path, path)?; #[cfg(unix)] @@ -415,11 +459,69 @@ mod tests { #[test] fn remove_refuses_unmanaged_file() { - let mut c = cfg(); - c.sysctl_tuning.enabled = false; - let e = remove_managed_sysctl_file_if_disabled(&c); - if Path::new(SYSCTL_CONF_PATH).exists() { - assert!(e.is_ok() || e.is_err()); + let path = std::env::temp_dir().join(format!( + "awg2_harness_unmanaged_sysctl_test_{}.conf", + std::process::id() + )); + + fs::write(&path, "# manual file\nnet.core.rmem_max = 1\n").expect("write unmanaged test"); + + let e = remove_managed_sysctl_file_at(&path); + assert!(e.is_err()); + + let _ = fs::remove_file(&path); + } + + #[test] + fn remove_deletes_managed_file() { + let path = std::env::temp_dir().join(format!( + "awg2_harness_managed_sysctl_test_{}.conf", + std::process::id() + )); + + fs::write(&path, format!("{MANAGED_MARKER}\nnet.core.rmem_max = 1\n")) + .expect("write managed test"); + + remove_managed_sysctl_file_at(&path).expect("remove managed file"); + assert!(!path.exists()); + } + + #[test] + fn ensure_refuses_overwrite_unmanaged_file() { + let path = std::env::temp_dir().join(format!( + "awg2_harness_unmanaged_overwrite_test_{}.conf", + std::process::id() + )); + fs::write(&path, "# manual file\nnet.core.wmem_max = 1\n").expect("write unmanaged"); + + let path_str = path.to_string_lossy().to_string(); + let e = ensure_managed_or_absent(&path_str); + assert!(e.is_err()); + + let _ = fs::remove_file(&path); + } + + #[test] + fn ensure_allows_absent_or_managed_file() { + let absent = std::env::temp_dir().join(format!( + "awg2_harness_absent_test_{}.conf", + std::process::id() + )); + if absent.exists() { + let _ = fs::remove_file(&absent); } + let absent_str = absent.to_string_lossy().to_string(); + ensure_managed_or_absent(&absent_str).expect("allow absent"); + + let managed = std::env::temp_dir().join(format!( + "awg2_harness_managed_overwrite_test_{}.conf", + std::process::id() + )); + fs::write(&managed, format!("{MANAGED_MARKER}\nnet.core.rmem_max = 1\n")) + .expect("write managed"); + let managed_str = managed.to_string_lossy().to_string(); + ensure_managed_or_absent(&managed_str).expect("allow managed"); + + let _ = fs::remove_file(&managed); } }