From 483cdc596b5c56c34cfe2aea246107f88592d06d Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Sun, 24 May 2026 17:06:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20DNS=20checker=EF=BC=8C=E8=87=AA?= =?UTF-8?q?=E7=A0=94=20codec/transport=EF=BC=8C=E6=94=AF=E6=8C=81=20system?= =?UTF-8?q?/server=20=E5=8F=8C=E6=A8=A1=E5=BC=8F=EF=BC=8CUDP/TCP=20+=20TC?= =?UTF-8?q?=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVELOPMENT.md | 23 +- README.md | 127 ++- probe-config.schema.json | 948 ++++++++++++++++++ probes.example.yaml | 61 ++ src/server/checker/runner/dns/codec.ts | 440 ++++++++ src/server/checker/runner/dns/execute.ts | 901 +++++++++++++++++ src/server/checker/runner/dns/expect.ts | 120 +++ src/server/checker/runner/dns/index.ts | 1 + src/server/checker/runner/dns/schema.ts | 107 ++ src/server/checker/runner/dns/transport.ts | 349 +++++++ src/server/checker/runner/dns/types.ts | 118 +++ src/server/checker/runner/dns/validate.ts | 351 +++++++ src/server/checker/runner/index.ts | 2 + tests/server/checker/config-loader.test.ts | 2 +- tests/server/checker/runner/detail.test.ts | 139 +++ tests/server/checker/runner/dns/codec.test.ts | 505 ++++++++++ .../server/checker/runner/dns/execute.test.ts | 539 ++++++++++ .../server/checker/runner/dns/expect.test.ts | 226 +++++ .../checker/runner/dns/transport.test.ts | 266 +++++ .../checker/runner/dns/validate.test.ts | 473 +++++++++ tests/server/checker/runner/registry.test.ts | 4 +- 21 files changed, 5686 insertions(+), 16 deletions(-) create mode 100644 src/server/checker/runner/dns/codec.ts create mode 100644 src/server/checker/runner/dns/execute.ts create mode 100644 src/server/checker/runner/dns/expect.ts create mode 100644 src/server/checker/runner/dns/index.ts create mode 100644 src/server/checker/runner/dns/schema.ts create mode 100644 src/server/checker/runner/dns/transport.ts create mode 100644 src/server/checker/runner/dns/types.ts create mode 100644 src/server/checker/runner/dns/validate.ts create mode 100644 tests/server/checker/runner/dns/codec.test.ts create mode 100644 tests/server/checker/runner/dns/execute.test.ts create mode 100644 tests/server/checker/runner/dns/expect.test.ts create mode 100644 tests/server/checker/runner/dns/transport.test.ts create mode 100644 tests/server/checker/runner/dns/validate.test.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 47b4f1f..42f38cb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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 是否已不可能通过,避免无意义读取。 diff --git a/README.md b/README.md index eba2aea..e7a48d5 100644 --- a/README.md +++ b/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`) **配置项** diff --git a/probe-config.schema.json b/probe-config.schema.json index d3131ea..136f428 100644 --- a/probe-config.schema.json +++ b/probe-config.schema.json @@ -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" + } + ] + } + } + } + ] + } + } } ] } diff --git a/probes.example.yaml b/probes.example.yaml index 98019df..d16ace6 100644 --- a/probes.example.yaml +++ b/probes.example.yaml @@ -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" diff --git a/src/server/checker/runner/dns/codec.ts b/src/server/checker/runner/dns/codec.ts new file mode 100644 index 0000000..75b20ea --- /dev/null +++ b/src/server/checker/runner/dns/codec.ts @@ -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 = { + 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 = { + [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 = { + 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; + 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 { + 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(); + 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; +} diff --git a/src/server/checker/runner/dns/execute.ts b/src/server/checker/runner/dns/execute.ts new file mode 100644 index 0000000..df94b42 --- /dev/null +++ b/src/server/checker/runner/dns/execute.ts @@ -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 { + readonly configKey = "dns"; + readonly schemas = dnsCheckerSchemas; + readonly type = "dns"; + + buildDetail(observation: Record): 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 { + 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 { + 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 = { + 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(); + 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 = { + 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 { + 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 = { + 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 = { + 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, 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, 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 { + if (signal.aborted) { + return { error: "探测已取消", ok: false }; + } + + return new Promise((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 }); + } + } + }); +} diff --git a/src/server/checker/runner/dns/expect.ts b/src/server/checker/runner/dns/expect.ts new file mode 100644 index 0000000..d430325 --- /dev/null +++ b/src/server/checker/runner/dns/expect.ts @@ -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, + 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", + }); +} diff --git a/src/server/checker/runner/dns/index.ts b/src/server/checker/runner/dns/index.ts new file mode 100644 index 0000000..1ccd644 --- /dev/null +++ b/src/server/checker/runner/dns/index.ts @@ -0,0 +1 @@ +export { DnsChecker } from "./execute"; diff --git a/src/server/checker/runner/dns/schema.ts b/src/server/checker/runner/dns/schema.ts new file mode 100644 index 0000000..661ac45 --- /dev/null +++ b/src/server/checker/runner/dns/schema.ts @@ -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))); +} diff --git a/src/server/checker/runner/dns/transport.ts b/src/server/checker/runner/dns/transport.ts new file mode 100644 index 0000000..ef4917c --- /dev/null +++ b/src/server/checker/runner/dns/transport.ts @@ -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 { + 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 { + const chunks: Uint8Array[] = []; + let totalBytes = 0; + let settled = false; + let resolver: ((value: DnsQueryResult) => void) | undefined; + const promise = new Promise((resolve) => { + resolver = resolve; + }); + + const settle = (result: DnsQueryResult) => { + if (settled) return; + settled = true; + resolver!(result); + }; + + const socketHandlers: Record 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 { + 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; +} diff --git a/src/server/checker/runner/dns/types.ts b/src/server/checker/runner/dns/types.ts new file mode 100644 index 0000000..4b2fc16 --- /dev/null +++ b/src/server/checker/runner/dns/types.ts @@ -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"; +} diff --git a/src/server/checker/runner/dns/validate.ts b/src/server/checker/runner/dns/validate.ts new file mode 100644 index 0000000..0cb8871 --- /dev/null +++ b/src/server/checker/runner/dns/validate.ts @@ -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 | undefined { + if (isString(target["name"])) return target["name"]; + return isString(target["id"]) ? target["id"] : undefined; +} + +function validateDnsExpect(target: Record, 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, 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)]; + } +} diff --git a/src/server/checker/runner/index.ts b/src/server/checker/runner/index.ts index 2f49089..add6bb8 100644 --- a/src/server/checker/runner/index.ts +++ b/src/server/checker/runner/index.ts @@ -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 { diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index 0810469..2ee89e1 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -743,7 +743,7 @@ targets: `targets: - name: "test" id: "test" - type: dns + type: ftp `, ); await expectConfigLoadError(configPath, "不支持的 type"); diff --git a/tests/server/checker/runner/detail.test.ts b/tests/server/checker/runner/detail.test.ts index 6a9b5a0..dcb8a34 100644 --- a/tests/server/checker/runner/detail.test.ts +++ b/tests/server/checker/runner/detail.test.ts @@ -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"); + }); }); diff --git a/tests/server/checker/runner/dns/codec.test.ts b/tests/server/checker/runner/dns/codec.test.ts new file mode 100644 index 0000000..f548378 --- /dev/null +++ b/tests/server/checker/runner/dns/codec.test.ts @@ -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(); + }); +}); diff --git a/tests/server/checker/runner/dns/execute.test.ts b/tests/server/checker/runner/dns/execute.test.ts new file mode 100644 index 0000000..0cd86be --- /dev/null +++ b/tests/server/checker/runner/dns/execute.test.ts @@ -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 = {}, + 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 = {}, + 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 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; + expect(parsed["resolver"]).toBe("system"); + expect(parsed["name"]).toBe("example.com"); + }); + + it("server mode: returns dns : / 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; + 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(); + } + }); +}); diff --git a/tests/server/checker/runner/dns/expect.test.ts b/tests/server/checker/runner/dns/expect.test.ts new file mode 100644 index 0000000..d5e16e4 --- /dev/null +++ b/tests/server/checker/runner/dns/expect.test.ts @@ -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"); + }); +}); diff --git a/tests/server/checker/runner/dns/transport.test.ts b/tests/server/checker/runner/dns/transport.test.ts new file mode 100644 index 0000000..9e8a9e1 --- /dev/null +++ b/tests/server/checker/runner/dns/transport.test.ts @@ -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(); + 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(); + } + }); +}); diff --git a/tests/server/checker/runner/dns/validate.test.ts b/tests/server/checker/runner/dns/validate.test.ts new file mode 100644 index 0000000..1368d87 --- /dev/null +++ b/tests/server/checker/runner/dns/validate.test.ts @@ -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> }): 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 = {}; + 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 = {}; + 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] })); + expect(issues).toHaveLength(0); + }); +}); diff --git a/tests/server/checker/runner/registry.test.ts b/tests/server/checker/runner/registry.test.ts index 8381908..5131355 100644 --- a/tests/server/checker/runner/registry.test.ts +++ b/tests/server/checker/runner/registry.test.ts @@ -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);