@meitaim/ssh
v1.0.0
Published
SSH client for Bun, Node and Cloudflare Workers
Readme
SSH client for Bun, Node and Cloudflare Workers. One implementation in TypeScript, fully typed, no native addons: the runtime supplies the TCP socket and nothing else.
Install
bun add @meitaim/sshConnect
Trust a host the first time you see it, the way ssh does with StrictHostKeyChecking=accept-new, then hold it to that key.
import { appendFileSync, readFileSync } from "node:fs";
import { connect, knownHosts, knownHostsLine } from "@meitaim/ssh";
const target = { host: "example.com", port: 22 };
const file = "/home/deploy/.ssh/known_hosts";
const trusted = knownHosts(readFileSync(file, "utf8"), target);
const client = await connect({
...target,
username: "deploy",
password: "hunter2",
verifyHostKey: (key) => {
if (trusted.status(key) !== "unknown") return trusted(key);
appendFileSync(file, `${knownHostsLine({ ...target, key })}\n`);
return true;
},
});
const { stdout, code } = await client.exec("uname -a");
await client.close();verifyHostKey is required and receives the key the server presented. A key you already trust needs no file: verifyHostKey: acceptHostKeys("SHA256:..."), with the fingerprint from ssh-keyscan example.com | ssh-keygen -lf -.
A private key file works in place of the password, with passphrase when it is encrypted.
const client = await connect({
...target,
username: "deploy",
privateKey: readFileSync("/home/deploy/.ssh/id_ed25519", "utf8"),
verifyHostKey,
});spawn returns the channel instead of waiting, so a long command streams.
const channel = await client.spawn("journalctl -f");
for await (const chunk of channel.stdout) process.stdout.write(chunk);openSftp moves files over the same connection.
import { openSftp } from "@meitaim/ssh/sftp";
const files = await openSftp(client);
await files.write("/tmp/report.csv", contents);
const back = await files.read("/tmp/report.csv");
for (const entry of await files.list("/tmp")) console.log(entry.name, entry.attributes.size);
await files.close();forward opens the tunnel ssh -L builds: a channel to whatever listens at the address the server dials.
const tunnel = await client.forward({ host: "127.0.0.1", port: 5432 });
await tunnel.write(query);The types on SshClient and SshChannel carry the rest: shell, listen, agents, security keys, jump hosts and host key scanning.
Entry points
| Import | Holds |
| -------------------------- | -------------------------------------------------------------- |
| @meitaim/ssh | Everything below plus connect, scanHostKeys and the client |
| @meitaim/ssh/sftp | openSftp, Sftp, SftpError |
| @meitaim/ssh/keys | parsePrivateKey, generateKey, security keys, sshsig |
| @meitaim/ssh/known-hosts | knownHosts, sshfp, public key parsing |
| @meitaim/ssh/config | parseSshConfig |
| @meitaim/ssh/socks | serveSocks, copyBothWays |
| @meitaim/ssh/agent | sshAgent, bindAgentSession |
sftp, known-hosts, config and socks bundle without the transport.
