多解析器 DNS 查询
多解析器 DNS 查询通过同一个后端请求,向维护的公共解析器列表并发查询指定域名和记录类型,再按运营商与协议展示结果。它用于对照不同解析器的回答,而不是检测浏览器实际使用的 DNS 服务。
目的与范围
本页覆盖域名输入、参数校验、/api/dnsresolver 路由、UDP DNS 与 DNS-over-HTTPS(DoH)调用、记录格式化、国家筛选,以及解析器列表的维护约束。DNS 泄漏检测、IP 查询、全站部署和通用日志基础设施属于相关独立主题,不在这里展开。
本文基于当前读取的实现;未读取通用网络请求封装、域名正则实现和测试正文,因此不推断其内部重试策略、完整域名接受范围或实际测试通过情况。
概述
一次查询选择一种记录类型,但会查询列表中的所有运营商及其已配置协议。当前列表包含 17 个运营商、17 个 UDP 入口和 5 个 DoH 入口,因此一次正常请求生成 22 个结果行。双协议运营商贡献两个独立结果,而不是先尝试 UDP、失败后再回退 DoH。
关键语义如下:
country表示运营商所在地,而不是本次请求命中的 anycast 节点位置。- 查询由后端发出;结果不能解释为从用户所在地开展的全球分布式探测。
- 输出顺序取决于配置顺序,同一运营商先 UDP 后 DoH,不按完成时间排序。
- 单个解析器无答案或查询失败通常变为
N/A,不会让其他正常结果丢失。 - 前端国家筛选只筛选已经返回的结果,不减少后端查询数。
依据:dns-resolvers.js、dns-resolver.js、DnsResolver.vue。
架构
Sources: backend-server.js、guards.js、guards.js、dns-resolver.js、DnsResolver.vue。
这一结构把运营商配置和查询实现分开:DNS_RESOLVERS 决定查谁,resolveDns 与 resolveDoh 负责协议适配,dnsResolver 负责聚合,前端只消费统一结果。增加既有协议的运营商不需要增加前端国家分支。
请求生命周期
1. 输入归一化与界面状态
前端初始状态为:输入为空、记录类型 A、查询状态 idle、国家筛选 all。validateInput(input) 先去掉首尾空白;如果没有匹配到 http:// 或 https:// 前缀,则补上 http://,再用 URL 提取 hostname 并调用 isValidDomain。因此界面可以接受带路径的 URL,但最终只提交主机名,不提交协议、路径或查询字符串。
onSubmit() 先记录 DNSResolver 点击事件,再清除旧错误并校验输入。无效输入显示 dnsresolver.invalidURL,不会调用查询函数;有效输入进入 getDNSResults,清空旧结果、设置 running,再发送请求。
查询期间输入、类型选择和按钮均禁用。成功与失败都会恢复 idle;HTTP 非成功状态或请求、JSON 解析异常会显示统一的 dnsresolver.fetchError。这种禁用是界面层防重复操作,函数本身没有请求序号、取消控制或重入锁。
依据:DnsResolver.vue、DnsResolver.vue。
2. 服务端校验
实际路由依次执行域名校验、类型校验和查询处理器:
app.get('/api/dnsresolver', requireValidDomain('hostname'), requireValidRecordType(), dnsResolver);Source: backend-server.js。
requireValidDomain 将输入转换为小写字符串,调用 isValidDomain,通过后写回 req.query.hostname。requireValidRecordType 对类型转大写,用 DNS_RECORD_TYPE_SET 检查,再写回查询参数。后端不提供记录类型默认值;界面默认 A 不代表 API 允许省略 type。
类型白名单在调用上游之前生效,避免把任意类型字符串直接转发给第三方 DoH 服务。具体集合定义未在本次读取中展开;下文列出的八种类型来自已验证的 UDP 实现分支。
3. 并发扇出与聚合
DNS_RESOLVERS.flatMap 遍历运营商,存在 udp 就启动 resolveDns,存在 doh 就启动 resolveDoh。任务在构建数组时已经启动,Promise.all 只负责等待全部完成。返回结果保留输入任务顺序,因此快慢不会改变显示顺序。
Sources: guards.js、guards.js、dns-resolver.js、DnsResolver.vue。
该接口没有逐行流式响应;用户要等所有任务完成后才看到结果。单个慢解析器仍会影响整体等待时间,因此实现分别设置了 UDP 与 DoH 的请求超时。
协议适配与记录格式化
UDP DNS
每次 resolveDns(hostname, type, name, server) 创建独立的 Resolver,设置 timeout: 3000、tries: 1,并用 setServers([server]) 限定目标。不同运营商不共享这个 Resolver,因此这里不会通过修改全局 DNS 配置互相覆盖目标地址。
| 类型 | Node 调用 | 本接口处理方式 |
|---|---|---|
A | resolve4 | 返回地址数组 |
AAAA | resolve6 | 返回地址数组 |
TXT | resolveTxt | 对二维数组执行 flat() |
CNAME | resolveCname | 返回名称数组,再按名称类型规则补根点 |
NS | resolveNs | 返回名称数组,再按名称类型规则补根点 |
MX | resolveMx | 转成 priority exchange.,以逗号连接 |
SOA | resolveSoa | 交给 formatSoaRecord,生成单个字符串 |
CAA | resolveCaa | 交给 formatCaaRecords,生成单个字符串 |
注意 TXT 的 flat() 会移除原来的记录分组层级;界面又将数组以逗号连接,所以这里的显示不是保留全部原始结构的 DNS 数据导出。
依据:dns-resolver.js。
DoH JSON API
resolveDoh(hostname, type, name, url) 在配置 URL 后直接追加 name 和 type,通过 fetchUpstream 设置 timeoutMs: 5000 与 Accept: application/dns-json。非 2xx HTTP 响应直接返回 N/A;正常响应解析 JSON,提取记录并返回其 data 字段数组。
这里要求 DoH JSON API,不能只提供 RFC 8484 二进制 wire-format 端点。实现没有依据 DNS JSON 中的 Status 字段单独分类错误,而是以记录存在与否和异常情况决定结果。
消除格式差异,而非证明答案一致
UDP 名称往往不带末尾根点,DoH 常带根点。withRootDot 对 NAME_VALUED_TYPES 指定的类型补点,减少视觉上的假差异:
export const withRootDot = (name) => (name.endsWith('.') ? name : `${name}.`);Source: dns-resolver.js。
SOA 的 UDP 格式按 nsname.、hostmaster.、serial、refresh、retry、expire、minttl 顺序拼接。CAA 则从记录对象中寻找第一个非 critical、非 type 的字段作为 tag;缺少 tag 的记录跳过,critical 缺省使用 0,value 通过 JSON.stringify 格式化。因此自定义 CAA tag 不会因为只识别固定 tag 名而直接丢失。
这些是展示归一化措施,不是完整的答案等价判断:实现没有排序记录、去重或对不同解析器的结果进行一致性判定。
SOA 的 Authority 回退
SOA 查询不能简单把 Answer 的每一项都展示成 SOA。别名查询可能在 Answer 里出现 CNAME,真正的区域 SOA 位于 Authority;普通子域名也可能只在权威区段获得区域 SOA。实现先筛选 Answer 中数字类型 6 的记录,找不到时再筛选 Authority:
1export const dohRecords = (data, type) => {
2 if (type !== 'SOA') return data.Answer ?? [];
3 const answers = (data.Answer ?? []).filter((record) => record.type === SOA_RECORD_TYPE);
4 if (answers.length) return answers;
5 return (data.Authority ?? []).filter((record) => record.type === SOA_RECORD_TYPE);
6};Source: dns-resolver.js。
这个回退只适用于 SOA。其他类型使用整个 Answer,没有按请求类型逐项过滤,也不会递归执行新的查询。
API 与结果契约
GET /api/dnsresolver
| 参数 | 位置 | 是否必填 | 处理 |
|---|---|---|---|
hostname | Query | 是 | 服务端转小写,通过域名校验后查询 |
type | Query | 是 | 服务端转大写,通过记录类型白名单后查询 |
正常响应为包含 hostname 与 results 的对象。hostname 是归一化后的域名;results 是按配置顺序排列的结果数组。
| 结果字段 | 实现中的值类型 | 说明 |
|---|---|---|
id | string | 运营商稳定标识;双协议行共享同一 id |
provider | string | 运营商展示名称 |
country | string | 运营商所在地代码,包括允许的 EU |
type | 'udp' 或 'doh' | 传输协议,不是查询的 DNS 记录类型 |
result | string[] 或 string | 记录数组、格式化的 MX/SOA/CAA 字符串,或 N/A |
响应没有单独携带查询记录类型,也没有 TTL、耗时或标准化错误码字段。正常空答案和上游失败都可能表现为同一个字符串 N/A,调用方不能仅凭它判断域名不存在。
依据:dns-resolver.js。
主要函数契约
以下签名使用源码中的 JavaScript 形式,类型描述由已读取的函数体归纳,并非仓库提供的 TypeScript 声明。
| 函数 | 输入 | 返回值与异常边界 |
|---|---|---|
resolveDns(hostname, type, name, server) | 域名、记录类型、运营商名、UDP 地址 | 异步返回数组、格式化字符串或 N/A;查询与格式化异常在内部捕获,Resolver 初始化例外见下文 |
resolveDoh(hostname, type, name, url) | 域名、记录类型、运营商名、DoH URL 前缀 | 异步返回数组或 N/A;请求和解析异常在内部捕获 |
formatSoaRecord(record) | Node SOA 对象 | 返回空格分隔字符串;不做独立字段校验 |
formatCaaRecords(records) | Node CAA 对象数组 | 返回逗号分隔字符串;无 tag 项跳过 |
withRootDot(name) | 名称字符串 | 已带点则原样返回,否则追加 .;非字符串不受保护 |
dohRecords(data, type) | DoH JSON 对象和记录类型 | 返回 Answer 或 SOA 筛选/回退数组;不独立校验 JSON 结构 |
dnsResolver(req, res) | Express 请求与响应 | 发送聚合 JSON、405 或捕获聚合错误后的 500 |
上述协议函数与格式化函数有命名导出,处理器使用默认导出。参数校验依赖路由中间件;绕过路由直接调用处理器不能获得相同的输入保护。依据:dns-resolver.js。
源码使用示例
前端请求入口
下列为实际请求函数中的片段,展示如何提交记录类型,以及为何服务端 HTTP 错误会进入统一的前端错误分支:
1const getDNSResults = async (hostname, type) => {
2 combinedResults.value = [];
3 dnsCheckStatus.value = 'running';
4 try {
5 const response = await fetch(`/api/dnsresolver?hostname=${hostname}&type=${type}`);
6 if (!response.ok) throw new Error('Network response was not ok');
7 const data = await response.json();
8 processResults(data);
9 dnsCheckStatus.value = 'idle';
10 errorMsg.value = '';
11 } catch (error) {
12 console.error('Error fetching DNS results:', error);
13 dnsCheckStatus.value = 'idle';
14 errorMsg.value = t('dnsresolver.fetchError');
15 }
16};Source: DnsResolver.vue。
将协议结果映射为展示行
1 combinedResults.value = data.results.map(entry => ({
2 country: entry.country,
3 provider: `${entry.provider} (${entry.type === 'doh' ? 'DoH 🔒' : 'DNS'})`,
4 address: Array.isArray(entry.result) ? entry.result.join(', ') : entry.result,
5 }));Source: DnsResolver.vue。
processResults 先检查 data.results 是数组,否则直接返回;这种结构异常不会主动抛出错误。因此 HTTP 成功但结果结构不正确时,界面可能恢复空结果状态,而不是显示网络错误。
国家选项来自结果数组的去重国家代码,使用首次出现顺序。旧筛选国家在新结果中不存在时重置为 all;仍存在则保留。N/A 以弱化样式显示,协议后缀让同一运营商的 UDP 与 DoH 行可区分。
添加解析器时参考的真实条目
{ id: 'google', name: 'Google', country: 'US', udp: '8.8.8.8', doh: 'https://dns.google/resolve?' },
{ id: 'cloudflare', name: 'Cloudflare', country: 'US', udp: '1.1.1.1', doh: 'https://cloudflare-dns.com/dns-query?ct=application/dns-json&' },
{ id: 'opendns', name: 'OpenDNS', country: 'US', udp: '208.67.222.222' },Source: dns-resolvers.js。
Cloudflare 示例说明:URL 已携带参数时必须以 & 结束;没有预置参数的前缀以 ? 结束。OpenDNS 示例说明只配置 UDP 也有效。
配置与扩展点
固定运行参数
| 配置 | 类型 | 当前值 | 作用 |
|---|---|---|---|
DNS_TIMEOUT_MS | number | 3000 | 每个 UDP Resolver 的超时参数,单位毫秒 |
UDP tries | number | 1 | 每个 Resolver 的尝试次数配置 |
DOH_TIMEOUT_MS | number | 5000 | 传递给 fetchUpstream 的单次超时覆盖 |
queryType | Vue ref,string | 'A' | 界面初始记录类型 |
countryFilter | Vue ref,string | 'all' | 界面初始国家筛选 |
这些值在所读实现中是代码常量或界面初始值,没有发现本功能专用的环境变量覆盖。不要把 DoH 的 5000 毫秒参数当成严格的端到端响应 SLA;网络封装的实现未在本次读取中验证。
依据:dns-resolver.js、dns-resolver.js、dns-resolver.js、DnsResolver.vue。
运营商条目约束
| 字段 | 类型 | 默认值 | 约束 |
|---|---|---|---|
id | string | 无 | 唯一小写 slug,字符为 [a-z0-9-] |
name | string | 无 | 界面名称 |
country | string | 无 | 大写 ISO 3166-1 alpha-2,允许 EU;表示运营商所在地 |
udp | string | 无,可选 | 公共 UDP 解析器 IPv4 地址 |
doh | string | 无,可选 | HTTPS JSON API URL 前缀,末尾为 ? 或 & |
udp 和 doh 至少提供一个。新增运营商只需修改数据列表;新增协议或记录类型则不是单纯的数据扩展,需要同步后端协议分支、记录类型定义和前端选择项。
维护注释要求在运营商所在国家之外验证解析器可用,并检查响应带 ra 标志;公开文档中的 IP 地址不等于仍然开放的递归解析服务。列表应保持精选,因为每个协议入口都会增加每次用户请求的上游负载。
依据:dns-resolvers.js。
失败模式、边界与并发
HTTP 层与结果层错误
| 场景 | 行为 |
|---|---|
缺少 hostname | 400,No domain provided |
| 域名校验失败 | 400,Invalid domain |
缺少 type | 400,No record type provided |
| 类型不在白名单 | 400,Invalid record type |
| 直接以非 GET 调用处理器 | 405,Method Not Allowed;不能据此推断整站非 GET 路由行为 |
| UDP 查询失败或结果为空 | 该行 N/A |
| DoH 非 2xx、无记录或捕获到异常 | 该行 N/A |
Promise.all 阶段发生未被单任务吞掉的异常 | 记录 error 日志,返回 500 与 error.message |
依据:guards.js、guards.js、dns-resolver.js。
异常捕获的真实边界
尽管处理器注释强调失败在解析函数中折叠为 N/A,UDP 的 new Resolver(...) 与 setServers([server]) 实际位于局部 try 之前。例如未来引入非法配置导致初始化抛错时,异步函数会拒绝,而非返回 N/A,最终可进入聚合的 500 分支。此外,任务数组本身在处理器的聚合 try 之前构建;不可将该 try 描述为包裹整个处理器的兜底。
这也是维护配置约束和配置测试重要的原因。上游正常失败是可容忍的行级问题,程序或配置错误不一定具有相同隔离能力。
日志与隐私
UDP 的 ETIMEOUT、ECONNREFUSED、EREFUSED 被作为可用性问题记录为 warn,字段只有服务器、运营商和错误码;其他 UDP 错误记录 debug,并保留完整错误对象。源码明确说明 warn 及以上会镜像到遥测,而 DNS 错误消息可能携带查询域名,因此可用性 warn 避免写完整错误。
DoH 非 2xx 记录状态码;DoH 异常 warn 包含 err。所以不能把 UDP 的隐私处理概括为“整个 DNS 功能的所有告警都不含错误对象”。依据:dns-resolver.js、dns-resolver.js。
并发与资源消耗
- 单次请求立即并发启动全部已配置入口,没有处理器级并发池、分批调度或仅选定国家查询。
- 正常情况下单次请求为 22 次上游查询;多个用户请求会各自再次扇出。
- 该路由注册未像某些其他路由那样显式使用
cacheable(...);所读查询处理器也没有结果缓存、数据库写入或历史记录保存。全站或外部基础设施是否另有缓存不在本页证据范围内。 - 本功能没有前端取消请求、查询结果版本检查或后台定时作业。浏览器端正常交互靠
running状态禁用控件,后端请求间没有共享结果状态。 - 排障时应区分 HTTP 请求整体失败与部分行
N/A:前者检查参数及服务端异常,后者检查特定运营商、协议的连通性和日志。
测试与验证边界
数据列表注释指出配置约束由 dns-resolvers-data.test.js 检查,并提示运行 pnpm test。本次定位还发现 dns-resolver-formatters.test.js、dns-resolver-logging.test.js、dns-record-types.test.js 和 check-dns-resolvers.test.js。
受本页源码读取预算限制,未读取这些测试正文,也未执行测试;这里只将它们作为维护入口,不宣称具体断言覆盖或测试通过。增加解析器时应验证配置、实际外网可达性和 JSON 协议兼容性;修改格式化逻辑时应重点回归 SOA Authority 回退、根点归一化、CAA tag 和 TXT 分组展示行为。
相关链接
DNS 泄漏检测关注实际解析路径,不应以本页的公共解析器对照结果替代;全站访问控制、通用网络封装和部署策略也应分别参阅相应主题。