The single implementation of "may this sandbox reach this destination"
(005 T060a1/T060a3, contracts/egress.md).
⚠️ This was ExSandbox.Egress.Pool, and there is no pool here any more (2026-08-29)
The old name is worth knowing because it is what the git history, the older
commit messages, and the historical notes in ExSandbox.Egress.Acceptor and
ExSandbox.Egress.Relay all say. It is not a synonym: everything those notes
attribute to Egress.Pool -- the host listener, the accept loop, the relay
call, the supervised child -- is gone, and what carried the name forward is
the one function that never had anything to do with a pool.
There was one. This module supervised a listener on 127.0.0.1 in the host
namespace, one for every sandbox, and 013-FR-014c's blast-radius argument is
what justified sharing it — a process per sandbox is the heaviest way to get
blast radius, and this pool held no platform credential.
That design cannot work, and the reason is not a bug that was fixed. An nft
redirect is DNAT to the local machine as the redirecting 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 the pool never saw the
connection. ExSandbox.Egress.Acceptor is where the traffic lands, one per
namespace, since setns(2) made a namespace-local socket possible from this
BEAM.
The listener stayed here for ten days after it stopped being reachable, and the
moduledoc it carried said so — including the condition for removing it: "If
this comment outlives the tests that justify it, the listener should go." It
did, in the worst way available. pool_relay_wiring_test.exs and
pool_transport_test.exs were the tests that justified it, and by the end they
were driving this copy of the accept-decide-relay path while the acceptor's
copy — the one every tenant connection actually reaches — had two tests.
Correct tests over dead code, which is the same defect species as the
unsupervised pool (3a4f5eb) and the unreferenced Binding (8af4e76) with
the polarity reversed. Both files now stand over the acceptor.
Deleted with the listener: init/1's :gen_tcp.listen, handle_continue/2,
accept_loop/3, port/1, handle_connection/3, relay/2,
source_address/1, and the entry in ExSandbox.Application's supervision
tree. This module is no longer a process.
What is left, and why it is shared
decide/3. Every acceptor calls it, so there is exactly one implementation of
the allowlist question and moving the listener into the namespaces did not fork
it. It used to be reached over an AF_UNIX socket via ExSandbox.Egress.Verdict,
because the acceptor was a separate OS process; both are gone and the call is
ordinary.
How a connection is attributed
⚠️ Not by peername any more, and the change is load-bearing. The host
pool masked the peer address to a /30 because every sandbox reached the same
socket and they had to be told apart. An acceptor serves one namespace:
nothing else can reach it, so the sandbox's identity is the acceptor's own
existence. ExSandbox.Egress.Acceptor.verdict/3 supplies the key it was
started with and reconstructs the address this function masks.
ExSandbox.Egress.Policy is where the masking and the matching live, and why
a source key cannot be forged.
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 socket handling is
ExSandbox.Egress.Acceptor.handle_connection/2's; this module returns a
verdict and touches no socket.
Summary
Types
What was decided about one connection attempt.
Functions
Decides whether a connection from source to destination may proceed.
Types
Functions
@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.