Packages

ssh

Run commands on remote machines, transfer files over SFTP, forward ports, and create and manage SSH keys.

import "std:ssh"

std:ssh connects to a server over SSH. Run a command, move files with SFTP, or forward a port through the connection. It always verifies the server's host key, so you can't be tricked into talking to an impostor without noticing.

Anything that reaches a server is norun. Key handling works offline and runs below, so we start there.

Make a key pair#

ssh.generateKeyPair("ed25519") returns a private and a public key as text. Ed25519 is the modern default, and "rsa" is there for old servers:

main.osl
import "std:ssh"

auto keys = ssh.generateKeyPair("ed25519")
log keys.type
log keys.public.startsWith("ssh-ed25519 ")
log keys.private.startsWith("-----BEGIN")
Click the code to edit it

Put the public key in the server's authorized_keys, and keep the private one to yourself.

Fingerprints#

A fingerprint is a short name for a key, which people compare to check it is the right one:

main.osl
import "std:ssh"

auto keys = ssh.generateKeyPair("ed25519")
log ssh.fingerprint(keys.public).unwrap().startsWith("SHA256:")
log ssh.fingerprint("not a key").unwrapErr().kind
Click the code to edit it

Save and load keys#

savePrivateKey writes a file only its owner can read. loadPrivateKey reads it back:

main.osl
import "std:ssh"
import "std:fs"

fs.remove("id_demo", {force: true}).unwrap()
fs.remove("id_demo.pub", {force: true}).unwrap()

auto keys = ssh.generateKeyPair("ed25519")
ssh.savePrivateKey("id_demo", keys.private).unwrap()
ssh.savePublicKey("id_demo.pub", keys.public).unwrap()

log fs.stat("id_demo").unwrap().mode
log ssh.loadPrivateKey("id_demo").unwrap() == keys.private
log ssh.loadPrivateKey("missing").unwrapErr().kind

fs.remove("id_demo", {force: true}).unwrap()
fs.remove("id_demo.pub", {force: true}).unwrap()
Click the code to edit it

The mode -rw------- is what SSH servers and clients insist on for private keys.

Connect#

ssh.connect(address, options) needs a username and either a password or a privateKey. The server's host key is checked against your ~/.ssh/known_hosts, or pass hostKey to pin one:

main.osl
import "std:ssh"

string key = ssh.loadPrivateKey("id_demo").unwrap()
auto client = ssh.connect("server.example.com", {username: "deploy", privateKey: key}).unwrap()
log client.isConnected()
client.close().unwrap()
Click the code to edit it

A server that is down is a NetworkError, which you can see run:

main.osl
import "std:ssh"

auto attempt = ssh.connect("127.0.0.1:1", {username: "me", password: "x", timeout: 1000})
log attempt.unwrapErr().kind
Click the code to edit it

Run a command#

client.run(command) runs it through the remote shell and returns {stdout, stderr, code, ok}. A command that exits with an error is still a successful call: check ok:

main.osl
import "std:ssh"

auto client = ssh.connect("server.example.com", {username: "deploy", password: "..."}).unwrap()

auto out = client.run("uname -a").unwrap()
log out.stdout.trim()

auto missing = client.run("ls /nowhere").unwrap()
log missing.ok missing.code
log missing.stderr.trim()
client.close().unwrap()
Click the code to edit it

For a command that keeps running or needs input, client.start(command) returns a session you can read from and write to.

Files with SFTP#

client.upload(local, remote) and client.download(remote, local) copy a file. For more control, client.sftp() gives a handle with the shape of fs: readFile, writeFile, entries, mkdir, remove:

main.osl
import "std:ssh"

auto client = ssh.connect("server.example.com", {username: "deploy", password: "..."}).unwrap()
auto files = client.sftp().unwrap()

files.mkdir("/srv/app/logs", {recursive: true}).unwrap()
files.writeFile("/srv/app/logs/hello.txt", "hi from OSL").unwrap()
log files.readFile("/srv/app/logs/hello.txt").unwrap()
for entry in files.entries("/srv/app/logs").unwrap() (
  log entry.name entry.size
)
files.close()
client.close().unwrap()
Click the code to edit it

Port forwarding#

client.forward(local, remote) listens on a local address and sends every connection through SSH to the remote one. It is how you reach a database that only the server can see:

main.osl
import "std:ssh"

auto client = ssh.connect("server.example.com", {username: "deploy", password: "..."}).unwrap()
auto tunnel = client.forward("127.0.0.1:0", "localhost:5432").unwrap()
log "connect your tool to" tunnel.localAddr()
Click the code to edit it
Quick check

client.run("exit 3").unwrap() returns. What is .ok?

Your turn

Key round trip

Generate an ed25519 key pair, save the private key to id_exercise, load it back, and print whether it matches. Delete the file at the end.

main.osl
Ctrl Enter runs
Expected output
true

API reference#

44 members, generated from the std:ssh source of OSL 9.3.0.

Functions

#ssh.connect(string address, any ...options) result<*ssh.Client, error>

connect opens and signs in to an SSH server. address is "host" or "host:port". Options: username (required), password, privateKey, passphrase, knownHosts, hostKey, insecureIgnoreHostKey and timeout (milliseconds, default 10000). Fails with NetworkError, PermissionError, NotFoundError, ParseError or TimeoutError. The deprecated five-argument form returns a disconnected client instead of failing.

#ssh.execRemote(any host, any port, any user, any ...rest) object

Deprecated. Deprecated: use ssh.connect(address, options) and client.run(command) instead.

execRemote(host, port, user, password, privateKey, command) connects, runs one command like exec and disconnects.

#ssh.fingerprint(string publicKey) result<string, error>

fingerprint returns the SHA256 fingerprint of a public key in authorized_keys format, such as "SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s". Fails with ParseError when publicKey isn't one.

#ssh.generateKeyPair(string keyType, object? options) {private: string, public: string, type: string}

generateKeyPair creates a new key pair of keyType, "ed25519" or "rsa" ("" means rsa). It returns an object with private (a PEM private key, unencrypted; save it with savePrivateKey), public (one authorized_keys line) and type. Options: bits (RSA key size, default 2048, from 2048 to 8192). Throws ValueError for another keyType or bits.

#ssh.loadPrivateKey(string path) result<string, error>

loadPrivateKey reads a private key file, for the privateKey option of connect. Fails with NotFoundError or PermissionError when it can't be read.

#ssh.savePrivateKey(string path, string key) result<unit, error>

savePrivateKey writes key to path readable only by its owner (mode 0600), as SSH requires. Fails with NotFoundError, PermissionError or IOError when path can't be written.

#ssh.savePublicKey(string path, string key) result<unit, error>

savePublicKey writes key to path. Fails like savePrivateKey.

#ssh.scpDownload(*ssh.Client client, any remotePath, any localPath) boolean

Deprecated. Deprecated: use client.download(remotePath, localPath) instead.

scpDownload copies a remote file to a local path over SFTP, returning false on failure.

#ssh.scpUpload(*ssh.Client client, any localPath, any remotePath) boolean

Deprecated. Deprecated: use client.upload(localPath, remotePath) instead.

scpUpload copies a local file to the server over SFTP, returning false on failure.

#ssh.tunnel(any localPort, any remoteHost, any remotePort, any ...rest) *ssh.Client

Deprecated. Deprecated: use ssh.connect(address, options) and client.forward(localAddress, remoteAddress) instead.

tunnel(localPort, remoteHost, remotePort, sshHost, sshPort) connects with the SSH_USER, SSH_PASSWORD and SSH_PRIVATE_KEY (a key file path) environment variables and forwards 127.0.0.1:localPort to remoteHost:remotePort, returning the client or null on failure.

*ssh.Client

#client.close() result<unit, error>

close closes the connection, its tunnels and any session started with the deprecated startCommand. Closing twice is a no-op.

#client.download(string remotePath, string localPath) result<unit, error>

download copies the remote file at remotePath to localPath over SFTP, creating missing local directories; a partly written file is removed. Fails like upload.

#client.exec(any command) object

Deprecated. Deprecated: use client.run(command, options?) instead, which returns {ok, code, stdout, stderr, truncated}.

exec runs a command with a 30-second limit and returns {success, output (stdout and stderr mixed), error, timeout, truncated}.

#client.execLimited(any command, any maxBytes) object

Deprecated. Deprecated: use client.run(command, {maxOutput: bytes}) instead.

execLimited is exec keeping at most maxBytes of output.

#client.execTimeout(any command, any timeout) object

Deprecated. Deprecated: use client.run(command, {timeout: milliseconds}) instead.

execTimeout is exec with a limit in seconds (at most 300).

#client.execTimeoutLimited(any command, any timeout, any maxBytes) object

Deprecated. Deprecated: use client.run(command, {timeout: milliseconds, maxOutput: bytes}) instead.

execTimeoutLimited is exec with a limit in seconds and at most maxBytes of output.

#client.forward(string localAddress, string remoteAddress) result<*ssh.Tunnel, error>

forward listens on localAddress (such as "127.0.0.1:8080", or port 0 for any free port) and forwards every connection to it through the SSH server to remoteAddress (such as "db.internal:5432"). It returns a Tunnel; tunnel.localAddr() tells the address it listens on. Connections the server can't make are closed. The tunnel closes with the client. Fails with NetworkError or PermissionError when localAddress can't be used. Throws StateError on a closed client.

#client.isConnected() boolean

isConnected reports whether the client is connected and not closed. It does not contact the server.

#client.readOutput(any timeout) string

Deprecated. Deprecated: use session.read({timeout: milliseconds}) instead, which fails with TimeoutError or StateError instead of returning "".

readOutput returns the next chunk of the startCommand command's output, waiting up to timeout seconds, or "" on timeout or at the end.

#client.run(string command, object? options) result<{code: int, ok: boolean, stderr: string, stdout: string, truncated: boolean}, error>

run runs command through the remote user's shell and returns {ok, code, stdout, stderr, truncated}. Options: timeout (milliseconds), maxOutput (bytes per stream, default 16 MiB), input and check. Fails with TimeoutError or NetworkError; throws StateError on a closed client.

#client.sendInput(any input) boolean

Deprecated. Deprecated: use session.write(data) instead.

sendInput writes to the startCommand command's stdin, returning false on failure.

#client.sftp() result<*ssh.SFTP, error>

sftp opens an SFTP session on the connection and returns an SFTP handle to read, write, list and manage remote files, named like the std:fs functions. Close it when done; it also closes with the client. Fails with UnsupportedError when the server has no SFTP and NetworkError when the connection breaks. Throws StateError on a closed client.

#client.start(string command) result<*ssh.Session, error>

start starts command on the server and returns a Session to talk to it: write sends input, read returns output as it arrives (stdout and stderr together), wait waits for the exit code. Fails with NetworkError when the connection breaks. Throws StateError on a closed client.

#client.startCommand(any command) boolean

Deprecated. Deprecated: use client.start(command) instead, which returns a Session.

startCommand starts one interactive command on the client, returning false when one is already running or it can't start.

#client.upload(string localPath, string remotePath) result<unit, error>

upload copies the local file at localPath to remotePath over SFTP, replacing it. Fails with NotFoundError or PermissionError for either side, IOError when localPath isn't a regular file, and UnsupportedError when the server has no SFTP. Throws StateError on a closed client.

*ssh.Session

#session.close()

close ends the session, killing the command if it is still running. Closing twice is a no-op.

#session.closeInput()

closeInput closes the command's stdin, so a command reading it sees the end of its input. Closing twice is a no-op.

#session.read(object? options) result<string, error>

read waits for output and returns the next chunk (stdout and stderr together, as they arrive). Once the command has exited and all of its output has been read, it fails with StateError, so a read loop ends with string chunk = session.read() catch (err) ( break ). Options: timeout (milliseconds to wait; by default it waits indefinitely). Fails with TimeoutError when the timeout passes. Throws StateError on a closed session.

#session.wait() result<int, error>

wait waits for the command to exit and returns its exit code (-1 when it was killed by a signal). Unread output stays readable. Fails with NetworkError when the connection breaks.

#session.write(byte[] data) result<unit, error>

write sends data to the command's stdin: a string (its UTF-8 bytes), a byte[] or an array of whole numbers from 0 to 255. Fails with NetworkError when the connection breaks or StateError once input is closed. Throws StateError on a closed session.

*ssh.Tunnel

#tunnel.close()

close stops accepting connections. Closing twice is a no-op.

#tunnel.localAddr() string

localAddr returns the address the tunnel listens on, such as "127.0.0.1:41234".

*ssh.SFTP

#sFTP.close()

close ends the SFTP session; the client stays connected. Closing twice is a no-op.

#sFTP.entries(string path) result<{ext: string, isDir: boolean, isSymlink: boolean, modTime: int, mode: string, name: string, path: string, size: int}[], error>

entries lists a remote directory's entries, sorted by name, with the keys of an fs.entries entry: name, path (path joined with name), ext, isDir, isSymlink, size (bytes), modTime (unix milliseconds) and mode (such as "-rw-r--r--"). A symlink reports itself, not its target. Fails like readDir.

#sFTP.exists(string path) boolean

exists reports whether something exists at the remote path. It is false when the path can't be checked. Throws StateError once the SFTP session is closed.

#sFTP.mkdir(string path, object? options) result<unit, error>

mkdir creates a remote directory. Options: recursive (boolean, default false) also creates missing parents and succeeds when the directory already exists. Fails with NotFoundError when a parent is missing (without recursive), IOError when path already exists, or PermissionError. Throws StateError once the SFTP session is closed.

#sFTP.readDir(string path) result<string[], error>

readDir lists the names of the entries in a remote directory, sorted by name. Fails with NotFoundError when the directory doesn't exist, PermissionError, or IOError when path isn't a directory. Throws StateError once the SFTP session is closed.

#sFTP.readFile(string path) result<string, error>

readFile reads a whole remote file as text. Fails with NotFoundError when the file doesn't exist, PermissionError when it can't be read, and IOError or NetworkError for anything else. Throws StateError once the SFTP session is closed.

#sFTP.readFileBytes(string path) result<byte[], error>

readFileBytes reads a whole remote file as bytes. Fails like readFile.

#sFTP.remove(string path) result<unit, error>

remove deletes a remote file, or a directory and everything in it, like fs.remove. A symlink is removed, not its target. Removing a path that doesn't exist does nothing. Fails with PermissionError or IOError when something can't be deleted. Throws StateError once the SFTP session is closed.

#sFTP.rename(string from, string to) result<unit, error>

rename moves or renames a remote file or directory, replacing an existing file at to when the server supports it (OpenSSH does). Fails with NotFoundError when from doesn't exist, PermissionError, or IOError. Throws StateError once the SFTP session is closed.

#sFTP.stat(string path) result<{ext: string, isDir: boolean, isSymlink: boolean, modTime: int, mode: string, name: string, path: string, size: int}, error>

stat describes the remote file, directory or symlink at path, with the same keys as an entries entry. A symlink describes its target, with isSymlink true. Fails with NotFoundError when nothing exists at path. Throws StateError once the SFTP session is closed.

#sFTP.writeFile(string path, string text) result<unit, error>

writeFile replaces a remote file's contents with text, creating it if needed. Fails with NotFoundError when the parent directory doesn't exist, PermissionError, or IOError. Throws StateError once the SFTP session is closed.

#sFTP.writeFileBytes(string path, byte[] data) result<unit, error>

writeFileBytes replaces a remote file's contents with bytes, creating it if needed. data is a byte[], a string (its UTF-8 bytes) or an array of whole numbers from 0 to 255. Fails like writeFile.