ExSandbox.Egress.Relay (ExSandbox v1.0.1)

Copy Markdown View Source

Forwards a permitted connection to its destination (005 T060a9, contracts/egress.md).

Why this is a module and not three lines in the pool

ExSandbox.Egress.Pool's relay/2 -- since removed -- was a placeholder that logged and closed. That was the honest shape while the netns did not exist — it denies something the policy allows, which fails closed and is visible as the "permitted destination is reachable" check not passing. It was never a false pass.

But it made two conformance checks unclosable, and separating the forwarding from the socket accept loop is what makes the forwarding testable. Off-Linux every connection to the pool dies at OriginalDst.read/1, so a test driving the listener never reaches this code at all — the same vacuity pool_transport_test.exs documents for the allowlist. splice/3 takes two ordinary sockets, so its behaviour is reachable on any host.

⚠️ The bug direction that matters here

Every other component in this subsystem fails safe when it fails: a broken decoder refuses, a missing policy denies, an unsupervised pool denies. The relay is the one place where the natural bug goes the other way. Code that forwards on an error path — a destination that could not be connected, a socket in an unexpected state, a recv that returned something unhandled — is a boundary that stops enforcing exactly when something is wrong with it.

So: this module never opens a socket it was not told to open, and never continues past an error. decide/3 has already permitted this one destination; the relay's only job is to carry bytes to it and to stop when anything at all goes wrong. There is no retry, no fallback destination, and no path where a failure results in more reachability than a success.

Both halves, and why the pair is torn down together

A half-duplex relay forwards the request and drops the response, which from inside the sandbox reads as a slow destination rather than a broken proxy — and passes every denial check. Both directions are carried, and either side closing tears down both, because a socket left open to a destination that is gone is a descriptor leak whose only symptom is the pool failing to accept long after and nowhere near the cause.

Summary

Types

Where a permitted connection is headed.

Functions

Connects to destination and carries bytes both ways until either side ends.

Types

destination()

@type destination() :: {:inet.ip4_address(), :inet.port_number()}

Where a permitted connection is headed.

Functions

splice(sandbox_socket, arg, opts \\ [])

@spec splice(:gen_tcp.socket(), destination(), keyword()) :: :ok | {:error, term()}

Connects to destination and carries bytes both ways until either side ends.

Returns :ok once the pair is torn down, and {:error, reason} if the destination could not be reached — in which case sandbox_socket is closed before returning, so the sandbox sees a refusal rather than a hang.

⚠️ sandbox_socket is closed on every path out of this function. From inside the sandbox a closed socket is what a denied or unreachable destination looks like (FR-011a), and leaving it open on an error path turns a refusal into an indefinite hang that the conformance probe scores as a timeout rather than a refusal.

upstream_connect_opts()

@spec upstream_connect_opts() :: [:gen_tcp.connect_option()]

The options splice/3 passes to :gen_tcp.connect/4.

Public only so acceptor_mark_wiring_test.exs can assert that the SO_MARK set here is the one the redirect exempts. Asserting on a duplicate literal in the test would pass while the socket carried something else, which is precisely the defect being guarded against.