Add runtime observation and systemd restart adapter
This commit is contained in:
Generated
+565
@@ -97,6 +97,149 @@ dependencies = [
|
|||||||
"rustversion",
|
"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]]
|
[[package]]
|
||||||
name = "base16ct"
|
name = "base16ct"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -148,6 +291,25 @@ dependencies = [
|
|||||||
"hybrid-array 0.4.15",
|
"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]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -189,6 +351,15 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
@@ -228,6 +399,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-utils"
|
||||||
|
version = "0.8.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-bigint"
|
name = "crypto-bigint"
|
||||||
version = "0.5.5"
|
version = "0.5.5"
|
||||||
@@ -366,6 +543,33 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -382,6 +586,26 @@ dependencies = [
|
|||||||
"windows-sys",
|
"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]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -516,6 +740,19 @@ version = "0.3.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
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]]
|
[[package]]
|
||||||
name = "futures-macro"
|
name = "futures-macro"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -617,6 +854,18 @@ version = "0.17.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
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]]
|
[[package]]
|
||||||
name = "hkdf"
|
name = "hkdf"
|
||||||
version = "0.12.4"
|
version = "0.12.4"
|
||||||
@@ -795,6 +1044,17 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
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]]
|
[[package]]
|
||||||
name = "keccak"
|
name = "keccak"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
@@ -853,6 +1113,15 @@ version = "2.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memoffset"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
@@ -908,6 +1177,16 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
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]]
|
[[package]]
|
||||||
name = "p256"
|
name = "p256"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
@@ -918,6 +1197,12 @@ dependencies = [
|
|||||||
"primeorder",
|
"primeorder",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking"
|
||||||
|
version = "2.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
@@ -977,6 +1262,31 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
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]]
|
[[package]]
|
||||||
name = "poly1305"
|
name = "poly1305"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -1024,6 +1334,15 @@ dependencies = [
|
|||||||
"elliptic-curve",
|
"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]]
|
[[package]]
|
||||||
name = "proc-macro-error-attr3"
|
name = "proc-macro-error-attr3"
|
||||||
version = "3.1.1"
|
version = "3.1.1"
|
||||||
@@ -1277,6 +1596,17 @@ dependencies = [
|
|||||||
"syn 3.0.9",
|
"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]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -1318,6 +1648,16 @@ dependencies = [
|
|||||||
"keccak",
|
"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]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -1502,6 +1842,18 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "toml_parser"
|
name = "toml_parser"
|
||||||
version = "1.1.5+spec-1.1.0"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
|
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]]
|
[[package]]
|
||||||
name = "type-map"
|
name = "type-map"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -1532,6 +1915,17 @@ version = "1.20.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
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]]
|
[[package]]
|
||||||
name = "unic-langid"
|
name = "unic-langid"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
@@ -1573,6 +1967,17 @@ dependencies = [
|
|||||||
"subtle",
|
"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]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
@@ -1604,6 +2009,51 @@ dependencies = [
|
|||||||
"wit-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "winapi-util"
|
name = "winapi-util"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
@@ -1633,6 +2083,9 @@ name = "winnow"
|
|||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wireguard-admin"
|
name = "wireguard-admin"
|
||||||
@@ -1650,6 +2103,7 @@ dependencies = [
|
|||||||
"time",
|
"time",
|
||||||
"toml 1.1.8+spec-1.1.0",
|
"toml 1.1.8+spec-1.1.0",
|
||||||
"x25519-dalek",
|
"x25519-dalek",
|
||||||
|
"zbus",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1671,6 +2125,76 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.63"
|
version = "0.8.63"
|
||||||
@@ -1726,3 +2250,44 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"zerofrom",
|
"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"
|
base64 = "=0.22.1"
|
||||||
getrandom = "=0.3.4"
|
getrandom = "=0.3.4"
|
||||||
ipnet = "=2.11.0"
|
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"] }
|
serde = { version = "=1.0.229", features = ["derive"] }
|
||||||
thiserror = "=2.0.20"
|
thiserror = "=2.0.20"
|
||||||
tar = { version = "=0.4.44", default-features = false }
|
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"
|
toml = "=1.1.8"
|
||||||
x25519-dalek = { version = "=2.0.1", default-features = false, features = ["static_secrets", "zeroize"] }
|
x25519-dalek = { version = "=2.0.1", default-features = false, features = ["static_secrets", "zeroize"] }
|
||||||
zeroize = "=1.9.0"
|
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 |
|
| 2 | Server configuration editing with byte preservation | 1 | accepted |
|
||||||
| 3 | Client keys, encrypted vault, and existing-client import | 2 | accepted |
|
| 3 | Client keys, encrypted vault, and existing-client import | 2 | accepted |
|
||||||
| 4 | Backups, safe Commit, and full restoration | 3 | 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 |
|
| 6 | Client configuration delivery and reissue lifecycle | 5 | pending |
|
||||||
| 7 | Complete terminal management workflow | 6 | pending |
|
| 7 | Complete terminal management workflow | 6 | pending |
|
||||||
| 8 | Alpine Testcontainers integration | 7; suitable disposable Docker environment | 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
|
### 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
|
### 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
|
## 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).
|
- 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 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 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 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.
|
- 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.
|
- 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–M4 integrated gates and independent reviews passed. No Alpine or VM results are claimed.
|
- M1–M5 integrated gates and independent reviews passed. No Alpine or VM results are claimed.
|
||||||
|
|
||||||
## Plan maintenance
|
## Plan maintenance
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
# wireguard-admin
|
# wireguard-admin
|
||||||
|
|
||||||
This repository contains the Rust package, startup boundary, validated settings
|
This repository contains the Rust package, startup boundary, validated settings
|
||||||
loader, client domain model, a byte-preserving in-memory WireGuard configuration
|
loader, client domain model, a byte-preserving WireGuard configuration editor,
|
||||||
editor, and library APIs for encrypted client vaults and read-only client import.
|
encrypted client vault and read-only import APIs, recoverable saved-file
|
||||||
The library also provides saved-file persistence with recovery, private backups,
|
persistence, private backups, Commit, and previewed full restore. The library
|
||||||
prepared Commit, and previewed full restore. The terminal management interface
|
also provides read-only WireGuard runtime observation, technical peer comparison,
|
||||||
and WireGuard/systemd runtime operations have not been implemented yet.
|
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 package requires Rust 1.97.1 or newer and uses the Rust 2024 edition. From
|
||||||
the repository root, run:
|
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
|
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
|
its root check. Runtime and restart library behavior is covered with synthetic
|
||||||
yet connected to a management session or runtime VPN adapter.
|
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
|
## 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.
|
contain fixed logical members, not source client files or prior archives.
|
||||||
|
|
||||||
The persistence journal recovers interrupted file replacement on reopen and
|
The persistence journal recovers interrupted file replacement on reopen and
|
||||||
returns a recovery error while earlier work remains unresolved. Restoring a
|
returns a recovery error while earlier work remains unresolved. A confirmed
|
||||||
backup does not restore runtime state: the running interface is unknown until a
|
restart marks the saved generation applied only after a successful service job,
|
||||||
future explicit runtime verification. Pending deleted-client addresses remain
|
a matching complete runtime observation, and journaled verification-state write.
|
||||||
reserved through Commit, reopen, and restore. See
|
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/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
|
## 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 |
|
| QR encoding | `qrcode` | Additional terminal delivery of client configurations |
|
||||||
| Backup archives | `tar` | Archives with manifests; initially without compression |
|
| Backup archives | `tar` | Archives with manifests; initially without compression |
|
||||||
| Temporary files | `tempfile` | Staged file replacement and isolated test directories |
|
| 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 |
|
| 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 |
|
| WireGuard key pairs | `x25519-dalek` | Key generation and public/private key matching |
|
||||||
| Secure randomness | `getrandom` | Operating-system randomness for key generation |
|
| Secure randomness | `getrandom` | Operating-system randomness for key generation |
|
||||||
| WireGuard key encoding | `base64` | Reading and writing WireGuard key representations |
|
| WireGuard key encoding | `base64` | Reading and writing WireGuard key representations |
|
||||||
| Secret handling | `secrecy`, `zeroize` | Explicit secret exposure and supported memory cleanup |
|
| Secret handling | `secrecy`, `zeroize` | Explicit secret exposure and supported memory cleanup |
|
||||||
| WireGuard runtime | `wireguard-uapi` | Direct Linux interface, peer, handshake, and traffic queries |
|
| 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 |
|
| 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.
|
- 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.
|
- 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.
|
- 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.
|
- `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
|
and is not automatic rollback after a failed runtime restart. The separate
|
||||||
pre-operation archive remains the recovery source if local journal data is
|
pre-operation archive remains the recovery source if local journal data is
|
||||||
unusable; the adapter does not silently discard an invalid journal.
|
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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
/// 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,
|
&mut self,
|
||||||
observed: Snapshot,
|
observed: Snapshot,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ pub mod backup;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod domain;
|
pub mod domain;
|
||||||
pub mod persistence;
|
pub mod persistence;
|
||||||
|
pub mod runtime;
|
||||||
|
pub mod service;
|
||||||
pub mod startup;
|
pub mod startup;
|
||||||
pub mod vault;
|
pub mod vault;
|
||||||
pub mod wireguard;
|
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
|
&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.
|
/// 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.
|
/// This narrow primitive supports later edit code that owns and replaces exact spans.
|
||||||
pub fn source_slice(&self, span: SourceSpan) -> Option<&str> {
|
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]
|
#[test]
|
||||||
fn wireguard_peer_inspection_preserves_multiple_routes_and_unknown_directives() {
|
fn wireguard_peer_inspection_preserves_multiple_routes_and_unknown_directives() {
|
||||||
let input = format!(
|
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