diff --git a/Cargo.lock b/Cargo.lock index 05fc941..c1a95ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,7 +46,7 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cipher", "cpufeatures 0.2.17", ] @@ -260,7 +260,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ "autocfg", - "cfg-if", + "cfg-if 1.0.4", "concurrent-queue", "futures-io", "futures-lite", @@ -305,7 +305,7 @@ dependencies = [ "async-signal", "async-task", "blocking", - "cfg-if", + "cfg-if 1.0.4", "event-listener", "futures-lite", "rustix 1.1.4", @@ -320,7 +320,7 @@ dependencies = [ "async-io", "async-lock", "atomic-waker", - "cfg-if", + "cfg-if 1.0.4", "futures-core", "futures-io", "rustix 1.1.4", @@ -429,9 +429,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" dependencies = [ "cc", "cmake", @@ -462,12 +462,6 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.22.1" @@ -543,16 +537,16 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.3" +version = "1.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" dependencies = [ "arrayref", "arrayvec", "cc", - "cfg-if", + "cfg-if 1.0.4", "constant_time_eq 0.4.2", - "cpufeatures 0.2.17", + "cpufeatures 0.3.0", ] [[package]] @@ -704,9 +698,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.57" +version = "1.2.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", "jobserver", @@ -741,6 +735,12 @@ dependencies = [ "target-lexicon", ] +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + [[package]] name = "cfg-if" version = "1.0.4" @@ -759,7 +759,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cipher", "cpufeatures 0.2.17", ] @@ -770,7 +770,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures 0.3.0", "rand_core 0.10.0", ] @@ -795,8 +795,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8380ce7721cc895fe8a184c49d615fe755b0c9a3d7986355cee847439fff907f" dependencies = [ "async-tungstenite", - "base64 0.22.1", - "cfg-if", + "base64", + "cfg-if 1.0.4", "chromiumoxide_cdp", "chromiumoxide_types", "dunce", @@ -921,9 +921,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] @@ -1230,7 +1230,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -1326,7 +1326,7 @@ dependencies = [ "dtoa-short", "itoa", "matches", - "phf 0.8.0", + "phf 0.10.1", "proc-macro2", "quote", "smallvec", @@ -1391,7 +1391,7 @@ name = "curve25519-dalek" version = "4.1.3" source = "git+https://github.com/signalapp/curve25519-dalek?tag=signal-curve25519-4.1.3#7c6d34756355a3566a704da84dce7b1c039a6572" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", @@ -1487,7 +1487,7 @@ version = "6.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "crossbeam-utils", "hashbrown 0.14.5", "lock_api", @@ -1725,9 +1725,9 @@ dependencies = [ [[package]] name = "dioxus" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92b583b48ac77158495e6678fe3a2b5954fc8866fc04cb9695dd146e88bc329d" +checksum = "0d5b0aec58753daee127a5fe2d1a40b0db8cebc0b8a7f97b34df2492cb90d78e" dependencies = [ "dioxus-asset-resolver", "dioxus-cli-config", @@ -1752,9 +1752,9 @@ dependencies = [ [[package]] name = "dioxus-asset-resolver" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0161af1d3cfc8ff31503ff1b7ee0068c97771fc38d0cc6566e23483142ddf4f" +checksum = "c240c4f092024b26e200ecd64723009173cf5bc2e5083c9feb778c077eb5741b" dependencies = [ "dioxus-cli-config", "http", @@ -1779,18 +1779,18 @@ dependencies = [ [[package]] name = "dioxus-cli-config" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccd67ab405e1915a47df9769cd5408545d1b559d5c01ce7a0f442caef520d1f3" +checksum = "86a13d42c5defcea333bdbae1dc5d64d078acd0fda1d8a1441c37e06be5146e3" dependencies = [ "wasm-bindgen", ] [[package]] name = "dioxus-config-macro" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f040ec7c41aa5428283f56bb0670afba9631bfe3ffd885f4814807f12c8c9d91" +checksum = "1ba1d68a05a8a15293ba65d45c7a3263356f3eedf1a3e599440683f3eb014637" dependencies = [ "proc-macro2", "quote", @@ -1798,24 +1798,24 @@ dependencies = [ [[package]] name = "dioxus-config-macros" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10c41b47b55a433b61f7c12327c85ba650572bacbcc42c342ba2e87a57975264" +checksum = "f43f2d511d3c3c439a2fb7f863668b84caf8e0d2440cbfbcbb28521e26ba7f44" [[package]] name = "dioxus-core" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b389b0e3cc01c7da292ad9b884b088835fdd1671d45fbd2f737506152b22eef0" +checksum = "fb3dd61889e6a09daec93d44db86047fb8e6603beedcf9351b8528582254e075" dependencies = [ "anyhow", "const_format", "dioxus-core-types", "futures-channel", "futures-util", - "generational-box 0.7.3", + "generational-box 0.7.4", "longest-increasing-subsequence", - "rustc-hash 2.1.1", + "rustc-hash 2.1.2", "rustversion", "serde", "slab", @@ -1826,9 +1826,9 @@ dependencies = [ [[package]] name = "dioxus-core-macro" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82d65f0024fc86f01911a16156d280eea583be5a82a3bed85e7e8e4194302d" +checksum = "8577c4d9a8cc23423c4d2137319044b03ab940e4b2790dd25f4f06601bd32d9a" dependencies = [ "convert_case 0.8.0", "dioxus-rsx", @@ -1839,18 +1839,18 @@ dependencies = [ [[package]] name = "dioxus-core-types" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfc4b8cdc440a55c17355542fc2089d97949bba674255d84cac77805e1db8c9f" +checksum = "b99d7d199aad72431b549759550002e7d72c8a257eba500dca9fbdb2122de103" [[package]] name = "dioxus-desktop" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6ec66749d1556636c5b4f661495565c155a7f78a46d4d007d7478c6bdc288c" +checksum = "1df90224b51e0246bedeffe166e8ae782440dfaf45e329532a1d71afc79e0732" dependencies = [ "async-trait", - "base64 0.22.1", + "base64", "bytes", "cocoa", "core-foundation", @@ -1867,7 +1867,7 @@ dependencies = [ "dunce", "futures-channel", "futures-util", - "generational-box 0.7.3", + "generational-box 0.7.4", "global-hotkey", "infer", "jni 0.21.1", @@ -1882,7 +1882,7 @@ dependencies = [ "percent-encoding", "rand 0.9.2", "rfd", - "rustc-hash 2.1.1", + "rustc-hash 2.1.2", "serde", "serde_json", "signal-hook", @@ -1893,16 +1893,16 @@ dependencies = [ "tokio", "tracing", "tray-icon", - "tungstenite 0.27.0", + "tungstenite 0.28.0", "webbrowser", "wry", ] [[package]] name = "dioxus-devtools" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf89488bad8fb0f18b9086ee2db01f95f709801c10c68be42691a36378a0f2d" +checksum = "d27e7212436a581ce058d7554f1383916bd18a68ebd6015b0b4c2e9ecb0d5535" dependencies = [ "dioxus-cli-config", "dioxus-core", @@ -1913,14 +1913,14 @@ dependencies = [ "subsecond", "thiserror 2.0.18", "tracing", - "tungstenite 0.27.0", + "tungstenite 0.28.0", ] [[package]] name = "dioxus-devtools-types" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e7381d9d7d0a0f66b9d5082d584853c3d53be21d34007073daca98ddf26fc4d" +checksum = "6aa24ed651b97e0b423270bf07a0f1b7dc0e0fa1f1dc26407cd2a118d6bf9de5" dependencies = [ "dioxus-core", "serde", @@ -1929,9 +1929,9 @@ dependencies = [ [[package]] name = "dioxus-document" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba0aeeff26d9d06441f59fd8d7f4f76098ba30ca9728e047c94486161185ceb" +checksum = "24685cb51cc6227ea606c49dfe531836f362c49183d3007241afcd8827498401" dependencies = [ "dioxus-core", "dioxus-core-macro", @@ -1939,7 +1939,7 @@ dependencies = [ "dioxus-html", "futures-channel", "futures-util", - "generational-box 0.7.3", + "generational-box 0.7.4", "lazy-js-bundle", "serde", "serde_json", @@ -1948,9 +1948,9 @@ dependencies = [ [[package]] name = "dioxus-history" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d00ba43bfe6e5ca226fef6128f240ca970bea73cac0462416188026360ccdcf" +checksum = "010b446322b3f9176476579fa61c7552f0430abbeec418cab543482da6ca4363" dependencies = [ "dioxus-core", "tracing", @@ -1958,15 +1958,15 @@ dependencies = [ [[package]] name = "dioxus-hooks" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab2da4f038c33cb38caa37ffc3f5d6dfbc018f05da35b238210a533bb075823" +checksum = "09e7a6ba279050cc161e1215c6db0bd15915c9314ec2916d7b22c113a3039536" dependencies = [ "dioxus-core", "dioxus-signals", "futures-channel", "futures-util", - "generational-box 0.7.3", + "generational-box 0.7.4", "rustversion", "slab", "tracing", @@ -1974,9 +1974,9 @@ dependencies = [ [[package]] name = "dioxus-html" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded5fa6d2e677b7442a93f4228bf3c0ad2597a8bd3292cae50c869d015f3a99" +checksum = "f0715e38cc6537aef5b79d0ddc1f4d7a56c2f4debe46b127eee24d8aa5dafd2d" dependencies = [ "async-trait", "bytes", @@ -1989,7 +1989,7 @@ dependencies = [ "euclid", "futures-channel", "futures-util", - "generational-box 0.7.3", + "generational-box 0.7.4", "keyboard-types", "lazy-js-bundle", "rustversion", @@ -2001,9 +2001,9 @@ dependencies = [ [[package]] name = "dioxus-html-internal-macro" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45462ab85fe059a36841508d40545109fd0e25855012d22583a61908eb5cd02a" +checksum = "ff6b7918b0908c8719a6165b4e3c362da4fd311fc7cb48720eddd8a45b2ddfc6" dependencies = [ "convert_case 0.8.0", "proc-macro2", @@ -2013,16 +2013,16 @@ dependencies = [ [[package]] name = "dioxus-interpreter-js" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a42a7f73ad32a5054bd8c1014f4ac78cca3b7f6889210ee2b57ea31b33b6d32f" +checksum = "a8ce1cf487007f90d0ec4ec87dff111d74ac04fca0918f9dcc4e80dc3b0531b2" dependencies = [ "dioxus-core", "dioxus-core-types", "dioxus-html", "js-sys", "lazy-js-bundle", - "rustc-hash 2.1.1", + "rustc-hash 2.1.2", "serde", "sledgehammer_bindgen", "sledgehammer_utils", @@ -2033,9 +2033,9 @@ dependencies = [ [[package]] name = "dioxus-logger" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1eeab114cb009d9e6b85ea10639a18cfc54bb342f3b837770b004c4daeb89c2" +checksum = "d4742b16791a71eb4db2d0747f15c50b278b27369b3d93e5a4d6ec2570bcb9bc" dependencies = [ "dioxus-cli-config", "tracing", @@ -2045,9 +2045,9 @@ dependencies = [ [[package]] name = "dioxus-rsx" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53128858f0ccca9de54292a4d48409fda1df75fd5012c6243f664042f0225d68" +checksum = "344621f6dc435e76fbe272da09988d0118cf35cc2aa88ebb5ae7c1317a36e57c" dependencies = [ "proc-macro2", "proc-macro2-diagnostics", @@ -2058,37 +2058,37 @@ dependencies = [ [[package]] name = "dioxus-signals" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f48020bc23bc9766e7cce986c0fd6de9af0b8cbfd432652ec6b1094439c1ec6" +checksum = "409bf65d243443416650945f22cd6caf2a6bb13ae0347a50ec5852adb1961072" dependencies = [ "dioxus-core", "futures-channel", "futures-util", - "generational-box 0.7.3", + "generational-box 0.7.4", "parking_lot", - "rustc-hash 2.1.1", + "rustc-hash 2.1.2", "tracing", "warnings", ] [[package]] name = "dioxus-stores" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77aaa9ac56d781bb506cf3c0d23bea96b768064b89fe50d3b4d4659cc6bd8058" +checksum = "245ec4f84348e5be77451bd204181998b8bc0995b48ff3adb2db0e0ec430dab4" dependencies = [ "dioxus-core", "dioxus-signals", "dioxus-stores-macro", - "generational-box 0.7.3", + "generational-box 0.7.4", ] [[package]] name = "dioxus-stores-macro" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b1a728622e7b63db45774f75e71504335dd4e6115b235bbcff272980499493a" +checksum = "dd9da8e9a1cc2d8bff387e0b99f09f2590b71f67d5d73ab343b2cc9d17990d92" dependencies = [ "convert_case 0.8.0", "proc-macro2", @@ -2098,9 +2098,9 @@ dependencies = [ [[package]] name = "dioxus-web" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b33fe739fed4e8143dac222a9153593f8e2451662ce8fc4c9d167a9d6ec0923" +checksum = "eac92ef863bc5333440021e8ec3e538a39598c9c960daeaab66ab10ba940b5e0" dependencies = [ "dioxus-cli-config", "dioxus-core", @@ -2113,11 +2113,11 @@ dependencies = [ "dioxus-signals", "futures-channel", "futures-util", - "generational-box 0.7.3", + "generational-box 0.7.4", "gloo-timers", "js-sys", "lazy-js-bundle", - "rustc-hash 2.1.1", + "rustc-hash 2.1.2", "send_wrapper", "serde", "serde-wasm-bindgen", @@ -2422,18 +2422,18 @@ dependencies = [ [[package]] name = "env_filter" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", ] [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ "env_filter", "log", @@ -2538,9 +2538,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f" [[package]] name = "fdeflate" @@ -2897,9 +2897,9 @@ dependencies = [ [[package]] name = "generational-box" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4ed190b9de8e734d47a70be59b1e7588b9e8e0d0036e332f4c014e8aed1bc5" +checksum = "4ede46ff252793f9b6ef752c506ba8600c69d73cad2ef9bbf2e6dee85019a3bc" dependencies = [ "parking_lot", "tracing", @@ -2941,7 +2941,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "wasi 0.9.0+wasi-snapshot-preview1", ] @@ -2952,7 +2952,7 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -2965,7 +2965,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "js-sys", "libc", "r-efi 5.3.0", @@ -2979,7 +2979,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "js-sys", "libc", "r-efi 6.0.0", @@ -3276,7 +3276,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "headers-core", "http", @@ -3468,9 +3468,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -3482,7 +3482,6 @@ dependencies = [ "httparse", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -3511,7 +3510,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-util", @@ -3735,9 +3734,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -3846,9 +3845,9 @@ checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" [[package]] name = "iri-string" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -3952,7 +3951,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" dependencies = [ "cesu8", - "cfg-if", + "cfg-if 1.0.4", "combine", "jni-sys 0.3.1", "log", @@ -3967,7 +3966,7 @@ version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "combine", "jni-macros", "jni-sys 0.4.1", @@ -4031,10 +4030,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ + "cfg-if 1.0.4", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -4119,9 +4120,9 @@ checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" [[package]] name = "lazy-js-bundle" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7b88b715ab1496c6e6b8f5e927be961c4235196121b6ae59bcb51077a21dd36" +checksum = "60d7adc10cb9440d17fa67e467febdfc98931338773d11bfee81809af54d0697" [[package]] name = "lazy_static" @@ -4170,9 +4171,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libloading" @@ -4180,7 +4181,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "winapi", ] @@ -4190,7 +4191,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "windows-link 0.2.1", ] @@ -4202,9 +4203,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "libc", ] @@ -4253,9 +4254,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "litrs" @@ -4375,21 +4376,25 @@ dependencies = [ [[package]] name = "manganis" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cce7d688848bf9d034168513b9a2ffbfe5f61df2ff14ae15e6cfc866efdd344" +checksum = "492da8d77990281eabe6ded633e7b0cf805c5cf7a023a99abed8811edc872d6f" dependencies = [ "const-serialize 0.7.2", "const-serialize 0.8.0-alpha.0", + "jni 0.21.1", "manganis-core", "manganis-macro", + "ndk-context", + "objc2", + "thiserror 2.0.18", ] [[package]] name = "manganis-core" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84ce917b978268fe8a7db49e216343ec7c8f471f7e686feb70940d67293f19d4" +checksum = "a1b84cc2951f3b119702fab499b9b1aec3f454929c62feca55b895b82c628308" dependencies = [ "const-serialize 0.7.2", "const-serialize 0.8.0-alpha.0", @@ -4401,9 +4406,9 @@ dependencies = [ [[package]] name = "manganis-macro" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad513e990f7c0bca86aa68659a7a3dc4c705572ed4c22fd6af32ccf261334cc2" +checksum = "6d2e60d36758b201b6ebb8a31aff6b013e58924eeb6d3cbf19aea764f51d69e4" dependencies = [ "dunce", "macro-string", @@ -4556,7 +4561,7 @@ dependencies = [ "backon", "bytes", "bytesize", - "cfg-if", + "cfg-if 1.0.4", "event-listener", "eyeball", "eyeball-im", @@ -4661,7 +4666,7 @@ dependencies = [ "async-trait", "bs58", "byteorder", - "cfg-if", + "cfg-if 1.0.4", "ctr", "eyeball", "futures-core", @@ -4697,7 +4702,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b6096084cc8d339c03e269ca25534d0f1e88d0097c35a215eb8c311797ec3e9" dependencies = [ "async-trait", - "base64 0.22.1", + "base64", "futures-util", "getrandom 0.2.17", "gloo-utils", @@ -4756,7 +4761,7 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "162a93e83114d5cef25c0ebaea72aa01b9f233df6ec4a2af45f175d01ec26323" dependencies = [ - "base64 0.22.1", + "base64", "blake3", "chacha20poly1305", "getrandom 0.2.17", @@ -4778,7 +4783,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245ff6a224b4df7b0c90dda2dd5a6eb46112708d49e8bdd8b007fccb09fea8e4" dependencies = [ "accessory", - "cfg-if", + "cfg-if 1.0.4", "delegate-display", "derive_more 2.1.1", "fancy_constructor", @@ -4900,9 +4905,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -4942,9 +4947,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.17.1" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" dependencies = [ "crossbeam-channel", "dpi", @@ -5027,7 +5032,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.11.0", - "cfg-if", + "cfg-if 1.0.4", "cfg_aliases", "libc", ] @@ -5039,7 +5044,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ "bitflags 2.11.0", - "cfg-if", + "cfg-if 1.0.4", "cfg_aliases", "libc", ] @@ -5107,9 +5112,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -5179,7 +5184,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "getrandom 0.2.17", "http", @@ -5343,7 +5348,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" dependencies = [ "bitflags 2.11.0", - "cfg-if", + "cfg-if 1.0.4", "foreign-types 0.3.2", "libc", "once_cell", @@ -5516,7 +5521,7 @@ version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "redox_syscall", "smallvec", @@ -5605,8 +5610,17 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros 0.8.0", "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", "proc-macro-hack", ] @@ -5670,6 +5684,16 @@ dependencies = [ "rand 0.7.3", ] +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + [[package]] name = "phf_generator" version = "0.11.3" @@ -5692,12 +5716,12 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", + "phf_generator 0.10.0", + "phf_shared 0.10.0", "proc-macro-hack", "proc-macro2", "quote", @@ -5726,6 +5750,15 @@ dependencies = [ "siphasher 0.3.11", ] +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + [[package]] name = "phf_shared" version = "0.11.3" @@ -5770,12 +5803,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "piper" version = "0.2.5" @@ -5863,7 +5890,7 @@ version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "concurrent-queue", "hermit-abi", "pin-project-lite", @@ -5894,7 +5921,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures 0.2.17", "opaque-debug", "universal-hash", @@ -5917,9 +5944,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -5996,7 +6023,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.5+spec-1.1.0", + "toml_edit 0.25.10+spec-1.1.0", ] [[package]] @@ -6222,7 +6249,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.1", + "rustc-hash 2.1.2", "rustls", "socket2", "thiserror 2.0.18", @@ -6243,7 +6270,7 @@ dependencies = [ "lru-slab", "rand 0.9.2", "ring", - "rustc-hash 2.1.1", + "rustc-hash 2.1.2", "rustls", "rustls-pki-types", "slab", @@ -6299,6 +6326,16 @@ dependencies = [ "scheduled-thread-pool", ] +[[package]] +name = "radix64" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "999718fa65c3be3a74f3f6dae5a98526ff436ea58a82a574f0de89eecd342bee" +dependencies = [ + "arrayref", + "cfg-if 0.1.10", +] + [[package]] name = "rand" version = "0.7.3" @@ -6568,7 +6605,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-core", "futures-util", @@ -6610,7 +6647,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-core", @@ -6687,7 +6724,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", - "cfg-if", + "cfg-if 1.0.4", "getrandom 0.2.17", "libc", "untrusted", @@ -6701,7 +6738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4c9c94680f75470ee8083a0667988b5d7b5beb70b9f998a8e51de7c682ce60" dependencies = [ "async-trait", - "base64 0.22.1", + "base64", "chrono", "futures", "pastey", @@ -6850,7 +6887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a01993f22d291320b7c9267675e7395775e95269ff526e2c8c3ed5e13175b" dependencies = [ "as_variant", - "base64 0.22.1", + "base64", "bytes", "form_urlencoded", "getrandom 0.2.17", @@ -6951,7 +6988,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a0753312ad577ac462de1742bf2e326b6ba9856ff6f13343aeb17d423fd5426" dependencies = [ "as_variant", - "cfg-if", + "cfg-if 1.0.4", "proc-macro-crate 3.5.0", "proc-macro2", "quote", @@ -6967,7 +7004,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146ace2cd59b60ec80d3e801a84e7e6a91e3e01d18a9f5d896ea7ca16a6b8e08" dependencies = [ - "base64 0.22.1", + "base64", "ed25519-dalek", "pkcs8", "rand 0.8.5", @@ -6998,6 +7035,48 @@ dependencies = [ "uuid", ] +[[package]] +name = "russh" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6500eedfaf8cd81597899d896908a4b9cd5cb566db875e843c04ccf92add2c16" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bitflags 2.11.0", + "byteorder", + "cbc", + "chacha20 0.9.1", + "ctr", + "curve25519-dalek", + "digest", + "elliptic-curve", + "flate2", + "futures", + "generic-array", + "hex-literal", + "hmac", + "log", + "num-bigint", + "once_cell", + "p256", + "p384", + "p521", + "poly1305", + "rand 0.8.5", + "rand_core 0.6.4", + "russh-cryptovec 0.7.3", + "russh-keys", + "sha1", + "sha2", + "ssh-encoding", + "ssh-key", + "subtle", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "russh" version = "0.52.1" @@ -7157,9 +7236,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -7316,7 +7395,7 @@ dependencies = [ "aho-corasick", "anyhow", "async-trait", - "base64 0.22.1", + "base64", "bincode", "chromiumoxide", "chrono", @@ -7328,6 +7407,7 @@ dependencies = [ "glob", "html2md", "httpdate", + "image", "indicatif", "ipnetwork", "landlock", @@ -7344,7 +7424,7 @@ dependencies = [ "rmcp", "rpassword", "rusqlite", - "russh", + "russh 0.52.1", "russh-keys", "rustls-pemfile", "schemars", @@ -7387,7 +7467,7 @@ name = "rustyclaw-desktop" version = "0.3.1" dependencies = [ "anyhow", - "base64 0.22.1", + "base64", "bincode", "chrono", "dioxus", @@ -7412,6 +7492,7 @@ name = "rustyclaw-tui" version = "0.3.1" dependencies = [ "anyhow", + "async-trait", "chrono", "clap", "colored", @@ -7424,6 +7505,8 @@ dependencies = [ "qrcode", "reqwest 0.13.2", "rpassword", + "russh 0.44.1", + "russh-keys", "rustyclaw-core", "serde", "serde_json", @@ -7593,12 +7676,12 @@ dependencies = [ [[package]] name = "securestore" -version = "0.100.0" +version = "0.100.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffda2694eca0e525be62fb62660ec4e21995296f527c0bb83cf51a6ab8a80d2e" +checksum = "0febcb3d737f705ec97d5a292db2d3abaeb6b8dbb37499c8114445db38a1eb31" dependencies = [ - "base64 0.13.1", "openssl", + "radix64", "serde", "serde_json", ] @@ -7658,16 +7741,16 @@ dependencies = [ "phf 0.13.1", "phf_codegen 0.13.1", "precomputed-hash", - "rustc-hash 2.1.1", + "rustc-hash 2.1.2", "servo_arc 0.4.3", "smallvec", ] [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "send_wrapper" @@ -7828,9 +7911,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -7885,7 +7968,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures 0.2.17", "digest", ] @@ -7896,7 +7979,7 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures 0.2.17", "digest", ] @@ -7968,9 +8051,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simd_cesu8" @@ -8289,9 +8372,9 @@ dependencies = [ [[package]] name = "subsecond" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8438668e545834d795d04c4335aafc332ce046106521a29f0a5c6501de34187c" +checksum = "5dbb9f2928b6654ccc28d4ddfef5213e97ed66afed4907774d049b376c62a838" dependencies = [ "js-sys", "libc", @@ -8308,9 +8391,9 @@ dependencies = [ [[package]] name = "subsecond-types" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e72f747606fc19fe81d6c59e491af93ed7dcbcb6aad9d1d18b05129914ec298" +checksum = "388bb28e6ddbee717745963b8932d9a6e24a5d3c93350655f733e938de04d81f" dependencies = [ "serde", ] @@ -8535,7 +8618,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -8572,9 +8655,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -8597,9 +8680,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" dependencies = [ "bytes", "libc", @@ -8614,9 +8697,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -8680,7 +8763,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-core", "futures-sink", @@ -8715,7 +8798,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.0.4", + "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", @@ -8742,9 +8825,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.1+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] @@ -8775,30 +8858,30 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.5+spec-1.1.0" +version = "0.25.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" +checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" dependencies = [ "indexmap", - "toml_datetime 1.0.1+spec-1.1.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.0", + "winnow 1.0.1", ] [[package]] name = "toml_parser" -version = "1.0.10+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.0", + "winnow 1.0.1", ] [[package]] name = "toml_writer" -version = "1.0.7+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "totp-rs" @@ -8996,25 +9079,6 @@ dependencies = [ "utf-8", ] -[[package]] -name = "tungstenite" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "native-tls", - "rand 0.9.2", - "rustls", - "sha1", - "thiserror 2.0.18", - "utf-8", -] - [[package]] name = "tungstenite" version = "0.28.0" @@ -9026,6 +9090,7 @@ dependencies = [ "http", "httparse", "log", + "native-tls", "rand 0.9.2", "rustls", "rustls-pki-types", @@ -9088,9 +9153,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "typewit" -version = "1.14.2" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8c1ae7cc0fdb8b842d65d127cb981574b0d2b249b74d1c7a2986863dc134f71" +checksum = "bc19094686c694eb41b3b99dcc2f2975d4b078512fa22ae6c63f7ca318bdcff7" dependencies = [ "typewit_proc_macros", ] @@ -9134,9 +9199,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -9196,7 +9261,7 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ - "base64 0.22.1", + "base64", "cookie_store", "log", "percent-encoding", @@ -9215,7 +9280,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ - "base64 0.22.1", + "base64", "http", "httparse", "log", @@ -9276,9 +9341,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -9324,7 +9389,7 @@ checksum = "c022a277687e4e8685d72b95a7ca3ccfec907daa946678e715f8badaa650883d" dependencies = [ "aes", "arrayvec", - "base64 0.22.1", + "base64", "base64ct", "cbc", "chacha20poly1305", @@ -9355,7 +9420,7 @@ dependencies = [ "anyhow", "async-channel", "async-trait", - "base64 0.22.1", + "base64", "bytes", "chrono", "dashmap", @@ -9425,7 +9490,7 @@ dependencies = [ "anyhow", "async-channel", "async-trait", - "base64 0.22.1", + "base64", "bytes", "chrono", "ctr", @@ -9658,11 +9723,11 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -9671,23 +9736,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -9695,9 +9756,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", @@ -9708,9 +9769,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] @@ -9795,9 +9856,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" dependencies = [ "js-sys", "wasm-bindgen", @@ -10608,9 +10669,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" dependencies = [ "memchr", ] @@ -10621,7 +10682,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "windows-sys 0.48.0", ] @@ -10727,9 +10788,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wry" @@ -10737,7 +10798,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" dependencies = [ - "base64 0.22.1", + "base64", "block2", "cookie", "crossbeam-channel", @@ -10849,9 +10910,9 @@ checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -10860,9 +10921,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -10872,18 +10933,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -10892,18 +10953,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -10933,9 +10994,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -10944,9 +11005,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -10955,9 +11016,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", @@ -10966,9 +11027,9 @@ dependencies = [ [[package]] name = "zip" -version = "8.3.1" +version = "8.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c546feb4481b0fbafb4ef0d79b6204fc41c6f9884b1b73b1d73f82442fc0845" +checksum = "2726508a48f38dceb22b35ecbbd2430efe34ff05c62bd3285f965d7911b33464" dependencies = [ "aes", "bzip2", diff --git a/crates/rustyclaw-cli/src/bin/rustyclaw-gateway.rs b/crates/rustyclaw-cli/src/bin/rustyclaw-gateway.rs index d715124..e746381 100644 --- a/crates/rustyclaw-cli/src/bin/rustyclaw-gateway.rs +++ b/crates/rustyclaw-cli/src/bin/rustyclaw-gateway.rs @@ -54,6 +54,36 @@ enum GatewayCommands { #[arg(long)] json: bool, }, + /// Manage SSH pairing and authorized clients + #[command(subcommand)] + Pair(PairCommands), +} + +#[derive(Debug, Subcommand)] +enum PairCommands { + /// List authorized clients + List, + /// Add a new authorized client + Add { + /// Public key in OpenSSH format (ssh-ed25519 AAAA...) + #[arg(value_name = "PUBLIC_KEY")] + key: String, + /// Optional name/comment for the client + #[arg(long, short)] + name: Option, + }, + /// Remove an authorized client by fingerprint + Remove { + /// Key fingerprint (SHA256:...) + #[arg(value_name = "FINGERPRINT")] + fingerprint: String, + }, + /// Show pairing QR code for this gateway + Qr { + /// Gateway host:port (required for QR generation) + #[arg(long, value_name = "HOST:PORT")] + host: String, + }, } #[derive(Debug, clap::Args)] @@ -149,6 +179,9 @@ async fn main() -> Result<()> { } return Ok(()); } + Some(GatewayCommands::Pair(pair_cmd)) => { + return handle_pair_command(pair_cmd).await; + } None => RunArgs::default(), }; @@ -386,7 +419,7 @@ async fn main() -> Result<()> { /// /// This mode is used when the gateway is invoked via OpenSSH's subsystem /// mechanism. Instead of listening on TCP, we read/write frames on stdin/stdout. -async fn run_ssh_stdio_mode(config: Config, args: RunArgs) -> Result<()> { +async fn run_ssh_stdio_mode(config: Config, _args: RunArgs) -> Result<()> { use rustyclaw_core::gateway::{StdioTransport, Transport}; // Get username from SSH environment @@ -424,7 +457,7 @@ async fn run_ssh_stdio_mode(config: Config, args: RunArgs) -> Result<()> { std::sync::Arc::new(tokio::sync::Mutex::new(vault)); // ── Resolve model context ──────────────────────────────────────────── - let model_ctx = { + let _model_ctx = { let env_key = std::env::var("RUSTYCLAW_MODEL_API_KEY").ok(); if let Some(ref key) = env_key { @@ -447,7 +480,7 @@ async fn run_ssh_stdio_mode(config: Config, args: RunArgs) -> Result<()> { if let Some(url) = config.clawhub_url.as_deref() { sm.set_registry(url, config.clawhub_token.clone()); } - let shared_skills: rustyclaw_core::gateway::SharedSkillManager = + let _shared_skills: rustyclaw_core::gateway::SharedSkillManager = std::sync::Arc::new(tokio::sync::Mutex::new(sm)); // Set up cancellation @@ -466,3 +499,123 @@ async fn run_ssh_stdio_mode(config: Config, args: RunArgs) -> Result<()> { transport.close().await?; Ok(()) } + +/// Handle pairing subcommands. +async fn handle_pair_command(cmd: PairCommands) -> Result<()> { + use rustyclaw_core::pairing::{ + default_authorized_clients_path, + load_authorized_clients, + add_authorized_client, + remove_authorized_client, + }; + + let auth_path = default_authorized_clients_path(); + + match cmd { + PairCommands::List => { + let clients = load_authorized_clients(&auth_path)?; + + if clients.clients.is_empty() { + println!("{}", t::muted("No authorized clients")); + println!(); + println!("Add a client with:"); + println!(" {} pair add --name ", t::info("rustyclaw-gateway")); + return Ok(()); + } + + println!("{}", t::heading("Authorized Clients")); + println!(); + + for (i, client) in clients.clients.iter().enumerate() { + let name = client.comment.as_deref().unwrap_or("(unnamed)"); + println!( + "{}. {} {}", + i + 1, + t::info(name), + t::muted(&format!("({})", &client.fingerprint)) + ); + } + + println!(); + println!( + "{} {}", + t::muted("File:"), + auth_path.display() + ); + } + + PairCommands::Add { key, name } => { + match add_authorized_client(&auth_path, &key, name.as_deref()) { + Ok(client) => { + println!( + "{} Added client: {}", + t::icon_ok(""), + t::info(client.comment.as_deref().unwrap_or("(unnamed)")) + ); + println!( + " {} {}", + t::muted("Fingerprint:"), + client.fingerprint + ); + } + Err(e) => { + eprintln!("{} Failed to add client: {}", t::icon_fail(""), e); + std::process::exit(1); + } + } + } + + PairCommands::Remove { fingerprint } => { + match remove_authorized_client(&auth_path, &fingerprint) { + Ok(true) => { + println!( + "{} Removed client with fingerprint: {}", + t::icon_ok(""), + fingerprint + ); + } + Ok(false) => { + eprintln!( + "{} No client found with fingerprint: {}", + t::icon_fail(""), + fingerprint + ); + std::process::exit(1); + } + Err(e) => { + eprintln!("{} Failed to remove client: {}", t::icon_fail(""), e); + std::process::exit(1); + } + } + } + + PairCommands::Qr { host } => { + use rustyclaw_core::pairing::{PairingData, generate_pairing_qr_ascii}; + + // Generate gateway pairing data + // For now, we use a placeholder key - in production, this would be the host key's public part + let data = PairingData::gateway( + "ssh-ed25519 (host key would go here)", + &host, + Some("RustyClaw Gateway".to_string()), + ); + + match generate_pairing_qr_ascii(&data) { + Ok(qr) => { + println!("{}", t::heading("Gateway Pairing QR Code")); + println!(); + println!("{}", qr); + println!(); + println!("Scan this QR code with a RustyClaw client to pair."); + println!("Gateway address: {}", t::info(&host)); + } + Err(e) => { + eprintln!("{} Failed to generate QR code: {}", t::icon_fail(""), e); + std::process::exit(1); + } + } + } + } + + Ok(()) +} diff --git a/crates/rustyclaw-cli/src/commands/mod.rs b/crates/rustyclaw-cli/src/commands/mod.rs index ff4f20a..7fa2ee2 100644 --- a/crates/rustyclaw-cli/src/commands/mod.rs +++ b/crates/rustyclaw-cli/src/commands/mod.rs @@ -6,6 +6,6 @@ pub mod gateway; // Re-export handlers for use in main.rs pub use gateway::{ - handle_reload_result, handle_restart, handle_run, handle_start, handle_status, handle_stop, + handle_restart, handle_run, handle_start, handle_status, handle_stop, parse_gateway_defaults, }; diff --git a/crates/rustyclaw-core/Cargo.toml b/crates/rustyclaw-core/Cargo.toml index 3401a8c..1ed6b51 100644 --- a/crates/rustyclaw-core/Cargo.toml +++ b/crates/rustyclaw-core/Cargo.toml @@ -23,6 +23,7 @@ mcp = ["dep:rmcp", "dep:schemars"] matrix = ["dep:matrix-sdk"] whatsapp = ["dep:wa-rs", "dep:wa-rs-sqlite-storage", "dep:wa-rs-tokio-transport", "dep:wa-rs-ureq-http"] ssh = ["dep:russh", "dep:russh-keys", "dep:rand_core", "dep:sha2"] +qr = ["dep:image"] # CLI-based messengers (tier 1) - no heavy deps, just HTTP signal-cli = [] matrix-cli = [] # Matrix CLI messenger using HTTP API (no external deps) @@ -107,6 +108,9 @@ russh-keys = { version = "0.44", optional = true } rand_core = { version = "0.6", optional = true } sha2 = { version = "0.10", optional = true } +# QR code generation (optional) +image = { version = "0.25", default-features = false, features = ["png"], optional = true } + [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/rustyclaw-core/src/gateway/mod.rs b/crates/rustyclaw-core/src/gateway/mod.rs index 1d667b5..2f6b37c 100644 --- a/crates/rustyclaw-core/src/gateway/mod.rs +++ b/crates/rustyclaw-core/src/gateway/mod.rs @@ -69,6 +69,11 @@ pub use ssh::{SshConfig, SshTransport}; // Re-export stdio transport (always available) pub use ssh::StdioTransport; +#[cfg(feature = "ssh")] +use ssh::SshServer; +#[cfg(feature = "ssh")] +use std::net::SocketAddr; + use crate::config::Config; use crate::observability::ObserverEvent; use crate::providers as crate_providers; @@ -355,7 +360,7 @@ pub async fn run_gateway( None }; // Wrap in shared type so it can be updated when models change - let shared_copilot_session: SharedCopilotSession = Arc::new(RwLock::new(copilot_session)); + let shared_copilot_session: SharedCopilotSession = Arc::new(RwLock::new(copilot_session.clone())); let model_ctx = model_ctx.map(Arc::new); @@ -426,16 +431,73 @@ pub async fn run_gateway( None }; + // ── Start SSH server if configured ────────────────────────────── + #[cfg(feature = "ssh")] + let mut ssh_server: Option = if let Some(ref ssh_listen) = options.ssh_listen { + let ssh_addr: SocketAddr = ssh_listen.parse() + .with_context(|| format!("Invalid SSH listen address: {}", ssh_listen))?; + + let ssh_config = SshConfig { + listen_addr: ssh_addr, + host_key_path: options.ssh_host_key.clone() + .unwrap_or_else(|| config.settings_dir.join("ssh_host_key")), + authorized_clients_path: options.ssh_authorized_clients.clone() + .unwrap_or_else(|| config.settings_dir.join("authorized_clients")), + allow_password: false, + require_pubkey: true, + }; + + match SshServer::new(ssh_config.clone()).await { + Ok(mut server) => { + if let Err(e) = server.listen(ssh_addr).await { + error!(error = %e, "Failed to start SSH server"); + None + } else { + info!(address = %ssh_addr, "SSH server listening"); + Some(server) + } + } + Err(e) => { + error!(error = %e, "Failed to initialize SSH server"); + None + } + } + } else { + None + }; + + #[cfg(not(feature = "ssh"))] + let _ssh_server: Option<()> = None; + let _ = &options.ssh_listen; // Suppress unused warning + info!(address = %addr, "Gateway listening"); if messenger_mgr.is_some() { info!("Messenger polling enabled"); } + #[cfg(feature = "ssh")] + if ssh_server.is_some() { + info!("SSH transport enabled"); + } loop { + // Create SSH accept future if server is available + #[cfg(feature = "ssh")] + let ssh_accept = async { + if let Some(ref mut server) = ssh_server { + server.accept().await + } else { + // Never resolves if no SSH server + std::future::pending().await + } + }; + #[cfg(not(feature = "ssh"))] + let ssh_accept = std::future::pending::>>(); + tokio::select! { _ = cancel.cancelled() => { break; } + // WebSocket connections (existing path) accepted = listener.accept() => { let (stream, peer) = accepted?; let shared_cfg = shared_config.clone(); @@ -483,12 +545,235 @@ pub async fn run_gateway( } }); } + // SSH connections (new path) + ssh_result = ssh_accept => { + match ssh_result { + Ok(transport) => { + let peer_info = transport.peer_info().clone(); + info!( + transport = %peer_info.transport_type, + user = ?peer_info.username, + fingerprint = ?peer_info.key_fingerprint, + "SSH connection accepted" + ); + + let shared_cfg = shared_config.clone(); + let shared_ctx = shared_model_ctx.clone(); + let session_clone = copilot_session.clone(); + let vault_clone = vault.clone(); + let skill_clone = skill_mgr.clone(); + let task_mgr_clone = task_mgr.clone(); + let observer_clone = observer.clone(); + let child_cancel = cancel.child_token(); + + tokio::spawn(async move { + if let Err(err) = handle_transport_connection( + transport, + shared_cfg, + shared_ctx, + session_clone, + vault_clone, + skill_clone, + task_mgr_clone, + observer_clone, + child_cancel, + ).await { + debug!(error = %err, "SSH connection error"); + } + }); + } + Err(e) => { + warn!(error = %e, "SSH accept error"); + } + } + } } } Ok(()) } +/// Handle a connection using the Transport trait. +/// +/// This is the transport-agnostic connection handler that works with any +/// transport implementation (SSH, stdio, future transports). For SSH +/// connections, authentication is already completed at the transport layer +/// via public key, so we skip TOTP. +async fn handle_transport_connection( + transport: Box, + shared_config: SharedConfig, + shared_model_ctx: SharedModelCtx, + copilot_session: Option>, + vault: SharedVault, + _skill_mgr: SharedSkillManager, + _task_mgr: SharedTaskManager, + _observer: Option, + cancel: CancellationToken, +) -> Result<()> { + let peer_info = transport.peer_info().clone(); + let (mut reader, mut writer) = transport.into_split(); + + // Snapshot config and model context for this connection + let config = shared_config.read().await.clone(); + let model_ctx = shared_model_ctx.read().await.clone(); + + // Thread manager for multi-task conversations + let threads_path = config.sessions_dir().join("threads.json"); + let thread_mgr = crate::threads::ThreadManager::load_or_default(&threads_path); + let _thread_events_rx = thread_mgr.subscribe(); + + // SSH connections are pre-authenticated via public key + // No TOTP challenge needed + info!( + transport = %peer_info.transport_type, + user = ?peer_info.username, + "Transport connection authenticated" + ); + + // Check vault status + let vault_is_locked = { + let v = vault.lock().await; + v.is_locked() + }; + + // Send hello frame + let hello_frame = ServerFrame { + frame_type: ServerFrameType::Hello, + payload: ServerPayload::Hello { + agent: "rustyclaw".to_string(), + settings_dir: config.settings_dir.to_string_lossy().to_string(), + vault_locked: vault_is_locked, + provider: model_ctx.as_ref().map(|c| c.provider.clone()), + model: model_ctx.as_ref().map(|c| c.model.clone()), + }, + }; + writer.send(&hello_frame).await?; + + if vault_is_locked { + let status_frame = ServerFrame { + frame_type: ServerFrameType::Status, + payload: ServerPayload::Status { + status: StatusType::VaultLocked, + detail: "Secrets vault is locked — provide password to unlock".to_string(), + }, + }; + writer.send(&status_frame).await?; + } + + // Report model status + let http = reqwest::Client::new(); + if let Some(ref ctx) = model_ctx { + let display = crate_providers::display_name_for_provider(&ctx.provider); + + // Model configured status + let detail = format!("{} / {}", display, ctx.model); + writer.send(&ServerFrame { + frame_type: ServerFrameType::Status, + payload: ServerPayload::Status { + status: StatusType::ModelConfigured, + detail, + }, + }).await?; + + // Credentials status + if ctx.api_key.is_some() { + writer.send(&ServerFrame { + frame_type: ServerFrameType::Status, + payload: ServerPayload::Status { + status: StatusType::CredentialsLoaded, + detail: format!("{} API key loaded", display), + }, + }).await?; + } + + // Probe connection + let probe_ctx = ctx.clone(); + writer.send(&ServerFrame { + frame_type: ServerFrameType::Status, + payload: ServerPayload::Status { + status: StatusType::ModelConnecting, + detail: format!("Probing {} …", ctx.base_url), + }, + }).await?; + + match providers::validate_model_connection(&http, &probe_ctx, copilot_session.as_deref()).await { + ProbeResult::Ready | ProbeResult::Connected { .. } => { + writer.send(&ServerFrame { + frame_type: ServerFrameType::Status, + payload: ServerPayload::Status { + status: StatusType::ModelReady, + detail: format!("{} / {} ready", display, ctx.model), + }, + }).await?; + } + ProbeResult::AuthError { detail } => { + writer.send(&ServerFrame { + frame_type: ServerFrameType::Status, + payload: ServerPayload::Status { + status: StatusType::ModelError, + detail: format!("{} auth failed: {}", display, detail), + }, + }).await?; + } + ProbeResult::Unreachable { detail } => { + writer.send(&ServerFrame { + frame_type: ServerFrameType::Status, + payload: ServerPayload::Status { + status: StatusType::ModelError, + detail: format!("{} probe failed: {}", display, detail), + }, + }).await?; + } + } + } else { + writer.send(&ServerFrame { + frame_type: ServerFrameType::Status, + payload: ServerPayload::Status { + status: StatusType::NoModel, + detail: "No model configured — clients must send full credentials".to_string(), + }, + }).await?; + } + + // TODO: Main message loop + // For now, just log that we connected and return + // The full message loop will be ported in a follow-up PR + info!(transport = %peer_info.transport_type, "Transport connection ready"); + + // Keep connection open until cancelled or client disconnects + loop { + tokio::select! { + _ = cancel.cancelled() => { + info!("Transport connection cancelled"); + break; + } + frame = reader.recv() => { + match frame { + Ok(Some(client_frame)) => { + // For now, just log received frames + // TODO: Route to message handlers (chat, control, etc.) + debug!(?client_frame, "Received frame from transport"); + + // TODO: Handle incoming frames properly + // For now, just log receipt - no acknowledgment needed + } + Ok(None) => { + info!("Transport connection closed by client"); + break; + } + Err(e) => { + warn!(error = %e, "Transport receive error"); + break; + } + } + } + } + } + + writer.close().await?; + Ok(()) +} + /// Send a threads update frame to the client. /// /// This includes: diff --git a/crates/rustyclaw-core/src/gateway/ssh.rs b/crates/rustyclaw-core/src/gateway/ssh.rs index 2025723..de7b96f 100644 --- a/crates/rustyclaw-core/src/gateway/ssh.rs +++ b/crates/rustyclaw-core/src/gateway/ssh.rs @@ -98,6 +98,8 @@ const MAX_FRAME_SIZE: u32 = 16 * 1024 * 1024; /// Configuration for the SSH transport. #[derive(Debug, Clone)] pub struct SshConfig { + /// Address to listen on (e.g., "0.0.0.0:2222"). + pub listen_addr: std::net::SocketAddr, /// Path to the server's host key. pub host_key_path: PathBuf, /// Path to the authorized_clients file. @@ -115,6 +117,7 @@ impl Default for SshConfig { .unwrap_or_else(|| PathBuf::from(".")); Self { + listen_addr: "0.0.0.0:2222".parse().unwrap(), host_key_path: config_dir.join("ssh_host_key"), authorized_clients_path: config_dir.join("authorized_clients"), allow_password: false, diff --git a/crates/rustyclaw-core/src/lib.rs b/crates/rustyclaw-core/src/lib.rs index 03b5dc5..9a9fb1d 100644 --- a/crates/rustyclaw-core/src/lib.rs +++ b/crates/rustyclaw-core/src/lib.rs @@ -24,6 +24,7 @@ pub mod messengers; pub mod mnemo; pub mod models; pub mod observability; +pub mod pairing; pub mod process_manager; pub mod providers; pub mod protocols; diff --git a/crates/rustyclaw-core/src/pairing/authorized.rs b/crates/rustyclaw-core/src/pairing/authorized.rs new file mode 100644 index 0000000..3c79a26 --- /dev/null +++ b/crates/rustyclaw-core/src/pairing/authorized.rs @@ -0,0 +1,328 @@ +//! Authorized clients management (gateway side). + +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; +use tracing::{info, warn}; + +/// An authorized client entry. +#[derive(Debug, Clone)] +pub struct AuthorizedClient { + /// The public key in OpenSSH format. + pub public_key_openssh: String, + + /// The key fingerprint (SHA256). + pub fingerprint: String, + + /// Optional comment (usually client-name@host). + pub comment: Option, + + /// When the key was authorized (Unix timestamp). + pub authorized_at: Option, +} + +impl AuthorizedClient { + /// Parse from an OpenSSH authorized_keys line. + pub fn from_line(line: &str) -> Option { + let line = line.trim(); + + // Skip empty lines and comments + if line.is_empty() || line.starts_with('#') { + return None; + } + + // Parse: "ssh-ed25519 AAAA... comment" + let parts: Vec<&str> = line.splitn(3, ' ').collect(); + if parts.len() < 2 { + return None; + } + + let key_type = parts[0]; + let key_data = parts[1]; + let comment = parts.get(2).map(|s| s.to_string()); + + // Reconstruct the key for fingerprinting + let public_key_openssh = if let Some(ref c) = comment { + format!("{} {} {}", key_type, key_data, c) + } else { + format!("{} {}", key_type, key_data) + }; + + // Calculate fingerprint + let fingerprint = calculate_fingerprint(&public_key_openssh); + + Some(AuthorizedClient { + public_key_openssh, + fingerprint, + comment, + authorized_at: None, + }) + } + + /// Format as an authorized_keys line. + pub fn to_line(&self) -> String { + self.public_key_openssh.clone() + } +} + +/// Collection of authorized clients. +#[derive(Debug, Clone, Default)] +pub struct AuthorizedClients { + /// List of authorized clients. + pub clients: Vec, + + /// Path to the authorized_clients file. + pub path: PathBuf, +} + +impl AuthorizedClients { + /// Create an empty authorized clients list. + pub fn new(path: PathBuf) -> Self { + Self { + clients: Vec::new(), + path, + } + } + + /// Check if a public key is authorized. + pub fn is_authorized(&self, public_key_openssh: &str) -> bool { + // Normalize the key for comparison (strip comment, whitespace) + let normalized = normalize_key(public_key_openssh); + + self.clients.iter().any(|c| { + normalize_key(&c.public_key_openssh) == normalized + }) + } + + /// Find a client by fingerprint. + pub fn find_by_fingerprint(&self, fingerprint: &str) -> Option<&AuthorizedClient> { + self.clients.iter().find(|c| c.fingerprint == fingerprint) + } + + /// Add a new client. + pub fn add(&mut self, client: AuthorizedClient) { + // Don't add duplicates + if !self.is_authorized(&client.public_key_openssh) { + self.clients.push(client); + } + } + + /// Remove a client by fingerprint. + pub fn remove_by_fingerprint(&mut self, fingerprint: &str) -> bool { + let original_len = self.clients.len(); + self.clients.retain(|c| c.fingerprint != fingerprint); + self.clients.len() < original_len + } +} + +/// Default path for the authorized_clients file. +pub fn default_authorized_clients_path() -> PathBuf { + super::rustyclaw_dir().join("authorized_clients") +} + +/// Load authorized clients from a file. +pub fn load_authorized_clients(path: &Path) -> Result { + let mut clients = AuthorizedClients::new(path.to_path_buf()); + + if !path.exists() { + // Return empty list if file doesn't exist + return Ok(clients); + } + + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read authorized_clients: {}", path.display()))?; + + for (line_num, line) in content.lines().enumerate() { + match AuthorizedClient::from_line(line) { + Some(client) => clients.clients.push(client), + None if line.trim().is_empty() || line.trim().starts_with('#') => { + // Ignore empty lines and comments + } + None => { + warn!( + line = line_num + 1, + content = line, + "Failed to parse authorized_clients line" + ); + } + } + } + + info!( + path = %path.display(), + count = clients.clients.len(), + "Loaded authorized clients" + ); + + Ok(clients) +} + +/// Add a client to the authorized_clients file. +pub fn add_authorized_client( + path: &Path, + public_key_openssh: &str, + comment: Option<&str>, +) -> Result { + use std::io::Write; + + // Build the key line + let key_line = if let Some(c) = comment { + // If key already has a comment, replace it + let parts: Vec<&str> = public_key_openssh.splitn(3, ' ').collect(); + if parts.len() >= 2 { + format!("{} {} {}", parts[0], parts[1], c) + } else { + format!("{} {}", public_key_openssh.trim(), c) + } + } else { + public_key_openssh.trim().to_string() + }; + + // Parse into AuthorizedClient + let client = AuthorizedClient::from_line(&key_line) + .context("Invalid public key format")?; + + // Check if already authorized + let existing = load_authorized_clients(path)?; + if existing.is_authorized(&client.public_key_openssh) { + anyhow::bail!("Key is already authorized"); + } + + // Ensure parent directory exists + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + // Append to file + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .with_context(|| format!("Failed to open authorized_clients: {}", path.display()))?; + + // Add header comment if file is new/empty + let metadata = file.metadata()?; + if metadata.len() == 0 { + writeln!(file, "# RustyClaw authorized clients")?; + writeln!(file, "# Format: ssh-ed25519 ")?; + writeln!(file)?; + } + + writeln!(file, "{}", key_line)?; + + info!( + path = %path.display(), + fingerprint = %client.fingerprint, + comment = ?client.comment, + "Added authorized client" + ); + + Ok(client) +} + +/// Remove a client from the authorized_clients file by fingerprint. +pub fn remove_authorized_client(path: &Path, fingerprint: &str) -> Result { + let mut clients = load_authorized_clients(path)?; + + if !clients.remove_by_fingerprint(fingerprint) { + return Ok(false); + } + + // Rewrite the file + let mut content = String::from("# RustyClaw authorized clients\n"); + content.push_str("# Format: ssh-ed25519 \n\n"); + + for client in &clients.clients { + content.push_str(&client.to_line()); + content.push('\n'); + } + + std::fs::write(path, content) + .with_context(|| format!("Failed to write authorized_clients: {}", path.display()))?; + + info!( + path = %path.display(), + fingerprint = fingerprint, + "Removed authorized client" + ); + + Ok(true) +} + +/// Normalize a public key for comparison. +/// +/// Strips comments and extra whitespace, keeping only "type base64key". +fn normalize_key(key: &str) -> String { + let parts: Vec<&str> = key.split_whitespace().collect(); + if parts.len() >= 2 { + format!("{} {}", parts[0], parts[1]) + } else { + key.trim().to_string() + } +} + +/// Calculate the SHA256 fingerprint of a public key. +#[cfg(feature = "ssh")] +fn calculate_fingerprint(public_key_openssh: &str) -> String { + use base64::Engine; + use sha2::{Sha256, Digest}; + + // Parse the key to get the base64 data + let parts: Vec<&str> = public_key_openssh.split_whitespace().collect(); + if parts.len() < 2 { + return "SHA256:invalid".to_string(); + } + + // Decode the base64 key data + let key_data = match base64::engine::general_purpose::STANDARD.decode(parts[1]) { + Ok(data) => data, + Err(_) => return "SHA256:invalid".to_string(), + }; + + // Calculate SHA256 hash + let mut hasher = Sha256::new(); + hasher.update(&key_data); + let hash = hasher.finalize(); + + // Encode as base64 (without padding, to match ssh-keygen format) + let fingerprint = base64::engine::general_purpose::STANDARD_NO_PAD + .encode(&hash); + + format!("SHA256:{}", fingerprint) +} + +/// Calculate fingerprint stub when ssh feature is disabled. +#[cfg(not(feature = "ssh"))] +fn calculate_fingerprint(_public_key_openssh: &str) -> String { + "SHA256:unavailable".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_authorized_line() { + let line = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKtJvJZDLNbPkTYf4ZbXaBeCq3I9sEG9qS9XvGBFMT4C test@localhost"; + let client = AuthorizedClient::from_line(line).expect("Should parse"); + + assert!(client.public_key_openssh.starts_with("ssh-ed25519")); + assert_eq!(client.comment, Some("test@localhost".to_string())); + assert!(client.fingerprint.starts_with("SHA256:")); + } + + #[test] + fn test_normalize_key() { + let key1 = "ssh-ed25519 AAAA... user@host"; + let key2 = "ssh-ed25519 AAAA... different@comment"; + + assert_eq!(normalize_key(key1), "ssh-ed25519 AAAA..."); + assert_eq!(normalize_key(key2), "ssh-ed25519 AAAA..."); + } + + #[test] + fn test_skip_comments() { + assert!(AuthorizedClient::from_line("# This is a comment").is_none()); + assert!(AuthorizedClient::from_line("").is_none()); + assert!(AuthorizedClient::from_line(" ").is_none()); + } +} diff --git a/crates/rustyclaw-core/src/pairing/client_keys.rs b/crates/rustyclaw-core/src/pairing/client_keys.rs new file mode 100644 index 0000000..b6cc239 --- /dev/null +++ b/crates/rustyclaw-core/src/pairing/client_keys.rs @@ -0,0 +1,217 @@ +//! Client keypair generation and management. + +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; + +/// An Ed25519 keypair for client authentication. +#[derive(Clone)] +pub struct ClientKeyPair { + /// The private key (kept secret). + #[cfg(feature = "ssh")] + pub private_key: russh::keys::PrivateKey, + #[cfg(not(feature = "ssh"))] + pub private_key_pem: String, + + /// The public key (shared with gateway). + #[cfg(feature = "ssh")] + pub public_key: russh::keys::PublicKey, + #[cfg(not(feature = "ssh"))] + pub public_key_openssh: String, + + /// Optional comment (e.g., "user@hostname"). + pub comment: Option, +} + +impl ClientKeyPair { + /// Load or generate a client keypair at the default location. + /// + /// If `comment` is None, generates "rustyclaw@client". + pub fn load_or_generate(comment: Option) -> Result { + let path = default_client_key_path(); + let comment = comment.or_else(|| Some("rustyclaw@client".to_string())); + load_or_generate_client_keypair(&path, comment) + } + + /// Load the private key for SSH authentication. + #[cfg(feature = "ssh")] + pub fn load_private_key(&self) -> Result { + let path = default_client_key_path(); + let key_data = std::fs::read_to_string(&path) + .context("Failed to read private key")?; + russh_keys::decode_secret_key(&key_data, None) + .context("Failed to decode private key") + } + + /// Get the public key in OpenSSH format (for display/copy). + pub fn public_key_openssh(&self) -> String { + #[cfg(feature = "ssh")] + { + let key_str = self.public_key + .to_openssh() + .unwrap_or_else(|_| format!("{:?}", self.public_key)); + if let Some(ref comment) = self.comment { + format!("{} {}", key_str.trim(), comment) + } else { + key_str.trim().to_string() + } + } + #[cfg(not(feature = "ssh"))] + { + if let Some(ref comment) = self.comment { + format!("{} {}", self.public_key_openssh.trim(), comment) + } else { + self.public_key_openssh.clone() + } + } + } + + /// Get the key fingerprint (SHA256). + pub fn fingerprint(&self) -> String { + super::key_fingerprint(self) + } + + /// Get a short fingerprint (last 8 characters). + pub fn fingerprint_short(&self) -> String { + super::key_fingerprint_short(self) + } +} + +/// Default path for the client private key. +pub fn default_client_key_path() -> PathBuf { + super::rustyclaw_dir().join("client_ed25519_key") +} + +/// Generate a new Ed25519 keypair for client authentication. +/// +/// The `comment` is typically "user@hostname" and will be appended to +/// the public key when displayed. +#[cfg(feature = "ssh")] +pub fn generate_client_keypair(comment: Option) -> Result { + use russh::keys::{Algorithm, PrivateKey}; + + // Generate Ed25519 key + let private_key = PrivateKey::random(&mut rand_core::OsRng, Algorithm::Ed25519) + .context("Failed to generate Ed25519 keypair")?; + + let public_key = private_key.public_key().clone(); + + Ok(ClientKeyPair { + private_key, + public_key, + comment, + }) +} + +#[cfg(not(feature = "ssh"))] +pub fn generate_client_keypair(_comment: Option) -> Result { + anyhow::bail!("SSH feature not enabled; cannot generate keypair") +} + +/// Load an existing client keypair from disk. +#[cfg(feature = "ssh")] +pub fn load_client_keypair(path: &Path) -> Result { + let key_data = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read key file: {}", path.display()))?; + + let private_key = russh::keys::PrivateKey::from_openssh(&key_data) + .with_context(|| format!("Failed to parse key: {}", path.display()))?; + + let public_key = private_key.public_key().clone(); + let comment = { + let c = public_key.comment(); + if c.is_empty() { None } else { Some(c.to_string()) } + }; + + Ok(ClientKeyPair { + private_key, + public_key, + comment, + }) +} + +#[cfg(not(feature = "ssh"))] +pub fn load_client_keypair(path: &Path) -> Result { + let _key_data = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read key file: {}", path.display()))?; + + // Parse just enough to extract the public key line + // This is a simplified fallback when SSH feature is disabled + anyhow::bail!("SSH feature not enabled; cannot load keypair from {}", path.display()) +} + +/// Save a client keypair to disk. +/// +/// The private key is saved with restrictive permissions (600 on Unix). +#[cfg(feature = "ssh")] +pub fn save_client_keypair(keypair: &ClientKeyPair, path: &Path) -> Result<()> { + use russh::keys::ssh_key::LineEnding; + + // Ensure parent directory exists + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory: {}", parent.display()))?; + } + + // Encode private key in OpenSSH format + let key_data = keypair.private_key + .to_openssh(LineEnding::LF) + .context("Failed to encode private key")?; + + // Write the key + std::fs::write(path, key_data.as_bytes()) + .with_context(|| format!("Failed to write key: {}", path.display()))?; + + // Set restrictive permissions on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .with_context(|| format!("Failed to set permissions: {}", path.display()))?; + } + + Ok(()) +} + +#[cfg(not(feature = "ssh"))] +pub fn save_client_keypair(_keypair: &ClientKeyPair, _path: &Path) -> Result<()> { + anyhow::bail!("SSH feature not enabled; cannot save keypair") +} + +/// Load or generate a client keypair. +/// +/// If the keypair exists at `path`, loads it. Otherwise, generates a new +/// keypair and saves it. +pub fn load_or_generate_client_keypair( + path: &Path, + comment: Option, +) -> Result { + if path.exists() { + load_client_keypair(path) + } else { + let keypair = generate_client_keypair(comment)?; + save_client_keypair(&keypair, path)?; + Ok(keypair) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_client_key_path() { + let path = default_client_key_path(); + assert!(path.to_string_lossy().contains("client_ed25519_key")); + } + + #[test] + #[cfg(feature = "ssh")] + fn test_generate_keypair() { + let keypair = generate_client_keypair(Some("test@localhost".to_string())) + .expect("Should generate keypair"); + + let openssh = keypair.public_key_openssh(); + assert!(openssh.starts_with("ssh-ed25519 ")); + assert!(openssh.contains("test@localhost")); + } +} diff --git a/crates/rustyclaw-core/src/pairing/fingerprint.rs b/crates/rustyclaw-core/src/pairing/fingerprint.rs new file mode 100644 index 0000000..4d3e6dc --- /dev/null +++ b/crates/rustyclaw-core/src/pairing/fingerprint.rs @@ -0,0 +1,181 @@ +//! Key fingerprint utilities. + +use super::client_keys::ClientKeyPair; + +/// Calculate the SHA256 fingerprint of a public key. +/// +/// Returns a string like "SHA256:AbCdEf...". +#[cfg(feature = "ssh")] +pub fn key_fingerprint(keypair: &ClientKeyPair) -> String { + keypair.public_key + .fingerprint(russh::keys::HashAlg::Sha256) + .to_string() +} + +#[cfg(not(feature = "ssh"))] +pub fn key_fingerprint(_keypair: &ClientKeyPair) -> String { + // Fallback: calculate from OpenSSH string + calculate_fingerprint_from_openssh(&_keypair.public_key_openssh) +} + +/// Get a short fingerprint (last 8 characters of the hash). +/// +/// Useful for display in limited space. +pub fn key_fingerprint_short(keypair: &ClientKeyPair) -> String { + let fp = key_fingerprint(keypair); + // Skip "SHA256:" prefix and take last 8 chars + if fp.starts_with("SHA256:") && fp.len() > 15 { + fp[fp.len() - 8..].to_string() + } else { + fp + } +} + +/// Calculate fingerprint from an OpenSSH public key string. +#[cfg(feature = "ssh")] +pub fn calculate_fingerprint_from_openssh(public_key_openssh: &str) -> String { + use base64::Engine; + use sha2::{Sha256, Digest}; + + // Parse the key to get the base64 data + let parts: Vec<&str> = public_key_openssh.split_whitespace().collect(); + if parts.len() < 2 { + return "SHA256:invalid".to_string(); + } + + // Decode the base64 key data + let key_data = match base64::engine::general_purpose::STANDARD.decode(parts[1]) { + Ok(data) => data, + Err(_) => return "SHA256:invalid".to_string(), + }; + + // Calculate SHA256 hash + let mut hasher = Sha256::new(); + hasher.update(&key_data); + let hash = hasher.finalize(); + + // Encode as base64 (without padding, to match ssh-keygen format) + let fingerprint = base64::engine::general_purpose::STANDARD_NO_PAD + .encode(&hash); + + format!("SHA256:{}", fingerprint) +} + +/// Calculate fingerprint from an OpenSSH public key string. +/// Stub implementation when ssh feature is disabled. +#[cfg(not(feature = "ssh"))] +pub fn calculate_fingerprint_from_openssh(_public_key_openssh: &str) -> String { + "SHA256:unavailable".to_string() +} + +/// Generate ASCII art representation of a key fingerprint. +/// +/// Similar to `ssh-keygen -lv`, this creates a visual hash that makes +/// it easier to verify keys by eye. +#[cfg(feature = "ssh")] +pub fn format_fingerprint_art(fingerprint: &str) -> String { + use base64::Engine; + + // Extract the hash part (after "SHA256:") + let hash = fingerprint.strip_prefix("SHA256:") + .unwrap_or(fingerprint); + + // Decode the base64 to get raw bytes + let bytes = match base64::engine::general_purpose::STANDARD_NO_PAD.decode(hash) { + Ok(b) => b, + Err(_) => return format!("[{}]", fingerprint), + }; + + // Generate the randomart image (17x9 field) + // This is the "drunken bishop" algorithm + const FIELD_WIDTH: usize = 17; + const FIELD_HEIGHT: usize = 9; + let mut field = [[0u8; FIELD_WIDTH]; FIELD_HEIGHT]; + + // Bishop starts in the center + let mut x: i32 = (FIELD_WIDTH / 2) as i32; + let mut y: i32 = (FIELD_HEIGHT / 2) as i32; + + // Walk the field based on hash bits + for byte in &bytes { + for i in 0..4 { + let step = (byte >> (i * 2)) & 0x03; + match step { + 0 => { x -= 1; y -= 1; } // upper left + 1 => { x += 1; y -= 1; } // upper right + 2 => { x -= 1; y += 1; } // lower left + 3 => { x += 1; y += 1; } // lower right + _ => unreachable!(), + } + + // Clamp to field boundaries + x = x.clamp(0, (FIELD_WIDTH - 1) as i32); + y = y.clamp(0, (FIELD_HEIGHT - 1) as i32); + + // Increment the cell visit count + let cell = &mut field[y as usize][x as usize]; + if *cell < 14 { + *cell += 1; + } + } + } + + // Mark start and end positions + field[FIELD_HEIGHT / 2][FIELD_WIDTH / 2] = 15; // 'S' for start + field[y as usize][x as usize] = 16; // 'E' for end + + // Convert to characters + const CHARS: &[char] = &[ + ' ', '.', 'o', '+', '=', '*', 'B', 'O', 'X', '@', '%', '&', '#', '/', '^', 'S', 'E' + ]; + + let mut output = String::new(); + output.push_str("+---[ED25519 256]----+\n"); + + for row in &field { + output.push('|'); + for &cell in row { + let c = CHARS.get(cell as usize).copied().unwrap_or('?'); + output.push(c); + } + output.push_str("|\n"); + } + + output.push_str("+----[SHA256]--------+"); + + output +} + +/// Stub implementation when ssh feature is disabled. +#[cfg(not(feature = "ssh"))] +pub fn format_fingerprint_art(_fingerprint: &str) -> String { + "+---[ED25519 256]----+\n\ + | (ssh disabled) |\n\ + +----[SHA256]--------+".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fingerprint_from_openssh() { + // This is a test key (not real) + let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKtJvJZDLNbPkTYf4ZbXaBeCq3I9sEG9qS9XvGBFMT4C test"; + let fp = calculate_fingerprint_from_openssh(key); + + assert!(fp.starts_with("SHA256:")); + assert!(fp.len() > 10); + } + + #[test] + fn test_fingerprint_art() { + let fingerprint = "SHA256:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789+/"; + let art = format_fingerprint_art(fingerprint); + + assert!(art.contains("+---[ED25519")); + assert!(art.contains("+----[SHA256]")); + #[cfg(feature = "ssh")] + assert!(art.lines().count() == 11); // 9 rows + 2 borders + } +} diff --git a/crates/rustyclaw-core/src/pairing/mod.rs b/crates/rustyclaw-core/src/pairing/mod.rs new file mode 100644 index 0000000..e4cb75a --- /dev/null +++ b/crates/rustyclaw-core/src/pairing/mod.rs @@ -0,0 +1,88 @@ +//! SSH pairing flow for RustyClaw clients and gateways. +//! +//! This module provides the infrastructure for pairing clients with gateways +//! using Ed25519 keypairs. The pairing flow works as follows: +//! +//! ## Client Side +//! +//! 1. Generate an Ed25519 keypair (stored in `~/.rustyclaw/client_ed25519_key`) +//! 2. Display the public key for copy/paste or as a QR code +//! 3. Connect to the gateway once the key has been authorized +//! +//! ## Gateway Side +//! +//! 1. Receive the client's public key (via QR scan, paste, or protocol) +//! 2. Display the key fingerprint for verification +//! 3. Add approved keys to `~/.rustyclaw/authorized_clients` +//! +//! ## File Formats +//! +//! ### Client Private Key (`~/.rustyclaw/client_ed25519_key`) +//! +//! Standard OpenSSH private key format (PEM-encoded). +//! +//! ### Authorized Clients (`~/.rustyclaw/authorized_clients`) +//! +//! Same format as OpenSSH's `authorized_keys`: +//! ```text +//! # RustyClaw authorized clients +//! ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... laptop@user +//! ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... phone@user +//! ``` + +mod client_keys; +mod authorized; +mod qr; +mod fingerprint; + +pub use client_keys::{ + ClientKeyPair, + generate_client_keypair, + load_client_keypair, + save_client_keypair, + default_client_key_path, +}; + +pub use authorized::{ + AuthorizedClient, + AuthorizedClients, + load_authorized_clients, + add_authorized_client, + remove_authorized_client, + default_authorized_clients_path, +}; + +pub use qr::{ + generate_pairing_qr, + generate_pairing_qr_ascii, + parse_pairing_qr, + PairingData, +}; + +pub use fingerprint::{ + key_fingerprint, + key_fingerprint_short, + format_fingerprint_art, +}; + +/// Default directory for RustyClaw configuration and keys. +pub fn rustyclaw_dir() -> std::path::PathBuf { + dirs::home_dir() + .map(|h| h.join(".rustyclaw")) + .unwrap_or_else(|| std::path::PathBuf::from(".rustyclaw")) +} + +/// Ensure the RustyClaw directory exists with proper permissions. +pub fn ensure_rustyclaw_dir() -> std::io::Result<()> { + let dir = rustyclaw_dir(); + std::fs::create_dir_all(&dir)?; + + // Set directory permissions to 700 on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?; + } + + Ok(()) +} diff --git a/crates/rustyclaw-core/src/pairing/qr.rs b/crates/rustyclaw-core/src/pairing/qr.rs new file mode 100644 index 0000000..ff9e7d4 --- /dev/null +++ b/crates/rustyclaw-core/src/pairing/qr.rs @@ -0,0 +1,221 @@ +//! QR code generation and parsing for pairing. +//! +//! The QR code contains JSON-encoded pairing data: +//! +//! ```json +//! { +//! "v": 1, +//! "type": "client" | "gateway", +//! "key": "ssh-ed25519 AAAA...", +//! "name": "laptop@user", +//! "host": "gateway.example.com:2222" // only for gateway type +//! } +//! ``` + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// Pairing data encoded in QR codes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PairingData { + /// Version number (currently 1). + #[serde(rename = "v")] + pub version: u8, + + /// Type of pairing data. + #[serde(rename = "type")] + pub pairing_type: PairingType, + + /// Public key in OpenSSH format. + pub key: String, + + /// Human-readable name (e.g., "laptop@user"). + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Gateway host:port (only for gateway pairing data). + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, +} + +/// Type of pairing data. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PairingType { + /// Client offering its public key to a gateway. + Client, + /// Gateway offering its host info and key to a client. + Gateway, +} + +impl PairingData { + /// Create client pairing data. + pub fn client(public_key: &str, name: Option) -> Self { + Self { + version: 1, + pairing_type: PairingType::Client, + key: public_key.to_string(), + name, + host: None, + } + } + + /// Create gateway pairing data. + pub fn gateway(public_key: &str, host: &str, name: Option) -> Self { + Self { + version: 1, + pairing_type: PairingType::Gateway, + key: public_key.to_string(), + name, + host: Some(host.to_string()), + } + } + + /// Encode to JSON. + pub fn to_json(&self) -> Result { + serde_json::to_string(self).context("Failed to serialize pairing data") + } + + /// Decode from JSON. + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json).context("Failed to parse pairing data") + } +} + +/// Generate a QR code image for pairing. +/// +/// Returns the QR code as a PNG image in bytes. +#[cfg(feature = "qr")] +pub fn generate_pairing_qr(data: &PairingData) -> Result> { + use qrcode::QrCode; + use image::{Luma, ImageEncoder, codecs::png::PngEncoder}; + + let json = data.to_json()?; + + let code = QrCode::new(json.as_bytes()) + .context("Failed to generate QR code")?; + + // Render to image + let image = code.render::>() + .min_dimensions(200, 200) + .max_dimensions(400, 400) + .build(); + + // Encode as PNG + let mut png_data = Vec::new(); + let encoder = PngEncoder::new(&mut png_data); + encoder.write_image( + image.as_raw(), + image.width(), + image.height(), + image::ExtendedColorType::L8, + ).context("Failed to encode QR code as PNG")?; + + Ok(png_data) +} + +/// Generate a QR code as ASCII art (for terminal display). +#[cfg(feature = "qr")] +pub fn generate_pairing_qr_ascii(data: &PairingData) -> Result { + use qrcode::QrCode; + + let json = data.to_json()?; + + let code = QrCode::new(json.as_bytes()) + .context("Failed to generate QR code")?; + + // Render to ASCII using Unicode block characters + let mut output = String::new(); + let modules = code.to_colors(); + let width = code.width(); + + // Use half-block characters for denser output + for y in (0..width).step_by(2) { + for x in 0..width { + let top = modules[y * width + x] == qrcode::Color::Dark; + let bottom = if y + 1 < width { + modules[(y + 1) * width + x] == qrcode::Color::Dark + } else { + false + }; + + let ch = match (top, bottom) { + (true, true) => '█', + (true, false) => '▀', + (false, true) => '▄', + (false, false) => ' ', + }; + output.push(ch); + } + output.push('\n'); + } + + Ok(output) +} + +#[cfg(not(feature = "qr"))] +pub fn generate_pairing_qr(_data: &PairingData) -> Result> { + anyhow::bail!("QR code feature not enabled") +} + +#[cfg(not(feature = "qr"))] +pub fn generate_pairing_qr_ascii(_data: &PairingData) -> Result { + anyhow::bail!("QR code feature not enabled") +} + +/// Parse pairing data from a QR code or JSON string. +pub fn parse_pairing_qr(input: &str) -> Result { + // Try to parse as JSON directly + if let Ok(data) = PairingData::from_json(input) { + return Ok(data); + } + + // Try to parse as an OpenSSH public key (raw key paste) + if input.starts_with("ssh-") { + return Ok(PairingData::client(input.trim(), None)); + } + + anyhow::bail!("Could not parse pairing data") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_pairing_data() { + let data = PairingData::client( + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5...", + Some("test@laptop".to_string()), + ); + + assert_eq!(data.version, 1); + assert_eq!(data.pairing_type, PairingType::Client); + assert!(data.host.is_none()); + + let json = data.to_json().unwrap(); + let parsed = PairingData::from_json(&json).unwrap(); + assert_eq!(parsed.key, data.key); + } + + #[test] + fn test_gateway_pairing_data() { + let data = PairingData::gateway( + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5...", + "gateway.example.com:2222", + Some("my-gateway".to_string()), + ); + + assert_eq!(data.pairing_type, PairingType::Gateway); + assert_eq!(data.host, Some("gateway.example.com:2222".to_string())); + } + + #[test] + fn test_parse_raw_key() { + let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host"; + let data = parse_pairing_qr(key).unwrap(); + + assert_eq!(data.pairing_type, PairingType::Client); + assert_eq!(data.key, key); + } +} diff --git a/crates/rustyclaw-tui/Cargo.toml b/crates/rustyclaw-tui/Cargo.toml index 130288d..8736e0d 100644 --- a/crates/rustyclaw-tui/Cargo.toml +++ b/crates/rustyclaw-tui/Cargo.toml @@ -45,3 +45,20 @@ dirs.workspace = true reqwest.workspace = true shellexpand.workspace = true nucleo-matcher.workspace = true + + +[features] +default = [] +ssh = ["rustyclaw-core/ssh", "dep:russh", "dep:russh-keys", "dep:async-trait"] + +[dependencies.russh] +version = "0.44" +optional = true + +[dependencies.russh-keys] +version = "0.44" +optional = true + +[dependencies.async-trait] +version = "0.1" +optional = true diff --git a/crates/rustyclaw-tui/src/app/app.rs b/crates/rustyclaw-tui/src/app/app.rs index 4df5e73..75b921b 100644 --- a/crates/rustyclaw-tui/src/app/app.rs +++ b/crates/rustyclaw-tui/src/app/app.rs @@ -30,6 +30,7 @@ use crate::gateway_client; /// Events pushed from the gateway reader into the iocraft render component. #[derive(Debug, Clone)] +#[allow(dead_code)] pub(crate) enum GwEvent { Disconnected(String), AuthChallenge, @@ -135,6 +136,12 @@ pub(crate) enum GwEvent { provider: String, provider_display: String, }, + /// SSH pairing connection succeeded + PairingSuccess { + gateway_name: String, + }, + /// SSH pairing connection failed + PairingError(String), } /// Messages from the iocraft render component back to tokio. @@ -209,6 +216,12 @@ pub(crate) enum UserInput { }, /// Cancel the current provider-flow dialog CancelProviderFlow, + /// Initiate SSH pairing connection + PairingConnect { + host: String, + port: u16, + public_key: String, + }, Quit, } @@ -1412,6 +1425,28 @@ impl App { Ok(UserInput::CancelProviderFlow) => { // User cancelled — nothing to do } + #[allow(unused_variables)] + Ok(UserInput::PairingConnect { host, port, public_key }) => { + // Initiate SSH connection for pairing + #[cfg(feature = "ssh")] + { + let gw_tx_pair = gw_tx.clone(); + tokio::spawn(async move { + match crate::pairing::connect_and_pair(&host, port, &public_key).await { + Ok(gateway_name) => { + let _ = gw_tx_pair.send(GwEvent::PairingSuccess { gateway_name }); + } + Err(e) => { + let _ = gw_tx_pair.send(GwEvent::PairingError(e.to_string())); + } + } + }); + } + #[cfg(not(feature = "ssh"))] + { + let _ = gw_tx.send(GwEvent::PairingError("SSH feature not enabled".to_string())); + } + } Ok(UserInput::Quit) => break, Err(sync_mpsc::TryRecvError::Empty) => {} Err(sync_mpsc::TryRecvError::Disconnected) => break, @@ -1732,6 +1767,20 @@ mod tui_component { let mut hatching_tick = hooks.use_state(|| 0usize); let mut hatching_pending = hooks.use_state(|| false); // True when waiting for hatching response + // ── Pairing dialog state ──────────────────────────────────────── + let mut show_pairing = hooks.use_state(|| false); + let mut pairing_step: State = + hooks.use_state(|| crate::components::pairing_dialog::PairingStep::ShowKey); + let mut pairing_field: State = + hooks.use_state(|| crate::components::pairing_dialog::PairingField::Host); + let mut pairing_public_key = hooks.use_state(|| String::new()); + let mut pairing_fingerprint = hooks.use_state(|| String::new()); + let mut pairing_fingerprint_art = hooks.use_state(|| String::new()); + let mut pairing_qr_ascii = hooks.use_state(|| String::new()); + let mut pairing_host = hooks.use_state(|| String::new()); + let mut pairing_port = hooks.use_state(|| "2222".to_string()); + let mut pairing_error = hooks.use_state(|| String::new()); + // ── User prompt dialog state ──────────────────────────────────── let mut show_user_prompt = hooks.use_state(|| false); let mut user_prompt_id = hooks.use_state(|| String::new()); @@ -2238,6 +2287,26 @@ mod tui_component { model_selector_loading.set(false); show_model_selector.set(true); } + GwEvent::PairingSuccess { gateway_name } => { + // Pairing succeeded — update dialog state + pairing_step.set(crate::components::pairing_dialog::PairingStep::Complete); + pairing_error.set(String::new()); + let mut m = messages.read().clone(); + m.push(DisplayMessage::success(format!( + "Successfully paired with gateway: {}", gateway_name + ))); + messages.set(m); + } + GwEvent::PairingError(err) => { + // Pairing failed — show error + pairing_step.set(crate::components::pairing_dialog::PairingStep::EnterGateway); + pairing_error.set(err.clone()); + let mut m = messages.read().clone(); + m.push(DisplayMessage::error(format!( + "Pairing failed: {}", err + ))); + messages.set(m); + } } } } @@ -2606,6 +2675,122 @@ mod tui_component { return; } + // ── Pairing dialog ────────────────────────────── + if show_pairing.get() { + use crate::components::pairing_dialog::{PairingStep, PairingField}; + let step = pairing_step.read().clone(); + match code { + KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { + should_quit.set(true); + if let Ok(guard) = tx_for_keys.lock() { + if let Some(ref tx) = *guard { + let _ = tx.send(UserInput::Quit); + } + } + } + KeyCode::Esc => { + match step { + PairingStep::ShowKey => { + // Cancel — close dialog + show_pairing.set(false); + } + PairingStep::EnterGateway => { + // Go back to ShowKey + pairing_step.set(PairingStep::ShowKey); + pairing_error.set(String::new()); + } + PairingStep::Connecting => { + // Cancel connection + pairing_step.set(PairingStep::EnterGateway); + } + PairingStep::Complete => { + show_pairing.set(false); + } + } + } + KeyCode::Enter => { + match step { + PairingStep::ShowKey => { + // Proceed to EnterGateway + pairing_step.set(PairingStep::EnterGateway); + } + PairingStep::EnterGateway => { + let host = pairing_host.read().clone(); + let port_str = pairing_port.read().clone(); + let public_key = pairing_public_key.read().clone(); + + if host.is_empty() { + pairing_error.set("Host is required".to_string()); + } else { + let port: u16 = port_str.parse().unwrap_or(2222); + pairing_step.set(PairingStep::Connecting); + + // Send connection request to async handler + if let Ok(guard) = tx_for_keys.lock() { + if let Some(ref tx) = *guard { + let _ = tx.send(UserInput::PairingConnect { + host, + port, + public_key, + }); + } + } + } + } + PairingStep::Connecting => { + // Wait for connection + } + PairingStep::Complete => { + show_pairing.set(false); + } + } + } + KeyCode::Tab if step == PairingStep::EnterGateway => { + // Toggle between host and port fields + let field = pairing_field.read().clone(); + pairing_field.set(match field { + PairingField::Host => PairingField::Port, + PairingField::Port => PairingField::Host, + }); + } + KeyCode::Char(c) if step == PairingStep::EnterGateway => { + let field = pairing_field.read().clone(); + match field { + PairingField::Host => { + let mut h = pairing_host.read().clone(); + h.push(c); + pairing_host.set(h); + } + PairingField::Port => { + if c.is_ascii_digit() { + let mut p = pairing_port.read().clone(); + p.push(c); + pairing_port.set(p); + } + } + } + pairing_error.set(String::new()); + } + KeyCode::Backspace if step == PairingStep::EnterGateway => { + let field = pairing_field.read().clone(); + match field { + PairingField::Host => { + let mut h = pairing_host.read().clone(); + h.pop(); + pairing_host.set(h); + } + PairingField::Port => { + let mut p = pairing_port.read().clone(); + p.pop(); + pairing_port.set(p); + } + } + } + _ => {} + } + return; + } + // ── Hatching dialog ───────────────────────────── if show_hatching.get() { match code { @@ -3155,6 +3340,50 @@ mod tui_component { KeyCode::Down => { scroll_offset.set((scroll_offset.get() - 1).max(0)); } + // Ctrl+Shift+P opens pairing dialog + KeyCode::Char('P') if modifiers.contains(KeyModifiers::CONTROL) => { + if !show_pairing.get() { + // Generate keypair and populate dialog + #[cfg(feature = "ssh")] + { + use rustyclaw_core::pairing::{ + ClientKeyPair, + key_fingerprint, + format_fingerprint_art, + generate_pairing_qr_ascii, + PairingData, + }; + match ClientKeyPair::load_or_generate(None) { + Ok(kp) => { + let pk = kp.public_key_openssh(); + pairing_public_key.set(pk.clone()); + let fp = key_fingerprint(&kp); + pairing_fingerprint_art.set(format_fingerprint_art(&fp)); + pairing_fingerprint.set(fp); + + // Generate QR code for pairing + let pairing_data = PairingData::client(&pk, None); + match generate_pairing_qr_ascii(&pairing_data) { + Ok(qr) => pairing_qr_ascii.set(qr), + Err(_) => pairing_qr_ascii.set(String::new()), + } + } + Err(e) => { + pairing_error.set(format!("Key generation failed: {}", e)); + } + } + } + #[cfg(not(feature = "ssh"))] + { + pairing_error.set("SSH feature not enabled".to_string()); + } + pairing_step.set(crate::components::pairing_dialog::PairingStep::ShowKey); + pairing_field.set(crate::components::pairing_dialog::PairingField::Host); + pairing_host.set(String::new()); + pairing_port.set("2222".to_string()); + show_pairing.set(true); + } + } _ => {} } } @@ -3210,6 +3439,7 @@ mod tui_component { && !show_api_key_dialog.get() && !show_device_flow.get() && !show_model_selector.get() + && !show_pairing.get() && !sidebar_focused.get(), on_change: move |new_val: String| { input_value.set(new_val.clone()); @@ -3300,6 +3530,16 @@ mod tui_component { model_selector_models: model_selector_models.read().clone(), model_selector_cursor: model_selector_cursor.get(), model_selector_loading: model_selector_loading.get(), + show_pairing: show_pairing.get(), + pairing_step: pairing_step.read().clone(), + pairing_field: pairing_field.read().clone(), + pairing_public_key: pairing_public_key.read().clone(), + pairing_fingerprint: pairing_fingerprint.read().clone(), + pairing_fingerprint_art: pairing_fingerprint_art.read().clone(), + pairing_qr_ascii: pairing_qr_ascii.read().clone(), + pairing_host: pairing_host.read().clone(), + pairing_port: pairing_port.read().clone(), + pairing_error: pairing_error.read().clone(), ) } } diff --git a/crates/rustyclaw-tui/src/components/mod.rs b/crates/rustyclaw-tui/src/components/mod.rs index c774bdc..e52503f 100644 --- a/crates/rustyclaw-tui/src/components/mod.rs +++ b/crates/rustyclaw-tui/src/components/mod.rs @@ -8,6 +8,7 @@ pub mod input_bar; pub mod message_bubble; pub mod messages; pub mod model_selector_dialog; +pub mod pairing_dialog; pub mod provider_selector_dialog; pub mod root; pub mod secrets_dialog; diff --git a/crates/rustyclaw-tui/src/components/pairing_dialog.rs b/crates/rustyclaw-tui/src/components/pairing_dialog.rs new file mode 100644 index 0000000..ded9a7d --- /dev/null +++ b/crates/rustyclaw-tui/src/components/pairing_dialog.rs @@ -0,0 +1,359 @@ +// ── Pairing dialog — SSH key pairing overlay ──────────────────────────────── +// +//! Multi-step wizard for SSH gateway pairing: +//! 1. ShowKey — Display public key and fingerprint +//! 2. EnterGateway — Input gateway host:port +//! 3. Connecting — Connection in progress +//! 4. Complete — Pairing successful + +use crate::theme; +use iocraft::prelude::*; + +/// Steps in the pairing flow. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PairingStep { + #[default] + ShowKey, + EnterGateway, + Connecting, + Complete, +} + +/// Input fields in the gateway entry step. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PairingField { + #[default] + Host, + Port, +} + +#[derive(Default, Props)] +pub struct PairingDialogProps { + /// Current step in the pairing flow. + pub step: PairingStep, + /// The client's public key in OpenSSH format. + pub public_key: String, + /// The key fingerprint (SHA256:...). + pub fingerprint: String, + /// ASCII art fingerprint visualization. + pub fingerprint_art: String, + /// QR code ASCII art (optional). + pub qr_ascii: String, + /// Gateway host input. + pub gateway_host: String, + /// Gateway port input. + pub gateway_port: String, + /// Which input field is active. + pub active_field: PairingField, + /// Error message to display. + pub error: String, + /// Success message. + pub success: String, +} + +#[component] +pub fn PairingDialog(props: &PairingDialogProps) -> impl Into> { + let title = match props.step { + PairingStep::ShowKey => "🔐 Pair with Gateway — Step 1/2", + PairingStep::EnterGateway => "🔐 Pair with Gateway — Step 2/2", + PairingStep::Connecting => "🔐 Connecting...", + PairingStep::Complete => "✅ Pairing Complete", + }; + + element! { + // Full-screen overlay + View( + width: 100pct, + height: 100pct, + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + ) { + // Dialog box + View( + width: 72, + flex_direction: FlexDirection::Column, + border_style: BorderStyle::Round, + border_color: theme::ACCENT_BRIGHT, + background_color: theme::BG_SURFACE, + padding_left: 2, + padding_right: 2, + padding_top: 1, + padding_bottom: 1, + ) { + // Title + Text( + content: title, + color: theme::ACCENT_BRIGHT, + weight: Weight::Bold, + ) + + View(height: 1) + + // Step-specific content + #(match props.step { + PairingStep::ShowKey => { + let el: AnyElement<'static> = render_show_key(props).into(); + el + }, + PairingStep::EnterGateway => { + let el: AnyElement<'static> = render_enter_gateway(props).into(); + el + }, + PairingStep::Connecting => { + let el: AnyElement<'static> = render_connecting(props).into(); + el + }, + PairingStep::Complete => { + let el: AnyElement<'static> = render_complete(props).into(); + el + }, + }) + } + } + } +} + +fn render_show_key(props: &PairingDialogProps) -> impl Into> { + let has_qr = !props.qr_ascii.is_empty(); + let visual = if has_qr { + props.qr_ascii.clone() + } else { + props.fingerprint_art.clone() + }; + let visual_title = if has_qr { "QR Code" } else { "Key Art" }; + + element! { + View(flex_direction: FlexDirection::Column) { + // Instructions + Text( + content: "Copy your public key and add it to the gateway's", + color: theme::TEXT, + ) + Text( + content: "~/.rustyclaw/authorized_clients", + color: theme::ACCENT_BRIGHT, + weight: Weight::Bold, + ) + + View(height: 1) + + // Public key box + View( + border_style: BorderStyle::Single, + border_color: theme::MUTED, + padding_left: 1, + padding_right: 1, + ) { + Text( + content: format!(" Public Key "), + color: theme::MUTED, + ) + } + View( + padding_left: 1, + padding_right: 1, + ) { + // Truncate key for display if too long + Text( + content: truncate_key(&props.public_key, 66), + color: theme::TEXT, + ) + } + + View(height: 1) + + // Fingerprint + View(flex_direction: FlexDirection::Row) { + Text(content: "Fingerprint: ", color: theme::MUTED) + Text(content: props.fingerprint.clone(), color: theme::ACCENT) + } + + View(height: 1) + + // Visual (fingerprint art or QR) + View( + border_style: BorderStyle::Single, + border_color: theme::MUTED, + padding_left: 1, + padding_right: 1, + ) { + Text( + content: format!(" {} ", visual_title), + color: theme::MUTED, + ) + } + View( + padding_left: 1, + align_items: AlignItems::Center, + ) { + Text(content: visual, color: theme::TEXT) + } + + View(height: 1) + + // Help text + View(flex_direction: FlexDirection::Row, justify_content: JustifyContent::Center) { + Text(content: "[Enter]", color: theme::ACCENT) + Text(content: " Next ", color: theme::MUTED) + Text(content: "[Esc]", color: theme::ACCENT) + Text(content: " Cancel", color: theme::MUTED) + } + } + } +} + +fn render_enter_gateway(props: &PairingDialogProps) -> impl Into> { + let host_focused = props.active_field == PairingField::Host; + let port_focused = props.active_field == PairingField::Port; + let has_error = !props.error.is_empty(); + + element! { + View(flex_direction: FlexDirection::Column) { + // Instructions + Text( + content: "Enter the gateway's SSH address:", + color: theme::TEXT, + ) + + View(height: 1) + + // Host input + View( + border_style: BorderStyle::Single, + border_color: if host_focused { theme::ACCENT_BRIGHT } else { theme::MUTED }, + padding_left: 1, + padding_right: 1, + ) { + Text( + content: " Host ", + color: if host_focused { theme::ACCENT_BRIGHT } else { theme::MUTED }, + ) + } + View(padding_left: 2) { + Text( + content: if props.gateway_host.is_empty() { + "example.com".to_string() + } else { + props.gateway_host.clone() + }, + color: if props.gateway_host.is_empty() { theme::MUTED } else { theme::TEXT }, + ) + } + + View(height: 1) + + // Port input + View( + border_style: BorderStyle::Single, + border_color: if port_focused { theme::ACCENT_BRIGHT } else { theme::MUTED }, + padding_left: 1, + padding_right: 1, + ) { + Text( + content: " Port ", + color: if port_focused { theme::ACCENT_BRIGHT } else { theme::MUTED }, + ) + } + View(padding_left: 2) { + Text( + content: props.gateway_port.clone(), + color: theme::TEXT, + ) + } + + View(height: 1) + + // Error message + #(if has_error { + element! { + Text(content: props.error.clone(), color: theme::ERROR) + }.into_any() + } else { + element! { View() }.into_any() + }) + + View(height: 1) + + // Help text + View(flex_direction: FlexDirection::Row, justify_content: JustifyContent::Center) { + Text(content: "[Tab]", color: theme::ACCENT) + Text(content: " Switch ", color: theme::MUTED) + Text(content: "[Enter]", color: theme::ACCENT) + Text(content: " Connect ", color: theme::MUTED) + Text(content: "[Esc]", color: theme::ACCENT) + Text(content: " Back", color: theme::MUTED) + } + } + } +} + +fn render_connecting(props: &PairingDialogProps) -> impl Into> { + let address = format!("{}:{}", props.gateway_host, props.gateway_port); + + element! { + View( + flex_direction: FlexDirection::Column, + align_items: AlignItems::Center, + padding_top: 2, + padding_bottom: 2, + ) { + Text( + content: "Connecting to gateway...", + color: theme::TEXT, + weight: Weight::Bold, + ) + + View(height: 1) + + Text(content: address, color: theme::ACCENT) + } + } +} + +fn render_complete(props: &PairingDialogProps) -> impl Into> { + let message = if props.success.is_empty() { + "Pairing successful!".to_string() + } else { + props.success.clone() + }; + + element! { + View( + flex_direction: FlexDirection::Column, + align_items: AlignItems::Center, + padding_top: 2, + padding_bottom: 2, + ) { + Text( + content: "✓", + color: theme::SUCCESS, + weight: Weight::Bold, + ) + + View(height: 1) + + Text( + content: message, + color: theme::SUCCESS, + weight: Weight::Bold, + ) + + View(height: 2) + + View(flex_direction: FlexDirection::Row) { + Text(content: "[Enter]", color: theme::ACCENT) + Text(content: " Close", color: theme::MUTED) + } + } + } +} + +/// Truncate a key for display, showing start and end. +fn truncate_key(key: &str, max_len: usize) -> String { + if key.len() <= max_len { + key.to_string() + } else { + let half = (max_len - 3) / 2; + format!("{}...{}", &key[..half], &key[key.len() - half..]) + } +} diff --git a/crates/rustyclaw-tui/src/components/root.rs b/crates/rustyclaw-tui/src/components/root.rs index c334794..d3d5c60 100644 --- a/crates/rustyclaw-tui/src/components/root.rs +++ b/crates/rustyclaw-tui/src/components/root.rs @@ -13,6 +13,7 @@ use crate::components::hatching_dialog::HatchingDialog; use crate::components::input_bar::InputBar; use crate::components::messages::Messages; use crate::components::model_selector_dialog::ModelSelectorDialog; +use crate::components::pairing_dialog::PairingDialog; use crate::components::provider_selector_dialog::ProviderSelectorDialog; use crate::components::secrets_dialog::{SecretInfo, SecretsDialog}; use crate::components::sidebar::Sidebar; @@ -144,6 +145,18 @@ pub struct RootProps { pub model_selector_models: Vec, pub model_selector_cursor: usize, pub model_selector_loading: bool, + + // pairing dialog overlay (SSH pairing) + pub show_pairing: bool, + pub pairing_step: crate::components::pairing_dialog::PairingStep, + pub pairing_field: crate::components::pairing_dialog::PairingField, + pub pairing_public_key: String, + pub pairing_fingerprint: String, + pub pairing_fingerprint_art: String, + pub pairing_qr_ascii: String, + pub pairing_host: String, + pub pairing_port: String, + pub pairing_error: String, } #[component] @@ -202,6 +215,18 @@ pub fn Root(props: &mut RootProps) -> impl Into> { let model_sel_cursor = props.model_selector_cursor; let model_sel_loading = props.model_selector_loading; + // Pairing dialog state + let show_pairing = props.show_pairing; + let pairing_step = props.pairing_step; + let pairing_field = props.pairing_field; + let pairing_public_key = std::mem::take(&mut props.pairing_public_key); + let pairing_fingerprint = std::mem::take(&mut props.pairing_fingerprint); + let pairing_fingerprint_art = std::mem::take(&mut props.pairing_fingerprint_art); + let pairing_qr_ascii = std::mem::take(&mut props.pairing_qr_ascii); + let pairing_host = std::mem::take(&mut props.pairing_host); + let pairing_port = std::mem::take(&mut props.pairing_port); + let pairing_error = std::mem::take(&mut props.pairing_error); + element! { View( width: props.width, @@ -528,6 +553,34 @@ pub fn Root(props: &mut RootProps) -> impl Into> { } else { element! { View() }.into_any() }) + + // ── Pairing dialog overlay ────────────────────────────────── + #(if show_pairing { + element! { + View( + width: props.width, + height: props.height, + position: Position::Absolute, + top: 0, + left: 0, + ) { + PairingDialog( + step: pairing_step, + public_key: pairing_public_key, + fingerprint: pairing_fingerprint, + fingerprint_art: pairing_fingerprint_art, + qr_ascii: pairing_qr_ascii, + gateway_host: pairing_host, + gateway_port: pairing_port, + active_field: pairing_field, + error: pairing_error, + success: String::new(), + ) + } + }.into_any() + } else { + element! { View() }.into_any() + }) } } } diff --git a/crates/rustyclaw-tui/src/lib.rs b/crates/rustyclaw-tui/src/lib.rs index 219f466..6fe3953 100644 --- a/crates/rustyclaw-tui/src/lib.rs +++ b/crates/rustyclaw-tui/src/lib.rs @@ -8,5 +8,6 @@ pub mod components; pub mod gateway_client; pub mod markdown; pub mod onboard; +pub mod pairing; pub mod theme; pub mod types; diff --git a/crates/rustyclaw-tui/src/pairing.rs b/crates/rustyclaw-tui/src/pairing.rs new file mode 100644 index 0000000..af95740 --- /dev/null +++ b/crates/rustyclaw-tui/src/pairing.rs @@ -0,0 +1,76 @@ +//! SSH pairing connection logic for TUI. +//! +//! This module handles the actual SSH connection to a gateway for pairing. + +#[cfg(feature = "ssh")] +use anyhow::{Context, Result}; + +/// Connect to a gateway via SSH and register this client's public key. +/// +/// Returns the gateway name on success. +#[cfg(feature = "ssh")] +pub async fn connect_and_pair(host: &str, port: u16, public_key: &str) -> Result { + use russh::client; + use russh_keys::key::PublicKey; + use std::sync::Arc; + + // Load the client keypair + let keypair = rustyclaw_core::pairing::ClientKeyPair::load_or_generate(None) + .context("Failed to load client keypair")?; + + // Create SSH client config + let config = Arc::new(client::Config::default()); + + // Connect to the gateway + let addr = format!("{}:{}", host, port); + tracing::info!("Connecting to gateway at {}", addr); + + // Create a client handler + struct PairingHandler { + gateway_name: Option, + } + + #[async_trait::async_trait] + impl client::Handler for PairingHandler { + type Error = anyhow::Error; + + async fn check_server_key( + &mut self, + _server_public_key: &PublicKey, + ) -> Result { + // For pairing, we accept any server key + // In production, we'd want to verify/store the server fingerprint + Ok(true) + } + } + + let handler = PairingHandler { gateway_name: None }; + + // Attempt connection + let mut session = client::connect(config, &addr, handler) + .await + .context("Failed to connect to gateway")?; + + // Authenticate with our keypair + let key = keypair.load_private_key()?; + let authenticated = session + .authenticate_publickey("rustyclaw", Arc::new(key)) + .await + .context("Failed to authenticate")?; + + if !authenticated { + anyhow::bail!("Authentication failed - key not authorized on gateway"); + } + + tracing::info!("Successfully paired with gateway at {}", host); + + // For now, use the host as the gateway name + // In the future, we could query the gateway for its actual name + Ok(host.to_string()) +} + +/// Stub for when SSH feature is disabled. +#[cfg(not(feature = "ssh"))] +pub async fn connect_and_pair(_host: &str, _port: u16, _public_key: &str) -> anyhow::Result { + anyhow::bail!("SSH feature not enabled") +} diff --git a/docs/v0.4.0-roadmap.md b/docs/v0.4.0-roadmap.md index 053b3a6..8fc5d99 100644 --- a/docs/v0.4.0-roadmap.md +++ b/docs/v0.4.0-roadmap.md @@ -267,27 +267,27 @@ ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... phone-app ## 4. Implementation Order ### Phase 1: SSH Transport (foundation) -1. Add `russh` dependency, feature flag -2. Create `transport.rs` trait abstracting WS/SSH -3. Implement standalone SSH server mode -4. Implement stdio subsystem mode -5. Add `authorized_clients` management -6. Update TUI to support SSH connection option - -### Phase 2: Desktop Client (parallel) -1. Create `rustyclaw-desktop` crate -2. Basic app shell with dioxus-bulma -3. Gateway client (WebSocket first) -4. Chat UI components -5. Port remaining TUI features -6. Add SSH transport support - -### Phase 3: Pairing Flow -1. Key generation utilities -2. QR code generation/scanning -3. Pairing UI in TUI -4. Pairing UI in desktop -5. Gateway-side pairing commands +1. ✅ Add `russh` dependency, feature flag +2. ✅ Create `transport.rs` trait abstracting WS/SSH +3. ✅ Implement standalone SSH server mode (`gateway/ssh.rs`) +4. ✅ Implement stdio subsystem mode +5. ✅ Add `authorized_clients` management (`pairing/authorized.rs`) +6. [ ] Update TUI to support SSH connection option + +### Phase 2: Desktop Client (parallel) — *Nemik leading* +1. [ ] Create `rustyclaw-desktop` crate +2. [ ] Basic app shell with dioxus-bulma +3. [ ] Gateway client (WebSocket first) +4. [ ] Chat UI components +5. [ ] Port remaining TUI features +6. [ ] Add SSH transport support + +### Phase 3: Pairing Flow — *In Progress* +1. ✅ Key generation utilities (`pairing/client_keys.rs`) +2. ✅ QR code generation (ASCII + PNG) (`pairing/qr.rs`) +3. ✅ Pairing UI in TUI (`components/pairing_dialog.rs`) +4. [ ] Pairing UI in desktop +5. ✅ Gateway-side pairing commands (`rustyclaw-gateway pair` subcommands) ---