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.
| 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 |
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.
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.
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.
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.
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.
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.
git clone https://github.com/syntso/ssh.git
cd ssh
git config core.hooksPath .githooks
cargo testAGPL-3.0. To use the library in a closed product, ask Synt for a commercial license at license@synt.so.