Run commands on a remote host over SSH from Node.js.
It shells out to the ssh binary you already have, rather than reimplementing
the protocol. What it adds is the part that is genuinely awkward to get right:
password authentication where the password never touches the argv, the
environment, or the disk — no sshpass, no pseudo-terminal.
import { CommandSSH } from '@bleed-believer/command-ssh';
const ssh = new CommandSSH({
hostname: 'example.com',
username: 'deploy',
password: 'hunter2',
encoding: 'utf-8'
});
const { code, stdout } = await ssh.execute('ls', '-la', '/srv');
console.log(code, stdout);- Node.js >= 20
- An
sshclient on thePATH. OpenSSH 8.4 or newer is recommended: that is the version that introducedSSH_ASKPASS_REQUIRE=force, which is what lets the password be delivered without a terminal. Older versions are handled through aDISPLAYfallback, which is less reliable. - A UNIX-like host for password authentication: the secret travels through a UNIX domain socket.
Public key authentication has none of those constraints — leave password out
and ssh uses your agent and your keys as usual.
npm install @bleed-believer/command-sshResolves once the remote command is over, with its exit code and whatever it wrote. The stdin of the remote command is closed immediately, so a command that reads it gets its EOF instead of hanging.
const ssh = new CommandSSH({
hostname: 'example.com',
username: 'deploy',
encoding: 'utf-8'
});
const { code, stdout, stderr } = await ssh.execute('systemctl', 'is-active', 'nginx');Without encoding, stdout and stderr come back as Buffer. With one, they
are string, already trimmed. The type follows the option:
const raw = new CommandSSH({ hostname, username });
(await raw.execute('cat', '/etc/hostname')).stdout; // Buffer | undefined
const text = new CommandSSH({ hostname, username, encoding: 'utf-8' });
(await text.execute('cat', '/etc/hostname')).stdout; // string | undefinedWhen the options are typed as
CommandSSHOptionsrather than inferred from a literal,encodingisBufferEncoding | undefinedand no type can tell which one it will be at runtime. The result isstring | Buffer | undefinedthere, and you narrow it. Pass the literal if you want the narrow type.
Either stream is undefined when the command wrote nothing to it.
Hands back the live ChildProcess, for a command you want to stream, or feed:
const child = await ssh.spawn('tail', '-f', '/var/log/syslog');
child.stdout.on('data', chunk => process.stdout.write(chunk));
child.on('close', code => console.log('done', code));Its stdin is a real, writable pipe, so an interactive remote command works:
const child = await ssh.spawn('sort');
child.stdin.write('pear\nfig\napple\n');
child.stdin.end(); // the remote command waits for this EOFBy default every call — execute or spawn — opens its own connection, with
its own credential helper. Two concurrent commands never share state.
Ten commands are ten connections: ten handshakes, ten authentications, and with a password, the secret asked for ten times. Against a host that counts repeated logins, that is also how an account gets locked out.
connect opens one connection and lends it to every command that follows,
using the connection multiplexing of OpenSSH:
await using ssh = new CommandSSH({
hostname: 'example.com',
username: 'deploy',
password: 'hunter2',
encoding: 'utf-8'
});
await ssh.connect(); // one handshake, one authentication
await ssh.execute('systemctl', 'is-active', 'nginx');
await ssh.execute('df', '-h');
const child = await ssh.spawn('tail', '-f', '/var/log/syslog');
// `await using` closes it at the end of the scope. Without it, call
// `await ssh.close()` yourself.What holds the connection open is a real ssh -N process: it runs no remote
command and exists only to own the socket. That is deliberate — the usual
alternative, letting the first command promote itself with ControlPersist,
leaves an authenticated connection alive in the background with nobody owning
it, outliving the process that opened it. Here it has an owner, and close is
what ends it.
The socket lives in a directory created with 0700 permissions, because once
the master has authenticated, whoever can reach that socket can run commands
through the connection without proving anything. If the host process is killed,
exit and signal handlers kill the master and wipe the directory: an orphaned
master is an authenticated session nobody owns any more.
With a password, the credential is used exactly once. The master
authenticates with it, the helper is torn down the moment the connection is up,
and every command afterwards runs with BatchMode=yes and no credential in
play at all.
If the connection dies — the master killed, the network gone — commands do not quietly open one of their own. They fail:
await ssh.connect();
// ...the master dies...
await ssh.execute('ls'); // throws: the connection is not availableclose is the exact inverse of connect: afterwards commands go back to
opening a connection each, and the same instance can be connected again.
| Option | Type | Default | |
|---|---|---|---|
hostname |
string |
required | |
username |
string |
required | |
password |
string |
— | Enables password authentication. Left out, ssh uses your keys and agent. |
encoding |
BufferEncoding |
— | Decodes and trims stdout/stderr for execute. |
timeout |
number |
— | Milliseconds for the whole thing. See below. |
connectTimeout |
number |
20_000 |
Milliseconds connect may take to bring the connection up. Unrelated to timeout. |
hostKeyChecking |
'accept-new' | 'yes' | 'no' |
'accept-new' |
See below. |
shell |
boolean |
false |
See below. |
legacyAlgorithms |
boolean |
false |
Appends ssh-rsa to the negotiable algorithms, for servers that offer nothing newer. Password authentication only. |
cwd |
string |
— | Working directory of the local ssh process. |
env |
NodeJS.ProcessEnv |
process.env |
Environment of the local ssh process. |
There is no default: without one, a command that never returns never returns. With one, the deadline covers everything — resolving the host, the handshake, the authentication and the remote command — because from the outside those are indistinguishable.
On expiry the process is killed with SIGTERM and an error named
TimeoutError is raised on it:
const ssh = new CommandSSH({ hostname, username, timeout: 30_000 });
try {
await ssh.execute('sleep', '600');
} catch (error) {
error.name; // 'TimeoutError'
}For spawn, that error reaches whoever listens — and, as with any error of a
child process, nobody listening means it is thrown. A spawned child with a
timeout owes an error listener.
What ssh does when the host key is not the one known_hosts expects.
accept-new(default) trusts a host it has never seen, and refuses one whose key changed. It is the default so that a first connection does not fail. It is still trust on first use: a man in the middle on that first connection is handed the password.yesrefuses anything not already inknown_hosts. The right setting once the host is known, and the recommended one when apasswordis involved.noaccepts anything, every time. No authentication of the server is left.
By default the program and every argument are quoted before being handed to the remote shell, so an argument is only ever an argument.
shell: true sends the command through unquoted, which is what makes
pipes, redirections, &&, globs and variable expansion work — and what makes
'hi; rm -rf ~' a second command rather than an argument:
// Safe with any input, shell syntax is inert:
await ssh.execute('grep', userInput, '/var/log/app.log');
// Only ever for a fixed script of your own:
const shellSSH = new CommandSSH({ hostname, username, shell: true });
await shellSSH.execute('cat /etc/os-release | grep ^ID=');Never turn it on for a command built from input you did not write yourself.
ssh has no option that takes a password, on purpose. The usual workarounds
either put it in the argv (visible in ps to every user on the machine), in
the environment (readable through /proc/<pid>/environ), or on disk.
This library uses the one door OpenSSH leaves open, SSH_ASKPASS:
- A helper script is written into a temporary directory created with
0700permissions, so no other user can even list it. The script contains no secret — only the path of a socket. - A UNIX domain socket is opened inside that same directory. The password
exists as a
Bufferin this process' memory and nowhere else. sshruns withSSH_ASKPASS_REQUIRE=force, so it asks the helper instead of a terminal. The helper reads the socket and echoes the secret back.- The secret is delivered once. The buffer is zeroed the moment the bytes
are on the wire, and any later peer gets nothing —
sshruns withNumberOfPasswordPrompts=1, so whoever asks afterwards is not the helper. - The socket and the directory are torn down when the process ends, through a
hook the consumer cannot detach, plus
exitand signal handlers for the cases where the process is killed outright.
The password never reaches the argv, the environment, or the disk.
With connect, all of the above happens once for the whole session instead
of once per command, and step 5 comes right after step 4 rather than at the end
of the last command.
Beyond that, the argv always closes its options with --, so a username
like -oProxyCommand=... stays a (bad) destination rather than becoming an
option ssh would obey — which would run a command locally, before ever
authenticating.
MIT