1
0

Compare commits

...

2 Commits

47 changed files with 7580 additions and 2035 deletions

131
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,131 @@
# Contributing
本文档是 DiAL 的贡献入口,重点指导新增或修改 checker。通用开发命令和全局规则见 [DEVELOPMENT.md](DEVELOPMENT.md),完整开发专题见 [docs/development/](docs/development/README.md)。
## 通用贡献规则
- 使用中文编写注释、文档和项目内交流内容。
- 仅使用 `bun` 作为包管理器,禁止使用 npm、pnpm、yarn。
- 运行工具使用 `bunx`,禁止使用 npx、pnpx。
- 新增代码优先复用已有组件、工具和依赖库,不引入新依赖。
- 新增逻辑必须编写测试,不允许跳过测试。
- 每次代码变更必须执行文档影响分析,并按影响范围更新对应文档或说明无需更新。
## 新增或修改 Checker 前必读
| 文档 | 用途 |
| ------------------------------------------------------- | ---------------------------------------------------- |
| [README.md](README.md) | 项目定位、快速开始和用户入口 |
| [DEVELOPMENT.md](DEVELOPMENT.md) | 开发入口、常用命令、质量门禁和全局规则 |
| [docs/README.md](docs/README.md) | 文档索引和文档归属矩阵 |
| [Checker 开发](docs/development/checker-development.md) | checker 实现机制和详细 checklist |
| [配置文件](docs/user/configuration.md) | YAML 顶层结构、变量和 target 通用字段 |
| [校验规则](docs/user/expectations.md) | ValueMatcher、ContentExpectations、KeyedExpectations |
| [Checker 用户文档](docs/user/checkers/README.md) | 已支持 checker 的配置和示例 |
还应阅读现有同类 checker 的实现和测试,例如 `src/server/checker/runner/http/``src/server/checker/runner/cmd/` 和对应 `tests/server/checker/runner/` 目录。
## Checker 设计原则
- 每个 checker 必须自包含在 `src/server/checker/runner/<type>/`
- checker 专属类型、schema、validate、execute、expect 和协议辅助逻辑放在同一目录。
- 注册只修改 `src/server/checker/runner/index.ts`,中间层不新增 type switch。
- schema 层只描述契约,语义规则放入 `validate.ts`
- `resolve()` 只做默认值填充、路径解析和单位转换,不执行校验。
- `execute()` 必须支持 `CheckerContext.signal` 超时取消。
- expect 字段必须选择合适断言模型,不为了统一而滥用 ValueMatcher。
- failure phase 命名遵循去单位后缀规则,例如 `durationMs` 对应 `duration`
## 文件清单
新增 checker 通常需要创建或修改:
```text
src/server/checker/runner/<type>/types.ts
src/server/checker/runner/<type>/schema.ts
src/server/checker/runner/<type>/validate.ts
src/server/checker/runner/<type>/execute.ts
src/server/checker/runner/<type>/expect.ts
src/server/checker/runner/<type>/index.ts
src/server/checker/runner/index.ts
tests/server/checker/runner/<type>/
probes.example.yaml
probe-config.schema.json
docs/user/checkers/<type>.md
docs/user/checkers/README.md
```
如果修改通用断言模型、开发机制或文档同步规则,还需要更新 `docs/user/expectations.md``docs/development/checker-development.md`、本文件或 `docs/README.md`
## 分层要求
| 层 | 职责 |
| ----------------- | -------------------------------------------- |
| `types.ts` | Raw/Resolved target 和 expect 类型 |
| `schema.ts` | TypeBox Authoring/Normalized schema |
| `validate.ts` | JSON Schema 无法表达的语义校验 |
| `execute.ts` | Checker 类,包含 resolve、execute、serialize |
| `expect.ts` | checker 专用断言 |
| `runner/index.ts` | 注册 checker |
## expect 模型选择
| 场景 | 模型 |
| ------------------------------------ | ------------------- |
| 状态类结果且集合小而稳定 | enum 或 boolean |
| 单值数字指标或字符串元数据 | ValueMatcher |
| 文本、JSON、HTML、XML 或半结构化内容 | ContentExpectations |
| 动态键值表 | KeyedExpectations |
详细说明见 [校验规则](docs/user/expectations.md) 和 [Checker 开发](docs/development/checker-development.md)。
## 测试要求
| 测试类别 | 覆盖内容 |
| ------------ | ---------------------------------------- |
| 契约测试 | TypeBox schema 与 JSON Schema 导出一致性 |
| 语义校验测试 | 合法和非法配置 |
| resolve 测试 | 默认值合并、路径解析、单位转换 |
| execute 测试 | 成功、失败、超时、expect 组合 |
| 注册测试 | registry 注册行为 |
| 配置加载测试 | 含新 checker 的 YAML 完整加载流程 |
## 文档同步矩阵
| 变更 | 必须更新 |
| ------------------------ | ------------------------------------------------------------------------------------- |
| 新增 checker | `docs/user/checkers/<type>.md``docs/user/checkers/README.md``probes.example.yaml` |
| 修改 checker 配置字段 | 对应 checker 用户文档、schema、测试、示例 |
| 修改 checker expect 字段 | 对应 checker 用户文档,必要时更新 `docs/user/expectations.md` |
| 修改通用 expect 模型 | `docs/user/expectations.md``docs/development/checker-development.md` |
| 修改 checker 开发机制 | `docs/development/checker-development.md`、本文件 |
| 修改文档同步规则 | `docs/README.md``openspec/config.yaml` |
## 验证命令
新增或修改 checker 后通常需要运行:
```bash
bun run schema
bun run schema:check
bun run check
bun run verify
```
如果因环境限制无法执行完整验证,必须在收尾说明中记录未执行项和原因。
## 完成 checklist
```text
□ checker 类型、schema、validate、resolve、execute、serialize 已实现
□ checker 已在 runner/index.ts 注册
□ 配置契约、语义校验和 JSON Schema 导出已同步
□ probes.example.yaml 已添加或更新示例
□ tests/server/checker/runner/<type>/ 已覆盖契约、校验、resolve、execute、注册和配置加载
□ docs/user/checkers/<type>.md 已添加或更新
□ docs/user/checkers/README.md 已添加或更新
□ 文档影响分析已完成,必要文档已同步
□ bun run schema 和 bun run schema:check 已通过
□ bun run check 已通过
□ bun run verify 已通过或记录未执行原因
```

File diff suppressed because it is too large Load Diff

595
README.md
View File

@@ -10,31 +10,16 @@
--- ---
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP**、**UDP**、**ICMP** 和 **LLM** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。 DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP**、**UDP**、**DNS**、**ICMP** 和 **LLM** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
**功能亮点:** ## 功能亮点
-种拨测类型HTTPGET/POST/PUT 等、Cmd命令行执行、DBPostgreSQL/MySQL/SQLite、TCP端口可达性 + Banner 探测、UDP自定义 payload 请求-响应、ICMP存活检测、延迟、丢包率、LLM大模型服务应用层健康检查 -类型拨测HTTP、Cmd、DB、TCP、UDP、DNS、ICMP、LLM
- 丰富校验规则状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等 - 丰富校验规则状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
- 结构化观测数据:检查结果保留按需读取的 HTTP body 预览、TCP/UDP 响应摘要、ICMP 丢包率、CMD 输出预览、LLM token 用量等 observation便于排障和后续分析 - 结构化观测数据HTTP body 预览、TCP/UDP 响应摘要、ICMP 丢包率、CMD 输出、LLM token 用量等
- 响应式 Dashboard实时状态、可用率统计、动态粒度趋势图avg/P95 + 状态条、手动/自动刷新、版本号展示 - 内置 Dashboard实时状态、可用率统计、趋势图、最近状态条、手动/自动刷新、版本号展示
- 多主题支持:系统、明亮、黑暗三种主题模式 - 多主题支持:系统、明亮、黑暗三种主题模式
- 零外部依赖:数据存储使用 SQLite无需额外数据库服务 - 自托管部署:本地 SQLite 存储,无需额外数据库服务
## 版本管理
DiAL 使用 `package.json.version` 作为唯一版本源Dashboard Header 展示当前运行实例版本号(如 `v0.1.0`)。
**版本升迁命令:**
```bash
bun run version:patch # 升迁 patch 版本0.1.0 -> 0.1.1
bun run version:minor # 升迁 minor 版本0.1.0 -> 0.2.0
bun run version:major # 升迁 major 版本0.1.0 -> 1.0.0
bun run version:set 0.2.0 # 显式设置版本
```
版本升迁仅更新 `package.json`,不自动创建 git commit、tag 或 changelog。
## 应用截图 ## 应用截图
@@ -50,572 +35,54 @@ bun run version:set 0.2.0 # 显式设置版本
ICMP checker 依赖系统 `ping` 命令。精简容器镜像需额外安装,例如 Alpine 可安装 `iputils-ping` ICMP checker 依赖系统 `ping` 命令。精简容器镜像需额外安装,例如 Alpine 可安装 `iputils-ping`
```bash ```bash
# 克隆仓库
git clone https://github.com/your-org/DiAL.git git clone https://github.com/your-org/DiAL.git
cd DiAL cd DiAL
# 安装依赖
bun install bun install
# 复制示例配置并按需修改
cp probes.example.yaml probes.yaml cp probes.example.yaml probes.yaml
# 启动开发服务器
bun run dev probes.yaml bun run dev probes.yaml
``` ```
`bun run dev` 会同时启动 Vite 开发服务器(`http://127.0.0.1:5173`)和 API 服务器(`http://127.0.0.1:3000`),访问前端地址即可使用 Dashboard。 `bun run dev` 会同时启动 Vite 开发服务器(`http://127.0.0.1:5173`)和 API 服务器(`http://127.0.0.1:3000`),访问前端地址即可使用 Dashboard。
## 生产部署 ## 最小配置示例
```bash
# 构建
bun run build
# 运行
./dist/dial-server ./probes.yaml
```
构建产物为独立可执行文件,只需一个 YAML 配置文件即可运行。
### Docker 部署
DiAL 提供基于 Alpine 的多阶段镜像。构建阶段使用 Bun 生成 musl 目标单可执行文件,运行阶段只包含 `dial-server`、基础证书、`ping`、musl executable 必需运行库、时区数据和容器运行所需目录。
```bash
# 构建当前架构镜像
docker build -t dial:alpine .
# 运行容器,使用内置容器配置示例
docker run --rm -p 3000:3000 -v dial-data:/data/dial dial:alpine
# 使用自定义配置文件
docker run --rm -p 3000:3000 \
-v "$PWD/docker/probes.yaml:/etc/dial/probes.yaml:ro" \
-v dial-data:/data/dial \
dial:alpine
```
容器默认读取 `/etc/dial/probes.yaml`,推荐将数据卷挂载到 `/data/dial`。容器专用示例配置位于 [`docker/probes.yaml`](docker/probes.yaml),默认监听 `0.0.0.0:3000`,并将 SQLite 数据和日志写入 `/data/dial`
多架构镜像可通过 Docker Buildx 构建:
```bash
docker buildx build --platform linux/amd64,linux/arm64 -t dial:alpine .
```
如需在容器中运行 ICMP checker除镜像内置的 `iputils-ping` 外,还需要授予 `NET_RAW` capability
```bash
docker run --rm --cap-add=NET_RAW -p 3000:3000 -v dial-data:/data/dial dial:alpine
```
官方镜像不内置 `bun``node``curl``dig``psql``mysql``redis-cli` 等 CMD checker 可能需要的额外命令。需要这些命令时请使用派生镜像自行安装:
```dockerfile
FROM dial:alpine
USER root
RUN apk add --no-cache curl bind-tools postgresql-client
USER dial
```
### 跨平台发布打包
```bash
# 编译全部 7 个目标平台
bun run release
# 编译指定平台
bun run release --target linux-x64
bun run release --target linux-x64,windows-x64,darwin-arm64
```
支持的目标平台:`linux-x64``linux-arm64``linux-x64-musl``linux-arm64-musl``windows-x64``darwin-x64``darwin-arm64`
**产出物结构:**
```
dist/release/
├── binaries/ ← 裸二进制(带版本号和平台标识)
│ ├── dial-server-0.1.0-linux-x64
│ ├── dial-server-0.1.0-windows-x64.exe
│ └── ...
└── packages/ ← tar.gz 压缩包 + SHA256 校验和
├── dial-server_0.1.0_linux_x64.tar.gz
├── dial-server_0.1.0_linux_x64.tar.gz.sha256
└── ...
```
压缩包内含可执行文件、`probes.example.yaml``LICENSE`,解压后可直接使用。
## 配置文件
程序通过 YAML 配置文件定义所有运行参数,完整示例参见 [`probes.example.yaml`](probes.example.yaml)。
### 配置文件结构
```yaml ```yaml
# yaml-language-server: $schema=./probe-config.schema.json # yaml-language-server: $schema=./probe-config.schema.json
server: # 服务配置(均可省略) targets:
listen:
host: "127.0.0.1"
port: "${server_port}"
storage:
dataDir: "/tmp/probes_data"
retention: "${retention}"
logging:
level: "${log_level|info}"
file:
path: "<dataDir>/logs/dial.log"
probes: # 拨测运行时配置(可省略)
execution:
maxConcurrentChecks: "${max_checks}"
variables: # 配置变量(可省略)
env_name: "生产"
base_url: "https://api.example.com"
server_port: 3000
retention: "7d"
max_checks: 20
default_interval: "30s" # 通过变量在多个 target 间共享常用值
default_timeout: "10s"
targets: # 拨测目标列表(必填)
- id: "baidu-home" - id: "baidu-home"
name: "Baidu" name: "Baidu"
type: http type: http
http: http:
url: "https://www.baidu.com" url: "https://www.baidu.com"
expect: expect:
# ... status: [200]
- id: "my-cmd" durationMs:
name: "脚本检查" lte: 5000
type: cmd
cmd:
# ...
expect:
# ...
# ... 更多 targets
``` ```
### server.listen — 监听配置 完整配置、checker 和 expect 规则参见 [配置文件](docs/user/configuration.md)、[Checker 参考](docs/user/checkers/README.md) 和 [校验规则](docs/user/expectations.md)。
| 字段 | 说明 | 必填 | 默认值 | ## 生产运行
| ------ | -------- | ---- | ----------- |
| `host` | 监听地址 | 否 | `127.0.0.1` |
| `port` | 监听端口 | 否 | `3000` |
### server.storage — 存储配置 ```bash
bun run build
| 字段 | 说明 | 必填 | 默认值 | ./dist/dial-server ./probes.yaml
| ----------- | ------------------------------------------------ | ---- | -------- |
| `dataDir` | 数据目录,相对路径基于配置文件所在目录解析 | 否 | `./data` |
| `retention` | 历史数据保留时长,支持 `ms`/`s`/`m`/`h`/`d` 单位 | 否 | `7d` |
### probes.execution — 拨测运行时配置
| 字段 | 说明 | 必填 | 默认值 |
| --------------------- | -------------- | ---- | ------ |
| `maxConcurrentChecks` | 最大并发拨测数 | 否 | `20` |
### server.logging — 日志配置
| 字段 | 说明 | 必填 | 默认值 |
| ---------------------------------------- | ---------------------------------------------- | ---- | ------------------------- |
| `server.logging.level` | 全局日志等级console 和 file 未指定时继承此值 | 否 | `info` |
| `server.logging.console.level` | 控制台日志等级 | 否 | 继承 `level` |
| `server.logging.file.level` | 文件日志等级 | 否 | 继承 `level` |
| `server.logging.file.path` | 日志文件路径,相对路径基于配置文件目录解析 | 否 | `<dataDir>/logs/dial.log` |
| `server.logging.file.rotation.size` | 按大小滚动,支持 `KB`/`MB`/`GB` 单位 | 否 | `50MB` |
| `server.logging.file.rotation.frequency` | 按时间滚动:`hourly``daily``weekly` | 否 | `daily` |
| `server.logging.file.rotation.maxFiles` | 保留的归档文件数量(不含活跃日志) | 否 | `14` |
日志等级支持:`trace``debug``info``warn``error``fatal`
控制台始终输出pretty 格式),文件始终输出 JSONL 格式并支持滚动。`rotation.size``rotation.frequency` 任一条件触发即滚动。
### 内置默认值
未显式配置时,系统使用以下内置默认值:
- `interval``30s`(拨测间隔)
- `timeout``10s`(超时时间)
- 各 checker 专属默认值见对应章节
如需在配置文件中共享相同的配置值,可使用 `variables` 定义变量,然后在 `server``probes``targets` 中通过 `${var}` 引用。例如在 `variables` 中定义 `default_interval: "30s"`,在多个 target 的 `interval` 字段写 `${default_interval}`
### variables — 配置变量
`variables` 是顶层动态键值表key 必须符合 `[a-zA-Z_][a-zA-Z0-9_]*`value 仅支持 string、number、boolean。`server``probes``targets` 中的字符串值可引用变量:
- `${key}`:引用 variables 或环境变量
- `${key|default}`:变量和环境变量都不存在时使用默认值,第一个 `|` 后的内容为默认值
- `${key|}`:变量和环境变量都不存在时使用空字符串作为默认值
- `$${key}`:转义输出字面量 `${key}`
解析优先级为 `variables -> process.env -> 默认值`,三者均不存在时配置校验失败。字段值完整等于单个变量引用时会保留 number/boolean/string 类型,环境变量和默认值会做类型推断,但空字符串保持为字符串;部分拼接时统一转为字符串。变量替换作用于 `server``probes``targets`,不作用于 `variables` 段自身,且不会替换 `targets[].id``targets[].type` 字段;对象 key 不参与替换。
配置加载内部区分三层形态:用户 YAML 属于 Authoring Config允许变量引用和 expect 简写;`normalizeAuthoringConfig()` 会在启动时完成变量替换、expect primitive/keyed/content 简写展开并移除 `variables` 段,生成 Normalized Configchecker 的 `resolve()` 只在 ResolvedConfig 阶段补默认值并解析 duration、size、路径和运行期环境。根目录 `probe-config.schema.json` 面向 Authoring Config因此 VSCode 校验会接受 `server.listen.port: "${server_port|3000}"``http.maxRedirects: "${MAX|5}"``expect.durationMs: 5000` 这类写法。
### targets — 拨测目标列表(必填)
每个 target 的通用字段:
| 字段 | 说明 | 必填 | 默认值 |
| ------------- | ------------------------------------------------------------------------------------ | ---- | --------- |
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 | |
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null前端展示时 null 回退到 `id` | 否 | |
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null允许空字符串 | 否 | |
| `type` | 目标类型:`http``cmd``db``tcp``udp``icmp``llm` | 是 | |
| `group` | 分组名称 | 否 | `default` |
| `interval` | 拨测间隔,未配置时使用内置默认值 `30s` | 否 | `30s` |
| `timeout` | 超时时间,未配置时使用内置默认值 `10s` | 否 | `10s` |
---
### HTTP Checker`type: http`
**配置项**
| 字段 | 说明 | 必填 | 默认值 |
| ------------------- | ------------------- | ---- | ------- |
| `http.url` | 目标 URL | 是 | |
| `http.method` | HTTP 方法 | 否 | `GET` |
| `http.headers` | 请求头 | 否 | |
| `http.body` | 请求体 | 否 | |
| `http.ignoreSSL` | 忽略 HTTPS 证书校验 | 否 | `false` |
| `http.maxRedirects` | 最大重定向跟随次数 | 否 | `0` |
**expect 校验项**
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | -------------------------------------------------- | ---- | ------- |
| `status` | 可接受的状态码列表,支持精确码和范围(如 `"2xx"` | 否 | `[200]` |
| `headers` | 响应头校验,使用动态键名和 `KeyedExpectations` | 否 | |
| `body` | 响应体校验,使用 `ContentExpectations` 数组 | 否 | |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | |
**配置示例**
```yaml
- id: "json-api"
name: "JSON API 示例"
type: http
http:
url: "https://httpbin.org/json"
headers:
Authorization: "Bearer token"
expect:
status: [200]
headers:
Content-Type:
contains: "application/json"
body:
- json:
path: "$.slideshow.title"
equals: "Sample Slide Show"
durationMs:
lte: 10000
``` ```
--- Docker、跨平台发布包和运行时注意事项参见 [部署文档](docs/user/deployment.md)。
### Cmd Checker`type: cmd` ## 文档导航
**配置项** | 入口 | 内容 |
| -------------------------------------------- | ---------------------------------------------------- |
| 字段 | 说明 | 必填 | 默认值 | | [文档总览](docs/README.md) | 全部文档入口和文档归属矩阵 |
| ---------- | -------------------------------------- | ---- | ------ | | [配置文件](docs/user/configuration.md) | YAML 结构、变量、server、targets 通用字段 |
| `cmd.exec` | 可执行文件名或路径 | 是 | | | [Checker 参考](docs/user/checkers/README.md) | 所有 checker 的配置、expect 和示例 |
| `cmd.args` | 命令行参数列表 | 否 | `[]` | | [校验规则](docs/user/expectations.md) | ValueMatcher、ContentExpectations、KeyedExpectations |
| `cmd.env` | 环境变量覆盖(继承进程环境变量并合并) | 否 | | | [部署文档](docs/user/deployment.md) | 构建、Docker、发布包和容器运行边界 |
| `cmd.cwd` | 工作目录(相对于配置文件所在目录) | 否 | | | [状态模型](docs/user/status-model.md) | UP/DOWN、failure、observation、detail |
| [故障排查](docs/user/troubleshooting.md) | 常见运行问题和排查入口 |
**expect 校验项**
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | --------------------------------------------- | ---- | ------ |
| `exitCode` | 可接受的退出码列表 | 否 | `[0]` |
| `stdout` | 标准输出校验,使用 `ContentExpectations` 数组 | 否 | |
| `stderr` | 标准错误校验,使用 `ContentExpectations` 数组 | 否 | |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | |
**配置示例**
```yaml
- id: "bun-script"
name: "Bun 脚本检查"
type: cmd
cmd:
exec: "bun"
args: ["-e", "console.log('ok')"]
expect:
exitCode: [0]
stdout:
- contains: "ok"
```
---
### DB Checker`type: db`
**配置项**
| 字段 | 说明 | 必填 | 默认值 |
| ---------- | ------------------------------------------------------------- | ---- | ------ |
| `db.url` | 数据库连接字符串,支持 `postgres://``mysql://``sqlite://` | 是 | |
| `db.query` | SQL 查询语句,不配置时仅测试连接 | 否 | |
**expect 校验项**
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | ----------------------------------------------------------------------- | ---- | ------ |
| `rowCount` | 查询返回行数校验,使用 `ValueMatcher` | 否 | |
| `rows` | 查询结果逐行校验,数组内每行为列名到 `KeyedExpectations` 的映射 | 否 | |
| `result` | 完整查询结果 `{ rows, rowCount }` 校验,使用 `ContentExpectations` 数组 | 否 | |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | |
**配置示例**
```yaml
- id: "sqlite-query"
name: "SQLite 数据库检查"
type: db
db:
url: "sqlite:///path/to/db.sqlite"
query: "SELECT COUNT(*) as cnt FROM users WHERE status = 'active'"
expect:
durationMs:
lte: 5000
rowCount: { gte: 1 }
rows:
- cnt: { gte: 0 }
```
---
### TCP Checker`type: tcp`
**配置项**
| 字段 | 说明 | 必填 | 默认值 |
| ----------------------- | ------------------------------------------- | ---- | ------- |
| `tcp.host` | 目标主机地址 | 是 | |
| `tcp.port` | 目标端口1-65535 | 是 | |
| `tcp.readBanner` | 是否读取服务端 banner | 否 | `false` |
| `tcp.bannerReadTimeout` | banner 读取超时(毫秒) | 否 | `2000` |
| `tcp.maxBannerBytes` | banner 最大字节数,支持 `KB`/`MB`/`GB` 单位 | 否 | `4KB` |
**expect 校验项**
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | ------------------------------------------------------------------------- | ---- | ------ |
| `connected` | 期望连接结果,`true` 可达或 `false` 期望不可达 | 否 | `true` |
| `banner` | Banner 内容校验,使用 `ContentExpectations` 数组,需开启 `tcp.readBanner` | 否 | |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | |
**配置示例**
```yaml
- id: "redis-port"
name: "Redis 端口可达"
type: tcp
tcp:
host: "127.0.0.1"
port: 6379
expect:
durationMs:
lte: 3000
```
---
### UDP Checker`type: udp`
**配置项**
| 字段 | 说明 | 必填 | 默认值 |
| ---------------------- | ---------------------------------------- | ---- | ------ |
| `udp.host` | 目标主机地址 | 是 | |
| `udp.port` | 目标端口1-65535 | 是 | |
| `udp.payload` | 发送数据 | 否 | `""` |
| `udp.encoding` | payload 编码:`text``hex``base64` | 否 | `text` |
| `udp.responseEncoding` | 响应解码:`text``hex``base64` | 否 | `text` |
| `udp.maxResponseBytes` | 响应最大字节数,支持 `KB`/`MB`/`GB` 单位 | 否 | `4KB` |
**expect 校验项**
| 字段 | 说明 | 必填 | 默认值 |
| -------------- | --------------------------------------------- | ---- | ------ |
| `responded` | 期望是否收到响应 | 否 | `true` |
| `response` | 响应内容校验,使用 `ContentExpectations` 数组 | 否 | |
| `responseSize` | 响应字节数校验,使用 `ValueMatcher` | 否 | |
| `sourceHost` | 响应来源地址校验,使用 `ValueMatcher` | 否 | |
| `sourcePort` | 响应来源端口校验,使用 `ValueMatcher` | 否 | |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | |
**配置示例**
```yaml
- id: "udp-heartbeat"
name: "UDP 心跳检测"
type: udp
udp:
host: "127.0.0.1"
port: 9000
payload: "PING"
expect:
responded: true
response:
- contains: "PONG"
durationMs:
lte: 100
```
---
### ICMP Checker`type: icmp`
**配置项**
| 字段 | 说明 | 必填 | 默认值 |
| ----------------- | ------------------------- | ---- | ------ |
| `icmp.host` | 目标主机地址 | 是 | |
| `icmp.count` | ICMP 包数量,范围 `1-100` | 否 | `3` |
| `icmp.packetSize` | ICMP 包大小bytes | 否 | `56` |
ICMP checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS 和 Windows 输出解析。
**expect 校验项**
| 字段 | 说明 | 必填 | 默认值 |
| ------------------- | --------------------------------------------------- | ---- | ------ |
| `alive` | 期望主机可达性 | 否 | `true` |
| `packetLossPercent` | 丢包率百分比校验,范围 `0-100`,使用 `ValueMatcher` | 否 | |
| `avgLatencyMs` | 平均延迟校验,使用 `ValueMatcher` | 否 | |
| `maxLatencyMs` | 最大单次延迟校验,使用 `ValueMatcher` | 否 | |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | |
**配置示例**
```yaml
- id: "gateway-icmp"
name: "网关 ICMP 可达"
type: icmp
icmp:
host: "10.0.0.1"
count: 3
packetSize: 56
expect:
alive: true
packetLossPercent:
lte: 10
avgLatencyMs:
lte: 100
maxLatencyMs:
lte: 300
durationMs:
lte: 5000
```
---
### LLM Checker`type: llm`
**配置项**
| 字段 | 说明 | 必填 | 默认值 |
| --------------------- | ------------------------------------------------------ | ---- | ------- |
| `llm.provider` | 模型提供方:`openai``openai-responses``anthropic` | 是 | |
| `llm.url` | API base URL | 是 | |
| `llm.model` | 模型名称 | 是 | |
| `llm.prompt` | 单轮 prompt | 是 | |
| `llm.mode` | 调用模式:`http`(非流式)或 `stream`(流式) | 否 | `http` |
| `llm.key` | API key支持 `${VAR}` 变量替换 | 否 | `""` |
| `llm.authToken` | Bearer token`anthropic` provider`key` 互斥) | 否 | |
| `llm.headers` | 附加请求头 | 否 | |
| `llm.ignoreSSL` | 忽略 HTTPS 证书校验 | 否 | `false` |
| `llm.options` | 生成选项 | 否 | |
| `llm.providerOptions` | Provider 专属选项 | 否 | |
`llm.options` 支持 `maxOutputTokens`(默认 `16`)、`temperature`(默认 `0`)、`topP``topK``presencePenalty``frequencyPenalty``stopSequences``seed`
**expect 校验项**
| 字段 | 说明 | 必填 | 默认值 |
| ----------------- | ---------------------------------------------------------------------- | ---- | ------- |
| `status` | 可接受的状态码列表,支持精确码和范围(如 `"2xx"` | 否 | `[200]` |
| `headers` | 响应头校验,使用动态键名和 `KeyedExpectations` | 否 | |
| `output` | 模型输出校验,使用 `ContentExpectations` 数组 | 否 | |
| `finishReason` | finish reason 校验,使用 `ValueMatcher` | 否 | |
| `rawFinishReason` | 原始 finish reason 校验,使用 `ValueMatcher` | 否 | |
| `usage` | Token usage 校验(`inputTokens`/`outputTokens`/`totalTokens` matcher | 否 | |
| `stream` | 流式断言(`completed``firstTokenMs` matcher`mode: stream` | 否 | |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | |
**配置示例**
```yaml
- id: "llm-openai-probe"
name: "OpenAI 健康检查"
type: llm
llm:
provider: openai
url: "https://api.openai.com/v1"
model: "gpt-4o-mini"
prompt: "Say OK"
key: "${OPENAI_API_KEY}"
expect:
status: [200]
finishReason: "stop"
output:
- contains: "OK"
```
---
### 通用校验规则
#### ContentExpectations 校验项
`body``stdout``stderr``banner``response``output``result` 均使用数组:
- `contains` — 响应体包含指定文本
- `regex` — 正则匹配(启动期会拒绝存在 ReDoS 风险的模式)
- `json` — JSONPath 提取值比较(`path` 必填,如 `$.slideshow.title`
- `css` — CSS 选择器提取 HTML 元素(`selector` 必填,`attr` 可选提取属性)
- `xpath` — XPath 提取 XML/HTML 节点(`path` 必填,如 `/html/body/h1/text()`
#### ValueMatcher
`equals``contains``regex``empty``exists``gte``lte``gt``lt``equals` 支持 JSON 深度相等;`regex` 固定使用无 flags 正则;提取器未配置 matcher 时等价于 `exists: true`。ValueMatcher expect 字段也可直接写 string、number、boolean 或 null等价于 `{ equals: value }`;数组和对象必须显式写成 `{ equals: ... }`
#### KeyedExpectations
`headers``rows` 中每行使用的校验结构,支持 ValueMatcher 的全部字段。
### 补充说明
- **大小说明**`maxBodyBytes``maxOutputBytes``maxResponseBytes``maxBannerBytes` 支持 `KB``MB``GB` 单位,也可直接使用数字
- **时长格式**`500ms``30s``5m``2h``7d`
- **JSON Schema**:仓库根目录导出 `probe-config.schema.json`,在 YAML 文件顶部添加 `# yaml-language-server: $schema=./probe-config.schema.json` 即可在编辑器中获得提示和校验
- **旧字段移除**`maxDurationMs``maxPacketLoss``maxAvgLatencyMs``maxMaxLatencyMs` 和旧正则字段 `match` 已移除,请分别改用 `durationMs`、ICMP matcher 字段和 `regex`
> **注意:** 配置校验在启动时执行,非法配置会阻止启动并输出错误信息。除动态键值表(`headers`、`env`、`variables`)外,未知字段会导致启动失败,请使用 YAML 注释。
## 目标状态判定
采用单层判定模型:
- **UP** = 拨测结果符合 expect 规则
- **DOWN** = 拨测结果不符合 expect 规则
执行失败(网络错误、超时、进程崩溃)和 expect 不匹配都统一为 DOWN通过 `failure.kind` 区分原因(`"error"` vs `"mismatch"`)。
API 返回的检查结果包含 `detail``observation``detail` 是后端按 checker 类型从结构化 observation 动态生成的人可读摘要,`observation` 保存该次检查的结构化观测数据。`detail` 不写入 SQLite存储层仅持久化 `observation` JSON、`failure` JSON、匹配状态、耗时和时间戳。
## 开发 ## 开发
@@ -624,7 +91,7 @@ bun run check # schema:check + typecheck + lint + test
bun run verify # check + build bun run verify # check + build
``` ```
开发相关文档(项目结构、构建、测试、代码规范等)请参阅 [DEVELOPMENT.md](DEVELOPMENT.md)。 开发入口参见 [DEVELOPMENT.md](DEVELOPMENT.md)。新增或修改 checker 前请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。
## License ## License

126
docs/README.md Normal file
View File

@@ -0,0 +1,126 @@
# DiAL 文档
本文档是 DiAL 的文档路由入口。AI 工具和开发者应先阅读本文件判断本次任务需要读取和更新哪些专题文档,再按任务类型读取最小必要上下文。
## 读者入口
| 读者 | 推荐入口 |
| -------------- | ------------------------------------------------------------------------------------------------------------ |
| 首次使用者 | [README 快速开始](../README.md#快速开始) |
| 配置编写者 | [配置文件](user/configuration.md)、[Checker 参考](user/checkers/README.md)、[校验规则](user/expectations.md) |
| 部署维护者 | [部署文档](user/deployment.md)、[故障排查](user/troubleshooting.md) |
| 项目开发者 | [开发文档索引](development/README.md)、[DEVELOPMENT.md](../DEVELOPMENT.md) |
| Checker 贡献者 | [CONTRIBUTING.md](../CONTRIBUTING.md)、[Checker 开发](development/checker-development.md) |
| AI 工具维护者 | 本文件的任务路由与文档归属矩阵、[OpenSpec 配置](../openspec/config.yaml) |
## 按任务阅读路径
| 任务 | 必读文档 |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 新增 checker | [CONTRIBUTING.md](../CONTRIBUTING.md)、[Checker 开发](development/checker-development.md)、[Checker 参考](user/checkers/README.md)、相近 checker 用户文档 |
| 修改 checker 配置 | 对应 `docs/user/checkers/<type>.md`、[配置文件](user/configuration.md)、[校验规则](user/expectations.md) |
| 修改 expect 机制 | [校验规则](user/expectations.md)、[后端开发](development/backend.md)、[Checker 开发](development/checker-development.md) |
| 修改前端 | [DEVELOPMENT.md](../DEVELOPMENT.md)、[前端开发](development/frontend.md)、[测试与质量](development/testing-quality.md) |
| 修改后端 API、store、engine、logger | [DEVELOPMENT.md](../DEVELOPMENT.md)、[后端开发](development/backend.md)、[测试与质量](development/testing-quality.md) |
| 修改构建、Docker、release | [构建与发布](development/build-release.md)、[部署文档](user/deployment.md) |
| 修改配置 schema | [配置文件](user/configuration.md)、相关 checker 文档、[后端开发](development/backend.md)、[测试与质量](development/testing-quality.md) |
| 修改文档规则 | 本文件、[DEVELOPMENT.md](../DEVELOPMENT.md)、[OpenSpec 配置](../openspec/config.yaml) |
## 目录结构
```text
docs/
README.md
user/
deployment.md
configuration.md
expectations.md
status-model.md
troubleshooting.md
checkers/
README.md
http.md
cmd.md
db.md
tcp.md
udp.md
icmp.md
dns.md
llm.md
development/
README.md
architecture.md
backend.md
frontend.md
checker-development.md
build-release.md
testing-quality.md
prompts/
README.md
```
## 文档归属矩阵
| 变更类型 | 默认更新位置 |
| ------------------------------------------------------------- | ------------------------------------------------------------ |
| 项目定位、核心能力、快速开始、文档导航变化 | `README.md` |
| 用户安装、首次运行、基础使用流程变化 | `README.md` |
| YAML 顶层结构、server、variables、targets 通用字段变化 | `docs/user/configuration.md` |
| checker 配置、expect 字段、示例变化 | `docs/user/checkers/` 对应文档 |
| ValueMatcher、ContentExpectations、KeyedExpectations 规则变化 | `docs/user/expectations.md` |
| 构建产物运行、Docker、发布包、运行时能力变化 | `docs/user/deployment.md` |
| UP/DOWN 判定、failure、observation、detail 行为变化 | `docs/user/status-model.md` |
| 常见运行问题、依赖命令、容器权限、配置校验问题变化 | `docs/user/troubleshooting.md` |
| 开发入口、常用命令、质量门禁、全局规则变化 | `DEVELOPMENT.md` |
| 架构边界、启动流程、运行时流程变化 | `docs/development/architecture.md` |
| 后端 API、store、engine、logger、expect 实现机制变化 | `docs/development/backend.md` |
| 前端技术栈、组件、样式、数据层规范变化 | `docs/development/frontend.md` |
| 新增或修改 checker 的开发机制变化 | `CONTRIBUTING.md``docs/development/checker-development.md` |
| 构建、发布、脚本、项目配置维护方式变化 | `docs/development/build-release.md` |
| 测试、lint、typecheck、hooks、格式化规范变化 | `docs/development/testing-quality.md` |
| 包管理、依赖、目录、提交、OpenSpec 约定变化 | `DEVELOPMENT.md` |
| 文档同步规则、文档影响分析规则变化 | `docs/README.md``openspec/config.yaml` |
| AI 提示词资产变化 | `docs/prompts/` |
## 文档影响分析
每次代码变更都必须执行文档影响分析。
```text
代码或配置变更
├─ 用户能感知吗?
│ ├─ 配置 / checker / expect -> docs/user/
│ ├─ 部署 / 运行 / release -> docs/user/deployment.md
│ ├─ 状态 / observation / failure -> docs/user/status-model.md
│ └─ 项目入口变化 -> README.md
├─ 开发者需要知道吗?
│ ├─ checker 机制 -> CONTRIBUTING.md + docs/development/checker-development.md
│ ├─ 架构边界 -> docs/development/architecture.md
│ ├─ 后端机制 -> docs/development/backend.md
│ ├─ 前端机制 -> docs/development/frontend.md
│ ├─ 构建测试质量 -> docs/development/testing-quality.md
│ └─ 开发入口规则 -> DEVELOPMENT.md
└─ 都不是
└─ 收尾说明无需更新文档及原因
```
## 维护原则
- 根目录入口文档保持轻量,不承载完整配置参考和实现教程。
- 用户文档解释“如何使用”,开发文档解释“如何实现和维护”。
- 配置事实来源是 TypeBox schema、`probe-config.schema.json`、语义校验器和测试;文档负责解释和示例。
- 每次代码变更都必须做文档影响分析;有影响时更新对应专题文档,无影响时在收尾说明中写明原因。
- 同一字段表只在最贴近读者的文档中完整展开,其他文档用链接引用。
- README 不承载完整配置表和 checker 表DEVELOPMENT 不承载完整架构百科和 checker 教程。
## 收尾说明示例
```text
文档影响分析:本次修改了 HTTP checker 的配置字段,已更新 docs/user/checkers/http.md、docs/user/configuration.md 和 probe-config.schema.json。
```
无需更新文档时:
```text
文档影响分析:本次仅调整内部测试 helper未改变用户可见行为、配置、架构边界或开发流程因此无需更新文档。
```

View File

@@ -0,0 +1,25 @@
# 开发文档
本目录承载 DiAL 的开发和维护专题。日常开发入口见 [`../../DEVELOPMENT.md`](../../DEVELOPMENT.md),新增或修改 checker 前先阅读 [`../../CONTRIBUTING.md`](../../CONTRIBUTING.md)。
## 专题索引
| 文档 | 内容 |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| [architecture.md](architecture.md) | 项目结构、启动流程、运行时流程、HTTP 请求流程、前后端边界 |
| [backend.md](backend.md) | 后端库优先级、API 路由、共享 helpers、类型规范、配置契约、store、engine、logger、expect、错误模型 |
| [frontend.md](frontend.md) | React、TDesign、TanStack Query、组件、样式和前端测试规范 |
| [checker-development.md](checker-development.md) | 新增或修改 checker 的实现机制和完整 checklist |
| [build-release.md](build-release.md) | 开发服务、前后端集成、构建、Docker、release、脚本、环境变量 |
| [testing-quality.md](testing-quality.md) | lint、format、typecheck、test、hooks 和测试编写规范 |
| [../../DEVELOPMENT.md](../../DEVELOPMENT.md) | 包管理、依赖、目录、提交、OpenSpec 和项目级约定 |
| [../README.md](../README.md) | 文档影响分析、文档归属矩阵和按任务阅读路径 |
## 事实来源
| 主题 | 事实来源 |
| ---------------- | ---------------------------------------------------------- |
| 代码结构和实现 | `src/``scripts/``tests/` |
| 配置 schema | TypeBox fragments、`probe-config.schema.json`、schema 测试 |
| 项目全局规则 | `openspec/config.yaml``DEVELOPMENT.md`、本目录专题文档 |
| checker 贡献流程 | `CONTRIBUTING.md``checker-development.md` |

View File

@@ -0,0 +1,106 @@
# 架构与边界
## 项目结构
```text
src/
server/
bootstrap.ts
config.ts
dev.ts
logger.ts
main.ts
server.ts
helpers.ts
middleware.ts
version.ts
routes/
checker/
config-loader.ts
variables.ts
schema/
store.ts
engine.ts
expect/
runner/
shared/
api.ts
web/
app.tsx
main.tsx
styles.css
components/
constants/
hooks/
utils/
scripts/
tests/
docs/
openspec/
probe-config.schema.json
```
## 启动流程
```text
dev.ts / main.ts
-> readRuntimeConfig(cli args)
-> bootstrap({ configPath, mode })
-> loadConfig(yaml)
-> createRuntimeLogger(logging)
-> ProbeStore(db)
-> store.syncTargets(targets)
-> ProbeEngine(...).start()
-> startServer({ config, mode, store, logger })
-> 注册 SIGINT/SIGTERM shutdown
```
`loadConfig()` 的处理顺序YAML 解析 -> Authoring normalize变量替换 + expect 简写展开)-> Normalized 契约校验 -> 语义校验 -> resolve。
## 运行时流程
```text
定时器 tick
-> ProbeEngine.probeGroup()
-> checkerRegistry.get(target.type).execute()
-> runner/*/expect.ts 校验
-> engine.writeResult()
-> store.insertCheckResult()
```
数据清理由 engine 定时调用 `store.prune(retentionMs)`,每小时执行一次。
## HTTP 请求流程
```text
Request
-> Bun.serve routes 声明式匹配
-> routes/*.ts handler
-> middleware.ts 参数校验
-> helpers.ts 响应格式化
-> Response
```
生产模式下,非 API 路径由 fetch fallback 处理静态资源和 SPA fallback。开发模式下Vite proxy 将 `/api``/health` 请求转发到 Bun API server。
## 前后端边界
- 前端只通过 HTTP 调用后端API 路径为 `/api/*`
- 共享类型放在 `src/shared/`
- 前端不得 import `src/server/` 的运行时实现。
- 后端不得依赖 `src/web/` 运行时代码HTML import 集成除外。
## 主要模块职责
| 模块 | 职责 |
| ------------------------------------- | ------------------------------------------- |
| `src/server/bootstrap.ts` | 统一启动引导和 shutdown 编排 |
| `src/server/server.ts` | Bun HTTP server 和 routes 注册 |
| `src/server/routes/` | API handler按端点拆分 |
| `src/server/checker/config-loader.ts` | YAML 解析、契约校验、语义校验、resolve 调度 |
| `src/server/checker/store.ts` | SQLite 数据存储 |
| `src/server/checker/engine.ts` | 定时调度、并发控制、结果写入、数据清理 |
| `src/server/checker/runner/` | 各 checker 自包含实现 |
| `src/server/checker/expect/` | 跨 checker 复用的断言基础设施 |
| `src/web/` | React Dashboard |
| `src/shared/api.ts` | 前后端共享 API 类型 |

123
docs/development/backend.md Normal file
View File

@@ -0,0 +1,123 @@
# 后端开发
## 库使用优先级
| 优先级 | 来源 | 典型用途 |
| ------ | ------------ | -------------------------------------------------------------- |
| 1 | Bun 内置 API | `Bun.serve``bun:sqlite``Bun.spawn``Bun.file``Bun.YAML` |
| 2 | es-toolkit | 类型判断、深度比较、错误判断、并发控制、集合操作 |
| 3 | 标准 Web API | `Object.fromEntries``Headers``fetch``AbortController` |
| 4 | 主流三方库 | cheerio、xpath、@xmldom/xmldom |
| 5 | 自行实现 | 仅在以上都无法满足时 |
新增依赖前必须先检查上述每一层是否已有可用方案。
## API 路由开发
路由文件位于 `src/server/routes/`,每个端点一个文件。路由通过 `server.ts``Bun.serve({ routes })` 声明式注册,使用 per-method handler 对象。
新增路由步骤:
1.`src/server/routes/` 下创建 `<name>.ts`
2. 实现 handler 函数并 export。
3.`server.ts``routes` 对象中注册路径和 method handler。
4.`tests/server/app.test.ts` 中添加集成测试。
请求参数校验使用 `middleware.ts` 提供的 `validateTargetId``validateTimeRange``validatePagination``validateDashboardWindow``validateRecentLimit``validateMetricsBucket`
## 共享 helpers
| 函数 | 用途 |
| ------------------------------- | ------------------------------------ |
| `createApiError(error, status)` | 构造 API 错误体 |
| `createHeaders(mode, init)` | 创建响应 Headers生产模式附加安全头 |
| `createHealthResponse()` | 构造健康检查响应 |
| `formatDuration(ms)` | 毫秒转为可读时长字符串 |
| `jsonResponse(body, options)` | JSON 响应构造 |
| `mapCheckResult(row, type)` | 数据库行转 API CheckResult |
## 类型规范
- 共享类型以 `src/shared/api.ts` 为唯一源头。
- 严格联合类型优先于宽类型。
- 存储层类型与 API 类型分离。
- checker 具体类型在各自目录定义,中间层通过 base interface 和 registry 完成类型擦除。
- 纯类型导入使用 `import type`
## 配置契约与校验
配置加载流程固定为:`unknown -> AuthoringProbeConfig -> NormalizedProbeConfig -> ValidatedProbeConfig -> ResolvedConfig`
| 层级 | 职责 |
| ---------- | ------------------------------------------------ |
| Authoring | 用户 YAML 可书写形态,允许变量引用和 expect 简写 |
| Normalized | 变量替换和 expect 简写展开后的契约校验形态 |
| Validated | 通过契约校验和语义校验的形态 |
| Resolved | checker `resolve()` 后的运行期配置 |
Ajv 保持严格拒绝模式:`allErrors: true`、不启用类型强制转换、不注入默认值、不自动删除未知字段。默认对象策略是 `additionalProperties: false`,只有明确的动态键值表可以开放任意键名。
新增或修改配置字段时必须同步更新 TypeBox schema fragments、`probe-config.schema.json`、语义 validator、测试和对应用户文档并运行 `bun run schema:check`
## 数据存储
存储层基于 `bun:sqlite`WAL 模式运行,数据库文件位于配置的 `dataDir` 下。
| 方法 | 用途 |
| ------------------------------------------ | ---------------------------------- |
| `syncTargets(targets)` | 启动期同步 targets |
| `insertCheckResult()` | 写入单条检查结果 |
| `getTargets()` | 查询全部 targets |
| `getLatestChecksMap()` | 批量获取每个 target 的最新检查结果 |
| `getAllTargetWindowStats(from, to)` | 批量获取窗口基础计数 |
| `getDashboardIncidentStates(from, to)` | 获取 Dashboard 窗口状态序列 |
| `getAllRecentSamples(limit)` | 批量获取最近采样 |
| `getTargetCheckpoints(targetId, from, to)` | 获取单目标窗口检查点序列 |
| `getTargetDurations(targetId, from, to)` | 获取单目标成功耗时数组 |
| `getHistory()` | 分页查询历史记录 |
| `prune(retentionMs)` | 清理过期数据 |
数据库只负责存储、筛选、排序、分页、LIMIT 和基础聚合。指标语义在后端应用层实现。
## 拨测引擎
- 按 interval 分组,每组独立定时触发。
- 使用 `es-toolkit/Semaphore` 限制全局最大并发数。
- 通过 `checkerRegistry.get(target.type)` 选择 runner。
- 每次检查创建 `AbortController` 并按 `target.timeoutMs` 触发 abort。
- 状态变化通过注入的 `Logger` 输出结构化日志。
## 日志模块
后端运行时代码统一通过 `Logger` 接口输出日志,禁止直接使用 `console.*`。配置加载失败前使用 `ConsoleFallbackLogger`
| 实现 | 用途 |
| ----------------------- | --------------------------------------------- |
| `PinoLoggerWrapper` | 生产运行时,封装 Pino、pino-pretty、pino-roll |
| `NoopLogger` | 静默丢弃日志 |
| `MemoryLogger` | 测试替身 |
| `ConsoleFallbackLogger` | 配置加载失败前的降级日志 |
敏感信息会自动 redact `authorization``cookie``set-cookie``authToken``key``password``token``apiKey` 及其嵌套路径。
## expect 系统
共享断言基础设施位于 `src/server/checker/expect/`。新增或修改 checker 的 expect 字段时,按以下原则选择模型:
| 模型 | 用途 | 典型字段 |
| --------------------- | ---------------------------- | ------------------------------------------------------------------- |
| enum / boolean | 状态类结果,结果集合小且稳定 | HTTP status、Cmd exitCode、TCP connected、UDP responded、ICMP alive |
| `ValueMatcher` | 数字指标和字符串元数据 | durationMs、rowCount、finishReason、usage |
| `ContentExpectations` | 返回内容或半结构化内容 | body、stdout、stderr、banner、response、output、result |
| `KeyedExpectations` | 动态键值断言 | headers、DB rows 列值 |
详细 checker 开发流程见 [Checker 开发](checker-development.md)。
## 错误模型
| 类型 | 结构 |
| ------------ | ----------------------------------- | ------------------------------------------------------- |
| API 错误 | `{ error: "描述", status: <code> }` |
| CheckFailure | `{ kind: "error" | "mismatch", phase, path, expected?, actual?, message }` |
expect 校验失败记录首个失败原因;网络、超时、进程崩溃统一为 `kind: "error"`

View File

@@ -0,0 +1,107 @@
# 构建与发布
## 开发期运行
```bash
bun run dev probes.yaml
```
`scripts/dev.ts` 同时启动两个进程:
| 进程 | 用途 |
| --------------- | ------------------------------------------------- |
| Bun API server | 后端 API 服务,`--watch` 监听后端文件变更自动重启 |
| Vite dev server | 前端 SPA、HMR、模块热替换 |
也可以单独启动:
```bash
bun run dev:server probes.yaml
bun run dev:web
```
## 前后端集成
开发模式下Vite 通过 proxy 将 `/api/*``/health` 转发到 Bun。
生产模式下,前端通过 Vite 构建为静态资源,通过 `import with { type: "file" }` 嵌入 Bun 可执行文件。非 API 路径由 fetch fallback 处理:有文件扩展名的返回静态资源或 404无扩展名的返回 SPA index.html。
## 构建
```bash
bun run build
```
构建流程:
```text
1. Vite build -> dist/web/
2. Code generation -> .build/static-assets.ts + .build/server-entry.ts
3. Bun compile -> dist/dial-server
```
构建参数:
| 环境变量 | 说明 |
| -------------- | ---------------- |
| `BUN_TARGET` | 交叉编译目标平台 |
| `BUILD_TARGET` | 交叉编译目标平台 |
## Docker 镜像
Docker 镜像使用 Alpine 多阶段构建,保持与生产单可执行文件交付模型一致。
```text
oven/bun:1-alpine -> bun install --frozen-lockfile
-> BUN_TARGET=bun-linux-*-musl bun run build
-> dist/dial-server
alpine -> 仅复制 /usr/local/bin/dial-server
-> 安装 ca-certificates、iputils-ping、libgcc、libstdc++、tzdata
-> 使用非 root dial 用户运行
```
Dockerfile 通过 `TARGETARCH` 选择 Bun compile target。
| `TARGETARCH` | `BUN_TARGET` |
| ------------ | ---------------------- |
| `amd64` | `bun-linux-x64-musl` |
| `arm64` | `bun-linux-arm64-musl` |
## Release
```bash
bun run release
bun run release --target linux-x64
bun run release --target linux-x64,windows-x64,darwin-arm64
```
release 流程:
```text
1. Vite build -> dist/web/
2. Code generation -> .build/
3. 多目标 Bun compile -> dist/release/binaries/
4. tar.gz 打包 -> dist/release/packages/
```
支持的平台见 [用户部署文档](../user/deployment.md#跨平台发布包)。
## 脚本说明
| 脚本 | 文件 | 说明 |
| ---------------------- | ----------------------------------- | ------------------------------ |
| `bun run dev` | `scripts/dev.ts` | 双进程开发服务 |
| `bun run dev:server` | `src/server/dev.ts` | 仅启动后端 API server |
| `bun run dev:web` | Vite CLI | 仅启动 Vite dev server |
| `bun run build` | `scripts/build.ts` | Vite -> codegen -> Bun compile |
| `bun run release` | `scripts/release.ts` | 多目标交叉编译和打包 |
| `bun run schema` | `scripts/generate-config-schema.ts` | 生成配置 JSON Schema |
| `bun run schema:check` | `scripts/generate-config-schema.ts` | 检查配置 JSON Schema 同步 |
| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 |
## 维护约定
- `scripts/build-common.ts` 中的 import specifier 输出必须使用 `/` 分隔符。
- 跨平台路径测试不得用当前平台 `path.sep` 伪装其他平台,应使用 `node:path.win32` 或等价注入方式模拟。
- 如本地 Docker 环境不支持 buildx 或多架构模拟,需在变更记录中说明未执行原因。

View File

@@ -0,0 +1,161 @@
# Checker 开发
Checker 是 DiAL 的核心扩展单元。每个 checker 是 `src/server/checker/runner/<type>/` 下的自包含目录,包含该 checker 所需的类型、schema、校验、执行逻辑和断言。
新增或修改 checker 前请同时阅读 [`../../CONTRIBUTING.md`](../../CONTRIBUTING.md)、[配置文件](../user/configuration.md)、[校验规则](../user/expectations.md) 和 [Checker 用户文档](../user/checkers/README.md)。
## 架构目标
```text
checkerRegistry
├── runner/index.ts
├── schema/builder.ts
├── schema/validate.ts
├── config-loader.ts
├── engine.ts
└── store.ts
```
注册后,中间层通过 registry 自动委托 schema 生成、契约校验、配置 resolve、执行和序列化。新增 checker 不应在中间层新增 `switch/case` 或类型分支。
## 标准文件结构
| 文件 | 职责 |
| ------------- | ----------------------------------------------------- |
| `index.ts` | 模块入口re-export Checker 类 |
| `types.ts` | Checker 专属类型 |
| `schema.ts` | TypeBox 契约 schema包含 config 和 expect |
| `validate.ts` | 启动期语义校验 |
| `execute.ts` | Checker 类,实现 resolve、execute、serialize |
| `expect.ts` | Checker 专用断言函数 |
| 其他文件 | 协议解析、编码、provider 适配、平台命令封装等专属逻辑 |
## 类型定义
`types.ts` 中定义:
- `RawXxxTargetConfig`
- `RawXxxExpectConfig`
- `ResolvedXxxExpectConfig`
- `ResolvedXxxTarget extends ResolvedTargetBase`
不需要修改顶层 `checker/types.ts`。base interface 使用 index signature 支持扩展。
## Schema
checker 必须提供 `CheckerSchemas`,包含 Authoring 和 Normalized 两套 config/expect 片段。Authoring 描述用户 YAML 可写 DSLNormalized 描述 normalizer 输出。
常用 fragments
| Fragment | 用途 |
| ----------------------------------- | ------------------------- |
| `durationSchema` | 时长字符串 |
| `sizeSchema` | 大小单位 |
| `statusCodePatternSchema` | HTTP 状态码或范围 |
| `stringMapSchema` | headers、env 等字符串映射 |
| `createValueMatcherSchema()` | ValueMatcher |
| `createContentExpectationsSchema()` | ContentExpectations |
| `createKeyedExpectationsSchema()` | KeyedExpectations |
默认对象策略为 `additionalProperties: false`。只有明确的动态键值表可以开放任意键名。
## 语义校验
`validate.ts` 中实现 JSON Schema 无法表达的规则,统一返回 `ConfigValidationIssue[]`,不要直接拼接最终错误字符串。
共享校验工具包括:
| 函数 | 用途 |
| -------------------------------- | ---------------------------- |
| `validateRawValueExpectation` | 校验 Raw ValueExpectation |
| `validateRawContentExpectations` | 校验 ContentExpectations |
| `validateRawKeyedExpectations` | 校验 KeyedExpectations |
| `validateJsonPath` | 校验项目支持的 JSONPath 子集 |
| `isJsonValue` | 判断合法 JSON value |
## resolve 规范
`resolve()` 只做内置默认值填充、路径解析、单位转换,不执行校验。输入已经通过 Normalized schema 和语义校验expect 已是 normalized 形态。
```typescript
const expect = target.expect as ResolvedXxxExpectConfig | undefined;
const resolvedExpect: ResolvedXxxExpectConfig = expect
? { ...expect, status: expect.status ?? [200] }
: { status: [200] };
```
返回值使用 `satisfies ResolvedXxxTarget` 确保类型正确。
## execute 规范
- 始终记录 `timestamp``start = performance.now()`
- 通过 `ctx.signal` 支持超时取消。
- 首个 expect 失败即停止,返回带 `failure` 的结果。
- 成功时 `failure: null, matched: true`
- 异常时使用 `errorFailure()`
- 不匹配时使用 `mismatchFailure()`
- `expected` 参数应传用户可读值,必要时使用 `displayValueExpectation()`
## expect 字段选择
| 场景 | 模型 |
| ------------------------------------ | ------------------- |
| 状态类结果且集合小而稳定 | enum 或 boolean |
| 单值数字指标或字符串元数据 | ValueMatcher |
| 文本、JSON、HTML、XML 或半结构化内容 | ContentExpectations |
| 动态键值表 | KeyedExpectations |
不要为了统一而把状态类字段改成 ValueMatcher。一个 expect 字段只能对应一种断言模型。
## 注册
1. 创建 `src/server/checker/runner/<type>/index.ts`
2.`src/server/checker/runner/index.ts` 添加导入。
3. 在 registry 初始化数组中添加 checker 实例。
注册后schema builder、validate、config-loader、engine、store 会自动按 registry 分发。
## 测试要求
测试文件放在 `tests/server/checker/runner/<type>/`,结构镜像源文件。
| 测试类别 | 覆盖内容 |
| ------------ | ---------------------------------------- |
| 契约测试 | TypeBox schema 与 JSON Schema 导出一致性 |
| 语义校验测试 | 合法和非法配置 |
| resolve 测试 | 默认值合并、路径解析、单位转换 |
| execute 测试 | 成功、失败、超时、expect 组合 |
| 注册测试 | registry 注册行为 |
| 配置加载测试 | 含新 checker 的 YAML 完整加载流程 |
## 文档和 schema 更新
新增或修改 checker 时通常需要更新:
- `probes.example.yaml`
- `probe-config.schema.json`,通过 `bun run schema` 生成
- `docs/user/checkers/<type>.md`
- `docs/user/checkers/README.md`
- `docs/user/expectations.md`,仅当断言模型或通用规则变化
- `docs/development/checker-development.md`,仅当开发机制变化
- `CONTRIBUTING.md`,仅当贡献流程或 checklist 变化
## 完成检查清单
```text
□ src/server/checker/runner/<type>/types.ts
□ src/server/checker/runner/<type>/schema.ts
□ src/server/checker/runner/<type>/validate.ts
□ src/server/checker/runner/<type>/execute.ts
□ src/server/checker/runner/<type>/expect.ts
□ src/server/checker/runner/<type>/index.ts
□ src/server/checker/runner/index.ts
□ tests/server/checker/runner/<type>/
□ probes.example.yaml
□ probe-config.schema.json
□ docs/user/checkers/<type>.md
□ bun run schema
□ bun run schema:check
□ bun run check
□ bun run verify
```

View File

@@ -0,0 +1,117 @@
# 前端开发
## 技术栈
| 层面 | 技术 | 用途 |
| ------ | ------------------------------------- | ---------------------------------------------- |
| 框架 | React 19 | UI 组件开发 |
| 构建 | Bun HTML import + Vite dev server | 开发服务与生产构建 |
| 语言 | TypeScript 6 | 类型安全 |
| UI 库 | TDesign React + tdesign-icons-react | UI 组件与图标 |
| 数据层 | TanStack Query + React Query Devtools | 服务端状态管理与自动轮询 |
| 图表 | Recharts | 拨测趋势图 |
| 动画 | @number-flow/react | 倒计时数字滚动过渡 |
| 路由 | 无 | 单页面 Dashboard通过 Drawer/Tab 做页面内导航 |
不引入 React Router 或额外状态管理库。TanStack Query 承担服务端状态,组件内状态使用 `useState`
## 组件树与数据流
```text
main.tsx
└── StrictMode
└── ErrorBoundary
└── QueryClientProvider
├── App
│ ├── useThemePreference()
│ ├── useDashboard(refreshInterval)
│ ├── SummaryCards
│ └── TargetBoard
│ └── TargetGroup[]
│ └── PrimaryTable
│ └── TargetDetailDrawer
│ └── useTargetDetail()
│ ├── OverviewTab
│ └── HistoryTab
└── ReactQueryDevtools
```
## TanStack Query 规范
Query key 使用 structured array排序为 scope -> id -> 参数。
```typescript
const queryKeys = {
dashboard: () => ["dashboard", "24h", 30] as const,
meta: () => ["meta"] as const,
metrics: (targetId: number, from: string, to: string, bucket: "auto" | MetricsBucket) =>
["metrics", targetId, from, to, bucket] as const,
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
};
```
全局面板级查询可持续刷新,详情级查询必须按 Drawer 状态和 Tab 状态条件启用。
## fetch 封装
统一使用 `fetch`,不引入 axios。错误抛异常由 TanStack Query 的 `error` 状态承接。
```typescript
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<T>;
}
```
## 组件开发规范
- 每个 React 组件一个 `.tsx` 文件,文件名使用 PascalCase。
- 组件 props 定义为 `interface XxxProps`,紧邻组件函数声明。
- 类型从 `../../shared/api` 导入,使用 `import type`
- 展示组件放在 `components/`,通过 props 接收数据,通过回调返回事件。
- 容器逻辑放在 hooks 中,组件只做数据消费。
- 列定义、排序器、筛选器、颜色阈值等常量放在 `constants/`
- 时间处理等纯函数放在 `utils/`
## 现有组件
| 组件 | 用途 |
| -------------------- | ----------------------------------------------------------------- |
| `App` | 根组件Layout + HeadMenu 骨架、主题模式、刷新控制、Skeleton 加载 |
| `ErrorBoundary` | React 错误边界 |
| `SummaryCards` | 总览统计卡片 |
| `TargetBoard` | 按分组渲染目标表格列表 |
| `TargetGroup` | 单个分组 Card + PrimaryTable |
| `TargetDetailDrawer` | 目标详情抽屉 |
| `OverviewTab` | 目标详情概览 |
| `HistoryTab` | 目标历史记录表格和分页 |
| `TrendChart` | 趋势折线图 |
| `StatusDot` | 圆形状态指示点 |
| `StatusBar` | 最近采样状态条 |
| `RefreshCountdown` | Header 刷新倒计时和手动刷新按钮 |
## 样式规范
前端基于 TDesign React 构建 UI样式开发优先级
1. TDesign 组件
2. TDesign 组件 props
3. TDesign CSS tokens`--td-*`
4. `styles.css` CSS 类
5. 自行开发组件
红线:
- 严禁在组件中使用 `style` 属性内联调整样式。
- 严禁通过 CSS 覆盖 TDesign 组件内部类名。
- 严禁使用 `!important`
- 颜色统一使用 TDesign CSS tokens不使用硬编码色值。
## 前端测试
- 测试目录为 `tests/web/`,结构对应 `src/web/`
- 重点测试 `constants/` 中的纯函数。
- 组件测试使用 jsdom 和 `@testing-library/react`
- 测试用户行为而非实现细节。
- 只 mock 系统边界,例如 `fetch`

View File

@@ -0,0 +1,93 @@
# 测试与质量
## 质量命令
| 命令 | 说明 |
| ---------------------- | -------------------------------------------------------------- |
| `bun run lint` | ESLint 检查含类型感知规则、导入排序、导入验证、Prettier 格式 |
| `bun run format` | Prettier 自动格式化 |
| `bun run schema:check` | 检查 `probe-config.schema.json` 是否与 TypeBox fragments 同步 |
| `bun run typecheck` | TypeScript 类型检查 |
| `bun test` | 运行所有测试 |
| `bun run check` | `schema:check + typecheck + lint + test` |
| `bun run verify` | `check + build` |
## ESLint
配置文件:`eslint.config.js`
| 配置来源 | 用途 |
| -------------------------------------------- | ---------------------------------------- |
| `@eslint/js` recommended | JavaScript 基础规则 |
| `typescript-eslint` recommended-type-checked | TypeScript 类型感知规则 |
| `typescript-eslint` stylistic-type-checked | TypeScript 风格规则 |
| `eslint-plugin-perfectionist` | 导入语句和命名导出排序 |
| `eslint-plugin-import` | 导入路径验证、循环依赖检测、重复导入合并 |
| `eslint-plugin-prettier` | 将 Prettier 格式集成为 ESLint 规则 |
后端运行时代码禁止直接使用 `console.*`,请通过注入的 Logger 实例输出日志。
## Prettier
配置文件:`.prettierrc.json`。显式声明格式化参数,包括 `printWidth: 120``semi: true``singleQuote: false``trailingComma: "all"``endOfLine: "lf"`
## TypeScript 严格标志
| 标志 | 值 | 说明 |
| ------------------------------------ | ----- | ------------------------------- |
| `strict` | true | 全局严格模式 |
| `noUnusedLocals` | true | 未使用局部变量视为错误 |
| `noUnusedParameters` | false | 保留关闭 |
| `noPropertyAccessFromIndexSignature` | true | 索引签名必须用括号访问 |
| `noUncheckedIndexedAccess` | true | 数组和 Map 访问必须运行时检查 |
| `noImplicitOverride` | true | 覆盖父类方法必须显式 `override` |
| `verbatimModuleSyntax` | true | 强制 `import type` 纯类型导入 |
## Git hooks
| Hook | 行为 |
| ------------ | ------------------------------------------ |
| `pre-commit` | lint-staged 对变更文件运行 eslint/prettier |
| `commit-msg` | commitlint 校验提交信息格式 |
提交信息格式为 `类型: 简短描述`,类型限定为 `feat``fix``refactor``docs``style``test``chore`
## 测试分层
| 层级 | 覆盖范围 | 位置 |
| -------- | ---------------------- | ----------------------------------------------------------------------------- |
| 单元测试 | 后端函数、纯函数、常量 | `tests/server/**/*.test.ts``tests/web/{constants,utils,hooks}/**/*.test.ts` |
| 组件测试 | React 组件渲染和交互 | `tests/web/components/**/*.test.tsx` |
## 测试命令
```bash
bun test
bun test tests/server
bun test tests/web
bun run check
bun run verify
```
## 组件测试环境
组件测试使用 jsdom配置位于 `tests/setup.ts`,通过 `bunfig.toml` preload 加载。
包含的 polyfill 和 mock
- ResizeObserver
- IntersectionObserver
- matchMedia
- attachEvent
- recharts 图表 mock
## 编写规范
- 优先使用 `@testing-library/react` 的语义化查询。
- 测试用户行为而非实现细节。
- 只 mock 系统边界。
- 使用真实的 QueryClientProvider 包裹组件。
- 组件测试文件命名为 `tests/web/components/ComponentName.test.tsx`
- 异步错误断言使用 helper 或显式 try/catch避免依赖 Bun `expect(...).rejects``await-thenable` 规则的类型不匹配。
- polyfill 中的 intentional no-op 使用显式可解释写法。
-`process.exit` 等系统 API 使用 `spyOn` 受控 mock。

View File

@@ -7,7 +7,6 @@
| 文件 | 用途 | | 文件 | 用途 |
| ------------------------------------------------------ | ------------------------------------------------------------------------ | | ------------------------------------------------------ | ------------------------------------------------------------------------ |
| [prompt-smart-merge.md](prompt-smart-merge.md) | 批量合并 `dev*` 分支到目标分支,含规则探测、依赖分析、冲突处理、安全回退 | | [prompt-smart-merge.md](prompt-smart-merge.md) | 批量合并 `dev*` 分支到目标分支,含规则探测、依赖分析、冲突处理、安全回退 |
| [prompt-spec-review.md](prompt-spec-review.md) | 审查和整理 `openspec/specs/` 下的稳定规范,提升可检索性和一致性 |
| [prompt-proposal-review.md](prompt-proposal-review.md) | 审查 proposal/design/tasks/specs 与讨论、代码现状、OpenSpec 规范的一致性 | | [prompt-proposal-review.md](prompt-proposal-review.md) | 审查 proposal/design/tasks/specs 与讨论、代码现状、OpenSpec 规范的一致性 |
| [prompt-apply-review.md](prompt-apply-review.md) | 审查 apply 后代码、测试、变更文档的一致性,并补齐遗漏或回写文档 | | [prompt-apply-review.md](prompt-apply-review.md) | 审查 apply 后代码、测试、变更文档的一致性,并补齐遗漏或回写文档 |
@@ -85,7 +84,7 @@
- 是否以代码、文档、讨论或用户确认为准 - 是否以代码、文档、讨论或用户确认为准
- 何时必须使用提问工具确认 - 何时必须使用提问工具确认
- 删除、重写前是否必须备份 - 删除、重写前是否必须备份
- 改动后是否必须同步 README、测试、变更文档 - 改动后是否必须同步相关用户文档、开发文档、测试、变更文档
### 4. 计划与执行分离 ### 4. 计划与执行分离
@@ -124,7 +123,7 @@
- 作用域边界:改什么,不改什么 - 作用域边界:改什么,不改什么
- 真相来源优先级:代码 / README / spec / 讨论 / 用户确认 - 真相来源优先级:代码 / README / spec / 讨论 / 用户确认
- 风险动作边界删除、重写、提交、推送、回退、stash、merge 等 - 风险动作边界删除、重写、提交、推送、回退、stash、merge 等
- 同步要求:测试、README、变更文档、现有 spec 是否要同步 - 同步要求:测试、用户文档、开发文档、变更文档、现有 spec 是否要同步
- 降级规则:信息不足时如何处理 - 降级规则:信息不足时如何处理
避免: 避免:
@@ -142,7 +141,7 @@
推荐做法: 推荐做法:
- 先读仓库规则来源,如 `README.md`、配置、架构文档、近期提交、任务入口 - 先读仓库规则来源,如 `README.md``DEVELOPMENT.md``CONTRIBUTING.md``docs/README.md`配置、架构文档、近期提交、任务入口
- 先读直接相关 artifacts再扩展到相关代码和测试 - 先读直接相关 artifacts再扩展到相关代码和测试
- 需要探测时,要求 AI 先探测再决定,不把仓库结构写死在提示词里 - 需要探测时,要求 AI 先探测再决定,不把仓库结构写死在提示词里

View File

@@ -1,143 +0,0 @@
请审查并整理 `openspec/specs/` 下的稳定规范,使其成为可搜索、边界清晰、无冗余、与当前业务一致的能力索引,按以下流程执行。
## 约束
- `openspec/specs/` 描述长期稳定的业务能力、规则和外部行为,不记录变更过程、迁移说明、实现路径、内部类型名、组件 props、样式数值、层级分层等实现细节
- 用户可感知或对外暴露的契约可以保留:公开 API 路径、请求/响应字段、协议名、错误码、数据约束、交互结果
- `Requirement``Scenario` 应描述业务能力、外部行为或稳定约束,不以“使用某层/某组件/某库实现”作为标题或核心表述
- 不把当前代码自动视为唯一真相若代码、README、现有 spec 冲突且无法判断应以哪边为准,列入待确认清单,不直接改写规范
- 仅删除内容已被其他规范完整覆盖且无独立检索价值的规范;非冗余内容仅迁移、合并、拆分或重命名
- 每批重构执行前用提问工具获得用户确认;删除或重写前先备份原文件为 `{file}.bak.{timestamp}`
- 命名、Purpose、Requirement 标题都必须保留用户下一次最可能搜索的业务关键词
- 使用subagents处理计算密集或多步骤的并行任务如代码实现、测试执行文件读取直接使用Read工具并行调用禁止用subagent转发文件内容
## 1. 收集
读取:
- `openspec/config.yaml`
- `README.md`以及与模块结构、API、架构相关的 README 或文档
- `openspec/specs/*/spec.md`
默认不读取 `openspec/changes/**`、历史 proposal/design/tasks 作为稳定规范整理依据;仅在用户明确要求“连同历史变更一起校对”时再纳入。
先建立索引,不直接开始改写:
| 索引 | 内容 |
| -------------- | ----------------------------------------------------------------------------- |
| `spec_index[]` | 每个 spec 的目录名、Purpose、Requirement 摘要、关键词、外部契约、疑似重叠对象 |
| `domain_map[]` | 从 README、API、模块文档中提炼的核心业务域、横切能力和术语 |
| `term_map[]` | 同义词、旧名、缩写和推荐标准术语 |
| `suspects[]` | 需要进一步对照代码或测试确认的 spec |
仅对 `suspects[]` 做定向读取:
- 读取与该 spec 对应的源码、测试、README 或架构文档
- 不对 `backend/``frontend/` 做无差别逐文件扫描
判定依据优先级:
- 当前稳定 spec 与 README 共同支持的事实,可直接视为高置信度
- 仅代码可见但 README 和 spec 未体现的内容,先判断它是稳定外部行为还是临时实现细节
- 代码、README、现有 spec 互相冲突且无法自动定夺时,进入 `待确认清单`
## 2. 审查
按 spec、Requirement、Scenario 三层检查:
| 维度 | 检查点 |
| --------- | --------------------------------------------------------------------------------- |
| 过时 | 描述的能力、术语、外部契约是否仍成立;是否存在 `TBD``TODO`、占位说明 |
| 冲突 | 不同规范是否对同一行为给出不同约束、命名或边界 |
| 重复/重叠 | 是否在文件级、Requirement 级、Scenario 级重复描述同一能力 |
| 错位 | 内容是否放错能力域;横切规则是否混入实体规范;平台实现是否混入通用能力规范 |
| 粒度 | 是否过大导致难检索,或过碎导致回答一个问题必须同时打开多个 spec |
| 术语 | 同一概念是否混用多个名字;旧名、别名、缩写是否需要归一并保留检索入口 |
| 命名/检索 | 目录名、Purpose、Requirement 标题是否准确;是否能被 README、API、业务术语直接命中 |
| 规范性 | 是否使用 SHALL/WHEN/THEN是否混入变更记录、迁移说明、内部实现或 UI/代码细节 |
| 完整性 | Purpose 是否明确;是否存在空目录、非 spec 噪音文件、无清晰归属的孤立规范 |
重构判定规则:
- 若两个 spec 回答的是同一个核心问题,或其中一个只是另一个的子集,优先合并
- 若一个 spec 混合多个独立检索意图,或同时包含横切规则与业务流程,优先拆分
- 若内容正确但目录名、Purpose 或 Requirement 标题不利于检索,优先重命名或改写标题
- 若多个术语指向同一概念,统一到一个标准术语,并在 Purpose 或 Requirement 中保留必要别名以支持搜索
- 若某段内容只是内部实现细节,且不影响外部行为理解,删除该段而不是为其单独保留 spec
- 若某个具体值同时属于外部契约与内部实现,按“是否对调用方可见、是否影响兼容性”判断是否保留
### 命名约定
命名优先复用仓库已存在的稳定术语,如 `provider``model``stats``protocol``proxy``logging``validation``migration``frontend``desktop``mysql`
| 类型 | 模式 | 示例 |
| ------------ | ---------------------------------------------------------- | -------------------------------------------------- |
| 实体生命周期 | `{entity}-management` | `provider-management``model-management` |
| 横切能力 | `{concern}``{concern}-{qualifier}` | `error-handling``structured-logging` |
| 协议/适配 | `{protocol}-{capability}``protocol-adapter-{protocol}` | `openai-protocol-proxy``protocol-adapter-openai` |
| 运行面/入口 | `{surface}``{surface}-{capability}` | `frontend``desktop-app` |
| 基础设施 | `{resource}-{operation}` | `database-migration``mysql-driver` |
命名原则:
- 1-4 个词,保持单一主题
- 优先使用业务名词,不使用 `basic``general``misc``info``data` 等泛化词
- 不使用 `crud``list``table``display` 等实现模式词,除非它本身就是外部契约的一部分
- 同一主题的命名模式保持一致,不同时混用多套前后缀
## 3. 报告
输出分析结果:
1. **问题总览表**:问题类型 × 涉及规范数
2. **规范关系表**:每个 spec 的主主题、重叠对象、冲突对象、建议动作
3. **术语归一表**:旧术语 / 别名 / 缩写 → 推荐标准术语
4. **逐项分析**:每个有问题的规范说明位置、问题、影响、建议和目标规范
5. **待确认清单**代码、README、现有 spec 冲突且无法自动定夺的事项
6. **重构方案**:按优先级分批
7. **重构后目录结构**:预期的新 `openspec/specs/` 目录树
优先级建议:
- P0删除空目录、非 spec 噪音文件、占位内容
- P1删除完全冗余规范将其内容映射到主规范
- P2合并重复/子集规范;拆分错位或过大规范
- P3重命名目录、改写 Purpose 和 Requirement 标题以提升检索性
- P4修正过时描述清理实现细节、迁移说明和变更记录
若所有问题清单为空,输出“审查通过,未发现问题”,跳至步骤 5。
## 4. 计划(用户确认)
先针对 `待确认清单` 用提问工具逐项向用户确认。
再按批次展示完整重构计划,每批必须包含:
- 操作类型:删除、重命名、迁移、合并、拆分、改写
- 路径变化:源路径 → 目标路径
- 内容映射:源 spec 的 Requirement / Scenario 将迁移到哪里
- 术语处理:哪些旧词保留为检索入口,哪些词统一替换
- 执行原因:为什么这样做更利于检索、去重和边界清晰
- 验证方式:如何确认没有丢失约束或引入新的冲突
用提问工具获得当前批次确认后再执行。
## 5. 执行
按 P0 → P4 逐批执行已确认的重构。
执行要求:
- 合并或拆分时先写目标 spec再删除或重命名源 spec
- 删除前确认其 Requirement 和 Scenario 已被完整保留、迁移或判定为纯冗余
- 每批执行后重新读取受影响的 spec并复核结构和内容
每批执行后至少验证:
- 目录结构完整,`openspec/specs/*/spec.md` 可正常读取
- 不存在未承接的 Requirement 或 Scenario
- Purpose、Requirement 标题、目录名可以直接表达主能力
- 不再包含 `TBD`、变更记录、迁移说明、内部实现细节或噪音文件
- 若本批涉及代码对照项,相关外部契约描述与当前仓库现状一致,或已列入残留待确认
收尾时输出:修改文件清单、备份文件清单、最终目录树、残留待确认事项和整理摘要。

View File

@@ -0,0 +1,37 @@
# Checker 参考
Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一个 checker并配置对应的专属字段和 `expect` 规则。
## 支持的类型
| 类型 | 用途 | 文档 |
| ------ | -------------------------------------- | --------------- |
| `http` | HTTP/HTTPS 应用层健康检查 | [HTTP](http.md) |
| `cmd` | 执行本地命令或脚本 | [Cmd](cmd.md) |
| `db` | PostgreSQL/MySQL/SQLite 连接和查询检查 | [DB](db.md) |
| `tcp` | TCP 端口可达性和 banner 探测 | [TCP](tcp.md) |
| `udp` | UDP payload 请求-响应检查 | [UDP](udp.md) |
| `icmp` | 基于系统 `ping` 的存活、延迟、丢包检查 | [ICMP](icmp.md) |
| `dns` | 本机解析或指定 DNS server 协议级检查 | [DNS](dns.md) |
| `llm` | 大模型服务应用层健康检查 | [LLM](llm.md) |
## 选择建议
| 目标 | 推荐 checker |
| ---------------------------------- | ------------ |
| Web API、网页、HTTP 状态码或响应体 | `http` |
| 本机脚本、外部命令、CLI 工具 | `cmd` |
| 数据库连接或查询结果 | `db` |
| 端口是否可连接、服务 banner | `tcp` |
| UDP 服务响应或简单心跳 | `udp` |
| 主机可达性、延迟、丢包率 | `icmp` |
| 域名解析值、DNS RCODE、TTL、flags | `dns` |
| LLM API 是否可用、输出是否符合预期 | `llm` |
## 通用字段
所有 checker 都共享 target 通用字段,见 [配置文件](../configuration.md#targets-通用字段)。
## 通用断言模型
各 checker 的 `expect` 字段复用 `ValueMatcher``ContentExpectations``KeyedExpectations`。详情见 [校验规则](../expectations.md)。

38
docs/user/checkers/cmd.md Normal file
View File

@@ -0,0 +1,38 @@
# Cmd Checker
`type: cmd` 用于执行本地命令或脚本并校验退出码、stdout、stderr 和耗时。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ---------- | ------------------------------------ | ---- | ------ |
| `cmd.exec` | 可执行文件名或路径 | 是 | 无 |
| `cmd.args` | 命令行参数列表 | 否 | `[]` |
| `cmd.env` | 环境变量覆盖,继承进程环境变量并合并 | 否 | 无 |
| `cmd.cwd` | 工作目录,相对于配置文件所在目录 | 否 | 无 |
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | --------------------------------------------- | ---- | ------ |
| `exitCode` | 可接受的退出码列表 | 否 | `[0]` |
| `stdout` | 标准输出校验,使用 `ContentExpectations` 数组 | 否 | 无 |
| `stderr` | 标准错误校验,使用 `ContentExpectations` 数组 | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "bun-script"
name: "Bun 脚本检查"
type: cmd
cmd:
exec: "bun"
args: ["-e", "console.log('ok')"]
expect:
exitCode: [0]
stdout:
- contains: "ok"
```
Docker 官方镜像不内置常见外部命令。容器内使用 CMD checker 时,按需通过派生镜像安装依赖命令。

38
docs/user/checkers/db.md Normal file
View File

@@ -0,0 +1,38 @@
# DB Checker
`type: db` 用于数据库连接和查询结果检查,支持 PostgreSQL、MySQL 和 SQLite。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ---------- | ------------------------------------------------------------- | ---- | ------ |
| `db.url` | 数据库连接字符串,支持 `postgres://``mysql://``sqlite://` | 是 | 无 |
| `db.query` | SQL 查询语句,不配置时仅测试连接 | 否 | 无 |
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | ----------------------------------------------------------------------- | ---- | ------ |
| `rowCount` | 查询返回行数校验,使用 `ValueMatcher` | 否 | 无 |
| `rows` | 查询结果逐行校验,数组内每行为列名到 `KeyedExpectations` 的映射 | 否 | 无 |
| `result` | 完整查询结果 `{ rows, rowCount }` 校验,使用 `ContentExpectations` 数组 | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "sqlite-query"
name: "SQLite 数据库检查"
type: db
db:
url: "sqlite:///path/to/db.sqlite"
query: "SELECT COUNT(*) as cnt FROM users WHERE status = 'active'"
expect:
durationMs:
lte: 5000
rowCount:
gte: 1
rows:
- cnt:
gte: 0
```

104
docs/user/checkers/dns.md Normal file
View File

@@ -0,0 +1,104 @@
# DNS Checker
`type: dns` 支持两种解析模式:本机解析器检查和指定 DNS server 协议级检查。
## resolver 模式
| 模式 | 说明 |
| -------- | ----------------------------------------------------------------------------- |
| `system` | 使用本机 DNS 解析器检查域名是否能解析到预期地址 |
| `server` | 直接向指定 DNS server 发起 UDP/TCP 深度拨测,检查 RCODE、TTL、flags、记录值等 |
## `dns.resolver: system` 配置项
| 字段 | 说明 | 必填 | 默认值 |
| -------------- | ----------------------------- | ---- | -------- |
| `dns.resolver` | 解析模式 | 是 | `system` |
| `dns.name` | 待解析域名 | 是 | 无 |
| `dns.family` | 地址族:`any``ipv4``ipv6` | 否 | `any` |
### system 模式 expect
| 字段 | 说明 | 断言模型 |
| ------------ | -------------------- | --------------------------------- |
| `values` | 解析结果地址集合断言 | DNS 集合include/exclude/exact |
| `valueCount` | 解析结果数量 | ValueMatcher |
| `durationMs` | 解析耗时 | ValueMatcher |
```yaml
- id: "dns-system-api"
name: "本机 DNS 解析"
type: dns
dns:
resolver: system
name: "api.example.com"
family: any
expect:
values:
exact:
- "203.0.113.10"
durationMs:
lte: 500
```
## `dns.resolver: server` 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ---------------------- | --------------------------------- | ---- | -------- |
| `dns.resolver` | 解析模式 | 是 | `server` |
| `dns.server` | DNS server 地址 | 是 | 无 |
| `dns.name` | 查询域名 | 是 | 无 |
| `dns.port` | DNS server 端口 | 否 | `53` |
| `dns.protocol` | 传输协议:`udp``tcp` | 否 | `udp` |
| `dns.recordType` | DNS 记录类型 | 否 | `A` |
| `dns.recursionDesired` | 是否设置 RD flag | 否 | `true` |
| `dns.tcpFallback` | UDP 响应 TC=1 时是否 TCP fallback | 否 | `true` |
| `dns.maxResponseBytes` | 响应最大字节数 | 否 | `4KB` |
`recordType` 可选值:`A``AAAA``CNAME``NS``MX``TXT``SOA``SRV``CAA``PTR`
### server 模式 expect
| 字段 | 说明 | 断言模型 |
| -------------------- | ---------------------------------- | --------------------------------- |
| `responded` | 是否收到 DNS response | boolean |
| `rcode` | 期望 RCODE 列表,如 `NOERROR` | string[] |
| `values` | 目标类型记录值集合断言 | DNS 集合include/exclude/exact |
| `valueCount` | 目标类型记录数量 | ValueMatcher |
| `answerCount` | answer section 总记录数 | ValueMatcher |
| `ttlMin` | answer 中最小 TTL | ValueMatcher |
| `ttlMax` | answer 中最大 TTL | ValueMatcher |
| `authoritative` | AA flag | boolean |
| `recursionAvailable` | RA flag | boolean |
| `truncated` | TC flag | boolean |
| `authenticatedData` | AD flag | boolean |
| `result` | 完整结构化响应的 JSONPath 兜底断言 | ContentExpectations |
| `durationMs` | 完整查询耗时 | ValueMatcher |
```yaml
- id: "dns-server-api"
name: "Cloudflare DNS A 记录"
type: dns
dns:
resolver: server
server: "1.1.1.1"
name: "api.example.com"
recordType: A
expect:
rcode: ["NOERROR"]
values:
include:
- "203.0.113.10"
ttlMin:
gte: 60
durationMs:
lte: 200
```
## 注意事项
- 未配置 expect 时,`system` 模式默认要求解析成功且 `valueCount > 0``server` 模式默认要求 `NOERROR + valueCount > 0`
- 显式配置非 `NOERROR` rcode`NXDOMAIN`)时,不自动要求 `valueCount > 0`
- `values.exact` 忽略返回顺序。
- 对 A/AAAA 查询CNAME 链不计入 `values`,单独放入 `cnameChain`
- `values` 按记录类型规范化为字符串,例如 MX 为 `"10 mail.example.com"`SRV 为 `"10 60 443 server.example.com"`

View File

@@ -0,0 +1,48 @@
# HTTP Checker
`type: http` 用于 HTTP/HTTPS 应用层健康检查。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ------------------- | ------------------- | ---- | ------- |
| `http.url` | 目标 URL | 是 | 无 |
| `http.method` | HTTP 方法 | 否 | `GET` |
| `http.headers` | 请求头 | 否 | 无 |
| `http.body` | 请求体 | 否 | 无 |
| `http.ignoreSSL` | 忽略 HTTPS 证书校验 | 否 | `false` |
| `http.maxRedirects` | 最大重定向跟随次数 | 否 | `0` |
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | -------------------------------------------------- | ---- | ------- |
| `status` | 可接受的状态码列表,支持精确码和范围(如 `"2xx"` | 否 | `[200]` |
| `headers` | 响应头校验,使用 `KeyedExpectations` | 否 | 无 |
| `body` | 响应体校验,使用 `ContentExpectations` 数组 | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "json-api"
name: "JSON API 示例"
type: http
http:
url: "https://httpbin.org/json"
headers:
Authorization: "Bearer token"
expect:
status: [200]
headers:
Content-Type:
contains: "application/json"
body:
- json:
path: "$.slideshow.title"
equals: "Sample Slide Show"
durationMs:
lte: 10000
```
HTTP checker 的 `durationMs` 覆盖完整执行,包括重定向、按需响应体读取、解码和 expect 校验。未配置 body expectation、status 失败或 headers 失败时不会读取 body。

View File

@@ -0,0 +1,45 @@
# ICMP Checker
`type: icmp` 使用系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS 和 Windows 输出解析。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ----------------- | ------------------------- | ---- | ------ |
| `icmp.host` | 目标主机地址 | 是 | 无 |
| `icmp.count` | ICMP 包数量,范围 `1-100` | 否 | `3` |
| `icmp.packetSize` | ICMP 包大小bytes | 否 | `56` |
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| ------------------- | --------------------------------------------------- | ---- | ------ |
| `alive` | 期望主机可达性 | 否 | `true` |
| `packetLossPercent` | 丢包率百分比校验,范围 `0-100`,使用 `ValueMatcher` | 否 | 无 |
| `avgLatencyMs` | 平均延迟校验,使用 `ValueMatcher` | 否 | 无 |
| `maxLatencyMs` | 最大单次延迟校验,使用 `ValueMatcher` | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "gateway-icmp"
name: "网关 ICMP 可达"
type: icmp
icmp:
host: "10.0.0.1"
count: 3
packetSize: 56
expect:
alive: true
packetLossPercent:
lte: 10
avgLatencyMs:
lte: 100
maxLatencyMs:
lte: 300
durationMs:
lte: 5000
```
容器中运行 ICMP checker 通常需要 `--cap-add=NET_RAW`,详情见 [部署文档](../deployment.md#icmp-权限)。

53
docs/user/checkers/llm.md Normal file
View File

@@ -0,0 +1,53 @@
# LLM Checker
`type: llm` 用于大模型服务应用层健康检查。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| --------------------- | ----------------------------------------------------- | ---- | ------- |
| `llm.provider` | 模型提供方:`openai``openai-responses``anthropic` | 是 | 无 |
| `llm.url` | API base URL | 是 | 无 |
| `llm.model` | 模型名称 | 是 | 无 |
| `llm.prompt` | 单轮 prompt | 是 | 无 |
| `llm.mode` | 调用模式:`http``stream` | 否 | `http` |
| `llm.key` | API key支持 `${VAR}` 变量替换 | 否 | `""` |
| `llm.authToken` | Bearer token`anthropic` provider`key` 互斥 | 否 | 无 |
| `llm.headers` | 附加请求头 | 否 | 无 |
| `llm.ignoreSSL` | 忽略 HTTPS 证书校验 | 否 | `false` |
| `llm.options` | 生成选项 | 否 | 无 |
| `llm.providerOptions` | Provider 专属选项 | 否 | 无 |
`llm.options` 支持 `maxOutputTokens`(默认 `16`)、`temperature`(默认 `0`)、`topP``topK``presencePenalty``frequencyPenalty``stopSequences``seed`
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| ----------------- | --------------------------------------------------------------------------- | ---- | ------- |
| `status` | 可接受的状态码列表,支持精确码和范围(如 `"2xx"` | 否 | `[200]` |
| `headers` | 响应头校验,使用 `KeyedExpectations` | 否 | 无 |
| `output` | 模型输出校验,使用 `ContentExpectations` 数组 | 否 | 无 |
| `finishReason` | finish reason 校验,使用 `ValueMatcher` | 否 | 无 |
| `rawFinishReason` | 原始 finish reason 校验,使用 `ValueMatcher` | 否 | 无 |
| `usage` | Token usage 校验,支持 `inputTokens``outputTokens``totalTokens` matcher | 否 | 无 |
| `stream` | 流式断言,支持 `completed``firstTokenMs` matcher`mode: stream` | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "llm-openai-probe"
name: "OpenAI 健康检查"
type: llm
llm:
provider: openai
url: "https://api.openai.com/v1"
model: "gpt-4o-mini"
prompt: "Say OK"
key: "${OPENAI_API_KEY}"
expect:
status: [200]
finishReason: "stop"
output:
- contains: "OK"
```

35
docs/user/checkers/tcp.md Normal file
View File

@@ -0,0 +1,35 @@
# TCP Checker
`type: tcp` 用于 TCP 端口可达性和可选 banner 探测。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ----------------------- | --------------------------------------------- | ---- | ------- |
| `tcp.host` | 目标主机地址 | 是 | 无 |
| `tcp.port` | 目标端口,范围 `1-65535` | 是 | 无 |
| `tcp.readBanner` | 是否读取服务端 banner | 否 | `false` |
| `tcp.bannerReadTimeout` | banner 读取超时,毫秒 | 否 | `2000` |
| `tcp.maxBannerBytes` | banner 最大字节数,支持 `KB``MB``GB` 单位 | 否 | `4KB` |
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | ------------------------------------------------------------------------- | ---- | ------ |
| `connected` | 期望连接结果,`true` 可达或 `false` 期望不可达 | 否 | `true` |
| `banner` | Banner 内容校验,使用 `ContentExpectations` 数组,需开启 `tcp.readBanner` | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "redis-port"
name: "Redis 端口可达"
type: tcp
tcp:
host: "127.0.0.1"
port: 6379
expect:
durationMs:
lte: 3000
```

43
docs/user/checkers/udp.md Normal file
View File

@@ -0,0 +1,43 @@
# UDP Checker
`type: udp` 用于 UDP payload 请求-响应检查。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ---------------------- | ------------------------------------------ | ---- | ------ |
| `udp.host` | 目标主机地址 | 是 | 无 |
| `udp.port` | 目标端口,范围 `1-65535` | 是 | 无 |
| `udp.payload` | 发送数据 | 否 | `""` |
| `udp.encoding` | payload 编码:`text``hex``base64` | 否 | `text` |
| `udp.responseEncoding` | 响应解码:`text``hex``base64` | 否 | `text` |
| `udp.maxResponseBytes` | 响应最大字节数,支持 `KB``MB``GB` 单位 | 否 | `4KB` |
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| -------------- | --------------------------------------------- | ---- | ------ |
| `responded` | 期望是否收到响应 | 否 | `true` |
| `response` | 响应内容校验,使用 `ContentExpectations` 数组 | 否 | 无 |
| `responseSize` | 响应字节数校验,使用 `ValueMatcher` | 否 | 无 |
| `sourceHost` | 响应来源地址校验,使用 `ValueMatcher` | 否 | 无 |
| `sourcePort` | 响应来源端口校验,使用 `ValueMatcher` | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "udp-heartbeat"
name: "UDP 心跳检测"
type: udp
udp:
host: "127.0.0.1"
port: 9000
payload: "PING"
expect:
responded: true
response:
- contains: "PONG"
durationMs:
lte: 100
```

135
docs/user/configuration.md Normal file
View File

@@ -0,0 +1,135 @@
# 配置文件
DiAL 通过 YAML 配置文件定义运行参数和拨测目标。完整可运行示例参见 [`../../probes.example.yaml`](../../probes.example.yaml)。配置 JSON Schema 位于 [`../../probe-config.schema.json`](../../probe-config.schema.json)。
## 配置结构
```yaml
# yaml-language-server: $schema=./probe-config.schema.json
server:
listen:
host: "127.0.0.1"
port: "${server_port}"
storage:
dataDir: "/tmp/probes_data"
retention: "${retention}"
logging:
level: "${log_level|info}"
file:
path: "<dataDir>/logs/dial.log"
probes:
execution:
maxConcurrentChecks: "${max_checks}"
variables:
server_port: 3000
retention: "7d"
max_checks: 20
default_interval: "30s"
default_timeout: "10s"
targets:
- id: "baidu-home"
name: "Baidu"
type: http
interval: "${default_interval}"
timeout: "${default_timeout}"
http:
url: "https://www.baidu.com"
expect:
status: [200]
```
## server.listen
| 字段 | 说明 | 必填 | 默认值 |
| ------ | -------- | ---- | ----------- |
| `host` | 监听地址 | 否 | `127.0.0.1` |
| `port` | 监听端口 | 否 | `3000` |
## server.storage
| 字段 | 说明 | 必填 | 默认值 |
| ----------- | ---------------------------------------------------- | ---- | -------- |
| `dataDir` | 数据目录,相对路径基于配置文件所在目录解析 | 否 | `./data` |
| `retention` | 历史数据保留时长,支持 `ms``s``m``h``d` 单位 | 否 | `7d` |
## probes.execution
| 字段 | 说明 | 必填 | 默认值 |
| --------------------- | -------------- | ---- | ------ |
| `maxConcurrentChecks` | 最大并发拨测数 | 否 | `20` |
## server.logging
| 字段 | 说明 | 必填 | 默认值 |
| ---------------------------------------- | ---------------------------------------------- | ---- | ------------------------- |
| `server.logging.level` | 全局日志等级console 和 file 未指定时继承此值 | 否 | `info` |
| `server.logging.console.level` | 控制台日志等级 | 否 | 继承 `level` |
| `server.logging.file.level` | 文件日志等级 | 否 | 继承 `level` |
| `server.logging.file.path` | 日志文件路径,相对路径基于配置文件目录解析 | 否 | `<dataDir>/logs/dial.log` |
| `server.logging.file.rotation.size` | 按大小滚动,支持 `KB``MB``GB` 单位 | 否 | `50MB` |
| `server.logging.file.rotation.frequency` | 按时间滚动:`hourly``daily``weekly` | 否 | `daily` |
| `server.logging.file.rotation.maxFiles` | 保留的归档文件数量,不含活跃日志 | 否 | `14` |
日志等级支持:`trace``debug``info``warn``error``fatal`
控制台始终输出 pretty 格式,文件始终输出 JSONL 格式并支持滚动。`rotation.size``rotation.frequency` 任一条件触发即滚动。
## 内置默认值
| 字段 | 默认值 |
| ---------- | ------ |
| `interval` | `30s` |
| `timeout` | `10s` |
各 checker 专属默认值见 [Checker 参考](checkers/README.md)。
## variables
`variables` 是顶层动态键值表key 必须符合 `[a-zA-Z_][a-zA-Z0-9_]*`value 仅支持 string、number、boolean。`server``probes``targets` 中的字符串值可引用变量。
| 语法 | 说明 |
| --------- | ------------------------- | ------------------------------------------ |
| `${key}` | 引用 variables 或环境变量 |
| `${key | default}` | variables 和环境变量都不存在时使用默认值 |
| `${key | }` | variables 和环境变量都不存在时使用空字符串 |
| `$${key}` | 转义输出字面量 `${key}` |
解析优先级为 `variables -> process.env -> 默认值`。三者均不存在时配置校验失败。字段值完整等于单个变量引用时会保留 number、boolean、string 类型;部分拼接时统一转为字符串。
变量替换作用于 `server``probes``targets`,不作用于 `variables` 段自身,且不会替换 `targets[].id``targets[].type` 字段;对象 key 不参与替换。
## 配置加载形态
配置加载内部区分三层形态:
| 形态 | 说明 |
| ----------------- | ------------------------------------------------------------------------------------- |
| Authoring Config | 用户 YAML 可书写形态,允许变量引用和 expect 简写 |
| Normalized Config | `normalizeAuthoringConfig()` 完成变量替换、expect 简写展开并移除 `variables` 后的形态 |
| ResolvedConfig | checker `resolve()` 补默认值并解析 duration、size、路径和运行期环境后的形态 |
根目录 `probe-config.schema.json` 面向 Authoring Config因此 VSCode 校验会接受 `server.listen.port: "${server_port|3000}"``http.maxRedirects: "${MAX|5}"``expect.durationMs: 5000` 这类写法。
## targets 通用字段
| 字段 | 说明 | 必填 | 默认值 |
| ------------- | ------------------------------------------------------------------------------------ | ---- | --------- |
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 | 无 |
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null前端展示时 null 回退到 `id` | 否 | 无 |
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null允许空字符串 | 否 | 无 |
| `type` | 目标类型:`http``cmd``db``tcp``udp``dns``icmp``llm` | 是 | 无 |
| `group` | 分组名称 | 否 | `default` |
| `interval` | 拨测间隔 | 否 | `30s` |
| `timeout` | 超时时间 | 否 | `10s` |
## Checker 专属配置
每个 target 必须根据 `type` 配置对应的 checker 专属字段。详情见 [Checker 参考](checkers/README.md)。
## 校验规则
`expect` 字段按 checker 类型不同而变化。通用断言模型见 [校验规则](expectations.md)。

109
docs/user/deployment.md Normal file
View File

@@ -0,0 +1,109 @@
# 部署
本文档说明如何构建、运行、容器化和发布 DiAL。开发环境运行见 [README 快速开始](../../README.md#快速开始)。
## 生产构建和运行
```bash
bun run build
./dist/dial-server ./probes.yaml
```
构建产物为独立可执行文件,只需要一个 YAML 配置文件即可运行。
启动后:
| 地址 | 行为 |
| ------------------------------ | ------------------ |
| `http://127.0.0.1:3000/` | 返回前端 Dashboard |
| `http://127.0.0.1:3000/api/*` | 返回后端 API |
| `http://127.0.0.1:3000/health` | 返回健康检查 |
## Docker 部署
DiAL 提供基于 Alpine 的多阶段镜像。构建阶段使用 Bun 生成 musl 目标单可执行文件,运行阶段只包含 `dial-server`、基础证书、`ping`、Bun musl executable 必需运行库、时区数据和容器运行目录。
```bash
docker build -t dial:alpine .
docker run --rm -p 3000:3000 -v dial-data:/data/dial dial:alpine
```
容器默认读取 `/etc/dial/probes.yaml`,推荐将数据卷挂载到 `/data/dial`
使用自定义配置文件:
```bash
docker run --rm -p 3000:3000 \
-v "$PWD/docker/probes.yaml:/etc/dial/probes.yaml:ro" \
-v dial-data:/data/dial \
dial:alpine
```
容器专用示例配置位于 [`../../docker/probes.yaml`](../../docker/probes.yaml),默认监听 `0.0.0.0:3000`,并将 SQLite 数据和日志写入 `/data/dial`
## ICMP 权限
如需在容器中运行 ICMP checker除镜像内置 `iputils-ping` 外,还需要授予 `NET_RAW` capability
```bash
docker run --rm --cap-add=NET_RAW -p 3000:3000 -v dial-data:/data/dial dial:alpine
```
## CMD checker 额外命令
官方镜像不内置 `bun``node``curl``dig``psql``mysql``redis-cli` 等 CMD checker 可能需要的额外命令。需要这些命令时请使用派生镜像自行安装:
```dockerfile
FROM dial:alpine
USER root
RUN apk add --no-cache curl bind-tools postgresql-client
USER dial
```
## 多架构镜像
```bash
docker buildx build --platform linux/amd64,linux/arm64 -t dial:alpine .
```
Dockerfile 通过 Docker 提供的 `TARGETARCH` 选择 Bun compile target。
| `TARGETARCH` | `BUN_TARGET` |
| ------------ | ---------------------- |
| `amd64` | `bun-linux-x64-musl` |
| `arm64` | `bun-linux-arm64-musl` |
## 跨平台发布包
```bash
bun run release
bun run release --target linux-x64
bun run release --target linux-x64,windows-x64,darwin-arm64
```
支持的目标平台:
| CLI 参数 | Bun CompileTarget |
| ------------------ | ---------------------- |
| `linux-x64` | `bun-linux-x64` |
| `linux-arm64` | `bun-linux-arm64` |
| `linux-x64-musl` | `bun-linux-x64-musl` |
| `linux-arm64-musl` | `bun-linux-arm64-musl` |
| `windows-x64` | `bun-windows-x64` |
| `darwin-x64` | `bun-darwin-x64` |
| `darwin-arm64` | `bun-darwin-arm64` |
产出物结构:
```text
dist/release/
├── binaries/
│ ├── dial-server-0.1.0-linux-x64
│ └── dial-server-0.1.0-windows-x64.exe
└── packages/
├── dial-server_0.1.0_linux_x64.tar.gz
└── dial-server_0.1.0_linux_x64.tar.gz.sha256
```
压缩包内含可执行文件、`probes.example.yaml``LICENSE`,解压后可直接使用。

104
docs/user/expectations.md Normal file
View File

@@ -0,0 +1,104 @@
# 校验规则
`expect` 描述拨测结果必须满足的条件。不同 checker 暴露不同字段,但共享三类基础断言模型:`ValueMatcher``ContentExpectations``KeyedExpectations`
## ContentExpectations
`body``stdout``stderr``banner``response``output``result` 等返回内容字段均使用数组。
| 规则 | 说明 |
| ---------- | ------------------------------------------------------ |
| `contains` | 内容包含指定文本 |
| `regex` | 正则匹配,启动期会拒绝存在 ReDoS 风险的模式 |
| `json` | JSONPath 提取值比较,`path` 必填 |
| `css` | CSS 选择器提取 HTML 元素,`selector` 必填,`attr` 可选 |
| `xpath` | XPath 提取 XML/HTML 节点,`path` 必填 |
示例:
```yaml
expect:
body:
- contains: "ok"
- json:
path: "$.status"
equals: "ready"
```
ContentExpectations 数组按顺序快速失败。数组项可以是直接 matcher也可以是 `json``css``xpath` 提取器规则。一条规则不能混用直接 matcher 和 extractor多个 extractor 也不能共存。Extractor 未配置 matcher 时等价于 `exists: true`
## ValueMatcher
`ValueMatcher` 用于单个标量值、数字指标和字符串元数据。
| 字段 | 说明 |
| ---------- | ------------------------------- |
| `equals` | 精确匹配,支持 JSON 深度相等 |
| `contains` | 字符串包含 |
| `regex` | 正则匹配,固定使用无 flags 正则 |
| `empty` | 判断是否为空 |
| `exists` | 判断是否存在 |
| `gte` | 大于等于 |
| `lte` | 小于等于 |
| `gt` | 大于 |
| `lt` | 小于 |
一个 matcher 对象内多个字段为 AND 语义。`exists: false` 不能和其他 matcher 组合。
ValueMatcher expect 字段可直接写 string、number、boolean 或 null等价于 `{ equals: value }`。数组和对象必须显式写成 `{ equals: ... }`
```yaml
expect:
durationMs:
lte: 5000
finishReason: "stop"
```
## KeyedExpectations
`headers`、DB `rows[]` 中的列值等动态键值对象使用 `KeyedExpectations`。每个键的值支持 `ValueMatcher` 的全部字段,字面量值自动等价于 `{ equals: value }`
```yaml
expect:
headers:
Content-Type:
contains: "application/json"
```
## 大小和时长格式
| 类型 | 示例 |
| ---- | -------------------------------- |
| 大小 | `4KB``10MB``1GB`、直接数字 |
| 时长 | `500ms``30s``5m``2h``7d` |
`maxBodyBytes``maxOutputBytes``maxResponseBytes``maxBannerBytes` 等大小字段支持 `KB``MB``GB` 单位。
## 快速失败顺序
| Checker | 顺序 |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| HTTP | `status -> headers -> body -> durationMs` |
| Cmd | `exitCode -> durationMs -> stdout -> stderr` |
| DB | `durationMs -> rowCount -> rows -> result` |
| TCP | `connected -> banner -> durationMs` |
| UDP | `responded -> responseSize -> response -> sourceHost -> sourcePort -> durationMs` |
| ICMP | `alive -> packetLossPercent -> avgLatencyMs -> maxLatencyMs -> durationMs` |
| DNS system | `values -> valueCount -> durationMs` |
| DNS server | `responded -> rcode -> values -> valueCount -> answerCount -> ttlMin -> ttlMax -> authoritative -> recursionAvailable -> truncated -> authenticatedData -> result -> durationMs` |
| LLM http | `status -> headers -> output -> finishReason -> rawFinishReason -> usage -> durationMs` |
| LLM stream | `status -> headers -> stream.completed -> stream.firstTokenMs -> output -> finishReason -> rawFinishReason -> usage -> durationMs` |
## JSON Schema
仓库根目录导出 `probe-config.schema.json`。在 YAML 文件顶部添加以下注释可在编辑器中获得提示和校验:
```yaml
# yaml-language-server: $schema=./probe-config.schema.json
```
## 已移除字段
旧字段 `maxDurationMs``maxPacketLoss``maxAvgLatencyMs``maxMaxLatencyMs` 和旧正则字段 `match` 已移除,请分别改用 `durationMs`、ICMP matcher 字段和 `regex`
非法配置会阻止启动并输出错误信息。除动态键值表(`headers``env``variables`)外,未知字段会导致启动失败,请使用 YAML 注释表达说明。

48
docs/user/status-model.md Normal file
View File

@@ -0,0 +1,48 @@
# 目标状态判定
DiAL 使用单层状态模型。
| 状态 | 含义 |
| ------ | ---------------------------------------- |
| `UP` | 拨测结果符合 `expect` 规则 |
| `DOWN` | 拨测结果不符合 `expect` 规则,或执行失败 |
执行失败(网络错误、超时、进程崩溃)和 expect 不匹配都统一为 `DOWN`,通过 `failure.kind` 区分原因。
| `failure.kind` | 含义 |
| -------------- | ---------------------------------------- |
| `error` | 网络、超时、进程、协议解析或内部执行错误 |
| `mismatch` | 拨测完成,但结果不满足 expect |
## API 结果字段
API 返回的检查结果包含 `detail``observation`
| 字段 | 说明 |
| ------------- | ------------------------------------------------------------ |
| `detail` | 后端按 checker 类型从结构化 observation 动态生成的人可读摘要 |
| `observation` | 保存该次检查的结构化观测数据 |
| `failure` | 保存首个错误或不匹配原因 |
| `matched` | 是否符合 expect |
| `durationMs` | 本次检查耗时 |
| `timestamp` | 本次检查时间 |
`detail` 不写入 SQLite。存储层仅持久化 `observation` JSON、`failure` JSON、匹配状态、耗时和时间戳。
## observation 示例
不同 checker 的 observation 字段不同,常见信息包括:
| Checker | observation 内容示例 |
| ------- | ------------------------------------------------------------------ |
| HTTP | 状态码、响应头、按需读取的 body 预览 |
| Cmd | exit code、stdout/stderr 预览 |
| TCP | 连接结果、banner 摘要 |
| UDP | 响应内容、来源地址、响应大小 |
| ICMP | 存活结果、丢包率、平均延迟、最大延迟 |
| DNS | RCODE、记录值、TTL、flags、CNAME 链 |
| LLM | HTTP 状态、模型输出、finish reason、token usage、流式首 token 时间 |
## 趋势与统计
Dashboard 基于存储的检查结果计算实时状态、可用率、耗时趋势、P95、状态条和故障段等指标。指标语义由后端应用层实现SQLite 主要负责存储、筛选、排序、分页和基础聚合。

View File

@@ -0,0 +1,73 @@
# 故障排查
本文档记录常见运行问题和排查入口。
## 配置校验失败
DiAL 启动时会校验 YAML 配置。除动态键值表(`headers``env``variables`)外,未知字段会导致启动失败。
排查顺序:
1. 在 YAML 顶部添加 `# yaml-language-server: $schema=./probe-config.schema.json`
2. 对照 [配置文件](configuration.md) 检查顶层结构和通用字段。
3. 对照 [Checker 参考](checkers/README.md) 检查 checker 专属字段。
4. 对照 [校验规则](expectations.md) 检查 expect 写法。
## 变量无法解析
变量解析优先级为 `variables -> process.env -> 默认值`。如果三者均不存在,配置校验会失败。
常见修复:
| 问题 | 修复 |
| -------------- | ----------------------------------- | --------- |
| 环境变量未设置 | 设置环境变量或在 `variables` 中声明 |
| 希望允许空值 | 使用 `${key | }` |
| 希望提供默认值 | 使用 `${key | default}` |
| 希望输出字面量 | 使用 `$${key}` |
## ICMP checker 无法运行
ICMP checker 依赖系统 `ping` 命令。
| 环境 | 处理 |
| ------------------- | -------------------------------------- |
| Alpine 或精简镜像 | 安装 `iputils-ping` |
| Docker 容器 | 运行容器时增加 `--cap-add=NET_RAW` |
| Windows/macOS/Linux | 确认系统 `ping` 可执行且输出格式受支持 |
Docker 示例:
```bash
docker run --rm --cap-add=NET_RAW -p 3000:3000 -v dial-data:/data/dial dial:alpine
```
## CMD checker 找不到命令
官方 Docker 镜像不内置 `bun``node``curl``dig``psql``mysql``redis-cli` 等额外命令。需要这些命令时请使用派生镜像安装。
```dockerfile
FROM dial:alpine
USER root
RUN apk add --no-cache curl bind-tools postgresql-client
USER dial
```
## Docker 数据或日志丢失
推荐将数据卷挂载到 `/data/dial`,并在配置中使用该目录作为 storage dataDir。
```bash
docker run --rm -p 3000:3000 -v dial-data:/data/dial dial:alpine
```
容器示例配置位于 [`../../docker/probes.yaml`](../../docker/probes.yaml)。
## HTTP 或 LLM 证书问题
HTTP 和 LLM checker 支持 `ignoreSSL`。该选项适合内网、自签名证书或测试环境;生产环境应优先修复证书链。
## 正则规则被拒绝
`regex` 启动期会执行 ReDoS 风险检测。被拒绝时应改写为更明确、回溯风险更低的表达式。

View File

@@ -3,8 +3,15 @@ schema: fast-drive
context: | context: |
- 使用中文(注释、文档、交流),面向中文开发者 - 使用中文(注释、文档、交流),面向中文开发者
- openspec文档的关键字按openspec规范使用不要翻译为中文 - openspec文档的关键字按openspec规范使用不要翻译为中文
- **优先阅读README.md和DEVELOPMENT.md**获取项目概览与开发规范所有代码风格、命名、注解、依赖、API等规范以DEVELOPMENT.md为准 - **优先阅读docs/README.md**判断文档归属和本次任务需要读取的专题文档
- 涉及模块结构、API、实体等变更时同步更新README.md - README.md用于项目概览、快速开始和用户入口DEVELOPMENT.md用于开发入口、全局规则和质量门禁CONTRIBUTING.md仅在新增或修改checker、贡献流程时必读
- 所有代码风格、命名、注解、依赖、API等开发规范以DEVELOPMENT.md和docs/development/下对应专题文档为准
- 新增或修改checker时必须阅读CONTRIBUTING.md和docs/development/checker-development.md
- 每次代码变更都必须执行文档影响分析判断是否影响用户可见行为、配置格式、checker行为、expect规则、API、部署方式、开发流程、架构边界、测试规范或构建发布流程
- 若影响用户使用方式、配置格式、checker行为、expect规则、部署方式或运行行为必须同步更新docs/user/下对应文档README.md仅在项目定位、快速开始、核心能力列表或文档导航变化时更新
- 若影响开发流程、架构边界、质量门禁、测试规范、构建发布流程或checker开发机制必须同步更新DEVELOPMENT.md、CONTRIBUTING.md或docs/development/下对应文档
- 若影响文档同步规则或文档归属矩阵必须同步更新docs/README.md和openspec/config.yaml
- 若无需更新文档,必须在收尾说明中说明原因
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖 - 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试 - 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
- 这是基于bun实现的前端后一体化项目使用bun作为唯一包管理器严禁使用pnpm、npm使用bunx运行工具严禁使用npx、pnpx - 这是基于bun实现的前端后一体化项目使用bun作为唯一包管理器严禁使用pnpm、npm使用bunx运行工具严禁使用npx、pnpx
@@ -20,10 +27,14 @@ context: |
- (当前项目未上线,不需要考虑向前兼容) - (当前项目未上线,不需要考虑向前兼容)
rules: rules:
explore:
- 本项目openspec使用fast-drive自定义schema变更文档只包含design.md和tasks.md无proposal.md和specs
design: design:
- 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线 - 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线
tasks: tasks:
- 一行一个任务,严禁任务内容跨行 - 一行一个任务,严禁任务内容跨行
- 如果是代码存在更新必须 - 如果是代码存在更新必须
- 执行完整的测试、代码检查、格式检查等质量保障手段 - 执行完整的测试、代码检查、格式检查等质量保障手段
- 更新 README.md 和/或 DEVELOPMENT.md - 执行文档影响分析,并按影响范围更新对应文档;若无需更新文档,必须在任务或收尾说明中明确写出原因
- 新增或修改checker时必须更新docs/user/checkers/下对应用户文档并在checker开发机制变化时更新CONTRIBUTING.md或docs/development/checker-development.md
- 新增或修改配置字段时必须更新probe-config.schema.json、probes.example.yaml、docs/user/configuration.md或对应checker文档

View File

@@ -4798,6 +4798,954 @@
} }
} }
} }
},
{
"additionalProperties": false,
"type": "object",
"required": [
"id",
"type",
"dns"
],
"properties": {
"description": {
"anyOf": [
{
"type": "null"
},
{
"anyOf": [
{
"maxLength": 500,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
}
]
},
"expect": {
"additionalProperties": false,
"type": "object",
"properties": {
"answerCount": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"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"
},
"regex": {
"type": "string"
}
}
}
]
},
"authenticatedData": {
"type": "boolean"
},
"authoritative": {
"type": "boolean"
},
"durationMs": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"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"
},
"regex": {
"type": "string"
}
}
}
]
},
"rcode": {
"type": "array",
"items": {
"type": "string"
}
},
"recursionAvailable": {
"type": "boolean"
},
"responded": {
"type": "boolean"
},
"result": {
"type": "array",
"items": {
"additionalProperties": false,
"minProperties": 1,
"type": "object",
"properties": {
"contains": {
"type": "string"
},
"empty": {
"type": "boolean"
},
"equals": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
"exists": {
"type": "boolean"
},
"gt": {
"type": "number"
},
"gte": {
"type": "number"
},
"lt": {
"type": "number"
},
"lte": {
"type": "number"
},
"regex": {
"type": "string"
},
"css": {
"additionalProperties": false,
"type": "object",
"required": [
"selector"
],
"properties": {
"attr": {
"type": "string"
},
"selector": {
"minLength": 1,
"type": "string"
},
"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"
},
"regex": {
"type": "string"
}
}
},
"json": {
"additionalProperties": false,
"type": "object",
"required": [
"path"
],
"properties": {
"path": {
"type": "string"
},
"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"
},
"regex": {
"type": "string"
}
}
},
"xpath": {
"additionalProperties": false,
"type": "object",
"required": [
"path"
],
"properties": {
"path": {
"minLength": 1,
"type": "string"
},
"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"
},
"regex": {
"type": "string"
}
}
}
}
}
},
"truncated": {
"type": "boolean"
},
"ttlMax": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"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"
},
"regex": {
"type": "string"
}
}
}
]
},
"ttlMin": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"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"
},
"regex": {
"type": "string"
}
}
}
]
},
"valueCount": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"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"
},
"regex": {
"type": "string"
}
}
}
]
},
"values": {
"additionalProperties": false,
"type": "object",
"properties": {
"exact": {
"type": "array",
"items": {
"type": "string"
}
},
"exclude": {
"type": "array",
"items": {
"type": "string"
}
},
"include": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
},
"group": {
"type": "string"
},
"id": {
"maxLength": 30,
"minLength": 1,
"type": "string"
},
"interval": {
"type": "string"
},
"name": {
"anyOf": [
{
"type": "null"
},
{
"anyOf": [
{
"maxLength": 30,
"minLength": 1,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
}
]
},
"timeout": {
"type": "string"
},
"type": {
"const": "dns",
"type": "string"
},
"dns": {
"anyOf": [
{
"additionalProperties": false,
"type": "object",
"required": [
"name",
"resolver"
],
"properties": {
"family": {
"anyOf": [
{
"anyOf": [
{
"const": "any",
"type": "string"
},
{
"const": "ipv4",
"type": "string"
},
{
"const": "ipv6",
"type": "string"
}
]
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
},
"name": {
"anyOf": [
{
"minLength": 1,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
},
"resolver": {
"const": "system",
"type": "string"
}
}
},
{
"additionalProperties": false,
"type": "object",
"required": [
"name",
"resolver",
"server"
],
"properties": {
"maxResponseBytes": {
"anyOf": [
{
"type": "string"
},
{
"minimum": 0,
"type": "integer"
}
]
},
"name": {
"anyOf": [
{
"minLength": 1,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
},
"port": {
"anyOf": [
{
"maximum": 65535,
"minimum": 1,
"type": "integer"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
},
"protocol": {
"anyOf": [
{
"anyOf": [
{
"const": "udp",
"type": "string"
},
{
"const": "tcp",
"type": "string"
}
]
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
},
"recordType": {
"anyOf": [
{
"anyOf": [
{
"const": "A",
"type": "string"
},
{
"const": "AAAA",
"type": "string"
},
{
"const": "CAA",
"type": "string"
},
{
"const": "CNAME",
"type": "string"
},
{
"const": "MX",
"type": "string"
},
{
"const": "NS",
"type": "string"
},
{
"const": "PTR",
"type": "string"
},
{
"const": "SOA",
"type": "string"
},
{
"const": "SRV",
"type": "string"
},
{
"const": "TXT",
"type": "string"
}
]
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
},
"recursionDesired": {
"anyOf": [
{
"type": "boolean"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
},
"resolver": {
"const": "server",
"type": "string"
},
"server": {
"anyOf": [
{
"minLength": 1,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
},
"tcpFallback": {
"anyOf": [
{
"type": "boolean"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
}
}
}
]
}
}
} }
] ]
} }

View File

@@ -207,6 +207,67 @@ targets:
durationMs: durationMs:
lte: 5000 lte: 5000
# ========== DNS targets ==========
# 本机 DNS 解析检查system 模式)
- id: "dns-system-localhost"
name: "本机 DNS 解析"
type: dns
group: "DNS"
dns:
resolver: system
name: "localhost"
family: ipv4
expect:
values:
exact:
- "127.0.0.1"
durationMs:
lte: 200
# DNS server 拨测server 模式A 记录)
- id: "dns-server-cf"
name: "Cloudflare DNS A 记录"
type: dns
group: "DNS"
dns:
resolver: server
server: "1.1.1.1"
name: "example.com"
recordType: A
expect:
rcode: ["NOERROR"]
ttlMin:
gte: 60
durationMs:
lte: 500
# 负向 DNS 检查NXDOMAIN
- id: "dns-nxdomain-check"
name: "负向 DNS 检查"
type: dns
group: "DNS"
dns:
resolver: server
server: "1.1.1.1"
name: "this-domain-should-not-exist.example.com"
recordType: A
expect:
rcode: ["NXDOMAIN"]
# MX 记录检查
- id: "dns-mx-check"
name: "MX 记录检查"
type: dns
group: "DNS"
dns:
resolver: server
server: "1.1.1.1"
name: "gmail.com"
recordType: MX
expect:
rcode: ["NOERROR"]
# ========== UDP targets ========== # ========== UDP targets ==========
- id: "udp-heartbeat" - id: "udp-heartbeat"

View File

@@ -0,0 +1,440 @@
const OPCODE_MASK = 0x7800;
const RCODE_MASK = 0x000f;
const FLAG_AA = 0x0400;
const FLAG_TC = 0x0200;
const FLAG_RD = 0x0100;
const FLAG_RA = 0x0080;
const FLAG_AD = 0x0020;
const RRTYPE_A = 1;
const RRTYPE_NS = 2;
const RRTYPE_CNAME = 5;
const RRTYPE_SOA = 6;
const RRTYPE_PTR = 12;
const RRTYPE_MX = 15;
const RRTYPE_TXT = 16;
const RRTYPE_AAAA = 28;
const RRTYPE_SRV = 33;
const RRTYPE_CAA = 257;
const RCODE_NAMES: Record<number, string> = {
0: "NOERROR",
1: "FORMERR",
2: "SERVFAIL",
3: "NXDOMAIN",
4: "NOTIMP",
5: "REFUSED",
6: "YXDOMAIN",
7: "YXRRSET",
8: "NXRRSET",
9: "NOTAUTH",
10: "NOTZONE",
};
const RRTYPE_NAMES: Record<number, string> = {
[RRTYPE_A]: "A",
[RRTYPE_AAAA]: "AAAA",
[RRTYPE_CAA]: "CAA",
[RRTYPE_CNAME]: "CNAME",
[RRTYPE_MX]: "MX",
[RRTYPE_NS]: "NS",
[RRTYPE_PTR]: "PTR",
[RRTYPE_SOA]: "SOA",
[RRTYPE_SRV]: "SRV",
[RRTYPE_TXT]: "TXT",
};
const RRTYPE_BY_NAME: Record<string, number> = {
A: RRTYPE_A,
AAAA: RRTYPE_AAAA,
CAA: RRTYPE_CAA,
CNAME: RRTYPE_CNAME,
MX: RRTYPE_MX,
NS: RRTYPE_NS,
PTR: RRTYPE_PTR,
SOA: RRTYPE_SOA,
SRV: RRTYPE_SRV,
TXT: RRTYPE_TXT,
};
const CLASS_IN = 1;
export interface DnsAnswer {
class: number;
data: Record<string, unknown>;
name: string;
ttl: number;
type: number;
value: string;
}
export interface DnsFlags {
authenticatedData: boolean;
authoritative: boolean;
recursionAvailable: boolean;
recursionDesired: boolean;
truncated: boolean;
}
export interface DnsHeader {
additionalCount: number;
answerCount: number;
authorityCount: number;
flags: DnsFlags;
id: number;
opcode: number;
questionCount: number;
rcode: number;
}
export interface DnsQuestion {
name: string;
qclass: number;
qtype: number;
}
export interface DnsResponse {
additional: DnsAnswer[];
answers: DnsAnswer[];
authorities: DnsAnswer[];
header: DnsHeader;
questions: DnsQuestion[];
}
interface ParseContext {
data: Uint8Array;
offset: number;
view: DataView;
}
interface RdataResult extends Record<string, unknown> {
value: string;
}
export function buildQuery(name: string, recordType: number, recursionDesired: boolean): Uint8Array {
const queryName = name.endsWith(".") ? name.slice(0, -1) : name;
const labels = queryName === "" ? [] : queryName.split(".");
const encodedLabels: Uint8Array[] = [];
let nameSize = 1;
for (const label of labels) {
if (label === "") {
throw new Error(`无效的 DNS 名称: ${name}`);
}
if (!isAscii(label)) {
throw new Error(`DNS 名称必须使用 ASCII/Punycode: ${name}`);
}
const encoded = new TextEncoder().encode(label);
if (encoded.length > 63) {
throw new Error(`DNS 标签超过 63 字节: ${label}`);
}
nameSize += 1 + encoded.length;
encodedLabels.push(encoded);
}
if (nameSize > 255) {
throw new Error(`DNS 名称超过 255 字节: ${name}`);
}
const questionSize = nameSize + 4;
const buf = new Uint8Array(12 + questionSize);
const view = new DataView(buf.buffer);
const id = (Math.random() * 65536) | 0;
view.setUint16(0, id);
let flags = 0;
if (recursionDesired) flags |= FLAG_RD;
view.setUint16(2, flags);
view.setUint16(4, 1);
view.setUint16(6, 0);
view.setUint16(8, 0);
view.setUint16(10, 0);
let offset = 12;
for (const encoded of encodedLabels) {
buf[offset] = encoded.length;
offset++;
buf.set(encoded, offset);
offset += encoded.length;
}
buf[offset] = 0;
offset++;
view.setUint16(offset, recordType);
offset += 2;
view.setUint16(offset, CLASS_IN);
return buf;
}
export function parseResponse(data: Uint8Array): DnsResponse {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
if (data.byteLength < 12) {
throw new Error(`DNS 响应过短: ${data.byteLength} 字节`);
}
const id = view.getUint16(0);
const flagsWord = view.getUint16(2);
const questionCount = view.getUint16(4);
const answerCount = view.getUint16(6);
const authorityCount = view.getUint16(8);
const additionalCount = view.getUint16(10);
const opcode = (flagsWord & OPCODE_MASK) >> 11;
const rcode = flagsWord & RCODE_MASK;
const flags: DnsFlags = {
authenticatedData: (flagsWord & FLAG_AD) !== 0,
authoritative: (flagsWord & FLAG_AA) !== 0,
recursionAvailable: (flagsWord & FLAG_RA) !== 0,
recursionDesired: (flagsWord & FLAG_RD) !== 0,
truncated: (flagsWord & FLAG_TC) !== 0,
};
const header: DnsHeader = {
additionalCount,
answerCount,
authorityCount,
flags,
id,
opcode,
questionCount,
rcode,
};
const ctx: ParseContext = { data, offset: 12, view };
const questions: DnsQuestion[] = [];
for (let i = 0; i < questionCount; i++) {
const name = readName(ctx);
const qtype = readUint16(ctx);
const qclass = readUint16(ctx);
questions.push({ name, qclass, qtype });
}
const answers = readAnswers(ctx, answerCount);
const authorities = readAnswers(ctx, authorityCount);
const additional = readAnswers(ctx, additionalCount);
return { additional, answers, authorities, header, questions };
}
export function rcodeName(code: number): string {
return RCODE_NAMES[code] ?? `UNKNOWN(${code})`;
}
export function rrtTypeByName(name: string): number {
const code = RRTYPE_BY_NAME[name];
if (code === undefined) throw new Error(`不支持的记录类型: ${name}`);
return code;
}
export function rrtTypeName(code: number): string {
return RRTYPE_NAMES[code] ?? `TYPE${code}`;
}
function isAscii(value: string): boolean {
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) > 0x7f) return false;
}
return true;
}
function normalizeIPv6(groups: string[]): string {
let maxZeroStart = -1;
let maxZeroLen = 0;
let currentStart = -1;
let currentLen = 0;
for (let i = 0; i < 8; i++) {
if (groups[i] === "0") {
if (currentStart === -1) currentStart = i;
currentLen++;
if (currentLen > maxZeroLen) {
maxZeroLen = currentLen;
maxZeroStart = currentStart;
}
} else {
currentStart = -1;
currentLen = 0;
}
}
if (maxZeroLen >= 2) {
const before = groups.slice(0, maxZeroStart).join(":");
const after = groups.slice(maxZeroStart + maxZeroLen).join(":");
if (before === "" && after === "") return "::";
if (before === "") return `::${after}`;
if (after === "") return `${before}::`;
return `${before}::${after}`;
}
return groups.join(":");
}
function parseRdata(ctx: ParseContext, type: number, rdLength: number): RdataResult {
const rdStart = ctx.offset;
const rdEnd = rdStart + rdLength;
switch (type) {
case RRTYPE_A: {
if (rdLength < 4) throw new Error("A 记录 rdata 过短");
const a = ctx.data[rdStart]!;
const b = ctx.data[rdStart + 1]!;
const c = ctx.data[rdStart + 2]!;
const d = ctx.data[rdStart + 3]!;
const address = `${a}.${b}.${c}.${d}`;
return { address, value: address };
}
case RRTYPE_AAAA: {
if (rdLength < 16) throw new Error("AAAA 记录 rdata 过短");
const groups: string[] = [];
for (let i = 0; i < 16; i += 2) {
groups.push(((ctx.data[rdStart + i]! << 8) | ctx.data[rdStart + i + 1]!).toString(16));
}
const address = normalizeIPv6(groups);
return { address, value: address };
}
case RRTYPE_CAA: {
const flags = ctx.data[rdStart]!;
const tagLen = ctx.data[rdStart + 1]!;
const tag = new TextDecoder().decode(ctx.data.subarray(rdStart + 2, rdStart + 2 + tagLen));
const valueBytes = ctx.data.subarray(rdStart + 2 + tagLen, rdEnd);
const caaValue = new TextDecoder().decode(valueBytes);
const value = `${flags} ${tag} ${caaValue}`;
return { flags, tag, value: value, valueStr: caaValue };
}
case RRTYPE_CNAME: {
const target = readName(ctx);
return { target, value: target };
}
case RRTYPE_MX: {
const preference = readUint16(ctx);
const exchange = readName(ctx);
const value = `${preference} ${exchange}`;
return { exchange, preference, value };
}
case RRTYPE_NS: {
const nsdname = readName(ctx);
return { nsdname, value: nsdname };
}
case RRTYPE_PTR: {
const ptrdname = readName(ctx);
return { ptrdname, value: ptrdname };
}
case RRTYPE_SOA: {
const mname = readName(ctx);
const rname = readName(ctx);
const serial = readUint32(ctx);
const refresh = readUint32(ctx);
const retry = readUint32(ctx);
const expire = readUint32(ctx);
const minimum = readUint32(ctx);
const value = `${mname} ${rname} ${serial} ${refresh} ${retry} ${expire} ${minimum}`;
return { expire, minimum, mname, refresh, retry, rname, serial, value };
}
case RRTYPE_SRV: {
const priority = readUint16(ctx);
const weight = readUint16(ctx);
const port = readUint16(ctx);
const target = readName(ctx);
const value = `${priority} ${weight} ${port} ${target}`;
return { port, priority, target, value, weight };
}
case RRTYPE_TXT: {
const texts: string[] = [];
let pos = rdStart;
while (pos < rdEnd) {
const txtLen = ctx.data[pos]!;
pos++;
if (pos + txtLen > rdEnd) break;
texts.push(new TextDecoder().decode(ctx.data.subarray(pos, pos + txtLen)));
pos += txtLen;
}
const fullText = texts.join("");
return { text: fullText, value: fullText };
}
default: {
const raw = Array.from(ctx.data.subarray(rdStart, rdEnd));
const value = raw.map((b) => b.toString(16).padStart(2, "0")).join(" ");
return { raw, value };
}
}
}
function readAnswers(ctx: ParseContext, count: number): DnsAnswer[] {
const answers: DnsAnswer[] = [];
for (let i = 0; i < count; i++) {
const name = readName(ctx);
const type = readUint16(ctx);
const cls = readUint16(ctx);
const ttl = readUint32(ctx);
const rdLength = readUint16(ctx);
const rdStart = ctx.offset;
const data = parseRdata(ctx, type, rdLength);
ctx.offset = rdStart + rdLength;
answers.push({ class: cls, data, name, ttl, type, value: data.value });
}
return answers;
}
function readName(ctx: ParseContext): string {
const parts: string[] = [];
const visited = new Set<number>();
let savedOffset: null | number = null;
let currentOffset = ctx.offset;
while (true) {
if (currentOffset >= ctx.data.byteLength) {
throw new Error("DNS 名称解析越界");
}
const len = ctx.data[currentOffset]!;
if (len === 0) {
currentOffset++;
break;
}
if ((len & 0xc0) === 0xc0) {
if (currentOffset + 1 >= ctx.data.byteLength) {
throw new Error("DNS 压缩指针越界");
}
savedOffset ??= currentOffset + 2;
const ptrOffset = ((len & 0x3f) << 8) | ctx.data[currentOffset + 1]!;
if (visited.has(ptrOffset)) {
throw new Error("DNS 压缩指针循环");
}
visited.add(ptrOffset);
currentOffset = ptrOffset;
continue;
}
currentOffset++;
if (currentOffset + len > ctx.data.byteLength) {
throw new Error("DNS 标签越界");
}
const label = new TextDecoder().decode(ctx.data.subarray(currentOffset, currentOffset + len));
parts.push(label);
currentOffset += len;
}
ctx.offset = savedOffset ?? currentOffset;
return parts.join(".");
}
function readUint16(ctx: ParseContext): number {
const val = ctx.view.getUint16(ctx.offset);
ctx.offset += 2;
return val;
}
function readUint32(ctx: ParseContext): number {
const val = ctx.view.getUint32(ctx.offset);
ctx.offset += 4;
return val;
}

View File

@@ -0,0 +1,901 @@
import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type {
DnsServerConfig,
DnsSystemConfig,
ResolvedDnsServerExpectConfig,
ResolvedDnsSystemExpectConfig,
ResolvedDnsTarget,
} from "./types";
import { errorFailure, mismatchFailure } from "../../expect/failure";
import { checkValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { buildQuery, rcodeName, rrtTypeByName, rrtTypeName } from "./codec";
import {
checkAnswerCount,
checkDnsValues,
checkFlag,
checkRcode,
checkResponded,
checkResult,
checkTtlMax,
checkTtlMin,
checkValueCount,
} from "./expect";
import { dnsCheckerSchemas } from "./schema";
import { queryDns } from "./transport";
import { validateDnsConfig } from "./validate";
const DEFAULT_MAX_RESPONSE_BYTES = 4096;
const DEFAULT_PORT = 53;
const DEFAULT_RECORD_TYPE = "A";
const DEFAULT_PROTOCOL = "udp";
const DEFAULT_TCP_FALLBACK = true;
const DEFAULT_RECURSION_DESIRED = true;
const DEFAULT_FAMILY = "any";
interface LookupAddress {
address: string;
family: number;
}
type LookupOutcome = { addresses: string[]; ok: true } | { error: string; ok: false };
export class DnsChecker implements CheckerDefinition<ResolvedDnsTarget> {
readonly configKey = "dns";
readonly schemas = dnsCheckerSchemas;
readonly type = "dns";
buildDetail(observation: Record<string, unknown>): null | string {
const resolver = observation["resolver"];
const durationMs = observation["durationMs"];
const duration = typeof durationMs === "number" ? `${durationMs}ms` : "?ms";
if (resolver === "system") {
return buildSystemDetail(observation, duration);
}
return buildServerDetail(observation, duration);
}
async execute(t: ResolvedDnsTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
try {
if (t.dns.resolver === "system") {
return await this.executeSystem(t, ctx, timestamp, start);
}
return await this.executeServer(t, ctx, timestamp, start);
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("query", "query", isError(error) ? error.message : String(error)),
matched: false,
observation: null,
targetId: t.id,
timestamp,
};
}
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedDnsTarget {
const dns = target["dns"] as DnsServerConfig | DnsSystemConfig;
if (dns.resolver === "system") {
return this.resolveSystem(target, dns, context);
}
return this.resolveServer(target, dns, context);
}
serialize(t: ResolvedDnsTarget): { config: string; target: string } {
if (t.dns.resolver === "system") {
return {
config: JSON.stringify(t.dns),
target: `dns system ${t.dns.name}`,
};
}
return {
config: JSON.stringify(t.dns),
target: `dns ${t.dns.server}:${t.dns.port} ${t.dns.name}/${t.dns.recordType}`,
};
}
validate(input: CheckerValidationInput) {
return validateDnsConfig(input);
}
private async executeServer(
t: ResolvedDnsTarget,
ctx: CheckerContext,
timestamp: string,
start: number,
): Promise<CheckResult> {
const dns = t.dns as {
maxResponseBytes: number;
name: string;
port: number;
protocol: "tcp" | "udp";
recordType: string;
recursionDesired: boolean;
resolver: "server";
server: string;
tcpFallback: boolean;
};
const expect = t.expect as ResolvedDnsServerExpectConfig | undefined;
const qtype = rrtTypeByName(dns.recordType);
const query = buildQuery(dns.name, qtype, dns.recursionDesired);
const queryResult = await queryDns(dns.server, dns.port, query, {
maxResponseBytes: dns.maxResponseBytes,
protocol: dns.protocol,
signal: ctx.signal,
tcpFallback: dns.tcpFallback,
});
const durationMs = Math.round(performance.now() - start);
if (!queryResult.ok) {
const observation: Record<string, unknown> = {
durationMs,
error: queryResult.error,
name: dns.name,
port: dns.port,
protocol: dns.protocol,
protocolUsed: dns.protocol,
recordType: dns.recordType,
recursionDesired: dns.recursionDesired,
resolver: "server",
responded: false,
server: dns.server,
tcpFallback: dns.tcpFallback,
};
const expectedResponded = expect?.responded ?? true;
if (expectedResponded) {
return {
detail: null,
durationMs,
failure: errorFailure("query", "query", queryResult.error),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
observation,
targetId: t.id,
timestamp,
};
}
const response = queryResult.response;
const rcode = rcodeName(response.header.rcode);
const protocolUsed = queryResult.protocolUsed;
const allAnswers = response.answers;
const targetAnswers = allAnswers.filter((a) => a.type === qtype);
const values = targetAnswers.map((a) => a.value);
const valueCount = values.length;
const answerCount = allAnswers.length;
let ttlMin: null | number = null;
let ttlMax: null | number = null;
if (allAnswers.length > 0) {
const ttls = allAnswers.map((a) => a.ttl);
ttlMin = Math.min(...ttls);
ttlMax = Math.max(...ttls);
}
const cnameChain: string[] = [];
const finalValues: string[] = [];
if (dns.recordType === "A" || dns.recordType === "AAAA") {
const targetType = dns.recordType === "A" ? 1 : 28;
const seen = new Set<string>();
for (const ans of allAnswers) {
if (ans.type === targetType) {
if (!seen.has(ans.value)) {
finalValues.push(ans.value);
seen.add(ans.value);
}
} else if (ans.type === 5) {
cnameChain.push(ans.value);
}
}
} else {
for (const v of values) {
if (!finalValues.includes(v)) {
finalValues.push(v);
}
}
}
const effectiveValueCount = dns.recordType === "A" || dns.recordType === "AAAA" ? finalValues.length : valueCount;
const effectiveValues = dns.recordType === "A" || dns.recordType === "AAAA" ? finalValues : values;
const observation: Record<string, unknown> = {
additionalCount: response.header.additionalCount,
answerCount,
answers: allAnswers.map((a) => ({
class: a.class === 1 ? "IN" : a.class,
data: a.data,
name: a.name,
ttl: a.ttl,
type: rrtTypeName(a.type),
value: a.value,
})),
authorityCount: response.header.authorityCount,
cnameChain,
durationMs,
error: null,
flags: {
authenticatedData: response.header.flags.authenticatedData,
authoritative: response.header.flags.authoritative,
recursionAvailable: response.header.flags.recursionAvailable,
recursionDesired: response.header.flags.recursionDesired,
truncated: response.header.flags.truncated,
},
name: dns.name,
port: dns.port,
protocol: dns.protocol,
protocolUsed,
rcode,
recordType: dns.recordType,
recursionDesired: dns.recursionDesired,
resolver: "server",
responded: true,
server: dns.server,
tcpFallback: dns.tcpFallback,
ttlMax,
ttlMin,
valueCount: effectiveValueCount,
values: effectiveValues,
};
if (!expect) {
const defaultRcodeOk = rcode === "NOERROR";
const defaultCountOk = effectiveValueCount > 0;
if (!defaultRcodeOk) {
return {
detail: null,
durationMs,
failure: mismatchFailure("rcode", "rcode", "NOERROR", rcode, `DNS 响应码: ${rcode}`),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
if (!defaultCountOk) {
return {
detail: null,
durationMs,
failure: mismatchFailure("valueCount", "valueCount", "> 0", 0, "DNS 响应成功但无目标记录"),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
observation,
targetId: t.id,
timestamp,
};
}
const expectedResponded = expect.responded;
const respondedResult = checkResponded(true, expectedResponded);
if (!respondedResult.matched) {
return {
detail: null,
durationMs,
failure: respondedResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
if (expect.rcode) {
const rcodeResult = checkRcode(rcode, expect.rcode);
if (!rcodeResult.matched) {
return {
detail: null,
durationMs,
failure: rcodeResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
const hasExplicitNonNoerrorRcode = expect.rcode && !expect.rcode.includes("NOERROR");
if (expect.values) {
const valuesResult = checkDnsValues(effectiveValues, expect.values);
if (!valuesResult.matched) {
return {
detail: null,
durationMs,
failure: valuesResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
if (expect.valueCount) {
const countResult = checkValueCount(effectiveValueCount, expect.valueCount);
if (!countResult.matched) {
return {
detail: null,
durationMs,
failure: countResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
} else if (!hasExplicitNonNoerrorRcode && !expect.values) {
if (effectiveValueCount === 0) {
return {
detail: null,
durationMs,
failure: mismatchFailure("valueCount", "valueCount", "> 0", 0, "DNS 响应成功但无目标记录"),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
if (expect.answerCount) {
const countResult = checkAnswerCount(answerCount, expect.answerCount);
if (!countResult.matched) {
return {
detail: null,
durationMs,
failure: countResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
if (expect.ttlMin !== undefined) {
if (ttlMin === null) {
return {
detail: null,
durationMs,
failure: mismatchFailure("ttlMin", "ttlMin", "可用 TTL", null, "响应中没有可用于 ttlMin 的 answer"),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const ttlResult = checkTtlMin(ttlMin, expect.ttlMin);
if (!ttlResult.matched) {
return {
detail: null,
durationMs,
failure: ttlResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
if (expect.ttlMax !== undefined) {
if (ttlMax === null) {
return {
detail: null,
durationMs,
failure: mismatchFailure("ttlMax", "ttlMax", "可用 TTL", null, "响应中没有可用于 ttlMax 的 answer"),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const ttlResult = checkTtlMax(ttlMax, expect.ttlMax);
if (!ttlResult.matched) {
return {
detail: null,
durationMs,
failure: ttlResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
const authoritativeResult = checkFlag(response.header.flags.authoritative, expect.authoritative, "authoritative");
if (authoritativeResult && !authoritativeResult.matched) {
return {
detail: null,
durationMs,
failure: authoritativeResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const raResult = checkFlag(
response.header.flags.recursionAvailable,
expect.recursionAvailable,
"recursionAvailable",
);
if (raResult && !raResult.matched) {
return {
detail: null,
durationMs,
failure: raResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const tcResult = checkFlag(response.header.flags.truncated, expect.truncated, "truncated");
if (tcResult && !tcResult.matched) {
return {
detail: null,
durationMs,
failure: tcResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const adResult = checkFlag(response.header.flags.authenticatedData, expect.authenticatedData, "authenticatedData");
if (adResult && !adResult.matched) {
return {
detail: null,
durationMs,
failure: adResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
if (expect.result) {
const resultExpectation = checkResult(observation, expect.result);
if (!resultExpectation.matched) {
return {
detail: null,
durationMs,
failure: resultExpectation.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
const durationResult = checkValueExpectation(durationMs, expect.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
observation,
targetId: t.id,
timestamp,
};
}
private async executeSystem(
t: ResolvedDnsTarget,
ctx: CheckerContext,
timestamp: string,
start: number,
): Promise<CheckResult> {
const dns = t.dns as { family: string; name: string; resolver: "system" };
const expect = t.expect;
try {
const familyOption = dns.family === "ipv4" ? 4 : dns.family === "ipv6" ? 6 : 0;
const result = await lookupWithAbort(dns.name, familyOption, ctx.signal);
const durationMs = Math.round(performance.now() - start);
if (!result.ok) {
const observation: Record<string, unknown> = {
durationMs,
error: result.error,
family: dns.family,
name: dns.name,
resolver: "system",
valueCount: 0,
values: [],
};
const defaultExpect = !expect;
if (defaultExpect) {
return {
detail: null,
durationMs,
failure: errorFailure("resolve", "resolve", result.error),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: errorFailure("resolve", "resolve", result.error),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const values = result.addresses;
const valueCount = values.length;
const observation: Record<string, unknown> = {
durationMs,
error: null,
family: dns.family,
name: dns.name,
resolver: "system",
valueCount,
values,
};
if (!expect) {
const defaultMatched = valueCount > 0;
if (!defaultMatched) {
return {
detail: null,
durationMs,
failure: mismatchFailure("valueCount", "valueCount", "> 0", 0, "解析成功但未返回任何地址"),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
observation,
targetId: t.id,
timestamp,
};
}
if (expect.values) {
const valuesResult = checkDnsValues(values, expect.values);
if (!valuesResult.matched) {
return {
detail: null,
durationMs,
failure: valuesResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
if (expect.valueCount) {
const countResult = checkValueCount(valueCount, expect.valueCount);
if (!countResult.matched) {
return {
detail: null,
durationMs,
failure: countResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
const durationResult = checkValueExpectation(durationMs, expect.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
observation,
targetId: t.id,
timestamp,
};
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("resolve", "resolve", isError(error) ? error.message : String(error)),
matched: false,
observation: null,
targetId: t.id,
timestamp,
};
}
}
private resolveServer(t: RawTargetConfig, dns: DnsServerConfig, context: ResolveContext): ResolvedDnsTarget {
const expect = t.expect as ResolvedDnsServerExpectConfig | undefined;
const resolvedExpect: ResolvedDnsServerExpectConfig | undefined = expect
? {
...expect,
responded: expect.responded ?? true,
}
: undefined;
return {
description: null,
dns: {
maxResponseBytes: parseSize(dns.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES),
name: dns.name,
port: dns.port ?? DEFAULT_PORT,
protocol: dns.protocol ?? DEFAULT_PROTOCOL,
recordType: dns.recordType ?? DEFAULT_RECORD_TYPE,
recursionDesired: dns.recursionDesired ?? DEFAULT_RECURSION_DESIRED,
resolver: "server",
server: dns.server,
tcpFallback: dns.tcpFallback ?? DEFAULT_TCP_FALLBACK,
},
expect: resolvedExpect,
group: t.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
timeoutMs: context.defaultTimeoutMs,
type: "dns",
} satisfies ResolvedDnsTarget;
}
private resolveSystem(t: RawTargetConfig, dns: DnsSystemConfig, context: ResolveContext): ResolvedDnsTarget {
const expect = t.expect as ResolvedDnsSystemExpectConfig | undefined;
const resolvedExpect: ResolvedDnsSystemExpectConfig | undefined = expect ? { ...expect } : undefined;
return {
description: null,
dns: {
family: dns.family ?? DEFAULT_FAMILY,
name: dns.name,
resolver: "system",
},
expect: resolvedExpect,
group: t.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
timeoutMs: context.defaultTimeoutMs,
type: "dns",
} satisfies ResolvedDnsTarget;
}
}
function buildServerDetail(observation: Record<string, unknown>, duration: string): null | string {
const responded = observation["responded"];
const error = observation["error"];
const rcode = observation["rcode"];
const protocolUsed = observation["protocolUsed"];
const cnameChain = observation["cnameChain"];
if (responded !== true) {
if (typeof error === "string") {
return `查询失败: ${error} (${duration})`;
}
return `未收到响应 (${duration})`;
}
const rcodeStr = typeof rcode === "string" ? rcode : "UNKNOWN";
const valueCount = observation["valueCount"];
const count = typeof valueCount === "number" ? valueCount : 0;
const values = observation["values"];
const addrs = Array.isArray(values) ? values : [];
const protoStr = typeof protocolUsed === "string" ? protocolUsed.toUpperCase() : "?";
const parts: string[] = [rcodeStr];
if (Array.isArray(cnameChain) && cnameChain.length > 0) {
parts.push(`CNAME: ${cnameChain.join(" → ")}`);
}
if (count > 0) {
const preview = addrs.slice(0, 3).join(", ");
const suffix = count > 3 ? `${count}` : "";
parts.push(`${preview}${suffix}`);
}
parts.push(`${protoStr} ${duration}`);
return parts.join(", ");
}
function buildSystemDetail(observation: Record<string, unknown>, duration: string): null | string {
const error = observation["error"];
const valueCount = observation["valueCount"];
const values = observation["values"];
if (typeof error === "string") {
return `解析失败: ${error} (${duration})`;
}
const count = typeof valueCount === "number" ? valueCount : 0;
const addrs = Array.isArray(values) ? values : [];
if (count === 0) {
return `解析成功但无结果 (${duration})`;
}
const preview = addrs.slice(0, 3).join(", ");
const suffix = count > 3 ? `${count}` : "";
return `${preview}${suffix} (${duration})`;
}
async function lookupWithAbort(hostname: string, family: number, signal: AbortSignal): Promise<LookupOutcome> {
if (signal.aborted) {
return { error: "探测已取消", ok: false };
}
return new Promise<LookupOutcome>((resolve) => {
let settled = false;
const onAbort = () => {
if (settled) return;
settled = true;
resolve({ error: "探测超时", ok: false });
};
signal.addEventListener("abort", onAbort, { once: true });
const onResult = (err: NodeJS.ErrnoException | null, address: LookupAddress[] | string | string[]) => {
if (settled) return;
settled = true;
signal.removeEventListener("abort", onAbort);
if (err) {
resolve({ error: err.message, ok: false });
return;
}
const addresses = Array.isArray(address)
? address.map((item) => (typeof item === "string" ? item : item.address))
: [address];
resolve({ addresses, ok: true });
};
try {
import("node:dns")
.then((dns) => {
if (family === 0) {
dns.lookup(hostname, { all: true }, (err, address) => onResult(err, address));
} else {
dns.lookup(hostname, { all: true, family }, (err, address) => onResult(err, address));
}
})
.catch((e) => {
if (!settled) {
settled = true;
signal.removeEventListener("abort", onAbort);
resolve({ error: e instanceof Error ? e.message : String(e), ok: false });
}
});
} catch (e) {
if (!settled) {
settled = true;
signal.removeEventListener("abort", onAbort);
resolve({ error: e instanceof Error ? e.message : String(e), ok: false });
}
}
});
}

View File

@@ -0,0 +1,120 @@
import type { ContentExpectations, ExpectationResult, ValueExpectation } from "../../expect/types";
import type { DnsValuesExpectation } from "./types";
import { checkContentExpectations } from "../../expect/content";
import { mismatchFailure } from "../../expect/failure";
import { checkValueExpectation } from "../../expect/value";
export function checkAnswerCount(actual: number, matcher: ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "answer 数量不满足条件",
path: "answerCount",
phase: "answerCount",
});
}
export function checkDnsValues(actualValues: string[], expectation: DnsValuesExpectation): ExpectationResult {
const actualSet = new Set(actualValues);
if (expectation.exact) {
const expectedSet = new Set(expectation.exact);
if (actualSet.size !== expectedSet.size || ![...expectedSet].every((v) => actualSet.has(v))) {
return {
failure: mismatchFailure(
"values",
"values",
expectation.exact,
actualValues,
"values 集合不匹配exact 忽略顺序)",
),
matched: false,
};
}
}
if (expectation.include) {
for (const v of expectation.include) {
if (!actualSet.has(v)) {
return {
failure: mismatchFailure("values", "values", `包含 ${v}`, actualValues, `values 缺少期望值: ${v}`),
matched: false,
};
}
}
}
if (expectation.exclude) {
for (const v of expectation.exclude) {
if (actualSet.has(v)) {
return {
failure: mismatchFailure("values", "values", `排除 ${v}`, actualValues, `values 包含排除值: ${v}`),
matched: false,
};
}
}
}
return { failure: null, matched: true };
}
export function checkFlag(actual: boolean, expected: boolean | undefined, name: string): ExpectationResult | null {
if (expected === undefined) return null;
if (actual === expected) return { failure: null, matched: true };
return {
failure: mismatchFailure(name, name, expected, actual, `${name} 不匹配`),
matched: false,
};
}
export function checkRcode(actual: string, expected: string[]): ExpectationResult {
if (expected.includes(actual)) return { failure: null, matched: true };
return {
failure: mismatchFailure("rcode", "rcode", expected.join(", "), actual, `RCODE 不在期望列表中`),
matched: false,
};
}
export function checkResponded(responded: boolean, expected: boolean): ExpectationResult {
if (responded === expected) return { failure: null, matched: true };
if (!responded && expected) {
return {
failure: mismatchFailure("responded", "responded", true, false, "期望收到响应但未收到"),
matched: false,
};
}
return {
failure: mismatchFailure("responded", "responded", false, true, "期望无响应但收到响应"),
matched: false,
};
}
export function checkResult(
observation: Record<string, unknown>,
expectations: ContentExpectations,
): ExpectationResult {
return checkContentExpectations(JSON.stringify(observation), expectations, { path: "result", phase: "result" });
}
export function checkTtlMax(actual: number, matcher: ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "最大 TTL 不满足条件",
path: "ttlMax",
phase: "ttlMax",
});
}
export function checkTtlMin(actual: number, matcher: ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "最小 TTL 不满足条件",
path: "ttlMin",
phase: "ttlMin",
});
}
export function checkValueCount(actual: number, matcher: ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "value 数量不满足条件",
path: "valueCount",
phase: "valueCount",
});
}

View File

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

View File

@@ -0,0 +1,107 @@
import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createAuthoringContentExpectationsSchema,
createAuthoringFieldSchema,
createAuthoringValueExpectationSchema,
createNormalizedContentExpectationsSchema,
createNormalizedValueExpectationSchema,
sizeSchema,
} from "../../schema/fragments";
const RECORD_TYPES = ["A", "AAAA", "CAA", "CNAME", "MX", "NS", "PTR", "SOA", "SRV", "TXT"] as const;
export const dnsCheckerSchemas: CheckerSchemas = {
authoring: {
config: createDnsConfigSchema("authoring"),
expect: createDnsExpectSchema("authoring"),
},
normalized: {
config: createDnsConfigSchema("normalized"),
expect: createDnsExpectSchema("normalized"),
},
};
function createDnsConfigSchema(kind: "authoring" | "normalized") {
const recordType = createRecordTypeSchema();
const family = Type.Union([Type.Literal("any"), Type.Literal("ipv4"), Type.Literal("ipv6")]);
const systemFields = {
family: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(family) : family),
name:
kind === "authoring" ? createAuthoringFieldSchema(Type.String({ minLength: 1 })) : Type.String({ minLength: 1 }),
resolver: Type.Literal("system"),
};
const serverFields = {
maxResponseBytes: Type.Optional(sizeSchema),
name:
kind === "authoring" ? createAuthoringFieldSchema(Type.String({ minLength: 1 })) : Type.String({ minLength: 1 }),
port: Type.Optional(
kind === "authoring"
? createAuthoringFieldSchema(Type.Integer({ maximum: 65535, minimum: 1 }))
: Type.Integer({ maximum: 65535, minimum: 1 }),
),
protocol: Type.Optional(
kind === "authoring"
? createAuthoringFieldSchema(Type.Union([Type.Literal("udp"), Type.Literal("tcp")]))
: Type.Union([Type.Literal("udp"), Type.Literal("tcp")]),
),
recordType: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(recordType) : recordType),
recursionDesired: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(Type.Boolean()) : Type.Boolean()),
resolver: Type.Literal("server"),
server:
kind === "authoring" ? createAuthoringFieldSchema(Type.String({ minLength: 1 })) : Type.String({ minLength: 1 }),
tcpFallback: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(Type.Boolean()) : Type.Boolean()),
};
return Type.Union(
[
Type.Object(systemFields, { additionalProperties: false }),
Type.Object(serverFields, { additionalProperties: false }),
],
{},
);
}
function createDnsExpectSchema(kind: "authoring" | "normalized") {
const valueSchema =
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema();
const contentSchema =
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema();
const expectFields = {
answerCount: Type.Optional(valueSchema),
authenticatedData: Type.Optional(Type.Boolean()),
authoritative: Type.Optional(Type.Boolean()),
durationMs: Type.Optional(valueSchema),
rcode: Type.Optional(Type.Array(Type.String())),
recursionAvailable: Type.Optional(Type.Boolean()),
responded: Type.Optional(Type.Boolean()),
result: Type.Optional(contentSchema),
truncated: Type.Optional(Type.Boolean()),
ttlMax: Type.Optional(valueSchema),
ttlMin: Type.Optional(valueSchema),
valueCount: Type.Optional(valueSchema),
values: Type.Optional(createDnsValuesExpectationSchema()),
};
return Type.Object(expectFields, { additionalProperties: false });
}
function createDnsValuesExpectationSchema() {
return Type.Object(
{
exact: Type.Optional(Type.Array(Type.String())),
exclude: Type.Optional(Type.Array(Type.String())),
include: Type.Optional(Type.Array(Type.String())),
},
{ additionalProperties: false },
);
}
function createRecordTypeSchema() {
return Type.Union(RECORD_TYPES.map((t) => Type.Literal(t)));
}

View File

@@ -0,0 +1,349 @@
import type { DnsResponse } from "./codec";
import { parseResponse } from "./codec";
export type DnsQueryResult = DnsTransportError | DnsTransportResult;
export interface DnsTransportError {
error: string;
ok: false;
}
export interface DnsTransportResult {
data: Uint8Array;
ok: true;
protocolUsed: "tcp" | "udp";
response: DnsResponse;
}
interface QueryMeta {
id: number;
name: string;
qclass: number;
qtype: number;
}
export async function queryDns(
server: string,
port: number,
query: Uint8Array,
options: {
maxResponseBytes: number;
protocol: "tcp" | "udp";
signal: AbortSignal;
tcpFallback: boolean;
},
): Promise<DnsQueryResult> {
if (options.protocol === "tcp") {
return queryTcp(server, port, query, options.signal, options.maxResponseBytes);
}
const udpResult = await queryUdp(server, port, query, options.signal, options.maxResponseBytes);
if (!udpResult.ok) return udpResult;
if (udpResult.response.header.flags.truncated && options.tcpFallback) {
const tcpResult = await queryTcp(server, port, query, options.signal, options.maxResponseBytes);
if (tcpResult.ok) {
return { ...tcpResult, protocolUsed: "tcp" };
}
return udpResult;
}
return udpResult;
}
function mergeChunks(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 parseAndValidateResponse(query: Uint8Array, payload: Uint8Array, protocolUsed: "tcp" | "udp"): DnsQueryResult {
try {
const response = parseResponse(payload);
const validationError = validateResponseForQuery(query, response);
if (validationError) {
return { error: validationError, ok: false };
}
return { data: payload, ok: true, protocolUsed, response };
} catch (e) {
return { error: `DNS 响应解析失败: ${e instanceof Error ? e.message : String(e)}`, ok: false };
}
}
async function queryTcp(
server: string,
port: number,
query: Uint8Array,
signal: AbortSignal,
maxResponseBytes: number,
): Promise<DnsQueryResult> {
const chunks: Uint8Array[] = [];
let totalBytes = 0;
let settled = false;
let resolver: ((value: DnsQueryResult) => void) | undefined;
const promise = new Promise<DnsQueryResult>((resolve) => {
resolver = resolve;
});
const settle = (result: DnsQueryResult) => {
if (settled) return;
settled = true;
resolver!(result);
};
const socketHandlers: Record<string, (...args: unknown[]) => void> = {
close() {
if (totalBytes >= 2) {
const full = mergeChunks(chunks, totalBytes);
const view = new DataView(full.buffer, full.byteOffset, full.byteLength);
const respLen = view.getUint16(0);
const payloadLen = Math.min(respLen, maxResponseBytes);
if (totalBytes - 2 >= payloadLen) {
if (respLen > maxResponseBytes) {
settle({ error: `TCP 响应超过 ${maxResponseBytes} 字节限制 (${respLen} bytes)`, ok: false });
return;
}
const payload = full.subarray(2, 2 + payloadLen);
settle(parseAndValidateResponse(query, payload, "tcp"));
} else {
settle({ error: `TCP 响应不完整: 期望 ${respLen} 字节,收到 ${totalBytes - 2} 字节`, ok: false });
}
} else {
settle({ error: "TCP 连接关闭,未收到响应", ok: false });
}
},
data(_socket: unknown, data: unknown) {
const buf = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer);
if (totalBytes + buf.byteLength > maxResponseBytes + 2) {
const trimmed = buf.subarray(0, maxResponseBytes + 2 - totalBytes);
if (trimmed.byteLength > 0) {
chunks.push(new Uint8Array(trimmed));
totalBytes += trimmed.byteLength;
}
} else {
chunks.push(new Uint8Array(buf));
totalBytes += buf.byteLength;
}
if (totalBytes >= 2) {
const full = mergeChunks(chunks, totalBytes);
const view = new DataView(full.buffer, full.byteOffset, full.byteLength);
const respLen = view.getUint16(0);
const payloadLen = Math.min(respLen, maxResponseBytes);
if (totalBytes - 2 >= payloadLen) {
try {
(_socket as { close(): void }).close();
} catch {
/* best-effort */
}
}
}
},
error(_socket: unknown, error: unknown) {
settle({ error: error instanceof Error ? error.message : String(error), ok: false });
},
open() {
// Bun socket handler 必填项,连接成功由 Bun.connect() resolve 表示
},
};
const onAbort = () => {
settle({ error: "探测超时", ok: false });
};
signal.addEventListener("abort", onAbort, { once: true });
try {
const socket = await Bun.connect({
hostname: server,
port,
socket: socketHandlers,
});
if (signal.aborted) {
try {
socket.close();
} catch {
/* best-effort */
}
signal.removeEventListener("abort", onAbort);
return promise;
}
const lengthBuf = new Uint8Array(2);
new DataView(lengthBuf.buffer).setUint16(0, query.byteLength);
socket.write(lengthBuf);
socket.write(query);
const result = await promise;
signal.removeEventListener("abort", onAbort);
try {
socket.close();
} catch {
/* best-effort */
}
return result;
} catch (error) {
signal.removeEventListener("abort", onAbort);
if (signal.aborted) {
return { error: "探测超时", ok: false };
}
const message = error instanceof Error ? error.message : String(error);
return { error: simplifyError(message), ok: false };
}
}
async function queryUdp(
server: string,
port: number,
query: Uint8Array,
signal: AbortSignal,
maxResponseBytes: number,
): Promise<DnsQueryResult> {
try {
const socket = await Bun.udpSocket({
connect: { hostname: server, port },
socket: {
data(socket, data) {
if (data.byteLength > maxResponseBytes) {
settle({ error: `UDP 响应超过 ${maxResponseBytes} 字节限制 (${data.byteLength} bytes)`, type: "error" });
try {
socket.close();
} catch {
/* best-effort */
}
return;
}
settle({ data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength), type: "data" });
try {
socket.close();
} catch {
/* best-effort */
}
},
drain() {
// Bun UDP socket handler 必填项DNS checker 不关注 drain 事件
},
error(_socket, error) {
settle({ error: error.message, type: "error" });
try {
_socket.close();
} catch {
/* best-effort */
}
},
},
});
if (signal.aborted) {
try {
socket.close();
} catch {
/* best-effort */
}
return { error: "探测已取消", ok: false };
}
let settled = false;
let resolver: ((value: { data?: Uint8Array; error?: string; type: string }) => void) | undefined;
const promise = new Promise<{ data?: Uint8Array; error?: string; type: string }>((resolve) => {
resolver = resolve;
});
const settle = (result: { data?: Uint8Array; error?: string; type: string }) => {
if (settled) return;
settled = true;
resolver!(result);
};
const onAbort = () => {
settle({ type: "abort" });
try {
socket.close();
} catch {
/* best-effort */
}
};
signal.addEventListener("abort", onAbort, { once: true });
socket.send(query);
const result = await promise;
signal.removeEventListener("abort", onAbort);
if (result.type === "error") {
return { error: result.error ?? "UDP 查询失败", ok: false };
}
if (result.type === "abort") {
return { error: "探测超时", ok: false };
}
if (!result.data) {
return { error: "未收到 UDP 响应", ok: false };
}
return parseAndValidateResponse(query, result.data, "udp");
} catch (error) {
if (signal.aborted) {
return { error: "探测超时", ok: false };
}
const message = error instanceof Error ? error.message : String(error);
return { error: simplifyError(message), ok: false };
}
}
function readQueryMeta(query: Uint8Array): null | QueryMeta {
if (query.byteLength < 12) return null;
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const id = view.getUint16(0);
const labels: string[] = [];
let offset = 12;
while (true) {
if (offset >= query.byteLength) return null;
const len = query[offset]!;
offset++;
if (len === 0) break;
if ((len & 0xc0) !== 0 || offset + len > query.byteLength) return null;
labels.push(new TextDecoder().decode(query.subarray(offset, offset + len)));
offset += len;
}
if (offset + 4 > query.byteLength) return null;
const qtype = view.getUint16(offset);
const qclass = view.getUint16(offset + 2);
return { id, name: labels.join("."), qclass, qtype };
}
function simplifyError(message: string): string {
const lower = message.toLowerCase();
if (lower.includes("econnrefused") || lower.includes("connection refused")) return "connection refused";
if (lower.includes("enoent") || lower.includes("not found")) return "host not found";
if (lower.includes("etimedout") || lower.includes("timed out")) return "timed out";
if (lower.includes("econnreset") || lower.includes("reset")) return "connection reset";
if (lower.includes("enetwork") || lower.includes("network")) return "network error";
return message;
}
function validateResponseForQuery(query: Uint8Array, response: DnsResponse): null | string {
const meta = readQueryMeta(query);
if (!meta) return "DNS 查询报文不完整";
if (response.header.id !== meta.id) {
return `DNS 响应 ID 不匹配: 期望 ${meta.id},实际 ${response.header.id}`;
}
const question = response.questions[0];
if (question && (question.name !== meta.name || question.qtype !== meta.qtype || question.qclass !== meta.qclass)) {
return "DNS 响应 question 与查询不匹配";
}
return null;
}

View File

@@ -0,0 +1,118 @@
import type {
ContentExpectations,
RawContentExpectations,
RawValueExpectation,
ValueExpectation,
} from "../../expect/types";
import type { ResolvedTargetBase } from "../../types";
export type DnsFamily = "any" | "ipv4" | "ipv6";
export type DnsProtocol = "tcp" | "udp";
export type DnsRecordType = "A" | "AAAA" | "CAA" | "CNAME" | "MX" | "NS" | "PTR" | "SOA" | "SRV" | "TXT";
export type DnsResolver = "server" | "system";
export interface DnsServerConfig {
maxResponseBytes?: number | string;
name: string;
port?: number;
protocol?: DnsProtocol;
recordType?: DnsRecordType;
recursionDesired?: boolean;
resolver: "server";
server: string;
tcpFallback?: boolean;
}
export interface DnsSystemConfig {
family?: DnsFamily;
name: string;
resolver: "system";
}
export type DnsTargetConfig = DnsServerConfig | DnsSystemConfig;
export interface DnsValuesExpectation {
exact?: string[];
exclude?: string[];
include?: string[];
}
export interface RawDnsExpectConfig {
answerCount?: RawValueExpectation;
authenticatedData?: boolean;
authoritative?: boolean;
durationMs?: RawValueExpectation;
rcode?: string[];
recursionAvailable?: boolean;
responded?: boolean;
result?: RawContentExpectations;
truncated?: boolean;
ttlMax?: RawValueExpectation;
ttlMin?: RawValueExpectation;
valueCount?: RawValueExpectation;
values?: DnsValuesExpectation;
}
export interface RawDnsServerExpectConfig extends RawDnsExpectConfig {
responded?: boolean;
}
export interface RawDnsSystemExpectConfig {
durationMs?: RawValueExpectation;
valueCount?: RawValueExpectation;
values?: DnsValuesExpectation;
}
export type ResolvedDnsConfig = ResolvedDnsServerConfig | ResolvedDnsSystemConfig;
export type ResolvedDnsExpectConfig = ResolvedDnsServerExpectConfig | ResolvedDnsSystemExpectConfig;
export interface ResolvedDnsServerConfig {
maxResponseBytes: number;
name: string;
port: number;
protocol: DnsProtocol;
recordType: DnsRecordType;
recursionDesired: boolean;
resolver: "server";
server: string;
tcpFallback: boolean;
}
export interface ResolvedDnsServerExpectConfig {
answerCount?: ValueExpectation;
authenticatedData?: boolean;
authoritative?: boolean;
durationMs?: ValueExpectation;
rcode?: string[];
recursionAvailable?: boolean;
responded: boolean;
result?: ContentExpectations;
truncated?: boolean;
ttlMax?: ValueExpectation;
ttlMin?: ValueExpectation;
valueCount?: ValueExpectation;
values?: DnsValuesExpectation;
}
export interface ResolvedDnsSystemConfig {
family: DnsFamily;
name: string;
resolver: "system";
}
export interface ResolvedDnsSystemExpectConfig {
durationMs?: ValueExpectation;
valueCount?: ValueExpectation;
values?: DnsValuesExpectation;
}
export interface ResolvedDnsTarget extends ResolvedTargetBase {
dns: ResolvedDnsConfig;
expect?: ResolvedDnsExpectConfig;
group: string;
intervalMs: number;
name: null | string;
timeoutMs: number;
type: "dns";
}

View File

@@ -0,0 +1,351 @@
import { isNumber, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { isPlainRecord, validateRawContentExpectations, validateRawValueExpectation } from "../../expect/validate";
import { issue, joinPath } from "../../schema/issues";
import { parseSize } from "../../utils";
const VALID_RECORD_TYPES = new Set(["A", "AAAA", "CAA", "CNAME", "MX", "NS", "PTR", "SOA", "SRV", "TXT"]);
const VALID_FAMILIES = new Set(["any", "ipv4", "ipv6"]);
const VALID_PROTOCOLS = new Set(["tcp", "udp"]);
const VALID_RCODES = new Set([
"FORMERR",
"NOERROR",
"NOTAUTH",
"NOTIMP",
"NOTZONE",
"NXDOMAIN",
"NXRRSET",
"REFUSED",
"SERVFAIL",
"YXDOMAIN",
"YXRRSET",
]);
const SYSTEM_EXPECT_KEYS = new Set(["durationMs", "valueCount", "values"]);
const SERVER_EXPECT_KEYS = new Set([
"answerCount",
"authenticatedData",
"authoritative",
"durationMs",
"rcode",
"recursionAvailable",
"responded",
"result",
"truncated",
"ttlMax",
"ttlMin",
"valueCount",
"values",
]);
const RESPONSE_EXPECT_KEYS = new Set([
"answerCount",
"authenticatedData",
"authoritative",
"rcode",
"recursionAvailable",
"result",
"truncated",
"ttlMax",
"ttlMin",
"valueCount",
"values",
]);
export function validateDnsConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isPlainRecord(target)) continue;
if (target["type"] !== "dns") continue;
issues.push(...validateDnsTarget(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 validateDnsExpect(target: Record<string, unknown>, path: string, resolver: string): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isPlainRecord(expect)) return [];
const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect");
const allowedKeys = resolver === "system" ? SYSTEM_EXPECT_KEYS : SERVER_EXPECT_KEYS;
for (const key of Object.keys(expect)) {
if (!allowedKeys.has(key)) {
if (resolver === "system") {
issues.push(
issue(
"dns-unsupported-expect",
joinPath(expectPath, key),
`不支持在 dns.resolver: system 下使用system 模式仅支持 expect.values、expect.valueCount、expect.durationMs。请改用 resolver: server 或移除该字段`,
targetName,
),
);
} else {
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
}
}
}
if (expect["values"] !== undefined) {
issues.push(...validateDnsValuesExpectation(expect["values"], joinPath(expectPath, "values"), targetName));
}
if (expect["valueCount"] !== undefined) {
issues.push(...validateRawValueExpectation(expect["valueCount"], joinPath(expectPath, "valueCount"), targetName));
}
if (expect["durationMs"] !== undefined) {
issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
}
if (resolver === "server") {
if (expect["responded"] !== undefined && typeof expect["responded"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "responded"), "必须为布尔值", targetName));
}
if (expect["responded"] === false) {
for (const key of Object.keys(expect)) {
if (RESPONSE_EXPECT_KEYS.has(key)) {
issues.push(
issue(
"invalid-value",
joinPath(expectPath, "responded"),
"响应内容或协议断言需要 expect.responded 为 true",
targetName,
),
);
break;
}
}
}
if (expect["rcode"] !== undefined) {
if (!Array.isArray(expect["rcode"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "rcode"), "必须为字符串数组", targetName));
} else {
const rcodeArray = expect["rcode"] as unknown[];
for (let j = 0; j < rcodeArray.length; j++) {
const rcode = rcodeArray[j];
if (!isString(rcode) || !VALID_RCODES.has(rcode)) {
issues.push(
issue(
"invalid-value",
joinPath(expectPath, `rcode[${j}]`),
`必须是有效的 RCODE如 NOERROR、NXDOMAIN、SERVFAIL`,
targetName,
),
);
}
}
}
}
if (expect["answerCount"] !== undefined) {
issues.push(
...validateRawValueExpectation(expect["answerCount"], joinPath(expectPath, "answerCount"), targetName),
);
}
if (expect["ttlMin"] !== undefined) {
issues.push(...validateRawValueExpectation(expect["ttlMin"], joinPath(expectPath, "ttlMin"), targetName));
}
if (expect["ttlMax"] !== undefined) {
issues.push(...validateRawValueExpectation(expect["ttlMax"], joinPath(expectPath, "ttlMax"), targetName));
}
if (expect["authoritative"] !== undefined && typeof expect["authoritative"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "authoritative"), "必须为布尔值", targetName));
}
if (expect["recursionAvailable"] !== undefined && typeof expect["recursionAvailable"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "recursionAvailable"), "必须为布尔值", targetName));
}
if (expect["truncated"] !== undefined && typeof expect["truncated"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "truncated"), "必须为布尔值", targetName));
}
if (expect["authenticatedData"] !== undefined && typeof expect["authenticatedData"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "authenticatedData"), "必须为布尔值", targetName));
}
if (expect["result"] !== undefined) {
issues.push(...validateRawContentExpectations(expect["result"], joinPath(expectPath, "result"), targetName));
}
}
return issues;
}
function validateDnsTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const dns = target["dns"];
if (!isPlainRecord(dns)) {
issues.push(issue("required", joinPath(path, "dns"), "缺少 dns 配置分组", targetName));
return issues;
}
const resolver: unknown = dns["resolver"];
if (resolver === undefined) {
issues.push(issue("required", joinPath(joinPath(path, "dns"), "resolver"), "缺少 dns.resolver 字段", targetName));
return issues;
}
if (resolver !== "system" && resolver !== "server") {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "dns"), "resolver"), "必须为 system 或 server", targetName),
);
return issues;
}
if (!isString(dns["name"]) || dns["name"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "dns"), "name"), "缺少 dns.name 字段", targetName));
}
if (resolver === "system") {
const family: unknown = dns["family"];
if (family !== undefined && (!isString(family) || !VALID_FAMILIES.has(family))) {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "dns"), "family"), "必须为 any、ipv4 或 ipv6", targetName),
);
}
const allowedSystemKeys = new Set(["family", "name", "resolver"]);
for (const key of Object.keys(dns)) {
if (!allowedSystemKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(joinPath(path, "dns"), key), "是未知字段", targetName));
}
}
}
if (resolver === "server") {
if (!isString(dns["server"]) || dns["server"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "dns"), "server"), "缺少 dns.server 字段", targetName));
}
const port: unknown = dns["port"];
if (port !== undefined && (!isNumber(port) || !Number.isInteger(port) || port < 1 || port > 65535)) {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "dns"), "port"), "必须为 1-65535 之间的整数", targetName),
);
}
const protocol: unknown = dns["protocol"];
if (protocol !== undefined && (!isString(protocol) || !VALID_PROTOCOLS.has(protocol))) {
issues.push(issue("invalid-value", joinPath(joinPath(path, "dns"), "protocol"), "必须为 udp 或 tcp", targetName));
}
const recordType: unknown = dns["recordType"];
if (recordType !== undefined && (!isString(recordType) || !VALID_RECORD_TYPES.has(recordType))) {
issues.push(
issue(
"invalid-value",
joinPath(joinPath(path, "dns"), "recordType"),
"必须为 A、AAAA、CAA、CNAME、MX、NS、PTR、SOA、SRV 或 TXT",
targetName,
),
);
}
issues.push(
...validateSize(dns["maxResponseBytes"], joinPath(joinPath(path, "dns"), "maxResponseBytes"), targetName),
);
if (dns["recursionDesired"] !== undefined && typeof dns["recursionDesired"] !== "boolean") {
issues.push(
issue("invalid-type", joinPath(joinPath(path, "dns"), "recursionDesired"), "必须为布尔值", targetName),
);
}
if (dns["tcpFallback"] !== undefined && typeof dns["tcpFallback"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(joinPath(path, "dns"), "tcpFallback"), "必须为布尔值", targetName));
}
const allowedServerKeys = new Set([
"maxResponseBytes",
"name",
"port",
"protocol",
"recordType",
"recursionDesired",
"resolver",
"server",
"tcpFallback",
]);
for (const key of Object.keys(dns)) {
if (!allowedServerKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(joinPath(path, "dns"), key), "是未知字段", targetName));
}
}
}
issues.push(...validateDnsExpect(target, path, resolver));
return issues;
}
function validateDnsValuesExpectation(
value: unknown,
path: string,
targetName: string | undefined,
): ConfigValidationIssue[] {
if (value === undefined || value === null) return [];
if (!isPlainRecord(value)) {
return [issue("invalid-type", path, "必须为包含 include/exclude/exact 的对象", targetName)];
}
const issues: ConfigValidationIssue[] = [];
const allowedKeys = new Set(["exact", "exclude", "include"]);
for (const key of Object.keys(value)) {
if (!allowedKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
}
if (value["exact"] !== undefined) {
if (!Array.isArray(value["exact"])) {
issues.push(issue("invalid-type", joinPath(path, "exact"), "必须为字符串数组", targetName));
}
}
if (value["include"] !== undefined) {
if (!Array.isArray(value["include"])) {
issues.push(issue("invalid-type", joinPath(path, "include"), "必须为字符串数组", targetName));
}
}
if (value["exclude"] !== undefined) {
if (!Array.isArray(value["exclude"])) {
issues.push(issue("invalid-type", joinPath(path, "exclude"), "必须为字符串数组", targetName));
}
}
return issues;
}
function validateSize(value: unknown, path: string, targetName: string | undefined): ConfigValidationIssue[] {
if (value === undefined) return [];
if (!isString(value) && !isNumber(value)) {
return [issue("invalid-value", path, "必须为合法 size 值", targetName)];
}
try {
parseSize(value);
return [];
} catch {
return [issue("invalid-value", path, "必须为合法 size 值", targetName)];
}
}

View File

@@ -1,5 +1,6 @@
import { CommandChecker } from "./cmd"; import { CommandChecker } from "./cmd";
import { DbChecker } from "./db"; import { DbChecker } from "./db";
import { DnsChecker } from "./dns";
import { HttpChecker } from "./http"; import { HttpChecker } from "./http";
import { IcmpChecker } from "./icmp"; import { IcmpChecker } from "./icmp";
import { LlmChecker } from "./llm"; import { LlmChecker } from "./llm";
@@ -15,6 +16,7 @@ const checkers = [
new IcmpChecker(), new IcmpChecker(),
new UdpChecker(), new UdpChecker(),
new LlmChecker(), new LlmChecker(),
new DnsChecker(),
]; ];
export function createDefaultCheckerRegistry(): CheckerRegistry { export function createDefaultCheckerRegistry(): CheckerRegistry {

View File

@@ -743,7 +743,7 @@ targets:
`targets: `targets:
- name: "test" - name: "test"
id: "test" id: "test"
type: dns type: ftp
`, `,
); );
await expectConfigLoadError(configPath, "不支持的 type"); await expectConfigLoadError(configPath, "不支持的 type");

View File

@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import { CommandChecker } from "../../../../src/server/checker/runner/cmd/execute"; import { CommandChecker } from "../../../../src/server/checker/runner/cmd/execute";
import { DbChecker } from "../../../../src/server/checker/runner/db/execute"; import { DbChecker } from "../../../../src/server/checker/runner/db/execute";
import { DnsChecker } from "../../../../src/server/checker/runner/dns/execute";
import { HttpChecker } from "../../../../src/server/checker/runner/http/execute"; import { HttpChecker } from "../../../../src/server/checker/runner/http/execute";
import { IcmpChecker } from "../../../../src/server/checker/runner/icmp/execute"; import { IcmpChecker } from "../../../../src/server/checker/runner/icmp/execute";
import { LlmChecker } from "../../../../src/server/checker/runner/llm/execute"; import { LlmChecker } from "../../../../src/server/checker/runner/llm/execute";
@@ -64,4 +65,142 @@ describe("Checker buildDetail", () => {
expect(detail).toContain("output=2 chars"); expect(detail).toContain("output=2 chars");
expect(detail).toContain("usage=12/2 tokens"); expect(detail).toContain("usage=12/2 tokens");
}); });
const dnsChecker = new DnsChecker();
test("DnsChecker system mode: successful resolution shows addresses and duration", () => {
const detail = dnsChecker.buildDetail({
durationMs: 15,
error: null,
family: "any",
name: "example.com",
resolver: "system",
valueCount: 2,
values: ["93.184.216.34", "93.184.216.35"],
});
expect(detail).toContain("93.184.216.34");
expect(detail).toContain("15ms");
});
test("DnsChecker system mode: failed resolution shows error", () => {
const detail = dnsChecker.buildDetail({
durationMs: 100,
error: "getaddrinfo ENOTFOUND",
family: "any",
name: "example.com",
resolver: "system",
valueCount: 0,
values: [],
});
expect(detail).toContain("解析失败");
expect(detail).toContain("getaddrinfo ENOTFOUND");
});
test("DnsChecker system mode: no results shows '解析成功但无结果'", () => {
const detail = dnsChecker.buildDetail({
durationMs: 10,
error: null,
family: "any",
name: "example.com",
resolver: "system",
valueCount: 0,
values: [],
});
expect(detail).toContain("解析成功但无结果");
});
test("DnsChecker server mode: successful response shows rcode, values, protocol, duration", () => {
const detail = dnsChecker.buildDetail({
durationMs: 25,
error: null,
name: "example.com",
port: 53,
protocol: "udp",
protocolUsed: "udp",
rcode: "NOERROR",
recordType: "A",
resolver: "server",
responded: true,
server: "8.8.8.8",
valueCount: 2,
values: ["1.1.1.1", "2.2.2.2"],
});
expect(detail).toContain("NOERROR");
expect(detail).toContain("1.1.1.1");
expect(detail).toContain("UDP");
expect(detail).toContain("25ms");
});
test("DnsChecker server mode: NXDOMAIN shows rcode", () => {
const detail = dnsChecker.buildDetail({
durationMs: 30,
error: null,
name: "example.com",
port: 53,
protocol: "udp",
protocolUsed: "udp",
rcode: "NXDOMAIN",
recordType: "A",
resolver: "server",
responded: true,
server: "8.8.8.8",
valueCount: 0,
values: [],
});
expect(detail).toContain("NXDOMAIN");
});
test("DnsChecker server mode: no response shows error", () => {
const detail = dnsChecker.buildDetail({
durationMs: 500,
error: "探测超时",
name: "example.com",
port: 53,
protocol: "udp",
resolver: "server",
responded: false,
server: "8.8.8.8",
});
expect(detail).toContain("查询失败");
expect(detail).toContain("探测超时");
});
test("DnsChecker server mode: CNAME chain shows CNAME chain", () => {
const detail = dnsChecker.buildDetail({
cnameChain: ["cdn.example.com", "cdn-edge.example.net"],
durationMs: 40,
error: null,
name: "example.com",
port: 53,
protocol: "udp",
protocolUsed: "udp",
rcode: "NOERROR",
recordType: "A",
resolver: "server",
responded: true,
server: "8.8.8.8",
valueCount: 1,
values: ["93.184.216.34"],
});
expect(detail).toContain("CNAME: cdn.example.com → cdn-edge.example.net");
});
test("DnsChecker server mode: TCP fallback shows TCP in output", () => {
const detail = dnsChecker.buildDetail({
durationMs: 50,
error: null,
name: "example.com",
port: 53,
protocol: "udp",
protocolUsed: "tcp",
rcode: "NOERROR",
recordType: "A",
resolver: "server",
responded: true,
server: "8.8.8.8",
valueCount: 1,
values: ["1.2.3.4"],
});
expect(detail).toContain("TCP");
});
}); });

View File

@@ -0,0 +1,505 @@
import { describe, expect, it } from "bun:test";
import {
buildQuery,
parseResponse,
rcodeName,
rrtTypeByName,
rrtTypeName,
} from "../../../../../src/server/checker/runner/dns/codec";
function buildName(name: string): Uint8Array {
const parts: number[] = [];
for (const label of name.split(".")) {
const encoded = new TextEncoder().encode(label);
parts.push(encoded.length);
parts.push(...encoded);
}
parts.push(0);
return new Uint8Array(parts);
}
function buildResponse(options: {
answers?: Array<{ class?: number; name: string; rdata: Uint8Array; ttl: number; type: number }>;
flags?: { aa?: boolean; ad?: boolean; ra?: boolean; rd?: boolean; tc?: boolean };
questions?: Array<{ name: string; qclass?: number; qtype: number }>;
rcode?: number;
}): Uint8Array {
const questions = options.questions ?? [];
const answers = options.answers ?? [];
const id = 0x1234;
let flags = 0x8000;
if (options.flags?.aa) flags |= 0x0400;
if (options.flags?.tc) flags |= 0x0200;
if (options.flags?.rd) flags |= 0x0100;
if (options.flags?.ra) flags |= 0x0080;
if (options.flags?.ad) flags |= 0x0020;
flags |= (options.rcode ?? 0) & 0x000f;
const header = new Uint8Array(12);
const hv = new DataView(header.buffer);
hv.setUint16(0, id);
hv.setUint16(2, flags);
hv.setUint16(4, questions.length);
hv.setUint16(6, answers.length);
hv.setUint16(8, 0);
hv.setUint16(10, 0);
const qParts: Uint8Array[] = [];
for (const q of questions) {
const nameBytes = buildName(q.name);
const qtype = new Uint8Array(4);
const qv = new DataView(qtype.buffer);
qv.setUint16(0, q.qtype);
qv.setUint16(2, q.qclass ?? 1);
qParts.push(nameBytes, qtype);
}
const aParts: Uint8Array[] = [];
for (const a of answers) {
const nameBytes = buildName(a.name);
const rrHead = new Uint8Array(10);
const rv = new DataView(rrHead.buffer);
rv.setUint16(0, a.type);
rv.setUint16(2, a.class ?? 1);
rv.setUint32(4, a.ttl);
rv.setUint16(8, a.rdata.length);
aParts.push(nameBytes, rrHead, a.rdata);
}
const allParts = [header, ...qParts, ...aParts];
const totalLen = allParts.reduce((s, p) => s + p.length, 0);
const result = new Uint8Array(totalLen);
let offset = 0;
for (const part of allParts) {
result.set(part, offset);
offset += part.length;
}
return result;
}
describe("buildQuery", () => {
it("produces a query buffer with correct header (QR=0, OPCODE=0, RD flag)", () => {
const buf = buildQuery("example.com", 1, true);
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
expect(buf.length).toBeGreaterThan(12);
const flags = view.getUint16(2);
expect(flags & 0x8000).toBe(0);
expect(flags & 0x7800).toBe(0);
expect(flags & 0x0100).toBe(0x0100);
expect(view.getUint16(4)).toBe(1);
expect(view.getUint16(6)).toBe(0);
expect(view.getUint16(8)).toBe(0);
expect(view.getUint16(10)).toBe(0);
});
it("encodes domain name correctly (labels with length prefixes, null terminator)", () => {
const buf = buildQuery("example.com", 1, false);
const nameBytes = buf.subarray(12, buf.length - 4);
const expected = buildName("example.com");
expect(Array.from(nameBytes)).toEqual(Array.from(expected));
});
it("encodes trailing-dot FQDN without an extra root label", () => {
const normal = buildQuery("example.com", 1, false);
const fqdn = buildQuery("example.com.", 1, false);
expect(Array.from(fqdn.subarray(12))).toEqual(Array.from(normal.subarray(12)));
});
it("encodes root domain", () => {
const buf = buildQuery(".", 1, false);
expect(Array.from(buf.subarray(12, buf.length - 4))).toEqual([0]);
});
it("rejects non-ASCII names instead of writing malformed UTF-8 labels", () => {
expect(() => buildQuery("é.example", 1, false)).toThrow("ASCII/Punycode");
});
it("rejects labels longer than 63 bytes", () => {
expect(() => buildQuery(`${"a".repeat(64)}.example`, 1, false)).toThrow("63 字节");
});
it("sets correct QTYPE and QCLASS", () => {
const buf = buildQuery("example.com", 28, false);
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
const qtypeOffset = buf.length - 4;
expect(view.getUint16(qtypeOffset)).toBe(28);
expect(view.getUint16(qtypeOffset + 2)).toBe(1);
});
it("sets RD=1 when recursionDesired=true, RD=0 when false", () => {
const bufRD = buildQuery("example.com", 1, true);
const viewRD = new DataView(bufRD.buffer, bufRD.byteOffset, bufRD.byteLength);
expect(viewRD.getUint16(2) & 0x0100).toBe(0x0100);
const bufNoRD = buildQuery("example.com", 1, false);
const viewNoRD = new DataView(bufNoRD.buffer, bufNoRD.byteOffset, bufNoRD.byteLength);
expect(viewNoRD.getUint16(2) & 0x0100).toBe(0);
});
});
describe("parseResponse", () => {
it("parses a minimal NOERROR response (no answers)", () => {
const resp = buildResponse({
questions: [{ name: "example.com", qtype: 1 }],
rcode: 0,
});
const result = parseResponse(resp);
expect(result.header.rcode).toBe(0);
expect(result.header.answerCount).toBe(0);
expect(result.answers).toHaveLength(0);
expect(result.questions).toHaveLength(1);
expect(result.questions[0]!.name).toBe("example.com");
expect(result.questions[0]!.qtype).toBe(1);
});
it("parses response with single A record", () => {
const rdata = new Uint8Array([93, 184, 216, 34]);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 300, type: 1 }],
questions: [{ name: "example.com", qtype: 1 }],
rcode: 0,
});
const result = parseResponse(resp);
expect(result.answers).toHaveLength(1);
const a = result.answers[0]!;
expect(a.type).toBe(1);
expect(a.value).toBe("93.184.216.34");
expect(a.ttl).toBe(300);
expect(a.name).toBe("example.com");
});
it("parses response with multiple A records", () => {
const rdata1 = new Uint8Array([1, 1, 1, 1]);
const rdata2 = new Uint8Array([8, 8, 8, 8]);
const resp = buildResponse({
answers: [
{ name: "example.com", rdata: rdata1, ttl: 60, type: 1 },
{ name: "example.com", rdata: rdata2, ttl: 60, type: 1 },
],
rcode: 0,
});
const result = parseResponse(resp);
expect(result.answers).toHaveLength(2);
expect(result.answers[0]!.value).toBe("1.1.1.1");
expect(result.answers[1]!.value).toBe("8.8.8.8");
});
it("parses AAAA record", () => {
const rdata = new Uint8Array([
0x26, 0x07, 0xf8, 0xb0, 0x40, 0x05, 0x08, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
]);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 300, type: 28 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("2607:f8b0:4005:80c::1");
});
it("parses CNAME record", () => {
const rdata = buildName("www.example.com");
const resp = buildResponse({
answers: [{ name: "alias.example.com", rdata, ttl: 300, type: 5 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("www.example.com");
expect(result.answers[0]!.data["target"]).toBe("www.example.com");
});
it("parses MX record (preference + exchange)", () => {
const exchange = buildName("mail.example.com");
const rdata = new Uint8Array(2 + exchange.length);
rdata[0] = 0x00;
rdata[1] = 0x0a;
rdata.set(exchange, 2);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 3600, type: 15 }],
});
const result = parseResponse(resp);
const mx = result.answers[0]!;
expect(mx.value).toBe("10 mail.example.com");
expect(mx.data["preference"]).toBe(10);
expect(mx.data["exchange"]).toBe("mail.example.com");
});
it("parses TXT record with single character-string", () => {
const txt = new TextEncoder().encode("v=spf1 include:_spf.example.com ~all");
const rdata = new Uint8Array(1 + txt.length);
rdata[0] = txt.length;
rdata.set(txt, 1);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 300, type: 16 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("v=spf1 include:_spf.example.com ~all");
});
it("parses TXT record with multiple character-strings", () => {
const s1 = new TextEncoder().encode("hello ");
const s2 = new TextEncoder().encode("world");
const rdata = new Uint8Array(1 + s1.length + 1 + s2.length);
rdata[0] = s1.length;
rdata.set(s1, 1);
rdata[1 + s1.length] = s2.length;
rdata.set(s2, 1 + s1.length + 1);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 300, type: 16 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("hello world");
});
it("parses NS record", () => {
const rdata = buildName("ns1.example.com");
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 86400, type: 2 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("ns1.example.com");
expect(result.answers[0]!.data["nsdname"]).toBe("ns1.example.com");
});
it("parses SOA record (all 7 fields)", () => {
const mname = buildName("ns1.example.com");
const rname = buildName("admin.example.com");
const fixed = new Uint8Array(20);
const fv = new DataView(fixed.buffer);
fv.setUint32(0, 2024010101);
fv.setUint32(4, 3600);
fv.setUint32(8, 900);
fv.setUint32(12, 604800);
fv.setUint32(16, 86400);
const rdata = new Uint8Array(mname.length + rname.length + fixed.length);
rdata.set(mname, 0);
rdata.set(rname, mname.length);
rdata.set(fixed, mname.length + rname.length);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 3600, type: 6 }],
});
const result = parseResponse(resp);
const soa = result.answers[0]!;
expect(soa.data["mname"]).toBe("ns1.example.com");
expect(soa.data["rname"]).toBe("admin.example.com");
expect(soa.data["serial"]).toBe(2024010101);
expect(soa.data["refresh"]).toBe(3600);
expect(soa.data["retry"]).toBe(900);
expect(soa.data["expire"]).toBe(604800);
expect(soa.data["minimum"]).toBe(86400);
expect(soa.value).toBe("ns1.example.com admin.example.com 2024010101 3600 900 604800 86400");
});
it("parses SRV record (priority, weight, port, target)", () => {
const target = buildName("server.example.com");
const rdata = new Uint8Array(6 + target.length);
const rv = new DataView(rdata.buffer);
rv.setUint16(0, 10);
rv.setUint16(2, 20);
rv.setUint16(4, 443);
rdata.set(target, 6);
const resp = buildResponse({
answers: [{ name: "_https._tcp.example.com", rdata, ttl: 300, type: 33 }],
});
const result = parseResponse(resp);
const srv = result.answers[0]!;
expect(srv.data["priority"]).toBe(10);
expect(srv.data["weight"]).toBe(20);
expect(srv.data["port"]).toBe(443);
expect(srv.data["target"]).toBe("server.example.com");
expect(srv.value).toBe("10 20 443 server.example.com");
});
it("parses PTR record", () => {
const rdata = buildName("host.example.com");
const resp = buildResponse({
answers: [{ name: "1.0.0.127.in-addr.arpa", rdata, ttl: 300, type: 12 }],
questions: [{ name: "1.0.0.127.in-addr.arpa", qtype: 12 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("host.example.com");
expect(result.answers[0]!.data["ptrdname"]).toBe("host.example.com");
});
it("parses CAA record (flags, tag, value)", () => {
const tag = new TextEncoder().encode("issue");
const value = new TextEncoder().encode("letsencrypt.org");
const rdata = new Uint8Array(2 + tag.length + value.length);
rdata[0] = 0;
rdata[1] = tag.length;
rdata.set(tag, 2);
rdata.set(value, 2 + tag.length);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 300, type: 257 }],
});
const result = parseResponse(resp);
const caa = result.answers[0]!;
expect(caa.data["flags"]).toBe(0);
expect(caa.data["tag"]).toBe("issue");
expect(caa.data["valueStr"]).toBe("letsencrypt.org");
expect(caa.value).toBe("0 issue letsencrypt.org");
});
it("parses response with DNS name compression (pointer)", () => {
const nameBytes = buildName("example.com");
const nameLen = nameBytes.length;
const headerSize = 12;
const qSectionEnd = headerSize + nameLen + 4;
const totalSize = qSectionEnd + nameLen + 10 + 4;
const buf = new Uint8Array(totalSize);
const view = new DataView(buf.buffer);
view.setUint16(0, 0xabcd);
view.setUint16(2, 0x8180);
view.setUint16(4, 1);
view.setUint16(6, 1);
view.setUint16(8, 0);
view.setUint16(10, 0);
buf.set(nameBytes, headerSize);
view.setUint16(headerSize + nameLen, 1);
view.setUint16(headerSize + nameLen + 2, 1);
const ptrByte1 = 0xc0 | ((headerSize >> 8) & 0x3f);
const ptrByte2 = headerSize & 0xff;
buf[qSectionEnd] = ptrByte1;
buf[qSectionEnd + 1] = ptrByte2;
view.setUint16(qSectionEnd + 2, 1);
view.setUint16(qSectionEnd + 4, 1);
view.setUint32(qSectionEnd + 6, 300);
view.setUint16(qSectionEnd + 10, 4);
buf[qSectionEnd + 12] = 93;
buf[qSectionEnd + 13] = 184;
buf[qSectionEnd + 14] = 216;
buf[qSectionEnd + 15] = 34;
const result = parseResponse(buf);
expect(result.answers[0]!.name).toBe("example.com");
expect(result.answers[0]!.value).toBe("93.184.216.34");
});
it("correctly reports flags (AA, RA, RD, TC, AD)", () => {
const resp = buildResponse({
flags: { aa: true, ad: true, ra: true, rd: true, tc: false },
});
const result = parseResponse(resp);
expect(result.header.flags.authoritative).toBe(true);
expect(result.header.flags.recursionAvailable).toBe(true);
expect(result.header.flags.recursionDesired).toBe(true);
expect(result.header.flags.truncated).toBe(false);
expect(result.header.flags.authenticatedData).toBe(true);
});
it("reports correct rcode", () => {
const resp = buildResponse({ rcode: 3 });
const result = parseResponse(resp);
expect(result.header.rcode).toBe(3);
});
it("reports correct answerCount, authorityCount, additionalCount", () => {
const a1 = new Uint8Array([1, 2, 3, 4]);
const a2 = new Uint8Array([5, 6, 7, 8]);
const header = new Uint8Array(12);
const hv = new DataView(header.buffer);
hv.setUint16(0, 0x0001);
hv.setUint16(2, 0x8180);
hv.setUint16(4, 0);
hv.setUint16(6, 1);
hv.setUint16(8, 1);
hv.setUint16(10, 1);
const nameBytes = buildName("example.com");
const buildRR = (rdata: Uint8Array) => {
const rr = new Uint8Array(nameBytes.length + 10 + rdata.length);
rr.set(nameBytes, 0);
const rv = new DataView(rr.buffer);
rv.setUint16(nameBytes.length, 1);
rv.setUint16(nameBytes.length + 2, 1);
rv.setUint32(nameBytes.length + 4, 60);
rv.setUint16(nameBytes.length + 8, rdata.length);
rr.set(rdata, nameBytes.length + 10);
return rr;
};
const rr1 = buildRR(a1);
const rr2 = buildRR(a2);
const rr3 = buildRR(new Uint8Array([9, 10, 11, 12]));
const buf = new Uint8Array(12 + rr1.length + rr2.length + rr3.length);
buf.set(header, 0);
buf.set(rr1, 12);
buf.set(rr2, 12 + rr1.length);
buf.set(rr3, 12 + rr1.length + rr2.length);
const result = parseResponse(buf);
expect(result.header.answerCount).toBe(1);
expect(result.header.authorityCount).toBe(1);
expect(result.header.additionalCount).toBe(1);
expect(result.answers).toHaveLength(1);
expect(result.authorities).toHaveLength(1);
expect(result.additional).toHaveLength(1);
});
it("throws on response shorter than 12 bytes", () => {
expect(() => parseResponse(new Uint8Array(11))).toThrow();
expect(() => parseResponse(new Uint8Array(0))).toThrow();
});
it("handles unknown record types (raw hex in value)", () => {
const rdata = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 300, type: 999 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("de ad be ef");
expect(result.answers[0]!.data["raw"]).toEqual([0xde, 0xad, 0xbe, 0xef]);
});
});
describe("rcodeName", () => {
it("maps known rcode numbers to names", () => {
expect(rcodeName(0)).toBe("NOERROR");
expect(rcodeName(1)).toBe("FORMERR");
expect(rcodeName(2)).toBe("SERVFAIL");
expect(rcodeName(3)).toBe("NXDOMAIN");
expect(rcodeName(4)).toBe("NOTIMP");
expect(rcodeName(5)).toBe("REFUSED");
});
it("returns UNKNOWN(n) for unknown codes", () => {
expect(rcodeName(99)).toBe("UNKNOWN(99)");
expect(rcodeName(255)).toBe("UNKNOWN(255)");
});
});
describe("rrtTypeName", () => {
it("maps known type numbers to names", () => {
expect(rrtTypeName(1)).toBe("A");
expect(rrtTypeName(28)).toBe("AAAA");
expect(rrtTypeName(5)).toBe("CNAME");
expect(rrtTypeName(15)).toBe("MX");
expect(rrtTypeName(16)).toBe("TXT");
expect(rrtTypeName(2)).toBe("NS");
expect(rrtTypeName(6)).toBe("SOA");
expect(rrtTypeName(33)).toBe("SRV");
expect(rrtTypeName(12)).toBe("PTR");
expect(rrtTypeName(257)).toBe("CAA");
});
});
describe("rrtTypeByName", () => {
it("maps known type names to numbers", () => {
expect(rrtTypeByName("A")).toBe(1);
expect(rrtTypeByName("AAAA")).toBe(28);
expect(rrtTypeByName("CNAME")).toBe(5);
expect(rrtTypeByName("MX")).toBe(15);
expect(rrtTypeByName("TXT")).toBe(16);
expect(rrtTypeByName("NS")).toBe(2);
expect(rrtTypeByName("SOA")).toBe(6);
expect(rrtTypeByName("SRV")).toBe(33);
expect(rrtTypeByName("PTR")).toBe(12);
expect(rrtTypeByName("CAA")).toBe(257);
});
it("throws for unknown type name", () => {
expect(() => rrtTypeByName("UNKNOWN_TYPE")).toThrow();
});
});

View File

@@ -0,0 +1,539 @@
import { describe, expect, it } from "bun:test";
import type {
ResolvedDnsServerExpectConfig,
ResolvedDnsSystemExpectConfig,
ResolvedDnsTarget,
} from "../../../../../src/server/checker/runner/dns/types";
import { DnsChecker } from "../../../../../src/server/checker/runner/dns/execute";
function buildName(name: string): Uint8Array {
const parts: number[] = [];
for (const label of name.split(".")) {
const encoded = new TextEncoder().encode(label);
parts.push(encoded.length);
parts.push(...encoded);
}
parts.push(0);
return new Uint8Array(parts);
}
function buildTestResponse(options: {
answers?: Array<{ class?: number; name: string; rdata: Uint8Array; ttl: number; type: number }>;
flags?: { aa?: boolean; ad?: boolean; ra?: boolean; rd?: boolean; tc?: boolean };
id: number;
questions?: Array<{ name: string; qclass?: number; qtype: number }>;
rcode?: number;
}): Uint8Array {
const questions = options.questions ?? [];
const answers = options.answers ?? [];
let flags = 0x8000;
if (options.flags?.aa) flags |= 0x0400;
if (options.flags?.tc) flags |= 0x0200;
if (options.flags?.rd) flags |= 0x0100;
if (options.flags?.ra) flags |= 0x0080;
if (options.flags?.ad) flags |= 0x0020;
flags |= (options.rcode ?? 0) & 0x000f;
const header = new Uint8Array(12);
const hv = new DataView(header.buffer);
hv.setUint16(0, options.id);
hv.setUint16(2, flags);
hv.setUint16(4, questions.length);
hv.setUint16(6, answers.length);
hv.setUint16(8, 0);
hv.setUint16(10, 0);
const qParts: Uint8Array[] = [];
for (const q of questions) {
const nameBytes = buildName(q.name);
const qtype = new Uint8Array(4);
const qv = new DataView(qtype.buffer);
qv.setUint16(0, q.qtype);
qv.setUint16(2, q.qclass ?? 1);
qParts.push(nameBytes, qtype);
}
const aParts: Uint8Array[] = [];
for (const a of answers) {
const nameBytes = buildName(a.name);
const rrHead = new Uint8Array(10);
const rv = new DataView(rrHead.buffer);
rv.setUint16(0, a.type);
rv.setUint16(2, a.class ?? 1);
rv.setUint32(4, a.ttl);
rv.setUint16(8, a.rdata.length);
aParts.push(nameBytes, rrHead, a.rdata);
}
const allParts = [header, ...qParts, ...aParts];
const totalLen = allParts.reduce((s, p) => s + p.length, 0);
const result = new Uint8Array(totalLen);
let offset = 0;
for (const part of allParts) {
result.set(part, offset);
offset += part.length;
}
return result;
}
async function createFakeDnsServer(
respondWith: (query: Uint8Array) => Uint8Array,
): Promise<{ close: () => void; port: number }> {
const socket = await Bun.udpSocket({
socket: {
data(sock, data, port, addr) {
const query = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
const response = respondWith(query);
sock.send(response, port, addr);
},
drain() {
// 必填 handler
void 0;
},
error() {
// 必填 handler
void 0;
},
},
});
return { close: () => socket.close(), port: socket.port };
}
function makeServerTarget(
overrides: Partial<ResolvedDnsTarget["dns"]> = {},
expect?: ResolvedDnsServerExpectConfig,
): ResolvedDnsTarget {
return {
description: null,
dns: {
maxResponseBytes: 4096,
name: "example.com",
port: 53,
protocol: "udp",
recordType: "A",
recursionDesired: true,
resolver: "server",
server: "127.0.0.1",
tcpFallback: false,
...overrides,
} as ResolvedDnsTarget["dns"],
expect,
group: "default",
id: "test-dns-server",
intervalMs: 30000,
name: null,
timeoutMs: 10000,
type: "dns",
};
}
function makeSignal(timeoutMs: number): { cleanup: () => void; signal: AbortSignal } {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
return { cleanup: () => clearTimeout(timer), signal: controller.signal };
}
function makeSystemTarget(
overrides: Partial<ResolvedDnsTarget["dns"]> = {},
expect?: ResolvedDnsSystemExpectConfig,
): ResolvedDnsTarget {
return {
description: null,
dns: {
family: "any",
name: "example.com",
resolver: "system",
...overrides,
} as ResolvedDnsTarget["dns"],
expect,
group: "default",
id: "test-dns-system",
intervalMs: 30000,
name: null,
timeoutMs: 10000,
type: "dns",
};
}
const checker = new DnsChecker();
const resolveContext = { configDir: "/tmp", defaultIntervalMs: 30000, defaultTimeoutMs: 10000 };
describe("DnsChecker resolve", () => {
it("system mode: fills defaults (family=any)", () => {
const target = checker.resolve(
{ dns: { name: "example.com", resolver: "system" }, id: "test", type: "dns" },
resolveContext,
);
expect(target.type).toBe("dns");
expect(target.dns).toMatchObject({ family: "any", name: "example.com", resolver: "system" });
});
it("server mode: fills defaults", () => {
const target = checker.resolve(
{ dns: { name: "example.com", resolver: "server", server: "8.8.8.8" }, id: "test", type: "dns" },
resolveContext,
);
expect(target.dns).toMatchObject({
maxResponseBytes: 4096,
port: 53,
protocol: "udp",
recordType: "A",
recursionDesired: true,
resolver: "server",
server: "8.8.8.8",
tcpFallback: true,
});
});
it("server mode: respects user overrides", () => {
const target = checker.resolve(
{
dns: {
maxResponseBytes: 2048,
name: "example.com",
port: 5353,
protocol: "tcp",
recordType: "AAAA",
recursionDesired: false,
resolver: "server",
server: "1.1.1.1",
tcpFallback: false,
},
id: "test",
type: "dns",
},
resolveContext,
);
expect(target.dns).toMatchObject({
maxResponseBytes: 2048,
port: 5353,
protocol: "tcp",
recordType: "AAAA",
recursionDesired: false,
server: "1.1.1.1",
tcpFallback: false,
});
});
it("both modes: sets type=dns, copies id/name/group, sets intervalMs/timeoutMs from context", () => {
const sysTarget = checker.resolve(
{ dns: { name: "example.com", resolver: "system" }, group: "grp1", id: "t1", name: "my-target", type: "dns" },
resolveContext,
);
expect(sysTarget.type).toBe("dns");
expect(sysTarget.id).toBe("t1");
expect(sysTarget.name).toBe("my-target");
expect(sysTarget.group).toBe("grp1");
expect(sysTarget.intervalMs).toBe(30000);
expect(sysTarget.timeoutMs).toBe(10000);
const srvTarget = checker.resolve(
{
dns: { name: "example.com", resolver: "server", server: "8.8.8.8" },
group: "grp2",
id: "t2",
name: "my-server",
type: "dns",
},
resolveContext,
);
expect(srvTarget.type).toBe("dns");
expect(srvTarget.id).toBe("t2");
expect(srvTarget.name).toBe("my-server");
expect(srvTarget.group).toBe("grp2");
expect(srvTarget.intervalMs).toBe(30000);
expect(srvTarget.timeoutMs).toBe(10000);
});
});
describe("DnsChecker serialize", () => {
it("system mode: returns dns system <name> and JSON config", () => {
const target = makeSystemTarget();
const result = checker.serialize(target);
expect(result.target).toBe("dns system example.com");
const parsed = JSON.parse(result.config) as Record<string, unknown>;
expect(parsed["resolver"]).toBe("system");
expect(parsed["name"]).toBe("example.com");
});
it("server mode: returns dns <server>:<port> <name>/<recordType> and JSON config", () => {
const target = makeServerTarget();
const result = checker.serialize(target);
expect(result.target).toBe("dns 127.0.0.1:53 example.com/A");
const parsed = JSON.parse(result.config) as Record<string, unknown>;
expect(parsed["resolver"]).toBe("server");
expect(parsed["server"]).toBe("127.0.0.1");
});
});
describe("DnsChecker execute (system mode)", () => {
it("localhost IPv4 resolution returns matched=true", async () => {
const target = makeSystemTarget({ family: "ipv4", name: "localhost" });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
expect(result.observation).toMatchObject({ family: "ipv4", resolver: "system" });
expect(result.observation!["valueCount"]).toBeGreaterThan(0);
});
it("pre-aborted signal returns matched=false without real lookup", async () => {
const target = makeSystemTarget({ name: "example.com" });
const controller = new AbortController();
controller.abort();
const result = await checker.execute(target, { signal: controller.signal });
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("resolve");
expect(result.observation).toMatchObject({ resolver: "system", valueCount: 0, values: [] });
});
});
describe("DnsChecker execute (server mode)", () => {
it("successful A record query returns matched=true with correct observation fields", async () => {
const server = await createFakeDnsServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const queryId = view.getUint16(0);
return buildTestResponse({
answers: [{ name: "example.com", rdata: new Uint8Array([93, 184, 216, 34]), ttl: 300, type: 1 }],
id: queryId,
questions: [{ name: "example.com", qtype: 1 }],
});
});
try {
const target = makeServerTarget({ port: server.port });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
expect(result.observation).toMatchObject({
rcode: "NOERROR",
resolver: "server",
responded: true,
});
expect(result.observation!["values"]).toContain("93.184.216.34");
} finally {
server.close();
}
});
it("NXDOMAIN response with expect.rcode=[NXDOMAIN] returns matched=true", async () => {
const server = await createFakeDnsServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const queryId = view.getUint16(0);
return buildTestResponse({
id: queryId,
questions: [{ name: "example.com", qtype: 1 }],
rcode: 3,
});
});
try {
const target = makeServerTarget({ port: server.port }, { rcode: ["NXDOMAIN"], responded: true });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(true);
expect(result.observation!["rcode"]).toBe("NXDOMAIN");
} finally {
server.close();
}
});
it("SERVFAIL response returns matched=false with default expect", async () => {
const server = await createFakeDnsServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const queryId = view.getUint16(0);
return buildTestResponse({
id: queryId,
questions: [{ name: "example.com", qtype: 1 }],
rcode: 2,
});
});
try {
const target = makeServerTarget({ port: server.port });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(false);
expect(result.failure).not.toBeNull();
expect(result.observation!["rcode"]).toBe("SERVFAIL");
} finally {
server.close();
}
});
it("server mode resolved without expect still requires NOERROR by default", async () => {
const server = await createFakeDnsServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const queryId = view.getUint16(0);
return buildTestResponse({
answers: [{ name: "example.com", rdata: new Uint8Array([93, 184, 216, 34]), ttl: 300, type: 1 }],
id: queryId,
questions: [{ name: "example.com", qtype: 1 }],
rcode: 2,
});
});
try {
const target = checker.resolve(
{
dns: { name: "example.com", port: server.port, resolver: "server", server: "127.0.0.1" },
id: "test",
type: "dns",
},
resolveContext,
);
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("rcode");
expect(result.observation!["rcode"]).toBe("SERVFAIL");
} finally {
server.close();
}
});
it("no response (timeout) returns matched=false", async () => {
const server = await createFakeDnsServer(() => new Uint8Array(0));
try {
const target = makeServerTarget({ port: server.port });
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 200);
const result = await checker.execute(target, { signal: controller.signal });
clearTimeout(timer);
expect(result.matched).toBe(false);
expect(result.observation!["responded"]).toBe(false);
} finally {
server.close();
}
});
it("checks values, valueCount, cnameChain for A query", async () => {
const cnameRdata = buildName("cdn.example.com");
const server = await createFakeDnsServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const queryId = view.getUint16(0);
return buildTestResponse({
answers: [
{ name: "example.com", rdata: cnameRdata, ttl: 300, type: 5 },
{ name: "example.com", rdata: new Uint8Array([93, 184, 216, 34]), ttl: 300, type: 1 },
],
id: queryId,
questions: [{ name: "example.com", qtype: 1 }],
});
});
try {
const target = makeServerTarget({ port: server.port });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(true);
const obs = result.observation!;
expect(obs["values"]).toContain("93.184.216.34");
expect(obs["valueCount"]).toBe(1);
expect(obs["cnameChain"]).toEqual(["cdn.example.com"]);
} finally {
server.close();
}
});
it("non-address record values only include requested record type", async () => {
const cnameRdata = buildName("mail-alias.example.com");
const exchange = buildName("mail.example.com");
const mxRdata = new Uint8Array(2 + exchange.length);
const mxView = new DataView(mxRdata.buffer);
mxView.setUint16(0, 10);
mxRdata.set(exchange, 2);
const server = await createFakeDnsServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const queryId = view.getUint16(0);
return buildTestResponse({
answers: [
{ name: "example.com", rdata: cnameRdata, ttl: 300, type: 5 },
{ name: "example.com", rdata: mxRdata, ttl: 300, type: 15 },
],
id: queryId,
questions: [{ name: "example.com", qtype: 15 }],
});
});
try {
const target = makeServerTarget({ port: server.port, recordType: "MX" });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(true);
const obs = result.observation!;
expect(obs["answerCount"]).toBe(2);
expect(obs["valueCount"]).toBe(1);
expect(obs["values"]).toEqual(["10 mail.example.com"]);
} finally {
server.close();
}
});
it("explicit ttlMin fails when response has no answer TTL", async () => {
const server = await createFakeDnsServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const queryId = view.getUint16(0);
return buildTestResponse({
id: queryId,
questions: [{ name: "example.com", qtype: 1 }],
rcode: 3,
});
});
try {
const target = makeServerTarget(
{ port: server.port },
{ rcode: ["NXDOMAIN"], responded: true, ttlMin: { gte: 0 } },
);
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("ttlMin");
} finally {
server.close();
}
});
it("responded=false still checks explicit durationMs", async () => {
const server = await createFakeDnsServer(() => new Uint8Array(0));
try {
const target = makeServerTarget({ port: server.port }, { durationMs: { lt: 0 }, responded: false });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("duration");
expect(result.observation!["responded"]).toBe(false);
} finally {
server.close();
}
});
it("durationMs is present in result", async () => {
const server = await createFakeDnsServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const queryId = view.getUint16(0);
return buildTestResponse({
answers: [{ name: "example.com", rdata: new Uint8Array([1, 1, 1, 1]), ttl: 300, type: 1 }],
id: queryId,
questions: [{ name: "example.com", qtype: 1 }],
});
});
try {
const target = makeServerTarget({ port: server.port });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.durationMs).toBeGreaterThanOrEqual(0);
expect(result.observation!["durationMs"]).toBeGreaterThanOrEqual(0);
} finally {
server.close();
}
});
});

View File

@@ -0,0 +1,226 @@
import { describe, expect, it } from "bun:test";
import type { ContentExpectations } from "../../../../../src/server/checker/expect/types";
import {
checkAnswerCount,
checkDnsValues,
checkFlag,
checkRcode,
checkResponded,
checkResult,
checkTtlMax,
checkTtlMin,
checkValueCount,
} from "../../../../../src/server/checker/runner/dns/expect";
describe("checkResponded", () => {
it("responded=true 期望 true → 匹配", () => {
const result = checkResponded(true, true);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("responded=false 期望 true → 不匹配", () => {
const result = checkResponded(false, true);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("responded");
expect(result.failure!.message).toContain("未收到");
});
it("responded=true 期望 false → 不匹配", () => {
const result = checkResponded(true, false);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("responded");
expect(result.failure!.message).toContain("收到响应");
});
it("responded=false 期望 false → 匹配", () => {
const result = checkResponded(false, false);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
});
describe("checkRcode", () => {
it("NOERROR 在 [NOERROR] → 匹配", () => {
const result = checkRcode("NOERROR", ["NOERROR"]);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("NXDOMAIN 在 [NOERROR] → 不匹配", () => {
const result = checkRcode("NXDOMAIN", ["NOERROR"]);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("rcode");
});
it("NXDOMAIN 在 [NXDOMAIN, SERVFAIL] → 匹配", () => {
const result = checkRcode("NXDOMAIN", ["NXDOMAIN", "SERVFAIL"]);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
});
describe("checkDnsValues", () => {
it("exact: 相同集合不同顺序 → 匹配", () => {
const result = checkDnsValues(["2.2.2.2", "1.1.1.1"], { exact: ["1.1.1.1", "2.2.2.2"] });
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("exact: 不同集合 → 不匹配", () => {
const result = checkDnsValues(["1.1.1.1"], { exact: ["2.2.2.2"] });
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("values");
});
it("exact: 数量不同 → 不匹配", () => {
const result = checkDnsValues(["1.1.1.1", "2.2.2.2"], { exact: ["1.1.1.1"] });
expect(result.matched).toBe(false);
});
it("include: 全部存在 → 匹配", () => {
const result = checkDnsValues(["1.1.1.1", "2.2.2.2"], { include: ["1.1.1.1"] });
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("include: 缺少一个 → 不匹配", () => {
const result = checkDnsValues(["1.1.1.1"], { include: ["3.3.3.3"] });
expect(result.matched).toBe(false);
expect(result.failure!.message).toContain("3.3.3.3");
});
it("exclude: 全不存在 → 匹配", () => {
const result = checkDnsValues(["1.1.1.1"], { exclude: ["3.3.3.3"] });
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("exclude: 存在一个 → 不匹配", () => {
const result = checkDnsValues(["1.1.1.1", "2.2.2.2"], { exclude: ["2.2.2.2"] });
expect(result.matched).toBe(false);
expect(result.failure!.message).toContain("2.2.2.2");
});
it("include + exclude 组合:全部满足 → 匹配", () => {
const result = checkDnsValues(["1.1.1.1", "2.2.2.2"], { exclude: ["3.3.3.3"], include: ["1.1.1.1"] });
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("include + exclude 组合include 失败 → 不匹配", () => {
const result = checkDnsValues(["1.1.1.1"], { exclude: ["3.3.3.3"], include: ["4.4.4.4"] });
expect(result.matched).toBe(false);
});
it("空 expectation → 匹配", () => {
const result = checkDnsValues(["1.1.1.1"], {});
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
});
describe("checkValueCount", () => {
it("count=3 gte=1 → 匹配", () => {
const result = checkValueCount(3, { gte: 1 });
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("count=0 gte=1 → 不匹配", () => {
const result = checkValueCount(0, { gte: 1 });
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("valueCount");
});
});
describe("checkAnswerCount", () => {
it("count=2 gte=2 → 匹配", () => {
const result = checkAnswerCount(2, { gte: 2 });
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("count=1 gte=2 → 不匹配", () => {
const result = checkAnswerCount(1, { gte: 2 });
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("answerCount");
});
});
describe("checkTtlMin", () => {
it("ttl=300 gte=60 → 匹配", () => {
const result = checkTtlMin(300, { gte: 60 });
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("ttl=30 gte=60 → 不匹配", () => {
const result = checkTtlMin(30, { gte: 60 });
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("ttlMin");
});
});
describe("checkTtlMax", () => {
it("ttl=100 lte=3600 → 匹配", () => {
const result = checkTtlMax(100, { lte: 3600 });
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("ttl=5000 lte=3600 → 不匹配", () => {
const result = checkTtlMax(5000, { lte: 3600 });
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("ttlMax");
});
});
describe("checkFlag", () => {
it("匹配 → matched", () => {
const result = checkFlag(true, true, "authoritative");
expect(result!.matched).toBe(true);
expect(result!.failure).toBeNull();
});
it("不匹配 → mismatch", () => {
const result = checkFlag(false, true, "authoritative");
expect(result!.matched).toBe(false);
expect(result!.failure!.kind).toBe("mismatch");
expect(result!.failure!.phase).toBe("authoritative");
});
it("undefined 期望 → 跳过返回 null", () => {
const result = checkFlag(true, undefined, "authoritative");
expect(result).toBeNull();
});
});
describe("checkResult", () => {
it("单条 contains 匹配 JSON 字符串", () => {
const observation = { answers: ["1.1.1.1"], rcode: "NOERROR" };
const expectations: ContentExpectations = [{ kind: "value", matcher: { contains: "NOERROR" } }];
const result = checkResult(observation, expectations);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("单条 contains 不匹配 → 不匹配", () => {
const observation = { rcode: "SERVFAIL" };
const expectations: ContentExpectations = [{ kind: "value", matcher: { contains: "NOERROR" } }];
const result = checkResult(observation, expectations);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("result");
});
});

View File

@@ -0,0 +1,266 @@
import { describe, expect, it } from "bun:test";
import { buildQuery } from "../../../../../src/server/checker/runner/dns/codec";
import { queryDns } from "../../../../../src/server/checker/runner/dns/transport";
function buildName(name: string): Uint8Array {
const parts: number[] = [];
for (const label of name.split(".")) {
const encoded = new TextEncoder().encode(label);
parts.push(encoded.length);
parts.push(...encoded);
}
parts.push(0);
return new Uint8Array(parts);
}
function buildResponse(options: {
answers?: Array<{ class?: number; name: string; rdata: Uint8Array; ttl: number; type: number }>;
flags?: { tc?: boolean };
id: number;
questions?: Array<{ name: string; qclass?: number; qtype: number }>;
rcode?: number;
}): Uint8Array {
const questions = options.questions ?? [];
const answers = options.answers ?? [];
let flags = 0x8000;
if (options.flags?.tc) flags |= 0x0200;
flags |= (options.rcode ?? 0) & 0x000f;
const header = new Uint8Array(12);
const hv = new DataView(header.buffer);
hv.setUint16(0, options.id);
hv.setUint16(2, flags);
hv.setUint16(4, questions.length);
hv.setUint16(6, answers.length);
hv.setUint16(8, 0);
hv.setUint16(10, 0);
const qParts: Uint8Array[] = [];
for (const q of questions) {
const nameBytes = buildName(q.name);
const qtype = new Uint8Array(4);
const qv = new DataView(qtype.buffer);
qv.setUint16(0, q.qtype);
qv.setUint16(2, q.qclass ?? 1);
qParts.push(nameBytes, qtype);
}
const aParts: Uint8Array[] = [];
for (const a of answers) {
const nameBytes = buildName(a.name);
const rrHead = new Uint8Array(10);
const rv = new DataView(rrHead.buffer);
rv.setUint16(0, a.type);
rv.setUint16(2, a.class ?? 1);
rv.setUint32(4, a.ttl);
rv.setUint16(8, a.rdata.length);
aParts.push(nameBytes, rrHead, a.rdata);
}
const allParts = [header, ...qParts, ...aParts];
const totalLen = allParts.reduce((s, p) => s + p.length, 0);
const result = new Uint8Array(totalLen);
let offset = 0;
for (const part of allParts) {
result.set(part, offset);
offset += part.length;
}
return result;
}
function createTcpServer(respondWith: (query: Uint8Array) => Uint8Array, port = 0): { port: number; stop: () => void } {
const states = new WeakMap<object, { chunks: Uint8Array[]; totalBytes: number }>();
const server = Bun.listen({
hostname: "127.0.0.1",
port,
socket: {
data(socket, data) {
const key = socket as object;
const state = states.get(key) ?? { chunks: [], totalBytes: 0 };
const chunk = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
state.chunks.push(chunk);
state.totalBytes += chunk.byteLength;
states.set(key, state);
const full = mergeChunks(state.chunks, state.totalBytes);
if (full.byteLength < 2) return;
const queryLength = new DataView(full.buffer, full.byteOffset, full.byteLength).getUint16(0);
if (full.byteLength < queryLength + 2) return;
const response = respondWith(full.subarray(2, 2 + queryLength));
const lengthPrefix = new Uint8Array(2);
new DataView(lengthPrefix.buffer).setUint16(0, response.byteLength);
socket.write(lengthPrefix);
socket.write(response);
socket.close();
},
error() {
// 测试 server 忽略错误
},
open() {
// Bun.listen 必填 handler
},
},
});
return { port: server.port, stop: () => server.stop() };
}
async function createUdpServer(
respondWith: (query: Uint8Array) => Uint8Array,
port?: number,
): Promise<{ close: () => void; port: number }> {
const socketHandlers = {
data(
sock: { send(data: Uint8Array, port: number, hostname: string): void },
data: Uint8Array,
remotePort: number,
addr: string,
) {
const query = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
sock.send(respondWith(query), remotePort, addr);
},
drain() {
// Bun UDP socket handler 必填项
},
error() {
// 测试 server 忽略错误
},
};
const socket =
port === undefined
? await Bun.udpSocket({ hostname: "127.0.0.1", socket: socketHandlers })
: await Bun.udpSocket({ hostname: "127.0.0.1", port, socket: socketHandlers });
return { close: () => socket.close(), port: socket.port };
}
function makeSignal(timeoutMs: number): { cleanup: () => void; signal: AbortSignal } {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
return { cleanup: () => clearTimeout(timer), signal: controller.signal };
}
function mergeChunks(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;
}
describe("DNS transport", () => {
it("executes TCP DNS query with length-prefixed response", async () => {
const server = createTcpServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const id = view.getUint16(0);
return buildResponse({
answers: [{ name: "example.com", rdata: new Uint8Array([93, 184, 216, 34]), ttl: 300, type: 1 }],
id,
questions: [{ name: "example.com", qtype: 1 }],
});
});
try {
const { cleanup, signal } = makeSignal(5000);
const result = await queryDns("127.0.0.1", server.port, buildQuery("example.com", 1, true), {
maxResponseBytes: 4096,
protocol: "tcp",
signal,
tcpFallback: false,
});
cleanup();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.protocolUsed).toBe("tcp");
expect(result.response.answers[0]!.value).toBe("93.184.216.34");
}
} finally {
server.stop();
}
});
it("falls back from UDP to TCP when response is truncated", async () => {
const tcpServer = createTcpServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const id = view.getUint16(0);
return buildResponse({
answers: [{ name: "example.com", rdata: new Uint8Array([1, 1, 1, 1]), ttl: 60, type: 1 }],
id,
questions: [{ name: "example.com", qtype: 1 }],
});
});
const udpServer = await createUdpServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const id = view.getUint16(0);
return buildResponse({ flags: { tc: true }, id, questions: [{ name: "example.com", qtype: 1 }] });
}, tcpServer.port);
try {
const { cleanup, signal } = makeSignal(5000);
const result = await queryDns("127.0.0.1", tcpServer.port, buildQuery("example.com", 1, true), {
maxResponseBytes: 4096,
protocol: "udp",
signal,
tcpFallback: true,
});
cleanup();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.protocolUsed).toBe("tcp");
expect(result.response.answers[0]!.value).toBe("1.1.1.1");
}
} finally {
udpServer.close();
tcpServer.stop();
}
});
it("rejects UDP responses larger than maxResponseBytes", async () => {
const server = await createUdpServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const id = view.getUint16(0);
return buildResponse({
answers: [{ name: "example.com", rdata: new Uint8Array([93, 184, 216, 34]), ttl: 300, type: 1 }],
id,
questions: [{ name: "example.com", qtype: 1 }],
});
});
try {
const { cleanup, signal } = makeSignal(5000);
const result = await queryDns("127.0.0.1", server.port, buildQuery("example.com", 1, true), {
maxResponseBytes: 8,
protocol: "udp",
signal,
tcpFallback: false,
});
cleanup();
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("超过");
} finally {
server.close();
}
});
it("rejects response ID mismatch", async () => {
const server = await createUdpServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const id = (view.getUint16(0) + 1) & 0xffff;
return buildResponse({ id, questions: [{ name: "example.com", qtype: 1 }] });
});
try {
const { cleanup, signal } = makeSignal(5000);
const result = await queryDns("127.0.0.1", server.port, buildQuery("example.com", 1, true), {
maxResponseBytes: 4096,
protocol: "udp",
signal,
tcpFallback: false,
});
cleanup();
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("ID 不匹配");
} finally {
server.close();
}
});
});

View File

@@ -0,0 +1,473 @@
import { describe, expect, it } from "bun:test";
import type { CheckerValidationInput } from "../../../../../src/server/checker/runner/types";
import { validateDnsConfig } from "../../../../../src/server/checker/runner/dns/validate";
function makeInput(overrides: { targets?: Array<Record<string, unknown>> }): CheckerValidationInput {
return { targets: (overrides.targets ?? []) as CheckerValidationInput["targets"] };
}
describe("validateDnsConfig", () => {
it("接受合法的 system 目标resolver/name/family", () => {
const issues = validateDnsConfig(
makeInput({
targets: [{ dns: { family: "ipv4", name: "example.com", resolver: "system" }, id: "t1", type: "dns" }],
}),
);
expect(issues).toHaveLength(0);
});
it("接受合法的 server 目标resolver/name/server/port/recordType", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: {
name: "example.com",
port: 53,
protocol: "udp",
recordType: "A",
resolver: "server",
server: "8.8.8.8",
},
id: "t2",
type: "dns",
},
],
}),
);
expect(issues).toHaveLength(0);
});
it("拒绝缺少 dns 配置分组", () => {
const issues = validateDnsConfig(makeInput({ targets: [{ id: "t3", type: "dns" }] }));
expect(issues).toHaveLength(1);
expect(issues[0]!.code).toBe("required");
expect(issues[0]!.path).toContain("dns");
expect(issues[0]!.message).toContain("dns");
});
it("拒绝缺少 dns.resolver", () => {
const issues = validateDnsConfig(makeInput({ targets: [{ dns: { name: "example.com" }, id: "t4", type: "dns" }] }));
expect(issues).toHaveLength(1);
expect(issues[0]!.code).toBe("required");
expect(issues[0]!.path).toContain("resolver");
});
it("拒绝无效的 dns.resolver 值", () => {
const issues = validateDnsConfig(
makeInput({ targets: [{ dns: { name: "example.com", resolver: "unknown" }, id: "t5", type: "dns" }] }),
);
expect(issues).toHaveLength(1);
expect(issues[0]!.code).toBe("invalid-value");
expect(issues[0]!.path).toContain("resolver");
expect(issues[0]!.message).toContain("system");
expect(issues[0]!.message).toContain("server");
});
it("拒绝缺少 dns.name", () => {
const issues = validateDnsConfig(makeInput({ targets: [{ dns: { resolver: "system" }, id: "t6", type: "dns" }] }));
expect(issues.length).toBeGreaterThanOrEqual(1);
expect(issues.some((i) => i.code === "required" && i.path.includes("name"))).toBe(true);
});
it("System 模式:拒绝空白 dns.name", () => {
const issues = validateDnsConfig(
makeInput({ targets: [{ dns: { name: " ", resolver: "system" }, id: "t7", type: "dns" }] }),
);
expect(issues.some((i) => i.code === "required" && i.path.includes("name"))).toBe(true);
});
it("System 模式:接受 family any/ipv4/ipv6拒绝无效 family", () => {
for (const family of ["any", "ipv4", "ipv6"]) {
const issues = validateDnsConfig(
makeInput({ targets: [{ dns: { family, name: "x.com", resolver: "system" }, id: "tf", type: "dns" }] }),
);
expect(issues).toHaveLength(0);
}
const issues = validateDnsConfig(
makeInput({ targets: [{ dns: { family: "ipx", name: "x.com", resolver: "system" }, id: "tf", type: "dns" }] }),
);
expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("family"))).toBe(true);
});
it("System 模式:拒绝 dns 中的未知字段", () => {
const issues = validateDnsConfig(
makeInput({
targets: [{ dns: { bogus: true, name: "x.com", resolver: "system" }, id: "t8", type: "dns" }],
}),
);
expect(issues.some((i) => i.code === "unknown-field" && i.path.includes("bogus"))).toBe(true);
});
it("System 模式:拒绝 server 专用字段server/port/protocol/recordType", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", port: 53, protocol: "udp", recordType: "A", resolver: "system", server: "8.8.8.8" },
id: "t9",
type: "dns",
},
],
}),
);
expect(issues.some((i) => i.code === "unknown-field" && i.path.includes("server"))).toBe(true);
expect(issues.some((i) => i.code === "unknown-field" && i.path.includes("port"))).toBe(true);
expect(issues.some((i) => i.code === "unknown-field" && i.path.includes("protocol"))).toBe(true);
expect(issues.some((i) => i.code === "unknown-field" && i.path.includes("recordType"))).toBe(true);
});
it("Server 模式:拒绝缺少 dns.server", () => {
const issues = validateDnsConfig(
makeInput({ targets: [{ dns: { name: "x.com", resolver: "server" }, id: "t10", type: "dns" }] }),
);
expect(issues.some((i) => i.code === "required" && i.path.includes("server"))).toBe(true);
});
it("Server 模式:拒绝无效 port0、-1、65536、1.5", () => {
for (const port of [0, -1, 65536, 1.5]) {
const issues = validateDnsConfig(
makeInput({
targets: [{ dns: { name: "x.com", port, resolver: "server", server: "8.8.8.8" }, id: "tp", type: "dns" }],
}),
);
expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("port"))).toBe(true);
}
});
it("Server 模式:接受 protocol udp/tcp拒绝无效值", () => {
for (const protocol of ["udp", "tcp"]) {
const issues = validateDnsConfig(
makeInput({
targets: [
{ dns: { name: "x.com", protocol, resolver: "server", server: "8.8.8.8" }, id: "tpr", type: "dns" },
],
}),
);
expect(issues).toHaveLength(0);
}
const issues = validateDnsConfig(
makeInput({
targets: [
{ dns: { name: "x.com", protocol: "http", resolver: "server", server: "8.8.8.8" }, id: "tpr", type: "dns" },
],
}),
);
expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("protocol"))).toBe(true);
});
it("Server 模式:接受有效 recordType拒绝无效 recordType", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{ dns: { name: "x.com", recordType: "A", resolver: "server", server: "8.8.8.8" }, id: "trt", type: "dns" },
],
}),
);
expect(issues).toHaveLength(0);
const badIssues = validateDnsConfig(
makeInput({
targets: [
{ dns: { name: "x.com", recordType: "FAKE", resolver: "server", server: "8.8.8.8" }, id: "trt", type: "dns" },
],
}),
);
expect(badIssues.some((i) => i.code === "invalid-value" && i.path.includes("recordType"))).toBe(true);
});
it("Server 模式:接受有效 maxResponseBytes数字和字符串", () => {
const issues1 = validateDnsConfig(
makeInput({
targets: [
{
dns: { maxResponseBytes: 512, name: "x.com", resolver: "server", server: "8.8.8.8" },
id: "tmr",
type: "dns",
},
],
}),
);
expect(issues1).toHaveLength(0);
const issues2 = validateDnsConfig(
makeInput({
targets: [
{
dns: { maxResponseBytes: "1KB", name: "x.com", resolver: "server", server: "8.8.8.8" },
id: "tmr",
type: "dns",
},
],
}),
);
expect(issues2).toHaveLength(0);
});
it("Server 模式:拒绝无效 maxResponseBytes 字符串", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { maxResponseBytes: "1kb", name: "x.com", resolver: "server", server: "8.8.8.8" },
id: "tmr-invalid",
type: "dns",
},
],
}),
);
expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("maxResponseBytes"))).toBe(true);
});
it("Server 模式:拒绝 recursionDesired/tcpFallback 非布尔值", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: {
name: "x.com",
recursionDesired: "false",
resolver: "server",
server: "8.8.8.8",
tcpFallback: "true",
},
id: "tb-dns",
type: "dns",
},
],
}),
);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("recursionDesired"))).toBe(true);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("tcpFallback"))).toBe(true);
});
it("Server 模式:拒绝 dns 中的未知字段", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{ dns: { name: "x.com", resolver: "server", server: "8.8.8.8", unknown: true }, id: "t16", type: "dns" },
],
}),
);
expect(issues.some((i) => i.code === "unknown-field" && i.path.includes("unknown"))).toBe(true);
});
it("System 模式 expect拒绝 rcode/ttlMin/ttlMax/answerCount/authoritative/recursionAvailable/truncated/authenticatedData/result/responded → dns-unsupported-expect", () => {
const serverOnlyFields = [
"rcode",
"ttlMin",
"ttlMax",
"answerCount",
"authoritative",
"recursionAvailable",
"truncated",
"authenticatedData",
"result",
"responded",
];
for (const field of serverOnlyFields) {
const expectObj: Record<string, unknown> = {};
expectObj[field] = field === "rcode" ? ["NOERROR"] : field === "result" ? ["ok"] : true;
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", resolver: "system" },
expect: expectObj,
id: "tse",
name: "sys-target",
type: "dns",
},
],
}),
);
const matched = issues.find((i) => i.code === "dns-unsupported-expect" && i.path.includes(field));
expect(matched).toBeDefined();
expect(matched!.message).toContain("system");
}
});
it("System 模式 expect接受 values/valueCount/durationMs", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", resolver: "system" },
expect: { durationMs: { lte: 100 }, valueCount: { gte: 1 }, values: { exact: ["1.2.3.4"] } },
id: "t18",
type: "dns",
},
],
}),
);
expect(issues).toHaveLength(0);
});
it("Server 模式 expect接受所有 server expect 字段", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", resolver: "server", server: "8.8.8.8" },
expect: {
answerCount: { gte: 1 },
authenticatedData: false,
authoritative: false,
durationMs: { lte: 100 },
rcode: ["NOERROR"],
recursionAvailable: true,
responded: true,
result: [{ contains: "ok" }],
truncated: false,
ttlMax: { lte: 3600 },
ttlMin: { gte: 0 },
valueCount: { gte: 1 },
values: { exact: ["1.2.3.4"] },
},
id: "t19",
type: "dns",
},
],
}),
);
expect(issues).toHaveLength(0);
});
it("Server 模式 expectresponded=false 时拒绝协议级响应断言", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", resolver: "server", server: "8.8.8.8" },
expect: { rcode: ["NOERROR"], responded: false },
id: "trf",
type: "dns",
},
],
}),
);
expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("responded"))).toBe(true);
});
it("Server 模式 expect拒绝未知 expect 字段", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", resolver: "server", server: "8.8.8.8" },
expect: { bogusField: 123 },
id: "t20",
type: "dns",
},
],
}),
);
expect(issues.some((i) => i.code === "unknown-field" && i.path.includes("bogusField"))).toBe(true);
});
it("验证 rcode 值必须为已知 RCODE", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", resolver: "server", server: "8.8.8.8" },
expect: { rcode: ["NOTAREALCODE"] },
id: "trc",
type: "dns",
},
],
}),
);
expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("rcode"))).toBe(true);
});
it("验证布尔字段responded/authoritative 等)", () => {
const fields = ["responded", "authoritative", "recursionAvailable", "truncated", "authenticatedData"];
for (const field of fields) {
const expectObj: Record<string, unknown> = {};
expectObj[field] = "notbool";
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", resolver: "server", server: "8.8.8.8" },
expect: expectObj,
id: "tb",
type: "dns",
},
],
}),
);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes(field))).toBe(true);
}
});
it("验证 ValueExpectation 字段durationMs/valueCount/answerCount/ttlMin/ttlMax", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", resolver: "server", server: "8.8.8.8" },
expect: { answerCount: [4], durationMs: [1, 2], ttlMax: [6], ttlMin: [5], valueCount: [3] },
id: "tve",
type: "dns",
},
],
}),
);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("durationMs"))).toBe(true);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("valueCount"))).toBe(true);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("answerCount"))).toBe(true);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("ttlMin"))).toBe(true);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("ttlMax"))).toBe(true);
});
it("验证 ContentExpectations 字段result", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", resolver: "server", server: "8.8.8.8" },
expect: { result: "not-array" },
id: "tce",
type: "dns",
},
],
}),
);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("result"))).toBe(true);
});
it("验证 DnsValuesExpectationexact/include/exclude 数组)", () => {
const issues = validateDnsConfig(
makeInput({
targets: [
{
dns: { name: "x.com", resolver: "server", server: "8.8.8.8" },
expect: { values: { exact: "not-array", exclude: true, include: 123, unknownKey: "x" } },
id: "tdv",
type: "dns",
},
],
}),
);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("exact"))).toBe(true);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("include"))).toBe(true);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("exclude"))).toBe(true);
expect(issues.some((i) => i.code === "unknown-field" && i.path.includes("unknownKey"))).toBe(true);
});
it("跳过非 dns 类型目标", () => {
const issues = validateDnsConfig(
makeInput({ targets: [{ http: { url: "http://example.com" }, id: "tother", type: "http" }] }),
);
expect(issues).toHaveLength(0);
});
it("跳过非对象目标", () => {
const issues = validateDnsConfig(makeInput({ targets: ["not-an-object" as unknown as Record<string, unknown>] }));
expect(issues).toHaveLength(0);
});
});

View File

@@ -72,8 +72,8 @@ describe("CheckerRegistry", () => {
const second = createDefaultCheckerRegistry(); const second = createDefaultCheckerRegistry();
first.register(createChecker("custom")); first.register(createChecker("custom"));
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "custom"]); expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns", "custom"]);
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm"]); expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns"]);
expect( expect(
first.definitions.every((checker) => checker.schemas.authoring.config && checker.schemas.normalized.expect), first.definitions.every((checker) => checker.schemas.authoring.config && checker.schemas.normalized.expect),
).toBe(true); ).toBe(true);