From 0a9a9016be2c6543c36e8b959ab85713d0fe7e52 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Sun, 17 May 2026 23:53:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20TCP=20checker?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E7=AB=AF=E5=8F=A3=E5=8F=AF=E8=BE=BE?= =?UTF-8?q?=E6=80=A7=E6=8E=A2=E6=B5=8B=E4=B8=8E=20banner=20=E8=AF=BB?= =?UTF-8?q?=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- DEVELOPMENT.md | 2 + README.md | 27 +- openspec/specs/probe-config/spec.md | 55 ++- openspec/specs/tcp-checker/spec.md | 113 ++++++ probe-config.schema.json | 178 +++++++++ probes.example.yaml | 25 ++ src/server/checker/runner/index.ts | 3 +- src/server/checker/runner/tcp/execute.ts | 358 +++++++++++++++++ src/server/checker/runner/tcp/expect.ts | 30 ++ src/server/checker/runner/tcp/index.ts | 1 + src/server/checker/runner/tcp/schema.ts | 33 ++ src/server/checker/runner/tcp/types.ts | 38 ++ src/server/checker/runner/tcp/validate.ts | 163 ++++++++ tests/server/checker/config-loader.test.ts | 196 ++++++++++ tests/server/checker/runner/registry.test.ts | 4 +- .../server/checker/runner/tcp/execute.test.ts | 367 ++++++++++++++++++ .../server/checker/runner/tcp/expect.test.ts | 65 ++++ .../checker/runner/tcp/validate.test.ts | 191 +++++++++ 18 files changed, 1841 insertions(+), 8 deletions(-) create mode 100644 openspec/specs/tcp-checker/spec.md create mode 100644 src/server/checker/runner/tcp/execute.ts create mode 100644 src/server/checker/runner/tcp/expect.ts create mode 100644 src/server/checker/runner/tcp/index.ts create mode 100644 src/server/checker/runner/tcp/schema.ts create mode 100644 src/server/checker/runner/tcp/types.ts create mode 100644 src/server/checker/runner/tcp/validate.ts create mode 100644 tests/server/checker/runner/tcp/execute.test.ts create mode 100644 tests/server/checker/runner/tcp/expect.test.ts create mode 100644 tests/server/checker/runner/tcp/validate.test.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 977731a..a3d34c4 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -59,6 +59,8 @@ src/ index.ts 注册入口(显式数组 + 循环注册) http/ HTTP Checker(自包含模块,含 types/schema/execute/expect/validate/body) 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/ api.ts 前后端共享 TypeScript 类型 web/ React 前端 Dashboard(通过 Bun HTML import 集成) diff --git a/README.md b/README.md index 33f34b8..8e1f18b 100644 --- a/README.md +++ b/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、正则匹配、数值比较等 - 响应式 Dashboard:实时状态、可用率统计、耗时趋势图、手动/自动刷新 - 多主题支持:系统、明亮、黑暗三种主题模式 @@ -132,6 +132,15 @@ targets: rowCount: { gte: 1 } rows: - 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 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 | | `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null;前端展示时 null 回退到 `id` | 否 | | `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null,允许空字符串 | 否 | -| `type` | 目标类型:`http`、`cmd`、`db` | 是 | +| `type` | 目标类型:`http`、`cmd`、`db`、`tcp` | 是 | | `group` | 分组名称 | 否,默认 `"default"` | | `interval` | 覆盖全局拨测间隔 | 否 | | `timeout` | 覆盖全局超时时间 | 否 | @@ -213,6 +222,16 @@ targets: | `db.url` | 数据库连接字符串,支持 `postgres://`、`mysql://`、`sqlite://` | | `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 — 期望校验 | 字段 | 适用类型 | 说明 | @@ -225,6 +244,8 @@ targets: | `stdout` / `stderr` | Cmd | 输出校验(数组,每项一个操作符对象) | | `rowCount` | DB | 查询返回行数校验(操作符对象) | | `rows` | DB | 查询结果逐行校验(数组,列名→操作符映射) | +| `connected` | TCP | 期望连接结果,`true`(默认)可达或 `false` 期望不可达 | +| `banner` | TCP | Banner 文本校验(操作符对象,需开启 `tcp.readBanner`) | **body 校验项**(数组中可混合使用): diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md index 1f2e67c..d4b99b8 100644 --- a/openspec/specs/probe-config/spec.md +++ b/openspec/specs/probe-config/spec.md @@ -5,9 +5,9 @@ ## Requirements ### Requirement: YAML 配置文件格式 -系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、可选的 variables 段、checker 默认值和 typed target 列表(含可选 group 字段)。target MUST 使用 `id` 字段作为唯一标识符,MUST 使用 `type` 字段声明 checker 类型,SHALL 支持可选的 `name` 字段作为展示名称元信息,SHALL 支持可选的 `description` 字段作为目标说明。`name` 和 `description` 均 SHALL 允许省略或显式配置为 `null`;省略或显式 null 时解析结果 SHALL 保留为 null。HTTP 领域字段 MUST 放在 `http` 分组,cmd 领域字段 MUST 放在 `cmd` 分组,db 领域字段 MUST 放在 `db` 分组。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: 完整配置文件解析 - **WHEN** 系统启动并读取包含 server、runtime、variables、defaults、targets(含 id、group 字段)的 YAML 配置文件 @@ -37,6 +37,14 @@ - **WHEN** 系统读取只包含一个 `type: db` target(含 `id` 和 `db.url`)的 YAML 配置文件 - **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 触发校验错误 - **WHEN** 配置文件中出现 `defaults.http.method` 字段 - **THEN** 系统 SHALL 以配置错误退出,提示 defaults.http 中存在未知字段 method @@ -382,3 +390,46 @@ #### Scenario: schema 导出 id 和 name - **WHEN** 系统导出 `probe-config.schema.json` - **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 包含未知字段 diff --git a/openspec/specs/tcp-checker/spec.md b/openspec/specs/tcp-checker/spec.md new file mode 100644 index 0000000..e4c003c --- /dev/null +++ b/openspec/specs/tcp-checker/spec.md @@ -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 为 `:`,`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 包含未知字段 diff --git a/probe-config.schema.json b/probe-config.schema.json index 08efad5..f71e442 100644 --- a/probe-config.schema.json +++ b/probe-config.schema.json @@ -62,6 +62,27 @@ "additionalProperties": false, "type": "object", "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" + } + } + } + } } ] } diff --git a/probes.example.yaml b/probes.example.yaml index cab4f0f..e351716 100644 --- a/probes.example.yaml +++ b/probes.example.yaml @@ -145,3 +145,28 @@ targets: exists: true role: 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" diff --git a/src/server/checker/runner/index.ts b/src/server/checker/runner/index.ts index b983015..c899167 100644 --- a/src/server/checker/runner/index.ts +++ b/src/server/checker/runner/index.ts @@ -2,8 +2,9 @@ import { CommandChecker } from "./cmd"; import { DbChecker } from "./db"; import { HttpChecker } from "./http"; 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 { const registry = new CheckerRegistry(); diff --git a/src/server/checker/runner/tcp/execute.ts b/src/server/checker/runner/tcp/execute.ts new file mode 100644 index 0000000..8049fa0 --- /dev/null +++ b/src/server/checker/runner/tcp/execute.ts @@ -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 { + readonly configKey = "tcp"; + + readonly schemas = tcpCheckerSchemas; + + readonly type = "tcp"; + + async execute(t: ResolvedTcpTarget, ctx: CheckerContext): Promise { + 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 { + const chunks: Uint8Array[] = []; + let totalBytes = 0; + let bannerSettled = false; + let bannerExceeded = false; + let bannerResolve: ((value: void) => void) | undefined; + const bannerPromise = new Promise((resolve) => { + bannerResolve = resolve; + }); + + const socketHandlers: Record 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)}…`; +} diff --git a/src/server/checker/runner/tcp/expect.ts b/src/server/checker/runner/tcp/expect.ts new file mode 100644 index 0000000..6496d82 --- /dev/null +++ b/src/server/checker/runner/tcp/expect.ts @@ -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, + }; +} diff --git a/src/server/checker/runner/tcp/index.ts b/src/server/checker/runner/tcp/index.ts new file mode 100644 index 0000000..db9ac18 --- /dev/null +++ b/src/server/checker/runner/tcp/index.ts @@ -0,0 +1 @@ +export { TcpChecker } from "./execute"; diff --git a/src/server/checker/runner/tcp/schema.ts b/src/server/checker/runner/tcp/schema.ts new file mode 100644 index 0000000..7218380 --- /dev/null +++ b/src/server/checker/runner/tcp/schema.ts @@ -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 }, + ), +}; diff --git a/src/server/checker/runner/tcp/types.ts b/src/server/checker/runner/tcp/types.ts new file mode 100644 index 0000000..d4f23ef --- /dev/null +++ b/src/server/checker/runner/tcp/types.ts @@ -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; +} diff --git a/src/server/checker/runner/tcp/validate.ts b/src/server/checker/runner/tcp/validate.ts new file mode 100644 index 0000000..2c6b1cf --- /dev/null +++ b/src/server/checker/runner/tcp/validate.ts @@ -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 | 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, + 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, 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 }; diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index 8c6d58d..0e24657 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import type { ResolvedCommandTarget } from "../../../src/server/checker/runner/cmd/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 { checkerRegistry } from "../../../src/server/checker/runner"; @@ -1778,4 +1779,199 @@ targets: // eslint-disable-next-line @typescript-eslint/await-thenable 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); + }); }); diff --git a/tests/server/checker/runner/registry.test.ts b/tests/server/checker/runner/registry.test.ts index d137f92..6171401 100644 --- a/tests/server/checker/runner/registry.test.ts +++ b/tests/server/checker/runner/registry.test.ts @@ -66,8 +66,8 @@ describe("CheckerRegistry", () => { const second = createDefaultCheckerRegistry(); first.register(createChecker("custom")); - expect(first.supportedTypes).toEqual(["http", "cmd", "db", "custom"]); - expect(second.supportedTypes).toEqual(["http", "cmd", "db"]); + expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "custom"]); + expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp"]); expect( first.definitions.every( (checker) => checker.schemas.config && checker.schemas.defaults && checker.schemas.expect, diff --git a/tests/server/checker/runner/tcp/execute.test.ts b/tests/server/checker/runner/tcp/execute.test.ts new file mode 100644 index 0000000..28976ed --- /dev/null +++ b/tests/server/checker/runner/tcp/execute.test.ts @@ -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, overrides?: Partial): 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; + 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"); + }); +}); diff --git a/tests/server/checker/runner/tcp/expect.test.ts b/tests/server/checker/runner/tcp/expect.test.ts new file mode 100644 index 0000000..ba05274 --- /dev/null +++ b/tests/server/checker/runner/tcp/expect.test.ts @@ -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); + }); +}); diff --git a/tests/server/checker/runner/tcp/validate.test.ts b/tests/server/checker/runner/tcp/validate.test.ts new file mode 100644 index 0000000..571df5e --- /dev/null +++ b/tests/server/checker/runner/tcp/validate.test.ts @@ -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): 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); + }); +});