mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 06:12:51 +00:00
russh building
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
// 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<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 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<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)
|
||||
|
||||
Reference in New Issue
Block a user