Complete terminal management workflow
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
These instructions apply to the entire repository. Keep documentation, code comments, and identifiers in English; use the user's language in conversation.
|
||||
|
||||
The project contains a Rust package with startup, settings validation, a pure client domain model, a byte-preserving WireGuard configuration editor, encrypted vault and read-only import APIs, saved-file persistence with recovery, backups, Commit, and previewed restore. The management interface and VPN runtime operations are not implemented yet. Do not report planned modules, tests, or commands as already working.
|
||||
The project contains a Rust package with startup, settings validation, a pure client domain model, a byte-preserving WireGuard configuration editor, encrypted vault and read-only import APIs, saved-file persistence with recovery, backups, Commit, previewed restore, a runtime observation/restart adapter, and a terminal management interface under Milestone 7 review. Real WireGuard and systemd integration remain for Milestones 8–9. Do not report planned modules, tests, or commands as already working.
|
||||
|
||||
- Read [SPEC.md](docs/SPEC.md) and [CONTEXT.md](CONTEXT.md) before implementing behavior. The approved specification is the product contract; do not change it to make tests pass.
|
||||
- For milestone execution, read and maintain [PLANS.md](PLANS.md), following [IMPLEMENTATION.md](docs/IMPLEMENTATION.md). Execute numbered milestones sequentially through implementation, independent review, repair, and acceptance with evidence.
|
||||
|
||||
Generated
+493
-9
@@ -88,6 +88,21 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "approx"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.3"
|
||||
@@ -310,6 +325,21 @@ version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "by_address"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
@@ -351,6 +381,20 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
|
||||
dependencies = [
|
||||
"castaway",
|
||||
"cfg-if",
|
||||
"itoa",
|
||||
"rustversion",
|
||||
"ryu",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -372,6 +416,15 @@ version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie-factory"
|
||||
version = "0.3.3"
|
||||
@@ -405,6 +458,33 @@ version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crossterm_winapi",
|
||||
"derive_more",
|
||||
"document-features",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm_winapi"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
@@ -472,6 +552,40 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -491,6 +605,28 @@ dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -524,6 +660,21 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
@@ -692,6 +843,12 @@ dependencies = [
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.34"
|
||||
@@ -853,6 +1010,17 @@ name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
@@ -988,6 +1156,12 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
@@ -998,6 +1172,15 @@ dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
@@ -1007,6 +1190,19 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"indoc",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "intl-memoizer"
|
||||
version = "0.5.3"
|
||||
@@ -1038,6 +1234,15 @@ version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -1055,6 +1260,17 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kasuari"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209"
|
||||
dependencies = [
|
||||
"hashbrown",
|
||||
"portable-atomic",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.1.6"
|
||||
@@ -1086,12 +1302,33 @@ version = "0.2.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "line-clipping"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -1107,6 +1344,15 @@ version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
|
||||
dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -1138,6 +1384,18 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ml-kem"
|
||||
version = "0.2.3"
|
||||
@@ -1161,9 +1419,27 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
@@ -1197,6 +1473,39 @@ dependencies = [
|
||||
"primeorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "palette"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"libm",
|
||||
"palette_derive",
|
||||
"palette_math",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "palette_derive"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be"
|
||||
dependencies = [
|
||||
"by_address",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "palette_math"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@@ -1310,6 +1619,12 @@ dependencies = [
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.1"
|
||||
@@ -1425,6 +1740,72 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.30.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d"
|
||||
dependencies = [
|
||||
"instability",
|
||||
"ratatui-core",
|
||||
"ratatui-crossterm",
|
||||
"ratatui-widgets",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-core"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"compact_str",
|
||||
"hashbrown",
|
||||
"itertools",
|
||||
"kasuari",
|
||||
"lru",
|
||||
"palette",
|
||||
"serde",
|
||||
"strum",
|
||||
"thiserror 2.0.20",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-crossterm"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossterm",
|
||||
"instability",
|
||||
"ratatui-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-widgets"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indoc",
|
||||
"instability",
|
||||
"itertools",
|
||||
"line-clipping",
|
||||
"ratatui-core",
|
||||
"serde",
|
||||
"strum",
|
||||
"time",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -1503,6 +1884,12 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "salsa20"
|
||||
version = "0.10.2"
|
||||
@@ -1654,6 +2041,27 @@ dependencies = [
|
||||
"keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-mio"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
@@ -1676,12 +2084,39 @@ version = "1.16.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -1775,30 +2210,32 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.44"
|
||||
version = "0.3.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
|
||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"libc",
|
||||
"num-conv",
|
||||
"num_threads",
|
||||
"powerfmt",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.6"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.24"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
|
||||
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
@@ -1963,6 +2400,29 @@ version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-truncate"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
|
||||
dependencies = [
|
||||
"itertools",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
@@ -2060,6 +2520,22 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -2069,6 +2545,12 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
@@ -2099,9 +2581,11 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"age",
|
||||
"base64",
|
||||
"crossterm",
|
||||
"getrandom 0.3.4",
|
||||
"ipnet",
|
||||
"qrcode",
|
||||
"ratatui",
|
||||
"rustix",
|
||||
"serde",
|
||||
"tar",
|
||||
|
||||
+3
-1
@@ -16,15 +16,17 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
age = { version = "=0.12.1", default-features = false }
|
||||
base64 = "=0.22.1"
|
||||
crossterm = { version = "=0.29.0", default-features = false, features = ["events"] }
|
||||
getrandom = "=0.3.4"
|
||||
ipnet = "=2.11.0"
|
||||
qrcode = { version = "=0.14.1", default-features = false }
|
||||
ratatui = { version = "=0.30.2", default-features = false, features = ["crossterm_0_29"] }
|
||||
rustix = { version = "=1.1.5", default-features = false, features = ["event", "fs", "net", "process", "std"] }
|
||||
serde = { version = "=1.0.229", features = ["derive"] }
|
||||
thiserror = "=2.0.20"
|
||||
tar = { version = "=0.4.44", default-features = false }
|
||||
tempfile = "=3.23.0"
|
||||
time = { version = "=0.3.44", default-features = false, features = ["formatting", "parsing", "std"] }
|
||||
time = { version = "=0.3.47", default-features = false, features = ["formatting", "parsing", "std"] }
|
||||
toml = "=1.1.8"
|
||||
x25519-dalek = { version = "=2.0.1", default-features = false, features = ["static_secrets", "zeroize"] }
|
||||
zeroize = "=1.9.0"
|
||||
|
||||
@@ -114,9 +114,9 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo
|
||||
| 2 | Server configuration editing with byte preservation | 1 | accepted |
|
||||
| 3 | Client keys, encrypted vault, and existing-client import | 2 | accepted |
|
||||
| 4 | Backups, safe Commit, and full restoration | 3 | accepted |
|
||||
| 5 | Runtime observation and systemd restart adapter | 4 | blocked |
|
||||
| 6 | Client configuration delivery and reissue lifecycle | 5 | pending |
|
||||
| 7 | Complete terminal management workflow | 6 | pending |
|
||||
| 5 | Runtime observation and systemd restart adapter | 4 | accepted |
|
||||
| 6 | Client configuration delivery and reissue lifecycle | 5 | accepted |
|
||||
| 7 | Complete terminal management workflow | 6 | accepted |
|
||||
| 8 | Alpine Testcontainers integration | 7; suitable disposable Docker environment | pending |
|
||||
| 9 | Real systemd acceptance and release readiness | 8; disposable systemd VM | pending |
|
||||
|
||||
@@ -2221,9 +2221,405 @@ Stop after M6; do not start M7 without a separate request.
|
||||
|
||||
### Milestone 7 — Complete terminal management workflow
|
||||
|
||||
Connect tested application operations to Ratatui/Crossterm screens: list and statistics, forms, anomalies/import, draft/diff, Commit, restart, output/QR, and backup selection/restore. Keep expensive work off the render/input loop and maintain public-key-based selection.
|
||||
Status: accepted (2026-10-21T23:23:41+03:00; independent R2 closed the finding and coordinator gates passed for synthetic UI/PTY scope).
|
||||
Prerequisites: milestones 1–6 accepted; verified coordinator/worker model roles,
|
||||
pinned native Rust environment and registry access or cached compatible approved
|
||||
`ratatui`/`crossterm` dependencies. Default validation runs without root, host
|
||||
WireGuard, host D-Bus or production files. Terminal lifecycle evidence requires
|
||||
a disposable pseudo-terminal or an isolated interactive terminal with synthetic
|
||||
adapters. Missing terminal evidence is an unmet M7 gate, not a passing test.
|
||||
|
||||
Acceptance: keyboard workflows and cancellation preserve the required states, statistics refresh without claiming online status, destructive actions identify targets, passwords are masked, and terminal cleanup/resize/non-TTY/error behavior is verified. No implicit apply or save on exit.
|
||||
#### Outcome and scope
|
||||
|
||||
Replace the management-startup placeholder with a usable native Rust TUI around
|
||||
M1–M6 application operations. The administrator can inspect clients/anomalies and
|
||||
current statistics, initialize the vault/import existing files, prepare creation,
|
||||
rename and deletion drafts, review and Commit, explicitly restart, deliver a file
|
||||
and optional QR, create/list backups, preview/confirm restoration, and exit with
|
||||
the specified draft warning. The UI keeps draft, saved and applied state distinct.
|
||||
|
||||
Read SPEC.md in full, CONTEXT.md, ADRs 0001–0003, IMPLEMENTATION.md,
|
||||
PROJECT_LAYOUT.md, DEVELOPMENT.md (especially terminal behavior), DEPENDENCIES.md,
|
||||
TESTING.md, ENVIRONMENT.md, CONFIG_FORMAT.md, VAULT_FORMAT.md, PERSISTENCE.md,
|
||||
BACKUP_FORMAT.md, RUNTIME.md and DELIVERY.md before editing their respective
|
||||
boundaries. Preserve their versioned formats and accepted M1–M6 contracts.
|
||||
|
||||
Out of scope: new VPN settings, IP/key editing operations, server bootstrap,
|
||||
remote/multiple-interface management, action/service logs, traffic history,
|
||||
automatic save/apply/rollback, concurrent-editor reconciliation, container/VM
|
||||
harness implementation and production testing. Real kernel and systemd evidence
|
||||
remain mandatory later at M8/M9; M7 acceptance covers UI and synthetic integration.
|
||||
Do not redesign accepted library behavior in key handlers.
|
||||
|
||||
#### Context and implementation steps
|
||||
|
||||
Actual preparation state and integration constraints:
|
||||
|
||||
- `src/main.rs` and `src/startup.rs` still return `ManagementUnavailable` for
|
||||
root management; help/version and lazy root checking already work. `src/lib.rs`
|
||||
exports the application modules; no `src/tui.rs` or TUI dependencies exist yet.
|
||||
- `Application` in `src/app.rs` owns document/domain/vault drafts and session-only
|
||||
first-issue material. `SavedStore::read_saved` performs journal recovery;
|
||||
`Application::open` accepts caller-provided `ClientStates` and marks runtime
|
||||
unknown. Introduce a narrow, tested application loading/projection boundary
|
||||
where needed so startup derives states from authoritative document bytes,
|
||||
rather than rebuilding client rules in the binary or supplying an empty set.
|
||||
- The narrow `Snapshot` cannot represent every inspected peer. List document
|
||||
anomalies and unsupported/ambiguous entries explicitly without silently
|
||||
dropping them. Disable actions requiring an unambiguous public key/supported
|
||||
projection; do not choose a duplicate alias or truncate routes to enable them.
|
||||
- Existing operations include `prepare_new_client`, `rename_client`,
|
||||
`delete_client`, `discard_draft`, `commit`, initial vault/setup preparation,
|
||||
backup list/create, restore preview/confirmation, restart preview/choice,
|
||||
first-file delivery and password-per-action `reissue_client_file`. Reuse these
|
||||
boundaries. Add only missing application-facing projections/adapters, with
|
||||
focused tests; ordinary UI state never contains server bytes or private keys.
|
||||
- `vault::source` provides bounded configured-directory scanning and setup;
|
||||
`vault::import` provides reconciliation/key verification. Explicit selected-file
|
||||
import needs a bounded adapter/application entry point if the existing API
|
||||
cannot express it. Keep originals untouched, imported secrets encrypted in
|
||||
draft, anomalies visible and conflicting reissue blocked. Do not invent an
|
||||
automatic conflict repair or an unlock-by-clearing-a-flag UI.
|
||||
- Runtime transport and restart adapters are synchronous bounded operations;
|
||||
terminal code must schedule them off the input/render loop. A read-only refresh
|
||||
may report technical correspondence, but must not mark applied, persist a
|
||||
verification timestamp, release addresses or suppress M6 activation warnings.
|
||||
- `Application::confirm_restore` replaces saved settings and session state.
|
||||
Refresh every settings-dependent view/operation from the restored generation;
|
||||
discard stale previews, output confirmations and queued statistics. Preserve
|
||||
fixed installation paths/interface constraints and M6 invalidation semantics.
|
||||
|
||||
One implementation writer at a time; coordinator owns PLANS.md. Establish the
|
||||
application/worker interface before terminal wiring. Add modules only as needed.
|
||||
|
||||
| Task | Owner | Files and deliverable |
|
||||
| --- | --- | --- |
|
||||
| M7.1 | implementer | `src/app.rs`, `src/startup.rs`, relevant config/persistence/vault adapters and tests: safe session loading, public display projections and missing selected-file import/application boundaries |
|
||||
| M7.2 | implementer | `src/tui.rs`, `src/tui/state.rs`, `src/tui/events.rs` as needed: secret-free screen/navigation state, typed commands/results, serialized application worker and bounded statistics scheduling |
|
||||
| M7.3 | implementer | `Cargo.toml`, `Cargo.lock`, `src/main.rs`, `src/lib.rs`, `src/startup.rs`, `src/tui/terminal.rs`, `src/tui/render.rs`: approved terminal dependencies, interactive startup, guarded lifecycle, responsive layout and client/statistics screens |
|
||||
| M7.4 | implementer | TUI forms/dialogs and narrow application additions: vault setup/import, creation/manual addresses, aliases/deletion, draft review/Commit, explicit restart and cancellation flows |
|
||||
| M7.5 | implementer | TUI delivery/backup screens: masked authorization, destination/overwrite/retry, file plus optional QR, manual backups, archive preview and confirmed restore |
|
||||
| M7.6 | implementer | `tests/tui_workflow.rs`, focused TUI/terminal tests, `README.md`, affected supporting docs: synthetic end-to-end workflows, terminal evidence and accurate operating instructions |
|
||||
| M7.7 | reviewer, then repairer | Independent integrated review of M7 scope and regressions; bounded finding repairs and fresh re-review evidence |
|
||||
| M7.8 | coordinator | Integrated gates, evidence ledger, scoped acceptance and M8 handoff in PLANS.md |
|
||||
|
||||
M7.1 loads executable-relative settings only after management privilege checks,
|
||||
uses the safe saved-store/recovery path and derives the initial client view from
|
||||
the server document. Do not silently replace corrupt state/vault with empty data.
|
||||
Initial vault creation asks for a masked password and confirmation, checks existing
|
||||
files at the earliest opportunity, presents reconciliation results and stages
|
||||
changes through Commit. Cancellation does not install a vault or rewrite source
|
||||
files. Explicit import and managed metadata additions remain draft operations;
|
||||
unknown creation dates remain unknown. Temporary fallback labels are filenames
|
||||
or shortened public keys, never a new alias registry.
|
||||
|
||||
M7.2 keeps one owner of mutable application/session state. Use standard-library
|
||||
threads/channels where sufficient; serialize mutations and reject duplicate
|
||||
submission while busy. Specify bounded/coalesced event queues, refresh interval,
|
||||
read timeout, stale-result generation checks and worker shutdown. Do not allow
|
||||
old results to overwrite a newer Commit/restore/restart view or recreate delivery
|
||||
authorization. Progress/error/cancel navigation remains responsive while work is
|
||||
pending. Cancellation of a dialog is distinct from aborting submitted work: do
|
||||
not report an operation cancelled while it may still save files or restart.
|
||||
Exit during submitted work must wait for an honest outcome with progress; no
|
||||
unbounded join or silent detachment of protected mutations. An already submitted
|
||||
systemd job can outlive its timeout; show the existing report's uncertainty.
|
||||
|
||||
M7.3 checks terminal suitability before raw/alternate-screen entry and before
|
||||
saved-store recovery or setup mutations. Help/version still require neither root,
|
||||
settings, terminal nor vault. Non-TTY/unsupported startup yields plain stderr and
|
||||
a nonzero result without escape sequences. A scoped terminal guard restores raw
|
||||
mode, alternate screen and cursor after normal exit, partial initialization,
|
||||
render/input errors and handled interruption, including best-effort panic cleanup.
|
||||
Avoid secret-bearing panic diagnostics and competing writes to the active screen.
|
||||
|
||||
List alias/fallback label, shortened key, IPv4/IPv6, draft/saved differences,
|
||||
unapplied/unknown runtime status and anomalies using text as well as color. Keep
|
||||
selection by full public key across refresh/sort and show that key in client
|
||||
confirmations. Malformed records may use document location for inspection, never
|
||||
as an alternate ambiguous mutation identity. Show last-handshake time and current
|
||||
interface RX/TX counters; distinguish never, zero and unavailable/stale readings.
|
||||
Missing runtime is nonfatal. Support keyboard-only use, visible focus, help,
|
||||
scrolling, empty/error states and resizing to a terminal too small for content.
|
||||
Escape all untrusted text, including imported filenames, aliases and errors.
|
||||
|
||||
M7.4 offers dual-stack default/manual allocation and reports validation/exhaustion
|
||||
without losing prior draft. Renames may repeat. Deletion warns that saved removal
|
||||
can leave runtime access active and addresses reserved. A semantic draft preview
|
||||
shows client changes without raw configuration diffs (which could expose secrets).
|
||||
Commit has no runtime calls. With a draft, restart offers exactly “Discard draft
|
||||
and restart” or “Cancel”; cancellation preserves draft and first-issue eligibility.
|
||||
Confirmed restart uses the application's preview and full backup/job/verification
|
||||
sequence. Exit warns that draft will be discarded, offers cancellation and never
|
||||
implicitly commits. Failed backup, Commit, restart or recovery displays the actual
|
||||
stage/result and leaves state consistent with the existing application contract.
|
||||
|
||||
M7.5 permits first delivery only after Commit in the same creation session;
|
||||
reissue requests a fresh password for the selected client every time. Passwords
|
||||
and QR matrices have short-lived dedicated owners, outside ordinary view/debug/
|
||||
snapshot state. Cancel/close clears them; do not retain an unlocked vault. The
|
||||
selected destination and explicit overwrite confirmation use M6 capabilities,
|
||||
not alias-derived paths. Show retry, publication uncertainty, manual-cleanup and
|
||||
file-success/QR-failure outcomes accurately. Changing the directory can retry a
|
||||
failed eligible first file. QR is additional to successful mandatory file output;
|
||||
render its quiet zone and modules without clipping, show a resize/file fallback
|
||||
when it cannot fit, and clear the disclosure screen on close. Show the M6
|
||||
activation warning without inferring activation from historical verification.
|
||||
|
||||
Backup rows show archive identity, UTC creation time, reason and historical
|
||||
verified status without labelling the newest archive as working. Restoration
|
||||
previews clients/settings changes safely, identifies the exact archive, warns of
|
||||
full saved-state replacement and draft loss, and requires confirmation. Cancel
|
||||
has no replacement/precautionary-backup side effects. Confirmed restore needs no
|
||||
password, makes its precautionary backup and never restarts; refresh the session
|
||||
view and report saved/unapplied state. No archive cleanup or merge is added.
|
||||
|
||||
#### Acceptance and validation
|
||||
|
||||
- [x] M7-A1: production management startup reaches the TUI using executable-relative
|
||||
settings and safe recovery; injected non-root/non-TTY/invalid startup checks
|
||||
prove early failure. Help/version remain independent of privileged data.
|
||||
- [x] M7-A2: keyboard list/details/forms, focus/help, scrolling, small/empty/error
|
||||
screens and resize are usable. Duplicate aliases and refresh cannot retarget
|
||||
actions. All authoritative peers/anomalies remain visible, including unsupported
|
||||
entries; untrusted terminal controls are escaped.
|
||||
- [x] M7-A3: synthetic setup/import covers server-only, file-only, matching,
|
||||
mismatched and invalid files, preserves original bytes, encrypts valid secrets,
|
||||
blocks conflicting reissue and stages metadata without fabricating dates.
|
||||
- [x] M7-A4: create/rename/delete, semantic preview, cancel/discard and Commit
|
||||
preserve draft/saved/applied boundaries. Allocation includes manual/invalid/
|
||||
exhausted cases. Backup failure stops protected mutation; Commit/restore invoke
|
||||
no service/runtime mutation even through the UI dispatch path.
|
||||
- [x] M7-A5: restart with draft exposes the specified two choices; Cancel preserves
|
||||
draft. Fake service/runtime outcomes prove successful verification releases
|
||||
reservations, while job failure, timeout, missing/mismatching peers or failed
|
||||
verification persistence do not. No implicit retry or rollback occurs.
|
||||
- [x] M7-A6: first-file success/retry/change-directory, overwrite refusal/approval,
|
||||
uncertain publication, QR failure after file success, reissue/password errors,
|
||||
session exit and restore invalidation follow M6. No original-file password
|
||||
bypass, secret snapshot or QR-only disclosure path is introduced.
|
||||
- [x] M7-A7: backup list/manual backup, exact-target preview/cancel/restore,
|
||||
precautionary-backup failure and restored operational settings are covered.
|
||||
Restoration fully replaces saved state without password or implicit restart.
|
||||
- [x] M7-A8: delayed/failing fake workers demonstrate responsive input, duplicate
|
||||
prevention, bounded/coalesced refresh and stale-result rejection. Statistics
|
||||
never imply online status or advance applied state; exit/shutdown is bounded
|
||||
and reports submitted-operation uncertainty accurately.
|
||||
- [x] M7-A9: synthetic terminal lifecycle checks demonstrate cleanup on normal
|
||||
exit, partial setup failure, render/input error, handled interruption and panic
|
||||
where feasible. Record exact PTY/manual procedure and outcomes; default tests
|
||||
never acquire production adapters or require root. Explicit QR display retains
|
||||
a complete quiet zone, is not clipped and clears on dismissal.
|
||||
- [x] M7-A10: independent reviewer has no unresolved actionable findings; all
|
||||
integrated Rust gates pass and documentation describes actual workflows and
|
||||
remaining M8/M9 limits. No real-system result is claimed from UI fixtures.
|
||||
|
||||
Planned commands (repository root; pinned environment verified first):
|
||||
`rustc -Vv`, `cargo -V`, `rustfmt --version`, `cargo clippy --version`;
|
||||
`cargo test --locked --test tui_workflow` after M7.6 creates that target;
|
||||
`cargo test --locked --test client_delivery`,
|
||||
`cargo test --locked --test runtime_restart`,
|
||||
`cargo test --locked --test commit_restore`,
|
||||
`cargo test --locked --test vault_import`,
|
||||
`cargo test --locked --test config_preservation`;
|
||||
`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`, and `git diff --check`.
|
||||
Expected: successful checks, default tests without host access, accurate help and
|
||||
no secret output. Select compatible minimal TUI dependency versions from official
|
||||
crate documentation at execution time; retain Cargo.lock, avoid unrelated updates.
|
||||
Record focused terminal test names and the exact isolated smoke procedure once
|
||||
implemented; never launch root management against the development host for proof.
|
||||
Phone/decoder QR interoperability must be recorded separately if demonstrated;
|
||||
no scan success is presumed from matrix/render tests or required via production.
|
||||
|
||||
#### Progress
|
||||
|
||||
- [x] M7.1 — implementer `/root/m7_1`: safe session bootstrap, secret-free all-peer projection and bounded selected-file import integrated; review remains at M7.7.
|
||||
- [x] M7.2 — implementer `/root/m7_1`: secret-free state/events and serialized application worker integrated; final independent review remains at M7.7.
|
||||
- [x] M7.3 — implementer `/root/m7_1`: terminal adapter, startup and list/statistics rendering integrated; synthetic PTY and independent review subsequently passed.
|
||||
- [x] M7.4 — implementer `/root/m7_1`: setup/import, create/rename/delete, draft/Commit and explicit restart forms integrated.
|
||||
- [x] M7.5 — implementer `/root/m7_1`: delivery, backups and previewed restore workflows integrated.
|
||||
- [x] M7.6 — implementer `/root/m7_1`: synthetic workflow/terminal evidence and documentation integrated.
|
||||
- [x] M7.7 — independent R1 review, targeted repair by `/root/m7_repair_r1`, and R2 re-review closed M7-R1-01; no actionable findings remain.
|
||||
- [x] M7.8 — coordinator reran all planned integrated gates, recorded scoped evidence and accepted M7-A1–A10.
|
||||
|
||||
#### Review and repair
|
||||
|
||||
| Finding | Iteration | Location and impact | Owner | Resolution and evidence | State |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| M7-R1-01 (P2) | R1/R2 | `src/tui/render.rs` client list omitted per-row draft/saved change and anomaly text required by SPEC/M7-A2. | `/root/m7_repair_r1` | Added row-leading state/anomaly markers, draft-removal rows, 34-column TestBackend and control-escaping assertions; independent R2 confirmed closure. | closed |
|
||||
|
||||
R1 separately noted coverage limits for M7-A5 confirmed restart through the UI
|
||||
executor and M7-A9 terminal failure/interruption cases. The reported M7-A4
|
||||
delete/discard coverage gap is disputed by `tests/tui_workflow.rs` worker-path
|
||||
assertions for `DeleteClient` and `DiscardDraft`; ask R2 to verify those lines.
|
||||
|
||||
Repairer reported M7-R1-01 fixed and added synthetic PTY handled Ctrl-C cleanup
|
||||
evidence. Follow-up added five isolated PTY child cases: failure after raw-mode
|
||||
setup, failure after alternate-screen entry, render I/O error, input I/O error
|
||||
and active-mode panic. Assertions check raw mode off, matching restoration
|
||||
sequences and a generic panic diagnostic without payload. Focused lifecycle
|
||||
PTY test, focused TUI tests (25), workflow tests (6), fmt, Clippy and diff checks
|
||||
passed in the repairer run; `docs/TESTING.md` records procedure and limits.
|
||||
Independent R2 review and coordinator rerun remain pending. M7-A5 UI executor
|
||||
fake-outcome coverage remains a separate acceptance question.
|
||||
|
||||
#### Evidence and handoff
|
||||
|
||||
2026-10-21 execution preparation: confirmed M1–M6 accepted in their milestone
|
||||
records and register; preserved the pre-existing M7 expansion in this worktree.
|
||||
Matched `CODEX_SESSION_ID` to this session's local rollout record and verified
|
||||
its latest `turn_context` is `gpt-6-sol`/`medium`; all configured worker roles are
|
||||
`gpt-6-luna`/`high`, and M7.1 was dispatched through the harness with those exact
|
||||
overrides. Native rustc/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 matched
|
||||
the pin. Inspected the actual source/startup/application/store/import interfaces.
|
||||
No M7 acceptance gate is claimed yet.
|
||||
|
||||
M7.1 implementation changed `src/app.rs`, `src/startup.rs` and
|
||||
`src/vault/source.rs`; no dependency or versioned format changed. The worker
|
||||
reported `cargo test --locked` (209 library unit plus integration/doc suites),
|
||||
Clippy, fmt and diff checks passing. Coordinator inspected the changed-file list
|
||||
and public interfaces, ran `git diff --check` (pass), and independently ran
|
||||
`cargo test --locked --lib selected_file` (4 passed). M7.1 supplies
|
||||
`open_from_saved_store`, `display_projection`, `open_management_session` and
|
||||
`import_selected_file`. Independent integrated review is still pending.
|
||||
|
||||
M7.2 implementation added `src/tui.rs`, `src/tui/state.rs`,
|
||||
`src/tui/events.rs` and a library export. The worker reported focused
|
||||
`cargo test --locked tui::` (9 passed), fmt, Clippy and diff checks passing.
|
||||
Coordinator inspection found a blocking `send` in the first shutdown fallback;
|
||||
the implementer replaced it with an atomic stop request and nonblocking signal,
|
||||
and added a full-queue/delayed-read regression test. This repair was checked
|
||||
before progressing. The operation protocol currently covers draft mutation,
|
||||
Commit, backups and selected import; later tasks extend it for restart, restore
|
||||
and delivery. The shutdown handle must remain live while it reports `Pending`.
|
||||
|
||||
M7.3 implementation added the guarded Crossterm/Ratatui terminal path,
|
||||
keyboard list/details/help and bounded read-only runtime sampling. Approved
|
||||
direct dependencies `ratatui` 0.30.2 and `crossterm` 0.29.0 were added; their
|
||||
resolution required advancing the already approved `time` pin from 0.3.44 to
|
||||
0.3.47. The worker reported `cargo test --locked tui::` (15 passed), fmt,
|
||||
Clippy and diff checks passing, plus successful help/version smoke checks.
|
||||
Coordinator reran focused TUI tests (13 passed before the final lifecycle
|
||||
repair) and inspected startup, dependency and terminal changes. Inspection
|
||||
found terminal I/O error paths could drop an active worker and a panic hook was
|
||||
left installed; the implementer repaired both and added synthetic tests for
|
||||
delayed operation reporting and hook restoration. Actual PTY lifecycle evidence
|
||||
and independent integrated review remain required.
|
||||
|
||||
M7.4 implementation added `src/tui/forms.rs` and extended terminal,
|
||||
render/state/events, with a narrow draft-only peer selection fix in `src/app.rs`.
|
||||
The worker reported `cargo test --locked tui::` (18 passed), fmt, Clippy and
|
||||
diff checks passing. Coordinator reran the focused TUI tests (18 passed) and
|
||||
inspected the secret owner, forms and command dispatch. Current focused tests
|
||||
cover masked setup input, manual-address validation, full-key rename
|
||||
confirmation and draft-restart choice; M7.6 must add broader synthetic behavior
|
||||
tests for Commit/restart failure and cancellation paths before acceptance.
|
||||
|
||||
M7.5 implementation extended worker-owned prepared destinations and validated
|
||||
restore previews, password-per-action reissue, file-plus-optional-QR disclosure,
|
||||
backup browsing and exact-archive restoration. The worker reported
|
||||
`cargo test --locked tui::` (22 passed), fmt, Clippy and diff checks passing;
|
||||
coordinator reran focused TUI tests (22 passed) and inspected continuation and
|
||||
result handling. Synthetic tests currently cover generation-bound prepared
|
||||
actions, password masking, retry confirmation and QR fit. Actual end-to-end
|
||||
publication/restore failure behavior and terminal disclosure lifecycle remain
|
||||
M7.6 evidence needs; no real-system behavior is claimed.
|
||||
|
||||
M7.6 added `tests/tui_workflow.rs` (6 tests), synthetic terminal injection and
|
||||
restart-result rendering test, and updated README/TESTING/PROJECT_LAYOUT.
|
||||
The worker reported `cargo test --locked --test tui_workflow` (6 passed),
|
||||
focused restart rendering (pass), fmt, Clippy, full `cargo test --locked`
|
||||
(233 unit, 36 integration, 2 doctests), release build and diff check passing.
|
||||
The PTY smoke ran under `/usr/bin/script` with a synthetic application and fake
|
||||
unavailable statistics; it exercised 80×24 → 40×8 → 80×24 resize, q exit and
|
||||
alternate-screen/cursor restoration. No host WireGuard/systemd or production
|
||||
path was accessed. The UI test constructs restart outcomes for display and
|
||||
cancel; actual fake service/runtime outcomes remain application-library
|
||||
evidence. QR file-success/encoder-failure is likewise covered at the M6 app
|
||||
boundary, while M7 proves QR ownership, dismissal and complete fit. No phone
|
||||
or external decoder interoperability is claimed. Coordinator gate rerun and
|
||||
independent review are pending.
|
||||
|
||||
M7.7 R1 was dispatched to a fresh read-only reviewer, separate from the
|
||||
implementer, with all M7-A1–A10 criteria and actual changed/untracked paths.
|
||||
The coordinator updated AGENTS.md's stale introductory implementation-status
|
||||
sentence without changing behavior rules. Review findings are pending.
|
||||
|
||||
R1 reviewer `/root/m7_integrated_review_r1` inspected actual source, untracked
|
||||
TUI files and docs; `cargo test --locked --test tui_workflow` passed 6/6 and
|
||||
`git diff --check` passed. It identified M7-R1-01 above. It did not claim
|
||||
real WireGuard/systemd evidence. Repair and re-review are next.
|
||||
|
||||
R2 independent re-review inspected the renderer repair and synthetic terminal
|
||||
refactor, ran the focused row test, five-case lifecycle PTY test,
|
||||
`cargo test --locked --test tui_workflow` (6/6) and `git diff --check` (all pass),
|
||||
and closed M7-R1-01 with no new actionable finding. R2 withdrew its earlier A4
|
||||
gap after verifying worker `DeleteClient`/`DiscardDraft` assertions. It found
|
||||
the existing application fake service/runtime restart tests plus TUI preview,
|
||||
choice/cancel and result-display tests sufficient for M7-A5's synthetic scope;
|
||||
these do not prove the real systemd adapter.
|
||||
|
||||
Coordinator final integrated validation on the reviewed revision (all exit 0):
|
||||
`rustc -Vv` and `cargo -V` reported 1.97.1; `rustfmt --version` reported
|
||||
1.9.0-stable; `cargo clippy --version` reported 0.1.97.
|
||||
`cargo test --locked --test tui_workflow` passed 6/6, including the synthetic
|
||||
PTY smoke; `cargo test --locked --test client_delivery` 5/5,
|
||||
`runtime_restart` 2/2, `commit_restore` 9/9, `vault_import` 5/5 and
|
||||
`config_preservation` 9/9. `cargo fmt --all -- --check`,
|
||||
`cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked`,
|
||||
`cargo build --locked --release`, release `--help`, release `--version` and
|
||||
`git diff --check` all passed. The coordinator independently reran the focused
|
||||
five-case lifecycle PTY test (1/1), and checked 29 local Markdown links in the
|
||||
changed guidance/plan files with none missing. Help and version printed only
|
||||
their expected public text. Actual changed and untracked source, test, manifest,
|
||||
lockfile and documentation paths were inspected; no unapproved direct crate,
|
||||
production path, live service or Git commit was introduced.
|
||||
|
||||
Acceptance mapping: A1 uses injected root/TTY/settings/recovery tests and the
|
||||
compiled production startup path; A2 uses all-peer projection, rendering,
|
||||
selection and 34-column anomaly/state tests; A3 uses synthetic source
|
||||
reconciliation and M3 vault checks; A4 and A7 use worker workflow plus
|
||||
Commit/restore fixture tests; A5 uses TUI choice/cancel/result tests and M5
|
||||
fake service/runtime cases; A6 uses worker delivery/reissue tests and M6
|
||||
file/QR failure cases; A8 uses delayed worker/queue/generation tests; A9 uses
|
||||
isolated PTY normal/interrupt/resize/failure/panic cases and QR fit/dismissal;
|
||||
A10 uses closed R2 review and the coordinator gates above. All are scoped to
|
||||
synthetic fixtures, fake adapters and isolated PTY behavior. No Alpine real
|
||||
WireGuard, disposable-VM real systemd/D-Bus, external QR decoder or phone scan
|
||||
was performed; those results are not inferred from M7 acceptance.
|
||||
|
||||
Planning only: inspected the approved specification, ADRs, supporting contracts,
|
||||
accepted M1–M6 records and current startup/application/store/import/delivery/runtime
|
||||
interfaces. No M7 code, dependency change, terminal session, Rust gate, Alpine or
|
||||
VM test was executed as part of this expansion. Planning validation passed: `git diff --check`; a local Markdown-link check
|
||||
found no missing PLANS.md targets; record-structure checks confirmed all eight
|
||||
template sections, tasks M7.1–M7.8, criteria M7-A1–M7-A10 and pending status.
|
||||
The milestone register now matches the accepted M5/M6 records. Rust rebuilds
|
||||
are not required for this documentation-only change.
|
||||
|
||||
Historical planning handoff above preceded M7 execution. The next milestone is
|
||||
M8 Alpine Testcontainers integration, which requires a separate execution
|
||||
request and its disposable environment prerequisites. Do not create Git
|
||||
commits, deploy or test on production from this acceptance.
|
||||
|
||||
#### Decisions and discoveries
|
||||
|
||||
M7 completes terminal orchestration around accepted library contracts rather
|
||||
than adding a second persistence/runtime/delivery implementation. Session loading,
|
||||
selected-file import and safe public projections are explicit integration tasks,
|
||||
not assumed existing APIs. Runtime statistics/technical matches and historical
|
||||
verification remain distinct from a verified explicit restart. Terminal cleanup
|
||||
and responsiveness need their own evidence beyond pure render tests. No new
|
||||
product decision or specification change is introduced by this expansion.
|
||||
|
||||
#### Outcome
|
||||
|
||||
Accepted for the defined synthetic UI/PTY scope after independent R2 review and
|
||||
all planned coordinator gates. M8/M9 retain real WireGuard/systemd release gates.
|
||||
|
||||
### Milestone 8 — Alpine Testcontainers integration
|
||||
|
||||
@@ -2239,7 +2635,7 @@ Acceptance: real D-Bus job handling, separate restart verification, address rele
|
||||
|
||||
## Current progress and resume point
|
||||
|
||||
- Milestones 1–6 are accepted; milestones 7–9 remain pending.
|
||||
- Milestones 1–7 are accepted; milestones 8–9 remain pending.
|
||||
- The approved dependency list is in [DEPENDENCIES.md](docs/DEPENDENCIES.md); container testing methodology is in [TESTING.md](docs/TESTING.md).
|
||||
- Milestone 1 is expanded and the Rust environment is pinned.
|
||||
- Milestone 2 delivered a lossless in-memory WireGuard configuration editor, versioned metadata, and behavioral preservation tests.
|
||||
@@ -2248,6 +2644,7 @@ Acceptance: real D-Bus job handling, separate restart verification, address rele
|
||||
- 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.
|
||||
- Milestone 6 delivered session-bound creation/first-file authorization, saved-state rendering and password-per-action reissue, secure plaintext file output, optional same-payload QR, and synthetic lifecycle tests. Independent integrated review and repair closed all actionable findings; all planned gates passed. TUI and real-system validation remain M7–M9.
|
||||
- M1–M6 integrated gates and independent reviews passed. No Alpine or VM results are claimed.
|
||||
- Milestone 7 delivered the native terminal workflow, safe startup/session projection, bounded single-owner worker, setup/import/draft/Commit/restart/delivery/backup/restore forms, synthetic PTY and workflow tests, and documentation. Independent R2 closed its list-status finding; all planned coordinator gates passed. Real WireGuard and systemd remain M8/M9.
|
||||
|
||||
## Plan maintenance
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# wireguard-admin
|
||||
|
||||
This repository contains the Rust package, startup boundary, validated settings
|
||||
loader, client domain model, a byte-preserving WireGuard configuration editor,
|
||||
encrypted client vault and read-only import APIs, recoverable saved-file
|
||||
persistence, private backups, Commit, and previewed full restore. The library
|
||||
also provides read-only WireGuard runtime observation, technical peer comparison,
|
||||
bounded systemd restart handling, and explicit restart orchestration. The
|
||||
terminal management interface is not implemented, and management startup is not
|
||||
connected to these library operations.
|
||||
This repository contains a root-only native terminal interface for one existing
|
||||
WireGuard interface and configurable systemd service. It connects the validated
|
||||
settings loader, byte-preserving server configuration editor, encrypted client
|
||||
vault, recoverable saved-file persistence, backups, Commit, previewed restore,
|
||||
explicit restart, read-only runtime statistics, and client file/QR delivery.
|
||||
The interface keeps draft, saved, and applied states distinct. Commit saves
|
||||
files; restore replaces saved state; only an explicit, verified restart applies
|
||||
saved changes to the running interface.
|
||||
|
||||
The package requires Rust 1.97.1 or newer and uses the Rust 2024 edition. From
|
||||
the repository root, run:
|
||||
@@ -17,9 +17,11 @@ cargo run -- --help
|
||||
cargo run -- --version
|
||||
```
|
||||
|
||||
`--help` and `--version` work without root. Starting with no arguments requires
|
||||
root; until the management interface is implemented, startup exits with an
|
||||
error and does not read server configuration or vault files.
|
||||
`--help` and `--version` work without root, settings, or a terminal. Starting
|
||||
with no arguments requires root and a suitable interactive terminal. Settings
|
||||
are read from `wireguard-admin.toml` beside the executable. If terminal checks
|
||||
or saved-state recovery fail, startup exits before entering raw mode or
|
||||
performing setup mutations.
|
||||
|
||||
## Settings
|
||||
|
||||
@@ -46,6 +48,32 @@ absolute. Settings validation checks syntax and values only; it does not
|
||||
require the referenced files, directories, or service to exist and does not
|
||||
perform DNS lookups.
|
||||
|
||||
## Terminal workflows
|
||||
|
||||
The client list supports arrow-key selection, Enter for details, `?` for help,
|
||||
and `q` to exit. Exit warns when a draft would be discarded; it never commits
|
||||
implicitly. From the list or details screen, the current shortcuts are:
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| `n` | Create a client with automatic or manual dual-stack addresses |
|
||||
| `r` / `d` | Rename or delete the selected supported peer by full public key |
|
||||
| `p` / `x` | Review or discard the draft |
|
||||
| `c` | Commit saved files after the mandatory backup |
|
||||
| `R` | Preview explicit restart; confirm only after reviewing the choice |
|
||||
| `o` / `O` | First file delivery / password-per-action reissue |
|
||||
| `b` / `B` | List and preview backups / create a manual backup |
|
||||
| `s` / `i` | Initial vault setup / import one selected client file |
|
||||
| `!` | Inspect document and peer anomalies |
|
||||
|
||||
Forms use Enter to advance or submit, Tab to change fields/options, and Esc to
|
||||
cancel. Backup restore previews identify the selected archive and require an
|
||||
explicit confirmation. Restore does not prompt for the vault password and does
|
||||
not restart WireGuard. Client configuration output is a disclosure: first
|
||||
delivery requires a successful Commit in the same session; QR appears only
|
||||
after file publication. Esc closes and clears a QR disclosure. Help in the
|
||||
interface contains the full current shortcut list.
|
||||
|
||||
## Library configuration API
|
||||
|
||||
The library exposes `wireguard_admin::wireguard::ConfigDocument` for UTF-8
|
||||
@@ -69,12 +97,10 @@ let updated = document.rename_client(&public_key, "office laptop")?;
|
||||
let bytes_to_review = updated.render(); // no persistence or VPN operation
|
||||
```
|
||||
|
||||
Management startup still exits with an explicit “not implemented” error after
|
||||
its root check. Runtime and restart library behavior is covered with synthetic
|
||||
fixtures and fake adapters; it has not been validated against a real WireGuard
|
||||
interface or systemd service. Real WireGuard integration is deferred to the
|
||||
isolated Alpine milestone, and real systemd/D-Bus acceptance requires the
|
||||
disposable VM milestone.
|
||||
Runtime and restart behavior has synthetic fixture and fake-adapter coverage;
|
||||
it has not been accepted 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
|
||||
|
||||
@@ -137,7 +163,30 @@ plaintext client files as a fallback. The result reports a secret-free failure
|
||||
stage and warns when the current saved peer set has not been verified as applied
|
||||
in this live session. Historical verification timestamps do not suppress that
|
||||
warning after reopen. File output uses mode `0600`, explicit overwrite
|
||||
confirmation, and separate QR failure reporting. These APIs have synthetic
|
||||
fixture coverage; the TUI password prompt and terminal QR rendering are not
|
||||
implemented.
|
||||
confirmation, and separate QR failure reporting. These APIs and the TUI
|
||||
password prompt/terminal QR display have synthetic fixture coverage. No
|
||||
production configuration or key is included in the repository.
|
||||
|
||||
## Test commands and integration limits
|
||||
|
||||
Run the default synthetic workflow and UI checks as an unprivileged user:
|
||||
|
||||
```sh
|
||||
cargo test --locked --test tui_workflow
|
||||
cargo test --locked tui::
|
||||
```
|
||||
|
||||
The first exercises setup, client staging, Commit, delivery, backup listing,
|
||||
and restore through the serialized worker with temporary files and a fake
|
||||
statistics reader. The second covers navigation/forms/rendering, delayed worker
|
||||
behavior, terminal lifecycle seams, and QR sizing. Neither command starts the
|
||||
management UI or uses host service/runtime mutation. See
|
||||
[`docs/TESTING.md`](docs/TESTING.md) for full gates and the isolated Alpine
|
||||
(M8) and disposable systemd VM (M9) boundaries. An actual PTY smoke run using
|
||||
synthetic application/runtime adapters is included in `tui_workflow`; it uses
|
||||
`script` and a fake unavailable-statistics reader, without root or host settings.
|
||||
It checks an 80×24 to 40×8 to 80×24 resize sequence and terminal cleanup after
|
||||
`q`. The workflow also checks that Esc clears a returned QR disclosure; the
|
||||
focused renderer test checks complete quiet-zone fit. Do not use the development
|
||||
host's settings or VPN as a substitute.
|
||||
|
||||
|
||||
+12
-3
@@ -1,12 +1,19 @@
|
||||
# Project Layout
|
||||
|
||||
Read this document when creating or reorganizing modules. The paths below are planned until implemented.
|
||||
Read this document when creating or reorganizing modules. The layout below
|
||||
reflects the current package; sections marked planned describe later test and
|
||||
integration work only.
|
||||
|
||||
## Target project layout
|
||||
|
||||
Start with one Cargo package containing a binary (`src/main.rs`) and a library (`src/lib.rs`). Keep startup, argument handling, and process exit in `main.rs`; place testable application logic in library modules. A library target does not require publishing a separate library package. Introduce a workspace only when independently reusable or separately built components justify it.
|
||||
The repository has one Cargo package with a binary (`src/main.rs`) and a
|
||||
library (`src/lib.rs`). Startup and process exit remain in `main.rs`; testable
|
||||
application logic and the management TUI are library modules. A library target
|
||||
does not require publishing a separate library package. Introduce a workspace
|
||||
only when independently reusable or separately built components justify it.
|
||||
|
||||
The following is the intended layout, not a claim that these files already exist. Create modules and directories as implementation requires them; do not generate empty scaffolding for the entire tree.
|
||||
Implemented modules include the following. Add modules as implementation
|
||||
requires them; do not generate empty scaffolding for future integration work.
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
@@ -36,12 +43,14 @@ src/
|
||||
tui/
|
||||
state.rs # Screen state and navigation
|
||||
events.rs # Keyboard and background events
|
||||
forms.rs # Short-lived forms and secret owners
|
||||
render.rs # Rendering
|
||||
terminal.rs # Terminal setup and cleanup
|
||||
tests/
|
||||
config_preservation.rs # Untouched server configuration remains identical
|
||||
commit_restore.rs # Persistence, backups, restore, runtime isolation
|
||||
client_lifecycle.rs # Import, creation, delivery, deletion, state transitions
|
||||
tui_workflow.rs # Synthetic worker-level TUI workflows
|
||||
fixtures/ # Synthetic configuration and archive inputs
|
||||
support/
|
||||
mod.rs # Shared test adapters and helpers
|
||||
|
||||
@@ -17,6 +17,21 @@ cargo build --locked --release
|
||||
|
||||
Use only supported feature combinations; do not assume `--all-features` is valid. Keep privileged integration tests out of the default test suite. If a check cannot run, report the exact limitation rather than claiming success. Documentation-only edits do not require rebuilding the application.
|
||||
|
||||
The unprivileged TUI fixture commands currently available are:
|
||||
|
||||
```sh
|
||||
cargo test --locked --test tui_workflow
|
||||
cargo test --locked tui::
|
||||
```
|
||||
|
||||
`tui_workflow` runs setup with a redacted invalid-source anomaly, client
|
||||
creation/rename, Commit, first-file and QR delivery, manual backup/list, and
|
||||
preview/cancel/confirm restore through the serialized application worker. It
|
||||
uses temporary settings/server/source files and a fake statistics reader; it
|
||||
does not invoke service or runtime mutation.
|
||||
The `tui::` filter covers forms, rendering, worker responsiveness/staleness,
|
||||
QR fit, and terminal cleanup/error seams. It does not require root or a PTY.
|
||||
|
||||
Prioritize meaningful behavioral checks:
|
||||
|
||||
- Configuration round trips preserve untouched bytes, unknown fields, and administrator comments; managed metadata cannot escape its comment block.
|
||||
@@ -30,6 +45,33 @@ Prioritize meaningful behavioral checks:
|
||||
|
||||
Test externally observable behavior and high-impact failure paths. Avoid tests that merely repeat implementation details, introduce root requirements, or assert real secrets in snapshots. Broaden testing when failures or new risks justify it.
|
||||
|
||||
### Terminal smoke coverage
|
||||
|
||||
`cargo test --locked --test tui_workflow terminal_pty_smoke -- --nocapture`
|
||||
launches the terminal loop through `script` with a synthetic `Application` and
|
||||
an injected fake unavailable-statistics reader. It enters at 80×24, changes to
|
||||
40×8 and back to 80×24, sends Ctrl-C (handled as the normal exit request), and
|
||||
asserts the alternate screen and cursor are restored. The setup is not root
|
||||
management startup and does not read host settings or query host WireGuard.
|
||||
The test reports itself incomplete if the
|
||||
`script` utility is unavailable. In the workflow test, an actual first-delivery
|
||||
QR result is transferred into the disclosure owner and Esc clears it; the
|
||||
renderer fit test separately checks complete quiet-zone dimensions. These
|
||||
checks are synthetic PTY/UI evidence, not phone/decoder interoperability.
|
||||
The PTY test exercises handled interruption and normal terminal restoration;
|
||||
`cargo test --locked tui::terminal::tests::lifecycle_failures_restore_terminal_modes_in_isolated_pty -- --nocapture`
|
||||
uses `script` to launch five isolated child probes at 80×24. The probes stop
|
||||
after raw-mode setup, stop after alternate-screen setup, return synthetic
|
||||
render and input I/O errors after full setup, and panic after full setup. Each
|
||||
child checks raw mode is off on return; the parent checks that alternate-screen
|
||||
and cursor enter/restore sequences match the resources reached. The panic case
|
||||
also checks that the panic hook emits its generic internal-failure diagnostic
|
||||
without the synthetic panic payload. These probes use the same private lifecycle
|
||||
guard and setup methods as production, without an environment-controlled
|
||||
production path or application/service adapters. The test reports itself
|
||||
incomplete if `script` is unavailable. PTY evidence remains synthetic; it does
|
||||
not test terminal-emulator behavior beyond the emitted control sequences.
|
||||
|
||||
## Integration testing with Alpine Testcontainers
|
||||
|
||||
Production access is limited and experimentation on the production VPN is prohibited. Use three complementary levels: ordinary unit/fixture tests, explicitly selected Alpine container integration tests, and a separate disposable Linux VM for real systemd/D-Bus acceptance. No level authorizes using production as a test environment.
|
||||
|
||||
+276
-2
@@ -5,6 +5,7 @@
|
||||
//! explicit runtime-restart confirmation boundary.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::ffi::OsStr;
|
||||
use std::fmt;
|
||||
use std::net::IpAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -28,11 +29,34 @@ use crate::runtime::{
|
||||
self, RuntimeObservation as TechnicalObservation, SavedPeerProjection, TechnicalMatch,
|
||||
};
|
||||
use crate::service::{self, RestartDeadline, RestartExecution, RestartOutcome};
|
||||
use crate::vault::source::{PreparedImportSetup, SourceImportReport};
|
||||
use crate::vault::source::{
|
||||
PreparedImportSetup, SelectedFileImportReport, SourceImportError, SourceImportReport,
|
||||
};
|
||||
use crate::vault::{VaultBundle, VaultError, WireGuardPrivateKey};
|
||||
use crate::wireguard::SectionKind;
|
||||
use crate::wireguard::{ConfigDocument, DocumentError, EditError};
|
||||
|
||||
/// Secret-free row for displaying every authoritative peer section.
|
||||
/// `mutable_identity` is present only when the peer has an unambiguous,
|
||||
/// supported projection into the client domain model.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ClientDisplay {
|
||||
pub peer_index: usize,
|
||||
pub line: usize,
|
||||
pub identity: Option<PublicKey>,
|
||||
pub label: String,
|
||||
pub allowed_ips: Vec<String>,
|
||||
pub issues: Vec<crate::wireguard::PeerIssue>,
|
||||
pub mutable_identity: Option<PublicKey>,
|
||||
}
|
||||
|
||||
/// Secret-free display projection of the current document and client state.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SessionDisplay {
|
||||
pub clients: Vec<ClientDisplay>,
|
||||
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
|
||||
}
|
||||
|
||||
/// An open, in-memory management session. The configuration and state drafts
|
||||
/// change independently of installed bytes until [`Self::commit`] succeeds.
|
||||
pub struct Application {
|
||||
@@ -295,6 +319,106 @@ impl RestorePreview {
|
||||
pub fn historical_apply(&self) -> &HistoricalApplyStatus {
|
||||
&self.historical_apply
|
||||
}
|
||||
|
||||
/// Safe comparison against current saved bytes for a restore confirmation screen.
|
||||
pub fn change_summary(&self, current: &SavedBytes) -> RestoreChangeSummary {
|
||||
let peer_map = |bytes: &[u8]| {
|
||||
ConfigDocument::parse(bytes)
|
||||
.map(|document| {
|
||||
document
|
||||
.peers()
|
||||
.iter()
|
||||
.filter_map(|peer| {
|
||||
peer.identity.map(|key| {
|
||||
(
|
||||
key,
|
||||
peer.allowed_ips
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let current_peer_map = peer_map(¤t.server_config);
|
||||
let proposed_peer_map = peer_map(&self.saved_bytes.server_config);
|
||||
let current_peers = current_peer_map.keys().copied().collect::<BTreeSet<_>>();
|
||||
let proposed_peers = proposed_peer_map.keys().copied().collect::<BTreeSet<_>>();
|
||||
let changed_clients = current_peers
|
||||
.intersection(&proposed_peers)
|
||||
.filter(|key| current_peer_map.get(key) != proposed_peer_map.get(key))
|
||||
.copied()
|
||||
.collect();
|
||||
RestoreChangeSummary {
|
||||
settings_changed: current.settings != self.saved_bytes.settings,
|
||||
settings_fields_changed: settings_fields_changed(
|
||||
¤t.settings,
|
||||
&self.saved_bytes.settings,
|
||||
),
|
||||
added_clients: proposed_peers.difference(¤t_peers).copied().collect(),
|
||||
removed_clients: current_peers.difference(&proposed_peers).copied().collect(),
|
||||
changed_clients,
|
||||
server_config_changed: current.server_config != self.saved_bytes.server_config,
|
||||
vault_changed: current.vault != self.saved_bytes.vault,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RestoreChangeSummary {
|
||||
pub settings_changed: bool,
|
||||
pub settings_fields_changed: Vec<&'static str>,
|
||||
pub server_config_changed: bool,
|
||||
pub vault_changed: bool,
|
||||
pub added_clients: Vec<PublicKey>,
|
||||
pub removed_clients: Vec<PublicKey>,
|
||||
pub changed_clients: Vec<PublicKey>,
|
||||
}
|
||||
|
||||
fn settings_fields_changed(current: &[u8], proposed: &[u8]) -> Vec<&'static str> {
|
||||
let parse = |bytes: &[u8]| {
|
||||
std::str::from_utf8(bytes)
|
||||
.ok()
|
||||
.and_then(|text| crate::config::SettingsFile::parse_toml(text).ok())
|
||||
};
|
||||
let (Some(current), Some(proposed)) = (parse(current), parse(proposed)) else {
|
||||
return vec!["settings file"];
|
||||
};
|
||||
let mut fields = Vec::new();
|
||||
if current.interface_name != proposed.interface_name {
|
||||
fields.push("interface name");
|
||||
}
|
||||
if current.server_config_path != proposed.server_config_path {
|
||||
fields.push("server configuration path");
|
||||
}
|
||||
if current.client_source_directory != proposed.client_source_directory {
|
||||
fields.push("client source directory");
|
||||
}
|
||||
if current.server_endpoint != proposed.server_endpoint {
|
||||
fields.push("server endpoint");
|
||||
}
|
||||
if current.ipv4_allocation_range != proposed.ipv4_allocation_range {
|
||||
fields.push("IPv4 allocation range");
|
||||
}
|
||||
if current.ipv6_allocation_range != proposed.ipv6_allocation_range {
|
||||
fields.push("IPv6 allocation range");
|
||||
}
|
||||
if current.dns_addresses != proposed.dns_addresses {
|
||||
fields.push("DNS addresses");
|
||||
}
|
||||
if current.systemd_unit != proposed.systemd_unit {
|
||||
fields.push("systemd unit");
|
||||
}
|
||||
if current.restart_timeout_seconds != proposed.restart_timeout_seconds {
|
||||
fields.push("restart timeout");
|
||||
}
|
||||
if current.data_root != proposed.data_root {
|
||||
fields.push("data directory");
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
impl fmt::Debug for RestorePreview {
|
||||
@@ -334,8 +458,30 @@ impl fmt::Debug for Application {
|
||||
impl Application {
|
||||
/// Open validated installed bytes and apply conservative persisted address
|
||||
/// reservations to the caller's narrower client-state model.
|
||||
pub fn open(store: SavedStore, mut states: ClientStates) -> Result<Self, AppError> {
|
||||
pub fn open(store: SavedStore, states: ClientStates) -> Result<Self, AppError> {
|
||||
let saved = store.read_saved()?;
|
||||
Self::open_saved(store, saved, states)
|
||||
}
|
||||
|
||||
/// Open a session by deriving its supported state from the authoritative
|
||||
/// saved server document. Unsupported peers remain available through
|
||||
/// [`Self::display_projection`] but never gain a guessed mutation identity.
|
||||
pub fn open_from_saved_store(store: SavedStore) -> Result<Self, AppError> {
|
||||
let saved = store.read_saved()?;
|
||||
let document = ConfigDocument::parse(&saved.server_config)?;
|
||||
let states = ClientStates::new(
|
||||
snapshot_from_document(&document),
|
||||
None,
|
||||
RuntimeObservation::Unknown,
|
||||
)?;
|
||||
Self::open_saved(store, saved, states)
|
||||
}
|
||||
|
||||
fn open_saved(
|
||||
store: SavedStore,
|
||||
saved: SavedBytes,
|
||||
mut states: ClientStates,
|
||||
) -> Result<Self, AppError> {
|
||||
states.mark_runtime_unknown();
|
||||
let document = ConfigDocument::parse(&saved.server_config)?;
|
||||
let vault = saved
|
||||
@@ -377,6 +523,49 @@ impl Application {
|
||||
&self.states
|
||||
}
|
||||
|
||||
/// Return a secret-free projection that includes unsupported and
|
||||
/// ambiguous peers instead of silently omitting them from the client list.
|
||||
pub fn display_projection(&self) -> SessionDisplay {
|
||||
let clients = self
|
||||
.document
|
||||
.peers()
|
||||
.iter()
|
||||
.map(|peer| {
|
||||
let projected = peer
|
||||
.identity
|
||||
.and_then(|identity| self.document.project_client(&identity).ok());
|
||||
let metadata_label = match &peer.metadata {
|
||||
crate::wireguard::MetadataAttachment::Valid { metadata, .. } => {
|
||||
Some(metadata.alias.clone())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let identity = peer.identity;
|
||||
let label = metadata_label.unwrap_or_else(|| {
|
||||
identity.map_or_else(
|
||||
|| format!("Peer at line {}", peer.section_line),
|
||||
|key| format!("{}…", key.to_string().chars().take(8).collect::<String>()),
|
||||
)
|
||||
});
|
||||
ClientDisplay {
|
||||
peer_index: peer.index,
|
||||
line: peer.section_line,
|
||||
identity,
|
||||
label,
|
||||
allowed_ips: peer.allowed_ips.iter().map(ToString::to_string).collect(),
|
||||
issues: peer.issues.clone(),
|
||||
mutable_identity: projected
|
||||
.filter(|client| self.states.draft().get(&client.identity).is_some())
|
||||
.map(|client| client.identity),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
SessionDisplay {
|
||||
clients,
|
||||
document_issues: self.document.issues().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add one prepared public-key client to the byte-preserving server draft
|
||||
/// and the narrower in-memory client draft.
|
||||
pub fn add_client(
|
||||
@@ -1001,6 +1190,33 @@ impl Application {
|
||||
self.vault.as_ref()
|
||||
}
|
||||
|
||||
/// Whether a vault is present in either saved state or the current draft.
|
||||
/// This reveals only setup availability, never vault contents.
|
||||
pub fn vault_initialized(&self) -> bool {
|
||||
self.vault.is_some()
|
||||
}
|
||||
|
||||
/// Whether the in-memory client/document/vault draft differs from saved.
|
||||
pub fn has_draft_changes(&self) -> bool {
|
||||
self.has_draft()
|
||||
}
|
||||
|
||||
/// Import one explicitly selected configured-directory entry into the
|
||||
/// in-memory vault draft. The result contains only redacted reconciliation
|
||||
/// data; saved files are changed only by a later Commit.
|
||||
pub fn import_selected_file(
|
||||
&mut self,
|
||||
selected_name: &OsStr,
|
||||
) -> Result<SelectedFileImportReport, AppError> {
|
||||
let settings = self.store.resolved_settings_for(&self.saved)?;
|
||||
let vault = self
|
||||
.vault
|
||||
.as_mut()
|
||||
.ok_or(AppError::VaultMissingForCreation)?;
|
||||
crate::vault::source::import_selected_file(vault, &settings, &self.document, selected_name)
|
||||
.map_err(AppError::SourceImport)
|
||||
}
|
||||
|
||||
/// Replace a client's alias through the span-preserving config editor.
|
||||
/// A peer outside the narrower M1 state projection remains editable.
|
||||
pub fn rename_client(&mut self, identity: PublicKey, alias: &str) -> Result<(), AppError> {
|
||||
@@ -1086,6 +1302,20 @@ impl Application {
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Prepare a password-protected vault and import configured source files
|
||||
/// against this session's authoritative document. All results remain in
|
||||
/// the in-memory draft until Commit.
|
||||
pub fn prepare_initial_setup_from_sources(
|
||||
&mut self,
|
||||
vault_password: &str,
|
||||
) -> Result<SourceImportReport, AppError> {
|
||||
let settings = self.store.resolved_settings_for(&self.saved)?;
|
||||
let prepared =
|
||||
crate::vault::source::prepare_initial_setup(&settings, &self.document, vault_password)
|
||||
.map_err(AppError::SourceImport)?;
|
||||
self.prepare_initial_setup(prepared)
|
||||
}
|
||||
|
||||
/// Drop all uncommitted edits and restore the in-memory drafts from the last
|
||||
/// successfully installed session snapshot. No files are written.
|
||||
pub fn discard_draft(&mut self) -> Result<(), AppError> {
|
||||
@@ -2118,10 +2348,15 @@ pub enum AppError {
|
||||
Domain(#[from] DomainError),
|
||||
#[error(transparent)]
|
||||
Delivery(#[from] crate::delivery::DeliveryError),
|
||||
#[error(transparent)]
|
||||
FileOutput(#[from] FileOutputError),
|
||||
#[error(transparent)]
|
||||
SourceImport(#[from] SourceImportError),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -2166,6 +2401,15 @@ systemd_unit = "wg-quick@wg0.service"
|
||||
restart_timeout_seconds = 30
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn restore_settings_projection_reports_changed_fields_without_exposing_values() {
|
||||
let proposed = SETTINGS.replace("vpn.example.test:51820", "vpn-new.example.test:51820");
|
||||
let fields = super::settings_fields_changed(SETTINGS.as_bytes(), proposed.as_bytes());
|
||||
assert_eq!(fields, ["server endpoint"]);
|
||||
let malformed = super::settings_fields_changed(SETTINGS.as_bytes(), b"invalid = [");
|
||||
assert_eq!(malformed, ["settings file"]);
|
||||
}
|
||||
|
||||
fn setup(
|
||||
root: &Path,
|
||||
archive_as_file: bool,
|
||||
@@ -2333,6 +2577,36 @@ restart_timeout_seconds = 30
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_file_import_uses_the_existing_vault_draft_without_saving() {
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
let mut app = setup_with_vault(root.path());
|
||||
let source_directory = root.path().join("clients");
|
||||
fs::create_dir_all(&source_directory).expect("source directory");
|
||||
fs::write(
|
||||
source_directory.join("invalid.conf"),
|
||||
b"not a client config\n",
|
||||
)
|
||||
.expect("synthetic invalid source file");
|
||||
let saved_vault = app.saved_bytes().vault.clone();
|
||||
let draft_records = app.vault().expect("vault exists").record_count();
|
||||
|
||||
let report = app
|
||||
.import_selected_file(OsStr::new("invalid.conf"))
|
||||
.expect("invalid selected file is reported rather than fatal");
|
||||
|
||||
assert!(report.scan_complete);
|
||||
assert!(matches!(
|
||||
report.candidates[0].1,
|
||||
crate::vault::import::CandidateStatus::Invalid(_)
|
||||
));
|
||||
assert_eq!(
|
||||
app.vault().expect("vault remains present").record_count(),
|
||||
draft_records
|
||||
);
|
||||
assert_eq!(app.saved_bytes().vault, saved_vault);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_addresses_reject_occupied_and_out_of_range_without_partial_staging() {
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
|
||||
@@ -7,5 +7,6 @@ pub mod persistence;
|
||||
pub mod runtime;
|
||||
pub mod service;
|
||||
pub mod startup;
|
||||
pub mod tui;
|
||||
pub mod vault;
|
||||
pub mod wireguard;
|
||||
|
||||
+19
-5
@@ -5,7 +5,7 @@ use wireguard_admin::startup::{self, StartupAction};
|
||||
|
||||
const USAGE: &str = "Usage: wireguard-admin [--help | --version]\n\
|
||||
With no arguments, starts the root-only management interface.\n\
|
||||
The management interface is not implemented yet.";
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let arguments: Vec<OsString> = env::args_os().skip(1).collect();
|
||||
@@ -22,13 +22,27 @@ fn main() -> ExitCode {
|
||||
println!("wireguard-admin {}", env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Ok(StartupAction::ManagementUnavailable) => {
|
||||
eprintln!("The management interface is not implemented yet.");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
Ok(StartupAction::Management) => match run_management() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("Error: {error}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
eprintln!("Error: {error}.\n{USAGE}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_management() -> Result<(), String> {
|
||||
let executable = env::current_exe().map_err(|_| "cannot locate executable".to_owned())?;
|
||||
let directory = executable
|
||||
.parent()
|
||||
.ok_or_else(|| "executable has no parent directory".to_owned())?;
|
||||
wireguard_admin::tui::terminal::check_suitable().map_err(|error| error.to_string())?;
|
||||
let app =
|
||||
startup::open_management_session(directory, || true).map_err(|error| error.to_string())?;
|
||||
wireguard_admin::tui::terminal::run(app).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
+138
-4
@@ -1,11 +1,20 @@
|
||||
use std::ffi::OsString;
|
||||
use std::fmt;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::app::Application;
|
||||
use crate::config::{ResolvedSettings, SettingsFile};
|
||||
use crate::persistence::SavedStore;
|
||||
|
||||
const MAX_SETTINGS_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum StartupAction {
|
||||
Help,
|
||||
Version,
|
||||
ManagementUnavailable,
|
||||
Management,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -25,6 +34,64 @@ impl fmt::Display for StartupError {
|
||||
|
||||
impl std::error::Error for StartupError {}
|
||||
|
||||
/// Fixed, secret-free reasons management session startup can fail.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SessionLoadError {
|
||||
RootRequired,
|
||||
SettingsUnavailable,
|
||||
SettingsInvalid,
|
||||
StoreInvalid,
|
||||
SavedStateInvalid,
|
||||
}
|
||||
|
||||
impl fmt::Display for SessionLoadError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::RootRequired => "management operations require root",
|
||||
Self::SettingsUnavailable => "executable-relative settings are unavailable",
|
||||
Self::SettingsInvalid => "executable-relative settings are invalid",
|
||||
Self::StoreInvalid => "saved-state paths are unsafe or inconsistent",
|
||||
Self::SavedStateInvalid => "saved state could not be loaded safely",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SessionLoadError {}
|
||||
|
||||
/// Load a management session only after checking privilege. Settings are read
|
||||
/// beside the executable, then SavedStore performs journal recovery and
|
||||
/// validates the complete saved state before Application is exposed.
|
||||
/// Terminal suitability is checked by the later terminal-startup boundary.
|
||||
pub fn open_management_session(
|
||||
executable_directory: &Path,
|
||||
effective_uid_is_root: impl FnOnce() -> bool,
|
||||
) -> Result<Application, SessionLoadError> {
|
||||
if !effective_uid_is_root() {
|
||||
return Err(SessionLoadError::RootRequired);
|
||||
}
|
||||
let settings_path = ResolvedSettings::settings_path(executable_directory);
|
||||
let metadata =
|
||||
fs::symlink_metadata(&settings_path).map_err(|_| SessionLoadError::SettingsUnavailable)?;
|
||||
if !metadata.file_type().is_file() || metadata.len() > MAX_SETTINGS_BYTES {
|
||||
return Err(SessionLoadError::SettingsUnavailable);
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(metadata.len() as usize);
|
||||
File::open(&settings_path)
|
||||
.and_then(|file| file.take(MAX_SETTINGS_BYTES + 1).read_to_end(&mut bytes))
|
||||
.map_err(|_| SessionLoadError::SettingsUnavailable)?;
|
||||
if bytes.len() as u64 > MAX_SETTINGS_BYTES {
|
||||
return Err(SessionLoadError::SettingsUnavailable);
|
||||
}
|
||||
let text = std::str::from_utf8(&bytes).map_err(|_| SessionLoadError::SettingsInvalid)?;
|
||||
let resolved = SettingsFile::parse_toml(text)
|
||||
.and_then(SettingsFile::validate)
|
||||
.map(|validated| validated.resolve_paths(executable_directory))
|
||||
.map_err(|_| SessionLoadError::SettingsInvalid)?;
|
||||
let store = SavedStore::new(executable_directory, resolved)
|
||||
.map_err(|_| SessionLoadError::StoreInvalid)?;
|
||||
Application::open_from_saved_store(store).map_err(|_| SessionLoadError::SavedStateInvalid)
|
||||
}
|
||||
|
||||
/// Resolves the supported command line and checks management privilege lazily.
|
||||
///
|
||||
/// The injected root check keeps this boundary testable without running tests as
|
||||
@@ -36,7 +103,7 @@ pub fn start(
|
||||
match arguments {
|
||||
[] => {
|
||||
if effective_uid_is_root() {
|
||||
Ok(StartupAction::ManagementUnavailable)
|
||||
Ok(StartupAction::Management)
|
||||
} else {
|
||||
Err(StartupError::RootRequired)
|
||||
}
|
||||
@@ -72,9 +139,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_management_startup_reports_unimplemented_without_side_effects() {
|
||||
fn root_management_startup_is_authorized_for_terminal_boundary() {
|
||||
let result = start(&[], || true);
|
||||
assert_eq!(result, Ok(StartupAction::ManagementUnavailable));
|
||||
assert_eq!(result, Ok(StartupAction::Management));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -89,4 +156,71 @@ mod tests {
|
||||
assert_eq!(result, Err(StartupError::UnknownArguments));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn management_session_checks_root_before_reading_settings() {
|
||||
let result = super::open_management_session(
|
||||
std::path::Path::new("/path/that/does/not/exist"),
|
||||
|| false,
|
||||
);
|
||||
assert_eq!(result.err(), Some(super::SessionLoadError::RootRequired));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn management_session_loads_supported_state_and_keeps_unsupported_peers_visible() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"wireguard-admin-startup-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
std::fs::write(
|
||||
directory.join(crate::config::SETTINGS_FILE_NAME),
|
||||
r#"interface_name = "wg0"
|
||||
server_config_path = "wg0.conf"
|
||||
client_source_directory = "clients"
|
||||
server_endpoint = "vpn.example.org:51820"
|
||||
ipv4_allocation_range = "10.80.0.0/24"
|
||||
ipv6_allocation_range = "fd80::/64"
|
||||
dns_addresses = ["10.80.0.1"]
|
||||
systemd_unit = "wg-quick@wg0.service"
|
||||
restart_timeout_seconds = 10
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let server_path = directory.join("wg0.conf");
|
||||
std::fs::write(
|
||||
&server_path,
|
||||
b"[Interface]\nAddress = 10.80.0.1/24\n# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=Y2xlYW4\n# wg-admin:end\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAllowedIPs = 10.80.0.2/32, fd80::2/128\n[Peer]\nPublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=\nAllowedIPs = 10.80.0.0/24\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&server_path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
|
||||
let app = super::open_management_session(&directory, || true)
|
||||
.expect("valid synthetic installation should open");
|
||||
let projection = app.display_projection();
|
||||
assert_eq!(projection.clients.len(), 2);
|
||||
assert_eq!(app.states().saved().clients().count(), 1);
|
||||
assert!(projection.clients[0].mutable_identity.is_some());
|
||||
assert!(projection.clients[1].mutable_identity.is_none());
|
||||
assert_eq!(projection.clients[1].allowed_ips, ["10.80.0.0/24"]);
|
||||
assert!(!format!("{projection:?}").contains("PrivateKey"));
|
||||
let internal_state = directory.join("Backup/data");
|
||||
std::fs::create_dir_all(&internal_state).unwrap();
|
||||
std::fs::write(
|
||||
internal_state.join("internal-state.toml"),
|
||||
b"not valid state",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
super::open_management_session(&directory, || true).err(),
|
||||
Some(super::SessionLoadError::SavedStateInvalid),
|
||||
"corrupt persisted state must stop session loading rather than start empty"
|
||||
);
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
//! Native terminal workflow state and the serialized application worker.
|
||||
//!
|
||||
//! Terminal input/rendering adapters are added in the subsequent M7 tasks.
|
||||
|
||||
pub mod events;
|
||||
pub mod forms;
|
||||
pub mod render;
|
||||
pub mod state;
|
||||
pub mod terminal;
|
||||
|
||||
pub use events::{ApplicationWorker, UiCommand, WorkerConfig, WorkerEvent};
|
||||
pub use state::{SessionView, UiState};
|
||||
+1430
File diff suppressed because it is too large
Load Diff
+1065
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,690 @@
|
||||
//! Responsive, secret-free rendering for the client list and detail screens.
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span, Text};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
|
||||
|
||||
use crate::app::ClientDisplay;
|
||||
use crate::domain::PublicKey;
|
||||
use crate::runtime::{InterfaceObservation, Observed, RuntimeObservation, RuntimePeer};
|
||||
|
||||
use super::forms::FormController;
|
||||
use super::state::{Screen, SessionView, UiState};
|
||||
|
||||
pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
|
||||
let area = frame.area();
|
||||
if area.width < 34 || area.height < 8 {
|
||||
let message = if forms.qr_output().is_some() {
|
||||
"Terminal too small for QR. Resize to at least 34 × 8, or use the saved client file; Esc clears the QR."
|
||||
} else {
|
||||
"Terminal too small. Resize to at least 34 × 8."
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(message)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("WireGuard Admin"),
|
||||
)
|
||||
.wrap(Wrap { trim: true }),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(2),
|
||||
])
|
||||
.split(area);
|
||||
let status = state
|
||||
.view
|
||||
.as_ref()
|
||||
.map_or("Loading saved state".to_owned(), state_summary);
|
||||
frame.render_widget(
|
||||
Paragraph::new(status)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("WireGuard Admin"),
|
||||
)
|
||||
.wrap(Wrap { trim: true }),
|
||||
rows[0],
|
||||
);
|
||||
|
||||
if forms.is_active() {
|
||||
if let Some(qr) = forms.qr_output() {
|
||||
render_qr_disclosure(frame, rows[1], qr, &forms.display_lines());
|
||||
} else {
|
||||
let lines = if forms.is_draft_review() {
|
||||
draft_review_lines(state)
|
||||
} else {
|
||||
forms.display_lines()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Text::from(
|
||||
lines.into_iter().map(Line::from).collect::<Vec<_>>(),
|
||||
))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Management form"),
|
||||
)
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)),
|
||||
rows[1],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
match state.screen {
|
||||
Screen::Help => render_help(frame, rows[1]),
|
||||
Screen::ClientDetails => render_details(frame, rows[1], state),
|
||||
Screen::Progress => {
|
||||
let text = state
|
||||
.status_message
|
||||
.as_deref()
|
||||
.unwrap_or("Operation in progress…");
|
||||
frame.render_widget(
|
||||
Paragraph::new(safe_text(text))
|
||||
.block(Block::default().borders(Borders::ALL).title("Progress"))
|
||||
.wrap(Wrap { trim: false }),
|
||||
rows[1],
|
||||
);
|
||||
}
|
||||
Screen::Message => {
|
||||
let text = state.status_message.as_deref().unwrap_or("Ready");
|
||||
frame.render_widget(
|
||||
Paragraph::new(safe_text(text))
|
||||
.block(Block::default().borders(Borders::ALL).title("Status"))
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)),
|
||||
rows[1],
|
||||
);
|
||||
}
|
||||
Screen::ClientList => render_list(frame, rows[1], state),
|
||||
}
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new("n new r rename d delete p preview c Commit R restart o first file O reissue b backups B backup s setup i import x discard q exit")
|
||||
.block(Block::default().borders(Borders::ALL)),
|
||||
rows[2],
|
||||
);
|
||||
}
|
||||
|
||||
fn render_qr_disclosure(
|
||||
frame: &mut Frame<'_>,
|
||||
area: ratatui::layout::Rect,
|
||||
qr: &crate::delivery::QrCodeOutput,
|
||||
lines: &[String],
|
||||
) {
|
||||
let inner_width = area.width.saturating_sub(2) as usize;
|
||||
let header_rows = lines.len();
|
||||
let available_height = (area.height as usize).saturating_sub(2 + header_rows);
|
||||
let side = qr.side();
|
||||
let mut text = lines.iter().map(|line| safe_text(line)).collect::<Vec<_>>();
|
||||
if !qr_fits(side, inner_width, available_height) {
|
||||
text.push("QR is too large for this terminal. Resize to fit the complete code or use the saved client file.".to_owned());
|
||||
} else {
|
||||
for y in 0..side {
|
||||
let mut row = String::with_capacity(side * 2);
|
||||
for x in 0..side {
|
||||
row.push_str(if qr.module(x, y).unwrap_or(false) {
|
||||
"██"
|
||||
} else {
|
||||
" "
|
||||
});
|
||||
}
|
||||
text.push(row);
|
||||
}
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(Text::from(
|
||||
text.into_iter().map(Line::from).collect::<Vec<_>>(),
|
||||
))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Secret disclosure · Esc clears"),
|
||||
)
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn qr_fits(side: usize, inner_width: usize, available_height: usize) -> bool {
|
||||
side.saturating_mul(2) <= inner_width && side <= available_height
|
||||
}
|
||||
|
||||
fn draft_review_lines(state: &UiState) -> Vec<String> {
|
||||
let Some(view) = state.view.as_ref() else {
|
||||
return vec!["Draft state is still loading.".to_owned()];
|
||||
};
|
||||
let mut lines = vec![
|
||||
"Semantic draft preview".to_owned(),
|
||||
format!(
|
||||
"Pending draft: {}",
|
||||
if view.has_draft { "yes" } else { "no" }
|
||||
),
|
||||
];
|
||||
for (identity, row) in &view.client_states {
|
||||
match (&row.saved, &row.draft) {
|
||||
(None, Some(draft)) => lines.push(format!(
|
||||
"Added {identity}: {} (IPv4 {}, IPv6 {})",
|
||||
safe_text(&draft.alias),
|
||||
draft.addresses.ipv4,
|
||||
draft
|
||||
.addresses
|
||||
.ipv6
|
||||
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
|
||||
)),
|
||||
(Some(saved), None) => lines.push(format!(
|
||||
"Removed {identity}: {} (saved removal remains unapplied until restart)",
|
||||
safe_text(&saved.alias)
|
||||
)),
|
||||
(Some(saved), Some(draft)) if saved != draft => lines.push(format!(
|
||||
"Changed {identity}: alias {} → {}; addresses {} / {} → {} / {}",
|
||||
safe_text(&saved.alias),
|
||||
safe_text(&draft.alias),
|
||||
saved.addresses.ipv4,
|
||||
saved
|
||||
.addresses
|
||||
.ipv6
|
||||
.map_or_else(|| "none".to_owned(), |ip| ip.to_string()),
|
||||
draft.addresses.ipv4,
|
||||
draft
|
||||
.addresses
|
||||
.ipv6
|
||||
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
|
||||
)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if lines.len() == 2 && view.has_draft {
|
||||
lines.push(
|
||||
"A vault or managed metadata draft is pending; no client state differs.".to_owned(),
|
||||
);
|
||||
} else if lines.len() == 2 {
|
||||
lines.push("No semantic client changes are pending.".to_owned());
|
||||
}
|
||||
lines.push("No raw server configuration is displayed.".to_owned());
|
||||
lines
|
||||
}
|
||||
|
||||
fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiState) {
|
||||
let Some(view) = state.view.as_ref() else {
|
||||
frame.render_widget(Paragraph::new("Loading client projection…"), area);
|
||||
return;
|
||||
};
|
||||
if view.clients.is_empty() {
|
||||
let text = if view.document_issues.is_empty() {
|
||||
"No peers are configured."
|
||||
} else {
|
||||
"No peer rows. Document issues are listed below."
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(text).block(Block::default().borders(Borders::ALL).title("Clients")),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let mut items: Vec<ListItem<'_>> = view
|
||||
.clients
|
||||
.iter()
|
||||
.map(|client| {
|
||||
let identity = client
|
||||
.identity
|
||||
.map(|key| short_key(&key))
|
||||
.unwrap_or_else(|| format!("peer at line {}", client.line));
|
||||
let supported = if client.mutable_identity.is_some() {
|
||||
"supported"
|
||||
} else {
|
||||
"inspection only"
|
||||
};
|
||||
let addresses = safe_text(&client.allowed_ips.join(", "));
|
||||
let row_state = client
|
||||
.identity
|
||||
.and_then(|identity| view.client_states.get(&identity))
|
||||
.map_or_else(
|
||||
|| "state: unavailable".to_owned(),
|
||||
|row| match (&row.saved, &row.draft) {
|
||||
(None, Some(_)) => "Draft added".to_owned(),
|
||||
(Some(saved), Some(draft)) if saved != draft => "Draft changed".to_owned(),
|
||||
(Some(_), None) => "Draft removed".to_owned(),
|
||||
(Some(_), Some(_)) => "Saved".to_owned(),
|
||||
(None, None) => "State unavailable".to_owned(),
|
||||
},
|
||||
);
|
||||
let anomaly_marker = if client.issues.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
" ! anomaly".to_owned()
|
||||
};
|
||||
let anomaly_details = if client.issues.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", safe_text(&format!("{:?}", client.issues)))
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::raw(format!("{row_state}{anomaly_marker} ")),
|
||||
Span::styled(safe_text(&client.label), Style::default().fg(Color::Cyan)),
|
||||
Span::raw(format!(
|
||||
" [{identity}] {supported} {addresses}{anomaly_details}"
|
||||
)),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
items.extend(view.client_states.iter().filter_map(|(identity, row)| {
|
||||
let saved = row.saved.as_ref()?;
|
||||
if row.draft.is_some()
|
||||
|| view
|
||||
.clients
|
||||
.iter()
|
||||
.any(|peer| peer.identity == Some(*identity))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let ipv6 = saved
|
||||
.addresses
|
||||
.ipv6
|
||||
.map_or_else(|| "none".to_owned(), |address| address.to_string());
|
||||
Some(ListItem::new(format!(
|
||||
"Draft removed {} [{}] saved addresses: {}, {}",
|
||||
safe_text(&saved.alias),
|
||||
short_key(identity),
|
||||
saved.addresses.ipv4,
|
||||
ipv6
|
||||
)))
|
||||
}));
|
||||
items.extend(view.document_issues.iter().map(|issue| {
|
||||
ListItem::new(format!(
|
||||
"Document issue: {}",
|
||||
safe_text(&format!("{issue:?}"))
|
||||
))
|
||||
.style(Style::default().fg(Color::Yellow))
|
||||
}));
|
||||
let selected = state
|
||||
.selected_peer_index
|
||||
.and_then(|index| view.clients.iter().position(|row| row.peer_index == index))
|
||||
.or_else(|| {
|
||||
state.selected_identity.and_then(|identity| {
|
||||
view.clients
|
||||
.iter()
|
||||
.position(|row| row.identity == Some(identity))
|
||||
})
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let list = List::new(items)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Clients and document issues"),
|
||||
)
|
||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
let mut list_state = ListState::default().with_selected(Some(selected));
|
||||
frame.render_stateful_widget(list, area, &mut list_state);
|
||||
}
|
||||
|
||||
fn render_details(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiState) {
|
||||
let Some(view) = state.view.as_ref() else {
|
||||
frame.render_widget(Paragraph::new("Loading client projection…"), area);
|
||||
return;
|
||||
};
|
||||
let selected = state
|
||||
.selected_peer_index
|
||||
.and_then(|index| view.clients.iter().find(|row| row.peer_index == index))
|
||||
.or_else(|| {
|
||||
state.selected_identity.and_then(|identity| {
|
||||
view.clients
|
||||
.iter()
|
||||
.find(|row| row.identity == Some(identity))
|
||||
})
|
||||
});
|
||||
let Some(client) = selected else {
|
||||
frame.render_widget(
|
||||
Paragraph::new("Select a peer from the client list.").block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Client details"),
|
||||
),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
};
|
||||
let lines = detail_lines(client, view);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Text::from(lines))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Client details"),
|
||||
)
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>> {
|
||||
let mut lines = vec![
|
||||
Line::from(format!("Label: {}", safe_text(&client.label))),
|
||||
Line::from(format!(
|
||||
"Public key: {}",
|
||||
client
|
||||
.identity
|
||||
.map_or_else(|| "unavailable".to_owned(), |key| key.to_string())
|
||||
)),
|
||||
Line::from(format!(
|
||||
"Allowed IPs: {}",
|
||||
safe_text(&client.allowed_ips.join(", "))
|
||||
)),
|
||||
Line::from(format!(
|
||||
"Mutation: {}",
|
||||
if client.mutable_identity.is_some() {
|
||||
"supported"
|
||||
} else {
|
||||
"disabled: ambiguous or unsupported peer"
|
||||
}
|
||||
)),
|
||||
];
|
||||
if let Some(identity) = client.identity {
|
||||
if let Some(row) = view.client_states.get(&identity) {
|
||||
lines.push(Line::from(format!(
|
||||
"Draft: {}",
|
||||
safe_text(row.draft.as_ref().map_or("absent", |c| c.alias.as_str()))
|
||||
)));
|
||||
lines.push(Line::from(format!(
|
||||
"Saved: {}",
|
||||
safe_text(row.saved.as_ref().map_or("absent", |c| c.alias.as_str()))
|
||||
)));
|
||||
if row.draft != row.saved {
|
||||
lines.push(Line::from("Draft differs from saved state."));
|
||||
if let Some(client) = &row.draft {
|
||||
lines.push(Line::from(format!(
|
||||
"Draft addresses: IPv4 {}, IPv6 {}",
|
||||
client.addresses.ipv4,
|
||||
client
|
||||
.addresses
|
||||
.ipv6
|
||||
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
|
||||
)));
|
||||
}
|
||||
if let Some(client) = &row.saved {
|
||||
lines.push(Line::from(format!(
|
||||
"Saved addresses: IPv4 {}, IPv6 {}",
|
||||
client.addresses.ipv4,
|
||||
client
|
||||
.addresses
|
||||
.ipv6
|
||||
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(peer) = runtime_peer(&view.statistics, &identity) {
|
||||
lines.extend(statistics_lines(peer));
|
||||
} else {
|
||||
lines.extend([
|
||||
Line::from("Last handshake: unavailable"),
|
||||
Line::from("Current received: unavailable"),
|
||||
Line::from("Current transmitted: unavailable"),
|
||||
]);
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(format!("Applied state: {:?}", view.applied)));
|
||||
for issue in &client.issues {
|
||||
lines.push(Line::from(format!(
|
||||
"Peer issue: {}",
|
||||
safe_text(&format!("{issue:?}"))
|
||||
)));
|
||||
}
|
||||
for issue in &view.document_issues {
|
||||
lines.push(Line::from(format!(
|
||||
"Document issue: {}",
|
||||
safe_text(&format!("{issue:?}"))
|
||||
)));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn runtime_peer<'a>(
|
||||
observation: &'a RuntimeObservation,
|
||||
key: &PublicKey,
|
||||
) -> Option<&'a RuntimePeer> {
|
||||
match &observation.interface {
|
||||
InterfaceObservation::Present { peers, .. } => peers.peers.get(key),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn statistics_lines(peer: &RuntimePeer) -> [Line<'static>; 3] {
|
||||
let handshake = match &peer.last_handshake {
|
||||
Observed::Unavailable => "unavailable".to_owned(),
|
||||
Observed::Available(None) => "never".to_owned(),
|
||||
Observed::Available(Some(time)) => time
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_else(|_| "available (time formatting failed)".to_owned()),
|
||||
};
|
||||
[
|
||||
Line::from(format!("Last handshake: {handshake}")),
|
||||
Line::from(format!(
|
||||
"Current received: {}",
|
||||
counter(&peer.received_bytes)
|
||||
)),
|
||||
Line::from(format!(
|
||||
"Current transmitted: {}",
|
||||
counter(&peer.transmitted_bytes)
|
||||
)),
|
||||
]
|
||||
}
|
||||
|
||||
fn counter(value: &Observed<u64>) -> String {
|
||||
match value {
|
||||
Observed::Available(bytes) => format!("{bytes} bytes"),
|
||||
Observed::Unavailable => "unavailable".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn state_summary(view: &SessionView) -> String {
|
||||
format!(
|
||||
"Draft / saved / applied: {} pending client changes / {} saved clients / {:?} Issues: {}",
|
||||
view.client_states
|
||||
.values()
|
||||
.filter(|row| row.draft != row.saved)
|
||||
.count(),
|
||||
view.client_states
|
||||
.values()
|
||||
.filter(|row| row.saved.is_some())
|
||||
.count(),
|
||||
view.applied,
|
||||
view.document_issues.len()
|
||||
)
|
||||
}
|
||||
|
||||
fn render_help(frame: &mut Frame<'_>, area: ratatui::layout::Rect) {
|
||||
frame.render_widget(
|
||||
Paragraph::new("↑/↓ selects a peer. Enter opens details. ! opens document and peer issues. Esc cancels a form or returns to the list. ? opens help. q requests exit and warns about a draft.\n\nn new client, r rename, d delete, p preview draft, c Commit, x discard draft, R preview explicit restart, o first file delivery, O password reissue, b list backups/restore, B create manual backup, s initial vault setup, i import one selected source file.\n\nPeer rows with malformed, duplicate, or unsupported configuration remain visible for inspection. Mutation requires an unambiguous supported full public key.\n\nCommit and restore only change saved files after backup. Restart is explicit, previewed, backed up, and verified. Statistics are read-only observations; a refresh never marks changes applied.")
|
||||
.block(Block::default().borders(Borders::ALL).title("Help"))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn short_key(key: &PublicKey) -> String {
|
||||
let value = key.to_string();
|
||||
format!("{}…{}", &value[..8], &value[value.len() - 6..])
|
||||
}
|
||||
|
||||
/// Render control characters visibly so untrusted labels cannot move the cursor
|
||||
/// or forge terminal output.
|
||||
pub fn safe_text(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.flat_map(|character| {
|
||||
if character.is_control() {
|
||||
character
|
||||
.escape_default()
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
vec![character]
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
#[test]
|
||||
fn terminal_controls_are_escaped_for_display() {
|
||||
assert_eq!(
|
||||
safe_text("client\u{1b}[31m\nname"),
|
||||
"client\\u{1b}[31m\\nname"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_never_and_unavailable_have_distinct_text() {
|
||||
assert_eq!(counter(&Observed::Available(0)), "0 bytes");
|
||||
assert_eq!(counter(&Observed::Unavailable), "unavailable");
|
||||
let now = time::OffsetDateTime::UNIX_EPOCH;
|
||||
assert!(
|
||||
statistics_lines(&RuntimePeer {
|
||||
allowed_ips: Observed::Unavailable,
|
||||
last_handshake: Observed::Available(None),
|
||||
received_bytes: Observed::Available(0),
|
||||
transmitted_bytes: Observed::Unavailable,
|
||||
})[0]
|
||||
.to_string()
|
||||
.contains("never")
|
||||
);
|
||||
assert!(now.unix_timestamp() == 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qr_fit_requires_the_complete_quiet_zone_in_both_dimensions() {
|
||||
assert!(qr_fits(25, 50, 25));
|
||||
assert!(!qr_fits(25, 49, 25));
|
||||
assert!(!qr_fits(25, 50, 24));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_rows_show_draft_state_and_escape_anomalous_unsupported_peers() {
|
||||
let identity = PublicKey::from_bytes([7; 32]);
|
||||
let saved = crate::domain::Client::new(
|
||||
identity,
|
||||
"old alias",
|
||||
crate::domain::ClientAddresses {
|
||||
ipv4: Ipv4Addr::new(10, 20, 0, 2),
|
||||
ipv6: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let draft =
|
||||
crate::domain::Client::new(identity, "new alias", saved.addresses.clone()).unwrap();
|
||||
let unsupported_key = PublicKey::from_bytes([8; 32]);
|
||||
let removed_key = PublicKey::from_bytes([9; 32]);
|
||||
let removed =
|
||||
crate::domain::Client::new(removed_key, "removed alias", saved.addresses.clone())
|
||||
.unwrap();
|
||||
let view = SessionView {
|
||||
generation: 1,
|
||||
clients: vec![
|
||||
ClientDisplay {
|
||||
peer_index: 0,
|
||||
line: 1,
|
||||
identity: Some(identity),
|
||||
label: "new alias".to_owned(),
|
||||
allowed_ips: vec!["10.20.0.2/32".to_owned()],
|
||||
issues: vec![],
|
||||
mutable_identity: Some(identity),
|
||||
},
|
||||
ClientDisplay {
|
||||
peer_index: 1,
|
||||
line: 5,
|
||||
identity: Some(unsupported_key),
|
||||
label: "odd\u{1b}[31m peer".to_owned(),
|
||||
allowed_ips: vec!["bad\nroute".to_owned()],
|
||||
issues: vec![crate::wireguard::PeerIssue::UnsupportedAllowedIpShape {
|
||||
field: 0,
|
||||
item: 1,
|
||||
}],
|
||||
mutable_identity: None,
|
||||
},
|
||||
],
|
||||
client_states: BTreeMap::from([
|
||||
(
|
||||
identity,
|
||||
super::super::state::ClientStateRow {
|
||||
saved: Some(saved),
|
||||
draft: Some(draft),
|
||||
},
|
||||
),
|
||||
(
|
||||
removed_key,
|
||||
super::super::state::ClientStateRow {
|
||||
saved: Some(removed),
|
||||
draft: None,
|
||||
},
|
||||
),
|
||||
]),
|
||||
document_issues: vec![],
|
||||
applied: super::super::state::AppliedState::Unknown,
|
||||
reserved_addresses: Default::default(),
|
||||
statistics: RuntimeObservation::unknown(),
|
||||
has_draft: true,
|
||||
vault_initialized: true,
|
||||
};
|
||||
let state = UiState {
|
||||
view: Some(view),
|
||||
..UiState::default()
|
||||
};
|
||||
let mut terminal = Terminal::new(TestBackend::new(220, 12)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| render_list(frame, frame.area(), &state))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
let rendered = (0..buffer.area.height)
|
||||
.map(|y| {
|
||||
(0..buffer.area.width)
|
||||
.map(|x| buffer[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(rendered.contains("Draft changed"));
|
||||
assert!(rendered.contains("inspection only"));
|
||||
assert!(rendered.contains("removed alias"));
|
||||
assert!(rendered.contains("Draft removed"));
|
||||
assert!(rendered.contains("UnsupportedAllowedIpShape"));
|
||||
assert!(rendered.contains("odd\\u{1b}[31m peer"));
|
||||
assert!(rendered.contains("bad\\nroute"));
|
||||
|
||||
let mut narrow = Terminal::new(TestBackend::new(34, 12)).unwrap();
|
||||
narrow
|
||||
.draw(|frame| render_list(frame, frame.area(), &state))
|
||||
.unwrap();
|
||||
let narrow_buffer = narrow.backend().buffer();
|
||||
let narrow_rendered = (0..narrow_buffer.area.height)
|
||||
.map(|y| {
|
||||
(0..narrow_buffer.area.width)
|
||||
.map(|x| narrow_buffer[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(narrow_rendered.contains("Draft changed"));
|
||||
assert!(narrow_rendered.contains("! anomaly"));
|
||||
assert!(narrow_rendered.contains("Draft removed"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
//! Secret-free navigation and session projections for the terminal interface.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::app::{Application, ClientDisplay};
|
||||
use crate::domain::{Client, PublicKey, RuntimeObservation};
|
||||
use crate::runtime::RuntimeObservation as Statistics;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Screen {
|
||||
ClientList,
|
||||
ClientDetails,
|
||||
Help,
|
||||
Progress,
|
||||
Message,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Focus {
|
||||
ClientList,
|
||||
Details,
|
||||
Dialog,
|
||||
Help,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum DialogKind {
|
||||
Rename,
|
||||
Delete,
|
||||
Create,
|
||||
Commit,
|
||||
Restart,
|
||||
Exit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum AppliedState {
|
||||
Unknown,
|
||||
Unavailable,
|
||||
Verified,
|
||||
}
|
||||
|
||||
/// Public client values for both states. No configuration bytes or secrets are
|
||||
/// retained in a view snapshot.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ClientStateRow {
|
||||
pub saved: Option<Client>,
|
||||
pub draft: Option<Client>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SessionView {
|
||||
pub generation: u64,
|
||||
pub clients: Vec<ClientDisplay>,
|
||||
pub client_states: std::collections::BTreeMap<PublicKey, ClientStateRow>,
|
||||
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
|
||||
pub applied: AppliedState,
|
||||
pub reserved_addresses: BTreeSet<IpAddr>,
|
||||
pub statistics: Statistics,
|
||||
pub has_draft: bool,
|
||||
pub vault_initialized: bool,
|
||||
}
|
||||
|
||||
impl SessionView {
|
||||
pub fn from_application(app: &Application, generation: u64) -> Self {
|
||||
let states = app.states();
|
||||
let mut identities = BTreeSet::new();
|
||||
identities.extend(states.saved().clients().map(|client| client.identity));
|
||||
identities.extend(states.draft().clients().map(|client| client.identity));
|
||||
let client_states = identities
|
||||
.into_iter()
|
||||
.map(|identity| {
|
||||
(
|
||||
identity,
|
||||
ClientStateRow {
|
||||
saved: states.saved().get(&identity).cloned(),
|
||||
draft: states.draft().get(&identity).cloned(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let applied = match states.applied() {
|
||||
RuntimeObservation::Unknown => AppliedState::Unknown,
|
||||
RuntimeObservation::Unavailable => AppliedState::Unavailable,
|
||||
RuntimeObservation::Verified => AppliedState::Verified,
|
||||
};
|
||||
let projection = app.display_projection();
|
||||
Self {
|
||||
generation,
|
||||
clients: projection.clients,
|
||||
client_states,
|
||||
document_issues: projection.document_issues,
|
||||
applied,
|
||||
reserved_addresses: states.reserved_addresses().clone(),
|
||||
statistics: Statistics::unknown(),
|
||||
has_draft: app.has_draft_changes(),
|
||||
vault_initialized: app.vault_initialized(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn contains_identity(&self, identity: &PublicKey) -> bool {
|
||||
self.clients
|
||||
.iter()
|
||||
.any(|client| client.identity == Some(*identity))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum OperationKind {
|
||||
CreateClient,
|
||||
RenameClient,
|
||||
DeleteClient,
|
||||
DiscardDraft,
|
||||
Commit,
|
||||
CreateBackup,
|
||||
ListBackups,
|
||||
ImportSelectedFile,
|
||||
InitialSetup,
|
||||
PreviewRestart,
|
||||
Restart,
|
||||
DeliverClient,
|
||||
Restore,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct PendingOperation {
|
||||
pub request_id: u64,
|
||||
pub kind: OperationKind,
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
/// Mutable UI navigation and latest accepted worker projection.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct UiState {
|
||||
pub screen: Screen,
|
||||
pub focus: Focus,
|
||||
pub dialog: Option<DialogKind>,
|
||||
pub selected_identity: Option<PublicKey>,
|
||||
/// Authoritative document index used only to inspect malformed peers that
|
||||
/// have no public key. Mutating actions must always use selected_identity.
|
||||
pub selected_peer_index: Option<usize>,
|
||||
pub scroll_offset: usize,
|
||||
pub generation: u64,
|
||||
pub view: Option<SessionView>,
|
||||
pub pending: Option<PendingOperation>,
|
||||
pub status_message: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
screen: Screen::ClientList,
|
||||
focus: Focus::ClientList,
|
||||
dialog: None,
|
||||
selected_identity: None,
|
||||
selected_peer_index: None,
|
||||
scroll_offset: 0,
|
||||
generation: 0,
|
||||
view: None,
|
||||
pending: None,
|
||||
status_message: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
/// Replace the view only when it belongs to the current generation. The
|
||||
/// selected full public key remains stable through sorting and refreshes.
|
||||
pub fn apply_view(&mut self, view: SessionView) -> bool {
|
||||
if view.generation != self.generation {
|
||||
return false;
|
||||
}
|
||||
if self
|
||||
.selected_identity
|
||||
.is_some_and(|identity| !view.contains_identity(&identity))
|
||||
{
|
||||
self.selected_identity = None;
|
||||
}
|
||||
if let Some(identity) = self.selected_identity {
|
||||
self.selected_peer_index = view
|
||||
.clients
|
||||
.iter()
|
||||
.find(|client| client.identity == Some(identity))
|
||||
.map(|client| client.peer_index);
|
||||
} else if self
|
||||
.selected_peer_index
|
||||
.is_some_and(|index| !view.clients.iter().any(|client| client.peer_index == index))
|
||||
{
|
||||
self.selected_peer_index = None;
|
||||
}
|
||||
self.view = Some(view);
|
||||
true
|
||||
}
|
||||
|
||||
/// Apply only statistics sampled from the still-current saved/draft view.
|
||||
/// This never changes the applied-state marker.
|
||||
pub fn apply_statistics(&mut self, generation: u64, statistics: Statistics) -> bool {
|
||||
if generation != self.generation {
|
||||
return false;
|
||||
}
|
||||
let Some(view) = self.view.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
if view.generation != generation {
|
||||
return false;
|
||||
}
|
||||
view.statistics = statistics;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn begin_operation(&mut self, operation: PendingOperation) -> bool {
|
||||
if self.pending.is_some() || operation.generation < self.generation {
|
||||
return false;
|
||||
}
|
||||
self.generation = operation.generation;
|
||||
self.pending = Some(operation);
|
||||
self.screen = Screen::Progress;
|
||||
true
|
||||
}
|
||||
|
||||
/// Close a form/dialog without cancelling work already submitted to the
|
||||
/// application worker. The pending progress state remains visible.
|
||||
pub fn cancel_dialog(&mut self) {
|
||||
self.dialog = None;
|
||||
if self.pending.is_some() {
|
||||
self.screen = Screen::Progress;
|
||||
} else {
|
||||
self.screen = if self.selected_identity.is_some() {
|
||||
Screen::ClientDetails
|
||||
} else {
|
||||
Screen::ClientList
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish_operation(&mut self, operation: PendingOperation) -> bool {
|
||||
if self.pending != Some(operation) {
|
||||
return false;
|
||||
}
|
||||
self.pending = None;
|
||||
self.screen = if self.dialog.is_some() {
|
||||
Screen::Message
|
||||
} else if self.selected_identity.is_some() {
|
||||
Screen::ClientDetails
|
||||
} else {
|
||||
Screen::ClientList
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
pub fn select(&mut self, identity: PublicKey) -> bool {
|
||||
if !self
|
||||
.view
|
||||
.as_ref()
|
||||
.is_some_and(|view| view.contains_identity(&identity))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.selected_identity = Some(identity);
|
||||
self.selected_peer_index = self.view.as_ref().and_then(|view| {
|
||||
view.clients
|
||||
.iter()
|
||||
.find(|client| client.identity == Some(identity))
|
||||
.map(|client| client.peer_index)
|
||||
});
|
||||
self.screen = Screen::ClientDetails;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn select_peer_index(&mut self, peer_index: usize) -> bool {
|
||||
let Some(client) = self.view.as_ref().and_then(|view| {
|
||||
view.clients
|
||||
.iter()
|
||||
.find(|client| client.peer_index == peer_index)
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
self.selected_peer_index = Some(peer_index);
|
||||
self.selected_identity = client.identity;
|
||||
self.screen = Screen::ClientDetails;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn selected_client_mutable(&self) -> bool {
|
||||
let Some(identity) = self.selected_identity else {
|
||||
return false;
|
||||
};
|
||||
self.view.as_ref().is_some_and(|view| {
|
||||
view.clients.iter().any(|client| {
|
||||
client.identity == Some(identity) && client.mutable_identity == Some(identity)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::PublicKey;
|
||||
use crate::wireguard::PeerIssue;
|
||||
|
||||
fn row(identity: PublicKey, label: &str, mutable: bool) -> ClientDisplay {
|
||||
ClientDisplay {
|
||||
peer_index: 0,
|
||||
line: 1,
|
||||
identity: Some(identity),
|
||||
label: label.to_owned(),
|
||||
allowed_ips: vec!["10.0.0.2/32".to_owned()],
|
||||
issues: if mutable {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![PeerIssue::MissingIpv4HostRoute]
|
||||
},
|
||||
mutable_identity: mutable.then_some(identity),
|
||||
}
|
||||
}
|
||||
|
||||
fn view(generation: u64, clients: Vec<ClientDisplay>) -> SessionView {
|
||||
SessionView {
|
||||
generation,
|
||||
clients,
|
||||
client_states: Default::default(),
|
||||
document_issues: Vec::new(),
|
||||
applied: AppliedState::Unknown,
|
||||
reserved_addresses: BTreeSet::new(),
|
||||
statistics: Statistics::unknown(),
|
||||
has_draft: false,
|
||||
vault_initialized: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_preserves_selection_by_public_key_not_alias_or_row_position() {
|
||||
let a = PublicKey::from_bytes([1; 32]);
|
||||
let b = PublicKey::from_bytes([2; 32]);
|
||||
let mut state = UiState::default();
|
||||
assert!(state.apply_view(view(
|
||||
0,
|
||||
vec![row(a, "same alias", true), row(b, "same alias", true)]
|
||||
)));
|
||||
assert!(state.select(b));
|
||||
assert!(state.apply_view(view(
|
||||
0,
|
||||
vec![row(b, "renamed", true), row(a, "same alias", true)]
|
||||
)));
|
||||
assert_eq!(state.selected_identity, Some(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_view_is_rejected_and_unsupported_selected_peer_is_not_mutable() {
|
||||
let key = PublicKey::from_bytes([3; 32]);
|
||||
let mut state = UiState::default();
|
||||
assert!(state.apply_view(view(0, vec![row(key, "legacy", false)])));
|
||||
assert!(state.select(key));
|
||||
assert!(!state.selected_client_mutable());
|
||||
state.generation = 1;
|
||||
assert!(!state.apply_view(view(0, vec![row(key, "stale", true)])));
|
||||
assert_eq!(state.selected_identity, Some(key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_peer_can_be_inspected_by_document_location_without_mutation_identity() {
|
||||
let mut malformed = row(PublicKey::from_bytes([8; 32]), "malformed", false);
|
||||
malformed.identity = None;
|
||||
malformed.mutable_identity = None;
|
||||
malformed.peer_index = 3;
|
||||
let mut state = UiState::default();
|
||||
assert!(state.apply_view(view(0, vec![malformed])));
|
||||
assert!(state.select_peer_index(3));
|
||||
assert_eq!(state.selected_identity, None);
|
||||
assert_eq!(state.selected_peer_index, Some(3));
|
||||
assert!(!state.selected_client_mutable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_a_dialog_does_not_claim_submitted_work_was_cancelled() {
|
||||
let mut state = UiState {
|
||||
dialog: Some(DialogKind::Commit),
|
||||
..UiState::default()
|
||||
};
|
||||
let operation = PendingOperation {
|
||||
request_id: 4,
|
||||
kind: OperationKind::Commit,
|
||||
generation: 1,
|
||||
};
|
||||
assert!(state.begin_operation(operation));
|
||||
state.cancel_dialog();
|
||||
assert_eq!(state.pending, Some(operation));
|
||||
assert_eq!(state.screen, Screen::Progress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn statistics_refresh_is_generation_bound_and_never_changes_applied_state() {
|
||||
let key = PublicKey::from_bytes([4; 32]);
|
||||
let mut state = UiState::default();
|
||||
assert!(state.apply_view(view(0, vec![row(key, "client", true)])));
|
||||
assert!(state.apply_statistics(0, Statistics::unknown()));
|
||||
assert_eq!(state.view.as_ref().unwrap().applied, AppliedState::Unknown);
|
||||
state.generation = 1;
|
||||
assert!(!state.apply_statistics(0, Statistics::unknown()));
|
||||
}
|
||||
}
|
||||
+1077
File diff suppressed because it is too large
Load Diff
+194
-2
@@ -1,6 +1,6 @@
|
||||
//! Bounded, read-only access to configured client source files.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Take};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -12,7 +12,7 @@ use thiserror::Error;
|
||||
use crate::config::ResolvedSettings;
|
||||
use crate::wireguard::ConfigDocument;
|
||||
|
||||
use super::import::{ImportReport, reconcile, reconcile_zeroizing};
|
||||
use super::import::{CandidateStatus, ImportReport, reconcile, reconcile_zeroizing};
|
||||
use super::{VaultBundle, VaultError};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
@@ -68,12 +68,97 @@ pub struct PreparedImportSetup {
|
||||
pub report: SourceImportReport,
|
||||
}
|
||||
|
||||
/// Secret-free result from importing one explicitly selected source entry.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SelectedFileImportReport {
|
||||
pub scan_complete: bool,
|
||||
pub issues: Vec<SourceIssue>,
|
||||
pub candidates: Vec<(String, CandidateStatus)>,
|
||||
pub server_only: Vec<crate::domain::PublicKey>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Eq, PartialEq)]
|
||||
pub enum SourceImportError {
|
||||
#[error("vault setup failed")]
|
||||
Vault(#[from] VaultError),
|
||||
#[error("source directory is invalid")]
|
||||
InvalidDirectory,
|
||||
#[error("selected source entry is invalid")]
|
||||
InvalidSelection,
|
||||
}
|
||||
|
||||
/// Read one explicitly selected immediate `*.conf` entry from the configured
|
||||
/// source directory, reconcile it against the server document, and encrypt it
|
||||
/// into the caller's draft bundle. The original entry is never modified.
|
||||
pub fn import_selected_file(
|
||||
bundle: &mut VaultBundle,
|
||||
settings: &ResolvedSettings,
|
||||
server: &ConfigDocument,
|
||||
selected_name: &OsStr,
|
||||
) -> Result<SelectedFileImportReport, SourceImportError> {
|
||||
if !is_candidate_name(selected_name)
|
||||
|| Path::new(selected_name).components().count() != 1
|
||||
|| selected_name == OsStr::new(".")
|
||||
|| selected_name == OsStr::new("..")
|
||||
{
|
||||
return Err(SourceImportError::InvalidSelection);
|
||||
}
|
||||
let display_name = safe_file_name(selected_name);
|
||||
let empty_report = || reconcile(server, []);
|
||||
let directory = match open_source_directory(&settings.client_source_directory) {
|
||||
Ok(directory) => directory,
|
||||
Err(_) => {
|
||||
return Ok(selected_failure_report(
|
||||
display_name,
|
||||
SourceFailureKind::DirectoryUnavailable,
|
||||
empty_report(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let bytes = match read_candidate(&directory, selected_name, 0, MAX_SCAN_BYTES) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(kind) => return Ok(selected_failure_report(display_name, kind, empty_report())),
|
||||
};
|
||||
let reconciliation = reconcile_zeroizing(server, [(display_name, bytes)]);
|
||||
let summaries = reconciliation.candidate_summaries();
|
||||
let mut issues = Vec::new();
|
||||
if let Some((name, status)) = summaries.first()
|
||||
&& !matches!(status, CandidateStatus::Invalid(_))
|
||||
&& let Err(error) = reconciliation.encrypt_candidate(bundle, 0)
|
||||
{
|
||||
issues.push(SourceIssue {
|
||||
file_name: Some((*name).to_owned()),
|
||||
kind: match error {
|
||||
VaultError::DuplicateRecord => SourceFailureKind::DuplicateRecordEqualityUnverified,
|
||||
_ => SourceFailureKind::EncryptionFailed,
|
||||
},
|
||||
});
|
||||
}
|
||||
Ok(SelectedFileImportReport {
|
||||
scan_complete: true,
|
||||
issues,
|
||||
candidates: summaries
|
||||
.into_iter()
|
||||
.map(|(name, status)| (name.to_owned(), status))
|
||||
.collect(),
|
||||
server_only: reconciliation.server_only().to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn selected_failure_report(
|
||||
display_name: String,
|
||||
kind: SourceFailureKind,
|
||||
reconciliation: ImportReport,
|
||||
) -> SelectedFileImportReport {
|
||||
SelectedFileImportReport {
|
||||
scan_complete: false,
|
||||
issues: vec![SourceIssue {
|
||||
file_name: Some(display_name),
|
||||
kind,
|
||||
}],
|
||||
candidates: Vec::new(),
|
||||
server_only: reconciliation.server_only().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new password-protected vault and encrypt valid candidates from the
|
||||
@@ -372,6 +457,113 @@ mod tests {
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
|
||||
fn resolved_settings(source: &Path) -> ResolvedSettings {
|
||||
crate::config::SettingsFile::parse_toml(
|
||||
r#"interface_name = "wg0"
|
||||
server_config_path = "wg0.conf"
|
||||
client_source_directory = "clients"
|
||||
server_endpoint = "vpn.example.org:51820"
|
||||
ipv4_allocation_range = "10.8.0.0/24"
|
||||
ipv6_allocation_range = "fd80::/64"
|
||||
dns_addresses = ["10.8.0.1"]
|
||||
systemd_unit = "wg-quick@wg0.service"
|
||||
restart_timeout_seconds = 10
|
||||
"#,
|
||||
)
|
||||
.unwrap()
|
||||
.validate()
|
||||
.unwrap()
|
||||
.resolve_paths(source)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_file_import_is_bounded_read_only_and_stages_encryption() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let source_directory = root.path().join("clients");
|
||||
fs::create_dir(&source_directory).unwrap();
|
||||
let server_key = WireGuardPrivateKey::generate().unwrap();
|
||||
let client_key = WireGuardPrivateKey::generate().unwrap();
|
||||
let server = ConfigDocument::parse(
|
||||
format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.1/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.8.0.2/32\n",
|
||||
server_key.to_base64(),
|
||||
client_key.public_key()
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
let original = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.2/32\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = vpn.example.org:51820\n",
|
||||
client_key.to_base64(),
|
||||
server_key.public_key()
|
||||
);
|
||||
let selected_path = source_directory.join("client.conf");
|
||||
fs::write(&selected_path, original.as_bytes()).unwrap();
|
||||
make_private(&selected_path);
|
||||
let mut bundle = VaultBundle::create("synthetic password").unwrap();
|
||||
|
||||
let report = import_selected_file(
|
||||
&mut bundle,
|
||||
&resolved_settings(root.path()),
|
||||
&server,
|
||||
OsStr::new("client.conf"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(report.scan_complete);
|
||||
assert!(report.issues.is_empty());
|
||||
assert_eq!(bundle.record_count(), 1);
|
||||
assert!(matches!(
|
||||
report.candidates[0].1,
|
||||
CandidateStatus::Matched {
|
||||
address_discrepancy: false,
|
||||
server_identity: super::super::import::ServerIdentityComparison::Matches,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert_eq!(fs::read(&selected_path).unwrap(), original.as_bytes());
|
||||
let duplicate = import_selected_file(
|
||||
&mut bundle,
|
||||
&resolved_settings(root.path()),
|
||||
&server,
|
||||
OsStr::new("client.conf"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
duplicate.issues[0].kind,
|
||||
SourceFailureKind::DuplicateRecordEqualityUnverified
|
||||
);
|
||||
assert_eq!(bundle.record_count(), 1);
|
||||
assert!(
|
||||
import_selected_file(
|
||||
&mut bundle,
|
||||
&resolved_settings(root.path()),
|
||||
&server,
|
||||
OsStr::new("../client.conf")
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(&selected_path, source_directory.join("linked.conf"))
|
||||
.unwrap();
|
||||
let link_report = import_selected_file(
|
||||
&mut bundle,
|
||||
&resolved_settings(root.path()),
|
||||
&server,
|
||||
OsStr::new("linked.conf"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!link_report.scan_complete);
|
||||
assert_eq!(
|
||||
link_report.issues[0].kind,
|
||||
SourceFailureKind::FileUnavailable
|
||||
);
|
||||
assert_eq!(bundle.record_count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_enumeration_error_is_visible_and_other_files_still_import() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,985 @@
|
||||
//! Synthetic end-to-end coverage for the serialized TUI application boundary.
|
||||
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use wireguard_admin::app::{FirstIssueAttempt, RestartChoice, RestartStage};
|
||||
use wireguard_admin::backup::BackupReason;
|
||||
use wireguard_admin::config::{ResolvedSettings, SettingsFile};
|
||||
use wireguard_admin::domain::{ClientStates, PublicKey, RuntimeObservation, Snapshot};
|
||||
use wireguard_admin::persistence::SavedStore;
|
||||
use wireguard_admin::runtime::{InterfaceObservation, RuntimeObservation as Statistics};
|
||||
use wireguard_admin::tui::events::{
|
||||
ApplicationWorker, CommandResult, FirstIssueStatus, QrStatus, SecretText, SensitiveCommand,
|
||||
ShutdownProgress, StatisticsReader, UiCommand, WorkerConfig, WorkerEvent,
|
||||
};
|
||||
use wireguard_admin::vault::WireGuardPrivateKey;
|
||||
|
||||
const SETTINGS: &str = 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 = "wg-quick@wg0.service"
|
||||
restart_timeout_seconds = 5
|
||||
"#;
|
||||
|
||||
struct Fixture {
|
||||
_root: tempfile::TempDir,
|
||||
settings: ResolvedSettings,
|
||||
store: SavedStore,
|
||||
server_identity: PublicKey,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let root = tempfile::tempdir().expect("synthetic fixture directory");
|
||||
let settings = SettingsFile::parse_toml(SETTINGS)
|
||||
.expect("settings parse")
|
||||
.validate()
|
||||
.expect("settings validate")
|
||||
.resolve_paths(root.path());
|
||||
fs::create_dir_all(&settings.client_source_directory).expect("source directory");
|
||||
fs::write(ResolvedSettings::settings_path(root.path()), SETTINGS).expect("settings file");
|
||||
let server_key = WireGuardPrivateKey::generate().expect("synthetic server key");
|
||||
let server_identity = server_key.public_key();
|
||||
fs::write(
|
||||
&settings.server_config_path,
|
||||
format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24, fd20::1/64\n\n# Fixture administrator note.\n",
|
||||
server_key.to_base64()
|
||||
),
|
||||
)
|
||||
.expect("server file");
|
||||
fs::set_permissions(
|
||||
&settings.server_config_path,
|
||||
fs::Permissions::from_mode(0o600),
|
||||
)
|
||||
.expect("private synthetic server file");
|
||||
let store = SavedStore::new(root.path(), settings.clone()).expect("saved store");
|
||||
Fixture {
|
||||
_root: root,
|
||||
settings,
|
||||
store,
|
||||
server_identity,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct CountingStatistics(Arc<AtomicUsize>);
|
||||
|
||||
impl StatisticsReader for CountingStatistics {
|
||||
fn read(&mut self, _interface: &str, _timeout: Duration) -> Statistics {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
Statistics {
|
||||
observed_at: None,
|
||||
interface: InterfaceObservation::Unavailable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn worker(fixture: &Fixture, reader: CountingStatistics) -> ApplicationWorker {
|
||||
let states = ClientStates::new(Snapshot::default(), None, RuntimeObservation::Unknown)
|
||||
.expect("empty states");
|
||||
let app = wireguard_admin::app::Application::open(fixture.store.clone(), states)
|
||||
.expect("application opens on synthetic files");
|
||||
ApplicationWorker::spawn(
|
||||
app,
|
||||
reader,
|
||||
WorkerConfig {
|
||||
refresh_interval: Duration::from_secs(60),
|
||||
read_timeout: Duration::from_millis(5),
|
||||
},
|
||||
)
|
||||
.expect("worker starts")
|
||||
}
|
||||
|
||||
fn wait_for(worker: &mut ApplicationWorker, request_id: u64) -> WorkerEvent {
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
while Instant::now() < deadline {
|
||||
if let Some(event) = worker.try_receive() {
|
||||
if matches!(&event, WorkerEvent::OperationFinished { operation, .. } if operation.request_id == request_id)
|
||||
{
|
||||
return event;
|
||||
}
|
||||
} else {
|
||||
thread::yield_now();
|
||||
}
|
||||
}
|
||||
panic!("worker did not finish request {request_id}");
|
||||
}
|
||||
|
||||
fn execute(worker: &mut ApplicationWorker, command: UiCommand) -> CommandResult {
|
||||
let operation = worker.submit(command).expect("command accepted");
|
||||
match wait_for(worker, operation.request_id) {
|
||||
WorkerEvent::OperationFinished { result, .. } => result.expect("operation succeeds"),
|
||||
_ => unreachable!("wait_for returns the requested completion"),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(mut worker: ApplicationWorker) {
|
||||
assert_eq!(
|
||||
worker.shutdown(Duration::from_secs(2)),
|
||||
ShutdownProgress::Stopped
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_pty_smoke() {
|
||||
const CHILD: &str = "WG_ADMIN_SYNTHETIC_PTY_CHILD";
|
||||
if std::env::var_os(CHILD).is_some() {
|
||||
let fixture = fixture();
|
||||
let states = ClientStates::new(Snapshot::default(), None, RuntimeObservation::Unknown)
|
||||
.expect("empty states");
|
||||
let app = wireguard_admin::app::Application::open(fixture.store, states)
|
||||
.expect("synthetic application opens");
|
||||
let resize = thread::spawn(|| {
|
||||
thread::sleep(Duration::from_millis(40));
|
||||
assert!(
|
||||
Command::new("stty")
|
||||
.args(["cols", "40", "rows", "8"])
|
||||
.status()
|
||||
.expect("shrink synthetic PTY")
|
||||
.success()
|
||||
);
|
||||
thread::sleep(Duration::from_millis(40));
|
||||
assert!(
|
||||
Command::new("stty")
|
||||
.args(["cols", "80", "rows", "24"])
|
||||
.status()
|
||||
.expect("grow synthetic PTY")
|
||||
.success()
|
||||
);
|
||||
});
|
||||
let result = wireguard_admin::tui::terminal::run_with_statistics(
|
||||
app,
|
||||
CountingStatistics::default(),
|
||||
WorkerConfig {
|
||||
refresh_interval: Duration::from_secs(60),
|
||||
read_timeout: Duration::from_millis(5),
|
||||
},
|
||||
);
|
||||
resize.join().expect("resize sequence completes");
|
||||
assert!(result.is_ok(), "synthetic terminal exits cleanly");
|
||||
return;
|
||||
}
|
||||
|
||||
if Command::new("script").arg("--version").output().is_err() {
|
||||
eprintln!("PTY smoke incomplete: `script` utility is unavailable");
|
||||
return;
|
||||
}
|
||||
let executable = std::env::current_exe().expect("test executable path");
|
||||
let command = format!(
|
||||
"stty cols 80 rows 24; env {CHILD}=1 {} --exact terminal_pty_smoke --nocapture",
|
||||
shell_quote(&executable.to_string_lossy())
|
||||
);
|
||||
let mut child = Command::new("script")
|
||||
.args(["-qefc", &command, "/dev/null"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("start isolated pseudo-terminal");
|
||||
use std::io::Write as _;
|
||||
let mut input = child.stdin.take().expect("script stdin");
|
||||
let exit_key = thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(180));
|
||||
input
|
||||
.write_all(b"\x03")
|
||||
.expect("send handled Ctrl-C interruption to synthetic TUI");
|
||||
});
|
||||
let output = child.wait_with_output().expect("collect PTY result");
|
||||
exit_key.join().expect("exit input completes");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"synthetic PTY child exits successfully"
|
||||
);
|
||||
let transcript = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
transcript.contains("\u{1b}[?1049h"),
|
||||
"alternate screen entered"
|
||||
);
|
||||
assert!(
|
||||
transcript.contains("\u{1b}[?1049l"),
|
||||
"alternate screen restored"
|
||||
);
|
||||
assert!(
|
||||
transcript.contains("\u{1b}[?25l"),
|
||||
"cursor hidden during TUI"
|
||||
);
|
||||
assert!(
|
||||
transcript.contains("\u{1b}[?25h"),
|
||||
"cursor restored after TUI"
|
||||
);
|
||||
}
|
||||
|
||||
fn shell_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
fn path(parent: &Path, name: &str) -> PathBuf {
|
||||
parent.join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_setup_create_commit_and_delivery_keep_saved_and_runtime_boundaries() {
|
||||
let fixture = fixture();
|
||||
let invalid_source = b"this fixture contains no valid client configuration\n";
|
||||
let source_path = fixture.settings.client_source_directory.join("broken.conf");
|
||||
fs::write(&source_path, invalid_source).expect("synthetic invalid source file");
|
||||
let reader = CountingStatistics::default();
|
||||
let reads = Arc::clone(&reader.0);
|
||||
let mut worker = worker(&fixture, reader);
|
||||
|
||||
let setup = worker
|
||||
.submit_sensitive(SensitiveCommand::PrepareInitialSetup(SecretText::new(
|
||||
"synthetic vault password".to_owned(),
|
||||
)))
|
||||
.expect("setup submitted");
|
||||
match wait_for(&mut worker, setup.request_id) {
|
||||
WorkerEvent::OperationFinished { result, view, .. } => {
|
||||
match result.unwrap() {
|
||||
CommandResult::InitialSetupPrepared(report) => {
|
||||
assert!(report.scan_complete);
|
||||
assert!(matches!(
|
||||
report.reconciliation.candidate_summaries()[0].1,
|
||||
wireguard_admin::vault::import::CandidateStatus::Invalid(_)
|
||||
));
|
||||
}
|
||||
_ => panic!("setup report returned"),
|
||||
}
|
||||
assert!(view.vault_initialized);
|
||||
assert!(view.document_issues.is_empty());
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
let created = execute(
|
||||
&mut worker,
|
||||
UiCommand::CreateClient {
|
||||
alias: "synthetic laptop".to_owned(),
|
||||
manual_addresses: None,
|
||||
created_utc: Some("2026-10-21T12:00:00Z".to_owned()),
|
||||
},
|
||||
);
|
||||
let identity = match created {
|
||||
CommandResult::ClientCreated(identity) => identity,
|
||||
_ => panic!("create returns identity"),
|
||||
};
|
||||
let renamed = execute(
|
||||
&mut worker,
|
||||
UiCommand::RenameClient {
|
||||
identity,
|
||||
alias: "renamed synthetic laptop".to_owned(),
|
||||
},
|
||||
);
|
||||
assert!(matches!(renamed, CommandResult::ClientRenamed));
|
||||
assert!(matches!(
|
||||
execute(&mut worker, UiCommand::Commit),
|
||||
CommandResult::Committed(_)
|
||||
));
|
||||
assert!(
|
||||
fs::read_to_string(&fixture.settings.server_config_path)
|
||||
.expect("saved server file")
|
||||
.contains("# Fixture administrator note.")
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&source_path).expect("original source preserved"),
|
||||
invalid_source
|
||||
);
|
||||
|
||||
let failed_parent = path(fixture._root.path(), "failed-output");
|
||||
fs::create_dir(&failed_parent).expect("failed output directory");
|
||||
let failed_path = path(&failed_parent, "client.conf");
|
||||
let prepared = execute(
|
||||
&mut worker,
|
||||
UiCommand::PrepareDelivery {
|
||||
identity,
|
||||
path: failed_path,
|
||||
include_qr: false,
|
||||
reissue: false,
|
||||
attempt: FirstIssueAttempt::Initial,
|
||||
},
|
||||
);
|
||||
let prepared_generation = match prepared {
|
||||
CommandResult::DeliveryDestination(summary) => summary.prepared_generation,
|
||||
_ => panic!("destination is prepared before publication"),
|
||||
};
|
||||
fs::set_permissions(&failed_parent, fs::Permissions::from_mode(0o500))
|
||||
.expect("make fixture destination unwritable");
|
||||
let failed = worker
|
||||
.submit(UiCommand::DeliverFirstIssue {
|
||||
attempt: FirstIssueAttempt::Initial,
|
||||
overwrite_confirmed: false,
|
||||
prepared_generation,
|
||||
})
|
||||
.expect("failed publication submitted");
|
||||
match wait_for(&mut worker, failed.request_id) {
|
||||
WorkerEvent::OperationFinished { result, .. } => match result.unwrap() {
|
||||
CommandResult::FirstIssue(summary) => {
|
||||
assert!(matches!(summary.status, FirstIssueStatus::Failed(_)));
|
||||
assert_eq!(
|
||||
summary.retry,
|
||||
Some(wireguard_admin::app::FirstIssueRetry::Available)
|
||||
);
|
||||
}
|
||||
_ => panic!("failure report returned"),
|
||||
},
|
||||
_ => unreachable!(),
|
||||
}
|
||||
fs::set_permissions(&failed_parent, fs::Permissions::from_mode(0o700))
|
||||
.expect("restore fixture destination permissions");
|
||||
|
||||
let output = path(fixture._root.path(), "delivered.conf");
|
||||
fs::write(&output, b"keep before overwrite confirmation").expect("synthetic existing output");
|
||||
let attempt = FirstIssueAttempt::Retry {
|
||||
confirm_possible_overwrite: false,
|
||||
confirm_manual_cleanup: false,
|
||||
};
|
||||
let prepared = execute(
|
||||
&mut worker,
|
||||
UiCommand::PrepareDelivery {
|
||||
identity,
|
||||
path: output.clone(),
|
||||
include_qr: true,
|
||||
reissue: false,
|
||||
attempt,
|
||||
},
|
||||
);
|
||||
let prepared_generation = match prepared {
|
||||
CommandResult::DeliveryDestination(summary) => {
|
||||
assert!(summary.overwrite_required);
|
||||
summary.prepared_generation
|
||||
}
|
||||
_ => panic!("overwrite target is prepared"),
|
||||
};
|
||||
let declined = worker
|
||||
.submit(UiCommand::DeliverFirstIssue {
|
||||
attempt,
|
||||
overwrite_confirmed: false,
|
||||
prepared_generation,
|
||||
})
|
||||
.expect("declined overwrite is handled by worker");
|
||||
assert!(matches!(
|
||||
wait_for(&mut worker, declined.request_id),
|
||||
WorkerEvent::OperationFinished { result: Err(_), .. }
|
||||
));
|
||||
assert_eq!(
|
||||
fs::read(&output).unwrap(),
|
||||
b"keep before overwrite confirmation"
|
||||
);
|
||||
|
||||
let attempt = FirstIssueAttempt::Retry {
|
||||
confirm_possible_overwrite: true,
|
||||
confirm_manual_cleanup: false,
|
||||
};
|
||||
let prepared = execute(
|
||||
&mut worker,
|
||||
UiCommand::PrepareDelivery {
|
||||
identity,
|
||||
path: output.clone(),
|
||||
include_qr: true,
|
||||
reissue: false,
|
||||
attempt,
|
||||
},
|
||||
);
|
||||
let prepared_generation = match prepared {
|
||||
CommandResult::DeliveryDestination(summary) => summary.prepared_generation,
|
||||
_ => panic!("confirmed overwrite target is prepared"),
|
||||
};
|
||||
let delivered = execute(
|
||||
&mut worker,
|
||||
UiCommand::DeliverFirstIssue {
|
||||
attempt,
|
||||
overwrite_confirmed: true,
|
||||
prepared_generation,
|
||||
},
|
||||
);
|
||||
match delivered {
|
||||
CommandResult::FirstIssue(summary) => {
|
||||
assert_eq!(summary.status, FirstIssueStatus::Delivered);
|
||||
assert!(summary.activation_warning);
|
||||
assert_eq!(summary.qr, QrStatus::Ready);
|
||||
let mut disclosure = wireguard_admin::tui::forms::FormController::default();
|
||||
disclosure.open_disclosure(
|
||||
summary.qr_output.expect("QR matrix returned"),
|
||||
"fixture".to_owned(),
|
||||
);
|
||||
assert!(disclosure.qr_output().is_some());
|
||||
assert!(matches!(
|
||||
disclosure.handle_key(crossterm::event::KeyCode::Esc),
|
||||
wireguard_admin::tui::forms::FormAction::Cancel
|
||||
));
|
||||
assert!(disclosure.qr_output().is_none(), "Esc clears the QR owner");
|
||||
}
|
||||
_ => panic!("delivery result returned"),
|
||||
}
|
||||
assert!(
|
||||
fs::read_to_string(output)
|
||||
.expect("delivered file")
|
||||
.contains("[Interface]")
|
||||
);
|
||||
let wrong_password_path = path(fixture._root.path(), "wrong-password.conf");
|
||||
let prepared = execute(
|
||||
&mut worker,
|
||||
UiCommand::PrepareDelivery {
|
||||
identity,
|
||||
path: wrong_password_path.clone(),
|
||||
include_qr: false,
|
||||
reissue: true,
|
||||
attempt: FirstIssueAttempt::Initial,
|
||||
},
|
||||
);
|
||||
let prepared_generation = match prepared {
|
||||
CommandResult::DeliveryDestination(summary) => summary.prepared_generation,
|
||||
_ => panic!("reissue destination is prepared"),
|
||||
};
|
||||
let wrong_password = "synthetic wrong vault password";
|
||||
let reissue = worker
|
||||
.submit_sensitive(SensitiveCommand::ReissueClient {
|
||||
identity,
|
||||
password: SecretText::new(wrong_password.to_owned()),
|
||||
overwrite_confirmed: false,
|
||||
prepared_generation,
|
||||
})
|
||||
.expect("reissue submitted");
|
||||
match wait_for(&mut worker, reissue.request_id) {
|
||||
WorkerEvent::OperationFinished { result, .. } => {
|
||||
let error = result.expect_err("wrong password rejected");
|
||||
assert!(!error.message.contains(wrong_password));
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(!wrong_password_path.exists());
|
||||
let reissued_path = path(fixture._root.path(), "reissued.conf");
|
||||
let prepared = execute(
|
||||
&mut worker,
|
||||
UiCommand::PrepareDelivery {
|
||||
identity,
|
||||
path: reissued_path.clone(),
|
||||
include_qr: false,
|
||||
reissue: true,
|
||||
attempt: FirstIssueAttempt::Initial,
|
||||
},
|
||||
);
|
||||
let prepared_generation = match prepared {
|
||||
CommandResult::DeliveryDestination(summary) => summary.prepared_generation,
|
||||
_ => panic!("authorized reissue destination prepared"),
|
||||
};
|
||||
let reissue = worker
|
||||
.submit_sensitive(SensitiveCommand::ReissueClient {
|
||||
identity,
|
||||
password: SecretText::new("synthetic vault password".to_owned()),
|
||||
overwrite_confirmed: false,
|
||||
prepared_generation,
|
||||
})
|
||||
.expect("password-authorized reissue submitted");
|
||||
match wait_for(&mut worker, reissue.request_id) {
|
||||
WorkerEvent::OperationFinished { result, .. } => match result.unwrap() {
|
||||
CommandResult::Reissued(summary) => {
|
||||
assert!(summary.activation_warning);
|
||||
assert_eq!(summary.qr, QrStatus::NotRequested);
|
||||
}
|
||||
_ => panic!("reissue result returned"),
|
||||
},
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(reissued_path.exists());
|
||||
|
||||
let deletion = worker
|
||||
.submit(UiCommand::DeleteClient { identity })
|
||||
.expect("delete selected full-key identity");
|
||||
match wait_for(&mut worker, deletion.request_id) {
|
||||
WorkerEvent::OperationFinished { result, view, .. } => {
|
||||
assert!(matches!(
|
||||
result.unwrap(),
|
||||
CommandResult::ClientDeleted { .. }
|
||||
));
|
||||
let row = &view.client_states[&identity];
|
||||
assert!(row.saved.is_some(), "saved peer remains until Commit");
|
||||
assert!(row.draft.is_none());
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let discarded = worker
|
||||
.submit(UiCommand::DiscardDraft)
|
||||
.expect("discard after draft deletion");
|
||||
match wait_for(&mut worker, discarded.request_id) {
|
||||
WorkerEvent::OperationFinished { result, view, .. } => {
|
||||
assert!(matches!(result.unwrap(), CommandResult::DraftDiscarded));
|
||||
let row = &view.client_states[&identity];
|
||||
assert!(row.saved.is_some() && row.draft.is_some());
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert_eq!(
|
||||
reads.load(Ordering::SeqCst),
|
||||
0,
|
||||
"no runtime adapter is called by Commit or delivery"
|
||||
);
|
||||
finish(worker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_backup_restore_preview_cancel_and_confirm_are_separate_saved_operations() {
|
||||
let fixture = fixture();
|
||||
let mut worker = worker(&fixture, CountingStatistics::default());
|
||||
let setup = worker
|
||||
.submit_sensitive(SensitiveCommand::PrepareInitialSetup(SecretText::new(
|
||||
"synthetic vault password".to_owned(),
|
||||
)))
|
||||
.expect("setup submitted");
|
||||
let _ = wait_for(&mut worker, setup.request_id);
|
||||
assert!(matches!(
|
||||
execute(&mut worker, UiCommand::Commit),
|
||||
CommandResult::Committed(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
execute(&mut worker, UiCommand::CreateBackup),
|
||||
CommandResult::BackupCreated(_)
|
||||
));
|
||||
let backups = execute(&mut worker, UiCommand::ListBackups);
|
||||
let archive_name = match backups {
|
||||
CommandResult::Backups(entries) => {
|
||||
let entry = entries
|
||||
.iter()
|
||||
.find(|entry| entry.reason == BackupReason::Manual)
|
||||
.expect("manual archive listed with its reason");
|
||||
assert!(!entry.file_name.is_empty());
|
||||
entry.file_name.clone()
|
||||
}
|
||||
_ => panic!("backup list returned"),
|
||||
};
|
||||
let created = execute(
|
||||
&mut worker,
|
||||
UiCommand::CreateClient {
|
||||
alias: "first-issue capability to invalidate".to_owned(),
|
||||
manual_addresses: None,
|
||||
created_utc: None,
|
||||
},
|
||||
);
|
||||
let issued_identity = match created {
|
||||
CommandResult::ClientCreated(identity) => identity,
|
||||
_ => panic!("client created for restore invalidation test"),
|
||||
};
|
||||
assert!(matches!(
|
||||
execute(&mut worker, UiCommand::Commit),
|
||||
CommandResult::Committed(_)
|
||||
));
|
||||
|
||||
let preview = execute(
|
||||
&mut worker,
|
||||
UiCommand::PreviewRestore {
|
||||
file_name: archive_name.clone(),
|
||||
},
|
||||
);
|
||||
let preview_generation = match preview {
|
||||
CommandResult::RestorePreview(summary) => {
|
||||
assert_eq!(summary.file_name, archive_name);
|
||||
assert_eq!(summary.reason, BackupReason::Manual);
|
||||
summary.preview_generation
|
||||
}
|
||||
_ => panic!("restore preview returned"),
|
||||
};
|
||||
assert!(matches!(
|
||||
execute(&mut worker, UiCommand::CancelRestorePreview),
|
||||
CommandResult::Cancelled
|
||||
));
|
||||
// Cancellation clears the worker-owned preview; confirmation cannot use it.
|
||||
let confirmation = worker
|
||||
.submit(UiCommand::ConfirmRestore { preview_generation })
|
||||
.expect("confirmation is handled and rejected by worker");
|
||||
assert!(matches!(
|
||||
wait_for(&mut worker, confirmation.request_id),
|
||||
WorkerEvent::OperationFinished { result: Err(_), .. }
|
||||
));
|
||||
|
||||
let preview = execute(
|
||||
&mut worker,
|
||||
UiCommand::PreviewRestore {
|
||||
file_name: archive_name.clone(),
|
||||
},
|
||||
);
|
||||
let preview_generation = match preview {
|
||||
CommandResult::RestorePreview(summary) => summary.preview_generation,
|
||||
_ => panic!("restore preview returned"),
|
||||
};
|
||||
let saved_before_restore = fs::read(&fixture.settings.server_config_path).unwrap();
|
||||
let archived_directory = fixture
|
||||
.settings
|
||||
.data_directory
|
||||
.join("archives-before-restore-failure");
|
||||
fs::rename(&fixture.settings.archives_directory, &archived_directory)
|
||||
.expect("hold valid archive directory aside");
|
||||
fs::write(&fixture.settings.archives_directory, b"not a directory")
|
||||
.expect("block precautionary restore backup");
|
||||
let restore = worker
|
||||
.submit(UiCommand::ConfirmRestore { preview_generation })
|
||||
.expect("restore confirmation reaches worker");
|
||||
match wait_for(&mut worker, restore.request_id) {
|
||||
WorkerEvent::OperationFinished { result, view, .. } => {
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"precautionary backup failure blocks replacement"
|
||||
);
|
||||
assert!(!view.has_draft);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
fs::remove_file(&fixture.settings.archives_directory).expect("remove blocker file");
|
||||
fs::rename(&archived_directory, &fixture.settings.archives_directory)
|
||||
.expect("restore valid archive directory");
|
||||
assert_eq!(
|
||||
fs::read(&fixture.settings.server_config_path).unwrap(),
|
||||
saved_before_restore
|
||||
);
|
||||
|
||||
let preview = execute(
|
||||
&mut worker,
|
||||
UiCommand::PreviewRestore {
|
||||
file_name: archive_name,
|
||||
},
|
||||
);
|
||||
let preview_generation = match preview {
|
||||
CommandResult::RestorePreview(summary) => summary.preview_generation,
|
||||
_ => panic!("restore preview returned after backup failure"),
|
||||
};
|
||||
let restore = worker
|
||||
.submit(UiCommand::ConfirmRestore { preview_generation })
|
||||
.expect("confirmed restore accepted");
|
||||
match wait_for(&mut worker, restore.request_id) {
|
||||
WorkerEvent::OperationFinished { result, view, .. } => {
|
||||
assert!(matches!(result.unwrap(), CommandResult::Restored(_)));
|
||||
assert_eq!(
|
||||
view.applied,
|
||||
wireguard_admin::tui::state::AppliedState::Unknown
|
||||
);
|
||||
assert!(!view.has_draft);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let output = path(fixture._root.path(), "restored-client.conf");
|
||||
let prepared = execute(
|
||||
&mut worker,
|
||||
UiCommand::PrepareDelivery {
|
||||
identity: issued_identity,
|
||||
path: output,
|
||||
include_qr: false,
|
||||
reissue: false,
|
||||
attempt: FirstIssueAttempt::Initial,
|
||||
},
|
||||
);
|
||||
let prepared_generation = match prepared {
|
||||
CommandResult::DeliveryDestination(summary) => summary.prepared_generation,
|
||||
_ => panic!("destination preparation is separate from issue authorization"),
|
||||
};
|
||||
let invalidated = worker
|
||||
.submit(UiCommand::DeliverFirstIssue {
|
||||
attempt: FirstIssueAttempt::Initial,
|
||||
overwrite_confirmed: false,
|
||||
prepared_generation,
|
||||
})
|
||||
.expect("delivery denial returned through worker");
|
||||
assert!(matches!(
|
||||
wait_for(&mut worker, invalidated.request_id),
|
||||
WorkerEvent::OperationFinished { result: Err(_), .. }
|
||||
));
|
||||
finish(worker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_commit_stops_at_mandatory_backup_failure_and_keeps_draft_and_saved_bytes() {
|
||||
let fixture = fixture();
|
||||
let mut worker = worker(&fixture, CountingStatistics::default());
|
||||
let setup = worker
|
||||
.submit_sensitive(SensitiveCommand::PrepareInitialSetup(SecretText::new(
|
||||
"synthetic vault password".to_owned(),
|
||||
)))
|
||||
.expect("setup submitted");
|
||||
let _ = wait_for(&mut worker, setup.request_id);
|
||||
assert!(matches!(
|
||||
execute(&mut worker, UiCommand::Commit),
|
||||
CommandResult::Committed(_)
|
||||
));
|
||||
let original = fs::read(&fixture.settings.server_config_path).expect("saved server bytes");
|
||||
let created = execute(
|
||||
&mut worker,
|
||||
UiCommand::CreateClient {
|
||||
alias: "draft only".to_owned(),
|
||||
manual_addresses: None,
|
||||
created_utc: None,
|
||||
},
|
||||
);
|
||||
assert!(matches!(created, CommandResult::ClientCreated(_)));
|
||||
let archived_directory = fixture
|
||||
.settings
|
||||
.data_directory
|
||||
.join("archives-before-commit-failure");
|
||||
fs::rename(&fixture.settings.archives_directory, &archived_directory)
|
||||
.expect("hold valid archive directory aside");
|
||||
fs::write(&fixture.settings.archives_directory, b"not a directory")
|
||||
.expect("block mandatory archive publication");
|
||||
let commit = worker
|
||||
.submit(UiCommand::Commit)
|
||||
.expect("Commit dispatch accepts operation");
|
||||
match wait_for(&mut worker, commit.request_id) {
|
||||
WorkerEvent::OperationFinished { result, view, .. } => {
|
||||
assert!(result.is_err(), "backup error reaches the worker result");
|
||||
assert!(view.has_draft, "draft survives failed Commit");
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
fs::remove_file(&fixture.settings.archives_directory).expect("remove blocker file");
|
||||
fs::rename(&archived_directory, &fixture.settings.archives_directory)
|
||||
.expect("restore valid archive directory");
|
||||
assert_eq!(
|
||||
fs::read(&fixture.settings.server_config_path).expect("saved server bytes remain"),
|
||||
original
|
||||
);
|
||||
assert!(matches!(
|
||||
execute(&mut worker, UiCommand::PreviewRestart),
|
||||
CommandResult::RestartPreview(summary) if summary.has_draft
|
||||
));
|
||||
match execute(&mut worker, UiCommand::Restart(RestartChoice::Cancel)) {
|
||||
CommandResult::Restarted(report) => {
|
||||
assert_eq!(report.stage, RestartStage::Cancelled);
|
||||
assert!(!report.restart_submitted);
|
||||
}
|
||||
_ => panic!("restart cancel report returned"),
|
||||
}
|
||||
finish(worker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_restore_refreshes_operational_allocation_settings_before_next_create() {
|
||||
let fixture = fixture();
|
||||
let states = ClientStates::new(Snapshot::default(), None, RuntimeObservation::Unknown)
|
||||
.expect("empty states");
|
||||
let mut original_app = wireguard_admin::app::Application::open(fixture.store.clone(), states)
|
||||
.expect("original application opens");
|
||||
original_app
|
||||
.prepare_initial_setup_from_sources("synthetic vault password")
|
||||
.expect("initial vault setup");
|
||||
original_app
|
||||
.commit()
|
||||
.expect("initial saved state committed");
|
||||
|
||||
let changed_settings_text = SETTINGS
|
||||
.replace("vpn.example.test:51820", "vpn2.example.test:51821")
|
||||
.replace("10.20.0.0/24", "10.30.0.0/24")
|
||||
.replace("fd20::/64", "fd30::/64");
|
||||
fs::write(
|
||||
ResolvedSettings::settings_path(fixture._root.path()),
|
||||
&changed_settings_text,
|
||||
)
|
||||
.expect("install changed operational fixture settings");
|
||||
let changed_settings = SettingsFile::parse_toml(&changed_settings_text)
|
||||
.expect("changed settings parse")
|
||||
.validate()
|
||||
.expect("changed settings validate")
|
||||
.resolve_paths(fixture._root.path());
|
||||
let changed_store = SavedStore::new(fixture._root.path(), changed_settings)
|
||||
.expect("changed settings preserve trusted paths");
|
||||
let changed_app = wireguard_admin::app::Application::open_from_saved_store(changed_store)
|
||||
.expect("changed settings app opens");
|
||||
let changed_archive = changed_app
|
||||
.create_manual_backup()
|
||||
.expect("archive changed operational settings");
|
||||
let changed_archive_name = changed_archive
|
||||
.file_name()
|
||||
.expect("archive filename")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
fs::write(
|
||||
ResolvedSettings::settings_path(fixture._root.path()),
|
||||
SETTINGS,
|
||||
)
|
||||
.expect("restore original current settings before worker session");
|
||||
let mut worker = worker(&fixture, CountingStatistics::default());
|
||||
let preview = execute(
|
||||
&mut worker,
|
||||
UiCommand::PreviewRestore {
|
||||
file_name: changed_archive_name,
|
||||
},
|
||||
);
|
||||
match preview {
|
||||
CommandResult::RestorePreview(summary) => {
|
||||
assert!(
|
||||
summary
|
||||
.changes
|
||||
.settings_fields_changed
|
||||
.contains(&"server endpoint")
|
||||
);
|
||||
assert!(
|
||||
summary
|
||||
.changes
|
||||
.settings_fields_changed
|
||||
.contains(&"IPv4 allocation range")
|
||||
);
|
||||
}
|
||||
_ => panic!("worker returns changed-settings restore preview"),
|
||||
}
|
||||
let restored = worker
|
||||
.submit(UiCommand::ConfirmRestore {
|
||||
preview_generation: worker.current_generation(),
|
||||
})
|
||||
.expect("restore confirmation submitted");
|
||||
assert!(matches!(
|
||||
wait_for(&mut worker, restored.request_id),
|
||||
WorkerEvent::OperationFinished {
|
||||
result: Ok(CommandResult::Restored(_)),
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
let create = worker
|
||||
.submit(UiCommand::CreateClient {
|
||||
alias: "uses restored allocation".to_owned(),
|
||||
manual_addresses: None,
|
||||
created_utc: None,
|
||||
})
|
||||
.expect("create after restore submitted");
|
||||
match wait_for(&mut worker, create.request_id) {
|
||||
WorkerEvent::OperationFinished { result, view, .. } => {
|
||||
let identity = match result.unwrap() {
|
||||
CommandResult::ClientCreated(identity) => identity,
|
||||
_ => panic!("client creation result returned"),
|
||||
};
|
||||
let client = view.client_states[&identity]
|
||||
.draft
|
||||
.as_ref()
|
||||
.expect("client is staged in draft");
|
||||
assert!(client.addresses.ipv4.to_string().starts_with("10.30."));
|
||||
assert!(
|
||||
client
|
||||
.addresses
|
||||
.ipv6
|
||||
.expect("dual-stack client IPv6")
|
||||
.to_string()
|
||||
.starts_with("fd30:")
|
||||
);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
finish(worker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_setup_reconciliation_keeps_match_mismatch_server_only_file_only_and_invalid_visible() {
|
||||
use wireguard_admin::vault::import::{CandidateStatus, ServerIdentityComparison};
|
||||
|
||||
let fixture = fixture();
|
||||
let matched_secret = WireGuardPrivateKey::generate().expect("matched client key");
|
||||
let mismatch_secret = WireGuardPrivateKey::generate().expect("mismatched client key");
|
||||
let file_only_secret = WireGuardPrivateKey::generate().expect("file-only client key");
|
||||
let wrong_server = WireGuardPrivateKey::generate().expect("mismatch fixture key");
|
||||
let server_only = WireGuardPrivateKey::generate().expect("server-only public identity");
|
||||
let matched = matched_secret.public_key();
|
||||
let mismatch = mismatch_secret.public_key();
|
||||
let server_only_identity = server_only.public_key();
|
||||
let server_text = format!(
|
||||
"{}\n[Peer]\nPublicKey = {matched}\nAllowedIPs = 10.20.0.2/32\n\n[Peer]\nPublicKey = {mismatch}\nAllowedIPs = 10.20.0.3/32\n\n[Peer]\nPublicKey = {server_only_identity}\nAllowedIPs = 10.20.0.5/32\n",
|
||||
fs::read_to_string(&fixture.settings.server_config_path).unwrap()
|
||||
);
|
||||
fs::write(&fixture.settings.server_config_path, server_text).expect("server peer fixtures");
|
||||
let source_files = [
|
||||
(
|
||||
"matched.conf",
|
||||
format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = vpn.example.test:51820\n",
|
||||
matched_secret.to_base64(),
|
||||
fixture.server_identity
|
||||
),
|
||||
),
|
||||
(
|
||||
"mismatch.conf",
|
||||
format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.3/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = vpn.example.test:51820\n",
|
||||
mismatch_secret.to_base64(),
|
||||
wrong_server.public_key()
|
||||
),
|
||||
),
|
||||
(
|
||||
"file-only.conf",
|
||||
format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.4/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = vpn.example.test:51820\n",
|
||||
file_only_secret.to_base64(),
|
||||
fixture.server_identity
|
||||
),
|
||||
),
|
||||
("invalid.conf", "PrivateKey = malformed\n".to_owned()),
|
||||
];
|
||||
let originals = source_files
|
||||
.iter()
|
||||
.map(|(name, text)| {
|
||||
let path = fixture.settings.client_source_directory.join(name);
|
||||
fs::write(&path, text).expect("synthetic client source");
|
||||
(path, text.as_bytes().to_vec())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut worker = worker(&fixture, CountingStatistics::default());
|
||||
let setup = worker
|
||||
.submit_sensitive(SensitiveCommand::PrepareInitialSetup(SecretText::new(
|
||||
"synthetic vault password".to_owned(),
|
||||
)))
|
||||
.expect("setup submitted");
|
||||
match wait_for(&mut worker, setup.request_id) {
|
||||
WorkerEvent::OperationFinished { result, .. } => match result.unwrap() {
|
||||
CommandResult::InitialSetupPrepared(report) => {
|
||||
let statuses = report
|
||||
.reconciliation
|
||||
.candidate_summaries()
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
assert!(matches!(
|
||||
statuses.get("matched.conf"),
|
||||
Some(CandidateStatus::Matched {
|
||||
server_identity: ServerIdentityComparison::Matches,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
statuses.get("mismatch.conf"),
|
||||
Some(CandidateStatus::Matched {
|
||||
server_identity: ServerIdentityComparison::Mismatch,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
statuses.get("file-only.conf"),
|
||||
Some(CandidateStatus::FileOnly)
|
||||
));
|
||||
assert!(matches!(
|
||||
statuses.get("invalid.conf"),
|
||||
Some(CandidateStatus::Invalid(_))
|
||||
));
|
||||
assert!(
|
||||
report
|
||||
.reconciliation
|
||||
.server_only()
|
||||
.contains(&server_only_identity)
|
||||
);
|
||||
}
|
||||
_ => panic!("setup reconciliation returned"),
|
||||
},
|
||||
_ => unreachable!(),
|
||||
}
|
||||
for (path, expected) in originals {
|
||||
assert_eq!(
|
||||
fs::read(path).unwrap(),
|
||||
expected,
|
||||
"source files are read-only"
|
||||
);
|
||||
}
|
||||
finish(worker);
|
||||
}
|
||||
Reference in New Issue
Block a user