Remote Machines
The VM does not have to run on the machine your program runs on. The Gondolin
daemon (gondolind) needs neither Node.js nor any other runtime, so you can
start it on another machine (for example over SSH) while your policy,
credentials, hooks and file providers stay in your local Node.js process (the
controller).
gondolin --native-command "ssh build-box gondolind" --remote bashimport { VM } from "@earendil-works/gondolin";
const vm = await VM.create({
native: {
command: ["ssh", "build-box", "gondolind"],
remote: true,
image: "alpine-base:latest",
},
});The remote machine needs the gondolin binary (see
Getting the Binary onto Another Machine)
and QEMU or the krun runner. Node.js is only needed where the controller runs.
How It Works
The controller talks to the daemon over the daemon's stdin/stdout. Anything
that gives you such a byte stream works: ssh host gondolind, a container
exec, or your own transport. The daemon starts the VMM, runs the network
stack and serves the guest filesystem on its machine, and calls back into the
controller for the things that live in your process (JavaScript hooks, custom
VFS providers, files of your machine).
The daemon exits (and kills its VM) when its stdin closes, so a crashed or killed controller never leaves VMs running behind. On Linux QEMU is also tied to the daemon's lifetime; on macOS a small watchdog process does the same.
For the process model and protocol, see Architecture.
Configuration
SDK
VMOptions.native says how to reach the daemon:
| Option | Meaning |
|---|---|
command |
argv that starts gondolind with the controller protocol on its stdin/stdout (e.g. ["ssh", host, "gondolind"]) |
connect() |
returns a connection (wins over command) |
remote |
the daemon runs on another machine: no shared filesystem |
image |
image selector for the daemon (name:tag, build id, or a directory on the daemon's machine); default: the daemon's default image |
checkpoint |
local checkpoint file to boot from; set by VmCheckpoint.resume() for remote daemons, you rarely set it yourself |
Without command or connect, the SDK starts the local gondolind binary
(see Installation). command without remote is for daemons
that share your filesystem (a wrapper script, a different binary); set
remote: true whenever the daemon cannot see your files.
Environment Variables
The same settings are available as environment variables (explicit
VMOptions.native fields win):
GONDOLIN_NATIVE_COMMAND: the daemon command, as whitespace-separated words or a JSON array ('["ssh", "build-box", "gondolind"]')GONDOLIN_NATIVE_REMOTE=1(ortrue): the daemon is remoteGONDOLIN_NATIVE_IMAGE: image selector for the daemon
See Environment Variables for the full list.
CLI
The Node CLI takes the same settings as global options, before the command;
they apply to every VM the command starts (including bash --resume,
--listen and --ssh):
gondolin --native-command "ssh build-box gondolind" --remote bash
gondolin --native-command='["ssh", "build-box", "~/bin/gondolin", "daemon"]' --remote exec -- uname -a| Flag | Meaning |
|---|---|
--native-command CMD |
start the daemon with CMD (words, or a JSON array); same as GONDOLIN_NATIVE_COMMAND |
--remote |
the daemon runs on another machine; same as GONDOLIN_NATIVE_REMOTE=1 |
gondolind is the name the npm platform package installs; where only the
binary was copied, start it as gondolin daemon (for example
--native-command "ssh build-box ~/bin/gondolin daemon").
The static CLI runs the VM in-process and does not support these flags; use the Node CLI for remote machines.
gondolin list, attach and exec --sock work for remote VMs as for local
ones: the session registry lives on the controller's machine.
What Remote Changes
With remote: true the controller never sends paths of its own machine to the
daemon (it refuses to, so mistakes fail loudly instead of pointing at the wrong
files):
- Images are resolved and downloaded by the daemon from its own image
cache (the same layout as on a local machine,
~/.cache/gondolin/images).native.imagewins oversandbox.imagePath, which must be a selector string; explicit asset paths are rejected. Without either, the daemon uses its default image, soGONDOLIN_DEFAULT_IMAGE,GONDOLIN_GUEST_DIRandGONDOLIN_IMAGE_STOREapply in the daemon's environment, not yours. - Root disks are prepared by the daemon: the
rootfs.mode(cow,memory,readonly, or the image manifest's default) androotfs.sizework as usual, and the copy-on-write overlay lives in the daemon's session directory. - Checkpoints (
vm.checkpoint(),gondolin snapshot) are written on the controller in the usual single-file format: the daemon stops the VM and streams its disk overlay. Resuming (VmCheckpoint.resume(),gondolin bash --resume) uploads the checkpoint to the daemon, which finds the matching image by build id. See Persistence and Snapshots. - TLS interception uses the daemon's own MITM CA (created and cached on
the daemon's machine); the guest trusts that one. Upstream certificates are
still verified against the controller's Node.js CA store (including
NODE_EXTRA_CA_CERTS). - VFS mounts of controller-side directories (
RealFSProvider, also wrapped inReadonlyProvider/ShadowProvider, and--mount-hostfs) are served from the controller through the object tier, since the daemon cannot see those files; every uncached operation is a round trip over the connection. To mount a directory on the daemon's machine, usenativeMounts.host("/path/on/daemon"). See Files and Mounts. - HTTP hooks written in JavaScript (
onRequest,onResponse, ...) run in the controller, so each hooked request crosses the connection. Declarative policy andcreateDjsHooks()policies run in the daemon (see HTTP Hooks and Policies). - SSH egress credentials (
ssh.credentials) are sent to the daemon;ssh.agentis not supported remotely (the agent socket is on the controller).known_hostsfiles are read by the controller and sent as content. See SSH. - DNS: the controller's resolvers are not used; the daemon uses explicitly
configured servers (
dns.trustedServers) or its own/etc/resolv.conf. - SSH and ingress access (
vm.enableSsh(),vm.enableIngress(),--ssh,--listen) listen on the controller's machine; the streams are tunneled through the daemon connection. - Backend: the default is
qemu, as the controller cannot see whether the daemon's machine has a krun runner and/dev/kvm. Setsandbox.vmm: "krun"(orGONDOLIN_VMM=krun,--vmm krun) to use krun; the runner issandbox.krunRunnerPathon the daemon's machine, elsegondolin-krun-runnerfrom itsPATH.sandbox.qemuPath, if set, is a path on the daemon's machine too. See VM Backends. vm.getHostPid()returnsnull(the VMM runs on another machine).- Not supported remotely (they name files on the controller's machine):
sandbox.rootDiskPath,rootDiskFormat,rootDiskReadOnly,rootDiskDeleteOnClose, thesandbox.virtio*SocketPathoverrides, andsandbox.imagePathas an object of asset paths.
Some configuration errors are reported when the VM starts rather than when
VM.create() returns, because the daemon validates them on its machine (for
example SSH credential files, a missing QEMU binary, an unknown image).
Connections
native.connect() returns an object with this shape (NativeConnection);
pi-env's openProcess returns a compatible object, and
spawnNativeConnection(command, { env?, cwd? }) builds one from a local
command:
type NativeConnection = {
/** write to the daemon's stdin; `false` or a pending promise means "buffer full" */
write(data: Buffer): boolean | void | Promise<unknown>;
/** daemon stdout */
onStdout(listener: (data: Buffer) => void): void;
/** daemon stderr (diagnostics, optional) */
onStderr?(listener: (data: Buffer | string) => void): void;
/** close the daemon's stdin: it stops the VM and exits */
close(): void | Promise<unknown>;
/** resolves once the daemon exited */
exited: Promise<{ code: number | null; signal?: string | null }>;
/** optional: forcefully terminate the daemon */
kill?(): void;
/** optional: resolves once `write()` accepts more data */
waitDrain?(): Promise<void>;
/** optional: local process id (local children only) */
pid?: number | null;
};import { VM, spawnNativeConnection } from "@earendil-works/gondolin";
const vm = await VM.create({
native: {
remote: true,
// `env`: a pi-env environment (or anything returning a NativeConnection)
connect: async () => env.openProcess("gondolind", []),
},
});
// equivalent to native.command, with a custom environment for ssh
const vm2 = await VM.create({
native: {
remote: true,
connect: () =>
spawnNativeConnection(["ssh", "build-box", "gondolind"], {
env: { ...process.env, SSH_AUTH_SOCK: "/path/to/agent.sock" },
}),
},
});Lines the daemon writes to stderr arrive in your
debug log as the error component.
Getting the Binary onto Another Machine
The daemon ships as one multi-call binary, gondolin: the static CLI, and
the daemon when started as gondolin daemon (or under the name gondolind,
e.g. through a symlink). One file is all a remote machine needs; Linux builds
are static musl binaries.
getNativeBinaryPath({ platform, arch }) finds a binary for any supported
platform (darwin or linux, arm64 or x64) without network access: a
development checkout (native/target/dist/<target>/ from make
native-release for other platforms), an installed platform package, or the
download cache. ensureNativeBinary({ platform, arch }) also downloads the
matching platform package from the npm registry (checking its integrity) into
~/.cache/gondolin/native/<version>/<platform>-<arch>/. Package managers only
install the platform package matching your machine, so this is how a macOS
controller gets the Linux binary.
Both return { path, args }: the binary and the arguments that start the
daemon (["daemon"] for the multi-call gondolin, none for a file named
gondolind):
import { ensureNativeBinary } from "@earendil-works/gondolin";
const { path, args } = await ensureNativeBinary({ platform: "linux", arch: "x64" });
// copy `path` to the remote machine (e.g. as ~/bin/gondolin), then
// native: { command: ["ssh", host, "~/bin/gondolin", ...args], remote: true }Related helpers:
nativeDaemonArgs(binary): the daemon arguments for a binary pathnativeDaemonCommand(binary):[binary, ...nativeDaemonArgs(binary)]nativePackageName({ platform, arch }): the npm package carrying the binary (@earendil-works/gondolin-native-linux-x64, ...)nativeRustTarget({ platform, arch }): its Rust target triple
ensureNativeBinary() accepts version (default: the SDK's version, which
should match the daemon) and registry (default: $npm_config_registry or
https://registry.npmjs.org).
The daemon also brings its own image tooling, which is handy on machines
without Node.js, for example to pre-pull images (gondolind ARGS is the same
as gondolin daemon ARGS; the static CLI's gondolin image manages the same
cache):
gondolin daemon --version
gondolin daemon assets list [--json]
gondolin daemon assets pull alpine-base:latest [--arch ARCH] [--json]
gondolin daemon assets resolve [SELECTOR] [--arch ARCH] [--offline] [--json]assets resolve without a selector resolves the default image. See
Images.
Troubleshooting
not supported with a remote daemon: ...: the named option names
files on the controller's machine (see
What Remote Changes). Drop it, or use the remote
equivalent (native.image, nativeMounts.host()).
remote session must not send a controller-local path: ...:
something resolved to a path on the controller (typically sandbox.imagePath
or native.image pointing at a local directory). Use an image selector
the daemon can resolve, or the daemon's own default image.
ssh.agent is not supported with a remote gondolind: use
ssh.credentials (--ssh-credential) instead of ssh.agent
(--ssh-agent).
gondolind exited (code=...) (...) or gondolind failed to start:
the daemon command failed; the message ends with the last line the command
wrote to stderr (for example gondolind: command not found, exit code
127). Run the command by hand (ssh build-box gondolind --version) to check that
the binary is on the remote PATH (non-interactive SSH sessions often have a
shorter PATH; use an absolute path) and executable for that platform.
The VM uses qemu although the machine has krun: remote daemons default
to qemu; set sandbox.vmm: "krun" or --vmm krun.
For daemon stderr and other diagnostics, see Debugging and Troubleshooting.