Complete terminal management workflow

This commit is contained in:
2026-10-21 23:25:31 +03:00
parent f718f739b4
commit 2ccd67d64c
19 changed files with 7313 additions and 53 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+403 -6
View File
@@ -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
+69 -20
View File
@@ -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
View File
@@ -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
+42
View File
@@ -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
View File
@@ -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(&current.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(
&current.settings,
&self.saved_bytes.settings,
),
added_clients: proposed_peers.difference(&current_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");
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1065
View File
File diff suppressed because it is too large Load Diff
+690
View File
@@ -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"));
}
}
+403
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+194 -2
View File
@@ -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();
+985
View File
@@ -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);
}