The platform's DNS service for sandboxes (029 T015, 029-FR-013,
029-FR-012, 029-FR-015).
Why a sandbox needs one at all
FR-013's own text calls a working DNS story "a precondition for FR-012
rather than a separate nicety", and it is right in a stronger sense than it
reads. Two independent things both stop without it:
T011's ruling made DNS the only permitted UDP destination. Theinet filter outputchain ends in a terminalmeta l4proto udp drop, so until something names a resolver, a sandbox sends no UDP at all — DNS is off, not permissive.FR-012matches a hostname entry against what this sandbox resolved that name to. Something has to have done the resolving, and it has to be the platform, because the whole point is that the tenant does not get to decide what its own allowlist means.
So this module is both halves: it answers the query, and the act of answering is what files the name→address binding the verdict later consults. Those are deliberately the same event. A design where resolution and recording are two steps has a state in which a sandbox has an answer the platform did not record — and a tenant that reaches that state connects to an address no entry can match, which reads as a broken allowlist.
Mechanism-neutral, and what that costs
⚠️ Written mechanism-neutral from the first line (D27's transfer
column). Nothing here knows about pasta, network namespaces, nft, or
bwrap. It receives DNS query bytes and a source key, and returns DNS
response bytes. A second mechanism supplies the same two things by whatever
route it has and reuses this untouched.
What that costs is a transport, and the transport is the same one the verdict
socket already uses and for the same reason: a network namespace isolates the
network stack. The socket a sandbox sends its queries to is bound inside
that sandbox's own network namespace by ExSandbox.Egress.Acceptor, using
ExSandbox.Egress.NetnsSocket, so it is reachable from the tenant's namespace
and belongs to no other.
⚠️ The datagrams used to reach this module over a second AF_UNIX socket on a
host path, carrying a length-prefixed "<source-key> " <> query frame,
because the in-namespace listener was a separate OS process that could not
call answer/3. That listener is now a process on this node and calls
answer_via/4 directly, so the socket, its framing, its parser, and the
chmod that made it reachable by the listener's uid are all gone.
Every answer passes the same structural filter as every entry
⚠️ This is the hole FR-012 opens, closed in the same module that opens
it. Name matching means a tenant who controls a DNS record for a name in
their own allowlist can point it at 127.0.0.1 — and every parse-time test
stays green while they do it. spec.md calls this "the sharpest concrete
instance of this spec's own thesis".
So every address this module is about to record is first put through
ExSandbox.Egress.Allowlist.classify/2 — the same classifier the written
entries go through, not a second copy. A refused answer is dropped from the
record and from the response, so:
- the tenant never learns the address from us, and
- the address is not in the record, so a connect to it matches no entry and
is refused by
Policyat connect time,
and the refusal names the same class (:loopback, :rfc1918_private, …) a
written entry would have been refused for.
⚠️ Dropping the answer rather than refusing the query is deliberate. A
SERVFAIL would tell the tenant which of its names the platform declines to
resolve, and more importantly it would make a rebinding attempt look like an
outage. An A record set with the excluded members removed is the honest
answer to "which of these may this sandbox be told about?".
Answers accumulate; they do not replace
A name that resolves into a rotation gives a different member on each query,
and a connection opened against the first answer while the second is being
recorded must not be refused for it. ExSandbox.Egress.Registry.record_resolution/4
unions.
The set is bounded by the sandbox's own lifetime, because it lives in the
registry entry that Binding.release/2 deletes.
What this module deliberately does not do
It does not decide anything. Policy decides; this records. And it does not
resolve on the verdict path — a resolution performed at connect time on the
platform's behalf would compare the tenant's connection against an answer the
tenant never received.
Summary
Functions
Answers one query for one sandbox: the whole service, minus the transport.
Answers query for source_key using the running resolver's state.
Returns a specification to start this module under a supervisor.
The address a sandbox reaches this resolver at, inside its own namespace.
Types
@type address() :: {:inet.ip_address(), :inet.port_number()}
Where a sandbox finds the resolver, as Netns.resolver() spells it.
Functions
@spec answer(binary(), ExSandbox.Egress.Policy.source_key(), keyword()) :: {:ok, binary()} | {:error, term()}
Answers one query for one sandbox: the whole service, minus the transport.
Takes the raw query bytes and the /30 the asking sandbox was provisioned
with; returns the raw response bytes, having filed every recordable answer
against that sandbox.
⚠️ Public and pure-ish on purpose. This is the part a second mechanism reuses untouched, and it is the part worth testing without a socket, a namespace or a container.
@spec answer_via( binary(), ExSandbox.Egress.Policy.source_key(), GenServer.server(), timeout() ) :: {:ok, binary()} | {:error, term()}
Answers query for source_key using the running resolver's state.
⚠️ Not answer/3. That function takes its registry, host aliases and upstream
resolver as options and defaults all three, so calling it directly answers
with host_aliases: [] -- and the alias list is what stops a sandbox
resolving a name to one of the host's own addresses. The defaults are correct
for a unit test and silently wrong for a tenant.
The aliases are detected once at init/1 (see the note there on why), so the
server is the only thing that holds them. This exists so a caller in this node
can reach that state without reconstructing it.
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec resolver_address() :: address()
The address a sandbox reaches this resolver at, inside its own namespace.
⚠️ This is what ExSandbox.Egress.LaunchPlan turns into the single accept
rule ahead of the UDP drop, and what ExSandbox.Hardening.Linux writes into
the tenant's /etc/resolv.conf.
⚠️ It does need tenant-side configuration, and an earlier version of this
comment claimed otherwise. The claim was that glibc falls back to
127.0.0.1 with no resolv.conf, so a sandbox with no /etc would find the
listener unaided. Measured inside unshare -n on the isolation image, with a
stub nameserver bound on 127.0.0.1:53: with no resolv.conf the stub
received nothing and the lookup returned :nxdomain; with a resolv.conf
naming 127.0.0.1 the same lookup reached the stub and resolved. The bind in
Hardening.Linux exists because of that measurement.
⚠️ A resolver on a port other than 53 gets no resolv.conf, because the file
has no syntax for one — see ExSandbox.Hardening.Linux.