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 { readonly configKey = "dns"; readonly schemas = dnsCheckerSchemas; readonly type = "dns"; buildDetail(observation: Record): 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 { 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 { 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 = { 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(); 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 = { 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 { 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 = { 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 = { 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, 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, 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 { if (signal.aborted) { return { error: "探测已取消", ok: false }; } return new Promise((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 }); } } }); }