Status: implemented (all phases), pending on-kernel verification. The datapath and Go plumbing have landed; because the eBPF cannot be compiled or verifier-tested off a Linux ≥6.6 host (same constraint as the existing
tapfilter, which is kept out of default CI), the conntrack-kfunc flow, checksum-offload behaviour, and verifier acceptance still need validation on a real host. See "Implementation map" at the bottom for where each piece lives.Original design follows.
Goal: give each Firecracker microVM real connectivity using an eBPF datapath — no Linux bridge, and no
iptables/nftablesrules (the actual constraint: we refuse to manage userspace netfilter rule chains). We do the packet parse, rewrite, redirect, filtering, and observability in eBPF programs attached via TCX (kernel ≥ 6.6), and we reuse the kernel'snf_conntracktable — driven directly from eBPF via thebpf_ct_*kfuncs — for connection tracking, TCP state, and NAT source-port allocation.nf_conntrackis a kernel module, not a ruleset: it just needs to be loaded; we install zero rules.Two flows in scope:
- Egress — VM → internet (Minecraft auth, mod downloads, etc.), with destination filtering and per-flow observability.
- Ingress — internet → VM: a host port (allocated per server, the P6 "random host port") DNAT'd to the in-VM service port (25565), with source filtering and observability.
The constraint is "no iptables/nftables rules," not "no kernel code." So we split the work where it's cheapest:
- Reused (kernel
nf_conntrackviabpf_ct_*kfuncs): connection tracking, the full TCP state machine + timeouts, garbage collection of dead flows, and NAT source-port allocation (bpf_ct_set_nat_inforeserves a unique manip tuple). These were the two hardest, most correctness-sensitive subsystems in the earlier "own everything" sketch — and they vanish. Cost: depend on thenf_conntrackmodule being loaded (no rules), kfuncs are ≥6.1 (we target ≥6.6). - Owned (eBPF): packet parse, the actual header rewrite (we bypass the
netfilter NAT hooks via
tcredirect, so the kernel won't mangle for us — we read the allocated tuple off thenf_connand rewrite the packet ourselves),bpf_redirect/bpf_redirect_neighsteering, egress/ingress filtering, and observability.
The sections below describe the owned datapath; conntrack appears as kfunc calls, not as a hand-built map.
TAP-per-VM, routed (no bridge). Steering between a TAP and the uplink is done
with bpf_redirect/bpf_redirect_neigh, so the host kernel does not route
or forward these packets and ip_forward is not required.
┌─────────────────────────── host ───────────────────────────┐
internet ◀──────▶│ uplink NIC (HOST_IP) │
│ │ ▲ │
│ [tc ingress: nat_uplink] (bpf_redirect_neigh out uplink) │
│ ▼ │ │
│ kernel nf_conntrack (via bpf_ct_* kfuncs) + DNAT/policy │
│ ▲ │ maps (shared, pinned) │
│ [tc ingress: nat_tap] ──── bpf_redirect(tapN, 0) ────┐ │
│ │ ▼ │
│ tap0 (fc…) tap1 (fc…) … tapN ───────────▶ Firecracker│
└────────────────────────────────────────────────────────────┘
│ │ │
VM 0 VM 1 VM N (each: VM_IP/31, VM_MAC)
Per-VM addressing (new — today TAPs are link-local/MMDS-only):
- Each VM gets a private
VM_IPand a deterministicVM_MAC. A/31point-to-point per TAP is sufficient (no host IP needed on the TAP in the redirect model — see §6 ARP). - The VM's default gateway is a single shared virtual gateway IP (
GW_IP, e.g.169.254.0.1or a chosen private addr). It is never assigned to a host interface; the eBPF ARP responder (or a static guest neighbor) resolves it. VM_IP/GW_IPare delivered to the guest via the existing MMDS runspec channel (internal/runspec), and applied by the in-VM init agent (cmd/init/net_linux.go) which today only sets the link-local MMDS address.
A single shared eBPF collection, loaded once at agent start. This is a
structural change from the current per-TAP tapfilter (which loads one objects
set per TAP): the DNAT/policy maps must be shared across all TAPs and the
uplink, so we load once and attach the same programs to many ifindices. (The
conntrack table is the kernel's, shared by nature.)
Program (SEC) |
Attach point | Handles |
|---|---|---|
nat_tap |
TCX ingress on every tapN |
VM-originated packets: new egress (SNAT) and replies to inbound (un-DNAT). ARP from guest. |
nat_uplink |
TCX ingress on the uplink, once | Internet-originated packets: replies to egress (un-SNAT) and new inbound to a published port (DNAT). |
Outbound, after rewrite, leaves via bpf_redirect_neigh(uplink); inbound, after
rewrite, enters the VM via bpf_redirect(tapN, 0). Neither needs an egress-hook
program. (An optional obs_tap on TAP egress can be added purely for
observability if we want post-rewrite visibility; not required.)
Programs may exceed the verifier's complexity budget as a monolith — split into
tail calls: parse → classify → {snat, dnat, unsnat, undnat} → emit.
| Map | Type | Key → Value | Purpose |
|---|---|---|---|
vm_config |
HASH | tap_ifindex / vm_ip → {vm_ip, vm_mac, tap_ifindex} |
VM identity + where to redirect inbound |
dnat_rules |
HASH | {proto, host_ip, host_port} → {vm_ip, vm_port, tap_ifindex, vm_mac} |
Published-port forwards (P6 host-port map) |
egress_policy |
LPM_TRIE | {dst_cidr, port} → verdict |
Egress destination ACL (filtering) |
ingress_policy |
LPM_TRIE | {src_cidr} → verdict |
Ingress source ACL (filtering) |
events |
RINGBUF | — | Flow observability to userspace |
stats |
PERCPU_HASH | vm_ip → counters |
Per-VM bytes/pkts/drops/conns |
No ct map and no port_alloc map. Connection state lives in the kernel's
nf_conntrack table, reached via kfuncs (§5). That removes the silent-LRU-eviction
hazard, the bpf_timer expiry machinery, and the source-port bitmap entirely —
the kernel handles flow GC and unique-tuple allocation.
We never hand-build a CT map. Each packet looks up the kernel's nf_conn; new
flows are allocated, given a NAT binding, and inserted. The nf_conn carries the
original and reply tuples, so "where does this translate to" is read off the
entry — no two-keys-per-flow bookkeeping, no manual rewrite tables.
kfunc vocabulary (all release the ref with bpf_ct_release before return):
bpf_skb_ct_lookup(skb, tuple, sz, opts, sz)→ existingnf_conn *or NULLbpf_skb_ct_alloc(...)→ new uncommittednf_conn *bpf_ct_set_nat_info(nfct, &addr, port, NF_NAT_MANIP_SRC|_DST)→ reserve a unique manip tuple (this is the source-port allocator)bpf_ct_set_timeout/bpf_ct_set_status, thenbpf_ct_insert_entry(nfct)
The bpf_ct_opts carries dir so a lookup tells you whether the packet is in the
original or reply direction of its flow — that's how each hook decides
which way to rewrite. Because we steer with tc redirect (bypassing the netfilter
NAT hooks), the kernel does not mangle the packet; we read the relevant
tuple off the nf_conn and do the rewrite + checksum fixups (§7) ourselves.
- Parse;
egress_policylookup ondst_ip/dport→ deny ⇒ drop + obs. bpf_skb_ct_lookupon the VM-native tuple.- Miss ⇒
bpf_skb_ct_alloc;bpf_ct_set_nat_info(NF_NAT_MANIP_SRC, HOST_IP, 0)(port 0 ⇒ kernel picks a free source port); set timeout/status;bpf_ct_insert_entry. Re-read to get the allocatedaport. - Hit (original dir) ⇒ read
aportfrom the entry's reply tuple. - Hit (reply dir) ⇒ this is actually a reply to an inbound flow (§5.3); un-DNAT the source instead.
- Miss ⇒
- SNAT rewrite:
src = HOST_IP:aport; fix IP + L4 checksums (§7). bpf_redirect_neigh(uplink_ifindex, NULL, 0, 0)⇒TC_ACT_REDIRECT. Kernel does FIB + neighbor to the real gateway, fills L2.bpf_ct_release; emit obs; bumpstats.
- Parse.
bpf_skb_ct_lookupon the packet tuple.- Hit (reply dir of a SNAT flow) ⇒ un-SNAT:
dst = VM_IP:sport(from the entry's original tuple); fix checksums; L2dst=VM_MAC(fromvm_config);bpf_redirect(tap_ifindex, 0); release; obs. - Miss ⇒ fall through to inbound DNAT (§5.3).
- Hit (reply dir of a SNAT flow) ⇒ un-SNAT:
After a ct miss in §5.2:
dnat_ruleslookup{proto, HOST_IP, dport}.- Miss ⇒
TC_ACT_OK(let the host's own stack handle it — host services keep working). - Hit ⇒
ingress_policylookup onsrc_ip→ deny ⇒ drop + obs.
- Miss ⇒
bpf_skb_ct_alloc;bpf_ct_set_nat_info(NF_NAT_MANIP_DST, VM_IP, vm_port); timeout/status;bpf_ct_insert_entry.- DNAT rewrite:
dst = VM_IP:vm_port; fix checksums; L2dst=VM_MAC;bpf_redirect(tap_ifindex, 0); release; obs.
Same hook as egress. The bpf_skb_ct_lookup returns the §5.3 flow in the reply
direction (opts.dir == reply) ⇒ un-DNAT the source: src = HOST_IP:host_port
(from the original tuple); checksums; bpf_redirect_neigh(uplink); release. So
nat_tap disambiguates new-egress vs reply-to-inbound purely from the lookup's
direction flag — no extra state of our own.
Timeouts, the TCP state machine, dead-flow garbage collection, and freeing the
allocated source port all happen inside nf_conntrack. We set an initial timeout
on insert and otherwise do nothing: no bpf_timer, no bitmap, no Go sweeper. Tune
via the standard nf_conntrack sysctls (e.g. nf_conntrack_tcp_timeout_*) if the
defaults don't suit; that's configuration, not rules.
The VM ARPs for GW_IP. With no host IP on the TAP, nothing answers. Two
options:
- eBPF ARP responder (self-contained):
nat_tapdetects ARP-request forGW_IPand crafts an ARP reply (GW_IPis-attap_mac) back out the TAP. - Static guest neighbor (simplest):
cmd/initinstalls a permanent ARP entryGW_IP → fixed gateway MAC, so the guest never ARPs. Recommended to start; add the eBPF responder later if we want zero guest assumptions.
Inbound packets to the VM are addressed by us (dst=VM_MAC), so no inbound ARP
concern.
- IPv4 header:
bpf_l3_csum_replaceover changed addr fields. - TCP/UDP:
bpf_l4_csum_replacewithBPF_F_PSEUDO_HDRfor the IP-address delta and a plain replace for the port delta; usebpf_csum_difffor multi-word deltas. Handle UDP zero-checksum. - virtio/TAP
CHECKSUM_PARTIALgotcha: packets off the guest may carry partial checksums (skb->ip_summed). Validate behavior against the real virtio-net path early — this is the most common source of silently-corrupt NAT.
- Egress ACL:
egress_policyLPM ondst_ip/dport, evaluated atnat_tapbefore SNAT (sees true public destination). - Ingress ACL:
ingress_policyLPM onsrc_ip, atnat_uplinkbefore DNAT CT creation (sees true remote source — DNAT only rewrites dst). - Obs: ringbuf event per translation carrying pre+post tuples, direction,
verdict, length. Userspace (Go) drains and correlates: it knows the
host_port → VM:vm_portmapping andvm_ip → server, so events are annotated with server identity and the public-facing port without observing the uplink separately. Per-VM counters instats.
- IPAM: allocate
VM_IP, deterministicVM_MAC, and the publishedhost_port; record thehost_port → VM:25565mapping (this is P6's per-server host-port allocation; surface it whereAdvertiseHostis reported today). - Map population:
vm_config,dnat_rules, policy maps on provision; remove on deprovision. - Attach: load the shared collection once at
Runtime.New; attachnat_uplinkto the uplink (newConfig.UplinkDevice); attachnat_tapto each TAP's ifindex increateTAP, detach indeleteTAP(mirrors the currenttapfilterwiring). - Guest config: publish
VM_IP/GW_IP/route via MMDS runspec; extendcmd/init/net_linux.goto apply them (+ static gateway neighbor per §6). - Obs: drain
events, export metrics. (No CT sweeper — the kernel GCs flows.) - Preflight: ensure
nf_conntrackis loaded (modprobe nf_conntrack) at agent start and fail fast with a clear error if the kfuncs aren't available.
- IP fragmentation: L4 header only in the first fragment; NAT of later fragments needs reassembly or a frag table. Start by dropping non-first fragments (+ obs) and revisit.
- ICMP: echo needs ID-based NAT; ICMP errors embed the original packet whose inner tuple must also be rewritten. Phase 2 — start with TCP/UDP only.
- TCP state & port allocation: handled by
nf_conntrack(the reason we reuse it). Nothing to build; just confirm the conntrack TCP timeouts suit us. nf_conntrackmust be enabled/loaded: the kfuncs need the module present and conntrack active in the netns. This is a module + sysctls dependency, not iptables/nftables rules — consistent with the "no rules" constraint. Preflight and fail fast (§9).- We still rewrite the packet ourselves: bypassing the netfilter NAT hooks via
tcredirect means the kernel won't mangle; the kfuncs give us the tuple + state, the eBPF code applies it. Validate the read-tuple-then-rewrite flow in the Phase-1 spike. - kfunc availability:
bpf_skb_ct_lookup/bpf_ct_releasesince 5.19; the alloc/insert/set_nat_infofamily since 6.1 — fine on the ≥6.6 target, but it pins the minimum kernel. - CT lookup/insert races: two packets of one new flow on different CPUs — the
loser's
bpf_ct_insert_entryfails; re-lookup and use the winner's entry. - Checksum offload (virtio): §7 — verify first.
- Host's own traffic:
nat_uplinkmustTC_ACT_OKon CT+DNAT miss so host services and SSH keep working. - Verifier limits: tail-call split; bounded loops everywhere.
- Capabilities/kernel: needs
CAP_BPF+CAP_NET_ADMIN(+CAP_SYS_ADMINfor pinning), BTF (CONFIG_DEBUG_INFO_BTF=y),CONFIG_NF_CONNTRACK, kernel ≥ 6.6.
- Addressing + guest config + gateway ARP — VM gets an IP, default route, reaches the gateway. (Prerequisite; nothing routes without it.)
- kfunc conntrack spike + egress SNAT + reply — prove the
lookup/alloc/
set_nat_info/insert → read-tuple → rewrite + checksum flow on the real virtio path; VM reaches the internet. No filtering. - Inbound DNAT + VM-reply un-DNAT — published host-port → VM:25565 works.
- Filtering + observability — egress/ingress ACLs, ringbuf, stats.
- Hardening — ICMP, fragmentation, conntrack-timeout tuning, checksum-offload validation.
Each phase is independently testable end-to-end (curl from guest for 1; external client → host_port for 2). Flow expiry/GC needs no phase — it's the kernel's.
- The current single-port
tapfilter(internal/agent/firecracker/bpf/tapfilter.ctapfilter_linux.go) is the filter/obs seed, but its per-TAP load model is replaced by the single shared collection of §3. The C grows intonat.cwith tail-called programs; the Go loader moves to a once-at-startup attach with per-VM map writes.
- Lifecycle hooks are the same ones already wired:
createTAP/deleteTAP(tap_linux.go),Runtime.Provision/Deprovision(runtime.go), and the MMDS runspec publish (mmds.go). ConfiggainsUplinkDevice(and gateway/subnet knobs);AdvertiseHost's stand-in is replaced by the realHOST_IP:host_portfrom §9 IPAM.
The datapath was implemented as a single shared collection alongside the
existing tapfilter (which is left intact — it is a separate, env-gated
observe/drop feature). The NAT dataplane is enabled only when Config.UplinkDevice
is set; otherwise the driver keeps its MMDS-only behaviour.
| Piece | File |
|---|---|
eBPF programs (nat_tap, nat_uplink, ARP responder) + maps |
internal/agent/firecracker/bpf/nat.c |
bpf2go directive + vmlinux.h instructions |
internal/agent/firecracker/bpf/gen.go |
IPAM (VM IP, deterministic MAC, host port) + vmNet |
internal/agent/firecracker/netalloc.go |
| Dataplane loader/attach, map population, ringbuf drain, conntrack preflight | internal/agent/firecracker/nat_linux.go (stub: nat_other.go) |
Config knobs (UplinkDevice, subnet/gateway/MAC/port range) + validation |
internal/agent/firecracker/config.go |
Runtime wiring (load once, allocate/release, publish/withdraw per VM) |
internal/agent/firecracker/runtime.go |
| Guest-MAC pinning + per-TAP attach at provision | internal/agent/firecracker/{machine.go,mmds.go} |
Runspec per-VM NetConfig (MMDS contract) |
internal/runspec/runspec.go |
| Guest applies address/neighbor/route (hand-rolled rtnetlink) | cmd/init/{net_linux.go,netlink_linux.go,run_linux.go} |
| Unit tests (IPAM, MAC, config parsing) | internal/agent/firecracker/netalloc_test.go |
Before this runs on a host: run make bpf-generate once on a Linux ≥6.6 box
(needs clang + libbpf + bpftool; it dumps vmlinux.h from the host BTF and runs
bpf2go) and commit the resulting bpf/vmlinux.h, bpf/*_bpfel.go, and
bpf/*_bpfel.o. After that go build needs only the Go toolchain — CO-RE makes
the one compiled object portable across kernels at load time. Then validate the
conntrack-kfunc flow, checksum offload on the virtio path (§7), and verifier
acceptance per the Phase-1 spike (§11).
Deviations from the original sketch: conntrack uses the two main programs
calling inlined helpers rather than a tail-call split — the kfunc-based programs
are small enough that the split (§3) is held as a fallback if the verifier
rejects the monolith. The MMDS TAP and the NAT'd data NIC are the same device
(MMDS traffic is intercepted by Firecracker's device model; everything else
reaches nat_tap), so no second NIC is added. The guest gets the §6 static
neighbor for the gateway via the init agent; the eBPF ARP responder is also
present as a fallback.