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, not the filesystem, so an AF_UNIX socket on a host path is
reachable from inside a sandbox's netns while being invisible to the tenant,
which bwrap never binds into its mount view. See ExSandbox.Egress.Verdict
for the measurement.
⚠️ The listener that carries datagrams to this socket runs inside the
namespace (nsacceptor.py), for the identical reason the TCP acceptor does:
a socket's network namespace is fixed by the namespace of the calling
process, and the BEAM never enters the sandbox's.
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.
Returns a specification to start this module under a supervisor.
Where the resolver socket lives.
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.
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec default_path() :: String.t()
Where the resolver socket lives.
⚠️ Deliberately not under a sandbox's storage or any path Hardening.Linux
binds into a tenant's mount view — the same rule, and the same reason, as
ExSandbox.Egress.Verdict.default_path/0.
⚠️ Defaults to the verdict socket's own directory rather than to a second
configured constant, and that is the point rather than a shortcut. The two
sockets have identical requirements — bindable by the platform, invisible to
the tenant — so a deployment that had to state the directory twice would have
two chances to state it differently. The failure mode of a divergence is not
symmetric: /var/run is writable on a deployment host and not on a developer
machine, and a resolver that cannot bind refuses to start the whole node.
@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.