ExSandbox.Egress.Resolver (ExSandbox v1.0.0)

Copy Markdown View Source

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. The inet filter output chain ends in a terminal meta l4proto udp drop, so until something names a resolver, a sandbox sends no UDP at all — DNS is off, not permissive.
  • FR-012 matches 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/2the 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 Policy at 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

Types

Where a sandbox finds the resolver, as Netns.resolver() spells it.

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

address()

@type address() :: {:inet.ip_address(), :inet.port_number()}

Where a sandbox finds the resolver, as Netns.resolver() spells it.

Functions

answer(query, source_key, opts \\ [])

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

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

default_path()

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

resolver_address()

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