Рефакторинг под прод: nsenter-first транспорт, drift-aware reconcile, hardening daemon/lock/reboot/swap, расширенный status и build-скрипт
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
use std::{fs, net::Ipv4Addr, path::Path};
|
||||
use std::{fs, net::Ipv4Addr, path::Path, process::Command};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -72,10 +72,38 @@ impl Config {
|
||||
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.reboot.enabled && self.reboot.window_start_utc == self.reboot.window_end_utc {
|
||||
anyhow::bail!("reboot window start and end must differ");
|
||||
}
|
||||
parse_cidr_v4(&self.amnezia.peer_ipv4_cidr)?;
|
||||
parse_cidr_v4(&self.anti_torrent.peer_ipv4_cidr)?;
|
||||
self.check_host_tools()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_host_tools(&self) -> anyhow::Result<()> {
|
||||
let mut required = vec!["tc", "ip", "wg", "nsenter", "docker", "modprobe"];
|
||||
if self.anti_torrent.enabled {
|
||||
required.push("nft");
|
||||
}
|
||||
|
||||
for tool in required {
|
||||
if !tool_exists(tool) {
|
||||
anyhow::bail!("required host tool not found in PATH: {tool}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_exists(tool: &str) -> bool {
|
||||
Command::new(tool).arg("--version").output().is_ok()
|
||||
}
|
||||
|
||||
pub fn parse_cidr_v4(cidr: &str) -> anyhow::Result<(Ipv4Addr, u8)> {
|
||||
@@ -106,3 +134,22 @@ pub fn ip_in_cidr(ip: Ipv4Addr, cidr: &str) -> anyhow::Result<bool> {
|
||||
Ok((ip_u & mask) == (base_u & mask))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+113
-12
@@ -1,31 +1,80 @@
|
||||
use std::{thread, time::Duration};
|
||||
use std::{
|
||||
fs,
|
||||
sync::{Arc, Mutex},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::{config::Config, firewall, reboot, traffic};
|
||||
use crate::{
|
||||
config::Config,
|
||||
docker,
|
||||
firewall,
|
||||
iface,
|
||||
netns::build_transport,
|
||||
reboot,
|
||||
traffic,
|
||||
};
|
||||
use tracing::{error, info};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct LastActionState {
|
||||
last_ok: Option<String>,
|
||||
last_error: Option<String>,
|
||||
}
|
||||
|
||||
const LAST_STATE_FILE: &str = "/run/awg2-harness/last_action.state";
|
||||
|
||||
pub fn run(cfg: Config) -> anyhow::Result<()> {
|
||||
println!("starting daemon");
|
||||
info!("starting daemon");
|
||||
let state = Arc::new(Mutex::new(LastActionState::default()));
|
||||
|
||||
let _ = traffic::reconcile_traffic(&cfg);
|
||||
let _ = firewall::apply_firewall(&cfg);
|
||||
if let Err(e) = traffic::reconcile_traffic(&cfg) {
|
||||
set_err(&state, format!("startup traffic reconcile: {e}"));
|
||||
} else {
|
||||
set_ok(&state, "startup traffic reconcile ok");
|
||||
}
|
||||
if let Err(e) = firewall::apply_firewall(&cfg) {
|
||||
set_err(&state, format!("startup firewall reconcile: {e}"));
|
||||
} else {
|
||||
set_ok(&state, "startup firewall reconcile ok");
|
||||
}
|
||||
let _ = reboot::maybe_schedule_reboot(&cfg);
|
||||
let reboot_cfg = cfg.clone();
|
||||
thread::spawn(move || {
|
||||
if let Err(e) = reboot::reboot_scheduler_loop(reboot_cfg) {
|
||||
error!(error = %e, "reboot scheduler loop error");
|
||||
}
|
||||
});
|
||||
|
||||
let events_cfg = cfg.clone();
|
||||
let events_state = state.clone();
|
||||
thread::spawn(move || {
|
||||
let _ = docker_events_loop(events_cfg);
|
||||
loop {
|
||||
if let Err(e) = docker_events_loop(&events_cfg, &events_state) {
|
||||
set_err(&events_state, format!("docker events loop crashed: {e}"));
|
||||
thread::sleep(Duration::from_secs(10));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
loop {
|
||||
if let Err(e) = traffic::reconcile_traffic(&cfg) {
|
||||
eprintln!("traffic reconcile error: {e}");
|
||||
error!(error = %e, "traffic reconcile error");
|
||||
set_err(&state, format!("periodic traffic reconcile: {e}"));
|
||||
} else {
|
||||
set_ok(&state, "periodic traffic reconcile ok");
|
||||
}
|
||||
if let Err(e) = firewall::apply_firewall(&cfg) {
|
||||
eprintln!("firewall reconcile error: {e}");
|
||||
error!(error = %e, "firewall reconcile error");
|
||||
set_err(&state, format!("periodic firewall reconcile: {e}"));
|
||||
} else {
|
||||
set_ok(&state, "periodic firewall reconcile ok");
|
||||
}
|
||||
thread::sleep(Duration::from_secs(cfg.traffic.reconcile_interval_secs.max(5)));
|
||||
}
|
||||
}
|
||||
|
||||
fn docker_events_loop(cfg: Config) -> anyhow::Result<()> {
|
||||
fn docker_events_loop(cfg: &Config, state: &Arc<Mutex<LastActionState>>) -> anyhow::Result<()> {
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
@@ -42,11 +91,63 @@ fn docker_events_loop(cfg: Config) -> anyhow::Result<()> {
|
||||
|
||||
for line in reader.lines() {
|
||||
let name = line?.trim().to_string();
|
||||
if cfg.amnezia.container_names.iter().any(|n| n == &name) {
|
||||
let _ = traffic::reconcile_traffic(&cfg);
|
||||
let _ = firewall::apply_firewall(&cfg);
|
||||
if cfg.amnezia.container_names.iter().any(|n| n == &name)
|
||||
&& wait_for_interface(cfg, &name, cfg.traffic.container_ready_timeout_secs)
|
||||
{
|
||||
if let Err(e) = traffic::reconcile_traffic(cfg) {
|
||||
set_err(state, format!("event traffic reconcile: {e}"));
|
||||
} else {
|
||||
set_ok(state, "event traffic reconcile ok");
|
||||
}
|
||||
|
||||
if let Err(e) = firewall::apply_firewall(cfg) {
|
||||
set_err(state, format!("event firewall reconcile: {e}"));
|
||||
} else {
|
||||
set_ok(state, "event firewall reconcile ok");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wait_for_interface(cfg: &Config, container_name: &str, timeout_secs: u64) -> bool {
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < Duration::from_secs(timeout_secs.max(1)) {
|
||||
if let Ok(target) = docker::discover_target(cfg) {
|
||||
if target.container_name == container_name {
|
||||
let transport = build_transport(target.container_pid, &target.container_name);
|
||||
if iface::detect_interface_with_transport(cfg, transport.as_ref()).is_ok() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn set_ok(state: &Arc<Mutex<LastActionState>>, msg: &str) {
|
||||
if let Ok(mut s) = state.lock() {
|
||||
s.last_ok = Some(msg.to_string());
|
||||
persist_state(&s);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_err(state: &Arc<Mutex<LastActionState>>, msg: String) {
|
||||
if let Ok(mut s) = state.lock() {
|
||||
s.last_error = Some(msg);
|
||||
persist_state(&s);
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_state(state: &LastActionState) {
|
||||
let _ = fs::create_dir_all("/run/awg2-harness");
|
||||
let content = format!(
|
||||
"last_ok={}\nlast_error={}\n",
|
||||
state.last_ok.clone().unwrap_or_default(),
|
||||
state.last_error.clone().unwrap_or_default()
|
||||
);
|
||||
let _ = fs::write(LAST_STATE_FILE, content);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ pub fn discover_target(cfg: &Config) -> anyhow::Result<RuntimeTarget> {
|
||||
])?;
|
||||
let container_pid = pid_str.trim().parse::<u32>()?;
|
||||
let netns_path = format!("/proc/{container_pid}/ns/net");
|
||||
let interface_name = crate::iface::detect_interface(cfg, &container_name)?;
|
||||
let interface_name = crate::iface::detect_interface(cfg, container_pid, &container_name)?;
|
||||
|
||||
Ok(RuntimeTarget {
|
||||
container_name,
|
||||
|
||||
@@ -1,29 +1,62 @@
|
||||
use crate::{config::Config, docker};
|
||||
use crate::{
|
||||
config::Config,
|
||||
docker,
|
||||
netns::{build_transport, NsTransport},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FirewallActual {
|
||||
table_exists: bool,
|
||||
chain_exists: bool,
|
||||
rule_matches: bool,
|
||||
}
|
||||
|
||||
pub fn apply_firewall(cfg: &Config) -> anyhow::Result<()> {
|
||||
let target = docker::discover_target(cfg)?;
|
||||
let transport = build_transport(target.container_pid, &target.container_name);
|
||||
|
||||
if !cfg.anti_torrent.enabled {
|
||||
let _ = docker::run_capture(&[
|
||||
"docker",
|
||||
"exec",
|
||||
&target.container_name,
|
||||
"sh",
|
||||
"-c",
|
||||
"nft delete table inet awg2_harness 2>/dev/null || true",
|
||||
])?;
|
||||
let _ = transport.run(&["sh", "-c", "nft delete table inet awg2_harness 2>/dev/null || true"])?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let actual = inspect_firewall(transport.as_ref(), cfg)?;
|
||||
if actual.table_exists && actual.chain_exists && actual.rule_matches {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let rule = format!(
|
||||
"nft 'add table inet awg2_harness' 2>/dev/null || true; \
|
||||
nft 'add chain inet awg2_harness forward_antitorrent {{ type filter hook forward priority filter; policy accept; }}' 2>/dev/null || true; \
|
||||
nft 'flush chain inet awg2_harness forward_antitorrent'; \
|
||||
nft 'add rule inet awg2_harness forward_antitorrent ip saddr {cidr} ct state new meter awg2_peer_connlimit_v4 {{ ip saddr ct count over {max} }} counter name antitorrent_dropped_v4 drop'",
|
||||
nft 'add rule inet awg2_harness forward_antitorrent ip saddr {cidr} ct state new meter awg2_peer_connlimit_v4 {{ ip saddr ct count over {max} }} counter name antitorrent_dropped_v4 drop' 2>/dev/null || true",
|
||||
cidr = cfg.anti_torrent.peer_ipv4_cidr,
|
||||
max = cfg.anti_torrent.max_connections_per_peer
|
||||
);
|
||||
let _ = docker::run_capture(&["docker", "exec", &target.container_name, "sh", "-c", &rule])?;
|
||||
apply_rule(transport.as_ref(), &rule)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inspect_firewall(transport: &dyn NsTransport, cfg: &Config) -> anyhow::Result<FirewallActual> {
|
||||
let table = transport
|
||||
.run(&["sh", "-c", "nft list table inet awg2_harness 2>/dev/null"])
|
||||
.unwrap_or_default();
|
||||
|
||||
let table_exists = !table.trim().is_empty();
|
||||
let chain_exists = table.contains("chain forward_antitorrent");
|
||||
|
||||
let needle_cidr = format!("ip saddr {}", cfg.anti_torrent.peer_ipv4_cidr);
|
||||
let needle_max = format!("ct count over {}", cfg.anti_torrent.max_connections_per_peer);
|
||||
let rule_matches = table.contains(&needle_cidr) && table.contains(&needle_max);
|
||||
|
||||
Ok(FirewallActual {
|
||||
table_exists,
|
||||
chain_exists,
|
||||
rule_matches,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_rule(transport: &dyn NsTransport, rule: &str) -> anyhow::Result<()> {
|
||||
let _ = transport.run(&["sh", "-c", rule])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+25
-14
@@ -1,23 +1,34 @@
|
||||
use crate::{config::Config, docker::run_capture};
|
||||
use crate::{
|
||||
config::Config,
|
||||
netns::{DockerExecTransport, NsTransport, NsenterTransport, nsenter_available},
|
||||
};
|
||||
|
||||
pub fn detect_interface(cfg: &Config, container: &str) -> anyhow::Result<String> {
|
||||
pub fn detect_interface(cfg: &Config, pid: u32, container: &str) -> anyhow::Result<String> {
|
||||
let boxed: Box<dyn NsTransport> = if nsenter_available() {
|
||||
Box::new(NsenterTransport { pid })
|
||||
} else {
|
||||
Box::new(DockerExecTransport {
|
||||
container: container.to_string(),
|
||||
})
|
||||
};
|
||||
|
||||
detect_interface_with_transport(cfg, boxed.as_ref())
|
||||
}
|
||||
|
||||
pub fn detect_interface_with_transport(
|
||||
cfg: &Config,
|
||||
transport: &dyn NsTransport,
|
||||
) -> anyhow::Result<String> {
|
||||
for ifname in &cfg.amnezia.interface_candidates {
|
||||
let ok = std::process::Command::new("docker")
|
||||
.args(["exec", container, "ip", "link", "show", ifname])
|
||||
.output()?;
|
||||
if ok.status.success() {
|
||||
if transport
|
||||
.run(&["ip", "link", "show", ifname])
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(ifname.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let links = run_capture(&[
|
||||
"docker",
|
||||
"exec",
|
||||
container,
|
||||
"sh",
|
||||
"-c",
|
||||
"ip -o link show | awk -F': ' '{print $2}'",
|
||||
])?;
|
||||
let links = transport.run(&["sh", "-c", "ip -o link show | awk -F': ' '{print $2}'"])?;
|
||||
|
||||
for raw in links.lines() {
|
||||
let name = raw.trim();
|
||||
|
||||
@@ -11,7 +11,12 @@ impl FileLock {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
if p.exists() {
|
||||
anyhow::bail!("lock already exists: {path}");
|
||||
let stale = is_stale_lock(p)?;
|
||||
if stale {
|
||||
let _ = fs::remove_file(p);
|
||||
} else {
|
||||
anyhow::bail!("lock already exists: {path}");
|
||||
}
|
||||
}
|
||||
fs::write(p, std::process::id().to_string())?;
|
||||
Ok(Self {
|
||||
@@ -26,3 +31,14 @@ impl Drop for FileLock {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_stale_lock(path: &Path) -> anyhow::Result<bool> {
|
||||
let pid_raw = fs::read_to_string(path)?;
|
||||
let pid = match pid_raw.trim().parse::<u32>() {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Ok(true),
|
||||
};
|
||||
|
||||
let proc_path = format!("/proc/{pid}");
|
||||
Ok(!Path::new(&proc_path).exists())
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,16 @@ mod wg;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use crate::cli::{Cli, Command};
|
||||
use crate::config::Config;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
let config_path = cli
|
||||
.config
|
||||
|
||||
+54
-12
@@ -1,16 +1,58 @@
|
||||
use std::process::Command;
|
||||
|
||||
pub fn nsenter_run_capture(pid: u32, args: &[&str]) -> anyhow::Result<String> {
|
||||
let mut cmd = Command::new("nsenter");
|
||||
cmd.args(["-t", &pid.to_string(), "-n"]);
|
||||
cmd.args(args);
|
||||
let out = cmd.output()?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"nsenter failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
pub trait NsTransport: Send + Sync {
|
||||
fn run(&self, args: &[&str]) -> anyhow::Result<String>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NsenterTransport {
|
||||
pub pid: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DockerExecTransport {
|
||||
pub container: String,
|
||||
}
|
||||
|
||||
impl NsTransport for NsenterTransport {
|
||||
fn run(&self, args: &[&str]) -> anyhow::Result<String> {
|
||||
let mut cmd = Command::new("nsenter");
|
||||
cmd.args(["-t", &self.pid.to_string(), "-n", "--"]);
|
||||
cmd.args(args);
|
||||
let out = cmd.output()?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!("nsenter failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl NsTransport for DockerExecTransport {
|
||||
fn run(&self, args: &[&str]) -> anyhow::Result<String> {
|
||||
let mut cmd = Command::new("docker");
|
||||
cmd.arg("exec").arg(&self.container).args(args);
|
||||
let out = cmd.output()?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"docker exec transport failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn nsenter_available() -> bool {
|
||||
Command::new("nsenter").arg("--version").output().is_ok()
|
||||
}
|
||||
|
||||
pub fn build_transport(pid: u32, container: &str) -> Box<dyn NsTransport> {
|
||||
if nsenter_available() {
|
||||
Box::new(NsenterTransport { pid })
|
||||
} else {
|
||||
Box::new(DockerExecTransport {
|
||||
container: container.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+62
-19
@@ -1,5 +1,6 @@
|
||||
use chrono::{Duration, Timelike, Utc};
|
||||
use chrono::{Datelike, Duration, NaiveDateTime, NaiveTime, Utc};
|
||||
use rand::Rng;
|
||||
use std::{process::Command, thread, time::Duration as StdDuration};
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
@@ -8,37 +9,79 @@ pub fn maybe_schedule_reboot(cfg: &Config) -> anyhow::Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let start = parse_hhmm(&cfg.reboot.window_start_utc)?;
|
||||
let end = parse_hhmm(&cfg.reboot.window_end_utc)?;
|
||||
let now = Utc::now();
|
||||
let date = now.date_naive();
|
||||
let planned = next_reboot_time(&cfg.reboot.window_start_utc, &cfg.reboot.window_end_utc)?;
|
||||
println!("reboot planned at UTC {}", planned);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let start_dt = date
|
||||
.and_hms_opt(start.0, start.1, 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid reboot start time"))?;
|
||||
let end_dt = date
|
||||
.and_hms_opt(end.0, end.1, 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid reboot end time"))?;
|
||||
pub fn reboot_scheduler_loop(cfg: Config) -> anyhow::Result<()> {
|
||||
if !cfg.reboot.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let span = if end_dt > start_dt {
|
||||
end_dt - start_dt
|
||||
loop {
|
||||
let planned = next_reboot_time(&cfg.reboot.window_start_utc, &cfg.reboot.window_end_utc)?;
|
||||
println!("reboot scheduler: next reboot at UTC {}", planned);
|
||||
|
||||
loop {
|
||||
let now = Utc::now().naive_utc();
|
||||
if now >= planned {
|
||||
break;
|
||||
}
|
||||
thread::sleep(StdDuration::from_secs(30));
|
||||
}
|
||||
|
||||
let out = Command::new("systemctl")
|
||||
.args(["--no-block", "reboot"])
|
||||
.output()?;
|
||||
if out.status.success() {
|
||||
println!("reboot request accepted by systemctl");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"reboot request failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
thread::sleep(StdDuration::from_secs(60));
|
||||
}
|
||||
}
|
||||
|
||||
fn next_reboot_time(start_hhmm: &str, end_hhmm: &str) -> anyhow::Result<NaiveDateTime> {
|
||||
let start = parse_hhmm(start_hhmm)?;
|
||||
let end = parse_hhmm(end_hhmm)?;
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let today = now.date();
|
||||
let mut base_day = today;
|
||||
|
||||
let now_t = NaiveTime::from_hms_opt(now.hour(), now.minute(), now.second())
|
||||
.ok_or_else(|| anyhow::anyhow!("bad current time"))?;
|
||||
if now_t >= start {
|
||||
base_day = today
|
||||
.succ_opt()
|
||||
.ok_or_else(|| anyhow::anyhow!("date overflow"))?;
|
||||
}
|
||||
|
||||
let span = if end > start {
|
||||
end.signed_duration_since(start)
|
||||
} else {
|
||||
Duration::hours(1)
|
||||
};
|
||||
|
||||
let secs = rand::thread_rng().gen_range(0..span.num_seconds().max(1));
|
||||
let planned = start_dt + Duration::seconds(secs);
|
||||
println!("reboot planned at UTC {}", planned);
|
||||
Ok(())
|
||||
let base_dt = base_day
|
||||
.and_hms_opt(start.hour(), start.minute(), 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid base datetime"))?;
|
||||
Ok(base_dt + Duration::seconds(secs))
|
||||
}
|
||||
|
||||
fn parse_hhmm(v: &str) -> anyhow::Result<(u32, u32)> {
|
||||
fn parse_hhmm(v: &str) -> anyhow::Result<NaiveTime> {
|
||||
let mut s = v.split(':');
|
||||
let h = s.next().ok_or_else(|| anyhow::anyhow!("bad time"))?.parse()?;
|
||||
let m = s.next().ok_or_else(|| anyhow::anyhow!("bad time"))?.parse()?;
|
||||
if h > 23 || m > 59 {
|
||||
anyhow::bail!("bad time")
|
||||
}
|
||||
Ok((h, m))
|
||||
NaiveTime::from_hms_opt(h, m, 0).ok_or_else(|| anyhow::anyhow!("bad time"))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
use crate::{config::Config, docker, wg};
|
||||
use crate::{config::Config, docker, traffic, wg};
|
||||
|
||||
use std::{fs, process::Command};
|
||||
|
||||
pub fn print_status(cfg: &Config) -> anyhow::Result<()> {
|
||||
println!("System:");
|
||||
let svc = Command::new("systemctl")
|
||||
.args(["is-active", "awg2-harness"])
|
||||
.output();
|
||||
match svc {
|
||||
Ok(out) if out.status.success() => println!(" service: active"),
|
||||
Ok(_) => println!(" service: inactive"),
|
||||
Err(_) => println!(" service: unknown"),
|
||||
}
|
||||
|
||||
let target = match docker::discover_target(cfg) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
@@ -16,7 +27,11 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> {
|
||||
println!(" netns: {}", target.netns_path);
|
||||
println!(" interface: {}", target.interface_name);
|
||||
|
||||
let peers = wg::discover_peers(&target.container_name, &cfg.amnezia.peer_ipv4_cidr)?;
|
||||
let peers = wg::discover_peers(
|
||||
target.container_pid,
|
||||
&target.container_name,
|
||||
&cfg.amnezia.peer_ipv4_cidr,
|
||||
)?;
|
||||
println!("Peers:");
|
||||
println!(" total: {}", peers.len());
|
||||
for p in peers {
|
||||
@@ -29,12 +44,70 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> {
|
||||
println!(" ifb_name: {}", cfg.traffic.ifb_name);
|
||||
println!(" class_start_id: {}", cfg.traffic.class_start_id);
|
||||
|
||||
println!("Traffic actual:");
|
||||
match traffic::inspect_drift_summary(cfg) {
|
||||
Ok(None) => println!(" drift: no"),
|
||||
Ok(Some(reason)) => {
|
||||
println!(" drift: yes");
|
||||
println!(" reason: {}", reason);
|
||||
}
|
||||
Err(e) => println!(" drift: unknown ({e})"),
|
||||
}
|
||||
|
||||
println!("Anti-torrent:");
|
||||
println!(" enabled: {}", cfg.anti_torrent.enabled);
|
||||
println!(
|
||||
" max_connections_per_peer: {}",
|
||||
cfg.anti_torrent.max_connections_per_peer
|
||||
);
|
||||
|
||||
if cfg.anti_torrent.enabled {
|
||||
let fw = Command::new("nsenter")
|
||||
.args([
|
||||
"-t",
|
||||
&target.container_pid.to_string(),
|
||||
"-n",
|
||||
"--",
|
||||
"nft",
|
||||
"list",
|
||||
"table",
|
||||
"inet",
|
||||
"awg2_harness",
|
||||
])
|
||||
.output();
|
||||
match fw {
|
||||
Ok(out) if out.status.success() => println!(" actual: table present"),
|
||||
Ok(_) => println!(" actual: table missing or drift"),
|
||||
Err(_) => println!(" actual: unable to inspect"),
|
||||
}
|
||||
}
|
||||
|
||||
let ct_count = Command::new("sysctl")
|
||||
.args(["-n", "net.netfilter.nf_conntrack_count"])
|
||||
.output()
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_else(|| "n/a".to_string());
|
||||
let ct_max = Command::new("sysctl")
|
||||
.args(["-n", "net.netfilter.nf_conntrack_max"])
|
||||
.output()
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_else(|| "n/a".to_string());
|
||||
println!("Conntrack:");
|
||||
println!(" nf_conntrack_count: {}", ct_count);
|
||||
println!(" nf_conntrack_max: {}", ct_max);
|
||||
|
||||
println!("Last actions:");
|
||||
let last = fs::read_to_string("/run/awg2-harness/last_action.state").unwrap_or_default();
|
||||
if last.trim().is_empty() {
|
||||
println!(" no persisted state");
|
||||
} else {
|
||||
for line in last.lines() {
|
||||
println!(" {}", line);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,17 @@ pub fn ensure_swap(cfg: &Config) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
let path = &cfg.swap.path;
|
||||
if !std::path::Path::new(path).exists() {
|
||||
let expected_size = cfg.swap.size_mb * 1024 * 1024;
|
||||
if std::path::Path::new(path).exists() {
|
||||
let actual_size = fs::metadata(path)?.len();
|
||||
if actual_size != expected_size {
|
||||
anyhow::bail!(
|
||||
"existing swapfile has wrong size: {} bytes, expected {} bytes; manual intervention required",
|
||||
actual_size,
|
||||
expected_size
|
||||
);
|
||||
}
|
||||
} else {
|
||||
run("fallocate", &["-l", &format!("{}M", cfg.swap.size_mb), path])?;
|
||||
run("chmod", &["600", path])?;
|
||||
run("mkswap", &[path])?;
|
||||
@@ -22,6 +32,7 @@ pub fn ensure_swap(cfg: &Config) -> anyhow::Result<()> {
|
||||
|
||||
ensure_fstab(path, cfg.swap.priority)?;
|
||||
run("sysctl", &["-w", &format!("vm.swappiness={}", cfg.swap.swappiness)])?;
|
||||
ensure_swappiness_persist(cfg.swap.swappiness)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -49,3 +60,13 @@ fn run(program: &str, args: &[&str]) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_swappiness_persist(swappiness: u8) -> anyhow::Result<()> {
|
||||
let conf_path = "/etc/sysctl.d/99-awg2-harness.conf";
|
||||
let expected = format!("vm.swappiness = {}\n", swappiness);
|
||||
let current = fs::read_to_string(conf_path).unwrap_or_default();
|
||||
if current != expected {
|
||||
fs::write(conf_path, expected)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+162
-9
@@ -1,4 +1,49 @@
|
||||
use crate::{config::Config, docker, lock::FileLock, wg};
|
||||
use crate::{
|
||||
config::Config,
|
||||
docker,
|
||||
lock::FileLock,
|
||||
netns::{NsTransport, build_transport},
|
||||
wg,
|
||||
};
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TrafficDrift {
|
||||
has_drift: bool,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
pub fn inspect_drift_summary(cfg: &Config) -> anyhow::Result<Option<String>> {
|
||||
if !cfg.traffic.enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let target = docker::discover_target(cfg)?;
|
||||
let transport = build_transport(target.container_pid, &target.container_name);
|
||||
let peers = wg::discover_peers(
|
||||
target.container_pid,
|
||||
&target.container_name,
|
||||
&cfg.amnezia.peer_ipv4_cidr,
|
||||
)?;
|
||||
|
||||
if peers.is_empty() {
|
||||
return Ok(Some("no peers discovered".to_string()));
|
||||
}
|
||||
|
||||
let drift = inspect_and_diff(
|
||||
transport.as_ref(),
|
||||
&target.interface_name,
|
||||
&cfg.traffic.ifb_name,
|
||||
&peers,
|
||||
)?;
|
||||
|
||||
if drift.has_drift {
|
||||
Ok(Some(drift.reason))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reconcile_traffic(cfg: &Config) -> anyhow::Result<()> {
|
||||
if !cfg.traffic.enabled {
|
||||
@@ -7,34 +52,142 @@ pub fn reconcile_traffic(cfg: &Config) -> anyhow::Result<()> {
|
||||
|
||||
let _lock = FileLock::acquire("/run/awg2-harness/traffic.lock")?;
|
||||
let target = docker::discover_target(cfg)?;
|
||||
let peers = wg::discover_peers(&target.container_name, &cfg.amnezia.peer_ipv4_cidr)?;
|
||||
let transport = build_transport(target.container_pid, &target.container_name);
|
||||
let peers = wg::discover_peers(
|
||||
target.container_pid,
|
||||
&target.container_name,
|
||||
&cfg.amnezia.peer_ipv4_cidr,
|
||||
)?;
|
||||
|
||||
if peers.is_empty() {
|
||||
println!("no peers found, skip tc apply");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let drift = inspect_and_diff(
|
||||
transport.as_ref(),
|
||||
&target.interface_name,
|
||||
&cfg.traffic.ifb_name,
|
||||
&peers,
|
||||
)?;
|
||||
|
||||
if !drift.has_drift {
|
||||
println!("traffic rules up-to-date, skip apply");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("traffic drift detected: {}", drift.reason);
|
||||
|
||||
if cfg.traffic.full_rebuild_on_drift {
|
||||
teardown_owned_topology(&target.container_name, &target.interface_name, &cfg.traffic.ifb_name)?;
|
||||
apply_full_topology(cfg, &target.container_name, &target.interface_name, &cfg.traffic.ifb_name, &peers)?;
|
||||
teardown_owned_topology(transport.as_ref(), &target.interface_name, &cfg.traffic.ifb_name)?;
|
||||
apply_full_topology(
|
||||
cfg,
|
||||
transport.as_ref(),
|
||||
&target.interface_name,
|
||||
&cfg.traffic.ifb_name,
|
||||
&peers,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn teardown_owned_topology(container: &str, iface: &str, ifb: &str) -> anyhow::Result<()> {
|
||||
fn inspect_and_diff(
|
||||
transport: &dyn NsTransport,
|
||||
iface: &str,
|
||||
ifb: &str,
|
||||
peers: &[wg::Peer],
|
||||
) -> anyhow::Result<TrafficDrift> {
|
||||
let root_qdisc = transport
|
||||
.run(&["sh", "-c", &format!("tc qdisc show dev {iface} 2>/dev/null")])
|
||||
.unwrap_or_default();
|
||||
if !root_qdisc.contains("htb") {
|
||||
return Ok(TrafficDrift {
|
||||
has_drift: true,
|
||||
reason: "missing egress root htb qdisc".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let ingress_qdisc = transport
|
||||
.run(&["sh", "-c", &format!("tc qdisc show dev {iface} 2>/dev/null")])
|
||||
.unwrap_or_default();
|
||||
if !ingress_qdisc.contains("ingress") {
|
||||
return Ok(TrafficDrift {
|
||||
has_drift: true,
|
||||
reason: "missing ingress qdisc".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let ifb_link = transport
|
||||
.run(&["sh", "-c", &format!("ip link show {ifb} 2>/dev/null")])
|
||||
.unwrap_or_default();
|
||||
if ifb_link.trim().is_empty() {
|
||||
return Ok(TrafficDrift {
|
||||
has_drift: true,
|
||||
reason: "missing ifb interface".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let filters = transport
|
||||
.run(&[
|
||||
"sh",
|
||||
"-c",
|
||||
&format!("tc filter show dev {iface} parent 1: 2>/dev/null"),
|
||||
])
|
||||
.unwrap_or_default();
|
||||
|
||||
let actual_ips = extract_filter_ips(&filters);
|
||||
let expected_ips: BTreeSet<String> = peers.iter().map(|p| p.ipv4.to_string()).collect();
|
||||
|
||||
if actual_ips != expected_ips {
|
||||
return Ok(TrafficDrift {
|
||||
has_drift: true,
|
||||
reason: format!(
|
||||
"peer filter mismatch (actual={}, expected={})",
|
||||
actual_ips.len(),
|
||||
expected_ips.len()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(TrafficDrift {
|
||||
has_drift: false,
|
||||
reason: "no drift".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_filter_ips(filters: &str) -> BTreeSet<String> {
|
||||
let mut set = BTreeSet::new();
|
||||
for line in filters.lines() {
|
||||
if !line.contains("match") || !line.contains("dst") {
|
||||
continue;
|
||||
}
|
||||
let toks: Vec<&str> = line.split_whitespace().collect();
|
||||
for (idx, tok) in toks.iter().enumerate() {
|
||||
if *tok == "dst" {
|
||||
if let Some(ip) = toks.get(idx + 1) {
|
||||
set.insert((*ip).to_string());
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
fn teardown_owned_topology(transport: &dyn NsTransport, iface: &str, ifb: &str) -> anyhow::Result<()> {
|
||||
let cmd = format!(
|
||||
"tc qdisc del dev {iface} root 2>/dev/null || true; \
|
||||
tc qdisc del dev {iface} ingress 2>/dev/null || true; \
|
||||
ip link del {ifb} 2>/dev/null || true"
|
||||
);
|
||||
let _ = docker::run_capture(&["docker", "exec", container, "sh", "-c", &cmd])?;
|
||||
let _ = transport.run(&["sh", "-c", &cmd])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_full_topology(
|
||||
cfg: &Config,
|
||||
container: &str,
|
||||
transport: &dyn NsTransport,
|
||||
iface: &str,
|
||||
ifb: &str,
|
||||
peers: &[wg::Peer],
|
||||
@@ -51,7 +204,7 @@ fn apply_full_topology(
|
||||
tc class add dev {ifb} parent 2: classid 2:1 htb rate {root} ceil {root}",
|
||||
root = cfg.traffic.root_rate
|
||||
);
|
||||
let _ = docker::run_capture(&["docker", "exec", container, "sh", "-c", &setup_cmd])?;
|
||||
let _ = transport.run(&["sh", "-c", &setup_cmd])?;
|
||||
|
||||
for (idx, p) in peers.iter().enumerate() {
|
||||
let class_id = cfg.traffic.class_start_id + idx as u16;
|
||||
@@ -65,7 +218,7 @@ fn apply_full_topology(
|
||||
tc filter add dev {ifb} parent 2: protocol ip u32 match ip src {ip} flowid 2:{class_id}",
|
||||
limit = cfg.traffic.peer_limit
|
||||
);
|
||||
let _ = docker::run_capture(&["docker", "exec", container, "sh", "-c", &per_peer])?;
|
||||
let _ = transport.run(&["sh", "-c", &per_peer])?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
+49
-12
@@ -1,26 +1,39 @@
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use crate::{config, docker::run_capture};
|
||||
use crate::{
|
||||
config,
|
||||
netns::{DockerExecTransport, NsTransport, NsenterTransport, nsenter_available},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Peer {
|
||||
pub ipv4: Ipv4Addr,
|
||||
}
|
||||
|
||||
pub fn discover_peers(container: &str, peer_cidr: &str) -> anyhow::Result<Vec<Peer>> {
|
||||
let primary = run_capture(&[
|
||||
"docker",
|
||||
"exec",
|
||||
container,
|
||||
"sh",
|
||||
"-c",
|
||||
"wg show all allowed-ips 2>/dev/null",
|
||||
])
|
||||
.unwrap_or_default();
|
||||
pub fn discover_peers(pid: u32, container: &str, peer_cidr: &str) -> anyhow::Result<Vec<Peer>> {
|
||||
let boxed: Box<dyn NsTransport> = if nsenter_available() {
|
||||
Box::new(NsenterTransport { pid })
|
||||
} else {
|
||||
Box::new(DockerExecTransport {
|
||||
container: container.to_string(),
|
||||
})
|
||||
};
|
||||
|
||||
discover_peers_with_transport(boxed.as_ref(), peer_cidr)
|
||||
}
|
||||
|
||||
pub fn discover_peers_with_transport(
|
||||
transport: &dyn NsTransport,
|
||||
peer_cidr: &str,
|
||||
) -> anyhow::Result<Vec<Peer>> {
|
||||
let primary = transport
|
||||
.run(&["sh", "-c", "wg show all allowed-ips 2>/dev/null"])
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut peers = parse_allowed_ips(&primary, peer_cidr)?;
|
||||
if peers.is_empty() {
|
||||
let fallback = run_capture(&["docker", "exec", container, "sh", "-c", "wg show 2>/dev/null"])
|
||||
let fallback = transport
|
||||
.run(&["sh", "-c", "wg show 2>/dev/null"])
|
||||
.unwrap_or_default();
|
||||
peers = parse_fallback_wg_show(&fallback, peer_cidr)?;
|
||||
}
|
||||
@@ -89,5 +102,29 @@ mod tests {
|
||||
let peers = parse_allowed_ips(raw, "10.8.1.0/24").expect("parse");
|
||||
assert_eq!(peers.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fallback() {
|
||||
let raw = "allowed ips: 10.8.1.4/32, 10.8.1.5/32\nallowed ips: 192.168.0.1/32";
|
||||
let peers = parse_fallback_wg_show(raw, "10.8.1.0/24").expect("parse");
|
||||
assert_eq!(peers.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cidr_filtering_keeps_only_target_subnet() {
|
||||
let raw = "wg0 key1 10.8.1.10/32\nwg0 key2 10.9.1.2/32";
|
||||
let peers = parse_allowed_ips(raw, "10.8.1.0/24").expect("parse");
|
||||
assert_eq!(peers.len(), 1);
|
||||
assert_eq!(peers[0].ipv4.to_string(), "10.8.1.10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexicographic_sorting_parity() {
|
||||
let raw = "wg0 key1 10.8.1.2/32\nwg0 key2 10.8.1.10/32";
|
||||
let mut peers = parse_allowed_ips(raw, "10.8.1.0/24").expect("parse");
|
||||
peers.sort_by_key(|p| p.ipv4.to_string());
|
||||
assert_eq!(peers[0].ipv4.to_string(), "10.8.1.10");
|
||||
assert_eq!(peers[1].ipv4.to_string(), "10.8.1.2");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user