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"
version = "0.1.0"
dependencies = [
"async-trait",
"bytes",
"futures",
"once_cell",
"rand",
"russh",
"russh-keys",
"thiserror",
"tokio",
"uniffi",
]

View File

@@ -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

View File

@@ -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<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)]
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<u8>);
}
/// 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<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 {
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)
// Dont 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<dyn DataListener>) {
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<u8>) -> Result<(), SshError> {
// No real transport yet; just succeed.
/// Send bytes to the remote shell (stdin).
pub async fn send_data(&self, data: Vec<u8>) -> Result<(), SshError> {
// 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(())
}
/// 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<SSHConnection>.
#[uniffi::export]
/// ---------- Top-level API ----------
#[uniffi::export(async_runtime = "tokio")]
pub async fn connect(
details: ConnectionDetails,
status_listener: Arc<dyn StatusListener>,
) -> Result<Arc<SSHConnection>, 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<NoopHandler> = 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<String, SshError> {
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<String>
let pem = key.to_openssh(LineEnding::LF)?;
Ok(pem.to_string())
}
/// Helper
fn now_ms() -> f64 {
let d = SystemTime::now()
.duration_since(UNIX_EPOCH)