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:
pasta --config-net --runas 0 -P <pidfile> -- <tenant>creates the namespace, configures its interface and default route, and starts the tenant inside it,- 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
@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
@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.
@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.
Whether ip route show output carries a default route.
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.
| flag | what the default does |
|---|---|
--no-map-gw | maps 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 none | the same for TCP in the outbound-to-host direction |
-u none | -u auto forwards inbound UDP |
-U none | the 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.
@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 namespacensenter -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.
@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.
@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 permittedand --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).
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.