Compare commits

..
28 Commits
Author SHA1 Message Date
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 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
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
76 changed files with 4981 additions and 3262 deletions

No files matched your search

+14
View File
@@ -63,6 +63,15 @@ jobs:
- 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
@@ -87,6 +96,11 @@ jobs:
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 }}
+5
View File
@@ -20,6 +20,11 @@ node_modules/
docs/.vitepress/.temp/
docs/.vitepress/cache/
docs/.vitepress/dist/
# 构建期临时配置文件(vitepress 每次构建生成,用完即弃)
docs/.vitepress/*.timestamp-*
tmp/
setting.json
*.BIN
# 本地调试脚本与实测产物(不入库,与 ipw-boce 的约定一致)
tmp-smoke/
+3
View File
@@ -12,11 +12,14 @@ curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o ins
完整部署(后端节点 / 独立中间件 / 前端)与配置说明见 [文档](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)(收集中心程序化接口:鉴权、端点与响应示例、公开状态接口)
## 项目结构
+9
View File
@@ -52,6 +52,7 @@ export default defineConfig({
text: '简介',
items: [
{ text: '概述', link: '/info/overview' },
{ text: '功能总览', link: '/info/features' },
{ text: '快速入门', link: '/guide/getting-started' },
{ text: '配置文件', link: '/guide/config' },
],
@@ -66,6 +67,14 @@ export default defineConfig({
{ text: '前端', link: '/guide/deploy-frontend' },
],
},
// 参考
{
text: '参考',
items: [
{ text: '节点 API 与协议', link: '/guide/node-api' },
{ text: '公开 API(/api/v1)', link: '/guide/public-api' },
],
},
],
socialLinks: [
+63 -9
View File
@@ -20,17 +20,44 @@
- `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 防护开关
- `ipdb`:IP 数据库开关(首次启动自动下载约 450MB,之后每 24h 更新)
- `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 访问令牌,留空则不启用鉴权
- `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 指令)
- `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)
- `node-ota`:**OTA 自更新开关**(`"true"` 启用)。启用后节点定期检查本仓库 GitHub Release,发现新版本时下载与当前平台匹配的二进制、**替换自身并重启**(旧二进制保留为 `<程序名>.old` 便于回滚);默认关闭
- `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
以上字段均可用环境变量覆盖(`PORTS` / `DNS_SERVER` / `DNSSEC_DNS_SERVER` / `BLOCK_PRIVATE_IPS` / `IPDB` / `CORS` / `TRUSTED_PROXIES` / `ACCESS_TOKEN` / `WS_URL` / `NODE_ID` / `NODE_KEY` / `NODE_OTA`)。需要从远端拉取配置时,设置 `remote-config-url` 或环境变量 `REMOTE_CONFIG_URL`(优先级:远端 > 环境变量 > setting.json)。
以上字段均可用环境变量覆盖(`PORTS` / `SINGLE_STACK` / `DNS_SERVER` / `DNSSEC_DNS_SERVER` / `BLOCK_PRIVATE_IPS` / `IPDB` / `CORS` / `TRUSTED_PROXIES` / `GH_PROXY` / `ACCESS_TOKEN` / `WS_URL` / `NODE_ID` / `NODE_KEY` / `NODE_OTA` / `REPORT_URL` / `REPORT_TOKEN` / `REPORT_INTERVAL_SECONDS`)。需要从远端拉取配置时,设置 `remote-config-url` 或环境变量 `REMOTE_CONFIG_URL`(优先级:远端 > 环境变量 > setting.json);`remote-ignore-config` 对应的环境变量为 `REMOTE_IGNORE_CONFIG`(JSON 数组字符串)。
### 运行时配置管理
除启动加载外,节点(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` 每次消费重读,同样即时生效。
- **内存改动会被重启顶掉**:节点侧优先级为 远端 > 环境变量 > `setting.json`。只 PATCH 内存(或 `persist` 写本地文件)时,重启后仍可能被 ENV 顶掉;要长期生效应 **`refresh`**(改远端配置)或把改动合并进托管配置。
> [!NOTE]
> **与托管配置的区别**:托管配置(收集中心的 `node_configs`,经 `GET /remote-config/:nodeId` 下发)是**节点启动时拉的远端配置**,是持久来源;运行时配置读写的是**节点进程内存里当前生效的值**。前者决定"重启后是什么",后者反映"现在是什么"。
接口细节见 [节点 API 与协议参考](/guide/node-api) 的「管理接口」一节。
---
@@ -141,6 +168,22 @@ APIBaseURL: {
也可以在 `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)
@@ -179,7 +222,7 @@ APIBaseURL: {
- `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 通道通信,详见 [中间件部署 - WS 通道](/guide/deploy-middleware#ws-通道拨测数据经-websocket-传输)
- 节点池与前端 `config/index.ts` 结构一致:`APIBaseURL` 为拨测节点池(含 `DualStack` / `IPv4` / `IPv6` 三栈),`IPLocationAPI` 为 IP 归属地 / ASN 节点池(纯数组,无栈);节点加 `"ws": true` 可让该节点走 WS 通道通信,详见 [中间件部署](/guide/deploy-middleware) 的「WS 通道」一节
### WS 通道配置一览
@@ -193,7 +236,15 @@ WS 通道涉及**中间件(服务端)**与**后端节点(客户端)**两
| 节点(客户端) | `node-id` | `NODE_ID` | 节点 id,与中间件 `api-keys`/`ws-keys` 键一致(建议 UUID) |
| 节点(客户端) | `node-key` | `NODE_KEY` | 注册 key,与中间件 `ws-keys[节点id]` 一致,**必填** |
**链路**:中间件收到 `ws:true` 节点的拨测请求 → 经 WS 发 `probe` → 节点执行探针(直调 webtest 函数 + 缓存)→ `probe_result` 回传 → 中间件返回 HTTP 响应(`Content-Type: application/json`)。节点侧接入步骤见 [后端节点部署 - WS 通道接入](/guide/deploy-node#ws-通道接入可选)。
**数据上报通道**(节点把自身观测的统计/拨测上报到收集中心,归属规则防双算——中间件转发的请求由中间件上报,节点只报直连/前端/边缘流量):
| 角色 | 配置项 | 环境变量 | 说明 |
|------|--------|----------|------|
| 节点(客户端) | `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 通道接入」一节。
---
@@ -216,8 +267,11 @@ WS 通道涉及**中间件(服务端)**与**后端节点(客户端)**两
- 部分字段缺省不会冲掉本地配置——远端只需包含要覆盖的字段
- **敏感凭据强制忽略,永远不被远端覆盖**(代码写死,无需配置):
- 后端 `access-token`:不随远端覆盖(保持 环境变量 > setting.json),远端里的 `access-token` 会被忽略
- 后端 `report-token`:同上。收集中心的托管配置里写了也不生效,节点会在日志与控制台应答的 `protectedIgnored` 里列出被跳过的键
- 中间件 `api-keys` / `ws-keys`:不随远端覆盖,远端里的 `api-keys`/`ws-keys` 会被忽略
- `remote-ignore-config`(后端键名 `remote-ignore-config` / env `REMOTE_IGNORE_CONFIG`):额外指定**不被远端覆盖的配置项列表**,数组中的键即使远端下发也不生效。适用于 access-token / api-keys / ws-keys 之外的敏感项(如 `node-key`、`dns-server` 等),也可覆盖"非空才覆盖"规则
为什么把这两项写死:它们被远端改错时的故障形态都是「远端一错、本地失联、且无法再远程修回」——`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` 等),也可覆盖"非空才覆盖"规则
**拉取行为**:
@@ -225,7 +279,7 @@ WS 通道涉及**中间件(服务端)**与**后端节点(客户端)**两
- 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`):
+2 -2
View File
@@ -26,7 +26,7 @@ pnpm dev
## 配置说明
前端所有配置集中在 `frontend-ssr/config/index.ts`,完整配置项见 [配置文件 - 前端](/guide/config#前端-configindexts):
前端所有配置集中在 `frontend-ssr/config/index.ts`,完整配置项见 [配置文件](/guide/config) 的「前端」一节:
| 配置项 | 说明 |
|--------|------|
@@ -106,4 +106,4 @@ node .output/server/index.mjs # 默认监听 3000 端口
## 常见问题
- **部署后接口 403 / 跨域**:检查后端 `cors` 配置(独立中间件见 `middleware-go/setting.json` 的 `cors` 字段,逗号分隔允许域名);服务端转发请求不带 `Origin`,浏览器直接调用才受 CORS 限制。
- **出站 IP 检测接口被浏览器拦截**:页面直连的 IP 检测接口需要 CORS,详见 [配置文件 - 出站 IP 检测接口](/guide/config#出站-ip-检测接口v4onlyapi--v6onlyapi--dualstackapi)。
- **出站 IP 检测接口被浏览器拦截**:页面直连的 IP 检测接口需要 CORS,详见 [配置文件](/guide/config) 的「出站 IP 检测接口」一节。
+9 -7
View File
@@ -50,9 +50,9 @@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o middleware-go-linux-amd64 .
**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#远端配置remoteconfigurl))。
所有配置可用环境变量覆盖(`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#方案五一键安装installsh) 的 systemd 管理方式(`ExecStart` 指向中间件二进制,`WorkingDirectory` 指向 `middleware-go/setting.json` 所在目录)。
需要守护运行时,参考 [后端节点部署](/guide/deploy-node) 的「方案五:一键安装」一节的 systemd 管理方式(`ExecStart` 指向中间件二进制,`WorkingDirectory` 指向 `middleware-go/setting.json` 所在目录)。
## WS 通道(拨测数据经 WebSocket 传输)
@@ -90,18 +90,20 @@ middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入
| 方向 | type | data 说明 |
|------|------|-----------|
| 节点 → middleware | `register` | `{ "nodeId": "...", "key": "..." }`,连接后首条消息,同 id 新连接顶掉旧连接;`key` 为注册凭证,与 setting.json `ws-keys` 配置比对 |
| 节点 → 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 | `probe_result` | `{ "requestId": "...", "status": 200, "body": <JSON 值> }`(body 为 JSON 字符串时按原文透传) |
| middleware → 节点 | `ping` | 心跳,节点回 `pong` |
| middleware → 节点 | `ping` | 心跳(每 20s),节点回 `pong` |
| middleware → 节点 | `status` | 状态/统计上报:`{ "uptimeSeconds": ..., "totalRequests": ..., "errorRequests": ... }`(每 20s) |
| 节点 → middleware | `command` | 指令下发,如 `{ "command": "reload_config" }`(重读配置;与并发请求存在竞态,生产建议重启生效) |
### 节点侧接入(WS 客户端)
1. 连接 `ws://<中间件IP>:8092/ws`(本仓库后端节点接入方式见 [后端节点部署 - WS 通道接入](/guide/deploy-node#ws-通道接入可选),配置 `WS_URL` / `NODE_ID` / `NODE_KEY` 即可自动连接注册;`WS_URL` 支持逗号分隔多个中间件地址,节点会**同时连接全部(多活)**,任一断开只重连自己)
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` 可忽略或记录
+74 -15
View File
@@ -18,6 +18,22 @@ 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 边缘函数版本):
@@ -42,10 +58,13 @@ docker run -d --restart unless-stopped -p 8080:8080 \
lemon-ipw
```
> `--restart unless-stopped`:主进程退出时 Docker 自动拉起容器(OTA 更新失败自愈依赖它);手动 `docker stop` 不会触发重启。
> `--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
@@ -151,22 +170,62 @@ NODE_KEY=<注册key,与中间件 ws-keys[节点id] 一致;节点未配置 ke
**注意**:`NODE_ID` 必须与中间件 `APIBaseURL` / `IPLocationAPI` 池中的节点 `id` 一致,且该节点需配置 `"ws": true` 才会走 WS 通道。未连接中间件时,`ws:true` 节点的拨测会返回 502。
## OTA 自更新(可选)
## 运行时配置管理(可选)
节点支持**自动升级**:检测到本仓库有新 Release 时,下载对应平台的二进制、**替换自身并重启**。
节点暴露 `/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
# 启用(环境变量,或 setting.json 的 "node-ota": "true")
NODE_OTA=true ./lemonipw
# Docker:拉取新镜像后重建容器
docker pull lemon-ipw && docker stop lemon-ipw && docker run ... lemon-ipw
# systemd:替换二进制后重启服务
systemctl restart lemon-ipw
```
- **默认关闭**,需显式开启;远端配置(`node-ota`)也可统一开关,除非列入 `remote-ignore-config`
- **检查时机**:启动 5 分钟后首次检查,之后每 6 小时 + 随机 0~1h 抖动
- **major 变化不自动更新**:仅 minor/patch 升级自动跟进(如 3.1.0 → 3.2.0);主版本变化(如 3.x → 4.x,通常含破坏性变更)会跳过并告警,需人工升级
- **更新流程**:查询 GitHub 最新 Release → 按 `GOOS/GOARCH` 匹配资产(如 `lemonipw-linux-amd64`、`lemonipw-windows-amd64.exe`)→ 下载到同目录临时文件(经 `gh-proxy` 前缀加速,文件过小则丢弃)→ 当前二进制改名备份为 `<程序名>.old` → 新二进制就位 → 重启
- **重启方式**:Linux/macOS 用 `exec` 原地替换进程镜像(PID 不变,systemd / Docker 无感);Windows 另起新进程后退出当前进程
- **失败处理**:下载/替换失败仅记录日志并继续运行,下次周期重试;替换过程中断会自动回滚到 `.old`
- **注意事项**:
- 二进制所在目录需**可写**(Docker 只读层或只读挂载会导致替换失败)
- 建议保留 `.old` 备份以便手动回滚:`mv lemonipw.old lemonipw`
- systemd 部署时若 OTA 重启未被托管,可配 `Restart=always` 兜底
## 版本与能力上报
节点无论是否接入 WS,都会上报自身版本与支持的管理能力:
- **版本号**来自构建时注入的 `VERSION`,随 WS `register` 报文上报(字段 `version`);纯 HTTP 主节点(无 WS 连接)由收集中心探活后再请求 `GET /info` 取回(`GET /` 只返回 `{"status":"ok"}`)。边缘函数版本不提供 `/info`。
- **能力清单**(`capabilities`)取值为 `probe` / `report` / `config` / `ota`,用于让收集中心在下发管理指令前判断该节点能否理解——老版本节点的消息循环没有对应分支,收到管理消息会**静默忽略**,只能靠超时暴露。
- 收集中心「节点状态」页展示每个节点的版本号,并据此判断哪些节点需要升级;节点升级部署后重新注册即为新版本。
能力清单的取值与三态判定规则见 [节点 API 与协议参考](/guide/node-api) 的「能力清单」一节。
+213
View File
@@ -0,0 +1,213 @@
# 节点 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) | 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、禁止跨跳重定向。
---
## 三、管理接口
### 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 | 5 分钟 |
| `ssl` | URL | 5 分钟 |
| `speed` | `url:version` | 5 分钟 |
| `tcping` | `host:port:count` | 5 分钟 |
| `whois` | 域名 | 5 分钟 |
| `asn whois` | `AS<asn>` | 5 分钟 |
| Bilibili 归属地(在线 API) | IP | 24 小时 |
- **失败结果 30s 后删除**:探测失败(如目标不可达)不缓存整 5 分钟,避免故障恢复后长时间返回旧结论。
- **singleflight 合并**:`detail` / `ssl` / `tcping` / `speed` 的并发同 key 请求合并为一次真实探测。
- **定时清扫**:每 10 分钟清理过期缓存条目与 Bilibili 缓存,防止只靠"同 key 重访才淘汰"时内存无限增长。
+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(普通用户只见自己的) |
+5 -1
View File
@@ -26,6 +26,10 @@ features:
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,乃至AWS Lambda,EdgeOne Makers,Vercel和阿里云函数计算
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)。
+4 -1
View File
@@ -20,6 +20,9 @@ Lemon IPW(柠檬味 ipw.cn 替代品)是一个开源的分布式站点测试
- TCPing(IPv4/IPv6 双栈)
- 网站测速
- 网站截图
- 远程运维(版本 / 能力上报、运行时配置下发、一次性 OTA 升级)
完整的能力矩阵、IP 数据源与可靠性设计见 [功能总览](/info/features)。
## 架构
@@ -29,7 +32,7 @@ Lemon IPW(柠檬味 ipw.cn 替代品)是一个开源的分布式站点测试
- 后端(Go)提供全部检测 API,可在任意地区部署测试节点,是分布式查询的核心
- 独立中间件(middleware-go)使用Fiber技术栈 负责请求转发和 Key 注入
后端支持二进制、Docker 自托管,也支持 AWS Lambda、EdgeOne Makers、Vercel、阿里云函数计算等 Serverless 平台部署。
后端支持二进制、Docker 自托管,以及仓库中提供的 EdgeOne Go 边缘函数和对应的 Vercel 部署入口。AWS Lambda、阿里云函数计算目前没有对应的实现或部署配置。
## 技术栈
+87 -12
View File
@@ -11,6 +11,12 @@ 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, '');
}
@@ -95,8 +101,9 @@ useHead({
{ name: 'robots', content: 'noindex, nofollow' },
{ name: 'googlebot', content: 'noindex, nofollow' },
{ name: 'bingbot', content: 'noindex, nofollow' },
{ name:'baidu-site-verification', content: 'codeva-xzdbvF8gQu'}
]
: [],
: [{ name:'baidu-site-verification', content: 'codeva-xzdbvF8gQu'}],
});
onMounted(() => {
mediaQueryList = window.matchMedia('(max-width: 768px)');
@@ -128,7 +135,7 @@ onMounted(() => {
<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>
@@ -149,16 +156,33 @@ onMounted(() => {
<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">
<el-menu-item index="0" aria-label="返回首页">
<el-icon v-if="isNarrow" @click="drawer = !drawer"><Expand /></el-icon>
<router-link to="/">
<el-image src="/favicon.svg" style="margin-top: 20px;" />
<router-link to="/" aria-label="返回首页">
<!-- alt 留空是有意的:logo 属装饰图,可访问名由外层链接提供。
若把 alt 写成站名,宽屏下会与旁边的 h2 一起被读两遍。 -->
<el-image src="/favicon.svg" alt="" style="margin-top: 20px;" />
<h2 style="display: inline-block; margin-left: 10px" v-if="!isNarrow">{{ config.siteName }}</h2>
</router-link>
</el-menu-item>
@@ -192,7 +216,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>
@@ -207,7 +232,9 @@ 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
</div>
@@ -215,7 +242,7 @@ onMounted(() => {
<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>
<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;
@@ -267,31 +294,79 @@ onMounted(() => {
--el-color-primary: #3EAF7C;
}
html.dark {
/* 暗色模式下根元素的文字色与页面底色。
这两条必须在【非 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: #3EAF7C;
}
/* 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) {
+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>
+15 -3
View File
@@ -157,6 +157,16 @@ async function queryDNS() {
}
// 行内「数据格是否该渲染」判定。
//
// 结果表共 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
@@ -242,9 +252,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>
+6 -65
View File
@@ -5,7 +5,6 @@ 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))
useHead({
@@ -17,69 +16,9 @@ useHead({
</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 class="sidebar-menu" />
<div class="content">
<div class="markdown-body" v-if="page">
<div v-html="page" />
@@ -119,8 +58,10 @@ html.dark .content {
}
@media (max-width: 768px) {
/* 窄屏不再挤一条窄侧栏(原先 100px,长标题全被截断):
文档导航已并入左侧抽屉菜单,这里直接撤掉,正文铺满整屏 */
.sidebar-menu {
width: 100px;
display: none;
}
.content {
padding: 10px;
+6 -66
View File
@@ -1,12 +1,10 @@
<script setup lang="ts">
import { getDocMeta } from '../../../config/doc'
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')
useHead({
@@ -18,69 +16,9 @@ useHead({
</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 class="sidebar-menu" />
<div class="content">
<div class="markdown-body" v-html="doc"></div>
</div>
@@ -118,8 +56,10 @@ html.dark .content {
}
@media (max-width: 768px) {
/* 窄屏不再挤一条窄侧栏(原先 100px,长标题全被截断):
文档导航已并入左侧抽屉菜单,这里直接撤掉,正文铺满整屏 */
.sidebar-menu {
width: 100px;
display: none;
}
.content {
padding: 10px;
+60 -25
View File
@@ -3,7 +3,6 @@ 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());
@@ -81,28 +80,50 @@ const ipAddress = ref('');
const yourIPv4 = ref('');
const yourIPv6 = ref('');
onMounted(async () => {
try {
highlightedCode.value = await highlightCode(code, 'bash');
} catch {
highlightedCode.value = '';
}
onMounted(() => {
// 代码高亮与三个 IP 查询互不依赖,一起发出即可。
// 注意别写成"先 await 高亮再发请求":shiki 首次要把语言包拉下来,会把三次查询整体推后数百毫秒。
//
// 用动态 import 而非顶层静态 import:shiki 的引擎核心 + 语言/主题注册表约 160KB,
// 首页只为这 4 行 curl 示例需要它。静态 import 会把它拽进首屏同步加载的关键路径
// (构建后体现为一个 164KB 的 chunk 出现在 modulepreload 列表);动态 import 后
// 它单独成 chunk,首屏渲染不再等它。
void import('../../utils/shiki')
.then(({ highlightCode }) => highlightCode(code, 'bash'))
.then((html) => {
highlightedCode.value = html;
})
.catch(() => {
highlightedCode.value = '';
});
const [dualStack, ipV4, ipV6] = await Promise.allSettled([
$fetch<string>(config.DualStackAPI),
$fetch<string>(config.v4OnlyAPI),
$fetch<string>(config.v6OnlyAPI)
]);
// 三个地址各自独立请求、各自渲染:谁先回来谁先上屏。
// 不用 Promise.allSettled 包起来等齐——那样只要有一个慢(例如纯 IPv4 网络下 v6 接口要等超时),
// 已经拿到的 IPv4 与双栈结果也得一起干等。
//
// retry: false —— ofetch 默认会对 GET 重发一次:网络层失败时它拿不到 response,就按 500 计
// (500 在默认重试白名单里),且默认延迟为 0,立即重发。于是"这个节点连不上"会被原样重做一遍,
// 用户白等一倍时间(实测 ofetch 1.5.1:裸调用触发 2 次真实 fetch,传 retry:false 后只剩 1 次;
// 浏览器自身不会重试,底层 fetch 对同一请求只被调用过一次)。
const ipFetchOptions = { retry: false } as const;
if (dualStack.status === 'fulfilled') {
ipAddress.value = dualStack.value;
}
if (ipV4.status === 'fulfilled') {
yourIPv4.value = ipV4.value;
}
if (ipV6.status === 'fulfilled') {
yourIPv6.value = ipV6.value;
}
$fetch<string>(config.DualStackAPI, ipFetchOptions)
.then((ip) => {
ipAddress.value = ip;
})
.catch(() => {
// 单个接口失败不影响其它两行展示,保持"查询中"占位
});
$fetch<string>(config.v4OnlyAPI, ipFetchOptions)
.then((ip) => {
yourIPv4.value = ip;
})
.catch(() => {});
$fetch<string>(config.v6OnlyAPI, ipFetchOptions)
.then((ip) => {
yourIPv6.value = ip;
})
.catch(() => {});
});
</script>
@@ -121,10 +142,10 @@ onMounted(async () => {
<div class="one-line">
<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)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h3>
<h3 v-else-if="ipAddress && isIPv4(ipAddress)"><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 +162,17 @@ 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;
}
.code-block {
margin-top: 1rem;
padding: 1rem;
@@ -159,6 +191,9 @@ onMounted(async () => {
}
@media (max-width: 768px) {
.ip-priority h2 {
font-size: 1.1em;
}
.code-block {
padding: 0.75rem;
font-size: 0.8em;
+13 -2
View File
@@ -5,7 +5,6 @@ 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);
@@ -118,7 +117,19 @@ function locateIP(IP: string){
}
onMounted(async () => {
html.value = await highlightCode(code, 'bash')
// 代码高亮与下面的「取本机 IP / 定位查询」互不依赖,别把它摆在流程最前面 await ——
// 否则后面那次 $fetch 要等 160KB 的 shiki 下载完才会发出。
// 动态 import 让 shiki(引擎 + 语言/主题注册表)单独成 chunk,首屏渲染不再等它。
void import('../../utils/shiki')
.then(({ highlightCode }) => highlightCode(code, 'bash'))
.then((h) => {
html.value = h
})
.catch(() => {
// 连 import 本身都失败时退回原始代码,别让代码块空着
html.value = code
})
const urlParam = route.query.ip;
if (urlParam) {
ipAddress.value = urlParam as string;
+10 -9
View File
@@ -108,8 +108,9 @@ blockquote p {
blockquote a{
color: #3EAF7C;
font-size: 1em;
text-decoration: none
/* 正文里的链接仅靠颜色区分不够(绿 #3EAF7C 与周围正文的对比度远低于 3:1),
补下划线提供「非颜色」的视觉区分(link-in-text-block) */
text-decoration: underline
}
a {
color: #3EAF7C;
@@ -205,15 +206,15 @@ footer {
color: #3EAF7C;
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);
+58 -26
View File
@@ -6,99 +6,131 @@ export interface DocMeta {
export const docConfig: Record<string, DocMeta> = {
'/doc': {
title: 'IPv6 工具箱使用文档',
description: 'IPv6 工具箱使用文档'
description: '致力于普及 IPv6,推进 IPv6 规模部署和应用,以全面推进 IPv6 技术创新与融合应用为主线,以提升应用广度深度为主攻方向。支持 IPv6 本地网络(宽带)开启验证、IPv6 网站开启检测、IPv6 Ping 延迟测试、IPv6 网站测速、IPv6 地址查询、IPv6 DNS 解析。'
},
'/doc/user/enable_ipv6': {
title: '个人宽带如何开启IPv6网络访问',
description: '介绍个人宽带如何开启IPv6网络访问'
description: 'IPv6 是大势所趋,就在前段时间湖南联通发布公告,对家庭宽带提供 IPv6 地址,不再提供 IPv4 地址,那本文就介绍个人宽带如何开启 IPv6 网络访问。'
},
'/doc/user/cmd_bash_disable_ipv6': {
title: '命令行禁用/启用IPv6本地网络',
description: 'macOS和Window10命令行禁用/启用IPv6本地网络'
description: '图形化禁用 IPv6 网络比较繁琐,以下是 macOS 和 Window 10 命令行下禁用/启用 IPv6 本地网络的方法。'
},
'/doc/user/cmd_getip': {
title: '命令行(curl)获取 IPv4 和 IPv6 地址',
description: '使用命令行curl获取IPv4和IPv6地址'
description: '通过 curl 命令获取公网 IPv4 和 IPv6 地址,还可以返回是 IPv4 还是 IPv6 访问优先。'
},
'/doc/user/view_ipv6_adress_url': {
title: '浏览器访问 IPv6 地址',
description: '如何在浏览器中访问IPv6地址'
description: '在 IPv6 地址两边加上中括号,就可以通过浏览器访问了,比如 http://[2402:4e00:1013:e500:0:9671:f018:4947]/'
},
'/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 命令调整。'
},
'/doc/user/ipv6_daohang': {
title: '国内 IPv6 资源导航',
description: '国内IPv6资源导航列表'
description: 'IPv6 网络开启成功后,可以访问一些 IPv6 网站资源,来体验下 IPv6 的魅力。不过目前纯 IPv6 网站比较少,目前网站支持 IPv6 的主流方式为 IPv4/IPv6 双栈访问,毕竟要兼顾 IPv4 网络用户的访问需求。'
},
'/doc/user/pure_ipv6_website': {
title: '国内纯 IPv6 网站导航',
description: '国内纯IPv6网站导航列表'
description: 'IPv6 网络开启成功后,可以访问一些纯 IPv6 网站资源,来体验下 IPv6 的魅力。'
},
'/doc/user/dns': {
title: '全国各省 DNS 服务器列表',
description: '全国各省DNS服务器列表'
description: '全国各省电信、联通、移动的首选 IPv4 DNS 服务器和备用 DNS 服务器,以及公共 DNS 服务器列表,最近更新为 2021-07-25。'
},
'/doc/user/ipv6_dns': {
title: 'IPv6 DNS 地址列表',
description: 'IPv6 DNS地址列表'
description: 'IPv6 DNS 地址列表,请求 IPv6 网络需要设置 IPv6 DNS。'
},
'/doc/user/AliyunAuthorizeSecurityGroup': {
title: '阿里云自动化添加安全组',
description: '阿里云自动化添加安全组'
description: 'Golang 代码实操阿里云自动化添加安全组'
},
'/doc/user/TencentCloudAddSecurityGroup': {
title: '腾讯云自动化添加安全组',
description: '腾讯云自动化添加安全组'
description: 'Golang 代码实操腾讯云自动化添加安全组'
},
'/doc/server/website_enable_ipv6': {
title: '网站开启 IPv6 的三种方式',
description: '从传统二进制部署的Nginx,到云原生部署的K8S、Istio,分别介绍网站开启IPv6的三种方式'
description: '从传统二进制部署的 Nginx,到云原生部署的 K8S、Istio,分别介绍网站开启 IPv6 的三种方式。'
},
'/doc/server/tencent_cloud_cvm_ipv6': {
title: '腾讯云 cvm 开启 IPv6',
description: '一文看懂在腾讯云CVM上开启IPv6,只需7步'
description: '一文看懂在腾讯云 CVM 上开启 IPv6,只需 7 步。'
},
'/doc/server/nginx_ipv6': {
title: 'Nginx 开启 IPv6',
description: '一文看懂Nginx中开启IPv6,包含设置IPv6 SSL证书'
description: '一文看懂 Nginx 中开启 IPv6,包含设置 IPv6 SSL 证书。'
},
'/doc/server/ipv6webcheck': {
title: '如何确认一个网站是否开启 IPv6',
description: '如何确认一个网站是否开启IPv6'
description: '通过 IPv6 网站检测工具,一键检查网站是否开启 IPv6 访问。'
},
'/doc/server/ipv6_sign': {
title: '网站增加支持IPv6访问标识',
description: 'HTML+CSS代码,可以提醒访客本网站支持IPv6访问'
description: 'HTML+CSS 代码,可以提醒访客本网站支持 IPv6 访问,同时可以跳转到 IPv6 网站检测页面。'
},
'/doc/server/ipv6_domain_record': {
title: '如何为域名添加 IPv6 解析记录',
description: '如何为域名添加IPv6解析记录'
description: 'IPv6 网站准备好了后,接下来可以为网站域名添加 IPv6 解析记录(AAAA)。'
},
'/doc/server/http2': {
title: '原创网站如何开启 HTTP/2',
description: '介绍网站如何开启HTTP/2协议'
title: '网站如何开启 HTTP/2',
description: 'HTTP/2 通过引入字段压缩和允许在同一连接上进行多个并发交换,使网络资源得到更有效的利用,并减少了延迟。'
},
'/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 的理解。'
},
'/doc/rfc/ipv6_address_format': {
title: 'IPv6 地址标识方法',
description: '首选格式、压缩格式、内嵌IPv4地址的IPv6地址格式'
description: '首选格式、压缩格式、内嵌 IPv4 地址的 IPv6 地址格式。'
},
'/doc/user/tcpdump_ipv6': {
title: 'tcpdump 分析IPv6包',
description: '使用tcpdump分析IPv6包'
title: 'tcpdump 分析 IPv6 包',
description: 'tcpdump 分析 IPv6 包'
},
'/doc/user/wireshark_ipv6': {
title: 'WireShark 分析IPv6包头',
description: '使用WireShark分析IPv6包头'
description: '使用 WireShark 分析 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'
},
'/doc/api/ip/locate': {
title: '获取客户端公网 IP 及位置',
description: '调用本项目的 /v1/location 接口,一次取回访问者自己的公网 IP 与 10 套 IP 库的归属地结果。'
},
'/doc/api/ip/myip': {
title: '获取客户端公网 IP 地址',
description: '用自带边缘函数获取客户端公网 IP,纯文本返回、支持跨域,可按 IPv4 / IPv6 / 双栈分别部署。'
},
'/doc/api/ip/query': {
title: '查询指定 IP 地址的位置信息',
description: '调用本项目的 /v1/location/<ip> 接口查询任意 IPv4 / IPv6 地址的归属地,返回多源聚合结果。'
},
'/doc/api/whois/query': {
title: '查询域名的 WHOIS 信息',
description: '调用本项目的 /v1/whois/<domain> 接口查询域名注册信息:IANA → 注册局 → 注册商转介三跳,返回状态、注册商、关键日期与 NS。'
},
'/doc/server/check_userip_ipv4_ipv6': {
title: 'JS 检查网络是 IPv4 还是 IPv6',
description: '通过 JS 代码检查客户端是 IPv4 还是 IPv6 访问优先。'
},
'/doc/server/tls': {
title: '网站如何开启 TLS',
description: '用 Wireshark 抓包分析 TLS 1.3 的 TCP 三次握手与 TLS 协商全过程。'
},
'/doc/user/code_getip': {
title: 'Python/Go 获取 IPv4 和 IPv6 地址',
description: '通过 Python/Golang 获取公网 IPv4 和 IPv6 地址,还可以返回是 IPv4 还是 IPv6 访问优先。'
},
'/doc/user/dns_dig': {
title: 'DNS 解析流程',
description: '剖析 DNS 解析流程'
}
}
+19 -40
View File
@@ -3,7 +3,7 @@
前端的一系列配置
*/
const config = {
siteUrl: "https://ipw.wsmdn.top/",
siteUrl: "https://nipw.cn/",
// 站点名称:用于页面标题 / 描述 / 页脚品牌展示
siteName: "柠檬味ipw.cn",
// 是否启用前端内置中间件(server/routes/middleware/[...slug].get.ts 本地转发)。
@@ -27,8 +27,8 @@ 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查询接口
@@ -43,11 +43,11 @@ const config = {
id: "cn-jiangsu",
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
},
{
label: "中国 湖北 武汉 电信",
id: "cn-wuhan-chinatelecom",
url: ""
},
// {
// label: "中国 湖北 武汉 电信",
// id: "cn-wuhan-chinatelecom",
// url: ""
// },
{
label: "中国 四川 沙渠 电信[ZFC]",
id: "cn2-sichuan",
@@ -67,11 +67,6 @@ const config = {
id: "lntl-cn-hk-kowloon",
url: "https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/"
},
{
label: "中国 河北 秦皇岛 联通",
id: "cn-hebei-qinhuangdao",
url: "https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/"
}
],
IPv4: [
{
@@ -105,27 +100,17 @@ const config = {
id: "zaozhuang",
url: ""
},
{
label: "湖北 十堰 电信",
id: "shiyan",
url: ""
},
{
label: "陕西 西安二 电信",
id: "xian2",
url: ""
},
{
label: "香港 Cogent",
id: "hongkong",
url: ""
},
// WS 通道节点:URL 留空,拨测经中间件 WS 转发
{
label: "呼和浩特移动",
id: "2faa15e6-18a7-4355-b422-ca53277d8d77",
url: ""
}
// // WS 通道节点:URL 留空,拨测经中间件 WS 转发
// {
// label: "呼和浩特移动",
// id: "2faa15e6-18a7-4355-b422-ca53277d8d77",
// url: ""
// }
],
DualStack: [
@@ -139,17 +124,11 @@ const config = {
id: "cn-shenzhen",
url: "https://cn-shenzhen.api-ipw.wsmdn.top/"
},
{
label: "中国 湖北 武汉 电信",
id: "cn-wuhan-chinatelecom",
url: ""
},
// IP 直连的节点:URL 留空,仅经独立中间件转发
{
label: "上海 腾讯云 BGP",
id: "tencent-sh",
url: ""
}
// {
// label: "中国 湖北 武汉 电信",
// id: "cn-wuhan-chinatelecom",
// url: ""
// },
]
}
}
+13 -4
View File
@@ -26,6 +26,8 @@ export default defineNuxtConfig({
include: [
'is-ip',
'shiki',
'dayjs',
'lodash-unified'
]
},
},
@@ -33,15 +35,22 @@ export default defineNuxtConfig({
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(否则异步加载)
-1
View File
@@ -23,7 +23,6 @@
"@tailwindcss/vite": "^4.3.3",
"@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",
+200 -1775
View File
File diff suppressed because it is too large. Load diff
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 -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

@@ -0,0 +1,177 @@
# [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(本节点视角) |
| `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
```
@@ -0,0 +1,218 @@
# <span style="background-color: #b95442;color: white;font-size: 0.43em;border-radius: 5px;padding: 2px 5px;">转载</span> Python/Golang获取 IPv4 和 IPv6 地址
> 本站是个人站点,用于作者实战学习前端和后台知识,请勿用于商业用途,仅供个人测试学习之用,请遵守中国法律法规
通过 Python/Golang 获取公网 IPv4 和 IPv6 地址,还可以返回是 IPv4 还是 IPv6 访问优先。
## 1. Python/Golang获取 IPv4 地址
### 1.1 Python 获取本机公网IPv4地址
- 输入示例
```python
#!/usr/bin/python3
import requests
# 获取 IPv4 地址接口; https://4.wsmdn.top
r = requests.get('https://4.wsmdn.top')
clientIP = r.text
print(clientIP)
```
- 输出示例
```
106.224.145.147
```
### 1.2 Golang 获取本机公网IPv4 地址
- 输入示例
```go
package main
import (
"fmt"
"io/ioutil"
"net/http"
)
func main() {
// 获取 IPv4 地址接口; https://4.wsmdn.top
responseClient, errClient := http.Get("https://4.wsmdn.top")
if errClient != nil {
fmt.Printf("获取外网 IP 失败,请检查网络\n")
panic(errClient)
}
defer responseClient.Body.Close()
// 获取 http response 的 body
body, _ := ioutil.ReadAll(responseClient.Body)
clientIP := string(body)
print(clientIP)
}
```
- 输出示例
```
106.224.145.147
```
## 2. Python/Golang获取 IPv6 地址
### 2.1 Python 获取本机公网IPv6地址
- 输入示例
```python
#!/usr/bin/python3
import requests
r = requests.get('https://6.wsmdn.top')
clientIP = r.text
print(clientIP)
```
- 输出示例
```
2408:824c:200::2b8b:336f:cc9c
```
### 2.2 Golang 获取本机公网IPv6地址
- 输入示例
```go
package main
import (
"fmt"
"io/ioutil"
"net/http"
)
func main() {
responseClient, errClient := http.Get("https://6.wsmdn.top")
if errClient != nil {
fmt.Printf("获取外网 IP 失败,请检查网络\n")
panic(errClient)
}
defer responseClient.Body.Close()
// 获取 http response 的 body
body, _ := ioutil.ReadAll(responseClient.Body)
clientIP := string(body)
print(clientIP)
}
```
- 输出示例
```
2408:824c:200::2b8b:336f:cc9c
```
## 3. 测试网络是IPv4还是IPv6访问优先
### 3.1 Python 测试网络是IPv4还是IPv6访问优先
- 输入示例
```python
#!/usr/bin/python3
import requests
## HTTP GET,返回的是纯文本地址
r = requests.get('https://test.wsmdn.top')
IP = r.text.strip()
## 返回的地址含冒号即 IPv6,否则为 IPv4
IPVersion = 'IPv6' if ':' in IP else 'IPv4'
## 打印
print('IP:',IP)
print('IPVersion:',IPVersion)
```
- 输出示例
```bash
## 返回示例1(IPv4 访问优先)
IP: 106.224.145.147
IPVersion: IPv4
## 返回示例2(IPv6 访问优先)
IP: 2408:824c:200::2b8b:336f:cc9c
IPVersion: IPv6
```
### 3.2 Golang 测试网络是IPv4还是IPv6访问优先
- 输入示例
```go
package main
import (
"fmt"
"io/ioutil"
"net/http"
"strings"
)
func main() {
responseClient, errClient := http.Get("https://test.wsmdn.top") // 获取外网 IP
if errClient != nil {
fmt.Printf("获取外网 IP 失败,请检查网络\n")
panic(errClient)
}
// 程序在使用完 response 后必须关闭 response 的主体。
defer responseClient.Body.Close()
body, _ := ioutil.ReadAll(responseClient.Body)
IP := strings.TrimSpace(string(body))
// 返回的地址含冒号即 IPv6,否则为 IPv4
IPVersion := "IPv4"
if strings.Contains(IP, ":") {
IPVersion = "IPv6"
}
fmt.Printf("IP: %s\n", IP)
fmt.Printf("IPVersion: %s\n", IPVersion)
}
```
- 输出示例
```bash
## 返回示例1(IPv4 访问优先)
IP: 106.224.145.147
IPVersion: IPv4
## 返回示例2(IPv6 访问优先)
IP: 2408:824c:200::2b8b:336f:cc9c
IPVersion: IPv6
```
@@ -0,0 +1,25 @@
# <span style="background-color: #b95442;color: white;font-size: 0.43em;border-radius: 5px;padding: 2px 5px;">转载</span> DNS解析流程
## SRV 记录
SRV 记录:找到要添加 SRV 记录的位置,然后输入:
端口 协议 名称 服务 优先级 权重 目标 443 _tcp @ _autodiscover 100 1 autodiscover.secureserver.net
![网络请求的是 IPv6 地址](/doc/dns_srv_record.png)
### 用途
SRV记录,全称为服务定位(Service Location)记录,是一种在DNS(域名系统)中指定服务位置的记录。它允许用户不仅可以通过域名找到一个服务的地址,还可以找到该服务在特定主机上运行的端口号以及该服务的一些额外信息。SRV记录在多种网络服务中起着关键作用,尤其是在需要定位特定类型服务的应用场景中,如电子邮件、即时通讯、VoIP(Voice over Internet Protocol)等。
根据您提供的信息,我们可以详细解读这条SRV记录的各个组成部分及其用途:
- **端口(Port):**`443`。这是服务监听的端口号。在这个例子中,端口443通常用于HTTPS服务,表示这是一个通过安全的方式提供服务的端口。
- **协议(Protocol):**`_tcp`。这指定了服务使用的协议类型。在这里,`_tcp`表示该服务使用TCP(传输控制协议)进行通信。SRV记录中的协议通常以`_`开头。
- **名称(Name):**`@`。这通常表示当前域,即将这条SRV记录应用于整个域。有时,这里可以指定子域或特定主机。
- **服务(Service):**`_autodiscover`。这指定了服务的类型。在这个例子中,`_autodiscover`服务通常与电子邮件相关,用于自动发现邮件服务器的配置信息,使得用户能够仅通过电子邮件地址和密码自动配置他们的邮件客户端。
- **优先级(Priority):**`100`。这个数字表示了多个SRV记录之间的优先级。数字越小,优先级越高。在有多个记录指向不同服务器的情况下,客户端会首先尝试优先级最高的服务器。
- **权重(Weight):**`1`。当有多个具有相同优先级的SRV记录时,权重决定了这些记录的相对处理顺序。权重越高,该服务器被选中的可能性就越大。权重用于在具有相同优先级的服务器之间分配负载。
- **目标(Target):**`autodiscover.secureserver.net`。这是提供服务的服务器的域名。客户端将使用这个地址来访问服务。
总的来说,这条SRV记录的用途是指导客户端如何找到并连接到提供`_autodiscover`服务的服务器。这对于自动配置电子邮件客户端尤其重要,因为它简化了用户设置邮箱的过程,使用户无需手动输入服务器地址和端口信息。
@@ -86,13 +86,13 @@ IPv6 网络开启成功后,我们需要验证下是否真正开启成功。
>该站点仅支持 IPv6 网络访问,IPv4 网络无法访问。
```json
// https://6.ipw.cn/
// https://6.wsmdn.dpdns.org/
240e:3b7:3b7:3b7::3b7
```
打开 [https://test.wsmdn.dpdns.org/](https://test.wsmdn.dpdns.org/),**如果返回的 IPVersion 字段为 IPv6,则当前网络 IPv6 访问优先,**如果返回的 IPVersion 字段为 IPv4,则当前网络 IPv4 访问优先。
打开 [https://test.wsmdn.dpdns.org/](https://test.wsmdn.dpdns.org/),**如果返回的是 IPv6 地址,则当前网络 IPv6 访问优先,**如果返回的是 IPv4 地址,则当前网络 IPv4 访问优先。
>[test.wsmdn.dpdns.org](https://test.wsmdn.dpdns.org/) 支持 IPv4/IPv6双栈访问。
```json
// https://test.ipw.cn/
// https://test.wsmdn.dpdns.org/
240e:3b7:3b7:3b7::3b7
```
@@ -197,7 +197,7 @@ PING6(56=40+8+8 bytes) --> 2402:4e00:40:40::2:331
16 bytes from 2402:4e00:40:40::2:331, icmp_seq=1 hlim=53 time=16.393 ms
16 bytes from 2402:4e00:40:40::2:331, icmp_seq=2 hlim=53 time=14.736 ms
16 bytes from 2402:4e00:40:40::2:331, icmp_seq=3 hlim=53 time=14.062 ms
--- ipw.cn ping6 statistics ---
--- ipw.wsmdn.top ping6 statistics ---
4 packets transmitted, 4 packets received, 0.0% packet loss
round-trip min/avg/max/std-dev = 14.062/15.811/18.052/1.547 ms
```
@@ -17,7 +17,7 @@
访问 `http://[2402:4e00:1013:e500:0:9671:f018:4947]` ,会返回本机 IPv6 IP地址。
![](https://static.ipw.wsmdn.top/images/doc/ipv6/user/media/view_ipv6_adress_url.png)
![](/doc/view_ipv6_adress_url.png)
如果有端口,在 IP 地址后面添加端口,比如
@@ -1,11 +1,8 @@
# <span style="background-color: #b95442;color: white;font-size: 0.43em;border-radius: 5px;padding: 2px 5px;">转载</span> Wireshare 分析 IPv6 包
>[WARNING]
>本文的部分URL已失效,但为了保留原文,未作修改
## 场景
执行 `curl 6.ipw.cn`,查看本机的 IPv6 通信包。
执行 `curl 6.wsmdn.top`,查看本机的 IPv6 通信包。
> 如果有 DNS 缓存,可以执行 `sudo killall -HUP mDNSResponder;sudo killall mDNSResponderHelper;sudo dscacheutil -flushcache` 清理缓存
@@ -21,6 +21,8 @@ function checkRateLimit(ip: string, limit: number): boolean {
return entry.count <= limit
}
// 数据上报已移除:统计与拨测明细统一由下游节点(lemon-ipw/src)自行上报,
// 本转发入口不再计数、不再 POST /report,转发请求也不再携带 X-Boce-Reporter 归属头(防双算协议已废除)。
export default defineEventHandler(async (event) => {
// 1) Origin / Referer 校验:跨域浏览器调用一律拒绝。
// 注意:无 Origin/Referer 的服务端 SSR 调用与命令行请求无法区分,仅靠下方限流约束。
@@ -77,9 +79,9 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: 'Invalid path' })
}
const query = getQuery(event)
const runtimeConfig = useRuntimeConfig(event)
// 从运行时变量读取 APIKEYS (JSON字符串),解析后按 backendID 查找 token
const runtimeConfig = useRuntimeConfig(event)
let apiKey: string | undefined
try {
const apiKeysMap: Record<string, string> = (runtimeConfig.apiKeys || {}) as Record<string, string>
@@ -90,7 +92,7 @@ export default defineEventHandler(async (event) => {
} catch (e) {
console.debug('[middleware debug] JSON parse FAILED:', e)
}
// 转发头:不透传 Origin,避免上游 CORS 误判;仅按需注入 Authorization
// 转发头:不透传 Origin,避免上游 CORS 误判;仅按需注入 Authorization。
const authHeaders: Record<string, string> = {}
if (apiKey) {
authHeaders['Authorization'] = `Bearer ${apiKey}`
@@ -98,7 +100,6 @@ export default defineEventHandler(async (event) => {
} else {
console.debug('[middleware debug] authHeaders NO Authorization header')
}
if (apiType === 'whois' || apiType === 'dns' || apiType === 'location' || apiType === 'ssl' || apiType === 'asn' || apiType === 'dnssec' || apiType === 'detail') {
let apiBaseUrls: Array<{ id: string, url: string ,label: string}> = []
switch (apiType) {
@@ -136,6 +137,9 @@ export default defineEventHandler(async (event) => {
method: 'GET',
headers: authHeaders,
timeout: 15_000,
// 关闭 ofetch 内置重试:上游超时/5xx 时它会在同一节点立即重打一遍(retryDelay 默认 0,无退避),
// 把 15s 超时变成 30s。重试统一由前端候选循环承担。
retry: false,
}).catch((error: any) => {
console.error(`Error fetching from ${apiBaseUrl}:`, error)
const errStatus = error?.status ?? error?.statusCode
@@ -178,6 +182,7 @@ export default defineEventHandler(async (event) => {
method: 'GET',
headers: authHeaders,
timeout: 30_000, // tcping/测速上游本身较慢,给更长超时
retry: false, // 同上:不让 ofetch 把 30s 超时放大成 60s
}).catch((error: any) => {
console.error(`Error fetching from ${apiBaseUrl}:`, error)
const errStatus = error?.status ?? error?.statusCode
+13 -4
View File
@@ -31,11 +31,19 @@ function isConnectionError(e: any): boolean {
// 401 未授权、403 被 CDN/WAF 风控拦截、418 被反爬/限流标记、502 中间件/上游不可用 —— 换一个节点往往就能恢复
const RETRY_STATUS_CODES = new Set([401, 403, 418, 502])
// 过滤掉 useFetch 专属选项(对 $fetch 无意义),其余(method/query/params/headers/body/timeout 等)原样透传
// 过滤掉 useFetch 专属选项(对 $fetch 无意义),其余(method/query/params/headers/body/timeout 等)原样透传。
//
// 默认关闭 ofetch 内置重试(retry: false):
// ofetch 对 GET 默认重试 1 次,且「拿不到响应(网络层错误)」会按 500 参与重试(见 ofetch onError:
// responseCode = response.status || 500)。于是 502、429、超时、"连不上"这几种最贵的失败,
// 都会在**同一个节点**上被立即重打一遍(retryDelay 默认 0,无退避),
// 请求数与等待时间同时 ×2 —— 而外层候选循环本来就会换节点重试,这层纯属重复。
// 调用方若显式传 retry,以调用方为准(rest 在后覆盖)。
const DEFAULT_RETRY = false
function toFetchOptions(options?: any) {
if (!options) return undefined
const { immediate, watch, key, deep, server, lazy, default: _default, transform, pick, getCachedData, dedupe, ...rest } = options
return rest
const { immediate, watch, key, deep, server, lazy, default: _default, transform, pick, getCachedData, dedupe, ...rest } = options || {}
return { retry: DEFAULT_RETRY, ...rest }
}
// useMiddlewareFetch:在 Vue 页面中调用中间件(替代直接 useFetch('/middleware/...'))。
@@ -44,6 +52,7 @@ function toFetchOptions(options?: any) {
// Nuxt useAsyncData 在 key 变化时会重新绑定 data/error 状态,导致切换后的数据无法可靠渲染):
// - 依次尝试 config.Middleware 中的外部节点 + 前端自带中间件兜底;
// - 仅当"节点无法连接"或上游返回 401/403/418 时切下一个候选;
// - 每次 $fetch 默认 retry: false,同一条通道不会被重打第二遍(重试统一由本循环承担);
// - 成功后 data 立即更新为最终节点的数据,调用方 await execute() 后可直接读取并渲染;
// - 全部候选失败时 error 为最终错误。
export function useMiddlewareFetch<T = any>(url: MaybeRefOrGetter<string>, options?: any) {
+17 -28
View File
@@ -1,6 +1,6 @@
#!/usr/bin/bash
# ============================================================
# 1IPW.CN 后端节点一键安装脚本
# 后端节点一键安装脚本
# - 自动检测架构并下载最新 release 二进制
# - 交互式输入配置(环境变量注入,无需 setting.json)
# - 生成并启用 systemd 守护进程
@@ -78,8 +78,7 @@ read -r -p "单栈模式 SINGLE_STACK(留空=双栈,ipv4 或 ipv6): " SING
read -r -p "访问令牌 access-token(留空=不启用鉴权): " ACCESS_TOKEN
read -r -p "DNS 服务器 [119.28.28.28:53](主从逗号分隔,如 119.28.28.28:53,223.5.5.5:53): " DNS_SERVER
DNS_SERVER=${DNS_SERVER:-119.28.28.28:53}
read -r -p "DNS 服务器(留空=启动时自动探测系统 DNS;主从逗号分隔,如 119.28.28.28:53,223.5.5.5:53): " DNS_SERVER
read -r -p "DNSSEC 专用 DNS(留空=沿用上面 dns-server): " DNSSEC_DNS_SERVER
@@ -93,42 +92,33 @@ read -r -p "CORS 允许来源(逗号分隔,留空=不限): " CORS
read -r -p "远端配置地址 remote-config-url(留空=不启用): " REMOTE_CONFIG_URL
read -r -p "节点 id NODE_ID(回车自动生成 UUID;WS 注册与 HTTP 上报共用): " NODE_ID
if [ -z "$NODE_ID" ]; then
NODE_ID=$(gen_uuid)
echo "节点 id(自动生成 UUID): $NODE_ID"
fi
echo ""
echo "--- WS 通道接入(可选,接入独立中间件)---"
read -r -p "接入中间件 WS 通道?[y/N]: " WS_CHOICE
WS_URL=""
NODE_ID=""
NODE_KEY=""
WS_USED_DEFAULT=""
case "${WS_CHOICE,,}" in
y|yes)
read -r -p " 中间件 WS 完整地址(含 wss:// 前缀与 /ws 路径,如 wss://host:8092/ws;留空=贡献节点给柠檬 wss://middleware-1.api-ipw.wsmdn.top/ws;逗号分隔多个将同时连接全部): " WS_URL
read -r -p " 中间件 WS 完整地址(含 wss:// 前缀与 /ws 路径,如 wss://host:8092/ws;留空=贡献节点给柠檬 wss://boce-api.api-ipw.wsmdn.top/ws;逗号分隔多个将同时连接全部): " WS_URL
if [ -z "$WS_URL" ]; then
WS_URL="wss://middleware-1.api-ipw.wsmdn.top/ws"
WS_URL="wss://boce-api.api-ipw.wsmdn.top/ws"
WS_USED_DEFAULT="true"
fi
read -r -p " 节点 id(回车自动生成 UUID): " NODE_ID
if [ -z "$NODE_ID" ]; then
NODE_ID=$(gen_uuid)
echo " 节点 id(自动生成 UUID): $NODE_ID"
read -r -p " 注册 key(留空自动生成,中间件 ws-keys 必须包含此节点,否则注册被拒 401): " NODE_KEY
if [ -z "$NODE_KEY" ]; then
NODE_KEY=$(gen_uuid | tr -d '-')
echo " 注册 key(自动生成): $NODE_KEY"
fi
while [ -z "$NODE_KEY" ]; do
read -r -p " 注册 key(必填,中间件 ws-keys 必须包含此节点,否则注册被拒 401): " NODE_KEY
if [ -z "$NODE_KEY" ]; then
echo " 错误:不加 key 禁止启用 WS,必须提供注册 key"
fi
done
;;
esac
echo ""
echo "--- OTA 自更新(可选)---"
read -r -p "启用 OTA 自动升级(跟随 GitHub Release,major 版本不自动升)?[y/N]: " OTA_CHOICE
NODE_OTA=""
case "${OTA_CHOICE,,}" in
y|yes) NODE_OTA="true" ;;
esac
echo ""
echo "--- 其他环境变量(可选)---"
echo "输入额外的 Environment= 项,每行一个(如 GH_PROXY=https://ghproxy.com/),空行结束:"
@@ -152,12 +142,12 @@ echo "========================================"
echo "安装目录: $INSTALL_DIR"
echo "服务名: lemon-ipw"
echo "监听端口: $PORTS"
echo "节点 id: $NODE_ID"
echo "单栈模式: ${SINGLE_STACK:-双栈}"
echo "access-token: ${ACCESS_TOKEN:+已设置 (隐藏)}"
echo "DNS: $DNS_SERVER"
echo "DNS: ${DNS_SERVER:-自动探测(系统 DNS)}"
echo "ipdb: $IPDB"
echo "WS 接入: $(if [ -n "$WS_URL" ]; then printf '%s (id=%s, key=%s)' "$WS_URL" "$NODE_ID" "${NODE_KEY:+已设置}"; else printf '未启用'; fi)"
echo "OTA 自更新: ${NODE_OTA:-未启用}"
echo "WS 接入: $(if [ -n "$WS_URL" ]; then printf '%s (注册 key=%s)' "$WS_URL" "$NODE_KEY"; else printf '未启用'; fi)"
echo "========================================"
read -r -p "确认安装?[Y/n]: " CONFIRM
case "${CONFIRM,,}" in
@@ -216,7 +206,6 @@ add_env REMOTE_CONFIG_URL "$REMOTE_CONFIG_URL"
add_env WS_URL "$WS_URL"
add_env NODE_ID "$NODE_ID"
add_env NODE_KEY "$NODE_KEY"
add_env NODE_OTA "$NODE_OTA"
if [ -n "$EXTRA_ENVS" ]; then
while IFS= read -r line; do
[ -n "$line" ] || continue
+3 -3
View File
@@ -799,9 +799,9 @@ func main() {
app.Get("/v1/*", middlewareHandler)
app.Get("/middleware/*", middlewareHandler)
// 监听地址,优先级: 环境变量 PORT > setting.json port > 默认 8080。
// 写回全局 PORT:waitChildReady 的健康检查地址要用同一个端口
PORT = os.Getenv("PORT")
// 监听地址:readConfig 已按 env PORT > setting.json port 填好全局 PORT,
// 这里只在为空时补默认 8080——不要再用 Getenv 覆盖,否则 setting.json 的 port 失效。
// waitChildReady 的健康检查地址要用同一个端口
if PORT == "" {
PORT = "8080"
}
+12 -1
View File
@@ -20,7 +20,9 @@ import (
// 现有 HTTP 接口(/v1/* /middleware/*)完全不变,WS 是独立端口上的新增数据面。
//
// 消息信封(JSON 文本帧):{ "type": "...", "nodeId": "...", "ts": <unix秒>, "data": {...} }
// - 节点 → middleware:register / probe_result / pong / command
// - 节点 → middleware:register / ping / pong / probe_result / report / config_result / ota_result
// 其中 report(统计与拨测明细上报)与 config_result / ota_result(远程运维应答)的消费者是
// 收集中心 ipw-boce,本中间件只是转发与拨测代理,收到即丢弃(见下方 switch 内注释)。
// - middleware → 节点:register_ok / register_error / probe / ping / status
//
// probe 消息 data:{ "requestId": "...", "apiType": "tcping", "raw": "qq.com", "query": {"port":"443"} }
@@ -183,6 +185,15 @@ func (s *wsServer) Handler(w http.ResponseWriter, r *http.Request) {
s.sendJSON(c, wsMessage{Type: "pong", NodeID: peer.id, TS: time.Now().Unix()})
}
case "report", "config_result", "ota_result":
// 协议已知、但本中间件不消费的帧 —— 刻意显式列出、收到即丢弃,绝不能落到 default:
// - report:节点按 report-interval-seconds 把统计与拨测明细**广播给所有在线 ws 连接**(多活),
// 它的消费者是收集中心 ipw-boce(中间件不在转发路径上采集,不落库也不计数)。
// - config_result / ota_result:只在对端下发过 config / ota 指令后才会出现,本中间件不下发
// 这类指令(远程运维归收集中心),因此正常情形收不到。
// 注意:这几个帧曾落到 default 被当成"未知消息"打印,节点每个上报周期刷一行
// (如 "unknown message type from cn-jiangsu: report"),日志噪音随节点数 × 上报频率线性增长。
default:
log.Printf("[ws] unknown message type from %s: %s", peer.id, msg.Type)
}
@@ -61,6 +61,11 @@ var (
V4Client *resty.Client
CORS string
ACCEPT_DOMAINS []string
// 数据上报(上报到收集中心 ipw-boce,详见 report.go)
NODE_ID string // 上报身份(node-id),留空回退 hostname
REPORT_URL string // 收集中心 HTTP 基址(report-url),留空则不上报
REPORT_TOKEN string // /report 鉴权 token(report-token)
REPORT_INTERVAL int // 上报间隔秒(report-interval-seconds),缺省 15
)
func fakePerfectWebsiteResult(host string) *WebsiteCheckDetail {
@@ -1183,6 +1188,23 @@ func applyRemoteConfig() {
if v := configValue(CONFIG, "block-private-ips"); v != "" {
ssrf.SetEnabled(v != "false" && v != "0")
}
// 数据上报(详见 report.go)
if v := configValue(CONFIG, "node-id"); v != "" {
NODE_ID = v
}
if v := configValue(CONFIG, "report-url"); v != "" {
REPORT_URL = v
}
// report-token 属受保护凭据:远端下发**不覆盖**(与主线 configRemoteProtectedKeys 同口径)。
// 远端一写错,上报会被收集中心 /report 拒收,而节点侧毫无察觉,故不交给配置源自律。
if configValue(CONFIG, "report-token") != "" {
slog.Warn("远端配置里的 report-token 已被忽略(凭据由节点本地管理)", "url", url)
}
if v := configValue(CONFIG, "report-interval-seconds"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
REPORT_INTERVAL = n
}
}
if CORS != "" {
ACCEPT_DOMAINS = splitAndTrim(CORS, ",")
}
@@ -1213,6 +1235,21 @@ func readConfig() {
if v := os.Getenv("BLOCK_PRIVATE_IPS"); v != "" {
ssrf.SetEnabled(v != "false" && v != "0")
}
// 数据上报(详见 report.go):node-id / report-url / report-token / report-interval-seconds
if v := os.Getenv("NODE_ID"); v != "" {
NODE_ID = strings.TrimSpace(v)
}
if v := os.Getenv("REPORT_URL"); v != "" {
REPORT_URL = strings.TrimSpace(v)
}
if v := os.Getenv("REPORT_TOKEN"); v != "" {
REPORT_TOKEN = strings.TrimSpace(v)
}
if v := os.Getenv("REPORT_INTERVAL_SECONDS"); v != "" {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 {
REPORT_INTERVAL = n
}
}
// 2) 远端配置(最高优先级,覆盖环境变量)
applyRemoteConfig()
@@ -1280,6 +1317,9 @@ func main() {
webtest.SetDNSSecServer(DNSSEC_DNS_SERVER)
slog.Info("Starting server", "port", PORTS, "single_stack", SINGLE_STACK)
// 数据上报:周期把本节点的统计与拨测明细上报到收集中心(详见 report.go)
startNodeReporter()
// 缓存清扫:定期淘汰过期条目
go func() {
for {
@@ -1296,6 +1336,7 @@ func main() {
corsConfig.AllowAllOrigins = true
}
r.Use(cors.New(corsConfig))
r.Use(nodeReportMiddleware()) // 数据上报统计:/v1/* 请求计数与拨测明细(详见 report.go)
r.GET("/v1/detail/*url", checkWebsiteHandler)
r.GET("/v1/ssl/*url", sslCheckHandler)
@@ -0,0 +1,291 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// ==================== 数据上报(EO 节点 → 收集中心 ipw-boce) ====================
//
// 自主线 ipw-cn/src/report.go 提取,按 EO 部署形态裁剪:EO 版不连中间件 WS 通道
// (无 WebSocket 客户端),因此上报只走一条路——HTTP POST 收集中心 /report。
// 报文协议与 ipw-boce/report.go 完全一致:
//
// POST /report body = {"instance":"上报方标识","stats":[...],"probes":[...]}
//
// 节点是唯一记录者:统计与拨测明细都由本节点上报,收集中心不在转发路径上计数。
// 覆盖的请求来源只有一条:HTTP 接口收到的 /v1/*(nodeReportMiddleware 计数)。
//
// 进程模型前提:本实现假定云函数运行时是**长驻进程**(与 index.go 里缓存清扫 goroutine
// 同一假设)。若部署为按需冷启动,内存中的计数与明细缓冲会随实例回收丢失——上报只在
// 实例存活期内累积,间隔到期后即发出。
//
// 配置(env 优先,远端配置 REMOTE_CONFIG_URL 最高,见 index.go 的 readConfig/applyRemoteConfig):
//
// NODE_ID 上报身份(node-id),留空回退 hostname
// REPORT_URL 收集中心 HTTP 基址(report-url),留空则不上报
// REPORT_TOKEN /report 鉴权 token(report-token)
// REPORT_INTERVAL 上报间隔秒(report-interval-seconds),缺省 15
const (
reportMaxProbes = 500
reportMaxStats = 500
)
var (
nodeStatsMu sync.Mutex
nodeStats = map[string]*nodeStatVal{} // apiType → 计数
nodeProbeCh chan nodeProbeRec
nodeProbeDropN int64
nodeDropMu sync.Mutex
)
type nodeStatVal struct {
total, errs, latSum, latMax int64
}
// nodeProbeRec 一条拨测明细(结构与 ipw-boce reportProbe 对齐)
type nodeProbeRec struct {
RequestID string `json:"requestId,omitempty"`
NodeID string `json:"nodeId"`
APIType string `json:"apiType"`
Raw string `json:"raw"`
Query string `json:"query,omitempty"`
Status int `json:"status"`
LatencyMs int64 `json:"latencyMs"`
Error string `json:"error,omitempty"`
Source string `json:"source,omitempty"`
Body string `json:"body,omitempty"`
CreatedAt int64 `json:"createdAt,omitempty"`
}
// nodeIsProbeType 拨测类接口(结果需明细上报)。
// 明细覆盖:连通性/证书类 detail、ssl,DNS 查询类 dns,直连类 tcping/speed。
// dnssec 属功能性校验(结果是否签名/可信),不逐条进明细,只进统计聚合。
func nodeIsProbeType(apiType string) bool {
switch apiType {
case "tcping", "speed", "detail", "ssl", "dns":
return true
}
return false
}
// startNodeReporter 启动上报循环(main() 调用,须在 readConfig 之后)。
// 循环常驻:即使当前没有配置 REPORT_URL 也照常起——留空只是"本轮不上报",
// 便于将来接入热更新/远端配置后无需重启即可开启上报。
func startNodeReporter() {
if REPORT_INTERVAL <= 0 {
REPORT_INTERVAL = 15
}
nodeProbeCh = make(chan nodeProbeRec, 2048)
go nodeReportLoop()
slog.Info("node reporter enabled", "httpReport", REPORT_URL, "interval", REPORT_INTERVAL, "nodeId", nodeReportNodeID())
}
// nodeReportMiddleware /v1 组统计中间件:归属规则过滤 → 计数 → 拨测类补明细
func nodeReportMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
// 收集中心(ipw-boce)主动调度下发的拨测带 X-Scheduler-Probe 头:
// 该次执行由收集中心侧本地落库(source=sched/biz),本节点跳过计数与明细上报,避免双算。
// 真实业务请求(用户/中间件转发)不带此头,仍按"节点是唯一记录者"正常上报。
if c.Request.Header.Get("X-Scheduler-Probe") != "" {
return
}
apiType, raw := nodeAPIFromPath(c)
if apiType == "" {
return
}
latency := time.Since(start)
status := c.Writer.Status()
nodeRecordAPI(apiType, status, latency)
if nodeIsProbeType(apiType) {
nodeRecordProbe(nodeProbeRec{
NodeID: nodeReportNodeID(), APIType: apiType, Raw: raw,
Query: c.Request.URL.RawQuery, Status: status, LatencyMs: latency.Milliseconds(),
Source: "http", CreatedAt: time.Now().Unix(),
})
}
}
}
func nodeRecordAPI(apiType string, status int, latency time.Duration) {
ms := latency.Milliseconds()
nodeStatsMu.Lock()
v, ok := nodeStats[apiType]
if !ok {
v = &nodeStatVal{}
nodeStats[apiType] = v
}
v.total++
if status == 0 || status >= 500 {
v.errs++
}
v.latSum += ms
if ms > v.latMax {
v.latMax = ms
}
nodeStatsMu.Unlock()
}
func nodeRecordProbe(rec nodeProbeRec) {
if nodeProbeCh == nil {
return
}
select {
case nodeProbeCh <- rec:
default:
nodeDropMu.Lock()
nodeProbeDropN++
n := nodeProbeDropN
nodeDropMu.Unlock()
if n%100 == 1 {
slog.Warn("node report probe queue full", "dropped", n)
}
}
}
// nodeReportNodeID 上报身份:优先 node-id(NODE_ID),否则 hostname
func nodeReportNodeID() string {
if NODE_ID != "" {
return NODE_ID
}
if h, err := os.Hostname(); err == nil && h != "" {
return h
}
return "unknown"
}
// nodeAPIFromPath 从 gin 路由模板提取 apiType 与拨测目标(对齐主线 WS probe 的 raw 格式)
func nodeAPIFromPath(c *gin.Context) (apiType, raw string) {
path := c.FullPath() // 形如 /v1/tcping/:ip
if !strings.HasPrefix(path, "/v1/") {
return "", ""
}
seg := strings.SplitN(strings.TrimPrefix(path, "/v1/"), "/", 2)
if len(seg) == 0 || seg[0] == "" {
return "", ""
}
apiType = seg[0]
switch apiType {
case "tcping", "dnssec", "whois":
raw = c.Param("ip")
if raw == "" {
raw = c.Param("domain")
}
case "speed":
raw = c.Param("version") + c.Param("url") // v4/example.com
case "dns":
raw = c.Param("type") + c.Param("domain") // a/example.com
case "detail", "ssl":
raw = strings.TrimPrefix(c.Param("url"), "/")
case "location", "asn":
raw = c.Param("ip")
}
return apiType, raw
}
// nodeReportLoop 周期上报:取走计数与明细缓冲 → HTTP POST 收集中心 /report
func nodeReportLoop() {
ticker := time.NewTicker(time.Duration(REPORT_INTERVAL) * time.Second)
defer ticker.Stop()
for range ticker.C {
if REPORT_URL == "" {
continue // 未配置收集中心地址:本轮不上报(配置就绪后自动开始)
}
payload := nodeBuildPayload()
if payload == nil {
continue
}
if err := nodeHTTPReport(payload); err != nil {
slog.Warn("node http report failed", "error", err)
}
}
}
// nodeBuildPayload 取走当前计数与拨测缓冲,组装上报报文;无数据返回 nil。
// minute 传节点自己的分钟桶(收集端另有 minute=0 → 按自身时钟入桶的兜底口径)
func nodeBuildPayload() map[string]any {
nodeStatsMu.Lock()
snap := nodeStats
nodeStats = make(map[string]*nodeStatVal, len(snap))
nodeStatsMu.Unlock()
probes := make([]nodeProbeRec, 0, reportMaxProbes)
if nodeProbeCh != nil {
drain:
for len(probes) < reportMaxProbes {
select {
case r := <-nodeProbeCh:
probes = append(probes, r)
default:
break drain // 队列已空,本轮结束
}
}
}
if len(snap) == 0 && len(probes) == 0 {
return nil
}
minute := time.Now().UTC().Unix() / 60
stats := make([]map[string]any, 0, len(snap))
for apiType, v := range snap {
stats = append(stats, map[string]any{
"minute": minute, "nodeId": nodeReportNodeID(), "apiType": apiType,
"total": v.total, "errors": v.errs, "latencySumMs": v.latSum, "latencyMaxMs": v.latMax,
})
}
payload := map[string]any{
"instance": nodeReportNodeID(),
"stats": stats,
}
if len(probes) > 0 {
payload["probes"] = probes
}
return payload
}
// nodeHTTPReport 上报到收集中心 /report。
// at-most-once:失败只记日志不重试——统计是累加语义,重试会导致重复计算。
func nodeHTTPReport(payload map[string]any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
url := strings.TrimSuffix(REPORT_URL, "/") + "/report"
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if REPORT_TOKEN != "" {
req.Header.Set("Authorization", "Bearer "+REPORT_TOKEN)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &httpStatusError{status: resp.StatusCode}
}
return nil
}
type httpStatusError struct{ status int }
func (e *httpStatusError) Error() string {
return fmt.Sprintf("collector returned status %d", e.status)
}
+166 -169
View File
@@ -1,5 +1,11 @@
package webtest
// EO(EdgeOne)特供版的 DNS 模块:与主线 ipw-cn/src/webtest/dns.go 同源,
// 但保留 EO 部署形态的两点差异:
// 1. DoH / UDP 双通道互备(单地址配置下主通道失败自动回退另一通道);
// 2. 查询耗时沿调用链透出(float64 毫秒),ResolveIP 供 tcping / speed 复用。
// 主线对 dns.go 的行为修正(Rcode 透出、服务器地址格式校验)已同步至此。
import (
"bytes"
"fmt"
@@ -7,6 +13,8 @@ import (
"log/slog"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
@@ -29,11 +37,20 @@ var dnsServers []string
// dnssecServers DNSSEC 专用服务器列表(未配置时沿用 dnsServers)
var dnssecServers []string
// defaultUDPServer 兜底 UDP 服务器
const defaultUDPServer = "119.28.28.28:53"
// DNSResult 统一的 DNS 查询结果格式。
//
// Rcode / RcodeText 与主线及各节点保持一致:解析器明确应答 NXDOMAIN 时,属于「域名不存在」
// 这个业务结论,而不是节点故障 —— 前端据此区分两者,别把负响应误显示成拨测失败。
type DNSResult struct {
Domain string `json:"domain"`
Duration float64 `json:"duration"`
Record []string `json:"record"`
TTL uint32 `json:"ttl"`
Domain string `json:"domain"`
Duration float64 `json:"duration"`
Record []string `json:"record"`
TTL uint32 `json:"ttl"`
Rcode int `json:"rcode"` // 解析器应答的 Rcode(0=Success, 3=NXDOMAIN)
RcodeText string `json:"rcode_text"` // Rcode 可读名称,如 NXDOMAIN / SERVFAIL
}
// SetDNSServer 设置 DNS 服务器:支持逗号分隔多地址主从 failover(第一个为主,主失败自动切换后续从服务器)。
@@ -42,15 +59,22 @@ func SetDNSServer(server string) {
if server == "" {
return
}
list := splitServers(server)
// dns-server 既能由本地管理面 PATCH 改,也能由远端托管配置下发 —— 都是外部可控输入。
// 在这里先挡掉畸形值,总比留到查询时才以"莫名其妙的超时/报错"暴露好排查;
// 全部非法时保留原有配置(与"空串不覆盖"的既有语义一致)。
list := validServers(splitServers(server))
if len(list) == 0 {
slog.Warn("all DNS server addresses invalid, keeping previous config", "server", server)
return
}
dnsServers = list
// 兼容单地址的旧语义:URL → DoH 主通道;ip:port → UDP 主通道
if len(list) == 1 {
if strings.HasPrefix(server, "http://") || strings.HasPrefix(server, "https://") {
dohEndpoint = server
if strings.HasPrefix(list[0], "http://") || strings.HasPrefix(list[0], "https://") {
dohEndpoint = list[0]
dnsMode = "doh"
} else {
dnsServer = server
dnsServer = list[0]
dnsMode = "udp"
}
}
@@ -58,7 +82,7 @@ func SetDNSServer(server string) {
// SetDNSSecServer 设置 DNSSEC 专用 DNS 服务器(逗号分隔主从;留空 = 沿用 dns-server 配置)
func SetDNSSecServer(server string) {
if list := splitServers(server); len(list) > 0 {
if list := validServers(splitServers(server)); len(list) > 0 {
dnssecServers = list
}
}
@@ -75,6 +99,50 @@ func splitServers(s string) []string {
return out
}
// validServers 过滤掉格式非法的地址(保留原顺序)。
// 全部非法时返回空列表,调用方据此保留原有配置。
func validServers(list []string) []string {
out := make([]string, 0, len(list))
for _, s := range list {
if err := validateServerAddr(s); err != nil {
slog.Warn("ignored invalid DNS server address", "server", s, "error", err)
continue
}
out = append(out, s)
}
return out
}
// validateServerAddr 校验单个 DNS 服务器地址的格式:
// - DoH URL:必须是 http/https 且 host 非空(**不限制私网** —— 节点自建内网 DoH 是合理部署);
// - UDP/TCP:必须是 host:port 且端口合法。
//
// 注意:UDP 形式**不禁止私有地址** —— 节点上跑本地缓存解析器(127.0.0.1:53、内网 10.x:53)是常见部署,
// 它是节点侧基础设施配置、不是被拨测目标,一并堵掉会直接让 DNS 拨测失效。
func validateServerAddr(server string) error {
if strings.HasPrefix(server, "http://") || strings.HasPrefix(server, "https://") {
u, err := url.Parse(server)
if err != nil {
return err
}
if u.Hostname() == "" {
return fmt.Errorf("missing host")
}
return nil
}
host, port, err := net.SplitHostPort(server)
if err != nil {
return err
}
if strings.TrimSpace(host) == "" {
return fmt.Errorf("missing host")
}
if p, err := strconv.Atoi(port); err != nil || p < 1 || p > 65535 {
return fmt.Errorf("invalid port %q", port)
}
return nil
}
// ==========================================
// ⭐️ 底层核心:DoH + UDP 双通道 DNS 查询
// ==========================================
@@ -139,9 +207,6 @@ func queryWithServers(msg *dns.Msg, servers []string) (*dns.Msg, float64, error)
return nil, 0, lastErr
}
// defaultUDPServer 兜底 UDP 服务器
const defaultUDPServer = "119.28.28.28:53"
// queryUDPMsg 通过 UDP/TCP 向指定 DNS 服务器发送查询(miekg/dns 自动处理大响应切 TCP)
func queryUDPMsg(msg *dns.Msg, server string) (*dns.Msg, float64, error) {
client := &dns.Client{Timeout: 5 * time.Second}
@@ -154,7 +219,12 @@ func queryUDPMsg(msg *dns.Msg, server string) (*dns.Msg, float64, error) {
return resp, duration, nil
}
// queryDoHMsg 通过 DoH(RFC 8484,POST application/dns-message)向指定端点发送查询
// queryDoHMsg 通过 DoH(RFC 8484,POST application/dns-message)向指定端点发送查询。
//
// 这里**刻意不做私网/回环拦截**(与 website / ssl / speed 那三处不同):
// endpoint 来自 dns-server 配置,而节点上自建内网 DoH(127.0.0.1 / 10.x)是合理部署,
// 堵私网会直接打死功能;配置又是管理员凭据才能改的,不构成匿名可达攻击面。
// 地址**格式**校验已在 SetDNSServer 入口由 validateServerAddr 完成(http/https + host 非空)。
func queryDoHMsg(msg *dns.Msg, endpoint string) (*dns.Msg, float64, error) {
packedMsg, err := msg.Pack()
if err != nil {
@@ -192,209 +262,136 @@ func queryDoHMsg(msg *dns.Msg, endpoint string) (*dns.Msg, float64, error) {
// 业务层:构造查询并经双通道执行,提取特定记录
// ==========================================
// executeDoHQuery 构造标准 DNS 请求报文并经双通道执行。
//
// **不再把 Rcode 非 Success 当错误**:解析器明确应答(哪怕 NXDOMAIN / SERVFAIL)说明网络与
// 解析链路是通的,属合法业务结果,由上层透出 Rcode;只有传输/网络层失败才返回 error。
// 若在此处返回 error,上层 dnsQueryHandler 会回 500,前端把「域名不存在」显示成「节点故障」。
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. 双通道查询(DoH 主 + UDP 备,或反之)
responseMsg, duration, err := queryDNSMsg(msg)
if err != nil {
return nil, duration, err
}
// 3. 检查 DNS 响应码 (Rcode)
if responseMsg.Rcode != dns.RcodeSuccess {
return responseMsg, duration, fmt.Errorf("DNS query failed with Rcode %d", responseMsg.Rcode)
}
return responseMsg, duration, nil
return queryDNSMsg(msg)
}
// ==========================================
// 业务层:调用底层函数并提取特定记录
// ==========================================
func ResolveARecord(domain string) (DNSResult, error) {
// resolveTyped 通用记录查询:成功收到解析器应答(即便 Rcode 非 Success,如 NXDOMAIN/SERVFAIL)
// 即视为一次有效查询,将 Rcode 透出并返回 200;仅在传输/网络层失败(超时、服务器不可达、
// DoH 异常)时才返回 error(上层据此返回 500)。避免把合法的负响应误判为节点故障。
// extract 从一个 RR 中抽取记录字符串(TXT 可能含多条),返回 (values, ttl, matched)。
func resolveTyped(domain, name string, qtype uint16, extract func(dns.RR) ([]string, uint32, bool)) (DNSResult, error) {
result := DNSResult{Domain: domain, Record: []string{}}
responseMsg, duration, err := executeDoHQuery(domain, dns.TypeA)
response, duration, err := executeDoHQuery(name, qtype)
result.Duration = duration
if err != nil {
slog.Warn("Failed to query A", "domain", domain, "err", err)
slog.Warn("Failed to query DNS", "domain", domain, "qtype", qtype, "error", err)
return result, err
}
for _, ans := range responseMsg.Answer {
if aRecord, ok := ans.(*dns.A); ok {
result.Record = append(result.Record, aRecord.A.String())
// 解析器已应答:无论 Rcode 是否为 Success,都属合法结果,不再当 error 返回
result.Rcode = response.Rcode
result.RcodeText = dns.RcodeToString[response.Rcode]
for _, ans := range response.Answer {
if values, ttl, ok := extract(ans); ok {
result.Record = append(result.Record, values...)
if result.TTL == 0 {
result.TTL = aRecord.Header().Ttl
result.TTL = ttl
}
}
}
return result, nil
}
func ResolveARecord(domain string) (DNSResult, error) {
return resolveTyped(domain, domain, dns.TypeA, func(rr dns.RR) ([]string, uint32, bool) {
if a, ok := rr.(*dns.A); ok {
return []string{a.A.String()}, a.Header().Ttl, true
}
return nil, 0, false
})
}
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 resolveTyped(domain, domain, dns.TypeAAAA, func(rr dns.RR) ([]string, uint32, bool) {
if a, ok := rr.(*dns.AAAA); ok {
return []string{a.AAAA.String()}, a.Header().Ttl, true
}
}
return result, nil
return nil, 0, false
})
}
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 resolveTyped(domain, domain, dns.TypeTXT, func(rr dns.RR) ([]string, uint32, bool) {
if txt, ok := rr.(*dns.TXT); ok {
// TXT 一条 RR 可能含多段字符串,保持原有「逐段单独成项」的行为
return txt.Txt, txt.Header().Ttl, true
}
}
return result, nil
return nil, 0, false
})
}
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 resolveTyped(domain, domain, dns.TypeNS, func(rr dns.RR) ([]string, uint32, bool) {
if ns, ok := rr.(*dns.NS); ok {
return []string{ns.Ns}, ns.Header().Ttl, true
}
}
return result, nil
return nil, 0, false
})
}
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 resolveTyped(domain, domain, dns.TypeCNAME, func(rr dns.RR) ([]string, uint32, bool) {
if cname, ok := rr.(*dns.CNAME); ok {
return []string{cname.Target}, cname.Header().Ttl, true
}
}
return result, nil
return nil, 0, false
})
}
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 resolveTyped(domain, domain, dns.TypeMX, func(rr dns.RR) ([]string, uint32, bool) {
if mx, ok := rr.(*dns.MX); ok {
return []string{mx.Mx}, mx.Header().Ttl, true
}
}
return result, nil
return nil, 0, false
})
}
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 resolveTyped(domain, domain, dns.TypeSRV, func(rr dns.RR) ([]string, uint32, bool) {
if srv, ok := rr.(*dns.SRV); ok {
return []string{srv.Target}, srv.Header().Ttl, true
}
}
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
return nil, 0, false
})
}
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 resolveTyped(domain, domain, dns.TypeCAA, func(rr dns.RR) ([]string, uint32, bool) {
if caa, ok := rr.(*dns.CAA); ok {
return []string{caa.Value}, caa.Header().Ttl, true
}
}
return result, nil
return nil, 0, false
})
}
// ResolveIP 通过 DoH 解析域名,返回指定版本(v4/v6)的 IP 地址字符串
// ResolvePTRRecord 反查 IP 的 PTR 记录(返回的 Domain 保持为原始 IP,便于前端直接展示)
func ResolvePTRRecord(ip string) (DNSResult, error) {
ptrName, err := dns.ReverseAddr(ip)
if err != nil {
slog.Warn("Invalid IP address for PTR query", "ip", ip, "error", err)
return DNSResult{Domain: ip, Record: []string{}}, fmt.Errorf("invalid IP address: %v", err)
}
return resolveTyped(ip, ptrName, dns.TypePTR, func(rr dns.RR) ([]string, uint32, bool) {
if ptr, ok := rr.(*dns.PTR); ok {
return []string{ptr.Ptr}, ptr.Header().Ttl, true
}
return nil, 0, false
})
}
// ResolveIP 通过双通道解析域名,返回指定版本(v4/v6)的 IP 地址字符串。
// EO 特供:供 tcping / speed 等需要"先把 host 归一化成 IP"的拨测复用。
func ResolveIP(host string, version string) (string, error) {
if ip := net.ParseIP(host); ip != nil {
if version == "v4" && ip.To4() != nil {
+195 -261
View File
@@ -8,12 +8,12 @@
"name": "lemon-getip",
"version": "0.0.0",
"dependencies": {
"hono": "^4.12.34"
"hono": "^4.13.5"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.22.0",
"typescript": "^5.5.2",
"vitest": "~4.1.10",
"vitest": "~4.1.11",
"wrangler": "^4.124.0"
}
},
@@ -160,18 +160,6 @@
"node": ">=12"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
@@ -183,17 +171,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
@@ -1239,33 +1216,14 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@oxc-project/types": {
"version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
"version": "0.149.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz",
"integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
"url": "https://github.com/sponsors/oxc-project"
}
},
"node_modules/@poppinss/colors": {
@@ -1297,10 +1255,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz",
"integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz",
"integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==",
"cpu": [
"arm64"
],
@@ -1315,9 +1290,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz",
"integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==",
"cpu": [
"arm64"
],
@@ -1332,9 +1307,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz",
"integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==",
"cpu": [
"x64"
],
@@ -1349,9 +1324,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz",
"integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==",
"cpu": [
"x64"
],
@@ -1366,9 +1341,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz",
"integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==",
"cpu": [
"arm"
],
@@ -1383,9 +1358,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz",
"integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==",
"cpu": [
"arm64"
],
@@ -1403,9 +1378,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz",
"integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==",
"cpu": [
"arm64"
],
@@ -1423,9 +1398,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz",
"integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==",
"cpu": [
"ppc64"
],
@@ -1443,9 +1418,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz",
"integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==",
"cpu": [
"s390x"
],
@@ -1463,9 +1438,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz",
"integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==",
"cpu": [
"x64"
],
@@ -1483,9 +1458,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz",
"integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==",
"cpu": [
"x64"
],
@@ -1503,9 +1478,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz",
"integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==",
"cpu": [
"arm64"
],
@@ -1519,40 +1494,10 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.11.1",
"@napi-rs/wasm-runtime": "^1.1.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz",
"integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==",
"cpu": [
"arm64"
],
@@ -1567,9 +1512,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz",
"integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==",
"cpu": [
"x64"
],
@@ -1617,17 +1562,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -1654,16 +1588,16 @@
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -1672,13 +1606,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"@vitest/spy": "4.1.11",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -1699,9 +1633,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1712,13 +1646,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
@@ -1726,14 +1660,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -1742,9 +1676,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1752,13 +1686,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -1944,9 +1878,9 @@
}
},
"node_modules/hono": {
"version": "4.12.34",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.34.tgz",
"integrity": "sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==",
"version": "4.13.5",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz",
"integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -1963,9 +1897,9 @@
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
@@ -1979,23 +1913,23 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
"lightningcss-android-arm64": "1.33.0",
"lightningcss-darwin-arm64": "1.33.0",
"lightningcss-darwin-x64": "1.33.0",
"lightningcss-freebsd-x64": "1.33.0",
"lightningcss-linux-arm-gnueabihf": "1.33.0",
"lightningcss-linux-arm64-gnu": "1.33.0",
"lightningcss-linux-arm64-musl": "1.33.0",
"lightningcss-linux-x64-gnu": "1.33.0",
"lightningcss-linux-x64-musl": "1.33.0",
"lightningcss-win32-arm64-msvc": "1.33.0",
"lightningcss-win32-x64-msvc": "1.33.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"cpu": [
"arm64"
],
@@ -2014,9 +1948,9 @@
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"cpu": [
"arm64"
],
@@ -2035,9 +1969,9 @@
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"cpu": [
"x64"
],
@@ -2056,9 +1990,9 @@
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"cpu": [
"x64"
],
@@ -2077,9 +2011,9 @@
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"cpu": [
"arm"
],
@@ -2098,9 +2032,9 @@
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"cpu": [
"arm64"
],
@@ -2122,9 +2056,9 @@
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"cpu": [
"arm64"
],
@@ -2146,9 +2080,9 @@
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"cpu": [
"x64"
],
@@ -2170,9 +2104,9 @@
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"cpu": [
"x64"
],
@@ -2194,9 +2128,9 @@
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"cpu": [
"arm64"
],
@@ -2215,9 +2149,9 @@
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"cpu": [
"x64"
],
@@ -2264,9 +2198,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
@@ -2318,9 +2252,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2331,9 +2265,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
"dev": true,
"funding": [
{
@@ -2351,7 +2285,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.16",
"nanoid": "^3.3.18",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -2360,13 +2294,13 @@
}
},
"node_modules/rolldown": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz",
"integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.139.0",
"@oxc-project/types": "=0.149.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -2376,21 +2310,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.1.5",
"@rolldown/binding-darwin-arm64": "1.1.5",
"@rolldown/binding-darwin-x64": "1.1.5",
"@rolldown/binding-freebsd-x64": "1.1.5",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
"@rolldown/binding-linux-arm64-musl": "1.1.5",
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
"@rolldown/binding-linux-x64-gnu": "1.1.5",
"@rolldown/binding-linux-x64-musl": "1.1.5",
"@rolldown/binding-openharmony-arm64": "1.1.5",
"@rolldown/binding-wasm32-wasi": "1.1.5",
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
"@rolldown/binding-win32-x64-msvc": "1.1.5"
"@rolldown/binding-android-arm-eabi": "1.2.8",
"@rolldown/binding-android-arm64": "1.2.8",
"@rolldown/binding-darwin-arm64": "1.2.8",
"@rolldown/binding-darwin-x64": "1.2.8",
"@rolldown/binding-freebsd-x64": "1.2.8",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.8",
"@rolldown/binding-linux-arm64-gnu": "1.2.8",
"@rolldown/binding-linux-arm64-musl": "1.2.8",
"@rolldown/binding-linux-ppc64-gnu": "1.2.8",
"@rolldown/binding-linux-s390x-gnu": "1.2.8",
"@rolldown/binding-linux-x64-gnu": "1.2.8",
"@rolldown/binding-linux-x64-musl": "1.2.8",
"@rolldown/binding-openharmony-arm64": "1.2.8",
"@rolldown/binding-win32-arm64-msvc": "1.2.8",
"@rolldown/binding-win32-x64-msvc": "1.2.8"
}
},
"node_modules/semver": {
@@ -2582,16 +2516,16 @@
}
},
"node_modules/vite": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.17",
"rolldown": "~1.1.5",
"postcss": "^8.5.26",
"rolldown": "~1.2.4",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -2608,7 +2542,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.3.0",
"@vitejs/devtools": "^0.4.0 || ^0.5.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -2660,19 +2594,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -2700,12 +2634,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+2 -2
View File
@@ -12,10 +12,10 @@
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.22.0",
"typescript": "^5.5.2",
"vitest": "~4.1.10",
"vitest": "~4.1.11",
"wrangler": "^4.124.0"
},
"dependencies": {
"hono": "^4.12.34"
"hono": "^4.13.5"
}
}
+7 -1
View File
@@ -5,6 +5,12 @@ FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
# 版本信息:CI(docker.yml)注入,与 build_and_release.yml 的二进制产物同源;
# BUILD_TIME 每次构建都变,会使 go build 层缓存失效(go mod download 层不受影响),
# 未传时用兜底值,本地 docker build 也能识别出容器构建
ARG VERSION=dev
ARG COMMIT=unknown
ARG BUILD_TIME=unknown
ENV GO111MODULE=on \
CGO_ENABLED=0 \
GOOS=${TARGETOS} \
@@ -22,7 +28,7 @@ RUN set -eux; \
v7) export GOARM=7 ;; \
v6) export GOARM=6 ;; \
esac; \
go build -o main .
go build -ldflags "-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}" -o main .
# 运行阶段
FROM alpine:latest
+559
View File
@@ -0,0 +1,559 @@
package main
// ==================== 运行时配置接口(HTTP + WS) ====================
//
// 三个动作:
//
// get 查看当前生效配置(凭据类键遮蔽为 ***)
// patch 修改运行时配置(可选写回 setting.json)
// refresh 重新从 REMOTE_CONFIG_URL 拉取远端配置并应用
//
// 通道:
//
// HTTP /v1/config (GET/PATCH) · /v1/config/refresh (POST)
// 鉴权与业务接口一致(tokenCheck)。刻意不挂 v1 组的 nodeReportMiddleware:
// 配置管理不是拨测请求,不该被计入拨测统计。
// WS type=config,data.action = get|patch|refresh,应答 type=config_result。
// 节点是 WS 客户端,消息来自已注册的中间件连接,鉴权由注册时的 node-key 保证。
//
// 备注:远端下发(applyRemoteConfig)与本地 patch 共用 applyConfigMap,避免两处键映射漂移。
// 远端下发的忽略名单统一由 remoteIgnoreList() 给出(受保护凭据 + remote-ignore-config),
// 凭据保护见 configRemoteProtectedKeys。
//
// 需重启的键(configRestartKeys):patch / refresh 里出现**值真的变了**的这类键时,
// 只在应答的 restartRequired 里如实报出,**节点不会自行重启**——重启会中断在途服务,
// 时机由运维掌握(容器编排 / supervisor 也可能接管重启)。端口、CORS 中间件、WS 连接等
// 启动期固定的东西,要等进程真正重启后才换过来。
//
// 通道鉴权差异(重要):
//
// HTTP access-token 未配置时整个管理面关闭——此时节点业务接口也无鉴权,
// 暴露配置读写等于公网可改配置。
// WS 常开。节点是 WS 客户端,指令只来自已通过中间件 ws-keys 校验的注册连接,
// 鉴权在中间件侧完成,与节点是否配 access-token 无关。
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/coder/websocket"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
"lemon-ipw/ssrf"
"lemon-ipw/webtest"
)
// wsClientReconcileDelay ws-url 热更新后延后多久收敛 WS 连接(见 ws.go reconcileWSClient)。
// 改 ws-url 会取消"承载这次指令的那条旧连接",同步收敛会把应答一起掐断:控制台只能看到超时,
// 未配 access-token 的节点连 HTTP 回退都没有。延后一点让应答先写回去。
const wsClientReconcileDelay = 300 * time.Millisecond
// errNoRemoteURL 未配置 remote-config-url 却要求刷新远端配置
var errNoRemoteURL = errors.New("remote-config-url 未配置")
// configSecretKeys 凭据类键:GET 快照里遮蔽,PATCH 仍可写(本地管理员的显式行为)
var configSecretKeys = map[string]bool{
"access-token": true,
"node-key": true,
"report-token": true,
}
// configRemoteProtectedKeys 远端下发(remote-config-url,含收集中心的"托管配置")**永不覆盖**的凭据键。
//
// 为什么硬编码、而不交给 remote-ignore-config:
//
// 这两把凭据被远端改错的故障形态都是"远端一错、本地失联、且无法再远程修回"——
// report-token 被改 → 节点上报被收集中心 /report 拒收,控制台上节点仍显示在线,数据却静默断了;
// access-token 被改 → 控制台再也连不上该节点的管理面,连 PATCH 回去的通道都没了。
// 所以这层解耦必须由节点自己兜底,不能依赖"配置源别写错"。
//
// node-key 有意不在此列:托管部署下常由远端统一下发节点身份;若要一并锁死,
// 把它写进 remote-ignore-config 即可(本名单只增不减,且对本地 PATCH 无影响)。
var configRemoteProtectedKeys = []string{"access-token", "report-token"}
// remoteIgnoreList 远端下发的忽略名单 = 受保护凭据 + 操作员自选的 remote-ignore-config。
// 本地 PATCH(HTTP /v1/config、WS patch)传 nil,不受本名单限制——那是管理员的显式操作。
func remoteIgnoreList() []string {
out := make([]string, 0, len(configRemoteProtectedKeys)+len(REMOTE_IGNORE_CONFIG))
out = append(out, configRemoteProtectedKeys...)
out = append(out, REMOTE_IGNORE_CONFIG...)
return out
}
// protectedKeysIn 取 cfg 中命中远端保护名单的键(升序)。
// 远端下发里出现这些键会被跳过,如实回报 / 记日志,避免"托管配置里写了却不生效"的困惑。
func protectedKeysIn(cfg map[string]any) []string {
var out []string
for _, k := range configRemoteProtectedKeys {
if configValue(cfg, k) != "" {
out = append(out, k)
}
}
sort.Strings(out)
return out
}
// configRestartKeys 改了内存变量但需重启进程才完全生效的键:
//
// - port / trusted-proxies / cors:监听地址与 gin 中间件在启动时构建并固定
// - ipdb:ipdb.Init 与 location / asn 路由注册都按启动时的值判定
// - report-interval-seconds:上报 ticker 在启动时按该值创建
// - node-id / node-key:WS 注册身份在启动时确立;运行中改动只改内存变量,
// 已建立的连接不会换身份,必须重启进程才生效
// - access-token:HTTP 管理面路由是否注册、v1 组是否挂 tokenCheck 都在启动时判定
//
// **不在本表但也不是"每次消费重读"的特例:`ws-url` 是热生效的**——由 WS 客户端控制器
// (ws.go 的 reconcileWSClient)按新地址多退少补,连接集合随配置变化,无需重启。
//
// 不在其中的键即为热生效:`dns-server` / `dnssec-server` / `block-private-ips` /
// `single-stack` / `node-ota` 每次消费时重新读变量。
//
// 判定口径:只有**值真的变了**才算需重启(见 applyConfigMap)——远端/托管配置每次都带全部键,
// 若按"键出现过"判定,每次 refresh 都会把节点误报成"需重启"。
//
// 本表只用于**如实回报** restartRequired,节点不据此自动重启(重启时机交给运维)。
var configRestartKeys = map[string]bool{
"port": true,
"cors": true,
"ipdb": true,
"report-interval-seconds": true,
"trusted-proxies": true,
"node-id": true,
"node-key": true,
"access-token": true,
}
// applyConfigMap 把配置 map 逐键应用到运行时变量(远端下发与本地 patch 共用)。
// ignore 里的键直接跳过(远端用它实现 remote-ignore-config,本地 patch 传 nil 表示不限制)。
//
// 返回:
//
// applied 实际生效的键(升序)
// unknown 不认识的键(不静默丢弃,交调用方回给请求方,便于发现拼错的键)
// restart 已改内存、需重启才完全生效、且**值确实变了**的键(升序)
func applyConfigMap(cfg map[string]any, ignore []string) (applied, unknown, restart []string) {
skip := func(key string) bool {
for _, k := range ignore {
if k == key {
return true
}
}
return false
}
mark := func(key string) {
applied = append(applied, key)
}
// 需重启类键的"改动前"取值:用于判断值是否真的变了。
// applied 的口径是"请求方给了这个键",不是"值变了"——直接拿它当重启依据的话,
// 每次都带全量键的托管配置会让节点每 refresh 一次就误报一次"需重启"。
before := make(map[string]string, len(configRestartKeys))
for k := range configRestartKeys {
before[k] = restartKeyValue(k)
}
// ws-url 改动后要收敛 WS 连接集合:这里只置标志位,循环结束后统一收敛一次(见下)。
// 与需重启键同口径——**值真的变了**才算,否则每次带全量键的托管配置 refresh 都会白跑一次收敛。
beforeWSURL := WS_URL
wsClientDirty := false
for _, key := range sortedKeys(cfg) {
v := configValue(cfg, key) // 空值视为"不覆盖",与远端配置一致
if v == "" || skip(key) {
continue
}
switch key {
case "port":
PORTS = v
case "gh-proxy":
GH_PROXY = v
case "single-stack":
SINGLE_STACK = strings.ToLower(v)
case "dns-server":
DNS_SERVER = v
webtest.SetDNSServer(v)
case "dnssec-server":
DNSSEC_DNS_SERVER = v
webtest.SetDNSSecServer(v)
case "ipdb":
// 仅改变量不够:ipdb.Init 与 location/asn 路由注册都在启动时按该值判定,运行中不重建
IPDB = v
case "cors":
CORS = v
ACCEPT_DOMAINS = splitAndTrim(CORS, ",")
case "block-private-ips":
ssrf.SetEnabled(v != "false" && v != "0")
case "trusted-proxies":
TRUSTED_PROXIES = v
case "remote-config-url":
REMOTE_CONFIG_URL = v
case "remote-ignore-config":
if arr, ok := cfg[key].([]any); ok { // setting.json 里是数组,也可给逗号分隔串
out := make([]string, 0, len(arr))
for _, item := range arr {
if s, ok := item.(string); ok {
if s = strings.TrimSpace(s); s != "" {
out = append(out, s)
}
}
}
REMOTE_IGNORE_CONFIG = out
} else {
REMOTE_IGNORE_CONFIG = splitAndTrim(v, ",")
}
case "access-token":
ACCESS_TOKEN = v
case "ws-url":
WS_URL = v
wsClientDirty = WS_URL != beforeWSURL
case "node-id":
WS_NODE_ID = v
case "node-key":
WS_NODE_KEY = v
case "report-url":
REPORT_URL = v
case "report-token":
REPORT_TOKEN = v
case "report-interval-seconds":
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
unknown = append(unknown, key) // 值非法:不覆盖原值,明确报给调用方
continue
}
REPORT_INTERVAL = n
case "node-ota":
// 热生效:下发 OTA 指令时才读 NODE_OTA(见 ota.go),故不在 configRestartKeys
enabled, known := parseOTASwitch(v)
if !known {
unknown = append(unknown, key) // 值非法:不覆盖原值,明确报给调用方
continue
}
NODE_OTA = enabled
default:
unknown = append(unknown, key)
continue
}
mark(key)
}
// 需重启的键:值真的变了才报(见 before 注释)。仅作为信息回报给调用方,
// 节点不据此重启——重启时机会中断在途服务,由运维决定。
for _, key := range applied {
if configRestartKeys[key] && restartKeyValue(key) != before[key] {
restart = append(restart, key)
}
}
// ws-url 热更新:把在跑的连接收敛到新地址(多退少补)。
// 放在循环外是为了避免一条指令里 ws-url 重复出现时收敛多次;
// 异步延后则是为了先让本次请求的应答写回去——收敛会取消承载它自己的那条旧连接。
if wsClientDirty {
go func() {
time.Sleep(wsClientReconcileDelay)
reconcileWSClient()
}()
}
sort.Strings(applied)
sort.Strings(unknown)
sort.Strings(restart)
return applied, unknown, restart
}
// restartKeyValue 取需重启类键的当前值(字符串化),只用于比对改动前后是否真的变了。
// 不认识的键返回 ""。
func restartKeyValue(key string) string {
switch key {
case "port":
return PORTS
case "cors":
return CORS
case "ipdb":
return IPDB
case "trusted-proxies":
return TRUSTED_PROXIES
case "ws-url":
return WS_URL
case "node-id":
return WS_NODE_ID
case "node-key":
return WS_NODE_KEY
case "access-token":
return ACCESS_TOKEN
case "report-interval-seconds":
return strconv.Itoa(REPORT_INTERVAL)
}
return ""
}
// configSnapshot 当前生效配置的快照(凭据类键只回显 ***,不泄露明文)
func configSnapshot() map[string]any {
out := map[string]any{
"port": PORTS,
"gh-proxy": GH_PROXY,
"single-stack": SINGLE_STACK,
"dns-server": DNS_SERVER,
"dnssec-server": DNSSEC_DNS_SERVER,
"ipdb": IPDB,
"cors": CORS,
"block-private-ips": ssrf.Enabled(),
"trusted-proxies": TRUSTED_PROXIES,
"remote-config-url": REMOTE_CONFIG_URL,
"remote-ignore-config": REMOTE_IGNORE_CONFIG,
"ws-url": WS_URL,
"node-id": WS_NODE_ID,
"node-ota": NODE_OTA,
"report-url": REPORT_URL,
"report-interval-seconds": REPORT_INTERVAL,
}
if ACCESS_TOKEN != "" {
out["access-token"] = "***"
} else {
out["access-token"] = ""
}
if WS_NODE_KEY != "" {
out["node-key"] = "***"
} else {
out["node-key"] = ""
}
if REPORT_TOKEN != "" {
out["report-token"] = "***"
} else {
out["report-token"] = ""
}
return out
}
// persistConfig 把已应用的键写回 setting.json(viper 保留文件中其它未改动的键)。
// 无 setting.json(纯环境变量部署)时新建一份。
func persistConfig(body map[string]any, applied []string) error {
for _, key := range applied {
viper.Set(key, body[key])
}
if err := viper.WriteConfig(); err == nil {
return nil
}
return viper.SafeWriteConfigAs("setting.json")
}
// sortedKeys 配置键升序,保证 applied/unknown 输出顺序稳定(便于日志 diff 与测试)
func sortedKeys(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// refreshRemoteConfig 重新拉取远端配置并应用;受保护凭据(access-token / report-token)
// 永不随远端下发,保持"ENV / setting.json 本地管理"的口径(见 configRemoteProtectedKeys)。
func refreshRemoteConfig() (map[string]any, error) {
url := strings.TrimSpace(REMOTE_CONFIG_URL)
if url == "" {
return nil, errNoRemoteURL
}
cfg, err := fetchRemoteConfig(url)
if err != nil {
return nil, err
}
applied, unknown, restart := applyConfigMap(cfg, remoteIgnoreList())
protected := protectedKeysIn(cfg)
if len(protected) > 0 {
slog.Warn("远端配置里的凭据键已被忽略(凭据由节点本地管理)", "keys", protected, "url", url)
}
// 远端配置本身就是持久来源(重启后会重新拉取),因此不需要补写 setting.json;
// restart 里的键只如实回报,重启时机交给运维
return map[string]any{
"source": url,
"applied": applied,
"unknown": unknown,
"restartRequired": restart,
"config": configSnapshot(),
"protectedIgnored": protected,
"remoteProtectedKeys": configRemoteProtectedKeys,
}, nil
}
// ==================== HTTP ====================
// registerConfigRoutes 注册运行时配置接口(/v1/config 组:与业务接口同鉴权,但不计入拨测统计)。
//
// access-token 未配置时**关闭整个 HTTP 管理面**:此时节点业务接口本身也无鉴权,
// 再暴露配置读写等于公网任何人可改节点配置。WS 通道不受影响——它由节点主动外连中间件,
// 注册需通过中间件的 ws-keys 校验,指令只发给已注册连接,天然有鉴权。
func registerConfigRoutes(r *gin.Engine) {
g := r.Group("/v1/config")
if ACCESS_TOKEN == "" {
slog.Warn("config HTTP API disabled: access-token not set, use WS channel instead")
disabled := func(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "配置管理接口已关闭:节点未配置 access-token,请通过 WS 通道操作",
})
}
g.GET("", disabled)
g.PATCH("", disabled)
g.POST("/refresh", disabled)
return
}
g.Use(tokenCheck())
g.GET("", configGetHandler)
g.PATCH("", configPatchHandler)
g.POST("/refresh", configRefreshHandler)
}
func configGetHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"config": configSnapshot(),
"secretKeys": sortedKeysBool(configSecretKeys),
"restartRequiredKeys": sortedKeysBool(configRestartKeys),
"remoteProtectedKeys": configRemoteProtectedKeys,
})
}
func configPatchHandler(c *gin.Context) {
var body map[string]any
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体须为 JSON 对象:" + err.Error()})
return
}
if len(body) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体为空"})
return
}
// 本地显式 PATCH 不受 remote-ignore-config 限制:那是"防远端覆盖"的白名单
applied, unknown, restart := applyConfigMap(body, nil)
persisted := false
if p := c.Query("persist"); p == "1" || strings.EqualFold(p, "true") {
if err := persistConfig(body, applied); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "写回 setting.json 失败:" + err.Error(),
"applied": applied,
})
return
}
persisted = true
}
// 未要求持久化时只改内存:需重启的键不落盘,进程重启后会回到 setting.json / 远端配置的值。
// 这是刻意的语义——PATCH 是"当前进程内的即时改动",要不要固化由调用方用 persist 明确决定。
c.JSON(http.StatusOK, gin.H{
"applied": applied,
"unknown": unknown,
"restartRequired": restart,
"persisted": persisted,
"config": configSnapshot(),
})
}
func configRefreshHandler(c *gin.Context) {
res, err := refreshRemoteConfig()
if err == errNoRemoteURL {
c.JSON(http.StatusBadRequest, gin.H{"error": "remote-config-url 未配置,无法刷新"})
return
}
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "拉取远端配置失败:" + err.Error(), "source": REMOTE_CONFIG_URL})
return
}
c.JSON(http.StatusOK, res)
}
// sortedKeysBool 取 map 键的升序列表(用于向调用方说明哪些键是凭据/需重启)
func sortedKeysBool(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// ==================== WS ====================
// wsConfigResult 配置指令的 WS 应答(type=config_result)
type wsConfigResult struct {
RequestID string `json:"requestId,omitempty"`
Action string `json:"action"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Source string `json:"source,omitempty"`
Applied []string `json:"applied,omitempty"`
Unknown []string `json:"unknown,omitempty"`
RestartRequired []string `json:"restartRequired,omitempty"`
Persisted bool `json:"persisted,omitempty"`
Config map[string]any `json:"config,omitempty"`
// ProtectedIgnored 本次 refresh 中因凭据保护被跳过的键:远端配置里写了也不生效(见 configRemoteProtectedKeys)
ProtectedIgnored []string `json:"protectedIgnored,omitempty"`
// 元信息:与 HTTP GET 一致,供控制台标注「凭据键不下发」「远端不可覆盖」「该键改动需重启」
SecretKeys []string `json:"secretKeys,omitempty"`
RestartKeys []string `json:"restartRequiredKeys,omitempty"`
RemoteProtectedKeys []string `json:"remoteProtectedKeys,omitempty"`
}
// wsHandleConfig 处理中间件下发的配置指令:get / patch / refresh
func wsHandleConfig(c *websocket.Conn, msg wsMsg) {
var req struct {
RequestID string `json:"requestId"`
Action string `json:"action"`
Config map[string]any `json:"config"`
Persist bool `json:"persist"`
}
_ = json.Unmarshal(msg.Data, &req)
action := strings.ToLower(strings.TrimSpace(req.Action))
if action == "" {
action = "get"
}
res := wsConfigResult{RequestID: req.RequestID, Action: action, OK: true}
switch action {
case "get":
res.Config = configSnapshot()
case "patch":
if len(req.Config) == 0 {
res.OK, res.Error = false, "config 为空"
break
}
applied, unknown, restart := applyConfigMap(req.Config, nil)
// 未要求持久化时只改内存(同 HTTP PATCH):需重启的键不落盘,重启后会回到原值
if req.Persist {
if err := persistConfig(req.Config, applied); err != nil {
res.OK, res.Error = false, "写回 setting.json 失败:"+err.Error()
break
}
res.Persisted = true
}
res.Applied, res.Unknown, res.RestartRequired = applied, unknown, restart
res.Config = configSnapshot()
case "refresh":
out, err := refreshRemoteConfig()
if err != nil {
res.OK, res.Error = false, err.Error()
break
}
res.Source, _ = out["source"].(string)
res.Applied, _ = out["applied"].([]string)
res.Unknown, _ = out["unknown"].([]string)
res.RestartRequired, _ = out["restartRequired"].([]string)
res.ProtectedIgnored, _ = out["protectedIgnored"].([]string)
res.Config, _ = out["config"].(map[string]any)
default:
res.OK, res.Error = false, "未知 action:"+action
}
// 元信息随每次应答带上,控制台据此标注凭据键、远端不可覆盖键与需重启键(与 HTTP GET 保持一致)
res.SecretKeys = sortedKeysBool(configSecretKeys)
res.RestartKeys = sortedKeysBool(configRestartKeys)
res.RemoteProtectedKeys = configRemoteProtectedKeys
wsSend(c, wsMsg{Type: "config_result", Data: wsRaw(res)})
}
+1 -1
View File
@@ -3,6 +3,7 @@ module lemon-ipw
go 1.26.6
require (
github.com/AdguardTeam/golibs v0.35.16
github.com/coder/websocket v1.8.15
github.com/gin-contrib/cors v1.7.7
github.com/gin-gonic/gin v1.12.0
@@ -20,7 +21,6 @@ require (
)
require (
github.com/AdguardTeam/golibs v0.35.16 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
+3 -10
View File
@@ -11,9 +11,9 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
@@ -80,16 +80,15 @@ github.com/oschwald/maxminddb-golang/v2 v2.3.0 h1:PnXjMGjkSQlwOBSyZ7hk6Fd75t7erk
github.com/oschwald/maxminddb-golang/v2 v2.3.0/go.mod h1:NSQvgFwPxODpBTJI5+5Ns1AAucnx7ggW9PSRRifAT1s=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
@@ -129,8 +128,6 @@ golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
@@ -142,12 +139,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+88 -81
View File
@@ -15,10 +15,11 @@ import (
"net/http"
"net/url"
"os"
"runtime"
"os/signal"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/AdguardTeam/golibs/netutil/sysresolv"
@@ -241,10 +242,12 @@ var (
WS_URL string // WS 客户端:中间件 WS 地址(ws://host:port/ws)
WS_NODE_ID string // WS 客户端:节点 id(与中间件 ws-keys 键一致)
WS_NODE_KEY string // WS 客户端:注册 key(与中间件 ws-keys[节点id] 一致,可空)
NODE_OTA string // OTA 自更新开关(node-ota / NODE_OTA),"true" 时启用
REPORT_URL string // 数据上报:收集中心 HTTP 接口基址(report-url / REPORT_URL),WS 全部掉线时的兜底通道
REPORT_TOKEN string // 数据上报:/report 鉴权 token(report-token / REPORT_TOKEN)
REPORT_INTERVAL int // 数据上报间隔秒(report-interval-seconds / REPORT_INTERVAL_SECONDS),缺省 15
TRUSTED_PROXIES string // 可信代理列表(trusted-proxies / TRUSTED_PROXIES),逗号分隔 IP/CIDR,供 ClientIP 判定
httpServer *http.Server // 显式持有的 HTTP 服务(Windows OTA 优雅重启需要先 Shutdown)
otaHandoverWait chan struct{} // 非 nil 表示 Windows OTA 优雅重启模式:Serve 关闭后 main 停在此等待老进程退出
NODE_OTA bool // OTA 升级开关(node-ota / NODE_OTA):缺省允许收集中心下发;只读容器等不可自更新部署显式设 false,节点拒绝指令并回传原因
httpServer *http.Server // 显式持有的 HTTP 服务(收到退出信号时优雅停机需要先 Shutdown)
VERSION string
COMMIT string
BUILD_TIME string
@@ -954,9 +957,26 @@ func pingHandler(c *gin.Context) {
c.JSON(200, rawResult.(*TCPingResult))
}
// healchCheck 健康检查(GET /)。只回答"进程是否活着",不回报版本与能力
// —— 版本号与能力清单走 GET /info(见 nodeInfoHandler)。
//
// 分开的理由:健康检查是免鉴权的对外端点,被负载均衡、OTA 就绪探测、运维 curl 打,
// 混进版本号等于向公网公开"这个节点跑的是哪个版本",也让一个只该回答存活的端点
// 承担了与本意无关的职责。
func healchCheck(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// nodeInfoHandler 节点信息(GET /info):节点版本号 + 能力清单。
//
// 注册在 /v1 组之外,所以与健康检查一样免鉴权,也不计入拨测统计
// (收集中心对纯 HTTP 版节点探活后取回,见 ipw-boce nodeHealth.go)。
// 用途:节点状态页展示版本;下发 config 等管理指令前判断节点能否理解对应指令。
// 能力清单与 WS register 报文同源(见 ws.go nodeCapabilities)。
func nodeInfoHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"version": VERSION,
"capabilities": nodeCapabilities(),
})
}
func tokenCheck() gin.HandlerFunc {
@@ -1026,63 +1046,18 @@ func applyRemoteConfig() {
slog.Warn("Failed to fetch remote config, falling back to local config", "url", url, "error", err)
return
}
// ignore 列表:数组中的配置项不被远端覆盖(逐键判断跳过)
ignored := func(key string) bool {
for _, k := range REMOTE_IGNORE_CONFIG {
if k == key {
return true
}
}
return false
}
if v := configValue(CONFIG, "port"); v != "" && !ignored("port") {
PORTS = v
}
if v := configValue(CONFIG, "gh-proxy"); v != "" && !ignored("gh-proxy") {
GH_PROXY = v
}
if v := configValue(CONFIG, "single-stack"); v != "" && !ignored("single-stack") {
SINGLE_STACK = strings.ToLower(v)
}
if v := configValue(CONFIG, "dns-server"); v != "" && !ignored("dns-server") {
DNS_SERVER = v
}
if v := configValue(CONFIG, "dnssec-server"); v != "" && !ignored("dnssec-server") {
DNSSEC_DNS_SERVER = v
}
if v := configValue(CONFIG, "ipdb"); v != "" && !ignored("ipdb") {
IPDB = v
}
if v := configValue(CONFIG, "cors"); v != "" && !ignored("cors") {
CORS = v
}
// block-private-ips 与 setting.json 格式一致,允许远端覆盖
if v := configValue(CONFIG, "block-private-ips"); v != "" && !ignored("block-private-ips") {
ssrf.SetEnabled(v != "false" && v != "0")
}
// WS 客户端配置(远端可覆盖,除非在 ignore 列表中)
if v := configValue(CONFIG, "ws-url"); v != "" && !ignored("ws-url") {
WS_URL = v
}
if v := configValue(CONFIG, "node-id"); v != "" && !ignored("node-id") {
WS_NODE_ID = v
}
if v := configValue(CONFIG, "node-key"); v != "" && !ignored("node-key") {
WS_NODE_KEY = v
}
// OTA 自更新开关(远端可统一开启/关闭节点的自更新)
if v := configValue(CONFIG, "node-ota"); v != "" && !ignored("node-ota") {
NODE_OTA = v
}
// 可信代理列表(远端可统一下发)
if v := configValue(CONFIG, "trusted-proxies"); v != "" && !ignored("trusted-proxies") {
TRUSTED_PROXIES = v
}
// access-token 不在此覆盖:保持原有优先级(环境变量 > setting.json)
// 键映射统一在 applyConfigMap(与本地 PATCH / WS 指令共用,见 config_api.go);
// 忽略名单由 remoteIgnoreList() 给出:受保护凭据(access-token / report-token)
// + 操作员自选的 remote-ignore-config —— 凭据始终保持"ENV > setting.json"的本地优先级
applied, unknown, _ := applyConfigMap(CONFIG, remoteIgnoreList())
if CORS != "" {
ACCEPT_DOMAINS = splitAndTrim(CORS, ",")
}
slog.Info("Remote config applied", "url", url)
slog.Info("Remote config applied", "url", url, "applied", applied, "unknown", unknown)
if protected := protectedKeysIn(CONFIG); len(protected) > 0 {
slog.Warn("远端配置里的凭据键已被忽略(凭据由节点本地管理,见 configRemoteProtectedKeys)",
"keys", protected, "url", url)
}
}
func readConfig() {
@@ -1098,7 +1073,6 @@ func readConfig() {
CORS = os.Getenv("CORS")
ACCESS_TOKEN = os.Getenv("ACCESS_TOKEN")
TRUSTED_PROXIES = os.Getenv("TRUSTED_PROXIES")
ssrf.SetEnabled(os.Getenv("BLOCK_PRIVATE_IPS") != "false" && os.Getenv("BLOCK_PRIVATE_IPS") != "0")
// SINGLE_STACK is intentionally excluded: empty string is a valid value (dual-stack).
@@ -1132,6 +1106,20 @@ func readConfig() {
if TRUSTED_PROXIES == "" {
TRUSTED_PROXIES = viper.GetString("trusted-proxies")
}
// block-private-ips:SSRF 防护开关,env 优先、其次 setting.json,缺省开启(见 ssrf 包)。
// 必须在 viper.ReadInConfig 之后解析:早期版本只读 ENV,setting.json 里的同名键在启动阶段
// 被静默忽略(只有运行中 patch / 远端配置才会生效),与本文件其余键的"env > setting.json"口径不一致。
blockPrivateRaw := strings.TrimSpace(os.Getenv("BLOCK_PRIVATE_IPS"))
if blockPrivateRaw == "" {
blockPrivateRaw = strings.TrimSpace(viper.GetString("block-private-ips"))
}
ssrf.SetEnabled(blockPrivateRaw != "false" && blockPrivateRaw != "0")
// OTA 升级开关:仅显式关闭才禁用(缺省允许收集中心下发 OTA,见 ota.go)
otaRaw := strings.TrimSpace(os.Getenv("NODE_OTA"))
if otaRaw == "" {
otaRaw = strings.TrimSpace(viper.GetString("node-ota"))
}
NODE_OTA, _ = parseOTASwitch(otaRaw)
if PORTS == "" {
PORTS = "8080"
}
@@ -1159,10 +1147,22 @@ func readConfig() {
if WS_NODE_KEY == "" {
WS_NODE_KEY = viper.GetString("node-key")
}
// NODE_OTA:OTA 自更新开关(环境变量 NODE_OTA 或 setting.json 的 node-ota)
NODE_OTA = os.Getenv("NODE_OTA")
if NODE_OTA == "" {
NODE_OTA = viper.GetString("node-ota")
// 数据上报(report-url / report-token / report-interval-seconds),env 优先;详见 report.go
REPORT_URL = os.Getenv("REPORT_URL")
if REPORT_URL == "" {
REPORT_URL = viper.GetString("report-url")
}
REPORT_TOKEN = os.Getenv("REPORT_TOKEN")
if REPORT_TOKEN == "" {
REPORT_TOKEN = viper.GetString("report-token")
}
REPORT_INTERVAL = viper.GetInt("report-interval-seconds")
if v := os.Getenv("REPORT_INTERVAL_SECONDS"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
REPORT_INTERVAL = n
}
} else if REPORT_INTERVAL <= 0 {
REPORT_INTERVAL = 15
}
// REMOTE_IGNORE_CONFIG:不被远端覆盖的配置项列表(JSON 数组字符串,env 优先)
if raw := os.Getenv("REMOTE_IGNORE_CONFIG"); raw != "" {
@@ -1270,14 +1270,13 @@ func main() {
slog.Info("Starting server", "port", PORTS, "gh_proxy", GH_PROXY, "single_stack", SINGLE_STACK, "dns_server", DNS_SERVER, "CORS_ACCEPT", ACCEPT_DOMAINS)
// WS 客户端:接入中间件 WS 通道(配置 WS_URL 时启用,HTTP 接口不变)
if WS_URL != "" {
go wsClientLoop()
slog.Info("WS client enabled", "url", WS_URL, "nodeId", WS_NODE_ID)
}
// WS 客户端:接入中间件 WS 通道(HTTP 接口不变)。
// 控制器常驻——即使当前 ws-url 为空也启动,否则运行中把 ws-url 热更新进来时没有协程去接管;
// 改 ws-url 由 reconcileWSClient 多退少补(无需重启),改 node-id / node-key 需重启进程。
startWSClientController()
// OTA 自更新:定期检查 GitHub Release 并替换二进制(配置 NODE_OTA / node-ota 启用)
initOTA(GH_PROXY)
// 数据上报:WS 在线走 WS 广播,否则 HTTP POST 收集中心 /report(详见 report.go)
startNodeReporter()
// 缓存清扫:各 sync.Map 只在同 key 重访时惰性淘汰过期条目,
// 公网端点被唯一 key 洪打时内存会无限增长,这里定期清扫
@@ -1315,6 +1314,7 @@ func main() {
if ACCESS_TOKEN != "" {
v1.Use(tokenCheck()) // Apply token check middleware to the v1 group
}
v1.Use(nodeReportMiddleware()) // 数据上报统计:归属规则过滤后计数(详见 report.go)
{
v1.GET("/detail/*url", checkWebsiteHandler)
v1.GET("/ssl/*url", sslCheckHandler)
@@ -1331,10 +1331,17 @@ func main() {
}
}
r.GET("/", healchCheck)
// 运行时配置接口(/v1/config):鉴权同业务接口,但不计入拨测统计
registerConfigRoutes(r)
// 显式持有 http.Server:Windows OTA 优雅重启需要先 Shutdown 释放端口再拉起新进程,
// 避免 r.Run 的隐式 Server 无法受控关闭、父子进程抢端口
// OTA 升级接口(POST /v1/ota):收集中心 HTTP 回退通道,鉴权语义同 config(见 ota.go)
registerOTARoute(r)
r.GET("/", healchCheck)
// 节点信息(版本 + 能力清单):与健康检查分开,免鉴权、不计入统计(见 nodeInfoHandler)
r.GET("/info", nodeInfoHandler)
// 显式持有 http.Server:收到退出信号时先 Shutdown 释放端口,避免 r.Run 的隐式 Server 无法受控关闭
httpServer = &http.Server{Handler: r}
ln, err := net.Listen("tcp", ":"+PORTS)
if err != nil {
@@ -1349,16 +1356,16 @@ func main() {
}
}()
// Windows OTA:Server 被 restartSelf 的优雅停机关闭后,main 停在这里不退出,
// 由 restartSelf 在新进程健康检查通过后 os.Exit 结束整个老进程
if otaEnabled() && runtime.GOOS == "windows" {
otaHandoverWait = make(chan struct{})
<-otaHandoverWait
}
// main 必须阻塞,否则 Server goroutine 随 main 返回而消亡,进程启动后立即退出。
// 阻塞在退出信号上,收到后走优雅停机。
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
<-sigCh
gracefulShutdown()
}
// gracefulShutdown 优雅停止 HTTP 服务:停止接收新请求,等待在途请求完成(上限 30s)。
// 供 Windows OTA 在拉起新进程前调用,确保监听端口先释放、子进程能正常绑定。
// 供收到退出信号时调用,确保监听端口先释放再退出进程。
func gracefulShutdown() {
if httpServer == nil {
return
+313 -268
View File
@@ -2,74 +2,247 @@ package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"math/rand"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
"resty.dev/v3"
"github.com/coder/websocket"
"github.com/gin-gonic/gin"
)
// OTA 自更新:定期检查 GitHub Release,下载与当前平台匹配的新版二进制替换自身并重启。
// 默认关闭,需显式开启(环境变量 NODE_OTA 或 setting.json 的 node-ota)。
// ==================== OTA 升级(收集中心下发,节点执行) ====================
//
// 控制台(ipw-boce)经 WS 下发 type=ota 指令(或 HTTP 回退 POST /v1/ota,同 config 的鉴权语义),
// 节点下载新二进制 → 校验 → 预检 → 原子替换 → 重启。下载源二选一:
// - url 直发:直接下载该地址
// - version + assetBase:节点按自身平台计算资产名 lemonipw-{goos}-{goarch}[.exe]
// (GOARCH=arm 无法在运行时区分 GOARM,依次尝试 armv7/armv6/arm),拼出
// {assetBase}/{version}/lemonipw-... 逐一尝试
// - sha256 提供时强校验(hex,兼容 "sha256:" 前缀)
//
// 进度经 WS 回 type=ota_result {requestId, ok, stage, error}:
// accepted → downloading → verifying → installing → restarting(ok=false 时 error 说明原因)。
// 进程重启后连接必然断开,"最终结果"无法经原连接回传——收集中心以节点重连注册上报的
// 新版本号判定成败(见 ipw-boce ota.go 的任务追踪)。
//
// 本地开关:node-ota=false(env NODE_OTA)时节点拒绝一切 OTA 指令并回传原因,
// 供只读文件系统 / 编排托管的部署使用(替换二进制不可行,升级走各自部署渠道)。
//
// 交接逻辑与 middleware-go/ota.go 保持一致:
// 预检 → 原子替换 → 优雅停机 → 拉起新进程 → 健康检查确认 → 老进程退出;失败回滚 .old。
const (
otaReleaseAPI = "https://api.github.com/repos/nomdn/ipw-cn/releases/latest"
otaFirstDelay = 5 * time.Minute // 启动后首次检查的延迟(避免与数据库下载冲突)
otaCheckInterval = 6 * time.Hour // 常规检查间隔
otaMinSize = 1 * 1024 * 1024 // 下载文件最小体积,防止拿到错误页/占位文件
otaMinSize = 1 * 1024 * 1024 // 下载文件最小体积,防止拿到错误页/占位文件
otaDownloadTimeout = 10 * time.Minute // 单次下载超时
)
var (
otaMu sync.Mutex
otaLastRun time.Time
)
// otaAssetBaseDefault 按版本下发且未指定 assetBase 时的默认发布地址
const otaAssetBaseDefault = "https://github.com/nomdn/ipw-cn/releases/download"
type otaRelease struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
Size int64 `json:"size"`
} `json:"assets"`
}
// otaEnabled 判断是否启用 OTA("true" / "1" / "yes" / "on" 视为开启)
func otaEnabled() bool {
switch strings.ToLower(strings.TrimSpace(NODE_OTA)) {
case "true", "1", "yes", "on":
return true
// parseOTASwitch 解析 OTA 开关的字面值——启动加载(ENV / setting.json)、远端下发、运行时 PATCH
// 三处共用,保证什么算关闭的口径只有一份。
//
// 语义是缺省允许、显式关闭(见本文件头):true/1/yes/on 与空值都算开启,
// false/0/no/off 算关闭。known=false 表示写法认不出来——运行时 PATCH 据此回
// unknown 且不覆盖原值,启动期与远端下发则按缺省(允许)处理,不因一个手滑的
// 字面值把节点锁死。
func parseOTASwitch(raw string) (enabled, known bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "true", "1", "yes", "on":
return true, true
case "false", "0", "no", "off":
return false, true
}
return false
return true, false
}
// initOTA 在后台启动 OTA 检查循环,未启用时直接返回
func initOTA(ghproxy string) {
if !otaEnabled() {
slog.Debug("OTA disabled")
// otaMu 单飞:同一时刻只允许一个 OTA 任务(重复下发直接拒绝)
var otaMu sync.Mutex
// otaRequest OTA 下发报文(WS ota 消息的 data 与 POST /v1/ota 的 body 同构)
type otaRequest struct {
RequestID string `json:"requestId"`
URL string `json:"url"` // 直发下载地址(与 version 二选一,url 优先)
Version string `json:"version"` // 目标版本(release tag,如 v1.2.3 / 1.2.3 均可)
AssetBase string `json:"assetBase"` // 发布资产基址(version 模式用;空 = otaAssetBaseDefault)
SHA256 string `json:"sha256"` // 可选,内容校验(hex64)
}
// otaProgress 阶段回报函数:WS 触发时回 ota_result,HTTP 触发时为 nil(只记日志)
type otaProgress func(ok bool, stage, errMsg string)
// ==================== WS 入口 ====================
// wsHandleOTA 处理收集中心下发的 OTA 指令(ws.go type=ota 调用)。
// 先同步回 accepted 表明节点认识该指令(老版本节点静默忽略,收集中心据此给出过旧提示),
// 再异步执行下载/替换/重启。
func wsHandleOTA(c *websocket.Conn, data []byte) {
var req otaRequest
if err := json.Unmarshal(data, &req); err != nil {
otaReport(c, req.RequestID, false, "accepted", "bad payload: "+err.Error())
return
}
slog.Info("OTA enabled", "current_version", VERSION, "check_interval", otaCheckInterval.String())
// 本地开关(node-ota=false,只读容器等不可自更新部署):明确拒绝并回传原因,
// 收集中心据此立即判任务失败,而不是干等超时
if !NODE_OTA {
otaReport(c, req.RequestID, false, "accepted", "节点已禁用 OTA(node-ota=false)")
return
}
otaReport(c, req.RequestID, true, "accepted", "")
go otaRun(req, func(ok bool, stage, errMsg string) {
otaReport(c, req.RequestID, ok, stage, errMsg)
})
}
go func() {
// 首次延迟,避免启动瞬间与数据库下载抢占带宽或触发重启
time.Sleep(otaFirstDelay)
for {
checkOTAUpdate(ghproxy)
jitter := time.Duration(rand.Int63n(int64(time.Hour)))
time.Sleep(otaCheckInterval + jitter)
// otaReport 回一帧 ota_result(进度或失败原因)
func otaReport(c *websocket.Conn, requestID string, ok bool, stage, errMsg string) {
if c == nil {
return
}
wsSend(c, wsMsg{Type: "ota_result", Data: wsRaw(map[string]any{
"requestId": requestID, "ok": ok, "stage": stage, "error": errMsg,
})})
}
// ==================== HTTP 入口 ====================
// registerOTARoute 注册 POST /v1/ota(收集中心 HTTP 回退通道)。
// 鉴权与 /v1/config 一致:access-token 未配置时整个 HTTP OTA 管理面关闭(RCE 面不能裸奔)。
func registerOTARoute(r *gin.Engine) {
g := r.Group("/v1/ota")
if ACCESS_TOKEN == "" {
slog.Warn("ota HTTP API disabled: access-token not set, use WS channel instead")
g.POST("", func(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "OTA 接口已关闭:节点未配置 access-token,请通过 WS 通道下发",
})
})
return
}
g.Use(tokenCheck())
g.POST("", func(c *gin.Context) {
if !NODE_OTA {
c.JSON(http.StatusForbidden, gin.H{"error": "节点已禁用 OTA(node-ota=false)"})
return
}
}()
var req otaRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
return
}
if err := otaValidate(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 异步执行:下载可能耗时数分钟,HTTP 无进度通道,结果看节点日志与重连后的版本号
go otaRun(req, nil)
c.JSON(http.StatusAccepted, gin.H{"ok": true, "started": true})
})
}
// otaValidate 校验下发参数(url 与 version 至少其一;sha256 合法时规整为小写 hex64)
func otaValidate(req *otaRequest) error {
req.URL = strings.TrimSpace(req.URL)
req.Version = strings.TrimSpace(req.Version)
req.SHA256 = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(req.SHA256), "sha256:"))
if req.URL == "" && req.Version == "" {
return fmt.Errorf("url 与 version 至少填一项")
}
if req.URL != "" {
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") {
return fmt.Errorf("url 必须是 http(s) 地址")
}
}
if req.SHA256 != "" {
if len(req.SHA256) != 64 {
return fmt.Errorf("sha256 必须是 64 位 hex")
}
if _, err := hex.DecodeString(req.SHA256); err != nil {
return fmt.Errorf("sha256 必须是合法 hex")
}
req.SHA256 = strings.ToLower(req.SHA256)
}
return nil
}
// ==================== 执行引擎 ====================
// otaRun 执行 OTA:解析下载源 → 下载校验 → 预检 → 替换 → 重启。
// 每个 ok=false 的阶段回报后终止;重启前的最后回报是 "restarting"(连接随后断开)。
func otaRun(req otaRequest, progress otaProgress) {
report := func(ok bool, stage, errMsg string) {
if errMsg == "" {
slog.Info("[ota]", "stage", stage, "ok", ok)
} else {
slog.Info("[ota]", "stage", stage, "ok", ok, "msg", errMsg)
}
if progress != nil {
progress(ok, stage, errMsg)
}
}
if !otaMu.TryLock() {
report(false, "accepted", "已有 OTA 任务在执行中,请稍后再试")
return
}
defer otaMu.Unlock()
if err := otaValidate(&req); err != nil {
report(false, "accepted", err.Error())
return
}
exePath, err := os.Executable()
if err != nil {
report(false, "accepted", "cannot locate executable: "+err.Error())
return
}
exePath, err = filepath.EvalSymlinks(exePath)
if err != nil {
report(false, "accepted", "cannot resolve executable path: "+err.Error())
return
}
// 下载到与目标同分区(保证 rename 原子替换)
tmpPath := filepath.Join(filepath.Dir(exePath), "ota-update.tmp")
report(true, "downloading", "")
if err := otaDownload(req, tmpPath); err != nil {
_ = os.Remove(tmpPath)
report(false, "downloading", err.Error())
return
}
// 预检:试运行新二进制(-v 打印版本即退出),损坏/架构不符的文件在停机前就拦下
report(true, "verifying", "")
if err := preflightBinary(tmpPath); err != nil {
_ = os.Remove(tmpPath)
report(false, "verifying", err.Error())
return
}
report(true, "installing", "")
if err := replaceBinary(tmpPath, exePath); err != nil {
report(false, "installing", err.Error())
return
}
report(true, "restarting", "")
// 给 WS 把 restarting 帧写出去留一点时间,然后交接(连接随进程结束断开)
time.Sleep(300 * time.Millisecond)
slog.Info("[ota] binary replaced, restarting")
restartSelf(exePath)
}
// otaAssetNames 按优先级返回当前平台可能的发布资产名(与 build_and_release.yml 命名一致)
@@ -78,223 +251,112 @@ func otaAssetNames() []string {
if runtime.GOOS == "windows" {
suffix = ".exe"
}
arch := runtime.GOARCH
// GOARCH=arm 时无法在运行时区分 GOARM(armv7/armv6),依次尝试
if runtime.GOOS == "linux" && arch == "arm" {
return []string{
"lemonipw-linux-armv7",
"lemonipw-linux-armv6",
"lemonipw-linux-arm",
}
if runtime.GOOS == "linux" && runtime.GOARCH == "arm" {
return []string{"lemonipw-linux-armv7", "lemonipw-linux-armv6", "lemonipw-linux-arm"}
}
return []string{fmt.Sprintf("lemonipw-%s-%s%s", runtime.GOOS, arch, suffix)}
return []string{fmt.Sprintf("lemonipw-%s-%s%s", runtime.GOOS, runtime.GOARCH, suffix)}
}
// compareVersion 比较版本号:latest > current 返回正数,相等返回 0,否则返回负数;
// 非数字段按字典序比较。latest 为空(无法获取远端版本)时返回 0,不做更新。
func compareVersion(latest, current string) int {
if strings.TrimSpace(latest) == "" {
return 0
}
trim := func(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "v")
s = strings.TrimPrefix(s, "V")
if i := strings.IndexAny(s, "-+"); i >= 0 { // 忽略预发布后缀
s = s[:i]
// otaDownload 把下发参数解析成候选下载地址并下载到 dst(url 直发只有一个候选;
// version 模式按平台资产名生成多个候选逐一尝试)
func otaDownload(req otaRequest, dst string) error {
var urls []string
if req.URL != "" {
urls = []string{req.URL}
} else {
base := strings.TrimSuffix(strings.TrimSpace(req.AssetBase), "/")
if base == "" {
base = otaAssetBaseDefault
}
return s
}
a := strings.Split(trim(latest), ".")
b := strings.Split(trim(current), ".")
for i := 0; i < len(a) || i < len(b); i++ {
var x, y int
var xe, ye error
if i < len(a) {
x, xe = strconv.Atoi(a[i])
tag := req.Version
if !strings.HasPrefix(tag, "v") {
tag = "v" + tag // 本仓库 release 一律 v 前缀(CI 由 v* 标签触发),宽容无前缀写法
}
if i < len(b) {
y, ye = strconv.Atoi(b[i])
}
switch {
case xe == nil && ye == nil:
if x != y {
return x - y
}
case xe == nil: // 只有当前版本非数字
return 1
case ye == nil:
return -1
default: // 两段都非数字,按字典序
if a[i] != b[i] {
if a[i] > b[i] {
return 1
}
return -1
for _, name := range otaAssetNames() {
u := fmt.Sprintf("%s/%s/%s", base, tag, name)
// 延续原版自更新的加速语义:GitHub 官方基址且节点配了 gh-proxy 时自动加前缀;
// url 直发不改写(管理员指定的地址保持原样)
if GH_PROXY != "" && strings.Contains(base, "github.com") {
u = strings.TrimRight(GH_PROXY, "/") + "/" + u
}
urls = append(urls, u)
}
}
return 0
}
// majorSame 判断 latest 与 current 的 major 段是否相同。
// 任一段无法解析(如本地构建 VERSION 为空)时返回 true(放行,不拦截更新)。
func majorSame(latest, current string) bool {
parseMajor := func(s string) (int, bool) {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "v")
s = strings.TrimPrefix(s, "V")
if i := strings.IndexAny(s, "-+."); i >= 0 {
s = s[:i]
var lastErr error
for _, u := range urls {
err := otaDownloadOne(u, dst, req.SHA256)
if err == nil {
return nil
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, false
}
return n, true
slog.Warn("[ota] download candidate failed", "url", u, "error", err)
lastErr = err
_ = os.Remove(dst)
}
lm, lok := parseMajor(latest)
cm, cok := parseMajor(current)
if !lok || !cok {
return true
}
return lm == cm
return lastErr
}
// fetchLatestRelease 查询 GitHub 最新 Release 信息
func fetchLatestRelease() (*otaRelease, error) {
client := resty.New().SetTimeout(30 * time.Second)
defer client.Close()
resp, err := client.R().
SetHeader("Accept", "application/vnd.github+json").
SetHeader("User-Agent", "lemon-ipw-ota").
Get(otaReleaseAPI)
if err != nil {
return nil, err
}
if resp.IsError() {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode())
}
var rel otaRelease
if err := json.Unmarshal(resp.Bytes(), &rel); err != nil {
return nil, err
}
if rel.TagName == "" {
return nil, fmt.Errorf("empty tag_name")
}
return &rel, nil
}
// findAssetURL 在 Release 资产中查找当前平台的下载链接,同时返回资产大小用于完整性校验
func findAssetURL(rel *otaRelease) (string, string, int64) {
for _, want := range otaAssetNames() {
for _, a := range rel.Assets {
if a.Name == want {
return a.BrowserDownloadURL, a.Name, a.Size
}
}
}
return "", "", 0
}
// checkOTAUpdate 执行一次检查:有新版本则下载替换并重启
func checkOTAUpdate(ghproxy string) {
otaMu.Lock()
defer otaMu.Unlock()
otaLastRun = time.Now()
rel, err := fetchLatestRelease()
if err != nil {
slog.Warn("OTA check failed", "error", err)
return
}
cmp := compareVersion(rel.TagName, VERSION)
if cmp <= 0 {
slog.Debug("OTA already up to date", "latest", rel.TagName, "current", VERSION)
return
}
// major 版本变化(如 3.x → 4.x)通常含破坏性变更,不自动更新,需人工升级
if !majorSame(rel.TagName, VERSION) {
slog.Warn("OTA skipped: major version upgrade requires manual action",
"latest", rel.TagName, "current", VERSION)
return
}
assetURL, assetName, assetSize := findAssetURL(rel)
if assetURL == "" {
slog.Warn("OTA no matching asset for this platform",
"tag", rel.TagName, "goos", runtime.GOOS, "goarch", runtime.GOARCH)
return
}
slog.Info("OTA new version found", "tag", rel.TagName, "current", VERSION, "asset", assetName)
exePath, err := os.Executable()
if err != nil {
slog.Error("OTA cannot locate executable", "error", err)
return
}
exePath, err = filepath.EvalSymlinks(exePath)
if err != nil {
slog.Error("OTA cannot resolve executable path", "error", err)
return
}
// 下载到同目录(保证与目标同分区,rename 原子替换),
// 临时文件使用远端资产名(如 lemonipw-linux-amd64)+ .tmp 后缀,避免与运行中的 exe 冲突
tmpPath := filepath.Join(filepath.Dir(exePath), assetName+".tmp")
if err := downloadOTA(ghproxy+assetURL, tmpPath, assetSize); err != nil {
slog.Error("OTA download failed", "error", err)
os.Remove(tmpPath)
return
}
// 预检:停机前先试运行新二进制(-v 打印版本即退出),确认文件可执行、架构匹配。
// 损坏/被杀毒隔离的文件若直到交接时才发现,子进程会秒退导致服务中断
if err := preflightBinary(tmpPath); err != nil {
slog.Error("OTA preflight failed, keep current version", "error", err)
os.Remove(tmpPath)
return
}
if err := replaceBinary(tmpPath, exePath); err != nil {
slog.Error("OTA replace failed", "error", err)
return
}
slog.Info("OTA binary replaced, restarting", "tag", rel.TagName)
restartSelf(exePath)
}
// downloadOTA 下载二进制到临时文件,并做体积校验(Release 声明的资产大小精确比对 + 最小体积兜底)。
// 注意:GitHub Release 未提供签名/哈希清单,无法做内容级认证,体积校验只能防截断与错装文件。
func downloadOTA(url, dst string, expectSize int64) error {
client := resty.New().SetTimeout(10 * time.Minute)
defer client.Close()
resp, err := client.R().SetOutputFileName(dst).SetSaveResponse(true).Get(url)
// otaDownloadOne 下载单个地址到 dst:体积下限 + 可选 sha256 强校验
func otaDownloadOne(url, dst, wantSHA string) error {
client := &http.Client{Timeout: otaDownloadTimeout}
resp, err := client.Get(url)
if err != nil {
return err
}
if resp.IsError() {
return fmt.Errorf("HTTP %d", resp.StatusCode())
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
f, err := os.Create(dst)
if err != nil {
return err
}
hash := sha256.New()
_, copyErr := io.Copy(io.MultiWriter(f, hash), resp.Body)
closeErr := f.Close()
if copyErr != nil || closeErr != nil {
_ = os.Remove(dst)
if copyErr != nil {
return copyErr
}
return closeErr
}
fi, err := os.Stat(dst)
if err != nil {
return err
}
if fi.Size() < otaMinSize {
_ = os.Remove(dst)
return fmt.Errorf("downloaded file too small: %d bytes", fi.Size())
}
if expectSize > 0 && fi.Size() != expectSize {
return fmt.Errorf("downloaded size mismatch: got %d, want %d", fi.Size(), expectSize)
if wantSHA != "" {
got := hex.EncodeToString(hash.Sum(nil))
if !strings.EqualFold(got, wantSHA) {
_ = os.Remove(dst)
return fmt.Errorf("sha256 mismatch: got %s", got)
}
}
// 下载文件默认不带执行位(os.Create 跟随 umask,通常 0644),而 preflightBinary
// 在 replaceBinary 的 chmod 之前就要试运行它;Linux 下不显式加 +x 会 EACCES(Permission denied)。
// 故此处下载落盘后即补执行位,确保 preflight 与最终 replace 都能 exec。
if runtime.GOOS != "windows" {
if err := os.Chmod(dst, 0o755); err != nil {
_ = os.Remove(dst)
return fmt.Errorf("chmod downloaded binary: %w", err)
}
}
return nil
}
// preflightBinary 试运行新二进制(-v 自检:打印版本后立即退出,不读配置不占端口),
// 确认文件可执行、架构匹配。10s 超时防损坏文件卡死。
func preflightBinary(path string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return exec.CommandContext(ctx, path, "-v").Run()
}
// replaceBinary 用新二进制替换当前可执行文件:
// 1) 当前二进制改名为 .old(Windows 下运行中的 exe 不能删除,但可以重命名)
// 2) 新文件 rename 到原位置;失败则回滚
@@ -311,20 +373,16 @@ func replaceBinary(tmpPath, exePath string) error {
}
if runtime.GOOS != "windows" {
if err := os.Chmod(exePath, 0o755); err != nil {
slog.Warn("OTA chmod failed", "error", err)
slog.Warn("[ota] chmod failed", "error", err)
}
}
return nil
}
// restartSelf 用新二进制重启进程:
// - Unix:syscall.Exec 原地替换进程镜像(PID 不变,systemd/Docker 无感;Go 的 socket 带
// CLOEXEC,exec 瞬间旧监听关闭、新进程正常重绑)
// - Unix:syscall.Exec 原地替换进程镜像(PID 不变,systemd/Docker 无感)
// - Windows:运行中的 exe 只能重命名不能替换,采用优雅交接:
// 1) 优雅停机:停止接收新请求并等待在途请求完成,端口释放
// 2) 拉起新进程:端口已空闲,子进程可正常绑定(旧实现先 os.Exit 再赌子进程抢端口,
// 输了就"Server failed to start"退出且 Windows 无守护接管,节点直接下线)
// 3) 轮询子进程健康检查通过后老进程才退出;超时则以退出码 1 结束
// 优雅停机(端口释放)→ 拉起新进程 → 健康检查通过后老进程退出;失败回滚 .old
func restartSelf(exePath string) {
if runtime.GOOS == "windows" {
gracefulShutdown()
@@ -334,45 +392,32 @@ func restartSelf(exePath string) {
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
if err := cmd.Start(); err != nil {
slog.Error("OTA restart failed, waiting for supervisor", "error", err)
slog.Error("[ota] restart failed, waiting for supervisor", "error", err)
os.Exit(1)
}
if waitChildReady(cmd, 10*time.Minute) {
slog.Info("OTA new process ready, old process exiting", "new_pid", cmd.Process.Pid)
if otaWaitChildReady(cmd, 10*time.Minute) {
slog.Info("[ota] new process ready, old process exiting", "new_pid", cmd.Process.Pid)
os.Exit(0)
}
slog.Error("OTA new process not ready (exited or timed out), rolling back", "new_pid", cmd.Process.Pid)
slog.Error("[ota] new process not ready (exited or timed out), rolling back", "new_pid", cmd.Process.Pid)
// 回滚:新版本起不来,用 .old 备份恢复服务,而不是陪它一起下线
if rollbackToOld(exePath) {
slog.Warn("OTA rolled back to previous version, old process exiting")
if otaRollbackToOld(exePath) {
slog.Warn("[ota] rolled back to previous version, old process exiting")
os.Exit(0)
}
os.Exit(1)
}
if err := syscall.Exec(exePath, os.Args, os.Environ()); err != nil {
slog.Error("OTA exec failed, wait for supervisor to restart", "error", err)
slog.Error("[ota] exec failed, wait for supervisor to restart", "error", err)
os.Exit(1)
}
}
// preflightBinary 试运行新二进制(-v 自检:打印版本后立即退出,不读配置不占端口),
// 确认文件可执行、架构匹配。10s 超时防损坏文件卡死。
func preflightBinary(path string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Stdout/Stderr 为 nil 时输出直接丢弃
return exec.CommandContext(ctx, path, "-v").Run()
}
// waitChildReady 轮询新进程的健康检查接口(GET /),确认其完成端口绑定并对外服务。
// otaWaitChildReady 轮询新进程的健康检查接口(GET /),确认其完成端口绑定并对外服务。
// 返回 true = 就绪;false = 子进程启动期间退出(立即判定失败)或超时未就绪。
// 超时给足 10 分钟:子进程在数据库文件缺失时(首次启动/文件损坏)会同步拉取约 450MB
// 库文件后才监听端口,属正常慢启动而非故障——所以判定失败的依据是"子进程退出"
// (进程死了立即返回),而不是固定超时。
func waitChildReady(cmd *exec.Cmd, timeout time.Duration) bool {
func otaWaitChildReady(cmd *exec.Cmd, timeout time.Duration) bool {
client := &http.Client{Timeout: 2 * time.Second}
url := "http://127.0.0.1:" + PORTS + "/"
deadline := time.Now().Add(timeout)
@@ -400,22 +445,22 @@ func waitChildReady(cmd *exec.Cmd, timeout time.Duration) bool {
}
}
// rollbackToOld 用 .old 备份恢复服务:新版本起不来时,把旧二进制换回原位并重新拉起。
// 返回 true 表示旧版本已就绪。恢复失败时尽力把新二进制挪回原位后返回 false。
func rollbackToOld(exePath string) bool {
// otaRollbackToOld 用 .old 备份恢复服务:新版本起不来时,把旧二进制换回原位并重新拉起。
// 返回 true 表示旧版本已就绪。
func otaRollbackToOld(exePath string) bool {
oldPath := exePath + ".old"
if _, err := os.Stat(oldPath); err != nil {
slog.Error("OTA rollback skipped: no .old backup", "error", err)
slog.Error("[ota] rollback skipped: no .old backup", "error", err)
return false
}
failed := exePath + ".failed"
_ = os.Remove(failed)
if err := os.Rename(exePath, failed); err != nil {
slog.Error("OTA rollback: cannot move failed binary", "error", err)
slog.Error("[ota] rollback: cannot move failed binary", "error", err)
return false
}
if err := os.Rename(oldPath, exePath); err != nil {
slog.Error("OTA rollback: cannot restore old binary", "error", err)
slog.Error("[ota] rollback: cannot restore old binary", "error", err)
_ = os.Rename(failed, exePath) // 尽力恢复现场
return false
}
@@ -424,13 +469,13 @@ func rollbackToOld(exePath string) bool {
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
if err := cmd.Start(); err != nil {
slog.Error("OTA rollback: cannot start old binary", "error", err)
slog.Error("[ota] rollback: cannot start old binary", "error", err)
return false
}
if waitChildReady(cmd, 10*time.Minute) {
slog.Warn("OTA rollback serving traffic on previous version", "pid", cmd.Process.Pid)
if otaWaitChildReady(cmd, 10*time.Minute) {
slog.Warn("[ota] rollback serving traffic on previous version", "pid", cmd.Process.Pid)
return true
}
slog.Error("OTA rollback: old binary also failed to become ready")
slog.Error("[ota] rollback: old binary also failed to become ready")
return false
}
+324
View File
@@ -0,0 +1,324 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/gin-gonic/gin"
)
// ==================== 数据上报(节点 → 收集中心 ipw-boce) ====================
//
// 节点是唯一记录者:统计与拨测明细都由本节点上报,中间件不在转发路径上计数,不存在双算。
// 上报通道(报文同构,协议定义见 ipw-boce 的 report.go):
// - WS 客户端启用(WS_URL 配置且至少一条连接在线)→ 经 WS 发 {"type":"report","data":...}
// - WS 未启用或全部连接掉线 → HTTP POST 到收集中心 /report(REPORT_URL)
//
// 两条来源都要上报:
// - HTTP 接口收到的请求(/v1/*)→ nodeReportMiddleware 计数
// - 中间件经 WS 下发的拨测(probe)→ nodeRecordWSProbe 计数
const (
reportMaxProbes = 500
reportMaxStats = 500
)
var (
nodeStatsMu sync.Mutex
nodeStats = map[string]*nodeStatVal{} // apiType → 计数
nodeProbeCh chan nodeProbeRec
nodeProbeDropN int64
nodeDropMu sync.Mutex
)
type nodeStatVal struct {
total, errs, latSum, latMax int64
}
// nodeProbeRec 一条拨测明细(结构与 ipw-boce reportProbe 对齐)
type nodeProbeRec struct {
RequestID string `json:"requestId,omitempty"`
NodeID string `json:"nodeId"`
APIType string `json:"apiType"`
Raw string `json:"raw"`
Query string `json:"query,omitempty"`
Status int `json:"status"`
LatencyMs int64 `json:"latencyMs"`
Error string `json:"error,omitempty"`
Source string `json:"source,omitempty"`
Body string `json:"body,omitempty"`
CreatedAt int64 `json:"createdAt,omitempty"`
}
// nodeIsProbeType 拨测类接口(结果需明细上报)。
// 明细覆盖:连通性/证书类 detail、ssl,DNS 查询类 dns,直连类 tcping/speed。
// dnssec 属功能性校验(结果是否签名/可信),不逐条进明细,只进统计聚合。
func nodeIsProbeType(apiType string) bool {
switch apiType {
// 与中心 store.go 的 isProbeType 白名单对齐:location 归属地查询也逐条进明细,
// 否则节点只上报 location 统计、不上报明细,导致中心大盘有 Location 统计而明细为空。
case "tcping", "speed", "detail", "ssl", "dns", "location":
return true
}
return false
}
// startNodeReporter 启动上报循环(main() 调用)。
// 循环常驻:上报通道(ws-url / report-url)都可能被热更新启用;若沿用"启动时没有通道就不启动",
// 后续热更新出通道也不会有人上报。
func startNodeReporter() {
if REPORT_INTERVAL <= 0 {
REPORT_INTERVAL = 15
}
nodeProbeCh = make(chan nodeProbeRec, 2048)
go nodeReportLoop()
slog.Info("node reporter enabled", "ws", WS_URL != "", "httpFallback", REPORT_URL, "interval", REPORT_INTERVAL)
}
// nodeReportMiddleware /v1 组统计中间件:归属规则过滤 → 计数 → 拨测类补明细
func nodeReportMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
// 收集中心(ipw-boce)主动调度下发的拨测带 X-Scheduler-Probe 头:
// 该次执行由收集中心侧本地落库(source=sched/biz),本节点跳过计数与明细上报,避免双算。
// 真实业务请求(用户/中间件转发)不带此头,仍按"节点是唯一记录者"正常上报。
if c.Request.Header.Get("X-Scheduler-Probe") != "" {
return
}
apiType, raw := nodeAPIFromPath(c)
if apiType == "" {
return
}
latency := time.Since(start)
status := c.Writer.Status()
nodeRecordAPI(apiType, status, latency)
if nodeIsProbeType(apiType) {
nodeRecordProbe(nodeProbeRec{
NodeID: nodeReportNodeID(), APIType: apiType, Raw: raw,
Query: c.Request.URL.RawQuery, Status: status, LatencyMs: latency.Milliseconds(),
Source: "http", CreatedAt: time.Now().Unix(),
})
}
}
}
// nodeRecordWSProbe WS 拨测出口(ws.go wsHandleProbe 调用)。
// 中间件已不在转发路径上计数,WS 下发的拨测同样由本节点上报(唯一记录者);
// requestId 只作为明细的关联标识带出,不参与归属判断。
func nodeRecordWSProbe(requestID, apiType, raw string, query map[string]string, status int, latency time.Duration) {
if apiType == "" {
return
}
nodeRecordAPI(apiType, status, latency)
if nodeIsProbeType(apiType) {
q := ""
for k, v := range query {
if q != "" {
q += "&"
}
q += k + "=" + v
}
nodeRecordProbe(nodeProbeRec{
RequestID: requestID,
NodeID: nodeReportNodeID(), APIType: apiType, Raw: raw, Query: q,
Status: status, LatencyMs: latency.Milliseconds(), Source: "ws",
CreatedAt: time.Now().Unix(),
})
}
}
func nodeRecordAPI(apiType string, status int, latency time.Duration) {
ms := latency.Milliseconds()
nodeStatsMu.Lock()
v, ok := nodeStats[apiType]
if !ok {
v = &nodeStatVal{}
nodeStats[apiType] = v
}
v.total++
if status == 0 || status >= 500 {
v.errs++
}
v.latSum += ms
if ms > v.latMax {
v.latMax = ms
}
nodeStatsMu.Unlock()
}
func nodeRecordProbe(rec nodeProbeRec) {
if nodeProbeCh == nil {
return
}
select {
case nodeProbeCh <- rec:
default:
nodeDropMu.Lock()
nodeProbeDropN++
n := nodeProbeDropN
nodeDropMu.Unlock()
if n%100 == 1 {
slog.Warn("node report probe queue full", "dropped", n)
}
}
}
// nodeReportNodeID 上报身份:优先 node-id(WS_NODE_ID),否则 hostname
func nodeReportNodeID() string {
if WS_NODE_ID != "" {
return WS_NODE_ID
}
if h, err := os.Hostname(); err == nil && h != "" {
return h
}
return "unknown"
}
// nodeAPIFromPath 从 gin 路由模板提取 apiType 与拨测目标(对齐 WS probe 的 raw 格式)
func nodeAPIFromPath(c *gin.Context) (apiType, raw string) {
path := c.FullPath() // 形如 /v1/tcping/:ip
if !strings.HasPrefix(path, "/v1/") {
return "", ""
}
seg := strings.SplitN(strings.TrimPrefix(path, "/v1/"), "/", 2)
if len(seg) == 0 || seg[0] == "" {
return "", ""
}
apiType = seg[0]
switch apiType {
case "tcping", "dnssec", "whois":
raw = c.Param("ip")
if raw == "" {
raw = c.Param("domain")
}
case "speed":
raw = c.Param("version") + c.Param("url") // v4/example.com(对齐 WS probe raw 格式)
case "dns":
raw = c.Param("type") + c.Param("domain") // a/example.com
case "detail", "ssl":
raw = strings.TrimPrefix(c.Param("url"), "/")
case "location", "asn":
raw = c.Param("ip")
}
return apiType, raw
}
// nodeReportLoop 周期上报:WS 在线走 WS 广播(所有已注册中间件),否则 HTTP POST 收集中心
func nodeReportLoop() {
ticker := time.NewTicker(time.Duration(REPORT_INTERVAL) * time.Second)
defer ticker.Stop()
for range ticker.C {
payload := nodeBuildPayload()
if payload == nil {
continue
}
sent := wsBroadcastReport(payload)
if !sent && REPORT_URL != "" {
if err := nodeHTTPReport(payload); err != nil {
slog.Warn("node http report failed", "error", err)
}
}
}
}
// nodeBuildPayload 取走当前计数与拨测缓冲,组装上报报文;无数据返回 nil。
// minute 传 0 = 收集器按自己时钟入桶(节点时钟不可信时不影响聚合口径)
func nodeBuildPayload() map[string]any {
nodeStatsMu.Lock()
snap := nodeStats
nodeStats = make(map[string]*nodeStatVal, len(snap))
nodeStatsMu.Unlock()
probes := make([]nodeProbeRec, 0, reportMaxProbes)
if nodeProbeCh != nil {
drain:
for len(probes) < reportMaxProbes {
select {
case r := <-nodeProbeCh:
probes = append(probes, r)
default:
break drain // 队列已空,本轮结束
}
}
}
if len(snap) == 0 && len(probes) == 0 {
return nil
}
minute := time.Now().UTC().Unix() / 60
stats := make([]map[string]any, 0, len(snap))
for apiType, v := range snap {
stats = append(stats, map[string]any{
"minute": minute, "nodeId": nodeReportNodeID(), "apiType": apiType,
"total": v.total, "errors": v.errs, "latencySumMs": v.latSum, "latencyMaxMs": v.latMax,
})
}
payload := map[string]any{
"instance": nodeReportNodeID(),
"stats": stats,
}
if len(probes) > 0 {
payload["probes"] = probes
}
return payload
}
// wsBroadcastReport 经 WS 通道向所有在线中间件广播 report 消息;无在线连接返回 false(触发 HTTP 兜底)
func wsBroadcastReport(payload map[string]any) bool {
sent := false
data, _ := json.Marshal(payload)
wsActiveConns.Range(func(_, v any) bool {
if c, ok := v.(*websocket.Conn); ok && c != nil {
if err := wsSendErr(c, wsMsg{Type: "report", NodeID: WS_NODE_ID, Data: data}); err != nil {
slog.Warn("node ws report send failed", "error", err)
} else {
sent = true
}
}
return true
})
return sent
}
// nodeHTTPReport HTTP 兜底上报(WS 未启用/全部掉线时)
func nodeHTTPReport(payload map[string]any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
url := strings.TrimSuffix(REPORT_URL, "/") + "/report"
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if REPORT_TOKEN != "" {
req.Header.Set("Authorization", "Bearer "+REPORT_TOKEN)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &httpStatusError{status: resp.StatusCode}
}
return nil
}
type httpStatusError struct{ status int }
func (e *httpStatusError) Error() string {
return fmt.Sprintf("collector returned status %d", e.status)
}
+5 -2
View File
@@ -14,5 +14,8 @@
"ws-url": "",
"node-id": "",
"node-key": "",
"node-ota": "false"
}
"node-ota": "true",
"report-url": "",
"report-token": "",
"report-interval-seconds": 15
}
+152 -297
View File
@@ -5,7 +5,10 @@ import (
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
@@ -23,14 +26,14 @@ var (
// SetDNSServer 设置DNS服务器地址(逗号分隔多地址:第一个为主,主失败自动切换后续从服务器)。
// 每项支持 "ip:port"(UDP)或 DoH URL http(s)://...
func SetDNSServer(server string) {
if list := splitServers(server); len(list) > 0 {
if list := validServers(splitServers(server)); len(list) > 0 {
dnsServers = list
}
}
// SetDNSSecServer 设置DNSSEC专用DNS服务器(逗号分隔主从;留空 = 沿用 dns-server 配置)
func SetDNSSecServer(server string) {
if list := splitServers(server); len(list) > 0 {
if list := validServers(splitServers(server)); len(list) > 0 {
dnssecServers = list
}
}
@@ -47,6 +50,53 @@ func splitServers(s string) []string {
return out
}
// validServers 过滤掉格式非法的地址(保留原顺序)。
//
// dns-server / dnssec-server 既能由本地管理面 PATCH 改,也能由远端托管配置下发 —— 都是外部可控输入。
// 在这里先挡掉畸形值,总比留到查询时才以"莫名其妙的超时/报错"暴露好排查。
// 全部非法时返回空列表,调用方据此保留原有配置(与"空串不覆盖"的既有语义一致)。
func validServers(list []string) []string {
out := make([]string, 0, len(list))
for _, s := range list {
if err := validateServerAddr(s); err != nil {
slog.Warn("ignored invalid DNS server address", "server", s, "error", err)
continue
}
out = append(out, s)
}
return out
}
// validateServerAddr 校验单个 DNS 服务器地址的格式:
// - DoH URL:必须是 http/https 且 host 非空(**不限制私网** —— 节点自建内网 DoH 是合理部署);
// - UDP/TCP:必须是 host:port 且端口合法。
//
// 注意:UDP 形式**不禁止私有地址** —— 节点上跑本地缓存解析器(127.0.0.1:53、内网 10.x:53)是常见部署,
// 它是节点侧基础设施配置、不是被拨测目标,一并堵掉会直接让 DNS 拨测失效。
func validateServerAddr(server string) error {
if strings.HasPrefix(server, "http://") || strings.HasPrefix(server, "https://") {
u, err := url.Parse(server)
if err != nil {
return err
}
if u.Hostname() == "" {
return fmt.Errorf("missing host")
}
return nil
}
host, port, err := net.SplitHostPort(server)
if err != nil {
return err
}
if strings.TrimSpace(host) == "" {
return fmt.Errorf("missing host")
}
if p, err := strconv.Atoi(port); err != nil || p < 1 || p > 65535 {
return fmt.Errorf("invalid port %q", port)
}
return nil
}
const (
defaultUDPServer = "119.28.28.28:53"
defaultDoHEndpoint = "https://doh.pub/dns-query"
@@ -104,7 +154,13 @@ func queryUDPMsg(msg *dns.Msg, server string) (*dns.Msg, error) {
return resp, nil
}
// queryDoHMsg 通过 DoH(RFC 8484,POST application/dns-message)向指定端点发送查询
// queryDoHMsg 通过 DoH(RFC 8484,POST application/dns-message)向指定端点发送查询。
//
// 这里**刻意不做私网/回环拦截**(与 website / ssl / speed 那三处不同):
// endpoint 来自 dns-server 配置,而节点上自建内网 DoH(127.0.0.1 / 10.x)是合理部署,
// 堵私网会直接打死功能;配置又是管理员凭据才能改的,不构成匿名可达攻击面。
// 地址**格式**校验已在 SetDNSServer 入口由 validateServerAddr 完成(http/https + host 非空)。
// 对应 CodeQL go/request-forgery(#24)按误报处理。
func queryDoHMsg(msg *dns.Msg, endpoint string) (*dns.Msg, error) {
packedMsg, err := msg.Pack()
if err != nil {
@@ -138,333 +194,132 @@ func queryDoHMsg(msg *dns.Msg, endpoint string) (*dns.Msg, error) {
// DNSResult 统一的DNS查询结果格式
type DNSResult struct {
Domain string `json:"domain"`
Record []string `json:"record"`
TTL uint32 `json:"ttl"`
Duration float64 `json:"duration"`
Domain string `json:"domain"`
Record []string `json:"record"`
TTL uint32 `json:"ttl"`
Duration float64 `json:"duration"`
Rcode int `json:"rcode"` // 解析器应答的 Rcode(0=Success, 3=NXDOMAIN)
RcodeText string `json:"rcode_text"` // Rcode 可读名称,如 NXDOMAIN / SERVFAIL
}
// resolveTyped 通用记录查询:成功收到解析器应答(即便 Rcode 非 Success,如 NXDOMAIN/SERVFAIL)
// 即视为一次有效查询,将 Rcode 透出并返回 200;仅在传输/网络层失败(超时、服务器不可达、
// DoH 异常)时才返回 error(上层据此返回 500)。避免把合法的负响应误判为节点故障。
// extract 从一个 RR 中抽取记录字符串(TXT 可能含多条),返回 (values, ttl, matched)。
func resolveTyped(domain, name string, qtype uint16, extract func(dns.RR) ([]string, uint32, bool)) (DNSResult, error) {
start := time.Now()
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(name), qtype)
result := DNSResult{Domain: domain}
response, err := queryDNS(msg)
result.Duration = time.Since(start).Seconds() * 1000
if err != nil {
slog.Warn("Failed to query DNS", "domain", domain, "qtype", qtype, "error", err)
result.Record = []string{}
return result, err
}
// 解析器已应答:无论 Rcode 是否为 Success,都属合法结果,不再当 error 返回
result.Rcode = response.Rcode
result.RcodeText = dns.RcodeToString[response.Rcode]
result.Record = []string{}
for _, ans := range response.Answer {
if values, ttl, ok := extract(ans); ok {
result.Record = append(result.Record, values...)
if result.TTL == 0 {
result.TTL = ttl
}
}
}
return result, nil
}
func ResolveARecord(domain string) (DNSResult, error) {
start := time.Now()
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(domain), dns.TypeA)
result := DNSResult{Domain: domain}
response, err := queryDNS(msg)
duration := time.Since(start).Seconds() * 1000
result.Duration = duration
if err != nil {
slog.Warn("Failed to query DNS", "domain", domain, "error", err)
result.Record = []string{}
return result, err
}
if response.Rcode != dns.RcodeSuccess {
slog.Warn("DNS query failed with Rcode", "rcode", response.Rcode)
return result, fmt.Errorf("DNS query failed with Rcode %d", response.Rcode)
}
for _, ans := range response.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 resolveTyped(domain, domain, dns.TypeA, func(rr dns.RR) ([]string, uint32, bool) {
if a, ok := rr.(*dns.A); ok {
return []string{a.A.String()}, a.Header().Ttl, true
}
}
if result.Record == nil {
result.Record = []string{}
}
return result, nil
return nil, 0, false
})
}
func ResolveAAAARecord(domain string) (DNSResult, error) {
start := time.Now()
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(domain), dns.TypeAAAA)
result := DNSResult{Domain: domain}
response, err := queryDNS(msg)
duration := time.Since(start).Seconds() * 1000
result.Duration = duration
if err != nil {
slog.Warn("Failed to query DNS", "domain", domain, "error", err)
result.Record = []string{}
return result, err
}
if response.Rcode != dns.RcodeSuccess {
slog.Warn("DNS query failed with Rcode", "rcode", response.Rcode)
return result, fmt.Errorf("DNS query failed with Rcode %d", response.Rcode)
}
for _, ans := range response.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 resolveTyped(domain, domain, dns.TypeAAAA, func(rr dns.RR) ([]string, uint32, bool) {
if a, ok := rr.(*dns.AAAA); ok {
return []string{a.AAAA.String()}, a.Header().Ttl, true
}
}
if result.Record == nil {
result.Record = []string{}
}
return result, nil
return nil, 0, false
})
}
func ResolveTXTRecord(domain string) (DNSResult, error) {
start := time.Now()
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(domain), dns.TypeTXT)
result := DNSResult{Domain: domain}
response, err := queryDNS(msg)
duration := time.Since(start).Seconds() * 1000
result.Duration = duration
if err != nil {
slog.Warn("Failed to query DNS", "domain", domain, "error", err)
result.Record = []string{}
return result, err
}
if response.Rcode != dns.RcodeSuccess {
slog.Warn("DNS query failed with Rcode", "rcode", response.Rcode)
return result, fmt.Errorf("DNS query failed with Rcode %d", response.Rcode)
}
for _, ans := range response.Answer {
if aRecord, ok := ans.(*dns.TXT); ok {
for _, txt := range aRecord.Txt {
result.Record = append(result.Record, txt)
}
if result.TTL == 0 {
result.TTL = aRecord.Header().Ttl
}
return resolveTyped(domain, domain, dns.TypeTXT, func(rr dns.RR) ([]string, uint32, bool) {
if txt, ok := rr.(*dns.TXT); ok {
// TXT 一条 RR 可能含多段字符串,保持原有「逐段单独成项」的行为
return txt.Txt, txt.Header().Ttl, true
}
}
if result.Record == nil {
result.Record = []string{}
}
return result, nil
return nil, 0, false
})
}
func ResolveNSRecord(domain string) (DNSResult, error) {
start := time.Now()
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(domain), dns.TypeNS)
result := DNSResult{Domain: domain}
response, err := queryDNS(msg)
duration := time.Since(start).Seconds() * 1000
result.Duration = duration
if err != nil {
slog.Warn("Failed to query DNS", "domain", domain, "error", err)
result.Record = []string{}
return result, err
}
if response.Rcode != dns.RcodeSuccess {
slog.Warn("DNS query failed with Rcode", "rcode", response.Rcode)
return result, fmt.Errorf("DNS query failed with Rcode %d", response.Rcode)
}
for _, ans := range response.Answer {
if aRecord, ok := ans.(*dns.NS); ok {
result.Record = append(result.Record, aRecord.Ns)
if result.TTL == 0 {
result.TTL = aRecord.Header().Ttl
}
return resolveTyped(domain, domain, dns.TypeNS, func(rr dns.RR) ([]string, uint32, bool) {
if ns, ok := rr.(*dns.NS); ok {
return []string{ns.Ns}, ns.Header().Ttl, true
}
}
if result.Record == nil {
result.Record = []string{}
}
return result, nil
return nil, 0, false
})
}
func ResolveCNAMERecord(domain string) (DNSResult, error) {
start := time.Now()
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(domain), dns.TypeCNAME)
result := DNSResult{Domain: domain}
response, err := queryDNS(msg)
duration := time.Since(start).Seconds() * 1000
result.Duration = duration
if err != nil {
slog.Warn("Failed to query CNAME", "domain", domain, "error", err)
result.Record = []string{}
return result, err
}
if response.Rcode != dns.RcodeSuccess {
slog.Warn("CNAME query failed with Rcode", "rcode", response.Rcode)
return result, fmt.Errorf("CNAME query failed with Rcode %d", response.Rcode)
}
for _, ans := range response.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 resolveTyped(domain, domain, dns.TypeCNAME, func(rr dns.RR) ([]string, uint32, bool) {
if cname, ok := rr.(*dns.CNAME); ok {
return []string{cname.Target}, cname.Header().Ttl, true
}
}
if result.Record == nil {
result.Record = []string{}
}
return result, nil
return nil, 0, false
})
}
func ResolveMXRecord(domain string) (DNSResult, error) {
start := time.Now()
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(domain), dns.TypeMX)
result := DNSResult{Domain: domain}
response, err := queryDNS(msg)
duration := time.Since(start).Seconds() * 1000
result.Duration = duration
if err != nil {
slog.Warn("Failed to query MX", "domain", domain, "error", err)
result.Record = []string{}
return result, err
}
if response.Rcode != dns.RcodeSuccess {
slog.Warn("MX query failed with Rcode", "rcode", response.Rcode)
return result, fmt.Errorf("MX query failed with Rcode %d", response.Rcode)
}
for _, ans := range response.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 resolveTyped(domain, domain, dns.TypeMX, func(rr dns.RR) ([]string, uint32, bool) {
if mx, ok := rr.(*dns.MX); ok {
return []string{mx.Mx}, mx.Header().Ttl, true
}
}
if result.Record == nil {
result.Record = []string{}
}
return result, nil
return nil, 0, false
})
}
func ResolveSRVRecord(domain string) (DNSResult, error) {
start := time.Now()
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(domain), dns.TypeSRV)
result := DNSResult{Domain: domain}
response, err := queryDNS(msg)
duration := time.Since(start).Seconds() * 1000
result.Duration = duration
if err != nil {
slog.Warn("Failed to query SRV", "domain", domain, "error", err)
result.Record = []string{}
return result, err
}
if response.Rcode != dns.RcodeSuccess {
slog.Warn("SRV query failed with Rcode", "rcode", response.Rcode)
return result, fmt.Errorf("SRV query failed with Rcode %d", response.Rcode)
}
for _, ans := range response.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 resolveTyped(domain, domain, dns.TypeSRV, func(rr dns.RR) ([]string, uint32, bool) {
if srv, ok := rr.(*dns.SRV); ok {
return []string{srv.Target}, srv.Header().Ttl, true
}
}
if result.Record == nil {
result.Record = []string{}
}
return result, nil
}
func ResolvePTRRecord(ip string) (DNSResult, error) {
start := time.Now()
ptrName, err := dns.ReverseAddr(ip)
if err != nil {
slog.Warn("Invalid IP address for PTR query", "ip", ip, "error", err)
result := DNSResult{Domain: ip, Record: []string{}}
return result, fmt.Errorf("invalid IP address: %v", err)
}
msg := new(dns.Msg)
msg.SetQuestion(ptrName, dns.TypePTR)
result := DNSResult{Domain: ip}
response, err := queryDNS(msg)
duration := time.Since(start).Seconds() * 1000
result.Duration = duration
if err != nil {
slog.Warn("Failed to query PTR", "ip", ip, "error", err)
result.Record = []string{}
return result, err
}
if response.Rcode != dns.RcodeSuccess {
slog.Warn("PTR query failed with Rcode", "rcode", response.Rcode)
return result, fmt.Errorf("PTR query failed with Rcode %d", response.Rcode)
}
for _, ans := range response.Answer {
if ptrRecord, ok := ans.(*dns.PTR); ok {
result.Record = append(result.Record, ptrRecord.Ptr)
if result.TTL == 0 {
result.TTL = ptrRecord.Header().Ttl
}
}
}
if result.Record == nil {
result.Record = []string{}
}
return result, nil
return nil, 0, false
})
}
func ResolveCAARecord(domain string) (DNSResult, error) {
start := time.Now()
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(domain), dns.TypeCAA)
result := DNSResult{Domain: domain}
response, err := queryDNS(msg)
duration := time.Since(start).Seconds() * 1000
result.Duration = duration
if err != nil {
slog.Warn("Failed to query CAA", "domain", domain, "error", err)
result.Record = []string{}
return result, err
}
if response.Rcode != dns.RcodeSuccess {
slog.Warn("CAA query failed with Rcode", "rcode", response.Rcode)
return result, fmt.Errorf("CAA query failed with Rcode %d", response.Rcode)
}
for _, ans := range response.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 resolveTyped(domain, domain, dns.TypeCAA, func(rr dns.RR) ([]string, uint32, bool) {
if caa, ok := rr.(*dns.CAA); ok {
return []string{caa.Value}, caa.Header().Ttl, true
}
return nil, 0, false
})
}
func ResolvePTRRecord(ip string) (DNSResult, error) {
ptrName, err := dns.ReverseAddr(ip)
if err != nil {
slog.Warn("Invalid IP address for PTR query", "ip", ip, "error", err)
return DNSResult{Domain: ip, Record: []string{}}, fmt.Errorf("invalid IP address: %v", err)
}
if result.Record == nil {
result.Record = []string{}
}
return result, nil
return resolveTyped(ip, ptrName, dns.TypePTR, func(rr dns.RR) ([]string, uint32, bool) {
if ptr, ok := rr.(*dns.PTR); ok {
return []string{ptr.Ptr}, ptr.Header().Ttl, true
}
return nil, 0, false
})
}
// DNSFullResult 完整的DNS查询结果(不包含PTR,PTR需单独查询)
+128 -9
View File
@@ -89,20 +89,138 @@ const happyEyeballsV6Delay = 150 * time.Millisecond
// whoisConnectTimeout 单 IP 连接 + 读写超时上限
const whoisConnectTimeout = 10 * time.Second
// whoisLibTimeout likexian/whois 单跳超时上限(库的超时按「跳」计,同一跳内读写共享该预算)。
// 库默认 30s:一旦注册商转介指向不可达地址(如 Cloudflare 后的网站域名,43 端口被丢弃),
// 单跳干等 30s,整条查询必然超过中心拨测默认 30s 超时。
// 主路径只剩 IANA + 注册局两跳(转介已改为独立限时补查),2×6s 仍在中心超时之内。
const whoisLibTimeout = 6 * time.Second
// whoisReferralTimeout 自行补查注册商转介的总超时。
// 转介只是「锦上添花」,失败即放弃,绝不阻塞已到手的注册局数据。
const whoisReferralTimeout = 3 * time.Second
// QueryWhois 执行 WHOIS 查询并解析结构化数据
// 使用 likexian/whois 库查询原始响应,再用 whois-parser 解析为结构化数据
// 首次失败后 fallback:内置 TLD 映射 → IANA 查询服务器地址 →
// 用 ResolveA/AAAA(自定义DNS) 解析IP → Happy Eyeballs 双栈竞争拨号
// 主路径:likexian/whois 走「IANA → 注册局」两跳,**不追注册商转介**(转介由 chaseReferral 单独限时补),
// 这样即便转介服务器不可达也只在 ~1s 内拿到注册局记录,不会干等满库的 30s 超时。
// 兜底:主路径拿不到内容时才 fallback(内置 TLD 映射 → IANA 查服务器 → 自定义 DNS 解析 IP →
// Happy Eyeballs 双栈竞争拨号)。
func QueryWhois(domain string) (*WhoisResult, error) {
raw, err := whois.Whois(domain)
if err != nil {
raw, err = whoisRetryWithFallback(domain, err)
raw, err := queryWhoisRegistry(domain)
// 库在「连接成功但读失败」等场景会同时返回部分数据与错误,此时数据不能丢;
// 因此只在「内容为空」或「带错误」时让 fallback 再兜一次,并取内容更长的一方。
if err != nil || raw == "" {
if fb, fbErr := whoisRetryWithFallback(domain, err); len(fb) > len(raw) {
raw, err = fb, fbErr
}
}
// 仅当注册局响应干净(err == nil)时才补查注册商转介
if err == nil && raw != "" {
if extra := chaseReferral(domain, raw); extra != "" {
raw += extra
}
}
result := parseWhoisResult(domain, raw)
result.Error = errString(err)
return result, nil
}
// queryWhoisRegistry 走 IANA → 注册局两跳取注册局记录,关闭库的转介链
// (转介改由 chaseReferral 限时补查,避免被不可达的转介服务器拖满 30s)
func queryWhoisRegistry(domain string) (string, error) {
return whois.NewClient().
SetDisableReferral(true).
SetTimeout(whoisLibTimeout).
Whois(domain)
}
// chaseReferral 从注册局响应里取出注册商转介服务器,限时补查一次
// 任何失败(无转介 / 不可达 / 超时)都返回空串,绝不影响已到手的注册局数据
func chaseReferral(domain, raw string) string {
server, port := extractReferralServer(raw)
if server == "" {
return ""
}
// 转介指向的仍是刚查过的注册局服务器(库自带判断的同款保护)
if ext := getExtension(domain); strings.EqualFold(tldWhoisServers[ext], server) {
return ""
}
v4IPs, v6IPs, err := resolveWhoisServerIPs(server)
if err != nil {
return ""
}
ctx, cancel := context.WithTimeout(context.Background(), whoisReferralTimeout)
defer cancel()
data, err := happyEyeballsWhoisQuery(ctx, domain, v4IPs, v6IPs, port)
if err != nil {
return ""
}
return data
}
// referralKeys 指向「下一个 WHOIS 服务器」的字段名(小写,按优先级前缀匹配)
var referralKeys = []string{"registrar whois server", "referralserver", "whois", "referral"}
// normalizeReferralValue 规整转介地址:去协议前缀 / 路径 / 认证尾巴,拆出 host 与 port
// 返回 "" 表示不是可用的 whois 转介。rwhois 是另一种协议,拨过去同样是白等,直接跳过
func normalizeReferralValue(value string) (string, string) {
value = strings.TrimSpace(value)
if value == "" || strings.HasPrefix(strings.ToLower(value), "rwhois:") {
return "", ""
}
for _, prefix := range []string{"http://", "https://", "whois://"} {
value = strings.TrimPrefix(value, prefix)
}
if i := strings.IndexAny(value, "/,"); i != -1 {
value = value[:i]
}
value = strings.TrimSpace(value)
host, port := value, "43"
if h, p, err := net.SplitHostPort(value); err == nil {
host, port = h, p
}
// 合法性:必须是形如 whois.example.com 的主机名,排除 "Server: xxx" 之类的误匹配
if host == "" || strings.ContainsAny(host, " \t:/,") || !strings.Contains(host, ".") {
return "", ""
}
return host, port
}
// extractReferralServer 从注册局响应中解析注册商转介的 WHOIS 服务器与端口,取不到返回 ("", "")
// 覆盖两种写法:字段式(Registrar WHOIS Server: / ReferralServer: / whois:)
// 与注释式(%referral whois://host:4321/auth-area=.)
func extractReferralServer(raw string) (string, string) {
for _, line := range strings.Split(raw, "\n") {
// 归一化:去掉 % / # 注释前缀
line = strings.TrimLeft(strings.TrimSpace(line), "%# \t")
if line == "" {
continue
}
lower := strings.ToLower(line)
for _, key := range referralKeys {
if !strings.HasPrefix(lower, key) {
continue
}
// 从 lower 切分,避免 ToLower 改变长度时错位(主机名大小写不敏感)
rest := strings.TrimSpace(lower[len(key):])
rest = strings.TrimSpace(strings.TrimPrefix(rest, ":"))
if rest == "" {
continue
}
if server, port := normalizeReferralValue(rest); server != "" {
return server, port
}
}
}
return "", ""
}
// errString safely converts an error to string
func errString(err error) string {
if err == nil {
@@ -187,12 +305,13 @@ func resolveWhoisServerIPs(server string) (v4IPs, v6IPs []string, err error) {
// - 等待 happyEyeballsV6Delay 后并发拨号所有 IPv6 IP
// - 取第一个成功完成 WHOIS 查询的结果返回
// - 其他仍在运行的 goroutine 通过 context 取消尽快退出
func happyEyeballsWhoisQuery(domain string, v4IPs, v6IPs []string, port string) (string, error) {
// parent 带 deadline 时(追注册商转介),到点即整体放弃
func happyEyeballsWhoisQuery(parent context.Context, domain string, v4IPs, v6IPs []string, port string) (string, error) {
if len(v4IPs) == 0 && len(v6IPs) == 0 {
return "", fmt.Errorf("no IPs available for WHOIS query")
}
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithCancel(parent)
defer cancel()
type outcome struct {
@@ -269,7 +388,7 @@ func whoisRetryWithFallback(domain string, firstErr error) (string, error) {
}
// Happy Eyeballs 双栈竞争拨号 + 并发多 IP
raw, err := happyEyeballsWhoisQuery(domain, v4IPs, v6IPs, "43")
raw, err := happyEyeballsWhoisQuery(context.Background(), domain, v4IPs, v6IPs, "43")
if err != nil {
return "", err
}
+126 -28
View File
@@ -133,7 +133,9 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
return webtest.TCPingRun(host, port, n, v, 10*time.Second, 100*time.Millisecond)
},
func(err error) *webtest.TCPingStats { return &webtest.TCPingStats{IP: "Error: " + err.Error()} },
func(v string) *webtest.TCPingStats { return &webtest.TCPingStats{IP: "Skipped due to SINGLE_STACK=" + v} },
func(v string) *webtest.TCPingStats {
return &webtest.TCPingStats{IP: "Skipped due to SINGLE_STACK=" + v}
},
)
result := &TCPingResult{IPv4: ipv4, IPv6: ipv6}
pingCache.Store(cacheKey, pingCacheEntry{result: result, timestamp: time.Now()})
@@ -334,19 +336,30 @@ func wsRaw(v any) json.RawMessage {
return b
}
// wsHandleProbe 处理中间件下发的拨测请求(并发执行):取数 → probe_result
// wsActiveConns 当前已注册的中间件连接(url → conn):注册成功写入、断开移除,数据上报广播用(report.go)
var wsActiveConns sync.Map
// wsHandleProbe 处理中间件/收集中心经 WS 下发的拨测请求(并发执行):取数 → probe_result。
// 归属规则:报文带 Scheduler=true = 收集中心主动调度拨测(收集中心侧已本地落库 sched/biz),
// 本节点跳过 data 上报,避免双算;仅回 probe_result。不带 Scheduler 的 WS probe
// (如中间件转发真实业务)仍由本节点上报(节点是唯一记录者,见 report.go)。
func wsHandleProbe(c *websocket.Conn, data json.RawMessage) {
var req struct {
RequestID string `json:"requestId"`
APIType string `json:"apiType"`
Raw string `json:"raw"`
Query map[string]string `json:"query"`
Scheduler bool `json:"scheduler"`
}
if err := json.Unmarshal(data, &req); err != nil || req.RequestID == "" || req.APIType == "" {
slog.Warn("ws client bad probe message")
return
}
start := time.Now()
status, body := wsProbe(req.APIType, req.Raw, req.Query)
if !req.Scheduler {
nodeRecordWSProbe(req.RequestID, req.APIType, req.Raw, req.Query, status, time.Since(start))
}
payload := struct {
RequestID string `json:"requestId"`
Status int `json:"status"`
@@ -372,21 +385,51 @@ const (
wsHeartbeatTimeout = 5 * time.Second
)
// nodeCapabilities 本节点支持的远程管理能力清单,随 register 报文上报给中间件,
// 同时也在 HTTP `GET /info` 里返回(供中间件对纯 HTTP 节点取回,健康检查 `/` 只回存活)。
//
// 存在的意义:中间件下发 config 指令前先查这份清单,
// - 清单里没有该能力 → 立刻给出明确错误(旧版节点会静默忽略这类消息,只能白白等满超时)
// - 未上报清单(老版本节点)→ 中间件按"未知"处理,仍会尝试但会给出旧版本提示
//
// 新增一类管理指令时在此追加,并保持取值稳定(中间件按字符串比对)。
func nodeCapabilities() []string {
return []string{
"probe", // 拨测(WS probe / HTTP 转发)
"report", // 统计与拨测明细上报
"config", // 运行时配置读写(/v1/config、WS config)
"ota", // OTA 升级(WS ota / HTTP POST /v1/ota,见 ota.go)
}
}
// wsClientOnce 连接并服务单个中间件 URL,返回下次重试前的等待时长(由 per-URL 连接循环调用):
// - 连接/读循环断开 → 3s(重试同一 URL)
// - 注册被拒(register_error)→ 30s(多为 key 配置错误,加大间隔避免空转刷日志)
// - 心跳连续失败 wsHeartbeatRetry 次 → 主动断开,3s
// - 返回 0 = 配置缺失(不再重试)
func wsClientOnce(url string) time.Duration {
ctx := context.Background()
//
// ctx 由 per-URL 连接循环(wsClientOnceLoop)传入:ws-url 热更新移除该 URL 时会 cancel,
// Dial / Read 立即返回错误,协程随即退出,不必等满退避。
func wsClientOnce(ctx context.Context, url string) time.Duration {
c, _, err := websocket.Dial(ctx, url, nil)
if err != nil {
slog.Warn("ws client dial failed", "url", url, "error", err)
return 3 * time.Second
}
defer c.CloseNow()
defer func() {
wsActiveConns.Delete(url)
c.CloseNow()
}()
reg := map[string]string{"nodeId": WS_NODE_ID}
// 注册报文带上版本号与能力清单:
// version → 中间件落到 nodes 表,节点状态页展示(升级部署后重新注册即为新版本)
// capabilities → 中间件在下发管理指令前据此判断本节点能否理解 config,
// 老节点不认这类消息又不会回错,只能靠超时暴露(白等 15s)
reg := map[string]any{
"nodeId": WS_NODE_ID,
"version": VERSION,
"capabilities": nodeCapabilities(),
}
if WS_NODE_KEY != "" {
reg["key"] = WS_NODE_KEY
}
@@ -413,6 +456,7 @@ func wsClientOnce(url string) time.Duration {
return 3 * time.Second
}
slog.Info("ws client registered", "nodeId", WS_NODE_ID, "url", url)
wsActiveConns.Store(url, c)
// 心跳 goroutine:每 10s 发 ping,失败重试,连续 3 次失败主动断开(per-URL 循环重连同一中间件)
stopHeartbeat := make(chan struct{})
@@ -452,6 +496,10 @@ func wsClientOnce(url string) time.Duration {
switch msg.Type {
case "probe":
go wsHandleProbe(c, msg.Data)
case "config":
go wsHandleConfig(c, msg) // 远端配置管理:get / patch / refresh(见 config_api.go)
case "ota":
go wsHandleOTA(c, msg.Data) // 收集中心下发的 OTA 升级(见 ota.go)
case "ping":
wsSend(c, wsMsg{Type: "pong"})
case "status":
@@ -460,35 +508,85 @@ func wsClientOnce(url string) time.Duration {
}
}
// wsClientLoop 同时连接所有配置的中间件 URL(逗号分隔,多活):
// 每个 URL 由独立 goroutine 负责,各自注册并保持连接,任一断开只重连自己,不影响其他中间件。
// 注意:多个 URL 指向同一中间件实例时,中间件按 nodeId 单连接,后注册的连接会顶掉先前的。
func wsClientLoop() {
urls := splitWSURLs(WS_URL)
if len(urls) == 0 {
slog.Info("ws client disabled (WS_URL not set)")
return
}
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
wsClientOnceLoop(u)
}(url)
}
wg.Wait()
// wsClientController WS 客户端连接的运行时控制器:持有每个中间件 URL 的独立连接协程,
// 使 ws-url 支持热更新(见 reconcileWSClient,多退少补)。
// node-id / node-key 仍是"需重启"配置项(见 config_api.go configRestartKeys),不在收敛范围内。
var wsClientController = struct {
mu sync.Mutex
started bool
conns map[string]context.CancelFunc // 在跑的 url → 取消函数
}{conns: map[string]context.CancelFunc{}}
// startWSClientController 启动 WS 客户端控制器(进程启动时调用一次)。
// 注意:即使此刻 WS_URL 为空也必须调用——否则运行中把 ws-url 热更新进来时没有协程去接管。
func startWSClientController() {
c := &wsClientController
c.mu.Lock()
c.started = true
c.mu.Unlock()
reconcileWSClient()
}
// wsClientOnceLoop 单个中间件 URL 的持续连接循环:连接 → 注册 → 心跳/读循环 → 断开重试
func wsClientOnceLoop(url string) {
// reconcileWSClient 把在跑的连接收敛到 WS_URL 的当前值(多退少补):
// - URL 已从配置移除 → 取消对应协程,断开该中间件连接
// - 新增 URL → 起新协程连接
//
// 幂等:只做差集增删,因此启动时"远端配置触发的收敛 + startWSClientController 的兜底收敛"
// 两次调用不会重复建连;conns 是"当前在跑哪些连接"的唯一事实来源。
// ctx 取消只是信号,真正的收尾(wsActiveConns.Delete + CloseNow)由该 URL 自己的协程在 defer 里完成,
// 控制器从不持有 *websocket.Conn,因此不存在"控制器关连接、协程还在读"的竞态。
func reconcileWSClient() {
c := &wsClientController
c.mu.Lock()
defer c.mu.Unlock()
if !c.started {
return // 控制器尚未启动(启动早期应用远端配置时),startWSClientController 会兜底收敛
}
urls := splitWSURLs(WS_URL)
want := make(map[string]bool, len(urls))
for _, u := range urls {
want[u] = true
}
for u, cancel := range c.conns {
if !want[u] {
slog.Info("ws client url removed", "url", u)
cancel()
delete(c.conns, u)
}
}
for _, u := range urls {
if _, ok := c.conns[u]; ok {
continue
}
ctx, cancel := context.WithCancel(context.Background())
c.conns[u] = cancel
slog.Info("ws client url added", "url", u, "nodeId", WS_NODE_ID)
go wsClientOnceLoop(ctx, u)
}
if len(c.conns) == 0 {
slog.Info("ws client disabled (no ws-url)")
}
}
// wsClientOnceLoop 单个中间件 URL 的持续连接循环:连接 → 注册 → 心跳/读循环 → 断开重试。
// ctx 取消(该 URL 被热更新移除)时立即退出,不再重试。
func wsClientOnceLoop(ctx context.Context, url string) {
for {
retryDelay := wsClientOnce(url)
retryDelay := wsClientOnce(ctx, url)
if retryDelay == 0 {
slog.Info("ws client stopped", "url", url)
return
}
time.Sleep(retryDelay)
select {
case <-ctx.Done():
slog.Info("ws client stopped", "url", url)
return
case <-time.After(retryDelay):
}
}
}