Skip to content
File

Blob: src/node/internal/internal_dns_client.ts

typescript272 lines
1// Copyright (c) 2026 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4 
5import { default as dnsUtil } from 'node-internal:dns';
6import * as errorCodes from 'node-internal:internal_dns_constants';
7import { DnsError } from 'node-internal:internal_errors';
8import { validateString } from 'node-internal:validators';
9 
10export type TTLResponse = {
11 ttl: number;
12 address: string;
13};
14export interface Answer {
15 // The record owner.
16 name: string;
17 // The type of DNS record.
18 // These are defined here: https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-4
19 type: number;
20 // The number of seconds the answer can be stored in cache before it is considered stale.
21 TTL: number;
22 // The value of the DNS record for the given name and type. The data will be in text for standardized record types and in hex for unknown types.
23 data: string;
24}
25export interface FailedResponse {
26 error: string;
27}
28export interface SuccessResponse {
29 // The Response Code of the DNS Query.
30 // These are defined here: https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-6
31 Status: number;
32 // If true, it means the truncated bit was set.
33 // This happens when the DNS answer is larger than a single UDP or TCP packet.
34 // TC will almost always be false with Cloudflare DNS over HTTPS because Cloudflare supports the maximum response size.
35 TC: boolean;
36 // If true, it means the Recursive Desired bit was set.
37 RD: boolean;
38 // If true, it means the Recursion Available bit was set.
39 RA: boolean;
40 // If true, it means that every record in the answer was verified with DNSSEC.
41 AD: boolean;
42 // If true, the client asked to disable DNSSEC validation.
43 CD: boolean;
44 Question: {
45 // The record name requested.
46 name: string;
47 // The type of DNS record requested.
48 // These are defined here: https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-4
49 type: number;
50 }[];
51 Answer?: Answer[];
52 Authority: Answer[];
53 Additional?: Answer;
54}
55 
56export async function sendDnsRequest(
57 name: string,
58 type: string
59): Promise<SuccessResponse> {
60 // We are using cloudflare-dns.com and not 1.1.1.1 because of certificate issues.
61 // TODO(soon): Replace this when KJ certificate issues are resolved.
62 const server = new URL('https://cloudflare-dns.com/dns-query');
63 server.searchParams.set('name', name);
64 server.searchParams.set('type', type);
65 
66 // syscall needs to be in format of `queryTxt`
67 const syscall = `query${type.at(0)?.toUpperCase()}${type.slice(1)}`;
68 
69 let json: SuccessResponse | FailedResponse;
70 try {
71 const response = await fetch(server, {
72 headers: {
73 Accept: 'application/dns-json',
74 },
75 method: 'GET',
76 });
77 if (!response.ok) {
78 throw new DnsError(name, errorCodes.BADRESP, syscall);
79 }
80 // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
81 json = await response.json();
82 } catch (e) {
83 throw e instanceof DnsError
84 ? e
85 : new DnsError(name, errorCodes.BADQUERY, syscall);
86 }
87 
88 if ('error' in json) {
89 throw new DnsError(name, errorCodes.BADRESP, syscall);
90 }
91 
92 if (!json.Question.at(0)) {
93 // Some APIs depend on Question being existent.
94 throw new DnsError(name, errorCodes.BADRESP, syscall);
95 }
96 
97 if (json.Answer?.at(0)?.name === '') {
98 throw new DnsError(name, errorCodes.NOTFOUND, syscall);
99 }
100 
101 return json;
102}
103 
104export function validateAnswer(
105 answer: unknown,
106 name: string,
107 query: string
108): asserts answer is Answer[] {
109 if (answer == null) {
110 throw new DnsError(name, errorCodes.NOTFOUND, query);
111 }
112}
113 
114export type MX = {
115 exchange: string;
116 priority: number;
117};
118export function normalizeMx(name: string, answer: Answer): MX {
119 const [priority, exchange]: string[] = answer.data.split(' ');
120 if (priority == null || exchange == null) {
121 throw new DnsError(name, errorCodes.BADRESP, 'queryMx');
122 }
123 
124 // Cloudflare API returns "data": "10 smtp.google.com." hence
125 // we need to parse it. Let's play it safe.
126 if (exchange.endsWith('.')) {
127 return {
128 exchange: exchange.slice(0, -1),
129 priority: parseInt(priority, 10),
130 };
131 }
132 
133 return {
134 exchange,
135 priority: parseInt(priority, 10),
136 };
137}
138 
139export function normalizeCname({ data }: Answer): string {
140 // Cloudflare DNS returns "nodejs.org." whereas
141 // Node.js returns "nodejs.org" as a CNAME data.
142 if (data.endsWith('.')) {
143 return data.slice(0, -1);
144 }
145 return data;
146}
147 
148export type CAA = {
149 critical: number;
150 issue?: string;
151 iodef?: string;
152 issuewild?: string;
153};
154export function normalizeCaa({ data }: Answer): CAA {
155 // CAA API returns "hex", so we need to convert it to UTF-8
156 const record = dnsUtil.parseCaaRecord(data);
157 const obj: CAA = { critical: record.critical };
158 obj[record.field] = record.value;
159 return obj;
160}
161 
162export type NAPTR = {
163 flags: string;
164 service: string;
165 regexp: string;
166 replacement: string;
167 order: number;
168 preference: number;
169};
170export function normalizeNaptr({ data }: Answer): NAPTR {
171 // Cloudflare DNS appends "." at the end whereas Node.js doesn't.
172 return dnsUtil.parseNaptrRecord(data);
173}
174 
175export function normalizePtr({ data }: Answer): string {
176 if (data.endsWith('.')) {
177 return data.slice(0, -1);
178 }
179 return data;
180}
181 
182export function normalizeNs({ data }: Answer): string {
183 if (data.endsWith('.')) {
184 return data.slice(0, -1);
185 }
186 return data;
187}
188 
189export type SOA = {
190 nsname: string;
191 hostmaster: string;
192 serial: number;
193 refresh: number;
194 retry: number;
195 expire: number;
196 minttl: number;
197};
198export function normalizeSoa({ data }: Answer): SOA {
199 // Cloudflare DNS returns ""meera.ns.cloudflare.com. dns.cloudflare.com. 2357999196 10000 2400 604800 1800""
200 const [nsname, hostmaster, serial, refresh, retry, expire, minttl] =
201 data.split(' ');
202 
203 validateString(nsname, 'nsname');
204 validateString(hostmaster, 'hostmaster');
205 validateString(serial, 'serial');
206 validateString(refresh, 'refresh');
207 validateString(retry, 'retry');
208 validateString(expire, 'expire');
209 validateString(minttl, 'minttl');
210 
211 return {
212 nsname,
213 hostmaster,
214 serial: parseInt(serial, 10),
215 refresh: parseInt(refresh, 10),
216 retry: parseInt(retry, 10),
217 expire: parseInt(expire, 10),
218 minttl: parseInt(minttl, 10),
219 };
220}
221 
222export type SRV = {
223 name: string;
224 port: number;
225 priority: number;
226 weight: number;
227};
228export function normalizeSrv({ data }: Answer): SRV {
229 // Cloudflare DNS returns "5 0 80 calendar.google.com"
230 const [priority, weight, port, name] = data.split(' ');
231 validateString(priority, 'priority');
232 validateString(weight, 'weight');
233 validateString(port, 'port');
234 validateString(name, 'name');
235 return {
236 priority: parseInt(priority, 10),
237 weight: parseInt(weight, 10),
238 port: parseInt(port, 10),
239 name,
240 };
241}
242 
243// This regex works by:
244//
245// `"` - Matches an opening quote
246// ([^"]|"(?!"))* - Matches either:
247// [^"] - Any character that's not a quote
248// "(?!") - A quote that's not followed by another quote
249// `"` - Matches a closing quote
250// /g - Global flag to match all occurrences
251const SPLIT_REGEX = /"([^"]|"(?!"))*"/g;
252 
253export function normalizeTxt({ data }: Answer): string[] {
254 // Each entry has quotation marks as a prefix and suffix.
255 // Node.js APIs doesn't have them.
256 if (data.startsWith('"') && data.endsWith('"')) {
257 // If the input starts and ends with a quotation mark, we need to split
258 // each occurrence and remove the leading/trailing characters.
259 // For example, for the input `"test""test""test with " quote"`
260 // It returns: ['test', 'test', 'test with " quote']
261 return (
262 data.match(SPLIT_REGEX)?.map((s) => {
263 if (s.startsWith('"') && s.endsWith('"')) {
264 return s.slice(1, -1);
265 }
266 return s;
267 }) ?? []
268 );
269 }
270 return [data];
271}