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

The listener that lives inside one sandbox's network namespace
(005 T060a1/T060a3, `contracts/egress.md`).

## Why this exists, and what it replaced

Egress.Pool -- the module now called `ExSandbox.Egress.Decision`, back when it
still held a socket -- bound `127.0.0.1` in the **host** namespace and was
designed as one pool for every sandbox — `013-FR-014c` argued for blast
radius, not process count, and a process per sandbox is the heaviest way to
get it.

That design cannot work, and the reason is not a bug to fix. An `nft`
`redirect` is DNAT **to the local machine as the namespace sees it**, so it
can only ever reach a socket in that namespace. Measured: with the pool
listening on the host and the redirect installed in the sandbox's namespace,
the tenant's connect returned OK and the pool never saw the connection.

The one alternative that preserved a single pool — `dnat` to the gateway —
was measured and **does not work**: `pasta` is a userspace stack that
terminates and re-originates connections, so the pool reads
`ORIGINAL_DST=127.0.0.1:<pool port>`, its own address, and would judge every
connection against that. See `egress-path-measurements.md` option (b).

So the acceptor moves to where the redirect lands. The blast-radius argument
survives in substance — no acceptor holds a platform credential, and no
sandbox has a route to any other — but the shape it justified does not.

## Why this is no longer a separate OS process (2026-08-29)

It used to be. The reasoning was that the BEAM runs in the host namespace, a
socket it opens is a host socket, and no option to `:gen_tcp.listen/2` changes
which namespace a socket belongs to. The first two are true. The third is
true and irrelevant, which is the part that was missed for a year.

`setns(2)` with `CLONE_NEWNET` affects only the calling **thread**. So the
socket can be created in the sandbox's namespace on a thread of its own and
the descriptor handed back, and `:gen_tcp.listen/2` will adopt it with
`{:fd, Fd}`. The socket never moves; it was never here. Measured:

    listener adopted from the namespace fd   {:ok, {{0, 0, 0, 0}, 9200}}
    connect from the HOST namespace          {:error, :econnrefused}
    connect from INSIDE the namespace        received its bytes
    SO_ORIGINAL_DST on the accepted socket   readable

The `econnrefused` is the load-bearing half: that port does not exist here.

⚠️ The namespace is named by `/proc/<holder-pid>/ns/net`, and `holder_pid` is
the **namespace holder**, never `pasta`'s own pid. See `ExSandbox.Egress.Pasta`:
the pidfile records pasta's host-side process, and entering that one puts the
listener in the *host* namespace, where it would bind a host port and see none
of the sandbox's traffic. That hazard is unchanged by dropping the helper --
only the mechanism that consumes the pid changed, from `nsenter -t` to a path
under `/proc`.

## What dropping the helper deleted

The helper could not be asked a question in-process, so everything it needed
had to become a protocol: an `AF_UNIX` verdict socket with its own wire
format, a second one for DNS with its own framing, the sandbox's identity
passed on `argv`, a readiness line parsed off stdout, and the discipline that
any failure to *obtain* a verdict is a refusal because the platform might be
unreachable. None of that was incidental complexity -- all of it was the cost
of the process boundary. `decide/3` is now an ordinary function call, so the
boundary and its protocols are gone rather than simplified.

## What is enforced here, and what is not

Nothing. The decision is `ExSandbox.Egress.Decision.decide/3`'s, unchanged and
shared, so there is exactly one implementation of "may this sandbox reach
this destination" and moving the listener did not fork it.

⚠️ The identity is different, though, and the difference is load-bearing.
The host pool attributed a connection by `peername` masked to a `/30`,
because every sandbox reached the same socket and they had to be told apart.
This acceptor serves **one** namespace: nothing else can reach it, so the
sandbox's identity is the acceptor's own existence rather than anything read
off the connection. `source_key` is supplied at start and is not derived from
the peer — a per-namespace listener that trusted `peername` would be reading
a value the tenant partly controls in order to answer a question it has
already answered by connecting at all.

# `spec`

```elixir
@type spec() :: %{
  source_key: ExSandbox.Egress.Policy.source_key(),
  holder_pid: pos_integer(),
  port: :inet.port_number()
}
```

How to reach the namespace this acceptor serves.

# `child_spec`

Returns a specification to start this module under a supervisor.

See `Supervisor`.

# `handle_connection`

```elixir
@spec handle_connection(:gen_tcp.socket(), map()) :: :ok
```

Decides one accepted connection and either relays it or closes it.

⚠️ The destination is read from the kernel with `SO_ORIGINAL_DST`, never from
the client. This is a *transparent* proxy: the sandbox believes it is talking
to the destination directly, so there is no frame in which it could state one
-- which is what keeps the claim unforgeable. Any design where the sandbox
tells the acceptor where it wants to go reintroduces exactly that claim.

⚠️ Every non-permit outcome closes the socket, including the ones that are not
policy decisions -- an undecodable destination, an unreadable option. Those
are host or kernel faults rather than denials, and they are deliberately
treated the same way at the socket. A fault that let the connection through
would be an enforcement point that stops enforcing precisely when something is
wrong with it. The reason is logged so an operator can tell a denial from a
malfunction; that distinction is lost in the *logs*, never in the *outcome*.

# `sandbox_address`

```elixir
@spec sandbox_address(ExSandbox.Egress.Policy.source_key()) :: :inet.ip4_address()
```

The acceptor's own /30, expressed as an address `Policy.source_key/1` masks
back to that /30 — so the shared decision function is reached with the
identity this acceptor was started for.

Public because `ExSandbox.Egress.Binding` derives the same address, and two
copies of this arithmetic would be two things that must agree forever. The
symptom of them drifting is a sandbox judged against a *neighbouring*
sandbox's allowlist — a cross-tenant policy error with no local sign of being
wrong.

# `verdict`

```elixir
@spec verdict(spec(), {String.t(), :inet.port_number()}, GenServer.server()) ::
  ExSandbox.Egress.Decision.decision()
```

What the shared decision says about a connection from this sandbox.

Delegates to `ExSandbox.Egress.Decision.decide/3` with the `source_key` this
acceptor was started for — see the moduledoc on why the key is supplied
rather than read from the peer.

⚠️ Returns the tagged verdict rather than a boolean because
`handle_connection/2` has to log *why* it refused, and `false` cannot say
whether the allowlist denied the destination or the sandbox has no registered
policy at all.

⚠️ A boolean `permits?/3` stood beside this until 2026-08-29, justified in its
own docstring as "one implementation, so the two cannot drift". Nothing called
it. A wrapper with no caller cannot drift from anything, and the justification
described a risk that only existed if it were used -- the same shape as the
pool listener this module replaced, kept for a reason that had stopped being
true.

---

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