feat: DNS checker,自研 codec/transport,支持 system/server 双模式,UDP/TCP + TC fallback
This commit is contained in:
901
src/server/checker/runner/dns/execute.ts
Normal file
901
src/server/checker/runner/dns/execute.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user