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

@@ -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 集成)

View File

@@ -10,11 +10,11 @@
---
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP** 和 **Ping** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP**、**UDP****Ping** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
**功能亮点:**
- 多种拨测类型HTTPGET/POST/PUT 等、Cmd命令行执行、DBPostgreSQL/MySQL/SQLite、TCP端口可达性 + Banner 探测、PingICMP 存活、延迟、丢包率)
- 多种拨测类型HTTPGET/POST/PUT 等、Cmd命令行执行、DBPostgreSQL/MySQL/SQLite、TCP端口可达性 + Banner 探测)、UDP自定义 payload 请求-响应)、PingICMP 存活、延迟、丢包率)
- 丰富的校验规则状态码、响应头、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

View File

@@ -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` 分钟),用于指定历史数据保留时长。

View File

@@ -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 <host>:<port>``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` 转换并截断后的响应摘要

View File

@@ -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"
}
]
}
}
}
}
}
]
}

View File

@@ -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

View File

@@ -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();

View 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;
}

View 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 };
}
}

View 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 };
}

View File

@@ -0,0 +1 @@
export { UdpChecker } from "./execute";

View 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 },
),
};

View 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;
}

View 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;
}

View File

@@ -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,

View File

@@ -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);
});
});

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);
});
});

View File

@@ -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");
});
});

View File

@@ -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<string, unknown>;
targets?: Array<Record<string, unknown>>;
}): 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);
});
});