1
0
Files
DiAL/src/server/checker/runner/dns/execute.ts
lanyuanxiaoyao 77c6015b3a refactor: 将 checker normalize 职责下沉到各 runner 目录
- 新增 CheckerDefinition.normalize 必需方法,typecheck 兜底遗漏实现
- 新增 expect/normalize.ts 共享 helper(compactExpect、normalizeValue、
  normalizeContent、normalizeKeyed)
- 为 HTTP、Cmd、DB、TCP、UDP、ICMP、LLM、WS、DNS 各新增独立 normalize.ts
- 简化 normalizer.ts:删除所有 checker type switch,改为 registry 委托
- 修复 DNS authoring 简写 bug:durationMs、valueCount、result 等字段
  现可通过完整加载链路
- 新增 DNS 回归测试和 registry 级合同测试
- 更新 docs/development/checker.md:补充 normalize 规范、文件结构、
  测试要求和 checklist
2026-05-25 16:16:41 +08:00

907 lines
25 KiB
TypeScript

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 { normalizeTargetExpect } from "./normalize";
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,
};
}
}
normalize(target: RawTargetConfig): RawTargetConfig {
return normalizeTargetExpect(target);
}
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 });
}
}
});
}