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

Sockets created inside a sandbox's network namespace and owned by this BEAM.

## The constraint this removes

An `nft` `redirect` is DNAT to the local machine *as the redirecting namespace
sees it*, so it can only ever land on a socket in that namespace. The BEAM runs
in the host namespace and no option to `:gen_tcp.listen/2` changes a socket's
namespace. That is the whole reason this project ran a separate helper process
entered via `nsenter`, and the reason Egress.Pool's own listener
was dead code: it bound the host namespace, and nothing redirected there.

`setns(2)` with `CLONE_NEWNET` affects only the calling *thread*. So the
descriptor can cross the boundary even though the process cannot: enter the
namespace on a dedicated thread, create the socket there, hand back the fd,
and adopt it with `{:fd, Fd}`. See `c_src/netns_nif.c` for why each job gets a
thread of its own and why running one on a scheduler would be a defect.

## What this is not

It is not an enforcement point and it holds no policy. It returns descriptors.
`ExSandbox.Egress.Decision.decide/3` remains the single implementation of "may
this sandbox reach this destination".

## ⚠️ Absence is refusal, not a fallback

The NIF is built only on Linux, and only when a C compiler was present. When
it is missing every function here returns `{:error, :unsupported}` and the
caller must refuse. There is deliberately no host-namespace fallback: a
listener that binds the host instead is precisely the silent no-op this module
exists to replace, and it would read as a working enforcement point while
enforcing nothing.

# `failure`

```elixir
@type failure() :: {:error, atom(), integer()} | {:error, :unsupported}
```

Why a descriptor could not be produced. The atom names the syscall that
refused, so `:setns` (namespace gone, or no `CAP_SYS_ADMIN`) is distinguishable
from `:setsockopt_mark` (no `CAP_NET_ADMIN`); the integer is `errno`.

# `netns`

```elixir
@type netns() :: String.t()
```

Path to a network namespace, e.g. `/var/run/netns/sandbox-7`.

# `available?`

```elixir
@spec available?() :: boolean()
```

Whether namespace-local sockets are possible on this host.

Recorded at load time rather than probed by calling, and read rather than
derived from `:os.type/0`: a Linux host whose build had no C compiler is
indistinguishable from macOS from here, and both must refuse.

# `listen`

```elixir
@spec listen(netns(), :inet.port_number()) :: {:ok, non_neg_integer()} | failure()
```

A listening socket bound to `port` on `INADDR_ANY` inside `netns`.

Bound to all addresses rather than loopback because the redirect rewrites the
destination to the namespace's own primary address; measured, a connection
arrived from `172.19.0.4`, so a loopback listener is never reached.

Adopt the descriptor with `:gen_tcp.listen(0, [..., {:fd, fd}])`. The port
argument to `:gen_tcp.listen/2` is ignored when `{:fd, _}` is given -- the
bind already happened here.

# `socket`

```elixir
@spec socket(netns(), non_neg_integer()) :: {:ok, non_neg_integer()} | failure()
```

An unconnected socket inside `netns`, carrying `SO_MARK` set to `mark`.

⚠️ **The mark is written, read back, and compared inside the NIF before the
descriptor is returned**, because setting it from Elixir fails open: with
`CAP_NET_ADMIN` dropped, `:inet.setopts/2` reports `:ok` while the kernel
refused and the option reads back as zero. The symptom of a lost mark is a
*permitted* destination timing out, which every denial-focused check scores as
green. There is no return path from the NIF that yields an unmarked socket.

Adopt with `:gen_tcp.connect(address, port, [..., {:fd, fd}], timeout)`, which
connects through the given descriptor and therefore out of `netns`.

# `udp`

```elixir
@spec udp(netns(), :inet.port_number()) :: {:ok, non_neg_integer()} | failure()
```

A bound UDP socket inside `netns`, for the DNS leg.

Bound to `INADDR_ANY` on `port` for the same measured reason `listen/2` is:
the redirect DNATs the tenant's port 53 traffic to the resolver's
address:port, which is not loopback. Adopt with
`:gen_udp.open(0, [..., {:fd, fd}])`.

---

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