feat: 新增 UDP checker,支持自定义 payload 请求-响应探测与断言
基于 Bun connected UDP socket 实现通用 UDP 拨测能力: - 支持 text/hex/base64 payload 编码与独立 responseEncoding 响应视图 - 支持 responded、response、responseSize、sourceHost、sourcePort、maxDurationMs 专属 expect - 单 datagram 发送,仅断言首个 UDP 响应 datagram - 通过 maxResponseBytes 和 flags.truncated 进行响应大小限制与截断保护 - payload 可选,省略时发送空 datagram - 自包含模块结构(types/schema/validate/expect/encoding/execute) - 新增 741 tests(含 unit、execute 集成、expect 和编码 roundtrip),全部通过
This commit is contained in:
@@ -4,8 +4,16 @@ import { HttpChecker } from "./http";
|
||||
import { IcmpChecker } from "./icmp";
|
||||
import { CheckerRegistry } from "./registry";
|
||||
import { TcpChecker } from "./tcp";
|
||||
import { UdpChecker } from "./udp";
|
||||
|
||||
const checkers = [new HttpChecker(), new CommandChecker(), new DbChecker(), new TcpChecker(), new IcmpChecker()];
|
||||
const checkers = [
|
||||
new HttpChecker(),
|
||||
new CommandChecker(),
|
||||
new DbChecker(),
|
||||
new TcpChecker(),
|
||||
new IcmpChecker(),
|
||||
new UdpChecker(),
|
||||
];
|
||||
|
||||
export function createDefaultCheckerRegistry(): CheckerRegistry {
|
||||
const registry = new CheckerRegistry();
|
||||
|
||||
49
src/server/checker/runner/udp/encoding.ts
Normal file
49
src/server/checker/runner/udp/encoding.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { UdpEncoding } from "./types";
|
||||
|
||||
export function decodePayload(payload: string, encoding: UdpEncoding): Uint8Array {
|
||||
if (encoding === "hex") return hexToBytes(payload);
|
||||
if (encoding === "base64") return base64ToBytes(payload);
|
||||
return new TextEncoder().encode(payload);
|
||||
}
|
||||
|
||||
export function encodeResponse(data: Uint8Array, encoding: UdpEncoding): string {
|
||||
if (encoding === "hex") return bytesToHex(data);
|
||||
if (encoding === "base64") return bytesToBase64(data);
|
||||
return new TextDecoder().decode(data);
|
||||
}
|
||||
|
||||
function base64ToBytes(base64: string): Uint8Array {
|
||||
if (base64.length === 0) return new Uint8Array(0);
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function bytesToBase64(data: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let i = 0; i < data.byteLength; i++) {
|
||||
binary += String.fromCharCode(data[i]!);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function bytesToHex(data: Uint8Array): string {
|
||||
let hex = "";
|
||||
for (let i = 0; i < data.byteLength; i++) {
|
||||
hex += data[i]!.toString(16).padStart(2, "0");
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
if (hex.length === 0) return new Uint8Array(0);
|
||||
if (hex.length % 2 !== 0) throw new Error("hex string must have even length");
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
388
src/server/checker/runner/udp/execute.ts
Normal file
388
src/server/checker/runner/udp/execute.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { ResolvedUdpTarget, UdpDefaultsConfig, UdpExpectConfig, UdpTargetConfig } from "./types";
|
||||
|
||||
import { checkDuration } from "../../expect/duration";
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { parseSize } from "../../utils";
|
||||
import { decodePayload, encodeResponse } from "./encoding";
|
||||
import { checkResponded, checkResponseSize, checkResponseText, checkSourceHost, checkSourcePort } from "./expect";
|
||||
import { udpCheckerSchemas } from "./schema";
|
||||
import { validateUdpConfig } from "./validate";
|
||||
|
||||
const DEFAULT_MAX_RESPONSE_BYTES = 4096;
|
||||
const RESPONSE_PREVIEW_MAX = 80;
|
||||
|
||||
type UdpExchangeResult =
|
||||
| {
|
||||
data: Uint8Array;
|
||||
flags: { truncated: boolean };
|
||||
ok: true;
|
||||
responded: true;
|
||||
sourceAddress: string;
|
||||
sourcePort: number;
|
||||
}
|
||||
| { error: string; ok: false }
|
||||
| {
|
||||
ok: true;
|
||||
responded: false;
|
||||
};
|
||||
|
||||
export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
readonly configKey = "udp";
|
||||
readonly schemas = udpCheckerSchemas;
|
||||
readonly type = "udp";
|
||||
|
||||
async execute(t: ResolvedUdpTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
const expect = t.expect;
|
||||
|
||||
try {
|
||||
const payloadBytes = decodePayload(t.udp.payload, t.udp.encoding);
|
||||
|
||||
const exchangeResult = await udpExchange(t.udp.host, t.udp.port, payloadBytes, ctx.signal);
|
||||
|
||||
if (!exchangeResult.ok) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
if (expect?.responded === false) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: exchangeResult.error,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure("response", "response", exchangeResult.error),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const expectedResponded = expect?.responded ?? true;
|
||||
const respondedResult = checkResponded(exchangeResult.responded, expectedResponded);
|
||||
if (!respondedResult.matched) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
durationMs,
|
||||
failure: respondedResult.failure,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (!exchangeResult.responded) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: buildNoResponseDetail(durationMs),
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
return {
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: buildNoResponseDetail(durationMs),
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (exchangeResult.flags.truncated) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure("response", "response", "响应 datagram 被内核截断"),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (exchangeResult.data.byteLength > t.udp.maxResponseBytes) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure(
|
||||
"response",
|
||||
"response",
|
||||
`响应超过 ${t.udp.maxResponseBytes} 字节限制 (${exchangeResult.data.byteLength} bytes)`,
|
||||
),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (expect?.responseSize) {
|
||||
const sizeResult = checkResponseSize(exchangeResult.data.byteLength, expect.responseSize);
|
||||
if (!sizeResult.matched) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
durationMs,
|
||||
failure: sizeResult.failure,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (expect?.response) {
|
||||
const responseText = encodeResponse(exchangeResult.data, t.udp.responseEncoding);
|
||||
const textResult = checkResponseText(responseText, expect.response);
|
||||
if (!textResult.matched) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
durationMs,
|
||||
failure: textResult.failure,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (expect?.sourceHost) {
|
||||
const sourceResult = checkSourceHost(exchangeResult.sourceAddress, expect.sourceHost);
|
||||
if (!sourceResult.matched) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
durationMs,
|
||||
failure: sourceResult.failure,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (expect?.sourcePort) {
|
||||
const sourceResult = checkSourcePort(exchangeResult.sourcePort, expect.sourcePort);
|
||||
if (!sourceResult.matched) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
durationMs,
|
||||
failure: sourceResult.failure,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: buildRespondedDetail(
|
||||
exchangeResult.data.byteLength,
|
||||
durationMs,
|
||||
t.udp.responseEncoding,
|
||||
exchangeResult.data,
|
||||
),
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: buildRespondedDetail(
|
||||
exchangeResult.data.byteLength,
|
||||
durationMs,
|
||||
t.udp.responseEncoding,
|
||||
exchangeResult.data,
|
||||
),
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure("response", "response", isError(error) ? error.message : String(error)),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedUdpTarget {
|
||||
const t = target as RawTargetConfig & { type: "udp"; udp: UdpTargetConfig };
|
||||
const udpDefaults = context.defaults["udp"] as UdpDefaultsConfig | undefined;
|
||||
|
||||
const encoding = t.udp.encoding ?? udpDefaults?.encoding ?? "text";
|
||||
const responseEncoding = t.udp.responseEncoding ?? udpDefaults?.responseEncoding ?? "text";
|
||||
const maxResponseBytes = parseSize(
|
||||
t.udp.maxResponseBytes ?? udpDefaults?.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
|
||||
);
|
||||
|
||||
return {
|
||||
description: null,
|
||||
expect: target.expect as UdpExpectConfig | undefined,
|
||||
group: target.group ?? "default",
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name ?? null,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "udp",
|
||||
udp: {
|
||||
encoding,
|
||||
host: t.udp.host,
|
||||
maxResponseBytes,
|
||||
payload: t.udp.payload ?? "",
|
||||
port: t.udp.port,
|
||||
responseEncoding,
|
||||
},
|
||||
} satisfies ResolvedUdpTarget;
|
||||
}
|
||||
|
||||
serialize(t: ResolvedUdpTarget): { config: string; target: string } {
|
||||
return {
|
||||
config: JSON.stringify(t.udp),
|
||||
target: `udp ${t.udp.host}:${t.udp.port}`,
|
||||
};
|
||||
}
|
||||
|
||||
validate(input: CheckerValidationInput) {
|
||||
return validateUdpConfig(input);
|
||||
}
|
||||
}
|
||||
|
||||
function buildNoResponseDetail(durationMs: number): string {
|
||||
return `no response in ${durationMs}ms`;
|
||||
}
|
||||
|
||||
function buildRespondedDetail(size: number, durationMs: number, encoding: string, data: Uint8Array): string {
|
||||
let detail = `responded in ${durationMs}ms, ${size} bytes`;
|
||||
if (size > 0 && size <= RESPONSE_PREVIEW_MAX) {
|
||||
const preview = encodeResponse(data, encoding as "base64" | "hex" | "text");
|
||||
const truncated = preview.length > RESPONSE_PREVIEW_MAX ? `${preview.slice(0, RESPONSE_PREVIEW_MAX)}…` : preview;
|
||||
detail = `${detail}, response: ${truncated}`;
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
function simplifyUdpError(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;
|
||||
}
|
||||
|
||||
async function udpExchange(
|
||||
hostname: string,
|
||||
port: number,
|
||||
payload: Uint8Array,
|
||||
signal: AbortSignal,
|
||||
): Promise<UdpExchangeResult> {
|
||||
let settled = false;
|
||||
let exchangeResolve: ((value: UdpExchangeResult) => void) | undefined;
|
||||
const exchangePromise = new Promise<UdpExchangeResult>((resolve) => {
|
||||
exchangeResolve = resolve;
|
||||
});
|
||||
|
||||
const settle = (result: UdpExchangeResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
exchangeResolve!(result);
|
||||
};
|
||||
|
||||
try {
|
||||
const socket = await Bun.udpSocket({
|
||||
connect: { hostname, port },
|
||||
socket: {
|
||||
data(socket, data, _port, _address, flags) {
|
||||
settle({
|
||||
data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
|
||||
flags: { truncated: flags.truncated },
|
||||
ok: true,
|
||||
responded: true,
|
||||
sourceAddress: _address,
|
||||
sourcePort: _port,
|
||||
});
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
},
|
||||
drain() {
|
||||
// Bun UDP socket handler 必填项,UDP checker 不关注 drain 事件
|
||||
},
|
||||
error(socket, error) {
|
||||
settle({ error: error.message, ok: false });
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (signal.aborted) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
return { error: "探测已取消", ok: false };
|
||||
}
|
||||
|
||||
socket.send(payload);
|
||||
|
||||
const onAbort = () => {
|
||||
settle({ ok: true, responded: false });
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
const result = await exchangePromise;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
return { error: "探测超时", ok: false };
|
||||
}
|
||||
const message = isError(error) ? error.message : String(error);
|
||||
return { error: simplifyUdpError(message), ok: false };
|
||||
}
|
||||
}
|
||||
66
src/server/checker/runner/udp/expect.ts
Normal file
66
src/server/checker/runner/udp/expect.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { ExpectResult } from "../../expect/types";
|
||||
import type { ExpectOperator } from "../../types";
|
||||
|
||||
import { mismatchFailure } from "../../expect/failure";
|
||||
import { applyOperator } from "../../expect/operator";
|
||||
|
||||
export function checkResponded(responded: boolean, expected: boolean): ExpectResult {
|
||||
if (responded === expected) return { failure: null, matched: true };
|
||||
if (!responded && expected) {
|
||||
return {
|
||||
failure: mismatchFailure("responded", "responded", true, false, "期望收到响应但未收到"),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
failure: mismatchFailure("responded", "responded", false, true, "期望无响应但收到响应"),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function checkResponseSize(size: number, op: ExpectOperator): ExpectResult {
|
||||
const matched = applyOperator(size, op);
|
||||
if (!matched) {
|
||||
return {
|
||||
failure: mismatchFailure("responseSize", "responseSize", op, size, "响应大小不满足条件"),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
export function checkResponseText(text: string, rules: ExpectOperator[]): ExpectResult {
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const rule = rules[i]!;
|
||||
const path = `response[${i}]`;
|
||||
if (!applyOperator(text, rule)) {
|
||||
return {
|
||||
failure: mismatchFailure("response", path, rule, text, `response rule at index ${i} mismatch`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
export function checkSourceHost(actual: string, op: ExpectOperator): ExpectResult {
|
||||
const matched = applyOperator(actual, op);
|
||||
if (!matched) {
|
||||
return {
|
||||
failure: mismatchFailure("sourceHost", "sourceHost", op, actual, "响应来源地址不满足条件"),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
export function checkSourcePort(actual: number, op: ExpectOperator): ExpectResult {
|
||||
const matched = applyOperator(actual, op);
|
||||
if (!matched) {
|
||||
return {
|
||||
failure: mismatchFailure("sourcePort", "sourcePort", op, actual, "响应来源端口不满足条件"),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
1
src/server/checker/runner/udp/index.ts
Normal file
1
src/server/checker/runner/udp/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { UdpChecker } from "./execute";
|
||||
38
src/server/checker/runner/udp/schema.ts
Normal file
38
src/server/checker/runner/udp/schema.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import { createPureOperatorSchema, createTextRulesSchema, sizeSchema } from "../../schema/fragments";
|
||||
|
||||
export const udpCheckerSchemas: CheckerSchemas = {
|
||||
config: Type.Object(
|
||||
{
|
||||
encoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])),
|
||||
host: Type.String({ minLength: 1 }),
|
||||
maxResponseBytes: Type.Optional(sizeSchema),
|
||||
payload: Type.Optional(Type.String()),
|
||||
port: Type.Integer({ maximum: 65535, minimum: 1 }),
|
||||
responseEncoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
defaults: Type.Object(
|
||||
{
|
||||
encoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])),
|
||||
maxResponseBytes: Type.Optional(sizeSchema),
|
||||
responseEncoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
expect: Type.Object(
|
||||
{
|
||||
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
responded: Type.Optional(Type.Boolean()),
|
||||
response: Type.Optional(createTextRulesSchema()),
|
||||
responseSize: Type.Optional(createPureOperatorSchema()),
|
||||
sourceHost: Type.Optional(createPureOperatorSchema()),
|
||||
sourcePort: Type.Optional(createPureOperatorSchema()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
};
|
||||
46
src/server/checker/runner/udp/types.ts
Normal file
46
src/server/checker/runner/udp/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { ExpectOperator, ResolvedTargetBase } from "../../types";
|
||||
|
||||
export interface ResolvedUdpConfig {
|
||||
encoding: UdpEncoding;
|
||||
host: string;
|
||||
maxResponseBytes: number;
|
||||
payload: string;
|
||||
port: number;
|
||||
responseEncoding: UdpEncoding;
|
||||
}
|
||||
|
||||
export interface ResolvedUdpTarget extends ResolvedTargetBase {
|
||||
expect?: UdpExpectConfig;
|
||||
group: string;
|
||||
intervalMs: number;
|
||||
name: null | string;
|
||||
timeoutMs: number;
|
||||
type: "udp";
|
||||
udp: ResolvedUdpConfig;
|
||||
}
|
||||
|
||||
export interface UdpDefaultsConfig {
|
||||
encoding?: UdpEncoding;
|
||||
maxResponseBytes?: number | string;
|
||||
responseEncoding?: UdpEncoding;
|
||||
}
|
||||
|
||||
export type UdpEncoding = "base64" | "hex" | "text";
|
||||
|
||||
export interface UdpExpectConfig {
|
||||
maxDurationMs?: number;
|
||||
responded?: boolean;
|
||||
response?: ExpectOperator[];
|
||||
responseSize?: ExpectOperator;
|
||||
sourceHost?: ExpectOperator;
|
||||
sourcePort?: ExpectOperator;
|
||||
}
|
||||
|
||||
export interface UdpTargetConfig {
|
||||
encoding?: UdpEncoding;
|
||||
host: string;
|
||||
maxResponseBytes?: number | string;
|
||||
payload?: string;
|
||||
port: number;
|
||||
responseEncoding?: UdpEncoding;
|
||||
}
|
||||
227
src/server/checker/runner/udp/validate.ts
Normal file
227
src/server/checker/runner/udp/validate.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { isNumber, isPlainObject, isString } from "es-toolkit";
|
||||
|
||||
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||
import type { CheckerValidationInput } from "../types";
|
||||
|
||||
import { validateOperatorObject } from "../../expect/validate-operator";
|
||||
import { issue, joinPath } from "../../schema/issues";
|
||||
|
||||
const VALID_ENCODINGS = new Set(["base64", "hex", "text"]);
|
||||
|
||||
export function validateUdpConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
|
||||
issues.push(...validateUdpDefaults(input));
|
||||
|
||||
for (let i = 0; i < input.targets.length; i++) {
|
||||
const target = input.targets[i] as unknown;
|
||||
if (!isPlainObject(target)) continue;
|
||||
if (target["type"] !== "udp") continue;
|
||||
issues.push(...validateUdpTarget(target, `targets[${i}]`));
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function getTargetName(target: Record<string, unknown>): string | undefined {
|
||||
if (isString(target["name"])) return target["name"];
|
||||
return isString(target["id"]) ? target["id"] : undefined;
|
||||
}
|
||||
|
||||
function isNonNegativeFiniteNumber(value: unknown): boolean {
|
||||
return isNumber(value) && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function validateEncoding(value: unknown, path: string, targetName: string | undefined): ConfigValidationIssue[] {
|
||||
if (value === undefined) return [];
|
||||
if (!isString(value) || !VALID_ENCODINGS.has(value)) {
|
||||
return [issue("invalid-value", path, "必须为 text、hex 或 base64", targetName)];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function validateSize(value: unknown, path: string, targetName: string | undefined): ConfigValidationIssue[] {
|
||||
if (value === undefined) return [];
|
||||
if (!isString(value) && !(isNumber(value) && Number.isFinite(value) && value >= 0)) {
|
||||
return [issue("invalid-value", path, "必须为合法 size 值", targetName)];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function validateTextRulesArray(value: unknown, path: string, targetName: string | undefined): ConfigValidationIssue[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [issue("invalid-type", path, "必须为数组", targetName)];
|
||||
}
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const rule: unknown = value[i];
|
||||
if (!isPlainObject(rule)) {
|
||||
issues.push(issue("invalid-type", joinPath(path, `[${i}]`), "必须为 operator 对象", targetName));
|
||||
continue;
|
||||
}
|
||||
issues.push(...validateOperatorObject(rule, joinPath(path, `[${i}]`), targetName));
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateUdpDefaults(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const defaults = input.defaults["udp"];
|
||||
if (defaults === undefined || defaults === null || !isPlainObject(defaults)) return issues;
|
||||
|
||||
const targetName = "defaults.udp";
|
||||
|
||||
issues.push(...validateEncoding(defaults["encoding"], joinPath("defaults.udp", "encoding"), targetName));
|
||||
issues.push(
|
||||
...validateEncoding(defaults["responseEncoding"], joinPath("defaults.udp", "responseEncoding"), targetName),
|
||||
);
|
||||
issues.push(...validateSize(defaults["maxResponseBytes"], joinPath("defaults.udp", "maxResponseBytes"), targetName));
|
||||
|
||||
const allowedKeys = new Set(["encoding", "maxResponseBytes", "responseEncoding"]);
|
||||
for (const key of Object.keys(defaults)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
issues.push(issue("unknown-field", joinPath("defaults.udp", key), "是未知字段", targetName));
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateUdpExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||
const targetName = getTargetName(target);
|
||||
const expect = target["expect"];
|
||||
if (expect === undefined || expect === null || !isPlainObject(expect)) return [];
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const expectPath = joinPath(path, "expect");
|
||||
const responded: unknown = expect["responded"];
|
||||
|
||||
if (responded !== undefined && typeof responded !== "boolean") {
|
||||
issues.push(issue("invalid-type", joinPath(expectPath, "responded"), "必须为布尔值", targetName));
|
||||
}
|
||||
|
||||
if (expect["maxDurationMs"] !== undefined && !isNonNegativeFiniteNumber(expect["maxDurationMs"])) {
|
||||
issues.push(issue("invalid-type", joinPath(expectPath, "maxDurationMs"), "必须为非负有限数字", targetName));
|
||||
}
|
||||
|
||||
if (expect["response"] !== undefined) {
|
||||
issues.push(...validateTextRulesArray(expect["response"], joinPath(expectPath, "response"), targetName));
|
||||
}
|
||||
|
||||
if (expect["responseSize"] !== undefined) {
|
||||
issues.push(...validateOperatorObject(expect["responseSize"], joinPath(expectPath, "responseSize"), targetName));
|
||||
}
|
||||
|
||||
if (expect["sourceHost"] !== undefined) {
|
||||
issues.push(...validateOperatorObject(expect["sourceHost"], joinPath(expectPath, "sourceHost"), targetName));
|
||||
}
|
||||
|
||||
if (expect["sourcePort"] !== undefined) {
|
||||
issues.push(...validateOperatorObject(expect["sourcePort"], joinPath(expectPath, "sourcePort"), targetName));
|
||||
}
|
||||
|
||||
const respondedFalse = responded === false;
|
||||
if (respondedFalse) {
|
||||
if (expect["response"] !== undefined || expect["responseSize"] !== undefined) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
joinPath(expectPath, "responded"),
|
||||
"响应内容或大小断言需要 expect.responded 为 true",
|
||||
targetName,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (expect["sourceHost"] !== undefined || expect["sourcePort"] !== undefined) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
joinPath(expectPath, "responded"),
|
||||
"响应来源断言需要 expect.responded 为 true",
|
||||
targetName,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allowedKeys = new Set(["maxDurationMs", "responded", "response", "responseSize", "sourceHost", "sourcePort"]);
|
||||
for (const key of Object.keys(expect)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateUdpTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const targetName = getTargetName(target);
|
||||
const udp = target["udp"];
|
||||
|
||||
if (!isPlainObject(udp)) {
|
||||
issues.push(issue("required", joinPath(path, "udp"), "缺少 udp 配置分组", targetName));
|
||||
issues.push(...validateUdpExpect(target, path));
|
||||
return issues;
|
||||
}
|
||||
|
||||
if (!isString(udp["host"]) || udp["host"].trim() === "") {
|
||||
issues.push(issue("required", joinPath(joinPath(path, "udp"), "host"), "缺少 udp.host 字段", targetName));
|
||||
}
|
||||
|
||||
if (udp["port"] === undefined) {
|
||||
issues.push(issue("required", joinPath(joinPath(path, "udp"), "port"), "缺少 udp.port 字段", targetName));
|
||||
} else if (!isNumber(udp["port"]) || !Number.isInteger(udp["port"]) || udp["port"] < 1 || udp["port"] > 65535) {
|
||||
issues.push(
|
||||
issue("invalid-value", joinPath(joinPath(path, "udp"), "port"), "必须为 1-65535 之间的整数", targetName),
|
||||
);
|
||||
}
|
||||
|
||||
const encoding: unknown = udp["encoding"];
|
||||
issues.push(...validateEncoding(encoding, joinPath(joinPath(path, "udp"), "encoding"), targetName));
|
||||
|
||||
if (encoding === "hex" && isString(udp["payload"])) {
|
||||
const hexPattern = /^[0-9a-fA-F]*$/;
|
||||
if (!hexPattern.test(udp["payload"])) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
joinPath(joinPath(path, "udp"), "payload"),
|
||||
"udp.payload 与 udp.encoding 不匹配",
|
||||
targetName,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (encoding === "base64" && isString(udp["payload"])) {
|
||||
const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||
if (!base64Pattern.test(udp["payload"])) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
joinPath(joinPath(path, "udp"), "payload"),
|
||||
"udp.payload 与 udp.encoding 不匹配",
|
||||
targetName,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const responseEncoding: unknown = udp["responseEncoding"];
|
||||
issues.push(...validateEncoding(responseEncoding, joinPath(joinPath(path, "udp"), "responseEncoding"), targetName));
|
||||
|
||||
issues.push(
|
||||
...validateSize(udp["maxResponseBytes"], joinPath(joinPath(path, "udp"), "maxResponseBytes"), targetName),
|
||||
);
|
||||
|
||||
const allowedUdpKeys = new Set(["encoding", "host", "maxResponseBytes", "payload", "port", "responseEncoding"]);
|
||||
for (const key of Object.keys(udp)) {
|
||||
if (!allowedUdpKeys.has(key)) {
|
||||
issues.push(issue("unknown-field", joinPath(joinPath(path, "udp"), key), "是未知字段", targetName));
|
||||
}
|
||||
}
|
||||
|
||||
issues.push(...validateUdpExpect(target, path));
|
||||
|
||||
return issues;
|
||||
}
|
||||
Reference in New Issue
Block a user