Skip to content

Instantly share code, notes, and snippets.

@c0nrad
Created March 12, 2026 18:25
Show Gist options
  • Select an option

  • Save c0nrad/8497352c18d27b93c67a530663335a55 to your computer and use it in GitHub Desktop.

Select an option

Save c0nrad/8497352c18d27b93c67a530663335a55 to your computer and use it in GitHub Desktop.
import { createSocket } from "node:dgram";
import { Socket } from "node:net";
const REMOTE_DNS_SERVER = "8.8.8.8";
const REMOTE_DNS_PORT = 53;
export async function sendDnsQuery(
message: Buffer,
server: string = REMOTE_DNS_SERVER,
port: number = REMOTE_DNS_PORT,
): Promise<Buffer> {
const client = createSocket("udp4");
return await new Promise<Buffer>((resolve, reject) => {
const cleanup = () => {
clearTimeout(timeout);
client.removeAllListeners("message");
client.removeAllListeners("error");
client.close();
};
const timeout = setTimeout(() => {
cleanup();
reject(new Error("timed out waiting for DNS response"));
}, 2_000);
client.once("message", (response) => {
cleanup();
resolve(response);
});
client.once("error", (err) => {
cleanup();
reject(err);
});
client.send(message, port, server, (err) => {
if (err) {
cleanup();
reject(err);
}
});
});
}
// DNS over TCP uses a 2-byte length prefix before the message payload.
export async function sendDnsQueryOverTcp(
message: Buffer,
server: string = REMOTE_DNS_SERVER,
port: number = REMOTE_DNS_PORT,
): Promise<Buffer> {
const client = new Socket();
const tcpMessage = Buffer.alloc(message.length + 2);
tcpMessage.writeUInt16BE(message.length, 0);
message.copy(tcpMessage, 2);
return await new Promise<Buffer>((resolve, reject) => {
let expectedLength: number | null = null;
let responseBuffer = Buffer.alloc(0);
let settled = false;
const cleanup = () => {
clearTimeout(timeout);
client.removeAllListeners("connect");
client.removeAllListeners("data");
client.removeAllListeners("error");
client.removeAllListeners("close");
client.destroy();
};
const resolveOnce = (response: Buffer) => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve(response);
};
const rejectOnce = (err: Error) => {
if (settled) {
return;
}
settled = true;
cleanup();
reject(err);
};
const timeout = setTimeout(() => {
rejectOnce(new Error("timed out waiting for DNS TCP response"));
}, 2_000);
client.once("connect", () => {
client.write(tcpMessage);
});
client.on("data", (chunk) => {
responseBuffer = Buffer.concat([responseBuffer, chunk]);
if (expectedLength === null) {
if (responseBuffer.length < 2) {
return;
}
expectedLength = responseBuffer.readUInt16BE(0);
}
if (responseBuffer.length >= expectedLength + 2) {
resolveOnce(responseBuffer.subarray(2, expectedLength + 2));
}
});
client.once("error", (err) => {
rejectOnce(err);
});
client.once("close", () => {
if (
expectedLength === null ||
responseBuffer.length < expectedLength + 2
) {
rejectOnce(
new Error(
"connection closed before full DNS TCP response was received",
),
);
}
});
client.connect(port, server);
});
}
// /usr/share/dns/root.hints
// D.ROOT-SERVERS.NET. 3600000 A 199.7.91.13
const ROOT_SERVER_IP = "199.7.91.13";
import { sendDnsQuery, sendDnsQueryOverTcp } from "./network.js";
import {
generateHeader,
generateOptRecord,
generateQuestion,
Header,
parseResponse,
QuestionType,
} from "./resolver.ts";
async function main() {
// Get the NS of the .io TLD nameservers by querying a root server.
const headerBytes = generateHeader(Header);
const questionBytes = generateQuestion(QuestionType.NS, "io.");
const optRecord = generateOptRecord();
const message = Buffer.concat([headerBytes, questionBytes, optRecord]);
const response = await sendDnsQuery(message, ROOT_SERVER_IP, 53);
const records = parseResponse(response);
console.dir(records, { depth: null });
const ioNsRecord = records.additionals.find(
(record) => record.type === QuestionType.A,
);
const ioNsIP = ioNsRecord?.A.ipAddress;
if (!ioNsIP) {
throw new Error("Failed to find A record for .io nameserver");
}
// Get the NS address of c0nrad.io by querying one of the .io nameservers.
const headerBytes2 = generateHeader({ ...Header, id: Header.id + 1 });
const questionBytes2 = generateQuestion(QuestionType.NS, "c0nrad.io.");
const optRecord2 = generateOptRecord();
const message2 = Buffer.concat([headerBytes2, questionBytes2, optRecord2]);
const response2 = await sendDnsQuery(message2, ioNsIP, 53);
const records2 = parseResponse(response2);
console.dir(records2, { depth: null });
// Get the A record for c0nrad.io by querying one of its nameservers.
const c0nradNsRecord = records2.authorities.find(
(record) => record.type === QuestionType.NS,
);
const c0nradNs = "205.251.195.199"; // c0nradNsRecord?.NS.dName;
if (!c0nradNs) {
throw new Error("Failed to find NS record for c0nrad.io");
}
// ns-967.awsdns-56.net 205.251.195.199
const headerBytes3 = generateHeader({ ...Header, id: Header.id + 2 });
const questionBytes3 = generateQuestion(QuestionType.A, "c0nrad.io");
const optRecord3 = generateOptRecord();
const message3 = Buffer.concat([headerBytes3, questionBytes3, optRecord3]);
const response3 = await sendDnsQuery(message3, c0nradNs, 53);
const records3 = parseResponse(response3);
console.dir(records3, { depth: null });
const c0nradARecord = records3.answers.find(
(record) => record.type === QuestionType.A,
);
const c0nradIP = c0nradARecord?.A.ipAddress;
if (!c0nradIP) {
throw new Error("Failed to find A record for c0nrad.io");
}
console.log(`c0nrad.io IP address: ${c0nradIP}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
import { Buffer } from "node:buffer";
// Create the message
interface DnsHeader {
id: number; // 16
qr: number; // 1
opcode: number; // 4
aa: number; // 1
tc: number; // 1
rd: number; // 1
ra: number; // 1
z: number; // 3
rcode: number; // 4
qdcount: number; // 16
ancount: number; // 16
nscount: number; // 16
arcount: number; // 16
}
export const Header: DnsHeader = {
id: 1337,
qr: 0, // query
opcode: 0, // standard query
rd: 1, // recursion desired
qdcount: 1, // question count
// unused
aa: 0,
tc: 0,
ra: 0,
z: 0,
rcode: 0,
ancount: 0,
nscount: 0,
arcount: 1,
};
export function encodeDnsName(domain: string): Buffer {
const labels = domain.split(".").filter((label) => label.length > 0);
const parts = labels.map((label) => {
const labelBytes = Buffer.from(label, "ascii");
return Buffer.concat([Buffer.from([labelBytes.length]), labelBytes]);
});
return Buffer.concat([...parts, Buffer.from([0])]);
}
// 1 1 1 1 1 1
// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
// | ID |
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
// |QR| Opcode |AA|TC|RD|RA| Z | RCODE |
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
// | QDCOUNT |
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
// | ANCOUNT |
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
// | NSCOUNT |
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
// | ARCOUNT |
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
export function generateHeader(header: DnsHeader): Buffer {
// https://datatracker.ietf.org/doc/html/rfc1035#section-4.1.1
const headerBytes = Buffer.alloc(12);
const flags =
(header.qr << 15) |
(header.opcode << 11) |
(header.aa << 10) |
(header.tc << 9) |
(header.rd << 8) |
(header.ra << 7) |
(header.z << 4) |
header.rcode;
headerBytes.writeUInt16BE(header.id, 0);
headerBytes.writeUInt16BE(flags, 2);
headerBytes.writeUInt16BE(header.qdcount, 4);
headerBytes.writeUInt16BE(header.ancount, 6);
headerBytes.writeUInt16BE(header.nscount, 8);
headerBytes.writeUInt16BE(header.arcount, 10);
return headerBytes;
}
export const QuestionType = {
A: 1,
NS: 2,
CNAME: 5,
SOA: 6,
PTR: 12,
MX: 15,
TXT: 16,
AAAA: 28,
};
export function generateQuestion(qtype: number, domain: string): Buffer {
// https://datatracker.ietf.org/doc/html/rfc1035#section-4.1.2
// question
// 0 1 2 3 4 5 6 7
// 6 c 0 n r a d 2
// i o 0
const qname = encodeDnsName(domain);
const questionBytes = Buffer.alloc(qname.length + 4);
qname.copy(questionBytes, 0);
questionBytes.writeUInt16BE(qtype, qname.length); // qtype
questionBytes.writeUInt16BE(1, qname.length + 2); // IN qclass
return questionBytes;
}
export function generateOptRecord(): Buffer {
const optRecord = Buffer.alloc(11);
// NAME: 0 (root domain)
optRecord.writeUInt8(0, 0);
// TYPE: OPT (41)
optRecord.writeUInt16BE(41, 1);
// CLASS: requestor's UDP payload size (1232)
optRecord.writeUInt16BE(1232, 3);
// TTL: extended RCODE and flags (0)
optRecord.writeUInt32BE(0, 5);
// RDLEN: 0 (no data)
optRecord.writeUInt16BE(0, 9);
return optRecord;
}
// const headerBytes = generateHeader(header);
// const questionBytes = generateQuestion("c0nrad.io");
// const message = Buffer.concat([headerBytes, questionBytes]);
// Parse response
// https://datatracker.ietf.org/doc/html/rfc1035#section-4.1.3
// 1 1 1 1 1 1
// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
// | |
// / /
// / NAME /
// | |
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
// | TYPE |
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
// | CLASS |
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
// | TTL |
// | |
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
// | RDLENGTH |
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--|
// / RDATA /
// / /
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
// +---------------------+
// | Header |
// +---------------------+
// | Question | the question for the name server
// +---------------------+
// | Answer | RRs answering the question
// +---------------------+
// | Authority | RRs pointing toward an authority
// +---------------------+
// | Additional | RRs holding additional information
// +---------------------+
export interface ResourceRecord {
name: string;
type: number;
class_: number;
ttl: number;
rdata: Buffer;
// A record specific
A: {
ipAddress: string;
};
NS: {
dName: string;
};
SOA: {
mName: string;
};
}
export function parserResourceRecord(
buffer: Buffer,
offset: number,
): { record: ResourceRecord; bytesRead: number } {
const { name, bytesRead: nameBytes } = decodeDnsName(buffer, offset);
const type = buffer.readUInt16BE(offset + nameBytes);
const class_ = buffer.readUInt16BE(offset + nameBytes + 2);
const ttl = buffer.readUInt32BE(offset + nameBytes + 4);
const rdlength = buffer.readUInt16BE(offset + nameBytes + 8);
const rdataOffset = offset + nameBytes + 10;
const rdata = buffer.slice(rdataOffset, rdataOffset + rdlength);
console.log({ name, type, class_, ttl, rdlength });
if (class_ !== 1 && type !== 41) {
throw new Error(`Unsupported record class: ${class_}`);
}
const record: ResourceRecord = {
name,
type,
class_,
ttl,
rdata,
A: {
ipAddress: "",
},
SOA: {
mName: "",
},
NS: {
dName: "",
},
};
switch (type) {
case 1: {
// A record
const ipAddress = parseIPAddress(rdata);
record.A.ipAddress = ipAddress;
break;
}
case 2: {
// NS record
// The RDATA field contains a <domain-name> which specifies a host which should be authoritative for the specified class and domain.
const { name: nsdname } = decodeDnsName(buffer, rdataOffset);
record.NS.dName = nsdname;
break;
}
case 6: {
// SOA record
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
// / MNAME /
// / /
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
// / RNAME /
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
// | SERIAL |
// | |
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
// | REFRESH |
// | |
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
// | RETRY |
// | |
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
// | EXPIRE |
// | |
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
// | MINIMUM |
// | |
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
const { name: mName } = decodeDnsName(buffer, rdataOffset);
record.SOA.mName = mName;
break;
}
case 28: {
// AAAA record
// The RDATA field of an AAAA RR is 16 octets, which is the length of an IPv6 address.
// https://datatracker.ietf.org/doc/html/rfc3596#section-2.1
break;
}
case 41: {
// OPT record (RFC 6891)
// The RDATA field of an OPT RR is empty (i.e., RDLEN is zero) in a request, and contains the variable-length data in a response.
// https://datatracker.ietf.org/doc/html/rfc6891#section-6.3.2
break;
}
default:
throw new Error(`Unsupported record type: ${type}`);
}
return {
record,
bytesRead: nameBytes + 10 + rdlength,
};
}
export function parseResponse(response: Buffer): {
answers: ResourceRecord[];
authorities: ResourceRecord[];
additionals: ResourceRecord[];
} {
const answers: ResourceRecord[] = [];
const authorities: ResourceRecord[] = [];
const additionals: ResourceRecord[] = [];
// header
const id = response.readUInt16BE(0);
const flags = response.readUInt16BE(2);
const qdcount = response.readUInt16BE(4);
const ancount = response.readUInt16BE(6);
const nscount = response.readUInt16BE(8);
const arcount = response.readUInt16BE(10);
// parse flags
// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
// |QR| Opcode |AA|TC|RD|RA| Z | RCODE
const tc = (flags >> 9) & 0x1;
const rd = (flags >> 8) & 0x1;
const ra = (flags >> 7) & 0x1;
const z = (flags >> 4) & 0x7;
const rcode = flags & 0xf;
console.log({
id,
flags,
qdcount,
ancount,
nscount,
arcount,
tc,
rd,
ra,
z,
rcode,
});
// question
let offset = 12; // start of question section
for (let i = 0; i < qdcount; i++) {
const { name, bytesRead } = decodeDnsName(response, offset);
const qtype = response.readUInt16BE(offset + bytesRead);
const qclass = response.readUInt16BE(offset + bytesRead + 2);
console.log({ name, qtype, qclass });
offset += bytesRead + 4;
}
// answers
for (let i = 0; i < ancount; i++) {
const { record, bytesRead } = parserResourceRecord(response, offset);
answers.push(record);
offset += bytesRead;
}
for (let i = 0; i < nscount; i++) {
const { record, bytesRead } = parserResourceRecord(response, offset);
authorities.push(record);
offset += bytesRead;
}
for (let i = 0; i < arcount; i++) {
const { record, bytesRead } = parserResourceRecord(response, offset);
additionals.push(record);
offset += bytesRead;
}
return { answers, authorities, additionals };
}
export function parseIPAddress(rdata: Buffer): string {
if (rdata.length !== 4) {
throw new Error(`Invalid A record RDATA length: ${rdata.length}`);
}
return Array.from(rdata).join(".");
}
export function decodeDnsName(
buffer: Buffer,
offset: number,
): { name: string; bytesRead: number } {
const labels: string[] = [];
let bytesRead = 0;
while (true) {
const length = buffer.readUInt8(offset + bytesRead);
if (length === 0) {
bytesRead += 1; // null terminator
break;
}
if ((length & 0xc0) === 0xc0) {
// Pointer Message Compression
// https://datatracker.ietf.org/doc/html/rfc1035#section-4.1.4
const pointer = buffer.readUInt16BE(offset + bytesRead) & 0x3fff;
const pointedName = decodeDnsName(buffer, pointer);
labels.push(pointedName.name);
bytesRead += 2; // pointer is 2 bytes
break;
}
const label = buffer.toString(
"ascii",
offset + bytesRead + 1,
offset + bytesRead + 1 + length,
);
labels.push(label);
bytesRead += length + 1;
}
return { name: labels.join("."), bytesRead };
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment