| 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 | */ |
| 14 | import * as Duration from "effect/Duration"; |
| 15 | import * as Effect from "effect/Effect"; |
| 16 | import * as Schedule from "effect/Schedule"; |
| 17 | import * as HttpClient from "effect/http/HttpClient"; |
| 18 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 19 | import { DnsPropagationTimeout } from "./Errors.ts"; |
| 20 | |
| 21 | export 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 | |
| 47 | export const DEFAULT_PROPAGATION_DELAY: Duration.Input = "5 seconds"; |
| 48 | |
| 49 | /** Sleep `options.delay` (default 5 seconds). */ |
| 50 | export 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 | |
| 59 | interface 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 | |
| 68 | const 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. */ |
| 80 | const 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). */ |
| 106 | const 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 | |
| 121 | export const DEFAULT_RESOLVERS: ReadonlyArray<string> = [ |
| 122 | "https://cloudflare-dns.com/dns-query", |
| 123 | "https://dns.google/resolve", |
| 124 | ]; |
| 125 | |
| 126 | interface DohAnswer { |
| 127 | readonly type?: number; |
| 128 | readonly data?: string; |
| 129 | } |
| 130 | |
| 131 | const 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 | */ |
| 140 | export 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 | */ |
| 166 | export 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 | }); |