Let's Connect

A laptop screen showing a terminal with green text on a dark background, lit by ambient room light

I wanted a fast, native terminal that stored my keys and sessions locally and carried none of the Electron weight, so I built an offline ssh client tauri rust app and wired it to xterm.js. The architecture is small: xterm.js renders the terminal inside the system webview, Rust owns the actual SSH connection through the russh crate (a pure-Rust async implementation with no OpenSSL or system libssh dependency), and Tauri's command/event bridge moves bytes between them. Keystrokes go from the frontend to Rust via invoke; server output comes back from Rust to the frontend via emit. The binary is a few megabytes and starts fast because there is no bundled Chromium to boot.

Why russh and Tauri instead of Electron + node-pty?

node-pty shells out to a system process, and an Electron app drags a full Chromium runtime along for the ride. I did not want either. russh speaks the SSH wire protocol directly in Rust, so there is no libssh2 to link, no OpenSSL version skew across distros, and no spawning of the system ssh binary. Tauri uses the OS webview (WebView2 on Windows, WebKitGTK on Linux, WKWebView on macOS), so the shipped binary is a few megabytes instead of well over a hundred. The split stays clean: the webview is pure UI, and every byte of crypto and socket I/O lives in Rust where I can audit it.

If you have ever fought a connection that hangs because the SSH port is silently dropped upstream, the same reachability rules apply here as anywhere else. I covered the AWS networking side of that in deploying Laravel on a production AWS architecture, and the security-group reasoning there is worth a read before you blame your client code for a timeout.

Colorful source code displayed on a dark editor, showing nested syntax across multiple lines
The frontend is just UI. Every byte of SSH crypto and socket I/O lives in the Rust layer.

Scaffolding the project and adding dependencies

Start from the official Tauri v2 scaffold and pick whatever frontend framework you like. The terminal lives in plain DOM, so the framework barely matters.

terminal
npm create tauri-app@latest

# frontend terminal dependencies
npm install @xterm/xterm @xterm/addon-fit

On the Rust side, add russh and tokio with the multi-thread runtime, the macros feature, and sync. There is no separate russh-keys crate to pull in for this version: key handling lives in the russh::keys module, which ships with the crate by default. tokio is what lets the SSH read loop run as a background task while Tauri commands stay responsive.

src-tauri/Cargo.toml
[dependencies]
tauri = { version = "2", features = [] }
russh = "0.54"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }

How does the Rust side open and own the SSH session?

The Rust half implements russh's client::Handler trait, connects, authenticates, opens a session channel, then requests a PTY and a shell. The detail that matters: a single Channel cannot be read and written from two places at once, so I call channel.split() to get a ChannelReadHalf and a ChannelWriteHalf<Msg>. The read half moves into a tokio task that loops on read_half.wait() and forwards every ChannelMsg::Data event to the frontend with app.emit. The write half goes into Tauri managed state behind a tokio Mutex, so later commands (writes, resizes) all reach the same session. One more Tauri v2 gotcha: emit lives on the Emitter trait, so you must import tauri::Emitter or the method will not resolve.

src-tauri/src/ssh.rs
use std::sync::Arc;
use russh::{client, ChannelMsg, ChannelWriteHalf};
use russh::client::Msg;
use russh::keys::ssh_key;
use tokio::sync::Mutex;
use tauri::{AppHandle, Emitter, State};

// Managed state holds the write half so every command hits the same session.
#[derive(Default)]
pub struct SshState(pub Arc<Mutex<Option<ChannelWriteHalf<Msg>>>>);

struct Client;

impl client::Handler for Client {
    type Error = russh::Error;

    // Verify the host key here. Returning Ok(true) blindly is dangerous --
    // compare against a known-hosts store before trusting an unknown key.
    async fn check_server_key(
        &mut self,
        server_public_key: &ssh_key::PublicKey,
    ) -> Result<bool, Self::Error> {
        let fingerprint = server_public_key.fingerprint(Default::default());
        Ok(known_hosts_contains(&fingerprint)) // your local lookup
    }
}

#[tauri::command]
pub async fn ssh_connect(
    app: AppHandle,
    state: State<'_, SshState>,
    host: String,
    port: u16,
    user: String,
    password: String,
) -> Result<(), String> {
    let config = Arc::new(client::Config::default());
    let mut session = client::connect(config, (host, port), Client)
        .await
        .map_err(|e| e.to_string())?;

    let auth = session
        .authenticate_password(user, password)
        .await
        .map_err(|e| e.to_string())?;
    if !auth.success() {
        return Err("authentication failed".into());
    }

    let channel = session
        .channel_open_session()
        .await
        .map_err(|e| e.to_string())?;
    channel
        .request_pty(false, "xterm-256color", 80, 24, 0, 0, &[])
        .await
        .map_err(|e| e.to_string())?;
    channel.request_shell(true).await.map_err(|e| e.to_string())?;

    // Split so the read loop and the write commands own separate halves.
    let (mut read_half, write_half) = channel.split();

    // Pump server output to the webview on a background task.
    let app_handle = app.clone();
    tokio::spawn(async move {
        while let Some(msg) = read_half.wait().await {
            if let ChannelMsg::Data { ref data } = msg {
                let _ = app_handle.emit("ssh://data", data.to_vec());
            }
        }
    });

    *state.0.lock().await = Some(write_half);
    Ok(())
}

#[tauri::command]
pub async fn ssh_write(state: State<'_, SshState>, data: String) -> Result<(), String> {
    if let Some(channel) = state.0.lock().await.as_ref() {
        channel.data(data.as_bytes()).await.map_err(|e| e.to_string())?;
    }
    Ok(())
}

#[tauri::command]
pub async fn ssh_resize(state: State<'_, SshState>, cols: u32, rows: u32) -> Result<(), String> {
    if let Some(channel) = state.0.lock().await.as_ref() {
        channel.window_change(cols, rows, 0, 0).await.map_err(|e| e.to_string())?;
    }
    Ok(())
}

Two notes on the API, because both bit me. First, channel.data is generic over anything implementing AsyncRead, and a &[u8] satisfies that, which is why passing data.as_bytes() compiles cleanly. Second, request_pty's terminal_modes argument is &[(Pty, u32)]; an empty slice is fine to start, but real applications like vim want the speed and echo modes set. Register the state and commands in your builder with .manage(SshState::default()) and .invoke_handler(tauri::generate_handler![ssh_connect, ssh_write, ssh_resize]).

Wiring xterm.js to the Rust bridge

The frontend mounts a Terminal, fits it to the container, and connects the two directions of the bridge. Output flows in through a listen on the ssh://data event; input flows out through term.onData, which fires for every keystroke and paste and hands the raw string to invoke('ssh_write').

src/terminal.ts
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import "@xterm/xterm/css/xterm.css";

export async function startSession(el: HTMLElement) {
  const term = new Terminal({ fontFamily: "monospace", fontSize: 14 });
  const fit = new FitAddon();
  term.loadAddon(fit);
  term.open(el);
  fit.fit();

  // Rust -> frontend: server output. Payload is a byte array.
  await listen<number[]>("ssh://data", (e) => {
    term.write(new Uint8Array(e.payload));
  });

  // frontend -> Rust: every keystroke and paste.
  term.onData((data) => {
    invoke("ssh_write", { data });
  });

  // Keep the remote PTY size in sync with the visible terminal.
  term.onResize(({ cols, rows }) => {
    invoke("ssh_resize", { cols, rows });
  });

  await invoke("ssh_connect", {
    host: "example.com",
    port: 22,
    user: "deploy",
    password: "", // prompt for this; never hardcode
  });

  // Refit on window resize so onResize fires and the remote follows.
  window.addEventListener("resize", () => fit.fit());
}

The emitted payload arrives as a JSON number array because that is how Tauri serializes a Vec<u8>, so I rebuild a Uint8Array before handing it to xterm.js. Skip that and you get garbled output the moment the server sends a multi-byte UTF-8 sequence or an ANSI escape.

Put the crypto in Rust and the pixels in the webview. The bridge is just bytes, and bytes are easy to get right.Md Raihan Hasan

What about offline storage and host-key safety?

Everything stays on disk, on the machine. There is no telemetry and no cloud sync, which was the entire point. A few things I treat as non-negotiable:

  • Verify host keys in check_server_key against a local known-hosts store. Returning Ok(true) is fine for a throwaway demo and a disaster in production, because it disables the exact MITM protection SSH exists to provide. russh ships check_known_hosts in russh::keys::known_hosts if you do not want to maintain your own store.
  • Persist saved sessions and known hosts locally. I reach for rusqlite to keep a single .db file next to the app config, which makes a saved-sessions list and host-key pinning straightforward.
  • Load private keys with russh's keys::load_secret_key helper rather than rolling your own parser, and authenticate with authenticate_publickey instead of passwords wherever the server allows it.
  • Never log the password or key material. It is tempting during debugging and it always leaks.

Full host-key pinning and a rusqlite-backed session manager are a post of their own, but the hook is already in place: check_server_key is the one chokepoint where every connection's trust decision is made, so wire your store in there and nowhere else.

What surprised me most is how little code this took. The hard part of an SSH client is the protocol, and russh hands you that with an async API that maps cleanly onto tokio tasks. Tauri's invoke and emit are a thin, typed seam between Rust and the webview, and xterm.js is a mature terminal that just wants bytes. If you have been assuming a native SSH client is months of work, it is not: it is a handler trait, three commands, and a read loop on a split channel. The russh docs at https://docs.rs/russh and the Tauri guides at https://tauri.app cover the rest.