SSH¶
Gondolin can expose an SSH server inside the guest VM and provide a host-local
port you can connect to with your regular ssh client.
This is mainly intended for interactive debugging, ad-hoc inspection, and tooling that expects SSH.
What enableSsh() Does¶
When you call vm.enableSsh():
- 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 and installs the public key
into the target user's
authorized_keys.
The returned SshAccess 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
SDK Usage¶
import { VM } from "@earendil-works/gondolin";
const vm = await VM.create();
await vm.start();
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();
If you want a non-root user, the user must exist in the guest image:
Gondolin will install authorized_keys into that user's home directory (from
getent passwd or /etc/passwd).
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 are expected to include them. - 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 service, prefer purpose-built host APIs instead of SSH tunnels.
Outbound SSH (Guest -> Upstream)¶
Separate from vm.enableSsh() (host -> guest SSH access), Gondolin can also
allow outbound SSH from the guest to specific allowlisted upstream hosts.
This is primarily intended for workflows like git over SSH.
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 an in-process SSH server.
- 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_hosts(or a custom verifier).
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
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->proxy SSH client UX. Upstream host key verification
still happens on the host (via known_hosts / --ssh-known-hosts).
CLI¶
See the SSH egress flags in the CLI reference: CLI.
SDK¶
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")
agent: process.env.SSH_AUTH_SOCK,
knownHostsFile: path.join(os.homedir(), ".ssh", "known_hosts"),
// 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 guest
Exec Policy¶
SSH egress supports an execPolicy hook that lets the host allow/deny each SSH
exec request before it is proxied upstream.
For git-over-SSH, you can parse the exec command and restrict access to a
specific 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({
dns: { mode: "synthetic", syntheticHostMapping: "per-host" },
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 };
},
},
});