- 新增 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
907 lines
25 KiB
TypeScript
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 });
|
|
}
|
|
}
|
|
});
|
|
}
|