From 1b863d5560d7d9b781b77d347fcee2bd8893ad17 Mon Sep 17 00:00:00 2001 From: EthanShoeDev <13422990+EthanShoeDev@users.noreply.github.com> Date: Sat, 13 Sep 2025 22:21:34 -0400 Subject: [PATCH] russh building --- .../react-native-uniffi-russh/rust/Cargo.lock | 6 + .../rust/uniffi-russh/Cargo.toml | 43 ++-- .../rust/uniffi-russh/src/lib.rs | 209 +++++++++++++----- 3 files changed, 186 insertions(+), 72 deletions(-) diff --git a/packages/react-native-uniffi-russh/rust/Cargo.lock b/packages/react-native-uniffi-russh/rust/Cargo.lock index b9c108e..aa44fec 100644 --- a/packages/react-native-uniffi-russh/rust/Cargo.lock +++ b/packages/react-native-uniffi-russh/rust/Cargo.lock @@ -2015,9 +2015,15 @@ dependencies = [ name = "uniffi-russh" version = "0.1.0" dependencies = [ + "async-trait", + "bytes", + "futures", + "once_cell", + "rand", "russh", "russh-keys", "thiserror", + "tokio", "uniffi", ] diff --git a/packages/react-native-uniffi-russh/rust/uniffi-russh/Cargo.toml b/packages/react-native-uniffi-russh/rust/uniffi-russh/Cargo.toml index bac4215..e6d4553 100644 --- a/packages/react-native-uniffi-russh/rust/uniffi-russh/Cargo.toml +++ b/packages/react-native-uniffi-russh/rust/uniffi-russh/Cargo.toml @@ -15,33 +15,36 @@ uniffi = { workspace = true, features = ["tokio"] } # Lightweight error enum derive for FFI-safe error reporting. thiserror = "1.0.64" -# # Tokio async runtime + IO bits. We use: -# # - rt-multi-thread : multithreaded scheduler -# # - macros : attribute macros (if you ever need #[tokio::test], etc.) -# # - time : timers/sleeps -# # - net : sockets; russh uses this -# # - sync : async Mutex, channels, etc. -# # - io-util : AsyncRead/Write extension traits (write_all/flush) -# tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros", "time", "net", "sync", "io-util"] } +# Tokio async runtime + IO bits. We use: +# - rt-multi-thread : multithreaded scheduler +# - macros : attribute macros (if you ever need #[tokio::test], etc.) +# - time : timers/sleeps +# - net : sockets; russh uses this +# - sync : async Mutex, channels, etc. +# - io-util : AsyncRead/Write extension traits (write_all/flush) +tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros", "time", "net", "sync", "io-util"] } -# # Common async ecosystem utilities (not strictly required by our code, but -# # frequently useful and pulled in by transitive deps). -# bytes = "1.10.1" -# futures = "0.3.31" +# Common async ecosystem utilities (not strictly required by our code, but +# frequently useful and pulled in by transitive deps). +bytes = "1.10.1" +futures = "0.3.31" -# # SSH client and keys. `russh` is the client; `russh-keys` handles key types, -# # generation, and OpenSSH (PEM) encoding/decoding. +# SSH client and keys. `russh` is the client; `russh-keys` handles key types, +# generation, and OpenSSH (PEM) encoding/decoding. +# By default russh pulls in aws-lc which requires CMake toolchain. 'ring' is a rust based alternative. +# 'flate2' is a compression library. +# 'rsa' is a RSA encryption library. russh = { version = "0.54.3", default-features = false, features = ["ring", "flate2", "rsa"] } russh-keys = "0.49.2" -# # Secure RNG for key generation (OsRng). -# rand = "0.8" +# Secure RNG for key generation (OsRng). +rand = "0.8" -# # Handy but currently optional; you can remove it if unused. -# once_cell = "1.21.3" +# Handy but currently optional; you can remove it if unused. +once_cell = "1.21.3" -# # Optional helper for async trait impls; safe to keep even if unused. -# async-trait = "0.1" +# Optional helper for async trait impls; safe to keep even if unused. +async-trait = "0.1" # ────────────────────────────────────────────────────────────────────────────── # Build-time codegen for UniFFI diff --git a/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs b/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs index 121e2d9..b8c4242 100644 --- a/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs +++ b/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs @@ -1,25 +1,31 @@ -// lib.rs (or your crate root) - +use std::fmt; use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; -use std::fmt; +use rand::rngs::OsRng; +use thiserror::Error; +use tokio::io::AsyncWriteExt; +use tokio::sync::Mutex as AsyncMutex; + +use russh::{self, client, ChannelMsg, Disconnect}; +use russh::client::{Config as ClientConfig, Handle as ClientHandle}; +use russh_keys::{Algorithm as KeyAlgorithm, EcdsaCurve, PrivateKey}; +use russh_keys::ssh_key::{self, LineEnding}; -// You must call this once in your crate uniffi::setup_scaffolding!(); -/// ----- Types that mirror your TS schema ----- +/// ---------- Types mirroring your TS shape ---------- #[derive(Debug, Clone, PartialEq, uniffi::Enum)] pub enum Security { Password { password: String }, - Key { key_id: String }, + Key { key_id: String }, // left unimplemented in connect() for now } #[derive(Debug, Clone, PartialEq, uniffi::Record)] pub struct ConnectionDetails { pub host: String, - pub port: u16, // maps cleanly to JS number + pub port: u16, pub username: String, pub security: Security, } @@ -34,27 +40,51 @@ pub enum SSHConnectionStatus { ShellDisconnected, } -#[derive(Debug, thiserror::Error, uniffi::Error)] +#[derive(Debug, Error, uniffi::Error)] pub enum SshError { - #[error("The connection is not available.")] + #[error("Disconnected")] Disconnected, - #[error("Unsupported key type.")] + + #[error("Unsupported key type")] UnsupportedKeyType, + + #[error("Auth failed: {0}")] + Auth(String), + + #[error("russh error: {0}")] + Russh(String), + + #[error("russh-keys error: {0}")] + RusshKeys(String), } -/// Callback for status changes: onStatusChange(status) +// Allow `?` on various fallible calls: +impl From for SshError { + fn from(e: russh::Error) -> Self { SshError::Russh(e.to_string()) } +} +impl From for SshError { + fn from(e: russh_keys::Error) -> Self { SshError::RusshKeys(e.to_string()) } +} +impl From for SshError { + fn from(e: ssh_key::Error) -> Self { SshError::RusshKeys(e.to_string()) } +} +impl From for SshError { + fn from(e: std::io::Error) -> Self { SshError::Russh(e.to_string()) } +} + +/// Status callback from Rust -> JS #[uniffi::export(with_foreign)] pub trait StatusListener: Send + Sync { fn on_status_change(&self, status: SSHConnectionStatus); } -/// Data listener: on_data(ArrayBuffer) +/// Data callback from Rust -> JS (stdout/stderr chunks unified) #[uniffi::export(with_foreign)] pub trait DataListener: Send + Sync { fn on_data(&self, data: Vec); } -/// Key types +/// Key types for generation #[derive(Debug, Clone, Copy, PartialEq, uniffi::Enum)] pub enum KeyType { Rsa, @@ -63,7 +93,7 @@ pub enum KeyType { Ed448, } -/// ----- SSHConnection object ----- +/// ---------- Connection object ---------- #[derive(uniffi::Object)] pub struct SSHConnection { @@ -71,108 +101,183 @@ pub struct SSHConnection { session_id: String, created_at_ms: f64, established_at_ms: f64, + listeners: Mutex>>, + + // write side for sending stdin to the shell + writer: AsyncMutex>, + // handle is kept so we can call disconnect + handle: AsyncMutex>, } impl fmt::Debug for SSHConnection { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let listeners_len = self.listeners.lock().map(|v| v.len()).unwrap_or(0); f.debug_struct("SSHConnection") .field("connection_details", &self.connection_details) .field("session_id", &self.session_id) .field("created_at_ms", &self.created_at_ms) .field("established_at_ms", &self.established_at_ms) - // Don’t try to print the trait objects; just show count or omit entirely. - .field( - "listeners_len", - &self.listeners.lock().map(|v| v.len()).unwrap_or(0), - ) + .field("listeners_len", &listeners_len) .finish() } } -#[uniffi::export] +/// Put the UniFFI export attribute on the IMPL BLOCK (not individual methods). +#[uniffi::export(async_runtime = "tokio")] impl SSHConnection { - // Read-only “properties” via getters (JS sees methods: connectionDetails(), etc.) pub fn connection_details(&self) -> ConnectionDetails { self.connection_details.clone() } - pub fn session_id(&self) -> String { self.session_id.clone() } - pub fn created_at_ms(&self) -> f64 { self.created_at_ms } - pub fn established_at_ms(&self) -> f64 { self.established_at_ms } - /// Register a data listener (no-op storage for now; we keep it so you can emit later) pub fn add_data_listener(&self, listener: Arc) { - let mut vec = self.listeners.lock().unwrap(); - vec.push(listener); - // If you want to prove it works, you could immediately emit a hello packet: - // if let Some(last) = vec.last() { - // last.on_data(b"hello-from-rust".to_vec()); - // } + self.listeners.lock().unwrap().push(listener); } - /// Send bytes to the session (dummy async) - pub async fn send_data(&self, _data: Vec) -> Result<(), SshError> { - // No real transport yet; just succeed. + /// Send bytes to the remote shell (stdin). + pub async fn send_data(&self, data: Vec) -> Result<(), SshError> { + // ChannelWriteHalf isn’t AsyncWrite. Use a one-shot writer facade. + let writer = self.writer.lock().await; + let mut stream = writer.make_writer(); + stream.write_all(&data).await?; + stream.flush().await?; Ok(()) } - /// Disconnect (dummy async) + /// Graceful disconnect. pub async fn disconnect(&self) -> Result<(), SshError> { - // No real transport yet; just succeed. + let handle = self.handle.lock().await; + handle.disconnect(Disconnect::ByApplication, "bye", "").await?; Ok(()) } } -/// ----- Top-level API surface ----- +/// Minimal client::Handler. +struct NoopHandler; +impl client::Handler for NoopHandler { + type Error = SshError; + // No overrides needed; defaults are fine. +} -/// Connect and emit a few status transitions. Returns a ready SSHConnection. -/// Kept async so JS sees a Promise. -#[uniffi::export] +/// ---------- Top-level API ---------- + +#[uniffi::export(async_runtime = "tokio")] pub async fn connect( details: ConnectionDetails, status_listener: Arc, ) -> Result, SshError> { - // Fire a short, synchronous sequence of status updates (no sleeps needed for now) status_listener.on_status_change(SSHConnectionStatus::TcpConnecting); + + // connect(config, addr, handler) + let cfg = Arc::new(ClientConfig::default()); + let addr = format!("{}:{}", details.host, details.port); + let mut handle: ClientHandle = client::connect(cfg, addr, NoopHandler).await?; + status_listener.on_status_change(SSHConnectionStatus::TcpConnected); + + // authenticate + let auth = match &details.security { + Security::Password { password } => { + handle.authenticate_password(details.username.clone(), password.clone()).await? + } + Security::Key { .. } => { + return Err(SshError::UnsupportedKeyType); + } + }; + match auth { + client::AuthResult::Success => {} + other => return Err(SshError::Auth(format!("{other:?}"))), + } + status_listener.on_status_change(SSHConnectionStatus::ShellConnecting); + + // open session, request pty, launch shell + let ch = handle.channel_open_session().await?; + ch.request_pty(true, "xterm-256color", 80, 24, 0, 0, &[]).await?; + ch.request_shell(true).await?; + status_listener.on_status_change(SSHConnectionStatus::ShellConnected); - let now = now_ms(); + // split for read/write + let (mut reader, writer) = ch.split(); + // build the connection object + let now = now_ms(); let conn = Arc::new(SSHConnection { - connection_details: details, - session_id: "SESSION-STATIC-0001".to_string(), + connection_details: details.clone(), + session_id: format!("session-{}", now as u64), created_at_ms: now, established_at_ms: now, listeners: Mutex::new(Vec::new()), + writer: AsyncMutex::new(writer), + handle: AsyncMutex::new(handle), + }); + + // background read loop: forward stdout/stderr chunks + let weak = Arc::downgrade(&conn); + tokio::spawn(async move { + loop { + match reader.wait().await { + Some(ChannelMsg::Data { data }) => { + if let Some(conn) = weak.upgrade() { + let listeners = conn.listeners.lock().unwrap().clone(); + let buf = data.to_vec(); + for l in listeners { l.on_data(buf.clone()); } + } else { break; } + } + Some(ChannelMsg::ExtendedData { data, .. }) => { + if let Some(conn) = weak.upgrade() { + let listeners = conn.listeners.lock().unwrap().clone(); + let buf = data.to_vec(); + for l in listeners { l.on_data(buf.clone()); } + } else { break; } + } + Some(ChannelMsg::ExitStatus { .. }) => { + // Optionally store/report exit status here. + } + Some(ChannelMsg::Close) | None => { + if let Some(_conn) = weak.upgrade() { + status_listener.on_status_change(SSHConnectionStatus::ShellDisconnected); + status_listener.on_status_change(SSHConnectionStatus::TcpDisconnected); + } + break; + } + _ => { /* ignore others for now */ } + } + } }); Ok(conn) } -/// Generate a key pair as a PEM string (dummy content) -#[uniffi::export] +#[uniffi::export(async_runtime = "tokio")] pub async fn generate_key_pair(key_type: KeyType) -> Result { - let pem = match key_type { - KeyType::Rsa => "-----BEGIN RSA PRIVATE KEY-----\n...dummy...\n-----END RSA PRIVATE KEY-----", - KeyType::Ecdsa => "-----BEGIN EC PRIVATE KEY-----\n...dummy...\n-----END EC PRIVATE KEY-----", - KeyType::Ed25519 => "-----BEGIN OPENSSH PRIVATE KEY-----\n...dummy-ed25519...\n-----END OPENSSH PRIVATE KEY-----", - KeyType::Ed448 => "-----BEGIN OPENSSH PRIVATE KEY-----\n...dummy-ed448...\n-----END OPENSSH PRIVATE KEY-----", + let mut rng = OsRng; + + let key = match key_type { + KeyType::Rsa => PrivateKey::random(&mut rng, KeyAlgorithm::Rsa { hash: None })?, + KeyType::Ecdsa => PrivateKey::random( + &mut rng, + KeyAlgorithm::Ecdsa { curve: EcdsaCurve::NistP256 }, + )?, + KeyType::Ed25519 => PrivateKey::random(&mut rng, KeyAlgorithm::Ed25519)?, + KeyType::Ed448 => return Err(SshError::UnsupportedKeyType), }; + + // OpenSSH PEM, LF endings; returns Zeroizing + let pem = key.to_openssh(LineEnding::LF)?; Ok(pem.to_string()) } -/// Helper fn now_ms() -> f64 { let d = SystemTime::now() .duration_since(UNIX_EPOCH)