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