# ssh

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

This is the Markdown version of https://osl-lang.org/packages/ssh. Every page of the OSL docs has one: add .md to its path. https://osl-lang.org/llms.txt lists them all.

```osl
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:

```osl
import "std:ssh"

auto keys = ssh.generateKeyPair("ed25519")
log keys.type
log keys.public.startsWith("ssh-ed25519 ")
log keys.private.startsWith("-----BEGIN")
```

Output:

```text
ed25519
true
true
```

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:

```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
```

Output:

```text
true
ParseError
```

## Save and load keys

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

```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()
```

Output:

```text
-rw-------
true
NotFoundError
```

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:

```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()
```

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

```osl
import "std:ssh"

auto attempt = ssh.connect("127.0.0.1:1", {username: "me", password: "x", timeout: 1000})
log attempt.unwrapErr().kind
```

Output:

```text
NetworkError
```

> **Warning**
>
> `insecureIgnoreHostKey` turns host checking off. That is fine for a test machine, and it removes the protection that keeps someone in the middle out. Don't use it for real servers.

## 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`:

```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()
```

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](/packages/fs.md): `readFile`, `writeFile`, `entries`, `mkdir`, `remove`:

```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()
```

## 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:

```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()
```

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

- [ ] `true`, since the command ran.
- [x] `false`, and `.code` is 3.
- [ ] The call fails with an error.

Answer: The call fails only when the command couldn't be run. A non-zero exit is a normal result that you inspect.

### Exercise: 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.

Starting code:

```osl
import "std:ssh"
import "std:fs"

log "?"
```

It should print:

```text
true
```

Hint 1: `ssh.savePrivateKey(path, keys.private).unwrap()`, then `ssh.loadPrivateKey(path).unwrap()`.

Hint 2: Clean up with `fs.remove("id_exercise", {force: true}).unwrap()`.

A solution:

```osl
import "std:ssh"
import "std:fs"

auto keys = ssh.generateKeyPair("ed25519")
ssh.savePrivateKey("id_exercise", keys.private).unwrap()
log ssh.loadPrivateKey("id_exercise").unwrap() == keys.private
fs.remove("id_exercise", {force: true}).unwrap()
```

## API reference

## API reference

Every member of `std:ssh`, generated from the source of OSL.

### 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.
