ExSandbox.Egress.Pool (ExSandbox v1.0.1)

Copy Markdown View Source

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

decision()

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

What the pool decided about one connection attempt.

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

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.

port(server \\ __MODULE__)

@spec port(GenServer.server()) :: :inet.port_number()

The port this pool is listening on.