1/**
2 * TXT propagation checks.
3 *
4 * Public recursive resolvers are a poor oracle: they cache negative
5 * answers for the zone's SOA minimum (30 minutes is common) and old
6 * record sets past their TTL, so a challenge value published a moment ago
7 * can stay invisible for far longer than the CA takes to validate. Where
8 * `node:dns` is available (deploy time, Fly Machines) the check therefore
9 * asks the zone's **authoritative** nameservers directly — the same
10 * servers the CA asks. Networks can intercept port 53, so DNS over HTTPS
11 * also checks for the exact challenge value. A stale recursive cache must
12 * not veto a positive answer; the CA performs its own validation.
13 */
14import * as Duration from "effect/Duration";
15import * as Effect from "effect/Effect";
16import * as Schedule from "effect/Schedule";
17import * as HttpClient from "effect/http/HttpClient";
18import * as HttpClientRequest from "effect/http/HttpClientRequest";
19import { DnsPropagationTimeout } from "./Errors.ts";
20
21export interface PropagationOptions {
22 /**
23 * Wait before the first lookup to avoid caching an unpublished record.
24 * Outside the polling budget. Cloudflare defaults to 60 seconds.
25 * @default "0 seconds"
26 */
27 readonly initialDelay?: Duration.Input | undefined;
28 /**
29 * Extra wait after the record is seen (or, for solvers that skip the
30 * check, the whole wait): anycast edges lag the first one that answers.
31 * @default "5 seconds"
32 */
33 readonly delay?: Duration.Input | undefined;
34 /**
35 * DNS-over-HTTPS JSON endpoints to use instead of automatic discovery.
36 * When supplied, every resolver must return the record. Automatic lookup
37 * accepts all discovered nameservers or any default DoH resolver.
38 * @default Cloudflare (1.1.1.1) and Google public DNS
39 */
40 readonly resolvers?: ReadonlyArray<string> | undefined;
41 /** Polling budget. @default "45 seconds" */
42 readonly timeout?: Duration.Input | undefined;
43 /** Polling interval. @default "3 seconds" */
44 readonly interval?: Duration.Input | undefined;
45}
46
47export const DEFAULT_PROPAGATION_DELAY: Duration.Input = "5 seconds";
48
49/** Sleep `options.delay` (default 5 seconds). */
50export const propagationDelay = (
51 options: PropagationOptions = {},
52): Effect.Effect<void> =>
53 Effect.sleep(options.delay ?? DEFAULT_PROPAGATION_DELAY);
54
55// =============================================================================
56// Authoritative lookup (node:dns where available)
57// =============================================================================
58
59interface NodeDns {
60 readonly resolveNs: (name: string) => Promise<string[]>;
61 readonly resolve4: (name: string) => Promise<string[]>;
62 readonly Resolver: new () => {
63 setServers(servers: string[]): void;
64 resolveTxt(name: string): Promise<string[][]>;
65 };
66}
67
68const nodeDns: Effect.Effect<NodeDns | undefined> = Effect.tryPromise(
69 () => import("node:dns/promises") as unknown as Promise<NodeDns>,
70).pipe(
71 Effect.map((mod) =>
72 typeof mod?.Resolver === "function" && typeof mod.resolveNs === "function"
73 ? mod
74 : undefined,
75 ),
76 Effect.orElseSucceed(() => undefined),
77);
78
79/** The zone apex's nameserver IPv4s for `fqdn`, or `[]` when unknown. */
80const authoritativeServers = (
81 dns: NodeDns,
82 fqdn: string,
83): Effect.Effect<ReadonlyArray<string>> =>
84 Effect.gen(function* () {
85 const labels = fqdn.replace(/\.$/, "").split(".");
86 for (let i = 0; i < labels.length - 1; i++) {
87 const candidate = labels.slice(i).join(".");
88 const names = yield* Effect.tryPromise(() =>
89 dns.resolveNs(candidate),
90 ).pipe(Effect.orElseSucceed(() => [] as string[]));
91 if (names.length === 0) continue;
92 const addresses = yield* Effect.forEach(
93 names,
94 (name) =>
95 Effect.tryPromise(() => dns.resolve4(name)).pipe(
96 Effect.orElseSucceed(() => [] as string[]),
97 ),
98 { concurrency: "unbounded" },
99 );
100 return addresses.flat();
101 }
102 return [];
103 });
104
105/** TXT values requested from one nameserver (port 53 may be intercepted). */
106const resolveTxtAt = (
107 dns: NodeDns,
108 server: string,
109 fqdn: string,
110): Effect.Effect<ReadonlyArray<string>> =>
111 Effect.gen(function* () {
112 const resolver = yield* Effect.sync(() => new dns.Resolver());
113 yield* Effect.sync(() => resolver.setServers([server]));
114 const records = yield* Effect.tryPromise(() => resolver.resolveTxt(fqdn));
115 return records.map((chunks) => chunks.join(""));
116 }).pipe(
117 Effect.timeout("3 seconds"),
118 Effect.orElseSucceed(() => [] as ReadonlyArray<string>),
119 );
120
121export const DEFAULT_RESOLVERS: ReadonlyArray<string> = [
122 "https://cloudflare-dns.com/dns-query",
123 "https://dns.google/resolve",
124];
125
126interface DohAnswer {
127 readonly type?: number;
128 readonly data?: string;
129}
130
131const unquote = (data: string): string =>
132 (data.match(/"((?:[^"\\]|\\.)*)"/g) ?? [data])
133 .map((part) => part.replace(/^"|"$/g, "").replace(/\\"/g, '"'))
134 .join("");
135
136/**
137 * TXT values for `fqdn` from one DoH resolver. Any transport or parse
138 * failure reads as "no records yet".
139 */
140export const resolveTxt = (
141 resolver: string,
142 fqdn: string,
143): Effect.Effect<ReadonlyArray<string>, never, HttpClient.HttpClient> =>
144 Effect.gen(function* () {
145 const client = yield* HttpClient.HttpClient;
146 const request = HttpClientRequest.get(resolver).pipe(
147 HttpClientRequest.setUrlParams({ name: fqdn, type: "TXT" }),
148 HttpClientRequest.setHeader("Accept", "application/dns-json"),
149 );
150 const response = yield* client.execute(request);
151 const body = (yield* response.json) as { Answer?: DohAnswer[] };
152 return (body.Answer ?? [])
153 .filter((answer) => answer.type === 16 && typeof answer.data === "string")
154 .map((answer) => unquote(answer.data!));
155 }).pipe(
156 Effect.timeout("3 seconds"),
157 Effect.orElseSucceed(() => [] as ReadonlyArray<string>),
158 );
159
160/**
161 * Wait `initialDelay`, then poll for the exact challenge value and wait
162 * `delay`. Explicit resolvers must all agree; automatic lookup accepts all
163 * discovered nameservers or a positive default DoH answer. Discovery and
164 * polling share `timeout`; the delays before and after are outside it.
165 */
166export const waitForTxt = (
167 fqdn: string,
168 value: string,
169 options: PropagationOptions = {},
170): Effect.Effect<void, DnsPropagationTimeout, HttpClient.HttpClient> =>
171 Effect.gen(function* () {
172 const interval = options.interval ?? "3 seconds";
173 const timeout = options.timeout ?? "45 seconds";
174 // Bounded by `timeout` below, so poll for the caller's whole budget.
175 const attempts = Math.max(
176 1,
177 Math.ceil(Duration.toMillis(timeout) / Duration.toMillis(interval)),
178 );
179 yield* Effect.sleep(options.initialDelay ?? "0 seconds");
180 const propagated = yield* Effect.gen(function* () {
181 // Explicit resolvers must work even when node:dns is available.
182 const dns = options.resolvers === undefined ? yield* nodeDns : undefined;
183 const servers =
184 dns === undefined
185 ? []
186 : yield* authoritativeServers(dns, fqdn).pipe(
187 Effect.timeout("5 seconds"),
188 Effect.orElseSucceed(() => [] as ReadonlyArray<string>),
189 );
190 const check = Effect.all(
191 {
192 direct: Effect.all(
193 dns === undefined
194 ? []
195 : servers.map((server) =>
196 resolveTxtAt(dns, server, fqdn).pipe(
197 Effect.map((values) => values.includes(value)),
198 ),
199 ),
200 { concurrency: "unbounded" },
201 ),
202 doh: Effect.all(
203 (options.resolvers ?? DEFAULT_RESOLVERS).map((resolver) =>
204 resolveTxt(resolver, fqdn).pipe(
205 Effect.map((values) => values.includes(value)),
206 ),
207 ),
208 { concurrency: "unbounded" },
209 ),
210 },
211 { concurrency: "unbounded" },
212 ).pipe(
213 Effect.map(({ direct, doh }) =>
214 options.resolvers !== undefined
215 ? doh.length > 0 && doh.every(Boolean)
216 : (direct.length > 0 && direct.every(Boolean)) || doh.some(Boolean),
217 ),
218 );
219 return yield* check.pipe(
220 Effect.repeat({
221 schedule: Schedule.spaced(interval),
222 until: (seen) => seen,
223 times: attempts,
224 }),
225 );
226 }).pipe(
227 Effect.timeoutOrElse({
228 duration: timeout,
229 orElse: () => Effect.fail(new DnsPropagationTimeout({ fqdn, value })),
230 }),
231 );
232 if (!propagated) {
233 return yield* new DnsPropagationTimeout({ fqdn, value });
234 }
235 yield* propagationDelay(options);
236 });