diff --git a/.vscode/settings.json b/.vscode/settings.json index aee9b0c..02222b2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -51,5 +51,8 @@ { "mode": "auto" } - ] + ], + "[typescript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + } } diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index e22207f..f7f030d 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -1,4 +1,4 @@ -import { connect, PtyType, Security } from '@fressh/react-native-uniffi-russh'; +import { RnRussh } from '@fressh/react-native-uniffi-russh'; import SegmentedControl from '@react-native-segmented-control/segmented-control'; import { useStore } from '@tanstack/react-form'; import { useMutation, useQuery } from '@tanstack/react-query'; @@ -13,6 +13,7 @@ import { secretsManager, } from '../lib/secrets-manager'; import { sshConnectionManager } from '../lib/ssh-connection-manager'; + const defaultValues: ConnectionDetails = { host: 'test.rebex.net', port: 22, @@ -30,31 +31,29 @@ const useSshConnMutation = () => { mutationFn: async (connectionDetails: ConnectionDetails) => { try { console.log('Connecting to SSH server...'); - const sshConnection = await connect( - { - host: connectionDetails.host, - port: connectionDetails.port, - username: connectionDetails.username, - security: - connectionDetails.security.type === 'password' - ? new Security.Password({ - password: connectionDetails.security.password, - }) - : new Security.Key({ keyId: connectionDetails.security.keyId }), + const sshConnection = await RnRussh.connect({ + host: connectionDetails.host, + port: connectionDetails.port, + username: connectionDetails.username, + security: + connectionDetails.security.type === 'password' + ? { + type: 'password', + password: connectionDetails.security.password, + } + : { type: 'key', privateKey: 'TODO' }, + onStatusChange: (status) => { + console.log('SSH connection status', status); }, - { - onStatusChange: (status) => { - console.log('SSH connection status', status); - }, - }, - ); + }); await secretsManager.connections.utils.upsertConnection({ id: 'default', details: connectionDetails, priority: 0, }); - await sshConnection.startShell(PtyType.Xterm, { + await sshConnection.startShell({ + pty: 'Xterm', onStatusChange: (status) => { console.log('SSH shell status', status); }, diff --git a/apps/mobile/src/app/shell.tsx b/apps/mobile/src/app/shell.tsx index 0e624f2..9aa1e5f 100644 --- a/apps/mobile/src/app/shell.tsx +++ b/apps/mobile/src/app/shell.tsx @@ -22,20 +22,14 @@ export default function Shell() { const [shellData, setShellData] = useState(''); useEffect(() => { - // sshConn.client.on('Shell', (data) => { - // console.log('Received data (on Shell):', data); - // setShellData((prev) => prev + data); - // }); - sshConn.client.addChannelListener({ + const channelListenerId = sshConn.client.addChannelListener({ onData: (data) => { console.log('Received data (on Shell):', data); setShellData((prev) => prev + data); }, }); return () => { - sshConn.client.removeChannelListener({ - onData: () => {}, - }); + sshConn.client.removeChannelListener(channelListenerId); }; }, [setShellData, sshConn.client]); diff --git a/apps/mobile/src/lib/secrets-manager.ts b/apps/mobile/src/lib/secrets-manager.ts index 5964503..7aa596e 100644 --- a/apps/mobile/src/lib/secrets-manager.ts +++ b/apps/mobile/src/lib/secrets-manager.ts @@ -1,4 +1,4 @@ -import * as Russh from '@fressh/react-native-uniffi-russh'; +import { RnRussh } from '@fressh/react-native-uniffi-russh'; import { queryOptions } from '@tanstack/react-query'; import * as Crypto from 'expo-crypto'; import * as SecureStore from 'expo-secure-store'; @@ -444,8 +444,8 @@ async function generateKeyPair(params: { comment?: string; }) { console.log('DEBUG: generating key pair', params); - const keyPair = await Russh.generateKeyPair( - Russh.KeyType.Ed25519, + const keyPair = await RnRussh.generateKeyPair( + 'ed25519', // params.keySize, // params.comment ?? '', ); diff --git a/apps/mobile/src/lib/ssh-connection-manager.ts b/apps/mobile/src/lib/ssh-connection-manager.ts index e71d0cc..e328aad 100644 --- a/apps/mobile/src/lib/ssh-connection-manager.ts +++ b/apps/mobile/src/lib/ssh-connection-manager.ts @@ -1,14 +1,14 @@ -import { type SshConnectionInterface } from '@fressh/react-native-uniffi-russh'; +import { type SshConnection } from '@fressh/react-native-uniffi-russh'; import * as Crypto from 'expo-crypto'; export type SSHConn = { - client: SshConnectionInterface; + client: SshConnection; sessionId: string; createdAt: Date; }; const sshConnections = new Map(); -function addSession(params: { client: SshConnectionInterface }) { +function addSession(params: { client: SshConnection }) { const sessionId = Crypto.randomUUID(); const createdAt = new Date(); const sshConn: SSHConn = { @@ -28,7 +28,6 @@ function getSession(params: { sessionId: string }) { async function removeAndDisconnectSession(params: { sessionId: string }) { const sshConn = getSession(params); - // sshConn.client.closeShell() await sshConn.client.disconnect(); sshConnections.delete(params.sessionId); } diff --git a/docs/todos.md b/docs/todos.md index 88fabe5..48ed91c 100644 --- a/docs/todos.md +++ b/docs/todos.md @@ -40,3 +40,7 @@ Uniffi is broken on RN 0.80 - https://github.com/jhugman/uniffi-bindgen-react-native/issues/295 - https://github.com/realm/realm-js/issues/7011#issuecomment-3149613234 + +- https://jhugman.github.io/uniffi-bindgen-react-native/idioms/common-types.html +- https://jhugman.github.io/uniffi-bindgen-react-native/idioms/callback-interfaces.html +- https://jhugman.github.io/uniffi-bindgen-react-native/idioms/async-callbacks.html diff --git a/packages/react-native-uniffi-russh/.gitignore b/packages/react-native-uniffi-russh/.gitignore index 2d6ad5e..570f76d 100644 --- a/packages/react-native-uniffi-russh/.gitignore +++ b/packages/react-native-uniffi-russh/.gitignore @@ -91,6 +91,8 @@ nitrogen/ /android /ios /cpp -/src +/src/* *.xcframework -*.podspec \ No newline at end of file +*.podspec + +!src/api.ts \ No newline at end of file diff --git a/packages/react-native-uniffi-russh/package.json b/packages/react-native-uniffi-russh/package.json index 70d80b2..83c393b 100644 --- a/packages/react-native-uniffi-russh/package.json +++ b/packages/react-native-uniffi-russh/package.json @@ -4,12 +4,12 @@ "license": "UNKNOWN", "description": "Uniffi bindings for russh", "version": "0.0.1", - "main": "./lib/module/index.js", - "types": "./lib/typescript/src/index.d.ts", + "main": "./lib/module/api.js", + "types": "./lib/typescript/src/api.d.ts", "exports": { ".": { - "types": "./lib/typescript/src/index.d.ts", - "default": "./lib/module/index.js" + "types": "./lib/typescript/src/api.d.ts", + "default": "./lib/module/api.js" }, "./package.json": "./package.json" }, 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 75f21c0..c665005 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 @@ -1,5 +1,5 @@ use std::fmt; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, Weak}; use std::time::{SystemTime, UNIX_EPOCH}; use rand::rngs::OsRng; @@ -13,6 +13,10 @@ use russh_keys::ssh_key::{self, LineEnding}; uniffi::setup_scaffolding!(); +// Simpler aliases to satisfy clippy type-complexity. +type ListenerEntry = (u64, Arc); +type ListenerList = Vec; + /// ---------- Types ---------- #[derive(Debug, Clone, PartialEq, uniffi::Enum)] @@ -29,6 +33,17 @@ pub struct ConnectionDetails { pub security: Security, } +/// Options for establishing a TCP connection and authenticating. +/// Listener is embedded here so TS has a single arg. +#[derive(Clone, uniffi::Record)] +pub struct ConnectOptions { + pub host: String, + pub port: u16, + pub username: String, + pub security: Security, + pub on_status_change: Option>, +} + #[derive(Debug, Clone, Copy, PartialEq, uniffi::Enum)] pub enum SSHConnectionStatus { TcpConnecting, @@ -92,7 +107,7 @@ impl From for SshError { /// 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); + fn on_change(&self, status: SSHConnectionStatus); } /// Channel data callback (stdout/stderr unified) @@ -110,6 +125,29 @@ pub enum KeyType { Ed448, } +/// Options for starting a shell. +#[derive(Clone, uniffi::Record)] +pub struct StartShellOptions { + pub pty: PtyType, + pub on_status_change: Option>, +} + +/// Snapshot of current connection info for property-like access in TS. +#[derive(Debug, Clone, PartialEq, uniffi::Record)] +pub struct SshConnectionInfo { + pub connection_details: ConnectionDetails, + pub created_at_ms: f64, + pub tcp_established_at_ms: f64, +} + +/// Snapshot of shell session info for property-like access in TS. +#[derive(Debug, Clone, PartialEq, uniffi::Record)] +pub struct ShellSessionInfo { + pub channel_id: u32, + pub created_at_ms: f64, + pub pty: PtyType, +} + /// ---------- Connection object (no shell until start_shell) ---------- #[derive(uniffi::Object)] @@ -121,19 +159,28 @@ pub struct SSHConnection { handle: AsyncMutex>, // Shell state (one active shell per connection by design). - shell: AsyncMutex>, + shell: AsyncMutex>>, - // Data listeners for whatever shell is active. - listeners: Arc>>>, + // Weak self for child sessions to refer back without cycles. + self_weak: AsyncMutex>, + + // Data listeners for whatever shell is active. We track by id for removal. + listeners: Arc>, + next_listener_id: Arc>, // simple counter guarded by same kind of mutex } -struct ShellState { +#[derive(uniffi::Object)] +pub struct ShellSession { + // Weak backref; avoid retain cycle. + parent: std::sync::Weak, channel_id: u32, - writer: russh::ChannelWriteHalf, + writer: AsyncMutex>, // We keep the reader task to allow cancellation on close. reader_task: tokio::task::JoinHandle<()>, // Only used for Shell* statuses. shell_status_listener: Option>, + created_at_ms: f64, + pty: PtyType, } impl fmt::Debug for SSHConnection { @@ -152,6 +199,15 @@ impl fmt::Debug for SSHConnection { struct NoopHandler; impl client::Handler for NoopHandler { type Error = SshError; + // Accept any server key for now so dev UX isn't blocked. + // TODO: Add known-hosts verification and surface API to control this. + #[allow(unused_variables)] + fn check_server_key( + &mut self, + _server_public_key: &russh::keys::PublicKey, + ) -> impl std::future::Future::Error>> + std::marker::Send { + std::future::ready(Ok(true)) + } } /// ---------- Methods ---------- @@ -168,33 +224,41 @@ impl SSHConnection { 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)); + /// Convenience snapshot for property-like access in TS. + pub async fn info(&self) -> SshConnectionInfo { + SshConnectionInfo { + connection_details: self.connection_details.clone(), + created_at_ms: self.created_at_ms, + tcp_established_at_ms: self.tcp_established_at_ms, } } - /// 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 { + /// Add a channel listener and get an id you can later remove with. + pub fn add_channel_listener(&self, listener: Arc) -> u64 { + let mut guard = self.listeners.lock().unwrap(); + let mut id_guard = self.next_listener_id.lock().unwrap(); + let id = *id_guard + 1; + *id_guard = id; + guard.push((id, listener)); + id + } + pub fn remove_channel_listener(&self, id: u64) { + if let Ok(mut v) = self.listeners.lock() { + v.retain(|(lid, _)| *lid != id); + } + } + + /// Start a shell with the given PTY. Emits only Shell* statuses via options.on_status_change. + pub async fn start_shell(&self, opts: StartShellOptions) -> Result, SshError> { // Prevent double-start (safe default). if self.shell.lock().await.is_some() { return Err(SshError::ShellAlreadyRunning); } + let pty = opts.pty; + let shell_status_listener = opts.on_status_change.clone(); if let Some(sl) = shell_status_listener.as_ref() { - sl.on_status_change(SSHConnectionStatus::ShellConnecting); + sl.on_change(SSHConnectionStatus::ShellConnecting); } // Open session channel. @@ -217,19 +281,19 @@ impl SSHConnection { if let Ok(cl) = listeners.lock() { let snapshot = cl.clone(); let buf = data.to_vec(); - for l in snapshot { l.on_data(buf.clone()); } + 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()); } + 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); + sl.on_change(SSHConnectionStatus::ShellDisconnected); } break; } @@ -238,38 +302,38 @@ impl SSHConnection { } }); - *self.shell.lock().await = Some(ShellState { + let session = Arc::new(ShellSession { + parent: self.self_weak.lock().await.clone(), channel_id, - writer, + writer: AsyncMutex::new(writer), reader_task, shell_status_listener, + created_at_ms: now_ms(), + pty, }); + *self.shell.lock().await = Some(session.clone()); + // 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); + if let Some(sl) = session.shell_status_listener.as_ref() { + sl.on_change(SSHConnectionStatus::ShellConnected); } - Ok(channel_id) + Ok(session) } /// 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(()) + let guard = self.shell.lock().await; + let session = guard.as_ref().ok_or(SshError::Disconnected)?; + session.send_data(data).await } /// 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); - } + if let Some(session) = self.shell.lock().await.take() { + // Try to close via the session; ignore error. + let _ = session.close().await; } Ok(()) } @@ -285,15 +349,57 @@ impl SSHConnection { } } +#[uniffi::export(async_runtime = "tokio")] +impl ShellSession { + pub fn info(&self) -> ShellSessionInfo { + ShellSessionInfo { + channel_id: self.channel_id, + created_at_ms: self.created_at_ms, + pty: self.pty, + } + } + pub fn channel_id(&self) -> u32 { self.channel_id } + pub fn created_at_ms(&self) -> f64 { self.created_at_ms } + pub fn pty(&self) -> PtyType { self.pty } + + /// Send bytes to the active shell (stdin). + pub async fn send_data(&self, data: Vec) -> Result<(), SshError> { + let w = self.writer.lock().await; + w.data(&data[..]).await?; + Ok(()) + } + + /// Close the associated shell channel and stop its reader task. + pub async fn close(&self) -> Result<(), SshError> { + // Try to close channel gracefully; ignore error. + self.writer.lock().await.close().await.ok(); + self.reader_task.abort(); + if let Some(sl) = self.shell_status_listener.as_ref() { + sl.on_change(SSHConnectionStatus::ShellDisconnected); + } + // Clear parent's notion of active shell if it matches us. + if let Some(parent) = self.parent.upgrade() { + let mut guard = parent.shell.lock().await; + if let Some(current) = guard.as_ref() { + if current.channel_id == self.channel_id { *guard = None; } + } + } + Ok(()) + } +} + /// ---------- Top-level API ---------- #[uniffi::export(async_runtime = "tokio")] -pub async fn connect( - details: ConnectionDetails, - connect_status_listener: Option>, -) -> Result, SshError> { - if let Some(sl) = connect_status_listener.as_ref() { - sl.on_status_change(SSHConnectionStatus::TcpConnecting); +pub async fn connect(options: ConnectOptions) -> Result, SshError> { + let details = ConnectionDetails { + host: options.host.clone(), + port: options.port, + username: options.username.clone(), + security: options.security.clone(), + }; + if let Some(sl) = options.on_status_change.as_ref() { + sl.on_change(SSHConnectionStatus::TcpConnecting); } // TCP @@ -301,8 +407,8 @@ pub async fn connect( let addr = format!("{}:{}", details.host, details.port); let mut handle: ClientHandle = client::connect(cfg, addr, NoopHandler).await?; - if let Some(sl) = connect_status_listener.as_ref() { - sl.on_status_change(SSHConnectionStatus::TcpConnected); + if let Some(sl) = options.on_status_change.as_ref() { + sl.on_change(SSHConnectionStatus::TcpConnected); } // Auth @@ -320,14 +426,19 @@ pub async fn connect( } let now = now_ms(); - Ok(Arc::new(SSHConnection { + let conn = Arc::new(SSHConnection { connection_details: details, created_at_ms: now, tcp_established_at_ms: now, handle: AsyncMutex::new(handle), shell: AsyncMutex::new(None), + self_weak: AsyncMutex::new(Weak::new()), listeners: Arc::new(Mutex::new(Vec::new())), - })) + next_listener_id: Arc::new(Mutex::new(0)), + }); + // Initialize weak self reference. + *conn.self_weak.lock().await = Arc::downgrade(&conn); + Ok(conn) } #[uniffi::export(async_runtime = "tokio")] diff --git a/packages/react-native-uniffi-russh/src/api.ts b/packages/react-native-uniffi-russh/src/api.ts new file mode 100644 index 0000000..ebf7fac --- /dev/null +++ b/packages/react-native-uniffi-russh/src/api.ts @@ -0,0 +1,109 @@ +/** + * We cannot make the generated code match this API exactly because uniffi + * - Doesn't support ts literals for rust enums + * - Doesn't support passing a js object with methods and properties to rust + * See: - https://jhugman.github.io/uniffi-bindgen-react-native/idioms/callback-interfaces.html + */ +import * as GeneratedRussh from './index'; + + +const privateKeyTypeLiteralToEnum = { + rsa: GeneratedRussh.KeyType.Rsa, + ecdsa: GeneratedRussh.KeyType.Ecdsa, + ed25519: GeneratedRussh.KeyType.Ed25519, +} as const satisfies Record; +export type PrivateKeyType = keyof typeof privateKeyTypeLiteralToEnum; + + +const ptyTypeLiteralToEnum = { + Vanilla: GeneratedRussh.PtyType.Vanilla, + Vt100: GeneratedRussh.PtyType.Vt100, + Vt102: GeneratedRussh.PtyType.Vt102, + Vt220: GeneratedRussh.PtyType.Vt220, + Ansi: GeneratedRussh.PtyType.Ansi, + Xterm: GeneratedRussh.PtyType.Xterm, + Xterm256: GeneratedRussh.PtyType.Xterm256, +} as const satisfies Record; +export type PtyType = keyof typeof ptyTypeLiteralToEnum; + + +const sshConnStatusEnumToLiteral = { + [GeneratedRussh.SshConnectionStatus.TcpConnecting]: 'tcpConnecting', + [GeneratedRussh.SshConnectionStatus.TcpConnected]: 'tcpConnected', + [GeneratedRussh.SshConnectionStatus.TcpDisconnected]: 'tcpDisconnected', + [GeneratedRussh.SshConnectionStatus.ShellConnecting]: 'shellConnecting', + [GeneratedRussh.SshConnectionStatus.ShellConnected]: 'shellConnected', + [GeneratedRussh.SshConnectionStatus.ShellDisconnected]: 'shellDisconnected', +} as const satisfies Record; +export type SshConnectionStatus = (typeof sshConnStatusEnumToLiteral)[keyof typeof sshConnStatusEnumToLiteral]; + +export type ConnectOptions = { + onStatusChange?: (status: SshConnectionStatus) => void; + abortSignal?: AbortSignal; + host: string; + port: number; + username: string; + security: + | { type: 'password'; password: string } + | { type: 'key'; privateKey: string }; +}; + +export type StartShellOptions = { + pty: PtyType; + onStatusChange?: (status: SshConnectionStatus) => void; + abortSignal?: AbortSignal; +} + +async function connect(options: ConnectOptions) { + const security = + options.security.type === 'password' + ? new GeneratedRussh.Security.Password({ + password: options.security.password, + }) + : new GeneratedRussh.Security.Key({ keyId: options.security.privateKey }); + const sshConnectionInterface = await GeneratedRussh.connect( + { + host: options.host, + port: options.port, + username: options.username, + security, + onStatusChange: options.onStatusChange ? { + onChange: (statusEnum) => { + options.onStatusChange?.(sshConnStatusEnumToLiteral[statusEnum]!); + }, + } : undefined, + }, + options.abortSignal + ? { + signal: options.abortSignal, + } + : undefined + ); + const originalStartShell = sshConnectionInterface.startShell; + return { + ...sshConnectionInterface, + startShell: (params: StartShellOptions) => { + return originalStartShell({ + pty: ptyTypeLiteralToEnum[params.pty], + onStatusChange: params.onStatusChange ? { + onChange: (statusEnum) => { + params.onStatusChange?.(sshConnStatusEnumToLiteral[statusEnum]!); + }, + } : undefined, + }, params.abortSignal ? { signal: params.abortSignal } : undefined); + } + } +} + +export type SshConnection = Awaited>; + +async function generateKeyPair(type: PrivateKeyType) { + return GeneratedRussh.generateKeyPair(privateKeyTypeLiteralToEnum[type]); +} + +export const RnRussh = { + uniffiInitAsync: GeneratedRussh.uniffiInitAsync, + connect, + generateKeyPair, + PtyType: GeneratedRussh.PtyType, +};