SSH
Gondolin supports SSH in two directions:
- SSH access to the VM (host -> guest): an
sshdinside the VM, reachable through a host-local port, for your regularsshclient and tools that expect SSH. Mainly intended for interactive debugging and ad-hoc inspection. - SSH egress (guest -> upstream): outbound SSH from the VM to allowlisted hosts, proxied by the host. Primarily intended for git over SSH.
# SSH into the VM
gondolin bash --ssh
# Let the VM use git over SSH with github.com via your ssh-agent
gondolin bash --ssh-allow-host github.com --ssh-agentimport { VM } from "@earendil-works/gondolin";
const vm = await VM.create({
ssh: { allowedHosts: ["github.com"], agent: process.env.SSH_AUTH_SOCK },
});
const access = await vm.enableSsh();
console.error(access.command); // ready-to-run ssh commandYou do not need SSH just to get another shell in a running VM: gondolin
attach does that (see CLI).
SSH Access to the VM
CLI Usage
gondolin bash --sshThe CLI prints the ready-to-run command on stderr (SSH enabled: ssh -p ...)
and keeps the forwarder open while the shell runs. The other flags:
--ssh-user USER: SSH username (default:root)--ssh-port PORT: local listen port (default:0= ephemeral)--ssh-listen HOST: local listen host (default:127.0.0.1)
--ssh works the same way in the
static CLI.
SDK Usage
import { VM } from "@earendil-works/gondolin";
const vm = await VM.create();
const access = await vm.enableSsh({
user: "root", // default
listenHost: "127.0.0.1",
listenPort: 0, // 0 picks an ephemeral port
});
console.error("SSH:", access.command);
// ... use SSH ...
await access.close();
await vm.close();enableSsh() starts the VM if it is not running yet. Calling it again while
the forwarder is open returns the same SshAccess, which includes:
host,port: where to connect on the hostuser: the SSH usernameidentityFile: path to a temporary private key filecommand: a ready-to-runsshcommand stringclose(): shuts down the local forwarder and removes the temporary key material
If you want a non-root user, the user must exist in the guest image:
const access = await vm.enableSsh({ user: "sandbox" });Gondolin installs authorized_keys into that user's home directory (from
getent passwd or /etc/passwd).
How It Works
When you call vm.enableSsh() (or pass --ssh):
- The guest starts
sshdbound to guest loopback only (127.0.0.1:22). - The guest starts
sandboxssh, a small helper that allows the host to open TCP streams to guest loopback. - The host creates a local TCP listener (default
127.0.0.1:<ephemeral>), and forwards each incoming connection to the guest's127.0.0.1:22viasandboxssh. - The host generates an ephemeral Ed25519 keypair (with the host's
ssh-keygen, which must be installed) and installs the public key into the target user'sauthorized_keys.
With a remote daemon the listener is still on your machine; the streams are tunneled through the daemon connection.
Client Command Hardening
The access.command string explicitly disables features that can create host
backchannels or leak credentials if your local SSH config enables them:
ForwardAgent=no(do not forward your host SSH agent)ClearAllForwardings=yes(disable local, remote, and dynamic forwarding)IdentitiesOnly=yes(use only the provided key)
It also disables host key persistence to avoid prompting:
StrictHostKeyChecking=noUserKnownHostsFile=/dev/null
For fully non-interactive use, you may also want:
-o BatchMode=yes-o LogLevel=ERROR
Server Side Hardening
The guest sshd is started with additional restrictions:
- public key auth only (no password, no keyboard-interactive)
AllowAgentForwarding=noAllowTcpForwarding=noX11Forwarding=noPermitTunnel=noAllowUsers=<user>
This is defense in depth so it stays safe even if a user runs their own ssh
command without the recommended options.
Notes and Limitations
- The guest image must include
sshd(OpenSSH) andsandboxssh. Default images include them;enableSsh()fails withsshd not found in guest imageorsandboxssh not found in guest imageotherwise. - The SSH server is only reachable through the host-local forwarder. It is not exposed on the guest network.
- Port forwarding is intentionally disabled. If you need host <-> guest connectivity for a specific HTTP service, use ingress instead of SSH tunnels.
SSH Egress
SSH egress lets the guest open SSH connections to specific allowlisted upstream hosts without granting it arbitrary TCP access.
How it works:
- The guest connects to
HOST:PORTas usual (default22; non-standard ports are enabled by allowlistingHOST:PORT). - The host intercepts that TCP flow (SSH is only allowed when explicitly
configured) and terminates it in a host-side SSH server (in
gondolind). - For each guest
execrequest, the host opens an upstream SSH connection to the real destination using either:- a host ssh-agent, or
- a configured private key
- Upstream host keys are verified on the host via OpenSSH
known_hostsor a customhostVerifiercallback. - SSH egress needs synthetic DNS (the default) with per-host mapping; the
mapping defaults to
per-hostwhen SSH egress is configured, and other DNS settings are rejected (see Network Access).
The guest never sees your keys or agent: it authenticates to the host-side proxy, and the host authenticates upstream.
CLI Usage
Git over SSH using your host ssh-agent:
gondolin bash \
--ssh-allow-host github.com \
--ssh-agent \
--ssh-known-hosts ~/.ssh/known_hostsOn a non-standard port:
gondolin bash \
--ssh-allow-host ssh.github.com:443 \
--ssh-agent \
--ssh-known-hosts ~/.ssh/known_hostsWith a dedicated key and its passphrase from the environment (the host of an
--ssh-credential is allowlisted automatically):
export GIT_KEY_PASSPHRASE='...'
gondolin bash \
--ssh-credential git@github.com=~/.ssh/id_ed25519,passphrase-env=GIT_KEY_PASSPHRASE \
--ssh-known-hosts ~/.ssh/known_hostsInteractive passphrase prompting is not supported; prefer passphrase-env.
In shells, ~/.ssh/known_hosts is only expanded when unquoted (otherwise use
$HOME/.ssh/known_hosts). The full flag reference is in the
CLI reference.
SDK Usage
import os from "node:os";
import path from "node:path";
import { VM } from "@earendil-works/gondolin";
const vm = await VM.create({
dns: {
mode: "synthetic",
syntheticHostMapping: "per-host",
},
ssh: {
allowedHosts: ["github.com"],
// Non-standard ports can be allowlisted as "HOST:PORT" (e.g. "ssh.github.com:443")
// Authenticate upstream using host ssh-agent OR a configured private key
agent: process.env.SSH_AUTH_SOCK,
// credentials: { "github.com": { username: "git", privateKey: "..." } },
// Verify upstream host keys (recommended)
knownHostsFile: path.join(os.homedir(), ".ssh", "known_hosts"),
// Optional: allow/deny individual ssh exec requests (useful for git repo filtering)
// execPolicy: (req) => ({ allow: true }),
// Optional safety/perf knobs:
// maxUpstreamConnectionsPerTcpSession: 4,
// maxUpstreamConnectionsTotal: 64,
// upstreamReadyTimeoutMs: 15_000,
// upstreamKeepaliveIntervalMs: 10_000,
// upstreamKeepaliveCountMax: 3,
},
});
// Now commands like `git clone git@github.com:org/repo.git` can work inside the guestThe dns block is optional (those are the defaults when ssh.allowedHosts
is set).
Options
VMOptions.ssh (SshOptions):
| Option | Meaning |
|---|---|
allowedHosts |
host patterns the guest may reach over SSH (* wildcards; HOST:PORT for non-standard ports, default port 22) |
agent |
host ssh-agent socket path (e.g. $SSH_AUTH_SOCK) |
credentials |
host pattern -> { username?, privateKey, passphrase? } (OpenSSH/PEM key as string or Buffer) |
knownHostsFile |
OpenSSH known_hosts file(s) for host key verification (default: ~/.ssh/known_hosts and /etc/ssh/ssh_known_hosts) |
hostVerifier |
(hostname, key, port) => boolean callback instead of known_hosts |
execPolicy |
allow/deny callback for each guest exec request (see below) |
hostKey |
host key the proxy presents to the guest (default: ephemeral) |
maxUpstreamConnectionsPerTcpSession |
max concurrent upstream connections per guest TCP flow |
maxUpstreamConnectionsTotal |
max concurrent upstream connections across all guest flows |
upstreamReadyTimeoutMs |
upstream connect and handshake timeout in ms |
upstreamKeepaliveIntervalMs |
upstream keepalive interval in ms |
upstreamKeepaliveCountMax |
upstream keepalive probes before disconnect |
Unlike --ssh-credential, ssh.credentials does not add hosts to
allowedHosts: list them there too.
Exec Policy
execPolicy lets the host allow or deny each SSH exec request before it is
proxied upstream. It receives an SshExecRequest:
hostname,port: the upstream target (from synthetic DNS attribution)guestUsername: the username the guest authenticated ascommand: the rawexeccommandsrc:{ ip, port }of the guest flow
It returns (or resolves to) { allow: true } or { allow: false, exitCode?,
message? }: the guest channel gets message on stderr and exits with
exitCode (default 1).
For git over SSH, getInfoFromSshExecRequest(req) parses the canonical git
commands into { service, repo } (for example git-upload-pack and
org/repo.git), and returns null for anything else (fail closed). Restrict
access to a set of repos:
import { VM, getInfoFromSshExecRequest } from "@earendil-works/gondolin";
const allowedRepos = new Set(["my-org/repo-a.git", "my-org/repo-b.git"]);
const vm = await VM.create({
ssh: {
allowedHosts: ["github.com"],
agent: process.env.SSH_AUTH_SOCK,
execPolicy: (req) => {
const git = getInfoFromSshExecRequest(req);
if (!git) return { allow: false, message: "non-git ssh denied" };
if (!allowedRepos.has(git.repo)) return { allow: false, message: "repo not allowed" };
if (git.service === "git-receive-pack") return { allow: false, message: "push disabled" };
return { allow: true };
},
},
});execPolicy and hostVerifier run in your Node.js process; the daemon asks
for each decision.
Guest SSH Client Options (git)
The guest's OpenSSH client is connecting to Gondolin's host-side SSH proxy. That proxy uses an ephemeral host key and does not currently support post-quantum key exchange, so OpenSSH may show:
Permanently added .../ host key prompts** WARNING: connection is not using a post-quantum key exchange algorithm.
For non-interactive tools like git, you can suppress prompts and these
warnings:
export GIT_SSH_COMMAND='ssh \
-o BatchMode=yes \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o GlobalKnownHostsFile=/dev/null \
-o LogLevel=ERROR'This only affects the guest-to-proxy SSH client UX. Upstream host key
verification still happens on the host (via known_hosts /
--ssh-known-hosts).
Limitations
- Only non-interactive
execchannels are supported- interactive shells are denied
- subsystems (such as
sftp) are denied
- Upstream connections are resource-capped and time-bounded to avoid guest-triggerable host DoS.
- SSH egress flows bypass HTTP hooks and secret placeholders; the
execpolicy is the hook point. - With a remote daemon,
ssh.agent(--ssh-agent) is not supported (the agent socket is on your machine; usecredentials), andknown_hostsfiles are read on your machine and sent to the daemon as content.
For the protocol-level details, see Network Stack.