import { describe, expect, it } from "bun:test"; import type { ResolvedDnsServerExpectConfig, ResolvedDnsSystemExpectConfig, ResolvedDnsTarget, } from "../../../../../src/server/checker/runner/dns/types"; import { DnsChecker } from "../../../../../src/server/checker/runner/dns/execute"; function buildName(name: string): Uint8Array { const parts: number[] = []; for (const label of name.split(".")) { const encoded = new TextEncoder().encode(label); parts.push(encoded.length); parts.push(...encoded); } parts.push(0); return new Uint8Array(parts); } function buildTestResponse(options: { answers?: Array<{ class?: number; name: string; rdata: Uint8Array; ttl: number; type: number }>; flags?: { aa?: boolean; ad?: boolean; ra?: boolean; rd?: boolean; tc?: boolean }; id: number; questions?: Array<{ name: string; qclass?: number; qtype: number }>; rcode?: number; }): Uint8Array { const questions = options.questions ?? []; const answers = options.answers ?? []; let flags = 0x8000; if (options.flags?.aa) flags |= 0x0400; if (options.flags?.tc) flags |= 0x0200; if (options.flags?.rd) flags |= 0x0100; if (options.flags?.ra) flags |= 0x0080; if (options.flags?.ad) flags |= 0x0020; flags |= (options.rcode ?? 0) & 0x000f; const header = new Uint8Array(12); const hv = new DataView(header.buffer); hv.setUint16(0, options.id); hv.setUint16(2, flags); hv.setUint16(4, questions.length); hv.setUint16(6, answers.length); hv.setUint16(8, 0); hv.setUint16(10, 0); const qParts: Uint8Array[] = []; for (const q of questions) { const nameBytes = buildName(q.name); const qtype = new Uint8Array(4); const qv = new DataView(qtype.buffer); qv.setUint16(0, q.qtype); qv.setUint16(2, q.qclass ?? 1); qParts.push(nameBytes, qtype); } const aParts: Uint8Array[] = []; for (const a of answers) { const nameBytes = buildName(a.name); const rrHead = new Uint8Array(10); const rv = new DataView(rrHead.buffer); rv.setUint16(0, a.type); rv.setUint16(2, a.class ?? 1); rv.setUint32(4, a.ttl); rv.setUint16(8, a.rdata.length); aParts.push(nameBytes, rrHead, a.rdata); } const allParts = [header, ...qParts, ...aParts]; const totalLen = allParts.reduce((s, p) => s + p.length, 0); const result = new Uint8Array(totalLen); let offset = 0; for (const part of allParts) { result.set(part, offset); offset += part.length; } return result; } async function createFakeDnsServer( respondWith: (query: Uint8Array) => Uint8Array, ): Promise<{ close: () => void; port: number }> { const socket = await Bun.udpSocket({ socket: { data(sock, data, port, addr) { const query = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); const response = respondWith(query); sock.send(response, port, addr); }, drain() { // 必填 handler void 0; }, error() { // 必填 handler void 0; }, }, }); return { close: () => socket.close(), port: socket.port }; } function makeServerTarget( overrides: Partial = {}, expect?: ResolvedDnsServerExpectConfig, ): ResolvedDnsTarget { return { description: null, dns: { maxResponseBytes: 4096, name: "example.com", port: 53, protocol: "udp", recordType: "A", recursionDesired: true, resolver: "server", server: "127.0.0.1", tcpFallback: false, ...overrides, } as ResolvedDnsTarget["dns"], expect, group: "default", id: "test-dns-server", intervalMs: 30000, name: null, timeoutMs: 10000, type: "dns", }; } function makeSignal(timeoutMs: number): { cleanup: () => void; signal: AbortSignal } { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); return { cleanup: () => clearTimeout(timer), signal: controller.signal }; } function makeSystemTarget( overrides: Partial = {}, expect?: ResolvedDnsSystemExpectConfig, ): ResolvedDnsTarget { return { description: null, dns: { family: "any", name: "example.com", resolver: "system", ...overrides, } as ResolvedDnsTarget["dns"], expect, group: "default", id: "test-dns-system", intervalMs: 30000, name: null, timeoutMs: 10000, type: "dns", }; } const checker = new DnsChecker(); const resolveContext = { configDir: "/tmp", defaultIntervalMs: 30000, defaultTimeoutMs: 10000 }; describe("DnsChecker resolve", () => { it("system mode: fills defaults (family=any)", () => { const target = checker.resolve( { dns: { name: "example.com", resolver: "system" }, id: "test", type: "dns" }, resolveContext, ); expect(target.type).toBe("dns"); expect(target.dns).toMatchObject({ family: "any", name: "example.com", resolver: "system" }); }); it("server mode: fills defaults", () => { const target = checker.resolve( { dns: { name: "example.com", resolver: "server", server: "8.8.8.8" }, id: "test", type: "dns" }, resolveContext, ); expect(target.dns).toMatchObject({ maxResponseBytes: 4096, port: 53, protocol: "udp", recordType: "A", recursionDesired: true, resolver: "server", server: "8.8.8.8", tcpFallback: true, }); }); it("server mode: respects user overrides", () => { const target = checker.resolve( { dns: { maxResponseBytes: 2048, name: "example.com", port: 5353, protocol: "tcp", recordType: "AAAA", recursionDesired: false, resolver: "server", server: "1.1.1.1", tcpFallback: false, }, id: "test", type: "dns", }, resolveContext, ); expect(target.dns).toMatchObject({ maxResponseBytes: 2048, port: 5353, protocol: "tcp", recordType: "AAAA", recursionDesired: false, server: "1.1.1.1", tcpFallback: false, }); }); it("both modes: sets type=dns, copies id/name/group, sets intervalMs/timeoutMs from context", () => { const sysTarget = checker.resolve( { dns: { name: "example.com", resolver: "system" }, group: "grp1", id: "t1", name: "my-target", type: "dns" }, resolveContext, ); expect(sysTarget.type).toBe("dns"); expect(sysTarget.id).toBe("t1"); expect(sysTarget.name).toBe("my-target"); expect(sysTarget.group).toBe("grp1"); expect(sysTarget.intervalMs).toBe(30000); expect(sysTarget.timeoutMs).toBe(10000); const srvTarget = checker.resolve( { dns: { name: "example.com", resolver: "server", server: "8.8.8.8" }, group: "grp2", id: "t2", name: "my-server", type: "dns", }, resolveContext, ); expect(srvTarget.type).toBe("dns"); expect(srvTarget.id).toBe("t2"); expect(srvTarget.name).toBe("my-server"); expect(srvTarget.group).toBe("grp2"); expect(srvTarget.intervalMs).toBe(30000); expect(srvTarget.timeoutMs).toBe(10000); }); }); describe("DnsChecker serialize", () => { it("system mode: returns dns system and JSON config", () => { const target = makeSystemTarget(); const result = checker.serialize(target); expect(result.target).toBe("dns system example.com"); const parsed = JSON.parse(result.config) as Record; expect(parsed["resolver"]).toBe("system"); expect(parsed["name"]).toBe("example.com"); }); it("server mode: returns dns : / and JSON config", () => { const target = makeServerTarget(); const result = checker.serialize(target); expect(result.target).toBe("dns 127.0.0.1:53 example.com/A"); const parsed = JSON.parse(result.config) as Record; expect(parsed["resolver"]).toBe("server"); expect(parsed["server"]).toBe("127.0.0.1"); }); }); describe("DnsChecker execute (system mode)", () => { it("localhost IPv4 resolution returns matched=true", async () => { const target = makeSystemTarget({ family: "ipv4", name: "localhost" }); const { cleanup, signal } = makeSignal(5000); const result = await checker.execute(target, { signal }); cleanup(); expect(result.matched).toBe(true); expect(result.failure).toBeNull(); expect(result.observation).toMatchObject({ family: "ipv4", resolver: "system" }); expect(result.observation!["valueCount"]).toBeGreaterThan(0); }); it("pre-aborted signal returns matched=false without real lookup", async () => { const target = makeSystemTarget({ name: "example.com" }); const controller = new AbortController(); controller.abort(); const result = await checker.execute(target, { signal: controller.signal }); expect(result.matched).toBe(false); expect(result.failure?.phase).toBe("resolve"); expect(result.observation).toMatchObject({ resolver: "system", valueCount: 0, values: [] }); }); }); describe("DnsChecker execute (server mode)", () => { it("successful A record query returns matched=true with correct observation fields", async () => { const server = await createFakeDnsServer((query) => { const view = new DataView(query.buffer, query.byteOffset, query.byteLength); const queryId = view.getUint16(0); return buildTestResponse({ answers: [{ name: "example.com", rdata: new Uint8Array([93, 184, 216, 34]), ttl: 300, type: 1 }], id: queryId, questions: [{ name: "example.com", qtype: 1 }], }); }); try { const target = makeServerTarget({ port: server.port }); const { cleanup, signal } = makeSignal(5000); const result = await checker.execute(target, { signal }); cleanup(); expect(result.matched).toBe(true); expect(result.failure).toBeNull(); expect(result.observation).toMatchObject({ rcode: "NOERROR", resolver: "server", responded: true, }); expect(result.observation!["values"]).toContain("93.184.216.34"); } finally { server.close(); } }); it("NXDOMAIN response with expect.rcode=[NXDOMAIN] returns matched=true", async () => { const server = await createFakeDnsServer((query) => { const view = new DataView(query.buffer, query.byteOffset, query.byteLength); const queryId = view.getUint16(0); return buildTestResponse({ id: queryId, questions: [{ name: "example.com", qtype: 1 }], rcode: 3, }); }); try { const target = makeServerTarget({ port: server.port }, { rcode: ["NXDOMAIN"], responded: true }); const { cleanup, signal } = makeSignal(5000); const result = await checker.execute(target, { signal }); cleanup(); expect(result.matched).toBe(true); expect(result.observation!["rcode"]).toBe("NXDOMAIN"); } finally { server.close(); } }); it("SERVFAIL response returns matched=false with default expect", async () => { const server = await createFakeDnsServer((query) => { const view = new DataView(query.buffer, query.byteOffset, query.byteLength); const queryId = view.getUint16(0); return buildTestResponse({ id: queryId, questions: [{ name: "example.com", qtype: 1 }], rcode: 2, }); }); try { const target = makeServerTarget({ port: server.port }); const { cleanup, signal } = makeSignal(5000); const result = await checker.execute(target, { signal }); cleanup(); expect(result.matched).toBe(false); expect(result.failure).not.toBeNull(); expect(result.observation!["rcode"]).toBe("SERVFAIL"); } finally { server.close(); } }); it("server mode resolved without expect still requires NOERROR by default", async () => { const server = await createFakeDnsServer((query) => { const view = new DataView(query.buffer, query.byteOffset, query.byteLength); const queryId = view.getUint16(0); return buildTestResponse({ answers: [{ name: "example.com", rdata: new Uint8Array([93, 184, 216, 34]), ttl: 300, type: 1 }], id: queryId, questions: [{ name: "example.com", qtype: 1 }], rcode: 2, }); }); try { const target = checker.resolve( { dns: { name: "example.com", port: server.port, resolver: "server", server: "127.0.0.1" }, id: "test", type: "dns", }, resolveContext, ); const { cleanup, signal } = makeSignal(5000); const result = await checker.execute(target, { signal }); cleanup(); expect(result.matched).toBe(false); expect(result.failure?.phase).toBe("rcode"); expect(result.observation!["rcode"]).toBe("SERVFAIL"); } finally { server.close(); } }); it("no response (timeout) returns matched=false", async () => { const server = await createFakeDnsServer(() => new Uint8Array(0)); try { const target = makeServerTarget({ port: server.port }); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 200); const result = await checker.execute(target, { signal: controller.signal }); clearTimeout(timer); expect(result.matched).toBe(false); expect(result.observation!["responded"]).toBe(false); } finally { server.close(); } }); it("checks values, valueCount, cnameChain for A query", async () => { const cnameRdata = buildName("cdn.example.com"); const server = await createFakeDnsServer((query) => { const view = new DataView(query.buffer, query.byteOffset, query.byteLength); const queryId = view.getUint16(0); return buildTestResponse({ answers: [ { name: "example.com", rdata: cnameRdata, ttl: 300, type: 5 }, { name: "example.com", rdata: new Uint8Array([93, 184, 216, 34]), ttl: 300, type: 1 }, ], id: queryId, questions: [{ name: "example.com", qtype: 1 }], }); }); try { const target = makeServerTarget({ port: server.port }); const { cleanup, signal } = makeSignal(5000); const result = await checker.execute(target, { signal }); cleanup(); expect(result.matched).toBe(true); const obs = result.observation!; expect(obs["values"]).toContain("93.184.216.34"); expect(obs["valueCount"]).toBe(1); expect(obs["cnameChain"]).toEqual(["cdn.example.com"]); } finally { server.close(); } }); it("non-address record values only include requested record type", async () => { const cnameRdata = buildName("mail-alias.example.com"); const exchange = buildName("mail.example.com"); const mxRdata = new Uint8Array(2 + exchange.length); const mxView = new DataView(mxRdata.buffer); mxView.setUint16(0, 10); mxRdata.set(exchange, 2); const server = await createFakeDnsServer((query) => { const view = new DataView(query.buffer, query.byteOffset, query.byteLength); const queryId = view.getUint16(0); return buildTestResponse({ answers: [ { name: "example.com", rdata: cnameRdata, ttl: 300, type: 5 }, { name: "example.com", rdata: mxRdata, ttl: 300, type: 15 }, ], id: queryId, questions: [{ name: "example.com", qtype: 15 }], }); }); try { const target = makeServerTarget({ port: server.port, recordType: "MX" }); const { cleanup, signal } = makeSignal(5000); const result = await checker.execute(target, { signal }); cleanup(); expect(result.matched).toBe(true); const obs = result.observation!; expect(obs["answerCount"]).toBe(2); expect(obs["valueCount"]).toBe(1); expect(obs["values"]).toEqual(["10 mail.example.com"]); } finally { server.close(); } }); it("explicit ttlMin fails when response has no answer TTL", async () => { const server = await createFakeDnsServer((query) => { const view = new DataView(query.buffer, query.byteOffset, query.byteLength); const queryId = view.getUint16(0); return buildTestResponse({ id: queryId, questions: [{ name: "example.com", qtype: 1 }], rcode: 3, }); }); try { const target = makeServerTarget( { port: server.port }, { rcode: ["NXDOMAIN"], responded: true, ttlMin: { gte: 0 } }, ); const { cleanup, signal } = makeSignal(5000); const result = await checker.execute(target, { signal }); cleanup(); expect(result.matched).toBe(false); expect(result.failure?.phase).toBe("ttlMin"); } finally { server.close(); } }); it("responded=false still checks explicit durationMs", async () => { const server = await createFakeDnsServer(() => new Uint8Array(0)); try { const target = makeServerTarget({ port: server.port }, { durationMs: { lt: 0 }, responded: false }); const { cleanup, signal } = makeSignal(5000); const result = await checker.execute(target, { signal }); cleanup(); expect(result.matched).toBe(false); expect(result.failure?.phase).toBe("duration"); expect(result.observation!["responded"]).toBe(false); } finally { server.close(); } }); it("durationMs is present in result", async () => { const server = await createFakeDnsServer((query) => { const view = new DataView(query.buffer, query.byteOffset, query.byteLength); const queryId = view.getUint16(0); return buildTestResponse({ answers: [{ name: "example.com", rdata: new Uint8Array([1, 1, 1, 1]), ttl: 300, type: 1 }], id: queryId, questions: [{ name: "example.com", qtype: 1 }], }); }); try { const target = makeServerTarget({ port: server.port }); const { cleanup, signal } = makeSignal(5000); const result = await checker.execute(target, { signal }); cleanup(); expect(result.durationMs).toBeGreaterThanOrEqual(0); expect(result.observation!["durationMs"]).toBeGreaterThanOrEqual(0); } finally { server.close(); } }); });