ExSandbox.Egress.Decision (ExSandbox v1.2.0)

Copy Markdown View Source

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

decision()

@type decision() ::
  :permitted | {:refused, :not_permitted} | {:refused, :unknown_source}

What was decided about one connection attempt.

Functions

decide(source, destination, registry \\ Registry)

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.