Add runtime observation and systemd restart adapter
This commit is contained in:
Generated
+565
@@ -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
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user