feat: DNS checker,自研 codec/transport,支持 system/server 双模式,UDP/TCP + TC fallback
This commit is contained in:
539
tests/server/checker/runner/dns/execute.test.ts
Normal file
539
tests/server/checker/runner/dns/execute.test.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
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<ResolvedDnsTarget["dns"]> = {},
|
||||
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<ResolvedDnsTarget["dns"]> = {},
|
||||
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 <name> 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<string, unknown>;
|
||||
expect(parsed["resolver"]).toBe("system");
|
||||
expect(parsed["name"]).toBe("example.com");
|
||||
});
|
||||
|
||||
it("server mode: returns dns <server>:<port> <name>/<recordType> 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<string, unknown>;
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user