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