One acceptor pool for every sandbox, enforcing each one's allowlist
(005 T060a1/T060a3, contracts/egress.md).
Why one pool rather than a process per sandbox
013-FR-014c asks for blast radius, not process count. A process per sandbox
is the heaviest way to get it: at 500 sandboxes that is 500 supervised OS
processes doing almost nothing. This pool holds no platform credential, and
because no sandbox has a route to any other, compromising it yields the union
of permitted outbound destinations rather than a path between tenants —
003-FR-002 does not depend on it.
How a connection is attributed
The sandbox's netns has a default route pointing here, so a connection arrives
with the sandbox's own source address. :inet.peername/1 reports what the
kernel saw, Policy.source_key/1 masks it to the /30, and that is the
identity — see ExSandbox.Egress.Policy for why it cannot be forged.
⚠️ The intended destination is recovered from the kernel, not from the
client. Under transparent enforcement the sandbox believes it is talking to
the destination directly, so there is no protocol frame in which it could
state one — which is exactly the property that makes T060a3 transparent and
keeps Principle VI intact. Any design where the sandbox tells the pool
where it wants to go reintroduces a claim the sandbox controls.
ExSandbox.Egress.OriginalDst reads that record. An earlier note here said
it was unreachable from :gen_tcp; that was wrong, and measuring it is what
unblocked this transport — see that module for what the measurement showed
and why every ambiguous read is refused.
Refusal is a closed socket, not an error message
A refused connection is closed. It is not answered with a protocol-level
rejection, because the sandbox is not aware it is being proxied and has no
frame in which to receive one. From inside, a denied destination behaves like
an unreachable one — which is what FR-011a describes.
⚠️ The listener here is no longer in the egress path (2026-08-18)
decide/3 is still the single implementation of "may this sandbox reach this
destination", and it is what ExSandbox.Egress.Verdict answers from. That
part is load-bearing and shared.
The listener is not. It binds 127.0.0.1 in the host namespace, and 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 this pool
listening on the host and the redirect installed in the sandbox's namespace,
the tenant's connect returned OK and this pool never saw the connection.
ExSandbox.Egress.Acceptor is where the traffic actually lands.
It is kept rather than deleted because decide/3, handle_connection/2 and
the relay wiring are exercised by tests that would otherwise have nowhere to
run, and because the transport tests document the allowlist's behaviour on a
socket. But nothing redirects here any more, and a supervised listener that
nothing reaches is exactly the shape that reads as a working enforcement point
while enforcing nothing. If this comment outlives the tests that justify it,
the listener should go.
Summary
Types
What the pool decided about one connection attempt.
Functions
Returns a specification to start this module under a supervisor.
Decides whether a connection from source to destination may proceed.
The port this pool is listening on.
Types
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec decide( :inet.ip4_address(), {String.t(), :inet.port_number()}, GenServer.server() ) :: decision()
Decides whether a connection from source to destination may proceed.
Split out from the socket handling so the decision is testable without a
network: 003's conformance suite establishes the boundary by attempting
connections, but a unit test of the decision itself should not need a
listener to state what the rule is.
@spec port(GenServer.server()) :: :inet.port_number()
The port this pool is listening on.