From 6ec593c7834da6f453d95f39560c12709c2dc95b Mon Sep 17 00:00:00 2001 From: sasa Date: Tue, 20 Oct 2026 23:01:43 +0300 Subject: [PATCH] Add runtime observation and systemd restart adapter --- Cargo.lock | 565 +++++++++++++++++++ Cargo.toml | 3 +- PLANS.md | 438 ++++++++++++++- README.md | 32 +- docs/DEPENDENCIES.md | 8 +- docs/PERSISTENCE.md | 13 + docs/RUNTIME.md | 168 ++++++ docs/TESTING.md | 2 +- src/app.rs | 1116 +++++++++++++++++++++++++++++++++++-- src/domain.rs | 2 +- src/lib.rs | 2 + src/runtime.rs | 674 ++++++++++++++++++++++ src/runtime/netlink.rs | 1113 ++++++++++++++++++++++++++++++++++++ src/runtime/transport.rs | 562 +++++++++++++++++++ src/service.rs | 469 ++++++++++++++++ src/service/dbus.rs | 680 ++++++++++++++++++++++ src/wireguard/document.rs | 59 ++ tests/runtime_restart.rs | 161 ++++++ 18 files changed, 6001 insertions(+), 66 deletions(-) create mode 100644 docs/RUNTIME.md create mode 100644 src/runtime.rs create mode 100644 src/runtime/netlink.rs create mode 100644 src/runtime/transport.rs create mode 100644 src/service.rs create mode 100644 src/service/dbus.rs create mode 100644 tests/runtime_restart.rs diff --git a/Cargo.lock b/Cargo.lock index 39e7213..25e7020 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,149 @@ dependencies = [ "rustversion", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base16ct" version = "0.2.0" @@ -148,6 +291,25 @@ dependencies = [ "hybrid-array 0.4.15", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "cfg-if" version = "1.0.5" @@ -189,6 +351,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -228,6 +399,12 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -366,6 +543,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -382,6 +586,26 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -516,6 +740,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -617,6 +854,18 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hkdf" version = "0.12.4" @@ -795,6 +1044,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "keccak" version = "0.1.6" @@ -853,6 +1113,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -908,6 +1177,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "p256" version = "0.13.2" @@ -918,6 +1197,12 @@ dependencies = [ "primeorder", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -977,6 +1262,31 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -1024,6 +1334,15 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-error-attr3" version = "3.1.1" @@ -1277,6 +1596,17 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "serde_spanned" version = "1.1.2" @@ -1318,6 +1648,16 @@ dependencies = [ "keccak", ] +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "slab" version = "0.4.12" @@ -1502,6 +1842,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_parser" version = "1.1.5+spec-1.1.0" @@ -1517,6 +1869,37 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" +[[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 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "type-map" version = "0.5.1" @@ -1532,6 +1915,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys", +] + [[package]] name = "unic-langid" version = "0.9.6" @@ -1573,6 +1967,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" @@ -1604,6 +2009,51 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -1633,6 +2083,9 @@ name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] [[package]] name = "wireguard-admin" @@ -1650,6 +2103,7 @@ dependencies = [ "time", "toml 1.1.8+spec-1.1.0", "x25519-dalek", + "zbus", "zeroize", ] @@ -1671,6 +2125,76 @@ dependencies = [ "zeroize", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.63" @@ -1726,3 +2250,44 @@ dependencies = [ "serde", "zerofrom", ] + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index a55c2b4..256e4e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ age = { version = "=0.12.1", default-features = false } base64 = "=0.22.1" getrandom = "=0.3.4" ipnet = "=2.11.0" -rustix = { version = "=1.1.5", default-features = false, features = ["fs", "process", "std"] } +rustix = { version = "=1.1.5", default-features = false, features = ["event", "fs", "net", "process", "std"] } serde = { version = "=1.0.229", features = ["derive"] } thiserror = "=2.0.20" tar = { version = "=0.4.44", default-features = false } @@ -27,3 +27,4 @@ time = { version = "=0.3.44", default-features = false, features = ["formatting" toml = "=1.1.8" x25519-dalek = { version = "=2.0.1", default-features = false, features = ["static_secrets", "zeroize"] } zeroize = "=1.9.0" +zbus = { version = "=5.19.0", default-features = false, features = ["async-io"] } diff --git a/PLANS.md b/PLANS.md index 564a969..627b761 100644 --- a/PLANS.md +++ b/PLANS.md @@ -114,7 +114,7 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo | 2 | Server configuration editing with byte preservation | 1 | accepted | | 3 | Client keys, encrypted vault, and existing-client import | 2 | accepted | | 4 | Backups, safe Commit, and full restoration | 3 | accepted | -| 5 | Runtime observation and systemd restart adapter | 4 | pending | +| 5 | Runtime observation and systemd restart adapter | 4 | blocked | | 6 | Client configuration delivery and reissue lifecycle | 5 | pending | | 7 | Complete terminal management workflow | 6 | pending | | 8 | Alpine Testcontainers integration | 7; suitable disposable Docker environment | pending | @@ -1391,9 +1391,435 @@ no Git commit, deployment or production access occurred. ### Milestone 5 — Runtime observation and systemd restart adapter -Implement `wireguard-uapi` runtime queries and the `zbus` systemd adapter behind narrow interfaces. Track service job completion and timeout, then verify interface/client correspondence before recording applied state and releasing addresses. Require backup before restart. +Status: accepted (2026-10-20T22:59:39+03:00; scoped fixture acceptance and independent integrated re-review complete). +Prerequisites: milestones 1–4 accepted; pinned native Rust environment and +compatible approved `rustix` and `zbus` dependencies. Ordinary +M5 acceptance uses injected adapters and temporary saved stores without root or +host-service/network access. Real WireGuard and systemd acceptance stays assigned +to milestones 8 and 9 respectively, not silently waived. -Acceptance: ordinary tests exercise successful and failed jobs, missing/mismatched interfaces, unavailable statistics, and no automatic rollback using controlled adapters. Real runtime/systemd acceptance remains explicitly pending until milestones 8 and 9; mocks cannot close those later gates. +#### Outcome and scope + +Deliver a read-only Linux WireGuard runtime adapter, a systemd D-Bus restart +adapter, and application orchestration for an explicitly confirmed restart: +complete backup, restart the configured unit, await its job, verify the configured +interface/peers, persist verification through M4, then publish applied state and +release reserved addresses. Supply safe public runtime/statistics views for the +future TUI without deriving client identity or online status from aliases. + +Read SPEC.md Operations and states, Client screen and statistics, and Backups and +restoration; ADRs 0001 and 0003; PERSISTENCE.md, BACKUP_FORMAT.md, DEVELOPMENT.md, +DEPENDENCIES.md, TESTING.md, and the current application/domain code. Commit and +restore must retain their strict no-runtime-mutation behavior. + +Out of scope: TUI/screens, automatic refresh scheduling in a render loop, delivery +or QR, automatic failed-restart rollback/retry, reload/syncconf as an alternative +apply path, additional service managers/interfaces, production tests, and creation +of the Alpine/VM harnesses. Keep management startup safely incomplete; do not +introduce a new restart CLI or run real adapters in default tests. + +#### Existing code and integration constraints + +- `Application` owns the saved store/bytes, byte-preserving document, `ClientStates`, + vault draft and historical verification. Resolve runtime settings from the + current saved generation, including settings restored from a backup, not a stale + constructor copy or archive-supplied arbitrary bus/service destination. +- `Application::create_pre_restart_backup` archives installed bytes and sets a + pending boundary flag. `mark_verified_after_runtime_check` is crate-private: + it checks that boundary and saved generation, validates an observed Snapshot, + installs verification state through SavedStore's journal, then advances memory. + Wire these existing boundaries into the real sequence; do not bypass them with + a direct state-file write or public "mark verified" setter. +- `PersistedState` v1 holds reservations and a historical UTC timestamp. Reopen is + always Unknown. Commit/restore clear stale verification; M5 records the timestamp + only after matching runtime and successful journaled write. BACKUP_FORMAT.md + ties archive historical status to that saved generation, not merely the date. +- The existing `pre_restart_backup_pending` boolean is not proof of a successful + restart. Scope/invalidate authorization per attempt, failure, cancellation and + saved-generation change; an old successful backup must never authorize later + unrelated verification. Refine the internal contract if needed, preserving M4 + guarantees and tests. No new concurrency/version-control scheme is required. +- `Snapshot` includes aliases and a narrow ClientAddresses model; kernel peers do + not contain aliases or managed comment timestamps. Do not compare raw kernel + structures directly against Snapshot equality or invent runtime metadata. +- M2 `ConfigDocument::peers()` retains full AllowedIPs and redacted issues; some + valid peers cannot project into M1 Client. Include every authoritative peer in + technical verification, or fail closed as unverifiable. Never ignore unsupported + records to obtain a matching reduced subset. Missing/invalid managed alias alone + must not be mistaken for evidence of a different runtime peer. +- Cargo already contains time, rustix, tempfile and persistence/crypto dependencies. + The approved `wireguard-uapi` 3.0.1 public read path cannot guarantee complete, + bounded dumps (M5-R1-04). Use existing approved `rustix` with narrow `net`/`event` + features for a checked read-only Generic Netlink GET adapter; add approved zbus + only when needed. Inspect current official APIs/features and Rust compatibility. + Do not assume older zbus blocking examples + apply to the selected version. No unapproved direct Tokio/executor/mock crate + or utility subprocess fallback is authorized. + +#### Context and implementation steps + +Run tasks sequentially with one writer under the established model policy: +GPT-6-Sol/medium coordinator; GPT-6-Luna/high implementer, independent reviewer and +repairer. Dispatch owned files, exact interfaces, acceptance IDs, checks and +required evidence. Workers do not mark milestones accepted or edit PLANS.md. + +| Task | Owner | Files and deliverable | +| --- | --- | --- | +| M5.1 | implementer | `src/runtime.rs`, narrow domain/application views and `docs/RUNTIME.md`: public observation model, saved technical projection and pure comparison contract | +| M5.2 | implementer | runtime adapter modules, Cargo files/library exports: approved-rustix read-only WireGuard Generic Netlink codec/transport with isolated conversion/error tests; split pure decoder then bounded socket transport | +| M5.3 | implementer | `src/service.rs`, child modules if necessary: zbus systemd restart/job observation with deadline and a mockable transport boundary | +| M5.4 | implementer | `src/app.rs`, narrow M4 state/persistence integration: confirmed restart orchestration, per-attempt authorization, durable verification and honest failure results | +| M5.5 | implementer | `tests/runtime_restart.rs`, test-only adapters, README/RUNTIME/PERSISTENCE documentation: integrated success, order and failure scenarios | +| M5.6 | reviewer, repairer | Independent integrated review, bounded fixes and re-review, including M4 persistence regressions | +| M5.7 | coordinator | Integrated gates, evidence/findings and milestone acceptance/handoff in PLANS.md | + +M5.1 defines public observations keyed by PublicKey, with explicit interface +presence/unavailability, peer AllowedIPs, last handshake, received/transmitted +byte counters, and observation time. Preserve zero/never-handshaken separately +from unavailable data. Counters are current interface values, may reset after a +restart, and are not historical usage or proof of online/offline status. Traffic +is reported from the server interface's perspective. Alias display joins by +public key using saved metadata, never list index or filename. + +Define technical equivalence independently of counters, handshake, peer ordering, +route ordering, aliases, dynamically learned endpoints and timestamps. Verify the +expected configured interface exists and the complete saved peer public-key set +and normalized AllowedIPs match, including absence of deleted/extra peers and +an empty valid peer set. Report missing/extra/mismatched/unverifiable records with +public identities and categories. Duplicate/ambiguous saved identities or partial +runtime dumps cannot pass. Do not claim comparison of fields the adapter cannot +observe. Retain full route sets for legacy peers; enrich M1 projection narrowly +only if required and do not drop valid data to fit Snapshot. + +Document the boundary between technical match and M4's narrow observed Snapshot: +use saved aliases only after technical peer/route equivalence is established, +not as alleged kernel observations. If the complete document cannot be safely +verified/represented for durable marking, keep the operation unverifiable and +reservations retained rather than passing a fabricated subset to M4. + +M5.2 uses the configured interface via Linux WireGuard/netlink APIs only; no set, +add/remove, module loading, host route manipulation or external `wg` invocation. +Handle multipart responses and peer/AllowedIPs fragments according to the +[kernel protocol](https://docs.kernel.org/netlink/specs/wireguard.html); require +matching request sequence, an error-free terminal `NLMSG_DONE`, no interrupted +dump and a complete response before claiming absence or equality. Bound +reads/waits and aggregate limits. Convert no-device, permission, malformed data, +transport failure and timeout to distinct actionable, redacted outcomes. + +WireGuard library structures may include server private or preshared keys even +when only public statistics are needed. Never derive/log their full Debug, retain +them in public observations, or serialize them as status data. Minimize lifetime +and clear supported owned buffers; document unavoidable library copies honestly. +Default tests exercise supplied decoded inputs/test transport, never a host socket. + +M5.3 connects to the system bus and fixed systemd Manager API for the validated +configured unit, requests an actual restart, and follows the returned job to its +terminal result. Subscribe/install observation before submission so immediate +completion cannot be missed; correlate by the actual job and unit, ignore unrelated +signals, and handle disconnection/name-owner changes or subscription failure. +Successful request submission, job disappearance, or a unit already being active +is not completed restart evidence. Accept only the documented successful terminal +result, then let runtime verification decide whether VPN configuration matches. +Account for wg-quick oneshot units that are active/exited; do not demand a daemon +PID or long-running process that WireGuard itself does not require. + +Use a monotonic configured deadline across D-Bus connection/subscription/request, +job waiting and post-job verification rather than resetting it on each retry. +Backup completion precedes service mutation but may have a separate filesystem +failure boundary. A timeout bounds the application's wait, not proof that systemd +stopped its job. Report "may still complete" when appropriate; do not retry a +restart or issue broad cancellation/rollback automatically. Ordinary test code +uses a supplied job event/transport adapter; no real system bus is contacted. + +M5.4 adds explicit preparation/confirmation for restart and draft discard suitable +for a future TUI. Preview/cancel does not save/discard a draft or call systemd. +On confirmed discard-and-restart, reject unresolved persistence recovery, validate +saved input and configured unit, complete the pre-restart backup, discard only +as authorized and restart only the saved configuration. Never implicitly Commit. +A backup failure leaves protected state and draft unchanged and submits no job. + +Check compatibility with service-managed config rewriting, notably wg-quick +SaveConfig behavior: do not edit administrator-owned settings to work around it +or claim saved-generation verification when shutdown has overwritten intended +peer changes. Detect an unsafe/unverifiable apply precondition and explain it; +use a safe test fixture/service setup, not an automatic production setting change. + +Demote stale current verification when a restart is submitted; a failed request, +job, read or comparison cannot leave a misleading previously Verified state. +Keep reservations and recovery information conservative. Read back the complete +runtime after successful job completion within the deadline; do not require a +client to connect or handshake, since correct peers can be idle. Periodic +read-only observations may report correspondence, but must not release deleted +addresses or persist successful restart history by bypassing the explicit sequence. + +Only a matching full observation from the successful attempt can reach M4's +journaled verification boundary. If the saved-generation check or state write +fails after VPN restart, report "runtime changed, verification not durably +recorded" rather than full success. Retain reservations/in-memory safety and +honor RecoveryRequired before later mutations. Do not rollback the VPN, repeat +restart automatically, mark the old history as current, or remove the backup. + +M5.5 uses fake monotonic time and deterministic adapter event sequences, temporary +saved stores and synthetic configuration. Assert call order: backup completed +before restart request; successful job before runtime verification; matched +observation before journaled state publication; persistence success before memory +and reservation release. Failures must be visible by stage, including whether +a restart was submitted/may still be active, backup location, and any required +manual recovery. Avoid sensitive D-Bus/library error text in reports. + +#### Acceptance and validation + +- [x] M5-A1: public read-only observation distinguishes missing/unavailable/unknown, + no handshake and valid zero counters; public-key joins and counter resets do not + invent online state, aliases, traffic history, or successful apply. +- [x] M5-A2: configured-interface runtime adapter consumes complete bounded peer + data, including fragmented route lists, and exposes only public information; + no runtime mutations, secret Debug output or default host access occur. +- [x] M5-A3: pure verification covers dual-stack and IPv4-only peers, reordered + sets, empty peers, missing/extra/deleted peers, route mismatches, malformed or + partial observations, and unsupported saved shapes without subset success. +- [x] M5-A4: service adapter targets only the configured systemd unit, tracks the + returned job without a fast-completion race, and handles unrelated events, + failed/canceled jobs, disconnects, permission errors and monotonic timeout. +- [x] M5-A5: explicit cancellation preserves the draft; confirmed draft discard + never commits. Failed mandatory backup submits no restart and preserves draft, + saved bytes, reservations and previous historical evidence. +- [x] M5-A6: successful restart alone is insufficient: interface/peer verification + must pass before M4 marking; idle clients need no handshake to be applied. +- [x] M5-A7: each failure invalidates that attempt's authorization; a stale backup + flag, old successful job, read-only refresh or old verified Snapshot cannot mark + a later generation applied or release a reserved address. +- [x] M5-A8: verified state and timestamp become visible only after M4 journaled + state persistence succeeds. Injected post-restart write/recovery failure retains + safe reservations and reports changed runtime without false overall success. +- [x] M5-A9: reopening still treats verification timestamps as historical; Commit + and restore retain their no-runtime-mutation behavior and invalidate stale apply + evidence. Archived settings changes select the restored unit/timeout correctly. +- [x] M5-A10: timeouts do not imply a job was canceled; no automatic restart retry, + live setting rewrite, reload/syncconf fallback or VPN rollback is introduced. +- [x] M5-A11: full M1–M4 regressions and integrated M5 fake-adapter tests pass with + no root, Docker, real system bus/interface, production paths or secret reads. +- [x] M5-A12: independent review closes actionable runtime/service/race/persistence + findings; documentation clearly defers real WireGuard checks to M8 and actual + systemd/D-Bus acceptance to M9 rather than claiming them from mocks. + +Planned commands from the repository root once M5 targets/lockfile exist: + +```sh +cargo test --locked runtime +cargo test --locked service +cargo test --locked --test runtime_restart +cargo test --locked --test commit_restore +cargo test --locked --test config_preservation +cargo test --locked --test vault_import +cargo fmt --all -- --check +cargo clippy --locked --all-targets -- -D warnings +cargo test --locked +cargo build --locked --release +./target/release/wireguard-admin --help +./target/release/wireguard-admin --version +git diff --check +``` + +Ensure filters execute actual tests and report counts and exit status. Resolve +approved dependencies and update Cargo.lock before locked gates. Default tests +must remain deterministic, unprivileged and independent of a daemon/bus/network. +No bare management launch, actual restart, or host WireGuard probing is permitted. + +#### Progress + +- [x] Description prepared against accepted M1–M4 interfaces and current documents. +- [x] 2026-10-20T20:44:56+03:00 preparation: confirmed M1–M4 accepted in this plan, inspected the worktree and role configuration; stopped before dispatch because the active coordinator model/effort is not exposed for verification. +- [x] 2026-10-20T21:39:34+03:00 execution resumed: verified the active session's latest turn context as `gpt-6-sol`/`medium` in its local session record, and confirmed the four configured roles use `gpt-6-luna`/`high`; reread required project documents and inspected the actual repository. The earlier model-verification blocker is resolved. +- [x] M5.1 — implementer delivered pure public observation and full saved-peer comparison in `src/runtime.rs`, `docs/RUNTIME.md` and library export. Independent R1 review found wrong-interface acceptance and two test gaps; repairer fixed all three; fresh R2 re-review found no remaining actionable M5.1 finding. Final `cargo test --locked runtime` passed (14 matching tests, including 10 runtime-module tests; 104 library tests filtered). This is partial M5 work, not milestone acceptance. +- [x] M5.2 — after confirming `wireguard-uapi` 3.0.1's incomplete/unbounded public read API, a read-only explorer identified existing approved `rustix` `net`/`event` as a bounded transport route. Implementers delivered a checked pure decoder (`src/runtime/netlink.rs`) and nonblocking read-only socket transport (`src/runtime/transport.rs`) with synthetic/fake-I/O tests. Independent R2 and R3 reviews found three on-wire/error defects; repairer fixed all, and independent re-review closed them. Latest focused `cargo test --locked runtime` passed 29 matching tests; fmt, Clippy and diff checks passed. No host socket or real WireGuard behavior was tested; M8 remains responsible for isolated real adapter validation. +- [x] M5.3 — approved `zbus` 5.19.0 async-io adapter and pure job tracker implemented in `src/service.rs` and `src/service/dbus.rs`, with a shared monotonic deadline and signals installed before restart. Independent R1 review found alias/primary-unit mismatch; repairer resolved `GetUnit` + `Unit.Id` before restart and R2 re-review closed the finding. `cargo test --locked service::` passed 18 fake/pure tests; fmt, Clippy and diff checks passed. No system bus or real service was contacted; M9 VM acceptance remains pending. +- [x] M5.4 — `Application` now exposes saved-bound restart preview, explicit cancel/restart/discard choices and redacted stage-aware reports; backup precedes discard/submission, full technical match and one-use attempt authorization precede M4 journaled marking. SaveConfig/recovery/stale-generation checks and fake adapter tests added. Independent R1 found false “may still complete” on known terminal job failure; repairer corrected it and fresh R2 review closed the finding. Latest `cargo test --locked app` passed 33 matching tests; fmt, Clippy and diff checks passed. No host bus/interface was accessed. +- [x] M5.5 — added `tests/runtime_restart.rs` public preview/cancel/restored-settings integration tests (2 passed), private fake-adapter/clock deadline coverage in `src/app.rs` (34 app-filtered tests passed), and README/RUNTIME/PERSISTENCE documentation distinguishing synthetic M5 evidence from later real M8/M9 validation. `cargo test --locked` passed 161 library units plus 25 integration tests; fmt, Clippy and diff checks passed. Full independent integrated review remains M5.6. +- [x] M5.6 — fresh independent integrated review found six actionable API, reporting, documentation and deadline findings; repairer addressed them in two bounded rounds, and independent re-review closed all six. The reviewer found no remaining actionable scoped M5 issue. Real kernel/systemd behavior remains M8/M9 work. +- [x] M5.7 — coordinator inspected the integrated worktree, ran every planned command on the final revision, recorded results below, and accepted M5-A1–A12 for the specified synthetic/fake-adapter scope. + +#### Review and repair + +R1 independent reviewer findings and R2 disposition: + +- M5-R1-01 (P1, `src/runtime.rs` interface observation/comparison): a peer/route + match from the wrong interface could pass. Repairer added observed interface + identity and a configured-name argument to comparison; wrong-name regression + now fails closed. R2 independently confirmed closure. +- M5-R1-02 (P2, `src/runtime.rs` test coverage): no direct invariance test for + handshake changes and counter resets. Repairer added one; R2 confirmed closure. +- M5-R1-03 (P2, `src/runtime.rs` test coverage): missing malformed-route and + structural-document subset-match regressions. Repairer added both; R2 + confirmed closure. +- M5-R1-04 (blocking dependency/API limitation, M5.2): independent reviewer + confirmed `wireguard-uapi` 3.0.1 `src/linux/socket/wg_socket.rs:87-108` + loops on `iter.next()` without a deadline and uses + `while let Some(Ok(response))`, allowing a receive error after a fragment to + terminate the loop and return a partial `Device` as success. The public API + provides no completeness proof or read timeout. Secret-bearing decoded fields + can be kept private by careful consumption, but that does not fix boundedness + or completeness. An abandoned timeout thread also cannot cancel the read. + Resolution: read-only explorer verified `rustix` 1.1.5 provides public safe + AF_NETLINK/NETLINK_GENERIC socket and polling APIs when its approved direct + dependency enables `net`/`event`. It does not provide a WireGuard codec, so + M5.2 must implement a small checked decoder with explicit complete-dump and + aggregate-limit fixture tests, followed by a bounded socket transport. This + is an implementation choice within the approved dependency list, not a new + direct dependency or specification change. No unsafe fallback or + partial-success adapter is accepted; this finding remains open until tested. +- M5-R2-01 (P1, `src/runtime/transport.rs` request IFNAME encoding): the initial + builder put the NUL outside `nla_len`; repairer included it in the payload and + added exact-byte assertions. Independent R3 re-review confirmed closure. +- M5-R2-02 (P2, `src/runtime/transport.rs` family errors): type validation made + `NLMSG_ERROR` handling unreachable; repairer added checked errno mapping for + no-device, permission and transport failures and injected tests. Independent + R3 re-review confirmed closure. +- M5-R3-01 (P1, `src/runtime/transport.rs` IFNAME attribute number): initial + request used type 3 (private key) instead of kernel-defined IFNAME type 2; + repairer corrected it and added a literal numeric assertion independent of + the implementation constant. R3 reviewer follow-up confirmed closure. +- M5.3-R1 (P2, `src/service.rs`/`src/service/dbus.rs` alias correlation): the + tracker initially compared `JobRemoved.unit` to the configured literal, which + can be an alias while systemd reports the primary unit name. Repairer added + bounded read-only `GetUnit`/`Unit.Id` resolution before `RestartUnit`; the + request still targets the configured name and the terminal event must match + both primary ID and returned job path. Synthetic alias/failure/order tests + passed; fresh R2 reviewer confirmed closure. +- M5.4-R1-01 (P2, `src/app.rs` service failure report): submitted terminal + `JobRemoved` failures were incorrectly reported as possibly still completing. + Repairer distinguished terminal failure from ambiguous request/wait timeout + or disconnect while preserving submission/manual-recovery fields. Synthetic + cases for failed, dependency, timeout, canceled, skipped and unknown results + passed; fresh R2 reviewer confirmed closure. + +M5-R1-04 is now resolved at the implementation-choice level with a bounded +`rustix` decoder/transport and synthetic tests; actual kernel behavior is +deferred to M8 and has not been claimed here. + +Integrated review finding IDs below are prefixed `M5-I` to distinguish them +from earlier task-local reviewer IDs: + +- M5-I-R1-01 (P1): a public raw systemd adapter could be called without the + mandatory application backup. Repairer made it crate-private and added a + compile-fail public-API doctest; independent R2 confirmed closure. +- M5-I-R1-02 (P1): a public domain verification setter bypassed the application + sequence. Repairer restricted it to the crate; R2 confirmed closure. +- M5-I-R1-03 (P2): runtime failures collapsed to one uninformative category. + Repairer added six redacted runtime-failure categories; R2 confirmed closure. +- M5-I-R1-04 (P2): TESTING.md incorrectly described the `wireguard-uapi` read + adapter. Repairer documented the actual approved-rustix path; R2 confirmed. +- M5-I-R2-05 (P2): expiry of the shared application deadline could be reported + as a generic runtime-read failure or mismatch. Repairer added a distinct + deadline result and deterministic before/after-read fake-clock tests; the + independent reviewer re-reviewed and closed it. +- M5-I-R2-06 (P2): RUNTIME.md called the now crate-private service adapter a + public API. Repairer corrected the boundary description; independent + re-review closed it. + +The fresh integrated reviewer reported no further actionable issue against +M5-A1–A12 after the second repair round. Review and tests use synthetic state; +they do not establish real kernel or systemd/D-Bus behavior. + +#### Evidence and handoff + +Planning inspected current Cargo dependencies, Application backup/verification +boundaries, persistence/state schemas, backup metadata rules and M4 evidence. +M4 records independent R5 acceptance and coordinator results: 108 library unit, +9 Commit/restore, 9 preservation and 5 import tests, plus completion gates. +Those are historical recorded results; this planning task did not rerun Cargo +checks, add dependencies/source, dispatch agents or contact a system bus/VPN. + +2026-10-20 preparation confirmed `.codex/config.toml` requests `gpt-6-sol`/ +`medium`, all four `.codex/agents/*.toml` request `gpt-6-luna`/`high`, and the +available spawn-agent catalog lists those models and efforts. The active primary +session exposes only the GPT-6 family, not its exact model or effort; project +defaults can be overridden by a live session. Thus the mandatory coordinator +verification is incomplete. The worktree already had a user change to this +M5 description; it was preserved. `rustc`/Cargo reported 1.97.1, rustfmt +1.9.0 and Clippy 0.1.97. `git diff --check` passed before this status edit. +No M5 code, tests, dependencies, worker dispatch or acceptance checks ran during +that earlier preparation. + +The active session was subsequently verified by matching `CODEX_SESSION_ID` to +its local rollout record and reading the latest `turn_context` model and effort: +`gpt-6-sol` / `medium` (2026-10-20T18:36:50.175Z). The four role files explicitly +configure `gpt-6-luna` / `high`; M5 workers will be spawned with those effective +model/effort overrides and their configured roles. This resolves the prior +configuration blocker without substituting models. + +2026-10-20T21:50:55+03:00 coordinator validation of the partial integrated +revision (all exit 0): `cargo fmt --all -- --check`, +`cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked` +(118 library unit, 9 Commit/restore, 9 config-preservation and 5 vault-import +tests passed; main/doc tests 0), `cargo build --locked --release`, +`cargo test --locked runtime` (14 matching tests, of which 10 are in the runtime +module), each of the three named M4 integration targets (9/9/5 passed), release +`--help` and `--version`, and `git diff --check`. No service or runtime_restart +test target exists yet, so those planned checks were unavailable and not counted +as passing. `wireguard-uapi` and `zbus` are not in the retained manifests/lockfile. +No root, Docker, host interface, system bus, production data, Alpine or VM test +was used. Passing partial gates does not establish M5-A2 or M5-A4–A12. + +2026-10-20T22:03:03+03:00 continuation: active coordinator session rollout +again showed `gpt-6-sol` / `medium`; role files still specify `gpt-6-luna` / +`high`. Worktree remained the prior partial M5 result. Read-only explorer +examined cached `wireguard-uapi`/`rustix` sources and kernel protocol docs; +no socket or production state was accessed. The chosen `rustix` route needs +checked Generic Netlink family resolution, exact sequence/DONE/error handling, +fragment coalescing, monotonic deadline and strict limits before M5-A2 can pass. + +Final M5.7 validation on the integrated revision (all exit 0): +`cargo test --locked runtime` (32 matching tests), +`cargo test --locked service` (19 matching tests), +`cargo test --locked --test runtime_restart` (2), +`cargo test --locked --test commit_restore` (9), +`cargo test --locked --test config_preservation` (9), +`cargo test --locked --test vault_import` (5), +`cargo fmt --all -- --check`, +`cargo clippy --locked --all-targets -- -D warnings`, +`cargo test --locked` (163 library unit tests, 25 integration tests and one +compile-fail doctest passed; binary tests 0), +`cargo build --locked --release`, release `--help` and `--version`, and +`git diff --check`. The release binary reports that the management interface +is not implemented, as expected. Tests were unprivileged synthetic/fake-adapter +checks; no Docker, Alpine, systemd VM, host bus/interface, production path, +private key or real restart was accessed. M8 and M9 still require their own +real-system acceptance and cannot inherit a passing result from these checks. + +Next action: stop after accepted M5. M6 may begin only on a separate request; +M8 Alpine and M9 disposable-VM validation remain future milestones. + +M5.3 prerequisite decision: use approved `zbus` 5.19.0 with `async-io` and +default features disabled. A standard-library wake/park executor with one +monotonic deadline can bound connection setup, match-rule registration, method +calls and signal waits without an unapproved direct runtime dependency. Subscribe +and install observation before `RestartUnit`; buffer/handle a fast completion +that arrives before the method reply. Confirm real signal and alias behavior in +the disposable systemd VM at M9; fake events here are not VM evidence. + +#### Decisions and discoveries + +M4 verification persistence already supplies the intended durable boundary, but +its pending-backup flag is only one prerequisite. M5 must establish restart/job +and runtime evidence before invoking it and invalidate per-attempt authority. +Aliases are server metadata, not kernel data; verification must separate technical +correspondence from display/domain projection. A successful service job and a +successful durable application record are distinct stages with distinct failures. + +Primary API references for implementation (check the chosen version at dispatch): +[systemd D-Bus](https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.systemd1.html), +[wireguard-uapi](https://docs.rs/wireguard-uapi/latest/wireguard_uapi/), and +[zbus](https://docs.rs/zbus/latest/zbus/). These references do not pin new versions. + +#### Outcome + +M5.1–M5.7 are complete. Accepted for the milestone's explicitly scoped +synthetic/fake-adapter checks after independent integrated review, two repair +rounds, re-review and all planned coordinator gates. No real WireGuard or +systemd/D-Bus result is claimed; M8/M9 remain future acceptance stages. No Git +commit, deployment or production access occurred. Stop after M5; do not start M6 +without a separate request. ### Milestone 6 — Client configuration delivery and reissue lifecycle @@ -1421,14 +1847,14 @@ Acceptance: real D-Bus job handling, separate restart verification, address rele ## Current progress and resume point -- Milestones 1–4 are accepted; milestones 5–9 remain pending. +- Milestones 1–5 are accepted; milestones 6–9 remain pending. - The approved dependency list is in [DEPENDENCIES.md](docs/DEPENDENCIES.md); container testing methodology is in [TESTING.md](docs/TESTING.md). - Milestone 1 is expanded and the Rust environment is pinned. - Milestone 2 delivered a lossless in-memory WireGuard configuration editor, versioned metadata, and behavioral preservation tests. - Milestone 3 delivered secret-safe key handling, a versioned encrypted vault, caller-buffer reconciliation, and a bounded read-only source-directory import adapter. - Milestone 4 delivered versioned private backups, journaled saved-file Commit and recovery, previewed full restore, durable address reservations and historical verification bookkeeping, with independent R5 closure and coordinator gates. -- Next action: stop after accepted M4; begin M5 only if separately requested. -- M1–M4 integrated gates and independent reviews passed. No Alpine or VM results are claimed. +- Milestone 5 delivered public runtime observations and technical comparison, an approved-rustix bounded read-only netlink adapter, a zbus systemd job adapter, restart orchestration, and synthetic integration tests. Independent integrated review and repairs closed all actionable scoped findings; all planned gates passed. Real kernel/systemd validation remains deferred to M8/M9. +- M1–M5 integrated gates and independent reviews passed. No Alpine or VM results are claimed. ## Plan maintenance diff --git a/README.md b/README.md index a556eb7..1b9acc8 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ # wireguard-admin This repository contains the Rust package, startup boundary, validated settings -loader, client domain model, a byte-preserving in-memory WireGuard configuration -editor, and library APIs for encrypted client vaults and read-only client import. -The library also provides saved-file persistence with recovery, private backups, -prepared Commit, and previewed full restore. The terminal management interface -and WireGuard/systemd runtime operations have not been implemented yet. +loader, client domain model, a byte-preserving WireGuard configuration editor, +encrypted client vault and read-only import APIs, recoverable saved-file +persistence, private backups, Commit, and previewed full restore. The library +also provides read-only WireGuard runtime observation, technical peer comparison, +bounded systemd restart handling, and explicit restart orchestration. The +terminal management interface is not implemented, and management startup is not +connected to these library operations. The package requires Rust 1.97.1 or newer and uses the Rust 2024 edition. From the repository root, run: @@ -68,8 +70,11 @@ let bytes_to_review = updated.render(); // no persistence or VPN operation ``` Management startup still exits with an explicit “not implemented” error after -its root check. The application library APIs are usable by callers, but are not -yet connected to a management session or runtime VPN adapter. +its root check. Runtime and restart library behavior is covered with synthetic +fixtures and fake adapters; it has not been validated against a real WireGuard +interface or systemd service. Real WireGuard integration is deferred to the +isolated Alpine milestone, and real systemd/D-Bus acceptance requires the +disposable VM milestone. ## Saved state, Commit, and backups @@ -83,12 +88,15 @@ under Backup/data by default. Archives are stored under Backup/archives and contain fixed logical members, not source client files or prior archives. The persistence journal recovers interrupted file replacement on reopen and -returns a recovery error while earlier work remains unresolved. Restoring a -backup does not restore runtime state: the running interface is unknown until a -future explicit runtime verification. Pending deleted-client addresses remain -reserved through Commit, reopen, and restore. See +returns a recovery error while earlier work remains unresolved. A confirmed +restart marks the saved generation applied only after a successful service job, +a matching complete runtime observation, and journaled verification-state write. +Restoring a backup does not restore runtime state: the running interface is +unknown until a later explicit restart and verification. Pending deleted-client +addresses remain reserved through Commit, reopen, and restore. See [docs/BACKUP_FORMAT.md](docs/BACKUP_FORMAT.md) and -[docs/PERSISTENCE.md](docs/PERSISTENCE.md) for archive and recovery details. +[docs/PERSISTENCE.md](docs/PERSISTENCE.md) for archive and recovery details, +and [docs/RUNTIME.md](docs/RUNTIME.md) for observation and restart boundaries. ## Vault and existing-client import library diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index 9d035e4..4a5fdd3 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -16,18 +16,20 @@ The user has approved the following choices. They may be introduced when their i | QR encoding | `qrcode` | Additional terminal delivery of client configurations | | Backup archives | `tar` | Archives with manifests; initially without compression | | Temporary files | `tempfile` | Staged file replacement and isolated test directories | -| Linux operations | `rustix` | User checks and controlled low-level filesystem operations | +| Linux operations | `rustix` | User checks, controlled filesystem operations, and bounded Generic Netlink runtime reads | | Encrypted vault | `age` | Recipient encryption and password protection of the vault secret key | | WireGuard key pairs | `x25519-dalek` | Key generation and public/private key matching | | Secure randomness | `getrandom` | Operating-system randomness for key generation | | WireGuard key encoding | `base64` | Reading and writing WireGuard key representations | | Secret handling | `secrecy`, `zeroize` | Explicit secret exposure and supported memory cleanup | | WireGuard runtime | `wireguard-uapi` | Direct Linux interface, peer, handshake, and traffic queries | -| Service management | `zbus` | Direct systemd D-Bus requests and job completion tracking | +| Service management | `zbus` 5.19.0 | Async systemd D-Bus requests and job completion tracking | | Container integration tests | `testcontainers` | Alpine server/client lifecycle; development dependency only | - Use a separate vault key pair from all WireGuard client/server key pairs. Encrypt each client secret to the vault public key; protect the vault secret key with password-based encryption using the established `age` format. Do not replace this with password-only encryption of every new client record, which would require a password on creation. -- Validate `wireguard-uapi` against the isolated integration environment before relying on it. A systemd D-Bus request being accepted is not proof of job completion or successful VPN application. +- `wireguard-uapi` remains an approved option but is not selected for the bounded runtime reader: its public read API does not expose a receive deadline or a complete-dump guarantee. A systemd D-Bus request being accepted is not proof of job completion or successful VPN application. +- Runtime observation uses the already approved `rustix` 1.1.5 safe `net` and `event` APIs for a nonblocking Linux Generic Netlink socket, polling and datagram I/O. This keeps one monotonic deadline and exact truncation/sender checks around the checked WireGuard decoder. It does not add a direct dependency or use a subprocess. +- The systemd adapter pins `zbus` 5.19.0 with default features disabled and only `async-io` enabled. It uses zbus's asynchronous signal and method APIs, driven by a small standard-library wake/park loop so every call shares the caller's monotonic deadline. zbus's async-io backend owns internal I/O executor thread(s); callers must allow the connection to be dropped after each operation. Signal polling uses zbus 5.19.0's doc-hidden `export::futures_core` re-export to avoid another direct dependency; keep zbus pinned until that implementation is deliberately migrated. - Avoid incompatible duplicate versions of secret-handling types where library APIs exchange them. Use supported re-exports where appropriate rather than adding redundant direct dependencies. - Additional direct dependencies still require the comparison and prior agreement required by [AGENTS.md](../AGENTS.md), including any test/build helpers. Transitive dependencies of approved crates are resolved through Cargo; review their compatibility and runtime requirements. Do not add a direct `tokio` dependency merely because an approved library uses an async runtime internally. - `clap`, `chrono`, SQLite, a generic INI writer, a mocking framework, and archive compression libraries are not part of the approved initial set. Use the standard library for the small startup argument surface and background threads/channels where sufficient. diff --git a/docs/PERSISTENCE.md b/docs/PERSISTENCE.md index 60406f5..cebbfdd 100644 --- a/docs/PERSISTENCE.md +++ b/docs/PERSISTENCE.md @@ -103,3 +103,16 @@ WireGuard or systemd operation, does not provide concurrent-editor locking, and is not automatic rollback after a failed runtime restart. The separate pre-operation archive remains the recovery source if local journal data is unusable; the adapter does not silently discard an invalid journal. + +For an explicit restart, the application creates the pre-restart archive before +calling the service adapter. After a successful terminal service job, it checks +the complete observed peer set against the saved server document. A match is +then recorded by installing the updated `internal-state.toml` through this same +journal, with reservations removed only from the proposed state as appropriate. +The in-memory saved/applied state advances only after installation succeeds. If +that write or journal recovery fails after the VPN may have changed, the +application reports the persistence stage, retains in-memory reservations, and +requires manual recovery; it does not roll back or restart again. Default tests +exercise this orchestration with private fake adapters and synthetic files. +Actual interface and systemd behavior remain deferred to the isolated Alpine +and disposable VM acceptance environments described in `RUNTIME.md`. diff --git a/docs/RUNTIME.md b/docs/RUNTIME.md new file mode 100644 index 0000000..095d1ed --- /dev/null +++ b/docs/RUNTIME.md @@ -0,0 +1,168 @@ +# Runtime observation and verification boundary + +`runtime` defines public, read-only observation values, a bounded Linux +WireGuard Generic Netlink reader, and a pure comparison between an observed +interface and the authoritative saved server document. The reader accesses +only the configured interface and never changes the running interface. + +## Observation values + +An observation records an optional UTC sample time and distinguishes an unknown +interface, an unavailable read, a missing interface, and a present interface. +A present interface carries its observed interface name and peers keyed by +`PublicKey`; duplicate keys are retained as an explicit ambiguity signal. The +comparison requires the currently configured interface name and rejects a +different observed interface, even when its peer set happens to match. The peer +set states whether it is unknown, unavailable, partial, or complete. Only a +complete set can prove that a saved peer is absent or that an extra runtime peer +exists. + +Each peer has AllowedIPs and statistics with their own availability. A valid +zero byte counter is `Available(0)`. A peer with no recorded handshake is +`Available(None)`. Unavailable statistics do not become zero or a disconnected +state. Counters are current values from the server interface's perspective and +may reset after restart; they are not history and do not establish online or +offline status. + +## Saved technical projection + +`SavedPeerProjection::from_document` includes every `[Peer]` section from the +byte-preserving `ConfigDocument`, including valid routes unsupported by the +narrow `domain::Client` address model and legacy IPv4-only peers. Routes are +normalized and compared as complete sets, independent of ordering and duplicate +entries. Aliases and managed creation dates are not technical runtime fields; +metadata anomalies alone do not prevent comparison. Missing or ambiguous keys, +malformed routes, structural document issues, and duplicate identities remain +unverifiable and cannot be omitted to obtain a matching reduced set. + +`compare_peers(saved, expected_interface, runtime)` checks interface presence +and identity, the complete public-key set, and each peer's complete AllowedIPs +set. It reports a wrong interface, missing, extra, route-mismatched, duplicate, +partial, and unverifiable cases. An empty saved peer set can match only a +present expected interface with an explicitly complete empty runtime peer set. +Handshake values, counters (including resets), endpoint changes, ordering, and +sample time do not affect technical equivalence. The comparison returns only a +technical result; it does not create online status, applied state, or history. + +This result is separate from M4's `domain::Snapshot` and applied marker. A +successful technical comparison is necessary evidence for applying state, but +it is not itself a durable M4 transition. Application restart orchestration +joins saved aliases and representable addresses by public key only after full +technical equivalence succeeds. If any authoritative peer cannot be safely +represented for that narrower durable view, verification remains unverifiable +and reservations are retained instead of supplying a fabricated subset. + +## WireGuard Generic Netlink decoder + +`runtime::netlink::GetDeviceDecoder` is a pure decoder for one WireGuard +`GET_DEVICE` dump. It consumes caller supplied datagrams and expected request +sequence, local netlink port ID, family ID, and interface name. It has no socket +or host access. The transport rejects truncated datagrams, enforces its +monotonic receive deadline, and wipes its receive buffer when its lifetime ends. + +The decoder checks every netlink and attribute length/alignment, message type, +sequence and port ID, Generic Netlink command/version, configured interface, +nested peer and AllowedIPs records, duplicate fields and aggregate limits. It +returns data only after an explicit `NLMSG_DONE` with an embedded zero error +code. It rejects `NLMSG_ERROR`, interrupted/overrun dumps, malformed or +incomplete data, and limits exceeded. Peer fragments are coalesced only when a +repeated public key is adjacent across multipart messages and the fragment +contains only that key and AllowedIPs; duplicate routes remain invalid. A +complete empty peer list is valid. + +Server private keys and peer preshared keys are length-checked through borrowed +input slices and are neither copied into decoder-owned structures nor returned. +The result contains only public keys, AllowedIPs, last-handshake timestamps and +current received/transmitted counters. Errors carry stable categories without +raw message data. The decoder uses configurable aggregate limits with bounded +defaults; future transport code must set request-specific limits explicitly. + +Protocol details follow the [WireGuard Generic Netlink specification](https://docs.kernel.org/netlink/specs/wireguard.html) +and the [Linux Netlink message specification](https://docs.kernel.org/6.1/userspace-api/netlink/intro.html). + +## Systemd restart job tracking boundary + +`service::RestartJobTracker` is the pure event state machine for one resolved +primary systemd unit ID. The bounded zbus 5.19.0 adapter is internal and +crate-private; public runtime mutation is exposed only through +`Application::restart`, after an explicit restart choice. The adapter installs +`JobRemoved` and systemd name-owner observation, calls Manager `Subscribe`, and +resolves the configured target with read-only Manager `GetUnit` followed by the +unit's `Id` property. It then submits `RestartUnit` using the original +configured target and tracks the returned job object path together with the +resolved primary ID, which is the unit name reported by `JobRemoved`. If +`GetUnit` cannot resolve a loaded unit, or reading its ID fails, the operation +stops with a redacted resolution failure; it does not load a unit or guess its +canonical name. The signal stream is installed before resolution and restart, +so a fast completion queued before the method reply remains available once the +reply supplies the path. The pure tracker also supports a fixed bound of early +events for transports that deliver events before the method reply; overflow +fails closed. + +Only systemd's `done` job result is successful. Failed, canceled, timed-out, +skipped, dependency, or unknown job results remain failures. Bus disconnection, +manager owner change, subscription failure, and permission/request failures are +separate redacted outcomes. `RestartDeadline` carries the operation's monotonic +start and timeout so connection, match registration, Subscribe, unit +resolution, RestartUnit, job wait, and later runtime verification can share one +budget. The adapter drives zbus async futures with a standard-library thread +waker and parks only for the remaining budget. zbus's `async-io` backend +continues socket I/O on its own executor; its connection is short lived and is +dropped after the adapter returns. Deadline expiry stops the application's +wait; a submitted job may still complete. The adapter never cancels or retries +the job, checks unit active state, or verifies the WireGuard interface. +Ordinary adapter tests inject a fake event transport and never connect to the +system bus. Real D-Bus behavior remains for the disposable systemd VM +acceptance at M9. Protocol +references: [systemd Manager D-Bus API](https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.systemd1.html) +and [zbus 5.19.0 API](https://docs.rs/zbus/5.19.0/zbus/). +## Application restart orchestration + +`Application::preview_restart` binds a read-only preview to the current saved +generation and resolves the configured interface, systemd unit, and timeout +from that generation. `Application::restart` accepts an explicit choice. Cancel +returns before backup, service, runtime, or persistence adapters run. A +confirmed operation completes a `pre-restart` backup, submits a systemd restart, +waits for a successful terminal job, reads and compares the complete runtime +peer set within the shared monotonic deadline, then writes verification state +through the saved-file journal. Only after that write succeeds does in-memory +applied state advance and deleted-client reservations release. + +Failure reports identify the stage, whether a restart was submitted, whether it +may still complete, the backup path when available, and whether manual recovery +is required. A timeout does not cancel a submitted systemd job. A runtime mismatch +or verification-state write failure does not roll back the VPN or release +reservations. Commit and restore remain saved-file operations and never invoke +the runtime or service adapters. + +Application unit tests use private fake adapters and an injected private +monotonic clock. Public integration tests exercise cancellation, preview, +restore, and saved-setting selection without confirming a host restart. These +are synthetic fixture results. The Linux transport has not been validated +against a real interface; that check belongs to the isolated Alpine suite at +M8. The real zbus/systemd path and end-to-end restart acceptance require the +disposable systemd VM at M9. The TUI and management startup wiring are not +implemented. + +## Bounded Linux transport + +`runtime::transport::observe_interface` resolves the WireGuard Generic Netlink +family through `nlctrl`, then sends only `WG_CMD_GET_DEVICE` for the supplied +interface. It uses rustix 1.1.5's safe nonblocking socket, `poll`, and datagram +APIs; no `SET_DEVICE`, subprocess, unsafe block, or host socket is used by the +default tests. Family lookup and the complete multipart dump share one +monotonic deadline. The datagram size is capped, `MSG_TRUNC` is detected using +the returned full datagram length, the sender socket address must be kernel +port 0, and decoder aggregate limits apply across the dump. + +Linux's Generic Netlink reply header `nlmsg_pid` carries the requester port ID +(the destination); the actual kernel sender port ID is 0 in the datagram +address. The transport validates both identities. A complete read is returned +only after the decoder consumes successful `NLMSG_DONE`; kernel errno values +map to distinct no-device and permission outcomes. Malformed data, transport +errors, timeouts, and limit excess remain separate, redacted categories. +Receive buffers are zeroized on every return path because device replies may +include private and preshared key attributes, although the decoder borrows and +does not retain those attributes. Runtime observation does not itself perform +saved-peer comparison or mark state applied. Real-interface behavior remains +subject to the isolated Alpine integration milestone. diff --git a/docs/TESTING.md b/docs/TESTING.md index 77109a2..dc888ca 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -40,7 +40,7 @@ Production access is limited and experimentation on the production VPN is prohib - Build and cache the fixture image ahead of repeated test runs. Pin the Alpine base/version or digest and document the image recipe; avoid package installation and image rebuilding for every test. Reuse image layers, not mutable containers or secret-bearing volumes between tests. - The Docker host must provide Linux kernel WireGuard support. Containers share that kernel; installing userspace tools in Alpine does not supply it. Probe prerequisites in the disposable environment and report missing capabilities clearly. Do not automatically load host modules or change host settings. - Grant `NET_ADMIN` in the test containers for their own network namespaces. Do not default to `--privileged`, `SYS_MODULE`, host networking, or shared host PID namespaces. Configure IPv6 for the dedicated test network as needed; exercise both address families. -- Run the application operations or a test driver linked to the real library inside the server container, including the real `wireguard-uapi` adapter. A host-side test harness may orchestrate containers, but must not query or modify the host's WireGuard interface as a shortcut. +- Run the application operations or a test driver linked to the real library inside the server container, including the approved `rustix` Generic Netlink runtime adapter. A host-side test harness may orchestrate containers, but must not query or modify the host's WireGuard interface as a shortcut. - Build the in-container executable for Alpine compatibility, preferably the matching musl target. Do not assume a glibc-linked host binary runs in Alpine. Keep compiler/toolchain layers out of the small runtime fixture image where practical. - Generate synthetic server/client keys and configurations for each test. Copy fixtures into container-local filesystems. Never mount production files, the host's `/etc/wireguard`, host system D-Bus sockets, or the Docker socket inside fixture containers. - Poll explicit readiness and handshake/traffic conditions with deadlines rather than fixed sleeps. Always clean up containers, dedicated networks, and temporary resources after success or failure. Keep diagnostic artifacts redacted. diff --git a/src/app.rs b/src/app.rs index b0be340..927eb6e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,20 +1,29 @@ //! Application session operations for prepared client changes and saved state. //! //! This module owns the boundary between a byte-preserving configuration draft, -//! encrypted vault data, backups, and the recoverable saved-file adapter. It has -//! no runtime or systemd integration. +//! encrypted vault data, backups, the recoverable saved-file adapter, and the +//! explicit runtime-restart confirmation boundary. use std::collections::BTreeSet; use std::fmt; use std::net::IpAddr; +use std::time::{Duration, Instant}; use thiserror::Error; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; use crate::backup::{self, BackupError, BackupReason, HistoricalApplyStatus, SavedBytes}; use crate::domain::{Client, ClientStates, DomainError, PublicKey, RuntimeObservation, Snapshot}; use crate::persistence::{BackupComplete, PersistedState, PersistenceError, SavedStore}; +use crate::runtime::transport::TransportError; +use crate::runtime::{ + self, RuntimeObservation as TechnicalObservation, SavedPeerProjection, TechnicalMatch, +}; +use crate::service::{self, RestartDeadline, RestartExecution, RestartOutcome}; use crate::vault::source::{PreparedImportSetup, SourceImportReport}; use crate::vault::{VaultBundle, VaultError}; +use crate::wireguard::SectionKind; use crate::wireguard::{ConfigDocument, DocumentError, EditError}; /// An open, in-memory management session. The configuration and state drafts @@ -27,7 +36,86 @@ pub struct Application { vault: Option, draft_unsupported_reservations: BTreeSet, historical_apply: HistoricalApplyStatus, - pre_restart_backup_pending: bool, + active_restart_attempt: Option, + next_restart_attempt: u64, +} + +/// Read-only, saved-generation-bound restart preview for a future confirmation UI. +pub struct RestartPreview { + saved: SavedBytes, + interface: String, + unit: String, + timeout: Duration, + projection: SavedPeerProjection, + expected_clients: Snapshot, + has_draft: bool, +} + +impl RestartPreview { + pub fn interface(&self) -> &str { + &self.interface + } + + pub fn systemd_unit(&self) -> &str { + &self.unit + } + + pub const fn has_draft(&self) -> bool { + self.has_draft + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RestartChoice { + Cancel, + RestartSavedState, + DiscardDraftAndRestart, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RestartStage { + Cancelled, + Preparation, + Backup, + Service, + RuntimeRead, + Verification, + Persistence, + Complete, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RestartFailureKind { + InvalidOrStaleSavedGeneration, + UnsafeSaveConfig, + UnsupportedSavedShape, + BackupFailed, + DraftDiscardFailed, + ServiceFailed, + RuntimeReadFailed, + RuntimeNoDevice, + RuntimePermissionDenied, + RuntimeMalformed, + RuntimeTransport, + RuntimeTimeout, + RuntimeLimitExceeded, + RuntimeMismatch, + VerificationPersistenceFailed, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RestartReport { + pub stage: RestartStage, + pub failure: Option, + pub backup_path: Option, + pub restart_submitted: bool, + pub restart_may_still_complete: bool, + pub manual_recovery_required: bool, +} + +struct RestartAttempt { + id: u64, + saved: SavedBytes, } /// Read-only restore details and an opaque, byte-bound confirmation value. @@ -75,6 +163,10 @@ impl fmt::Debug for Application { .field("states", &self.states) .field("vault_present", &self.vault.is_some()) .field("historical_apply", &self.historical_apply) + .field( + "restart_attempt_active", + &self.active_restart_attempt.is_some(), + ) .field( "pending_reservation_count", &self.draft_unsupported_reservations.len(), @@ -110,7 +202,8 @@ impl Application { vault, draft_unsupported_reservations: BTreeSet::new(), historical_apply, - pre_restart_backup_pending: false, + active_restart_attempt: None, + next_restart_attempt: 1, }) } @@ -280,9 +373,415 @@ impl Application { /// Reusable mandatory backup boundary for M5 before it invokes restart. /// This method only archives saved bytes; it never calls a runtime adapter. pub fn create_pre_restart_backup(&mut self) -> Result { - let path = self.create_backup(BackupReason::PreRestart)?; - self.pre_restart_backup_pending = true; - Ok(path) + self.active_restart_attempt = None; + self.create_backup(BackupReason::PreRestart) + } + + /// Prepare a read-only preview bound to the exact currently installed generation. + pub fn preview_restart(&self) -> Result { + let installed = self.store.read_saved()?; + if !same_saved_bytes(&installed, &self.saved) { + return Err(AppError::SavedGenerationChanged); + } + let settings = self.store.resolved_settings_for(&installed)?; + let document = ConfigDocument::parse(&installed.server_config)?; + if document + .sections() + .iter() + .filter(|section| section.kind == SectionKind::Interface) + .count() + != 1 + { + return Err(AppError::InvalidSavedState); + } + if document.has_unsafe_save_config() { + return Err(AppError::UnsafeSaveConfig); + } + let projection = SavedPeerProjection::from_document(&document); + let expected_clients = checked_snapshot_from_document(&document)?; + Ok(RestartPreview { + saved: clone_saved(&installed), + interface: settings.interface_name, + unit: settings.systemd_unit, + timeout: settings.restart_timeout, + projection, + expected_clients, + has_draft: self.has_draft(), + }) + } + + fn has_draft(&self) -> bool { + let vault_differs = match &self.vault { + Some(vault) => vault.to_toml().map(String::into_bytes).ok() != self.saved.vault, + None => self.saved.vault.is_some(), + }; + self.states.draft() != self.states.saved() + || self.document.render() != self.saved.server_config + || !self.draft_unsupported_reservations.is_empty() + || vault_differs + } + + /// Confirm or cancel an explicit restart preview. The adapters use only the + /// saved generation and share one monotonic deadline after backup completion. + pub fn restart(&mut self, preview: RestartPreview, choice: RestartChoice) -> RestartReport { + self.restart_with_adapters( + preview, + choice, + service::restart_systemd_unit_report, + |interface, timeout| { + runtime::transport::observe_interface( + interface, + timeout, + runtime::netlink::DecodeLimits::default(), + ) + }, + |store, saved, backup| store.install(saved, backup), + ) + } + + fn restart_with_adapters( + &mut self, + preview: RestartPreview, + choice: RestartChoice, + service: impl FnOnce(&str, RestartDeadline) -> RestartExecution, + runtime_read: impl FnOnce(&str, Duration) -> Result, + install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>, + ) -> RestartReport { + self.restart_with_adapters_and_clock( + preview, + choice, + service, + runtime_read, + install, + Instant::now, + ) + } + + fn restart_with_adapters_and_clock( + &mut self, + preview: RestartPreview, + choice: RestartChoice, + service: impl FnOnce(&str, RestartDeadline) -> RestartExecution, + runtime_read: impl FnOnce(&str, Duration) -> Result, + install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>, + now: impl Fn() -> Instant, + ) -> RestartReport { + if choice == RestartChoice::Cancel { + return restart_report(RestartStage::Cancelled, None, None, false, false, false); + } + self.active_restart_attempt = None; + if self.has_draft() != preview.has_draft + || (preview.has_draft && choice != RestartChoice::DiscardDraftAndRestart) + || (!preview.has_draft && choice == RestartChoice::DiscardDraftAndRestart) + { + return restart_report( + RestartStage::Preparation, + Some(RestartFailureKind::InvalidOrStaleSavedGeneration), + None, + false, + false, + false, + ); + } + let installed = match self.store.read_saved() { + Ok(saved) + if same_saved_bytes(&saved, &preview.saved) + && same_saved_bytes(&saved, &self.saved) => + { + saved + } + _ => { + self.active_restart_attempt = None; + return restart_report( + RestartStage::Preparation, + Some(RestartFailureKind::InvalidOrStaleSavedGeneration), + None, + false, + false, + false, + ); + } + }; + let settings = match self.store.resolved_settings_for(&installed) { + Ok(settings) => settings, + Err(_) => { + return restart_report( + RestartStage::Preparation, + Some(RestartFailureKind::InvalidOrStaleSavedGeneration), + None, + false, + false, + false, + ); + } + }; + let document = match ConfigDocument::parse(&installed.server_config) { + Ok(document) => document, + Err(_) => { + return restart_report( + RestartStage::Preparation, + Some(RestartFailureKind::InvalidOrStaleSavedGeneration), + None, + false, + false, + false, + ); + } + }; + if document + .sections() + .iter() + .filter(|section| section.kind == SectionKind::Interface) + .count() + != 1 + { + return restart_report( + RestartStage::Preparation, + Some(RestartFailureKind::InvalidOrStaleSavedGeneration), + None, + false, + false, + false, + ); + } + if document.has_unsafe_save_config() { + return restart_report( + RestartStage::Preparation, + Some(RestartFailureKind::UnsafeSaveConfig), + None, + false, + false, + false, + ); + } + let expected_clients = match checked_snapshot_from_document(&document) { + Ok(snapshot) => snapshot, + Err(_) => { + return restart_report( + RestartStage::Preparation, + Some(RestartFailureKind::UnsupportedSavedShape), + None, + false, + false, + false, + ); + } + }; + let projection = SavedPeerProjection::from_document(&document); + if settings.interface_name != preview.interface + || settings.systemd_unit != preview.unit + || settings.restart_timeout != preview.timeout + || projection != preview.projection + || expected_clients != preview.expected_clients + { + return restart_report( + RestartStage::Preparation, + Some(RestartFailureKind::InvalidOrStaleSavedGeneration), + None, + false, + false, + false, + ); + } + if projection.document_unverifiable + || !projection.unverifiable_peer_indices.is_empty() + || !projection.duplicate_public_keys.is_empty() + { + return restart_report( + RestartStage::Preparation, + Some(RestartFailureKind::UnsupportedSavedShape), + None, + false, + false, + false, + ); + } + + let historical_apply = match historical_apply_from_saved(&installed) { + Ok(status) => status, + Err(_) => { + return restart_report( + RestartStage::Preparation, + Some(RestartFailureKind::InvalidOrStaleSavedGeneration), + None, + false, + false, + false, + ); + } + }; + let backup_path = match backup::create_archive( + self.store.archives_directory(), + BackupReason::PreRestart, + historical_apply, + &installed, + ) { + Ok(path) => path, + Err(_) => { + self.active_restart_attempt = None; + return restart_report( + RestartStage::Backup, + Some(RestartFailureKind::BackupFailed), + None, + false, + false, + false, + ); + } + }; + + let id = self.next_restart_attempt; + let Some(next_id) = id.checked_add(1) else { + return restart_report( + RestartStage::Preparation, + Some(RestartFailureKind::InvalidOrStaleSavedGeneration), + Some(backup_path), + false, + false, + false, + ); + }; + self.next_restart_attempt = next_id; + self.active_restart_attempt = Some(id); + if choice == RestartChoice::DiscardDraftAndRestart && self.discard_draft().is_err() { + self.active_restart_attempt = None; + return restart_report( + RestartStage::Preparation, + Some(RestartFailureKind::DraftDiscardFailed), + Some(backup_path), + false, + false, + false, + ); + } + + self.states.mark_runtime_unknown(); + let deadline = RestartDeadline::new(now(), settings.restart_timeout); + let execution = service(&settings.systemd_unit, deadline); + let job = execution.outcome; + let submitted = execution.submitted; + if job != RestartOutcome::Succeeded { + self.active_restart_attempt = None; + let may_complete = submitted + && !matches!( + job, + RestartOutcome::Failed( + service::RestartFailure::JobFailed + | service::RestartFailure::JobDependency + | service::RestartFailure::JobTimedOut + | service::RestartFailure::JobCanceled + | service::RestartFailure::JobSkipped + | service::RestartFailure::JobResultUnknown + ) + ); + return restart_report( + RestartStage::Service, + Some(RestartFailureKind::ServiceFailed), + Some(backup_path), + submitted, + may_complete, + submitted, + ); + } + let remaining = deadline.remaining_at(now()); + if remaining.is_zero() { + self.active_restart_attempt = None; + return restart_report( + RestartStage::RuntimeRead, + Some(RestartFailureKind::RuntimeTimeout), + Some(backup_path), + true, + false, + true, + ); + } + let observation = match runtime_read(&settings.interface_name, remaining) { + Ok(observation) => observation, + Err(error) => { + self.active_restart_attempt = None; + return restart_report( + RestartStage::RuntimeRead, + Some(match error { + TransportError::NoDevice => RestartFailureKind::RuntimeNoDevice, + TransportError::PermissionDenied => { + RestartFailureKind::RuntimePermissionDenied + } + TransportError::Malformed => RestartFailureKind::RuntimeMalformed, + TransportError::Transport => RestartFailureKind::RuntimeTransport, + TransportError::Timeout => RestartFailureKind::RuntimeTimeout, + TransportError::LimitExceeded => RestartFailureKind::RuntimeLimitExceeded, + }), + Some(backup_path), + true, + false, + true, + ); + } + }; + if deadline.is_expired_at(now()) { + self.active_restart_attempt = None; + return restart_report( + RestartStage::Verification, + Some(RestartFailureKind::RuntimeTimeout), + Some(backup_path), + true, + false, + true, + ); + } + if runtime::compare_peers(&projection, &settings.interface_name, &observation) + != TechnicalMatch::Match + { + self.active_restart_attempt = None; + return restart_report( + RestartStage::Verification, + Some(RestartFailureKind::RuntimeMismatch), + Some(backup_path), + true, + false, + true, + ); + } + let Some(verified_at) = OffsetDateTime::now_utc() + .replace_nanosecond(0) + .ok() + .and_then(|timestamp| timestamp.format(&Rfc3339).ok()) + else { + self.active_restart_attempt = None; + return restart_report( + RestartStage::Persistence, + Some(RestartFailureKind::VerificationPersistenceFailed), + Some(backup_path), + true, + false, + true, + ); + }; + let attempt = RestartAttempt { + id, + saved: clone_saved(&installed), + }; + let marked = + self.mark_verified_with_attempt(attempt, expected_clients, verified_at, install); + if marked.is_err() { + self.active_restart_attempt = None; + return restart_report( + RestartStage::Persistence, + Some(RestartFailureKind::VerificationPersistenceFailed), + Some(backup_path), + true, + false, + true, + ); + } + restart_report( + RestartStage::Complete, + None, + Some(backup_path), + true, + false, + false, + ) } /// Validate a selected archive without changing saved files or the draft. @@ -392,35 +891,26 @@ impl Application { self.states = states; self.draft_unsupported_reservations.clear(); self.historical_apply = HistoricalApplyStatus::Unknown; - self.pre_restart_backup_pending = false; + self.active_restart_attempt = None; Ok(archive) } - /// M5 calls this only after successful systemd job completion and runtime - /// verification. The saved generation and its pre-restart backup must still - /// match before the verification timestamp can be persisted. - #[allow(dead_code)] // Reserved as the narrow M5 adapter handoff boundary. - pub(crate) fn mark_verified_after_runtime_check( - &mut self, - observed: Snapshot, - verified_at_utc: String, - ) -> Result<(), AppError> { - self.mark_verified_with_install(observed, verified_at_utc, |store, saved, backup| { - store.install(saved, backup) - }) - } - - fn mark_verified_with_install( + /// Persist applied state only with the one-use authorization produced by + /// the active confirmed restart after full technical verification. + fn mark_verified_with_attempt( &mut self, + attempt: RestartAttempt, observed: Snapshot, verified_at_utc: String, install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>, ) -> Result<(), AppError> { - if !self.pre_restart_backup_pending { + if self.active_restart_attempt != Some(attempt.id) { return Err(AppError::PreRestartBackupRequired); } let installed = self.store.read_saved()?; - if !same_saved_bytes(&installed, &self.saved) { + if !same_saved_bytes(&installed, &self.saved) + || !same_saved_bytes(&installed, &attempt.saved) + { return Err(AppError::SavedGenerationChanged); } let mut next_states = self.states.clone(); @@ -451,7 +941,7 @@ impl Application { self.saved = proposed; self.states = next_states; self.historical_apply = historical_apply; - self.pre_restart_backup_pending = false; + self.active_restart_attempt = None; Ok(()) } @@ -534,7 +1024,7 @@ impl Application { self.draft_unsupported_reservations.clear(); self.document = ConfigDocument::parse(&self.saved.server_config)?; self.historical_apply = HistoricalApplyStatus::Unknown; - self.pre_restart_backup_pending = false; + self.active_restart_attempt = None; Ok(archive) } } @@ -566,6 +1056,49 @@ fn same_saved_bytes(left: &SavedBytes, right: &SavedBytes) -> bool { && left.vault == right.vault } +fn clone_saved(saved: &SavedBytes) -> SavedBytes { + SavedBytes { + settings: saved.settings.clone(), + server_config: saved.server_config.clone(), + internal_state: saved.internal_state.clone(), + vault: saved.vault.clone(), + } +} + +fn checked_snapshot_from_document(document: &ConfigDocument) -> Result { + if !document.issues().is_empty() { + return Err(AppError::UnsupportedSavedShape); + } + let mut clients = Vec::with_capacity(document.peers().len()); + for peer in document.peers() { + let identity = peer.identity.ok_or(AppError::UnsupportedSavedShape)?; + clients.push( + document + .project_client(&identity) + .map_err(|_| AppError::UnsupportedSavedShape)?, + ); + } + Snapshot::new(clients).map_err(|_| AppError::UnsupportedSavedShape) +} + +fn restart_report( + stage: RestartStage, + failure: Option, + backup_path: Option, + restart_submitted: bool, + restart_may_still_complete: bool, + manual_recovery_required: bool, +) -> RestartReport { + RestartReport { + stage, + failure, + backup_path, + restart_submitted, + restart_may_still_complete, + manual_recovery_required, + } +} + fn snapshot_from_document(document: &ConfigDocument) -> Snapshot { let mut snapshot = Snapshot::default(); for client in document.peers().iter().filter_map(|peer| { @@ -591,6 +1124,12 @@ pub enum AppError { DeletedPeerAddressUnknown, #[error("runtime verification cannot be recorded without a completed pre-restart backup")] PreRestartBackupRequired, + #[error("wg-quick SaveConfig may overwrite the saved server configuration during restart")] + UnsafeSaveConfig, + #[error( + "the complete saved peer set cannot be represented for durable applied-state recording" + )] + UnsupportedSavedShape, #[error("installed saved bytes changed after this session opened")] SavedGenerationChanged, #[error(transparent)] @@ -613,6 +1152,7 @@ mod tests { use std::net::{Ipv4Addr, Ipv6Addr}; use std::os::unix::fs::PermissionsExt; use std::path::Path; + use std::time::{Duration, Instant}; use crate::backup::{ArchiveCheckpoint, create_archive_with_hook}; use crate::backup::{BackupReason, HistoricalApplyStatus, create_archive, preview_archive}; @@ -623,10 +1163,19 @@ mod tests { use crate::persistence::{ BackupComplete, InstallCheckpoint, PersistedState, PersistenceError, SavedStore, }; + use crate::runtime::transport::TransportError; + use crate::runtime::{ + InterfaceObservation, Observed, PeerSetCompleteness, RuntimeObservation as ObservedRuntime, + RuntimePeer, RuntimePeerSet, + }; + use crate::service::{RestartExecution, RestartFailure, RestartOutcome}; use crate::vault::{VaultBundle, WireGuardPrivateKey}; use crate::wireguard::ConfigDocument; - use super::{AppError, Application, snapshot_from_document}; + use super::{ + AppError, Application, RestartChoice, RestartFailureKind, RestartStage, clone_saved, + snapshot_from_document, + }; const SETTINGS: &str = r#" interface_name = "wg0" @@ -654,7 +1203,7 @@ restart_timeout_seconds = 30 let secret = WireGuardPrivateKey::generate().expect("synthetic peer key"); let key = secret.public_key(); let server = format!( - "[Interface]\nPrivateKey = synthetic\nAddress = 10.20.0.1/24\n\n# admin note\n[Peer]\nPublicKey = {key}\nAllowedIPs = 10.20.0.2/32\n" + "[Interface]\nPrivateKey = synthetic\nAddress = 10.20.0.1/24\n\n# admin note\n[Peer]\nPublicKey = {key}\nAllowedIPs = 10.20.0.2/32, fd20::2/128\n" ); fs::write(root.join("server.conf"), server.as_bytes()).expect("server installed"); fs::set_permissions(root.join("server.conf"), fs::Permissions::from_mode(0o600)) @@ -683,6 +1232,55 @@ restart_timeout_seconds = 30 (store, states, key, secret) } + fn test_mark_verified( + app: &mut Application, + observed: Snapshot, + verified_at: String, + ) -> Result<(), AppError> { + app.create_pre_restart_backup()?; + let id = app.next_restart_attempt; + app.next_restart_attempt += 1; + app.active_restart_attempt = Some(id); + let attempt = super::RestartAttempt { + id, + saved: clone_saved(&app.saved), + }; + app.mark_verified_with_attempt(attempt, observed, verified_at, |store, saved, backup| { + store.install(saved, backup) + }) + } + + fn prepare_restartable(root: &Path) -> (Application, PublicKey) { + let (store, states, key, _) = setup(root, false); + let mut app = Application::open(store, states).expect("application opens"); + app.rename_client(key, "restart fixture") + .expect("install managed metadata"); + app.commit().expect("restart fixture commits"); + (app, key) + } + + fn observed_runtime(app: &Application) -> ObservedRuntime { + let records = app.document().peers().iter().map(|peer| { + let identity = peer.identity.expect("synthetic peer identity"); + ( + identity, + RuntimePeer { + allowed_ips: Observed::Available(peer.allowed_ips.clone()), + last_handshake: Observed::Available(None), + received_bytes: Observed::Available(0), + transmitted_bytes: Observed::Available(0), + }, + ) + }); + ObservedRuntime { + observed_at: None, + interface: InterfaceObservation::Present { + name: "wg0".to_owned(), + peers: RuntimePeerSet::from_records(PeerSetCompleteness::Complete, records), + }, + } + } + #[test] fn backup_failure_preserves_installed_files_and_draft() { let root = tempfile::tempdir().expect("temp directory"); @@ -1284,6 +1882,440 @@ restart_timeout_seconds = 30 assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); } + #[test] + fn restart_cancel_preserves_draft_and_never_calls_adapters() { + let root = tempfile::tempdir().expect("temp directory"); + let (mut app, key) = prepare_restartable(root.path()); + app.rename_client(key, "pending draft") + .expect("stage draft alias"); + let preview = app.preview_restart().expect("preview saved generation"); + let called = std::cell::Cell::new(false); + let report = app.restart_with_adapters( + preview, + RestartChoice::Cancel, + |_, _| { + called.set(true); + RestartExecution { + outcome: RestartOutcome::Succeeded, + submitted: true, + } + }, + |_, _| { + called.set(true); + Err(TransportError::Transport) + }, + |_, _, _| { + called.set(true); + Ok(()) + }, + ); + assert_eq!(report.stage, RestartStage::Cancelled); + assert!(!called.get()); + assert_eq!( + app.states().draft().get(&key).unwrap().alias, + "pending draft" + ); + } + + #[test] + fn restart_backup_failure_preserves_draft_and_submits_no_job() { + let root = tempfile::tempdir().expect("temp directory"); + let (mut app, key) = prepare_restartable(root.path()); + app.rename_client(key, "pending draft") + .expect("stage draft alias"); + let preview = app.preview_restart().expect("preview saved generation"); + let archives = root.path().join("Backup/archives"); + fs::remove_dir_all(&archives).expect("remove archive directory"); + fs::write(&archives, b"synthetic archive blocker").expect("install archive blocker"); + let submitted = std::cell::Cell::new(false); + let report = app.restart_with_adapters( + preview, + RestartChoice::DiscardDraftAndRestart, + |_, _| { + submitted.set(true); + RestartExecution { + outcome: RestartOutcome::Succeeded, + submitted: true, + } + }, + |_, _| Err(TransportError::Transport), + |_, _, _| Ok(()), + ); + assert_eq!(report.stage, RestartStage::Backup); + assert_eq!(report.failure, Some(RestartFailureKind::BackupFailed)); + assert!(!report.restart_submitted); + assert!(!submitted.get()); + assert_eq!( + app.states().draft().get(&key).unwrap().alias, + "pending draft" + ); + } + + #[test] + fn confirmed_restart_orders_backup_job_read_and_durable_publication() { + let root = tempfile::tempdir().expect("temp directory"); + let (mut app, key) = prepare_restartable(root.path()); + app.rename_client(key, "discard me") + .expect("stage draft alias"); + let preview = app.preview_restart().expect("preview saved generation"); + let observation = observed_runtime(&app); + let archives = root.path().join("Backup/archives"); + let events = std::cell::RefCell::new(Vec::new()); + let report = app.restart_with_adapters( + preview, + RestartChoice::DiscardDraftAndRestart, + |unit, _| { + assert_eq!(unit, "wg-quick@wg0.service"); + assert!( + crate::backup::list_archives(&archives) + .expect("archive listing") + .iter() + .any(|archive| archive.reason == BackupReason::PreRestart) + ); + events.borrow_mut().push("service"); + RestartExecution { + outcome: RestartOutcome::Succeeded, + submitted: true, + } + }, + |interface, remaining| { + assert_eq!(interface, "wg0"); + assert!(!remaining.is_zero()); + events.borrow_mut().push("runtime"); + Ok(observation) + }, + |store, saved, backup| { + events.borrow_mut().push("persist"); + store.install(saved, backup) + }, + ); + assert_eq!(report.stage, RestartStage::Complete, "{report:?}"); + assert!(report.backup_path.is_some()); + assert!(report.restart_submitted); + assert_eq!(*events.borrow(), vec!["service", "runtime", "persist"]); + assert_eq!(app.states().applied(), &RuntimeObservation::Verified); + assert_eq!( + app.states().draft().get(&key).unwrap().alias, + "restart fixture" + ); + } + + #[test] + fn service_timeout_reports_submission_and_may_still_complete() { + let root = tempfile::tempdir().expect("temp directory"); + let (mut app, _) = prepare_restartable(root.path()); + let preview = app.preview_restart().expect("preview saved generation"); + let runtime_called = std::cell::Cell::new(false); + let report = app.restart_with_adapters( + preview, + RestartChoice::RestartSavedState, + |_, _| RestartExecution { + outcome: RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete), + submitted: true, + }, + |_, _| { + runtime_called.set(true); + Err(TransportError::Transport) + }, + |_, _, _| Ok(()), + ); + assert_eq!(report.stage, RestartStage::Service); + assert!(report.restart_submitted); + assert!(report.restart_may_still_complete); + assert!(report.manual_recovery_required); + assert!(!runtime_called.get()); + } + + #[test] + fn shared_deadline_expired_after_successful_job_stops_before_runtime_read() { + let root = tempfile::tempdir().expect("temp directory"); + let (mut app, key) = prepare_restartable(root.path()); + app.delete_client(key).expect("delete saved peer in draft"); + app.commit().expect("save deletion and reservation"); + let preview = app.preview_restart().expect("preview saved generation"); + let started_at = Instant::now(); + let clock_values = [started_at, started_at + Duration::from_secs(30)]; + let clock_index = std::cell::Cell::new(0); + let runtime_called = std::cell::Cell::new(false); + + let report = app.restart_with_adapters_and_clock( + preview, + RestartChoice::RestartSavedState, + |_, deadline| { + assert_eq!(deadline.started_at(), started_at); + assert_eq!(deadline.timeout(), Duration::from_secs(30)); + RestartExecution { + outcome: RestartOutcome::Succeeded, + submitted: true, + } + }, + |_, _| { + runtime_called.set(true); + Err(TransportError::Transport) + }, + |store, saved, backup| store.install(saved, backup), + || { + let index = clock_index.get(); + clock_index.set(index + 1); + clock_values[index] + }, + ); + + assert_eq!(report.stage, RestartStage::RuntimeRead); + assert_eq!(report.failure, Some(RestartFailureKind::RuntimeTimeout)); + assert!(report.backup_path.is_some()); + assert!(report.restart_submitted); + assert!(!report.restart_may_still_complete); + assert!(report.manual_recovery_required); + assert!(!runtime_called.get()); + assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert!(!app.states().reserved_addresses().is_empty()); + } + + #[test] + fn shared_deadline_expired_after_runtime_read_reports_timeout_before_comparison() { + let root = tempfile::tempdir().expect("temp directory"); + let (mut app, key) = prepare_restartable(root.path()); + app.delete_client(key).expect("delete saved peer in draft"); + app.commit().expect("save deletion and reservation"); + let preview = app.preview_restart().expect("preview saved generation"); + let started_at = Instant::now(); + let clock_values = [started_at, started_at, started_at + Duration::from_secs(30)]; + let clock_index = std::cell::Cell::new(0); + let install_called = std::cell::Cell::new(false); + let observation = observed_runtime(&app); + + let report = app.restart_with_adapters_and_clock( + preview, + RestartChoice::RestartSavedState, + |_, deadline| { + assert_eq!(deadline.started_at(), started_at); + assert_eq!(deadline.timeout(), Duration::from_secs(30)); + RestartExecution { + outcome: RestartOutcome::Succeeded, + submitted: true, + } + }, + |_, remaining| { + assert_eq!(remaining, Duration::from_secs(30)); + Ok(observation) + }, + |store, saved, backup| { + install_called.set(true); + store.install(saved, backup) + }, + || { + let index = clock_index.get(); + clock_index.set(index + 1); + clock_values[index] + }, + ); + + assert_eq!(report.stage, RestartStage::Verification); + assert_eq!(report.failure, Some(RestartFailureKind::RuntimeTimeout)); + assert!(report.backup_path.is_some()); + assert!(report.restart_submitted); + assert!(!report.restart_may_still_complete); + assert!(report.manual_recovery_required); + assert!(!install_called.get()); + assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert!(!app.states().reserved_addresses().is_empty()); + } + + #[test] + fn runtime_transport_failures_remain_distinct_in_public_reports() { + let cases = [ + ( + TransportError::NoDevice, + RestartFailureKind::RuntimeNoDevice, + ), + ( + TransportError::PermissionDenied, + RestartFailureKind::RuntimePermissionDenied, + ), + ( + TransportError::Malformed, + RestartFailureKind::RuntimeMalformed, + ), + ( + TransportError::Transport, + RestartFailureKind::RuntimeTransport, + ), + (TransportError::Timeout, RestartFailureKind::RuntimeTimeout), + ( + TransportError::LimitExceeded, + RestartFailureKind::RuntimeLimitExceeded, + ), + ]; + + for (error, expected) in cases { + let root = tempfile::tempdir().expect("temp directory"); + let (mut app, _) = prepare_restartable(root.path()); + let preview = app.preview_restart().expect("preview saved generation"); + let install_called = std::cell::Cell::new(false); + let report = app.restart_with_adapters( + preview, + RestartChoice::RestartSavedState, + |_, _| RestartExecution { + outcome: RestartOutcome::Succeeded, + submitted: true, + }, + |_, _| Err(error), + |store, saved, backup| { + install_called.set(true); + store.install(saved, backup) + }, + ); + + assert_eq!(report.stage, RestartStage::RuntimeRead); + assert_eq!(report.failure, Some(expected)); + assert!(report.restart_submitted); + assert!(!report.restart_may_still_complete); + assert!(report.manual_recovery_required); + assert!(!install_called.get()); + assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + } + } + + #[test] + fn submitted_terminal_job_failures_cannot_still_complete() { + let root = tempfile::tempdir().expect("temp directory"); + let (mut app, _) = prepare_restartable(root.path()); + + for failure in [ + RestartFailure::JobFailed, + RestartFailure::JobDependency, + RestartFailure::JobTimedOut, + RestartFailure::JobCanceled, + RestartFailure::JobSkipped, + RestartFailure::JobResultUnknown, + ] { + let preview = app.preview_restart().expect("preview saved generation"); + let report = app.restart_with_adapters( + preview, + RestartChoice::RestartSavedState, + |_, _| RestartExecution { + outcome: RestartOutcome::Failed(failure), + submitted: true, + }, + |_, _| unreachable!("terminal service failure must stop before runtime read"), + |_, _, _| unreachable!("terminal service failure must stop before persistence"), + ); + + assert_eq!(report.stage, RestartStage::Service); + assert!(report.restart_submitted); + assert!(!report.restart_may_still_complete); + assert!(report.manual_recovery_required); + } + } + + #[test] + fn restart_mismatch_and_post_restart_write_failure_never_release_addresses() { + let root = tempfile::tempdir().expect("temp directory"); + let (mut app, key) = prepare_restartable(root.path()); + app.delete_client(key).expect("delete saved peer in draft"); + app.commit().expect("save deletion and reservation"); + assert!(!app.states().reserved_addresses().is_empty()); + let preview = app.preview_restart().expect("preview empty saved peer set"); + let mismatch = app.restart_with_adapters( + preview, + RestartChoice::RestartSavedState, + |_, _| RestartExecution { + outcome: RestartOutcome::Succeeded, + submitted: true, + }, + |_, _| { + Ok(ObservedRuntime { + observed_at: None, + interface: InterfaceObservation::Missing, + }) + }, + |store, saved, backup| store.install(saved, backup), + ); + assert_eq!(mismatch.stage, RestartStage::Verification); + assert!(!app.states().reserved_addresses().is_empty()); + + let preview = app.preview_restart().expect("preview remains available"); + let observation = observed_runtime(&app); + let failed_write = app.restart_with_adapters( + preview, + RestartChoice::RestartSavedState, + |_, _| RestartExecution { + outcome: RestartOutcome::Succeeded, + submitted: true, + }, + |_, _| Ok(observation), + |_, _, _| Err(PersistenceError::RecoveryRequired), + ); + assert_eq!(failed_write.stage, RestartStage::Persistence); + assert!(failed_write.manual_recovery_required); + assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert!(!app.states().reserved_addresses().is_empty()); + } + + #[test] + fn restart_rejects_unsafe_saveconfig_before_backup_or_submission() { + let root = tempfile::tempdir().expect("temp directory"); + let (mut app, key) = prepare_restartable(root.path()); + let mut saved = clone_saved(app.saved_bytes()); + let server = String::from_utf8(saved.server_config.clone()).expect("synthetic config"); + saved.server_config = server + .replace( + "Address = 10.20.0.1/24", + "Address = 10.20.0.1/24\nSaveConfig = true", + ) + .into_bytes(); + app.store + .install(&saved, BackupComplete(())) + .expect("install synthetic unsafe generation"); + let reopened_store = app.store.clone(); + let states = app.states.clone(); + app = Application::open(reopened_store, states).expect("reopen fixture"); + app.rename_client(key, "draft") + .expect("stage a draft for preservation"); + assert!(matches!( + app.preview_restart(), + Err(AppError::UnsafeSaveConfig) + )); + } + + #[test] + fn restart_confirmation_rejects_a_generation_changed_after_preview() { + let root = tempfile::tempdir().expect("temp directory"); + let (mut app, _) = prepare_restartable(root.path()); + let preview = app.preview_restart().expect("preview saved generation"); + let mut externally_replaced = clone_saved(app.saved_bytes()); + let settings = + String::from_utf8(externally_replaced.settings.clone()).expect("synthetic settings"); + externally_replaced.settings = settings + .replace("wg-quick@wg0.service", "wg-quick@wg1.service") + .into_bytes(); + app.store + .install(&externally_replaced, BackupComplete(())) + .expect("install changed synthetic generation"); + let called = std::cell::Cell::new(false); + let report = app.restart_with_adapters( + preview, + RestartChoice::RestartSavedState, + |_, _| { + called.set(true); + RestartExecution { + outcome: RestartOutcome::Succeeded, + submitted: true, + } + }, + |_, _| Err(TransportError::Transport), + |_, _, _| Ok(()), + ); + assert_eq!(report.stage, RestartStage::Preparation); + assert_eq!( + report.failure, + Some(RestartFailureKind::InvalidOrStaleSavedGeneration) + ); + assert!(!called.get()); + assert!(report.backup_path.is_none()); + } + #[test] fn verified_history_persists_across_reopen_without_marking_runtime_applied() { let root = tempfile::tempdir().expect("temp directory"); @@ -1291,9 +2323,7 @@ restart_timeout_seconds = 30 let mut app = Application::open(store.clone(), states.clone()).expect("application opens"); let verified_at = "2026-10-20T08:15:00Z"; let observed = app.states().saved().clone(); - app.create_pre_restart_backup() - .expect("pre-restart backup completes"); - app.mark_verified_after_runtime_check(observed, verified_at.to_owned()) + test_mark_verified(&mut app, observed, verified_at.to_owned()) .expect("verified generation persists"); let saved = store.read_saved().expect("verified saved bytes"); @@ -1323,7 +2353,15 @@ restart_timeout_seconds = 30 let observed = app.states().saved().clone(); app.create_pre_restart_backup() .expect("pre-restart backup completes"); - let result = app.mark_verified_with_install( + let id = app.next_restart_attempt; + app.next_restart_attempt += 1; + app.active_restart_attempt = Some(id); + let attempt = super::RestartAttempt { + id, + saved: clone_saved(&app.saved), + }; + let result = app.mark_verified_with_attempt( + attempt, observed, "2026-10-20T08:30:00Z".to_owned(), |store, saved, _backup| { @@ -1366,9 +2404,7 @@ restart_timeout_seconds = 30 let mut app = Application::open(store.clone(), states.clone()).expect("application opens"); let verified_at = "2026-10-20T09:00:00Z"; let observed = app.states().saved().clone(); - app.create_pre_restart_backup() - .expect("pre-restart backup completes"); - app.mark_verified_after_runtime_check(observed, verified_at.to_owned()) + test_mark_verified(&mut app, observed, verified_at.to_owned()) .expect("verified generation persists"); app.rename_client(key, "next saved alias") .expect("draft alias"); @@ -1406,9 +2442,7 @@ restart_timeout_seconds = 30 let mut app = Application::open(store.clone(), states).expect("application opens"); let verified_at = "2026-10-20T10:00:00Z"; let observed = app.states().saved().clone(); - app.create_pre_restart_backup() - .expect("pre-restart backup completes"); - app.mark_verified_after_runtime_check(observed, verified_at.to_owned()) + test_mark_verified(&mut app, observed, verified_at.to_owned()) .expect("verified generation persists"); let before = store.read_saved().expect("verified saved bytes"); app.rename_client(key, "retryable draft") @@ -1443,9 +2477,7 @@ restart_timeout_seconds = 30 let selected = app.create_manual_backup().expect("select initial archive"); let verified_at = "2026-10-20T11:00:00Z"; let observed = app.states().saved().clone(); - app.create_pre_restart_backup() - .expect("pre-restart backup completes"); - app.mark_verified_after_runtime_check(observed, verified_at.to_owned()) + test_mark_verified(&mut app, observed, verified_at.to_owned()) .expect("verified generation persists"); let preview = app .preview_restore(selected.file_name().unwrap().to_str().unwrap()) diff --git a/src/domain.rs b/src/domain.rs index 2277fa6..88f2afe 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -256,7 +256,7 @@ impl ClientStates { } /// Record an applied state only when the verified observation matches saved clients. - pub fn mark_applied_after_verification( + pub(crate) fn mark_applied_after_verification( &mut self, observed: Snapshot, ) -> Result<(), DomainError> { diff --git a/src/lib.rs b/src/lib.rs index 0dac691..2b2a863 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,8 @@ pub mod backup; pub mod config; pub mod domain; pub mod persistence; +pub mod runtime; +pub mod service; pub mod startup; pub mod vault; pub mod wireguard; diff --git a/src/runtime.rs b/src/runtime.rs new file mode 100644 index 0000000..12a67cf --- /dev/null +++ b/src/runtime.rs @@ -0,0 +1,674 @@ +//! Public, read-only runtime observations and pure saved-peer verification. +//! +//! The transport performs bounded read-only Linux access; comparison remains +//! pure and accepts caller-supplied observations. + +use std::collections::{BTreeMap, BTreeSet}; + +use ipnet::IpNet; +use time::OffsetDateTime; + +use crate::domain::PublicKey; +use crate::wireguard::{ConfigDocument, DocumentIssue, PeerIssue}; + +#[allow(dead_code)] // Integrated by the M5 orchestration step. +pub(crate) mod netlink; +#[allow(dead_code)] // Consumed by the M5 application integration step. +pub(crate) mod transport; + +/// A value that could not be observed is distinct from a valid zero or empty value. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Observed { + Available(T), + Unavailable, +} + +/// Current statistics for a peer, from the server interface's perspective. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RuntimePeer { + pub allowed_ips: Observed>, + /// `Available(None)` means WireGuard has never recorded a handshake. + pub last_handshake: Observed>, + /// These are current counters and may reset when the interface restarts. + pub received_bytes: Observed, + pub transmitted_bytes: Observed, +} + +/// Whether the peer map is exhaustive enough to establish absence and equality. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PeerSetCompleteness { + Unknown, + Unavailable, + Partial, + Complete, +} + +/// Runtime peers keyed by their public key, with duplicate identities retained +/// as an explicit ambiguity signal rather than silently overwritten. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RuntimePeerSet { + pub peers: BTreeMap, + pub duplicate_public_keys: BTreeSet, + pub completeness: PeerSetCompleteness, +} + +impl RuntimePeerSet { + /// Collect records while preserving evidence that the adapter returned a + /// duplicate public key. The first record remains available for diagnostics. + pub fn from_records( + completeness: PeerSetCompleteness, + records: impl IntoIterator, + ) -> Self { + let mut peers = BTreeMap::new(); + let mut duplicate_public_keys = BTreeSet::new(); + for (identity, peer) in records { + if let std::collections::btree_map::Entry::Vacant(entry) = peers.entry(identity) { + entry.insert(peer); + } else { + duplicate_public_keys.insert(identity); + } + } + Self { + peers, + duplicate_public_keys, + completeness, + } + } +} + +/// Interface presence and peer observation are independent facts. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum InterfaceObservation { + Unknown, + Unavailable, + Missing, + Present { + /// Interface returned by the runtime adapter. + name: String, + peers: RuntimePeerSet, + }, +} + +/// One read-only sample. A missing timestamp means the sample time is unknown. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RuntimeObservation { + pub observed_at: Option, + pub interface: InterfaceObservation, +} + +impl RuntimeObservation { + pub const fn unknown() -> Self { + Self { + observed_at: None, + interface: InterfaceObservation::Unknown, + } + } +} + +/// One saved peer's complete technical routes, independent of alias metadata +/// and the narrower `domain::Client` address representation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SavedPeer { + pub identity: PublicKey, + pub allowed_ips: BTreeSet, +} + +/// Technical peer projection from the authoritative byte-preserving document. +/// Invalid or ambiguous records remain represented as unverifiable entries. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SavedPeerProjection { + pub peers: BTreeMap, + pub duplicate_public_keys: BTreeSet, + pub unverifiable_peer_indices: BTreeSet, + pub document_unverifiable: bool, +} + +impl SavedPeerProjection { + /// Project every `[Peer]` section, retaining valid but unsupported route + /// shapes that cannot fit the UI's ordinary client address model. + pub fn from_document(document: &ConfigDocument) -> Self { + let mut peers = BTreeMap::new(); + let mut duplicate_public_keys = BTreeSet::new(); + let mut unverifiable_peer_indices = BTreeSet::new(); + + for peer in document.peers() { + let Some(identity) = peer.identity else { + unverifiable_peer_indices.insert(peer.index); + continue; + }; + let invalid_route = peer + .issues + .iter() + .any(|issue| matches!(issue, PeerIssue::MalformedAllowedIp { .. })); + let duplicate_key_field = peer.issues.iter().any(|issue| { + matches!( + issue, + PeerIssue::MalformedPublicKey { .. } + | PeerIssue::DuplicatePublicKeyField { .. } + | PeerIssue::DuplicatePeerIdentity { .. } + ) + }); + if invalid_route || duplicate_key_field { + unverifiable_peer_indices.insert(peer.index); + } + + let routes = peer + .allowed_ips + .iter() + .map(|network| network.trunc()) + .collect(); + if peers + .insert( + identity, + SavedPeer { + identity, + allowed_ips: routes, + }, + ) + .is_some() + { + duplicate_public_keys.insert(identity); + unverifiable_peer_indices.insert(peer.index); + } + } + + let document_unverifiable = document + .issues() + .iter() + .any(|issue| !matches!(issue, DocumentIssue::ManagedMetadata(_))); + Self { + peers, + duplicate_public_keys, + unverifiable_peer_indices, + document_unverifiable, + } + } +} + +/// A technical discrepancy or a reason verification could not safely decide. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ComparisonFinding { + InterfaceUnknown, + InterfaceUnavailable, + InterfaceMissing, + InterfaceMismatch { expected: String, observed: String }, + PeerSetUnknown, + PeerSetUnavailable, + PeerSetPartial, + SavedDocumentUnverifiable, + SavedPeerUnverifiable { peer_index: usize }, + DuplicateSavedPublicKey { identity: PublicKey }, + DuplicateRuntimePublicKey { identity: PublicKey }, + MissingRuntimePeer { identity: PublicKey }, + ExtraRuntimePeer { identity: PublicKey }, + RuntimeRoutesUnavailable { identity: PublicKey }, + AllowedIpsMismatch { identity: PublicKey }, +} + +/// Outcome does not depend on handshake, counters, aliases, or record ordering. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TechnicalMatch { + Match, + Mismatch(Vec), + Unverifiable(Vec), +} + +/// Compare the complete saved peer set with a runtime sample, requiring the +/// observed interface to be the interface selected by current configuration. +pub fn compare_peers( + saved: &SavedPeerProjection, + expected_interface: &str, + runtime: &RuntimeObservation, +) -> TechnicalMatch { + let mut findings = Vec::new(); + let runtime_peers = match &runtime.interface { + InterfaceObservation::Unknown => { + return TechnicalMatch::Unverifiable(vec![ComparisonFinding::InterfaceUnknown]); + } + InterfaceObservation::Unavailable => { + return TechnicalMatch::Unverifiable(vec![ComparisonFinding::InterfaceUnavailable]); + } + InterfaceObservation::Missing => { + return TechnicalMatch::Mismatch(vec![ComparisonFinding::InterfaceMissing]); + } + InterfaceObservation::Present { name, peers } => { + if name != expected_interface { + return TechnicalMatch::Mismatch(vec![ComparisonFinding::InterfaceMismatch { + expected: expected_interface.to_owned(), + observed: name.clone(), + }]); + } + peers + } + }; + + if saved.document_unverifiable { + findings.push(ComparisonFinding::SavedDocumentUnverifiable); + } + findings.extend(saved.unverifiable_peer_indices.iter().map(|peer_index| { + ComparisonFinding::SavedPeerUnverifiable { + peer_index: *peer_index, + } + })); + findings.extend(saved.duplicate_public_keys.iter().map(|identity| { + ComparisonFinding::DuplicateSavedPublicKey { + identity: *identity, + } + })); + findings.extend(runtime_peers.duplicate_public_keys.iter().map(|identity| { + ComparisonFinding::DuplicateRuntimePublicKey { + identity: *identity, + } + })); + + match runtime_peers.completeness { + PeerSetCompleteness::Unknown => findings.push(ComparisonFinding::PeerSetUnknown), + PeerSetCompleteness::Unavailable => findings.push(ComparisonFinding::PeerSetUnavailable), + PeerSetCompleteness::Partial => findings.push(ComparisonFinding::PeerSetPartial), + PeerSetCompleteness::Complete => {} + } + + for (identity, expected) in &saved.peers { + match runtime_peers.peers.get(identity) { + None if runtime_peers.completeness == PeerSetCompleteness::Complete => { + findings.push(ComparisonFinding::MissingRuntimePeer { + identity: *identity, + }); + } + None => {} + Some(actual) => match &actual.allowed_ips { + Observed::Unavailable => { + findings.push(ComparisonFinding::RuntimeRoutesUnavailable { + identity: *identity, + }) + } + Observed::Available(routes) => { + let normalized: BTreeSet = + routes.iter().map(|network| network.trunc()).collect(); + if normalized != expected.allowed_ips { + findings.push(ComparisonFinding::AllowedIpsMismatch { + identity: *identity, + }); + } + } + }, + } + } + if runtime_peers.completeness == PeerSetCompleteness::Complete { + for identity in runtime_peers.peers.keys() { + if !saved.peers.contains_key(identity) { + findings.push(ComparisonFinding::ExtraRuntimePeer { + identity: *identity, + }); + } + } + } + + if findings.is_empty() { + TechnicalMatch::Match + } else if findings.iter().any(is_unverifiable_finding) { + TechnicalMatch::Unverifiable(findings) + } else { + TechnicalMatch::Mismatch(findings) + } +} + +fn is_unverifiable_finding(finding: &ComparisonFinding) -> bool { + matches!( + finding, + ComparisonFinding::InterfaceUnknown + | ComparisonFinding::InterfaceUnavailable + | ComparisonFinding::PeerSetUnknown + | ComparisonFinding::PeerSetUnavailable + | ComparisonFinding::PeerSetPartial + | ComparisonFinding::SavedDocumentUnverifiable + | ComparisonFinding::SavedPeerUnverifiable { .. } + | ComparisonFinding::DuplicateSavedPublicKey { .. } + | ComparisonFinding::DuplicateRuntimePublicKey { .. } + | ComparisonFinding::RuntimeRoutesUnavailable { .. } + ) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + + const KEY_A: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + const KEY_B: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="; + + fn key(value: &str) -> PublicKey { + PublicKey::from_str(value).unwrap() + } + + fn runtime_peer(routes: Observed>) -> RuntimePeer { + RuntimePeer { + allowed_ips: routes, + last_handshake: Observed::Available(None), + received_bytes: Observed::Available(0), + transmitted_bytes: Observed::Available(0), + } + } + + fn observation(records: Vec<(PublicKey, RuntimePeer)>) -> RuntimeObservation { + RuntimeObservation { + observed_at: None, + interface: InterfaceObservation::Present { + name: "wg0".to_owned(), + peers: RuntimePeerSet::from_records(PeerSetCompleteness::Complete, records), + }, + } + } + + fn compare(saved: &SavedPeerProjection, runtime: &RuntimeObservation) -> TechnicalMatch { + compare_peers(saved, "wg0", runtime) + } + + fn projection(source: &str) -> SavedPeerProjection { + SavedPeerProjection::from_document(&ConfigDocument::parse(source).unwrap()) + } + + fn route(value: &str) -> IpNet { + value.parse().unwrap() + } + + #[test] + fn observation_distinguishes_unknown_missing_unavailable_zero_and_no_handshake() { + assert_eq!( + RuntimeObservation::unknown().interface, + InterfaceObservation::Unknown + ); + assert_eq!( + RuntimeObservation { + observed_at: None, + interface: InterfaceObservation::Unavailable, + } + .interface, + InterfaceObservation::Unavailable + ); + assert!(matches!( + runtime_peer(Observed::Available(Vec::new())).last_handshake, + Observed::Available(None) + )); + assert_eq!( + runtime_peer(Observed::Available(Vec::new())).received_bytes, + Observed::Available(0) + ); + assert_eq!( + runtime_peer(Observed::Unavailable).allowed_ips, + Observed::Unavailable + ); + } + + #[test] + fn comparison_accepts_empty_set_and_normalized_reordered_dual_stack_routes() { + let empty = projection(""); + assert_eq!( + compare(&empty, &observation(Vec::new())), + TechnicalMatch::Match + ); + + let source = + format!("[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = fd00::2/128, 10.0.0.2/32\n"); + let expected = projection(&source); + let actual = observation(vec![( + key(KEY_A), + runtime_peer(Observed::Available(vec![ + route("10.0.0.2/32"), + route("fd00::2/128"), + route("10.0.0.2/32"), + ])), + )]); + assert_eq!(compare(&expected, &actual), TechnicalMatch::Match); + } + + #[test] + fn comparison_requires_the_configured_interface_identity() { + let expected = projection(&format!( + "[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n" + )); + let wrong_interface = RuntimeObservation { + observed_at: None, + interface: InterfaceObservation::Present { + name: "wg1".to_owned(), + peers: RuntimePeerSet::from_records( + PeerSetCompleteness::Complete, + [( + key(KEY_A), + runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])), + )], + ), + }, + }; + assert_eq!( + compare_peers(&expected, "wg0", &wrong_interface), + TechnicalMatch::Mismatch(vec![ComparisonFinding::InterfaceMismatch { + expected: "wg0".to_owned(), + observed: "wg1".to_owned(), + }]) + ); + } + + #[test] + fn counters_and_handshake_changes_do_not_change_technical_match() { + let expected = projection(&format!( + "[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n" + )); + let mut before_restart_peer = runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])); + before_restart_peer.last_handshake = Observed::Available(Some( + OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(), + )); + before_restart_peer.received_bytes = Observed::Available(1_000); + before_restart_peer.transmitted_bytes = Observed::Available(300); + let before_restart = observation(vec![(key(KEY_A), before_restart_peer)]); + let mut after_restart_peer = runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])); + after_restart_peer.last_handshake = Observed::Available(None); + after_restart_peer.received_bytes = Observed::Available(0); + after_restart_peer.transmitted_bytes = Observed::Available(7); + let after_restart = observation(vec![(key(KEY_A), after_restart_peer)]); + + assert_eq!(compare(&expected, &before_restart), TechnicalMatch::Match); + assert_eq!(compare(&expected, &after_restart), TechnicalMatch::Match); + } + + #[test] + fn projection_keeps_valid_unsupported_routes_and_ipv4_only_peers() { + let source = format!( + "[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32, 10.20.0.0/16\n[Peer]\nPublicKey = {KEY_B}\nAllowedIPs = 10.0.0.3/32\n" + ); + let expected = projection(&source); + assert_eq!(expected.peers.len(), 2); + assert!(expected.unverifiable_peer_indices.is_empty()); + let actual = observation(vec![ + ( + key(KEY_A), + runtime_peer(Observed::Available(vec![ + route("10.20.0.0/16"), + route("10.0.0.2/32"), + ])), + ), + ( + key(KEY_B), + runtime_peer(Observed::Available(vec![route("10.0.0.3/32")])), + ), + ]); + assert_eq!(compare(&expected, &actual), TechnicalMatch::Match); + } + + #[test] + fn comparison_reports_missing_extra_and_route_mismatch() { + let expected = projection(&format!( + "[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n" + )); + let actual = observation(vec![( + key(KEY_B), + runtime_peer(Observed::Available(vec![route("10.0.0.3/32")])), + )]); + let TechnicalMatch::Mismatch(findings) = compare(&expected, &actual) else { + panic!("expected concrete set differences"); + }; + assert!(findings.contains(&ComparisonFinding::MissingRuntimePeer { + identity: key(KEY_A) + })); + assert!(findings.contains(&ComparisonFinding::ExtraRuntimePeer { + identity: key(KEY_B) + })); + + let actual = observation(vec![( + key(KEY_A), + runtime_peer(Observed::Available(vec![route("10.0.0.9/32")])), + )]); + assert!(matches!( + compare(&expected, &actual), + TechnicalMatch::Mismatch(findings) + if findings.contains(&ComparisonFinding::AllowedIpsMismatch { identity: key(KEY_A) }) + )); + } + + #[test] + fn partial_duplicate_and_unavailable_records_never_pass() { + let expected = projection(&format!( + "[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n" + )); + let duplicate = RuntimePeerSet::from_records( + PeerSetCompleteness::Complete, + [ + ( + key(KEY_A), + runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])), + ), + ( + key(KEY_A), + runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])), + ), + ], + ); + assert!(matches!( + compare( + &expected, + &RuntimeObservation { + observed_at: None, + interface: InterfaceObservation::Present { + name: "wg0".to_owned(), + peers: duplicate, + }, + } + ), + TechnicalMatch::Unverifiable(findings) + if findings.contains(&ComparisonFinding::DuplicateRuntimePublicKey { identity: key(KEY_A) }) + )); + + for completeness in [ + PeerSetCompleteness::Unknown, + PeerSetCompleteness::Unavailable, + PeerSetCompleteness::Partial, + ] { + let partial = RuntimeObservation { + observed_at: None, + interface: InterfaceObservation::Present { + name: "wg0".to_owned(), + peers: RuntimePeerSet { + peers: BTreeMap::from([( + key(KEY_A), + runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])), + )]), + duplicate_public_keys: BTreeSet::new(), + completeness, + }, + }, + }; + let TechnicalMatch::Unverifiable(findings) = compare(&expected, &partial) else { + panic!("an incomplete peer set cannot verify"); + }; + if completeness != PeerSetCompleteness::Complete { + assert!(!findings.iter().any(|finding| matches!( + finding, + ComparisonFinding::MissingRuntimePeer { .. } + ))); + } + } + + let no_routes = observation(vec![(key(KEY_A), runtime_peer(Observed::Unavailable))]); + assert!(matches!( + compare(&expected, &no_routes), + TechnicalMatch::Unverifiable(_) + )); + } + + #[test] + fn duplicate_saved_identities_are_unverifiable_even_if_the_map_would_match() { + let source = format!( + "[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.3/32\n" + ); + let expected = projection(&source); + let actual = observation(vec![( + key(KEY_A), + runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])), + )]); + assert!(matches!( + compare(&expected, &actual), + TechnicalMatch::Unverifiable(findings) + if findings.contains(&ComparisonFinding::DuplicateSavedPublicKey { identity: key(KEY_A) }) + )); + } + + #[test] + fn invalid_saved_peer_and_interface_states_fail_closed_but_metadata_does_not() { + let invalid = projection("[Peer]\nAllowedIPs = 10.0.0.2/32\n"); + assert!(matches!( + compare(&invalid, &observation(Vec::new())), + TechnicalMatch::Unverifiable(_) + )); + let valid_with_bad_alias = projection(&format!( + "# wireguard-admin:v1 alias=bad%0A date=unknown\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n" + )); + assert!(!valid_with_bad_alias.document_unverifiable); + + for interface in [ + InterfaceObservation::Unknown, + InterfaceObservation::Unavailable, + InterfaceObservation::Missing, + ] { + let result = compare( + &projection(""), + &RuntimeObservation { + observed_at: None, + interface, + }, + ); + assert!(!matches!(result, TechnicalMatch::Match)); + } + } + + #[test] + fn malformed_routes_and_structural_saved_issues_cannot_be_omitted_for_a_match() { + let malformed_route = projection(&format!( + "[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32, not-a-network\n" + )); + assert!(!malformed_route.unverifiable_peer_indices.is_empty()); + let apparent_subset_match = observation(vec![( + key(KEY_A), + runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])), + )]); + assert!(matches!( + compare(&malformed_route, &apparent_subset_match), + TechnicalMatch::Unverifiable(findings) + if findings.iter().any(|finding| matches!(finding, ComparisonFinding::SavedPeerUnverifiable { .. })) + )); + + let structural = projection(&format!( + "[Interface]\nMalformed Directive\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n" + )); + assert!(structural.document_unverifiable); + let exact_runtime = observation(vec![( + key(KEY_A), + runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])), + )]); + assert!(matches!( + compare(&structural, &exact_runtime), + TechnicalMatch::Unverifiable(findings) + if findings.contains(&ComparisonFinding::SavedDocumentUnverifiable) + )); + } +} diff --git a/src/runtime/netlink.rs b/src/runtime/netlink.rs new file mode 100644 index 0000000..55b1eb9 --- /dev/null +++ b/src/runtime/netlink.rs @@ -0,0 +1,1113 @@ +//! Pure, bounded decoder for a WireGuard Generic Netlink `GET_DEVICE` dump. +//! +//! The caller supplies datagrams from one request and the request/socket +//! identity expected in every reply. This module performs no socket access and +//! never copies private or preshared keys. The transport must still enforce a +//! receive deadline, detect `MSG_TRUNC`, validate the kernel sender address, +//! and wipe its owned receive buffer when appropriate. + +use std::collections::{BTreeMap, BTreeSet}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +use ipnet::IpNet; +use time::OffsetDateTime; + +use crate::domain::PublicKey; +use crate::runtime::{Observed, RuntimePeer}; + +const NLMSG_HEADER_LEN: usize = 16; +const GENL_HEADER_LEN: usize = 4; +const NLA_HEADER_LEN: usize = 4; +const ALIGNMENT: usize = 4; +const NLMSG_ERROR: u16 = 2; +const NLMSG_DONE: u16 = 3; +const NLMSG_OVERRUN: u16 = 4; +const NLM_F_MULTI: u16 = 0x0002; +const NLM_F_DUMP_INTR: u16 = 0x0010; +const NLA_F_NESTED: u16 = 0x8000; +const NLA_TYPE_MASK: u16 = 0x3fff; +const WG_CMD_GET_DEVICE: u8 = 0; +const WG_GENL_VERSION: u8 = 1; +const AF_INET: u16 = 2; +const AF_INET6: u16 = 10; + +/// Expected metadata echoed by the kernel for one request. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct DecodeContext { + pub sequence: u32, + /// Netlink header port ID expected in replies (the local socket port ID). + pub port_id: u32, + pub family_id: u16, +} + +/// Hard aggregate limits for one dump. These are deliberately supplied by the +/// future adapter so its policy is visible at construction time. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct DecodeLimits { + pub max_datagrams: usize, + pub max_messages: usize, + pub max_peers: usize, + pub max_routes: usize, + pub max_bytes: usize, +} + +impl Default for DecodeLimits { + fn default() -> Self { + Self { + max_datagrams: 4096, + max_messages: 16_384, + max_peers: 16_384, + max_routes: 262_144, + max_bytes: 16 * 1024 * 1024, + } + } +} + +/// Public data decoded from a complete response. Keys, routes, timestamps and +/// counters are public WireGuard observations; no secret-bearing fields exist. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DecodedDevice { + pub interface_name: String, + pub peers: BTreeMap, +} + +/// Stable, secret-free failure categories. Raw kernel payloads and attribute +/// bytes are never attached to errors. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub(crate) enum DecodeError { + #[error("netlink dump is incomplete")] + Incomplete, + #[error("netlink dump contains malformed data")] + Malformed, + #[error("netlink response does not match the request")] + Mismatch, + #[error("netlink dump reported an error")] + DumpFailed, + #[error("netlink operation failed with kernel errno {0}")] + KernelError(i32), + #[error("netlink dump was interrupted")] + Interrupted, + #[error("netlink dump exceeded a configured limit")] + LimitExceeded, + #[error("netlink response contains a rejected control message")] + RejectedControl, +} + +/// Incremental checked decoder. `push_datagram` must receive the exact bytes +/// from each non-truncated datagram. A result is available only after an +/// explicit successful `NLMSG_DONE` has been consumed by `finish`. +pub(crate) struct GetDeviceDecoder { + context: DecodeContext, + limits: DecodeLimits, + expected_interface: String, + interface_name: Option, + peers: BTreeMap, + previous_peer: Option<(PublicKey, usize)>, + routes: usize, + bytes: usize, + datagrams: usize, + messages: usize, + done: bool, +} + +#[derive(Clone)] +struct DecodedPeer { + runtime: RuntimePeer, + routes: BTreeSet, +} + +impl GetDeviceDecoder { + pub(crate) fn new( + context: DecodeContext, + expected_interface: &str, + limits: DecodeLimits, + ) -> Self { + Self { + context, + limits, + expected_interface: expected_interface.to_owned(), + interface_name: None, + peers: BTreeMap::new(), + previous_peer: None, + routes: 0, + bytes: 0, + datagrams: 0, + messages: 0, + done: false, + } + } + + pub(crate) fn push_datagram(&mut self, bytes: &[u8]) -> Result<(), DecodeError> { + if self.done || bytes.is_empty() { + return Err(DecodeError::Malformed); + } + self.datagrams = self + .datagrams + .checked_add(1) + .ok_or(DecodeError::LimitExceeded)?; + self.bytes = self + .bytes + .checked_add(bytes.len()) + .ok_or(DecodeError::LimitExceeded)?; + if self.datagrams > self.limits.max_datagrams || self.bytes > self.limits.max_bytes { + return Err(DecodeError::LimitExceeded); + } + + let mut offset = 0usize; + while offset < bytes.len() { + if bytes.len() - offset < NLMSG_HEADER_LEN { + return Err(DecodeError::Malformed); + } + self.messages = self + .messages + .checked_add(1) + .ok_or(DecodeError::LimitExceeded)?; + if self.messages > self.limits.max_messages { + return Err(DecodeError::LimitExceeded); + } + + let message_len = + usize::try_from(read_u32(bytes, offset)?).map_err(|_| DecodeError::Malformed)?; + if message_len < NLMSG_HEADER_LEN || message_len > bytes.len() - offset { + return Err(DecodeError::Malformed); + } + let message_type = read_u16(bytes, offset + 4)?; + let flags = read_u16(bytes, offset + 6)?; + let sequence = read_u32(bytes, offset + 8)?; + let port_id = read_u32(bytes, offset + 12)?; + if sequence != self.context.sequence || port_id != self.context.port_id { + return Err(DecodeError::Mismatch); + } + if flags & NLM_F_DUMP_INTR != 0 { + return Err(DecodeError::Interrupted); + } + + let payload_start = offset + .checked_add(NLMSG_HEADER_LEN) + .ok_or(DecodeError::Malformed)?; + let payload_end = offset + .checked_add(message_len) + .ok_or(DecodeError::Malformed)?; + match message_type { + NLMSG_DONE => { + if payload_end - payload_start != 4 { + return Err(DecodeError::Malformed); + } + let status = read_i32(bytes, payload_start)?; + if status != 0 { + return Err(if status < 0 { + DecodeError::KernelError(status.saturating_neg()) + } else { + DecodeError::Malformed + }); + } + self.done = true; + } + NLMSG_ERROR => { + if payload_end - payload_start < 4 { + return Err(DecodeError::Malformed); + } + let status = read_i32(bytes, payload_start)?; + return Err(if status < 0 { + DecodeError::KernelError(status.saturating_neg()) + } else { + DecodeError::RejectedControl + }); + } + NLMSG_OVERRUN => return Err(DecodeError::RejectedControl), + kind if kind < 16 => return Err(DecodeError::RejectedControl), + kind if kind == self.context.family_id => { + if flags & NLM_F_MULTI == 0 { + return Err(DecodeError::Malformed); + } + self.parse_device_message(&bytes[payload_start..payload_end])?; + } + _ => return Err(DecodeError::Mismatch), + } + + let aligned_len = align(message_len)?; + let remaining = bytes.len() - offset; + if aligned_len > remaining { + if message_len != remaining { + return Err(DecodeError::Malformed); + } + offset = bytes.len(); + } else { + offset = offset + .checked_add(aligned_len) + .ok_or(DecodeError::Malformed)?; + } + if self.done && offset != bytes.len() { + return Err(DecodeError::Malformed); + } + } + Ok(()) + } + + pub(crate) fn finish(self) -> Result { + if !self.done { + return Err(DecodeError::Incomplete); + } + let interface_name = self.interface_name.ok_or(DecodeError::Malformed)?; + let peers = self + .peers + .into_iter() + .map(|(identity, mut peer)| { + peer.runtime.allowed_ips = Observed::Available(peer.routes.into_iter().collect()); + (identity, peer.runtime) + }) + .collect(); + Ok(DecodedDevice { + interface_name, + peers, + }) + } + + pub(crate) fn is_done(&self) -> bool { + self.done + } + + fn parse_device_message(&mut self, payload: &[u8]) -> Result<(), DecodeError> { + if payload.len() < GENL_HEADER_LEN { + return Err(DecodeError::Malformed); + } + if payload[0] != WG_CMD_GET_DEVICE + || payload[1] != WG_GENL_VERSION + || read_u16(payload, 2)? != 0 + { + return Err(DecodeError::Mismatch); + } + let attrs = attributes(&payload[GENL_HEADER_LEN..])?; + let mut seen = BTreeSet::new(); + let mut peer_list_seen = false; + for attr in attrs { + let kind = attr.kind; + if !seen.insert(kind) { + return Err(DecodeError::Malformed); + } + match kind { + 0 => { + require_plain(attr)?; + if !attr.payload.is_empty() { + return Err(DecodeError::Malformed); + } + } + 1 => { + require_plain(attr)?; + exact_len(attr.payload, 4)?; + } + 2 => { + require_plain(attr)?; + let name = interface_name(attr.payload)?; + if name != self.expected_interface + || self + .interface_name + .as_deref() + .is_some_and(|old| old != name) + { + return Err(DecodeError::Mismatch); + } + self.interface_name = Some(name.to_owned()); + } + 3 | 4 => { + require_plain(attr)?; + // Validate length, but never copy the device private key or + // public key into a decoder-owned value. + exact_len(attr.payload, 32)?; + } + 5 | 7 => { + require_plain(attr)?; + exact_len(attr.payload, 4)?; + } + 6 => { + require_plain(attr)?; + exact_len(attr.payload, 2)?; + } + 8 => { + require_nested(attr)?; + peer_list_seen = true; + self.parse_peers(attr.payload)?; + } + _ => return Err(DecodeError::Malformed), + } + } + if !peer_list_seen { + self.previous_peer = None; + } + Ok(()) + } + + fn parse_peers(&mut self, payload: &[u8]) -> Result<(), DecodeError> { + let mut within_message = BTreeSet::new(); + for outer in attributes(payload)? { + require_nested(outer)?; + if outer.kind != 0 { + return Err(DecodeError::Malformed); + } + let fragment = parse_peer(outer.payload)?; + if !within_message.insert(fragment.identity) { + return Err(DecodeError::Malformed); + } + + if let Some(existing) = self.peers.get_mut(&fragment.identity) { + let immediate = self.previous_peer.is_some_and(|(identity, message)| { + identity == fragment.identity && message + 1 == self.messages + }); + if !immediate || !fragment.route_continuation { + return Err(DecodeError::Malformed); + } + for route in fragment.routes { + self.routes = self + .routes + .checked_add(1) + .ok_or(DecodeError::LimitExceeded)?; + if self.routes > self.limits.max_routes || !existing.routes.insert(route) { + return Err(if self.routes > self.limits.max_routes { + DecodeError::LimitExceeded + } else { + DecodeError::Malformed + }); + } + } + } else { + if fragment.route_continuation { + return Err(DecodeError::Malformed); + } + if self.peers.len() >= self.limits.max_peers { + return Err(DecodeError::LimitExceeded); + } + self.routes = self + .routes + .checked_add(fragment.routes.len()) + .ok_or(DecodeError::LimitExceeded)?; + if self.routes > self.limits.max_routes { + return Err(DecodeError::LimitExceeded); + } + let allowed_ips = Observed::Available(fragment.routes.iter().copied().collect()); + let runtime = RuntimePeer { + allowed_ips, + last_handshake: Observed::Available(fragment.handshake), + received_bytes: Observed::Available(fragment.received), + transmitted_bytes: Observed::Available(fragment.transmitted), + }; + self.peers.insert( + fragment.identity, + DecodedPeer { + runtime, + routes: fragment.routes, + }, + ); + } + self.previous_peer = Some((fragment.identity, self.messages)); + } + Ok(()) + } +} + +struct PeerFragment { + identity: PublicKey, + routes: BTreeSet, + handshake: Option, + received: u64, + transmitted: u64, + route_continuation: bool, +} + +fn parse_peer(payload: &[u8]) -> Result { + let mut identity = None; + let mut routes = BTreeSet::new(); + let mut handshake = None; + let mut handshake_seen = false; + let mut received = None; + let mut transmitted = None; + let mut seen = BTreeSet::new(); + let mut allowedips_seen = false; + + for attr in attributes(payload)? { + if !seen.insert(attr.kind) { + return Err(DecodeError::Malformed); + } + match attr.kind { + 0 => { + require_plain(attr)?; + if !attr.payload.is_empty() { + return Err(DecodeError::Malformed); + } + } + 1 => { + require_plain(attr)?; + exact_len(attr.payload, 32)?; + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(attr.payload); + if bytes.iter().all(|byte| *byte == 0) { + return Err(DecodeError::Malformed); + } + identity = Some(PublicKey::from_bytes(bytes)); + } + 2 => { + require_plain(attr)?; + // Validate and discard the preshared key without making a copy. + exact_len(attr.payload, 32)?; + } + 3 | 10 => { + require_plain(attr)?; + exact_len(attr.payload, 4)?; + } + 4 => { + require_plain(attr)?; + validate_endpoint(attr.payload)?; + } + 5 => { + require_plain(attr)?; + exact_len(attr.payload, 2)?; + } + 6 => { + require_plain(attr)?; + exact_len(attr.payload, 16)?; + handshake_seen = true; + let seconds = read_u64(attr.payload, 0)?; + let nanoseconds = read_u64(attr.payload, 8)?; + if nanoseconds >= 1_000_000_000 { + return Err(DecodeError::Malformed); + } + handshake = if seconds == 0 && nanoseconds == 0 { + None + } else { + let nanos = i128::from(seconds) + .checked_mul(1_000_000_000) + .and_then(|value| value.checked_add(i128::from(nanoseconds))) + .ok_or(DecodeError::Malformed)?; + Some( + OffsetDateTime::from_unix_timestamp_nanos(nanos) + .map_err(|_| DecodeError::Malformed)?, + ) + }; + } + 7 => { + require_plain(attr)?; + received = Some(read_u64_exact(attr.payload)?); + } + 8 => { + require_plain(attr)?; + transmitted = Some(read_u64_exact(attr.payload)?); + } + 9 => { + require_nested(attr)?; + allowedips_seen = true; + for route in attributes(attr.payload)? { + require_nested(route)?; + if route.kind != 0 { + return Err(DecodeError::Malformed); + } + let route = parse_allowed_ip(route.payload)?; + if !routes.insert(route) { + return Err(DecodeError::Malformed); + } + } + } + _ => return Err(DecodeError::Malformed), + } + } + + let identity = identity.ok_or(DecodeError::Malformed)?; + let route_continuation = !handshake_seen + && received.is_none() + && transmitted.is_none() + && allowedips_seen + && seen.iter().all(|kind| *kind == 1 || *kind == 9); + if route_continuation { + return Ok(PeerFragment { + identity, + routes, + handshake: None, + received: 0, + transmitted: 0, + route_continuation, + }); + } + if !handshake_seen || received.is_none() || transmitted.is_none() { + return Err(DecodeError::Malformed); + } + Ok(PeerFragment { + identity, + routes, + handshake, + received: received.ok_or(DecodeError::Malformed)?, + transmitted: transmitted.ok_or(DecodeError::Malformed)?, + route_continuation, + }) +} + +fn parse_allowed_ip(payload: &[u8]) -> Result { + let mut family = None; + let mut address = None; + let mut cidr = None; + let mut seen = BTreeSet::new(); + for attr in attributes(payload)? { + if !seen.insert(attr.kind) { + return Err(DecodeError::Malformed); + } + match attr.kind { + 0 => { + require_plain(attr)?; + if !attr.payload.is_empty() { + return Err(DecodeError::Malformed); + } + } + 1 => { + require_plain(attr)?; + family = Some(read_u16_exact(attr.payload)?); + } + 2 => { + require_plain(attr)?; + address = Some(attr.payload); + } + 3 => { + require_plain(attr)?; + cidr = Some(read_u8_exact(attr.payload)?); + } + 4 => { + require_plain(attr)?; + exact_len(attr.payload, 4)?; + } + _ => return Err(DecodeError::Malformed), + } + } + let family = family.ok_or(DecodeError::Malformed)?; + let address = address.ok_or(DecodeError::Malformed)?; + let cidr = cidr.ok_or(DecodeError::Malformed)?; + let ip = match (family, address.len()) { + (AF_INET, 4) => IpAddr::V4(Ipv4Addr::new( + address[0], address[1], address[2], address[3], + )), + (AF_INET6, 16) => { + let mut octets = [0u8; 16]; + octets.copy_from_slice(address); + IpAddr::V6(Ipv6Addr::from(octets)) + } + _ => return Err(DecodeError::Malformed), + }; + IpNet::new(ip, cidr).map_err(|_| DecodeError::Malformed) +} + +fn validate_endpoint(payload: &[u8]) -> Result<(), DecodeError> { + if payload.len() < 2 { + return Err(DecodeError::Malformed); + } + let family = read_u16(payload, 0)?; + match family { + AF_INET if payload.len() == 16 => Ok(()), + AF_INET6 if payload.len() == 28 => Ok(()), + _ => Err(DecodeError::Malformed), + } +} + +fn interface_name(payload: &[u8]) -> Result<&str, DecodeError> { + if payload.len() < 2 || payload.last() != Some(&0) || payload[..payload.len() - 1].contains(&0) + { + return Err(DecodeError::Malformed); + } + let name = + std::str::from_utf8(&payload[..payload.len() - 1]).map_err(|_| DecodeError::Malformed)?; + if name.is_empty() || name.len() > 15 { + return Err(DecodeError::Malformed); + } + Ok(name) +} + +#[derive(Clone, Copy)] +struct Attribute<'a> { + kind: u16, + flags: u16, + payload: &'a [u8], +} + +fn attributes(mut bytes: &[u8]) -> Result>, DecodeError> { + let mut result = Vec::new(); + while !bytes.is_empty() { + if bytes.len() < NLA_HEADER_LEN { + return Err(DecodeError::Malformed); + } + let len = usize::from(read_u16(bytes, 0)?); + if len < NLA_HEADER_LEN || len > bytes.len() { + return Err(DecodeError::Malformed); + } + let raw_kind = read_u16(bytes, 2)?; + result.push(Attribute { + kind: raw_kind & NLA_TYPE_MASK, + flags: raw_kind & !NLA_TYPE_MASK, + payload: &bytes[NLA_HEADER_LEN..len], + }); + let aligned_len = align(len)?; + if aligned_len > bytes.len() { + if len != bytes.len() { + return Err(DecodeError::Malformed); + } + bytes = &[]; + } else { + bytes = &bytes[aligned_len..]; + } + } + Ok(result) +} + +fn require_nested(attr: Attribute<'_>) -> Result<(), DecodeError> { + if attr.flags == NLA_F_NESTED { + Ok(()) + } else { + Err(DecodeError::Malformed) + } +} + +fn require_plain(attr: Attribute<'_>) -> Result<(), DecodeError> { + if attr.flags == 0 { + Ok(()) + } else { + Err(DecodeError::Malformed) + } +} + +fn exact_len(bytes: &[u8], expected: usize) -> Result<(), DecodeError> { + if bytes.len() == expected { + Ok(()) + } else { + Err(DecodeError::Malformed) + } +} + +fn read_u8_exact(bytes: &[u8]) -> Result { + exact_len(bytes, 1)?; + Ok(bytes[0]) +} + +fn read_u16_exact(bytes: &[u8]) -> Result { + exact_len(bytes, 2)?; + read_u16(bytes, 0) +} + +fn read_u64_exact(bytes: &[u8]) -> Result { + exact_len(bytes, 8)?; + read_u64(bytes, 0) +} + +fn read_u16(bytes: &[u8], offset: usize) -> Result { + let bytes = bytes + .get(offset..offset.checked_add(2).ok_or(DecodeError::Malformed)?) + .ok_or(DecodeError::Malformed)?; + Ok(u16::from_ne_bytes([bytes[0], bytes[1]])) +} + +fn read_u32(bytes: &[u8], offset: usize) -> Result { + let bytes = bytes + .get(offset..offset.checked_add(4).ok_or(DecodeError::Malformed)?) + .ok_or(DecodeError::Malformed)?; + Ok(u32::from_ne_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) +} + +fn read_i32(bytes: &[u8], offset: usize) -> Result { + let bytes = bytes + .get(offset..offset.checked_add(4).ok_or(DecodeError::Malformed)?) + .ok_or(DecodeError::Malformed)?; + Ok(i32::from_ne_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) +} + +fn read_u64(bytes: &[u8], offset: usize) -> Result { + let bytes = bytes + .get(offset..offset.checked_add(8).ok_or(DecodeError::Malformed)?) + .ok_or(DecodeError::Malformed)?; + Ok(u64::from_ne_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ])) +} + +fn align(value: usize) -> Result { + value + .checked_add(ALIGNMENT - 1) + .map(|rounded| rounded & !(ALIGNMENT - 1)) + .ok_or(DecodeError::Malformed) +} + +#[cfg(test)] +mod tests { + use super::*; + + const CONTEXT: DecodeContext = DecodeContext { + sequence: 17, + port_id: 41, + family_id: 42, + }; + const KEY: [u8; 32] = [7; 32]; + const LIMITS: DecodeLimits = DecodeLimits { + max_datagrams: 8, + max_messages: 8, + max_peers: 4, + max_routes: 8, + max_bytes: 4096, + }; + + fn put_u16(out: &mut Vec, value: u16) { + out.extend_from_slice(&value.to_ne_bytes()); + } + + fn put_u32(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_ne_bytes()); + } + + fn put_u64(out: &mut Vec, value: u64) { + out.extend_from_slice(&value.to_ne_bytes()); + } + + fn attr(kind: u16, value: &[u8]) -> Vec { + let mut out = Vec::new(); + put_u16(&mut out, u16::try_from(value.len() + 4).unwrap()); + put_u16(&mut out, kind); + out.extend_from_slice(value); + while out.len() % 4 != 0 { + out.push(0); + } + out + } + + fn nested(kind: u16, value: &[u8]) -> Vec { + attr(kind | NLA_F_NESTED, value) + } + + fn append(target: &mut Vec, item: Vec) { + target.extend(item); + } + + fn route_v4(last: u8) -> Vec { + let mut route = Vec::new(); + append(&mut route, attr(1, &AF_INET.to_ne_bytes())); + append(&mut route, attr(2, &[10, 0, 0, last])); + append(&mut route, attr(3, &[32])); + nested(0, &route) + } + + fn route_v6(last: u8) -> Vec { + let mut route = Vec::new(); + append(&mut route, attr(1, &AF_INET6.to_ne_bytes())); + let mut address = [0u8; 16]; + address[0] = 0xfd; + address[15] = last; + append(&mut route, attr(2, &address)); + append(&mut route, attr(3, &[128])); + nested(0, &route) + } + + fn peer(identity: [u8; 32], routes: &[Vec], stats: bool) -> Vec { + peer_with_handshake(identity, routes, stats, 0, 0) + } + + fn peer_with_handshake( + identity: [u8; 32], + routes: &[Vec], + stats: bool, + seconds: u64, + nanoseconds: u64, + ) -> Vec { + let mut peer = Vec::new(); + append(&mut peer, attr(1, &identity)); + // Synthetic secret bytes verify that the decoder validates but never + // exposes a copied key in its public result or error text. + if stats { + append(&mut peer, attr(2, &[0xa5; 32])); + } + if stats { + let mut handshake = Vec::new(); + put_u64(&mut handshake, seconds); + put_u64(&mut handshake, nanoseconds); + append(&mut peer, attr(6, &handshake)); + let mut received = Vec::new(); + put_u64(&mut received, 0); + append(&mut peer, attr(7, &received)); + let mut transmitted = Vec::new(); + put_u64(&mut transmitted, 19); + append(&mut peer, attr(8, &transmitted)); + } + let mut allowed = Vec::new(); + for route in routes { + append(&mut allowed, route.clone()); + } + append(&mut peer, nested(9, &allowed)); + nested(0, &peer) + } + + fn message(message_type: u16, flags: u16, payload: &[u8]) -> Vec { + let mut out = Vec::new(); + put_u32( + &mut out, + u32::try_from(NLMSG_HEADER_LEN + payload.len()).unwrap(), + ); + put_u16(&mut out, message_type); + put_u16(&mut out, flags); + put_u32(&mut out, CONTEXT.sequence); + put_u32(&mut out, CONTEXT.port_id); + out.extend_from_slice(payload); + while out.len() % 4 != 0 { + out.push(0); + } + out + } + + fn device_message(attrs: &[u8]) -> Vec { + let mut payload = vec![WG_CMD_GET_DEVICE, WG_GENL_VERSION, 0, 0]; + payload.extend_from_slice(attrs); + message(CONTEXT.family_id, NLM_F_MULTI, &payload) + } + + fn done(status: i32, flags: u16) -> Vec { + message(NLMSG_DONE, flags, &status.to_ne_bytes()) + } + + fn fragments() -> Vec> { + let mut first_attrs = attr(2, b"wg0\0"); + append( + &mut first_attrs, + nested( + 8, + &peer_with_handshake(KEY, &[route_v4(2), route_v6(2)], true, 1_700_000_000, 123), + ), + ); + + let mut second_attrs = attr(2, b"wg0\0"); + append( + &mut second_attrs, + nested(8, &peer(KEY, &[route_v4(3)], false)), + ); + vec![ + device_message(&first_attrs), + device_message(&second_attrs), + done(0, 0), + ] + } + + fn decode(messages: &[Vec]) -> Result { + let mut decoder = GetDeviceDecoder::new(CONTEXT, "wg0", LIMITS); + for message in messages { + decoder.push_datagram(message)?; + } + decoder.finish() + } + + #[test] + fn coalesces_adjacent_peer_route_fragments_and_returns_public_statistics() { + let result = decode(&fragments()).unwrap(); + assert_eq!(result.interface_name, "wg0"); + assert_eq!(result.peers.len(), 1); + let peer = result.peers.get(&PublicKey::from_bytes(KEY)).unwrap(); + assert_eq!( + peer.allowed_ips, + Observed::Available(vec![ + "10.0.0.2/32".parse().unwrap(), + "10.0.0.3/32".parse().unwrap(), + "fd00::2/128".parse().unwrap() + ]) + ); + assert_eq!( + peer.last_handshake, + Observed::Available(Some( + OffsetDateTime::from_unix_timestamp_nanos(1_700_000_000_000_000_123).unwrap() + )) + ); + assert_eq!(peer.received_bytes, Observed::Available(0)); + assert_eq!(peer.transmitted_bytes, Observed::Available(19)); + assert!(!format!("{result:?}").contains("a5a5a5a5")); + } + + #[test] + fn accepts_explicit_complete_empty_peer_set() { + let mut attrs = attr(2, b"wg0\0"); + append(&mut attrs, nested(8, &[])); + assert!( + decode(&[device_message(&attrs), done(0, 0)]) + .unwrap() + .peers + .is_empty() + ); + } + + #[test] + fn zero_kernel_handshake_is_available_none() { + let mut attrs = attr(2, b"wg0\0"); + append(&mut attrs, nested(8, &peer(KEY, &[], true))); + let result = decode(&[device_message(&attrs), done(0, 0)]).unwrap(); + assert_eq!( + result.peers[&PublicKey::from_bytes(KEY)].last_handshake, + Observed::Available(None) + ); + } + + #[test] + fn requires_done_and_rejects_done_errors_and_control_errors() { + let only_data = [fragments()[0].clone()]; + let mut decoder = GetDeviceDecoder::new(CONTEXT, "wg0", LIMITS); + decoder.push_datagram(&only_data[0]).unwrap(); + assert_eq!(decoder.finish(), Err(DecodeError::Incomplete)); + assert_eq!(decode(&[done(-5, 0)]), Err(DecodeError::KernelError(5))); + assert_eq!( + decode(&[message(NLMSG_ERROR, 0, &[0; 4])]), + Err(DecodeError::RejectedControl) + ); + assert_eq!( + decode(&[message(NLMSG_OVERRUN, 0, &[])]), + Err(DecodeError::RejectedControl) + ); + } + + #[test] + fn rejects_sequence_port_family_command_and_interface_mismatches() { + let good = fragments()[0].clone(); + let mut bad = good.clone(); + bad[8..12].copy_from_slice(&99u32.to_ne_bytes()); + assert_eq!(decode(&[bad]), Err(DecodeError::Mismatch)); + let mut bad = good.clone(); + bad[12..16].copy_from_slice(&99u32.to_ne_bytes()); + assert_eq!(decode(&[bad]), Err(DecodeError::Mismatch)); + let mut bad = good.clone(); + bad[4..6].copy_from_slice(&43u16.to_ne_bytes()); + assert_eq!(decode(&[bad]), Err(DecodeError::Mismatch)); + let mut bad = good.clone(); + bad[16] = 1; + assert_eq!(decode(&[bad]), Err(DecodeError::Mismatch)); + let mut other = GetDeviceDecoder::new(CONTEXT, "wg1", LIMITS); + assert_eq!(other.push_datagram(&good), Err(DecodeError::Mismatch)); + } + + #[test] + fn rejects_interrupted_dump_and_truncated_or_misaligned_messages() { + assert_eq!( + decode(&[done(0, NLM_F_DUMP_INTR)]), + Err(DecodeError::Interrupted) + ); + let mut truncated = fragments()[0].clone(); + truncated.truncate(17); + assert_eq!(decode(&[truncated]), Err(DecodeError::Malformed)); + let mut wrong_length = fragments()[0].clone(); + wrong_length[..4].copy_from_slice(&15u32.to_ne_bytes()); + assert_eq!(decode(&[wrong_length]), Err(DecodeError::Malformed)); + let mut decoder = GetDeviceDecoder::new(CONTEXT, "wg0", LIMITS); + assert_eq!( + decoder.push_datagram(&[0, 0, 0]), + Err(DecodeError::Malformed) + ); + } + + #[test] + fn rejects_bad_attributes_duplicate_fields_and_non_adjacent_peer_duplicates() { + let mut attrs = attr(2, b"wg0\0"); + append(&mut attrs, attr(2, b"wg0\0")); + assert_eq!( + decode(&[device_message(&attrs)]), + Err(DecodeError::Malformed) + ); + + let mut malformed = fragments()[0].clone(); + // Corrupt the first attribute's nla_len to be smaller than its header. + malformed[20..22].copy_from_slice(&3u16.to_ne_bytes()); + assert_eq!(decode(&[malformed]), Err(DecodeError::Malformed)); + + let mut no_routes_stats = peer(KEY, &[route_v4(2)], true); + // Insert a second copy of the public key field before the peer ends. + let duplicate = attr(1, &KEY); + no_routes_stats.splice(4..4, duplicate); + let mut attrs = attr(2, b"wg0\0"); + append(&mut attrs, nested(8, &no_routes_stats)); + assert_eq!( + decode(&[device_message(&attrs)]), + Err(DecodeError::Malformed) + ); + + let mut middle_attrs = attr(2, b"wg0\0"); + append(&mut middle_attrs, nested(8, &peer([8; 32], &[], true))); + let messages = vec![ + fragments()[0].clone(), + device_message(&middle_attrs), + fragments()[1].clone(), + done(0, 0), + ]; + assert_eq!(decode(&messages), Err(DecodeError::Malformed)); + } + + #[test] + fn rejects_invalid_routes_and_statistics_fields() { + let mut invalid_route = Vec::new(); + append(&mut invalid_route, attr(1, &AF_INET.to_ne_bytes())); + append(&mut invalid_route, attr(2, &[10, 0, 0, 1])); + append(&mut invalid_route, attr(3, &[33])); + let mut peer_attrs = peer(KEY, &[], true); + append(&mut peer_attrs, nested(9, &nested(0, &invalid_route))); + let mut device_attrs = attr(2, b"wg0\0"); + append(&mut device_attrs, nested(8, &peer_attrs)); + assert_eq!( + decode(&[device_message(&device_attrs)]), + Err(DecodeError::Malformed) + ); + + let mut no_stats = Vec::new(); + append(&mut no_stats, attr(1, &KEY)); + append(&mut no_stats, nested(9, &[])); + let mut device_attrs = attr(2, b"wg0\0"); + append(&mut device_attrs, nested(8, &nested(0, &no_stats))); + assert_eq!( + decode(&[device_message(&device_attrs)]), + Err(DecodeError::Malformed) + ); + } + + #[test] + fn enforces_each_aggregate_limit() { + let messages = fragments(); + let mut limits = LIMITS; + limits.max_datagrams = 1; + let mut decoder = GetDeviceDecoder::new(CONTEXT, "wg0", limits); + decoder.push_datagram(&messages[0]).unwrap(); + assert_eq!( + decoder.push_datagram(&messages[1]), + Err(DecodeError::LimitExceeded) + ); + + let mut limits = LIMITS; + limits.max_messages = 1; + let mut decoder = GetDeviceDecoder::new(CONTEXT, "wg0", limits); + assert_eq!(decoder.push_datagram(&messages[0]), Ok(())); + assert_eq!( + decoder.push_datagram(&messages[1]), + Err(DecodeError::LimitExceeded) + ); + + let mut limits = LIMITS; + limits.max_peers = 0; + let mut decoder = GetDeviceDecoder::new(CONTEXT, "wg0", limits); + assert_eq!( + decoder.push_datagram(&messages[0]), + Err(DecodeError::LimitExceeded) + ); + + let mut limits = LIMITS; + limits.max_routes = 1; + assert_eq!( + decode_with_limits(&messages, limits), + Err(DecodeError::LimitExceeded) + ); + + let mut limits = LIMITS; + limits.max_bytes = messages[0].len() - 1; + let mut decoder = GetDeviceDecoder::new(CONTEXT, "wg0", limits); + assert_eq!( + decoder.push_datagram(&messages[0]), + Err(DecodeError::LimitExceeded) + ); + } + + fn decode_with_limits( + messages: &[Vec], + limits: DecodeLimits, + ) -> Result { + let mut decoder = GetDeviceDecoder::new(CONTEXT, "wg0", limits); + for message in messages { + decoder.push_datagram(message)?; + } + decoder.finish() + } +} diff --git a/src/runtime/transport.rs b/src/runtime/transport.rs new file mode 100644 index 0000000..d5595ac --- /dev/null +++ b/src/runtime/transport.rs @@ -0,0 +1,562 @@ +//! Bounded, read-only Linux Generic Netlink transport for WireGuard. +//! +//! Replies are received into zeroizing buffers. The datagram sender address +//! must be kernel port 0; `nlmsg_pid` in request replies is the requester's +//! port ID, as Linux Generic Netlink constructs replies using that destination. + +use std::time::{Duration, Instant}; + +use rustix::event::{PollFd, PollFlags, Timespec, poll}; +use rustix::fd::OwnedFd; +use rustix::io::Errno; +use rustix::net::{ + AddressFamily, RecvFlags, SendFlags, SocketFlags, SocketType, bind, getsockname, + netlink::SocketAddrNetlink, recvfrom, sendto, socket_with, +}; +use zeroize::Zeroizing; + +use super::netlink::{DecodeContext, DecodeError, DecodeLimits, GetDeviceDecoder}; +use super::{InterfaceObservation, PeerSetCompleteness, RuntimeObservation, RuntimePeerSet}; + +const NETLINK_GENERIC: u32 = 16; +const CTRL_FAMILY_ID: u16 = 0x10; +const CTRL_CMD_GETFAMILY: u8 = 3; +const CTRL_CMD_NEWFAMILY: u8 = 1; +const CTRL_ATTR_FAMILY_ID: u16 = 1; +const CTRL_ATTR_FAMILY_NAME: u16 = 2; +const WG_CMD_GET_DEVICE: u8 = 0; +const WG_GENL_VERSION: u8 = 1; +const WG_ATTR_IFNAME: u16 = 2; +const NLMSG_ERROR: u16 = 2; +const NLMSG_DONE: u16 = 3; +const NLMSG_REQUEST: u16 = 1; +const NLMSG_DUMP: u16 = 0x300; +const NLMSG_MULTI: u16 = 2; +const MAX_DATAGRAM: usize = 1024 * 1024; +const MAX_INTERFACE_NAME: usize = 15; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub(crate) enum TransportError { + #[error("configured WireGuard interface was not found")] + NoDevice, + #[error("permission denied while reading WireGuard state")] + PermissionDenied, + #[error("WireGuard netlink response was malformed or incomplete")] + Malformed, + #[error("WireGuard netlink transport failed")] + Transport, + #[error("WireGuard netlink request timed out")] + Timeout, + #[error("WireGuard netlink response exceeded configured limits")] + LimitExceeded, +} + +#[derive(Debug)] +struct Datagram { + bytes: Zeroizing>, + sender: u32, +} + +trait Io { + fn now(&self) -> Duration; + fn send(&mut self, destination: u32, bytes: &[u8]) -> Result<(), TransportError>; + fn receive(&mut self, wait: Duration) -> Result; + fn port_id(&self) -> u32; +} + +struct LinuxIo { + socket: OwnedFd, + port_id: u32, + origin: Instant, +} + +impl LinuxIo { + fn new() -> Result { + let socket = socket_with( + AddressFamily::NETLINK, + SocketType::DGRAM, + SocketFlags::CLOEXEC.union(SocketFlags::NONBLOCK), + Some(rustix::net::netlink::GENERIC), + ) + .map_err(map_errno)?; + bind(&socket, &SocketAddrNetlink::new(0, 0)).map_err(map_errno)?; + let addr = SocketAddrNetlink::try_from(getsockname(&socket).map_err(map_errno)?) + .map_err(|_| TransportError::Transport)?; + Ok(Self { + socket, + port_id: addr.pid(), + origin: Instant::now(), + }) + } +} + +impl Io for LinuxIo { + fn now(&self) -> Duration { + self.origin.elapsed() + } + + fn port_id(&self) -> u32 { + self.port_id + } + + fn send(&mut self, destination: u32, bytes: &[u8]) -> Result<(), TransportError> { + let sent = sendto( + &self.socket, + bytes, + SendFlags::empty(), + &SocketAddrNetlink::new(destination, 0), + ) + .map_err(map_errno)?; + if sent == bytes.len() { + Ok(()) + } else { + Err(TransportError::Transport) + } + } + + fn receive(&mut self, wait: Duration) -> Result { + let timeout = Timespec { + tv_sec: wait.as_secs().min(i64::MAX as u64) as i64, + tv_nsec: wait.subsec_nanos() as i64, + }; + let mut fds = [PollFd::new(&self.socket, PollFlags::IN)]; + if poll(&mut fds, Some(&timeout)).map_err(map_errno)? == 0 { + return Err(TransportError::Timeout); + } + let mut bytes = Zeroizing::new(vec![0; MAX_DATAGRAM]); + let (_, count, address) = recvfrom( + &self.socket, + &mut bytes[..], + RecvFlags::TRUNC.union(RecvFlags::DONTWAIT), + ) + .map_err(map_errno)?; + if count > MAX_DATAGRAM { + return Err(TransportError::LimitExceeded); + } + let sender = address + .and_then(|a| SocketAddrNetlink::try_from(a).ok()) + .ok_or(TransportError::Malformed)? + .pid(); + bytes.truncate(count); + Ok(Datagram { bytes, sender }) + } +} + +fn map_errno(error: Errno) -> TransportError { + if error == Errno::ACCESS || error == Errno::PERM { + TransportError::PermissionDenied + } else if error == Errno::NOENT || error == Errno::NODEV { + TransportError::NoDevice + } else if error == Errno::TIMEDOUT { + TransportError::Timeout + } else { + TransportError::Transport + } +} + +fn map_kernel_error(error: i32) -> TransportError { + if error == Errno::ACCESS.raw_os_error() || error == Errno::PERM.raw_os_error() { + TransportError::PermissionDenied + } else if error == Errno::NOENT.raw_os_error() || error == Errno::NODEV.raw_os_error() { + TransportError::NoDevice + } else { + TransportError::Transport + } +} + +pub(crate) fn observe_interface( + interface: &str, + timeout: Duration, + limits: DecodeLimits, +) -> Result { + let mut io = LinuxIo::new()?; + observe_with_io(&mut io, interface, timeout, limits) +} + +fn observe_with_io( + io: &mut impl Io, + interface: &str, + timeout: Duration, + limits: DecodeLimits, +) -> Result { + if interface.is_empty() + || interface.len() > MAX_INTERFACE_NAME + || interface.as_bytes().contains(&0) + || timeout.is_zero() + { + return Err(TransportError::Malformed); + } + let start = io.now(); + let deadline = start.checked_add(timeout).ok_or(TransportError::Timeout)?; + let family = resolve_family(io, deadline)?; + let seq = 2; + let request = get_device_request(family, seq, io.port_id(), interface); + io.send(0, &request)?; + let context = DecodeContext { + sequence: seq, + port_id: io.port_id(), + family_id: family, + }; + let mut decoder = GetDeviceDecoder::new(context, interface, limits); + loop { + let datagram = receive_before(io, deadline)?; + if datagram.sender != 0 { + return Err(TransportError::Malformed); + } + decoder.push_datagram(&datagram.bytes).map_err(map_decode)?; + // Decoder only returns a device after its explicit terminal DONE. + if decoder.is_done() { + let device = decoder.finish().map_err(map_decode)?; + return Ok(RuntimeObservation { + observed_at: None, + interface: InterfaceObservation::Present { + name: device.interface_name, + peers: RuntimePeerSet::from_records( + PeerSetCompleteness::Complete, + device.peers, + ), + }, + }); + } + } +} + +fn receive_before(io: &mut impl Io, deadline: Duration) -> Result { + let remaining = deadline + .checked_sub(io.now()) + .ok_or(TransportError::Timeout)?; + if remaining.is_zero() { + return Err(TransportError::Timeout); + } + io.receive(remaining) +} + +fn resolve_family(io: &mut impl Io, deadline: Duration) -> Result { + let seq = 1; + let mut request = message(CTRL_FAMILY_ID, NLMSG_REQUEST, seq, io.port_id()); + request.extend_from_slice(&[CTRL_CMD_GETFAMILY, 1, 0, 0]); + push_attr(&mut request, CTRL_ATTR_FAMILY_NAME, b"wireguard\0"); + set_len(&mut request); + io.send(0, &request)?; + let d = receive_before(io, deadline)?; + if d.sender != 0 { + return Err(TransportError::Malformed); + } + parse_family(&d.bytes, seq, io.port_id()) +} + +fn parse_family(bytes: &[u8], seq: u32, port: u32) -> Result { + if bytes.len() < 20 { + return Err(TransportError::Malformed); + } + if read_u32(bytes, 0)? as usize != bytes.len() + || read_u32(bytes, 8)? != seq + || read_u32(bytes, 12)? != port + { + return Err(TransportError::Malformed); + } + let message_type = read_u16(bytes, 4)?; + if message_type == NLMSG_ERROR { + let status = read_u32(bytes, 16)? as i32; + if status == 0 { + return Err(TransportError::Malformed); + } + return Err(status + .checked_neg() + .map(map_kernel_error) + .ok_or(TransportError::Malformed)?); + } + if message_type != CTRL_FAMILY_ID { + return Err(TransportError::Malformed); + } + if bytes[16] != CTRL_CMD_NEWFAMILY || read_u16(bytes, 18)? != 0 { + return Err(TransportError::Malformed); + } + let mut off = 20; + while off < bytes.len() { + if bytes.len() - off < 4 { + return Err(TransportError::Malformed); + } + let len = read_u16(bytes, off)? as usize; + let kind = read_u16(bytes, off + 2)? & 0x3fff; + if len < 4 || off + len > bytes.len() { + return Err(TransportError::Malformed); + } + if kind == CTRL_ATTR_FAMILY_ID && len == 6 { + return read_u16(bytes, off + 4); + } + let aligned_len = len.checked_add(3).ok_or(TransportError::Malformed)? & !3; + off = off + .checked_add(aligned_len) + .filter(|&end| end <= bytes.len()) + .ok_or(TransportError::Malformed)?; + } + Err(TransportError::NoDevice) +} + +fn get_device_request(family: u16, seq: u32, port: u32, interface: &str) -> Vec { + let mut out = message(family, NLMSG_REQUEST | NLMSG_DUMP, seq, port); + out.extend_from_slice(&[WG_CMD_GET_DEVICE, WG_GENL_VERSION, 0, 0]); + let mut interface_name = interface.as_bytes().to_vec(); + interface_name.push(0); + push_attr(&mut out, WG_ATTR_IFNAME, &interface_name); + set_len(&mut out); + out +} +fn message(kind: u16, flags: u16, seq: u32, port: u32) -> Vec { + let mut out = vec![0; 16]; + out[4..6].copy_from_slice(&kind.to_ne_bytes()); + out[6..8].copy_from_slice(&flags.to_ne_bytes()); + out[8..12].copy_from_slice(&seq.to_ne_bytes()); + out[12..16].copy_from_slice(&port.to_ne_bytes()); + out +} +fn push_attr(out: &mut Vec, kind: u16, value: &[u8]) { + let len = (4 + value.len()) as u16; + out.extend_from_slice(&len.to_ne_bytes()); + out.extend_from_slice(&kind.to_ne_bytes()); + out.extend_from_slice(value); + while !out.len().is_multiple_of(4) { + out.push(0); + } +} +fn set_len(out: &mut [u8]) { + let len = out.len() as u32; + out[..4].copy_from_slice(&len.to_ne_bytes()); +} +fn read_u16(bytes: &[u8], at: usize) -> Result { + bytes + .get(at..at + 2) + .and_then(|b| b.try_into().ok()) + .map(u16::from_ne_bytes) + .ok_or(TransportError::Malformed) +} +fn read_u32(bytes: &[u8], at: usize) -> Result { + bytes + .get(at..at + 4) + .and_then(|b| b.try_into().ok()) + .map(u32::from_ne_bytes) + .ok_or(TransportError::Malformed) +} +fn map_decode(error: DecodeError) -> TransportError { + match error { + DecodeError::KernelError(n) if n == Errno::NODEV.raw_os_error() => TransportError::NoDevice, + DecodeError::KernelError(n) + if n == Errno::ACCESS.raw_os_error() || n == Errno::PERM.raw_os_error() => + { + TransportError::PermissionDenied + } + DecodeError::LimitExceeded => TransportError::LimitExceeded, + DecodeError::KernelError(_) | DecodeError::DumpFailed => TransportError::Transport, + _ => TransportError::Malformed, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctrl_family_message(sequence: u32, port: u32, family: u16) -> Vec { + let mut out = message(CTRL_FAMILY_ID, 0, sequence, port); + out.extend_from_slice(&[CTRL_CMD_NEWFAMILY, 2, 0, 0]); + push_attr(&mut out, CTRL_ATTR_FAMILY_ID, &family.to_ne_bytes()); + set_len(&mut out); + out + } + + #[test] + fn resolves_family_only_from_matching_kernel_reply() { + assert_eq!(parse_family(&ctrl_family_message(1, 55, 42), 1, 55), Ok(42)); + assert_eq!( + parse_family(&ctrl_family_message(2, 55, 42), 1, 55), + Err(TransportError::Malformed) + ); + assert_eq!( + parse_family(&ctrl_family_message(1, 56, 42), 1, 55), + Err(TransportError::Malformed) + ); + } + + fn netlink_error(sequence: u32, port: u32, status: i32) -> Vec { + let mut out = message(NLMSG_ERROR, 0, sequence, port); + out.extend_from_slice(&status.to_ne_bytes()); + set_len(&mut out); + out + } + + #[test] + fn family_errors_map_kernel_errno_after_validating_reply_identity() { + for errno in [Errno::NOENT, Errno::NODEV] { + assert_eq!( + parse_family(&netlink_error(1, 55, -errno.raw_os_error()), 1, 55), + Err(TransportError::NoDevice) + ); + } + for errno in [Errno::ACCESS, Errno::PERM] { + assert_eq!( + parse_family(&netlink_error(1, 55, -errno.raw_os_error()), 1, 55), + Err(TransportError::PermissionDenied) + ); + } + assert_eq!( + parse_family(&netlink_error(1, 55, -Errno::IO.raw_os_error()), 1, 55), + Err(TransportError::Transport) + ); + assert_eq!( + parse_family(&netlink_error(1, 55, i32::MIN), 1, 55), + Err(TransportError::Malformed) + ); + assert_eq!( + parse_family(&netlink_error(1, 55, 0), 1, 55), + Err(TransportError::Malformed) + ); + + assert_eq!( + parse_family(&netlink_error(2, 55, -Errno::NOENT.raw_os_error()), 1, 55), + Err(TransportError::Malformed) + ); + assert_eq!( + parse_family(&netlink_error(1, 56, -Errno::NOENT.raw_os_error()), 1, 55), + Err(TransportError::Malformed) + ); + assert_eq!( + parse_family( + &netlink_error(1, 55, -Errno::NOENT.raw_os_error())[..19], + 1, + 55 + ), + Err(TransportError::Malformed) + ); + } + + #[test] + fn family_resolution_propagates_kernel_errors_from_injected_transport() { + for (errno, expected) in [ + (Errno::NODEV, TransportError::NoDevice), + (Errno::ACCESS, TransportError::PermissionDenied), + (Errno::IO, TransportError::Transport), + ] { + let mut fake = FakeIo { + port: 55, + ..FakeIo::default() + }; + fake.incoming + .push_back(datagram(netlink_error(1, 55, -errno.raw_os_error()), 0)); + assert_eq!( + resolve_family(&mut fake, Duration::from_secs(1)), + Err(expected) + ); + assert_eq!(fake.sent.len(), 1); + } + } + + #[test] + fn get_request_targets_only_named_interface_and_dump_flags() { + let request = get_device_request(42, 7, 55, "wg-test"); + assert_eq!(read_u16(&request, 4), Ok(42)); + assert_eq!(read_u16(&request, 6), Ok(NLMSG_REQUEST | NLMSG_DUMP)); + assert_eq!( + &request[16..20], + &[WG_CMD_GET_DEVICE, WG_GENL_VERSION, 0, 0] + ); + assert_eq!(read_u16(&request, 20), Ok(12)); + assert_eq!(&request[22..24], &2u16.to_ne_bytes()); + assert_eq!(&request[24..32], b"wg-test\0"); + assert_eq!(request.len(), 32); + assert_eq!(read_u32(&request, 0), Ok(request.len() as u32)); + } + + #[derive(Default)] + struct FakeIo { + clock: Duration, + port: u32, + sent: Vec<(u32, Vec)>, + incoming: std::collections::VecDeque, + } + impl Io for FakeIo { + fn now(&self) -> Duration { + self.clock + } + fn port_id(&self) -> u32 { + self.port + } + fn send(&mut self, destination: u32, bytes: &[u8]) -> Result<(), TransportError> { + self.sent.push((destination, bytes.to_vec())); + Ok(()) + } + fn receive(&mut self, wait: Duration) -> Result { + if let Some(datagram) = self.incoming.pop_front() { + Ok(datagram) + } else { + self.clock += wait; + Err(TransportError::Timeout) + } + } + } + + fn datagram(bytes: Vec, sender: u32) -> Datagram { + Datagram { + bytes: Zeroizing::new(bytes), + sender, + } + } + + #[test] + fn injected_transport_rejects_non_kernel_sender_and_bounds_resolution_wait() { + let mut fake = FakeIo { + port: 55, + ..FakeIo::default() + }; + fake.incoming + .push_back(datagram(ctrl_family_message(1, 55, 42), 22)); + assert_eq!( + resolve_family(&mut fake, Duration::from_secs(1)), + Err(TransportError::Malformed) + ); + assert_eq!(fake.sent.len(), 1); + assert_eq!(fake.sent[0].0, 0); + + let mut silent = FakeIo { + port: 55, + ..FakeIo::default() + }; + assert_eq!( + resolve_family(&mut silent, Duration::from_millis(5)), + Err(TransportError::Timeout) + ); + assert_eq!(silent.clock, Duration::from_millis(5)); + } + + #[test] + fn injected_transport_returns_only_a_complete_public_empty_observation() { + let mut fake = FakeIo { + port: 55, + ..FakeIo::default() + }; + fake.incoming + .push_back(datagram(ctrl_family_message(1, 55, 42), 0)); + let mut response = message(42, NLMSG_MULTI, 2, 55); + response.extend_from_slice(&[WG_CMD_GET_DEVICE, WG_GENL_VERSION, 0, 0]); + push_attr(&mut response, 2, b"wg0\0"); + push_attr(&mut response, 8 | 0x8000, &[]); + set_len(&mut response); + let mut complete = response; + let done_start = complete.len(); + complete.extend_from_slice(&message(NLMSG_DONE, 0, 2, 55)); + complete.extend_from_slice(&0i32.to_ne_bytes()); + set_len(&mut complete[done_start..]); + fake.incoming.push_back(datagram(complete, 0)); + let observation = observe_with_io( + &mut fake, + "wg0", + Duration::from_secs(2), + DecodeLimits::default(), + ) + .unwrap(); + assert!( + matches!(observation.interface, InterfaceObservation::Present { ref name, ref peers } if name == "wg0" && peers.completeness == PeerSetCompleteness::Complete && peers.peers.is_empty()) + ); + assert_eq!(fake.sent.len(), 2); + assert!(fake.sent.iter().all(|(destination, _)| *destination == 0)); + } +} diff --git a/src/service.rs b/src/service.rs new file mode 100644 index 0000000..f9f0211 --- /dev/null +++ b/src/service.rs @@ -0,0 +1,469 @@ +//! Systemd restart job tracking and bounded D-Bus adapter. +//! +//! The event tracker is pure. The real D-Bus restart adapter is crate-private; +//! callers outside the crate must use the application restart boundary. +//! +//! ```compile_fail +//! use wireguard_admin::service::dbus::restart_systemd_unit_report; +//! ``` + +mod dbus; + +pub(crate) use dbus::restart_systemd_unit_report; + +use std::time::{Duration, Instant}; + +/// One operation-wide monotonic deadline, created before bus setup begins. +/// +/// The same value should be used for connection, subscription, request, +/// waiting, and subsequent runtime verification. A timed-out wait does not +/// imply that systemd canceled its job. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RestartDeadline { + started_at: Instant, + timeout: Duration, +} + +impl RestartDeadline { + pub const fn new(started_at: Instant, timeout: Duration) -> Self { + Self { + started_at, + timeout, + } + } + + pub const fn started_at(self) -> Instant { + self.started_at + } + + pub const fn timeout(self) -> Duration { + self.timeout + } + + /// Remaining time at a monotonic sample. Values sampled before the start + /// are treated as the start, which also keeps deterministic callers safe. + pub fn remaining_at(self, now: Instant) -> Duration { + self.timeout + .saturating_sub(now.saturating_duration_since(self.started_at)) + } + + pub fn is_expired_at(self, now: Instant) -> bool { + self.remaining_at(now).is_zero() + } +} + +/// The only event the tracker needs from systemd's `JobRemoved` signal. +/// Unknown systemd result strings should map to [`JobResult::Other`], without +/// retaining or surfacing the raw bus value. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum JobResult { + Done, + Failed, + Dependency, + Timeout, + Canceled, + Skipped, + Other, +} + +/// An event delivered by the future D-Bus adapter. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ServiceEvent { + JobRemoved { + job_path: String, + unit: String, + result: JobResult, + }, + Disconnected, + ManagerOwnerChanged, + SubscriptionFailed, + PermissionDenied, + RequestFailed, +} + +/// Redacted terminal reason. No D-Bus error string or signal payload is kept. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RestartFailure { + JobFailed, + JobDependency, + JobTimedOut, + JobCanceled, + JobSkipped, + JobResultUnknown, + BusDisconnected, + ManagerOwnerChanged, + SubscriptionFailed, + UnitResolutionFailed, + PermissionDenied, + RequestFailed, + WaitTimedOutMayStillComplete, + EarlyEventBufferFull, + InvalidJobPath, +} + +/// Current state of a single restart attempt. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RestartOutcome { + Waiting, + Succeeded, + Failed(RestartFailure), +} + +/// Terminal job outcome plus the request-submission boundary. A timeout after +/// submission explicitly leaves open the possibility that the job may finish. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RestartExecution { + pub outcome: RestartOutcome, + pub submitted: bool, +} + +/// Pure event tracker for the primary systemd unit resolved from the configured +/// target. +/// +/// Signals can arrive before the method reply containing the job path. To +/// close that race, up to [`MAX_EARLY_JOB_EVENTS`] events for the configured +/// unit are retained until `job_submitted` provides the actual path. Overflow +/// fails closed instead of silently dropping a potentially relevant event. +pub struct RestartJobTracker { + configured_unit: String, + deadline: RestartDeadline, + submitted_job_path: Option, + early_events: Vec<(String, JobResult)>, + outcome: RestartOutcome, +} + +/// Bound for `JobRemoved` events received before the systemd method reply. +pub const MAX_EARLY_JOB_EVENTS: usize = 64; + +impl RestartJobTracker { + pub fn new(configured_unit: impl Into, deadline: RestartDeadline) -> Self { + Self { + configured_unit: configured_unit.into(), + deadline, + submitted_job_path: None, + early_events: Vec::new(), + outcome: RestartOutcome::Waiting, + } + } + + pub fn configured_unit(&self) -> &str { + &self.configured_unit + } + + pub const fn deadline(&self) -> RestartDeadline { + self.deadline + } + + pub fn submitted_job_path(&self) -> Option<&str> { + self.submitted_job_path.as_deref() + } + + pub const fn outcome(&self) -> RestartOutcome { + self.outcome + } + + /// Record the actual object path returned by systemd's restart request. + /// Any earlier matching-unit event is reconciled against that path. + pub fn job_submitted(&mut self, job_path: impl Into, now: Instant) -> RestartOutcome { + if self.outcome != RestartOutcome::Waiting { + return self.outcome; + } + if self.expire_if_needed(now) { + return self.outcome; + } + + let job_path = job_path.into(); + if job_path.is_empty() { + self.fail(RestartFailure::InvalidJobPath); + return self.outcome; + } + if self.submitted_job_path.is_some() { + self.fail(RestartFailure::RequestFailed); + return self.outcome; + } + self.submitted_job_path = Some(job_path.clone()); + + for (event_path, result) in std::mem::take(&mut self.early_events) { + if event_path == job_path { + self.finish_job(result); + break; + } + } + self.outcome + } + + /// Apply one event using a caller-supplied monotonic time sample. + pub fn observe(&mut self, event: ServiceEvent, now: Instant) -> RestartOutcome { + if self.outcome != RestartOutcome::Waiting { + return self.outcome; + } + if self.expire_if_needed(now) { + return self.outcome; + } + + match event { + ServiceEvent::JobRemoved { + job_path, + unit, + result, + } => { + if unit != self.configured_unit || job_path.is_empty() { + return self.outcome; + } + if let Some(submitted_path) = &self.submitted_job_path { + if &job_path == submitted_path { + self.finish_job(result); + } + } else if self.early_events.len() == MAX_EARLY_JOB_EVENTS { + self.fail(RestartFailure::EarlyEventBufferFull); + } else { + self.early_events.push((job_path, result)); + } + } + ServiceEvent::Disconnected => self.fail(RestartFailure::BusDisconnected), + ServiceEvent::ManagerOwnerChanged => self.fail(RestartFailure::ManagerOwnerChanged), + ServiceEvent::SubscriptionFailed => self.fail(RestartFailure::SubscriptionFailed), + ServiceEvent::PermissionDenied => self.fail(RestartFailure::PermissionDenied), + ServiceEvent::RequestFailed => self.fail(RestartFailure::RequestFailed), + } + self.outcome + } + + /// Check the operation deadline even when no event arrives. + pub fn poll(&mut self, now: Instant) -> RestartOutcome { + self.expire_if_needed(now); + self.outcome + } + + fn expire_if_needed(&mut self, now: Instant) -> bool { + if self.deadline.is_expired_at(now) { + self.fail(RestartFailure::WaitTimedOutMayStillComplete); + true + } else { + false + } + } + + fn finish_job(&mut self, result: JobResult) { + self.outcome = match result { + JobResult::Done => RestartOutcome::Succeeded, + JobResult::Failed => RestartOutcome::Failed(RestartFailure::JobFailed), + JobResult::Dependency => RestartOutcome::Failed(RestartFailure::JobDependency), + JobResult::Timeout => RestartOutcome::Failed(RestartFailure::JobTimedOut), + JobResult::Canceled => RestartOutcome::Failed(RestartFailure::JobCanceled), + JobResult::Skipped => RestartOutcome::Failed(RestartFailure::JobSkipped), + JobResult::Other => RestartOutcome::Failed(RestartFailure::JobResultUnknown), + }; + } + + fn fail(&mut self, failure: RestartFailure) { + self.outcome = RestartOutcome::Failed(failure); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup() -> (Instant, RestartJobTracker) { + let start = Instant::now(); + let deadline = RestartDeadline::new(start, Duration::from_secs(5)); + ( + start, + RestartJobTracker::new("wg-quick@wg0.service", deadline), + ) + } + + fn removed(path: &str, unit: &str, result: JobResult) -> ServiceEvent { + ServiceEvent::JobRemoved { + job_path: path.to_owned(), + unit: unit.to_owned(), + result, + } + } + + #[test] + fn buffers_fast_completion_until_actual_job_path_is_returned() { + let (start, mut tracker) = setup(); + assert_eq!( + tracker.observe( + removed( + "/org/freedesktop/systemd1/job/41", + "wg-quick@wg0.service", + JobResult::Done + ), + start + Duration::from_millis(1), + ), + RestartOutcome::Waiting + ); + assert_eq!( + tracker.job_submitted( + "/org/freedesktop/systemd1/job/41", + start + Duration::from_millis(2), + ), + RestartOutcome::Succeeded + ); + } + + #[test] + fn unrelated_jobs_and_units_do_not_finish_the_attempt() { + let (start, mut tracker) = setup(); + tracker.job_submitted("/job/actual", start + Duration::from_millis(1)); + for event in [ + removed("/job/other", "wg-quick@wg0.service", JobResult::Done), + removed("/job/actual", "wg-quick@wg1.service", JobResult::Done), + ] { + assert_eq!( + tracker.observe(event, start + Duration::from_millis(2)), + RestartOutcome::Waiting + ); + } + } + + #[test] + fn configured_alias_matches_job_removed_primary_unit_name() { + let start = Instant::now(); + let deadline = RestartDeadline::new(start, Duration::from_secs(5)); + // The adapter resolves this primary ID from configured alias + // "wireguard-vpn.service" before constructing the tracker. + let mut tracker = RestartJobTracker::new("wg-quick@wg0.service", deadline); + tracker.job_submitted("/job/alias-target", start + Duration::from_millis(1)); + assert_eq!( + tracker.observe( + removed("/job/alias-target", "wg-quick@wg0.service", JobResult::Done,), + start + Duration::from_millis(2), + ), + RestartOutcome::Succeeded + ); + } + + #[test] + fn only_the_done_job_result_is_success() { + for (job_result, failure) in [ + (JobResult::Failed, RestartFailure::JobFailed), + (JobResult::Dependency, RestartFailure::JobDependency), + (JobResult::Timeout, RestartFailure::JobTimedOut), + (JobResult::Canceled, RestartFailure::JobCanceled), + (JobResult::Skipped, RestartFailure::JobSkipped), + (JobResult::Other, RestartFailure::JobResultUnknown), + ] { + let (start, mut tracker) = setup(); + tracker.job_submitted("/job/actual", start + Duration::from_millis(1)); + assert_eq!( + tracker.observe( + removed("/job/actual", "wg-quick@wg0.service", job_result), + start + Duration::from_millis(2), + ), + RestartOutcome::Failed(failure) + ); + } + } + + #[test] + fn events_for_same_unit_are_still_correlated_by_returned_path() { + let (start, mut tracker) = setup(); + tracker.observe( + removed("/job/unrelated", "wg-quick@wg0.service", JobResult::Failed), + start + Duration::from_millis(1), + ); + assert_eq!( + tracker.job_submitted("/job/actual", start + Duration::from_millis(2)), + RestartOutcome::Waiting + ); + } + + #[test] + fn systemd_oneshot_success_needs_no_pid_or_active_state() { + let (start, mut tracker) = setup(); + tracker.job_submitted("/job/actual", start + Duration::from_millis(1)); + assert_eq!( + tracker.observe( + removed("/job/actual", "wg-quick@wg0.service", JobResult::Done), + start + Duration::from_millis(2), + ), + RestartOutcome::Succeeded + ); + } + + #[test] + fn monotonic_deadline_is_shared_and_timeout_does_not_claim_cancellation() { + let (start, mut tracker) = setup(); + let deadline = tracker.deadline(); + assert_eq!( + deadline.remaining_at(start + Duration::from_secs(2)), + Duration::from_secs(3) + ); + assert_eq!( + tracker.poll(start + Duration::from_secs(5)), + RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete) + ); + } + + #[test] + fn bus_and_request_failures_are_redacted_terminal_outcomes() { + for (event, failure) in [ + (ServiceEvent::Disconnected, RestartFailure::BusDisconnected), + ( + ServiceEvent::ManagerOwnerChanged, + RestartFailure::ManagerOwnerChanged, + ), + ( + ServiceEvent::SubscriptionFailed, + RestartFailure::SubscriptionFailed, + ), + ( + ServiceEvent::PermissionDenied, + RestartFailure::PermissionDenied, + ), + (ServiceEvent::RequestFailed, RestartFailure::RequestFailed), + ] { + let (start, mut tracker) = setup(); + assert_eq!( + tracker.observe(event, start + Duration::from_millis(1)), + RestartOutcome::Failed(failure) + ); + } + } + + #[test] + fn early_event_buffer_overflow_fails_closed() { + let (start, mut tracker) = setup(); + for index in 0..MAX_EARLY_JOB_EVENTS { + assert_eq!( + tracker.observe( + removed( + &format!("/job/{index}"), + "wg-quick@wg0.service", + JobResult::Done, + ), + start + Duration::from_millis(1), + ), + RestartOutcome::Waiting + ); + } + assert_eq!( + tracker.observe( + removed("/job/overflow", "wg-quick@wg0.service", JobResult::Done), + start + Duration::from_millis(2), + ), + RestartOutcome::Failed(RestartFailure::EarlyEventBufferFull) + ); + } + + #[test] + fn terminal_outcome_cannot_be_replaced_by_later_events() { + let (start, mut tracker) = setup(); + tracker.job_submitted("/job/actual", start + Duration::from_millis(1)); + tracker.observe( + removed("/job/actual", "wg-quick@wg0.service", JobResult::Failed), + start + Duration::from_millis(2), + ); + assert_eq!( + tracker.observe( + removed("/job/actual", "wg-quick@wg0.service", JobResult::Done), + start + Duration::from_millis(3), + ), + RestartOutcome::Failed(RestartFailure::JobFailed) + ); + } +} diff --git a/src/service/dbus.rs b/src/service/dbus.rs new file mode 100644 index 0000000..c8c00fd --- /dev/null +++ b/src/service/dbus.rs @@ -0,0 +1,680 @@ +//! Bounded asynchronous systemd D-Bus restart adapter. +//! +//! zbus's `async-io` backend owns its socket-driving thread. This module uses +//! a small standard-library executor to poll each caller-side future only +//! until the shared monotonic deadline; it does not use zbus's blocking signal +//! iterator, whose `next` call has no application deadline. + +use std::future::{Future, poll_fn}; +use std::pin::{Pin, pin}; +use std::sync::Arc; +use std::task::{Context, Poll, Wake, Waker}; +use std::thread; +use std::time::Instant; + +use zbus::export::futures_core::Stream; +use zbus::message::Type; +use zbus::{Connection, MatchRule, MessageStream, Proxy}; + +use super::{ + JobResult, RestartDeadline, RestartExecution, RestartFailure, RestartJobTracker, + RestartOutcome, ServiceEvent, +}; + +const SYSTEMD_NAME: &str = "org.freedesktop.systemd1"; +const SYSTEMD_PATH: &str = "/org/freedesktop/systemd1"; +const SYSTEMD_MANAGER: &str = "org.freedesktop.systemd1.Manager"; +const DBUS_NAME: &str = "org.freedesktop.DBus"; +const DBUS_INTERFACE: &str = "org.freedesktop.DBus"; + +/// Resolve the configured unit's primary ID, request a restart of exactly +/// `configured_unit`, and wait for its matching systemd `JobRemoved` result. +/// The caller owns backup ordering and subsequent runtime verification, and +/// must pass the same deadline to those stages. +/// Restart a configured unit and retain whether a timed-out request was submitted. +pub(crate) fn restart_systemd_unit_report( + configured_unit: &str, + deadline: RestartDeadline, +) -> RestartExecution { + restart_with_transport_report(&mut SystemdBus::default(), configured_unit, deadline) +} + +trait RestartTransport { + fn connect(&mut self, deadline: RestartDeadline) -> Result<(), RestartFailure>; + fn subscribe(&mut self, deadline: RestartDeadline) -> Result<(), RestartFailure>; + fn resolve_primary_unit( + &mut self, + configured_unit: &str, + deadline: RestartDeadline, + ) -> Result; + fn restart_unit( + &mut self, + unit: &str, + deadline: RestartDeadline, + ) -> Result; + fn next_event(&mut self, deadline: RestartDeadline) -> Result; +} + +#[cfg(test)] +fn restart_with_transport( + transport: &mut impl RestartTransport, + configured_unit: &str, + deadline: RestartDeadline, +) -> RestartOutcome { + restart_with_transport_report(transport, configured_unit, deadline).outcome +} + +fn restart_with_transport_report( + transport: &mut impl RestartTransport, + configured_unit: &str, + deadline: RestartDeadline, +) -> RestartExecution { + if configured_unit.is_empty() || configured_unit.as_bytes().contains(&0) { + return execution(RestartOutcome::Failed(RestartFailure::RequestFailed), false); + } + + if let Err(failure) = transport.connect(deadline) { + return execution(RestartOutcome::Failed(failure), false); + } + if deadline.is_expired_at(Instant::now()) { + return execution( + RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete), + false, + ); + } + if let Err(failure) = transport.subscribe(deadline) { + return execution(RestartOutcome::Failed(failure), false); + } + if deadline.is_expired_at(Instant::now()) { + return execution( + RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete), + false, + ); + } + let primary_unit = match transport.resolve_primary_unit(configured_unit, deadline) { + Ok(unit) if !unit.is_empty() => unit, + Ok(_) => { + return execution( + RestartOutcome::Failed(RestartFailure::UnitResolutionFailed), + false, + ); + } + Err(failure) => return execution(RestartOutcome::Failed(failure), false), + }; + if deadline.is_expired_at(Instant::now()) { + return execution( + RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete), + false, + ); + } + + let job_path = match transport.restart_unit(configured_unit, deadline) { + Ok(path) => path, + Err(failure) => { + let submitted = matches!( + failure, + RestartFailure::WaitTimedOutMayStillComplete | RestartFailure::BusDisconnected + ); + return execution(RestartOutcome::Failed(failure), submitted); + } + }; + let mut tracker = RestartJobTracker::new(primary_unit, deadline); + let mut outcome = tracker.job_submitted(job_path, Instant::now()); + while outcome == RestartOutcome::Waiting { + let event = match transport.next_event(deadline) { + Ok(event) => event, + Err(failure) => return execution(RestartOutcome::Failed(failure), true), + }; + outcome = tracker.observe(event, Instant::now()); + } + execution(outcome, true) +} + +fn execution(outcome: RestartOutcome, submitted: bool) -> RestartExecution { + RestartExecution { outcome, submitted } +} + +#[derive(Default)] +struct SystemdBus { + connection: Option, + manager: Option>, + job_events: Option>, + owner_events: Option, +} + +impl RestartTransport for SystemdBus { + fn connect(&mut self, deadline: RestartDeadline) -> Result<(), RestartFailure> { + let connection = drive(Connection::system(), deadline)?.map_err(map_bus_error)?; + let manager = drive( + Proxy::new_owned( + connection.clone(), + SYSTEMD_NAME, + SYSTEMD_PATH, + SYSTEMD_MANAGER, + ), + deadline, + )? + .map_err(map_bus_error)?; + self.manager = Some(manager); + self.connection = Some(connection); + Ok(()) + } + + fn subscribe(&mut self, deadline: RestartDeadline) -> Result<(), RestartFailure> { + let manager = self + .manager + .as_ref() + .ok_or(RestartFailure::SubscriptionFailed)?; + let job_events = drive(manager.receive_signal("JobRemoved"), deadline)? + .map_err(|_| RestartFailure::SubscriptionFailed)?; + + let owner_rule = MatchRule::builder() + .msg_type(Type::Signal) + .sender(DBUS_NAME) + .map_err(|_| RestartFailure::SubscriptionFailed)? + .interface(DBUS_INTERFACE) + .map_err(|_| RestartFailure::SubscriptionFailed)? + .member("NameOwnerChanged") + .map_err(|_| RestartFailure::SubscriptionFailed)? + .add_arg(SYSTEMD_NAME) + .map_err(|_| RestartFailure::SubscriptionFailed)? + .build(); + let owner_events = drive( + MessageStream::for_match_rule( + owner_rule, + self.connection + .as_ref() + .ok_or(RestartFailure::SubscriptionFailed)?, + Some(1), + ), + deadline, + )? + .map_err(|_| RestartFailure::SubscriptionFailed)?; + + // Both signal matches are installed before Subscribe and RestartUnit. + self.job_events = Some(job_events); + self.owner_events = Some(owner_events); + drive(manager.call::<_, _, ()>("Subscribe", &()), deadline)?.map_err(map_bus_error) + } + + fn resolve_primary_unit( + &mut self, + configured_unit: &str, + deadline: RestartDeadline, + ) -> Result { + let manager = self + .manager + .as_ref() + .ok_or(RestartFailure::UnitResolutionFailed)?; + let connection = self + .connection + .as_ref() + .ok_or(RestartFailure::BusDisconnected)?; + let unit_path: zbus::zvariant::OwnedObjectPath = + drive(manager.call("GetUnit", &(configured_unit,)), deadline)? + .map_err(map_unit_resolution_error)?; + let unit = drive( + Proxy::new_owned( + connection.clone(), + SYSTEMD_NAME, + unit_path, + "org.freedesktop.systemd1.Unit", + ), + deadline, + )? + .map_err(map_unit_resolution_error)?; + drive(unit.get_property::("Id"), deadline)?.map_err(map_unit_resolution_error) + } + + fn restart_unit( + &mut self, + unit: &str, + deadline: RestartDeadline, + ) -> Result { + let manager = self.manager.as_ref().ok_or(RestartFailure::RequestFailed)?; + let connection = self + .connection + .as_ref() + .ok_or(RestartFailure::BusDisconnected)?; + let owner_events = self + .owner_events + .as_mut() + .ok_or(RestartFailure::BusDisconnected)?; + let request_body = (unit, "replace"); + let request = manager.call("RestartUnit", &request_body); + let job_path: zbus::zvariant::OwnedObjectPath = drive( + async { + let mut request = pin!(request); + let mut closed = pin!(connection.closed()); + poll_fn(|cx| { + if connection.is_closed() { + return Poll::Ready(Err(RestartFailure::BusDisconnected)); + } + if let Poll::Ready(event) = Stream::poll_next(Pin::new(&mut *owner_events), cx) + { + return Poll::Ready(match event { + None => Err(RestartFailure::BusDisconnected), + Some(Err(error)) => Err(map_bus_error(error)), + Some(Ok(_)) => Err(RestartFailure::ManagerOwnerChanged), + }); + } + if let Poll::Ready(result) = Future::poll(request.as_mut(), cx) { + return Poll::Ready(result.map_err(map_bus_error)); + } + if Future::poll(closed.as_mut(), cx).is_ready() { + return Poll::Ready(Err(RestartFailure::BusDisconnected)); + } + Poll::Pending + }) + .await + }, + deadline, + )??; + Ok(job_path.to_string()) + } + + fn next_event(&mut self, deadline: RestartDeadline) -> Result { + let connection = self + .connection + .as_ref() + .ok_or(RestartFailure::BusDisconnected)?; + let job_events = self + .job_events + .as_mut() + .ok_or(RestartFailure::BusDisconnected)?; + let owner_events = self + .owner_events + .as_mut() + .ok_or(RestartFailure::BusDisconnected)?; + drive( + async { + let mut closed = pin!(connection.closed()); + poll_fn(|cx| { + if connection.is_closed() { + return Poll::Ready(Ok(ServiceEvent::Disconnected)); + } + if let Poll::Ready(message) = + Stream::poll_next(Pin::new(&mut *owner_events), cx) + { + return Poll::Ready(match message { + None => Ok(ServiceEvent::Disconnected), + Some(Err(error)) => Err(map_bus_error(error)), + Some(Ok(message)) => { + let decoded: Result<(String, String, String), _> = + message.body().deserialize(); + match decoded { + Ok((name, _, _)) if name == SYSTEMD_NAME => { + Ok(ServiceEvent::ManagerOwnerChanged) + } + Ok(_) => Ok(ServiceEvent::JobRemoved { + job_path: String::new(), + unit: String::new(), + result: JobResult::Other, + }), + Err(_) => Err(RestartFailure::RequestFailed), + } + } + }); + } + if let Poll::Ready(message) = Stream::poll_next(Pin::new(&mut *job_events), cx) + { + return Poll::Ready(match message { + None => Ok(ServiceEvent::Disconnected), + Some(message) => { + let decoded: Result< + (u32, zbus::zvariant::OwnedObjectPath, String, String), + _, + > = message.body().deserialize(); + match decoded { + Ok((_, path, unit, result)) => Ok(ServiceEvent::JobRemoved { + job_path: path.to_string(), + unit, + result: map_job_result(&result), + }), + Err(_) => Err(RestartFailure::RequestFailed), + } + } + }); + } + if Future::poll(closed.as_mut(), cx).is_ready() { + return Poll::Ready(Ok(ServiceEvent::Disconnected)); + } + Poll::Pending + }) + .await + }, + deadline, + )? + } +} + +fn map_job_result(result: &str) -> JobResult { + match result { + "done" => JobResult::Done, + "failed" => JobResult::Failed, + "dependency" => JobResult::Dependency, + "timeout" => JobResult::Timeout, + "canceled" => JobResult::Canceled, + "skipped" => JobResult::Skipped, + _ => JobResult::Other, + } +} + +fn map_bus_error(error: zbus::Error) -> RestartFailure { + match error { + zbus::Error::MethodError(name, _, _) + if name.as_str() == "org.freedesktop.DBus.Error.AccessDenied" + || name.as_str() == "org.freedesktop.systemd1.AccessDenied" => + { + RestartFailure::PermissionDenied + } + zbus::Error::InputOutput(_) | zbus::Error::Handshake(_) => RestartFailure::BusDisconnected, + _ => RestartFailure::RequestFailed, + } +} + +fn map_unit_resolution_error(error: zbus::Error) -> RestartFailure { + match map_bus_error(error) { + RestartFailure::PermissionDenied => RestartFailure::PermissionDenied, + RestartFailure::BusDisconnected => RestartFailure::BusDisconnected, + _ => RestartFailure::UnitResolutionFailed, + } +} + +struct ThreadWake(thread::Thread); + +impl Wake for ThreadWake { + fn wake(self: Arc) { + self.0.unpark(); + } + + fn wake_by_ref(self: &Arc) { + self.0.unpark(); + } +} + +/// Poll a future on this thread, parking only for the remaining operation +/// budget. zbus's async-io connection executor continues driving socket I/O +/// independently and wakes this thread when the future can make progress. +fn drive(future: F, deadline: RestartDeadline) -> Result { + let waker = Waker::from(Arc::new(ThreadWake(thread::current()))); + let mut context = Context::from_waker(&waker); + let mut future = pin!(future); + loop { + if deadline.is_expired_at(Instant::now()) { + return Err(RestartFailure::WaitTimedOutMayStillComplete); + } + match Future::poll(future.as_mut(), &mut context) { + Poll::Ready(output) => return Ok(output), + Poll::Pending => { + let remaining = deadline.remaining_at(Instant::now()); + if remaining.is_zero() { + return Err(RestartFailure::WaitTimedOutMayStillComplete); + } + thread::park_timeout(remaining); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + use std::time::Duration; + + struct FakeBus { + calls: Vec<&'static str>, + deadlines: Vec<(&'static str, RestartDeadline)>, + events: VecDeque, + failure: Option<(&'static str, RestartFailure)>, + configured_unit: &'static str, + job_path: &'static str, + primary_unit: &'static str, + } + + impl Default for FakeBus { + fn default() -> Self { + Self { + calls: Vec::new(), + deadlines: Vec::new(), + events: VecDeque::new(), + failure: None, + configured_unit: "wg-quick@wg0.service", + job_path: "", + primary_unit: "wg-quick@wg0.service", + } + } + } + + impl FakeBus { + fn stage(&mut self, stage: &'static str) -> Result<(), RestartFailure> { + self.calls.push(stage); + match self.failure { + Some((failed_stage, failure)) if failed_stage == stage => Err(failure), + _ => Ok(()), + } + } + } + + impl RestartTransport for FakeBus { + fn connect(&mut self, deadline: RestartDeadline) -> Result<(), RestartFailure> { + self.deadlines.push(("connect", deadline)); + self.stage("connect") + } + + fn subscribe(&mut self, deadline: RestartDeadline) -> Result<(), RestartFailure> { + self.deadlines.push(("subscribe", deadline)); + self.stage("subscribe") + } + + fn resolve_primary_unit( + &mut self, + configured_unit: &str, + deadline: RestartDeadline, + ) -> Result { + assert_eq!(configured_unit, self.configured_unit); + self.deadlines.push(("resolve", deadline)); + self.stage("resolve")?; + Ok(self.primary_unit.to_owned()) + } + + fn restart_unit( + &mut self, + unit: &str, + deadline: RestartDeadline, + ) -> Result { + assert_eq!(unit, self.configured_unit); + self.deadlines.push(("restart", deadline)); + self.stage("restart")?; + Ok(self.job_path.to_owned()) + } + + fn next_event( + &mut self, + deadline: RestartDeadline, + ) -> Result { + self.deadlines.push(("event", deadline)); + self.stage("event")?; + self.events + .pop_front() + .ok_or(RestartFailure::BusDisconnected) + } + } + + fn deadline() -> RestartDeadline { + RestartDeadline::new(Instant::now(), Duration::from_secs(2)) + } + + fn removed(path: &str, unit: &str, result: JobResult) -> ServiceEvent { + ServiceEvent::JobRemoved { + job_path: path.to_owned(), + unit: unit.to_owned(), + result, + } + } + + #[test] + fn subscribes_before_restart_and_uses_returned_job_path() { + let mut fake = FakeBus { + job_path: "/job/actual", + primary_unit: "wg-quick@wg0.service", + events: VecDeque::from([ + removed("/job/other", "wg-quick@wg0.service", JobResult::Done), + removed("/job/actual", "wg-quick@wg0.service", JobResult::Done), + ]), + ..FakeBus::default() + }; + assert_eq!( + restart_with_transport(&mut fake, "wg-quick@wg0.service", deadline()), + RestartOutcome::Succeeded + ); + assert_eq!( + fake.calls, + [ + "connect", + "subscribe", + "resolve", + "restart", + "event", + "event" + ] + ); + } + + #[test] + fn fast_completion_and_failure_results_are_processed_without_a_bus() { + let mut fast = FakeBus { + job_path: "/job/fast", + primary_unit: "wg-quick@wg0.service", + events: VecDeque::from([removed( + "/job/fast", + "wg-quick@wg0.service", + JobResult::Done, + )]), + ..FakeBus::default() + }; + assert_eq!( + restart_with_transport(&mut fast, "wg-quick@wg0.service", deadline()), + RestartOutcome::Succeeded + ); + + let mut failed = FakeBus { + job_path: "/job/failed", + primary_unit: "wg-quick@wg0.service", + events: VecDeque::from([removed( + "/job/failed", + "wg-quick@wg0.service", + JobResult::Canceled, + )]), + ..FakeBus::default() + }; + assert_eq!( + restart_with_transport(&mut failed, "wg-quick@wg0.service", deadline()), + RestartOutcome::Failed(RestartFailure::JobCanceled) + ); + } + + #[test] + fn setup_and_permission_failures_stop_before_restart() { + for (stage, failure) in [ + ("connect", RestartFailure::BusDisconnected), + ("subscribe", RestartFailure::SubscriptionFailed), + ("resolve", RestartFailure::UnitResolutionFailed), + ("restart", RestartFailure::PermissionDenied), + ] { + let mut fake = FakeBus { + job_path: "/job/never", + primary_unit: "wg-quick@wg0.service", + failure: Some((stage, failure)), + ..FakeBus::default() + }; + assert_eq!( + restart_with_transport(&mut fake, "wg-quick@wg0.service", deadline()), + RestartOutcome::Failed(failure) + ); + if stage != "restart" { + assert!(!fake.calls.contains(&"restart")); + } + } + } + + #[test] + fn resolves_alias_before_restart_and_uses_one_shared_deadline() { + let deadline = deadline(); + let mut fake = FakeBus { + job_path: "/job/alias", + configured_unit: "wireguard-vpn.service", + primary_unit: "wg-quick@wg0.service", + events: VecDeque::from([removed( + "/job/alias", + "wg-quick@wg0.service", + JobResult::Done, + )]), + ..FakeBus::default() + }; + assert_eq!( + restart_with_transport(&mut fake, "wireguard-vpn.service", deadline), + RestartOutcome::Succeeded + ); + assert_eq!( + fake.calls, + ["connect", "subscribe", "resolve", "restart", "event"] + ); + assert!(fake.deadlines.iter().all(|(_, passed)| *passed == deadline)); + } + + #[test] + fn expired_shared_deadline_stops_between_setup_and_submission() { + let mut fake = FakeBus { + job_path: "/job/never", + ..FakeBus::default() + }; + let expired = RestartDeadline::new(Instant::now(), Duration::ZERO); + assert_eq!( + restart_with_transport(&mut fake, "wg-quick@wg0.service", expired), + RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete) + ); + assert_eq!(fake.calls, ["connect"]); + } + + #[test] + fn result_mapping_accepts_only_the_documented_done_value() { + assert_eq!(map_job_result("done"), JobResult::Done); + for value in ["failed", "dependency", "timeout", "canceled", "skipped"] { + assert_ne!(map_job_result(value), JobResult::Done); + } + assert_eq!(map_job_result("future-result"), JobResult::Other); + } + + #[test] + fn std_wake_park_driver_rejects_an_already_expired_future() { + let expired = RestartDeadline::new(Instant::now(), Duration::ZERO); + assert_eq!( + drive(std::future::pending::<()>(), expired), + Err(RestartFailure::WaitTimedOutMayStillComplete) + ); + } + + #[test] + fn owner_change_and_disconnect_fail_the_attempt() { + for (event, failure) in [ + ( + ServiceEvent::ManagerOwnerChanged, + RestartFailure::ManagerOwnerChanged, + ), + (ServiceEvent::Disconnected, RestartFailure::BusDisconnected), + ] { + let mut fake = FakeBus { + job_path: "/job/actual", + primary_unit: "wg-quick@wg0.service", + events: VecDeque::from([event]), + ..FakeBus::default() + }; + assert_eq!( + restart_with_transport(&mut fake, "wg-quick@wg0.service", deadline()), + RestartOutcome::Failed(failure) + ); + } + } +} diff --git a/src/wireguard/document.rs b/src/wireguard/document.rs index 4c64668..2e005bf 100644 --- a/src/wireguard/document.rs +++ b/src/wireguard/document.rs @@ -457,6 +457,45 @@ impl ConfigDocument { &self.issues } + /// Whether any interface section enables or ambiguously configures + /// wg-quick SaveConfig. Restarting such a service can write live peer state + /// back over the saved configuration during shutdown. + pub fn has_unsafe_save_config(&self) -> bool { + let mut found = false; + for section in self + .sections + .iter() + .filter(|section| section.kind == SectionKind::Interface) + { + let Some(source) = self.source_slice(section.span) else { + return true; + }; + let mut in_interface = false; + for line in source.lines() { + let trimmed = line.trim_matches(|ch: char| ch.is_ascii_whitespace()); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_interface = parse_section_name(trimmed) + .is_some_and(|name| name.eq_ignore_ascii_case("Interface")); + continue; + } + if !in_interface || trimmed.is_empty() || trimmed.starts_with(['#', ';']) { + continue; + } + let Some((key, value)) = trimmed.split_once('=') else { + continue; + }; + if !key.trim().eq_ignore_ascii_case("SaveConfig") { + continue; + } + if found || !value.trim().eq_ignore_ascii_case("false") { + return true; + } + found = true; + } + } + false + } + /// A raw source slice for an explicitly selected span. Callers must treat it as secret-bearing. /// This narrow primitive supports later edit code that owns and replaces exact spans. pub fn source_slice(&self, span: SourceSpan) -> Option<&str> { @@ -491,6 +530,26 @@ mod tests { } } + #[test] + fn save_config_detection_is_interface_scoped_and_fails_closed_on_ambiguity() { + let disabled = + ConfigDocument::parse(b"[Interface]\nSaveConfig = false\n[Peer]\nSaveConfig = true\n") + .unwrap(); + assert!(!disabled.has_unsafe_save_config()); + + for input in [ + b"[Interface]\nSaveConfig = true\n".as_slice(), + b"[Interface]\nSaveConfig = maybe\n".as_slice(), + b"[Interface]\nSaveConfig = false\nSaveConfig = false\n".as_slice(), + ] { + assert!( + ConfigDocument::parse(input) + .unwrap() + .has_unsafe_save_config() + ); + } + } + #[test] fn wireguard_peer_inspection_preserves_multiple_routes_and_unknown_directives() { let input = format!( diff --git a/tests/runtime_restart.rs b/tests/runtime_restart.rs new file mode 100644 index 0000000..52ed31c --- /dev/null +++ b/tests/runtime_restart.rs @@ -0,0 +1,161 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use wireguard_admin::app::{Application, RestartChoice, RestartStage}; +use wireguard_admin::config::{ResolvedSettings, SettingsFile}; +use wireguard_admin::domain::{ + Client, ClientAddresses, ClientStates, RuntimeObservation, Snapshot, +}; +use wireguard_admin::persistence::SavedStore; +use wireguard_admin::vault::WireGuardPrivateKey; + +fn settings(service: &str, timeout: u64) -> String { + format!( + r#" +interface_name = "wg0" +server_config_path = "server.conf" +client_source_directory = "clients" +server_endpoint = "vpn.example.test:51820" +ipv4_allocation_range = "10.20.0.0/24" +ipv6_allocation_range = "fd20::/64" +dns_addresses = ["10.20.0.1"] +systemd_unit = "{service}" +restart_timeout_seconds = {timeout} +"# + ) +} + +struct Fixture { + _root: tempfile::TempDir, + store: SavedStore, + initial_settings: String, +} + +fn fixture(service: &str, timeout: u64) -> Fixture { + let root = tempfile::tempdir().expect("temporary saved store"); + let initial_settings = settings(service, timeout); + let settings = SettingsFile::parse_toml(&initial_settings) + .expect("synthetic settings parse") + .validate() + .expect("synthetic settings validate") + .resolve_paths(root.path()); + fs::create_dir_all(&settings.client_source_directory).expect("client directory"); + fs::create_dir_all(&settings.data_directory).expect("data directory"); + fs::create_dir_all(&settings.archives_directory).expect("archive directory"); + fs::write( + ResolvedSettings::settings_path(root.path()), + initial_settings.as_bytes(), + ) + .expect("install synthetic settings"); + fs::write( + &settings.server_config_path, + b"[Interface]\nPrivateKey = synthetic-server-key\nAddress = 10.20.0.1/24\n", + ) + .expect("install synthetic server configuration"); + fs::set_permissions( + &settings.server_config_path, + fs::Permissions::from_mode(0o600), + ) + .expect("restrict synthetic server configuration"); + let store = SavedStore::new(root.path(), settings.clone()).expect("saved store mapping"); + Fixture { + _root: root, + store, + initial_settings, + } +} + +fn open(fixture: &Fixture) -> Application { + let states = ClientStates::new(Snapshot::default(), None, RuntimeObservation::Unknown) + .expect("empty saved states"); + Application::open(fixture.store.clone(), states).expect("open synthetic application") +} + +fn archive_name(path: &Path) -> &str { + path.file_name() + .and_then(|name| name.to_str()) + .expect("archive filename") +} + +#[test] +fn public_restart_preview_and_cancel_preserve_draft_and_saved_files() { + let fixture = fixture("wg-quick@wg0.service", 30); + let mut app = open(&fixture); + let original_server_config = app.saved_bytes().server_config.clone(); + let secret = WireGuardPrivateKey::generate().expect("synthetic client key"); + let client = Client::new( + secret.public_key(), + "draft client", + ClientAddresses { + ipv4: "10.20.0.2".parse().expect("synthetic IPv4"), + ipv6: Some("fd20::2".parse().expect("synthetic IPv6")), + }, + ) + .expect("synthetic client"); + app.add_client(client.clone(), None) + .expect("stage client draft"); + + let preview = app.preview_restart().expect("preview saved generation"); + assert!(preview.has_draft()); + assert_eq!(preview.interface(), "wg0"); + assert_eq!(preview.systemd_unit(), "wg-quick@wg0.service"); + assert!(app.list_backups().expect("list archives").is_empty()); + + // The public cancellation path returns before either real host adapter can run. + let report = app.restart(preview, RestartChoice::Cancel); + + assert_eq!(report.stage, RestartStage::Cancelled); + assert!(!report.restart_submitted); + assert_eq!(app.saved_bytes().server_config, original_server_config); + assert!(!app.document().render().is_empty()); + assert_eq!( + app.states() + .draft() + .get(&client.identity) + .map(|saved| saved.alias.as_str()), + Some("draft client") + ); + assert!( + app.list_backups() + .expect("no cancellation backup") + .is_empty() + ); + assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); +} + +#[test] +fn restore_changes_the_settings_used_by_the_next_restart_preview() { + let fixture = fixture("wg-quick@wg0.service", 30); + let app = open(&fixture); + let archive = app + .create_manual_backup() + .expect("archive original saved settings"); + + let replacement = settings("wg-quick@wg9.service", 90); + fs::write( + ResolvedSettings::settings_path(fixture._root.path()), + replacement.as_bytes(), + ) + .expect("install a second synthetic saved settings generation"); + drop(app); + + let mut app = open(&fixture); + let preview = app + .preview_restore(archive_name(&archive)) + .expect("preview archived saved settings"); + app.confirm_restore(preview) + .expect("restore archived saved settings"); + + assert_eq!( + app.saved_bytes().settings, + fixture.initial_settings.as_bytes() + ); + assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + let restart = app + .preview_restart() + .expect("preview restored generation without invoking adapters"); + assert_eq!(restart.interface(), "wg0"); + assert_eq!(restart.systemd_unit(), "wg-quick@wg0.service"); + assert!(!restart.has_draft()); +}