ExSandbox.Egress.Netns (ExSandbox v1.0.0)

Copy Markdown View Source

The commands that turn a sandbox's network namespace into its only path out (005 T060a3, contracts/egress.md).

The shape, and why it is inverted from the obvious one

The obvious design — create a named namespace, configure it, attach pasta, then have the tenant join it — is not reachable. Measured, in egress-path-measurements.md: pasta cannot join a namespace made by ip netns add here, failing with Failed to join network namespace: Permission denied. It works only when it spawns the namespace itself.

So the order inverts:

  1. pasta --config-net --runas 0 -P <pidfile> -- <tenant> creates the namespace, configures its interface and default route, and starts the tenant inside it,
  2. the host then installs the redirect into that namespace with nsenter -t <holder-pid> -n nft ….

⚠️ Step 2 cannot be done from inside. pasta's namespace is unprivileged, so the tenant has no CAP_NET_ADMIN and nft there fails with Operation not permitted. That is desirable — a tenant able to edit the ruleset would defeat FR-011b by reconfiguration rather than by connection — but it means the policy is installed from outside, by us, after the tenant is running.

What pasta configures, and what this module therefore does not

pasta --config-net brings up the interface, assigns the address, and installs the default route. An earlier version of this module emitted ip addr add, ip link set, and ip route add steps of its own; all three failed with Cannot find device "sb0", because nothing had created the device and pasta had not yet run. They were not merely redundant — they ran before the thing that makes them possible.

⚠️ The default route is still load-bearing, it is simply not ours to install. Without it the kernel rejects an outbound connect with enetunreach before the nat hook runs, and the sandbox is merely isolated — the state --unshare-net already gave, which passes every denial check while enforcing no policy at all. pasta provides it; this module's job is to not get in the way, and verify_route/1 exists so its absence is caught rather than assumed.

Why the redirect is output rather than prerouting

The traffic originates inside this namespace, so it traverses the output hook. prerouting sees only packets arriving from elsewhere, and there is no elsewhere. A prerouting rule would install cleanly, list correctly, and match nothing.

Why the UDP drop is a filter chain and not another nat rule

The TCP redirect lives in nat/output because it translates a destination. The UDP rule does not translate anything — it refuses — and a drop does not belong in a nat chain merely because that is where the neighbouring rule was. nat chains are consulted by conntrack for the first packet of a flow only; a filtering verdict placed there is evaluated on a schedule that has nothing to do with how often the traffic it is refusing occurs. So the drop gets its own base chain of type filter, and the two hooks stay honest about what each is for.

⚠️ The chain's policy is accept, and that is not a widening. A drop policy on an output filter chain refuses everything, TCP included, and the TCP path — which is policed by the redirect, not by this chain — would go with it. Default-deny for UDP is expressed by the terminal meta l4proto udp drop below, which nothing after it can reach past.

⚠️ Family inet, not ip. The redirect's table is ip (IPv4 only), which is defensible for a translation whose target is an IPv4 acceptor. A refusal scoped to IPv4 would leave the identical IPv6 datagram to walk out, which is the shape FR-015 calls "a control that reads as the guarantee it is not". inet covers both in one chain. It also fails closed if a kernel will not build it: run_steps/1 halts on a non-zero nft exit and police_or_terminate/3 terminates the tenant, so an unsupported table stops the launch rather than passing it unpoliced.

Measured, on docker-isolation:latest (nftables v1.1.3), 2026-08-23

Every command below installs (rc=0), and nft folds the redundant meta l4proto udp into the udp dport match when it lists the exemption back:

table inet filter {
  chain output {
    type filter hook output priority filter; policy accept;
    ip daddr 10.0.0.53 udp dport 53 accept
    meta l4proto udp drop
  }
}

⚠️ And the rule was measured by attempting the operation, not by reading the ruleset (FR-016). In a namespace with a real default route — without one every result is ENETUNREACH before the hook runs, and isolated reads exactly like policed:

BEFORE            udp 8.8.8.8:53      -> SENT        <- the hole
                  udp 10.0.0.1:53     -> SENT
no resolver       udp 8.8.8.8:53      -> EPERM
                  udp 10.0.0.1:53     -> EPERM
                  udp 127.0.0.1:53    -> EPERM
resolver 10.0.0.53:53
                  udp 10.0.0.53:53    -> SENT        <- the sole destination
                  udp 10.0.0.53:5353  -> EPERM
                  udp 8.8.8.8:53      -> EPERM
TCP control       tcp 10.0.0.1:80     -> timeout, NOT EPERM

⚠️ The TCP line is the one that makes the rest non-vacuous: a chain whose policy were drop would refuse TCP too, and every UDP line above would look identical. A timeout there is the correct non-policy outcome for a route with nothing behind it.

⚠️ This was a hand-built namespace, not pasta's, and the commands were run directly rather than through LaunchPlan. It establishes the grammar and the kernel's behaviour; it does not establish that the policed launch path installs them. That is 029 T014's checkpoint.

What this module deliberately does not do

It builds commands; it does not run them. The launch path composes them, and the conformance suite establishes the boundary by attempting connections rather than by inspecting rules. A test asserting that the right nft string was produced proves the string, not the boundary.

Summary

Types

The one UDP destination a sandbox may reach, or nil for none.

Functions

The SO_MARK value the acceptor sets on its own upstream connections.

The sandbox-side address of a /30, and the gateway address.

Whether ip route show output carries a default route.

The pasta invocation that creates a namespace and starts the tenant in it.

The commands that install the redirect into a running tenant's namespace.

The command that reads a namespace's routing table.

The --runas value for a uid, as pasta spells it.

Returns resolver unchanged, or raises if it is not a usable one.

Types

resolver()

@type resolver() :: {String.t() | :inet.ip_address(), :inet.port_number()} | nil

The one UDP destination a sandbox may reach, or nil for none.

⚠️ An address, never a hostname — an nft rule cannot resolve a name, and the thing that would resolve it is the resolver this names.

Functions

acceptor_mark()

@spec acceptor_mark() :: pos_integer()

The SO_MARK value the acceptor sets on its own upstream connections.

Exists so the redirect can skip them: without the exemption the acceptor's connect to a permitted destination is caught by its own redirect and it talks to itself, which surfaces as a permitted destination timing out.

addresses(arg)

@spec addresses(ExSandbox.Egress.Policy.source_key()) :: %{
  gateway: String.t(),
  sandbox: String.t()
}

The sandbox-side address of a /30, and the gateway address.

A /30 holds exactly four addresses: network, two hosts, broadcast. The first host is the gateway, the second is the sandbox.

⚠️ Retained because Policy.source_key/1 masks the address the pool sees back to the key its allowlist is filed under — the join between a namespace and its policy. pasta assigns the namespace's address from the host interface it copies, so this is the addressing scheme, not a claim about what pasta will hand out.

default_route?(output)

@spec default_route?(String.t()) :: boolean()

Whether ip route show output carries a default route.

pasta_command(pidfile, tenant_command, runas \\ "0")

@spec pasta_command(String.t(), [String.t()], String.t()) :: [String.t()]

The pasta invocation that creates a namespace and starts the tenant in it.

⚠️ --runas 0 is required and is not a hardening choice. Without it pasta drops to nobody, then cannot re-enter the namespace it just made:

Started as root, will change to nobody.
Failed to join network namespace: Permission denied

⚠️ --config-net is what makes this work without a host capability. Its one real prerequisite is the /dev/net/tun device, not a capability, which is why probe_network_policy/0 checks for the device and compose.isolation.yml declares it.

⚠️ There is deliberately no --interface flag. An earlier version passed --interface sb0, believing it named the namespace-side device; -i selects the host interface to copy addresses and routes from, and the namespace-side name is -I/--ns-ifname. Measured: pasta --config-net --interface sb0 … fails with Invalid interface name sb0: No such device. Neither is needed — pasta picks the host's default route interface, which is the one with a route out.

The four flags that close the host off (029-FR-015, 029-FR-018)

pasta's defaults are built for convenience — it wants the namespace to reach the host and the host to reach the namespace. Every one of the flags below turns a default off, and none of them is a hardening extra.

flagwhat the default does
--no-map-gwmaps the namespace's default gateway to the host, so the host is reachable at the gateway address
-t none-t auto forwards inbound TCP: a tenant binding 0.0.0.0:8080 binds 0.0.0.0:8080 on the host (FR-018)
-T nonethe same for TCP in the outbound-to-host direction
-u none-u auto forwards inbound UDP
-U nonethe same for UDP in the outbound-to-host direction

⚠️ --no-map-gw alone closes half the doors and reads as complete. The namespace reaches host 127.0.0.1 by two independent paths, and that flag closes one of them: the gateway-address mapping. The other is the port forwarding that -t/-T/-u/-U default to auto. A build that passes --no-map-gw and stops has a narrower hole rather than no hole, and it presents identically to one that has none — curl to the gateway address gets nothing, which is exactly what a correct configuration looks like.

⚠️ -t none deliberately disables the inbound forwarding Phase 3 wants. That is not an oversight to be repaired when Phase 3 lands. Phase 3 replaces it with an explicit -t <hostport>:<nsport>, which is a narrowing of auto — one named port instead of every port the tenant chooses to bind — and not a re-widening back to the default.

⚠️ This function builds a command. That the flags are passed is all a command-string assertion can show; that they close the doors is a different claim needing a live namespace, and it belongs to T012/T014's probe set, not here.

redirect_commands(holder_pid, pool_port, resolver \\ nil)

@spec redirect_commands(pos_integer(), :inet.port_number(), resolver()) :: [
  [String.t()]
]

The commands that install the redirect into a running tenant's namespace.

holder_pid is the pid of the process inside the namespace.

⚠️ It is not pasta's own pid, and the difference is a silent catastrophe rather than an error. pasta -P writes its host-side pid; the tenant runs in a child. Measured:

pidfile pid = 10 -> ns net:[4026534462]   <- the HOST namespace
tenant  pid = 11 -> ns net:[4026534599]   <- the sandbox namespace

nsenter -t 10 -n nft … installs the sandbox's redirect into the host namespace: it succeeds, warns about nothing, and leaves the tenant unpoliced while the host acquires a stray NAT rule. ExSandbox.Egress.Pasta finds the holder by comparing namespace inodes for exactly this reason.

route_command(holder_pid)

@spec route_command(pos_integer()) :: [String.t()]

The command that reads a namespace's routing table.

Used to verify pasta installed a default route before the tenant is allowed to matter. See the moduledoc: without one, every connect fails enetunreach before the redirect is consulted, and the sandbox is isolated rather than policed — a state that passes every denial check.

runas_for_uid(uid)

@spec runas_for_uid(non_neg_integer()) :: String.t()

The --runas value for a uid, as pasta spells it.

⚠️ --runas 0 is correct only when pasta runs as root. Under the split ordering (LaunchPlan.build/4) it runs after setpriv has already dropped to the sandbox uid, and there --runas 0 fails outright:

Can't set GID to 0: Operation not permitted

and --runas 0:0 hangs rather than erroring, which is worse -- a launch that never returns rather than one that fails. A matching non-zero uid:gid pair is the only value measured to work after the drop, and it produces uid_map = 0 <uid> 1 (see egress-path-measurements.md).

validate_resolver!(resolver)

@spec validate_resolver!(resolver()) :: resolver()

Returns resolver unchanged, or raises if it is not a usable one.

⚠️ Exists so the refusal lands at plan-build time rather than at rule-install time. resolver_exemption/2 also raises, but it runs after pasta has started the tenant, so a bad address there terminates a running sandbox instead of refusing a launch. Both raise; this one raises early, and the two share parse_resolver_address/1 so they cannot disagree about what is readable.

nil is valid and means no UDP destination at all — see udp_commands/2 on why that is default-deny rather than a degradation.