Files
fressh/packages/react-native-uniffi-russh/rust/uniffi-russh/README.md
EthanShoeDev a51ee62437 better api
2025-09-13 23:22:51 -04:00

7.7 KiB
Raw Blame History

uniffi-russh

UniFFI bindings around russh, designed to make a safe, async SSH client usable from JavaScript/TypeScript — especially React Native (Hermes/JSI) — while staying close to russhs model.

This crate hosts the Rust code and UniFFI exports; the React Native module that consumes it lives in @fressh/react-native-uniffi-russh.


Why this exists

  • The React Native SSH landscape is thin and often not truly async.

  • We want a thin, principled wrapper over russh that:

    • exposes connection + channel (shell) primitives;
    • streams binary data chunks (stdout/stderr) to JS;
    • reports connection state transitions (TCP and shell);
    • provides keypair generation via russh-keys;
    • compiles cleanly for Android (NDK) and other targets with Tokio.

High-level design

  • Rust core (this crate): Tokio-based client using russh and russh-keys. Exposes functions/objects via UniFFI macros.

  • FFI surface (stable across languages):

    • connect(details, connect_status_listener?) -> SSHConnection
    • SSHConnection.start_shell(pty, shell_status_listener?) -> channel_id
    • SSHConnection.send_data(bytes)
    • SSHConnection.exec(command)
    • SSHConnection.close_shell()
    • SSHConnection.disconnect()
    • generate_key_pair(key_type) -> String (OpenSSH PEM)
  • Events:

    • connect_status_listener: TcpConnectingTcpConnectedTcpDisconnected
    • shell_status_listener: ShellConnectingShellConnectedShellDisconnected
  • Streaming:

    • Register multiple ChannelListeners with SSHConnection.add_channel_listener/remove_channel_listener.
    • Each listener gets on_data(Vec<u8>) for stdout/stderr frames (no OSC parsing; renderer decides).

We intentionally keep shell start separate from connect so app UIs can render intermediate states and so advanced consumers can open multiple channels.


Whats exported (conceptual API)

Records & Enums

// Authentication choice
enum Security {
  Password { password: String },
  Key { key_id: String }, // (planned for auth; keygen is available)
}

// Connection details
record ConnectionDetails {
  host: String,
  port: u16,
  username: String,
  security: Security,
}

// Status events
enum SSHConnectionStatus {
  TcpConnecting,
  TcpConnected,
  TcpDisconnected,
  ShellConnecting,
  ShellConnected,
  ShellDisconnected,
}

// PTY selection (maps to SSH pty term names)
enum PtyType { Vanilla, Vt100, Vt102, Vt220, Ansi, Xterm }

// Key generation
enum KeyType { Rsa, Ecdsa, Ed25519, Ed448 /* unsupported */ }

Objects & Traits

// Rust -> JS callback traits
trait StatusListener { fn on_status_change(status: SSHConnectionStatus); }
trait ChannelListener { fn on_data(data: Vec<u8>); }

// Main connection object
object SSHConnection {
  // read-only getters
  fn connection_details() -> ConnectionDetails;
  fn created_at_ms() -> f64;
  fn tcp_established_at_ms() -> f64;

  // channel streaming
  fn add_channel_listener(listener: Arc<dyn ChannelListener>);
  fn remove_channel_listener(listener: Arc<dyn ChannelListener>);

  // shell lifecycle (optional; call only if you want a shell)
  async fn start_shell(pty: PtyType, shell_status: Option<Arc<dyn StatusListener>>) -> u32; // channel_id
  async fn close_shell();

  // writing
  async fn send_data(bytes: Vec<u8>);
  async fn exec(command: String);

  // connection lifecycle
  async fn disconnect();
}

// top-level
async fn connect(details: ConnectionDetails, connect_status: Option<Arc<dyn StatusListener>>)
  -> Arc<SSHConnection>;

async fn generate_key_pair(key_type: KeyType) -> String; // OpenSSH PEM

Error model

All fallible functions return Result<_, SshError>. We map errors from russh, russh-keys, ssh-key, and std::io into a single enum that UniFFI can surface to JS.


React Native (TypeScript) usage

This assumes youre using the companion package @fressh/react-native-uniffi-russh which wires this crate through UniFFI + JSI for React Native.

import {
  connect,
  generateKeyPair,
  PtyType,
  type ConnectionDetails,
  type SSHConnectionStatus,
  type SSHConnection,
} from '@fressh/react-native-uniffi-russh';

const details: ConnectionDetails = {
  host: 'example.com',
  port: 22,
  username: 'me',
  security: { Password: { password: 'secret' } },
};

const connStatus = {
  on_status_change(status: SSHConnectionStatus) {
    console.log('connect status:', status);
  },
};

const shellStatus = {
  on_status_change(status: SSHConnectionStatus) {
    console.log('shell status:', status);
  },
};

const channelListener = {
  on_data(data: Uint8Array) {
    // bytes → feed your terminal emulator / decoder
    console.log('got', data.length, 'bytes');
  },
};

(async () => {
  const conn: SSHConnection = await connect(details, connStatus);

  // streaming callbacks
  conn.add_channel_listener(channelListener);

  // optionally start a shell
  const chanId = await conn.start_shell(PtyType.Xterm, shellStatus);
  console.log('shell channel id', chanId);

  // write to the shell
  await conn.send_data(new TextEncoder().encode('echo hello\n'));

  // or run a one-shot exec request
  await conn.exec('uname -a');

  // later…
  await conn.close_shell();
  await conn.disconnect();
})();

Key generation:

const pem = await generateKeyPair('Ed25519'); // string (OpenSSH format)

Note: For now, password authentication is implemented. Public-key auth is on the roadmap. Key generation works today.


Building & platforms

  • Tokio runtime (multi-thread) is used for all async.

  • This crate is meant to be consumed via UniFFI:

    • For React Native, we use uniffi-bindgen-react-native (UBRN) to generate the TypeScript/JS glue and JSI/Hermes bindings.
  • Android: Requires NDK; our russh dependency is configured to use the ring crypto backend (no CMake-heavy aws-lc).

  • iOS / Desktop: The core crate is platform-agnostic; UBRN/JSI wiring is what determines where you can use it from JS.


Staying close to russh

We intentionally keep the shape near russhs primitives:

  • Connect returns a SSHConnection backed by a russh::client::Handle.
  • Shell is an optional channel you can start on demand (start_shell), with a PTY term (e.g., xterm-256color).
  • Multiple channels are possible (russh supports it). The exported surface focuses on the typical single shell flow. Advanced multiplexing is a natural extension.

What we dont do here:

  • Terminal emulation or OSC parsing (leave that to your renderer).
  • SFTP (out of scope for this crate).
  • Key agent / forwarding / port forwarding (not yet).

Feature flags / deps (summary)

  • tokio (rt-multi-thread, macros, time, net, sync)
  • russh with the ring crypto backend (to avoid aws-lc-sys/CMake churn on Android)
  • russh-keys for key handling + PEM export
  • thiserror for error ergonomics
  • rand (keygen), bytes, futures, once_cell as needed
  • uniffi/uniffi_macros for the FFI surface

Roadmap

  • Public key authentication (using russh-keys and servers supported hash algs)
  • Channel multiplexing helpers (e.g., open arbitrary exec channels alongside shell)
  • Port forwarding
  • Optional OSC133-ish event surfacing (if we later add a parser utility crate)
  • SFTP (in a separate crate)

Contributing

We aim for:

  • Predictable FFI surface (stable enums/records)
  • Tokio-friendly code (no blocking in async)
  • Clippy-clean builds (-D warnings)
  • Thin abstraction over russh (no surprises)

PRs welcome — especially improvements to error mapping and additional authentication modes.


License

Same as russh unless otherwise noted in this repository. (Check the repo root for the definitive license file.)