From 52262a31f62cc1cf9c42092a80b65afa66ccc120 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Mon, 18 May 2026 17:23:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20UDP=20checker?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A=E4=B9=89=20payloa?= =?UTF-8?q?d=20=E8=AF=B7=E6=B1=82-=E5=93=8D=E5=BA=94=E6=8E=A2=E6=B5=8B?= =?UTF-8?q?=E4=B8=8E=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 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),全部通过 --- DEVELOPMENT.md | 1 + README.md | 16 +- openspec/specs/probe-config/spec.md | 22 +- openspec/specs/udp-checker/spec.md | 203 +++++++++ probe-config.schema.json | 402 ++++++++++++++++++ probes.example.yaml | 41 ++ src/server/checker/runner/index.ts | 10 +- src/server/checker/runner/udp/encoding.ts | 49 +++ src/server/checker/runner/udp/execute.ts | 388 +++++++++++++++++ src/server/checker/runner/udp/expect.ts | 66 +++ src/server/checker/runner/udp/index.ts | 1 + src/server/checker/runner/udp/schema.ts | 38 ++ src/server/checker/runner/udp/types.ts | 46 ++ src/server/checker/runner/udp/validate.ts | 227 ++++++++++ tests/server/checker/runner/registry.test.ts | 4 +- .../checker/runner/udp/encoding.test.ts | 76 ++++ .../server/checker/runner/udp/execute.test.ts | 364 ++++++++++++++++ .../server/checker/runner/udp/expect.test.ts | 120 ++++++ .../checker/runner/udp/validate.test.ts | 262 ++++++++++++ 19 files changed, 2328 insertions(+), 8 deletions(-) create mode 100644 openspec/specs/udp-checker/spec.md create mode 100644 src/server/checker/runner/udp/encoding.ts create mode 100644 src/server/checker/runner/udp/execute.ts create mode 100644 src/server/checker/runner/udp/expect.ts create mode 100644 src/server/checker/runner/udp/index.ts create mode 100644 src/server/checker/runner/udp/schema.ts create mode 100644 src/server/checker/runner/udp/types.ts create mode 100644 src/server/checker/runner/udp/validate.ts create mode 100644 tests/server/checker/runner/udp/encoding.test.ts create mode 100644 tests/server/checker/runner/udp/execute.test.ts create mode 100644 tests/server/checker/runner/udp/expect.test.ts create mode 100644 tests/server/checker/runner/udp/validate.test.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b33391d..b784ef8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -62,6 +62,7 @@ src/ db/ DB Checker(自包含模块,含 types/schema/execute/expect/validate) tcp/ TCP Checker(自包含模块,含 types/schema/execute/expect/validate) icmp/ Ping Checker(自包含模块,含 types/schema/execute/expect/validate/parse) + udp/ UDP Checker(自包含模块,含 types/schema/execute/expect/validate/encoding) shared/ api.ts 前后端共享 TypeScript 类型 web/ React 前端 Dashboard(通过 Bun HTML import 集成) diff --git a/README.md b/README.md index 3b8cd69..acbf5d1 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@ --- -DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP** 和 **Ping** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite,前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。 +DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP**、**UDP** 和 **Ping** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite,前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。 **功能亮点:** -- 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/MySQL/SQLite)、TCP(端口可达性 + Banner 探测)、Ping(ICMP 存活、延迟、丢包率) +- 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/MySQL/SQLite)、TCP(端口可达性 + Banner 探测)、UDP(自定义 payload 请求-响应)、Ping(ICMP 存活、延迟、丢包率) - 丰富的校验规则:状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等 - 响应式 Dashboard:实时状态、可用率统计、耗时趋势图、手动/自动刷新 - 多主题支持:系统、明亮、黑暗三种主题模式 @@ -144,6 +144,18 @@ targets: expect: maxDurationMs: 3000 + - id: "udp-heartbeat" + name: "UDP 心跳检测" + type: udp + udp: + host: "127.0.0.1" + port: 9000 + payload: "PING" + expect: + response: + - contains: "PONG" + maxDurationMs: 100 + - id: "gateway-ping" name: "网关 ICMP 可达" type: ping diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md index 278e8f2..6cfa078 100644 --- a/openspec/specs/probe-config/spec.md +++ b/openspec/specs/probe-config/spec.md @@ -5,9 +5,9 @@ ## Requirements ### Requirement: YAML 配置文件格式 -系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、可选的 variables 段、checker 默认值和 typed target 列表(含可选 group 字段)。target MUST 使用 `id` 字段作为唯一标识符,MUST 使用 `type` 字段声明 checker 类型,SHALL 支持可选的 `name` 字段作为展示名称元信息,SHALL 支持可选的 `description` 字段作为目标说明。`name` 和 `description` 均 SHALL 允许省略或显式配置为 `null`;省略或显式 null 时解析结果 SHALL 保留为 null。HTTP 领域字段 MUST 放在 `http` 分组,cmd 领域字段 MUST 放在 `cmd` 分组,db 领域字段 MUST 放在 `db` 分组,tcp 领域字段 MUST 放在 `tcp` 分组,ping 领域字段 MUST 放在 `ping` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`(非负整数)字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`(可选)字段。Tcp target 的 `tcp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`readBanner`(可选)、`bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。Ping target 的 `ping` 分组 SHALL 支持 `host`(必填)、`count`(可选,默认 3)和 `packetSize`(可选,默认 56)字段。 +系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、可选的 variables 段、checker 默认值和 typed target 列表(含可选 group 字段)。target MUST 使用 `id` 字段作为唯一标识符,MUST 使用 `type` 字段声明 checker 类型,SHALL 支持可选的 `name` 字段作为展示名称元信息,SHALL 支持可选的 `description` 字段作为目标说明。`name` 和 `description` 均 SHALL 允许省略或显式配置为 `null`;省略或显式 null 时解析结果 SHALL 保留为 null。HTTP 领域字段 MUST 放在 `http` 分组,cmd 领域字段 MUST 放在 `cmd` 分组,db 领域字段 MUST 放在 `db` 分组,tcp 领域字段 MUST 放在 `tcp` 分组,ping 领域字段 MUST 放在 `ping` 分组,udp 领域字段 MUST 放在 `udp` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`(非负整数)字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`(可选)字段。Tcp target 的 `tcp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`readBanner`(可选)、`bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。Ping target 的 `ping` 分组 SHALL 支持 `host`(必填)、`count`(可选,默认 3)和 `packetSize`(可选,默认 56)字段。Udp target 的 `udp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`payload`(可选,默认空字符串)、`encoding`(可选,默认 `text`)、`responseEncoding`(可选,默认 `text`)和 `maxResponseBytes`(可选,默认 4096)字段。 -`defaults.http` 分组 SHALL 仅支持 `headers`(可选)和 `maxBodyBytes`(可选)字段。`defaults.http` 分组 MUST NOT 支持 `method` 字段。`defaults.tcp` 分组 SHALL 仅支持 `bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。 +`defaults.http` 分组 SHALL 仅支持 `headers`(可选)和 `maxBodyBytes`(可选)字段。`defaults.http` 分组 MUST NOT 支持 `method` 字段。`defaults.tcp` 分组 SHALL 仅支持 `bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。`defaults.udp` 分组 SHALL 仅支持 `encoding`(可选)、`responseEncoding`(可选)和 `maxResponseBytes`(可选)字段。 #### Scenario: 完整配置文件解析 - **WHEN** 系统启动并读取包含 server、runtime、variables、defaults、targets(含 id、group 字段)的 YAML 配置文件 @@ -61,6 +61,14 @@ - **WHEN** 系统读取只包含一个 `type: ping` target(含 `id` 和 `ping.host`)的 YAML 配置文件 - **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(interval=30s, timeout=10s, group="default", ping.count=3, ping.packetSize=56),并保留 name=null、description=null +#### Scenario: 最简 udp 配置文件解析 +- **WHEN** 系统读取只包含一个 `type: udp` target(含 `id`、`udp.host` 和 `udp.port`)的 YAML 配置文件 +- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(interval=30s, timeout=10s, group="default", udp.payload="", udp.encoding="text", udp.responseEncoding="text", udp.maxResponseBytes=4096),并保留 name=null、description=null + +#### Scenario: defaults.udp 配置默认值 +- **WHEN** YAML 配置中 defaults.udp 设置 `encoding`、`responseEncoding` 和 `maxResponseBytes` +- **THEN** 未显式覆盖对应字段的 udp target SHALL 使用 defaults.udp 中的值 + ### Requirement: CLI 参数 系统 SHALL 通过单一命令行参数接受 YAML 配置文件路径。 @@ -285,7 +293,7 @@ - **THEN** 系统 SHALL 调用 `Bun.YAML.parse()` 将内容解析为配置对象 ### Requirement: expect 配置增强 -系统 SHALL 支持 typed target 的领域专用 expect 配置,包括 HTTP 的 `status`(支持精确数字和范围模式)、`headers`、`body`,cmd 的 `exitCode`、`stdout`、`stderr`,tcp 的 `connected`、`banner`,以及 ping 的 `alive`、`maxPacketLoss`、`maxAvgLatencyMs`、`maxMaxLatencyMs`。内容类 expect MUST 使用数组表达配置顺序。 +系统 SHALL 支持 typed target 的领域专用 expect 配置,包括 HTTP 的 `status`(支持精确数字和范围模式)、`headers`、`body`,cmd 的 `exitCode`、`stdout`、`stderr`,tcp 的 `connected`、`banner`,ping 的 `alive`、`maxPacketLoss`、`maxAvgLatencyMs`、`maxMaxLatencyMs`,以及 udp 的 `responded`、`response`、`responseSize`、`sourceHost`、`sourcePort` 和 `maxDurationMs`。内容类 expect MUST 使用数组表达配置顺序。 #### Scenario: 解析 HTTP expect 配置 - **WHEN** YAML 配置文件中 HTTP target 的 expect 包含 status、headers、body 规则数组及内部方法 @@ -327,6 +335,14 @@ - **WHEN** ping target 未配置任何 expect 规则 - **THEN** 系统 SHALL 正常处理,expect 字段为 undefined,执行时使用默认 alive=true 语义 +#### Scenario: 解析 udp expect 配置 +- **WHEN** YAML 配置文件中 udp target 的 expect 包含 responded、response、responseSize、sourceHost、sourcePort 和 maxDurationMs +- **THEN** 系统 SHALL 正确解析并存储为 udp target 的 expect 字段 + +#### Scenario: 不配置 udp expect +- **WHEN** udp target 未配置任何 expect 规则 +- **THEN** 系统 SHALL 正常处理,expect 字段为 undefined,执行时使用默认 responded=true 语义 + ### Requirement: 数据保留配置字段 配置 schema 的 `runtime` 段 SHALL 支持 `retention` 字段,类型为字符串,格式为 `<数字><单位>`(单位:`d` 天、`h` 小时、`m` 分钟),用于指定历史数据保留时长。 diff --git a/openspec/specs/udp-checker/spec.md b/openspec/specs/udp-checker/spec.md new file mode 100644 index 0000000..a1f1bdf --- /dev/null +++ b/openspec/specs/udp-checker/spec.md @@ -0,0 +1,203 @@ +## Purpose + +定义 UDP checker 的 target 配置、defaults、执行语义、响应编码、expect 校验、失败结构和状态摘要。 + +## Requirements + +### Requirement: udp target 配置 +系统 SHALL 支持 `type: udp` 的 target 配置,通过 `udp.host` 和 `udp.port` 描述目标 UDP 地址,并通过可选字段控制 payload、编码和响应大小限制。 + +#### Scenario: 解析最简 udp target +- **WHEN** YAML 中 target 配置 `type: udp`、`udp.host: "127.0.0.1"` 和 `udp.port: 9000` +- **THEN** 系统 SHALL 将其解析为 udp checker,并填充 `payload=""`、`encoding="text"`、`responseEncoding="text"`、`maxResponseBytes=4096`、interval、timeout、group 和 expect 配置 + +#### Scenario: udp target 缺少 host +- **WHEN** YAML 中 target 配置 `type: udp` 但缺少 `udp.host` +- **THEN** 系统 SHALL 以配置错误退出,并提示该 target 缺少 udp.host 字段 + +#### Scenario: udp target 缺少 port +- **WHEN** YAML 中 target 配置 `type: udp` 但缺少 `udp.port` +- **THEN** 系统 SHALL 以配置错误退出,并提示该 target 缺少 udp.port 字段 + +#### Scenario: udp port 范围非法 +- **WHEN** YAML 中 udp target 的 `udp.port` 不是 1 到 65535 之间的整数 +- **THEN** 系统 SHALL 以配置错误退出,并提示 udp.port 必须为合法 UDP 端口 + +#### Scenario: 省略 payload 发送空 datagram +- **WHEN** YAML 中 udp target 未配置 `udp.payload` +- **THEN** 系统 SHALL 使用空字符串作为 payload,并在执行时发送零长度 UDP datagram + +#### Scenario: udp defaults 覆盖通用 UDP 参数 +- **WHEN** YAML 中配置 `defaults.udp.encoding: "hex"`、`defaults.udp.responseEncoding: "hex"` 和 `defaults.udp.maxResponseBytes: "8KB"` +- **THEN** 未显式配置对应字段的 udp target SHALL 使用 defaults.udp 中的值 + +#### Scenario: per-target UDP 参数覆盖 defaults +- **WHEN** defaults.udp 配置了 encoding、responseEncoding 或 maxResponseBytes,且某个 udp target 显式配置对应字段 +- **THEN** 该 target SHALL 使用自身 udp 分组中的值 + +#### Scenario: udp 分组未知字段失败 +- **WHEN** YAML 中 udp target 的 `udp` 分组包含 `dnsQuery`、`expectResponse` 或其他未知字段 +- **THEN** 系统 SHALL 以配置错误退出,提示 udp 分组包含未知字段 + +#### Scenario: udp 序列化展示摘要 +- **WHEN** 系统同步 udp target 到 targets 表 +- **THEN** `target` 展示摘要 SHALL 为 `udp :`,`config` JSON SHALL 包含 resolved 后的 host、port、payload、encoding、responseEncoding 和 maxResponseBytes + +### Requirement: udp payload 编码 +系统 SHALL 支持将 `udp.payload` 按 `udp.encoding` 解码为发送字节,编码类型限定为 `text`、`hex` 和 `base64`。 + +#### Scenario: text payload 编码 +- **WHEN** udp target 配置 `udp.payload: "PING"` 且 `udp.encoding` 未配置或为 `text` +- **THEN** 系统 SHALL 以 UTF-8 字节发送 `PING` + +#### Scenario: hex payload 编码 +- **WHEN** udp target 配置 `udp.payload: "50494e47"` 和 `udp.encoding: "hex"` +- **THEN** 系统 SHALL 解码 hex 后发送字节内容 `PING` + +#### Scenario: base64 payload 编码 +- **WHEN** udp target 配置 `udp.payload: "UElORw=="` 和 `udp.encoding: "base64"` +- **THEN** 系统 SHALL 解码 base64 后发送字节内容 `PING` + +#### Scenario: 非法 encoding 失败 +- **WHEN** YAML 中 udp target 配置 `udp.encoding: "json"` +- **THEN** 系统 SHALL 以配置错误退出,提示 udp.encoding 必须为 `text`、`hex` 或 `base64` + +#### Scenario: 非法 responseEncoding 失败 +- **WHEN** YAML 中 udp target 配置 `udp.responseEncoding: "json"` +- **THEN** 系统 SHALL 以配置错误退出,提示 udp.responseEncoding 必须为 `text`、`hex` 或 `base64` + +#### Scenario: 非法 hex payload 失败 +- **WHEN** YAML 中 udp target 配置 `udp.encoding: "hex"` 但 `udp.payload` 不是合法 hex 字符串 +- **THEN** 系统 SHALL 以配置错误退出,提示 udp.payload 与 udp.encoding 不匹配 + +#### Scenario: 非法 base64 payload 失败 +- **WHEN** YAML 中 udp target 配置 `udp.encoding: "base64"` 但 `udp.payload` 不是合法 base64 字符串 +- **THEN** 系统 SHALL 以配置错误退出,提示 udp.payload 与 udp.encoding 不匹配 + +### Requirement: udp checker 执行 +系统 SHALL 使用 Bun connected UDP socket 向目标发送单个 datagram,等待第一个 UDP 响应 datagram,记录完整执行耗时,并在发送失败、超时、资源超限或底层 socket 错误时产生结构化失败信息。 + +#### Scenario: UDP 请求响应成功 +- **WHEN** udp target 指向会返回 `PONG` 的 UDP 服务,且未配置 expect 或 `expect.responded` 为 `true` +- **THEN** 系统 SHALL 记录 `matched=true`、`durationMs` 和包含响应大小的 `statusDetail`,并关闭 socket + +#### Scenario: 使用 hostname 执行 UDP 探测 +- **WHEN** udp target 的 `udp.host` 为可解析域名或 `localhost` +- **THEN** 系统 SHALL 使用 Bun connected UDP socket 完成发送和接收,不要求配置 IP 地址 + +#### Scenario: 只处理第一个响应 datagram +- **WHEN** UDP 服务对一次请求返回多个 datagram +- **THEN** 系统 SHALL 仅使用第一个收到的 UDP datagram 执行 expect 校验,并关闭 socket + +#### Scenario: UDP 无响应且默认期望响应 +- **WHEN** udp target 指向在 timeout 内不返回 UDP datagram 的服务,且未配置 expect 或 `expect.responded` 为 `true` +- **THEN** 系统 SHALL 在 `ctx.signal` abort 后记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `response`,message 包含超时或未响应信息 + +#### Scenario: 期望无响应且实际无响应 +- **WHEN** udp target 配置 `expect.responded: false`,且 timeout 内未收到 UDP datagram +- **THEN** 系统 SHALL 记录 `matched=true`,statusDetail SHALL 表示未收到响应 + +#### Scenario: 期望无响应但实际收到响应 +- **WHEN** udp target 配置 `expect.responded: false`,但收到了 UDP datagram +- **THEN** 系统 SHALL 记录 `matched=false`,failure 的 kind 为 `mismatch`,phase 为 `responded` + +#### Scenario: UDP socket 底层错误 +- **WHEN** Bun UDP socket 在发送或接收过程中触发 error 事件 +- **THEN** 系统 SHALL best-effort 关闭 socket,并记录 `matched=false`、failure.kind=`error` 和可读错误信息 + +#### Scenario: ICMP unreachable 不作为 UDP 响应 +- **WHEN** 底层系统因目标端口不可达产生 ICMP unreachable +- **THEN** 系统 SHALL NOT 将其视为 `responded=true` 的 UDP datagram 响应 + +#### Scenario: UDP 执行超时关闭 socket +- **WHEN** 引擎注入的 `ctx.signal` 在 UDP 发送或等待响应过程中 abort +- **THEN** 系统 SHALL best-effort 关闭 UDP socket,并记录结构化超时或未响应结果 + +### Requirement: udp 响应大小限制 +系统 SHALL 使用 `udp.maxResponseBytes` 限制单个 UDP 响应 datagram 的可处理字节数,默认值为 4096,支持数字或 size string。 + +#### Scenario: 响应大小未超过限制 +- **WHEN** udp target 配置 `udp.maxResponseBytes: 4096`,且实际响应为 16 字节 +- **THEN** 系统 SHALL 允许后续 expect 校验继续执行 + +#### Scenario: 响应大小超过限制 +- **WHEN** udp target 配置 `udp.maxResponseBytes: 4`,且实际响应为 16 字节 +- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 kind 为 `error`,phase 为 `response`,message 包含响应超过大小限制的信息 + +#### Scenario: Bun 标记 datagram 被截断 +- **WHEN** Bun UDP data 回调中的 `flags.truncated` 为 `true` +- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 kind 为 `error`,phase 为 `response`,message 包含响应被截断的信息 + +#### Scenario: maxResponseBytes 格式非法 +- **WHEN** YAML 中 udp target 或 defaults.udp 的 `maxResponseBytes` 不是非负整数或合法 size string +- **THEN** 系统 SHALL 以配置错误退出,提示 maxResponseBytes 格式错误 + +### Requirement: udp expect 校验 +系统 SHALL 支持 udp 专属 expect,包括 `responded`、`response`、`responseSize`、`sourceHost`、`sourcePort` 和 `maxDurationMs`,并按 responded、responseSize、response、sourceHost、sourcePort、duration 的阶段顺序快速失败。 + +#### Scenario: 默认 responded 成功语义 +- **WHEN** udp target 未显式配置 `expect.responded` +- **THEN** 系统 SHALL 使用默认 `expect.responded: true` 进行校验 + +#### Scenario: response text rules 校验通过 +- **WHEN** udp target 配置 `expect.response: [{ contains: "PONG" }]`,且按 `responseEncoding` 转换后的响应文本包含 `PONG` +- **THEN** 系统 SHALL 判定 response 阶段通过 + +#### Scenario: response text rules 校验失败 +- **WHEN** udp target 配置 `expect.response: [{ contains: "PONG" }]`,但按 `responseEncoding` 转换后的响应文本不包含 `PONG` +- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 kind 为 `mismatch`,phase 为 `response`,path 指向失败的 response 规则 + +#### Scenario: responseEncoding 为 hex +- **WHEN** udp target 配置 `udp.responseEncoding: "hex"` 且收到字节内容 `PONG` +- **THEN** 系统 SHALL 将响应转换为小写 hex 字符串 `504f4e47` 后执行 `expect.response` 规则 + +#### Scenario: responseEncoding 为 base64 +- **WHEN** udp target 配置 `udp.responseEncoding: "base64"` 且收到字节内容 `PONG` +- **THEN** 系统 SHALL 将响应转换为 base64 字符串 `UE9ORw==` 后执行 `expect.response` 规则 + +#### Scenario: responseSize operator 校验通过 +- **WHEN** udp target 配置 `expect.responseSize: { gte: 4 }`,且实际响应为 4 字节 +- **THEN** 系统 SHALL 判定 responseSize 阶段通过 + +#### Scenario: responseSize operator 校验失败 +- **WHEN** udp target 配置 `expect.responseSize: { gte: 4 }`,但实际响应为 2 字节 +- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 kind 为 `mismatch`,phase 为 `responseSize` + +#### Scenario: sourceHost operator 校验 +- **WHEN** udp target 配置 `expect.sourceHost: { equals: "127.0.0.1" }`,且 Bun 回调中的来源地址为 `127.0.0.1` +- **THEN** 系统 SHALL 判定 sourceHost 阶段通过 + +#### Scenario: sourcePort operator 校验 +- **WHEN** udp target 配置 `expect.sourcePort: { equals: 9000 }`,且 Bun 回调中的来源端口为 `9000` +- **THEN** 系统 SHALL 判定 sourcePort 阶段通过 + +#### Scenario: maxDurationMs 校验 +- **WHEN** udp target 配置 `expect.maxDurationMs: 100`,且完整执行耗时超过 100ms +- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 phase 为 `duration` + +#### Scenario: response 断言要求实际有响应 +- **WHEN** udp target 配置了 `expect.response` 或 `expect.responseSize`,但同时配置 `expect.responded: false` +- **THEN** 系统 SHALL 在启动期配置校验失败,提示响应内容或大小断言需要 `expect.responded` 为 true + +#### Scenario: source 断言要求实际有响应 +- **WHEN** udp target 配置了 `expect.sourceHost` 或 `expect.sourcePort`,但同时配置 `expect.responded: false` +- **THEN** 系统 SHALL 在启动期配置校验失败,提示响应来源断言需要 `expect.responded` 为 true + +#### Scenario: udp expect 未知字段失败 +- **WHEN** YAML 中 udp target 的 expect 包含 `status: [200]` 或其他非 udp expect 字段 +- **THEN** 系统 SHALL 以配置错误退出,提示 expect 包含未知字段 + +### Requirement: udp statusDetail 摘要 +系统 SHALL 在 udp 执行后生成简短 statusDetail 摘要,展示关键结果并避免写入过长响应内容。 + +#### Scenario: 收到响应的摘要 +- **WHEN** udp target 收到 4 字节响应且完整执行耗时为 12ms +- **THEN** statusDetail SHALL 包含 `responded in 12ms` 和 `4 bytes` + +#### Scenario: 未收到响应的摘要 +- **WHEN** udp target 配置 `expect.responded: false` 且 timeout 内未收到 UDP datagram +- **THEN** statusDetail SHALL 包含 `no response` 和执行耗时 + +#### Scenario: 响应内容摘要截断 +- **WHEN** udp target 收到较长响应内容 +- **THEN** statusDetail SHALL 只展示按 `responseEncoding` 转换并截断后的响应摘要 diff --git a/probe-config.schema.json b/probe-config.schema.json index 8fba557..e3fb2e9 100644 --- a/probe-config.schema.json +++ b/probe-config.schema.json @@ -88,6 +88,55 @@ "additionalProperties": false, "type": "object", "properties": {} + }, + "udp": { + "additionalProperties": false, + "type": "object", + "properties": { + "encoding": { + "anyOf": [ + { + "const": "text", + "type": "string" + }, + { + "const": "hex", + "type": "string" + }, + { + "const": "base64", + "type": "string" + } + ] + }, + "maxResponseBytes": { + "anyOf": [ + { + "type": "string" + }, + { + "minimum": 0, + "type": "integer" + } + ] + }, + "responseEncoding": { + "anyOf": [ + { + "const": "text", + "type": "string" + }, + { + "const": "hex", + "type": "string" + }, + { + "const": "base64", + "type": "string" + } + ] + } + } } } }, @@ -1248,6 +1297,359 @@ } } } + }, + { + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "type", + "udp" + ], + "properties": { + "description": { + "anyOf": [ + { + "type": "null" + }, + { + "maxLength": 500, + "type": "string" + } + ] + }, + "expect": { + "additionalProperties": false, + "type": "object", + "properties": { + "maxDurationMs": { + "minimum": 0, + "type": "number" + }, + "responded": { + "type": "boolean" + }, + "response": { + "type": "array", + "items": { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "match": { + "type": "string" + } + } + } + }, + "responseSize": { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "match": { + "type": "string" + } + } + }, + "sourceHost": { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "match": { + "type": "string" + } + } + }, + "sourcePort": { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "match": { + "type": "string" + } + } + } + } + }, + "group": { + "type": "string" + }, + "id": { + "maxLength": 30, + "minLength": 1, + "type": "string" + }, + "interval": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "null" + }, + { + "maxLength": 30, + "minLength": 1, + "type": "string" + } + ] + }, + "timeout": { + "type": "string" + }, + "type": { + "const": "udp", + "type": "string" + }, + "udp": { + "additionalProperties": false, + "type": "object", + "required": [ + "host", + "port" + ], + "properties": { + "encoding": { + "anyOf": [ + { + "const": "text", + "type": "string" + }, + { + "const": "hex", + "type": "string" + }, + { + "const": "base64", + "type": "string" + } + ] + }, + "host": { + "minLength": 1, + "type": "string" + }, + "maxResponseBytes": { + "anyOf": [ + { + "type": "string" + }, + { + "minimum": 0, + "type": "integer" + } + ] + }, + "payload": { + "type": "string" + }, + "port": { + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "responseEncoding": { + "anyOf": [ + { + "const": "text", + "type": "string" + }, + { + "const": "hex", + "type": "string" + }, + { + "const": "base64", + "type": "string" + } + ] + } + } + } + } } ] } diff --git a/probes.example.yaml b/probes.example.yaml index b96020a..0b5fa21 100644 --- a/probes.example.yaml +++ b/probes.example.yaml @@ -187,3 +187,44 @@ targets: maxAvgLatencyMs: 100 maxMaxLatencyMs: 300 maxDurationMs: 5000 + + # ========== UDP targets ========== + + - id: "udp-heartbeat" + name: "UDP 心跳检测" + type: udp + group: "基础设施" + udp: + host: "127.0.0.1" + port: 9000 + payload: "PING" + expect: + response: + - contains: "PONG" + maxDurationMs: 100 + + - id: "udp-binary-probe" + name: "UDP 二进制协议探测" + type: udp + group: "基础设施" + udp: + host: "127.0.0.1" + port: 5683 + payload: "400100" + encoding: hex + responseEncoding: hex + expect: + responseSize: + gte: 4 + maxDurationMs: 200 + + - id: "udp-fire-and-forget" + name: "UDP 发送验证(不等待响应)" + type: udp + group: "基础设施" + udp: + host: "127.0.0.1" + port: 514 + payload: "<14>health check" + expect: + responded: false diff --git a/src/server/checker/runner/index.ts b/src/server/checker/runner/index.ts index 56f6382..3508cea 100644 --- a/src/server/checker/runner/index.ts +++ b/src/server/checker/runner/index.ts @@ -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(); diff --git a/src/server/checker/runner/udp/encoding.ts b/src/server/checker/runner/udp/encoding.ts new file mode 100644 index 0000000..d8e5c67 --- /dev/null +++ b/src/server/checker/runner/udp/encoding.ts @@ -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; +} diff --git a/src/server/checker/runner/udp/execute.ts b/src/server/checker/runner/udp/execute.ts new file mode 100644 index 0000000..1b8eab5 --- /dev/null +++ b/src/server/checker/runner/udp/execute.ts @@ -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 { + readonly configKey = "udp"; + readonly schemas = udpCheckerSchemas; + readonly type = "udp"; + + async execute(t: ResolvedUdpTarget, ctx: CheckerContext): Promise { + 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 { + let settled = false; + let exchangeResolve: ((value: UdpExchangeResult) => void) | undefined; + const exchangePromise = new Promise((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 }; + } +} diff --git a/src/server/checker/runner/udp/expect.ts b/src/server/checker/runner/udp/expect.ts new file mode 100644 index 0000000..37fe537 --- /dev/null +++ b/src/server/checker/runner/udp/expect.ts @@ -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 }; +} diff --git a/src/server/checker/runner/udp/index.ts b/src/server/checker/runner/udp/index.ts new file mode 100644 index 0000000..b537a43 --- /dev/null +++ b/src/server/checker/runner/udp/index.ts @@ -0,0 +1 @@ +export { UdpChecker } from "./execute"; diff --git a/src/server/checker/runner/udp/schema.ts b/src/server/checker/runner/udp/schema.ts new file mode 100644 index 0000000..aca6d22 --- /dev/null +++ b/src/server/checker/runner/udp/schema.ts @@ -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 }, + ), +}; diff --git a/src/server/checker/runner/udp/types.ts b/src/server/checker/runner/udp/types.ts new file mode 100644 index 0000000..3fbbfbf --- /dev/null +++ b/src/server/checker/runner/udp/types.ts @@ -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; +} diff --git a/src/server/checker/runner/udp/validate.ts b/src/server/checker/runner/udp/validate.ts new file mode 100644 index 0000000..b077775 --- /dev/null +++ b/src/server/checker/runner/udp/validate.ts @@ -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 | 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, 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, 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; +} diff --git a/tests/server/checker/runner/registry.test.ts b/tests/server/checker/runner/registry.test.ts index 0e440b0..6919242 100644 --- a/tests/server/checker/runner/registry.test.ts +++ b/tests/server/checker/runner/registry.test.ts @@ -66,8 +66,8 @@ describe("CheckerRegistry", () => { const second = createDefaultCheckerRegistry(); first.register(createChecker("custom")); - expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "ping", "custom"]); - expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "ping"]); + expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "ping", "udp", "custom"]); + expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "ping", "udp"]); expect( first.definitions.every( (checker) => checker.schemas.config && checker.schemas.defaults && checker.schemas.expect, diff --git a/tests/server/checker/runner/udp/encoding.test.ts b/tests/server/checker/runner/udp/encoding.test.ts new file mode 100644 index 0000000..8bc4780 --- /dev/null +++ b/tests/server/checker/runner/udp/encoding.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "bun:test"; + +import { decodePayload, encodeResponse } from "../../../../../src/server/checker/runner/udp/encoding"; + +describe("decodePayload", () => { + it("text: 解码 ASCII 字符串", () => { + const bytes = decodePayload("PING", "text"); + expect(bytes).toEqual(new Uint8Array([0x50, 0x49, 0x4e, 0x47])); + }); + + it("hex: 解码十六进制字符串", () => { + const bytes = decodePayload("50494e47", "hex"); + expect(bytes).toEqual(new Uint8Array([0x50, 0x49, 0x4e, 0x47])); + }); + + it("base64: 解码 Base64 字符串", () => { + const bytes = decodePayload("UElORw==", "base64"); + expect(bytes).toEqual(new Uint8Array([0x50, 0x49, 0x4e, 0x47])); + }); + + it("text: 空字符串返回空 Uint8Array", () => { + const bytes = decodePayload("", "text"); + expect(bytes).toEqual(new Uint8Array(0)); + }); + + it("hex: 空字符串返回空 Uint8Array", () => { + const bytes = decodePayload("", "hex"); + expect(bytes).toEqual(new Uint8Array(0)); + }); + + it("base64: 空字符串返回空 Uint8Array", () => { + const bytes = decodePayload("", "base64"); + expect(bytes).toEqual(new Uint8Array(0)); + }); + + it("text: 多字节 UTF-8 字符", () => { + const bytes = decodePayload("你好", "text"); + expect(encodeResponse(bytes, "text")).toBe("你好"); + }); + + it("hex: 奇数长度字符串抛出错误", () => { + expect(() => decodePayload("abc", "hex")).toThrow(); + }); +}); + +describe("encodeResponse", () => { + it("text: 编码为 UTF-8 字符串", () => { + const bytes = new Uint8Array([0x50, 0x49, 0x4e, 0x47]); + expect(encodeResponse(bytes, "text")).toBe("PING"); + }); + + it("hex: 编码为小写十六进制字符串", () => { + const bytes = new Uint8Array([0x50, 0x49, 0x4e, 0x47]); + expect(encodeResponse(bytes, "hex")).toBe("50494e47"); + }); + + it("base64: 编码为 Base64 字符串", () => { + const bytes = new Uint8Array([0x50, 0x49, 0x4e, 0x47]); + expect(encodeResponse(bytes, "base64")).toBe("UElORw=="); + }); +}); + +describe("roundtrip", () => { + it("hex: [0x00, 0xff, 0x0a] 往返一致", () => { + const original = new Uint8Array([0x00, 0xff, 0x0a]); + const hex = encodeResponse(original, "hex"); + expect(hex).toBe("00ff0a"); + expect(decodePayload(hex, "hex")).toEqual(original); + }); + + it("base64: 任意字节往返一致", () => { + const original = new Uint8Array([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]); + const b64 = encodeResponse(original, "base64"); + expect(decodePayload(b64, "base64")).toEqual(original); + }); +}); diff --git a/tests/server/checker/runner/udp/execute.test.ts b/tests/server/checker/runner/udp/execute.test.ts new file mode 100644 index 0000000..15dff56 --- /dev/null +++ b/tests/server/checker/runner/udp/execute.test.ts @@ -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 = {}, 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; + expect(parsed["host"]).toBe("10.0.0.1"); + expect(parsed["port"]).toBe(9000); + }); +}); diff --git a/tests/server/checker/runner/udp/expect.test.ts b/tests/server/checker/runner/udp/expect.test.ts new file mode 100644 index 0000000..15a4403 --- /dev/null +++ b/tests/server/checker/runner/udp/expect.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "bun:test"; + +import { + checkResponded, + checkResponseSize, + checkResponseText, + checkSourceHost, + checkSourcePort, +} from "../../../../../src/server/checker/runner/udp/expect"; + +describe("checkResponded", () => { + it("responded=true 期望 true → 匹配", () => { + const result = checkResponded(true, true); + expect(result.matched).toBe(true); + expect(result.failure).toBeNull(); + }); + + it("responded=false 期望 true → 不匹配", () => { + const result = checkResponded(false, true); + expect(result.matched).toBe(false); + expect(result.failure!.kind).toBe("mismatch"); + expect(result.failure!.phase).toBe("responded"); + }); + + it("responded=false 期望 false → 匹配", () => { + const result = checkResponded(false, false); + expect(result.matched).toBe(true); + expect(result.failure).toBeNull(); + }); + + it("responded=true 期望 false → 不匹配", () => { + const result = checkResponded(true, false); + expect(result.matched).toBe(false); + expect(result.failure!.kind).toBe("mismatch"); + expect(result.failure!.phase).toBe("responded"); + }); +}); + +describe("checkResponseSize", () => { + it("size=4 gte=4 → 匹配", () => { + const result = checkResponseSize(4, { gte: 4 }); + expect(result.matched).toBe(true); + expect(result.failure).toBeNull(); + }); + + it("size=2 gte=4 → 不匹配,phase=responseSize", () => { + const result = checkResponseSize(2, { gte: 4 }); + expect(result.matched).toBe(false); + expect(result.failure!.kind).toBe("mismatch"); + expect(result.failure!.phase).toBe("responseSize"); + }); + + it("size=10 lt=20 → 匹配", () => { + const result = checkResponseSize(10, { lt: 20 }); + expect(result.matched).toBe(true); + }); + + it("size=5 equals=5 → 匹配", () => { + const result = checkResponseSize(5, { equals: 5 }); + expect(result.matched).toBe(true); + }); +}); + +describe("checkResponseText", () => { + it("单条 contains 匹配", () => { + const result = checkResponseText("PONG", [{ contains: "PONG" }]); + expect(result.matched).toBe(true); + expect(result.failure).toBeNull(); + }); + + it("单条 contains 不匹配,phase=response", () => { + const result = checkResponseText("PING", [{ contains: "PONG" }]); + expect(result.matched).toBe(false); + expect(result.failure!.kind).toBe("mismatch"); + expect(result.failure!.phase).toBe("response"); + }); + + it("多条规则全部匹配", () => { + const result = checkResponseText("hello world", [{ contains: "hello" }, { match: "^hello" }]); + expect(result.matched).toBe(true); + expect(result.failure).toBeNull(); + }); + + it("多条规则第二条失败 → 不匹配", () => { + const result = checkResponseText("hello world", [{ contains: "hello" }, { match: "^world" }]); + expect(result.matched).toBe(false); + expect(result.failure!.phase).toBe("response"); + expect(result.failure!.path).toBe("response[1]"); + }); +}); + +describe("checkSourceHost", () => { + it("equals 匹配", () => { + const result = checkSourceHost("127.0.0.1", { equals: "127.0.0.1" }); + expect(result.matched).toBe(true); + expect(result.failure).toBeNull(); + }); + + it("equals 不匹配", () => { + const result = checkSourceHost("10.0.0.1", { equals: "127.0.0.1" }); + expect(result.matched).toBe(false); + expect(result.failure!.kind).toBe("mismatch"); + expect(result.failure!.phase).toBe("sourceHost"); + }); +}); + +describe("checkSourcePort", () => { + it("equals 匹配", () => { + const result = checkSourcePort(9000, { equals: 9000 }); + expect(result.matched).toBe(true); + expect(result.failure).toBeNull(); + }); + + it("equals 不匹配", () => { + const result = checkSourcePort(8080, { equals: 9000 }); + expect(result.matched).toBe(false); + expect(result.failure!.kind).toBe("mismatch"); + expect(result.failure!.phase).toBe("sourcePort"); + }); +}); diff --git a/tests/server/checker/runner/udp/validate.test.ts b/tests/server/checker/runner/udp/validate.test.ts new file mode 100644 index 0000000..6d39998 --- /dev/null +++ b/tests/server/checker/runner/udp/validate.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from "bun:test"; + +import type { CheckerValidationInput } from "../../../../../src/server/checker/runner/types"; + +import { validateUdpConfig } from "../../../../../src/server/checker/runner/udp/validate"; + +describe("validateUdpConfig", () => { + const makeInput = (overrides: { + defaults?: Record; + targets?: Array>; + }): CheckerValidationInput => ({ + defaults: overrides.defaults ?? {}, + targets: (overrides.targets ?? []) as CheckerValidationInput["targets"], + }); + + it("accepts minimal valid UDP target", () => { + const issues = validateUdpConfig( + makeInput({ + targets: [{ id: "test", type: "udp", udp: { host: "127.0.0.1", port: 53 } }], + }), + ); + expect(issues).toHaveLength(0); + }); + + it("reports required when udp.host is missing", () => { + const issues = validateUdpConfig( + makeInput({ + targets: [{ id: "test", type: "udp", udp: { port: 53 } }], + }), + ); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe("required"); + expect(issues[0]!.path).toContain("host"); + }); + + it("reports required when udp.port is missing", () => { + const issues = validateUdpConfig( + makeInput({ + targets: [{ id: "test", type: "udp", udp: { host: "127.0.0.1" } }], + }), + ); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe("required"); + expect(issues[0]!.path).toContain("port"); + }); + + it("rejects invalid port values", () => { + for (const port of [0, -1, 65536, 1.5]) { + const issues = validateUdpConfig( + makeInput({ + targets: [{ id: "test", type: "udp", udp: { host: "127.0.0.1", port } }], + }), + ); + expect(issues.length).toBeGreaterThanOrEqual(1); + expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("port"))).toBe(true); + } + }); + + it("reports required when udp section is missing", () => { + const issues = validateUdpConfig( + makeInput({ + targets: [{ id: "test", type: "udp" }], + }), + ); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe("required"); + expect(issues[0]!.path).toContain("udp"); + }); + + it("accepts valid defaults.udp with encoding, responseEncoding, maxResponseBytes", () => { + const issues = validateUdpConfig( + makeInput({ + defaults: { + udp: { encoding: "hex", maxResponseBytes: 1024, responseEncoding: "text" }, + }, + targets: [{ id: "test", type: "udp", udp: { host: "127.0.0.1", port: 53 } }], + }), + ); + expect(issues).toHaveLength(0); + }); + + it("reports unknown-field in defaults.udp", () => { + const issues = validateUdpConfig( + makeInput({ + defaults: { udp: { unknownField: true } }, + targets: [{ id: "test", type: "udp", udp: { host: "127.0.0.1", port: 53 } }], + }), + ); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe("unknown-field"); + }); + + it("reports invalid-value for udp.encoding with bad value", () => { + const issues = validateUdpConfig( + makeInput({ + targets: [ + { + id: "test", + type: "udp", + udp: { encoding: "json", host: "127.0.0.1", port: 53 }, + }, + ], + }), + ); + expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("encoding"))).toBe(true); + }); + + it("reports invalid-value for udp.responseEncoding with bad value", () => { + const issues = validateUdpConfig( + makeInput({ + targets: [ + { + id: "test", + type: "udp", + udp: { host: "127.0.0.1", port: 53, responseEncoding: "binary" }, + }, + ], + }), + ); + expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("responseEncoding"))).toBe(true); + }); + + it("reports invalid-value for hex payload that is not valid hex", () => { + const issues = validateUdpConfig( + makeInput({ + targets: [ + { + id: "test", + type: "udp", + udp: { encoding: "hex", host: "127.0.0.1", payload: "ZZZZ", port: 53 }, + }, + ], + }), + ); + expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("payload"))).toBe(true); + }); + + it("reports invalid-value for base64 payload that is not valid base64", () => { + const issues = validateUdpConfig( + makeInput({ + targets: [ + { + id: "test", + type: "udp", + udp: { encoding: "base64", host: "127.0.0.1", payload: "!!!notbase64", port: 53 }, + }, + ], + }), + ); + expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("payload"))).toBe(true); + }); + + it("reports unknown-field for unknown key in udp group", () => { + const issues = validateUdpConfig( + makeInput({ + targets: [ + { + id: "test", + type: "udp", + udp: { bogus: true, host: "127.0.0.1", port: 53 }, + }, + ], + }), + ); + expect(issues.some((i) => i.code === "unknown-field" && i.path.includes("bogus"))).toBe(true); + }); + + it("reports unknown-field for unknown key in expect", () => { + const issues = validateUdpConfig( + makeInput({ + targets: [ + { + expect: { unknownExpect: true }, + id: "test", + type: "udp", + udp: { host: "127.0.0.1", port: 53 }, + }, + ], + }), + ); + expect(issues.some((i) => i.code === "unknown-field" && i.path.includes("unknownExpect"))).toBe(true); + }); + + it("reports conflict when expect.responded=false with expect.response", () => { + const issues = validateUdpConfig( + makeInput({ + targets: [ + { + expect: { responded: false, response: [{ type: "contains", value: "ok" }] }, + id: "test", + type: "udp", + udp: { host: "127.0.0.1", port: 53 }, + }, + ], + }), + ); + expect(issues.some((i) => i.path.includes("responded") && i.message.includes("响应内容"))).toBe(true); + }); + + it("reports conflict when expect.responded=false with expect.sourceHost", () => { + const issues = validateUdpConfig( + makeInput({ + targets: [ + { + expect: { responded: false, sourceHost: { eq: "1.2.3.4" } }, + id: "test", + type: "udp", + udp: { host: "127.0.0.1", port: 53 }, + }, + ], + }), + ); + expect(issues.some((i) => i.path.includes("responded") && i.message.includes("响应来源"))).toBe(true); + }); + + it("reports invalid-type for negative expect.maxDurationMs", () => { + const issues = validateUdpConfig( + makeInput({ + targets: [ + { + expect: { maxDurationMs: -100 }, + id: "test", + type: "udp", + udp: { host: "127.0.0.1", port: 53 }, + }, + ], + }), + ); + expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("maxDurationMs"))).toBe(true); + }); + + it("reports invalid-type for non-boolean expect.responded", () => { + const issues = validateUdpConfig( + makeInput({ + targets: [ + { + expect: { responded: "yes" }, + id: "test", + type: "udp", + udp: { host: "127.0.0.1", port: 53 }, + }, + ], + }), + ); + expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("responded"))).toBe(true); + }); + + it("reports invalid-value for negative maxResponseBytes", () => { + const issues = validateUdpConfig( + makeInput({ + targets: [ + { + id: "test", + type: "udp", + udp: { host: "127.0.0.1", maxResponseBytes: -1, port: 53 }, + }, + ], + }), + ); + expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("maxResponseBytes"))).toBe(true); + }); +});