From b3dad5974cb9de6d784afdc96dc440c8ae14de3f Mon Sep 17 00:00:00 2001 From: Crimson Date: Sun, 10 May 2026 15:41:44 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=D0=B0=D0=BA=D1=88=D0=BD-=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D1=81:=20hardened=20build/install,=20drift/firewall/wg=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D1=83=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/INSTALLATION.md | 21 ++-- rust-toolchain.toml | 3 + tools/README.md | 56 ++++++++- tools/build.sh | 41 +++++-- traffic_core/Cargo.lock | 30 ++++- traffic_core/Cargo.toml | 1 + traffic_core/src/firewall.rs | 91 ++++++++++++--- traffic_core/src/netns.rs | 10 +- traffic_core/src/reboot.rs | 2 +- traffic_core/src/traffic.rs | 216 ++++++++++++++++++++++++++++++----- traffic_core/src/wg.rs | 6 +- 11 files changed, 410 insertions(+), 67 deletions(-) create mode 100644 rust-toolchain.toml diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 2e33525..207af05 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -31,29 +31,36 @@ nft Сборку лучше выполнять на отдельной build-машине с тем же целевым ABI. ```bash -cargo build --release +./tools/build.sh +``` + +Альтернатива (ручной запуск cargo из корня репозитория): + +```bash +cargo build --locked --manifest-path traffic_core/Cargo.toml --release ``` Проверки перед переносом: ```bash -cargo fmt --check -cargo clippy --all-targets --all-features -- -D warnings -cargo test --release -file target/release/awg2-harness -ldd target/release/awg2-harness +cargo fmt --manifest-path traffic_core/Cargo.toml --all --check +cargo clippy --locked --manifest-path traffic_core/Cargo.toml --all-targets -- -D warnings +cargo test --locked --manifest-path traffic_core/Cargo.toml --release +file traffic_core/target/release/awg2-harness +ldd traffic_core/target/release/awg2-harness ``` ## 3. Установка файлов ```bash -install -m 0755 target/release/awg2-harness /usr/local/bin/awg2-harness +install -m 0755 traffic_core/target/release/awg2-harness /usr/local/bin/awg2-harness install -d -m 0755 /etc/awg2-harness install -m 0644 config.toml /etc/awg2-harness/config.toml ``` Важно: production-установка **не должна** автоматически копировать [`config.example.toml`](config.example.toml) как боевой конфиг. Если используется [`tools/build.sh`](tools/build.sh), режим `--install` завершится ошибкой при отсутствии `/etc/awg2-harness/config.toml`. +Режим `--install` копирует только бинарь и unit, выполняет `systemctl daemon-reload`, но не делает `systemctl enable --now`. Сначала подготовьте осознанный production-конфиг вручную. Проверка конфига: diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..b475f2f --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.85.0" +components = ["rustfmt", "clippy"] diff --git a/tools/README.md b/tools/README.md index ccb107b..923a9d4 100644 --- a/tools/README.md +++ b/tools/README.md @@ -1,2 +1,54 @@ -На будущее: -Тут должен быть билдер на баш, который проверяет совместимость и зависимости если их нет доставляет и все собирает сам, то есть тупо запустили он сам все сделал. \ No newline at end of file +# Build tooling + +[`build.sh`](tools/build.sh) — production build gate для [`traffic_core`](traffic_core/Cargo.toml). + +## Что делает + +Скрипт: + +1. Проверяет OS и архитектуру (Debian/Ubuntu, amd64/x86_64). +2. Проверяет наличие инструментов: `cargo`, `rustc`, `gcc`, `pkg-config`, `file`, `ldd`. +3. Проверяет минимальную версию Rust (`rustc >= 1.85.0`). +4. Запускает quality gate: + - `cargo fmt --check` (если не указан `--skip-fmt`), + - `cargo clippy --locked --all-targets -- -D warnings`, + - `cargo test --locked --release`, + - `cargo build --locked --release`. +5. Проверяет собранный бинарь: `file`, `ldd`, `--help`, `sha256sum`. + +## Что НЕ делает + +- Не устанавливает Rust/toolchain автоматически. +- Не устанавливает системные зависимости через apt. +- Не создаёт production-конфиг автоматически. + +## Использование + +Из корня репозитория: + +```bash +./tools/build.sh +``` + +Опции: + +- `--skip-fmt` — пропустить шаг `cargo fmt --check`. +- `--install` — после успешной сборки установить бинарь и systemd unit. + +## Режим `--install` + +`--install`: + +1. Копирует бинарь в `/usr/local/bin/awg2-harness`. +2. Проверяет, что `/etc/awg2-harness/config.toml` уже существует. +3. Копирует [`awg2-harness.service`](awg2-harness.service). +4. Выполняет `systemd-analyze verify` (если доступно). +5. Выполняет `systemctl daemon-reload`. + +Важно: + +- Если скрипт запущен от root, команды установки выполняются напрямую. +- Если не от root — используется `sudo` (и проверяется его наличие). +- Скрипт **не** выполняет `systemctl enable --now` автоматически. + +Это intentional: включение сервиса и запуск остаются осознанным эксплуатационным шагом. diff --git a/tools/build.sh b/tools/build.sh index e870f8b..1612439 100644 --- a/tools/build.sh +++ b/tools/build.sh @@ -4,6 +4,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CRATE_MANIFEST="$ROOT_DIR/traffic_core/Cargo.toml" TARGET_BIN="$ROOT_DIR/traffic_core/target/release/awg2-harness" +MIN_RUSTC="1.85.0" AUTO_INSTALL=false SKIP_FMT=false @@ -20,6 +21,12 @@ require_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "Missing command: $1"; exit 1; } } +version_ge() { + local have="$1" + local need="$2" + [[ "$(printf '%s\n' "$need" "$have" | sort -V | head -n1)" == "$need" ]] +} + if [[ -f /etc/os-release ]]; then # shellcheck disable=SC1091 source /etc/os-release @@ -40,6 +47,14 @@ if ! command -v cargo >/dev/null 2>&1; then exit 1 fi +require_cmd rustc + +RUSTC_VERSION="$(rustc --version | awk '{print $2}')" +if ! version_ge "$RUSTC_VERSION" "$MIN_RUSTC"; then + echo "rustc $RUSTC_VERSION is too old. Require >= $MIN_RUSTC" + exit 1 +fi + for cmd in gcc pkg-config; do require_cmd "$cmd" done @@ -48,9 +63,9 @@ if [[ "$SKIP_FMT" != true ]]; then cargo fmt --manifest-path "$CRATE_MANIFEST" --all --check fi -cargo clippy --manifest-path "$CRATE_MANIFEST" --all-targets -- -D warnings -cargo test --manifest-path "$CRATE_MANIFEST" --release -cargo build --manifest-path "$CRATE_MANIFEST" --release +cargo clippy --locked --manifest-path "$CRATE_MANIFEST" --all-targets -- -D warnings +cargo test --locked --manifest-path "$CRATE_MANIFEST" --release +cargo build --locked --manifest-path "$CRATE_MANIFEST" --release require_cmd file require_cmd ldd @@ -64,16 +79,28 @@ ls -lh "$TARGET_BIN" if [[ "$AUTO_INSTALL" == true ]]; then require_cmd install - sudo install -m 0755 "$TARGET_BIN" /usr/local/bin/awg2-harness - sudo mkdir -p /etc/awg2-harness + + RUN_AS_ROOT=( ) + if [[ "$(id -u)" -eq 0 ]]; then + RUN_AS_ROOT=( ) + else + require_cmd sudo + RUN_AS_ROOT=(sudo) + fi + + "${RUN_AS_ROOT[@]}" install -m 0755 "$TARGET_BIN" /usr/local/bin/awg2-harness + "${RUN_AS_ROOT[@]}" mkdir -p /etc/awg2-harness if [[ ! -f /etc/awg2-harness/config.toml ]]; then echo "Missing /etc/awg2-harness/config.toml" echo "Refusing to install example config as production config." echo "Create config manually from $ROOT_DIR/config.example.toml and re-run with --install." exit 1 fi - sudo install -m 0644 "$ROOT_DIR/awg2-harness.service" /etc/systemd/system/awg2-harness.service - sudo systemctl daemon-reload + "${RUN_AS_ROOT[@]}" install -m 0644 "$ROOT_DIR/awg2-harness.service" /etc/systemd/system/awg2-harness.service + if command -v systemd-analyze >/dev/null 2>&1; then + "${RUN_AS_ROOT[@]}" systemd-analyze verify /etc/systemd/system/awg2-harness.service + fi + "${RUN_AS_ROOT[@]}" systemctl daemon-reload fi echo "Build completed: $TARGET_BIN" diff --git a/traffic_core/Cargo.lock b/traffic_core/Cargo.lock index ad32840..1a1993c 100644 --- a/traffic_core/Cargo.lock +++ b/traffic_core/Cargo.lock @@ -91,6 +91,7 @@ dependencies = [ "clap", "rand", "serde", + "serde_json", "thiserror", "toml", "tracing", @@ -233,9 +234,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -283,6 +284,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "js-sys" version = "0.3.98" @@ -474,6 +481,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -823,3 +843,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/traffic_core/Cargo.toml b/traffic_core/Cargo.toml index 54de44e..2597092 100644 --- a/traffic_core/Cargo.toml +++ b/traffic_core/Cargo.toml @@ -9,6 +9,7 @@ chrono = { version = "0.4", features = ["clock"] } clap = { version = "4.5", features = ["derive"] } rand = "0.8" serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" thiserror = "2.0" toml = "0.8" tracing = "0.1" diff --git a/traffic_core/src/firewall.rs b/traffic_core/src/firewall.rs index d8441e7..7fa5119 100644 --- a/traffic_core/src/firewall.rs +++ b/traffic_core/src/firewall.rs @@ -3,6 +3,7 @@ use crate::{ docker, netns::{NsTransport, build_host_transport, build_transport}, }; +use serde_json::Value; #[derive(Debug, Clone)] struct FirewallActual { @@ -77,7 +78,14 @@ fn inspect_firewall(transport: &dyn NsTransport, cfg: &Config) -> anyhow::Result "ct count over {}", cfg.anti_torrent.max_connections_per_peer ); - let rule_matches = table.contains(&needle_cidr) && table.contains(&needle_max); + let rule_matches = table.contains(&needle_cidr) + && table.contains(&needle_max) + && table.contains("ct state new") + && table.contains("meter awg2_peer_connlimit_v4") + && table.contains("counter name antitorrent_dropped_v4") + && table.contains("drop") + && table.contains("hook forward") + && table.contains("policy accept"); Ok(FirewallActual { table_exists, @@ -90,7 +98,10 @@ fn inspect_firewall(transport: &dyn NsTransport, cfg: &Config) -> anyhow::Result fn firewall_transport(cfg: &Config) -> anyhow::Result> { if cfg.anti_torrent.apply_in_amnezia_netns { let target = docker::discover_target(cfg)?; - Ok(build_transport(target.container_pid, &target.container_name)) + Ok(build_transport( + target.container_pid, + &target.container_name, + )) } else { Ok(build_host_transport()) } @@ -180,25 +191,75 @@ pub fn antitorrent_drop_counter(cfg: &Config) -> anyhow::Result = line.split_whitespace().collect(); - if let Some(idx) = toks.iter().position(|t| *t == "packets") { - if let Some(v) = toks.get(idx + 1).and_then(|s| s.parse::().ok()) { - let bytes = toks - .iter() - .position(|t| *t == "bytes") - .and_then(|i| toks.get(i + 1)) - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - return Ok(Some((v, bytes))); - } + let t = line.trim(); + if !t.contains("packets") { + continue; + } + let toks: Vec<&str> = t.split_whitespace().collect(); + let packets = toks + .iter() + .position(|x| *x == "packets") + .and_then(|i| toks.get(i + 1)) + .and_then(|s| s.parse::().ok()); + let bytes = toks + .iter() + .position(|x| *x == "bytes") + .and_then(|i| toks.get(i + 1)) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + if let Some(p) = packets { + return Ok(Some((p, bytes))); + } + } + + Ok(None) +} + +fn parse_counter_json(raw: &str) -> anyhow::Result> { + let v: Value = serde_json::from_str(raw)?; + let Some(arr) = v.get("nftables").and_then(|x| x.as_array()) else { + return Ok(None); + }; + for item in arr { + if let Some(counter) = item.get("counter") { + let packets = counter.get("packets").and_then(|x| x.as_u64()); + let bytes = counter.get("bytes").and_then(|x| x.as_u64()); + if let (Some(p), Some(b)) = (packets, bytes) { + return Ok(Some((p, b))); } } } Ok(None) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_nft_counter_json() { + let raw = r#"{"nftables":[{"metainfo":{"json_schema_version":1}},{"counter":{"family":"inet","table":"awg2_harness","name":"antitorrent_dropped_v4","handle":2,"packets":7,"bytes":448}}]}"#; + let got = parse_counter_json(raw).expect("json parse"); + assert_eq!(got, Some((7, 448))); + } +} diff --git a/traffic_core/src/netns.rs b/traffic_core/src/netns.rs index ac210fe..21f69dc 100644 --- a/traffic_core/src/netns.rs +++ b/traffic_core/src/netns.rs @@ -47,11 +47,17 @@ impl NsTransport for DockerExecTransport { impl NsTransport for HostTransport { fn run(&self, args: &[&str]) -> anyhow::Result { - let mut cmd = Command::new(args.first().ok_or_else(|| anyhow::anyhow!("empty command"))?); + let mut cmd = Command::new( + args.first() + .ok_or_else(|| anyhow::anyhow!("empty command"))?, + ); cmd.args(&args[1..]); let out = cmd.output()?; if !out.status.success() { - anyhow::bail!("host transport failed: {}", String::from_utf8_lossy(&out.stderr)); + anyhow::bail!( + "host transport failed: {}", + String::from_utf8_lossy(&out.stderr) + ); } Ok(String::from_utf8_lossy(&out.stdout).to_string()) } diff --git a/traffic_core/src/reboot.rs b/traffic_core/src/reboot.rs index 93e32cd..a3027d0 100644 --- a/traffic_core/src/reboot.rs +++ b/traffic_core/src/reboot.rs @@ -1,4 +1,4 @@ -use chrono::{Datelike, Duration, NaiveDateTime, NaiveTime, Timelike, Utc}; +use chrono::{Duration, NaiveDateTime, NaiveTime, Timelike, Utc}; use rand::Rng; use std::{process::Command, thread, time::Duration as StdDuration}; diff --git a/traffic_core/src/traffic.rs b/traffic_core/src/traffic.rs index c89d49b..67d2170 100644 --- a/traffic_core/src/traffic.rs +++ b/traffic_core/src/traffic.rs @@ -58,6 +58,7 @@ pub fn inspect_drift_summary(cfg: &Config) -> anyhow::Result> { &target.interface_name, &cfg.traffic.ifb_name, cfg.traffic.class_start_id, + &cfg.traffic.root_rate, &cfg.traffic.peer_limit, &peers, )?; @@ -93,6 +94,7 @@ pub fn reconcile_traffic(cfg: &Config) -> anyhow::Result<()> { &target.interface_name, &cfg.traffic.ifb_name, cfg.traffic.class_start_id, + &cfg.traffic.root_rate, &cfg.traffic.peer_limit, &peers, )?; @@ -104,21 +106,26 @@ pub fn reconcile_traffic(cfg: &Config) -> anyhow::Result<()> { println!("traffic drift detected: {}", drift.reason); - if cfg.traffic.full_rebuild_on_drift { - 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, - )?; + if !cfg.traffic.full_rebuild_on_drift { + anyhow::bail!( + "traffic drift detected but full_rebuild_on_drift=false: {}", + drift.reason + ); } + 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(()) } @@ -139,10 +146,26 @@ pub fn peer_status_table(cfg: &Config) -> anyhow::Result anyhow::Result anyhow::Result anyhow::Result { @@ -259,8 +289,26 @@ fn inspect_and_diff( .run(&["tc", "filter", "show", "dev", ifb, "parent", "2:"]) .unwrap_or_default(); - let actual_map_egress = extract_filter_ip_class_map(&filters_egress); - let actual_map_ingress = extract_filter_ip_class_map(&filters_ingress); + let (actual_map_egress, dup_egress) = extract_filter_ip_class_map(&filters_egress); + let (actual_map_ingress, dup_ingress) = extract_filter_ip_class_map(&filters_ingress); + if !dup_egress.is_empty() { + return Ok(TrafficDrift { + has_drift: true, + reason: format!( + "duplicate egress filters detected for {} peer(s)", + dup_egress.len() + ), + }); + } + if !dup_ingress.is_empty() { + return Ok(TrafficDrift { + has_drift: true, + reason: format!( + "duplicate ingress filters detected for {} peer(s)", + dup_ingress.len() + ), + }); + } let actual_ips_egress: BTreeSet = actual_map_egress.keys().cloned().collect(); let actual_ips_ingress: BTreeSet = actual_map_ingress.keys().cloned().collect(); let expected_ips: BTreeSet = peers.iter().map(|p| p.ipv4.to_string()).collect(); @@ -316,9 +364,33 @@ fn inspect_and_diff( let classes_ingress_full = transport .run(&["tc", "class", "show", "dev", ifb]) .unwrap_or_default(); + let expected_root = normalize_tc_rate(root_rate); let egress_rates = parse_class_rate_ceil_map(&classes_egress_full, "1:"); let ingress_rates = parse_class_rate_ceil_map(&classes_ingress_full, "2:"); + let root_egress = parse_root_class_rate_ceil(&classes_egress_full, "1:1"); + let root_ingress = parse_root_class_rate_ceil(&classes_ingress_full, "2:1"); + if root_egress + .as_ref() + .map(|v| !rate_ceil_matches_limit(v, &expected_root)) + .unwrap_or(true) + { + return Ok(TrafficDrift { + has_drift: true, + reason: "egress root class 1:1 rate/ceil mismatch".to_string(), + }); + } + if root_ingress + .as_ref() + .map(|v| !rate_ceil_matches_limit(v, &expected_root)) + .unwrap_or(true) + { + return Ok(TrafficDrift { + has_drift: true, + reason: "ingress root class 2:1 rate/ceil mismatch".to_string(), + }); + } + for class_id in expected_map.values() { let class_egress = format!("1:{class_id}"); let class_ingress = format!("2:{class_id}"); @@ -330,8 +402,10 @@ fn inspect_and_diff( reason: format!("missing rate/ceil class details for peer class_id={class_id}"), }); } - if e.map(|v| !rate_ceil_matches_limit(v, &expected_limit)).unwrap_or(true) - || i.map(|v| !rate_ceil_matches_limit(v, &expected_limit)).unwrap_or(true) + if e.map(|v| !rate_ceil_matches_limit(v, &expected_limit)) + .unwrap_or(true) + || i.map(|v| !rate_ceil_matches_limit(v, &expected_limit)) + .unwrap_or(true) { return Ok(TrafficDrift { has_drift: true, @@ -347,27 +421,51 @@ fn inspect_and_diff( .run(&["tc", "class", "show", "dev", ifb, "parent", "2:1"]) .unwrap_or_default(); let expected_class_count = peers.len(); - if count_leaf_classes(&classes_egress) < expected_class_count { + if count_leaf_classes(&classes_egress) != expected_class_count { return Ok(TrafficDrift { has_drift: true, - reason: "missing egress per-peer classes".to_string(), + reason: "egress per-peer class count mismatch".to_string(), }); } - if count_leaf_classes(&classes_ingress) < expected_class_count { + if count_leaf_classes(&classes_ingress) != expected_class_count { return Ok(TrafficDrift { has_drift: true, - reason: "missing ingress per-peer classes".to_string(), + reason: "ingress per-peer class count mismatch".to_string(), }); } + let qdisc_egress = transport + .run(&["tc", "qdisc", "show", "dev", iface]) + .unwrap_or_default(); + let qdisc_ingress = transport + .run(&["tc", "qdisc", "show", "dev", ifb]) + .unwrap_or_default(); + for class_id in expected_map.values() { + let class_egress = format!("1:{class_id}"); + let class_ingress = format!("2:{class_id}"); + if !has_fq_codel_for_parent(&qdisc_egress, &class_egress) { + return Ok(TrafficDrift { + has_drift: true, + reason: format!("missing fq_codel for egress class {class_egress}"), + }); + } + if !has_fq_codel_for_parent(&qdisc_ingress, &class_ingress) { + return Ok(TrafficDrift { + has_drift: true, + reason: format!("missing fq_codel for ingress class {class_ingress}"), + }); + } + } + Ok(TrafficDrift { has_drift: false, reason: "no drift".to_string(), }) } -fn extract_filter_ip_class_map(filters: &str) -> BTreeMap { +fn extract_filter_ip_class_map(filters: &str) -> (BTreeMap, BTreeSet) { let mut out = BTreeMap::new(); + let mut duplicates = BTreeSet::new(); for block in split_filter_blocks(filters) { let mut ip: Option = None; @@ -384,11 +482,46 @@ fn extract_filter_ip_class_map(filters: &str) -> BTreeMap { } if let (Some(ip), Some(class_id)) = (ip, class_id) { + if out.contains_key(&ip) { + duplicates.insert(ip.clone()); + } out.insert(ip, class_id); } } - out + (out, duplicates) +} + +fn has_fq_codel_for_parent(raw_qdisc: &str, parent_class: &str) -> bool { + raw_qdisc.lines().map(str::trim).any(|line| { + line.contains("fq_codel") && line.contains("parent") && line.contains(parent_class) + }) +} + +fn parse_root_class_rate_ceil(raw: &str, class_id: &str) -> Option { + for line in raw.lines() { + let t = line.trim(); + if !t.contains("class htb") + || !t.contains(class_id) + || !t.contains(" rate ") + || !t.contains(" ceil ") + { + continue; + } + let toks: Vec<&str> = t.split_whitespace().collect(); + let rate = toks + .iter() + .position(|v| *v == "rate") + .and_then(|i| toks.get(i + 1)) + .map(|s| s.to_string())?; + let ceil = toks + .iter() + .position(|v| *v == "ceil") + .and_then(|i| toks.get(i + 1)) + .map(|s| s.to_string())?; + return Some(ClassRateCeil { rate, ceil }); + } + None } fn split_filter_blocks(raw: &str) -> Vec { @@ -649,6 +782,7 @@ fn apply_full_topology( iface, ifb, cfg.traffic.class_start_id, + &cfg.traffic.root_rate, &cfg.traffic.peer_limit, peers, )?; @@ -742,18 +876,42 @@ mod tests { #[test] fn parse_tc_u32_hex_ipv4() { let raw = " match 0a080105/ffffffff at 16\n flowid 1:42"; - let m = extract_filter_ip_class_map(raw); + let (m, dup) = extract_filter_ip_class_map(raw); + assert!(dup.is_empty()); assert_eq!(m.get("10.8.1.5").copied(), Some(42)); } #[test] fn parse_block_based_filters() { let raw = "filter parent 1: protocol ip pref 1 u32\n match 0a080102/ffffffff at 16\n flowid 1:10\nfilter parent 1: protocol ip pref 2 u32\n match 0a080103/ffffffff at 16\n flowid 1:11"; - let m = extract_filter_ip_class_map(raw); + let (m, dup) = extract_filter_ip_class_map(raw); + assert!(dup.is_empty()); assert_eq!(m.get("10.8.1.2").copied(), Some(10)); assert_eq!(m.get("10.8.1.3").copied(), Some(11)); } + #[test] + fn detect_duplicate_filter_ip() { + let raw = "filter parent 1: protocol ip pref 1 u32\n match 0a080102/ffffffff at 16\n flowid 1:10\nfilter parent 1: protocol ip pref 2 u32\n match 0a080102/ffffffff at 16\n flowid 1:10"; + let (_m, dup) = extract_filter_ip_class_map(raw); + assert!(dup.contains("10.8.1.2")); + } + + #[test] + fn detect_fq_codel_parent() { + let raw = "qdisc fq_codel 8001: dev wg0 parent 1:10 limit 10240p"; + assert!(has_fq_codel_for_parent(raw, "1:10")); + assert!(!has_fq_codel_for_parent(raw, "1:11")); + } + + #[test] + fn parse_root_rate_ceil() { + let raw = "class htb 1:1 root rate 1000Mbit ceil 1000Mbit"; + let rc = parse_root_class_rate_ceil(raw, "1:1").expect("root class exists"); + assert_eq!(normalize_tc_rate(&rc.rate), "1000mbit"); + assert_eq!(normalize_tc_rate(&rc.ceil), "1000mbit"); + } + #[test] fn parse_class_rate_ceil() { let raw = "class htb 1:10 parent 1:1 rate 70Mbit ceil 70Mbit burst 1599b cburst 1599b\nclass htb 1:1 root rate 1000Mbit ceil 1000Mbit"; diff --git a/traffic_core/src/wg.rs b/traffic_core/src/wg.rs index 9f0c885..8354cf4 100644 --- a/traffic_core/src/wg.rs +++ b/traffic_core/src/wg.rs @@ -28,11 +28,13 @@ pub fn discover_peers_with_transport( ) -> anyhow::Result> { let primary = transport .run(&["wg", "show", "all", "allowed-ips"]) - .unwrap_or_default(); + .map_err(|e| anyhow::anyhow!("wg show all allowed-ips failed: {e}"))?; let mut peers = parse_allowed_ips(&primary, peer_cidr)?; if peers.is_empty() { - let fallback = transport.run(&["wg", "show"]).unwrap_or_default(); + let fallback = transport + .run(&["wg", "show"]) + .map_err(|e| anyhow::anyhow!("wg show fallback failed: {e}"))?; peers = parse_fallback_wg_show(&fallback, peer_cidr)?; }