cleaner russh api

This commit is contained in:
EthanShoeDev
2025-09-14 01:52:02 -04:00
parent 4cdd8cd4bf
commit 39027ee1a1
10 changed files with 316 additions and 95 deletions

View File

@@ -51,5 +51,8 @@
{ {
"mode": "auto" "mode": "auto"
} }
] ],
"[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
}
} }

View File

@@ -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 SegmentedControl from '@react-native-segmented-control/segmented-control';
import { useStore } from '@tanstack/react-form'; import { useStore } from '@tanstack/react-form';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
@@ -13,6 +13,7 @@ import {
secretsManager, secretsManager,
} from '../lib/secrets-manager'; } from '../lib/secrets-manager';
import { sshConnectionManager } from '../lib/ssh-connection-manager'; import { sshConnectionManager } from '../lib/ssh-connection-manager';
const defaultValues: ConnectionDetails = { const defaultValues: ConnectionDetails = {
host: 'test.rebex.net', host: 'test.rebex.net',
port: 22, port: 22,
@@ -30,31 +31,29 @@ const useSshConnMutation = () => {
mutationFn: async (connectionDetails: ConnectionDetails) => { mutationFn: async (connectionDetails: ConnectionDetails) => {
try { try {
console.log('Connecting to SSH server...'); console.log('Connecting to SSH server...');
const sshConnection = await connect( const sshConnection = await RnRussh.connect({
{ host: connectionDetails.host,
host: connectionDetails.host, port: connectionDetails.port,
port: connectionDetails.port, username: connectionDetails.username,
username: connectionDetails.username, security:
security: connectionDetails.security.type === 'password'
connectionDetails.security.type === 'password' ? {
? new Security.Password({ type: 'password',
password: connectionDetails.security.password, password: connectionDetails.security.password,
}) }
: new Security.Key({ keyId: connectionDetails.security.keyId }), : { 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({ await secretsManager.connections.utils.upsertConnection({
id: 'default', id: 'default',
details: connectionDetails, details: connectionDetails,
priority: 0, priority: 0,
}); });
await sshConnection.startShell(PtyType.Xterm, { await sshConnection.startShell({
pty: 'Xterm',
onStatusChange: (status) => { onStatusChange: (status) => {
console.log('SSH shell status', status); console.log('SSH shell status', status);
}, },

View File

@@ -22,20 +22,14 @@ export default function Shell() {
const [shellData, setShellData] = useState(''); const [shellData, setShellData] = useState('');
useEffect(() => { useEffect(() => {
// sshConn.client.on('Shell', (data) => { const channelListenerId = sshConn.client.addChannelListener({
// console.log('Received data (on Shell):', data);
// setShellData((prev) => prev + data);
// });
sshConn.client.addChannelListener({
onData: (data) => { onData: (data) => {
console.log('Received data (on Shell):', data); console.log('Received data (on Shell):', data);
setShellData((prev) => prev + data); setShellData((prev) => prev + data);
}, },
}); });
return () => { return () => {
sshConn.client.removeChannelListener({ sshConn.client.removeChannelListener(channelListenerId);
onData: () => {},
});
}; };
}, [setShellData, sshConn.client]); }, [setShellData, sshConn.client]);

View File

@@ -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 { queryOptions } from '@tanstack/react-query';
import * as Crypto from 'expo-crypto'; import * as Crypto from 'expo-crypto';
import * as SecureStore from 'expo-secure-store'; import * as SecureStore from 'expo-secure-store';
@@ -444,8 +444,8 @@ async function generateKeyPair(params: {
comment?: string; comment?: string;
}) { }) {
console.log('DEBUG: generating key pair', params); console.log('DEBUG: generating key pair', params);
const keyPair = await Russh.generateKeyPair( const keyPair = await RnRussh.generateKeyPair(
Russh.KeyType.Ed25519, 'ed25519',
// params.keySize, // params.keySize,
// params.comment ?? '', // params.comment ?? '',
); );

View File

@@ -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'; import * as Crypto from 'expo-crypto';
export type SSHConn = { export type SSHConn = {
client: SshConnectionInterface; client: SshConnection;
sessionId: string; sessionId: string;
createdAt: Date; createdAt: Date;
}; };
const sshConnections = new Map<string, SSHConn>(); const sshConnections = new Map<string, SSHConn>();
function addSession(params: { client: SshConnectionInterface }) { function addSession(params: { client: SshConnection }) {
const sessionId = Crypto.randomUUID(); const sessionId = Crypto.randomUUID();
const createdAt = new Date(); const createdAt = new Date();
const sshConn: SSHConn = { const sshConn: SSHConn = {
@@ -28,7 +28,6 @@ function getSession(params: { sessionId: string }) {
async function removeAndDisconnectSession(params: { sessionId: string }) { async function removeAndDisconnectSession(params: { sessionId: string }) {
const sshConn = getSession(params); const sshConn = getSession(params);
// sshConn.client.closeShell()
await sshConn.client.disconnect(); await sshConn.client.disconnect();
sshConnections.delete(params.sessionId); sshConnections.delete(params.sessionId);
} }

View File

@@ -40,3 +40,7 @@ Uniffi is broken on RN 0.80
- https://github.com/jhugman/uniffi-bindgen-react-native/issues/295 - https://github.com/jhugman/uniffi-bindgen-react-native/issues/295
- https://github.com/realm/realm-js/issues/7011#issuecomment-3149613234 - 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

View File

@@ -91,6 +91,8 @@ nitrogen/
/android /android
/ios /ios
/cpp /cpp
/src /src/*
*.xcframework *.xcframework
*.podspec *.podspec
!src/api.ts

View File

@@ -4,12 +4,12 @@
"license": "UNKNOWN", "license": "UNKNOWN",
"description": "Uniffi bindings for russh", "description": "Uniffi bindings for russh",
"version": "0.0.1", "version": "0.0.1",
"main": "./lib/module/index.js", "main": "./lib/module/api.js",
"types": "./lib/typescript/src/index.d.ts", "types": "./lib/typescript/src/api.d.ts",
"exports": { "exports": {
".": { ".": {
"types": "./lib/typescript/src/index.d.ts", "types": "./lib/typescript/src/api.d.ts",
"default": "./lib/module/index.js" "default": "./lib/module/api.js"
}, },
"./package.json": "./package.json" "./package.json": "./package.json"
}, },

View File

@@ -1,5 +1,5 @@
use std::fmt; use std::fmt;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex, Weak};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use rand::rngs::OsRng; use rand::rngs::OsRng;
@@ -13,6 +13,10 @@ use russh_keys::ssh_key::{self, LineEnding};
uniffi::setup_scaffolding!(); uniffi::setup_scaffolding!();
// Simpler aliases to satisfy clippy type-complexity.
type ListenerEntry = (u64, Arc<dyn ChannelListener>);
type ListenerList = Vec<ListenerEntry>;
/// ---------- Types ---------- /// ---------- Types ----------
#[derive(Debug, Clone, PartialEq, uniffi::Enum)] #[derive(Debug, Clone, PartialEq, uniffi::Enum)]
@@ -29,6 +33,17 @@ pub struct ConnectionDetails {
pub security: Security, 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<Arc<dyn StatusListener>>,
}
#[derive(Debug, Clone, Copy, PartialEq, uniffi::Enum)] #[derive(Debug, Clone, Copy, PartialEq, uniffi::Enum)]
pub enum SSHConnectionStatus { pub enum SSHConnectionStatus {
TcpConnecting, TcpConnecting,
@@ -92,7 +107,7 @@ impl From<ssh_key::Error> for SshError {
/// Status callback (used separately by connect and by start_shell) /// Status callback (used separately by connect and by start_shell)
#[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_change(&self, status: SSHConnectionStatus);
} }
/// Channel data callback (stdout/stderr unified) /// Channel data callback (stdout/stderr unified)
@@ -110,6 +125,29 @@ pub enum KeyType {
Ed448, Ed448,
} }
/// Options for starting a shell.
#[derive(Clone, uniffi::Record)]
pub struct StartShellOptions {
pub pty: PtyType,
pub on_status_change: Option<Arc<dyn StatusListener>>,
}
/// 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) ---------- /// ---------- Connection object (no shell until start_shell) ----------
#[derive(uniffi::Object)] #[derive(uniffi::Object)]
@@ -121,19 +159,28 @@ pub struct SSHConnection {
handle: AsyncMutex<ClientHandle<NoopHandler>>, handle: AsyncMutex<ClientHandle<NoopHandler>>,
// Shell state (one active shell per connection by design). // Shell state (one active shell per connection by design).
shell: AsyncMutex<Option<ShellState>>, shell: AsyncMutex<Option<Arc<ShellSession>>>,
// Data listeners for whatever shell is active. // Weak self for child sessions to refer back without cycles.
listeners: Arc<Mutex<Vec<Arc<dyn ChannelListener>>>>, self_weak: AsyncMutex<Weak<SSHConnection>>,
// Data listeners for whatever shell is active. We track by id for removal.
listeners: Arc<Mutex<ListenerList>>,
next_listener_id: Arc<Mutex<u64>>, // 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<SSHConnection>,
channel_id: u32, channel_id: u32,
writer: russh::ChannelWriteHalf<client::Msg>, writer: AsyncMutex<russh::ChannelWriteHalf<client::Msg>>,
// We keep the reader task to allow cancellation on close. // We keep the reader task to allow cancellation on close.
reader_task: tokio::task::JoinHandle<()>, reader_task: tokio::task::JoinHandle<()>,
// Only used for Shell* statuses. // Only used for Shell* statuses.
shell_status_listener: Option<Arc<dyn StatusListener>>, shell_status_listener: Option<Arc<dyn StatusListener>>,
created_at_ms: f64,
pty: PtyType,
} }
impl fmt::Debug for SSHConnection { impl fmt::Debug for SSHConnection {
@@ -152,6 +199,15 @@ impl fmt::Debug for SSHConnection {
struct NoopHandler; struct NoopHandler;
impl client::Handler for NoopHandler { impl client::Handler for NoopHandler {
type Error = SshError; 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<Output = std::result::Result<bool, <Self as russh::client::Handler>::Error>> + std::marker::Send {
std::future::ready(Ok(true))
}
} }
/// ---------- Methods ---------- /// ---------- Methods ----------
@@ -168,33 +224,41 @@ impl SSHConnection {
self.tcp_established_at_ms self.tcp_established_at_ms
} }
/// Return current shell channel id, if any. /// Convenience snapshot for property-like access in TS.
pub async fn channel_id(&self) -> Option<u32> { pub async fn info(&self) -> SshConnectionInfo {
self.shell.lock().await.as_ref().map(|s| s.channel_id) SshConnectionInfo {
} connection_details: self.connection_details.clone(),
created_at_ms: self.created_at_ms,
pub fn add_channel_listener(&self, listener: Arc<dyn ChannelListener>) { tcp_established_at_ms: self.tcp_established_at_ms,
self.listeners.lock().unwrap().push(listener);
}
pub fn remove_channel_listener(&self, listener: Arc<dyn ChannelListener>) {
if let Ok(mut v) = self.listeners.lock() {
v.retain(|l| !Arc::ptr_eq(l, &listener));
} }
} }
/// Start a shell with the given PTY. Emits only Shell* statuses via `shell_status_listener`. /// Add a channel listener and get an id you can later remove with.
pub async fn start_shell( pub fn add_channel_listener(&self, listener: Arc<dyn ChannelListener>) -> u64 {
&self, let mut guard = self.listeners.lock().unwrap();
pty: PtyType, let mut id_guard = self.next_listener_id.lock().unwrap();
shell_status_listener: Option<Arc<dyn StatusListener>>, let id = *id_guard + 1;
) -> Result<u32, SshError> { *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<Arc<ShellSession>, SshError> {
// Prevent double-start (safe default). // Prevent double-start (safe default).
if self.shell.lock().await.is_some() { if self.shell.lock().await.is_some() {
return Err(SshError::ShellAlreadyRunning); 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() { if let Some(sl) = shell_status_listener.as_ref() {
sl.on_status_change(SSHConnectionStatus::ShellConnecting); sl.on_change(SSHConnectionStatus::ShellConnecting);
} }
// Open session channel. // Open session channel.
@@ -217,19 +281,19 @@ impl SSHConnection {
if let Ok(cl) = listeners.lock() { if let Ok(cl) = listeners.lock() {
let snapshot = cl.clone(); let snapshot = cl.clone();
let buf = data.to_vec(); 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, .. }) => { Some(ChannelMsg::ExtendedData { data, .. }) => {
if let Ok(cl) = listeners.lock() { if let Ok(cl) = listeners.lock() {
let snapshot = cl.clone(); let snapshot = cl.clone();
let buf = data.to_vec(); 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 => { Some(ChannelMsg::Close) | None => {
if let Some(sl) = shell_listener_for_task.as_ref() { if let Some(sl) = shell_listener_for_task.as_ref() {
sl.on_status_change(SSHConnectionStatus::ShellDisconnected); sl.on_change(SSHConnectionStatus::ShellDisconnected);
} }
break; 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, channel_id,
writer, writer: AsyncMutex::new(writer),
reader_task, reader_task,
shell_status_listener, shell_status_listener,
created_at_ms: now_ms(),
pty,
}); });
*self.shell.lock().await = Some(session.clone());
// Report ShellConnected. // Report ShellConnected.
if let Some(sl) = self.shell.lock().await.as_ref().and_then(|s| s.shell_status_listener.clone()) { if let Some(sl) = session.shell_status_listener.as_ref() {
sl.on_status_change(SSHConnectionStatus::ShellConnected); sl.on_change(SSHConnectionStatus::ShellConnected);
} }
Ok(channel_id) Ok(session)
} }
/// Send bytes to the active shell (stdin). /// Send bytes to the active shell (stdin).
pub async fn send_data(&self, data: Vec<u8>) -> Result<(), SshError> { pub async fn send_data(&self, data: Vec<u8>) -> Result<(), SshError> {
let mut guard = self.shell.lock().await; let guard = self.shell.lock().await;
let state = guard.as_mut().ok_or(SshError::Disconnected)?; let session = guard.as_ref().ok_or(SshError::Disconnected)?;
state.writer.data(&data[..]).await?; session.send_data(data).await
Ok(())
} }
/// Close the active shell channel (if any) and stop its reader task. /// Close the active shell channel (if any) and stop its reader task.
pub async fn close_shell(&self) -> Result<(), SshError> { pub async fn close_shell(&self) -> Result<(), SshError> {
if let Some(state) = self.shell.lock().await.take() { if let Some(session) = self.shell.lock().await.take() {
// Try to close channel gracefully; ignore error. // Try to close via the session; ignore error.
state.writer.close().await.ok(); let _ = session.close().await;
state.reader_task.abort();
if let Some(sl) = state.shell_status_listener {
sl.on_status_change(SSHConnectionStatus::ShellDisconnected);
}
} }
Ok(()) 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<u8>) -> 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 ---------- /// ---------- Top-level API ----------
#[uniffi::export(async_runtime = "tokio")] #[uniffi::export(async_runtime = "tokio")]
pub async fn connect( pub async fn connect(options: ConnectOptions) -> Result<Arc<SSHConnection>, SshError> {
details: ConnectionDetails, let details = ConnectionDetails {
connect_status_listener: Option<Arc<dyn StatusListener>>, host: options.host.clone(),
) -> Result<Arc<SSHConnection>, SshError> { port: options.port,
if let Some(sl) = connect_status_listener.as_ref() { username: options.username.clone(),
sl.on_status_change(SSHConnectionStatus::TcpConnecting); security: options.security.clone(),
};
if let Some(sl) = options.on_status_change.as_ref() {
sl.on_change(SSHConnectionStatus::TcpConnecting);
} }
// TCP // TCP
@@ -301,8 +407,8 @@ pub async fn connect(
let addr = format!("{}:{}", details.host, details.port); let addr = format!("{}:{}", details.host, details.port);
let mut handle: ClientHandle<NoopHandler> = client::connect(cfg, addr, NoopHandler).await?; let mut handle: ClientHandle<NoopHandler> = client::connect(cfg, addr, NoopHandler).await?;
if let Some(sl) = connect_status_listener.as_ref() { if let Some(sl) = options.on_status_change.as_ref() {
sl.on_status_change(SSHConnectionStatus::TcpConnected); sl.on_change(SSHConnectionStatus::TcpConnected);
} }
// Auth // Auth
@@ -320,14 +426,19 @@ pub async fn connect(
} }
let now = now_ms(); let now = now_ms();
Ok(Arc::new(SSHConnection { let conn = Arc::new(SSHConnection {
connection_details: details, connection_details: details,
created_at_ms: now, created_at_ms: now,
tcp_established_at_ms: now, tcp_established_at_ms: now,
handle: AsyncMutex::new(handle), handle: AsyncMutex::new(handle),
shell: AsyncMutex::new(None), shell: AsyncMutex::new(None),
self_weak: AsyncMutex::new(Weak::new()),
listeners: Arc::new(Mutex::new(Vec::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")] #[uniffi::export(async_runtime = "tokio")]

View File

@@ -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<string, GeneratedRussh.KeyType>;
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<string, GeneratedRussh.PtyType>;
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<GeneratedRussh.SshConnectionStatus, string>;
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<ReturnType<typeof connect>>;
async function generateKeyPair(type: PrivateKeyType) {
return GeneratedRussh.generateKeyPair(privateKeyTypeLiteralToEnum[type]);
}
export const RnRussh = {
uniffiInitAsync: GeneratedRussh.uniffiInitAsync,
connect,
generateKeyPair,
PtyType: GeneratedRussh.PtyType,
};