Compare commits

..
43 Commits
Author SHA1 Message Date
nomdn f8482414e4 docs: 新增 CHANGELOG(v4.2.0 发布说明) 2026-10-25 20:33:12 +08:00
nomdn 05093190b9 feat: DNSSEC 可用性重写、EO 请求级解析同步、文档灯箱与全站 SEO(v4.2.0)
后端 src/:
- DNSSEC:默认解析器改为内置境外 DO 友好列表(1.1.1.1/8.8.8.8 等,
  v4/v6 双栈 14 地址),不再回退系统/国内解析器(带 DO 也不返回 RRSIG);
  新增 DNSKEY 自验签兜底(TLD 与无 A 记录域名可验);DS 与 A 并行查询,
  has_ds 不再依赖失败路径;解析器不支持时给出配置提示
- whois:移除自定义 DNS 解析,回归系统解析器,简化 QueryWhois 签名
- ssl:接口新增 SAN(Subject Alternative Names)字段
- ipdb:条件下载(ETag/Last-Modified,日更不再全量重拉 450MB)、
  单文件超时放宽至 10 分钟、后台初始化(服务降级启动不等 450MB)、
  bilibili 并行查询/负缓存/singleflight、加载器原子换指针(重载零真空窗)、
  移除 closeAllDBs/loadAll 死代码

EO 特供 serverless/edgeone:
- 同步主线请求级解析全套(dns=/dns4=/dns6=/resolve=/resolve4=/resolve6=,
  经 context 透传)、ResolveByType 统一分发、DialContext DNS 接管与
  SSRF 预检同源(SetResolveFunc)、tcping ResolveHostWith/TCPingRunWith
- whois 切换系统解析器;ssl 新增 SAN;dnssec 修复同步主线
- 内置中间件补 h3 路径 %2F 解码(与 fiber 对齐,带协议目标不再拼坏)

前端 frontend-ssr:
- 全站 SEO:32 篇文档 seoTitle/描述、全局 canonical/og/twitter/站点级
  JSON-LD、文档页面包屑、sitemap 全量枚举加权重、/ipv6 空壳页改服务端 301
- IndexNow:key 静态文件 + 提交端点(GET 全量/POST 增量)+ 部署后自动提交
- 文档图灯箱:ImageLightbox 全局委托 + el-image-viewer(多图切换/
  裂图守卫/路由切换关闭/Esc)
- DNSSEC 原理介绍文档与验证链图(Style 7);whois 生命周期图重绘为
  OpenAI 风并替换旧 Cloud Fabric 版
- 高级选项:单栈页只渲染对应栈(移除冗余通用行);DNS 输入自动规整
  (纯域名→DoH URL、缺 scheme/路径补齐、裸 IP 补端口)
- 内置中间件同步 h3 %2F 解码修复;验证链/生命周期图桌面端等比缩放

其他:.gitignore 排除 agent 工具产物(.agents/.zcode/skills-lock.json)

CI:frontend-ssr 部署后自动向 IndexNow 提交 sitemap 全量 URL
2026-10-25 20:22:26 +08:00
nomdn 7debff02fe Merge pull request #38 from nomdn/dependabot/npm_and_yarn/frontend-ssr/shell-quote-1.12.0
chore(deps): bump shell-quote from 1.10.0 to 1.12.0 in /frontend-ssr
2026-10-18 16:56:08 +08:00
nomdn 25a46b8e13 Merge pull request #40 from nomdn/dependabot/npm_and_yarn/docs/source-map-js-1.2.2
chore(deps): bump source-map-js from 1.2.1 to 1.2.2 in /docs
2026-10-18 16:55:56 +08:00
nomdn 0bbaf443e1 Comment out 河南 安阳 联通 configuration
Commented out the configuration for 河南 安阳 联通.
2026-10-18 16:55:01 +08:00
dependabot[bot] f82731eb77 chore(deps): bump source-map-js from 1.2.1 to 1.2.2 in /docs
Bumps [source-map-js](https://github.com/7rulnik/source-map-js) from 1.2.1 to 1.2.2.
- [Release notes](https://github.com/7rulnik/source-map-js/releases)
- [Changelog](https://github.com/7rulnik/source-map-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/7rulnik/source-map-js/compare/v1.2.1...v1.2.2)

---
updated-dependencies:
- dependency-name: source-map-js
  dependency-version: 1.2.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-18 07:55:55 +00:00
nomdn 8f57649832 Remove Xi'an configuration from index.ts
Removed commented-out configuration for Xi'an, Shaanxi.
2026-10-18 15:51:58 +08:00
nomdn b72136a461 feat(frontend-ssr): 节点池补录上海奉贤 / 江西南昌 / 广东梅州,移除枣庄
按中间件注册表对齐 DualStack 节点池:
- 新增 70dc5411…(上海市 上海市 奉贤区 移动)、97d8873f…(江西 南昌 移动)、
  b3e06025…(中国 广东 梅州 电信),三者 url 留空,仅经外部中间件转发
  (内置中间件对空 url 直接 502 Backend URL not configured)
- 移除 zaozhuang(山东 枣庄 双线),中间件注册表已无该节点
- hongkong 保留(URL 同样为空,仅走中间件)

三个新节点 pools 均为 api,未加入 IPLocationAPI。
实测 middleware-1:三个 nodeId 的 ssl/example.com 均返回 200。
2026-10-18 14:33:40 +08:00
nomdn 2a65abb8c3 fix(frontend-ssr): 清理各工具页残留的 devNodes 引用,修复构建失败
4c0f299 删掉了 config/index.ts 的 devNodes 导出,但 7 个页面仍在 import 并在
节点列表里展开,dev 下直接编译失败。

- dns / dnssec / ipv6webcheck / ssl / whois:移除 import 与数组首项 ...devNodes,
  以及上方「调试节点放最前」的注释
- speedtest:删除整个 devFetches 块,并去掉 allFetches 里的引用
- tcping:移除 allServers 的 ...devNodes.map 与 initServerResults 里的
  devNodes.forEach(两处需与 allServers 顺序保持一致)
- config/index.ts:删掉遗留的「dev 本机 8091 排最前」空注释

验证:dev 下 7 个页面均返回 200,tcping 首行节点恢复为「中国 江苏 移动」。
2026-10-18 10:42:56 +08:00
nomdn 4c0f29918c Refactor configuration by removing dev-related code
Removed development environment checks and local debugging nodes.
2026-10-17 22:47:25 +08:00
nomdn 5de97082aa Merge pull request #37 from Linming-XHL/main
feat. 可以一键清空输入框
2026-10-17 21:24:01 +08:00
nomdn 8b094bcb36 feat(node): 拨测支持请求级 DNS 与强制解析,前后端与中间件全链路透传
背景:排查解析差异(污染、区域 DNS、预发布域名)时需要「临时换解析服务器」或
「把某域名指到指定 IP」再拨测,此前只能改节点全局配置或 hosts。

节点 src
- webtest 新增 ReqResolve:dns / dns4 / dns6 + resolve / resolve4 / resolve6 九参数,
  ParseReqResolve 统一解析;Servers(stack) 按「栈内覆盖 → dns 回落」取服务器,
  Override(host, wantV6) 取强制解析目标(map 键经 canonicalHost 归一化)
- 解析层补 ResolveXRecordWith(domain, servers) 与 ResolveByType,ctx 传参走
  WithDNSServers / DNSServersFromContext
- 拨号:initHTTPClients 的 DialContext 从 ctx 取参数——命中 Override 时改写 addr,
  IP 直连、域名当作新 host 再解析一跳;未命中则用请求级服务器解析
- tcping:ResolveHostWith 同样支持 Override 与自定义 DNS;字面 IP 也做私网拦截
- SSRF:解析器改为可注入(ssrf.SetResolveFunc + webtest.InstallSSRFResolver),
  预检与实际拨号同源,新增 src/webtest/ssrfresolver.go
- 缓存:ReqResolve.CacheKey() 把参数按固定字段顺序拼进键,替代「有参数即跳过共享
  缓存」;零值返回空串,旧缓存键逐字节不变,升级不失效
- ws:wsProbe 接收 query,与 HTTP 侧同口径

middleware
- middleware-go:query 透传从 tcping/speed 分支提升到所有分支(detail/ssl/dns/whois
  也带 dns 系参数),WS 侧以 map 传给 RequestProbe
- frontend-ssr 内置中间件路由([...slug].get.ts)同步透传 query

frontend-ssr
- 新增 AdvancedOptions 组件:dns 通用 + 按栈 dns4/dns6、resolve 三档;单栈页只渲染
  本栈一列,dns/whois 页隐藏 resolve
- 8 个工具页接线;utils/tools.ts 新增 withQuery(空对象原样返回,不填参数时 URL 不变)
- dev 下注入本地调试节点并排在首位,中间件指向 127.0.0.1:8091

docs
- node-api 补 resolve 参数表、IP / 域名两种目标的语义、缓存键片段与 TTL 修正

验证:本地链路(前端 3010 → 中间件 8091 → 节点 18081)tcping/ssl/detail 带
dns= / resolve= 实测生效;SSRF 开启时私网目标被拦、关闭时放行。
2026-10-17 21:01:38 +08:00
dependabot[bot] 62b80d06dc chore(deps): bump shell-quote from 1.10.0 to 1.12.0 in /frontend-ssr
Bumps [shell-quote](https://github.com/ljharb/shell-quote) from 1.10.0 to 1.12.0.
- [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/shell-quote/compare/v1.10.0...v1.12.0)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 1.12.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-14 02:24:05 +00:00
Linming-XHL 81a61c0836 fix. 修复某些遗漏的输入框 2026-10-11 09:20:03 +08:00
Linming-XHL cee22230ea feat. 可以一键清空输入框 2026-10-11 09:08:28 +08:00
nomdn 2947ad2125 Prefix ASN values with 'AS' in asn.vue 2026-10-05 22:36:46 +08:00
nomdn 1235ccffbd Merge pull request #35 from nomdn/dependabot/npm_and_yarn/serverless/lemon-getip/hono-4.13.7
chore(deps): bump hono from 4.13.5 to 4.13.7 in /serverless/lemon-getip
2026-10-05 21:38:27 +08:00
nomdn d688e9bb8e Merge pull request #36 from nomdn/dependabot/npm_and_yarn/frontend-ssr/devalue-5.9.4
chore(deps): bump devalue from 5.9.2 to 5.9.4 in /frontend-ssr
2026-10-05 21:38:06 +08:00
nomdn 805b65bc07 fix(node): ASN WHOIS 支持多 RIR 解析,补 ASN 库兜底与缓存键归一化
现象:/v1/asn/:ip 与 ws 的 asn 路径,除 1.1.1.1(AS13335,ARIN 注册)外全部拿不到 whois 信息。

解析侧 src/webtest/whois.go
- 根因:parseASNWhoisRaw 只认 ARIN 字段名(ASNumber/ASName/OrgName/OrgAbuse*),
  而 QueryASNWhois 走 IANA → 归属 RIR 链路,非 ARIN 的 ASN 回的是 aut-num/
  as-name/descr/country/last-modified 格式,于是字段解析全空
- 另一坑:ARIN 对非 ARIN 资源只回转介桩(ASName: APNIC-4134 + ReferralServer),
  同一条 raw 里存在两段内容,桩的字段是误导信息
- 改为按空行分块解析:存在原生 aut-num 块时以它为准、整体忽略 ARIN 段;字段按 RIR
  分级取值(RIPE 的组织名与国别按 aut-num 的 org: 句柄定位 organisation 对象)
- abuse 优先取 % Abuse contact 注释行,其次含 abuse-mailbox 的对象
- 实网验证 22/22 全解析(电信/联通/移动/阿里/腾讯/百度/华为 + CF/Google/Amazon/
  Hetzner/Linode 等),覆盖 ARIN+APNIC+RIPE 三类 RIR

消费侧 src/main.go src/ws.go
- 新增 resolveASN():ASN 来源按 maxmind → dbip → ip2location 逐级兜底。此前只认
  maxmind_asn,某 IP 未命中该库时整段 whois 直接消失,哪怕另两个库有数据
- 新增 normalizeASN():MMDBASNResult.ASN 自带 "AS" 前缀,ip2location 的 asn 是裸数字,
  缓存键与查询入参统一归一化(此前 "AS"+ASN 实际拼成 ASAS13335)
- 新增 lookupASNWhois():handler 与 ws 共用同一套缓存 —— ws 路径此前完全没有缓存,
  每个请求都真查一次 WHOIS
- 新增 asnWhoisFailed():likexian/whois 出错时返回 (result, nil),错误塞在 result.Error
  里;老代码只判 err != nil(恒为假)⇒ 失败结果被当成功缓存 5min,现按失败走 30s TTL
2026-10-05 20:51:01 +08:00
nomdn 682983e411 fix(frontend-ssr): 手机端两 IP 块间大空隙——min-height 只留给 IPv6 行,IP 行 margin 归零
- 窄屏 .ip-row 一刀切 min-height:75px 使 IPv4 行(1 行 30px)下部空出 ~45px,
  与 style.css 窄屏 .one-line 的 0.5em margin 叠加成大段空白
- IPv6 行拆出 ip-row-v6 类单独吃 75px 预留(align-content:flex-start 防行间拉伸)
- 窄屏 .ip-row margin:0 盖掉 .one-line 的 0.5em(scoped 特异性更高)
- IPv4 回填前后均 1 行 30px,高度不变,不引入新 CLS
- CDP 实测(412 宽):两行空隙 61px → 0px,IP 回填正常
2026-10-05 09:58:18 +08:00
dependabot[bot] 4abe6e4d1a chore(deps): bump devalue from 5.9.2 to 5.9.4 in /frontend-ssr
Bumps [devalue](https://github.com/sveltejs/devalue) from 5.9.2 to 5.9.4.
- [Release notes](https://github.com/sveltejs/devalue/releases)
- [Changelog](https://github.com/sveltejs/devalue/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/devalue/compare/v5.9.2...v5.9.4)

---
updated-dependencies:
- dependency-name: devalue
  dependency-version: 5.9.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-04 18:06:57 +00:00
nomdn 19fe134762 perf(frontend-ssr): modulepreload 降级+探测提前+关 appManifest+链接色达标,LH desktop 100
- compress-html: modulepreload 注入 fetchpriority=low,render-blocking CSS 先行
  (mobile FCP 2345→1109ms,三档实测 low 最优:medium 仍挤 CSS 净亏)
- index.vue: 三个 IP 探测 fetch 从 onMounted 提前到 head 内联脚本(实测 392ms 发出,
  旧时序要等水合 ~2000ms),结果挂 window.__ipProbe + ip-probe 事件,onMounted 只消费
- nuxt.config: experimental.appManifest=false,不再请求 builds/meta/<id>.json,
  NUXT_E5002 从源头消失(本地「服务旧磁盘新」与线上发版瞬间均免疫)
- 链接色 --link-color 变量:亮 #1d7a4a(白底 5.34:1 过 axe 4.5 线),暗保持 #299764,
  style.css 六处硬编码色值收口
- LH 13.5:desktop 100/99/100(TBT 14-53ms),mobile 干净轮 92-96;A11y/BP 全程 100
2026-10-05 01:56:25 +08:00
nomdn 5445b4df64 revert(frontend-ssr): 撤回文档内容图片宽高注入
回退 243060a 中的图片治理部分:markdown.ts 移除 width/height 注入与
doc-image-dims.json 构建期清单,package.json/pnpm-lock 去 image-size 依赖。
2026-10-05 01:56:24 +08:00
nomdn 4f6cf7fac7 chore(frontend): 移除 frontend-svelte 前端目录
整目录删除(SvelteKit 3 + adapter-cloudflare)。注意:.github/workflows/frontend-svelte.yml
仍保留,其 paths 过滤 frontend-svelte/** 在目录删除后不再触发,属死文件,去留待定。
2026-10-05 01:55:57 +08:00
nomdn 243060a32a perf(frontend-ssr): 修文档页移动端 500,LH 拉升至 95/86/93,主题色改 #299764
一揽子水合修复 + 性能优化 + 品牌色更换:

- 修 P0:/doc/** 撤预渲染改回 SSR——预渲染 HTML 恒为宽屏,真机 UA 下
  客户端窄屏判定与之失配导致整页 500。窄屏判定收口到
  composables/useShellNarrow.ts(服务端按 sec-ch-ua-mobile/UA 判、客户端
  沿用序列化值、爬虫强制宽屏),移动端 /doc 恢复 200 且水合成本大降
- 图片治理:markdown 渲染注入 loading=lazy + decoding=async + 真实宽高;
  尺寸清单 config/doc-image-dims.json 由 nuxt.config 构建期扫 public/ 生成
  (CF Workers 无 fs,只能构建期算),unsized-images fail→pass
- 源站压缩:compressPublicAssets(142 个 .gz/.br)+ server/plugins/
  compress-html.ts(SSR HTML 按 Accept-Encoding 回 br/gzip,首页
  35KB→7.5KB),消除 Lantern 无压缩高估
- a11y:/dns 的 el-select 补 aria-label(92→97)
- NuxtLink 预取改 interaction(省 idle 期观察者任务,ssl TBT 370→120ms)
- 主题色全局 #3EAF7C → #299764(17 文件 38 处,白底对比 2.75→3.68:1)

LH 实证(mobile):/ 67→95、/dns 61→86、/ssl 57→93;桌面 / 100、/dns 99、
/ssl 98。零新增 fail;unsized-images、unused-css/js、interactive、
speed-index 等一批 fail→pass。剩余 fail:bf-cache(LH 内部错误)、
color-contrast(品牌绿 3.68:1,需 4.5:1,知情保留)、TBT(页面 chunk
求值+水合地板,再降需组件级懒水合,UX 有损暂缓)。
2026-10-04 15:49:16 +08:00
nomdn 924b9bcc36 style(frontend-svelte): 还原原版 Element Plus 观感,图标改用本地 EP 图标
把迁移到 SvelteKit 后走样的 UI 逐项改回 nipw.cn 的 element-plus 观感;所有数值
都以线上原版的 computed style 为准(下载 live CSS 用脚本遍历 document.styleSheets
逐条 trace 命中规则),不靠读源码猜。

- 字体:font-family 还原成 UA 默认(不再落到细体);补齐标题的 UA 外边距
- 顶栏:60px 高 + 1px 底线;项 padding 0 20px(文字起点 +30px)、14px、#303133
- 文档菜单:56px/50px 行高、20px/40px 逐层缩进、选中用 EP 蓝 #409eff、
  分组箭头 12px 且颜色继承标题
- /doc 几何对齐:body 外边距归零(.doc-route 门控,离开路由自动摘除)、
  正文容器 margin-top 40px → 正文首行 y=120、侧栏 260px 与原版完全一致
- 输入框 16px、按钮 font-weight 400、圆角统一 4px(不再是大圆角)
- 链接色:正文 #2f7d5a(深色 #3eaf7c);文档锚点用「非分层 + 更高特异性」
  压过 github-markdown-css 的 .markdown-body a
- 图标:新增本地 Element Plus 图标组件(几何取自 @element-plus/icons-vue@2.3.2),
  配 scripts/extract-element-plus-icons.mjs 生成;去掉 @lucide/svelte 依赖(同步 bun.lock)
- EP 变量(--el-*)收拢在 site.css 的 :root,与 element-plus 源码逐条对应

实证:/ssl 与 / 在 1440×900 下与原版逐像素一致;/doc 正文 y=120、侧栏与菜单项
(56px/50px、箭头 t=138 l=227 12px #303133)与原版完全一致;375×812 下单一汉堡、
无横向溢出。bun run check 0 error 0 warning。
2026-10-04 03:38:51 +08:00
nomdn 611caa30f6 feat(frontend): 新增 SvelteKit 3 前端 frontend-svelte,接管 ipw-cn Worker
把 frontend-ssr(Nuxt 4 全栈)整体迁移到 SvelteKit 3 + Svelte 5,包管理用 bun,
组件库 shadcn-svelte。18 条路由 + 5 个 server endpoint 全部跑通。

迁移内容
- 12 个工具页(location/asn/whois/ssl/ipv6webcheck/dns/dnssec/tcping/ipv6tcping/
  speedtest/ipv6speedtest/screenshot)+ 首页 + 页头页脚 + 文档站
- server 路由:/ip、/ip/[ip]、/api/markdown/[...path]、/api/whois/[domain]、
  /middleware/[...slug](内置中间件兜底转发,含 403/429 与路径穿越防护)
- lib 收口:node-pool(单成功重试 / 多节点并发)、visitor-ip、ui-classes、
  TcpingPanel/SpeedtestPanel(v4/v6 共用)、server/docs(36 篇 md 构建期内联)
- 部署:adapter-cloudflare + wrangler.jsonc;CI 新增 frontend-svelte.yml
  (bun + wrangler deploy --keep-vars),frontend-ssr.yml 去掉 push 触发

运行时实证中修掉的问题(构建期均不可见)
- TcpingPanel/SpeedtestPanel 用了 untrack 但未 import ⇒ 4 个页面 SSR 500
- middleware 路由的 API_KEYS/APIKEYS 是裸标识符 ⇒ 运行时 ReferenceError,
  改为从 $app/env/private 导入(Kit 3;$env/dynamic/private 已废弃)
- 补 /doc/** 的 SSG(对齐旧站 routeRules.prerender):32 个 html;
  DocMenu 折叠改用 hidden,否则预渲染爬虫抓不到子树
- app.html 内联脚本在预渲染页拿不到 CSP hash(Kit 只 hash 组件内联脚本),
  改为构建期现算 sha256 注入 script-src,避免深色模式首屏脚本被拦
- 修正 16 处历史遗留的文档目录锚点(旧站同样存在,Nitro 不校验锚点)

验证:bun run check 0 error;wrangler dev 下 18 条路由全 200;SSR 页 CSP 走响应头
+ nonce,SSG 页走 <meta> + hash 且两个内联脚本均被覆盖;middleware 的 Authorization
注入用本地 echo 上游实测通过;无 API_KEYS 时优雅降级。

另外把链接颜色改回品牌绿 #3eaf7c(frontend-ssr 同步)。
2026-10-04 00:44:24 +08:00
nomdn a8f7f2bd17 refactor(node): 配置字面量归一成一张表,布尔与字符串彻底等价
- 新增 literalString 作为两条读取路径唯一的「字面量 → 字符串」表:启动路径
  (setting.json 经 viper / 环境变量)与运行路径(远端下发 / PATCH)共用,
  取代原先「viper 隐式 cast + configValue 里手写 type switch」两套规则 ——
  二者只是碰巧一致,任一边不认布尔,ipdb: false 就会静默变回「开」
- configValue 收敛为一行;configString 的 setting.json 一侧改走 viper.Get 后
  归一,于是布尔 false / 数字 0 / 字符串 "false" 三种写法现在必然等价
- 新增 src/config_switch_test.go(仓库首个测试文件):覆盖 literalString、
  parseBoolSwitch(含 8 个非法值)、启动路径的布尔与数字写法、运行路径
  布尔与字符串的双向对称(布尔 true 开 / 字符串 "false" 关)
- parseBoolSwitch 签名不变,注释明确入参是「已归一」的字符串,职责更干净

对外行为不变:入口仍宽容接收布尔 / 字符串 / 数字,出口 GET /v1/config 的
ipdb 恒为 JSON 布尔;「空串 / null = 未提,不等于 false」的口径保持不变。

docs:config.md 补数字写法与「四条来源先归一成同一张表再判定」的口径说明
2026-10-03 17:52:59 +08:00
nomdn ff5caddab3 refactor(node): 四个开关统一字面值口径,新增 access-log 热开关
- 新增 access-log / ACCESS_LOG(缺省开):开关判定放在请求路径上,
  PATCH /v1/config 与远端下发改完即生效;关闭时中间件直接短路放行,
  连 logger 的计时与字段拼接都不做(访问日志是每请求一次同步写)
- 引入 parseBoolSwitch 作为 ipdb / block-private-ips / node-ota / access-log
  四个开关唯一的字面值解析表,替换口径互不相同的 configDisabled 与
  parseOTASwitch:认不出的值不再静默当成"开",启动阶段告警并落默认值,
  PATCH / 远端下发计入应答的 unknown 并保留原值
- setting.json.example 收敛为最小可用集(11 键),其余以文档为准

BREAKING CHANGE: ipdb 由字符串改为布尔(IPDB string -> IPDB_ENABLED bool)。
/v1/config 的 ipdb 返回 boolean,setting.json 里写 false 而不是 "false";
同时 ipdb 不再大小写敏感,"False" / "no" 由原来的"开"变为"关"。

docs:config.md 补四个开关的统一口径、22 个字段与环境变量清单
2026-10-03 17:23:45 +08:00
nomdn a00e42a896 feat(frontend): 首屏性能与可访问性打磨,缓存控制交 CDN 侧
性能(Lighthouse 驱动):
- 代码高亮改在 SSR 期算完(index / location 走 useAsyncData),客户端不再
  下载执行 shiki;配合 build:manifest 抹掉 prefetch 标记,首页少白下 165KB
- 三个 IP 探测源站 preconnect(crossorigin)、中间件 dns-prefetch,把握手
  提前到 HTML 解析期
- runtimeConfig.public 移除 docConfig(每页 HTML 少背约 4.5KB),改成直接 import
- el-image 换原生 img 并补宽高:SSR 不再只吐占位 div,顺带治图片重排
- routeRules 不再设置任何 cache-control,缓存策略交给前置 CDN

可访问性:
- el-divider 换 aria-hidden 原生分隔线,并压制 EP Menu.init 写入的 tabindex
- 水平菜单项内层 a 撑满 li,满足 target-size(24x24)
- logo 链接补条件 aria-label;代码注释 / 副标题 / 链接颜色对比度达标

docs:deploy-frontend.md 移除缓存策略一节(缓存由 CDN 侧控制)
2026-10-03 17:23:32 +08:00
dependabot[bot] 165caa9127 chore(deps): bump hono from 4.13.5 to 4.13.7 in /serverless/lemon-getip
Bumps [hono](https://github.com/honojs/hono) from 4.13.5 to 4.13.7.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.13.5...v4.13.7)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.13.7
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-01 08:55:32 +00:00
nomdn 031c1e8b69 feat(ci): 优化构建和发布流程,支持并行构建和版本元数据计算 2026-10-01 16:53:51 +08:00
nomdn bb02b0c5bd feat(install): 支持非 root 安装,install.sh 询问降级、contribute.sh 静默降级
- install.sh:非 root 时提示并询问是否改用 systemd --user(答 n/回车取消),
  支持 --user/--system 显式指定;用户级 unit 落 ~/.config/systemd/user,
  去掉 User= 与 network-online 依赖、改 WantedBy=default.target,并在装后启用 linger
- contribute.sh:保持"零交互"定位,非 root 且未指定作用域时直接静默降级为
  用户级(不打断询问),仅显式 --system 在非 root 下报错
- 两个脚本作用域敏感的调用统一收进 sc()/jc() 包装,避免漏掉 --user 后
  查到的其实是系统级命名空间(空结果)
2026-10-01 16:27:04 +08:00
nomdn e7365e3005 fix(frontend): ASN 页去掉重复的 AS 前缀,后端返回值已自带 2026-10-01 15:13:48 +08:00
nomdn 1b0f2646fe refactor(node): 迁移到 Fiber v3、收敛配置读取,修拨测内存放大并重定缓存 TTL
- HTTP 层 gin → Fiber v3:路由与中间件接线全部收进 newFiberApp,main 只按步骤调用;
  补齐 fiber 不带的访问日志(转投 slog)与 panic 兜底,上报中间件并入 main.go 与 tokenCheck 并列
- 配置读取收敛到 main.go 的 readConfig(env > setting.json > 默认值),其余文件不再直接读配置;
  新增 max-response-body / memory-limit,两者都支持运行中热更新
- 修响应体内存放大:拨测请求统一走 SetDoNotParseResponse,响应体只计数不读入内存;
  zstd 解码器收紧窗口与并发,堵住按帧头声明值预分配导致的内存放大
- 缓存改为按条目标记:成功 30s、失败 5s(原为成功 5 分钟、失败靠后台协程 30s 后删);
  失败判定收进 tcpingFailed / dualAnyFailed,HTTP 与 WS 两条入口共用同一套口径,
  并补上 tcping "拨号全失败"漏判为成功的口子
2026-10-01 15:13:39 +08:00
nomdn 31d725ff84 feat: 添加贡献节点脚本并更新安装脚本以支持 IP 数据库和中间件地址配置 2026-09-26 20:11:23 +08:00
nomdn 21fa445ff8 Merge branch 'main' of https://github.com/nomdn/ipw-cn 2026-09-26 20:03:19 +08:00
nomdn e7cccfbd99 Fix typo in label property 2026-09-26 15:14:10 +08:00
nomdn f12c97cf8b Update index.ts 2026-09-26 14:56:12 +08:00
nomdn cc8690d264 fix(install): systemd 单元里路径不再加引号,并拦截含空格的安装目录
systemd 的 WorkingDirectory/ExecStart 不按 shell 规则解析,写成
WorkingDirectory="…" 会把引号当作字面量的一部分;而路径含空白时,裸写又会被
拆成多个参数。两种写法都会让服务起不来。这里改为裸写路径,并在交互采集安装
目录时提前拒绝含空格/制表符的输入,直接给出明确报错而不是事后看 systemd 报错。

Environment= 那处仍保留引号(systemd 对 Environment 的引号是有语义的,与
WorkingDirectory/ExecStart 不同)。
2026-09-26 12:50:41 +08:00
nomdn b705dbdc18 Merge branch 'main' of https://github.com/nomdn/ipw-cn 2026-09-25 21:09:06 +08:00
nomdn 25a8d7a651 Merge pull request #34 from nomdn/dependabot/npm_and_yarn/docs/vite-5.4.21
chore(deps): bump vite from 5.4.14 to 5.4.21 in /docs
2026-09-19 23:34:55 +08:00
dependabot[bot] f44da9a467 chore(deps): bump vite from 5.4.14 to 5.4.21 in /docs
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.14 to 5.4.21.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.21/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.21/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.21
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-19 00:57:19 +00:00
93 changed files with 8384 additions and 2304 deletions

No files matched your search

+113 -102
View File
@@ -1,6 +1,11 @@
name: Build and Release
# 并行构建:原来 8 个 Build step 串行跑在一个 job 里,现在拆成 8 个矩阵任务同时跑
# (2 个程序 × 4 个目标系统),构建完各自上传 artifact,最后统一发 Release。
#
# 元数据(VERSION / COMMIT / BUILD_TIME)由 meta job 算一次分发下去:
# 若放到每个矩阵任务里各算一遍,BUILD_TIME 会互不相同,同一个 Release 里的
# 二进制时间戳就对不上了。
on:
push:
tags:
@@ -8,95 +13,131 @@ on:
workflow_dispatch:
jobs:
build-and-release:
# 只负责算版本元数据,秒级完成,不产出 artifact
meta:
runs-on: ubuntu-latest
outputs:
ldflags: ${{ steps.meta.outputs.ldflags }}
steps:
- name: Compute version metadata
id: meta
run: |
echo "ldflags=-X main.VERSION=${GITHUB_REF_NAME} -X main.COMMIT=${GITHUB_SHA} -X main.BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
build:
needs: meta
runs-on: ubuntu-latest
strategy:
# 一个目标挂了不影响其余目标,全部跑完再统一看结果
fail-fast: false
# 纯 include 模式:每条自带全部字段、各自独立成一个任务。
# 不要把 app 写成顶层数组再和 os 组合——GitHub 会把能匹配现有组合的
# include 条目合并进组合本身,任务数被折叠(docker.yml 里踩过,见 v3.3.0 事故)
matrix:
include:
- app: lemonipw
dir: src
os: linux
- app: lemonipw
dir: src
os: windows
- app: lemonipw
dir: src
os: darwin
- app: lemonipw
dir: src
os: freebsd
- app: middleware-go
dir: middleware-go
os: linux
- app: middleware-go
dir: middleware-go
os: windows
- app: middleware-go
dir: middleware-go
os: darwin
- app: middleware-go
dir: middleware-go
os: freebsd
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v4
# v4 基于 Node 16,GitHub 已不再支持该运行时(actionlint 会报 runner too old)
uses: actions/setup-go@v5
with:
go-version: '1.26.6' # 与 src/go.mod 声明保持一致,避免依赖 GOTOOLCHAIN 隐式下载
- name: Set version metadata
run: |
echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV
echo "COMMIT=${GITHUB_SHA}" >> $GITHUB_ENV
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_ENV
- name: Build for Linux
working-directory: src
# 产物统一写到仓库根目录(working-directory 的上一级),命名规则与原串行版完全一致
- name: Build ${{ matrix.app }} for ${{ matrix.os }}
working-directory: ${{ matrix.dir }}
env:
APP: ${{ matrix.app }}
LDFLAGS: ${{ needs.meta.outputs.ldflags }}
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-386
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-amd64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-arm64
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-armv7
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-armv6
CGO_ENABLED=0 GOOS=linux GOARCH=loong64 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-loong64
- name: Build for Windows
working-directory: src
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=windows GOARCH=386 go build -ldflags "$LDFLAGS" -o ../lemonipw-windows-386.exe
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-windows-amd64.exe
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-windows-arm64.exe
- name: Build for macOS
working-directory: src
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-darwin-amd64
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-darwin-arm64
- name: Build for FreeBSD
working-directory: src
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags "$LDFLAGS" -o ../lemonipw-freebsd-386
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-freebsd-amd64
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-freebsd-arm64
- name: Build middleware-go for Linux
working-directory: middleware-go
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-386
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-amd64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-arm64
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-armv7
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-armv6
CGO_ENABLED=0 GOOS=linux GOARCH=loong64 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-loong64
- name: Build middleware-go for Windows
working-directory: middleware-go
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=windows GOARCH=386 go build -ldflags "$LDFLAGS" -o ../middleware-go-windows-386.exe
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-windows-amd64.exe
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-windows-arm64.exe
- name: Build middleware-go for macOS
working-directory: middleware-go
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-darwin-amd64
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-darwin-arm64
- name: Build middleware-go for FreeBSD
working-directory: middleware-go
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags "$LDFLAGS" -o ../middleware-go-freebsd-386
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-freebsd-amd64
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-freebsd-arm64
set -euo pipefail
case "${{ matrix.os }}" in
linux)
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags "$LDFLAGS" -o "../${APP}-linux-386"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o "../${APP}-linux-amd64"
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o "../${APP}-linux-arm64"
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$LDFLAGS" -o "../${APP}-linux-armv7"
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$LDFLAGS" -o "../${APP}-linux-armv6"
CGO_ENABLED=0 GOOS=linux GOARCH=loong64 go build -ldflags "$LDFLAGS" -o "../${APP}-linux-loong64"
;;
windows)
CGO_ENABLED=0 GOOS=windows GOARCH=386 go build -ldflags "$LDFLAGS" -o "../${APP}-windows-386.exe"
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o "../${APP}-windows-amd64.exe"
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o "../${APP}-windows-arm64.exe"
;;
darwin)
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o "../${APP}-darwin-amd64"
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o "../${APP}-darwin-arm64"
;;
freebsd)
CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags "$LDFLAGS" -o "../${APP}-freebsd-386"
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o "../${APP}-freebsd-amd64"
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o "../${APP}-freebsd-arm64"
;;
esac
- name: Upload ${{ matrix.app }}-${{ matrix.os }}
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.app }}-${{ matrix.os }}
# 只捞本任务自己产出的二进制;middleware-go/ 目录名不带连字符,不会误匹配
path: ${{ matrix.app }}-*
if-no-files-found: error
# 只在打 tag 时发 Release;workflow_dispatch 手动跑则只构建、可从 run 页面下载 artifact
release:
needs: build
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download all build artifacts
uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Prepare config files for release
run: |
# 后端:发布 src/ 下的 setting.json.example;中间件:只发布 setting.json.example
cp src/setting.json.example setting.json.example
cp middleware-go/setting.json.example middleware.setting.json.example
cp src/setting.json.example dist/setting.json.example
cp middleware-go/setting.json.example dist/middleware.setting.json.example
- name: Release
uses: softprops/action-gh-release@v3
if: github.ref_type == 'tag'
with:
name: Release ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}
token: ${{ secrets.MINT_TOKEN }}
generate_release_notes: true
body: |
## 配置文件说明
@@ -116,34 +157,4 @@ jobs:
cp middleware.setting.json.example middleware-go/setting.json
```
files: |
lemonipw-linux-386
lemonipw-linux-amd64
lemonipw-linux-arm64
lemonipw-linux-armv7
lemonipw-linux-armv6
lemonipw-linux-loong64
lemonipw-windows-386.exe
lemonipw-windows-amd64.exe
lemonipw-windows-arm64.exe
lemonipw-darwin-amd64
lemonipw-darwin-arm64
lemonipw-freebsd-386
lemonipw-freebsd-amd64
lemonipw-freebsd-arm64
middleware-go-linux-386
middleware-go-linux-amd64
middleware-go-linux-arm64
middleware-go-linux-armv7
middleware-go-linux-armv6
middleware-go-linux-loong64
middleware-go-windows-386.exe
middleware-go-windows-amd64.exe
middleware-go-windows-arm64.exe
middleware-go-darwin-amd64
middleware-go-darwin-arm64
middleware-go-freebsd-386
middleware-go-freebsd-amd64
middleware-go-freebsd-arm64
setting.json.example
middleware.setting.json.example
files: dist/*
+19 -6
View File
@@ -1,11 +1,15 @@
name: Build and Deploy to Cloudflare Workers
name: (已停用自动部署) frontend-ssr → Cloudflare Workers
# 前端已迁移到 frontend-svelte(SvelteKit + shadcn-svelte,包管理器 bun),
# 自动部署改由 .github/workflows/frontend-svelte.yml 负责。
#
# 这里**刻意去掉 push 触发**:两个 workflow 都部署同名 Worker(wrangler.jsonc 里的 "ipw-cn"),
# 若继续按 push 触发,改 frontend-ssr 会把新前端的线上版本覆盖掉。
# 保留 workflow_dispatch 只是为了万一还需要临时用旧站顶一下(手动触发)。
# 新前端确认稳定后,这个文件可以直接删除。
on:
push:
branches: ['main']
paths:
- 'frontend-ssr/**'
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
@@ -31,4 +35,13 @@ jobs:
run: |
cd frontend-ssr
pnpm install
pnpm run deploy --keep-vars
pnpm run deploy --keep-vars
# 部署成功后把 sitemap 全量提交给 IndexNow(Bing/Yandex/Seznam 等共用端点),
# 新页面/新文档以天级收录代替等爬虫回访。
# key 是公开所有权凭证(对应 frontend-ssr/public/<key>.txt,IndexNow 靠抓取它验证)。
- name: Submit sitemap to IndexNow
if: success()
run: |
cd frontend-ssr
node scripts/indexnow-submit.mjs --site https://nipw.cn --key d49c90fb2fb160cdd2cd4b09532bc380
+33
View File
@@ -0,0 +1,33 @@
name: Build and Deploy frontend-svelte to Cloudflare Workers
on:
push:
branches: ['main']
paths:
- 'frontend-svelte/**'
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# 包管理器是 bun(见 frontend-svelte/bun.lock),不再用 pnpm/npm
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# 部署到 Cloudflare Workers
# --keep-vars:保留 Worker 上已配置的环境变量(如 API_KEYS),别被本次部署清掉
- name: Deploy to Cloudflare Workers
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
cd frontend-svelte
bun install --frozen-lockfile
bun run build
bunx wrangler deploy --keep-vars
+5
View File
@@ -28,3 +28,8 @@ setting.json
# 本地调试脚本与实测产物(不入库,与 ipw-boce 的约定一致)
tmp-smoke/
# Agent 工具产物(本机技能安装/会话状态,不入库)
.agents/
.zcode/
skills-lock.json
+41
View File
@@ -0,0 +1,41 @@
# Changelog
本文件记录面向用户/运维的版本变更。格式参照 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本(SemVer)。
## [v4.2.0] - 2026-10-25
> DNSSEC 从不可用变为可用、请求级解析能力同步 EO 特供、全站文档图灯箱、SEO 与部署链路整体升级。
### ✨ 新功能
- **DNSSEC 验证重写**:默认解析器内置境外 DO 友好列表(1.1.1.1 / 8.8.8.8 等,IPv4/IPv6 双栈 14 地址);新增 DNSKEY 自验签(TLD 与无 A 记录域名可验);DS 与 A 并行查询;解析器不支持 DNSSEC 时给出配置提示
- **SSL 检查新增 SAN 字段**:返回证书全部主体别名(DNS + IP)
- **IndexNow 接入**:key 静态文件 + 提交端点(GET 全量 / POST 增量)+ 部署后自动向 Bing/Yandex 提交 sitemap
- **文档图灯箱**:全站文档图片点击放大(el-image-viewer),支持多图切换、滚轮缩放、旋转、Esc 关闭、路由切换自动关闭
- **高级选项输入规整**:DNS 服务器填纯域名自动补全为 `https://域名/dns-query`,缺 scheme/路径/端口的写法自动补齐
- **EO 特供获得请求级解析**:`dns=` / `dns4=` / `dns6=` / `resolve=` 全套参数与主线对齐,DialContext DNS 接管 + SSRF 预检同源
- **ipdb 后台初始化**:首次启动不再被 450MB 下载阻塞,服务降级运行、库就绪一个上线一个
- **DNSSEC 原理文档**:/dnssec 页新增原理介绍、验证链图(OpenAI 风)与字段说明
### 🛠 修复
- **DNSSEC「一直用不了」**:国内公共解析器带 DO 位也不返回 RRSIG(根因,实测 6 家);TLD/无 A 记录域没有验签路径;DS 只在失败路径查询——三项全修
- **构建 EBUSY 静默失败**:运行中的服务锁住 `.output` 导致产物不更新,`.gitignore` 同步补齐 agent 工具目录
- **内置中间件**:h3 不解码路径 `%2F`,带协议头的拨测目标经兜底转发必然拼坏——已与 fiber 语义对齐
- **/ipv6 空壳页**:改为服务端 301(消除无 h1/无 title 的低质量页)
- **sitemap**:全站显式枚举 + 权重标注
- whois 摘除自定义解析后回归系统解析器;EO 同步
### ⚡ 优化(ipdb)
- 条件下载(ETag/Last-Modified):日更不再全量重拉 450MB
- 单文件下载超时 60s → 10 分钟(大文件慢链路不再必然失败)
- bilibili 并行查询 + 负缓存 + singleflight(/v1/location 首查 292ms → 复查 1.7ms)
- 数据库加载器原子换指针:日更重载零服务真空窗
### ⚠️ 行为变化
- `/v1/whois` 不再接受 `dns=` 参数(静默忽略)
- `/ipv6` 改为服务端 301 到 `/location`
- DNSSEC 默认走境外解析器:网络屏蔽/劫持这些 UDP 53 时需配置 `dnssec-server`
- 部署/构建前需停掉 3000/4000 端口的服务进程(EBUSY 会静默失败,本次已验证此坑)
+41 -1
View File
@@ -21,6 +21,39 @@ curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o ins
- [节点 API 与协议](docs/guide/node-api.md)(业务接口、管理接口、WS 协议、上报协议)
- [公开 API(/api/v1)](docs/guide/public-api.md)(收集中心程序化接口:鉴权、端点与响应示例、公开状态接口)
## 贡献节点
把一台闲置的 Linux 机器(VPS、软路由、小主机)贡献为公共拨测节点:
```bash
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/contribute.sh -o contribute.sh && sudo bash contribute.sh
```
脚本静默完成全部步骤,中途无需任何交互:
- 自动挑选一个空闲高位端口(20000-65000),节点标识(节点 id / 注册 key / access-token)随机生成
- 默认以 WS 方式**同时接入官方两个中间件**(多活,节点主动出站连接,无需公网放行端口)
- 安装到 `/opt/lemon-ipw`,注册为 systemd 服务 `lemon-ipw` 并设置开机自启
- 结束时打印节点信息(同时备份在 `/opt/lemon-ipw/node-info.txt`)
把打印出来的信息发给管理员登记,节点即可上线(联系方式见文末)。
| 选项 | 说明 |
| --- | --- |
| `--ipdb` | 启用 IP 数据库(首次启动后台下载约 450MB,默认关闭) |
| `--port <n>` | 指定监听端口(默认随机空闲端口) |
| `--ws-url <url>` | 改为接入自建中间件,逗号分隔可填多个(默认官方两个) |
| `--version <tag>` | 指定版本(默认取 GitHub 最新 release) |
| `--gh-proxy <p>` | GitHub 下载加速前缀,如 `https://ghfast.top/` |
| `--dry-run` | 只检查环境并打印信息,不下载、不写 systemd |
日常运维:
```bash
systemctl status lemon-ipw # 查看状态
journalctl -u lemon-ipw -f # 跟踪日志
```
## 项目结构
```
@@ -30,7 +63,8 @@ ipw-cn/
├── serverless/ # 独立组件 边缘/Serverless(edgeone、edgeone-getip、lemon-getip)
├── frontend-ssr/ # Nuxt 4 SSR 前端
├── docs/ # 文档站
└── install.sh # 一键安装脚本
├── install.sh # 一键安装脚本(自建完整部署)
└── contribute.sh # 贡献节点脚本(一台闲置机器接为公共节点)
```
## 友情链接
### 来自社区的IPW版本
@@ -49,6 +83,12 @@ ipw-cn/
| DNS 查询 | miekg/dns(DoH / UDP 双通道 + 主从 failover) |
| 部署 | Docker、Cloudflare Workers、EdgeOne Pages、systemd |
## 联系方式
- 邮箱:[iduhih777@outlook.com](mailto:iduhih777@outlook.com)
- GitHub:[@nomdn](https://github.com/nomdn)
- 站点:[nipw.cn](https://nipw.cn) | 拨测控制台:[boce.wsmdn.top](https://boce.wsmdn.top)
## 许可证
[GPL-3.0](LICENSE)
+541
View File
@@ -0,0 +1,541 @@
#!/usr/bin/bash
# ============================================================
# 节点贡献脚本(静默安装)
#
# 用途:把一台闲置 Linux 机器贡献为拨测节点。
# - 全程无交互:端口自动挑一个空闲高位端口,节点标识随机生成
# - 命名与官方 install.sh 保持一致:二进制 lemonipw / 目录 /opt/lemon-ipw / 服务 lemon-ipw
# - 默认同时接入柠檬的两个中间件 WS 通道(多活,节点主动出站,无需公网放行端口)
# - 安装完成后打印节点信息,提交给管理员登记即可上线
#
# 用法:
# sudo bash contribute.sh # 全默认:系统级安装 + 自动端口 + 接 WS
# sudo bash contribute.sh --ipdb # 启用 IP 数据库(首启后台下载 ~450MB)
# sudo bash contribute.sh --port 34567 # 指定端口
# bash contribute.sh # 非 root:自动装成 systemd --user 用户级服务(不询问)
# bash contribute.sh --user # 显式指定用户级(与上面的自动降级等价)
# sudo bash contribute.sh --dry-run # 只生成并打印信息,不下载、不落盘
# ============================================================
set -e
# 默认同时接入的两个中间件(逗号分隔 = 节点侧同时连接全部、多活;
# 任一断开只重连自己,不影响另一条连接)。顺序无关,收集中心放前面便于阅读。
WS_URL_DEFAULT="wss://boce-api.api-ipw.wsmdn.top/ws,wss://middleware-1.api-ipw.wsmdn.top/ws"
CONTACT_EMAIL="iduhih777@outlook.com"
PORT_MIN=20000
PORT_MAX=65000
OPT_PORT=""
OPT_WS_URL=""
OPT_VERSION=""
OPT_GH_PROXY=""
OPT_IPDB="false"
OPT_SCOPE=""
DRY_RUN="false"
usage() {
cat <<'EOF'
用法: sudo bash contribute.sh [选项]
--port <n> 监听端口(默认:20000-65000 中的随机空闲端口)
--ws-url <url> 中间件 WS 地址,逗号分隔可多个(默认:柠檬两个中间件,同时连接)
--version <tag> 指定版本(默认:取 GitHub 最新 release)
--gh-proxy <p> GitHub 下载加速前缀,如 https://ghfast.top/
--ipdb 启用 IP 数据库(首次启动后台下载,约 450MB;默认关闭)
--user 用户级安装(systemd --user,unit 写 ~/.config/systemd/user,无需 root)
--system 系统级安装(unit 写 /etc/systemd/system,需 root;非 root 下指定会直接报错)
--dry-run 只做检查与信息生成,不下载、不写 systemd
-h, --help 显示本帮助
EOF
}
# ---------- 参数解析 ----------
while [ $# -gt 0 ]; do
key="$1"
case "$key" in
--ipdb) OPT_IPDB="true"; shift; continue ;;
--user) OPT_SCOPE="user"; shift; continue ;;
--system) OPT_SCOPE="system"; shift; continue ;;
--dry-run) DRY_RUN="true"; shift; continue ;;
-h|--help) usage; exit 0 ;;
--port|--ws-url|--version|--gh-proxy) ;;
*) echo "错误:未知参数 $key(用 --help 查看用法)" >&2; exit 1 ;;
esac
if [ $# -lt 2 ]; then
echo "错误:$key 缺少参数值(用 --help 查看用法)" >&2
exit 1
fi
case "$key" in
--port) OPT_PORT="$2" ;;
--ws-url) OPT_WS_URL="$2" ;;
--version) OPT_VERSION="$2" ;;
--gh-proxy) OPT_GH_PROXY="$2" ;;
esac
shift 2
done
# ---------- 随机值生成 ----------
# 生成 UUID(优先 /proc,其次 uuidgen,最后 /dev/urandom)
gen_uuid() {
if [ -r /proc/sys/kernel/random/uuid ]; then
cat /proc/sys/kernel/random/uuid
elif command -v uuidgen >/dev/null 2>&1; then
uuidgen | tr 'A-Z' 'a-z'
else
od -An -N16 -tx1 /dev/urandom | tr -d ' \n' \
| sed 's/\(........\)\(....\)\(....\)\(....\)\(............\)/\1-\2-\3-\4-\5/'
fi
}
# 32 位十六进制令牌(去横线的 UUID)
gen_token() { gen_uuid | tr -d '-'; }
# ---------- 端口 ----------
# 列出本机监听端口(ss 优先,netstat 次之)
listening_ports() {
if command -v ss >/dev/null 2>&1; then
ss -ltn 2>/dev/null | awk 'NR>1 {print $4}'
elif command -v netstat >/dev/null 2>&1; then
netstat -ltn 2>/dev/null | awk 'NR>2 {print $4}'
else
return 1
fi
}
# 0 = 端口已被占用,1 = 空闲
port_in_use() {
local p="$1" ports
ports=$(listening_ports || true)
if [ -n "$ports" ]; then
if printf '%s\n' "$ports" | sed 's/.*[.:]//' | grep -qx "$p"; then
return 0
fi
return 1
fi
# 兜底:连得上即视为被占用(无 ss / netstat 的极简系统)
if (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then
exec 3>&-
return 0
fi
return 1
}
# 随机高位端口(20000-65000),连续试探直到落到空闲端口
pick_port() {
local i p
for i in $(seq 1 50); do
p=$(( (RANDOM * 32768 + RANDOM) % (PORT_MAX - PORT_MIN + 1) + PORT_MIN ))
if ! port_in_use "$p"; then
printf '%s' "$p"
return 0
fi
done
echo "错误:连续 50 次未找到空闲的高位端口,请用 --port 指定" >&2
exit 1
}
# ---------- 权限与 systemd 作用域 ----------
# system = 系统级:写 /etc/systemd/system,开机自启走 multi-user.target(需 root)
# user = 用户级:写 ~/.config/systemd/user,开机自启走 default.target(systemd --user)
if [ "$(id -u)" -ne 0 ]; then
case "$OPT_SCOPE" in
system)
# dry-run 只在本地打印信息,不落盘,不必真有 root
if [ "$DRY_RUN" != "true" ]; then
echo "错误:--system 需要 root 权限(写 /etc/systemd/system),请用 sudo 运行或改用 --user" >&2
exit 1
fi
SYSTEMD_MODE="system"
;;
user)
SYSTEMD_MODE="user"
;;
*)
# 非 root 且未指定作用域:本脚本定位是"静默安装",直接降为用户级,不打断询问
SYSTEMD_MODE="user"
echo "提示:当前不是 root 环境,自动改用 systemd --user(用户级服务,仅对当前用户生效)。" >&2
echo " 如需系统级安装,请用 sudo 重新运行。" >&2
;;
esac
else
SYSTEMD_MODE="system"
if [ "$OPT_SCOPE" = "user" ]; then
SYSTEMD_MODE="user"
echo "提示:以 root 身份使用 --user,unit 会写到 root 的用户级目录(~/.config/systemd/user)" >&2
fi
fi
# ---------- 环境检查 ----------
if [ "$DRY_RUN" != "true" ]; then
if ! command -v systemctl >/dev/null 2>&1; then
echo "错误:未检测到 systemd(systemctl 不存在),无法创建守护进程" >&2
exit 1
fi
# 用户级实例依赖登录会话与 DBus,容器 / cron 等环境常常拿不到,提前拦掉
if [ "$SYSTEMD_MODE" = "user" ] && ! systemctl --user list-units >/dev/null 2>&1; then
echo "错误:当前环境无法使用 systemd --user(无用户级 systemd 实例或缺少 DBus 会话)" >&2
echo " 可改用 sudo bash contribute.sh 做系统级安装" >&2
exit 1
fi
if ! command -v wget >/dev/null 2>&1 && ! command -v curl >/dev/null 2>&1; then
echo "错误:未找到 wget 或 curl,无法下载" >&2
exit 1
fi
fi
# 运维命令按作用域切换(用户级必须带 --user,否则查的是系统级命名空间)
if [ "$SYSTEMD_MODE" = "user" ]; then
CTL_CMD="systemctl --user"
JRN_CMD="journalctl --user -u"
SCOPE_LABEL="用户级 systemd --user"
else
CTL_CMD="systemctl"
JRN_CMD="journalctl -u"
SCOPE_LABEL="系统级 systemd"
fi
sc() { if [ "$SYSTEMD_MODE" = "user" ]; then systemctl --user "$@"; else systemctl "$@"; fi; }
jc() { if [ "$SYSTEMD_MODE" = "user" ]; then journalctl --user "$@"; else journalctl "$@"; fi; }
# ---------- 架构检测 ----------
case "$(uname -m)" in
x86_64) ARCH_TAG="amd64" ;;
i386|i486|i586|i686) ARCH_TAG="386" ;;
aarch64|arm64) ARCH_TAG="arm64" ;;
armv7l|armv7hl) ARCH_TAG="armv7" ;;
armv6l) ARCH_TAG="armv6" ;;
loongarch64) ARCH_TAG="loong64" ;;
*)
echo "错误:不支持的架构 '$(uname -m)'" >&2
exit 1
;;
esac
RELEASE_ASSET="lemonipw-linux-${ARCH_TAG}"
# ---------- 命名(与 install.sh 保持一致) ----------
# 二进制 / 安装目录 / 服务名统一固定为 lemon 系列,便于运维识别与排查;
# 一台机器只跑一份,重跑本脚本 = 覆盖同名服务与目录。
BIN_BASE="lemonipw"
SERVICE_NAME="lemon-ipw"
if [ "$SYSTEMD_MODE" = "user" ]; then
INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/lemon-ipw"
SERVICE_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/${SERVICE_NAME}.service"
UNIT_USER_LINE=""
UNIT_NETWORK_LINES=""
UNIT_WANTED_BY="default.target"
else
INSTALL_DIR="/opt/lemon-ipw"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
# 系统级 unit 的 User= 行与 network-online 依赖;用户级没有这两个概念
UNIT_USER_LINE="User=root
"
UNIT_NETWORK_LINES="After=network-online.target
Wants=network-online.target
"
UNIT_WANTED_BY="multi-user.target"
fi
# 同名服务已存在(例如这台机器已经跑着一个节点):再跑一次会用新生成的
# 节点标识覆盖旧配置,旧节点在收集中心的记录就失效了,先提醒一句。
if [ "$DRY_RUN" != "true" ] && [ -f "$SERVICE_FILE" ]; then
echo "提示:已存在服务 ${SERVICE_NAME},本次会覆盖其配置并生成新的节点标识(旧标识在收集中心将失效)" >&2
fi
PORTS="${OPT_PORT:-$(pick_port)}"
case "$PORTS" in
""|*[!0-9]*)
echo "错误:端口必须是数字(当前值:$PORTS)" >&2
exit 1
;;
esac
# 10# 前缀强制十进制:避免 "0800" 这类带前导 0 的值被当成八进制解析
PORTS=$((10#$PORTS))
if [ "$PORTS" -lt 1 ] || [ "$PORTS" -gt 65535 ]; then
echo "错误:端口超出范围 1-65535(当前值:$PORTS)" >&2
exit 1
fi
if [ -n "$OPT_PORT" ] && port_in_use "$PORTS"; then
echo "警告:端口 $PORTS 当前已被占用,节点可能启动失败" >&2
fi
WS_URL="${OPT_WS_URL:-$WS_URL_DEFAULT}"
# 逐段校验(逗号分隔可多个),并去掉段内空格:节点侧虽会 trim,
# 但空格会原样进 systemd 的 Environment 值,还是在这里清掉更干净。
WS_URL_SEGMENTS=""
_old_ifs="$IFS"; IFS=','
for _seg in $WS_URL; do
# 去前导/尾随空白(IFS 只含逗号,空白不会被自动去掉)
_seg="${_seg#"${_seg%%[![:space:]]*}"}"
_seg="${_seg%"${_seg##*[![:space:]]}"}"
[ -n "$_seg" ] || continue
case "$_seg" in
ws://*|wss://*) ;;
*)
IFS="$_old_ifs"
echo "错误:WS 地址的每一段都需以 ws:// 或 wss:// 开头(问题段:${_seg})" >&2
exit 1
;;
esac
case "$_seg" in
*/ws|*/ws/) ;;
*) echo "警告:WS 地址 ${_seg} 看起来不含 /ws 路径,节点可能连不上(节点默认连 <地址>/ws)" >&2 ;;
esac
WS_URL_SEGMENTS="${WS_URL_SEGMENTS:+${WS_URL_SEGMENTS},}${_seg}"
done
IFS="$_old_ifs"
if [ -z "$WS_URL_SEGMENTS" ]; then
echo "错误:WS 地址为空(当前值:${WS_URL})" >&2
exit 1
fi
WS_URL="$WS_URL_SEGMENTS"
NODE_ID=$(gen_uuid)
NODE_KEY=$(gen_token)
ACCESS_TOKEN=$(gen_token)
# ---------- 公网地址探测(失败不阻断) ----------
detect_ip() {
local url="$1" out=""
if command -v curl >/dev/null 2>&1; then
out=$(curl -fsS -m 5 "$url" 2>/dev/null || true)
elif command -v wget >/dev/null 2>&1; then
out=$(wget -qO- --timeout=5 "$url" 2>/dev/null || true)
fi
printf '%s' "$out" | tr -d ' \t\r\n'
}
is_ipv4() { printf '%s' "$1" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}$'; }
is_ipv6() {
printf '%s' "$1" | grep -qE '^[0-9A-Fa-f:]+$' || return 1
printf '%s' "$1" | grep -q ':' || return 1
return 0
}
IPV4=$(detect_ip "https://4.wsmdn.top/")
is_ipv4 "$IPV4" || IPV4=""
IPV6=$(detect_ip "https://6.wsmdn.top/")
is_ipv6 "$IPV6" || IPV6=""
# 单栈判定:只有一侧能出网就按单栈跑,避免另一协议的拨测全失败刷错误日志
SINGLE_STACK=""
STACK_LABEL="双栈"
if [ -n "$IPV4" ] && [ -z "$IPV6" ]; then
SINGLE_STACK="ipv4"
STACK_LABEL="IPv4"
elif [ -z "$IPV4" ] && [ -n "$IPV6" ]; then
SINGLE_STACK="ipv6"
STACK_LABEL="IPv6"
fi
# ---------- 版本与下载地址 ----------
fetch_latest_version() {
local api="https://api.github.com/repos/nomdn/ipw-cn/releases/latest" raw=""
if command -v curl >/dev/null 2>&1; then
raw=$(curl -fsS -m 20 "$api" 2>/dev/null || true)
else
raw=$(wget -qO- --timeout=20 "$api" 2>/dev/null || true)
fi
printf '%s' "$raw" | grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4
}
VERSION="$OPT_VERSION"
if [ -z "$VERSION" ]; then
VERSION=$(fetch_latest_version || true)
fi
if [ -z "$VERSION" ]; then
echo "错误:无法获取最新版本号(GitHub API 不可达或限流)。可用 --version <tag> 指定,例如 --version v3.7.0" >&2
exit 1
fi
DOWNLOAD_URL="https://github.com/nomdn/ipw-cn/releases/download/${VERSION}/${RELEASE_ASSET}"
if [ -n "$OPT_GH_PROXY" ]; then
DOWNLOAD_URL="${OPT_GH_PROXY%/}/${DOWNLOAD_URL}"
fi
# ---------- 输出节点信息 ----------
print_info() {
local dry_note=""
if [ "$DRY_RUN" = "true" ]; then
dry_note="(dry-run,未实际安装)"
fi
local ipv4_disp="${IPV4:-检测失败}"
local ipv6_disp="${IPV6:-检测失败}"
local ipdb_disp="未启用(仅影响 IP 归属地/ASN 类拨测,需要时用 --ipdb 重装启用)"
if [ "$OPT_IPDB" = "true" ]; then
ipdb_disp="已启用(首次启动后台下载,约 450MB)"
fi
# WS 地址逐个列出(可能接多个中间件),首个带标签、其余对齐缩进
local ws_block=""
local _first="true" _u=""
local _old_ifs="$IFS"; IFS=','
for _u in $WS_URL; do
if [ "$_first" = "true" ]; then
ws_block=" WS 地址: ${_u}"
_first="false"
else
ws_block="${ws_block}
${_u}"
fi
done
IFS="$_old_ifs"
cat << EOF
========================================
贡献节点${dry_note}已就绪,请把以下信息提交给管理员
========================================
节点 id: ${NODE_ID}
注册 key: ${NODE_KEY}
${ws_block}
监听端口: ${PORTS}
单栈模式: ${STACK_LABEL}
access-token: ${ACCESS_TOKEN}
CORS: 不限
公网 IPv4: ${ipv4_disp}
公网 IPv6: ${ipv6_disp}
地区-运营商: 请自行填写
----------------------------------------
中间件 ws-keys(**每个 WS 地址对应的中间件都要登记**,缺一个该条连接会被拒 401):
"${NODE_ID}": "${NODE_KEY}"
中心 / 中间件 api-keys(HTTP 直连本节点时的鉴权):
"${NODE_ID}": "${ACCESS_TOKEN}"
----------------------------------------
提交邮箱: ${CONTACT_EMAIL}
说明:
1. 本节点默认以 WS 方式同时接入上面的中间件(节点主动出站连接),无需公网放行 ${PORTS}/TCP。
2. 若接收方希望以 HTTP 方式直连本节点,需放行 ${PORTS}/TCP 且该端口公网可达。
3. IP 数据库: ${ipdb_disp}
----------------------------------------
运维命令(${SCOPE_LABEL}):
状态: ${CTL_CMD} status ${SERVICE_NAME}
日志: ${JRN_CMD} ${SERVICE_NAME} -f
重启: ${CTL_CMD} restart ${SERVICE_NAME}
卸载: ${CTL_CMD} disable --now ${SERVICE_NAME} && rm -f ${SERVICE_FILE} && rm -rf ${INSTALL_DIR}
节点信息备份: ${INSTALL_DIR}/node-info.txt
========================================
EOF
}
# ---------- dry-run:只展示 ----------
if [ "$DRY_RUN" = "true" ]; then
echo "架构: $(uname -m) → ${RELEASE_ASSET}"
echo "版本: ${VERSION}"
echo "下载地址: ${DOWNLOAD_URL}"
echo "安装目录: ${INSTALL_DIR}"
echo "服务名: ${SERVICE_NAME}"
echo "作用域: ${SCOPE_LABEL}"
echo "随机: 端口=${PORTS} 节点id=${NODE_ID}"
print_info
exit 0
fi
# ---------- 下载二进制 ----------
echo "正在下载 ${VERSION} / ${RELEASE_ASSET} ..."
mkdir -p "$INSTALL_DIR"
if command -v wget >/dev/null 2>&1; then
wget -q -O "${INSTALL_DIR}/${BIN_BASE}" "$DOWNLOAD_URL"
else
# -f 让 4xx 直接失败,避免把 GitHub 的 Not Found 页面存成二进制
curl -fsSL -o "${INSTALL_DIR}/${BIN_BASE}" "$DOWNLOAD_URL"
fi
chmod +x "${INSTALL_DIR}/${BIN_BASE}"
# ---------- 生成 systemd 服务 ----------
# 仅非空项写入,避免空值覆盖节点自身默认值。
# 值内的双引号 / 反斜杠 / % 必须转义:前两者会破坏 systemd 的引号包裹,
# % 是 unit 说明符(要写成 %% 才是字面量)。
ENV_LINES=""
add_env() {
local name="$1" value="$2"
[ -n "$value" ] || return 0
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
value="${value//%/%%}"
ENV_LINES="${ENV_LINES}Environment=\"${name}=${value}\"
"
}
add_env PORTS "$PORTS"
add_env SINGLE_STACK "$SINGLE_STACK"
add_env WS_URL "$WS_URL"
add_env NODE_ID "$NODE_ID"
add_env NODE_KEY "$NODE_KEY"
add_env ACCESS_TOKEN "$ACCESS_TOKEN"
add_env IPDB "$OPT_IPDB"
# 用户级 unit 目录(~/.config/systemd/user)默认可能不存在
mkdir -p "$(dirname "$SERVICE_FILE")"
cat > "$SERVICE_FILE" << EOF
[Unit]
Description=Lemon IPW Backend Node
${UNIT_NETWORK_LINES}
[Service]
Type=simple
${UNIT_USER_LINE}WorkingDirectory=${INSTALL_DIR}
ExecStart=${INSTALL_DIR}/${BIN_BASE}
${ENV_LINES}Restart=always
RestartSec=5
[Install]
WantedBy=${UNIT_WANTED_BY}
EOF
# ---------- 启动服务 ----------
sc daemon-reload
sc enable --now "$SERVICE_NAME" >/dev/null 2>&1 || true
sleep 2
if ! sc is-active --quiet "$SERVICE_NAME"; then
echo "错误:服务未能启动,最近日志:" >&2
jc -u "$SERVICE_NAME" --no-pager -n 20 >&2 || true
exit 1
fi
# 用户级服务默认随最后一个会话结束而停止,注销后要继续跑必须开 linger
if [ "$SYSTEMD_MODE" = "user" ]; then
if loginctl show-user "$(id -un)" -p Linger 2>/dev/null | grep -q 'Linger=yes'; then
echo "linger 已启用:用户注销后服务继续运行"
elif loginctl enable-linger "$(id -un)" >/dev/null 2>&1; then
echo "已启用 linger:用户注销后服务继续运行"
else
echo "提示:未能自动启用 linger,注销后服务会被停止。请手动执行:" >&2
echo " sudo loginctl enable-linger $(id -un)" >&2
fi
fi
# ---------- 落一份信息备份(节点标识随机,记不住) ----------
{
echo "node_id=${NODE_ID}"
echo "node_key=${NODE_KEY}"
echo "access_token=${ACCESS_TOKEN}"
echo "ws_url=${WS_URL}"
echo "port=${PORTS}"
echo "single_stack=${SINGLE_STACK:-双栈}"
echo "service=${SERVICE_NAME}"
echo "install_dir=${INSTALL_DIR}"
echo "version=${VERSION}"
} > "${INSTALL_DIR}/node-info.txt"
chmod 600 "${INSTALL_DIR}/node-info.txt"
# 本地存活自检(只打本机回环,不依赖接收方是否已登记)
LOCAL_CHECK="失败"
if command -v curl >/dev/null 2>&1; then
if curl -fsS -m 5 "http://127.0.0.1:${PORTS}/" >/dev/null 2>&1; then
LOCAL_CHECK='正常({"status":"ok"})'
fi
fi
echo "本地自检: ${LOCAL_CHECK}"
print_info
+23 -3
View File
@@ -22,20 +22,40 @@
- `dnssec-server`:**DNSSEC 专用** DNS 服务器(同样支持主从逗号分隔);留空则沿用 `dns-server`;`dns-server` 与 `dnssec-server` **都留空时自动探测系统 DNS** 兜底
- `block-private-ips`:SSRF 防护开关(拒绝出站连接内网/私有地址;探测目标命中私有网段时直接返回伪造结果,禁止跨跳重定向)
- `single-stack`:单栈模式,`ipv4` / `ipv6` / 留空(双栈)。声明单栈的节点跳过另一栈的探测(直接返回 `Skipped due to SINGLE_STACK=...`),EdgeOne / Vercel 等 IPv4-only 环境建议设为 `ipv4`
- `ipdb`:IP 数据库开关。**不配置时默认启用**(首次启动自动下载约 450MB,之后每 24h 更新);仓库的 `setting.json.example` 里显式写为 `"false"`,即**示例配置是关闭状态**——关闭后不加载任何数据库,`/v1/location`、`/v1/asn` 路由不注册,适合只做网络拨测的轻量节点
- `ipdb`:IP 数据库开关。**不配置时默认启用**(首次启动自动下载约 450MB,之后每 24h 更新);仓库的 `setting.json.example` 里显式写为 `false`,即**示例配置是关闭状态**——关闭后不加载任何数据库,`/v1/location`、`/v1/asn` 路由不注册,适合只做网络拨测的轻量节点
- `cors`:允许的请求来源(逗号分隔)
- `trusted-proxies`:可信代理 IP/CIDR(逗号分隔,如 `"10.0.0.0/8,173.245.48.0/20"`)。配置后 `ClientIP` 与 `/v1/location` 归属地接口只信任这些代理转发的 `X-Forwarded-For`,直连公网的节点可防止访客伪造 XFF;**留空保持默认行为(信任所有代理,XFF 可被伪造)**
- `access-token`:API 访问令牌,留空则不启用鉴权。**它同时是 HTTP 管理面的开关**:留空时 `/v1/config`、`/v1/ota` 整组返回 `403`,只能走 WS 通道管理
- `gh-proxy`:GitHub 下载代理前缀(如 `"https://ghproxy.com/"`),用于 IP 数据库拉取与 OTA 下载加速;留空直连
- `node-ota`:OTA 开关,缺省 `true`(允许收集中心下发)。设为 `false` 时节点拒绝一切 OTA 指令并回传原因,适合只读文件系统 / 编排托管(升级走各自部署渠道)的部署。**支持运行时热改**(`PATCH /v1/config` 或 WS `config` 下发即时生效,改后按新值判定后续 OTA 指令)
- `access-log`:访问日志开关,缺省 `true`(每个请求输出一条 `http` 记录:method / path / status / latency_ms / ip)。设为 `false` 后不再输出访问日志,**适合高吞吐节点**——访问日志是每请求一次同步写,压测中即便业务逻辑极轻(`/` 健康检查)吞吐也会被它压住。关闭后 panic 兜底与其余日志不受影响,仍会记录 500 与堆栈。**支持运行时热改**(`PATCH /v1/config` 或 WS `config` 下发即时生效,无需重启)
- `ws-url`:WS 通道地址——接入独立中间件的 WebSocket 地址(如 `"wss://middleware-1.api-ipw.wsmdn.top/ws"`,须含 `wss://` 前缀与 `/ws` 路径);支持**逗号分隔多个中间件,同时连接全部(多活)**,任一断开只重连自己,不影响其他连接;留空 = 不启用 WS 客户端,走原 HTTP 接口
- `node-id`:WS 节点 id(与中间件 `api-keys`/`ws-keys` 键、前端配置的节点 `id` 一致;建议用 UUID 唯一标识)
- `node-key`:WS 注册 key(与中间件 `ws-keys[节点id]` 一致;**必填**——节点未配置该 key 时中间件拒绝注册并返回 401)
- `report-url`:**数据上报收集中心** HTTP 基址(如 `"https://collector.example.com"`,即 ipw-boce 中间件)。节点把自身观测的统计/拨测按 `report-interval-seconds` 周期上报;`ws-url` 已启用时优先走 WS `report` 消息(所有已连接中间件广播),WS 未启用或全部掉线时 HTTP POST 到 `<report-url>/report` 兜底;远端配置可覆盖(在 `remote-ignore-config` 里加上 `report-url` 可禁止覆盖)
- `report-token`:收集中心 `/report` 鉴权 token(与中间件 `report-token` 配置一致);属凭据类键,GET 快照里遮蔽为 `***`;**不随远端配置覆盖**(硬编码保护,清单见本页末的「远端配置」一节)
- `report-interval-seconds`:上报周期秒,缺省 15
- `max-response-body`:单次拨测可读的响应体上限,**按解压后大小**算,缺省 `32MB`(33554432 字节);支持裸数字与 `K`/`M`/`G` 后缀(`32MB` / `8M` / `1g` / `8MiB` 均可,大小写不敏感)。`detail` / `speed` / `ssl` 只需要一个字节数,正常网页远小于默认值;超过上限**直接返回错误,而不是截断上报**——报一个"截断后的大小/速度"比报错更误导人。**支持运行时热改**(`PATCH /v1/config` 下发即应用到出站客户端)
- `memory-limit`:Go 运行时的**软**内存上限(等价 `GOMEMLIMIT`),字节数,写法同上;**留空(=0)表示自动取容器内存限额的 90%**,探测不到容器限额就不设置、保持 Go 默认 GC 行为。不设时 Go 只按 `GOGC` 比例回收,够不到突增,小内存机器上堆会一路涨到被内核 OOM kill,中间没有减速带。**软上限不是硬闸**:必要时运行时仍会越过它,只是付出更多 GC 代价。**支持运行时热改**
以上字段均可用环境变量覆盖(`PORTS` / `SINGLE_STACK` / `DNS_SERVER` / `DNSSEC_DNS_SERVER` / `BLOCK_PRIVATE_IPS` / `IPDB` / `CORS` / `TRUSTED_PROXIES` / `GH_PROXY` / `ACCESS_TOKEN` / `WS_URL` / `NODE_ID` / `NODE_KEY` / `NODE_OTA` / `REPORT_URL` / `REPORT_TOKEN` / `REPORT_INTERVAL_SECONDS`)。需要从远端拉取配置时,设置 `remote-config-url` 或环境变量 `REMOTE_CONFIG_URL`(优先级:远端 > 环境变量 > setting.json);`remote-ignore-config` 对应的环境变量为 `REMOTE_IGNORE_CONFIG`(JSON 数组字符串)。
以上字段均可用环境变量覆盖(`PORTS` / `SINGLE_STACK` / `DNS_SERVER` / `DNSSEC_DNS_SERVER` / `BLOCK_PRIVATE_IPS` / `ACCESS_LOG` / `IPDB` / `CORS` / `TRUSTED_PROXIES` / `GH_PROXY` / `ACCESS_TOKEN` / `WS_URL` / `NODE_ID` / `NODE_KEY` / `NODE_OTA` / `REPORT_URL` / `REPORT_TOKEN` / `REPORT_INTERVAL_SECONDS` / `MAX_RESPONSE_BODY` / `MEMORY_LIMIT`)。需要从远端拉取配置时,设置 `remote-config-url` 或环境变量 `REMOTE_CONFIG_URL`(优先级:远端 > 环境变量 > setting.json);`remote-ignore-config` 对应的环境变量为 `REMOTE_IGNORE_CONFIG`(JSON 数组字符串)。
> [!TIP]
> 后端字段共 22 个,`src/setting.json.example` 只列**最小可用集**(11 个:端口 / 代理 / 单栈 / DNS / IP 库 / CORS / 令牌 / WS 身份)——其余按需加,完整定义以本页为准。
<a id="bool-switches"></a>
> [!NOTE]
> **四个开关(`ipdb` / `block-private-ips` / `node-ota` / `access-log`)共用一套字面值口径**(忽略大小写与首尾空白):
>
> - 开:`true` / `1` / `yes` / `on` / `enable` / `enabled`
> - 关:`false` / `0` / `no` / `off` / `disable` / `disabled`
>
> 不配置(空值)时各用各的默认值(`block-private-ips` / `node-ota` / `access-log` / `ipdb` 都缺省**开**)。JSON 里写布尔 `true` 或字符串 `"true"` 均可,数字 `1` / `0` 也可以 —— 四条来源(`setting.json` / 环境变量 / `PATCH /v1/config` / 远端下发)会先归一成同一张字面量表再判定,所以布尔与字符串的结果必然一致。
>
> **认不出来的值不再静默当成"开"**:启动阶段告警并按默认值处理;`PATCH /v1/config` 与远端下发会把它计入应答的 `unknown` 并保留原值,便于发现拼错的键值。
>
> 历史差异(升级时注意):旧版本里只有 `node-ota` 认 `yes`/`no`/`off`,在 `ipdb` / `block-private-ips` / `access-log` 上写 `no` / `off` 会被当成**开**,`ipdb` 还大小写敏感(`"False"` 也是开)。现在四个开关统一按上表判定。
### 运行时配置管理
@@ -51,7 +71,7 @@
- **凭据类键不回显明文**:`access-token` / `node-key` / `report-token` 在快照里是 `***`。下发时必须剔除这些键,否则会把真值覆盖成三个星号(收集中心控制台已自动剔除)。
- **凭据类键中 `access-token` / `report-token` 不受远端配置影响**:它们被硬编码进节点侧的保护名单(`configRemoteProtectedKeys`),远端配置里写了也会被跳过,并在应答 `protectedIgnored` 与日志中如实回报;本地 PATCH 不受此限。原因见本页末的「远端配置」一节。
- **节点不自行重启**:`port` / `cors` / `ipdb` / `report-interval-seconds` / `trusted-proxies` / `node-id` / `node-key` / `access-token` 是启动期固定的,改动后在应答 `restartRequired` 里如实列出,但**不会自动重启**;只有"值真的变了"才报,避免每次全量下发都误报。**`ws-url` 是例外,热生效**(控制器按新地址多退少补);`dns-server` / `dnssec-server` / `block-private-ips` / `single-stack` / `node-ota` 每次消费重读,同样即时生效。
- **节点不自行重启**:`port` / `cors` / `ipdb` / `report-interval-seconds` / `trusted-proxies` / `node-id` / `node-key` / `access-token` 是启动期固定的,改动后在应答 `restartRequired` 里如实列出,但**不会自动重启**;只有"值真的变了"才报,避免每次全量下发都误报。**`ws-url` 是例外,热生效**(控制器按新地址多退少补);`dns-server` / `dnssec-server` / `block-private-ips` / `single-stack` / `node-ota` / `access-log` / `max-response-body` / `memory-limit` 每次消费重读,同样即时生效。
- **内存改动会被重启顶掉**:节点侧优先级为 远端 > 环境变量 > `setting.json`。只 PATCH 内存(或 `persist` 写本地文件)时,重启后仍可能被 ENV 顶掉;要长期生效应 **`refresh`**(改远端配置)或把改动合并进托管配置。
> [!NOTE]
+4
View File
@@ -103,6 +103,10 @@ node .output/server/index.mjs # 默认监听 3000 端口
- **所需 Secrets**:`CLOUDFLARE_API_TOKEN`、`CLOUDFLARE_ACCOUNT_ID`
- **注意**:工作流设置了 `NITRO_PRESET: cloudflare_module`
## 缓存
缓存头不在应用侧设置,全部交由前置 CDN(EdgeOne)控制。
## 常见问题
- **部署后接口 403 / 跨域**:检查后端 `cors` 配置(独立中间件见 `middleware-go/setting.json` 的 `cors` 字段,逗号分隔允许域名);服务端转发请求不带 `Origin`,浏览器直接调用才受 CORS 限制。
+74 -14
View File
@@ -42,7 +42,7 @@ GET /info → {"version":"v1.2.3","capabilities":["probe","report","config","ot
| GET | `/v1/detail/*url` | 目标 URL(路径传入,自动补全协议) | 网站探测明细:DNS/TCP/HTTP 各阶段耗时、状态码、页面体积、下载速度、可达性;双栈分别返回 |
| GET | `/v1/ssl/*url` | 目标 URL | SSL 证书检查:剩余有效天数、颁发/主体、TLS 版本、是否过期;双栈分别返回 |
| GET | `/v1/speed/:version/*url` | `version` = `v4` / `v6`,目标 URL | 下载测速 |
| GET | `/v1/tcping/:ip` | `port`(默认 80)、`count`(默认 4,上限 20) | TCP 连通性与 RTT 统计 |
| GET | `/v1/tcping/:ip` | `port`(默认 80)、`count`(默认 4,上限 20)、`version`(见下文「请求级参数」) | TCP 连通性与 RTT 统计 |
| GET | `/v1/dns/:type/*domain` | `type` ∈ `a`/`aaaa`/`cname`/`mx`/`ns`/`ptr`/`srv`/`txt`/`caa` | DNS 记录查询(`dns-server` 支持主从 failover 与 DoH) |
| GET | `/v1/dnssec/:domain` | 域名 | DNSSEC 链式验证(`dnssec-server`,留空沿用 `dns-server`) |
| GET | `/v1/whois/:domain` | 域名 | Whois 注册信息 |
@@ -54,7 +54,36 @@ GET /info → {"version":"v1.2.3","capabilities":["probe","report","config","ot
**单栈模式**:`single-stack` 配为 `ipv4` / `ipv6` 时只跑对应栈,另一栈直接返回 `Skipped due to SINGLE_STACK=...`,不发起无意义的探测与超时。
**SSRF 防护**:`block-private-ips` 开启时,`detail` / `ssl` 对解析到内网/私有地址的目标直接返回伪造结果,出站客户端同时拒绝连接私有 IP、禁止跨跳重定向。
**SSRF 防护**:`block-private-ips` 开启时,`detail` / `ssl` 对解析到内网/私有地址的目标直接返回伪造结果,出站客户端同时拒绝连接私有 IP、禁止跨跳重定向。`tcping` 是裸 TCP、不经过出站客户端,故在 `webtest.ResolveHostWith` 内单独把关:**字面 IP**、**DNS 解析结果**、**`resolve*` 改写后的目标**三者只要落在私网/回环/链路本地/组播网段一律拒绝,该栈返回 `Error: connection to private/internal address <IP> is not allowed`(`sent` 仍为请求次数、`success=0`、`loss_rate=100`);关闭开关时三者一并放行。
### 请求级参数
除路径参数与常规 query 外,`/v1/tcping/:ip` 与所有走 DNS 的探测接口还接受一组**请求级参数**,用于按次覆盖解析行为。参数统一由 `webtest.ParseReqResolve` 解析,HTTP 拨测经 context、`tcping` 经形参透传到解析层;HTTP 与 WS 两条通道语义一致。
| 参数 | 取值 | 作用接口 | 说明 |
|------|------|----------|------|
| `dns` | 逗号分隔的 `ip:port` 或 `http(s)://` DoH URL | `detail` / `ssl` / `speed` / `tcping` / `whois` / `dns` | 本次探测改用指定 DNS 服务器,取代配置的 `dns-server` |
| `dns4` | 同上 | 同上 | 只覆盖 IPv4 族解析,**优先于 `dns`** |
| `dns6` | 同上 | 同上 | 只覆盖 IPv6 族解析,**优先于 `dns`** |
| `resolve` | 逗号分隔的 `域名:目标`,目标为 IP 或域名 | `detail` / `ssl` / `speed` / `tcping` | 强制改写某域名的解析结果(两栈通用) |
| `resolve4` | 同上 | 同上 | 只影响 IPv4 侧;非空时 IPv4 侧**只用这张表**(与 `dns4` 同口径) |
| `resolve6` | 同上 | 同上 | 只影响 IPv6 侧;非空时 IPv6 侧**只用这张表** |
| `version` | `ipv4` / `ipv6`(兼容 `v4` / `v6`,大小写与首尾空格不敏感) | `tcping` | 请求级指定单栈 |
`dns*` 与 `resolve*` 是两类正交的覆盖:前者「换服务器去问」,后者「直接改答案」,可同时使用。
**`resolve*` 的目标可以是 IP,也可以是域名**:
- 目标是 **IP**(`resolve4=example.com:1.2.3.4`,IPv6 可写 `[::1]` 或裸地址)→ 拨号阶段不再查 DNS,直接对该 IP 做私网校验后连接;
- 目标是 **域名**(`resolve=www.example.com:origin.internal`)→ 把它当作新的 host 继续走解析,解析服务器仍取 `dns*`/配置,且**不再查映射表**(因此不会绕成环)。
行为要点:
- **取值与配置侧同一套校验**(`NormalizeServers`):合法项为 `ip:port`(UDP)或 DoH URL,非法项被丢弃;**参数存在但所有项都非法时返回 `400`**,不静默忽略——避免「看起来指定了、实际按默认跑」。`resolve*` 同理:每组必须形如 `域名:目标`,目标为 IP 或形如域名(不含端口、空格),非法项丢弃、全非法返回 `400`。
- **`version` 不越权单栈配置**:节点 `single-stack` 非空时一律以配置为准(单栈机指定另一栈也跑不起来);仅当配置为空(双栈机)时 `version` 才生效。跳过文案借此区分来源:配置造成记 `SINGLE_STACK`,参数造成记 `version`。
- **作用止于「域名 → IP」这一步**:`dns*` 只更换解析服务器、`resolve*` 只改写解析结果,都不改变探测目标与端口;`dnssec` / `location` / `asn` 三类接口不接受上述参数,`whois` / `dns` 只取 `dns*`、忽略 `resolve*`(`dns` 拨测问的就是真实解析,改写会自相矛盾)。
- **SSRF 预检同源**:开启 `block-private-ips` 时,预检校验的是**拨号真正会连的地址**(即 `resolve*` 改写后的目标),不会拿原域名的解析结果去判定。`tcping` 没有独立的预检步骤,同一判断落在 `ResolveHostWith` 进入拨号之前,口径与上述一致。
- **带覆盖时按独立缓存键缓存**:规范化后的参数会拼进缓存键(见「七、缓存与并发」),不同服务器/不同映射的结果各占一条,既不串味、也不牺牲命中率。
---
@@ -198,16 +227,47 @@ POST /v1/ota
业务探测结果在节点侧缓存,避免同 key 洪打把上游打崩:
| 缓存 | 键 | TTL |
|------|-----|-----|
| `detail` | 归一化 URL | 5 分钟 |
| `ssl` | URL | 5 分钟 |
| `speed` | `url:version` | 5 分钟 |
| `tcping` | `host:port:count` | 5 分钟 |
| `whois` | 域名 | 5 分钟 |
| `asn whois` | `AS<asn>` | 5 分钟 |
| Bilibili 归属地(在线 API) | IP | 24 小时 |
| 缓存 | 键(末尾拼请求级解析片段) | TTL(成功 / 失败) |
|------|-----|--------------------|
| `detail` | 归一化 URL | 30s / 5s |
| `ssl` | URL | 30s / 5s |
| `speed` | `url:version` | 30s / 5s |
| `tcping` | `host:port:count:stack` | 30s / 5s |
| `whois` | 域名 | 30s / 5s |
| `asn whois` | `AS<asn>` | 30s / 5s |
| Bilibili 归属地(在线 API) | IP | 24 小时(不分成败) |
- **失败结果 30s 后删除**:探测失败(如目标不可达)不缓存整 5 分钟,避免故障恢复后长时间返回旧结论。
- **singleflight 合并**:`detail` / `ssl` / `tcping` / `speed` 的并发同 key 请求合并为一次真实探测。
- **定时清扫**:每 10 分钟清理过期缓存条目与 Bilibili 缓存,防止只靠"同 key 重访才淘汰"时内存无限增长。
- **TTL 按结果分档**(`cacheTTL`):成功 30s、失败 5s。失败结果不缓存太久,避免故障恢复后长时间返回旧结论;TTL 记在条目里、读时判断,无需后台协程唤醒。
- **缓存键含生效栈**:`tcping` 的键为 `host:port:count:stack`,`version=ipv6` 的结果与默认双栈不会互相污染。
- **singleflight 合并**:`detail` / `ssl` / `tcping` / `speed` 的并发同 key 请求合并为一次真实探测(合并键同缓存键)。
- **请求级解析参数进键**:`dns*` 与 `resolve*` 一并拼入,详见下一节。
- **定时清扫**:每 1 分钟清理过期缓存条目,防止只靠「同 key 重访才淘汰」时内存无限增长。
### 7.1 请求级解析参数如何进入缓存键
缓存键的基础部分只含**目标**维度(URL / host),但探测结果依赖**怎么解析**——同一域名经不同服务器、或经 `resolve*` 改写后可能指向完全不同的 IP(企业内网、CDN 调度、分线路权威、上线前的强制指向)。若把两者混在一条键里,会双向串味且无从察觉:
- 带 `dns=1.1.1.1:53` 的结果写进 `example.com` 这条 → 随后的普通请求(用配置的 `dns-server`)读出来,用户以为是自己默认 DNS 的结果;
- 反过来,普通请求的结果也会被带 `dns=` 的请求读走——用户明明指定了服务器,却拿到别人用另一台服务器查的结果;
- `resolve*` 更直接:强制指向 A 与强制指向 B 是两次不同探测,共用一条键等于互相覆盖。
因此把**规范化后的参数片段拼在键末尾**(`ReqResolve.CacheKey()`),两件事同时成立:不同解析配置各占一条,相同配置仍能命中。
```
https://example.com ← 无请求级参数
https://example.com;dns=1.1.1.1:53 ← dns=
https://example.com:v4;dns=8.8.8.8:53;dns6=[2400::1]:53 ← speed,按栈覆盖
qq.com:80:4:ipv4;dns=1.1.1.1:53 ← tcping
https://example.com;resolve4=example.com:127.0.0.1 ← resolve4,强制解析
```
片段规则:
- **字段顺序固定**为 `dns` → `dns4` → `dns6` → `resolve` → `resolve4` → `resolve6`。所以参数在 URL 里谁先谁后不影响键(`?dns6=…&dns=…` 与 `?dns=…&dns6=…` 是同一个键)。
- **`dns*` 内部按输入顺序,`resolve*` 内部按域名排序**:映射表本质是 map,迭代顺序随机;不排序的话同一请求每次算出的键都不同,缓存永远命不中。
- **无参数时片段为空串** ⇒ 键与未引入该特性时**逐字节一致**,升级后旧缓存条目照常命中。
- **按规范化结果拼**:非法项在校验阶段已被丢弃,故 `dns=1.1.1.1:53,garbage` 与 `dns=1.1.1.1:53` 落在同一个键上;`resolve4=Example.COM.:1.2.3.4` 与 `resolve4=example.com:1.2.3.4` 同理(域名统一小写、去尾点)。
- **键空间随组合增长**:每条仍受 30s / 5s 的 TTL 约束,由每分钟的清扫兜住,不会无限堆积。
- `asn` / `location` / `dnssec` 不接这些参数,键与以前相同。
> 已知残留(与本次改动无关):`detail` 与 `ssl` 的 singleflight 合并键都是「URL + 片段」,两种探测**并发**打同一 URL 时会撞在一起,后到者按另一类型断言会 panic(被 `recover` 接住 → 500)。修法是给合并键加探测类型前缀,暂未处理。
+13 -13
View File
@@ -783,8 +783,8 @@ packages:
shiki@2.1.0:
resolution: {integrity: sha512-yvKPdNGLXZv7WC4bl7JBbU3CEcUxnBanvMez8MG3gZXKpClGL4bHqFyLhTx+2zUvbjClUANs/S22HXb7aeOgmA==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
source-map-js@1.2.2:
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
engines: {node: '>=0.10.0'}
space-separated-tokens@2.0.2:
@@ -839,8 +839,8 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite@5.4.14:
resolution: {integrity: sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==}
vite@5.4.21:
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
@@ -1302,9 +1302,9 @@ snapshots:
'@ungap/structured-clone@1.3.3': {}
'@vitejs/plugin-vue@5.2.1(vite@5.4.14)(vue@3.5.41)':
'@vitejs/plugin-vue@5.2.1(vite@5.4.21)(vue@3.5.41)':
dependencies:
vite: 5.4.14
vite: 5.4.21
vue: 3.5.41
'@vue/compiler-core@3.5.41':
@@ -1313,7 +1313,7 @@ snapshots:
'@vue/shared': 3.5.41
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.1
source-map-js: 1.2.2
'@vue/compiler-dom@3.5.41':
dependencies:
@@ -1330,7 +1330,7 @@ snapshots:
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.26
source-map-js: 1.2.1
source-map-js: 1.2.2
'@vue/compiler-ssr@3.5.41':
dependencies:
@@ -1603,7 +1603,7 @@ snapshots:
dependencies:
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
source-map-js: 1.2.2
preact@10.29.8: {}
@@ -1672,7 +1672,7 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
source-map-js@1.2.1: {}
source-map-js@1.2.2: {}
space-separated-tokens@2.0.2: {}
@@ -1733,7 +1733,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@5.4.14:
vite@5.4.21:
dependencies:
esbuild: 0.21.3
postcss: 8.5.26
@@ -1806,7 +1806,7 @@ snapshots:
'@shikijs/transformers': 2.1.0
'@shikijs/types': 2.1.0
'@types/markdown-it': 14.1.2
'@vitejs/plugin-vue': 5.2.1(vite@5.4.14)(vue@3.5.41)
'@vitejs/plugin-vue': 5.2.1(vite@5.4.21)(vue@3.5.41)
'@vue/devtools-api': 7.7.10
'@vue/shared': 3.5.41
'@vueuse/core': 12.4.0
@@ -1815,7 +1815,7 @@ snapshots:
mark.js: 8.11.1
minisearch: 7.2.0
shiki: 2.1.0
vite: 5.4.14
vite: 5.4.21
vue: 3.5.41
optionalDependencies:
postcss: 8.5.26
+188 -16
View File
@@ -1,10 +1,24 @@
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
import { computed, nextTick, onMounted, onBeforeUnmount, ref } from 'vue';
import { useDark, useToggle } from '@vueuse/core';
import { Moon, Sunny, Expand } from '@element-plus/icons-vue';
import { config } from '../config/index';
const isNarrow = ref(false);
// ==================== 窄屏判定(水合前就必须定下来) ====================
// 为什么不能等到 onMounted 才算:SSR 不知道视口宽度,只能按宽屏输出 ——
// 于是整条桌面版顶栏菜单(10 个条目 + 分隔线 + 3 个下拉)会被下发给手机;
// 水合时 isNarrow 还是 false,手机就先把这一整套水合一遍
// (实测 375×812:header-menu 区域 268 次 DOM 变更、70 次事件绑定),
// 紧接着 onMounted 把它改成窄屏、再整棵销毁重建 —— 这一整轮纯属白干。
// 所以改成「服务端按请求头判定、客户端直接沿用服务端序列化下来的结论」,
// 两边输入一致 ⇒ 结论一致 ⇒ 水合期不产生任何分歧,桌面上仍是原来的形态。
// 权威值依旧由 onMounted 的 matchMedia 覆盖,管「桌面窗口被拖窄」这类请求头判不出的情形。
//
// 注意:**不要**在客户端重新嗅探 navigator.userAgent。SSR 页两边大概率巧合一致,
// 但预渲染页没有请求头(服务端恒判宽屏),客户端一嗅探就必然分叉 ——
// 实测那会让整个 /doc/** 在真手机上水合失配、Nuxt 直接渲染 500 页。
// 判定逻辑与文档见 app/composables/useShellNarrow.ts。
const isNarrow = useShellNarrow()
let mediaQueryList: MediaQueryList | null = null;
const drawer = ref(false);
@@ -90,20 +104,90 @@ const hidden = computed<Array<NavItem | NavGroup>>(
entry.kind !== 'divider' && viewportWidth.value < entry.minWidth),
)
// EP 的 Menu.init() 会在挂载时(以及菜单项集合变化时)遍历顶栏 <ul> 的**每个直接子元素**、
// 统一写 tabindex="0" —— 连纯装饰的分隔条也不放过。调用栈实测:
// Menu.init → Array.forEach → new MenuItem(el).init → setAttribute('tabindex','0')
// 分隔条是 aria-hidden 的装饰元素,留着 tabindex="0" 会被 axe 判为 aria-hidden-focus
// ("Focusable content should have tabindex=-1 or be removed from the DOM")——
// 这一条比它替换掉的 aria-required-attr 更靠后,所以必须显式改回去。
// 改的时机:子组件 mounted 早于父组件,onMounted 里 EP 已经 init 完;
// 但要等 DOM 更新落定 —— updateViewport() 会改 visible、让 el-menu 重渲染并再 init 一遍。
function fixDividerTabindex() {
document.querySelectorAll('.menu-divider').forEach((el) => {
el.setAttribute('tabindex', '-1')
})
}
// 实时同步视口宽度(resize 触发,驱动折叠)
function updateViewport() {
viewportWidth.value = window.innerWidth
nextTick(fixDividerTabindex)
}
let umamiScript: HTMLScriptElement | null = null
// ==================== 全站级 SEO 兜底 ====================
// 以下标签对每一页生效,页面级 useHead 不必再重复声明(index/whois 原有的
// canonical / og:url / twitter:card 已上收到这里,避免 head 出现两条 canonical)。
//
// canonical 按纯路径(不含 query):工具页普遍接受 ?ip= / ?site= 等参数,
// 同一内容会以无限多组 query 变体出现,SEO 审计由此判定「sitemap 缺少重要页面」;
// 统一收敛到裸路径,爬虫就不会把 ?ip=x 当独立页面收录。
const siteOrigin = config.siteUrl.endsWith('/') ? config.siteUrl : config.siteUrl + '/'
const canonicalUrl = computed(() => new URL(route.path, siteOrigin).href)
// 站点级结构化数据(WebSite + Organization):给搜索引擎的实体锚点,
// 各工具页的 WebApplication 与文档页的 BreadcrumbList 通过 url 与之关联。
const siteJsonLd = JSON.stringify({
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'WebSite',
'@id': siteOrigin + '#website',
name: config.siteName,
alternateName: 'Lemon IPW',
url: siteOrigin,
inLanguage: 'zh-CN',
description: '专业IP查询与网络测试工具平台:IPv4/IPv6地址查询、归属地定位、网站测速、DNS解析、SSL证书检测、TCPing、Whois查询。'
},
{
'@type': 'Organization',
'@id': siteOrigin + '#organization',
name: config.siteName,
url: siteOrigin,
logo: siteOrigin + 'favicon.svg',
sameAs: ['https://github.com/nomdn/ipw-cn']
}
]
})
useHead({
// 页面没写 title 时的兜底(正常页面各自已带完整标题)
titleTemplate: (title?: string) => title || config.siteName,
link: [
{ rel: 'canonical', href: canonicalUrl },
],
meta: config.noindex
? [
{ name: 'robots', content: 'noindex, nofollow' },
{ name: 'googlebot', content: 'noindex, nofollow' },
{ name: 'bingbot', content: 'noindex, nofollow' },
{ name:'baidu-site-verification', content: 'codeva-xzdbvF8gQu'}
{ name:'baidu-site-verification', content: 'codeva-xzdbvF8gQu'},
{ property: 'og:url', content: canonicalUrl },
{ property: 'og:site_name', content: config.siteName },
{ property: 'og:locale', content: 'zh_CN' },
{ name: 'twitter:card', content: 'summary_large_image' },
]
: [{ name:'baidu-site-verification', content: 'codeva-xzdbvF8gQu'}],
: [
{ name:'baidu-site-verification', content: 'codeva-xzdbvF8gQu'},
{ property: 'og:url', content: canonicalUrl },
{ property: 'og:site_name', content: config.siteName },
{ property: 'og:locale', content: 'zh_CN' },
{ name: 'twitter:card', content: 'summary_large_image' },
],
script: [
{
type: 'application/ld+json',
innerHTML: siteJsonLd,
}
]
});
onMounted(() => {
mediaQueryList = window.matchMedia('(max-width: 768px)');
@@ -123,13 +207,22 @@ onMounted(() => {
window.removeEventListener('resize', updateViewport);
});
if (!umamiScript && config.umamiScriptUrl) {
// 统计脚本延迟到浏览器空闲再注入:umami 源响应慢(实测 ~1.6s),
// 挂在 onMounted 里会和首页首屏的拨测请求抢慢速网络的带宽,
// 拖累 Speed Index;统计本身不需要抢首屏前的任何窗口。
const loadUmami = () => {
if (umamiScript || !config.umamiScriptUrl) return
umamiScript = document.createElement('script')
umamiScript.src = config.umamiScriptUrl
umamiScript.async = true
umamiScript.setAttribute('data-website-id', config.umamiWebsiteId)
document.head.appendChild(umamiScript)
}
if ('requestIdleCallback' in window) {
requestIdleCallback(loadUmami, { timeout: 4000 })
} else {
setTimeout(loadUmami, 2000)
}
})
</script>
@@ -177,12 +270,25 @@ onMounted(() => {
:ellipsis="false"
>
<el-menu-item index="0" aria-label="返回首页">
<!-- 宽屏不给 aria-label:这个 <li> 里有可见文本(右侧的 h2 站名),而 axe 的
label-content-name-mismatch 要求 aria-label 必须把可见文本包含进去 ——
写死「返回首页」只会被判不匹配(Lighthouse 桌面独有项,权重 0 不扣分,但语义是错的)。
窄屏 h2 被 v-if 隐藏、img 的 alt 留空、el-icon 也没有文本 ⇒ 那时才需要
aria-label 兜底,否则承载 role="menuitem" 的 <li> 就没有可访问名(aria-command-name)。
里面那个 <router-link> 同理(它自己也是一个可访问名有冲突的目标),
宽屏靠 h2 的「柠檬味ipw.cn」当名字,窄屏才补「返回首页」。 -->
<el-menu-item index="0" :aria-label="isNarrow ? '返回首页' : undefined">
<el-icon v-if="isNarrow" @click="drawer = !drawer"><Expand /></el-icon>
<router-link to="/" aria-label="返回首页">
<router-link to="/" :aria-label="isNarrow ? '返回首页' : undefined">
<!-- alt 留空是有意的:logo 属装饰图,可访问名由外层链接提供。
若把 alt 写成站名,宽屏下会与旁边的 h2 一起被读两遍。 -->
<el-image src="/favicon.svg" alt="" style="margin-top: 20px;" />
若把 alt 写成站名,宽屏下会与旁边的 h2 一起被读两遍。
这里用原生 <img> 而不是 el-image:el-image 默认 lazy,SSR 只输出一个
`.el-image__placeholder` 空壳,真正的 <img> 要等客户端水合后由
IntersectionObserver 插进去 ⇒ logo 在首屏 HTML 里不存在(无 JS 时永不显示,
有 JS 时也要闪一下)。原生 <img> 直接进 HTML,还顺带省掉 el-image
及其 image-viewer 的样式。宽高属性声明固有比例,显示宽度由下文
:deep(.el-menu-item a img) 定为 50px。 -->
<img src="/favicon.svg" alt="" width="50" height="50" style="margin-top: 20px;" />
<h2 style="display: inline-block; margin-left: 10px" v-if="!isNarrow">{{ config.siteName }}</h2>
</router-link>
</el-menu-item>
@@ -199,7 +305,13 @@ onMounted(() => {
<router-link :to="child.to"><p class="menu-item-text">{{ child.label }}</p></router-link>
</el-menu-item>
</el-sub-menu>
<el-divider v-else style="margin-top: 20px;height: 1.2em;" direction="vertical"/>
<!-- 分组之间的竖分隔条。原来是 <el-divider direction="vertical">,但它在客户端水合后
会被 EP 补上 tabindex="0"(SSR HTML 里干净、没有这个属性),于是 axe 把
role="separator" 当成「可聚焦控件」并要求它带 aria-valuenow —— 这就是桌面端
aria-required-attr 那一条(Lighthouse 权重 10,是桌面 a11y 掉 12 分里的最大单项)。
换成 aria-hidden 的原生 <span>:装饰元素不参与焦点,也不会被 EP 改写属性。
视觉与 .el-divider--vertical 等价,数值见下方 .menu-divider 的注释。 -->
<span v-else class="menu-divider" aria-hidden="true"></span>
</template>
<!-- 折叠项:统一收进「更多」子菜单(分组用 el-menu-item-group 保留组名) -->
<el-sub-menu v-if="hidden.length" index="more">
@@ -236,12 +348,19 @@ onMounted(() => {
且窄屏下与侧栏抽屉的可滚动区域互相挤压。 -->
<footer v-if="!isDocRoute">
<div class="one-line">
Copyright © nomdn & IP 查询 2026 | <img src="/ipv6-s1.svg" alt="IPv6 相关标识"/> | <img src="/ssl-s1.svg" alt="SSL 相关标识"/> | All right reserved
<!-- 这两张 svg 补上 width/height 属性(= 各自的固有尺寸 190×36 / 85×36):
浏览器要在图下载完之前就知道占位比例,否则会先把整行按 0 高度排一遍、
图到了再重排(Lighthouse 的 unsized-images 就是这条)。
属性只声明固有比例,不改变显示尺寸:桌面端本来就是按固有尺寸渲染,
窄屏由 style.css 的 `footer .one-line img { height:1.2em; width:auto }` 接管。 -->
Copyright © nomdn & IP 查询 2026 | <img src="/ipv6-s1.svg" alt="IPv6 相关标识" width="190" height="36"/> | <img src="/ssl-s1.svg" alt="SSL 相关标识" width="85" height="36"/> | All right reserved
</div>
<div class="one-line">
<a v-if="config.ICP" href="https://beian.miit.gov.cn/" target="_blank" rel="noreferrer" >{{ config.ICP }}</a>
<span v-if="config.ICP">&nbsp;|&nbsp;</span>
<el-image v-if="config.GongAn" style="height: 1em; width: 1em;" src="/备案图标.png" />
<!-- 同上:原生 <img> 而非 el-image(1em 见方的小图标,用不着 lazy/预览器)。
显示尺寸由行内 style 定死;width/height 属性只声明固有比例(36×40),防抖动。 -->
<img v-if="config.GongAn" src="/备案图标.png" alt="" width="36" height="40" style="height: 1em; width: 1em;" />
<a v-if="config.GongAn" :href="'https://beian.mps.gov.cn/#/query/webSearch?code=' + cleanChineseCharacters(config.GongAn)" target="_blank" rel="noreferrer" >{{ config.GongAn }}</a>
<span v-if="config.GongAn">&nbsp;|&nbsp;</span>
<a href="https://www.china-ipv6.cn/" target="_blank" rel="noreferrer" >国家IPv6发展监测平台</a>
@@ -254,6 +373,8 @@ onMounted(() => {
</div>
</footer>
<!-- 全站文档图片灯箱:委托拦截 .markdown / .markdown-body 内的图片点击 -->
<ImageLightbox />
</template>
<style scoped>
@import "~/style.css";
@@ -288,10 +409,27 @@ onMounted(() => {
margin-bottom: 20px;
}
/* 顶栏分组之间的竖分隔条(template 里那个 aria-hidden 的 span)。
数值抄自 element-plus 的 .el-divider--vertical:1px 左边框、1.2em 高、
margin 左右各 8px、vertical-align: middle;再加上原来行内 style 的 margin-top: 20px
与 height: 1.2em,保证与替换前的排布一致。 */
.menu-divider {
display: inline-block;
position: relative;
width: 1px;
height: 1.2em;
margin: 20px 8px 0;
vertical-align: middle;
border-left: 1px solid var(--el-border-color);
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
/* 正文/页脚链接色:亮色白底需 ≥4.5:1(axe color-contrast 审计线)。
#299764 白底只有 3.68:1,加深到 #1d7a4a = 5.34:1,留足余量。
主题色(按钮/高亮等 --el-color-primary 消费方)保持 #299764 不动,只分流链接。 */
--link-color: #1d7a4a;
}
html.dark {
/* 暗色模式下根元素的文字色与页面底色。
@@ -300,9 +438,10 @@ html.dark {
`html.dark[data-v-xxxx]` —— 整条规则永不匹配,底色只能靠浏览器的 color-scheme 兜底。 */
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
/* 暗底 #242424 上 #299764 对比 ~4.2:1,且再加深只会更差 —— 暗色链接保持原色 */
--link-color: #299764;
}
/* Drawer 内部链接占满一行。
注意用直接子选择器(> a):文档菜单(DocMenu)里的 <a> 嵌在 el-menu-item 里,
不能被这套「整行大按钮」规则吃掉,否则菜单项会变成上下带 1em 内边距的块。
@@ -370,7 +509,7 @@ html.dark {
/* 窄屏(≤768px,与 script 中 isNarrow 的 matchMedia 断点一致):
在水合前由 CSS 兜底隐藏宽屏菜单项,避免窄屏设备闪现宽屏布局 */
@media (max-width: 768px) {
.el-menu--horizontal > .el-divider {
.el-menu--horizontal > .menu-divider {
display: none !important;
}
.el-menu--horizontal > .el-menu-item[index="1"],
@@ -392,6 +531,39 @@ html.dark {
--el-menu-bg-color: transparent !important;
}
/* 顶栏菜单项的点击目标:让内层 <a> 撑满整个 <li>。
axe 的 target-size(WCAG 2.2 的 2.5.8,24×24 最小目标)会把 <li role="menuitem">
减掉内层 <a> 之后剩下的「侧翼」算成被遮挡的空间:EP 默认给 li 左右各 20px 内边距,
而 <a> 只占中间 97px 宽,于是报
"partially obscured (smallest space is 20px by 59px)" —— 桌面 7 条、权重 7。
有两条路:① 把 --el-menu-base-level-padding 提到 >24px —— 顶栏整体就变宽,
1350px 下直接横向溢出 45px(各档折叠阈值还得跟着重算);② 让 <a> 撑满 li —— 本条。
选②:li 宽度、文字位置、分隔条位置与改动前【逐像素一致】(CDP 量 rect 比对过),
可点区域却从 97×59 变成整项 137×59,远超 24×24。
- flex + align-items:center 是为了复现原来 inline 盒子靠 line-height 得到的垂直居中
(logo 的 img 顶边仍在 li 顶 +4.5px 处,改完不位移);
- 负 margin 抵消 li 的左右内边距,与 padding 取同一个变量,改内边距时不会脱节;
- height:100% 成立是因为 EP 给 .el-menu-item 写了确定的 height(--el-menu-item-height)。
必须写在这个【非 scoped】块里:el-menu 是 fragment 根,scope 属性在它自己身上而不是
祖先,<style scoped> 里的 :deep(.el-menu--horizontal) 选不到它。
只作用于顶栏这条 ul —— sub-menu 的弹层被 teleport 到 body,跑不到这个选择器下。 */
.el-menu--horizontal > .el-menu-item > a {
display: flex;
align-items: center;
box-sizing: border-box;
height: 100%;
margin: 0 calc(-1 * var(--el-menu-base-level-padding));
padding: 0 var(--el-menu-base-level-padding);
}
/* logo 那个 <a> 单独放开 overflow(选择器多一层 :nth-child(1),免得靠书写顺序
去压 scoped 块里 :deep(.el-menu-item a) 的 overflow:hidden)。
它的 img 带 20px 上下外边距、总高 90px 塞在 59px 的 li 里:原来 <a> 是 inline
元素、overflow 本来就不生效才没被裁;上面改成 flex 后若不显式放开,logo 会被切掉。 */
.el-menu--horizontal > .el-menu-item:nth-child(1) > a {
overflow: visible;
}
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
@@ -0,0 +1,267 @@
<script setup lang="ts">
import { ref } from 'vue'
import { isIPv6 } from 'is-ip'
/**
* 高级选项:请求级解析参数(与 ipw-ak 的 AdvancedOptions 同源)
*
* - 展开后按地址族分列:IPv4 → dns4 / resolve4,IPv6 → dns6 / resolve6
* - stack 非空时(单栈页)只渲染对应栈那一列,且不显示 dns / resolve 通用行:
* 单栈页里两者与 dns4=/dns6= 等价,双份输入框只会让人纠结该填哪个;
* tcping / speed 的前端两个栈是分开的页面,另一列的参数永远吃不到。
* - dns / resolve 是双栈页的两栈通用兜底,dns4/dns6、resolve4/resolve6 非空时覆盖它们。
*
* 用法:
* <AdvancedOptions ref="adv" /> 双栈页(dns4/dns6 + resolve4/resolve6)
* <AdvancedOptions ref="adv" stack="ipv4" /> 单栈页,只给 IPv4 一列
* <AdvancedOptions ref="adv" :show-resolve="false" /> 仅 dns4/dns6(dns、whois 页不支持强制解析)
*
* 页面里取参数:
* withQuery(baseUrl, adv.value?.query() ?? {})
*/
const props = withDefaults(
defineProps<{
/** 是否显示 resolve / resolve4 / resolve6(dns、whois 页不支持强制解析) */
showResolve?: boolean
/** 只显示某一栈的配置项;单栈页面(tcping / speed 的 v4、v6 页)传,双栈页面不传 */
stack?: 'ipv4' | 'ipv6' | ''
}>(),
{ showResolve: true, stack: '' },
)
const expanded = ref(false)
/** 两栈通用的 DNS 服务器:dns4/dns6 非空时覆盖它 */
const dns = ref('')
const dns4 = ref('')
const dns6 = ref('')
/** 两栈通用的强制解析:resolve4/resolve6 非空时覆盖它 */
const resolve = ref('')
const resolve4 = ref('')
const resolve6 = ref('')
/** 域名 HOST 判定:含点、标签为字母数字/连字符/下划线(宽松匹配,畸形值交给后端报错) */
const DOMAIN_RE = /^[a-z0-9_](?:[a-z0-9_-]*[a-z0-9_])?(?:\.[a-z0-9_](?:[a-z0-9_-]*[a-z0-9_])?)+$/i
const IPV4_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/
/**
* 把用户输入的单个 DNS 服务器条目规整成后端认可的形式:
* - 已带协议的 URL:路径为空或仅 "/" 时补上 DoH 标准路径 /dns-query
* - 无协议但含 "/"(缺了 scheme 的 DoH 地址):补 https:// 前缀,路径按上条处理
* - 纯域名 HOST(无端口无路径):包裹为 https://域名/dns-query
* - 无端口的裸 IP(v4 / 裸 v6):补默认端口 53(v6 顺手加方括号)
* - 其余(host:port、[v6]:53 等)原样返回,交给后端校验
*/
function normalizeDnsEntry(raw: string): string {
const v = raw.trim()
if (!v) return ''
// 1) 已带协议的 URL
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) return completeDohPath(v)
// 2) 无协议但含 "/":缺 scheme 的 DoH 地址
if (v.includes('/')) return completeDohPath('https://' + v)
// 3) 带方括号的 v6(含/含端口均原样,端口要求交后端校验)
if (v.startsWith('[')) return v
// 4) host:port:IP 或域名都按 UDP 语义原样(写了端口就尊重端口)
if (/^[^:]+:\d+$/.test(v)) return v
// 5) 无方括号的裸 v6:补 [..]:53
if (isIPv6(v)) return `[${v}]:53`
// 6) 裸 IPv4:补 :53
if (IPV4_RE.test(v)) return `${v}:53`
// 7) 纯域名 HOST:包裹为标准 DoH URL
if (DOMAIN_RE.test(v)) return `https://${v}/dns-query`
return v
}
/** 补齐 DoH URL 的标准路径:空路径/根路径 → /dns-query;自带路径(如 /custom)原样保留 */
function completeDohPath(url: string): string {
try {
const u = new URL(url)
if (!u.hostname) return url
if (u.pathname === '/' || u.pathname === '') u.pathname = '/dns-query'
return u.toString()
} catch {
return url
}
}
/** 逗号分隔列表逐项规整(空项丢弃) */
function normalizeDnsList(value: string): string {
return value
.split(',')
.map(p => normalizeDnsEntry(p))
.filter(Boolean)
.join(',')
}
/** 组装请求级解析参数(空值不下发;DNS 服务器逐项规整) */
function query(): Record<string, string> {
const q: Record<string, string> = {}
if (dns.value.trim()) q.dns = normalizeDnsList(dns.value)
if (dns4.value.trim()) q.dns4 = normalizeDnsList(dns4.value)
if (dns6.value.trim()) q.dns6 = normalizeDnsList(dns6.value)
if (props.showResolve) {
if (resolve.value.trim()) q.resolve = resolve.value.trim()
if (resolve4.value.trim()) q.resolve4 = resolve4.value.trim()
if (resolve6.value.trim()) q.resolve6 = resolve6.value.trim()
}
return q
}
defineExpose({ query })
</script>
<template>
<div class="adv">
<!-- 用原生 button:页面的全局 .el-button{width:165px} 会把 el-button 撑成实心大按钮 -->
<button type="button" class="adv__toggle" :aria-expanded="expanded" @click="expanded = !expanded">
<span class="adv__caret" :class="{ 'is-open': expanded }">▸</span>
<span>高级选项</span>
</button>
<div v-if="expanded" class="adv__body">
<!-- 两栈通用的 DNS 服务器(dns4/dns6 非空时覆盖它)。仅双栈页显示:
单栈页里 dns= 与 dns4=/dns6= 对该栈请求效果等价(后端按栈取值),
再给一个"两栈通用"行只会让人纠结该填哪个 —— 单栈只留对应栈那一列。 -->
<div v-if="!stack" class="adv__field adv__field--wide">
<span class="adv__label">DNS 服务器(dns,两栈通用)</span>
<el-input v-model="dns" clearable placeholder="如 1.1.1.1:53 / doh.pub / dns.google,逗号分隔" />
</div>
<!-- 两栈通用的强制解析(resolve4/6 非空时覆盖它),同上仅双栈页显示 -->
<div v-if="!stack && showResolve" class="adv__field adv__field--wide">
<span class="adv__label">强制解析(resolve,两栈通用)</span>
<el-input v-model="resolve" clearable placeholder="如 example.com:1.2.3.4,逗号分隔" />
</div>
<div class="adv__grid" :class="{ 'is-single': !!stack }">
<section v-if="!stack || stack === 'ipv4'" class="adv__col">
<h4 class="adv__col-title">IPv4</h4>
<div class="adv__field">
<span class="adv__label">DNS 服务器(dns4)</span>
<el-input v-model="dns4" clearable placeholder="如 1.1.1.1:53 / doh.pub,逗号分隔" />
</div>
<div v-if="showResolve" class="adv__field">
<span class="adv__label">强制解析(resolve4)</span>
<el-input v-model="resolve4" clearable placeholder="如 example.com:1.2.3.4" />
</div>
</section>
<section v-if="!stack || stack === 'ipv6'" class="adv__col">
<h4 class="adv__col-title">IPv6</h4>
<div class="adv__field">
<span class="adv__label">DNS 服务器(dns6)</span>
<el-input v-model="dns6" clearable placeholder="如 [2400:3200::1]:53,逗号分隔" />
</div>
<div v-if="showResolve" class="adv__field">
<span class="adv__label">强制解析(resolve6)</span>
<el-input v-model="resolve6" clearable placeholder="如 example.com:[::1]" />
</div>
</section>
</div>
<p class="adv__hint">
DNS 服务器可填 <code>ip:port</code>、DoH 地址或纯域名(自动补全为
<code>https://域名/dns-query</code>),逗号分隔多个;强制解析格式
<code>域名:IP</code> 或 <code>域名:CNAME</code>,IPv6 目标写 <code>[::1]</code>。{{
stack ? '参数直接作用于本栈解析;双栈页的 dns / resolve 通用项在此页不展示。'
: 'dns4 / dns6、resolve4 / resolve6 非空时覆盖 dns / resolve,分别只作用于对应栈。'
}}
</p>
</div>
</div>
</template>
<style scoped>
.adv {
margin-top: 0.25rem;
}
.adv__toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
width: auto;
height: auto;
margin: 0;
padding: 0;
border: 0;
background: none;
color: var(--el-color-primary, #299764);
font: inherit;
font-size: 0.85rem;
letter-spacing: 0.03em;
cursor: pointer;
}
.adv__caret {
display: inline-block;
font-size: 0.7rem;
transition: transform 150ms ease;
}
.adv__caret.is-open {
transform: rotate(90deg);
}
.adv__body {
margin-top: 0.5rem;
padding: 0.75rem 0.9rem;
border: 1px solid var(--el-border-color-lighter, #ebeef5);
border-radius: 6px;
background: var(--el-fill-color-lighter, #fafafa);
}
.adv__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 1rem;
}
/* 单栈页只渲染一列:撑满,别再留半幅空白 */
.adv__grid.is-single {
grid-template-columns: 1fr;
}
.adv__col-title {
margin: 0 0 0.4rem;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--el-text-color-secondary, #909399);
}
.adv__field {
margin-bottom: 0.5rem;
}
.adv__field--wide {
margin-bottom: 0.75rem;
}
.adv__label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.78rem;
color: var(--el-text-color-secondary, #909399);
}
.adv__hint {
margin: 0.35rem 0 0;
font-size: 0.75rem;
line-height: 1.6;
color: var(--el-text-color-secondary, #909399);
}
.adv__hint code {
padding: 0 0.2rem;
border-radius: 2px;
background: var(--el-fill-color, #f0f2f5);
}
@media (max-width: 768px) {
.adv__grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
/**
* 全站文档图片灯箱:全局点击委托 + Element Plus 自带查看器(el-image-viewer)。
*
* 在 app.vue 挂载一次即覆盖所有 v-html 文档容器(.markdown / .markdown-body),
* 页面无需任何接线。命中规则:容器内的 <img>,且不在 <a> 链接里(放行导航)。
* 查看器自带 Esc 关闭、滚轮缩放、旋转工具栏;hide-on-click-modal 允许点遮罩关闭。
*/
const visible = ref(false)
const urlList = ref<string[]>([])
const initialIndex = ref(0)
function onClick(e: MouseEvent) {
const target = e.target as HTMLElement
const img = target.closest?.<HTMLImageElement>('.markdown img, .markdown-body img')
if (!img) return
if (img.closest('a')) return // 图片套在链接里 → 放行导航,不开灯箱
if (img.complete && img.naturalWidth === 0) return // 加载失败的裂图不进灯箱
const src = img.currentSrc || img.src
if (!src) return
e.preventDefault()
// 收集同一容器内的全部图片:EP 查看器由此获得左右箭头/键盘多图切换
// (doc 页一篇文章带几十张截图,单图模式等于白瞎了查看器的切换能力)
const container = img.closest('.markdown, .markdown-body')
const imgs = container ? Array.from(container.querySelectorAll('img')) : [img]
const list = imgs
.filter(i => !(i.complete && i.naturalWidth === 0))
.map(i => i.currentSrc || i.src)
.filter(Boolean)
if (!list.length) return
urlList.value = list
initialIndex.value = Math.max(0, list.indexOf(src))
visible.value = true
}
// 打开期间锁 body 滚动(EP 查看器自带 Esc 关闭与缩放/旋转工具栏)
watch(visible, (v) => {
document.body.style.overflow = v ? 'hidden' : ''
})
// 路由切换时强制关闭:灯箱开着按浏览器后退,查看器不能跨页面残留
const route = useRoute()
watch(
() => route.path,
() => {
visible.value = false
}
)
onMounted(() => document.addEventListener('click', onClick))
onBeforeUnmount(() => {
document.removeEventListener('click', onClick)
document.body.style.overflow = ''
})
</script>
<template>
<el-image-viewer
v-if="visible"
:url-list="urlList"
:initial-index="initialIndex"
:z-index="3000"
hide-on-click-modal
@close="visible = false"
/>
</template>
<style>
/* 文档图片可点的视觉暗示(非 scoped:v-html 内容拿不到 scope id) */
.markdown img,
.markdown-body img {
cursor: zoom-in;
}
</style>
@@ -0,0 +1,34 @@
/**
* 顶栏 / 抽屉「宽屏还是窄屏」的唯一判定入口(服务端算一次,客户端沿用)。
*
* 为什么客户端不自己嗅探 navigator.userAgent:
* SSR 出的 HTML 是跟着【服务端】的判定走的。客户端若各算各的,只要两边结论不一致,
* Vue 水合就会整棵树失配 —— 实测在预渲染的 /doc/** 上是直接抛
* `Cannot read properties of null (reading 'nodeType')`,然后被 Nuxt 渲染成 500 页
* (真手机打开文档站必然复现:预渲染时没有请求头 ⇒ 服务端恒判宽屏)。
* 把服务端的判定序列化进 payload 当客户端的初值,首帧 vdom 与 HTML 必然一致;
* 顺带还免疫「CDN 把桌面版缓存回给手机」——那种情况下客户端也会乖乖按 HTML 走宽屏。
*
* 权威值仍由 app.vue 的 onMounted 用 matchMedia 覆盖,管「桌面窗口被拖窄」这类
* 请求头判不出的情形。窄屏判定是全局共享状态(useState),文档页据此决定要不要
* 渲染侧栏,所以两边读的必须是同一个 ref。
*/
const CRAWLER_RE =
/bot|crawler|spider|slurp|bingbot|baiduspider|yandex|duckduckbot|facebookexternalhit|preview/i
export function uaIsNarrow(ua: string, chMobile?: string) {
if (!ua && !chMobile) return false // 两个都拿不到(含预渲染):按宽屏,与改造前一致
if (CRAWLER_RE.test(ua)) return false // 抓取端一律宽屏,否则顶栏导航链接会从 HTML 里消失
if (chMobile) return chMobile === '?1' // Chromium 的 Client Hint,最准
return /Android|iPhone|iPod|Windows Phone|webOS|BlackBerry|Opera Mini|IEMobile|Mobile/i.test(ua)
}
export function useShellNarrow() {
// 只有服务端拿得到请求头;客户端分支只为类型与「init 被意外调用」兜底。
const headers: Record<string, string | undefined> = import.meta.server
? useRequestHeaders(['user-agent', 'sec-ch-ua-mobile'])
: {}
return useState<boolean>('ipw-shell-narrow', () =>
uaIsNarrow(headers['user-agent'] || '', headers['sec-ch-ua-mobile']),
)
}
+2 -2
View File
@@ -9,10 +9,10 @@ html.dark .markdown-body {
/* GitHub 暗色主题的核心颜色 */
--color-fg-default: #e6edf3;
--color-fg-muted: #8b949e;
--color-fg-accent: #3EAF7C;
--color-fg-accent: #299764;
--color-bg-default: #242424;
--color-bg-muted: #080707;
--color-bg-accent: #3EAF7C;
--color-bg-accent: #299764;
--color-border-default: #242424;
--color-border-muted: #242424;
+5 -4
View File
@@ -145,6 +145,7 @@ function getStatusClass(status: string): string {
<el-input
v-model="tmpIP"
placeholder="请输入IP地址(如:1.1.1.1)"
clearable
/>
<el-button
@click="queryASN()"
@@ -175,14 +176,14 @@ function getStatusClass(status: string): string {
<tr v-if="result.geolite2_asn?.asn">
<td class="table-label">Maxmind ASN</td>
<td class="table-value">
AS{{ result.geolite2_asn.asn }}
{{ result.geolite2_asn.asn }}
<span v-if="result.geolite2_asn.org" style="color: #999; font-size: 0.9em;">{{ result.geolite2_asn.org }}</span>
</td>
</tr>
<tr v-if="result.dbip_asn?.asn">
<td class="table-label">DB-IP ASN</td>
<td class="table-value">
AS{{ result.dbip_asn.asn }}
{{ result.dbip_asn.asn }}
<span v-if="result.dbip_asn.org" style="color: #999; font-size: 0.9em;">{{ result.dbip_asn.org }}</span>
</td>
</tr>
@@ -261,10 +262,10 @@ function getStatusClass(status: string): string {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+34 -7
View File
@@ -4,7 +4,8 @@ import { useRoute } from 'vue-router'
import { config } from '../../config/index';
import { useMiddlewareFetch } from '../../utils/middleware';
import { isIPv6 } from 'is-ip';
import { formatTime } from '../../utils/tools';
import { formatTime, withQuery } from '../../utils/tools';
import AdvancedOptions from '../components/AdvancedOptions.vue';
const route = useRoute()
useHead({
@@ -74,12 +75,15 @@ const recordTypes = [
{ value: 'ptr', label: 'PTR 记录' }
]
// 高级选项:DNS 拨测问的就是真实解析结果,只取 DNS 服务器(dns4/dns6),不支持强制解析
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
const dnsServerFetches = [
...config.APIBaseURL.DualStack,
...config.APIBaseURL.IPv4,
...config.APIBaseURL.IPv6
].map((server) => {
const url = computed(() => "/middleware/" + server.id + "/dns/" + recordType.value + "/" + encodeURIComponent(domain.value));
const url = computed(() => withQuery("/middleware/" + server.id + "/dns/" + recordType.value + "/" + encodeURIComponent(domain.value), adv.value?.query() ?? {}));
const { data, error, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
@@ -199,9 +203,11 @@ const doc = page.value;
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:example.com)"
placeholder="请输入域名(如:example.com)"
clearable
/>
<el-select v-model="recordType" style="width: 150px;" class="custom-height-select">
<!-- aria-label 落到 EP select 内部的原生 input 上,否则 Lighthouse 的 label 审计必挂 -->
<el-select v-model="recordType" style="width: 150px;" class="custom-height-select" aria-label="DNS 记录类型">
<el-option
v-for="item in recordTypes"
:key="item.value"
@@ -218,6 +224,9 @@ const doc = page.value;
查询
</el-button>
</div>
<AdvancedOptions ref="adv" :show-resolve="false" />
<div class="result-section">
<table class="result-table" v-if="results.length > 0">
<thead>
@@ -292,12 +301,30 @@ const doc = page.value;
@import "../style.css";
@import "../../assets/css/tool-common.css";
/* 本页行内容比通用行宽(input 420 + select 150 + button 165 + 间距 ≈ 765px),
换行点更高:容器低于 780px(≈自然换行点)时换行元素铺满整行,
消除 615~780px 之间「换行但仍固定宽」的夹缝(tool-common 的通用阈值只覆盖到 615px)。
select 的 width 是行内 style,需 !important 才能覆盖。 */
@container (max-width: 780px) {
.el-input,
.el-select,
.el-button {
width: 100% !important;
margin-right: 0 !important;
}
.el-input,
.el-select {
margin-bottom: 10px;
}
}
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
.markdown :deep(a) {
color: #3EAF7C !important;
color: #299764 !important;
font-size: 1.3em;
text-decoration: none
}
@@ -305,11 +332,11 @@ const doc = page.value;
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon {
+33 -5
View File
@@ -168,6 +168,11 @@ async function getUserIP() {
}
return userIP.value
}
const { data: page, error } = await useAsyncData('dnssec-page', () =>
$fetch('/api/markdown/dnssec')
)
const doc = page.value;
</script>
<template>
@@ -182,6 +187,7 @@ async function getUserIP() {
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:example.com)"
clearable
/>
<el-button
@click="checkDNSSEC()"
@@ -255,10 +261,12 @@ async function getUserIP() {
</table>
</div>
<div class="markdown">
<div v-html="doc"></div>
</div>
<blockquote>
DNSSEC 原理介绍即将上线<br/>
DNSSEC 通过 DNSKEY、RRSIG、DS 记录为 DNS 数据提供完整性验证,防止 DNS 欺骗攻击。<br/>
如果显示 "验证失败",可能原因:域名未配置 DNSSEC、DNSKEY 密钥不匹配、DS 记录缺失等。<br/>
如果显示 "验证失败",可能原因:域名未配置 DNSSEC、DNSKEY 密钥不匹配、DS 记录缺失等(详见上方原理介绍)。<br/>
<a href="/dns" target="_blank">DNS 解析查询</a> | <a href="/whois" target="_blank">Whois 查询</a><br/>
访客IP: {{ userIP || '获取中...' }},您的网络{{ isIPv6(userIP) ? 'IPv6' : 'IPv4' }}优先<br/>
</blockquote>
@@ -269,6 +277,26 @@ async function getUserIP() {
@import "../style.css";
@import "../../assets/css/tool-common.css";
.markdown :deep(a) {
color: #299764 !important;
font-size: 1.3em;
text-decoration: none
}
/* 1240px 宽的验证链图必须缩进 60% 的内容列,桌面端也要约束(仅移动端限制不够) */
.markdown :deep(img) {
height: auto;
max-width: 100%;
}
@media (max-width: 768px) {
.markdown :deep(a) {
font-size: 1.1em;
}
.markdown :deep(img) {
height: auto;
max-width: 100%;
}
}
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
@@ -284,10 +312,10 @@ async function getUserIP() {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+32 -3
View File
@@ -1,5 +1,10 @@
<script setup lang="ts">
import { getDocMeta } from '../../../config/doc'
import { config } from '../../../config/index'
// 窄屏下这条侧栏被 CSS 隐藏(导航在顶栏抽屉里),但照样要花钱水合 ——
// 详见 app/pages/doc/index.vue 的同名注释。
const isNarrow = useShellNarrow()
const route = useRoute();
@@ -7,10 +12,34 @@ const path = computed(() => route.path)
const { data: page } = await useFetch(() => `/api/markdown${path.value}`)
const meta = computed(() => getDocMeta(route.path))
// <title>/meta 优先取 seoXxx 加长版(Bing 审计判文档页标题与描述过短);
// 侧栏菜单与卡片仍读原字段展示,不受影响。
const seoTitle = computed(() => meta.value.seoTitle ?? meta.value.title)
const seoDescription = computed(() => meta.value.seoDescription ?? meta.value.description)
const docUrl = computed(() => new URL(route.path, config.siteUrl).href)
useHead({
title: computed(() => meta.value.title),
title: seoTitle,
titleTemplate: computed(() => `%s | ${config.siteName}`),
meta: [
{ name: 'description', content: computed(() => meta.value.description) }
{ name: 'description', content: seoDescription },
{ property: 'og:title', content: computed(() => `${seoTitle.value} | ${config.siteName}`) },
{ property: 'og:description', content: seoDescription },
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
{ property: 'og:type', content: 'article' },
],
script: [
{
type: 'application/ld+json',
innerHTML: computed(() => JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: '首页', item: config.siteUrl },
{ '@type': 'ListItem', position: 2, name: '文档', item: new URL('/doc', config.siteUrl).href },
{ '@type': 'ListItem', position: 3, name: meta.value.title, item: docUrl.value }
]
}))
}
]
})
</script>
@@ -18,7 +47,7 @@ useHead({
<div class="box">
<!-- 文档导航只有一份实现(app/components/DocMenu.vue)。
宽屏:就是这条左侧固定侧栏;窄屏:本侧栏隐藏,改由顶部抽屉菜单承载 -->
<DocMenu class="sidebar-menu" />
<DocMenu v-if="!isNarrow" class="sidebar-menu" />
<div class="content">
<div class="markdown-body" v-if="page">
<div v-html="page" />
+33 -3
View File
@@ -1,5 +1,13 @@
<script setup lang="ts">
import { getDocMeta } from '../../../config/doc'
import { config } from '../../../config/index'
// 窄屏(≤768px)下这条侧栏被 CSS 整个隐藏,导航改由顶栏抽屉承载。
// 但「只是藏起来」不等于不花钱:Vue 依旧会把整棵 40 项的 el-menu 水合一遍
// (实测手机端 doc-sidebar 区域 53 次事件绑定)。所以直接按判定结果不渲染。
// 这里用的是全局共享的窄屏状态(服务端按请求头判定并随 payload 下发),
// 与 app.vue 同一份 —— 首帧与服务端 HTML 一致,不会有水合分歧。
const isNarrow = useShellNarrow()
const { data: page } = await useAsyncData('doc-index', () =>
$fetch('/api/markdown/doc/index')
@@ -7,10 +15,32 @@ const { data: page } = await useAsyncData('doc-index', () =>
const doc = page.value;
const meta = getDocMeta('/doc')
// <title>/meta 优先取 seoXxx 加长版(Bing 审计判文档页标题与描述过短);
// 侧栏菜单与卡片仍读原字段展示,不受影响。
const seoTitle = meta.seoTitle ?? meta.title
const seoDescription = meta.seoDescription ?? meta.description
useHead({
title: meta.title,
title: seoTitle,
titleTemplate: `%s | ${config.siteName}`,
meta: [
{ name: 'description', content: meta.description }
{ name: 'description', content: seoDescription },
{ property: 'og:title', content: `${seoTitle} | ${config.siteName}` },
{ property: 'og:description', content: seoDescription },
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
{ property: 'og:type', content: 'website' },
],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: '首页', item: config.siteUrl },
{ '@type': 'ListItem', position: 2, name: meta.title, item: new URL('/doc', config.siteUrl).href }
]
})
}
]
})
</script>
@@ -18,7 +48,7 @@ useHead({
<div class="box">
<!-- 文档导航只有一份实现(app/components/DocMenu.vue)。
宽屏:就是这条左侧固定侧栏;窄屏:本侧栏隐藏,改由顶部抽屉菜单承载 -->
<DocMenu class="sidebar-menu" />
<DocMenu v-if="!isNarrow" class="sidebar-menu" />
<div class="content">
<div class="markdown-body" v-html="doc"></div>
</div>
+139 -54
View File
@@ -5,14 +5,13 @@ import { config } from '../../config/index';
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
import { isIPv4 } from '../../utils/tools';
const route = useRoute();
const canonicalUrl = computed(() => new URL(route.path, config.siteUrl).toString());
// canonical 由 app.vue 全局下发(见下方 useHead 注释)。
useHead({
title: `${config.siteName} | IP查询工具 | IPv4/IPv6地址查询与网络测试平台`,
titleTemplate: '%s',
link: [
{ rel: 'canonical', href: canonicalUrl.value }
],
// canonical / og:url / twitter:card 由 app.vue 全局下发(按纯路径去 query),
// 这里不再重复声明,避免 head 里出现两条 canonical。
meta: [
{ name: 'description', content: `${config.siteName}提供专业的IP查询服务,支持IPv4和IPv6地址在线查询、归属地定位、网络测速、DNS解析、SSL证书检测、TCPing测试等多种网络工具,致力于推进IPv6规模部署和应用,打造去中心化的IP查询平台` },
{ name: 'keywords', content: 'ipv6,ipv4,ip查询,ipv6查询,ipv4查询,ipv6地址查询,ipv4地址查询,网络测速,DNS查询,SSL检测,TCPing,IP归属地,IPv6优先' },
@@ -20,10 +19,34 @@ useHead({
{ property: 'og:description', content: '提供IPv4/IPv6地址查询、网络测速、DNS解析、SSL检测等全方位网络诊断工具,助力IPv6普及与部署' },
{ property: 'og:image', content: `${config.siteUrl}favicon.svg` },
{ property: 'og:type', content: 'website' },
{ property: 'og:url', content: canonicalUrl.value },
{ name: 'twitter:card', content: 'summary_large_image' },
],
script: [
{
// —— IP 探测提前到 HTML 解析期(LCP 优化)——
// 原来三个探测请求在 onMounted(水合完成后)才发出,模拟链路是
// 「水合 → 发请求 → 等服务端 → 回填渲染」,IPv6 行作为 LCP 元素被整段串行拖住
// (mobile 模拟 LCP 2275ms,满分线约 1600ms)。
// 这里在 head 里用内联脚本立刻发起,与 render-blocking CSS 的下载、
// hydration 三路并行;结果挂 window.__ipProbe,完成时派发 ip-probe 事件,
// onMounted 只负责消费(见下方),不再等水合才起步。
// 用原生 fetch:此刻 ofetch 还没加载;不带 retry 的语义与旧版 retry:false 一致
// (底层 fetch 本来就不会重试)。
innerHTML: [
'window.__ipProbe = {};',
'(function () {',
` function emit(key) {`,
` return function (text) {`,
` window.__ipProbe[key] = text;`,
` document.dispatchEvent(new CustomEvent('ip-probe', { detail: key }));`,
` };`,
` }`,
// 与 onMounted 的语义一致:三接口各自独立、失败静默(保持「查询中」占位)
` fetch(${JSON.stringify(config.v6OnlyAPI)}).then(function (r) { return r.text(); }).then(emit('v6')).catch(function () {});`,
` fetch(${JSON.stringify(config.v4OnlyAPI)}).then(function (r) { return r.text(); }).then(emit('v4')).catch(function () {});`,
` fetch(${JSON.stringify(config.DualStackAPI)}).then(function (r) { return r.text(); }).then(emit('dual')).catch(function () {});`,
'})();',
].join('\n'),
},
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
@@ -31,7 +54,7 @@ useHead({
'@type': 'WebApplication',
name: config.siteName,
description: `${config.siteName}提供专业的IP查询服务,支持IPv4和IPv6地址在线查询、归属地定位、网络测速、DNS解析、SSL证书检测、TCPing测试等多种网络工具,致力于推进IPv6规模部署和应用,打造去中心化的IP查询平台`,
url: canonicalUrl.value,
url: new URL(route.path, config.siteUrl).toString(),
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Any',
offers: {
@@ -74,56 +97,91 @@ curl ${config.v6OnlyAPI}
# (访问 IPv4/IPv6 双栈站点,如果返回 IPv6 地址,则 IPv6 访问优先)
curl ${config.DualStackAPI}
`.trim(); // 关键:去掉首尾多余的空行
const highlightedCode = ref('');
// 代码高亮放在 SSR 期算:HTML 下发时就已经是带 <span> 的高亮结果,
// 于是 ①客户端不必再下载/执行 shiki(引擎 + 语言 + 主题 ≈ 164KB);
// ②不会再有"先出纯文本兜底、若干秒后才换成高亮"的那次块高变化(可见重排)。
//
// 这段代码是构建期常量(只依赖 config,不随请求变化),所以结果可以安全地随 HTML 一起缓存。
//
// import 仍然写在 handler 内部(而不是文件顶层):动态 import 会把它切成独立 chunk,
// 只在真正要高亮时加载,不会像顶层静态 import 那样被塞进首屏的 modulepreload 列表。
// 首次进入是 SSR 执行的;客户端水合直接读 payload(Nuxt 会序列化 useAsyncData 的结果),
// 只有客户端软导航到本页时才在浏览器里跑一次。
const { data: highlightedCode } = await useAsyncData(
'home-curl-code',
async () => {
const { highlightCode } = await import('../../utils/shiki');
return await highlightCode(code, 'bash');
},
// 默认值必须留空:模板是 v-if="highlightedCode" / v-else 的双分支,
// 给成 code 会让 v-if 命中、把纯文本当 HTML 塞进 v-html(fallback 分支永远走不到)。
// 留空则失败时自然落到 v-else 的 code-block--fallback,与改动前 catch 里清空的行为一致。
{ default: () => '' },
);
const ipAddress = ref('');
const yourIPv4 = ref('');
const yourIPv6 = ref('');
onMounted(() => {
// 代码高亮与三个 IP 查询互不依赖,一起发出即可。
// 注意别写成"先 await 高亮再发请求":shiki 首次要把语言包拉下来,会把三次查询整体推后数百毫秒。
//
// 用动态 import 而非顶层静态 import:shiki 的引擎核心 + 语言/主题注册表约 160KB,
// 首页只为这 4 行 curl 示例需要它。静态 import 会把它拽进首屏同步加载的关键路径
// (构建后体现为一个 164KB 的 chunk 出现在 modulepreload 列表);动态 import 后
// 它单独成 chunk,首屏渲染不再等它。
void import('../../utils/shiki')
.then(({ highlightCode }) => highlightCode(code, 'bash'))
.then((html) => {
highlightedCode.value = html;
})
.catch(() => {
highlightedCode.value = '';
});
// (代码高亮已移到 setup 顶部的 useAsyncData,随 SSR 一起产出。)
// 三个地址各自独立请求、各自渲染:谁先回来谁先上屏。
// 不用 Promise.allSettled 包起来等齐——那样只要有一个慢(例如纯 IPv4 网络下 v6 接口要等超时),
// 已经拿到的 IPv4 与双栈结果也得一起干等。
// —— 三个 IP 探测:请求已由 head 内联脚本提前发出(见 useHead),这里只消费 ——
// 内联脚本把结果挂 window.__ipProbe[key] 并派发 ip-probe 事件。三种时序都要覆盖:
// ① 值已就位(事件在 onMounted 之前派发过)→ 同步读 store 直接上屏;
// ② 值未到 → 挂监听等事件(收到匹配 key 才移除,不能 once——
// 三个 key 共用同一事件名,once 会把别人的事件当自己的吃掉);
// ③ window.__ipProbe 不存在(内联脚本被 CSP 拦掉/异常)→ 退回自己发请求,行为同旧版。
//
// retry: false —— ofetch 默认会对 GET 重发一次:网络层失败时它拿不到 response,就按 500 计
// (500 在默认重试白名单里),且默认延迟为 0,立即重发。于是"这个节点连不上"会被原样重做一遍,
// 用户白等一倍时间(实测 ofetch 1.5.1:裸调用触发 2 次真实 fetch,传 retry:false 后只剩 1 次;
// 浏览器自身不会重试,底层 fetch 对同一请求只被调用过一次)。
// 浏览器自身不会重试,底层 fetch 对同一请求只被调用过一次)。仅 ③ 的兜底路径会走到 $fetch。
const ipFetchOptions = { retry: false } as const;
const probeStore = (window as any).__ipProbe as Record<string, string> | undefined;
$fetch<string>(config.DualStackAPI, ipFetchOptions)
.then((ip) => {
ipAddress.value = ip;
})
.catch(() => {
// 单个接口失败不影响其它两行展示,保持"查询中"占位
});
$fetch<string>(config.v4OnlyAPI, ipFetchOptions)
.then((ip) => {
yourIPv4.value = ip;
})
.catch(() => {});
$fetch<string>(config.v6OnlyAPI, ipFetchOptions)
.then((ip) => {
yourIPv6.value = ip;
})
.catch(() => {});
const consumeProbe = (key: string, apply: (ip: string) => void): boolean => {
if (!probeStore) return false; // ③ 内联脚本没跑起来
if (typeof probeStore[key] === 'string') {
apply(probeStore[key]); // ① 值已就位
return true;
}
const onProbe = (e: Event) => {
if ((e as CustomEvent).detail !== key) return;
document.removeEventListener('ip-probe', onProbe);
apply(probeStore[key]); // ② 事件到达
};
document.addEventListener('ip-probe', onProbe);
return true;
};
// 三个地址各自独立请求、各自渲染:谁先回来谁先上屏。
// 不用 Promise.allSettled 包起来等齐——那样只要有一个慢(例如纯 IPv4 网络下 v6 接口要等超时),
// 已经拿到的 IPv4 与双栈结果也得一起干等。
if (!consumeProbe('dual', (ip) => { ipAddress.value = ip; })) {
$fetch<string>(config.DualStackAPI, ipFetchOptions)
.then((ip) => {
ipAddress.value = ip;
})
.catch(() => {
// 单个接口失败不影响其它两行展示,保持"查询中"占位
});
}
if (!consumeProbe('v4', (ip) => { yourIPv4.value = ip; })) {
$fetch<string>(config.v4OnlyAPI, ipFetchOptions)
.then((ip) => {
yourIPv4.value = ip;
})
.catch(() => {});
}
if (!consumeProbe('v6', (ip) => { yourIPv6.value = ip; })) {
$fetch<string>(config.v6OnlyAPI, ipFetchOptions)
.then((ip) => {
yourIPv6.value = ip;
})
.catch(() => {});
}
});
</script>
@@ -136,10 +194,10 @@ onMounted(() => {
</header>
</div>
<div class="content">
<div class="one-line">
<div class="one-line ip-row">
<b>IPv4</b>&nbsp<p>{{ yourIPv4 }} </p>&nbsp<RouterLink :to="`/location?ip=${yourIPv4}`" target="_blank">查询归属地</RouterLink>
</div>
<div class="one-line">
<div class="one-line ip-row ip-row-v6">
<b>IPv6</b>&nbsp<p v-if="yourIPv6">{{ yourIPv6 }}</p><RouterLink :to="`/location?ip=${yourIPv6}`" target="_blank" v-if="yourIPv6">&nbsp查询归属地</RouterLink><RouterLink v-else to="/doc/user/enable_ipv6" target="_blank">没有IPv6地址,查看如何开启IPv6</RouterLink>
</div>
<div class="ip-priority">
@@ -173,6 +231,20 @@ onMounted(() => {
margin: 1em 0;
}
/* 两行 IP 记录预留确定高度,消除 CLS。
实测(CDP 逐帧采样 + CSS 变体对照,填值后量终态几何):
这两个 div 的文本是 onMounted 之后才由 $fetch 填上的。IPv6 地址(39 字符)
在窄屏下把 .one-line 从 1 行撑到 3 行,下面 .ip-priority / 代码块 / blockquote /
footer 被整体顶下 50px —— Lighthouse 记的 `div.code-block` 那条 CLS 0.0359
(占全页 88%)就是这么来的,而满分门槛只有 0.0396,差 3% 就被扣分。
修法是让高度与「填完之后」一致。终态高度按视口分档(CDP 实测,不是估算):
移动 412 宽 → ip2 终态 75px(3 行);桌面 1350 宽 → 30px(1 行,.content 宽 60%)
所以必须分媒体查询给值:一刀切 75px 会让桌面凭空多出 45px 空白。
代价是接口 pending 时这里空着,但换来 CLS 归零。 */
.ip-row {
min-height: 30px;
}
.code-block {
margin-top: 1rem;
padding: 1rem;
@@ -191,17 +263,30 @@ onMounted(() => {
}
@media (max-width: 768px) {
.ip-priority h2 {
font-size: 1.1em;
}
.code-block {
padding: 0.75rem;
font-size: 0.8em;
}
.ip-priority h2 {
font-size: 1.1em;
}
.code-block {
padding: 0.75rem;
font-size: 0.8em;
}
/* 窄屏下 IPv6 地址换 3 行(CDP 实测 412 宽终态 h=75px),见上面 .ip-row 的注释。
75px 只给 IPv6 行:IPv4 地址窄屏仍是 1 行(~30px),一刀切 75px 会让
IPv4 行下部空出 ~45px,两块 IP 之间出现一大段空白。
IPv4 回填前后都是 1 行 30px,高度不变 → 不引入新的 CLS。 */
.ip-row {
/* style.css 窄屏给 .one-line 加了 0.5em 上下 margin(两块之间叠 16px),
IP 行的间距由行高本身决定,这里归零。scoped 属性选择器特异性更高,稳赢。 */
margin: 0;
}
.ip-row-v6 {
min-height: 75px;
align-content: flex-start;
}
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
</style>
-26
View File
@@ -1,26 +0,0 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
onMounted(async () => {
const urlParam = route.query.ip;
// ip 参数缺失时不要拼出 ?ip=undefined
await navigateTo(urlParam ? { path: '/location', query: { ip: urlParam } } : { path: '/location' });
});
</script>
<template>
<div class="redirecting">正在跳转到 IP 归属地查询…</div>
</template>
<style scoped>
.redirecting {
display: flex;
align-items: center;
justify-content: center;
min-height: 50vh;
color: #999;
}
</style>
+12 -6
View File
@@ -3,7 +3,8 @@ import { ref, onMounted, computed, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { config } from '../../config/index';
import { useMiddlewareFetch } from '../../utils/middleware';
import { extractHost, formatTime, formatSpeed, formatSize, getStatusCodeClass } from '../../utils/tools';
import { extractHost, formatTime, formatSpeed, formatSize, getStatusCodeClass, withQuery } from '../../utils/tools';
import AdvancedOptions from '../components/AdvancedOptions.vue';
const route = useRoute()
@@ -48,6 +49,8 @@ const loading = ref(false)
const result = ref<any[]>([])
const error = ref('')
const domain = ref('')
// 高级选项(dns6 / resolve / resolve6):用户填了才拼进请求
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
/** 服务器结果接口 */
interface ServerResult {
@@ -72,7 +75,7 @@ const serverResults = ref<ServerResult[]>([])
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + encodeURIComponent(domain.value));
const url = computed(() => withQuery("/middleware/" + server.id + "/speed/v6/" + encodeURIComponent(domain.value), adv.value?.query() ?? {}));
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
@@ -82,7 +85,7 @@ const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
/** IPv6 专用服务器:仅支持 IPv6 的节点 */
const ipv6Fetches = config.APIBaseURL.IPv6.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + encodeURIComponent(domain.value));
const url = computed(() => withQuery("/middleware/" + server.id + "/speed/v6/" + encodeURIComponent(domain.value), adv.value?.query() ?? {}));
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
@@ -180,7 +183,8 @@ onMounted(() => {
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:https://zakoflare.com)"
placeholder="请输入域名(如:https://zakoflare.com)"
clearable
/>
<el-button
@click="SpeedTest()"
@@ -191,6 +195,8 @@ onMounted(() => {
</el-button>
</div>
<AdvancedOptions ref="adv" stack="ipv6" />
<div v-if="error" class="error-message">
{{ error }}
</div>
@@ -272,10 +278,10 @@ onMounted(() => {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+27 -6
View File
@@ -4,7 +4,8 @@ import { useRoute } from 'vue-router'
import { config } from '../../config/index';
import { useMiddlewareFetch } from '../../utils/middleware';
import { isIPv6 } from 'is-ip';
import { extractHost } from '../../utils/tools';
import { extractHost, withQuery } from '../../utils/tools';
import AdvancedOptions from '../components/AdvancedOptions.vue';
const route = useRoute()
useHead({
@@ -77,6 +78,8 @@ const port = ref('80')
const userIP = ref('')
const loading = ref(false)
const serverResults = ref<ServerResult[]>([])
// 高级选项(dns6 / resolve / resolve6):用户填了才拼进请求
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
@@ -86,7 +89,7 @@ const allServers = [
];
const tcpingFetches = allServers.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/tcping/" + encodeURIComponent(extractHost(tmpDomain.value)) + "?port=" + encodeURIComponent(port.value));
const url = computed(() => withQuery("/middleware/" + server.id + "/tcping/" + encodeURIComponent(extractHost(tmpDomain.value)) + "?port=" + encodeURIComponent(port.value) + "&version=ipv6", adv.value?.query() ?? {}));
const { data, error: fetchError, execute } = useMiddlewareFetch<TCPingResponse>(url, {
immediate: false,
watch: false,
@@ -195,11 +198,13 @@ onMounted(() => {
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:example.com)"
placeholder="请输入域名(如:example.com)"
clearable
/>
<el-input
v-model="port"
placeholder="端口号(默认 80)"
placeholder="端口号(默认 80)"
clearable
style="width: 200px;"
/>
<el-button
@@ -211,6 +216,8 @@ onMounted(() => {
</el-button>
</div>
<AdvancedOptions ref="adv" stack="ipv6" />
<div class="result-section">
<table class="result-table">
<thead>
@@ -280,6 +287,20 @@ onMounted(() => {
@import "../style.css";
@import "../../assets/css/tool-common.css";
/* 本页行是双输入框(420×2 + 按钮 165 + 间距 ≈ 1035px),换行点远高于通用阈值的
615px:容器低于 1050px(≈自然换行点)时换行元素铺满整行,消除中间的夹缝区。 */
@container (max-width: 1050px) {
.el-input,
.el-button {
width: 100% !important;
margin-right: 0 !important;
}
.el-input {
margin-bottom: 10px;
}
}
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
@@ -287,11 +308,11 @@ onMounted(() => {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon {
+14 -6
View File
@@ -4,7 +4,8 @@ import { useRoute } from 'vue-router'
import { config } from '../../config/index';
import { useMiddlewareFetch } from '../../utils/middleware';
import { CircleCheckFilled, CircleCloseFilled,InfoFilled,Position } from '@element-plus/icons-vue';
import { extractHost, getStatusCodeClass, formatTime, formatSize, formatSpeed } from '../../utils/tools';
import { extractHost, getStatusCodeClass, formatTime, formatSize, formatSpeed, withQuery } from '../../utils/tools';
import AdvancedOptions from '../components/AdvancedOptions.vue';
const route = useRoute()
@@ -63,7 +64,9 @@ interface PerformanceCheckItem {
download_speed: number
is_reachable: boolean
}
const apiList = config.APIBaseURL.DualStack
const apiList = [
...config.APIBaseURL.DualStack,
]
const currentApiIndex = ref(0)
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
const tmpDomain = ref('https://www.zakoflare.com')
@@ -71,7 +74,9 @@ const testDomain = ref('')
const loading = ref(false)
const error = ref('')
const result = ref<PerformanceCheckResponse | null>(null)
const checkUrl = computed(() => "/middleware/" + backendID.value + "/detail/" + testDomain.value);
// 高级选项(dns4/dns6 + resolve/resolve4/resolve6):本页一次探测两栈
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
const checkUrl = computed(() => withQuery("/middleware/" + backendID.value + "/detail/" + testDomain.value, adv.value?.query() ?? {}));
// testDomain 是完整 URL(含 https:// 与路径斜杠),服务端 [...slug].get.ts 依赖
// 分段重组协议部分,这里刻意不做 encodeURIComponent
@@ -136,7 +141,8 @@ onMounted(() => {
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:https://zakoflare.com)"
placeholder="请输入域名(如:https://zakoflare.com)"
clearable
/>
<el-button
@click="checkWeb()"
@@ -147,6 +153,8 @@ onMounted(() => {
</el-button>
</div>
<AdvancedOptions ref="adv" />
<div v-if="error" class="error-message">
{{ error }}
</div>
@@ -305,10 +313,10 @@ onMounted(() => {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+21 -16
View File
@@ -67,7 +67,22 @@ curl ${config.v6OnlyAPI}
# (访问 IPv4/IPv6 双栈站点,如果返回 IPv6 地址,则 IPv6 访问优先)
curl ${config.DualStackAPI}
`.trim();
const html = ref('');
// 代码高亮放 SSR 期算(与首页同款做法):HTML 里直接是带 <span> 的高亮结果,
// 客户端不必再下载/执行 shiki(引擎 + 语言 + 主题 ≈ 164KB),也不会出现
// "先渲染纯文本、稍后换成高亮"的块高变化。代码是构建期常量,结果可随 HTML 一起缓存。
// import 写在 handler 内部(动态 import 单独成 chunk),首屏不会预载它。
const { data: html } = await useAsyncData(
'location-curl-code',
async () => {
const { highlightCode } = await import('../../utils/shiki');
return await highlightCode(code, 'bash');
},
// 默认值给 code(不是空串):这里模板只有单个 v-html,没有 fallback 分支,
// 高亮失败时正好退回原始代码文本,别让代码块空着。
// (首页那处模板有 v-else 分支,所以那边默认值必须留空,两处不可照抄。)
{ default: () => code },
);
const apiList = config.IPLocationAPI
const currentApiIndex = ref(0)
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
@@ -117,18 +132,7 @@ function locateIP(IP: string){
}
onMounted(async () => {
// 代码高亮与下面的「取本机 IP / 定位查询」互不依赖,别把它摆在流程最前面 await ——
// 否则后面那次 $fetch 要等 160KB 的 shiki 下载完才会发出。
// 动态 import 让 shiki(引擎 + 语言/主题注册表)单独成 chunk,首屏渲染不再等它。
void import('../../utils/shiki')
.then(({ highlightCode }) => highlightCode(code, 'bash'))
.then((h) => {
html.value = h
})
.catch(() => {
// 连 import 本身都失败时退回原始代码,别让代码块空着
html.value = code
})
// (代码高亮已移到 setup 顶部的 useAsyncData,随 SSR 一起产出,这里只剩定位查询。)
const urlParam = route.query.ip;
if (urlParam) {
@@ -161,7 +165,8 @@ onMounted(async () => {
<div class="one-line">
<el-input
v-model="ipAddress"
placeholder="请输入IP地址"
placeholder="请输入IP地址"
clearable
/>
<el-button
@click="locateIP(ipAddress)"
@@ -257,7 +262,7 @@ onMounted(async () => {
<style>
@import "../style.css";
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
@@ -440,7 +445,7 @@ html.dark pre code {
.badge-item h4 {
margin: 0 0 15px 0;
color: #3EAF7C;
color: #299764;
font-size: 1.2em;
}
+98 -8
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { config } from '../../config/index'
import { Loading } from '@element-plus/icons-vue'
@@ -10,14 +10,40 @@ const route = useRoute()
const url = ref('')
useHead({
title: '网站截图工具 - '+(route.query.site || config.siteName),
// 原标题「网站截图工具 - 站名」默认态过短;描述不足 50 字被审计判过短,一并加长。
title: route.query.site
? `网站截图工具 - ${route.query.site} 的网页快照 | ${config.siteName}`
: `网站截图工具 - 在线网页快照生成 | ${config.siteName}`,
meta: [
{ name: 'description', content: '在线网站截图工具,输入网址即可获取网页快照,支持所有公开网站的实时截图,方便查看网站布局和设计效果' },
{ name: 'keywords', content: '网站截图,网页快照,在线截图,网页预览,网站布局查看,截图工具' },
{ property: 'og:title', content: '网站截图工具 - 在线网页快照' },
{ property: 'og:description', content: '输入网址即可获取网页快照,查看网站布局和设计效果' },
{ name: 'description', content: '在线网站截图工具:输入网址即可获取网页实时快照,支持任意公开网站的整页截图与预览,查看网页布局、设计效果与渲染状态,可用于页面监控、设计走查与内容存档比对,免费无需注册' },
{ name: 'keywords', content: '网站截图,网页快照,在线截图,网页预览,网站布局查看,截图工具,整页截图,网页快照生成' },
{ property: 'og:title', content: '网站截图工具 - 在线网页快照生成' },
{ property: 'og:description', content: '输入网址即可获取网页实时快照,查看网页布局与设计效果,支持任意公开网站,免费无需注册' },
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
{ property: 'og:type', content: 'website' },
],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: '网站截图工具',
description: '在线网站截图工具,输入网址即可获取网页实时快照,支持任意公开网站的整页截图与预览。',
url: new URL('/screenshot', config.siteUrl).href,
applicationCategory: 'UtilitiesApplication',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY'
},
provider: {
'@type': 'Organization',
name: config.siteName
}
})
}
]
})
@@ -26,6 +52,8 @@ const loading = ref(false)
const error = ref('')
const screenshotUrl = ref('')
const imgLoaded = ref(false)
// 主通道(thum.io)经 fetch 下载后以 blob URL 喂给 <img>;换新截图前要 revoke 旧的
const blobUrl = ref('')
let detectionImg: HTMLImageElement | null = null
@@ -38,6 +66,45 @@ const DEFAULT_MSHOTS_WIDTH = 1200
const DEFAULT_MSHOTS_HEIGHT = 900
const LOADING_TIMEOUT = 15000 // 15 seconds
// —— 主通道:thum.io(同步出图、CORS 全开)——
// content-type 即结果信号:image/png|jpeg = 成品;image/gif = thum.io 的
// 「生成中/目标不可达」动画(对没缓存且生成慢的站首访可能出现)→ 等片刻重试一次,
// 仍 GIF 就放弃。网络层失败(被墙/CSP/超时)→ 立即回退 mshots。
const THUM_TIMEOUT = 10_000
const THUM_RETRY_DELAY = 4_000
async function takeThumio(target: string, requestId: number): Promise<string | null> {
for (let attempt = 0; attempt < 2; attempt++) {
if (requestId !== currentRequestId) return null
try {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), THUM_TIMEOUT)
const res = await fetch(`${config.screenshotAPI}/get/width/1280/${target}`, { signal: ctrl.signal })
clearTimeout(timer)
if (requestId !== currentRequestId) return null
const type = res.headers.get('content-type') || ''
if (res.ok && /^image\/(png|jpe?g)\b/.test(type)) {
return URL.createObjectURL(await res.blob())
}
if (attempt === 0) {
await new Promise(r => setTimeout(r, THUM_RETRY_DELAY))
continue
}
return null
} catch {
return null
}
}
return null
}
function releaseBlob() {
if (blobUrl.value) {
URL.revokeObjectURL(blobUrl.value)
blobUrl.value = ''
}
}
async function getUserIP(){
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
@@ -82,14 +149,31 @@ function takeScreenshot() {
imgLoaded.value = false
loading.value = true
screenshotUrl.value = ''
releaseBlob()
const requestId = ++currentRequestId
// Set the screenshot URL on next tick so Vue can update the displayed <img>
nextTick(() => {
nextTick(async () => {
// Check if this request was superseded
if (requestId !== currentRequestId) return
// —— 主通道 thum.io:fetch 下载成 blob 直接展示(同步出图,无「请稍后」体验)——
const blob = await takeThumio(url.value, requestId)
if (requestId !== currentRequestId) {
if (blob) URL.revokeObjectURL(blob)
return
}
if (blob) {
blobUrl.value = blob
screenshotUrl.value = blob
// blob 已完整在手,无需等 <img> 的 load 事件再揭开
imgLoaded.value = true
loading.value = false
return
}
// —— 回退 mshots:异步生成,先重定向到占位图,需要隐藏 <img> 探测真伪 ——
screenshotUrl.value = `https://s0.wp.com/mshots/v1/${encodeURIComponent(url.value)}`
// Create a hidden detection image to check for default WordPress error image
@@ -153,6 +237,11 @@ function onImageError() {
showError('截图加载失败,请检查网址是否正确')
}
onBeforeUnmount(() => {
cleanupDetection()
releaseBlob()
})
onMounted(() => {
const urlParam = route.query.site as string
if (urlParam) {
@@ -180,6 +269,7 @@ const doc = page.value
<el-input
v-model="tmpDomain"
placeholder="请输入网址(如:https://example.com)"
clearable
@keyup.enter="takeScreenshot"
/>
<el-button
@@ -286,7 +376,7 @@ html.dark .loading-overlay p {
}
.markdown :deep(a) {
color: #3EAF7C !important;
color: #299764 !important;
font-size: 1.3em;
text-decoration: none
}
+12 -6
View File
@@ -3,7 +3,8 @@ import { ref, onMounted, computed, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { config } from '../../config/index';
import { useMiddlewareFetch } from '../../utils/middleware';
import { extractHost, formatTime, formatSpeed, formatSize, getStatusCodeClass } from '../../utils/tools';
import { extractHost, formatTime, formatSpeed, formatSize, getStatusCodeClass, withQuery } from '../../utils/tools';
import AdvancedOptions from '../components/AdvancedOptions.vue';
const route = useRoute()
@@ -48,6 +49,8 @@ const loading = ref(false)
const result = ref<any[]>([])
const error = ref('')
const domain = ref('')
// 高级选项(dns4 / resolve / resolve4):用户填了才拼进请求
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
/** 服务器结果接口 */
interface ServerResult {
@@ -72,7 +75,7 @@ const serverResults = ref<ServerResult[]>([])
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + encodeURIComponent(domain.value));
const url = computed(() => withQuery("/middleware/" + server.id + "/speed/v4/" + encodeURIComponent(domain.value), adv.value?.query() ?? {}));
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
@@ -82,7 +85,7 @@ const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
/** IPv4 专用服务器:仅支持 IPv4 的节点 */
const ipv4Fetches = config.APIBaseURL.IPv4.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + encodeURIComponent(domain.value));
const url = computed(() => withQuery("/middleware/" + server.id + "/speed/v4/" + encodeURIComponent(domain.value), adv.value?.query() ?? {}));
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
@@ -180,7 +183,8 @@ onMounted(() => {
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:https://zakoflare.com)"
placeholder="请输入域名(如:https://zakoflare.com)"
clearable
/>
<el-button
@click="SpeedTest()"
@@ -191,6 +195,8 @@ onMounted(() => {
</el-button>
</div>
<AdvancedOptions ref="adv" stack="ipv4" />
<div v-if="error" class="error-message">
{{ error }}
</div>
@@ -272,10 +278,10 @@ onMounted(() => {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+32 -5
View File
@@ -4,7 +4,8 @@ import { useRoute } from 'vue-router'
import { CircleCheckFilled, CircleCloseFilled,InfoFilled } from '@element-plus/icons-vue';
import { config } from '../../config/index';
import { useMiddlewareFetch } from '../../utils/middleware';
import { extractHost, getStatusCodeClass, formatTime, formatSpeed } from '../../utils/tools';
import { extractHost, getStatusCodeClass, formatTime, formatSpeed, withQuery } from '../../utils/tools';
import AdvancedOptions from '../components/AdvancedOptions.vue';
const route = useRoute()
const siteUrlWithSlash = computed(() => config.siteUrl.replace(/\/$/, '') + '/')
@@ -62,10 +63,13 @@ interface SSLCheckItem {
issuer_organization: string[] | null
issuer_common_name: string
subject_common_name: string
san: string[] | null
is_expired: boolean
is_reachable: boolean
}
const apiList = config.APIBaseURL.DualStack
const apiList = [
...config.APIBaseURL.DualStack,
]
const currentApiIndex = ref(0)
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
const tmpDomain = ref('https://www.zakoflare.com')
@@ -73,7 +77,16 @@ const testDomain = ref('')
const loading = ref(false)
const error = ref('')
const result = ref<SSLCheckResponse | null>(null)
const sslUrl = computed(() => "/middleware/" + backendID.value + "/ssl/" + encodeURIComponent(testDomain.value));
// SAN 行只在「至少一个栈拿到非空 SAN」时渲染:两栈都空(节点未升级返回无 san 字段、
// 或证书本就没有 SAN)时整行隐藏,而不是渲染一行「- -」
const showSanRow = computed(() => {
const r = result.value
if (!r) return false
return !!((r.ipv4?.is_reachable && r.ipv4.san?.length) || (r.ipv6?.is_reachable && r.ipv6.san?.length))
})
// 高级选项(dns4/dns6 + resolve/resolve4/resolve6):本页一次探测两栈
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
const sslUrl = computed(() => withQuery("/middleware/" + backendID.value + "/ssl/" + encodeURIComponent(testDomain.value), adv.value?.query() ?? {}));
const { data: sslData, error: sslError, execute: executeSSL } = useMiddlewareFetch<SSLCheckResponse>(sslUrl, {
immediate: false,
@@ -146,7 +159,8 @@ onMounted(() => {
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:https://zakoflare.com)"
placeholder="请输入域名(如:https://zakoflare.com)"
clearable
/>
<el-button
@click="checkSSL()"
@@ -157,6 +171,8 @@ onMounted(() => {
</el-button>
</div>
<AdvancedOptions ref="adv" />
<div v-if="error" class="error-message">
{{ error }}
</div>
@@ -201,6 +217,17 @@ onMounted(() => {
<span v-else>-</span>
</td>
</tr>
<tr v-if="showSanRow">
<td class="table-label">SAN(备用域名)</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable && result.ipv4!.san?.length">{{ result.ipv4!.san.join(', ') }}</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable && result.ipv6.san?.length">{{ result.ipv6.san.join(', ') }}</template>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">签发者</td>
<td class="table-value">
@@ -389,7 +416,7 @@ onMounted(() => {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+29 -7
View File
@@ -3,7 +3,8 @@ import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { config } from '../../config/index';
import { useMiddlewareFetch } from '../../utils/middleware';
import { extractHost } from '../../utils/tools';
import { extractHost, withQuery } from '../../utils/tools';
import AdvancedOptions from '../components/AdvancedOptions.vue';
const route = useRoute()
@@ -76,6 +77,8 @@ const tmpDomain = ref('www.zakoflare.com')
const port = ref('80')
const loading = ref(false)
const serverResults = ref<ServerResult[]>([])
// 高级选项(dns4 / resolve / resolve4):用户填了才拼进请求
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
@@ -85,7 +88,7 @@ const allServers = [
];
const tcpingFetches = allServers.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/tcping/" + encodeURIComponent(extractHost(tmpDomain.value)) + "?port=" + encodeURIComponent(port.value));
const url = computed(() => withQuery("/middleware/" + server.id + "/tcping/" + encodeURIComponent(extractHost(tmpDomain.value)) + "?port=" + encodeURIComponent(port.value) + "&version=ipv4", adv.value?.query() ?? {}));
const { data, error: fetchError, execute } = useMiddlewareFetch<TCPingResponse>(url, {
immediate: false,
watch: false,
@@ -95,7 +98,8 @@ const tcpingFetches = allServers.map((server) => {
function initServerResults() {
const results: ServerResult[] = []
// 与 allServers 顺序保持一致:DualStack → IPv4
config.APIBaseURL.DualStack.forEach((server: ServerConfig) => {
results.push({
label: server.label,
@@ -186,11 +190,13 @@ onMounted(() => {
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:example.com)"
placeholder="请输入域名(如:example.com)"
clearable
/>
<el-input
v-model="port"
placeholder="端口号(默认 80)"
placeholder="端口号(默认 80)"
clearable
style="width: 200px;"
/>
<el-button
@@ -202,6 +208,8 @@ onMounted(() => {
</el-button>
</div>
<AdvancedOptions ref="adv" stack="ipv4" />
<div class="result-section">
<table class="result-table">
<thead>
@@ -256,6 +264,20 @@ onMounted(() => {
@import "../style.css";
@import "../../assets/css/tool-common.css";
/* 本页行是双输入框(420×2 + 按钮 165 + 间距 ≈ 1035px),换行点远高于通用阈值的
615px:容器低于 1050px(≈自然换行点)时换行元素铺满整行,消除中间的夹缝区。 */
@container (max-width: 1050px) {
.el-input,
.el-button {
width: 100% !important;
margin-right: 0 !important;
}
.el-input {
margin-bottom: 10px;
}
}
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
@@ -271,11 +293,11 @@ onMounted(() => {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon {
+25 -12
View File
@@ -3,25 +3,26 @@ import { useMiddlewareFetch } from '../../utils/middleware';
import { ref, onMounted, computed, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { config } from '../../config/index'
import { withQuery } from '../../utils/tools'
import AdvancedOptions from '../components/AdvancedOptions.vue'
import { isIPv6 } from 'is-ip'
const route = useRoute()
useHead({
title: 'Whois查询 | ' + (route.query.site || config.siteName),
// 原标题「Whois查询 | 站名」只有十几个字符,被 SEO 审计判为过短;
// 带查询参数时展示的是具体域名,单独给一个含关键词的标题。
title: route.query.site
? `Whois查询结果 - ${route.query.site} 的域名注册信息 | ${config.siteName}`
: `Whois域名查询工具 | 域名注册信息与到期时间查询 | ${config.siteName}`,
titleTemplate: '%s',
link: [
{ rel: 'canonical', href: computed(() => new URL(route.path, config.siteUrl).toString()).value }
],
meta: [
{ name: 'description', content: '专业的Whois域名查询工具,支持.com、.net等国内外域名WHOIS信息查询,提供域名注册商、注册时间、到期时间、DNS服务器等详细信息,助力域名管理和交易决策' },
{ name: 'description', content: '专业的Whois域名查询工具,支持.com、.net、.cn等国内外域名WHOIS信息在线查询,提供域名注册商、注册时间、到期时间、DNS服务器、域名状态等详细信息,支持IPv4/IPv6多节点检测,助力域名管理与交易决策' },
{ name: 'keywords', content: 'whois查询,域名whois,whois信息查询,域名注册信息,域名到期时间,域名注册商,dns服务器查询,域名whois工具' },
{ property: 'og:title', content: 'Whois域名查询工具 - 域名注册信息在线查询' },
{ property: 'og:description', content: '专业的Whois域名查询工具,支持国内外域名WHOIS信息查询,提供域名注册商、注册时间、到期时间等详细信息' },
{ property: 'og:image', content: `${config.siteUrl}favicon.svg` },
{ property: 'og:type', content: 'website' },
{ property: 'og:url', content: computed(() => new URL(route.path, config.siteUrl).toString()).value },
{ name: 'twitter:card', content: 'summary_large_image' },
],
script: [
{
@@ -64,10 +65,14 @@ const result = ref<any>(null)
const error = ref('')
const userIP = ref('')
const apiList = config.APIBaseURL.DualStack
const apiList = [
...config.APIBaseURL.DualStack,
]
const currentApiIndex = ref(0)
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
const whoisUrl = computed(() => '/middleware/' + backendID.value + '/whois/' + encodeURIComponent(domain.value))
// 高级选项:whois 只取 DNS 服务器(dns4/dns6),不支持强制解析
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
const whoisUrl = computed(() => withQuery('/middleware/' + backendID.value + '/whois/' + encodeURIComponent(domain.value), adv.value?.query() ?? {}))
const { data: whoisData, error: whoisError, execute: executeWhois } = useMiddlewareFetch<any>(whoisUrl, {
immediate: false,
@@ -168,6 +173,7 @@ function getStatusClass(status: string): string {
<el-input
v-model="tmpdomain"
placeholder="请输入域名(如:example.com)"
clearable
/>
<el-button
@click="queryWhois()"
@@ -178,6 +184,8 @@ function getStatusClass(status: string): string {
</el-button>
</div>
<AdvancedOptions ref="adv" :show-resolve="false" />
<div v-if="error" class="error-message">
{{ error }}
</div>
@@ -281,10 +289,15 @@ function getStatusClass(status: string): string {
@import "../style.css";
@import "../../assets/css/tool-common.css";
.markdown :deep(a) {
color: #3EAF7C !important;
color: #299764 !important;
font-size: 1.3em;
text-decoration: none
}
/* 1240px 宽的生命周期图需缩进内容列,桌面端也要约束 */
.markdown :deep(img) {
height: auto;
max-width: 100%;
}
@media (max-width: 768px) {
.markdown :deep(a) {
font-size: 1.1em;
@@ -319,10 +332,10 @@ function getStatusClass(status: string): string {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+11 -8
View File
@@ -31,7 +31,10 @@ h3{
}
.title p{
font-size: 25.6px;
color: #6A8BAD;
/* 首页副标题。原来 #6A8BAD 在白底只有 3.55:1(axe color-contrast,
是移动端 12 条里唯一不是链接/代码的那条);加深到 #4D7396 = 4.99:1,
色相不变、只是明度降一档。 */
color: #4D7396;
margin-right: 20px;
margin-left: 20px;
text-align: center;
@@ -106,14 +109,14 @@ blockquote p {
margin: 0.5em;
}
blockquote a{
color: #3EAF7C;
color: var(--link-color);
font-size: 1em;
/* 正文里的链接仅靠颜色区分不够(绿 #3EAF7C 与周围正文的对比度远低于 3:1),
/* 正文里的链接仅靠颜色区分不够(绿 #299764 与周围正文的对比度远低于 3:1),
补下划线提供「非颜色」的视觉区分(link-in-text-block) */
text-decoration: underline
}
a {
color: #3EAF7C;
color: var(--link-color);
font-size: 1.3em;
text-decoration: none
@@ -129,7 +132,7 @@ footer .one-line {
footer .one-line a {
font-size: 1em;
text-align: center;
color: #3EAF7C;
color: var(--link-color);
text-decoration: none
}
footer {
@@ -204,7 +207,7 @@ footer {
}
blockquote a{
color: #3EAF7C;
color: var(--link-color);
font-size: 1em;
/* 与上面的非窄屏规则保持一致:窄屏同样靠下划线做非颜色区分。
此处若仍写 none,因其位置在后、特异性相同,会把桌面的 underline 盖掉。 */
@@ -269,7 +272,7 @@ h4>.header-anchor,
h5>.header-anchor,
h6>.header-anchor {
opacity: 1;
color: #3EAF7C;
color: var(--link-color);
}
h1:hover>.header-anchor,
@@ -282,5 +285,5 @@ h6:hover>.header-anchor {
}
.header-anchor::before {
content: "#";
color: #3EAF7C;
color: var(--link-color);
}
+25 -1
View File
@@ -226,7 +226,7 @@ html.dark pre code {
.badge-item h4 {
margin: 0 0 15px 0;
color: #3EAF7C;
color: #299764;
font-size: 1.2em;
}
@@ -236,6 +236,30 @@ html.dark pre code {
max-width: 200px;
}
/* —— 行内换行自适应(容器查询)——
.one-line 里的输入框/按钮是固定宽度,容器放不下时 flex 只换行不改宽,
出现「换行了却不铺满」的夹缝态:换行点 ~605px(input 420 + 按钮 165 + 间距)、
铺满点 768px(下方媒体查询断点),手机视口直接落在断点内看不出来,
桌面缩窗在 768~换行点之间全是夹缝。容器查询补齐:容器低于行内容的
自然换行点 ⇒ 换行的元素铺满整行。阈值略高于换行点保证无缝衔接;
行内容更宽的页面(tcping 双输入框 1035px、dns 带 select 765px)
在各自 scoped 样式里用同名容器再写一条更高阈值的规则。 */
.one-line {
container-type: inline-size;
}
@container (max-width: 615px) {
.el-input,
.el-button {
width: 100% !important;
margin-right: 0 !important;
}
.el-input {
margin-bottom: 10px;
}
}
/* 窄屏响应式:所有组件缩小 */
@media (max-width: 768px) {
.el-input,
+102 -32
View File
@@ -1,136 +1,206 @@
export interface DocMeta {
title: string
description: string
// —— SEO 专用字段(可选)——
// 审计(Bing 站长工具)判定 10 个页面标题过短、13 个页面 meta description 过短,
// 全是文档页:上面的 title/description 同时用于侧栏菜单与卡片展示,不适合拉长,
// 因此文档页的 <title> 与 meta 优先取这里的加长版(见 doc 两个页面的 useHead)。
seoTitle?: string
seoDescription?: string
}
export const docConfig: Record<string, DocMeta> = {
'/doc': {
title: 'IPv6 工具箱使用文档',
description: '致力于普及 IPv6,推进 IPv6 规模部署和应用,以全面推进 IPv6 技术创新与融合应用为主线,以提升应用广度深度为主攻方向。支持 IPv6 本地网络(宽带)开启验证、IPv6 网站开启检测、IPv6 Ping 延迟测试、IPv6 网站测速、IPv6 地址查询、IPv6 DNS 解析。'
description: '致力于普及 IPv6,推进 IPv6 规模部署和应用,以全面推进 IPv6 技术创新与融合应用为主线,以提升应用广度深度为主攻方向。支持 IPv6 本地网络(宽带)开启验证、IPv6 网站开启检测、IPv6 Ping 延迟测试、IPv6 网站测速、IPv6 地址查询、IPv6 DNS 解析。',
seoTitle: 'IPv6 工具箱使用文档:宽带开启、网站检测、测速与 DNS 解析教程',
seoDescription: 'IPv6 工具箱完整使用文档:涵盖家庭宽带开启 IPv6、网站 IPv6 支持检测、IPv6 Ping 延迟测试、IPv6 网站测速、IP 归属地查询与 IPv6 DNS 解析等教程,致力于普及 IPv6、推进 IPv6 规模部署和应用。'
},
'/doc/user/enable_ipv6': {
title: '个人宽带如何开启IPv6网络访问',
description: 'IPv6 是大势所趋,就在前段时间湖南联通发布公告,对家庭宽带提供 IPv6 地址,不再提供 IPv4 地址,那本文就介绍个人宽带如何开启 IPv6 网络访问。'
description: 'IPv6 是大势所趋,就在前段时间湖南联通发布公告,对家庭宽带提供 IPv6 地址,不再提供 IPv4 地址,那本文就介绍个人宽带如何开启 IPv6 网络访问。',
seoTitle: '个人宽带如何开启 IPv6 网络访问:光猫与路由器设置教程',
seoDescription: '手把手教你为家庭宽带开启 IPv6 网络访问:从光猫与路由器的设置,到向运营商申请(湖南联通等已对家庭宽带提供 IPv6 地址、不再提供 IPv4),附开启后的 IPv6 地址验证方法与常见问题排查。'
},
'/doc/user/cmd_bash_disable_ipv6': {
title: '命令行禁用/启用IPv6本地网络',
description: '图形化禁用 IPv6 网络比较繁琐,以下是 macOS 和 Window 10 命令行下禁用/启用 IPv6 本地网络的方法。'
description: '图形化禁用 IPv6 网络比较繁琐,以下是 macOS 和 Window 10 命令行下禁用/启用 IPv6 本地网络的方法。',
seoTitle: 'macOS / Windows 命令行禁用或启用 IPv6 本地网络',
seoDescription: '图形化开关 IPv6 网络比较繁琐,本文整理 macOS(networksetup)与 Windows 10/11(netsh、PowerShell)命令行下禁用/启用 IPv6 本地网络的完整命令,可直接复制执行,便于脚本化批量管理多台机器。'
},
'/doc/user/cmd_getip': {
title: '命令行(curl)获取 IPv4 和 IPv6 地址',
description: '通过 curl 命令获取公网 IPv4 和 IPv6 地址,还可以返回是 IPv4 还是 IPv6 访问优先。'
description: '通过 curl 命令获取公网 IPv4 和 IPv6 地址,还可以返回是 IPv4 还是 IPv6 访问优先。',
seoTitle: 'curl 命令获取公网 IPv4 / IPv6 地址及访问优先级',
seoDescription: '通过 curl 命令查询本机公网 IPv4 和 IPv6 地址:分别指定 -4 / -6 强制协议栈,还可一键返回当前网络是 IPv4 还是 IPv6 访问优先。适用于 Linux、macOS、Windows 终端以及监控脚本与 CI 环境调用。'
},
'/doc/user/view_ipv6_adress_url': {
title: '浏览器访问 IPv6 地址',
description: '在 IPv6 地址两边加上中括号,就可以通过浏览器访问了,比如 http://[2402:4e00:1013:e500:0:9671:f018:4947]/'
description: '在 IPv6 地址两边加上中括号,就可以通过浏览器访问了,比如 http://[2402:4e00:1013:e500:0:9671:f018:4947]/',
seoTitle: '浏览器访问 IPv6 地址:中括号写法与端口注意事项',
seoDescription: '在 IPv6 地址两边加上中括号即可用浏览器访问,例如 http://[2402:4e00:1013:e500:0:9671:f018:4947]/。本文介绍浏览器访问 IPv6 地址的正确写法、端口号的书写位置,以及无法访问时的排查思路。'
},
'/doc/user/ipv4_ipv6_prefix_precedence': {
title: 'Windows 10/11 设置 IPv4/IPv6 访问优先级',
description: 'Windows 10/11 开启 IPv6 后默认 IPv6 访问优先(以访问 IPv4/IPv6 双栈站点为例,操作系统会优先访问 IPv6),如果期望 IPv4 访问优先,可以通过 netsh 命令调整。'
description: 'Windows 10/11 开启 IPv6 后默认 IPv6 访问优先(以访问 IPv4/IPv6 双栈站点为例,操作系统会优先访问 IPv6),如果期望 IPv4 访问优先,可以通过 netsh 命令调整。',
seoTitle: 'Windows 10/11 设置 IPv4/IPv6 访问优先级(netsh 前缀策略)',
seoDescription: 'Windows 10/11 开启 IPv6 后默认 IPv6 访问优先(访问 IPv4/IPv6 双栈站点时操作系统会优先走 IPv6)。如果期望 IPv4 访问优先,可通过 netsh interface ipv6 修改前缀策略调整,本文提供可直接复制的命令与恢复默认的方法。'
},
'/doc/user/ipv6_daohang': {
title: '国内 IPv6 资源导航',
description: 'IPv6 网络开启成功后,可以访问一些 IPv6 网站资源,来体验下 IPv6 的魅力。不过目前纯 IPv6 网站比较少,目前网站支持 IPv6 的主流方式为 IPv4/IPv6 双栈访问,毕竟要兼顾 IPv4 网络用户的访问需求。'
description: 'IPv6 网络开启成功后,可以访问一些 IPv6 网站资源,来体验下 IPv6 的魅力。不过目前纯 IPv6 网站比较少,目前网站支持 IPv6 的主流方式为 IPv4/IPv6 双栈访问,毕竟要兼顾 IPv4 网络用户的访问需求。',
seoTitle: '国内 IPv6 资源导航:支持 IPv6 访问的网站大全',
seoDescription: 'IPv6 网络开启成功后,可以访问这些国内 IPv6 网站资源体验下一代互联网。目前纯 IPv6 网站还比较少,主流网站支持 IPv6 的方式为 IPv4/IPv6 双栈访问,以兼顾 IPv4 网络用户的访问需求。'
},
'/doc/user/pure_ipv6_website': {
title: '国内纯 IPv6 网站导航',
description: 'IPv6 网络开启成功后,可以访问一些纯 IPv6 网站资源,来体验下 IPv6 的魅力。'
description: 'IPv6 网络开启成功后,可以访问一些纯 IPv6 网站资源,来体验下 IPv6 的魅力。',
seoTitle: '国内纯 IPv6 网站导航:仅支持 IPv6 访问的站点合集',
seoDescription: '收录一批仅提供 IPv6 访问的国内纯 IPv6 网站:家庭宽带开启 IPv6 网络后即可直接访问,用来验证本机 IPv6 连通性,或体验真正的 IPv6-only 互联网服务,列表持续更新。'
},
'/doc/user/dns': {
title: '全国各省 DNS 服务器列表',
description: '全国各省电信、联通、移动的首选 IPv4 DNS 服务器和备用 DNS 服务器,以及公共 DNS 服务器列表,最近更新为 2021-07-25。'
description: '全国各省电信、联通、移动的首选 IPv4 DNS 服务器和备用 DNS 服务器,以及公共 DNS 服务器列表,最近更新为 2021-07-25。',
seoTitle: '全国各省电信/联通/移动 DNS 服务器地址列表(IPv4)',
seoDescription: '全国各省电信、联通、移动的首选与备用 IPv4 DNS 服务器地址汇总,外加 114DNS、阿里 DNS、DNSPod 等公共 DNS 列表,按省份与运营商分类整理,方便网络排障时就近选用延迟最低的 DNS 服务器。'
},
'/doc/user/ipv6_dns': {
title: 'IPv6 DNS 地址列表',
description: 'IPv6 DNS 地址列表,请求 IPv6 网络需要设置 IPv6 DNS。'
description: 'IPv6 DNS 地址列表,请求 IPv6 网络需要设置 IPv6 DNS。',
seoTitle: 'IPv6 DNS 服务器地址列表:公共 IPv6 DNS 推荐',
seoDescription: 'IPv6 网络需要设置 IPv6 DNS 才能正常解析域名。本文汇总国内外常用 IPv6 DNS 服务器地址:包括阿里、DNSPod、Google、Cloudflare 等公共 IPv6 DNS,可直接复制到光猫、路由器或网卡设置中。'
},
'/doc/user/AliyunAuthorizeSecurityGroup': {
title: '阿里云自动化添加安全组',
description: 'Golang 代码实操阿里云自动化添加安全组'
description: 'Golang 代码实操阿里云自动化添加安全组',
seoTitle: 'Golang 实现阿里云 ECS 安全组规则自动化添加',
seoDescription: '使用 Golang 调用阿里云 ECS OpenAPI 实现安全组放行规则的自动化添加:包含 SDK 初始化、AccessKey 鉴权、AuthorizeSecurityGroup 接口的参数说明与完整示例代码,适合批量管理多台 ECS 实例的场景。'
},
'/doc/user/TencentCloudAddSecurityGroup': {
title: '腾讯云自动化添加安全组',
description: 'Golang 代码实操腾讯云自动化添加安全组'
description: 'Golang 代码实操腾讯云自动化添加安全组',
seoTitle: 'Golang 实现腾讯云 CVM 安全组规则自动化添加',
seoDescription: '使用 Golang 调用腾讯云 CVM OpenAPI 实现安全组规则的自动化添加:涵盖 SDK 安装、密钥配置、安全组策略接口的调用示例与完整源码,便于在脚本中批量放行端口、管理大量云主机实例。'
},
'/doc/server/website_enable_ipv6': {
title: '网站开启 IPv6 的三种方式',
description: '从传统二进制部署的 Nginx,到云原生部署的 K8S、Istio,分别介绍网站开启 IPv6 的三种方式。'
description: '从传统二进制部署的 Nginx,到云原生部署的 K8S、Istio,分别介绍网站开启 IPv6 的三种方式。',
seoTitle: '网站开启 IPv6 的三种方式:Nginx、K8s 与 Istio',
seoDescription: '从传统二进制部署的 Nginx 监听 IPv6 地址,到云原生部署的 K8s Service 双栈与 Istio 网关,一文讲清网站开启 IPv6 的三种方式及各自适用场景,附关键配置片段与开启后的验证方法。'
},
'/doc/server/tencent_cloud_cvm_ipv6': {
title: '腾讯云 cvm 开启 IPv6',
description: '一文看懂在腾讯云 CVM 上开启 IPv6,只需 7 步。'
description: '一文看懂在腾讯云 CVM 上开启 IPv6,只需 7 步。',
seoTitle: '腾讯云 CVM 开启 IPv6 完整教程(只需 7 步)',
seoDescription: '一文看懂在腾讯云 CVM 上开启 IPv6:从 VPC 与子网启用 IPv6、给实例分配 IPv6 地址、安全组放行,到系统内网卡配置与连通性验证,共 7 步完成,附每步操作说明与常见报错的处理办法。'
},
'/doc/server/nginx_ipv6': {
title: 'Nginx 开启 IPv6',
description: '一文看懂 Nginx 中开启 IPv6,包含设置 IPv6 SSL 证书。'
description: '一文看懂 Nginx 中开启 IPv6,包含设置 IPv6 SSL 证书。',
seoTitle: 'Nginx 开启 IPv6:listen 监听与 IPv6 SSL 证书配置',
seoDescription: '一文看懂 Nginx 中开启 IPv6:listen [::] 监听写法、与 IPv4 共存的配置示例、IPv6 SSL 证书的设置,以及防火墙放行 IPv6 流量,配置完成后可用 IPv6 网站检测工具一键验证。'
},
'/doc/server/ipv6webcheck': {
title: '如何确认一个网站是否开启 IPv6',
description: '通过 IPv6 网站检测工具,一键检查网站是否开启 IPv6 访问。'
description: '通过 IPv6 网站检测工具,一键检查网站是否开启 IPv6 访问。',
seoTitle: '如何确认一个网站是否开启 IPv6:一键检测方法',
seoDescription: '使用本站的 IPv6 网站检测工具,输入域名即可一键检查网站是否开启 IPv6 访问:返回 IPv4 与 IPv6 双栈的 HTTP/HTTPS 状态码、DNS 解析与连接耗时对比数据,并可申请 IPv6 徽标认证。'
},
'/doc/server/ipv6_sign': {
title: '网站增加支持IPv6访问标识',
description: 'HTML+CSS 代码,可以提醒访客本网站支持 IPv6 访问,同时可以跳转到 IPv6 网站检测页面。'
description: 'HTML+CSS 代码,可以提醒访客本网站支持 IPv6 访问,同时可以跳转到 IPv6 网站检测页面。',
seoTitle: '网站添加「支持 IPv6 访问」标识(HTML+CSS 代码)',
seoDescription: '提供一段现成的 HTML+CSS 代码,在网站页脚展示「本网站支持 IPv6 访问」标识,点击即可跳转到 IPv6 网站检测页面实时验证,让访客直观感知网站的 IPv6 支持状态。'
},
'/doc/server/ipv6_domain_record': {
title: '如何为域名添加 IPv6 解析记录',
description: 'IPv6 网站准备好了后,接下来可以为网站域名添加 IPv6 解析记录(AAAA)。'
description: 'IPv6 网站准备好了后,接下来可以为网站域名添加 IPv6 解析记录(AAAA)。',
seoTitle: '如何为域名添加 IPv6 解析记录(AAAA 记录)',
seoDescription: '网站支持 IPv6 后,还需为域名添加 IPv6 解析记录(AAAA)。本文演示在常见 DNS 服务商控制台添加 AAAA 记录的操作步骤、TTL 建议值,以及添加后用 dig / nslookup 验证解析是否生效。'
},
'/doc/server/http2': {
title: '网站如何开启 HTTP/2',
description: 'HTTP/2 通过引入字段压缩和允许在同一连接上进行多个并发交换,使网络资源得到更有效的利用,并减少了延迟。'
description: 'HTTP/2 通过引入字段压缩和允许在同一连接上进行多个并发交换,使网络资源得到更有效的利用,并减少了延迟。',
seoTitle: '网站如何开启 HTTP/2:Nginx 配置与抓包验证',
seoDescription: 'HTTP/2 通过引入字段压缩和允许在同一连接上进行多个并发交换,使网络资源得到更有效的利用并减少延迟。本文介绍网站在 Nginx 中开启 HTTP/2 的配置方法,并用浏览器与 Wireshark 抓包验证协商结果。'
},
'/doc/rfc/rfc8200': {
title: 'IPv6 RFC8200 解读',
description: 'RFC8200(Internet Protocol, Version 6 (IPv6) Specification)是 IPv6 的权威定义,接下来在解读 RFC8200 文档中的过程中,加深对 IPv6 的理解。'
description: 'RFC8200(Internet Protocol, Version 6 (IPv6) Specification)是 IPv6 的权威定义,接下来在解读 RFC8200 文档中的过程中,加深对 IPv6 的理解。',
seoTitle: 'IPv6 RFC 8200 解读:IPv6 协议规范逐节精读',
seoDescription: 'RFC 8200(Internet Protocol, Version 6 (IPv6) Specification)是 IPv6 协议的权威定义。本文逐节解读其报文格式、扩展报头、地址语义等核心内容,在精读文档的过程中建立对 IPv6 的完整理解。'
},
'/doc/rfc/ipv6_address_format': {
title: 'IPv6 地址标识方法',
description: '首选格式、压缩格式、内嵌 IPv4 地址的 IPv6 地址格式。'
description: '首选格式、压缩格式、内嵌 IPv4 地址的 IPv6 地址格式。',
seoTitle: 'IPv6 地址表示方法:首选格式、压缩格式与内嵌 IPv4',
seoDescription: 'IPv6 地址共 128 位,本文讲解三种标准表示方法:首选格式(8 组 4 位十六进制数)、零压缩格式(:: 缩写规则)以及内嵌 IPv4 地址的混合表示法,并给出每组示例与常见的书写误区。'
},
'/doc/user/tcpdump_ipv6': {
title: 'tcpdump 分析 IPv6 包',
description: 'tcpdump 分析 IPv6 包'
description: 'tcpdump 分析 IPv6 包',
seoTitle: 'tcpdump 抓包分析 IPv6 数据包与常用过滤命令',
seoDescription: '使用 tcpdump 抓取并分析 IPv6 数据包:整理 ip6、icmp6、TCP/UDP 端口等常用过滤表达式,配合 -nn -vv 等参数看清 IPv6 报文结构,适用于排查 IPv6 连通性与邻居发现(NDP)协议问题。'
},
'/doc/user/wireshark_ipv6': {
title: 'WireShark 分析IPv6包头',
description: '使用 WireShark 分析 IPv6 包头'
description: '使用 WireShark 分析 IPv6 包头',
seoTitle: 'WireShark 分析 IPv6 包头:各字段逐项讲解',
seoDescription: '使用 WireShark 逐字段分析 IPv6 包头:版本号、流量类别(Traffic Class)、流标签(Flow Label)、有效载荷长度、下一报头、跳数限制与源/目的地址,配合显示过滤器快速定位 IPv6 通信问题。'
},
'/doc/user/ipv6_ping': {
title: 'IPv6 Ping 检测原理',
description: '协议标准:Internet Control Message Protocol (ICMPv6) for the Internet Protocol Version 6 (IPv6) Specification'
description: '协议标准:Internet Control Message Protocol (ICMPv6) for the Internet Protocol Version 6 (IPv6) Specification',
seoTitle: 'IPv6 Ping 检测原理:ICMPv6 回显请求与应答',
seoDescription: 'IPv6 Ping 基于 ICMPv6 协议(Internet Control Message Protocol for IPv6)的回显请求(Echo Request)与应答(Echo Reply)实现。本文讲解 ICMPv6 报文结构与类型字段,以及 IPv6 为何用多播取代广播探测。'
},
'/doc/api/ip/locate': {
title: '获取客户端公网 IP 及位置',
description: '调用本项目的 /v1/location 接口,一次取回访问者自己的公网 IP 与 10 套 IP 库的归属地结果。'
description: '调用本项目的 /v1/location 接口,一次取回访问者自己的公网 IP 与 10 套 IP 库的归属地结果。',
seoTitle: 'API 文档:获取客户端公网 IP 与多库归属地结果',
seoDescription: '调用本项目的 /v1/location 接口,一次取回访问者自己的公网 IPv4/IPv6 地址与 10 套 IP 库(MaxMind、IP2Region、GeoCN 等)的归属地结果,返回 JSON、支持跨域,附请求示例与响应字段说明。'
},
'/doc/api/ip/myip': {
title: '获取客户端公网 IP 地址',
description: '用自带边缘函数获取客户端公网 IP,纯文本返回、支持跨域,可按 IPv4 / IPv6 / 双栈分别部署。'
description: '用自带边缘函数获取客户端公网 IP,纯文本返回、支持跨域,可按 IPv4 / IPv6 / 双栈分别部署。',
seoTitle: 'API 文档:边缘函数获取客户端公网 IP(纯文本跨域)',
seoDescription: '使用本项目自带的边缘函数获取客户端公网 IP:纯文本直接返回、支持 CORS 跨域,可按 IPv4-only / IPv6-only / 双栈三种方式分别部署,适合脚本、服务器监控与 CI 环境快速取公网 IP。'
},
'/doc/api/ip/query': {
title: '查询指定 IP 地址的位置信息',
description: '调用本项目的 /v1/location/<ip> 接口查询任意 IPv4 / IPv6 地址的归属地,返回多源聚合结果。'
description: '调用本项目的 /v1/location/<ip> 接口查询任意 IPv4 / IPv6 地址的归属地,返回多源聚合结果。',
seoTitle: 'API 文档:查询指定 IPv4/IPv6 地址的归属地信息',
seoDescription: '调用本项目的 /v1/location/<ip> 接口查询任意 IPv4 / IPv6 地址的归属地:返回多套 IP 数据库聚合的地理位置、运营商、ASN 等结果,支持节点直连与中间件转发两种调用方式,附完整响应示例。'
},
'/doc/api/whois/query': {
title: '查询域名的 WHOIS 信息',
description: '调用本项目的 /v1/whois/<domain> 接口查询域名注册信息:IANA → 注册局 → 注册商转介三跳,返回状态、注册商、关键日期与 NS。'
description: '调用本项目的 /v1/whois/<domain> 接口查询域名注册信息:IANA → 注册局 → 注册商转介三跳,返回状态、注册商、关键日期与 NS。',
seoTitle: 'API 文档:查询域名 WHOIS 注册信息(三跳转介)',
seoDescription: '调用本项目的 /v1/whois/<domain> 接口查询域名注册信息:按 IANA → 注册局 → 注册商三跳转介获取完整 WHOIS 数据,返回查询状态、注册商、注册/到期等关键日期与 NS 服务器字段。'
},
'/doc/server/check_userip_ipv4_ipv6': {
title: 'JS 检查网络是 IPv4 还是 IPv6',
description: '通过 JS 代码检查客户端是 IPv4 还是 IPv6 访问优先。'
description: '通过 JS 代码检查客户端是 IPv4 还是 IPv6 访问优先。',
seoTitle: 'JS 检查客户端网络是 IPv4 还是 IPv6 访问优先',
seoDescription: '通过一段前端 JS 代码检查客户端当前是 IPv4 还是 IPv6 访问、以及双栈网络下的访问优先级:分别向 IPv4/IPv6 探测端点发起请求并按可达性判定,可嵌入任意网页实时展示访客的网络栈状态。'
},
'/doc/server/tls': {
title: '网站如何开启 TLS',
description: '用 Wireshark 抓包分析 TLS 1.3 的 TCP 三次握手与 TLS 协商全过程。'
description: '用 Wireshark 抓包分析 TLS 1.3 的 TCP 三次握手与 TLS 协商全过程。',
seoTitle: '网站如何开启 TLS:TLS 1.3 握手过程抓包分析',
seoDescription: '用 Wireshark 抓包分析 TLS 1.3 的 TCP 三次握手与 TLS 协商全过程:ClientHello、密钥共享(Key Share)、证书验证与 Finished 报文逐个拆解,理解网站开启 TLS 加密后的完整建连链路。'
},
'/doc/user/code_getip': {
title: 'Python/Go 获取 IPv4 和 IPv6 地址',
description: '通过 Python/Golang 获取公网 IPv4 和 IPv6 地址,还可以返回是 IPv4 还是 IPv6 访问优先。'
description: '通过 Python/Golang 获取公网 IPv4 和 IPv6 地址,还可以返回是 IPv4 还是 IPv6 访问优先。',
seoTitle: 'Python / Golang 代码获取公网 IPv4 与 IPv6 地址',
seoDescription: '用 Python 和 Golang 获取本机公网 IPv4 和 IPv6 地址:分别请求 IPv4、IPv6 探测端点并判定当前网络的访问优先栈,附可直接运行的完整示例代码,适合嵌入监控脚本或后端服务使用。'
},
'/doc/user/dns_dig': {
title: 'DNS 解析流程',
description: '剖析 DNS 解析流程'
description: '剖析 DNS 解析流程',
seoTitle: 'DNS 解析流程详解:递归查询与迭代查询全过程',
seoDescription: '剖析一次完整的 DNS 解析流程:浏览器缓存 → 操作系统缓存 → 本地 DNS 服务器的递归查询 → 根域名 / 顶级域 / 权威服务器的迭代过程,配合 dig 命令逐步演示,理解域名解析为何时快时慢。'
}
}
+42 -19
View File
@@ -2,6 +2,7 @@
/*
前端的一系列配置
*/
const config = {
siteUrl: "https://nipw.cn/",
// 站点名称:用于页面标题 / 描述 / 页脚品牌展示
@@ -18,8 +19,9 @@ const config = {
// 前端自带中间件始终放在候选列表最后一位兜底。
// 未配置或为空数组时,直接使用前端自带中间件。
Middleware: <string[]>[
"https://middleware-1.api-ipw.wsmdn.top/"
// "http://127.0.0.1:8092/",
"https://middleware-1.api-ipw.wsmdn.top/",
"https://boce-api.api-ipw.wsmdn.top/"
// "https://middleware-2.wsmdn.top/",
],
// Umami 统计
@@ -35,24 +37,31 @@ const config = {
v4OnlyAPI: "https://4.wsmdn.top",
v6OnlyAPI: "https://6.wsmdn.top",
DualStackAPI: "https://test.wsmdn.top",
// 网站截图服务(screenshot 页主通道):thum.io 同步出图、CORS 全开,
// 响应 content-type 即结果信号(PNG/JPEG=成品,GIF=生成中或目标不可达),
// 失败自动回退 WordPress mshots(s0.wp.com,见 screenshot.vue)。
// 写进 config 的另一个作用:nuxt.config 的 extractDomains 会把它收进
// CSP connect-src 白名单,页面里的 fetch 才能通过安全头。
screenshotAPI: "https://image.thum.io",
// ---- 节点池结构 ----
// IPLocationAPI:IP 归属查询(location / asn)上游节点池(纯数组,无栈区分)
IPLocationAPI: [
{
label: "中国 四川 沙渠 电信[ZFC]",
id: "cn2-sichuan",
url: "https://cn2-sichuan.api-ipw.wsmdn.top/"
},
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
},
}
// {
// label: "中国 湖北 武汉 电信",
// id: "cn-wuhan-chinatelecom",
// url: ""
// },
{
label: "中国 四川 沙渠 电信[ZFC]",
id: "cn2-sichuan",
url: "https://cn2-sichuan.api-ipw.wsmdn.top/"
}
],
// APIBaseURL:其余拨测(whois / ssl / detail / dns / dnssec / tcping / speed)上游节点池,含 { IPv6, IPv4, DualStack } 三栈
APIBaseURL: {
@@ -84,22 +93,17 @@ const config = {
id: "hk-shatin",
url: "https://china-hk-kowloon-shatindistrict-newcloud.ipwapi.zfap.wsmdn.top/"
},
{
label: "中国 陕西 西安 北经济技术开发区 未央区凤城 中国电信[ZFC]",
id: "cn-xian",
url: "https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/"
},
// {
// label: "中国 陕西 西安 北经济技术开发区 未央区凤城 中国电信[ZFC]",
// id: "cn-xian",
// url: "https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/"
// },
{
label: "美国/加利福尼亚州/洛杉矶/蒙特利奇帕克/USCD[ZFC]",
id: "us-la",
url: "https://america-california-losangeles.ipwapi.zfap.wsmdn.top/"
},
// IP 直连新节点:URL 留空,仅经独立中间件转发
{
label: "山东 枣庄 双线",
id: "zaozhuang",
url: ""
},
{
label: "香港 Cogent",
id: "hongkong",
@@ -113,7 +117,6 @@ const config = {
// }
],
DualStack: [
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
@@ -125,6 +128,26 @@ const config = {
url: "https://cn-shenzhen.api-ipw.wsmdn.top/"
},
// {
// label: "河南 安阳 联通",
// "id": "anyang-cu"
// },
// 仅经独立中间件转发:URL 留空,内置中间件会直接报 502 "Backend URL not configured"
{
label: "上海市 上海市 奉贤区 移动",
id: "70dc5411-0cb6-425d-838f-a9bc81984ed4",
url: ""
},
{
label: "江西 南昌 移动",
id: "97d8873f-f0bb-4e3a-bf41-0b78878b8f34",
url: ""
},
{
label: "中国 广东 梅州 电信",
id: "b3e06025-d9bd-458d-b1c4-cbba27f2a537",
url: ""
}
// {
// label: "中国 湖北 武汉 电信",
// id: "cn-wuhan-chinatelecom",
// url: ""
+90 -16
View File
@@ -1,5 +1,4 @@
import {config} from "./config/index";
import { docConfig } from "./config/doc";
// https://nuxt.com/docs/api/configuration/nuxt-config
const extractDomains = (obj: any): string[] => {
// 将对象转为 JSON 字符串,用正则匹配所有 https:// 开头的域名部分
@@ -10,9 +9,42 @@ const extractDomains = (obj: any): string[] => {
};
const allowedDomains = extractDomains(config);
// 首屏「IPv4 / IPv6 / 优先级」三行的数据源(见 app/pages/index.vue 的 onMounted):
// 三个接口分属三个独立源站,且都要等水合完成后才发起 —— 那时才做 DNS+TCP+TLS 太晚。
// 把它们抽出来做 preconnect,让握手在 HTML 解析期就开始。
const ipProbeOrigins = [
config.DualStackAPI,
config.v4OnlyAPI,
config.v6OnlyAPI,
].map((url) => new URL(url).origin);
// 中间件(工具页 /middleware/* 的上游):只在部分页面用到,DNS 预解析即可,不占连接。
const middlewareOrigin = config.Middleware?.[0]
? new URL(config.Middleware[0]).origin
: null;
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
experimental: {
// 关闭 app manifest 版本检查:水合后 Nuxt 会在 requestIdleCallback 里请求
// /_nuxt/builds/meta/<buildId>.json,buildId 对不上(本地「服务旧、磁盘新」、
// 或线上发版切换瞬间)就抛 NUXT_E5002 污染 console,还拖累 Best Practices 分。
// 本项目没依赖 appManifest 的客户端能力(无客户端 routeRules、无 prerender
// 判定),关掉少一个请求,console 永远干净。
appManifest: false,
// 注:inlineStyles: true 实测是负优化——它只内联 page 组件样式(+12KB HTML),
// entry/EP 等共享样式仍是 render-blocking 外链,关键请求一个没省。勿开。
defaults: {
nuxtLink: {
// NuxtLink 默认「链接进入视口就预取目标页 JS」——顶栏 15 个菜单项会让
// 首屏把其它工具页的 chunk(含 87KB 的校验库等重依赖)全拉下来,
// 白吃带宽还挤占首屏关键请求。改为「悬停/聚焦才预取」:真正要去
// 哪个页面时再拉那一页,首屏只加载当前页需要的代码。
prefetchOn: { interaction: true, visibility: false },
},
},
},
modules: [
"nitro-cloudflare-dev",
'@element-plus/nuxt',
@@ -31,6 +63,24 @@ export default defineNuxtConfig({
]
},
},
hooks: {
// 掐掉「当前路由的动态 import 预取」。
// Nuxt 的 vue-bundle-renderer 会把 manifest 里带 prefetch 标记的动态 chunk
// 渲染成 `<link rel="prefetch" as="script">`,于是首页一边解析 HTML 一边就把
// shiki 高亮器那一整块(实测 165 KB,占首页字节 25%)下下来了 —— 而高亮已经在
// SSR 期算完写进 HTML,客户端根本不会执行它,纯属白下。
// 把 manifest 上的标记抹掉即可:动态 chunk 回到「真正 import 时才加载」。
// 静态闭包走的是 preload 分支(modulepreload),不受影响。
// —— 2026-10-04 曾试把 preload 也置 false:FCP 确实 2333→1404(少了 22 个请求的
// 下载竞争),但 LCP 2387→2933、TBT 86→280(权重 25%/30% 远大于 FCP 的 10%),
// 总分 94→89 净亏。原因:这 22 个 modulepreload 是 entry 的静态依赖闭包,全掐掉后
// hydration 推迟、长任务落进 LCP/TBT 窗口。故只掐 prefetch,preload 必须留。
'build:manifest': (manifest) => {
for (const chunk of Object.values(manifest) as unknown as Array<Record<string, unknown>>) {
if (chunk.prefetch) chunk.prefetch = false
}
},
},
site: {
url: config.siteUrl,
name: 'Lemon IPW'
@@ -66,24 +116,44 @@ export default defineNuxtConfig({
}
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.svg' }
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.svg' },
// preconnect 三个 IP 探测源站。crossorigin 不能省:$fetch 走 fetch() 的跨域
// (CORS)模式,不带它预热出来的是 no-cors 连接,真正的 fetch 用不上、还得重连。
...ipProbeOrigins.map((href) => ({
rel: 'preconnect',
href,
crossorigin: 'anonymous',
} as const)),
...(middlewareOrigin
? [{ rel: 'dns-prefetch', href: middlewareOrigin } as const]
: []),
]
}
},
runtimeConfig: {
indexnowKey: '',
// IndexNow 所有权凭证:与 public/<同名>.txt 一致(IndexNow 靠抓取该文件验证),
// key 本身是公开凭证不是机密;环境变量 NUXT_INDEXNOW_KEY 可覆盖。
// 消费方:server/api/indexnow.*.ts(运行时提交端点)。
indexnowKey: 'd49c90fb2fb160cdd2cd4b09532bc380',
apiKeys: '',
public: {
siteUrl: config.siteUrl,
docConfig: docConfig,
// 这里**不要**再放 docConfig:runtimeConfig.public 会被整份序列化进
// 每个页面 HTML 的 `window.__NUXT__.config`,而这份文档树(32 条 title+description)
// 实测占 4571 字节 —— 也就是每一页的 HTML 都白白多背 4.5KB(首页 HTML 的 10%)。
// 它全站只有一个消费方,且是服务端路由(server/api/__sitemap__/urls.ts),
// 那里改成直接 import 构建期常量即可;文档页本来就用 `getDocMeta` 直接 import。
},
},
routeRules: {
// /doc 文档站改为 SSG:构建时预渲染为静态 HTML,由边缘静态资源直接响应
'/doc/**': { prerender: true },
// 缓存头一律不在这里设,交由 CDN(EdgeOne)侧控制。
// /doc/** 曾经是 SSG(prerender),已撤回改为 SSR,原因见下方 nitro.prerender 处的注释。
},
nitro: {
// 构建期给 public/ 静态资源生成 .gz/.br 预压缩副本,按 Accept-Encoding 直接回;
// SSR HTML 的压缩在 server/plugins/compress-html.ts。两者合起来让本地直连的
// 传输体积与线上(EdgeOne 会压)一致,Lighthouse 的 Lantern 模拟才不会虚高。
compressPublicAssets: true,
publicAssets: [
{
dir: 'public',
@@ -95,17 +165,21 @@ export default defineNuxtConfig({
target: 'es2022' // 明确告诉 Nitro 使用 es2022 进行打包
}
},
prerender: {
// 显式列出所有 doc 路由(来自 config/doc.ts 的 docConfig 键),
// 让动态 [...slug] 页面在构建时全部预渲染为静态 HTML
routes: ['/doc', ...Object.keys(docConfig).filter((p) => p !== '/doc')],
},
// 这里曾用 prerender.routes 把整份 docConfig 的文档页在构建期预渲染成静态 HTML。
// 已撤回,改回 SSR —— 因为预渲染与「按请求头决定渲染形态」根本冲突:
// 构建期没有请求头,窄屏判定恒为宽屏,于是预渲染产物里永远躺着
// 「15 条桌面顶栏菜单 + 被 CSS 隐藏的 40 项文档侧栏」;真手机打开时客户端判定为窄屏,
// 首帧 vdom 与 HTML 对不上,Vue 水合直接抛
// `Cannot read properties of null (reading 'nodeType')`,整页变成 500。
// 顺带收益:/doc 现在也走运行时安全头(预渲染页拿不到 CSP,见下方 security.ssg)。
// 代价只是源站多跑一层 SSR,前面有 EdgeOne 缓存兜着。
},
security: {
ssg: {
// /doc 预渲染页的 CSP 会因 SRI modulepreload 哈希全量拼入而单行超 2000 字符,
// 触发 Cloudflare _headers 限制;关闭静态页安全头写入 _headers。
// SSR 页面仍由运行时中间件下发完整安全头,不受影响。
// 预渲染页的 CSP 会因 SRI modulepreload 哈希全量拼入而单行超 2000 字符,
// 触发 Cloudflare _headers 限制,故关掉静态页的安全头写入。
// 当前没有预渲染路由(/doc/** 已改回 SSR),这条是留给将来再开 prerender 时的开关:
// 一旦重新启用 prerender,那些页就会退回「无 CSP」状态。
nitroHeaders: false,
},
headers: {
@@ -125,7 +199,7 @@ export default defineNuxtConfig({
],
'style-src': ["'self'", 'https:', "'unsafe-inline'"],
'img-src': ["'self'", 'https://s0.wp.com', 'data:', 'https:'],
'img-src': ["'self'", 'https://s0.wp.com', 'data:', 'blob:', 'https:'],
'font-src': ["'self'", 'https:', 'data:'],
}
}
+9 -9
View File
@@ -2457,8 +2457,8 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
devalue@5.9.2:
resolution: {integrity: sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==}
devalue@5.9.4:
resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==}
devframe@0.7.16:
resolution: {integrity: sha512-U4vqEPgjYfovFEyOAmUlNG5DVGnb6Sx0Tgy9QiQAbtGieC5zTraOBL7GUyXGqdc53SCx/DhFfwjJcebt85T/Bw==}
@@ -4375,8 +4375,8 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shell-quote@1.10.0:
resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==}
shell-quote@1.12.0:
resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==}
engines: {node: '>= 0.4'}
shiki@4.4.1:
@@ -6173,7 +6173,7 @@ snapshots:
consola: 3.4.2
defu: 6.1.7
destr: 2.0.5
devalue: 5.9.2
devalue: 5.9.4
errx: 0.1.2
escape-string-regexp: 5.0.0
exsolve: 1.1.1
@@ -7741,7 +7741,7 @@ snapshots:
detect-libc@2.1.2: {}
devalue@5.9.2: {}
devalue@5.9.4: {}
devframe@0.7.16(srvx@0.11.22)(typescript@5.9.3):
dependencies:
@@ -8449,7 +8449,7 @@ snapshots:
launch-editor@2.14.1:
dependencies:
picocolors: 1.1.1
shell-quote: 1.10.0
shell-quote: 1.12.0
lazystream@1.0.1:
dependencies:
@@ -9052,7 +9052,7 @@ snapshots:
consola: 3.4.2
cookie-es: 3.1.1
defu: 6.1.7
devalue: 5.9.2
devalue: 5.9.4
errx: 0.1.2
escape-string-regexp: 5.0.0
exsolve: 1.1.1
@@ -9988,7 +9988,7 @@ snapshots:
shebang-regex@3.0.0: {}
shell-quote@1.10.0: {}
shell-quote@1.12.0: {}
shiki@4.4.1:
dependencies:
@@ -0,0 +1 @@
d49c90fb2fb160cdd2cd4b09532bc380
@@ -0,0 +1,154 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1240 640" width="1240" height="640" data-generator="fireworks-tech-graph" data-schema-version="1" data-text-metrics="heuristic-v1" data-style-id="7" data-visual-theme="OpenAI" data-diagram-type="architecture" data-motion-scene="" data-semantic-profile="generic" data-semantic-valid="true" data-quality-profile="standard" data-max-bends-per-edge="12" data-max-total-bends="100" data-max-route-stretch="5.0" data-max-bridged-crossings="8" data-min-node-gap="0.0" data-min-container-gutter="0.0" data-min-label-clearance="2.0" data-min-segment-length="0.0">
<defs>
<marker id="arrowA" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#10a37f"/>
</marker>
<marker id="arrowB" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#0f766e"/>
</marker>
<marker id="arrowC" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#0891b2"/>
</marker>
<marker id="arrowE" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#f59e0b"/>
</marker>
<marker id="arrowF" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#64748b"/>
</marker>
<marker id="arrowG" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#475569"/>
</marker>
<marker id="arrowH" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#94a3b8"/>
</marker>
<style>
text { font-family: 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', 'Noto Sans CJK SC', 'Microsoft YaHei', 'Microsoft JhengHei', 'SimHei', sans-serif; }
.title { font-size: 24px; font-weight: 700; fill: #0f172a; }
.subtitle { font-size: 13px; font-weight: 500; fill: #64748b; }
.section { font-size: 13px; font-weight: 700; fill: #087f5b; letter-spacing: 1.4px; }
.section-sub { font-size: 12px; font-weight: 500; fill: #64748b; }
.node-title { font-weight: 700; fill: #0f172a; }
.node-sub { font-weight: 500; fill: #475569; }
.node-type { font-size: 11px; font-weight: 700; fill: #64748b; letter-spacing: 0.08em; }
.arrow-label { font-size: 12px; font-weight: 600; fill: #475569; }
.legend { font-size: 12px; font-weight: 500; fill: #475569; }
.footnote { font-size: 12px; font-weight: 500; fill: #64748b; }
.metric-label { font-size: 8.5px; font-weight: 700; fill: #64748b; text-transform: uppercase; }
.metric-value { font-size: 9.5px; font-weight: 700; fill: #0f172a; }
</style>
</defs>
<rect data-graph-role="background" width="1240.0" height="640.0" fill="#ffffff"/>
<text x="48.0" y="48" text-anchor="start" class="title">DNSSEC 验证链与本地验签</text>
<text x="48.0" y="72" text-anchor="start" class="subtitle">DS 信任链自根向下逐级建立,递归侧以 DNSKEY 验证 RRset 签名</text>
<line x1="48" y1="100" x2="1192.0" y2="100" stroke="#e2e8f0" stroke-width="1"/>
<g id="trust" data-graph-role="container" data-container-id="trust" data-semantic-role="boundary" data-graph-bounds="40,110,1200,260">
<rect data-graph-role="container" x="40.0" y="110.0" width="1160.0" height="150.0" rx="16.0" fill="none" stroke="#e2e8f0" stroke-width="1.4" stroke-dasharray="5 4"/>
<text x="58.0" y="134.0" class="section">信任链 · 由根向下逐级签发</text>
<rect id="trust-header" data-graph-role="reserved" data-reserved-kind="container-header" x="48" y="116" width="189.9" height="30" fill="none" stroke="none"/>
</g>
<g id="verify" data-graph-role="container" data-container-id="verify" data-semantic-role="boundary" data-graph-bounds="40,310,1200,560">
<rect data-graph-role="container" x="40.0" y="310.0" width="1160.0" height="250.0" rx="16.0" fill="none" stroke="#e2e8f0" stroke-width="1.4" stroke-dasharray="5 4"/>
<text x="58.0" y="334.0" class="section">递归解析侧 · 本地验签</text>
<rect id="verify-header" data-graph-role="reserved" data-reserved-kind="container-header" x="48" y="316" width="163.9" height="30" fill="none" stroke="none"/>
</g>
<path id="ds-tld" data-graph-role="edge" data-edge-id="ds-tld" data-source="root" data-target="tld" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 330,195 L 495,195" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
<path id="ds-auth" data-graph-role="edge" data-edge-id="ds-auth" data-source="tld" data-target="auth" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 745,195 L 905,195" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
<path id="pub-key" data-graph-role="edge" data-edge-id="pub-key" data-source="auth" data-target="dnskey" data-edge-kind="flow" data-topic-id="" data-flow="write" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="" d="M 1023.5,225 L 1023.5,346 L 205,346 L 205,370" fill="none" stroke="#0f766e" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowB)"/>
<path id="sign" data-graph-role="edge" data-edge-id="sign" data-source="auth" data-target="rrset" data-edge-kind="flow" data-topic-id="" data-flow="write" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="6" data-route-stretch="1.872" data-bridges="" d="M 1041.5,225 L 1041.5,245.4 L 1184,245.4 L 1184,454 L 745,454 L 745,349.6 L 620,349.6 L 620,370" fill="none" stroke="#0f766e" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowB)"/>
<path id="fetch-key-bridge-mask" data-graph-role="bridge-mask" data-owner="fetch-key" d="M 330,400 L 350.4,400 L 350.4,461 L 884.6,461 L 884.6,391 L 905,391" fill="none" stroke="#ffffff" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="fetch-key" data-graph-role="edge" data-edge-id="fetch-key" data-source="dnskey" data-target="resolver" data-edge-kind="flow" data-topic-id="" data-flow="read" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="1.209" data-bridges="884.6,454" d="M 330,400 L 350.4,400 L 350.4,461 L 884.6,461 L 884.6,391 L 905,391" fill="none" stroke="#0891b2" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowC)"/>
<path id="fetch-rr-bridge-mask" data-graph-role="bridge-mask" data-owner="fetch-rr" d="M 745,400 L 748.28,400 L 748.28,409 L 879.6,409 A 5 5 0 0 0 889.6,409 L 905,409" fill="none" stroke="#ffffff" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="fetch-rr" data-graph-role="edge" data-edge-id="fetch-rr" data-source="rrset" data-target="resolver" data-edge-kind="flow" data-topic-id="" data-flow="read" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="745,400;884.6,409" d="M 745,400 L 748.28,400 L 748.28,409 L 879.6,409 A 5 5 0 0 0 889.6,409 L 905,409" fill="none" stroke="#0891b2" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowC)"/>
<path id="a-check-bridge-mask" data-graph-role="bridge-mask" data-owner="a-check" d="M 1032.5,430 L 1032.5,450.4 L 1044,450.4 L 1044,565 L 590,565 L 590,485 L 445,485 L 445,485" fill="none" stroke="#ffffff" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="a-check" data-graph-role="edge" data-edge-id="a-check" data-source="resolver" data-target="check" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="5" data-route-stretch="1.285" data-bridges="1044,454" d="M 1032.5,430 L 1032.5,450.4 L 1044,450.4 L 1044,565 L 590,565 L 590,485 L 445,485 L 445,485" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
<path id="a-pass-bridge-mask" data-graph-role="bridge-mask" data-owner="a-pass" d="M 590,513 L 730,513" fill="none" stroke="#ffffff" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="a-pass" data-graph-role="edge" data-edge-id="a-pass" data-source="check" data-target="pass" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="590,513" d="M 590,513 L 730,513" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
<g id="node-root" data-graph-role="node" data-node-id="root" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="80,165,330,225">
<rect x="80.0" y="165.0" width="250.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
<text x="205.0" y="183.0" text-anchor="middle" class="node-type">ROOT · 信任锚</text>
<text data-text-role="title" data-full-text="根 DNS(内置信任锚)" data-text-max-width="226.0" x="205.0" y="201.0" text-anchor="middle" class="node-title" font-size="18">根 DNS(内置信任锚)</text>
</g>
<g id="node-tld" data-graph-role="node" data-node-id="tld" data-semantic-role="double_rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="495,165,745,225">
<rect x="495.0" y="165.0" width="250.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#10a37f" stroke-width="2.0"/>
<rect x="501.0" y="171.0" width="238.0" height="48.0" rx="11.0" fill="none" stroke="#10a37f" stroke-width="1.2" opacity="0.65"/>
<text x="620.0" y="183.0" text-anchor="middle" class="node-type">TLD · .top</text>
<text data-text-role="title" data-full-text="顶级 DNS(发布 DS)" data-text-max-width="218.0" x="620.0" y="201.0" text-anchor="middle" class="node-title" font-size="18">顶级 DNS(发布 DS)</text>
</g>
<g id="node-auth" data-graph-role="node" data-node-id="auth" data-semantic-role="double_rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="905,165,1160,225">
<rect x="905.0" y="165.0" width="255.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#10a37f" stroke-width="2.0"/>
<rect x="911.0" y="171.0" width="243.0" height="48.0" rx="11.0" fill="none" stroke="#10a37f" stroke-width="1.2" opacity="0.65"/>
<text x="1032.5" y="183.0" text-anchor="middle" class="node-type">AUTHORITATIVE</text>
<text data-text-role="title" data-full-text="权威 DNS(发布 DNSKEY)" data-text-max-width="223.0" x="1032.5" y="201.0" text-anchor="middle" class="node-title" font-size="17.29">权威 DNS(发布 DNSKEY)</text>
</g>
<g id="node-dnskey" data-graph-role="node" data-node-id="dnskey" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="80,370,330,430">
<rect x="80.0" y="370.0" width="250.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
<text x="205.0" y="388.0" text-anchor="middle" class="node-type">DNSKEY</text>
<text data-text-role="title" data-full-text="KSK / ZSK 密钥" data-text-max-width="226.0" x="205.0" y="406.0" text-anchor="middle" class="node-title" font-size="18">KSK / ZSK 密钥</text>
</g>
<g id="node-rrset" data-graph-role="node" data-node-id="rrset" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="495,370,745,430">
<rect x="495.0" y="370.0" width="250.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
<text x="620.0" y="388.0" text-anchor="middle" class="node-type">RRSET + RRSIG</text>
<text data-text-role="title" data-full-text="记录数据与签名" data-text-max-width="226.0" x="620.0" y="406.0" text-anchor="middle" class="node-title" font-size="18">记录数据与签名</text>
</g>
<g id="node-resolver" data-graph-role="node" data-node-id="resolver" data-semantic-role="double_rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="905,370,1160,430">
<rect x="905.0" y="370.0" width="255.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#1d4ed8" stroke-width="2.0"/>
<rect x="911.0" y="376.0" width="243.0" height="48.0" rx="11.0" fill="none" stroke="#1d4ed8" stroke-width="1.2" opacity="0.65"/>
<text x="1032.5" y="388.0" text-anchor="middle" class="node-type">RECURSIVE · DO=1</text>
<text data-text-role="title" data-full-text="递归解析器(带 DO 查询)" data-text-max-width="223.0" x="1032.5" y="406.0" text-anchor="middle" class="node-title" font-size="17.38">递归解析器(带 DO 查询)</text>
</g>
<g id="node-check" data-graph-role="node" data-node-id="check" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="300,485,590,541">
<rect x="300.0" y="485.0" width="290.0" height="56.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
<text x="445.0" y="503.0" text-anchor="middle" class="node-type">VERIFY</text>
<text data-text-role="title" data-full-text="RRSIG.Verify 本地验签" data-text-max-width="266.0" x="445.0" y="519.0" text-anchor="middle" class="node-title" font-size="18">RRSIG.Verify 本地验签</text>
</g>
<g id="node-pass" data-graph-role="node" data-node-id="pass" data-semantic-role="double_rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="730,485,1020,541">
<rect x="730.0" y="485.0" width="290.0" height="56.0" rx="14.0" fill="#ffffff" stroke="#10a37f" stroke-width="2.0"/>
<rect x="736.0" y="491.0" width="278.0" height="44.0" rx="11.0" fill="none" stroke="#10a37f" stroke-width="1.2" opacity="0.65"/>
<text x="875.0" y="503.0" text-anchor="middle" class="node-type">RESULT</text>
<text data-text-role="title" data-full-text="验证通过 · 数据完整可信" data-text-max-width="258.0" x="875.0" y="519.0" text-anchor="middle" class="node-title" font-size="18">验证通过 · 数据完整可信</text>
</g>
<g id="ds-tld-label" data-graph-role="label" data-owner="ds-tld" data-graph-bounds="384.38,164,440.62,184">
<rect x="384.38" y="164.0" width="56.24" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="412.5" y="178.0" text-anchor="middle" class="arrow-label">DS 签发</text>
</g>
<g id="ds-auth-label" data-graph-role="label" data-owner="ds-auth" data-graph-bounds="772.28,164,877.72,184">
<rect x="772.28" y="164.0" width="105.44" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="825.0" y="178.0" text-anchor="middle" class="arrow-label">DS 指向下级 KEY</text>
</g>
<g id="pub-key-label" data-graph-role="label" data-owner="pub-key" data-graph-bounds="583.25,315,645.25,335">
<rect x="583.25" y="315.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="614.25" y="329.0" text-anchor="middle" class="arrow-label">发布密钥</text>
</g>
<g id="sign-label" data-graph-role="label" data-owner="sign" data-graph-bounds="933.5,441,995.5,461">
<rect x="933.5" y="441.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="964.5" y="455.0" text-anchor="middle" class="arrow-label">私钥签名</text>
</g>
<g id="fetch-key-label" data-graph-role="label" data-owner="fetch-key" data-graph-bounds="575.46,434,659.54,454">
<rect x="575.46" y="434.0" width="84.08" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="617.5" y="448.0" text-anchor="middle" class="arrow-label">取回 DNSKEY</text>
</g>
<g id="fetch-rr-label" data-graph-role="label" data-owner="fetch-rr" data-graph-bounds="789.64,378,863.64,398">
<rect x="789.64" y="378.0" width="74.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="826.64" y="392.0" text-anchor="middle" class="arrow-label">带签名应答</text>
</g>
<g id="a-check-label" data-graph-role="label" data-owner="a-check" data-graph-bounds="772.92,552,861.08,572">
<rect x="772.92" y="552.0" width="88.16" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="817.0" y="566.0" text-anchor="middle" class="arrow-label">RRSIG.Verify</text>
</g>
<g id="a-pass-label" data-graph-role="label" data-owner="a-pass" data-graph-bounds="629,482,691,502">
<rect x="629.0" y="482.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="660.0" y="496.0" text-anchor="middle" class="arrow-label">验签通过</text>
</g>
<g id="legend" data-graph-role="legend">
<rect id="legend-zone" data-graph-role="reserved" data-reserved-kind="legend" x="32" y="516" width="183.6" height="90" rx="10" fill="none" stroke="none"/>
<line data-graph-role="decoration" x1="42.0" y1="530.0" x2="72.0" y2="530.0" stroke="#10a37f" stroke-width="2.0" marker-end="url(#arrowA)"/>
<text data-graph-role="decoration" x="82.0" y="534.0" class="legend">信任链 / 主验证路径</text>
<line data-graph-role="decoration" x1="42.0" y1="552.0" x2="72.0" y2="552.0" stroke="#0891b2" stroke-width="2.0" marker-end="url(#arrowC)"/>
<text data-graph-role="decoration" x="82.0" y="556.0" class="legend">数据获取</text>
<line data-graph-role="decoration" x1="42.0" y1="574.0" x2="72.0" y2="574.0" stroke="#0f766e" stroke-width="2.0" marker-end="url(#arrowB)"/>
<text data-graph-role="decoration" x="82.0" y="578.0" class="legend">权威侧写操作</text>
</g>
<g id="footer" data-graph-role="reserved" data-graph-bounds="48,613,391,629">
<text x="48.0" y="625.0" class="footnote">Style 7 · OpenAI Official · DNSSEC chain of trust</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 20 KiB

@@ -1,225 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 880" width="960" height="880" data-generator="fireworks-tech-graph" data-schema-version="1" data-text-metrics="heuristic-v1" data-style-id="10" data-visual-theme="Cloud Fabric" data-diagram-type="deployment" data-motion-scene="" data-semantic-profile="generic" data-semantic-valid="true" data-quality-profile="standard" data-max-bends-per-edge="12" data-max-total-bends="100" data-max-route-stretch="5.0" data-max-bridged-crossings="8" data-min-node-gap="0.0" data-min-container-gutter="0.0" data-min-label-clearance="2.0" data-min-segment-length="0.0">
<defs>
<marker id="arrowA" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#2563eb"/>
</marker>
<marker id="arrowB" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#ea580c"/>
</marker>
<marker id="arrowC" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#0891b2"/>
</marker>
<marker id="arrowE" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#059669"/>
</marker>
<marker id="arrowF" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#7c3aed"/>
</marker>
<marker id="arrowG" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#db2777"/>
</marker>
<marker id="arrowH" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#64748b"/>
</marker>
<filter id="shadowSoft" x="-20%" y="-20%" width="140%" height="160%">
<feDropShadow dx="0" dy="3" stdDeviation="6" flood-color="#0f172a" flood-opacity="0.12"/>
</filter>
<filter id="shadowGlass" x="-20%" y="-20%" width="140%" height="160%">
<feDropShadow dx="0" dy="10" stdDeviation="16" flood-color="#020617" flood-opacity="0.28"/>
</filter>
<linearGradient id="cloudGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f8fcff"/>
<stop offset="100%" stop-color="#dfedf7"/>
</linearGradient>
<pattern id="cloudGrid" width="32" height="32" patternUnits="userSpaceOnUse">
<path d="M 32 0 L 0 0 0 32" fill="none" stroke="#7fa3c2" stroke-opacity="0.10" stroke-width="1"/>
</pattern>
<style>
text { font-family: Inter, 'Helvetica Neue', 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'SimHei', 'Noto Sans CJK SC', sans-serif; }
.title { font-size: 27px; font-weight: 700; fill: #102a43; }
.subtitle { font-size: 13px; font-weight: 500; fill: #52718d; }
.section { font-size: 13px; font-weight: 700; fill: #315d7e; letter-spacing: 1.4px; }
.section-sub { font-size: 12px; font-weight: 500; fill: #7892a8; }
.node-title { font-weight: 700; fill: #102a43; }
.node-sub { font-size: 12px; font-weight: 500; fill: #486581; }
.node-type { font-size: 10px; font-weight: 700; fill: #6b879d; letter-spacing: 0.08em; }
.arrow-label { font-size: 12px; font-weight: 600; fill: #334e68; }
.legend { font-size: 12px; font-weight: 500; fill: #486581; }
.footnote { font-size: 12px; font-weight: 500; fill: #829ab1; }
.metric-label { font-size: 8.5px; font-weight: 700; fill: #829ab1; text-transform: uppercase; }
.metric-value { font-size: 9.5px; font-weight: 700; fill: #102a43; }
</style>
</defs>
<rect data-graph-role="background" width="960.0" height="880.0" fill="url(#cloudGradient)"/>
<rect data-graph-role="decoration" width="960.0" height="880.0" fill="url(#cloudGrid)"/>
<text x="28" y="24" font-size="11.0" font-weight="600" fill="#829ab1">WHOIS · REGISTRY · REGISTRAR</text>
<text x="932.0" y="24" text-anchor="end" font-size="11.0" font-weight="600" fill="#829ab1">Cloud Fabric · Style 10</text>
<text x="48.0" y="48" text-anchor="start" class="title">域名生命周期</text>
<text x="48.0" y="72" text-anchor="start" class="subtitle">Domain Name Lifecycle · Cloud Fabric</text>
<g id="style-signature" data-graph-role="decoration" data-style-signature="cloud-ownership-map">
<rect x="736.0" y="22.0" width="176.0" height="34.0" rx="9" fill="#ffffff" fill-opacity="0.82" stroke="#7fa3c2" stroke-width="1.1"/>
<rect x="747.0" y="31.0" width="14" height="14" rx="4" fill="#dbeafe" stroke="#2563eb" stroke-width="1"/>
<rect x="756.0" y="35.0" width="14" height="14" rx="4" fill="#dcfce7" stroke="#059669" stroke-width="1"/>
<text data-full-text="CLOUD · 1 REGIONS" x="779.0" y="36.0" font-size="8.5" font-weight="800" fill="#315d7e">CLOUD · 1 REGIONS</text>
<text data-full-text="DEPLOYMENT MAP" x="779.0" y="49.0" font-size="8" font-weight="700" fill="#52718d">DEPLOYMENT MAP</text>
</g>
<g id="global" data-graph-role="container" data-container-id="global" data-semantic-role="global" data-graph-bounds="30,120,930,210">
<rect data-graph-role="container" x="30.0" y="120.0" width="900.0" height="90.0" rx="16.0" fill="rgba(255,255,255,0.54)" stroke="#7fa3c2" stroke-width="1.4" stroke-dasharray="7 5"/>
<rect data-graph-role="decoration" x="862.0" y="130.0" width="54.0" height="18" rx="9" fill="#dbeafe" stroke="#93c5fd" stroke-width="0.8"/>
<text data-graph-role="decoration" x="889.0" y="142.5" text-anchor="middle" font-size="9" font-weight="700" fill="#315d7e">GLOBAL</text>
<line data-graph-role="decoration" x1="38.0" y1="162.0" x2="38.0" y2="196.0" stroke="#2563eb" stroke-width="2.2" stroke-linecap="round" opacity="0.72"/>
<text x="48.0" y="144.0" class="section">GLOBAL</text>
<rect id="global-header" data-graph-role="reserved" data-reserved-kind="container-header" x="38" y="126" width="75.24" height="30" fill="none" stroke="none"/>
</g>
<g id="lifecycle" data-graph-role="container" data-container-id="lifecycle" data-semantic-role="region" data-graph-bounds="30,230,930,930">
<rect data-graph-role="container" x="30.0" y="230.0" width="900.0" height="700.0" rx="16.0" fill="rgba(255,255,255,0.54)" stroke="#7fa3c2" stroke-width="1.4" stroke-dasharray="7 5"/>
<rect data-graph-role="decoration" x="862.0" y="240.0" width="54.0" height="18" rx="9" fill="#dbeafe" stroke="#93c5fd" stroke-width="0.8"/>
<text data-graph-role="decoration" x="889.0" y="252.5" text-anchor="middle" font-size="9" font-weight="700" fill="#315d7e">REGION</text>
<line data-graph-role="decoration" x1="38.0" y1="272.0" x2="38.0" y2="916.0" stroke="#0891b2" stroke-width="2.2" stroke-linecap="round" opacity="0.72"/>
<text x="48.0" y="254.0" class="section">LIFECYCLE</text>
<rect id="lifecycle-header" data-graph-role="reserved" data-reserved-kind="container-header" x="38" y="236" width="94.48" height="30" fill="none" stroke="none"/>
</g>
<path id="pending-to-available" data-graph-role="edge" data-edge-id="pending-to-available" data-source="pending-delete" data-target="available" data-edge-kind="flow" data-topic-id="" data-flow="async" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="6" data-route-stretch="1.968" data-bridges="" d="M 380,770 L 380,742 L 160,742 L 160,210 L 160,130 L 170,130 L 170,220.4 L 451,220.4 L 451,200" fill="none" stroke="#7c3aed" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowF)"/>
<path id="register" data-graph-role="edge" data-edge-id="register" data-source="available" data-target="add-period" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="" d="M 469,200 L 469,225 L 380,225 L 380,250" fill="none" stroke="#2563eb" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
<path id="configure-ns" data-graph-role="edge" data-edge-id="configure-ns" data-source="add-period" data-target="active" data-edge-kind="flow" data-topic-id="" data-flow="write" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 380,322 L 380,350" fill="none" stroke="#ea580c" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowB)"/>
<path id="renew" data-graph-role="edge" data-edge-id="renew" data-source="active" data-target="renew-period" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 389,430 L 389,470" fill="none" stroke="#2563eb" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
<path id="manual-renew" data-graph-role="edge" data-edge-id="manual-renew" data-source="renew-period" data-target="active" data-edge-kind="flow" data-topic-id="" data-flow="write" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="1.75" data-bridges="" d="M 371,470 L 371,459.8 L 386,459.8 L 386,434.08 L 371,434.08 L 371,430" fill="none" stroke="#ea580c" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowB)"/>
<path id="auto-renew" data-graph-role="edge" data-edge-id="auto-renew" data-source="active" data-target="auto-renew-period" data-edge-kind="flow" data-topic-id="" data-flow="async" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="1.924" data-bridges="" d="M 353,430 L 353,434.08 L 280,434.08 L 280,549.6 L 371,549.6 L 371,570" fill="none" stroke="#7c3aed" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowF)"/>
<path id="renew-success" data-graph-role="edge" data-edge-id="renew-success" data-source="auto-renew-period" data-target="active" data-edge-kind="flow" data-topic-id="" data-flow="write" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="1.924" data-bridges="" d="M 389,570 L 389,549.6 L 480,549.6 L 480,450.4 L 407,450.4 L 407,430" fill="none" stroke="#ea580c" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowB)"/>
<path id="to-redemption" data-graph-role="edge" data-edge-id="to-redemption" data-source="auto-renew-period" data-target="redemption-period" data-edge-kind="flow" data-topic-id="" data-flow="data" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="1.272" data-bridges="" d="M 480,606 L 500.4,606 L 500.4,642 L 259.6,642 L 259.6,706 L 280,706" fill="none" stroke="#059669" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowE)"/>
<path id="to-transfer" data-graph-role="edge" data-edge-id="to-transfer" data-source="active" data-target="transfer" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="" d="M 480,381 L 504,381 L 504,326 L 680,326" fill="none" stroke="#2563eb" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
<path id="transfer-complete" data-graph-role="edge" data-edge-id="transfer-complete" data-source="transfer" data-target="transfer-period" data-edge-kind="flow" data-topic-id="" data-flow="write" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 780,362 L 780,390" fill="none" stroke="#ea580c" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowB)"/>
<path id="transfer-ok" data-graph-role="edge" data-edge-id="transfer-ok" data-source="transfer-period" data-target="active" data-edge-kind="flow" data-topic-id="" data-flow="write" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="1" data-route-stretch="1.0" data-bridges="" d="M 680,426 L 480,426 L 480,399 L 480,399" fill="none" stroke="#ea580c" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowB)"/>
<g id="node-available" data-graph-role="node" data-node-id="available" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="global" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="360,130,560,200">
<rect x="360.0" y="130.0" width="200.0" height="70.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
<rect x="372.0" y="144.0" width="42.0" height="42.0" rx="11" fill="#2563eb" opacity="0.12" stroke="#2563eb" stroke-width="1.2"/>
<circle cx="393.0" cy="165.0" r="11" fill="none" stroke="#2563eb" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 382.0 165.0 H 404.0 M 393.0 154.0 C 387.0 160.0 387.0 170.0 393.0 176.0 M 393.0 154.0 C 399.0 160.0 399.0 170.0 393.0 176.0" fill="none" stroke="#2563eb" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<text data-text-role="title" data-full-text="Available" x="426.0" y="161.0" text-anchor="start" class="node-title" font-size="13.5">Available</text>
<text data-text-role="subtitle" data-full-text="域名可注册" x="426.0" y="180.0" text-anchor="start" class="node-sub" font-size="11.5">域名可注册</text>
<text x="550.0" y="144.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">DNS</text>
</g>
<g id="node-add-period" data-graph-role="node" data-node-id="add-period" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="lifecycle" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="280,250,480,322">
<rect x="280.0" y="250.0" width="200.0" height="72.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
<rect x="292.0" y="265.0" width="42.0" height="42.0" rx="11" fill="#0891b2" opacity="0.12" stroke="#0891b2" stroke-width="1.2"/>
<path d="M 313.0 274.0 L 325.0 286.0 L 313.0 298.0 L 301.0 286.0 Z" fill="none" stroke="#0891b2" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 307.0 286.0 H 319.0 M 316.0 283.0 L 319.0 286.0 L 316.0 289.0" fill="none" stroke="#0891b2" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<text data-text-role="title" data-full-text="AddPeriod" x="346.0" y="281.0" text-anchor="start" class="node-title" font-size="13.5">AddPeriod</text>
<text data-text-role="subtitle" data-full-text="注册完成 · 5天" x="346.0" y="300.0" text-anchor="start" class="node-sub" font-size="11.5">注册完成 · 5天</text>
<text x="470.0" y="264.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">REGISTRAR</text>
</g>
<g id="node-active" data-graph-role="node" data-node-id="active" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="lifecycle" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="280,350,480,430">
<rect x="280.0" y="350.0" width="200.0" height="80.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
<rect x="292.0" y="369.0" width="42.0" height="42.0" rx="11" fill="#059669" opacity="0.12" stroke="#059669" stroke-width="1.2"/>
<rect x="302.0" y="381.0" width="22" height="18" rx="4" fill="none" stroke="#059669" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 307.0 387.0 H 319.0 M 307.0 393.0 H 316.0" fill="none" stroke="#059669" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<text data-text-role="title" data-full-text="Active (ok)" x="346.0" y="381.0" text-anchor="start" class="node-title" font-size="13.5">Active (ok)</text>
<text data-text-role="subtitle" data-full-text="正常解析 · 配置NS" x="346.0" y="400.0" text-anchor="start" class="node-sub" font-size="11.5">正常解析 · 配置NS</text>
<text x="470.0" y="364.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">REGISTRY</text>
</g>
<g id="node-renew-period" data-graph-role="node" data-node-id="renew-period" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="lifecycle" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="280,470,480,542">
<rect x="280.0" y="470.0" width="200.0" height="72.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
<rect x="292.0" y="485.0" width="42.0" height="42.0" rx="11" fill="#7c3aed" opacity="0.12" stroke="#7c3aed" stroke-width="1.2"/>
<path d="M 301.0 499.0 H 318.0 M 308.0 506.0 H 325.0 M 301.0 513.0 H 318.0" fill="none" stroke="#7c3aed" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="321.0" cy="499.0" r="2.4" fill="#7c3aed"/><circle cx="305.0" cy="506.0" r="2.4" fill="#7c3aed"/><circle cx="321.0" cy="513.0" r="2.4" fill="#7c3aed"/>
<text data-text-role="title" data-full-text="RenewPeriod" x="346.0" y="501.0" text-anchor="start" class="node-title" font-size="13.5">RenewPeriod</text>
<text data-text-role="subtitle" data-full-text="手动续费 · 5天" x="346.0" y="520.0" text-anchor="start" class="node-sub" font-size="11.5">手动续费 · 5天</text>
<text x="470.0" y="484.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">REGISTRAR</text>
</g>
<g id="node-auto-renew-period" data-graph-role="node" data-node-id="auto-renew-period" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="lifecycle" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="280,570,480,642">
<rect x="280.0" y="570.0" width="200.0" height="72.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
<rect x="292.0" y="585.0" width="42.0" height="42.0" rx="11" fill="#ea580c" opacity="0.12" stroke="#ea580c" stroke-width="1.2"/>
<path d="M 301.0 599.0 H 318.0 M 308.0 606.0 H 325.0 M 301.0 613.0 H 318.0" fill="none" stroke="#ea580c" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="321.0" cy="599.0" r="2.4" fill="#ea580c"/><circle cx="305.0" cy="606.0" r="2.4" fill="#ea580c"/><circle cx="321.0" cy="613.0" r="2.4" fill="#ea580c"/>
<text data-text-role="title" data-full-text="AutoRenewPeriod" x="346.0" y="601.0" text-anchor="start" class="node-title" font-size="13.38">AutoRenewPeriod</text>
<text data-text-role="subtitle" data-full-text="自动续费 · 等待扣费" x="346.0" y="620.0" text-anchor="start" class="node-sub" font-size="11.5">自动续费 · 等待扣费</text>
<text x="470.0" y="584.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">REGISTRY</text>
</g>
<g id="node-redemption-period" data-graph-role="node" data-node-id="redemption-period" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="lifecycle" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="280,670,480,742">
<rect x="280.0" y="670.0" width="200.0" height="72.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
<rect x="292.0" y="685.0" width="42.0" height="42.0" rx="11" fill="#dc2626" opacity="0.12" stroke="#dc2626" stroke-width="1.2"/>
<ellipse cx="313.0" cy="698.0" rx="11" ry="4" fill="none" stroke="#dc2626" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 302.0 698.0 V 714.0 C 302.0 719.0 324.0 719.0 324.0 714.0 V 698.0" fill="none" stroke="#dc2626" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 302.0 706.0 C 302.0 711.0 324.0 711.0 324.0 706.0" fill="none" stroke="#dc2626" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<text data-text-role="title" data-full-text="RedemptionPeriod" x="346.0" y="701.0" text-anchor="start" class="node-title" font-size="12.89">RedemptionPeriod</text>
<text data-text-role="subtitle" data-full-text="赎回期 · 30天" x="346.0" y="720.0" text-anchor="start" class="node-sub" font-size="11.5">赎回期 · 30天</text>
<text x="470.0" y="684.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">REGISTRY</text>
</g>
<g id="node-pending-delete" data-graph-role="node" data-node-id="pending-delete" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="lifecycle" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="280,770,480,842">
<rect x="280.0" y="770.0" width="200.0" height="72.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
<rect x="292.0" y="785.0" width="42.0" height="42.0" rx="11" fill="#64748b" opacity="0.12" stroke="#64748b" stroke-width="1.2"/>
<path d="M 300.0 806.0 Q 313.0 794.0 326.0 806.0 Q 313.0 818.0 300.0 806.0 Z" fill="none" stroke="#64748b" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 307.0 806.0 H 311.0 L 314.0 801.0 L 317.0 811.0 L 320.0 806.0" fill="none" stroke="#64748b" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<text data-text-role="title" data-full-text="PendingDelete" x="346.0" y="801.0" text-anchor="start" class="node-title" font-size="13.5">PendingDelete</text>
<text data-text-role="subtitle" data-full-text="删除倒计时 · 5天" x="346.0" y="820.0" text-anchor="start" class="node-sub" font-size="11.5">删除倒计时 · 5天</text>
<text x="470.0" y="784.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">REGISTRY</text>
</g>
<g id="node-transfer" data-graph-role="node" data-node-id="transfer" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="lifecycle" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="680,290,880,362">
<rect x="680.0" y="290.0" width="200.0" height="72.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
<rect x="692.0" y="305.0" width="42.0" height="42.0" rx="11" fill="#2563eb" opacity="0.12" stroke="#2563eb" stroke-width="1.2"/>
<path d="M 713.0 314.0 L 725.0 326.0 L 713.0 338.0 L 701.0 326.0 Z" fill="none" stroke="#2563eb" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 707.0 326.0 H 719.0 M 716.0 323.0 L 719.0 326.0 L 716.0 329.0" fill="none" stroke="#2563eb" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<text data-text-role="title" data-full-text="PendingTransfer" x="746.0" y="321.0" text-anchor="start" class="node-title" font-size="13.38">PendingTransfer</text>
<text data-text-role="subtitle" data-full-text="转移中 · 注册商切换" x="746.0" y="340.0" text-anchor="start" class="node-sub" font-size="11.5">转移中 · 注册商切换</text>
<text x="870.0" y="304.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">REGISTRAR</text>
</g>
<g id="node-transfer-period" data-graph-role="node" data-node-id="transfer-period" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="lifecycle" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="680,390,880,462">
<rect x="680.0" y="390.0" width="200.0" height="72.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
<rect x="692.0" y="405.0" width="42.0" height="42.0" rx="11" fill="#0891b2" opacity="0.12" stroke="#0891b2" stroke-width="1.2"/>
<path d="M 701.0 419.0 H 718.0 M 708.0 426.0 H 725.0 M 701.0 433.0 H 718.0" fill="none" stroke="#0891b2" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="721.0" cy="419.0" r="2.4" fill="#0891b2"/><circle cx="705.0" cy="426.0" r="2.4" fill="#0891b2"/><circle cx="721.0" cy="433.0" r="2.4" fill="#0891b2"/>
<text data-text-role="title" data-full-text="TransferPeriod" x="746.0" y="421.0" text-anchor="start" class="node-title" font-size="13.5">TransferPeriod</text>
<text data-text-role="subtitle" data-full-text="转移完成 · 5天" x="746.0" y="440.0" text-anchor="start" class="node-sub" font-size="11.5">转移完成 · 5天</text>
<text x="870.0" y="404.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">REGISTRAR</text>
</g>
<g id="node-client-hold" data-graph-role="node" data-node-id="client-hold" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="lifecycle" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="680,490,880,562">
<rect x="680.0" y="490.0" width="200.0" height="72.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
<rect x="692.0" y="505.0" width="42.0" height="42.0" rx="11" fill="#f59e0b" opacity="0.12" stroke="#f59e0b" stroke-width="1.2"/>
<path d="M 700.0 526.0 Q 713.0 514.0 726.0 526.0 Q 713.0 538.0 700.0 526.0 Z" fill="none" stroke="#f59e0b" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 707.0 526.0 H 711.0 L 714.0 521.0 L 717.0 531.0 L 720.0 526.0" fill="none" stroke="#f59e0b" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<text data-text-role="title" data-full-text="ClientHold" x="746.0" y="521.0" text-anchor="start" class="node-title" font-size="13.5">ClientHold</text>
<text data-text-role="subtitle" data-full-text="客户暂停 · DNS停止" x="746.0" y="540.0" text-anchor="start" class="node-sub" font-size="11.5">客户暂停 · DNS停止</text>
<text x="870.0" y="504.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">REGISTRAR</text>
</g>
<g id="node-server-hold" data-graph-role="node" data-node-id="server-hold" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="lifecycle" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="680,590,880,662">
<rect x="680.0" y="590.0" width="200.0" height="72.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
<rect x="692.0" y="605.0" width="42.0" height="42.0" rx="11" fill="#ef4444" opacity="0.12" stroke="#ef4444" stroke-width="1.2"/>
<path d="M 700.0 626.0 Q 713.0 614.0 726.0 626.0 Q 713.0 638.0 700.0 626.0 Z" fill="none" stroke="#ef4444" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 707.0 626.0 H 711.0 L 714.0 621.0 L 717.0 631.0 L 720.0 626.0" fill="none" stroke="#ef4444" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<text data-text-role="title" data-full-text="ServerHold" x="746.0" y="621.0" text-anchor="start" class="node-title" font-size="13.5">ServerHold</text>
<text data-text-role="subtitle" data-full-text="注册局暂停 · 违规" x="746.0" y="640.0" text-anchor="start" class="node-sub" font-size="11.5">注册局暂停 · 违规</text>
<text x="870.0" y="604.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">REGISTRY</text>
</g>
<g id="register-label" data-graph-role="label" data-owner="register" data-graph-bounds="455.3,215,493.3,235">
<rect x="455.3" y="215.0" width="38.0" height="20" rx="6" fill="#f7fbfe" opacity="0.96"/>
<text x="474.3" y="229.0" text-anchor="middle" class="arrow-label">注册</text>
</g>
<g id="renew-label" data-graph-role="label" data-owner="renew" data-graph-bounds="348,436,386,456">
<rect x="348.0" y="436.0" width="38.0" height="20" rx="6" fill="#f7fbfe" opacity="0.96"/>
<text x="367.0" y="450.0" text-anchor="middle" class="arrow-label">续费</text>
</g>
<g id="auto-renew-label" data-graph-role="label" data-owner="auto-renew" data-graph-bounds="221,477.84,259,497.84">
<rect x="221.0" y="477.84" width="38.0" height="20" rx="6" fill="#f7fbfe" opacity="0.96"/>
<text x="240.0" y="491.84" text-anchor="middle" class="arrow-label">到期</text>
</g>
<g id="to-redemption-label" data-graph-role="label" data-owner="to-redemption" data-graph-bounds="355,646,405,666">
<rect x="355.0" y="646.0" width="50.0" height="20" rx="6" fill="#f7fbfe" opacity="0.96"/>
<text x="380.0" y="660.0" text-anchor="middle" class="arrow-label">未支付</text>
</g>
<g id="to-transfer-label" data-graph-role="label" data-owner="to-transfer" data-graph-bounds="573,295,611,315">
<rect x="573.0" y="295.0" width="38.0" height="20" rx="6" fill="#f7fbfe" opacity="0.96"/>
<text x="592.0" y="309.0" text-anchor="middle" class="arrow-label">转移</text>
</g>
<g id="transfer-complete-label" data-graph-role="label" data-owner="transfer-complete" data-graph-bounds="749,366,811,386">
<rect x="749.0" y="366.0" width="62.0" height="20" rx="6" fill="#f7fbfe" opacity="0.96"/>
<text x="780.0" y="380.0" text-anchor="middle" class="arrow-label">转移完成</text>
</g>
<g id="transfer-ok-label" data-graph-role="label" data-owner="transfer-ok" data-graph-bounds="537,395,623,415">
<rect x="537.0" y="395.0" width="86.0" height="20" rx="6" fill="#f7fbfe" opacity="0.96"/>
<text x="580.0" y="409.0" text-anchor="middle" class="arrow-label">期满回到正常</text>
</g>
<g id="pending-to-available-label" data-graph-role="label" data-owner="pending-to-available" data-graph-bounds="119,462,157,482">
<rect x="119.0" y="462.0" width="38.0" height="20" rx="6" fill="#f7fbfe" opacity="0.96"/>
<text x="138.0" y="476.0" text-anchor="middle" class="arrow-label">释放</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 31 KiB

@@ -0,0 +1,198 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1240 880" width="1240" height="880" data-generator="fireworks-tech-graph" data-schema-version="1" data-text-metrics="heuristic-v1" data-style-id="7" data-visual-theme="OpenAI" data-diagram-type="architecture" data-motion-scene="" data-semantic-profile="generic" data-semantic-valid="true" data-quality-profile="standard" data-max-bends-per-edge="4" data-max-total-bends="30" data-max-route-stretch="5.0" data-max-bridged-crossings="14" data-min-node-gap="0.0" data-min-container-gutter="0.0" data-min-label-clearance="2.0" data-min-segment-length="0.0">
<defs>
<marker id="arrowA" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#10a37f"/>
</marker>
<marker id="arrowB" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#0f766e"/>
</marker>
<marker id="arrowC" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#0891b2"/>
</marker>
<marker id="arrowE" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#f59e0b"/>
</marker>
<marker id="arrowF" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#64748b"/>
</marker>
<marker id="arrowG" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#475569"/>
</marker>
<marker id="arrowH" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#94a3b8"/>
</marker>
<style>
text { font-family: 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', 'Noto Sans CJK SC', 'Microsoft YaHei', 'Microsoft JhengHei', 'SimHei', sans-serif; }
.title { font-size: 24px; font-weight: 700; fill: #0f172a; }
.subtitle { font-size: 13px; font-weight: 500; fill: #64748b; }
.section { font-size: 13px; font-weight: 700; fill: #087f5b; letter-spacing: 1.4px; }
.section-sub { font-size: 12px; font-weight: 500; fill: #64748b; }
.node-title { font-weight: 700; fill: #0f172a; }
.node-sub { font-weight: 500; fill: #475569; }
.node-type { font-size: 11px; font-weight: 700; fill: #64748b; letter-spacing: 0.08em; }
.arrow-label { font-size: 12px; font-weight: 600; fill: #475569; }
.legend { font-size: 12px; font-weight: 500; fill: #475569; }
.footnote { font-size: 12px; font-weight: 500; fill: #64748b; }
.metric-label { font-size: 8.5px; font-weight: 700; fill: #64748b; text-transform: uppercase; }
.metric-value { font-size: 9.5px; font-weight: 700; fill: #0f172a; }
</style>
</defs>
<rect data-graph-role="background" width="1240.0" height="880.0" fill="#ffffff"/>
<text x="48.0" y="48" text-anchor="start" class="title">域名生命周期状态机</text>
<text x="48.0" y="72" text-anchor="start" class="subtitle">从可注册到释放的完整 EPP 状态流转(REGISTRY / REGISTRAR 管理方标注)</text>
<line x1="48" y1="100" x2="1192.0" y2="100" stroke="#e2e8f0" stroke-width="1"/>
<g id="reg" data-graph-role="container" data-container-id="reg" data-semantic-role="boundary" data-graph-bounds="40,70,1200,290">
<rect data-graph-role="container" x="40.0" y="70.0" width="1160.0" height="220.0" rx="16.0" fill="none" stroke="#e2e8f0" stroke-width="1.4" stroke-dasharray="5 4"/>
<text x="58.0" y="94.0" class="section">注册与活跃</text>
<rect id="reg-header" data-graph-role="reserved" data-reserved-kind="container-header" x="48" y="76" width="95" height="30" fill="none" stroke="none"/>
</g>
<g id="grace" data-graph-role="container" data-container-id="grace" data-semantic-role="boundary" data-graph-bounds="40,330,1200,470">
<rect data-graph-role="container" x="40.0" y="330.0" width="1160.0" height="140.0" rx="16.0" fill="none" stroke="#e2e8f0" stroke-width="1.4" stroke-dasharray="5 4"/>
<text x="58.0" y="354.0" class="section">续费 · 到期 · 转移中</text>
<rect id="grace-header" data-graph-role="reserved" data-reserved-kind="container-header" x="48" y="336" width="154.8" height="30" fill="none" stroke="none"/>
</g>
<g id="end" data-graph-role="container" data-container-id="end" data-semantic-role="boundary" data-graph-bounds="40,510,1200,810">
<rect data-graph-role="container" x="40.0" y="510.0" width="1160.0" height="300.0" rx="16.0" fill="none" stroke="#e2e8f0" stroke-width="1.4" stroke-dasharray="5 4"/>
<text x="58.0" y="534.0" class="section">赎回 · 释放 · 转移完成</text>
<rect id="end-header" data-graph-role="reserved" data-reserved-kind="container-header" x="48" y="516" width="167.8" height="30" fill="none" stroke="none"/>
</g>
<path id="a-reg" data-graph-role="edge" data-edge-id="a-reg" data-source="available" data-target="addperiod" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 270,170 L 380,170" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
<path id="a-ok" data-graph-role="edge" data-edge-id="a-ok" data-source="addperiod" data-target="active" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="" d="M 600,170 L 620.4,170 L 620.4,179 L 700,179" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
<path id="a-hold-c" data-graph-role="edge" data-edge-id="a-hold-c" data-source="active" data-target="clienthold" data-edge-kind="flow" data-topic-id="" data-flow="feedback" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="" d="M 910,152 L 930.4,152 L 930.4,137 L 990,137" fill="none" stroke="#475569" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowG)"/>
<path id="a-hold-s" data-graph-role="edge" data-edge-id="a-hold-s" data-source="active" data-target="serverhold" data-edge-kind="flow" data-topic-id="" data-flow="feedback" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="" d="M 910,170 L 930.4,170 L 930.4,232 L 990,232" fill="none" stroke="#475569" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowG)"/>
<path id="a-renew" data-graph-role="edge" data-edge-id="a-renew" data-source="active" data-target="renewperiod" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="" d="M 787,200 L 787,220.4 L 796,220.4 L 796,380" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
<path id="a-renew-back" data-graph-role="edge" data-edge-id="a-renew-back" data-source="renewperiod" data-target="active" data-edge-kind="flow" data-topic-id="" data-flow="feedback" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="1.032" data-bridges="" d="M 814,380 L 814,359.6 L 817,359.6 L 817,220.4 L 805,220.4 L 805,200" fill="none" stroke="#475569" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowG)"/>
<path id="a-expire-bridge-mask" data-graph-role="bridge-mask" data-owner="a-expire" d="M 700,161 L 692.86,161 L 692.86,174 A 5 5 0 0 1 692.86,184 L 692.86,224 L 510,224 L 510,380" fill="none" stroke="#ffffff" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="a-expire" data-graph-role="edge" data-edge-id="a-expire" data-source="active" data-target="autorenew" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="3" data-route-stretch="1.0" data-bridges="692.86,179" d="M 700,161 L 692.86,161 L 692.86,174 A 5 5 0 0 1 692.86,184 L 692.86,224 L 510,224 L 510,380" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
<path id="a-no-pay" data-graph-role="edge" data-edge-id="a-no-pay" data-source="autorenew" data-target="redemption" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 510,440 L 510,560" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
<path id="a-redemption-end" data-graph-role="edge" data-edge-id="a-redemption-end" data-source="redemption" data-target="pendingdelete" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 380,590 L 310,590" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
<path id="a-release" data-graph-role="edge" data-edge-id="a-release" data-source="pendingdelete" data-target="released" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="2.625" data-bridges="" d="M 190,620 L 190,640.4 L 305.6,640.4 L 305.6,732.86 L 200,732.86 L 200,740" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
<path id="a-transfer" data-graph-role="edge" data-edge-id="a-transfer" data-source="active" data-target="pendingtransfer" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="1.0" data-bridges="" d="M 910,188 L 930,188 L 930,356 L 959.6,356 L 959.6,410 L 980,410" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
<path id="a-transfer-done" data-graph-role="edge" data-edge-id="a-transfer-done" data-source="pendingtransfer" data-target="transferperiod" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="3" data-route-stretch="1.078" data-bridges="" d="M 1080,440 L 1080,464 L 679.6,464 L 679.6,581 L 700,581" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
<path id="a-transfer-ok-bridge-mask" data-graph-role="bridge-mask" data-owner="a-transfer-ok" d="M 700,599 L 676,599 L 676,230.4 L 700,230.4 L 700,200 L 782,200 A 5 5 0 0 0 792,200 L 800,200 A 5 5 0 0 0 810,200 L 823,200 L 823,200" fill="none" stroke="#ffffff" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="a-transfer-ok" data-graph-role="edge" data-edge-id="a-transfer-ok" data-source="transferperiod" data-target="active" data-edge-kind="flow" data-topic-id="" data-flow="feedback" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="1.092" data-bridges="787,200;805,200" d="M 700,599 L 676,599 L 676,230.4 L 700,230.4 L 700,200 L 782,200 A 5 5 0 0 0 792,200 L 800,200 A 5 5 0 0 0 810,200 L 823,200 L 823,200" fill="none" stroke="#475569" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowG)"/>
<g id="node-available" data-graph-role="node" data-node-id="available" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="70,140,270,200">
<rect x="70.0" y="140.0" width="200.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
<text x="170.0" y="158.0" text-anchor="middle" class="node-type">REGISTRY</text>
<text data-text-role="title" data-full-text="Available · 域名可注册" data-text-max-width="176.0" x="170.0" y="176.0" text-anchor="middle" class="node-title" font-size="15.17">Available · 域名可注册</text>
</g>
<g id="node-addperiod" data-graph-role="node" data-node-id="addperiod" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="380,140,600,200">
<rect x="380.0" y="140.0" width="220.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
<text x="490.0" y="158.0" text-anchor="middle" class="node-type">REGISTRY</text>
<text data-text-role="title" data-full-text="AddPeriod · 注册完成 5天" data-text-max-width="196.0" x="490.0" y="176.0" text-anchor="middle" class="node-title" font-size="14.87">AddPeriod · 注册完成 5天</text>
</g>
<g id="node-active" data-graph-role="node" data-node-id="active" data-semantic-role="double_rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="700,140,910,200">
<rect x="700.0" y="140.0" width="210.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#10a37f" stroke-width="2.0"/>
<rect x="706.0" y="146.0" width="198.0" height="48.0" rx="11.0" fill="none" stroke="#10a37f" stroke-width="1.2" opacity="0.65"/>
<text x="805.0" y="158.0" text-anchor="middle" class="node-type">REGISTRAR · Active</text>
<text data-text-role="title" data-full-text="Active (ok) · 正常解析" data-text-max-width="178.0" x="805.0" y="176.0" text-anchor="middle" class="node-title" font-size="14.71">Active (ok) · 正常解析</text>
</g>
<g id="node-clienthold" data-graph-role="node" data-node-id="clienthold" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="990,110,1190,164">
<rect x="990.0" y="110.0" width="200.0" height="54.0" rx="14.0" fill="#ffffff" stroke="#f97316" stroke-width="1.8"/>
<text x="1090.0" y="128.0" text-anchor="middle" class="node-type">REGISTRAR</text>
<text data-text-role="title" data-full-text="ClientHold · 客户暂停" data-text-max-width="176.0" x="1090.0" y="143.0" text-anchor="middle" class="node-title" font-size="15.79">ClientHold · 客户暂停</text>
</g>
<g id="node-serverhold" data-graph-role="node" data-node-id="serverhold" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="990,205,1190,259">
<rect x="990.0" y="205.0" width="200.0" height="54.0" rx="14.0" fill="#ffffff" stroke="#f97316" stroke-width="1.8"/>
<text x="1090.0" y="223.0" text-anchor="middle" class="node-type">REGISTRY</text>
<text data-text-role="title" data-full-text="ServerHold · 注册局暂停" data-text-max-width="176.0" x="1090.0" y="238.0" text-anchor="middle" class="node-title" font-size="13.76">ServerHold · 注册局暂停</text>
</g>
<g id="node-renewperiod" data-graph-role="node" data-node-id="renewperiod" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="700,380,910,440">
<rect x="700.0" y="380.0" width="210.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
<text x="805.0" y="398.0" text-anchor="middle" class="node-type">REGISTRY</text>
<text data-text-role="title" data-full-text="RenewPeriod · 手动续费 5天" data-text-max-width="186.0" x="805.0" y="416.0" text-anchor="middle" class="node-title" font-size="12.89">RenewPeriod · 手动续费 5天</text>
</g>
<g id="node-autorenew" data-graph-role="node" data-node-id="autorenew" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="380,380,640,440">
<rect x="380.0" y="380.0" width="260.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
<text x="510.0" y="398.0" text-anchor="middle" class="node-type">REGISTRY</text>
<text data-text-role="title" data-full-text="AutoRenewPeriod · 等待扣费" data-text-max-width="236.0" x="510.0" y="416.0" text-anchor="middle" class="node-title" font-size="15.9">AutoRenewPeriod · 等待扣费</text>
</g>
<g id="node-pendingtransfer" data-graph-role="node" data-node-id="pendingtransfer" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="980,380,1180,440">
<rect x="980.0" y="380.0" width="200.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
<text x="1080.0" y="398.0" text-anchor="middle" class="node-type">REGISTRAR</text>
<text data-text-role="title" data-full-text="PendingTransfer · 转移中" data-text-max-width="176.0" x="1080.0" y="416.0" text-anchor="middle" class="node-title" font-size="12.79">PendingTransfer · 转移中</text>
</g>
<g id="node-transferperiod" data-graph-role="node" data-node-id="transferperiod" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="700,560,910,620">
<rect x="700.0" y="560.0" width="210.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
<text x="805.0" y="578.0" text-anchor="middle" class="node-type">REGISTRAR</text>
<text data-text-role="title" data-full-text="TransferPeriod · 5天" data-text-max-width="186.0" x="805.0" y="596.0" text-anchor="middle" class="node-title" font-size="16.03">TransferPeriod · 5天</text>
</g>
<g id="node-redemption" data-graph-role="node" data-node-id="redemption" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="380,560,640,620">
<rect x="380.0" y="560.0" width="260.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
<text x="510.0" y="578.0" text-anchor="middle" class="node-type">REGISTRY</text>
<text data-text-role="title" data-full-text="RedemptionPeriod · 赎回 30天" data-text-max-width="236.0" x="510.0" y="596.0" text-anchor="middle" class="node-title" font-size="14.98">RedemptionPeriod · 赎回 30天</text>
</g>
<g id="node-pendingdelete" data-graph-role="node" data-node-id="pendingdelete" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="70,560,310,620">
<rect x="70.0" y="560.0" width="240.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
<text x="190.0" y="578.0" text-anchor="middle" class="node-type">REGISTRY</text>
<text data-text-role="title" data-full-text="PendingDelete · 删除 5天" data-text-max-width="216.0" x="190.0" y="596.0" text-anchor="middle" class="node-title" font-size="16.31">PendingDelete · 删除 5天</text>
</g>
<g id="node-released" data-graph-role="node" data-node-id="released" data-semantic-role="double_rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="70,740,330,800">
<rect x="70.0" y="740.0" width="260.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#1d4ed8" stroke-width="2.0"/>
<rect x="76.0" y="746.0" width="248.0" height="48.0" rx="11.0" fill="none" stroke="#1d4ed8" stroke-width="1.2" opacity="0.65"/>
<text x="200.0" y="758.0" text-anchor="middle" class="node-type">END</text>
<text data-text-role="title" data-full-text="已释放 · 重新开放注册" data-text-max-width="228.0" x="200.0" y="776.0" text-anchor="middle" class="node-title" font-size="18">已释放 · 重新开放注册</text>
</g>
<g id="a-reg-label" data-graph-role="label" data-owner="a-reg" data-graph-bounds="306,139,344,159">
<rect x="306.0" y="139.0" width="38.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="325.0" y="153.0" text-anchor="middle" class="arrow-label">注册</text>
</g>
<g id="a-ok-label" data-graph-role="label" data-owner="a-ok" data-graph-bounds="629.2,148,691.2,168">
<rect x="629.2" y="148.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="660.2" y="162.0" text-anchor="middle" class="arrow-label">注册完成</text>
</g>
<g id="a-hold-c-label" data-graph-role="label" data-owner="a-hold-c" data-graph-bounds="911.2,106,973.2,126">
<rect x="911.2" y="106.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="942.2" y="120.0" text-anchor="middle" class="arrow-label">暂停解析</text>
</g>
<g id="a-hold-s-label" data-graph-role="label" data-owner="a-hold-s" data-graph-bounds="865.4,205,927.4,225">
<rect x="865.4" y="205.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="896.4" y="219.0" text-anchor="middle" class="arrow-label">违规暂停</text>
</g>
<g id="a-renew-label" data-graph-role="label" data-owner="a-renew" data-graph-bounds="755,286.2,793,306.2">
<rect x="755.0" y="286.2" width="38.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="774.0" y="300.2" text-anchor="middle" class="arrow-label">续费</text>
</g>
<g id="a-renew-back-label" data-graph-role="label" data-owner="a-renew-back" data-graph-bounds="820,276,906,296">
<rect x="820.0" y="276.0" width="86.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="863.0" y="290.0" text-anchor="middle" class="arrow-label">期满回到正常</text>
</g>
<g id="a-expire-label" data-graph-role="label" data-owner="a-expire" data-graph-bounds="582.43,211,620.43,231">
<rect x="582.43" y="211.0" width="38.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="601.43" y="225.0" text-anchor="middle" class="arrow-label">到期</text>
</g>
<g id="a-no-pay-label" data-graph-role="label" data-owner="a-no-pay" data-graph-bounds="457,486,507,506">
<rect x="457.0" y="486.0" width="50.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="482.0" y="500.0" text-anchor="middle" class="arrow-label">未支付</text>
</g>
<g id="a-redemption-end-label" data-graph-role="label" data-owner="a-redemption-end" data-graph-bounds="314,559,376,579">
<rect x="314.0" y="559.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="345.0" y="573.0" text-anchor="middle" class="arrow-label">赎回期满</text>
</g>
<g id="a-release-label" data-graph-role="label" data-owner="a-release" data-graph-bounds="228.8,627.4,266.8,647.4">
<rect x="228.8" y="627.4" width="38.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="247.8" y="641.4" text-anchor="middle" class="arrow-label">释放</text>
</g>
<g id="a-transfer-label" data-graph-role="label" data-owner="a-transfer" data-graph-bounds="889,248,927,268">
<rect x="889.0" y="248.0" width="38.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="908.0" y="262.0" text-anchor="middle" class="arrow-label">转移</text>
</g>
<g id="a-transfer-done-label" data-graph-role="label" data-owner="a-transfer-done" data-graph-bounds="848.8,451,910.8,471">
<rect x="848.8" y="451.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="879.8" y="465.0" text-anchor="middle" class="arrow-label">转移完成</text>
</g>
<g id="a-transfer-ok-label" data-graph-role="label" data-owner="a-transfer-ok" data-graph-bounds="611,474.42,673,494.42">
<rect x="611.0" y="474.42" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
<text x="642.0" y="488.42" text-anchor="middle" class="arrow-label">转移生效</text>
</g>
<g id="legend" data-graph-role="legend">
<rect id="legend-zone" data-graph-role="reserved" data-reserved-kind="legend" x="32" y="684" width="273.6" height="46" rx="10" fill="none" stroke="none"/>
<line data-graph-role="decoration" x1="42.0" y1="698.0" x2="72.0" y2="698.0" stroke="#10a37f" stroke-width="2.0" marker-end="url(#arrowA)"/>
<text data-graph-role="decoration" x="82.0" y="702.0" class="legend">状态流转</text>
<line data-graph-role="decoration" x1="158.0" y1="698.0" x2="188.0" y2="698.0" stroke="#475569" stroke-width="2.0" marker-end="url(#arrowG)"/>
<text data-graph-role="decoration" x="198.0" y="702.0" class="legend">回到活跃 / 暂停</text>
</g>
<g id="footer" data-graph-role="reserved" data-graph-bounds="48,833,433,849">
<text x="48.0" y="845.0" class="footnote">Style 7 · OpenAI Official · EPP domain lifecycle states</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 27 KiB

+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env node
// CI 部署完成后一次性提交 sitemap 全部 URL 到 IndexNow(Bing / Yandex / Seznam / Yep 共用端点)。
// 用法:node scripts/indexnow-submit.mjs --site https://nipw.cn --key <key>
//
// key 必须与 frontend-ssr/public/<key>.txt 完全一致 —— IndexNow 的所有权校验就是抓取
// 这个文件比对,所以 key 本身是公开凭证、写在 CI 里并不泄密。
// 逻辑与 server/utils/indexnow.ts 保持一致(那边是运行时端点,这边是构建机直跑)。
const args = process.argv.slice(2)
function argOf(name) {
const i = args.indexOf(name)
return i >= 0 ? args[i + 1] : undefined
}
const site = (argOf('--site') || 'https://nipw.cn').replace(/\/$/, '')
const key = argOf('--key')
if (!key) {
console.error('usage: node scripts/indexnow-submit.mjs --site <url> --key <key>')
process.exit(1)
}
const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/IndexNow'
const sitemapUrl = `${site}/sitemap.xml`
const res = await fetch(sitemapUrl)
if (!res.ok) {
console.error(`sitemap fetch failed: ${res.status} ${sitemapUrl}`)
process.exit(1)
}
const xml = await res.text()
const urls = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map(m => m[1].trim())
if (!urls.length) {
console.error('sitemap has no <loc> entries, nothing to submit')
process.exit(1)
}
// IndexNow 单请求最多 1 万条,分片兜底
const CHUNK = 10_000
let ok = 0
for (let i = 0; i < urls.length; i += CHUNK) {
const urlList = urls.slice(i, i + CHUNK)
const r = await fetch(INDEXNOW_ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json; charset=utf-8' },
body: JSON.stringify({
host: new URL(site).host,
key,
keyLocation: `${site}/${key}.txt`,
urlList,
}),
})
const body = await r.text().catch(() => '')
console.log(`IndexNow chunk ${i / CHUNK + 1}: HTTP ${r.status} (${urlList.length} urls) ${body.slice(0, 120)}`)
if (r.ok || r.status === 202) ok += urlList.length
}
console.log(`submitted ${ok}/${urls.length} urls from ${sitemapUrl}`)
// IndexNow 202 = 已受理;4xx 校验失败会让 CI 亮红,属预期失败信号
if (!ok) process.exit(1)
+30 -8
View File
@@ -1,14 +1,36 @@
import { defineSitemapEventHandler } from '#imports'
// 直接 import 构建期常量,而不是走 runtimeConfig.public.docConfig:
// 后者会被 Nuxt 序列化进每个页面的 HTML(4571 字节 / 页),而本路由是全站唯一消费方。
// 相对路径与 server/routes/middleware/[...slug].get.ts 保持一致(`~` 在 Nuxt 4 里指向 app/,指不到根目录的 config/)。
import { docConfig } from '../../../config/doc'
// 显式枚举全部工具页并标注权重:与 nuxt:pages 自动扫描并存(模块会按 loc 去重合并),
// 好处是收录不依赖扫描行为、且带 priority/changefreq。
// 注意 /ipv6 不在列表里:它已改为 server/routes/ipv6.get.ts 的服务端 301,不再是页面。
const TOOL_PAGES: Array<{ loc: string; priority: number; changefreq: string }> = [
{ loc: '/', priority: 1.0, changefreq: 'daily' },
{ loc: '/location', priority: 0.9, changefreq: 'weekly' },
{ loc: '/asn', priority: 0.9, changefreq: 'weekly' },
{ loc: '/dns', priority: 0.9, changefreq: 'weekly' },
{ loc: '/dnssec', priority: 0.9, changefreq: 'weekly' },
{ loc: '/whois', priority: 0.9, changefreq: 'weekly' },
{ loc: '/ssl', priority: 0.9, changefreq: 'weekly' },
{ loc: '/tcping', priority: 0.9, changefreq: 'weekly' },
{ loc: '/speedtest', priority: 0.9, changefreq: 'weekly' },
{ loc: '/ipv6speedtest', priority: 0.9, changefreq: 'weekly' },
{ loc: '/ipv6tcping', priority: 0.9, changefreq: 'weekly' },
{ loc: '/ipv6webcheck', priority: 0.9, changefreq: 'weekly' },
{ loc: '/screenshot', priority: 0.8, changefreq: 'weekly' },
]
export default defineSitemapEventHandler(async () => {
const config = useRuntimeConfig();
console.log('Generating sitemap URLs from docConfig:', config.public.docConfig);
const urls = config.public.docConfig ? Object.keys(config.public.docConfig).map(path => ({ path })) : [];
return urls.map(url => ({
loc: url.path,
_encoded: true,
const toolPages = TOOL_PAGES.map(({ loc, priority, changefreq }) => ({ loc, priority, changefreq }))
// 文档页权重:索引页 /doc 高一档,正文页低一档
const docPages = Object.keys(docConfig).map(path => ({
loc: path,
priority: path === '/doc' ? 0.8 : 0.7,
changefreq: 'monthly',
}))
return [...toolPages, ...docPages]
})
+15
View File
@@ -0,0 +1,15 @@
// GET /api/indexnow?key=<key> —— 把 sitemap 里的全部 URL 提交到 IndexNow。
// 给运维手动触发(curl)或外部监控调用;key 校验失败一律 403。
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event)
const key = getQuery(event).key
if (!config.indexnowKey || key !== config.indexnowKey) {
throw createError({ statusCode: 403, statusMessage: 'invalid key' })
}
const siteUrl = config.public.siteUrl
const urls = await fetchSitemapUrls(siteUrl)
if (!urls.length) {
return { submitted: 0, status: 0, body: 'sitemap is empty' }
}
return await submitIndexNow(urls, siteUrl)
})
+16
View File
@@ -0,0 +1,16 @@
// POST /api/indexnow?key=<key>(或请求头 x-indexnow-key),body: { urls: string[] }
// —— 精确提交新增/变更的 URL(发布新文档、工具页上线等场景)。
// URL 限本站域名(相对路径按 siteUrl 补全),跨域 URL 直接过滤。
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event)
const key = getQuery(event).key || getHeader(event, 'x-indexnow-key')
if (!config.indexnowKey || key !== config.indexnowKey) {
throw createError({ statusCode: 403, statusMessage: 'invalid key' })
}
const body = await readBody<{ urls?: unknown[] }>(event).catch(() => null)
const urls = normalizeIndexNowUrls(body?.urls ?? [], config.public.siteUrl)
if (!urls.length) {
throw createError({ statusCode: 400, statusMessage: 'no valid urls (same host only)' })
}
return await submitIndexNow(urls, config.public.siteUrl)
})
@@ -0,0 +1,501 @@
{
"canvas": {
"height": 640.0,
"width": 1240.0
},
"composition": {
"limits": {
"max_bends_per_edge": 12,
"max_bridged_crossings": 8,
"max_route_stretch": 5.0,
"max_total_bends": 100,
"min_container_gutter": 0.0,
"min_label_clearance": 2.0,
"min_node_gap": 0.0,
"min_segment_length": 0.0,
"profile": "standard"
},
"metrics": {
"bridged_crossings": 5,
"max_route_stretch": 1.872,
"minimum_container_gutter": 19.0,
"minimum_node_gap": 55.0,
"shortest_segment": 3.28,
"total_bends": 19
},
"ok": true,
"profile": "standard",
"score": 38,
"violations": []
},
"edges": [
{
"bends": 0,
"bridges": [],
"crossings": [],
"id": "ds-tld",
"length": 165.0,
"route": [
[
330.0,
195.0
],
[
495.0,
195.0
]
],
"route_stretch": 1.0,
"source": "root",
"source_port": [
330.0,
195.0
],
"target": "tld",
"target_port": [
495.0,
195.0
],
"waypoints": []
},
{
"bends": 0,
"bridges": [],
"crossings": [],
"id": "ds-auth",
"length": 160.0,
"route": [
[
745.0,
195.0
],
[
905.0,
195.0
]
],
"route_stretch": 1.0,
"source": "tld",
"source_port": [
745.0,
195.0
],
"target": "auth",
"target_port": [
905.0,
195.0
],
"waypoints": []
},
{
"bends": 2,
"bridges": [],
"crossings": [],
"id": "pub-key",
"length": 963.5,
"route": [
[
1023.5,
225.0
],
[
1023.5,
346.0
],
[
205.0,
346.0
],
[
205.0,
370.0
]
],
"route_stretch": 1.0,
"source": "auth",
"source_port": [
1023.5,
225.0
],
"target": "dnskey",
"target_port": [
205.0,
370.0
],
"waypoints": []
},
{
"bends": 6,
"bridges": [],
"crossings": [],
"id": "sign",
"length": 1060.3,
"route": [
[
1041.5,
225.0
],
[
1041.5,
245.4
],
[
1184.0,
245.4
],
[
1184.0,
454.0
],
[
745.0,
454.0
],
[
745.0,
349.6
],
[
620.0,
349.6
],
[
620.0,
370.0
]
],
"route_stretch": 1.872,
"source": "auth",
"source_port": [
1041.5,
225.0
],
"target": "rrset",
"target_port": [
620.0,
370.0
],
"waypoints": []
},
{
"bends": 4,
"bridges": [
[
884.6,
454.0
]
],
"crossings": [
[
884.6,
454.0
]
],
"id": "fetch-key",
"length": 706.0,
"route": [
[
330.0,
400.0
],
[
350.4,
400.0
],
[
350.4,
461.0
],
[
884.6,
461.0
],
[
884.6,
391.0
],
[
905.0,
391.0
]
],
"route_stretch": 1.209,
"source": "dnskey",
"source_port": [
330.0,
400.0
],
"target": "resolver",
"target_port": [
905.0,
391.0
],
"waypoints": []
},
{
"bends": 2,
"bridges": [
[
745.0,
400.0
],
[
884.6,
409.0
]
],
"crossings": [
[
745.0,
400.0
],
[
884.6,
409.0
]
],
"id": "fetch-rr",
"length": 169.0,
"route": [
[
745.0,
400.0
],
[
748.28,
400.0
],
[
748.28,
409.0
],
[
905.0,
409.0
]
],
"route_stretch": 1.0,
"source": "rrset",
"source_port": [
745.0,
400.0
],
"target": "resolver",
"target_port": [
905.0,
409.0
],
"waypoints": []
},
{
"bends": 5,
"bridges": [
[
1044.0,
454.0
]
],
"crossings": [
[
1044.0,
454.0
]
],
"id": "a-check",
"length": 825.5,
"route": [
[
1032.5,
430.0
],
[
1032.5,
450.4
],
[
1044.0,
450.4
],
[
1044.0,
565.0
],
[
590.0,
565.0
],
[
590.0,
485.0
],
[
445.0,
485.0
],
[
445.0,
485.0
]
],
"route_stretch": 1.285,
"source": "resolver",
"source_port": [
1032.5,
430.0
],
"target": "check",
"target_port": [
445.0,
485.0
],
"waypoints": []
},
{
"bends": 0,
"bridges": [
[
590.0,
513.0
]
],
"crossings": [
[
590.0,
513.0
]
],
"id": "a-pass",
"length": 140.0,
"route": [
[
590.0,
513.0
],
[
730.0,
513.0
]
],
"route_stretch": 1.0,
"source": "check",
"source_port": [
590.0,
513.0
],
"target": "pass",
"target_port": [
730.0,
513.0
],
"waypoints": []
}
],
"input_schema": "v1",
"issues": [
{
"code": "EDGE_CROSSING_BRIDGED",
"coordinates": [
[
884.6,
454.0
]
],
"element": "fetch-key",
"severity": "info"
},
{
"code": "EDGE_CROSSING_BRIDGED",
"coordinates": [
[
745.0,
400.0
],
[
884.6,
409.0
]
],
"element": "fetch-rr",
"severity": "info"
},
{
"code": "EDGE_CROSSING_BRIDGED",
"coordinates": [
[
1044.0,
454.0
]
],
"element": "a-check",
"severity": "info"
},
{
"code": "EDGE_CROSSING_BRIDGED",
"coordinates": [
[
590.0,
513.0
]
],
"element": "a-pass",
"severity": "info"
}
],
"mode": "architecture",
"ok": true,
"palette": {
"below_target": [],
"minimum_target": 4.5,
"ratios": {
"section_sub_fill": 4.76,
"text_muted": 4.76,
"text_primary": 17.85,
"text_secondary": 7.58
},
"scope": "default opaque canvas text tokens; not a full rendered accessibility audit",
"unsupported_tokens": []
},
"placements": {
"legend": {
"actual": [
42.0,
530.0
],
"bounds": [
32.0,
516.0,
215.6,
606.0
],
"moved": true,
"requested": [
42.0,
540.0
]
}
},
"schema_version": 1,
"semantics": {
"details": {},
"ok": true,
"profile": "generic",
"style": 7,
"visual_theme": "OpenAI"
},
"style": {
"id": 7,
"name": "OpenAI"
},
"summary": {
"bridged_crossings": 5,
"edges": 8,
"nodes": 8
},
"text_metrics": "heuristic-v1",
"typography": {
"complete_text": true,
"hint": null,
"metrics": "heuristic widths; inspect the actual rendered font",
"truncated": []
}
}
@@ -0,0 +1,226 @@
{
"schema_version": 1,
"mode": "architecture",
"template_type": "architecture",
"style": 7,
"quality_profile": "standard",
"width": 1240,
"height": 640,
"title": "DNSSEC 验证链与本地验签",
"subtitle": "DS 信任链自根向下逐级建立,递归侧以 DNSKEY 验证 RRset 签名",
"containers": [
{
"id": "trust",
"x": 40,
"y": 110,
"width": 1160,
"height": 150,
"label": "信任链 · 由根向下逐级签发",
"stroke": "#e2e8f0",
"fill": "none"
},
{
"id": "verify",
"x": 40,
"y": 310,
"width": 1160,
"height": 250,
"label": "递归解析侧 · 本地验签",
"stroke": "#e2e8f0",
"fill": "none"
}
],
"nodes": [
{
"id": "root",
"kind": "rect",
"x": 80,
"y": 165,
"width": 250,
"height": 60,
"type_label": "ROOT · 信任锚",
"label": "根 DNS(内置信任锚)",
"fill": "#ffffff",
"stroke": "#dce5e3",
"flat": true
},
{
"id": "tld",
"kind": "double_rect",
"x": 495,
"y": 165,
"width": 250,
"height": 60,
"type_label": "TLD · .top",
"label": "顶级 DNS(发布 DS)",
"fill": "#ffffff",
"stroke": "#10a37f"
},
{
"id": "auth",
"kind": "double_rect",
"x": 905,
"y": 165,
"width": 255,
"height": 60,
"type_label": "AUTHORITATIVE",
"label": "权威 DNS(发布 DNSKEY)",
"fill": "#ffffff",
"stroke": "#10a37f"
},
{
"id": "dnskey",
"kind": "rect",
"x": 80,
"y": 370,
"width": 250,
"height": 60,
"type_label": "DNSKEY",
"label": "KSK / ZSK 密钥",
"fill": "#ffffff",
"stroke": "#dce5e3",
"flat": true
},
{
"id": "rrset",
"kind": "rect",
"x": 495,
"y": 370,
"width": 250,
"height": 60,
"type_label": "RRSET + RRSIG",
"label": "记录数据与签名",
"fill": "#ffffff",
"stroke": "#dce5e3",
"flat": true
},
{
"id": "resolver",
"kind": "double_rect",
"x": 905,
"y": 370,
"width": 255,
"height": 60,
"type_label": "RECURSIVE · DO=1",
"label": "递归解析器(带 DO 查询)",
"fill": "#ffffff",
"stroke": "#1d4ed8"
},
{
"id": "check",
"kind": "rect",
"x": 300,
"y": 485,
"width": 290,
"height": 56,
"type_label": "VERIFY",
"label": "RRSIG.Verify 本地验签",
"fill": "#ffffff",
"stroke": "#dce5e3",
"flat": true
},
{
"id": "pass",
"kind": "double_rect",
"x": 730,
"y": 485,
"width": 290,
"height": 56,
"type_label": "RESULT",
"label": "验证通过 · 数据完整可信",
"fill": "#ffffff",
"stroke": "#10a37f"
}
],
"arrows": [
{
"id": "ds-tld",
"source": "root",
"target": "tld",
"source_port": "right",
"target_port": "left",
"flow": "control",
"label": "DS 签发"
},
{
"id": "ds-auth",
"source": "tld",
"target": "auth",
"source_port": "right",
"target_port": "left",
"flow": "control",
"label": "DS 指向下级 KEY"
},
{
"id": "pub-key",
"source": "auth",
"target": "dnskey",
"source_port": "bottom",
"target_port": "top",
"flow": "write",
"label": "发布密钥"
},
{
"id": "sign",
"source": "auth",
"target": "rrset",
"source_port": "bottom",
"target_port": "top",
"flow": "write",
"label": "私钥签名"
},
{
"id": "fetch-key",
"source": "dnskey",
"target": "resolver",
"source_port": "right",
"target_port": "left",
"flow": "read",
"label": "取回 DNSKEY"
},
{
"id": "fetch-rr",
"source": "rrset",
"target": "resolver",
"source_port": "right",
"target_port": "left",
"flow": "read",
"label": "带签名应答"
},
{
"id": "a-check",
"source": "resolver",
"target": "check",
"source_port": "bottom",
"target_port": "top",
"flow": "control",
"label": "RRSIG.Verify"
},
{
"id": "a-pass",
"source": "check",
"target": "pass",
"source_port": "right",
"target_port": "left",
"flow": "control",
"label": "验签通过"
}
],
"legend": [
{
"flow": "control",
"label": "信任链 / 主验证路径"
},
{
"flow": "read",
"label": "数据获取"
},
{
"flow": "write",
"label": "权威侧写操作"
}
],
"footer": "Style 7 · OpenAI Official · DNSSEC chain of trust",
"footer_x": 48,
"footer_y": 625
}
@@ -0,0 +1,648 @@
{
"canvas": {
"height": 880.0,
"width": 1240.0
},
"composition": {
"limits": {
"max_bends_per_edge": 4,
"max_bridged_crossings": 14,
"max_route_stretch": 5.0,
"max_total_bends": 30,
"min_container_gutter": 0.0,
"min_label_clearance": 2.0,
"min_node_gap": 0.0,
"min_segment_length": 0.0,
"profile": "standard"
},
"metrics": {
"bridged_crossings": 3,
"max_route_stretch": 2.625,
"minimum_container_gutter": 10.0,
"minimum_node_gap": 41.0,
"shortest_segment": 3.0,
"total_bends": 30
},
"ok": true,
"profile": "standard",
"score": 42,
"violations": []
},
"edges": [
{
"bends": 0,
"bridges": [],
"crossings": [],
"id": "a-reg",
"length": 110.0,
"route": [
[
270.0,
170.0
],
[
380.0,
170.0
]
],
"route_stretch": 1.0,
"source": "available",
"source_port": [
270.0,
170.0
],
"target": "addperiod",
"target_port": [
380.0,
170.0
],
"waypoints": []
},
{
"bends": 2,
"bridges": [],
"crossings": [],
"id": "a-ok",
"length": 109.0,
"route": [
[
600.0,
170.0
],
[
620.4,
170.0
],
[
620.4,
179.0
],
[
700.0,
179.0
]
],
"route_stretch": 1.0,
"source": "addperiod",
"source_port": [
600.0,
170.0
],
"target": "active",
"target_port": [
700.0,
179.0
],
"waypoints": []
},
{
"bends": 2,
"bridges": [],
"crossings": [],
"id": "a-hold-c",
"length": 95.0,
"route": [
[
910.0,
152.0
],
[
930.4,
152.0
],
[
930.4,
137.0
],
[
990.0,
137.0
]
],
"route_stretch": 1.0,
"source": "active",
"source_port": [
910.0,
152.0
],
"target": "clienthold",
"target_port": [
990.0,
137.0
],
"waypoints": []
},
{
"bends": 2,
"bridges": [],
"crossings": [],
"id": "a-hold-s",
"length": 142.0,
"route": [
[
910.0,
170.0
],
[
930.4,
170.0
],
[
930.4,
232.0
],
[
990.0,
232.0
]
],
"route_stretch": 1.0,
"source": "active",
"source_port": [
910.0,
170.0
],
"target": "serverhold",
"target_port": [
990.0,
232.0
],
"waypoints": []
},
{
"bends": 2,
"bridges": [],
"crossings": [],
"id": "a-renew",
"length": 189.0,
"route": [
[
787.0,
200.0
],
[
787.0,
220.4
],
[
796.0,
220.4
],
[
796.0,
380.0
]
],
"route_stretch": 1.0,
"source": "active",
"source_port": [
787.0,
200.0
],
"target": "renewperiod",
"target_port": [
796.0,
380.0
],
"waypoints": []
},
{
"bends": 4,
"bridges": [],
"crossings": [],
"id": "a-renew-back",
"length": 195.0,
"route": [
[
814.0,
380.0
],
[
814.0,
359.6
],
[
817.0,
359.6
],
[
817.0,
220.4
],
[
805.0,
220.4
],
[
805.0,
200.0
]
],
"route_stretch": 1.032,
"source": "renewperiod",
"source_port": [
814.0,
380.0
],
"target": "active",
"target_port": [
805.0,
200.0
],
"waypoints": []
},
{
"bends": 3,
"bridges": [
[
692.86,
179.0
]
],
"crossings": [
[
692.86,
179.0
]
],
"id": "a-expire",
"length": 409.0,
"route": [
[
700.0,
161.0
],
[
692.86,
161.0
],
[
692.86,
224.0
],
[
510.0,
224.0
],
[
510.0,
380.0
]
],
"route_stretch": 1.0,
"source": "active",
"source_port": [
700.0,
161.0
],
"target": "autorenew",
"target_port": [
510.0,
380.0
],
"waypoints": []
},
{
"bends": 0,
"bridges": [],
"crossings": [],
"id": "a-no-pay",
"length": 120.0,
"route": [
[
510.0,
440.0
],
[
510.0,
560.0
]
],
"route_stretch": 1.0,
"source": "autorenew",
"source_port": [
510.0,
440.0
],
"target": "redemption",
"target_port": [
510.0,
560.0
],
"waypoints": []
},
{
"bends": 0,
"bridges": [],
"crossings": [],
"id": "a-redemption-end",
"length": 70.0,
"route": [
[
380.0,
590.0
],
[
310.0,
590.0
]
],
"route_stretch": 1.0,
"source": "redemption",
"source_port": [
380.0,
590.0
],
"target": "pendingdelete",
"target_port": [
310.0,
590.0
],
"waypoints": []
},
{
"bends": 4,
"bridges": [],
"crossings": [],
"id": "a-release",
"length": 341.2,
"route": [
[
190.0,
620.0
],
[
190.0,
640.4
],
[
305.6,
640.4
],
[
305.6,
732.86
],
[
200.0,
732.86
],
[
200.0,
740.0
]
],
"route_stretch": 2.625,
"source": "pendingdelete",
"source_port": [
190.0,
620.0
],
"target": "released",
"target_port": [
200.0,
740.0
],
"waypoints": []
},
{
"bends": 4,
"bridges": [],
"crossings": [],
"id": "a-transfer",
"length": 292.0,
"route": [
[
910.0,
188.0
],
[
930.0,
188.0
],
[
930.0,
356.0
],
[
959.6,
356.0
],
[
959.6,
410.0
],
[
980.0,
410.0
]
],
"route_stretch": 1.0,
"source": "active",
"source_port": [
910.0,
188.0
],
"target": "pendingtransfer",
"target_port": [
980.0,
410.0
],
"waypoints": []
},
{
"bends": 3,
"bridges": [],
"crossings": [],
"id": "a-transfer-done",
"length": 561.8,
"route": [
[
1080.0,
440.0
],
[
1080.0,
464.0
],
[
679.6,
464.0
],
[
679.6,
581.0
],
[
700.0,
581.0
]
],
"route_stretch": 1.078,
"source": "pendingtransfer",
"source_port": [
1080.0,
440.0
],
"target": "transferperiod",
"target_port": [
700.0,
581.0
],
"waypoints": []
},
{
"bends": 4,
"bridges": [
[
787.0,
200.0
],
[
805.0,
200.0
]
],
"crossings": [
[
787.0,
200.0
],
[
805.0,
200.0
]
],
"id": "a-transfer-ok",
"length": 570.0,
"route": [
[
700.0,
599.0
],
[
676.0,
599.0
],
[
676.0,
230.4
],
[
700.0,
230.4
],
[
700.0,
200.0
],
[
823.0,
200.0
],
[
823.0,
200.0
]
],
"route_stretch": 1.092,
"source": "transferperiod",
"source_port": [
700.0,
599.0
],
"target": "active",
"target_port": [
823.0,
200.0
],
"waypoints": []
}
],
"input_schema": "v1",
"issues": [
{
"code": "EDGE_CROSSING_BRIDGED",
"coordinates": [
[
692.86,
179.0
]
],
"element": "a-expire",
"severity": "info"
},
{
"code": "EDGE_CROSSING_BRIDGED",
"coordinates": [
[
787.0,
200.0
],
[
805.0,
200.0
]
],
"element": "a-transfer-ok",
"severity": "info"
}
],
"mode": "architecture",
"ok": true,
"palette": {
"below_target": [],
"minimum_target": 4.5,
"ratios": {
"section_sub_fill": 4.76,
"text_muted": 4.76,
"text_primary": 17.85,
"text_secondary": 7.58
},
"scope": "default opaque canvas text tokens; not a full rendered accessibility audit",
"unsupported_tokens": []
},
"placements": {
"legend": {
"actual": [
42.0,
698.0
],
"bounds": [
32.0,
684.0,
305.6,
730.0
],
"moved": true,
"requested": [
42.0,
798.0
]
}
},
"schema_version": 1,
"semantics": {
"details": {},
"ok": true,
"profile": "generic",
"style": 7,
"visual_theme": "OpenAI"
},
"style": {
"id": 7,
"name": "OpenAI"
},
"summary": {
"bridged_crossings": 3,
"edges": 13,
"nodes": 12
},
"text_metrics": "heuristic-v1",
"typography": {
"complete_text": true,
"hint": null,
"metrics": "heuristic widths; inspect the actual rendered font",
"truncated": []
}
}
@@ -0,0 +1,338 @@
{
"schema_version": 1,
"mode": "architecture",
"template_type": "architecture",
"style": 7,
"quality_profile": "standard",
"width": 1240,
"height": 880,
"title": "域名生命周期状态机",
"subtitle": "从可注册到释放的完整 EPP 状态流转(REGISTRY / REGISTRAR 管理方标注)",
"composition": {
"profile": "standard",
"max_bridged_crossings": 14,
"max_total_bends": 30,
"max_bends_per_edge": 4
},
"containers": [
{
"id": "reg",
"x": 40,
"y": 70,
"width": 1160,
"height": 220,
"label": "注册与活跃",
"stroke": "#e2e8f0",
"fill": "none"
},
{
"id": "grace",
"x": 40,
"y": 330,
"width": 1160,
"height": 140,
"label": "续费 · 到期 · 转移中",
"stroke": "#e2e8f0",
"fill": "none"
},
{
"id": "end",
"x": 40,
"y": 510,
"width": 1160,
"height": 300,
"label": "赎回 · 释放 · 转移完成",
"stroke": "#e2e8f0",
"fill": "none"
}
],
"nodes": [
{
"id": "available",
"kind": "rect",
"x": 70,
"y": 140,
"width": 200,
"height": 60,
"type_label": "REGISTRY",
"label": "Available · 域名可注册",
"fill": "#ffffff",
"stroke": "#dce5e3",
"flat": true
},
{
"id": "addperiod",
"kind": "rect",
"x": 380,
"y": 140,
"width": 220,
"height": 60,
"type_label": "REGISTRY",
"label": "AddPeriod · 注册完成 5天",
"fill": "#ffffff",
"stroke": "#dce5e3",
"flat": true
},
{
"id": "active",
"kind": "double_rect",
"x": 700,
"y": 140,
"width": 210,
"height": 60,
"type_label": "REGISTRAR · Active",
"label": "Active (ok) · 正常解析",
"fill": "#ffffff",
"stroke": "#10a37f"
},
{
"id": "clienthold",
"kind": "rect",
"x": 990,
"y": 110,
"width": 200,
"height": 54,
"type_label": "REGISTRAR",
"label": "ClientHold · 客户暂停",
"fill": "#ffffff",
"stroke": "#f97316",
"flat": true
},
{
"id": "serverhold",
"kind": "rect",
"x": 990,
"y": 205,
"width": 200,
"height": 54,
"type_label": "REGISTRY",
"label": "ServerHold · 注册局暂停",
"fill": "#ffffff",
"stroke": "#f97316",
"flat": true
},
{
"id": "renewperiod",
"kind": "rect",
"x": 700,
"y": 380,
"width": 210,
"height": 60,
"type_label": "REGISTRY",
"label": "RenewPeriod · 手动续费 5天",
"fill": "#ffffff",
"stroke": "#dce5e3",
"flat": true
},
{
"id": "autorenew",
"kind": "rect",
"x": 380,
"y": 380,
"width": 260,
"height": 60,
"type_label": "REGISTRY",
"label": "AutoRenewPeriod · 等待扣费",
"fill": "#ffffff",
"stroke": "#dce5e3",
"flat": true
},
{
"id": "pendingtransfer",
"kind": "rect",
"x": 980,
"y": 380,
"width": 200,
"height": 60,
"type_label": "REGISTRAR",
"label": "PendingTransfer · 转移中",
"fill": "#ffffff",
"stroke": "#dce5e3",
"flat": true
},
{
"id": "transferperiod",
"kind": "rect",
"x": 700,
"y": 560,
"width": 210,
"height": 60,
"type_label": "REGISTRAR",
"label": "TransferPeriod · 5天",
"fill": "#ffffff",
"stroke": "#dce5e3",
"flat": true
},
{
"id": "redemption",
"kind": "rect",
"x": 380,
"y": 560,
"width": 260,
"height": 60,
"type_label": "REGISTRY",
"label": "RedemptionPeriod · 赎回 30天",
"fill": "#ffffff",
"stroke": "#dce5e3",
"flat": true
},
{
"id": "pendingdelete",
"kind": "rect",
"x": 70,
"y": 560,
"width": 240,
"height": 60,
"type_label": "REGISTRY",
"label": "PendingDelete · 删除 5天",
"fill": "#ffffff",
"stroke": "#dce5e3",
"flat": true
},
{
"id": "released",
"kind": "double_rect",
"x": 70,
"y": 740,
"width": 260,
"height": 60,
"type_label": "END",
"label": "已释放 · 重新开放注册",
"fill": "#ffffff",
"stroke": "#1d4ed8"
}
],
"arrows": [
{
"id": "a-reg",
"source": "available",
"target": "addperiod",
"source_port": "right",
"target_port": "left",
"flow": "control",
"label": "注册"
},
{
"id": "a-ok",
"source": "addperiod",
"target": "active",
"source_port": "right",
"target_port": "left",
"flow": "control",
"label": "注册完成"
},
{
"id": "a-hold-c",
"source": "active",
"target": "clienthold",
"source_port": "right",
"target_port": "left",
"flow": "feedback",
"label": "暂停解析"
},
{
"id": "a-hold-s",
"source": "active",
"target": "serverhold",
"source_port": "right",
"target_port": "left",
"flow": "feedback",
"label": "违规暂停"
},
{
"id": "a-renew",
"source": "active",
"target": "renewperiod",
"source_port": "bottom",
"target_port": "top",
"flow": "control",
"label": "续费"
},
{
"id": "a-renew-back",
"source": "renewperiod",
"target": "active",
"source_port": "top",
"target_port": "bottom",
"flow": "feedback",
"label": "期满回到正常"
},
{
"id": "a-expire",
"source": "active",
"target": "autorenew",
"source_port": "left",
"target_port": "top",
"flow": "control",
"label": "到期"
},
{
"id": "a-no-pay",
"source": "autorenew",
"target": "redemption",
"source_port": "bottom",
"target_port": "top",
"flow": "control",
"label": "未支付"
},
{
"id": "a-redemption-end",
"source": "redemption",
"target": "pendingdelete",
"source_port": "left",
"target_port": "right",
"flow": "control",
"label": "赎回期满"
},
{
"id": "a-release",
"source": "pendingdelete",
"target": "released",
"source_port": "bottom",
"target_port": "top",
"flow": "control",
"label": "释放"
},
{
"id": "a-transfer",
"source": "active",
"target": "pendingtransfer",
"source_port": "right",
"target_port": "left",
"flow": "control",
"label": "转移"
},
{
"id": "a-transfer-done",
"source": "pendingtransfer",
"target": "transferperiod",
"source_port": "bottom",
"target_port": "left",
"flow": "control",
"label": "转移完成"
},
{
"id": "a-transfer-ok",
"source": "transferperiod",
"target": "active",
"source_port": "left",
"target_port": "bottom",
"flow": "feedback",
"label": "转移生效"
}
],
"legend_orientation": "horizontal",
"legend": [
{
"flow": "control",
"label": "状态流转"
},
{
"flow": "feedback",
"label": "回到活跃 / 暂停"
}
],
"footer": "Style 7 · OpenAI Official · EPP domain lifecycle states",
"footer_x": 48,
"footer_y": 845
}
@@ -0,0 +1,38 @@
# DNSSEC 原理介绍
DNSSEC(Domain Name System Security Extensions,DNS 安全扩展)通过**数字签名**为 DNS 应答提供来源验证与数据完整性保护:解析器收到应答后,用域名公钥本地验证签名,确认记录确实来自该域名的权威 DNS、且在传输途中没有被篡改或劫持。它防的是 DNS 欺骗与中间人攻击,但不加密内容——机密性仍由 TLS 负责。
![DNSSEC 验证链](/dnssec-trustchain-openai.svg)
## 三类关键记录
| 记录 | 发布位置 | 作用 |
| --- | --- | --- |
| `DNSKEY` | 域名自身 | 域名公钥,分 KSK(签密钥集)与 ZSK(签记录集) |
| `RRSIG` | 域名自身 | 对每类记录集(RRset)的数字签名,随应答一并返回 |
| `DS` | **父区**(如 `.top`) | 子域 DNSKEY 的摘要,是把信任链"挂"到上级的凭证 |
## 信任链如何建立
验证从根开始逐级向下:根的信任锚 → 顶级域的 `DS` → 域名的 `DNSKEY`。解析器拿到 `RRSIG` 后,用对应的 `DNSKEY` 做 `RRSIG.Verify` 本地验签;`DNSKEY` 自身的真伪再由父区 `DS` 核对,如此递归到根——任何一环对不上,应答即判定为 BOGUS(伪造)。
**本站的验证路径**:查询 `DNSKEY` → 带 DO 位查询 A 记录取回 `RRSIG` → 本地 `RRSIG.Verify` 验签(对 TLD / 无 A 记录的域,退回用 DNSKEY 自验签)→ 核对 `DS`。因国内公共 DNS(DNSPod / doh.pub / AliDNS)即使带 DO 位也不返回 RRSIG,本站默认使用实测支持 DNSSEC 的境外解析器列表(`1.1.1.1` / `8.8.8.8` 等,IPv4/IPv6 双栈)。
## 查询结果字段说明
| 字段 | 含义 |
| --- | --- |
| `enabled` | 是否启用 DNSSEC(存在 RRSIG 或 DNSKEY) |
| `valid` | 签名是否验证通过 |
| `has_rrsig` / `has_dnskey` / `has_ds` | 是否查到对应记录 |
| `algorithm` / `key_tag` | 签名算法与密钥标签(如算法 13 = ECDSAP256SHA256) |
| `validation` | 人类可读的判定说明 |
## 常见"验证失败"原因
- 域名本身**未配置 DNSSEC**(最常见——先在注册商处确认是否已开启);
- `DNSKEY` 与 `DS` 不匹配(密钥轮换后父区 `DS` 未同步更新);
- 解析器不支持 DNSSEC(带 DO 位也不返回 RRSIG,本站已默认规避);
- 域名处于 ClientHold / ServerHold 暂停状态。
[DNS 解析查询](/dns) | [Whois 域名查询](/whois) | [回到顶部](#dnssec-原理介绍)
+1 -1
View File
@@ -14,7 +14,7 @@
### 域名生命周期流程图
![流程](/domain-lifecycle-cloudfabric.svg)
![流程](/domain-lifecycle-openai.svg)
### 域名状态详解
| 状态 | 描述 | 特征/影响 |
@@ -0,0 +1,76 @@
import { brotliCompressSync, gzipSync, constants } from 'node:zlib'
import type { NitroApp } from 'nitro/types'
/**
* SSR HTML 压缩。
*
* ## 为什么要压
*
* nitro 不压 SSR 响应体(静态资源另有 compressPublicAssets 预压缩),
* 本地直连 / Lighthouse 测到的 HTML 就是裸传的。生产链路前面有 EdgeOne 兜底,
* 但源站自己压是标准做法,也让本地测量贴近线上。
*
* 只压 SSR 渲染出的 text/html:body 是字符串、体积 > 1KB、对方 Accept-Encoding
* 支持才压,按客户端优先级选 br(brotli)否则 gzip。
*
* ## 为什么必须显式指定 brotli quality = 4(别删这行)
*
* `brotliCompressSync` 不带 params 时默认 **quality 11**,那是给「离线压一次、
* 反复复用」准备的(压一次存盘,读一万次)。SSR 是**每请求现压**:
*
* quality 33KB HTML 68KB HTML
* 11 123ms / 7.2KB 169ms / 11.0KB
* 6 4ms / 7.8KB 5ms / 12.2KB
* 4 2ms / 8.6KB 3ms / 13.5KB
*
* Node 是单线程,这段同步压缩**全程阻塞事件循环,直接进 TTFB**。
* 首页 TTFB 实测:q11 时 225~260ms,改 q4 后 55~105ms —— 差的就是这 120~170ms。
* q4 只多 2.5KB,在 1.6Mbps 上是 12ms,换回 166ms,净赚。
*
* 教训:凡「每请求现压」的同步压缩都要显式指定质量,别吃库默认值。
*/
export default defineNitroPlugin((nitroApp: NitroApp) => {
nitroApp.hooks.hook('render:response', (response, { event }) => {
const headers = (response.headers ??= {})
const type = String(
headers['content-type'] ?? event.node.res.getHeader('content-type') ?? '',
)
if (!type.includes('text/html')) return
if (typeof response.body !== 'string' || response.body.length < 1024) return
if (headers['content-encoding']) return // 已有编码(异常路径),不叠加
// —— modulepreload 降优先级(2026-10-04 实测,mobile LH 三档对比后选定 low)——
// entry 的 26 个静态依赖 chunk 默认 High 优先级,和 4 个 render-blocking CSS 一起
// 排队(本地 H1 只有 6 个连接),CSS 被挤到后面 ⇒ Lantern 模拟 FCP 2345ms。
// 加 fetchpriority="low" 后 CSS 先行:FCP 2345→1109(-1236ms,首次过 1800 满分线),
// desktop 三轮全 100。代价是 JS 下载推迟 → hydration 落到 FCP 之后,TBT 86→213。
// 三档实测(mobile perf):无 fp 94/95,medium 92/93(medium 仍挤 CSS,FCP 2253,
// 收益尽失),low 94/95 —— low 总分最优。
// TBT 的 213 是这项改动的全部代价:FCP 提前后,原本在 FCP 前不计账的 hydration
// 长任务进了 TBT 窗口(FCP 每提前 ~500ms,TBT 约 +55,两者在当前架构下互斥)。
if (response.body.includes('rel="modulepreload"')) {
response.body = response.body.replaceAll(
'rel="modulepreload"',
'rel="modulepreload" fetchpriority="low"',
)
}
const accept = String(event.node.req.headers['accept-encoding'] ?? '')
let encoding: 'br' | 'gzip'
if (/\bbr\b/.test(accept)) {
response.body = brotliCompressSync(Buffer.from(response.body), {
params: { [constants.BROTLI_PARAM_QUALITY]: 4 },
})
encoding = 'br'
} else if (/\bgzip\b/.test(accept)) {
response.body = gzipSync(Buffer.from(response.body))
encoding = 'gzip'
} else {
return
}
headers['content-encoding'] = encoding
// 体积变了,content-length 必须跟着改,否则连接会被截断/挂起
headers['content-length'] = String((response.body as Buffer).length)
})
})
+14
View File
@@ -0,0 +1,14 @@
// /ipv6 是历史对外地址(ipw.cn 同款 ?ip= 参数曾大量被外部引用,不能 404)。
// 原实现是一个客户端 onMounted 跳转的空壳页:无 title、无 description、无 h1,
// 被 SEO 审计判为「缺少 H1」页,还以低质量页面的身份混进 sitemap。
// 这里改为服务端 301:爬虫直接拿到重定向,HTML 空壳页不复存在,
// query 原样带到 /location(?ip=xxx → /location?ip=xxx)。
export default defineEventHandler((event) => {
const query = getQuery(event)
const qs = new URLSearchParams(
Object.entries(query).flatMap(([k, v]) =>
v === undefined ? [] : Array.isArray(v) ? v.map((item) => [k, String(item)] as [string, string]) : [[k, String(v)] as [string, string]]
)
).toString()
return sendRedirect(event, '/location' + (qs ? `?${qs}` : ''), 301)
})
@@ -41,10 +41,20 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
}
const slugString: string = event.context.params?.slug as any
if (!slugString) {
const rawSlug: string = event.context.params?.slug as any
if (!rawSlug) {
throw createError({ statusCode: 400, statusMessage: 'Missing slug parameter' })
}
}
// h3 出于路径安全不会把 %2F 解码回 '/',而 detail/ssl/speed 的目标 URL 恰恰依赖它
// (前端 encodeURIComponent('https://x') → 'https%3A%2F%2Fx')。远程中间件(fiber)会解码,
// 这里补齐同样的语义,否则内置中间件转发带协议头的拨测目标必然拼坏
// (下游拿到 'https://https:%2F%2Fx')。畸形 % 序列解码失败时按原文处理。
let slugString: string = rawSlug
try {
slugString = decodeURIComponent(rawSlug)
} catch {
// keep raw
}
// 将路径参数转为数组
let slug: string[] = slugString ? slugString.split('/') : []
console.debug('[middleware debug] raw slugString:', slugString, '| split:', slug)
@@ -133,7 +143,11 @@ export default defineEventHandler(async (event) => {
if (apiBaseUrl.slice(-1) != '/') {
apiBaseUrl = `${apiBaseUrl}/`
}
data = await $fetch(`${apiBaseUrl}v1/${apiType}/${raw}`, {
const queryString = new URLSearchParams(
Object.entries(query).filter(([_, v]) => v !== undefined) as [string, string][]
).toString()
data = await $fetch(`${apiBaseUrl}v1/${apiType}/${raw}${queryString ? '?' + queryString : ''}`, {
method: 'GET',
headers: authHeaders,
timeout: 15_000,
+55
View File
@@ -0,0 +1,55 @@
// IndexNow 提交的共享实现:server/api/indexnow.get.ts / indexnow.post.ts 与
// scripts/indexnow-submit.mjs(CI 部署后一次性提交)三方语义保持一致。
//
// key 是「站点所有权凭证」而非机密:IndexNow 的校验方式就是抓取
// https://nipw.cn/<key>.txt 并比对,所以 key 必须能公开访问(见 public/ 下的 key 文件)。
// 放进 runtimeConfig 只是为了支持环境变量覆盖(NUXT_INDEXNOW_KEY),默认值与 key 文件一致。
import { useRuntimeConfig } from '#imports'
const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/IndexNow'
export interface IndexNowResult {
submitted: number
status: number
body: string
}
// 归一化 + 同域校验:只允许提交本站 URL(相对路径按 siteUrl 补全),
// 防止提交端点被当作向 IndexNow 提交任意域名的跳板。
export function normalizeIndexNowUrls(input: unknown[], siteUrl: string): string[] {
const origin = new URL(siteUrl).origin
const out = new Set<string>()
for (const raw of input) {
if (typeof raw !== 'string' || !raw) continue
const abs = new URL(raw, siteUrl)
if (abs.origin !== origin) continue
out.add(abs.href)
}
return [...out]
}
// 拉取 sitemap.xml 并抽出全部 <loc>(本站 sitemap 的 XML 很简单,正则足够且免依赖)
export async function fetchSitemapUrls(siteUrl: string): Promise<string[]> {
const res = await fetch(new URL('sitemap.xml', siteUrl).href)
if (!res.ok) throw new Error(`sitemap fetch failed: ${res.status}`)
const xml = await res.text()
return [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map(m => m[1].trim())
}
export async function submitIndexNow(urls: string[], siteUrl: string): Promise<IndexNowResult> {
const key = useRuntimeConfig().indexnowKey
if (!key) throw new Error('indexnowKey is not configured')
const res = await fetch(INDEXNOW_ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json; charset=utf-8' },
body: JSON.stringify({
host: new URL(siteUrl).host,
key,
keyLocation: new URL(`/${key}.txt`, siteUrl).href,
// IndexNow 单请求最多 1 万条 URL,超出截断(本站量级远够)
urlList: urls.slice(0, 10_000),
}),
})
const body = await res.text().catch(() => '')
return { submitted: urls.length, status: res.status, body: body.slice(0, 200) }
}
+8 -1
View File
@@ -2,7 +2,14 @@ import { codeToHtml } from './shiki.bundle'
export async function highlightCode(code: string, lang: string) {
try {
return await codeToHtml(code, { lang, theme: 'github-dark' })
return await codeToHtml(code, {
lang,
theme: 'github-dark',
// github-dark 的注释色 #6A737D 在它自带底色 #24292E 上只有 3.04:1(axe 的 color-contrast,
// 首页 12 条里有 5 条都是它)。用 shiki 原生的颜色替换提亮到 #8B949E = 4.77:1 ——
// 只动这一个 token,换主题(比如已在 bundle 里的 vitesse-dark)会把整块代码的配色一起换掉。
colorReplacements: { '#6a737d': '#8b949e' },
})
} catch (error) {
console.error('Error highlighting code:', error)
return code
+14
View File
@@ -40,3 +40,17 @@ export function isIPv4(ip: string): boolean {
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
return ipRegex.test(ip);
}
/**
* 把参数对象拼到 URL 上(空值跳过,自动判断用 ? 还是 &)。
* 用于高级选项:dns4/dns6/resolve/resolve4/resolve6 这些都是「请求级解析参数」,
* 只有用户填了才下发,没填时 URL 与加参数之前逐字节一致(缓存键也不变)。
*/
export function withQuery(url: string, params: Record<string, string>): string {
const pairs: string[] = []
for (const [key, value] of Object.entries(params)) {
if (value) pairs.push(encodeURIComponent(key) + '=' + encodeURIComponent(value))
}
if (pairs.length === 0) return url
return url + (url.includes('?') ? '&' : '?') + pairs.join('&')
}
+134 -27
View File
@@ -4,7 +4,9 @@
# - 自动检测架构并下载最新 release 二进制
# - 交互式输入配置(环境变量注入,无需 setting.json)
# - 生成并启用 systemd 守护进程
# 用法:sudo bash install.sh
# - 非 root 运行时会提示是否改用 systemd --user(用户级服务)
# 用法:sudo bash install.sh # 系统级安装(推荐,开机自启、独立于登录会话)
# bash install.sh # 非 root:确认后走 systemd --user,仅对当前用户生效
# ============================================================
text="
@@ -20,15 +22,58 @@ echo "$text"
set -e
# ---------- 前置检查 ----------
# ---------- 前置检查与 systemd 作用域 ----------
# system = 系统级:写 /etc/systemd/system,开机自启走 multi-user.target(需 root)
# user = 用户级:写 ~/.config/systemd/user,开机自启走 default.target(systemd --user,无需 root)
SYSTEMD_MODE="system"
if [ "$(id -u)" -ne 0 ]; then
echo "错误:需要 root 权限(写入 /etc/systemd/system 与安装目录),请用 sudo 运行" >&2
exit 1
echo "提示:当前不是 root 环境,无法写入 /etc/systemd/system 与系统安装目录。"
read -r -p "是否改用 systemd --user(用户级服务,仅对当前用户生效)?[y/N]: " USE_USER_SYSTEMD
case "${USE_USER_SYSTEMD,,}" in
y|yes) SYSTEMD_MODE="user" ;;
*)
echo "已取消:如需系统级安装,请用 sudo 重新运行" >&2
exit 1
;;
esac
fi
if ! command -v systemctl >/dev/null 2>&1; then
echo "错误:未检测到 systemd(systemctl 不存在),无法创建守护进程" >&2
exit 1
fi
# 用户级实例依赖登录会话与 DBus,容器 / cron 等环境常常拿不到,提前拦掉
if [ "$SYSTEMD_MODE" = "user" ] && ! systemctl --user list-units >/dev/null 2>&1; then
echo "错误:当前环境无法使用 systemd --user(无用户级 systemd 实例或缺少 DBus 会话)" >&2
echo " 可改用 sudo bash install.sh 做系统级安装" >&2
exit 1
fi
SERVICE_NAME="lemon-ipw"
if [ "$SYSTEMD_MODE" = "user" ]; then
SERVICE_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/${SERVICE_NAME}.service"
DEFAULT_INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/lemon-ipw"
UNIT_USER_LINE=""
UNIT_NETWORK_LINES=""
UNIT_WANTED_BY="default.target"
else
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
DEFAULT_INSTALL_DIR="/opt/lemon-ipw"
# 系统级 unit 的 User= 行与 network-online 依赖;用户级没有这两个概念
UNIT_USER_LINE="User=root
"
UNIT_NETWORK_LINES="After=network-online.target
Wants=network-online.target
"
UNIT_WANTED_BY="multi-user.target"
fi
# 统一 systemctl / journalctl 调用(用户级自动补 --user)
sc() { if [ "$SYSTEMD_MODE" = "user" ]; then systemctl --user "$@"; else systemctl "$@"; fi; }
jc() { if [ "$SYSTEMD_MODE" = "user" ]; then journalctl --user "$@"; else journalctl "$@"; fi; }
if ! command -v wget >/dev/null 2>&1 && ! command -v curl >/dev/null 2>&1; then
echo "错误:未找到 wget 或 curl,无法下载" >&2
exit 1
@@ -68,8 +113,16 @@ echo "========================================"
echo " 配置后端节点(直接回车使用默认值)"
echo "========================================"
read -r -p "安装目录 [/opt/lemon-ipw]: " INSTALL_DIR
INSTALL_DIR=${INSTALL_DIR:-/opt/lemon-ipw}
read -r -p "安装目录 [$DEFAULT_INSTALL_DIR]: " INSTALL_DIR
INSTALL_DIR=${INSTALL_DIR:-$DEFAULT_INSTALL_DIR}
# unit 里 WorkingDirectory/ExecStart 按 systemd 语法裸写(不加引号),
# 路径含空白会被拆成多个参数导致启动失败,这里提前拦掉
case "$INSTALL_DIR" in
*[[:space:]]*)
echo "错误:安装目录不能包含空格或制表符(当前值:$INSTALL_DIR)" >&2
exit 1
;;
esac
read -r -p "监听端口 [8080]: " PORTS
PORTS=${PORTS:-8080}
@@ -82,10 +135,10 @@ read -r -p "DNS 服务器(留空=启动时自动探测系统 DNS;主从逗
read -r -p "DNSSEC 专用 DNS(留空=沿用上面 dns-server): " DNSSEC_DNS_SERVER
read -r -p "启用 IP 数据库 ipdb(首次启动下载约 450MB)[Y/n]: " IPDB_CHOICE
read -r -p "启用 IP 数据库 ipdb(首次启动下载约 450MB,用于 IP 归属地/ASN 类拨测)[y/N]: " IPDB_CHOICE
case "${IPDB_CHOICE,,}" in
n|no) IPDB="false" ;;
*) IPDB="true" ;;
y|yes) IPDB="true" ;;
*) IPDB="false" ;;
esac
read -r -p "CORS 允许来源(逗号分隔,留空=不限): " CORS
@@ -106,11 +159,35 @@ NODE_KEY=""
WS_USED_DEFAULT=""
case "${WS_CHOICE,,}" in
y|yes)
read -r -p " 中间件 WS 完整地址(含 wss:// 前缀与 /ws 路径,如 wss://host:8092/ws;留空=贡献节点给柠檬 wss://boce-api.api-ipw.wsmdn.top/ws;逗号分隔多个将同时连接全部): " WS_URL
read -r -p " 中间件 WS 完整地址(含 wss:// 前缀与 /ws 路径,如 wss://host:8092/ws;多个用逗号分隔将同时连接全部;留空=贡献节点给柠檬的两条公共中间件): " WS_URL
if [ -z "$WS_URL" ]; then
WS_URL="wss://boce-api.api-ipw.wsmdn.top/ws"
WS_URL="wss://boce-api.api-ipw.wsmdn.top/ws,wss://middleware-1.api-ipw.wsmdn.top/ws"
WS_USED_DEFAULT="true"
fi
# 多地址(逗号分隔):逐段去掉首尾空白并校验 scheme。
# 不清理的话空格会原样写进 unit 的 Environment,节点侧解析失败后只会反复重连,很难排查。
_ws_norm=""
_old_ifs="$IFS"; IFS=','
for _seg in $WS_URL; do
_seg="${_seg#"${_seg%%[![:space:]]*}"}"
_seg="${_seg%"${_seg##*[![:space:]]}"}"
case "$_seg" in
ws://*|wss://*) ;;
"")
IFS="$_old_ifs"
echo "错误:WS 地址含空段(检查是否多写了逗号)" >&2
exit 1
;;
*)
IFS="$_old_ifs"
echo "错误:WS 地址的每一段都需以 ws:// 或 wss:// 开头(问题段:${_seg})" >&2
exit 1
;;
esac
_ws_norm="${_ws_norm:+${_ws_norm},}${_seg}"
done
IFS="$_old_ifs"
WS_URL="$_ws_norm"
read -r -p " 注册 key(留空自动生成,中间件 ws-keys 必须包含此节点,否则注册被拒 401): " NODE_KEY
if [ -z "$NODE_KEY" ]; then
NODE_KEY=$(gen_uuid | tr -d '-')
@@ -141,6 +218,11 @@ echo " 配置汇总"
echo "========================================"
echo "安装目录: $INSTALL_DIR"
echo "服务名: lemon-ipw"
if [ "$SYSTEMD_MODE" = "user" ]; then
echo "服务作用域: 用户级(systemd --user,仅对当前用户生效)"
else
echo "服务作用域: 系统级(systemd)"
fi
echo "监听端口: $PORTS"
echo "节点 id: $NODE_ID"
echo "单栈模式: ${SINGLE_STACK:-双栈}"
@@ -182,7 +264,9 @@ chmod +x "$INSTALL_DIR/lemonipw"
echo "二进制已安装到 $INSTALL_DIR/lemonipw"
# ---------- 生成 systemd 服务 ----------
SERVICE_FILE="/etc/systemd/system/lemon-ipw.service"
# 用户级 unit 目录(~/.config/systemd/user)默认可能不存在
mkdir -p "$(dirname "$SERVICE_FILE")"
# 收集环境变量(仅非空的写入,避免空值覆盖默认)
# 值里的双引号/反斜杠需要转义,否则会提前终止 systemd 的引号包裹、损坏 unit
@@ -216,43 +300,66 @@ fi
cat > "$SERVICE_FILE" << EOF
[Unit]
Description=Lemon IPW Backend Node
After=network-online.target
Wants=network-online.target
${UNIT_NETWORK_LINES}
[Service]
Type=simple
User=root
WorkingDirectory="${INSTALL_DIR}"
ExecStart="${INSTALL_DIR}/lemonipw"
${UNIT_USER_LINE}WorkingDirectory=${INSTALL_DIR}
ExecStart=${INSTALL_DIR}/lemonipw
${ENV_LINES}Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
WantedBy=${UNIT_WANTED_BY}
EOF
echo "已生成服务文件 $SERVICE_FILE"
# ---------- 启动服务 ----------
systemctl daemon-reload
sc daemon-reload
# restart 而非 enable --now:重跑安装改配置后旧进程必须重启才会生效
systemctl enable lemon-ipw
systemctl restart lemon-ipw
systemctl status lemon-ipw --no-pager | head -15
sc enable lemon-ipw
sc restart lemon-ipw
sc status lemon-ipw --no-pager | head -15
# 用户级服务默认随最后一个会话结束而停止,注销后要继续跑必须开 linger
if [ "$SYSTEMD_MODE" = "user" ]; then
if loginctl show-user "$(id -un)" -p Linger 2>/dev/null | grep -q 'Linger=yes'; then
echo "linger 已启用:用户注销后服务继续运行"
elif loginctl enable-linger "$(id -un)" >/dev/null 2>&1; then
echo "已启用 linger:用户注销后服务继续运行"
else
echo "提示:未能自动启用 linger,注销后服务会被停止。请手动执行:" >&2
echo " sudo loginctl enable-linger $(id -un)" >&2
fi
fi
echo ""
echo "========================================"
echo " 安装完成"
echo "========================================"
# 用户级服务的运维命令都要带 --user,否则看到的是系统级命名空间(空)
if [ "$SYSTEMD_MODE" = "user" ]; then
CTL_CMD="systemctl --user"
JRN_CMD="journalctl --user -u"
else
CTL_CMD="systemctl"
JRN_CMD="journalctl -u"
fi
echo "常用命令:"
echo " systemctl status lemon-ipw # 查看状态"
echo " journalctl -u lemon-ipw -f # 查看日志"
echo " systemctl restart lemon-ipw # 重启(改配置后)"
echo " $CTL_CMD status lemon-ipw # 查看状态"
echo " $JRN_CMD lemon-ipw -f # 查看日志"
echo " $CTL_CMD restart lemon-ipw # 重启(改配置后)"
echo "验证:curl http://127.0.0.1:$PORTS/ 应返回 {\"status\":\"ok\"}"
if [ -n "$WS_URL" ]; then
echo ""
echo "WS 通道已启用,请把节点注册信息加入中间件 setting.json 的 ws-keys:"
echo "WS 通道已启用,请把节点注册信息加入每一个对应中间件 setting.json 的 ws-keys(缺一个该条连接会被拒 401):"
echo " \"$NODE_ID\": \"$NODE_KEY\""
echo " 地址清单:"
_old_ifs="$IFS"; IFS=','
for _seg in $WS_URL; do
echo " - $_seg"
done
IFS="$_old_ifs"
fi
if [ -n "$WS_USED_DEFAULT" ]; then
echo ""
+14 -12
View File
@@ -348,6 +348,18 @@ func middlewareHandler(c fiber.Ctx) error {
query := c.Queries()
// 请求级 query 透传给节点(过滤空值,等价 TS 版 URLSearchParams 过滤):
// 两个分支都要用 —— tcping/udping/speed 的 port/count/version,以及 detail/ssl/dns/whois 的 dns/dns4/dns6。
// HTTP 侧用 url.Values 重新编码后拼接(原样透传会被下一跳的 query 解析器重新切分),WS 侧以 map 形式进 RequestProbe。
queryString := url.Values{}
queryMap := make(map[string]string)
for k, v := range query {
if v != "" {
queryString.Set(k, v)
queryMap[k] = v
}
}
// 从 API_KEYS 查找 token,未配置时回退到环境变量 API_KEYS (JSON 字符串)
apiKey := lookupAPIKey(backendID)
@@ -378,7 +390,7 @@ func middlewareHandler(c fiber.Ctx) error {
// 不再静默落回空 URL 的 HTTP 转发(必然 502 且掩盖真实配置错误)
return c.Status(fiber.StatusBadGateway).SendString("Node " + backendID + " is WS-only but WS channel is disabled (ws-port=0 or bind failed)")
}
status, body, err := wsSrv.RequestProbe(backendID, apiType, raw, nil, wsProbeTimeout())
status, body, err := wsSrv.RequestProbe(backendID, apiType, raw, queryMap, wsProbeTimeout())
if err != nil {
return c.Status(fiber.StatusBadGateway).SendString("WS probe failed: " + err.Error())
}
@@ -391,22 +403,12 @@ func middlewareHandler(c fiber.Ctx) error {
}
// 上游错误原样透传(状态码 + body),网络错误返回 502
return forwardUpstream(c, apiBaseUrl, apiBaseUrl+"v1/"+apiType+"/"+raw, authHeaders, nil)
return forwardUpstream(c, apiBaseUrl, apiBaseUrl+"v1/"+apiType+"/"+raw, authHeaders, queryString)
case "tcping", "udping", "speed":
// tcping/udping/speed 统一走 APIBaseURL 节点池
apiBaseUrls := flattenStack(API_BASE_URLS)
// 转发 query 参数(过滤空值,等价于 TS 中 URLSearchParams 过滤 undefined)
queryString := url.Values{}
queryMap := make(map[string]string)
for k, v := range query {
if v != "" {
queryString.Set(k, v)
queryMap[k] = v
}
}
node := findNode(apiBaseUrls, backendID)
if node == nil || (!node.UseWS() && node.URL == "") {
return badRequest(c, "Invalid backend ID")
+148 -135
View File
@@ -184,8 +184,11 @@ type SSLCheckDetail struct {
IssuerOrganization []string `json:"issuer_organization"`
IssuerCommonName string `json:"issuer_common_name"`
SubjectCommonName string `json:"subject_common_name"`
IsExpired bool `json:"is_expired"`
IsReachable bool `json:"is_reachable"`
// SAN 证书主体别名(Subject Alternative Names):DNS 名在前、IP 在后,
// 与 CN 相互独立——校验「证书覆盖了哪些域名」以这里为准
SAN []string `json:"san"`
IsExpired bool `json:"is_expired"`
IsReachable bool `json:"is_reachable"`
}
type SSLCheckResult struct {
@@ -202,26 +205,39 @@ func initHttpClient() {
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
return &http.Transport{
DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) {
if ssrf.Enabled() {
host, port, err := net.SplitHostPort(addr)
// DNS 接管与 SSRF 解耦:域名一律先经 webtest 解析成 IP 再拨号。
// 服务器取请求级覆盖(dns=/dns4=/dns6=,经 ctx 传来),无覆盖则用配置 dns-server;
// 不这样时关掉 SSRF 会绕过配置/系统 DNS,detail/ssl/speed 与 tcping/dns 各问各的。
// SSRF 只保留「私网校验」职责,不再是 DNS 接管的开关。
// 同源:SSRF 预检(ValidateOutboundTarget)经 InstallSSRFResolver 读到的是同一份 ctx 配置。
req := webtest.ReqResolveFromContext(ctx)
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
// 强制解析(resolve=/resolve4=/resolve6=):命中就把目标填回 host。
// 目标是 IP → 走下面的字面 IP 分支(私网检测后直连,不再查 DNS);
// 目标是域名 → 当作新 host 交给下面的解析分支。
if target := req.Override(host, network == "tcp6"); target != "" {
host = strings.Trim(target, "[]")
addr = net.JoinHostPort(host, port)
}
if ip := net.ParseIP(host); ip != nil {
if ssrf.Enabled() && ssrf.IsPrivateIP(ip) {
slog.Warn("Blocked connection to private IP", "host", host)
return nil, fmt.Errorf("request to private/internal address is not allowed")
}
} else {
var dnsResult webtest.DNSResult
if network == "tcp4" {
dnsResult, err = webtest.ResolveARecord(host, req.Servers("ipv4"))
} else {
dnsResult, err = webtest.ResolveAAAARecord(host, req.Servers("ipv6"))
}
if err != nil {
return nil, err
}
if ip := net.ParseIP(host); ip != nil {
if ssrf.IsPrivateIP(ip) {
slog.Warn("Blocked connection to private IP", "host", host)
return nil, fmt.Errorf("request to private/internal address is not allowed")
}
} else {
var dnsResult webtest.DNSResult
if network == "tcp4" {
dnsResult, err = webtest.ResolveARecord(host)
} else {
dnsResult, err = webtest.ResolveAAAARecord(host)
}
if err != nil {
return nil, err
}
if ssrf.Enabled() {
for _, ipStr := range dnsResult.Record {
ip := net.ParseIP(ipStr)
if ip != nil && ssrf.IsPrivateIP(ip) {
@@ -229,9 +245,9 @@ func initHttpClient() {
return nil, fmt.Errorf("request to private/internal address is not allowed")
}
}
if len(dnsResult.Record) > 0 {
addr = net.JoinHostPort(dnsResult.Record[0], port)
}
}
if len(dnsResult.Record) > 0 {
addr = net.JoinHostPort(dnsResult.Record[0], port)
}
}
return dialer.DialContext(ctx, network, addr)
@@ -301,13 +317,16 @@ func (b *zstdReader) Close() error {
return b.closeErr
}
func checkWebsite(url string, version string) (*WebsiteCheckDetail, error) {
// checkWebsite 对单个栈做站点拨测;req 为请求级解析配置(dns*/resolve*,零值 = 用配置 DNS)。
func checkWebsite(url string, version string, req webtest.ReqResolve) (*WebsiteCheckDetail, error) {
ctx := context.Background()
var err error
ctx, err = ssrf.ValidateOutboundTarget(ctx, url)
if err != nil {
return nil, err
}
// 请求级解析配置进 ctx:DialContext 与 SSRF 预检同源读取(dns=/dns4=/dns6=、resolve=)
ctx = webtest.WithReqResolve(ctx, req)
client := V4Client
if version == "v6" {
@@ -338,7 +357,7 @@ func checkWebsite(url string, version string) (*WebsiteCheckDetail, error) {
dnsLookupTime := trace.DNSLookup.Seconds() * 1000
if dnsLookupTime == 0 {
dnsLookupTime = measureDNSTime(url, version)
dnsLookupTime = measureDNSTime(url, version, req)
}
tcpConnectTime := trace.TCPConnTime.Seconds() * 1000
httpConnectTime := trace.ConnTime.Seconds() * 1000
@@ -373,13 +392,16 @@ func checkWebsite(url string, version string) (*WebsiteCheckDetail, error) {
return result, nil
}
func websiteSpeed(url string, version string) (*WebsiteSpeedTestResult, error) {
// websiteSpeed 对单个栈做测速拨测;req 为请求级解析配置(零值 = 用配置 DNS)。
func websiteSpeed(url string, version string, req webtest.ReqResolve) (*WebsiteSpeedTestResult, error) {
ctx := context.Background()
var err error
ctx, err = ssrf.ValidateOutboundTarget(ctx, url)
if err != nil {
return nil, err
}
// 请求级解析配置进 ctx:DialContext 与 SSRF 预检同源读取(dns=/dns4=/dns6=、resolve=)
ctx = webtest.WithReqResolve(ctx, req)
client := V4Client
if version == "v6" {
@@ -409,7 +431,7 @@ func websiteSpeed(url string, version string) (*WebsiteSpeedTestResult, error) {
dnsLookupTime := trace.DNSLookup.Seconds() * 1000
if dnsLookupTime == 0 {
dnsLookupTime = measureDNSTime(url, version)
dnsLookupTime = measureDNSTime(url, version, req)
}
tcpConnectTime := trace.TCPConnTime.Seconds() * 1000
httpConnectTime := trace.ConnTime.Seconds() * 1000
@@ -445,7 +467,7 @@ func websiteSpeed(url string, version string) (*WebsiteSpeedTestResult, error) {
return result, nil
}
func measureDNSTime(urlStr string, version string) float64 {
func measureDNSTime(urlStr string, version string, req webtest.ReqResolve) float64 {
parsed, err := url.Parse(urlStr)
if err != nil {
return 0
@@ -454,12 +476,15 @@ func measureDNSTime(urlStr string, version string) float64 {
if host == "" {
return 0
}
// 与拨测同源:走请求级 DNS 覆盖(dns4/dns6),无覆盖用配置 dns-server
servers := req.Servers("ipv4")
start := time.Now()
var dnsErr error
if version == "v6" {
_, dnsErr = webtest.ResolveAAAARecord(host)
servers = req.Servers("ipv6")
_, dnsErr = webtest.ResolveAAAARecord(host, servers)
} else {
_, dnsErr = webtest.ResolveARecord(host)
_, dnsErr = webtest.ResolveARecord(host, servers)
}
if dnsErr != nil {
return 0
@@ -467,13 +492,15 @@ func measureDNSTime(urlStr string, version string) float64 {
return time.Since(start).Seconds() * 1000
}
func checkSSL(url string, version string) (*SSLCheckDetail, error) {
func checkSSL(url string, version string, req webtest.ReqResolve) (*SSLCheckDetail, error) {
ssrfCtx := context.Background()
var err error
ssrfCtx, err = ssrf.ValidateOutboundTarget(ssrfCtx, url)
if err != nil {
return nil, err
}
// 请求级解析配置进 ctx:DialContext 与 SSRF 预检同源读取(dns=/dns4=/dns6=、resolve=)
ssrfCtx = webtest.WithReqResolve(ssrfCtx, req)
client := V4Client
if version == "v6" {
@@ -504,6 +531,7 @@ func checkSSL(url string, version string) (*SSLCheckDetail, error) {
var certStartTime, certEndTime time.Time
var issuerOrganization []string
var issuerCommonName, subjectCommonName, domain string
var san []string
if rawResp.TLS != nil && len(rawResp.TLS.PeerCertificates) > 0 {
cert = rawResp.TLS.PeerCertificates[0]
@@ -516,6 +544,12 @@ func checkSSL(url string, version string) (*SSLCheckDetail, error) {
issuerCommonName = cert.Issuer.CommonName
subjectCommonName = cert.Subject.CommonName
domain = cleanHostRecord(cert.Subject.CommonName)
// SAN:DNS 名在前、IP 在后(IP 站点证书可能只有 IP SAN)
san = make([]string, 0, len(cert.DNSNames)+len(cert.IPAddresses))
san = append(san, cert.DNSNames...)
for _, ip := range cert.IPAddresses {
san = append(san, ip.String())
}
} else {
return nil, fmt.Errorf("no SSL certificate found")
}
@@ -534,6 +568,7 @@ func checkSSL(url string, version string) (*SSLCheckDetail, error) {
IssuerOrganization: issuerOrganization,
IssuerCommonName: issuerCommonName,
SubjectCommonName: subjectCommonName,
SAN: san,
IsReachable: true,
}
@@ -600,19 +635,28 @@ func checkWebsiteHandler(c *gin.Context) {
return
}
if cached, ok := websiteCache.Load(testUrl); ok {
req, err := webtest.ParseReqResolve(func(k string) string { return c.Query(k) })
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 缓存键带上请求级 DNS 覆盖:结果取决于所用服务器,参数不同必须各占一条,
// 否则互相串味;无覆盖时 req.CacheKey() 为空串,键与旧版完全一致。
cacheKey := testUrl + req.CacheKey()
if cached, ok := websiteCache.Load(cacheKey); ok {
entry := cached.(websiteCacheEntry)
if time.Since(entry.timestamp) < 5*time.Minute {
c.JSON(200, entry.result)
return
}
websiteCache.Delete(testUrl)
websiteCache.Delete(cacheKey)
}
result := &WebsiteCheckResult{}
switch SINGLE_STACK {
case "ipv4":
ipv4, errV4 := checkWebsite(testUrl, "v4")
ipv4, errV4 := checkWebsite(testUrl, "v4", req)
if errV4 != nil {
ipv4 = &WebsiteCheckDetail{
HostRecord: "Error: " + errV4.Error(),
@@ -625,7 +669,7 @@ func checkWebsiteHandler(c *gin.Context) {
IsReachable: false,
}
case "ipv6":
ipv6, errV6 := checkWebsite(testUrl, "v6")
ipv6, errV6 := checkWebsite(testUrl, "v6", req)
if errV6 != nil {
ipv6 = &WebsiteCheckDetail{
HostRecord: "Error: " + errV6.Error(),
@@ -643,7 +687,7 @@ func checkWebsiteHandler(c *gin.Context) {
go func() {
defer wg.Done()
ipv6, errV6 := checkWebsite(testUrl, "v6")
ipv6, errV6 := checkWebsite(testUrl, "v6", req)
if errV6 != nil {
ipv6 = &WebsiteCheckDetail{
HostRecord: "Error: " + errV6.Error(),
@@ -655,7 +699,7 @@ func checkWebsiteHandler(c *gin.Context) {
go func() {
defer wg.Done()
ipv4, errV4 := checkWebsite(testUrl, "v4")
ipv4, errV4 := checkWebsite(testUrl, "v4", req)
if errV4 != nil {
ipv4 = &WebsiteCheckDetail{
HostRecord: "Error: " + errV4.Error(),
@@ -667,14 +711,14 @@ func checkWebsiteHandler(c *gin.Context) {
wg.Wait()
}
websiteCache.Store(testUrl, websiteCacheEntry{result: result, timestamp: time.Now()})
websiteCache.Store(cacheKey, websiteCacheEntry{result: result, timestamp: time.Now()})
// If both IPv4 and IPv6 fail, only cache for 30 seconds
// 如果 IPv4 和 IPv6 都失败,只缓存30秒
if (result.IPv4 != nil && !result.IPv4.IsReachable) && (result.IPv6 != nil && !result.IPv6.IsReachable) {
go func() {
time.Sleep(30 * time.Second)
websiteCache.Delete(testUrl)
websiteCache.Delete(cacheKey)
}()
}
@@ -701,19 +745,27 @@ func sslCheckHandler(c *gin.Context) {
return
}
if cached, ok := sslCache.Load(testUrl); ok {
req, err := webtest.ParseReqResolve(func(k string) string { return c.Query(k) })
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 缓存键带上请求级 DNS 覆盖(结果取决于所用服务器,参数不同不能共用)
cacheKey := testUrl + req.CacheKey()
if cached, ok := sslCache.Load(cacheKey); ok {
entry := cached.(sslCacheEntry)
if time.Since(entry.timestamp) < 5*time.Minute {
c.JSON(200, entry.result)
return
}
sslCache.Delete(testUrl)
sslCache.Delete(cacheKey)
}
result := &SSLCheckResult{}
switch SINGLE_STACK {
case "ipv4":
ipv4, errV4 := checkSSL(testUrl, "v4")
ipv4, errV4 := checkSSL(testUrl, "v4", req)
if errV4 != nil {
ipv4 = &SSLCheckDetail{
HostRecord: "Error: " + errV4.Error(),
@@ -726,7 +778,7 @@ func sslCheckHandler(c *gin.Context) {
IsExpired: true,
}
case "ipv6":
ipv6, errV6 := checkSSL(testUrl, "v6")
ipv6, errV6 := checkSSL(testUrl, "v6", req)
if errV6 != nil {
ipv6 = &SSLCheckDetail{
HostRecord: "Error: " + errV6.Error(),
@@ -745,7 +797,7 @@ func sslCheckHandler(c *gin.Context) {
go func() {
defer wg.Done()
ipv6, errV6 := checkSSL(testUrl, "v6")
ipv6, errV6 := checkSSL(testUrl, "v6", req)
if errV6 != nil {
ipv6 = &SSLCheckDetail{
HostRecord: "Error: " + errV6.Error(),
@@ -757,7 +809,7 @@ func sslCheckHandler(c *gin.Context) {
go func() {
defer wg.Done()
ipv4, errV4 := checkSSL(testUrl, "v4")
ipv4, errV4 := checkSSL(testUrl, "v4", req)
if errV4 != nil {
ipv4 = &SSLCheckDetail{
HostRecord: "Error: " + errV4.Error(),
@@ -770,13 +822,13 @@ func sslCheckHandler(c *gin.Context) {
wg.Wait()
}
sslCache.Store(testUrl, sslCacheEntry{result: result, timestamp: time.Now()})
sslCache.Store(cacheKey, sslCacheEntry{result: result, timestamp: time.Now()})
// 如果 IPv4 和 IPv6 都失败,只缓存30秒
if (result.IPv4 != nil && !result.IPv4.IsReachable) && (result.IPv6 != nil && !result.IPv6.IsReachable) {
go func() {
time.Sleep(30 * time.Second)
sslCache.Delete(testUrl)
sslCache.Delete(cacheKey)
}()
}
@@ -796,6 +848,13 @@ func websiteSpeedTestHandler(c *gin.Context) {
}
url := normalizeURL(testUrl)
req, err := webtest.ParseReqResolve(func(k string) string { return c.Query(k) })
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 检查请求版本是否与 SINGLE_STACK 配置匹配
switch SINGLE_STACK {
case "ipv4":
if version != "v4" {
@@ -815,7 +874,8 @@ func websiteSpeedTestHandler(c *gin.Context) {
}
}
cacheKey := fmt.Sprintf("%s:%s", url, version)
// 缓存键:URL + 版本 + 请求级 DNS 覆盖(结果取决于所用服务器,参数不同不能共用)
cacheKey := fmt.Sprintf("%s:%s%s", url, version, req.CacheKey())
if cached, ok := speedCache.Load(cacheKey); ok {
entry := cached.(speedCacheEntry)
@@ -828,9 +888,9 @@ func websiteSpeedTestHandler(c *gin.Context) {
switch version {
case "v6":
result, err = websiteSpeed(url, "v6")
result, err = websiteSpeed(url, "v6", req)
case "v4":
result, err = websiteSpeed(url, "v4")
result, err = websiteSpeed(url, "v4", req)
default:
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid version",
@@ -868,93 +928,35 @@ func dnsQueryHandler(c *gin.Context) {
domain = parsedURL.Host
recodeType := c.Param("type")
switch recodeType {
case "a":
result, err := webtest.ResolveARecord(domain)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, result)
case "aaaa":
result, err := webtest.ResolveAAAARecord(domain)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, result)
case "cname":
result, err := webtest.ResolveCNAMERecord(domain)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, result)
case "mx":
result, err := webtest.ResolveMXRecord(domain)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, result)
case "ns":
result, err := webtest.ResolveNSRecord(domain)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, result)
case "ptr":
result, err := webtest.ResolvePTRRecord(domain)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, result)
case "srv":
result, err := webtest.ResolveSRVRecord(domain)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, result)
case "txt":
result, err := webtest.ResolveTXTRecord(domain)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, result)
case "caa":
result, err := webtest.ResolveCAARecord(domain)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, result)
case "a", "aaaa", "cname", "mx", "ns", "ptr", "srv", "txt", "caa":
default:
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid record type",
})
return
}
req, err := webtest.ParseReqResolve(func(k string) string { return c.Query(k) })
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 按 qtype 选栈:a→dns4|dns,aaaa→dns6|dns,其余→dns
stack := ""
if recodeType == "a" {
stack = "ipv4"
} else if recodeType == "aaaa" {
stack = "ipv6"
}
result, err := webtest.ResolveByType(domain, recodeType, req.Servers(stack))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, result)
}
func pingHandler(c *gin.Context) {
@@ -982,7 +984,15 @@ func pingHandler(c *gin.Context) {
count = n
}
cacheKey := fmt.Sprintf("%s:%s:%d", host, port, count)
req, err := webtest.ParseReqResolve(func(k string) string { return c.Query(k) })
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 缓存键带上请求级 DNS 覆盖:不同 dns=/resolve= 的结果不能互相污染;
// 无覆盖时 req.CacheKey() 为空串,键与旧版完全一致。
cacheKey := fmt.Sprintf("%s:%s:%d%s", host, port, count, req.CacheKey())
if cached, ok := pingCache.Load(cacheKey); ok {
entry := cached.(pingCacheEntry)
if time.Since(entry.timestamp) < 5*time.Minute {
@@ -996,7 +1006,7 @@ func pingHandler(c *gin.Context) {
switch SINGLE_STACK {
case "ipv4":
ipv4, errV4 := webtest.TCPingRun(host, port, count, "v4", 10*time.Second, 100*time.Millisecond)
ipv4, errV4 := webtest.TCPingRunWith(host, port, count, "v4", 10*time.Second, 100*time.Millisecond, req)
if errV4 != nil {
ipv4 = &webtest.TCPingStats{
IP: "Error: " + errV4.Error(),
@@ -1007,7 +1017,7 @@ func pingHandler(c *gin.Context) {
IP: "Skipped due to SINGLE_STACK=ipv4",
}
case "ipv6":
ipv6, errV6 := webtest.TCPingRun(host, port, count, "v6", 10*time.Second, 100*time.Millisecond)
ipv6, errV6 := webtest.TCPingRunWith(host, port, count, "v6", 10*time.Second, 100*time.Millisecond, req)
if errV6 != nil {
ipv6 = &webtest.TCPingStats{
IP: "Error: " + errV6.Error(),
@@ -1023,7 +1033,7 @@ func pingHandler(c *gin.Context) {
go func() {
defer wg.Done()
ipv6, errV6 := webtest.TCPingRun(host, port, count, "v6", 10*time.Second, 100*time.Millisecond)
ipv6, errV6 := webtest.TCPingRunWith(host, port, count, "v6", 10*time.Second, 100*time.Millisecond, req)
if errV6 != nil {
ipv6 = &webtest.TCPingStats{
IP: "Error: " + errV6.Error(),
@@ -1034,7 +1044,7 @@ func pingHandler(c *gin.Context) {
go func() {
defer wg.Done()
ipv4, errV4 := webtest.TCPingRun(host, port, count, "v4", 10*time.Second, 100*time.Millisecond)
ipv4, errV4 := webtest.TCPingRunWith(host, port, count, "v4", 10*time.Second, 100*time.Millisecond, req)
if errV4 != nil {
ipv4 = &webtest.TCPingStats{
IP: "Error: " + errV4.Error(),
@@ -1315,6 +1325,9 @@ func main() {
initHttpClient()
webtest.SetDNSServer(DNS_SERVER)
webtest.SetDNSSecServer(DNSSEC_DNS_SERVER)
// SSRF 预检改走 webtest 的自定义 DNS(含请求级 dns= 覆盖),
// 避免「预检用系统解析器、拨号用自定义 DNS」两套解析器口径不一
webtest.InstallSSRFResolver()
slog.Info("Starting server", "port", PORTS, "single_stack", SINGLE_STACK)
// 数据上报:周期把本节点的统计与拨测明细上报到收集中心(详见 report.go)
@@ -45,6 +45,23 @@ func SetEnabled(v bool) {
blockPrivateIPs = v
}
// ResolveFunc 把主机名解析为 IP 列表。默认 net.LookupIP(系统解析器)。
// 可由上层(webtest)通过 SetResolveFunc 注入,使出站校验与真正的拨号走同一个 DNS
// ——包括请求级 DNS 服务器覆盖。否则预检(系统解析器)与拨号(自定义 DNS)会用两套解析器,
// 既多一次查询,结果也可能不一致。
type ResolveFunc func(ctx context.Context, host string) ([]net.IP, error)
var resolveHostFn ResolveFunc = func(_ context.Context, host string) ([]net.IP, error) {
return net.LookupIP(host)
}
// SetResolveFunc 注入自定义解析器(传 nil 忽略,保持默认 net.LookupIP)。
func SetResolveFunc(f ResolveFunc) {
if f != nil {
resolveHostFn = f
}
}
// Go 标准库 IsPrivate 只覆盖 RFC1918 与 fd00::/8,这里补齐其余不应出网的内部/特殊网段
var extraPrivateNets = mustParseCIDRs(
"100.64.0.0/10", // CGNAT:运营商级 NAT、云内网、Tailscale 等
@@ -99,7 +116,7 @@ func IsPrivateIP(ip net.IP) bool {
// Used in handlers to short-circuit requests to internal hosts before making outbound connections.
// 用于在发起出站连接前,在处理器中短路对内部主机的请求。
func HasLocalOrPrivateIP(host string) bool {
ips, err := net.LookupIP(host)
ips, err := resolveHostFn(context.Background(), host)
if err != nil {
return false
}
@@ -142,7 +159,7 @@ func ValidateOutboundTarget(ctx context.Context, targetURL string) (context.Cont
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return ctx, fmt.Errorf("invalid scheme: %s", parsed.Scheme)
}
ips, err := net.LookupIP(host)
ips, err := resolveHostFn(ctx, host)
if err != nil {
return ctx, err
}
@@ -179,7 +196,7 @@ func SecureCheckRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
ips, err := net.LookupIP(host)
ips, err := resolveHostFn(req.Context(), host)
if err != nil {
return err
}
+332 -94
View File
@@ -3,17 +3,21 @@ package webtest
// EO(EdgeOne)特供版的 DNS 模块:与主线 ipw-cn/src/webtest/dns.go 同源,
// 但保留 EO 部署形态的两点差异:
// 1. DoH / UDP 双通道互备(单地址配置下主通道失败自动回退另一通道);
// 2. 查询耗时沿调用链透出(float64 毫秒),ResolveIP 供 tcping / speed 复用。
// 主线对 dns.go 的行为修正(Rcode 透出、服务器地址格式校验)已同步至此。
// 2. 查询耗时沿调用链透出(float64 毫秒),供上层(tcping 的 ResolveHostWith 等)复用。
// 主线对 dns.go 的行为修正与新增能力已同步至此:Rcode 透出、服务器地址格式校验、
// 请求级解析配置(dns=/dns4=/dns6=、resolve=/resolve4=/resolve6=,经 context 透传)、
// ResolveByType 统一分发、各 Resolve*Record 的 servers 参数化。
import (
"bytes"
"context"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
@@ -34,25 +38,14 @@ var dnsMode = "doh"
// dnsServers 普通 DNS 查询服务器列表(主从:第一个为主,主失败自动切换后续从服务器)
var dnsServers []string
// dnssecServers DNSSEC 专用服务器列表(未配置时沿用 dnsServers)
var dnssecServers []string
// dnssecServers DNSSEC 专用服务器列表:默认内置实测支持 DO 位的境外解析器
//(国内公共 DNS 带 DO 也不返回 RRSIG,普通解析回退会让验签永远失败,见 dnssec.go)。
// 网络屏蔽/劫持这些 UDP 53 时请配置 dnssec-server 指向可达的解析器。
var dnssecServers = defaultDOCapableServers
// defaultUDPServer 兜底 UDP 服务器
const defaultUDPServer = "119.28.28.28:53"
// DNSResult 统一的 DNS 查询结果格式。
//
// Rcode / RcodeText 与主线及各节点保持一致:解析器明确应答 NXDOMAIN 时,属于「域名不存在」
// 这个业务结论,而不是节点故障 —— 前端据此区分两者,别把负响应误显示成拨测失败。
type DNSResult struct {
Domain string `json:"domain"`
Duration float64 `json:"duration"`
Record []string `json:"record"`
TTL uint32 `json:"ttl"`
Rcode int `json:"rcode"` // 解析器应答的 Rcode(0=Success, 3=NXDOMAIN)
RcodeText string `json:"rcode_text"` // Rcode 可读名称,如 NXDOMAIN / SERVFAIL
}
// SetDNSServer 设置 DNS 服务器:支持逗号分隔多地址主从 failover(第一个为主,主失败自动切换后续从服务器)。
// 每项支持 "ip:port"(UDP)或 DoH URL http(s)://...;单地址时保持原有语义。
func SetDNSServer(server string) {
@@ -100,7 +93,10 @@ func splitServers(s string) []string {
}
// validServers 过滤掉格式非法的地址(保留原顺序)。
// 全部非法时返回空列表,调用方据此保留原有配置。
//
// dns-server / dnssec-server 既能由本地管理面 PATCH 改,也能由远端托管配置下发 —— 都是外部可控输入。
// 在这里先挡掉畸形值,总比留到查询时才以"莫名其妙的超时/报错"暴露好排查。
// 全部非法时返回空列表,调用方据此保留原有配置(与"空串不覆盖"的既有语义一致)。
func validServers(list []string) []string {
out := make([]string, 0, len(list))
for _, s := range list {
@@ -143,6 +139,227 @@ func validateServerAddr(server string) error {
return nil
}
// NormalizeServers 解析「逗号分隔的 DNS 服务器」字符串并做格式校验,规则与配置侧一致:
// 每项为 "ip:port"(UDP/TCP)或 http(s):// DoH URL;非法项丢弃(记 Warn)。全非法时返回空。
// 供请求级参数(dns / dns4 / dns6)复用,避免两套校验漂移。
func NormalizeServers(s string) []string {
return validServers(splitServers(s))
}
// ---- 请求级解析配置:dns*/resolve* ----
//
// dns=1.1.1.1:53 两栈都用这个服务器(换服务器去问)
// dns4=1.1.1.1:53,dns6=[2400::1]:53 按栈覆盖(优先于 dns=)
// resolve=example.com:1.2.3.4 两栈都把 example.com 解析成 1.2.3.4(直接改答案)
// resolve4=example.com:1.2.3.4 只 IPv4 侧生效(优先于 resolve=)
// resolve6=example.com:[::1] 只 IPv6 侧生效(优先于 resolve=)
// resolve=www.example.com:origin.net 目标是域名时把它当作新的 host 再解析
//
// 只在「域名 → IP」这一步生效:detail/ssl/speed(经 DialContext)、tcping、SSRF 预检;
// whois 与 dns 拨测不接 resolve*(前者只做归属查询,后者问的就是真实解析)。
//
// 为什么走 context:HTTP 拨测的解析发生在 net/http 的 DialContext 里,那里只有 ctx、
// 没有直接入参;存进 ctx 后 DialContext 与 SSRF 预检便能同源读到同一份配置。
// ReqResolve 一次拨测的请求级解析配置。零值 = 没有任何请求级覆盖。
type ReqResolve struct {
dns []string // dns=
dns4 []string // dns4=
dns6 []string // dns6=
resolve map[string]string // resolve=:域名 → IP 或另一域名
resolve4 map[string]string // resolve4=
resolve6 map[string]string // resolve6=
}
// ParseReqResolve 从 query 解析全部 9 个参数(dns/dns4/dns6、resolve/resolve4/resolve6)。
// 参数存在但**全部项非法**时报错(handler 返 400),避免「看起来指定了、实际被静默忽略」;
// 缺省/为空则视为不覆盖。
func ParseReqResolve(get func(string) string) (ReqResolve, error) {
var out ReqResolve
// DNS 服务器:逗号分隔的 ip:port 或 DoH URL
for _, item := range []struct {
key string
dst *[]string
}{
{"dns", &out.dns}, {"dns4", &out.dns4}, {"dns6", &out.dns6},
} {
if raw := strings.TrimSpace(get(item.key)); raw != "" {
if list := NormalizeServers(raw); len(list) > 0 {
*item.dst = list
} else {
return ReqResolve{}, fmt.Errorf("%s must be a comma-separated list of ip:port or DoH URLs", item.key)
}
}
}
// 强制解析:逗号分隔的 域名:IP 或 域名:域名(后者按新 host 再解析)
for _, item := range []struct {
key string
dst *map[string]string
}{
{"resolve", &out.resolve}, {"resolve4", &out.resolve4}, {"resolve6", &out.resolve6},
} {
if raw := strings.TrimSpace(get(item.key)); raw != "" {
if m := parseResolveMap(raw); len(m) > 0 {
*item.dst = m
} else {
return ReqResolve{}, fmt.Errorf("%s must be a comma-separated list of host:ip or host:cname", item.key)
}
}
}
return out, nil
}
// parseResolveMap 把「域名:目标,域名:目标」切成映射表,非法项丢弃。
// 每组按**第一个冒号**切:域名不含冒号,IPv6 目标可带或不带方括号。
func parseResolveMap(s string) map[string]string {
out := map[string]string{}
for _, item := range strings.Split(s, ",") {
item = strings.TrimSpace(item)
idx := strings.Index(item, ":")
if idx <= 0 || idx == len(item)-1 {
continue
}
host, target := canonicalHost(item[:idx]), strings.TrimSpace(item[idx+1:])
if host == "" || strings.ContainsAny(host, " /\\") || !validResolveTarget(target) {
continue
}
out[host] = target
}
return out
}
// canonicalHost 归一化主机名:去首尾空白、转小写、去结尾的点,便于与查询域名匹配。
func canonicalHost(host string) string {
return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
}
// validResolveTarget 目标必须是 IP 或形如域名的字符串(含点、无空格、无端口)。
func validResolveTarget(target string) bool {
t := strings.Trim(target, "[]")
if net.ParseIP(t) != nil {
return true
}
// 非 IP 却含冒号:视为「带端口」的误用,丢弃(映射不需要端口)
if strings.Contains(t, ":") || !strings.Contains(t, ".") || strings.ContainsAny(t, " /\\") {
return false
}
for _, r := range t {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' ||
r == '.' || r == '-' || r == '_' || r == '*' {
continue
}
return false
}
return true
}
// Empty 无任何请求级覆盖(此时才允许走共享缓存)。
func (r ReqResolve) Empty() bool {
return len(r.dns)+len(r.dns4)+len(r.dns6)+len(r.resolve)+len(r.resolve4)+len(r.resolve6) == 0
}
// Servers 按栈名取本次生效的 DNS 服务器:stack 为 "ipv4"/"ipv6" 时优先取对应覆盖,
// 其余(含空串)取 dns= 默认;最终为 nil 表示用配置 dns-server。
// 供 whois、dns 拨测等按记录类型选栈的调用方使用。
func (r ReqResolve) Servers(stack string) []string {
switch stack {
case "ipv4":
if len(r.dns4) > 0 {
return r.dns4
}
case "ipv6":
if len(r.dns6) > 0 {
return r.dns6
}
}
return r.dns
}
// Override 返回该域名在指定族下的强制解析目标;"" 表示未命中(照常走 DNS)。
// 优先级与 dns* 对称:resolve4/resolve6 非空时覆盖 resolve。
// 目标是 IP 还是域名由调用方判断——IP 直连、域名当作新 host 再解析。
func (r ReqResolve) Override(host string, wantV6 bool) string {
m := r.resolve
if wantV6 && len(r.resolve6) > 0 {
m = r.resolve6
} else if !wantV6 && len(r.resolve4) > 0 {
m = r.resolve4
}
return m[canonicalHost(host)]
}
// CacheKey 返回请求级配置的规范化片段,拼在拨测缓存键末尾。
// 零值返回空串(缓存键与「没有任何请求级参数」时完全一致);
// 否则形如 ";dns=1.1.1.1:53,8.8.8.8:53;resolve6=example.com:[::1]"。
//
// 逐字段拼接而不是 join 结构体:字段名与顺序由这里固定,避免参数书写顺序
// (dns= 在前还是 dns6= 在前)把同一个请求拆成两条缓存。
func (r ReqResolve) CacheKey() string {
var b strings.Builder
for _, f := range []struct {
name string
vals []string
}{
{"dns", r.dns}, {"dns4", r.dns4}, {"dns6", r.dns6},
} {
if len(f.vals) == 0 {
continue
}
b.WriteString(";")
b.WriteString(f.name)
b.WriteString("=")
b.WriteString(strings.Join(f.vals, ","))
}
for _, f := range []struct {
name string
vals map[string]string
}{
{"resolve", r.resolve}, {"resolve4", r.resolve4}, {"resolve6", r.resolve6},
} {
if len(f.vals) == 0 {
continue
}
// map 迭代顺序随机:必须按键排序,否则同一请求每次拼出的键都不同,缓存永远命不中
keys := make([]string, 0, len(f.vals))
for k := range f.vals {
keys = append(keys, k)
}
sort.Strings(keys)
pairs := make([]string, 0, len(keys))
for _, k := range keys {
pairs = append(pairs, k+":"+f.vals[k])
}
b.WriteString(";")
b.WriteString(f.name)
b.WriteString("=")
b.WriteString(strings.Join(pairs, ","))
}
return b.String()
}
// reqResolveCtxKey 请求级解析配置在 context 中的键。
type reqResolveCtxKey struct{}
// WithReqResolve 把请求级解析配置写进 context(空配置原样返回,零开销)。
func WithReqResolve(ctx context.Context, r ReqResolve) context.Context {
if r.Empty() {
return ctx
}
return context.WithValue(ctx, reqResolveCtxKey{}, r)
}
// ReqResolveFromContext 取出请求级解析配置(零值 = 无覆盖)。
func ReqResolveFromContext(ctx context.Context) ReqResolve {
if ctx == nil {
return ReqResolve{}
}
if v, ok := ctx.Value(reqResolveCtxKey{}).(ReqResolve); ok {
return v
}
return ReqResolve{}
}
// ==========================================
// ⭐️ 底层核心:DoH + UDP 双通道 DNS 查询
// ==========================================
@@ -180,6 +397,15 @@ func queryDNSSECMsg(msg *dns.Msg) (*dns.Msg, float64, error) {
return queryDNSMsg(msg)
}
// queryOne 向单个 DNS 服务器查询:配置为 URL(http/https)走 DoH,否则走 UDP
//(DNSSEC 的 DO 能力探测按服务器逐台试探,需要单发入口)
func queryOne(msg *dns.Msg, server string) (*dns.Msg, float64, error) {
if strings.HasPrefix(server, "http://") || strings.HasPrefix(server, "https://") {
return queryDoHMsg(msg, server)
}
return queryUDPMsg(msg, server)
}
// queryWithServers 依次尝试服务器列表,主失败切从;每个服务器按类型走 DoH 或 UDP
func queryWithServers(msg *dns.Msg, servers []string) (*dns.Msg, float64, error) {
if len(servers) == 0 {
@@ -187,14 +413,7 @@ func queryWithServers(msg *dns.Msg, servers []string) (*dns.Msg, float64, error)
}
var lastErr error
for i, srv := range servers {
var resp *dns.Msg
var dur float64
var err error
if strings.HasPrefix(srv, "http://") || strings.HasPrefix(srv, "https://") {
resp, dur, err = queryDoHMsg(msg, srv)
} else {
resp, dur, err = queryUDPMsg(msg, srv)
}
resp, dur, err := queryOne(msg, srv)
if err == nil {
return resp, dur, nil
}
@@ -275,13 +494,41 @@ func executeDoHQuery(domain string, qtype uint16) (*dns.Msg, float64, error) {
return queryDNSMsg(msg)
}
// DNSResult 统一的 DNS 查询结果格式。
//
// Rcode / RcodeText 与主线及各节点保持一致:解析器明确应答 NXDOMAIN 时,属于「域名不存在」
// 这个业务结论,而不是节点故障 —— 前端据此区分两者,别把负响应误显示成拨测失败。
type DNSResult struct {
Domain string `json:"domain"`
Duration float64 `json:"duration"`
Record []string `json:"record"`
TTL uint32 `json:"ttl"`
Rcode int `json:"rcode"` // 解析器应答的 Rcode(0=Success, 3=NXDOMAIN)
RcodeText string `json:"rcode_text"` // Rcode 可读名称,如 NXDOMAIN / SERVFAIL
}
// resolveTyped 通用记录查询:成功收到解析器应答(即便 Rcode 非 Success,如 NXDOMAIN/SERVFAIL)
// 即视为一次有效查询,将 Rcode 透出并返回 200;仅在传输/网络层失败(超时、服务器不可达、
// DoH 异常)时才返回 error(上层据此返回 500)。避免把合法的负响应误判为节点故障。
// extract 从一个 RR 中抽取记录字符串(TXT 可能含多条),返回 (values, ttl, matched)。
func resolveTyped(domain, name string, qtype uint16, extract func(dns.RR) ([]string, uint32, bool)) (DNSResult, error) {
// servers 为请求级 DNS 覆盖:非空时用它(queryWithServers),为空则用配置 dns-server
//(EO:配置列表也为空时回退 DoH/UDP 双通道互备,见 queryDNSMsg)。
func resolveTyped(domain, name string, qtype uint16, servers []string, extract func(dns.RR) ([]string, uint32, bool)) (DNSResult, error) {
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(name), qtype)
// EO 保留:主线不显式设 RD;EO 双通道历史上始终请求递归解析,改动会影响各边缘节点
msg.RecursionDesired = true
result := DNSResult{Domain: domain, Record: []string{}}
response, duration, err := executeDoHQuery(name, qtype)
// 请求级 servers 优先;未传(nil/空)走配置 dns-server。
var response *dns.Msg
var duration float64
var err error
if len(servers) > 0 {
response, duration, err = queryWithServers(msg, servers)
} else {
response, duration, err = queryDNSMsg(msg)
}
result.Duration = duration
if err != nil {
slog.Warn("Failed to query DNS", "domain", domain, "qtype", qtype, "error", err)
@@ -302,8 +549,13 @@ func resolveTyped(domain, name string, qtype uint16, extract func(dns.RR) ([]str
return result, nil
}
func ResolveARecord(domain string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeA, func(rr dns.RR) ([]string, uint32, bool) {
// ---- 记录解析:每类一个函数,末个参数 servers 为「请求级 DNS 覆盖」 ----
//
// servers 为空 → 走 resolveTyped 的 queryDNSMsg(配置 dns-server,EO 未配置时双通道互备);
// 非空 → queryWithServers(请求级覆盖)。
func ResolveARecord(domain string, servers []string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeA, servers, func(rr dns.RR) ([]string, uint32, bool) {
if a, ok := rr.(*dns.A); ok {
return []string{a.A.String()}, a.Header().Ttl, true
}
@@ -311,8 +563,8 @@ func ResolveARecord(domain string) (DNSResult, error) {
})
}
func ResolveAAAARecord(domain string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeAAAA, func(rr dns.RR) ([]string, uint32, bool) {
func ResolveAAAARecord(domain string, servers []string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeAAAA, servers, func(rr dns.RR) ([]string, uint32, bool) {
if a, ok := rr.(*dns.AAAA); ok {
return []string{a.AAAA.String()}, a.Header().Ttl, true
}
@@ -320,8 +572,8 @@ func ResolveAAAARecord(domain string) (DNSResult, error) {
})
}
func ResolveTXTRecord(domain string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeTXT, func(rr dns.RR) ([]string, uint32, bool) {
func ResolveTXTRecord(domain string, servers []string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeTXT, servers, func(rr dns.RR) ([]string, uint32, bool) {
if txt, ok := rr.(*dns.TXT); ok {
// TXT 一条 RR 可能含多段字符串,保持原有「逐段单独成项」的行为
return txt.Txt, txt.Header().Ttl, true
@@ -330,8 +582,8 @@ func ResolveTXTRecord(domain string) (DNSResult, error) {
})
}
func ResolveNSRecord(domain string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeNS, func(rr dns.RR) ([]string, uint32, bool) {
func ResolveNSRecord(domain string, servers []string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeNS, servers, func(rr dns.RR) ([]string, uint32, bool) {
if ns, ok := rr.(*dns.NS); ok {
return []string{ns.Ns}, ns.Header().Ttl, true
}
@@ -339,8 +591,8 @@ func ResolveNSRecord(domain string) (DNSResult, error) {
})
}
func ResolveCNAMERecord(domain string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeCNAME, func(rr dns.RR) ([]string, uint32, bool) {
func ResolveCNAMERecord(domain string, servers []string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeCNAME, servers, func(rr dns.RR) ([]string, uint32, bool) {
if cname, ok := rr.(*dns.CNAME); ok {
return []string{cname.Target}, cname.Header().Ttl, true
}
@@ -348,8 +600,8 @@ func ResolveCNAMERecord(domain string) (DNSResult, error) {
})
}
func ResolveMXRecord(domain string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeMX, func(rr dns.RR) ([]string, uint32, bool) {
func ResolveMXRecord(domain string, servers []string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeMX, servers, func(rr dns.RR) ([]string, uint32, bool) {
if mx, ok := rr.(*dns.MX); ok {
return []string{mx.Mx}, mx.Header().Ttl, true
}
@@ -357,8 +609,8 @@ func ResolveMXRecord(domain string) (DNSResult, error) {
})
}
func ResolveSRVRecord(domain string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeSRV, func(rr dns.RR) ([]string, uint32, bool) {
func ResolveSRVRecord(domain string, servers []string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeSRV, servers, func(rr dns.RR) ([]string, uint32, bool) {
if srv, ok := rr.(*dns.SRV); ok {
return []string{srv.Target}, srv.Header().Ttl, true
}
@@ -366,8 +618,8 @@ func ResolveSRVRecord(domain string) (DNSResult, error) {
})
}
func ResolveCAARecord(domain string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeCAA, func(rr dns.RR) ([]string, uint32, bool) {
func ResolveCAARecord(domain string, servers []string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeCAA, servers, func(rr dns.RR) ([]string, uint32, bool) {
if caa, ok := rr.(*dns.CAA); ok {
return []string{caa.Value}, caa.Header().Ttl, true
}
@@ -376,13 +628,13 @@ func ResolveCAARecord(domain string) (DNSResult, error) {
}
// ResolvePTRRecord 反查 IP 的 PTR 记录(返回的 Domain 保持为原始 IP,便于前端直接展示)
func ResolvePTRRecord(ip string) (DNSResult, error) {
func ResolvePTRRecord(ip string, servers []string) (DNSResult, error) {
ptrName, err := dns.ReverseAddr(ip)
if err != nil {
slog.Warn("Invalid IP address for PTR query", "ip", ip, "error", err)
return DNSResult{Domain: ip, Record: []string{}}, fmt.Errorf("invalid IP address: %v", err)
}
return resolveTyped(ip, ptrName, dns.TypePTR, func(rr dns.RR) ([]string, uint32, bool) {
return resolveTyped(ip, ptrName, dns.TypePTR, servers, func(rr dns.RR) ([]string, uint32, bool) {
if ptr, ok := rr.(*dns.PTR); ok {
return []string{ptr.Ptr}, ptr.Header().Ttl, true
}
@@ -390,46 +642,30 @@ func ResolvePTRRecord(ip string) (DNSResult, error) {
})
}
// ResolveIP 通过双通道解析域名,返回指定版本(v4/v6)的 IP 地址字符串。
// EO 特供:供 tcping / speed 等需要"先把 host 归一化成 IP"的拨测复用。
func ResolveIP(host string, version string) (string, error) {
if ip := net.ParseIP(host); ip != nil {
if version == "v4" && ip.To4() != nil {
return ip.String(), nil
}
if version == "v6" && ip.To4() == nil && ip.To16() != nil {
return ip.String(), nil
}
return "", fmt.Errorf("no %s address found for %s", version, host)
// ResolveByType 按记录类型名(a/aaaa/cname/mx/ns/txt/srv/ptr/caa)解析,servers 为请求级覆盖。
// DNS 拨测的 HTTP 路由统一走这里,与主线保持同构,避免 handler 各维护一套 switch。
func ResolveByType(domain, recodeType string, servers []string) (DNSResult, error) {
switch recodeType {
case "a":
return ResolveARecord(domain, servers)
case "aaaa":
return ResolveAAAARecord(domain, servers)
case "cname":
return ResolveCNAMERecord(domain, servers)
case "mx":
return ResolveMXRecord(domain, servers)
case "ns":
return ResolveNSRecord(domain, servers)
case "srv":
return ResolveSRVRecord(domain, servers)
case "txt":
return ResolveTXTRecord(domain, servers)
case "ptr":
return ResolvePTRRecord(domain, servers)
case "caa":
return ResolveCAARecord(domain, servers)
}
var qtype uint16
switch version {
case "v6":
qtype = dns.TypeAAAA
default:
qtype = dns.TypeA
}
responseMsg, _, err := executeDoHQuery(host, qtype)
if err != nil {
return "", err
}
for _, ans := range responseMsg.Answer {
switch v := ans.(type) {
case *dns.A:
if qtype == dns.TypeA {
return v.A.String(), nil
}
case *dns.AAAA:
if qtype == dns.TypeAAAA {
return v.AAAA.String(), nil
}
}
}
return "", fmt.Errorf("no %s record found for %s", version, host)
return DNSResult{}, fmt.Errorf("unsupported record type: %s", recodeType)
}
// ==========================================
@@ -448,19 +684,21 @@ type DNSFullResult struct {
CAA DNSResult `json:"caa"`
}
func ResolveARecordllDNSRecords(domain string) DNSFullResult {
// ResolveARecordllDNSRecords 并行查询所有主流DNS记录类型(不包含PTR)。
// servers 为请求级 DNS 覆盖(空则用配置 dns-server)。
func ResolveARecordllDNSRecords(domain string, servers []string) DNSFullResult {
result := DNSFullResult{Domain: domain}
var wg sync.WaitGroup
wg.Add(8)
go func() { defer wg.Done(); result.A, _ = ResolveARecord(domain) }()
go func() { defer wg.Done(); result.AAAA, _ = ResolveAAAARecord(domain) }()
go func() { defer wg.Done(); result.CNAME, _ = ResolveCNAMERecord(domain) }()
go func() { defer wg.Done(); result.MX, _ = ResolveMXRecord(domain) }()
go func() { defer wg.Done(); result.NS, _ = ResolveNSRecord(domain) }()
go func() { defer wg.Done(); result.TXT, _ = ResolveTXTRecord(domain) }()
go func() { defer wg.Done(); result.SRV, _ = ResolveSRVRecord(domain) }()
go func() { defer wg.Done(); result.CAA, _ = ResolveCAARecord(domain) }()
go func() { defer wg.Done(); result.A, _ = ResolveARecord(domain, servers) }()
go func() { defer wg.Done(); result.AAAA, _ = ResolveAAAARecord(domain, servers) }()
go func() { defer wg.Done(); result.CNAME, _ = ResolveCNAMERecord(domain, servers) }()
go func() { defer wg.Done(); result.MX, _ = ResolveMXRecord(domain, servers) }()
go func() { defer wg.Done(); result.NS, _ = ResolveNSRecord(domain, servers) }()
go func() { defer wg.Done(); result.TXT, _ = ResolveTXTRecord(domain, servers) }()
go func() { defer wg.Done(); result.SRV, _ = ResolveSRVRecord(domain, servers) }()
go func() { defer wg.Done(); result.CAA, _ = ResolveCAARecord(domain, servers) }()
wg.Wait()
return result
@@ -3,24 +3,54 @@ package webtest
import (
"fmt"
"strings"
"sync"
"time"
"github.com/miekg/dns"
)
// —— DNSSEC 默认解析服务器(与主线 ipw-cn/src/webtest/dnssec.go 同源)——
//
// 根因(实测):国内公共解析器(DNSPod 119.28.28.28、doh.pub、AliDNS 223.5.5.5、114DNS)
// 即使查询带 DO 位也不在应答里返回 RRSIG——DNSKEY 能查到、RRSIG 永远缺席。而
// dnssec 查询若回退到 dns-server(默认 doh.pub 同族,国内递归),验签流程永远失败。
// 所以 DNSSEC 的默认服务器直接内置这份实测支持 DO 位的境外列表,不再回退到国内
// 解析器;境内网络若屏蔽/劫持了这些 UDP 53,请在节点配置 dnssec-server。
//
// 顺序即主从顺序,v4/v6 交错(同厂相邻):单栈节点上错误协议族的拨号是
// "network unreachable" 秒级失败,failover 到下一台几乎零成本,双栈节点则
// 自动绕过被劫持/丢包的那一族。国内家宽实测(江苏移动 v4)8.8.8.8 主 IP 存在
// 随机丢包,1.1.1.1 更稳故放首位。注意境外 DoH(443)在国内普遍被阻断,全部走 53。
var defaultDOCapableServers = []string{
"1.1.1.1:53", // Cloudflare v4
"[2606:4700:4700::1111]:53", // Cloudflare v6
"8.8.8.8:53", // Google v4
"[2001:4860:4860::8888]:53", // Google v6
"8.8.4.4:53", // Google 2 v4
"[2001:4860:4860::8844]:53", // Google 2 v6
"9.9.9.9:53", // Quad9 v4
"[2620:fe::fe]:53", // Quad9 v6
"208.67.222.222:53", // OpenDNS v4
"[2620:119:35::35]:53", // OpenDNS v6
"208.67.220.220:53", // OpenDNS 2 v4
"149.112.112.112:53", // Quad9 2 v4
"[2620:fe::9]:53", // Quad9 2 v6
"[2620:119:53::53]:53", // OpenDNS 2 v6
}
// DNSSECResult 包含 DNSSEC 验证的结果
type DNSSECResult struct {
Domain string `json:"domain"`
Enabled bool `json:"enabled"`
Valid bool `json:"valid"`
HasRRSIG bool `json:"has_rrsig"`
HasDNSKEY bool `json:"has_dnskey"`
HasDS bool `json:"has_ds"`
Algorithm uint8 `json:"algorithm"`
KeyTag uint16 `json:"key_tag"`
SignerName string `json:"signer_name"`
Validation string `json:"validation"`
Duration float64 `json:"duration"`
Domain string `json:"domain"`
Enabled bool `json:"enabled"`
Valid bool `json:"valid"`
HasRRSIG bool `json:"has_rrsig"`
HasDNSKEY bool `json:"has_dnskey"`
HasDS bool `json:"has_ds"`
Algorithm uint8 `json:"algorithm"`
KeyTag uint16 `json:"key_tag"`
SignerName string `json:"signer_name"`
Validation string `json:"validation"`
Duration float64 `json:"duration"`
}
// ResolveDNSSEC 执行 DNSSEC 验证
@@ -56,26 +86,65 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
var dnskeyRRset []dns.RR
var dnskeyList []*dns.DNSKEY
// DO 位生效时,DNSKEY 应答自带 RRSIG(DNSKEY)——已签名域名的确定信号,
// 也是 TLD / 无 A 记录域名唯一的验签素材(它们没有 A 应答可拿 RRSIG(A))
var rrsigDNSKEYList []*dns.RRSIG
for _, ans := range responseDNSKEY.Answer {
if dnskey, ok := ans.(*dns.DNSKEY); ok {
dnskeyRRset = append(dnskeyRRset, dnskey)
dnskeyList = append(dnskeyList, dnskey)
switch v := ans.(type) {
case *dns.DNSKEY:
dnskeyRRset = append(dnskeyRRset, v)
dnskeyList = append(dnskeyList, v)
result.HasDNSKEY = true
case *dns.RRSIG:
rrsigDNSKEYList = append(rrsigDNSKEYList, v)
}
}
if len(rrsigDNSKEYList) > 0 {
result.HasRRSIG = true
}
if len(dnskeyRRset) > 0 {
result.KeyTag = dnskeyList[0].KeyTag()
result.Algorithm = dnskeyList[0].Algorithm
}
// 2. 查询 A 记录(带 DO 位)
// 2. A 与 DS 并行查询。DS 记录在父区(如 .com),**任何**验签结论(通过或失败)
// 都应带上它——原实现把 DS 查询放在函数最后,验签成功时提前 return,
// has_ds 永远是 false(只有验证失败的路径才查得到 DS)。
msgA := new(dns.Msg)
msgA.SetQuestion(dns.Fqdn(domain), dns.TypeA)
msgA.SetEdns0(4096, true)
msgDS := new(dns.Msg)
msgDS.SetQuestion(dns.Fqdn(domain), dns.TypeDS)
msgDS.SetEdns0(4096, true)
responseA, _, err := executeDoHQueryMsg(msgA)
if err == nil && responseA.Rcode == dns.RcodeSuccess {
// rrsigList 提升到函数作用域:A 应答里的 RRSIG(A) 在「A 验签失败」的最终判定里也要用
var rrsigList []*dns.RRSIG
var responseA *dns.Msg
var responseDS *dns.Msg
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
responseA, _, err = executeDoHQueryMsg(msgA)
}()
go func() {
defer wg.Done()
responseDS, _, _ = executeDoHQueryMsg(msgDS)
}()
wg.Wait()
// DS 结论先于一切 early return 就绪
if responseDS != nil && responseDS.Rcode == dns.RcodeSuccess {
for _, ans := range responseDS.Answer {
if _, ok := ans.(*dns.DS); ok {
result.HasDS = true
break
}
}
}
if err == nil && responseA != nil && responseA.Rcode == dns.RcodeSuccess {
var aRRset []dns.RR
for _, ans := range responseA.Answer {
if aRecord, ok := ans.(*dns.A); ok {
@@ -83,7 +152,6 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
}
}
var rrsigList []*dns.RRSIG
for _, ans := range responseA.Answer {
if rrsig, ok := ans.(*dns.RRSIG); ok {
rrsigList = append(rrsigList, rrsig)
@@ -91,7 +159,8 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
}
}
// 3. 用 DNSKEY 逐条验证 RRSIG
// 3. 用 DNSKEY 逐条验证 A 的 RRSIG。不在验证失败时提前返回:
// 下面还有 DNSKEY 自验签兜底
for _, rrsig := range rrsigList {
for _, dnskey := range dnskeyList {
if keyErr := rrsig.Verify(dnskey, aRRset); keyErr == nil {
@@ -105,29 +174,37 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
}
}
}
if len(rrsigList) > 0 && len(dnskeyRRset) > 0 {
result.Enabled = true
result.Valid = false
result.Validation = fmt.Sprintf("RRSIG 验证失败: %d 条 RRSIG, %d 个 DNSKEY,无匹配签名", len(rrsigList), len(dnskeyRRset))
return result, nil
}
}
// 4. 查询 DS 记录
msgDS := new(dns.Msg)
msgDS.SetQuestion(dns.Fqdn(domain), dns.TypeDS)
responseDS, _, _ := executeDoHQueryMsg(msgDS)
if responseDS != nil && responseDS.Rcode == dns.RcodeSuccess {
for _, ans := range responseDS.Answer {
if _, ok := ans.(*dns.DS); ok {
result.HasDS = true
break
// 3.5 DNSKEY 自验签:用 DNSKEY 验证 DNSKEY 自己的 RRSIG(RFC 4035 §5.3.3 的
// 自签检查)。TLD(如 cn)与无 A 记录的已签名域拿不到 RRSIG(A),这是它们的
// 唯一验签路径;有 A 记录但 A 验签失败的域走到这里仍失败才算真失败。
if len(rrsigDNSKEYList) > 0 && len(dnskeyRRset) > 0 {
for _, rrsig := range rrsigDNSKEYList {
for _, dnskey := range dnskeyList {
if keyErr := rrsig.Verify(dnskey, dnskeyRRset); keyErr == nil {
result.Enabled = true
result.Valid = true
result.Algorithm = dnskey.Algorithm
result.KeyTag = dnskey.KeyTag()
result.SignerName = rrsig.SignerName
result.Validation = fmt.Sprintf("DNSSEC 验证通过 (DNSKEY 自验签, 算法: %d, KeyTag: %d)", dnskey.Algorithm, dnskey.KeyTag())
return result, nil
}
}
}
}
// A 验签与 DNSKEY 自验签都未通过:只要任一应答里有 RRSIG 就说明签名存在但无效
//(真失败,如数据被污染/密钥轮换不完整),返回明确的失败结论
if (len(rrsigList) > 0 || len(rrsigDNSKEYList) > 0) && len(dnskeyRRset) > 0 {
result.Enabled = true
result.Valid = false
result.Validation = fmt.Sprintf("RRSIG 验证失败: %d 条 RRSIG(A), %d 条 RRSIG(DNSKEY), %d 个 DNSKEY,无匹配签名", len(rrsigList), len(rrsigDNSKEYList), len(dnskeyRRset))
return result, nil
}
// 4. 结论分支(DS 已在并行阶段查完并计入 HasDS)
if result.HasRRSIG && result.HasDNSKEY {
result.Enabled = true
result.Valid = false
@@ -139,7 +216,7 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
} else if result.HasDNSKEY {
result.Enabled = false
result.Valid = false
result.Validation = "存在 DNSKEY,但缺少 RRSIG"
result.Validation = "存在 DNSKEY,但缺少 RRSIG(若确认该域名已签名,说明默认境外解析器在本网络不可达,请在节点配置 dnssec-server 指向可达的 DNSSEC 解析器,如 8.8.8.8:53)"
} else {
result.Enabled = false
result.Valid = false
@@ -255,7 +332,7 @@ func ResolveDNSSECForRecord(domain string, recordType uint16) (*DNSSECResult, er
return result, nil
}
// executeDoHQueryMsg 通过 queryDNSSECMsg(DNSSEC 专用 DNS,主从 failover;未配置专用则沿用 dns-server)发送 DNS 查询并返回解析后的响应消息
// executeDoHQueryMsg 通过 queryDNSSECMsg(内置 DO 友好列表 / dnssec-server 配置,主从 failover)发送 DNS 查询并返回解析后的响应消息
func executeDoHQueryMsg(msg *dns.Msg) (*dns.Msg, float64, error) {
responseMsg, duration, err := queryDNSSECMsg(msg)
if err != nil {
@@ -0,0 +1,65 @@
package webtest
import (
"context"
"net"
"strings"
"edgeone-cloud-functions/ssrf"
)
// InstallSSRFResolver 让 SSRF 出站校验走本包的自定义 DNS(含请求级覆盖)。
// 必须在启动阶段调用一次(index.go 的 main)。
//
// 动机:不注入时 ssrf 用 net.LookupIP(系统解析器)做预检,而真正拨号走自定义 DNS —— 两套解析器,
// 既多一次查询,结果也可能不一致;更关键的是,当调用方用请求级 dns= 指定了服务器、且目标域名只能
// 靠该服务器解析时,系统解析器预检会先行失败,把请求直接毙掉,覆盖形同失效。
//
// 校验只需判定「是否私网」,故 A / AAAA 都查(任一命中私网即拦,口径与原先 net.LookupIP 一致)。
// 请求级配置(DNS 服务器)取自 context,与真正拨号同源。
func InstallSSRFResolver() {
ssrf.SetResolveFunc(func(ctx context.Context, host string) ([]net.IP, error) {
// 字面 IP 直接返回,无需解析
if ip := net.ParseIP(host); ip != nil {
return []net.IP{ip}, nil
}
req := ReqResolveFromContext(ctx)
var ips []net.IP
var firstErr error
for _, wantV6 := range []bool{false, true} {
// 强制解析命中时,预检要验「拨号真正会连的地址」,否则会拿原域名的
// 真实解析去判定,预检与实际连接两个口径对不上(resolve= 也就白设了)。
name := host
if target := req.Override(name, wantV6); target != "" {
name = strings.Trim(target, "[]")
}
if ip := net.ParseIP(name); ip != nil {
ips = append(ips, ip)
continue
}
var res DNSResult
var err error
if wantV6 {
res, err = ResolveAAAARecord(name, req.Servers("ipv6"))
} else {
res, err = ResolveARecord(name, req.Servers("ipv4"))
}
if err != nil && firstErr == nil {
firstErr = err
}
for _, s := range res.Record {
if ip := net.ParseIP(s); ip != nil {
ips = append(ips, ip)
}
}
}
// 两栈都解析失败才报错;只要有一栈拿到 IP 就交给上层做私网判定
if len(ips) == 0 && firstErr != nil {
return nil, firstErr
}
return ips, nil
})
}
@@ -4,6 +4,7 @@ import (
"fmt"
"math"
"net"
"strings"
"time"
"edgeone-cloud-functions/ssrf"
@@ -30,28 +31,89 @@ type TCPingStats struct {
Results []TCPingResult `json:"results"`
}
func resolveHost(host string, version string) (string, error) {
return ResolveIP(host, version)
// ResolveHost 解析 host 为指定版本(v4/v6)的 IP 地址字符串。
// 字面 IP 不查 DNS,但仍要过私网校验(与 DNS 解析结果同口径)。
func ResolveHost(host string, version string) (string, error) {
return ResolveHostWith(host, version, ReqResolve{})
}
// ResolveHostWith 同 ResolveHost,但带请求级解析配置(req 零值 = 用配置 DNS)。
// 强制解析(resolve=)命中就把目标当作新的 host——IP 交给下面的字面 IP 分支,域名继续走 DNS。
func ResolveHostWith(host string, version string, req ReqResolve) (string, error) {
// 强制解析:命中就把目标当作新的 host——IP 交给下面的字面 IP 分支,域名继续走 DNS。
if target := req.Override(host, version == "v6"); target != "" {
host = strings.Trim(target, "[]")
}
cleanHost := strings.Trim(host, "[]")
if ip := net.ParseIP(cleanHost); ip != nil {
// SSRF:字面 IP 与 DNS 解析结果同口径,私网地址一律拒绝(tcping 是裸 TCP,
// 不经过 DialContext,这里不做就完全没人拦)。放在族校验之前:安全判定
// 优先于参数语义,也与 DialContext 里「先查私网、再拨号」的顺序一致。
if ssrf.Enabled() && ssrf.IsPrivateIP(ip) {
return "", fmt.Errorf("connection to private/internal address %s is not allowed", cleanHost)
}
switch version {
case "v4":
if ip.To4() != nil {
return ip.String(), nil
}
case "v6":
if ip.To4() == nil {
return ip.String(), nil
}
}
return "", fmt.Errorf("host %s is not a %s address", host, version)
}
var result DNSResult
var err error
if version == "v6" {
result, err = ResolveAAAARecord(host, req.Servers("ipv6"))
} else {
result, err = ResolveARecord(host, req.Servers("ipv4"))
}
if err != nil {
return "", fmt.Errorf("DNS lookup failed: %w", err)
}
if len(result.Record) == 0 {
return "", fmt.Errorf("no %s address found for %s", version, host)
}
ipStr := result.Record[0]
if ssrf.Enabled() {
if ip := net.ParseIP(ipStr); ip != nil && ssrf.IsPrivateIP(ip) {
return "", fmt.Errorf("connection to private/internal address %s is not allowed", ipStr)
}
}
return ipStr, nil
}
// TCPing 执行单次 TCP 连接测试
// 参数:
// - host: 目标主机名或 IP 地址
// - port: 目标端口号
// - version: 协议版本,"v4" 或 "v6"
// - timeout: 连接超时时间
//
// 返回 TCPingResult 包含连接结果和响应时间
func TCPing(host string, port string, version string, timeout time.Duration) (*TCPingResult, error) {
ip, err := resolveHost(host, version)
ip, err := ResolveHost(host, version)
if err != nil {
return nil, err
}
// SSRF 防护:内网/云元数据段(127.0.0.1、10.x、169.254.169.254 等)直接拒绝,
// 否则 tcping 接口会被当作内网端口扫描器
if ssrf.Enabled() {
if pip := net.ParseIP(ip); pip != nil && ssrf.IsPrivateIP(pip) {
return nil, fmt.Errorf("request to private/internal address is not allowed")
}
}
return tcpingDial(ip, port, version, timeout), nil
}
// tcpingDial 对已解析好的 IP 做一次 TCP 拨号,不再做 DNS 解析与 SSRF 校验。
// 拆出来是为了让 TCPingRun 的多次探测复用同一个解析结果:原实现每轮都走 TCPing→
// ResolveHost,既拖慢,又会因多 A 记录轮询导致 results 里每次连到不同 IP(与顶层 ip 不符)。
func tcpingDial(ip string, port string, version string, timeout time.Duration) *TCPingResult {
addr := ""
switch version {
case "v4":
addr = fmt.Sprintf("%s:%s", ip, port)
case "v6":
addr = fmt.Sprintf("[%s]:%s", ip, port)
default:
addr = fmt.Sprintf("%s:%s", ip, port)
}
start := time.Now()
@@ -67,7 +129,6 @@ func TCPing(host string, port string, version string, timeout time.Duration) (*T
if err != nil {
result.Success = false
result.RTT = -1
result.Error = err.Error()
} else {
@@ -76,11 +137,26 @@ func TCPing(host string, port string, version string, timeout time.Duration) (*T
result.RTT = float64(rtt.Microseconds()) / 1000.0
}
return result, nil
return result
}
// TCPingRun 执行多次 TCP 连接测试并返回统计结果
// 参数:
// - host: 目标主机名或 IP 地址
// - port: 目标端口号
// - count: 测试次数
// - version: 协议版本,"v4" 或 "v6"
// - timeout: 每次连接的超时时间
// - interval: 两次测试之间的间隔时间
//
// 返回 TCPingStats 包含统计信息
func TCPingRun(host string, port string, count int, version string, timeout time.Duration, interval time.Duration) (*TCPingStats, error) {
ip, err := resolveHost(host, version)
return TCPingRunWith(host, port, count, version, timeout, interval, ReqResolve{})
}
// TCPingRunWith 同 TCPingRun,但带请求级解析配置(req 零值 = 用配置 DNS)。
func TCPingRunWith(host string, port string, count int, version string, timeout time.Duration, interval time.Duration, req ReqResolve) (*TCPingStats, error) {
ip, err := ResolveHostWith(host, version, req)
if err != nil {
return &TCPingStats{
IP: "Error: " + err.Error(),
@@ -107,10 +183,8 @@ func TCPingRun(host string, port string, count int, version string, timeout time
successCount := 0
for i := 0; i < count; i++ {
result, err := TCPing(host, port, version, timeout)
if err != nil {
return nil, err
}
// 复用开头解析出的 ip,不再每轮重新 ResolveHost
result := tcpingDial(ip, port, version, timeout)
stats.Results = append(stats.Results, *result)
@@ -7,7 +7,6 @@ import (
"net"
"regexp"
"strings"
"sync"
"time"
"edgeone-cloud-functions/ssrf"
@@ -147,33 +146,29 @@ func filterPublicIPs(ips []string) []string {
return filtered
}
// resolveWhoisServerIPs 使用 webtest/dns 的统一解析接口
// 并行查询 A + AAAA(走自定义 DNS 服务器,不受系统 resolver 策略影响)
// resolveWhoisServerIPs 用系统解析器(net.LookupIP)取 whois 服务器的 v4/v6 地址。
// whois 不走自定义 DNS(dns-server / 请求级 dns=):whois 服务器是稳定的公共基础设施,
// 解析结果只用于建立连接、不作为拨测结论,没必要让 DNS 配置的故障面波及 whois。
func resolveWhoisServerIPs(server string) (v4IPs, v6IPs []string, err error) {
var (
aResult DNSResult
aaaaResult DNSResult
wg sync.WaitGroup
aErr error
aaaaErr error
)
wg.Add(2)
go func() {
defer wg.Done()
aResult, aErr = ResolveARecord(server)
}()
go func() {
defer wg.Done()
aaaaResult, aaaaErr = ResolveAAAARecord(server)
}()
wg.Wait()
if aErr != nil && aaaaErr != nil {
return nil, nil, fmt.Errorf("both A and AAAA DNS lookup failed for %s: A=%w, AAAA=%w", server, aErr, aaaaErr)
ips, err := net.LookupIP(server)
if err != nil {
return nil, nil, err
}
v4IPs = filterPublicIPs(aResult.Record)
v6IPs = filterPublicIPs(aaaaResult.Record)
var v4, v6 []string
for _, ip := range ips {
if ip == nil {
continue
}
if ip.To4() != nil {
v4 = append(v4, ip.String())
} else {
v6 = append(v6, ip.String())
}
}
v4IPs = filterPublicIPs(v4)
v6IPs = filterPublicIPs(v6)
if len(v4IPs) == 0 && len(v6IPs) == 0 {
return nil, nil, fmt.Errorf("no IPs resolved for %s", server)
+4 -4
View File
@@ -8,7 +8,7 @@
"name": "lemon-getip",
"version": "0.0.0",
"dependencies": {
"hono": "^4.13.5"
"hono": "^4.13.7"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.22.0",
@@ -1878,9 +1878,9 @@
}
},
"node_modules/hono": {
"version": "4.13.5",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz",
"integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==",
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz",
"integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
+1 -1
View File
@@ -16,6 +16,6 @@
"wrangler": "^4.124.0"
},
"dependencies": {
"hono": "^4.13.5"
"hono": "^4.13.7"
}
}
+74 -35
View File
@@ -43,7 +43,7 @@ import (
"time"
"github.com/coder/websocket"
"github.com/gin-gonic/gin"
"github.com/gofiber/fiber/v3"
"github.com/spf13/viper"
"lemon-ipw/ssrf"
"lemon-ipw/webtest"
@@ -101,7 +101,8 @@ func protectedKeysIn(cfg map[string]any) []string {
// configRestartKeys 改了内存变量但需重启进程才完全生效的键:
//
// - port / trusted-proxies / cors:监听地址与 gin 中间件在启动时构建并固定
// - port / trusted-proxies / cors:监听地址与 fiber 应用配置(buildFiberConfig,含 CORS
// 中间件)都在启动时构建并固定
// - ipdb:ipdb.Init 与 location / asn 路由注册都按启动时的值判定
// - report-interval-seconds:上报 ticker 在启动时按该值创建
// - node-id / node-key:WS 注册身份在启动时确立;运行中改动只改内存变量,
@@ -112,7 +113,8 @@ func protectedKeysIn(cfg map[string]any) []string {
// (ws.go 的 reconcileWSClient)按新地址多退少补,连接集合随配置变化,无需重启。
//
// 不在其中的键即为热生效:`dns-server` / `dnssec-server` / `block-private-ips` /
// `single-stack` / `node-ota` 每次消费时重新读变量。
// `single-stack` / `node-ota` / `access-log` 每次消费时重新读变量
// (`access-log` 的中间件虽在启动时挂载,但开关判定在请求路径上,因此同样热生效)。
//
// 判定口径:只有**值真的变了**才算需重启(见 applyConfigMap)——远端/托管配置每次都带全部键,
// 若按"键出现过"判定,每次 refresh 都会把节点误报成"需重启"。
@@ -183,12 +185,23 @@ func applyConfigMap(cfg map[string]any, ignore []string) (applied, unknown, rest
webtest.SetDNSSecServer(v)
case "ipdb":
// 仅改变量不够:ipdb.Init 与 location/asn 路由注册都在启动时按该值判定,运行中不重建
IPDB = v
enabled, known := parseBoolSwitch(v)
if !known {
unknown = append(unknown, key)
continue
}
IPDB_ENABLED = enabled
case "cors":
CORS = v
ACCEPT_DOMAINS = splitAndTrim(CORS, ",")
case "block-private-ips":
ssrf.SetEnabled(v != "false" && v != "0")
// 与启动口径共用 parseBoolSwitch(main.go),避免"启动认 false、热更新认 FALSE"这类漂移
enabled, known := parseBoolSwitch(v)
if !known {
unknown = append(unknown, key) // 值非法:不覆盖原值,明确报给调用方
continue
}
ssrf.SetEnabled(enabled)
case "trusted-proxies":
TRUSTED_PROXIES = v
case "remote-config-url":
@@ -229,12 +242,40 @@ func applyConfigMap(cfg map[string]any, ignore []string) (applied, unknown, rest
REPORT_INTERVAL = n
case "node-ota":
// 热生效:下发 OTA 指令时才读 NODE_OTA(见 ota.go),故不在 configRestartKeys
enabled, known := parseOTASwitch(v)
enabled, known := parseBoolSwitch(v)
if !known {
unknown = append(unknown, key) // 值非法:不覆盖原值,明确报给调用方
continue
}
NODE_OTA = enabled
case "access-log":
// 热生效:中间件在请求路径上实时读 ACCESS_LOG(见 accessLogMiddleware),
// 中间件本身在启动时就已挂好,所以这里改完即生效、无需重启。
enabled, known := parseBoolSwitch(v)
if !known {
unknown = append(unknown, key) // 值非法:不覆盖原值,明确报给调用方
continue
}
ACCESS_LOG = enabled
case "max-response-body":
// 热生效:改完即应用到出站客户端(applyResponseBodyLimit 内部有锁)
n, ok := parseByteSize(v)
if !ok {
unknown = append(unknown, key) // 值非法:不覆盖原值,明确报给调用方
continue
}
MAX_RESPONSE_BODY = n
applyResponseBodyLimit()
case "memory-limit":
// 热生效:debug.SetMemoryLimit 支持运行时调整。
// 0 表示"回到自动探测 cgroup",与启动口径一致(见 resources.go)。
n, ok := parseByteSize(v)
if !ok {
unknown = append(unknown, key)
continue
}
MEMORY_LIMIT = n
applyMemoryLimit()
default:
unknown = append(unknown, key)
continue
@@ -275,7 +316,7 @@ func restartKeyValue(key string) string {
case "cors":
return CORS
case "ipdb":
return IPDB
return strconv.FormatBool(IPDB_ENABLED)
case "trusted-proxies":
return TRUSTED_PROXIES
case "ws-url":
@@ -300,7 +341,7 @@ func configSnapshot() map[string]any {
"single-stack": SINGLE_STACK,
"dns-server": DNS_SERVER,
"dnssec-server": DNSSEC_DNS_SERVER,
"ipdb": IPDB,
"ipdb": IPDB_ENABLED,
"cors": CORS,
"block-private-ips": ssrf.Enabled(),
"trusted-proxies": TRUSTED_PROXIES,
@@ -309,6 +350,9 @@ func configSnapshot() map[string]any {
"ws-url": WS_URL,
"node-id": WS_NODE_ID,
"node-ota": NODE_OTA,
"access-log": ACCESS_LOG,
"max-response-body": MAX_RESPONSE_BODY,
"memory-limit": MEMORY_LIMIT,
"report-url": REPORT_URL,
"report-interval-seconds": REPORT_INTERVAL,
}
@@ -388,28 +432,28 @@ func refreshRemoteConfig() (map[string]any, error) {
// access-token 未配置时**关闭整个 HTTP 管理面**:此时节点业务接口本身也无鉴权,
// 再暴露配置读写等于公网任何人可改节点配置。WS 通道不受影响——它由节点主动外连中间件,
// 注册需通过中间件的 ws-keys 校验,指令只发给已注册连接,天然有鉴权。
func registerConfigRoutes(r *gin.Engine) {
func registerConfigRoutes(r *fiber.App) {
g := r.Group("/v1/config")
if ACCESS_TOKEN == "" {
slog.Warn("config HTTP API disabled: access-token not set, use WS channel instead")
disabled := func(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
disabled := func(c fiber.Ctx) error {
return c.Status(http.StatusForbidden).JSON(fiber.Map{
"error": "配置管理接口已关闭:节点未配置 access-token,请通过 WS 通道操作",
})
}
g.GET("", disabled)
g.PATCH("", disabled)
g.POST("/refresh", disabled)
g.Get("", disabled)
g.Patch("", disabled)
g.Post("/refresh", disabled)
return
}
g.Use(tokenCheck())
g.GET("", configGetHandler)
g.PATCH("", configPatchHandler)
g.POST("/refresh", configRefreshHandler)
g.Get("", configGetHandler)
g.Patch("", configPatchHandler)
g.Post("/refresh", configRefreshHandler)
}
func configGetHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
func configGetHandler(c fiber.Ctx) error {
return c.Status(http.StatusOK).JSON(fiber.Map{
"config": configSnapshot(),
"secretKeys": sortedKeysBool(configSecretKeys),
"restartRequiredKeys": sortedKeysBool(configRestartKeys),
@@ -417,15 +461,13 @@ func configGetHandler(c *gin.Context) {
})
}
func configPatchHandler(c *gin.Context) {
func configPatchHandler(c fiber.Ctx) error {
var body map[string]any
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体须为 JSON 对象:" + err.Error()})
return
if err := c.Bind().JSON(&body); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "请求体须为 JSON 对象:" + err.Error()})
}
if len(body) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体为空"})
return
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "请求体为空"})
}
// 本地显式 PATCH 不受 remote-ignore-config 限制:那是"防远端覆盖"的白名单
applied, unknown, restart := applyConfigMap(body, nil)
@@ -433,17 +475,16 @@ func configPatchHandler(c *gin.Context) {
persisted := false
if p := c.Query("persist"); p == "1" || strings.EqualFold(p, "true") {
if err := persistConfig(body, applied); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "写回 setting.json 失败:" + err.Error(),
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"error": "写回 setting.json 失败:" + err.Error(),
"applied": applied,
})
return
}
persisted = true
}
// 未要求持久化时只改内存:需重启的键不落盘,进程重启后会回到 setting.json / 远端配置的值。
// 这是刻意的语义——PATCH 是"当前进程内的即时改动",要不要固化由调用方用 persist 明确决定。
c.JSON(http.StatusOK, gin.H{
return c.Status(http.StatusOK).JSON(fiber.Map{
"applied": applied,
"unknown": unknown,
"restartRequired": restart,
@@ -452,17 +493,15 @@ func configPatchHandler(c *gin.Context) {
})
}
func configRefreshHandler(c *gin.Context) {
func configRefreshHandler(c fiber.Ctx) error {
res, err := refreshRemoteConfig()
if err == errNoRemoteURL {
c.JSON(http.StatusBadRequest, gin.H{"error": "remote-config-url 未配置,无法刷新"})
return
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "remote-config-url 未配置,无法刷新"})
}
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "拉取远端配置失败:" + err.Error(), "source": REMOTE_CONFIG_URL})
return
return c.Status(http.StatusBadGateway).JSON(fiber.Map{"error": "拉取远端配置失败:" + err.Error(), "source": REMOTE_CONFIG_URL})
}
c.JSON(http.StatusOK, res)
return c.Status(http.StatusOK).JSON(res)
}
// sortedKeysBool 取 map 键的升序列表(用于向调用方说明哪些键是凭据/需重启)
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"strings"
"testing"
"github.com/spf13/viper"
)
func contains(list []string, want string) bool {
for _, s := range list {
if s == want {
return true
}
}
return false
}
// literalString 是两条读取路径共用的「字面量 → 字符串」表,"布尔与字符串等价"就靠它。
func TestLiteralString(t *testing.T) {
cases := []struct {
name string
in any
want string
}{
{"nil(未配置 / null)", nil, ""},
{"bool false", false, "false"},
{"bool true", true, "true"},
{"字符串去首尾空白", " no \n", "no"},
{"JSON 数字(float64)", float64(443), "443"},
{"int 0", 0, "0"},
}
for _, c := range cases {
if got := literalString(c.in); got != c.want {
t.Errorf("%s: literalString(%#v) = %q, want %q", c.name, c.in, got, c.want)
}
}
}
// parseBoolSwitch 是四个开关唯一的字面值口径。
func TestParseBoolSwitch(t *testing.T) {
for _, s := range []string{"true", "TRUE", " True ", "1", "yes", "ON", "enable", "enabled"} {
if v, ok := parseBoolSwitch(s); !ok || !v {
t.Errorf("parseBoolSwitch(%q) = (%v, %v), want (true, true)", s, v, ok)
}
}
for _, s := range []string{"false", "FALSE", " False ", "0", "no", "off", "disable", "disabled"} {
if v, ok := parseBoolSwitch(s); !ok || v {
t.Errorf("parseBoolSwitch(%q) = (%v, %v), want (false, true)", s, v, ok)
}
}
// 认不出来的一律 known=false —— 绝不静默当"开"
for _, s := range []string{"", " ", "banana", "tru", "2", "on!", "是", "否"} {
if v, ok := parseBoolSwitch(s); ok || v {
t.Errorf("parseBoolSwitch(%q) = (%v, %v), want (false, false)", s, v, ok)
}
}
}
// 启动路径:setting.json 里把开关写成 JSON 布尔 / 数字,读出来必须与字符串写法等价,
// 且不能被 configString 的"空值"判断误吞(false 变成"未配置"就等于静默变回默认的"开")。
func TestConfigStringAcceptsJSONBoolAndNumber(t *testing.T) {
viper.Reset()
defer viper.Reset()
viper.SetConfigType("json")
if err := viper.ReadConfig(strings.NewReader(`{"ipdb": false, "access-log": 0, "port": 443, "cors": ""}`)); err != nil {
t.Fatal(err)
}
if got := configString("ZZ_NO_IPDB", "ipdb", "true"); got != "false" {
t.Errorf("configString(ipdb) = %q, want \"false\"", got)
}
if configBool("ZZ_NO_IPDB", "ipdb", true) {
t.Error("configBool(ipdb) = true,JSON 布尔 false 被当成了「开」")
}
if configBool("ZZ_NO_ACCESS_LOG", "access-log", true) {
t.Error("configBool(access-log) = true,数字 0 被当成了「开」")
}
if got := configString("ZZ_NO_PORT", "port", "8080"); got != "443" {
t.Errorf("configString(port) = %q, want \"443\"", got)
}
if got := configString("ZZ_NO_CORS", "cors", "fallback"); got != "fallback" {
t.Errorf("configString(cors) = %q, want \"fallback\"(空串应视为未配置)", got)
}
}
// 运行路径:远端下发 / PATCH 收到布尔与字符串,行为必须一致;
// 非法值计入 unknown 且不覆盖原值。
func TestApplyConfigMapAcceptsBoolAndString(t *testing.T) {
ipdb0, access0, ota0 := IPDB_ENABLED, ACCESS_LOG, NODE_OTA
defer func() { IPDB_ENABLED, ACCESS_LOG, NODE_OTA = ipdb0, access0, ota0 }()
IPDB_ENABLED = true // 先置"开",才能验证布尔 false 真的把它关掉
ACCESS_LOG = false // 反向同理
NODE_OTA = true // 非法值不该动它
applied, unknown, _ := applyConfigMap(map[string]any{
"ipdb": false, // JSON 布尔
"access-log": "true", // 字符串
"node-ota": "banana", // 非法值
}, nil)
if IPDB_ENABLED {
t.Error("ipdb:布尔 false 未生效(仍是开)")
}
if !ACCESS_LOG {
t.Error("access-log:字符串 \"true\" 未生效(仍是关)")
}
if !NODE_OTA {
t.Error("node-ota:收到非法值却把原值 true 改掉了")
}
if !contains(unknown, "node-ota") {
t.Errorf("unknown = %v,期望包含 node-ota", unknown)
}
for _, k := range []string{"ipdb", "access-log"} {
if !contains(applied, k) {
t.Errorf("applied = %v,期望包含 %s", applied, k)
}
}
// 反向对称:布尔 true 必须能打开、字符串 "false" 必须能关闭。
// 这里专门覆盖「布尔」和「字符串」两个方向,防止只测了一个方向就以为等价。
IPDB_ENABLED = false
ACCESS_LOG = true
applied, unknown, _ = applyConfigMap(map[string]any{
"ipdb": true,
"access-log": "false",
}, nil)
if !IPDB_ENABLED {
t.Error("ipdb:布尔 true 未生效(仍是关)")
}
if ACCESS_LOG {
t.Error(`access-log:字符串 "false" 未生效(仍是开)`)
}
if len(unknown) != 0 {
t.Errorf("unknown = %v,期望为空", unknown)
}
for _, k := range []string{"ipdb", "access-log"} {
if !contains(applied, k) {
t.Errorf("applied = %v,期望包含 %s", applied, k)
}
}
}
+12 -26
View File
@@ -5,8 +5,7 @@ go 1.26.6
require (
github.com/AdguardTeam/golibs v0.35.16
github.com/coder/websocket v1.8.15
github.com/gin-contrib/cors v1.7.7
github.com/gin-gonic/gin v1.12.0
github.com/gofiber/fiber/v3 v3.4.0
github.com/ip2location/ip2location-go/v9 v9.8.0
github.com/ipipdotnet/ipdb-go v1.3.3
github.com/klauspost/compress v1.19.1
@@ -21,46 +20,33 @@ require (
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/gofiber/schema v1.8.0 // indirect
github.com/gofiber/utils/v2 v2.1.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/likexian/gokit v0.25.16 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.72.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.48.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
lukechampine.com/uint128 v1.2.0 // indirect
)
+33 -72
View File
@@ -1,64 +1,42 @@
github.com/AdguardTeam/golibs v0.35.16 h1:e9dyk7jfzpxMcyVZ8RktKV6JMeiQ9MpEhp/Ahm3j/R4=
github.com/AdguardTeam/golibs v0.35.16/go.mod h1:lGsRwlo3R3ma5o8mgdDv3I+6DYTiCHfrhNyUamqhbK8=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gofiber/fiber/v3 v3.4.0 h1:F0aND4vwZF7dR7cbvSwFQQEpBU902XHKWxrLsFBkVqw=
github.com/gofiber/fiber/v3 v3.4.0/go.mod h1:nAhJfdxUIJJph2tPWPmqWf8QDIN2iiqQiQf3lENZpdk=
github.com/gofiber/schema v1.8.0 h1:NGsC9toPHmj8Xg4KpznuXBzNmHG6V5YV0tXKpKMcmis=
github.com/gofiber/schema v1.8.0/go.mod h1:lmbXPQ8hvzXSLkdS2DS7pb4kpunC2Roh7Sj3HMjGfzA=
github.com/gofiber/utils/v2 v2.1.1 h1:kGnoGjwEnFW6w0x45W+kLlmMJvqBGkuUA4oMWKn/T/I=
github.com/gofiber/utils/v2 v2.1.1/go.mod h1:DdOgEVwQTi8cou/AKWPqhXOR4fHGRVhA/rEWL3IXG7Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/ip2location/ip2location-go/v9 v9.8.0 h1:drPzGjj1EBl45I33ErMHFtIfsQ3mR85dAQbqMDbi9mc=
github.com/ip2location/ip2location-go/v9 v9.8.0/go.mod h1:MPLnsKxwQlvd2lBNcQCsLoyzJLDBFizuO67wXXdzoyI=
github.com/ipipdotnet/ipdb-go v1.3.3 h1:GLSAW9ypLUd6EF9QNK2Uhxew9Jzs4XMJ9gOZEFnJm7U=
github.com/ipipdotnet/ipdb-go v1.3.3/go.mod h1:yZ+8puwe3R37a/3qRftXo40nZVQbxYDLqls9o5foexs=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/likexian/gokit v0.25.16 h1:wwBeUIN/OdoPp6t00xTnZE8Di/+s969Bl5N2Kw6bzP8=
github.com/likexian/gokit v0.25.16/go.mod h1:Wqd4f+iifV0qxA1N3MqePJTUsmRy/lpst9/yXriDx/4=
github.com/likexian/whois v1.15.7 h1:sajjDhi2bVD71AHJhjV7jLYxN92H4AWhTwxM8hmj7c0=
@@ -67,30 +45,26 @@ github.com/likexian/whois-parser v1.24.21 h1:MxsrGRxDOiZIVp7q7N/yAIbKuN4QAkGjCpO
github.com/likexian/whois-parser v1.24.21/go.mod h1:o3DUruO65Pb8WXCJCTlSVkTbwuYVrBCeoMTw2q0mxY4=
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260417083004-03cc433717a7 h1:OOVHPQGWDqC8tMrwFXd4Y4VrxXEL+y77YD1Rh2dLjc0=
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260417083004-03cc433717a7/go.mod h1:sj5LMpsqB4IWdwIrcmmBJM6m+rW/uOQLSGUPhKkqdh8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/oschwald/maxminddb-golang/v2 v2.3.0 h1:PnXjMGjkSQlwOBSyZ7hk6Fd75t7erkAhJNJgEhA3MQU=
github.com/oschwald/maxminddb-golang/v2 v2.3.0/go.mod h1:NSQvgFwPxODpBTJI5+5Ns1AAucnx7ggW9PSRRifAT1s=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/shamaton/msgpack/v3 v3.1.2 h1:d5gWAIyMU4M0WgDjz6IFSCuXJUA2dFwRHBpDclE8CLw=
github.com/shamaton/msgpack/v3 v3.1.2/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@@ -101,31 +75,22 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M=
github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
@@ -134,19 +99,15 @@ golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
+116 -12
View File
@@ -3,9 +3,11 @@ package ipdb
import (
"archive/zip"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
@@ -20,7 +22,63 @@ type downloadTask struct {
name string
}
// dbValidator 条件请求凭证(ETag / Last-Modified):日更同步时只有真变了的文件
// 才会重新下载,没变的 304 直接跳过——原实现每 24h 无条件全量重拉 450MB。
// 凭证与「已解压到工作目录的最终文件」绑定,持久化在 ipdb-etags.json。
type dbValidator struct {
ETag string `json:"etag,omitempty"`
LastModified string `json:"last_modified,omitempty"`
}
const validatorsFile = "ipdb-etags.json"
// pullMu 串行化下载(后台首次补齐与每日同步理论上不重叠,但配置面手动触发
// 之类的新入口将来出现时,这里保证不会两个 PullDatabase 互踩同一个 tmp 目录)
var pullMu sync.Mutex
func loadValidators() map[string]dbValidator {
m := make(map[string]dbValidator)
data, err := os.ReadFile(validatorsFile)
if err != nil {
return m
}
_ = json.Unmarshal(data, &m)
return m
}
func saveValidators(m map[string]dbValidator) {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
slog.Warn("failed to marshal db validators", "error", err)
return
}
if err := os.WriteFile(validatorsFile, data, 0o644); err != nil {
slog.Warn("failed to write db validators", "error", err)
}
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
// finalFileName 计算下载产物的最终文件名(.gz / .zip 会被解掉,后缀大小写不敏感——
// IP2LOCATION 的资产名是大写 .ZIP)
func finalFileName(name string) string {
lower := strings.ToLower(name)
if strings.HasSuffix(lower, ".gz") {
return name[:len(name)-3]
}
if strings.HasSuffix(lower, ".zip") {
return name[:len(name)-4]
}
return name
}
func PullDatabase(ghproxy string) error {
pullMu.Lock()
defer pullMu.Unlock()
tasks := []downloadTask{
{"https://cdn.jsdelivr.net/gh/ljxi/GeoCN@main/data/full.txt", "full.txt"},
{"https://cdn.jsdelivr.net/gh/ljxi/GeoCN@main/data/short.txt", "short.txt"},
@@ -43,15 +101,34 @@ func PullDatabase(ghproxy string) error {
return err
}
// 条件请求凭证:只在「最终文件还在本地」时才有意义——304 的语义是
// 「服务器上和你手里这份一样」,本地文件没了就必须整份重下。
validators := loadValidators()
newValidators := make(map[string]dbValidator, len(tasks))
for k, v := range validators {
newValidators[k] = v
}
slog.Info("Starting parallel downloads...", "count", len(tasks))
var wg sync.WaitGroup
errs := make([]error, len(tasks))
notModified := make([]bool, len(tasks))
for i, t := range tasks {
wg.Add(1)
go func(i int, t downloadTask) {
defer wg.Done()
errs[i] = downloadWithRetry(t.url, t.name)
val := validators[t.name]
if !fileExists("./" + finalFileName(t.name)) {
val = dbValidator{}
}
nv, skip, err := downloadWithRetry(t, val)
errs[i] = err
notModified[i] = skip
if err == nil {
newValidators[t.name] = nv
}
// 失败的文件保留旧凭证:本地旧版本文件仍在,下次可继续走条件请求
}(i, t)
}
wg.Wait()
@@ -71,6 +148,11 @@ func PullDatabase(ghproxy string) error {
slog.Warn("Skipping failed download, keep existing file", "file", t.name)
continue
}
if notModified[i] {
// 304:本地文件就是最新版,无需覆盖也不必解压
slog.Info("Unchanged since last sync, skipped", "file", t.name)
continue
}
src := filepath.Join(tmpDir, t.name)
if _, err := os.Stat(src); os.IsNotExist(err) {
slog.Warn("Skipping missing file", "file", t.name)
@@ -103,6 +185,7 @@ func PullDatabase(ghproxy string) error {
}
os.RemoveAll(tmpDir)
saveValidators(newValidators)
if failCount > 0 {
slog.Error("Some downloads failed", "failed", failCount)
@@ -113,36 +196,57 @@ func PullDatabase(ghproxy string) error {
return nil
}
func downloadWithRetry(url, name string) error {
// downloadWithRetry 带重试的条件下载。返回 (新凭证, 是否 304 跳过, 错误)。
//
// 超时 10 分钟:这是**整个请求**(含响应体下载)的超时,GeoLite2-City 这类几十 MB
// 的大文件需要持续 ~1Mbps 以上才能在 10 分钟内完成;原实现的 60s 在慢链路上
// 会让大文件 3 次重试全部超时失败。
func downloadWithRetry(t downloadTask, val dbValidator) (dbValidator, bool, error) {
const maxRetries = 3
client := resty.New().SetTimeout(60 * time.Second).SetOutputDirectory("./tmp")
client := resty.New().SetTimeout(10 * time.Minute).SetOutputDirectory("./tmp")
defer client.Close()
var lastErr error
for attempt := 1; attempt <= maxRetries; attempt++ {
slog.Info("Downloading...", "file", name, "attempt", attempt)
slog.Info("Downloading...", "file", t.name, "attempt", attempt)
resp, err := client.R().SetOutputFileName(name).SetSaveResponse(true).Get(url)
req := client.R().SetOutputFileName(t.name).SetSaveResponse(true)
if val.ETag != "" {
req.SetHeader("If-None-Match", val.ETag)
}
if val.LastModified != "" {
req.SetHeader("If-Modified-Since", val.LastModified)
}
resp, err := req.Get(t.url)
if err != nil {
lastErr = fmt.Errorf("request failed: %w", err)
slog.Warn("Download attempt failed", "file", name, "attempt", attempt, "error", err)
slog.Warn("Download attempt failed", "file", t.name, "attempt", attempt, "error", err)
// 传输中断会在输出文件里留下残缺内容,删掉避免被当作下载成功覆盖现有数据库
os.Remove(filepath.Join("./tmp", name))
os.Remove(filepath.Join("./tmp", t.name))
time.Sleep(time.Duration(attempt) * time.Second)
continue
}
if resp.StatusCode() == http.StatusNotModified {
slog.Info("Not modified since last sync", "file", t.name)
return val, true, nil
}
if resp.IsError() {
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode())
slog.Warn("Download attempt failed", "file", name, "attempt", attempt, "status", resp.StatusCode())
os.Remove(filepath.Join("./tmp", name))
slog.Warn("Download attempt failed", "file", t.name, "attempt", attempt, "status", resp.StatusCode())
os.Remove(filepath.Join("./tmp", t.name))
time.Sleep(time.Duration(attempt) * time.Second)
continue
}
slog.Info("Downloaded", "file", name)
return nil
nv := dbValidator{
ETag: resp.Header().Get("ETag"),
LastModified: resp.Header().Get("Last-Modified"),
}
slog.Info("Downloaded", "file", t.name)
return nv, false, nil
}
return lastErr
return val, false, lastErr
}
func copyFile(src, dst string) error {
+226 -285
View File
@@ -2,11 +2,10 @@ package ipdb
import (
"bufio"
"math/rand"
"net"
"fmt"
"log/slog"
"math/rand"
"net"
"net/netip"
"os"
"strconv"
@@ -18,6 +17,7 @@ import (
"github.com/ip2location/ip2location-go/v9"
"github.com/lionsoul2014/ip2region/binding/golang/service"
maxminddb "github.com/oschwald/maxminddb-golang/v2"
"golang.org/x/sync/singleflight"
"resty.dev/v3"
)
@@ -36,13 +36,37 @@ var (
divisionShort map[int]string
divisionMu sync.RWMutex
bilibiliCache sync.Map
// bilibiliGroup 同一 IP 的并发未命中合并为一次上游调用(SearchIP 之外
// 定时任务/多 handler 并发触达同一 IP 时,避免重复打 B 站 API)
bilibiliGroup singleflight.Group
)
// bilibiliClient 共享的 B 站归属地客户端:连接池跨查询复用。
// 原实现每次 searchBilibili 都 resty.New(),每个未缓存 IP 都付一次完整 TCP+TLS 握手
// (实测首查 ~440ms,其中 TLS 209ms;复用后热查询 ~80ms)。进程生命周期内不 Close。
var bilibiliClient = resty.New().SetTimeout(10 * time.Second)
type bilibiliCacheEntry struct {
result *BilibiliResult
errMsg string // 非空 = 负缓存(上游失败),TTL 远短于正缓存,避免对非法/不可达 IP 反复打上游
timestamp time.Time
}
// ttl:正结果 24h(归属地几乎不变),负结果 1 分钟
func (e bilibiliCacheEntry) ttl() time.Duration {
if e.errMsg != "" {
return time.Minute
}
return 24 * time.Hour
}
func (e bilibiliCacheEntry) err() error {
if e.errMsg == "" {
return nil
}
return fmt.Errorf("%s", e.errMsg)
}
type MMDBCityResult struct {
Country string `json:"country"`
CountryCode string `json:"country_code"`
@@ -160,14 +184,12 @@ func loadDivisionData() error {
return nil
}
func loadIp2Region() error {
ip2regionMu.Lock()
defer ip2regionMu.Unlock()
// —— 加载器的统一模式:先在锁外构建新实例,再持锁原子换指针,旧实例放锁外 Close ——
// 原实现是「持锁 → 关旧 → 开新」,重载期间该库有几百 ms 到几秒的查询真空窗
// (查询拿到 "not loaded");换成换指针模式后窗口降到 μs 级。
// 榜样本来就是 reloadQQWry 的写法,这里推广到全部加载器。
if ip2region != nil {
ip2region.Close()
slog.Info("Previous ip2region closed")
}
func loadIp2Region() error {
v4Config, err := service.NewV4Config(service.VIndexCache, "ip2region_v4.xdb", 20)
if err != nil {
slog.Error("failed to create v4 config", "error", err)
@@ -180,161 +202,136 @@ func loadIp2Region() error {
return err
}
ip2region, err = service.NewIp2Region(v4Config, v6Config)
next, err := service.NewIp2Region(v4Config, v6Config)
if err != nil {
slog.Error("failed to create ip2region service", "error", err)
return err
}
ip2regionMu.Lock()
old := ip2region
ip2region = next
ip2regionMu.Unlock()
if old != nil {
old.Close()
slog.Info("Previous ip2region closed")
}
slog.Info("ip2region loaded successfully")
return nil
}
// loadIP2Location 加载 IP2Location LITE DB11 数据库(.BIN 格式,官方 ip2location-go 库读取)
func loadIP2Location() error {
ip2locMu.Lock()
defer ip2locMu.Unlock()
if ip2locDB != nil {
ip2locDB.Close()
ip2locDB = nil
slog.Info("Previous ip2location closed")
}
db, err := ip2location.OpenDB("./IP2LOCATION-LITE-DB11.IPV6.BIN")
if err != nil {
slog.Error("failed to open ip2location db", "error", err)
return err
}
ip2locMu.Lock()
old := ip2locDB
ip2locDB = db
ip2locMu.Unlock()
if old != nil {
old.Close()
slog.Info("Previous ip2location closed")
}
slog.Info("ip2location loaded successfully")
return nil
}
// loadIP2LocationASN 加载 IP2Location LITE ASN 数据库(仅 asn / as 字段)
func loadIP2LocationASN() error {
ip2locASNMu.Lock()
defer ip2locASNMu.Unlock()
if ip2locASNDB != nil {
ip2locASNDB.Close()
ip2locASNDB = nil
slog.Info("Previous ip2location asn closed")
}
db, err := ip2location.OpenDB("./IP2LOCATION-LITE-ASN.IPV6.BIN")
if err != nil {
slog.Error("failed to open ip2location asn db", "error", err)
return err
}
ip2locASNMu.Lock()
old := ip2locASNDB
ip2locASNDB = db
ip2locASNMu.Unlock()
if old != nil {
old.Close()
slog.Info("Previous ip2location asn closed")
}
slog.Info("ip2location asn loaded successfully")
return nil
}
func loadQQWry() error {
qqwryMu.Lock()
defer qqwryMu.Unlock()
if qqwryDB != nil {
qqwryDB = nil
slog.Info("Previous qqwry.ipdb closed")
}
var err error
qqwryDB, err = ipdbgo.NewCity("qqwry.ipdb")
if err != nil {
slog.Error("failed to load qqwry.ipdb", "error", err)
return err
}
slog.Info("qqwry.ipdb loaded successfully")
return nil
}
func reloadQQWry() error {
// 1. 先创建新实例
// 先建新实例,再持锁替换(旧实例由 GC 回收,ipdb-go 底层 mmap 会自动释放)
newDB, err := ipdbgo.NewCity("qqwry.ipdb")
if err != nil {
return fmt.Errorf("reload qqwry failed: %w", err)
slog.Error("failed to load qqwry.ipdb", "error", err)
return fmt.Errorf("load qqwry failed: %w", err)
}
// 2. 加锁替换(旧实例由 GC 回收,ipdb-go 底层 mmap 会自动释放)
qqwryMu.Lock()
qqwryDB = newDB
qqwryMu.Unlock()
slog.Info("qqwry.ipdb reloaded successfully")
slog.Info("qqwry.ipdb loaded successfully")
return nil
}
var mmdbFiles = map[string]string{
"geolite2_city": "GeoLite2-City.mmdb",
"geolite2_asn": "GeoLite2-ASN.mmdb",
"geocn": "GeoCN.mmdb",
"dbip_city": "dbip-city-lite.mmdb",
"dbip_asn": "dbip-asn-lite.mmdb",
}
// loadMMDB 重建全部 mmdb 并原子换指针。个别文件加载失败时沿用旧库(数据旧一点
// 好过整库消失),只有首次加载(无旧库)才真正缺库。
func loadMMDB() error {
mmdbMu.Lock()
defer mmdbMu.Unlock()
for name, db := range mmdbDBs {
db.Close()
slog.Info("Previous mmdb closed", "name", name)
}
mmdbFiles := map[string]string{
"geolite2_city": "GeoLite2-City.mmdb",
"geolite2_asn": "GeoLite2-ASN.mmdb",
"geocn": "GeoCN.mmdb",
"dbip_city": "dbip-city-lite.mmdb",
"dbip_asn": "dbip-asn-lite.mmdb",
}
mmdbDBs = make(map[string]*maxminddb.Reader)
next := make(map[string]*maxminddb.Reader, len(mmdbFiles))
for name, path := range mmdbFiles {
db, err := maxminddb.Open(path)
if err != nil {
slog.Error("failed to load mmdb", "name", name, "error", err)
continue
}
mmdbDBs[name] = db
next[name] = db
slog.Info("mmdb loaded", "name", name)
}
if len(mmdbDBs) < len(mmdbFiles) {
return fmt.Errorf("some mmdb files missing (%d/%d loaded)", len(mmdbDBs), len(mmdbFiles))
}
return nil
}
func reloadMMDB() error {
mmdbMu.Lock()
defer mmdbMu.Unlock()
for name, db := range mmdbDBs {
db.Close()
slog.Info("Previous mmdb closed", "name", name)
}
mmdbFiles := map[string]string{
"geolite2_city": "GeoLite2-City.mmdb",
"geolite2_asn": "GeoLite2-ASN.mmdb",
"geocn": "GeoCN.mmdb",
"dbip_city": "dbip-city-lite.mmdb",
"dbip_asn": "dbip-asn-lite.mmdb",
}
mmdbDBs = make(map[string]*maxminddb.Reader)
for name, path := range mmdbFiles {
db, err := maxminddb.Open(path)
if err != nil {
slog.Error("failed to reload mmdb", "name", name, "error", err)
continue
old := mmdbDBs
merged := make(map[string]*maxminddb.Reader, len(mmdbFiles))
for name := range mmdbFiles {
if db, ok := next[name]; ok {
merged[name] = db
} else if db, ok := old[name]; ok {
// 新文件损坏:沿用旧库,不跟着一起下线
merged[name] = db
delete(next, name)
}
mmdbDBs[name] = db
slog.Info("mmdb reloaded", "name", name)
}
mmdbDBs = merged
mmdbMu.Unlock()
// 换指针后再关被替换的旧库(合并时沿用的旧库保留,不关)
for name, db := range old {
if _, keep := merged[name]; !keep {
db.Close()
slog.Info("Previous mmdb closed", "name", name)
}
}
if len(merged) < len(mmdbFiles) {
return fmt.Errorf("some mmdb files missing (%d/%d loaded)", len(merged), len(mmdbFiles))
}
return nil
}
func searchMMDBCity(ip string) (*MMDBCityResult, error) {
mmdbMu.RLock()
defer mmdbMu.RUnlock()
db, ok := mmdbDBs["geolite2_city"]
if !ok {
return nil, fmt.Errorf("geolite2_city not loaded")
}
// searchMMDBCityIn 在指定库上查城市(searchMMDBCity / searchDBIPCity 的公共实现)
func searchMMDBCityIn(db *maxminddb.Reader, ip string) (*MMDBCityResult, error) {
addr, err := netip.ParseAddr(ip)
if err != nil {
return nil, err
@@ -385,72 +382,8 @@ func searchMMDBCity(ip string) (*MMDBCityResult, error) {
return result, nil
}
func searchDBIPCity(ip string) (*MMDBCityResult, error) {
mmdbMu.RLock()
defer mmdbMu.RUnlock()
db, ok := mmdbDBs["dbip_city"]
if !ok {
return nil, fmt.Errorf("dbip_city not loaded")
}
addr, err := netip.ParseAddr(ip)
if err != nil {
return nil, err
}
var record struct {
Country struct {
ISOCode string `maxminddb:"iso_code"`
Names map[string]string `maxminddb:"names"`
} `maxminddb:"country"`
Subdivisions []struct {
Names map[string]string `maxminddb:"names"`
} `maxminddb:"subdivisions"`
City struct {
Names map[string]string `maxminddb:"names"`
} `maxminddb:"city"`
Location struct {
Latitude float64 `maxminddb:"latitude"`
Longitude float64 `maxminddb:"longitude"`
} `maxminddb:"location"`
}
if err := db.Lookup(addr).Decode(&record); err != nil {
return nil, err
}
result := &MMDBCityResult{
CountryCode: record.Country.ISOCode,
Latitude: record.Location.Latitude,
Longitude: record.Location.Longitude,
}
if name, ok := record.Country.Names["zh-CN"]; ok {
result.Country = name
} else if name, ok := record.Country.Names["en"]; ok {
result.Country = name
}
if len(record.Subdivisions) > 0 {
if name, ok := record.Subdivisions[0].Names["zh-CN"]; ok {
result.Region = name
} else if name, ok := record.Subdivisions[0].Names["en"]; ok {
result.Region = name
}
}
if name, ok := record.City.Names["zh-CN"]; ok {
result.City = name
} else if name, ok := record.City.Names["en"]; ok {
result.City = name
}
return result, nil
}
func searchMMDBASN(ip string) (*MMDBASNResult, error) {
mmdbMu.RLock()
defer mmdbMu.RUnlock()
db, ok := mmdbDBs["geolite2_asn"]
if !ok {
return nil, fmt.Errorf("geolite2_asn not loaded")
}
// searchMMDBASNIn 在指定库上查 ASN(searchMMDBASN / searchDBIPASN 的公共实现)
func searchMMDBASNIn(db *maxminddb.Reader, ip string) (*MMDBASNResult, error) {
addr, err := netip.ParseAddr(ip)
if err != nil {
return nil, err
@@ -470,37 +403,50 @@ func searchMMDBASN(ip string) (*MMDBASNResult, error) {
}, nil
}
func searchMMDBCity(ip string) (*MMDBCityResult, error) {
mmdbMu.RLock()
db, ok := mmdbDBs["geolite2_city"]
mmdbMu.RUnlock()
if !ok {
return nil, fmt.Errorf("geolite2_city not loaded")
}
return searchMMDBCityIn(db, ip)
}
func searchDBIPCity(ip string) (*MMDBCityResult, error) {
mmdbMu.RLock()
db, ok := mmdbDBs["dbip_city"]
mmdbMu.RUnlock()
if !ok {
return nil, fmt.Errorf("dbip_city not loaded")
}
return searchMMDBCityIn(db, ip)
}
func searchMMDBASN(ip string) (*MMDBASNResult, error) {
mmdbMu.RLock()
db, ok := mmdbDBs["geolite2_asn"]
mmdbMu.RUnlock()
if !ok {
return nil, fmt.Errorf("geolite2_asn not loaded")
}
return searchMMDBASNIn(db, ip)
}
func searchDBIPASN(ip string) (*MMDBASNResult, error) {
mmdbMu.RLock()
defer mmdbMu.RUnlock()
db, ok := mmdbDBs["dbip_asn"]
mmdbMu.RUnlock()
if !ok {
return nil, fmt.Errorf("dbip_asn not loaded")
}
addr, err := netip.ParseAddr(ip)
if err != nil {
return nil, err
}
var record struct {
ASN uint32 `maxminddb:"autonomous_system_number"`
Org string `maxminddb:"autonomous_system_organization"`
}
if err := db.Lookup(addr).Decode(&record); err != nil {
return nil, err
}
return &MMDBASNResult{
ASN: fmt.Sprintf("AS%d", record.ASN),
Org: record.Org,
}, nil
return searchMMDBASNIn(db, ip)
}
func searchGeoCN(ip string) (*GeoCNResult, error) {
mmdbMu.RLock()
defer mmdbMu.RUnlock()
db, ok := mmdbDBs["geocn"]
mmdbMu.RUnlock()
if !ok {
return nil, fmt.Errorf("geocn not loaded")
}
@@ -536,107 +482,80 @@ func searchGeoCN(ip string) (*GeoCNResult, error) {
return result, nil
}
func searchBilibili(ip string) (*BilibiliResult, error) {
if net.ParseIP(ip) == nil {
return nil, fmt.Errorf("invalid IP address: %s", ip)
}
// loadBilibiliEntry 查缓存(按正/负各自的 TTL 判新鲜),过期顺手删除
func loadBilibiliEntry(ip string) (bilibiliCacheEntry, bool) {
if cached, ok := bilibiliCache.Load(ip); ok {
entry := cached.(bilibiliCacheEntry)
if time.Since(entry.timestamp) < 24*time.Hour {
return entry.result, nil
e := cached.(bilibiliCacheEntry)
if time.Since(e.timestamp) < e.ttl() {
return e, true
}
bilibiliCache.Delete(ip)
}
return bilibiliCacheEntry{}, false
}
client := resty.New().SetTimeout(10 * time.Second)
defer client.Close()
resp, err := client.R().
func fetchBilibili(ip string) bilibiliCacheEntry {
resp, err := bilibiliClient.R().
SetQueryParam("ip", ip).
SetResult(&BilibiliIPQueryResponse{}).
Get("https://api.live.bilibili.com/ip_service/v1/ip_service/get_ip_addr")
if err != nil {
return nil, err
return bilibiliCacheEntry{errMsg: err.Error(), timestamp: time.Now()}
}
if resp.IsError() {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode())
return bilibiliCacheEntry{errMsg: fmt.Sprintf("HTTP %d", resp.StatusCode()), timestamp: time.Now()}
}
result := &BilibiliResult{
Country: resp.Result().(*BilibiliIPQueryResponse).Data.Country,
Region: resp.Result().(*BilibiliIPQueryResponse).Data.Province,
City: resp.Result().(*BilibiliIPQueryResponse).Data.City,
ISP: resp.Result().(*BilibiliIPQueryResponse).Data.ISP,
Latitude: resp.Result().(*BilibiliIPQueryResponse).Data.Latitude,
Longitude: resp.Result().(*BilibiliIPQueryResponse).Data.Longitude,
r := resp.Result().(*BilibiliIPQueryResponse)
return bilibiliCacheEntry{
result: &BilibiliResult{
Country: r.Data.Country,
Region: r.Data.Province,
City: r.Data.City,
ISP: r.Data.ISP,
Latitude: r.Data.Latitude,
Longitude: r.Data.Longitude,
},
timestamp: time.Now(),
}
}
func searchBilibili(ip string) (*BilibiliResult, error) {
if net.ParseIP(ip) == nil {
// 非法 IP 是纯内存判断,不进缓存也不打上游
return nil, fmt.Errorf("invalid IP address: %s", ip)
}
bilibiliCache.Store(ip, bilibiliCacheEntry{result: result, timestamp: time.Now()})
return result, nil
if e, ok := loadBilibiliEntry(ip); ok {
return e.result, e.err()
}
v, _, _ := bilibiliGroup.Do(ip, func() (any, error) {
// singleflight 等待期间可能已被同 IP 的前一个请求填进缓存
if e, ok := loadBilibiliEntry(ip); ok {
return e, nil
}
e := fetchBilibili(ip)
bilibiliCache.Store(ip, e)
return e, nil
})
e := v.(bilibiliCacheEntry)
return e.result, e.err()
}
// SweepBilibiliCache 清理过期的 Bilibili 归属地缓存(由外部定时调用,
// 防止公网端点被唯一 key 洪打时缓存内存无限增长)
// 防止公网端点被唯一 key 洪打时缓存内存无限增长)。
// 负缓存的 TTL 只有 1 分钟,但条目若按 24h 清扫会滞留内存——按各自 TTL 清。
func SweepBilibiliCache() {
cutoff := time.Now().Add(-24 * time.Hour)
now := time.Now()
bilibiliCache.Range(func(k, v any) bool {
if e, ok := v.(bilibiliCacheEntry); ok && e.timestamp.Before(cutoff) {
if e, ok := v.(bilibiliCacheEntry); ok && now.Sub(e.timestamp) >= e.ttl() {
bilibiliCache.Delete(k)
}
return true
})
}
func loadAll() {
if err := loadIp2Region(); err != nil {
slog.Error("Error loading ip2region", "error", err)
}
if err := loadQQWry(); err != nil {
slog.Error("Error loading qqwry.ipdb", "error", err)
}
if err := loadMMDB(); err != nil {
slog.Error("Error loading mmdb", "error", err)
}
if err := loadDivisionData(); err != nil {
slog.Error("Error loading division data", "error", err)
}
}
func closeAllDBs() {
ip2regionMu.Lock()
if ip2region != nil {
ip2region.Close()
ip2region = nil
}
ip2regionMu.Unlock()
ip2locMu.Lock()
if ip2locDB != nil {
ip2locDB.Close()
ip2locDB = nil
}
ip2locMu.Unlock()
ip2locASNMu.Lock()
if ip2locASNDB != nil {
ip2locASNDB.Close()
ip2locASNDB = nil
}
ip2locASNMu.Unlock()
qqwryMu.Lock()
qqwryDB = nil
qqwryMu.Unlock()
mmdbMu.Lock()
for name, db := range mmdbDBs {
db.Close()
slog.Info("Closed mmdb", "name", name)
}
mmdbDBs = nil
mmdbMu.Unlock()
}
// reloadAll 全量重载(各加载器自身即「先建新再换指针」,重载期间查询不中断)
func reloadAll() {
if err := loadIp2Region(); err != nil {
slog.Error("Error loading ip2region", "error", err)
@@ -647,11 +566,11 @@ func reloadAll() {
if err := loadIP2LocationASN(); err != nil {
slog.Error("Error loading ip2location asn", "error", err)
}
if err := reloadQQWry(); err != nil {
slog.Error("Error reloading qqwry.ipdb", "error", err)
if err := loadQQWry(); err != nil {
slog.Error("Error loading qqwry.ipdb", "error", err)
}
if err := reloadMMDB(); err != nil {
slog.Error("Error reloading mmdb", "error", err)
if err := loadMMDB(); err != nil {
slog.Error("Error loading mmdb", "error", err)
}
if err := loadDivisionData(); err != nil {
slog.Error("Error reloading division data", "error", err)
@@ -659,7 +578,9 @@ func reloadAll() {
}
func Init(ghproxy string) {
// 先用本地文件全量加载;有缺失不再同步阻塞下载(原实现会把节点启动拖住几分钟,
// 下载 450MB 全程不对外服务)——SearchIP 的结果结构天然支持 "not loaded" 降级,
// 立即起服务,缺的库后台补齐后换指针上线。
localOK := true
if err := loadIp2Region(); err != nil {
slog.Warn("Local ip2region not available", "error", err)
@@ -689,13 +610,18 @@ func Init(ghproxy string) {
if localOK {
slog.Info("All databases loaded from local files")
} else {
slog.Info("Downloading databases...")
if pullErr := PullDatabase(ghproxy); pullErr != nil {
slog.Error("Some databases failed to download", "error", pullErr)
}
reloadAll()
slog.Info("Some databases missing, downloading in background (serving degraded results meanwhile)")
go func() {
if err := PullDatabase(ghproxy); err != nil {
slog.Error("Background database download failed", "error", err)
}
reloadAll()
slog.Info("Background database initialization completed")
}()
}
// 每日同步:PullDatabase 内部带条件请求(ETag/Last-Modified),文件没变的
// 节点只花 12 个小请求,不再全量重拉 450MB。
go func() {
for {
jitter := time.Duration(rand.Int63n(int64(time.Hour)))
@@ -706,7 +632,6 @@ func Init(ghproxy string) {
slog.Error("Database sync failed", "error", err)
continue
}
closeAllDBs()
reloadAll()
slog.Info("Database sync completed, waiting for next sync...")
}
@@ -722,8 +647,27 @@ func SearchIP(ip string, databases ...string) map[string]interface{} {
result := map[string]interface{}{"ip": ip}
// bilibili 是 10 个源里唯一的远程调用(实测 80~440ms,超时 10s),其余 9 个本地
// 库合计 <5ms:把它并行出去,不让整个接口陪它等;本地库保持串行
// (微秒级操作,并行反而净增调度开销)。结果写进独立变量,Wait 后合并——
// Go 的 map 并发写(哪怕 key 不同)是不安全的。
var wg sync.WaitGroup
var bili any
for _, name := range databases {
switch name {
case "bilibili":
wg.Add(1)
go func() {
defer wg.Done()
res, err := searchBilibili(ip)
if err != nil {
bili = "error: " + err.Error()
} else {
bili = res
}
}()
case "ip2region":
ip2regionMu.RLock()
if ip2region != nil {
@@ -859,19 +803,16 @@ func SearchIP(ip string, databases ...string) map[string]interface{} {
result["dbip_city"] = dbip
}
case "bilibili":
bilibili, err := searchBilibili(ip)
if err != nil {
result["bilibili"] = "error: " + err.Error()
} else {
result["bilibili"] = bilibili
}
default:
result[name] = "unknown database"
}
}
wg.Wait()
if bili != nil {
result["bilibili"] = bili
}
return result
}
+955 -505
View File
File diff suppressed because it is too large. Load diff
+15 -28
View File
@@ -19,7 +19,7 @@ import (
"time"
"github.com/coder/websocket"
"github.com/gin-gonic/gin"
"github.com/gofiber/fiber/v3"
)
// ==================== OTA 升级(收集中心下发,节点执行) ====================
@@ -51,22 +51,12 @@ const (
// otaAssetBaseDefault 按版本下发且未指定 assetBase 时的默认发布地址
const otaAssetBaseDefault = "https://github.com/nomdn/ipw-cn/releases/download"
// parseOTASwitch 解析 OTA 开关的字面值——启动加载(ENV / setting.json)、远端下发、运行时 PATCH
// 三处共用,保证什么算关闭的口径只有一份。
// OTA 开关(node-ota)的字面值解析已并入 main.go 的 parseBoolSwitch —— 原先这里有一份
// 独立的 parseOTASwitch(唯一认 yes/no 的),与 ipdb / block-private-ips / access-log
// 各写一套,同一个字面值在四个开关上判定不一致。现在四条路径共用一张表。
//
// 语义是缺省允许、显式关闭(见本文件头):true/1/yes/on 与空值都算开启,
// false/0/no/off 算关闭。known=false 表示写法认不出来——运行时 PATCH 据此回
// unknown 且不覆盖原值,启动期与远端下发则按缺省(允许)处理,不因一个手滑的
// 字面值把节点锁死。
func parseOTASwitch(raw string) (enabled, known bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "true", "1", "yes", "on":
return true, true
case "false", "0", "no", "off":
return false, true
}
return true, false
}
// 语义不变:缺省允许、显式关闭。启动时写法认不出来按缺省(允许)处理并告警,
// 运行时 PATCH 则回 unknown 且不覆盖原值。
// otaMu 单飞:同一时刻只允许一个 OTA 任务(重复下发直接拒绝)
var otaMu sync.Mutex
@@ -120,35 +110,32 @@ func otaReport(c *websocket.Conn, requestID string, ok bool, stage, errMsg strin
// registerOTARoute 注册 POST /v1/ota(收集中心 HTTP 回退通道)。
// 鉴权与 /v1/config 一致:access-token 未配置时整个 HTTP OTA 管理面关闭(RCE 面不能裸奔)。
func registerOTARoute(r *gin.Engine) {
func registerOTARoute(r *fiber.App) {
g := r.Group("/v1/ota")
if ACCESS_TOKEN == "" {
slog.Warn("ota HTTP API disabled: access-token not set, use WS channel instead")
g.POST("", func(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
g.Post("", func(c fiber.Ctx) error {
return c.Status(http.StatusForbidden).JSON(fiber.Map{
"error": "OTA 接口已关闭:节点未配置 access-token,请通过 WS 通道下发",
})
})
return
}
g.Use(tokenCheck())
g.POST("", func(c *gin.Context) {
g.Post("", func(c fiber.Ctx) error {
if !NODE_OTA {
c.JSON(http.StatusForbidden, gin.H{"error": "节点已禁用 OTA(node-ota=false)"})
return
return c.Status(http.StatusForbidden).JSON(fiber.Map{"error": "节点已禁用 OTA(node-ota=false)"})
}
var req otaRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
return
if err := c.Bind().JSON(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid payload: " + err.Error()})
}
if err := otaValidate(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
// 异步执行:下载可能耗时数分钟,HTTP 无进度通道,结果看节点日志与重连后的版本号
go otaRun(req, nil)
c.JSON(http.StatusAccepted, gin.H{"ok": true, "started": true})
return c.Status(http.StatusAccepted).JSON(fiber.Map{"ok": true, "started": true})
})
}
+23 -40
View File
@@ -12,7 +12,7 @@ import (
"time"
"github.com/coder/websocket"
"github.com/gin-gonic/gin"
"github.com/gofiber/fiber/v3"
)
// ==================== 数据上报(节点 → 收集中心 ipw-boce) ====================
@@ -83,35 +83,8 @@ func startNodeReporter() {
slog.Info("node reporter enabled", "ws", WS_URL != "", "httpFallback", REPORT_URL, "interval", REPORT_INTERVAL)
}
// nodeReportMiddleware /v1 组统计中间件:归属规则过滤 → 计数 → 拨测类补明细
func nodeReportMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
// 收集中心(ipw-boce)主动调度下发的拨测带 X-Scheduler-Probe 头:
// 该次执行由收集中心侧本地落库(source=sched/biz),本节点跳过计数与明细上报,避免双算。
// 真实业务请求(用户/中间件转发)不带此头,仍按"节点是唯一记录者"正常上报。
if c.Request.Header.Get("X-Scheduler-Probe") != "" {
return
}
apiType, raw := nodeAPIFromPath(c)
if apiType == "" {
return
}
latency := time.Since(start)
status := c.Writer.Status()
nodeRecordAPI(apiType, status, latency)
if nodeIsProbeType(apiType) {
nodeRecordProbe(nodeProbeRec{
NodeID: nodeReportNodeID(), APIType: apiType, Raw: raw,
Query: c.Request.URL.RawQuery, Status: status, LatencyMs: latency.Milliseconds(),
Source: "http", CreatedAt: time.Now().Unix(),
})
}
}
}
// nodeReportMiddleware 已移至 main.go(与 tokenCheck 放在一起,见该文件「鉴权 / 上报中间件」段)。
// 这里保留的只有上报的数据面:计数、拨测明细缓冲与周期上报。
// nodeRecordWSProbe WS 拨测出口(ws.go wsHandleProbe 调用)。
// 中间件已不在转发路径上计数,WS 下发的拨测同样由本节点上报(唯一记录者);
@@ -185,9 +158,18 @@ func nodeReportNodeID() string {
return "unknown"
}
// nodeAPIFromPath 从 gin 路由模板提取 apiType 与拨测目标(对齐 WS probe 的 raw 格式)
func nodeAPIFromPath(c *gin.Context) (apiType, raw string) {
path := c.FullPath() // 形如 /v1/tcping/:ip
// nodeAPIFromPath 从 fiber 路由模板提取 apiType 与拨测目标(对齐 WS probe 的 raw 格式)。
//
// 路由模板经 c.Route().Path 取(形如 /v1/tcping/:ip)。fiber 的通配符不支持命名,
// 统一是 `*`,取值走 c.Params("*")。两个 fiber 特有的坑:
//
// 1. c.Params / c.Route 返回的是**零拷贝字符串**(直接指向请求缓冲)。handler 执行期间
// 这份缓冲会被复用,所以调用方必须在 c.Next() 之前取值,这里再 Clone 一道双保险。
// 2. 通配符值**不带**前导斜杠(gin 的 `*url` 带)。原来靠 catch-all 自带 `/` 拼出
// "a/example.com"、"v4/example.com" 的 raw 格式,这里显式补回斜杠;detail / ssl
// 侧原本就 TrimPrefix("/"),fiber 下该操作变成无操作,结果一致。
func nodeAPIFromPath(c fiber.Ctx) (apiType, raw string) {
path := c.Route().Path
if !strings.HasPrefix(path, "/v1/") {
return "", ""
}
@@ -198,20 +180,21 @@ func nodeAPIFromPath(c *gin.Context) (apiType, raw string) {
apiType = seg[0]
switch apiType {
case "tcping", "dnssec", "whois":
raw = c.Param("ip")
raw = c.Params("ip")
if raw == "" {
raw = c.Param("domain")
raw = c.Params("domain")
}
case "speed":
raw = c.Param("version") + c.Param("url") // v4/example.com(对齐 WS probe raw 格式)
raw = c.Params("version") + "/" + c.Params("*") // v4/example.com(对齐 WS probe raw 格式)
case "dns":
raw = c.Param("type") + c.Param("domain") // a/example.com
raw = c.Params("type") + "/" + c.Params("*") // a/example.com
case "detail", "ssl":
raw = strings.TrimPrefix(c.Param("url"), "/")
raw = strings.TrimPrefix(c.Params("*"), "/")
case "location", "asn":
raw = c.Param("ip")
raw = c.Params("ip")
}
return apiType, raw
// Clone:切断对请求缓冲的引用(见上方说明 1)
return strings.Clone(apiType), strings.Clone(raw)
}
// nodeReportLoop 周期上报:WS 在线走 WS 广播(所有已注册中间件),否则 HTTP POST 收集中心
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"log/slog"
"math"
"os"
"runtime/debug"
"strconv"
"strings"
"resty.dev/v3"
)
// 本文件集中放"资源上限"的处理:单次拨测的响应体上限、Go 运行时的软内存上限。
// 起因:1c1g 节点被单个请求打爆(响应体整块进堆,见 webtest.readBodySize 注释)。
//
// **本文件不读配置源**(不碰 os.Getenv / viper):配置的读取口径统一在 main.go 的 readConfig,
// 这里只负责"拿到值之后怎么用" —— 字节后缀解析(parseByteSize)与应用到运行时(apply*)。
// defaultMaxResponseBody 单次拨测响应体的默认上限(32 MiB,解压后)。
//
// 取 32MB 的理由:detail/speed/ssl 都只需要一个字节数,正常网页远小于此值;
// 而节点 RAM 只有 GB 级,没有上限时一个几百 MB 的响应体就能吃掉全部内存。
// 超过上限不是截断上报,而是直接返回错误 —— 报一个"截断后的大小/速度"比报错更误导人。
const defaultMaxResponseBody int64 = 32 << 20
// zstdMaxWindow zstd 解码窗口上限(8 MiB)。
//
// klauspost/compress 默认允许 512MB 窗口、64GiB 解码量,默认并发 4。窗口是在解析帧头时
// 就按流里声明的值分配的,早于任何输出,所以 max-response-body 拦不住它 ——
// 一条声明超大窗口的恶意 zstd 流即可让节点瞬间吃掉几百 MB。
// HTTP 场景下正常服务端的窗口远小于此值(RFC 8878 同样建议流式场景限制窗口)。
const zstdMaxWindow = 8 << 20
// parseByteSize 解析字节数:支持裸数字与 K/M/G 后缀(KB/MB/GB、KiB/MiB/GiB 亦可),大小写不敏感。
// 例:"33554432"、"32MB"、"8MiB"、"900M"。
func parseByteSize(s string) (int64, bool) {
s = strings.ToUpper(strings.TrimSpace(s))
if s == "" {
return 0, false
}
s = strings.TrimSuffix(s, "B") // 32MB -> 32M
s = strings.TrimSuffix(s, "I") // 32Mi -> 32M
if s == "" {
return 0, false
}
mult := int64(1)
switch s[len(s)-1] {
case 'K':
mult, s = 1<<10, s[:len(s)-1]
case 'M':
mult, s = 1<<20, s[:len(s)-1]
case 'G':
mult, s = 1<<30, s[:len(s)-1]
}
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
if err != nil {
return 0, false
}
return n * mult, true
}
// applyResponseBodyLimit 把当前 MAX_RESPONSE_BODY 应用到出站客户端(启动与配置热更新都走这里)。
// SetResponseBodyLimit 内部有锁,可与在途请求并发调用。
func applyResponseBodyLimit() {
for _, c := range []*resty.Client{V4Client, V6Client} {
if c != nil {
c.SetResponseBodyLimit(MAX_RESPONSE_BODY)
}
}
}
// applyMemoryLimit 设置 Go 运行时的软内存上限(等价于 GOMEMLIMIT,可用 debug.SetMemoryLimit 热改)。
//
// 取值口径:读全局 MEMORY_LIMIT(启动时由 resolveByteLimit 填好,配置热更新时由
// applyConfigMap 直接赋值)。**不要在这里再解析一次配置源** —— 那样配置热更新刚写进
// MEMORY_LIMIT 的值会被重新解析覆盖成旧值,PATCH 静默失效。
//
// MEMORY_LIMIT<=0 时回落到自动探测容器限额的 90%;仍探测不到就不设置(保持 Go 默认)。
// 目的:不设时 Go 只按 GOGC 比例回收,够不到"突增";小内存机器上堆会一路涨到被内核 OOM kill,
// 中间没有减速带。设了软上限后,逼近限额时 GC 会提前加密。
// 注意软上限不是硬闸:必要时运行时仍会越过它,只是付出更多 GC 代价。
func applyMemoryLimit() {
limit := MEMORY_LIMIT
if limit <= 0 {
if cg := detectCgroupMemoryLimit(); cg > 0 {
limit = cg / 10 * 9
}
}
if limit <= 0 {
debug.SetMemoryLimit(math.MaxInt64)
slog.Info("No memory limit configured or detected, runtime keeps default GC behavior")
return
}
debug.SetMemoryLimit(limit)
slog.Info("Runtime soft memory limit applied", "bytes", limit, "mb", limit>>20)
}
// detectCgroupMemoryLimit 读容器内存限额(字节)。不在容器里 / 未限额 / 读不到时返回 0。
// cgroup v2: /sys/fs/cgroup/memory.max(无限时值为 "max")
// cgroup v1: /sys/fs/cgroup/memory/memory.limit_in_bytes(无限时是接近 int64 上限的大数)
func detectCgroupMemoryLimit() int64 {
for _, p := range []string{
"/sys/fs/cgroup/memory.max",
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
} {
raw, err := os.ReadFile(p)
if err != nil {
continue
}
s := strings.TrimSpace(string(raw))
if s == "" || s == "max" {
return 0
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil || n <= 0 {
continue
}
if n >= 1<<62 { // "无限制"的常见写法
return 0
}
return n
}
return 0
}
+3 -11
View File
@@ -4,18 +4,10 @@
"single-stack": "",
"dns-server": "",
"dnssec-server": "",
"block-private-ips": true,
"ipdb": "false",
"ipdb": false,
"cors": "",
"trusted-proxies": "",
"remote-config-url": "",
"remote-ignore-config": [],
"access-token": "",
"ws-url": "",
"node-id": "",
"node-key": "",
"node-ota": "true",
"report-url": "",
"report-token": "",
"report-interval-seconds": 15
}
"node-key": ""
}
+20 -3
View File
@@ -37,6 +37,23 @@ func Enabled() bool {
return blockPrivateIPs
}
// ResolveFunc 把主机名解析为 IP 列表。默认 net.LookupIP(系统解析器)。
// 可由上层(webtest)通过 SetResolveFunc 注入,使出站校验与真正的拨号走同一个 DNS
// ——包括请求级 DNS 服务器覆盖。否则预检(系统解析器)与拨号(自定义 DNS)会用两套解析器,
// 既多一次查询,结果也可能不一致。
type ResolveFunc func(ctx context.Context, host string) ([]net.IP, error)
var resolveHostFn ResolveFunc = func(_ context.Context, host string) ([]net.IP, error) {
return net.LookupIP(host)
}
// SetResolveFunc 注入自定义解析器(传 nil 忽略,保持默认 net.LookupIP)。
func SetResolveFunc(f ResolveFunc) {
if f != nil {
resolveHostFn = f
}
}
// SetEnabled enables or disables SSRF protection globally.
// SetEnabled 全局启用或禁用 SSRF 防护。
// Typically called during configuration loading (readConfig).
@@ -99,7 +116,7 @@ func IsPrivateIP(ip net.IP) bool {
// Used in handlers to short-circuit requests to internal hosts before making outbound connections.
// 用于在发起出站连接前,在处理器中短路对内部主机的请求。
func HasLocalOrPrivateIP(host string) bool {
ips, err := net.LookupIP(host)
ips, err := resolveHostFn(context.Background(), host)
if err != nil {
return false
}
@@ -142,7 +159,7 @@ func ValidateOutboundTarget(ctx context.Context, targetURL string) (context.Cont
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return ctx, fmt.Errorf("invalid scheme: %s", parsed.Scheme)
}
ips, err := net.LookupIP(host)
ips, err := resolveHostFn(ctx, host)
if err != nil {
return ctx, err
}
@@ -179,7 +196,7 @@ func SecureCheckRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
ips, err := net.LookupIP(host)
ips, err := resolveHostFn(req.Context(), host)
if err != nil {
return err
}
+73 -1
View File
@@ -1,6 +1,13 @@
package webtest
import "resty.dev/v3"
import (
"context"
"errors"
"fmt"
"io"
"resty.dev/v3"
)
// 出站 HTTP 客户端(定义与初始化仍在 main.go,启动时经 SetHTTPClient 注入)
var v4Client, v6Client *resty.Client
@@ -18,3 +25,68 @@ func httpClient(version string) *resty.Client {
}
return v4Client
}
// newProbeRequest 构造拨测请求:开 trace(要 DNS/TCP/TLS 各段计时)、绑 context,
// 并关掉 resty 的自动解析(SetDoNotParseResponse)—— 这一条是关键,见 readBodySize。
//
// 必须走这个构造函数,不要在调用点手写请求链:漏掉 DoNotParseResponse 时
// resty 的 AutoParseResponseMiddleware 会替我们把整个响应体读进 bodyBytes
// (Content-Type 不是 JSON/XML 时它就 readAll),响应体上限和流式计数全部失效。
func newProbeRequest(version string, ctx context.Context) *resty.Request {
return httpClient(version).R().
EnableTrace().
SetContext(ctx).
SetDoNotParseResponse(true)
}
// readBodySize 流式读完响应体,返回**解压后**的字节数。
//
// 不用 resp.Bytes():它只是把 resty 已经缓存的 bodyBytes 返回给调用方,而缓存动作
// 发生在 Get() 内部 —— resty 的 AutoParseResponseMiddleware 对没注册解码器的
// Content-Type(text/html、text/plain、octet-stream……差不多就是全部被测站点)
// 会直接 res.readAll(),等价于 io.ReadAll,整个响应体留在堆上。
// detail / speed / ssl 要的只是一个计数(page_size、download_speed),而响应体大小
// 完全由被测站点决定:一个压缩炸弹(Content-Encoding: gzip/zstd)能把几百 KB 的响应
// 放大到几百 MB,1c1g 节点单次请求即可 OOM。
// 配合 newProbeRequest 的 DoNotParseResponse,这里边读边丢,内存恒定,
// 与响应体大小无关。
//
// 兜底上限由客户端侧的 SetResponseBodyLimit(配置项 max-response-body)提供:
// 超限时 resty 的 limitReadCloser 会返回 ErrReadExceedsThresholdLimit,
// 这里转成可读的错误,而不是悄悄把截断的字节数当成页面大小报出去。
func readBodySize(resp *resty.Response) (int64, error) {
if resp == nil || resp.Body == nil {
return 0, nil
}
n, err := io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
if err != nil {
if errors.Is(err, resty.ErrReadExceedsThresholdLimit) {
return n, fmt.Errorf("response body exceeds the limit of %d bytes (max-response-body)", responseBodyLimit(resp))
}
return n, err
}
return n, nil
}
// responseBodyLimit 取本次请求实际生效的响应体上限(<=0 表示不限制)
func responseBodyLimit(resp *resty.Response) int64 {
if resp == nil || resp.Request == nil {
return 0
}
return resp.Request.ResponseBodyLimit
}
// closeResponseBody 尽力释放失败请求的响应体。
//
// 为什么需要:resty 除了传输错误,还会在**响应体解压失败**时提前返回
// (例如目标回了未注册的 Content-Encoding,client.go 里 wrapContentDecompresser 直接返回
// ErrContentDecompresserNotFound)。这时 Response.Body 已经挂上原始响应体但没有任何人关闭,
// 而我们随后就走 `if err != nil { return nil, err }` 把 Response 丢掉 ——
// 每个这样的请求漏一个连接/FD,且因为 DisableKeepAlives,要等 GC 终结器才回收。
// 目标站点只要固定回一个没注册的 Content-Encoding,就能一点点耗掉节点的 FD。
func closeResponseBody(resp *resty.Response) {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
}
+296 -40
View File
@@ -2,12 +2,14 @@ package webtest
import (
"bytes"
"context"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
@@ -19,8 +21,10 @@ import (
var (
// dnsServers 普通 DNS 查询服务器列表(主从:第一个为主,主失败自动切换后续从服务器)
dnsServers = []string{"119.28.28.28:53"}
// dnssecServers DNSSEC 专用服务器列表(未配置时沿用 dnsServers)
dnssecServers []string
// dnssecServers DNSSEC 专用服务器列表:默认内置实测支持 DO 位的境外解析器
//(国内公共 DNS 带 DO 也不返回 RRSIG,普通解析回退会让验签永远失败,见 dnssec.go)。
// 网络屏蔽/劫持这些 UDP 53 时请配置 dnssec-server 指向可达的解析器。
dnssecServers = defaultDOCapableServers
)
// SetDNSServer 设置DNS服务器地址(逗号分隔多地址:第一个为主,主失败自动切换后续从服务器)。
@@ -102,17 +106,230 @@ const (
defaultDoHEndpoint = "https://doh.pub/dns-query"
)
// queryDNS 普通 DNS 查询:主从 failover——按配置顺序依次尝试每个服务器,
// 当前服务器查询失败(网络错误/超时)自动切换下一个;全部失败返回最后一个错误。
func queryDNS(msg *dns.Msg) (*dns.Msg, error) {
return queryWithServers(msg, dnsServers)
// NormalizeServers 解析「逗号分隔的 DNS 服务器」字符串并做格式校验,规则与配置侧一致:
// 每项为 "ip:port"(UDP/TCP)或 http(s):// DoH URL;非法项丢弃(记 Warn)。全非法时返回空。
// 供请求级参数(dns / dns4 / dns6)复用,避免两套校验漂移。
func NormalizeServers(s string) []string {
return validServers(splitServers(s))
}
// queryDNSSEC DNSSEC 查询:优先使用专用服务器(dnssec-server),未配置则沿用普通 dns-server
func queryDNSSEC(msg *dns.Msg) (*dns.Msg, error) {
if len(dnssecServers) > 0 {
return queryWithServers(msg, dnssecServers)
// ---- 请求级解析配置:dns*/resolve* ----
//
// dns=1.1.1.1:53 两栈都用这个服务器(换服务器去问)
// dns4=1.1.1.1:53,dns6=[2400::1]:53 按栈覆盖(优先于 dns=)
// resolve=example.com:1.2.3.4 两栈都把 example.com 解析成 1.2.3.4(直接改答案)
// resolve4=example.com:1.2.3.4 只 IPv4 侧生效(优先于 resolve=)
// resolve6=example.com:[::1] 只 IPv6 侧生效(优先于 resolve=)
// resolve=www.example.com:origin.net 目标是域名时把它当作新的 host 再解析
//
// 只在「域名 → IP」这一步生效:detail/ssl/speed(经 DialContext)、tcping、SSRF 预检;
// whois 与 dns 拨测不接 resolve*(前者只做归属查询,后者问的就是真实解析)。
//
// 为什么走 context:HTTP 拨测的解析发生在 net/http 的 DialContext 里,那里只有 ctx、
// 没有直接入参;存进 ctx 后 DialContext 与 SSRF 预检便能同源读到同一份配置。
// ReqResolve 一次拨测的请求级解析配置。零值 = 没有任何请求级覆盖。
type ReqResolve struct {
dns []string // dns=
dns4 []string // dns4=
dns6 []string // dns6=
resolve map[string]string // resolve=:域名 → IP 或另一域名
resolve4 map[string]string // resolve4=
resolve6 map[string]string // resolve6=
}
// ParseReqResolve 从 query 解析全部 9 个参数(dns/dns4/dns6、resolve/resolve4/resolve6)。
// 参数存在但**全部项非法**时报错(handler 返 400),避免「看起来指定了、实际被静默忽略」;
// 缺省/为空则视为不覆盖。
func ParseReqResolve(get func(string) string) (ReqResolve, error) {
var out ReqResolve
// DNS 服务器:逗号分隔的 ip:port 或 DoH URL
for _, item := range []struct {
key string
dst *[]string
}{
{"dns", &out.dns}, {"dns4", &out.dns4}, {"dns6", &out.dns6},
} {
if raw := strings.TrimSpace(get(item.key)); raw != "" {
if list := NormalizeServers(raw); len(list) > 0 {
*item.dst = list
} else {
return ReqResolve{}, fmt.Errorf("%s must be a comma-separated list of ip:port or DoH URLs", item.key)
}
}
}
// 强制解析:逗号分隔的 域名:IP 或 域名:域名(后者按新 host 再解析)
for _, item := range []struct {
key string
dst *map[string]string
}{
{"resolve", &out.resolve}, {"resolve4", &out.resolve4}, {"resolve6", &out.resolve6},
} {
if raw := strings.TrimSpace(get(item.key)); raw != "" {
if m := parseResolveMap(raw); len(m) > 0 {
*item.dst = m
} else {
return ReqResolve{}, fmt.Errorf("%s must be a comma-separated list of host:ip or host:cname", item.key)
}
}
}
return out, nil
}
// parseResolveMap 把「域名:目标,域名:目标」切成映射表,非法项丢弃。
// 每组按**第一个冒号**切:域名不含冒号,IPv6 目标可带或不带方括号。
func parseResolveMap(s string) map[string]string {
out := map[string]string{}
for _, item := range strings.Split(s, ",") {
item = strings.TrimSpace(item)
idx := strings.Index(item, ":")
if idx <= 0 || idx == len(item)-1 {
continue
}
host, target := canonicalHost(item[:idx]), strings.TrimSpace(item[idx+1:])
if host == "" || strings.ContainsAny(host, " /\\") || !validResolveTarget(target) {
continue
}
out[host] = target
}
return out
}
// canonicalHost 归一化主机名:去首尾空白、转小写、去结尾的点,便于与查询域名匹配。
func canonicalHost(host string) string {
return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
}
// validResolveTarget 目标必须是 IP 或形如域名的字符串(含点、无空格、无端口)。
func validResolveTarget(target string) bool {
t := strings.Trim(target, "[]")
if net.ParseIP(t) != nil {
return true
}
// 非 IP 却含冒号:视为「带端口」的误用,丢弃(映射不需要端口)
if strings.Contains(t, ":") || !strings.Contains(t, ".") || strings.ContainsAny(t, " /\\") {
return false
}
for _, r := range t {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' ||
r == '.' || r == '-' || r == '_' || r == '*' {
continue
}
return false
}
return true
}
// Empty 无任何请求级覆盖(此时才允许走共享缓存)。
func (r ReqResolve) Empty() bool {
return len(r.dns)+len(r.dns4)+len(r.dns6)+len(r.resolve)+len(r.resolve4)+len(r.resolve6) == 0
}
// Servers 按栈名取本次生效的 DNS 服务器:stack 为 "ipv4"/"ipv6" 时优先取对应覆盖,
// 其余(含空串)取 dns= 默认;最终为 nil 表示用配置 dns-server。
// 供 whois、dns 拨测等按记录类型选栈的调用方使用。
func (r ReqResolve) Servers(stack string) []string {
switch stack {
case "ipv4":
if len(r.dns4) > 0 {
return r.dns4
}
case "ipv6":
if len(r.dns6) > 0 {
return r.dns6
}
}
return r.dns
}
// Override 返回该域名在指定族下的强制解析目标;"" 表示未命中(照常走 DNS)。
// 优先级与 dns* 对称:resolve4/resolve6 非空时覆盖 resolve。
// 目标是 IP 还是域名由调用方判断——IP 直连、域名当作新 host 再解析。
func (r ReqResolve) Override(host string, wantV6 bool) string {
m := r.resolve
if wantV6 && len(r.resolve6) > 0 {
m = r.resolve6
} else if !wantV6 && len(r.resolve4) > 0 {
m = r.resolve4
}
return m[canonicalHost(host)]
}
// CacheKey 返回请求级配置的规范化片段,拼在拨测缓存键末尾。
// 零值返回空串(缓存键与「没有任何请求级参数」时完全一致);
// 否则形如 ";dns=1.1.1.1:53,8.8.8.8:53;resolve6=example.com:[::1]"。
//
// 逐字段拼接而不是 join 结构体:字段名与顺序由这里固定,避免参数书写顺序
// (dns= 在前还是 dns6= 在前)把同一个请求拆成两条缓存。
func (r ReqResolve) CacheKey() string {
var b strings.Builder
for _, f := range []struct {
name string
vals []string
}{
{"dns", r.dns}, {"dns4", r.dns4}, {"dns6", r.dns6},
} {
if len(f.vals) == 0 {
continue
}
b.WriteString(";")
b.WriteString(f.name)
b.WriteString("=")
b.WriteString(strings.Join(f.vals, ","))
}
for _, f := range []struct {
name string
vals map[string]string
}{
{"resolve", r.resolve}, {"resolve4", r.resolve4}, {"resolve6", r.resolve6},
} {
if len(f.vals) == 0 {
continue
}
// map 迭代顺序随机:必须按键排序,否则同一请求每次拼出的键都不同,缓存永远命不中
keys := make([]string, 0, len(f.vals))
for k := range f.vals {
keys = append(keys, k)
}
sort.Strings(keys)
pairs := make([]string, 0, len(keys))
for _, k := range keys {
pairs = append(pairs, k+":"+f.vals[k])
}
b.WriteString(";")
b.WriteString(f.name)
b.WriteString("=")
b.WriteString(strings.Join(pairs, ","))
}
return b.String()
}
// reqResolveCtxKey 请求级解析配置在 context 中的键。
type reqResolveCtxKey struct{}
// WithReqResolve 把请求级解析配置写进 context(空配置原样返回,零开销)。
func WithReqResolve(ctx context.Context, r ReqResolve) context.Context {
if r.Empty() {
return ctx
}
return context.WithValue(ctx, reqResolveCtxKey{}, r)
}
// ReqResolveFromContext 取出请求级解析配置(零值 = 无覆盖)。
func ReqResolveFromContext(ctx context.Context) ReqResolve {
if ctx == nil {
return ReqResolve{}
}
if v, ok := ctx.Value(reqResolveCtxKey{}).(ReqResolve); ok {
return v
}
return ReqResolve{}
}
// queryDNS 普通 DNS 查询:使用配置的 dns-server(主从 failover,见 queryWithServers)。
// resolveTyped 未收到请求级 servers 时走这里。
func queryDNS(msg *dns.Msg) (*dns.Msg, error) {
return queryWithServers(msg, dnsServers)
}
@@ -206,13 +423,21 @@ type DNSResult struct {
// 即视为一次有效查询,将 Rcode 透出并返回 200;仅在传输/网络层失败(超时、服务器不可达、
// DoH 异常)时才返回 error(上层据此返回 500)。避免把合法的负响应误判为节点故障。
// extract 从一个 RR 中抽取记录字符串(TXT 可能含多条),返回 (values, ttl, matched)。
func resolveTyped(domain, name string, qtype uint16, extract func(dns.RR) ([]string, uint32, bool)) (DNSResult, error) {
// servers 为请求级 DNS 覆盖:非空时用它(queryWithServers),为空则用配置 dns-server(queryDNS)。
func resolveTyped(domain, name string, qtype uint16, servers []string, extract func(dns.RR) ([]string, uint32, bool)) (DNSResult, error) {
start := time.Now()
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(name), qtype)
result := DNSResult{Domain: domain}
response, err := queryDNS(msg)
// 请求级 servers 优先;未传(nil/空)走配置 dns-server。
var response *dns.Msg
var err error
if len(servers) > 0 {
response, err = queryWithServers(msg, servers)
} else {
response, err = queryDNS(msg)
}
result.Duration = time.Since(start).Seconds() * 1000
if err != nil {
slog.Warn("Failed to query DNS", "domain", domain, "qtype", qtype, "error", err)
@@ -235,8 +460,12 @@ func resolveTyped(domain, name string, qtype uint16, extract func(dns.RR) ([]str
return result, nil
}
func ResolveARecord(domain string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeA, func(rr dns.RR) ([]string, uint32, bool) {
// ---- 记录解析:每类一个函数,第三个参数 servers 为「请求级 DNS 覆盖」 ----
//
// servers 为空 → 走 resolveTyped 的 queryDNS(配置 dns-server);非空 → queryWithServers(请求级覆盖)。
func ResolveARecord(domain string, servers []string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeA, servers, func(rr dns.RR) ([]string, uint32, bool) {
if a, ok := rr.(*dns.A); ok {
return []string{a.A.String()}, a.Header().Ttl, true
}
@@ -244,8 +473,8 @@ func ResolveARecord(domain string) (DNSResult, error) {
})
}
func ResolveAAAARecord(domain string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeAAAA, func(rr dns.RR) ([]string, uint32, bool) {
func ResolveAAAARecord(domain string, servers []string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeAAAA, servers, func(rr dns.RR) ([]string, uint32, bool) {
if a, ok := rr.(*dns.AAAA); ok {
return []string{a.AAAA.String()}, a.Header().Ttl, true
}
@@ -253,8 +482,8 @@ func ResolveAAAARecord(domain string) (DNSResult, error) {
})
}
func ResolveTXTRecord(domain string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeTXT, func(rr dns.RR) ([]string, uint32, bool) {
func ResolveTXTRecord(domain string, servers []string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeTXT, servers, func(rr dns.RR) ([]string, uint32, bool) {
if txt, ok := rr.(*dns.TXT); ok {
// TXT 一条 RR 可能含多段字符串,保持原有「逐段单独成项」的行为
return txt.Txt, txt.Header().Ttl, true
@@ -263,8 +492,8 @@ func ResolveTXTRecord(domain string) (DNSResult, error) {
})
}
func ResolveNSRecord(domain string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeNS, func(rr dns.RR) ([]string, uint32, bool) {
func ResolveNSRecord(domain string, servers []string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeNS, servers, func(rr dns.RR) ([]string, uint32, bool) {
if ns, ok := rr.(*dns.NS); ok {
return []string{ns.Ns}, ns.Header().Ttl, true
}
@@ -272,8 +501,8 @@ func ResolveNSRecord(domain string) (DNSResult, error) {
})
}
func ResolveCNAMERecord(domain string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeCNAME, func(rr dns.RR) ([]string, uint32, bool) {
func ResolveCNAMERecord(domain string, servers []string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeCNAME, servers, func(rr dns.RR) ([]string, uint32, bool) {
if cname, ok := rr.(*dns.CNAME); ok {
return []string{cname.Target}, cname.Header().Ttl, true
}
@@ -281,8 +510,8 @@ func ResolveCNAMERecord(domain string) (DNSResult, error) {
})
}
func ResolveMXRecord(domain string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeMX, func(rr dns.RR) ([]string, uint32, bool) {
func ResolveMXRecord(domain string, servers []string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeMX, servers, func(rr dns.RR) ([]string, uint32, bool) {
if mx, ok := rr.(*dns.MX); ok {
return []string{mx.Mx}, mx.Header().Ttl, true
}
@@ -290,8 +519,8 @@ func ResolveMXRecord(domain string) (DNSResult, error) {
})
}
func ResolveSRVRecord(domain string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeSRV, func(rr dns.RR) ([]string, uint32, bool) {
func ResolveSRVRecord(domain string, servers []string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeSRV, servers, func(rr dns.RR) ([]string, uint32, bool) {
if srv, ok := rr.(*dns.SRV); ok {
return []string{srv.Target}, srv.Header().Ttl, true
}
@@ -299,8 +528,8 @@ func ResolveSRVRecord(domain string) (DNSResult, error) {
})
}
func ResolveCAARecord(domain string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeCAA, func(rr dns.RR) ([]string, uint32, bool) {
func ResolveCAARecord(domain string, servers []string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeCAA, servers, func(rr dns.RR) ([]string, uint32, bool) {
if caa, ok := rr.(*dns.CAA); ok {
return []string{caa.Value}, caa.Header().Ttl, true
}
@@ -308,13 +537,13 @@ func ResolveCAARecord(domain string) (DNSResult, error) {
})
}
func ResolvePTRRecord(ip string) (DNSResult, error) {
func ResolvePTRRecord(ip string, servers []string) (DNSResult, error) {
ptrName, err := dns.ReverseAddr(ip)
if err != nil {
slog.Warn("Invalid IP address for PTR query", "ip", ip, "error", err)
return DNSResult{Domain: ip, Record: []string{}}, fmt.Errorf("invalid IP address: %v", err)
}
return resolveTyped(ip, ptrName, dns.TypePTR, func(rr dns.RR) ([]string, uint32, bool) {
return resolveTyped(ip, ptrName, dns.TypePTR, servers, func(rr dns.RR) ([]string, uint32, bool) {
if ptr, ok := rr.(*dns.PTR); ok {
return []string{ptr.Ptr}, ptr.Header().Ttl, true
}
@@ -322,6 +551,32 @@ func ResolvePTRRecord(ip string) (DNSResult, error) {
})
}
// ResolveByType 按记录类型名(a/aaaa/cname/mx/ns/txt/srv/ptr/caa)解析,servers 为请求级覆盖。
// DNS 拨测的 HTTP 路由与 WS 报文统一走这里,避免两处各维护一套 switch。
func ResolveByType(domain, recodeType string, servers []string) (DNSResult, error) {
switch recodeType {
case "a":
return ResolveARecord(domain, servers)
case "aaaa":
return ResolveAAAARecord(domain, servers)
case "cname":
return ResolveCNAMERecord(domain, servers)
case "mx":
return ResolveMXRecord(domain, servers)
case "ns":
return ResolveNSRecord(domain, servers)
case "srv":
return ResolveSRVRecord(domain, servers)
case "txt":
return ResolveTXTRecord(domain, servers)
case "ptr":
return ResolvePTRRecord(domain, servers)
case "caa":
return ResolveCAARecord(domain, servers)
}
return DNSResult{}, fmt.Errorf("unsupported record type: %s", recodeType)
}
// DNSFullResult 完整的DNS查询结果(不包含PTR,PTR需单独查询)
type DNSFullResult struct {
Domain string `json:"domain"`
@@ -335,8 +590,9 @@ type DNSFullResult struct {
CAA DNSResult `json:"caa"`
}
// ResolveARecordllDNSRecords 并行查询所有主流DNS记录类型(不包含PTR)
func ResolveARecordllDNSRecords(domain string) DNSFullResult {
// ResolveARecordllDNSRecords 并行查询所有主流DNS记录类型(不包含PTR)。
// servers 为请求级 DNS 覆盖(空则用配置 dns-server)。
func ResolveARecordllDNSRecords(domain string, servers []string) DNSFullResult {
result := DNSFullResult{Domain: domain}
var wg sync.WaitGroup
@@ -344,42 +600,42 @@ func ResolveARecordllDNSRecords(domain string) DNSFullResult {
go func() {
defer wg.Done()
result.A, _ = ResolveARecord(domain)
result.A, _ = ResolveARecord(domain, servers)
}()
go func() {
defer wg.Done()
result.AAAA, _ = ResolveAAAARecord(domain)
result.AAAA, _ = ResolveAAAARecord(domain, servers)
}()
go func() {
defer wg.Done()
result.CNAME, _ = ResolveCNAMERecord(domain)
result.CNAME, _ = ResolveCNAMERecord(domain, servers)
}()
go func() {
defer wg.Done()
result.MX, _ = ResolveMXRecord(domain)
result.MX, _ = ResolveMXRecord(domain, servers)
}()
go func() {
defer wg.Done()
result.NS, _ = ResolveNSRecord(domain)
result.NS, _ = ResolveNSRecord(domain, servers)
}()
go func() {
defer wg.Done()
result.TXT, _ = ResolveTXTRecord(domain)
result.TXT, _ = ResolveTXTRecord(domain, servers)
}()
go func() {
defer wg.Done()
result.SRV, _ = ResolveSRVRecord(domain)
result.SRV, _ = ResolveSRVRecord(domain, servers)
}()
go func() {
defer wg.Done()
result.CAA, _ = ResolveCAARecord(domain)
result.CAA, _ = ResolveCAARecord(domain, servers)
}()
wg.Wait()
+119 -36
View File
@@ -3,24 +3,60 @@ package webtest
import (
"fmt"
"strings"
"sync"
"time"
"github.com/miekg/dns"
)
// —— DNSSEC 默认解析服务器 ——
//
// 根因(实测):国内公共解析器(DNSPod 119.28.28.28、doh.pub、AliDNS 223.5.5.5、114DNS)
// 即使查询带 DO 位也不在应答里返回 RRSIG——DNSKEY 能查到、RRSIG 永远缺席。而
// dnssec 查询若回退到 dns-server(默认 119.28.28.28 或系统 DNS,都是国内递归),
// 验签流程永远失败。所以 DNSSEC 的默认服务器直接内置这份实测支持 DO 位的境外列表,
// 不再回退到国内解析器;境内网络若屏蔽/劫持了这些 UDP 53,请在节点配置 dnssec-server。
//
// 顺序即主从顺序,v4/v6 交错(同厂相邻):单栈节点上错误协议族的拨号是
// "network unreachable" 秒级失败,failover 到下一台几乎零成本,双栈节点则
// 自动绕过被劫持/丢包的那一族。国内家宽实测(江苏移动 v4)8.8.8.8 主 IP 存在
// 随机丢包,1.1.1.1 更稳故放首位。注意境外 DoH(443)在国内普遍被阻断,全部走 53。
var defaultDOCapableServers = []string{
"1.1.1.1:53", // Cloudflare v4
"[2606:4700:4700::1111]:53", // Cloudflare v6
"8.8.8.8:53", // Google v4
"[2001:4860:4860::8888]:53", // Google v6
"8.8.4.4:53", // Google 2 v4
"[2001:4860:4860::8844]:53", // Google 2 v6
"9.9.9.9:53", // Quad9 v4
"[2620:fe::fe]:53", // Quad9 v6
"208.67.222.222:53", // OpenDNS v4
"[2620:119:35::35]:53", // OpenDNS v6
"208.67.220.220:53", // OpenDNS 2 v4
"149.112.112.112:53", // Quad9 2 v4
"[2620:fe::9]:53", // Quad9 2 v6
"[2620:119:53::53]:53", // OpenDNS 2 v6
}
// queryDNSSEC DNSSEC 查询:走内置 DO 友好列表(或 dnssec-server 配置,配置优先),
// 由 queryWithServers 的主从 failover 保证可用性。
func queryDNSSEC(msg *dns.Msg) (*dns.Msg, error) {
return queryWithServers(msg, dnssecServers)
}
// DNSSECResult 包含 DNSSEC 验证的结果
type DNSSECResult struct {
Domain string `json:"domain"`
Enabled bool `json:"enabled"`
Valid bool `json:"valid"`
HasRRSIG bool `json:"has_rrsig"`
HasDNSKEY bool `json:"has_dnskey"`
HasDS bool `json:"has_ds"`
Algorithm uint8 `json:"algorithm"`
KeyTag uint16 `json:"key_tag"`
SignerName string `json:"signer_name"`
Validation string `json:"validation"`
Duration float64 `json:"duration"`
Domain string `json:"domain"`
Enabled bool `json:"enabled"`
Valid bool `json:"valid"`
HasRRSIG bool `json:"has_rrsig"`
HasDNSKEY bool `json:"has_dnskey"`
HasDS bool `json:"has_ds"`
Algorithm uint8 `json:"algorithm"`
KeyTag uint16 `json:"key_tag"`
SignerName string `json:"signer_name"`
Validation string `json:"validation"`
Duration float64 `json:"duration"`
}
// ResolveDNSSEC 执行 DNSSEC 验证
@@ -35,7 +71,6 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
Domain: domain,
}
// 1. 查询 DNSKEY 记录
msgDNSKEY := new(dns.Msg)
msgDNSKEY.SetQuestion(dns.Fqdn(domain), dns.TypeDNSKEY)
@@ -56,25 +91,64 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
var dnskeyRRset []dns.RR
var dnskeyList []*dns.DNSKEY
// DO 位生效时,DNSKEY 应答自带 RRSIG(DNSKEY)——已签名域名的确定信号,
// 也是 TLD / 无 A 记录域名唯一的验签素材(它们没有 A 应答可拿 RRSIG(A))
var rrsigDNSKEYList []*dns.RRSIG
for _, ans := range responseDNSKEY.Answer {
if dnskey, ok := ans.(*dns.DNSKEY); ok {
dnskeyRRset = append(dnskeyRRset, dnskey)
dnskeyList = append(dnskeyList, dnskey)
switch v := ans.(type) {
case *dns.DNSKEY:
dnskeyRRset = append(dnskeyRRset, v)
dnskeyList = append(dnskeyList, v)
result.HasDNSKEY = true
case *dns.RRSIG:
rrsigDNSKEYList = append(rrsigDNSKEYList, v)
}
}
if len(rrsigDNSKEYList) > 0 {
result.HasRRSIG = true
}
if len(dnskeyRRset) > 0 {
result.KeyTag = dnskeyList[0].KeyTag()
result.Algorithm = dnskeyList[0].Algorithm
}
// 2. 查询 A 记录(带 DO 位)
// 2. A 与 DS 并行查询。DS 记录在父区(如 .com),**任何**验签结论(通过或失败)
// 都应带上它——原实现把 DS 查询放在函数最后,验签成功时提前 return,
// has_ds 永远是 false(只有验证失败的路径才查得到 DS)。
msgA := new(dns.Msg)
msgA.SetQuestion(dns.Fqdn(domain), dns.TypeA)
msgA.SetEdns0(4096, true)
msgDS := new(dns.Msg)
msgDS.SetQuestion(dns.Fqdn(domain), dns.TypeDS)
msgDS.SetEdns0(4096, true)
// rrsigList 提升到函数作用域:A 应答里的 RRSIG(A) 在「A 验签失败」的最终判定里也要用
var rrsigList []*dns.RRSIG
var responseA *dns.Msg
var responseDS *dns.Msg
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
responseA, err = queryDNSSEC(msgA)
}()
go func() {
defer wg.Done()
responseDS, _ = queryDNSSEC(msgDS)
}()
wg.Wait()
// DS 结论先于一切 early return 就绪
if responseDS != nil && responseDS.Rcode == dns.RcodeSuccess {
for _, ans := range responseDS.Answer {
if _, ok := ans.(*dns.DS); ok {
result.HasDS = true
break
}
}
}
responseA, err := queryDNSSEC(msgA)
if err == nil && responseA.Rcode == dns.RcodeSuccess {
var aRRset []dns.RR
for _, ans := range responseA.Answer {
@@ -83,7 +157,6 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
}
}
var rrsigList []*dns.RRSIG
for _, ans := range responseA.Answer {
if rrsig, ok := ans.(*dns.RRSIG); ok {
rrsigList = append(rrsigList, rrsig)
@@ -91,7 +164,8 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
}
}
// 3. 用 DNSKEY 逐条验证 RRSIG
// 3. 用 DNSKEY 逐条验证 A 的 RRSIG。不在验证失败时提前返回:
// 下面还有 DNSKEY 自验签兜底
for _, rrsig := range rrsigList {
for _, dnskey := range dnskeyList {
if keyErr := rrsig.Verify(dnskey, aRRset); keyErr == nil {
@@ -105,28 +179,37 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
}
}
}
if len(rrsigList) > 0 && len(dnskeyRRset) > 0 {
result.Enabled = true
result.Valid = false
result.Validation = fmt.Sprintf("RRSIG 验证失败: %d 条 RRSIG, %d 个 DNSKEY,无匹配签名", len(rrsigList), len(dnskeyRRset))
return result, nil
}
}
// 4. 查询 DS 记录
msgDS := new(dns.Msg)
msgDS.SetQuestion(dns.Fqdn(domain), dns.TypeDS)
responseDS, _ := queryDNSSEC(msgDS)
if responseDS != nil && responseDS.Rcode == dns.RcodeSuccess {
for _, ans := range responseDS.Answer {
if _, ok := ans.(*dns.DS); ok {
result.HasDS = true
break
// 3.5 DNSKEY 自验签:用 DNSKEY 验证 DNSKEY 自己的 RRSIG(RFC 4035 §5.3.3 的
// 自签检查)。TLD(如 cn)与无 A 记录的已签名域拿不到 RRSIG(A),这是它们的
// 唯一验签路径;有 A 记录但 A 验签失败的域走到这里仍失败才算真失败。
if len(rrsigDNSKEYList) > 0 && len(dnskeyRRset) > 0 {
for _, rrsig := range rrsigDNSKEYList {
for _, dnskey := range dnskeyList {
if keyErr := rrsig.Verify(dnskey, dnskeyRRset); keyErr == nil {
result.Enabled = true
result.Valid = true
result.Algorithm = dnskey.Algorithm
result.KeyTag = dnskey.KeyTag()
result.SignerName = rrsig.SignerName
result.Validation = fmt.Sprintf("DNSSEC 验证通过 (DNSKEY 自验签, 算法: %d, KeyTag: %d)", dnskey.Algorithm, dnskey.KeyTag())
return result, nil
}
}
}
}
// A 验签与 DNSKEY 自验签都未通过:只要任一应答里有 RRSIG 就说明签名存在但无效
//(真失败,如数据被污染/密钥轮换不完整),返回明确的失败结论
if (len(rrsigList) > 0 || len(rrsigDNSKEYList) > 0) && len(dnskeyRRset) > 0 {
result.Enabled = true
result.Valid = false
result.Validation = fmt.Sprintf("RRSIG 验证失败: %d 条 RRSIG(A), %d 条 RRSIG(DNSKEY), %d 个 DNSKEY,无匹配签名", len(rrsigList), len(rrsigDNSKEYList), len(dnskeyRRset))
return result, nil
}
// 4. 结论分支(DS 已在并行阶段查完并计入 HasDS)
if result.HasRRSIG && result.HasDNSKEY {
result.Enabled = true
result.Valid = false
@@ -138,7 +221,7 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
} else if result.HasDNSKEY {
result.Enabled = false
result.Valid = false
result.Validation = "存在 DNSKEY,但缺少 RRSIG"
result.Validation = "存在 DNSKEY,但缺少 RRSIG(若确认该域名已签名,说明默认境外解析器在本网络不可达,请在节点配置 dnssec-server 指向可达的 DNSSEC 解析器,如 8.8.8.8:53)"
} else {
result.Enabled = false
result.Valid = false
+13 -4
View File
@@ -1,6 +1,7 @@
package webtest
import (
"net"
"net/netip"
"net/url"
"strings"
@@ -21,8 +22,8 @@ func CleanHostRecord(addr string) string {
return addr
}
// measureDNSTime 当 trace 未提供 DNS 耗时时的兜底测量
func measureDNSTime(urlStr string, version string) float64 {
// measureDNSTime 当 trace 未提供 DNS 耗时时的兜底测量。req 为请求级解析配置(dns 服务器)。
func measureDNSTime(urlStr string, version string, req ReqResolve) float64 {
parsed, err := url.Parse(urlStr)
if err != nil {
return 0
@@ -31,12 +32,20 @@ func measureDNSTime(urlStr string, version string) float64 {
if host == "" {
return 0
}
// 强制解析:目标是 IP 时原域名根本不查 DNS,不能报一个凭空的耗时;
// 目标是域名则改按新 host 计时(真实查询的是它)。
if target := req.Override(host, version == "v6"); target != "" {
host = strings.Trim(target, "[]")
if net.ParseIP(host) != nil {
return 0
}
}
start := time.Now()
var dnsErr error
if version == "v6" {
_, dnsErr = ResolveAAAARecord(host)
_, dnsErr = ResolveAAAARecord(host, req.Servers("ipv6"))
} else {
_, dnsErr = ResolveARecord(host)
_, dnsErr = ResolveARecord(host, req.Servers("ipv4"))
}
if dnsErr != nil {
return 0
+20 -8
View File
@@ -10,8 +10,9 @@ import (
)
// SpeedTest 网站测速(speed)。客户端由 main.go 经 SetHTTPClient 注入。
func SpeedTest(url string, version string) (*WebsiteSpeedTestResult, error) {
ctx := context.Background()
// req 为请求级解析配置(dns/dns4/dns6),经 ctx 传到 DialContext。
func SpeedTest(url string, version string, req ReqResolve) (*WebsiteSpeedTestResult, error) {
ctx := WithReqResolve(context.Background(), req)
var err error
ctx, err = lemon_ssrf.ValidateOutboundTarget(ctx, url)
if err != nil {
@@ -19,29 +20,40 @@ func SpeedTest(url string, version string) (*WebsiteSpeedTestResult, error) {
}
startTime := time.Now()
resp, err := httpClient(version).R().EnableTrace().SetContext(ctx).Get(url)
resp, err := newProbeRequest(version, ctx).Get(url)
fallbackToHTTP := false
if err != nil && strings.HasPrefix(url, "https://") {
closeResponseBody(resp) // 丢弃本次失败的响应,避免连接/FD 悬挂(见 closeResponseBody)
httpURL := strings.Replace(url, "https://", "http://", 1)
startTime = time.Now()
resp, err = httpClient(version).R().EnableTrace().SetContext(ctx).Get(httpURL)
resp, err = newProbeRequest(version, ctx).Get(httpURL)
fallbackToHTTP = true
}
if err != nil {
closeResponseBody(resp)
return nil, err
}
// 流式计数,不把响应体留在堆上(详见 readBodySize)。
// 必须放在 endTime 之前:改造前 resty 是在 Get() 内部读完整个响应体的,
// 所以 total_time 一直包含响应体下载耗时,download_speed 才是"字节数/总耗时"。
// 读体挪到 Get() 之外后若不在此处计入,total_time 会只剩到首字节的时间,
// download_speed 会算出几百 GB/s 这种离谱值。
bodySize, err := readBodySize(resp)
if err != nil {
return nil, err
}
endTime := time.Now()
body := resp.Bytes()
trace := resp.Request.TraceInfo()
hostRecord := CleanHostRecord(trace.RemoteAddr)
dnsLookupTime := trace.DNSLookup.Seconds() * 1000
if dnsLookupTime == 0 {
dnsLookupTime = measureDNSTime(url, version)
dnsLookupTime = measureDNSTime(url, version, req)
}
tcpConnectTime := trace.TCPConnTime.Seconds() * 1000
httpConnectTime := trace.ConnTime.Seconds() * 1000
@@ -50,7 +62,7 @@ func SpeedTest(url string, version string) (*WebsiteSpeedTestResult, error) {
totalTime := float64(endTime.Sub(startTime).Milliseconds())
var downloadSpeed float64
if totalTime > 0 {
downloadSpeed = float64(len(body)) / 1024.0 / (totalTime / 1000.0)
downloadSpeed = float64(bodySize) / 1024.0 / (totalTime / 1000.0)
}
dumpBytes, _ := httputil.DumpResponse(resp.RawResponse, false)
httpStatus := resp.StatusCode()
@@ -69,7 +81,7 @@ func SpeedTest(url string, version string) (*WebsiteSpeedTestResult, error) {
HTTPConnectTime: httpConnectTime,
FirstByteTime: firstByteTime,
TotalTime: totalTime,
PageSize: int64(len(body)),
PageSize: bodySize,
DownloadSpeed: downloadSpeed,
IsReachable: true,
}
+22 -5
View File
@@ -10,8 +10,9 @@ import (
)
// CheckSSL SSL 检查(ssl)。客户端由 main.go 经 SetHTTPClient 注入。
func CheckSSL(url string, version string) (*SSLCheckDetail, error) {
ctx := context.Background()
// req 为请求级解析配置(dns/dns4/dns6),经 ctx 传到 DialContext。
func CheckSSL(url string, version string, req ReqResolve) (*SSLCheckDetail, error) {
ctx := WithReqResolve(context.Background(), req)
var err error
ctx, err = lemon_ssrf.ValidateOutboundTarget(ctx, url)
if err != nil {
@@ -19,7 +20,16 @@ func CheckSSL(url string, version string) (*SSLCheckDetail, error) {
}
startTime := time.Now()
resp, err := httpClient(version).R().EnableTrace().SetContext(ctx).Get(url)
resp, err := newProbeRequest(version, ctx).Get(url)
if err != nil {
closeResponseBody(resp)
return nil, err
}
// 流式计数,不把响应体留在堆上(详见 readBodySize)。
// 放在 endTime 之前:改造前 resty 在 Get() 内部就读完了整个响应体,
// total_time 一直包含响应体下载耗时,见 speed.go 同处注释。
bodySize, err := readBodySize(resp)
if err != nil {
return nil, err
}
@@ -29,10 +39,9 @@ func CheckSSL(url string, version string) (*SSLCheckDetail, error) {
hostRecord := CleanHostRecord(trace.RemoteAddr)
totalTime := float64(endTime.Sub(startTime).Milliseconds())
body := resp.Bytes()
var downloadSpeed float64
if totalTime > 0 {
downloadSpeed = float64(len(body)) / 1024.0 / (totalTime / 1000.0)
downloadSpeed = float64(bodySize) / 1024.0 / (totalTime / 1000.0)
}
rawResp := resp.RawResponse
@@ -41,6 +50,7 @@ func CheckSSL(url string, version string) (*SSLCheckDetail, error) {
var isExpired bool
var issuerOrganization []string
var issuerCommonName, subjectCommonName, domain string
var san []string
if rawResp.TLS != nil && len(rawResp.TLS.PeerCertificates) > 0 {
cert = rawResp.TLS.PeerCertificates[0]
@@ -51,6 +61,12 @@ func CheckSSL(url string, version string) (*SSLCheckDetail, error) {
issuerCommonName = cert.Issuer.CommonName
subjectCommonName = cert.Subject.CommonName
domain = CleanHostRecord(cert.Subject.CommonName)
// SAN:DNS 名在前、IP 在后(IP 站点证书可能只有 IP SAN)
san = make([]string, 0, len(cert.DNSNames)+len(cert.IPAddresses))
san = append(san, cert.DNSNames...)
for _, ip := range cert.IPAddresses {
san = append(san, ip.String())
}
} else {
return nil, fmt.Errorf("no SSL certificate found")
}
@@ -69,6 +85,7 @@ func CheckSSL(url string, version string) (*SSLCheckDetail, error) {
IssuerOrganization: issuerOrganization,
IssuerCommonName: issuerCommonName,
SubjectCommonName: subjectCommonName,
SAN: san,
IsReachable: true,
}
+65
View File
@@ -0,0 +1,65 @@
package webtest
import (
"context"
"net"
"strings"
"lemon-ipw/ssrf"
)
// InstallSSRFResolver 让 SSRF 出站校验走本包的自定义 DNS(含请求级覆盖)。
// 必须在启动阶段调用一次(main.go)。
//
// 动机:不注入时 ssrf 用 net.LookupIP(系统解析器)做预检,而真正拨号走自定义 DNS —— 两套解析器,
// 既多一次查询,结果也可能不一致;更关键的是,当调用方用请求级 dns= 指定了服务器、且目标域名只能
// 靠该服务器解析时,系统解析器预检会先行失败,把请求直接毙掉,覆盖形同失效。
//
// 校验只需判定「是否私网」,故 A / AAAA 都查(任一命中私网即拦,口径与原先 net.LookupIP 一致)。
// 请求级配置(DNS 服务器)取自 context,与真正拨号同源。
func InstallSSRFResolver() {
ssrf.SetResolveFunc(func(ctx context.Context, host string) ([]net.IP, error) {
// 字面 IP 直接返回,无需解析
if ip := net.ParseIP(host); ip != nil {
return []net.IP{ip}, nil
}
req := ReqResolveFromContext(ctx)
var ips []net.IP
var firstErr error
for _, wantV6 := range []bool{false, true} {
// 强制解析命中时,预检要验「拨号真正会连的地址」,否则会拿原域名的
// 真实解析去判定,预检与实际连接两个口径对不上(resolve= 也就白设了)。
name := host
if target := req.Override(name, wantV6); target != "" {
name = strings.Trim(target, "[]")
}
if ip := net.ParseIP(name); ip != nil {
ips = append(ips, ip)
continue
}
var res DNSResult
var err error
if wantV6 {
res, err = ResolveAAAARecord(name, req.Servers("ipv6"))
} else {
res, err = ResolveARecord(name, req.Servers("ipv4"))
}
if err != nil && firstErr == nil {
firstErr = err
}
for _, s := range res.Record {
if ip := net.ParseIP(s); ip != nil {
ips = append(ips, ip)
}
}
}
// 两栈都解析失败才报错;只要有一栈拿到 IP 就交给上层做私网判定
if len(ips) == 0 && firstErr != nil {
return nil, firstErr
}
return ips, nil
})
}
+56 -40
View File
@@ -34,53 +34,60 @@ type TCPingStats struct {
Results []TCPingResult `json:"results"` // 每次测试的详细结果
}
// resolveHost 将主机名解析为指定协议版本的 IP 地址
// version 参数支持 "v4"(IPv4)和 "v6"(IPv6)
// 如果 host 已经是 IP 地址(可带 []),直接返回,跳过 DNS 解析
// ResolveHost 将主机名解析为指定协议版本的 IP 地址。
// version 支持 "v4" / "v6";host 已是 IP(可带 [])时直接返回,跳过 DNS 解析。
func ResolveHost(host string, version string) (string, error) {
return ResolveHostWith(host, version, ReqResolve{})
}
// ResolveHostWith 同 ResolveHost,但带请求级解析配置(req 零值 = 用配置 DNS)。
// 字面 IP 不查 DNS,但仍要过私网校验(与 DNS 解析结果同口径)。
func ResolveHostWith(host string, version string, req ReqResolve) (string, error) {
// 强制解析:命中就把目标当作新的 host——IP 交给下面的字面 IP 分支,域名继续走 DNS。
if target := req.Override(host, version == "v6"); target != "" {
host = strings.Trim(target, "[]")
}
cleanHost := strings.Trim(host, "[]")
if ip := net.ParseIP(cleanHost); ip != nil {
if version == "v4" && ip.To4() != nil {
return ip.String(), nil
// SSRF:字面 IP 与 DNS 解析结果同口径,私网地址一律拒绝(tcping 是裸 TCP,
// 不经过 DialContext,这里不做就完全没人拦)。放在族校验之前:安全判定
// 优先于参数语义,也与 DialContext 里「先查私网、再拨号」的顺序一致。
if ssrf.Enabled() && ssrf.IsPrivateIP(ip) {
return "", fmt.Errorf("connection to private/internal address %s is not allowed", cleanHost)
}
if version == "v6" && ip.To4() == nil {
return ip.String(), nil
switch version {
case "v4":
if ip.To4() != nil {
return ip.String(), nil
}
case "v6":
if ip.To4() == nil {
return ip.String(), nil
}
}
return "", fmt.Errorf("host %s is not a %s address", host, version)
}
var ipStr string
var result DNSResult
var err error
if version == "v4" {
var result DNSResult
result, err = ResolveARecord(host)
if err != nil {
return "", fmt.Errorf("DNS lookup failed: %w", err)
}
if len(result.Record) == 0 {
return "", fmt.Errorf("no %s address found for %s", version, host)
}
ipStr = result.Record[0]
if version == "v6" {
result, err = ResolveAAAARecord(host, req.Servers("ipv6"))
} else {
var result DNSResult
result, err = ResolveAAAARecord(host)
if err != nil {
return "", fmt.Errorf("DNS lookup failed: %w", err)
}
if len(result.Record) == 0 {
return "", fmt.Errorf("no %s address found for %s", version, host)
}
ipStr = result.Record[0]
result, err = ResolveARecord(host, req.Servers("ipv4"))
}
if err != nil {
return "", fmt.Errorf("DNS lookup failed: %w", err)
}
if len(result.Record) == 0 {
return "", fmt.Errorf("no %s address found for %s", version, host)
}
ipStr := result.Record[0]
if ssrf.Enabled() {
ip := net.ParseIP(ipStr)
if ip != nil && ssrf.IsPrivateIP(ip) {
if ip := net.ParseIP(ipStr); ip != nil && ssrf.IsPrivateIP(ip) {
return "", fmt.Errorf("connection to private/internal address %s is not allowed", ipStr)
}
}
return ipStr, nil
}
@@ -97,13 +104,19 @@ func TCPing(host string, port string, version string, timeout time.Duration) (*T
if err != nil {
return nil, err
}
return tcpingDial(ip, port, version, timeout), nil
}
// tcpingDial 对已解析好的 IP 做一次 TCP 拨号,不再做 DNS 解析与 SSRF 校验。
// 拆出来是为了让 TCPingRun 的多次探测复用同一个解析结果:原实现每轮都走 TCPing→
// ResolveHost,既拖慢,又会因多 A 记录轮询导致 results 里每次连到不同 IP(与顶层 ip 不符)。
func tcpingDial(ip string, port string, version string, timeout time.Duration) *TCPingResult {
addr := ""
switch version {
case "v4":
addr = fmt.Sprintf("%s:%s", ip, port)
case "v6":
addr = fmt.Sprintf("[%s]:%s", ip, port)
default:
addr = fmt.Sprintf("%s:%s", ip, port)
}
start := time.Now()
@@ -127,7 +140,7 @@ func TCPing(host string, port string, version string, timeout time.Duration) (*T
result.RTT = float64(rtt.Microseconds()) / 1000.0
}
return result, nil
return result
}
// TCPingRun 执行多次 TCP 连接测试并返回统计结果
@@ -141,7 +154,12 @@ func TCPing(host string, port string, version string, timeout time.Duration) (*T
//
// 返回 TCPingStats 包含统计信息
func TCPingRun(host string, port string, count int, version string, timeout time.Duration, interval time.Duration) (*TCPingStats, error) {
ip, err := ResolveHost(host, version)
return TCPingRunWith(host, port, count, version, timeout, interval, ReqResolve{})
}
// TCPingRunWith 同 TCPingRun,但带请求级解析配置(req 零值 = 用配置 DNS)。
func TCPingRunWith(host string, port string, count int, version string, timeout time.Duration, interval time.Duration, req ReqResolve) (*TCPingStats, error) {
ip, err := ResolveHostWith(host, version, req)
if err != nil {
return &TCPingStats{
IP: "Error: " + err.Error(),
@@ -168,10 +186,8 @@ func TCPingRun(host string, port string, count int, version string, timeout time
successCount := 0
for i := 0; i < count; i++ {
result, err := TCPing(host, port, version, timeout)
if err != nil {
return nil, err
}
// 复用开头解析出的 ip,不再每轮重新 ResolveHost
result := tcpingDial(ip, port, version, timeout)
stats.Results = append(stats.Results, *result)
+5 -2
View File
@@ -31,8 +31,11 @@ type SSLCheckDetail struct {
IssuerOrganization []string `json:"issuer_organization"`
IssuerCommonName string `json:"issuer_common_name"`
SubjectCommonName string `json:"subject_common_name"`
IsExpired bool `json:"is_expired"`
IsReachable bool `json:"is_reachable"`
// SAN 证书主体别名(Subject Alternative Names):DNS 名在前、IP 在后,
// 与 CN 相互独立——校验「证书覆盖了哪些域名」以这里为准
SAN []string `json:"san"`
IsExpired bool `json:"is_expired"`
IsReachable bool `json:"is_reachable"`
}
type WebsiteSpeedTestResult struct {
+18 -8
View File
@@ -9,8 +9,9 @@ import (
)
// CheckWebsite 网站检测(detail)。客户端由 main.go 经 SetHTTPClient 注入。
func CheckWebsite(url string, version string) (*WebsiteCheckDetail, error) {
ctx := context.Background()
// req 为请求级解析配置(dns/dns4/dns6),经 ctx 传到 DialContext。
func CheckWebsite(url string, version string, req ReqResolve) (*WebsiteCheckDetail, error) {
ctx := WithReqResolve(context.Background(), req)
var err error
ctx, err = lemon_ssrf.ValidateOutboundTarget(ctx, url)
if err != nil {
@@ -18,30 +19,39 @@ func CheckWebsite(url string, version string) (*WebsiteCheckDetail, error) {
}
startTime := time.Now()
resp, err := httpClient(version).R().EnableTrace().SetContext(ctx).Get(url)
resp, err := newProbeRequest(version, ctx).Get(url)
// HTTPS 请求失败时 fallback 到 HTTP
fallbackToHTTP := false
if err != nil && strings.HasPrefix(url, "https://") {
closeResponseBody(resp) // 丢弃本次失败的响应,避免连接/FD 悬挂(见 closeResponseBody)
httpURL := strings.Replace(url, "https://", "http://", 1)
startTime = time.Now()
resp, err = httpClient(version).R().EnableTrace().SetContext(ctx).Get(httpURL)
resp, err = newProbeRequest(version, ctx).Get(httpURL)
fallbackToHTTP = true
}
if err != nil {
closeResponseBody(resp)
return nil, err
}
// 流式计数,不把响应体留在堆上(详见 readBodySize)。
// 放在 endTime 之前:改造前 resty 在 Get() 内部就读完了整个响应体,
// total_time 一直包含响应体下载耗时,见 speed.go 同处注释。
bodySize, err := readBodySize(resp)
if err != nil {
return nil, err
}
endTime := time.Now()
body := resp.Bytes()
trace := resp.Request.TraceInfo()
hostRecord := CleanHostRecord(trace.RemoteAddr)
dnsLookupTime := trace.DNSLookup.Seconds() * 1000
if dnsLookupTime == 0 {
dnsLookupTime = measureDNSTime(url, version)
dnsLookupTime = measureDNSTime(url, version, req)
}
tcpConnectTime := trace.TCPConnTime.Seconds() * 1000
httpConnectTime := trace.ConnTime.Seconds() * 1000
@@ -50,7 +60,7 @@ func CheckWebsite(url string, version string) (*WebsiteCheckDetail, error) {
totalTime := float64(endTime.Sub(startTime).Milliseconds())
var downloadSpeed float64
if totalTime > 0 {
downloadSpeed = float64(len(body)) / 1024.0 / (totalTime / 1000.0)
downloadSpeed = float64(bodySize) / 1024.0 / (totalTime / 1000.0)
}
httpStatus := resp.StatusCode()
@@ -68,7 +78,7 @@ func CheckWebsite(url string, version string) (*WebsiteCheckDetail, error) {
HTTPConnectTime: httpConnectTime,
FirstByteTime: firstByteTime,
TotalTime: totalTime,
PageSize: int64(len(body)),
PageSize: bodySize,
DownloadSpeed: downloadSpeed,
IsReachable: true,
}
+200 -82
View File
@@ -7,7 +7,6 @@ import (
"net"
"regexp"
"strings"
"sync"
"time"
"lemon-ipw/ssrf"
@@ -102,8 +101,10 @@ const whoisReferralTimeout = 3 * time.Second
// QueryWhois 执行 WHOIS 查询并解析结构化数据
// 主路径:likexian/whois 走「IANA → 注册局」两跳,**不追注册商转介**(转介由 chaseReferral 单独限时补),
// 这样即便转介服务器不可达也只在 ~1s 内拿到注册局记录,不会干等满库的 30s 超时。
// 兜底:主路径拿不到内容时才 fallback(内置 TLD 映射 → IANA 查服务器 → 自定义 DNS 解析 IP →
// 兜底:主路径拿不到内容时才 fallback(内置 TLD 映射 → IANA 查服务器 → 系统解析器解析 IP →
// Happy Eyeballs 双栈竞争拨号)。
// whois 不走自定义 DNS(dns-server / 请求级 dns=):whois 服务器是稳定的公共基础设施,
// 解析结果只用于建立连接、不作为拨测结论,没必要让 DNS 配置的故障面波及 whois。
func QueryWhois(domain string) (*WhoisResult, error) {
raw, err := queryWhoisRegistry(domain)
@@ -130,10 +131,10 @@ func QueryWhois(domain string) (*WhoisResult, error) {
// queryWhoisRegistry 走 IANA → 注册局两跳取注册局记录,关闭库的转介链
// (转介改由 chaseReferral 限时补查,避免被不可达的转介服务器拖满 30s)
func queryWhoisRegistry(domain string) (string, error) {
return whois.NewClient().
client := whois.NewClient().
SetDisableReferral(true).
SetTimeout(whoisLibTimeout).
Whois(domain)
SetTimeout(whoisLibTimeout)
return client.Whois(domain)
}
// chaseReferral 从注册局响应里取出注册商转介服务器,限时补查一次
@@ -266,33 +267,29 @@ func filterPublicIPs(ips []string) []string {
return filtered
}
// resolveWhoisServerIPs 使用 webtest/dns 的统一解析接口
// 并行查询 A + AAAA(走自定义 DNS 服务器,不受系统 resolver 策略影响)
// resolveWhoisServerIPs 用系统解析器(net.LookupIP)取 whois 服务器的 v4/v6 地址。
// whois 不走自定义 DNS(dns-server / 请求级 dns=):whois 服务器是稳定的公共基础设施,
// 解析结果只用于建立连接、不作为拨测结论,没必要让 DNS 配置的故障面波及 whois。
func resolveWhoisServerIPs(server string) (v4IPs, v6IPs []string, err error) {
var (
aResult DNSResult
aaaaResult DNSResult
wg sync.WaitGroup
aErr error
aaaaErr error
)
wg.Add(2)
go func() {
defer wg.Done()
aResult, aErr = ResolveARecord(server)
}()
go func() {
defer wg.Done()
aaaaResult, aaaaErr = ResolveAAAARecord(server)
}()
wg.Wait()
if aErr != nil && aaaaErr != nil {
return nil, nil, fmt.Errorf("both A and AAAA DNS lookup failed for %s: A=%w, AAAA=%w", server, aErr, aaaaErr)
ips, err := net.LookupIP(server)
if err != nil {
return nil, nil, err
}
v4IPs = filterPublicIPs(aResult.Record)
v6IPs = filterPublicIPs(aaaaResult.Record)
var v4, v6 []string
for _, ip := range ips {
if ip == nil {
continue
}
if ip.To4() != nil {
v4 = append(v4, ip.String())
} else {
v6 = append(v6, ip.String())
}
}
v4IPs = filterPublicIPs(v4)
v6IPs = filterPublicIPs(v6)
if len(v4IPs) == 0 && len(v6IPs) == 0 {
return nil, nil, fmt.Errorf("no reachable public IPs for %s after SSRF filter", server)
@@ -381,7 +378,7 @@ func whoisRetryWithFallback(domain string, firstErr error) (string, error) {
return "", firstErr
}
// 使用 ResolveA/AAAA(自定义 DNS)并行解析 v4/v6
// 系统解析器并行拿 v4/v6(whois 不走自定义 DNS)
v4IPs, v6IPs, err := resolveWhoisServerIPs(server)
if err != nil {
return "", firstErr
@@ -614,63 +611,184 @@ type ASNWhoisResult struct {
Error string `json:"error"`
}
// asnFieldPatterns 用于从原始 ASN WHOIS 文本中提取字段
var asnFieldPatterns = map[string]*regexp.Regexp{
"asNumber": regexp.MustCompile(`(?i)^\s*ASNumber\s*[:=]\s*(.+?)\s*$`),
"asName": regexp.MustCompile(`(?i)^\s*ASName\s*[:=]\s*(.+?)\s*$`),
"asHandle": regexp.MustCompile(`(?i)^\s*ASHandle\s*[:=]\s*(.+?)\s*$`),
"regDate": regexp.MustCompile(`(?i)^\s*RegDate\s*[:=]\s*(.+?)\s*$`),
"updated": regexp.MustCompile(`(?i)^\s*Updated\s*[:=]\s*(.+?)\s*$`),
"orgName": regexp.MustCompile(`(?i)^\s*OrgName\s*[:=]\s*(.+?)\s*$`),
"orgId": regexp.MustCompile(`(?i)^\s*OrgId\s*[:=]\s*(.+?)\s*$`),
"country": regexp.MustCompile(`(?i)^\s*Country\s*[:=]\s*([A-Z]{2})\s*$`),
"abuseName": regexp.MustCompile(`(?i)^\s*OrgAbuseName\s*[:=]\s*(.+?)\s*$`),
"abuseEmail": regexp.MustCompile(`(?i)^\s*OrgAbuseEmail\s*[:=]\s*(.+?)\s*$`),
"abusePhone": regexp.MustCompile(`(?i)^\s*OrgAbusePhone\s*[:=]\s*(.+?)\s*$`),
// ==================== ASN WHOIS 多 RIR 解析 ====================
//
// QueryASNWhois 走 likexian/whois 的默认链路:先问 IANA(IANA 按 ASN 给出归属 RIR),
// 再取该 RIR 的记录;若 ARIN 只回了「转介桩」,库会再跟一跳取到真实记录。
// 因此 raw 里可能同时存在两段内容,且字段名随 RIR 而异:
//
// ARIN : ASNumber / ASName / OrgName / OrgId / Country / RegDate / Updated / OrgAbuse*
// APNIC : aut-num / as-name / descr / country / org / last-modified(无 org-name 时组织名在 descr)
// RIPE : aut-num / as-name / org / org-name / country / created / last-modified
// LACNIC : aut-num / owner / ownerid / country / created / changed
//
// 改造前只认 ARIN 字段 ⇒ 除 ARIN 注册的 ASN(Cloudflare AS13335、Google AS15169 等)外,
// 其余 RIR 的 ASN 全部解析为空(如中国电信 AS4134 只拿到 ARIN 转介桩的 "APNIC-4134")。
//
// 现在的规则:raw 里**存在 RIR 原生 aut-num 记录时一律以它为准,并整体忽略 ARIN 段**
// (ARIN 对非 ARIN 的 ASN 只回桩,其 ASName/OrgName/OrgAbuseEmail 都是误导信息);
// 没有原生记录时才按 ARIN 格式解析。
var (
// 原生(APNIC/RIPE/LACNIC/AFRINIC)字段
reASNAutNum = regexp.MustCompile(`(?im)^\s*aut-num:\s*AS?(\d+)\s*$`)
reASNAsName = regexp.MustCompile(`(?im)^\s*as-name:\s*(.+?)\s*$`)
reASNOrgName = regexp.MustCompile(`(?im)^\s*org-name:\s*(.+?)\s*$`)
reASNOwner = regexp.MustCompile(`(?im)^\s*owner:\s*(.+?)\s*$`)
reASNOwnerID = regexp.MustCompile(`(?im)^\s*ownerid:\s*(.+?)\s*$`)
reASNOrgRef = regexp.MustCompile(`(?im)^\s*org:\s*(.+?)\s*$`)
reASNDescr = regexp.MustCompile(`(?im)^\s*descr:\s*(.+?)\s*$`)
reASNCreated = regexp.MustCompile(`(?im)^\s*created:\s*(.+?)\s*$`)
reASNRegistered = regexp.MustCompile(`(?im)^\s*registered:\s*(.+?)\s*$`)
reASNLastMod = regexp.MustCompile(`(?im)^\s*last-modified:\s*(.+?)\s*$`)
reASNChanged = regexp.MustCompile(`(?im)^\s*changed:\s*(.+?)\s*$`)
reASNMailbox = regexp.MustCompile(`(?im)^\s*abuse-mailbox:\s*(\S+@\S+)\s*$`)
// APNIC/RIPE 会在响应顶部给一行注释:% Abuse contact for 'AS37963' is 'xxx@yyy'
reASNCommentAbuse = regexp.MustCompile(`(?i)%\s*Abuse contact for '?\s*AS\d+\s*'?\s*is\s*'?([^'\s]+)'?`)
// ARIN 字段(保持改造前语义;注意:这些正则在「多行整段」文本上用,必须带 m 标志)
reASNNumberArin = regexp.MustCompile(`(?im)^\s*ASNumber\s*[:=]\s*(.+?)\s*$`)
reASNNameArin = regexp.MustCompile(`(?im)^\s*ASName\s*[:=]\s*(.+?)\s*$`)
reASNHandleArin = regexp.MustCompile(`(?im)^\s*ASHandle\s*[:=]\s*(.+?)\s*$`)
reASNRegDateArin = regexp.MustCompile(`(?im)^\s*RegDate\s*[:=]\s*(.+?)\s*$`)
reASNUpdatedArin = regexp.MustCompile(`(?im)^\s*Updated\s*[:=]\s*(.+?)\s*$`)
reASNOrgNameArin = regexp.MustCompile(`(?im)^\s*OrgName\s*[:=]\s*(.+?)\s*$`)
reASNOrgIDArin = regexp.MustCompile(`(?im)^\s*OrgId\s*[:=]\s*(.+?)\s*$`)
reASNCountryArin = regexp.MustCompile(`(?im)^\s*Country\s*[:=]\s*([A-Z]{2})\s*$`)
reASNAbuseName = regexp.MustCompile(`(?im)^\s*OrgAbuseName\s*[:=]\s*(.+?)\s*$`)
reASNAbuseEmail = regexp.MustCompile(`(?im)^\s*OrgAbuseEmail\s*[:=]\s*(.+?)\s*$`)
reASNAbusePhone = regexp.MustCompile(`(?im)^\s*OrgAbusePhone\s*[:=]\s*(.+?)\s*$`)
// RIR 通用的「非 ASN 记录块」噪音(as-block 对象的 descr,如 "APNIC ASN block")
reASNBlockNoise = regexp.MustCompile(`(?i)(^|\s)ASN? block\s*$`)
// 滥用联系人的对象名:RIPE 用 irt:,APNIC 的 abuse-c 对象用 role:
reASNRoleName = regexp.MustCompile(`(?im)^\s*(?:irt|role):\s*(.+?)\s*$`)
reASNPhone = regexp.MustCompile(`(?im)^\s*phone:\s*(.+?)\s*$`)
)
// splitWhoisBlocks 按空行把响应切成记录块(每个 RIR 对象一段)
func splitWhoisBlocks(raw string) []string {
raw = strings.ReplaceAll(raw, "\r\n", "\n")
return strings.Split(raw, "\n\n")
}
// parseASNWhoisRaw 从原始 ASN WHOIS 响应中解析结构化数据
func parseASNWhoisRaw(raw string) *ASNWhoisResult {
result := &ASNWhoisResult{Raw: raw}
lines := strings.Split(raw, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") {
continue
}
for field, pattern := range asnFieldPatterns {
if matches := pattern.FindStringSubmatch(line); len(matches) == 2 {
switch field {
case "asNumber":
result.ASNumber = matches[1]
case "asName":
result.ASName = matches[1]
case "asHandle":
if result.ASNumber == "" {
result.ASNumber = strings.TrimPrefix(matches[1], "AS")
}
case "regDate":
result.RegDate = matches[1]
case "updated":
result.Updated = matches[1]
case "orgName":
result.OrgName = matches[1]
case "orgId":
result.OrgID = matches[1]
case "country":
result.Country = matches[1]
case "abuseName":
result.AbuseName = matches[1]
case "abuseEmail":
result.AbuseEmail = matches[1]
case "abusePhone":
result.AbusePhone = matches[1]
}
// pickField 按给定优先级在文本中取第一个命中的字段值;全不命中返回 ""
func pickField(text string, patterns ...*regexp.Regexp) string {
for _, pattern := range patterns {
if m := pattern.FindStringSubmatch(text); len(m) == 2 {
if v := strings.TrimSpace(m[1]); v != "" {
return v
}
}
}
return ""
}
// findASNNativeBlock 找出含 aut-num 的原生记录块;没有返回 ""
func findASNNativeBlock(raw string) string {
for _, block := range splitWhoisBlocks(raw) {
if reASNAutNum.MatchString(block) {
return block
}
}
return ""
}
// findASNOrgBlock 按原生块里的 org: 句柄定位对应的 organisation/owner 对象
// (RIPE 的 org-name 与 country 不在 aut-num 对象里,而在 organisation 对象里)
func findASNOrgBlock(raw, nativeBlock string) string {
handle := pickField(nativeBlock, reASNOrgRef)
if handle == "" {
return ""
}
for _, block := range splitWhoisBlocks(raw) {
if strings.Contains(block, handle) && (reASNOrgName.MatchString(block) || reASNOwner.MatchString(block)) {
return block
}
}
return ""
}
// findASNOrgName 取组织名:organisation 对象的 org-name/owner(RIPE/APNIC/JPNIC)
// → 原生块首行 descr(APNIC 常见,如 "Hangzhou Alibaba Advertising Co.,Ltd."),并过滤 block 噪音
func findASNOrgName(orgBlock, nativeBlock string) string {
if v := pickField(orgBlock, reASNOrgName, reASNOwner); v != "" {
return v
}
for _, m := range reASNDescr.FindAllStringSubmatch(nativeBlock, -1) {
v := strings.TrimSpace(m[1])
if v == "" || reASNBlockNoise.MatchString(v) {
continue
}
return v
}
return ""
}
// findASNCommentAbuseEmail 取响应顶部注释里的 Abuse 邮箱(APNIC/RIPE 提供)
func findASNCommentAbuseEmail(raw string) string {
if m := reASNCommentAbuse.FindStringSubmatch(raw); len(m) == 2 {
return strings.TrimSpace(m[1])
}
return ""
}
// findASNMailboxAbuse 取含 abuse-mailbox 的记录块里的联系人名与电话(RIPE 的 irt:、APNIC 的 role:)
func findASNMailboxAbuse(raw string) (name, email, phone string) {
for _, block := range splitWhoisBlocks(raw) {
mailbox := pickField(block, reASNMailbox)
if mailbox == "" {
continue
}
return pickField(block, reASNRoleName), mailbox, pickField(block, reASNPhone)
}
return "", "", ""
}
// parseASNWhoisRaw 从原始 ASN WHOIS 响应中解析结构化数据(多 RIR)
func parseASNWhoisRaw(raw string) *ASNWhoisResult {
result := &ASNWhoisResult{Raw: raw}
// 情况一:有 RIR 原生记录(APNIC/RIPE/LACNIC/AFRINIC),只认它
if nativeBlock := findASNNativeBlock(raw); nativeBlock != "" {
orgBlock := findASNOrgBlock(raw, nativeBlock)
result.ASNumber = pickField(nativeBlock, reASNAutNum, reASNNumberArin)
result.ASName = pickField(nativeBlock, reASNAsName)
result.OrgName = findASNOrgName(orgBlock, nativeBlock)
result.OrgID = pickField(nativeBlock, reASNOrgRef, reASNOwnerID)
result.Country = pickField(nativeBlock, reASNCountryArin)
if result.Country == "" {
result.Country = pickField(orgBlock, reASNCountryArin)
}
result.RegDate = pickField(nativeBlock, reASNCreated, reASNRegistered, reASNRegDateArin)
result.Updated = pickField(nativeBlock, reASNLastMod, reASNUpdatedArin, reASNChanged)
// 滥用联系人:先用注释行(最权威),再用 abuse-mailbox 所在对象
result.AbuseEmail = findASNCommentAbuseEmail(raw)
name, email, phone := findASNMailboxAbuse(raw)
if result.AbuseName == "" {
result.AbuseName = name
}
if result.AbuseEmail == "" {
result.AbuseEmail = email
}
result.AbusePhone = phone
return result
}
// 情况二:ARIN 格式(ARIN 自有的 ASN),保持改造前行为
result.ASNumber = pickField(raw, reASNNumberArin)
if result.ASNumber == "" {
result.ASNumber = strings.TrimPrefix(pickField(raw, reASNHandleArin), "AS")
}
result.ASName = pickField(raw, reASNNameArin)
result.OrgName = pickField(raw, reASNOrgNameArin)
result.OrgID = pickField(raw, reASNOrgIDArin)
result.Country = pickField(raw, reASNCountryArin)
result.RegDate = pickField(raw, reASNRegDateArin)
result.Updated = pickField(raw, reASNUpdatedArin)
result.AbuseName = pickField(raw, reASNAbuseName)
result.AbuseEmail = pickField(raw, reASNAbuseEmail)
result.AbusePhone = pickField(raw, reASNAbusePhone)
return result
}
+94 -48
View File
@@ -35,15 +35,23 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
switch apiType {
case "detail":
testURL := normalizeURL(raw)
if cached, ok := websiteCache.Load(testURL); ok {
req, err := webtest.ParseReqResolve(func(k string) string { return query[k] })
if err != nil {
return 400, map[string]string{"error": err.Error()}
}
// 缓存键带上请求级 DNS 覆盖(结果取决于所用服务器,参数不同不能共用)
cacheKey := testURL + req.CacheKey()
if cached, ok := websiteCache.Load(cacheKey); ok {
entry := cached.(websiteCacheEntry)
if time.Since(entry.timestamp) < 5*time.Minute {
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
return 200, entry.result
}
websiteCache.Delete(testURL)
websiteCache.Delete(cacheKey)
}
ipv4, ipv6 := wsDual[webtest.WebsiteCheckDetail](
func(v string) (*webtest.WebsiteCheckDetail, error) { return webtest.CheckWebsite(testURL, v) },
func(v string) (*webtest.WebsiteCheckDetail, error) {
return webtest.CheckWebsite(testURL, v, req)
},
func(err error) *webtest.WebsiteCheckDetail {
return &webtest.WebsiteCheckDetail{HostRecord: "Error: " + err.Error(), IsReachable: false}
},
@@ -52,20 +60,31 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
},
)
result := &WebsiteCheckResult{IPv4: ipv4, IPv6: ipv6}
websiteCache.Store(testURL, websiteCacheEntry{result: result, timestamp: time.Now()})
failed := dualAnyFailed(result.IPv4, result.IPv6, func(d *webtest.WebsiteCheckDetail) bool {
return d != nil && !d.IsReachable
})
websiteCache.Store(cacheKey, websiteCacheEntry{result: result, timestamp: time.Now(), failed: failed})
return 200, result
case "ssl":
testURL := normalizeURL(raw)
if cached, ok := sslCache.Load(testURL); ok {
req, err := webtest.ParseReqResolve(func(k string) string { return query[k] })
if err != nil {
return 400, map[string]string{"error": err.Error()}
}
// 缓存键带上请求级 DNS 覆盖(结果取决于所用服务器,参数不同不能共用)
cacheKey := testURL + req.CacheKey()
if cached, ok := sslCache.Load(cacheKey); ok {
entry := cached.(sslCacheEntry)
if time.Since(entry.timestamp) < 5*time.Minute {
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
return 200, entry.result
}
sslCache.Delete(testURL)
sslCache.Delete(cacheKey)
}
ipv4, ipv6 := wsDual[webtest.SSLCheckDetail](
func(v string) (*webtest.SSLCheckDetail, error) { return webtest.CheckSSL(testURL, v) },
func(v string) (*webtest.SSLCheckDetail, error) {
return webtest.CheckSSL(testURL, v, req)
},
func(err error) *webtest.SSLCheckDetail {
return &webtest.SSLCheckDetail{HostRecord: "Error: " + err.Error(), IsExpired: true, IsReachable: false}
},
@@ -74,7 +93,10 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
},
)
result := &SSLCheckResult{IPv4: ipv4, IPv6: ipv6}
sslCache.Store(testURL, sslCacheEntry{result: result, timestamp: time.Now()})
failed := dualAnyFailed(result.IPv4, result.IPv6, func(d *webtest.SSLCheckDetail) bool {
return d != nil && !d.IsReachable
})
sslCache.Store(cacheKey, sslCacheEntry{result: result, timestamp: time.Now(), failed: failed})
return 200, result
case "speed":
@@ -84,18 +106,23 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
version = "v4"
}
testURL = normalizeURL(testURL)
cacheKey := fmt.Sprintf("%s:%s", testURL, version)
req, err := webtest.ParseReqResolve(func(k string) string { return query[k] })
if err != nil {
return 400, map[string]string{"error": err.Error()}
}
// 缓存键:URL + 版本 + 请求级 DNS 覆盖(结果取决于所用服务器,参数不同不能共用)
cacheKey := fmt.Sprintf("%s:%s%s", testURL, version, req.CacheKey())
if cached, ok := speedCache.Load(cacheKey); ok {
entry := cached.(speedCacheEntry)
if time.Since(entry.timestamp) < 5*time.Minute {
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
return 200, entry.result
}
speedCache.Delete(cacheKey)
}
r, e := webtest.SpeedTest(testURL, version)
r, e := webtest.SpeedTest(testURL, version, req)
if e != nil {
errorResult := &webtest.WebsiteSpeedTestResult{HostRecord: "Error: " + e.Error()}
speedCache.Store(cacheKey, speedCacheEntry{result: errorResult, timestamp: time.Now()})
speedCache.Store(cacheKey, speedCacheEntry{result: errorResult, timestamp: time.Now(), failed: true})
return 200, errorResult
}
speedCache.Store(cacheKey, speedCacheEntry{result: r, timestamp: time.Now()})
@@ -120,25 +147,40 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
n = c
}
}
cacheKey := fmt.Sprintf("%s:%s:%d", host, port, n)
// version=ipv4/ipv6:与 HTTP handler 同义,请求级指定单栈(配置优先)
version := query["version"]
if version != "" && normalizeStack(version) == "" {
return 400, map[string]string{"error": "version must be ipv4 or ipv6"}
}
stack, skipLabel := probeStackFor(version)
// 请求级解析参数(dns=/dns4=/dns6=):与 HTTP handler 同解析;
// 带覆盖时旁路共享缓存(结果取决于所用服务器)
req, err := webtest.ParseReqResolve(func(k string) string { return query[k] })
if err != nil {
return 400, map[string]string{"error": err.Error()}
}
// 缓存键与 HTTP handler 保持一致(含生效栈 + 请求级 DNS 覆盖),两边共用 pingCache
cacheKey := fmt.Sprintf("%s:%s:%d:%s%s", host, port, n, stack, req.CacheKey())
if cached, ok := pingCache.Load(cacheKey); ok {
entry := cached.(pingCacheEntry)
if time.Since(entry.timestamp) < 5*time.Minute {
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
return 200, entry.result
}
pingCache.Delete(cacheKey)
}
ipv4, ipv6 := wsDual[webtest.TCPingStats](
ipv4, ipv6 := wsDualStack[webtest.TCPingStats](stack,
func(v string) (*webtest.TCPingStats, error) {
return webtest.TCPingRun(host, port, n, v, 10*time.Second, 100*time.Millisecond)
return webtest.TCPingRunWith(host, port, n, v, 10*time.Second, 100*time.Millisecond, req)
},
func(err error) *webtest.TCPingStats { return &webtest.TCPingStats{IP: "Error: " + err.Error()} },
func(v string) *webtest.TCPingStats {
return &webtest.TCPingStats{IP: "Skipped due to SINGLE_STACK=" + v}
func(skipStack string) *webtest.TCPingStats {
return &webtest.TCPingStats{IP: "Skipped due to " + skipLabel + "=" + skipStack}
},
)
result := &TCPingResult{IPv4: ipv4, IPv6: ipv6}
pingCache.Store(cacheKey, pingCacheEntry{result: result, timestamp: time.Now()})
// 只有双栈都失败才算整体失败,单侧失败仍返回了可用的延迟数据(口径同 HTTP handler)
failed := tcpingFailed(ipv4) && tcpingFailed(ipv6)
pingCache.Store(cacheKey, pingCacheEntry{result: result, timestamp: time.Now(), failed: failed})
return 200, result
case "dns":
@@ -149,29 +191,25 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
return 400, map[string]string{"error": "Invalid domain"}
}
domain = parsedURL.Host
var result any
switch recodeType {
case "a":
result, err = webtest.ResolveARecord(domain)
case "aaaa":
result, err = webtest.ResolveAAAARecord(domain)
case "cname":
result, err = webtest.ResolveCNAMERecord(domain)
case "mx":
result, err = webtest.ResolveMXRecord(domain)
case "ns":
result, err = webtest.ResolveNSRecord(domain)
case "txt":
result, err = webtest.ResolveTXTRecord(domain)
case "srv":
result, err = webtest.ResolveSRVRecord(domain)
case "ptr":
result, err = webtest.ResolvePTRRecord(domain)
case "caa":
result, err = webtest.ResolveCAARecord(domain)
case "a", "aaaa", "cname", "mx", "ns", "ptr", "srv", "txt", "caa":
default:
return 400, map[string]string{"error": "Unsupported record type: " + recodeType}
}
req, err := webtest.ParseReqResolve(func(k string) string { return query[k] })
if err != nil {
return 400, map[string]string{"error": err.Error()}
}
// 按 qtype 选栈:a→dns4|dns,aaaa→dns6|dns,其余→dns
stack := ""
if recodeType == "a" {
stack = "ipv4"
} else if recodeType == "aaaa" {
stack = "ipv6"
}
result, err := webtest.ResolveByType(domain, recodeType, req.Servers(stack))
if err != nil {
return 500, map[string]string{"error": err.Error()}
}
@@ -188,18 +226,20 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
if raw == "" {
return 400, map[string]string{"error": "Domain parameter is required"}
}
if cached, ok := whoisCache.Load(raw); ok {
// whois 不走请求级 DNS 覆盖(见 webtest.QueryWhois 注释),缓存键就是域名本身
cacheKey := raw
if cached, ok := whoisCache.Load(cacheKey); ok {
entry := cached.(whoisCacheEntry)
if time.Since(entry.timestamp) < 5*time.Minute {
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
return 200, entry.result
}
whoisCache.Delete(raw)
whoisCache.Delete(cacheKey)
}
result, err := webtest.QueryWhois(raw)
if err != nil {
return 500, map[string]string{"error": err.Error()}
}
whoisCache.Store(raw, whoisCacheEntry{result: result, timestamp: time.Now()})
whoisCache.Store(cacheKey, whoisCacheEntry{result: result, timestamp: time.Now()})
return 200, result
case "location":
@@ -226,9 +266,9 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
} else if errStr, ok := result["dbip_asn"].(string); ok {
asnResult["dbip_asn"] = map[string]string{"error": errStr}
}
if maxmindASN, ok := result["maxmind_asn"].(*ipdb.MMDBASNResult); ok {
whoisData, err := webtest.QueryASNWhois(maxmindASN.ASN)
if err == nil {
// WHOIS 进一步解析(与 /v1/asn/:ip 走同一套兜底与缓存)
if asn := resolveASN(result); asn != "" {
if whoisData, ok := lookupASNWhois(asn); ok {
asnResult["whois"] = whoisData
}
}
@@ -250,7 +290,13 @@ func splitRaw(raw string) (string, string) {
// wsDual 双栈编排(ws.go 内部,学 main.go handler 的 SINGLE_STACK 处理)
func wsDual[T any](run func(version string) (*T, error), mkErr func(err error) *T, mkSkip func(version string) *T) (ipv4, ipv6 *T) {
switch SINGLE_STACK {
return wsDualStack(SINGLE_STACK, run, mkErr, mkSkip)
}
// wsDualStack 是 wsDual 的可指定栈版本:stack 为 "" 双栈并行,"ipv4"/"ipv6" 只跑该栈。
// tcping 借此支持请求级 version 参数(probeStackFor 已把配置优先规则算好)。
func wsDualStack[T any](stack string, run func(version string) (*T, error), mkErr func(err error) *T, mkSkip func(stack string) *T) (ipv4, ipv6 *T) {
switch stack {
case "ipv4":
v4, err := run("v4")
if err != nil {