Ctrl K
ComunidadGuías…

docs/adr/0047-doh-json-legs-verified.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# ADR-0047: Live-Verified DoH Legs — Provider Endpoints and RFC 8484 Wire

## Metadata

| Field | Value |
|-------|-------|
| **Status** | Superseded by [ADR-0072](0072-dns-consensus-revalidation-hardening.md) and [ADR-0075](0075-dns-unbound-single-source-completion.md) |
| **Date** | 2026-08-08 |
| **Authors** | AlessioBrillo |
| **Deciders** | AlessioBrillo |
| **Supersedes** | N/A |
| **Relates to** | ADR-0002, ADR-0040, ADR-0044, ADR-0045 |
| **Project** | DOMINUS |

## Context

The default DoH group (`multi-doh`) is built from a static list of three
providers — Cloudflare, Google, Quad9 — and every `DNS_LOOKUP_STRATEGY`
variant derives its `multi-doh` lookups from that list. The DNS consensus
gate (ADR-0040, ADR-0045) requires a **strict majority of the group** for
an Available verdict, and a single definitive resolve alone decides
Registered. The conservatism of the whole gate therefore rests on the
premise that every configured leg can actually answer a query.

A live probe of the three endpoints (reproduced in the RED commit
7052d21) showed that premise to be false:

1. **Google** — `https://dns.google/dns-query` answers `400` to JSON GETs.
   The JSON API lives on `/resolve` and additionally requires the
   `ct=application/dns-json` query parameter. The configured endpoint was
   perpetually inert.
2. **Quad9** — `dns.quad9.net/dns-query` is **RFC 8484 wire-format only**:
   it answers errors to `application/dns-json` JSON GETs. There is no JSON
   API at all.
3. **Cloudflare** — answers JSON on `/dns-query`; the only leg that
   worked.

The practical consequence: a "2-of-3" Available verdict was in reality
decided by a single live transport (Cloudflare). A lone external opinion
was passing the majority gate, which is exactly the single-opinion
availability risk ADR-0040 was written to eliminate, and which the
scoring engine's conservatism principle (ADR-0002) forbids for the risky
(available) verdict.

## Decision

Every default DoH lookup now ships the **request format its endpoint
actually speaks**, and each provider's endpoint is corrected to its
live-verified path:

1. **Google** → `https://dns.google/resolve`; `resolveDoh` (JSON leg)
   unconditionally sets `ct=application/dns-json` (Google requires it,
   Cloudflare ignores it) so the parameter is safe on all JSON legs.
2. **Quad9** → stays on `/dns-query` but its spec is marked
   `format: 'wire'`; a new **RFC 8484 wire leg** (`GET ?dns=<base64url>`)
   is used for wire-only endpoints. The wire leg reuses the DoT pool's
   `buildDnsQuery` (A/NS/SOA/… qtype mapping via `recordTypeToQtype`),
   verifies the DNS ID with `validateDnsResponse`, and shares
   `classifyResponse` — the same conservative RCODE semantics as DoT:
   NXDOMAIN and NODATA are definitive, SERVFAIL/REFUSED/truncated are
   neutral errors, a mismatched query ID is dropped as corrupt (ADR-0002).
3. `DnsLookupSpec.format?: 'json' | 'wire'` flows from
   `strategyToResolverGroups` (all DoH strategies) through `resolvesAnyDoh`
   into the leg selection; `getDefaultDohProviders()` exposes the format
   for status surfaces.

Verdict semantics on the JSON leg are unchanged (Status 0 + Answer =
registered; Status 3 = NXDOMAIN; any other RCODE = neutral ESERVFAIL).

## Alternatives Considered

- **Set Google to wire format instead.** Rejected: Google's wire endpoint
  is not usable as a default edge for identical reasons (it requires
  `/resolve` semantics distinctions); JSON is the primary, documented
  Google transport.
- **Drop Google or Quad9 from the default group.** Rejected: reduces
  transport diversity for a fixable endpoint issue; both providers are
  free, keyless, and well-connected.
- **Treat any single NXDOMAIN from any leg as Available.** Rejected:
  that is precisely the single-opinion risk the majority gate exists to
  prevent (ADR-0040).
- **Route Quad9 wire requests through the DoT pool chained on the same
  endpoint.** Rejected: DoH is its own transport with its own timeout
  budget and answer; chaining doubles latency and couples two legs.

## Consequences

- **Positive**: a majority Available verdict now requires three live
  transports, one lone NXDOMAIN can no longer pass as a consensus, and
  provider status surfaces report the real per-endpoint formats. The
  fix is rolling config: existing deployments gain correctness on
  restart with no schema, CLI, or env changes.
- **Negative**: the DoH layer now has two request shapes (JSON + wire)
  to maintain, and wire responses require buffer/RCODE parsing instead
  of `response.json()`. Both transports are free, keyless public
  endpoints — the zero-cost constraint (ADR-0001) holds.

## Verification

- The reproducer committed with 7052d21 sends real JSON GETs to all three
  endpoints with `accept: application/dns-json` and captured the 400 /
  505 / 200 split that motivated this ADR.
- `npx vitest run src/providers/dns` — 114 tests green.
- Full backend suite — 2037 tests green.
- `npx tsc --noEmit`, `npx eslint` on the touched providers, Prettier
  clean at 100 print width.