Let's Connect

Terminal window showing an SSH session connecting to a remote server

If you want rust ssh sftp russh working in a single binary with no system dependency, the russh crate is the answer. It is a pure-Rust async SSH implementation, so you do not link against the host's libssh2 or OpenSSL, and you do not fight the C build toolchain on Windows. Pair it with the russh-sftp crate and you get file transfer over the same connection. The problem most people hit first is that russh is low-level: there is no `connect_and_run()` helper, you implement a `Handler`, you own host-key verification, and you read command output off a message stream yourself. This guide walks the whole path: connect, authenticate, run a command, then open SFTP, all on tokio.

This is the crate-level guide. If you want the full desktop app around it, the terminal UI and PTY handling live in the separate offline SSH client in Tauri post, and tunneling is covered in SSH port forwarding in Rust.

What do you add to Cargo.toml?

Pin the crates explicitly. russh is at 0.61 and russh-sftp at 2.3 as of this writing, and the API has shifted between minor versions, so do not float these. You also need tokio with the full feature set because the SFTP file handles want `AsyncReadExt` and `AsyncWriteExt`. Note that russh 0.61 uses native async fn in traits, so you do not need the `async-trait` crate to implement the `Handler`.

Cargo.toml
[dependencies]
russh = "0.61"
russh-sftp = "2.3"
tokio = { version = "1", features = ["full"] }
anyhow = "1"

How do you connect and authenticate?

You implement `client::Handler`. In russh 0.61 the trait functions are plain `async fn` (no `#[async_trait]` macro), and the one you must not get lazy about is `check_server_key`. russh hands you the server's public key and asks a yes/no question. Returning `Ok(true)` unconditionally is the equivalent of `StrictHostKeyChecking no`: it accepts any key and defeats the entire point of SSH against man-in-the-middle. In production you compare the key against a stored fingerprint (your `known_hosts` equivalent) and only return true on a match, or prompt the user on first contact and persist the answer.

src/ssh.rs
use std::sync::Arc;
use russh::client::{self, Config, Handle, Handler};
use russh::keys::{load_secret_key, HashAlg, PrivateKeyWithHashAlg};

struct Client {
    // In real code, hold the pinned fingerprint here.
    expected_fingerprint: Option<String>,
}

// russh 0.61 uses native async fn in traits: no #[async_trait].
impl Handler for Client {
    type Error = russh::Error;

    async fn check_server_key(
        &mut self,
        server_public_key: &russh::keys::ssh_key::PublicKey,
    ) -> Result<bool, Self::Error> {
        let fp = server_public_key.fingerprint(Default::default()).to_string();
        match &self.expected_fingerprint {
            Some(known) => Ok(&fp == known), // TOFU / known_hosts check
            None => {
                // First contact: surface `fp` to the user and persist it.
                eprintln!("server fingerprint: {fp}");
                Ok(true)
            }
        }
    }
}

pub async fn connect_key(
    addr: &str,
    user: &str,
    key_path: &str,
) -> anyhow::Result<Handle<Client>> {
    let config = Arc::new(Config {
        inactivity_timeout: Some(std::time::Duration::from_secs(30)),
        ..Default::default()
    });

    let handler = Client { expected_fingerprint: None };
    let mut session = client::connect(config, addr, handler).await?;

    // Key auth. Pass a passphrase as Some("...") if the key is encrypted.
    let key = load_secret_key(key_path, None)?;
    let auth = session
        .authenticate_publickey(
            user,
            // For RSA keys you must request a modern hash; for Ed25519
            // it is ignored, so Some(Sha512) is a safe default.
            PrivateKeyWithHashAlg::new(Arc::new(key), Some(HashAlg::Sha512)),
        )
        .await?;

    anyhow::ensure!(auth.success(), "authentication failed");
    Ok(session)
}

For password auth, swap the key block for `session.authenticate_password(user, password).await?`, which returns the same `AuthResult` you check with `.success()`. The address argument to `client::connect` accepts anything that resolves with `ToSocketAddrs`, so `"host:22"` works directly. Mind the second argument to `PrivateKeyWithHashAlg::new`: it is the RSA hash algorithm, and passing `None` does not negotiate — for RSA it falls back to the legacy `ssh-rsa` (SHA-1) signature, which current OpenSSH servers reject by default. Pass `Some(HashAlg::Sha512)` (or `Sha256`); for Ed25519 keys the value is ignored.

How do you run a remote command and capture output?

Open a session channel, call `exec`, then drain the channel's message stream. There is no single "give me stdout" call. You loop on `channel.wait()` and match `ChannelMsg` variants: `Data` carries stdout bytes, `ExtendedData` carries stderr, and `ExitStatus` gives you the remote exit code. The stream ends when `wait()` returns `None`.

src/exec.rs
use russh::{ChannelMsg, client::Handle};

pub async fn run_command<H: russh::client::Handler>(
    session: &mut Handle<H>,
    command: &str,
) -> anyhow::Result<(String, u32)> {
    let mut channel = session.channel_open_session().await?;
    // first arg = want_reply: ask the server to confirm the exec request
    channel.exec(true, command).await?;

    let mut stdout = Vec::new();
    let mut code: u32 = 0;

    loop {
        let Some(msg) = channel.wait().await else { break };
        match msg {
            ChannelMsg::Data { ref data } => stdout.extend_from_slice(data),
            ChannelMsg::ExtendedData { ref data, ext: 1 } => {
                eprint!("{}", String::from_utf8_lossy(data));
            }
            ChannelMsg::ExitStatus { exit_status } => {
                code = exit_status;
                // Server may keep the channel open; close our side.
                channel.eof().await?;
            }
            _ => {}
        }
    }

    Ok((String::from_utf8_lossy(&stdout).into_owned(), code))
}

One gotcha: do not assume `ExitStatus` is the last message, and do not break the loop on it. Some servers send `ExitStatus` and then close, others send `Eof` and `Close` after. Accumulate output until `wait()` returns `None`, otherwise you can truncate the tail of a large command's output.

Source code on a dark editor screen representing the Rust SSH client implementation
russh keeps everything in one async Rust process: the SSH transport, the command channel, and the SFTP subsystem all share the same authenticated connection.

How do you do SFTP file transfer over the same connection?

SFTP is just another SSH subsystem. You open a fresh session channel, request the `sftp` subsystem, hand the channel's stream to `russh-sftp`, and then you get a filesystem-like API. `SftpSession::new` takes anything that is `AsyncRead + AsyncWrite`, which is exactly what `channel.into_stream()` gives you. From there `read_dir`, `open`, and `create` mirror `std::fs`, and the returned `File` implements the tokio async read/write extension traits.

src/sftp.rs
use russh::client::Handle;
use russh_sftp::client::SftpSession;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

pub async fn sftp_demo<H: russh::client::Handler>(
    session: &mut Handle<H>,
) -> anyhow::Result<()> {
    let channel = session.channel_open_session().await?;
    channel.request_subsystem(true, "sftp").await?;
    let sftp = SftpSession::new(channel.into_stream()).await?;

    // List a directory.
    for entry in sftp.read_dir("/var/www").await? {
        println!("{} ({} bytes)", entry.file_name(), entry.metadata().size.unwrap_or(0));
    }

    // Download: read remote file fully into memory.
    let remote = sftp.read("/etc/hostname").await?;
    tokio::fs::write("hostname.txt", &remote).await?;

    // Upload via a streaming handle (good for large files).
    let mut local = tokio::fs::File::open("deploy.tar.gz").await?;
    let mut buf = Vec::new();
    local.read_to_end(&mut buf).await?;

    let mut remote_file = sftp.create("/tmp/deploy.tar.gz").await?;
    remote_file.write_all(&buf).await?;
    remote_file.flush().await?;
    remote_file.shutdown().await?; // ensures the SFTP CLOSE is sent

    Ok(())
}

A few things that will save you debugging time with the SFTP client:

  • Always `shutdown()` (or `flush()` then drop) an upload handle. If you let the `File` drop without flushing, the final write packet and CLOSE may not reach the server and you get a truncated or zero-byte remote file.
  • `sftp.read(path)` slurps the whole file into a `Vec<u8>`. For multi-gigabyte transfers use `sftp.open(path)` and copy in chunks with `tokio::io::copy` instead, or you will blow up memory.
  • `read_dir` returns entries including `.` and `..` on some servers; filter them before recursing.
  • The SFTP channel is independent of your exec channel. You can run commands and transfer files concurrently over one authenticated connection by opening separate session channels.
The single biggest mistake I see with russh is returning Ok(true) from check_server_key and shipping it. That one line silently turns SSH into plaintext-over-TCP against an active attacker. Verify the fingerprint or you have built a false sense of security.Md Raihan Hasan

Where does this leave you?

You now have the three primitives that cover most automation: an authenticated connection with real host-key verification, remote command execution with captured stdout and exit codes, and SFTP upload and download, all in pure async Rust with no native SSH library to link or cross-compile. That is the foundation. Wrap the connection handle in your own type, add reconnect and timeout policy, and you have a deployment tool, a backup agent, or the engine behind a desktop client. For the interactive terminal layer on top of this, with PTY allocation and an xterm.js front end, continue to the offline SSH client build. Pin your versions, verify your fingerprints, and ship it.