Public: anyone can read this; the crew can change it.
A domain's proof is asked of the zone's own nameservers when the box's resolver says no #327
Check · Review · Land · Live done — Landed and live in production.
domains-authoritative → main 1 commit
2 files
changed +145
−4
src/apps/domains.ts +92 −4
| @@ -9,7 +9,13 @@ | ||
| 9 | 9 | * that will not answer — is a sentence a person can act on, never a throw. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import { resolveCname, resolveTxt } from "node:dns/promises"; | |
| 12 | import { | |
| 13 | Resolver, | |
| 14 | resolve4, | |
| 15 | resolveCname, | |
| 16 | resolveNs, | |
| 17 | resolveTxt, | |
| 18 | } from "node:dns/promises"; | |
| 13 | 19 | |
| 14 | 20 | export interface DnsRecord { |
| 15 | 21 | readonly type: "TXT" | "CNAME" | "ALIAS"; |
| @@ -52,10 +58,92 @@ export interface DomainResolver { | ||
| 52 | 58 | resolveCname(name: string): Promise<string[]>; |
| 53 | 59 | } |
| 54 | 60 | |
| 55 | const systemResolver: DomainResolver = { resolveTxt, resolveCname }; | |
| 61 | /** The three questions the authoritative lookup asks, so a test can answer them. */ | |
| 62 | export interface Authority { | |
| 63 | resolveNs(zone: string): Promise<string[]>; | |
| 64 | resolve4(host: string): Promise<string[]>; | |
| 65 | /** TXT for `name`, asked of exactly these servers. */ | |
| 66 | resolveTxtAt(servers: string[], name: string): Promise<string[][]>; | |
| 67 | } | |
| 68 | ||
| 69 | const isNone = (error: unknown): boolean => { | |
| 70 | const code = | |
| 71 | typeof error === "object" && error !== null && "code" in error | |
| 72 | ? String((error as { code: unknown }).code) | |
| 73 | : ""; | |
| 74 | return code === "ENODATA" || code === "ENOTFOUND"; | |
| 75 | }; | |
| 76 | ||
| 77 | /** | |
| 78 | * The zone's own nameservers, found by walking up from the record's name: | |
| 79 | * `_forge-verify.game.example.com` → `game.example.com` (no NS) → | |
| 80 | * `example.com` (its NS). The top label is never a zone anyone answers for. | |
| 81 | */ | |
| 82 | async function nameserversFor( | |
| 83 | name: string, | |
| 84 | authority: Authority, | |
| 85 | ): Promise<string[]> { | |
| 86 | const labels = name.split("."); | |
| 87 | for (let at = 1; at < labels.length - 1; at++) { | |
| 88 | const zone = labels.slice(at).join("."); | |
| 89 | const hosts = await authority.resolveNs(zone).catch(() => [] as string[]); | |
| 90 | if (hosts.length === 0) continue; | |
| 91 | const addresses = ( | |
| 92 | await Promise.all( | |
| 93 | hosts.slice(0, 4).map((h) => authority.resolve4(h).catch(() => [])), | |
| 94 | ) | |
| 95 | ).flat(); | |
| 96 | if (addresses.length > 0) return addresses; | |
| 97 | } | |
| 98 | throw Object.assign(new Error(`no nameservers for ${name}`), { | |
| 99 | code: "ENOTFOUND", | |
| 100 | }); | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * The system resolver first — the box's own view — and, when it says there | |
| 105 | * is no such record, the zone's own nameservers. A resolver on the way (a | |
| 106 | * cluster's, a tailnet's) may hold a "not found" from a minute before the | |
| 107 | * record was published, for as long as the zone's negative TTL says; the | |
| 108 | * authority never does. Certificate issuers look a proof up the same way. | |
| 109 | * Names are asked absolute, so a search suffix never widens the question. | |
| 110 | */ | |
| 111 | export function withAuthority( | |
| 112 | primary: DomainResolver, | |
| 113 | authority: Authority, | |
| 114 | ): DomainResolver { | |
| 115 | return { | |
| 116 | async resolveTxt(name) { | |
| 117 | try { | |
| 118 | return await primary.resolveTxt(name); | |
| 119 | } catch (error) { | |
| 120 | if (!isNone(error)) throw error; | |
| 121 | const servers = await nameserversFor(name, authority); | |
| 122 | return authority.resolveTxtAt(servers, name); | |
| 123 | } | |
| 124 | }, | |
| 125 | resolveCname: (name) => primary.resolveCname(name), | |
| 126 | }; | |
| 127 | } | |
| 128 | ||
| 129 | const systemResolver: DomainResolver = withAuthority( | |
| 130 | { | |
| 131 | resolveTxt: (name) => resolveTxt(`${name}.`), | |
| 132 | resolveCname: (name) => resolveCname(`${name}.`), | |
| 133 | }, | |
| 134 | { | |
| 135 | resolveNs: (zone) => resolveNs(`${zone}.`), | |
| 136 | resolve4: (host) => resolve4(`${host}.`), | |
| 137 | resolveTxtAt(servers, name) { | |
| 138 | const asked = new Resolver({ timeout: 3_000, tries: 1 }); | |
| 139 | asked.setServers(servers); | |
| 140 | return asked.resolveTxt(`${name}.`); | |
| 141 | }, | |
| 142 | }, | |
| 143 | ); | |
| 56 | 144 | |
| 57 | 145 | /** How long a lookup may take before it is called a timeout. */ |
| 58 | const LOOKUP_TIMEOUT_MS = 5_000; | |
| 146 | const LOOKUP_TIMEOUT_MS = 8_000; | |
| 59 | 147 | |
| 60 | 148 | export interface DomainCheck { |
| 61 | 149 | readonly verified: boolean; |
| @@ -139,11 +227,11 @@ async function lookup<T>(run: () => Promise<T>): Promise<Lookup<T>> { | ||
| 139 | 227 | try { |
| 140 | 228 | return { kind: "found", value: await Promise.race([run(), timeout]) }; |
| 141 | 229 | } catch (error) { |
| 230 | if (isNone(error)) return { kind: "none" }; | |
| 142 | 231 | const code = |
| 143 | 232 | typeof error === "object" && error !== null && "code" in error |
| 144 | 233 | ? String((error as { code: unknown }).code) |
| 145 | 234 | : ""; |
| 146 | if (code === "ENODATA" || code === "ENOTFOUND") return { kind: "none" }; | |
| 147 | 235 | return { |
| 148 | 236 | kind: "failed", |
| 149 | 237 | why: code === "ETIMEOUT" ? "timed out" : `failed (${code || "error"})`, |
test/app-domains.test.ts +53
| @@ -120,6 +120,59 @@ describe("checkDomain: every outcome is a sentence", () => { | ||
| 120 | 120 | }); |
| 121 | 121 | }); |
| 122 | 122 | |
| 123 | test("when the box's resolver holds a stale not-found, the zone's own nameservers are asked", async () => { | |
| 124 | const { withAuthority } = await import("../src/apps/domains.ts"); | |
| 125 | const asked: string[] = []; | |
| 126 | const stale = { | |
| 127 | resolveTxt: () => | |
| 128 | Promise.reject(Object.assign(new Error("nx"), { code: "ENOTFOUND" })), | |
| 129 | resolveCname: () => | |
| 130 | Promise.reject(Object.assign(new Error("nx"), { code: "ENODATA" })), | |
| 131 | }; | |
| 132 | const authority = { | |
| 133 | resolveNs: (zone: string) => { | |
| 134 | asked.push(`ns ${zone}`); | |
| 135 | return zone === "example.com" | |
| 136 | ? Promise.resolve(["ns1.example.net"]) | |
| 137 | : Promise.reject(Object.assign(new Error("nx"), { code: "ENODATA" })); | |
| 138 | }, | |
| 139 | resolve4: (host: string) => { | |
| 140 | asked.push(`a ${host}`); | |
| 141 | return Promise.resolve(["192.0.2.53"]); | |
| 142 | }, | |
| 143 | resolveTxtAt: (servers: string[], name: string) => { | |
| 144 | asked.push(`txt@${servers.join(",")} ${name}`); | |
| 145 | return Promise.resolve([[`forge-verify=${token}`]]); | |
| 146 | }, | |
| 147 | }; | |
| 148 | const result = await checkDomain( | |
| 149 | "app.example.com", | |
| 150 | token, | |
| 151 | "notes.apps.test", | |
| 152 | withAuthority(stale, authority), | |
| 153 | ); | |
| 154 | expect(result.verified).toBe(true); | |
| 155 | // Walked up past the record's own label and the host, to the zone. | |
| 156 | expect(asked).toEqual([ | |
| 157 | "ns app.example.com", | |
| 158 | "ns example.com", | |
| 159 | "a ns1.example.net", | |
| 160 | "[email protected] _forge-verify.app.example.com", | |
| 161 | ]); | |
| 162 | // And a resolver that says something other than "none" is believed. | |
| 163 | const broken = withAuthority( | |
| 164 | { | |
| 165 | resolveTxt: () => | |
| 166 | Promise.reject(Object.assign(new Error("x"), { code: "ESERVFAIL" })), | |
| 167 | resolveCname: stale.resolveCname, | |
| 168 | }, | |
| 169 | authority, | |
| 170 | ); | |
| 171 | const failed = await checkDomain("app.example.com", token, "notes.apps.test", broken); | |
| 172 | expect(failed.verified).toBe(false); | |
| 173 | expect(failed.said).toContain("failed"); | |
| 174 | }); | |
| 175 | ||
| 123 | 176 | test("an apex gets an ALIAS line instead of a CNAME, and is told why", () => { |
| 124 | 177 | expect(recordsFor(HOST, token, "notes.apps.test")).toEqual([ |
| 125 | 178 | { type: "TXT", name: PROOF, value: `forge-verify=${token}` }, |
new above
offline