diff --git a/README.md b/README.md index a414467..837c1c8 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ /lib/systemd/system/awg2-reboot-scheduler.service ``` -`awg2-harness.service` запускает `ensure-swap` перед стартом daemon, затем в фоне держит drift recovery. Daemon слушает Docker events и дополнительно делает периодический reconcile. `awg2-reboot-scheduler.service` отдельно читает общий config, выбирает случайное время в заданном UTC-окне и запрашивает reboot через `systemctl --no-block reboot`. +`awg2-harness.service` запускает `ensure-swap` перед стартом daemon, затем в фоне держит drift recovery. Daemon слушает Docker events и дополнительно делает периодический reconcile для `traffic`/`firewall`/`swap`; `sysctl_tuning` применяется только на старте daemon (если включён) или вручную отдельной командой. `awg2-reboot-scheduler.service` отдельно читает общий config, выбирает случайное время в заданном UTC-окне и запрашивает reboot через `systemctl --no-block reboot`. Пакет **не** устанавливает Docker, **не** устанавливает AmneziaVPN, **не** включает сервисы автоматически и **не** создаёт боевой `/etc/awg2-harness/config.toml`. Это сделано намеренно: SSH, Amnezia и боевой config должны быть подготовлены оператором до включения firewall/reconcile. diff --git a/config.example.toml b/config.example.toml index 80fc4b3..a1ab7f6 100644 --- a/config.example.toml +++ b/config.example.toml @@ -18,6 +18,36 @@ container_ready_timeout_secs = 120 class_start_id = 10 ifb_name = "ifb0" +[sysctl_tuning] +enabled = true +apply_on_daemon_start = true + +# Включает IPv4 forwarding для VPN/Docker gateway host. +ip_forward = true + +# off | auto | require +# auto: использовать BBR, если доступен; иначе пропустить без ошибки. +# require: завершить с ошибкой, если BBR недоступен. +tcp_bbr = "auto" + +# none | fq | fq_codel +# fq применяется только когда BBR активен. +default_qdisc = "fq" + +# Лимиты socket buffers в байтах. +# Баланс для хоста порядка ~500 Mbit/s. +rmem_max = 67108864 +wmem_max = 67108864 + +netdev_backlog_enabled = true +netdev_max_backlog = 50000 + +udp_rmem_min_enabled = true +udp_rmem_min = 8192 + +# net.ipv4.udp_wmem_min intentionally not managed: +# Linux kernel documentation states it has no effect. + [host_firewall] enabled = true # Должен совпадать с реально слушающим портом sshd до apply-firewall. diff --git a/docs/ACCEPTANCE_CHECKLIST.md b/docs/ACCEPTANCE_CHECKLIST.md index 1952a97..54afd60 100644 --- a/docs/ACCEPTANCE_CHECKLIST.md +++ b/docs/ACCEPTANCE_CHECKLIST.md @@ -96,6 +96,18 @@ - [ ] `vm.swappiness = 1` применён. - [ ] Повторный `ensure-swap` ничего не пересоздаёт и не добавляет дублей. +## H2. Sysctl network tuning + +- [ ] `awg2-harness apply-network-tuning` успешно завершается. +- [ ] `awg2-harness inspect-network-tuning` показывает `enabled: true` и `drift: no`. +- [ ] Создаётся только `/etc/sysctl.d/90-awg2-harness-vpn.conf`. +- [ ] В managed-файле есть marker `Managed by awg2-harness sysctl_tuning`. +- [ ] В managed-файле отсутствует `udp_wmem_min`. +- [ ] Runtime apply меняет только drifted keys (`sysctl -w`), без `sysctl --system`. +- [ ] При `tcp_bbr = auto` и недоступном BBR сервис не падает, в статусе есть warning. +- [ ] При `tcp_bbr = require` и недоступном BBR команда/старт завершается с ошибкой. +- [ ] При `enabled=false` managed-файл удаляется только если содержит managed marker. + ## I. Anti-torrent nftables guard - [ ] Если `anti_torrent.enabled = true`, в Amnezia netns есть `table inet awg2_harness`. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c1a2d42..8ef3543 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -49,6 +49,19 @@ container_ready_timeout_secs = 120 class_start_id = 10 ifb_name = "ifb0" +[sysctl_tuning] +enabled = true +apply_on_daemon_start = true +ip_forward = true +tcp_bbr = "auto" # off | auto | require +default_qdisc = "fq" # none | fq | fq_codel +rmem_max = 67108864 +wmem_max = 67108864 +netdev_backlog_enabled = true +netdev_max_backlog = 50000 +udp_rmem_min_enabled = true +udp_rmem_min = 8192 + [host_firewall] enabled = true ssh_port = 2323 @@ -98,6 +111,21 @@ swappiness = 1 `traffic.ifb_name` — имя IFB-интерфейса для ingress shaping. Production default — `ifb0`. Этот параметр можно оставить в конфиге, потому что бинарь реально создаёт/удаляет этот интерфейс. +`sysctl_tuning` — отдельный host-level модуль управляемого сетевого baseline через `sysctl`: + +- persistent-файл: `/etc/sysctl.d/90-awg2-harness-vpn.conf`; +- runtime apply: только `sysctl -w` и только для drifted keys; +- на установке `.deb` модуль не применяется; +- на старте daemon модуль применяется, если `enabled=true` и `apply_on_daemon_start=true`; +- `tcp_bbr = auto` не валит запуск при недоступном BBR (warning), `require` — валит. + +Ограничения модуля: + +- не использует `/etc/sysctl.conf`; +- не запускает `sysctl --system`; +- не меняет `nftables`, Docker bridge и container-local sysctl; +- не управляет `net.ipv4.udp_wmem_min` (в Linux документации параметр указан как не имеющий эффекта). + При drift orchestrator всегда делает controlled rebuild owned topology. `anti_torrent.enabled` — включает nftables connection-count guard против high-peer-count P2P-нагрузки. Это отдельный AWG2 Harness module и часть текущего production-контракта. diff --git a/docs/IMPLEMENTATION_SPEC.md b/docs/IMPLEMENTATION_SPEC.md index 20c88f5..2d2c808 100644 --- a/docs/IMPLEMENTATION_SPEC.md +++ b/docs/IMPLEMENTATION_SPEC.md @@ -44,6 +44,8 @@ Subcommands: ```bash awg2-harness daemon awg2-harness apply-limits +awg2-harness apply-network-tuning +awg2-harness inspect-network-tuning awg2-harness apply-firewall awg2-harness status awg2-harness ensure-swap @@ -51,6 +53,15 @@ awg2-harness validate-config awg2-harness validate-runtime ``` +`apply-network-tuning`/`inspect-network-tuning` относятся к отдельному host-level модулю `sysctl_tuning`. +Этот модуль: + +- управляет только sysctl параметрами хоста; +- пишет только `/etc/sysctl.d/90-awg2-harness-vpn.conf`; +- применяет runtime-изменения точечно (`sysctl -w`) только для drifted keys; +- не использует `/etc/sysctl.conf` и `sysctl --system`; +- не управляет `udp_wmem_min`. + ## 3. Module layout Recommended Rust module split: diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 593cf15..ca2d0f1 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -11,6 +11,7 @@ - Production-хост получает готовый Debian-пакет; Rust/cargo/source tree на нём не требуются. - `/etc/awg2-harness/config.toml` создаётся вручную. `config.example.toml` не копируется автоматически как боевой config. - Пакет не устанавливает Docker/Amnezia и не включает services автоматически. +- `sysctl_tuning` не применяется в `postinst`; применение только на старте daemon или вручную через CLI. ## A. Build machine diff --git a/docs/PRODUCTION_EXAMPLE.md b/docs/PRODUCTION_EXAMPLE.md index 8e36bd9..fabb681 100644 --- a/docs/PRODUCTION_EXAMPLE.md +++ b/docs/PRODUCTION_EXAMPLE.md @@ -24,6 +24,7 @@ AmneziaDNS: Expected status: service: active + network tuning drift: no traffic drift: no host firewall drift: no anti-torrent drift: no diff --git a/traffic_core/src/cli.rs b/traffic_core/src/cli.rs index 5cd0483..77696d9 100644 --- a/traffic_core/src/cli.rs +++ b/traffic_core/src/cli.rs @@ -16,6 +16,8 @@ pub struct Cli { pub enum Command { Daemon, ApplyLimits, + ApplyNetworkTuning, + InspectNetworkTuning, ApplyFirewall, Status, EnsureSwap, diff --git a/traffic_core/src/config.rs b/traffic_core/src/config.rs index fd0313f..93f12a8 100644 --- a/traffic_core/src/config.rs +++ b/traffic_core/src/config.rs @@ -30,12 +30,48 @@ fn default_input_policy() -> String { pub struct Config { pub amnezia: AmneziaConfig, pub traffic: TrafficConfig, + #[serde(default)] + pub sysctl_tuning: SysctlTuningConfig, pub host_firewall: HostFirewallConfig, pub anti_torrent: AntiTorrentConfig, pub reboot: RebootConfig, pub swap: SwapConfig, } +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct SysctlTuningConfig { + pub enabled: bool, + pub apply_on_daemon_start: bool, + pub ip_forward: bool, + pub tcp_bbr: String, + pub default_qdisc: String, + pub rmem_max: u64, + pub wmem_max: u64, + pub netdev_backlog_enabled: bool, + pub netdev_max_backlog: u32, + pub udp_rmem_min_enabled: bool, + pub udp_rmem_min: u32, +} + +impl Default for SysctlTuningConfig { + fn default() -> Self { + Self { + enabled: true, + apply_on_daemon_start: true, + ip_forward: true, + tcp_bbr: "auto".to_string(), + default_qdisc: "fq".to_string(), + rmem_max: 67_108_864, + wmem_max: 67_108_864, + netdev_backlog_enabled: true, + netdev_max_backlog: 50_000, + udp_rmem_min_enabled: true, + udp_rmem_min: 8192, + } + } +} + #[derive(Debug, Clone, Deserialize)] pub struct AmneziaConfig { pub container_names: Vec, @@ -139,6 +175,33 @@ impl Config { } parse_tc_rate_bps(&self.traffic.peer_limit)?; parse_tc_rate_bps(&self.traffic.root_rate)?; + + let tcp_bbr_mode = self.sysctl_tuning.tcp_bbr.trim().to_ascii_lowercase(); + if tcp_bbr_mode != "off" && tcp_bbr_mode != "auto" && tcp_bbr_mode != "require" { + anyhow::bail!("sysctl_tuning.tcp_bbr must be one of: off, auto, require"); + } + + let default_qdisc = self.sysctl_tuning.default_qdisc.trim().to_ascii_lowercase(); + if default_qdisc != "none" && default_qdisc != "fq" && default_qdisc != "fq_codel" { + anyhow::bail!("sysctl_tuning.default_qdisc must be one of: none, fq, fq_codel"); + } + + const MIB: u64 = 1024 * 1024; + if self.sysctl_tuning.rmem_max < MIB || self.sysctl_tuning.rmem_max > 512 * MIB { + anyhow::bail!("sysctl_tuning.rmem_max must be in 1MiB..=512MiB"); + } + if self.sysctl_tuning.wmem_max < MIB || self.sysctl_tuning.wmem_max > 512 * MIB { + anyhow::bail!("sysctl_tuning.wmem_max must be in 1MiB..=512MiB"); + } + if self.sysctl_tuning.netdev_max_backlog < 1000 + || self.sysctl_tuning.netdev_max_backlog > 100_000 + { + anyhow::bail!("sysctl_tuning.netdev_max_backlog must be in 1000..=100000"); + } + if self.sysctl_tuning.udp_rmem_min < 4096 || self.sysctl_tuning.udp_rmem_min > 65_536 { + anyhow::bail!("sysctl_tuning.udp_rmem_min must be in 4096..=65536"); + } + if self.swap.swappiness > 100 { anyhow::bail!("swap.swappiness must be <= 100"); } @@ -224,11 +287,19 @@ impl Config { } fn check_host_tools(&self) -> anyhow::Result<()> { - let mut required = vec!["tc", "ip", "wg", "docker", "modprobe"]; + let mut required = vec!["tc", "ip", "wg", "docker"]; if !crate::netns::nsenter_available() { anyhow::bail!("required host tool not found in PATH: nsenter"); } required.push("nsenter"); + if self.sysctl_tuning.enabled { + required.push("sysctl"); + } + if self.sysctl_tuning.enabled + && self.sysctl_tuning.tcp_bbr.trim().to_ascii_lowercase() != "off" + { + required.push("modprobe"); + } if self.anti_torrent.enabled { required.push("nft"); } @@ -408,6 +479,46 @@ pub fn ip_in_cidr(ip: Ipv4Addr, cidr: &str) -> anyhow::Result { mod tests { use super::*; + fn base_config() -> Config { + Config { + amnezia: AmneziaConfig { + container_names: vec!["x".into()], + interface_candidates: vec!["awg0".into()], + interface_patterns: vec!["awg*".into()], + peer_ipv4_cidr: "10.8.1.0/24".into(), + }, + traffic: TrafficConfig { + enabled: true, + peer_limit: "70mbit".into(), + root_rate: "1000mbit".into(), + reconcile_interval_secs: 15, + container_ready_timeout_secs: 30, + class_start_id: 10, + ifb_name: "ifb0".into(), + }, + sysctl_tuning: SysctlTuningConfig::default(), + host_firewall: HostFirewallConfig::default(), + anti_torrent: AntiTorrentConfig { + enabled: false, + max_connections_per_peer: 100, + peer_ipv4_cidr: "10.8.1.0/24".into(), + apply_in_amnezia_netns: false, + }, + reboot: RebootConfig { + enabled: false, + window_start_utc: "01:00".into(), + window_end_utc: "02:00".into(), + }, + swap: SwapConfig { + enabled: false, + path: "/swapfile".into(), + size_mb: 2048, + priority: 1, + swappiness: 1, + }, + } + } + #[test] fn parse_cidr_invalid_prefix() { let err = parse_cidr_v4("10.8.1.0/33").err(); @@ -438,86 +549,64 @@ mod tests { #[test] fn swap_rejects_dangerous_path() { - let cfg = Config { - amnezia: AmneziaConfig { - container_names: vec!["x".into()], - interface_candidates: vec!["awg0".into()], - interface_patterns: vec!["awg*".into()], - peer_ipv4_cidr: "10.8.1.0/24".into(), - }, - traffic: TrafficConfig { - enabled: true, - peer_limit: "70mbit".into(), - root_rate: "1000mbit".into(), - reconcile_interval_secs: 15, - container_ready_timeout_secs: 30, - class_start_id: 10, - ifb_name: "ifb0".into(), - }, - host_firewall: HostFirewallConfig::default(), - anti_torrent: AntiTorrentConfig { - enabled: false, - max_connections_per_peer: 100, - peer_ipv4_cidr: "10.8.1.0/24".into(), - apply_in_amnezia_netns: false, - }, - reboot: RebootConfig { - enabled: false, - window_start_utc: "01:00".into(), - window_end_utc: "02:00".into(), - }, - swap: SwapConfig { - enabled: true, - path: "/etc/fstab".into(), - size_mb: 2048, - priority: 1, - swappiness: 1, - }, - }; + let mut cfg = base_config(); + cfg.swap.enabled = true; + cfg.swap.path = "/etc/fstab".into(); assert!(cfg.validate_config().is_err()); } #[test] fn host_firewall_rejects_empty_protocols() { - let cfg = Config { - amnezia: AmneziaConfig { - container_names: vec!["x".into()], - interface_candidates: vec!["awg0".into()], - interface_patterns: vec!["awg*".into()], - peer_ipv4_cidr: "10.8.1.0/24".into(), - }, - traffic: TrafficConfig { - enabled: true, - peer_limit: "70mbit".into(), - root_rate: "1000mbit".into(), - reconcile_interval_secs: 15, - container_ready_timeout_secs: 30, - class_start_id: 10, - ifb_name: "ifb0".into(), - }, - host_firewall: HostFirewallConfig { - amnezia_protocols: vec![], - ..HostFirewallConfig::default() - }, - anti_torrent: AntiTorrentConfig { - enabled: false, - max_connections_per_peer: 100, - peer_ipv4_cidr: "10.8.1.0/24".into(), - apply_in_amnezia_netns: false, - }, - reboot: RebootConfig { - enabled: false, - window_start_utc: "01:00".into(), - window_end_utc: "02:00".into(), - }, - swap: SwapConfig { - enabled: false, - path: "/swapfile".into(), - size_mb: 2048, - priority: 1, - swappiness: 1, - }, - }; + let mut cfg = base_config(); + cfg.host_firewall.amnezia_protocols = vec![]; + assert!(cfg.validate_config().is_err()); + } + + #[test] + fn sysctl_tuning_default_is_valid() { + let cfg = base_config(); + assert!(cfg.validate_config().is_ok()); + } + + #[test] + fn sysctl_tuning_rejects_invalid_tcp_bbr() { + let mut cfg = base_config(); + cfg.sysctl_tuning.tcp_bbr = "bad".into(); + assert!(cfg.validate_config().is_err()); + } + + #[test] + fn sysctl_tuning_rejects_invalid_default_qdisc() { + let mut cfg = base_config(); + cfg.sysctl_tuning.default_qdisc = "cake".into(); + assert!(cfg.validate_config().is_err()); + } + + #[test] + fn sysctl_tuning_rejects_rmem_max_below_1mib() { + let mut cfg = base_config(); + cfg.sysctl_tuning.rmem_max = 1024; + assert!(cfg.validate_config().is_err()); + } + + #[test] + fn sysctl_tuning_rejects_rmem_max_above_512mib() { + let mut cfg = base_config(); + cfg.sysctl_tuning.rmem_max = 513 * 1024 * 1024; + assert!(cfg.validate_config().is_err()); + } + + #[test] + fn sysctl_tuning_rejects_netdev_backlog_above_limit() { + let mut cfg = base_config(); + cfg.sysctl_tuning.netdev_max_backlog = 100_001; + assert!(cfg.validate_config().is_err()); + } + + #[test] + fn sysctl_tuning_rejects_udp_rmem_min_below_limit() { + let mut cfg = base_config(); + cfg.sysctl_tuning.udp_rmem_min = 4095; assert!(cfg.validate_config().is_err()); } diff --git a/traffic_core/src/daemon.rs b/traffic_core/src/daemon.rs index 5630fbf..ea7bb51 100644 --- a/traffic_core/src/daemon.rs +++ b/traffic_core/src/daemon.rs @@ -6,7 +6,9 @@ use std::{ time::{Duration, Instant}, }; -use crate::{config::Config, docker, firewall, iface, netns::build_transport, swap, traffic}; +use crate::{ + config::Config, docker, firewall, iface, netns::build_transport, swap, sysctl_tuning, traffic, +}; use tracing::{error, info}; #[derive(Debug, Clone, Default)] @@ -26,6 +28,17 @@ pub fn run(cfg: Config) -> anyhow::Result<()> { info!("starting daemon"); let state = Arc::new(Mutex::new(LastActionState::default())); + if cfg.sysctl_tuning.enabled && cfg.sysctl_tuning.apply_on_daemon_start { + if let Err(e) = sysctl_tuning::ensure_sysctl_tuning(&cfg) { + set_err(&state, format!("startup network tuning reconcile: {e}")); + append_event(&format!("startup_network_tuning error=\"{e}\"")); + return Err(e); + } else { + set_ok(&state, "startup network tuning reconcile ok"); + append_event("startup_network_tuning ok"); + } + } + if let Err(e) = swap::ensure_swap(&cfg) { set_err(&state, format!("startup swap reconcile: {e}")); append_event(&format!("startup_swap error=\"{e}\"")); diff --git a/traffic_core/src/main.rs b/traffic_core/src/main.rs index baa9460..f4e9e1e 100644 --- a/traffic_core/src/main.rs +++ b/traffic_core/src/main.rs @@ -9,6 +9,7 @@ mod lock; mod netns; mod status; mod swap; +mod sysctl_tuning; mod traffic; mod wg; @@ -49,6 +50,18 @@ fn main() -> anyhow::Result<()> { traffic::reconcile_traffic(&cfg)?; println!("traffic limits reconciled"); } + Command::ApplyNetworkTuning => { + let cfg = Config::load(&config_path)?; + cfg.validate_all()?; + sysctl_tuning::ensure_sysctl_tuning(&cfg)?; + println!("network tuning reconciled"); + } + Command::InspectNetworkTuning => { + let cfg = Config::load(&config_path)?; + cfg.validate_config()?; + let s = sysctl_tuning::inspect_sysctl_tuning(&cfg)?; + sysctl_tuning::print_sysctl_tuning_status(&s); + } Command::ApplyFirewall => { let cfg = Config::load(&config_path)?; cfg.validate_all()?; diff --git a/traffic_core/src/status.rs b/traffic_core/src/status.rs index 5a787ef..35208ec 100644 --- a/traffic_core/src/status.rs +++ b/traffic_core/src/status.rs @@ -1,4 +1,6 @@ -use crate::{config::Config, docker, firewall, netns::build_transport, swap, traffic, wg}; +use crate::{ + config::Config, docker, firewall, netns::build_transport, swap, sysctl_tuning, traffic, wg, +}; use std::{ collections::BTreeMap, @@ -20,6 +22,8 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { Err(_) => println!(" service: unknown"), } + print_network_tuning_status(cfg); + let target = match docker::discover_target(cfg) { Ok(t) => t, Err(e) => { @@ -270,6 +274,17 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { Ok(()) } +fn print_network_tuning_status(cfg: &Config) { + match sysctl_tuning::inspect_sysctl_tuning(cfg) { + Ok(s) => sysctl_tuning::print_sysctl_tuning_status(&s), + Err(e) => { + println!("Network tuning:"); + println!(" enabled: {}", cfg.sysctl_tuning.enabled); + println!(" drift: unknown ({e})"); + } + } +} + fn print_degraded_sections(cfg: &Config) -> anyhow::Result<()> { println!("Peers:"); println!(" unavailable (container/netns not discovered)"); diff --git a/traffic_core/src/sysctl_tuning.rs b/traffic_core/src/sysctl_tuning.rs new file mode 100644 index 0000000..774adca --- /dev/null +++ b/traffic_core/src/sysctl_tuning.rs @@ -0,0 +1,425 @@ +use std::{collections::BTreeMap, fs, io::Write, path::Path, process::Command, time::Duration}; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use crate::{config::Config, lock::FileLock}; + +const SYSCTL_CONF_PATH: &str = "/etc/sysctl.d/90-awg2-harness-vpn.conf"; +const MANAGED_MARKER: &str = "# Managed by awg2-harness sysctl_tuning"; + +#[derive(Debug, Clone)] +pub struct SysctlTuningStatus { + pub enabled: bool, + pub apply_on_daemon_start: bool, + pub tcp_bbr_mode: String, + pub conf_path: String, + pub persisted_present: bool, + pub persisted_matches: bool, + pub bbr_available: Option, + pub bbr_active: Option, + pub current: Vec, + pub desired: Vec, + pub drift_reason: Option, + pub warnings: Vec, +} + +#[derive(Debug, Clone)] +pub struct SysctlValue { + pub key: String, + pub desired: Option, + pub current: Option, + pub managed: bool, +} + +#[derive(Debug, Clone)] +struct RenderPlan { + desired: BTreeMap, + persisted: String, + bbr_available: Option, + bbr_active: Option, + warnings: Vec, +} + +pub fn ensure_sysctl_tuning(cfg: &Config) -> anyhow::Result<()> { + if !cfg.sysctl_tuning.enabled { + remove_managed_sysctl_file_if_disabled(cfg)?; + return Ok(()); + } + + let _lock = FileLock::acquire_timeout( + "/run/awg2-harness/sysctl_tuning.lock", + Duration::from_secs(30), + )?; + + let plan = build_render_plan(cfg, true)?; + write_atomic(SYSCTL_CONF_PATH, &plan.persisted)?; + + if let Some(v) = plan.desired.get("net.ipv4.ip_forward") { + let current = read_sysctl("net.ipv4.ip_forward").ok(); + if current.as_deref() != Some(v.as_str()) { + write_sysctl("net.ipv4.ip_forward", v)?; + } + } + + for (k, v) in &plan.desired { + if k == "net.ipv4.ip_forward" { + continue; + } + let current = read_sysctl(k).ok(); + if current.as_deref() != Some(v.as_str()) { + write_sysctl(k, v)?; + } + } + + for (k, v) in &plan.desired { + let current = read_sysctl(k).unwrap_or_else(|_| "".to_string()); + if current != *v { + anyhow::bail!("sysctl verify failed for {k}: current={current}, desired={v}"); + } + } + + Ok(()) +} + +pub fn inspect_sysctl_tuning(cfg: &Config) -> anyhow::Result { + let plan = build_render_plan(cfg, false)?; + let persisted_raw = fs::read_to_string(SYSCTL_CONF_PATH).ok(); + let persisted_present = persisted_raw.is_some(); + let persisted_matches = persisted_raw + .as_deref() + .map(|v| normalize(v) == normalize(&plan.persisted)) + .unwrap_or(false); + + let mut values = Vec::::new(); + for (k, v) in &plan.desired { + values.push(SysctlValue { + key: k.clone(), + desired: Some(v.clone()), + current: read_sysctl(k).ok(), + managed: true, + }); + } + + let mut drift_reason = None; + if cfg.sysctl_tuning.enabled { + if !persisted_present { + drift_reason = Some("managed sysctl file is missing".to_string()); + } else if !persisted_matches { + drift_reason = Some("persisted sysctl file differs from rendered config".to_string()); + } else if let Some(v) = values + .iter() + .find(|v| v.desired.as_deref() != v.current.as_deref()) + { + drift_reason = Some(format!( + "runtime value mismatch for {}: current={:?} desired={:?}", + v.key, v.current, v.desired + )); + } + } + + 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, + persisted_matches, + bbr_available: plan.bbr_available, + bbr_active: plan.bbr_active, + current: values.clone(), + desired: values, + drift_reason, + warnings: plan.warnings, + }) +} + +pub fn print_sysctl_tuning_status(status: &SysctlTuningStatus) { + println!("Network tuning:"); + println!(" enabled: {}", status.enabled); + println!(" apply_on_daemon_start: {}", status.apply_on_daemon_start); + println!(" tcp_bbr_mode: {}", status.tcp_bbr_mode); + println!(" conf_path: {}", status.conf_path); + println!( + " persisted_present: {}", + if status.persisted_present { + "yes" + } else { + "no" + } + ); + println!( + " persisted_matches: {}", + if status.persisted_matches { + "yes" + } else { + "no" + } + ); + if let Some(v) = status.bbr_available { + println!(" bbr_available: {}", if v { "yes" } else { "no" }); + } + if let Some(v) = status.bbr_active { + println!(" bbr_active: {}", if v { "yes" } else { "no" }); + } + + for row in &status.current { + println!( + " {}: current={} desired={}", + row.key, + row.current.as_deref().unwrap_or("n/a"), + row.desired.as_deref().unwrap_or("n/a") + ); + } + + if let Some(reason) = &status.drift_reason { + println!(" drift: yes"); + println!(" reason: {reason}"); + } else { + println!(" drift: no"); + } + for w in &status.warnings { + println!(" warning: {w}"); + } +} + +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); + 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}"); + } + fs::remove_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 { + return Ok(RenderPlan { + desired, + persisted: render_persisted(&desired), + bbr_available: None, + bbr_active: None, + warnings, + }); + } + + let mode = cfg.sysctl_tuning.tcp_bbr.trim().to_ascii_lowercase(); + let mut bbr_avail = bbr_available()?; + if !bbr_avail && mode != "off" && allow_modprobe { + let _ = Command::new("modprobe").args(["tcp_bbr"]).output(); + bbr_avail = bbr_available()?; + } + + let bbr_active = if mode == "off" { + None + } else if bbr_avail { + Some(true) + } else { + if mode == "require" { + anyhow::bail!("tcp_bbr=require but bbr is unavailable"); + } + warnings.push("bbr unavailable; tcp tuning skipped in auto mode".to_string()); + Some(false) + }; + + if cfg.sysctl_tuning.ip_forward { + desired.insert("net.ipv4.ip_forward".to_string(), "1".to_string()); + } + desired.insert( + "net.core.rmem_max".to_string(), + cfg.sysctl_tuning.rmem_max.to_string(), + ); + desired.insert( + "net.core.wmem_max".to_string(), + cfg.sysctl_tuning.wmem_max.to_string(), + ); + if cfg.sysctl_tuning.netdev_backlog_enabled { + desired.insert( + "net.core.netdev_max_backlog".to_string(), + cfg.sysctl_tuning.netdev_max_backlog.to_string(), + ); + } + if cfg.sysctl_tuning.udp_rmem_min_enabled { + desired.insert( + "net.ipv4.udp_rmem_min".to_string(), + cfg.sysctl_tuning.udp_rmem_min.to_string(), + ); + } + + if bbr_active == Some(true) { + desired.insert( + "net.ipv4.tcp_congestion_control".to_string(), + "bbr".to_string(), + ); + let qdisc = cfg.sysctl_tuning.default_qdisc.trim().to_ascii_lowercase(); + if qdisc != "none" { + desired.insert("net.core.default_qdisc".to_string(), qdisc); + } + } + + let persisted = render_persisted(&desired); + Ok(RenderPlan { + desired, + persisted, + bbr_available: Some(bbr_avail), + bbr_active, + warnings, + }) +} + +fn bbr_available() -> anyhow::Result { + let v = read_sysctl("net.ipv4.tcp_available_congestion_control")?; + Ok(v.split_whitespace().any(|p| p == "bbr")) +} + +fn render_persisted(desired: &BTreeMap) -> String { + let mut out = String::new(); + out.push_str(MANAGED_MARKER); + out.push('\n'); + out.push_str("# Source: /etc/awg2-harness/config.toml\n"); + out.push_str("# Do not edit this file manually; use [sysctl_tuning].\n\n"); + for (k, v) in desired { + out.push_str(&format!("{k} = {v}\n")); + } + out +} + +fn read_sysctl(key: &str) -> anyhow::Result { + let out = Command::new("sysctl").args(["-n", key]).output()?; + if !out.status.success() { + anyhow::bail!( + "sysctl -n {key} failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +fn write_sysctl(key: &str, value: &str) -> anyhow::Result<()> { + let out = Command::new("sysctl") + .args(["-w", &format!("{key}={value}")]) + .output()?; + if !out.status.success() { + anyhow::bail!( + "sysctl -w {key}={value} failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(()) +} + +fn normalize(v: &str) -> String { + v.lines().map(str::trim_end).collect::>().join("\n") +} + +fn write_atomic(path: &str, content: &str) -> anyhow::Result<()> { + let p = Path::new(path); + let dir = p + .parent() + .ok_or_else(|| anyhow::anyhow!("no parent directory for path: {path}"))?; + 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))?; + + fs::rename(&tmp_path, path)?; + + #[cfg(unix)] + { + let d = fs::File::open(dir)?; + d.sync_all()?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ + AmneziaConfig, AntiTorrentConfig, HostFirewallConfig, RebootConfig, SwapConfig, + TrafficConfig, + }; + + fn cfg() -> Config { + Config { + amnezia: AmneziaConfig { + container_names: vec!["x".into()], + interface_candidates: vec!["awg0".into()], + interface_patterns: vec!["awg*".into()], + peer_ipv4_cidr: "10.8.1.0/24".into(), + }, + traffic: TrafficConfig { + enabled: true, + peer_limit: "70mbit".into(), + root_rate: "1000mbit".into(), + reconcile_interval_secs: 15, + container_ready_timeout_secs: 30, + class_start_id: 10, + ifb_name: "ifb0".into(), + }, + sysctl_tuning: crate::config::SysctlTuningConfig::default(), + host_firewall: HostFirewallConfig::default(), + anti_torrent: AntiTorrentConfig { + enabled: false, + max_connections_per_peer: 100, + peer_ipv4_cidr: "10.8.1.0/24".into(), + apply_in_amnezia_netns: false, + }, + reboot: RebootConfig { + enabled: false, + window_start_utc: "01:00".into(), + window_end_utc: "02:00".into(), + }, + swap: SwapConfig { + enabled: false, + path: "/swapfile".into(), + size_mb: 2048, + priority: 1, + swappiness: 1, + }, + } + } + + #[test] + fn managed_marker_is_present() { + let mut map = BTreeMap::new(); + map.insert("net.core.rmem_max".to_string(), "1".to_string()); + let s = render_persisted(&map); + assert!(s.starts_with(MANAGED_MARKER)); + } + + #[test] + fn no_udp_wmem_min_in_rendered_output() { + let mut map = BTreeMap::new(); + map.insert("net.core.rmem_max".to_string(), "1".to_string()); + let s = render_persisted(&map); + assert!(!s.contains("udp_wmem_min")); + } + + #[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()); + } + } +}