russh building

This commit is contained in:
EthanShoeDev
2025-09-13 22:21:34 -04:00
parent 4ca74f4ea2
commit 1b863d5560
3 changed files with 186 additions and 72 deletions

View File

@@ -2015,9 +2015,15 @@ dependencies = [
name = "uniffi-russh" name = "uniffi-russh"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait",
"bytes",
"futures",
"once_cell",
"rand",
"russh", "russh",
"russh-keys", "russh-keys",
"thiserror", "thiserror",
"tokio",
"uniffi", "uniffi",
] ]

View File

@@ -15,33 +15,36 @@ uniffi = { workspace = true, features = ["tokio"] }
# Lightweight error enum derive for FFI-safe error reporting. # Lightweight error enum derive for FFI-safe error reporting.
thiserror = "1.0.64" thiserror = "1.0.64"
# # Tokio async runtime + IO bits. We use: # Tokio async runtime + IO bits. We use:
# # - rt-multi-thread : multithreaded scheduler # - rt-multi-thread : multithreaded scheduler
# # - macros : attribute macros (if you ever need #[tokio::test], etc.) # - macros : attribute macros (if you ever need #[tokio::test], etc.)
# # - time : timers/sleeps # - time : timers/sleeps
# # - net : sockets; russh uses this # - net : sockets; russh uses this
# # - sync : async Mutex, channels, etc. # - sync : async Mutex, channels, etc.
# # - io-util : AsyncRead/Write extension traits (write_all/flush) # - 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 = { 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 # Common async ecosystem utilities (not strictly required by our code, but
# # frequently useful and pulled in by transitive deps). # frequently useful and pulled in by transitive deps).
# bytes = "1.10.1" bytes = "1.10.1"
# futures = "0.3.31" futures = "0.3.31"
# # SSH client and keys. `russh` is the client; `russh-keys` handles key types, # SSH client and keys. `russh` is the client; `russh-keys` handles key types,
# # generation, and OpenSSH (PEM) encoding/decoding. # 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 = { version = "0.54.3", default-features = false, features = ["ring", "flate2", "rsa"] }
russh-keys = "0.49.2" russh-keys = "0.49.2"
# # Secure RNG for key generation (OsRng). # Secure RNG for key generation (OsRng).
# rand = "0.8" rand = "0.8"
# # Handy but currently optional; you can remove it if unused. # Handy but currently optional; you can remove it if unused.
# once_cell = "1.21.3" once_cell = "1.21.3"
# # Optional helper for async trait impls; safe to keep even if unused. # Optional helper for async trait impls; safe to keep even if unused.
# async-trait = "0.1" async-trait = "0.1"
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# Build-time codegen for UniFFI # Build-time codegen for UniFFI

View File

@@ -1,25 +1,31 @@
// lib.rs (or your crate root) use std::fmt;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH}; 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!(); uniffi::setup_scaffolding!();
/// ----- Types that mirror your TS schema ----- /// ---------- Types mirroring your TS shape ----------
#[derive(Debug, Clone, PartialEq, uniffi::Enum)] #[derive(Debug, Clone, PartialEq, uniffi::Enum)]
pub enum Security { pub enum Security {
Password { password: String }, Password { password: String },
Key { key_id: String }, Key { key_id: String }, // left unimplemented in connect() for now
} }
#[derive(Debug, Clone, PartialEq, uniffi::Record)] #[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct ConnectionDetails { pub struct ConnectionDetails {
pub host: String, pub host: String,
pub port: u16, // maps cleanly to JS number pub port: u16,
pub username: String, pub username: String,
pub security: Security, pub security: Security,
} }
@@ -34,27 +40,51 @@ pub enum SSHConnectionStatus {
ShellDisconnected, ShellDisconnected,
} }
#[derive(Debug, thiserror::Error, uniffi::Error)] #[derive(Debug, Error, uniffi::Error)]
pub enum SshError { pub enum SshError {
#[error("The connection is not available.")] #[error("Disconnected")]
Disconnected, Disconnected,
#[error("Unsupported key type.")]
#[error("Unsupported key type")]
UnsupportedKeyType, 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<russh::Error> for SshError {
fn from(e: russh::Error) -> Self { SshError::Russh(e.to_string()) }
}
impl From<russh_keys::Error> for SshError {
fn from(e: russh_keys::Error) -> Self { SshError::RusshKeys(e.to_string()) }
}
impl From<ssh_key::Error> for SshError {
fn from(e: ssh_key::Error) -> Self { SshError::RusshKeys(e.to_string()) }
}
impl From<std::io::Error> for SshError {
fn from(e: std::io::Error) -> Self { SshError::Russh(e.to_string()) }
}
/// Status callback from Rust -> JS
#[uniffi::export(with_foreign)] #[uniffi::export(with_foreign)]
pub trait StatusListener: Send + Sync { pub trait StatusListener: Send + Sync {
fn on_status_change(&self, status: SSHConnectionStatus); 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)] #[uniffi::export(with_foreign)]
pub trait DataListener: Send + Sync { pub trait DataListener: Send + Sync {
fn on_data(&self, data: Vec<u8>); fn on_data(&self, data: Vec<u8>);
} }
/// Key types /// Key types for generation
#[derive(Debug, Clone, Copy, PartialEq, uniffi::Enum)] #[derive(Debug, Clone, Copy, PartialEq, uniffi::Enum)]
pub enum KeyType { pub enum KeyType {
Rsa, Rsa,
@@ -63,7 +93,7 @@ pub enum KeyType {
Ed448, Ed448,
} }
/// ----- SSHConnection object ----- /// ---------- Connection object ----------
#[derive(uniffi::Object)] #[derive(uniffi::Object)]
pub struct SSHConnection { pub struct SSHConnection {
@@ -71,108 +101,183 @@ pub struct SSHConnection {
session_id: String, session_id: String,
created_at_ms: f64, created_at_ms: f64,
established_at_ms: f64, established_at_ms: f64,
listeners: Mutex<Vec<Arc<dyn DataListener>>>, listeners: Mutex<Vec<Arc<dyn DataListener>>>,
// write side for sending stdin to the shell
writer: AsyncMutex<russh::ChannelWriteHalf<client::Msg>>,
// handle is kept so we can call disconnect
handle: AsyncMutex<ClientHandle<NoopHandler>>,
} }
impl fmt::Debug for SSHConnection { impl fmt::Debug for SSHConnection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 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") f.debug_struct("SSHConnection")
.field("connection_details", &self.connection_details) .field("connection_details", &self.connection_details)
.field("session_id", &self.session_id) .field("session_id", &self.session_id)
.field("created_at_ms", &self.created_at_ms) .field("created_at_ms", &self.created_at_ms)
.field("established_at_ms", &self.established_at_ms) .field("established_at_ms", &self.established_at_ms)
// Dont try to print the trait objects; just show count or omit entirely. .field("listeners_len", &listeners_len)
.field(
"listeners_len",
&self.listeners.lock().map(|v| v.len()).unwrap_or(0),
)
.finish() .finish()
} }
} }
#[uniffi::export] /// Put the UniFFI export attribute on the IMPL BLOCK (not individual methods).
#[uniffi::export(async_runtime = "tokio")]
impl SSHConnection { impl SSHConnection {
// Read-only “properties” via getters (JS sees methods: connectionDetails(), etc.)
pub fn connection_details(&self) -> ConnectionDetails { pub fn connection_details(&self) -> ConnectionDetails {
self.connection_details.clone() self.connection_details.clone()
} }
pub fn session_id(&self) -> String { pub fn session_id(&self) -> String {
self.session_id.clone() self.session_id.clone()
} }
pub fn created_at_ms(&self) -> f64 { pub fn created_at_ms(&self) -> f64 {
self.created_at_ms self.created_at_ms
} }
pub fn established_at_ms(&self) -> f64 { pub fn established_at_ms(&self) -> f64 {
self.established_at_ms 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<dyn DataListener>) { pub fn add_data_listener(&self, listener: Arc<dyn DataListener>) {
let mut vec = self.listeners.lock().unwrap(); self.listeners.lock().unwrap().push(listener);
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());
// }
} }
/// Send bytes to the session (dummy async) /// Send bytes to the remote shell (stdin).
pub async fn send_data(&self, _data: Vec<u8>) -> Result<(), SshError> { pub async fn send_data(&self, data: Vec<u8>) -> Result<(), SshError> {
// No real transport yet; just succeed. // ChannelWriteHalf isnt 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(()) Ok(())
} }
/// Disconnect (dummy async) /// Graceful disconnect.
pub async fn disconnect(&self) -> Result<(), SshError> { 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(()) 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. /// ---------- Top-level API ----------
/// Kept async so JS sees a Promise<SSHConnection>.
#[uniffi::export] #[uniffi::export(async_runtime = "tokio")]
pub async fn connect( pub async fn connect(
details: ConnectionDetails, details: ConnectionDetails,
status_listener: Arc<dyn StatusListener>, status_listener: Arc<dyn StatusListener>,
) -> Result<Arc<SSHConnection>, SshError> { ) -> Result<Arc<SSHConnection>, SshError> {
// Fire a short, synchronous sequence of status updates (no sleeps needed for now)
status_listener.on_status_change(SSHConnectionStatus::TcpConnecting); 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<NoopHandler> = client::connect(cfg, addr, NoopHandler).await?;
status_listener.on_status_change(SSHConnectionStatus::TcpConnected); 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); 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); 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 { let conn = Arc::new(SSHConnection {
connection_details: details, connection_details: details.clone(),
session_id: "SESSION-STATIC-0001".to_string(), session_id: format!("session-{}", now as u64),
created_at_ms: now, created_at_ms: now,
established_at_ms: now, established_at_ms: now,
listeners: Mutex::new(Vec::new()), 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) Ok(conn)
} }
/// Generate a key pair as a PEM string (dummy content) #[uniffi::export(async_runtime = "tokio")]
#[uniffi::export]
pub async fn generate_key_pair(key_type: KeyType) -> Result<String, SshError> { pub async fn generate_key_pair(key_type: KeyType) -> Result<String, SshError> {
let pem = match key_type { let mut rng = OsRng;
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-----", let key = match key_type {
KeyType::Ed25519 => "-----BEGIN OPENSSH PRIVATE KEY-----\n...dummy-ed25519...\n-----END OPENSSH PRIVATE KEY-----", KeyType::Rsa => PrivateKey::random(&mut rng, KeyAlgorithm::Rsa { hash: None })?,
KeyType::Ed448 => "-----BEGIN OPENSSH PRIVATE KEY-----\n...dummy-ed448...\n-----END OPENSSH PRIVATE KEY-----", 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<String>
let pem = key.to_openssh(LineEnding::LF)?;
Ok(pem.to_string()) Ok(pem.to_string())
} }
/// Helper
fn now_ms() -> f64 { fn now_ms() -> f64 {
let d = SystemTime::now() let d = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)