Skip to content
syntsoPublic

About

Fast, careful SSH for Rust: pooled connections, ProxyJump, sessions, SFTP and tunnels

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Synt SSH

An SSH client library for Rust, built on russh, that reads your OpenSSH setup and refuses hosts it does not know.

It is the SSH layer of Synt and works on its own, without the rest of Synt.


What's inside

Targets user@host:port, ssh:// URIs and aliases from ~/.ssh/config, with ProxyJump chains up to 8 hops
Host keys Checked against ~/.ssh/known_hosts; an unknown or changed key stops the connection
Logins Keys in ssh-agent, IdentityFile and the default key files, or a key from your own vault
Connection pool One connection per target, shared by every caller and kept for a grace period after the last one leaves
Commands Run with a timeout and an output cap; exit code, signal and the negotiated algorithms come back
Terminals Interactive shells with a PTY, resize and hang-up
Files SFTP listing, stat, read, write, rename, remove, and streamed downloads and uploads
Tunnels Local port forwards on 127.0.0.1 with traffic counters
Imports A pasted ssh command turned into a target, jumps and forwards, with the options it ignored

Install

Add it from Git:

[dependencies]
synt-ssh = { git = "https://github.com/syntso/ssh", tag = "v0.2.0" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

You need Rust 1.98 or later.


Usage

Run a command

use std::future;

use synt_ssh::{ExecLimits, Pool, SshError, Target};

#[tokio::main]
async fn main() -> Result<(), SshError> {
    let pool = Pool::new();
    let target = Target::resolve("deploy@api-1.example.com")?;

    let outcome = pool
        .exec(&target, "uptime", ExecLimits::default(), future::pending())
        .await?;

    print!("{}", outcome.output.stdout);
    println!("exit code: {:?}", outcome.exit);

    Ok(())
}

Target::resolve reads ~/.ssh/config, so a Host api-1 entry with its HostName, User, Port, IdentityFile and ProxyJump works as Target::resolve("api-1"). The last argument of exec is a future that stops the command when it completes; future::pending() lets it run until it ends or hits the timeout (10 minutes by default, with 1 MiB of output).

The host must already be in ~/.ssh/known_hosts. Connect once with ssh to add it.

List files

use synt_ssh::{FileSessions, Pool, SshError, Target};

#[tokio::main]
async fn main() -> Result<(), SshError> {
    let pool = Pool::new();
    let target = Target::resolve("api-1")?;
    let files = FileSessions::new(pool);

    let listing = files
        .with(&target, |sftp| async move { sftp.list("/var/log").await })
        .await?;

    for entry in listing.entries {
        println!("{:?} {} {:?}", entry.kind, entry.name, entry.size);
    }

    Ok(())
}

FileSessions keeps one SFTP session per target and opens it again when the server closes it.

Forward a port

use synt_ssh::{Destination, Pool, SshError, Target, Tunnels};
use tokio_util::sync::CancellationToken;

#[tokio::main]
async fn main() -> Result<(), SshError> {
    let pool = Pool::new();
    let target = Target::resolve("api-1")?;
    let tunnels = Tunnels::new(pool);
    let stop = CancellationToken::new();

    let destination = Destination::parse("10.0.2.5:5432")?;
    let status = tunnels.open(&target, destination, None, stop.clone()).await?;
    println!("127.0.0.1:{} -> {}", status.local_port, status.destination);

    tokio::signal::ctrl_c().await.ok();
    stop.cancel();

    Ok(())
}

This one also needs tokio-util and the signal feature of tokio. Pass a port instead of None to choose the local port.

Import an ssh command

use synt_ssh::{CommandLine, SshError};

fn main() -> Result<(), SshError> {
    let line = CommandLine::parse("ssh -J admin@bastion -p 2222 -L 5432:10.0.2.5:5432 deploy@api-1")?;

    let destination = line.destination();
    println!("{:?}@{} port {:?}", destination.user(), destination.host(), destination.port());

    for jump in line.jumps() {
        println!("through {}", jump.host());
    }

    for forward in line.forwards() {
        println!("127.0.0.1:{} -> {}", forward.local_port(), forward.remote());
    }

    Ok(())
}

Options a stored host does not keep, such as -v or -o ConnectTimeout=10, are listed in line.ignored(); -o StrictHostKeyChecking=no and -A are listed there with a note, because host keys are always checked and the agent is never forwarded. Shell syntax, a command after the destination and a ProxyCommand other than ssh -W %h:%p through one host are refused.

Keys from your own vault

use std::future;
use std::sync::Arc;

use synt_ssh::{ExecLimits, KeyVault, Pool, SshError, Target};
use zeroize::Zeroizing;

struct Keychain;

impl KeyVault for Keychain {
    fn private_key(&self, name: &str) -> Result<Option<Zeroizing<Vec<u8>>>, SshError> {
        let openssh_text = std::fs::read(format!("/secure/keys/{name}")).ok();

        Ok(openssh_text.map(Zeroizing::new))
    }
}

#[tokio::main]
async fn main() -> Result<(), SshError> {
    let pool = Pool::builder().key_vault(Arc::new(Keychain)).build();
    let target = Target::new("10.0.2.5", 22, "deploy").with_vault_key("deploy");

    let outcome = pool
        .exec(&target, "id", ExecLimits::default(), future::pending())
        .await?;
    print!("{}", outcome.output.stdout);

    Ok(())
}

The vault returns an unencrypted private key in OpenSSH form. A target with a vault key logs in with that key only. StoredKey::import and StoredKey::generate_ed25519 give you that form from a key file or a new Ed25519 key.


Build

git clone https://github.com/syntso/ssh.git
cd ssh
git config core.hooksPath .githooks
cargo test

License

AGPL-3.0. To use the library in a closed product, ask Synt for a commercial license at license@synt.so.

About

Fast, careful SSH for Rust: pooled connections, ProxyJump, sessions, SFTP and tunnels

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages