From 819cc9c3089061e5ff19ce0024d579f1499824e9 Mon Sep 17 00:00:00 2001 From: Crimson Date: Sun, 10 May 2026 22:58:59 +0500 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B8=20runtime:=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9=20production-fix=20=D0=BF=D0=BE?= =?UTF-8?q?=20fix6=20=D0=B1=D0=B5=D0=B7=20legacy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + docs/CONFIGURATION.md | 4 +- docs/IMPLEMENTATION_SPEC.md | 16 +++- docs/INSTALLATION.md | 4 +- reboot_module/README.md | 114 +++--------------------- reboot_module/reboot-scheduler.service | 22 ----- reboot_module/src/main.rs | 9 ++ tools/build.sh | 6 +- traffic_core/src/config.rs | 17 +++- traffic_core/src/reboot.rs | 116 ------------------------- traffic_core/src/status.rs | 16 +++- traffic_core/src/traffic.rs | 45 +++++++--- 12 files changed, 106 insertions(+), 267 deletions(-) create mode 100644 .gitignore delete mode 100644 reboot_module/reboot-scheduler.service delete mode 100644 traffic_core/src/reboot.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e5e10fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target/ +/.idea/ +/.vscode/ + diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 221cffb..b556df6 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -13,8 +13,8 @@ peer_ipv4_cidr = "10.8.1.0/24" [traffic] enabled = true -peer_limit = "50mbit" -root_rate = "500mbit" +peer_limit = "70mbit" +root_rate = "1000mbit" reconcile_interval_secs = 15 container_ready_timeout_secs = 120 class_start_id = 10 diff --git a/docs/IMPLEMENTATION_SPEC.md b/docs/IMPLEMENTATION_SPEC.md index 147b14c..50b69b4 100644 --- a/docs/IMPLEMENTATION_SPEC.md +++ b/docs/IMPLEMENTATION_SPEC.md @@ -2,7 +2,12 @@ ## 1. Architectural role -`awg2-harness` is a single Rust binary acting as a host-side multi-orchestrator for a dedicated Debian 13 amd64 AmneziaWG/AWG 2.0 server. +AWG2 Harness production package contains two Rust binaries: + +- `awg2-harness` — host-side traffic/firewall/status/swap orchestrator; +- `awg2-reboot-scheduler` — dedicated reboot scheduler service. + +`awg2-harness` remains the main host-side multi-orchestrator for a dedicated Debian 13 amd64 AmneziaWG/AWG 2.0 server. It owns the infrastructure state needed by the VPN service: @@ -23,13 +28,16 @@ It does not replace Amnezia. It controls the surrounding system state that the o ## 2. Process model -One systemd service runs: +Two systemd services run: ```bash /usr/sbin/awg2-harness daemon --config /etc/awg2-harness/config.toml +/usr/sbin/awg2-reboot-scheduler --config /etc/awg2-harness/config.toml ``` -The daemon starts as root and keeps running. On startup it performs initial reconcile. During runtime it reacts to Docker events and also runs a periodic reconcile loop. +`awg2-harness` daemon starts as root and keeps running. On startup it performs initial reconcile. During runtime it reacts to Docker events and also runs a periodic reconcile loop. + +Reboot scheduling authority belongs to `awg2-reboot-scheduler` service. `awg2-harness` does not schedule reboots internally. Subcommands: @@ -40,6 +48,7 @@ awg2-harness apply-firewall awg2-harness status awg2-harness ensure-swap awg2-harness validate-config +awg2-harness validate-runtime ``` ## 3. Module layout @@ -71,7 +80,6 @@ src/ │ ├── apply.rs │ └── nft.rs ├── status.rs -├── reboot.rs ├── swap.rs └── error.rs ``` diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 07b8a70..5a0115f 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -55,15 +55,17 @@ install -m 0644 /root/awg2-harness.config.toml /etc/awg2-harness/config.toml ```bash awg2-harness validate-config --config /etc/awg2-harness/config.toml +awg2-harness validate-runtime --config /etc/awg2-harness/config.toml awg2-harness apply-limits --config /etc/awg2-harness/config.toml awg2-harness apply-firewall --config /etc/awg2-harness/config.toml awg2-harness status --config /etc/awg2-harness/config.toml ``` -### C4. Enable daemon +### C4. Enable services ```bash systemctl enable --now awg2-harness.service +systemctl enable --now awg2-reboot-scheduler.service ``` ## D. Manual source-tree install (staging/debug only) diff --git a/reboot_module/README.md b/reboot_module/README.md index 43a7e17..099f9ce 100644 --- a/reboot_module/README.md +++ b/reboot_module/README.md @@ -1,111 +1,23 @@ -# reboot-scheduler +# awg2-reboot-scheduler -`reboot-scheduler` — небольшой `systemd`-сервис на Rust, который раз в запуск планирует перезагрузку в случайную секунду окна **[01:00, 02:00) UTC**, затем запрашивает reboot через `systemctl --no-block reboot`. +`awg2-reboot-scheduler` — production auxiliary binary в составе пакета AWG2 Harness. -## Состав репозитория +Он читает общий конфиг [`/etc/awg2-harness/config.toml`](../config.example.toml), выбирает случайный момент в окне reboot и запрашивает перезагрузку через `systemctl --no-block reboot`. -```text -reboot-scheduler/ -├── src/ -│ └── main.rs -├── Cargo.toml -├── Cargo.lock -├── reboot-scheduler.service -├── .gitignore -└── README.md -``` +## Production model -## Требования +- binary: `/usr/sbin/awg2-reboot-scheduler` +- unit: [`/lib/systemd/system/awg2-reboot-scheduler.service`](../awg2-reboot-scheduler.service) +- config: `/etc/awg2-harness/config.toml` +- runtime plan file: `/run/awg2-harness/reboot.plan` -- Linux x86_64 -- `systemd` -- Rust toolchain (cargo + rustc) -- Поддерживаемые дистрибутивы: Debian 12/13 (и совместимые) +Сервис ставится и управляется общим packaging flow [`tools/build.sh`](../tools/build.sh), вручную в `/opt/...` ничего не раскладывается. -## Сборка +## Проверка на host ```bash -cargo build --release +awg2-reboot-scheduler --config /etc/awg2-harness/config.toml +systemctl status awg2-reboot-scheduler +journalctl -u awg2-reboot-scheduler -f ``` -Итоговый бинарь: - -```text -target/release/reboot-scheduler -``` - -По умолчанию release-сборка даёт **динамически связанный** glibc-бинарь. Это штатное поведение. - -## Установка - -Команды выполняются от root (или через `sudo`). - -```bash -mkdir -p /opt/reboot-scheduler -cp target/release/reboot-scheduler /opt/reboot-scheduler/reboot-scheduler -chmod 755 /opt/reboot-scheduler/reboot-scheduler - -cp reboot-scheduler.service /etc/systemd/system/reboot-scheduler.service -chmod 644 /etc/systemd/system/reboot-scheduler.service - -systemd-analyze verify /etc/systemd/system/reboot-scheduler.service -systemctl daemon-reload -systemctl enable --now reboot-scheduler.service -``` - -## Поведение сервиса - -1. На старте вычисляет целевой timestamp в окне **(01:00, 02:00) UTC**. -2. Ожидает наступления времени, проверяя раз в 30 секунд. -3. Вызывает `systemctl --no-block reboot`. -4. Завершается: - - кодом `0`, если reboot-запрос принят; - - кодом `1`, если команда завершилась ошибкой или не запустилась. - -Это важно для корректной работы `Restart=on-failure` в unit-файле. - -## Логирование - -Сервис не пишет в собственные файлы. Логи идут в `journald` (stdout/stderr): - -```bash -journalctl -u reboot-scheduler -f -``` - -Формат сообщений: - -```text -[YYYY-MM-DD HH:MM:SS UTC] Сообщение -``` - -## Управление - -```bash -systemctl status reboot-scheduler -systemctl restart reboot-scheduler -systemctl stop reboot-scheduler -systemctl disable reboot-scheduler -``` - -## Проверки качества - -Рекомендуемый локальный прогон перед релизом: - -```bash -cargo fmt --check -cargo clippy --all-targets --all-features -- -D warnings -cargo test --release -cargo build --release -file target/release/reboot-scheduler -ldd target/release/reboot-scheduler -``` - -## Удаление - -```bash -systemctl stop reboot-scheduler -systemctl disable reboot-scheduler -rm -f /etc/systemd/system/reboot-scheduler.service -rm -rf /opt/reboot-scheduler -systemctl daemon-reload -``` diff --git a/reboot_module/reboot-scheduler.service b/reboot_module/reboot-scheduler.service deleted file mode 100644 index edb07f1..0000000 --- a/reboot_module/reboot-scheduler.service +++ /dev/null @@ -1,22 +0,0 @@ -[Unit] -Description=Daily random reboot scheduler (01:00-02:00 UTC) - -[Service] -Type=simple -User=root -ExecStart=/opt/reboot-scheduler/reboot-scheduler -Restart=on-failure -RestartSec=60 -StandardOutput=journal -StandardError=journal - -# Базовое hardening -CapabilityBoundingSet=CAP_SYS_BOOT -AmbientCapabilities=CAP_SYS_BOOT -NoNewPrivileges=true -ProtectSystem=strict -ProtectHome=true -PrivateTmp=true - -[Install] -WantedBy=multi-user.target diff --git a/reboot_module/src/main.rs b/reboot_module/src/main.rs index 3376c60..48cdcf1 100644 --- a/reboot_module/src/main.rs +++ b/reboot_module/src/main.rs @@ -96,6 +96,9 @@ fn persist_plan(planned: NaiveDateTime) { fn next_reboot_time(start_hhmm: &str, end_hhmm: &str) -> anyhow::Result { let start = parse_hhmm(start_hhmm)?; let end = parse_hhmm(end_hhmm)?; + if start == end { + anyhow::bail!("reboot window start and end must differ"); + } let now = Utc::now().naive_utc(); let now_t = NaiveTime::from_hms_opt(now.hour(), now.minute(), now.second()) @@ -233,5 +236,11 @@ mod tests { assert_eq!(s, now); assert_eq!(e.format("%Y-%m-%d %H:%M:%S").to_string(), "2026-01-11 02:00:00"); } + + #[test] + fn equal_window_boundaries_rejected() { + let err = next_reboot_time("01:00", "01:00").err(); + assert!(err.is_some()); + } } diff --git a/tools/build.sh b/tools/build.sh index 0b59928..d2eae9f 100644 --- a/tools/build.sh +++ b/tools/build.sh @@ -208,13 +208,13 @@ prepare_build_gate() { exit 1 fi - if [[ ! -f "$ROOT_DIR/traffic_core/Cargo.lock" ]]; then - echo "Missing lockfile: $ROOT_DIR/traffic_core/Cargo.lock" + if [[ ! -f "$ROOT_DIR/Cargo.lock" ]]; then + echo "Missing lockfile: $ROOT_DIR/Cargo.lock" exit 1 fi if [[ "$SKIP_FMT" != true ]]; then - cargo fmt --manifest-path "$CRATE_MANIFEST" --all --check + cargo fmt --manifest-path "$WORKSPACE_MANIFEST" --all --check fi if [[ ! -f "$WORKSPACE_MANIFEST" ]]; then diff --git a/traffic_core/src/config.rs b/traffic_core/src/config.rs index 920dc85..4690bdf 100644 --- a/traffic_core/src/config.rs +++ b/traffic_core/src/config.rs @@ -130,13 +130,18 @@ impl Config { fn check_host_tools(&self) -> anyhow::Result<()> { let mut required = vec!["tc", "ip", "wg", "docker", "modprobe"]; if !crate::netns::nsenter_available() { - eprintln!("nsenter is not available, will use docker exec fallback"); - } else { - required.push("nsenter"); + anyhow::bail!("required host tool not found in PATH: nsenter"); } + required.push("nsenter"); if self.anti_torrent.enabled { required.push("nft"); } + if self.reboot.enabled { + required.push("systemctl"); + } + if self.swap.enabled { + required.extend(["fallocate", "chmod", "mkswap", "swapon", "sysctl"]); + } for tool in required { if !tool_exists(tool) { @@ -199,6 +204,12 @@ fn tool_exists(tool: &str) -> bool { "modprobe" => &[&["--version"]], "nsenter" => &[&["--help"]], "nft" => &[&["--version"]], + "systemctl" => &[&["--version"], &["--help"]], + "fallocate" => &[&["--help"]], + "chmod" => &[&["--help"]], + "mkswap" => &[&["--help"]], + "swapon" => &[&["--help"]], + "sysctl" => &[&["--version"], &["--help"]], _ => &[&["--version"], &["--help"], &["-V"]], }; diff --git a/traffic_core/src/reboot.rs b/traffic_core/src/reboot.rs deleted file mode 100644 index a3027d0..0000000 --- a/traffic_core/src/reboot.rs +++ /dev/null @@ -1,116 +0,0 @@ -use chrono::{Duration, NaiveDateTime, NaiveTime, Timelike, Utc}; -use rand::Rng; -use std::{process::Command, thread, time::Duration as StdDuration}; - -use crate::config::Config; - -pub fn maybe_schedule_reboot(cfg: &Config) -> anyhow::Result<()> { - if !cfg.reboot.enabled { - return Ok(()); - } - - let planned = next_reboot_time(&cfg.reboot.window_start_utc, &cfg.reboot.window_end_utc)?; - println!("reboot planned at UTC {}", planned); - Ok(()) -} - -pub fn reboot_scheduler_loop(cfg: Config) -> anyhow::Result<()> { - if !cfg.reboot.enabled { - return Ok(()); - } - - 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 { - 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(24) - start.signed_duration_since(end) - }; - - let secs = rand::thread_rng().gen_range(0..span.num_seconds().max(1)); - 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 { - 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") - } - NaiveTime::from_hms_opt(h, m, 0).ok_or_else(|| anyhow::anyhow!("bad time")) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_time_rejects_invalid() { - assert!(parse_hhmm("24:00").is_err()); - assert!(parse_hhmm("10:60").is_err()); - assert!(parse_hhmm("ab:cd").is_err()); - } - - #[test] - fn cross_midnight_window_has_expected_span() { - let start = parse_hhmm("23:00").expect("start"); - let end = parse_hhmm("02:00").expect("end"); - let span = if end > start { - end.signed_duration_since(start) - } else { - Duration::hours(24) - start.signed_duration_since(end) - }; - assert_eq!(span.num_hours(), 3); - } -} diff --git a/traffic_core/src/status.rs b/traffic_core/src/status.rs index de8826f..486250d 100644 --- a/traffic_core/src/status.rs +++ b/traffic_core/src/status.rs @@ -183,6 +183,13 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { } } + println!("Swap:"); + println!(" enabled: {}", cfg.swap.enabled); + println!(" path: {}", cfg.swap.path); + println!(" size_mb: {}", cfg.swap.size_mb); + println!(" priority: {}", cfg.swap.priority); + println!(" swappiness: {}", cfg.swap.swappiness); + let ct_count = Command::new("sysctl") .args(["-n", "net.netfilter.nf_conntrack_count"]) .output() @@ -220,7 +227,7 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { } } - print_reboot_section(); + print_reboot_section(cfg); Ok(()) } @@ -301,12 +308,15 @@ fn print_degraded_sections(cfg: &Config) -> anyhow::Result<()> { } } - print_reboot_section(); + print_reboot_section(cfg); Ok(()) } -fn print_reboot_section() { +fn print_reboot_section(cfg: &Config) { println!("Reboot scheduler:"); + println!(" enabled: {}", cfg.reboot.enabled); + println!(" window_start_utc: {}", cfg.reboot.window_start_utc); + println!(" window_end_utc: {}", cfg.reboot.window_end_utc); let svc = Command::new("systemctl") .args(["is-active", "awg2-reboot-scheduler"]) .output(); diff --git a/traffic_core/src/traffic.rs b/traffic_core/src/traffic.rs index 4086064..c78552b 100644 --- a/traffic_core/src/traffic.rs +++ b/traffic_core/src/traffic.rs @@ -180,7 +180,7 @@ pub fn peer_status_table(cfg: &Config) -> anyhow::Result Option { class_tail.parse::().ok() } -fn expected_peer_class_map(peers: &[wg::Peer], class_start_id: u16) -> BTreeMap { +fn expected_peer_class_map( + peers: &[wg::Peer], + class_start_id: u16, +) -> anyhow::Result> { let mut map = BTreeMap::new(); for (idx, p) in peers.iter().enumerate() { - map.insert(p.ipv4.to_string(), class_start_id + idx as u16); + let idx_u16 = u16::try_from(idx) + .map_err(|_| anyhow::anyhow!("too many peers for class id allocation"))?; + let class_id = class_start_id + .checked_add(idx_u16) + .ok_or_else(|| anyhow::anyhow!("class id overflow: too many peers"))?; + map.insert(p.ipv4.to_string(), class_id); } - map + Ok(map) } fn extract_u32_match_hex(line: &str) -> Option { @@ -773,7 +781,13 @@ fn apply_full_topology( )?; for (idx, p) in peers.iter().enumerate() { - let class_id = cfg.traffic.class_start_id + idx as u16; + let idx_u16 = u16::try_from(idx) + .map_err(|_| anyhow::anyhow!("too many peers for class id allocation"))?; + let class_id = cfg + .traffic + .class_start_id + .checked_add(idx_u16) + .ok_or_else(|| anyhow::anyhow!("class id overflow: too many peers"))?; let ip = p.ipv4.to_string(); let class_egress = format!("1:{class_id}"); let class_ingress = format!("2:{class_id}"); @@ -912,8 +926,12 @@ fn run_allow_exists(transport: &dyn NsTransport, args: &[&str]) -> anyhow::Resul match transport.run(args) { Ok(_) => Ok(()), Err(e) => { - let msg = e.to_string(); - if msg.contains("File exists") || msg.contains("EEXIST") { + let msg = e.to_string().to_ascii_lowercase(); + if msg.contains("file exists") + || msg.contains("eexist") + || msg.contains("object already exists") + || msg.contains("exclusive flag on") + { return Ok(()); } Err(e) @@ -925,11 +943,14 @@ fn run_allow_notfound(transport: &dyn NsTransport, args: &[&str]) -> anyhow::Res match transport.run(args) { Ok(_) => Ok(()), Err(e) => { - let msg = e.to_string(); - if msg.contains("No such file") - || msg.contains("No such") + let msg = e.to_string().to_ascii_lowercase(); + if msg.contains("no such file") + || msg.contains("no such") || msg.contains("not found") - || msg.contains("Cannot find") + || msg.contains("cannot find") + || msg.contains("cannot find device") + || msg.contains("no such device") + || msg.contains("does not exist") { return Ok(()); }