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:
@@ -62,6 +62,7 @@ src/
|
|||||||
db/ DB Checker(自包含模块,含 types/schema/execute/expect/validate)
|
db/ DB Checker(自包含模块,含 types/schema/execute/expect/validate)
|
||||||
tcp/ TCP Checker(自包含模块,含 types/schema/execute/expect/validate)
|
tcp/ TCP Checker(自包含模块,含 types/schema/execute/expect/validate)
|
||||||
icmp/ Ping Checker(自包含模块,含 types/schema/execute/expect/validate/parse)
|
icmp/ Ping Checker(自包含模块,含 types/schema/execute/expect/validate/parse)
|
||||||
|
udp/ UDP Checker(自包含模块,含 types/schema/execute/expect/validate/encoding)
|
||||||
shared/
|
shared/
|
||||||
api.ts 前后端共享 TypeScript 类型
|
api.ts 前后端共享 TypeScript 类型
|
||||||
web/ React 前端 Dashboard(通过 Bun HTML import 集成)
|
web/ React 前端 Dashboard(通过 Bun HTML import 集成)
|
||||||
|
|||||||
16
README.md
16
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、正则匹配、数值比较等
|
- 丰富的校验规则:状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
|
||||||
- 响应式 Dashboard:实时状态、可用率统计、耗时趋势图、手动/自动刷新
|
- 响应式 Dashboard:实时状态、可用率统计、耗时趋势图、手动/自动刷新
|
||||||
- 多主题支持:系统、明亮、黑暗三种主题模式
|
- 多主题支持:系统、明亮、黑暗三种主题模式
|
||||||
@@ -144,6 +144,18 @@ targets:
|
|||||||
expect:
|
expect:
|
||||||
maxDurationMs: 3000
|
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"
|
- id: "gateway-ping"
|
||||||
name: "网关 ICMP 可达"
|
name: "网关 ICMP 可达"
|
||||||
type: ping
|
type: ping
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Requirement: YAML 配置文件格式
|
### 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: 完整配置文件解析
|
#### Scenario: 完整配置文件解析
|
||||||
- **WHEN** 系统启动并读取包含 server、runtime、variables、defaults、targets(含 id、group 字段)的 YAML 配置文件
|
- **WHEN** 系统启动并读取包含 server、runtime、variables、defaults、targets(含 id、group 字段)的 YAML 配置文件
|
||||||
@@ -61,6 +61,14 @@
|
|||||||
- **WHEN** 系统读取只包含一个 `type: ping` target(含 `id` 和 `ping.host`)的 YAML 配置文件
|
- **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
|
- **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 参数
|
### Requirement: CLI 参数
|
||||||
系统 SHALL 通过单一命令行参数接受 YAML 配置文件路径。
|
系统 SHALL 通过单一命令行参数接受 YAML 配置文件路径。
|
||||||
|
|
||||||
@@ -285,7 +293,7 @@
|
|||||||
- **THEN** 系统 SHALL 调用 `Bun.YAML.parse()` 将内容解析为配置对象
|
- **THEN** 系统 SHALL 调用 `Bun.YAML.parse()` 将内容解析为配置对象
|
||||||
|
|
||||||
### Requirement: expect 配置增强
|
### 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 配置
|
#### Scenario: 解析 HTTP expect 配置
|
||||||
- **WHEN** YAML 配置文件中 HTTP target 的 expect 包含 status、headers、body 规则数组及内部方法
|
- **WHEN** YAML 配置文件中 HTTP target 的 expect 包含 status、headers、body 规则数组及内部方法
|
||||||
@@ -327,6 +335,14 @@
|
|||||||
- **WHEN** ping target 未配置任何 expect 规则
|
- **WHEN** ping target 未配置任何 expect 规则
|
||||||
- **THEN** 系统 SHALL 正常处理,expect 字段为 undefined,执行时使用默认 alive=true 语义
|
- **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: 数据保留配置字段
|
### Requirement: 数据保留配置字段
|
||||||
配置 schema 的 `runtime` 段 SHALL 支持 `retention` 字段,类型为字符串,格式为 `<数字><单位>`(单位:`d` 天、`h` 小时、`m` 分钟),用于指定历史数据保留时长。
|
配置 schema 的 `runtime` 段 SHALL 支持 `retention` 字段,类型为字符串,格式为 `<数字><单位>`(单位:`d` 天、`h` 小时、`m` 分钟),用于指定历史数据保留时长。
|
||||||
|
|
||||||
|
|||||||
203
openspec/specs/udp-checker/spec.md
Normal file
203
openspec/specs/udp-checker/spec.md
Normal 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` 转换并截断后的响应摘要
|
||||||
@@ -88,6 +88,55 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {}
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,3 +187,44 @@ targets:
|
|||||||
maxAvgLatencyMs: 100
|
maxAvgLatencyMs: 100
|
||||||
maxMaxLatencyMs: 300
|
maxMaxLatencyMs: 300
|
||||||
maxDurationMs: 5000
|
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
|
||||||
|
|||||||
@@ -4,8 +4,16 @@ import { HttpChecker } from "./http";
|
|||||||
import { IcmpChecker } from "./icmp";
|
import { IcmpChecker } from "./icmp";
|
||||||
import { CheckerRegistry } from "./registry";
|
import { CheckerRegistry } from "./registry";
|
||||||
import { TcpChecker } from "./tcp";
|
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 {
|
export function createDefaultCheckerRegistry(): CheckerRegistry {
|
||||||
const registry = new CheckerRegistry();
|
const registry = new CheckerRegistry();
|
||||||
|
|||||||
49
src/server/checker/runner/udp/encoding.ts
Normal file
49
src/server/checker/runner/udp/encoding.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { UdpEncoding } from "./types";
|
||||||
|
|
||||||
|
export function decodePayload(payload: string, encoding: UdpEncoding): Uint8Array {
|
||||||
|
if (encoding === "hex") return hexToBytes(payload);
|
||||||
|
if (encoding === "base64") return base64ToBytes(payload);
|
||||||
|
return new TextEncoder().encode(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeResponse(data: Uint8Array, encoding: UdpEncoding): string {
|
||||||
|
if (encoding === "hex") return bytesToHex(data);
|
||||||
|
if (encoding === "base64") return bytesToBase64(data);
|
||||||
|
return new TextDecoder().decode(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBytes(base64: string): Uint8Array {
|
||||||
|
if (base64.length === 0) return new Uint8Array(0);
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64(data: Uint8Array): string {
|
||||||
|
let binary = "";
|
||||||
|
for (let i = 0; i < data.byteLength; i++) {
|
||||||
|
binary += String.fromCharCode(data[i]!);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToHex(data: Uint8Array): string {
|
||||||
|
let hex = "";
|
||||||
|
for (let i = 0; i < data.byteLength; i++) {
|
||||||
|
hex += data[i]!.toString(16).padStart(2, "0");
|
||||||
|
}
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToBytes(hex: string): Uint8Array {
|
||||||
|
if (hex.length === 0) return new Uint8Array(0);
|
||||||
|
if (hex.length % 2 !== 0) throw new Error("hex string must have even length");
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
388
src/server/checker/runner/udp/execute.ts
Normal file
388
src/server/checker/runner/udp/execute.ts
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
import { isError } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||||
|
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||||
|
import type { ResolvedUdpTarget, UdpDefaultsConfig, UdpExpectConfig, UdpTargetConfig } from "./types";
|
||||||
|
|
||||||
|
import { checkDuration } from "../../expect/duration";
|
||||||
|
import { errorFailure } from "../../expect/failure";
|
||||||
|
import { parseSize } from "../../utils";
|
||||||
|
import { decodePayload, encodeResponse } from "./encoding";
|
||||||
|
import { checkResponded, checkResponseSize, checkResponseText, checkSourceHost, checkSourcePort } from "./expect";
|
||||||
|
import { udpCheckerSchemas } from "./schema";
|
||||||
|
import { validateUdpConfig } from "./validate";
|
||||||
|
|
||||||
|
const DEFAULT_MAX_RESPONSE_BYTES = 4096;
|
||||||
|
const RESPONSE_PREVIEW_MAX = 80;
|
||||||
|
|
||||||
|
type UdpExchangeResult =
|
||||||
|
| {
|
||||||
|
data: Uint8Array;
|
||||||
|
flags: { truncated: boolean };
|
||||||
|
ok: true;
|
||||||
|
responded: true;
|
||||||
|
sourceAddress: string;
|
||||||
|
sourcePort: number;
|
||||||
|
}
|
||||||
|
| { error: string; ok: false }
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
responded: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||||
|
readonly configKey = "udp";
|
||||||
|
readonly schemas = udpCheckerSchemas;
|
||||||
|
readonly type = "udp";
|
||||||
|
|
||||||
|
async execute(t: ResolvedUdpTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const start = performance.now();
|
||||||
|
const expect = t.expect;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payloadBytes = decodePayload(t.udp.payload, t.udp.encoding);
|
||||||
|
|
||||||
|
const exchangeResult = await udpExchange(t.udp.host, t.udp.port, payloadBytes, ctx.signal);
|
||||||
|
|
||||||
|
if (!exchangeResult.ok) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
if (expect?.responded === false) {
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: null,
|
||||||
|
matched: true,
|
||||||
|
statusDetail: exchangeResult.error,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("response", "response", exchangeResult.error),
|
||||||
|
matched: false,
|
||||||
|
statusDetail: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedResponded = expect?.responded ?? true;
|
||||||
|
const respondedResult = checkResponded(exchangeResult.responded, expectedResponded);
|
||||||
|
if (!respondedResult.matched) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: respondedResult.failure,
|
||||||
|
matched: false,
|
||||||
|
statusDetail: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exchangeResult.responded) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
|
||||||
|
if (!durationResult.matched) {
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: durationResult.failure,
|
||||||
|
matched: false,
|
||||||
|
statusDetail: buildNoResponseDetail(durationMs),
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: null,
|
||||||
|
matched: true,
|
||||||
|
statusDetail: buildNoResponseDetail(durationMs),
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exchangeResult.flags.truncated) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("response", "response", "响应 datagram 被内核截断"),
|
||||||
|
matched: false,
|
||||||
|
statusDetail: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exchangeResult.data.byteLength > t.udp.maxResponseBytes) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure(
|
||||||
|
"response",
|
||||||
|
"response",
|
||||||
|
`响应超过 ${t.udp.maxResponseBytes} 字节限制 (${exchangeResult.data.byteLength} bytes)`,
|
||||||
|
),
|
||||||
|
matched: false,
|
||||||
|
statusDetail: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect?.responseSize) {
|
||||||
|
const sizeResult = checkResponseSize(exchangeResult.data.byteLength, expect.responseSize);
|
||||||
|
if (!sizeResult.matched) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: sizeResult.failure,
|
||||||
|
matched: false,
|
||||||
|
statusDetail: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect?.response) {
|
||||||
|
const responseText = encodeResponse(exchangeResult.data, t.udp.responseEncoding);
|
||||||
|
const textResult = checkResponseText(responseText, expect.response);
|
||||||
|
if (!textResult.matched) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: textResult.failure,
|
||||||
|
matched: false,
|
||||||
|
statusDetail: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect?.sourceHost) {
|
||||||
|
const sourceResult = checkSourceHost(exchangeResult.sourceAddress, expect.sourceHost);
|
||||||
|
if (!sourceResult.matched) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: sourceResult.failure,
|
||||||
|
matched: false,
|
||||||
|
statusDetail: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect?.sourcePort) {
|
||||||
|
const sourceResult = checkSourcePort(exchangeResult.sourcePort, expect.sourcePort);
|
||||||
|
if (!sourceResult.matched) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: sourceResult.failure,
|
||||||
|
matched: false,
|
||||||
|
statusDetail: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
|
||||||
|
if (!durationResult.matched) {
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: durationResult.failure,
|
||||||
|
matched: false,
|
||||||
|
statusDetail: buildRespondedDetail(
|
||||||
|
exchangeResult.data.byteLength,
|
||||||
|
durationMs,
|
||||||
|
t.udp.responseEncoding,
|
||||||
|
exchangeResult.data,
|
||||||
|
),
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: null,
|
||||||
|
matched: true,
|
||||||
|
statusDetail: buildRespondedDetail(
|
||||||
|
exchangeResult.data.byteLength,
|
||||||
|
durationMs,
|
||||||
|
t.udp.responseEncoding,
|
||||||
|
exchangeResult.data,
|
||||||
|
),
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("response", "response", isError(error) ? error.message : String(error)),
|
||||||
|
matched: false,
|
||||||
|
statusDetail: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedUdpTarget {
|
||||||
|
const t = target as RawTargetConfig & { type: "udp"; udp: UdpTargetConfig };
|
||||||
|
const udpDefaults = context.defaults["udp"] as UdpDefaultsConfig | undefined;
|
||||||
|
|
||||||
|
const encoding = t.udp.encoding ?? udpDefaults?.encoding ?? "text";
|
||||||
|
const responseEncoding = t.udp.responseEncoding ?? udpDefaults?.responseEncoding ?? "text";
|
||||||
|
const maxResponseBytes = parseSize(
|
||||||
|
t.udp.maxResponseBytes ?? udpDefaults?.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: null,
|
||||||
|
expect: target.expect as UdpExpectConfig | undefined,
|
||||||
|
group: target.group ?? "default",
|
||||||
|
id: t.id,
|
||||||
|
intervalMs: context.defaultIntervalMs,
|
||||||
|
name: t.name ?? null,
|
||||||
|
timeoutMs: context.defaultTimeoutMs,
|
||||||
|
type: "udp",
|
||||||
|
udp: {
|
||||||
|
encoding,
|
||||||
|
host: t.udp.host,
|
||||||
|
maxResponseBytes,
|
||||||
|
payload: t.udp.payload ?? "",
|
||||||
|
port: t.udp.port,
|
||||||
|
responseEncoding,
|
||||||
|
},
|
||||||
|
} satisfies ResolvedUdpTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize(t: ResolvedUdpTarget): { config: string; target: string } {
|
||||||
|
return {
|
||||||
|
config: JSON.stringify(t.udp),
|
||||||
|
target: `udp ${t.udp.host}:${t.udp.port}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(input: CheckerValidationInput) {
|
||||||
|
return validateUdpConfig(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNoResponseDetail(durationMs: number): string {
|
||||||
|
return `no response in ${durationMs}ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRespondedDetail(size: number, durationMs: number, encoding: string, data: Uint8Array): string {
|
||||||
|
let detail = `responded in ${durationMs}ms, ${size} bytes`;
|
||||||
|
if (size > 0 && size <= RESPONSE_PREVIEW_MAX) {
|
||||||
|
const preview = encodeResponse(data, encoding as "base64" | "hex" | "text");
|
||||||
|
const truncated = preview.length > RESPONSE_PREVIEW_MAX ? `${preview.slice(0, RESPONSE_PREVIEW_MAX)}…` : preview;
|
||||||
|
detail = `${detail}, response: ${truncated}`;
|
||||||
|
}
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
function simplifyUdpError(message: string): string {
|
||||||
|
const lower = message.toLowerCase();
|
||||||
|
if (lower.includes("econnrefused") || lower.includes("connection refused")) return "connection refused";
|
||||||
|
if (lower.includes("enoent") || lower.includes("not found")) return "host not found";
|
||||||
|
if (lower.includes("etimedout") || lower.includes("timed out")) return "timed out";
|
||||||
|
if (lower.includes("econnreset") || lower.includes("reset")) return "connection reset";
|
||||||
|
if (lower.includes("enetwork") || lower.includes("network")) return "network error";
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function udpExchange(
|
||||||
|
hostname: string,
|
||||||
|
port: number,
|
||||||
|
payload: Uint8Array,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<UdpExchangeResult> {
|
||||||
|
let settled = false;
|
||||||
|
let exchangeResolve: ((value: UdpExchangeResult) => void) | undefined;
|
||||||
|
const exchangePromise = new Promise<UdpExchangeResult>((resolve) => {
|
||||||
|
exchangeResolve = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const settle = (result: UdpExchangeResult) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
exchangeResolve!(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const socket = await Bun.udpSocket({
|
||||||
|
connect: { hostname, port },
|
||||||
|
socket: {
|
||||||
|
data(socket, data, _port, _address, flags) {
|
||||||
|
settle({
|
||||||
|
data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
|
||||||
|
flags: { truncated: flags.truncated },
|
||||||
|
ok: true,
|
||||||
|
responded: true,
|
||||||
|
sourceAddress: _address,
|
||||||
|
sourcePort: _port,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
drain() {
|
||||||
|
// Bun UDP socket handler 必填项,UDP checker 不关注 drain 事件
|
||||||
|
},
|
||||||
|
error(socket, error) {
|
||||||
|
settle({ error: error.message, ok: false });
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
return { error: "探测已取消", ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.send(payload);
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
settle({ ok: true, responded: false });
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
|
||||||
|
const result = await exchangePromise;
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return { error: "探测超时", ok: false };
|
||||||
|
}
|
||||||
|
const message = isError(error) ? error.message : String(error);
|
||||||
|
return { error: simplifyUdpError(message), ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/server/checker/runner/udp/expect.ts
Normal file
66
src/server/checker/runner/udp/expect.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { ExpectResult } from "../../expect/types";
|
||||||
|
import type { ExpectOperator } from "../../types";
|
||||||
|
|
||||||
|
import { mismatchFailure } from "../../expect/failure";
|
||||||
|
import { applyOperator } from "../../expect/operator";
|
||||||
|
|
||||||
|
export function checkResponded(responded: boolean, expected: boolean): ExpectResult {
|
||||||
|
if (responded === expected) return { failure: null, matched: true };
|
||||||
|
if (!responded && expected) {
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure("responded", "responded", true, false, "期望收到响应但未收到"),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure("responded", "responded", false, true, "期望无响应但收到响应"),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkResponseSize(size: number, op: ExpectOperator): ExpectResult {
|
||||||
|
const matched = applyOperator(size, op);
|
||||||
|
if (!matched) {
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure("responseSize", "responseSize", op, size, "响应大小不满足条件"),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { failure: null, matched: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkResponseText(text: string, rules: ExpectOperator[]): ExpectResult {
|
||||||
|
for (let i = 0; i < rules.length; i++) {
|
||||||
|
const rule = rules[i]!;
|
||||||
|
const path = `response[${i}]`;
|
||||||
|
if (!applyOperator(text, rule)) {
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure("response", path, rule, text, `response rule at index ${i} mismatch`),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { failure: null, matched: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkSourceHost(actual: string, op: ExpectOperator): ExpectResult {
|
||||||
|
const matched = applyOperator(actual, op);
|
||||||
|
if (!matched) {
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure("sourceHost", "sourceHost", op, actual, "响应来源地址不满足条件"),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { failure: null, matched: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkSourcePort(actual: number, op: ExpectOperator): ExpectResult {
|
||||||
|
const matched = applyOperator(actual, op);
|
||||||
|
if (!matched) {
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure("sourcePort", "sourcePort", op, actual, "响应来源端口不满足条件"),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { failure: null, matched: true };
|
||||||
|
}
|
||||||
1
src/server/checker/runner/udp/index.ts
Normal file
1
src/server/checker/runner/udp/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { UdpChecker } from "./execute";
|
||||||
38
src/server/checker/runner/udp/schema.ts
Normal file
38
src/server/checker/runner/udp/schema.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
import type { CheckerSchemas } from "../types";
|
||||||
|
|
||||||
|
import { createPureOperatorSchema, createTextRulesSchema, sizeSchema } from "../../schema/fragments";
|
||||||
|
|
||||||
|
export const udpCheckerSchemas: CheckerSchemas = {
|
||||||
|
config: Type.Object(
|
||||||
|
{
|
||||||
|
encoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])),
|
||||||
|
host: Type.String({ minLength: 1 }),
|
||||||
|
maxResponseBytes: Type.Optional(sizeSchema),
|
||||||
|
payload: Type.Optional(Type.String()),
|
||||||
|
port: Type.Integer({ maximum: 65535, minimum: 1 }),
|
||||||
|
responseEncoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
),
|
||||||
|
defaults: Type.Object(
|
||||||
|
{
|
||||||
|
encoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])),
|
||||||
|
maxResponseBytes: Type.Optional(sizeSchema),
|
||||||
|
responseEncoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
),
|
||||||
|
expect: Type.Object(
|
||||||
|
{
|
||||||
|
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
|
||||||
|
responded: Type.Optional(Type.Boolean()),
|
||||||
|
response: Type.Optional(createTextRulesSchema()),
|
||||||
|
responseSize: Type.Optional(createPureOperatorSchema()),
|
||||||
|
sourceHost: Type.Optional(createPureOperatorSchema()),
|
||||||
|
sourcePort: Type.Optional(createPureOperatorSchema()),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
),
|
||||||
|
};
|
||||||
46
src/server/checker/runner/udp/types.ts
Normal file
46
src/server/checker/runner/udp/types.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { ExpectOperator, ResolvedTargetBase } from "../../types";
|
||||||
|
|
||||||
|
export interface ResolvedUdpConfig {
|
||||||
|
encoding: UdpEncoding;
|
||||||
|
host: string;
|
||||||
|
maxResponseBytes: number;
|
||||||
|
payload: string;
|
||||||
|
port: number;
|
||||||
|
responseEncoding: UdpEncoding;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedUdpTarget extends ResolvedTargetBase {
|
||||||
|
expect?: UdpExpectConfig;
|
||||||
|
group: string;
|
||||||
|
intervalMs: number;
|
||||||
|
name: null | string;
|
||||||
|
timeoutMs: number;
|
||||||
|
type: "udp";
|
||||||
|
udp: ResolvedUdpConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UdpDefaultsConfig {
|
||||||
|
encoding?: UdpEncoding;
|
||||||
|
maxResponseBytes?: number | string;
|
||||||
|
responseEncoding?: UdpEncoding;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UdpEncoding = "base64" | "hex" | "text";
|
||||||
|
|
||||||
|
export interface UdpExpectConfig {
|
||||||
|
maxDurationMs?: number;
|
||||||
|
responded?: boolean;
|
||||||
|
response?: ExpectOperator[];
|
||||||
|
responseSize?: ExpectOperator;
|
||||||
|
sourceHost?: ExpectOperator;
|
||||||
|
sourcePort?: ExpectOperator;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UdpTargetConfig {
|
||||||
|
encoding?: UdpEncoding;
|
||||||
|
host: string;
|
||||||
|
maxResponseBytes?: number | string;
|
||||||
|
payload?: string;
|
||||||
|
port: number;
|
||||||
|
responseEncoding?: UdpEncoding;
|
||||||
|
}
|
||||||
227
src/server/checker/runner/udp/validate.ts
Normal file
227
src/server/checker/runner/udp/validate.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { isNumber, isPlainObject, isString } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||||
|
import type { CheckerValidationInput } from "../types";
|
||||||
|
|
||||||
|
import { validateOperatorObject } from "../../expect/validate-operator";
|
||||||
|
import { issue, joinPath } from "../../schema/issues";
|
||||||
|
|
||||||
|
const VALID_ENCODINGS = new Set(["base64", "hex", "text"]);
|
||||||
|
|
||||||
|
export function validateUdpConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
|
||||||
|
issues.push(...validateUdpDefaults(input));
|
||||||
|
|
||||||
|
for (let i = 0; i < input.targets.length; i++) {
|
||||||
|
const target = input.targets[i] as unknown;
|
||||||
|
if (!isPlainObject(target)) continue;
|
||||||
|
if (target["type"] !== "udp") continue;
|
||||||
|
issues.push(...validateUdpTarget(target, `targets[${i}]`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTargetName(target: Record<string, unknown>): string | undefined {
|
||||||
|
if (isString(target["name"])) return target["name"];
|
||||||
|
return isString(target["id"]) ? target["id"] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNonNegativeFiniteNumber(value: unknown): boolean {
|
||||||
|
return isNumber(value) && Number.isFinite(value) && value >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEncoding(value: unknown, path: string, targetName: string | undefined): ConfigValidationIssue[] {
|
||||||
|
if (value === undefined) return [];
|
||||||
|
if (!isString(value) || !VALID_ENCODINGS.has(value)) {
|
||||||
|
return [issue("invalid-value", path, "必须为 text、hex 或 base64", targetName)];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSize(value: unknown, path: string, targetName: string | undefined): ConfigValidationIssue[] {
|
||||||
|
if (value === undefined) return [];
|
||||||
|
if (!isString(value) && !(isNumber(value) && Number.isFinite(value) && value >= 0)) {
|
||||||
|
return [issue("invalid-value", path, "必须为合法 size 值", targetName)];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTextRulesArray(value: unknown, path: string, targetName: string | undefined): ConfigValidationIssue[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [issue("invalid-type", path, "必须为数组", targetName)];
|
||||||
|
}
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
const rule: unknown = value[i];
|
||||||
|
if (!isPlainObject(rule)) {
|
||||||
|
issues.push(issue("invalid-type", joinPath(path, `[${i}]`), "必须为 operator 对象", targetName));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
issues.push(...validateOperatorObject(rule, joinPath(path, `[${i}]`), targetName));
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateUdpDefaults(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
const defaults = input.defaults["udp"];
|
||||||
|
if (defaults === undefined || defaults === null || !isPlainObject(defaults)) return issues;
|
||||||
|
|
||||||
|
const targetName = "defaults.udp";
|
||||||
|
|
||||||
|
issues.push(...validateEncoding(defaults["encoding"], joinPath("defaults.udp", "encoding"), targetName));
|
||||||
|
issues.push(
|
||||||
|
...validateEncoding(defaults["responseEncoding"], joinPath("defaults.udp", "responseEncoding"), targetName),
|
||||||
|
);
|
||||||
|
issues.push(...validateSize(defaults["maxResponseBytes"], joinPath("defaults.udp", "maxResponseBytes"), targetName));
|
||||||
|
|
||||||
|
const allowedKeys = new Set(["encoding", "maxResponseBytes", "responseEncoding"]);
|
||||||
|
for (const key of Object.keys(defaults)) {
|
||||||
|
if (!allowedKeys.has(key)) {
|
||||||
|
issues.push(issue("unknown-field", joinPath("defaults.udp", key), "是未知字段", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateUdpExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||||
|
const targetName = getTargetName(target);
|
||||||
|
const expect = target["expect"];
|
||||||
|
if (expect === undefined || expect === null || !isPlainObject(expect)) return [];
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
const expectPath = joinPath(path, "expect");
|
||||||
|
const responded: unknown = expect["responded"];
|
||||||
|
|
||||||
|
if (responded !== undefined && typeof responded !== "boolean") {
|
||||||
|
issues.push(issue("invalid-type", joinPath(expectPath, "responded"), "必须为布尔值", targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect["maxDurationMs"] !== undefined && !isNonNegativeFiniteNumber(expect["maxDurationMs"])) {
|
||||||
|
issues.push(issue("invalid-type", joinPath(expectPath, "maxDurationMs"), "必须为非负有限数字", targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect["response"] !== undefined) {
|
||||||
|
issues.push(...validateTextRulesArray(expect["response"], joinPath(expectPath, "response"), targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect["responseSize"] !== undefined) {
|
||||||
|
issues.push(...validateOperatorObject(expect["responseSize"], joinPath(expectPath, "responseSize"), targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect["sourceHost"] !== undefined) {
|
||||||
|
issues.push(...validateOperatorObject(expect["sourceHost"], joinPath(expectPath, "sourceHost"), targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect["sourcePort"] !== undefined) {
|
||||||
|
issues.push(...validateOperatorObject(expect["sourcePort"], joinPath(expectPath, "sourcePort"), targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
const respondedFalse = responded === false;
|
||||||
|
if (respondedFalse) {
|
||||||
|
if (expect["response"] !== undefined || expect["responseSize"] !== undefined) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-value",
|
||||||
|
joinPath(expectPath, "responded"),
|
||||||
|
"响应内容或大小断言需要 expect.responded 为 true",
|
||||||
|
targetName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (expect["sourceHost"] !== undefined || expect["sourcePort"] !== undefined) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-value",
|
||||||
|
joinPath(expectPath, "responded"),
|
||||||
|
"响应来源断言需要 expect.responded 为 true",
|
||||||
|
targetName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedKeys = new Set(["maxDurationMs", "responded", "response", "responseSize", "sourceHost", "sourcePort"]);
|
||||||
|
for (const key of Object.keys(expect)) {
|
||||||
|
if (!allowedKeys.has(key)) {
|
||||||
|
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateUdpTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
const targetName = getTargetName(target);
|
||||||
|
const udp = target["udp"];
|
||||||
|
|
||||||
|
if (!isPlainObject(udp)) {
|
||||||
|
issues.push(issue("required", joinPath(path, "udp"), "缺少 udp 配置分组", targetName));
|
||||||
|
issues.push(...validateUdpExpect(target, path));
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isString(udp["host"]) || udp["host"].trim() === "") {
|
||||||
|
issues.push(issue("required", joinPath(joinPath(path, "udp"), "host"), "缺少 udp.host 字段", targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (udp["port"] === undefined) {
|
||||||
|
issues.push(issue("required", joinPath(joinPath(path, "udp"), "port"), "缺少 udp.port 字段", targetName));
|
||||||
|
} else if (!isNumber(udp["port"]) || !Number.isInteger(udp["port"]) || udp["port"] < 1 || udp["port"] > 65535) {
|
||||||
|
issues.push(
|
||||||
|
issue("invalid-value", joinPath(joinPath(path, "udp"), "port"), "必须为 1-65535 之间的整数", targetName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoding: unknown = udp["encoding"];
|
||||||
|
issues.push(...validateEncoding(encoding, joinPath(joinPath(path, "udp"), "encoding"), targetName));
|
||||||
|
|
||||||
|
if (encoding === "hex" && isString(udp["payload"])) {
|
||||||
|
const hexPattern = /^[0-9a-fA-F]*$/;
|
||||||
|
if (!hexPattern.test(udp["payload"])) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-value",
|
||||||
|
joinPath(joinPath(path, "udp"), "payload"),
|
||||||
|
"udp.payload 与 udp.encoding 不匹配",
|
||||||
|
targetName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (encoding === "base64" && isString(udp["payload"])) {
|
||||||
|
const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||||
|
if (!base64Pattern.test(udp["payload"])) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-value",
|
||||||
|
joinPath(joinPath(path, "udp"), "payload"),
|
||||||
|
"udp.payload 与 udp.encoding 不匹配",
|
||||||
|
targetName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseEncoding: unknown = udp["responseEncoding"];
|
||||||
|
issues.push(...validateEncoding(responseEncoding, joinPath(joinPath(path, "udp"), "responseEncoding"), targetName));
|
||||||
|
|
||||||
|
issues.push(
|
||||||
|
...validateSize(udp["maxResponseBytes"], joinPath(joinPath(path, "udp"), "maxResponseBytes"), targetName),
|
||||||
|
);
|
||||||
|
|
||||||
|
const allowedUdpKeys = new Set(["encoding", "host", "maxResponseBytes", "payload", "port", "responseEncoding"]);
|
||||||
|
for (const key of Object.keys(udp)) {
|
||||||
|
if (!allowedUdpKeys.has(key)) {
|
||||||
|
issues.push(issue("unknown-field", joinPath(joinPath(path, "udp"), key), "是未知字段", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
issues.push(...validateUdpExpect(target, path));
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
@@ -66,8 +66,8 @@ describe("CheckerRegistry", () => {
|
|||||||
const second = createDefaultCheckerRegistry();
|
const second = createDefaultCheckerRegistry();
|
||||||
first.register(createChecker("custom"));
|
first.register(createChecker("custom"));
|
||||||
|
|
||||||
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "ping", "custom"]);
|
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "ping", "udp", "custom"]);
|
||||||
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "ping"]);
|
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "ping", "udp"]);
|
||||||
expect(
|
expect(
|
||||||
first.definitions.every(
|
first.definitions.every(
|
||||||
(checker) => checker.schemas.config && checker.schemas.defaults && checker.schemas.expect,
|
(checker) => checker.schemas.config && checker.schemas.defaults && checker.schemas.expect,
|
||||||
|
|||||||
76
tests/server/checker/runner/udp/encoding.test.ts
Normal file
76
tests/server/checker/runner/udp/encoding.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
364
tests/server/checker/runner/udp/execute.test.ts
Normal file
364
tests/server/checker/runner/udp/execute.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
120
tests/server/checker/runner/udp/expect.test.ts
Normal file
120
tests/server/checker/runner/udp/expect.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
262
tests/server/checker/runner/udp/validate.test.ts
Normal file
262
tests/server/checker/runner/udp/validate.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user