Compare commits

...
150 Commits
Author SHA1 Message Date
nomdn 2c655bf58e docs: CHANGELOG v4.3.1(resolveASN 优先级调整拆出 v4.3.0) 2026-11-01 21:58:41 +08:00
nomdn 818517ba1f feat(ipdb): resolveASN 优先级改回 maxmind_asn 打头,lemondb_asn 补位\n\nwhois 段的 AS 以 GeoLite2-ASN 为基准(各家共认),maxmind 未命中时\n依次落 lemondb(GeoLite2 底子 + APNIC CN 精修)→ dbip → ip2location。\nCHANGELOG 同步。 2026-11-01 21:56:46 +08:00
nomdn 81936ea7f7 docs: CHANGELOG v4.3.0 措辞收敛 2026-11-01 21:53:42 +08:00
nomdn a2be64c1cc docs: CHANGELOG v4.3.0 发布说明 2026-11-01 21:50:49 +08:00
nomdn 2a4e417b28 前端:地图打点改回统一实心圆点样式 2026-11-01 21:43:25 +08:00
nomdn 65001d1eff 前端:地图打点改环形+实心混排,重合不再互相遮盖\n\n挪绘制顺序只是把遮挡换个方向(LemonDB 置顶后 GeoCN 又被盖)。改为\nLemonDB 画空心大环(radius 14)套在外面,其余源实心小点(radius 7,\n深色描边增强辨识)在内,重合时全部可见。 2026-11-01 21:41:23 +08:00
nomdn 4d2273c885 前端:location 地图 LemonDB 打点置顶\n\n圆点按 SOURCE_DEFS 顺序绘制,后画的盖在先画的上面;各源坐标基本重合,\nLemonDB 排首位先画被完全遮住。挪到末尾最后画,琥珀色点置顶可见。 2026-11-01 21:38:38 +08:00
nomdn a837f38c8f 前端:location 页 LemonDB 行去掉(自家全球库)标注 2026-11-01 21:36:58 +08:00
nomdn 5f202a3a2b docs(ipdb)/前端:LemonDB 坐标统一 WGS-84 的跟随调整
源库 world 阶段已把 division-coords(GCJ-02)逆解为 WGS-84,coord_system
恒为 wgs84。前端 location 页 lemondb 打点回归 wgs84ToGcj02 统一转换路径
(coord_system 动态判断保留作前向兼容),注释与 locate.md 同步。
2026-11-01 21:33:29 +08:00
nomdn df9f7857ed docs(ipdb): 注明 LemonDB 拉取节奏——启动即拉 + 每日条件请求,中断恢复后一天内追平 2026-11-01 21:10:19 +08:00
nomdn 61788fa7ae feat(ipdb): 接入 LemonDB(自家全球库)作为定位与 ASN 源
- ipdb.go:PullDatabase 新增 LEMON-LOCATION.mmdb / LEMON-ASN.mmdb 下载任务
  (nomdn/LemonDB 月度 release,走 ghproxy 镜像链)
- searchip.go:mmdbFiles 注册 lemondb_location / lemondb_asn;
  searchLemonDB 解析全球定位记录(country/region/city/district/division_code/
  isp/type/coord_system——坐标国内 GCJ-02、境外 WGS-84,透传给前端);
  lemondb_asn 与 GeoLite2-ASN 记录同键,复用 searchMMDBASNIn
- allDatabases 加入 lemondb / lemondb_asn,/v1/location 全源输出自动带上
- resolveASN 优先级:lemondb_asn 打头(GeoLite2 底子相同、CN 组织名更准),
  /v1/asn 与 ws asn 分支透传 lemondb_asn
- 前端 location 页新增 LemonDB 行与地图打点(coord_system 动态判断 GCJ-02);
  asn 页新增 LemonDB ASN 行;locate.md 补字段说明
2026-11-01 20:55:25 +08:00
nomdn c9d1f21fa7 feat(location): GeoCN 补充区划政府驻地坐标打点
- 内置 division-coords.json(GB/T 2260 区划代码 → 政府驻地 [lng,lat],
  GCJ-02,数据来自 AreaCity),go:embed 编译期内建
- searchGeoCN 按 division_code 查表输出 latitude/longitude,新设区划查不到时省略
- 修复:var 初始化时解析结果未赋回 map,导致对照表恒为空(新增单测覆盖)
- 前端归属地地图增加 GeoCN 打点(天青色,GCJ-02 直接用)
2026-10-31 14:35:36 +08:00
nomdn fbf5c9e3b0 fix(location): 地图加 maxNativeZoom=17,修复最大缩放级瓦片空白
高德栅格瓦片原生层级约到 17:部分区域(尤其县乡)z=18 会返回纯米色
无数据空瓦片,Leaflet 照常贴上就是用户看到的放大到底一片空白。
声明 maxNativeZoom 后,Leaflet 对更高缩放自动放大 17 级瓦片
(轻度模糊但绝不变空白)。
2026-10-31 13:49:54 +08:00
nomdn af0b5e1ea0 fix(location): MaxMind/DB-IP 打点字段名修正(JSON 键为小写 latitude/longitude)
源定义误用了 Go 结构体字段名(Latitude/Longitude),与 JSON 序列化键
不一致导致这两源永远取不到坐标。修正后四源打点全部生效(实测
223.68.218.79:bilibili/IP2Location 宿迁 + MaxMind 郑州质心 +
DB-IP 南京,视野自适应包全)
2026-10-31 13:14:51 +08:00
nomdn 4d608a6697 feat(location): 地图数据源补充 DB-IP City
DB-IP 对中国 IP 常给省会级坐标(如宿迁 IP 给南京),与 bilibili/IP2Location
同图对比可直观看出免费库的城市级精度差异。
2026-10-31 12:54:43 +08:00
nomdn afd9df87c1 feat(location): 归属地地图改多源打点
每个有坐标的来源各画一个点(bilibili 绿 / MaxMind 蓝 / IP2Location 橙),
fitBounds 自适应包住全部点;只画优先级第一点改为多点对比展示——
归属地页的核心本就是多源差异对比,图上也应直观可见。
2026-10-31 12:19:39 +08:00
nomdn 4987cea8e0 feat(location): 归属地地图(高德瓦片直连 + Leaflet + GCJ-02 转换)
- 高德矢量瓦片直连(webrd0-4,免 key,底图自带审图号),attribution 标注来源
- 打点坐标优先级:bilibili(国内服务已是 GCJ-02 直接用)→
  MaxMind / IP2Location(WGS84 经 utils/gcj02 转 GCJ-02 与底图对齐)
- circleMarker 打点 + popup(IP 与坐标来源),无默认 marker 图标的打包问题
- leaflet 走动态 import(免 SSR 访问 window),CSS 以非 scoped 引入
  (动态创建的窗格拿不到 data-v 属性)
2026-10-31 12:09:00 +08:00
nomdn 0dce0fef14 feat(frontend-ssr): ASN 页节点池改为双栈∩IPLocationAPI 交集
- asn.vue:apiList 取两个池都有的节点(按 id 交集过滤),ASN 与
  location/双栈节点对齐;任一池单独加节点不会进本页
- config:cn2-sichuan / cn-jiangsu 补入 DualStack 池(与 IPLocationAPI
  共享),交集立即生效并扩展 whois/ssl/detail 的 CN 节点覆盖
2026-10-31 11:23:52 +08:00
nomdn fac8be622c feat(frontend-ssr): 恢复西安电信节点(cn-xian) 2026-10-31 10:55:40 +08:00
nomdn 8ae615d0e3 fix(ipdb): GeoCN 行政区划下载补镜像列表,jsdelivr 被墙不再导致数据永久缺失
full.txt/short.txt 是 12 个下载任务里唯一没有镜像可退的(其余 10 个走
ghproxy+raw 均可达):cdn.jsdelivr.net 在国内移动网络下 v4/v6 双栈都被
RST,重试 3 次全打在同一域名上,行政区划数据随之永久缺失(GeoCN 归属
地只剩 division_code 没有省市区文案)。

- downloadTask 改为镜像列表:jsdelivr → fastly → gcore → ghproxy+raw
  依次轮试,每镜像 2 次尝试;单栈节点错误协议族/单镜像被墙均秒级 failover
- 下载失败日志区分「保留本地旧副本」与「本地无副本、相关库降级至下次同步」
  (原日志在本地无文件时谎称 keep existing)

实测(江苏移动家宽,与故障节点同网络):后台初始化全量拉取成功,
division data loaded full=3595 short=3595,/v1/location 返回完整省市区。
2026-10-31 10:45:22 +08:00
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 862717a47e docs: 域名自有化、补四篇 API 文档与 9 张配图
- 域名自有化:全站 ipw.cn 映射到自有域名(站点 nipw.cn;取本机 IP 用 test./4./6.wsmdn.top),
  7 篇正文与 6 个 svg 图层名同步替换;纯文本接口的示例代码相应重写。
- 新增 4 篇 API 文档(api/ip/{locate,myip,query}.md、api/whois/query.md):按自有接口实测
  重写,给出可直接执行的公开实例(节点直连 + middleware-1 转发),另设 1.2 节引导到拨测
  控制台 boce.wsmdn.top 取用已部署的节点与中间件。
- 新增 4 篇文档:tls / dns_dig / code_getip / check_userip_ipv4_ipv6,正文首行徽标一律标
  「转载」(本站为转载方)。
- 补齐 9 张原始配图,置于 public/doc/ 下同名覆盖(wayback 仅存 1 张,实际取自 arquivo.pt)。
- 修正 Serverless 部署支持范围的表述,与仓库内实际实现对齐;/info 拆分后正文同步。
2026-09-25 20:49:52 +08:00
nomdn 88984719c5 feat(frontend): 文档导航收敛到单一组件 + 窄屏抽屉承载;首屏性能与可访问性打磨
导航
- 抽出 app/components/DocMenu.vue:文档导航全站唯一实现,/doc 与 /doc/** 两个页面
  与窄屏抽屉共用同一组件(原先两页各有一份内联菜单,已删)。
- 窄屏(≤768px)隐藏文档页自身的 260px 侧栏,改由 app.vue 左侧抽屉承载(size 80%、
  分组默认展开、:key 重挂避免停在首次开合状态、选中后自动收起);非文档路由的抽屉里
  只留一个「文档」链接作为入口。宽屏布局与桌面端行为不变。
- 文档路由不渲染页脚(阅读页不该被备案 / 版权块打断,窄屏下也避免与抽屉抢滚动区域)。

性能
- Element Plus 去掉整库 dist/index.css,走 @element-plus/nuxt 按需引入;仅额外显式引
  theme-chalk 的暗色变量文件(按需引入不含它)。
- shiki 由静态 import 改动态 import:引擎 + 语言 / 主题注册表约 160KB 原本压在首屏关键
  路径上,首页只为 4 行 curl 示例需要它。

可访问性
- 补 html lang="zh-CN";纯图标菜单项补 aria-label(返回首页 / 切换主题);logo 改装饰性
  (alt="" 留空,可访问名由外层链接提供,避免宽屏下与旁边站名被读两遍)。
- 正文链接补下划线(绿 #3EAF7C 与正文对比度不足,需提供非颜色的视觉区分),窄屏规则同步,
  否则窄屏写 none 会把桌面的 underline 盖掉。
- 页脚公安备案链接补 v-if,未配置时不渲染成空链接。

样式作用域
- 暗色模式中作用于 <html> 自身的规则(文字色 / 页面底色 / 主色变量)从被 scoped @import
  的 style.css 上移到 app.vue 的非 scoped 块 —— scope 属性会让根元素规则永不匹配。
- 抽屉内链接改用直接子选择器(> a),避免把文档菜单里的 <a> 也吃成整行大按钮。
2026-09-25 20:49:36 +08:00
nomdn 684292de76 chore(frontend): 构建配置调整(transpile dayjs/lodash-unified,移除 better-sqlite3)
better-sqlite3 是原生模块,在构建环境里常需现场编译;移除后 lock 中相关条目
转为 optional。dayjs / lodash-unified 加入 build.transpile 以走预构建产物。
2026-09-24 23:57:22 +08:00
nomdn 293157b3f8 fix(frontend): 下线三节点、修复 DNS 结果表列溢出、根页 IP 查询改并行
- config/index.ts:深圳(cn-shenzhen)、武汉(cn-wuhan-chinatelecom)、呼和浩特
  (2faa15e6-...,WS 节点)按行注释保留备查,不再派发
- dns.vue:失败行原本同时渲染"记录数/耗时/TTL"三格并使 colspan 溢出,整表多出 3 列;
  新增 hasData() 让后三格与失败态互斥,列数守恒
- index.vue:根页三个本机 IP 查询去掉 ofetch 默认重试(retry: false),并从串行改为
  各自消费、先到先上屏,不再等 shiki 高亮与最慢一项
- utils/middleware.ts + 对应 server route:候选请求同样关闭默认重试
2026-09-24 23:57:20 +08:00
nomdn d6fdf67bc9 fix(edgeone): 同步主线的 Rcode 透出与服务器地址格式校验
EO 特供副本是独立维护版,此前把 Rcode 非 Success 当错误抛出,导致查不存在的域名
在上层被回 500 —— 前端把"域名不存在"显示成"节点故障"。现与主线对齐:

- 只在传输层失败(超时、服务器不可达、DoH 异常)才返回 error,Rcode/RcodeText 透出
- 补 validServers / validateServerAddr,8 个 ResolveXxxRecord 收敛到 resolveTyped
- 保留 EO 特有形态:DoH/UDP 单地址互相兜底、耗时透出、ResolveIP(tcping/speed 复用)
2026-09-24 23:57:19 +08:00
nomdn 3449babbe0 feat(node): 拆分 GET /info,健康检查只回存活
健康检查 `/` 是免鉴权的对外端点,被负载均衡、OTA 就绪探测、运维 curl 打,
混进版本号等于向公网公开节点版本,也让一个只该回答存活的端点承担了无关职责。

- `/` 只回 {"status":"ok"}
- 新增 /info 回 {version, capabilities},注册在 /v1 组之外,同样免鉴权、不计入统计
- 收集中心对纯 HTTP 节点探活后单独取回;取不到留空、不影响判活
2026-09-24 23:57:17 +08:00
nomdn ba0286bd07 fix(whois): 补注册局 referral 追踪,修复 .top 等域名查询超时
.top 这类 TLD 的 whois 服务器只回一层 referral,不再返回完整记录,原实现拿到的
是"空壳"结果。新增 queryWhoisRegistry / chaseReferral / extractReferralServer /
normalizeReferralValue:先查注册局拿到权威 whois 服务器,再追一层取回完整记录;
happyEyeballsWhoisQuery 补 parent context 以便整条链路可取消。
2026-09-24 23:57:15 +08:00
nomdn e3d5eb0200 fix(dns): 撤除 DoH 私网拦截、补地址格式校验,负响应透出 Rcode
- DoH 端点(dns-server)不再拦截私网/回环:节点上自建内网 DoH(127.0.0.1 / 10.x)是合理
  部署,堵私网会直接打死功能;该配置又需管理员凭据才能改,不构成匿名可达攻击面。
  对应 CodeQL go/request-forgery(#24) 按误报处理。
- 新增 validServers / validateServerAddr:非法地址在 SetDNSServer 入口即丢弃并告警,
  全部非法时保留原配置;UDP 形式同样不禁止私有地址(本地缓存解析器是常见部署)。
- 8 个 ResolveXxxRecord 收敛到 resolveTyped;解析器已应答(哪怕 Rcode 非 Success,
  如 NXDOMAIN / SERVFAIL)不再当 error 返回,改为透出 Rcode / RcodeText——负响应是
  业务结论而非节点故障,此前会被上层回 500。
2026-09-24 23:57:12 +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
nomdn dbaf6a0da9 fix(ota): 下载落盘后显式补执行位,修复 preflight 阶段 permission denied
otaDownloadOne 仅用 os.Create 写临时文件(默认 0644 无 +x),而 preflightBinary
在 replaceBinary 的 chmod 之前就要 exec 该 tmp 文件,Linux 下 EACCES 致所有节点 OTA
必现 permission denied。下载落盘(sha 校验通过后)对非 Windows 平台补 chmod 0755。
2026-09-19 23:29:55 +08:00
nomdn 9a4c7d57a8 fix(report): nodeIsProbeType 白名单补齐 location,与中心侧对齐
节点侧 nodeIsProbeType 白名单原漏 location,导致节点转发/自发 location 查询只累加统计
(中心 request_stats 收全部 apiType,故大盘有 Location 统计)而不上报明细(被 !isProbeType 挡),
造成『大盘有、明细没有』。补齐后与中心 store.go 白名单一致,location 现进入 probe_results 明细。
2026-09-19 23:01:41 +08:00
nomdn 8522fd5c23 Merge branch 'main' of https://github.com/nomdn/ipw-cn 2026-09-19 15:35:32 +08:00
nomdn 08f2a6780d feat(install): 交互改造——DNS 留空即自动探测 / WS 预设指向收集中心 / 注册 key 自动生成
- dns-server 去掉写死的 119.28.28.28:53 默认值:留空 = 不写 Environment,节点启动探 /etc/resolv.conf
  (docs/guide/deploy-node 一直写着"留空=自动探测",是脚本把这个默认值堵死了,两边现在对齐)
- WS 预设链接改为收集中心 wss://boce-api.api-ipw.wsmdn.top/ws(原 middleware-1,提示语与默认值两处)
- 注册 key 留空自动生成 32 位 hex,不再强制必填重问;汇总与结尾信息明文回显(非机密,access-token 仍隐藏)
- 节点 id 提问提到 WS 分支之外:WS 关掉也问、也写 Environment=NODE_ID(HTTP 上报身份同样依赖它)
- 汇总展示补「节点 id」行;移除脚本头注释里的第三方品牌字样
2026-09-19 15:15:38 +08:00
nomdn b03d0a05ee fix(middleware): WS 已知帧显式丢弃——report/config_result/ota_result 不再落 default 刷日志
节点按 report-interval-seconds 把统计与拨测明细广播给所有在线 WS 连接(多活),
它们的消费者是收集中心 ipw-boce;middleware-go 只做转发与拨测代理、不在转发路径采集,
原先这三个帧落到 default 被当成"未知消息"逐条打印,日志噪音随节点数 × 上报频率线性增长。

- ws.go:显式列出 report / config_result / ota_result,收到即丢弃并注明归属(空 case 体,不走 default)
- docs:补齐「节点 → middleware」帧表(ping/pong、report、config_result/ota_result),
  修正心跳周期(middleware → 节点 ping 为 20s),删除已废弃的 command 行,register 补 version/capabilities
2026-09-19 15:15:28 +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
nomdn 7beb66ee2b Merge pull request #31 from nomdn/dependabot/npm_and_yarn/serverless/lemon-getip/hono-4.13.5
chore(deps): bump hono from 4.12.34 to 4.13.5 in /serverless/lemon-getip
2026-09-19 08:56:14 +08:00
nomdn 5ff430214d Merge pull request #33 from nomdn/dependabot/npm_and_yarn/frontend-ssr/devalue-5.9.2
chore(deps): bump devalue from 5.9.0 to 5.9.2 in /frontend-ssr
2026-09-19 08:55:57 +08:00
nomdn 1588896fac Add Baidu site verification meta tag 2026-09-19 08:55:37 +08:00
nomdn edc8f97acd Clear ICP and GongAn values in config
Removed ICP and GongAn values from configuration.
2026-09-18 23:00:26 +08:00
nomdn 4dd807d99c Update siteUrl in configuration 2026-09-18 22:33:40 +08:00
nomdn 2cf31260ae Update index.ts 2026-09-18 22:31:10 +08:00
dependabot[bot] d7df245b75 chore(deps): bump devalue from 5.9.0 to 5.9.2 in /frontend-ssr
Bumps [devalue](https://github.com/sveltejs/devalue) from 5.9.0 to 5.9.2.
- [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.0...v5.9.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 02:51:04 +00:00
nomdn 345422b60f Merge branch 'main' of https://github.com/nomdn/ipw-cn 2026-09-13 21:10:43 +08:00
nomdn d7cc9d45e7 feat: 节点远程运维能力落地——运行时配置/能力协商/OTA 下发 + EO 上报通道
OTA 模型反转:节点不再跟随 GitHub Release 自更新,改为收集中心下发、节点执行
的一次性任务(WS type=ota / HTTP POST /v1/ota),升级时机由管理员掌握。

- 新增 /v1/config(GET/PATCH,?persist=true 写回)与 /v1/config/refresh,WS 同款 type=config;凭据类键遮蔽为 ***,应答带 restartRequired / remoteProtectedKeys 键
- access-token / report-token 列入远端不可覆盖名单,本地 PATCH 不受限
- node-ota 开关支持运行时热改(每次下发 OTA 指令时才读取),非法字面值报 unknown 且不覆盖原值
- 节点上报 capabilities(probe/report/config/ota),收集中心三态门控管理指令
- ws-url 支持热更新(per-URL 连接协程多退少补),node-id/node-key 仍需重启
- EO 边缘函数补齐 HTTP 上报通道(report.go)与 GET / 的版本/能力回报
- 上报循环常驻化;显式持有 http.Server 实现优雅停机
- 修复 block-private-ips 启动期忽略 setting.json 的口径不一致
- OTA 开关键沿用 node-ota / NODE_OTA,语义改为缺省允许、仅显式 false 禁用;install.sh 不再询问自更新
- 文档站新增 节点 API与协议参考 / 公开 API / 功能总览
- Dockerfile 注入 VERSION/COMMIT/BUILD_TIME
2026-09-13 21:05:27 +08:00
nomdn 972ca4c744 Merge pull request #32 from nomdn/dependabot/npm_and_yarn/serverless/lemon-getip/multi-d0c2d048a7
chore(deps): bump @vitest/mocker and vitest in /serverless/lemon-getip
2026-09-10 23:34:47 +08:00
dependabot[bot] 12c91ed2d3 chore(deps): bump @vitest/mocker and vitest in /serverless/lemon-getip
Bumps [@vitest/mocker](https://github.com/vitest-dev/vitest/tree/HEAD/packages/mocker) to 4.1.11 and updates ancestor dependency [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). These dependencies need to be updated together.


Updates `@vitest/mocker` from 4.1.10 to 4.1.11
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/mocker)

Updates `vitest` from 4.1.10 to 4.1.11
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/vitest)

---
updated-dependencies:
- dependency-name: "@vitest/mocker"
  dependency-version: 4.1.11
  dependency-type: indirect
- dependency-name: vitest
  dependency-version: 4.1.11
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-10 06:54:32 +00:00
dependabot[bot] 4fceb8421e chore(deps): bump hono from 4.12.34 to 4.13.5 in /serverless/lemon-getip
Bumps [hono](https://github.com/honojs/hono) from 4.12.34 to 4.13.5.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.34...v4.13.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-10 00:04:48 +00:00
nomdn 56d6bc6c08 refactor: 上报归属收敛——节点为唯一记录者,废除前端转发双算协议
- frontend-ssr(Nuxt TS 中间件):移除转发路径上的 /report 数据上报与 X-Boce-Reporter 归属标记头及 nuxt.config 上报配置,本转发入口不再计数(转发请求也不再携带防双算标记)
- src/report.go:归属规则改为"节点是唯一记录者",统计与拨测明细均由本节点上报,中间件不在转发路径计数故不存在双算;HTTP(/v1) 与 WS 下发两条来源都上报;明细类型扩充为 tcping/speed/detail/ssl/dns(dnssec 只进统计不进明细);废除按 requestId/X-Boce-Reporter 判归属,改用 X-Scheduler-Probe 头(HTTP)与 Scheduler 标记(WS)区分收集中心主动调度拨测(收集中心侧本地落库 sched/biz,本节点跳过避免双算)
- src/ws.go:wsHandleProbe 识别 Scheduler=true 仅回 probe_result 不上报
- 依赖整理:AdguardTeam/golibs 转直接依赖,go.sum tidy
2026-09-06 16:54:37 +08:00
nomdn 981b2fec9c feat(node): 数据上报收集中心对接;修复非 OTA 节点启动即退出
数据上报(src/report.go):
- 新增 report-url / report-token / report-interval-seconds 配置(env REPORT_URL 等)
- 节点把统计与拨测明细按 report-interval-seconds 周期上报收集中心(ipw-boce):
  WS 在线优先广播 report 消息,全部掉线时 HTTP POST <report-url>/report 兜底
- 归属规则防双算:中间件带 X-Boce-Reporter 标记头的转发请求、WS 带 requestId
  的下发拨测由中间件侧上报,节点只报直连/前端/边缘流量
- report-token 属敏感凭据,不随远端配置覆盖

修复节点启动即退出(严重,v3.3.0 回归):
- OTA 重构后 main 仅在「OTA 开启且 Windows」停车,其余形态 main 返回 = 进程
  立即退出(Serve 在后台 goroutine),节点起不来(systemd 下 5s 崩溃循环)
- 非 OTA 交接模式改为阻塞等退出信号(Ctrl+C / SIGTERM),收到后走
  gracefulShutdown 优雅停机;顺带使 systemctl stop 从硬杀变为优雅退出

附带:
- 修复 report 排水循环 break 位置错误(每周期仅上报 1 条明细,队列打满后
  明细近乎全丢)
2026-08-29 22:23:23 +08:00
nomdn f1790742a3 fix(middleware): 恢复 setting.json port 生效(env > setting.json > 默认 8080)
OTA 重构时 main() 的 PORT = os.Getenv("PORT") 在 env 未设置时覆盖了
readConfig 从 setting.json 读出的端口,导致 setting.json 的 port 永不生效。
删掉该覆盖,仅在 PORT 为空时补默认 8080;env 与 setting.json 的合并
本就由 readConfig 完成,优先级恢复为 env > setting.json > 默认
2026-08-29 21:08:05 +08:00
nomdn 6b9f6b6b54 fix(ci): matrix 改纯 include 模式,修复任务被折叠成 2 个
GitHub Actions 的 include 规则会把能匹配现有组合的条目合并进组合本身,
image 顶层数组 + 部分字段 include 的写法导致 6 个任务折叠成 2 个
(且都用了最后一条的 armv7)。改为每条 include 自带全部字段
2026-08-29 20:42:16 +08:00
nomdn cfabb06f73 fix(ci): merge 阶段按文件名收集 digest,不再依赖 artifact 目录结构
download-artifact@v4 pattern 下载的实际布局与脚本假设不符,glob 未命中
导致 @sha256:* 字面量传入 imagetools。改为 digest 文件名自带
<镜像>-<平台>-<hex> 元数据 + merge-multiple 平铺下载,脚本按前缀收集,
缺失时列出目录内容并显式报错
2026-08-29 20:36:50 +08:00
nomdn 372cc62473 fix(ci): docker digest 推送去掉 tags 参数
新版 buildx 禁止 tagged ref 按 digest 推送(can't push tagged ref by digest),
push-by-digest 模式改为 outputs name= 传仓库名,最终 tag 由 merge 阶段
imagetools create 统一打
2026-08-29 20:24:20 +08:00
nomdn e9b3a75f02 feat: v3.3.0 — OTA 优雅交接与中间件自更新、多架构镜像、全项目审计修复
OTA 自更新:
- Windows 改优雅交接:预检新二进制 → 原子替换 → 优雅停机 → 拉起新进程 → 健康检查确认 → 老进程退出,失败自动回滚 .old,不再依赖端口抢占竞态
- 下载增加 Release 资产尺寸精确比对;库缺失慢启动按子进程存活判定(10min 宽限)
- OTA 移植至中间件(middleware-go/ota.go,ota/OTA + gh-proxy 配置)

多架构镜像:
- 新增 docker.yml:amd64 原生 + GitHub 原生 ARM 实例构建 arm64/armv7(无 QEMU),打 v* 标签推送 GHCR(DockerHub 预留注释暂未启用)
- 两个 Dockerfile 支持 buildx 交叉编译(TARGETVARIANT → GOARM 映射),修复 middleware-go Dockerfile 漏拷 ws.go 导致镜像无法构建

审计修复(安全/崩溃/数据正确性):
- SSRF:私网黑名单补齐 CGNAT/ULA/NAT64/组播段;重定向校验检查即将跳转的目标并限 10 跳;边缘函数 tcping 补私网拦截;探针响应体 10MB 上限
- 修复 url.Parse 空指针 ×4、启动解析器初始化崩溃、IP2LOCATION .ZIP 大小写永久缺库、残缺下载覆盖现有库、WHOIS 单次 Read 截断、Bilibili 查询无超时
- 修复中间件 WS 写失败干等 30s、僵死节点永久占位;前端死 catch、路径穿越、toFixed 空指针、首页 IPv4/IPv6 优先判断
- 新增 TRUSTED_PROXIES(后端/中间件)与缓存定期清扫;CORS trim/端口归一化等 20+ 项边缘修复

部署:
- install.sh:curl -f、systemd unit 引号与 env 转义、重跑安装 restart 生效
- CI go-version 对齐 1.26.6
2026-08-29 20:17:31 +08:00
nomdn 4ce9637918 feat(install.sh): 节点 id 支持自定义(默认生成 UUID)+ 新增 OTA 配置项
- WS 节点 id:改为交互输入,直接回车才自动生成 UUID(原强制生成)
- 新增 OTA 自更新询问项(y/N),启用后注入 NODE_OTA=true 到 systemd 环境变量
- 汇总展示增加 OTA 行;ipdb 下载体积提示 200MB→450MB
2026-08-28 22:39:01 +08:00
nomdn 56c08f3629 feat(ota): major 版本变化不自动更新
- 新增 majorSame():latest 与 current 的 major 段不同(如 3.x→4.x)时跳过更新,
  记 Warn 日志提示人工升级(minor/patch 仍自动跟进)
- 任一段无法解析(本地构建 VERSION 为空)放行,不影响首次部署升级
2026-08-28 22:30:03 +08:00
nomdn 949f71ca16 refactor(ota): 下载临时文件使用远端资产名
- 临时文件从 <exe>.ota.tmp 改为 <同目录>/<远端资产名>.tmp(如 lemonipw-linux-amd64.tmp),
  替换时再 rename 到目标路径,最终二进制名不变(服务引用不受影响)
2026-08-28 22:20:50 +08:00
nomdn c0a8f7e55c feat(node): 新增 OTA 自更新(node-ota / NODE_OTA 开关)
- 新增 src/ota.go:定期检查 GitHub Release(启动 5 分钟后首次,之后 6h+jitter),
  按 GOOS/GOARCH 匹配资产,下载后替换当前二进制并重启
- 替换策略:当前二进制改名备份为 <程序名>.old,新二进制就位,失败自动回滚;
  下载文件过小(<1MB)判定为异常丢弃
- 重启:Unix 用 syscall.Exec 原地换镜像(PID 不变),Windows 起新进程后退出
- 版本比较支持 v 前缀与预发布后缀;远端为空/解析失败则不更新
- 默认关闭,需 node-ota:'true' 或 NODE_OTA=true;远端配置可统一开关
- 文档:config.md 配置项 + deploy-node.md「OTA 自更新」章节
2026-08-28 22:04:37 +08:00
nomdn fa9b83c951 docs(deploy-node): 修正 IP 数据库下载体积 200MB→450MB 2026-08-28 19:57:24 +08:00
nomdn c81f92bbd4 docs: DNS 自动探测兜底与数据库体积说明更新
- config.md / deploy-node.md / getting-started.md:dns-server/dnssec-server
  留空时启动自动探测系统 DNS 的说明
- 顺带修正 IP 数据库下载体积 200MB→450MB(含 IP2Location DB11+ASN)
2026-08-28 19:57:01 +08:00
nomdn a411d0bb72 feat(node): DNS/DNSSEC 服务器留空时自动探测系统 DNS 兜底
- readConfig:DNS_SERVER / DNSSEC_DNS_SERVER 未配置时,
  用 sysresolv.NewSystemResolvers 读取系统 DNS,
  经 AddrPortsToCSV 转 'IP:Port' 逗号分隔串后回填
- webtest/helpers.go 新增 AddrPortsToCSV([]netip.AddrPort) string
- go.mod:go 1.26.2→1.26.6,引入 AdguardTeam/golibs(间接依赖)
- main.go 全局变量区 gofmt 对齐
2026-08-28 19:55:36 +08:00
nomdn a93e74a8c7 refactor(ipv6): ipv6.vue 只保留跳转 /location,其余删除
- 页面简化为 onMounted 携带 ?ip= 跳转到 /location(归属地查询页)
- 删除原完整模板/脚本/样式(399 行 → 22 行)
2026-08-28 14:24:07 +08:00
nomdn 4cf136b367 feat(location): IP2Location 行右侧列显示 ASN 组织信息
- location.vue / ipv6.vue 的 IP2Location 行第三列显示 ip2location_asn.as(组织名),
  与 Maxmind(org) / DB-IP(org) 行结构对齐
- 补 IPLocationType 的 ip2location / ip2location_asn 字段
2026-08-28 14:22:11 +08:00
nomdn b350413902 style(location): IP2Location 行去掉邮编与时区展示 2026-08-28 14:16:52 +08:00
nomdn 08877871af feat(ipdb): 装载 IP2Location LITE ASN 数据库
- 新增 ip2location_asn 数据源:IP2LOCATION-LITE-ASN.IPV6.BIN(277MB,v4/v6 全覆盖)
- /v1/asn 与 WS asn 查询加入该源,输出 { asn, as },LITE 占位串过滤为空
- PullDatabase 自动下载(ghproxy 前缀)+ zip 解压复用
- 前端 asn.vue 展示 IP2Location ASN 行,描述更新为多数据源
2026-08-28 14:07:08 +08:00
nomdn d942ff0c5c feat(ipdb): 装载 IP2Location LITE DB11 数据库
- 新增 ip2location 数据源:官方 ip2location-go/v9 读取 .BIN(IPV6 双栈)
- PullDatabase 自动下载(ghproxy 前缀的 nomdn/ip2location release ZIP)+ 新增 zip 解压支持
- SearchIP 输出 {country, country_code, administrative_area, city, zipcode,
  timezone, latitude, longitude, asn, isp...};LITE 版不可用字段(isp/asn 等)过滤为空
- 前端 location/ipv6 页面与 /ip 路由展示 IP2Location 行
- .gitignore 忽略 *.BIN(229MB 数据不入库,靠自动下载)
2026-08-28 14:01:35 +08:00
nomdn 5aaf30dc16 fix(ipdb): 修正 ip2region 管道串解析错位
实测 v4/v6 xdb 返回格式为 国家|省份|城市|ISP(或0)|国家代码,
原实现按 国家|区域|省份|城市|ISP 切分导致省/市/ISP/国家代码全部错位。
- administrative_area/city 改取第 1/2 段
- isp 取第 3 段并过滤 "0"(v4 xdb 无运营商数据)
- 兼容老 4 段格式
2026-08-28 11:09:07 +08:00
nomdn 789968069b feat(ipv6webcheck): 增加网站不支持 IPv4 的检测结论分支
- 反向场景:v4 不可达但 v6 可达时提示『网站不支持 IPv4 访问』
- 配《IPv6发展报告》2026 文案与 jingya 图
2026-08-28 11:03:05 +08:00
nomdn 87fd2fd4df fix: 移除重复的节点配置并调整节点顺序 2026-08-27 13:39:21 +08:00
nomdn 57b5824370 Merge pull request #27 from nomdn/dependabot/go_modules/serverless/edgeone/cloud-functions/golang.org/x/net-0.55.0
chore(deps): bump golang.org/x/net from 0.54.0 to 0.55.0 in /serverless/edgeone/cloud-functions
2026-08-27 10:37:57 +08:00
nomdn 52796aa089 Merge pull request #29 from nomdn/dependabot/npm_and_yarn/serverless/lemon-getip/multi-d2d58c24ab
chore(deps): bump undici, @cloudflare/vitest-pool-workers and wrangler in /serverless/lemon-getip
2026-08-27 10:37:36 +08:00
dependabot[bot] 62b9f686e9 chore(deps): bump undici, @cloudflare/vitest-pool-workers and wrangler
Bumps [undici](https://github.com/nodejs/undici) to 7.29.0 and updates ancestor dependencies [undici](https://github.com/nodejs/undici), [@cloudflare/vitest-pool-workers](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/vitest-pool-workers) and [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler). These dependencies need to be updated together.


Updates `undici` from 7.28.0 to 7.29.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.28.0...v7.29.0)

Updates `@cloudflare/vitest-pool-workers` from 0.18.8 to 0.22.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Changelog](https://github.com/cloudflare/workers-sdk/blob/@cloudflare/vitest-pool-workers@0.22.0/packages/vitest-pool-workers/CHANGELOG.md)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/@cloudflare/vitest-pool-workers@0.22.0/packages/vitest-pool-workers)

Updates `wrangler` from 4.114.0 to 4.124.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.124.0/packages/wrangler)

---
updated-dependencies:
- dependency-name: "@cloudflare/vitest-pool-workers"
  dependency-version: 0.22.0
  dependency-type: direct:development
- dependency-name: undici
  dependency-version: 7.29.0
  dependency-type: indirect
- dependency-name: wrangler
  dependency-version: 4.124.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-27 02:36:57 +00:00
nomdn 7e5bc92aea Merge pull request #30 from nomdn/dependabot/npm_and_yarn/serverless/lemon-getip/multi-1160cd7046
chore(deps): bump sharp, @cloudflare/vitest-pool-workers and wrangler in /serverless/lemon-getip
2026-08-27 10:34:41 +08:00
nomdn ee28aa2cd6 Merge pull request #28 from nomdn/dependabot/go_modules/serverless/edgeone/cloud-functions/github.com/quic-go/quic-go-0.59.1
chore(deps): bump github.com/quic-go/quic-go from 0.59.0 to 0.59.1 in /serverless/edgeone/cloud-functions
2026-08-27 10:34:03 +08:00
dependabot[bot] a95519428b chore(deps): bump sharp, @cloudflare/vitest-pool-workers and wrangler
Bumps [sharp](https://github.com/lovell/sharp) to 0.35.2 and updates ancestor dependencies [sharp](https://github.com/lovell/sharp), [@cloudflare/vitest-pool-workers](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/vitest-pool-workers) and [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler). These dependencies need to be updated together.


Updates `sharp` from 0.34.5 to 0.35.2
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.34.5...v0.35.2)

Updates `@cloudflare/vitest-pool-workers` from 0.18.5 to 0.18.8
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Changelog](https://github.com/cloudflare/workers-sdk/blob/@cloudflare/vitest-pool-workers@0.18.8/packages/vitest-pool-workers/CHANGELOG.md)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/@cloudflare/vitest-pool-workers@0.18.8/packages/vitest-pool-workers)

Updates `wrangler` from 4.111.0 to 4.114.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.114.0/packages/wrangler)

---
updated-dependencies:
- dependency-name: sharp
  dependency-version: 0.35.2
  dependency-type: indirect
- dependency-name: "@cloudflare/vitest-pool-workers"
  dependency-version: 0.18.8
  dependency-type: direct:development
- dependency-name: wrangler
  dependency-version: 4.114.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-27 02:30:56 +00:00
dependabot[bot] aa65f23ac9 chore(deps): bump github.com/quic-go/quic-go
Bumps [github.com/quic-go/quic-go](https://github.com/quic-go/quic-go) from 0.59.0 to 0.59.1.
- [Release notes](https://github.com/quic-go/quic-go/releases)
- [Commits](https://github.com/quic-go/quic-go/compare/v0.59.0...v0.59.1)

---
updated-dependencies:
- dependency-name: github.com/quic-go/quic-go
  dependency-version: 0.59.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-27 02:30:30 +00:00
dependabot[bot] 0dfbf7cbdf chore(deps): bump golang.org/x/net
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.54.0 to 0.55.0.
- [Commits](https://github.com/golang/net/compare/v0.54.0...v0.55.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.55.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-27 02:30:26 +00:00
nomdn 130ac61df0 fix: 更新 README.md 中的项目结构注释以更准确地描述 Go 后端 2026-08-27 10:28:29 +08:00
nomdn e1a00236ee chore: 项目结构重组(Go 后端迁入 src/,serverless 迁入 serverless/)
- Go 后端(main.go/ws.go/ipdb/webtest/ssrf/go.mod 等)移入 src/
- EdgeOne/lemon-getip 等 serverless 组件移入 serverless/
- 同步更新 README 结构图、docs 路径引用、GitHub Actions 工作流
  (build_and_release 加 working-directory: src;edgeone/workers 触发路径与 cd 更新)
2026-08-27 10:28:29 +08:00
nomdn 71ec93f068 Merge pull request #26 from AoruiKai/patch-1
Update README.md with some ....
2026-08-27 10:05:43 +08:00
AKC c0f0640075 Update README.md with some .... 2026-08-27 01:08:03 +08:00
nomdn 778ed2bd29 refactor: 配置键统一 kebab-case、ip2region 返回 JSON、WS 多活
- 中间件/后端 JSON 配置键统一 kebab-case:APIBaseURL→api-base-url、
  IPLocationAPI→ip-location-api、apiKeys→api-keys、wsKeys→ws-keys;
  环境变量 APIKEYS/WSKEYS 改下划线 API_KEYS/WS_KEYS;
  修正 remote-ingore-config 拼写为 remote-ignore-config(REMOTE_IGNORE_CONFIG)
- ip2region 后端预分割为 JSON(country/administrative_area/city/isp),
  前端 location/ipv6 页面与 /ip 路由不再 split 管道串
- WS_URL 多中间件同时连接全部(多活),任一断开只重连自己,不再主备切换
- 新增前端 /ip 归属地查询路由(index/[ip],text/plain 输出)
- 同步更新 docs/install.sh/setting.json.example
2026-08-26 23:27:23 +08:00
nomdn 2ec182fb08 feat: 更新配置以添加新的中间件和节点信息 2026-08-24 23:20:15 +08:00
nomdn e8ec3757fd fix: 更新文档以支持多个中间件地址同时连接的说明 2026-08-24 22:57:30 +08:00
nomdn d398c4085c Fix link formatting in README.md 2026-08-24 22:37:44 +08:00
nomdn d04a90b20c Update README with community IPW version links
Add community links for IPW versions in README.
2026-08-24 22:37:15 +08:00
nomdn 92a8c1f308 feat:CURL查询IP接口 2026-08-23 22:01:37 +08:00
nomdn d2e3601f20 fix: 修复发布配置文件的复制命令 2026-08-23 08:59:44 +08:00
nomdn 6a4a3821de fix: 修复发布配置文件的复制命令 2026-08-22 22:39:06 +08:00
nomdn 069847e11f fix: 统一配置文件中的 access_token 为 access-token 2026-08-22 22:36:09 +08:00
nomdn 30ba61bc11 refactor(middleware): 拆分 apiKeys/wsKeys 为独立鉴权表
- apiKeys 仅用于 HTTP 转发鉴权(注入 Authorization: Bearer)
- 新增 wsKeys 用于 WS 注册校验(register key 比对,否则 401 断开)
- 两表全局变量(API_KEYS/WS_KEYS)、环境变量(APIKEYS/WSKEYS)相互独立
- 远端配置对 apiKeys/wsKeys 均强制忽略,不随远端覆盖
- 更新 config/deploy 文档与 install.sh 提示,标明两者分开管理
2026-08-22 22:19:45 +08:00
nomdn 94f03ffa85 chore: 删除不再使用的设置文件 setting.json 2026-08-22 22:01:01 +08:00
nomdn 3a3eeab9f9 fix: 修复 ipdb 配置为字符串类型并添加 node-key 2026-08-22 21:57:17 +08:00
nomdn fa5c2329fe refactor: 配置大重构——节点池统一结构(APIBaseURL 三栈 + IPLocationAPI 纯数组)
将前端与中间件的『一功能一配置项』改为统一节点池结构:
- APIBaseURL:合并原 apiBaseUrls(whois/ssl/detail)、NSLookup(dns/dnssec)、
  TCPing、SpeedTest,含 {IPv6,IPv4,DualStack} 三栈;
  whois/ssl/detail 用 DualStack,dns/dnssec/tcping/speed 用三栈平铺
- IPLocationAPI:原 IPLocationAPIs,location/asn,纯数组(无栈区分)
- 节点项统一为 {label, id, url, ws?};前端无 ws 字段
- 中间件 handler/readConfig/applyRemoteConfig 改为按池取数,
  flattenStack 平铺查找节点,tcping/speed 分页取栈(普通页 DualStack+IPv4、
  ipv6 页 DualStack+IPv6)均可在池内命中
- 前端 11 个页面与 server 路由同步改用新池取数
- 文档 config.md / deploy-* 同步;.gitignore 忽略 tmp/(冒烟产物)
- 冒烟验证:whois/ssl/detail/dns/dnssec/location/asn/tcping/udping/speed 分发正确
2026-08-22 21:51:32 +08:00
nomdn 957ac5a4c8 docs: 安装脚本管道执行警告、统一 GitHub alert 语法、README 安装命令更新
- getting-started.md:交互式部署新增 [!WARNING] 警告——勿用
  `curl ... | sudo bash` 管道方式执行(边下载边执行,网络抖动时 bash
  读到不完整内容报 syntax error);curl 参数改为 -fsSL -o
- deploy-node.md:方案五命令块补全获取脚本步骤(git clone / curl -fsSL),
  版本兼容提醒改为 [!WARNING]
- config.md:两处敏感凭据警告去 emoji(块引用改 [!WARNING],列表项去 ⚠️)
- 全站警告块统一为 GitHub alert 语法([!WARNING] / [!IMPORTANT]),无 emoji

- 快速开始命令补全 git clone 步骤(install.sh 需先从仓库获取)
2026-08-22 15:32:29 +08:00
nomdn 63c42627f6 Fix installation command URL in README
Updated installation command in README to use raw.githubusercontent.com.
2026-08-22 15:00:54 +08:00
nomdn 6512a46596 fix: WS 通道转发响应 Content-Type 缺失(text/plain → application/json)
## 问题

中间件经 WS 通道转发拨测结果时,成功路径用 fiber 的 `c.Send(body)`
直接返回节点上报的 JSON 字节,未指定 Content-Type——fiber 默认
`text/plain; charset=utf-8`,即使 body 是合法 JSON。

仅 WS 通道受影响:HTTP 直连通道(forwardUpstream)本就 `.Type("json")`。

## 修复

middleware-go/main.go 两处 WS 转发成功路径(detail/whois/ssl 等 322 行、
tcping/speed 368 行)加 `.Type("json")`:

    return c.Status(status).Type("json").Send(body)

失败路径(502 文本)保持不变。

## 冒烟验证

- 复现:/v1/test/tcping/1.1.1.1?count=1 → Content-Type: text/plain(body 为 JSON)
- 修复后:tcping(368 分支)与 whois(322 分支)均返回
  application/json; charset=utf-8
- go build / go vet 通过
2026-08-22 12:47:35 +08:00
nomdn e261666efc fix:补全SINGLE_STACK 2026-08-22 11:28:59 +08:00
nomdn 0d1a9e5bbd fix:修正一键安装脚本 2026-08-22 11:24:20 +08:00
nomdn 279f663694 fix:测试配置误上传 2026-08-21 22:40:52 +08:00
nomdn 3af1076492 docs:清理README的过时内容 2026-08-21 22:25:37 +08:00
nomdn 10f48f8619 feat: 一键安装脚本(install.sh 交互式部署 + systemd),快速开始只保留交互式部署
## install.sh(新增)

一键安装后端节点:自动检测架构 → 下载最新 release 二进制 → 交互式输入配置
(环境变量注入,无需 setting.json)→ 生成并启动 systemd 守护进程。

- 前置检查:root 权限 / systemd 存在 / wget|curl 可用
- 交互式配置项:安装目录、端口、access_token、DNS(主从逗号分隔)、DNSSEC 专用
  DNS、ipdb、CORS、远端配置地址、WS 通道接入、其他环境变量
- 下载用 GitHub API 解析最新 tag(releases/download/latest 会 404,latest 非真实 tag)
- WS 接入规则:WS_URL 留空默认 wss://middleware-1.api-ipw.wsmdn.top;
  NODE_ID 强制自动生成 UUID(/proc → uuidgen → /dev/urandom 三级降级);
  NODE_KEY 必填(不加 key 禁止启用 WS),安装完成提示加入中间件 apiKeys
- 生成 systemd 服务:配置以 Environment="K=V" 注入(仅非空写入),
  daemon-reload + enable --now 自动启动
- service 描述使用中性名(Lemon IPW Backend Node),不含站点标识

## 文档

- getting-started.md 重写:只保留交互式一键部署(install.sh),删除手动构建、
  配置示例与手写 systemd 章节
- deploy-node.md 新增「方案五:一键安装(install.sh)」:配置项表格、WS 规则、
  systemd 管理命令、版本兼容提醒(v1.1.0 读 IPW_ 旧变量名)
- deploy-middleware.md / deploy-node.md / index.md:systemd 指引改指向方案五

## 其他

- setting.json 清理测试残留值(remote-config-url / ws-url 的 ws://localhost)
2026-08-21 22:17:52 +08:00
nomdn 694ddac6eb refactor: WS 配置去 IPW 前缀、删除 command 指令、注册被拒 30s 重试
## WS 客户端配置改名(去 IPW 前缀)

- 环境变量:IPW_WS_URL → WS_URL、IPW_NODE_ID → NODE_ID、IPW_NODE_KEY → NODE_KEY
- setting.json / 远端配置键:ipw-ws-url → ws-url、ipw-node-id → node-id、ipw-node-key → node-key
- 同步位置:main.go(env 读取 / viper 键 / 远端 configValue + ignored 判断)、
  ws.go(配置注释 / 日志)、setting.json、docs/guide/config.md
- 全局变量名 WS_URL / WS_NODE_ID / WS_NODE_KEY 保持不变

## 删除 WS command 指令(两侧)

- 中间件(middleware-go):删除 handleWSCommand(reload_config 热重载,原本即死代码)、
  onCommand 回调字段、newWSServer 参数、Handler 的 case "command" 分支
- 节点侧:撤销未落地的 wsActive / wsSendCommand / /v1/ws-command 路由
- wsCommand 类型保留(register_error 错误消息仍使用)

## 注册被拒重试间隔 3s → 30s

- wsClientOnce 返回值从 bool 改为 time.Duration(重试等待时长):
  连接失败/断开/心跳失败 → 3s;注册被拒(register_error,多为 key 配置错误)
  → 30s 避免空转刷日志;配置缺失 → 0 停止
- wsClientLoop 直接使用返回时长,多中间件场景各 URL 独立计间隔

## 文档

- deploy-node.md 新增「WS 通道接入(可选)」:WS_URL/NODE_ID/NODE_KEY 三配置
  用法、主备切换、双向心跳、注册 key 校验、NODE_ID 与中间件节点 id 对应关系
- deploy-middleware.md 节点侧接入步骤交叉引用 deploy-node.md WS 章节
- config.md 同步 ws-url/node-id/node-key 新键名(remote-ingore-config 示例、安全说明)

验证:go build/vet 通过;全站文档无 IPW 前缀残留。
2026-08-21 18:22:51 +08:00
nomdn 73ecf67c4e feat: WS 通道、探针函数抽取 webtest、统一 viper 配置、DNS 主从 failover
大幅重构与功能扩展,覆盖后端探针、WS 双通道、配置体系与文档四大块。

## 探针核心抽取 webtest 包(按 API 单文件)

- 从 main.go 抽取站点检测核心到 webtest/,每个 API 一个文件:
  website.go(CheckWebsite) / speed.go(SpeedTest) / ssl.go(CheckSSL) /
  dns.go / dnssec.go / tcping.go / whois.go + types.go / helpers.go / client.go
- 函数签名保持原样 (url, version):HTTP 客户端经 webtest.SetHTTPClient
  (V4Client, V6Client) 注入,main.go 启动时接线,不污染签名
- main.go 只留 handler 薄包装(缓存 / SINGLE_STACK / 双栈编排 / c.JSON 都在
  handler 内),用户注释、包名、函数名、变量名均未改动
- 删除中途的 probeX 编排层 / 自调用方案(均已被"函数+handler 同源"取代)

## WS 双通道(中间件服务端 + 节点客户端)

中间件(middleware-go/ws.go,新增):
- 独立端口 WS 服务端(ws-port,默认 8092,0=关闭,路径 /ws),与 fiber HTTP
  完全分离;节点配置 "ws": true 时拨测请求改走 WS 通道,HTTP 通道保留
- 注册 key 校验:节点在 apiKeys 配了 key 就必须传对,不传/传错返回
  register_error {"code":401,"command":"invalid key"} 并断开;未配置开放
- probe/probe_result 按 requestId 异步关联(并发多拨测),未连接/超时返回 502
- 每 20s ping + status 统计上报;新增处理节点主动心跳 ping → 回 pong

节点客户端(ws.go,新增):
- IPW_WS_URL 支持逗号分隔多中间件(第一个为主,故障自动切换下一个)
- 双向心跳:每 10s 发 ping,发送失败重试,连续失败 3 次判定中间件不可用,
  断开并切换下一个 URL;收到中间件 ping 回 pong;断线 3s 重连
- 收到 probe 直调 webtest 函数 + 共享缓存,probe_result 上报
- 配置:IPW_WS_URL / IPW_NODE_ID / IPW_NODE_KEY(env > setting.json > 远端)

## 配置体系统一 viper + remote-ingore-config

- middleware-go 从自定义 loader 迁移到 viper 统一读取(先 Getenv 再
  viper.GetString,env 优先 > setting.json > 默认值)
- remote-ingore-config(env REMOTE_INGORE_CONFIG):数组中的配置项不被远端
  覆盖,applyRemoteConfig 逐键 !ignored(key) 判断跳过(两端 main.go 均已实现)
- 敏感凭据强制忽略:apiKeys(中间件)与 access_token(后端)不随远端配置覆盖
- JSON 键名规则:标量配置项用连接线(http-timeout-seconds / rate-limit /
  ws-port / remote-config-url / remote-ingore-config),节点键保留驼峰
  (apiBaseUrls / IPLocationAPIs / TCPing / SpeedTest / NSLookup / apiKeys)
- ws-port 与 port 一致用 stringOrNumber(兼容 "8092" 与 8092,"0"=关闭)

## DNS 主从 failover + DNSSEC 专用服务器

- dns-server 支持逗号分隔多地址(第一个为主,主失败自动切换从,超时/网络
  错误/SERVFAIL 均触发切换);每项支持 ip:port(UDP)或 https://(DoH)
- 新增 dnssec-server(env DNSSEC_DNS_SERVER):DNSSEC 查询专用 DNS,
  未配置则沿用 dns-server;dnssec.go 改用 queryDNSSEC
- EO 兼容版本(edgeone/cloud-functions/webtest)同步:主从列表 +
  queryDNSSECMsg(保留带 duration 签名)+ index.go DNSSEC_DNS_SERVER 接线

## 前端

- utils/middleware.ts:RETRY_STATUS_CODES 加入 502——请求中间件收到 502
  自动切换下一个候选(config.Middleware 外部节点 + 内置中间件兜底)
- server/routes/middleware/[...slug].get.ts:内置中间件新增单 IP 限流
  (rateLimitPerMinute,默认 120,0=不限流)+ Origin/Referer 校验
- config/index.ts:新增 rateLimitPerMinute;新增 IP 直连新节点
  (tencent-sh 等 url 留空,仅经独立中间件转发);删除 huawei 节点

## 文档

- 新增 docs/guide/config.md「配置文件」独立页面(后端 setting.json 全字段、
  前端 config/index.ts、内置/独立中间件、远端配置详解)
- getting-started.md 只留最小示例 + systemd,配置详解移入 config.md
- deploy-middleware.md 新增「WS 通道」章节(架构/协议表/接入步骤/注册 key
  校验);deploy-node/deploy-frontend 链接修正到 config.md
- 侧边栏新增「配置文件」入口;outline 显示 h2+h3

冒烟验证:WS 端到端(注册 key 四场景、拨测 200、断线切换中间件)、DNS 主从
failover、dnssec-server 专用、ws-port 三写法均通过;go build/vet 双端通过。
2026-08-20 22:54:08 +08:00
nomdn b4a18e0fbf feat: 文档站新增简介/部署方案页与 favicon,中间件不再透传 Origin
- docs: 新增 info/overview(项目概述)、guide/getting-started(快速入门:构建/配置/systemd)、deploy-node(EO/Vercel/Docker/二进制 + Serverless IPv4-only 说明)、deploy-middleware(Docker/二进制 + example 复制提示)
- docs: 前端部署页重构为 EdgeOne/Vercel/Cloudflare Workers/直接运行(去掉 Docker,去掉 CF 一键按钮);部署总览改三组件表;首页按钮指向快速开始;侧边栏分简介/部署;移植 favicon.svg/ico
- middleware-go / 前端内置中间件:转发不再携带 Origin,避免上游 CORS 误判(保留 Authorization Bearer 注入)
2026-08-19 16:10:39 +08:00
nomdn b36314174c fix: 关闭 nuxt-security 静态页安全头写入 _headers,修复 Cloudflare 部署超长行错误
根因:/doc 改为 SSG 后,nuxt-security 在预渲染时把每页 CSP 的 SRI modulepreload 哈希全量拼入
script-src,单行超过 Cloudflare _headers 2000 字符上限(24 个 doc 路由各一处)。
修复:security.ssg.nitroHeaders=false,静态页不再写 _headers(SSR 页面仍由运行时中间件下发完整安全头)。
已验证本地构建产物 _headers 无超长行。
2026-08-18 23:00:54 +08:00
nomdn a3b7d9375f feat: 文档站初始化、远端配置、DNS 双通道、Release 配置文件附件
- docs: 初始化 VitePress+Teek 文档站(部署指南/前端部署/首页特性),关闭自动侧边栏插件改为手动维护
- main/edgeone: 支持 REMOTE_CONFIG_URL 远端配置拉取(远端 > env > setting.json,access_token 保持原优先级)
- main/edgeone: DNS 层支持 DoH+UDP 双通道自动互备,dns-server 支持 URL 或 ip:port
- edgeone: 补齐 /v1/dnssec 路由,WHOIS 增加 SSRF 公网 IP 过滤
- frontend-ssr: /doc 路由改为 SSG 预渲染;移除未使用的 @nuxt/content 依赖
- release: 工作流附带 setting.json.example 与 middleware.setting.json.example,正文给出重命名指引
- frontend: 响应式折叠菜单、节点重试、siteName/内置中间件开关等既有改动一并提交
- README: 部署文档与配置优先级说明更新
2026-08-18 22:45:12 +08:00
nomdn 21989b06ce fix:修复节点重试和middleware重试竞态,修复ACTION部署覆盖ENV 2026-08-14 20:55:56 +08:00
nomdn c4d796868a feat: 独立转发中间件支持Docker部署,环境变量,CORS 2026-08-14 15:13:27 +08:00
186 changed files with 23786 additions and 9393 deletions

No files matched your search

+134 -94
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,113 +13,148 @@ 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.2' # 选择适合的 Go 版本
- 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
go-version: '1.26.6' # 与 src/go.mod 声明保持一致,避免依赖 GOTOOLCHAIN 隐式下载
- name: Build for Linux
# 产物统一写到仓库根目录(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
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: |
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
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
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
# 后端:发布 src/ 下的 setting.json.example;中间件:只发布 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
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
body: |
## 配置文件说明
Release 附带两份配置文件(**.example** 后缀用于标识,需重命名后使用):
| 发布文件 | 重命名为 | 放置位置 |
|----------|----------|----------|
| `setting.json.example` | `setting.json` | 主程序(后端测试节点)同目录 |
| `middleware.setting.json.example` | `setting.json` | `middleware-go/` 目录 |
```bash
# 后端测试节点
cp setting.json.example setting.json
# 独立中间件
cp middleware.setting.json.example middleware-go/setting.json
```
files: dist/*
+203
View File
@@ -0,0 +1,203 @@
name: Docker Multi-Arch
# 打 v* 标签发版时构建多架构镜像并推送到 GHCR + Docker Hub。
# ARM 用 GitHub 原生 arm64 实例(ubuntu-24.04-arm,公开仓库免费)交叉编译,全程无 QEMU:
# - ubuntu-latest → linux/amd64(原生)
# - ubuntu-24.04-arm → linux/arm64(原生)、linux/arm/v7(Go 交叉编译,GOARM 由 Dockerfile 处理)
# 各平台按 digest 推到 GHCR,最后由 merge job 用 buildx imagetools 合并成多架构
# manifest list,同时打上 GHCR 与 Docker Hub 的标签。
#
# Docker Hub 推送为可选:需要仓库 Secrets 配置 DOCKERHUB_USERNAME / DOCKERHUB_TOKEN,
# 未配置时只推 GHCR,不影响流程。
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
# 纯 include 模式:每条自带全部字段,独立成一个任务。
# 注意不能把 image 写成顶层数组再用 include 拆——GitHub 会把能匹配
# 现有组合的 include 条目合并进组合本身,导致任务被折叠(见 v3.3.0 事故)
include:
- image: lemon-ipw
context: src
runner: ubuntu-latest
platforms: linux/amd64
platform_id: amd64
- image: lemon-ipw
context: src
runner: ubuntu-24.04-arm
platforms: linux/arm64
platform_id: arm64
- image: lemon-ipw
context: src
runner: ubuntu-24.04-arm
platforms: linux/arm/v7
platform_id: armv7
- image: lemon-ipw-middleware
context: middleware-go
runner: ubuntu-latest
platforms: linux/amd64
platform_id: amd64
- image: lemon-ipw-middleware
context: middleware-go
runner: ubuntu-24.04-arm
platforms: linux/arm64
platform_id: arm64
- image: lemon-ipw-middleware
context: middleware-go
runner: ubuntu-24.04-arm
platforms: linux/arm/v7
platform_id: armv7
steps:
- name: Checkout repository
uses: actions/checkout@v4
# 版本注入 src/Dockerfile(build-args 见下方构建步骤):
# VERSION 取 ref 名——打 v* 标签时与 build_and_release.yml 的二进制产物一致(带 v 前缀),
# 手动触发时为分支名(与 merge 阶段打的 main 标签对应)
- 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: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}/${{ matrix.image }}
# push-by-digest 模式:不传 tags(新 buildx 禁止 tagged ref 按 digest 推送),
# 仓库名通过 outputs 的 name= 传入,最终 tag 由 merge 阶段统一打
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
platforms: ${{ matrix.platforms }}
# 只有 src/Dockerfile 声明了这三个 ARG;middleware-go 未声明,BuildKit 会静默忽略
build-args: |
VERSION=${{ env.VERSION }}
COMMIT=${{ env.COMMIT }}
BUILD_TIME=${{ env.BUILD_TIME }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=ghcr.io/${{ github.repository }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.image }}-${{ matrix.platform_id }}
cache-to: type=gha,mode=max,scope=${{ matrix.image }}-${{ matrix.platform_id }}
- name: Export digest
run: |
mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.build.outputs.digest }}"
# 文件名自带镜像与平台元数据:merge 阶段 merge-multiple 平铺下载后
# 按前缀收集,不依赖 artifact 的目录结构
touch "${{ runner.temp }}/digests/${{ matrix.image }}-${{ matrix.platform_id }}-${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.image }}-${{ matrix.platform_id }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: build
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# ===== Docker Hub 推送暂时停用 =====
# 需要启用时:取消注释本步骤与下方 merge 脚本中的 docker.io 标签行,
# 并在仓库 Secrets 配置 DOCKERHUB_USERNAME / DOCKERHUB_TOKEN
# - name: Log in to Docker Hub
# if: ${{ secrets.DOCKERHUB_USERNAME != '' }}
# uses: docker/login-action@v3
# with:
# username: ${{ secrets.DOCKERHUB_USERNAME }}
# password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push multi-arch manifest lists
# DOCKERHUB_USERNAME 暂不注入(Docker Hub 推送停用,见上方登录步骤注释)
# env:
# DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
run: |
set -euo pipefail
DIGESTS_DIR="${{ runner.temp }}/digests"
GHCR_REPO="ghcr.io/${GITHUB_REPOSITORY,,}"
# 标签:v1.2.3 → ["1.2.3", "1.2"];手动触发 → ["main"]
if [[ "${GITHUB_REF_NAME}" == v* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
MINOR="$(echo "${VERSION}" | cut -d. -f1,2)"
TAGS=("${VERSION}" "${MINOR}")
else
TAGS=("main")
fi
for IMG in lemon-ipw lemon-ipw-middleware; do
ARGS=()
for TAG in "${TAGS[@]}"; do
ARGS+=(-t "${GHCR_REPO}/${IMG}:${TAG}")
# Docker Hub 推送停用;启用时取消注释并恢复上方 DOCKERHUB_USERNAME env
# if [ -n "${DOCKERHUB_USERNAME}" ]; then
# ARGS+=(-t "docker.io/${DOCKERHUB_USERNAME,,}/${IMG}:${TAG}")
# fi
done
# 各平台单架构 manifest(GHCR 上按 digest 推送的产物)合并为一份
# 多架构 manifest list;imagetools create 会按 -t 目标推送到 GHCR
shopt -s nullglob
for PLATFORM_ID in amd64 arm64 armv7; do
PREFIX="${IMG}-${PLATFORM_ID}-"
FILES=("${DIGESTS_DIR}"/${PREFIX}*)
if [ ${#FILES[@]} -eq 0 ]; then
echo "ERROR: no digest file for ${IMG} (${PLATFORM_ID}); dir contents:" >&2
ls -la "${DIGESTS_DIR}" >&2 || true
exit 1
fi
for f in "${FILES[@]}"; do
hex="$(basename "${f}")"
ARGS+=("${GHCR_REPO}/${IMG}@sha256:${hex#${PREFIX}}")
done
done
shopt -u nullglob
echo "Merging ${#ARGS[@]} refs for ${IMG}:"; printf ' %s\n' "${ARGS[@]}"
docker buildx imagetools create "${ARGS[@]}"
done
echo "Pushed multi-arch manifests: ${TAGS[*]}"
+3 -3
View File
@@ -5,7 +5,7 @@ on:
branches:
- main
paths:
- 'edgeone/**'
- 'serverless/edgeone/**'
workflow_dispatch:
jobs:
@@ -24,10 +24,10 @@ jobs:
- name: Install dependencies
run: npm install -g edgeone
- name: Deploy to EdgeOne Pages
run: cd edgeone && npx edgeone pages deploy -n ipw-cn -t ${{ secrets.EDGEONE_API_TOKEN }}
run: cd serverless/edgeone && npx edgeone pages deploy -n ipw-cn -t ${{ secrets.EDGEONE_API_TOKEN }}
env:
EDGEONE_API_TOKEN: ${{ secrets.EDGEONE_API_TOKEN }}
- name: Deploy to EdgeOne Pages
run: cd edgeone && npx edgeone pages deploy -n ipw-cn2 -t ${{ secrets.EDGEONE_API_TOKEN }}
run: cd serverless/edgeone && npx edgeone pages deploy -n ipw-cn2 -t ${{ secrets.EDGEONE_API_TOKEN }}
env:
EDGEONE_API_TOKEN: ${{ secrets.EDGEONE_API_TOKEN }}
+2 -2
View File
@@ -5,7 +5,7 @@ on:
branches:
- main
paths:
- 'edgeone-getip/**'
- 'serverless/edgeone-getip/**'
workflow_dispatch:
jobs:
@@ -24,6 +24,6 @@ jobs:
- name: Install dependencies
run: npm install -g edgeone
- name: Deploy to EdgeOne Pages
run: cd edgeone-getip && npx edgeone pages deploy -n edgeone-getip -t ${{ secrets.EDGEONE_API_TOKEN }}
run: cd serverless/edgeone-getip && npx edgeone pages deploy -n edgeone-getip -t ${{ secrets.EDGEONE_API_TOKEN }}
env:
EDGEONE_API_TOKEN: ${{ secrets.EDGEONE_API_TOKEN }}
+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
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
+2 -2
View File
@@ -4,7 +4,7 @@ on:
push:
branches: ['main']
paths:
- 'lemon-getip/**'
- 'serverless/lemon-getip/**'
workflow_dispatch:
jobs:
deploy:
@@ -25,6 +25,6 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
cd lemon-getip
cd serverless/lemon-getip
npm install
npm run deploy
+17 -1
View File
@@ -15,5 +15,21 @@ lemon-ipw
*.tmp
*.log
node_modules/
.workbuddy/
# VitePress 构建产物
docs/.vitepress/.temp/
docs/.vitepress/cache/
docs/.vitepress/dist/
# 构建期临时配置文件(vitepress 每次构建生成,用完即弃)
docs/.vitepress/*.timestamp-*
tmp/
setting.json
.workbuddy/
*.BIN
# 本地调试脚本与实测产物(不入库,与 ipw-boce 的约定一致)
tmp-smoke/
# Agent 工具产物(本机技能安装/会话状态,不入库)
.agents/
.zcode/
skills-lock.json
+74
View File
@@ -0,0 +1,74 @@
# Changelog
本文件记录面向用户/运维的版本变更。格式参照 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本(SemVer)。
## [v4.3.1] - 2026-11-01
### 🛠 修复
- **resolveASN 优先级调整**:改回 `maxmind_asn` 打头(whois 段的 AS 以 GeoLite2-ASN 为各家共认基准),`lemondb_asn` 退居未命中补位,链路为 maxmind_asn → lemondb_asn → dbip_asn → ip2location_asn
## [v4.3.0] - 2026-11-01
> 接入自家 LemonDB 全球库(定位 + ASN 双源)、归属地可视化地图、GeoCN 镜像链修复。
### ✨ 新功能
- **接入 LemonDB 作为定位与 ASN 源**:`/v1/location` 与 `/v1/asn` 全链路新增 `lemondb` / `lemondb_asn` 输出。LemonDB(nomdn/LemonDB 月度构建)为全球打底 + 中国省市县高精度层:中国段含省/市/区县 + 运营商 + 网络类型 + GB/T 2260 区划码,v6 部分段可到区县;坐标全库统一 WGS-84,`coord_system` 字段透传
- **ASN 解析优先级重排**:`resolveASN` 改为 lemondb_asn → maxmind_asn → dbip_asn → ip2location_asn(补齐未命中兜底),whois 段与 ws 同步
- **LemonDB 自动下载**:`PullDatabase` 新增 `LEMON-LOCATION.mmdb` / `LEMON-ASN.mmdb` 任务,走 ghproxy 镜像链;启动即拉 + 每日 ETag 条件请求,节点中断后恢复可在一天内追平
- **归属地地图**:location 页新增地图(高德瓦片直连 + Leaflet + 免 key),多源打点直观对比各库定位差异与离散度;坐标按源静态标记或 `coord_system` 动态判断(LemonDB),经 `wgs84ToGcj02` 统一转 GCJ-02 与底图对齐
- **地图数据源补充**:DB-IP City、GeoCN(含区划政府驻地坐标打点)
- **ASN 页节点池**:改为「双栈 ∩ IPLocationAPI」节点交集,只展示两个接口都通的节点
- **前端 LemonDB 展示**:location 表格首行 + asn 页 LemonDB ASN 行;locate.md 补字段说明
### 🛠 修复
- **GeoCN 行政区划下载单点依赖**:jsdelivr 被墙时数据永久缺失,补镜像列表兜底
- **地图最大缩放瓦片空白**:高德栅格瓦片原生上限约 17 级,声明 `maxNativeZoom=17` 后更高缩放自动放大 17 级瓦片(轻度模糊但不再空白)
- **MaxMind/DB-IP 地图打点无效**:字段名与实际 JSON 不符(小写 `latitude`/`longitude`),修正后打点恢复
- 节点池恢复西安电信节点(cn-xian)
### ⚠️ 行为变化
- `/v1/location` 输出新增 `lemondb` 键(全源之首);`/v1/asn` 与 ws 的 asn 分支新增 `lemondb_asn`
- ASN whois 段的解析库优先级变化(见上),lemondb 未命中的 IP 会依次落到 maxmind / dbip
- ipdb 目录新增约 70MB 的 LemonDB 两库磁盘占用(首次启动后台自动拉取,不阻塞服务)
## [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 会静默失败,本次已验证此坑)
-31
View File
@@ -1,31 +0,0 @@
FROM golang:1.26-alpine AS build
ENV GO111MODULE=on \
CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY main.go ./
COPY webtest/ ./webtest/
COPY ipdb/ ./ipdb/
COPY ssrf/ ./ssrf/
RUN go build -o main .
# 运行阶段
FROM alpine:latest
# 添加必要的证书
RUN apk --no-cache add ca-certificates
# 创建非root用户
RUN adduser -D appuser
USER appuser
WORKDIR /home/appuser
# 从构建阶段复制二进制文件
COPY --from=build /app/main .
EXPOSE 8080
CMD ["./main"]
+72 -174
View File
@@ -2,194 +2,92 @@
ipw.cn 替代品,提供 IP 查询、网站检测、SSL 检查、DNS 解析、TCPing 测速、Whois、DNSSEC、ASN 查询、网站截图等功能。
## 功能
**[预览站点](https://ipw.wsmdn.top/)** · **[文档](https://github.com/nomdn/ipw-cn/tree/main/docs)**
- **IP 地址查询** — 支持 IPv4/IPv6,集成 ip2region、qqwry、MaxMind GeoIP、GeoCN、DbIP、Bilibili 等多数据源
- **ASN 自治系统查询** — 查询 IP 所属自治系统号,支持 MaxMind GeoLite2、DbIP 双数据源,集成 WHOIS 解析
- **网站检测** — HTTP 状态码、响应时间、Host 记录
- **SSL 证书检查** — 证书有效期、颁发机构、剩余天数
- **DNS 解析** — 多节点并发查询,支持 A/AAAA/CNAME/MX/TXT/NS/SRV/PTR/CAA 等记录类型
- **DNSSEC 验证** — 检测域名 DNSSEC 签名状态,验证 DNSKEY、RRSIG、DS 记录链式信任关系
- **Whois 查询** — 域名注册信息、注册商、到期时间等
- **TCPing** — TCP 连接延迟测试,支持 IPv4/IPv6 双栈
- **网站测速** — 下载速度、响应头、Host 记录
- **网站截图** — 在线网站页面截图
- **SSRF 防护** — 自动拦截对内网/私有 IP 的出站请求
- **暗色模式** — 支持明暗主题切换
## 快速开始
```bash
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o install.sh && sudo bash install.sh
```
完整部署(后端节点 / 独立中间件 / 前端)与配置说明见 [文档](https://github.com/nomdn/ipw-cn/tree/main/docs):
- [功能总览](docs/info/features.md)(探测能力、IP 数据源、可靠性设计)
- [快速开始](docs/guide/getting-started.md)
- [后端节点部署](docs/guide/deploy-node.md)
- [中间件部署](docs/guide/deploy-middleware.md)
- [前端部署](docs/guide/deploy-frontend.md)
- [配置文件](docs/guide/config.md)
- [节点 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 # 跟踪日志
```
## 项目结构
```
ipw-cn/
├── main.go # Go 后端入口(Gin 框架,自托管)
├── go.mod # Go 模块定义
├── Dockerfile # 后端 Docker 镜像
├── setting.json # 后端运行配置
│
├── ipdb/ # IP 数据库查询模块
│ ├── searchip.go # 多数据源 IP 查询(ip2region/qqwry/MaxMind/GeoCN/DbIP/Bilibili)
│ └── ipdb.go # 数据库下载与更新
│
├── webtest/ # 网络测试工具
│ ├── dns.go # DNS 查询(基于 miekg/dns)
│ ├── dnssec.go # DNSSEC 验证
│ ├── tcping.go # TCP 连接测试
│ └── whois.go # WHOIS 域名查询
│
├── ssrf/ # SSRF 防护模块
│ └── ssrf.go # 私有 IP 拦截、安全跳转校验
│
├── frontend-ssr/ # Nuxt 4 SSR 前端(主版本,部署至 Cloudflare Workers)
│ ├── app/ # Nuxt 应用源码
│ │ └── pages/ # 页面组件(location/dns/tcping/ssl/speedtest/whois/dnssec/asn/screenshot)
│ ├── config/ # 前端配置(API 地址、节点列表)
│ ├── nuxt.config.ts # Nuxt 配置
│ ├── package.json
│ ├── pnpm-workspace.yaml
│ └── wrangler.jsonc # Cloudflare Workers 部署配置
│
├── edgeone/ # 腾讯 EdgeOne 边缘函数版本后端
│ ├── cloud-functions/ # Go 边缘函数源码
│ │ ├── index.go # 后端入口(Gin 框架)
│ │ ├── webtest/ # 网络测试工具
│ │ ├── ssrf/ # SSRF 防护
│ │ └── go.mod
│ └── .edgeone/ # EdgeOne 部署配置
│
├── lemon-getip/ # Cloudflare Workers IP 查询服务(Hono + TypeScript)
│ ├── src/index.ts
│ ├── wrangler.jsonc
│ ├── package.json
│ └── test/
│
├── edgeone-getip/ # EdgeOne Pages IP 查询服务(TypeScript 边缘函数)
│ ├── edge-functions/index.ts
│ └── .edgeone/
│
├── .github/workflows/ # CI/CD 流水线
│ ├── frontend-ssr.yml # 部署至 Cloudflare Workers
│ ├── workers.yml # 部署 lemon-getip 至 Cloudflare Workers
│ ├── edgeone-backend.yml # 部署 edgeone 至 EdgeOne Pages
│ └── edgeone-getip.yml # 部署 edgeone-getip 至 EdgeOne Pages
│
├── tmp/ # IP 数据库下载临时目录
├── short.txt # GeoCN 行政区划短表(省市级)
├── full.txt # GeoCN 行政区划全表(区县级)
├── ipw-backend.exe # Windows 预编译后端二进制
├── .node-version # Node.js 版本(v22)
└── LICENSE # GPL-3.0
├── src/ # Go 后端
├── middleware-go/ # 独立组件 转发中间件(Fiber v3,多节点候选)
├── serverless/ # 独立组件 边缘/Serverless(edgeone、edgeone-getip、lemon-getip)
├── frontend-ssr/ # Nuxt 4 SSR 前端
├── docs/ # 文档站
├── install.sh # 一键安装脚本(自建完整部署)
└── contribute.sh # 贡献节点脚本(一台闲置机器接为公共节点)
```
## 友情链接
### 来自社区的IPW版本
[ZAKOFLARE/ipw-cn-php](https://github.com/ZAKOFLARE/ipw-cn-php) 一个基于PHP的IPW节点
[ZAKOFLARE/ipw-cn-rust](https://github.com/ZAKOFLARE/ipw-cn-rust) 一个生锈的IPW节点[开发成本过高,已存档]
[KFCV50TK/ipw-cn](https://github.com/KFCV50TK/ipw-cn) 一个有许多扩展功能的IPW分支
## 技术栈
| 组件 | 技术 |
|------|------|
| 后端(自托管) | Go 1.26 + Gin + Resty |
| 边缘后端(EdgeOne) | Go(边缘函数) |
| SSR 前端 | Nuxt 4 + Vue 3 + Element Plus + VueUse |
| Cloudflare Workers | Hono + TypeScript + Wrangler |
| EdgeOne Pages | TypeScript 边缘函数 |
| IP 数据库 | ip2region、qqwry.ipdb、MaxMind GeoLite2、GeoCN、DbIP、Bilibili API |
| DNS 查询 | miekg/dns(Go)/ 原生 DNS-over-HTTPS(EdgeOne) |
| 其他 | SSRF 防护、Zstandard 压缩、WordPress mshots 截图 |
| 部署 | Docker、Cloudflare Workers、EdgeOne Pages |
| 后端(自托管) | Go + Gin + Resty |
| 独立中间件 | Go + Fiber v3(WS 通道:coder/websocket) |
| 边缘后端(EdgeOne) | Go 边缘函数 |
| SSR 前端 | Nuxt 4 + Vue 3 + Element Plus |
| DNS 查询 | miekg/dns(DoH / UDP 双通道 + 主从 failover) |
| 部署 | Docker、Cloudflare Workers、EdgeOne Pages、systemd |
## 快速开始
## 联系方式
### 后端(自托管)
```bash
# 安装依赖
go mod download
# 配置 setting.json(可选)
# {
# "port": 8080,
# "gh-proxy": "https://fastgit.cc/",
# "single-stack": "", # "ipv4" 或 "ipv6" 仅启用单栈
# "dns-server": "", # 自定义 DNS 服务器
# "block-private-ips": true,# SSRF 防护开关
# "ipdb": true, # IP 数据库开关
# "cors": "", # 允许的请求来源,详见 https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Guides/CORS
# "access_token": "" # API 访问令牌(可选)
# }
# 运行
go run main.go
```
首次启动时会自动下载 IP 数据库文件(约 200MB),之后每 24 小时自动更新。
### SSR 前端(部署至 Cloudflare Workers)
```bash
cd frontend-ssr
# 安装依赖(需要 pnpm)
pnpm install
# 开发
pnpm dev
# 构建并部署
pnpm deploy
```
### SPA 前端(旧版本,部署至 Cloudflare Pages)
> [!WARNING]
> 原生 SPA 已停止支持。要部署 SPA 版本前端请使用 Nuxt SPA 版本。
### Docker 部署
```bash
docker build -t lemon-ipw .
docker run -p 8080:8080 -v $(pwd)/setting.json:/app/setting.json lemon-ipw
```
## API 接口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/v1/location/:ip` | 查询指定 IP 地理位置 |
| GET | `/v1/location` | 查询请求者 IP 地理位置 |
| GET | `/v1/asn/:ip` | 查询 IP 所属自治系统号(ASN) |
| GET | `/v1/detail/*url` | 检测网站可达性(HTTP 状态码、响应时间) |
| GET | `/v1/ssl/*url` | 检查 SSL 证书信息 |
| GET | `/v1/speed/:version/*url` | 网站测速(version: v4/v6/dual) |
| GET | `/v1/tcping/:ip?port=80&count=4` | TCP 连接测试 |
| GET | `/v1/dns/:type/*domain` | DNS 解析(type: A/AAAA/CNAME/MX/TXT/NS/SRV/PTR/CAA) |
| GET | `/v1/dnssec/:domain` | DNSSEC 验证 |
| GET | `/v1/whois/:domain` | Whois 域名查询 |
| GET | `/` | 健康检查 |
## 部署架构
本项目支持多平台部署,前端和后端可独立部署到不同平台:
- **自托管后端**:使用 Docker 或直接运行 Go 二进制,配合 `setting.json` 配置
- **Cloudflare Workers 后端**:`lemon-getip/` 使用 Hono 框架部署到 Cloudflare Workers
- **EdgeOne 边缘后端**:`edgeone/` 和 `edgeone-getip/` 部署到腾讯 EdgeOne Pages
- **SSR 前端**:`frontend-ssr/` 使用 Nuxt 4 + Wrangler 部署到 Cloudflare Workers
## 配置
### 后端环境变量
| 配置项 | 环境变量 | 默认值 | 说明 |
|--------|----------|--------|------|
| `port` | `PORT` | `8080` | 监听端口 |
| `gh-proxy` | `GH_PROXY` | `""` | GitHub 文件代理(用于下载数据库) |
| `single-stack` | `SINGLE_STACK` | `""` | 单栈模式:`ipv4` 或 `ipv6` |
| `dns-server` | `DNS_SERVER` | `119.28.28.28:53` | DNS 服务器地址 |
| `block-private-ips` | `BLOCK_PRIVATE_IPS` | `true` | SSRF 防护开关 |
| `ipdb` | `IPDB` | `true` | IP 数据库开关 |
| `access_token` | `ACCESS_TOKEN` | `""` | API 访问令牌 |
| `cors` | `CORS` | `""` | 允许的请求来源 |
### 前端配置
前端 API 地址和多节点配置位于各前端项目的 `config/` 目录中。
- 邮箱:[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)
## 许可证
+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
+88
View File
@@ -0,0 +1,88 @@
import { defineConfig } from 'vitepress'
// .vitepress/config.mts
import { defineTeekConfig } from "vitepress-theme-teek/config";
// Teek 主题配置:defineTeekConfig 返回的对象必须合并进 defineConfig 才会生效。
// teekHome:false + vpHome:true 使用 VitePress 标准首页布局,避免出现空荡的博客风格右侧卡片。
const teekConfig = defineTeekConfig({
teekHome: false,
vpHome: true,
vitePlugins: {
// 关闭文章统计插件,不再扫描/统计文档
docAnalysis: false,
// 关闭自动生成侧边栏插件,侧边栏由下方 themeConfig.sidebar 手动维护
sidebar: false,
},
});
// https://vitepress.dev/reference/site-config
export default defineConfig({
// 合并 Teek 注入的 vite / markdown / ignoreDeadLinks 等配置
...teekConfig,
title: "LEMON IPW",
description: "柠檬味 ipw.cn 替代品 · 文档(占位)",
lang: "zh-CN",
cleanUrls: true,
head: [
['link', { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }],
['link', { rel: 'icon', href: '/favicon.ico' }],
],
themeConfig: {
// 合并 Teek 的 themeConfig(如 teekHome 等开关)
...teekConfig.themeConfig,
nav: [
{ text: 'Home', link: '/' },
{ text: '部署', link: '/guide/' },
{ text: 'GitHub', link: 'https://github.com/nomdn/ipw-cn', target: '_blank' },
],
// 右侧目录:显示 h2 + h3(默认只显示 h2)
outline: {
level: [2, 3],
label: '本页目录',
},
sidebar: [
// 简介
{
text: '简介',
items: [
{ text: '概述', link: '/info/overview' },
{ text: '功能总览', link: '/info/features' },
{ text: '快速入门', link: '/guide/getting-started' },
{ text: '配置文件', link: '/guide/config' },
],
},
// 部署
{
text: '部署',
items: [
{ text: '部署指南', link: '/guide/' },
{ text: '后端节点', link: '/guide/deploy-node' },
{ text: '中间件', link: '/guide/deploy-middleware' },
{ text: '前端', link: '/guide/deploy-frontend' },
],
},
// 参考
{
text: '参考',
items: [
{ text: '节点 API 与协议', link: '/guide/node-api' },
{ text: '公开 API(/api/v1)', link: '/guide/public-api' },
],
},
],
socialLinks: [
{ icon: 'github', link: 'https://github.com/nomdn/ipw-cn' },
],
search: {
provider: 'local',
},
},
})
+6
View File
@@ -0,0 +1,6 @@
import { defineTeekConfig } from "vitepress-theme-teek/config";
export const teekConfig = defineTeekConfig({
homeCardListPosition: false,
teekHome: false,
});
+7
View File
@@ -0,0 +1,7 @@
// .vitepress/theme/index.ts
import Teek from "vitepress-theme-teek";
import "vitepress-theme-teek/index.css";
export default {
extends: Teek,
};
+367
View File
@@ -0,0 +1,367 @@
# 配置文件
各组件配置说明与完整字段参考。快速上手示例见 [快速开始](/guide/getting-started)。
## 后端(setting.json)
后端从当前目录读取 `setting.json`,不存在时使用默认值。最小配置:
```json
{
"port": 8080,
"dns-server": "119.28.28.28:53",
"block-private-ips": true,
"ipdb": true,
"cors": "",
"access-token": ""
}
```
- `port`:监听端口
- `dns-server`:DNS 解析服务器,支持**主从 failover**——逗号分隔多地址(如 `"119.28.28.28:53,223.5.5.5:53"`),第一个为主,主查询失败(超时/网络错误)自动切换下一个;每项支持 `ip:port`(UDP)或 `https://` URL(DoH);**留空则启动时自动探测系统 DNS**(`IP:Port` 逗号分隔串)兜底
- `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` 路由不注册,适合只做网络拨测的轻量节点
- `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` / `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"` 也是开)。现在四个开关统一按上表判定。
### 运行时配置管理
除启动加载外,节点(v1.4+)还支持被**远程读写当前生效配置**——收集中心控制台可以改配置而不必登录节点主机,节点也可以在运行中刷新远端配置。
| 动作 | HTTP | WS | 说明 |
|------|------|----|------|
| 读取 | `GET /v1/config` | `config` + `action=get` | 返回生效配置快照 + `secretKeys` + `restartRequiredKeys` |
| 修改 | `PATCH /v1/config`(`?persist=1` 时写回本地 setting.json) | `config` + `action=patch` | 逐键应用,只改传入的键 |
| 刷新远端 | `POST /v1/config/refresh` | `config` + `action=refresh` | 重新拉取 `remote-config-url` 并应用 |
三条约定:
- **凭据类键不回显明文**:`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` / `access-log` / `max-response-body` / `memory-limit` 每次消费重读,同样即时生效。
- **内存改动会被重启顶掉**:节点侧优先级为 远端 > 环境变量 > `setting.json`。只 PATCH 内存(或 `persist` 写本地文件)时,重启后仍可能被 ENV 顶掉;要长期生效应 **`refresh`**(改远端配置)或把改动合并进托管配置。
> [!NOTE]
> **与托管配置的区别**:托管配置(收集中心的 `node_configs`,经 `GET /remote-config/:nodeId` 下发)是**节点启动时拉的远端配置**,是持久来源;运行时配置读写的是**节点进程内存里当前生效的值**。前者决定"重启后是什么",后者反映"现在是什么"。
接口细节见 [节点 API 与协议参考](/guide/node-api) 的「管理接口」一节。
---
## 前端(config/index.ts)
前端配置在 `frontend-ssr/config/index.ts`,部署前按需修改。完整配置项如下:
| 配置项 | 类型 | 说明 |
|--------|------|------|
| `siteUrl` | string | 站点对外地址 |
| `siteName` | string | 站点名称(页面标题 / 描述 / 页脚品牌) |
| `EnableInternalMiddleware` | boolean | 是否启用前端内置中间件(作为 `/middleware/*` 候选最后一位兜底),默认 `true` |
| `rateLimitPerMinute` | number | 内置中间件单 IP 限流次数(次/分钟),默认 `120`,`0` 表示不限流 |
| `Middleware` | string[] | 外部独立中间件 base URL 列表,`/middleware/*` 请求依次尝试、出错重试下一个 |
| `umamiHost` / `umamiScriptUrl` / `umamiWebsiteId` | string | Umami 统计配置 |
| `ICP` / `GongAn` | string | 网站备案号(页脚展示) |
| `noindex` | boolean | 全站是否禁止搜索引擎索引 |
| `v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` | string | IPv4 / IPv6 / 双栈 IP 查询接口 |
| `APIBaseURL` | 节点池 | 拨测上游节点池(whois / ssl / detail / dns / dnssec / tcping / speed),含 `DualStack` / `IPv4` / `IPv6` 三栈 |
| `IPLocationAPI` | 节点数组 | IP 归属地 / ASN 查询节点池(纯数组,无栈区分) |
完整模板(复制到 `frontend-ssr/config/index.ts`,替换占位值即可):
```ts
const config = {
siteUrl: "https://your-domain.com/", // 站点对外地址
siteName: "你的站点名称", // 站点名称(页脚展示)
// 是否启用前端内置中间件(本地转发,作为候选最后一位兜底)
EnableInternalMiddleware: true,
// 内置中间件单 IP 限流次数(次/分钟),0 表示不限流
rateLimitPerMinute: 120,
// 外部独立中间件 base URL 列表(可留空,仅用内置中间件)
Middleware: <string[]>[],
// Umami 统计(不需要留空)
umamiHost: "",
umamiScriptUrl: "",
umamiWebsiteId: "",
// 备案号(页脚展示,不需要留空)
ICP: "",
GongAn: "",
// 是否禁止搜索引擎索引
noindex: false,
// 出站 IP 检测接口(页面直连需 CORS,建议 wsmdn.top 的三个)
v4OnlyAPI: "https://4.wsmdn.top",
v6OnlyAPI: "https://6.wsmdn.top",
DualStackAPI: "https://test.wsmdn.top",
// 拨测节点池(whois / ssl / detail / dns / dnssec / tcping / speed)
// IPv4-only 节点只能放 IPv4 栈,IPv6-only 放 IPv6 栈,双栈放 DualStack
APIBaseURL: {
DualStack: [],
IPv4: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
],
IPv6: []
},
// IP 归属地 / ASN 查询节点池(纯数组,无栈区分)
IPLocationAPI: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
]
}
export { config }
```
> 节点 `id` 需与后端节点 / 中间件 `api-keys` 的键保持一致,中间件按 `id` 注入 `Authorization: Bearer <key>`;WS 通道的节点注册校验另见中间件 `ws-keys`(与后端 `node-key` 对应)。
节点数组项均为 `{ label, id, url }` 结构,例如:
```ts
APIBaseURL: {
DualStack: [
{ label: "中国 江苏 移动", id: "cn-jiangsu", url: "https://cn-jiangsu.api-ipw.wsmdn.top/" }
],
IPv4: [],
IPv6: []
}
```
`label` 为节点展示名,`id` 为节点唯一标识(中间件 `api-keys` 按此 id 注入 HTTP 转发 key,WS 注册校验另见 `ws-keys`),`url` 为节点 base URL。节点池结构如下:`APIBaseURL` 为拨测节点池,含 `DualStack` / `IPv4` / `IPv6` 三栈(分别对应双栈 / 单栈测试时使用的节点列表,IPv4-only 节点只能放 `IPv4` 栈,IPv6-only 只能放 `IPv6` 栈,不能放 `DualStack`);`IPLocationAPI` 为 IP 归属地 / ASN 查询节点池,为纯数组(无栈区分)。
### 出站 IP 检测接口(v4OnlyAPI / v6OnlyAPI / DualStackAPI)
`v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` 用于获取请求方的出站 IP(分别对应 IPv4、IPv6、双栈),首页与部分页面会直接请求。可直接使用的接口:
| 接口 | 类型 |
|------|------|
| `https://4.wsmdn.top` | IPv4 |
| `https://6.wsmdn.top` | IPv6 |
| `https://test.wsmdn.top` | 双栈 |
| `https://ipv4.ip.sb` | IPv4 |
| `https://ipv6.ip.sb` | IPv6 |
| `https://ip.sb` | 双栈 |
| `https://4.itdog.cn` | IPv4 |
| `https://6.itdog.cn` | IPv6 |
| `https://v.itdog.cn` | 双栈 |
> **CORS 说明**:以上接口仅 `4.wsmdn.top` / `6.wsmdn.top` / `test.wsmdn.top` 返回 `Access-Control-Allow-Origin: *`,其余(ip.sb、itdog.cn)不带 CORS 头。前端页面在客户端导航时会从浏览器直接请求这些接口,**没有 CORS 会被浏览器拦截**,因此页面直连检测建议使用 wsmdn.top 的三个;ip.sb / itdog.cn 适合在服务端或命令行场景使用。
---
## 前端内置中间件(NUXT_APIKEYS)
前端内置中间件(`frontend-ssr/server/routes/middleware/[...slug].get.ts`)作为 `/middleware/*` 候选列表的最后一位兜底,转发时也会注入 key:
- 读取方式:`useRuntimeConfig(event).apiKeys`,按请求的 `backendID`(节点 `id`)查找
- 环境变量:**`NUXT_APIKEYS`**(Nuxt 约定 `NUXT_` + 配置键 `apiKeys`)
- 格式:JSON 字符串,例如 `NUXT_APIKEYS='{"cn-jiangsu":"your-key"}'`
- 注入方式:转发请求头 `Authorization: Bearer <key>`
也可以在 `nuxt.config.ts` 的 `runtimeConfig.apiKeys` 里直接写死(不推荐,密钥不应进仓库)。
### 内置中间件数据上报(调一次上报一次)
内置中间件可作为数据上报入口:每转发一次请求,向收集中心(ipw-boce 中间件)`POST /report` 上报一次统计与拨测明细。**配置全部经环境变量注入**(敏感项不进仓库):
| 环境变量 | 说明 |
|------|------|
| `NUXT_BOCE_REPORT_URL` | 收集中心基址(如 `https://collector.example.com`);**留空 = 不上报**(此时转发也不带标记头,由节点兜底记账) |
| `NUXT_BOCE_REPORT_TOKEN` | 收集中心 `/report` 鉴权 token(敏感,与收集中心 `report-token` 配置一致) |
| `NUXT_BOCE_REPORT_INSTANCE` | 上报方标识(存收集库 `probe_results.origin`);默认 `frontend-ssr` |
行为约定:
- 配置了上报后,内置中间件转发请求会带 `X-Boce-Reporter` 标记头——后端节点据此跳过这些请求的统计(防双算,见后端节点"数据上报"一节)
- 上报为 fire-and-forget:失败静默丢弃、不重试(统计是增量累加语义,重试会双算),不影响转发响应
- 上报失败不影响主链路;超时 5 秒
---
## 独立中间件(middleware-go)
中间件配置在 `middleware-go/setting.json`:
```json
{
"port": "8091",
"http-timeout-seconds": 30,
"cors": "",
"trusted-proxies": "",
"gh-proxy": "",
"ota": "false",
"rate-limit": 120,
"ws-port": 8092,
"remote-config-url": "",
"remote-ignore-config": [],
"api-base-url": {
"DualStack": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"IPv4": [],
"IPv6": []
},
"ip-location-api": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"api-keys": { "cn-jiangsu": "" },
"ws-keys": { "cn-jiangsu": "" }
}
```
- `rate-limit`:单 IP 每分钟限流次数,默认 `120`,`0` 表示不限流(也可用环境变量 `RATE_LIMIT` 覆盖)
- `trusted-proxies`:可信代理 IP/CIDR(逗号分隔)。配置后 `c.IP()`(限流按它分桶)只信任这些代理转发的 `X-Forwarded-For`,反代/CDN 后限流才按真实客户端 IP 计数;留空保持默认(不信任 XFF 头,按直连地址限流)。也可用环境变量 `TRUSTED_PROXIES` 覆盖
- `gh-proxy`:GitHub 代理前缀(OTA 下载加速,如 `"https://ghproxy.com/"`),可空;也可用环境变量 `GH_PROXY` 覆盖
- `ota`:**OTA 自更新开关**(`"true"` 启用)。启用后中间件定期检查本仓库 GitHub Release,发现新版本时下载匹配的 `middleware-go-*` 资产、预检、**替换自身并重启**(Windows 下为优雅交接,失败自动回滚旧版;major 版本升级需人工);默认关闭。也可用环境变量 `OTA` 覆盖
- `ws-port`:WS 服务端口,默认 `8092`,`0` 表示关闭 WS 通道(也可用环境变量 `WS_PORT` 覆盖;优先级:远端配置 > 环境变量 > setting.json)
- `remote-config-url`:远端配置地址,也可用环境变量 `REMOTE_CONFIG_URL`(环境变量优先)
- `remote-ignore-config`:**不被远端配置覆盖的配置项列表**(数组),如 `["port", "rate-limit"]`;也可用环境变量 `REMOTE_IGNORE_CONFIG`(JSON 数组字符串,优先于 setting.json)
- `api-keys`:用于向后端注入 `Authorization: Bearer <key>`,优先级:setting.json `api-keys` > 环境变量 `API_KEYS`(JSON 字符串)。**敏感凭据,强制忽略,不随远端配置覆盖**
- `ws-keys`:WS 注册校验表(`节点id → key`):节点经 WS 注册时,`register` 消息的 `key` 必须与 `ws-keys[节点id]` 一致,否则返回 401 并断开;优先级:setting.json `ws-keys` > 环境变量 `WS_KEYS`(JSON 字符串)。**敏感凭据,强制忽略,不随远端配置覆盖**。与 `api-keys` **相互独立**——`api-keys` 管 HTTP 转发鉴权,`ws-keys` 管 WS 注册校验,可分别配置不同 key(例如 WS 节点单独换 key 不影响 HTTP 转发)
- 节点池与前端 `config/index.ts` 结构一致:`APIBaseURL` 为拨测节点池(含 `DualStack` / `IPv4` / `IPv6` 三栈),`IPLocationAPI` 为 IP 归属地 / ASN 节点池(纯数组,无栈);节点加 `"ws": true` 可让该节点走 WS 通道通信,详见 [中间件部署](/guide/deploy-middleware) 的「WS 通道」一节
### WS 通道配置一览
WS 通道涉及**中间件(服务端)**与**后端节点(客户端)**两侧配置:
| 角色 | 配置项 | 环境变量 | 说明 |
|------|--------|----------|------|
| 中间件(服务端) | `ws-port` | `WS_PORT` | WS 监听端口,默认 `8092`,`0`=关闭;节点连 `ws(s)://<中间件>:<ws-port>/ws` |
| 中间件(服务端) | `ws-keys` | `WS_KEYS` | WS 注册校验表:`{"<node-id>": "<key>"}`,节点 `register` 的 key 必须匹配 |
| 节点(客户端) | `ws-url` | `WS_URL` | 中间件 WS 完整地址(含 `/ws` 路径),逗号分隔多个则**同时连接全部(多活)** |
| 节点(客户端) | `node-id` | `NODE_ID` | 节点 id,与中间件 `api-keys`/`ws-keys` 键一致(建议 UUID) |
| 节点(客户端) | `node-key` | `NODE_KEY` | 注册 key,与中间件 `ws-keys[节点id]` 一致,**必填** |
**数据上报通道**(节点把自身观测的统计/拨测上报到收集中心,归属规则防双算——中间件转发的请求由中间件上报,节点只报直连/前端/边缘流量):
| 角色 | 配置项 | 环境变量 | 说明 |
|------|--------|----------|------|
| 节点(客户端) | `report-url` | `REPORT_URL` | 收集中心(ipw-boce 中间件)HTTP 基址;WS 在线时优先走 WS 广播,掉线时 POST `<report-url>/report` 兜底 |
| 节点(客户端) | `report-token` | `REPORT_TOKEN` | `/report` 鉴权 token,与收集中心 `report-token` 一致;属凭据类键,快照遮蔽为 `***`,**不随远端配置覆盖** |
| 节点(客户端) | `report-interval-seconds` | `REPORT_INTERVAL_SECONDS` | 上报周期秒,缺省 15 |
**链路**:中间件收到 `ws:true` 节点的拨测请求 → 经 WS 发 `probe` → 节点执行探针(直调 webtest 函数 + 缓存)→ `probe_result` 回传 → 中间件返回 HTTP 响应(`Content-Type: application/json`)。节点侧接入步骤见 [后端节点部署](/guide/deploy-node) 的「WS 通道接入」一节。
---
## 远端配置(REMOTE_CONFIG_URL)
**什么是远端配置**:把配置文件放到一个可访问的 URL 上(如 Gist、对象存储、静态托管、任意 HTTP 服务),部署时只需设置一个环境变量,之后改配置**不用重新部署、不用重启**,下次启动自动拉取最新配置。适合多节点统一管理、批量更新节点配置的场景。
**支持的组件**(三个组件均已支持):
| 组件 | 触发方式 | 远端配置格式 |
|------|----------|--------------|
| 后端(主线 `src/main.go`) | `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` | 后端 `setting.json` 格式 |
| 后端(`serverless/edgeone/`) | `REMOTE_CONFIG_URL` | 后端 `setting.json` 格式 |
| 独立中间件(middleware-go) | `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` | 中间件 `setting.json` 格式 |
**合并规则**(重要,逐字段生效):
- **优先级:远端配置 > 环境变量 > setting.json**
- 远端 JSON 中**非空的字段才覆盖**本地值:字符串非空、数组非空、数字非 0(`rate-limit` 为指针语义,缺省=不覆盖,显式 0=不限流)
- 部分字段缺省不会冲掉本地配置——远端只需包含要覆盖的字段
- **敏感凭据强制忽略,永远不被远端覆盖**(代码写死,无需配置):
- 后端 `access-token`:不随远端覆盖(保持 环境变量 > setting.json),远端里的 `access-token` 会被忽略
- 后端 `report-token`:同上。收集中心的托管配置里写了也不生效,节点会在日志与控制台应答的 `protectedIgnored` 里列出被跳过的键
- 中间件 `api-keys` / `ws-keys`:不随远端覆盖,远端里的 `api-keys`/`ws-keys` 会被忽略
为什么把这两项写死:它们被远端改错时的故障形态都是「远端一错、本地失联、且无法再远程修回」——`report-token` 被改 → 上报被收集中心 `/report` 拒收,控制台上节点仍显示在线、数据却静默断了;`access-token` 被改 → 控制台再也连不上该节点的管理面,连改回去的通道都没了。所以这层解耦必须由节点自己兜底,不能依赖「配置源别写错」。`node-key` 有意**不在**保护名单里(托管部署下常由远端统一下发节点身份),需要一并锁死时把它写进 `remote-ignore-config`。保护名单只增不减,且**只约束远端下发**,本地 `PATCH` 不受影响。
- `remote-ignore-config`(后端键名 `remote-ignore-config` / env `REMOTE_IGNORE_CONFIG`):额外指定**不被远端覆盖的配置项列表**,数组中的键即使远端下发也不生效。适用于 Node 身份类与其它不想被远端左右的项(如 `node-key`、`dns-server` 等),也可覆盖"非空才覆盖"规则
**拉取行为**:
- 在**进程启动时的配置加载阶段**一次性拉取
- HTTP 超时 **10 秒**,仅接受 **200** 响应(404/5xx 视为失败)
- 拉取或 JSON 解析失败:打印警告日志 `WARN failed to fetch remote config, fallback to local: ...`,**自动回退本地配置**,服务正常启动,不会崩溃
- 拉取成功:启动日志打印 `remote config applied from <url>`
- **启动时拉取一次,运行中可手动刷新**:进程启动的配置加载阶段拉取一次;此后不会自动轮询远端,但可经 `POST /v1/config/refresh`(或 WS `config` + `action=refresh`)**主动重新拉取并应用**,无需重启——收集中心控制台的「刷新远端」按钮即走这条路
**示例一:后端**(主线,远端 JSON 放在 `https://example.com/ipw-config.json`):
```json
{
"port": 8080,
"single-stack": "ipv4",
"dns-server": "119.28.28.28:53",
"cors": "https://ipw.wsmdn.top",
"remote-config-url": ""
}
```
部署时:
```bash
REMOTE_CONFIG_URL=https://example.com/ipw-config.json ./lemonipw
```
**示例二:独立中间件**(middleware-go,远端 JSON 放在 `https://example.com/mw-config.json`):
```json
{
"port": "8091",
"cors": "",
"trusted-proxies": "",
"rate-limit": 120,
"api-base-url": {
"DualStack": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"IPv4": [],
"IPv6": []
}
}
```
> [!WARNING]
> 敏感凭据不得上传远端:`api-keys`/`ws-keys`(及后端 `access-token`、节点 key 等密钥)**禁止**写入远端配置——即使写了也会被强制忽略。凭据只放本地 setting.json 或环境变量。
部署时:
```bash
REMOTE_CONFIG_URL=https://example.com/mw-config.json ./middleware-go-linux-amd64
```
systemd 中同样用 `Environment="REMOTE_CONFIG_URL=..."` 配置。
**如何验证生效**:
- 启动日志出现 `remote config applied from <url>` 即拉取成功
- 用 `curl -i <远端URL>` 确认远端返回 `200` 且是合法 JSON
- 实际操作验证:在远端把 `port` 改成另一个值,重启进程,观察监听端口是否变化
**安全与排查**:
- **敏感凭据不得上传远端**:远端配置(Gist / 对象存储 / 静态托管等)**禁止包含任何密钥**——`api-keys`/`ws-keys`、`access-token`、节点 key(`node-key`)等。这些值会被强制忽略,但远端文件本身一旦泄露等于直接暴露凭据;凭据一律放本地 `setting.json` 或环境变量注入
- 远端 URL 必须能被节点访问;优先使用 **HTTPS** 地址
- 远端配置文件如果放在公开可访问的位置(公开 Gist、无鉴权对象存储),即便不含密钥,也等于把节点拓扑、节点 id 等敏感信息公之于众,建议放在私有仓库 / 带鉴权的对象存储 / 内部服务上
- 常见失败排查:
| 现象 | 原因 | 排查 |
|------|------|------|
| 日志 `WARN ... status 404` | URL 路径错误或远端文件不存在 | `curl -i <URL>` 检查返回码 |
| 日志 `WARN ... timeout` / 连接失败 | 网络不通或超时 | 确认节点可访问该 URL |
| 日志 `WARN ... invalid ... JSON` | 远端内容不是合法 JSON 或格式不符 | 检查 JSON 合法性、字段名与组件格式一致 |
| 启动日志无 remote 相关输出 | `REMOTE_CONFIG_URL` 未设置 | `env \| grep REMOTE` 确认环境变量已注入 |
+113
View File
@@ -0,0 +1,113 @@
# 前端部署
前端(`frontend-ssr/`)是全项目的核心,负责数据可视化与接口调用。基于 **Nuxt 4 SSR** 构建。
## 架构与部署目标
| 项 | 说明 |
|----|------|
| 技术栈 | Nuxt 4 + Vue 3 + Element Plus + VueUse |
| 运行时 | Node.js 22(见 `.node-version`)、包管理器 pnpm 10 |
| 构建产物 | `.output/server/index.mjs` + `.output/public/`(静态资源) |
| 构建命令 | `pnpm build`(Nitro SSR 构建) |
## 准备工作
```bash
# 1. 进入前端目录
cd frontend-ssr
# 2. 安装依赖(需要 Node 22 + pnpm 10)
pnpm install
# 3. 本地开发
pnpm dev
```
## 配置说明
前端所有配置集中在 `frontend-ssr/config/index.ts`,完整配置项见 [配置文件](/guide/config) 的「前端」一节:
| 配置项 | 说明 |
|--------|------|
| `siteUrl` | 站点对外地址 |
| `siteName` | 站点名称(页面标题 / 描述 / 页脚品牌) |
| `APIBaseURL` | 拨测上游节点池(whois / ssl / detail / dns / dnssec / tcping / speed),含 `DualStack` / `IPv4` / `IPv6` 三栈 |
| `IPLocationAPI` | IP 归属地 / ASN 上游节点池(纯数组,无栈区分) |
| `Middleware` | 外部独立中间件 base URL 列表,`/middleware/*` 请求依次尝试、失败重试下一个 |
| `EnableInternalMiddleware` | 是否启用前端内置中间件(本地转发,作为候选列表最后一位兜底),默认 `true` |
| `rateLimitPerMinute` | 内置中间件单 IP 限流次数(次/分钟),默认 `120`,`0` 表示不限流 |
| `v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` | 出站 IP 检测接口(页面直连建议使用带 CORS 的 wsmdn.top 接口) |
| `umamiHost` 等 | Umami 统计配置 |
| `ICP` / `GongAn` | 网站备案号(页脚展示) |
| `noindex` | 是否禁止搜索引擎索引 |
> 生产环境建议通过运行时的环境变量或部署平台配置覆盖敏感项,不要把密钥写进 `config/index.ts` 提交到仓库。
## 部署方案
### 方案一:EdgeOne
腾讯云 EdgeOne 原生支持 Nuxt,点击下方按钮即可通过 **EdgeOne Makers** 一键部署(无需手动配置构建):
[![使用 EdgeOne Makers 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://console.cloud.tencent.com/edgeone/makers/new?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=frontend-ssr&install-command=pnpm%20install&build-command=pnpm%20run%20build&output-directory=.output)
### 方案二:Vercel
点击下方按钮一键导入并部署到 Vercel(自动识别 Nuxt 项目,`root-directory` 已预置为 `frontend-ssr`):
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=frontend-ssr&install-command=pnpm%20install&build-command=pnpm%20run%20build&output-directory=.output)
### 方案三:Cloudflare Workers
使用 wrangler 手动部署:
```bash
cd frontend-ssr
pnpm build
pnpm deploy # 等价于 pnpm build && wrangler deploy
```
部署前需要配置 wrangler 认证(二选一):
```bash
# 方式一:环境变量(CI 中使用)
export CLOUDFLARE_API_TOKEN=your_api_token
export CLOUDFLARE_ACCOUNT_ID=your_account_id
# 方式二:wrangler login(本地交互登录)
npx wrangler login
```
> Cloudflare 部署按钮不支持子目录,而前端位于仓库 `frontend-ssr/` 子目录,因此不提供一键部署按钮;使用上方 wrangler 命令或仓库 CI/CD 工作流部署。
### 方案四:直接运行(Node 自托管)
前端是 Node.js 应用,构建产物为 JS 脚本 + 静态资源(非二进制),可直接用 Node 运行:
```bash
cd frontend-ssr
pnpm build
node .output/server/index.mjs # 默认监听 3000 端口
```
生产环境建议使用进程守护(systemd / pm2 等)保持常驻。
## CI/CD 自动部署
仓库内置工作流 `.github/workflows/frontend-ssr.yml`:
- **触发条件**:push 到 `main` 且改动路径为 `frontend-ssr/**`;或手动 `workflow_dispatch`
- **步骤**:checkout → Node 22 + pnpm 10 → `pnpm install` → `pnpm run deploy --keep-vars`
- **所需 Secrets**:`CLOUDFLARE_API_TOKEN`、`CLOUDFLARE_ACCOUNT_ID`
- **注意**:工作流设置了 `NITRO_PRESET: cloudflare_module`
## 缓存
缓存头不在应用侧设置,全部交由前置 CDN(EdgeOne)控制。
## 常见问题
- **部署后接口 403 / 跨域**:检查后端 `cors` 配置(独立中间件见 `middleware-go/setting.json` 的 `cors` 字段,逗号分隔允许域名);服务端转发请求不带 `Origin`,浏览器直接调用才受 CORS 限制。
- **出站 IP 检测接口被浏览器拦截**:页面直连的 IP 检测接口需要 CORS,详见 [配置文件](/guide/config) 的「出站 IP 检测接口」一节。
+124
View File
@@ -0,0 +1,124 @@
# 中间件部署
独立中间件(middleware-go)只负责**请求转发和 Key 注入**,不参与业务计算。前端将 `/middleware/*` 请求转发到中间件,由中间件转发到对应后端节点并注入 `Authorization: Bearer <key>`。
> [!IMPORTANT]
> 项目只提供配置示例,**使用前必须先复制为 `setting.json`**(GitHub Releases 中对应文件为 `middleware.setting.json.example`):
>
> ```bash
> # 仓库内
> cp middleware-go/setting.json.example middleware-go/setting.json
>
> # 从 Release 下载后
> cp middleware.setting.json.example setting.json
> ```
>
> 复制后按需修改 `api-base-url` / `ip-location-api` / `api-keys` / `ws-keys` / `cors` 等配置项。
## 方案一:Docker
`middleware-go/` 目录提供 `Dockerfile`(多阶段构建,产物基于 Alpine,支持多架构):
```bash
cd middleware-go
docker build -t middleware-go .
docker run -d -p 8091:8091 \
-v $(pwd)/setting.json:/home/appuser/setting.json \
middleware-go
```
> 配置挂载注意:容器工作目录是 `/home/appuser`(非 root 用户运行),`setting.json` 要挂到这个路径下。
**多架构**:Dockerfile 已适配 buildx 交叉编译,本机构建其他架构:
```bash
cd middleware-go
docker buildx build --platform linux/arm64 -t middleware-go:arm64 --load .
```
打 `v*` 版本标签发版时,CI 会自动构建 `linux/amd64` / `linux/arm64` / `linux/arm/v7` 三架构镜像(amd64 原生构建,arm64/armv7 在 GitHub 原生 ARM 实例上交叉构建,无 QEMU),推送 GHCR(`ghcr.io/<owner>/<repo>/lemon-ipw-middleware`;Docker Hub 推送在 workflow 中暂未启用,预留了注释可随时开启),Docker 按宿主机架构自动拉取对应变体。
配置通过挂载 `setting.json` 提供(`api-base-url` / `ip-location-api` / `api-keys` / `ws-keys` / `cors` / `trusted-proxies` / `rate-limit` / `remote-config-url` 等;`rate-limit` 为单 IP 每分钟限流次数,默认 120,0 表示不限流,可用环境变量 `RATE_LIMIT` 覆盖;`remote-config-url` 为远端配置地址,可用环境变量 `REMOTE_CONFIG_URL` 覆盖;**`api-keys` / `ws-keys` 为敏感凭据,不随远端配置覆盖**——`api-keys` 管 HTTP 转发鉴权,`ws-keys` 管 WS 注册校验,两者相互独立)。
## 方案二:二进制
```bash
cd middleware-go
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o middleware-go-linux-amd64 .
./middleware-go-linux-amd64 # 运行(与 setting.json 同目录)
```
**OTA 自更新**(可选,默认关闭):配置 `"ota": "true"`(或环境变量 `OTA=true`)后,中间件定期检查本仓库 Release,下载与当前平台匹配的 `middleware-go-*` 资产并替换自身重启。下载加速可配 `"gh-proxy": ""`(或 `GH_PROXY` 环境变量)。交接流程与后端节点一致:预检 → 原子替换 → 优雅停机 → 拉起新进程 → 健康检查确认;失败自动回滚 `.old` 备份。
所有配置可用环境变量覆盖(`API_BASE_URLS` / `IP_LOCATION_APIS` / `CORS` / `TRUSTED_PROXIES` / `API_KEYS` / `WS_KEYS` / `RATE_LIMIT` 等,数组/对象用 JSON 字符串),优先级:**环境变量 > setting.json > 默认值**(远端配置 `REMOTE_CONFIG_URL` 高于两者,详见 [配置文件](/guide/config) 的「远端配置」一节)。
需要守护运行时,参考 [后端节点部署](/guide/deploy-node) 的「方案五:一键安装」一节的 systemd 管理方式(`ExecStart` 指向中间件二进制,`WorkingDirectory` 指向 `middleware-go/setting.json` 所在目录)。
## WS 通道(拨测数据经 WebSocket 传输)
middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入,拨测数据上传改用 WS 通道传输(HTTP 转发保留,双通道并存)。**现有 HTTP 接口(`/v1/*` / `/middleware/*`)完全不变**。
### 架构
```
前端(HTTP) → middleware-go(8091,HTTP 转发)→ 后端节点(ws:false 时)
↘ middleware-go(8092,WS 服务端)→ 后端节点(ws:true 时,WS 客户端连入)
```
- WS 服务端跑在**独立端口**(配置 `ws-port`,默认 `8092`,路径 `/ws`;`ws-port: 0` 或环境变量 `WS_PORT=0` 关闭),与 fiber HTTP 服务完全分离;端口走统一配置管道(远端配置 > 环境变量 > setting.json)
- 节点配置加 `"ws": true`(兼容 `"ws": "true"` 字符串写法)→ 该节点的拨测请求改走 WS 通道;缺省 `false` → 原 HTTP 转发不变
- ws:true 节点未连接或拨测超时 → 返回 502,不影响其他节点
### 配置示例
```json
{
"port": "8091",
"APIBaseURL": {
"DualStack": [ { "label": "上海 腾讯云 BGP", "id": "tencent-sh", "url": "", "ws": true } ],
"IPv4": [],
"IPv6": [ { "label": "河北 秦皇岛 联通", "id": "cn-hebei-qinhuangdao", "url": "https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/", "ws": true } ]
}
}
```
启动后日志会打印 `ws channel enabled on port 8092`。
### 消息协议
所有消息为 JSON 文本帧,信封:`{ "type": "...", "nodeId": "...", "ts": <unix秒>, "data": {...} }`
| 方向 | type | data 说明 |
|------|------|-----------|
| 节点 → middleware | `register` | `{ "nodeId": "...", "key": "...", "version": "...", "capabilities": [...] }`,连接后首条消息,同 id 新连接顶掉旧连接;`key` 为注册凭证,与 setting.json `ws-keys` 配置比对 |
| 节点 → middleware | `probe_result` | `{ "requestId": "...", "status": 200, "body": <JSON 值> }`(body 为 JSON 字符串时按原文透传) |
| 节点 → middleware | `ping` / `pong` | 心跳:节点每 10s 发 `ping`(middleware 回 `pong`);`pong` 为对 middleware `ping` 的应答 |
| 节点 → middleware | `report` | 统计 + 拨测明细的周期上报(同 `/report` body)。节点会**向所有已连接中间件广播**(多活),消费者是收集中心 ipw-boce;middleware-go 只做转发与拨测代理、不在转发路径采集,收到后**静默丢弃**(不打"未知消息"日志) |
| 节点 → middleware | `config_result` / `ota_result` | 运行时配置 / OTA 指令的应答。只有对端下发过 `config` / `ota` 才会出现;middleware-go 不下发这类指令(远程运维归收集中心),正常收不到,收到同样静默丢弃 |
| middleware → 节点 | `register_ok` | `{ "heartbeatSeconds": 20 }` |
| middleware → 节点 | `register_error` | `{ "code": 401, "command": "invalid key" }`(注册凭证错误时返回并断开连接) |
| middleware → 节点 | `probe` | `{ "requestId": "...", "apiType": "tcping", "raw": "qq.com", "query": {"port":"443"} }`,拨测请求(HTTP/WS 双通道时前端请求仍走 HTTP,middleware 内部转 WS) |
| middleware → 节点 | `ping` | 心跳(每 20s),节点回 `pong` |
| middleware → 节点 | `status` | 状态/统计上报:`{ "uptimeSeconds": ..., "totalRequests": ..., "errorRequests": ... }`(每 20s) |
### 节点侧接入(WS 客户端)
1. 连接 `ws://<中间件IP>:8092/ws`(本仓库后端节点接入方式见 [后端节点部署](/guide/deploy-node) 的「WS 通道接入」一节,配置 `WS_URL` / `NODE_ID` / `NODE_KEY` 即可自动连接注册;`WS_URL` 支持逗号分隔多个中间件地址,节点会**同时连接全部(多活)**,任一断开只重连自己)
2. 首条消息发 `register { "nodeId": "<与中间件配置一致的节点 id>", "key": "<注册凭证>" }`(节点在 setting.json `ws-keys` 里配了 key 就必须传对;未配置 key 的开放节点可不传)
3. 收到 `probe` → 执行拨测 → 回 `probe_result`(必须携带原 `requestId`,支持乱序返回,同一连接可并发多个拨测)
4. 收到 `ping` → 回 `pong`;`status` 可忽略或记录
5. 断线后重连并重新 `register` 即可
### 注册 key 校验
节点注册时中间件按 `ws-keys` 配置校验 key(与 `api-keys` HTTP 转发鉴权相互独立):
- 节点在 `ws-keys` 配置了 key(非空)→ `register` 必须携带正确 key,不传或传错返回 `register_error { "code": 401, "command": "invalid key" }` 并**断开连接**,不进入数据阶段
- 节点未配置 key(`ws-keys` 无该项或为空)→ 开放注册,不传 key 即可
与 HTTP 通道的鉴权语义一致(配置了 key 就必须带凭据,留空 = 开放)。
### 说明
- 注册阶段按节点 `ws-keys` 配置校验 key(配了 key 必须传对,否则 401 断开;未配置则开放,详见上文「注册 key 校验」);拨测请求经 WS 转发时不携带 `Authorization` 头(后续由节点侧自行决定是否需要额外鉴权)
- `ws:true` 且 WS 未启用(`WS_PORT=0`)时,自动回退原 HTTP 转发
+231
View File
@@ -0,0 +1,231 @@
# 后端节点部署
后端节点提供全部检测 API(IP 归属地 / ASN / SSL / DNS / DNSSEC / Whois / TCPing / 测速 / 截图等),可在任意地区部署,组成去中心化测试网络。
## 方案一:EdgeOne
腾讯云 EdgeOne 支持 Go 边缘函数,点击下方按钮通过 **EdgeOne Makers** 一键部署 `serverless/edgeone/` 版本(无需本地构建):
[![使用 EdgeOne Makers 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://console.cloud.tencent.com/edgeone/makers/new?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=serverless%2Fedgeone)
也可以使用 EdgeOne CLI 手动部署:
```bash
cd serverless/edgeone
npx edgeone pages deploy -n ipw-cn -t $EDGEONE_API_TOKEN
```
`serverless/edgeone/` 版本的配置来源为 环境变量 + 远端配置(`REMOTE_CONFIG_URL`)。
### 数据上报(可选)
`serverless/edgeone/` 版本**没有 WS 客户端**(边缘函数不维持长连接),因此上报只走 HTTP 一条路:按周期把统计与拨测明细 `POST <report-url>/report` 到收集中心。下列键同样支持环境变量与远端配置两种来源(远端配置优先):
| 配置键 | 环境变量 | 说明 |
| --- | --- | --- |
| `node-id` | `NODE_ID` | 上报身份(写入 `probe_results.origin`);留空回退 hostname |
| `report-url` | `REPORT_URL` | 收集中心基址;留空则不上报 |
| `report-token` | `REPORT_TOKEN` | `/report` 鉴权,请求头 `Authorization: Bearer <token>`;收集中心未配 token 时可留空 |
| `report-interval-seconds` | `REPORT_INTERVAL_SECONDS` | 上报间隔秒,缺省 15 |
记账口径与主节点完全一致(节点是唯一记录者、`stats` 为增量累加、上报 at-most-once),报文格式见 [节点 API 与协议](/guide/node-api) 的「数据上报协议」一节。
> [!NOTE]
> 上报依赖进程存活期内的内存计数。若所用平台会把实例缩容到零,缓冲区会随实例回收丢失——上报只在实例存活期内累积。长驻部署(Docker / 二进制)不受此限。
## 方案二:Vercel
点击下方按钮一键导入仓库(部署目标为 `serverless/edgeone/cloud-functions` 目录,即 Go 边缘函数版本):
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=serverless%2Fedgeone%2Fcloud-functions)
> [!IMPORTANT]
> **Serverless 节点均为 IPv4-only**(EdgeOne、Vercel 等平台的出站网络只提供 IPv4)。部署后需要:
>
> 1. 给节点配置 `SINGLE_STACK=ipv4`(声明节点为 IPv4 单栈),跳过 IPv6 相关测试,避免无意义的错误日志与超时
> 2. 在前端配置 `config/index.ts` 中,**该节点只能填入 `APIBaseURL.IPv4`**(拨测节点池的 IPv4 栈),不要放进 `DualStack` / `IPv6` 栈
## 方案三:Docker
`src/` 目录提供 `Dockerfile`(多阶段构建,产物基于 Alpine,支持多架构):
```bash
cd src
docker build -t lemon-ipw .
docker run -d --restart unless-stopped -p 8080:8080 \
-v $(pwd)/setting.json:/home/appuser/setting.json \
lemon-ipw
```
> `--restart unless-stopped`:主进程意外退出时 Docker 自动拉起容器;手动 `docker stop` 不会触发重启。
配置通过挂载 `setting.json` 提供(注意挂载到容器工作目录 `/home/appuser`),也可用环境变量覆盖(`PORTS` / `CORS` / `TRUSTED_PROXIES` / `ACCESS_TOKEN` 等)。
> [!TIP]
> 容器内 OTA 替换二进制意义有限(重建容器即回滚到镜像版本),如需彻底关闭可设 `NODE_OTA=false`;镜像**不含 IP 库数据**,首次启动若 `ipdb` 未关闭会现场拉取约 450MB 到容器可写层,轻量部署建议直接 `IPDB=false`。
**多架构**:Dockerfile 已适配 buildx——构建阶段固定在构建机原生平台交叉编译(`TARGETOS`/`TARGETARCH`),目标架构无需 QEMU 模拟。本机为其他架构构建:
```bash
cd src
docker buildx build --platform linux/arm64 -t lemon-ipw:arm64 --load .
```
打 `v*` 版本标签发版时,CI(`.github/workflows/docker.yml`)会自动构建 `linux/amd64` / `linux/arm64` / `linux/arm/v7` 三架构镜像(amd64 在 x64 实例上原生构建,arm64/armv7 在 GitHub 原生 ARM 实例 `ubuntu-24.04-arm` 上交叉构建,全程无 QEMU),推送 GHCR 与 Docker Hub:
- GHCR:`ghcr.io/<owner>/<repo>/lemon-ipw`(自动)
- Docker Hub:`docker.io/<namespace>/lemon-ipw`(**workflow 中暂未启用**——`.github/workflows/docker.yml` 已预留注释,取消注释并配置 `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` Secrets 即可开启)
均带 `版本` 与 `major.minor` 标签。部署直接:
```bash
docker run -d -p 8080:8080 \
-v $(pwd)/setting.json:/home/appuser/setting.json \
ghcr.io/nomdn/ipw-cn/lemon-ipw:v1.2.3
```
Docker 会按宿主机架构自动拉取 manifest 中对应的镜像变体。
## 方案四:二进制
```bash
# 编译(Go 后端在 src/ 目录)
cd src && go build -o lemonipw .
# 运行(与 setting.json 同目录)
./lemonipw
```
首次启动自动下载 IP 数据库(约 450MB),之后每 24 小时自动更新一次。需要守护运行时,可参考下方「方案五」生成的 systemd 服务,或手动创建 service 文件(`ExecStart` 指向二进制,`WorkingDirectory` 指向 `setting.json` 所在目录)。
## 方案五:一键安装(install.sh)
仓库根目录提供 `install.sh` 一键安装脚本:自动检测架构 → 下载最新 release 二进制 → **交互式输入配置**(无需准备 setting.json,配置以环境变量注入)→ 生成 systemd 服务并守护进程。
```bash
# 先下载脚本到本地(勿用 curl | bash 管道执行,避免下载中断导致语法解析错误)
git clone https://github.com/nomdn/ipw-cn && cd ipw-cn # 或
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o install.sh
sudo bash install.sh
```
**交互式配置项**(直接回车使用默认值):
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| 安装目录 | `/opt/lemon-ipw` | 二进制与工作目录 |
| 监听端口 | `8080` | `PORTS` |
| 单栈模式 | 双栈 | `SINGLE_STACK`,`ipv4` / `ipv6` / 留空 |
| access-token | 留空 | 留空 = 不启用鉴权 |
| DNS 服务器 | 留空 | 主从逗号分隔(`119.28.28.28:53,223.5.5.5:53`);留空 = 启动时自动探测系统 DNS |
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server;都留空 = 自动探测系统 DNS |
| IP 数据库 | `Y` | `IPDB`,首次启动下载约 450MB |
| CORS | 留空 | 逗号分隔允许来源 |
| 远端配置地址 | 留空 | `REMOTE_CONFIG_URL` |
| WS 通道接入 | `N` | 选 `y` 后继续输入 WS_URL / NODE_ID / NODE_KEY |
| 其他环境变量 | 无 | 每行一个 `K=V`,空行结束。如需可信代理在此输入 `TRUSTED_PROXIES=<IP/CIDR 逗号分隔>`(配置后归属地接口只信任这些代理转发的 X-Forwarded-For,防伪造) |
**WS 接入特殊规则**:
- `WS_URL` 留空时自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址时须为**完整路径**(含 `wss://` 前缀与 `/ws` 路径,如 `wss://host:8092/ws`),逗号分隔多个则**同时连接全部(多活)**
- `NODE_ID` **强制自动生成 UUID**(无需输入,如 `4580ea9d-2a16-4b8e-8090-5f2e7f0a2229`),适合作为节点唯一标识
- `NODE_KEY` **必填**:不加 key 禁止启用 WS(中间件 `ws-keys` 必须包含该节点,否则注册被拒 401);安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 setting.json 的 `ws-keys`
**安装完成后自动生成并启动 systemd 服务**(`lemon-ipw.service`),配置以 `Environment="K=V"` 注入,无需 setting.json。常用管理命令:
```bash
systemctl status lemon-ipw # 状态
journalctl -u lemon-ipw -f # 日志
sudo systemctl restart lemon-ipw # 重启(改配置后)
```
修改配置:`sudo systemctl edit --full lemon-ipw` 编辑环境变量,保存后 `sudo systemctl daemon-reload && sudo systemctl restart lemon-ipw` 生效。
> [!WARNING]
> **版本兼容提醒**:v1.1.0 及更早的 release 二进制读的是旧环境变量名 `IPW_WS_URL` / `IPW_NODE_ID` / `IPW_NODE_KEY`(8-20 及之前构建)。install.sh 生成的 service 用的是新名 `WS_URL` / `NODE_ID` / `NODE_KEY`——**旧二进制 + 新 service 会因读不到配置而完全不启用 WS**(启动日志无任何 ws 输出)。升级到包含改名后的新 release 即可;急用可临时把 service 环境变量改回 `IPW_` 前缀。
## 配置
后端配置见 [配置文件](/guide/config):`setting.json` 或环境变量,支持远端配置(`REMOTE_CONFIG_URL`,优先级:远端 > 环境变量 > setting.json)。
## WS 通道接入(可选)
后端节点可作为 WS 客户端接入 [独立中间件](/guide/deploy-middleware) 的 WS 通道,拨测请求经 WebSocket 转发,节点本地执行探针后回传。不配置则完全走原 HTTP 接口。
**配置三个环境变量**(或 setting.json 的 `ws-url` / `node-id` / `node-key`,env 优先;也支持远端配置覆盖,除非列入 `remote-ignore-config`):
```bash
WS_URL=ws://<中间件IP>:8092/ws \
NODE_ID=<节点id,与中间件 api-base-url / ip-location-api 池的节点 id 一致> \
NODE_KEY=<注册key,与中间件 ws-keys[节点id] 一致;节点未配置 key 可留空> \
./lemonipw
```
- `WS_URL` 支持**逗号分隔多个中间件**(`ws://中间件1:8092/ws,ws://中间件2:8092/ws`),**同时连接全部(多活)**:每个地址独立注册并保持连接,任一断开只重连自己,不影响其他中间件;注意多个地址指向同一中间件实例时,同 `nodeId` 后注册的连接会顶掉先前的
- **双向心跳**:节点每 10s 发 ping,中间件回 pong;心跳发送连续失败 3 次判定中间件不可用,断开后 3s 重试(注册被拒 30s 重试)
- 收到 `probe` 后节点直调探针函数(带缓存)并回 `probe_result`,结果与 HTTP 通道一致
- 节点注册带 `NODE_KEY`:中间件 `ws-keys` 里配了 key 就必须传对,否则返回 401 并断开;未配置 key 的节点开放注册
**注意**:`NODE_ID` 必须与中间件 `APIBaseURL` / `IPLocationAPI` 池中的节点 `id` 一致,且该节点需配置 `"ws": true` 才会走 WS 通道。未连接中间件时,`ws:true` 节点的拨测会返回 502。
## 运行时配置管理(可选)
节点暴露 `/v1/config`,供收集中心读写该节点**当前生效**的配置(内存值),是「控制台改配置不用登机器」的基础:
| 动作 | HTTP | WS | 说明 |
|------|------|----|------|
| 读取 | `GET /v1/config` | `config` + `action=get` | 返回生效配置快照 + `secretKeys` + `restartRequiredKeys` |
| 修改 | `PATCH /v1/config`(`?persist=1` 写回本地文件) | `config` + `action=patch` | 只应用传入的键,其余不动 |
| 刷新远端 | `POST /v1/config/refresh` | `config` + `action=refresh` | 重新拉 `remote-config-url` 并应用 |
三条重要约定:
- **凭据类键不回显明文**:`access-token` / `node-key` / `report-token` 在快照中为 `***`。调用方下发配置时必须剔除这些键,否则会把真值覆盖成三个星号。收集中心控制台已自动剔除。
- **节点不自行重启**:`port` / `cors` / `ipdb` / `report-interval-seconds` / `trusted-proxies` / `node-id` / `node-key` / `access-token` 这些启动期固定的键,改动后在应答的 `restartRequired` 中如实列出,但**不会自动重启进程**——重启会中断在途服务,时机由运维 / 编排层掌握。`ws-url` 是例外:热生效,控制器按新地址多退少补。
- **只 PATCH 内存的改动会被重启顶掉**:节点侧优先级是 远端 > 环境变量 > `setting.json`,长期生效需写回托管配置或本地文件,详见 [配置文件](/guide/config) 的「运行时配置管理」一节。
> [!NOTE]
> **HTTP 管理面需要凭据**:节点未配置 `access-token` 时,`/v1/config` 与 `/v1/ota` 整组返回 `403`(此时业务接口也无鉴权,开放配置读写等于公网可改配置)。**WS 通道不受此限制**——节点是 WS 客户端,指令只来自已通过中间件 `ws-keys` 校验的连接。
## OTA 升级(收集中心下发,可选)
节点支持由收集中心下发**一次性升级任务**:下载新二进制 → 校验 → 预检 → 原子替换 → 重启,全程无需登录节点主机。
**触发方式**:收集中心经 WS `ota` 消息下发(HTTP 回退 `POST /v1/ota`)。请求体三选一指定下载源:
| 字段 | 说明 |
|------|------|
| `version` | 按版本号下发。节点按自身平台拼资产名 `lemonipw-{goos}-{goarch}[.exe]`,到资产基址 `{assetBase}/{tag}/` 下取;`assetBase` 缺省为 GitHub Releases,自建镜像源可覆盖 |
| `url` | 直发下载地址(完全自定义) |
| `sha256` | 可选,提供即强校验(hex64,兼容 `sha256:` 前缀) |
**执行流程**:下载到与目标同分区的临时文件(保证 `rename` 原子)→ 体积下限 1MB(防错误页)→ sha256 校验 → **预检**(试运行新二进制 `-v`,确认架构匹配、可执行,在停机前拦下损坏文件)→ 当前二进制改名 `.old` → 新文件就位 → 重启。任一步失败都会回滚 `.old`。
**进度回传**:经 WS `ota_result` 依次回报 `accepted` → `downloading` → `verifying` → `installing` → `restarting`,失败时带 `error`。重启后连接必然断开,**最终结果由收集中心以重连注册上报的新版本号判定**(15 分钟未观测到新版本即判超时失败)。
**并发与开关**:
- 同一时刻只允许一个 OTA 任务,重复下发直接拒绝。
- `node-ota: false`(env `NODE_OTA`)可完全禁用:节点拒绝一切 OTA 指令并回传原因。**只读文件系统 / 编排托管**(Docker Swarm、K8s 等自行管理镜像)的部署建议关闭。
- 部署方式受限导致替换失败(如容器只读层)时,升级会失败并回滚,此时仍按传统方式人工升级。
手动更新(OTA 不可用时):
```bash
# Docker:拉取新镜像后重建容器
docker pull lemon-ipw && docker stop lemon-ipw && docker run ... lemon-ipw
# systemd:替换二进制后重启服务
systemctl restart lemon-ipw
```
## 版本与能力上报
节点无论是否接入 WS,都会上报自身版本与支持的管理能力:
- **版本号**来自构建时注入的 `VERSION`,随 WS `register` 报文上报(字段 `version`);纯 HTTP 主节点(无 WS 连接)由收集中心探活后再请求 `GET /info` 取回(`GET /` 只返回 `{"status":"ok"}`)。边缘函数版本不提供 `/info`。
- **能力清单**(`capabilities`)取值为 `probe` / `report` / `config` / `ota`,用于让收集中心在下发管理指令前判断该节点能否理解——老版本节点的消息循环没有对应分支,收到管理消息会**静默忽略**,只能靠超时暴露。
- 收集中心「节点状态」页展示每个节点的版本号,并据此判断哪些节点需要升级;节点升级部署后重新注册即为新版本。
能力清单的取值与三态判定规则见 [节点 API 与协议参考](/guide/node-api) 的「能力清单」一节。
+76
View File
@@ -0,0 +1,76 @@
# 快速开始
> 本章节将指导您以最低成本部署一个功能完整的测试站
## 尝试一下
我们的开发团队已经部署了一个功能完整的预览站:
[预览站点](https://ipw.wsmdn.top/)
## 前置准备
- 一台 Linux 服务器(需 root 权限 + systemd)
- 服务器上可访问 GitHub(下载 release 二进制)
- 无需本地编译环境,无需准备配置文件
## 开始部署
### 交互式一键部署(推荐)
```bash
# 获取安装脚本(任选其一)
git clone https://github.com/nomdn/ipw-cn && cd ipw-cn # 或
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o install.sh
# 执行安装(root 权限)
sudo bash install.sh
```
> [!WARNING]
> 请勿使用 `curl ... | sudo bash` 管道方式执行:脚本经管道边下载边执行,网络慢/抖动时 bash 会读到不完整的脚本内容,报 `syntax error near unexpected token` 类错误(脚本本身无语法问题)。务必**先下载完整文件再执行**。
脚本会自动完成:检测架构 → 下载最新 release 二进制 → **交互式输入配置** → 生成并启动 systemd 守护进程。
**交互式配置项**(直接回车使用默认值):
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| 安装目录 | `/opt/lemon-ipw` | 二进制与工作目录 |
| 监听端口 | `8080` | 后端 HTTP 端口 |
| 单栈模式 | 双栈 | `SINGLE_STACK`,`ipv4` / `ipv6` / 留空 |
| access-token | 留空 | 留空 = 不启用鉴权 |
| DNS 服务器 | 留空 | 主从逗号分隔(如 `119.28.28.28:53,223.5.5.5:53`);留空 = 启动时自动探测系统 DNS |
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server;都留空 = 自动探测系统 DNS |
| IP 数据库 | `Y` | 首次启动下载约 450MB |
| CORS | 留空 | 逗号分隔允许来源 |
| 远端配置地址 | 留空 | `REMOTE_CONFIG_URL` |
| WS 通道接入 | `N` | 选 `y` 后输入 WS_URL / NODE_ID / NODE_KEY |
| 其他环境变量 | 无 | 每行一个 `K=V`,空行结束。如需可信代理在此输入 `TRUSTED_PROXIES=<IP/CIDR 逗号分隔>`(配置后归属地接口只信任这些代理转发的 X-Forwarded-For,防伪造) |
**WS 通道接入规则**(选 `y` 时):
- `WS_URL` 留空自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址须为完整路径(`wss://host:8092/ws` 形式);逗号分隔多个则**同时连接全部(多活)**
- `NODE_ID` **强制自动生成 UUID**(无需输入)
- `NODE_KEY` **必填**:不加 key 禁止启用 WS;安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 `ws-keys`
### 验证安装
```bash
systemctl status lemon-ipw # 服务状态(active running)
curl http://127.0.0.1:8080/ # 应返回 {"status":"ok"}
journalctl -u lemon-ipw -f # 实时日志
```
### 修改配置
```bash
sudo systemctl edit --full lemon-ipw # 编辑环境变量
sudo systemctl daemon-reload && sudo systemctl restart lemon-ipw
```
## 下一步
- 部署**独立中间件**(WS 通道 / 多节点转发):见 [中间件部署](/guide/deploy-middleware)
- 部署**前端**(Cloudflare Workers / EdgeOne Makers 等):见 [前端部署](/guide/deploy-frontend)
- 完整**配置说明**(后端 / 前端 / 中间件 / 远端配置):见 [配置文件](/guide/config)
- 手动**编译部署**(源码构建三个组件):见 [后端节点部署](/guide/deploy-node) / [中间件部署](/guide/deploy-middleware) / [前端部署](/guide/deploy-frontend)
+27
View File
@@ -0,0 +1,27 @@
# 部署指南
### 本章节将一步步指导您自部署柠檬味IPW
Lemon IPW 采用前后端分离架构,**后端节点、独立中间件、前端**三个组件可各自独立部署,并按需组合:
| 组件 | 职责 | 部署方案 |
|------|------|----------|
| [后端节点](/guide/deploy-node) | 提供全部检测 API | EdgeOne / Vercel / Docker / 二进制 |
| [独立中间件](/guide/deploy-middleware) | 请求转发 + Key 注入 | Docker / 二进制 |
| [前端](/guide/deploy-frontend) | 数据可视化与接口调用 | EdgeOne / Vercel / Cloudflare Workers / 直接运行 |
组件之间通过 `/v1/*`(直连后端)与 `/middleware/*`(经中间件转发)两类接口通信,前端按节点列表轮询、失败自动重试下一个节点。
## 快速开始
想用最低成本部署一套功能完整的测试站,直接看 [快速入门](/guide/getting-started):交互式一键部署(install.sh)→ systemd 守护。
## CI/CD
仓库内置 GitHub Actions 流水线,push 到 `main` 时按改动路径自动部署:
| 工作流 | 部署目标 |
|--------|----------|
| `frontend-ssr.yml` | SSR 前端 → Cloudflare Workers |
| `edgeone-backend.yml` | `serverless/edgeone/` → EdgeOne Pages |
| `build_and_release.yml` | 后端多平台构建与发布(Release 附带配置文件) |
+273
View File
@@ -0,0 +1,273 @@
# 节点 API 与协议参考
后端节点(`src/`)对外暴露的全部接口与协议。配置项说明见 [配置文件](/guide/config),部署方式见 [后端节点部署](/guide/deploy-node)。
> 本文描述 `src/` 主节点。边缘函数版本(`serverless/edgeone/`)是**精简形态**:只有 `/v1/*` 探测端点与 `GET /`(仅返回 `{"status":"ok"}`,不回报版本与能力),没有 WS 通道、管理接口与 OTA;但同样支持 HTTP 数据上报,见下文「数据上报协议」。
## 鉴权总则
| 通道 | 鉴权方式 | 未配置凭据时 |
|------|----------|--------------|
| 业务探测 `/v1/*` | `access-token` 非空时校验 `Authorization: Bearer <access-token>`,不符返回 `401` | 不启用鉴权,公开可访问 |
| 健康检查 `/`、节点信息 `/info` | 无 | — |
| 管理接口 `/v1/config`、`/v1/ota`(HTTP) | 同业务接口,复用 `tokenCheck` | **整组关闭**,一律返回 `403`(节点未配 `access-token` 时业务接口本身也无鉴权,暴露配置读写等于公网可改配置) |
| WS 通道 | 中间件 `ws-keys` 校验注册报文 | WS **常开**:节点是客户端,指令只来自已通过中间件校验的连接,与节点是否配 `access-token` 无关 |
> 结论:**HTTP 管理面是"有凭据才开"的**;只想用 WS 通道管理节点时,节点无需配置 `access-token`。
---
## 一、健康检查与节点信息
```
GET / → {"status":"ok"}
GET /info → {"version":"v1.2.3","capabilities":["probe","report","config","ota"]}
```
两个端点都无鉴权、不计入统计,且都注册在 `/v1` 组之外——因此既不受 `access-token` 约束,也不经过业务统计中间件。
- **健康检查 `/`**:只回答"进程是否活着"。给负载均衡、OTA 就绪探测(`otaWaitChildReady`)与运维 `curl` 用。**不回报版本**:它是免鉴权的对外端点,回版本号等于向公网公开节点版本。
- **节点信息 `/info`**:回报节点身份。`version` 为版本号,`capabilities` 为能力清单(与 WS `register` 报文同源,见 `ws.go nodeCapabilities`)。收集中心对纯 HTTP 节点(无 WS 连接)探活后单独取回,用于节点状态页展示版本、以及下发管理指令前判断该节点能否理解对应指令。
> 老版本节点没有 `/info`(返回 `404`),边缘函数版节点同样不提供;收集中心取不到就留空,不影响判活。
---
## 二、业务探测接口
全部挂在 `/v1` 组下,共用 CORS + 可选 `tokenCheck` + 统计中间件(`nodeReportMiddleware`,见下文「数据上报」)。
| 方法 | 路径 | 参数 | 说明 |
|------|------|------|------|
| 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)、`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 注册信息 |
| GET | `/v1/location/:ip` | IP | 多源 IP 归属地聚合(见 [功能总览](/info/features)) |
| GET | `/v1/location` | 无(取请求方 IP) | 查询访问者自身归属地;配置 `trusted-proxies` 后只信任这些代理转发的 `X-Forwarded-For` |
| GET | `/v1/asn/:ip` | IP | ASN 查询(三套 ASN 库 + ASN Whois 解析) |
**条件注册**:`/v1/location/*`、`/v1/asn/:ip` 仅在 `ipdb` 未关闭时注册——IP 库未加载时这些接口无意义。
**单栈模式**:`single-stack` 配为 `ipv4` / `ipv6` 时只跑对应栈,另一栈直接返回 `Skipped due to SINGLE_STACK=...`,不发起无意义的探测与超时。
**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` 进入拨号之前,口径与上述一致。
- **带覆盖时按独立缓存键缓存**:规范化后的参数会拼进缓存键(见「七、缓存与并发」),不同服务器/不同映射的结果各占一条,既不串味、也不牺牲命中率。
---
## 三、管理接口
### 3.1 运行时配置 `/v1/config`
读写节点**当前生效**的配置(内存值),与收集中心的「托管配置」是两回事——后者是节点启动时拉的远端配置,见 [配置文件](/guide/config) 的「运行时配置管理」一节。
| 方法 | 路径 | 作用 |
|------|------|------|
| GET | `/v1/config` | 返回当前生效配置快照 + `secretKeys` + `restartRequiredKeys` + `remoteProtectedKeys` |
| PATCH | `/v1/config` | 按下发 map 逐键应用;`?persist=1` 时同时写回本地 `setting.json` |
| POST | `/v1/config/refresh` | 重新拉取 `remote-config-url` 并应用(远端是持久来源,不写回本地文件) |
行为要点:
- **凭据遮蔽**:`access-token` / `node-key` / `report-token` 在 GET 快照里回显 `***`,不泄露明文;下发时原样提交会把真值覆盖成三个星号,因此调用方必须**剔除这些键**(收集中心控制台已自动剔除)。
- **`access-token` / `report-token` 不随远端配置覆盖**:这两项硬编码在保护名单 `configRemoteProtectedKeys` 里,`remote-config-url`(含收集中心托管配置)下发时会被跳过,并在 `refresh` 应答的 `protectedIgnored` 与节点日志里列出被跳过的键。**只约束远端下发**,本地 `PATCH` 照常可改。理由与清单见 [配置文件](/guide/config) 的「远端配置」一节。
- **未持久化的改动会被重启顶掉**:节点侧优先级是 远端 > 环境变量 > `setting.json`,只 PATCH 内存时重启即回到旧值。需要长期生效应 `refresh`(改远端配置)或把改动合并进托管配置——受保护凭据例外,只能落到本地 ENV / `setting.json`。
- **`restartRequired` 只如实回报**:`port` / `cors` / `ipdb` / `report-interval-seconds` / `trusted-proxies` / `node-id` / `node-key` / `access-token` 这些启动期固定的键,改动后节点**不会自行重启**,只在应答里列出键名,重启时机交给运维。
- **`ws-url` 例外,热生效**:WS 客户端控制器按新地址"多退少补",无需重启。 `node-ota` 同样即时生效:该开关在每次下发 OTA 指令时才读取,不属需重启类键。
### 3.2 OTA 升级 `/v1/ota`
收集中心 HTTP 回退通道(WS 在线时优先走 WS `ota` 消息)。
```
POST /v1/ota
{
"requestId": "可选,回执关联用",
"version": "v1.4.0", // 二选一:按版本号下发
"url": "https://.../lemonipw-linux-amd64", // 二选一:直发下载地址
"assetBase": "https://.../releases/download", // 可选,配合 version 使用
"sha256": "可选,hex64(兼容 sha256: 前缀),提供则强校验"
}
→ 202 {"ok":true,"started":true}
```
响应仅为**受理回执**(下载可能数分钟,HTTP 无进度通道)。执行阶段见 [后端节点部署](/guide/deploy-node) 的「OTA 升级」一节。
---
## 四、WebSocket 协议
节点作为 **WS 客户端**连接收集中心(或独立中间件)的 `/ws` 端点:`ws(s)://<中间件>:<ws-port>/ws`。`ws-url` 支持逗号分隔多个地址,**同时连接全部(多活)**,由 per-URL 连接控制器统一收敛。
### 4.1 消息信封
```json
{ "type": "...", "nodeId": "...", "ts": 1710000000, "data": { } }
```
### 4.2 消息类型
**节点 → 中间件**
| type | data 字段 | 作用 |
|------|-----------|------|
| `register` | `nodeId` / `key`(配了 ws-keys 时必填)/ `version` / `capabilities[]` | 注册接入,建立身份 |
| `ping` | — | 心跳,每 10s |
| `pong` | — | 应答中间件 ping |
| `probe_result` | `requestId` / `status` / `body` | 拨测结果回传 |
| `report` | 统计 + 拨测明细(同 `/report` body) | 周期数据上报 |
| `config_result` | `requestId` / `action` / `ok` / `error` / 配置快照 / `applied` / `unknown` / `restartRequired` / `persisted` | 配置指令应答 |
| `ota_result` | `requestId` / `ok` / `stage` / `error` | OTA 进度与失败原因 |
**中间件 → 节点**
| type | data 字段 | 作用 |
|------|-----------|------|
| `register_ok` | `heartbeatSeconds` | 注册通过,告知心跳周期 |
| `register_error` | `code` / `command` | 注册被拒(如 key 不匹配),30s 后重试 |
| `probe` | `requestId` / `apiType` / `raw` / `query` / `scheduler` | 下发拨测 |
| `config` | `requestId` / `action` / `config` / `persist` | 下发配置指令 |
| `ota` | `requestId` / `url` 或 `version`+`assetBase` / `sha256` | 下发升级任务 |
| `ping` | — | 中间件心跳(节点回 `pong`) |
| `status` | 统计字段 | 中间件状态,节点仅记录日志 |
### 4.3 心跳与重连
- 节点每 **10s** 发 `ping`;发送连续失败 **3 次**判定通道不可用,主动断开并在 **3s** 后重连。
- 注册被拒(key 错误等)时 **30s** 后重试。
- 中间件侧每 20s 发 `ping` + `status`,空闲 **75s** 未收到任何报文即剔除该节点并记离线事件。
### 4.4 OTA 进度阶段
节点经 `ota_result` 依次回报:`accepted` → `downloading` → `verifying` → `installing` → `restarting`;任一阶段失败即终止并带 `error`。重启后连接必然断开,"最终结果"由收集中心以**重连注册上报的新版本号**判定。
---
## 五、数据上报协议
统计与拨测明细**由节点自己上报**,收集中心不在转发路径上采集——节点是唯一记录者,不存在双算。
| 通道 | 条件 | 形式 |
|------|------|------|
| WS `report` 消息 | 已连接中间件 | 向所有在线中间件广播 |
| HTTP `POST <report-url>/report` | WS 未启用或全部掉线 | 携带 `Authorization: Bearer <report-token>` |
```json
{
"instance": "cn-jiangsu",
"stats": [
{ "nodeId": "cn-jiangsu", "apiType": "tcping",
"total": 1, "errors": 0, "latencySumMs": 12, "latencyMaxMs": 12, "minute": 29821676 }
],
"probes": [
{ "nodeId": "cn-jiangsu", "apiType": "tcping", "raw": "qq.com",
"status": 200, "latencyMs": 33, "body": { } }
]
}
```
- 上报周期由 `report-interval-seconds` 控制(缺省 15s)。
- `stats` 是**增量**,收集器按 `(分钟 × 节点 × apiType)` 累加;上报为 at-most-once(失败丢弃并记日志,不重试——重试会双算)。
- 单次报文上限 500 条 stats / 500 条 probes / 4MB;超出或队列(缓冲 2048)打满时丢弃并计数。
- **只上报第一方观测**:节点自己处理的请求才记账,不转播从别处收到的数据。
- `minute` 是 unix 分钟桶(按节点时钟);上报方传 `0` 表示"由收集器按自身时钟入桶",收集端另有"不接受未来 1 小时以外"的漂移容忍。
- **部署形态差异**:`serverless/edgeone/` 版本不维持长连接(无 WS 客户端),只走 HTTP `POST /report` 一条通道,配置方式见 [后端节点部署](/guide/deploy-node) 的 EdgeOne 一节。
---
## 六、能力清单(capabilities)
节点在 `register` 报文与主节点的 `GET /info` 中上报自身支持的管理能力,取值为 `probe` / `report` / `config` / `ota` 的数组。边缘函数版本不提供 `/info`,也不回报能力清单。
收集中心据此做**三态判定**,绝不能把"未知"当作"不支持":
| 上报情况 | 收集中心行为 |
|----------|--------------|
| 清单含该能力 | 正常下发对应指令 |
| 清单明确不含 | 该能力确实没有:立刻拒绝,不发起指令、不等超时 |
| 清单为空(未上报) | 老版本节点,能力**未知**:仍尝试下发;超时后主动探活,区分"节点活着但静默忽略(程序过旧)"与"连接已僵死" |
新增一类管理指令时应在此追加能力标识,并保持取值稳定(收集中心按字符串比对)。
---
## 七、缓存与并发
业务探测结果在节点侧缓存,避免同 key 洪打把上游打崩:
| 缓存 | 键(末尾拼请求级解析片段) | 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 小时(不分成败) |
- **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)。修法是给合并键加探测类型前缀,暂未处理。
+361
View File
@@ -0,0 +1,361 @@
# 公开 API(/api/v1)
收集中心(`ipw-boce` 中间件)对外提供的**程序化接口**,资源按 REST 规范组织:名词复数、裸资源 + HTTP 状态码、无信封。
- **面向任何持有凭据的调用方**:控制台用户生成一枚个人 API Token 即可用,适合脚本、巡检、看板、CI 取数。
- 与内部 `/admin/*` 共用**同一套鉴权与可见性**,只是对外形态更规范。
- 拨测**节点**自身的接口不在这里,见 [节点 API 与协议](/guide/node-api)。
> 下文 `<collector>` 指收集中心实例的 origin,例如 `https://collector.example.com`。
## 端点一览
| 方法 | 路径 | 作用 |
|------|------|------|
| GET | `/api/v1/tasks` | 任务列表(分页 + 标签过滤) |
| GET | `/api/v1/tasks/:id` | 单任务详情 |
| GET | `/api/v1/tasks/:id/sla` | 任务 SLA 聚合(顶层汇总 + 按节点明细) |
| GET | `/api/v1/probes` | 拨测明细(分页 + 过滤) |
| POST | `/api/v1/probes` | 一键拨测(同步聚合;本层唯一的写操作) |
| GET | `/api/v1/nodes` | 节点池简表(脱敏) |
| GET | `/api/v1/usage` | 用量聚合 |
---
## 快速开始
```bash
# 1) 取一枚个人 API Token(控制台「个人资料 → 个人 API Token」同效)
curl -sX POST "https://<collector>/admin/me/token" -H "Authorization: Bearer <你的 JWT>"
# → {"token":"ipt_9f3c...e1a7","hint":"e1a7","createdAt":"2026-09-13T07:00:00Z"}
# 2) 调用公开 API
curl -s -H "Authorization: Bearer ipt_9f3c...e1a7" \
"https://<collector>/api/v1/tasks?page=1&pageSize=20"
```
- Token **明文只在生成时返回一次**(`token` 字段);库里只存 bcrypt 哈希,`hint` 是末 4 位供列表辨识。
- 再次 `POST /admin/me/token` 会**立即作废旧 Token**;`DELETE /admin/me/token` 吊销。
- 静态 `admin-token` 没有账号,不能生成个人 Token(返回 400)。
---
## 鉴权
三轨,与 `/admin/*` 完全一致:
| 凭据 | 特征 | 身份 | 适用场景 |
|------|------|------|----------|
| 个人 API Token | `ipt_` 前缀 | 其账号(角色随账号) | 程序化访问的推荐方式,可随时吊销 |
| JWT | 无前缀 | 其账号(角色随账号) | `POST /admin/login` 签发,有过期时间 |
| 静态 `admin-token` | 无前缀 | 恒为 admin(`uid=0`) | 向后兼容;无归属,脚本慎用 |
请求头固定 `Authorization: Bearer <凭据>`。失败一律 `401`:
| message | 场景 |
|---------|------|
| `missing bearer token` | 未携带 `Authorization: Bearer ` 前缀 |
| `invalid or revoked token` | `ipt_` 开头但库中查不到(未生成 / 已吊销 / 被轮换) |
| `invalid credentials` | 其它凭据无效 |
> **API Token 等价于其账号登录**:权限随账号角色。普通用户只看得见自己的资源。
> 若收集中心既没配 `admin-token` 也没启用 JWT,则管理面**不鉴权**(仅限内网/联调部署,勿用于公网)。
---
## 通用约定
### 错误体
非 2xx 统一为:
```json
{ "error": { "code": "not_found", "message": "task not found" } }
```
| code | HTTP | 含义 |
|------|------|------|
| `unauthorized` | 401 | 缺凭据 / 凭据无效或已吊销 |
| `forbidden` | 403 | 凭据有效但无权访问该资源(如别人的任务) |
| `not_found` | 404 | 资源不存在(任务、分享令牌) |
| `bad_request` | 400 | 参数非法 |
| `rate_limited` | 429 | 请求过于频繁(仅免登录的公开状态接口) |
| `internal` | 500 | 服务端错误 |
### 分页
两套参数,**互不通用**:
| 端点 | 参数 | 缺省 | 边界处理 | 返回字段 |
|------|------|------|----------|----------|
| `/tasks` | `page`(1 起)、`pageSize`(别名 `limit`) | 1 / 50 | `page<1` → 1;`pageSize` 夹在 1~200 | `{items,total,page,pageSize}` |
| `/probes` | `limit`、`offset` | 50 / 0 | `limit` 夹在 1~500;`offset<0` → 0 | `{items,total,limit,offset}` |
越界值是**夹到边界**而非报错(`pageSize=9999` → 200,`page=0` → 1)。`total` 为符合过滤条件的总数,与当前页无关。两个列表均按 `id` 倒序(最新在前)。
### 可见性
| 角色 | 任务 | 拨测明细 |
|------|------|----------|
| admin / 静态 token | 全量 | 全量 |
| 普通用户 | 仅 `ownerId` = 本人 | 定时样本按「本人任务」归属、业务拨测按发起者 |
访问**单个**他人的任务返回 `403`(而非 404),便于区分"不存在"与"不是你的"。
### 限流
`/api/v1` **不挂限流**——限流只作用于转发口 `/v1` 与 `/middleware`。唯一例外是免登录的公开状态接口(见文末)。
---
## 一、任务
### 1.1 `GET /api/v1/tasks`
| 参数 | 必填 | 说明 |
|------|------|------|
| `page` | 否 | 页码,1 起,缺省 1 |
| `pageSize` | 否 | 每页条数,缺省 50,上限 200(`limit` 为别名) |
| `tag` | 否 | 按标签**子串**过滤(标签以逗号分隔存储) |
```json
{
"items": [
{
"id": 5, "name": "官网可用性", "ownerId": 2, "ownerUsername": "alice",
"enabled": true, "apiType": "detail", "target": "example.com", "stack": "",
"recordType": "", "nodeScope": "all", "nodeIds": "", "intervalSec": 60,
"slowMs": 800, "expectStatus": "2xx", "bothProtocols": false,
"requireAllStacks": true, "certExpiredDown": false, "notifyRecover": true,
"quietHours": "23:00-07:00", "hideTarget": false, "tags": "prod,官网",
"updatedAt": "2026-09-13T06:12:00Z"
}
],
"total": 1, "page": 1, "pageSize": 50
}
```
任务对象字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | number | 任务 id |
| `name` | string | 任务名 |
| `ownerId` | number | 所有者用户 id;`0` = 无归属(静态 token 建的旧任务) |
| `ownerUsername` | string | 派生字段:所有者用户名(无归属为空串) |
| `enabled` | bool | 是否启用调度 |
| `apiType` | string | `tcping` / `speed` / `ssl` / `detail` / `dns` |
| `target` | string | `detail`/`ssl`/`dns` = 域名;`tcping` = `host[:port]`;`speed` = `v4/`或`v6/`+URL |
| `stack` | string | `""`(按类型) / `v4` / `v6`,仅 `speed` 想固定单栈时用 |
| `recordType` | string | `dns` 专用:`a`/`aaaa`/`cname`/`mx`/`ns`/`ptr`/`srv`/`txt`/`caa`(缺省 `a`) |
| `nodeScope` | string | `all`(全池)/ `custom`(指定) |
| `nodeIds` | string | `custom` 时的逗号分隔节点 id;`all` 忽略 |
| `intervalSec` | number | 调度间隔(秒),最小 10 |
| `slowMs` | number | 慢阈值(ms),超过计一次"未达标";0 = 不启用 |
| `expectStatus` | string | 期望状态码:`"2xx"` 或 `"200"` / `"200,301,302"` |
| `bothProtocols` | bool | `detail`:http 与 https 都要命中才算成功 |
| `requireAllStacks` | bool | 双栈全通才算该节点可用 |
| `certExpiredDown` | bool | `ssl`:证书过期视为不可用 |
| `notifyRecover` | bool | 从 down 恢复时也通知一次 |
| `quietHours` | string | 免打扰时段 `"HH:MM-HH:MM"`(服务器本地时区,支持跨零点) |
| `hideTarget` | bool | 公开状态页隐藏探测目标 |
| `tags` | string | 逗号分隔标签 |
| `updatedAt` | string | 最后更新时间(RFC3339) |
> 分享令牌 `shareToken` **不在 JSON 里**(`json:"-"`),只经管理接口读写。
### 1.2 `GET /api/v1/tasks/:id`
返回单个任务对象(字段同 1.1)。
| 状态 | 场景 |
|------|------|
| `404 not_found` | `task not found` |
| `403 forbidden` | `not your task`(普通用户访问他人任务) |
### 1.3 `GET /api/v1/tasks/:id/sla?hours=24`
| 参数 | 必填 | 说明 |
|------|------|------|
| `hours` | 否 | 统计窗口(小时),缺省 24,夹在 1 ~ 2160(90 天) |
```json
{
"taskId": 5, "hours": 24,
"window": { "from": "2026-09-12T07:00:00Z", "to": "2026-09-13T07:00:00Z" },
"samples": 1440, "up": 1432, "down": 8,
"availability": 99.44, "avgMs": 128, "maxMs": 2103, "p95Ms": 402,
"byNode": [
{ "nodeId": "cn-jiangsu", "up": 720, "down": 0, "availability": 100,
"avgMs": 96, "p95Ms": 180, "latestMs": 88 }
]
}
```
- **只统计 `source=sched` 的定时样本**:节点自主上报与手动一键拨测不进 SLA(详见 [节点 API 与协议](/guide/node-api) 的「数据上报协议」一节)。
- 判定配置(`expectStatus` / `bothProtocols` / `requireAllStacks` / `certExpiredDown`)在**读取时**生效,改配置立刻作用于历史样本,无需回填重算。
- `availability` 是**百分数**(0~100,两位小数)。两处口径略有差异:
- 顶层 `availability` = `up/(up+down)`;
- `byNode[].availability` = `up/samples`(分母含无法判定的 `invalid` 样本,故通常略低)。
- `byNode[].latestMs` 是该节点窗口内最新一条可达样本的延迟。
---
## 二、拨测明细
### 2.1 `GET /api/v1/probes`
| 参数 | 必填 | 说明 |
|------|------|------|
| `node` | 否 | 节点 id 精确匹配 |
| `type` | 否 | `apiType` 精确匹配(`detail`/`ssl`/`dns`/`tcping`/`speed` …) |
| `source` | 否 | `sched`(定时)或 `biz`(业务拨测);**其它取值返回 400** |
| `since` | 否 | 起始时间,**RFC3339**(如 `2026-09-13T00:00:00Z`),非法返回 400 |
| `limit` | 否 | 缺省 50,上限 500 |
| `offset` | 否 | 缺省 0 |
```json
{
"items": [
{ "nodeId": "cn-jiangsu", "apiType": "tcping", "raw": "example.com:443",
"status": 200, "latencyMs": 33, "source": "sched", "error": "",
"createdAt": "2026-09-13T07:01:12Z" }
],
"total": 1440, "limit": 50, "offset": 0
}
```
- 为控制体积,`items` **精简且不含 `body`**(原始响应体)。需要看原始 body 请用控制台「拨测明细」页。
- 不传 `source` 时按可见性返回全部来源:admin 含节点上报的 `ws`/`http` 样本,普通用户仅自己的 `sched` + `biz`。
- **`source=biz` 的边界**:admin 身份下等价于「非 `sched` 的全部来源」(含节点上报的 `ws`/`http`);普通用户下严格等于「自己发起的 `biz`」。要精确取节点上报样本,请按 `node` / `type` 过滤。
### 2.2 `POST /api/v1/probes`
一键拨测:对节点池(或指定节点)并发发起一轮探测,**同步**聚合后一次性返回。这是公开 API 上唯一的写操作——结果会落库(`source=biz`)并归属 Token 主人。
请求体:
```json
{
"apiType": "detail",
"raw": "example.com",
"query": { "port": "443" },
"nodes": ["cn-jiangsu", "cn2-sichuan"]
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `apiType` | 是 | 探测类型;`location`/`asn` 走 IP 定位池,其余走 API 节点池 |
| `raw` | 是 | 探测目标;前导 `/` 会被去掉 |
| `query` | 否 | 透传给节点的查询参数对象(空值项被忽略) |
| `nodes` | 否 | 限定节点 id 列表;缺省 = 池内全部节点 |
```json
{
"apiType": "detail", "raw": "example.com",
"targeted": 2, "ok": 2, "failed": 0, "unknown": [], "persisted": 2,
"results": [
{ "nodeId": "cn-jiangsu", "label": "中国 江苏 移动", "channel": "http",
"status": 200, "latencyMs": 412, "body": { "ipv4": { "http_status_code": 200 } } },
{ "nodeId": "cn2-sichuan", "label": "中国 四川 电信", "channel": "ws",
"status": 0, "latencyMs": 15000, "error": "probe timeout" }
]
}
```
- **HTTP 200 即便部分节点失败**:单节点成败看 `results[i].status` 与 `results[i].error`。`results[i].channel` 为 `ws` / `http`,表示该节点实际走的通道。
- `unknown`:`nodes` 里指定了但不在池中的节点 id。
- `persisted`:实际落库条数。仅 `detail`/`ssl`/`dns`/`tcping`/`speed` 这类拨测型入库;`whois`/`dnssec`/`location`/`asn` 等诊断型不入库。
- **400** 的几种情况:池为空、`nodes` 全部不在池中(错误信息里会列出已知节点 id)、`apiType` 与 `raw` 缺失。
- 这是**并发同步**调用,耗时取决于最慢的节点(WS 通道有独立超时)。客户端超时应放宽到 **30s 以上**。
---
## 三、节点与用量
### 3.1 `GET /api/v1/nodes`
节点池简表,**脱敏**——只有启用中的节点,不含上游地址。
```json
{
"items": [
{ "nodeId": "cn-jiangsu", "label": "中国 江苏 移动",
"ws": false, "pools": "api,location", "stack": "", "online": true, "version": "v1.4.0" }
],
"total": 1
}
```
| 字段 | 说明 |
|------|------|
| `ws` | 该节点拨测是否走 WS 通道 |
| `pools` | 归属池,逗号分隔:`api` / `location` / `api,location` |
| `stack` | 仅 `api` 池有意义:`DualStack` / `IPv4` / `IPv6` |
| `online` | 是否在线(来自节点在线快照) |
| `version` | 节点上报的版本号;从未上线则为 `""` |
### 3.2 `GET /api/v1/usage?hours=24`
| 参数 | 必填 | 说明 |
|------|------|------|
| `hours` | 否 | 统计窗口(小时),缺省 24,夹在 1 ~ 2160 |
```json
{
"hours": 24, "total": 1440, "up": 1432, "down": 8, "invalid": 0,
"byType": [
{ "apiType": "detail", "total": 1200, "up": 1194, "down": 6, "invalid": 0, "avgMs": 128 }
]
}
```
- 口径:admin = 全站(`sched` + `biz` 样本);普通用户 = 自己任务的定时样本 + 自己发起的业务拨测。
- `byType` 按 `total` 倒序;`invalid` = 无法判定的样本,不计入成败。
---
## 附:免登录的公开状态接口
SLA 任务的**分享组**对外开放状态页,无需任何凭据。它不属于 `/api/v1`(不挂鉴权),但因同样面向公众而并列在此。
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/s/:token` | 只读状态页(Vue SPA,30s 自刷新;需托管侧提供 SPA fallback) |
| GET | `/api/public/status/:token` | 同数据的 JSON(页面用),参数 `hours` 同上 |
```json
{
"token": "a1b2c3", "hours": 24,
"tasks": [
{ "taskId": 5, "name": "官网可用性", "apiType": "detail", "target": "example.com",
"hours": 24, "samples": 1440, "up": 1432, "down": 8, "availability": 99.44,
"avgMs": 128, "maxMs": 2103, "p95Ms": 402,
"byNode": [ { "nodeId": "cn-jiangsu", "up": 720, "down": 0,
"availability": 100, "avgMs": 96, "p95Ms": 180, "latestMs": 88 } ],
"series": [ { "time": "2026-09-13T07:00:00Z", "minute": 29758260, "samples": 10,
"up": 10, "down": 0, "availability": 100, "avgMs": 120.5 } ] }
]
}
```
- 同一令牌的任务**同页展示**(令牌即"分享组");令牌为 3~32 位小写字母/数字/连字符,可自定义,管理接口见控制台「SLA 监控 → 分享」。
- **暴露面刻意收窄**:只有任务名/类型/目标 + 窗口聚合与延迟时序(`series` 为每轮多节点平均;多节点时另给 `nodeSeries` 分节点曲线);不含 owner、上游地址、内部字段。任务勾选 `hideTarget` 时,`target` 字段**整体不出现在 JSON 里**。
- 令牌未知或已关闭分享 → `404 not_found`(`invalid share link`)。
- **防枚举限流**:每 IP 60 次/分,超限 `429 rate_limited`(随机令牌仅 24-bit 熵,必须有这层保护);响应带 `Cache-Control: no-store`。
---
## 排错
| 现象 | 原因与处理 |
|------|------------|
| `401 missing bearer token` | 没带 `Authorization: Bearer ` 前缀(注意 `Bearer` 后有一个空格) |
| `401 invalid or revoked token` | Token 被重新生成过或已吊销 → 重新生成一枚 |
| `403 not your task` | 用普通用户的 Token 去读别人的任务 |
| `400 source 只能是 sched / biz` | `?source=` 传了其它取值 |
| `400 since 须为 RFC3339 时间` | 时间必须带时区,如 `2026-09-13T00:00:00Z` |
| `POST /probes` 400 且提示 known nodes | `nodes` 里的 id 都不在节点池 → 先用 `GET /api/v1/nodes` 取真实 id |
| `POST /probes` 耗时很久 | 同步聚合要等最慢节点 → 客户端超时设到 30s 以上 |
| 列表查不到自己刚建的任务 | 列表按 `id` 倒序;确认用的是同一个账号的 Token(普通用户只见自己的) |
+35
View File
@@ -0,0 +1,35 @@
---
# 首页(VitePress 标准布局,已在 config 中设置 vpHome: true / teekHome: false)
layout: home
hero:
name: "LEMON IPW"
text: "LEMON IPW官方文档"
tagline: IP 查询 · 网站检测 · SSL · DNS · DNSSEC · Whois · ASN · 截图
actions:
- theme: brand
text: 快速开始
link: /guide/getting-started
- theme: alt
text: GitHub
link: https://github.com/nomdn/ipw-cn
target: _blank
features:
- title: 多源 IP 查询
details: 支持 IPv4/IPv6,集成 ip2region、qqwry、MaxMind GeoIP、GeoCN、DbIP、Bilibili 等多数据源,覆盖国内外归属地。
- title: ASN 与 Whois
details: 查询 IP 所属自治系统号(多数据源 + WHOIS 解析),以及域名注册信息、注册商、到期时间等。
- title: 网站检测全家桶
details: HTTP 状态码与响应时间、SSL 证书有效期与颁发机构、TCPing 延迟、下载测速、在线页面截图一站式完成。
- title: DNS 解析
details: 多节点并发查询,支持 A / AAAA / CNAME / MX / TXT / NS / SRV / PTR / CAA 等记录类型,并提供 DNSSEC 链式信任验证。
- title: SSRF 防护
details: 自动拦截对内网 / 私有 IP 的出站请求,校验安全跳转,避免服务端请求伪造风险。
- title: 分布式节点与 WS 通道
details: 节点可作为客户端接入中间件,拨测请求经 WebSocket 下发与回传;支持多地址同时连接(多活)、断线自动重连,`ws-url` 改动热生效无需重启。
- title: 远程运维
details: 节点上报版本号与能力清单,收集中心展示版本、远程读写运行时配置,并可下发一次性 OTA 升级任务(下载 → 校验 → 替换 → 重启,失败自动回滚)。
- title: 多平台部署
details: 支持二进制、Docker,以及 EdgeOne Makers 和 Vercel 部署
---
+79
View File
@@ -0,0 +1,79 @@
# 功能总览
后端节点(`src/`,Go + Gin)承担全部检测能力,可在任意地区部署组成去中心化测试网络。接口清单见 [节点 API 与协议参考](/guide/node-api)。
## 探测能力矩阵
| 能力 | 路由 | 依赖 | 说明 |
|------|------|------|------|
| 网站探测明细 | `/v1/detail/*url` | 出站 HTTP(v4/v6 双客户端) | DNS 解析、TCP 连接、TLS 握手、首字节、总耗时、状态码、页面体积、下载速度、可达性 |
| SSL 证书检查 | `/v1/ssl/*url` | TLS 握手 | 证书有效期/剩余天数、颁发机构、主体、TLS 版本、是否过期 |
| 网站测速 | `/v1/speed/:version/*url` | 出站下载 | 指定栈的下载速度测试 |
| TCPing | `/v1/tcping/:ip` | TCP 拨号 | 端口连通性 + RTT 统计(次数可配) |
| DNS 解析 | `/v1/dns/:type/*domain` | `dns-server` | 9 种记录类型;DNS 服务器支持主从 failover 与 DoH |
| DNSSEC 验证 | `/v1/dnssec/:domain` | `dnssec-server` | DNSKEY / RRSIG / DS 链式信任校验 |
| Whois | `/v1/whois/:domain` | 上游 whois 服务器 | 域名注册商、注册/到期时间等 |
| IP 归属地 | `/v1/location`、`/v1/location/:ip` | IP 数据库(10 源) | 多源聚合,含国家/省/市/ISP/经纬度/时区 |
| ASN 查询 | `/v1/asn/:ip` | 3 套 ASN 库 + Whois | 自治系统号与所属组织 |
**双栈**:除 IP 归属地/ASN 外的能力均按 IPv4 / IPv6 分别执行并同时返回(`single-stack` 可声明单栈节点以跳过另一栈)。
**统计口径**:所有 `/v1/*` 请求经统计中间件计数,由节点按周期上报收集中心(见 [节点 API · 数据上报](/guide/node-api))。
## IP 数据源
`ipdb` 未关闭时启动加载,共 10 个源;任一本地文件缺失即从远端拉取全部数据库(首次约 450MB,之后每 24h + 随机抖动更新一次)。
| 源 | 用途 | 形式 |
|----|------|------|
| ip2region | 国家 / 省 / 市 / ISP | 本地 xdb(v4 + v6) |
| IP2Location LITE DB11 | 国家 / 省 / 市 / ISP / 经纬度 / 时区 | 本地 BIN |
| IP2Location LITE ASN | ASN | 本地 BIN |
| qqwry | 国家 / 省 / 市 / ISP / 国家码 | 本地 ipdb |
| MaxMind GeoLite2 City | 国家 / 省 / 市 / 经纬度 | 本地 MMDB |
| MaxMind GeoLite2 ASN | ASN / 组织 | 本地 MMDB |
| DbIP City Lite | 国家 / 省 / 市 / 经纬度 | 本地 MMDB |
| DbIP ASN Lite | ASN / 组织 | 本地 MMDB |
| GeoCN | 中国行政区划码 + ISP + 类型 | 本地 MMDB + 行政区划表 |
| Bilibili | 国家 / 省 / 市 / ISP / 经纬度 | **在线 API**,24h 缓存 |
- `/v1/location` 聚合全部 10 源(结果按源分组返回,便于交叉比对);`/v1/asn` 只查三套 ASN 库并对 MaxMind ASN 做 Whois 解析。
- `gh-proxy` 可给 GitHub 下载加代理前缀加速;GeoCN 走 jsDelivr CDN。
- `ipdb=false` 时整体关闭:不加载数据库,`/v1/location`、`/v1/asn` 路由不注册,适合只做网络拨测的轻量节点。
## 可靠性设计
- **结果缓存**:7 类缓存(6 个业务缓存 5 分钟 + Bilibili 24 小时),失败结果 30s 后即失效;每 10 分钟定时清扫过期条目。详见 [节点 API · 缓存与并发](/guide/node-api)。
- **singleflight**:`detail` / `ssl` / `tcping` / `speed` 的并发同 key 请求合并为一次真实探测。
- **SSRF 防护**(`block-private-ips`):拒绝出站连接私有/内网地址,探测目标命中私有网段时直接返回伪造结果,禁止跨跳重定向;`trusted-proxies` 控制 `X-Forwarded-For` 信任范围,防止访客伪造来源 IP。
- **DNS 兜底**:`dns-server` / `dnssec-server` 留空时自动探测系统 DNS,避免容器环境下解析不可用。
## 与收集中心 / 中间件的关系
```
┌───────────────────────────────┐
浏览器 / 前端 ───▶│ 中间件(ipw-cn/middleware-go │
│ 或 ipw-boce 收集中心) │
└───────┬───────────────┬───────┘
HTTP │ │ WS(/ws)
转发 ▼ ▼
┌───────────────────────────────┐
│ 后端节点(本仓库) │
│ 执行探测 → 结果原路回传 │
│ 统计/明细 → 周期上报收集中心 │
└───────────────────────────────┘
```
- **转发链路**:前端不直连节点,而是经中间件按池轮询转发;节点也可以直接对外提供 `/v1/*`(自建纯节点模式)。
- **WS 通道**:节点作为客户端接入中间件,拨测请求经 WS 下发、结果经 WS 回传;同时承载配置管理(`config`)与升级(`ota`)指令。
- **数据上报**:统计与拨测明细只由节点上报,中间件不在转发路径上采集,避免双算。
- **版本与能力上报**:节点上报版本号与能力清单,收集中心据此展示版本、判断能否下发管理指令。
## 部署形态
| 形态 | 说明 |
|------|------|
| 二进制 / systemd | `install.sh` 一键安装,配置以环境变量注入 |
| Docker | `src/Dockerfile` 多阶段构建,多架构(amd64 / arm64 / armv7);镜像不含 IP 库,首次运行现场拉取 |
| Serverless | EdgeOne Go 边缘函数、Vercel 部署入口(仓库提供实现;均为 IPv4-only,需声明 `SINGLE_STACK=ipv4`) |
各形态的详细步骤见 [后端节点部署](/guide/deploy-node)。
+44
View File
@@ -0,0 +1,44 @@
# 柠檬味IPW
柠檬味ipw是一个基于Golang的分布式站点测试平台,旨在构建一个去中心化的,共建贡献的测试平台。
> [!TIP]
> 尝试一下?这里是官方站点:[链接](https://ipw.wsmdn.top)
## 简介
Lemon IPW(柠檬味 ipw.cn 替代品)是一个开源的分布式站点测试平台。与中心化测试服务不同,本项目鼓励社区成员部署自己的测试节点,前端多节点轮询、后端多地区部署,组成一张去中心化的测试网络,单一节点故障不影响整体可用性。
## 功能
- IP 地址查询(IPv4/IPv6,集成 ip2region、qqwry、MaxMind、GeoCN、DbIP 等多数据源)
- ASN 自治系统查询(MaxMind、DbIP,集成 WHOIS 解析)
- 网站检测(HTTP 状态码、响应时间)
- SSL 证书检查(有效期、颁发机构)
- DNS 解析(A/AAAA/CNAME/MX/TXT/NS/SRV/PTR/CAA 等,多节点并发)
- DNSSEC 验证(DNSKEY、RRSIG、DS 链式信任)
- Whois 查询
- TCPing(IPv4/IPv6 双栈)
- 网站测速
- 网站截图
- 远程运维(版本 / 能力上报、运行时配置下发、一次性 OTA 升级)
完整的能力矩阵、IP 数据源与可靠性设计见 [功能总览](/info/features)。
## 架构
前后端分离,可独立部署、自由组合:
- 前端(Nuxt 4 SSR)负责数据可视化与接口调用
- 后端(Go)提供全部检测 API,可在任意地区部署测试节点,是分布式查询的核心
- 独立中间件(middleware-go)使用Fiber技术栈 负责请求转发和 Key 注入
后端支持二进制、Docker 自托管,以及仓库中提供的 EdgeOne Go 边缘函数和对应的 Vercel 部署入口。AWS Lambda、阿里云函数计算目前没有对应的实现或部署配置。
## 技术栈
Go、Gin、Nuxt 4、Vue 3、Element Plus。
## 下一步
- 想自己部署一套?前往 [部署指南](/guide/)
- 想了解前端如何部署?阅读 [前端部署](/guide/deploy-frontend)
+11
View File
@@ -0,0 +1,11 @@
{
"scripts": {
"docs:dev": "vitepress dev",
"docs:build": "vitepress build",
"docs:preview": "vitepress preview"
},
"devDependencies": {
"vitepress": "^1.6.4",
"vitepress-theme-teek": "^1.6.2"
}
}
+1858
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1626616227481" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2469" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M512 64a448 448 0 1 0 0 896A448 448 0 0 0 512 64z m0 820.032A372.032 372.032 0 0 1 512 139.968a372.032 372.032 0 0 1 0 744.064z" fill="#07c160" p-id="2470"></path><path d="M382.208 731.264V320H320v411.264h62.208z m144.576 0V571.136h106.56c98.496 0 148.032-42.048 148.032-126.144C781.376 361.472 732.416 320 634.496 320H464v411.264h62.784z m102.528-213.696H526.784v-144h102.528c30.528 0 52.992 5.76 67.392 17.28 14.4 10.368 21.888 28.8 21.888 54.144 0 25.344-7.488 43.776-21.312 55.296-14.4 11.52-36.864 17.28-67.968 17.28z" fill="#07c160" p-id="2471"></path></svg>

After

Width:  |  Height:  |  Size: 941 B

-349
View File
@@ -1,349 +0,0 @@
package webtest
import (
"bytes"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"sync"
"time"
"github.com/miekg/dns"
)
// 腾讯云标准 DoH 端点
var dohEndpoint string = "https://doh.pub/dns-query"
type DNSResult struct {
Domain string `json:"domain"`
Duration float64 `json:"duration"`
Record []string `json:"record"`
TTL uint32 `json:"ttl"`
}
func SetDNSServer(server string) {
if server == "" {
server = "https://doh.pub/dns-query"
}
dohEndpoint = server
}
// ==========================================
// ⭐️ 底层核心:构造、发送并解析二进制 DNS 报文
// ==========================================
func executeDoHQuery(domain string, qtype uint16) (*dns.Msg, float64, error) {
// 1. 构造标准的 DNS 请求报文
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(domain), qtype)
msg.RecursionDesired = true // 请求递归解析
// 2. 将 DNS 报文打包成二进制字节流
packedMsg, err := msg.Pack()
if err != nil {
return nil, 0, fmt.Errorf("failed to pack DNS message: %v", err)
}
// 3. 构造 HTTP POST 请求,Body 为二进制报文
req, err := http.NewRequest("POST", dohEndpoint, bytes.NewReader(packedMsg))
if err != nil {
return nil, 0, err
}
// ⭐️ 灵魂 Header:RFC 8484 标准规定的 MIME 类型
req.Header.Set("Content-Type", "application/dns-message")
req.Header.Set("Accept", "application/dns-message")
// 4. 发起 HTTPS 请求 (走 443 端口)
client := &http.Client{Timeout: 5 * time.Second}
start := time.Now()
resp, err := client.Do(req)
duration := time.Since(start).Seconds() * 1000
if err != nil {
return nil, duration, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, duration, fmt.Errorf("DoH API returned status %d", resp.StatusCode)
}
// 5. 读取返回的二进制响应报文
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, duration, fmt.Errorf("failed to read response body: %v", err)
}
// 6. 使用 miekg/dns 解包二进制响应
responseMsg := new(dns.Msg)
if err := responseMsg.Unpack(bodyBytes); err != nil {
return nil, duration, fmt.Errorf("failed to unpack DNS response: %v", err)
}
// 7. 检查 DNS 响应码 (Rcode)
if responseMsg.Rcode != dns.RcodeSuccess {
return responseMsg, duration, fmt.Errorf("DNS query failed with Rcode %d", responseMsg.Rcode)
}
return responseMsg, duration, nil
}
// ==========================================
// 业务层:调用底层函数并提取特定记录
// ==========================================
func ResolveARecord(domain string) (DNSResult, error) {
result := DNSResult{Domain: domain, Record: []string{}}
responseMsg, duration, err := executeDoHQuery(domain, dns.TypeA)
result.Duration = duration
if err != nil {
slog.Warn("Failed to query A", "domain", domain, "err", err)
return result, err
}
for _, ans := range responseMsg.Answer {
if aRecord, ok := ans.(*dns.A); ok {
result.Record = append(result.Record, aRecord.A.String())
if result.TTL == 0 {
result.TTL = aRecord.Header().Ttl
}
}
}
return result, nil
}
func ResolveAAAARecord(domain string) (DNSResult, error) {
result := DNSResult{Domain: domain, Record: []string{}}
responseMsg, duration, err := executeDoHQuery(domain, dns.TypeAAAA)
result.Duration = duration
if err != nil {
slog.Warn("Failed to query AAAA", "domain", domain, "err", err)
return result, err
}
for _, ans := range responseMsg.Answer {
if aRecord, ok := ans.(*dns.AAAA); ok {
result.Record = append(result.Record, aRecord.AAAA.String())
if result.TTL == 0 {
result.TTL = aRecord.Header().Ttl
}
}
}
return result, nil
}
func ResolveTXTRecord(domain string) (DNSResult, error) {
result := DNSResult{Domain: domain, Record: []string{}}
responseMsg, duration, err := executeDoHQuery(domain, dns.TypeTXT)
result.Duration = duration
if err != nil {
slog.Warn("Failed to query TXT", "domain", domain, "err", err)
return result, err
}
for _, ans := range responseMsg.Answer {
if txtRecord, ok := ans.(*dns.TXT); ok {
result.Record = append(result.Record, txtRecord.Txt...)
if result.TTL == 0 {
result.TTL = txtRecord.Header().Ttl
}
}
}
return result, nil
}
func ResolveNSRecord(domain string) (DNSResult, error) {
result := DNSResult{Domain: domain, Record: []string{}}
responseMsg, duration, err := executeDoHQuery(domain, dns.TypeNS)
result.Duration = duration
if err != nil {
slog.Warn("Failed to query NS", "domain", domain, "err", err)
return result, err
}
for _, ans := range responseMsg.Answer {
if nsRecord, ok := ans.(*dns.NS); ok {
result.Record = append(result.Record, nsRecord.Ns)
if result.TTL == 0 {
result.TTL = nsRecord.Header().Ttl
}
}
}
return result, nil
}
func ResolveCNAMERecord(domain string) (DNSResult, error) {
result := DNSResult{Domain: domain, Record: []string{}}
responseMsg, duration, err := executeDoHQuery(domain, dns.TypeCNAME)
result.Duration = duration
if err != nil {
slog.Warn("Failed to query CNAME", "domain", domain, "err", err)
return result, err
}
for _, ans := range responseMsg.Answer {
if cnameRecord, ok := ans.(*dns.CNAME); ok {
result.Record = append(result.Record, cnameRecord.Target)
if result.TTL == 0 {
result.TTL = cnameRecord.Header().Ttl
}
}
}
return result, nil
}
func ResolveMXRecord(domain string) (DNSResult, error) {
result := DNSResult{Domain: domain, Record: []string{}}
responseMsg, duration, err := executeDoHQuery(domain, dns.TypeMX)
result.Duration = duration
if err != nil {
slog.Warn("Failed to query MX", "domain", domain, "err", err)
return result, err
}
for _, ans := range responseMsg.Answer {
if mxRecord, ok := ans.(*dns.MX); ok {
result.Record = append(result.Record, mxRecord.Mx)
if result.TTL == 0 {
result.TTL = mxRecord.Header().Ttl
}
}
}
return result, nil
}
func ResolveSRVRecord(domain string) (DNSResult, error) {
result := DNSResult{Domain: domain, Record: []string{}}
responseMsg, duration, err := executeDoHQuery(domain, dns.TypeSRV)
result.Duration = duration
if err != nil {
slog.Warn("Failed to query SRV", "domain", domain, "err", err)
return result, err
}
for _, ans := range responseMsg.Answer {
if srvRecord, ok := ans.(*dns.SRV); ok {
result.Record = append(result.Record, srvRecord.Target)
if result.TTL == 0 {
result.TTL = srvRecord.Header().Ttl
}
}
}
return result, nil
}
func ResolvePTRRecord(ip string) (DNSResult, error) {
result := DNSResult{Domain: ip, Record: []string{}}
ptrName, err := dns.ReverseAddr(ip)
if err != nil {
return result, fmt.Errorf("invalid IP: %v", err)
}
responseMsg, duration, err := executeDoHQuery(ptrName, dns.TypePTR)
result.Duration = duration
result.Domain = ip // 保持返回的 Domain 为原始 IP
if err != nil {
slog.Warn("Failed to query PTR", "ip", ip, "err", err)
return result, err
}
for _, ans := range responseMsg.Answer {
if ptrRecord, ok := ans.(*dns.PTR); ok {
result.Record = append(result.Record, ptrRecord.Ptr)
if result.TTL == 0 {
result.TTL = ptrRecord.Header().Ttl
}
}
}
return result, nil
}
func ResolveCAARecord(domain string) (DNSResult, error) {
result := DNSResult{Domain: domain, Record: []string{}}
responseMsg, duration, err := executeDoHQuery(domain, dns.TypeCAA)
result.Duration = duration
if err != nil {
slog.Warn("Failed to query CAA", "domain", domain, "err", err)
return result, err
}
for _, ans := range responseMsg.Answer {
if caaRecord, ok := ans.(*dns.CAA); ok {
result.Record = append(result.Record, caaRecord.Value)
if result.TTL == 0 {
result.TTL = caaRecord.Header().Ttl
}
}
}
return result, nil
}
// ResolveIP 通过 DoH 解析域名,返回指定版本(v4/v6)的 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)
}
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)
}
// ==========================================
// 并发查询所有记录
// ==========================================
type DNSFullResult struct {
Domain string `json:"domain"`
A DNSResult `json:"a"`
AAAA DNSResult `json:"aaaa"`
CNAME DNSResult `json:"cname"`
MX DNSResult `json:"mx"`
NS DNSResult `json:"ns"`
TXT DNSResult `json:"txt"`
SRV DNSResult `json:"srv"`
CAA DNSResult `json:"caa"`
}
func ResolveARecordllDNSRecords(domain 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) }()
wg.Wait()
return result
}
-136
View File
@@ -1,136 +0,0 @@
package webtest
import (
"fmt"
"math"
"net"
"time"
)
type TCPingResult struct {
IP string `json:"ip"`
Port string `json:"port"`
Success bool `json:"success"`
RTT float64 `json:"rtt"`
Error string `json:"error"`
Timestamp time.Time `json:"timestamp"`
}
type TCPingStats struct {
IP string `json:"ip"`
Port string `json:"port"`
Sent int `json:"sent"`
Success int `json:"success"`
LossRate float64 `json:"loss_rate"`
MaxRTT float64 `json:"max_rtt"`
MinRTT float64 `json:"min_rtt"`
AvgRTT float64 `json:"avg_rtt"`
Results []TCPingResult `json:"results"`
}
func resolveHost(host string, version string) (string, error) {
return ResolveIP(host, version)
}
func TCPing(host string, port string, version string, timeout time.Duration) (*TCPingResult, error) {
ip, err := resolveHost(host, version)
if err != nil {
return nil, err
}
addr := ""
switch version {
case "v4":
addr = fmt.Sprintf("%s:%s", ip, port)
case "v6":
addr = fmt.Sprintf("[%s]:%s", ip, port)
}
start := time.Now()
conn, err := net.DialTimeout("tcp", addr, timeout)
rtt := time.Since(start)
result := &TCPingResult{
IP: ip,
Port: port,
Timestamp: start,
}
if err != nil {
result.Success = false
result.RTT = -1
result.Error = err.Error()
} else {
conn.Close()
result.Success = true
result.RTT = float64(rtt.Microseconds()) / 1000.0
}
return result, nil
}
func TCPingRun(host string, port string, count int, version string, timeout time.Duration, interval time.Duration) (*TCPingStats, error) {
ip, err := resolveHost(host, version)
if err != nil {
return &TCPingStats{
IP: "Error: " + err.Error(),
Port: port,
Sent: count,
Success: 0,
Results: nil,
MinRTT: -1,
MaxRTT: -1,
AvgRTT: -1,
LossRate: 100,
}, nil
}
stats := &TCPingStats{
IP: ip,
Port: port,
Sent: count,
MinRTT: math.MaxFloat64,
Results: make([]TCPingResult, 0, count),
}
var totalRTT float64
successCount := 0
for i := 0; i < count; i++ {
result, err := TCPing(host, port, version, timeout)
if err != nil {
return nil, err
}
stats.Results = append(stats.Results, *result)
if result.Success {
successCount++
totalRTT += result.RTT
if result.RTT > stats.MaxRTT {
stats.MaxRTT = result.RTT
}
if result.RTT < stats.MinRTT {
stats.MinRTT = result.RTT
}
}
if i < count-1 && interval > 0 {
time.Sleep(interval)
}
}
stats.Success = successCount
stats.LossRate = math.Round(float64(count-successCount)*10000/float64(count)) / 100
if successCount > 0 {
stats.AvgRTT = math.Round(totalRTT*100/float64(successCount)) / 100
} else {
stats.MinRTT = -1
stats.MaxRTT = -1
stats.AvgRTT = -1
}
return stats, nil
}
+290 -41
View File
@@ -1,16 +1,36 @@
<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);
const isDark = useDark();
const toggleDark = useToggle(isDark);
// ==================== 抽屉里的文档菜单 ====================
// 窄屏下 /doc 页面自己的左侧侧栏会被隐藏(见 app/pages/doc/*.vue),
// 文档导航改由这里的抽屉承载;宽屏不渲染抽屉,一切照旧。
const route = useRoute()
const isDocRoute = computed(() => route.path === '/doc' || route.path.startsWith('/doc/'))
function cleanChineseCharacters(str:string) {
return str.replace(/[\u4e00-\u9fa5]/g, '');
}
@@ -41,23 +61,25 @@ interface NavDivider {
type MenuEntry = NavItem | NavGroup | NavDivider
const menu: MenuEntry[] = [
{ kind: 'item', index: '1', label: 'IPv6 网站检测', to: '/ipv6webcheck', minWidth: 950 },
{ kind: 'item', index: '2', label: 'IPv6/IPv4 地址查询', to: '/location', minWidth: 990 },
{ kind: 'item', index: '3', label: 'IPv6 TCPing测试', to: '/ipv6tcping', minWidth: 1030 },
{ kind: 'divider', minWidth: 1080 }, // 与 index 4 相同:4-6 段全折叠时隐藏
{ kind: 'item', index: '4', label: 'IPv6 DNS解析', to: '/dns', minWidth: 1080 },
{ kind: 'item', index: '5', label: 'IPv6 SSL检查', to: '/ssl', minWidth: 1130 },
{ kind: 'item', index: '6', label: 'IPv6 网站测速', to: '/ipv6speedtest', minWidth: 1180 },
{ kind: 'divider', minWidth: 1240 }, // 与 index 7 相同:7-9 段全折叠时隐藏
// 断点设计基准(从右往左折叠):全显示约 1480px,1366 屏可用约 1350px,
// 故 1366 屏折叠 index 7/8/9(显示 1-6 +「更多」,约 1220px 宽裕);1440 折叠 8/9;1500+ 全显示。
{ kind: 'item', index: '1', label: 'IPv6 网站检测', to: '/ipv6webcheck', minWidth: 980 },
{ kind: 'item', index: '2', label: 'IPv6/IPv4 地址查询', to: '/location', minWidth: 1040 },
{ kind: 'item', index: '3', label: 'IPv6 TCPing测试', to: '/ipv6tcping', minWidth: 1100 },
{ kind: 'divider', minWidth: 1160 }, // 与 index 4 相同:4-6 段全折叠时隐藏
{ kind: 'item', index: '4', label: 'IPv6 DNS解析', to: '/dns', minWidth: 1160 },
{ kind: 'item', index: '5', label: 'IPv6 SSL检查', to: '/ssl', minWidth: 1220 },
{ kind: 'item', index: '6', label: 'IPv6 网站测速', to: '/ipv6speedtest', minWidth: 1280 },
{ kind: 'divider', minWidth: 1380 }, // 与 index 7 相同:7-9 段全折叠时隐藏
{
kind: 'group', index: '7', label: 'IPv4工具箱', minWidth: 1240,
kind: 'group', index: '7', label: 'IPv4工具箱', minWidth: 1380,
children: [
{ index: '7-0', label: 'IPv4 网站测速', to: '/speedtest' },
{ index: '7-1', label: 'IPv4 TCPing测试', to: '/tcping' },
],
},
{
kind: 'group', index: '8', label: '其他工具', minWidth: 1300,
kind: 'group', index: '8', label: '其他工具', minWidth: 1420,
children: [
{ index: '8-0', label: '网站截图', to: '/screenshot' },
{ index: '8-1', label: 'Whois查询', to: '/whois' },
@@ -65,7 +87,7 @@ const menu: MenuEntry[] = [
{ index: '8-4', label: 'DNSSEC验证', to: '/dnssec' },
],
},
{ kind: 'item', index: '9', label: '文档', to: '/doc', minWidth: 1360 },
{ kind: 'item', index: '9', label: '文档', to: '/doc', minWidth: 1500 },
]
// SSR 阶段无 window,默认按宽屏输出全部菜单项;onMounted 后更新为真实视口宽度
@@ -82,19 +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'},
{ 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'},
{ 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)');
@@ -114,19 +207,28 @@ 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>
<template>
<el-drawer v-if="isNarrow" v-model="drawer" direction="ltr" style="height: 100%;" size="50%">
<el-drawer v-if="isNarrow" v-model="drawer" direction="ltr" style="height: 100%;" :size="isDocRoute ? '80%' : '60%'">
<router-link to="/ipv6webcheck">
<p class="menu-item-text">IPv6 网站检测</p>
</router-link>
@@ -140,24 +242,54 @@ onMounted(() => {
<router-link to="/ssl">
<p class="menu-item-text">IPv6 SSL检查</p>
</router-link>
<a href="/ipv6speedtest"><p class="menu-item-text">IPv6 网站测速</p></a>
<a href="/speedtest"><p class="menu-item-text">IPv4 网站测速</p></a>
<a href="/tcping"><p class="menu-item-text">IPv4 TCPing</p></a>
<a href="/screenshot"><p class="menu-item-text">网站截图</p></a>
<a href="/whois"><p class="menu-item-text">Whois查询</p></a>
<a href="/asn"><p class="menu-item-text">ASN查询</p></a>
<a href="/dnssec"><p class="menu-item-text">DNSSEC验证</p></a>
<router-link to="/ipv6speedtest"><p class="menu-item-text">IPv6 网站测速</p></router-link>
<router-link to="/speedtest"><p class="menu-item-text">IPv4 网站测速</p></router-link>
<router-link to="/tcping"><p class="menu-item-text">IPv4 TCPing</p></router-link>
<router-link to="/screenshot"><p class="menu-item-text">网站截图</p></router-link>
<router-link to="/whois"><p class="menu-item-text">Whois查询</p></router-link>
<router-link to="/asn"><p class="menu-item-text">ASN查询</p></router-link>
<router-link to="/dnssec"><p class="menu-item-text">DNSSEC验证</p></router-link>
<!-- 窄屏唯一的文档入口:非文档页面抽屉里不挂整份文档菜单(太长),
只留这一个链接,对应宽屏顶栏 index 9 的「文档」项。
进入文档页后,抽屉里才会出现完整的文档导航。 -->
<router-link v-if="!isDocRoute" to="/doc" @click="drawer = false"><p class="menu-item-text">文档</p></router-link>
<!-- 文档导航并进同一个左侧菜单:与 /doc 页面用的是同一个组件。
只在文档路由下出现(非文档页面抽屉里就只留工具链接);
文档路由下分组默认展开(手机上没 hover,省一次点击)。
:key 是为了让分组开合重新初始化 —— el-menu 只在挂载时读一次 default-openeds,
抽屉一旦打开过就不会重挂,不加 key 会停在首次打开时的开合状态。 -->
<DocMenu
v-if="isDocRoute"
:key="'doc-expanded'"
expand-all
@select="drawer = false"
/>
</el-drawer>
<el-menu
mode="horizontal"
:ellipsis="false"
>
<el-menu-item index="0">
<!-- 宽屏不给 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="/">
<el-image src="/favicon.svg" style="margin-top: 20px;" />
<h2 style="display: inline-block; margin-left: 10px" v-if="!isNarrow">柠檬味ipw.cn</h2>
<router-link to="/" :aria-label="isNarrow ? '返回首页' : undefined">
<!-- alt 留空是有意的:logo 属装饰图,可访问名由外层链接提供。
若把 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>
@@ -173,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">
@@ -190,7 +328,8 @@ onMounted(() => {
</template>
</el-sub-menu>
</template>
<el-menu-item index="10">
<!-- 纯图标菜单项:无可访问名,补 aria-label(图标本身不带文字) -->
<el-menu-item index="10" aria-label="切换深色/浅色模式">
<ClientOnly>
<el-icon @click="toggleDark()" v-if="isDark" style="cursor: pointer;"><Moon style="height: 20px; width: 20px;"/></el-icon>
<el-icon @click="toggleDark()" v-else style="cursor: pointer;"><Sunny style="height: 20px; width: 20px;"/></el-icon>
@@ -205,15 +344,24 @@ onMounted(() => {
<NuxtPage />
</main>
<footer>
<!-- 文档路由不渲染页脚:文档页是阅读页,正文底部加备案/版权块会打断阅读,
且窄屏下与侧栏抽屉的可滚动区域互相挤压。 -->
<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" />
<a :href="'https://beian.mps.gov.cn/#/query/webSearch?code=' + cleanChineseCharacters(config.GongAn)" target="_blank" rel="noreferrer" >{{ config.GongAn }}</a>
<!-- 同上:原生 <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>
&nbsp;|&nbsp;请遵守中国法律法规&nbsp;|&nbsp;
@@ -225,6 +373,8 @@ onMounted(() => {
</div>
</footer>
<!-- 全站文档图片灯箱:委托拦截 .markdown / .markdown-body 内的图片点击 -->
<ImageLightbox />
</template>
<style scoped>
@import "~/style.css";
@@ -259,41 +409,107 @@ 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 {
--el-color-primary: #3EAF7C;
/* 暗色模式下根元素的文字色与页面底色。
这两条必须在【非 scoped】块里:<html> 永远不会带上组件 scope 属性,
若写在 <style scoped>(含被 scoped @import 进来的 style.css)里,会被改写成
`html.dark[data-v-xxxx]` —— 整条规则永不匹配,底色只能靠浏览器的 color-scheme 兜底。 */
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
--el-color-primary: #299764;
/* 暗底 #242424 上 #299764 对比 ~4.2:1,且再加深只会更差 —— 暗色链接保持原色 */
--link-color: #299764;
}
/* Drawer 内部链接占满一行 */
/* Drawer 内部链接占满一行。
注意用直接子选择器(> a):文档菜单(DocMenu)里的 <a> 嵌在 el-menu-item 里,
不能被这套「整行大按钮」规则吃掉,否则菜单项会变成上下带 1em 内边距的块。
间距三层叠加会挤没文字(body 20px + a 1em + p margin-left 10px),
所以 body 归零、a 只留一层、p 的 margin 清掉,字号定死 15px 保证最长的
「IPv6/IPv4 地址查询」在 320px 屏的 60% 抽屉里也放得下。 */
.el-drawer__body {
overflow-x: hidden;
padding: 6px 0;
font-size: 15px;
}
.el-drawer__body a {
.el-drawer__body > a {
display: block !important;
width: 100% !important;
box-sizing: border-box;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 1em;
padding: 12px 16px;
font-size: 15px;
}
.el-drawer__body a p {
.el-drawer__body > a p.menu-item-text {
display: block !important;
width: 100% !important;
box-sizing: border-box;
margin: 0;
font-size: 15px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 并进抽屉的文档菜单:与上面的工具链接视觉上分段,并去掉菜单自带底色与右边框
(.el-menu 默认 border-right,竖排菜单是给页面侧栏用的,在抽屉里会多出一条竖线) */
.el-drawer__body .el-menu {
background-color: transparent;
border-right: none;
border-top: 1px solid var(--el-border-color-lighter);
padding-top: 8px;
}
/* 手机上条目多,行高压到 44px 让一屏能多看几项(默认 56px)。
文档标题可能很长(如「Windows 10/11 设置 IPv4/IPv6 访问优先级」),
截断会丢信息 → 允许换行,高度改 auto 只保 44px 最小行高。 */
/* 手机上条目多,行高压到 44px 让一屏能多看几项(默认 56px)。
注意:element-plus 竖排菜单规则是
`.el-menu--vertical:not(..)×3 .el-menu-item`(特异性 5 个类),
这里必须 !important 才压得住 height / line-height / white-space。
文档标题可能很长(如「Windows 10/11 设置 IPv4/IPv6 访问优先级」),
截断会丢信息 → 允许换行,高度改 auto 只保 44px 最小行高。 */
.el-drawer__body .el-menu .el-menu-item,
.el-drawer__body .el-menu .el-sub-menu__title {
height: auto !important;
min-height: 44px;
line-height: 1.45 !important;
padding-top: 6px;
padding-bottom: 6px;
white-space: normal !important; /* 长标题裸文本在 li 上,必须解掉 nowrap */
}
.el-drawer__body .el-menu .el-menu-item a,
.el-drawer__body .el-menu .el-menu-item p {
white-space: normal;
word-break: break-word;
}
/* 窄屏(≤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"],
@@ -315,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>
+111
View File
@@ -0,0 +1,111 @@
<script setup lang="ts">
/**
* 文档导航菜单(全站唯一实现,结构别在别处再抄一份)
*
* - 宽屏:由 /doc 与 /doc/** 页面直接渲染成左侧固定侧边栏(由页面传 class="sidebar-menu")
* - 窄屏:页面那边把侧边栏整个隐藏,改由根布局的左侧抽屉菜单挂同一份本组件,
* 这样手机上不需要两个菜单,也不至于没入口进文档。
*/
const route = useRoute()
const activeIndex = computed(() => route.path)
// 所有可展开分组的 index(含嵌套的「最佳实践」)。
// 抽屉里给展开态用:手机上没有 hover,分组默认收着的话用户还得先点开一层才能看到目的地。
const ALL_GROUPS = [
'IPv6 用户端',
'最佳实践',
'云服务器配置 IPv6',
'IPv6 协议规范',
'API 接口',
]
const props = withDefaults(defineProps<{
/** true = 所有分组初始展开(抽屉用);false = 保持 element-plus 默认的收起态(页面侧栏用) */
expandAll?: boolean
}>(), { expandAll: false })
// undefined 让 element-plus 走自己的默认值,宽屏下行为与改造前完全一致
const openeds = computed(() => (props.expandAll ? ALL_GROUPS : undefined))
const emit = defineEmits<{ select: [index: string] }>()
</script>
<template>
<el-menu
:default-active="activeIndex"
:default-openeds="openeds"
router
@select="(index: string) => emit('select', index)"
>
<!-- 1. 独立菜单项 -->
<el-menu-item index="/doc">
<span>IPv6 工具箱使用文档</span>
</el-menu-item>
<!-- 2. IPv6 用户端 -->
<el-sub-menu index="IPv6 用户端">
<template #title>
<span>IPv6 用户端</span>
</template>
<el-menu-item index="/doc/user/enable_ipv6">个人宽带如何开启IPv6网络访问</el-menu-item>
<el-menu-item index="/doc/user/cmd_bash_disable_ipv6">命令行禁用/启用IPv6本地网络</el-menu-item>
<el-menu-item index="/doc/user/cmd_getip">命令行(curl)获取 IPv4 和 IPv6 地址</el-menu-item>
<el-menu-item index="/doc/user/view_ipv6_adress_url">浏览器访问 IPv6 地址</el-menu-item>
<el-menu-item index="/doc/user/ipv4_ipv6_prefix_precedence">Windows 10/11 设置 IPv4/IPv6 访问优先级</el-menu-item>
<el-menu-item index="/doc/user/ipv6_daohang">国内 IPv6 资源导航</el-menu-item>
<el-menu-item index="/doc/user/pure_ipv6_website">国内纯 IPv6 网站导航</el-menu-item>
<el-menu-item index="/doc/user/dns">全国各省 DNS 服务器列表</el-menu-item>
<el-menu-item index="/doc/user/ipv6_dns">IPv6 DNS 地址列表</el-menu-item>
<el-menu-item index="/doc/user/code_getip">Python/Go 获取 IPv4 和 IPv6 地址</el-menu-item>
<el-menu-item index="/doc/user/dns_dig">DNS 解析流程</el-menu-item>
<!-- 嵌套子菜单:最佳实践 -->
<el-sub-menu index="最佳实践">
<template #title>
<span>最佳实践</span>
</template>
<el-menu-item index="/doc/user/AliyunAuthorizeSecurityGroup">阿里云自动化添加安全组</el-menu-item>
<el-menu-item index="/doc/user/TencentCloudAddSecurityGroup">腾讯云自动化添加安全组</el-menu-item>
</el-sub-menu>
</el-sub-menu>
<!-- 3. 云服务器配置 IPv6 -->
<el-sub-menu index="云服务器配置 IPv6">
<template #title>
<span>云服务器配置 IPv6</span>
</template>
<el-menu-item index="/doc/server/website_enable_ipv6">网站开启 IPv6 的三种方式</el-menu-item>
<el-menu-item index="/doc/server/tencent_cloud_cvm_ipv6">腾讯云 cvm 开启 IPv6</el-menu-item>
<el-menu-item index="/doc/server/nginx_ipv6">Nginx 开启 IPv6</el-menu-item>
<el-menu-item index="/doc/server/ipv6webcheck">如何确认一个网站是否开启 IPv6</el-menu-item>
<el-menu-item index="/doc/server/ipv6_sign">网站增加支持IPv6访问标识</el-menu-item>
<el-menu-item index="/doc/server/ipv6_domain_record">如何为域名添加 IPv6 解析记录</el-menu-item>
<el-menu-item index="/doc/server/http2">网站如何开启 HTTP/2</el-menu-item>
<el-menu-item index="/doc/server/check_userip_ipv4_ipv6">JS 检查网络是 IPv4 还是 IPv6</el-menu-item>
<el-menu-item index="/doc/server/tls">网站如何开启 TLS</el-menu-item>
</el-sub-menu>
<!-- 4. IPv6 协议规范 -->
<el-sub-menu index="IPv6 协议规范">
<template #title>
<span>IPv6 协议规范</span>
</template>
<el-menu-item index="/doc/rfc/rfc8200">IPv6 RFC8200 解读</el-menu-item>
<el-menu-item index="/doc/rfc/ipv6_address_format">IPv6 地址标识方法</el-menu-item>
<el-menu-item index="/doc/user/tcpdump_ipv6">tcpdump 分析IPv6包</el-menu-item>
<el-menu-item index="/doc/user/wireshark_ipv6">WireShark 分析IPv6包头</el-menu-item>
<el-menu-item index="/doc/user/ipv6_ping">IPv6 Ping 原理</el-menu-item>
</el-sub-menu>
<!-- 5. API 接口 -->
<el-sub-menu index="API 接口">
<template #title>
<span>API 接口</span>
</template>
<el-menu-item index="/doc/api/ip/locate">获取客户端公网 IP 及位置</el-menu-item>
<el-menu-item index="/doc/api/ip/myip">获取客户端公网 IP 地址</el-menu-item>
<el-menu-item index="/doc/api/ip/query">查询指定 IP 地址的位置信息</el-menu-item>
<el-menu-item index="/doc/api/whois/query">查询域名的 WHOIS 信息</el-menu-item>
</el-sub-menu>
</el-menu>
</template>
@@ -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;
+38 -17
View File
@@ -8,12 +8,12 @@ import { isIPv6 } from 'is-ip'
const route = useRoute()
useHead({
title: 'ASN查询 | IP自治系统号查询 | 柠檬味ipw.cn',
title: `ASN查询 | IP自治系统号查询 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP双数据源对比,同时集成WHOIS解析获取ASN详细信息,包括组织名称、国家、注册日期等,助力网络运维和路由分析' },
{ name: 'description', content: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP、IP2Location 多数据源对比,同时集成WHOIS解析获取ASN详细信息,包括组织名称、国家、注册日期等,助力网络运维和路由分析' },
{ name: 'keywords', content: 'asn查询,自治系统号,asn lookup,ip asn,asn whois,网络自治系统,运营商asn,ip归属asn' },
{ property: 'og:title', content: 'ASN自治系统查询工具 - IP ASN号与组织信息查询' },
{ property: 'og:description', content: '多数据源ASN查询,支持Maxmind GEOLite2和DB-IP,集成WHOIS解析获取ASN详细信息' },
{ property: 'og:description', content: '多数据源ASN查询,支持Maxmind GEOLite2、DB-IP、IP2Location,集成WHOIS解析获取ASN详细信息' },
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
{ property: 'og:type', content: 'website' },
],
@@ -24,7 +24,7 @@ useHead({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'ASN自治系统查询工具',
description: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP双数据源对比,集成WHOIS解析。',
description: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP、IP2Location 多数据源对比,集成WHOIS解析。',
url: config.siteUrl + 'asn',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
@@ -35,7 +35,7 @@ useHead({
},
provider: {
'@type': 'Organization',
name: '柠檬味ipw.cn'
name: config.siteName
}
})
}
@@ -44,8 +44,10 @@ useHead({
interface ASNResult {
ip: string
lemondb_asn?: { asn: string; org: string }
geolite2_asn?: { asn: string; org: string }
dbip_asn?: { asn: string; org: string }
ip2location_asn?: { asn: string; as: string }
whois?: any
}
@@ -55,12 +57,17 @@ const result = ref<ASNResult | null>(null)
const error = ref('')
const userIP = ref('')
const apiList = config.IPLocationAPIs
// ASN 页节点池:DualStack ∩ IPLocationAPI 的交集(按 id 取两个池都有的节点)。
// 交集保证:节点在 IPLocationAPI 里(中间件 asn 分支按它解析转发),
// 同时也是双栈池在册的全功能节点;任一池单独加节点都不会进本页。
const apiList = config.IPLocationAPI.filter((loc) =>
config.APIBaseURL.DualStack.some((d) => d.id === loc.id)
)
const currentApiIndex = ref(0)
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
const { data: asnData, error: asnError, execute: executeASN } = useMiddlewareFetch<ASNResult>(() => '/middleware/' + backendID.value + '/asn/' + tmpIP.value, {
const { data: asnData, error: asnError, execute: executeASN } = useMiddlewareFetch<ASNResult>(() => '/middleware/' + backendID.value + '/asn/' + encodeURIComponent(tmpIP.value), {
key: 'asn-fetch',
immediate: false,
watch: false,
@@ -118,11 +125,11 @@ onMounted(() => {
})
async function getUserIP() {
await $fetch<string>(config.DualStackAPI).then(
function (data) {
userIP.value = data
}
)
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
} catch {
// 获取失败保持为空,避免未处理的 Promise 拒绝
}
return userIP.value
}
@@ -144,6 +151,7 @@ function getStatusClass(status: string): string {
<el-input
v-model="tmpIP"
placeholder="请输入IP地址(如:1.1.1.1)"
clearable
/>
<el-button
@click="queryASN()"
@@ -171,20 +179,34 @@ function getStatusClass(status: string): string {
<td class="table-label">IP 地址</td>
<td class="table-value">{{ result.ip }}</td>
</tr>
<tr v-if="result.lemondb_asn?.asn">
<td class="table-label">LemonDB ASN</td>
<td class="table-value">
{{ result.lemondb_asn.asn }}
<span v-if="result.lemondb_asn.org" style="color: #999; font-size: 0.9em;">{{ result.lemondb_asn.org }}</span>
</td>
</tr>
<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>
<tr v-if="result.ip2location_asn?.asn">
<td class="table-label">IP2Location ASN</td>
<td class="table-value">
AS{{ result.ip2location_asn.asn }}
<span v-if="result.ip2location_asn.as" style="color: #999; font-size: 0.9em;">{{ result.ip2location_asn.as }}</span>
</td>
</tr>
<tr v-if="result.whois?.asNumber">
<td class="table-label">ASN</td>
<td class="table-value">AS{{ result.whois.asNumber }}</td>
@@ -226,7 +248,6 @@ function getStatusClass(status: string): string {
</div>
<blockquote>
<a href="/doc/user/asn" target="_blank">ASN 查询原理介绍</a><br/>
ASN(Autonomous System Number)是互联网中每个自治系统的唯一标识符。<br/>
数据来源:Maxmind GEOLite2、DB-IP,部分节点集成 WHOIS 进一步解析 ASN 详情。<br/>
<a href="/location" target="_blank">IP 归属地查询</a> | <a href="/whois" target="_blank">Whois 查询</a><br/>
@@ -254,10 +275,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;
+88 -26
View File
@@ -4,11 +4,12 @@ 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({
title: 'DNS查询工具 | 多节点域名解析检测 | 柠檬味ipw.cn',
title: `DNS查询工具 | 多节点域名解析检测 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的多节点DNS查询工具,支持A记录、AAAA记录、CNAME记录、MX记录、NS记录、TXT记录、SRV记录、CAA记录等多种DNS解析记录查询,提供全国多节点并发检测,快速返回DNS解析结果,助力域名解析问题排查与优化' },
{ name: 'keywords', content: 'dns查询,dns解析,域名解析,a记录查询,aaaa记录,cname记录,mx记录,ns记录,txt记录,srv记录,dns服务器,域名dns检测' },
@@ -35,7 +36,7 @@ useHead({
},
provider: {
'@type': 'Organization',
name: '柠檬味ipw.cn'
name: config.siteName
}
})
}
@@ -50,13 +51,15 @@ const results = ref<any>([])
const isloading = ref(false)
const nowRecordType = ref('')
const userIP = ref('')
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
async function getUserIP(){
await $fetch<string>(config.DualStackAPI).then(
function (data){
userIP.value = data
})
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
} catch {
// 获取失败保持为空,避免未处理的 Promise 拒绝
}
return userIP.value
}
@@ -72,8 +75,15 @@ const recordTypes = [
{ value: 'ptr', label: 'PTR 记录' }
]
const dnsServerFetches = config.NSLookup.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/dns/" + recordType.value + "/" + domain.value);
// 高级选项: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(() => withQuery("/middleware/" + server.id + "/dns/" + recordType.value + "/" + encodeURIComponent(domain.value), adv.value?.query() ?? {}));
const { data, error, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
@@ -85,7 +95,9 @@ async function queryDNS() {
isloading.value = true
domain.value = tmpDomain.value
await nextTick()
const id = ++queryId
// 初始化结果数组,保持响应式结构
results.value = dnsServerFetches.map(fetch => ({
server: fetch.label,
@@ -97,7 +109,20 @@ async function queryDNS() {
const promises = dnsServerFetches.map(async (fetch, index) => {
try {
await fetch.execute();
if (id !== queryId) return null
// execute() 不会抛错,全部候选失败时以 error.value 为准(否则故障节点渲染成空白)
if (fetch.error.value) {
results.value[index] = {
server: fetch.label,
loading: false,
data: null,
error: fetch.error.value
};
return { server: fetch.label, error: fetch.error.value };
}
// 直接更新对应索引的结果,保持响应式
results.value[index] = {
server: fetch.label,
@@ -105,12 +130,13 @@ async function queryDNS() {
data: fetch.data.value,
error: null
};
return {
server: fetch.label,
data: fetch.data.value
};
} catch (err) {
if (id !== queryId) return null
// 错误时也更新对应索引
results.value[index] = {
server: fetch.label,
@@ -118,7 +144,7 @@ async function queryDNS() {
data: null,
error: err
};
return {
server: fetch.label,
error: err
@@ -126,14 +152,25 @@ async function queryDNS() {
}
});
nowRecordType.value = recordType.value
const promiseResults = await Promise.all(promises)
console.log(promiseResults)
isloading.value = false
const promiseResults = (await Promise.all(promises)).filter(Boolean)
if (id === queryId) {
isloading.value = false
}
return promiseResults
}
// 行内「数据格是否该渲染」判定。
//
// 结果表共 6 列(服务器 / 类型 / 记录 / 记录数 / 耗时 / TTL);后三列(记录数、耗时、TTL)
// 只在真正拿到结果时才有意义。加载中与失败两种状态由「记录」那格用 colspan=4 横跨到表尾,
// 若此时再渲染后三格,整行就会变成 2+4+3=9 格 —— 表格按最大列数排版,凭空多出 3 列,
// 表头只剩 6 列、右侧留出空档,错误行也跟着错位。
function hasData(result: any) {
return !result?.loading && !result?.error
}
onMounted(() => {
const domainParam = route.query.domain as string
@@ -166,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"
@@ -185,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>
@@ -219,9 +261,11 @@ const doc = page.value;
</span>
</td>
<td class="table-value" v-if="!result.loading">{{result.data?.record?.length || 0}}</td>
<td class="table-value" v-if="!result.loading">{{formatTime(result.data?.duration)}}</td>
<td class="table-value" v-if="!result.loading">{{result.data?.ttl}}</td>
<!-- 后三格必须与「记录」格的 colspan 互斥:加载中/失败时该格已跨满到表尾,
这里再渲染就会多出 3 列(见 hasData 注释) -->
<td class="table-value" v-if="hasData(result)">{{result.data?.record?.length || 0}}</td>
<td class="table-value" v-if="hasData(result)">{{formatTime(result.data?.duration)}}</td>
<td class="table-value" v-if="hasData(result)">{{result.data?.ttl}}</td>
</tr>
</tbody>
@@ -257,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
}
@@ -270,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 {
+63 -19
View File
@@ -9,7 +9,7 @@ import { isIPv6 } from 'is-ip'
const route = useRoute()
useHead({
title: 'DNSSEC查询 | 域名DNSSEC验证检测 | 柠檬味ipw.cn',
title: `DNSSEC查询 | 域名DNSSEC验证检测 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的DNSSEC查询工具,支持域名DNSSEC验证检测,检查域名是否启用DNSSEC签名,验证DNSKEY、RRSIG、DS记录链式信任关系,检测域名DNS数据完整性和真实性,防止DNS欺骗和中间人攻击,保障域名解析安全' },
{ name: 'keywords', content: 'dnssec查询,dnssec验证,dnskey,rrsig,ds记录,域名安全,链式信任,dns欺骗防护,域名解析安全,dns签名验证' },
@@ -36,7 +36,7 @@ useHead({
},
provider: {
'@type': 'Organization',
name: '柠檬味ipw.cn'
name: config.siteName
}
})
}
@@ -68,11 +68,17 @@ const tmpDomain = ref('www.zakoflare.com')
const loading = ref(false)
const serverResults = ref<ServerResult[]>([])
const userIP = ref('')
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
const apiList = config.NSLookup
const apiList = [
...config.APIBaseURL.DualStack,
...config.APIBaseURL.IPv4,
...config.APIBaseURL.IPv6
]
const dnssecFetches = apiList.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/dnssec/" + tmpDomain.value)
const url = computed(() => "/middleware/" + server.id + "/dnssec/" + encodeURIComponent(tmpDomain.value.trim()))
const { data, error, execute } = useMiddlewareFetch<DNSSECResult>(url, {
immediate: false,
watch: false,
@@ -93,6 +99,8 @@ async function checkDNSSEC() {
loading.value = true
const id = ++queryId
serverResults.value.forEach((result) => {
result.loading = true
result.data = undefined
@@ -101,9 +109,15 @@ async function checkDNSSEC() {
const promises = dnssecFetches.map(async (fetch, index) => {
try {
await fetch.execute()
if (id !== queryId) return
const result = serverResults.value[index]
if (result) {
result.data = fetch.data.value
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成 '-')
if (fetch.error.value) {
result.error = (fetch.error.value as any)?.message || '请求失败'
} else {
result.data = fetch.data.value
}
}
} catch (err) {
console.error(err)
@@ -113,14 +127,16 @@ async function checkDNSSEC() {
}
} finally {
const result = serverResults.value[index]
if (result) {
if (result && id === queryId) {
result.loading = false
}
}
})
Promise.allSettled(promises).finally(function () {
loading.value = false
if (id === queryId) {
loading.value = false
}
})
}
@@ -130,8 +146,8 @@ function getValidationClass(result: DNSSECResult): string {
return 'status-error'
}
function formatDuration(ms: number): string {
return ms.toFixed(2) + ' ms'
function formatDuration(ms?: number): string {
return typeof ms === 'number' && isFinite(ms) ? ms.toFixed(2) + ' ms' : '-'
}
onMounted(() => {
@@ -145,13 +161,18 @@ onMounted(() => {
})
async function getUserIP() {
await $fetch<string>(config.DualStackAPI).then(
function (data) {
userIP.value = data
}
)
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
} catch {
// 获取失败保持为空,避免未处理的 Promise 拒绝
}
return userIP.value
}
const { data: page, error } = await useAsyncData('dnssec-page', () =>
$fetch('/api/markdown/dnssec')
)
const doc = page.value;
</script>
<template>
@@ -166,6 +187,7 @@ async function getUserIP() {
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:example.com)"
clearable
/>
<el-button
@click="checkDNSSEC()"
@@ -239,10 +261,12 @@ async function getUserIP() {
</table>
</div>
<div class="markdown">
<div v-html="doc"></div>
</div>
<blockquote>
<a href="/doc/user/dnssec" target="_blank">DNSSEC 原理介绍</a><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>
@@ -253,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;
}
@@ -268,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;
+38 -68
View File
@@ -1,85 +1,53 @@
<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();
const path = computed(() => route.path)
const { data: page } = await useFetch(() => `/api/markdown${path.value}`)
const activeIndex = computed(() => route.path)
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>
<template>
<div class="box">
<el-menu
:default-active="activeIndex"
class="sidebar-menu"
router
>
<!-- 1. 独立菜单项 -->
<el-menu-item index="/doc">
<span>IPv6 工具箱使用文档</span>
</el-menu-item>
<!-- 2. IPv6 用户端 -->
<el-sub-menu index="IPv6 用户端">
<template #title>
<span>IPv6 用户端</span>
</template>
<el-menu-item index="/doc/user/enable_ipv6">个人宽带如何开启IPv6网络访问</el-menu-item>
<el-menu-item index="/doc/user/cmd_bash_disable_ipv6">命令行禁用/启用IPv6本地网络</el-menu-item>
<el-menu-item index="/doc/user/cmd_getip">命令行(curl)获取 IPv4 和 IPv6 地址</el-menu-item>
<el-menu-item index="/doc/user/view_ipv6_adress_url">浏览器访问 IPv6 地址</el-menu-item>
<el-menu-item index="/doc/user/ipv4_ipv6_prefix_precedence">Windows 10/11 设置 IPv4/IPv6 访问优先级</el-menu-item>
<el-menu-item index="/doc/user/ipv6_daohang">国内 IPv6 资源导航</el-menu-item>
<el-menu-item index="/doc/user/pure_ipv6_website">国内纯 IPv6 网站导航</el-menu-item>
<el-menu-item index="/doc/user/dns">全国各省 DNS 服务器列表</el-menu-item>
<el-menu-item index="/doc/user/ipv6_dns">IPv6 DNS 地址列表</el-menu-item>
<!-- 嵌套子菜单:最佳实践 -->
<el-sub-menu index="最佳实践">
<template #title>
<span>最佳实践</span>
</template>
<el-menu-item index="/doc/user/AliyunAuthorizeSecurityGroup">阿里云自动化添加安全组</el-menu-item>
<el-menu-item index="/doc/user/TencentCloudAddSecurityGroup">腾讯云自动化添加安全组</el-menu-item>
</el-sub-menu>
</el-sub-menu>
<!-- 3. 云服务器配置 IPv6 -->
<el-sub-menu index="云服务器配置 IPv6">
<template #title>
<span>云服务器配置 IPv6</span>
</template>
<el-menu-item index="/doc/server/website_enable_ipv6">网站开启 IPv6 的三种方式</el-menu-item>
<el-menu-item index="/doc/server/tencent_cloud_cvm_ipv6">腾讯云 cvm 开启 IPv6</el-menu-item>
<el-menu-item index="/doc/server/nginx_ipv6">Nginx 开启 IPv6</el-menu-item>
<el-menu-item index="/doc/server/ipv6webcheck">如何确认一个网站是否开启 IPv6</el-menu-item>
<el-menu-item index="/doc/server/ipv6_sign">网站增加支持IPv6访问标识</el-menu-item>
<el-menu-item index="/doc/server/ipv6_domain_record">如何为域名添加 IPv6 解析记录</el-menu-item>
<el-menu-item index="/doc/server/http2">网站如何开启 HTTP/2</el-menu-item>
</el-sub-menu>
<!-- 4. IPv6 协议规范 -->
<el-sub-menu index="IPv6 协议规范">
<template #title>
<span>IPv6 协议规范</span>
</template>
<el-menu-item index="/doc/rfc/rfc8200">IPv6 RFC8200 解读</el-menu-item>
<el-menu-item index="/doc/rfc/ipv6_address_format">IPv6 地址标识方法</el-menu-item>
<el-menu-item index="/doc/user/tcpdump_ipv6">tcpdump 分析IPv6包</el-menu-item>
<el-menu-item index="/doc/user/wireshark_ipv6">WireShark 分析IPv6包头</el-menu-item>
<el-menu-item index="/doc/user/ipv6_ping">IPv6 Ping 原理</el-menu-item>
</el-sub-menu>
</el-menu>
<!-- 文档导航只有一份实现(app/components/DocMenu.vue)。
宽屏:就是这条左侧固定侧栏;窄屏:本侧栏隐藏,改由顶部抽屉菜单承载 -->
<DocMenu v-if="!isNarrow" class="sidebar-menu" />
<div class="content">
<div class="markdown-body" v-if="page">
<div v-html="page" />
@@ -119,8 +87,10 @@ html.dark .content {
}
@media (max-width: 768px) {
/* 窄屏不再挤一条窄侧栏(原先 100px,长标题全被截断):
文档导航已并入左侧抽屉菜单,这里直接撤掉,正文铺满整屏 */
.sidebar-menu {
width: 100px;
display: none;
}
.content {
padding: 10px;
@@ -129,5 +99,5 @@ html.dark .content {
</style>
<style>
@import "github-markdown-css/github-markdown-light.css";
@import "/github-markdown-dark.css";
@import "../../github-markdown-dark.css";
</style>
+39 -69
View File
@@ -1,86 +1,54 @@
<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 route = useRoute();
const { data: page } = await useAsyncData('doc-index', () =>
$fetch('/api/markdown/doc/index')
)
const doc = page.value;
const activeIndex = computed(() => route.path)
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>
<template>
<div class="box">
<el-menu
:default-active="activeIndex"
class="sidebar-menu"
router
>
<!-- 1. 独立菜单项 -->
<el-menu-item index="/doc">
<span>IPv6 工具箱使用文档</span>
</el-menu-item>
<!-- 2. IPv6 用户端 -->
<el-sub-menu index="IPv6 用户端">
<template #title>
<span>IPv6 用户端</span>
</template>
<el-menu-item index="/doc/user/enable_ipv6">个人宽带如何开启IPv6网络访问</el-menu-item>
<el-menu-item index="/doc/user/cmd_bash_disable_ipv6">命令行禁用/启用IPv6本地网络</el-menu-item>
<el-menu-item index="/doc/user/cmd_getip">命令行(curl)获取 IPv4 和 IPv6 地址</el-menu-item>
<el-menu-item index="/doc/user/view_ipv6_adress_url">浏览器访问 IPv6 地址</el-menu-item>
<el-menu-item index="/doc/user/ipv4_ipv6_prefix_precedence">Windows 10/11 设置 IPv4/IPv6 访问优先级</el-menu-item>
<el-menu-item index="/doc/user/ipv6_daohang">国内 IPv6 资源导航</el-menu-item>
<el-menu-item index="/doc/user/pure_ipv6_website">国内纯 IPv6 网站导航</el-menu-item>
<el-menu-item index="/doc/user/dns">全国各省 DNS 服务器列表</el-menu-item>
<el-menu-item index="/doc/user/ipv6_dns">IPv6 DNS 地址列表</el-menu-item>
<!-- 嵌套子菜单:最佳实践 -->
<el-sub-menu index="最佳实践">
<template #title>
<span>最佳实践</span>
</template>
<el-menu-item index="/doc/user/AliyunAuthorizeSecurityGroup">阿里云自动化添加安全组</el-menu-item>
<el-menu-item index="/doc/user/TencentCloudAddSecurityGroup">腾讯云自动化添加安全组</el-menu-item>
</el-sub-menu>
</el-sub-menu>
<!-- 3. 云服务器配置 IPv6 -->
<el-sub-menu index="云服务器配置 IPv6">
<template #title>
<span>云服务器配置 IPv6</span>
</template>
<el-menu-item index="/doc/server/website_enable_ipv6">网站开启 IPv6 的三种方式</el-menu-item>
<el-menu-item index="/doc/server/tencent_cloud_cvm_ipv6">腾讯云 cvm 开启 IPv6</el-menu-item>
<el-menu-item index="/doc/server/nginx_ipv6">Nginx 开启 IPv6</el-menu-item>
<el-menu-item index="/doc/server/ipv6webcheck">如何确认一个网站是否开启 IPv6</el-menu-item>
<el-menu-item index="/doc/server/ipv6_sign">网站增加支持IPv6访问标识</el-menu-item>
<el-menu-item index="/doc/server/ipv6_domain_record">如何为域名添加 IPv6 解析记录</el-menu-item>
<el-menu-item index="/doc/server/http2">网站如何开启 HTTP/2</el-menu-item>
</el-sub-menu>
<!-- 4. IPv6 协议规范 -->
<el-sub-menu index="IPv6 协议规范">
<template #title>
<span>IPv6 协议规范</span>
</template>
<el-menu-item index="/doc/rfc/rfc8200">IPv6 RFC8200 解读</el-menu-item>
<el-menu-item index="/doc/rfc/ipv6_address_format">IPv6 地址标识方法</el-menu-item>
<el-menu-item index="/doc/user/tcpdump_ipv6">tcpdump 分析IPv6包</el-menu-item>
<el-menu-item index="/doc/user/wireshark_ipv6">WireShark 分析IPv6包头</el-menu-item>
<el-menu-item index="/doc/user/ipv6_ping">IPv6 Ping 原理</el-menu-item>
</el-sub-menu>
</el-menu>
<!-- 文档导航只有一份实现(app/components/DocMenu.vue)。
宽屏:就是这条左侧固定侧栏;窄屏:本侧栏隐藏,改由顶部抽屉菜单承载 -->
<DocMenu v-if="!isNarrow" class="sidebar-menu" />
<div class="content">
<div class="markdown-body" v-html="doc"></div>
</div>
@@ -118,8 +86,10 @@ html.dark .content {
}
@media (max-width: 768px) {
/* 窄屏不再挤一条窄侧栏(原先 100px,长标题全被截断):
文档导航已并入左侧抽屉菜单,这里直接撤掉,正文铺满整屏 */
.sidebar-menu {
width: 100px;
display: none;
}
.content {
padding: 10px;
@@ -128,7 +98,7 @@ html.dark .content {
</style>
<style>
@import "github-markdown-css/github-markdown-light.css";
@import "/github-markdown-dark.css";
@import "../../github-markdown-dark.css";
@import "/style.css"
</style>
+162 -42
View File
@@ -3,36 +3,58 @@ import { computed, ref, onMounted } from 'vue';
import { isIPv6 } from 'is-ip';
import { config } from '../../config/index';
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
import { highlightCode } from '../../utils/shiki';
import { isIPv4 } from '../../utils/tools';
const route = useRoute();
const canonicalUrl = computed(() => new URL(route.path, config.siteUrl).toString());
// canonical 由 app.vue 全局下发(见下方 useHead 注释)。
useHead({
title: '柠檬味ipw.cn | IP查询工具 | IPv4/IPv6地址查询与网络测试平台',
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: '柠檬味ipw.cn提供专业的IP查询服务,支持IPv4和IPv6地址在线查询、归属地定位、网络测速、DNS解析、SSL证书检测、TCPing测试等多种网络工具,致力于推进IPv6规模部署和应用,打造去中心化的IP查询平台' },
{ 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优先' },
{ property: 'og:title', content: '柠檬味ipw.cn - 专业IP查询与网络测试工具平台' },
{ property: 'og:title', content: `${config.siteName} - 专业IP查询与网络测试工具平台` },
{ 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({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: '柠檬味ipw.cn',
description: '柠檬味ipw.cn提供专业的IP查询服务,支持IPv4和IPv6地址在线查询、归属地定位、网络测速、DNS解析、SSL证书检测、TCPing测试等多种网络工具,致力于推进IPv6规模部署和应用,打造去中心化的IP查询平台',
url: canonicalUrl.value,
name: config.siteName,
description: `${config.siteName}提供专业的IP查询服务,支持IPv4和IPv6地址在线查询、归属地定位、网络测速、DNS解析、SSL证书检测、TCPing测试等多种网络工具,致力于推进IPv6规模部署和应用,打造去中心化的IP查询平台`,
url: new URL(route.path, config.siteUrl).toString(),
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Any',
offers: {
@@ -75,33 +97,90 @@ 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(async () => {
try {
highlightedCode.value = await highlightCode(code, 'bash');
} catch {
highlightedCode.value = '';
}
onMounted(() => {
// (代码高亮已移到 setup 顶部的 useAsyncData,随 SSR 一起产出。)
const [dualStack, ipV4, ipV6] = await Promise.allSettled([
$fetch<string>(config.DualStackAPI),
$fetch<string>(config.v4OnlyAPI),
$fetch<string>(config.v6OnlyAPI)
]);
// —— 三个 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。
const ipFetchOptions = { retry: false } as const;
const probeStore = (window as any).__ipProbe as Record<string, string> | undefined;
if (dualStack.status === 'fulfilled') {
ipAddress.value = dualStack.value;
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 (ipV4.status === 'fulfilled') {
yourIPv4.value = ipV4.value;
if (!consumeProbe('v4', (ip) => { yourIPv4.value = ip; })) {
$fetch<string>(config.v4OnlyAPI, ipFetchOptions)
.then((ip) => {
yourIPv4.value = ip;
})
.catch(() => {});
}
if (ipV6.status === 'fulfilled') {
yourIPv6.value = ipV6.value;
if (!consumeProbe('v6', (ip) => { yourIPv6.value = ip; })) {
$fetch<string>(config.v6OnlyAPI, ipFetchOptions)
.then((ip) => {
yourIPv6.value = ip;
})
.catch(() => {});
}
});
</script>
@@ -115,16 +194,16 @@ onMounted(async () => {
</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 style="font-size: 1.5em;">
<h3 v-if="(ipAddress && isIPv6(ipAddress)) || isIPv6(yourIPv6)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h3>
<h3 v-else-if="(ipAddress && isIPv4(ipAddress)) || isIPv6(yourIPv6)"><el-icon><CircleCloseFilled style="color: red;"/></el-icon>您的网络IPv4优先</h3>
<h3 v-else><el-icon><CircleCloseFilled /></el-icon>查询中,请稍后</h3>
<div class="ip-priority">
<h2 v-if="ipAddress && isIPv6(ipAddress)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h2>
<h2 v-else-if="ipAddress && isIPv4(ipAddress)"><el-icon><CircleCloseFilled style="color: red;"/></el-icon>您的网络IPv4优先</h2>
<h2 v-else><el-icon><CircleCloseFilled /></el-icon>查询中,请稍后</h2>
</div>
<blockquote>
手机默认开启 IPv6,宽带开启 IPv6 请参阅<a href="/doc/user/enable_ipv6" target="_blank">文档</a>
@@ -141,6 +220,31 @@ onMounted(async () => {
margin-right: auto;
}
/* 网络优先级提示:原来是「div 1.5em × h3」,但 h1 之后直跳 h3 违反标题层级(heading-order),
语义上应为 h2。style.css 给 h3 显式定了 1.3em / 窄屏 1.1em,h2 只有 1.5em 一档,
所以这里把字号按原样补回,避免语义修正顺带改了视觉(含 margin:UA 的 h3 是 1em,h2 是 0.83em)。 */
.ip-priority {
font-size: 1.5em;
}
.ip-priority h2 {
font-size: 1.3em;
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;
@@ -159,14 +263,30 @@ onMounted(async () => {
}
@media (max-width: 768px) {
.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>
-396
View File
@@ -1,396 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, computed, nextTick, watch } from 'vue';
import { isIPv6 } from 'is-ip';
import {config} from '../../config/index';
import { useMiddlewareFetch } from '../../utils/middleware';
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
import { useRoute } from 'vue-router'
import { highlightCode } from '../../utils/shiki'
import { isIPv4 } from '../../utils/tools';
const route = useRoute();
const loading = ref(false);
useHead({
title: 'IPv6/IPv4地址查询工具 | IP归属地定位 | 柠檬味ipw.cn',
meta: [
{ name: 'description', content: '专业的IPv6/IPv4地址查询工具,支持IPv4和IPv6地址归属地查询,提供BiliBili Live、GeoCN、IP2Region、Maxmind等多种数据源对比,精确定位IP地理位置、运营商信息,支持中国大陆及境外地址查询,助力IPv6普及与应用' },
{ property: 'og:title', content: 'IPv6/IPv4地址归属地查询工具 - 柠檬味ipw.cn' },
{ property: 'og:description', content: '多数据源IP地址归属地查询,支持IPv4和IPv6,提供地理位置、运营商等详细信息' },
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
{ property: 'og:type', content: 'website' },
],
});
interface IPLocationType {
bilibili?: any;
ip?: string;
geocn?: any;
ip2region?: any;
maxmind_asn?: any;
maxmind_city?: any;
qqwry?: any;
[key: string]: any;
}
const code = `
# 请勿用于商业用途,仅供个人测试学习之用,请遵守中国法律法规
# 查询本机外网 IPv4 地址
curl ${config.v4OnlyAPI}
# 查询本机外网 IPv6 地址
curl ${config.v6OnlyAPI}
# 测试网络是 IPv4 还是 IPv6 访问优先
# (访问 IPv4/IPv6 双栈站点,如果返回 IPv6 地址,则 IPv6 访问优先)
curl ${config.DualStackAPI}
`.trim();
const html = ref('');
const apiList = config.IPLocationAPIs
const currentApiIndex = ref(0)
const backendID = computed(() => apiList[currentApiIndex.value]!.id)
const ipAddress = ref('');
const IPLocation = ref<IPLocationType>({});
const UserIP = ref('');
const locationUrl = computed(() => "/middleware/" + backendID.value + "/location/" + ipAddress.value);
const { data: locationData, error: locationError, execute: executeLocation } = useMiddlewareFetch<IPLocationType>(locationUrl, {
immediate: false,
watch: false,
});
watch(locationData, (newData) => {
if (newData) {
IPLocation.value = newData;
loading.value = false;
}
});
watch(locationError, (newError) => {
if (newError) {
console.error('Error fetching IP location:', newError);
loading.value = false;
}
});
function locateIP(IP: string){
ipAddress.value = IP;
loading.value = true;
nextTick(() => {
executeLocation();
});
}
onMounted(async () => {
const urlParam = route.query.ip;
await navigateTo({ path: '/location', query: { ip: urlParam } });
html.value = await highlightCode(code, 'bash')
if (urlParam) {
ipAddress.value = urlParam as string;
locateIP(urlParam as string);
}else{
$fetch<string>(config.DualStackAPI).then(
function(data) {
ipAddress.value = data;
UserIP.value = data;
}
).catch(
function(error) {
console.error('Error fetching IP address:', error);
}
);
}
});
</script>
<template>
<div class="title">
<header>
<h1>IPv6/IPv4地址归属地查询</h1>
<p>极简的IPv6地址查询工具,致力于普及 IPv6</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="ipAddress"
placeholder="请输入IP地址"
/>
<el-button
@click="locateIP(ipAddress)"
type="primary"
:loading="loading"
>
查询
</el-button>
</div>
<div class="location">
<div class="one-line" style="height: 40px;">
<b>IP</b>&nbsp<p>{{ ipAddress }}</p>
</div>
<div v-if="IPLocation" class="result-section">
<table class="result-table">
<tbody>
<tr v-if="IPLocation.bilibili && (IPLocation.bilibili.administrative_area || IPLocation.bilibili.city)">
<td class="table-label">bilibili Live接口</td>
<td class="table-value">
<span>
{{ IPLocation.bilibili?.country }}&nbsp;{{ IPLocation.bilibili?.administrative_area }}&nbsp;{{ IPLocation.bilibili?.city }}
</span>
</td>
<td class="table-value">
<span>
{{ IPLocation.bilibili?.isp }}
</span>
</td>
</tr>
<tr v-if="IPLocation.ip2region && IPLocation.ip2region.split('|').length >= 4">
<td class="table-label">IP2Region</td>
<td class="table-value">{{ IPLocation.ip2region?.split("|")[0] }}&nbsp;{{ IPLocation.ip2region?.split("|")[1] }}&nbsp;{{ IPLocation.ip2region?.split("|")[2] }}</td>
<td class="table-value">{{ IPLocation.ip2region?.split("|")[3] }}</td>
</tr>
<tr v-if="IPLocation.geocn && (IPLocation.geocn.administrative_area || IPLocation.geocn.city || IPLocation.geocn.district)">
<td class="table-label">GeoCN(仅中国大陆)</td>
<td class="table-value">{{ IPLocation.geocn?.administrative_area }}&nbsp;{{ IPLocation.geocn?.city }}&nbsp;{{ IPLocation.geocn?.district }}</td>
<td class="table-value">{{ IPLocation.geocn?.isp }}</td>
</tr>
<tr v-if="IPLocation.maxmind_city && IPLocation.maxmind_asn && (IPLocation.maxmind_city.country || IPLocation.maxmind_city.city)">
<td class="table-label">Maxmind GEOLite2 City</td>
<td class="table-value">{{ IPLocation.maxmind_city?.country }}&nbsp;{{ IPLocation.maxmind_city?.administrative_area }}&nbsp;{{ IPLocation.maxmind_city?.city }}</td>
<td class="table-value">{{ IPLocation.maxmind_asn?.org }}</td>
</tr>
<tr v-if="IPLocation.qqwry && (IPLocation.qqwry.country || IPLocation.qqwry.administrative_area || IPLocation.qqwry.city)">
<td class="table-label">纯真社区库</td>
<td class="table-value">{{ IPLocation.qqwry?.country }}&nbsp;{{ IPLocation.qqwry?.administrative_area }}&nbsp;{{ IPLocation.qqwry?.city }}</td>
<td class="table-value">{{ IPLocation.qqwry?.isp }}</td>
</tr>
<tr v-if="IPLocation.dbip_city && (IPLocation.dbip_city.administrative_area || IPLocation.dbip_city.city)">
<td class="table-label">DB-IP City</td>
<td class="table-value">{{ IPLocation.dbip_city?.country }}&nbsp;{{ IPLocation.dbip_city?.administrative_area }}&nbsp;{{ IPLocation.dbip_city?.city }}</td>
<td class="table-value">{{IPLocation.dbip_asn?.org || '--'}}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div style="font-size: 1.5em;">
<h3 v-if="isIPv6(UserIP)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h3>
<h3 v-else-if="isIPv4(UserIP)"><el-icon><CircleCloseFilled style="color: red;"/></el-icon>您的网络IPv4优先</h3>
</div>
<blockquote>
精度参照表:<br>
中国大陆:BiliBili Live > GeoCN > IP2Region > 纯真社区库 > Maxmind GEOLite2 City ≈ DB-IP<br>
中国大陆 IPv6 地址: BiliBili Live > GeoCN > IP2Region > Maxmind GEOLite2 City ≈ DB-IP > 纯真社区库<br>
境外及港澳台地址: Maxmind GEOLite2 City ≈ DB-IP > BiliBili Live > > IP2Region > 纯真社区库<br>
手机默认开启 IPv6,宽带开启 IPv6 请自行搜索<br>
访客IP: {{UserIP}},<p v-if="isIPv4(UserIP)">您的网络IPv4优先</p><p v-else-if="isIPv6(UserIP)">您的网络IPv6优先</p>
</blockquote>
<div v-html="html"></div>
</div>
</template>
<style scoped>
@import "../style.css";
@import "../../assets/css/tool-common.css";
</style>
<style>
@import "../style.css";
:root {
--el-color-primary: #3EAF7C;
}
.el-icon{
font-size: 1.3em;
}
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
.el-input {
width: 420px;
height: 50px;
font: 1.3em sans-serif;
margin-right: 10px;
}
.el-button {
width: 165px;
height: 50px;
font: 1.3em sans-serif;
}
.result-section {
margin-top: 30px;
}
.result-table {
width: 100%;
border-collapse: collapse;
background: #fff;
border: 1px solid #dcdfe6;
}
html.dark .result-table {
width: 100%;
border-collapse: collapse;
background: #2e2d2d;
border: 1px solid #2e2e2e;
}
.result-table thead tr {
background-color: #c0c4cc;
}
html.dark .result-table thead tr {
background: #2e2d2d;
}
.result-table .table-header {
padding: 12px 15px;
font-weight: 600;
color: #303133;
text-align: left;
border: 1px solid #dcdfe6;
}
html.dark .result-table .table-header {
padding: 12px 15px;
font-weight: 600;
color: #cfcfcf;
text-align: left;
border: 1px solid #1a1919;
}
.result-table tbody tr {
border-bottom: 1px solid #dcdfe6;
}
html.dark .result-table tbody tr {
border-bottom: 1px solid #1a1919;
}
.result-table tbody tr:last-child {
border-bottom: none;
}
.result-table tbody tr:hover {
background-color: #f5f7fa;
}
html.dark .result-table tbody tr:hover {
background-color: #393a3a;
}
.result-table .table-label {
padding: 12px 15px;
font-weight: 600;
color: #606266;
width: 150px;
text-align: left;
border: 1px solid #dcdfe6;
}
html.dark .result-table .table-label {
color: #c0c4cc;
border: 1px solid #1a1919;
}
.result-table .table-value {
padding: 12px 15px;
color: #303133;
border: 1px solid #dcdfe6;
}
html.dark .result-table .table-value {
color: #cfcfcf;
border: 1px solid #1a1919;
}
.valid {
color: #67C23A;
font-weight: 600;
}
.expired {
color: #F56C6C;
font-weight: 600;
}
.status-code {
font-weight: 600;
padding: 4px 12px;
border-radius: 4px;
}
.status-success {
color: #67C23A;
background: #f0f9eb;
}
.status-warning {
color: #E6A23C;
background: #fdf6ec;
}
.status-error {
color: #F56C6C;
background: #fef0f0;
}
.error-message {
margin-top: 20px;
padding: 15px;
background: #fef0f0;
color: #F56C6C;
border-radius: 6px;
text-align: center;
font-size: 1.1em;
}
pre {
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
overflow-x: auto;
white-space: pre;
max-width: 100%;
}
pre code {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.9em;
color: #303133;
}
html.dark pre {
background: #303133;
}
html.dark pre code {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
color: #f8f9fa;
}
.badge-section {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 20px;
}
.badge-item {
background: #fff;
padding: 20px;
border-radius: 8px;
border: 1px solid #e4e7ed;
}
.badge-item h4 {
margin: 0 0 15px 0;
color: #3EAF7C;
font-size: 1.2em;
}
.badge-item img {
display: block;
margin-bottom: 15px;
max-width: 200px;
}
</style>
+34 -13
View File
@@ -3,13 +3,14 @@ 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()
useHead({
title: 'IPv6网站测速工具 | 全国多节点并发测速 | 柠檬味ipw.cn',
title: `IPv6网站测速工具 | 全国多节点并发测速 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的IPv6网站测速工具,提供全国多节点并发测速服务,1秒内快速返回网站响应时间、下载速度、页面大小、DNS解析时间、HTTP连接时间等详细信息,支持IPv6网站性能检测与优化,助力IPv6网站性能监控与用户体验提升' },
{ name: 'keywords', content: 'ipv6网站测速,ipv6测速,网站速度测试,ipv6性能检测,网站响应时间,ipv6下载速度,ipv6性能优化,ipv6网站监控' },
@@ -36,7 +37,7 @@ useHead({
},
provider: {
'@type': 'Organization',
name: '柠檬味ipw.cn'
name: config.siteName
}
})
}
@@ -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 {
@@ -57,6 +60,9 @@ interface ServerResult {
data: any
}
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
/** 预构建的服务器列表,页面挂载即展示,用户可提前看到测速节点 */
const serverResults = ref<ServerResult[]>([])
@@ -68,8 +74,8 @@ const serverResults = ref<ServerResult[]>([])
// =============================================
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + domain.value);
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
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,
@@ -78,8 +84,8 @@ const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
});
/** IPv6 专用服务器:仅支持 IPv6 的节点 */
const ipv6Fetches = config.SpeedTest.IPv6.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + domain.value);
const ipv6Fetches = config.APIBaseURL.IPv6.map((server) => {
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,
@@ -116,6 +122,8 @@ async function SpeedTest() {
error.value = ''
await nextTick()
const id = ++queryId
// 重置所有行状态为加载中
serverResults.value.forEach((row) => {
row.loading = true
@@ -130,16 +138,26 @@ async function SpeedTest() {
try {
await fetch.execute();
row.data = fetch.data.value;
if (id !== queryId) return
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成空白)
if (fetch.error.value) {
row.error = fetch.error.value?.data?.error || fetch.error.value?.message || '请求失败';
} else {
row.data = fetch.data.value;
}
} catch (err: any) {
row.error = err?.data?.error || err?.message || '请求失败';
} finally {
row.loading = false;
if (id === queryId) {
row.loading = false;
}
}
});
Promise.all(promises).finally(() => {
loading.value = false
if (id === queryId) {
loading.value = false
}
})
}
@@ -165,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()"
@@ -176,6 +195,8 @@ onMounted(() => {
</el-button>
</div>
<AdvancedOptions ref="adv" stack="ipv6" />
<div v-if="error" class="error-message">
{{ error }}
</div>
@@ -257,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;
+63 -30
View File
@@ -4,11 +4,12 @@ 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({
title: 'IPv6 TCPing测试工具 | IPv6服务器连通性检测 | 柠檬味ipw.cn',
title: `IPv6 TCPing测试工具 | IPv6服务器连通性检测 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的IPv6 TCPing测试工具,提供多节点IPv6 TCP连通性检测服务,支持自定义端口测试,实时检测IPv6服务器丢包率、平均延迟、最大最小响应时间,助力IPv6网络质量诊断与优化,推进IPv6规模部署和应用' },
{ name: 'keywords', content: 'ipv6 tcping测试,ipv6连通性检测,ipv6服务器延迟,ipv6丢包率,ipv6端口测试,ipv6网络质量,ipv6服务器测试,ipv6网络诊断' },
@@ -35,7 +36,7 @@ useHead({
},
provider: {
'@type': 'Organization',
name: '柠檬味ipw.cn'
name: config.siteName
}
})
}
@@ -77,14 +78,18 @@ 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
const allServers = [
...config.TCPing.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
...config.TCPing.IPv6.map((s: ServerConfig) => ({ ...s, type: 'IPv6' }))
...config.APIBaseURL.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
...config.APIBaseURL.IPv6.map((s: ServerConfig) => ({ ...s, type: 'IPv6' }))
];
const tcpingFetches = allServers.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/tcping/" + extractHost(tmpDomain.value) + "?port=" + 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,
@@ -95,7 +100,7 @@ const tcpingFetches = allServers.map((server) => {
function initServerResults() {
const results: ServerResult[] = []
config.TCPing.DualStack.forEach((server: ServerConfig) => {
config.APIBaseURL.DualStack.forEach((server: ServerConfig) => {
results.push({
label: server.label,
loading: false,
@@ -103,7 +108,7 @@ function initServerResults() {
})
})
config.TCPing.IPv6.forEach((server: ServerConfig) => {
config.APIBaseURL.IPv6.forEach((server: ServerConfig) => {
results.push({
label: server.label,
loading: false,
@@ -117,23 +122,31 @@ function initServerResults() {
function TCPingAll() {
const host = extractHost(tmpDomain.value)
if (!host) return
loading.value = true
const id = ++queryId
serverResults.value.forEach((result) => {
result.loading = true
result.error = ''
result.ipv4 = undefined
result.ipv6 = undefined
})
const promises = tcpingFetches.map(async (fetch, index) => {
try {
await fetch.execute();
if (id !== queryId) return
const result = serverResults.value[index];
if (result) {
result.ipv4 = fetch.data.value?.ipv4;
result.ipv6 = fetch.data.value?.ipv6;
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成 '-')
if (fetch.error.value) {
result.error = (fetch.error.value as any)?.message || '请求失败';
} else {
result.ipv4 = fetch.data.value?.ipv4;
result.ipv6 = fetch.data.value?.ipv6;
}
}
} catch (err) {
console.error(err);
@@ -143,22 +156,24 @@ function TCPingAll() {
}
} finally {
const result = serverResults.value[index];
if (result) {
if (result && id === queryId) {
result.loading = false;
}
}
})
Promise.all(promises).finally(function () {
loading.value = false
if (id === queryId) {
loading.value = false
}
})
}
async function getUserIP(){
await $fetch<string>(config.DualStackAPI).then(
function (data){
userIP.value = data
})
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
} catch {
// 获取失败保持为空,避免未处理的 Promise 拒绝
}
return userIP.value
}
onMounted(() => {
@@ -183,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
@@ -199,6 +216,8 @@ onMounted(() => {
</el-button>
</div>
<AdvancedOptions ref="adv" stack="ipv6" />
<div class="result-section">
<table class="result-table">
<thead>
@@ -221,13 +240,13 @@ onMounted(() => {
<td class="table-value">{{ server.ipv6.sent }}</td>
<td class="table-value">{{ server.ipv6.success }}</td>
<td class="table-value">
<span :class="server.ipv6.loss_rate > 0 ? 'loss-warning' : 'loss-ok'">
{{ server.ipv6.loss_rate.toFixed(1) }}
<span :class="(server.ipv6?.loss_rate ?? 0) > 0 ? 'loss-warning' : 'loss-ok'">
{{ server.ipv6?.loss_rate?.toFixed(1) ?? '-' }}
</span>
</td>
<td class="table-value">{{ server.ipv6.max_rtt.toFixed(2) }}</td>
<td class="table-value">{{ server.ipv6.min_rtt.toFixed(2) }}</td>
<td class="table-value">{{ server.ipv6.avg_rtt.toFixed(2) }}</td>
<td class="table-value">{{ server.ipv6?.max_rtt?.toFixed(2) ?? '-' }}</td>
<td class="table-value">{{ server.ipv6?.min_rtt?.toFixed(2) ?? '-' }}</td>
<td class="table-value">{{ server.ipv6?.avg_rtt?.toFixed(2) ?? '-' }}</td>
</tr>
<tr v-else-if="server.loading">
<td class="table-label">{{ server.label }}</td>
@@ -268,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;
}
@@ -275,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 {
+25 -9
View File
@@ -4,14 +4,15 @@ 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()
const siteUrlWithSlash = computed(() => config.siteUrl.replace(/\/$/, '') + '/')
useHead({
title: 'IPv6网站检测工具 | IPv6访问支持检查 | 柠檬味ipw.cn',
title: `IPv6网站检测工具 | IPv6访问支持检查 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的IPv6网站检测工具,全面检查网站是否支持IPv6访问,提供IPv4和IPv6双栈HTTP/HTTPS状态码、DNS解析时间、TCP连接时间、下载速度等详细对比数据,帮助网站管理员确认IPv6部署状态,提供IPv6徽标认证,推进IPv6规模部署和应用' },
{ name: 'keywords', content: 'ipv6网站检测,ipv6访问检测,ipv6支持检查,ipv6双栈检测,ipv6 http检测,ipv6 https检测,ipv6网站认证,ipv6徽标,ipv6部署' },
@@ -38,7 +39,7 @@ useHead({
},
provider: {
'@type': 'Organization',
name: '柠檬味ipw.cn'
name: config.siteName
}
})
}
@@ -63,7 +64,9 @@ interface PerformanceCheckItem {
download_speed: number
is_reachable: boolean
}
const apiList = config.apiBaseUrls
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,11 @@ 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
const { data: checkData, error: checkError, execute: executeCheck } = useMiddlewareFetch<PerformanceCheckResponse>(checkUrl, {
immediate: false,
@@ -134,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()"
@@ -145,6 +153,8 @@ onMounted(() => {
</el-button>
</div>
<AdvancedOptions ref="adv" />
<div v-if="error" class="error-message">
{{ error }}
</div>
@@ -162,7 +172,7 @@ onMounted(() => {
<tr>
<td class="table-label">主机记录</td>
<td class="table-value"><div class="one-line"><RouterLink :to="`/ipv6?ip=${result.ipv4!.host_record}`">{{ result.ipv4!.host_record }}</RouterLink><el-icon><Position /></el-icon></div></td>
<td class="table-value"><div class="one-line"><RouterLink :to="`/ipv6?ip=${result.ipv6?.host_record}`">{{ result.ipv6?.host_record || '-' }}</RouterLink><el-icon><Position /></el-icon></div></td>
<td class="table-value"><div class="one-line"><RouterLink v-if="result.ipv6?.host_record" :to="`/ipv6?ip=${result.ipv6!.host_record}`">{{ result.ipv6!.host_record }}</RouterLink><span v-else>-</span><el-icon><Position /></el-icon></div></td>
</tr>
<tr>
<td class="table-label">HTTP 状态码</td>
@@ -275,6 +285,12 @@ onMounted(() => {
<h2>...</h2>
<el-image src="/jingya.jpg"></el-image>
</div>
<div v-else-if="result && result.ipv4 && result.ipv6 && !result.ipv4.is_reachable && result.ipv6.is_reachable">
<h3>结论:<CircleCloseFilled style="width: 1.3em;color: red;"/>网站{{ extractHost(testDomain) }} 不支持IPv4访问 </h3>
<h2>全球来看,越南、捷克等部分国家已提出了单栈发展目标。尽管我国网络IPv6流量占比大幅提升,但网络中仍存在大量的NAT(网络地址转换)设备和IPv4依赖应用。</h2>
<h2 style="text-align: right;">----中国《IPv6发展报告》2026</h2>
<el-image src="/jingya.jpg"></el-image>
</div>
<blockquote>
网站不支持 IPv6可能原因:<br/>
<br/>
@@ -297,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;
+146 -14
View File
@@ -5,17 +5,17 @@ import {config} from '../../config/index';
import { useMiddlewareFetch } from '../../utils/middleware';
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
import { useRoute } from 'vue-router'
import { highlightCode } from '../../utils/shiki'
import { isIPv4 } from '../../utils/tools';
import { wgs84ToGcj02 } from '../../utils/gcj02';
const route = useRoute();
const loading = ref(false);
useHead({
title: 'IPv6/IPv4地址查询工具 | IP归属地定位 | 柠檬味ipw.cn',
title: `IPv6/IPv4地址查询工具 | IP归属地定位 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的IPv6/IPv4地址查询工具,支持IPv4和IPv6地址归属地查询,提供BiliBili Live、GeoCN、IP2Region、Maxmind等多种数据源对比,精确定位IP地理位置、运营商信息,支持中国大陆及境外地址查询,助力IPv6普及与应用' },
{ name: 'keywords', content: 'ipv6地址查询,ipv4地址查询,ip归属地,ip地理位置,ip定位,运营商查询,ipv6归属地,ipv4归属地,maxmind,ip2region,geocn' },
{ property: 'og:title', content: 'IPv6/IPv4地址归属地查询工具 - 柠檬味ipw.cn' },
{ property: 'og:title', content: `IPv6/IPv4地址归属地查询工具 - ${config.siteName}` },
{ property: 'og:description', content: '多数据源IP地址归属地查询,支持IPv4和IPv6,提供地理位置、运营商等详细信息' },
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
{ property: 'og:type', content: 'website' },
@@ -38,7 +38,7 @@ useHead({
},
provider: {
'@type': 'Organization',
name: '柠檬味ipw.cn'
name: config.siteName
}
})
}
@@ -48,7 +48,11 @@ interface IPLocationType {
bilibili?: any;
ip?: string;
geocn?: any;
lemondb?: any;
lemondb_asn?: any;
ip2region?: any;
ip2location?: any;
ip2location_asn?: any;
maxmind_asn?: any;
maxmind_city?: any;
qqwry?: any;
@@ -66,15 +70,112 @@ curl ${config.v6OnlyAPI}
# (访问 IPv4/IPv6 双栈站点,如果返回 IPv6 地址,则 IPv6 访问优先)
curl ${config.DualStackAPI}
`.trim();
const html = ref('');
const apiList = config.IPLocationAPIs
// 代码高亮放 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 || '')
const ipAddress = ref('');
const IPLocation = ref<IPLocationType>({});
const UserIP = ref('');
const error = ref('')
const locationUrl = computed(() => "/middleware/" + backendID.value + "/location/" + ipAddress.value);
// —— 归属地地图:高德瓦片(GCJ-02)+ Leaflet,免 key ——
// 每个有坐标的来源各画一个点(bilibili / geocn 坐标已是 GCJ-02 直接用;
// maxmind / ip2location / dbip / lemondb 为 WGS84 → 转 GCJ-02 与底图对齐),
// 多点同时展示可直观看出各库定位的差异与离散度。
const mapContainer = ref<HTMLElement | null>(null)
let mapInstance: any = null
let markerGroup: any = null
const SOURCE_DEFS = [
{ key: 'lemondb', lat: 'latitude', lng: 'longitude', gcj: false, label: 'LemonDB', color: '#ffab00' },
{ key: 'bilibili', lat: 'latitude', lng: 'longitude', gcj: true, label: 'bilibili Live', color: '#299764' },
{ key: 'maxmind_city', lat: 'latitude', lng: 'longitude', gcj: false, label: 'MaxMind', color: '#1d4ed8' },
{ key: 'ip2location', lat: 'latitude', lng: 'longitude', gcj: false, label: 'IP2Location', color: '#f97316' },
{ key: 'dbip_city', lat: 'latitude', lng: 'longitude', gcj: false, label: 'DB-IP', color: '#8b5cf6' },
{ key: 'geocn', lat: 'latitude', lng: 'longitude', gcj: true, label: 'GeoCN', color: '#0ea5e9' },
]
const mapPoints = computed(() => {
const r = IPLocation.value
if (!r) return []
const pts: Array<{ lat: number; lng: number; label: string; color: string }> = []
for (const s of SOURCE_DEFS) {
const lat = parseFloat(r[s.key]?.[s.lat])
const lng = parseFloat(r[s.key]?.[s.lng])
if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
// LemonDB 全库统一 WGS-84(coord_system 恒为 wgs84,这里留个动态判断作前向兼容,
// 若哪天源库换坐标系标注会自动跟随);其余源维持静态标记
const gcj = s.gcj || r[s.key]?.coord_system === 'gcj02'
const p = gcj ? { lat, lng } : wgs84ToGcj02(lat, lng)
pts.push({ ...p, label: s.label, color: s.color })
}
}
return pts
})
let leafletModule: any = null
async function renderLocationMap() {
if (!import.meta.client || !mapPoints.value.length || !mapContainer.value) return
const pts = mapPoints.value
leafletModule = leafletModule || (await import('leaflet'))
const L = leafletModule.default ?? leafletModule
if (!mapInstance) {
mapInstance = L.map(mapContainer.value, { attributionControl: true })
L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', {
subdomains: ['1', '2', '3', '4'],
maxZoom: 18,
// 高德栅格瓦片原生层级上限约 17:部分区域(尤其县乡)z=18 会返回纯米色
// "无数据"空瓦片。声明 maxNativeZoom 后,Leaflet 对更高缩放自动放大
// 17 级瓦片(轻度模糊但绝不变空白)。
maxNativeZoom: 17,
attribution: '&copy; <a href="https://www.amap.com/" target="_blank" rel="noreferrer">高德地图</a>',
}).addTo(mapInstance)
}
if (markerGroup) markerGroup.remove()
markerGroup = L.featureGroup()
for (const p of pts) {
L.circleMarker([p.lat, p.lng], {
radius: 8,
color: p.color,
fillColor: p.color,
fillOpacity: 0.85,
weight: 2,
})
.bindPopup(`<b>${p.label}</b><br>${ipAddress.value || UserIP.value}<br>${p.lat.toFixed(5)}, ${p.lng.toFixed(5)}`)
.addTo(markerGroup)
}
markerGroup.addTo(mapInstance)
// 多点:视野自适应包住全部点;单点:定位到 10 级
if (pts.length > 1) {
mapInstance.fitBounds(markerGroup.getBounds().pad(0.3))
} else {
mapInstance.setView([pts[0].lat, pts[0].lng], 10)
}
}
watch(IPLocation, async () => {
if (mapPoints.value.length) {
await nextTick()
await renderLocationMap()
}
})
const locationUrl = computed(() => "/middleware/" + backendID.value + "/location/" + encodeURIComponent(ipAddress.value));
const { data: locationData, error: locationError, execute: executeLocation } = useMiddlewareFetch<IPLocationType>(locationUrl, {
immediate: false,
@@ -116,7 +217,8 @@ function locateIP(IP: string){
}
onMounted(async () => {
html.value = await highlightCode(code, 'bash')
// (代码高亮已移到 setup 顶部的 useAsyncData,随 SSR 一起产出,这里只剩定位查询。)
const urlParam = route.query.ip;
if (urlParam) {
ipAddress.value = urlParam as string;
@@ -148,7 +250,8 @@ onMounted(async () => {
<div class="one-line">
<el-input
v-model="ipAddress"
placeholder="请输入IP地址"
placeholder="请输入IP地址"
clearable
/>
<el-button
@click="locateIP(ipAddress)"
@@ -169,6 +272,17 @@ onMounted(async () => {
<div v-if="IPLocation" class="result-section">
<table class="result-table">
<tbody>
<tr v-if="IPLocation.lemondb && (IPLocation.lemondb.country || IPLocation.lemondb.administrative_area || IPLocation.lemondb.city)">
<td class="table-label">LemonDB</td>
<td class="table-value">
{{ IPLocation.lemondb?.country }}&nbsp;{{ IPLocation.lemondb?.administrative_area }}&nbsp;{{ IPLocation.lemondb?.city }}&nbsp;{{ IPLocation.lemondb?.district }}
</td>
<td class="table-value">
<span>
{{ IPLocation.lemondb?.isp }}&nbsp;{{ IPLocation.lemondb?.type }}
</span>
</td>
</tr>
<tr v-if="IPLocation.bilibili && (IPLocation.bilibili.administrative_area || IPLocation.bilibili.city)">
<td class="table-label">bilibili Live接口</td>
<td class="table-value">
@@ -182,10 +296,15 @@ onMounted(async () => {
</span>
</td>
</tr>
<tr v-if="IPLocation.ip2region && IPLocation.ip2region.split('|').length >= 4">
<tr v-if="IPLocation.ip2region && typeof IPLocation.ip2region === 'object' && (IPLocation.ip2region.administrative_area || IPLocation.ip2region.city)">
<td class="table-label">IP2Region</td>
<td class="table-value">{{ IPLocation.ip2region?.split("|")[0] }}&nbsp;{{ IPLocation.ip2region?.split("|")[1] }}&nbsp;{{ IPLocation.ip2region?.split("|")[2] }}</td>
<td class="table-value">{{ IPLocation.ip2region?.split("|")[3] }}</td>
<td class="table-value">{{ IPLocation.ip2region?.country }}&nbsp;{{ IPLocation.ip2region?.administrative_area }}&nbsp;{{ IPLocation.ip2region?.city }}</td>
<td class="table-value">{{ IPLocation.ip2region?.isp }}</td>
</tr>
<tr v-if="IPLocation.ip2location && typeof IPLocation.ip2location === 'object' && (IPLocation.ip2location.administrative_area || IPLocation.ip2location.city)">
<td class="table-label">IP2Location</td>
<td class="table-value">{{ IPLocation.ip2location?.country }}&nbsp;{{ IPLocation.ip2location?.administrative_area }}&nbsp;{{ IPLocation.ip2location?.city }}</td>
<td class="table-value">{{ IPLocation.ip2location_asn?.as }}</td>
</tr>
<tr v-if="IPLocation.geocn && (IPLocation.geocn.administrative_area || IPLocation.geocn.city || IPLocation.geocn.district)">
<td class="table-label">GeoCN(仅中国大陆)</td>
@@ -210,6 +329,8 @@ onMounted(async () => {
</tbody>
</table>
</div>
<!-- 归属地地图:高德瓦片(GCJ-02)+ Leaflet,每个有坐标的来源各画一点 -->
<div v-if="mapPoints.length" ref="mapContainer" class="location-map"></div>
</div>
<div style="font-size: 1.5em;">
<h3 v-if="isIPv6(UserIP)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h3>
@@ -235,11 +356,22 @@ onMounted(async () => {
<style scoped>
@import "../style.css";
@import "../../assets/css/tool-common.css";
/* leaflet 基础样式必须非 scoped 引入(动态创建的窗格拿不到 data-v 属性) */
</style>
<style>
@import "leaflet/dist/leaflet.css";
.location-map {
height: 320px;
margin-top: 16px;
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter, #ebeef5);
z-index: 0; /* 压住 leaflet 内部窗格的层级,避免盖过下拉/弹窗 */
}
</style>
<style>
@import "../style.css";
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
@@ -422,7 +554,7 @@ html.dark pre code {
.badge-item h4 {
margin: 0 0 15px 0;
color: #3EAF7C;
color: #299764;
font-size: 1.2em;
}
+107 -16
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 || '柠檬味ipw.cn'),
// 原标题「网站截图工具 - 站名」默认态过短;描述不足 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,23 +52,65 @@ 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
let detectionTimeout: ReturnType<typeof setTimeout> | null = null
let currentRequestId = 0
const userIP = ref('')
// WordPress mshots default error image dimensions
const DEFAULT_MSHOTS_WIDTH = 400
const DEFAULT_MSHOTS_HEIGHT = 300
// WordPress mshots 默认占位图(生成失败/重定向目标)的尺寸是 1200x900,
// 与下方注释及判定逻辑一致;真实网站截图可以是任意尺寸
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(){
await $fetch<string>(config.DualStackAPI).then(
function (data){
userIP.value = data
})
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
} catch {
// 获取失败保持为空,避免未处理的 Promise 拒绝
}
return userIP.value
}
function cleanupDetection() {
@@ -81,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
@@ -152,6 +237,11 @@ function onImageError() {
showError('截图加载失败,请检查网址是否正确')
}
onBeforeUnmount(() => {
cleanupDetection()
releaseBlob()
})
onMounted(() => {
const urlParam = route.query.site as string
if (urlParam) {
@@ -179,6 +269,7 @@ const doc = page.value
<el-input
v-model="tmpDomain"
placeholder="请输入网址(如:https://example.com)"
clearable
@keyup.enter="takeScreenshot"
/>
<el-button
@@ -285,7 +376,7 @@ html.dark .loading-overlay p {
}
.markdown :deep(a) {
color: #3EAF7C !important;
color: #299764 !important;
font-size: 1.3em;
text-decoration: none
}
+34 -13
View File
@@ -3,13 +3,14 @@ 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()
useHead({
title: 'IPv4网站测速工具 | 全国多节点并发测速 | 柠檬味ipw.cn',
title: `IPv4网站测速工具 | 全国多节点并发测速 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的IPv4网站测速工具,提供全国多节点并发测速服务,快速返回网站响应时间、下载速度、页面大小、DNS解析时间、HTTP连接时间等详细性能指标,支持IPv4网站性能检测与优化,助力网站性能监控与用户体验改善' },
{ name: 'keywords', content: 'ipv4网站测速,ipv4测速,网站速度测试,ipv4性能检测,网站响应时间,ipv4下载速度,ipv4性能优化,网站性能监控' },
@@ -36,7 +37,7 @@ useHead({
},
provider: {
'@type': 'Organization',
name: '柠檬味ipw.cn'
name: config.siteName
}
})
}
@@ -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 {
@@ -57,6 +60,9 @@ interface ServerResult {
data: any
}
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
/** 预构建的服务器列表,页面挂载即展示,用户可提前看到测速节点 */
const serverResults = ref<ServerResult[]>([])
@@ -68,8 +74,8 @@ const serverResults = ref<ServerResult[]>([])
// =============================================
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + domain.value);
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
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,
@@ -78,8 +84,8 @@ const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
});
/** IPv4 专用服务器:仅支持 IPv4 的节点 */
const ipv4Fetches = config.SpeedTest.IPv4.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + domain.value);
const ipv4Fetches = config.APIBaseURL.IPv4.map((server) => {
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,
@@ -116,6 +122,8 @@ async function SpeedTest() {
error.value = ''
await nextTick()
const id = ++queryId
// 重置所有行状态为加载中
serverResults.value.forEach((row) => {
row.loading = true
@@ -130,16 +138,26 @@ async function SpeedTest() {
try {
await fetch.execute()
row.data = fetch.data.value
if (id !== queryId) return
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成空白)
if (fetch.error.value) {
row.error = fetch.error.value?.data?.error || fetch.error.value?.message || '请求失败'
} else {
row.data = fetch.data.value
}
} catch (err: any) {
row.error = err?.data?.error || err?.message || '请求失败'
} finally {
row.loading = false
if (id === queryId) {
row.loading = false
}
}
})
Promise.all(promises).finally(() => {
loading.value = false
if (id === queryId) {
loading.value = false
}
})
}
@@ -165,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()"
@@ -176,6 +195,8 @@ onMounted(() => {
</el-button>
</div>
<AdvancedOptions ref="adv" stack="ipv4" />
<div v-if="error" class="error-message">
{{ error }}
</div>
@@ -257,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;
+34 -7
View File
@@ -4,13 +4,14 @@ 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(/\/$/, '') + '/')
useHead({
title: 'SSL证书检测工具 | IPv4/IPv6证书检查 | 柠檬味ipw.cn',
title: `SSL证书检测工具 | IPv4/IPv6证书检查 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的SSL证书检测工具,全面检查网站的IPv4和IPv6 SSL证书状态、有效期、签发机构、HTTP版本等信息,支持HTTPS状态码检测、下载速度测试,帮助网站管理员及时发现证书问题,确保网站安全访问' },
{ name: 'keywords', content: 'ssl证书检测,ssl检查,https证书,ipv6 ssl,ipv4 ssl,证书有效期,ssl状态,https检测,网站安全,证书签发机构' },
@@ -37,7 +38,7 @@ useHead({
},
provider: {
'@type': 'Organization',
name: '柠檬味ipw.cn'
name: config.siteName
}
})
}
@@ -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.apiBaseUrls
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/" + 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;
+60 -26
View File
@@ -3,12 +3,13 @@ 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()
useHead({
title: 'TCPing测试工具 | IPv4服务器连通性检测 | 柠檬味ipw.cn',
title: `TCPing测试工具 | IPv4服务器连通性检测 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的IPv4 TCPing测试工具,提供多节点TCP连通性检测服务,支持自定义端口测试,实时检测服务器丢包率、平均延迟、最大最小响应时间,帮助运维人员快速诊断服务器网络质量,确保服务稳定运行' },
{ name: 'keywords', content: 'tcping测试,tcp连通性检测,ipv4 tcping,服务器延迟测试,丢包率检测,端口连通性,服务器网络测试,网络质量检测' },
@@ -35,7 +36,7 @@ useHead({
},
provider: {
'@type': 'Organization',
name: '柠檬味ipw.cn'
name: config.siteName
}
})
}
@@ -76,14 +77,18 @@ 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
const allServers = [
...config.TCPing.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
...config.TCPing.IPv4.map((s: ServerConfig) => ({ ...s, type: 'IPv4' }))
...config.APIBaseURL.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
...config.APIBaseURL.IPv4.map((s: ServerConfig) => ({ ...s, type: 'IPv4' }))
];
const tcpingFetches = allServers.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/tcping/" + extractHost(tmpDomain.value) + "?port=" + 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,
@@ -93,8 +98,9 @@ const tcpingFetches = allServers.map((server) => {
function initServerResults() {
const results: ServerResult[] = []
config.TCPing.DualStack.forEach((server: ServerConfig) => {
// 与 allServers 顺序保持一致:DualStack → IPv4
config.APIBaseURL.DualStack.forEach((server: ServerConfig) => {
results.push({
label: server.label,
loading: false,
@@ -102,7 +108,7 @@ function initServerResults() {
})
})
config.TCPing.IPv4.forEach((server: ServerConfig) => {
config.APIBaseURL.IPv4.forEach((server: ServerConfig) => {
results.push({
label: server.label,
loading: false,
@@ -116,23 +122,31 @@ function initServerResults() {
function TCPingAll() {
const host = extractHost(tmpDomain.value)
if (!host) return
loading.value = true
const id = ++queryId
serverResults.value.forEach((result) => {
result.loading = true
result.error = ''
result.ipv4 = undefined
result.ipv6 = undefined
})
const promises = tcpingFetches.map(async (fetch, index) => {
try {
await fetch.execute();
if (id !== queryId) return
const result = serverResults.value[index];
if (result) {
result.ipv4 = fetch.data.value?.ipv4;
result.ipv6 = fetch.data.value?.ipv6;
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成 '-')
if (fetch.error.value) {
result.error = (fetch.error.value as any)?.message || '请求失败';
} else {
result.ipv4 = fetch.data.value?.ipv4;
result.ipv6 = fetch.data.value?.ipv6;
}
}
} catch (err) {
console.error(err);
@@ -142,14 +156,16 @@ function TCPingAll() {
}
} finally {
const result = serverResults.value[index];
if (result) {
if (result && id === queryId) {
result.loading = false;
}
}
})
Promise.all(promises).finally(function () {
loading.value = false
if (id === queryId) {
loading.value = false
}
})
}
@@ -174,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
@@ -190,6 +208,8 @@ onMounted(() => {
</el-button>
</div>
<AdvancedOptions ref="adv" stack="ipv4" />
<div class="result-section">
<table class="result-table">
<thead>
@@ -212,13 +232,13 @@ onMounted(() => {
<td class="table-value">{{ server.ipv4?.sent }}</td>
<td class="table-value">{{ server.ipv4?.success }}</td>
<td class="table-value">
<span :class="server.ipv4.loss_rate > 0 ? 'loss-warning' : 'loss-ok'">
{{ server.ipv4.loss_rate.toFixed(1) }}
<span :class="(server.ipv4?.loss_rate ?? 0) > 0 ? 'loss-warning' : 'loss-ok'">
{{ server.ipv4?.loss_rate?.toFixed(1) ?? '-' }}
</span>
</td>
<td class="table-value">{{ server.ipv4?.max_rtt.toFixed(2) }}</td>
<td class="table-value">{{ server.ipv4?.min_rtt.toFixed(2) }}</td>
<td class="table-value">{{ server.ipv4?.avg_rtt.toFixed(2) }}</td>
<td class="table-value">{{ server.ipv4?.max_rtt?.toFixed(2) ?? '-' }}</td>
<td class="table-value">{{ server.ipv4?.min_rtt?.toFixed(2) ?? '-' }}</td>
<td class="table-value">{{ server.ipv4?.avg_rtt?.toFixed(2) ?? '-' }}</td>
</tr>
<tr v-else-if="server.loading">
<td class="table-label">{{ server.label }}</td>
@@ -244,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;
}
@@ -259,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 {
+30 -17
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 || '柠檬味ipw.cn'),
// 原标题「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.apiBaseUrls
const apiList = [
...config.APIBaseURL.DualStack,
]
const currentApiIndex = ref(0)
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
const whoisUrl = computed(() => '/middleware/' + backendID.value + '/whois/' + 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,
@@ -124,11 +129,11 @@ onMounted(() => {
})
async function getUserIP(){
await $fetch<string>(config.DualStackAPI).then(
function (data){
userIP.value = data
}
)
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
} catch {
// 获取失败保持为空,避免未处理的 Promise 拒绝
}
return userIP.value
}
@@ -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;
+20 -16
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,13 +109,14 @@ blockquote p {
margin: 0.5em;
}
blockquote a{
color: #3EAF7C;
color: var(--link-color);
font-size: 1em;
text-decoration: none
/* 正文里的链接仅靠颜色区分不够(绿 #299764 与周围正文的对比度远低于 3:1),
补下划线提供「非颜色」的视觉区分(link-in-text-block) */
text-decoration: underline
}
a {
color: #3EAF7C;
color: var(--link-color);
font-size: 1.3em;
text-decoration: none
@@ -128,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 {
@@ -203,17 +207,17 @@ footer {
}
blockquote a{
color: #3EAF7C;
color: var(--link-color);
font-size: 1em;
text-decoration: none
/* 与上面的非窄屏规则保持一致:窄屏同样靠下划线做非颜色区分。
此处若仍写 none,因其位置在后、特异性相同,会把桌面的 underline 盖掉。 */
text-decoration: underline
}
}
html.dark {
/* 在暗色模式下,覆盖根元素的文字颜色和背景色 */
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
--el-color-primary: #3EAF7C;
}
/* 注:暗色模式的「根元素」规则(html.dark 的文字色 / 背景色 / 主色变量)已上移到
app.vue 的【非 scoped】<style> 块。本文件是被 <style scoped> @import 进来的,
凡是作用于 <html> 自身的规则都会被加上 scope 属性而永不匹配。
这里只保留作用于「html 后代元素」的规则(后代元素带 scope 属性,能正常命中)。 */
html.dark blockquote {
/* 在暗色模式下,覆盖根元素的文字颜色和背景色 */
color: rgba(255, 255, 255, 0.87);
@@ -268,7 +272,7 @@ h4>.header-anchor,
h5>.header-anchor,
h6>.header-anchor {
opacity: 1;
color: #3EAF7C;
color: var(--link-color);
}
h1:hover>.header-anchor,
@@ -281,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,
+128 -26
View File
@@ -1,104 +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 工具箱使用文档'
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网络访问'
description: 'IPv6 是大势所趋,就在前段时间湖南联通发布公告,对家庭宽带提供 IPv6 地址,不再提供 IPv4 地址,那本文就介绍个人宽带如何开启 IPv6 网络访问。',
seoTitle: '个人宽带如何开启 IPv6 网络访问:光猫与路由器设置教程',
seoDescription: '手把手教你为家庭宽带开启 IPv6 网络访问:从光猫与路由器的设置,到向运营商申请(湖南联通等已对家庭宽带提供 IPv6 地址、不再提供 IPv4),附开启后的 IPv6 地址验证方法与常见问题排查。'
},
'/doc/user/cmd_bash_disable_ipv6': {
title: '命令行禁用/启用IPv6本地网络',
description: 'macOS和Window10命令行禁用/启用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地址'
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地址'
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 设置 IPv4/IPv6 访问优先级'
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资源导航列表'
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网站导航列表'
description: 'IPv6 网络开启成功后,可以访问一些纯 IPv6 网站资源,来体验下 IPv6 的魅力。',
seoTitle: '国内纯 IPv6 网站导航:仅支持 IPv6 访问的站点合集',
seoDescription: '收录一批仅提供 IPv6 访问的国内纯 IPv6 网站:家庭宽带开启 IPv6 网络后即可直接访问,用来验证本机 IPv6 连通性,或体验真正的 IPv6-only 互联网服务,列表持续更新。'
},
'/doc/user/dns': {
title: '全国各省 DNS 服务器列表',
description: '全国各省DNS服务器列表'
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地址列表'
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: '阿里云自动化添加安全组'
description: 'Golang 代码实操阿里云自动化添加安全组',
seoTitle: 'Golang 实现阿里云 ECS 安全组规则自动化添加',
seoDescription: '使用 Golang 调用阿里云 ECS OpenAPI 实现安全组放行规则的自动化添加:包含 SDK 初始化、AccessKey 鉴权、AuthorizeSecurityGroup 接口的参数说明与完整示例代码,适合批量管理多台 ECS 实例的场景。'
},
'/doc/user/TencentCloudAddSecurityGroup': {
title: '腾讯云自动化添加安全组',
description: '腾讯云自动化添加安全组'
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'
description: '通过 IPv6 网站检测工具,一键检查网站是否开启 IPv6 访问。',
seoTitle: '如何确认一个网站是否开启 IPv6:一键检测方法',
seoDescription: '使用本站的 IPv6 网站检测工具,输入域名即可一键检查网站是否开启 IPv6 访问:返回 IPv4 与 IPv6 双栈的 HTTP/HTTPS 状态码、DNS 解析与连接耗时对比数据,并可申请 IPv6 徽标认证。'
},
'/doc/server/ipv6_sign': {
title: '网站增加支持IPv6访问标识',
description: 'HTML+CSS代码,可以提醒访客本网站支持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解析记录'
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协议'
title: '网站如何开启 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的权威定义'
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包'
title: '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: 'IPv6 Ping检测原理'
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 库的归属地结果。',
seoTitle: 'API 文档:获取客户端公网 IP 与多库归属地结果',
seoDescription: '调用本项目的 /v1/location 接口,一次取回访问者自己的公网 IPv4/IPv6 地址与 10 套 IP 库(MaxMind、IP2Region、GeoCN 等)的归属地结果,返回 JSON、支持跨域,附请求示例与响应字段说明。'
},
'/doc/api/ip/myip': {
title: '获取客户端公网 IP 地址',
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 地址的归属地,返回多源聚合结果。',
seoTitle: 'API 文档:查询指定 IPv4/IPv6 地址的归属地信息',
seoDescription: '调用本项目的 /v1/location/<ip> 接口查询任意 IPv4 / IPv6 地址的归属地:返回多套 IP 数据库聚合的地理位置、运营商、ASN 等结果,支持节点直连与中间件转发两种调用方式,附完整响应示例。'
},
'/doc/api/whois/query': {
title: '查询域名的 WHOIS 信息',
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 访问优先。',
seoTitle: 'JS 检查客户端网络是 IPv4 还是 IPv6 访问优先',
seoDescription: '通过一段前端 JS 代码检查客户端当前是 IPv4 还是 IPv6 访问、以及双栈网络下的访问优先级:分别向 IPv4/IPv6 探测端点发起请求并按可达性判定,可嵌入任意网页实时展示访客的网络栈状态。'
},
'/doc/server/tls': {
title: '网站如何开启 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 访问优先。',
seoTitle: 'Python / Golang 代码获取公网 IPv4 与 IPv6 地址',
seoDescription: '用 Python 和 Golang 获取本机公网 IPv4 和 IPv6 地址:分别请求 IPv4、IPv6 探测端点并判定当前网络的访问优先栈,附可直接运行的完整示例代码,适合嵌入监控脚本或后端服务使用。'
},
'/doc/user/dns_dig': {
title: 'DNS 解析流程',
description: '剖析 DNS 解析流程',
seoTitle: 'DNS 解析流程详解:递归查询与迭代查询全过程',
seoDescription: '剖析一次完整的 DNS 解析流程:浏览器缓存 → 操作系统缓存 → 本地 DNS 服务器的递归查询 → 根域名 / 顶级域 / 权威服务器的迭代过程,配合 dig 命令逐步演示,理解域名解析为何时快时慢。'
}
}
+141 -178
View File
@@ -2,15 +2,26 @@
/*
前端的一系列配置
*/
const config = {
siteUrl: "https://ipw.wsmdn.top/",
siteUrl: "https://nipw.cn/",
// 站点名称:用于页面标题 / 描述 / 页脚品牌展示
siteName: "柠檬味ipw.cn",
// 是否启用前端内置中间件(server/routes/middleware/[...slug].get.ts 本地转发)。
// true(默认):/middleware/* 候选为「外部节点… + 内置中间件(最后一位兜底)」;
// false:仅使用外部节点,候选列表不含内置中间件。
EnableInternalMiddleware: true,
// 内置中间件单 IP 限流次数(次/分钟);0 表示不限流。默认 120。
rateLimitPerMinute: 120,
// 外部独立中间件服务列表(base URL)。
// 与前端自带中间件(server/routes/middleware/[...slug].get.ts 本地转发)同级,
// 前端 /middleware/* 请求会依次尝试这些节点(出错重试下一个),
// 前端自带中间件始终放在候选列表最后一位兜底。
// 未配置或为空数组时,直接使用前端自带中间件。
Middleware: <string[]>[
// "http://127.0.0.1:8091/",
"https://middleware-1.api-ipw.wsmdn.top/",
"https://boce-api.api-ipw.wsmdn.top/"
// "https://middleware-2.wsmdn.top/",
],
// Umami 统计
@@ -18,190 +29,142 @@ const config = {
umamiScriptUrl: "https://umami.wsmdn.top/zako.js",
umamiWebsiteId: "69a91329-b110-4cf7-a04a-be4360b1a8d3",
// 中华人民共和国备案系统
ICP: "苏ICP备2026012471号",
GongAn: "苏公网安备32132402000813号",
ICP: "",
GongAn: "",
// 全站是否禁止搜索引擎索引
noindex: false,
// Worker IP查询接口
v4OnlyAPI: "https://4.wsmdn.dpdns.org/",
v6OnlyAPI: "https://6.wsmdn.dpdns.org/",
DualStackAPI: "https://test.wsmdn.dpdns.org/",
apiBaseUrls: [
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
},
{
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
id: "cn-shenzhen",
url: "https://cn-shenzhen.api-ipw.wsmdn.top/"
}
],
IPLocationAPIs: [
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
},
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/"
}
],
// 全站是否禁止搜索引擎索引
noindex: false,
TCPing:{
DualStack: [
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
url :"https://cn-jiangsu.api-ipw.wsmdn.top/",
},
{
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
id: "cn-shenzhen",
url :"https://cn-shenzhen.api-ipw.wsmdn.top/",
},
],
IPv4: [
{
label: "中国 广东 广州 腾讯云",
id: "cn-guangzhou",
url :"https://cn-guangzhou.api-ipw.wsmdn.top/",
},
{
label: "新加坡 腾讯云",
id: "sg-1",
url :"https://sg-1.api-ipw.wsmdn.top/",
},
{
label: "中国 香港 新界 西贡区 将军澳 MICC[ZFC]",
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: "美国/加利福尼亚州/洛杉矶/蒙特雷帕克/USCD[ZFC]",
id: "us-la",
url :"https://america-california-losangeles.ipwapi.zfap.wsmdn.top/",
}
],
IPv6: [
{
label: "中国 四川 沙渠 电信[ZFC]",
id: "cn2-sichuan",
url:"https://cn2-sichuan.api-ipw.wsmdn.top/",
},
{
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
id: "lntl-cn-hk-kowloon",
url:"https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/",
}
]
},
SpeedTest:{
DualStack: [
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
url :"https://cn-jiangsu.api-ipw.wsmdn.top/",
},
{
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
id: "cn-shenzhen",
url :"https://cn-shenzhen.api-ipw.wsmdn.top/",
},
],
IPv4: [
{
label: "中国 广东 广州 腾讯云",
id: "cn-guangzhou",
url :"https://cn-guangzhou.api-ipw.wsmdn.top/",
},
{
label: "新加坡 腾讯云",
id: "sg-1",
url :"https://sg-1.api-ipw.wsmdn.top/",
},
{
label: "中国 香港 新界 西贡区 将军澳 MICC[ZFC]",
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: "美国/加利福尼亚州/洛杉矶/蒙特雷帕克/USCD[ZFC]",
id: "us-la",
url :"https://america-california-losangeles.ipwapi.zfap.wsmdn.top/",
}
],
IPv6: [
{
label: "中国 四川 沙渠 电信[ZFC]",
id: "cn2-sichuan",
url:"https://cn2-sichuan.api-ipw.wsmdn.top/",
},
{
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
id: "lntl-cn-hk-kowloon",
url:"https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/",
}
]
},
NSLookup:[
},
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
url :"https://cn-jiangsu.api-ipw.wsmdn.top/",
},
{
label: "中国 广州 腾讯云",
id: "cn-guangzhou",
url :"https://cn-guangzhou.api-ipw.wsmdn.top/",
},
{
label: "新加坡 腾讯云",
id: "sg-1",
url :"https://sg-1.api-ipw.wsmdn.top/",
},
{
label: "中国 四川 沙渠 电信[ZFC]",
id: "cn2-sichuan",
url:"https://cn2-sichuan.api-ipw.wsmdn.top/",
},
{
label: "中国 陕西 西安 北经济技术开发区 未央区凤城 中国电信[ZFC]",
id: "cn-xian",
url :"https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/",
},
{
label: "中国 香港 新界 西贡区 将军澳 MICC[ZFC]",
id: "hk-shatin",
url :"https://china-hk-kowloon-shatindistrict-newcloud.ipwapi.zfap.wsmdn.top/",
},
{
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
id: "lntl-cn-hk-kowloon",
url:"https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/",
},
{
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
id: "cn-shenzhen",
url :"https://cn-shenzhen.api-ipw.wsmdn.top/",
},
{
label: "美国/加利福尼亚州/洛杉矶/蒙特雷帕克/USCD[ZFC]",
id: "us-la",
url :"https://america-california-losangeles.ipwapi.zfap.wsmdn.top/",
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
}
]
// {
// label: "中国 湖北 武汉 电信",
// id: "cn-wuhan-chinatelecom",
// url: ""
// },
],
// APIBaseURL:其余拨测(whois / ssl / detail / dns / dnssec / tcping / speed)上游节点池,含 { IPv6, IPv4, DualStack } 三栈
APIBaseURL: {
IPv6: [
{
label: "中国 四川 沙渠 电信[ZFC]",
id: "cn2-sichuan",
url: "https://cn2-sichuan.api-ipw.wsmdn.top/"
},
{
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
id: "lntl-cn-hk-kowloon",
url: "https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/"
},
],
IPv4: [
{
label: "中国 广东 广州 腾讯云",
id: "cn-guangzhou",
url: "https://cn-guangzhou.api-ipw.wsmdn.top/"
},
{
label: "新加坡 腾讯云",
id: "sg-1",
url: "https://sg-1.api-ipw.wsmdn.top/"
},
{
label: "中国 香港 新界 西贡区 将军澳 MICC[ZFC]",
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: "美国/加利福尼亚州/洛杉矶/蒙特利奇帕克/USCD[ZFC]",
id: "us-la",
url: "https://america-california-losangeles.ipwapi.zfap.wsmdn.top/"
},
{
// 与 IPLocationAPI 池共享的节点:ASN 页取两池交集,
// 只有同时出现在两个池里的节点才会显示(cn-xian 仅双栈池故不显示)
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/"
},
// IP 直连新节点:URL 留空,仅经独立中间件转发
{
label: "香港 Cogent",
id: "hongkong",
url: ""
},
// // WS 通道节点:URL 留空,拨测经中间件 WS 转发
// {
// label: "呼和浩特移动",
// id: "2faa15e6-18a7-4355-b422-ca53277d8d77",
// url: ""
// }
],
DualStack: [
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
},
{
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
id: "cn-shenzhen",
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: ""
// },
]
}
}
export { config }
+110 -12
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',
@@ -26,22 +58,49 @@ export default defineNuxtConfig({
include: [
'is-ip',
'shiki',
'dayjs',
'lodash-unified'
]
},
},
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'
},
// Element Plus 样式走按需引入:@element-plus/nuxt 默认 importStyle: 'css',
// 模板里用到的每个组件都会各自带上 `element-plus/es/components/<组件>/style/css`
// (其内部再链到 theme-chalk/base.css,`:root` 的 --el-* 变量就在那里定义)。
// 所以这里**不要**再引 element-plus/dist/index.css —— 那是整库样式(原始 360KB),
// 而首页实测 98% 的规则用不到,却全压在关键路径上。
css: [
// 1. 引入 Element Plus 基础样式 (如果你还没有引入的话)
'element-plus/dist/index.css',
// 2. 🌟 关键:引入 Element Plus 官方的暗黑模式 CSS 变量文件
// 暗黑模式的 CSS 变量覆盖(html.dark 下的 --el-* 值),按需引入不含它,必须显式引
'element-plus/theme-chalk/dark/css-vars.css',
],
app:{
head: {
// 站点内容为简体中文。不写时 Nuxt 输出的就是裸 <html>,
// 屏幕阅读器会按系统语言瞎猜发音,Lighthouse 的 html-has-lang 也会失败。
htmlAttrs: {
lang: 'zh-CN',
},
script: [
{
// 必须 innerHTML,不能 src(否则异步加载)
@@ -57,20 +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。
},
},
nitro: {
routeRules: {
// 缓存头一律不在这里设,交由 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',
@@ -81,9 +164,24 @@ nitro: {
options: {
target: 'es2022' // 明确告诉 Nitro 使用 es2022 进行打包
}
}
},
// 这里曾用 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: {
// 预渲染页的 CSP 会因 SRI modulepreload 哈希全量拼入而单行超 2000 字符,
// 触发 Cloudflare _headers 限制,故关掉静态页的安全头写入。
// 当前没有预渲染路由(/doc/** 已改回 SSR),这条是留给将来再开 prerender 时的开关:
// 一旦重新启用 prerender,那些页就会退回「无 CSP」状态。
nitroHeaders: false,
},
headers: {
contentSecurityPolicy: {
@@ -101,7 +199,7 @@ nitro: {
],
'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:'],
}
}
+2 -2
View File
@@ -14,7 +14,6 @@
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@element-plus/nuxt": "^1.1.5",
"@nuxt/content": "3.14.0",
"@nuxtjs/robots": "^6.1.3",
"@nuxtjs/sitemap": "^8.3.2",
"@nuxtjs/tailwindcss": "6.14.0",
@@ -22,13 +21,14 @@
"@shikijs/engine-javascript": "^4.4.1",
"@sindresorhus/slugify": "^3.0.0",
"@tailwindcss/vite": "^4.3.3",
"@types/leaflet": "^1.9.22",
"@vueuse/core": "^14.4.0",
"@vueuse/nuxt": "^14.4.0",
"better-sqlite3": "^12.11.1",
"element-plus": "^2.14.3",
"github-markdown-css": "^5.9.0",
"github-slugger": "^2.0.0",
"is-ip": "^5.0.1",
"leaflet": "1.9.4",
"markdown-it": "^14.3.0",
"markdown-it-anchor": "^9.2.1",
"nuxt": "^4.5.1",
+75 -1735
View File
File diff suppressed because it is too large. Load diff
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 718 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 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

+1 -1
View File
@@ -2,7 +2,7 @@
<svg width="85px" height="36px" viewBox="0 0 85 36" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>ssl-s1</title>
<g id="ssl-s1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="ipw.cn" transform="translate(5.000000, 4.000000)">
<g id="nipw.cn" transform="translate(5.000000, 4.000000)">
<rect stroke="#07C160" x="0.5" y="0.5" width="74" height="27" rx="5"></rect>
<g transform="translate(15.000000, 4.000000)" fill="#07C160">
<text id="SSL" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500">

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

+1 -1
View File
@@ -2,7 +2,7 @@
<svg width="85px" height="36px" viewBox="0 0 85 36" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>ssl-s2</title>
<g id="ssl-s2" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="ipw.cn" transform="translate(5.000000, 4.000000)">
<g id="nipw.cn" transform="translate(5.000000, 4.000000)">
<rect stroke="#61BD13" x="0.5" y="0.5" width="74" height="27" rx="5"></rect>
<g transform="translate(15.000000, 4.000000)" fill="#61BD13">
<text id="SSL" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500">

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

+1 -1
View File
@@ -2,7 +2,7 @@
<svg width="85px" height="36px" viewBox="0 0 85 36" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>ssl-s3</title>
<g id="ssl-s3" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="ipw.cn" transform="translate(5.000000, 4.000000)">
<g id="nipw.cn" transform="translate(5.000000, 4.000000)">
<rect id="矩形备份" stroke="#1A42A0" x="0.5" y="0.5" width="74" height="27" rx="5"></rect>
<text id="SSL" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500" fill="#1A42A0">
<tspan x="34" y="19">SSL</tspan>

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

+1 -1
View File
@@ -2,7 +2,7 @@
<svg width="85px" height="36px" viewBox="0 0 85 36" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>ssl-s4</title>
<g id="ssl-s4" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="ipw.cn" transform="translate(5.000000, 4.000000)">
<g id="nipw.cn" transform="translate(5.000000, 4.000000)">
<rect stroke="#3A84FF" x="0.5" y="0.5" width="74" height="27" rx="5"></rect>
<g transform="translate(15.000000, 4.000000)" fill="#3A84FF">
<text id="SSL" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500">

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

+1 -1
View File
@@ -2,7 +2,7 @@
<svg width="85px" height="36px" viewBox="0 0 85 36" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>ssl-s5</title>
<g id="ssl-s5" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="ipw.cn" transform="translate(5.000000, 4.000000)">
<g id="nipw.cn" transform="translate(5.000000, 4.000000)">
<rect stroke="#313238" x="0.5" y="0.5" width="74" height="27" rx="5"></rect>
<text id="SSL" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500" fill="#313238">
<tspan x="34" y="19">SSL</tspan>

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

+1 -1
View File
@@ -2,7 +2,7 @@
<svg width="85px" height="36px" viewBox="0 0 85 36" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>ssl-s6</title>
<g id="ssl-s6" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="ipw.cn" transform="translate(5.000000, 4.000000)">
<g id="nipw.cn" transform="translate(5.000000, 4.000000)">
<rect stroke="#757575" x="0.5" y="0.5" width="74" height="27" rx="5"></rect>
<text id="SSL" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500" fill="#757575">
<tspan x="34" y="19">SSL</tspan>

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 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)
})
@@ -1,6 +1,9 @@
import { defineEventHandler, createError } from 'h3'
const IANA_BOOTSTRAP_URL = 'https://data.iana.org/rdap/dns.json'
// bootstrap 数据基本不变,按进程缓存 24h,避免每个请求都重新下载
const BOOTSTRAP_TTL = 24 * 60 * 60 * 1000
let bootstrapCache: { map: Map<string, string>; at: number } | null = null
function encodeDomain(input: string): string {
try {
@@ -21,8 +24,12 @@ function getTLDCandidates(hostname: string): string[] {
}
async function fetchBootstrap(): Promise<Map<string, string>> {
if (bootstrapCache && Date.now() - bootstrapCache.at < BOOTSTRAP_TTL) {
return bootstrapCache.map
}
const res = await fetch(IANA_BOOTSTRAP_URL, {
headers: { accept: 'application/json' },
signal: AbortSignal.timeout(10_000),
})
if (!res.ok) {
throw createError({
@@ -42,6 +49,7 @@ async function fetchBootstrap(): Promise<Map<string, string>> {
map.set(String(tld), baseUrl)
}
}
bootstrapCache = { map, at: Date.now() }
return map
}
@@ -214,6 +222,7 @@ export default defineEventHandler(async (event) => {
const primaryRes = await fetch(rdapUrl, {
headers: { accept: 'application/rdap+json' },
signal: AbortSignal.timeout(15_000),
})
if (!primaryRes.ok) {
@@ -233,6 +242,7 @@ export default defineEventHandler(async (event) => {
try {
const thickRes = await fetch(relatedLink.href, {
headers: { accept: 'application/rdap+json' },
signal: AbortSignal.timeout(15_000),
})
if (thickRes.ok) {
thickData = await thickRes.json()
@@ -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-原理介绍)
@@ -0,0 +1,179 @@
# [API] 获取客户端公网 IP 及位置
## 1. 接口描述
> IP 库来源于网络,本项目不对结果的准确性负责,请在中国法律许可范围内使用.
接口地址: `https://cn-jiangsu.api-ipw.wsmdn.top/v1/location`(公开实例,见 1.1;换成你自己节点的域名同样可用)
请求方法:`GET`
用途:取**请求方自己**的公网 IP,并给出 IP 归属地。
- **不带参数**:用服务端看到的客户端地址(Go `gin` 的 `ClientIP()`),因此"查自己"只需这一个请求。
- **多源聚合,不合并**:返回 10 个 IP 库各自的结论,按源名分组,便于交叉比对;本项目不替你做"以哪个源为准"的判断。
- **只查本地库**:不依赖任何第三方在线查询接口(唯一例外是 `bilibili` 源,24 小时缓存)。
- **免鉴权**:节点未配 `access-token` 时公开可访问;配了则需带 `Authorization: Bearer <access-token>`。
> 本接口属于"业务探测"端点,请求会被节点计入统计并周期上报收集中心(不落库明细)。
### 1.1 公开实例
本项目对外开放了一个江苏节点,可以直接用它把本接口调通,**无需令牌**:
| 方式 | 地址 |
| --- | --- |
| 直连节点(推荐) | `https://cn-jiangsu.api-ipw.wsmdn.top/v1/location` |
| 经中间件转发 | **不适用**——中间件路径的最后一段(拨测目标)必须非空,而本接口没有目标参数 |
> 公共实例是**共享**的,调试与小流量接入没问题,请勿高频轮询、批量抓取,也不要拿它当线上服务的依赖。
### 1.2 还有别的节点 —— 拨测控制台
上面的江苏节点只是节点池里的一个。节点池的入口是拨测控制台 <https://boce.wsmdn.top>:
登录后进「可用节点」页,能看到**全部已启用节点**的节点标识、归属地、归属池(定位 / 拨测)、
协议栈与在线状态,并附上每个节点的调用前缀。
比自己搭一套省事的地方:
- **节点现成** —— 名单里的节点都是已经部署好的,不用自己找机器、装程序、配隧道。
- **入口现成** —— 统一走 HTTPS 转发,证书与反向代理不用自己操心。
- **一套规则调所有节点** —— 接口路径与返回结构完全一致,换节点只换地址。
- **池与栈标得很清楚** —— 定位类请求走定位池,拨测类走拨测池,两个池都有的节点两种都能接。
- **名单会自己更新** —— 新接入的节点自动出现在列表里,不需要改代码。
> 本接口("查自己")**只能直连节点**:中间件路径的最后一段必须是拨测目标,而"查自己"没有目标参数,
> 所以走中间件转发不适用。换别的节点时,把上例的 `cn-jiangsu.api-ipw.wsmdn.top`
> 换成该节点的接入域名即可(节点标识见控制台的「可用节点」页)。
>
> 节点有上下线,列表中会标出在线状态;离线节点调用会失败。
## 2. 输入参数
无。
## 3. 输出参数
顶层是「源名 → 该源结果」的字典,另有 `ip` 回显请求方地址:
| 字段 | 类型 | 描述 |
| --- | --- | --- |
| `ip` | String | 请求方公网 IP(本节点视角) |
| `lemondb` | Object | `country`/`country_code`/`administrative_area`/`city`/`district`/`division_code`/`isp`/`type`/`latitude`/`longitude`/`coord_system`(自家 LemonDB 月更库:全球打底 + 中国省市县高精度层,坐标统一 WGS-84,上高德底图需自转 GCJ-02) |
| `lemondb_asn` | Object | `asn`/`org`(GeoLite2-ASN 打底 + APNIC CN 精修) |
| `ip2region` | Object | `country`/`administrative_area`/`city`(部分记录另带 `isp`) |
| `ip2location` | Object | `country`/`country_code`/`administrative_area`/`city`/`district`/`zipcode`/`latitude`/`longitude`/`timezone`/`isp`/`asn`/`usagetype` |
| `ip2location_asn` | Object | `asn`/`as` |
| `qqwry` | Object | `country`/`country_code`/`administrative_area`/`city`/`isp` |
| `maxmind_city` | Object | `country`/`country_code`/`administrative_area`/`city`/`latitude`/`longitude` |
| `maxmind_asn` | Object | `asn`/`org` |
| `dbip_city` | Object | `country`/`country_code`/`administrative_area`/`city`/`latitude`/`longitude` |
| `dbip_asn` | Object | `asn`/`org` |
| `geocn` | Object | `division_code`(中国行政区划码)/`administrative_area`/`city`/`isp`/`type` |
| `bilibili` | Object | `country`/`administrative_area`/`city`/`isp`/`latitude`/`longitude`(在线源) |
取值约定:
- 某源**未加载**时该键为字符串 `"not loaded"`;该源**查询出错**时为字符串 `"error: ..."`。
单个源失败**不会**让整个请求失败——HTTP 状态码仍是 `200`。
- 库里没有的字段返回空串 `""`。IP2Location LITE 免费版缺失的字段(如 `isp`、`asn`)已统一过滤,不会出现 `"This parameter is unavailable"`。
- 经纬度类型各源不一致:`ip2location` 是**字符串**,`maxmind_*` / `dbip_*` 是**数字**;`bilibili` 也是字符串。
- ASN 带前缀与否各源不一致:`maxmind_asn` / `dbip_asn` 形如 `AS13335`,`ip2location_asn.asn` 是裸数字 `13335`。
## 4. 示例
### 4.1 cURL 获取本机公网 IP 及位置
```bash
curl https://cn-jiangsu.api-ipw.wsmdn.top/v1/location
```
输出示例(节选):
```json
{
"ip": "1.1.1.1",
"ip2region": { "country": "Australia", "administrative_area": "Queensland", "city": "Brisbane" },
"ip2location": {
"country": "Australia", "country_code": "AU", "administrative_area": "Queensland",
"city": "Brisbane", "zipcode": "4000", "timezone": "+10:00",
"latitude": "-27.46754", "longitude": "153.02809"
},
"maxmind_asn": { "asn": "AS13335", "org": "Cloudflare, Inc." },
"dbip_city": {
"country": "澳大利亚", "country_code": "AU", "administrative_area": "New South Wales",
"city": "Sydney", "latitude": -33.8688, "longitude": 151.209
},
"qqwry": { "country": "澳大利亚", "country_code": "AU", "administrative_area": "", "city": "", "isp": "APNIC" },
"geocn": { "division_code": "0", "isp": "", "type": "" },
"bilibili": { "country": "CLOUDFLARE.COM", "administrative_area": "CLOUDFLARE.COM", "city": "", "isp": "" }
}
```
### 4.2 Python
```python
#!/usr/bin/python3
import json
import requests
r = requests.get('https://cn-jiangsu.api-ipw.wsmdn.top/v1/location')
data = json.loads(r.text)
print(data['ip']) # 本机公网 IP
print(data['ip2location']['country'],
data['ip2location']['administrative_area'],
data['ip2location']['city']) # 取某一个源
print(data['maxmind_asn']['org']) # 运营商 / 组织
```
### 4.3 Golang
```go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
)
func main() {
resp, err := http.Get("https://cn-jiangsu.api-ipw.wsmdn.top/v1/location")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// 各源字段不一致,逐源取用;此处只解出需要的部分
var result map[string]json.RawMessage
if err := json.Unmarshal(body, &result); err != nil {
panic(err)
}
var ip string
_ = json.Unmarshal(result["ip"], &ip)
var loc struct {
Country string `json:"country"`
City string `json:"city"`
}
_ = json.Unmarshal(result["ip2location"], &loc)
fmt.Println(ip, loc.Country, loc.City)
}
```
## 5. 错误码
| 现象 | 说明 |
| --- | --- |
| `404 page not found` | 该节点 `ipdb` 已关闭:IP 库不加载时 `/v1/location`、`/v1/asn/:ip` 路由不注册 |
| `401` | 节点配了 `access-token` 而未携带正确的 `Authorization: Bearer <access-token>` |
| 全部源都是 `"error: ..."` / `"not loaded"` | IP 库仍在下载/加载,或本地库文件缺失。节点启动时自动拉取全套库(首次约 450MB),之后每 24h 更新一次 |
| `ip` 是内网地址(如 `10.x`) | 直连节点、未经代理时看到的就是内网地址,属预期 |
| `ip` 是代理/CDN 的地址 | 节点前置了 Nginx/CDN 但未配 `trusted-proxies`,`X-Forwarded-For` 不被信任。配置方式见《节点 API 与协议参考》 |
@@ -0,0 +1,129 @@
# [API] 获取客户端公网 IP 地址
## 1. 接口描述
> 该接口用于网络排障,请在中国法律许可范围内使用.
接口地址: `https://test.wsmdn.top/`(本项目公共实例,双栈;另有仅 IPv4 / 仅 IPv6 的实例,见下文)
请求方法:`GET`
用途:**原样**返回访问者的公网 IP——纯文本、无 JSON 包裹、无参数、无鉴权、不查任何 IP 库。
实现是本项目自带的两个边缘函数(任选其一部署,都只做这一件事):
| 形态 | 代码 | 取 IP 的方式 |
| --- | --- | --- |
| Cloudflare Workers | `serverless/lemon-getip` | `CF-Connecting-IP` 请求头 |
| EdgeOne Edge Functions | `serverless/edgeone-getip` | `request.eo.clientIp` |
两者都返回 `content-type: text/plain` 并带 `Access-Control-Allow-Origin: *`,所以浏览器里可以直接跨域取、也可以 `curl`。
**按协议栈部署三份**,域名的解析记录决定拿到哪一族的地址:
| 用途 | DNS 记录 | 行为 |
| --- | --- | --- |
| 双栈 | A + AAAA | 返回客户端**实际使用**的那一族地址 |
| 仅 IPv4 | 只配 A | 强制走 v4 出口,返回 IPv4 地址 |
| 仅 IPv6 | 只配 AAAA | 纯 IPv6 网络下返回 IPv6 地址;本机没有 IPv6 出口时域名**解析不出来**(请求直接失败,而不是返回空) |
本项目公共实例即按此部署:`4.wsmdn.top`(仅 IPv4)、`6.wsmdn.top`(仅 IPv6)、`test.wsmdn.top`(双栈)。
> 想把"本机 IP"和"归属地"一次拿全,用同一套体系里的 `/v1/location`(见「获取客户端公网 IP 及位置」),
> 它的 `ip` 字段与这里返回的是同一个地址。江苏节点公开实例:
> `curl https://cn-jiangsu.api-ipw.wsmdn.top/v1/location`(无需令牌;该接口没有目标参数,
> 只能直连节点,见该页的「公开实例」)。
## 2. 输入参数
无。
## 3. 输出参数
| 参数名称 | 类型 | 描述 |
| --- | --- | --- |
| 无 | String | 响应体**本身就是**客户端公网 IP 文本,例如 `223.68.219.100` |
判定要点:
- 只有 IP 一个 token,没有换行、没有 JSON 包裹;末尾是否带 `\n` 取决于边缘平台,取值前先 `strip()`。
- 不查库 ⇒ 不会出现 `"not loaded"` 之类的占位串,也没有归属地信息。
- 边缘函数按平台提供的客户端地址取值,**不解析 `X-Forwarded-For`**:它本身就是直接面向公网的入口。若你在它前面又套了一层自建代理,拿到的是代理地址。
## 4. 示例
### 4.1 cURL 获取本机公网 IP
```bash
# 双栈(返回实际使用的栈)
curl https://test.wsmdn.top
# 只要 IPv4 / 只要 IPv6
curl https://4.wsmdn.top
curl https://6.wsmdn.top
```
输出示例:
```
223.68.219.100
```
### 4.2 Python
```python
#!/usr/bin/python3
import requests
r = requests.get('https://test.wsmdn.top')
clientIP = r.text.strip()
print(clientIP)
```
### 4.3 Golang
```go
package main
import (
"fmt"
"io"
"net/http"
"strings"
)
func main() {
resp, err := http.Get("https://test.wsmdn.top")
if err != nil {
fmt.Println("获取外网 IP 失败,请检查网络")
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
clientIP := strings.TrimSpace(string(body))
fmt.Println(clientIP)
}
```
### 4.4 浏览器里直接取(CORS 已放开)
```js
const ip = await fetch('https://test.wsmdn.top').then(r => r.text())
console.log(ip.trim())
```
> 要判断"当前访问更偏向 IPv4 还是 IPv6",可以同时取双栈域名与两个单栈域名做对比,
> 思路见「JS 检查网络是 IPv4 还是 IPv6」。
## 5. 错误码
| 现象 | 说明 |
| --- | --- |
| 域名解析失败 / 连接超时 | 用的是**仅 IPv6** 的域名,而本机没有 IPv6 出口。改用双栈或仅 IPv4 的域名 |
| 返回 `Unable to determine IP address` | 边缘平台没有提供客户端地址。常见于本地直连调试(`wrangler dev` / EdgeOne 本地调试)或自建前置代理未透传 |
| 返回的是代理地址 | 你在边缘函数前面又套了一层代理;该函数只认平台提供的客户端地址,不解析 `X-Forwarded-For` |
| 拿到私有地址(如 `192.168.x.x`) | 本地/内网调试环境,边缘平台看到的就是内网出口 |
@@ -0,0 +1,189 @@
# [API] 查询指定 IP 地址的位置信息
## 1. 接口描述
> IP 库来源于网络,本项目不对结果的准确性负责,请在中国法律许可范围内使用.
接口地址: `https://cn-jiangsu.api-ipw.wsmdn.top/v1/location/<ip>`(公开实例,见 1.1)
请求方法:`GET`
用途:查询**任意 IP**(IPv4 或 IPv6)的归属地,返回与本项目其他查询同一套多源聚合结果。
- IP 写在**路径**里,不是查询串:`/v1/location/106.224.145.147`。IPv6 直接用标准写法(含 `:`)即可,无需 URL 编码以外的特殊处理。
- 与「获取客户端公网 IP 及位置」共用同一个处理器与同一份 IP 库,**区别只是"查谁"**:不带路径参数 = 查请求方自己,带 = 查指定地址。
- 出参结构与「获取客户端公网 IP 及位置」**完全一致**,见该页的字段表,此处不重复。
- 免鉴权规则同所有 `/v1/*` 端点:节点配了 `access-token` 才需要 `Authorization: Bearer`。
- 查 IP 归属地**不需要**目标是本项目节点、也不做连通性探测,只查库;因此查任何公网 IP 都是同样耗时。
### 1.1 公开实例
本项目对外开放了一个江苏节点,可以直接用它把本接口调通,**无需令牌**:
| 方式 | 地址 |
| --- | --- |
| 直连节点(推荐) | `https://cn-jiangsu.api-ipw.wsmdn.top/v1/location/<ip>` |
| 经中间件转发 | `https://middleware-1.api-ipw.wsmdn.top/v1/cn-jiangsu/location/<ip>` |
两种写法都是「节点 / 接口 / 目标」三段:直连时端点本身就是节点,经中间件时多一段节点标识。
中间件入口的 `/v1/` 前缀与 `/middleware/` 前缀等价,用哪个都行。
> 公共实例是**共享**的,调试与小流量接入没问题,请勿高频轮询、批量抓取,也不要拿它当线上服务的依赖。
### 1.2 还有别的节点 —— 拨测控制台
上面的江苏节点只是节点池里的一个。节点池的入口是拨测控制台 <https://boce.wsmdn.top>:
登录后进「可用节点」页,能看到**全部已启用节点**的节点标识、归属地、归属池(定位 / 拨测)、
协议栈与在线状态,每行都能一键复制调用前缀 —— 把上面那个中间件地址换成控制台给你的入口域名,
再接上 `/v1/<节点标识>/` 就是完整前缀,**换节点只动中间那一段标识**。
比自己搭一套省事的地方:
- **节点现成** —— 名单里的节点都是已经部署好的,不用自己找机器、装程序、配隧道。
- **入口现成** —— 统一走 HTTPS 转发,证书与反向代理不用自己操心。
- **一套地址调所有节点** —— 换节点只改标识,接口路径、参数、返回结构都不变。
- **池与栈标得很清楚** —— 定位类请求走定位池,拨测类走拨测池,两个池都有的节点两种都能接。
- **名单会自己更新** —— 新接入的节点自动出现在列表里,不需要改代码。
用它调本接口(可直接粘贴执行):
```bash
# 用列表里的江苏节点查一个 IP
curl https://middleware-1.api-ipw.wsmdn.top/v1/cn-jiangsu/location/106.224.145.147
# 换成别的节点:只改中间那一段标识
curl https://middleware-1.api-ipw.wsmdn.top/v1/<节点标识>/location/106.224.145.147
```
> 节点有上下线,**离线的节点调用会失败**(返回 `502`)——名单上标了在线状态,挑在线的用。
## 2. 输入参数
| 参数名称 | 类型 | 描述 |
| --- | --- | --- |
| `ip`(必选,路径) | String | 要查询的 IP,IPv4 或 IPv6 均可,例如 `/v1/location/106.224.145.147` |
> 路径中的 IP 不要带方括号。IPv6 形如 `/v1/location/2400:3200::1`。
## 3. 输出参数
与「获取客户端公网 IP 及位置」相同:顶层为「源名 → 该源结果」的字典,`ip` 字段回显被查询的地址。取值约定(未加载、出错、空字段、经纬度类型)也一致。
**非法 IP 是"看起来成功"的**:IP 传错时 HTTP 仍返回 `200`,但每个源会各自报错,形如:
```json
{
"ip": "not-an-ip",
"ip2region": "error: parse ip not-an-ip: invalid ip address: not-an-ip",
"maxmind_city": "error: ParseAddr(\"not-an-ip\"): unable to parse IP",
"ip2location": { "country": "Invalid IP address.", "city": "Invalid IP address." },
"ip2location_asn": { "asn": "Invalid IP address.", "as": "Invalid IP address." }
}
```
所以**判断成功与否要看字段内容,不能只看状态码**:字段是对象才有结论;是 `"error: ..."` 字符串或 `"Invalid IP address."` 就是没查到。建议调用前先自行校验 IP 格式。
## 4. 示例
### 4.1 cURL 查询指定 IP 的位置
```bash
curl https://cn-jiangsu.api-ipw.wsmdn.top/v1/location/106.224.145.147
# IPv6 同样直接拼:curl https://cn-jiangsu.api-ipw.wsmdn.top/v1/location/2400:3200::1
# 经公共中间件转发(见 1.1):
# curl https://middleware-1.api-ipw.wsmdn.top/v1/cn-jiangsu/location/106.224.145.147
```
输出示例(节选,真实响应):
```json
{
"ip": "106.224.145.147",
"ip2region": { "country": "中国", "administrative_area": "江西省", "city": "南昌市", "isp": "电信" },
"ip2location": {
"country": "China", "country_code": "CN", "administrative_area": "Jiangxi",
"city": "Ji'an", "zipcode": "343000", "timezone": "+08:00",
"latitude": "27.11716", "longitude": "114.97927"
},
"qqwry": { "country": "中国", "country_code": "CN", "administrative_area": "江西", "city": "吉安", "isp": "电信" },
"geocn": { "country": "中国", "administrative_area": "江西省", "city": "南昌市", "division_code": "360100", "isp": "电信" },
"maxmind_asn": { "asn": "AS4134", "org": "Chinanet" }
}
```
> 上面同一 IP 出现了**南昌 / 吉安**两种结论:这就是"多源并列、不做合并"的含义——
> 各库口径与更新批次不同,写法也不统一(`江西省` / `Jiangxi`、`南昌市` / `Nanchang`)。
> 想要单一结论请自行选源或做交叉投票。
### 4.2 Python
```python
#!/usr/bin/python3
import requests
queryIP = '106.224.145.147'
r = requests.get('https://cn-jiangsu.api-ipw.wsmdn.top/v1/location/' + queryIP)
data = r.json()
qqwry = data.get('qqwry', {})
if isinstance(qqwry, dict):
print(qqwry.get('country'), qqwry.get('administrative_area'),
qqwry.get('city'), qqwry.get('isp'))
else:
print('该源不可用:', qqwry)
```
### 4.3 Golang
```go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
)
func main() {
queryIP := "106.224.145.147"
resp, err := http.Get("https://cn-jiangsu.api-ipw.wsmdn.top/v1/location/" + queryIP)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]json.RawMessage
if err := json.Unmarshal(body, &result); err != nil {
panic(err)
}
var loc struct {
Country string `json:"country"`
City string `json:"city"`
Isp string `json:"isp"`
}
if err := json.Unmarshal(result["qqwry"], &loc); err != nil {
fmt.Println("该源不可用,原始值:", string(result["qqwry"]))
return
}
fmt.Printf("%s %s %s\n", loc.Country, loc.City, loc.Isp)
}
```
## 5. 错误码
| 现象 | 说明 |
| --- | --- |
| 字段是 `"Invalid IP address."`(`ip2location` 系列) | 路径里的 IP 不合法。该库对非法输入仍返回对象,值里写明原因 |
| 字段是 `"error: ... unable to parse IP"` | 同上,其它源对非法输入直接返回错误字符串 |
| `404 page not found` | 该节点 `ipdb` 已关闭,`/v1/location/:ip` 未注册 |
| `401` | 节点配了 `access-token` 但未携带正确的 `Authorization: Bearer <access-token>` |
| 结果与常识不符 | 免费 IP 库本身有偏差;本项目是**多源并列**,可交叉比对,不保证单一源正确 |
@@ -0,0 +1,228 @@
# [API] 查询域名的 WHOIS 信息
## 1. 接口描述
> 该接口用于学习域名基础信息,请在中国法律许可范围内使用.
接口地址: `https://cn-jiangsu.api-ipw.wsmdn.top/v1/whois/<domain>`(公开实例,见 1.1)
请求方法:`GET`
用途:查询域名注册信息——注册局记录 + 注册商数据,结构化返回,同时附带原始文本。
本项目自有的查询流程(不是简单地把库调用包一层):
1. **先问 IANA**:用域名后缀查 `whois.iana.org`,拿到该后缀的注册局服务器(不猜、不硬编码后缀表);
2. **只查注册局**:关闭 whois 库自带的转介链,避免被不可达的注册商服务器拖到超时;
3. **补查注册商**:若注册局响应里给出注册商转介服务器(如 `.com` 的 thick 注册局),**限时**再查一次,把结果追加进 `raw`;这一步失败不影响已到手的注册局数据;
4. **解析失败不算请求失败**:查询/解析的错误放进响应体的 `error` 字段,HTTP 状态码仍是 `200`。
其他特性:
- **结果缓存 5 分钟**。缓存键是**传入的原始字符串**,未做归一化:`QQ.com` 与 `qq.com` 各占一份。
- **IPv4 / IPv6 双栈解析** whois 服务器地址(Happy Eyeballs,v6 延迟 150ms 起步),某一栈不通时另一栈仍可用。
- **超时预算是分层的**:库单跳 6s(主路径 IANA + 注册局两跳),注册商转介补查另有 3s 总预算且失败即弃。
- **入参不做归一化**:不剥 `www.`、不吃 `host:port`。要查的是"注册的那个域名"本身。
- `QQ.COM`、`qq.com` 都可以(大小写不敏感,结果里 `domain` 统一大写);
- `www.qq.com` 会去查这个**子域**,注册信息为空——不是接口坏了;
- `qq.com:43` 不支持,返回 `whois: no whois server found for domain: qq.com:43`。
- 免鉴权规则同所有 `/v1/*` 端点:节点配了 `access-token` 才需要 `Authorization: Bearer`。
### 1.1 公开实例
本项目对外开放了一个江苏节点,可以直接用它把本接口调通,**无需令牌**:
| 方式 | 地址 |
| --- | --- |
| 直连节点(推荐) | `https://cn-jiangsu.api-ipw.wsmdn.top/v1/whois/<domain>` |
| 经中间件转发 | `https://middleware-1.api-ipw.wsmdn.top/v1/cn-jiangsu/whois/<domain>` |
两种写法都是「节点 / 接口 / 目标」三段:直连时端点本身就是节点,经中间件时多一段节点标识。
中间件入口的 `/v1/` 前缀与 `/middleware/` 前缀等价,用哪个都行。
> 公共实例是**共享**的,调试与小流量接入没问题,请勿高频轮询、批量抓取,也不要拿它当线上服务的依赖。
### 1.2 还有别的节点 —— 拨测控制台
上面的江苏节点只是节点池里的一个。节点池的入口是拨测控制台 <https://boce.wsmdn.top>:
登录后进「可用节点」页,能看到**全部已启用节点**的节点标识、归属地、归属池(定位 / 拨测)、
协议栈与在线状态,每行都能一键复制调用前缀 —— 把上面那个中间件地址换成控制台给你的入口域名,
再接上 `/v1/<节点标识>/` 就是完整前缀,**换节点只动中间那一段标识**。
比自己搭一套省事的地方:
- **节点现成** —— 名单里的节点都是已经部署好的,不用自己找机器、装程序、配隧道。
- **入口现成** —— 统一走 HTTPS 转发,证书与反向代理不用自己操心。
- **一套地址调所有节点** —— 换节点只改标识,接口路径、参数、返回结构都不变。
- **池与栈标得很清楚** —— 定位类请求走定位池,拨测类走拨测池,两个池都有的节点两种都能接。
- **名单会自己更新** —— 新接入的节点自动出现在列表里,不需要改代码。
用它调本接口(可直接粘贴执行):
```bash
# 用列表里的江苏节点查一个域名
curl https://middleware-1.api-ipw.wsmdn.top/v1/cn-jiangsu/whois/qq.com
# 换成别的节点:只改中间那一段标识
curl https://middleware-1.api-ipw.wsmdn.top/v1/<节点标识>/whois/qq.com
```
> 节点有上下线,**离线的节点调用会失败**(返回 `502`)——名单上标了在线状态,挑在线的用。
## 2. 输入参数
| 参数名称 | 类型 | 描述 |
| --- | --- | --- |
| `domain`(必选,路径) | String | 要查询的域名,例如 `/v1/whois/qq.com`。写注册域名本身,不要带子域 |
## 3. 输出参数
| 参数名称 | 类型 | 描述 |
| --- | --- | --- |
| `domain` | String | 归一化后的域名(**大写**,如 `QQ.COM`) |
| `status` | Array \| null | EPP 状态码列表,如 `["clientTransferProhibited"]`;**没拿到时为 `null`**(不是空数组) |
| `registrar.name` | String | 注册商名称 |
| `registrar.ianaId` | String | 注册商 IANA ID |
| `registrant` | Object | 注册人:`name`/`org`/`phone`/`email`/`province`/`contactUri` |
| `technical` | Object | 技术联系人,字段同上 |
| `abuseContact` | Object | 滥用投诉联系人,字段同上 |
| `dates.registration` | String | 注册时间(RFC3339,缺失为 `""`) |
| `dates.expiration` | String | 到期时间(RFC3339,缺失为 `""`) |
| `dates.lastChanged` | String | 最后变更时间(RFC3339,缺失为 `""`) |
| `nameservers` | Array | NS 列表(保留注册局原始大小写) |
| `whoisServer` | String | 最终实际查询的 whois 服务器;未找到后缀对应服务器时为空串 |
| `raw` | String | 注册局响应原文(含补查到的注册商内容),排查用 |
| `error` | String | 查询/解析错误信息;成功时为空串 `""` |
判定要点:
- **字段缺失一律返回空串,不会省略键**;所以判断"有没有数据"要看内容而不是键是否存在。
- `status` 为 `null` 与 `[]` 含义不同:前者是**没取到**,后者是取到了但确实没有状态。
- 隐私保护(GDPR、注册商隐私服务)会让 `registrant` / `technical` 的字段为空或提示串(例如要求到注册商网页填表才能看邮箱),这是上游数据的样子,不是解析失败。
- 想拿"能不能注册 / 什么时候释放",以 `dates.expiration` 与 `status`(是否 `pendingDelete`、`redemptionPeriod`)为准。
## 4. 示例
### 4.1 cURL 查询域名注册信息
```bash
curl https://cn-jiangsu.api-ipw.wsmdn.top/v1/whois/qq.com
# 经公共中间件转发(见 1.1):
# curl https://middleware-1.api-ipw.wsmdn.top/v1/cn-jiangsu/whois/qq.com
```
输出示例(真实响应,节选):
```json
{
"domain": "QQ.COM",
"status": [
"clientDeleteProhibited", "clientTransferProhibited", "clientUpdateProhibited",
"serverDeleteProhibited", "serverTransferProhibited", "serverUpdateProhibited"
],
"registrar": { "name": "MarkMonitor Information Technology (Shanghai) Co., Ltd.", "ianaId": "3838" },
"registrant": {
"name": "",
"org": "深圳市腾讯计算机系统有限公司",
"phone": "",
"email": "select request email form at https://domains.markmonitor.com/whois/qq.com",
"province": "",
"contactUri": ""
},
"abuseContact": { "name": "", "org": "", "phone": "+1.2086851750", "email": "abusecomplaints@markmonitor.com" },
"dates": {
"registration": "1995-05-04T04:00:00Z",
"expiration": "2034-07-27T02:09:19Z",
"lastChanged": "2026-09-23T10:00:17Z"
},
"nameservers": ["ns1.qq.com", "ns2.qq.com", "ns3.qq.com", "ns4.qq.com"],
"whoisServer": "whois.markmonitor.com",
"error": ""
}
```
### 4.2 Python
```python
#!/usr/bin/python3
import requests
queryDomain = 'qq.com'
r = requests.get('https://cn-jiangsu.api-ipw.wsmdn.top/v1/whois/' + queryDomain)
data = r.json()
if data.get('error'):
print('查询出错:', data['error'])
else:
print('Domain Name: %s' % data['domain'])
print('Status: %s' % data.get('status'))
print('Registrar: %s' % data['registrar']['name'])
print('Registration Time: %s' % data['dates']['registration'])
print('Expiration Time: %s' % data['dates']['expiration'])
```
### 4.3 Golang
```go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
)
func main() {
queryDomain := "qq.com"
resp, err := http.Get("https://cn-jiangsu.api-ipw.wsmdn.top/v1/whois/" + queryDomain)
if err != nil {
fmt.Println("请检查网络")
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result struct {
Domain string `json:"domain"`
Status []string `json:"status"`
Nameservers []string `json:"nameservers"`
Dates struct {
Registration string `json:"registration"`
Expiration string `json:"expiration"`
} `json:"dates"`
Registrar struct {
Name string `json:"name"`
} `json:"registrar"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &result); err != nil {
panic(err)
}
if result.Error != "" {
fmt.Println("查询出错:", result.Error)
return
}
fmt.Printf("Domain: %s\n", result.Domain)
fmt.Printf("Registrar: %s\n", result.Registrar.Name)
fmt.Printf("Expiration: %s\n", result.Dates.Expiration)
}
```
## 5. 错误码
| 现象 | 原因与处理 |
| --- | --- |
| `400 {"error":"Domain parameter is required"}` | 路径里没有域名 |
| `401` | 节点配了 `access-token` 而未携带正确的 `Authorization: Bearer <access-token>` |
| `500 {"error":"..."}` | 上游查询异常(网络不可达等),可稍后重试 |
| `200` 且 `error` 为 `whois: no whois server found for domain: xxx` | 该后缀**没有 whois 服务器**(国家后缀常见)或不支持。换用该后缀的注册局查询入口 |
| `200` 且各字段全空、`status` 为 `null` | 域名格式不合法(如 `not_a_domain`),IANA 查不到对应后缀;或传了子域(如 `www.qq.com`),因注册信息不在子域上故为空 |
| `200` 但 `registrant`/`technical` 为空或提示串 | 上游做了隐私保护;`raw` 里能看到原文措辞 |
| 改了域名的注册信息但查询结果是旧的 | 命中 5 分钟缓存;等待或用另一个节点查(缓存是**节点本地**的) |
@@ -5,7 +5,7 @@
原版支持 IPv6 本地网络(宽带)开启验证、IPv6 网站开启检测、IPv6 TCPing 延迟测试、IPv6 网站测速、IPv6地址查询、IPv6 DNS解析。
镜像站点由于开发者财力限制,测速、TCPing、DNS等需要多地测速节点的功能无重现计划
本文档并不一定完全和ipw.cn文档相同,部分文段有删改
本文档并不一定完全和原版文档相同,部分文段有删改
## 本地网络(宽带)开启IPv6验证
工具地址:[IPv6 地址查询](https://ipw.wsmdn.top/)。
@@ -0,0 +1,68 @@
# <span style="background-color: #b95442;color: white;font-size: 0.43em;border-radius: 5px;padding: 2px 5px;">转载</span> JS:检查网络是IPv4还是IPv6
> 仅供个人测试学习之用,本站不对稳定性负责。
通过一段 js 代码可以显示用户请求网络是 IPv4 访问优先,还是 IPv6 访问优先。
## 效果
> 如果你没有 IPv6 的网络环境,可以先用手机流量访问该页面。
## 代码
```html
<template>
<div>
<a href='https://nipw.cn' target='_blank'> <b>访客IP:</b> {{IP}},您的网络 {{IPVersion}} 访问优先</a>
</div>
</template>
<script>
import Axios from "axios";
export default {
name: "Home",
data(){
return{
IP: "",
IPVersion: "",
}
},
headers:
{
'X-Requested-With': 'XMLHttpRequest',
'Access-Control-Allow-Origin': "*"
},
methods:{
getData(){
var that=this
// 双栈接口:按本次请求实际走的协议,返回纯文本的 IPv4 或 IPv6 地址
var api = "https://test.wsmdn.top";
Axios.get(api)
.then(function (res) {
// handle success
console.log(res.data);
var ip = String(res.data).trim();
console.log(ip);
that.IP = ip
// 返回的地址含冒号即 IPv6,否则为 IPv4
that.IPVersion = ip.indexOf(':') > -1 ? "IPv6" : "IPv4"
console.log(that.IPVersion);
})
.catch(function (error) {
// handle error
console.log(error);
})
.then(function () {
// always executed
});
},
},
mounted(){
this.getData();
},
}
</script>
```
@@ -35,7 +35,7 @@
查看 Response Header 来确认服务器支持的 HTTP 的版本。
```
$ curl -I https://ipw.cn
$ curl -I https://nipw.cn
HTTP/2 200
server: nginx
content-type: text/html
@@ -0,0 +1,26 @@
# <span style="background-color: #b95442;color: white;font-size: 0.43em;border-radius: 5px;padding: 2px 5px;">转载</span> 网站如何开启 TLS
## TLS是什么?
[RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3](https://datatracker.ietf.org/doc/html/rfc8446)
TLS allows client/server applications to communicate over the Internet in a way that is designed to prevent eavesdropping, tampering, and message forgery.
### Major Differences from TLS 1.2
## Wireshark 抓包分析
### TCP 三次握手
- SYN ![SYN](/doc/tls_tcp_01_syn.png)
- SYN ACK ![SYN ACK](/doc/tls_tcp_02_syn_ack.png)
- ACK ![ACK](/doc/tls_tcp_03_ack.png)
### TLS 协商
以 TLS 1.3 为例
- Client Hello ![Client Hello](/doc/tls_01_client_hello.png)
- Server Hello, Change Cipher Spec, Encrypted Extensions ![Server Hello, Change Cipher Spec, Encrypted Extensions](/doc/tls_02_server_hello_change_cipher_spec_encrypted_extensions.png)
- Certificate, Certificate Verify, Finished ![Certificate, Certificate Verify, Finished](/doc/tls_03_certificate_verify_finished.png)
- Change Cipher Spec, Finished ![Change Cipher Spec, Finished](/doc/tls_04_change_cipher_spec_finished.png)
@@ -12,7 +12,7 @@
- 禁用 IPv6 本地网络
```sh
# 验证是否可以访问 IPv6 网络
$ curl 6.ipw.cn
$ curl 6.wsmdn.top
2408:824c:200::2b8b:336f:cc9c
# 找到网络设备的名字,这里用的是 Wi-Fi (因为 macbook 用的是无线网络访问,即 Wi-Fi)
@@ -25,12 +25,12 @@ Bluetooth PAN
$ networksetup -setv6off Wi-Fi
# 请求 IPv4/IPv6 双栈网络,返回 IPv4 地址,证明 IPv4 访问优先。
$ curl test.ipw.cn
$ curl test.wsmdn.top
159.75.190.197
# 访问 IPv6 网络失败
$ curl 6.ipw.cn
curl: (6) Could not resolve host: 6.ipw.cn
$ curl 6.wsmdn.top
curl: (6) Could not resolve host: 6.wsmdn.top
```
- 启用 IPv6 本地网络
```sh
@@ -38,7 +38,7 @@ curl: (6) Could not resolve host: 6.ipw.cn
$ networksetup -setv6automatic Wi-Fi
# 验证本地 IPv6 网络访问是否成功
$ curl test.ipw.cn
$ curl test.wsmdn.top
2408:824c:200::2b8b:336f:cc9c
```
## Window10 禁用或启用 IPv6 本地网络
@@ -55,7 +55,7 @@ WLAN Internet 协议版本 6 (TCP/IPv6)
以太网 Internet 协议版本 6 (TCP/IPv6) ms_tcpip6 True
# 验证本地 IPv6 网络是否访问成功
PS C:\WINDOWS\system32> curl 6.ipw.cn
PS C:\WINDOWS\system32> curl 6.wsmdn.top
Content : 2408:824c:200::2b8b:336f:cc9c
# 禁用 WLAN 设备的本地 IPv6 网络(因为笔记本用的是 无线,即 WLAN)
@@ -70,10 +70,10 @@ WLAN Internet 协议版本 6 (TCP/IPv6)
以太网 Internet 协议版本 6 (TCP/IPv6) ms_tcpip6 True
# 无法访问 IPv6 网络
PS C:\WINDOWS\system32> curl 6.ipw.cn
PS C:\WINDOWS\system32> curl 6.wsmdn.top
curl : 无法连接到远程服务器
PS C:\WINDOWS\system32> curl 4.ipw.cn
PS C:\WINDOWS\system32> curl 4.wsmdn.top
Content : 159.75.190.197
```
- 启用 IPv6 本地网络
@@ -82,7 +82,7 @@ Content : 159.75.190.197
PS C:\WINDOWS\system32> Enable-NetAdapterBinding -Name "WLAN" -ComponentID ms_tcpip6
# 验证 IPv6 网络是否访问成功
PS C:\WINDOWS\system32> curl 6.ipw.cn
PS C:\WINDOWS\system32> curl 6.wsmdn.top
Content : 2408:824c:200::2b8b:336f:cc9c
```
@@ -97,15 +97,15 @@ Content : 2408:824c:200::2b8b:336f:cc9c
$ networksetup -setv4off Wi-Fi
# 访问 IPv4 网络失败
$ curl 4.ipw.cn
curl: (6) Could not resolve host: 4.ipw.cn
$ curl 4.wsmdn.top
curl: (6) Could not resolve host: 4.wsmdn.top
```
- 启用 IPv4 本地网络
```sh
$ networksetup -setdhcp Wi-Fi
$ curl 4.ipw.cn
$ curl 4.wsmdn.top
159.75.190.197
```
### Window10 禁用/启用IPv4本地网络
@@ -115,13 +115,13 @@ $ curl 4.ipw.cn
```sh
PS C:\WINDOWS\system32> Disable-NetAdapterBinding -Name "WLAN" -ComponentID ms_tcpip
PS C:\WINDOWS\system32> curl 4.ipw.cn
PS C:\WINDOWS\system32> curl 4.wsmdn.top
curl : 无法连接到远程服务器
```
- 启用 IPv4 本地网络
```sh
PS C:\WINDOWS\system32> Enable-NetAdapterBinding -Name "WLAN" -ComponentID ms_tcpip
PS C:\WINDOWS\system32> curl 4.ipw.cn
PS C:\WINDOWS\system32> curl 4.wsmdn.top
159.75.190.197
```
Loaded 100 of 186 files, more files were not shown because too many files have changed in this diff. Show more