Реализован production-модуль sysctl_tuning и полная интеграция fix33

Добавлен отдельный host-level модуль sysctl_tuning без легаси-подходов: managed sysctl файл, точечный reconcile через sysctl -w только для drifted ключей, режимы tcp_bbr off/auto/require, безопасное удаление только managed-файла и атомарная запись конфигурации.

Интегрированы новые CLI-команды apply-network-tuning/inspect-network-tuning, startup reconcile в daemon и секция Network tuning в status. Расширены config/defaults/валидация, обновлен пример конфига и документация. Добавлены unit-тесты для config и sysctl_tuning.
This commit is contained in:
2026-05-28 11:31:13 +05:00
parent 4ff2bb2c30
commit 2c27bff800
13 changed files with 719 additions and 79 deletions
+1 -1
View File
@@ -13,7 +13,7 @@
/lib/systemd/system/awg2-reboot-scheduler.service /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. Пакет **не** устанавливает Docker, **не** устанавливает AmneziaVPN, **не** включает сервисы автоматически и **не** создаёт боевой `/etc/awg2-harness/config.toml`. Это сделано намеренно: SSH, Amnezia и боевой config должны быть подготовлены оператором до включения firewall/reconcile.
+30
View File
@@ -18,6 +18,36 @@ container_ready_timeout_secs = 120
class_start_id = 10 class_start_id = 10
ifb_name = "ifb0" 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] [host_firewall]
enabled = true enabled = true
# Должен совпадать с реально слушающим портом sshd до apply-firewall. # Должен совпадать с реально слушающим портом sshd до apply-firewall.
+12
View File
@@ -96,6 +96,18 @@
- [ ] `vm.swappiness = 1` применён. - [ ] `vm.swappiness = 1` применён.
- [ ] Повторный `ensure-swap` ничего не пересоздаёт и не добавляет дублей. - [ ] Повторный `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 ## I. Anti-torrent nftables guard
- [ ] Если `anti_torrent.enabled = true`, в Amnezia netns есть `table inet awg2_harness`. - [ ] Если `anti_torrent.enabled = true`, в Amnezia netns есть `table inet awg2_harness`.
+28
View File
@@ -49,6 +49,19 @@ container_ready_timeout_secs = 120
class_start_id = 10 class_start_id = 10
ifb_name = "ifb0" 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] [host_firewall]
enabled = true enabled = true
ssh_port = 2323 ssh_port = 2323
@@ -98,6 +111,21 @@ swappiness = 1
`traffic.ifb_name` — имя IFB-интерфейса для ingress shaping. Production default — `ifb0`. Этот параметр можно оставить в конфиге, потому что бинарь реально создаёт/удаляет этот интерфейс. `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. При drift orchestrator всегда делает controlled rebuild owned topology.
`anti_torrent.enabled` — включает nftables connection-count guard против high-peer-count P2P-нагрузки. Это отдельный AWG2 Harness module и часть текущего production-контракта. `anti_torrent.enabled` — включает nftables connection-count guard против high-peer-count P2P-нагрузки. Это отдельный AWG2 Harness module и часть текущего production-контракта.
+11
View File
@@ -44,6 +44,8 @@ Subcommands:
```bash ```bash
awg2-harness daemon awg2-harness daemon
awg2-harness apply-limits awg2-harness apply-limits
awg2-harness apply-network-tuning
awg2-harness inspect-network-tuning
awg2-harness apply-firewall awg2-harness apply-firewall
awg2-harness status awg2-harness status
awg2-harness ensure-swap awg2-harness ensure-swap
@@ -51,6 +53,15 @@ awg2-harness validate-config
awg2-harness validate-runtime 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 ## 3. Module layout
Recommended Rust module split: Recommended Rust module split:
+1
View File
@@ -11,6 +11,7 @@
- Production-хост получает готовый Debian-пакет; Rust/cargo/source tree на нём не требуются. - Production-хост получает готовый Debian-пакет; Rust/cargo/source tree на нём не требуются.
- `/etc/awg2-harness/config.toml` создаётся вручную. `config.example.toml` не копируется автоматически как боевой config. - `/etc/awg2-harness/config.toml` создаётся вручную. `config.example.toml` не копируется автоматически как боевой config.
- Пакет не устанавливает Docker/Amnezia и не включает services автоматически. - Пакет не устанавливает Docker/Amnezia и не включает services автоматически.
- `sysctl_tuning` не применяется в `postinst`; применение только на старте daemon или вручную через CLI.
## A. Build machine ## A. Build machine
+1
View File
@@ -24,6 +24,7 @@ AmneziaDNS:
Expected status: Expected status:
service: active service: active
network tuning drift: no
traffic drift: no traffic drift: no
host firewall drift: no host firewall drift: no
anti-torrent drift: no anti-torrent drift: no
+2
View File
@@ -16,6 +16,8 @@ pub struct Cli {
pub enum Command { pub enum Command {
Daemon, Daemon,
ApplyLimits, ApplyLimits,
ApplyNetworkTuning,
InspectNetworkTuning,
ApplyFirewall, ApplyFirewall,
Status, Status,
EnsureSwap, EnsureSwap,
+165 -76
View File
@@ -30,12 +30,48 @@ fn default_input_policy() -> String {
pub struct Config { pub struct Config {
pub amnezia: AmneziaConfig, pub amnezia: AmneziaConfig,
pub traffic: TrafficConfig, pub traffic: TrafficConfig,
#[serde(default)]
pub sysctl_tuning: SysctlTuningConfig,
pub host_firewall: HostFirewallConfig, pub host_firewall: HostFirewallConfig,
pub anti_torrent: AntiTorrentConfig, pub anti_torrent: AntiTorrentConfig,
pub reboot: RebootConfig, pub reboot: RebootConfig,
pub swap: SwapConfig, 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)] #[derive(Debug, Clone, Deserialize)]
pub struct AmneziaConfig { pub struct AmneziaConfig {
pub container_names: Vec<String>, pub container_names: Vec<String>,
@@ -139,6 +175,33 @@ impl Config {
} }
parse_tc_rate_bps(&self.traffic.peer_limit)?; parse_tc_rate_bps(&self.traffic.peer_limit)?;
parse_tc_rate_bps(&self.traffic.root_rate)?; 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 { if self.swap.swappiness > 100 {
anyhow::bail!("swap.swappiness must be <= 100"); anyhow::bail!("swap.swappiness must be <= 100");
} }
@@ -224,11 +287,19 @@ impl Config {
} }
fn check_host_tools(&self) -> anyhow::Result<()> { 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() { if !crate::netns::nsenter_available() {
anyhow::bail!("required host tool not found in PATH: nsenter"); anyhow::bail!("required host tool not found in PATH: nsenter");
} }
required.push("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 { if self.anti_torrent.enabled {
required.push("nft"); required.push("nft");
} }
@@ -408,6 +479,46 @@ pub fn ip_in_cidr(ip: Ipv4Addr, cidr: &str) -> anyhow::Result<bool> {
mod tests { mod tests {
use super::*; 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] #[test]
fn parse_cidr_invalid_prefix() { fn parse_cidr_invalid_prefix() {
let err = parse_cidr_v4("10.8.1.0/33").err(); let err = parse_cidr_v4("10.8.1.0/33").err();
@@ -438,86 +549,64 @@ mod tests {
#[test] #[test]
fn swap_rejects_dangerous_path() { fn swap_rejects_dangerous_path() {
let cfg = Config { let mut cfg = base_config();
amnezia: AmneziaConfig { cfg.swap.enabled = true;
container_names: vec!["x".into()], cfg.swap.path = "/etc/fstab".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,
},
};
assert!(cfg.validate_config().is_err()); assert!(cfg.validate_config().is_err());
} }
#[test] #[test]
fn host_firewall_rejects_empty_protocols() { fn host_firewall_rejects_empty_protocols() {
let cfg = Config { let mut cfg = base_config();
amnezia: AmneziaConfig { cfg.host_firewall.amnezia_protocols = vec![];
container_names: vec!["x".into()], assert!(cfg.validate_config().is_err());
interface_candidates: vec!["awg0".into()], }
interface_patterns: vec!["awg*".into()],
peer_ipv4_cidr: "10.8.1.0/24".into(), #[test]
}, fn sysctl_tuning_default_is_valid() {
traffic: TrafficConfig { let cfg = base_config();
enabled: true, assert!(cfg.validate_config().is_ok());
peer_limit: "70mbit".into(), }
root_rate: "1000mbit".into(),
reconcile_interval_secs: 15, #[test]
container_ready_timeout_secs: 30, fn sysctl_tuning_rejects_invalid_tcp_bbr() {
class_start_id: 10, let mut cfg = base_config();
ifb_name: "ifb0".into(), cfg.sysctl_tuning.tcp_bbr = "bad".into();
}, assert!(cfg.validate_config().is_err());
host_firewall: HostFirewallConfig { }
amnezia_protocols: vec![],
..HostFirewallConfig::default() #[test]
}, fn sysctl_tuning_rejects_invalid_default_qdisc() {
anti_torrent: AntiTorrentConfig { let mut cfg = base_config();
enabled: false, cfg.sysctl_tuning.default_qdisc = "cake".into();
max_connections_per_peer: 100, assert!(cfg.validate_config().is_err());
peer_ipv4_cidr: "10.8.1.0/24".into(), }
apply_in_amnezia_netns: false,
}, #[test]
reboot: RebootConfig { fn sysctl_tuning_rejects_rmem_max_below_1mib() {
enabled: false, let mut cfg = base_config();
window_start_utc: "01:00".into(), cfg.sysctl_tuning.rmem_max = 1024;
window_end_utc: "02:00".into(), assert!(cfg.validate_config().is_err());
}, }
swap: SwapConfig {
enabled: false, #[test]
path: "/swapfile".into(), fn sysctl_tuning_rejects_rmem_max_above_512mib() {
size_mb: 2048, let mut cfg = base_config();
priority: 1, cfg.sysctl_tuning.rmem_max = 513 * 1024 * 1024;
swappiness: 1, 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()); assert!(cfg.validate_config().is_err());
} }
+14 -1
View File
@@ -6,7 +6,9 @@ use std::{
time::{Duration, Instant}, 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}; use tracing::{error, info};
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@@ -26,6 +28,17 @@ pub fn run(cfg: Config) -> anyhow::Result<()> {
info!("starting daemon"); info!("starting daemon");
let state = Arc::new(Mutex::new(LastActionState::default())); 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) { if let Err(e) = swap::ensure_swap(&cfg) {
set_err(&state, format!("startup swap reconcile: {e}")); set_err(&state, format!("startup swap reconcile: {e}"));
append_event(&format!("startup_swap error=\"{e}\"")); append_event(&format!("startup_swap error=\"{e}\""));
+13
View File
@@ -9,6 +9,7 @@ mod lock;
mod netns; mod netns;
mod status; mod status;
mod swap; mod swap;
mod sysctl_tuning;
mod traffic; mod traffic;
mod wg; mod wg;
@@ -49,6 +50,18 @@ fn main() -> anyhow::Result<()> {
traffic::reconcile_traffic(&cfg)?; traffic::reconcile_traffic(&cfg)?;
println!("traffic limits reconciled"); 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 => { Command::ApplyFirewall => {
let cfg = Config::load(&config_path)?; let cfg = Config::load(&config_path)?;
cfg.validate_all()?; cfg.validate_all()?;
+16 -1
View File
@@ -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::{ use std::{
collections::BTreeMap, collections::BTreeMap,
@@ -20,6 +22,8 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> {
Err(_) => println!(" service: unknown"), Err(_) => println!(" service: unknown"),
} }
print_network_tuning_status(cfg);
let target = match docker::discover_target(cfg) { let target = match docker::discover_target(cfg) {
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
@@ -270,6 +274,17 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> {
Ok(()) 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<()> { fn print_degraded_sections(cfg: &Config) -> anyhow::Result<()> {
println!("Peers:"); println!("Peers:");
println!(" unavailable (container/netns not discovered)"); println!(" unavailable (container/netns not discovered)");
+425
View File
@@ -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<bool>,
pub bbr_active: Option<bool>,
pub current: Vec<SysctlValue>,
pub desired: Vec<SysctlValue>,
pub drift_reason: Option<String>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SysctlValue {
pub key: String,
pub desired: Option<String>,
pub current: Option<String>,
pub managed: bool,
}
#[derive(Debug, Clone)]
struct RenderPlan {
desired: BTreeMap<String, String>,
persisted: String,
bbr_available: Option<bool>,
bbr_active: Option<bool>,
warnings: Vec<String>,
}
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(|_| "<unreadable>".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<SysctlTuningStatus> {
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::<SysctlValue>::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<RenderPlan> {
let mut desired = BTreeMap::<String, String>::new();
let mut warnings = Vec::<String>::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<bool> {
let v = read_sysctl("net.ipv4.tcp_available_congestion_control")?;
Ok(v.split_whitespace().any(|p| p == "bbr"))
}
fn render_persisted(desired: &BTreeMap<String, String>) -> 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<String> {
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::<Vec<_>>().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());
}
}
}