-
-
Save c0nrad/8497352c18d27b93c67a530663335a55 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | |
| }); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // /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); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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