your personal website on atproto - mirror
blento.app
1// Authoritative DNS verification
2// Queries authoritative nameservers directly instead of relying on cached resolvers.
3// See: https://jacob.gold/posts/stop-telling-users-their-dns-is-wrong/
4
5const TYPE_A = 1;
6const TYPE_NS = 2;
7const TYPE_CNAME = 5;
8
9interface DnsRecord {
10 type: number;
11 data: string;
12}
13
14// --- Wire format encoding ---
15
16function encodeName(name: string): Uint8Array {
17 const n = name.endsWith('.') ? name.slice(0, -1) : name;
18 const parts = n.split('.');
19 const bytes: number[] = [];
20 for (const part of parts) {
21 bytes.push(part.length);
22 for (let i = 0; i < part.length; i++) {
23 bytes.push(part.charCodeAt(i));
24 }
25 }
26 bytes.push(0);
27 return new Uint8Array(bytes);
28}
29
30function buildQuery(name: string, type: number): Uint8Array {
31 const qname = encodeName(name);
32 const msg = new Uint8Array(12 + qname.length + 4);
33 const id = (Math.random() * 0xffff) | 0;
34 msg[0] = id >> 8;
35 msg[1] = id & 0xff;
36 msg[2] = 0x01; // RD=1
37 msg[5] = 0x01; // QDCOUNT=1
38 msg.set(qname, 12);
39 const off = 12 + qname.length;
40 msg[off] = type >> 8;
41 msg[off + 1] = type & 0xff;
42 msg[off + 3] = 0x01; // CLASS IN
43 return msg;
44}
45
46// --- Wire format decoding ---
47
48function decodeName(data: Uint8Array, offset: number): [string, number] {
49 const labels: string[] = [];
50 let pos = offset;
51 let jumped = false;
52 let savedPos = 0;
53 for (let safety = 0; safety < 128 && pos < data.length; safety++) {
54 const len = data[pos];
55 if (len === 0) {
56 pos++;
57 break;
58 }
59 if ((len & 0xc0) === 0xc0) {
60 if (!jumped) savedPos = pos + 2;
61 pos = ((len & 0x3f) << 8) | data[pos + 1];
62 jumped = true;
63 continue;
64 }
65 pos++;
66 let label = '';
67 for (let i = 0; i < len && pos + i < data.length; i++) {
68 label += String.fromCharCode(data[pos + i]);
69 }
70 labels.push(label);
71 pos += len;
72 }
73 return [labels.join('.'), jumped ? savedPos : pos];
74}
75
76function parseResponse(data: Uint8Array): DnsRecord[] {
77 if (data.length < 12) return [];
78 const qdcount = (data[4] << 8) | data[5];
79 const ancount = (data[6] << 8) | data[7];
80 let offset = 12;
81
82 // Skip questions
83 for (let i = 0; i < qdcount && offset < data.length; i++) {
84 const [, off] = decodeName(data, offset);
85 offset = off + 4;
86 }
87
88 const records: DnsRecord[] = [];
89 for (let i = 0; i < ancount && offset + 10 < data.length; i++) {
90 const [, nameEnd] = decodeName(data, offset);
91 offset = nameEnd;
92 const type = (data[offset] << 8) | data[offset + 1];
93 const rdlength = (data[offset + 8] << 8) | data[offset + 9];
94 offset += 10;
95 if (offset + rdlength > data.length) break;
96
97 let value = '';
98 if (type === TYPE_A && rdlength === 4) {
99 value = `${data[offset]}.${data[offset + 1]}.${data[offset + 2]}.${data[offset + 3]}`;
100 } else if (type === TYPE_CNAME || type === TYPE_NS) {
101 [value] = decodeName(data, offset);
102 }
103 if (value) records.push({ type, data: value });
104 offset += rdlength;
105 }
106 return records;
107}
108
109// --- DNS-over-HTTPS (for finding NS records and as fallback) ---
110
111interface DohAnswer {
112 type: number;
113 data: string;
114}
115
116async function dohQuery(name: string, type: string): Promise<DohAnswer[]> {
117 const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=${type}`;
118 const res = await fetch(url, { headers: { Accept: 'application/dns-json' } });
119 const json: { Answer?: DohAnswer[] } = await res.json();
120 return json.Answer ?? [];
121}
122
123// --- Find authoritative nameservers (walking up the domain tree) ---
124
125async function findNameservers(domain: string): Promise<string[]> {
126 let current = domain;
127 while (current.includes('.')) {
128 const answers = await dohQuery(current, 'NS');
129 const ns = answers.filter((a) => a.type === TYPE_NS).map((a) => a.data.replace(/\.$/, ''));
130 if (ns.length > 0) return ns;
131 current = current.substring(current.indexOf('.') + 1);
132 }
133 return [];
134}
135
136// --- Resolve hostname to IPs via DoH ---
137
138async function resolveToIps(hostname: string): Promise<string[]> {
139 const answers = await dohQuery(hostname, 'A');
140 return answers.filter((a) => a.type === TYPE_A).map((a) => a.data);
141}
142
143// --- Query nameserver directly via TCP (cloudflare:sockets) ---
144
145async function queryViaTcp(serverIp: string, name: string, type: number): Promise<DnsRecord[]> {
146 // @ts-expect-error: cloudflare:sockets is only available in Workers runtime
147 const { connect } = await import('cloudflare:sockets');
148
149 const query = buildQuery(name, type);
150 const tcpMsg = new Uint8Array(query.length + 2);
151 tcpMsg[0] = (query.length >> 8) & 0xff;
152 tcpMsg[1] = query.length & 0xff;
153 tcpMsg.set(query, 2);
154
155 const socket = connect({ hostname: serverIp, port: 53 });
156 const writer = socket.writable.getWriter();
157 await writer.write(tcpMsg);
158 writer.releaseLock();
159
160 const reader = socket.readable.getReader();
161 let buffer = new Uint8Array(0);
162 let responseLen = -1;
163
164 const timeout = setTimeout(() => socket.close(), 5000);
165 try {
166 while (true) {
167 const { value, done } = await reader.read();
168 if (done) break;
169 const chunk = new Uint8Array(value as ArrayBuffer);
170 const newBuf = new Uint8Array(buffer.length + chunk.length);
171 newBuf.set(buffer);
172 newBuf.set(chunk, buffer.length);
173 buffer = newBuf;
174
175 if (responseLen === -1 && buffer.length >= 2) {
176 responseLen = (buffer[0] << 8) | buffer[1];
177 }
178 if (responseLen >= 0 && buffer.length >= responseLen + 2) break;
179 }
180 } finally {
181 clearTimeout(timeout);
182 reader.releaseLock();
183 socket.close();
184 }
185
186 if (buffer.length < 2) return [];
187 return parseResponse(buffer.slice(2, 2 + responseLen));
188}
189
190// --- Query with TCP, fall back to DoH for local dev ---
191
192async function queryAuthoritative(
193 serverIp: string,
194 name: string,
195 type: number
196): Promise<DnsRecord[]> {
197 try {
198 return await queryViaTcp(serverIp, name, type);
199 } catch {
200 // cloudflare:sockets not available (local dev) — fall back to DoH
201 const typeStr = type === TYPE_A ? 'A' : type === TYPE_CNAME ? 'CNAME' : 'NS';
202 return (await dohQuery(name, typeStr)).map((a) => ({ type: a.type, data: a.data }));
203 }
204}
205
206// --- Main verification ---
207
208export interface DnsVerifyResult {
209 ok: boolean;
210 error?: string;
211 hint?: string;
212}
213
214function normalize(s: string): string {
215 return s.replace(/\.$/, '').toLowerCase();
216}
217
218export async function verifyDomainDns(
219 domain: string,
220 expectedTarget: string
221): Promise<DnsVerifyResult> {
222 // Find authoritative nameservers
223 const nameservers = await findNameservers(domain);
224 if (nameservers.length === 0) {
225 return { ok: false, error: `Could not find nameservers for "${domain}".` };
226 }
227
228 // Try each nameserver until one responds
229 for (const ns of nameservers) {
230 const ips = await resolveToIps(ns);
231 if (ips.length === 0) continue;
232
233 const nsIp = ips[0];
234
235 // Check CNAME records
236 const cnameRecords = await queryAuthoritative(nsIp, domain, TYPE_CNAME);
237 const cnames = cnameRecords.filter((r) => r.type === TYPE_CNAME);
238
239 if (cnames.length > 0) {
240 if (cnames.length > 1) {
241 return {
242 ok: false,
243 error: `Multiple CNAME records found for "${domain}": ${cnames.map((r) => r.data).join(', ')}. Remove duplicate records so only one remains.`
244 };
245 }
246 const got = normalize(cnames[0].data);
247 const want = normalize(expectedTarget);
248 if (got !== want) {
249 return {
250 ok: false,
251 error: `CNAME for "${domain}" points to "${got}" instead of "${want}".`,
252 hint: `If you're using Cloudflare, make sure the proxy (orange cloud) is turned off for this record.`
253 };
254 }
255 return { ok: true };
256 }
257
258 // No CNAME — check A records (root/apex domains with CNAME flattening or A records)
259 const aRecords = await queryAuthoritative(nsIp, domain, TYPE_A);
260 const aValues = aRecords.filter((r) => r.type === TYPE_A);
261
262 if (aValues.length > 0) {
263 // Resolve the expected target to get its IPs for comparison
264 const expectedIps = await resolveToIps(expectedTarget);
265 if (expectedIps.length === 0) {
266 return {
267 ok: false,
268 error: `Could not resolve "${expectedTarget}" to verify A records.`
269 };
270 }
271 const expectedSet = new Set(expectedIps);
272 const unexpected = aValues.filter((r) => !expectedSet.has(r.data));
273 if (unexpected.length > 0) {
274 return {
275 ok: false,
276 error: `A record(s) for "${domain}" include unexpected IPs: ${unexpected.map((r) => r.data).join(', ')}. Expected only IPs matching "${expectedTarget}" (${expectedIps.join(', ')}).`,
277 hint: `If you're using Cloudflare, make sure the proxy (orange cloud) is turned off for this record.`
278 };
279 }
280 return { ok: true };
281 }
282
283 // Neither CNAME nor A found
284 return {
285 ok: false,
286 error: `No CNAME or A record found for "${domain}". Please add a CNAME record pointing to "${expectedTarget}".`
287 };
288 }
289
290 return {
291 ok: false,
292 error: `Could not reach any nameserver for "${domain}".`
293 };
294}