# `ExSandbox.Egress.Netns`
[🔗](https://github.com/FoundryStack/ex_sandbox/blob/v1.2.0/lib/ex_sandbox/egress/netns.ex#L1)

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.

# `resolver`

```elixir
@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.

# `acceptor_mark`

```elixir
@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`

```elixir
@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.

# `pasta_command`

```elixir
@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.

| 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.

# `redirect_commands`

```elixir
@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.

# `runas_for_uid`

```elixir
@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!`

```elixir
@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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
