ExSandbox.Egress.Acceptor (ExSandbox v1.0.0)

Copy Markdown View Source

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

Why this exists, and what it replaced

ExSandbox.Egress.Pool binds 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 a port helper rather than :gen_tcp

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 — that is fixed by the namespace of the process at the moment of the syscall.

So the listener is a separate OS process, entered into the sandbox's namespace with nsenter -t <holder-pid> -n, which binds there and relays.

⚠️ nsenter targets 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 acceptor in the host namespace, where it would bind a host port and see none of the sandbox's traffic.

What is enforced here, and what is not

Nothing. The decision is ExSandbox.Egress.Pool.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.

Summary

Types

How to reach the namespace this acceptor serves.

Functions

Returns a specification to start this module under a supervisor.

The command that starts a listener inside holder_pid's namespace.

Whether a connection from this acceptor's sandbox may reach destination.

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.

Types

spec()

@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.

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

listener_command(holder_pid, port, helper_path, verdict_path, source_key, resolver_path, arg7)

The command that starts a listener inside holder_pid's namespace.

Built here rather than inlined at the call site so it is testable on a host where it cannot run — which is every developer machine that is not Linux, and therefore every host where this would otherwise go unverified.

⚠️ Binds 0.0.0.0, not 127.0.0.1. A redirect rewrites the destination address to a local one, but the packet arrives on the namespace's own interface rather than on loopback. Measured: the acceptor bound 0.0.0.0 received peer=('172.19.0.4', 48160) — the namespace's eth0 address, not 127.0.0.1. A loopback-only bind would have missed every connection while looking correct.

This is safe because of where it binds: the namespace holds exactly one tenant and nothing else can route to it, so 0.0.0.0 there is narrower than 127.0.0.1 on the host.

permits?(map, destination, registry)

@spec permits?(spec(), {String.t(), :inet.port_number()}, GenServer.server()) ::
  boolean()

Whether a connection from this acceptor's sandbox may reach destination.

Delegates to ExSandbox.Egress.Pool.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.

sandbox_address(arg)

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.Verdict reconstructs the same address when an acceptor names its sandbox on the wire. ⚠️ Two copies of this arithmetic would be two things that must agree forever, and 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.