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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

domains-authoritative → main 1 commit

  • Merged. domains-authoritative was merged into main as 1470161.

Review

Sign in to review this change.
2 files changed +145 −4
src/apps/domains.ts +92 −4
@@ -9,7 +9,13 @@
99 * that will not answer — is a sentence a person can act on, never a throw.
1010 */
1111
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";
1319
1420 export interface DnsRecord {
1521 readonly type: "TXT" | "CNAME" | "ALIAS";
@@ -52,10 +58,92 @@ export interface DomainResolver {
5258 resolveCname(name: string): Promise<string[]>;
5359 }
5460
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 );
56144
57145 /** 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;
59147
60148 export interface DomainCheck {
61149 readonly verified: boolean;
@@ -139,11 +227,11 @@ async function lookup<T>(run: () => Promise<T>): Promise<Lookup<T>> {
139227 try {
140228 return { kind: "found", value: await Promise.race([run(), timeout]) };
141229 } catch (error) {
230 if (isNone(error)) return { kind: "none" };
142231 const code =
143232 typeof error === "object" && error !== null && "code" in error
144233 ? String((error as { code: unknown }).code)
145234 : "";
146 if (code === "ENODATA" || code === "ENOTFOUND") return { kind: "none" };
147235 return {
148236 kind: "failed",
149237 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", () => {
120120 });
121121 });
122122
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
123176 test("an apex gets an ALIAS line instead of a CNAME, and is told why", () => {
124177 expect(recordsFor(HOST, token, "notes.apps.test")).toEqual([
125178 { type: "TXT", name: PROOF, value: `forge-verify=${token}` },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs