Network Stack¶
This document explains Gondolin's network design: what the guest VM sees, what the host actually does with that traffic, and which security and policy guarantees fall out of that design.
The main idea is:
- The guest gets a normal
eth0interface. - The host does not provide a generic NAT.
- Instead, the host terminates and mediates traffic in a userspace network stack, and only allows a narrow set of egress patterns (primarily HTTP and TLS that can be intercepted, plus optional explicit SSH and host-mapped TCP egress).
The guest should be treated as adversarial: the host is the enforcement point.
Network Design Goals¶
- Constrain egress to intended destinations (host allowlist + internal-range blocking)
- Prevent arbitrary TCP tunneling (no generic "open socket to the internet")
- Make requests observable and programmable (hooks)
- Enable secret injection without delivering secret values into the guest
High-Level Architecture¶
At a high level, the data path looks like this:
- Guest app writes bytes to a socket.
- Bytes become Ethernet frames emitted by the guest kernel via virtio-net.
- QEMU forwards those frames to the host through a private transport.
- The host parses frames into L2/L3/L4 and either:
- services the traffic locally (e.g. DHCP replies),
- relays it in a restricted way (DNS), or
- bridges it into host-side HTTP(S) using an HTTP parser + TLS MITM.
Conceptually:
- The guest speaks "real networking" (Ethernet/IP/TCP/UDP).
- The host speaks "real networking" only for a narrow subset, and otherwise drops packets.
Addressing and "LAN" Model¶
The guest is placed into a small private IPv4 subnet with a host-side "gateway":
- Gateway IP:
192.168.127.1 - Guest IP:
192.168.127.3 - Subnet:
/24(255.255.255.0)
The host implements enough LAN behavior for a typical Linux userspace to work:
- ARP resolution for the gateway
- DHCP (so the guest can use standard
udhcpc/dhclient flows)
This is a virtual LAN: it is not bridged to the real host network!
Protocol Layers Implemented by the Host¶
The host implements a userspace network stack so it can inspect and control all traffic:
- Ethernet framing and dispatch
- ARP (so the guest can discover the "gateway" MAC)
- IPv4 parsing and emission
- ICMP echo handling (ping)
- DHCP server (address assignment)
- TCP state machine (connection tracking and stream reassembly)
- UDP handling, but restricted (see DNS)
Important implication: Gondolin does not rely on "host firewall rules" for correctness. The enforcement happens before any real host sockets are created.
DNS Handling¶
DNS exists because it is useful for HTTP clients, but it is intentionally constrained.
DNS Modes (behavior and effects)¶
- Only UDP destination port 53 is handled.
- The guest learns a DNS server via DHCP:
- In
synthetic/trustedmode, the DHCP server advertises the gateway (192.168.127.1) as the resolver (so the host can intercept DNS) - In
openmode, the DHCP server advertises the host's non-loopback IPv4 resolvers (falling back to8.8.8.8if none are suitable) - DNS behavior is configurable via a DNS mode:
synthetic(default)- No upstream DNS; the host replies directly with synthetic answers
- Responses are only generated for normal-looking queries, and only for
A/AAAAquestions - This prevents using DNS as a network egress channel
trusted- The guest may send DNS queries to any destination IP, but the host forwards the query only to the host's trusted resolvers
- Non-DNS payloads on UDP/53 are blocked (queries must parse as a standard DNS query)
- Trusted upstream resolvers are currently IPv4-only; if none are available/configured, Gondolin fails fast
- This prevents using UDP/53 as arbitrary UDP transport to arbitrary destination IPs, but it does not prevent classic DNS tunneling via real DNS semantics
open- UDP/53 is forwarded to the destination IP the guest targeted
- Payloads are not validated as DNS, which enables DNS-like UDP tunneling
There is no goal of being a full-featured recursive resolver (for example, caching is not required for correctness).
DNS Options (CLI and SDK)¶
CLI (gondolin bash / gondolin exec):
--dns MODE- Sets the DNS mode:
synthetic(default),trusted, oropen --dns-trusted-server IP- Repeatable
- Adds a trusted upstream DNS resolver (IPv4) for
--dns trusted
SDK (VM.create):
import { VM } from "@earendil-works/gondolin";
const vm = await VM.create({
dns: {
mode: "synthetic", // "synthetic" | "trusted" | "open"
// trustedServers: ["1.1.1.1"],
// syntheticIPv4: "192.0.2.1",
// syntheticIPv6: "2001:db8::1",
// syntheticTtlSeconds: 60,
},
});
dns.mode- Selects
synthetic/trusted/open dns.trustedServers- Upstream resolver IPv4 addresses for
trustedmode - If omitted, Gondolin uses the host's configured DNS servers filtered to IPv4
- In
trustedmode, having no usable IPv4 resolvers is an error dns.syntheticIPv4/dns.syntheticIPv6- The IP addresses returned in synthetic
A/AAAAanswers (synthetic mode only) - Defaults:
192.0.2.1and2001:db8::1 localhostand*.localhostare always answered as loopback (127.0.0.1/::1)dns.syntheticTtlSeconds- TTL for synthetic answers in
seconds(synthetic mode only) - Default:
60
DNS and Policy¶
Even though DNS is available, policy decisions are not based on "what the guest resolved." Instead, HTTP policy is enforced by the host using host-side resolution at the point a real upstream connection is made.
This matters for security because it prevents a class of attacks where the guest tries to confuse policy via DNS tricks (e.g. DNS rebinding).
TCP Stream Classification¶
For each outbound TCP flow, Gondolin either applies an explicit host mapping first (if configured) or inspects the beginning of the byte stream and classifies it:
- TCP (mapped): explicit
tcp.hostsmapping matched by synthetic hostname (+ optional port) - HTTP: looks like an HTTP/1.x request line
- TLS: looks like a TLS ClientHello
- SSH: looks like an SSH client banner (
SSH-2.0-...) and is only allowed when SSH egress is explicitly enabled + allowlisted - Anything else: blocked
This is the core mechanism that prevents arbitrary TCP tunneling. The guest can open TCP sockets, but only connections that are explicitly mapped or quickly turn into HTTP, TLS, or configured SSH will be forwarded.
Design notes:
- HTTP
CONNECTis explicitly denied. This prevents using HTTP as a generic tunnel. - SSH egress (when enabled) is proxied by the host and intentionally limited to
non-interactive
execusage. - Protocol classification is deliberately conservative.
Outbound SSH Egress (Optional)¶
If SSH egress is enabled (via VM.create({ ssh: ... }) or the CLI flags), the
guest can use normal SSH tooling (for example git clone git@github.com:...).
Key properties:
- SSH is only allowed to allowlisted
HOST[:PORT]targets (default port:22) - SSH requires
dns.mode: "synthetic"withdns.syntheticHostMapping: "per-host"so the host can derive the intended hostname from the synthetic destination IP - The host verifies upstream host keys (OpenSSH
known_hostsby default) - The guest never receives the host's private key material; upstream auth happens on the host (ssh-agent or configured key)
The SSH proxy is intentionally constrained:
- only
execchannels are supported - interactive shells and SSH subsystems (such as
sftp) are denied
To keep behavior predictable and bound resource usage, the host applies
connection caps and timeouts (configurable via ssh.* options):
- max concurrent upstream SSH connections per guest TCP flow
- max concurrent upstream SSH connections total (across all flows)
- bounded handshake (
readyTimeout) and SSH keepalives
Host-Mapped TCP Egress (Optional)¶
For selected workloads, Gondolin can forward specific guest hostnames/ports to
explicit upstream HOST:PORT targets via tcp.hosts.
How it works:
- The guest resolves
HOSTin synthetic DNS mode - In
syntheticHostMapping: "per-host", Gondolin can map destination synthetic IPs back to hostnames - If a
tcp.hostsrule matches (HOSTorHOST:PORT), the flow is marked as mapped TCP - The host opens a TCP socket to the configured upstream target and forwards bytes
Important constraints:
- Mapped TCP requires
dns.mode: "synthetic"anddns.syntheticHostMapping: "per-host" - Mapping values must be explicit
UPSTREAM_HOST:UPSTREAM_PORT - Mapped TCP is a raw tunnel to the configured target
- no HTTP parsing/hook pipeline
- no HTTP secret placeholder substitution
This keeps the exception explicit and narrow while still avoiding generic NAT.
HTTP Bridging¶
For connections classified as HTTP:
- The host parses the request line and headers.
- The host applies the configured request hooks and policy checks.
- The host performs the actual upstream request using a host HTTP client.
- The host streams (or buffers, depending on hooks) the response back to the guest as an HTTP/1.x response.
The guest experiences this as "it made an HTTP request over TCP", but in reality the guest never gets a raw upstream TCP tunnel.
HTTPS via TLS MITM¶
For connections classified as TLS, Gondolin performs a controlled MITM:
- The host reads the TLS ClientHello and extracts the intended server name (SNI).
- The host presents a dynamically generated certificate for that name, signed by a local CA.
- The guest establishes TLS to the host (believing it is the origin).
- The host decrypts the HTTP request inside TLS and then repeats the same HTTP bridging pipeline as for plain HTTP.
CA and Guest Trust¶
To make this work, the guest must trust the host-generated CA:
- Gondolin creates (and caches) a CA keypair on the host.
- The CA certificate is made available inside the guest so common TLS clients (curl, node, python, etc.) will accept MITM certificates.
Policy Enforcement¶
Policy enforcement happens on the host and is designed to be robust against common evasion tricks.
Allowlist by Hostname¶
A typical setup uses an allowlist of hostnames (often with * wildcards).
Requests to hosts not on the allowlist are denied.
Blocking Internal Ranges¶
By default, Gondolin blocks connections to internal / local ranges (e.g. loopback, RFC1918, link-local, metadata-style targets). This prevents the guest from reaching sensitive services on the host's LAN or cloud metadata endpoints.
DNS Rebinding Protection¶
Policy is checked using host-side DNS resolution and is typically validated more than once:
- Once when evaluating the request target
- Again at the point where the underlying HTTP client resolves and connects
This closes the common "resolve to public IP at check-time, then to private IP at connect-time" rebinding pattern.
When upstream keep-alive pooling is in use, the connect-time check runs when a new upstream connection is opened; requests sent over a reused pooled connection do not trigger a fresh connect-time IP check.
Redirect Handling¶
Redirects are handled by the host (not blindly followed by the guest's TCP stack), and each redirect target is re-validated against policy.
Hooks and Programmability¶
The network layer exposes hooks so you can:
- Enforce a stricter policy than "hostname allowlist" (paths, methods, headers)
- Rewrite requests (e.g. add/remove headers)
- Observe responses for auditing
Egress hooks use WHATWG Request/Response objects:
onRequest(request)runs before upstream fetch and may inspect/replace the requestonResponse(response, request)can rewrite upstream responses
onRequest may return a synthetic Response to short-circuit upstream fetch. In
that short-circuit path, upstream DNS/IP policy checks and fetch are skipped,
and onResponse is not called.
Request bodies are streams. If your hook needs body-aware decisions, read from
request.clone() (for example with await request.clone().text()) and return
undefined to keep forwarding unchanged.
A key design principle is that hooks run on the host after the traffic has been parsed into structured HTTP requests, not on raw packets.
Secret Injection¶
Secrets are handled as part of the HTTP mediation pipeline:
- The guest receives placeholders (random values) in its environment.
- When the guest sends an HTTP request, the host scans headers for placeholders.
- If the destination host is allowed for that secret, the host substitutes the real secret value into the outbound request.
- If not allowed, the request is blocked.
This design ensures real secret values never need to exist in the guest's memory, environment, or filesystem.
Buffering and Limits¶
To keep the system predictable and bound resource usage, the network layer enforces size limits such as:
- Maximum request body size
- Maximum response body size (especially when response hooks need buffering)
- Limits for header parsing and internal queues
If you plan to transfer large payloads, design around these limits (e.g. avoid large downloads through response hooks).
Known Limitations¶
The network stack is intentionally not a general-purpose internet connection. Common limitations include:
- No HTTP/3 or HTTP/2 (HTTP/1.x only)
- WebSocket upgrades are supported, but after the
101response the connection becomes an opaque tunnel (only the request handshake is mediated/hookable). Disable viaallowWebSockets: false/--disable-websockets - No HTTP
CONNECT - No generic UDP (DNS-only)
- No arbitrary TCP/NAT mode
- allowed TCP paths are limited to HTTP/TLS mediation, optional proxied SSH egress, and optional explicit host-mapped TCP rules
- Limited handling for unusual IP behaviors (e.g. fragmentation is not a target feature)
If your workload needs general networking, Gondolin's security properties will not hold as designed; you would need a different architecture (and a different threat model).
Debugging¶
If networking is not behaving as expected, useful things to check first:
- Your allowlist patterns (especially wildcard scope)
- Whether internal-range blocking is preventing the destination
- Whether the protocol is actually HTTP/1.x or TLS with SNI (non-HTTP TLS traffic will be blocked)
Gondolin also includes debug logging for networking to help trace policy decisions and connection classification.