Ingress
Ingress exposes HTTP servers running inside the VM to the host: a gateway on a host port forwards each request to a server on the guest's loopback interface, according to a routing table the guest (or your program) controls.
gondolin bash --listenimport { VM } from "@earendil-works/gondolin";
const vm = await VM.create();
const ingress = await vm.enableIngress(); // http://127.0.0.1:<ephemeral>
vm.setIngressRoutes([{ prefix: "/", port: 8000, stripPrefix: true }]);This is intentionally not a generic port forward. It only speaks HTTP, so the host keeps control over what the sandbox exposes and can inspect, rewrite or deny every request with hooks.
How it works:
- A small guest helper (
sandboxingress) opens TCP connections to guest loopback (127.0.0.1). - A host-side HTTP gateway listens on a host port.
- Each incoming request is routed to a guest-local HTTP server based on the
routing table in
/etc/gondolin/listeners.
CLI Usage
Start an interactive VM shell with ingress enabled:
gondolin bash --listenThis prints something like:
Ingress enabled: http://127.0.0.1:58650
Configure routes by editing /etc/gondolin/listeners inside the VM.By default, the gateway binds to 127.0.0.1 on an ephemeral port. --listen
works the same in the static CLI.
To bind a specific host interface and port:
# HOST:PORT
gondolin bash --listen 127.0.0.1:8008
# :PORT or PORT (host defaults to 127.0.0.1)
gondolin bash --listen :8008
gondolin bash --listen 8008
# IPv6 hosts in brackets
gondolin bash --listen '[::1]:8008'
# PORT=0 picks an ephemeral port
gondolin bash --listen 127.0.0.1:0--disable-websockets turns off WebSocket upgrades through the gateway (and
for guest egress). See the CLI reference for the
other bash options.
Quick Start Example
Inside the VM, configure a route:
echo '/ :8000' > /etc/gondolin/listenersAnd then start a server:
python3 -m http.server 8000Now from the host:
curl -v http://127.0.0.1:58650/SDK Usage
vm.enableIngress() starts the VM if it is not running yet, starts the host
gateway and returns an IngressAccess:
import { VM } from "@earendil-works/gondolin";
const vm = await VM.create();
const ingress = await vm.enableIngress({
listenHost: "127.0.0.1",
listenPort: 0, // 0 picks an ephemeral port
});
console.log("Ingress:", ingress.url);
// Route all requests to the guest server on port 8000
vm.setIngressRoutes([{ prefix: "/", port: 8000, stripPrefix: true }]);
// Start a server inside the guest (it keeps running while other
// vm.exec() calls run)
const server = vm.exec(["/bin/sh", "-lc", "python3 -m http.server 8000"], {
buffer: false,
stdout: "inherit",
stderr: "inherit",
});
// Now you can reach the guest service from the host at ingress.url
// ...
await ingress.close();
await vm.close();There is one gateway per VM: calling enableIngress() again returns the
first IngressAccess (the new options are ignored), and vm.close() stops
it. IngressAccess has:
host,port: where the gateway listensurl:http://HOST:PORTclose(): stops the gateway
enableIngress() Options
| Option | Default | Meaning |
|---|---|---|
listenHost |
127.0.0.1 |
host interface to bind |
listenPort |
0 |
host port to bind (0 = ephemeral) |
allowWebSockets |
true |
allow WebSocket upgrades |
hooks |
none | ingress hooks |
bufferResponseBody |
false |
buffer full upstream responses before onResponse runs |
maxBufferedResponseBodyBytes |
2 MiB | largest buffered response body in bytes |
upstreamHeaderTimeoutMs |
2000 |
timeout waiting for the guest server's response headers in ms |
upstreamResponseTimeoutMs |
15000 |
timeout between response body chunks in ms |
Routes from the SDK
You can read or replace the routing table programmatically:
vm.getIngressRoutes()returns the current routes (parsed from/etc/gondolin/listeners;[]when the mount is not available)vm.setIngressRoutes(routes)replaces them and writes the canonical file back into the guest
A route is { prefix, port, stripPrefix } (an IngressRoute): prefix must
not be empty or contain whitespace, port is the guest loopback port
(1–65535), and stripPrefix defaults to true. Invalid routes throw.
Routing Table
The file /etc/gondolin/listeners is the authoritative ingress configuration.
The host gateway watches it and reloads routes shortly after it changes. If an
edit does not parse (or the file grows beyond 64 KiB), the previous routes stay
in effect.
Gondolin may rewrite the file into a canonical form (normalized prefixes, a
trailing newline, and consistent formatting). Unknown key=value options are
ignored for forward compatibility.
This file is provided by the host via a dedicated /etc/gondolin mount, so the
configuration is per-VM and does not persist after the VM is closed and is
also not snapshotted.
Each non-empty, non-comment line has the form:
<prefix> :<port> [key=value ...]prefixis a URL path prefix and must start with/.:<port>is the guest loopback port to connect to.#starts a comment (everything after#is ignored).
Example:
# Send everything to the server on port 8000
/ :8000
# Route /api/* to port 9000, and strip the /api prefix
/api :9000Prefix Matching
If multiple routes match, the longest matching prefix wins.
For example:
/ :8000
/api :9000/api/usersgoes to:9000/aboutgoes to:8000
Strip Prefix
By default, the prefix is stripped before the request is forwarded.
Example route:
/api :9000- Host request
GET /api/usersbecomes guest requestGET /users.
To disable rewriting (strip_prefix accepts true or false):
/api :9000 strip_prefix=falseForwarded Headers
The gateway adds X-Forwarded-For (appending the client IP),
X-Forwarded-Host, X-Forwarded-Proto: http, and, when a non-root prefix is
stripped, X-Forwarded-Prefix to each upstream request.
Ingress Hooks
enableIngress({ hooks }) installs host-side hook points on the gateway. Use
them to:
- allow or deny requests based on client IP, path or route
- rewrite the upstream target path, method or headers
- add or remove response headers
- optionally buffer responses so you can rewrite bodies
The hooks:
isAllowed(info) -> boolean: returnfalseto deny (default response:403 Forbidden, bodyforbidden).infohasclientIp,method,path(including the query),headers(lowercased) and the matchedroute.onRequest(request) -> patch | undefined:requesthasclientIp,method,path,route,backendHost,backendPort,backendTarget(path and query sent to the guest) andheaders. The patch may setmethod,backendPort,backendTarget,headers, and per requestbufferResponseBody/maxBufferedResponseBodyBytes.backendHostmay only be127.0.0.1orlocalhost.onResponse(response, request) -> patch | undefined:responsehasstatusCode,statusMessage,headersand, when buffered,body(aBuffer). The patch may setstatusCode,statusMessage,headersand a replacementbody.
All hooks may be async. For a custom deny response from any hook, throw
new IngressRequestBlockedError(message, statusCode, statusMessage, body)
(defaults: 403, "Forbidden", "forbidden\n"). Any other error answers
502 Bad Gateway.
Streaming vs buffering:
- by default, responses are streamed directly (no buffering)
- if you enable buffering (globally via
enableIngress({ bufferResponseBody: true })or per request fromonRequest()) and anonResponsehook is installed, the full upstream response body is buffered beforeonResponse()runs and provided asresponse.body; a body larger thanmaxBufferedResponseBodyBytesfails the request with502 - a replacement
bodyis sent with a fixedContent-Length
Header patch semantics:
- set a header to a
string/string[]to set or overwrite it - set a header to
nullto delete it
Example:
import { IngressRequestBlockedError, VM } from "@earendil-works/gondolin";
const vm = await VM.create();
await vm.enableIngress({
hooks: {
isAllowed: ({ clientIp, path }) => {
if (path.startsWith("/admin")) {
throw new IngressRequestBlockedError(
`admin blocked for ${clientIp}`,
403,
"Forbidden",
"nope\n",
);
}
return true;
},
onRequest: (req) => ({
// Rewrite /api/* -> /* inside the guest
backendTarget: req.backendTarget.startsWith("/api/")
? req.backendTarget.slice(4)
: req.backendTarget,
headers: { "x-added": "1", "x-remove": null },
// Only buffer responses we plan to inspect/modify
bufferResponseBody: req.backendTarget.endsWith(".json"),
maxBufferedResponseBodyBytes: 8 * 1024 * 1024,
}),
onResponse: (res) => ({
headers: { "x-ingress": "1" },
body: res.body
? Buffer.from(res.body.toString("utf8").toUpperCase())
: undefined,
}),
},
});Behavior and Limitations
- The gateway only speaks HTTP (HTTP/1.1 on the host side).
- WebSocket upgrades are supported by default. The handshake goes through the
hooks, but after the
101response the gateway tunnels bytes. Disable them withenableIngress({ allowWebSockets: false })or--disable-websockets(upgrade requests then get501). - Each inbound request is forwarded over a fresh guest loopback TCP
connection. The gateway sets
Connection: close. - The guest backend must be reachable on
127.0.0.1:<port>. Most servers binding to0.0.0.0:<port>will also accept loopback. - Ingress requires the default
/etc/gondolinmount. If you disable VFS entirely (vfs: null) or override/etc/gondolinwith your own mount,enableIngress()fails. - With a remote daemon the gateway still listens on your machine; the streams are tunneled through the daemon connection.
Debugging
404(no route) means no route in/etc/gondolin/listenersmatched.502means the gateway could not connect to the guest backend, the backend closed the connection before sending a response, it timed out, or a hook failed.
With any GONDOLIN_DEBUG value set, the gateway prints the underlying error
of a 502 to stderr (ingress bad gateway: ...); protocol also traces the
guest streams:
GONDOLIN_DEBUG=protocol gondolin bash --listen