1
0

feat: DNS checker,自研 codec/transport,支持 system/server 双模式,UDP/TCP + TC fallback

This commit is contained in:
2026-05-24 17:06:22 +08:00
parent 4f33fba793
commit 483cdc596b
21 changed files with 5686 additions and 16 deletions

View File

@@ -743,7 +743,7 @@ targets:
`targets:
- name: "test"
id: "test"
type: dns
type: ftp
`,
);
await expectConfigLoadError(configPath, "不支持的 type");

View File

@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import { CommandChecker } from "../../../../src/server/checker/runner/cmd/execute";
import { DbChecker } from "../../../../src/server/checker/runner/db/execute";
import { DnsChecker } from "../../../../src/server/checker/runner/dns/execute";
import { HttpChecker } from "../../../../src/server/checker/runner/http/execute";
import { IcmpChecker } from "../../../../src/server/checker/runner/icmp/execute";
import { LlmChecker } from "../../../../src/server/checker/runner/llm/execute";
@@ -64,4 +65,142 @@ describe("Checker buildDetail", () => {
expect(detail).toContain("output=2 chars");
expect(detail).toContain("usage=12/2 tokens");
});
const dnsChecker = new DnsChecker();
test("DnsChecker system mode: successful resolution shows addresses and duration", () => {
const detail = dnsChecker.buildDetail({
durationMs: 15,
error: null,
family: "any",
name: "example.com",
resolver: "system",
valueCount: 2,
values: ["93.184.216.34", "93.184.216.35"],
});
expect(detail).toContain("93.184.216.34");
expect(detail).toContain("15ms");
});
test("DnsChecker system mode: failed resolution shows error", () => {
const detail = dnsChecker.buildDetail({
durationMs: 100,
error: "getaddrinfo ENOTFOUND",
family: "any",
name: "example.com",
resolver: "system",
valueCount: 0,
values: [],
});
expect(detail).toContain("解析失败");
expect(detail).toContain("getaddrinfo ENOTFOUND");
});
test("DnsChecker system mode: no results shows '解析成功但无结果'", () => {
const detail = dnsChecker.buildDetail({
durationMs: 10,
error: null,
family: "any",
name: "example.com",
resolver: "system",
valueCount: 0,
values: [],
});
expect(detail).toContain("解析成功但无结果");
});
test("DnsChecker server mode: successful response shows rcode, values, protocol, duration", () => {
const detail = dnsChecker.buildDetail({
durationMs: 25,
error: null,
name: "example.com",
port: 53,
protocol: "udp",
protocolUsed: "udp",
rcode: "NOERROR",
recordType: "A",
resolver: "server",
responded: true,
server: "8.8.8.8",
valueCount: 2,
values: ["1.1.1.1", "2.2.2.2"],
});
expect(detail).toContain("NOERROR");
expect(detail).toContain("1.1.1.1");
expect(detail).toContain("UDP");
expect(detail).toContain("25ms");
});
test("DnsChecker server mode: NXDOMAIN shows rcode", () => {
const detail = dnsChecker.buildDetail({
durationMs: 30,
error: null,
name: "example.com",
port: 53,
protocol: "udp",
protocolUsed: "udp",
rcode: "NXDOMAIN",
recordType: "A",
resolver: "server",
responded: true,
server: "8.8.8.8",
valueCount: 0,
values: [],
});
expect(detail).toContain("NXDOMAIN");
});
test("DnsChecker server mode: no response shows error", () => {
const detail = dnsChecker.buildDetail({
durationMs: 500,
error: "探测超时",
name: "example.com",
port: 53,
protocol: "udp",
resolver: "server",
responded: false,
server: "8.8.8.8",
});
expect(detail).toContain("查询失败");
expect(detail).toContain("探测超时");
});
test("DnsChecker server mode: CNAME chain shows CNAME chain", () => {
const detail = dnsChecker.buildDetail({
cnameChain: ["cdn.example.com", "cdn-edge.example.net"],
durationMs: 40,
error: null,
name: "example.com",
port: 53,
protocol: "udp",
protocolUsed: "udp",
rcode: "NOERROR",
recordType: "A",
resolver: "server",
responded: true,
server: "8.8.8.8",
valueCount: 1,
values: ["93.184.216.34"],
});
expect(detail).toContain("CNAME: cdn.example.com → cdn-edge.example.net");
});
test("DnsChecker server mode: TCP fallback shows TCP in output", () => {
const detail = dnsChecker.buildDetail({
durationMs: 50,
error: null,
name: "example.com",
port: 53,
protocol: "udp",
protocolUsed: "tcp",
rcode: "NOERROR",
recordType: "A",
resolver: "server",
responded: true,
server: "8.8.8.8",
valueCount: 1,
values: ["1.2.3.4"],
});
expect(detail).toContain("TCP");
});
});

View File

@@ -0,0 +1,505 @@
import { describe, expect, it } from "bun:test";
import {
buildQuery,
parseResponse,
rcodeName,
rrtTypeByName,
rrtTypeName,
} from "../../../../../src/server/checker/runner/dns/codec";
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 buildResponse(options: {
answers?: Array<{ class?: number; name: string; rdata: Uint8Array; ttl: number; type: number }>;
flags?: { aa?: boolean; ad?: boolean; ra?: boolean; rd?: boolean; tc?: boolean };
questions?: Array<{ name: string; qclass?: number; qtype: number }>;
rcode?: number;
}): Uint8Array {
const questions = options.questions ?? [];
const answers = options.answers ?? [];
const id = 0x1234;
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, 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;
}
describe("buildQuery", () => {
it("produces a query buffer with correct header (QR=0, OPCODE=0, RD flag)", () => {
const buf = buildQuery("example.com", 1, true);
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
expect(buf.length).toBeGreaterThan(12);
const flags = view.getUint16(2);
expect(flags & 0x8000).toBe(0);
expect(flags & 0x7800).toBe(0);
expect(flags & 0x0100).toBe(0x0100);
expect(view.getUint16(4)).toBe(1);
expect(view.getUint16(6)).toBe(0);
expect(view.getUint16(8)).toBe(0);
expect(view.getUint16(10)).toBe(0);
});
it("encodes domain name correctly (labels with length prefixes, null terminator)", () => {
const buf = buildQuery("example.com", 1, false);
const nameBytes = buf.subarray(12, buf.length - 4);
const expected = buildName("example.com");
expect(Array.from(nameBytes)).toEqual(Array.from(expected));
});
it("encodes trailing-dot FQDN without an extra root label", () => {
const normal = buildQuery("example.com", 1, false);
const fqdn = buildQuery("example.com.", 1, false);
expect(Array.from(fqdn.subarray(12))).toEqual(Array.from(normal.subarray(12)));
});
it("encodes root domain", () => {
const buf = buildQuery(".", 1, false);
expect(Array.from(buf.subarray(12, buf.length - 4))).toEqual([0]);
});
it("rejects non-ASCII names instead of writing malformed UTF-8 labels", () => {
expect(() => buildQuery("é.example", 1, false)).toThrow("ASCII/Punycode");
});
it("rejects labels longer than 63 bytes", () => {
expect(() => buildQuery(`${"a".repeat(64)}.example`, 1, false)).toThrow("63 字节");
});
it("sets correct QTYPE and QCLASS", () => {
const buf = buildQuery("example.com", 28, false);
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
const qtypeOffset = buf.length - 4;
expect(view.getUint16(qtypeOffset)).toBe(28);
expect(view.getUint16(qtypeOffset + 2)).toBe(1);
});
it("sets RD=1 when recursionDesired=true, RD=0 when false", () => {
const bufRD = buildQuery("example.com", 1, true);
const viewRD = new DataView(bufRD.buffer, bufRD.byteOffset, bufRD.byteLength);
expect(viewRD.getUint16(2) & 0x0100).toBe(0x0100);
const bufNoRD = buildQuery("example.com", 1, false);
const viewNoRD = new DataView(bufNoRD.buffer, bufNoRD.byteOffset, bufNoRD.byteLength);
expect(viewNoRD.getUint16(2) & 0x0100).toBe(0);
});
});
describe("parseResponse", () => {
it("parses a minimal NOERROR response (no answers)", () => {
const resp = buildResponse({
questions: [{ name: "example.com", qtype: 1 }],
rcode: 0,
});
const result = parseResponse(resp);
expect(result.header.rcode).toBe(0);
expect(result.header.answerCount).toBe(0);
expect(result.answers).toHaveLength(0);
expect(result.questions).toHaveLength(1);
expect(result.questions[0]!.name).toBe("example.com");
expect(result.questions[0]!.qtype).toBe(1);
});
it("parses response with single A record", () => {
const rdata = new Uint8Array([93, 184, 216, 34]);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 300, type: 1 }],
questions: [{ name: "example.com", qtype: 1 }],
rcode: 0,
});
const result = parseResponse(resp);
expect(result.answers).toHaveLength(1);
const a = result.answers[0]!;
expect(a.type).toBe(1);
expect(a.value).toBe("93.184.216.34");
expect(a.ttl).toBe(300);
expect(a.name).toBe("example.com");
});
it("parses response with multiple A records", () => {
const rdata1 = new Uint8Array([1, 1, 1, 1]);
const rdata2 = new Uint8Array([8, 8, 8, 8]);
const resp = buildResponse({
answers: [
{ name: "example.com", rdata: rdata1, ttl: 60, type: 1 },
{ name: "example.com", rdata: rdata2, ttl: 60, type: 1 },
],
rcode: 0,
});
const result = parseResponse(resp);
expect(result.answers).toHaveLength(2);
expect(result.answers[0]!.value).toBe("1.1.1.1");
expect(result.answers[1]!.value).toBe("8.8.8.8");
});
it("parses AAAA record", () => {
const rdata = new Uint8Array([
0x26, 0x07, 0xf8, 0xb0, 0x40, 0x05, 0x08, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
]);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 300, type: 28 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("2607:f8b0:4005:80c::1");
});
it("parses CNAME record", () => {
const rdata = buildName("www.example.com");
const resp = buildResponse({
answers: [{ name: "alias.example.com", rdata, ttl: 300, type: 5 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("www.example.com");
expect(result.answers[0]!.data["target"]).toBe("www.example.com");
});
it("parses MX record (preference + exchange)", () => {
const exchange = buildName("mail.example.com");
const rdata = new Uint8Array(2 + exchange.length);
rdata[0] = 0x00;
rdata[1] = 0x0a;
rdata.set(exchange, 2);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 3600, type: 15 }],
});
const result = parseResponse(resp);
const mx = result.answers[0]!;
expect(mx.value).toBe("10 mail.example.com");
expect(mx.data["preference"]).toBe(10);
expect(mx.data["exchange"]).toBe("mail.example.com");
});
it("parses TXT record with single character-string", () => {
const txt = new TextEncoder().encode("v=spf1 include:_spf.example.com ~all");
const rdata = new Uint8Array(1 + txt.length);
rdata[0] = txt.length;
rdata.set(txt, 1);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 300, type: 16 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("v=spf1 include:_spf.example.com ~all");
});
it("parses TXT record with multiple character-strings", () => {
const s1 = new TextEncoder().encode("hello ");
const s2 = new TextEncoder().encode("world");
const rdata = new Uint8Array(1 + s1.length + 1 + s2.length);
rdata[0] = s1.length;
rdata.set(s1, 1);
rdata[1 + s1.length] = s2.length;
rdata.set(s2, 1 + s1.length + 1);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 300, type: 16 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("hello world");
});
it("parses NS record", () => {
const rdata = buildName("ns1.example.com");
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 86400, type: 2 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("ns1.example.com");
expect(result.answers[0]!.data["nsdname"]).toBe("ns1.example.com");
});
it("parses SOA record (all 7 fields)", () => {
const mname = buildName("ns1.example.com");
const rname = buildName("admin.example.com");
const fixed = new Uint8Array(20);
const fv = new DataView(fixed.buffer);
fv.setUint32(0, 2024010101);
fv.setUint32(4, 3600);
fv.setUint32(8, 900);
fv.setUint32(12, 604800);
fv.setUint32(16, 86400);
const rdata = new Uint8Array(mname.length + rname.length + fixed.length);
rdata.set(mname, 0);
rdata.set(rname, mname.length);
rdata.set(fixed, mname.length + rname.length);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 3600, type: 6 }],
});
const result = parseResponse(resp);
const soa = result.answers[0]!;
expect(soa.data["mname"]).toBe("ns1.example.com");
expect(soa.data["rname"]).toBe("admin.example.com");
expect(soa.data["serial"]).toBe(2024010101);
expect(soa.data["refresh"]).toBe(3600);
expect(soa.data["retry"]).toBe(900);
expect(soa.data["expire"]).toBe(604800);
expect(soa.data["minimum"]).toBe(86400);
expect(soa.value).toBe("ns1.example.com admin.example.com 2024010101 3600 900 604800 86400");
});
it("parses SRV record (priority, weight, port, target)", () => {
const target = buildName("server.example.com");
const rdata = new Uint8Array(6 + target.length);
const rv = new DataView(rdata.buffer);
rv.setUint16(0, 10);
rv.setUint16(2, 20);
rv.setUint16(4, 443);
rdata.set(target, 6);
const resp = buildResponse({
answers: [{ name: "_https._tcp.example.com", rdata, ttl: 300, type: 33 }],
});
const result = parseResponse(resp);
const srv = result.answers[0]!;
expect(srv.data["priority"]).toBe(10);
expect(srv.data["weight"]).toBe(20);
expect(srv.data["port"]).toBe(443);
expect(srv.data["target"]).toBe("server.example.com");
expect(srv.value).toBe("10 20 443 server.example.com");
});
it("parses PTR record", () => {
const rdata = buildName("host.example.com");
const resp = buildResponse({
answers: [{ name: "1.0.0.127.in-addr.arpa", rdata, ttl: 300, type: 12 }],
questions: [{ name: "1.0.0.127.in-addr.arpa", qtype: 12 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("host.example.com");
expect(result.answers[0]!.data["ptrdname"]).toBe("host.example.com");
});
it("parses CAA record (flags, tag, value)", () => {
const tag = new TextEncoder().encode("issue");
const value = new TextEncoder().encode("letsencrypt.org");
const rdata = new Uint8Array(2 + tag.length + value.length);
rdata[0] = 0;
rdata[1] = tag.length;
rdata.set(tag, 2);
rdata.set(value, 2 + tag.length);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 300, type: 257 }],
});
const result = parseResponse(resp);
const caa = result.answers[0]!;
expect(caa.data["flags"]).toBe(0);
expect(caa.data["tag"]).toBe("issue");
expect(caa.data["valueStr"]).toBe("letsencrypt.org");
expect(caa.value).toBe("0 issue letsencrypt.org");
});
it("parses response with DNS name compression (pointer)", () => {
const nameBytes = buildName("example.com");
const nameLen = nameBytes.length;
const headerSize = 12;
const qSectionEnd = headerSize + nameLen + 4;
const totalSize = qSectionEnd + nameLen + 10 + 4;
const buf = new Uint8Array(totalSize);
const view = new DataView(buf.buffer);
view.setUint16(0, 0xabcd);
view.setUint16(2, 0x8180);
view.setUint16(4, 1);
view.setUint16(6, 1);
view.setUint16(8, 0);
view.setUint16(10, 0);
buf.set(nameBytes, headerSize);
view.setUint16(headerSize + nameLen, 1);
view.setUint16(headerSize + nameLen + 2, 1);
const ptrByte1 = 0xc0 | ((headerSize >> 8) & 0x3f);
const ptrByte2 = headerSize & 0xff;
buf[qSectionEnd] = ptrByte1;
buf[qSectionEnd + 1] = ptrByte2;
view.setUint16(qSectionEnd + 2, 1);
view.setUint16(qSectionEnd + 4, 1);
view.setUint32(qSectionEnd + 6, 300);
view.setUint16(qSectionEnd + 10, 4);
buf[qSectionEnd + 12] = 93;
buf[qSectionEnd + 13] = 184;
buf[qSectionEnd + 14] = 216;
buf[qSectionEnd + 15] = 34;
const result = parseResponse(buf);
expect(result.answers[0]!.name).toBe("example.com");
expect(result.answers[0]!.value).toBe("93.184.216.34");
});
it("correctly reports flags (AA, RA, RD, TC, AD)", () => {
const resp = buildResponse({
flags: { aa: true, ad: true, ra: true, rd: true, tc: false },
});
const result = parseResponse(resp);
expect(result.header.flags.authoritative).toBe(true);
expect(result.header.flags.recursionAvailable).toBe(true);
expect(result.header.flags.recursionDesired).toBe(true);
expect(result.header.flags.truncated).toBe(false);
expect(result.header.flags.authenticatedData).toBe(true);
});
it("reports correct rcode", () => {
const resp = buildResponse({ rcode: 3 });
const result = parseResponse(resp);
expect(result.header.rcode).toBe(3);
});
it("reports correct answerCount, authorityCount, additionalCount", () => {
const a1 = new Uint8Array([1, 2, 3, 4]);
const a2 = new Uint8Array([5, 6, 7, 8]);
const header = new Uint8Array(12);
const hv = new DataView(header.buffer);
hv.setUint16(0, 0x0001);
hv.setUint16(2, 0x8180);
hv.setUint16(4, 0);
hv.setUint16(6, 1);
hv.setUint16(8, 1);
hv.setUint16(10, 1);
const nameBytes = buildName("example.com");
const buildRR = (rdata: Uint8Array) => {
const rr = new Uint8Array(nameBytes.length + 10 + rdata.length);
rr.set(nameBytes, 0);
const rv = new DataView(rr.buffer);
rv.setUint16(nameBytes.length, 1);
rv.setUint16(nameBytes.length + 2, 1);
rv.setUint32(nameBytes.length + 4, 60);
rv.setUint16(nameBytes.length + 8, rdata.length);
rr.set(rdata, nameBytes.length + 10);
return rr;
};
const rr1 = buildRR(a1);
const rr2 = buildRR(a2);
const rr3 = buildRR(new Uint8Array([9, 10, 11, 12]));
const buf = new Uint8Array(12 + rr1.length + rr2.length + rr3.length);
buf.set(header, 0);
buf.set(rr1, 12);
buf.set(rr2, 12 + rr1.length);
buf.set(rr3, 12 + rr1.length + rr2.length);
const result = parseResponse(buf);
expect(result.header.answerCount).toBe(1);
expect(result.header.authorityCount).toBe(1);
expect(result.header.additionalCount).toBe(1);
expect(result.answers).toHaveLength(1);
expect(result.authorities).toHaveLength(1);
expect(result.additional).toHaveLength(1);
});
it("throws on response shorter than 12 bytes", () => {
expect(() => parseResponse(new Uint8Array(11))).toThrow();
expect(() => parseResponse(new Uint8Array(0))).toThrow();
});
it("handles unknown record types (raw hex in value)", () => {
const rdata = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 300, type: 999 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("de ad be ef");
expect(result.answers[0]!.data["raw"]).toEqual([0xde, 0xad, 0xbe, 0xef]);
});
});
describe("rcodeName", () => {
it("maps known rcode numbers to names", () => {
expect(rcodeName(0)).toBe("NOERROR");
expect(rcodeName(1)).toBe("FORMERR");
expect(rcodeName(2)).toBe("SERVFAIL");
expect(rcodeName(3)).toBe("NXDOMAIN");
expect(rcodeName(4)).toBe("NOTIMP");
expect(rcodeName(5)).toBe("REFUSED");
});
it("returns UNKNOWN(n) for unknown codes", () => {
expect(rcodeName(99)).toBe("UNKNOWN(99)");
expect(rcodeName(255)).toBe("UNKNOWN(255)");
});
});
describe("rrtTypeName", () => {
it("maps known type numbers to names", () => {
expect(rrtTypeName(1)).toBe("A");
expect(rrtTypeName(28)).toBe("AAAA");
expect(rrtTypeName(5)).toBe("CNAME");
expect(rrtTypeName(15)).toBe("MX");
expect(rrtTypeName(16)).toBe("TXT");
expect(rrtTypeName(2)).toBe("NS");
expect(rrtTypeName(6)).toBe("SOA");
expect(rrtTypeName(33)).toBe("SRV");
expect(rrtTypeName(12)).toBe("PTR");
expect(rrtTypeName(257)).toBe("CAA");
});
});
describe("rrtTypeByName", () => {
it("maps known type names to numbers", () => {
expect(rrtTypeByName("A")).toBe(1);
expect(rrtTypeByName("AAAA")).toBe(28);
expect(rrtTypeByName("CNAME")).toBe(5);
expect(rrtTypeByName("MX")).toBe(15);
expect(rrtTypeByName("TXT")).toBe(16);
expect(rrtTypeByName("NS")).toBe(2);
expect(rrtTypeByName("SOA")).toBe(6);
expect(rrtTypeByName("SRV")).toBe(33);
expect(rrtTypeByName("PTR")).toBe(12);
expect(rrtTypeByName("CAA")).toBe(257);
});
it("throws for unknown type name", () => {
expect(() => rrtTypeByName("UNKNOWN_TYPE")).toThrow();
});
});

View 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();
}
});
});

View File

@@ -0,0 +1,226 @@
import { describe, expect, it } from "bun:test";
import type { ContentExpectations } from "../../../../../src/server/checker/expect/types";
import {
checkAnswerCount,
checkDnsValues,
checkFlag,
checkRcode,
checkResponded,
checkResult,
checkTtlMax,
checkTtlMin,
checkValueCount,
} from "../../../../../src/server/checker/runner/dns/expect";
describe("checkResponded", () => {
it("responded=true 期望 true → 匹配", () => {
const result = checkResponded(true, true);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("responded=false 期望 true → 不匹配", () => {
const result = checkResponded(false, true);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("responded");
expect(result.failure!.message).toContain("未收到");
});
it("responded=true 期望 false → 不匹配", () => {
const result = checkResponded(true, false);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("responded");
expect(result.failure!.message).toContain("收到响应");
});
it("responded=false 期望 false → 匹配", () => {
const result = checkResponded(false, false);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
});
describe("checkRcode", () => {
it("NOERROR 在 [NOERROR] → 匹配", () => {
const result = checkRcode("NOERROR", ["NOERROR"]);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("NXDOMAIN 在 [NOERROR] → 不匹配", () => {
const result = checkRcode("NXDOMAIN", ["NOERROR"]);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("rcode");
});
it("NXDOMAIN 在 [NXDOMAIN, SERVFAIL] → 匹配", () => {
const result = checkRcode("NXDOMAIN", ["NXDOMAIN", "SERVFAIL"]);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
});
describe("checkDnsValues", () => {
it("exact: 相同集合不同顺序 → 匹配", () => {
const result = checkDnsValues(["2.2.2.2", "1.1.1.1"], { exact: ["1.1.1.1", "2.2.2.2"] });
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("exact: 不同集合 → 不匹配", () => {
const result = checkDnsValues(["1.1.1.1"], { exact: ["2.2.2.2"] });
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("values");
});
it("exact: 数量不同 → 不匹配", () => {
const result = checkDnsValues(["1.1.1.1", "2.2.2.2"], { exact: ["1.1.1.1"] });
expect(result.matched).toBe(false);
});
it("include: 全部存在 → 匹配", () => {
const result = checkDnsValues(["1.1.1.1", "2.2.2.2"], { include: ["1.1.1.1"] });
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("include: 缺少一个 → 不匹配", () => {
const result = checkDnsValues(["1.1.1.1"], { include: ["3.3.3.3"] });
expect(result.matched).toBe(false);
expect(result.failure!.message).toContain("3.3.3.3");
});
it("exclude: 全不存在 → 匹配", () => {
const result = checkDnsValues(["1.1.1.1"], { exclude: ["3.3.3.3"] });
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("exclude: 存在一个 → 不匹配", () => {
const result = checkDnsValues(["1.1.1.1", "2.2.2.2"], { exclude: ["2.2.2.2"] });
expect(result.matched).toBe(false);
expect(result.failure!.message).toContain("2.2.2.2");
});
it("include + exclude 组合:全部满足 → 匹配", () => {
const result = checkDnsValues(["1.1.1.1", "2.2.2.2"], { exclude: ["3.3.3.3"], include: ["1.1.1.1"] });
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("include + exclude 组合include 失败 → 不匹配", () => {
const result = checkDnsValues(["1.1.1.1"], { exclude: ["3.3.3.3"], include: ["4.4.4.4"] });
expect(result.matched).toBe(false);
});
it("空 expectation → 匹配", () => {
const result = checkDnsValues(["1.1.1.1"], {});
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
});
describe("checkValueCount", () => {
it("count=3 gte=1 → 匹配", () => {
const result = checkValueCount(3, { gte: 1 });
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("count=0 gte=1 → 不匹配", () => {
const result = checkValueCount(0, { gte: 1 });
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("valueCount");
});
});
describe("checkAnswerCount", () => {
it("count=2 gte=2 → 匹配", () => {
const result = checkAnswerCount(2, { gte: 2 });
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("count=1 gte=2 → 不匹配", () => {
const result = checkAnswerCount(1, { gte: 2 });
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("answerCount");
});
});
describe("checkTtlMin", () => {
it("ttl=300 gte=60 → 匹配", () => {
const result = checkTtlMin(300, { gte: 60 });
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("ttl=30 gte=60 → 不匹配", () => {
const result = checkTtlMin(30, { gte: 60 });
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("ttlMin");
});
});
describe("checkTtlMax", () => {
it("ttl=100 lte=3600 → 匹配", () => {
const result = checkTtlMax(100, { lte: 3600 });
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("ttl=5000 lte=3600 → 不匹配", () => {
const result = checkTtlMax(5000, { lte: 3600 });
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("ttlMax");
});
});
describe("checkFlag", () => {
it("匹配 → matched", () => {
const result = checkFlag(true, true, "authoritative");
expect(result!.matched).toBe(true);
expect(result!.failure).toBeNull();
});
it("不匹配 → mismatch", () => {
const result = checkFlag(false, true, "authoritative");
expect(result!.matched).toBe(false);
expect(result!.failure!.kind).toBe("mismatch");
expect(result!.failure!.phase).toBe("authoritative");
});
it("undefined 期望 → 跳过返回 null", () => {
const result = checkFlag(true, undefined, "authoritative");
expect(result).toBeNull();
});
});
describe("checkResult", () => {
it("单条 contains 匹配 JSON 字符串", () => {
const observation = { answers: ["1.1.1.1"], rcode: "NOERROR" };
const expectations: ContentExpectations = [{ kind: "value", matcher: { contains: "NOERROR" } }];
const result = checkResult(observation, expectations);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("单条 contains 不匹配 → 不匹配", () => {
const observation = { rcode: "SERVFAIL" };
const expectations: ContentExpectations = [{ kind: "value", matcher: { contains: "NOERROR" } }];
const result = checkResult(observation, expectations);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("result");
});
});

View File

@@ -0,0 +1,266 @@
import { describe, expect, it } from "bun:test";
import { buildQuery } from "../../../../../src/server/checker/runner/dns/codec";
import { queryDns } from "../../../../../src/server/checker/runner/dns/transport";
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 buildResponse(options: {
answers?: Array<{ class?: number; name: string; rdata: Uint8Array; ttl: number; type: number }>;
flags?: { 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?.tc) flags |= 0x0200;
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;
}
function createTcpServer(respondWith: (query: Uint8Array) => Uint8Array, port = 0): { port: number; stop: () => void } {
const states = new WeakMap<object, { chunks: Uint8Array[]; totalBytes: number }>();
const server = Bun.listen({
hostname: "127.0.0.1",
port,
socket: {
data(socket, data) {
const key = socket as object;
const state = states.get(key) ?? { chunks: [], totalBytes: 0 };
const chunk = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
state.chunks.push(chunk);
state.totalBytes += chunk.byteLength;
states.set(key, state);
const full = mergeChunks(state.chunks, state.totalBytes);
if (full.byteLength < 2) return;
const queryLength = new DataView(full.buffer, full.byteOffset, full.byteLength).getUint16(0);
if (full.byteLength < queryLength + 2) return;
const response = respondWith(full.subarray(2, 2 + queryLength));
const lengthPrefix = new Uint8Array(2);
new DataView(lengthPrefix.buffer).setUint16(0, response.byteLength);
socket.write(lengthPrefix);
socket.write(response);
socket.close();
},
error() {
// 测试 server 忽略错误
},
open() {
// Bun.listen 必填 handler
},
},
});
return { port: server.port, stop: () => server.stop() };
}
async function createUdpServer(
respondWith: (query: Uint8Array) => Uint8Array,
port?: number,
): Promise<{ close: () => void; port: number }> {
const socketHandlers = {
data(
sock: { send(data: Uint8Array, port: number, hostname: string): void },
data: Uint8Array,
remotePort: number,
addr: string,
) {
const query = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
sock.send(respondWith(query), remotePort, addr);
},
drain() {
// Bun UDP socket handler 必填项
},
error() {
// 测试 server 忽略错误
},
};
const socket =
port === undefined
? await Bun.udpSocket({ hostname: "127.0.0.1", socket: socketHandlers })
: await Bun.udpSocket({ hostname: "127.0.0.1", port, socket: socketHandlers });
return { close: () => socket.close(), port: socket.port };
}
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 mergeChunks(chunks: Uint8Array[], totalBytes: number): Uint8Array {
const result = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
}
describe("DNS transport", () => {
it("executes TCP DNS query with length-prefixed response", async () => {
const server = createTcpServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const id = view.getUint16(0);
return buildResponse({
answers: [{ name: "example.com", rdata: new Uint8Array([93, 184, 216, 34]), ttl: 300, type: 1 }],
id,
questions: [{ name: "example.com", qtype: 1 }],
});
});
try {
const { cleanup, signal } = makeSignal(5000);
const result = await queryDns("127.0.0.1", server.port, buildQuery("example.com", 1, true), {
maxResponseBytes: 4096,
protocol: "tcp",
signal,
tcpFallback: false,
});
cleanup();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.protocolUsed).toBe("tcp");
expect(result.response.answers[0]!.value).toBe("93.184.216.34");
}
} finally {
server.stop();
}
});
it("falls back from UDP to TCP when response is truncated", async () => {
const tcpServer = createTcpServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const id = view.getUint16(0);
return buildResponse({
answers: [{ name: "example.com", rdata: new Uint8Array([1, 1, 1, 1]), ttl: 60, type: 1 }],
id,
questions: [{ name: "example.com", qtype: 1 }],
});
});
const udpServer = await createUdpServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const id = view.getUint16(0);
return buildResponse({ flags: { tc: true }, id, questions: [{ name: "example.com", qtype: 1 }] });
}, tcpServer.port);
try {
const { cleanup, signal } = makeSignal(5000);
const result = await queryDns("127.0.0.1", tcpServer.port, buildQuery("example.com", 1, true), {
maxResponseBytes: 4096,
protocol: "udp",
signal,
tcpFallback: true,
});
cleanup();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.protocolUsed).toBe("tcp");
expect(result.response.answers[0]!.value).toBe("1.1.1.1");
}
} finally {
udpServer.close();
tcpServer.stop();
}
});
it("rejects UDP responses larger than maxResponseBytes", async () => {
const server = await createUdpServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const id = view.getUint16(0);
return buildResponse({
answers: [{ name: "example.com", rdata: new Uint8Array([93, 184, 216, 34]), ttl: 300, type: 1 }],
id,
questions: [{ name: "example.com", qtype: 1 }],
});
});
try {
const { cleanup, signal } = makeSignal(5000);
const result = await queryDns("127.0.0.1", server.port, buildQuery("example.com", 1, true), {
maxResponseBytes: 8,
protocol: "udp",
signal,
tcpFallback: false,
});
cleanup();
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("超过");
} finally {
server.close();
}
});
it("rejects response ID mismatch", async () => {
const server = await createUdpServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const id = (view.getUint16(0) + 1) & 0xffff;
return buildResponse({ id, questions: [{ name: "example.com", qtype: 1 }] });
});
try {
const { cleanup, signal } = makeSignal(5000);
const result = await queryDns("127.0.0.1", server.port, buildQuery("example.com", 1, true), {
maxResponseBytes: 4096,
protocol: "udp",
signal,
tcpFallback: false,
});
cleanup();
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("ID 不匹配");
} finally {
server.close();
}
});
});

View File

@@ -0,0 +1,473 @@
import { describe, expect, it } from "bun:test";
import type { CheckerValidationInput } from "../../../../../src/server/checker/runner/types";
import { validateDnsConfig } from "../../../../../src/server/checker/runner/dns/validate";
function makeInput(overrides: { targets?: Array<Record<string, unknown>> }): CheckerValidationInput {
return { targets: (overrides.targets ?? []) as CheckerValidationInput["targets"] };
}
describe("validateDnsConfig", () => {
it("接受合法的 system 目标resolver/name/family", () => {
const issues = validateDnsConfig(
makeInput({
targets: [{ dns: { family: "ipv4", name: "example.com", resolver: "system" }, id: "t1", type: "dns" }],
}),
);
expect(issues).toHaveLength(0);
});
it("接受合法的 server 目标resolver/name/server/port/recordType", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: {
name: "example.com",
port: 53,
protocol: "udp",
recordType: "A",
resolver: "server",
server: "8.8.8.8",
},
id: "t2",
type: "dns",
},
],
}),
);
expect(issues).toHaveLength(0);
});
it("拒绝缺少 dns 配置分组", () => {
const issues = validateDnsConfig(makeInput({ targets: [{ id: "t3", type: "dns" }] }));
expect(issues).toHaveLength(1);
expect(issues[0]!.code).toBe("required");
expect(issues[0]!.path).toContain("dns");
expect(issues[0]!.message).toContain("dns");
});
it("拒绝缺少 dns.resolver", () => {
const issues = validateDnsConfig(makeInput({ targets: [{ dns: { name: "example.com" }, id: "t4", type: "dns" }] }));
expect(issues).toHaveLength(1);
expect(issues[0]!.code).toBe("required");
expect(issues[0]!.path).toContain("resolver");
});
it("拒绝无效的 dns.resolver 值", () => {
const issues = validateDnsConfig(
makeInput({ targets: [{ dns: { name: "example.com", resolver: "unknown" }, id: "t5", type: "dns" }] }),
);
expect(issues).toHaveLength(1);
expect(issues[0]!.code).toBe("invalid-value");
expect(issues[0]!.path).toContain("resolver");
expect(issues[0]!.message).toContain("system");
expect(issues[0]!.message).toContain("server");
});
it("拒绝缺少 dns.name", () => {
const issues = validateDnsConfig(makeInput({ targets: [{ dns: { resolver: "system" }, id: "t6", type: "dns" }] }));
expect(issues.length).toBeGreaterThanOrEqual(1);
expect(issues.some((i) => i.code === "required" && i.path.includes("name"))).toBe(true);
});
it("System 模式:拒绝空白 dns.name", () => {
const issues = validateDnsConfig(
makeInput({ targets: [{ dns: { name: " ", resolver: "system" }, id: "t7", type: "dns" }] }),
);
expect(issues.some((i) => i.code === "required" && i.path.includes("name"))).toBe(true);
});
it("System 模式:接受 family any/ipv4/ipv6拒绝无效 family", () => {
for (const family of ["any", "ipv4", "ipv6"]) {
const issues = validateDnsConfig(
makeInput({ targets: [{ dns: { family, name: "x.com", resolver: "system" }, id: "tf", type: "dns" }] }),
);
expect(issues).toHaveLength(0);
}
const issues = validateDnsConfig(
makeInput({ targets: [{ dns: { family: "ipx", name: "x.com", resolver: "system" }, id: "tf", type: "dns" }] }),
);
expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("family"))).toBe(true);
});
it("System 模式:拒绝 dns 中的未知字段", () => {
const issues = validateDnsConfig(
makeInput({
targets: [{ dns: { bogus: true, name: "x.com", resolver: "system" }, id: "t8", type: "dns" }],
}),
);
expect(issues.some((i) => i.code === "unknown-field" && i.path.includes("bogus"))).toBe(true);
});
it("System 模式:拒绝 server 专用字段server/port/protocol/recordType", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", port: 53, protocol: "udp", recordType: "A", resolver: "system", server: "8.8.8.8" },
id: "t9",
type: "dns",
},
],
}),
);
expect(issues.some((i) => i.code === "unknown-field" && i.path.includes("server"))).toBe(true);
expect(issues.some((i) => i.code === "unknown-field" && i.path.includes("port"))).toBe(true);
expect(issues.some((i) => i.code === "unknown-field" && i.path.includes("protocol"))).toBe(true);
expect(issues.some((i) => i.code === "unknown-field" && i.path.includes("recordType"))).toBe(true);
});
it("Server 模式:拒绝缺少 dns.server", () => {
const issues = validateDnsConfig(
makeInput({ targets: [{ dns: { name: "x.com", resolver: "server" }, id: "t10", type: "dns" }] }),
);
expect(issues.some((i) => i.code === "required" && i.path.includes("server"))).toBe(true);
});
it("Server 模式:拒绝无效 port0、-1、65536、1.5", () => {
for (const port of [0, -1, 65536, 1.5]) {
const issues = validateDnsConfig(
makeInput({
targets: [{ dns: { name: "x.com", port, resolver: "server", server: "8.8.8.8" }, id: "tp", type: "dns" }],
}),
);
expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("port"))).toBe(true);
}
});
it("Server 模式:接受 protocol udp/tcp拒绝无效值", () => {
for (const protocol of ["udp", "tcp"]) {
const issues = validateDnsConfig(
makeInput({
targets: [
{ dns: { name: "x.com", protocol, resolver: "server", server: "8.8.8.8" }, id: "tpr", type: "dns" },
],
}),
);
expect(issues).toHaveLength(0);
}
const issues = validateDnsConfig(
makeInput({
targets: [
{ dns: { name: "x.com", protocol: "http", resolver: "server", server: "8.8.8.8" }, id: "tpr", type: "dns" },
],
}),
);
expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("protocol"))).toBe(true);
});
it("Server 模式:接受有效 recordType拒绝无效 recordType", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{ dns: { name: "x.com", recordType: "A", resolver: "server", server: "8.8.8.8" }, id: "trt", type: "dns" },
],
}),
);
expect(issues).toHaveLength(0);
const badIssues = validateDnsConfig(
makeInput({
targets: [
{ dns: { name: "x.com", recordType: "FAKE", resolver: "server", server: "8.8.8.8" }, id: "trt", type: "dns" },
],
}),
);
expect(badIssues.some((i) => i.code === "invalid-value" && i.path.includes("recordType"))).toBe(true);
});
it("Server 模式:接受有效 maxResponseBytes数字和字符串", () => {
const issues1 = validateDnsConfig(
makeInput({
targets: [
{
dns: { maxResponseBytes: 512, name: "x.com", resolver: "server", server: "8.8.8.8" },
id: "tmr",
type: "dns",
},
],
}),
);
expect(issues1).toHaveLength(0);
const issues2 = validateDnsConfig(
makeInput({
targets: [
{
dns: { maxResponseBytes: "1KB", name: "x.com", resolver: "server", server: "8.8.8.8" },
id: "tmr",
type: "dns",
},
],
}),
);
expect(issues2).toHaveLength(0);
});
it("Server 模式:拒绝无效 maxResponseBytes 字符串", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { maxResponseBytes: "1kb", name: "x.com", resolver: "server", server: "8.8.8.8" },
id: "tmr-invalid",
type: "dns",
},
],
}),
);
expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("maxResponseBytes"))).toBe(true);
});
it("Server 模式:拒绝 recursionDesired/tcpFallback 非布尔值", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: {
name: "x.com",
recursionDesired: "false",
resolver: "server",
server: "8.8.8.8",
tcpFallback: "true",
},
id: "tb-dns",
type: "dns",
},
],
}),
);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("recursionDesired"))).toBe(true);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("tcpFallback"))).toBe(true);
});
it("Server 模式:拒绝 dns 中的未知字段", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{ dns: { name: "x.com", resolver: "server", server: "8.8.8.8", unknown: true }, id: "t16", type: "dns" },
],
}),
);
expect(issues.some((i) => i.code === "unknown-field" && i.path.includes("unknown"))).toBe(true);
});
it("System 模式 expect拒绝 rcode/ttlMin/ttlMax/answerCount/authoritative/recursionAvailable/truncated/authenticatedData/result/responded → dns-unsupported-expect", () => {
const serverOnlyFields = [
"rcode",
"ttlMin",
"ttlMax",
"answerCount",
"authoritative",
"recursionAvailable",
"truncated",
"authenticatedData",
"result",
"responded",
];
for (const field of serverOnlyFields) {
const expectObj: Record<string, unknown> = {};
expectObj[field] = field === "rcode" ? ["NOERROR"] : field === "result" ? ["ok"] : true;
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", resolver: "system" },
expect: expectObj,
id: "tse",
name: "sys-target",
type: "dns",
},
],
}),
);
const matched = issues.find((i) => i.code === "dns-unsupported-expect" && i.path.includes(field));
expect(matched).toBeDefined();
expect(matched!.message).toContain("system");
}
});
it("System 模式 expect接受 values/valueCount/durationMs", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", resolver: "system" },
expect: { durationMs: { lte: 100 }, valueCount: { gte: 1 }, values: { exact: ["1.2.3.4"] } },
id: "t18",
type: "dns",
},
],
}),
);
expect(issues).toHaveLength(0);
});
it("Server 模式 expect接受所有 server expect 字段", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", resolver: "server", server: "8.8.8.8" },
expect: {
answerCount: { gte: 1 },
authenticatedData: false,
authoritative: false,
durationMs: { lte: 100 },
rcode: ["NOERROR"],
recursionAvailable: true,
responded: true,
result: [{ contains: "ok" }],
truncated: false,
ttlMax: { lte: 3600 },
ttlMin: { gte: 0 },
valueCount: { gte: 1 },
values: { exact: ["1.2.3.4"] },
},
id: "t19",
type: "dns",
},
],
}),
);
expect(issues).toHaveLength(0);
});
it("Server 模式 expectresponded=false 时拒绝协议级响应断言", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", resolver: "server", server: "8.8.8.8" },
expect: { rcode: ["NOERROR"], responded: false },
id: "trf",
type: "dns",
},
],
}),
);
expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("responded"))).toBe(true);
});
it("Server 模式 expect拒绝未知 expect 字段", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", resolver: "server", server: "8.8.8.8" },
expect: { bogusField: 123 },
id: "t20",
type: "dns",
},
],
}),
);
expect(issues.some((i) => i.code === "unknown-field" && i.path.includes("bogusField"))).toBe(true);
});
it("验证 rcode 值必须为已知 RCODE", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", resolver: "server", server: "8.8.8.8" },
expect: { rcode: ["NOTAREALCODE"] },
id: "trc",
type: "dns",
},
],
}),
);
expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("rcode"))).toBe(true);
});
it("验证布尔字段responded/authoritative 等)", () => {
const fields = ["responded", "authoritative", "recursionAvailable", "truncated", "authenticatedData"];
for (const field of fields) {
const expectObj: Record<string, unknown> = {};
expectObj[field] = "notbool";
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", resolver: "server", server: "8.8.8.8" },
expect: expectObj,
id: "tb",
type: "dns",
},
],
}),
);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes(field))).toBe(true);
}
});
it("验证 ValueExpectation 字段durationMs/valueCount/answerCount/ttlMin/ttlMax", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", resolver: "server", server: "8.8.8.8" },
expect: { answerCount: [4], durationMs: [1, 2], ttlMax: [6], ttlMin: [5], valueCount: [3] },
id: "tve",
type: "dns",
},
],
}),
);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("durationMs"))).toBe(true);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("valueCount"))).toBe(true);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("answerCount"))).toBe(true);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("ttlMin"))).toBe(true);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("ttlMax"))).toBe(true);
});
it("验证 ContentExpectations 字段result", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", resolver: "server", server: "8.8.8.8" },
expect: { result: "not-array" },
id: "tce",
type: "dns",
},
],
}),
);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("result"))).toBe(true);
});
it("验证 DnsValuesExpectationexact/include/exclude 数组)", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", resolver: "server", server: "8.8.8.8" },
expect: { values: { exact: "not-array", exclude: true, include: 123, unknownKey: "x" } },
id: "tdv",
type: "dns",
},
],
}),
);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("exact"))).toBe(true);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("include"))).toBe(true);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("exclude"))).toBe(true);
expect(issues.some((i) => i.code === "unknown-field" && i.path.includes("unknownKey"))).toBe(true);
});
it("跳过非 dns 类型目标", () => {
const issues = validateDnsConfig(
makeInput({ targets: [{ http: { url: "http://example.com" }, id: "tother", type: "http" }] }),
);
expect(issues).toHaveLength(0);
});
it("跳过非对象目标", () => {
const issues = validateDnsConfig(makeInput({ targets: ["not-an-object" as unknown as Record<string, unknown>] }));
expect(issues).toHaveLength(0);
});
});

View File

@@ -72,8 +72,8 @@ describe("CheckerRegistry", () => {
const second = createDefaultCheckerRegistry();
first.register(createChecker("custom"));
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "custom"]);
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm"]);
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns", "custom"]);
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns"]);
expect(
first.definitions.every((checker) => checker.schemas.authoring.config && checker.schemas.normalized.expect),
).toBe(true);