Repository Wiki
jason5ng32/MyIP

多解析器 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。

架构

Loading diagram...

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. 服务端校验

实际路由依次执行域名校验、类型校验和查询处理器:

javascript
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 只负责等待全部完成。返回结果保留输入任务顺序,因此快慢不会改变显示顺序。

Loading diagram...

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 调用本接口处理方式
Aresolve4返回地址数组
AAAAresolve6返回地址数组
TXTresolveTxt对二维数组执行 flat()
CNAMEresolveCname返回名称数组,再按名称类型规则补根点
NSresolveNs返回名称数组,再按名称类型规则补根点
MXresolveMx转成 priority exchange.,以逗号连接
SOAresolveSoa交给 formatSoaRecord,生成单个字符串
CAAresolveCaa交给 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 指定的类型补点,减少视觉上的假差异:

javascript
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:

javascript
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

参数位置是否必填处理
hostnameQuery是服务端转小写,通过域名校验后查询
typeQuery是服务端转大写,通过记录类型白名单后查询

正常响应为包含 hostname 与 results 的对象。hostname 是归一化后的域名;results 是按配置顺序排列的结果数组。

结果字段实现中的值类型说明
idstring运营商稳定标识;双协议行共享同一 id
providerstring运营商展示名称
countrystring运营商所在地代码,包括允许的 EU
type'udp' 或 'doh'传输协议,不是查询的 DNS 记录类型
resultstring[] 或 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 错误会进入统一的前端错误分支:

javascript
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。

将协议结果映射为展示行

javascript
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 行可区分。

添加解析器时参考的真实条目

javascript
{ 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_MSnumber3000每个 UDP Resolver 的超时参数,单位毫秒
UDP triesnumber1每个 Resolver 的尝试次数配置
DOH_TIMEOUT_MSnumber5000传递给 fetchUpstream 的单次超时覆盖
queryTypeVue ref,string'A'界面初始记录类型
countryFilterVue ref,string'all'界面初始国家筛选

这些值在所读实现中是代码常量或界面初始值,没有发现本功能专用的环境变量覆盖。不要把 DoH 的 5000 毫秒参数当成严格的端到端响应 SLA;网络封装的实现未在本次读取中验证。

依据:dns-resolver.js、dns-resolver.js、dns-resolver.js、DnsResolver.vue。

运营商条目约束

字段类型默认值约束
idstring无唯一小写 slug,字符为 [a-z0-9-]
namestring无界面名称
countrystring无大写 ISO 3166-1 alpha-2,允许 EU;表示运营商所在地
udpstring无,可选公共 UDP 解析器 IPv4 地址
dohstring无,可选HTTPS JSON API URL 前缀,末尾为 ? 或 &

udp 和 doh 至少提供一个。新增运营商只需修改数据列表;新增协议或记录类型则不是单纯的数据扩展,需要同步后端协议分支、记录类型定义和前端选择项。

维护注释要求在运营商所在国家之外验证解析器可用,并检查响应带 ra 标志;公开文档中的 IP 地址不等于仍然开放的递归解析服务。列表应保持精选,因为每个协议入口都会增加每次用户请求的上游负载。

依据:dns-resolvers.js。

失败模式、边界与并发

HTTP 层与结果层错误

场景行为
缺少 hostname400,No domain provided
域名校验失败400,Invalid domain
缺少 type400,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 泄漏检测关注实际解析路径,不应以本页的公共解析器对照结果替代;全站访问控制、通用网络封装和部署策略也应分别参阅相应主题。

Sources

(4 files)
common
frontend/components/advanced-tools