Skip to content

feat(orchestrator): nftables sandbox network datapath behind NETWORK_VERSION=2 - #3556

Closed
tomassrnka wants to merge 10 commits into
mainfrom
net-v2-nftables-datapath
Closed

tomassrnka wants to merge 10 commits into
mainfrom
net-v2-nftables-datapath

Conversation

@tomassrnka

Copy link
Copy Markdown
Member

Adds a second sandbox-network datapath that programs nftables directly instead of writing per-sandbox iptables rules, behind NETWORK_VERSION=2. Ported from the 26-03-24-net-sbx-v2-poc branch, datapath only — egress gateways, migration, WireGuard and the publish stub are not in this PR.

With NETWORK_VERSION unset or 1 the v1 pool is constructed and its code path is untouched: the only edits to pre-existing files are a *network.Pool → network.PoolInterface widening (type-only, 5 lines across sandbox.go/server/main.go), three additive accessors, and the env field itself. Everything else is new files under pkg/sandbox/network/v2/.

v2 matches v1's observable behaviour rather than the PoC's: the same fixed tap MAC (resumed guests keep their cached gateway ARP), the same two unconditional forward ACCEPTs with no default-drop hook, the same egress DSCP semantics per EgressClass including the BUILD_SANDBOX_EGRESS_DSCP override and its nil-inherits-sandbox rule, and the same teardown ordering that preserves the netns as a reclaim anchor when host-side cleanup fails.

Known limitation. v1 appends its forward ACCEPTs into the iptables filter FORWARD chain, so they beat that chain's policy. v2's accepts live in its own table, and an accept in one table cannot override a DROP policy in another — measured, not assumed: on a host with -P FORWARD DROP (what Docker sets on install) a UDP probe between two namespaces is delivered with v1's rule shape and dropped with v2's. Hosts running v2 need a permissive FORWARD policy until we decide between documenting that, checking it at boot, or installing one iptables rule per slot.

Rolling back to v1 needs one manual step: Close deliberately keeps the v2-host-firewall table while any slot is still registered, so sandboxes survive an orchestrator restart, and nothing on the v1 path removes it — nft delete table inet v2-host-firewall once the host has no v2 sandboxes left.

Verified as root on an x86_64 box with nft + netns: go test ./pkg/sandbox/network/v2/ 32 pass / 0 skip, also under -race and -shuffle=on; the v1 network suite still green; go build ./..., go vet ./... and golangci-lint clean. Egress DSCP is checked on the wire — the TOS byte and the IPv4 header checksum on a real capture, plus a ping round trip. The full module suite is green except pkg/nfsproxy and pkg/tcpfirewall, which fail identically on unmodified main here (testcontainers, no Docker on that box).

Reviewed internally before opening: an adversarial multi-lens pass over the whole diff plus a verification round, which caught a checksum bug in the DSCP stamp that would have blackholed all v2 egress, a reintroduction of the poisoned-nftables-conn bug fixed in 03f10e0, and a nil-slot panic on shutdown. Draft until the metric rename is reflected in dashboards — v2 reports under orchestrator.network.v2.*.

…lint-clean

The ported observability tests assumed veth-1/veth-2 already existed on
the host; they now create the links they observe. Kernel-touching tests
carry the repo's paralleltest nolint with the shared resource named.
NETWORK_VERSION absent or empty must parse as 1, so the datapath stays
opt-in.
go test ./... runs the two network packages concurrently and the ported
tests hardcoded indices inside the range v1's suite allocates from, so
namespace creation raced on /run/netns/ns-30001. Observer veth links are
named per-test for the same reason.
Tap MAC, forward-chain posture, restart survival, teardown symmetry and the
boot prerequisite all diverged from v1, so NETWORK_VERSION=2 was not a drop-in:

- stamp the fixed host tap MAC, so resumed guests' cached gateway ARP stays valid
- forward chain accepts by policy and accepts gw->veth unconditionally, as v1
  does; the default-drop hook and ct-state match filtered traffic v1 allows,
  including every non-v2 forwarding path on the host
- reconcile set membership at startup, and keep the table on Close while slots
  are still registered, so sandboxes that outlive the process keep connectivity
- teardown tolerates already-absent state, runs on partial-create failure, and
  preserves the netns anchor when host-side teardown fails
- drop the src_valid_mark prerequisite: it gates fwmark policy routing, which
  this datapath does not use, and nothing sets it
- Populate no longer strands a slot when the pool closes mid-send
… code

The egress DSCP stamp rewrote the TOS byte as a 1-byte payload write at odd
offset 1. The kernel's inet checksum fixup is 16-bit-word based, so the delta
landed in the wrong half and every stamped packet left with a corrupt IPv4
header checksum: with SANDBOX_EGRESS_DSCP set, all sandbox egress was dropped
by the next hop. Writing the two leading header bytes, as nft itself does,
keeps the fixup aligned. The wire test now validates the header checksum —
an L2 capture accepts a packet the receiver would discard, which is why the
original passed.

Also from the same review round:

- discard the shared nftables conn when a mutation fails, matching the v1
  firewall: a failed buffer or Flush leaves the batch queued and the error
  sticky, and every slot on the host shares this conn
- Get returns ErrClosed instead of a nil slot once Populate has closed the
  channel; CreateNetworkV2 keeps the OS thread locked when the namespace
  restore fails, so a poisoned thread cannot build the next slot in the
  wrong netns
- compare set keys by trimming the ifname padding; the previous slice
  classified every live slot as both stale and missing
- delete the fwmark policy-routing helpers, the mangle chain and SlotV2.FwMark
  — nothing in this datapath sets or matches an fwmark
- pin the NAT, redirect and masquerade rules, the counters and the absence of
  iptables rules in tests that fail when the behaviour regresses
The namespace restore at the end of a slot create is deferred and can fail.
Keeping the OS thread locked so it dies with its goroutine only works if the
goroutine actually exits — and the only caller is the pool's Populate loop,
which runs forever. A failed restore therefore pinned that loop to a thread
stuck inside a sandbox namespace, and every later slot got its host-side veth,
addresses and routes built in that namespace: silently, and for the rest of the
process's life. On the path where the create itself succeeded the call even
returned nil, handing out a slot with no host connectivity.

Each create now runs on its own goroutine, so a thread that cannot get back to
the host namespace is destroyed when that goroutine returns, and a failed
restore fails the slot instead of reporting success.

Also from the same review round: classifyVeths is split out as a pure function
so a unit test can pin the padded-key comparison that decides whether reconcile
deletes a live sandbox's veth — the previous assertion recomputed the answer
with the same helper it was meant to check, and stayed green when that helper
was bypassed.
Flush clears the queued messages even when it fails; what sticks to the conn
is the serialization error, and messages queued by a mutation that returned
before reaching Flush. Both still justify replacing the conn.
@cla-bot cla-bot Bot added the cla-signed label Aug 12, 2026
@cursor

cursor Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

PR Summary

High Risk
Touches host firewall, NAT, and sandbox datapath selection; misconfiguration or rollback can blackhole sandbox traffic on production hosts.

Overview
Enabling NETWORK_VERSION=2 on hosts with iptables FORWARD DROP (Docker default) silently drops sandbox traffic because an nftables ACCEPT in a separate table cannot override that policy. CreateNetworkV2/RemoveNetworkV2 never call egressProxy.OnSlotCreate/OnSlotDelete, so any non-noop egress proxy (including resume-build's default-route removal) is skipped under v2. Rolling back to v1 without manually deleting table inet v2-host-firewall leaves its set-based redirects/masquerade active against reused veth-N names, since only the v2 startup path reconciles that table.

Reviewed by Cursor Bugbot for commit 98b8ded. Bugbot is set up for automated code reviews on this repo. Configure here.

An nftables batch is atomic, so tearing the veth and host-CIDR entries down in
one flush means an already-absent element aborts the whole thing — and because
teardown treats ENOENT as success, the element that was present stayed in the
set while RemoveSlot reported success. A stale veth keeps matching the forward
and redirect rules. Proven with nft directly, and the regression test fails
against the single-batch form.
@tomassrnka tomassrnka closed this Aug 12, 2026
@ValentaTomas
ValentaTomas deleted the net-v2-nftables-datapath branch September 11, 2026 03:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant