From 026f27add65c8722cbe78ccbd0db2ff580c979bb Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Sun, 11 Oct 2026 16:14:55 +0100 Subject: [PATCH] rust: SSH client on russh (auth chain, TOTP, jump hosts, proxies, forwards, agent forwarding) and SFTP, with sshd integration tests --- rust/Cargo.lock | 1534 ++++++++++++++++++++++++++-- rust/core/Cargo.toml | 6 + rust/core/src/clients/mod.rs | 13 +- rust/core/src/clients/sftp.rs | 152 +++ rust/core/src/clients/ssh.rs | 766 ++++++++++++++ rust/core/tests/ssh_integration.rs | 262 +++++ 6 files changed, 2674 insertions(+), 59 deletions(-) create mode 100644 rust/core/src/clients/sftp.rs create mode 100644 rust/core/src/clients/ssh.rs create mode 100644 rust/core/tests/ssh_integration.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index cb8cc7d..f9bdaa5 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2,14 +2,30 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", - "generic-array", + "crypto-common 0.1.7", + "generic-array 0.14.7", +] + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", ] [[package]] @@ -19,8 +35,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", + "zeroize", ] [[package]] @@ -29,14 +57,29 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", "subtle", ] +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead 0.6.1", + "aes 0.9.3", + "cipher 0.5.2", + "ctr 0.10.1", + "ctutils", + "ghash 0.6.0", + "zeroize", +] + [[package]] name = "android_system_properties" version = "0.1.6" @@ -53,9 +96,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", - "blake2", - "cpufeatures", - "password-hash", + "blake2 0.10.6", + "cpufeatures 0.2.17", + "password-hash 0.5.0", +] + +[[package]] +name = "argon2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" +dependencies = [ + "base64ct", + "blake2 0.11.0", + "cpufeatures 0.3.1", + "password-hash 0.6.1", ] [[package]] @@ -212,6 +267,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.22.1" @@ -224,11 +285,25 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bcrypt-pbkdf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2 0.11.0", +] + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] [[package]] name = "blake2" @@ -236,7 +311,16 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", ] [[package]] @@ -245,7 +329,17 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.7", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", ] [[package]] @@ -254,7 +348,16 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ - "generic-array", + "generic-array 0.14.7", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", ] [[package]] @@ -270,6 +373,16 @@ dependencies = [ "piper", ] +[[package]] +name = "blowfish" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" +dependencies = [ + "byteorder", + "cipher 0.5.2", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -294,7 +407,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", ] [[package]] @@ -319,6 +441,19 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cipher 0.5.2", + "cpufeatures 0.3.1", + "rand_core 0.10.1", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -339,10 +474,28 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.1.7", + "inout 0.1.4", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", + "zeroize", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -352,6 +505,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.9.4" @@ -378,6 +537,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -387,32 +552,155 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "serdect", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", - "rand_core", + "generic-array 0.14.7", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint", + "rand_core 0.10.1", +] + [[package]] name = "ctr" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", ] +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", + "subtle", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rand_core 0.10.1", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "dbus" version = "0.9.12" @@ -430,35 +718,153 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" dependencies = [ - "aes", - "block-padding", - "cbc", + "aes 0.8.4", + "block-padding 0.3.3", + "cbc 0.1.2", "dbus", "fastrand", - "hkdf", + "hkdf 0.12.4", "num", "once_cell", - "sha2", + "sha2 0.10.9", "zeroize", ] +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "des" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der", + "digest 0.11.3", + "elliptic-curve", + "rfc6979", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff", + "group", + "hkdf 0.13.0", + "hybrid-array", + "pem-rfc7468", + "pkcs8", + "rand_core 0.10.1", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "endi" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -534,24 +940,87 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "find-msvc-tools" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.34" @@ -600,6 +1069,7 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -620,6 +1090,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "generic-array" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" +dependencies = [ + "generic-array 0.14.7", + "rustversion", + "typenum", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -638,8 +1119,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -649,9 +1133,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ "opaque-debug", - "polyval", + "polyval 0.6.2", ] +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.3", + "zeroize", +] + +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -688,13 +1211,28 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + [[package]] name = "hkdf" version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -703,7 +1241,28 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "ctutils", + "subtle", + "typenum", + "zeroize", ] [[package]] @@ -746,8 +1305,18 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", - "generic-array", + "block-padding 0.3.3", + "generic-array 0.14.7", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding 0.4.2", + "hybrid-array", ] [[package]] @@ -767,6 +1336,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + [[package]] name = "keyring" version = "3.6.3" @@ -816,12 +1405,27 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + [[package]] name = "memchr" version = "2.8.3" @@ -837,6 +1441,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.4" @@ -848,6 +1462,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8", + "rand_core 0.10.1", + "sha3 0.11.0", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + [[package]] name = "nexus" version = "0.1.0" @@ -859,15 +1498,21 @@ dependencies = [ name = "nexus-core" version = "0.1.0" dependencies = [ - "aes-gcm", - "argon2", + "aes-gcm 0.10.3", + "argon2 0.5.3", "base64", "chrono", + "hmac 0.13.0", "keyring", - "rand", + "rand 0.8.8", "rusqlite", + "russh", + "russh-sftp", "serde", "serde_json", + "sha1 0.11.0", + "sha2 0.11.0", + "tokio", "uuid", ] @@ -884,13 +1529,25 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "num" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", "num-complex", "num-integer", "num-iter", @@ -908,6 +1565,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.10.3", + "rand_core 0.10.1", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -942,7 +1611,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", "num-integer", "num-traits", ] @@ -978,12 +1647,96 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "p384" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" +dependencies = [ + "ecdsa", + "elliptic-curve", + "fiat-crypto", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "p521" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "pageant" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0786409b5aaa884655c9b4065681db0835db0cc185a6fb19fb17a5d38656866" +dependencies = [ + "base16ct", + "byteorder", + "bytes", + "delegate", + "futures", + "log", + "rand 0.10.3", + "sha2 0.11.0", + "thiserror", + "tokio", + "windows", + "windows-strings", +] + [[package]] name = "parking" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "password-hash" version = "0.5.0" @@ -991,10 +1744,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "phc", +] + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1012,6 +1803,45 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkcs5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" +dependencies = [ + "aes 0.9.3", + "aes-gcm 0.11.1", + "cbc 0.2.1", + "der", + "pbkdf2", + "rand_core 0.10.1", + "scrypt", + "sha2 0.11.0", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "pkcs5", + "rand_core 0.10.1", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -1032,6 +1862,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.1", + "universal-hash 0.6.1", + "zeroize", +] + [[package]] name = "polyval" version = "0.6.2" @@ -1039,9 +1880,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash 0.6.1", + "zeroize", ] [[package]] @@ -1053,6 +1906,33 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1094,7 +1974,18 @@ checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -1104,7 +1995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1116,6 +2007,64 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac 0.13.0", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.10.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" +dependencies = [ + "const-oid", + "crypto-bigint", + "crypto-primes", + "digest 0.11.3", + "pkcs1", + "pkcs8", + "rand_core 0.10.1", + "sha2 0.11.0", + "signature", + "spki", + "zeroize", +] + [[package]] name = "rusqlite" version = "0.37.0" @@ -1130,6 +2079,130 @@ dependencies = [ "smallvec", ] +[[package]] +name = "russh" +version = "0.64.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba61e87b9ec9a39a59a6bbed4c0b8ff7fb07073405b24a767a1f2c8560ac8f58" +dependencies = [ + "aes 0.9.3", + "bitflags", + "block-padding 0.4.2", + "byteorder", + "bytes", + "cbc 0.2.1", + "cipher 0.5.2", + "crypto-bigint", + "ctr 0.10.1", + "curve25519-dalek", + "data-encoding", + "delegate", + "der", + "digest 0.11.3", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "enum_dispatch", + "flate2", + "futures", + "generic-array 1.4.5", + "getrandom 0.4.3", + "ghash 0.6.0", + "hex-literal", + "hmac 0.13.0", + "inout 0.2.2", + "keccak", + "log", + "md5", + "ml-kem", + "module-lattice", + "num-bigint 0.5.1", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2", + "pkcs1", + "pkcs5", + "pkcs8", + "polyval 0.7.3", + "rand 0.10.3", + "rand_core 0.10.1", + "ring", + "rsa", + "russh-cryptovec", + "russh-util", + "salsa20", + "scrypt", + "sec1", + "sha1 0.11.0", + "sha2 0.11.0", + "sha3 0.12.0", + "signature", + "spki", + "ssh-encoding", + "ssh-key", + "subtle", + "thiserror", + "tokio", + "typenum", + "universal-hash 0.6.1", + "zeroize", +] + +[[package]] +name = "russh-cryptovec" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aec6cb630dbe85d72ffd7bcd95f07e1bd69f9f270ee8adfa1afe443a6331438" +dependencies = [ + "log", + "nix 0.31.3", + "ssh-encoding", + "windows-sys 0.61.2", +] + +[[package]] +name = "russh-sftp" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb17f72aba4314656b359ffba72c68dcbd70ed9e97497898b75498fe7198042" +dependencies = [ + "bitflags", + "bytes", + "chrono", + "dashmap", + "gloo-timers", + "log", + "serde", + "serde_bytes", + "thiserror", + "tokio", + "tokio-util", + "wasm-bindgen-futures", +] + +[[package]] +name = "russh-util" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.5" @@ -1149,22 +2222,64 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher 0.5.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" +dependencies = [ + "cfg-if", + "pbkdf2", + "salsa20", + "sha2 0.11.0", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "secret-service" version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" dependencies = [ - "aes", - "cbc", + "aes 0.8.4", + "cbc 0.1.2", "futures-util", - "generic-array", - "hkdf", + "generic-array 0.14.7", + "hkdf 0.12.4", "num", "once_cell", - "rand", + "rand 0.8.8", "serde", - "sha2", + "sha2 0.10.9", "zbus", ] @@ -1204,6 +2319,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -1214,6 +2335,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -1258,6 +2389,16 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha1" version = "0.10.7" @@ -1265,8 +2406,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -1276,8 +2428,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak", +] + +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", ] [[package]] @@ -1296,6 +2480,22 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -1315,7 +2515,82 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + +[[package]] +name = "ssh-cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597" +dependencies = [ + "aead 0.6.1", + "aes 0.9.3", + "aes-gcm 0.11.1", + "chacha20", + "cipher 0.5.2", + "ctutils", + "des", + "poly1305", + "ssh-encoding", + "zeroize", +] + +[[package]] +name = "ssh-encoding" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e" +dependencies = [ + "base64ct", + "bytes", + "crypto-bigint", + "ctutils", + "digest 0.11.3", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "ssh-key" +version = "0.7.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a32fae177b74a22aa9c5b01bf7e68b33545be32d9e381e248058d2adc15ce3" +dependencies = [ + "argon2 0.6.0", + "bcrypt-pbkdf", + "ctutils", + "ed25519-dalek", + "hex", + "hmac 0.13.0", + "p256", + "p384", + "p521", + "rand_core 0.10.1", + "rsa", + "sec1", + "sha1 0.11.0", + "sha2 0.11.0", + "signature", + "ssh-cipher", + "ssh-encoding", + "zeroize", ] [[package]] @@ -1365,6 +2640,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + [[package]] name = "tokio" version = "1.53.2" @@ -1374,13 +2669,40 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", + "tokio-macros", "tracing", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml_datetime" version = "1.1.2+spec-1.1.0" @@ -1471,10 +2793,26 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "uuid" version = "1.28.0" @@ -1517,6 +2855,17 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.129" @@ -1549,6 +2898,27 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -1562,6 +2932,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -1590,6 +2971,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -1677,6 +3068,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -1782,6 +3182,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + [[package]] name = "xdg-home" version = "1.3.0" @@ -1814,12 +3226,12 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.29.0", "ordered-stream", - "rand", + "rand 0.8.8", "serde", "serde_repr", - "sha1", + "sha1 0.10.7", "static_assertions", "tokio", "tracing", @@ -1895,6 +3307,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/rust/core/Cargo.toml b/rust/core/Cargo.toml index a3c8903..14a5f94 100644 --- a/rust/core/Cargo.toml +++ b/rust/core/Cargo.toml @@ -14,3 +14,9 @@ aes-gcm = "0.10" rand = "0.8" base64 = "0.22" keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "tokio", "crypto-rust"] } +russh = { version = "0.64.1", default-features = false, features = ["flate2", "ring", "rsa"] } +russh-sftp = "3.0.1" +tokio = { version = "1.53.2", features = ["full"] } +sha2 = "0.11.0" +hmac = "0.13.0" +sha1 = "0.11.0" diff --git a/rust/core/src/clients/mod.rs b/rust/core/src/clients/mod.rs index d811535..af36640 100644 --- a/rust/core/src/clients/mod.rs +++ b/rust/core/src/clients/mod.rs @@ -6,7 +6,18 @@ //! - `write`/`resize`/`close` are callable from any thread; `close` is idempotent and must not //! fire `on_close` re-entrantly while holding locks. -use std::sync::Arc; +pub mod sftp; +pub mod ssh; + +use std::sync::{Arc, OnceLock}; + +/// The shared tokio runtime for async protocol stacks (russh). Blocking clients use threads. +pub fn rt() -> &'static tokio::runtime::Runtime { + static RT: OnceLock = OnceLock::new(); + RT.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().thread_name("nexus-net").build().expect("tokio runtime") + }) +} pub type DataFn = Arc; pub type ErrorFn = Arc; diff --git a/rust/core/src/clients/sftp.rs b/rust/core/src/clients/sftp.rs new file mode 100644 index 0000000..5b856c9 --- /dev/null +++ b/rust/core/src/clients/sftp.rs @@ -0,0 +1,152 @@ +//! SFTP operations over an SSH session's subsystem (internal/sftpclient). Every call blocks the +//! caller, so the UI runs them on worker threads. + +use super::rt; +use crate::Result; +use russh_sftp::client::SftpSession; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[derive(Clone, Debug, PartialEq)] +pub struct FileEntry { + pub name: String, + /// Full remote path. + pub path: String, + pub size: u64, + pub is_dir: bool, + /// Unix permission string, e.g. "-rwxr-xr-x". + pub mode: String, + /// Unix epoch milliseconds. + pub modified_at: i64, +} + +#[derive(Clone, Debug, Default)] +pub struct Progress { + pub transferred: u64, + pub total: u64, + pub path: String, + pub done: bool, + pub error: String, +} + +fn mode_string(m: &russh_sftp::protocol::FileAttributes) -> String { + let kind = if m.is_dir() { 'd' } else if m.is_symlink() { 'L' } else { '-' }; + format!("{kind}{}", m.permissions()) +} + +fn entry(name: String, path: String, m: &russh_sftp::protocol::FileAttributes) -> FileEntry { + FileEntry { + name, + path, + size: m.size.unwrap_or(0), + is_dir: m.is_dir(), + mode: mode_string(m), + modified_at: m.mtime.map_or(0, |t| t as i64 * 1000), + } +} + +/// `path.Join` for remote (always '/') paths. +pub fn join(dir: &str, name: &str) -> String { + if dir.is_empty() { + name.to_string() + } else if dir.ends_with('/') { + format!("{dir}{name}") + } else { + format!("{dir}/{name}") + } +} + +/// Directories first, then case-insensitive name order. +pub fn list(s: &Arc, path: &str) -> Result> { + let rd = rt().block_on(s.read_dir(path)).map_err(|e| format!("sftpclient: failed to list {path}: {e}"))?; + let mut out: Vec = rd + .filter(|e| e.file_name() != "." && e.file_name() != "..") + .map(|e| entry(e.file_name(), join(path, &e.file_name()), &e.metadata())) + .collect(); + out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + Ok(out) +} + +pub fn stat(s: &Arc, path: &str) -> Result { + let m = rt().block_on(s.metadata(path)).map_err(|e| format!("sftpclient: stat failed for {path}: {e}"))?; + let name = path.rsplit('/').next().unwrap_or(path).to_string(); + Ok(entry(name, path.to_string(), &m)) +} + +pub fn mkdir(s: &Arc, path: &str) -> Result<()> { + Ok(rt().block_on(s.create_dir(path))?) +} + +/// Removes a file, or an empty directory. +pub fn remove(s: &Arc, path: &str) -> Result<()> { + rt().block_on(async { + match s.remove_file(path).await { + Ok(()) => Ok(()), + Err(e) => s.remove_dir(path).await.map_err(|_| e), + } + })?; + Ok(()) +} + +pub fn rename(s: &Arc, from: &str, to: &str) -> Result<()> { + Ok(rt().block_on(s.rename(from, to))?) +} + +pub fn pwd(s: &Arc) -> Result { + Ok(rt().block_on(s.canonicalize("."))?) +} + +async fn pump( + mut src: impl tokio::io::AsyncRead + Unpin, + mut dst: impl tokio::io::AsyncWrite + Unpin, + total: u64, + path: &str, + on: &dyn Fn(Progress), +) -> Result<()> { + let mut buf = vec![0u8; 32 * 1024]; + let (mut done, mut last) = (0u64, Instant::now()); + loop { + let n = src.read(&mut buf).await.map_err(|e| format!("sftpclient: read failed: {e}"))?; + if n == 0 { + break; + } + dst.write_all(&buf[..n]).await.map_err(|e| format!("sftpclient: write failed: {e}"))?; + done += n as u64; + if last.elapsed() > Duration::from_millis(100) { + on(Progress { transferred: done, total, path: path.into(), ..Default::default() }); + last = Instant::now(); + } + } + dst.flush().await?; + dst.shutdown().await?; + on(Progress { transferred: done, total, path: path.into(), done: true, ..Default::default() }); + Ok(()) +} + +pub fn download(s: &Arc, remote: &str, local: &std::path::Path, on: &dyn Fn(Progress)) -> Result<()> { + rt().block_on(async { + let total = s.metadata(remote).await.map_err(|e| format!("sftpclient: stat failed: {e}"))?.size.unwrap_or(0); + let src = s.open(remote).await.map_err(|e| format!("sftpclient: failed to open remote file: {e}"))?; + let dst = tokio::fs::File::create(local).await.map_err(|e| format!("sftpclient: failed to create local file: {e}"))?; + pump(src, dst, total, remote, on).await + }) +} + +pub fn upload(s: &Arc, local: &std::path::Path, remote: &str, on: &dyn Fn(Progress)) -> Result<()> { + rt().block_on(async { + let src = tokio::fs::File::open(local).await.map_err(|e| format!("sftpclient: failed to open local file: {e}"))?; + let total = src.metadata().await?.len(); + let dst = s.create(remote).await.map_err(|e| format!("sftpclient: failed to create remote file: {e}"))?; + pump(src, dst, total, remote, on).await + }) +} + +#[cfg(test)] +mod tests { + #[test] + fn join_paths() { + assert_eq!(super::join("/", "a"), "/a"); + assert_eq!(super::join("/home/x", "a"), "/home/x/a"); + } +} diff --git a/rust/core/src/clients/ssh.rs b/rust/core/src/clients/ssh.rs new file mode 100644 index 0000000..addc97a --- /dev/null +++ b/rust/core/src/clients/ssh.rs @@ -0,0 +1,766 @@ +//! SSH client on russh: interactive PTY shell, the profile's auth chain (agent, public key, +//! password, keyboard-interactive with TOTP), jump hosts, SOCKS5/HTTP proxies, local and remote +//! port forwards, agent forwarding and SFTP. A port of internal/sshclient + sftpclient. + +use super::{Callbacks, rt}; +use crate::models::{ForwardDirection, JumpHost, PortForward, Profile, SshConfig}; +use crate::Result; +use base64::Engine; +use russh::client::{self, Handle, Msg}; +use russh::keys::{HashAlg, PrivateKeyWithHashAlg, PublicKey}; +use russh::{Channel, ChannelMsg, Pty}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::sync::mpsc; + +const MAX_PRIVATE_KEY_BYTES: u64 = 1 << 20; + +/// What a host-key decision needs. The manager's checker consults the known-hosts table and, +/// per policy, prompts the user; it returns Err with the reason to reject. +#[derive(Clone, Debug)] +pub struct HostKeyInfo { + pub host: String, + pub port: u16, + pub key_type: String, + pub fingerprint: String, + pub public_key_b64: String, +} + +pub type HostKeyCheck = Arc std::result::Result<(), String> + Send + Sync>; +/// Blocking credential source (may prompt the user). +pub type SecretFn = Arc Result + Send + Sync>; + +pub fn host_key_info(host: &str, port: u16, key: &PublicKey) -> HostKeyInfo { + HostKeyInfo { + host: host.into(), + port, + key_type: key.algorithm().as_str().into(), + fingerprint: key.fingerprint(HashAlg::Sha256).to_string(), + public_key_b64: base64::engine::general_purpose::STANDARD.encode(key.to_bytes().unwrap_or_default()), + } +} + +/// State the russh handler shares with the client: remote-forward targets and the last +/// host-key rejection reason (russh only reports "unknown key"). +#[derive(Default)] +struct Shared { + remote_targets: Mutex>, + rejection: Mutex>, + agent_forwarding: std::sync::atomic::AtomicBool, +} + +struct Handler { + host: String, + port: u16, + check: HostKeyCheck, + shared: Arc, +} + +impl client::Handler for Handler { + type Error = russh::Error; + + async fn check_server_key(&mut self, key: &russh::keys::PublicKeyOrCertificate) -> std::result::Result { + let key = match key { + russh::keys::PublicKeyOrCertificate::PublicKey { key, .. } => key.clone(), + russh::keys::PublicKeyOrCertificate::Certificate(c) => c.public_key().clone().into(), + }; + let info = host_key_info(&self.host, self.port, &key); + let check = self.check.clone(); + // The check may block on a UI prompt. + let res = tokio::task::spawn_blocking(move || check(&info)).await.unwrap_or(Err("host key check failed".into())); + match res { + Ok(()) => Ok(true), + Err(why) => { + *self.shared.rejection.lock().unwrap() = Some(why); + Ok(false) + } + } + } + + async fn server_channel_open_forwarded_tcpip( + &mut self, + channel: Channel, + connected_address: &str, + connected_port: u32, + _originator_address: &str, + _originator_port: u32, + reply: client::ChannelOpenHandle, + _session: &mut client::Session, + ) -> std::result::Result<(), Self::Error> { + let dest = self.shared.remote_targets.lock().unwrap().get(&(connected_address.to_string(), connected_port)).cloned(); + let Some((host, port)) = dest else { + reply.reject(russh::ChannelOpenFailure::AdministrativelyProhibited).await; + return Ok(()); + }; + reply.accept().await; + tokio::spawn(async move { + if let Ok(mut local) = tokio::net::TcpStream::connect((host.as_str(), port)).await { + let mut remote = channel.into_stream(); + let _ = tokio::io::copy_bidirectional(&mut remote, &mut local).await; + } + }); + Ok(()) + } + + async fn server_channel_open_agent_forward( + &mut self, + channel: Channel, + reply: client::ChannelOpenHandle, + _session: &mut client::Session, + ) -> std::result::Result<(), Self::Error> { + if !self.shared.agent_forwarding.load(std::sync::atomic::Ordering::Relaxed) { + reply.reject(russh::ChannelOpenFailure::AdministrativelyProhibited).await; + return Ok(()); + } + reply.accept().await; + tokio::spawn(async move { + if let Ok(mut agent) = connect_agent_socket().await { + let mut remote = channel.into_stream(); + let _ = tokio::io::copy_bidirectional(&mut remote, &mut agent).await; + } + }); + Ok(()) + } +} + +trait Stream: AsyncRead + AsyncWrite + Unpin + Send {} +impl Stream for T {} + +/// The local agent's socket: `SSH_AUTH_SOCK` on Unix, the OpenSSH named pipe on Windows. +async fn connect_agent_socket() -> std::io::Result> { + #[cfg(unix)] + { + let p = std::env::var("SSH_AUTH_SOCK").map_err(|_| std::io::Error::other("SSH_AUTH_SOCK not set — SSH agent unavailable"))?; + Ok(Box::new(tokio::net::UnixStream::connect(p).await?)) + } + #[cfg(windows)] + { + let p = std::env::var("SSH_AUTH_SOCK").unwrap_or_else(|_| r"\\.\pipe\openssh-ssh-agent".into()); + Ok(Box::new(tokio::net::windows::named_pipe::ClientOptions::new().open(p)?)) + } +} + +type Agent = russh::keys::agent::client::AgentClient>; + +async fn connect_agent() -> Result { + #[cfg(unix)] + { + Ok(russh::keys::agent::client::AgentClient::connect_env().await.map_err(|e| format!("SSH agent unavailable: {e}"))?.dynamic()) + } + #[cfg(windows)] + { + if let Ok(a) = russh::keys::agent::client::AgentClient::connect_named_pipe(r"\\.\pipe\openssh-ssh-agent").await { + return Ok(a.dynamic()); + } + Ok(russh::keys::agent::client::AgentClient::connect_pageant().await.map_err(|e| format!("SSH agent unavailable: {e}"))?.dynamic()) + } +} + +/// Profile env vars plus a UTF-8 LC_CTYPE when the profile sets no locale (Go's SSHEnvVars). +fn ssh_env_vars(env: &std::collections::BTreeMap) -> std::collections::BTreeMap { + let mut out: std::collections::BTreeMap = + env.iter().filter(|(k, _)| !k.trim().is_empty()).map(|(k, v)| (k.trim().to_string(), v.clone())).collect(); + if !["LC_ALL", "LC_CTYPE", "LANG"].iter().any(|k| out.contains_key(*k)) { + out.insert("LC_CTYPE".into(), if cfg!(target_os = "macos") { "en_US.UTF-8" } else { "C.UTF-8" }.into()); + } + out +} + +fn is_totp_prompt(q: &str) -> bool { + let q = q.to_lowercase(); + [ + "verification code", "otp", "one-time", "one time", "two-factor", "two factor", "2fa", "totp", "authenticator", + "security code", "6-digit", "6 digit", + ] + .iter() + .any(|k| q.contains(k)) +} + +/// RFC 6238 code (SHA-1, 6 digits, 30 s) for a base32 secret, normalised as pquerna/otp does. +pub fn totp_code(secret: &str) -> Result { + totp_at(secret, std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()) +} + +fn totp_at(secret: &str, unix: u64) -> Result { + use hmac::{Hmac, KeyInit, Mac}; + let key = base32_decode(secret).ok_or("invalid TOTP secret (not base32)")?; + let mut mac = Hmac::::new_from_slice(&key).map_err(|e| e.to_string())?; + mac.update(&(unix / 30).to_be_bytes()); + let h = mac.finalize().into_bytes(); + let o = (h[19] & 0xf) as usize; + let bin = u32::from_be_bytes([h[o] & 0x7f, h[o + 1], h[o + 2], h[o + 3]]); + Ok(format!("{:06}", bin % 1_000_000)) +} + +fn base32_decode(s: &str) -> Option> { + let (mut out, mut buf, mut bits) = (vec![], 0u64, 0u32); + for c in s.chars().filter(|c| !c.is_whitespace() && *c != '=') { + let v = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".find(c.to_ascii_uppercase())? as u64; + buf = (buf << 5) | v; + bits += 5; + if bits >= 8 { + bits -= 8; + out.push((buf >> bits) as u8); + } + } + (!out.is_empty()).then_some(out) +} + +/// Reads an OpenSSH/PEM private key, asking for a passphrase only if it's encrypted. +pub fn load_private_key(path: &str, passphrase: Option<&SecretFn>) -> Result { + let meta = std::fs::metadata(path).map_err(|e| format!("unable to stat key file: {e}"))?; + if meta.is_dir() { + return Err("key path is a directory".into()); + } + if meta.len() > MAX_PRIVATE_KEY_BYTES { + return Err(format!("key file is too large: {} bytes exceeds {MAX_PRIVATE_KEY_BYTES} bytes", meta.len()).into()); + } + let data = std::fs::read_to_string(path).map_err(|e| format!("unable to read key file: {e}"))?; + match russh::keys::decode_secret_key(&data, None) { + Ok(k) => Ok(k), + Err(first) => { + let Some(get) = passphrase else { + return Err(format!("key is encrypted and no passphrase callback provided: {first}").into()); + }; + let pass = get().map_err(|e| format!("failed to get passphrase: {e}"))?; + russh::keys::decode_secret_key(&data, Some(&pass)).map_err(|e| format!("failed to parse encrypted key: {e}").into()) + } + } +} + +async fn blocking(f: impl FnOnce() -> T + Send + 'static) -> T { + tokio::task::spawn_blocking(f).await.expect("blocking task panicked") +} + +async fn dial(sc: &SshConfig, host: &str, port: u16, timeout: Duration) -> Result { + let fut = async { + match sc.proxy_type.to_lowercase().as_str() { + "socks5" => socks5(sc, host, port).await, + "http" => http_connect(sc, host, port).await, + _ => Ok(tokio::net::TcpStream::connect((host, port)).await?), + } + }; + tokio::time::timeout(timeout, fut).await.map_err(|_| format!("dial tcp {host}:{port}: i/o timeout"))? +} + +async fn socks5(sc: &SshConfig, host: &str, port: u16) -> Result { + let pport = if sc.proxy_port == 0 { 1080 } else { sc.proxy_port as u16 }; + let mut s = tokio::net::TcpStream::connect((sc.proxy_host.as_str(), pport)).await.map_err(|e| format!("SOCKS5 proxy setup failed: {e}"))?; + let auth = !sc.proxy_username.is_empty(); + s.write_all(if auth { &[5, 2, 0, 2] } else { &[5, 1, 0] }).await?; + let mut r = [0u8; 2]; + s.read_exact(&mut r).await?; + match r[1] { + 0 => {} + 2 if auth => { + let mut m = vec![1, sc.proxy_username.len() as u8]; + m.extend(sc.proxy_username.as_bytes()); + m.push(sc.proxy_password.len() as u8); + m.extend(sc.proxy_password.as_bytes()); + s.write_all(&m).await?; + s.read_exact(&mut r).await?; + if r[1] != 0 { + return Err("SOCKS5 proxy: authentication failed".into()); + } + } + _ => return Err("SOCKS5 proxy: no acceptable authentication method".into()), + } + let mut req = vec![5, 1, 0, 3, host.len() as u8]; + req.extend(host.as_bytes()); + req.extend(port.to_be_bytes()); + s.write_all(&req).await?; + let mut head = [0u8; 4]; + s.read_exact(&mut head).await?; + if head[1] != 0 { + return Err(format!("SOCKS5 proxy connection to {host}:{port} failed: reply code {}", head[1]).into()); + } + let skip = match head[3] { + 1 => 4, + 4 => 16, + 3 => s.read_u8().await? as usize, + _ => 0, + } + 2; + let mut rest = vec![0u8; skip]; + s.read_exact(&mut rest).await?; + Ok(s) +} + +async fn http_connect(sc: &SshConfig, host: &str, port: u16) -> Result { + let pport = if sc.proxy_port == 0 { 8080 } else { sc.proxy_port as u16 }; + let mut s = tokio::net::TcpStream::connect((sc.proxy_host.as_str(), pport)).await.map_err(|e| format!("HTTP proxy connection failed: {e}"))?; + let mut req = format!("CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n"); + if !sc.proxy_username.is_empty() { + let cred = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", sc.proxy_username, sc.proxy_password)); + req += &format!("Proxy-Authorization: Basic {cred}\r\n"); + } + req += "\r\n"; + s.write_all(req.as_bytes()).await?; + // Read the response head byte by byte so no tunnelled bytes are consumed. + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + head.push(s.read_u8().await.map_err(|e| format!("HTTP CONNECT response read failed: {e}"))?); + if head.len() > 16 * 1024 { + return Err("HTTP CONNECT response too large".into()); + } + } + let status = String::from_utf8_lossy(&head).lines().next().unwrap_or("").to_string(); + if status.split_whitespace().nth(1) != Some("200") { + return Err(format!("HTTP CONNECT proxy returned {status}").into()); + } + Ok(s) +} + +fn config(sc: &SshConfig) -> Arc { + let mut c = client::Config::default(); + if sc.keepalive_interval > 0 { + c.keepalive_interval = Some(Duration::from_secs(sc.keepalive_interval as u64)); + c.keepalive_max = sc.keepalive_count_max.max(1) as usize; + } + c.preferred.compression = if sc.compression { + vec![russh::compression::ZLIB_LEGACY, russh::compression::ZLIB, russh::compression::NONE].into() + } else { + vec![russh::compression::NONE].into() + }; + c.nodelay = true; + Arc::new(c) +} + +struct Auth<'a> { + user: &'a str, + methods: Vec, + agent: bool, + key: Option, + password: Option, + totp_secret: String, +} + +async fn authenticate(h: &mut Handle, a: Auth<'_>) -> Result<()> { + if a.agent + && let Ok(mut agent) = connect_agent().await + && let Ok(ids) = agent.request_identities().await + { + for id in ids { + let key = id.public_key().into_owned(); + let hash = if key.algorithm().is_rsa() { h.best_supported_rsa_hash().await.ok().flatten().flatten() } else { None }; + if let Ok(r) = h.authenticate_publickey_with(a.user, key, hash, &mut agent).await + && r.success() + { + return Ok(()); + } + } + } + for m in &a.methods { + match m.as_str() { + "publickey" => { + if let Some(k) = &a.key { + let hash = if k.algorithm().is_rsa() { h.best_supported_rsa_hash().await?.flatten() } else { None }; + if h.authenticate_publickey(a.user, PrivateKeyWithHashAlg::new(Arc::new(k.clone()), hash)).await?.success() { + return Ok(()); + } + } + } + "password" => { + if let Some(get) = a.password.clone() { + let pw = blocking(move || get()).await?; + if h.authenticate_password(a.user, pw).await?.success() { + return Ok(()); + } + } + } + "keyboard-interactive" => { + let Some(get) = a.password.clone() else { continue }; + let mut resp = h.authenticate_keyboard_interactive_start(a.user, None).await?; + loop { + match resp { + client::KeyboardInteractiveAuthResponse::Success => return Ok(()), + client::KeyboardInteractiveAuthResponse::Failure { .. } => break, + client::KeyboardInteractiveAuthResponse::InfoRequest { prompts, .. } => { + let mut answers = vec![]; + for p in prompts { + if !a.totp_secret.is_empty() && is_totp_prompt(&p.prompt) { + answers.push(totp_code(&a.totp_secret).map_err(|e| format!("failed to generate TOTP code: {e}"))?); + } else { + let get = get.clone(); + answers.push(blocking(move || get()).await?); + } + } + resp = h.authenticate_keyboard_interactive_respond(answers).await?; + } + } + } + } + _ => {} + } + } + Err("ssh: handshake failed: ssh: unable to authenticate, no supported methods remain".into()) +} + +enum Cmd { + Data(Vec), + Resize(u32, u32), +} + +struct Forward { + rule: PortForward, + tasks: Arc>>, +} + +pub struct SshClient { + profile: Profile, + cb: Callbacks, + shared: Arc, + handle: Mutex>>>, + /// Earlier hops of a jump chain; kept alive for the session's lifetime. + hops: Mutex>>>, + tx: Mutex>>, + forwards: Mutex>, + sftp: tokio::sync::Mutex>>, + closed: std::sync::atomic::AtomicBool, +} + +impl SshClient { + pub fn new(profile: &Profile, cb: Callbacks) -> Arc { + Arc::new(SshClient { + profile: profile.clone(), + cb, + shared: Arc::default(), + handle: Mutex::new(None), + hops: Mutex::new(vec![]), + tx: Mutex::new(None), + forwards: Mutex::new(HashMap::new()), + sftp: tokio::sync::Mutex::new(None), + closed: false.into(), + }) + } + + fn sc(&self) -> SshConfig { + self.profile.ssh.clone().unwrap_or_else(SshConfig::defaults) + } + + /// Connects, authenticates and starts the shell. Blocks the calling thread. + pub fn connect(self: &Arc, check: HostKeyCheck, password: Option, passphrase: Option) -> Result<()> { + let me = self.clone(); + rt().block_on(async move { me.connect_async(check, password, passphrase).await }) + } + + async fn handshake( + &self, + stream: impl AsyncRead + AsyncWrite + Unpin + Send + 'static, + host: &str, + port: u16, + check: &HostKeyCheck, + ) -> Result> { + let handler = Handler { host: host.into(), port, check: check.clone(), shared: self.shared.clone() }; + let timeout = Duration::from_secs(self.profile.connect_timeout.max(1) as u64); + match tokio::time::timeout(timeout, client::connect_stream(config(&self.sc()), stream, handler)).await { + Err(_) => Err(format!("SSH handshake with {host}:{port} timed out").into()), + Ok(Ok(h)) => Ok(h), + Ok(Err(e)) => match self.shared.rejection.lock().unwrap().take() { + Some(why) => Err(why.into()), + None => Err(format!("SSH handshake failed: {e}").into()), + }, + } + } + + async fn connect_async(self: &Arc, check: HostKeyCheck, password: Option, passphrase: Option) -> Result<()> { + let sc = self.sc(); + let p = &self.profile; + let port = if p.port == 0 { 22 } else { p.port as u16 }; + let timeout = Duration::from_secs(if p.connect_timeout == 0 { 30 } else { p.connect_timeout as u64 }); + let methods = if sc.auth_methods.is_empty() { SshConfig::defaults().auth_methods } else { sc.auth_methods.clone() }; + let key = if methods.iter().any(|m| m == "publickey") && !sc.private_key_path.is_empty() { + let (path, pp) = (sc.private_key_path.clone(), passphrase.clone()); + Some(blocking(move || load_private_key(&path, pp.as_ref())).await.map_err(|e| format!("failed to build auth methods: failed to parse private key {}: {e}", sc.private_key_path))?) + } else { + None + }; + + let mut handle = if sc.jump_hosts.is_empty() { + let tcp = dial(&sc, &p.host, port, timeout).await.map_err(|e| format!("SSH connection failed: {e}"))?; + self.handshake(tcp, &p.host, port, &check).await? + } else { + let (prev, h) = self.via_jumps(&sc, &check, passphrase.clone(), timeout).await?; + let ch = prev.channel_open_direct_tcpip(p.host.clone(), port as u32, "127.0.0.1", 0).await.map_err(|e| format!("failed to dial target {}:{port} through jump hosts: {e}", p.host))?; + self.hops.lock().unwrap().extend(h); + self.hops.lock().unwrap().push(prev); + self.handshake(ch.into_stream(), &p.host, port, &check).await? + }; + + authenticate( + &mut handle, + Auth { user: &p.username, methods, agent: sc.agent_auth, key, password, totp_secret: sc.totp_secret.clone() }, + ) + .await?; + let handle = Arc::new(handle); + + let ch = handle.channel_open_session().await.map_err(|e| format!("failed to create SSH session: {e}"))?; + for (k, v) in ssh_env_vars(&sc.env_vars) { + let _ = ch.set_env(false, k, v).await; + } + let term = if sc.terminal_type.is_empty() { "xterm-256color" } else { &sc.terminal_type }; + let modes = [(Pty::ECHO, 1), (Pty::TTY_OP_ISPEED, 115200), (Pty::TTY_OP_OSPEED, 115200), (Pty::IUTF8, 1)]; + ch.request_pty(true, term, 80, 24, 0, 0, &modes).await.map_err(|e| format!("PTY request failed: {e}"))?; + if sc.agent_forwarding { + self.shared.agent_forwarding.store(true, std::sync::atomic::Ordering::Relaxed); + if let Err(e) = ch.agent_forward(false).await { + (self.cb.on_error)(format!("agent forwarding: {e}")); + } + } + if sc.startup_command.is_empty() { + ch.request_shell(true).await.map_err(|e| format!("failed to start shell: {e}"))?; + } else { + ch.exec(true, sc.startup_command.as_bytes()).await.map_err(|e| format!("failed to start command: {e}"))?; + } + + let (mut read, write) = ch.split(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + *self.tx.lock().unwrap() = Some(tx); + *self.handle.lock().unwrap() = Some(handle); + tokio::spawn(async move { + while let Some(cmd) = rx.recv().await { + let r = match cmd { + Cmd::Data(d) => write.data_bytes(d).await, + Cmd::Resize(c, r) => write.window_change(c, r, 0, 0).await, + }; + if r.is_err() { + break; + } + } + }); + let me = self.clone(); + tokio::spawn(async move { + while let Some(msg) = read.wait().await { + match msg { + ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, .. } => (me.cb.on_data)(&data), + ChannelMsg::Eof | ChannelMsg::Close => break, + _ => {} + } + } + let me2 = me.clone(); + let _ = tokio::task::spawn_blocking(move || me2.close()).await; + }); + Ok(()) + } + + /// Authenticates through each jump host in turn; returns the last hop and the ones before it. + async fn via_jumps(&self, sc: &SshConfig, check: &HostKeyCheck, passphrase: Option, timeout: Duration) -> Result<(Arc>, Vec>>)> { + let mut chain: Vec>> = vec![]; + for (i, j) in sc.jump_hosts.iter().enumerate() { + let JumpHost { host, port, username, private_key_path, .. } = j; + let jport = if *port == 0 { 22 } else { *port as u16 }; + let key = if private_key_path.is_empty() { + None + } else { + let (path, pp) = (private_key_path.clone(), passphrase.clone()); + Some(blocking(move || load_private_key(&path, pp.as_ref())).await.map_err(|e| format!("jump host {i} private key error: {e}"))?) + }; + let mut h = match chain.last() { + None => { + let tcp = tokio::time::timeout(timeout, tokio::net::TcpStream::connect((host.as_str(), jport))) + .await + .map_err(|_| format!("failed to connect to jump host {i} ({host}:{jport}): timeout"))? + .map_err(|e| format!("failed to connect to jump host {i} ({host}:{jport}): {e}"))?; + self.handshake(tcp, host, jport, check).await? + } + Some(prev) => { + let ch = prev.channel_open_direct_tcpip(host.clone(), jport as u32, "127.0.0.1", 0).await.map_err(|e| format!("failed to dial through jump host {i} ({host}:{jport}): {e}"))?; + self.handshake(ch.into_stream(), host, jport, check).await? + } + }; + authenticate( + &mut h, + Auth { user: username, methods: vec!["publickey".into()], agent: key.is_none() || sc.agent_auth, key, password: None, totp_secret: String::new() }, + ) + .await + .map_err(|e| format!("failed to establish SSH through jump host {i}: {e}"))?; + chain.push(Arc::new(h)); + } + let last = chain.pop().expect("at least one jump host"); + Ok((last, chain)) + } + + fn handle(&self) -> Result>> { + self.handle.lock().unwrap().clone().ok_or_else(|| "not connected".into()) + } + + pub fn write(&self, data: &[u8]) -> Result<()> { + let tx = self.tx.lock().unwrap(); + tx.as_ref().ok_or("not connected")?.send(Cmd::Data(data.to_vec())).map_err(|_| "not connected".into()) + } + + pub fn resize(&self, cols: u32, rows: u32) -> Result<()> { + let tx = self.tx.lock().unwrap(); + tx.as_ref().ok_or("not connected")?.send(Cmd::Resize(cols, rows)).map_err(|_| "not connected".into()) + } + + /// Round trip of a keepalive, in milliseconds. + pub fn ping(&self) -> Result { + let h = self.handle()?; + let start = Instant::now(); + rt().block_on(h.send_ping())?; + Ok(start.elapsed().as_millis() as i64) + } + + /// Runs a command on a separate channel and returns trimmed stdout. + pub fn run_command(&self, command: &str) -> Result { + let h = self.handle()?; + let command = command.to_string(); + rt().block_on(async move { + let mut ch = h.channel_open_session().await.map_err(|e| format!("failed to open SSH session: {e}"))?; + ch.exec(true, command.as_bytes()).await?; + let mut out = vec![]; + while let Some(m) = ch.wait().await { + match m { + ChannelMsg::Data { data } => out.extend_from_slice(&data), + ChannelMsg::Eof | ChannelMsg::Close => break, + _ => {} + } + } + Ok(String::from_utf8_lossy(&out).trim().to_string()) + }) + } + + pub fn is_connected(&self) -> bool { + self.handle.lock().unwrap().as_ref().is_some_and(|h| !h.is_closed()) + } + + /// Idempotent; fires on_close once. + pub fn close(&self) { + if self.closed.swap(true, std::sync::atomic::Ordering::SeqCst) { + return; + } + self.stop_all_forwards(); + *self.tx.lock().unwrap() = None; + let h = self.handle.lock().unwrap().take(); + let hops = std::mem::take(&mut *self.hops.lock().unwrap()); + rt().spawn(async move { + if let Some(h) = h { + let _ = h.disconnect(russh::Disconnect::ByApplication, "", "en").await; + } + for h in hops.into_iter().rev() { + let _ = h.disconnect(russh::Disconnect::ByApplication, "", "en").await; + } + }); + (self.cb.on_close)(); + } + + // --- port forwarding ------------------------------------------------------------------- + + pub fn start_forward(&self, rule: PortForward) -> Result<()> { + if self.forwards.lock().unwrap().contains_key(&rule.id) { + return Err(format!("forward {:?} is already active", rule.id).into()); + } + let h = self.handle()?; + let tasks: Arc>> = Arc::default(); + match rule.direction { + ForwardDirection::Local => { + let bind = if rule.bind_host.is_empty() { "127.0.0.1".to_string() } else { rule.bind_host.clone() }; + let addr = format!("{bind}:{}", rule.bind_port); + let listener = rt() + .block_on(tokio::net::TcpListener::bind(addr.clone())) + .map_err(|e| format!("local forward: listen on {addr} failed: {e}"))?; + let (dhost, dport) = (rule.dest_host.clone(), rule.dest_port as u32); + let t2 = tasks.clone(); + let task = rt().spawn(async move { + while let Ok((mut sock, peer)) = listener.accept().await { + let (h, dhost) = (h.clone(), dhost.clone()); + let conn = tokio::spawn(async move { + if let Ok(ch) = h.channel_open_direct_tcpip(dhost, dport, peer.ip().to_string(), peer.port() as u32).await { + let mut remote = ch.into_stream(); + let _ = tokio::io::copy_bidirectional(&mut sock, &mut remote).await; + } + }); + t2.lock().unwrap().push(conn.abort_handle()); + } + }); + tasks.lock().unwrap().push(task.abort_handle()); + } + ForwardDirection::Remote => { + let bind = if rule.bind_host.is_empty() { "0.0.0.0".to_string() } else { rule.bind_host.clone() }; + let port = rt() + .block_on(h.tcpip_forward(bind.clone(), rule.bind_port as u32)) + .map_err(|e| format!("remote forward: server listen on {bind}:{} failed: {e}", rule.bind_port))?; + let port = if rule.bind_port == 0 { port } else { rule.bind_port as u32 }; + self.shared.remote_targets.lock().unwrap().insert((bind, port), (rule.dest_host.clone(), rule.dest_port as u16)); + } + } + self.forwards.lock().unwrap().insert(rule.id.clone(), Forward { rule, tasks }); + Ok(()) + } + + pub fn stop_forward(&self, id: &str) -> Result<()> { + let f = self.forwards.lock().unwrap().remove(id).ok_or_else(|| format!("forward {id:?} not found"))?; + self.shutdown_forward(f); + Ok(()) + } + + fn shutdown_forward(&self, f: Forward) { + for t in f.tasks.lock().unwrap().drain(..) { + t.abort(); + } + if f.rule.direction == ForwardDirection::Remote { + let bind = if f.rule.bind_host.is_empty() { "0.0.0.0".to_string() } else { f.rule.bind_host.clone() }; + self.shared.remote_targets.lock().unwrap().retain(|(a, p), _| !(a == &bind && *p == f.rule.bind_port as u32)); + if let Ok(h) = self.handle() { + let port = f.rule.bind_port as u32; + rt().spawn(async move { + let _ = h.cancel_tcpip_forward(bind, port).await; + }); + } + } + } + + fn stop_all_forwards(&self) { + let all: Vec = self.forwards.lock().unwrap().drain().map(|(_, f)| f).collect(); + for f in all { + self.shutdown_forward(f); + } + } + + pub fn active_forwards(&self) -> Vec { + self.forwards.lock().unwrap().values().map(|f| f.rule.clone()).collect() + } + + // --- SFTP ------------------------------------------------------------------------------- + + /// The SFTP subsystem, opened on first use. + pub fn sftp(&self) -> Result> { + rt().block_on(async { + let mut slot = self.sftp.lock().await; + if let Some(s) = slot.as_ref() { + return Ok(s.clone()); + } + let h = self.handle()?; + let ch = h.channel_open_session().await?; + ch.request_subsystem(true, "sftp").await?; + let s = Arc::new( + russh_sftp::client::SftpSession::new(ch.into_stream()).await.map_err(|e| format!("sftpclient: failed to start SFTP subsystem: {e}"))?, + ); + *slot = Some(s.clone()); + Ok(s) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn totp_matches_rfc_vector() { + // RFC 6238 SHA-1 secret "12345678901234567890" is GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ in base32. + let secret = "gezd gnbv gy3t qojq gezd gnbv gy3t qojq"; + assert_eq!(totp_at(secret, 59).unwrap(), "287082"); + assert_eq!(totp_at(secret, 1111111109).unwrap(), "081804"); + assert!(totp_code("JBSWY3DPEHPK3PXP").is_ok()); + } + + #[test] + fn totp_prompt_detection() { + assert!(is_totp_prompt("Verification code: ")); + assert!(is_totp_prompt("Enter your 6-digit OTP")); + assert!(!is_totp_prompt("Password: ")); + } +} diff --git a/rust/core/tests/ssh_integration.rs b/rust/core/tests/ssh_integration.rs new file mode 100644 index 0000000..7736d28 --- /dev/null +++ b/rust/core/tests/ssh_integration.rs @@ -0,0 +1,262 @@ +//! End-to-end SSH tests against a throwaway OpenSSH server run as the current user. Skipped +//! (passing) when `sshd`/`ssh-keygen` aren't installed. + +use nexus_core::clients::Callbacks; +use nexus_core::clients::sftp; +use nexus_core::clients::ssh::{HostKeyCheck, SshClient}; +use nexus_core::models::{ForwardDirection, JumpHost, PortForward, Profile, Protocol}; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +struct Server { + dir: PathBuf, + port: u16, + child: Child, + agent: Option, +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.child.kill(); + if let Some(a) = &mut self.agent { + let _ = a.kill(); + } + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +fn which(p: &str) -> Option { + ["/usr/sbin", "/usr/bin", "/usr/local/sbin", "/usr/local/bin"].iter().map(|d| Path::new(d).join(p)).find(|p| p.exists()) +} + +fn free_port() -> u16 { + TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port() +} + +fn start(name: &str) -> Option { + let sshd = which("sshd")?; + let keygen = which("ssh-keygen")?; + let sftp_server = ["/usr/lib/openssh/sftp-server", "/usr/libexec/openssh/sftp-server", "/usr/libexec/sftp-server"] + .iter() + .find(|p| Path::new(p).exists())?; + let dir = std::env::temp_dir().join(format!("nexus-sshd-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for k in ["host", "client"] { + Command::new(&keygen).args(["-q", "-t", "ed25519", "-N", "", "-f"]).arg(dir.join(k)).status().unwrap(); + } + std::fs::copy(dir.join("client.pub"), dir.join("authorized_keys")).unwrap(); + let port = free_port(); + let cfg = format!( + "Port {port}\nListenAddress 127.0.0.1\nHostKey {d}/host\nAuthorizedKeysFile {d}/authorized_keys\nPidFile {d}/pid\n\ + StrictModes no\nUsePAM no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPubkeyAuthentication yes\n\ + AllowTcpForwarding yes\nAllowAgentForwarding yes\nSubsystem sftp {sftp_server}\n", + d = dir.display() + ); + std::fs::write(dir.join("sshd_config"), cfg).unwrap(); + let child = Command::new(sshd).args(["-D", "-e", "-f"]).arg(dir.join("sshd_config")).spawn().ok()?; + let start = Instant::now(); + while TcpStream::connect(("127.0.0.1", port)).is_err() { + if start.elapsed() > Duration::from_secs(5) { + return None; + } + std::thread::sleep(Duration::from_millis(50)); + } + Some(Server { dir, port, child, agent: None }) +} + +fn profile(s: &Server) -> Profile { + let mut p = Profile::new(Protocol::Ssh); + p.host = "127.0.0.1".into(); + p.port = s.port as i64; + p.username = std::env::var("USER").unwrap_or_else(|_| "root".into()); + let c = p.ssh.as_mut().unwrap(); + c.auth_methods = vec!["publickey".into()]; + c.private_key_path = s.dir.join("client").to_string_lossy().into(); + // Never start the user's login shell: profiles can auto-attach tmux/byobu to their real session. + c.startup_command = "/bin/sh -i".into(); + p +} + +type Seen = Arc>>; + +fn callbacks() -> (Callbacks, Seen) { + let seen: Seen = Arc::default(); + let s2 = seen.clone(); + let mut cb = Callbacks::noop(); + cb.on_data = Arc::new(move |d| s2.lock().unwrap().extend_from_slice(d)); + (cb, seen) +} + +fn accept_all() -> (HostKeyCheck, Arc>>) { + let fps: Arc>> = Arc::default(); + let f2 = fps.clone(); + (Arc::new(move |i| { + f2.lock().unwrap().push(format!("{} {}", i.key_type, i.fingerprint)); + Ok(()) + }), fps) +} + +fn wait_for(seen: &Seen, needle: &str) -> bool { + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(10) { + if String::from_utf8_lossy(&seen.lock().unwrap()).contains(needle) { + return true; + } + std::thread::sleep(Duration::from_millis(50)); + } + false +} + +fn echo_server() -> u16 { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + std::thread::spawn(move || { + for s in l.incoming().flatten() { + std::thread::spawn(move || { + let mut s = s; + let mut buf = [0u8; 1024]; + while let Ok(n) = s.read(&mut buf) { + if n == 0 || s.write_all(&buf[..n]).is_err() { + break; + } + } + }); + } + }); + port +} + +fn echo_through(port: u16) -> String { + let start = Instant::now(); + loop { + if let Ok(mut s) = TcpStream::connect(("127.0.0.1", port)) { + s.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + s.write_all(b"ping").unwrap(); + let mut buf = [0u8; 4]; + if s.read_exact(&mut buf).is_ok() { + return String::from_utf8_lossy(&buf).into(); + } + } + if start.elapsed() > Duration::from_secs(5) { + return String::new(); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +#[test] +fn shell_exec_forwards_sftp() { + let Some(s) = start("main") else { return eprintln!("sshd not available; skipping") }; + let (cb, seen) = callbacks(); + let (check, fps) = accept_all(); + let c = SshClient::new(&profile(&s), cb); + c.connect(check, None, None).unwrap(); + assert!(fps.lock().unwrap()[0].starts_with("ssh-ed25519 SHA256:")); + + c.resize(120, 40).unwrap(); + c.write(b"echo nexus-$((40+2)) $COLUMNS\n").unwrap(); + assert!(wait_for(&seen, "nexus-42"), "shell output: {}", String::from_utf8_lossy(&seen.lock().unwrap())); + assert_eq!(c.run_command("echo hi").unwrap(), "hi"); + assert!(c.ping().unwrap() >= 0); + + let echo = echo_server(); + let lport = free_port(); + let rule = PortForward { id: "l".into(), direction: ForwardDirection::Local, bind_port: lport as i64, dest_host: "127.0.0.1".into(), dest_port: echo as i64, enabled: true, ..Default::default() }; + c.start_forward(rule).unwrap(); + assert_eq!(echo_through(lport), "ping"); + let rport = free_port(); + let rule = PortForward { id: "r".into(), direction: ForwardDirection::Remote, bind_host: "127.0.0.1".into(), bind_port: rport as i64, dest_host: "127.0.0.1".into(), dest_port: echo as i64, enabled: true, ..Default::default() }; + c.start_forward(rule).unwrap(); + assert_eq!(echo_through(rport), "ping"); + assert_eq!(c.active_forwards().len(), 2); + c.stop_forward("l").unwrap(); + let start = Instant::now(); + while TcpStream::connect(("127.0.0.1", lport)).is_ok() { + assert!(start.elapsed() < Duration::from_secs(2), "listener still open after stop"); + std::thread::sleep(Duration::from_millis(20)); + } + + let sf = c.sftp().unwrap(); + let local = s.dir.join("up.txt"); + std::fs::write(&local, b"sftp payload").unwrap(); + let remote = s.dir.join("remote.txt").to_string_lossy().to_string(); + sftp::upload(&sf, &local, &remote, &|_| {}).unwrap(); + let names: Vec = sftp::list(&sf, &s.dir.to_string_lossy()).unwrap().into_iter().map(|e| e.name).collect(); + assert!(names.contains(&"remote.txt".to_string())); + let back = s.dir.join("back.txt"); + sftp::download(&sf, &remote, &back, &|_| {}).unwrap(); + assert_eq!(std::fs::read(back).unwrap(), b"sftp payload"); + assert_eq!(sftp::stat(&sf, &remote).unwrap().size, 12); + c.close(); +} + +#[test] +fn close_fires_once_and_remote_exit_closes() { + let Some(s) = start("close") else { return }; + let (mut cb, _) = callbacks(); + let n = Arc::new(Mutex::new(0)); + let n2 = n.clone(); + cb.on_close = Arc::new(move || *n2.lock().unwrap() += 1); + let c = SshClient::new(&profile(&s), cb); + c.connect(accept_all().0, None, None).unwrap(); + c.write(b"exit\n").unwrap(); + let start = Instant::now(); + while *n.lock().unwrap() == 0 && start.elapsed() < Duration::from_secs(10) { + std::thread::sleep(Duration::from_millis(50)); + } + c.close(); + assert_eq!(*n.lock().unwrap(), 1); +} + +#[test] +fn rejected_host_key_reports_reason() { + let Some(s) = start("reject") else { return }; + let c = SshClient::new(&profile(&s), Callbacks::noop()); + let err = c.connect(Arc::new(|_| Err("host key rejected by user".into())), None, None).unwrap_err(); + assert!(err.to_string().contains("rejected by user"), "{err}"); +} + +#[test] +fn jump_host_chain() { + let Some(s) = start("jump") else { return }; + let mut p = profile(&s); + let key = p.ssh.as_ref().unwrap().private_key_path.clone(); + p.ssh.as_mut().unwrap().jump_hosts = vec![JumpHost { host: "127.0.0.1".into(), port: s.port as i64, username: p.username.clone(), private_key_path: key, ..Default::default() }]; + let (check, fps) = accept_all(); + let c = SshClient::new(&p, Callbacks::noop()); + c.connect(check, None, None).unwrap(); + assert_eq!(fps.lock().unwrap().len(), 2, "jump and target both verified"); + assert_eq!(c.run_command("echo via-jump").unwrap(), "via-jump"); + c.close(); +} + +#[test] +fn agent_auth_and_forwarding() { + let Some(mut s) = start("agent") else { return }; + let (Some(agent), Some(add)) = (which("ssh-agent"), which("ssh-add")) else { return }; + let sock = s.dir.join("agent.sock"); + s.agent = Some(Command::new(agent).arg("-D").arg("-a").arg(&sock).spawn().unwrap()); + let start = Instant::now(); + while !sock.exists() && start.elapsed() < Duration::from_secs(5) { + std::thread::sleep(Duration::from_millis(50)); + } + assert!(Command::new(add).arg(s.dir.join("client")).env("SSH_AUTH_SOCK", &sock).status().unwrap().success()); + // SAFETY: tests in this file don't read SSH_AUTH_SOCK concurrently except through this client. + unsafe { std::env::set_var("SSH_AUTH_SOCK", &sock) }; + let mut p = profile(&s); + let c = p.ssh.as_mut().unwrap(); + c.private_key_path.clear(); + c.agent_auth = true; + c.agent_forwarding = true; + let (cb, seen) = callbacks(); + let cl = SshClient::new(&p, cb); + cl.connect(accept_all().0, None, None).unwrap(); + cl.write(b"ssh-add -l >/dev/null && echo agent-$((1+1))-ok\n").unwrap(); + assert!(wait_for(&seen, "agent-2-ok"), "forwarded agent not usable: {}", String::from_utf8_lossy(&seen.lock().unwrap())); + cl.close(); +}