1
0

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:
2026-05-18 17:23:17 +08:00
parent 550c427814
commit 52262a31f6
19 changed files with 2328 additions and 8 deletions

View File

@@ -0,0 +1,364 @@
import { describe, expect, it } from "bun:test";
import type { ResolvedUdpTarget, UdpExpectConfig } from "../../../../../src/server/checker/runner/udp/types";
import { UdpChecker } from "../../../../../src/server/checker/runner/udp/execute";
async function createEchoServer(): Promise<{ close: () => void; port: number }> {
const socket = await Bun.udpSocket({
socket: {
data(sock, buf, port, addr) {
sock.send(buf, port, addr);
},
drain() {
// Bun UDP socket handler 必填项
},
error() {
// Bun UDP socket handler 必填项
},
},
});
return { close: () => socket.close(), port: socket.port };
}
function makeSignal(timeoutMs: number): { cleanup: () => void; signal: AbortSignal } {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
return { cleanup: () => clearTimeout(timer), signal: controller.signal };
}
function makeTarget(overrides: Partial<ResolvedUdpTarget["udp"]> = {}, expect?: UdpExpectConfig): ResolvedUdpTarget {
return {
description: null,
expect,
group: "default",
id: "test-udp",
intervalMs: 30000,
name: null,
timeoutMs: 10000,
type: "udp",
udp: {
encoding: "text",
host: "127.0.0.1",
maxResponseBytes: 4096,
payload: "PING",
port: 0,
responseEncoding: "text",
...overrides,
},
};
}
describe("UdpChecker execute", () => {
it("should resolve and respond successfully", async () => {
const server = await createEchoServer();
try {
const checker = new UdpChecker();
const target = makeTarget({ port: server.port });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
expect(result.statusDetail).toContain("responded");
expect(result.durationMs).toBeGreaterThanOrEqual(0);
} finally {
server.close();
}
});
it("should work with localhost hostname", async () => {
const server = await createEchoServer();
try {
const checker = new UdpChecker();
const target = makeTarget({ host: "localhost", port: server.port });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(true);
} finally {
server.close();
}
});
it("should fail when no response and default expect.responded=true", async () => {
const server = await Bun.udpSocket({
socket: {
data() {
// sink - 不回包以模拟无响应
},
drain() {
// Bun UDP socket handler 必填项
},
error() {
// Bun UDP socket handler 必填项
},
},
});
try {
const checker = new UdpChecker();
const target = makeTarget({ port: server.port });
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 200);
const result = await checker.execute(target, { signal: controller.signal });
clearTimeout(timer);
expect(result.matched).toBe(false);
expect(result.failure).not.toBeNull();
} finally {
server.close();
}
});
it("should match when expect.responded=false and no response", async () => {
const server = await Bun.udpSocket({
socket: {
data() {
// sink - 不回包以模拟无响应
},
drain() {
// Bun UDP socket handler 必填项
},
error() {
// Bun UDP socket handler 必填项
},
},
});
try {
const checker = new UdpChecker();
const target = makeTarget({ port: server.port }, { responded: false });
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 200);
const result = await checker.execute(target, { signal: controller.signal });
clearTimeout(timer);
expect(result.matched).toBe(true);
expect(result.statusDetail).toContain("no response");
} finally {
server.close();
}
});
it("should fail when expect.responded=false but response received", async () => {
const server = await createEchoServer();
try {
const checker = new UdpChecker();
const target = makeTarget({ port: server.port }, { responded: false });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("responded");
} finally {
server.close();
}
});
it("should validate response content with expect.response", async () => {
const server = await createEchoServer();
try {
const checker = new UdpChecker();
const target = makeTarget({ port: server.port }, { response: [{ contains: "PING" }] });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(true);
} finally {
server.close();
}
});
it("should fail response content mismatch", async () => {
const server = await createEchoServer();
try {
const checker = new UdpChecker();
const target = makeTarget({ port: server.port }, { response: [{ contains: "PONG" }] });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("response");
} finally {
server.close();
}
});
it("should validate responseSize", async () => {
const server = await createEchoServer();
try {
const checker = new UdpChecker();
const target = makeTarget({ port: server.port }, { responseSize: { gte: 1 } });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(true);
} finally {
server.close();
}
});
it("should only use the first datagram when server sends multiple", async () => {
const server = await Bun.udpSocket({
socket: {
data(sock, buf, port, addr) {
sock.send(Buffer.from("FIRST"), port, addr);
setTimeout(() => {
try {
sock.send(Buffer.from("SECOND"), port, addr);
} catch {
// checker 已关闭 connected socket第二次发送可能失败
}
}, 30);
},
drain() {
// Bun UDP socket handler 必填项
},
error() {
// Bun UDP socket handler 必填项
},
},
});
try {
const checker = new UdpChecker();
const target = makeTarget({ payload: "PING", port: server.port }, { response: [{ contains: "FIRST" }] });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(true);
expect(result.statusDetail).toContain("5 bytes");
} finally {
server.close();
}
});
it("should fail when response exceeds maxResponseBytes", async () => {
const server = await createEchoServer();
try {
const checker = new UdpChecker();
const longPayload = "X".repeat(100);
const target = makeTarget({
maxResponseBytes: 10,
payload: longPayload,
port: server.port,
});
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("response");
expect(result.failure?.kind).toBe("error");
} finally {
server.close();
}
});
it("should fail when duration exceeds maxDurationMs", async () => {
const server = await Bun.udpSocket({
socket: {
data() {
// sink - 不回包,通过 abort 触发 no-response 路径
},
drain() {
// Bun UDP socket handler 必填项
},
error() {
// Bun UDP socket handler 必填项
},
},
});
try {
const checker = new UdpChecker();
const target = makeTarget({ port: server.port }, { maxDurationMs: 1, responded: false });
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 200);
const result = await checker.execute(target, { signal: controller.signal });
clearTimeout(timer);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("duration");
} finally {
server.close();
}
});
it("should match sourceHost assertion", async () => {
const server = await createEchoServer();
try {
const checker = new UdpChecker();
const target = makeTarget({ port: server.port }, { sourceHost: { equals: "127.0.0.1" } });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(true);
} finally {
server.close();
}
});
it("should match sourcePort assertion", async () => {
const server = await createEchoServer();
try {
const checker = new UdpChecker();
const target = makeTarget({ port: server.port }, { sourcePort: { equals: server.port } });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(true);
} finally {
server.close();
}
});
});
describe("UdpChecker resolve", () => {
it("should fill defaults for minimal config", () => {
const checker = new UdpChecker();
const target = checker.resolve(
{ id: "test", type: "udp", udp: { host: "127.0.0.1", port: 9000 } },
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect(target.udp.payload).toBe("");
expect(target.udp.encoding).toBe("text");
expect(target.udp.responseEncoding).toBe("text");
expect(target.udp.maxResponseBytes).toBe(4096);
});
it("should use defaults.udp for missing fields", () => {
const checker = new UdpChecker();
const target = checker.resolve(
{ id: "test", type: "udp", udp: { host: "127.0.0.1", port: 9000 } },
{
configDir: "/tmp",
defaultIntervalMs: 30000,
defaults: { udp: { encoding: "hex", maxResponseBytes: "8KB", responseEncoding: "hex" } },
defaultTimeoutMs: 10000,
},
);
expect(target.udp.encoding).toBe("hex");
expect(target.udp.responseEncoding).toBe("hex");
expect(target.udp.maxResponseBytes).toBe(8192);
});
it("should override defaults with target-level config", () => {
const checker = new UdpChecker();
const target = checker.resolve(
{ id: "test", type: "udp", udp: { encoding: "base64", host: "127.0.0.1", port: 9000 } },
{
configDir: "/tmp",
defaultIntervalMs: 30000,
defaults: { udp: { encoding: "hex" } },
defaultTimeoutMs: 10000,
},
);
expect(target.udp.encoding).toBe("base64");
});
});
describe("UdpChecker serialize", () => {
it("should produce udp host:port target summary", () => {
const checker = new UdpChecker();
const target = makeTarget({ host: "10.0.0.1", port: 9000 });
const { config, target: display } = checker.serialize(target);
expect(display).toBe("udp 10.0.0.1:9000");
const parsed = JSON.parse(config) as Record<string, unknown>;
expect(parsed["host"]).toBe("10.0.0.1");
expect(parsed["port"]).toBe(9000);
});
});