Whois 查询与注册信息
Whois 工具为域名、公网 IP 和 ASN 提供注册信息查询。实现并非统一调用传统 WHOIS:域名优先使用 WHOIS,IP 优先使用 RDAP,ASN 则只使用 RDAP;前端通过统一的 __raw 文本字段展示结果。
目的与范围
本页覆盖输入识别、GET /api/whois、WHOIS/RDAP 选择与回退、IANA 引导数据、返回结构、前端展示及运行边界。ASN 排名、网络拓扑、前缀与 RPKI 等属于 ASN 档案能力;IP 地理定位、DNS 解析和整体部署不在本页展开。
本文依据已读取的实现片段。共享输入校验器、路由守卫、缓存响应头中间件、上游请求封装及完整 RDAP 文本格式化函数未展开读取,因此不推断它们的内部规则、超时实现或测试保证。
概述
| 查询类型 | 第一来源 | 回退来源 | 前端展示 |
|---|---|---|---|
| 域名 | whoiser.domain(),传统 WHOIS | rdapDomain() | 按服务提供方分组的折叠面板 |
| 公网 IPv4/IPv6 | rdapIp() | whoiser.ip(),限制为一跳 | 单个文本块 |
| ASN | rdapAutnum() | 无 | 单个文本块 |
这一差异来自上游协议特性:传统域名 WHOIS 可提供丰富记录,但部分新顶级域缺少可用的端口 43 服务;IP 的 RDAP 路径可以避免 WHOIS 客户端无法处理的 rwhois referral。这里的 __raw 是兼容展示字段:WHOIS 路径返回原始文本,RDAP 路径则从 JSON 生成类 WHOIS 文本,不能把两者都理解为服务器返回的原始报文。
依据:get-whois.js、rdap.js。
架构与入口
Sources: Whois.vue、backend-server.js、get-whois.js、rdap.js。
所有 /api 路由先经过默认 Cache-Control: no-store 设置和 requireReferer。Whois 再按注册顺序执行 ASN 规范化、缓存策略与处理器。它没有挂载本地 MaxMind 数据就绪守卫;其查找依赖外部注册服务,而非本地地理定位库。
实际路由声明如下,ONE_DAY_CACHE 在同一文件中为 24 * 60 * 60:
app.get('/api/whois', normalizeAsnQuery(), cacheable(ONE_DAY_CACHE), getWhois);Source: backend-server.js。
这里可以确认路由声明了一天的缓存策略,但不能据此断言服务端缓存了查询结果、所有错误响应都可缓存,或某个部署一定有边缘缓存命中;这些取决于中间件与部署环境。
输入到请求:前端控制流
输入识别与规范化
Whois.vue 的 onSubmit() 先记录 trackEvent('Section', 'StartClick', 'Whois'),清空错误、提供方列表和旧结果,再调用 validInput()。校验成功才发出请求。
识别顺序是 ASN、域名、IP,而不是先判断 IP:
- 调用
parseAsnInput(input, { requirePrefix: true });成功后把类型设为asn,发送AS${asn}。组件明确不接受裸数字作为 ASN。 - 调用
formatURL():没有小写http://或https://前缀时先补http://,通过URL取hostname,再取最后两个点分段,使用isValidDomain()校验。 - 域名识别失败后再检测 IP。有效但不可用作公网地址的输入显示
whois.reservedIP,不发送请求。 - 其他输入显示
whois.invalidURL。
域名规范化的实际实现:
1const formatURL = (domain) => {
2 if (!domain.match(/^https?:\/\//)) domain = 'http://' + domain;
3 try {
4 const url = new URL(domain);
5 const parts = url.hostname.split('.');
6 const mainDomain = parts.slice(-2).join('.');
7 if (isValidDomain(mainDomain)) return mainDomain;
8 } catch { /* noop */ }
9 return false;
10};Source: Whois.vue。
关键边界:这不是公共后缀列表算法。 最后两段截取会丢弃子域,也不能正确识别多级公共后缀下的可注册域。例如按该代码计算,example.co.uk 会被截成 co.uk。后端则不执行这一截取,因此前端查询与直接 API 调用可能查询不同名称。
请求状态与结果展示
getWhoisResults(query) 将状态设为 running,通过 fetch 访问同源 API。运行期间输入框和按钮被禁用,并显示 Spinner;请求完成、4xx 返回及异常路径都会将状态恢复为 idle。
成功响应还需满足展示条件:
- 域名:
getProviders()只保留键名匹配域名形式且具有真值__raw的条目,至少一个条目才能显示。提供方标题转为大写。 - IP/ASN:要求顶层
data.__raw为真值。 - 满足 HTTP 成功但不满足上述结构时,仍显示
whois.fetchError。 - 只有成功的域名查询发送
emitAppEvent('whois:lookup', { query });IP 和 ASN 分支没有发送此事件。
域名文本会去掉每行起始的 1~20 个空格,并在第一个 \nFor more information 位置截断;IP/ASN 文本去掉匹配 # 注释行的内容、开头空行及末尾一个换行。这些是展示清理,并不修改服务端记录。模板使用文本插值放入 <pre>,没有通过 v-html 渲染上游文本。
查询分支与回退机制
后端校验
处理器从 req.query.q 读取参数。缺失、空字符串或非字符串返回 400;这也拒绝 Express 对重复 q 参数生成的数组。随后先匹配 ^AS(\d+)$。代码依赖路由守卫提前把 ASN 规范化为 AS<n>,直接调用处理器不能假设小写 ASN 等输入也受支持。
非 ASN 输入必须满足 IP 校验或后端域名正则。域名正则要求点分隔的字母数字标签,可在标签内部使用连字符,末段必须是至少两个 ASCII 字母;处理器本身不接收完整 URL,也不执行 trim()。公网判断在语法判断之后,非公网 IP 返回 400,避免浪费外部查询。
依据:get-whois.js。
三条路径
Source: get-whois.js。
ASN: 将数字部分转换为 Number 后调用 rdapAutnum()。isAutnumMissing() 判断“ASN 不存在”或“没有 RDAP 端点”错误并映射为 404;其余错误记录 error 日志并返回 500。
IP: 任意 rdapIp() 异常都会触发 warn 日志并继续 WHOIS,包括 RDAP 的“未找到”。回退请求使用 5 秒超时、follow: 1、raw: true。其目的不是继续追踪 referral,而是限制到一跳,规避不兼容 referral。回退结果直接返回,不进行域名分支那样的原始文本完整性检查。
const ipinfo = await whoiser.ip(query, { timeout: 5000, follow: 1, raw: true });
return res.json(ipinfo);Source: get-whois.js。
域名: 先执行 WHOIS,保留隐私相关数据,最多跟随两跳。WHOIS 抛错会被吞掉;正常返回但没有任何服务提供方携带非空字符串 __raw 时也回退 RDAP。该判定只检查文本存在,不分析文本是否真正代表有效注册信息。
1function domainHasWhoisText(result) {
2 if (!result || typeof result !== 'object') return false;
3 return Object.values(result).some(
4 (v) => v && typeof v === 'object' && typeof v.__raw === 'string' && v.__raw.length > 0,
5 );
6}Source: get-whois.js。
因此空白字符串以外的任意非空字符串、甚至仅含空格的字符串,都能满足后端这个判定。扩展时若希望识别“无记录”文本,需要显式增加语义判断,而不是依赖现有条件。
RDAP:端点选择、数据结构与生命周期
IANA 引导与缓存
loadBootstrap(file) 从 https://data.iana.org/rdap/ 获取引导数据,以文件名作为 bootstrapCache 的键。条目包含 data 和 expiresAt,有效期为 24 小时:未过期直接返回,缺失或过期则重新请求;只有响应成功并解析 JSON 后才写入缓存。非成功 HTTP 状态记录日志并抛出 RDAP bootstrap failed: <status>。
四类引导文件分别为 dns.json、ipv4.json、ipv6.json 和 asn.json。此缓存保存的是“哪个注册服务负责哪段资源”的映射,而不是某个域名、IP 或 ASN 的注册结果。
Source: rdap.js。
三类端点匹配算法
| 类别 | 匹配方式 | 端点选择 | 请求路径 |
|---|---|---|---|
| 域名 | 取最后一段 TLD,不区分大小写匹配服务条目 | 匹配条目的 urls[0] | /domain/ 加 encodeURIComponent(domain) |
| IP | 解析 CIDR、检查地址族、选择包含 IP 的最长前缀 | 优先列表中的 HTTPS,否则首个 URL | /ip/ 加 IP 原文 |
| ASN | 按引导文件顺序匹配单值或闭区间 N-M | 优先 HTTPS,否则首个 URL | /autnum/ 加数字 ASN |
IP 的最长前缀匹配只在找到严格更长的匹配时替换结果,因此同长度匹配保留先前选中的端点。无效 CIDR 或地址族不符的条目会跳过;无法转为大整数的输入返回 null。ASN 则是第一个匹配区间,不是最长前缀算法。
IPv6 路径特意不使用 encodeURIComponent():代码注释说明某些经 RIR 重定向到达的服务会拒绝编码为 %3A 的冒号。所有路径都只选中一个引导端点,没有遍历备用 URL 的实现。
依据:rdap.js。
返回结构与注册信息
域名 RDAP 返回对象以所选基础 URL 的 hostname 为键,内层保留 RDAP JSON 并添加生成的 __raw;它有意模拟 whoiser.domain() 的多提供方结构。IP 返回顶层 RDAP JSON 加 __raw,模拟 whoiser.ip()。
ASN 不透传整份 RDAP JSON,而通过 parseAutnum(data, asn, host) 提取以下字段:
| 字段 | 提取方式与缺省值 |
|---|---|
asn | 调用方传入的 ASN |
handle、name | RDAP 同名字段,缺省 null |
rir | 已知 hostname 映射为 ARIN、RIPE NCC、APNIC、LACNIC、AFRINIC;否则 hostname,最终可为 null |
status | RDAP status,缺省空数组 |
registered | registration 事件日期,缺省 null |
lastChanged | last changed 事件日期,缺省 null |
registrant | 首个 registrant 实体的 vCard fn,否则 org,否则 null |
country | 顶层 data.country,缺省 null |
abuse | 首个 abuse 实体的第一条 email,缺省 null |
__raw | formatAutnum(data) 生成的文本 |
实体角色查找使用深度优先遍历:先检查当前实体,再查其子实体,最后继续同级实体。事件先按 eventAction 写入对象,因此同类事件重复出现时后面的日期覆盖前面的。vCard 提取保留每种属性的值数组,忽略 version;字段解析没有把所有联系人合并为一份注册人记录。
1 const events = {};
2 for (const e of data.events || []) events[e.eventAction] = e.eventDate;
3 const registrant = findEntityByRole(data.entities, 'registrant');
4 const registrantCard = registrant ? extractVcard(registrant) : {};
5 const abuseCard = extractVcard(findEntityByRole(data.entities, 'abuse'));Source: rdap.js。
rir 使用选中的引导基础 URL 的 hostname,而不是读取最终响应 URL;发生 HTTP 重定向时不能把该字段解释为最终响应服务的精确标识。完整文本格式化实现未在本次摘录中读取,因此不列出未经核实的 __raw 行格式。
依据:rdap.js。
API 与配置参考
HTTP 接口
GET /api/whois
- 必需查询参数:
q,单个非空字符串。 - 业务输入:域名、公网 IP、经过路由规范化的
AS<n>。 - 成功:返回对应查询分支的 JSON,三种类型的结构不同,不存在统一的
data包装层。 - 处理器错误:返回包含
error字段的 JSON;共享守卫可能在进入处理器前结束请求,其具体状态与响应体未核实。 - 不要把前端接受完整 URL 的行为视为 HTTP API 契约;URL 提取是在 Vue 组件中完成的。
实际调用代码:
const response = await fetch(`/api/whois?q=${query}`);Source: Whois.vue。
RDAP 模块函数
以下是源码中的 JavaScript 签名形式;返回语义来自函数实现,而非 TypeScript 类型声明。
| 函数 | 参数与返回 | 失败行为 |
|---|---|---|
rdapDomain(domain, { timeoutMs = 5000 } = {}) | 域名与可选请求超时;异步返回提供方键控对象 | 无端点、404、其他非成功状态抛 Error;引导或网络失败向上传播 |
rdapIp(ip, { timeoutMs = 5000 } = {}) | IP 与可选请求超时;异步返回 RDAP 字段和 __raw | 无端点、404、其他非成功状态抛 Error |
rdapAutnum(asn, { timeoutMs = 5000 } = {}) | ASN 经字符串化、去除开头 AS、转数字;异步返回解析字段 | 无端点、404、其他非成功状态抛 Error |
findIpEndpoint(services, ip) | IANA 服务数组和 IP;返回 URL 或 null | 无匹配返回 null |
findAutnumEndpoint(services, asn) | 服务数组和数字 ASN;返回首个匹配 URL 或 null | 无匹配返回 null |
parseAutnum(data, asn, host) | RDAP JSON、ASN、服务 hostname;返回注册信息对象 | 不构成完整的上游 JSON schema 校验 |
isAutnumMissing(error) | 按错误消息前缀识别 ASN 无记录;返回布尔值 | 对缺失消息按空字符串处理 |
依据:rdap.js。
参数与常量
本次已读实现没有 Whois 专属环境变量;下列值是代码常量或函数参数,不应描述为现成的部署开关。
| 参数/常量 | 类型 | 默认值 | 作用 |
|---|---|---|---|
RDAP timeoutMs | number,毫秒 | 5000 | 传给具体注册记录请求的 fetchUpstream |
WHOIS timeout | number,毫秒 | 5000 | IP 和域名 WHOIS 请求选项 |
IP WHOIS follow | number | 1 | 限制 referral 跟随 |
域名 WHOIS follow | number | 2 | 域名查询跟随上限选项 |
WHOIS raw | boolean | true | 请求原始文本 |
域名 ignorePrivacy | boolean | false | 不启用忽略隐私记录选项 |
CACHE_TTL_MS | number,毫秒 | 86400000 | IANA 引导数据内存有效期 |
ONE_DAY_CACHE | number,秒 | 86400 | Whois 路由传入的缓存策略时长 |
BOOTSTRAP_BASE | string | https://data.iana.org/rdap/ | IANA 引导文件地址 |
注意:引导文件请求直接调用 fetchUpstream(BOOTSTRAP_BASE + file),并没有传入查询函数的 timeoutMs。其默认超时取决于共享封装,不能用此表中的 5 秒替代。
依据:get-whois.js、rdap.js、backend-server.js。
错误、边界与并发
HTTP 错误映射
| 场景 | 后端处理 | 前端最终提示 |
|---|---|---|
缺失/非字符串 q | 400,No address provided | whois.noData |
| 非合法 IP/域名 | 400,Invalid IP or address | whois.noData |
| 非公网 IP | 400,Not a public IP address | 若请求到达后端则为 whois.noData;前端通常先显示 whois.reservedIP |
| ASN 无记录或无端点 | 404 | whois.noData |
| 域名无 RDAP 端点 | 404 | whois.noData |
| 域名 RDAP 返回 404 | 处理器实际返回 500 | whois.fetchError |
| IP RDAP 失败、WHOIS 也抛错 | 500,使用 WHOIS 错误消息 | whois.fetchError |
| 其他 RDAP 故障 | 500 | whois.fetchError |
| HTTP 成功但缺少可展示文本 | 后端可能已经返回 200 | whois.fetchError |
域名 RDAP 404 的差异尤其重要:rdapDomain() 抛出 Domain not found: ...,处理器却只将以 No RDAP endpoint for 开头的错误映射为 404,其余都返回 500。不能依据前端注释把“未注册域名”概括为一定返回 4xx。
前端对非成功响应不读取错误 JSON:低于 500 的失败状态统一显示无数据,500 及以上抛出本地错误后进入通用异常提示。因此服务端日志比 UI 更适合区分上游故障原因。
依据:get-whois.js、rdap.js、Whois.vue。
并发与一致性
- 前端是交互级防重复,不是请求协调器。 输入和按钮在运行时禁用,但
onSubmit()本身没有检查运行状态;实现没有AbortController、请求序号或“只接受最后一次响应”的逻辑。若通过其他途径触发重叠调用,共享的type、providers和结果状态存在被不同请求交叉使用的可能。 - 引导缓存没有请求合并。
bootstrapCache存储已解析数据,不存储在途 Promise;同一文件冷启动或过期时,多个并发请求可能同时访问 IANA。 - 缓存刷新失败不提供旧值回退。 已过期条目不会被返回;新请求失败向上抛出。
- 没有跨进程共享的证据。
Map属于当前模块所在进程,不能视为多实例一致缓存;重启后需要重新获取引导数据。
性能、运行维护与扩展
性能与排障
回退是串行执行,5 秒是单项查询选项而非整个接口的端到端预算。冷启动还会增加 IANA 引导请求;域名需要先等待 WHOIS 返回或失败,IP 则先等待 RDAP。已读实现没有应用层重试循环,回退不能等同于重试;共享请求封装和第三方库的内部行为未核实。
排障时建议按实际链路检查:
- 比较用户输入与规范化后
q,尤其是多级公共后缀、完整 URL、ASN 前缀及公网 IP 校验。 - 判断 HTTP 成功还是仅缺少
__raw;前端都可能显示通用失败,但根因不同。 - IP 路径查看
whois: RDAP IP lookup failed, trying WHOIS,确认是否已经切到传统协议。 - 域名首次 WHOIS 异常会被吞掉,不应期待存在对应错误日志;最终 RDAP 失败才有明确日志。
- 区分 IANA 引导失败与注册端点失败。前者影响一类资源的端点发现,后者影响已选中的服务查询。
- 核查运行环境到 IANA/RDAP HTTPS 和 WHOIS 端口 43 的连通性;仅网页可访问并不证明所有查询路径可用。
扩展时应保留的契约
- 新增上游时保持域名的“提供方 →
__raw”结构,以及 IP/ASN 的顶层__raw;否则现有组件无法直接展示。 - 新增注册机构可扩展
RIR_BY_HOST,未知机构目前以 hostname 显示,不会仅因缺少映射而失败。 - 修改域名错误分类时同步调整处理器状态映射,避免仅修正底层消息却改变前端“无数据/请求失败”的语义。
- 若优化域名规范化,应替换最后两段截取策略,并验证前后端查询目标一致。
- 若增加缓存请求合并或超时预算,应分别考虑引导获取、注册查询和协议回退,而不是只改变一个 5000 常量。
这些是基于现有实现边界的维护建议,不代表已经存在的功能。
测试与核实边界
findIpEndpoint、findAutnumEndpoint、parseAutnum 的源码注释明确说明其导出用于测试,但本次有限读取未包含测试文件,不能声称已验证具体覆盖率或用例通过情况。建议重点回归:最长前缀及地址族匹配、ASN 区间边界、嵌套实体角色、WHOIS 空结果回退、域名 RDAP 404 映射、重复 q 参数、缓存过期并发与多级公共后缀输入。
相关链接
- 项目中文说明中的 Whois 与 ASN 档案能力:了解与相邻工具的产品边界。
- Whois 输入及请求处理:修改输入、状态和展示行为的入口。
- 查询分支与错误映射:调整协议回退及 HTTP 语义的入口。
- RDAP 端点选择与 ASN 解析:扩展注册信息解析的入口。