diff --git a/AGENTS.md b/AGENTS.md index f651d70..13b1ec6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index 1439370..ec6f2e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 62973ee..7c75d90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/PLANS.md b/PLANS.md index 2d5507f..2433b03 100644 --- a/PLANS.md +++ b/PLANS.md @@ -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 diff --git a/README.md b/README.md index b527fc9..0c0f93a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/PROJECT_LAYOUT.md b/docs/PROJECT_LAYOUT.md index 7a55104..87b20b6 100644 --- a/docs/PROJECT_LAYOUT.md +++ b/docs/PROJECT_LAYOUT.md @@ -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 diff --git a/docs/TESTING.md b/docs/TESTING.md index dc888ca..81f5d8c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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. diff --git a/src/app.rs b/src/app.rs index 3b48b29..b659440 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, + pub label: String, + pub allowed_ips: Vec, + pub issues: Vec, + pub mutable_identity: Option, +} + +/// Secret-free display projection of the current document and client state. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SessionDisplay { + pub clients: Vec, + pub document_issues: Vec, +} + /// 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::>(), + ) + }) + }) + .collect::>() + }) + .unwrap_or_default() + }; + let current_peer_map = peer_map(¤t.server_config); + let proposed_peer_map = peer_map(&self.saved_bytes.server_config); + let current_peers = current_peer_map.keys().copied().collect::>(); + let proposed_peers = proposed_peer_map.keys().copied().collect::>(); + let changed_clients = current_peers + .intersection(&proposed_peers) + .filter(|key| current_peer_map.get(key) != proposed_peer_map.get(key)) + .copied() + .collect(); + RestoreChangeSummary { + settings_changed: current.settings != self.saved_bytes.settings, + settings_fields_changed: settings_fields_changed( + ¤t.settings, + &self.saved_bytes.settings, + ), + added_clients: proposed_peers.difference(¤t_peers).copied().collect(), + removed_clients: current_peers.difference(&proposed_peers).copied().collect(), + changed_clients, + server_config_changed: current.server_config != self.saved_bytes.server_config, + vault_changed: current.vault != self.saved_bytes.vault, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RestoreChangeSummary { + pub settings_changed: bool, + pub settings_fields_changed: Vec<&'static str>, + pub server_config_changed: bool, + pub vault_changed: bool, + pub added_clients: Vec, + pub removed_clients: Vec, + pub changed_clients: Vec, +} + +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 { + pub fn open(store: SavedStore, states: ClientStates) -> Result { 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 { + 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 { 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::()), + ) + }); + 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 { + 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 { + 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"); diff --git a/src/lib.rs b/src/lib.rs index 78f67ab..8beb663 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index f6a5329..03a4e42 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 = 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()) +} diff --git a/src/startup.rs b/src/startup.rs index 27a6085..d1ca2fd 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -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 { + 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(); + } } diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..5652439 --- /dev/null +++ b/src/tui.rs @@ -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}; diff --git a/src/tui/events.rs b/src/tui/events.rs new file mode 100644 index 0000000..0b16c30 --- /dev/null +++ b/src/tui/events.rs @@ -0,0 +1,1430 @@ +//! Typed commands and a single-owner worker for application operations. + +use std::collections::VecDeque; +use std::ffi::OsString; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use crate::app::Application; +use crate::app::{ + FirstIssueAttempt, FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartReport, + RestorePreview, +}; +use crate::backup::{ArchiveSummary, BackupReason, HistoricalApplyStatus}; +use crate::config::SettingsFile; +use crate::delivery::files::{FileOutputError, PreparedDestination}; +use crate::delivery::{QrCodeOutput, QrError}; +use crate::domain::{ClientAddresses, PublicKey}; +use crate::runtime::RuntimeObservation; +use crate::vault::source::SelectedFileImportReport; +use crate::vault::source::SourceImportReport; + +use super::state::{OperationKind, PendingOperation, SessionView}; + +pub const EVENT_QUEUE_CAPACITY: usize = 16; +pub const COMMAND_QUEUE_CAPACITY: usize = 4; +pub const DEFAULT_REFRESH_INTERVAL: Duration = Duration::from_secs(2); +pub const DEFAULT_READ_TIMEOUT: Duration = Duration::from_millis(500); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum UiCommand { + CreateClient { + alias: String, + manual_addresses: Option, + created_utc: Option, + }, + RenameClient { + identity: PublicKey, + alias: String, + }, + DeleteClient { + identity: PublicKey, + }, + DiscardDraft, + Commit, + CreateBackup, + ListBackups, + ImportSelectedFile { + file_name: OsString, + }, + PreviewRestart, + Restart(RestartChoice), + PrepareDelivery { + identity: PublicKey, + path: PathBuf, + include_qr: bool, + reissue: bool, + attempt: FirstIssueAttempt, + }, + DeliverFirstIssue { + attempt: FirstIssueAttempt, + overwrite_confirmed: bool, + prepared_generation: u64, + }, + PreviewRestore { + file_name: String, + }, + CancelPreparedDelivery, + CancelRestorePreview, + ConfirmRestore { + preview_generation: u64, + }, +} + +/// Secret-bearing text is carried only by a non-Debug, non-Clone owner and +/// zeroized when the worker finishes or drops the queued request. +pub struct SecretText(zeroize::Zeroizing); + +impl SecretText { + pub fn new(value: String) -> Self { + Self(zeroize::Zeroizing::new(value)) + } + + pub fn expose(&self) -> &str { + self.0.as_str() + } +} + +pub enum SensitiveCommand { + PrepareInitialSetup(SecretText), + ReissueClient { + identity: PublicKey, + password: SecretText, + overwrite_confirmed: bool, + prepared_generation: u64, + }, +} + +impl UiCommand { + pub const fn kind(&self) -> OperationKind { + match self { + Self::CreateClient { .. } => OperationKind::CreateClient, + Self::RenameClient { .. } => OperationKind::RenameClient, + Self::DeleteClient { .. } => OperationKind::DeleteClient, + Self::DiscardDraft => OperationKind::DiscardDraft, + Self::Commit => OperationKind::Commit, + Self::CreateBackup => OperationKind::CreateBackup, + Self::ListBackups => OperationKind::ListBackups, + Self::ImportSelectedFile { .. } => OperationKind::ImportSelectedFile, + Self::PreviewRestart => OperationKind::PreviewRestart, + Self::Restart(_) => OperationKind::Restart, + Self::PrepareDelivery { .. } | Self::DeliverFirstIssue { .. } => { + OperationKind::DeliverClient + } + Self::PreviewRestore { .. } + | Self::CancelRestorePreview + | Self::ConfirmRestore { .. } => OperationKind::Restore, + Self::CancelPreparedDelivery => OperationKind::DeliverClient, + } + } + + const fn changes_view(&self) -> bool { + matches!( + self, + Self::CreateClient { .. } + | Self::RenameClient { .. } + | Self::DeleteClient { .. } + | Self::DiscardDraft + | Self::Commit + | Self::ImportSelectedFile { .. } + | Self::Restart(_) + | Self::ConfirmRestore { .. } + ) + } +} + +impl SensitiveCommand { + const fn kind(&self) -> OperationKind { + match self { + Self::PrepareInitialSetup(_) => OperationKind::InitialSetup, + Self::ReissueClient { .. } => OperationKind::DeliverClient, + } + } + const fn changes_view(&self) -> bool { + matches!(self, Self::PrepareInitialSetup(_)) + } +} + +#[derive(Debug)] +pub enum CommandResult { + ClientCreated(PublicKey), + ClientRenamed, + ClientDeleted { record_removed: bool }, + DraftDiscarded, + Committed(PathBuf), + BackupCreated(PathBuf), + Backups(Vec), + SelectedFileImported(SelectedFileImportReport), + InitialSetupPrepared(SourceImportReport), + RestartPreview(RestartPreviewSummary), + Restarted(RestartReport), + DeliveryDestination(DeliveryDestinationSummary), + FirstIssue(FirstIssueSummary), + Reissued(ReissueSummary), + RestorePreview(RestorePreviewSummary), + Restored(PathBuf), + Cancelled, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RestartPreviewSummary { + pub has_draft: bool, + pub interface: String, + pub systemd_unit: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeliveryDestinationSummary { + pub identity: PublicKey, + pub path: PathBuf, + pub overwrite_required: bool, + pub include_qr: bool, + pub reissue: bool, + pub attempt: FirstIssueAttempt, + pub prepared_generation: u64, +} + +#[derive(Debug)] +pub struct FirstIssueSummary { + pub identity: PublicKey, + pub status: FirstIssueStatus, + pub retry: Option, + pub activation_warning: bool, + pub qr: QrStatus, + pub qr_output: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FirstIssueStatus { + Delivered, + PublishedButBookkeepingUncertain, + Failed(FileOutputError), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum QrStatus { + NotRequested, + Ready, + Failed(QrError), +} + +#[derive(Debug)] +pub struct ReissueSummary { + pub activation_warning: bool, + pub qr: QrStatus, + pub qr_output: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RestorePreviewSummary { + pub file_name: String, + pub created_at_utc: String, + pub reason: BackupReason, + pub historical_apply: HistoricalApplyStatus, + pub changes: crate::app::RestoreChangeSummary, + pub preview_generation: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OperationError { + pub message: String, +} + +#[derive(Debug)] +pub enum WorkerEvent { + SessionReady(SessionView), + OperationStarted(PendingOperation), + OperationFinished { + operation: PendingOperation, + result: Result, + view: Box, + }, + Statistics { + generation: u64, + observation: RuntimeObservation, + }, + ShutdownComplete, +} + +/// The UI never shares its Application with the input/render thread. Tests may +/// inject a delayed/failing executor; production uses `ApplicationExecutor`. +pub trait OperationExecutor: Send + 'static { + fn execute( + &mut self, + app: &mut Application, + command: UiCommand, + ) -> Result; + + fn execute_sensitive( + &mut self, + _app: &mut Application, + _command: SensitiveCommand, + ) -> Result { + Err(OperationError { + message: "sensitive operation is unavailable".to_owned(), + }) + } + + fn execute_at( + &mut self, + app: &mut Application, + command: UiCommand, + _generation: u64, + ) -> Result { + self.execute(app, command) + } + fn execute_sensitive_at( + &mut self, + app: &mut Application, + command: SensitiveCommand, + _generation: u64, + ) -> Result { + self.execute_sensitive(app, command) + } +} + +#[derive(Default)] +pub struct ApplicationExecutor { + prepared_destination: Option<( + PublicKey, + bool, + bool, + FirstIssueAttempt, + u64, + PreparedDestination, + )>, + restore_preview: Option<(String, u64, RestorePreview)>, +} + +impl OperationExecutor for ApplicationExecutor { + fn execute( + &mut self, + app: &mut Application, + command: UiCommand, + ) -> Result { + self.execute_at(app, command, 0) + } + + fn execute_at( + &mut self, + app: &mut Application, + command: UiCommand, + generation: u64, + ) -> Result { + let delivery_continuation = matches!(command, UiCommand::DeliverFirstIssue { .. }); + let restore_continuation = matches!( + command, + UiCommand::ConfirmRestore { .. } | UiCommand::CancelRestorePreview + ); + if !delivery_continuation && !matches!(&command, UiCommand::CancelPreparedDelivery) { + self.prepared_destination = None; + } + if !restore_continuation && !matches!(&command, UiCommand::PreviewRestore { .. }) { + self.restore_preview = None; + } + let result = match command { + UiCommand::CreateClient { + alias, + manual_addresses, + created_utc, + } => app + .prepare_new_client(&alias, manual_addresses, created_utc.as_deref()) + .map(CommandResult::ClientCreated), + UiCommand::RenameClient { identity, alias } => app + .rename_client(identity, &alias) + .map(|()| CommandResult::ClientRenamed), + UiCommand::DeleteClient { identity } => app + .delete_client(identity) + .map(|record_removed| CommandResult::ClientDeleted { record_removed }), + UiCommand::DiscardDraft => app.discard_draft().map(|()| CommandResult::DraftDiscarded), + UiCommand::Commit => app.commit().map(CommandResult::Committed), + UiCommand::CreateBackup => app.create_manual_backup().map(CommandResult::BackupCreated), + UiCommand::ListBackups => app.list_backups().map(CommandResult::Backups), + UiCommand::ImportSelectedFile { file_name } => app + .import_selected_file(&file_name) + .map(CommandResult::SelectedFileImported), + UiCommand::PreviewRestart => app.preview_restart().map(|preview| { + CommandResult::RestartPreview(RestartPreviewSummary { + has_draft: preview.has_draft(), + interface: preview.interface().to_owned(), + systemd_unit: preview.systemd_unit().to_owned(), + }) + }), + UiCommand::Restart(choice) => { + let result = app + .preview_restart() + .map(|preview| app.restart(preview, choice)); + result.map(CommandResult::Restarted) + } + UiCommand::PrepareDelivery { + identity, + path, + include_qr, + reissue, + attempt, + } => app + .prepare_first_issue_destination(path) + .map_err(crate::app::AppError::FileOutput) + .map(|destination| { + let summary = DeliveryDestinationSummary { + identity, + path: destination.path().to_path_buf(), + overwrite_required: destination.overwrite_confirmation_required(), + include_qr, + reissue, + attempt, + prepared_generation: generation, + }; + self.prepared_destination = Some(( + identity, + include_qr, + reissue, + attempt, + generation, + destination, + )); + CommandResult::DeliveryDestination(summary) + }), + UiCommand::DeliverFirstIssue { + attempt, + overwrite_confirmed, + prepared_generation, + } => { + let ( + identity, + include_qr, + reissue, + prepared_attempt, + destination_generation, + mut destination, + ) = self + .prepared_destination + .take() + .ok_or_else(|| OperationError { + message: "No prepared output destination is available.".to_owned(), + })?; + if reissue + || prepared_attempt != attempt + || !destination_generation_matches( + destination_generation, + prepared_generation, + generation, + ) + { + return Err(OperationError { + message: "Prepared output destination is stale; select it again." + .to_owned(), + }); + } + if destination.overwrite_confirmation_required() { + if !overwrite_confirmed { + return Err(OperationError { + message: "Overwrite confirmation was not supplied.".to_owned(), + }); + } + destination = destination.confirm_overwrite().map_err(operation_error)?; + } + if include_qr { + app.deliver_first_issue_file_and_qr(identity, attempt, destination) + .map(|outcome| match outcome { + FirstIssueDeliveryOutcome::Delivered(delivery) => { + let (qr, qr_output) = qr_result(delivery.qr); + CommandResult::FirstIssue(FirstIssueSummary { + identity, + status: FirstIssueStatus::Delivered, + retry: None, + activation_warning: delivery.activation_warning, + qr, + qr_output, + }) + } + FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain { + value, + .. + } => { + let (qr, qr_output) = qr_result(value.qr); + CommandResult::FirstIssue(FirstIssueSummary { + identity, + status: FirstIssueStatus::PublishedButBookkeepingUncertain, + retry: None, + activation_warning: value.activation_warning, + qr, + qr_output, + }) + } + FirstIssueDeliveryOutcome::Failed { + error, + retry, + activation_warning, + } => CommandResult::FirstIssue(FirstIssueSummary { + identity, + status: FirstIssueStatus::Failed(error), + retry: Some(retry), + activation_warning, + qr: QrStatus::NotRequested, + qr_output: None, + }), + }) + } else { + app.deliver_first_issue_file(identity, attempt, destination) + .map(|outcome| match outcome { + FirstIssueDeliveryOutcome::Delivered(delivery) => { + CommandResult::FirstIssue(FirstIssueSummary { + identity, + status: FirstIssueStatus::Delivered, + retry: None, + activation_warning: delivery.activation_warning, + qr: QrStatus::NotRequested, + qr_output: None, + }) + } + FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain { + value, + .. + } => CommandResult::FirstIssue(FirstIssueSummary { + identity, + status: FirstIssueStatus::PublishedButBookkeepingUncertain, + retry: None, + activation_warning: value.activation_warning, + qr: QrStatus::NotRequested, + qr_output: None, + }), + FirstIssueDeliveryOutcome::Failed { + error, + retry, + activation_warning, + } => CommandResult::FirstIssue(FirstIssueSummary { + identity, + status: FirstIssueStatus::Failed(error), + retry: Some(retry), + activation_warning, + qr: QrStatus::NotRequested, + qr_output: None, + }), + }) + } + } + UiCommand::PreviewRestore { file_name } => { + app.preview_restore(&file_name).map(|preview| { + let changes = preview.change_summary(app.saved_bytes()); + let summary = RestorePreviewSummary { + file_name: file_name.clone(), + created_at_utc: preview.created_at_utc().to_owned(), + reason: preview.reason(), + historical_apply: preview.historical_apply().clone(), + changes, + preview_generation: generation, + }; + self.restore_preview = Some((file_name, generation, preview)); + CommandResult::RestorePreview(summary) + }) + } + UiCommand::CancelPreparedDelivery => { + self.prepared_destination = None; + Ok(CommandResult::Cancelled) + } + UiCommand::CancelRestorePreview => { + self.restore_preview = None; + Ok(CommandResult::Cancelled) + } + UiCommand::ConfirmRestore { preview_generation } => { + let (_, stored_generation, preview) = + self.restore_preview.take().ok_or_else(|| OperationError { + message: + "Restore preview is no longer available; select the archive again." + .to_owned(), + })?; + if !restore_preview_generation_matches( + stored_generation, + preview_generation, + generation, + ) { + return Err(OperationError { + message: "Restore preview is stale; select the archive again.".to_owned(), + }); + } + app.confirm_restore(preview).map(CommandResult::Restored) + } + }; + result.map_err(|error| OperationError { + message: error.to_string(), + }) + } + + fn execute_sensitive( + &mut self, + app: &mut Application, + command: SensitiveCommand, + ) -> Result { + match command { + SensitiveCommand::PrepareInitialSetup(password) => app + .prepare_initial_setup_from_sources(password.expose()) + .map(CommandResult::InitialSetupPrepared) + .map_err(|error| OperationError { + message: error.to_string(), + }), + SensitiveCommand::ReissueClient { + identity, + password, + overwrite_confirmed, + prepared_generation, + } => { + let ( + prepared_identity, + _include_qr, + reissue, + _attempt, + destination_generation, + mut destination, + ) = self + .prepared_destination + .take() + .ok_or_else(|| OperationError { + message: "No prepared output destination is available.".to_owned(), + })?; + if prepared_identity != identity + || !reissue + || destination_generation != prepared_generation + { + return Err(OperationError { + message: "Prepared output destination is stale; select it again." + .to_owned(), + }); + } + if destination.overwrite_confirmation_required() { + if !overwrite_confirmed { + return Err(OperationError { + message: "Overwrite confirmation was not supplied.".to_owned(), + }); + } + destination = destination.confirm_overwrite().map_err(operation_error)?; + } + let delivery = app + .reissue_client_file(identity, password.expose(), destination, _include_qr) + .map_err(|error| OperationError { + message: error.to_string(), + })?; + let (qr, qr_output) = match delivery.qr { + None => (QrStatus::NotRequested, None), + Some(result) => qr_result(result), + }; + Ok(CommandResult::Reissued(ReissueSummary { + activation_warning: delivery.activation_warning, + qr, + qr_output, + })) + } + } + } + + fn execute_sensitive_at( + &mut self, + app: &mut Application, + command: SensitiveCommand, + generation: u64, + ) -> Result { + match &command { + SensitiveCommand::ReissueClient { + prepared_generation, + .. + } if *prepared_generation != generation => { + return Err(OperationError { + message: "Prepared output destination is stale; select it again.".to_owned(), + }); + } + _ => {} + } + if matches!(command, SensitiveCommand::PrepareInitialSetup(_)) { + self.prepared_destination = None; + self.restore_preview = None; + } + self.execute_sensitive(app, command) + } +} + +fn operation_error(error: impl std::fmt::Display) -> OperationError { + OperationError { + message: error.to_string(), + } +} + +fn destination_generation_matches(stored: u64, requested: u64, current: u64) -> bool { + stored == requested && stored == current +} + +fn restore_preview_generation_matches(stored: u64, requested: u64, current: u64) -> bool { + stored == requested && current == requested.saturating_add(1) +} + +fn qr_result(result: Result) -> (QrStatus, Option) { + match result { + Ok(output) => (QrStatus::Ready, Some(output)), + Err(error) => (QrStatus::Failed(error), None), + } +} + +pub trait StatisticsReader: Send + 'static { + /// Implementations must return within `timeout`; unavailable runtime data + /// is an observation, not a failure or inferred offline status. + fn read(&mut self, interface: &str, timeout: Duration) -> RuntimeObservation; +} + +pub struct UnavailableStatistics; + +impl StatisticsReader for UnavailableStatistics { + fn read(&mut self, _interface: &str, _timeout: Duration) -> RuntimeObservation { + RuntimeObservation { + observed_at: None, + interface: crate::runtime::InterfaceObservation::Unavailable, + } + } +} + +/// Read-only Linux WireGuard observation adapter. Failures remain unavailable +/// observations and never alter the application's applied-state marker. +pub struct LinuxStatisticsReader; + +impl StatisticsReader for LinuxStatisticsReader { + fn read(&mut self, interface: &str, timeout: Duration) -> RuntimeObservation { + match crate::runtime::transport::observe_interface( + interface, + timeout, + crate::runtime::netlink::DecodeLimits::default(), + ) { + Ok(observation) => observation, + Err(crate::runtime::transport::TransportError::NoDevice) => RuntimeObservation { + observed_at: None, + interface: crate::runtime::InterfaceObservation::Missing, + }, + Err(_) => RuntimeObservation { + observed_at: None, + interface: crate::runtime::InterfaceObservation::Unavailable, + }, + } + } +} + +pub struct WorkerConfig { + pub refresh_interval: Duration, + pub read_timeout: Duration, +} + +impl Default for WorkerConfig { + fn default() -> Self { + Self { + refresh_interval: DEFAULT_REFRESH_INTERVAL, + read_timeout: DEFAULT_READ_TIMEOUT, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SubmitError { + Busy, + QueueFull, + ShuttingDown, + RequestIdsExhausted, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ShutdownProgress { + Pending, + Stopped, + Disconnected, +} + +enum WorkerMessage { + Execute(PendingOperation, WorkerCommand), + Refresh, + Shutdown, +} + +enum WorkerCommand { + Public(UiCommand), + Sensitive(SensitiveCommand), +} + +/// Handle stays live while bounded shutdown is pending. Call `shutdown` again +/// to collect the final operation result and join only after its completion +/// event; never discard the handle while a protected operation is submitted. +#[must_use = "retain the worker and keep rendering progress until shutdown completes"] +pub struct ApplicationWorker { + sender: SyncSender, + events: Receiver, + pending_events: VecDeque, + busy: Arc, + stop_requested: Arc, + shutting_down: bool, + shutdown_complete: bool, + next_request_id: AtomicU64, + generation: Arc, + refresh_pending: Arc, + join: Option>, +} + +impl ApplicationWorker { + pub fn spawn( + app: Application, + statistics: impl StatisticsReader, + config: WorkerConfig, + ) -> std::io::Result { + Self::spawn_with_executor(app, ApplicationExecutor::default(), statistics, config) + } + + pub fn spawn_with_executor( + app: Application, + executor: impl OperationExecutor, + statistics: impl StatisticsReader, + config: WorkerConfig, + ) -> std::io::Result { + let (sender, receiver) = mpsc::sync_channel(COMMAND_QUEUE_CAPACITY); + let (event_sender, events) = mpsc::sync_channel(EVENT_QUEUE_CAPACITY); + let busy = Arc::new(AtomicBool::new(false)); + let worker_busy = Arc::clone(&busy); + let stop_requested = Arc::new(AtomicBool::new(false)); + let worker_stop_requested = Arc::clone(&stop_requested); + let generation = Arc::new(AtomicU64::new(0)); + let worker_generation = Arc::clone(&generation); + let refresh_pending = Arc::new(AtomicBool::new(false)); + let worker_refresh_pending = Arc::clone(&refresh_pending); + let join = thread::Builder::new() + .name("wireguard-admin-app".to_owned()) + .spawn(move || { + run_worker( + app, + WorkerLoop { + executor: Box::new(executor), + statistics: Box::new(statistics), + receiver, + events: event_sender, + busy: worker_busy, + stop_requested: worker_stop_requested, + generation: worker_generation, + refresh_pending: worker_refresh_pending, + config, + }, + ); + })?; + Ok(Self { + sender, + events, + pending_events: VecDeque::with_capacity(EVENT_QUEUE_CAPACITY), + busy, + stop_requested, + shutting_down: false, + shutdown_complete: false, + next_request_id: AtomicU64::new(1), + generation, + refresh_pending, + join: Some(join), + }) + } + + pub fn submit(&self, command: UiCommand) -> Result { + let kind = command.kind(); + let changes_view = command.changes_view(); + self.submit_request(WorkerCommand::Public(command), kind, changes_view) + } + + pub fn submit_sensitive( + &self, + command: SensitiveCommand, + ) -> Result { + let kind = command.kind(); + let changes_view = command.changes_view(); + self.submit_request(WorkerCommand::Sensitive(command), kind, changes_view) + } + + fn submit_request( + &self, + command: WorkerCommand, + kind: OperationKind, + changes_view: bool, + ) -> Result { + if self.shutting_down { + return Err(SubmitError::ShuttingDown); + } + if self + .busy + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err(SubmitError::Busy); + } + let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed); + if request_id == u64::MAX { + self.busy.store(false, Ordering::Release); + return Err(SubmitError::RequestIdsExhausted); + } + let generation = if changes_view { + self.generation + .fetch_add(1, Ordering::AcqRel) + .saturating_add(1) + } else { + self.generation.load(Ordering::Acquire) + }; + let operation = PendingOperation { + request_id, + kind, + generation, + }; + match self + .sender + .try_send(WorkerMessage::Execute(operation, command)) + { + Ok(()) => Ok(operation), + Err(TrySendError::Full(_)) => { + self.busy.store(false, Ordering::Release); + Err(SubmitError::QueueFull) + } + Err(TrySendError::Disconnected(_)) => { + self.busy.store(false, Ordering::Release); + Err(SubmitError::ShuttingDown) + } + } + } + + /// Coalesce refresh requests. Periodic refresh also runs at the configured + /// interval; neither path can queue an unbounded number of samples. + pub fn request_statistics(&self) -> Result<(), SubmitError> { + if self.shutting_down { + return Err(SubmitError::ShuttingDown); + } + if self.refresh_pending.swap(true, Ordering::AcqRel) { + return Ok(()); + } + match self.sender.try_send(WorkerMessage::Refresh) { + Ok(()) => Ok(()), + Err(TrySendError::Full(_)) => Ok(()), + Err(TrySendError::Disconnected(_)) => Err(SubmitError::ShuttingDown), + } + } + + pub fn try_receive(&mut self) -> Option { + self.pending_events + .pop_front() + .or_else(|| self.events.try_recv().ok()) + } + + pub fn current_generation(&self) -> u64 { + self.generation.load(Ordering::Acquire) + } + + /// Request shutdown and wait only for the supplied bound. `Pending` means + /// an accepted operation may still be saving or restarting; keep the UI and + /// this handle alive and call again to observe its result. + pub fn shutdown(&mut self, timeout: Duration) -> ShutdownProgress { + if self.shutdown_complete { + return ShutdownProgress::Stopped; + } + if !self.shutting_down { + self.shutting_down = true; + self.stop_requested.store(true, Ordering::Release); + // The atomic flag wakes the worker's drain logic even when a full + // bounded queue cannot accept a shutdown message. + let _ = self.sender.try_send(WorkerMessage::Shutdown); + } + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return ShutdownProgress::Pending; + } + match self.events.recv_timeout(remaining) { + Ok(WorkerEvent::ShutdownComplete) => { + self.shutdown_complete = true; + if let Some(join) = self.join.take() { + let _ = join.join(); + } + return ShutdownProgress::Stopped; + } + Ok(event) => self.retain_pending_event(event), + Err(RecvTimeoutError::Timeout) => return ShutdownProgress::Pending, + Err(RecvTimeoutError::Disconnected) => return ShutdownProgress::Disconnected, + } + } + } + + fn retain_pending_event(&mut self, event: WorkerEvent) { + if self.pending_events.len() < EVENT_QUEUE_CAPACITY { + self.pending_events.push_back(event); + return; + } + if let Some(index) = self + .pending_events + .iter() + .position(|queued| matches!(queued, WorkerEvent::Statistics { .. })) + { + self.pending_events.remove(index); + self.pending_events.push_back(event); + } + } +} + +struct WorkerLoop { + executor: Box, + statistics: Box, + receiver: Receiver, + events: SyncSender, + busy: Arc, + stop_requested: Arc, + generation: Arc, + refresh_pending: Arc, + config: WorkerConfig, +} + +fn run_worker(mut app: Application, worker: WorkerLoop) { + let WorkerLoop { + mut executor, + mut statistics, + receiver, + events, + busy, + stop_requested, + generation, + refresh_pending, + config, + } = worker; + let mut next_refresh = Instant::now() + config.refresh_interval; + let initial_view = SessionView::from_application(&app, generation.load(Ordering::Acquire)); + if events + .send(WorkerEvent::SessionReady(initial_view)) + .is_err() + { + return; + } + loop { + let now = Instant::now(); + let until_refresh = next_refresh.saturating_duration_since(now); + let message = if stop_requested.load(Ordering::Acquire) { + match receiver.try_recv() { + Ok(message) => Some(message), + Err(mpsc::TryRecvError::Empty | mpsc::TryRecvError::Disconnected) => break, + } + } else if refresh_pending.swap(false, Ordering::AcqRel) || until_refresh.is_zero() { + None + } else { + match receiver.recv_timeout(until_refresh) { + Ok(message) => Some(message), + Err(RecvTimeoutError::Timeout) => None, + Err(RecvTimeoutError::Disconnected) => break, + } + }; + match message { + Some(WorkerMessage::Execute(operation, command)) => { + let _ = events.send(WorkerEvent::OperationStarted(operation)); + let result = match command { + WorkerCommand::Public(command) => { + executor.execute_at(&mut app, command, operation.generation) + } + WorkerCommand::Sensitive(command) => { + executor.execute_sensitive_at(&mut app, command, operation.generation) + } + }; + let view = SessionView::from_application(&app, operation.generation); + if events + .send(WorkerEvent::OperationFinished { + operation, + result, + view: Box::new(view), + }) + .is_err() + { + break; + } + busy.store(false, Ordering::Release); + } + Some(WorkerMessage::Refresh) => {} + Some(WorkerMessage::Shutdown) => break, + None => { + let before = generation.load(Ordering::Acquire); + let interface = std::str::from_utf8(&app.saved_bytes().settings) + .ok() + .and_then(|text| SettingsFile::parse_toml(text).ok()) + .and_then(|settings| settings.validate().ok()) + .map(|settings| settings.interface_name); + let observation = interface.map_or_else(RuntimeObservation::unknown, |interface| { + statistics.read(&interface, config.read_timeout) + }); + let after = generation.load(Ordering::Acquire); + if before == after { + let _ = events.try_send(WorkerEvent::Statistics { + generation: after, + observation, + }); + } + next_refresh = Instant::now() + config.refresh_interval; + } + } + } + let _ = events.send(WorkerEvent::ShutdownComplete); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::net::{Ipv4Addr, Ipv6Addr}; + use std::os::unix::fs::PermissionsExt; + use std::sync::atomic::AtomicUsize; + use std::sync::mpsc::{self, Receiver}; + + use crate::config::SettingsFile; + use crate::domain::Client; + use crate::persistence::SavedStore; + use crate::vault::WireGuardPrivateKey; + + #[test] + fn prepared_delivery_and_restore_confirmations_are_bound_to_the_reviewed_generation() { + assert!(destination_generation_matches(8, 8, 8)); + assert!(!destination_generation_matches(8, 9, 8)); + assert!(!destination_generation_matches(8, 8, 9)); + assert!(restore_preview_generation_matches(8, 8, 9)); + assert!(!restore_preview_generation_matches(7, 8, 9)); + assert!(!restore_preview_generation_matches(8, 8, 8)); + } + + struct FakeStatistics { + started: Option>, + release: Option>, + calls: Arc, + } + + impl StatisticsReader for FakeStatistics { + fn read(&mut self, _interface: &str, _timeout: Duration) -> RuntimeObservation { + self.calls.fetch_add(1, Ordering::SeqCst); + if let Some(started) = self.started.take() { + let _ = started.send(()); + } + if let Some(release) = self.release.take() { + let _ = release.recv(); + } + RuntimeObservation::unknown() + } + } + + struct FakeExecutor { + started: mpsc::SyncSender<()>, + release: Receiver<()>, + calls: Arc, + } + + impl OperationExecutor for FakeExecutor { + fn execute( + &mut self, + _app: &mut Application, + command: UiCommand, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + let _ = self.started.send(()); + let _ = self.release.recv(); + if matches!(command, UiCommand::Commit) { + Err(OperationError { + message: "synthetic commit failure".to_owned(), + }) + } else { + Ok(CommandResult::DraftDiscarded) + } + } + } + + fn application() -> (tempfile::TempDir, Application) { + let root = tempfile::tempdir().unwrap(); + let settings_text = 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 +"#; + let settings = SettingsFile::parse_toml(settings_text) + .unwrap() + .validate() + .unwrap() + .resolve_paths(root.path()); + fs::write( + root.path().join(crate::config::SETTINGS_FILE_NAME), + settings_text, + ) + .unwrap(); + let server_key = WireGuardPrivateKey::generate().unwrap(); + let client_key = WireGuardPrivateKey::generate().unwrap(); + let client = Client::new( + client_key.public_key(), + "fixture", + ClientAddresses { + ipv4: Ipv4Addr::new(10, 8, 0, 2), + ipv6: Some("fd80::2".parse::().unwrap()), + }, + ) + .unwrap(); + let server = format!( + "[Interface]\nPrivateKey = {}\nAddress = 10.8.0.1/24, fd80::1/64\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.8.0.2/32, fd80::2/128\n", + server_key.to_base64(), + client.identity + ); + let server_path = root.path().join("wg0.conf"); + fs::write(&server_path, server).unwrap(); + fs::set_permissions(&server_path, fs::Permissions::from_mode(0o600)).unwrap(); + let store = SavedStore::new(root.path(), settings).unwrap(); + let app = Application::open_from_saved_store(store).unwrap(); + (root, app) + } + + #[test] + fn command_execution_is_serialized_and_delayed_work_keeps_ui_submit_nonblocking() { + let (started_tx, started_rx) = mpsc::sync_channel(1); + let (release_tx, release_rx) = mpsc::sync_channel(1); + let calls = Arc::new(AtomicUsize::new(0)); + let executor = FakeExecutor { + started: started_tx, + release: release_rx, + calls: Arc::clone(&calls), + }; + let (_root, app) = application(); + let mut worker = ApplicationWorker::spawn_with_executor( + app, + executor, + UnavailableStatistics, + WorkerConfig { + refresh_interval: Duration::from_secs(60), + read_timeout: Duration::from_millis(20), + }, + ) + .unwrap(); + let operation = worker.submit(UiCommand::Commit).unwrap(); + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!( + worker.submit(UiCommand::DiscardDraft), + Err(SubmitError::Busy) + ); + let started = Instant::now(); + assert!(worker.try_receive().is_some()); + assert!(started.elapsed() < Duration::from_millis(100)); + release_tx.send(()).unwrap(); + let deadline = Instant::now() + Duration::from_secs(1); + let mut finished = false; + while Instant::now() < deadline { + if let Some(WorkerEvent::OperationFinished { + operation: actual, + result, + .. + }) = worker.try_receive() + { + assert_eq!(actual, operation); + assert_eq!(result.unwrap_err().message, "synthetic commit failure"); + finished = true; + break; + } + thread::yield_now(); + } + assert!(finished); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!( + worker.shutdown(Duration::from_secs(1)), + ShutdownProgress::Stopped + ); + } + + #[test] + fn shutdown_timeout_reports_pending_until_submitted_operation_finishes() { + let (started_tx, started_rx) = mpsc::sync_channel(1); + let (release_tx, release_rx) = mpsc::sync_channel(1); + let executor = FakeExecutor { + started: started_tx, + release: release_rx, + calls: Arc::new(AtomicUsize::new(0)), + }; + let (_root, app) = application(); + let mut worker = ApplicationWorker::spawn_with_executor( + app, + executor, + UnavailableStatistics, + WorkerConfig { + refresh_interval: Duration::from_secs(60), + read_timeout: Duration::from_millis(20), + }, + ) + .unwrap(); + worker.submit(UiCommand::Commit).unwrap(); + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + let before = Instant::now(); + assert_eq!( + worker.shutdown(Duration::from_millis(20)), + ShutdownProgress::Pending + ); + assert!(before.elapsed() < Duration::from_millis(250)); + release_tx.send(()).unwrap(); + assert_eq!( + worker.shutdown(Duration::from_secs(1)), + ShutdownProgress::Stopped + ); + let mut saw_completion = false; + while let Some(event) = worker.try_receive() { + saw_completion |= matches!(event, WorkerEvent::OperationFinished { .. }); + } + assert!( + saw_completion, + "submitted work outcome must remain observable" + ); + } + + #[test] + fn shutdown_with_full_queue_returns_within_bound_while_statistics_is_delayed() { + let (started_tx, started_rx) = mpsc::sync_channel(1); + let (release_tx, release_rx) = mpsc::sync_channel(1); + let (_root, app) = application(); + let mut worker = ApplicationWorker::spawn( + app, + FakeStatistics { + started: Some(started_tx), + release: Some(release_rx), + calls: Arc::new(AtomicUsize::new(0)), + }, + WorkerConfig { + refresh_interval: Duration::from_secs(60), + read_timeout: Duration::from_millis(50), + }, + ) + .unwrap(); + + worker.request_statistics().unwrap(); + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + while worker.sender.try_send(WorkerMessage::Refresh).is_ok() {} + assert!(matches!( + worker.sender.try_send(WorkerMessage::Refresh), + Err(TrySendError::Full(_)) + )); + + let before = Instant::now(); + assert_eq!( + worker.shutdown(Duration::from_millis(20)), + ShutdownProgress::Pending + ); + assert!(before.elapsed() < Duration::from_millis(250)); + + release_tx.send(()).unwrap(); + assert_eq!( + worker.shutdown(Duration::from_secs(1)), + ShutdownProgress::Stopped + ); + } + + #[test] + fn delayed_statistics_are_bounded_and_do_not_advance_generation() { + let (started_tx, started_rx) = mpsc::sync_channel(1); + let (release_tx, release_rx) = mpsc::sync_channel(1); + let calls = Arc::new(AtomicUsize::new(0)); + let (_root, app) = application(); + let mut worker = ApplicationWorker::spawn( + app, + FakeStatistics { + started: Some(started_tx), + release: Some(release_rx), + calls: Arc::clone(&calls), + }, + WorkerConfig { + refresh_interval: Duration::from_secs(60), + read_timeout: Duration::from_millis(50), + }, + ) + .unwrap(); + worker.request_statistics().unwrap(); + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + for _ in 0..100 { + worker.request_statistics().unwrap(); + } + assert_eq!(worker.current_generation(), 0); + release_tx.send(()).unwrap(); + let deadline = Instant::now() + Duration::from_secs(1); + while calls.load(Ordering::SeqCst) < 2 && Instant::now() < deadline { + thread::yield_now(); + } + assert_eq!( + calls.load(Ordering::SeqCst), + 2, + "100 refresh requests coalesce to one pending read" + ); + assert_eq!( + worker.shutdown(Duration::from_secs(1)), + ShutdownProgress::Stopped + ); + } + + #[test] + fn statistics_started_before_a_mutation_are_discarded_as_stale() { + let (stats_started_tx, stats_started_rx) = mpsc::sync_channel(1); + let (stats_release_tx, stats_release_rx) = mpsc::sync_channel(1); + let (operation_started_tx, operation_started_rx) = mpsc::sync_channel(1); + let (operation_release_tx, operation_release_rx) = mpsc::sync_channel(1); + let (_root, app) = application(); + let mut worker = ApplicationWorker::spawn_with_executor( + app, + FakeExecutor { + started: operation_started_tx, + release: operation_release_rx, + calls: Arc::new(AtomicUsize::new(0)), + }, + FakeStatistics { + started: Some(stats_started_tx), + release: Some(stats_release_rx), + calls: Arc::new(AtomicUsize::new(0)), + }, + WorkerConfig { + refresh_interval: Duration::from_secs(60), + read_timeout: Duration::from_millis(50), + }, + ) + .unwrap(); + worker.request_statistics().unwrap(); + stats_started_rx + .recv_timeout(Duration::from_secs(1)) + .unwrap(); + let operation = worker.submit(UiCommand::Commit).unwrap(); + assert_eq!(operation.generation, 1); + stats_release_tx.send(()).unwrap(); + operation_started_rx + .recv_timeout(Duration::from_secs(1)) + .unwrap(); + operation_release_tx.send(()).unwrap(); + + let deadline = Instant::now() + Duration::from_secs(1); + let mut saw_finish = false; + while Instant::now() < deadline { + if let Some(event) = worker.try_receive() { + match event { + WorkerEvent::Statistics { generation, .. } => { + assert_ne!(generation, 0, "pre-mutation statistics must be dropped"); + } + WorkerEvent::OperationFinished { + operation: finished, + view, + .. + } => { + assert_eq!(finished, operation); + assert_eq!(view.generation, 1); + saw_finish = true; + break; + } + WorkerEvent::SessionReady(_) + | WorkerEvent::OperationStarted(_) + | WorkerEvent::ShutdownComplete => {} + } + } else { + thread::yield_now(); + } + } + assert!(saw_finish); + assert_eq!( + worker.shutdown(Duration::from_secs(1)), + ShutdownProgress::Stopped + ); + } +} diff --git a/src/tui/forms.rs b/src/tui/forms.rs new file mode 100644 index 0000000..93a95c8 --- /dev/null +++ b/src/tui/forms.rs @@ -0,0 +1,1065 @@ +//! Short-lived keyboard forms. Password fields live only in zeroizing owners +//! here and are never copied into `UiState`, screen snapshots, or Debug output. + +use std::ffi::OsString; +use std::net::{Ipv4Addr, Ipv6Addr}; + +use crossterm::event::KeyCode; + +use crate::app::RestartChoice; +use crate::app::{FirstIssueAttempt, FirstIssueRetry}; +use crate::backup::ArchiveSummary; +use crate::delivery::QrCodeOutput; +use crate::domain::{ClientAddresses, PublicKey}; +use crate::tui::events::{ + DeliveryDestinationSummary, RestartPreviewSummary, RestorePreviewSummary, SecretText, + SensitiveCommand, UiCommand, +}; + +const MAX_INPUT_CHARS: usize = 4096; + +pub enum FormAction { + None, + Cancel, + Submit(UiCommand), + SubmitSensitive(SensitiveCommand), + PreviewRestart, + ConfirmRename { + identity: PublicKey, + alias: String, + }, + CancelRestorePreview, + CancelPreparedDelivery, + StartReissue { + identity: PublicKey, + overwrite_confirmed: bool, + prepared_generation: u64, + }, + Exit, + Feedback(String), +} + +enum Form { + Setup { + password: SecretInput, + confirmation: SecretInput, + confirmation_focused: bool, + }, + Create { + alias: String, + ipv4: String, + ipv6: String, + field: usize, + }, + Rename { + identity: PublicKey, + alias: String, + }, + Import { + file_name: String, + }, + Confirm(Confirmation), + DraftReview, + DeliveryPath { + identity: PublicKey, + reissue: bool, + attempt: FirstIssueAttempt, + path: String, + include_qr: bool, + }, + ReissuePassword { + identity: PublicKey, + password: SecretInput, + overwrite_confirmed: bool, + prepared_generation: u64, + }, + Backups { + items: Vec, + selected: usize, + }, + Restore(Confirmation), + Disclosure { + qr: QrCodeOutput, + notice: String, + }, +} + +struct Confirmation { + title: String, + description: String, + affirmative: String, + action: ConfirmAction, +} + +enum ConfirmAction { + Rename { identity: PublicKey, alias: String }, + Delete(PublicKey), + Commit, + Discard, + Exit, + Restart(RestartChoice), + Delivery { summary: DeliveryDestinationSummary }, + ConfirmRestore { preview_generation: u64 }, +} + +struct SecretInput(zeroize::Zeroizing); + +impl SecretInput { + fn new() -> Self { + Self(zeroize::Zeroizing::new(String::new())) + } + + fn push(&mut self, character: char) { + if !character.is_control() && self.0.chars().count() < MAX_INPUT_CHARS { + self.0.push(character); + } + } + + fn pop(&mut self) { + self.0.pop(); + } + + fn is_empty(&self) -> bool { + self.0.is_empty() + } + + fn matches(&self, other: &Self) -> bool { + self.0.as_str() == other.0.as_str() + } + + fn masked(&self) -> String { + "•".repeat(self.0.chars().count().min(64)) + } + + fn into_secret(self) -> SecretText { + SecretText::new(self.0.to_string()) + } +} + +/// Per-run form owner. It intentionally has no Debug/Clone implementation. +#[derive(Default)] +pub struct FormController { + active: Option
, + feedback: Option, + first_retry: Option<(PublicKey, FirstIssueRetry)>, +} + +impl FormController { + pub fn is_active(&self) -> bool { + self.active.is_some() + } + + pub fn is_draft_review(&self) -> bool { + matches!(self.active, Some(Form::DraftReview)) + } + + pub fn qr_output(&self) -> Option<&QrCodeOutput> { + match self.active.as_ref() { + Some(Form::Disclosure { qr, .. }) => Some(qr), + _ => None, + } + } + + pub fn open_delivery(&mut self, identity: PublicKey, reissue: bool) { + let attempt = if reissue { + FirstIssueAttempt::Initial + } else { + match self.first_retry { + Some((key, FirstIssueRetry::Available)) if key == identity => { + FirstIssueAttempt::Retry { + confirm_possible_overwrite: false, + confirm_manual_cleanup: false, + } + } + Some((key, FirstIssueRetry::OverwriteConfirmationRequired)) if key == identity => { + FirstIssueAttempt::Retry { + confirm_possible_overwrite: true, + confirm_manual_cleanup: false, + } + } + Some((key, FirstIssueRetry::ManualCleanupRequired)) if key == identity => { + FirstIssueAttempt::Retry { + confirm_possible_overwrite: false, + confirm_manual_cleanup: true, + } + } + _ => FirstIssueAttempt::Initial, + } + }; + self.active = Some(Form::DeliveryPath { + identity, + reissue, + attempt, + path: String::new(), + include_qr: false, + }); + self.feedback = None; + } + + pub fn note_first_issue(&mut self, identity: PublicKey, retry: Option) { + self.first_retry = retry.map(|value| (identity, value)); + } + + pub fn clear_delivery_state(&mut self) { + self.first_retry = None; + self.active = None; + } + + pub fn open_delivery_confirmation(&mut self, summary: DeliveryDestinationSummary) { + let mut description = format!( + "Always publish the required client file at {}.", + safe_form_text(&summary.path.display().to_string()) + ); + if summary.overwrite_required { + description + .push_str(" This exact existing file must be explicitly approved for replacement."); + } + if matches!( + summary.attempt, + FirstIssueAttempt::Retry { + confirm_possible_overwrite: true, + .. + } + ) { + description.push_str( + " The prior publication was uncertain; inspect the selected path before retrying.", + ); + } + if matches!( + summary.attempt, + FirstIssueAttempt::Retry { + confirm_manual_cleanup: true, + .. + } + ) { + description.push_str(" Inspect and manually remove any retained private temporary file before acknowledging cleanup."); + } + self.active = Some(Form::Confirm(Confirmation { + title: "Confirm file delivery".to_owned(), + description, + affirmative: if summary.overwrite_required { + "Replace this exact destination and deliver".to_owned() + } else { + "Deliver client file".to_owned() + }, + action: ConfirmAction::Delivery { summary }, + })); + self.feedback = None; + } + + pub fn open_reissue_password( + &mut self, + identity: PublicKey, + overwrite_confirmed: bool, + prepared_generation: u64, + ) { + self.active = Some(Form::ReissuePassword { + identity, + password: SecretInput::new(), + overwrite_confirmed, + prepared_generation, + }); + self.feedback = None; + } + + pub fn open_backups(&mut self, items: Vec) { + self.active = Some(Form::Backups { items, selected: 0 }); + self.feedback = None; + } + + pub fn open_restore_confirmation(&mut self, summary: RestorePreviewSummary, has_draft: bool) { + let changes = &summary.changes; + let mut description = format!( + "Archive {} created {} ({:?}, historical apply: {:?}). Full replacement; running VPN will not restart. Settings changed: {}; server configuration changed: {}; vault changed: {}; added peers: {}; removed peers: {}.{}", + safe_form_text(&summary.file_name), + safe_form_text(&summary.created_at_utc), + summary.reason, + summary.historical_apply, + changes.settings_changed, + changes.server_config_changed, + changes.vault_changed, + changes.added_clients.len(), + changes.removed_clients.len(), + if has_draft { + " The current draft will be discarded." + } else { + "" + } + ); + if !changes.added_clients.is_empty() { + description.push_str(&format!( + " Added full keys: {}.", + changes + .added_clients + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + )); + } + if !changes.removed_clients.is_empty() { + description.push_str(&format!( + " Removed full keys: {}.", + changes + .removed_clients + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + )); + } + if !changes.changed_clients.is_empty() { + description.push_str(&format!( + " Changed peer routes: {}.", + changes + .changed_clients + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + )); + } + if !changes.settings_fields_changed.is_empty() { + description.push_str(&format!( + " Changed settings: {}.", + changes.settings_fields_changed.join(", ") + )); + } + self.active = Some(Form::Restore(Confirmation { + title: "Confirm full restore".to_owned(), + description, + affirmative: format!( + "Restore exact archive {}", + safe_form_text(&summary.file_name) + ), + action: ConfirmAction::ConfirmRestore { + preview_generation: summary.preview_generation, + }, + })); + self.feedback = None; + } + + pub fn open_disclosure(&mut self, qr: QrCodeOutput, notice: String) { + self.active = Some(Form::Disclosure { + qr, + notice: safe_form_text(¬ice), + }); + self.feedback = None; + } + + pub fn open_setup(&mut self) { + self.active = Some(Form::Setup { + password: SecretInput::new(), + confirmation: SecretInput::new(), + confirmation_focused: false, + }); + self.feedback = None; + } + + pub fn open_create(&mut self) { + self.active = Some(Form::Create { + alias: String::new(), + ipv4: String::new(), + ipv6: String::new(), + field: 0, + }); + self.feedback = None; + } + + pub fn open_rename(&mut self, identity: PublicKey, current_alias: &str) { + self.active = Some(Form::Rename { + identity, + alias: current_alias.to_owned(), + }); + self.feedback = None; + } + + pub fn open_rename_confirmation(&mut self, identity: PublicKey, alias: String) { + self.open_confirmation(Confirmation { + title: "Confirm alias change".to_owned(), + description: format!("Change alias for peer {identity}?"), + affirmative: format!("Rename peer {identity}"), + action: ConfirmAction::Rename { identity, alias }, + }); + } + + pub fn open_import(&mut self) { + self.active = Some(Form::Import { + file_name: String::new(), + }); + self.feedback = None; + } + + pub fn open_delete(&mut self, identity: PublicKey, label: &str) { + self.open_confirmation(Confirmation { + title: "Confirm deletion".to_owned(), + description: format!( + "Delete {} ({identity}) from the draft? Saved removal can leave runtime access active until restart, and the address remains reserved.", + safe_form_text(label) + ), + affirmative: format!("Delete peer {identity}"), + action: ConfirmAction::Delete(identity), + }); + } + + pub fn open_commit(&mut self) { + self.open_confirmation(Confirmation { + title: "Confirm Commit".to_owned(), + description: "Commit writes a complete backup, then saves the draft files and vault. It does not change the running VPN.".to_owned(), + affirmative: "Commit saved files only".to_owned(), + action: ConfirmAction::Commit, + }); + } + + pub fn open_discard(&mut self) { + self.open_confirmation(Confirmation { + title: "Discard draft".to_owned(), + description: "Discard all uncommitted client, metadata, and vault changes?".to_owned(), + affirmative: "Discard draft".to_owned(), + action: ConfirmAction::Discard, + }); + } + + pub fn open_exit_with_draft(&mut self) { + self.open_confirmation(Confirmation { + title: "Exit with draft changes".to_owned(), + description: + "The draft will be discarded when this session closes. Nothing will be committed." + .to_owned(), + affirmative: "Discard draft and exit".to_owned(), + action: ConfirmAction::Exit, + }); + } + + pub fn open_restart(&mut self, preview: RestartPreviewSummary) { + let (description, affirmative, choice) = if preview.has_draft { + ( + format!( + "Restart {} using the saved configuration? The current draft will be discarded after the mandatory backup completes.", + safe_form_text(&preview.interface) + ), + "Discard draft and restart".to_owned(), + RestartChoice::DiscardDraftAndRestart, + ) + } else { + ( + format!( + "Restart {} using the saved configuration? A complete backup will be created first.", + safe_form_text(&preview.interface) + ), + "Restart saved state".to_owned(), + RestartChoice::RestartSavedState, + ) + }; + self.open_confirmation(Confirmation { + title: "Explicit restart".to_owned(), + description: format!( + "{description}\nService: {}", + safe_form_text(&preview.systemd_unit) + ), + affirmative, + action: ConfirmAction::Restart(choice), + }); + } + + pub fn open_draft_review(&mut self) { + self.active = Some(Form::DraftReview); + self.feedback = None; + } + + pub fn cancel(&mut self) { + self.active = None; + self.feedback = None; + } + + pub fn feedback(&mut self, message: impl Into) { + self.feedback = Some(message.into()); + } + + pub fn display_lines(&self) -> Vec { + let mut lines = match self.active.as_ref() { + Some(Form::Setup { password, confirmation, confirmation_focused }) => vec![ + "Create vault and scan configured client files".to_owned(), + format!("Vault password: {}{}", if *confirmation_focused { " " } else { "> " }, password.masked()), + format!("Confirm password: {}{}", if *confirmation_focused { "> " } else { " " }, confirmation.masked()), + "Tab switches fields; Enter confirms a field; typing is masked.".to_owned(), + ], + Some(Form::Create { alias, ipv4, ipv6, field }) => vec![ + "New dual-stack client".to_owned(), + format!("{}Alias: {alias}", if *field == 0 { "> " } else { " " }), + format!("{}Manual IPv4 (blank = automatic): {ipv4}", if *field == 1 { "> " } else { " " }), + format!("{}Manual IPv6 (blank = automatic): {ipv6}", if *field == 2 { "> " } else { " " }), + "Enter advances/submits; Tab changes field. Both manual addresses are required together.".to_owned(), + ], + Some(Form::Rename { identity, alias }) => vec![ + format!("Rename peer {identity}"), + format!("Alias (duplicates allowed): {alias}"), + "Enter submits; Esc cancels without changing the draft.".to_owned(), + ], + Some(Form::Import { file_name }) => vec![ + "Import one selected immediate *.conf entry".to_owned(), + format!("Filename: {file_name}"), + "Only the configured client directory is searched. Original files are preserved.".to_owned(), + ], + Some(Form::Confirm(confirm)) => vec![ + confirm.title.clone(), + confirm.description.clone(), + format!("[y] {}", confirm.affirmative), + "[n / Esc] Cancel".to_owned(), + ], + Some(Form::DraftReview) => vec![ + "Semantic draft preview".to_owned(), + "Client additions, removals, alias changes, and address changes are shown without configuration bytes.".to_owned(), + "Esc returns without changing the draft.".to_owned(), + ], + Some(Form::DeliveryPath { identity, path, include_qr, .. }) => vec![ + format!("Deliver client file for {identity}"), + format!("Selected absolute destination: {path}"), + format!("Optional QR after successful file: {} (Tab toggles)", if *include_qr { "yes" } else { "no" }), + "Enter prepares this exact destination; source files are never used as output targets.".to_owned(), + ], + Some(Form::ReissuePassword { identity, password, .. }) => vec![ + format!("Authorize reissue for {identity}"), + format!("Password: {}", password.masked()), + "This password is used for this action only and is not retained.".to_owned(), + ], + Some(Form::Backups { items, selected }) => { + let mut lines = vec!["Manual backups: select an exact archive; Enter validates a restore preview.".to_owned()]; + if items.is_empty() { lines.push("No archives are available.".to_owned()); } + for (index, item) in items.iter().enumerate() { lines.push(format!("{} {} {} {:?} historical apply: {:?}", if index == *selected { ">" } else { " " }, safe_form_text(&item.file_name), safe_form_text(&item.created_at_utc), item.reason, item.historical_apply)); } + lines + } + Some(Form::Restore(confirm)) => vec![confirm.title.clone(), confirm.description.clone(), format!("[y] {}", confirm.affirmative), "[n / Esc] Cancel without creating a backup".to_owned()], + Some(Form::Disclosure { qr, notice }) => vec![format!("Client configuration QR disclosure ({} modules including quiet zone)", qr.side()), notice.clone(), "Esc closes and clears the QR.".to_owned()], + None => Vec::new(), + }; + if let Some(feedback) = &self.feedback { + lines.push(format!("Status: {}", safe_form_text(feedback))); + } + lines + } + + pub fn handle_key(&mut self, code: KeyCode) -> FormAction { + if code == KeyCode::Esc { + let cancelled = match self.active.as_ref() { + Some(Form::Restore(_)) => FormAction::CancelRestorePreview, + Some(Form::Confirm(Confirmation { + action: ConfirmAction::Delivery { .. }, + .. + })) + | Some(Form::ReissuePassword { .. }) => FormAction::CancelPreparedDelivery, + _ => FormAction::Cancel, + }; + self.cancel(); + return cancelled; + } + let Some(form) = self.active.as_mut() else { + return FormAction::None; + }; + match form { + Form::Setup { + password, + confirmation, + confirmation_focused, + } => match code { + KeyCode::Tab => *confirmation_focused = !*confirmation_focused, + KeyCode::Backspace => { + if *confirmation_focused { + confirmation.pop(); + } else { + password.pop(); + } + } + KeyCode::Enter if !*confirmation_focused => *confirmation_focused = true, + KeyCode::Enter => { + if password.is_empty() { + return FormAction::Feedback( + "Vault password must not be empty.".to_owned(), + ); + } + if !password.matches(confirmation) { + return FormAction::Feedback( + "Vault password confirmation does not match.".to_owned(), + ); + } + let password = std::mem::replace(password, SecretInput::new()); + self.active = None; + return FormAction::SubmitSensitive(SensitiveCommand::PrepareInitialSetup( + password.into_secret(), + )); + } + KeyCode::Char(character) => { + if *confirmation_focused { + confirmation.push(character); + } else { + password.push(character); + } + } + _ => {} + }, + Form::Create { + alias, + ipv4, + ipv6, + field, + } => match code { + KeyCode::Tab => *field = (*field + 1) % 3, + KeyCode::Backspace => { + field_value_mut(*field, alias, ipv4, ipv6).pop(); + } + KeyCode::Enter if *field < 2 => *field += 1, + KeyCode::Enter => { + if alias.trim().is_empty() { + return FormAction::Feedback("Alias is required.".to_owned()); + } + let manual_addresses = match (ipv4.trim().is_empty(), ipv6.trim().is_empty()) { + (true, true) => None, + (false, false) => match (ipv4.trim().parse::(), ipv6.trim().parse::()) { + (Ok(ipv4), Ok(ipv6)) => Some(ClientAddresses { ipv4, ipv6: Some(ipv6) }), + _ => return FormAction::Feedback("Enter a valid IPv4 and IPv6 address, or leave both blank for automatic allocation.".to_owned()), + }, + _ => return FormAction::Feedback("Manual addressing requires both IPv4 and IPv6 values.".to_owned()), + }; + let created_utc = match time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + { + Ok(value) => value, + Err(_) => { + return FormAction::Feedback( + "Could not prepare client creation time.".to_owned(), + ); + } + }; + let command = UiCommand::CreateClient { + alias: alias.trim().to_owned(), + manual_addresses, + created_utc: Some(created_utc), + }; + self.active = None; + return FormAction::Submit(command); + } + KeyCode::Char(character) => { + field_value_mut(*field, alias, ipv4, ipv6).push_bounded(character) + } + _ => {} + }, + Form::Rename { identity, alias } => match code { + KeyCode::Backspace => { + alias.pop(); + } + KeyCode::Enter => { + if alias.trim().is_empty() { + return FormAction::Feedback("Alias must not be empty.".to_owned()); + } + let identity = *identity; + let alias = alias.trim().to_owned(); + self.active = None; + return FormAction::ConfirmRename { identity, alias }; + } + KeyCode::Char(character) if !character.is_control() => { + alias.push_bounded(character) + } + _ => {} + }, + Form::Import { file_name } => match code { + KeyCode::Backspace => { + file_name.pop(); + } + KeyCode::Enter => { + if file_name.trim().is_empty() { + return FormAction::Feedback( + "A configured-directory filename is required.".to_owned(), + ); + } + let command = UiCommand::ImportSelectedFile { + file_name: OsString::from(file_name.trim()), + }; + self.active = None; + return FormAction::Submit(command); + } + KeyCode::Char(character) if !character.is_control() => { + file_name.push_bounded(character) + } + _ => {} + }, + Form::DeliveryPath { + identity, + reissue, + attempt, + path, + include_qr, + } => match code { + KeyCode::Tab => *include_qr = !*include_qr, + KeyCode::Backspace => { + path.pop(); + } + KeyCode::Enter => { + let output_path = std::path::PathBuf::from(path.trim()); + if !output_path.is_absolute() { + return FormAction::Feedback( + "Choose an absolute destination path.".to_owned(), + ); + } + let command = UiCommand::PrepareDelivery { + identity: *identity, + path: output_path, + include_qr: *include_qr, + reissue: *reissue, + attempt: *attempt, + }; + self.active = None; + return FormAction::Submit(command); + } + KeyCode::Char(character) if !character.is_control() => path.push_bounded(character), + _ => {} + }, + Form::ReissuePassword { + identity, + password, + overwrite_confirmed, + prepared_generation, + } => match code { + KeyCode::Backspace => password.pop(), + KeyCode::Enter => { + if password.is_empty() { + return FormAction::Feedback( + "Enter the selected client's vault password.".to_owned(), + ); + } + let password = std::mem::replace(password, SecretInput::new()); + let sensitive = SensitiveCommand::ReissueClient { + identity: *identity, + password: password.into_secret(), + overwrite_confirmed: *overwrite_confirmed, + prepared_generation: *prepared_generation, + }; + self.active = None; + return FormAction::SubmitSensitive(sensitive); + } + KeyCode::Char(character) => password.push(character), + _ => {} + }, + Form::Backups { items, selected } => match code { + KeyCode::Up => *selected = selected.saturating_sub(1), + KeyCode::Down if !items.is_empty() => { + *selected = (*selected + 1).min(items.len() - 1) + } + KeyCode::Enter => { + let Some(item) = items.get(*selected) else { + return FormAction::Feedback("Select an archive first.".to_owned()); + }; + let command = UiCommand::PreviewRestore { + file_name: item.file_name.clone(), + }; + self.active = None; + return FormAction::Submit(command); + } + _ => {} + }, + Form::Restore(confirm) => match code { + KeyCode::Char('y') | KeyCode::Enter => { + let action = match &confirm.action { + ConfirmAction::ConfirmRestore { preview_generation } => { + FormAction::Submit(UiCommand::ConfirmRestore { + preview_generation: *preview_generation, + }) + } + _ => FormAction::None, + }; + self.active = None; + return action; + } + KeyCode::Char('n') => { + self.active = None; + return FormAction::CancelRestorePreview; + } + _ => {} + }, + Form::Disclosure { .. } => {} + Form::Confirm(confirm) => match code { + KeyCode::Char('y') | KeyCode::Enter => { + let action = match &confirm.action { + ConfirmAction::Rename { identity, alias } => { + FormAction::Submit(UiCommand::RenameClient { + identity: *identity, + alias: alias.clone(), + }) + } + ConfirmAction::Delete(identity) => { + FormAction::Submit(UiCommand::DeleteClient { + identity: *identity, + }) + } + ConfirmAction::Commit => FormAction::Submit(UiCommand::Commit), + ConfirmAction::Discard => FormAction::Submit(UiCommand::DiscardDraft), + ConfirmAction::Exit => FormAction::Exit, + ConfirmAction::Restart(choice) => { + FormAction::Submit(UiCommand::Restart(*choice)) + } + ConfirmAction::Delivery { summary } => { + if summary.reissue { + FormAction::StartReissue { + identity: summary.identity, + overwrite_confirmed: true, + prepared_generation: summary.prepared_generation, + } + } else { + FormAction::Submit(UiCommand::DeliverFirstIssue { + attempt: summary.attempt, + overwrite_confirmed: true, + prepared_generation: summary.prepared_generation, + }) + } + } + ConfirmAction::ConfirmRestore { preview_generation } => { + FormAction::Submit(UiCommand::ConfirmRestore { + preview_generation: *preview_generation, + }) + } + }; + self.active = None; + return action; + } + KeyCode::Char('n') => { + let action = match &confirm.action { + ConfirmAction::Delivery { .. } => FormAction::CancelPreparedDelivery, + ConfirmAction::ConfirmRestore { .. } => FormAction::CancelRestorePreview, + _ => FormAction::Cancel, + }; + self.cancel(); + return action; + } + _ => {} + }, + Form::DraftReview => {} + } + FormAction::None + } + + fn open_confirmation(&mut self, confirmation: Confirmation) { + self.active = Some(Form::Confirm(confirmation)); + self.feedback = None; + } +} + +fn field_value_mut<'a>( + field: usize, + alias: &'a mut String, + ipv4: &'a mut String, + ipv6: &'a mut String, +) -> &'a mut String { + match field { + 0 => alias, + 1 => ipv4, + _ => ipv6, + } +} + +trait BoundedString { + fn push_bounded(&mut self, character: char); +} + +impl BoundedString for String { + fn push_bounded(&mut self, character: char) { + if !character.is_control() && self.chars().count() < MAX_INPUT_CHARS { + self.push(character); + } + } +} + +pub fn safe_form_text(text: &str) -> String { + text.chars() + .flat_map(|character| { + if character.is_control() { + character + .escape_default() + .collect::() + .chars() + .collect::>() + } else { + vec![character] + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backup::{BackupReason, HistoricalApplyStatus}; + + #[test] + fn setup_password_is_masked_confirmation_required_and_secret_command_has_no_debug() { + let mut form = FormController::default(); + form.open_setup(); + for ch in "correct horse".chars() { + form.handle_key(KeyCode::Char(ch)); + } + assert!(form.display_lines()[1].contains("••••")); + assert!(!form.display_lines()[1].contains("correct")); + assert!(matches!(form.handle_key(KeyCode::Enter), FormAction::None)); + for ch in "correct horse".chars() { + form.handle_key(KeyCode::Char(ch)); + } + assert!(matches!( + form.handle_key(KeyCode::Enter), + FormAction::SubmitSensitive(_) + )); + assert!(safe_form_text("a\u{1b}[2J").contains("\\u{1b}")); + } + + #[test] + fn manual_addresses_require_both_valid_families_and_cancellation_preserves_no_submission() { + let mut form = FormController::default(); + form.open_create(); + for ch in "device".chars() { + form.handle_key(KeyCode::Char(ch)); + } + form.handle_key(KeyCode::Enter); + for ch in "10.0.0.4".chars() { + form.handle_key(KeyCode::Char(ch)); + } + form.handle_key(KeyCode::Enter); + assert!(matches!( + form.handle_key(KeyCode::Enter), + FormAction::Feedback(_) + )); + assert!(matches!(form.handle_key(KeyCode::Esc), FormAction::Cancel)); + } + + #[test] + fn rename_requires_full_key_confirmation_and_restart_exposes_exact_draft_choice() { + let identity = PublicKey::from_bytes([0x5a; 32]); + let identity_text = identity.to_string(); + let mut form = FormController::default(); + form.open_rename(identity, "old alias"); + for _ in 0.."old alias".chars().count() { + form.handle_key(KeyCode::Backspace); + } + for ch in "new alias".chars() { + form.handle_key(KeyCode::Char(ch)); + } + assert!(matches!( + form.handle_key(KeyCode::Enter), + FormAction::ConfirmRename { .. } + )); + form.open_rename_confirmation(identity, "new alias".to_owned()); + assert!( + form.display_lines() + .iter() + .any(|line| line.contains(&identity_text)) + ); + assert!( + matches!(form.handle_key(KeyCode::Char('y')), FormAction::Submit(UiCommand::RenameClient { identity: key, .. }) if key == identity) + ); + + form.open_restart(RestartPreviewSummary { + has_draft: true, + interface: "wg-test".to_owned(), + systemd_unit: "wg-quick@wg-test.service".to_owned(), + }); + assert!( + form.display_lines() + .iter() + .any(|line| line.contains("Discard draft and restart")) + ); + assert!(matches!( + form.handle_key(KeyCode::Char('y')), + FormAction::Submit(UiCommand::Restart( + crate::app::RestartChoice::DiscardDraftAndRestart + )) + )); + } + + #[test] + fn delivery_retry_requires_destination_approval_and_restore_cancel_only_clears_preview() { + let identity = PublicKey::from_bytes([0x33; 32]); + let mut form = FormController::default(); + form.note_first_issue( + identity, + Some(FirstIssueRetry::OverwriteConfirmationRequired), + ); + form.open_delivery(identity, false); + for ch in "/tmp/client.conf".chars() { + form.handle_key(KeyCode::Char(ch)); + } + form.handle_key(KeyCode::Tab); + let FormAction::Submit(UiCommand::PrepareDelivery { + path, + include_qr, + attempt, + .. + }) = form.handle_key(KeyCode::Enter) + else { + panic!("path should prepare"); + }; + assert_eq!(path, std::path::PathBuf::from("/tmp/client.conf")); + assert!(include_qr); + assert_eq!( + attempt, + FirstIssueAttempt::Retry { + confirm_possible_overwrite: true, + confirm_manual_cleanup: false + } + ); + form.open_delivery_confirmation(DeliveryDestinationSummary { + identity, + path: std::path::PathBuf::from("/tmp/client.conf"), + overwrite_required: true, + include_qr: true, + reissue: false, + attempt, + prepared_generation: 4, + }); + assert!(matches!( + form.handle_key(KeyCode::Char('n')), + FormAction::CancelPreparedDelivery + )); + + let summary = RestorePreviewSummary { + file_name: "archive-1.tar".to_owned(), + created_at_utc: "2026-10-21T00:00:00Z".to_owned(), + reason: BackupReason::Manual, + historical_apply: HistoricalApplyStatus::Unknown, + changes: crate::app::RestoreChangeSummary { + settings_changed: true, + settings_fields_changed: vec!["DNS addresses"], + server_config_changed: false, + vault_changed: false, + added_clients: vec![], + removed_clients: vec![], + changed_clients: vec![], + }, + preview_generation: 9, + }; + form.open_restore_confirmation(summary, true); + assert!( + form.display_lines() + .iter() + .any(|line| line.contains("current draft will be discarded")) + ); + assert!(matches!( + form.handle_key(KeyCode::Esc), + FormAction::CancelRestorePreview + )); + } + + #[test] + fn reissue_password_is_masked_and_submitted_as_a_sensitive_owner() { + let identity = PublicKey::from_bytes([0x44; 32]); + let mut form = FormController::default(); + form.open_reissue_password(identity, true, 7); + for ch in "one action password".chars() { + form.handle_key(KeyCode::Char(ch)); + } + assert!( + !form + .display_lines() + .iter() + .any(|line| line.contains("one action password")) + ); + assert!( + matches!(form.handle_key(KeyCode::Enter), FormAction::SubmitSensitive(SensitiveCommand::ReissueClient { identity: found, overwrite_confirmed: true, prepared_generation: 7, .. }) if found == identity) + ); + } +} diff --git a/src/tui/render.rs b/src/tui/render.rs new file mode 100644 index 0000000..e2efbe2 --- /dev/null +++ b/src/tui/render.rs @@ -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::>(), + )) + .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::>(); + 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::>(), + )) + .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 { + 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> = 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> { + 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) -> 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::() + .chars() + .collect::>() + } 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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .join("\n"); + assert!(narrow_rendered.contains("Draft changed")); + assert!(narrow_rendered.contains("! anomaly")); + assert!(narrow_rendered.contains("Draft removed")); + } +} diff --git a/src/tui/state.rs b/src/tui/state.rs new file mode 100644 index 0000000..4c43fe7 --- /dev/null +++ b/src/tui/state.rs @@ -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, + pub draft: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SessionView { + pub generation: u64, + pub clients: Vec, + pub client_states: std::collections::BTreeMap, + pub document_issues: Vec, + pub applied: AppliedState, + pub reserved_addresses: BTreeSet, + 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, + pub selected_identity: Option, + /// 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, + pub scroll_offset: usize, + pub generation: u64, + pub view: Option, + pub pending: Option, + pub status_message: Option, +} + +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) -> 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())); + } +} diff --git a/src/tui/terminal.rs b/src/tui/terminal.rs new file mode 100644 index 0000000..38dff85 --- /dev/null +++ b/src/tui/terminal.rs @@ -0,0 +1,1077 @@ +//! Crossterm terminal lifecycle and responsive application event loop. + +use std::io::{self, IsTerminal, Write}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, size, +}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; + +use super::events::{ + ApplicationWorker, CommandResult, FirstIssueStatus, LinuxStatisticsReader, SensitiveCommand, + ShutdownProgress, StatisticsReader, SubmitError, UiCommand, WorkerConfig, WorkerEvent, +}; +use super::forms::{FormAction, FormController}; +use super::render; +use super::state::{Screen, UiState}; +use crate::app::Application; + +const MIN_WIDTH: u16 = 34; +const MIN_HEIGHT: u16 = 8; +const EVENT_POLL: Duration = Duration::from_millis(50); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TerminalError { + NotInteractive, + TooSmall, + Unsupported, + Setup, + Io, + Worker, + Unexpected, +} + +impl std::fmt::Display for TerminalError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::NotInteractive => "interactive management requires terminal stdin and stdout", + Self::TooSmall => "terminal must be at least 34 columns by 8 rows", + Self::Unsupported => "terminal does not support the required interactive mode", + Self::Setup => "could not initialize terminal mode", + Self::Io => "terminal input or rendering failed", + Self::Worker => "application worker stopped unexpectedly", + Self::Unexpected => "an unexpected internal failure occurred", + }) + } +} + +impl std::error::Error for TerminalError {} + +pub fn check_suitable() -> Result<(), TerminalError> { + check_suitable_for( + io::stdin().is_terminal(), + io::stdout().is_terminal(), + size(), + ) +} + +fn check_suitable_for( + stdin_is_terminal: bool, + stdout_is_terminal: bool, + dimensions: io::Result<(u16, u16)>, +) -> Result<(), TerminalError> { + if !stdin_is_terminal || !stdout_is_terminal { + return Err(TerminalError::NotInteractive); + } + let (width, height) = dimensions.map_err(|_| TerminalError::Unsupported)?; + if width < MIN_WIDTH || height < MIN_HEIGHT { + return Err(TerminalError::TooSmall); + } + Ok(()) +} + +struct Lifecycle { + raw: AtomicBool, + alternate: AtomicBool, + hidden_cursor: AtomicBool, +} + +impl Lifecycle { + const fn new() -> Self { + Self { + raw: AtomicBool::new(false), + alternate: AtomicBool::new(false), + hidden_cursor: AtomicBool::new(false), + } + } + + fn restore(&self) { + let mut stdout = io::stdout(); + if self.hidden_cursor.swap(false, Ordering::AcqRel) { + let _ = execute!(stdout, crossterm::cursor::Show); + } + if self.alternate.swap(false, Ordering::AcqRel) { + let _ = execute!(stdout, LeaveAlternateScreen); + } + if self.raw.swap(false, Ordering::AcqRel) { + let _ = disable_raw_mode(); + } + } + + fn enable_raw(&self) -> Result<(), TerminalError> { + self.raw.store(true, Ordering::Release); + enable_raw_mode().map_err(|_| TerminalError::Setup) + } + + fn enter_alternate(&self, stdout: &mut io::Stdout) -> Result<(), TerminalError> { + self.alternate.store(true, Ordering::Release); + execute!(stdout, EnterAlternateScreen).map_err(|_| TerminalError::Setup) + } + + fn hide_cursor(&self, stdout: &mut io::Stdout) -> Result<(), TerminalError> { + self.hidden_cursor.store(true, Ordering::Release); + execute!(stdout, crossterm::cursor::Hide).map_err(|_| TerminalError::Setup) + } +} + +struct TerminalGuard(Arc); + +impl Drop for TerminalGuard { + fn drop(&mut self) { + self.0.restore(); + } +} + +type PanicHook = Box) + Send + Sync + 'static>; + +struct PanicHookGuard(Option); + +impl PanicHookGuard { + fn install(lifecycle: Arc) -> Self { + let previous = std::panic::take_hook(); + let owner = std::thread::current().id(); + std::panic::set_hook(Box::new(move |_info| { + if std::thread::current().id() == owner { + lifecycle.restore(); + let _ = writeln!( + io::stderr().lock(), + "wireguard-admin encountered an unexpected internal failure" + ); + } + })); + Self(Some(previous)) + } +} + +impl Drop for PanicHookGuard { + fn drop(&mut self) { + if let Some(previous) = self.0.take() { + let _installed = std::panic::take_hook(); + std::panic::set_hook(previous); + } + } +} + +pub fn run(app: Application) -> Result<(), TerminalError> { + run_with_statistics(app, LinuxStatisticsReader, WorkerConfig::default()) +} + +/// Run the terminal loop with an injected statistics source. This also allows +/// isolated PTY harnesses to exercise terminal cleanup without probing a host +/// WireGuard interface. +#[doc(hidden)] +pub fn run_with_statistics( + app: Application, + statistics: impl StatisticsReader, + config: WorkerConfig, +) -> Result<(), TerminalError> { + check_suitable()?; + with_terminal_lifecycle(|lifecycle| run_terminal_session(app, statistics, config, lifecycle)) +} + +fn with_terminal_lifecycle( + operation: impl FnOnce(Arc) -> Result<(), TerminalError>, +) -> Result<(), TerminalError> { + let lifecycle = Arc::new(Lifecycle::new()); + let _panic_hook_guard = PanicHookGuard::install(Arc::clone(&lifecycle)); + let _guard = TerminalGuard(Arc::clone(&lifecycle)); + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + operation(Arc::clone(&lifecycle)) + })) { + Ok(result) => result, + Err(_) => { + lifecycle.restore(); + Err(TerminalError::Unexpected) + } + } +} + +fn run_terminal_session( + app: Application, + statistics: impl StatisticsReader, + config: WorkerConfig, + lifecycle: Arc, +) -> Result<(), TerminalError> { + lifecycle.enable_raw()?; + let mut stdout = io::stdout(); + lifecycle.enter_alternate(&mut stdout)?; + lifecycle.hide_cursor(&mut stdout)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend).map_err(|_| TerminalError::Setup)?; + + let mut worker = + ApplicationWorker::spawn(app, statistics, config).map_err(|_| TerminalError::Worker)?; + let mut state = UiState::default(); + let mut forms = FormController::default(); + let mut last_operation_outcome = None; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + run_screen_loop( + &mut terminal, + &mut worker, + &mut state, + &mut forms, + &mut last_operation_outcome, + ) + })) + .unwrap_or(Err(TerminalError::Unexpected)); + if let Err(error) = result { + drop(terminal); + lifecycle.restore(); + let _ = writeln!( + io::stderr().lock(), + "Terminal interaction failed; waiting for submitted application work to finish." + ); + for outcome in + await_worker_after_terminal_error(&mut worker, state.pending, last_operation_outcome) + { + let _ = writeln!(io::stderr().lock(), "{outcome}"); + } + return Err(error); + } + Ok(()) +} + +fn run_screen_loop( + terminal: &mut Terminal>, + worker: &mut ApplicationWorker, + state: &mut UiState, + forms: &mut FormController, + last_operation_outcome: &mut Option, +) -> Result<(), TerminalError> { + let mut exiting = false; + loop { + drain_worker_events(worker, state, forms, last_operation_outcome); + terminal + .draw(|frame| render::draw(frame, state, forms)) + .map_err(|_| TerminalError::Io)?; + + if exiting { + match worker.shutdown(Duration::from_millis(20)) { + ShutdownProgress::Stopped => return Ok(()), + ShutdownProgress::Disconnected => return Err(TerminalError::Worker), + ShutdownProgress::Pending => { + state.screen = Screen::Progress; + state.status_message = Some( + "Waiting for the submitted operation or runtime read to finish. Press no further keys; outcome will be retained.".to_owned(), + ); + } + } + continue; + } + + if event::poll(EVENT_POLL).map_err(|_| TerminalError::Io)? { + match event::read().map_err(|_| TerminalError::Io)? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + let code = if key.code == KeyCode::Char('c') + && key.modifiers.contains(KeyModifiers::CONTROL) + { + KeyCode::Char('q') + } else { + key.code + }; + if forms.is_active() { + apply_form_action( + forms.handle_key(code), + worker, + state, + forms, + &mut exiting, + ); + } else { + handle_key(code, worker, state, forms, &mut exiting); + } + } + Event::Resize(_, _) => {} + _ => {} + } + } + } +} + +trait WorkerLifecycle { + fn shutdown(&mut self, timeout: Duration) -> ShutdownProgress; + fn try_receive(&mut self) -> Option; +} + +impl WorkerLifecycle for ApplicationWorker { + fn shutdown(&mut self, timeout: Duration) -> ShutdownProgress { + ApplicationWorker::shutdown(self, timeout) + } + + fn try_receive(&mut self) -> Option { + ApplicationWorker::try_receive(self) + } +} + +fn await_worker_after_terminal_error( + worker: &mut impl WorkerLifecycle, + initially_pending: Option, + previous_outcome: Option, +) -> Vec { + let mut outcomes = Vec::new(); + if let Some(outcome) = previous_outcome { + outcomes.push(format!( + "Most recent submitted operation: {}", + render::safe_text(&outcome) + )); + } + let mut pending_operation = initially_pending; + loop { + match worker.shutdown(Duration::from_millis(100)) { + ShutdownProgress::Stopped | ShutdownProgress::Disconnected => break, + ShutdownProgress::Pending => {} + } + while let Some(event) = worker.try_receive() { + record_shutdown_event(event, &mut pending_operation, &mut outcomes); + } + } + while let Some(event) = worker.try_receive() { + record_shutdown_event(event, &mut pending_operation, &mut outcomes); + } + if let Some(operation) = pending_operation { + outcomes.push(format!( + "Submitted operation {} ({:?}) has an uncertain outcome; the worker disconnected before reporting completion.", + operation.request_id, operation.kind + )); + } + if outcomes.is_empty() { + outcomes.push("No submitted application operation remained pending.".to_owned()); + } + outcomes +} + +fn record_shutdown_event( + event: WorkerEvent, + pending_operation: &mut Option, + outcomes: &mut Vec, +) { + match event { + WorkerEvent::OperationStarted(operation) => *pending_operation = Some(operation), + WorkerEvent::OperationFinished { + operation, result, .. + } => { + let outcome = match result { + Ok(_) => "completed".to_owned(), + Err(error) => format!("failed: {}", render::safe_text(&error.message)), + }; + outcomes.push(format!( + "Submitted operation {} ({:?}) {outcome}.", + operation.request_id, operation.kind + )); + if *pending_operation == Some(operation) { + *pending_operation = None; + } + } + WorkerEvent::SessionReady(_) + | WorkerEvent::Statistics { .. } + | WorkerEvent::ShutdownComplete => {} + } +} + +fn drain_worker_events( + worker: &mut ApplicationWorker, + state: &mut UiState, + forms: &mut FormController, + last_operation_outcome: &mut Option, +) { + while let Some(event) = worker.try_receive() { + match event { + WorkerEvent::SessionReady(view) => { + state.generation = view.generation; + let _ = state.apply_view(view); + } + WorkerEvent::OperationStarted(operation) => { + let _ = state.begin_operation(operation); + } + WorkerEvent::OperationFinished { + operation, + result, + view, + } => { + if operation.generation >= state.generation { + state.generation = operation.generation; + let _ = state.apply_view(*view); + } + let (message, outcome) = match result { + Ok(result) => { + let message = process_command_result(result, state, forms); + (message, "completed".to_owned()) + } + Err(error) => ( + error.message.clone(), + format!("failed: {}", render::safe_text(&error.message)), + ), + }; + *last_operation_outcome = Some(format!( + "Submitted operation {} ({:?}) {outcome}.", + operation.request_id, operation.kind + )); + let _ = state.finish_operation(operation); + state.status_message = Some(super::render::safe_text(&message)); + if !forms.is_active() { + state.screen = Screen::Message; + state.scroll_offset = 0; + } + } + WorkerEvent::Statistics { + generation, + observation, + } => { + let _ = state.apply_statistics(generation, observation); + } + WorkerEvent::ShutdownComplete => {} + } + } +} + +fn result_message(result: &CommandResult) -> String { + match result { + CommandResult::ClientCreated(key) => format!("Client {key} added to the draft."), + CommandResult::ClientRenamed => "Alias updated in the draft.".to_owned(), + CommandResult::ClientDeleted { record_removed } => { + format!("Peer removed from the draft; vault record removed: {record_removed}.") + } + CommandResult::DraftDiscarded => "Draft discarded.".to_owned(), + CommandResult::Committed(path) => format!( + "Saved files committed with backup at {}.", + render::safe_text(&path.display().to_string()) + ), + CommandResult::BackupCreated(path) => format!( + "Backup created at {}.", + render::safe_text(&path.display().to_string()) + ), + CommandResult::Backups(items) => format!( + "{} archive(s) listed. Select an exact archive to preview restoration.", + items.len() + ), + CommandResult::SelectedFileImported(report) => { + let mut lines = vec![format!( + "Selected source scanned; complete: {}. Original file preserved.", + report.scan_complete + )]; + lines.extend(report.candidates.iter().map(|(name, status)| { + format!("Candidate {}: {status:?}", render::safe_text(name)) + })); + lines.extend(report.issues.iter().map(|issue| { + format!( + "Anomaly: {} ({:?})", + issue + .file_name + .as_deref() + .map(render::safe_text) + .unwrap_or_else(|| "selected entry".to_owned()), + issue.kind + ) + })); + lines.extend( + report + .server_only + .iter() + .map(|key| format!("Server-only peer: {key}")), + ); + lines.join("\n") + } + CommandResult::InitialSetupPrepared(report) => { + let mut lines = vec![format!( + "Vault setup staged; scan complete: {}. Review draft, then Commit to save.", + report.scan_complete + )]; + lines.extend(report.issues.iter().map(|issue| { + format!( + "Anomaly: {} ({:?})", + issue + .file_name + .as_deref() + .map(render::safe_text) + .unwrap_or_else(|| "source directory".to_owned()), + issue.kind + ) + })); + lines.extend(report.reconciliation.candidate_summaries().into_iter().map( + |(name, status)| format!("Candidate {}: {status:?}", render::safe_text(name)), + )); + lines.extend( + report + .reconciliation + .server_only() + .iter() + .map(|key| format!("Server-only peer: {key}")), + ); + lines.join("\n") + } + CommandResult::RestartPreview(_) => { + "Restart preview ready; confirm the exact saved-state action.".to_owned() + } + CommandResult::Restarted(report) => format!( + "Restart result: {:?}; failure: {:?}; submitted: {}; may still complete: {}; manual recovery required: {}.", + report.stage, + report.failure, + report.restart_submitted, + report.restart_may_still_complete, + report.manual_recovery_required + ), + CommandResult::DeliveryDestination(destination) => format!( + "Prepared destination {}. Overwrite confirmation required: {}.", + render::safe_text(&destination.path.display().to_string()), + destination.overwrite_required + ), + CommandResult::FirstIssue(summary) => match summary.status { + FirstIssueStatus::Delivered => format!( + "Client file delivered.{} QR result: {:?}.", + if summary.activation_warning { + " Access is not verified as active yet." + } else { + "" + }, + summary.qr + ), + FirstIssueStatus::PublishedButBookkeepingUncertain => format!( + "Client file may have been published but delivery bookkeeping is uncertain. Inspect the destination; fresh password authorization is required.{} QR result: {:?}.", + if summary.activation_warning { + " Access is not verified as active yet." + } else { + "" + }, + summary.qr + ), + FirstIssueStatus::Failed(error) => format!( + "Client file delivery failed: {error:?}. Retry state: {:?}.", + summary.retry + ), + }, + CommandResult::Reissued(summary) => format!( + "Password-authorized file reissue completed.{} QR result: {:?}.", + if summary.activation_warning { + " Access is not verified as active yet." + } else { + "" + }, + summary.qr + ), + CommandResult::RestorePreview(preview) => format!( + "Restore preview ready for exact archive {}.", + render::safe_text(&preview.file_name) + ), + CommandResult::Restored(path) => format!( + "Saved state restored from {}. Runtime state remains unchanged and unknown until explicit restart.", + render::safe_text(&path.display().to_string()) + ), + CommandResult::Cancelled => "Prepared action cancelled.".to_owned(), + } +} + +fn process_command_result( + result: CommandResult, + state: &mut UiState, + forms: &mut FormController, +) -> String { + let message = result_message(&result); + match result { + CommandResult::RestartPreview(preview) => forms.open_restart(preview), + CommandResult::DeliveryDestination(summary) => forms.open_delivery_confirmation(summary), + CommandResult::FirstIssue(mut summary) => { + forms.note_first_issue(summary.identity, summary.retry); + if let Some(qr) = summary.qr_output.take() { + forms.open_disclosure(qr, message.clone()); + } + } + CommandResult::Reissued(mut summary) => { + if let Some(qr) = summary.qr_output.take() { + forms.open_disclosure(qr, message.clone()); + } + } + CommandResult::Backups(items) => forms.open_backups(items), + CommandResult::RestorePreview(summary) => forms.open_restore_confirmation( + summary, + state.view.as_ref().is_some_and(|view| view.has_draft), + ), + CommandResult::Restored(_) => forms.clear_delivery_state(), + _ => {} + } + state.status_message = Some(render::safe_text(&message)); + if !forms.is_active() { + state.screen = Screen::Message; + } + message +} + +fn submit_command(worker: &ApplicationWorker, command: UiCommand, state: &mut UiState) { + let result = worker.submit(command); + match result { + Ok(operation) => { + let _ = state.begin_operation(operation); + state.status_message = Some("Operation submitted; waiting for its result.".to_owned()); + } + Err(error) => state.status_message = Some(submit_error(error)), + } +} + +fn submit_sensitive(worker: &ApplicationWorker, command: SensitiveCommand, state: &mut UiState) { + match worker.submit_sensitive(command) { + Ok(operation) => { + let _ = state.begin_operation(operation); + state.status_message = + Some("Setup submitted; scanning configured client files.".to_owned()); + } + Err(error) => state.status_message = Some(submit_error(error)), + } +} + +fn submit_error(error: SubmitError) -> String { + match error { + SubmitError::Busy => "An operation is already running.".to_owned(), + SubmitError::QueueFull => "Application queue is full; try again shortly.".to_owned(), + SubmitError::ShuttingDown => "Application worker is shutting down.".to_owned(), + SubmitError::RequestIdsExhausted => { + "No further operations can be submitted in this session.".to_owned() + } + } +} + +fn apply_form_action( + action: FormAction, + worker: &ApplicationWorker, + state: &mut UiState, + forms: &mut FormController, + exiting: &mut bool, +) { + match action { + FormAction::None => {} + FormAction::Cancel => { + state.cancel_dialog(); + state.status_message = Some("Cancelled; draft preserved.".to_owned()); + } + FormAction::Submit(command) => submit_command(worker, command, state), + FormAction::SubmitSensitive(command) => submit_sensitive(worker, command, state), + FormAction::PreviewRestart => submit_command(worker, UiCommand::PreviewRestart, state), + FormAction::ConfirmRename { identity, alias } => { + forms.open_rename_confirmation(identity, alias); + } + FormAction::CancelRestorePreview => { + submit_command(worker, UiCommand::CancelRestorePreview, state) + } + FormAction::CancelPreparedDelivery => { + submit_command(worker, UiCommand::CancelPreparedDelivery, state) + } + FormAction::StartReissue { + identity, + overwrite_confirmed, + prepared_generation, + } => forms.open_reissue_password(identity, overwrite_confirmed, prepared_generation), + FormAction::Exit => *exiting = true, + FormAction::Feedback(message) => forms.feedback(message), + } +} + +fn handle_key( + code: KeyCode, + worker: &ApplicationWorker, + state: &mut UiState, + forms: &mut FormController, + exiting: &mut bool, +) { + if state.pending.is_some() { + return; + } + match code { + KeyCode::Char('q') => { + if state.view.as_ref().is_some_and(|view| view.has_draft) { + forms.open_exit_with_draft(); + } else { + *exiting = true; + } + } + KeyCode::Char('s') + if state + .view + .as_ref() + .is_some_and(|view| !view.vault_initialized) => + { + forms.open_setup() + } + KeyCode::Char('n') if state.view.is_some() => forms.open_create(), + KeyCode::Char('i') => forms.open_import(), + KeyCode::Char('r') => { + if state.selected_client_mutable() { + let selected = state.selected_identity.and_then(|id| { + state + .view + .as_ref() + .and_then(|v| v.clients.iter().find(|c| c.identity == Some(id))) + }); + if let (Some(id), Some(client)) = (state.selected_identity, selected) { + forms.open_rename(id, &client.label); + } + } else { + state.status_message = Some( + "Select a supported peer with an unambiguous public key first.".to_owned(), + ); + } + } + KeyCode::Char('d') => { + if state.selected_client_mutable() { + if let (Some(id), Some(view)) = (state.selected_identity, state.view.as_ref()) { + let label = view + .clients + .iter() + .find(|c| c.identity == Some(id)) + .map_or("peer", |c| c.label.as_str()); + forms.open_delete(id, label); + } + } else { + state.status_message = Some( + "Select a supported peer with an unambiguous public key first.".to_owned(), + ); + } + } + KeyCode::Char('p') => forms.open_draft_review(), + KeyCode::Char('c') => forms.open_commit(), + KeyCode::Char('x') => forms.open_discard(), + KeyCode::Char('R') => submit_command(worker, UiCommand::PreviewRestart, state), + KeyCode::Char('o') | KeyCode::Char('O') => { + if state.selected_client_mutable() + && let Some(identity) = state.selected_identity + && state.view.as_ref().is_some_and(|view| { + view.client_states + .get(&identity) + .is_some_and(|row| row.saved.is_some()) + }) + { + forms.open_delivery(identity, code == KeyCode::Char('O')); + } else { + state.status_message = + Some("Select a supported saved client by full public key first.".to_owned()); + } + } + KeyCode::Char('b') => submit_command(worker, UiCommand::ListBackups, state), + KeyCode::Char('B') => submit_command(worker, UiCommand::CreateBackup, state), + KeyCode::Char('?') => { + state.screen = Screen::Help; + state.status_message = None; + state.scroll_offset = 0; + } + KeyCode::Char('!') => { + let Some(view) = &state.view else { + state.status_message = Some("Client and issue data are still loading.".to_owned()); + state.screen = Screen::Message; + return; + }; + let mut issues = Vec::new(); + issues.extend( + view.document_issues + .iter() + .map(|issue| format!("Document issue: {issue:?}")), + ); + for client in &view.clients { + for issue in &client.issues { + issues.push(format!("Peer at line {}: {issue:?}", client.line)); + } + } + state.status_message = Some(if issues.is_empty() { + "No document or peer issues were reported.".to_owned() + } else { + issues.join("\n") + }); + state.screen = Screen::Message; + state.scroll_offset = 0; + } + KeyCode::Esc => { + state.screen = Screen::ClientList; + state.dialog = None; + state.scroll_offset = 0; + } + KeyCode::Enter if state.screen == Screen::ClientList => { + let peer_index = state.selected_peer_index.or_else(|| { + state + .view + .as_ref() + .and_then(|view| view.clients.first().map(|client| client.peer_index)) + }); + if let Some(peer_index) = peer_index { + let _ = state.select_peer_index(peer_index); + } + } + KeyCode::Up | KeyCode::Down if state.screen == Screen::ClientList => { + move_selection(state, matches!(code, KeyCode::Down)); + } + KeyCode::Up | KeyCode::PageUp + if state.screen == Screen::ClientDetails + || state.screen == Screen::Help + || state.screen == Screen::Message => + { + state.scroll_offset = state.scroll_offset.saturating_sub(1); + } + KeyCode::Down | KeyCode::PageDown + if state.screen == Screen::ClientDetails + || state.screen == Screen::Help + || state.screen == Screen::Message => + { + state.scroll_offset = state.scroll_offset.saturating_add(1); + } + _ => {} + } +} + +fn move_selection(state: &mut UiState, down: bool) { + let Some(view) = &state.view else { return }; + if view.clients.is_empty() { + return; + } + let current = state.selected_peer_index.and_then(|selected| { + view.clients + .iter() + .position(|row| row.peer_index == selected) + }); + let next = match (current, down) { + (None, true) => 0, + (None, false) => view.clients.len() - 1, + (Some(index), true) => (index + 1) % view.clients.len(), + (Some(index), false) => index.checked_sub(1).unwrap_or(view.clients.len() - 1), + }; + let peer_index = view.clients[next].peer_index; + let _ = state.select_peer_index(peer_index); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + use std::process::Command; + use std::sync::Mutex; + + use crate::runtime::RuntimeObservation; + use crate::tui::events::OperationError; + use crate::tui::state::{AppliedState, OperationKind, SessionView}; + + struct FakeWorker { + shutdown_calls: usize, + events: VecDeque, + } + + impl WorkerLifecycle for FakeWorker { + fn shutdown(&mut self, _timeout: Duration) -> ShutdownProgress { + self.shutdown_calls += 1; + if self.shutdown_calls < 3 { + ShutdownProgress::Pending + } else { + ShutdownProgress::Stopped + } + } + + fn try_receive(&mut self) -> Option { + self.events.pop_front() + } + } + + #[test] + fn suitability_rejects_non_tty_and_small_or_unsupported_terminal_before_setup() { + assert_eq!( + check_suitable_for(false, true, Ok((80, 24))), + Err(TerminalError::NotInteractive) + ); + assert_eq!( + check_suitable_for(true, true, Ok((20, 10))), + Err(TerminalError::TooSmall) + ); + assert_eq!( + check_suitable_for(true, true, Err(io::Error::other("fixture"))), + Err(TerminalError::Unsupported) + ); + assert_eq!(check_suitable_for(true, true, Ok((80, 24))), Ok(())); + } + + #[test] + fn restart_results_render_cancel_success_and_uncertain_failure_honestly() { + use crate::app::{RestartFailureKind, RestartReport, RestartStage}; + + let report = |stage, failure, restart_submitted, restart_may_still_complete| { + CommandResult::Restarted(RestartReport { + stage, + failure, + backup_path: None, + restart_submitted, + restart_may_still_complete, + manual_recovery_required: restart_may_still_complete, + }) + }; + let cancelled = result_message(&report(RestartStage::Cancelled, None, false, false)); + assert!(cancelled.contains("Cancelled")); + assert!(cancelled.contains("submitted: false")); + + let success = result_message(&report(RestartStage::Complete, None, true, false)); + assert!(success.contains("Complete")); + assert!(success.contains("submitted: true")); + + let uncertain = result_message(&report( + RestartStage::Service, + Some(RestartFailureKind::ServiceFailed), + true, + true, + )); + assert!(uncertain.contains("ServiceFailed")); + assert!(uncertain.contains("may still complete: true")); + assert!(uncertain.contains("manual recovery required: true")); + } + + #[test] + fn terminal_error_waits_for_worker_and_records_its_submitted_result() { + let operation = super::super::state::PendingOperation { + request_id: 9, + kind: OperationKind::Commit, + generation: 1, + }; + let view = SessionView { + generation: 1, + clients: Vec::new(), + client_states: Default::default(), + document_issues: Vec::new(), + applied: AppliedState::Unknown, + reserved_addresses: Default::default(), + statistics: RuntimeObservation::unknown(), + has_draft: false, + vault_initialized: true, + }; + let mut worker = FakeWorker { + shutdown_calls: 0, + events: VecDeque::from([ + WorkerEvent::OperationStarted(operation), + WorkerEvent::OperationFinished { + operation, + result: Err(OperationError { + message: "synthetic failure".to_owned(), + }), + view: Box::new(view), + }, + ]), + }; + let outcomes = await_worker_after_terminal_error(&mut worker, None, None); + assert_eq!(worker.shutdown_calls, 3); + assert_eq!( + outcomes, + ["Submitted operation 9 (Commit) failed: synthetic failure."] + ); + } + + #[test] + fn panic_cleanup_hook_is_restored_when_tui_scope_ends() { + static HOOK_TEST_LOCK: Mutex<()> = Mutex::new(()); + let _lock = HOOK_TEST_LOCK.lock().unwrap(); + let original = std::panic::take_hook(); + let previous_called = Arc::new(AtomicBool::new(false)); + let called = Arc::clone(&previous_called); + std::panic::set_hook(Box::new(move |_| { + called.store(true, Ordering::Release); + })); + + let guard = PanicHookGuard::install(Arc::new(Lifecycle::new())); + let result = std::panic::catch_unwind(|| panic!("synthetic marker")); + assert!(result.is_err()); + assert!(!previous_called.load(Ordering::Acquire)); + drop(guard); + + let _ = std::panic::catch_unwind(|| panic!("verify prior hook")); + assert!(previous_called.load(Ordering::Acquire)); + let _temporary = std::panic::take_hook(); + std::panic::set_hook(original); + } + + #[test] + fn lifecycle_failures_restore_terminal_modes_in_isolated_pty() { + const CHILD: &str = "WG_ADMIN_LIFECYCLE_PROBE"; + if let Some(scenario) = std::env::var_os(CHILD) { + let scenario = scenario.to_string_lossy(); + let result = with_terminal_lifecycle(|lifecycle| { + lifecycle.enable_raw()?; + if scenario == "partial-raw" { + return Err(TerminalError::Setup); + } + let mut stdout = io::stdout(); + lifecycle.enter_alternate(&mut stdout)?; + if scenario == "partial-alternate" { + return Err(TerminalError::Setup); + } + lifecycle.hide_cursor(&mut stdout)?; + match scenario.as_ref() { + "render-error" | "input-error" => Err(TerminalError::Io), + "panic" => panic!("synthetic lifecycle panic marker"), + _ => Err(TerminalError::Unexpected), + } + }); + let expected = if scenario == "panic" { + TerminalError::Unexpected + } else if scenario == "render-error" || scenario == "input-error" { + TerminalError::Io + } else { + TerminalError::Setup + }; + assert_eq!(result, Err(expected)); + assert!(!crossterm::terminal::is_raw_mode_enabled().unwrap()); + return; + } + + if Command::new("script").arg("--version").output().is_err() { + eprintln!("PTY lifecycle checks incomplete: `script` utility is unavailable"); + return; + } + let executable = std::env::current_exe().expect("test executable path"); + for (scenario, minimum_alt_enters, minimum_cursor_hides) in [ + ("partial-raw", 0, 0), + ("partial-alternate", 1, 0), + ("render-error", 1, 1), + ("input-error", 1, 1), + ("panic", 1, 1), + ] { + let command = format!( + "stty cols 80 rows 24; env {CHILD}={scenario} {} --exact tui::terminal::tests::lifecycle_failures_restore_terminal_modes_in_isolated_pty --nocapture", + shell_quote(&executable.to_string_lossy()) + ); + let output = Command::new("script") + .args(["-qefc", &command, "/dev/null"]) + .output() + .expect("run isolated lifecycle PTY probe"); + assert!( + output.status.success(), + "PTY lifecycle probe {scenario} succeeded: {}", + String::from_utf8_lossy(&output.stdout) + ); + let transcript = String::from_utf8_lossy(&output.stdout); + assert_eq!( + transcript.matches("\u{1b}[?1049h").count(), + minimum_alt_enters, + "alternate screen entered for {scenario}" + ); + assert_eq!( + transcript.matches("\u{1b}[?1049l").count(), + minimum_alt_enters, + "alternate screen restored for {scenario}" + ); + assert_eq!( + transcript.matches("\u{1b}[?25l").count(), + minimum_cursor_hides, + "cursor hidden for {scenario}" + ); + assert_eq!( + transcript.matches("\u{1b}[?25h").count(), + minimum_cursor_hides, + "cursor shown for {scenario}" + ); + if scenario == "panic" { + assert!( + transcript + .contains("wireguard-admin encountered an unexpected internal failure") + ); + assert!(!transcript.contains("synthetic lifecycle panic marker")); + } + } + } + + fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) + } +} diff --git a/src/vault/source.rs b/src/vault/source.rs index 8d6b06d..59792b8 100644 --- a/src/vault/source.rs +++ b/src/vault/source.rs @@ -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, + pub candidates: Vec<(String, CandidateStatus)>, + pub server_only: Vec, +} + #[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 { + 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(); diff --git a/tests/tui_workflow.rs b/tests/tui_workflow.rs new file mode 100644 index 0000000..11813e4 --- /dev/null +++ b/tests/tui_workflow.rs @@ -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); + +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::>(); + + 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::>(); + 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); +}