ExSandbox.Egress.Resolver (ExSandbox v1.2.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. 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/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.

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

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.

answer_via(query, source_key, server \\ __MODULE__, timeout \\ 10000)

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

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

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.