Add runtime observation and systemd restart adapter

This commit is contained in:
2026-10-20 23:01:43 +03:00
parent 5418841b92
commit 6ec593c783
18 changed files with 6001 additions and 66 deletions
Generated
+565
View File
@@ -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",
]
+2 -1
View File
@@ -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"] }
+432 -6
View File
@@ -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
+20 -12
View File
@@ -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
+5 -3
View File
@@ -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.
+13
View File
@@ -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`.
+168
View File
@@ -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.
+1 -1
View File
@@ -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.
+1074 -42
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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> {
+2
View File
@@ -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;
+674
View File
@@ -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<T> {
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<Vec<IpNet>>,
/// `Available(None)` means WireGuard has never recorded a handshake.
pub last_handshake: Observed<Option<OffsetDateTime>>,
/// These are current counters and may reset when the interface restarts.
pub received_bytes: Observed<u64>,
pub transmitted_bytes: Observed<u64>,
}
/// 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<PublicKey, RuntimePeer>,
pub duplicate_public_keys: BTreeSet<PublicKey>,
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<Item = (PublicKey, RuntimePeer)>,
) -> 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<OffsetDateTime>,
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<IpNet>,
}
/// 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<PublicKey, SavedPeer>,
pub duplicate_public_keys: BTreeSet<PublicKey>,
pub unverifiable_peer_indices: BTreeSet<usize>,
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<ComparisonFinding>),
Unverifiable(Vec<ComparisonFinding>),
}
/// 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<IpNet> =
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<Vec<IpNet>>) -> 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)
));
}
}
File diff suppressed because it is too large Load Diff
+562
View File
@@ -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<Vec<u8>>,
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<Datagram, TransportError>;
fn port_id(&self) -> u32;
}
struct LinuxIo {
socket: OwnedFd,
port_id: u32,
origin: Instant,
}
impl LinuxIo {
fn new() -> Result<Self, TransportError> {
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<Datagram, TransportError> {
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<RuntimeObservation, TransportError> {
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<RuntimeObservation, TransportError> {
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<Datagram, TransportError> {
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<u16, TransportError> {
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<u16, TransportError> {
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<u8> {
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<u8> {
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<u8>, 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<u16, TransportError> {
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<u32, TransportError> {
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<u8> {
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<u8> {
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<u8>)>,
incoming: std::collections::VecDeque<Datagram>,
}
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<Datagram, TransportError> {
if let Some(datagram) = self.incoming.pop_front() {
Ok(datagram)
} else {
self.clock += wait;
Err(TransportError::Timeout)
}
}
}
fn datagram(bytes: Vec<u8>, 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));
}
}
+469
View File
@@ -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<String>,
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<String>, 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<String>, 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)
);
}
}
+680
View File
@@ -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<String, RestartFailure>;
fn restart_unit(
&mut self,
unit: &str,
deadline: RestartDeadline,
) -> Result<String, RestartFailure>;
fn next_event(&mut self, deadline: RestartDeadline) -> Result<ServiceEvent, RestartFailure>;
}
#[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<Connection>,
manager: Option<Proxy<'static>>,
job_events: Option<zbus::proxy::SignalStream<'static>>,
owner_events: Option<zbus::MessageStream>,
}
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<String, RestartFailure> {
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::<String>("Id"), deadline)?.map_err(map_unit_resolution_error)
}
fn restart_unit(
&mut self,
unit: &str,
deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
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<ServiceEvent, RestartFailure> {
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>) {
self.0.unpark();
}
fn wake_by_ref(self: &Arc<Self>) {
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<F: Future>(future: F, deadline: RestartDeadline) -> Result<F::Output, RestartFailure> {
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<ServiceEvent>,
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<String, RestartFailure> {
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<String, RestartFailure> {
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<ServiceEvent, RestartFailure> {
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)
);
}
}
}
+59
View File
@@ -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!(
+161
View File
@@ -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());
}