# `ExSandbox.Egress.Resolver`
[🔗](https://github.com/FoundryStack/ex_sandbox/blob/v1.2.0/lib/ex_sandbox/egress/resolver.ex#L1)

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/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 `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.

# `address`

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

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

# `answer`

```elixir
@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`

```elixir
@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`

Returns a specification to start this module under a supervisor.

See `Supervisor`.

# `resolver_address`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
