diff --git a/awg2-harness.service b/awg2-harness.service index b158f5b..709872e 100644 --- a/awg2-harness.service +++ b/awg2-harness.service @@ -7,7 +7,8 @@ Requires=docker.service [Service] Type=simple User=root -ExecStart=/usr/sbin/awg2-harness daemon --config /etc/awg2-harness/config.toml +ExecStartPre=/usr/sbin/awg2-harness --config /etc/awg2-harness/config.toml ensure-swap +ExecStart=/usr/sbin/awg2-harness --config /etc/awg2-harness/config.toml daemon Restart=always RestartSec=5 StandardOutput=journal diff --git a/config.example.toml b/config.example.toml index 0695c24..edc8c6c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -28,5 +28,5 @@ window_end_utc = "02:00" enabled = true path = "/swapfile" size_mb = 2048 -priority = -2 +priority = 1 swappiness = 1 diff --git a/docs/ACCEPTANCE_CHECKLIST.md b/docs/ACCEPTANCE_CHECKLIST.md index 2d0f1b8..78dd9c7 100644 --- a/docs/ACCEPTANCE_CHECKLIST.md +++ b/docs/ACCEPTANCE_CHECKLIST.md @@ -89,11 +89,12 @@ - [ ] `ensure-swap` создаёт `/swapfile` размером 2048 MB. - [ ] Права `/swapfile` — `0600`. -- [ ] `swapon --show` показывает активный swap. -- [ ] В `/etc/fstab` нет дублей. +- [ ] Если до запуска был активен другой swap, после `ensure-swap` он выключен. +- [ ] После `ensure-swap` активен ровно один swap: `/swapfile`. +- [ ] Все старые swap entries удалены из `/etc/fstab`. +- [ ] Для block-device swap выполняется только `swapoff` и удаление `fstab` entry; block device не удаляется. - [ ] `vm.swappiness = 1` применён. -- [ ] Повторный `ensure-swap` не пересоздаёт корректный swap. -- [ ] Несовместимый существующий swap не удаляется молча. +- [ ] Повторный `ensure-swap` ничего не пересоздаёт и не добавляет дублей. ## I. Anti-torrent nftables guard diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b556df6..212b609 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -35,7 +35,7 @@ window_end_utc = "02:00" enabled = true path = "/swapfile" size_mb = 2048 -priority = -2 +priority = 1 swappiness = 1 ``` @@ -82,7 +82,7 @@ swappiness = 1 `swap.enabled` — включает setup-задачу swap. -`swap.path`, `swap.size_mb`, `swap.priority`, `swap.swappiness` — параметры аварийного swap. Default: 2 ГБ, низкий priority, `swappiness = 1`. +`swap.path`, `swap.size_mb`, `swap.priority`, `swap.swappiness` — параметры managed swap. При `swap.enabled=true` пакет приводит swap-state к exclusive состоянию: активен ровно один swap (`swap.path`), остальные выключаются и swap-entries в `/etc/fstab` пересобираются в одну managed строку. Рекомендуемый low priority — `1`, `swappiness = 1`. ## Что не выносить в конфиг diff --git a/docs/IMPLEMENTATION_SPEC.md b/docs/IMPLEMENTATION_SPEC.md index 50b69b4..578b9a8 100644 --- a/docs/IMPLEMENTATION_SPEC.md +++ b/docs/IMPLEMENTATION_SPEC.md @@ -31,7 +31,7 @@ It does not replace Amnezia. It controls the surrounding system state that the o Two systemd services run: ```bash -/usr/sbin/awg2-harness daemon --config /etc/awg2-harness/config.toml +/usr/sbin/awg2-harness --config /etc/awg2-harness/config.toml daemon /usr/sbin/awg2-reboot-scheduler --config /etc/awg2-harness/config.toml ``` @@ -440,7 +440,13 @@ Production reboot path is external binary/service `awg2-reboot-scheduler`: - one `/etc/fstab` entry with configured priority; - `vm.swappiness = 1` persisted and applied. -The module must be idempotent. It must not delete unknown swap state automatically. +When `swap.enabled=true`, AWG2 Harness owns host swap state exclusively. +The desired state is exactly one active swap entry: `swap.path`. +All other active swap devices/files are disabled via `swapoff`. +All non-comment swap entries in `/etc/fstab` are removed and replaced by one managed AWG2 entry. +Regular-file swapfiles that are not the configured `swap.path` may be deleted only after successful `swapoff`. +Block devices are never deleted, only disabled and removed from fstab. +The operation must be idempotent. ## 18. Testing diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 5a0115f..efc3ae2 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -54,13 +54,16 @@ install -m 0644 /root/awg2-harness.config.toml /etc/awg2-harness/config.toml ### C3. Validate and apply ```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 +awg2-harness --config /etc/awg2-harness/config.toml validate-config +awg2-harness --config /etc/awg2-harness/config.toml validate-runtime +awg2-harness --config /etc/awg2-harness/config.toml ensure-swap +awg2-harness --config /etc/awg2-harness/config.toml apply-limits +awg2-harness --config /etc/awg2-harness/config.toml apply-firewall +awg2-harness --config /etc/awg2-harness/config.toml status ``` +When `swap.enabled=true`, AWG2 Harness will disable all other active swap entries and rewrite `/etc/fstab` swap entries to a single managed swapfile. + ### C4. Enable services ```bash diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 3eb15e5..df70d2c 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -234,7 +234,8 @@ Requires=docker.service [Service] Type=simple User=root -ExecStart=/usr/sbin/awg2-harness daemon --config /etc/awg2-harness/config.toml +ExecStartPre=/usr/sbin/awg2-harness --config /etc/awg2-harness/config.toml ensure-swap +ExecStart=/usr/sbin/awg2-harness --config /etc/awg2-harness/config.toml daemon Restart=always RestartSec=5 StandardOutput=journal @@ -250,7 +251,7 @@ WantedBy=multi-user.target Логика reboot работает только через встроенный scheduler `traffic_core`. Окно перезагрузки задаётся в конфиге, включая корректные окна через полночь. Механика `systemctl --no-block reboot` сохраняется. -`ensure-swap` должен создать 2 ГБ swap-файл с правами `0600`, добавить одну запись в `/etc/fstab`, включить swap и выставить низкую склонность к использованию (`vm.swappiness = 1`, низкий priority). Если на сервере уже есть несовместимый swap, команда должна сообщить об этом явно, а не разрушать чужое состояние без подтверждения. +`ensure-swap` работает как exclusive reconciler: при `swap.enabled=true` пакет полностью владеет swap-state хоста. После reconcile активен ровно один swap (`swap.path`), все прочие активные swap entries выключены (`swapoff`), а все старые swap-строки в `/etc/fstab` заменены на одну managed-запись AWG2. Для block-device swap выполняются только `swapoff` и удаление записи из `fstab`; device не удаляется. ## 13. Acceptance критерий миграции diff --git a/reboot_module/Cargo.lock b/reboot_module/Cargo.lock deleted file mode 100644 index b1befdd..0000000 --- a/reboot_module/Cargo.lock +++ /dev/null @@ -1,7 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "reboot-scheduler" -version = "0.1.1" diff --git a/tools/build.sh b/tools/build.sh index d2eae9f..2f867ee 100644 --- a/tools/build.sh +++ b/tools/build.sh @@ -225,6 +225,18 @@ prepare_build_gate() { cargo clippy --locked --workspace --all-targets --manifest-path "$WORKSPACE_MANIFEST" -- -D warnings cargo test --locked --workspace --release --manifest-path "$WORKSPACE_MANIFEST" cargo build --locked --workspace --release --manifest-path "$WORKSPACE_MANIFEST" + cargo metadata --locked --manifest-path "$WORKSPACE_MANIFEST" >/dev/null + + if command -v systemd-analyze >/dev/null 2>&1; then + systemd-analyze verify "$ROOT_DIR/awg2-harness.service" + systemd-analyze verify "$ROOT_DIR/awg2-reboot-scheduler.service" + fi + cargo metadata --locked --manifest-path "$WORKSPACE_MANIFEST" >/dev/null + + if command -v systemd-analyze >/dev/null 2>&1; then + systemd-analyze verify "$ROOT_DIR/awg2-harness.service" + systemd-analyze verify "$ROOT_DIR/awg2-reboot-scheduler.service" + fi file "$TARGET_BIN" file "$REBOOT_BIN" @@ -337,7 +349,7 @@ Depends: libc6, iproute2, wireguard-tools, nftables, kmod, util-linux, procps, s Description: Host-side orchestrator for AmneziaWG traffic shaping and lifecycle recovery AWG2 Harness manages AmneziaWG traffic shaping, IFB ingress shaping, nftables anti-torrent guard, reboot scheduling, swap setup and daemonized - drift recovery on dedicated Debian hosts. Includes external reboot scheduler. + drift recovery on dedicated Debian hosts. Includes external reboot scheduler. EOF cat >"$staging/DEBIAN/postinst" <<'EOF' @@ -371,6 +383,10 @@ EOF pkg_path="$DIST_DIR/$pkg_name" dpkg-deb --build "$staging" "$pkg_path" + dpkg-deb --info "$pkg_path" + dpkg-deb --contents "$pkg_path" | sort + dpkg-deb --info "$pkg_path" + dpkg-deb --contents "$pkg_path" | sort ( cd "$DIST_DIR" diff --git a/traffic_core/Cargo.lock b/traffic_core/Cargo.lock deleted file mode 100644 index 1a1993c..0000000 --- a/traffic_core/Cargo.lock +++ /dev/null @@ -1,851 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "awg2-harness" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "clap", - "rand", - "serde", - "serde_json", - "thiserror", - "toml", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "cc" -version = "1.2.62" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "clap" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "is_terminal_polyfill" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasm-bindgen" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "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/src/config.rs b/traffic_core/src/config.rs index 4690bdf..f073165 100644 --- a/traffic_core/src/config.rs +++ b/traffic_core/src/config.rs @@ -95,12 +95,22 @@ impl Config { anyhow::bail!("traffic.reconcile_interval_secs must be > 0"); } if self.swap.enabled { - if self.swap.size_mb == 0 { - anyhow::bail!("swap.size_mb must be > 0 when swap.enabled=true"); + 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"); @@ -140,7 +150,15 @@ impl Config { required.push("systemctl"); } if self.swap.enabled { - required.extend(["fallocate", "chmod", "mkswap", "swapon", "sysctl"]); + required.extend([ + "fallocate", + "chmod", + "mkswap", + "swapon", + "swapoff", + "sysctl", + "dd", + ]); } for tool in required { @@ -209,7 +227,9 @@ fn tool_exists(tool: &str) -> bool { "chmod" => &[&["--help"]], "mkswap" => &[&["--help"]], "swapon" => &[&["--help"]], + "swapoff" => &[&["--help"]], "sysctl" => &[&["--version"], &["--help"]], + "dd" => &[&["--help"]], _ => &[&["--version"], &["--help"], &["-V"]], }; @@ -280,4 +300,44 @@ mod tests { 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 cfg = 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(), + }, + anti_torrent: AntiTorrentConfig { + enabled: false, + max_connections_per_peer: 100, + peer_ipv4_cidr: "10.8.1.0/24".into(), + apply_in_amnezia_netns: false, + }, + reboot: RebootConfig { + enabled: false, + window_start_utc: "01:00".into(), + window_end_utc: "02:00".into(), + }, + swap: SwapConfig { + enabled: true, + path: "/etc/fstab".into(), + size_mb: 2048, + priority: 1, + swappiness: 1, + }, + }; + assert!(cfg.validate_config().is_err()); + } } diff --git a/traffic_core/src/daemon.rs b/traffic_core/src/daemon.rs index 25bb1c9..b907705 100644 --- a/traffic_core/src/daemon.rs +++ b/traffic_core/src/daemon.rs @@ -6,7 +6,7 @@ use std::{ time::{Duration, Instant}, }; -use crate::{config::Config, docker, firewall, iface, netns::build_transport, traffic}; +use crate::{config::Config, docker, firewall, iface, netns::build_transport, swap, traffic}; use tracing::{error, info}; #[derive(Debug, Clone, Default)] @@ -26,6 +26,15 @@ pub fn run(cfg: Config) -> anyhow::Result<()> { info!("starting daemon"); let state = Arc::new(Mutex::new(LastActionState::default())); + if let Err(e) = swap::ensure_swap(&cfg) { + set_err(&state, format!("startup swap reconcile: {e}")); + append_event(&format!("startup_swap error=\"{e}\"")); + return Err(e); + } else { + set_ok(&state, "startup swap reconcile ok"); + append_event("startup_swap ok"); + } + if let Err(e) = traffic::reconcile_traffic(&cfg) { set_err(&state, format!("startup traffic reconcile: {e}")); append_event(&format!("startup_reconcile traffic=error error=\"{e}\"")); @@ -60,6 +69,15 @@ pub fn run(cfg: Config) -> anyhow::Result<()> { }); loop { + if let Err(e) = swap::ensure_swap(&cfg) { + error!(error = %e, "swap reconcile error"); + set_err(&state, format!("periodic swap reconcile: {e}")); + append_event(&format!("periodic_reconcile swap=error error=\"{e}\"")); + } else { + set_ok(&state, "periodic swap reconcile ok"); + append_event("periodic_reconcile swap=ok"); + } + if let Err(e) = traffic::reconcile_traffic(&cfg) { error!(error = %e, "traffic reconcile error"); set_err(&state, format!("periodic traffic reconcile: {e}")); @@ -199,7 +217,11 @@ fn append_event(event: &str) { } } - if let Ok(mut f) = fs::OpenOptions::new().create(true).write(true).truncate(true).open(EVENTS_FILE) + if let Ok(mut f) = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(EVENTS_FILE) { let _ = f.write_all(content.as_bytes()); } diff --git a/traffic_core/src/firewall.rs b/traffic_core/src/firewall.rs index 6e95251..8a85c3c 100644 --- a/traffic_core/src/firewall.rs +++ b/traffic_core/src/firewall.rs @@ -1,9 +1,11 @@ use crate::{ config::Config, docker, + lock::FileLock, netns::{NsTransport, build_host_transport, build_transport}, }; use serde_json::Value; +use std::time::Duration; #[derive(Debug, Clone)] struct FirewallActual { @@ -20,6 +22,9 @@ struct FirewallActual { } pub fn apply_firewall(cfg: &Config) -> anyhow::Result<()> { + let _lock = + FileLock::acquire_timeout("/run/awg2-harness/firewall.lock", Duration::from_secs(10))?; + if !cfg.anti_torrent.enabled { apply_firewall_disabled(cfg)?; return Ok(()); @@ -94,6 +99,13 @@ fn apply_firewall_disabled(cfg: &Config) -> anyhow::Result<()> { } fn inspect_firewall(transport: &dyn NsTransport, cfg: &Config) -> anyhow::Result { + let json = transport.run(&["nft", "-j", "list", "table", "inet", "awg2_harness"]); + if let Ok(raw) = json { + if let Some(actual) = inspect_firewall_json(&raw, cfg)? { + return Ok(actual); + } + } + let table = match transport.run(&["nft", "list", "table", "inet", "awg2_harness"]) { Ok(out) => out, Err(e) if is_nft_not_found(&e.to_string()) => { @@ -113,15 +125,16 @@ fn inspect_firewall(transport: &dyn NsTransport, cfg: &Config) -> anyhow::Result Err(e) => return Err(anyhow::anyhow!("nft table inspect failed: {e}")), }; - let table_exists = !table.trim().is_empty(); - let counter_exists = table.contains("counter antitorrent_dropped_v4"); - let chain_exists = table.contains("chain forward_antitorrent"); - let hook_forward_present = table.contains("hook forward"); - let policy_accept_present = table.contains("policy accept"); - let ct_state_new_present = table.contains("ct state new"); - let meter_present = table.contains("meter awg2_peer_connlimit_v4"); - let counter_name_used = table.contains("counter name antitorrent_dropped_v4"); - let drop_present = table.contains(" drop") || table.contains("\ndrop"); + let nt = normalize_nft_text(&table); + let table_exists = !nt.trim().is_empty(); + let counter_exists = nt.contains("counter antitorrent_dropped_v4"); + let chain_exists = nt.contains("chain forward_antitorrent"); + let hook_forward_present = nt.contains("hook forward"); + let policy_accept_present = nt.contains("policy accept"); + let ct_state_new_present = nt.contains("ct state new"); + let meter_present = nt.contains("meter awg2_peer_connlimit_v4"); + let counter_name_used = nt.contains("counter name antitorrent_dropped_v4"); + let drop_present = nt.contains(" drop"); let rule_matches = firewall_rule_contract_matches( &table, @@ -292,12 +305,15 @@ fn firewall_drift_reason(actual: &FirewallActual) -> String { } fn firewall_rule_contract_matches(table: &str, cidr: &str, max_conn: u32) -> bool { + let table = normalize_nft_text(table); let needle_cidr = format!("ip saddr {cidr}"); let needle_max = format!("ct count over {max_conn}"); table.contains("table inet awg2_harness") && table.contains("counter antitorrent_dropped_v4") && table.contains("chain forward_antitorrent") - && table.contains("type filter hook forward priority filter; policy accept") + && table.contains("type filter hook forward") + && (table.contains("priority filter") || table.contains("priority 0")) + && table.contains("policy accept") && table.contains(&needle_cidr) && table.contains("ct state new") && table.contains("meter awg2_peer_connlimit_v4") @@ -306,6 +322,48 @@ fn firewall_rule_contract_matches(table: &str, cidr: &str, max_conn: u32) -> boo && table.contains("drop") } +fn normalize_nft_text(input: &str) -> String { + input + .replace(['\n', '\t', '{', '}', ';', ','], " ") + .split_whitespace() + .collect::>() + .join(" ") +} + +fn inspect_firewall_json(raw: &str, cfg: &Config) -> 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); + }; + let mut text = String::new(); + for item in arr { + text.push_str(&item.to_string()); + text.push(' '); + } + let nt = normalize_nft_text(&text); + let table_exists = nt.contains("awg2_harness"); + if !table_exists { + return Ok(None); + } + let actual = FirewallActual { + table_exists, + counter_exists: nt.contains("antitorrent_dropped_v4"), + chain_exists: nt.contains("forward_antitorrent"), + rule_matches: firewall_rule_contract_matches( + &nt, + &cfg.anti_torrent.peer_ipv4_cidr, + cfg.anti_torrent.max_connections_per_peer, + ), + hook_forward_present: nt.contains("hook") && nt.contains("forward"), + policy_accept_present: nt.contains("policy") && nt.contains("accept"), + ct_state_new_present: nt.contains("ct") && nt.contains("state") && nt.contains("new"), + meter_present: nt.contains("awg2_peer_connlimit_v4"), + counter_name_used: nt.contains("antitorrent_dropped_v4"), + drop_present: nt.contains("drop"), + }; + Ok(Some(actual)) +} + pub fn antitorrent_drop_counter(cfg: &Config) -> anyhow::Result> { if !cfg.anti_torrent.enabled { return Ok(None); @@ -412,4 +470,18 @@ table inet awg2_harness { "#; assert!(!firewall_rule_contract_matches(table, "10.8.1.0/24", 100)); } + + #[test] + fn nft_text_parser_tolerates_priority_0_and_spacing() { + let table = r#" +table inet awg2_harness { + counter antitorrent_dropped_v4 + chain forward_antitorrent { + type filter hook forward priority 0; policy accept; + ip saddr 10.8.1.0/24 ct state new meter awg2_peer_connlimit_v4 { ip saddr ct count over 100 } counter name antitorrent_dropped_v4 drop + } +} +"#; + assert!(firewall_rule_contract_matches(table, "10.8.1.0/24", 100)); + } } diff --git a/traffic_core/src/lock.rs b/traffic_core/src/lock.rs index c12fbe5..b75718e 100644 --- a/traffic_core/src/lock.rs +++ b/traffic_core/src/lock.rs @@ -2,6 +2,8 @@ use std::{ fs::{self, OpenOptions}, io::Write, path::Path, + thread, + time::{Duration, Instant}, }; pub struct FileLock { @@ -10,6 +12,29 @@ pub struct FileLock { impl FileLock { pub fn acquire(path: &str) -> anyhow::Result { + Self::acquire_timeout(path, Duration::from_secs(0)) + } + + pub fn acquire_timeout(path: &str, timeout: Duration) -> anyhow::Result { + let deadline = Instant::now() + timeout; + loop { + match Self::try_acquire_once(path) { + Ok(lock) => return Ok(lock), + Err(e) => { + let msg = e.to_string(); + if !msg.contains("lock already exists") { + return Err(e); + } + if Instant::now() >= deadline { + anyhow::bail!("lock busy timeout: {path}"); + } + thread::sleep(Duration::from_millis(100)); + } + } + } + } + + fn try_acquire_once(path: &str) -> anyhow::Result { let p = Path::new(path); if let Some(parent) = p.parent() { fs::create_dir_all(parent)?; @@ -66,4 +91,12 @@ mod tests { let second = FileLock::acquire(&p); assert!(second.is_err()); } + + #[test] + fn acquire_timeout_times_out_for_live_lock() { + let p = format!("/tmp/awg2_harness_lock_test_timeout_{}", std::process::id()); + let _first = FileLock::acquire(&p).expect("first lock"); + let second = FileLock::acquire_timeout(&p, Duration::from_millis(250)); + assert!(second.is_err()); + } } diff --git a/traffic_core/src/status.rs b/traffic_core/src/status.rs index 486250d..067384b 100644 --- a/traffic_core/src/status.rs +++ b/traffic_core/src/status.rs @@ -1,4 +1,4 @@ -use crate::{config::Config, docker, firewall, netns::build_transport, traffic, wg}; +use crate::{config::Config, docker, firewall, netns::build_transport, swap, traffic, wg}; use std::{ collections::BTreeMap, @@ -183,12 +183,7 @@ 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); + print_swap_status(cfg); let ct_count = Command::new("sysctl") .args(["-n", "net.netfilter.nf_conntrack_count"]) @@ -265,11 +260,7 @@ fn print_degraded_sections(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); + print_swap_status(cfg); println!("Conntrack:"); let ct_count = Command::new("sysctl") @@ -336,6 +327,51 @@ fn print_reboot_section(cfg: &Config) { } } +fn print_swap_status(cfg: &Config) { + println!("Swap:"); + match swap::inspect_swap(cfg) { + Ok(s) => { + println!(" enabled: {}", s.enabled); + println!(" desired_path: {}", s.desired_path.display()); + println!(" desired_size_mb: {}", cfg.swap.size_mb); + println!(" desired_priority: {}", cfg.swap.priority); + println!(" active_entries: {}", s.active_entries.len()); + println!( + " desired_active: {}", + if s.desired_active { "yes" } else { "no" } + ); + println!(" exclusive: {}", if s.exclusive { "yes" } else { "no" }); + println!( + " fstab_exclusive: {}", + if s.fstab_exclusive { "yes" } else { "no" } + ); + println!( + " swappiness_current: {}", + s.current_swappiness + .map(|v| v.to_string()) + .unwrap_or_else(|| "n/a".to_string()) + ); + println!( + " swappiness_persisted: {}", + s.persisted_swappiness + .map(|v| v.to_string()) + .unwrap_or_else(|| "n/a".to_string()) + ); + if let Some(reason) = s.drift_reason { + println!(" drift: yes"); + println!(" drift_reason: {}", reason); + } else { + println!(" drift: no"); + } + } + Err(e) => { + println!(" enabled: {}", cfg.swap.enabled); + println!(" desired_path: {}", cfg.swap.path); + println!(" drift: unknown ({e})"); + } + } +} + fn handshake_is_online(epoch: Option, threshold_secs: u64) -> &'static str { if let Some(ts) = epoch { let now = SystemTime::now() diff --git a/traffic_core/src/swap.rs b/traffic_core/src/swap.rs index a266636..a169db1 100644 --- a/traffic_core/src/swap.rs +++ b/traffic_core/src/swap.rs @@ -1,61 +1,484 @@ -use std::{fs, process::Command}; +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, + process::Command, + time::Duration, +}; -use crate::config::Config; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use anyhow::Context; + +use crate::{config::Config, lock::FileLock}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActiveSwap { + pub name: PathBuf, + pub kind: String, + pub size_bytes: u64, + pub priority: i32, +} + +#[derive(Debug, Clone)] +struct DesiredSwap { + path: PathBuf, + size_bytes: u64, + priority: i32, + swappiness: u8, +} + +#[derive(Debug, Clone)] +pub struct SwapStatus { + pub enabled: bool, + pub desired_path: PathBuf, + pub active_entries: Vec, + pub desired_active: bool, + pub exclusive: bool, + pub fstab_swap_entries: Vec, + pub fstab_exclusive: bool, + pub current_swappiness: Option, + pub persisted_swappiness: Option, + pub drift_reason: Option, +} pub fn ensure_swap(cfg: &Config) -> anyhow::Result<()> { if !cfg.swap.enabled { return Ok(()); } - let path = &cfg.swap.path; - 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])?; - } + let _lock = FileLock::acquire_timeout("/run/awg2-harness/swap.lock", Duration::from_secs(30))?; - let swapon = Command::new("swapon").args(["--show"]).output()?; - let active = String::from_utf8_lossy(&swapon.stdout); - if !active.contains(path) { - run("swapon", &["-p", &cfg.swap.priority.to_string(), path])?; - } - - ensure_fstab(path, cfg.swap.priority)?; - run( - "sysctl", - &["-w", &format!("vm.swappiness={}", cfg.swap.swappiness)], - )?; - ensure_swappiness_persist(cfg.swap.swappiness)?; + let desired = DesiredSwap::from_config(cfg)?; + let active_before = read_active_swaps()?; + disable_foreign_swaps(&desired, &active_before)?; + ensure_desired_swapfile(&desired)?; + rewrite_fstab_exclusive(&desired)?; + enable_desired_swap(&desired)?; + apply_swappiness(desired.swappiness)?; + verify_exclusive_swap_state(&desired)?; Ok(()) } -fn ensure_fstab(path: &str, priority: i32) -> anyhow::Result<()> { - let file = "/etc/fstab"; - let content = fs::read_to_string(file).unwrap_or_default(); - if content - .lines() - .any(|l| l.contains(path) && l.contains("swap")) +pub fn inspect_swap(cfg: &Config) -> anyhow::Result { + let desired_path = PathBuf::from(cfg.swap.path.clone()); + let active_entries = read_active_swaps()?; + let desired_active = active_entries.iter().any(|s| s.name == desired_path); + let exclusive = active_entries.len() == 1 && desired_active; + let fstab_swap_entries = read_fstab_swap_entries("/etc/fstab")?; + let expected_line = render_fstab_line(&desired_path, cfg.swap.priority); + let fstab_exclusive = fstab_swap_entries.len() == 1 + && normalize_ws(&fstab_swap_entries[0]) + == normalize_ws(&expected_line.trim_end().to_string()); + let current_swappiness = read_current_swappiness().ok(); + let persisted_swappiness = read_persisted_swappiness()?; + + let drift_reason = if !cfg.swap.enabled { + None + } else if !exclusive { + if let Some(foreign) = active_entries.iter().find(|s| s.name != desired_path) { + Some(format!("foreign active swap: {}", foreign.name.display())) + } else { + Some("desired swap is not exclusively active".to_string()) + } + } else if !fstab_exclusive { + Some("/etc/fstab swap entries are not exclusive".to_string()) + } else if current_swappiness != Some(cfg.swap.swappiness) { + Some(format!( + "runtime swappiness mismatch: current={:?} desired={}", + current_swappiness, cfg.swap.swappiness + )) + } else if persisted_swappiness != Some(cfg.swap.swappiness) { + Some(format!( + "persisted swappiness mismatch: persisted={:?} desired={}", + persisted_swappiness, cfg.swap.swappiness + )) + } else { + None + }; + + Ok(SwapStatus { + enabled: cfg.swap.enabled, + desired_path, + active_entries, + desired_active, + exclusive, + fstab_swap_entries, + fstab_exclusive, + current_swappiness, + persisted_swappiness, + drift_reason, + }) +} + +impl DesiredSwap { + fn from_config(cfg: &Config) -> anyhow::Result { + let path = PathBuf::from(cfg.swap.path.clone()); + if !path.is_absolute() { + anyhow::bail!("swap.path must be absolute: {}", path.display()); + } + let size_bytes = cfg + .swap + .size_mb + .checked_mul(1024 * 1024) + .ok_or_else(|| anyhow::anyhow!("swap.size_mb overflow"))?; + Ok(Self { + path, + size_bytes, + priority: cfg.swap.priority, + swappiness: cfg.swap.swappiness, + }) + } +} + +fn read_active_swaps() -> anyhow::Result> { + let out = Command::new("swapon") + .args([ + "--show", + "--bytes", + "--raw", + "--noheadings", + "--output", + "NAME,TYPE,SIZE,PRIO", + ]) + .output()?; + if !out.status.success() { + anyhow::bail!( + "swapon --show failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + parse_swapon_output(&String::from_utf8_lossy(&out.stdout)) +} + +fn parse_swapon_output(raw: &str) -> anyhow::Result> { + let mut out = Vec::new(); + for line in raw.lines() { + let t = line.trim(); + if t.is_empty() { + continue; + } + let parts: Vec<&str> = t.split_whitespace().collect(); + if parts.len() != 4 { + anyhow::bail!("malformed swapon row: {t}"); + } + let size_bytes = parts[2] + .parse::() + .map_err(|_| anyhow::anyhow!("malformed swapon size: {}", parts[2]))?; + let priority = parts[3] + .parse::() + .map_err(|_| anyhow::anyhow!("malformed swapon priority: {}", parts[3]))?; + out.push(ActiveSwap { + name: PathBuf::from(parts[0]), + kind: parts[1].to_string(), + size_bytes, + priority, + }); + } + Ok(out) +} + +fn disable_foreign_swaps(desired: &DesiredSwap, active: &[ActiveSwap]) -> anyhow::Result<()> { + for entry in active { + if entry.name != desired.path { + run("swapoff", &[entry.name.to_string_lossy().as_ref()])?; + maybe_delete_foreign_swapfile(entry, &desired.path)?; + } else if entry.size_bytes != desired.size_bytes || entry.priority != desired.priority { + run("swapoff", &[desired.path.to_string_lossy().as_ref()])?; + } + } + Ok(()) +} + +fn maybe_delete_foreign_swapfile(entry: &ActiveSwap, desired_path: &Path) -> anyhow::Result<()> { + if entry.name == desired_path { + return Ok(()); + } + let md = match fs::symlink_metadata(&entry.name) { + Ok(v) => v, + Err(_) => return Ok(()), + }; + if md.file_type().is_symlink() { + return Ok(()); + } + if md.file_type().is_file() { + let _ = fs::remove_file(&entry.name); + } + Ok(()) +} + +fn ensure_desired_swapfile(desired: &DesiredSwap) -> anyhow::Result<()> { + let p = &desired.path; + if !p.is_absolute() { + anyhow::bail!("swap.path must be absolute: {}", p.display()); + } + + let mut need_create = true; + if p.exists() { + let md = fs::symlink_metadata(p)?; + if md.file_type().is_symlink() { + anyhow::bail!("desired swap path is symlink: {}", p.display()); + } + if !md.file_type().is_file() { + anyhow::bail!("desired swap path is not regular file: {}", p.display()); + } + + if md.len() == desired.size_bytes { + need_create = false; + } else { + fs::remove_file(p)?; + } + } + + if need_create { + create_swapfile(p, desired.size_bytes)?; + run("mkswap", &[p.to_string_lossy().as_ref()])?; + } + + #[cfg(unix)] + { + fs::set_permissions(p, fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +fn create_swapfile(path: &Path, size_bytes: u64) -> anyhow::Result<()> { + let size_mb = size_bytes / (1024 * 1024); + let p = path.to_string_lossy().to_string(); + + let out = Command::new("fallocate") + .args(["-l", &format!("{}M", size_mb), &p]) + .output()?; + if out.status.success() { + return Ok(()); + } + + let out_dd = Command::new("dd") + .args([ + "if=/dev/zero", + &format!("of={p}"), + "bs=1M", + &format!("count={size_mb}"), + "status=none", + ]) + .output()?; + if !out_dd.status.success() { + anyhow::bail!( + "swapfile create failed (fallocate+dd): fallocate={}, dd={}", + String::from_utf8_lossy(&out.stderr), + String::from_utf8_lossy(&out_dd.stderr) + ); + } + Ok(()) +} + +fn rewrite_fstab_exclusive(desired: &DesiredSwap) -> anyhow::Result<()> { + let fstab_path = "/etc/fstab"; + let bak_path = "/etc/fstab.awg2-harness.bak"; + let tmp_path = "/etc/fstab.awg2-harness.tmp"; + + let content = fs::read_to_string(fstab_path).unwrap_or_default(); + let rewritten = rewrite_fstab_content(&content, &desired.path, desired.priority); + if rewritten == content { + return Ok(()); + } + + let _ = fs::write(bak_path, &content); + let mut f = fs::File::create(tmp_path)?; + f.write_all(rewritten.as_bytes())?; + f.sync_all()?; + drop(f); + fs::rename(tmp_path, fstab_path)?; + Ok(()) +} + +fn rewrite_fstab_content(content: &str, desired_path: &Path, priority: i32) -> String { + let mut out = Vec::::new(); + for line in content.lines() { + let t = line.trim(); + if t.is_empty() || t.starts_with('#') { + out.push(line.to_string()); + continue; + } + let cols: Vec<&str> = t.split_whitespace().collect(); + if cols.len() >= 3 && cols[2] == "swap" { + continue; + } + out.push(line.to_string()); + } + out.push( + render_fstab_line(desired_path, priority) + .trim_end() + .to_string(), + ); + let mut s = out.join("\n"); + if !s.ends_with('\n') { + s.push('\n'); + } + s +} + +fn render_fstab_line(path: &Path, priority: i32) -> String { + format!("{} none swap sw,pri={} 0 0\n", path.display(), priority) +} + +fn enable_desired_swap(desired: &DesiredSwap) -> anyhow::Result<()> { + let active = read_active_swaps()?; + if let Some(found) = active.iter().find(|v| v.name == desired.path) + && found.size_bytes == desired.size_bytes + && found.priority == desired.priority { return Ok(()); } - let line = format!("{path} none swap sw,pri={priority} 0 0\n"); - fs::write(file, format!("{}{}", content, line))?; + + run( + "swapon", + &[ + "-p", + &desired.priority.to_string(), + desired.path.to_string_lossy().as_ref(), + ], + )?; Ok(()) } +fn apply_swappiness(v: u8) -> anyhow::Result<()> { + run("sysctl", &["-w", &format!("vm.swappiness={v}")])?; + let current = read_current_swappiness()?; + if current != v { + anyhow::bail!("failed to apply vm.swappiness: got {current}, expected {v}"); + } + + let conf_path = "/etc/sysctl.d/99-awg2-harness.conf"; + let expected = format!("# Managed by awg2-harness\nvm.swappiness = {v}\n"); + let current_file = fs::read_to_string(conf_path).unwrap_or_default(); + if current_file != expected { + fs::write(conf_path, expected)?; + } + + let persisted = read_persisted_swappiness()?; + if persisted != Some(v) { + anyhow::bail!( + "failed to persist vm.swappiness: got {:?}, expected {}", + persisted, + v + ); + } + Ok(()) +} + +pub fn read_current_swappiness() -> anyhow::Result { + let out = Command::new("sysctl") + .args(["-n", "vm.swappiness"]) + .output()?; + if !out.status.success() { + anyhow::bail!("sysctl read vm.swappiness failed"); + } + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + s.parse::() + .with_context(|| format!("invalid vm.swappiness value: {s}")) +} + +pub fn read_persisted_swappiness() -> anyhow::Result> { + let conf_path = "/etc/sysctl.d/99-awg2-harness.conf"; + let content = match fs::read_to_string(conf_path) { + Ok(v) => v, + Err(_) => return Ok(None), + }; + for line in content.lines() { + let t = line.trim(); + if t.is_empty() || t.starts_with('#') { + continue; + } + let Some((k, v)) = t.split_once('=') else { + continue; + }; + if k.trim() == "vm.swappiness" { + return Ok(Some(v.trim().parse::()?)); + } + } + Ok(None) +} + +fn verify_exclusive_swap_state(desired: &DesiredSwap) -> anyhow::Result<()> { + let active = read_active_swaps()?; + if active.len() != 1 { + anyhow::bail!( + "swap verify failed: expected exactly one active swap, got {}", + active.len() + ); + } + let only = &active[0]; + if only.name != desired.path { + anyhow::bail!( + "swap verify failed: active swap {} != desired {}", + only.name.display(), + desired.path.display() + ); + } + if only.size_bytes != desired.size_bytes { + anyhow::bail!( + "swap verify failed: size mismatch {} != {}", + only.size_bytes, + desired.size_bytes + ); + } + if only.priority != desired.priority { + anyhow::bail!( + "swap verify failed: priority mismatch {} != {}", + only.priority, + desired.priority + ); + } + + let entries = read_fstab_swap_entries("/etc/fstab")?; + if entries.len() != 1 { + anyhow::bail!( + "swap verify failed: expected one fstab swap entry, got {}", + entries.len() + ); + } + let expected = render_fstab_line(&desired.path, desired.priority); + if normalize_ws(&entries[0]) != normalize_ws(expected.trim_end()) { + anyhow::bail!( + "swap verify failed: fstab entry mismatch: got='{}' expected='{}'", + entries[0], + expected.trim_end() + ); + } + + let current = read_current_swappiness()?; + if current != desired.swappiness { + anyhow::bail!( + "swap verify failed: vm.swappiness current={} expected={}", + current, + desired.swappiness + ); + } + Ok(()) +} + +fn read_fstab_swap_entries(path: &str) -> anyhow::Result> { + let content = fs::read_to_string(path).unwrap_or_default(); + let mut out = Vec::new(); + for line in content.lines() { + let t = line.trim(); + if t.is_empty() || t.starts_with('#') { + continue; + } + let cols: Vec<&str> = t.split_whitespace().collect(); + if cols.len() >= 3 && cols[2] == "swap" { + out.push(t.to_string()); + } + } + Ok(out) +} + +fn normalize_ws(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + fn run(program: &str, args: &[&str]) -> anyhow::Result<()> { let out = Command::new(program).args(args).output()?; if !out.status.success() { @@ -69,12 +492,79 @@ 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)?; +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_swapon_empty() { + let got = parse_swapon_output("").expect("parse"); + assert!(got.is_empty()); + } + + #[test] + fn parse_swapon_single_file() { + let got = parse_swapon_output("/swapfile file 2147479552 1\n").expect("parse"); + assert_eq!(got.len(), 1); + assert_eq!(got[0].name, PathBuf::from("/swapfile")); + assert_eq!(got[0].kind, "file"); + assert_eq!(got[0].size_bytes, 2147479552); + assert_eq!(got[0].priority, 1); + } + + #[test] + fn parse_swapon_multiple_entries() { + let raw = "/swapfile file 2147479552 1\n/dev/zram0 partition 1073741824 100\n"; + let got = parse_swapon_output(raw).expect("parse"); + assert_eq!(got.len(), 2); + } + + #[test] + fn parse_swapon_rejects_malformed_size() { + let err = parse_swapon_output("/swapfile file abc 1\n"); + assert!(err.is_err()); + } + + #[test] + fn fstab_rewrite_removes_all_swap_entries() { + let raw = + "UUID=x / ext4 defaults 0 1\n/swap-old none swap sw 0 0\n/dev/zram0 none swap sw 0 0\n"; + let out = rewrite_fstab_content(raw, Path::new("/swapfile"), 1); + assert!(!out.contains("/swap-old")); + assert!(!out.contains("/dev/zram0")); + assert!(out.contains("/swapfile none swap sw,pri=1 0 0")); + } + + #[test] + fn fstab_rewrite_preserves_comments_and_mounts() { + let raw = "# head\nUUID=x / ext4 defaults 0 1\n\n# tail\n"; + let out = rewrite_fstab_content(raw, Path::new("/swapfile"), 1); + assert!(out.contains("# head")); + assert!(out.contains("UUID=x / ext4 defaults 0 1")); + assert!(out.contains("# tail")); + } + + #[test] + fn fstab_rewrite_is_idempotent() { + let raw = "UUID=x / ext4 defaults 0 1\n/swapfile none swap sw,pri=1 0 0\n"; + let one = rewrite_fstab_content(raw, Path::new("/swapfile"), 1); + let two = rewrite_fstab_content(&one, Path::new("/swapfile"), 1); + assert_eq!(one, two); + } + + #[test] + fn fstab_rewrite_adds_trailing_newline() { + let raw = "UUID=x / ext4 defaults 0 1"; + let out = rewrite_fstab_content(raw, Path::new("/swapfile"), 1); + assert!(out.ends_with('\n')); + } + + #[test] + fn swappiness_conf_render_is_stable() { + let expected = "# Managed by awg2-harness\nvm.swappiness = 1\n"; + assert_eq!( + expected, + format!("# Managed by awg2-harness\nvm.swappiness = {}\n", 1) + ); } - Ok(()) } diff --git a/traffic_core/src/traffic.rs b/traffic_core/src/traffic.rs index c78552b..6c9c168 100644 --- a/traffic_core/src/traffic.rs +++ b/traffic_core/src/traffic.rs @@ -9,6 +9,7 @@ use crate::{ use std::{ collections::{BTreeMap, BTreeSet}, net::Ipv4Addr, + time::Duration, }; #[derive(Debug, Clone)] @@ -82,7 +83,8 @@ pub fn reconcile_traffic(cfg: &Config) -> anyhow::Result<()> { return Ok(()); } - let _lock = FileLock::acquire("/run/awg2-harness/traffic.lock")?; + let _lock = + FileLock::acquire_timeout("/run/awg2-harness/traffic.lock", Duration::from_secs(10))?; let target = docker::discover_target(cfg)?; let transport = build_transport(target.container_pid, &target.container_name); let peers = wg::discover_peers( @@ -1022,7 +1024,9 @@ fn parse_tc_rate_bps(v: &str) -> anyhow::Result { } else { anyhow::bail!("unknown tc rate unit: {v}") }; - let base: u64 = num.parse().map_err(|_| anyhow::anyhow!("invalid tc rate: {v}"))?; + let base: u64 = num + .parse() + .map_err(|_| anyhow::anyhow!("invalid tc rate: {v}"))?; base.checked_mul(mul) .ok_or_else(|| anyhow::anyhow!("tc rate overflow: {v}")) } @@ -1085,15 +1089,13 @@ mod tests { #[test] fn detects_ingress_redirect_to_ifb_dev_format_lowercase() { - let raw = - "filter parent ffff: protocol all pref 1 u32\n action order 1: mirred egress redirect dev ifb0"; + let raw = "filter parent ffff: protocol all pref 1 u32\n action order 1: mirred egress redirect dev ifb0"; assert!(has_ingress_redirect_to_ifb(raw, "ifb0")); } #[test] fn does_not_match_redirect_to_other_ifb() { - let raw = - "filter parent ffff: protocol all pref 1 u32\n action order 1: mirred (Egress Redirect to device ifb1) stolen"; + let raw = "filter parent ffff: protocol all pref 1 u32\n action order 1: mirred (Egress Redirect to device ifb1) stolen"; assert!(!has_ingress_redirect_to_ifb(raw, "ifb0")); }