diff --git a/Cargo.lock b/Cargo.lock index 3341d0a..669d1cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,7 +89,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "rand 0.9.2", - "sha1", + "sha1 0.10.6", "smallvec", "tokio", "tokio-util", @@ -230,6 +230,43 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.0", + "zeroize", +] + +[[package]] +name = "aes-gcm" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", + "zeroize", +] + [[package]] name = "ahash" version = "0.7.8" @@ -425,6 +462,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "argon2" +version = "0.6.0-rc.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7af50940b73bf4e16c15c448a2b121c63f2d68e3e54b6a8731673cb4aa0cdff5" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.3.0", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -726,6 +775,30 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.8.8" @@ -778,6 +851,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.13.1" @@ -790,6 +869,23 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt-pbkdf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2 0.11.0", +] + [[package]] name = "bds-cli" version = "0.1.0" @@ -835,7 +931,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml 0.9.34+deprecated", - "sha2", + "sha2 0.10.9", "sys-locale", "tempfile", "thiserror 2.0.18", @@ -865,6 +961,22 @@ dependencies = [ "tempfile", ] +[[package]] +name = "bds-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "bds-core", + "clap", + "dirs 5.0.1", + "russh", + "serde", + "serde_json", + "tempfile", + "tokio", + "uuid", +] + [[package]] name = "bds-ui" version = "0.1.0" @@ -872,6 +984,7 @@ dependencies = [ "anyhow", "bds-core", "bds-editor", + "bds-server", "chrono", "dirs 5.0.1", "fluent-syntax", @@ -980,6 +1093,15 @@ dependencies = [ "core2", ] +[[package]] +name = "blake2" +version = "0.11.0-rc.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "block" version = "0.1.6" @@ -992,7 +1114,26 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.7", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", ] [[package]] @@ -1026,6 +1167,16 @@ dependencies = [ "piper", ] +[[package]] +name = "blowfish" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "brotli" version = "8.0.2" @@ -1207,6 +1358,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.58" @@ -1253,6 +1413,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.3.0", + "rand_core 0.10.1", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.44" @@ -1267,6 +1440,18 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "4.6.0" @@ -1346,6 +1531,21 @@ dependencies = [ "x11rb", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "codespan-reporting" version = "0.11.1" @@ -1485,6 +1685,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const_format" version = "0.2.35" @@ -1645,6 +1851,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1654,6 +1866,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1703,16 +1924,54 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.2", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "serdect", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", + "generic-array 0.14.7", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.2", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint", + "rand_core 0.10.1", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -1742,12 +2001,58 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e162d0c2e2068eb736b71e5597eff0b9944e6b973cd9f37b6a288ab9bf20e300" +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", + "subtle", +] + [[package]] name = "cursor-icon" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "curve25519-dalek" +version = "5.0.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c906a87e53a36ff795d72e06e8162a83c5436e3ea89e942a9cb9fc083f0a384f" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "cxx" version = "1.0.198" @@ -1916,6 +2221,12 @@ dependencies = [ "serde", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "data-url" version = "0.3.2" @@ -1949,6 +2260,28 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7046468a81e6a002061c01e6a7c83139daf91b11c30e66795b13217c2d885c8b" +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2012,6 +2345,15 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "des" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" +dependencies = [ + "cipher", +] + [[package]] name = "detect-desktop-environment" version = "0.2.0" @@ -2076,8 +2418,20 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -2303,12 +2657,75 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der", + "digest 0.11.3", + "elliptic-curve", + "rfc6979", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1685663e23882cd8517dcbcb1c23a6ebff4433c22dfb681d760219b62cd1b849" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff", + "group", + "hkdf", + "hybrid-array", + "pem-rfc7468", + "pkcs8", + "rand_core 0.10.1", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "emojis" version = "0.8.0" @@ -2339,6 +2756,18 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -2540,6 +2969,22 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "field-offset" version = "0.3.6" @@ -2713,6 +3158,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -2909,6 +3360,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "generic-array" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab4e5aa225bc56696909483320f0ff9b600f1a971b52e07a17d70f3d9b43254b" +dependencies = [ + "generic-array 0.14.7", + "rustversion", + "typenum", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -2962,10 +3424,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", ] [[package]] @@ -3168,6 +3642,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "gtk" version = "0.18.2" @@ -3340,6 +3825,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + [[package]] name = "hexf-parse" version = "0.2.1" @@ -3366,6 +3857,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "hmac-sha256" version = "1.1.14" @@ -3464,6 +3973,18 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "ctutils", + "subtle", + "typenum", + "zeroize", +] + [[package]] name = "hyper" version = "1.9.0" @@ -3976,6 +4497,16 @@ dependencies = [ "web-time", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -3985,6 +4516,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "internal-russh-num-bigint" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8e22120c32fb4d19ec55fba35015f57095cd95a2e3b732e44457f5915b2ee8" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.10.2", + "rand_core 0.10.1", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -4208,6 +4751,26 @@ dependencies = [ "mutate_once", ] +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -4365,9 +4928,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.184" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libdbus-sys" @@ -4778,6 +5341,12 @@ dependencies = [ "rayon", ] +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + [[package]] name = "memchr" version = "2.8.0" @@ -4911,6 +5480,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8", + "rand_core 0.10.1", + "sha3", +] + [[package]] name = "mlua" version = "0.12.0" @@ -4943,6 +5526,17 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + [[package]] name = "monostate" version = "0.1.18" @@ -5094,6 +5688,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -5744,6 +6350,67 @@ dependencies = [ "ttf-parser 0.25.1", ] +[[package]] +name = "p256" +version = "0.14.0-rc.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bb40a5099e2c38a09dd29321a7a7f045f165a54317679c7cdfb0cbaf8f6b1e" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "p384" +version = "0.14.0-rc.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "492f329d7eb11d22dadc988626b9ea1f503b4ab043a8b1e4e2cc4ae45dabdd70" +dependencies = [ + "ecdsa", + "elliptic-curve", + "fiat-crypto", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "p521" +version = "0.14.0-rc.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff42e4ace5424e3b6d7cb82514be89866b85af87015f80341e37dcc21a66ce6e" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "pageant" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f3a5ae18f65a85c67a77d18d42d3606c07948e3c17c1e5f74852b26589e88a5" +dependencies = [ + "base16ct", + "byteorder", + "bytes", + "delegate", + "futures", + "log", + "rand 0.10.2", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "windows 0.62.2", + "windows-strings 0.5.1", +] + [[package]] name = "pagefind" version = "1.5.2" @@ -5898,6 +6565,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "phc", +] + [[package]] name = "paste" version = "1.0.15" @@ -5922,6 +6598,25 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -5968,7 +6663,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", +] + +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", ] [[package]] @@ -6115,6 +6820,45 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkcs5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "der", + "pbkdf2", + "rand_core 0.10.1", + "scrypt", + "sha2 0.11.0", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "pkcs5", + "rand_core 0.10.1", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -6186,6 +6930,28 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.0", + "universal-hash", + "zeroize", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash", +] + [[package]] name = "pori" version = "0.0.0" @@ -6265,6 +7031,33 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -6523,6 +7316,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -6561,6 +7365,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "range-alloc" version = "0.1.5" @@ -6833,6 +7643,16 @@ dependencies = [ "usvg", ] +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac", +] + [[package]] name = "rfd" version = "0.15.4" @@ -6876,7 +7696,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -6896,6 +7716,25 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rsa" +version = "0.10.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" +dependencies = [ + "const-oid", + "crypto-bigint", + "crypto-primes", + "digest 0.11.3", + "pkcs1", + "pkcs8", + "rand_core 0.10.1", + "sha2 0.11.0", + "signature", + "spki", + "zeroize", +] + [[package]] name = "rsqlite-vfs" version = "0.1.1" @@ -6906,6 +7745,102 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "russh" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca6c3c1dd0a9ad3a9915a2f6e907d158f9a7e9ac32ddc772b003faafc027d9a8" +dependencies = [ + "aes", + "aws-lc-rs", + "bitflags 2.11.0", + "block-padding", + "byteorder", + "bytes", + "cbc", + "cipher", + "crypto-bigint", + "ctr", + "curve25519-dalek", + "data-encoding", + "delegate", + "der", + "digest 0.11.3", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "enum_dispatch", + "flate2", + "futures", + "generic-array 1.4.4", + "getrandom 0.4.2", + "ghash", + "hex-literal", + "hmac", + "inout", + "internal-russh-num-bigint", + "keccak", + "log", + "md5", + "ml-kem", + "module-lattice", + "num-bigint", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2", + "pkcs1", + "pkcs5", + "pkcs8", + "polyval", + "rand 0.10.2", + "rand_core 0.10.1", + "rsa", + "russh-cryptovec", + "russh-util", + "salsa20", + "scrypt", + "sec1", + "sha1 0.11.0", + "sha2 0.11.0", + "sha3", + "signature", + "spki", + "ssh-encoding", + "ssh-key", + "subtle", + "thiserror 2.0.18", + "tokio", + "typenum", + "universal-hash", + "zeroize", +] + +[[package]] +name = "russh-cryptovec" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aec6cb630dbe85d72ffd7bcd95f07e1bd69f9f270ee8adfa1afe443a6331438" +dependencies = [ + "log", + "nix 0.31.3", + "ssh-encoding", + "windows-sys 0.61.2", +] + +[[package]] +name = "russh-util" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "rust-ini" version = "0.18.0" @@ -7027,7 +7962,7 @@ checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" dependencies = [ "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -7072,6 +8007,16 @@ dependencies = [ "tempfile", ] +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -7099,6 +8044,18 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" +[[package]] +name = "scrypt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" +dependencies = [ + "cfg-if", + "pbkdf2", + "salsa20", + "sha2 0.11.0", +] + [[package]] name = "sctk-adwaita" version = "0.10.1" @@ -7112,6 +8069,20 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -7328,6 +8299,16 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "servo_arc" version = "0.4.3" @@ -7344,8 +8325,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -7355,8 +8336,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -7366,8 +8358,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak", ] [[package]] @@ -7386,6 +8399,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -7633,6 +8656,16 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "spm_precompiled" version = "0.1.4" @@ -7657,6 +8690,65 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ssh-cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "chacha20", + "cipher", + "ctutils", + "des", + "poly1305", + "ssh-encoding", + "zeroize", +] + +[[package]] +name = "ssh-encoding" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e" +dependencies = [ + "base64ct", + "bytes", + "crypto-bigint", + "ctutils", + "digest 0.11.3", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "ssh-key" +version = "0.7.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a32fae177b74a22aa9c5b01bf7e68b33545be32d9e381e248058d2adc15ce3" +dependencies = [ + "argon2", + "bcrypt-pbkdf", + "ctutils", + "ed25519-dalek", + "hex", + "hmac", + "p256", + "p384", + "p521", + "rand_core 0.10.1", + "rsa", + "sec1", + "sha1 0.11.0", + "sha2 0.11.0", + "signature", + "ssh-cipher", + "ssh-encoding", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -8441,9 +9533,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -8588,12 +9680,28 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -9387,11 +10495,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -9403,6 +10523,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -9446,7 +10575,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -9493,6 +10633,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -9655,6 +10805,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -10030,6 +11189,17 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wnaf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" +dependencies = [ + "ff", + "group", + "hybrid-array", +] + [[package]] name = "writeable" version = "0.6.2" @@ -10066,7 +11236,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", "thiserror 2.0.18", @@ -10245,12 +11415,12 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.29.0", "ordered-stream", "rand 0.8.5", "serde", "serde_repr", - "sha1", + "sha1 0.10.6", "static_assertions", "tracing", "uds_windows", @@ -10395,18 +11565,18 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 8b502f4..cf01dc8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/bds-ui", "crates/bds-cli", "crates/bds-mcp", + "crates/bds-server", ] [workspace.package] @@ -55,6 +56,7 @@ url = "2.5" fastembed = { version = "5.17.3", default-features = false, features = ["hf-hub-rustls-tls", "ort-download-binaries-rustls-tls"] } usearch = "2.26" ort = { version = "=2.0.0-rc.12", default-features = false, features = ["std", "api-24", "coreml", "directml"] } +russh = "0.62.2" # UI framework iced = { version = "0.13", features = ["wgpu", "advanced", "canvas", "image", "svg", "tokio", "markdown"] } @@ -72,3 +74,4 @@ rfd = "0.15" bds-core = { path = "crates/bds-core" } bds-editor = { path = "crates/bds-editor" } bds-mcp = { path = "crates/bds-mcp" } +bds-server = { path = "crates/bds-server" } diff --git a/README.md b/README.md index 2a5ac54..373ed15 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ The project is under active development. Core blogging workflows are broadly ava - Project-scoped typed domain events synchronize desktop views with shared-engine and future CLI mutations; persisted CLI notifications are consumed once, and the selected UI language is shared through settings. - Headless `bds-cli` automation for rebuild/repair/render, publishing and Git sync, post/media/gallery creation, shared settings/projects, utility Lua tasks, JSON I/O, airplane-mode AI routing, and guarded launcher installation from Settings → Data or `bds-cli install`. - Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, inert write proposals, explicit desktop approval, and opt-in Claude Code/Copilot configuration. +- Headless `bds-server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection. - Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups. - Local preview in the app or system browser. - Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using online or local OpenAI-compatible endpoints with airplane-mode gating. @@ -35,6 +36,7 @@ RuDS uses no JavaScript application runtime and loads no CSS or JavaScript from - `crates/bds-ui` — desktop application and platform integration - `crates/bds-cli` — headless automation CLI over the shared engines - `crates/bds-mcp` — packaged stdio MCP transport over the shared MCP engine +- `crates/bds-server` — headless engine host, SSH transport, remote protocol, and desktop client - `specs` — authoritative Allium behavior specifications - `fixtures` — compatibility projects and generated-site fixtures - `locales` — UI and native-menu translations @@ -48,3 +50,7 @@ RuDS uses no JavaScript application runtime and loads no CSS or JavaScript from - `../bDS2` — reference implementation when an Allium contract is ambiguous Contributor workflow and project invariants are documented in [AGENTS.md](AGENTS.md). + +## Headless server + +Run `bds-server` for a dedicated headless process, or set `BDS_MODE=server` when launching `bds-ui`. The SSH listener defaults to `127.0.0.1:2222`; use `--bind`/`BDS_SSH_BIND` and `--port`/`BDS_SSH_PORT` to opt into another address. Startup prints the private `authorized_keys` path. The desktop creates its own private `id_ed25519.pub`; add that public key to the server file, then use **File → Connect to Server…** and select a remote project. Host keys are recorded on first connection and verified thereafter. `BDS_MODE=tui` starts the same headless host with a terminal session attached to the launching shell. diff --git a/RUST_PLAN_CORE.md b/RUST_PLAN_CORE.md index b36ab3d..4b4fc26 100644 --- a/RUST_PLAN_CORE.md +++ b/RUST_PLAN_CORE.md @@ -29,6 +29,7 @@ Status in this document describes the current source code as of 2026-07-19. It d | `bds-editor` | Reusable Ropey/Syntect/Cosmic Text editor widget | | `bds-ui` | Iced application, native menus/dialogs, platform lifecycle, and embedded preview | | `bds-cli` | Extension-only headless automation surface over the shared engines | +| `bds-server` | Extension-only headless engine host and authenticated SSH transport | ## Current Core Status diff --git a/RUST_PLAN_EXTENSION.md b/RUST_PLAN_EXTENSION.md index ac7f0ad..0f406cb 100644 --- a/RUST_PLAN_EXTENSION.md +++ b/RUST_PLAN_EXTENSION.md @@ -89,14 +89,15 @@ Done: - Project-scoped `bds.*` capabilities, managed task progress, and operator cancellation during transform execution. - bDS2-compatible delivery behavior without adding unsupported deep-link actions. -### Headless server — Open +### Headless server — Complete -Open: +Done: -- Desktop/server/TUI boot-mode selection. -- Headless engine host and SSH transport. -- Private host-key and authorized-key management. -- Desktop connection flow for remote projects. +- Desktop/server/TUI boot selection occurs before desktop initialization; the dedicated `bds-server` binary never depends on or starts the native UI. +- The headless host reuses the application database, project registry, `CoreHost` service operations, task manager, MCP loopback endpoint, ordered domain-event bus, and CLI-notification watcher. +- A loopback-by-default Russh daemon generates and reuses a restrictive RSA host key, validates private directory/file modes, reloads `authorized_keys` for every authentication, and supports explicit external binding only. +- Versioned native remote-project sessions provide protocol negotiation, server locale, project selection, shared engine calls, replay-safe request IDs, ordered domain events, task snapshots, reconnect, concurrent clients, and graceful shutdown. +- The desktop uses a restrictive generated Ed25519 identity and TOFU `known_hosts`, with localized File-menu connect/project selection/open/failure/disconnect states. SSH shell channels host server-side terminal sessions and direct forwarding is restricted to the server-owned loopback endpoint. ### Terminal UI — Open diff --git a/crates/bds-core/src/scripting/core_host.rs b/crates/bds-core/src/scripting/core_host.rs index 69fbd39..1ab7780 100644 --- a/crates/bds-core/src/scripting/core_host.rs +++ b/crates/bds-core/src/scripting/core_host.rs @@ -88,6 +88,13 @@ impl CoreHost { self } + /// Attach the application-wide task service for remote and alternate UI + /// sessions that are not themselves running inside one managed task. + pub fn with_task_manager(mut self, manager: Arc) -> Self { + self.task_manager = Some(manager); + self + } + pub fn with_offline_mode(mut self, offline_mode: bool) -> Self { self.offline_mode = offline_mode; self diff --git a/crates/bds-server/Cargo.toml b/crates/bds-server/Cargo.toml new file mode 100644 index 0000000..ed23a53 --- /dev/null +++ b/crates/bds-server/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "bds-server" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +bds-core = { workspace = true } +anyhow = { workspace = true } +clap = { workspace = true } +dirs = { workspace = true } +russh = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/bds-server/src/auth.rs b/crates/bds-server/src/auth.rs new file mode 100644 index 0000000..6c33183 --- /dev/null +++ b/crates/bds-server/src/auth.rs @@ -0,0 +1,317 @@ +use std::fs::{self, OpenOptions}; +use std::io::Write as _; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use russh::keys::key::safe_rng; +use russh::keys::{Algorithm, PrivateKey, PublicKey, load_secret_key, ssh_key}; + +pub const HOST_KEY_FILE: &str = "ssh_host_rsa_key"; +pub const AUTHORIZED_KEYS_FILE: &str = "authorized_keys"; +pub const CLIENT_KEY_FILE: &str = "id_ed25519"; +pub const KNOWN_HOSTS_FILE: &str = "known_hosts"; + +#[derive(Debug, Clone)] +pub struct KeyMaterial { + pub directory: PathBuf, + pub host_key_path: PathBuf, + pub authorized_keys_path: PathBuf, +} + +impl KeyMaterial { + pub fn ensure(data_dir: &Path) -> Result { + let directory = data_dir.join("ssh"); + ensure_private_directory(&directory)?; + let host_key_path = directory.join(HOST_KEY_FILE); + let authorized_keys_path = directory.join(AUTHORIZED_KEYS_FILE); + ensure_private_file(&authorized_keys_path, b"")?; + if !host_key_path.exists() { + let key = PrivateKey::random(&mut safe_rng(), Algorithm::Rsa { hash: None }) + .context("could not generate the SSH host key")?; + let encoded = key + .to_openssh(ssh_key::LineEnding::LF) + .context("could not encode the SSH host key")?; + ensure_private_file(&host_key_path, encoded.as_bytes())?; + } + validate_private_file(&host_key_path)?; + validate_private_file(&authorized_keys_path)?; + load_secret_key(&host_key_path, None) + .with_context(|| format!("could not read SSH host key {}", host_key_path.display()))?; + Ok(Self { + directory, + host_key_path, + authorized_keys_path, + }) + } + + pub fn host_key(&self) -> Result { + validate_private_file(&self.host_key_path)?; + load_secret_key(&self.host_key_path, None).with_context(|| { + format!( + "could not read SSH host key {}", + self.host_key_path.display() + ) + }) + } + + /// Re-reads the file for every authentication attempt, so removing a key + /// revokes it without restarting the server. + pub fn authorizes(&self, candidate: &PublicKey) -> Result { + validate_private_file(&self.authorized_keys_path)?; + let contents = fs::read_to_string(&self.authorized_keys_path).with_context(|| { + format!( + "could not read authorized keys {}", + self.authorized_keys_path.display() + ) + })?; + for (index, line) in contents.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let fields = line.split_whitespace().collect::>(); + let key_start = fields + .iter() + .position(|field| { + field.starts_with("ssh-") + || field.starts_with("ecdsa-") + || field.starts_with("sk-") + }) + .filter(|index| fields.get(index + 1).is_some()); + let encoded = key_start + .map(|index| format!("{} {}", fields[index], fields[index + 1])) + .unwrap_or_else(|| line.to_owned()); + let key = PublicKey::from_openssh(&encoded).with_context(|| { + format!( + "invalid authorized key at {}:{}", + self.authorized_keys_path.display(), + index + 1 + ) + })?; + if key.key_data() == candidate.key_data() { + return Ok(true); + } + } + Ok(false) + } +} + +#[derive(Debug, Clone)] +pub struct ClientKeyMaterial { + pub directory: PathBuf, + pub private_key_path: PathBuf, + pub public_key_path: PathBuf, + pub known_hosts_path: PathBuf, +} + +impl ClientKeyMaterial { + pub fn ensure(data_dir: &Path) -> Result { + let directory = data_dir.join("ssh"); + ensure_private_directory(&directory)?; + let private_key_path = directory.join(CLIENT_KEY_FILE); + let public_key_path = directory.join(format!("{CLIENT_KEY_FILE}.pub")); + let known_hosts_path = directory.join(KNOWN_HOSTS_FILE); + ensure_private_file(&known_hosts_path, b"")?; + if !private_key_path.exists() { + let mut key = PrivateKey::random(&mut safe_rng(), Algorithm::Ed25519) + .context("could not generate the SSH client identity")?; + key.set_comment("ruds-desktop"); + let private = key + .to_openssh(ssh_key::LineEnding::LF) + .context("could not encode the SSH client identity")?; + ensure_private_file(&private_key_path, private.as_bytes())?; + let public = format!("{}\n", key.public_key().to_openssh()?); + ensure_public_file(&public_key_path, public.as_bytes())?; + } + validate_private_file(&private_key_path)?; + validate_private_file(&known_hosts_path)?; + let key = load_secret_key(&private_key_path, None).with_context(|| { + format!( + "could not read SSH client identity {}", + private_key_path.display() + ) + })?; + let public = format!("{}\n", key.public_key().to_openssh()?); + ensure_public_file(&public_key_path, public.as_bytes())?; + Ok(Self { + directory, + private_key_path, + public_key_path, + known_hosts_path, + }) + } +} + +fn ensure_private_directory(path: &Path) -> Result<()> { + if !path.exists() { + fs::create_dir_all(path).with_context(|| { + format!("could not create private SSH directory {}", path.display()) + })?; + set_mode(path, 0o700)?; + } + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!("SSH key directory is not a directory: {}", path.display()); + } + validate_mode(path, 0o700, "SSH key directory") +} + +fn ensure_private_file(path: &Path, contents: &[u8]) -> Result<()> { + if path.exists() { + return validate_private_file(path); + } + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let mut file = options + .open(path) + .with_context(|| format!("could not create private SSH file {}", path.display()))?; + file.write_all(contents)?; + file.sync_all()?; + set_mode(path, 0o600)?; + Ok(()) +} + +fn ensure_public_file(path: &Path, contents: &[u8]) -> Result<()> { + if path.exists() { + return Ok(()); + } + let mut file = OpenOptions::new().write(true).create_new(true).open(path)?; + file.write_all(contents)?; + file.sync_all()?; + set_mode(path, 0o644) +} + +fn validate_private_file(path: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!( + "SSH key file is missing or not a regular file: {}", + path.display() + ); + } + validate_mode(path, 0o600, "SSH key file") +} + +#[cfg(unix)] +fn validate_mode(path: &Path, maximum: u32, label: &str) -> Result<()> { + use std::os::unix::fs::PermissionsExt as _; + let mode = fs::metadata(path)?.permissions().mode() & 0o777; + if mode & !maximum != 0 { + bail!( + "unsafe permissions {mode:o} on {label} {}; expected {maximum:o}", + path.display() + ); + } + Ok(()) +} + +#[cfg(not(unix))] +fn validate_mode(_path: &Path, _maximum: u32, _label: &str) -> Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn set_mode(path: &Path, mode: u32) -> Result<()> { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(path, fs::Permissions::from_mode(mode))?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_mode(_path: &Path, _mode: u32) -> Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn creates_and_reuses_restrictive_server_key_material() { + let root = tempfile::tempdir().unwrap(); + let first = KeyMaterial::ensure(root.path()).unwrap(); + let bytes = fs::read(&first.host_key_path).unwrap(); + assert_eq!( + first.host_key().unwrap().algorithm(), + Algorithm::Rsa { hash: None } + ); + assert_eq!(fs::read_to_string(&first.authorized_keys_path).unwrap(), ""); + let second = KeyMaterial::ensure(root.path()).unwrap(); + assert_eq!(fs::read(second.host_key_path).unwrap(), bytes); + assert_private(&first.directory, 0o700); + assert_private(&first.host_key_path, 0o600); + assert_private(&first.authorized_keys_path, 0o600); + } + + #[test] + fn authorized_keys_accept_reject_and_revoke_immediately() { + let root = tempfile::tempdir().unwrap(); + let material = KeyMaterial::ensure(root.path()).unwrap(); + let allowed = PrivateKey::random(&mut safe_rng(), Algorithm::Ed25519).unwrap(); + let unknown = PrivateKey::random(&mut safe_rng(), Algorithm::Ed25519).unwrap(); + fs::write( + &material.authorized_keys_path, + format!( + "# desktop\nrestrict {} user@desktop\n", + allowed.public_key().to_openssh().unwrap() + ), + ) + .unwrap(); + assert!(material.authorizes(allowed.public_key()).unwrap()); + assert!(!material.authorizes(unknown.public_key()).unwrap()); + fs::write(&material.authorized_keys_path, "").unwrap(); + assert!(!material.authorizes(allowed.public_key()).unwrap()); + } + + #[cfg(unix)] + #[test] + fn unsafe_key_files_are_rejected_with_the_path_and_mode() { + use std::os::unix::fs::PermissionsExt as _; + let root = tempfile::tempdir().unwrap(); + let material = KeyMaterial::ensure(root.path()).unwrap(); + fs::set_permissions( + &material.authorized_keys_path, + fs::Permissions::from_mode(0o644), + ) + .unwrap(); + let error = material + .authorizes( + PrivateKey::random(&mut safe_rng(), Algorithm::Ed25519) + .unwrap() + .public_key(), + ) + .unwrap_err() + .to_string(); + assert!(error.contains("unsafe permissions 644")); + assert!(error.contains(AUTHORIZED_KEYS_FILE)); + } + + #[test] + fn creates_a_reusable_desktop_identity_and_known_hosts() { + let root = tempfile::tempdir().unwrap(); + let first = ClientKeyMaterial::ensure(root.path()).unwrap(); + let bytes = fs::read(&first.private_key_path).unwrap(); + fs::remove_file(&first.public_key_path).unwrap(); + let second = ClientKeyMaterial::ensure(root.path()).unwrap(); + assert_eq!(fs::read(second.private_key_path).unwrap(), bytes); + assert!(first.public_key_path.is_file()); + assert_private(&first.private_key_path, 0o600); + assert_private(&first.known_hosts_path, 0o600); + } + + fn assert_private(path: &Path, expected: u32) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + expected + ); + } + } +} diff --git a/crates/bds-server/src/boot.rs b/crates/bds-server/src/boot.rs new file mode 100644 index 0000000..a7c6072 --- /dev/null +++ b/crates/bds-server/src/boot.rs @@ -0,0 +1,121 @@ +use std::collections::HashMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BootMode { + Desktop, + Server, + Tui, +} + +impl BootMode { + pub fn resolve(value: Option<&str>) -> Self { + match value.map(str::trim).map(str::to_ascii_lowercase).as_deref() { + Some("server") => Self::Server, + Some("tui") => Self::Tui, + _ => Self::Desktop, + } + } + + pub fn effective(self, platform: Platform, env: &HashMap) -> Self { + if self != Self::Desktop { + return self; + } + match platform { + Platform::MacOs if has_any(env, &["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]) => { + Self::Tui + } + Platform::Unix if !has_any(env, &["DISPLAY", "WAYLAND_DISPLAY"]) => Self::Tui, + _ => Self::Desktop, + } + } + + pub fn components(self) -> BootComponents { + match self { + Self::Desktop => BootComponents { + engine_host: true, + ssh_daemon: false, + desktop_ui: true, + local_tui: false, + }, + Self::Server => BootComponents { + engine_host: true, + ssh_daemon: true, + desktop_ui: false, + local_tui: false, + }, + Self::Tui => BootComponents { + engine_host: true, + ssh_daemon: true, + desktop_ui: false, + local_tui: true, + }, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Platform { + MacOs, + Unix, + Windows, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BootComponents { + pub engine_host: bool, + pub ssh_daemon: bool, + pub desktop_ui: bool, + pub local_tui: bool, +} + +fn has_any(env: &HashMap, keys: &[&str]) -> bool { + keys.iter() + .any(|key| env.get(*key).is_some_and(|value| !value.is_empty())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn modes_resolve_and_initialize_only_their_components() { + assert_eq!(BootMode::resolve(None), BootMode::Desktop); + assert_eq!(BootMode::resolve(Some("SERVER")), BootMode::Server); + assert_eq!(BootMode::resolve(Some("tui")), BootMode::Tui); + assert!(BootMode::Desktop.components().desktop_ui); + assert!(!BootMode::Server.components().desktop_ui); + assert!(!BootMode::Tui.components().desktop_ui); + assert!(BootMode::Tui.components().local_tui); + } + + #[test] + fn desktop_falls_back_only_when_the_platform_is_headless() { + let empty = HashMap::new(); + assert_eq!( + BootMode::Desktop.effective(Platform::Unix, &empty), + BootMode::Tui + ); + assert_eq!( + BootMode::Desktop.effective(Platform::MacOs, &empty), + BootMode::Desktop + ); + assert_eq!( + BootMode::Desktop.effective(Platform::Windows, &empty), + BootMode::Desktop + ); + let display = HashMap::from([("DISPLAY".into(), ":0".into())]); + assert_eq!( + BootMode::Desktop.effective(Platform::Unix, &display), + BootMode::Desktop + ); + let ssh = HashMap::from([("SSH_TTY".into(), "/dev/tty".into())]); + assert_eq!( + BootMode::Desktop.effective(Platform::MacOs, &ssh), + BootMode::Tui + ); + assert_eq!( + BootMode::Server.effective(Platform::Unix, &empty), + BootMode::Server + ); + } +} diff --git a/crates/bds-server/src/client.rs b/crates/bds-server/src/client.rs new file mode 100644 index 0000000..85283ba --- /dev/null +++ b/crates/bds-server/src/client.rs @@ -0,0 +1,497 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow, bail}; +use bds_core::model::Project; +use russh::ChannelMsg; +use russh::client; +use russh::keys::key::PrivateKeyWithHashAlg; +use russh::keys::{PublicKey, load_secret_key}; +use serde_json::Value; +use tokio::sync::mpsc; +use uuid::Uuid; + +use crate::auth::ClientKeyMaterial; +use crate::protocol::{Command, PROTOCOL_VERSION, Request, SUBSYSTEM, ServerMessage}; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteTarget { + pub user: String, + pub host: String, + pub port: u16, +} + +impl RemoteTarget { + pub fn parse(value: &str) -> Result { + let (user, address) = value + .trim() + .split_once('@') + .filter(|(user, address)| !user.is_empty() && !address.is_empty()) + .ok_or_else(|| anyhow!("use the form user@host or user@host:port"))?; + let (host, port) = if let Some(address) = address.strip_prefix('[') { + let (host, suffix) = address + .split_once(']') + .ok_or_else(|| anyhow!("invalid bracketed host"))?; + let port = match suffix.strip_prefix(':') { + Some(value) => parse_port(value)?, + None if suffix.is_empty() => 2222, + None => bail!("invalid host and port"), + }; + (host, port) + } else if address.matches(':').count() == 1 { + let (host, port) = address.rsplit_once(':').expect("one colon"); + (host, parse_port(port)?) + } else { + (address, 2222) + }; + if host.is_empty() { + bail!("remote host is required"); + } + Ok(Self { + user: user.to_owned(), + host: host.to_owned(), + port, + }) + } + + pub fn label(&self) -> String { + if self.port == 2222 { + format!("{}@{}", self.user, self.host) + } else if self.host.contains(':') { + format!("{}@[{}]:{}", self.user, self.host, self.port) + } else { + format!("{}@{}:{}", self.user, self.host, self.port) + } + } +} + +fn parse_port(value: &str) -> Result { + value + .parse::() + .ok() + .filter(|port| *port > 0) + .ok_or_else(|| anyhow!("remote SSH port is invalid")) +} + +enum ClientCommand { + Request { + request: Request, + response: std::sync::mpsc::Sender>, + }, + Stop, +} + +pub struct DesktopClient { + target: RemoteTarget, + server_locale: String, + commands: mpsc::UnboundedSender, + events: Arc>>, + thread: Option>, +} + +impl std::fmt::Debug for DesktopClient { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DesktopClient") + .field("target", &self.target) + .field("server_locale", &self.server_locale) + .finish_non_exhaustive() + } +} + +impl DesktopClient { + pub fn connect(target: RemoteTarget, data_root: &Path) -> Result { + let keys = ClientKeyMaterial::ensure(data_root)?; + let (commands, command_rx) = mpsc::unbounded_channel(); + let events = Arc::new(Mutex::new(Vec::new())); + let event_sink = Arc::clone(&events); + let error_sink = Arc::clone(&events); + let thread_target = target.clone(); + let (started_tx, started_rx) = std::sync::mpsc::sync_channel(1); + let thread = std::thread::Builder::new() + .name("bds-remote-client".into()) + .spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + let result = match runtime { + Ok(runtime) => runtime.block_on(run_client( + thread_target, + keys, + command_rx, + event_sink, + started_tx, + )), + Err(error) => { + let _ = started_tx.send(Err(error.to_string())); + return; + } + }; + if let Err(error) = result { + lock(&error_sink).push(ServerMessage::Error { + id: String::new(), + code: "connection_lost".into(), + message: error.to_string(), + }); + } + })?; + match started_rx.recv_timeout(CONNECT_TIMEOUT) { + Ok(Ok(server_locale)) => { + let client = Self { + target, + server_locale, + commands, + events, + thread: Some(thread), + }; + // Prove the selected endpoint speaks the RuDS protocol before + // exposing the connection to the desktop. + client.list_projects()?; + Ok(client) + } + Ok(Err(error)) => { + let _ = thread.join(); + Err(anyhow!(error)) + } + Err(_) => { + let _ = commands.send(ClientCommand::Stop); + let _ = thread.join(); + bail!("timed out connecting to the RuDS server") + } + } + } + + pub fn target(&self) -> &RemoteTarget { + &self.target + } + + pub fn server_locale(&self) -> &str { + &self.server_locale + } + + pub fn list_projects(&self) -> Result> { + let value = self.request(Command::ListProjects)?; + serde_json::from_value(value).context("server returned an invalid project list") + } + + pub fn open_project(&self, project_id: &str) -> Result { + let value = self.request(Command::OpenProject { + project_id: project_id.to_owned(), + })?; + serde_json::from_value(value).context("server returned an invalid project") + } + + pub fn call(&self, namespace: &str, method: &str, arguments: Vec) -> Result { + self.request(Command::Call { + namespace: namespace.to_owned(), + method: method.to_owned(), + arguments, + }) + } + + pub fn ping(&self) -> Result<()> { + self.request(Command::Ping).map(|_| ()) + } + + pub fn drain_events(&self) -> Vec { + std::mem::take(&mut *lock(&self.events)) + } + + pub fn close(&self) { + let _ = self.commands.send(ClientCommand::Stop); + } + + pub fn disconnect(mut self) -> Result<()> { + let _ = self.commands.send(ClientCommand::Stop); + if let Some(thread) = self.thread.take() { + thread + .join() + .map_err(|_| anyhow!("remote client thread panicked"))?; + } + Ok(()) + } + + fn request(&self, command: Command) -> Result { + let (response, receiver) = std::sync::mpsc::channel(); + self.commands + .send(ClientCommand::Request { + request: Request { + id: Uuid::new_v4().to_string(), + command, + }, + response, + }) + .map_err(|_| anyhow!("remote connection is closed"))?; + match receiver.recv_timeout(REQUEST_TIMEOUT) { + Ok(Ok(value)) => Ok(value), + Ok(Err(error)) => Err(anyhow!(error)), + Err(_) => bail!("remote request timed out"), + } + } +} + +impl Drop for DesktopClient { + fn drop(&mut self) { + let _ = self.commands.send(ClientCommand::Stop); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +#[derive(Clone)] +struct HostVerifier { + host: String, + port: u16, + path: PathBuf, + error: Arc>>, +} + +impl client::Handler for HostVerifier { + type Error = anyhow::Error; + + async fn check_server_key(&mut self, key: &PublicKey) -> Result { + match russh::keys::known_hosts::check_known_hosts_path( + &self.host, self.port, key, &self.path, + ) { + Ok(true) => Ok(true), + Ok(false) => { + russh::keys::known_hosts::learn_known_hosts_path( + &self.host, self.port, key, &self.path, + )?; + set_private_mode(&self.path)?; + Ok(true) + } + Err(error) => { + *lock(&self.error) = Some(match error { + russh::keys::Error::KeyChanged { .. } => { + "the remote server host key changed; remove its known_hosts entry only if the change is trusted".into() + } + _ => format!("could not verify the remote server host key: {error}"), + }); + Ok(false) + } + } + } +} + +async fn run_client( + target: RemoteTarget, + keys: ClientKeyMaterial, + commands: mpsc::UnboundedReceiver, + events: Arc>>, + started: std::sync::mpsc::SyncSender>, +) -> Result<()> { + let verification_error = Arc::new(Mutex::new(None)); + let verifier = HostVerifier { + host: target.host.clone(), + port: target.port, + path: keys.known_hosts_path.clone(), + error: Arc::clone(&verification_error), + }; + let config = Arc::new(client::Config { + inactivity_timeout: Some(Duration::from_secs(60 * 60)), + nodelay: true, + ..Default::default() + }); + let mut ssh = match client::connect(config, (target.host.as_str(), target.port), verifier).await + { + Ok(ssh) => ssh, + Err(error) => { + let reason = lock(&verification_error) + .take() + .unwrap_or_else(|| format!("SSH connection failed: {error}")); + let _ = started.send(Err(reason.clone())); + bail!(reason); + } + }; + let private_key = load_secret_key(&keys.private_key_path, None)?; + let hash = ssh.best_supported_rsa_hash().await?.flatten(); + let authentication = ssh + .authenticate_publickey( + &target.user, + PrivateKeyWithHashAlg::new(Arc::new(private_key), hash), + ) + .await?; + if !authentication.success() { + let message = format!( + "public-key authentication failed; add {} to the server authorized_keys file", + keys.public_key_path.display() + ); + let _ = started.send(Err(message.clone())); + bail!(message); + } + let mut channel = ssh.channel_open_session().await?; + channel.request_subsystem(true, SUBSYSTEM).await?; + let hello = Request { + id: Uuid::new_v4().to_string(), + command: Command::Hello { + protocol_version: PROTOCOL_VERSION, + }, + }; + let encoded = encode_request(&hello)?; + channel.data(&encoded[..]).await?; + let mut bytes = Vec::new(); + loop { + let Some(message) = channel.wait().await else { + let _ = started.send(Err("server closed during protocol negotiation".into())); + bail!("server closed during protocol negotiation"); + }; + if let ChannelMsg::Data { data } = message { + bytes.extend_from_slice(&data); + for message in decode_messages(&mut bytes)? { + match message { + ServerMessage::Response { id, result } if id == hello.id => { + let Some(locale) = result.get("locale").and_then(Value::as_str) else { + let message = "server hello did not include its locale".to_owned(); + let _ = started.send(Err(message.clone())); + bail!(message); + }; + let locale = locale.to_owned(); + let _ = started.send(Ok(locale)); + return client_loop(ssh, channel, commands, events, bytes).await; + } + ServerMessage::Error { id, message, .. } if id == hello.id => { + let _ = started.send(Err(message.clone())); + bail!(message); + } + other => lock(&events).push(other), + } + } + } + } +} + +async fn client_loop( + ssh: client::Handle, + mut channel: russh::Channel, + mut commands: mpsc::UnboundedReceiver, + events: Arc>>, + mut bytes: Vec, +) -> Result<()> { + let mut pending = HashMap::>>::new(); + loop { + tokio::select! { + command = commands.recv() => match command { + Some(ClientCommand::Request { request, response }) => { + pending.insert(request.id.clone(), response); + let encoded = encode_request(&request)?; + if let Err(error) = channel.data(&encoded[..]).await { + fail_pending(&mut pending, &format!("remote connection write failed: {error}")); + return Err(error.into()); + } + } + Some(ClientCommand::Stop) | None => { + let _ = channel.eof().await; + let _ = ssh.disconnect(russh::Disconnect::ByApplication, "", "en").await; + fail_pending(&mut pending, "remote connection closed"); + return Ok(()); + } + }, + message = channel.wait() => match message { + Some(ChannelMsg::Data { data }) => { + bytes.extend_from_slice(&data); + for message in decode_messages(&mut bytes)? { + match message { + ServerMessage::Response { ref id, ref result } => { + if let Some(response) = pending.remove(id) { + let _ = response.send(Ok(result.clone())); + } else { + lock(&events).push(message); + } + } + ServerMessage::Error { ref id, message: ref error_message, .. } => { + if let Some(response) = pending.remove(id) { + let _ = response.send(Err(error_message.clone())); + } else { + lock(&events).push(message.clone()); + } + } + other => lock(&events).push(other), + } + } + } + Some(ChannelMsg::Close | ChannelMsg::Eof) | None => { + fail_pending(&mut pending, "remote server disconnected"); + bail!("remote server disconnected"); + } + _ => {} + } + } + } +} + +fn encode_request(request: &Request) -> Result> { + let mut bytes = serde_json::to_vec(request)?; + bytes.push(b'\n'); + Ok(bytes) +} + +fn decode_messages(bytes: &mut Vec) -> Result> { + let mut messages = Vec::new(); + while let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') { + let line = bytes.drain(..=newline).collect::>(); + if line.len() > 1 { + messages.push(serde_json::from_slice(&line[..line.len() - 1])?); + } + } + if bytes.len() > 1024 * 1024 { + bail!("remote response exceeds 1 MiB"); + } + Ok(messages) +} + +fn fail_pending( + pending: &mut HashMap>>, + message: &str, +) { + for (_, response) in pending.drain() { + let _ = response.send(Err(message.to_owned())); + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +#[cfg(unix)] +fn set_private_mode(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_private_mode(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_default_explicit_and_ipv6_targets() { + assert_eq!( + RemoteTarget::parse(" gb@blog.example ").unwrap(), + RemoteTarget { + user: "gb".into(), + host: "blog.example".into(), + port: 2222, + } + ); + assert_eq!(RemoteTarget::parse("gb@host:2022").unwrap().port, 2022); + assert_eq!(RemoteTarget::parse("gb@[::1]:2200").unwrap().host, "::1"); + assert!(RemoteTarget::parse("host").is_err()); + assert!(RemoteTarget::parse("@host").is_err()); + assert!(RemoteTarget::parse("user@host:nope").is_err()); + } +} diff --git a/crates/bds-server/src/host.rs b/crates/bds-server/src/host.rs new file mode 100644 index 0000000..617bfc0 --- /dev/null +++ b/crates/bds-server/src/host.rs @@ -0,0 +1,695 @@ +use std::collections::{HashMap, VecDeque}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, mpsc}; +use std::thread::JoinHandle; +use std::time::Duration; + +use anyhow::{Result, anyhow}; +use bds_core::db::Database; +use bds_core::engine::domain_events::EventSubscription; +use bds_core::engine::task::{TaskManager, TaskSnapshot, TaskStatus}; +use bds_core::engine::{domain_events, project, settings}; +use bds_core::scripting::{CoreHost, HostApi}; +use serde_json::{Value, json}; + +use crate::protocol::{Command, PROTOCOL_VERSION, RemoteTask, Request, ServerMessage}; + +pub fn run_local_terminal(host: ApplicationHost) -> Result<()> { + use std::io::{BufRead as _, Write as _}; + + let mut session = host.session()?; + let _ = session.handle(Request { + id: "local-terminal-hello".into(), + command: Command::Hello { + protocol_version: PROTOCOL_VERSION, + }, + }); + let projects = session.handle(Request { + id: "local-terminal-projects".into(), + command: Command::ListProjects, + }); + let names = match projects { + ServerMessage::Response { result, .. } => result + .as_array() + .into_iter() + .flatten() + .filter_map(|project| project.get("name").and_then(Value::as_str)) + .collect::>() + .join("\n • "), + _ => String::new(), + }; + let locale = bds_core::i18n::normalize_language(session.locale()); + let title = bds_core::i18n::translate(locale, "remoteTerminal.localTitle"); + let available = bds_core::i18n::translate(locale, "remoteTerminal.availableProjects"); + let quit = bds_core::i18n::translate(locale, "remoteTerminal.quit"); + println!( + "\x1b[2J\x1b[H{title}\n\n{available}:\n • {}\n\n{quit}", + if names.is_empty() { "—" } else { &names } + ); + std::io::stdout().flush()?; + for line in std::io::stdin().lock().lines() { + if line?.trim().eq_ignore_ascii_case("q") { + break; + } + } + Ok(()) +} + +#[derive(Clone)] +pub struct ApplicationHost { + inner: Arc, +} + +struct HostInner { + database_path: PathBuf, + data_root: PathBuf, + tasks: Arc, + sync_watcher: SyncWatcher, + completed_requests: Mutex>, + execution_lock: Mutex<()>, +} + +struct SyncWatcher { + shutdown: mpsc::Sender<()>, + thread: Option>, + errors: Arc>, +} + +#[derive(Default)] +struct WatcherErrors { + next_id: u64, + entries: VecDeque<(u64, String)>, +} + +impl SyncWatcher { + fn start(database_path: PathBuf) -> Result { + let (shutdown, shutdown_rx) = mpsc::channel(); + let (started_tx, started_rx) = mpsc::sync_channel(1); + let errors = Arc::new(Mutex::new(WatcherErrors::default())); + let thread_errors = Arc::clone(&errors); + let thread = std::thread::Builder::new() + .name("bds-cli-sync-watcher".into()) + .spawn(move || { + let database = match Database::open(&database_path) { + Ok(database) => database, + Err(error) => { + let _ = started_tx.send(Err(error.to_string())); + return; + } + }; + let _ = started_tx.send(Ok(())); + loop { + match shutdown_rx.recv_timeout(Duration::from_millis(100)) { + Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => break, + Err(mpsc::RecvTimeoutError::Timeout) => { + if let Err(error) = + bds_core::engine::cli_sync::poll_notifications(database.conn()) + { + let mut errors = lock(&thread_errors); + let message = error.to_string(); + if errors + .entries + .back() + .is_some_and(|(_, last)| last == &message) + { + continue; + } + errors.next_id += 1; + let id = errors.next_id; + errors.entries.push_back((id, message)); + if errors.entries.len() > 128 { + errors.entries.pop_front(); + } + } + } + } + } + })?; + match started_rx.recv() { + Ok(Ok(())) => Ok(Self { + shutdown, + thread: Some(thread), + errors, + }), + Ok(Err(error)) => { + let _ = thread.join(); + Err(anyhow!("could not start the CLI sync watcher: {error}")) + } + Err(_) => { + let _ = thread.join(); + Err(anyhow!("the CLI sync watcher stopped during startup")) + } + } + } + + fn errors_since(&self, id: u64) -> Vec<(u64, String)> { + lock(&self.errors) + .entries + .iter() + .filter(|(entry_id, _)| *entry_id > id) + .cloned() + .collect() + } +} + +impl Drop for SyncWatcher { + fn drop(&mut self) { + let _ = self.shutdown.send(()); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +impl ApplicationHost { + pub fn start(database_path: PathBuf, data_root: PathBuf) -> Result { + if let Some(parent) = database_path.parent() { + std::fs::create_dir_all(parent)?; + } + let db = Database::open(&database_path)?; + db.migrate() + .map_err(|error| anyhow!("could not migrate the application database: {error}"))?; + bds_core::engine::search::prepare_search_index(db.conn())?; + let sync_watcher = SyncWatcher::start(database_path.clone())?; + Ok(Self { + inner: Arc::new(HostInner { + database_path, + data_root, + tasks: Arc::new(TaskManager::default()), + sync_watcher, + completed_requests: Mutex::new(HashMap::new()), + execution_lock: Mutex::new(()), + }), + }) + } + + pub fn tasks(&self) -> Arc { + Arc::clone(&self.inner.tasks) + } + + pub fn session(&self) -> Result { + let db = self.database()?; + let locale = settings::ui_language(db.conn())? + .map(|value| bds_core::i18n::normalize_language(&value)) + .unwrap_or_else(bds_core::i18n::detect_os_locale) + .code() + .to_owned(); + Ok(ApplicationSession { + host: self.clone(), + selected_project: None, + negotiated: false, + locale, + sequence: 0, + events: domain_events::subscribe(), + last_tasks: Vec::new(), + last_watcher_error: 0, + }) + } + + fn database(&self) -> Result { + Database::open(&self.inner.database_path).map_err(Into::into) + } + + fn cached(&self, id: &str) -> Option { + lock(&self.inner.completed_requests).get(id).cloned() + } + + fn remember(&self, id: String, response: ServerMessage) { + let mut completed = lock(&self.inner.completed_requests); + if completed.len() >= 4_096 + && let Some(oldest) = completed.keys().next().cloned() + { + completed.remove(&oldest); + } + completed.insert(id, response); + } +} + +pub struct ApplicationSession { + host: ApplicationHost, + selected_project: Option, + negotiated: bool, + locale: String, + sequence: u64, + events: EventSubscription, + last_tasks: Vec, + last_watcher_error: u64, +} + +#[derive(Clone)] +struct SelectedProject { + id: String, + data_dir: PathBuf, +} + +impl ApplicationSession { + pub fn locale(&self) -> &str { + &self.locale + } + + pub fn handle(&mut self, request: Request) -> ServerMessage { + let idempotent = matches!(request.command, Command::Call { .. }); + if idempotent { + let inner = Arc::clone(&self.host.inner); + let _execution = lock(&inner.execution_lock); + if let Some(response) = self.host.cached(&request.id) { + return response; + } + let id = request.id; + let response = self.response(id.clone(), &request.command); + self.host.remember(id, response.clone()); + return response; + } + let id = request.id; + self.response(id, &request.command) + } + + fn response(&mut self, id: String, command: &Command) -> ServerMessage { + match self.execute(command) { + Ok(result) => ServerMessage::Response { id, result }, + Err(error) => ServerMessage::Error { + id, + code: error.code.to_owned(), + message: error.message, + }, + } + } + + pub fn pending(&mut self) -> Vec { + let mut messages = self + .events + .drain() + .into_iter() + .filter(|event| { + event.project_id().is_none() + || self + .selected_project + .as_ref() + .is_some_and(|project| event.project_id() == Some(project.id.as_str())) + }) + .map(|event| { + self.sequence += 1; + ServerMessage::Event { + sequence: self.sequence, + event, + } + }) + .collect::>(); + let tasks = self + .host + .inner + .tasks + .snapshots() + .into_iter() + .map(remote_task) + .collect::>(); + if tasks != self.last_tasks { + self.last_tasks.clone_from(&tasks); + self.sequence += 1; + messages.push(ServerMessage::Tasks { + sequence: self.sequence, + tasks, + }); + } + for (id, message) in self + .host + .inner + .sync_watcher + .errors_since(self.last_watcher_error) + { + self.last_watcher_error = id; + messages.push(ServerMessage::Error { + id: String::new(), + code: "sync_watcher_error".into(), + message, + }); + } + messages + } + + fn execute(&mut self, command: &Command) -> Result { + if !matches!(command, Command::Hello { .. }) && !self.negotiated { + return Err(ProtocolError::new( + "protocol_required", + "hello must be the first request", + )); + } + match command { + Command::Hello { protocol_version } => { + if *protocol_version != PROTOCOL_VERSION { + return Err(ProtocolError::new( + "unsupported_protocol", + format!( + "unsupported remote protocol {protocol_version}; server requires {PROTOCOL_VERSION}" + ), + )); + } + self.negotiated = true; + Ok(json!({ + "protocol_version": PROTOCOL_VERSION, + "server_name": "Blogging Desktop Server", + "locale": self.locale, + })) + } + Command::ListProjects => { + let db = self.host.database().map_err(ProtocolError::engine)?; + let projects = project::list_projects(db.conn()).map_err(ProtocolError::engine)?; + serde_json::to_value(projects).map_err(ProtocolError::engine) + } + Command::OpenProject { project_id } => { + let db = self.host.database().map_err(ProtocolError::engine)?; + let value = + bds_core::db::queries::project::get_project_by_id(db.conn(), project_id) + .map_err(|_| { + ProtocolError::new( + "project_not_found", + format!("project '{project_id}' was not found on the server"), + ) + })?; + let data_dir = value + .data_path + .as_deref() + .map(PathBuf::from) + .unwrap_or_else(|| self.host.inner.data_root.join("projects").join(&value.id)); + if !data_dir.join("meta/project.json").is_file() { + return Err(ProtocolError::new( + "project_unavailable", + format!("project '{}' data is unavailable", value.name), + )); + } + self.selected_project = Some(SelectedProject { + id: value.id.clone(), + data_dir, + }); + serde_json::to_value(value).map_err(ProtocolError::engine) + } + Command::Call { + namespace, + method, + arguments, + } => { + let selected = self.selected_project.as_ref().ok_or_else(|| { + ProtocolError::new("project_required", "open a remote project first") + })?; + CoreHost::new( + &self.host.inner.database_path, + &selected.id, + &selected.data_dir, + ) + .with_task_manager(Arc::clone(&self.host.inner.tasks)) + .call(namespace, method, arguments.clone()) + .map_err(|message| ProtocolError::new("engine_error", message)) + } + Command::Ping => Ok(json!({"ok": true})), + } + } +} + +struct ProtocolError { + code: &'static str, + message: String, +} + +impl ProtocolError { + fn new(code: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } + + fn engine(error: impl std::fmt::Display) -> Self { + Self::new("engine_error", error.to_string()) + } +} + +fn remote_task(snapshot: TaskSnapshot) -> RemoteTask { + let (status, failure) = match snapshot.status { + TaskStatus::Pending => ("pending", None), + TaskStatus::Running => ("running", None), + TaskStatus::Completed => ("completed", None), + TaskStatus::Failed(message) => ("failed", Some(message)), + TaskStatus::Cancelled => ("cancelled", None), + }; + RemoteTask { + id: snapshot.id, + label: snapshot.label, + status: status.to_owned(), + progress: snapshot.progress, + message: snapshot.message.or(failure), + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{Command, Request}; + + struct Fixture { + _root: tempfile::TempDir, + host: ApplicationHost, + project_id: String, + } + + impl Fixture { + fn new() -> Self { + let root = tempfile::tempdir().unwrap(); + let database_path = root.path().join("bds.db"); + let data_root = root.path().join("data"); + let host = ApplicationHost::start(database_path.clone(), data_root.clone()).unwrap(); + let db = Database::open(&database_path).unwrap(); + let project_dir = root.path().join("blog"); + let value = project::create_project( + db.conn(), + "Remote Blog", + Some(project_dir.to_str().unwrap()), + ) + .unwrap(); + settings::set(db.conn(), settings::UI_LANGUAGE_KEY, "de").unwrap(); + Self { + _root: root, + host, + project_id: value.id, + } + } + + fn session(&self) -> ApplicationSession { + let mut session = self.host.session().unwrap(); + assert!(matches!( + session.handle(request( + "hello", + Command::Hello { + protocol_version: PROTOCOL_VERSION, + } + )), + ServerMessage::Response { .. } + )); + session + } + } + + fn request(id: &str, command: Command) -> Request { + Request { + id: id.to_owned(), + command, + } + } + + #[test] + fn session_negotiates_server_locale_and_selects_a_project() { + let fixture = Fixture::new(); + let mut session = fixture.host.session().unwrap(); + assert_eq!(session.locale(), "de"); + assert!(matches!( + session.handle(request("early", Command::ListProjects)), + ServerMessage::Error { ref code, .. } if code == "protocol_required" + )); + let mut session = fixture.session(); + let listed = session.handle(request("list", Command::ListProjects)); + assert!(matches!(listed, ServerMessage::Response { .. })); + let opened = session.handle(request( + "open", + Command::OpenProject { + project_id: fixture.project_id.clone(), + }, + )); + assert!(matches!(opened, ServerMessage::Response { .. })); + } + + #[test] + fn two_clients_observe_one_ordered_mutation_and_request_replay_is_idempotent() { + let fixture = Fixture::new(); + let mut first = fixture.session(); + let mut second = fixture.session(); + for (index, session) in [&mut first, &mut second].into_iter().enumerate() { + session.handle(request( + &format!("open-{index}"), + Command::OpenProject { + project_id: fixture.project_id.clone(), + }, + )); + let _ = session.pending(); + } + let create = request( + "globally-unique-create", + Command::Call { + namespace: "posts".into(), + method: "create".into(), + arguments: vec![json!({"title":"Exactly Once","content":"body"})], + }, + ); + assert!(matches!( + first.handle(create.clone()), + ServerMessage::Response { .. } + )); + assert_eq!(second.handle(create.clone()), first.handle(create)); + + let first_events = first.pending(); + let second_events = second.pending(); + assert_eq!(event_count(&first_events, &fixture.project_id), 1); + assert_eq!(event_count(&second_events, &fixture.project_id), 1); + assert!(strictly_ordered(&first_events)); + assert!(strictly_ordered(&second_events)); + + let db = fixture.host.database().unwrap(); + assert_eq!( + bds_core::db::queries::post::list_posts_by_project(db.conn(), &fixture.project_id) + .unwrap() + .len(), + 1 + ); + } + + #[test] + fn simultaneous_replay_from_two_clients_executes_the_write_once() { + let fixture = Fixture::new(); + let mut first = fixture.session(); + let mut second = fixture.session(); + for (index, session) in [&mut first, &mut second].into_iter().enumerate() { + session.handle(request( + &format!("parallel-open-{index}"), + Command::OpenProject { + project_id: fixture.project_id.clone(), + }, + )); + } + let barrier = Arc::new(std::sync::Barrier::new(2)); + let command = request( + "same-concurrent-id", + Command::Call { + namespace: "posts".into(), + method: "create".into(), + arguments: vec![json!({"title":"Concurrent","content":"body"})], + }, + ); + let first_barrier = Arc::clone(&barrier); + let first_command = command.clone(); + let first = std::thread::spawn(move || { + first_barrier.wait(); + first.handle(first_command) + }); + let second = std::thread::spawn(move || { + barrier.wait(); + second.handle(command) + }); + assert_eq!(first.join().unwrap(), second.join().unwrap()); + let db = fixture.host.database().unwrap(); + assert_eq!( + bds_core::db::queries::post::list_posts_by_project(db.conn(), &fixture.project_id) + .unwrap() + .len(), + 1 + ); + } + + #[test] + fn task_progress_is_shared_without_repeating_unchanged_snapshots() { + let fixture = Fixture::new(); + let mut session = fixture.session(); + let _ = session.pending(); + let task = fixture.host.tasks().submit("Generate site"); + fixture + .host + .tasks() + .report_progress(task, Some(0.5), Some("Writing".into())); + let update = session.pending(); + assert!(matches!( + update.as_slice(), + [ServerMessage::Tasks { tasks, .. }] if tasks[0].progress == Some(0.5) + )); + assert!(session.pending().is_empty()); + } + + #[test] + fn headless_sync_watcher_republishes_external_cli_notifications() { + let fixture = Fixture::new(); + let mut session = fixture.session(); + session.handle(request( + "open-for-sync", + Command::OpenProject { + project_id: fixture.project_id.clone(), + }, + )); + let _ = session.pending(); + + let db = fixture.host.database().unwrap(); + bds_core::engine::cli_sync::record_cli_event( + db.conn(), + &bds_core::model::DomainEvent::EntityChanged { + project_id: fixture.project_id.clone(), + entity: bds_core::model::DomainEntity::Post, + entity_id: "external-post".into(), + action: bds_core::model::NotificationAction::Created, + }, + ) + .unwrap(); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let mut updates = Vec::new(); + while std::time::Instant::now() < deadline { + updates.extend(session.pending()); + if event_count(&updates, &fixture.project_id) == 1 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + + assert_eq!(event_count(&updates, &fixture.project_id), 1); + let notifications = + bds_core::db::queries::db_notification::list_notifications(db.conn()).unwrap(); + assert_eq!(notifications.len(), 1); + assert!(notifications[0].seen_at.is_some()); + } + + fn event_count(messages: &[ServerMessage], project_id: &str) -> usize { + messages + .iter() + .filter(|message| { + matches!( + message, + ServerMessage::Event { event, .. } + if event.project_id() == Some(project_id) + ) + }) + .count() + } + + fn strictly_ordered(messages: &[ServerMessage]) -> bool { + let sequences = messages + .iter() + .filter_map(|message| match message { + ServerMessage::Event { sequence, .. } | ServerMessage::Tasks { sequence, .. } => { + Some(*sequence) + } + _ => None, + }) + .collect::>(); + sequences.windows(2).all(|pair| pair[0] < pair[1]) + } +} diff --git a/crates/bds-server/src/lib.rs b/crates/bds-server/src/lib.rs new file mode 100644 index 0000000..f4b5fc2 --- /dev/null +++ b/crates/bds-server/src/lib.rs @@ -0,0 +1,63 @@ +pub mod auth; +pub mod boot; +pub mod client; +pub mod host; +pub mod protocol; +pub mod transport; + +pub use client::{DesktopClient, RemoteTarget}; +pub use transport::{ServerConfig, ServerRuntime}; + +use anyhow::{Result, bail}; +use boot::BootMode; + +/// Run the headless host until Ctrl+C, optionally attaching the launching +/// terminal in `tui` mode. This path never links or initializes Iced. +pub fn run_headless(mode: BootMode, config: ServerConfig) -> Result<()> { + if mode == BootMode::Desktop { + bail!("desktop mode must be started by bds-ui"); + } + let database_path = config.database_path.clone(); + let runtime = ServerRuntime::start(config)?; + let locale = bds_core::db::Database::open(&database_path) + .ok() + .and_then(|database| { + bds_core::engine::settings::ui_language(database.conn()) + .ok() + .flatten() + }) + .map(|language| bds_core::i18n::normalize_language(&language)) + .unwrap_or_else(bds_core::i18n::detect_os_locale); + eprintln!( + "{}", + bds_core::i18n::translate_with( + locale, + "remoteTerminal.serverListening", + &[("address", &runtime.address().to_string())], + ) + ); + eprintln!( + "{}", + bds_core::i18n::translate_with( + locale, + "remoteTerminal.authorizedKeys", + &[( + "path", + &runtime + .key_material() + .authorized_keys_path + .display() + .to_string(), + )], + ) + ); + if mode == BootMode::Tui { + host::run_local_terminal(runtime.application_host())?; + } else { + let tokio = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + tokio.block_on(tokio::signal::ctrl_c())?; + } + runtime.stop() +} diff --git a/crates/bds-server/src/main.rs b/crates/bds-server/src/main.rs new file mode 100644 index 0000000..db9fe8c --- /dev/null +++ b/crates/bds-server/src/main.rs @@ -0,0 +1,41 @@ +use std::net::IpAddr; +use std::path::PathBuf; + +use bds_server::boot::BootMode; +use clap::Parser; + +#[derive(Debug, Parser)] +#[command( + name = "bds-server", + about = "Headless RuDS engine host over authenticated SSH" +)] +struct Args { + /// SSH listen address. Defaults to loopback; external access must be explicit. + #[arg(long)] + bind: Option, + /// SSH listen port. + #[arg(long)] + port: Option, + /// Application database path. + #[arg(long)] + database: Option, + /// Private application data directory containing SSH key material. + #[arg(long)] + data_dir: Option, +} + +fn main() -> anyhow::Result<()> { + let args = Args::parse(); + let data_root = args + .data_dir + .unwrap_or_else(bds_core::util::application_data_dir); + let database_path = args.database.unwrap_or_else(|| data_root.join("bds.db")); + let mut config = bds_server::ServerConfig::from_environment(database_path, data_root)?; + if let Some(bind) = args.bind { + config.bind = bind; + } + if let Some(port) = args.port { + config.port = port; + } + bds_server::run_headless(BootMode::Server, config) +} diff --git a/crates/bds-server/src/protocol.rs b/crates/bds-server/src/protocol.rs new file mode 100644 index 0000000..c136425 --- /dev/null +++ b/crates/bds-server/src/protocol.rs @@ -0,0 +1,63 @@ +use bds_core::model::DomainEvent; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const SUBSYSTEM: &str = "ruds"; +pub const PROTOCOL_VERSION: u16 = 1; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Request { + pub id: String, + #[serde(flatten)] + pub command: Command, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "command", rename_all = "snake_case")] +pub enum Command { + Hello { + protocol_version: u16, + }, + ListProjects, + OpenProject { + project_id: String, + }, + Call { + namespace: String, + method: String, + #[serde(default)] + arguments: Vec, + }, + Ping, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ServerMessage { + Response { + id: String, + result: Value, + }, + Error { + id: String, + code: String, + message: String, + }, + Event { + sequence: u64, + event: DomainEvent, + }, + Tasks { + sequence: u64, + tasks: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RemoteTask { + pub id: u64, + pub label: String, + pub status: String, + pub progress: Option, + pub message: Option, +} diff --git a/crates/bds-server/src/transport.rs b/crates/bds-server/src/transport.rs new file mode 100644 index 0000000..faf1616 --- /dev/null +++ b/crates/bds-server/src/transport.rs @@ -0,0 +1,523 @@ +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::PathBuf; +use std::sync::Arc; +use std::thread::JoinHandle; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow}; +use bds_core::engine::mcp::McpHttpServer; +use russh::keys::PublicKey; +use russh::server::{Auth, ChannelOpenHandle, Handler, Msg, Server, Session}; +use russh::{Channel, ChannelId}; +use tokio::io::{AsyncBufReadExt as _, AsyncWrite, AsyncWriteExt as _, BufReader}; + +use crate::auth::KeyMaterial; +use crate::host::{ApplicationHost, ApplicationSession}; +use crate::protocol::{Command, PROTOCOL_VERSION, Request, SUBSYSTEM, ServerMessage}; + +#[derive(Debug, Clone)] +pub struct ServerConfig { + pub database_path: PathBuf, + pub data_root: PathBuf, + pub bind: IpAddr, + pub port: u16, + pub mcp_port: u16, +} + +impl ServerConfig { + pub fn local(database_path: PathBuf, data_root: PathBuf) -> Self { + Self { + database_path, + data_root, + bind: IpAddr::V4(Ipv4Addr::LOCALHOST), + port: 2222, + mcp_port: 0, + } + } + + pub fn from_environment(database_path: PathBuf, data_root: PathBuf) -> Result { + let mut config = Self::local(database_path, data_root); + if let Some(bind) = std::env::var_os("BDS_SSH_BIND") { + config.bind = bind + .to_string_lossy() + .parse() + .context("BDS_SSH_BIND must be an IP address")?; + } + if let Some(port) = std::env::var_os("BDS_SSH_PORT") { + config.port = port + .to_string_lossy() + .parse() + .context("BDS_SSH_PORT must be a TCP port")?; + } + Ok(config) + } +} + +pub struct ServerRuntime { + address: SocketAddr, + key_material: KeyMaterial, + host: ApplicationHost, + shutdown: Option>, + thread: Option>>, + _mcp: McpHttpServer, +} + +impl ServerRuntime { + pub fn start(config: ServerConfig) -> Result { + let key_material = KeyMaterial::ensure(&config.data_root)?; + let host = ApplicationHost::start(config.database_path.clone(), config.data_root.clone())?; + let server_host = host.clone(); + let mcp = McpHttpServer::start(config.database_path, config.mcp_port)?; + let forward_address = mcp.address(); + let listener = + std::net::TcpListener::bind((config.bind, config.port)).with_context(|| { + format!( + "could not bind the RuDS SSH server to {}:{}", + config.bind, config.port + ) + })?; + listener.set_nonblocking(true)?; + let address = listener.local_addr()?; + let host_key = key_material.host_key()?; + let auth = key_material.clone(); + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel(); + let thread = std::thread::Builder::new() + .name("bds-ssh-server".into()) + .spawn(move || -> Result<()> { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build()?; + runtime.block_on(async move { + let listener = tokio::net::TcpListener::from_std(listener)?; + let ssh_config = Arc::new(russh::server::Config { + inactivity_timeout: Some(Duration::from_secs(60 * 60)), + auth_rejection_time: Duration::from_millis(500), + auth_rejection_time_initial: Some(Duration::ZERO), + keys: vec![host_key], + nodelay: true, + ..Default::default() + }); + let mut factory = ServerFactory { + auth, + host: server_host, + forward_address, + }; + let running = factory.run_on_socket(ssh_config, &listener); + let handle = running.handle(); + tokio::spawn(async move { + let _ = shutdown_rx.await; + handle.shutdown("RuDS server is shutting down".into()); + }); + running.await.map_err(anyhow::Error::from) + }) + })?; + Ok(Self { + address, + key_material, + host, + shutdown: Some(shutdown), + thread: Some(thread), + _mcp: mcp, + }) + } + + pub fn address(&self) -> SocketAddr { + self.address + } + + pub fn key_material(&self) -> &KeyMaterial { + &self.key_material + } + + pub fn application_host(&self) -> ApplicationHost { + self.host.clone() + } + + pub fn stop(mut self) -> Result<()> { + self.shutdown.take(); + if let Some(thread) = self.thread.take() { + thread + .join() + .map_err(|_| anyhow!("RuDS SSH server thread panicked"))??; + } + Ok(()) + } +} + +impl Drop for ServerRuntime { + fn drop(&mut self) { + self.shutdown.take(); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +struct ServerFactory { + auth: KeyMaterial, + host: ApplicationHost, + forward_address: SocketAddr, +} + +impl Server for ServerFactory { + type Handler = ConnectionHandler; + + fn new_client(&mut self, _peer_addr: Option) -> Self::Handler { + ConnectionHandler { + auth: self.auth.clone(), + host: self.host.clone(), + forward_address: self.forward_address, + channels: HashMap::new(), + } + } +} + +struct ConnectionHandler { + auth: KeyMaterial, + host: ApplicationHost, + forward_address: SocketAddr, + channels: HashMap>, +} + +impl Handler for ConnectionHandler { + type Error = anyhow::Error; + + async fn auth_publickey(&mut self, _user: &str, key: &PublicKey) -> Result { + Ok(if self.auth.authorizes(key)? { + Auth::Accept + } else { + Auth::reject() + }) + } + + async fn channel_open_session( + &mut self, + channel: Channel, + reply: ChannelOpenHandle, + _session: &mut Session, + ) -> Result<(), Self::Error> { + self.channels.insert(channel.id(), channel); + reply.accept().await; + Ok(()) + } + + async fn pty_request( + &mut self, + channel: ChannelId, + _term: &str, + _col_width: u32, + _row_height: u32, + _pix_width: u32, + _pix_height: u32, + _modes: &[(russh::Pty, u32)], + session: &mut Session, + ) -> Result<(), Self::Error> { + session.channel_success(channel)?; + Ok(()) + } + + async fn subsystem_request( + &mut self, + channel: ChannelId, + name: &str, + session: &mut Session, + ) -> Result<(), Self::Error> { + if name != SUBSYSTEM { + session.channel_failure(channel)?; + return Ok(()); + } + let Some(channel) = self.channels.remove(&channel) else { + session.channel_failure(channel)?; + return Ok(()); + }; + let application = self.host.session()?; + session.channel_success(channel.id())?; + tokio::spawn(async move { + let _ = run_protocol(channel, application).await; + }); + Ok(()) + } + + async fn shell_request( + &mut self, + channel: ChannelId, + session: &mut Session, + ) -> Result<(), Self::Error> { + let Some(channel) = self.channels.remove(&channel) else { + session.channel_failure(channel)?; + return Ok(()); + }; + let application = self.host.session()?; + session.channel_success(channel.id())?; + tokio::spawn(async move { + let _ = run_terminal_session(channel, application).await; + }); + Ok(()) + } + + async fn channel_open_direct_tcpip( + &mut self, + channel: Channel, + host_to_connect: &str, + port_to_connect: u32, + _originator_address: &str, + _originator_port: u32, + reply: ChannelOpenHandle, + _session: &mut Session, + ) -> Result<(), Self::Error> { + let permitted_host = matches!(host_to_connect, "127.0.0.1" | "localhost" | "::1"); + if !permitted_host || port_to_connect != u32::from(self.forward_address.port()) { + return Ok(()); + } + let Ok(mut target) = tokio::net::TcpStream::connect(self.forward_address).await else { + return Ok(()); + }; + reply.accept().await; + tokio::spawn(async move { + let mut stream = channel.into_stream(); + let _ = tokio::io::copy_bidirectional(&mut stream, &mut target).await; + }); + Ok(()) + } +} + +async fn run_protocol(channel: Channel, mut application: ApplicationSession) -> Result<()> { + let (read, mut write) = tokio::io::split(channel.into_stream()); + let mut lines = BufReader::new(read).lines(); + let mut interval = tokio::time::interval(Duration::from_millis(100)); + loop { + tokio::select! { + line = lines.next_line() => { + let Some(line) = line? else { break }; + let response = if line.len() > 1024 * 1024 { + ServerMessage::Error { id: String::new(), code: "request_too_large".into(), message: "remote request exceeds 1 MiB".into() } + } else { + match serde_json::from_str::(&line) { + Ok(request) => application.handle(request), + Err(error) => ServerMessage::Error { id: String::new(), code: "invalid_request".into(), message: error.to_string() }, + } + }; + write_message(&mut write, &response).await?; + } + _ = interval.tick() => { + for message in application.pending() { + write_message(&mut write, &message).await?; + } + } + } + } + Ok(()) +} + +async fn write_message( + write: &mut (impl AsyncWrite + Unpin), + message: &ServerMessage, +) -> Result<()> { + let mut encoded = serde_json::to_vec(message)?; + encoded.push(b'\n'); + write.write_all(&encoded).await?; + write.flush().await?; + Ok(()) +} + +async fn run_terminal_session( + mut channel: Channel, + mut application: ApplicationSession, +) -> Result<()> { + let locale = application.locale().to_owned(); + let hello = application.handle(Request { + id: "terminal-hello".into(), + command: Command::Hello { + protocol_version: PROTOCOL_VERSION, + }, + }); + if matches!(hello, ServerMessage::Error { .. }) { + return Err(anyhow!("terminal protocol negotiation failed")); + } + let projects = application.handle(Request { + id: "terminal-projects".into(), + command: Command::ListProjects, + }); + let project_names = match projects { + ServerMessage::Response { result, .. } => result + .as_array() + .into_iter() + .flatten() + .filter_map(|project| project.get("name").and_then(|name| name.as_str())) + .collect::>() + .join("\r\n • "), + _ => String::new(), + }; + let banner = terminal_banner(&locale, &project_names); + channel.data(banner.as_bytes()).await?; + let mut interval = tokio::time::interval(Duration::from_millis(250)); + loop { + tokio::select! { + message = channel.wait() => match message { + Some(russh::ChannelMsg::Data { data }) if data.iter().any(|byte| matches!(byte, b'q' | 3)) => { + channel.close().await?; + break; + } + None | Some(russh::ChannelMsg::Close) => break, + _ => {} + }, + _ = interval.tick() => { + let updates = application.pending(); + if !updates.is_empty() { + let status = format!("\r\n[{} update{}]\r\n", updates.len(), if updates.len() == 1 { "" } else { "s" }); + channel.data(status.as_bytes()).await?; + } + } + } + } + Ok(()) +} + +fn terminal_banner(locale: &str, projects: &str) -> String { + let locale = bds_core::i18n::normalize_language(locale); + let title = bds_core::i18n::translate(locale, "remoteTerminal.serverTitle"); + let available = bds_core::i18n::translate(locale, "remoteTerminal.availableProjects"); + let quit = bds_core::i18n::translate(locale, "remoteTerminal.quit"); + let projects = if projects.is_empty() { "—" } else { projects }; + format!("\x1b[2J\x1b[H{title}\r\n\r\n{available}:\r\n • {projects}\r\n\r\n{quit}\r\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::ClientKeyMaterial; + use crate::client::{DesktopClient, RemoteTarget}; + use bds_core::db::Database; + use std::fs; + use std::thread; + + #[test] + fn defaults_to_loopback_and_external_binding_requires_an_explicit_value() { + let config = ServerConfig::local("db".into(), "data".into()); + assert_eq!(config.bind, IpAddr::V4(Ipv4Addr::LOCALHOST)); + assert_eq!(config.port, 2222); + } + + #[test] + fn terminal_banner_uses_the_server_locale_and_only_terminal_control_bytes() { + let banner = terminal_banner("de", "Blog"); + assert!(banner.starts_with("\x1b[2J\x1b[H")); + assert!(banner.contains("RuDS-Serversitzung")); + assert!(banner.contains("Blog")); + assert!(!banner.contains('{')); + } + + #[test] + fn real_ssh_authentication_revocation_reconnect_events_and_shutdown() { + let root = tempfile::tempdir().unwrap(); + let server_data = root.path().join("server"); + let client_data = root.path().join("client"); + let unknown_data = root.path().join("unknown"); + fs::create_dir_all(&server_data).unwrap(); + let database_path = server_data.join("bds.db"); + let db = Database::open(&database_path).unwrap(); + db.migrate().unwrap(); + let project_dir = root.path().join("blog"); + let project = bds_core::engine::project::create_project( + db.conn(), + "Remote Blog", + Some(project_dir.to_str().unwrap()), + ) + .unwrap(); + bds_core::engine::settings::set( + db.conn(), + bds_core::engine::settings::UI_LANGUAGE_KEY, + "fr", + ) + .unwrap(); + + let runtime = ServerRuntime::start(ServerConfig { + database_path, + data_root: server_data.clone(), + bind: IpAddr::V4(Ipv4Addr::LOCALHOST), + port: 0, + mcp_port: 0, + }) + .unwrap(); + let target = RemoteTarget { + user: "author".into(), + host: runtime.address().ip().to_string(), + port: runtime.address().port(), + }; + + let unknown = match DesktopClient::connect(target.clone(), &unknown_data) { + Ok(_) => panic!("unknown key authenticated"), + Err(error) => error, + }; + assert!( + unknown + .to_string() + .contains("public-key authentication failed") + ); + + let identity = ClientKeyMaterial::ensure(&client_data).unwrap(); + let public_key = fs::read_to_string(&identity.public_key_path).unwrap(); + fs::write(&runtime.key_material().authorized_keys_path, &public_key).unwrap(); + + let first = DesktopClient::connect(target.clone(), &client_data).unwrap(); + let second = DesktopClient::connect(target.clone(), &client_data).unwrap(); + assert_eq!(first.server_locale(), "fr"); + assert_eq!(second.server_locale(), "fr"); + assert_eq!(first.list_projects().unwrap()[0].name, "Remote Blog"); + first.open_project(&project.id).unwrap(); + second.open_project(&project.id).unwrap(); + let _ = first.drain_events(); + let _ = second.drain_events(); + first + .call( + "posts", + "create", + vec![serde_json::json!({"title":"Over SSH","content":"body"})], + ) + .unwrap(); + thread::sleep(Duration::from_millis(250)); + assert_eq!(domain_event_count(&first.drain_events(), &project.id), 1); + assert_eq!(domain_event_count(&second.drain_events(), &project.id), 1); + assert_eq!( + bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id) + .unwrap() + .len(), + 1 + ); + + // Revocation affects new authentication attempts immediately without + // corrupting an already authenticated session. + fs::write(&runtime.key_material().authorized_keys_path, "").unwrap(); + first.ping().unwrap(); + second.disconnect().unwrap(); + let revoked = match DesktopClient::connect(target.clone(), &client_data) { + Ok(_) => panic!("revoked key authenticated"), + Err(error) => error, + }; + assert!( + revoked + .to_string() + .contains("public-key authentication failed") + ); + + fs::write(&runtime.key_material().authorized_keys_path, public_key).unwrap(); + let reconnected = DesktopClient::connect(target, &client_data).unwrap(); + reconnected.ping().unwrap(); + reconnected.disconnect().unwrap(); + + runtime.stop().unwrap(); + assert!(first.ping().is_err()); + first.disconnect().unwrap(); + } + + fn domain_event_count(messages: &[ServerMessage], project_id: &str) -> usize { + messages + .iter() + .filter(|message| { + matches!(message, ServerMessage::Event { event, .. } if event.project_id() == Some(project_id)) + }) + .count() + } +} diff --git a/crates/bds-ui/Cargo.toml b/crates/bds-ui/Cargo.toml index d273df2..cbc0948 100644 --- a/crates/bds-ui/Cargo.toml +++ b/crates/bds-ui/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] bds-core = { workspace = true } bds-editor = { workspace = true } +bds-server = { workspace = true } iced = { workspace = true } muda = { workspace = true } rfd = { workspace = true } @@ -38,11 +39,12 @@ winresource = "0.1" product-name = "Blogging Desktop Server" identifier = "de.rfc1437.ruds" description = "A desktop application for writing and publishing static blogs." -before-packaging-command = "cargo build --release -p bds-ui -p bds-cli -p bds-mcp" +before-packaging-command = "cargo build --release -p bds-ui -p bds-cli -p bds-mcp -p bds-server" binaries = [ { path = "bds-ui", main = true }, { path = "bds-cli" }, { path = "bds-mcp" }, + { path = "bds-server" }, ] deep-link-protocols = [{ schemes = ["ruds"] }] icons = [ diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 8e9fd6b..1aef216 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -200,6 +200,13 @@ pub enum Message { ShowModal(modal::ModalState), DismissModal, ConfirmModal(modal::ConfirmAction), + RemoteTargetChanged(String), + RemoteConnectRequested, + RemoteConnected(Result<(Arc, Vec), String>), + RemoteProjectSelected(String), + RemoteOpenProjectRequested, + RemoteProjectOpened(Result), + RemoteDisconnectRequested, FindQueryChanged(String), ReplaceQueryChanged(String), FindNext, @@ -933,6 +940,11 @@ pub struct BdsApp { // Modal active_modal: Option, + remote_client: Option>, + remote_projects: Vec, + remote_project: Option, + remote_display_name: Option, + remote_previous_locale: Option, // Local preview preview_session: Option, @@ -1048,6 +1060,7 @@ impl BdsApp { registry.set_enabled(MenuAction::Find, false); registry.set_enabled(MenuAction::Replace, false); registry.set_enabled(MenuAction::OpenInBrowser, false); + registry.set_enabled(MenuAction::DisconnectServer, false); let chat_conversations = db .as_ref() .and_then(|db| engine::chat::list_conversations(db.conn()).ok()) @@ -1105,6 +1118,11 @@ impl BdsApp { toasts: Vec::new(), active_modal: search_index_rebuild_required .then_some(modal::ModalState::SearchIndexRepair), + remote_client: None, + remote_projects: Vec::new(), + remote_project: None, + remote_display_name: None, + remote_previous_locale: None, preview_session: None, mcp_server, embedded_preview: None, @@ -1187,6 +1205,11 @@ impl BdsApp { theme_badge: String::from("pico"), toasts: Vec::new(), active_modal: None, + remote_client: None, + remote_projects: Vec::new(), + remote_project: None, + remote_display_name: None, + remote_previous_locale: None, preview_session: None, mcp_server: None, embedded_preview: None, @@ -2694,6 +2717,212 @@ impl BdsApp { } } } + Message::RemoteTargetChanged(value) => { + if let Some(modal::ModalState::RemoteConnection { target, error, .. }) = + self.active_modal.as_mut() + { + *target = value; + *error = None; + } + Task::none() + } + Message::RemoteConnectRequested => { + let target = match self.active_modal.as_mut() { + Some(modal::ModalState::RemoteConnection { + target, + connecting, + error, + .. + }) => { + *connecting = true; + *error = None; + target.clone() + } + _ => return Task::none(), + }; + let parsed = match bds_server::RemoteTarget::parse(&target) { + Ok(target) => target, + Err(error) => { + if let Some(modal::ModalState::RemoteConnection { + connecting, + error: shown, + .. + }) = self.active_modal.as_mut() + { + *connecting = false; + let _ = error; + *shown = Some(t(self.ui_locale, "remoteConnection.invalidTarget")); + } + return Task::none(); + } + }; + let data_root = bds_core::util::application_data_dir(); + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + let client = Arc::new( + bds_server::DesktopClient::connect(parsed, &data_root) + .map_err(|error| error.to_string())?, + ); + let projects = + client.list_projects().map_err(|error| error.to_string())?; + Ok((client, projects)) + }) + .await + .unwrap_or_else(|error| { + Err(format!("remote connection task failed: {error}")) + }) + }, + Message::RemoteConnected, + ) + } + Message::RemoteConnected(result) => { + match result { + Ok((client, projects)) => { + let server_locale = + bds_core::i18n::normalize_language(client.server_locale()); + self.remote_projects.clone_from(&projects); + self.remote_client = Some(client); + if let Some(modal::ModalState::RemoteConnection { + connecting, + connected, + error, + projects: shown_projects, + selected_project_id, + .. + }) = self.active_modal.as_mut() + { + *connecting = false; + *connected = true; + *error = None; + shown_projects.clone_from(&projects); + *selected_project_id = + projects.first().map(|project| project.id.clone()); + } + self.menu_registry + .set_enabled(MenuAction::DisconnectServer, true); + if self.remote_previous_locale.is_none() { + self.remote_previous_locale = Some(self.ui_locale); + } + self.apply_ui_locale(server_locale); + } + Err(connection_error) => { + let remains_connected = self.remote_client.is_some(); + if let Some(modal::ModalState::RemoteConnection { + connecting, + connected, + error, + .. + }) = self.active_modal.as_mut() + { + *connecting = false; + *connected = remains_connected; + *error = Some(tw( + self.ui_locale, + "remoteConnection.failed", + &[("reason", &connection_error)], + )); + } + } + } + Task::none() + } + Message::RemoteProjectSelected(project_id) => { + if let Some(modal::ModalState::RemoteConnection { + selected_project_id, + error, + .. + }) = self.active_modal.as_mut() + { + *selected_project_id = Some(project_id); + *error = None; + } + Task::none() + } + Message::RemoteOpenProjectRequested => { + let project_id = match self.active_modal.as_mut() { + Some(modal::ModalState::RemoteConnection { + selected_project_id: Some(project_id), + connecting, + error, + .. + }) => { + *connecting = true; + *error = None; + project_id.clone() + } + _ => return Task::none(), + }; + let Some(client) = self.remote_client.clone() else { + return Task::done(Message::RemoteProjectOpened(Err(t( + self.ui_locale, + "remoteConnection.connectionLost", + )))); + }; + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + client + .open_project(&project_id) + .map_err(|error| error.to_string()) + }) + .await + .unwrap_or_else(|error| Err(format!("remote project task failed: {error}"))) + }, + Message::RemoteProjectOpened, + ) + } + Message::RemoteProjectOpened(result) => { + match result { + Ok(project) => { + let target = self + .remote_client + .as_ref() + .map(|client| client.target().label()) + .unwrap_or_default(); + self.remote_display_name = Some(format!("{} — {target}", project.name)); + self.remote_project = Some(project); + self.active_modal = None; + self.notify( + ToastLevel::Success, + &t(self.ui_locale, "remoteConnection.opened"), + ); + } + Err(open_error) => { + if let Some(modal::ModalState::RemoteConnection { + connecting, error, .. + }) = self.active_modal.as_mut() + { + *connecting = false; + *error = Some(tw( + self.ui_locale, + "remoteConnection.openFailed", + &[("reason", &open_error)], + )); + } + } + } + Task::none() + } + Message::RemoteDisconnectRequested => { + if let Some(client) = self.remote_client.take() { + client.close(); + } + self.remote_projects.clear(); + self.remote_project = None; + self.remote_display_name = None; + self.active_modal = None; + self.menu_registry + .set_enabled(MenuAction::DisconnectServer, false); + if let Some(locale) = self.remote_previous_locale.take() { + self.apply_ui_locale(locale); + } + self.notify( + ToastLevel::Info, + &t(self.ui_locale, "remoteConnection.disconnected"), + ); + Task::none() + } Message::FindQueryChanged(value) => { if let Some(modal::ModalState::FindReplace { query, .. }) = self.active_modal.as_mut() @@ -3095,7 +3324,11 @@ impl BdsApp { } pub fn view(&self) -> Element<'_, Message> { - let active_name = self.active_project.as_ref().map(|p| p.name.as_str()); + let active_name = self.remote_display_name.as_deref().or_else(|| { + self.active_project + .as_ref() + .map(|project| project.name.as_str()) + }); let active_post_filter = match self.sidebar_view { SidebarView::Pages => &self.page_filter, _ => &self.post_filter, @@ -3615,6 +3848,47 @@ impl BdsApp { } fn process_domain_events(&mut self) -> Task { + let remote_messages = self + .remote_client + .as_ref() + .map(|client| client.drain_events()) + .unwrap_or_default(); + for message in remote_messages { + match message { + bds_server::protocol::ServerMessage::Event { sequence, event } => { + self.add_output(&tw( + self.ui_locale, + "remoteConnection.eventReceived", + &[ + ("sequence", &sequence.to_string()), + ("event", &format!("{event:?}")), + ], + )); + } + bds_server::protocol::ServerMessage::Tasks { tasks, .. } => { + self.add_output(&tw( + self.ui_locale, + "remoteConnection.taskUpdate", + &[("count", &tasks.len().to_string())], + )); + } + bds_server::protocol::ServerMessage::Error { code, message, .. } => { + self.notify(ToastLevel::Error, &message); + if remote_error_closes_connection(&code) { + self.remote_client = None; + self.remote_project = None; + self.remote_projects.clear(); + self.remote_display_name = None; + self.menu_registry + .set_enabled(MenuAction::DisconnectServer, false); + if let Some(locale) = self.remote_previous_locale.take() { + self.apply_ui_locale(locale); + } + } + } + bds_server::protocol::ServerMessage::Response { .. } => {} + } + } if let Some(db) = &self.db { let _ = engine::cli_sync::poll_notifications(db.conn()); } @@ -3849,6 +4123,26 @@ impl BdsApp { } Task::none() } + MenuAction::ConnectServer => { + let target = self + .remote_client + .as_ref() + .map(|client| client.target().label()) + .unwrap_or_default(); + self.active_modal = Some(modal::ModalState::RemoteConnection { + target, + connecting: false, + connected: self.remote_client.is_some(), + error: None, + projects: self.remote_projects.clone(), + selected_project_id: self + .remote_project + .as_ref() + .map(|project| project.id.clone()), + }); + Task::none() + } + MenuAction::DisconnectServer => Task::done(Message::RemoteDisconnectRequested), // Edit MenuAction::Find => { self.active_modal = Some(modal::ModalState::FindReplace { @@ -5279,6 +5573,8 @@ impl BdsApp { ), ); } + self.menu_registry + .set_enabled(MenuAction::DisconnectServer, self.remote_client.is_some()); } // ── Editor save/publish helpers ── @@ -8652,16 +8948,21 @@ fn language_label(locale: UiLocale, code: &str) -> String { } } +fn remote_error_closes_connection(code: &str) -> bool { + code == "connection_lost" +} + #[cfg(test)] mod tests { use super::{ BdsApp, Message, POST_AUTO_SAVE_DELAY_MS, PersistedMediaState, PersistedPostState, PostStatus, SettingsMsg, month_abbreviation, persist_media_editor_state_impl, persist_post_editor_preview_state_impl, persist_post_editor_state_impl, - save_editor_settings_state_impl, save_script_editor_state_impl, - save_template_editor_state_impl, + remote_error_closes_connection, save_editor_settings_state_impl, + save_script_editor_state_impl, save_template_editor_state_impl, }; use crate::i18n::t; + use crate::platform::menu::MenuAction; use crate::state::ToastLevel; use crate::state::sidebar_filter::{MediaFilter, PostFilter}; use crate::state::tabs::{Tab, TabType}; @@ -8735,6 +9036,49 @@ mod tests { (db, project, tempdir) } + #[test] + fn remote_connection_menu_opens_localized_selection_and_failure_states() { + let (db, project, temp) = setup(); + let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf()); + let _ = app.dispatch_menu_action(MenuAction::ConnectServer); + assert!(matches!( + app.active_modal, + Some(modal::ModalState::RemoteConnection { + connected: false, + ref projects, + .. + }) if projects.is_empty() + )); + + let _ = app.update(Message::RemoteTargetChanged("not-a-target".into())); + let _ = app.update(Message::RemoteConnectRequested); + assert!(matches!( + app.active_modal, + Some(modal::ModalState::RemoteConnection { + connecting: false, + error: Some(ref error), + .. + }) if error == &t(UiLocale::En, "remoteConnection.invalidTarget") + )); + + let _ = app.update(Message::RemoteConnected(Err("refused".into()))); + assert!(matches!( + app.active_modal, + Some(modal::ModalState::RemoteConnection { + connected: false, + error: Some(ref error), + .. + }) if error.contains("refused") && error != "refused" + )); + } + + #[test] + fn only_transport_loss_closes_an_open_remote_session() { + assert!(remote_error_closes_connection("connection_lost")); + assert!(!remote_error_closes_connection("sync_watcher_error")); + assert!(!remote_error_closes_connection("engine_error")); + } + #[test] fn documentation_external_links_require_confirmation_and_api_help_opens_real_tab() { let (db, project, temp) = setup(); diff --git a/crates/bds-ui/src/main.rs b/crates/bds-ui/src/main.rs index 4b9b73a..dc656c1 100644 --- a/crates/bds-ui/src/main.rs +++ b/crates/bds-ui/src/main.rs @@ -1,9 +1,28 @@ #![cfg_attr(target_os = "windows", windows_subsystem = "windows")] +use bds_server::boot::{BootMode, Platform}; use bds_ui::BdsApp; use bds_ui::components::inputs; -fn main() -> iced::Result { +fn main() -> anyhow::Result<()> { + let resolved = BootMode::resolve(std::env::var("BDS_MODE").ok().as_deref()); + let env = std::env::vars().collect(); + let mode = resolved.effective(current_platform(), &env); + if mode != BootMode::Desktop { + if mode != resolved { + let locale = bds_core::i18n::detect_os_locale(); + eprintln!( + "{}", + bds_core::i18n::translate(locale, "remoteTerminal.headlessFallback") + ); + } + let config = bds_server::ServerConfig::from_environment( + bds_core::util::application_database_path(), + bds_core::util::application_data_dir(), + )?; + return bds_server::run_headless(mode, config); + } + let icon = iced::window::icon::from_file_data(include_bytes!("../assets/app-icons/bds.png"), None) .expect("bundled application icon must be valid"); @@ -16,5 +35,17 @@ fn main() -> iced::Result { icon: Some(icon), ..Default::default() }) - .run_with(BdsApp::new) + .run_with(BdsApp::new)?; + Ok(()) +} + +fn current_platform() -> Platform { + #[cfg(target_os = "macos")] + return Platform::MacOs; + #[cfg(windows)] + return Platform::Windows; + #[cfg(all(unix, not(target_os = "macos")))] + return Platform::Unix; + #[allow(unreachable_code)] + Platform::Windows } diff --git a/crates/bds-ui/src/platform/menu.rs b/crates/bds-ui/src/platform/menu.rs index cee1393..a6cc68b 100644 --- a/crates/bds-ui/src/platform/menu.rs +++ b/crates/bds-ui/src/platform/menu.rs @@ -20,6 +20,8 @@ pub enum MenuAction { Save, OpenInBrowser, OpenDataFolder, + ConnectServer, + DisconnectServer, // Edit (custom items only) Find, Replace, @@ -60,6 +62,8 @@ impl MenuAction { MenuAction::Save, MenuAction::OpenInBrowser, MenuAction::OpenDataFolder, + MenuAction::ConnectServer, + MenuAction::DisconnectServer, MenuAction::Find, MenuAction::Replace, MenuAction::EditPreferences, @@ -95,6 +99,8 @@ impl MenuAction { "save" => Self::Save, "open_in_browser" => Self::OpenInBrowser, "open_data_folder" => Self::OpenDataFolder, + "connect_server" => Self::ConnectServer, + "disconnect_server" => Self::DisconnectServer, "find" => Self::Find, "replace" => Self::Replace, "edit_preferences" => Self::EditPreferences, @@ -133,6 +139,8 @@ impl MenuAction { Self::Save => "menu.item.save", Self::OpenInBrowser => "menu.item.openInBrowser", Self::OpenDataFolder => "menu.item.openDataFolder", + Self::ConnectServer => "menu.item.connectServer", + Self::DisconnectServer => "menu.item.disconnectServer", Self::Find => "menu.item.find", Self::Replace => "menu.item.replace", Self::EditPreferences => "menu.item.editPreferences", @@ -342,6 +350,9 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) { let _ = file_menu.append(&item(&mut reg, MenuAction::OpenInBrowser, locale, None)); let _ = file_menu.append(&item(&mut reg, MenuAction::OpenDataFolder, locale, None)); let _ = file_menu.append(&PredefinedMenuItem::separator()); + let _ = file_menu.append(&item(&mut reg, MenuAction::ConnectServer, locale, None)); + let _ = file_menu.append(&item(&mut reg, MenuAction::DisconnectServer, locale, None)); + let _ = file_menu.append(&PredefinedMenuItem::separator()); let _ = file_menu.append(&PredefinedMenuItem::close_window(None)); // -- Edit -- @@ -523,16 +534,18 @@ pub fn update_menu_labels(registry: &MenuRegistry, locale: UiLocale) { } } -/// Iced subscription that polls muda `MenuEvent`s each frame. +/// Iced subscription that polls muda `MenuEvent`s independently of window input. /// /// Produces `Message::MenuEvent(MenuId)` so the app can look up the -/// `MenuAction` via its `MenuRegistry`. +/// `MenuAction` via its `MenuRegistry`. Native menu selection does not itself +/// produce an Iced window event, so an `iced::event` listener would leave the +/// action queued until the user next moved or clicked inside the window. pub fn menu_subscription() -> Subscription { - iced::event::listen_with(|_event, _status, _id| { + iced::time::every(std::time::Duration::from_millis(50)).map(|_| { if let Ok(event) = MenuEvent::receiver().try_recv() { - Some(Message::MenuEvent(event.id)) + Message::MenuEvent(event.id) } else { - None + Message::Noop } }) } diff --git a/crates/bds-ui/src/views/modal.rs b/crates/bds-ui/src/views/modal.rs index f9920f7..426d892 100644 --- a/crates/bds-ui/src/views/modal.rs +++ b/crates/bds-ui/src/views/modal.rs @@ -104,6 +104,14 @@ pub enum ModalState { source_language: String, available_targets: Vec, }, + RemoteConnection { + target: String, + connecting: bool, + connected: bool, + error: Option, + projects: Vec, + selected_project_id: Option, + }, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -219,6 +227,119 @@ pub fn view( data_dir: Option<&Path>, ) -> Element<'static, Message> { let modal_content: Element<'static, Message> = match state { + ModalState::RemoteConnection { + target, + connecting, + connected, + error, + projects, + selected_project_id, + } => { + let target_input = + text_input(&t(locale, "remoteConnection.targetPlaceholder"), &target) + .on_input(Message::RemoteTargetChanged) + .on_submit(Message::RemoteConnectRequested) + .style(inputs::field_style); + let mut content = column![ + text(t(locale, "remoteConnection.title")) + .size(16) + .shaping(Shaping::Advanced) + .color(Color::WHITE), + text(t(locale, "remoteConnection.description")) + .size(12) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.70, 0.70, 0.75)), + Space::with_height(8.0), + text(t(locale, "remoteConnection.target")) + .size(12) + .color(Color::from_rgb(0.80, 0.80, 0.85)), + target_input, + ] + .spacing(6) + .width(Length::Fill); + + if let Some(error) = error { + content = content.push( + inputs::card(text(error).size(12).color(Color::from_rgb(1.0, 0.55, 0.55))) + .width(Length::Fill), + ); + } + + if !projects.is_empty() { + content = content.push(Space::with_height(6.0)); + content = content.push( + text(t(locale, "remoteConnection.projects")) + .size(13) + .color(Color::WHITE), + ); + let mut project_rows = column![].spacing(6); + for project in projects { + let selected = selected_project_id.as_deref() == Some(project.id.as_str()); + let marker = if selected { "●" } else { "○" }; + project_rows = project_rows.push( + button( + row![ + text(marker).size(12), + text(project.name.clone()).size(13), + Space::with_width(Length::Fill), + text(project.description.unwrap_or_default()) + .size(11) + .color(Color::from_rgb(0.65, 0.65, 0.70)), + ] + .spacing(8) + .align_y(Alignment::Center), + ) + .on_press(Message::RemoteProjectSelected(project.id)) + .padding([7, 10]) + .width(Length::Fill) + .style(if selected { + inputs::primary_button + } else { + inputs::secondary_button + }), + ); + } + content = content.push(project_rows); + } + + let cancel = button(text(t(locale, "remoteConnection.cancel")).size(13)) + .on_press(Message::DismissModal) + .padding([6, 16]) + .style(cancel_button_style); + let action = if !connected { + let button = button( + text(if connecting { + t(locale, "remoteConnection.connecting") + } else { + t(locale, "remoteConnection.connect") + }) + .size(13), + ) + .padding([6, 16]) + .style(confirm_button_style); + if connecting || target.trim().is_empty() { + button + } else { + button.on_press(Message::RemoteConnectRequested) + } + } else { + let button = button(text(t(locale, "remoteConnection.open")).size(13)) + .padding([6, 16]) + .style(confirm_button_style); + if selected_project_id.is_some() { + button.on_press(Message::RemoteOpenProjectRequested) + } else { + button + } + }; + content = content.push(Space::with_height(8.0)).push( + row![cancel, Space::with_width(Length::Fill), action].align_y(Alignment::Center), + ); + container(content.padding(20)) + .width(Length::Fixed(500.0)) + .style(modal_box_style) + .into() + } ModalState::ConfirmDelete { entity_name, references, diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index 2ca1a4c..0f2d8b1 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -712,3 +712,29 @@ import-sidebar-pending = Nicht analysiert import-deleteTitle = Importdefinition löschen? import-deleteMessage = „{ $name }“ und die gespeicherte Analyse löschen? Bereits importierte Projektinhalte bleiben erhalten. import-toast-deleted = Importdefinition gelöscht. +menu-item-connectServer = Mit Server verbinden… +menu-item-disconnectServer = Serververbindung trennen +remoteConnection-title = Mit RuDS-Server verbinden +remoteConnection-description = Die private RuDS-SSH-Identität wird verwendet. Bei der ersten Verbindung wird der Host-Schlüssel gespeichert. +remoteConnection-target = Server +remoteConnection-targetPlaceholder = benutzer@host oder benutzer@host:port +remoteConnection-connect = Verbinden +remoteConnection-connecting = Verbindung wird hergestellt… +remoteConnection-projects = Entfernte Projekte +remoteConnection-open = Projekt öffnen +remoteConnection-cancel = Abbrechen +remoteConnection-connectionLost = Die Serververbindung ist nicht mehr verfügbar. +remoteConnection-opened = Entferntes Projekt geöffnet. +remoteConnection-disconnected = Verbindung zum RuDS-Server getrennt. +remoteConnection-eventReceived = Entferntes Ereignis #{ $sequence }: { $event } +remoteConnection-taskUpdate = Entfernte Aufgabenaktualisierung: { $count } Aufgaben. +remoteConnection-invalidTarget = Verwenden Sie das Format benutzer@host oder benutzer@host:port. +remoteConnection-failed = Verbindung zum RuDS-Server fehlgeschlagen: { $reason } +remoteConnection-openFailed = Das entfernte Projekt konnte nicht geöffnet werden: { $reason } +remoteTerminal-serverTitle = RuDS-Serversitzung +remoteTerminal-localTitle = RuDS-Terminalsitzung +remoteTerminal-availableProjects = Verfügbare Projekte +remoteTerminal-quit = q zum Beenden +remoteTerminal-serverListening = RuDS-SSH-Server hört auf { $address } +remoteTerminal-authorizedKeys = Autorisierte Schlüssel: { $path } +remoteTerminal-headlessFallback = Keine grafische Sitzung verfügbar; die Terminaloberfläche wird gestartet. diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index e046be4..e703239 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -712,3 +712,29 @@ import-sidebar-pending = Not analyzed import-deleteTitle = Delete import definition? import-deleteMessage = Delete “{ $name }” and its saved analysis? Imported project content is not removed. import-toast-deleted = Import definition deleted. +menu-item-connectServer = Connect to Server… +menu-item-disconnectServer = Disconnect from Server +remoteConnection-title = Connect to RuDS Server +remoteConnection-description = Use your private RuDS SSH identity. The first connection records the server host key. +remoteConnection-target = Server +remoteConnection-targetPlaceholder = user@host or user@host:port +remoteConnection-connect = Connect +remoteConnection-connecting = Connecting… +remoteConnection-projects = Remote projects +remoteConnection-open = Open Project +remoteConnection-cancel = Cancel +remoteConnection-connectionLost = The remote connection is no longer available. +remoteConnection-opened = Remote project opened. +remoteConnection-disconnected = Disconnected from the RuDS server. +remoteConnection-eventReceived = Remote event #{ $sequence }: { $event } +remoteConnection-taskUpdate = Remote task update: { $count } tasks. +remoteConnection-invalidTarget = Use the form user@host or user@host:port. +remoteConnection-failed = Could not connect to the RuDS server: { $reason } +remoteConnection-openFailed = Could not open the remote project: { $reason } +remoteTerminal-serverTitle = RuDS server session +remoteTerminal-localTitle = RuDS terminal session +remoteTerminal-availableProjects = Available projects +remoteTerminal-quit = q to quit +remoteTerminal-serverListening = RuDS SSH server listening on { $address } +remoteTerminal-authorizedKeys = Authorized keys: { $path } +remoteTerminal-headlessFallback = No graphical session is available; starting the terminal UI. diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index b790026..3b186f8 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -712,3 +712,29 @@ import-sidebar-pending = Sin analizar import-deleteTitle = ¿Eliminar la definición de importación? import-deleteMessage = ¿Eliminar «{ $name }» y su análisis guardado? El contenido ya importado se conservará. import-toast-deleted = Definición de importación eliminada. +menu-item-connectServer = Conectar al servidor… +menu-item-disconnectServer = Desconectar del servidor +remoteConnection-title = Conectar al servidor RuDS +remoteConnection-description = Se usa la identidad SSH privada de RuDS. La clave de host del servidor se guarda en la primera conexión. +remoteConnection-target = Servidor +remoteConnection-targetPlaceholder = usuario@host o usuario@host:puerto +remoteConnection-connect = Conectar +remoteConnection-connecting = Conectando… +remoteConnection-projects = Proyectos remotos +remoteConnection-open = Abrir proyecto +remoteConnection-cancel = Cancelar +remoteConnection-connectionLost = La conexión remota ya no está disponible. +remoteConnection-opened = Proyecto remoto abierto. +remoteConnection-disconnected = Desconectado del servidor RuDS. +remoteConnection-eventReceived = Evento remoto n.º { $sequence }: { $event } +remoteConnection-taskUpdate = Actualización de tareas remotas: { $count } tareas. +remoteConnection-invalidTarget = Usa el formato usuario@host o usuario@host:puerto. +remoteConnection-failed = No se pudo conectar al servidor RuDS: { $reason } +remoteConnection-openFailed = No se pudo abrir el proyecto remoto: { $reason } +remoteTerminal-serverTitle = Sesión de servidor RuDS +remoteTerminal-localTitle = Sesión de terminal RuDS +remoteTerminal-availableProjects = Proyectos disponibles +remoteTerminal-quit = q para salir +remoteTerminal-serverListening = El servidor SSH de RuDS escucha en { $address } +remoteTerminal-authorizedKeys = Claves autorizadas: { $path } +remoteTerminal-headlessFallback = No hay una sesión gráfica disponible; se iniciará la interfaz de terminal. diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index 897f6a4..42d1c8a 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -712,3 +712,29 @@ import-sidebar-pending = Non analysé import-deleteTitle = Supprimer la définition d’import ? import-deleteMessage = Supprimer « { $name } » et son analyse enregistrée ? Le contenu déjà importé sera conservé. import-toast-deleted = Définition d’import supprimée. +menu-item-connectServer = Se connecter au serveur… +menu-item-disconnectServer = Se déconnecter du serveur +remoteConnection-title = Se connecter au serveur RuDS +remoteConnection-description = L’identité SSH privée de RuDS est utilisée. La clé d’hôte du serveur est enregistrée lors de la première connexion. +remoteConnection-target = Serveur +remoteConnection-targetPlaceholder = utilisateur@hôte ou utilisateur@hôte:port +remoteConnection-connect = Se connecter +remoteConnection-connecting = Connexion… +remoteConnection-projects = Projets distants +remoteConnection-open = Ouvrir le projet +remoteConnection-cancel = Annuler +remoteConnection-connectionLost = La connexion distante n’est plus disponible. +remoteConnection-opened = Projet distant ouvert. +remoteConnection-disconnected = Déconnecté du serveur RuDS. +remoteConnection-eventReceived = Événement distant nº { $sequence } : { $event } +remoteConnection-taskUpdate = Mise à jour des tâches distantes : { $count } tâches. +remoteConnection-invalidTarget = Utilisez le format utilisateur@hôte ou utilisateur@hôte:port. +remoteConnection-failed = Impossible de se connecter au serveur RuDS : { $reason } +remoteConnection-openFailed = Impossible d’ouvrir le projet distant : { $reason } +remoteTerminal-serverTitle = Session serveur RuDS +remoteTerminal-localTitle = Session terminal RuDS +remoteTerminal-availableProjects = Projets disponibles +remoteTerminal-quit = q pour quitter +remoteTerminal-serverListening = Le serveur SSH RuDS écoute sur { $address } +remoteTerminal-authorizedKeys = Clés autorisées : { $path } +remoteTerminal-headlessFallback = Aucune session graphique n’est disponible ; démarrage de l’interface terminal. diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index b89fbc1..082124b 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -712,3 +712,29 @@ import-sidebar-pending = Non analizzato import-deleteTitle = Eliminare la definizione di importazione? import-deleteMessage = Eliminare “{ $name }” e l’analisi salvata? I contenuti già importati resteranno invariati. import-toast-deleted = Definizione di importazione eliminata. +menu-item-connectServer = Connetti al server… +menu-item-disconnectServer = Disconnetti dal server +remoteConnection-title = Connetti al server RuDS +remoteConnection-description = Viene usata l’identità SSH privata di RuDS. Alla prima connessione viene registrata la chiave host del server. +remoteConnection-target = Server +remoteConnection-targetPlaceholder = utente@host o utente@host:porta +remoteConnection-connect = Connetti +remoteConnection-connecting = Connessione… +remoteConnection-projects = Progetti remoti +remoteConnection-open = Apri progetto +remoteConnection-cancel = Annulla +remoteConnection-connectionLost = La connessione remota non è più disponibile. +remoteConnection-opened = Progetto remoto aperto. +remoteConnection-disconnected = Disconnesso dal server RuDS. +remoteConnection-eventReceived = Evento remoto n. { $sequence }: { $event } +remoteConnection-taskUpdate = Aggiornamento attività remote: { $count } attività. +remoteConnection-invalidTarget = Usa il formato utente@host o utente@host:porta. +remoteConnection-failed = Impossibile connettersi al server RuDS: { $reason } +remoteConnection-openFailed = Impossibile aprire il progetto remoto: { $reason } +remoteTerminal-serverTitle = Sessione server RuDS +remoteTerminal-localTitle = Sessione terminale RuDS +remoteTerminal-availableProjects = Progetti disponibili +remoteTerminal-quit = q per uscire +remoteTerminal-serverListening = Il server SSH RuDS è in ascolto su { $address } +remoteTerminal-authorizedKeys = Chiavi autorizzate: { $path } +remoteTerminal-headlessFallback = Non è disponibile una sessione grafica; avvio dell’interfaccia terminale. diff --git a/specs/server.allium b/specs/server.allium index 01f5ebb..7ca27d0 100644 --- a/specs/server.allium +++ b/specs/server.allium @@ -64,20 +64,23 @@ rule ServerBoot { -- HTTP endpoint stays bound to 127.0.0.1; never exposed directly ensures: CliSyncWatcherStarted() ensures: SshDaemonStarted() - -- ExRatatui.SSH.Daemon: public-key auth only (no passwords), - -- tcpip_tunnel_out enabled so GUI clients tunnel to the loopback - -- endpoint through the same SSH connection and the same keys + -- Public-key auth only (no passwords). Direct TCP/IP forwarding is + -- restricted to the server-owned loopback endpoint; the native GUI + -- protocol and terminal sessions use channels on the same connection. ensures: SshKeyMaterial.created() -- host key generated and authorized_keys touched on first boot only } rule GuiRemoteConnection { when: GuiConnectRequested() - -- Desktop "Connect to Server…": ssh connect with the same public keys - -- (client identity + known_hosts in the private ssh dir), then an OTP - -- TCP/IP tunnel to the server's loopback endpoint; the webview loads - -- the local tunnel end. Disconnect returns to the local shell URL. - ensures: WebviewTunneledToServer() + -- Desktop "Connect to Server…": SSH connects with the same public keys + -- (client identity + known_hosts in the private ssh dir), negotiates the + -- versioned `ruds` subsystem, lists server projects, and opens the selected + -- project through the shared CoreHost application-service operations. The + -- channel carries ordered domain events, task snapshots, and the server UI + -- locale. Request IDs make replayed writes exactly-once. Disconnect returns + -- to the local project without exposing or copying the SQLite database. + ensures: NativeRemoteProjectOpened() } rule TuiSession {