feat: DNS checker,自研 codec/transport,支持 system/server 双模式,UDP/TCP + TC fallback
This commit is contained in:
@@ -97,6 +97,7 @@ src/
|
||||
tcp/ TCP Checker(自包含模块,含 types/schema/execute/expect/validate)
|
||||
icmp/ ICMP Checker(自包含模块,含 types/schema/execute/expect/validate/parse)
|
||||
udp/ UDP Checker(自包含模块,含 types/schema/execute/expect/validate/encoding)
|
||||
dns/ DNS Checker(自包含模块,含 types/schema/execute/expect/codec/transport)
|
||||
llm/ LLM Checker(自包含模块,含 types/schema/execute/expect/validate/provider/observation)
|
||||
shared/
|
||||
api.ts 前后端共享 TypeScript 类型
|
||||
@@ -648,16 +649,18 @@ expect(logger.entries[0]!.msg).toContain("UP → DOWN");
|
||||
|
||||
**快速失败顺序**:
|
||||
|
||||
| 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` |
|
||||
| LLM http | `status → headers → output → finishReason → rawFinishReason → usage → durationMs` |
|
||||
| LLM stream | `status → headers → stream.completed → stream.firstTokenMs → output → finishReason → rawFinishReason → usage → durationMs` |
|
||||
| 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` |
|
||||
|
||||
HTTP checker 的 `durationMs` 覆盖完整执行(含重定向、按需响应体读取、解码和 expect 校验)。未配置 body expectation、status 失败或 headers 失败时不读取 body;有 body expectation 时,在读取 body 前可先检查 `durationMs` 上界 matcher 是否已不可能通过,避免无意义读取。
|
||||
|
||||
|
||||
127
README.md
127
README.md
@@ -10,11 +10,11 @@
|
||||
|
||||
---
|
||||
|
||||
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP**、**UDP**、**ICMP** 和 **LLM** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite,前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
|
||||
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP**、**UDP**、**DNS**、**ICMP** 和 **LLM** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite,前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
|
||||
|
||||
**功能亮点:**
|
||||
|
||||
- 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/MySQL/SQLite)、TCP(端口可达性 + Banner 探测)、UDP(自定义 payload 请求-响应)、ICMP(存活检测、延迟、丢包率)、LLM(大模型服务应用层健康检查)
|
||||
- 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/MySQL/SQLite)、TCP(端口可达性 + Banner 探测)、UDP(自定义 payload 请求-响应)、DNS(本机解析检查 + DNS server 深度拨测)、ICMP(存活检测、延迟、丢包率)、LLM(大模型服务应用层健康检查)
|
||||
- 丰富的校验规则:状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
|
||||
- 结构化观测数据:检查结果保留按需读取的 HTTP body 预览、TCP/UDP 响应摘要、ICMP 丢包率、CMD 输出预览、LLM token 用量等 observation,便于排障和后续分析
|
||||
- 响应式 Dashboard:实时状态、可用率统计、动态粒度趋势图(avg/P95 + 状态条)、手动/自动刷新、版本号展示
|
||||
@@ -269,7 +269,7 @@ targets: # 拨测目标列表(必填)
|
||||
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 | |
|
||||
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null;前端展示时 null 回退到 `id` | 否 | |
|
||||
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null,允许空字符串 | 否 | |
|
||||
| `type` | 目标类型:`http`、`cmd`、`db`、`tcp`、`udp`、`icmp`、`llm` | 是 | |
|
||||
| `type` | 目标类型:`http`、`cmd`、`db`、`tcp`、`udp`、`dns`、`icmp`、`llm` | 是 | |
|
||||
| `group` | 分组名称 | 否 | `default` |
|
||||
| `interval` | 拨测间隔,未配置时使用内置默认值 `30s` | 否 | `30s` |
|
||||
| `timeout` | 超时时间,未配置时使用内置默认值 `10s` | 否 | `10s` |
|
||||
@@ -523,6 +523,127 @@ ICMP checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS
|
||||
|
||||
---
|
||||
|
||||
### DNS Checker(`type: dns`)
|
||||
|
||||
DNS Checker 支持两种解析模式,通过 `dns.resolver` 字段区分:
|
||||
|
||||
- **`system` 模式**:使用本机 DNS 解析器检查域名是否能解析到预期地址,输出有限 observation。
|
||||
- **`server` 模式**:直接向指定 DNS server 发起 UDP/TCP 深度拨测,检查 DNS 协议级响应(RCODE、TTL、flags、记录值等)。
|
||||
|
||||
#### `dns.resolver: system` 配置项
|
||||
|
||||
| 字段 | 说明 | 必填 | 默认值 |
|
||||
| -------------- | ---------- | ---- | -------- |
|
||||
| `dns.resolver` | 解析模式 | 是 | `system` |
|
||||
| `dns.name` | 待解析域名 | 是 | |
|
||||
| `dns.family` | 地址族 | 否 | `any` |
|
||||
|
||||
`family` 可选值:`any`(返回 IPv4 和 IPv6)、`ipv4`(仅 IPv4)、`ipv6`(仅 IPv6)。
|
||||
|
||||
**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"
|
||||
port: 53
|
||||
protocol: udp
|
||||
name: "api.example.com"
|
||||
recordType: A
|
||||
expect:
|
||||
rcode: ["NOERROR"]
|
||||
values:
|
||||
include:
|
||||
- "203.0.113.10"
|
||||
ttlMin:
|
||||
gte: 60
|
||||
durationMs:
|
||||
lte: 200
|
||||
|
||||
- id: "dns-nxdomain-check"
|
||||
name: "负向 DNS 检查"
|
||||
type: dns
|
||||
dns:
|
||||
resolver: server
|
||||
server: "1.1.1.1"
|
||||
name: "nxdomain.example.com"
|
||||
recordType: A
|
||||
expect:
|
||||
rcode: ["NXDOMAIN"]
|
||||
```
|
||||
|
||||
**Notes**:
|
||||
|
||||
- 未配置 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"`、SOA=空格分隔字段、SRV=`"10 60 443 server.example.com"`、CAA=`"0 issue letsencrypt.org"` 等。
|
||||
|
||||
---
|
||||
|
||||
### LLM Checker(`type: llm`)
|
||||
|
||||
**配置项**
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -207,6 +207,67 @@ targets:
|
||||
durationMs:
|
||||
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 ==========
|
||||
|
||||
- id: "udp-heartbeat"
|
||||
|
||||
440
src/server/checker/runner/dns/codec.ts
Normal file
440
src/server/checker/runner/dns/codec.ts
Normal 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;
|
||||
}
|
||||
901
src/server/checker/runner/dns/execute.ts
Normal file
901
src/server/checker/runner/dns/execute.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
120
src/server/checker/runner/dns/expect.ts
Normal file
120
src/server/checker/runner/dns/expect.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
1
src/server/checker/runner/dns/index.ts
Normal file
1
src/server/checker/runner/dns/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { DnsChecker } from "./execute";
|
||||
107
src/server/checker/runner/dns/schema.ts
Normal file
107
src/server/checker/runner/dns/schema.ts
Normal 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)));
|
||||
}
|
||||
349
src/server/checker/runner/dns/transport.ts
Normal file
349
src/server/checker/runner/dns/transport.ts
Normal 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;
|
||||
}
|
||||
118
src/server/checker/runner/dns/types.ts
Normal file
118
src/server/checker/runner/dns/types.ts
Normal 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";
|
||||
}
|
||||
351
src/server/checker/runner/dns/validate.ts
Normal file
351
src/server/checker/runner/dns/validate.ts
Normal 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)];
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommandChecker } from "./cmd";
|
||||
import { DbChecker } from "./db";
|
||||
import { DnsChecker } from "./dns";
|
||||
import { HttpChecker } from "./http";
|
||||
import { IcmpChecker } from "./icmp";
|
||||
import { LlmChecker } from "./llm";
|
||||
@@ -15,6 +16,7 @@ const checkers = [
|
||||
new IcmpChecker(),
|
||||
new UdpChecker(),
|
||||
new LlmChecker(),
|
||||
new DnsChecker(),
|
||||
];
|
||||
|
||||
export function createDefaultCheckerRegistry(): CheckerRegistry {
|
||||
|
||||
@@ -743,7 +743,7 @@ targets:
|
||||
`targets:
|
||||
- name: "test"
|
||||
id: "test"
|
||||
type: dns
|
||||
type: ftp
|
||||
`,
|
||||
);
|
||||
await expectConfigLoadError(configPath, "不支持的 type");
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { CommandChecker } from "../../../../src/server/checker/runner/cmd/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 { IcmpChecker } from "../../../../src/server/checker/runner/icmp/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("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");
|
||||
});
|
||||
});
|
||||
|
||||
505
tests/server/checker/runner/dns/codec.test.ts
Normal file
505
tests/server/checker/runner/dns/codec.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
539
tests/server/checker/runner/dns/execute.test.ts
Normal file
539
tests/server/checker/runner/dns/execute.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
226
tests/server/checker/runner/dns/expect.test.ts
Normal file
226
tests/server/checker/runner/dns/expect.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
266
tests/server/checker/runner/dns/transport.test.ts
Normal file
266
tests/server/checker/runner/dns/transport.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
473
tests/server/checker/runner/dns/validate.test.ts
Normal file
473
tests/server/checker/runner/dns/validate.test.ts
Normal 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 模式:拒绝无效 port(0、-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 模式 expect:responded=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("验证 DnsValuesExpectation(exact/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);
|
||||
});
|
||||
});
|
||||
@@ -72,8 +72,8 @@ describe("CheckerRegistry", () => {
|
||||
const second = createDefaultCheckerRegistry();
|
||||
first.register(createChecker("custom"));
|
||||
|
||||
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "custom"]);
|
||||
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm"]);
|
||||
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns", "custom"]);
|
||||
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns"]);
|
||||
expect(
|
||||
first.definitions.every((checker) => checker.schemas.authoring.config && checker.schemas.normalized.expect),
|
||||
).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user