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

@@ -0,0 +1,440 @@
const OPCODE_MASK = 0x7800;
const RCODE_MASK = 0x000f;
const FLAG_AA = 0x0400;
const FLAG_TC = 0x0200;
const FLAG_RD = 0x0100;
const FLAG_RA = 0x0080;
const FLAG_AD = 0x0020;
const RRTYPE_A = 1;
const RRTYPE_NS = 2;
const RRTYPE_CNAME = 5;
const RRTYPE_SOA = 6;
const RRTYPE_PTR = 12;
const RRTYPE_MX = 15;
const RRTYPE_TXT = 16;
const RRTYPE_AAAA = 28;
const RRTYPE_SRV = 33;
const RRTYPE_CAA = 257;
const RCODE_NAMES: Record<number, string> = {
0: "NOERROR",
1: "FORMERR",
2: "SERVFAIL",
3: "NXDOMAIN",
4: "NOTIMP",
5: "REFUSED",
6: "YXDOMAIN",
7: "YXRRSET",
8: "NXRRSET",
9: "NOTAUTH",
10: "NOTZONE",
};
const RRTYPE_NAMES: Record<number, string> = {
[RRTYPE_A]: "A",
[RRTYPE_AAAA]: "AAAA",
[RRTYPE_CAA]: "CAA",
[RRTYPE_CNAME]: "CNAME",
[RRTYPE_MX]: "MX",
[RRTYPE_NS]: "NS",
[RRTYPE_PTR]: "PTR",
[RRTYPE_SOA]: "SOA",
[RRTYPE_SRV]: "SRV",
[RRTYPE_TXT]: "TXT",
};
const RRTYPE_BY_NAME: Record<string, number> = {
A: RRTYPE_A,
AAAA: RRTYPE_AAAA,
CAA: RRTYPE_CAA,
CNAME: RRTYPE_CNAME,
MX: RRTYPE_MX,
NS: RRTYPE_NS,
PTR: RRTYPE_PTR,
SOA: RRTYPE_SOA,
SRV: RRTYPE_SRV,
TXT: RRTYPE_TXT,
};
const CLASS_IN = 1;
export interface DnsAnswer {
class: number;
data: Record<string, unknown>;
name: string;
ttl: number;
type: number;
value: string;
}
export interface DnsFlags {
authenticatedData: boolean;
authoritative: boolean;
recursionAvailable: boolean;
recursionDesired: boolean;
truncated: boolean;
}
export interface DnsHeader {
additionalCount: number;
answerCount: number;
authorityCount: number;
flags: DnsFlags;
id: number;
opcode: number;
questionCount: number;
rcode: number;
}
export interface DnsQuestion {
name: string;
qclass: number;
qtype: number;
}
export interface DnsResponse {
additional: DnsAnswer[];
answers: DnsAnswer[];
authorities: DnsAnswer[];
header: DnsHeader;
questions: DnsQuestion[];
}
interface ParseContext {
data: Uint8Array;
offset: number;
view: DataView;
}
interface RdataResult extends Record<string, unknown> {
value: string;
}
export function buildQuery(name: string, recordType: number, recursionDesired: boolean): Uint8Array {
const queryName = name.endsWith(".") ? name.slice(0, -1) : name;
const labels = queryName === "" ? [] : queryName.split(".");
const encodedLabels: Uint8Array[] = [];
let nameSize = 1;
for (const label of labels) {
if (label === "") {
throw new Error(`无效的 DNS 名称: ${name}`);
}
if (!isAscii(label)) {
throw new Error(`DNS 名称必须使用 ASCII/Punycode: ${name}`);
}
const encoded = new TextEncoder().encode(label);
if (encoded.length > 63) {
throw new Error(`DNS 标签超过 63 字节: ${label}`);
}
nameSize += 1 + encoded.length;
encodedLabels.push(encoded);
}
if (nameSize > 255) {
throw new Error(`DNS 名称超过 255 字节: ${name}`);
}
const questionSize = nameSize + 4;
const buf = new Uint8Array(12 + questionSize);
const view = new DataView(buf.buffer);
const id = (Math.random() * 65536) | 0;
view.setUint16(0, id);
let flags = 0;
if (recursionDesired) flags |= FLAG_RD;
view.setUint16(2, flags);
view.setUint16(4, 1);
view.setUint16(6, 0);
view.setUint16(8, 0);
view.setUint16(10, 0);
let offset = 12;
for (const encoded of encodedLabels) {
buf[offset] = encoded.length;
offset++;
buf.set(encoded, offset);
offset += encoded.length;
}
buf[offset] = 0;
offset++;
view.setUint16(offset, recordType);
offset += 2;
view.setUint16(offset, CLASS_IN);
return buf;
}
export function parseResponse(data: Uint8Array): DnsResponse {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
if (data.byteLength < 12) {
throw new Error(`DNS 响应过短: ${data.byteLength} 字节`);
}
const id = view.getUint16(0);
const flagsWord = view.getUint16(2);
const questionCount = view.getUint16(4);
const answerCount = view.getUint16(6);
const authorityCount = view.getUint16(8);
const additionalCount = view.getUint16(10);
const opcode = (flagsWord & OPCODE_MASK) >> 11;
const rcode = flagsWord & RCODE_MASK;
const flags: DnsFlags = {
authenticatedData: (flagsWord & FLAG_AD) !== 0,
authoritative: (flagsWord & FLAG_AA) !== 0,
recursionAvailable: (flagsWord & FLAG_RA) !== 0,
recursionDesired: (flagsWord & FLAG_RD) !== 0,
truncated: (flagsWord & FLAG_TC) !== 0,
};
const header: DnsHeader = {
additionalCount,
answerCount,
authorityCount,
flags,
id,
opcode,
questionCount,
rcode,
};
const ctx: ParseContext = { data, offset: 12, view };
const questions: DnsQuestion[] = [];
for (let i = 0; i < questionCount; i++) {
const name = readName(ctx);
const qtype = readUint16(ctx);
const qclass = readUint16(ctx);
questions.push({ name, qclass, qtype });
}
const answers = readAnswers(ctx, answerCount);
const authorities = readAnswers(ctx, authorityCount);
const additional = readAnswers(ctx, additionalCount);
return { additional, answers, authorities, header, questions };
}
export function rcodeName(code: number): string {
return RCODE_NAMES[code] ?? `UNKNOWN(${code})`;
}
export function rrtTypeByName(name: string): number {
const code = RRTYPE_BY_NAME[name];
if (code === undefined) throw new Error(`不支持的记录类型: ${name}`);
return code;
}
export function rrtTypeName(code: number): string {
return RRTYPE_NAMES[code] ?? `TYPE${code}`;
}
function isAscii(value: string): boolean {
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) > 0x7f) return false;
}
return true;
}
function normalizeIPv6(groups: string[]): string {
let maxZeroStart = -1;
let maxZeroLen = 0;
let currentStart = -1;
let currentLen = 0;
for (let i = 0; i < 8; i++) {
if (groups[i] === "0") {
if (currentStart === -1) currentStart = i;
currentLen++;
if (currentLen > maxZeroLen) {
maxZeroLen = currentLen;
maxZeroStart = currentStart;
}
} else {
currentStart = -1;
currentLen = 0;
}
}
if (maxZeroLen >= 2) {
const before = groups.slice(0, maxZeroStart).join(":");
const after = groups.slice(maxZeroStart + maxZeroLen).join(":");
if (before === "" && after === "") return "::";
if (before === "") return `::${after}`;
if (after === "") return `${before}::`;
return `${before}::${after}`;
}
return groups.join(":");
}
function parseRdata(ctx: ParseContext, type: number, rdLength: number): RdataResult {
const rdStart = ctx.offset;
const rdEnd = rdStart + rdLength;
switch (type) {
case RRTYPE_A: {
if (rdLength < 4) throw new Error("A 记录 rdata 过短");
const a = ctx.data[rdStart]!;
const b = ctx.data[rdStart + 1]!;
const c = ctx.data[rdStart + 2]!;
const d = ctx.data[rdStart + 3]!;
const address = `${a}.${b}.${c}.${d}`;
return { address, value: address };
}
case RRTYPE_AAAA: {
if (rdLength < 16) throw new Error("AAAA 记录 rdata 过短");
const groups: string[] = [];
for (let i = 0; i < 16; i += 2) {
groups.push(((ctx.data[rdStart + i]! << 8) | ctx.data[rdStart + i + 1]!).toString(16));
}
const address = normalizeIPv6(groups);
return { address, value: address };
}
case RRTYPE_CAA: {
const flags = ctx.data[rdStart]!;
const tagLen = ctx.data[rdStart + 1]!;
const tag = new TextDecoder().decode(ctx.data.subarray(rdStart + 2, rdStart + 2 + tagLen));
const valueBytes = ctx.data.subarray(rdStart + 2 + tagLen, rdEnd);
const caaValue = new TextDecoder().decode(valueBytes);
const value = `${flags} ${tag} ${caaValue}`;
return { flags, tag, value: value, valueStr: caaValue };
}
case RRTYPE_CNAME: {
const target = readName(ctx);
return { target, value: target };
}
case RRTYPE_MX: {
const preference = readUint16(ctx);
const exchange = readName(ctx);
const value = `${preference} ${exchange}`;
return { exchange, preference, value };
}
case RRTYPE_NS: {
const nsdname = readName(ctx);
return { nsdname, value: nsdname };
}
case RRTYPE_PTR: {
const ptrdname = readName(ctx);
return { ptrdname, value: ptrdname };
}
case RRTYPE_SOA: {
const mname = readName(ctx);
const rname = readName(ctx);
const serial = readUint32(ctx);
const refresh = readUint32(ctx);
const retry = readUint32(ctx);
const expire = readUint32(ctx);
const minimum = readUint32(ctx);
const value = `${mname} ${rname} ${serial} ${refresh} ${retry} ${expire} ${minimum}`;
return { expire, minimum, mname, refresh, retry, rname, serial, value };
}
case RRTYPE_SRV: {
const priority = readUint16(ctx);
const weight = readUint16(ctx);
const port = readUint16(ctx);
const target = readName(ctx);
const value = `${priority} ${weight} ${port} ${target}`;
return { port, priority, target, value, weight };
}
case RRTYPE_TXT: {
const texts: string[] = [];
let pos = rdStart;
while (pos < rdEnd) {
const txtLen = ctx.data[pos]!;
pos++;
if (pos + txtLen > rdEnd) break;
texts.push(new TextDecoder().decode(ctx.data.subarray(pos, pos + txtLen)));
pos += txtLen;
}
const fullText = texts.join("");
return { text: fullText, value: fullText };
}
default: {
const raw = Array.from(ctx.data.subarray(rdStart, rdEnd));
const value = raw.map((b) => b.toString(16).padStart(2, "0")).join(" ");
return { raw, value };
}
}
}
function readAnswers(ctx: ParseContext, count: number): DnsAnswer[] {
const answers: DnsAnswer[] = [];
for (let i = 0; i < count; i++) {
const name = readName(ctx);
const type = readUint16(ctx);
const cls = readUint16(ctx);
const ttl = readUint32(ctx);
const rdLength = readUint16(ctx);
const rdStart = ctx.offset;
const data = parseRdata(ctx, type, rdLength);
ctx.offset = rdStart + rdLength;
answers.push({ class: cls, data, name, ttl, type, value: data.value });
}
return answers;
}
function readName(ctx: ParseContext): string {
const parts: string[] = [];
const visited = new Set<number>();
let savedOffset: null | number = null;
let currentOffset = ctx.offset;
while (true) {
if (currentOffset >= ctx.data.byteLength) {
throw new Error("DNS 名称解析越界");
}
const len = ctx.data[currentOffset]!;
if (len === 0) {
currentOffset++;
break;
}
if ((len & 0xc0) === 0xc0) {
if (currentOffset + 1 >= ctx.data.byteLength) {
throw new Error("DNS 压缩指针越界");
}
savedOffset ??= currentOffset + 2;
const ptrOffset = ((len & 0x3f) << 8) | ctx.data[currentOffset + 1]!;
if (visited.has(ptrOffset)) {
throw new Error("DNS 压缩指针循环");
}
visited.add(ptrOffset);
currentOffset = ptrOffset;
continue;
}
currentOffset++;
if (currentOffset + len > ctx.data.byteLength) {
throw new Error("DNS 标签越界");
}
const label = new TextDecoder().decode(ctx.data.subarray(currentOffset, currentOffset + len));
parts.push(label);
currentOffset += len;
}
ctx.offset = savedOffset ?? currentOffset;
return parts.join(".");
}
function readUint16(ctx: ParseContext): number {
const val = ctx.view.getUint16(ctx.offset);
ctx.offset += 2;
return val;
}
function readUint32(ctx: ParseContext): number {
const val = ctx.view.getUint32(ctx.offset);
ctx.offset += 4;
return val;
}

View File

@@ -0,0 +1,901 @@
import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type {
DnsServerConfig,
DnsSystemConfig,
ResolvedDnsServerExpectConfig,
ResolvedDnsSystemExpectConfig,
ResolvedDnsTarget,
} from "./types";
import { errorFailure, mismatchFailure } from "../../expect/failure";
import { checkValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { buildQuery, rcodeName, rrtTypeByName, rrtTypeName } from "./codec";
import {
checkAnswerCount,
checkDnsValues,
checkFlag,
checkRcode,
checkResponded,
checkResult,
checkTtlMax,
checkTtlMin,
checkValueCount,
} from "./expect";
import { dnsCheckerSchemas } from "./schema";
import { queryDns } from "./transport";
import { validateDnsConfig } from "./validate";
const DEFAULT_MAX_RESPONSE_BYTES = 4096;
const DEFAULT_PORT = 53;
const DEFAULT_RECORD_TYPE = "A";
const DEFAULT_PROTOCOL = "udp";
const DEFAULT_TCP_FALLBACK = true;
const DEFAULT_RECURSION_DESIRED = true;
const DEFAULT_FAMILY = "any";
interface LookupAddress {
address: string;
family: number;
}
type LookupOutcome = { addresses: string[]; ok: true } | { error: string; ok: false };
export class DnsChecker implements CheckerDefinition<ResolvedDnsTarget> {
readonly configKey = "dns";
readonly schemas = dnsCheckerSchemas;
readonly type = "dns";
buildDetail(observation: Record<string, unknown>): null | string {
const resolver = observation["resolver"];
const durationMs = observation["durationMs"];
const duration = typeof durationMs === "number" ? `${durationMs}ms` : "?ms";
if (resolver === "system") {
return buildSystemDetail(observation, duration);
}
return buildServerDetail(observation, duration);
}
async execute(t: ResolvedDnsTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
try {
if (t.dns.resolver === "system") {
return await this.executeSystem(t, ctx, timestamp, start);
}
return await this.executeServer(t, ctx, timestamp, start);
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("query", "query", isError(error) ? error.message : String(error)),
matched: false,
observation: null,
targetId: t.id,
timestamp,
};
}
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedDnsTarget {
const dns = target["dns"] as DnsServerConfig | DnsSystemConfig;
if (dns.resolver === "system") {
return this.resolveSystem(target, dns, context);
}
return this.resolveServer(target, dns, context);
}
serialize(t: ResolvedDnsTarget): { config: string; target: string } {
if (t.dns.resolver === "system") {
return {
config: JSON.stringify(t.dns),
target: `dns system ${t.dns.name}`,
};
}
return {
config: JSON.stringify(t.dns),
target: `dns ${t.dns.server}:${t.dns.port} ${t.dns.name}/${t.dns.recordType}`,
};
}
validate(input: CheckerValidationInput) {
return validateDnsConfig(input);
}
private async executeServer(
t: ResolvedDnsTarget,
ctx: CheckerContext,
timestamp: string,
start: number,
): Promise<CheckResult> {
const dns = t.dns as {
maxResponseBytes: number;
name: string;
port: number;
protocol: "tcp" | "udp";
recordType: string;
recursionDesired: boolean;
resolver: "server";
server: string;
tcpFallback: boolean;
};
const expect = t.expect as ResolvedDnsServerExpectConfig | undefined;
const qtype = rrtTypeByName(dns.recordType);
const query = buildQuery(dns.name, qtype, dns.recursionDesired);
const queryResult = await queryDns(dns.server, dns.port, query, {
maxResponseBytes: dns.maxResponseBytes,
protocol: dns.protocol,
signal: ctx.signal,
tcpFallback: dns.tcpFallback,
});
const durationMs = Math.round(performance.now() - start);
if (!queryResult.ok) {
const observation: Record<string, unknown> = {
durationMs,
error: queryResult.error,
name: dns.name,
port: dns.port,
protocol: dns.protocol,
protocolUsed: dns.protocol,
recordType: dns.recordType,
recursionDesired: dns.recursionDesired,
resolver: "server",
responded: false,
server: dns.server,
tcpFallback: dns.tcpFallback,
};
const expectedResponded = expect?.responded ?? true;
if (expectedResponded) {
return {
detail: null,
durationMs,
failure: errorFailure("query", "query", queryResult.error),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
observation,
targetId: t.id,
timestamp,
};
}
const response = queryResult.response;
const rcode = rcodeName(response.header.rcode);
const protocolUsed = queryResult.protocolUsed;
const allAnswers = response.answers;
const targetAnswers = allAnswers.filter((a) => a.type === qtype);
const values = targetAnswers.map((a) => a.value);
const valueCount = values.length;
const answerCount = allAnswers.length;
let ttlMin: null | number = null;
let ttlMax: null | number = null;
if (allAnswers.length > 0) {
const ttls = allAnswers.map((a) => a.ttl);
ttlMin = Math.min(...ttls);
ttlMax = Math.max(...ttls);
}
const cnameChain: string[] = [];
const finalValues: string[] = [];
if (dns.recordType === "A" || dns.recordType === "AAAA") {
const targetType = dns.recordType === "A" ? 1 : 28;
const seen = new Set<string>();
for (const ans of allAnswers) {
if (ans.type === targetType) {
if (!seen.has(ans.value)) {
finalValues.push(ans.value);
seen.add(ans.value);
}
} else if (ans.type === 5) {
cnameChain.push(ans.value);
}
}
} else {
for (const v of values) {
if (!finalValues.includes(v)) {
finalValues.push(v);
}
}
}
const effectiveValueCount = dns.recordType === "A" || dns.recordType === "AAAA" ? finalValues.length : valueCount;
const effectiveValues = dns.recordType === "A" || dns.recordType === "AAAA" ? finalValues : values;
const observation: Record<string, unknown> = {
additionalCount: response.header.additionalCount,
answerCount,
answers: allAnswers.map((a) => ({
class: a.class === 1 ? "IN" : a.class,
data: a.data,
name: a.name,
ttl: a.ttl,
type: rrtTypeName(a.type),
value: a.value,
})),
authorityCount: response.header.authorityCount,
cnameChain,
durationMs,
error: null,
flags: {
authenticatedData: response.header.flags.authenticatedData,
authoritative: response.header.flags.authoritative,
recursionAvailable: response.header.flags.recursionAvailable,
recursionDesired: response.header.flags.recursionDesired,
truncated: response.header.flags.truncated,
},
name: dns.name,
port: dns.port,
protocol: dns.protocol,
protocolUsed,
rcode,
recordType: dns.recordType,
recursionDesired: dns.recursionDesired,
resolver: "server",
responded: true,
server: dns.server,
tcpFallback: dns.tcpFallback,
ttlMax,
ttlMin,
valueCount: effectiveValueCount,
values: effectiveValues,
};
if (!expect) {
const defaultRcodeOk = rcode === "NOERROR";
const defaultCountOk = effectiveValueCount > 0;
if (!defaultRcodeOk) {
return {
detail: null,
durationMs,
failure: mismatchFailure("rcode", "rcode", "NOERROR", rcode, `DNS 响应码: ${rcode}`),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
if (!defaultCountOk) {
return {
detail: null,
durationMs,
failure: mismatchFailure("valueCount", "valueCount", "> 0", 0, "DNS 响应成功但无目标记录"),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
observation,
targetId: t.id,
timestamp,
};
}
const expectedResponded = expect.responded;
const respondedResult = checkResponded(true, expectedResponded);
if (!respondedResult.matched) {
return {
detail: null,
durationMs,
failure: respondedResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
if (expect.rcode) {
const rcodeResult = checkRcode(rcode, expect.rcode);
if (!rcodeResult.matched) {
return {
detail: null,
durationMs,
failure: rcodeResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
const hasExplicitNonNoerrorRcode = expect.rcode && !expect.rcode.includes("NOERROR");
if (expect.values) {
const valuesResult = checkDnsValues(effectiveValues, expect.values);
if (!valuesResult.matched) {
return {
detail: null,
durationMs,
failure: valuesResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
if (expect.valueCount) {
const countResult = checkValueCount(effectiveValueCount, expect.valueCount);
if (!countResult.matched) {
return {
detail: null,
durationMs,
failure: countResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
} else if (!hasExplicitNonNoerrorRcode && !expect.values) {
if (effectiveValueCount === 0) {
return {
detail: null,
durationMs,
failure: mismatchFailure("valueCount", "valueCount", "> 0", 0, "DNS 响应成功但无目标记录"),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
if (expect.answerCount) {
const countResult = checkAnswerCount(answerCount, expect.answerCount);
if (!countResult.matched) {
return {
detail: null,
durationMs,
failure: countResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
if (expect.ttlMin !== undefined) {
if (ttlMin === null) {
return {
detail: null,
durationMs,
failure: mismatchFailure("ttlMin", "ttlMin", "可用 TTL", null, "响应中没有可用于 ttlMin 的 answer"),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const ttlResult = checkTtlMin(ttlMin, expect.ttlMin);
if (!ttlResult.matched) {
return {
detail: null,
durationMs,
failure: ttlResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
if (expect.ttlMax !== undefined) {
if (ttlMax === null) {
return {
detail: null,
durationMs,
failure: mismatchFailure("ttlMax", "ttlMax", "可用 TTL", null, "响应中没有可用于 ttlMax 的 answer"),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const ttlResult = checkTtlMax(ttlMax, expect.ttlMax);
if (!ttlResult.matched) {
return {
detail: null,
durationMs,
failure: ttlResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
const authoritativeResult = checkFlag(response.header.flags.authoritative, expect.authoritative, "authoritative");
if (authoritativeResult && !authoritativeResult.matched) {
return {
detail: null,
durationMs,
failure: authoritativeResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const raResult = checkFlag(
response.header.flags.recursionAvailable,
expect.recursionAvailable,
"recursionAvailable",
);
if (raResult && !raResult.matched) {
return {
detail: null,
durationMs,
failure: raResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const tcResult = checkFlag(response.header.flags.truncated, expect.truncated, "truncated");
if (tcResult && !tcResult.matched) {
return {
detail: null,
durationMs,
failure: tcResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const adResult = checkFlag(response.header.flags.authenticatedData, expect.authenticatedData, "authenticatedData");
if (adResult && !adResult.matched) {
return {
detail: null,
durationMs,
failure: adResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
if (expect.result) {
const resultExpectation = checkResult(observation, expect.result);
if (!resultExpectation.matched) {
return {
detail: null,
durationMs,
failure: resultExpectation.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
const durationResult = checkValueExpectation(durationMs, expect.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
observation,
targetId: t.id,
timestamp,
};
}
private async executeSystem(
t: ResolvedDnsTarget,
ctx: CheckerContext,
timestamp: string,
start: number,
): Promise<CheckResult> {
const dns = t.dns as { family: string; name: string; resolver: "system" };
const expect = t.expect;
try {
const familyOption = dns.family === "ipv4" ? 4 : dns.family === "ipv6" ? 6 : 0;
const result = await lookupWithAbort(dns.name, familyOption, ctx.signal);
const durationMs = Math.round(performance.now() - start);
if (!result.ok) {
const observation: Record<string, unknown> = {
durationMs,
error: result.error,
family: dns.family,
name: dns.name,
resolver: "system",
valueCount: 0,
values: [],
};
const defaultExpect = !expect;
if (defaultExpect) {
return {
detail: null,
durationMs,
failure: errorFailure("resolve", "resolve", result.error),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: errorFailure("resolve", "resolve", result.error),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const values = result.addresses;
const valueCount = values.length;
const observation: Record<string, unknown> = {
durationMs,
error: null,
family: dns.family,
name: dns.name,
resolver: "system",
valueCount,
values,
};
if (!expect) {
const defaultMatched = valueCount > 0;
if (!defaultMatched) {
return {
detail: null,
durationMs,
failure: mismatchFailure("valueCount", "valueCount", "> 0", 0, "解析成功但未返回任何地址"),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
observation,
targetId: t.id,
timestamp,
};
}
if (expect.values) {
const valuesResult = checkDnsValues(values, expect.values);
if (!valuesResult.matched) {
return {
detail: null,
durationMs,
failure: valuesResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
if (expect.valueCount) {
const countResult = checkValueCount(valueCount, expect.valueCount);
if (!countResult.matched) {
return {
detail: null,
durationMs,
failure: countResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
const durationResult = checkValueExpectation(durationMs, expect.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
observation,
targetId: t.id,
timestamp,
};
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("resolve", "resolve", isError(error) ? error.message : String(error)),
matched: false,
observation: null,
targetId: t.id,
timestamp,
};
}
}
private resolveServer(t: RawTargetConfig, dns: DnsServerConfig, context: ResolveContext): ResolvedDnsTarget {
const expect = t.expect as ResolvedDnsServerExpectConfig | undefined;
const resolvedExpect: ResolvedDnsServerExpectConfig | undefined = expect
? {
...expect,
responded: expect.responded ?? true,
}
: undefined;
return {
description: null,
dns: {
maxResponseBytes: parseSize(dns.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES),
name: dns.name,
port: dns.port ?? DEFAULT_PORT,
protocol: dns.protocol ?? DEFAULT_PROTOCOL,
recordType: dns.recordType ?? DEFAULT_RECORD_TYPE,
recursionDesired: dns.recursionDesired ?? DEFAULT_RECURSION_DESIRED,
resolver: "server",
server: dns.server,
tcpFallback: dns.tcpFallback ?? DEFAULT_TCP_FALLBACK,
},
expect: resolvedExpect,
group: t.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
timeoutMs: context.defaultTimeoutMs,
type: "dns",
} satisfies ResolvedDnsTarget;
}
private resolveSystem(t: RawTargetConfig, dns: DnsSystemConfig, context: ResolveContext): ResolvedDnsTarget {
const expect = t.expect as ResolvedDnsSystemExpectConfig | undefined;
const resolvedExpect: ResolvedDnsSystemExpectConfig | undefined = expect ? { ...expect } : undefined;
return {
description: null,
dns: {
family: dns.family ?? DEFAULT_FAMILY,
name: dns.name,
resolver: "system",
},
expect: resolvedExpect,
group: t.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
timeoutMs: context.defaultTimeoutMs,
type: "dns",
} satisfies ResolvedDnsTarget;
}
}
function buildServerDetail(observation: Record<string, unknown>, duration: string): null | string {
const responded = observation["responded"];
const error = observation["error"];
const rcode = observation["rcode"];
const protocolUsed = observation["protocolUsed"];
const cnameChain = observation["cnameChain"];
if (responded !== true) {
if (typeof error === "string") {
return `查询失败: ${error} (${duration})`;
}
return `未收到响应 (${duration})`;
}
const rcodeStr = typeof rcode === "string" ? rcode : "UNKNOWN";
const valueCount = observation["valueCount"];
const count = typeof valueCount === "number" ? valueCount : 0;
const values = observation["values"];
const addrs = Array.isArray(values) ? values : [];
const protoStr = typeof protocolUsed === "string" ? protocolUsed.toUpperCase() : "?";
const parts: string[] = [rcodeStr];
if (Array.isArray(cnameChain) && cnameChain.length > 0) {
parts.push(`CNAME: ${cnameChain.join(" → ")}`);
}
if (count > 0) {
const preview = addrs.slice(0, 3).join(", ");
const suffix = count > 3 ? `${count}` : "";
parts.push(`${preview}${suffix}`);
}
parts.push(`${protoStr} ${duration}`);
return parts.join(", ");
}
function buildSystemDetail(observation: Record<string, unknown>, duration: string): null | string {
const error = observation["error"];
const valueCount = observation["valueCount"];
const values = observation["values"];
if (typeof error === "string") {
return `解析失败: ${error} (${duration})`;
}
const count = typeof valueCount === "number" ? valueCount : 0;
const addrs = Array.isArray(values) ? values : [];
if (count === 0) {
return `解析成功但无结果 (${duration})`;
}
const preview = addrs.slice(0, 3).join(", ");
const suffix = count > 3 ? `${count}` : "";
return `${preview}${suffix} (${duration})`;
}
async function lookupWithAbort(hostname: string, family: number, signal: AbortSignal): Promise<LookupOutcome> {
if (signal.aborted) {
return { error: "探测已取消", ok: false };
}
return new Promise<LookupOutcome>((resolve) => {
let settled = false;
const onAbort = () => {
if (settled) return;
settled = true;
resolve({ error: "探测超时", ok: false });
};
signal.addEventListener("abort", onAbort, { once: true });
const onResult = (err: NodeJS.ErrnoException | null, address: LookupAddress[] | string | string[]) => {
if (settled) return;
settled = true;
signal.removeEventListener("abort", onAbort);
if (err) {
resolve({ error: err.message, ok: false });
return;
}
const addresses = Array.isArray(address)
? address.map((item) => (typeof item === "string" ? item : item.address))
: [address];
resolve({ addresses, ok: true });
};
try {
import("node:dns")
.then((dns) => {
if (family === 0) {
dns.lookup(hostname, { all: true }, (err, address) => onResult(err, address));
} else {
dns.lookup(hostname, { all: true, family }, (err, address) => onResult(err, address));
}
})
.catch((e) => {
if (!settled) {
settled = true;
signal.removeEventListener("abort", onAbort);
resolve({ error: e instanceof Error ? e.message : String(e), ok: false });
}
});
} catch (e) {
if (!settled) {
settled = true;
signal.removeEventListener("abort", onAbort);
resolve({ error: e instanceof Error ? e.message : String(e), ok: false });
}
}
});
}

View File

@@ -0,0 +1,120 @@
import type { ContentExpectations, ExpectationResult, ValueExpectation } from "../../expect/types";
import type { DnsValuesExpectation } from "./types";
import { checkContentExpectations } from "../../expect/content";
import { mismatchFailure } from "../../expect/failure";
import { checkValueExpectation } from "../../expect/value";
export function checkAnswerCount(actual: number, matcher: ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "answer 数量不满足条件",
path: "answerCount",
phase: "answerCount",
});
}
export function checkDnsValues(actualValues: string[], expectation: DnsValuesExpectation): ExpectationResult {
const actualSet = new Set(actualValues);
if (expectation.exact) {
const expectedSet = new Set(expectation.exact);
if (actualSet.size !== expectedSet.size || ![...expectedSet].every((v) => actualSet.has(v))) {
return {
failure: mismatchFailure(
"values",
"values",
expectation.exact,
actualValues,
"values 集合不匹配exact 忽略顺序)",
),
matched: false,
};
}
}
if (expectation.include) {
for (const v of expectation.include) {
if (!actualSet.has(v)) {
return {
failure: mismatchFailure("values", "values", `包含 ${v}`, actualValues, `values 缺少期望值: ${v}`),
matched: false,
};
}
}
}
if (expectation.exclude) {
for (const v of expectation.exclude) {
if (actualSet.has(v)) {
return {
failure: mismatchFailure("values", "values", `排除 ${v}`, actualValues, `values 包含排除值: ${v}`),
matched: false,
};
}
}
}
return { failure: null, matched: true };
}
export function checkFlag(actual: boolean, expected: boolean | undefined, name: string): ExpectationResult | null {
if (expected === undefined) return null;
if (actual === expected) return { failure: null, matched: true };
return {
failure: mismatchFailure(name, name, expected, actual, `${name} 不匹配`),
matched: false,
};
}
export function checkRcode(actual: string, expected: string[]): ExpectationResult {
if (expected.includes(actual)) return { failure: null, matched: true };
return {
failure: mismatchFailure("rcode", "rcode", expected.join(", "), actual, `RCODE 不在期望列表中`),
matched: false,
};
}
export function checkResponded(responded: boolean, expected: boolean): ExpectationResult {
if (responded === expected) return { failure: null, matched: true };
if (!responded && expected) {
return {
failure: mismatchFailure("responded", "responded", true, false, "期望收到响应但未收到"),
matched: false,
};
}
return {
failure: mismatchFailure("responded", "responded", false, true, "期望无响应但收到响应"),
matched: false,
};
}
export function checkResult(
observation: Record<string, unknown>,
expectations: ContentExpectations,
): ExpectationResult {
return checkContentExpectations(JSON.stringify(observation), expectations, { path: "result", phase: "result" });
}
export function checkTtlMax(actual: number, matcher: ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "最大 TTL 不满足条件",
path: "ttlMax",
phase: "ttlMax",
});
}
export function checkTtlMin(actual: number, matcher: ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "最小 TTL 不满足条件",
path: "ttlMin",
phase: "ttlMin",
});
}
export function checkValueCount(actual: number, matcher: ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "value 数量不满足条件",
path: "valueCount",
phase: "valueCount",
});
}

View File

@@ -0,0 +1 @@
export { DnsChecker } from "./execute";

View File

@@ -0,0 +1,107 @@
import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createAuthoringContentExpectationsSchema,
createAuthoringFieldSchema,
createAuthoringValueExpectationSchema,
createNormalizedContentExpectationsSchema,
createNormalizedValueExpectationSchema,
sizeSchema,
} from "../../schema/fragments";
const RECORD_TYPES = ["A", "AAAA", "CAA", "CNAME", "MX", "NS", "PTR", "SOA", "SRV", "TXT"] as const;
export const dnsCheckerSchemas: CheckerSchemas = {
authoring: {
config: createDnsConfigSchema("authoring"),
expect: createDnsExpectSchema("authoring"),
},
normalized: {
config: createDnsConfigSchema("normalized"),
expect: createDnsExpectSchema("normalized"),
},
};
function createDnsConfigSchema(kind: "authoring" | "normalized") {
const recordType = createRecordTypeSchema();
const family = Type.Union([Type.Literal("any"), Type.Literal("ipv4"), Type.Literal("ipv6")]);
const systemFields = {
family: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(family) : family),
name:
kind === "authoring" ? createAuthoringFieldSchema(Type.String({ minLength: 1 })) : Type.String({ minLength: 1 }),
resolver: Type.Literal("system"),
};
const serverFields = {
maxResponseBytes: Type.Optional(sizeSchema),
name:
kind === "authoring" ? createAuthoringFieldSchema(Type.String({ minLength: 1 })) : Type.String({ minLength: 1 }),
port: Type.Optional(
kind === "authoring"
? createAuthoringFieldSchema(Type.Integer({ maximum: 65535, minimum: 1 }))
: Type.Integer({ maximum: 65535, minimum: 1 }),
),
protocol: Type.Optional(
kind === "authoring"
? createAuthoringFieldSchema(Type.Union([Type.Literal("udp"), Type.Literal("tcp")]))
: Type.Union([Type.Literal("udp"), Type.Literal("tcp")]),
),
recordType: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(recordType) : recordType),
recursionDesired: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(Type.Boolean()) : Type.Boolean()),
resolver: Type.Literal("server"),
server:
kind === "authoring" ? createAuthoringFieldSchema(Type.String({ minLength: 1 })) : Type.String({ minLength: 1 }),
tcpFallback: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(Type.Boolean()) : Type.Boolean()),
};
return Type.Union(
[
Type.Object(systemFields, { additionalProperties: false }),
Type.Object(serverFields, { additionalProperties: false }),
],
{},
);
}
function createDnsExpectSchema(kind: "authoring" | "normalized") {
const valueSchema =
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema();
const contentSchema =
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema();
const expectFields = {
answerCount: Type.Optional(valueSchema),
authenticatedData: Type.Optional(Type.Boolean()),
authoritative: Type.Optional(Type.Boolean()),
durationMs: Type.Optional(valueSchema),
rcode: Type.Optional(Type.Array(Type.String())),
recursionAvailable: Type.Optional(Type.Boolean()),
responded: Type.Optional(Type.Boolean()),
result: Type.Optional(contentSchema),
truncated: Type.Optional(Type.Boolean()),
ttlMax: Type.Optional(valueSchema),
ttlMin: Type.Optional(valueSchema),
valueCount: Type.Optional(valueSchema),
values: Type.Optional(createDnsValuesExpectationSchema()),
};
return Type.Object(expectFields, { additionalProperties: false });
}
function createDnsValuesExpectationSchema() {
return Type.Object(
{
exact: Type.Optional(Type.Array(Type.String())),
exclude: Type.Optional(Type.Array(Type.String())),
include: Type.Optional(Type.Array(Type.String())),
},
{ additionalProperties: false },
);
}
function createRecordTypeSchema() {
return Type.Union(RECORD_TYPES.map((t) => Type.Literal(t)));
}

View File

@@ -0,0 +1,349 @@
import type { DnsResponse } from "./codec";
import { parseResponse } from "./codec";
export type DnsQueryResult = DnsTransportError | DnsTransportResult;
export interface DnsTransportError {
error: string;
ok: false;
}
export interface DnsTransportResult {
data: Uint8Array;
ok: true;
protocolUsed: "tcp" | "udp";
response: DnsResponse;
}
interface QueryMeta {
id: number;
name: string;
qclass: number;
qtype: number;
}
export async function queryDns(
server: string,
port: number,
query: Uint8Array,
options: {
maxResponseBytes: number;
protocol: "tcp" | "udp";
signal: AbortSignal;
tcpFallback: boolean;
},
): Promise<DnsQueryResult> {
if (options.protocol === "tcp") {
return queryTcp(server, port, query, options.signal, options.maxResponseBytes);
}
const udpResult = await queryUdp(server, port, query, options.signal, options.maxResponseBytes);
if (!udpResult.ok) return udpResult;
if (udpResult.response.header.flags.truncated && options.tcpFallback) {
const tcpResult = await queryTcp(server, port, query, options.signal, options.maxResponseBytes);
if (tcpResult.ok) {
return { ...tcpResult, protocolUsed: "tcp" };
}
return udpResult;
}
return udpResult;
}
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;
}
function parseAndValidateResponse(query: Uint8Array, payload: Uint8Array, protocolUsed: "tcp" | "udp"): DnsQueryResult {
try {
const response = parseResponse(payload);
const validationError = validateResponseForQuery(query, response);
if (validationError) {
return { error: validationError, ok: false };
}
return { data: payload, ok: true, protocolUsed, response };
} catch (e) {
return { error: `DNS 响应解析失败: ${e instanceof Error ? e.message : String(e)}`, ok: false };
}
}
async function queryTcp(
server: string,
port: number,
query: Uint8Array,
signal: AbortSignal,
maxResponseBytes: number,
): Promise<DnsQueryResult> {
const chunks: Uint8Array[] = [];
let totalBytes = 0;
let settled = false;
let resolver: ((value: DnsQueryResult) => void) | undefined;
const promise = new Promise<DnsQueryResult>((resolve) => {
resolver = resolve;
});
const settle = (result: DnsQueryResult) => {
if (settled) return;
settled = true;
resolver!(result);
};
const socketHandlers: Record<string, (...args: unknown[]) => void> = {
close() {
if (totalBytes >= 2) {
const full = mergeChunks(chunks, totalBytes);
const view = new DataView(full.buffer, full.byteOffset, full.byteLength);
const respLen = view.getUint16(0);
const payloadLen = Math.min(respLen, maxResponseBytes);
if (totalBytes - 2 >= payloadLen) {
if (respLen > maxResponseBytes) {
settle({ error: `TCP 响应超过 ${maxResponseBytes} 字节限制 (${respLen} bytes)`, ok: false });
return;
}
const payload = full.subarray(2, 2 + payloadLen);
settle(parseAndValidateResponse(query, payload, "tcp"));
} else {
settle({ error: `TCP 响应不完整: 期望 ${respLen} 字节,收到 ${totalBytes - 2} 字节`, ok: false });
}
} else {
settle({ error: "TCP 连接关闭,未收到响应", ok: false });
}
},
data(_socket: unknown, data: unknown) {
const buf = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer);
if (totalBytes + buf.byteLength > maxResponseBytes + 2) {
const trimmed = buf.subarray(0, maxResponseBytes + 2 - totalBytes);
if (trimmed.byteLength > 0) {
chunks.push(new Uint8Array(trimmed));
totalBytes += trimmed.byteLength;
}
} else {
chunks.push(new Uint8Array(buf));
totalBytes += buf.byteLength;
}
if (totalBytes >= 2) {
const full = mergeChunks(chunks, totalBytes);
const view = new DataView(full.buffer, full.byteOffset, full.byteLength);
const respLen = view.getUint16(0);
const payloadLen = Math.min(respLen, maxResponseBytes);
if (totalBytes - 2 >= payloadLen) {
try {
(_socket as { close(): void }).close();
} catch {
/* best-effort */
}
}
}
},
error(_socket: unknown, error: unknown) {
settle({ error: error instanceof Error ? error.message : String(error), ok: false });
},
open() {
// Bun socket handler 必填项,连接成功由 Bun.connect() resolve 表示
},
};
const onAbort = () => {
settle({ error: "探测超时", ok: false });
};
signal.addEventListener("abort", onAbort, { once: true });
try {
const socket = await Bun.connect({
hostname: server,
port,
socket: socketHandlers,
});
if (signal.aborted) {
try {
socket.close();
} catch {
/* best-effort */
}
signal.removeEventListener("abort", onAbort);
return promise;
}
const lengthBuf = new Uint8Array(2);
new DataView(lengthBuf.buffer).setUint16(0, query.byteLength);
socket.write(lengthBuf);
socket.write(query);
const result = await promise;
signal.removeEventListener("abort", onAbort);
try {
socket.close();
} catch {
/* best-effort */
}
return result;
} catch (error) {
signal.removeEventListener("abort", onAbort);
if (signal.aborted) {
return { error: "探测超时", ok: false };
}
const message = error instanceof Error ? error.message : String(error);
return { error: simplifyError(message), ok: false };
}
}
async function queryUdp(
server: string,
port: number,
query: Uint8Array,
signal: AbortSignal,
maxResponseBytes: number,
): Promise<DnsQueryResult> {
try {
const socket = await Bun.udpSocket({
connect: { hostname: server, port },
socket: {
data(socket, data) {
if (data.byteLength > maxResponseBytes) {
settle({ error: `UDP 响应超过 ${maxResponseBytes} 字节限制 (${data.byteLength} bytes)`, type: "error" });
try {
socket.close();
} catch {
/* best-effort */
}
return;
}
settle({ data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength), type: "data" });
try {
socket.close();
} catch {
/* best-effort */
}
},
drain() {
// Bun UDP socket handler 必填项DNS checker 不关注 drain 事件
},
error(_socket, error) {
settle({ error: error.message, type: "error" });
try {
_socket.close();
} catch {
/* best-effort */
}
},
},
});
if (signal.aborted) {
try {
socket.close();
} catch {
/* best-effort */
}
return { error: "探测已取消", ok: false };
}
let settled = false;
let resolver: ((value: { data?: Uint8Array; error?: string; type: string }) => void) | undefined;
const promise = new Promise<{ data?: Uint8Array; error?: string; type: string }>((resolve) => {
resolver = resolve;
});
const settle = (result: { data?: Uint8Array; error?: string; type: string }) => {
if (settled) return;
settled = true;
resolver!(result);
};
const onAbort = () => {
settle({ type: "abort" });
try {
socket.close();
} catch {
/* best-effort */
}
};
signal.addEventListener("abort", onAbort, { once: true });
socket.send(query);
const result = await promise;
signal.removeEventListener("abort", onAbort);
if (result.type === "error") {
return { error: result.error ?? "UDP 查询失败", ok: false };
}
if (result.type === "abort") {
return { error: "探测超时", ok: false };
}
if (!result.data) {
return { error: "未收到 UDP 响应", ok: false };
}
return parseAndValidateResponse(query, result.data, "udp");
} catch (error) {
if (signal.aborted) {
return { error: "探测超时", ok: false };
}
const message = error instanceof Error ? error.message : String(error);
return { error: simplifyError(message), ok: false };
}
}
function readQueryMeta(query: Uint8Array): null | QueryMeta {
if (query.byteLength < 12) return null;
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const id = view.getUint16(0);
const labels: string[] = [];
let offset = 12;
while (true) {
if (offset >= query.byteLength) return null;
const len = query[offset]!;
offset++;
if (len === 0) break;
if ((len & 0xc0) !== 0 || offset + len > query.byteLength) return null;
labels.push(new TextDecoder().decode(query.subarray(offset, offset + len)));
offset += len;
}
if (offset + 4 > query.byteLength) return null;
const qtype = view.getUint16(offset);
const qclass = view.getUint16(offset + 2);
return { id, name: labels.join("."), qclass, qtype };
}
function simplifyError(message: string): string {
const lower = message.toLowerCase();
if (lower.includes("econnrefused") || lower.includes("connection refused")) return "connection refused";
if (lower.includes("enoent") || lower.includes("not found")) return "host not found";
if (lower.includes("etimedout") || lower.includes("timed out")) return "timed out";
if (lower.includes("econnreset") || lower.includes("reset")) return "connection reset";
if (lower.includes("enetwork") || lower.includes("network")) return "network error";
return message;
}
function validateResponseForQuery(query: Uint8Array, response: DnsResponse): null | string {
const meta = readQueryMeta(query);
if (!meta) return "DNS 查询报文不完整";
if (response.header.id !== meta.id) {
return `DNS 响应 ID 不匹配: 期望 ${meta.id},实际 ${response.header.id}`;
}
const question = response.questions[0];
if (question && (question.name !== meta.name || question.qtype !== meta.qtype || question.qclass !== meta.qclass)) {
return "DNS 响应 question 与查询不匹配";
}
return null;
}

View File

@@ -0,0 +1,118 @@
import type {
ContentExpectations,
RawContentExpectations,
RawValueExpectation,
ValueExpectation,
} from "../../expect/types";
import type { ResolvedTargetBase } from "../../types";
export type DnsFamily = "any" | "ipv4" | "ipv6";
export type DnsProtocol = "tcp" | "udp";
export type DnsRecordType = "A" | "AAAA" | "CAA" | "CNAME" | "MX" | "NS" | "PTR" | "SOA" | "SRV" | "TXT";
export type DnsResolver = "server" | "system";
export interface DnsServerConfig {
maxResponseBytes?: number | string;
name: string;
port?: number;
protocol?: DnsProtocol;
recordType?: DnsRecordType;
recursionDesired?: boolean;
resolver: "server";
server: string;
tcpFallback?: boolean;
}
export interface DnsSystemConfig {
family?: DnsFamily;
name: string;
resolver: "system";
}
export type DnsTargetConfig = DnsServerConfig | DnsSystemConfig;
export interface DnsValuesExpectation {
exact?: string[];
exclude?: string[];
include?: string[];
}
export interface RawDnsExpectConfig {
answerCount?: RawValueExpectation;
authenticatedData?: boolean;
authoritative?: boolean;
durationMs?: RawValueExpectation;
rcode?: string[];
recursionAvailable?: boolean;
responded?: boolean;
result?: RawContentExpectations;
truncated?: boolean;
ttlMax?: RawValueExpectation;
ttlMin?: RawValueExpectation;
valueCount?: RawValueExpectation;
values?: DnsValuesExpectation;
}
export interface RawDnsServerExpectConfig extends RawDnsExpectConfig {
responded?: boolean;
}
export interface RawDnsSystemExpectConfig {
durationMs?: RawValueExpectation;
valueCount?: RawValueExpectation;
values?: DnsValuesExpectation;
}
export type ResolvedDnsConfig = ResolvedDnsServerConfig | ResolvedDnsSystemConfig;
export type ResolvedDnsExpectConfig = ResolvedDnsServerExpectConfig | ResolvedDnsSystemExpectConfig;
export interface ResolvedDnsServerConfig {
maxResponseBytes: number;
name: string;
port: number;
protocol: DnsProtocol;
recordType: DnsRecordType;
recursionDesired: boolean;
resolver: "server";
server: string;
tcpFallback: boolean;
}
export interface ResolvedDnsServerExpectConfig {
answerCount?: ValueExpectation;
authenticatedData?: boolean;
authoritative?: boolean;
durationMs?: ValueExpectation;
rcode?: string[];
recursionAvailable?: boolean;
responded: boolean;
result?: ContentExpectations;
truncated?: boolean;
ttlMax?: ValueExpectation;
ttlMin?: ValueExpectation;
valueCount?: ValueExpectation;
values?: DnsValuesExpectation;
}
export interface ResolvedDnsSystemConfig {
family: DnsFamily;
name: string;
resolver: "system";
}
export interface ResolvedDnsSystemExpectConfig {
durationMs?: ValueExpectation;
valueCount?: ValueExpectation;
values?: DnsValuesExpectation;
}
export interface ResolvedDnsTarget extends ResolvedTargetBase {
dns: ResolvedDnsConfig;
expect?: ResolvedDnsExpectConfig;
group: string;
intervalMs: number;
name: null | string;
timeoutMs: number;
type: "dns";
}

View File

@@ -0,0 +1,351 @@
import { isNumber, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { isPlainRecord, validateRawContentExpectations, validateRawValueExpectation } from "../../expect/validate";
import { issue, joinPath } from "../../schema/issues";
import { parseSize } from "../../utils";
const VALID_RECORD_TYPES = new Set(["A", "AAAA", "CAA", "CNAME", "MX", "NS", "PTR", "SOA", "SRV", "TXT"]);
const VALID_FAMILIES = new Set(["any", "ipv4", "ipv6"]);
const VALID_PROTOCOLS = new Set(["tcp", "udp"]);
const VALID_RCODES = new Set([
"FORMERR",
"NOERROR",
"NOTAUTH",
"NOTIMP",
"NOTZONE",
"NXDOMAIN",
"NXRRSET",
"REFUSED",
"SERVFAIL",
"YXDOMAIN",
"YXRRSET",
]);
const SYSTEM_EXPECT_KEYS = new Set(["durationMs", "valueCount", "values"]);
const SERVER_EXPECT_KEYS = new Set([
"answerCount",
"authenticatedData",
"authoritative",
"durationMs",
"rcode",
"recursionAvailable",
"responded",
"result",
"truncated",
"ttlMax",
"ttlMin",
"valueCount",
"values",
]);
const RESPONSE_EXPECT_KEYS = new Set([
"answerCount",
"authenticatedData",
"authoritative",
"rcode",
"recursionAvailable",
"result",
"truncated",
"ttlMax",
"ttlMin",
"valueCount",
"values",
]);
export function validateDnsConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isPlainRecord(target)) continue;
if (target["type"] !== "dns") continue;
issues.push(...validateDnsTarget(target, `targets[${i}]`));
}
return issues;
}
function getTargetName(target: Record<string, unknown>): string | undefined {
if (isString(target["name"])) return target["name"];
return isString(target["id"]) ? target["id"] : undefined;
}
function validateDnsExpect(target: Record<string, unknown>, path: string, resolver: string): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isPlainRecord(expect)) return [];
const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect");
const allowedKeys = resolver === "system" ? SYSTEM_EXPECT_KEYS : SERVER_EXPECT_KEYS;
for (const key of Object.keys(expect)) {
if (!allowedKeys.has(key)) {
if (resolver === "system") {
issues.push(
issue(
"dns-unsupported-expect",
joinPath(expectPath, key),
`不支持在 dns.resolver: system 下使用system 模式仅支持 expect.values、expect.valueCount、expect.durationMs。请改用 resolver: server 或移除该字段`,
targetName,
),
);
} else {
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
}
}
}
if (expect["values"] !== undefined) {
issues.push(...validateDnsValuesExpectation(expect["values"], joinPath(expectPath, "values"), targetName));
}
if (expect["valueCount"] !== undefined) {
issues.push(...validateRawValueExpectation(expect["valueCount"], joinPath(expectPath, "valueCount"), targetName));
}
if (expect["durationMs"] !== undefined) {
issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
}
if (resolver === "server") {
if (expect["responded"] !== undefined && typeof expect["responded"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "responded"), "必须为布尔值", targetName));
}
if (expect["responded"] === false) {
for (const key of Object.keys(expect)) {
if (RESPONSE_EXPECT_KEYS.has(key)) {
issues.push(
issue(
"invalid-value",
joinPath(expectPath, "responded"),
"响应内容或协议断言需要 expect.responded 为 true",
targetName,
),
);
break;
}
}
}
if (expect["rcode"] !== undefined) {
if (!Array.isArray(expect["rcode"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "rcode"), "必须为字符串数组", targetName));
} else {
const rcodeArray = expect["rcode"] as unknown[];
for (let j = 0; j < rcodeArray.length; j++) {
const rcode = rcodeArray[j];
if (!isString(rcode) || !VALID_RCODES.has(rcode)) {
issues.push(
issue(
"invalid-value",
joinPath(expectPath, `rcode[${j}]`),
`必须是有效的 RCODE如 NOERROR、NXDOMAIN、SERVFAIL`,
targetName,
),
);
}
}
}
}
if (expect["answerCount"] !== undefined) {
issues.push(
...validateRawValueExpectation(expect["answerCount"], joinPath(expectPath, "answerCount"), targetName),
);
}
if (expect["ttlMin"] !== undefined) {
issues.push(...validateRawValueExpectation(expect["ttlMin"], joinPath(expectPath, "ttlMin"), targetName));
}
if (expect["ttlMax"] !== undefined) {
issues.push(...validateRawValueExpectation(expect["ttlMax"], joinPath(expectPath, "ttlMax"), targetName));
}
if (expect["authoritative"] !== undefined && typeof expect["authoritative"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "authoritative"), "必须为布尔值", targetName));
}
if (expect["recursionAvailable"] !== undefined && typeof expect["recursionAvailable"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "recursionAvailable"), "必须为布尔值", targetName));
}
if (expect["truncated"] !== undefined && typeof expect["truncated"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "truncated"), "必须为布尔值", targetName));
}
if (expect["authenticatedData"] !== undefined && typeof expect["authenticatedData"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "authenticatedData"), "必须为布尔值", targetName));
}
if (expect["result"] !== undefined) {
issues.push(...validateRawContentExpectations(expect["result"], joinPath(expectPath, "result"), targetName));
}
}
return issues;
}
function validateDnsTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const dns = target["dns"];
if (!isPlainRecord(dns)) {
issues.push(issue("required", joinPath(path, "dns"), "缺少 dns 配置分组", targetName));
return issues;
}
const resolver: unknown = dns["resolver"];
if (resolver === undefined) {
issues.push(issue("required", joinPath(joinPath(path, "dns"), "resolver"), "缺少 dns.resolver 字段", targetName));
return issues;
}
if (resolver !== "system" && resolver !== "server") {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "dns"), "resolver"), "必须为 system 或 server", targetName),
);
return issues;
}
if (!isString(dns["name"]) || dns["name"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "dns"), "name"), "缺少 dns.name 字段", targetName));
}
if (resolver === "system") {
const family: unknown = dns["family"];
if (family !== undefined && (!isString(family) || !VALID_FAMILIES.has(family))) {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "dns"), "family"), "必须为 any、ipv4 或 ipv6", targetName),
);
}
const allowedSystemKeys = new Set(["family", "name", "resolver"]);
for (const key of Object.keys(dns)) {
if (!allowedSystemKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(joinPath(path, "dns"), key), "是未知字段", targetName));
}
}
}
if (resolver === "server") {
if (!isString(dns["server"]) || dns["server"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "dns"), "server"), "缺少 dns.server 字段", targetName));
}
const port: unknown = dns["port"];
if (port !== undefined && (!isNumber(port) || !Number.isInteger(port) || port < 1 || port > 65535)) {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "dns"), "port"), "必须为 1-65535 之间的整数", targetName),
);
}
const protocol: unknown = dns["protocol"];
if (protocol !== undefined && (!isString(protocol) || !VALID_PROTOCOLS.has(protocol))) {
issues.push(issue("invalid-value", joinPath(joinPath(path, "dns"), "protocol"), "必须为 udp 或 tcp", targetName));
}
const recordType: unknown = dns["recordType"];
if (recordType !== undefined && (!isString(recordType) || !VALID_RECORD_TYPES.has(recordType))) {
issues.push(
issue(
"invalid-value",
joinPath(joinPath(path, "dns"), "recordType"),
"必须为 A、AAAA、CAA、CNAME、MX、NS、PTR、SOA、SRV 或 TXT",
targetName,
),
);
}
issues.push(
...validateSize(dns["maxResponseBytes"], joinPath(joinPath(path, "dns"), "maxResponseBytes"), targetName),
);
if (dns["recursionDesired"] !== undefined && typeof dns["recursionDesired"] !== "boolean") {
issues.push(
issue("invalid-type", joinPath(joinPath(path, "dns"), "recursionDesired"), "必须为布尔值", targetName),
);
}
if (dns["tcpFallback"] !== undefined && typeof dns["tcpFallback"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(joinPath(path, "dns"), "tcpFallback"), "必须为布尔值", targetName));
}
const allowedServerKeys = new Set([
"maxResponseBytes",
"name",
"port",
"protocol",
"recordType",
"recursionDesired",
"resolver",
"server",
"tcpFallback",
]);
for (const key of Object.keys(dns)) {
if (!allowedServerKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(joinPath(path, "dns"), key), "是未知字段", targetName));
}
}
}
issues.push(...validateDnsExpect(target, path, resolver));
return issues;
}
function validateDnsValuesExpectation(
value: unknown,
path: string,
targetName: string | undefined,
): ConfigValidationIssue[] {
if (value === undefined || value === null) return [];
if (!isPlainRecord(value)) {
return [issue("invalid-type", path, "必须为包含 include/exclude/exact 的对象", targetName)];
}
const issues: ConfigValidationIssue[] = [];
const allowedKeys = new Set(["exact", "exclude", "include"]);
for (const key of Object.keys(value)) {
if (!allowedKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
}
if (value["exact"] !== undefined) {
if (!Array.isArray(value["exact"])) {
issues.push(issue("invalid-type", joinPath(path, "exact"), "必须为字符串数组", targetName));
}
}
if (value["include"] !== undefined) {
if (!Array.isArray(value["include"])) {
issues.push(issue("invalid-type", joinPath(path, "include"), "必须为字符串数组", targetName));
}
}
if (value["exclude"] !== undefined) {
if (!Array.isArray(value["exclude"])) {
issues.push(issue("invalid-type", joinPath(path, "exclude"), "必须为字符串数组", targetName));
}
}
return issues;
}
function validateSize(value: unknown, path: string, targetName: string | undefined): ConfigValidationIssue[] {
if (value === undefined) return [];
if (!isString(value) && !isNumber(value)) {
return [issue("invalid-value", path, "必须为合法 size 值", targetName)];
}
try {
parseSize(value);
return [];
} catch {
return [issue("invalid-value", path, "必须为合法 size 值", targetName)];
}
}

View File

@@ -1,5 +1,6 @@
import { CommandChecker } from "./cmd";
import { DbChecker } from "./db";
import { DnsChecker } from "./dns";
import { HttpChecker } from "./http";
import { IcmpChecker } from "./icmp";
import { LlmChecker } from "./llm";
@@ -15,6 +16,7 @@ const checkers = [
new IcmpChecker(),
new UdpChecker(),
new LlmChecker(),
new DnsChecker(),
];
export function createDefaultCheckerRegistry(): CheckerRegistry {