diff --git a/packages/react-native-uniffi-russh/package.json b/packages/react-native-uniffi-russh/package.json index 60f29f2..70d80b2 100644 --- a/packages/react-native-uniffi-russh/package.json +++ b/packages/react-native-uniffi-russh/package.json @@ -41,7 +41,8 @@ "typecheck": "tsc", "lint": "eslint \"**/*.{js,ts,tsx}\"", "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", - "release": "release-it --only-version" + "release": "release-it --only-version", + "lint:rust": "cd rust/uniffi-russh && just lint" }, "keywords": [ "react-native", diff --git a/packages/react-native-uniffi-russh/rust/uniffi-russh/README.md b/packages/react-native-uniffi-russh/rust/uniffi-russh/README.md new file mode 100644 index 0000000..1601576 --- /dev/null +++ b/packages/react-native-uniffi-russh/rust/uniffi-russh/README.md @@ -0,0 +1,261 @@ +# uniffi-russh + +UniFFI bindings around [`russh`](https://github.com/Eugeny/russh), designed to make a **safe, async SSH client** usable from JavaScript/TypeScript — especially **React Native (Hermes/JSI)** — while staying close to russh’s model. + +This crate hosts the Rust code and UniFFI exports; the React Native module that consumes it lives in `@fressh/react-native-uniffi-russh`. + +--- + +## Why this exists + +* The React Native SSH landscape is thin and often not truly async. +* We want a **thin, principled wrapper** over russh that: + + * exposes **connection + channel (shell) primitives**; + * streams **binary data chunks** (stdout/stderr) to JS; + * reports **connection state transitions** (TCP and shell); + * provides **keypair generation** via `russh-keys`; + * compiles cleanly for **Android (NDK)** and other targets with **Tokio**. + +--- + +## High-level design + +* **Rust core** (this crate): Tokio-based client using `russh` and `russh-keys`. Exposes functions/objects via UniFFI macros. +* **FFI surface** (stable across languages): + + * `connect(details, connect_status_listener?) -> SSHConnection` + * `SSHConnection.start_shell(pty, shell_status_listener?) -> channel_id` + * `SSHConnection.send_data(bytes)` + * `SSHConnection.exec(command)` + * `SSHConnection.close_shell()` + * `SSHConnection.disconnect()` + * `generate_key_pair(key_type) -> String (OpenSSH PEM)` +* **Events**: + + * `connect_status_listener`: `TcpConnecting` → `TcpConnected` → `TcpDisconnected` + * `shell_status_listener`: `ShellConnecting` → `ShellConnected` → `ShellDisconnected` +* **Streaming**: + + * Register multiple `ChannelListener`s with `SSHConnection.add_channel_listener/remove_channel_listener`. + * Each listener gets `on_data(Vec)` for stdout/stderr frames (no OSC parsing; renderer decides). + +We intentionally keep **shell start** separate from **connect** so app UIs can render intermediate states and so advanced consumers can open multiple channels. + +--- + +## What’s exported (conceptual API) + +### Records & Enums + +```rust +// Authentication choice +enum Security { + Password { password: String }, + Key { key_id: String }, // (planned for auth; keygen is available) +} + +// Connection details +record ConnectionDetails { + host: String, + port: u16, + username: String, + security: Security, +} + +// Status events +enum SSHConnectionStatus { + TcpConnecting, + TcpConnected, + TcpDisconnected, + ShellConnecting, + ShellConnected, + ShellDisconnected, +} + +// PTY selection (maps to SSH pty term names) +enum PtyType { Vanilla, Vt100, Vt102, Vt220, Ansi, Xterm } + +// Key generation +enum KeyType { Rsa, Ecdsa, Ed25519, Ed448 /* unsupported */ } +``` + +### Objects & Traits + +```rust +// Rust -> JS callback traits +trait StatusListener { fn on_status_change(status: SSHConnectionStatus); } +trait ChannelListener { fn on_data(data: Vec); } + +// Main connection object +object SSHConnection { + // read-only getters + fn connection_details() -> ConnectionDetails; + fn created_at_ms() -> f64; + fn tcp_established_at_ms() -> f64; + + // channel streaming + fn add_channel_listener(listener: Arc); + fn remove_channel_listener(listener: Arc); + + // shell lifecycle (optional; call only if you want a shell) + async fn start_shell(pty: PtyType, shell_status: Option>) -> u32; // channel_id + async fn close_shell(); + + // writing + async fn send_data(bytes: Vec); + async fn exec(command: String); + + // connection lifecycle + async fn disconnect(); +} + +// top-level +async fn connect(details: ConnectionDetails, connect_status: Option>) + -> Arc; + +async fn generate_key_pair(key_type: KeyType) -> String; // OpenSSH PEM +``` + +### Error model + +All fallible functions return `Result<_, SshError>`. We map errors from `russh`, `russh-keys`, `ssh-key`, and `std::io` into a single enum that UniFFI can surface to JS. + +--- + +## React Native (TypeScript) usage + +> This assumes you’re using the companion package `@fressh/react-native-uniffi-russh` which wires this crate through UniFFI + JSI for React Native. + +```ts +import { + connect, + generateKeyPair, + PtyType, + type ConnectionDetails, + type SSHConnectionStatus, + type SSHConnection, +} from '@fressh/react-native-uniffi-russh'; + +const details: ConnectionDetails = { + host: 'example.com', + port: 22, + username: 'me', + security: { Password: { password: 'secret' } }, +}; + +const connStatus = { + on_status_change(status: SSHConnectionStatus) { + console.log('connect status:', status); + }, +}; + +const shellStatus = { + on_status_change(status: SSHConnectionStatus) { + console.log('shell status:', status); + }, +}; + +const channelListener = { + on_data(data: Uint8Array) { + // bytes → feed your terminal emulator / decoder + console.log('got', data.length, 'bytes'); + }, +}; + +(async () => { + const conn: SSHConnection = await connect(details, connStatus); + + // streaming callbacks + conn.add_channel_listener(channelListener); + + // optionally start a shell + const chanId = await conn.start_shell(PtyType.Xterm, shellStatus); + console.log('shell channel id', chanId); + + // write to the shell + await conn.send_data(new TextEncoder().encode('echo hello\n')); + + // or run a one-shot exec request + await conn.exec('uname -a'); + + // later… + await conn.close_shell(); + await conn.disconnect(); +})(); +``` + +Key generation: + +```ts +const pem = await generateKeyPair('Ed25519'); // string (OpenSSH format) +``` + +> **Note:** For now, *password* authentication is implemented. Public-key auth is on the roadmap. Key generation works today. + +--- + +## Building & platforms + +* **Tokio** runtime (multi-thread) is used for all async. +* This crate is meant to be consumed via **UniFFI**: + + * For React Native, we use `uniffi-bindgen-react-native` (UBRN) to generate the TypeScript/JS glue and JSI/Hermes bindings. +* **Android:** Requires NDK; our `russh` dependency is configured to use the `ring` crypto backend (no CMake-heavy `aws-lc`). +* **iOS / Desktop:** The core crate is platform-agnostic; UBRN/JSI wiring is what determines where you can use it from JS. + +--- + +## Staying close to russh + +We intentionally keep the shape near russh’s primitives: + +* **Connect** returns a `SSHConnection` backed by a `russh::client::Handle`. +* **Shell** is an **optional channel** you can start on demand (`start_shell`), with a PTY term (e.g., `xterm-256color`). +* **Multiple channels** are possible (russh supports it). The exported surface focuses on the **typical single shell** flow. Advanced multiplexing is a natural extension. + +What we **don’t** do here: + +* Terminal emulation or OSC parsing (leave that to your renderer). +* SFTP (out of scope for this crate). +* Key agent / forwarding / port forwarding (not yet). + +--- + +## Feature flags / deps (summary) + +* `tokio` (rt-multi-thread, macros, time, net, sync) +* `russh` with the **`ring`** crypto backend (to avoid `aws-lc-sys`/CMake churn on Android) +* `russh-keys` for key handling + PEM export +* `thiserror` for error ergonomics +* `rand` (keygen), `bytes`, `futures`, `once_cell` as needed +* `uniffi`/`uniffi_macros` for the FFI surface + +--- + +## Roadmap + +* Public key authentication (using `russh-keys` and server’s supported hash algs) +* Channel multiplexing helpers (e.g., open arbitrary exec channels alongside shell) +* Port forwarding +* Optional OSC133-ish event surfacing (if we later add a parser utility crate) +* SFTP (in a separate crate) + +--- + +## Contributing + +We aim for: + +* **Predictable FFI surface** (stable enums/records) +* **Tokio-friendly** code (no blocking in async) +* **Clippy-clean** builds (`-D warnings`) +* **Thin abstraction** over russh (no surprises) + +PRs welcome — especially improvements to error mapping and additional authentication modes. + +--- + +## License + +Same as russh unless otherwise noted in this repository. (Check the repo root for the definitive license file.) 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 b8c4242..75f21c0 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 @@ -4,7 +4,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; use rand::rngs::OsRng; use thiserror::Error; -use tokio::io::AsyncWriteExt; use tokio::sync::Mutex as AsyncMutex; use russh::{self, client, ChannelMsg, Disconnect}; @@ -14,12 +13,12 @@ use russh_keys::ssh_key::{self, LineEnding}; uniffi::setup_scaffolding!(); -/// ---------- Types mirroring your TS shape ---------- +/// ---------- Types ---------- #[derive(Debug, Clone, PartialEq, uniffi::Enum)] pub enum Security { Password { password: String }, - Key { key_id: String }, // left unimplemented in connect() for now + Key { key_id: String }, // (key-based auth can be wired later) } #[derive(Debug, Clone, PartialEq, uniffi::Record)] @@ -40,25 +39,46 @@ pub enum SSHConnectionStatus { ShellDisconnected, } +/// PTY types similar to the old TS lib (plus xterm-256color, which is common). +#[derive(Debug, Clone, Copy, PartialEq, uniffi::Enum)] +pub enum PtyType { + Vanilla, + Vt100, + Vt102, + Vt220, + Ansi, + Xterm, + Xterm256, +} +impl PtyType { + fn as_ssh_name(self) -> &'static str { + match self { + PtyType::Vanilla => "vanilla", + PtyType::Vt100 => "vt100", + PtyType::Vt102 => "vt102", + PtyType::Vt220 => "vt220", + PtyType::Ansi => "ansi", + PtyType::Xterm => "xterm", + PtyType::Xterm256 => "xterm-256color", + } + } +} + #[derive(Debug, Error, uniffi::Error)] pub enum SshError { #[error("Disconnected")] Disconnected, - #[error("Unsupported key type")] UnsupportedKeyType, - #[error("Auth failed: {0}")] Auth(String), - + #[error("Shell already running")] + ShellAlreadyRunning, #[error("russh error: {0}")] Russh(String), - #[error("russh-keys error: {0}")] RusshKeys(String), } - -// Allow `?` on various fallible calls: impl From for SshError { fn from(e: russh::Error) -> Self { SshError::Russh(e.to_string()) } } @@ -68,19 +88,16 @@ impl From for SshError { 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 +/// Status callback (used separately by connect and by start_shell) #[uniffi::export(with_foreign)] pub trait StatusListener: Send + Sync { fn on_status_change(&self, status: SSHConnectionStatus); } -/// Data callback from Rust -> JS (stdout/stderr chunks unified) +/// Channel data callback (stdout/stderr unified) #[uniffi::export(with_foreign)] -pub trait DataListener: Send + Sync { +pub trait ChannelListener: Send + Sync { fn on_data(&self, data: Vec); } @@ -93,21 +110,30 @@ pub enum KeyType { Ed448, } -/// ---------- Connection object ---------- +/// ---------- Connection object (no shell until start_shell) ---------- #[derive(uniffi::Object)] pub struct SSHConnection { connection_details: ConnectionDetails, - session_id: String, created_at_ms: f64, - established_at_ms: f64, + tcp_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>, + + // Shell state (one active shell per connection by design). + shell: AsyncMutex>, + + // Data listeners for whatever shell is active. + listeners: Arc>>>, +} + +struct ShellState { + channel_id: u32, + writer: russh::ChannelWriteHalf, + // We keep the reader task to allow cancellation on close. + reader_task: tokio::task::JoinHandle<()>, + // Only used for Shell* statuses. + shell_status_listener: Option>, } impl fmt::Debug for SSHConnection { @@ -115,57 +141,148 @@ impl fmt::Debug for SSHConnection { 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) + .field("tcp_established_at_ms", &self.tcp_established_at_ms) .field("listeners_len", &listeners_len) .finish() } } -/// Put the UniFFI export attribute on the IMPL BLOCK (not individual methods). -#[uniffi::export(async_runtime = "tokio")] -impl SSHConnection { - 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 - } - - pub fn add_data_listener(&self, listener: Arc) { - self.listeners.lock().unwrap().push(listener); - } - - /// 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(()) - } - - /// Graceful disconnect. - pub async fn disconnect(&self) -> Result<(), SshError> { - let handle = self.handle.lock().await; - handle.disconnect(Disconnect::ByApplication, "bye", "").await?; - Ok(()) - } -} - /// Minimal client::Handler. struct NoopHandler; impl client::Handler for NoopHandler { type Error = SshError; - // No overrides needed; defaults are fine. +} + +/// ---------- Methods ---------- + +#[uniffi::export(async_runtime = "tokio")] +impl SSHConnection { + pub fn connection_details(&self) -> ConnectionDetails { + self.connection_details.clone() + } + pub fn created_at_ms(&self) -> f64 { + self.created_at_ms + } + pub fn tcp_established_at_ms(&self) -> f64 { + self.tcp_established_at_ms + } + + /// Return current shell channel id, if any. + pub async fn channel_id(&self) -> Option { + self.shell.lock().await.as_ref().map(|s| s.channel_id) + } + + pub fn add_channel_listener(&self, listener: Arc) { + self.listeners.lock().unwrap().push(listener); + } + pub fn remove_channel_listener(&self, listener: Arc) { + if let Ok(mut v) = self.listeners.lock() { + v.retain(|l| !Arc::ptr_eq(l, &listener)); + } + } + + /// Start a shell with the given PTY. Emits only Shell* statuses via `shell_status_listener`. + pub async fn start_shell( + &self, + pty: PtyType, + shell_status_listener: Option>, + ) -> Result { + // Prevent double-start (safe default). + if self.shell.lock().await.is_some() { + return Err(SshError::ShellAlreadyRunning); + } + + if let Some(sl) = shell_status_listener.as_ref() { + sl.on_status_change(SSHConnectionStatus::ShellConnecting); + } + + // Open session channel. + let handle = self.handle.lock().await; + let ch = handle.channel_open_session().await?; + let channel_id: u32 = ch.id().into(); + + // Request PTY & shell. + ch.request_pty(true, pty.as_ssh_name(), 80, 24, 0, 0, &[]).await?; + ch.request_shell(true).await?; + + // Split for read/write; spawn reader. + let (mut reader, writer) = ch.split(); + let listeners = self.listeners.clone(); + let shell_listener_for_task = shell_status_listener.clone(); + let reader_task = tokio::spawn(async move { + loop { + match reader.wait().await { + Some(ChannelMsg::Data { data }) => { + if let Ok(cl) = listeners.lock() { + let snapshot = cl.clone(); + let buf = data.to_vec(); + for l in snapshot { l.on_data(buf.clone()); } + } + } + Some(ChannelMsg::ExtendedData { data, .. }) => { + if let Ok(cl) = listeners.lock() { + let snapshot = cl.clone(); + let buf = data.to_vec(); + for l in snapshot { l.on_data(buf.clone()); } + } + } + Some(ChannelMsg::Close) | None => { + if let Some(sl) = shell_listener_for_task.as_ref() { + sl.on_status_change(SSHConnectionStatus::ShellDisconnected); + } + break; + } + _ => { /* ignore others */ } + } + } + }); + + *self.shell.lock().await = Some(ShellState { + channel_id, + writer, + reader_task, + shell_status_listener, + }); + + // Report ShellConnected. + if let Some(sl) = self.shell.lock().await.as_ref().and_then(|s| s.shell_status_listener.clone()) { + sl.on_status_change(SSHConnectionStatus::ShellConnected); + } + + Ok(channel_id) + } + + /// Send bytes to the active shell (stdin). + pub async fn send_data(&self, data: Vec) -> Result<(), SshError> { + let mut guard = self.shell.lock().await; + let state = guard.as_mut().ok_or(SshError::Disconnected)?; + state.writer.data(&data[..]).await?; + Ok(()) + } + + /// Close the active shell channel (if any) and stop its reader task. + pub async fn close_shell(&self) -> Result<(), SshError> { + if let Some(state) = self.shell.lock().await.take() { + // Try to close channel gracefully; ignore error. + state.writer.close().await.ok(); + state.reader_task.abort(); + if let Some(sl) = state.shell_status_listener { + sl.on_status_change(SSHConnectionStatus::ShellDisconnected); + } + } + Ok(()) + } + + /// Disconnect TCP (also closes any active shell). + pub async fn disconnect(&self) -> Result<(), SshError> { + // Close shell first. + let _ = self.close_shell().await; + + let h = self.handle.lock().await; + h.disconnect(Disconnect::ByApplication, "bye", "").await?; + Ok(()) + } } /// ---------- Top-level API ---------- @@ -173,18 +290,22 @@ impl client::Handler for NoopHandler { #[uniffi::export(async_runtime = "tokio")] pub async fn connect( details: ConnectionDetails, - status_listener: Arc, + connect_status_listener: Option>, ) -> Result, SshError> { - status_listener.on_status_change(SSHConnectionStatus::TcpConnecting); + if let Some(sl) = connect_status_listener.as_ref() { + sl.on_status_change(SSHConnectionStatus::TcpConnecting); + } - // connect(config, addr, handler) + // TCP 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); + if let Some(sl) = connect_status_listener.as_ref() { + sl.on_status_change(SSHConnectionStatus::TcpConnected); + } - // authenticate + // Auth let auth = match &details.security { Security::Password { password } => { handle.authenticate_password(details.username.clone(), password.clone()).await? @@ -198,71 +319,20 @@ pub async fn connect( 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); - - // 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.clone(), - session_id: format!("session-{}", now as u64), + Ok(Arc::new(SSHConnection { + connection_details: details, created_at_ms: now, - established_at_ms: now, - listeners: Mutex::new(Vec::new()), - writer: AsyncMutex::new(writer), + tcp_established_at_ms: now, 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) + shell: AsyncMutex::new(None), + listeners: Arc::new(Mutex::new(Vec::new())), + })) } #[uniffi::export(async_runtime = "tokio")] pub async fn generate_key_pair(key_type: KeyType) -> Result { let mut rng = OsRng; - let key = match key_type { KeyType::Rsa => PrivateKey::random(&mut rng, KeyAlgorithm::Rsa { hash: None })?, KeyType::Ecdsa => PrivateKey::random( @@ -272,9 +342,7 @@ pub async fn generate_key_pair(key_type: KeyType) -> Result { 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)?; + let pem = key.to_openssh(LineEnding::LF)?; // Zeroizing Ok(pem.to_string()) } diff --git a/packages/react-native-uniffi-russh/turbo.json b/packages/react-native-uniffi-russh/turbo.json index d3be725..a4c7ba2 100644 --- a/packages/react-native-uniffi-russh/turbo.json +++ b/packages/react-native-uniffi-russh/turbo.json @@ -2,11 +2,16 @@ "$schema": "https://turbo.build/schema.json", "tasks": { "build": { - "outputs": ["lib/**", "android/**", "ios/**", "cpp/**", "src/**"] + "outputs": ["lib/**", "android/**", "ios/**", "cpp/**", "src/**"], + "dependsOn": ["lint:rust"] }, "typecheck": { "dependsOn": ["build"] - } + }, + "lint": { + "with": ["typecheck", "lint:rust"] + }, + "lint:rust": {} }, "extends": ["//"] }