From 0e13c35f3edb8faf79265499d07581072bf228b2 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 20 May 2026 15:26:16 +0000 Subject: [PATCH] =?UTF-8?q?feat(brahman-ssh-multiplex):=20A6=20=E2=80=94?= =?UTF-8?q?=20sesi=C3=B3n=20SSH=20multiplexada=20(russh)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Envuelve russh 0.54 con una API mínima: una SshSession mantiene el Handle maestro; cada exec() concurrente abre su propio canal en paralelo sobre la misma conexión TCP (SSH multiplexa canales por diseño del protocolo). - SshConfig (host/port/user/auth/keepalive) + SshAuth (Password | Key). - SshSession::connect — config russh + keepalive + auth password o clave privada en disco; verificación de host key TOFU por default. - SshSession::exec — corre un comando en un canal nuevo, junta stdout/stderr/exit_code. - SshSession es Clone barato (comparte el Handle). Base de sandokan RemoteEngine y del Linker SSH de matilda. Compila contra russh 0.54. El test de conexión real requiere un servidor SSH (fuera del unit test). Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 512 +++++++++++++++++- Cargo.toml | 4 + .../protocol/brahman-ssh-multiplex/Cargo.toml | 14 + .../protocol/brahman-ssh-multiplex/src/lib.rs | 191 +++++++ 4 files changed, 717 insertions(+), 4 deletions(-) create mode 100644 crates/protocol/brahman-ssh-multiplex/Cargo.toml create mode 100644 crates/protocol/brahman-ssh-multiplex/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 3612284..b650a57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1244,6 +1244,29 @@ dependencies = [ "arrayvec 0.7.6", ] +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.7.9" @@ -1334,6 +1357,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base256emoji" version = "1.0.2" @@ -1364,9 +1393,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.3" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" [[package]] name = "bcrypt" @@ -1381,6 +1410,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2", +] + [[package]] name = "beef" version = "0.5.2" @@ -1819,6 +1859,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "brahman-ssh-multiplex" +version = "0.1.0" +dependencies = [ + "russh", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "bs58" version = "0.5.1" @@ -2301,6 +2350,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cobs" version = "0.3.0" @@ -2944,6 +3002,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array 0.14.7", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -3159,6 +3229,17 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "der" version = "0.7.10" @@ -3166,6 +3247,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] @@ -3250,6 +3332,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -3468,6 +3551,20 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -3486,6 +3583,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", + "rand_core 0.6.4", "serde", "sha2", "subtle", @@ -3498,6 +3596,27 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array 0.14.7", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.9" @@ -3572,6 +3691,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -3879,6 +4010,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -4101,6 +4242,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -4319,6 +4466,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -4845,6 +4993,17 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12101ecc8225ea6d675bc70263074eab6169079621c2186fe0c66590b2df9681" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.14" @@ -5028,6 +5187,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + [[package]] name = "hexf-parse" version = "0.2.1" @@ -5619,6 +5784,34 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "internal-russh-forked-ssh-key" +version = "0.6.11+upstream-0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a77eae781ed6a7709fb15b64862fcca13d886b07c7e2786f5ed34e5e2b9187" +dependencies = [ + "argon2", + "bcrypt-pbkdf", + "ecdsa", + "ed25519-dalek", + "hex", + "hmac", + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -6719,6 +6912,12 @@ dependencies = [ "digest", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.0" @@ -7766,6 +7965,7 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "rand 0.8.6", ] [[package]] @@ -8234,6 +8434,61 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + +[[package]] +name = "pageant" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb28bd89a207e5cad59072ac4b364b08459d05f90ccfbcdaa920a95857d94430" +dependencies = [ + "byteorder", + "bytes", + "delegate", + "futures", + "log", + "rand 0.8.6", + "thiserror 1.0.69", + "tokio", + "windows 0.59.0", +] + [[package]] name = "parking" version = "2.2.1" @@ -8370,6 +8625,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -8687,6 +8951,32 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes", + "cbc", + "der", + "pbkdf2", + "scrypt", + "sha2", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -8694,6 +8984,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ "der", + "pkcs5", + "rand_core 0.6.4", "spki", ] @@ -8874,6 +9166,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -9631,6 +9932,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "rgb" version = "0.8.53" @@ -9679,7 +9990,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -9774,6 +10085,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rstar" version = "0.8.4" @@ -9877,6 +10209,94 @@ dependencies = [ "smallvec", ] +[[package]] +name = "russh" +version = "0.54.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3ee9363fcf66d434d8015d9ae7d879681206981534c21bfdff8a7e34f52cca" +dependencies = [ + "aes", + "aws-lc-rs", + "base64ct", + "bitflags 2.11.1", + "block-padding", + "byteorder", + "bytes", + "cbc", + "ctr", + "curve25519-dalek", + "data-encoding", + "delegate", + "der", + "digest", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "enum_dispatch", + "flate2", + "futures", + "generic-array 0.14.7", + "getrandom 0.2.17", + "hex-literal", + "hmac", + "home", + "inout", + "internal-russh-forked-ssh-key", + "log", + "md5", + "num-bigint", + "once_cell", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2", + "pkcs1", + "pkcs5", + "pkcs8", + "rand 0.8.6", + "rand_core 0.6.4", + "rsa", + "russh-cryptovec", + "russh-util", + "sec1", + "sha1", + "sha2", + "signature", + "spki", + "ssh-encoding", + "subtle", + "thiserror 1.0.69", + "tokio", + "typenum", + "zeroize", +] + +[[package]] +name = "russh-cryptovec" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb0ed583ff0f6b4aa44c7867dd7108df01b30571ee9423e250b4cc939f8c6cf" +dependencies = [ + "libc", + "log", + "nix 0.29.0", + "ssh-encoding", + "winapi", +] + +[[package]] +name = "russh-util" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "rust-embed" version = "8.11.0" @@ -10064,7 +10484,7 @@ checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -10335,6 +10755,20 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array 0.14.7", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -10769,6 +11203,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest", "rand_core 0.6.4", ] @@ -11020,6 +11455,35 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20", + "cipher", + "ctr", + "poly1305", + "ssh-encoding", + "subtle", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "bytes", + "pem-rfc7468", + "sha2", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -12504,6 +12968,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -13208,6 +13678,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f919aee0a93304be7f62e8e5027811bbba96bcb1de84d6618be56e43f8a32a1" +dependencies = [ + "windows-core 0.59.0", + "windows-targets 0.53.5", +] + [[package]] name = "windows" version = "0.61.3" @@ -13276,6 +13756,19 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "810ce18ed2112484b0d4e15d022e5f598113e220c53e373fb31e67e21670c1ce" +dependencies = [ + "windows-implement 0.59.0", + "windows-interface 0.59.3", + "windows-result 0.3.4", + "windows-strings 0.3.1", + "windows-targets 0.53.5", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -13335,6 +13828,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-implement" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index 042dce0..e69cfa5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/protocol/brahman-net", "crates/protocol/brahman-dht", "crates/protocol/brahman-card-discovery", + "crates/protocol/brahman-ssh-multiplex", "crates/protocol/arje-card", # ============================================================ @@ -263,6 +264,9 @@ libp2p = { version = "0.56", features = ["tokio", "tcp", "noise", "yamux", "macr libp2p-stream = "=0.4.0-alpha" libp2p-allow-block-list = "0.6" +# === SSH (brahman-ssh-multiplex, sandokan RemoteEngine, matilda) === +russh = "0.54" + # === Code parsing (minga) === tree-sitter = "0.24" tree-sitter-rust = "0.23" diff --git a/crates/protocol/brahman-ssh-multiplex/Cargo.toml b/crates/protocol/brahman-ssh-multiplex/Cargo.toml new file mode 100644 index 0000000..96be637 --- /dev/null +++ b/crates/protocol/brahman-ssh-multiplex/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "brahman-ssh-multiplex" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Brahman — sesión SSH maestra con canales multiplexados (russh). Una conexión, N canales paralelos. Base de sandokan RemoteEngine y matilda." + +[dependencies] +russh = { workspace = true } +tokio = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/protocol/brahman-ssh-multiplex/src/lib.rs b/crates/protocol/brahman-ssh-multiplex/src/lib.rs new file mode 100644 index 0000000..eaa7d3a --- /dev/null +++ b/crates/protocol/brahman-ssh-multiplex/src/lib.rs @@ -0,0 +1,191 @@ +//! `brahman-ssh-multiplex` — sesión SSH maestra con canales multiplexados. +//! +//! SSH ya multiplexa canales sobre una sola conexión TCP por diseño del +//! protocolo. Este crate envuelve `russh` con una API mínima: una +//! `SshSession` mantiene el `Handle` maestro; cada `exec` concurrente +//! abre su propio canal en paralelo sobre la misma conexión. +//! +//! Lo consumen `sandokan::RemoteEngine` y el `Linker` SSH de `matilda`. +//! +//! Verificación de host key: TOFU por default (acepta la primera vez). +//! Pasá un fingerprint esperado para verificación estricta. + +#![forbid(unsafe_code)] + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +/// Método de autenticación. +#[derive(Debug, Clone)] +pub enum SshAuth { + /// Password en claro. + Password(String), + /// Clave privada en disco, con passphrase opcional. + Key { + path: PathBuf, + passphrase: Option, + }, +} + +/// Configuración de conexión. +#[derive(Debug, Clone)] +pub struct SshConfig { + pub host: String, + pub port: u16, + pub user: String, + pub auth: SshAuth, + /// Intervalo de keepalive en segundos (0 = usar default de russh). + pub keepalive_secs: u64, +} + +impl SshConfig { + /// Config con puerto 22 y keepalive de 15s. + pub fn new(host: impl Into, user: impl Into, auth: SshAuth) -> Self { + Self { + host: host.into(), + port: 22, + user: user.into(), + auth, + keepalive_secs: 15, + } + } +} + +/// Falla de una operación SSH. +#[derive(Debug, thiserror::Error)] +pub enum SshError { + #[error("conexión SSH: {0}")] + Connect(String), + #[error("autenticación SSH rechazada")] + AuthRejected, + #[error("clave privada: {0}")] + Key(String), + #[error("canal SSH: {0}")] + Channel(String), +} + +/// Salida de un comando remoto. +#[derive(Debug, Clone)] +pub struct ExecOutput { + pub stdout: Vec, + pub stderr: Vec, + pub exit_code: i32, +} + +/// Handler de cliente russh — verificación de host key. +struct ClientHandler { + /// Fingerprint esperado; `None` = TOFU (acepta cualquiera). + expected: Option>, +} + +impl russh::client::Handler for ClientHandler { + type Error = russh::Error; + + async fn check_server_key( + &mut self, + server_public_key: &russh::keys::ssh_key::PublicKey, + ) -> Result { + match &self.expected { + None => Ok(true), + Some(exp) => Ok(server_public_key.to_bytes().map(|b| &b == exp).unwrap_or(false)), + } + } +} + +/// Sesión SSH. `Clone` barato — comparte el `Handle` maestro; clones +/// abren canales paralelos sobre la misma conexión. +#[derive(Clone)] +pub struct SshSession { + handle: Arc>, +} + +impl SshSession { + /// Conecta y autentica contra el host. + pub async fn connect(config: &SshConfig) -> Result { + let mut russh_cfg = russh::client::Config::default(); + if config.keepalive_secs > 0 { + russh_cfg.keepalive_interval = Some(Duration::from_secs(config.keepalive_secs)); + } + let russh_cfg = Arc::new(russh_cfg); + let handler = ClientHandler { expected: None }; + + let mut handle = russh::client::connect( + russh_cfg, + (config.host.as_str(), config.port), + handler, + ) + .await + .map_err(|e| SshError::Connect(e.to_string()))?; + + let ok = match &config.auth { + SshAuth::Password(pw) => handle + .authenticate_password(&config.user, pw) + .await + .map_err(|e| SshError::Connect(e.to_string()))? + .success(), + SshAuth::Key { path, passphrase } => { + let key = russh::keys::load_secret_key(path, passphrase.as_deref()) + .map_err(|e| SshError::Key(e.to_string()))?; + let key = russh::keys::PrivateKeyWithHashAlg::new(Arc::new(key), None); + handle + .authenticate_publickey(&config.user, key) + .await + .map_err(|e| SshError::Connect(e.to_string()))? + .success() + } + }; + if !ok { + return Err(SshError::AuthRejected); + } + Ok(Self { handle: Arc::new(handle) }) + } + + /// Ejecuta `command` en un canal nuevo y junta su salida completa. + /// Canales concurrentes se multiplexan sobre la misma conexión. + pub async fn exec(&self, command: &str) -> Result { + let mut channel = self + .handle + .channel_open_session() + .await + .map_err(|e| SshError::Channel(e.to_string()))?; + channel + .exec(true, command) + .await + .map_err(|e| SshError::Channel(e.to_string()))?; + + let mut out = ExecOutput { + stdout: Vec::new(), + stderr: Vec::new(), + exit_code: -1, + }; + while let Some(msg) = channel.wait().await { + match msg { + russh::ChannelMsg::Data { ref data } => out.stdout.extend_from_slice(data), + russh::ChannelMsg::ExtendedData { ref data, .. } => { + out.stderr.extend_from_slice(data) + } + russh::ChannelMsg::ExitStatus { exit_status } => { + out.exit_code = exit_status as i32 + } + _ => {} + } + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_defaults_port_and_keepalive() { + let c = SshConfig::new("host.example", "user", SshAuth::Password("x".into())); + assert_eq!(c.port, 22); + assert_eq!(c.keepalive_secs, 15); + } + + // El test de conexión real necesita un servidor SSH — se hace fuera + // del unit test (ver instrucciones de prueba de matilda/sandokan). +}