feat: 新增 TCP checker,支持端口可达性探测与 banner 读取
- 新增 src/server/checker/runner/tcp/ 自包含目录(types/schema/validate/execute/expect) - 注册 TcpChecker 到 checkerRegistry,schema/engine/store/config-loader 自动委托 - 支持 expect.connected 正反向语义(默认期待可达,可配置期待不可达) - 支持 readBanner opt-in banner 读取,受 bannerReadTimeout + maxBannerBytes 双重限制 - 复用电有 expect/operator/duration/failure 基础设施 - 新增 3 个测试文件 51 条用例(execute/validate/expect),全量 634 测试通过 - 更新 README/DEVELOPMENT/probes.example.yaml,新增 tcp-checker capability spec
This commit is contained in:
@@ -59,6 +59,8 @@ src/
|
|||||||
index.ts 注册入口(显式数组 + 循环注册)
|
index.ts 注册入口(显式数组 + 循环注册)
|
||||||
http/ HTTP Checker(自包含模块,含 types/schema/execute/expect/validate/body)
|
http/ HTTP Checker(自包含模块,含 types/schema/execute/expect/validate/body)
|
||||||
cmd/ Cmd Checker(自包含模块,含 types/schema/execute/expect/validate/text)
|
cmd/ Cmd Checker(自包含模块,含 types/schema/execute/expect/validate/text)
|
||||||
|
db/ DB Checker(自包含模块,含 types/schema/execute/expect/validate)
|
||||||
|
tcp/ TCP Checker(自包含模块,含 types/schema/execute/expect/validate)
|
||||||
shared/
|
shared/
|
||||||
api.ts 前后端共享 TypeScript 类型
|
api.ts 前后端共享 TypeScript 类型
|
||||||
web/ React 前端 Dashboard(通过 Bun HTML import 集成)
|
web/ React 前端 Dashboard(通过 Bun HTML import 集成)
|
||||||
|
|||||||
27
README.md
27
README.md
@@ -10,11 +10,11 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行** 和 **数据库** 三种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite,前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
|
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库** 和 **TCP** 四种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite,前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
|
||||||
|
|
||||||
**功能亮点:**
|
**功能亮点:**
|
||||||
|
|
||||||
- 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/MySQL/SQLite)
|
- 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/MySQL/SQLite)、TCP(端口可达性 + Banner 探测)
|
||||||
- 丰富的校验规则:状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
|
- 丰富的校验规则:状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
|
||||||
- 响应式 Dashboard:实时状态、可用率统计、耗时趋势图、手动/自动刷新
|
- 响应式 Dashboard:实时状态、可用率统计、耗时趋势图、手动/自动刷新
|
||||||
- 多主题支持:系统、明亮、黑暗三种主题模式
|
- 多主题支持:系统、明亮、黑暗三种主题模式
|
||||||
@@ -132,6 +132,15 @@ targets:
|
|||||||
rowCount: { gte: 1 }
|
rowCount: { gte: 1 }
|
||||||
rows:
|
rows:
|
||||||
- cnt: { gte: 0 }
|
- cnt: { gte: 0 }
|
||||||
|
|
||||||
|
- id: "redis-port"
|
||||||
|
name: "Redis 端口可达"
|
||||||
|
type: tcp
|
||||||
|
tcp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 6379
|
||||||
|
expect:
|
||||||
|
maxDurationMs: 3000
|
||||||
```
|
```
|
||||||
|
|
||||||
### 配置说明
|
### 配置说明
|
||||||
@@ -181,7 +190,7 @@ targets:
|
|||||||
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 |
|
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 |
|
||||||
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null;前端展示时 null 回退到 `id` | 否 |
|
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null;前端展示时 null 回退到 `id` | 否 |
|
||||||
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null,允许空字符串 | 否 |
|
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null,允许空字符串 | 否 |
|
||||||
| `type` | 目标类型:`http`、`cmd`、`db` | 是 |
|
| `type` | 目标类型:`http`、`cmd`、`db`、`tcp` | 是 |
|
||||||
| `group` | 分组名称 | 否,默认 `"default"` |
|
| `group` | 分组名称 | 否,默认 `"default"` |
|
||||||
| `interval` | 覆盖全局拨测间隔 | 否 |
|
| `interval` | 覆盖全局拨测间隔 | 否 |
|
||||||
| `timeout` | 覆盖全局超时时间 | 否 |
|
| `timeout` | 覆盖全局超时时间 | 否 |
|
||||||
@@ -213,6 +222,16 @@ targets:
|
|||||||
| `db.url` | 数据库连接字符串,支持 `postgres://`、`mysql://`、`sqlite://` |
|
| `db.url` | 数据库连接字符串,支持 `postgres://`、`mysql://`、`sqlite://` |
|
||||||
| `db.query` | SQL 查询语句(不配置时仅测试连接) |
|
| `db.query` | SQL 查询语句(不配置时仅测试连接) |
|
||||||
|
|
||||||
|
**TCP 类型** (`type: tcp`)
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| ----------------------- | ------------------------------------------------------- |
|
||||||
|
| `tcp.host` | 目标主机地址 |
|
||||||
|
| `tcp.port` | 目标端口(1-65535) |
|
||||||
|
| `tcp.readBanner` | 是否读取服务端 banner,默认 `false` |
|
||||||
|
| `tcp.bannerReadTimeout` | banner 读取超时(毫秒),默认 `2000` |
|
||||||
|
| `tcp.maxBannerBytes` | banner 最大字节数,支持 `KB`/`MB`/`GB` 单位,默认 `4KB` |
|
||||||
|
|
||||||
#### expect — 期望校验
|
#### expect — 期望校验
|
||||||
|
|
||||||
| 字段 | 适用类型 | 说明 |
|
| 字段 | 适用类型 | 说明 |
|
||||||
@@ -225,6 +244,8 @@ targets:
|
|||||||
| `stdout` / `stderr` | Cmd | 输出校验(数组,每项一个操作符对象) |
|
| `stdout` / `stderr` | Cmd | 输出校验(数组,每项一个操作符对象) |
|
||||||
| `rowCount` | DB | 查询返回行数校验(操作符对象) |
|
| `rowCount` | DB | 查询返回行数校验(操作符对象) |
|
||||||
| `rows` | DB | 查询结果逐行校验(数组,列名→操作符映射) |
|
| `rows` | DB | 查询结果逐行校验(数组,列名→操作符映射) |
|
||||||
|
| `connected` | TCP | 期望连接结果,`true`(默认)可达或 `false` 期望不可达 |
|
||||||
|
| `banner` | TCP | Banner 文本校验(操作符对象,需开启 `tcp.readBanner`) |
|
||||||
|
|
||||||
**body 校验项**(数组中可混合使用):
|
**body 校验项**(数组中可混合使用):
|
||||||
|
|
||||||
|
|||||||
@@ -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` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`(非负整数)字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`(可选)字段。
|
系统 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` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`(非负整数)字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`(可选)字段。Tcp target 的 `tcp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`readBanner`(可选)、`bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。
|
||||||
|
|
||||||
`defaults.http` 分组 SHALL 仅支持 `headers`(可选)和 `maxBodyBytes`(可选)字段。`defaults.http` 分组 MUST NOT 支持 `method` 字段。
|
`defaults.http` 分组 SHALL 仅支持 `headers`(可选)和 `maxBodyBytes`(可选)字段。`defaults.http` 分组 MUST NOT 支持 `method` 字段。`defaults.tcp` 分组 SHALL 仅支持 `bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。
|
||||||
|
|
||||||
#### Scenario: 完整配置文件解析
|
#### Scenario: 完整配置文件解析
|
||||||
- **WHEN** 系统启动并读取包含 server、runtime、variables、defaults、targets(含 id、group 字段)的 YAML 配置文件
|
- **WHEN** 系统启动并读取包含 server、runtime、variables、defaults、targets(含 id、group 字段)的 YAML 配置文件
|
||||||
@@ -37,6 +37,14 @@
|
|||||||
- **WHEN** 系统读取只包含一个 `type: db` target(含 `id` 和 `db.url`)的 YAML 配置文件
|
- **WHEN** 系统读取只包含一个 `type: db` target(含 `id` 和 `db.url`)的 YAML 配置文件
|
||||||
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(interval=30s, timeout=10s, group="default"),并保留 name=null、description=null
|
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(interval=30s, timeout=10s, group="default"),并保留 name=null、description=null
|
||||||
|
|
||||||
|
#### Scenario: 最简 tcp 配置文件解析
|
||||||
|
- **WHEN** 系统读取只包含一个 `type: tcp` target(含 `id`、`tcp.host` 和 `tcp.port`)的 YAML 配置文件
|
||||||
|
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(interval=30s, timeout=10s, group="default", tcp.readBanner=false, tcp.bannerReadTimeout=2000, tcp.maxBannerBytes=4096),并保留 name=null、description=null
|
||||||
|
|
||||||
|
#### Scenario: defaults.tcp 配置 banner 默认值
|
||||||
|
- **WHEN** YAML 配置中 defaults.tcp 设置 `bannerReadTimeout` 和 `maxBannerBytes`
|
||||||
|
- **THEN** 未显式覆盖对应字段的 tcp target SHALL 使用 defaults.tcp 中的值
|
||||||
|
|
||||||
#### Scenario: defaults.http.method 触发校验错误
|
#### Scenario: defaults.http.method 触发校验错误
|
||||||
- **WHEN** 配置文件中出现 `defaults.http.method` 字段
|
- **WHEN** 配置文件中出现 `defaults.http.method` 字段
|
||||||
- **THEN** 系统 SHALL 以配置错误退出,提示 defaults.http 中存在未知字段 method
|
- **THEN** 系统 SHALL 以配置错误退出,提示 defaults.http 中存在未知字段 method
|
||||||
@@ -382,3 +390,46 @@
|
|||||||
#### Scenario: schema 导出 id 和 name
|
#### Scenario: schema 导出 id 和 name
|
||||||
- **WHEN** 系统导出 `probe-config.schema.json`
|
- **WHEN** 系统导出 `probe-config.schema.json`
|
||||||
- **THEN** target schema SHALL 声明 `id` 的 minLength 为 1、maxLength 为 30,并声明 `name` 为可选字段,类型为 string 或 null,字符串的 minLength 为 1、maxLength 为 30
|
- **THEN** target schema SHALL 声明 `id` 的 minLength 为 1、maxLength 为 30,并声明 `name` 为可选字段,类型为 string 或 null,字符串的 minLength 为 1、maxLength 为 30
|
||||||
|
|
||||||
|
### Requirement: TCP 配置校验
|
||||||
|
系统 SHALL 在启动期对 tcp checker 的配置契约和语义执行严格校验。Tcp target 的 `tcp` 分组 SHALL 只允许 `host`、`port`、`readBanner`、`bannerReadTimeout` 和 `maxBannerBytes` 字段;Tcp expect SHALL 只允许 `connected`、`maxDurationMs` 和 `banner` 字段。未知字段、非法类型、非法端口、非法 size 和不可编译正则 MUST 导致启动期配置错误。
|
||||||
|
|
||||||
|
#### Scenario: tcp host 类型非法
|
||||||
|
- **WHEN** YAML 中 tcp target 的 `tcp.host` 不是非空字符串
|
||||||
|
- **THEN** 系统 SHALL 以配置错误退出,提示 tcp.host 必须为非空字符串
|
||||||
|
|
||||||
|
#### Scenario: tcp port 类型非法
|
||||||
|
- **WHEN** YAML 中 tcp target 的 `tcp.port` 不是整数
|
||||||
|
- **THEN** 系统 SHALL 以配置错误退出,提示 tcp.port 必须为整数端口
|
||||||
|
|
||||||
|
#### Scenario: tcp readBanner 类型非法
|
||||||
|
- **WHEN** YAML 中 tcp target 的 `tcp.readBanner` 不是布尔值
|
||||||
|
- **THEN** 系统 SHALL 以配置错误退出,提示 tcp.readBanner 必须为布尔值
|
||||||
|
|
||||||
|
#### Scenario: tcp bannerReadTimeout 非法
|
||||||
|
- **WHEN** YAML 中 tcp target 或 defaults.tcp 的 `bannerReadTimeout` 不是非负有限数字
|
||||||
|
- **THEN** 系统 SHALL 以配置错误退出,提示 bannerReadTimeout 格式错误
|
||||||
|
|
||||||
|
#### Scenario: tcp maxBannerBytes 非法
|
||||||
|
- **WHEN** YAML 中 tcp target 或 defaults.tcp 的 `maxBannerBytes` 不是合法 size 值
|
||||||
|
- **THEN** 系统 SHALL 以配置错误退出,提示 maxBannerBytes 格式错误
|
||||||
|
|
||||||
|
#### Scenario: tcp expect connected 类型非法
|
||||||
|
- **WHEN** YAML 中 tcp target 的 `expect.connected` 不是布尔值
|
||||||
|
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.connected 必须为布尔值
|
||||||
|
|
||||||
|
#### Scenario: tcp expect banner 非法
|
||||||
|
- **WHEN** YAML 中 tcp target 的 `expect.banner` 不是合法 operator 对象
|
||||||
|
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.banner 格式错误
|
||||||
|
|
||||||
|
#### Scenario: tcp expect banner match 正则非法
|
||||||
|
- **WHEN** YAML 中 tcp target 配置 `expect.banner: { match: "[invalid" }`
|
||||||
|
- **THEN** 系统 SHALL 在启动期配置校验失败,而不是延迟到运行期抛错
|
||||||
|
|
||||||
|
#### Scenario: tcp 分组未知字段失败
|
||||||
|
- **WHEN** YAML 中 tcp target 的 `tcp` 分组包含 `tls: true` 等未知字段
|
||||||
|
- **THEN** 系统 SHALL 以配置错误退出,提示 tcp 分组包含未知字段
|
||||||
|
|
||||||
|
#### Scenario: defaults.tcp 未知字段失败
|
||||||
|
- **WHEN** YAML 中 defaults.tcp 包含 `host` 或其他非默认字段
|
||||||
|
- **THEN** 系统 SHALL 以配置错误退出,提示 defaults.tcp 包含未知字段
|
||||||
|
|||||||
113
openspec/specs/tcp-checker/spec.md
Normal file
113
openspec/specs/tcp-checker/spec.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
定义 TCP checker 的配置格式、连接执行、banner 读取、expect 校验、失败结构和状态摘要。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: tcp target 配置
|
||||||
|
系统 SHALL 支持 `type: tcp` 的 target 配置,通过 `tcp.host` 和 `tcp.port` 描述目标 TCP 地址,并通过可选字段控制 banner 读取行为。
|
||||||
|
|
||||||
|
#### Scenario: 解析最简 tcp target
|
||||||
|
- **WHEN** YAML 中 target 配置 `type: tcp`、`tcp.host: "127.0.0.1"` 和 `tcp.port: 6379`
|
||||||
|
- **THEN** 系统 SHALL 将其解析为 tcp checker,并填充 `readBanner=false`、`bannerReadTimeout=2000`、`maxBannerBytes=4096`、interval、timeout、group 和 expect 配置
|
||||||
|
|
||||||
|
#### Scenario: tcp target 缺少 host
|
||||||
|
- **WHEN** YAML 中 target 配置 `type: tcp` 但缺少 `tcp.host`
|
||||||
|
- **THEN** 系统 SHALL 以配置错误退出,并提示该 target 缺少 tcp.host 字段
|
||||||
|
|
||||||
|
#### Scenario: tcp target 缺少 port
|
||||||
|
- **WHEN** YAML 中 target 配置 `type: tcp` 但缺少 `tcp.port`
|
||||||
|
- **THEN** 系统 SHALL 以配置错误退出,并提示该 target 缺少 tcp.port 字段
|
||||||
|
|
||||||
|
#### Scenario: tcp port 范围非法
|
||||||
|
- **WHEN** YAML 中 tcp target 的 `tcp.port` 不是 1 到 65535 之间的整数
|
||||||
|
- **THEN** 系统 SHALL 以配置错误退出,并提示 tcp.port 必须为合法 TCP 端口
|
||||||
|
|
||||||
|
#### Scenario: tcp defaults 覆盖 banner 参数
|
||||||
|
- **WHEN** YAML 中配置 `defaults.tcp.bannerReadTimeout: 1000` 和 `defaults.tcp.maxBannerBytes: "8KB"`
|
||||||
|
- **THEN** 未显式配置对应字段的 tcp target SHALL 使用 defaults.tcp 中的值
|
||||||
|
|
||||||
|
#### Scenario: per-target banner 参数覆盖 defaults
|
||||||
|
- **WHEN** defaults.tcp 配置了 banner 参数,且某个 tcp target 显式配置 `tcp.bannerReadTimeout` 或 `tcp.maxBannerBytes`
|
||||||
|
- **THEN** 该 target SHALL 使用自身 tcp 分组中的值
|
||||||
|
|
||||||
|
#### Scenario: tcp 序列化展示摘要
|
||||||
|
- **WHEN** 系统同步 tcp target 到 targets 表
|
||||||
|
- **THEN** `target` 展示摘要 SHALL 为 `<host>:<port>`,`config` JSON SHALL 包含 resolved 后的 host、port、readBanner、bannerReadTimeout 和 maxBannerBytes
|
||||||
|
|
||||||
|
### Requirement: tcp checker 执行
|
||||||
|
系统 SHALL 按 tcp target 配置建立 TCP 连接,记录完整执行耗时,并在连接失败、超时或资源超限时产生结构化失败信息。
|
||||||
|
|
||||||
|
#### Scenario: TCP 连接成功
|
||||||
|
- **WHEN** tcp target 指向可连接的 TCP 服务,且未配置 expect 或 `expect.connected` 为 `true`
|
||||||
|
- **THEN** 系统 SHALL 记录 `matched=true`、`durationMs` 和 `statusDetail`,并关闭 socket
|
||||||
|
|
||||||
|
#### Scenario: TCP 连接失败
|
||||||
|
- **WHEN** tcp target 指向不可连接的 host/port,且未配置 expect 或 `expect.connected` 为 `true`
|
||||||
|
- **THEN** 系统 SHALL 记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `connect`,message 包含可读连接失败原因
|
||||||
|
|
||||||
|
#### Scenario: 期望端口不可达且连接失败
|
||||||
|
- **WHEN** tcp target 配置 `expect.connected: false`,且 TCP 连接失败
|
||||||
|
- **THEN** 系统 SHALL 记录 `matched=true`,statusDetail SHALL 展示实际连接失败原因摘要
|
||||||
|
|
||||||
|
#### Scenario: 期望端口不可达但连接成功
|
||||||
|
- **WHEN** tcp target 配置 `expect.connected: false`,但 TCP 连接成功
|
||||||
|
- **THEN** 系统 SHALL 记录 `matched=false`,failure 的 kind 为 `mismatch`,phase 为 `connected`
|
||||||
|
|
||||||
|
#### Scenario: TCP 执行超时
|
||||||
|
- **WHEN** 引擎注入的 `ctx.signal` 在 TCP 连接或 banner 读取过程中 abort
|
||||||
|
- **THEN** 系统 SHALL best-effort 关闭 socket,记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `connect` 或 `banner`,message 包含超时信息
|
||||||
|
|
||||||
|
#### Scenario: duration 包含 banner 读取
|
||||||
|
- **WHEN** tcp target 开启 `readBanner` 且服务端延迟发送 banner
|
||||||
|
- **THEN** 结果中的 `durationMs` SHALL 覆盖连接建立、banner 等待、banner 读取和 expect 校验的完整耗时
|
||||||
|
|
||||||
|
### Requirement: tcp banner 读取
|
||||||
|
系统 SHALL 仅在 `tcp.readBanner: true` 时读取服务端主动发送的 banner 数据,并同时受 `bannerReadTimeout` 和 `maxBannerBytes` 限制。
|
||||||
|
|
||||||
|
#### Scenario: 默认不读取 banner
|
||||||
|
- **WHEN** tcp target 未配置 `readBanner` 或配置为 `false`
|
||||||
|
- **THEN** 系统 SHALL 在连接建立后立即进入 connected 和 duration 校验,不等待服务端数据
|
||||||
|
|
||||||
|
#### Scenario: 读取服务端 banner
|
||||||
|
- **WHEN** tcp target 配置 `readBanner: true`,且服务端连接后发送 `220 smtp.example.com ESMTP`
|
||||||
|
- **THEN** 系统 SHALL 收集 banner 文本,并允许后续 `expect.banner` 对该文本执行 operator 断言
|
||||||
|
|
||||||
|
#### Scenario: banner 等待超时无数据
|
||||||
|
- **WHEN** tcp target 配置 `readBanner: true`,但服务端在 `bannerReadTimeout` 内未发送任何数据
|
||||||
|
- **THEN** 系统 SHALL 将 banner 视为空字符串并继续执行 expect 校验,不将无 banner 本身作为连接错误
|
||||||
|
|
||||||
|
#### Scenario: banner 读取超过最大字节数
|
||||||
|
- **WHEN** 服务端发送的 banner 数据超过 `maxBannerBytes`
|
||||||
|
- **THEN** 系统 SHALL 停止读取并记录 `matched=false`、failure.kind=`error`、failure.phase=`banner` 的结构化错误
|
||||||
|
|
||||||
|
#### Scenario: banner statusDetail 截断展示
|
||||||
|
- **WHEN** tcp target 成功读取到较长 banner
|
||||||
|
- **THEN** `statusDetail` SHALL 展示截断后的 banner 摘要,避免 UI 和历史记录写入过长文本
|
||||||
|
|
||||||
|
### Requirement: tcp expect 校验
|
||||||
|
系统 SHALL 支持 tcp 专属 expect,包括 `connected`、`banner` 和 `maxDurationMs`,并按 connected、banner、duration 的阶段顺序快速失败。
|
||||||
|
|
||||||
|
#### Scenario: 默认 connected 成功语义
|
||||||
|
- **WHEN** tcp target 未显式配置 `expect.connected`
|
||||||
|
- **THEN** 系统 SHALL 使用默认 `expect.connected: true` 进行校验
|
||||||
|
|
||||||
|
#### Scenario: maxDurationMs 校验
|
||||||
|
- **WHEN** tcp target 配置 `expect.maxDurationMs: 100`,且完整执行耗时超过 100ms
|
||||||
|
- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 phase 为 `duration`
|
||||||
|
|
||||||
|
#### Scenario: banner operator 校验通过
|
||||||
|
- **WHEN** tcp target 配置 `readBanner: true`、`expect.banner: { contains: "ESMTP" }`,且实际 banner 包含 `ESMTP`
|
||||||
|
- **THEN** 系统 SHALL 判定 banner 阶段通过
|
||||||
|
|
||||||
|
#### Scenario: banner operator 校验失败
|
||||||
|
- **WHEN** tcp target 配置 `readBanner: true`、`expect.banner: { contains: "ESMTP" }`,且实际 banner 不包含 `ESMTP`
|
||||||
|
- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 kind 为 `mismatch`,phase 为 `banner`,path 为 `banner`
|
||||||
|
|
||||||
|
#### Scenario: expect.banner 未开启 readBanner
|
||||||
|
- **WHEN** tcp target 配置 `expect.banner`,但 `tcp.readBanner` 未配置为 `true`
|
||||||
|
- **THEN** 系统 SHALL 在启动期配置校验失败,提示 banner 断言需要启用 tcp.readBanner
|
||||||
|
|
||||||
|
#### Scenario: tcp expect 未知字段失败
|
||||||
|
- **WHEN** YAML 中 tcp target 的 expect 包含 `status: [200]` 或其他非 tcp expect 字段
|
||||||
|
- **THEN** 系统 SHALL 以配置错误退出,提示 expect 包含未知字段
|
||||||
@@ -62,6 +62,27 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {}
|
"properties": {}
|
||||||
|
},
|
||||||
|
"tcp": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"bannerReadTimeout": {
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"maxBannerBytes": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -964,6 +985,163 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"type",
|
||||||
|
"tcp"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"description": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"maxLength": 500,
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"banner": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connected": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"maxDurationMs": {
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": "tcp",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tcp": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"host",
|
||||||
|
"port"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"bannerReadTimeout": {
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"maxBannerBytes": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"maximum": 65535,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"readBanner": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,3 +145,28 @@ targets:
|
|||||||
exists: true
|
exists: true
|
||||||
role:
|
role:
|
||||||
contains: "engineer"
|
contains: "engineer"
|
||||||
|
|
||||||
|
# ========== TCP targets ==========
|
||||||
|
|
||||||
|
- id: "redis-port"
|
||||||
|
name: "Redis 端口可达"
|
||||||
|
type: tcp
|
||||||
|
group: "基础设施"
|
||||||
|
tcp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 6379
|
||||||
|
expect:
|
||||||
|
maxDurationMs: 3000
|
||||||
|
|
||||||
|
- id: "smtp-banner"
|
||||||
|
name: "SMTP Banner 探测"
|
||||||
|
type: tcp
|
||||||
|
group: "基础设施"
|
||||||
|
tcp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 25
|
||||||
|
readBanner: true
|
||||||
|
bannerReadTimeout: 3000
|
||||||
|
expect:
|
||||||
|
banner:
|
||||||
|
contains: "ESMTP"
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { CommandChecker } from "./cmd";
|
|||||||
import { DbChecker } from "./db";
|
import { DbChecker } from "./db";
|
||||||
import { HttpChecker } from "./http";
|
import { HttpChecker } from "./http";
|
||||||
import { CheckerRegistry } from "./registry";
|
import { CheckerRegistry } from "./registry";
|
||||||
|
import { TcpChecker } from "./tcp";
|
||||||
|
|
||||||
const checkers = [new HttpChecker(), new CommandChecker(), new DbChecker()];
|
const checkers = [new HttpChecker(), new CommandChecker(), new DbChecker(), new TcpChecker()];
|
||||||
|
|
||||||
export function createDefaultCheckerRegistry(): CheckerRegistry {
|
export function createDefaultCheckerRegistry(): CheckerRegistry {
|
||||||
const registry = new CheckerRegistry();
|
const registry = new CheckerRegistry();
|
||||||
|
|||||||
358
src/server/checker/runner/tcp/execute.ts
Normal file
358
src/server/checker/runner/tcp/execute.ts
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import { isError } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||||
|
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||||
|
import type { ResolvedTcpTarget, TcpExpectConfig, TcpTargetConfig } from "./types";
|
||||||
|
|
||||||
|
import { checkDuration } from "../../expect/duration";
|
||||||
|
import { errorFailure } from "../../expect/failure";
|
||||||
|
import { parseSize } from "../../utils";
|
||||||
|
import { checkBanner, checkConnected } from "./expect";
|
||||||
|
import { tcpCheckerSchemas } from "./schema";
|
||||||
|
import { validateTcpConfig } from "./validate";
|
||||||
|
|
||||||
|
const DEFAULT_BANNER_READ_TIMEOUT = 2000;
|
||||||
|
const DEFAULT_MAX_BANNER_BYTES = 4096;
|
||||||
|
|
||||||
|
type ConnectAndBannerResult =
|
||||||
|
| { banner?: string; bannerExceeded?: boolean; ok: true; socket: { close(): void } }
|
||||||
|
| { error: string; ok: false };
|
||||||
|
|
||||||
|
export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||||
|
readonly configKey = "tcp";
|
||||||
|
|
||||||
|
readonly schemas = tcpCheckerSchemas;
|
||||||
|
|
||||||
|
readonly type = "tcp";
|
||||||
|
|
||||||
|
async execute(t: ResolvedTcpTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const start = performance.now();
|
||||||
|
const expect = t.expect;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const connectResult = await connectAndMaybeReadBanner(
|
||||||
|
t.tcp.host,
|
||||||
|
t.tcp.port,
|
||||||
|
t.tcp.readBanner,
|
||||||
|
t.tcp.bannerReadTimeout,
|
||||||
|
t.tcp.maxBannerBytes,
|
||||||
|
ctx.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!connectResult.ok) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
if (expect?.connected === false) {
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: null,
|
||||||
|
matched: true,
|
||||||
|
statusDetail: connectResult.error,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("connect", "connect", connectResult.error),
|
||||||
|
matched: false,
|
||||||
|
statusDetail: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = connectResult.socket;
|
||||||
|
|
||||||
|
if (ctx.signal.aborted) {
|
||||||
|
closeSocket(socket);
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("connect", "connect", `连接超时 (${t.timeoutMs}ms)`),
|
||||||
|
matched: false,
|
||||||
|
statusDetail: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedConnected = expect?.connected ?? true;
|
||||||
|
const connectedResult = checkConnected(true, expectedConnected);
|
||||||
|
if (!connectedResult.matched) {
|
||||||
|
closeSocket(socket);
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: connectedResult.failure,
|
||||||
|
matched: false,
|
||||||
|
statusDetail: "connected",
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectResult.bannerExceeded) {
|
||||||
|
closeSocket(socket);
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("banner", "banner", `banner 数据超过 ${t.tcp.maxBannerBytes} 字节限制`),
|
||||||
|
matched: false,
|
||||||
|
statusDetail: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const banner = connectResult.banner ?? "";
|
||||||
|
closeSocket(socket);
|
||||||
|
|
||||||
|
if (expect?.banner) {
|
||||||
|
const bannerCheck = checkBanner(banner, expect.banner);
|
||||||
|
if (!bannerCheck.matched) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: bannerCheck.failure,
|
||||||
|
matched: false,
|
||||||
|
statusDetail: banner ? truncateBanner(banner) : 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: buildStatusDetail(banner, durationMs),
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: null,
|
||||||
|
matched: true,
|
||||||
|
statusDetail: buildStatusDetail(banner, durationMs),
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure(
|
||||||
|
"connect",
|
||||||
|
"connect",
|
||||||
|
ctx.signal.aborted ? `连接超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
|
||||||
|
),
|
||||||
|
matched: false,
|
||||||
|
statusDetail: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTcpTarget {
|
||||||
|
const t = target as RawTargetConfig & { tcp: TcpTargetConfig; type: "tcp" };
|
||||||
|
const tcpDefaults = context.defaults["tcp"] as
|
||||||
|
| undefined
|
||||||
|
| { bannerReadTimeout?: number; maxBannerBytes?: number | string };
|
||||||
|
|
||||||
|
const maxBannerBytes = parseSize(t.tcp.maxBannerBytes ?? tcpDefaults?.maxBannerBytes ?? DEFAULT_MAX_BANNER_BYTES);
|
||||||
|
const bannerReadTimeout = t.tcp.bannerReadTimeout ?? tcpDefaults?.bannerReadTimeout ?? DEFAULT_BANNER_READ_TIMEOUT;
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: null,
|
||||||
|
expect: target.expect as TcpExpectConfig | undefined,
|
||||||
|
group: target.group ?? "default",
|
||||||
|
id: t.id,
|
||||||
|
intervalMs: context.defaultIntervalMs,
|
||||||
|
name: t.name ?? null,
|
||||||
|
tcp: {
|
||||||
|
bannerReadTimeout,
|
||||||
|
host: t.tcp.host,
|
||||||
|
maxBannerBytes,
|
||||||
|
port: t.tcp.port,
|
||||||
|
readBanner: t.tcp.readBanner ?? false,
|
||||||
|
},
|
||||||
|
timeoutMs: context.defaultTimeoutMs,
|
||||||
|
type: "tcp",
|
||||||
|
} satisfies ResolvedTcpTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize(t: ResolvedTcpTarget): { config: string; target: string } {
|
||||||
|
return {
|
||||||
|
config: JSON.stringify({
|
||||||
|
bannerReadTimeout: t.tcp.bannerReadTimeout,
|
||||||
|
host: t.tcp.host,
|
||||||
|
maxBannerBytes: t.tcp.maxBannerBytes,
|
||||||
|
port: t.tcp.port,
|
||||||
|
readBanner: t.tcp.readBanner,
|
||||||
|
}),
|
||||||
|
target: `${t.tcp.host}:${t.tcp.port}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(input: CheckerValidationInput) {
|
||||||
|
return validateTcpConfig(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assembleChunks(chunks: Uint8Array[], totalBytes: number): Uint8Array {
|
||||||
|
const result = new Uint8Array(totalBytes);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
result.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStatusDetail(banner: string, durationMs: number): string {
|
||||||
|
const base = `connected in ${durationMs}ms`;
|
||||||
|
if (!banner) return base;
|
||||||
|
return `${base}, banner: ${truncateBanner(banner)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSocket(socket: { close(): void }) {
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {
|
||||||
|
/* best-effort close */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectAndMaybeReadBanner(
|
||||||
|
hostname: string,
|
||||||
|
port: number,
|
||||||
|
readBanner: boolean,
|
||||||
|
bannerTimeoutMs: number,
|
||||||
|
maxBannerBytes: number,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<ConnectAndBannerResult> {
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
let bannerSettled = false;
|
||||||
|
let bannerExceeded = false;
|
||||||
|
let bannerResolve: ((value: void) => void) | undefined;
|
||||||
|
const bannerPromise = new Promise<void>((resolve) => {
|
||||||
|
bannerResolve = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const socketHandlers: Record<string, (...args: unknown[]) => void> = {
|
||||||
|
close() {
|
||||||
|
if (readBanner && !bannerSettled) {
|
||||||
|
bannerSettled = true;
|
||||||
|
bannerResolve!();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data(_socket: unknown, data: unknown) {
|
||||||
|
if (!readBanner || bannerSettled) return;
|
||||||
|
const bytes = data as Uint8Array;
|
||||||
|
totalBytes += bytes.byteLength;
|
||||||
|
if (totalBytes > maxBannerBytes) {
|
||||||
|
bannerSettled = true;
|
||||||
|
bannerExceeded = true;
|
||||||
|
bannerResolve!();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(bytes);
|
||||||
|
},
|
||||||
|
drain() {
|
||||||
|
// Bun socket handler 必填项,TCP checker 不关注 drain 事件
|
||||||
|
},
|
||||||
|
end() {
|
||||||
|
if (readBanner && !bannerSettled) {
|
||||||
|
bannerSettled = true;
|
||||||
|
bannerResolve!();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error() {
|
||||||
|
if (readBanner && !bannerSettled) {
|
||||||
|
bannerSettled = true;
|
||||||
|
bannerResolve!();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
open() {
|
||||||
|
// Bun socket handler 必填项,连接成功由 Bun.connect() resolve 表示
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const socket = await Bun.connect({
|
||||||
|
hostname,
|
||||||
|
port,
|
||||||
|
socket: socketHandlers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
closeSocket(socket);
|
||||||
|
return { error: "连接已取消", ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!readBanner) {
|
||||||
|
return { bannerExceeded: false, ok: true, socket };
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (bannerSettled) return;
|
||||||
|
bannerSettled = true;
|
||||||
|
bannerResolve!();
|
||||||
|
}, bannerTimeoutMs);
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
if (bannerSettled) return;
|
||||||
|
bannerSettled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
bannerResolve!();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
closeSocket(socket);
|
||||||
|
return { error: "连接已取消", ok: false };
|
||||||
|
}
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
|
||||||
|
await bannerPromise;
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
|
||||||
|
if (bannerExceeded) {
|
||||||
|
return { bannerExceeded: true, ok: true, socket };
|
||||||
|
}
|
||||||
|
|
||||||
|
const banner = new TextDecoder().decode(assembleChunks(chunks, totalBytes));
|
||||||
|
return { banner, bannerExceeded: false, ok: true, socket };
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return { error: "连接超时", ok: false };
|
||||||
|
}
|
||||||
|
const message = isError(error) ? error.message : String(error);
|
||||||
|
return { error: simplifyConnectError(message), ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function simplifyConnectError(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 "connection timed out";
|
||||||
|
if (lower.includes("econnreset") || lower.includes("reset")) return "connection reset";
|
||||||
|
if (lower.includes("enetwork") || lower.includes("network")) return "network error";
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateBanner(banner: string, maxLen = 80): string {
|
||||||
|
if (banner.length <= maxLen) return banner;
|
||||||
|
return `${banner.slice(0, maxLen)}…`;
|
||||||
|
}
|
||||||
30
src/server/checker/runner/tcp/expect.ts
Normal file
30
src/server/checker/runner/tcp/expect.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ExpectResult } from "../../expect/types";
|
||||||
|
import type { ExpectOperator } from "../../types";
|
||||||
|
|
||||||
|
import { mismatchFailure } from "../../expect/failure";
|
||||||
|
import { applyOperator } from "../../expect/operator";
|
||||||
|
|
||||||
|
export function checkBanner(banner: string, op: ExpectOperator): ExpectResult {
|
||||||
|
const matched = applyOperator(banner, op);
|
||||||
|
if (!matched) {
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure("banner", "banner", op, banner, `banner 不满足条件`),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { failure: null, matched: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkConnected(connected: boolean, expected: boolean): ExpectResult {
|
||||||
|
if (connected === expected) return { failure: null, matched: true };
|
||||||
|
if (!connected && expected) {
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure("connected", "connected", true, false, "期望端口可达但连接失败"),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure("connected", "connected", false, true, "期望端口不可达但连接成功"),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
1
src/server/checker/runner/tcp/index.ts
Normal file
1
src/server/checker/runner/tcp/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { TcpChecker } from "./execute";
|
||||||
33
src/server/checker/runner/tcp/schema.ts
Normal file
33
src/server/checker/runner/tcp/schema.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
import type { CheckerSchemas } from "../types";
|
||||||
|
|
||||||
|
import { createPureOperatorSchema, sizeSchema } from "../../schema/fragments";
|
||||||
|
|
||||||
|
export const tcpCheckerSchemas: CheckerSchemas = {
|
||||||
|
config: Type.Object(
|
||||||
|
{
|
||||||
|
bannerReadTimeout: Type.Optional(Type.Number({ minimum: 0 })),
|
||||||
|
host: Type.String({ minLength: 1 }),
|
||||||
|
maxBannerBytes: Type.Optional(sizeSchema),
|
||||||
|
port: Type.Integer({ maximum: 65535, minimum: 1 }),
|
||||||
|
readBanner: Type.Optional(Type.Boolean()),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
),
|
||||||
|
defaults: Type.Object(
|
||||||
|
{
|
||||||
|
bannerReadTimeout: Type.Optional(Type.Number({ minimum: 0 })),
|
||||||
|
maxBannerBytes: Type.Optional(sizeSchema),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
),
|
||||||
|
expect: Type.Object(
|
||||||
|
{
|
||||||
|
banner: Type.Optional(createPureOperatorSchema()),
|
||||||
|
connected: Type.Optional(Type.Boolean()),
|
||||||
|
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
),
|
||||||
|
};
|
||||||
38
src/server/checker/runner/tcp/types.ts
Normal file
38
src/server/checker/runner/tcp/types.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { ExpectOperator, ResolvedTargetBase } from "../../types";
|
||||||
|
|
||||||
|
export interface ResolvedTcpConfig {
|
||||||
|
bannerReadTimeout: number;
|
||||||
|
host: string;
|
||||||
|
maxBannerBytes: number;
|
||||||
|
port: number;
|
||||||
|
readBanner: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedTcpTarget extends ResolvedTargetBase {
|
||||||
|
expect?: TcpExpectConfig;
|
||||||
|
group: string;
|
||||||
|
intervalMs: number;
|
||||||
|
name: null | string;
|
||||||
|
tcp: ResolvedTcpConfig;
|
||||||
|
timeoutMs: number;
|
||||||
|
type: "tcp";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TcpDefaultsConfig {
|
||||||
|
bannerReadTimeout?: number;
|
||||||
|
maxBannerBytes?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TcpExpectConfig {
|
||||||
|
banner?: ExpectOperator;
|
||||||
|
connected?: boolean;
|
||||||
|
maxDurationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TcpTargetConfig {
|
||||||
|
bannerReadTimeout?: number;
|
||||||
|
host: string;
|
||||||
|
maxBannerBytes?: number | string;
|
||||||
|
port: number;
|
||||||
|
readBanner?: boolean;
|
||||||
|
}
|
||||||
163
src/server/checker/runner/tcp/validate.ts
Normal file
163
src/server/checker/runner/tcp/validate.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
export function validateTcpConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
|
||||||
|
issues.push(...validateTcpDefaults(input));
|
||||||
|
|
||||||
|
for (let i = 0; i < input.targets.length; i++) {
|
||||||
|
const target = input.targets[i] as unknown;
|
||||||
|
if (!isPlainObject(target)) continue;
|
||||||
|
if (target["type"] !== "tcp") continue;
|
||||||
|
issues.push(...validateTcpTarget(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 validateTcpDefaults(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
const defaults = input.defaults["tcp"];
|
||||||
|
if (defaults === undefined || defaults === null || !isPlainObject(defaults)) return issues;
|
||||||
|
|
||||||
|
const targetName = "defaults.tcp";
|
||||||
|
|
||||||
|
if (defaults["bannerReadTimeout"] !== undefined && !isNonNegativeFiniteNumber(defaults["bannerReadTimeout"])) {
|
||||||
|
issues.push(issue("invalid-type", "defaults.tcp.bannerReadTimeout", "必须为非负有限数字", targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaults["maxBannerBytes"] !== undefined) {
|
||||||
|
if (
|
||||||
|
!isString(defaults["maxBannerBytes"]) &&
|
||||||
|
!(
|
||||||
|
isNumber(defaults["maxBannerBytes"]) &&
|
||||||
|
Number.isFinite(defaults["maxBannerBytes"]) &&
|
||||||
|
defaults["maxBannerBytes"] >= 0
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
issues.push(issue("invalid-value", "defaults.tcp.maxBannerBytes", "必须为合法 size 值", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedKeys = new Set(["bannerReadTimeout", "maxBannerBytes"]);
|
||||||
|
for (const key of Object.keys(defaults)) {
|
||||||
|
if (!allowedKeys.has(key)) {
|
||||||
|
issues.push(issue("unknown-field", joinPath("defaults.tcp", key), "是未知字段", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTcpExpect(
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
path: string,
|
||||||
|
readBanner: boolean,
|
||||||
|
): 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");
|
||||||
|
|
||||||
|
if (expect["connected"] !== undefined && typeof expect["connected"] !== "boolean") {
|
||||||
|
issues.push(issue("invalid-type", joinPath(expectPath, "connected"), "必须为布尔值", targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect["maxDurationMs"] !== undefined && !isNonNegativeFiniteNumber(expect["maxDurationMs"])) {
|
||||||
|
issues.push(issue("invalid-type", joinPath(expectPath, "maxDurationMs"), "必须为非负有限数字", targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect["banner"] !== undefined) {
|
||||||
|
if (!readBanner) {
|
||||||
|
issues.push(
|
||||||
|
issue("invalid-value", joinPath(expectPath, "banner"), "banner 断言需要启用 tcp.readBanner", targetName),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
issues.push(...validateOperatorObject(expect["banner"], joinPath(expectPath, "banner"), targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedKeys = new Set(["banner", "connected", "maxDurationMs"]);
|
||||||
|
for (const key of Object.keys(expect)) {
|
||||||
|
if (!allowedKeys.has(key)) {
|
||||||
|
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTcpTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
const targetName = getTargetName(target);
|
||||||
|
const tcp = target["tcp"];
|
||||||
|
|
||||||
|
if (!isPlainObject(tcp)) {
|
||||||
|
issues.push(issue("required", joinPath(path, "tcp"), "缺少 tcp 配置分组", targetName));
|
||||||
|
issues.push(...validateTcpExpect(target, path, false));
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isString(tcp["host"]) || tcp["host"].trim() === "") {
|
||||||
|
issues.push(issue("required", joinPath(joinPath(path, "tcp"), "host"), "缺少 tcp.host 字段", targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tcp["port"] === undefined) {
|
||||||
|
issues.push(issue("required", joinPath(joinPath(path, "tcp"), "port"), "缺少 tcp.port 字段", targetName));
|
||||||
|
} else if (!isNumber(tcp["port"]) || !Number.isInteger(tcp["port"]) || tcp["port"] < 1 || tcp["port"] > 65535) {
|
||||||
|
issues.push(
|
||||||
|
issue("invalid-value", joinPath(joinPath(path, "tcp"), "port"), "必须为 1-65535 之间的整数", targetName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tcp["readBanner"] !== undefined && typeof tcp["readBanner"] !== "boolean") {
|
||||||
|
issues.push(issue("invalid-type", joinPath(joinPath(path, "tcp"), "readBanner"), "必须为布尔值", targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tcp["bannerReadTimeout"] !== undefined && !isNonNegativeFiniteNumber(tcp["bannerReadTimeout"])) {
|
||||||
|
issues.push(
|
||||||
|
issue("invalid-type", joinPath(joinPath(path, "tcp"), "bannerReadTimeout"), "必须为非负有限数字", targetName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tcp["maxBannerBytes"] !== undefined) {
|
||||||
|
if (
|
||||||
|
!isString(tcp["maxBannerBytes"]) &&
|
||||||
|
!(isNumber(tcp["maxBannerBytes"]) && Number.isFinite(tcp["maxBannerBytes"]) && tcp["maxBannerBytes"] >= 0)
|
||||||
|
) {
|
||||||
|
issues.push(
|
||||||
|
issue("invalid-value", joinPath(joinPath(path, "tcp"), "maxBannerBytes"), "必须为合法 size 值", targetName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedTcpKeys = new Set(["bannerReadTimeout", "host", "maxBannerBytes", "port", "readBanner"]);
|
||||||
|
for (const key of Object.keys(tcp)) {
|
||||||
|
if (!allowedTcpKeys.has(key)) {
|
||||||
|
issues.push(issue("unknown-field", joinPath(joinPath(path, "tcp"), key), "是未知字段", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readBanner = tcp["readBanner"] === true;
|
||||||
|
issues.push(...validateTcpExpect(target, path, readBanner));
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { validateTcpDefaults };
|
||||||
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
import type { ResolvedCommandTarget } from "../../../src/server/checker/runner/cmd/types";
|
import type { ResolvedCommandTarget } from "../../../src/server/checker/runner/cmd/types";
|
||||||
import type { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types";
|
import type { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types";
|
||||||
|
import type { ResolvedTcpTarget } from "../../../src/server/checker/runner/tcp/types";
|
||||||
|
|
||||||
import { loadConfig, parseDuration } from "../../../src/server/checker/config-loader";
|
import { loadConfig, parseDuration } from "../../../src/server/checker/config-loader";
|
||||||
import { checkerRegistry } from "../../../src/server/checker/runner";
|
import { checkerRegistry } from "../../../src/server/checker/runner";
|
||||||
@@ -1778,4 +1779,199 @@ targets:
|
|||||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||||
await expect(loadConfig(configPath)).rejects.toThrow("name");
|
await expect(loadConfig(configPath)).rejects.toThrow("name");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("解析最简 tcp 配置", async () => {
|
||||||
|
const configPath = join(tempDir, "minimal-tcp.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- id: "redis-port"
|
||||||
|
type: tcp
|
||||||
|
tcp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 6379
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = await loadConfig(configPath);
|
||||||
|
expect(config.targets).toHaveLength(1);
|
||||||
|
const t = config.targets[0]! as ResolvedTcpTarget;
|
||||||
|
expect(t.type).toBe("tcp");
|
||||||
|
expect(t.id).toBe("redis-port");
|
||||||
|
expect(t.name).toBeNull();
|
||||||
|
expect(t.tcp.host).toBe("127.0.0.1");
|
||||||
|
expect(t.tcp.port).toBe(6379);
|
||||||
|
expect(t.tcp.readBanner).toBe(false);
|
||||||
|
expect(t.tcp.bannerReadTimeout).toBe(2000);
|
||||||
|
expect(t.tcp.maxBannerBytes).toBe(4096);
|
||||||
|
expect(t.group).toBe("default");
|
||||||
|
expect(t.intervalMs).toBe(30000);
|
||||||
|
expect(t.timeoutMs).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tcp 缺少 host 抛出错误", async () => {
|
||||||
|
await expectConfigError(
|
||||||
|
"tcp-no-host.yaml",
|
||||||
|
`targets:
|
||||||
|
- id: "t"
|
||||||
|
type: tcp
|
||||||
|
tcp:
|
||||||
|
port: 80
|
||||||
|
`,
|
||||||
|
"tcp.host",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tcp 缺少 port 抛出错误", async () => {
|
||||||
|
await expectConfigError(
|
||||||
|
"tcp-no-port.yaml",
|
||||||
|
`targets:
|
||||||
|
- id: "t"
|
||||||
|
type: tcp
|
||||||
|
tcp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
`,
|
||||||
|
"tcp.port",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tcp 非法端口范围抛出错误", async () => {
|
||||||
|
await expectConfigError(
|
||||||
|
"tcp-bad-port.yaml",
|
||||||
|
`targets:
|
||||||
|
- id: "t"
|
||||||
|
type: tcp
|
||||||
|
tcp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 99999
|
||||||
|
`,
|
||||||
|
"tcp.port",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tcp 未知分组字段抛出错误", async () => {
|
||||||
|
await expectConfigError(
|
||||||
|
"tcp-unknown-field.yaml",
|
||||||
|
`targets:
|
||||||
|
- id: "t"
|
||||||
|
type: tcp
|
||||||
|
tcp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 80
|
||||||
|
tls: true
|
||||||
|
`,
|
||||||
|
"是未知字段",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tcp readBanner 开启并配置 expect.banner", async () => {
|
||||||
|
const configPath = join(tempDir, "tcp-banner.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- id: "smtp-check"
|
||||||
|
type: tcp
|
||||||
|
tcp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 25
|
||||||
|
readBanner: true
|
||||||
|
expect:
|
||||||
|
banner:
|
||||||
|
contains: "ESMTP"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = await loadConfig(configPath);
|
||||||
|
const t = config.targets[0]! as ResolvedTcpTarget;
|
||||||
|
expect(t.tcp.readBanner).toBe(true);
|
||||||
|
expect(t.expect?.banner).toEqual({ contains: "ESMTP" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tcp expect.banner 未开启 readBanner 抛出错误", async () => {
|
||||||
|
await expectConfigError(
|
||||||
|
"tcp-banner-no-read.yaml",
|
||||||
|
`targets:
|
||||||
|
- id: "t"
|
||||||
|
type: tcp
|
||||||
|
tcp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 25
|
||||||
|
expect:
|
||||||
|
banner:
|
||||||
|
contains: "ESMTP"
|
||||||
|
`,
|
||||||
|
"banner 断言需要启用 tcp.readBanner",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tcp defaults 覆盖 banner 参数", async () => {
|
||||||
|
const configPath = join(tempDir, "tcp-defaults.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`defaults:
|
||||||
|
tcp:
|
||||||
|
bannerReadTimeout: 1000
|
||||||
|
maxBannerBytes: "8KB"
|
||||||
|
targets:
|
||||||
|
- id: "t1"
|
||||||
|
type: tcp
|
||||||
|
tcp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 80
|
||||||
|
- id: "t2"
|
||||||
|
type: tcp
|
||||||
|
tcp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 81
|
||||||
|
bannerReadTimeout: 3000
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = await loadConfig(configPath);
|
||||||
|
const t1 = config.targets[0]! as ResolvedTcpTarget;
|
||||||
|
expect(t1.tcp.bannerReadTimeout).toBe(1000);
|
||||||
|
expect(t1.tcp.maxBannerBytes).toBe(8192);
|
||||||
|
|
||||||
|
const t2 = config.targets[1]! as ResolvedTcpTarget;
|
||||||
|
expect(t2.tcp.bannerReadTimeout).toBe(3000);
|
||||||
|
expect(t2.tcp.maxBannerBytes).toBe(8192);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tcp expect 未知字段抛出错误", async () => {
|
||||||
|
await expectConfigError(
|
||||||
|
"tcp-unknown-expect.yaml",
|
||||||
|
`targets:
|
||||||
|
- id: "t"
|
||||||
|
type: tcp
|
||||||
|
tcp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 80
|
||||||
|
expect:
|
||||||
|
status: [200]
|
||||||
|
`,
|
||||||
|
"是未知字段",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tcp expect connected 和 maxDurationMs", async () => {
|
||||||
|
const configPath = join(tempDir, "tcp-expect-connected.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- id: "t"
|
||||||
|
type: tcp
|
||||||
|
tcp:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 80
|
||||||
|
expect:
|
||||||
|
connected: false
|
||||||
|
maxDurationMs: 5000
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = await loadConfig(configPath);
|
||||||
|
const t = config.targets[0]! as ResolvedTcpTarget;
|
||||||
|
expect(t.expect?.connected).toBe(false);
|
||||||
|
expect(t.expect?.maxDurationMs).toBe(5000);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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", "custom"]);
|
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "custom"]);
|
||||||
expect(second.supportedTypes).toEqual(["http", "cmd", "db"]);
|
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp"]);
|
||||||
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,
|
||||||
|
|||||||
367
tests/server/checker/runner/tcp/execute.test.ts
Normal file
367
tests/server/checker/runner/tcp/execute.test.ts
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import type { ResolvedTcpTarget } from "../../../../../src/server/checker/runner/tcp/types";
|
||||||
|
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
|
||||||
|
|
||||||
|
import { TcpChecker } from "../../../../../src/server/checker/runner/tcp/execute";
|
||||||
|
|
||||||
|
const checker = new TcpChecker();
|
||||||
|
|
||||||
|
let server: Bun.TCPSocketListener;
|
||||||
|
let serverPort: number;
|
||||||
|
let bannerServer: Bun.TCPSocketListener;
|
||||||
|
let bannerServerPort: number;
|
||||||
|
let largeBannerServer: Bun.TCPSocketListener;
|
||||||
|
let largeBannerServerPort: number;
|
||||||
|
|
||||||
|
function makeCtx(timeoutMs = 5000): CheckerContext {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
return { signal: controller.signal };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTarget(tcp: Partial<ResolvedTcpTarget["tcp"]>, overrides?: Partial<ResolvedTcpTarget>): ResolvedTcpTarget {
|
||||||
|
return {
|
||||||
|
description: null,
|
||||||
|
group: "default",
|
||||||
|
id: "test-tcp",
|
||||||
|
intervalMs: 60000,
|
||||||
|
name: "test-tcp",
|
||||||
|
tcp: {
|
||||||
|
bannerReadTimeout: 2000,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
maxBannerBytes: 4096,
|
||||||
|
port: serverPort,
|
||||||
|
readBanner: false,
|
||||||
|
...tcp,
|
||||||
|
},
|
||||||
|
timeoutMs: 5000,
|
||||||
|
type: "tcp",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
server = Bun.listen({
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: 0,
|
||||||
|
socket: {
|
||||||
|
data() {
|
||||||
|
// Bun.listen 必填 handler,echo server 不处理数据
|
||||||
|
},
|
||||||
|
end(socket) {
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {
|
||||||
|
// best-effort 关闭
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error() {
|
||||||
|
// Bun.listen 必填 handler,测试 server 忽略错误
|
||||||
|
},
|
||||||
|
open() {
|
||||||
|
// Bun.listen 必填 handler,open 时无需操作
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
serverPort = server.port;
|
||||||
|
|
||||||
|
bannerServer = Bun.listen({
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: 0,
|
||||||
|
socket: {
|
||||||
|
data() {
|
||||||
|
// Bun.listen 必填 handler
|
||||||
|
},
|
||||||
|
end(socket) {
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {
|
||||||
|
// best-effort 关闭
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error() {
|
||||||
|
// Bun.listen 必填 handler
|
||||||
|
},
|
||||||
|
open(socket) {
|
||||||
|
socket.write("220 smtp.example.com ESMTP\r\n");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
bannerServerPort = bannerServer.port;
|
||||||
|
|
||||||
|
largeBannerServer = Bun.listen({
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: 0,
|
||||||
|
socket: {
|
||||||
|
data() {
|
||||||
|
// Bun.listen 必填 handler
|
||||||
|
},
|
||||||
|
end(socket) {
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {
|
||||||
|
// best-effort 关闭
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error() {
|
||||||
|
// Bun.listen 必填 handler
|
||||||
|
},
|
||||||
|
open(socket) {
|
||||||
|
socket.write("X".repeat(8192));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
largeBannerServerPort = largeBannerServer.port;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
server.stop();
|
||||||
|
bannerServer.stop();
|
||||||
|
largeBannerServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TcpChecker execute", () => {
|
||||||
|
test("TCP 连接成功", async () => {
|
||||||
|
const result = await checker.execute(makeTarget({}), makeCtx());
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
expect(result.failure).toBeNull();
|
||||||
|
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(result.statusDetail).toMatch(/^connected in \d+ms$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("TCP 连接失败", async () => {
|
||||||
|
const result = await checker.execute(makeTarget({ host: "127.0.0.1", port: 1 }), makeCtx());
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure!.kind).toBe("error");
|
||||||
|
expect(result.failure!.phase).toBe("connect");
|
||||||
|
expect(result.failure!.message).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("期望端口不可达且连接失败", async () => {
|
||||||
|
const result = await checker.execute(
|
||||||
|
makeTarget({ host: "127.0.0.1", port: 1 }, { expect: { connected: false } }),
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
expect(result.failure).toBeNull();
|
||||||
|
expect(result.statusDetail).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("期望端口不可达但连接成功", async () => {
|
||||||
|
const result = await checker.execute(makeTarget({}, { expect: { connected: false } }), makeCtx());
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure!.kind).toBe("mismatch");
|
||||||
|
expect(result.failure!.phase).toBe("connected");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maxDurationMs 超时返回失败", async () => {
|
||||||
|
const result = await checker.execute(makeTarget({}, { expect: { maxDurationMs: -1 } }), makeCtx());
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure!.phase).toBe("duration");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("读取服务端 banner 成功", async () => {
|
||||||
|
const result = await checker.execute(
|
||||||
|
makeTarget({
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: bannerServerPort,
|
||||||
|
readBanner: true,
|
||||||
|
}),
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
expect(result.statusDetail).toContain("banner:");
|
||||||
|
expect(result.statusDetail).toContain("220 smtp.example.com ESMTP");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("banner operator 校验通过", async () => {
|
||||||
|
const result = await checker.execute(
|
||||||
|
makeTarget(
|
||||||
|
{ host: "127.0.0.1", port: bannerServerPort, readBanner: true },
|
||||||
|
{ expect: { banner: { contains: "ESMTP" } } },
|
||||||
|
),
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("banner operator 校验失败", async () => {
|
||||||
|
const result = await checker.execute(
|
||||||
|
makeTarget(
|
||||||
|
{ host: "127.0.0.1", port: bannerServerPort, readBanner: true },
|
||||||
|
{ expect: { banner: { contains: "POSTFIX" } } },
|
||||||
|
),
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure!.kind).toBe("mismatch");
|
||||||
|
expect(result.failure!.phase).toBe("banner");
|
||||||
|
expect(result.failure!.path).toBe("banner");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("默认不读取 banner", async () => {
|
||||||
|
const result = await checker.execute(
|
||||||
|
makeTarget({ host: "127.0.0.1", port: bannerServerPort, readBanner: false }),
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
expect(result.statusDetail).not.toContain("banner:");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("banner 超时空字符串继续执行", async () => {
|
||||||
|
const result = await checker.execute(
|
||||||
|
makeTarget({
|
||||||
|
bannerReadTimeout: 100,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: serverPort,
|
||||||
|
readBanner: true,
|
||||||
|
}),
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("banner 读取超过最大字节数", async () => {
|
||||||
|
const result = await checker.execute(
|
||||||
|
makeTarget({
|
||||||
|
bannerReadTimeout: 2000,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
maxBannerBytes: 1024,
|
||||||
|
port: largeBannerServerPort,
|
||||||
|
readBanner: true,
|
||||||
|
}),
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure!.kind).toBe("error");
|
||||||
|
expect(result.failure!.phase).toBe("banner");
|
||||||
|
expect(result.failure!.message).toContain("字节限制");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("TCP 执行超时(预 abort)", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
const result = await checker.execute(makeTarget({ host: "127.0.0.1", port: serverPort }), {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure!.phase).toBe("connect");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("banner 读取过程中 abort", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setTimeout(() => controller.abort(), 10);
|
||||||
|
const result = await checker.execute(
|
||||||
|
makeTarget({
|
||||||
|
bannerReadTimeout: 5000,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: serverPort,
|
||||||
|
readBanner: true,
|
||||||
|
}),
|
||||||
|
{ signal: controller.signal },
|
||||||
|
);
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure!.kind).toBe("error");
|
||||||
|
expect(["connect", "banner"]).toContain(result.failure!.phase);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serialize 返回 host:port 和 config JSON", () => {
|
||||||
|
const target = makeTarget({ host: "10.0.0.1", port: 8080 });
|
||||||
|
const s = checker.serialize(target);
|
||||||
|
expect(s.target).toBe("10.0.0.1:8080");
|
||||||
|
const config = JSON.parse(s.config) as Record<string, unknown>;
|
||||||
|
expect(config["host"]).toBe("10.0.0.1");
|
||||||
|
expect(config["port"]).toBe(8080);
|
||||||
|
expect(config["readBanner"]).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TcpChecker resolve", () => {
|
||||||
|
test("最简 tcp 配置解析默认值", () => {
|
||||||
|
const target = checker.resolve(
|
||||||
|
{ id: "t", tcp: { host: "127.0.0.1", port: 6379 }, type: "tcp" },
|
||||||
|
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
|
||||||
|
);
|
||||||
|
expect(target.tcp.host).toBe("127.0.0.1");
|
||||||
|
expect(target.tcp.port).toBe(6379);
|
||||||
|
expect(target.tcp.readBanner).toBe(false);
|
||||||
|
expect(target.tcp.bannerReadTimeout).toBe(2000);
|
||||||
|
expect(target.tcp.maxBannerBytes).toBe(4096);
|
||||||
|
expect(target.group).toBe("default");
|
||||||
|
expect(target.name).toBeNull();
|
||||||
|
expect(target.intervalMs).toBe(30000);
|
||||||
|
expect(target.timeoutMs).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bannerReadTimeout 和 maxBannerBytes 支持 per-target 覆盖", () => {
|
||||||
|
const target = checker.resolve(
|
||||||
|
{
|
||||||
|
id: "t",
|
||||||
|
tcp: { bannerReadTimeout: 5000, host: "127.0.0.1", maxBannerBytes: "1KB", port: 80, readBanner: true },
|
||||||
|
type: "tcp",
|
||||||
|
},
|
||||||
|
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
|
||||||
|
);
|
||||||
|
expect(target.tcp.bannerReadTimeout).toBe(5000);
|
||||||
|
expect(target.tcp.maxBannerBytes).toBe(1024);
|
||||||
|
expect(target.tcp.readBanner).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults.tcp 合并到 target", () => {
|
||||||
|
const target = checker.resolve(
|
||||||
|
{ id: "t", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" },
|
||||||
|
{
|
||||||
|
configDir: "/tmp",
|
||||||
|
defaultIntervalMs: 30000,
|
||||||
|
defaults: { tcp: { bannerReadTimeout: 1000, maxBannerBytes: "8KB" } },
|
||||||
|
defaultTimeoutMs: 10000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(target.tcp.bannerReadTimeout).toBe(1000);
|
||||||
|
expect(target.tcp.maxBannerBytes).toBe(8192);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("per-target 覆盖 defaults.tcp", () => {
|
||||||
|
const target = checker.resolve(
|
||||||
|
{ id: "t", tcp: { bannerReadTimeout: 5000, host: "127.0.0.1", port: 80 }, type: "tcp" },
|
||||||
|
{
|
||||||
|
configDir: "/tmp",
|
||||||
|
defaultIntervalMs: 30000,
|
||||||
|
defaults: { tcp: { bannerReadTimeout: 1000 } },
|
||||||
|
defaultTimeoutMs: 10000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(target.tcp.bannerReadTimeout).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maxBannerBytes 整数默认值解析", () => {
|
||||||
|
const target = checker.resolve(
|
||||||
|
{ id: "t", tcp: { host: "127.0.0.1", maxBannerBytes: 2048, port: 80 }, type: "tcp" },
|
||||||
|
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
|
||||||
|
);
|
||||||
|
expect(target.tcp.maxBannerBytes).toBe(2048);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 配置解析", () => {
|
||||||
|
const target = checker.resolve(
|
||||||
|
{
|
||||||
|
expect: { banner: { contains: "ESMTP" }, connected: false, maxDurationMs: 5000 },
|
||||||
|
id: "t",
|
||||||
|
tcp: { host: "127.0.0.1", port: 80, readBanner: true },
|
||||||
|
type: "tcp",
|
||||||
|
},
|
||||||
|
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
|
||||||
|
);
|
||||||
|
expect(target.expect).toEqual({ banner: { contains: "ESMTP" }, connected: false, maxDurationMs: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("name 和 group 解析", () => {
|
||||||
|
const target = checker.resolve(
|
||||||
|
{ group: "infra", id: "t", name: "redis", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" },
|
||||||
|
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
|
||||||
|
);
|
||||||
|
expect(target.name).toBe("redis");
|
||||||
|
expect(target.group).toBe("infra");
|
||||||
|
});
|
||||||
|
});
|
||||||
65
tests/server/checker/runner/tcp/expect.test.ts
Normal file
65
tests/server/checker/runner/tcp/expect.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { checkBanner, checkConnected } from "../../../../../src/server/checker/runner/tcp/expect";
|
||||||
|
|
||||||
|
describe("checkConnected", () => {
|
||||||
|
test("connected=true 期望 true 匹配", () => {
|
||||||
|
const result = checkConnected(true, true);
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
expect(result.failure).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("connected=false 期望 false 匹配", () => {
|
||||||
|
const result = checkConnected(false, false);
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
expect(result.failure).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("connected=false 期望 true 不匹配", () => {
|
||||||
|
const result = checkConnected(false, true);
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure!.kind).toBe("mismatch");
|
||||||
|
expect(result.failure!.phase).toBe("connected");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("connected=true 期望 false 不匹配", () => {
|
||||||
|
const result = checkConnected(true, false);
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure!.kind).toBe("mismatch");
|
||||||
|
expect(result.failure!.phase).toBe("connected");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkBanner", () => {
|
||||||
|
test("contains 匹配", () => {
|
||||||
|
const result = checkBanner("220 smtp.example.com ESMTP", { contains: "ESMTP" });
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains 不匹配", () => {
|
||||||
|
const result = checkBanner("220 smtp.example.com ESMTP", { contains: "POSTFIX" });
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure!.kind).toBe("mismatch");
|
||||||
|
expect(result.failure!.phase).toBe("banner");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("match 正则匹配", () => {
|
||||||
|
const result = checkBanner("220 smtp.example.com ESMTP", { match: "^220" });
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("空 banner 与 contains 空字符串", () => {
|
||||||
|
const result = checkBanner("", { contains: "" });
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("多 operator 同时匹配", () => {
|
||||||
|
const result = checkBanner("220 ESMTP", { contains: "ESMTP", match: "^220" });
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("多 operator 部分不匹配", () => {
|
||||||
|
const result = checkBanner("220 ESMTP", { contains: "ESMTP", match: "^250" });
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
191
tests/server/checker/runner/tcp/validate.test.ts
Normal file
191
tests/server/checker/runner/tcp/validate.test.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import type { CheckerValidationInput } from "../../../../../src/server/checker/runner/types";
|
||||||
|
|
||||||
|
import { validateTcpConfig } from "../../../../../src/server/checker/runner/tcp/validate";
|
||||||
|
|
||||||
|
function makeInput(targets: unknown[], defaults?: Record<string, unknown>): CheckerValidationInput {
|
||||||
|
return {
|
||||||
|
defaults: defaults ?? {},
|
||||||
|
targets: targets as CheckerValidationInput["targets"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("validateTcpConfig", () => {
|
||||||
|
test("合法 tcp target 无错误", () => {
|
||||||
|
const issues = validateTcpConfig(makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" }]));
|
||||||
|
expect(issues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("缺少 tcp 分组", () => {
|
||||||
|
const issues = validateTcpConfig(makeInput([{ id: "t1", type: "tcp" }]));
|
||||||
|
expect(issues.length).toBeGreaterThan(0);
|
||||||
|
expect(issues.some((i) => i.message.includes("tcp"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("缺少 host", () => {
|
||||||
|
const issues = validateTcpConfig(makeInput([{ id: "t1", tcp: { port: 80 }, type: "tcp" }]));
|
||||||
|
expect(issues.some((i) => i.path.includes("host"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("缺少 port", () => {
|
||||||
|
const issues = validateTcpConfig(makeInput([{ id: "t1", tcp: { host: "127.0.0.1" }, type: "tcp" }]));
|
||||||
|
expect(issues.some((i) => i.path.includes("port"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("端口超范围", () => {
|
||||||
|
const issues = validateTcpConfig(makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 99999 }, type: "tcp" }]));
|
||||||
|
expect(issues.some((i) => i.path.includes("port"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("端口为 0", () => {
|
||||||
|
const issues = validateTcpConfig(makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 0 }, type: "tcp" }]));
|
||||||
|
expect(issues.some((i) => i.path.includes("port"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("readBanner 非布尔值", () => {
|
||||||
|
const issues = validateTcpConfig(
|
||||||
|
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80, readBanner: "yes" }, type: "tcp" }]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.path.includes("readBanner"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bannerReadTimeout 非数字", () => {
|
||||||
|
const issues = validateTcpConfig(
|
||||||
|
makeInput([{ id: "t1", tcp: { bannerReadTimeout: "slow", host: "127.0.0.1", port: 80 }, type: "tcp" }]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.path.includes("bannerReadTimeout"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tcp 分组未知字段", () => {
|
||||||
|
const issues = validateTcpConfig(
|
||||||
|
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80, tls: true }, type: "tcp" }]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.message.includes("未知字段"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect.banner 未开启 readBanner", () => {
|
||||||
|
const issues = validateTcpConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { banner: { contains: "ESMTP" } },
|
||||||
|
id: "t1",
|
||||||
|
tcp: { host: "127.0.0.1", port: 25 },
|
||||||
|
type: "tcp",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.message.includes("readBanner"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect.banner 开启 readBanner 无错误", () => {
|
||||||
|
const issues = validateTcpConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { banner: { contains: "ESMTP" } },
|
||||||
|
id: "t1",
|
||||||
|
tcp: { host: "127.0.0.1", port: 25, readBanner: true },
|
||||||
|
type: "tcp",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect connected 非布尔值", () => {
|
||||||
|
const issues = validateTcpConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { connected: "yes" },
|
||||||
|
id: "t1",
|
||||||
|
tcp: { host: "127.0.0.1", port: 80 },
|
||||||
|
type: "tcp",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.path.includes("connected"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect maxDurationMs 非数字", () => {
|
||||||
|
const issues = validateTcpConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { maxDurationMs: "slow" },
|
||||||
|
id: "t1",
|
||||||
|
tcp: { host: "127.0.0.1", port: 80 },
|
||||||
|
type: "tcp",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.path.includes("maxDurationMs"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 未知字段", () => {
|
||||||
|
const issues = validateTcpConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { status: [200] },
|
||||||
|
id: "t1",
|
||||||
|
tcp: { host: "127.0.0.1", port: 80 },
|
||||||
|
type: "tcp",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.message.includes("未知字段"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect.banner match 正则非法", () => {
|
||||||
|
const issues = validateTcpConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { banner: { match: "[invalid" } },
|
||||||
|
id: "t1",
|
||||||
|
tcp: { host: "127.0.0.1", port: 25, readBanner: true },
|
||||||
|
type: "tcp",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.message.includes("正则"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("非 tcp 类型 target 跳过", () => {
|
||||||
|
const issues = validateTcpConfig(makeInput([{ http: { url: "http://example.com" }, id: "t1", type: "http" }]));
|
||||||
|
expect(issues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults.tcp 合法字段无错误", () => {
|
||||||
|
const issues = validateTcpConfig(
|
||||||
|
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" }], {
|
||||||
|
tcp: { bannerReadTimeout: 1000, maxBannerBytes: "8KB" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(issues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults.tcp 未知字段", () => {
|
||||||
|
const issues = validateTcpConfig(
|
||||||
|
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" }], {
|
||||||
|
tcp: { bannerReadTimeout: 1000, host: "127.0.0.1" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.message.includes("未知字段"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults.tcp bannerReadTimeout 非法", () => {
|
||||||
|
const issues = validateTcpConfig(
|
||||||
|
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" }], {
|
||||||
|
tcp: { bannerReadTimeout: "slow" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.path.includes("bannerReadTimeout"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults.tcp maxBannerBytes 非法", () => {
|
||||||
|
const issues = validateTcpConfig(
|
||||||
|
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" }], {
|
||||||
|
tcp: { maxBannerBytes: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.path.includes("maxBannerBytes"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user