Files
AWG2_harness/traffic_core/src/config.rs
T

768 lines
23 KiB
Rust

use std::{fs, net::Ipv4Addr, path::Path, process::Command};
use serde::Deserialize;
fn default_host_firewall_enabled() -> bool {
true
}
fn default_ssh_port() -> u16 {
2323
}
fn default_amnezia_port() -> u16 {
443
}
fn default_amnezia_protocols() -> Vec<String> {
vec!["udp".to_string()]
}
fn default_allow_icmp() -> bool {
true
}
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 = "default_sysctl_tuning_for_missing_block")]
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<String>,
pub interface_candidates: Vec<String>,
pub interface_patterns: Vec<String>,
pub peer_ipv4_cidr: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TrafficConfig {
pub enabled: bool,
pub peer_limit: String,
pub root_rate: String,
pub reconcile_interval_secs: u64,
pub container_ready_timeout_secs: u64,
pub class_start_id: u16,
pub ifb_name: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AntiTorrentConfig {
pub enabled: bool,
pub max_connections_per_peer: u32,
pub peer_ipv4_cidr: String,
pub apply_in_amnezia_netns: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HostFirewallConfig {
#[serde(default = "default_host_firewall_enabled")]
pub enabled: bool,
#[serde(default = "default_ssh_port")]
pub ssh_port: u16,
#[serde(default = "default_amnezia_port")]
pub amnezia_port: u16,
#[serde(default = "default_amnezia_protocols")]
pub amnezia_protocols: Vec<String>,
#[serde(default = "default_allow_icmp")]
pub allow_icmp: bool,
#[serde(default = "default_input_policy")]
pub default_input_policy: String,
}
impl Default for HostFirewallConfig {
fn default() -> Self {
Self {
enabled: default_host_firewall_enabled(),
ssh_port: default_ssh_port(),
amnezia_port: default_amnezia_port(),
amnezia_protocols: default_amnezia_protocols(),
allow_icmp: default_allow_icmp(),
default_input_policy: default_input_policy(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct RebootConfig {
pub enabled: bool,
pub window_start_utc: String,
pub window_end_utc: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SwapConfig {
pub enabled: bool,
pub path: String,
pub size_mb: u64,
pub priority: i32,
pub swappiness: u8,
}
impl Config {
pub fn load(path: &Path) -> anyhow::Result<Self> {
let raw = fs::read_to_string(path)?;
let cfg: Self = toml::from_str(&raw)?;
Ok(cfg)
}
pub fn validate_config(&self) -> anyhow::Result<()> {
if self.amnezia.container_names.is_empty() {
anyhow::bail!("amnezia.container_names must not be empty");
}
if self.amnezia.interface_candidates.is_empty() {
anyhow::bail!("amnezia.interface_candidates must not be empty");
}
if self.amnezia.interface_patterns.is_empty() {
anyhow::bail!("amnezia.interface_patterns must not be empty");
}
if self.traffic.class_start_id < 10 {
anyhow::bail!("traffic.class_start_id must be >= 10");
}
if self.traffic.ifb_name.trim().is_empty() {
anyhow::bail!("traffic.ifb_name must not be empty");
}
if self.traffic.peer_limit.trim().is_empty() {
anyhow::bail!("traffic.peer_limit must not be empty");
}
if self.traffic.root_rate.trim().is_empty() {
anyhow::bail!("traffic.root_rate must not be empty");
}
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");
}
if self.traffic.container_ready_timeout_secs == 0 {
anyhow::bail!("traffic.container_ready_timeout_secs must be > 0");
}
if self.traffic.reconcile_interval_secs == 0 {
anyhow::bail!("traffic.reconcile_interval_secs must be > 0");
}
if self.swap.enabled {
if self.swap.size_mb < 64 {
anyhow::bail!("swap.size_mb must be >= 64 when swap.enabled=true");
}
if self.swap.size_mb > 65536 {
anyhow::bail!("swap.size_mb must be <= 65536 when swap.enabled=true");
}
if !self.swap.path.starts_with('/') {
anyhow::bail!("swap.path must be absolute");
}
let dangerous = ["/", "/etc", "/etc/fstab", "/dev", "/proc", "/sys", "/run"];
if dangerous.iter().any(|p| *p == self.swap.path) {
anyhow::bail!("swap.path is dangerous and forbidden: {}", self.swap.path);
}
if self.swap.priority < -1 || self.swap.priority > 32767 {
anyhow::bail!("swap.priority must be between -1 and 32767");
}
}
if self.anti_torrent.enabled && self.anti_torrent.max_connections_per_peer == 0 {
anyhow::bail!("anti_torrent.max_connections_per_peer must be > 0");
}
if self.host_firewall.ssh_port == 0 {
anyhow::bail!("host_firewall.ssh_port must be in 1..=65535");
}
if self.host_firewall.amnezia_port == 0 {
anyhow::bail!("host_firewall.amnezia_port must be in 1..=65535");
}
if self.host_firewall.amnezia_protocols.is_empty() {
anyhow::bail!(
"host_firewall.amnezia_protocols must not be empty; allowed values: tcp, udp"
);
}
let mut normalized = std::collections::BTreeSet::new();
for p in &self.host_firewall.amnezia_protocols {
let pp = p.trim().to_ascii_lowercase();
if pp != "tcp" && pp != "udp" {
anyhow::bail!(
"host_firewall.amnezia_protocols contains invalid value: {p}; allowed: tcp, udp"
);
}
if !normalized.insert(pp) {
anyhow::bail!("host_firewall.amnezia_protocols contains duplicate protocol: {p}");
}
}
let policy = self
.host_firewall
.default_input_policy
.trim()
.to_ascii_lowercase();
if policy != "drop" && policy != "accept" {
anyhow::bail!("host_firewall.default_input_policy must be one of: drop, accept");
}
if self.reboot.enabled && self.reboot.window_start_utc == self.reboot.window_end_utc {
anyhow::bail!("reboot window start and end must differ");
}
if self.reboot.enabled {
parse_hhmm(&self.reboot.window_start_utc)?;
parse_hhmm(&self.reboot.window_end_utc)?;
}
parse_cidr_v4(&self.amnezia.peer_ipv4_cidr)?;
parse_cidr_v4(&self.anti_torrent.peer_ipv4_cidr)?;
Ok(())
}
pub fn validate_runtime(&self) -> anyhow::Result<()> {
self.check_supported_host()?;
self.check_host_tools()
}
pub fn validate_sysctl_tuning_runtime(&self) -> anyhow::Result<()> {
self.validate_config()?;
self.check_supported_host()?;
if !self.sysctl_tuning.enabled {
return Ok(());
}
if !tool_exists("sysctl") {
anyhow::bail!("required host tool not found in PATH: sysctl");
}
let tcp_bbr_mode = self.sysctl_tuning.tcp_bbr.trim().to_ascii_lowercase();
if tcp_bbr_mode == "require" && !tool_exists("modprobe") {
anyhow::bail!("required host tool not found in PATH: modprobe");
}
Ok(())
}
pub fn validate_all(&self) -> anyhow::Result<()> {
self.validate_config()?;
self.validate_runtime()?;
Ok(())
}
fn check_host_tools(&self) -> anyhow::Result<()> {
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");
let tcp_bbr_mode = self.sysctl_tuning.tcp_bbr.trim().to_ascii_lowercase();
if self.sysctl_tuning.enabled {
required.push("sysctl");
}
if requires_modprobe(
self.traffic.enabled,
self.sysctl_tuning.enabled,
&tcp_bbr_mode,
) {
required.push("modprobe");
}
if self.anti_torrent.enabled {
required.push("nft");
}
if self.host_firewall.enabled {
required.push("nft");
}
if self.reboot.enabled {
required.push("systemctl");
}
if self.swap.enabled {
required.extend([
"fallocate",
"chmod",
"mkswap",
"swapon",
"swapoff",
"sysctl",
"dd",
]);
}
required.sort_unstable();
required.dedup();
for tool in required {
if !tool_exists(tool) {
anyhow::bail!("required host tool not found in PATH: {tool}");
}
}
Ok(())
}
fn check_supported_host(&self) -> anyhow::Result<()> {
let os_release = std::fs::read_to_string("/etc/os-release").map_err(|_| {
anyhow::anyhow!(
"unsupported host: missing /etc/os-release; only Debian 13 amd64 is supported"
)
})?;
let mut id = String::new();
let mut version_id = String::new();
for line in os_release.lines() {
if let Some((k, v)) = line.split_once('=') {
let val = v.trim_matches('"');
match k {
"ID" => id = val.to_string(),
"VERSION_ID" => version_id = val.to_string(),
_ => {}
}
}
}
if id != "debian" || version_id != "13" {
anyhow::bail!(
"unsupported host: only Debian 13 amd64 is supported (got ID={id}, VERSION_ID={version_id})"
);
}
let arch = std::process::Command::new("dpkg")
.args(["--print-architecture"])
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
.unwrap_or_else(|| std::env::consts::ARCH.to_string());
if arch != "amd64" && arch != "x86_64" {
anyhow::bail!("unsupported host arch: {arch}; only Debian 13 amd64 is supported");
}
Ok(())
}
}
fn requires_modprobe(traffic_enabled: bool, sysctl_enabled: bool, tcp_bbr_mode: &str) -> bool {
traffic_enabled || (sysctl_enabled && tcp_bbr_mode == "require")
}
fn parse_hhmm(v: &str) -> anyhow::Result<()> {
let mut s = v.split(':');
let h = s
.next()
.ok_or_else(|| anyhow::anyhow!("invalid time: {v}"))?
.parse::<u8>()?;
let m = s
.next()
.ok_or_else(|| anyhow::anyhow!("invalid time: {v}"))?
.parse::<u8>()?;
if s.next().is_some() {
anyhow::bail!("invalid time: {v}");
}
if h > 23 || m > 59 {
anyhow::bail!("invalid time: {v}");
}
Ok(())
}
fn parse_tc_rate_bps(v: &str) -> anyhow::Result<u64> {
let raw = v.trim().to_ascii_lowercase().replace(' ', "");
if raw.is_empty() {
anyhow::bail!("invalid tc rate: empty");
}
let (num, mul) = if let Some(n) = raw.strip_suffix("gbit") {
(n, 1_000_000_000u64)
} else if let Some(n) = raw.strip_suffix("mbit") {
(n, 1_000_000u64)
} else if let Some(n) = raw.strip_suffix("kbit") {
(n, 1_000u64)
} else if let Some(n) = raw.strip_suffix("bit") {
(n, 1u64)
} else {
anyhow::bail!("invalid tc rate unit: {v}");
};
let base = num
.parse::<u64>()
.map_err(|_| anyhow::anyhow!("invalid tc rate number: {v}"))?;
base.checked_mul(mul)
.ok_or_else(|| anyhow::anyhow!("tc rate overflow: {v}"))
}
fn tool_exists(tool: &str) -> bool {
let probes: &[&[&str]] = match tool {
"tc" => &[&["-V"]],
"ip" => &[&["-V"], &["-Version"]],
"wg" => &[&["--version"]],
"docker" => &[&["--version"]],
"modprobe" => &[&["--version"]],
"nsenter" => &[&["--help"]],
"nft" => &[&["--version"]],
"systemctl" => &[&["--version"], &["--help"]],
"fallocate" => &[&["--help"]],
"chmod" => &[&["--help"]],
"mkswap" => &[&["--help"]],
"swapon" => &[&["--help"]],
"swapoff" => &[&["--help"]],
"sysctl" => &[&["--version"], &["--help"]],
"dd" => &[&["--help"]],
_ => &[&["--version"], &["--help"], &["-V"]],
};
for args in probes {
if Command::new(tool).args(*args).output().is_ok() {
return true;
}
}
false
}
pub fn parse_cidr_v4(cidr: &str) -> anyhow::Result<(Ipv4Addr, u8)> {
let mut parts = cidr.split('/');
let ip = parts
.next()
.ok_or_else(|| anyhow::anyhow!("invalid cidr: {cidr}"))?
.parse::<Ipv4Addr>()?;
let prefix = parts
.next()
.ok_or_else(|| anyhow::anyhow!("invalid cidr: {cidr}"))?
.parse::<u8>()?;
if prefix > 32 {
anyhow::bail!("invalid prefix in cidr: {cidr}");
}
Ok((ip, prefix))
}
pub fn ip_in_cidr(ip: Ipv4Addr, cidr: &str) -> anyhow::Result<bool> {
let (base, prefix) = parse_cidr_v4(cidr)?;
let mask = if prefix == 0 {
0u32
} else {
u32::MAX << (32 - prefix)
};
let ip_u = u32::from(ip);
let base_u = u32::from(base);
Ok((ip_u & mask) == (base_u & mask))
}
#[cfg(test)]
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();
assert!(err.is_some());
}
#[test]
fn ip_membership() {
let ok = ip_in_cidr("10.8.1.10".parse().expect("ip"), "10.8.1.0/24").expect("cidr");
assert!(ok);
let no = ip_in_cidr("10.9.1.10".parse().expect("ip"), "10.8.1.0/24").expect("cidr");
assert!(!no);
}
#[test]
fn parse_hhmm_works() {
assert!(parse_hhmm("01:00").is_ok());
assert!(parse_hhmm("23:59").is_ok());
assert!(parse_hhmm("24:00").is_err());
}
#[test]
fn parse_tc_rate_works() {
assert_eq!(parse_tc_rate_bps("1gbit").expect("bps"), 1_000_000_000);
assert_eq!(parse_tc_rate_bps("1000mbit").expect("bps"), 1_000_000_000);
assert!(parse_tc_rate_bps("foo").is_err());
}
#[test]
fn swap_rejects_dangerous_path() {
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 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());
}
#[test]
fn config_requires_host_firewall_section() {
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"
[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 parsed = toml::from_str::<Config>(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::<Config>(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");
}
#[test]
fn validate_sysctl_tuning_runtime_skips_modprobe_for_auto_when_disabled() {
let mut cfg = base_config();
cfg.sysctl_tuning.enabled = false;
cfg.sysctl_tuning.tcp_bbr = "auto".into();
if let Err(e) = cfg.validate_sysctl_tuning_runtime() {
let msg = e.to_string();
assert!(
!msg.contains("modprobe"),
"validation must not require modprobe when sysctl_tuning is disabled, got error: {msg}"
);
}
}
#[test]
fn requires_modprobe_when_traffic_enabled() {
assert!(requires_modprobe(true, true, "auto"));
assert!(requires_modprobe(true, false, "off"));
}
#[test]
fn requires_modprobe_for_sysctl_require_mode() {
assert!(requires_modprobe(false, true, "require"));
assert!(!requires_modprobe(false, true, "auto"));
assert!(!requires_modprobe(false, true, "off"));
}
}