Compare commits

...
72 Commits
Author SHA1 Message Date
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
nomdn 6b9f6b6b54 fix(ci): matrix 改纯 include 模式,修复任务被折叠成 2 个
GitHub Actions 的 include 规则会把能匹配现有组合的条目合并进组合本身,
image 顶层数组 + 部分字段 include 的写法导致 6 个任务折叠成 2 个
(且都用了最后一条的 armv7)。改为每条 include 自带全部字段
2026-08-29 20:42:16 +08:00
nomdn cfabb06f73 fix(ci): merge 阶段按文件名收集 digest,不再依赖 artifact 目录结构
download-artifact@v4 pattern 下载的实际布局与脚本假设不符,glob 未命中
导致 @sha256:* 字面量传入 imagetools。改为 digest 文件名自带
<镜像>-<平台>-<hex> 元数据 + merge-multiple 平铺下载,脚本按前缀收集,
缺失时列出目录内容并显式报错
2026-08-29 20:36:50 +08:00
nomdn 372cc62473 fix(ci): docker digest 推送去掉 tags 参数
新版 buildx 禁止 tagged ref 按 digest 推送(can't push tagged ref by digest),
push-by-digest 模式改为 outputs name= 传仓库名,最终 tag 由 merge 阶段
imagetools create 统一打
2026-08-29 20:24:20 +08:00
nomdn e9b3a75f02 feat: v3.3.0 — OTA 优雅交接与中间件自更新、多架构镜像、全项目审计修复
OTA 自更新:
- Windows 改优雅交接:预检新二进制 → 原子替换 → 优雅停机 → 拉起新进程 → 健康检查确认 → 老进程退出,失败自动回滚 .old,不再依赖端口抢占竞态
- 下载增加 Release 资产尺寸精确比对;库缺失慢启动按子进程存活判定(10min 宽限)
- OTA 移植至中间件(middleware-go/ota.go,ota/OTA + gh-proxy 配置)

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

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

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


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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

## 修复

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

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

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

## 冒烟验证

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

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

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

## 文档

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

## 其他

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

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

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

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

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

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

## 文档

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

验证:go build/vet 通过;全站文档无 IPW 前缀残留。
2026-08-21 18:22:51 +08:00
100 changed files with 6839 additions and 5040 deletions

No files matched your search

+21 -17
View File
@@ -17,7 +17,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: '1.26.2' # 选择适合的 Go 版本
go-version: '1.26.6' # 与 src/go.mod 声明保持一致,避免依赖 GOTOOLCHAIN 隐式下载
- name: Set version metadata
run: |
echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV
@@ -25,31 +25,35 @@ jobs:
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_ENV
- name: Build for Linux
working-directory: src
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags "$LDFLAGS" -o lemonipw-linux-386
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o lemonipw-linux-amd64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o lemonipw-linux-arm64
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$LDFLAGS" -o lemonipw-linux-armv7
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$LDFLAGS" -o lemonipw-linux-armv6
CGO_ENABLED=0 GOOS=linux GOARCH=loong64 go build -ldflags "$LDFLAGS" -o lemonipw-linux-loong64
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-386
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-amd64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-arm64
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-armv7
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-armv6
CGO_ENABLED=0 GOOS=linux GOARCH=loong64 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-loong64
- name: Build for Windows
working-directory: src
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=windows GOARCH=386 go build -ldflags "$LDFLAGS" -o lemonipw-windows-386.exe
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o lemonipw-windows-amd64.exe
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o lemonipw-windows-arm64.exe
CGO_ENABLED=0 GOOS=windows GOARCH=386 go build -ldflags "$LDFLAGS" -o ../lemonipw-windows-386.exe
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-windows-amd64.exe
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-windows-arm64.exe
- name: Build for macOS
working-directory: src
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o lemonipw-darwin-amd64
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o lemonipw-darwin-arm64
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-darwin-amd64
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-darwin-arm64
- name: Build for FreeBSD
working-directory: src
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags "$LDFLAGS" -o lemonipw-freebsd-386
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o lemonipw-freebsd-amd64
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o lemonipw-freebsd-arm64
CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags "$LDFLAGS" -o ../lemonipw-freebsd-386
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-freebsd-amd64
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-freebsd-arm64
- name: Build middleware-go for Linux
working-directory: middleware-go
run: |
@@ -82,8 +86,8 @@ jobs:
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-freebsd-arm64
- name: Prepare config files for release
run: |
# 后端:发布真实 setting.json(重命名为 setting.json.example);中间件:只发布 setting.json.example
cp setting.json setting.json.example
# 后端:发布 src/ 下的 setting.json.example;中间件:只发布 setting.json.example
cp src/setting.json.example setting.json.example
cp middleware-go/setting.json.example middleware.setting.json.example
- name: Release
uses: softprops/action-gh-release@v3
+203
View File
@@ -0,0 +1,203 @@
name: Docker Multi-Arch
# 打 v* 标签发版时构建多架构镜像并推送到 GHCR + Docker Hub。
# ARM 用 GitHub 原生 arm64 实例(ubuntu-24.04-arm,公开仓库免费)交叉编译,全程无 QEMU:
# - ubuntu-latest → linux/amd64(原生)
# - ubuntu-24.04-arm → linux/arm64(原生)、linux/arm/v7(Go 交叉编译,GOARM 由 Dockerfile 处理)
# 各平台按 digest 推到 GHCR,最后由 merge job 用 buildx imagetools 合并成多架构
# manifest list,同时打上 GHCR 与 Docker Hub 的标签。
#
# Docker Hub 推送为可选:需要仓库 Secrets 配置 DOCKERHUB_USERNAME / DOCKERHUB_TOKEN,
# 未配置时只推 GHCR,不影响流程。
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
# 纯 include 模式:每条自带全部字段,独立成一个任务。
# 注意不能把 image 写成顶层数组再用 include 拆——GitHub 会把能匹配
# 现有组合的 include 条目合并进组合本身,导致任务被折叠(见 v3.3.0 事故)
include:
- image: lemon-ipw
context: src
runner: ubuntu-latest
platforms: linux/amd64
platform_id: amd64
- image: lemon-ipw
context: src
runner: ubuntu-24.04-arm
platforms: linux/arm64
platform_id: arm64
- image: lemon-ipw
context: src
runner: ubuntu-24.04-arm
platforms: linux/arm/v7
platform_id: armv7
- image: lemon-ipw-middleware
context: middleware-go
runner: ubuntu-latest
platforms: linux/amd64
platform_id: amd64
- image: lemon-ipw-middleware
context: middleware-go
runner: ubuntu-24.04-arm
platforms: linux/arm64
platform_id: arm64
- image: lemon-ipw-middleware
context: middleware-go
runner: ubuntu-24.04-arm
platforms: linux/arm/v7
platform_id: armv7
steps:
- name: Checkout repository
uses: actions/checkout@v4
# 版本注入 src/Dockerfile(build-args 见下方构建步骤):
# VERSION 取 ref 名——打 v* 标签时与 build_and_release.yml 的二进制产物一致(带 v 前缀),
# 手动触发时为分支名(与 merge 阶段打的 main 标签对应)
- name: Set version metadata
run: |
echo "VERSION=${GITHUB_REF_NAME}" >> "$GITHUB_ENV"
echo "COMMIT=${GITHUB_SHA}" >> "$GITHUB_ENV"
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}/${{ matrix.image }}
# push-by-digest 模式:不传 tags(新 buildx 禁止 tagged ref 按 digest 推送),
# 仓库名通过 outputs 的 name= 传入,最终 tag 由 merge 阶段统一打
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
platforms: ${{ matrix.platforms }}
# 只有 src/Dockerfile 声明了这三个 ARG;middleware-go 未声明,BuildKit 会静默忽略
build-args: |
VERSION=${{ env.VERSION }}
COMMIT=${{ env.COMMIT }}
BUILD_TIME=${{ env.BUILD_TIME }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=ghcr.io/${{ github.repository }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.image }}-${{ matrix.platform_id }}
cache-to: type=gha,mode=max,scope=${{ matrix.image }}-${{ matrix.platform_id }}
- name: Export digest
run: |
mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.build.outputs.digest }}"
# 文件名自带镜像与平台元数据:merge 阶段 merge-multiple 平铺下载后
# 按前缀收集,不依赖 artifact 的目录结构
touch "${{ runner.temp }}/digests/${{ matrix.image }}-${{ matrix.platform_id }}-${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.image }}-${{ matrix.platform_id }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: build
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# ===== Docker Hub 推送暂时停用 =====
# 需要启用时:取消注释本步骤与下方 merge 脚本中的 docker.io 标签行,
# 并在仓库 Secrets 配置 DOCKERHUB_USERNAME / DOCKERHUB_TOKEN
# - name: Log in to Docker Hub
# if: ${{ secrets.DOCKERHUB_USERNAME != '' }}
# uses: docker/login-action@v3
# with:
# username: ${{ secrets.DOCKERHUB_USERNAME }}
# password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push multi-arch manifest lists
# DOCKERHUB_USERNAME 暂不注入(Docker Hub 推送停用,见上方登录步骤注释)
# env:
# DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
run: |
set -euo pipefail
DIGESTS_DIR="${{ runner.temp }}/digests"
GHCR_REPO="ghcr.io/${GITHUB_REPOSITORY,,}"
# 标签:v1.2.3 → ["1.2.3", "1.2"];手动触发 → ["main"]
if [[ "${GITHUB_REF_NAME}" == v* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
MINOR="$(echo "${VERSION}" | cut -d. -f1,2)"
TAGS=("${VERSION}" "${MINOR}")
else
TAGS=("main")
fi
for IMG in lemon-ipw lemon-ipw-middleware; do
ARGS=()
for TAG in "${TAGS[@]}"; do
ARGS+=(-t "${GHCR_REPO}/${IMG}:${TAG}")
# Docker Hub 推送停用;启用时取消注释并恢复上方 DOCKERHUB_USERNAME env
# if [ -n "${DOCKERHUB_USERNAME}" ]; then
# ARGS+=(-t "docker.io/${DOCKERHUB_USERNAME,,}/${IMG}:${TAG}")
# fi
done
# 各平台单架构 manifest(GHCR 上按 digest 推送的产物)合并为一份
# 多架构 manifest list;imagetools create 会按 -t 目标推送到 GHCR
shopt -s nullglob
for PLATFORM_ID in amd64 arm64 armv7; do
PREFIX="${IMG}-${PLATFORM_ID}-"
FILES=("${DIGESTS_DIR}"/${PREFIX}*)
if [ ${#FILES[@]} -eq 0 ]; then
echo "ERROR: no digest file for ${IMG} (${PLATFORM_ID}); dir contents:" >&2
ls -la "${DIGESTS_DIR}" >&2 || true
exit 1
fi
for f in "${FILES[@]}"; do
hex="$(basename "${f}")"
ARGS+=("${GHCR_REPO}/${IMG}@sha256:${hex#${PREFIX}}")
done
done
shopt -u nullglob
echo "Merging ${#ARGS[@]} refs for ${IMG}:"; printf ' %s\n' "${ARGS[@]}"
docker buildx imagetools create "${ARGS[@]}"
done
echo "Pushed multi-arch manifests: ${TAGS[*]}"
+3 -3
View File
@@ -5,7 +5,7 @@ on:
branches:
- main
paths:
- 'edgeone/**'
- 'serverless/edgeone/**'
workflow_dispatch:
jobs:
@@ -24,10 +24,10 @@ jobs:
- name: Install dependencies
run: npm install -g edgeone
- name: Deploy to EdgeOne Pages
run: cd edgeone && npx edgeone pages deploy -n ipw-cn -t ${{ secrets.EDGEONE_API_TOKEN }}
run: cd serverless/edgeone && npx edgeone pages deploy -n ipw-cn -t ${{ secrets.EDGEONE_API_TOKEN }}
env:
EDGEONE_API_TOKEN: ${{ secrets.EDGEONE_API_TOKEN }}
- name: Deploy to EdgeOne Pages
run: cd edgeone && npx edgeone pages deploy -n ipw-cn2 -t ${{ secrets.EDGEONE_API_TOKEN }}
run: cd serverless/edgeone && npx edgeone pages deploy -n ipw-cn2 -t ${{ secrets.EDGEONE_API_TOKEN }}
env:
EDGEONE_API_TOKEN: ${{ secrets.EDGEONE_API_TOKEN }}
+2 -2
View File
@@ -5,7 +5,7 @@ on:
branches:
- main
paths:
- 'edgeone-getip/**'
- 'serverless/edgeone-getip/**'
workflow_dispatch:
jobs:
@@ -24,6 +24,6 @@ jobs:
- name: Install dependencies
run: npm install -g edgeone
- name: Deploy to EdgeOne Pages
run: cd edgeone-getip && npx edgeone pages deploy -n edgeone-getip -t ${{ secrets.EDGEONE_API_TOKEN }}
run: cd serverless/edgeone-getip && npx edgeone pages deploy -n edgeone-getip -t ${{ secrets.EDGEONE_API_TOKEN }}
env:
EDGEONE_API_TOKEN: ${{ secrets.EDGEONE_API_TOKEN }}
+2 -2
View File
@@ -4,7 +4,7 @@ on:
push:
branches: ['main']
paths:
- 'lemon-getip/**'
- 'serverless/lemon-getip/**'
workflow_dispatch:
jobs:
deploy:
@@ -25,6 +25,6 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
cd lemon-getip
cd serverless/lemon-getip
npm install
npm run deploy
+6 -2
View File
@@ -15,9 +15,13 @@ lemon-ipw
*.tmp
*.log
node_modules/
setting.json
.workbuddy/
# VitePress 构建产物
docs/.vitepress/.temp/
docs/.vitepress/cache/
docs/.vitepress/dist/
docs/.vitepress/dist/
# 构建期临时配置文件(vitepress 每次构建生成,用完即弃)
docs/.vitepress/*.timestamp-*
tmp/
setting.json
*.BIN
-31
View File
@@ -1,31 +0,0 @@
FROM golang:1.26-alpine AS build
ENV GO111MODULE=on \
CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY main.go ./
COPY webtest/ ./webtest/
COPY ipdb/ ./ipdb/
COPY ssrf/ ./ssrf/
RUN go build -o main .
# 运行阶段
FROM alpine:latest
# 添加必要的证书
RUN apk --no-cache add ca-certificates
# 创建非root用户
RUN adduser -D appuser
USER appuser
WORKDIR /home/appuser
# 从构建阶段复制二进制文件
COPY --from=build /app/main .
EXPOSE 8080
CMD ["./main"]
+35 -260
View File
@@ -1,278 +1,53 @@
# Lemon IPW
ipw.cn 替代品,提供 IP 查询、网站检测、SSL 检查、DNS 解析、TCPing 测速、Whois、DNSSEC、ASN 查询、网站截图等功能。
文档编写中....
ipw.cn 替代品,提供 IP 查询、网站检测、SSL 检查、DNS 解析、TCPing 测速、Whois、DNSSEC、ASN 查询、网站截图等功能。
## 功能
**[预览站点](https://ipw.wsmdn.top/)** · **[文档](https://github.com/nomdn/ipw-cn/tree/main/docs)**
- **IP 地址查询** — 支持 IPv4/IPv6,集成 ip2region、qqwry、MaxMind GeoIP、GeoCN、DbIP、Bilibili 等多数据源
- **ASN 自治系统查询** — 查询 IP 所属自治系统号,支持 MaxMind GeoLite2、DbIP 双数据源,集成 WHOIS 解析
- **网站检测** — HTTP 状态码、响应时间、Host 记录
- **SSL 证书检查** — 证书有效期、颁发机构、剩余天数
- **DNS 解析** — 多节点并发查询,支持 A/AAAA/CNAME/MX/TXT/NS/SRV/PTR/CAA 等记录类型
- **DNSSEC 验证** — 检测域名 DNSSEC 签名状态,验证 DNSKEY、RRSIG、DS 记录链式信任关系
- **Whois 查询** — 域名注册信息、注册商、到期时间等
- **TCPing** — TCP 连接延迟测试,支持 IPv4/IPv6 双栈
- **网站测速** — 下载速度、响应头、Host 记录
- **网站截图** — 在线网站页面截图
- **SSRF 防护** — 自动拦截对内网/私有 IP 的出站请求
- **暗色模式** — 支持明暗主题切换
## 快速开始
```bash
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o install.sh && sudo bash install.sh
```
完整部署(后端节点 / 独立中间件 / 前端)与配置说明见 [文档](https://github.com/nomdn/ipw-cn/tree/main/docs):
- [功能总览](docs/info/features.md)(探测能力、IP 数据源、可靠性设计)
- [快速开始](docs/guide/getting-started.md)
- [后端节点部署](docs/guide/deploy-node.md)
- [中间件部署](docs/guide/deploy-middleware.md)
- [前端部署](docs/guide/deploy-frontend.md)
- [配置文件](docs/guide/config.md)
- [节点 API 与协议](docs/guide/node-api.md)(业务接口、管理接口、WS 协议、上报协议)
- [公开 API(/api/v1)](docs/guide/public-api.md)(收集中心程序化接口:鉴权、端点与响应示例、公开状态接口)
## 项目结构
```
ipw-cn/
├── main.go # Go 后端入口(Gin 框架,自托管)
├── go.mod # Go 模块定义
├── Dockerfile # 后端 Docker 镜像
├── setting.json # 后端运行配置
│
├── ipdb/ # IP 数据库查询模块
│ ├── searchip.go # 多数据源 IP 查询(ip2region/qqwry/MaxMind/GeoCN/DbIP/Bilibili)
│ └── ipdb.go # 数据库下载与更新
│
├── webtest/ # 网络测试工具
│ ├── dns.go # DNS 查询(基于 miekg/dns)
│ ├── dnssec.go # DNSSEC 验证
│ ├── tcping.go # TCP 连接测试
│ └── whois.go # WHOIS 域名查询
│
├── ssrf/ # SSRF 防护模块
│ └── ssrf.go # 私有 IP 拦截、安全跳转校验
│
├── middleware-go/ # 独立转发中间件(Go Fiber v3,可单独部署)
│ ├── main.go # 中间件入口(/v1/* 与 /middleware/* 路由)
│ ├── setting.json # 中间件配置(节点列表、apiKeys、cors 等)
│ ├── setting.json.example # 中间件配置示例
│ ├── Dockerfile # 中间件 Docker 镜像
│ └── middleware-go-linux-arm64 # Linux ARM64 预编译二进制
│
├── frontend-ssr/ # Nuxt 4 SSR 前端(主版本,部署至 Cloudflare Workers)
│ ├── app/ # Nuxt 应用源码
│ │ └── pages/ # 页面组件(location/dns/tcping/ssl/speedtest/whois/dnssec/asn/screenshot)
│ ├── config/ # 前端配置(API 地址、节点列表)
│ ├── nuxt.config.ts # Nuxt 配置
│ ├── package.json
│ ├── pnpm-workspace.yaml
│ └── wrangler.jsonc # Cloudflare Workers 部署配置
│
├── edgeone/ # 腾讯 EdgeOne 边缘函数版本后端
│ ├── cloud-functions/ # Go 边缘函数源码
│ │ ├── index.go # 后端入口(Gin 框架)
│ │ ├── webtest/ # 网络测试工具
│ │ ├── ssrf/ # SSRF 防护
│ │ └── go.mod
│ └── .edgeone/ # EdgeOne 部署配置
│
├── lemon-getip/ # Cloudflare Workers IP 查询服务(Hono + TypeScript)
│ ├── src/index.ts
│ ├── wrangler.jsonc
│ ├── package.json
│ └── test/
│
├── edgeone-getip/ # EdgeOne Pages IP 查询服务(TypeScript 边缘函数)
│ ├── edge-functions/index.ts
│ └── .edgeone/
│
├── .github/workflows/ # CI/CD 流水线
│ ├── frontend-ssr.yml # 部署至 Cloudflare Workers
│ ├── workers.yml # 部署 lemon-getip 至 Cloudflare Workers
│ ├── edgeone-backend.yml # 部署 edgeone 至 EdgeOne Pages
│ └── edgeone-getip.yml # 部署 edgeone-getip 至 EdgeOne Pages
│
├── tmp/ # IP 数据库下载临时目录
├── short.txt # GeoCN 行政区划短表(省市级)
├── full.txt # GeoCN 行政区划全表(区县级)
├── ipw-backend.exe # Windows 预编译后端二进制
├── .node-version # Node.js 版本(v22)
└── LICENSE # GPL-3.0
├── src/ # Go 后端
├── middleware-go/ # 独立组件 转发中间件(Fiber v3,多节点候选)
├── serverless/ # 独立组件 边缘/Serverless(edgeone、edgeone-getip、lemon-getip)
├── frontend-ssr/ # Nuxt 4 SSR 前端
├── docs/ # 文档站
└── install.sh # 一键安装脚本
```
## 友情链接
### 来自社区的IPW版本
[ZAKOFLARE/ipw-cn-php](https://github.com/ZAKOFLARE/ipw-cn-php) 一个基于PHP的IPW节点
[ZAKOFLARE/ipw-cn-rust](https://github.com/ZAKOFLARE/ipw-cn-rust) 一个生锈的IPW节点[开发成本过高,已存档]
[KFCV50TK/ipw-cn](https://github.com/KFCV50TK/ipw-cn) 一个有许多扩展功能的IPW分支
## 技术栈
| 组件 | 技术 |
|------|------|
| 后端(自托管) | Go 1.26 + Gin + Resty |
| 独立中间件 | Go 1.26 + Fiber v3(转发中间件,多节点候选) |
| 边缘后端(EdgeOne) | Go(边缘函数) |
| SSR 前端 | Nuxt 4 + Vue 3 + Element Plus + VueUse |
| Cloudflare Workers | Hono + TypeScript + Wrangler |
| EdgeOne Pages | TypeScript 边缘函数 |
| IP 数据库 | ip2region、qqwry.ipdb、MaxMind GeoLite2、GeoCN、DbIP、Bilibili API |
| DNS 查询 | miekg/dns(Go)/ 原生 DNS-over-HTTPS(EdgeOne) |
| 其他 | SSRF 防护、Zstandard 压缩、WordPress mshots 截图 |
| 部署 | Docker、Cloudflare Workers、EdgeOne Pages |
## 一键部署到 EdgeOne(Nuxt 原生支持)
前端 `frontend-ssr/` 基于 Nuxt,腾讯云 EdgeOne 原生支持 Nuxt 构建,点击下方按钮即可通过 EdgeOne Makers 一键部署前端:
[![使用 EdgeOne Makers 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://console.cloud.tencent.com/edgeone/makers/new?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=frontend-ssr&install-command=pnpm%20install&build-command=pnpm%20run%20build&output-directory=.output)
| 按钮参数 | 值 | 说明 |
|----------|-----|------|
| `repository-url` | `https://github.com/nomdn/ipw-cn` | GitHub 仓库地址 |
| `root-directory` | `frontend-ssr` | 构建根目录(前端源码所在目录) |
| `install-command` | `pnpm install` | 依赖安装命令 |
| `build-command` | `pnpm run build` | 构建命令(Nuxt 默认 SSR 构建) |
| `output-directory` | `.output` | 构建产物输出目录(Nitro SSR 产物) |
> 说明:EdgeOne Makers 原生支持 Nuxt 部署,SSR / SSG / ISR(SWR) / 中间件 / 流式传输均支持(Nuxt 3.16+,推荐 Nuxt 4),按钮默认以 **SSR** 方式构建。如需纯静态部署,可将 `build-command` 改为 `pnpm run generate`、`output-directory` 改为 `.output/public`。
## 快速开始
### 后端(自托管)
```bash
# 安装依赖
go mod download
# 配置 setting.json(可选)
# {
# "port": 8080,
# "gh-proxy": "https://fastgit.cc/",
# "single-stack": "", # "ipv4" 或 "ipv6" 仅启用单栈
# "dns-server": "", # 自定义 DNS 服务器
# "block-private-ips": true,# SSRF 防护开关
# "ipdb": true, # IP 数据库开关
# "cors": "", # 允许的请求来源,详见 https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Guides/CORS
# "access_token": "" # API 访问令牌(可选)
# }
# 运行
go run main.go
```
首次启动时会自动下载 IP 数据库文件(约 200MB),之后每 24 小时自动更新。
### 独立中间件(middleware-go)
转发中间件,供 SSR 前端在多个上游节点之间做候选重试,可独立部署。
```bash
cd middleware-go
# 构建(Linux ARM64 示例)
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o middleware-go-linux-arm64 .
# 运行(需配置文件,可用 SETTING_FILE 指定路径)
./middleware-go-linux-arm64
# 查看版本
./middleware-go-linux-arm64 --version
```
所有配置均可通过环境变量覆盖,无需修改配置文件(详见下方「中间件配置」)。
### SSR 前端(部署至 Cloudflare Workers)
```bash
cd frontend-ssr
# 安装依赖(需要 pnpm)
pnpm install
# 开发
pnpm dev
# 构建并部署
pnpm deploy
```
### SPA 前端(旧版本,部署至 Cloudflare Pages)
> [!WARNING]
> 原生 SPA 已停止支持。要部署 SPA 版本前端请使用 Nuxt SPA 版本。
### Docker 部署
```bash
docker build -t lemon-ipw .
docker run -p 8080:8080 -v $(pwd)/setting.json:/app/setting.json lemon-ipw
```
## API 接口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/v1/location/:ip` | 查询指定 IP 地理位置 |
| GET | `/v1/location` | 查询请求者 IP 地理位置 |
| GET | `/v1/asn/:ip` | 查询 IP 所属自治系统号(ASN) |
| GET | `/v1/detail/*url` | 检测网站可达性(HTTP 状态码、响应时间) |
| GET | `/v1/ssl/*url` | 检查 SSL 证书信息 |
| GET | `/v1/speed/:version/*url` | 网站测速(version: v4/v6/dual) |
| GET | `/v1/tcping/:ip?port=80&count=4` | TCP 连接测试 |
| GET | `/v1/dns/:type/*domain` | DNS 解析(type: A/AAAA/CNAME/MX/TXT/NS/SRV/PTR/CAA) |
| GET | `/v1/dnssec/:domain` | DNSSEC 验证 |
| GET | `/v1/whois/:domain` | Whois 域名查询 |
| GET | `/` | 健康检查 |
## 部署架构
本项目支持多平台部署,前端和后端可独立部署到不同平台:
- **自托管后端**:使用 Docker 或直接运行 Go 二进制,配合 `setting.json` 配置
- **独立转发中间件**:`middleware-go/` 使用 Go Fiber v3,提供 `/v1/*` 与 `/middleware/*` 转发路由,供前端做多节点候选重试
- **Cloudflare Workers 后端**:`lemon-getip/` 使用 Hono 框架部署到 Cloudflare Workers
- **EdgeOne 边缘后端**:`edgeone/` 和 `edgeone-getip/` 部署到腾讯 EdgeOne Pages
- **SSR 前端**:`frontend-ssr/` 使用 Nuxt 4 + Wrangler 部署到 Cloudflare Workers
## 配置
### 后端环境变量
| 配置项 | 环境变量 | 默认值 | 说明 |
|--------|----------|--------|------|
| `port` | `PORT` | `8080` | 监听端口 |
| `gh-proxy` | `GH_PROXY` | `""` | GitHub 文件代理(用于下载数据库) |
| `single-stack` | `SINGLE_STACK` | `""` | 单栈模式:`ipv4` 或 `ipv6` |
| `dns-server` | `DNS_SERVER` | `119.28.28.28:53` | DNS 服务器地址 |
| `block-private-ips` | `BLOCK_PRIVATE_IPS` | `true` | SSRF 防护开关 |
| `ipdb` | `IPDB` | `true` | IP 数据库开关 |
| `access_token` | `ACCESS_TOKEN` | `""` | API 访问令牌 |
| `cors` | `CORS` | `""` | 允许的请求来源 |
| 远端配置 | `REMOTE_CONFIG_URL` | `""` | 远端配置文件 URL(遵守 `setting.json` 格式),优先级最高 |
> 配置优先级:**远端配置(`REMOTE_CONFIG_URL`)> 环境变量 > setting.json**。远端配置 URL 可通过环境变量 `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` 提供(环境变量优先);远端配置本身需遵守 `setting.json` 的 JSON 格式(`port` / `gh-proxy` / `single-stack` / `dns-server` / `block-private-ips` / `ipdb` / `cors` 等),拉取或解析失败时自动回退到本地配置并打印警告。`access_token` 例外:**不随远端配置覆盖**,保持原有优先级(环境变量 > setting.json)。EdgeOne 版本(`edgeone/`)配置来源为 环境变量 + 远端配置。
### 前端配置
前端 API 地址和多节点配置位于各前端项目的 `config/` 目录中。
### 中间件配置(middleware-go)
配置来源优先级:**环境变量 > `setting.json` > 默认值**。`setting.json` 采用平铺结构(键名与前端 `config/index.ts` 一致),也可用环境变量覆盖任意配置项;数组/对象类配置项从环境变量读取 **JSON 字符串**解析。
```json
{
"port": "8091",
"httpTimeoutSeconds": 30,
"cors": "https://ipw.wsmdn.top",
"apiBaseUrls": [{ "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" }],
"IPLocationAPIs": [],
"TCPing": { "DualStack": [], "IPv4": [], "IPv6": [] },
"SpeedTest": { "DualStack": [], "IPv4": [], "IPv6": [] },
"NSLookup": [],
"apiKeys": { "cn-jiangsu": "token" }
}
```
| 配置项 | 环境变量 | 类型 | 说明 |
|--------|----------|------|------|
| `port` | `PORT` | 数字/字符串 | 监听端口 |
| `httpTimeoutSeconds` | `HTTP_TIMEOUT` | 数字 | 上游请求超时(秒) |
| `cors` | `CORS` | 字符串 | 逗号分隔的允许域名;为空则允许所有(`*`) |
| `apiBaseUrls` | `API_BASE_URLS` | JSON 数组 | whois/ssl/detail 上游节点 |
| `IPLocationAPIs` | `IP_LOCATION_APIS` | JSON 数组 | location/asn 上游节点 |
| `TCPing` | `TCPING` | JSON 对象 | tcping 节点(DualStack/IPv4/IPv6) |
| `SpeedTest` | `SPEED_TEST` | JSON 对象 | speed 节点(DualStack/IPv4/IPv6) |
| `NSLookup` | `NS_LOOKUP` | JSON 数组 | dns/dnssec 上游节点 |
| `apiKeys` | `APIKEYS` | JSON 对象 | `{"backendID": "token"}`,转发时加 `Authorization: Bearer <token>` |
| 配置文件路径 | `SETTING_FILE` | 字符串 | 指定 setting.json 路径(默认 `./setting.json` 或 `../setting.json`) |
**路由格式**:`/{prefix}/{backendID}/{apiType}/{raw...}`,其中 `prefix` 为 `/v1` 或 `/middleware`,`apiType` 支持 `whois/dns/location/ssl/asn/dnssec/detail/tcping/speed`。响应透传上游状态码与 body;上游网络不可达返回 502。
**版本信息**:通过 ldflags 注入 `main.VERSION / main.COMMIT / main.BUILD_TIME`,运行 `--version` 查看。
| 后端(自托管) | Go + Gin + Resty |
| 独立中间件 | Go + Fiber v3(WS 通道:coder/websocket) |
| 边缘后端(EdgeOne) | Go 边缘函数 |
| SSR 前端 | Nuxt 4 + Vue 3 + Element Plus |
| DNS 查询 | miekg/dns(DoH / UDP 双通道 + 主从 failover) |
| 部署 | Docker、Cloudflare Workers、EdgeOne Pages、systemd |
## 许可证
+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: [
+130 -52
View File
@@ -13,19 +13,51 @@
"block-private-ips": true,
"ipdb": true,
"cors": "",
"access_token": ""
"access-token": ""
}
```
- `port`:监听端口
- `dns-server`:DNS 解析服务器,支持**主从 failover**——逗号分隔多地址(如 `"119.28.28.28:53,223.5.5.5:53"`),第一个为主,主查询失败(超时/网络错误)自动切换下一个;每项支持 `ip:port`(UDP)或 `https://` URL(DoH)
- `dnssec-server`:**DNSSEC 专用** DNS 服务器(同样支持主从逗号分隔);留空则沿用 `dns-server`
- `block-private-ips`:SSRF 防护开关
- `ipdb`:IP 数据库开关(首次启动自动下载约 200MB,之后每 24h 更新)
- `dns-server`:DNS 解析服务器,支持**主从 failover**——逗号分隔多地址(如 `"119.28.28.28:53,223.5.5.5:53"`),第一个为主,主查询失败(超时/网络错误)自动切换下一个;每项支持 `ip:port`(UDP)或 `https://` URL(DoH);**留空则启动时自动探测系统 DNS**(`IP:Port` 逗号分隔串)兜底
- `dnssec-server`:**DNSSEC 专用** DNS 服务器(同样支持主从逗号分隔);留空则沿用 `dns-server`;`dns-server` 与 `dnssec-server` **都留空时自动探测系统 DNS** 兜底
- `block-private-ips`:SSRF 防护开关(拒绝出站连接内网/私有地址;探测目标命中私有网段时直接返回伪造结果,禁止跨跳重定向)
- `single-stack`:单栈模式,`ipv4` / `ipv6` / 留空(双栈)。声明单栈的节点跳过另一栈的探测(直接返回 `Skipped due to SINGLE_STACK=...`),EdgeOne / Vercel 等 IPv4-only 环境建议设为 `ipv4`
- `ipdb`:IP 数据库开关。**不配置时默认启用**(首次启动自动下载约 450MB,之后每 24h 更新);仓库的 `setting.json.example` 里显式写为 `"false"`,即**示例配置是关闭状态**——关闭后不加载任何数据库,`/v1/location`、`/v1/asn` 路由不注册,适合只做网络拨测的轻量节点
- `cors`:允许的请求来源(逗号分隔)
- `access_token`:API 访问令牌,留空则不启用鉴权
- `trusted-proxies`:可信代理 IP/CIDR(逗号分隔,如 `"10.0.0.0/8,173.245.48.0/20"`)。配置后 `ClientIP` 与 `/v1/location` 归属地接口只信任这些代理转发的 `X-Forwarded-For`,直连公网的节点可防止访客伪造 XFF;**留空保持默认行为(信任所有代理,XFF 可被伪造)**
- `access-token`:API 访问令牌,留空则不启用鉴权。**它同时是 HTTP 管理面的开关**:留空时 `/v1/config`、`/v1/ota` 整组返回 `403`,只能走 WS 通道管理
- `gh-proxy`:GitHub 下载代理前缀(如 `"https://ghproxy.com/"`),用于 IP 数据库拉取与 OTA 下载加速;留空直连
- `node-ota`:OTA 开关,缺省 `true`(允许收集中心下发)。设为 `false` 时节点拒绝一切 OTA 指令并回传原因,适合只读文件系统 / 编排托管(升级走各自部署渠道)的部署。**支持运行时热改**(`PATCH /v1/config` 或 WS `config` 下发即时生效,改后按新值判定后续 OTA 指令)
- `ws-url`:WS 通道地址——接入独立中间件的 WebSocket 地址(如 `"wss://middleware-1.api-ipw.wsmdn.top/ws"`,须含 `wss://` 前缀与 `/ws` 路径);支持**逗号分隔多个中间件,同时连接全部(多活)**,任一断开只重连自己,不影响其他连接;留空 = 不启用 WS 客户端,走原 HTTP 接口
- `node-id`:WS 节点 id(与中间件 `api-keys`/`ws-keys` 键、前端配置的节点 `id` 一致;建议用 UUID 唯一标识)
- `node-key`:WS 注册 key(与中间件 `ws-keys[节点id]` 一致;**必填**——节点未配置该 key 时中间件拒绝注册并返回 401)
- `report-url`:**数据上报收集中心** HTTP 基址(如 `"https://collector.example.com"`,即 ipw-boce 中间件)。节点把自身观测的统计/拨测按 `report-interval-seconds` 周期上报;`ws-url` 已启用时优先走 WS `report` 消息(所有已连接中间件广播),WS 未启用或全部掉线时 HTTP POST 到 `<report-url>/report` 兜底;远端配置可覆盖(在 `remote-ignore-config` 里加上 `report-url` 可禁止覆盖)
- `report-token`:收集中心 `/report` 鉴权 token(与中间件 `report-token` 配置一致);属凭据类键,GET 快照里遮蔽为 `***`;**不随远端配置覆盖**(硬编码保护,清单见本页末的「远端配置」一节)
- `report-interval-seconds`:上报周期秒,缺省 15
以上字段均可用环境变量覆盖(`PORTS` / `DNS_SERVER` / `DNSSEC_DNS_SERVER` / `BLOCK_PRIVATE_IPS` / `IPDB` / `CORS` / `ACCESS_TOKEN`)。需要从远端拉取配置时,设置 `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) 的「管理接口」一节。
---
@@ -44,11 +76,8 @@
| `ICP` / `GongAn` | string | 网站备案号(页脚展示) |
| `noindex` | boolean | 全站是否禁止搜索引擎索引 |
| `v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` | string | IPv4 / IPv6 / 双栈 IP 查询接口 |
| `apiBaseUrls` | 节点数组 | 基础 API 节点列表(detail / ssl / whois 等) |
| `IPLocationAPIs` | 节点数组 | IP 归属地 / ASN 查询节点列表 |
| `TCPing` | 节点分组 | TCPing 节点,按 `DualStack` / `IPv4` / `IPv6` 分组 |
| `SpeedTest` | 节点分组 | 测速节点,按 `DualStack` / `IPv4` / `IPv6` 分组 |
| `NSLookup` | 节点数组 | DNS 解析节点列表 |
| `APIBaseURL` | 节点池 | 拨测上游节点池(whois / ssl / detail / dns / dnssec / tcping / speed),含 `DualStack` / `IPv4` / `IPv6` 三栈 |
| `IPLocationAPI` | 节点数组 | IP 归属地 / ASN 查询节点池(纯数组,无栈区分) |
完整模板(复制到 `frontend-ssr/config/index.ts`,替换占位值即可):
@@ -75,49 +104,38 @@ const config = {
v4OnlyAPI: "https://4.wsmdn.top",
v6OnlyAPI: "https://6.wsmdn.top",
DualStackAPI: "https://test.wsmdn.top",
// 基础 API 节点(detail / ssl / whois 等)
apiBaseUrls: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
],
// IP 归属地 / ASN 查询节点
IPLocationAPIs: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
],
// TCPing 节点:IPv4-only 节点只能放 IPv4 分组,不能放 DualStack / IPv6
TCPing: {
// 拨测节点池(whois / ssl / detail / dns / dnssec / tcping / speed)
// IPv4-only 节点只能放 IPv4 栈,IPv6-only 放 IPv6 栈,双栈放 DualStack
APIBaseURL: {
DualStack: [],
IPv4: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
],
IPv6: []
},
// 测速节点:同上,IPv4-only 节点只能放 IPv4 分组
SpeedTest: {
DualStack: [],
IPv4: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
],
IPv6: []
},
// DNS 解析节点
NSLookup: [
// IP 归属地 / ASN 查询节点池(纯数组,无栈区分)
IPLocationAPI: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
]
}
export { config }
```
> 节点 `id` 需与后端节点 / 中间件 `apiKeys` 的键保持一致,中间件按 `id` 注入 `Authorization: Bearer <key>`。
> 节点 `id` 需与后端节点 / 中间件 `api-keys` 的键保持一致,中间件按 `id` 注入 `Authorization: Bearer <key>`;WS 通道的节点注册校验另见中间件 `ws-keys`(与后端 `node-key` 对应)。
节点数组项均为 `{ label, id, url }` 结构,例如:
```ts
apiBaseUrls: [
{ label: "中国 江苏 移动", id: "cn-jiangsu", url: "https://cn-jiangsu.api-ipw.wsmdn.top/" }
]
APIBaseURL: {
DualStack: [
{ label: "中国 江苏 移动", id: "cn-jiangsu", url: "https://cn-jiangsu.api-ipw.wsmdn.top/" }
],
IPv4: [],
IPv6: []
}
```
`label` 为节点展示名,`id` 为节点唯一标识(中间件 `apiKeys` 也按此 id 注入 key),`url` 为节点 base URL。`TCPing` / `SpeedTest` 节点再多一层 `DualStack` / `IPv4` / `IPv6` 分组,分别对应双栈 / 单栈测试时使用的节点列表。
`label` 为节点展示名,`id` 为节点唯一标识(中间件 `api-keys` 按此 id 注入 HTTP 转发 key,WS 注册校验另见 `ws-keys`),`url` 为节点 base URL。节点池结构如下:`APIBaseURL` 为拨测节点池,含 `DualStack` / `IPv4` / `IPv6` 三栈(分别对应双栈 / 单栈测试时使用的节点列表,IPv4-only 节点只能放 `IPv4` 栈,IPv6-only 只能放 `IPv6` 栈,不能放 `DualStack`);`IPLocationAPI` 为 IP 归属地 / ASN 查询节点池,为纯数组(无栈区分)。
### 出站 IP 检测接口(v4OnlyAPI / v6OnlyAPI / DualStackAPI)
@@ -150,6 +168,22 @@ apiBaseUrls: [
也可以在 `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)
@@ -161,21 +195,56 @@ apiBaseUrls: [
"port": "8091",
"http-timeout-seconds": 30,
"cors": "",
"trusted-proxies": "",
"gh-proxy": "",
"ota": "false",
"rate-limit": 120,
"ws-port": 8092,
"remote-config-url": "",
"remote-ingore-config": [],
"apiBaseUrls": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"apiKeys": { "cn-jiangsu": "" }
"remote-ignore-config": [],
"api-base-url": {
"DualStack": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"IPv4": [],
"IPv6": []
},
"ip-location-api": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"api-keys": { "cn-jiangsu": "" },
"ws-keys": { "cn-jiangsu": "" }
}
```
- `rate-limit`:单 IP 每分钟限流次数,默认 `120`,`0` 表示不限流(也可用环境变量 `RATE_LIMIT` 覆盖)
- `trusted-proxies`:可信代理 IP/CIDR(逗号分隔)。配置后 `c.IP()`(限流按它分桶)只信任这些代理转发的 `X-Forwarded-For`,反代/CDN 后限流才按真实客户端 IP 计数;留空保持默认(不信任 XFF 头,按直连地址限流)。也可用环境变量 `TRUSTED_PROXIES` 覆盖
- `gh-proxy`:GitHub 代理前缀(OTA 下载加速,如 `"https://ghproxy.com/"`),可空;也可用环境变量 `GH_PROXY` 覆盖
- `ota`:**OTA 自更新开关**(`"true"` 启用)。启用后中间件定期检查本仓库 GitHub Release,发现新版本时下载匹配的 `middleware-go-*` 资产、预检、**替换自身并重启**(Windows 下为优雅交接,失败自动回滚旧版;major 版本升级需人工);默认关闭。也可用环境变量 `OTA` 覆盖
- `ws-port`:WS 服务端口,默认 `8092`,`0` 表示关闭 WS 通道(也可用环境变量 `WS_PORT` 覆盖;优先级:远端配置 > 环境变量 > setting.json)
- `remote-config-url`:远端配置地址,也可用环境变量 `REMOTE_CONFIG_URL`(环境变量优先)
- `remote-ingore-config`:**不被远端配置覆盖的配置项列表**(数组),如 `["port", "rate-limit"]`;也可用环境变量 `REMOTE_INGORE_CONFIG`(JSON 数组字符串,优先于 setting.json)
- `apiKeys`:用于向后端注入 `Authorization: Bearer <key>`,优先级:setting.json `apiKeys` > 环境变量 `APIKEYS`(JSON 字符串)。**敏感凭据,强制忽略,不随远端配置覆盖**
- 节点数组项(`apiBaseUrls` / `IPLocationAPIs` / `TCPing` / `SpeedTest` / `NSLookup`)与前端 `config/index.ts` 结构一致;节点加 `"ws": true` 可让该节点走 WS 通道通信,详见 [中间件部署 - WS 通道](/guide/deploy-middleware#ws-通道拨测数据经-websocket-传输)
- `remote-ignore-config`:**不被远端配置覆盖的配置项列表**(数组),如 `["port", "rate-limit"]`;也可用环境变量 `REMOTE_IGNORE_CONFIG`(JSON 数组字符串,优先于 setting.json)
- `api-keys`:用于向后端注入 `Authorization: Bearer <key>`,优先级:setting.json `api-keys` > 环境变量 `API_KEYS`(JSON 字符串)。**敏感凭据,强制忽略,不随远端配置覆盖**
- `ws-keys`:WS 注册校验表(`节点id → key`):节点经 WS 注册时,`register` 消息的 `key` 必须与 `ws-keys[节点id]` 一致,否则返回 401 并断开;优先级:setting.json `ws-keys` > 环境变量 `WS_KEYS`(JSON 字符串)。**敏感凭据,强制忽略,不随远端配置覆盖**。与 `api-keys` **相互独立**——`api-keys` 管 HTTP 转发鉴权,`ws-keys` 管 WS 注册校验,可分别配置不同 key(例如 WS 节点单独换 key 不影响 HTTP 转发)
- 节点池与前端 `config/index.ts` 结构一致:`APIBaseURL` 为拨测节点池(含 `DualStack` / `IPv4` / `IPv6` 三栈),`IPLocationAPI` 为 IP 归属地 / ASN 节点池(纯数组,无栈);节点加 `"ws": true` 可让该节点走 WS 通道通信,详见 [中间件部署](/guide/deploy-middleware) 的「WS 通道」一节
### WS 通道配置一览
WS 通道涉及**中间件(服务端)**与**后端节点(客户端)**两侧配置:
| 角色 | 配置项 | 环境变量 | 说明 |
|------|--------|----------|------|
| 中间件(服务端) | `ws-port` | `WS_PORT` | WS 监听端口,默认 `8092`,`0`=关闭;节点连 `ws(s)://<中间件>:<ws-port>/ws` |
| 中间件(服务端) | `ws-keys` | `WS_KEYS` | WS 注册校验表:`{"<node-id>": "<key>"}`,节点 `register` 的 key 必须匹配 |
| 节点(客户端) | `ws-url` | `WS_URL` | 中间件 WS 完整地址(含 `/ws` 路径),逗号分隔多个则**同时连接全部(多活)** |
| 节点(客户端) | `node-id` | `NODE_ID` | 节点 id,与中间件 `api-keys`/`ws-keys` 键一致(建议 UUID) |
| 节点(客户端) | `node-key` | `NODE_KEY` | 注册 key,与中间件 `ws-keys[节点id]` 一致,**必填** |
**数据上报通道**(节点把自身观测的统计/拨测上报到收集中心,归属规则防双算——中间件转发的请求由中间件上报,节点只报直连/前端/边缘流量):
| 角色 | 配置项 | 环境变量 | 说明 |
|------|--------|----------|------|
| 节点(客户端) | `report-url` | `REPORT_URL` | 收集中心(ipw-boce 中间件)HTTP 基址;WS 在线时优先走 WS 广播,掉线时 POST `<report-url>/report` 兜底 |
| 节点(客户端) | `report-token` | `REPORT_TOKEN` | `/report` 鉴权 token,与收集中心 `report-token` 一致;属凭据类键,快照遮蔽为 `***`,**不随远端配置覆盖** |
| 节点(客户端) | `report-interval-seconds` | `REPORT_INTERVAL_SECONDS` | 上报周期秒,缺省 15 |
**链路**:中间件收到 `ws:true` 节点的拨测请求 → 经 WS 发 `probe` → 节点执行探针(直调 webtest 函数 + 缓存)→ `probe_result` 回传 → 中间件返回 HTTP 响应(`Content-Type: application/json`)。节点侧接入步骤见 [后端节点部署](/guide/deploy-node) 的「WS 通道接入」一节。
---
@@ -187,8 +256,8 @@ apiBaseUrls: [
| 组件 | 触发方式 | 远端配置格式 |
|------|----------|--------------|
| 后端(主线 `main.go`) | `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` | 后端 `setting.json` 格式 |
| 后端(`edgeone/`) | `REMOTE_CONFIG_URL` | 后端 `setting.json` 格式 |
| 后端(主线 `src/main.go`) | `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` | 后端 `setting.json` 格式 |
| 后端(`serverless/edgeone/`) | `REMOTE_CONFIG_URL` | 后端 `setting.json` 格式 |
| 独立中间件(middleware-go) | `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` | 中间件 `setting.json` 格式 |
**合并规则**(重要,逐字段生效):
@@ -197,9 +266,12 @@ apiBaseUrls: [
- 远端 JSON 中**非空的字段才覆盖**本地值:字符串非空、数组非空、数字非 0(`rate-limit` 为指针语义,缺省=不覆盖,显式 0=不限流)
- 部分字段缺省不会冲掉本地配置——远端只需包含要覆盖的字段
- **敏感凭据强制忽略,永远不被远端覆盖**(代码写死,无需配置):
- 后端 `access_token`:不随远端覆盖(保持 环境变量 > setting.json),远端里的 `access_token` 会被忽略
- 中间件 `apiKeys`:不随远端覆盖,远端里的 `apiKeys` 会被忽略
- `remote-ingore-config`(后端键名 `remote-ingore-config` / env `REMOTE_INGORE_CONFIG`):额外指定**不被远端覆盖的配置项列表**,数组中的键即使远端下发也不生效。适用于 access_token / apiKeys 之外的敏感项(如 `ipw-node-key`、`dns-server` 等),也可覆盖"非空才覆盖"规则
- 后端 `access-token`:不随远端覆盖(保持 环境变量 > setting.json),远端里的 `access-token` 会被忽略
- 后端 `report-token`:同上。收集中心的托管配置里写了也不生效,节点会在日志与控制台应答的 `protectedIgnored` 里列出被跳过的键
- 中间件 `api-keys` / `ws-keys`:不随远端覆盖,远端里的 `api-keys`/`ws-keys` 会被忽略
为什么把这两项写死:它们被远端改错时的故障形态都是「远端一错、本地失联、且无法再远程修回」——`report-token` 被改 → 上报被收集中心 `/report` 拒收,控制台上节点仍显示在线、数据却静默断了;`access-token` 被改 → 控制台再也连不上该节点的管理面,连改回去的通道都没了。所以这层解耦必须由节点自己兜底,不能依赖「配置源别写错」。`node-key` 有意**不在**保护名单里(托管部署下常由远端统一下发节点身份),需要一并锁死时把它写进 `remote-ignore-config`。保护名单只增不减,且**只约束远端下发**,本地 `PATCH` 不受影响。
- `remote-ignore-config`(后端键名 `remote-ignore-config` / env `REMOTE_IGNORE_CONFIG`):额外指定**不被远端覆盖的配置项列表**,数组中的键即使远端下发也不生效。适用于 Node 身份类与其它不想被远端左右的项(如 `node-key`、`dns-server` 等),也可覆盖"非空才覆盖"规则
**拉取行为**:
@@ -207,7 +279,7 @@ apiBaseUrls: [
- 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`):
@@ -233,12 +305,18 @@ REMOTE_CONFIG_URL=https://example.com/ipw-config.json ./lemonipw
{
"port": "8091",
"cors": "",
"trusted-proxies": "",
"rate-limit": 120,
"apiBaseUrls": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ]
"api-base-url": {
"DualStack": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"IPv4": [],
"IPv6": []
}
}
```
> **⚠️ 敏感凭据不得上传远端**:`apiKeys`(及后端 `access_token`、节点 key 等密钥)**禁止**写入远端配置——即使写了也会被强制忽略。凭据只放本地 setting.json 或环境变量。
> [!WARNING]
> 敏感凭据不得上传远端:`api-keys`/`ws-keys`(及后端 `access-token`、节点 key 等密钥)**禁止**写入远端配置——即使写了也会被强制忽略。凭据只放本地 setting.json 或环境变量。
部署时:
@@ -256,7 +334,7 @@ systemd 中同样用 `Environment="REMOTE_CONFIG_URL=..."` 配置。
**安全与排查**:
- **⚠️ 敏感凭据不得上传远端**:远端配置(Gist / 对象存储 / 静态托管等)**禁止包含任何密钥**——`apiKeys`、`access_token`、节点 key(`ipw-node-key`)等。这些值会被强制忽略,但远端文件本身一旦泄露等于直接暴露凭据;凭据一律放本地 `setting.json` 或环境变量注入
- **敏感凭据不得上传远端**:远端配置(Gist / 对象存储 / 静态托管等)**禁止包含任何密钥**——`api-keys`/`ws-keys`、`access-token`、节点 key(`node-key`)等。这些值会被强制忽略,但远端文件本身一旦泄露等于直接暴露凭据;凭据一律放本地 `setting.json` 或环境变量注入
- 远端 URL 必须能被节点访问;优先使用 **HTTPS** 地址
- 远端配置文件如果放在公开可访问的位置(公开 Gist、无鉴权对象存储),即便不含密钥,也等于把节点拓扑、节点 id 等敏感信息公之于众,建议放在私有仓库 / 带鉴权的对象存储 / 内部服务上
- 常见失败排查:
+4 -5
View File
@@ -26,15 +26,14 @@ pnpm dev
## 配置说明
前端所有配置集中在 `frontend-ssr/config/index.ts`,完整配置项见 [配置文件 - 前端](/guide/config#前端-configindexts):
前端所有配置集中在 `frontend-ssr/config/index.ts`,完整配置项见 [配置文件](/guide/config) 的「前端」一节:
| 配置项 | 说明 |
|--------|------|
| `siteUrl` | 站点对外地址 |
| `siteName` | 站点名称(页面标题 / 描述 / 页脚品牌) |
| `apiBaseUrls` | 后端上游节点列表(whois / ssl / detail) |
| `IPLocationAPIs` | IP 归属地 / ASN 上游节点列表 |
| `TCPing` / `SpeedTest` / `NSLookup` | 对应功能的多节点候选(DualStack / IPv4 / IPv6) |
| `APIBaseURL` | 拨测上游节点池(whois / ssl / detail / dns / dnssec / tcping / speed),含 `DualStack` / `IPv4` / `IPv6` 三栈 |
| `IPLocationAPI` | IP 归属地 / ASN 上游节点池(纯数组,无栈区分) |
| `Middleware` | 外部独立中间件 base URL 列表,`/middleware/*` 请求依次尝试、失败重试下一个 |
| `EnableInternalMiddleware` | 是否启用前端内置中间件(本地转发,作为候选列表最后一位兜底),默认 `true` |
| `rateLimitPerMinute` | 内置中间件单 IP 限流次数(次/分钟),默认 `120`,`0` 表示不限流 |
@@ -107,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 检测接口」一节。
+36 -20
View File
@@ -13,21 +13,32 @@
> cp middleware.setting.json.example setting.json
> ```
>
> 复制后按需修改 `apiBaseUrls` / `apiKeys` / `cors` 等配置项。
> 复制后按需修改 `api-base-url` / `ip-location-api` / `api-keys` / `ws-keys` / `cors` 等配置项。
## 方案一:Docker
`middleware-go/` 目录提供 `Dockerfile`:
`middleware-go/` 目录提供 `Dockerfile`(多阶段构建,产物基于 Alpine,支持多架构):
```bash
cd middleware-go
docker build -t middleware-go .
docker run -d -p 8091:8091 \
-v $(pwd)/setting.json:/app/setting.json \
-v $(pwd)/setting.json:/home/appuser/setting.json \
middleware-go
```
配置通过挂载 `setting.json` 提供(`apiBaseUrls` / `apiKeys` / `cors` / `rate-limit` / `remote-config-url` 等;`rate-limit` 为单 IP 每分钟限流次数,默认 120,0 表示不限流,可用环境变量 `RATE_LIMIT` 覆盖;`remote-config-url` 为远端配置地址,可用环境变量 `REMOTE_CONFIG_URL` 覆盖;**`apiKeys` 为敏感凭据,不随远端配置覆盖**)。
> 配置挂载注意:容器工作目录是 `/home/appuser`(非 root 用户运行),`setting.json` 要挂到这个路径下。
**多架构**:Dockerfile 已适配 buildx 交叉编译,本机构建其他架构:
```bash
cd middleware-go
docker buildx build --platform linux/arm64 -t middleware-go:arm64 --load .
```
打 `v*` 版本标签发版时,CI 会自动构建 `linux/amd64` / `linux/arm64` / `linux/arm/v7` 三架构镜像(amd64 原生构建,arm64/armv7 在 GitHub 原生 ARM 实例上交叉构建,无 QEMU),推送 GHCR(`ghcr.io/<owner>/<repo>/lemon-ipw-middleware`;Docker Hub 推送在 workflow 中暂未启用,预留了注释可随时开启),Docker 按宿主机架构自动拉取对应变体。
配置通过挂载 `setting.json` 提供(`api-base-url` / `ip-location-api` / `api-keys` / `ws-keys` / `cors` / `trusted-proxies` / `rate-limit` / `remote-config-url` 等;`rate-limit` 为单 IP 每分钟限流次数,默认 120,0 表示不限流,可用环境变量 `RATE_LIMIT` 覆盖;`remote-config-url` 为远端配置地址,可用环境变量 `REMOTE_CONFIG_URL` 覆盖;**`api-keys` / `ws-keys` 为敏感凭据,不随远端配置覆盖**——`api-keys` 管 HTTP 转发鉴权,`ws-keys` 管 WS 注册校验,两者相互独立)。
## 方案二:二进制
@@ -37,9 +48,11 @@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o middleware-go-linux-amd64 .
./middleware-go-linux-amd64 # 运行(与 setting.json 同目录)
```
所有配置可用环境变量覆盖(`API_BASE_URLS` / `IP_LOCATION_APIS` / `CORS` / `APIKEYS` / `RATE_LIMIT` 等,数组/对象用 JSON 字符串),优先级:**环境变量 > setting.json > 默认值**(远端配置 `REMOTE_CONFIG_URL` 高于两者,详见 [配置文件 - 远端配置](/guide/config#远端配置remoteconfigurl))。
**OTA 自更新**(可选,默认关闭):配置 `"ota": "true"`(或环境变量 `OTA=true`)后,中间件定期检查本仓库 Release,下载与当前平台匹配的 `middleware-go-*` 资产并替换自身重启。下载加速可配 `"gh-proxy": ""`(或 `GH_PROXY` 环境变量)。交接流程与后端节点一致:预检 → 原子替换 → 优雅停机 → 拉起新进程 → 健康检查确认;失败自动回滚 `.old` 备份。
需要守护运行时,参考 [快速入门](/guide/getting-started) 中的 systemd 配置(`WorkingDirectory` 指向 `middleware-go/setting.json` 所在目录)。
所有配置可用环境变量覆盖(`API_BASE_URLS` / `IP_LOCATION_APIS` / `CORS` / `TRUSTED_PROXIES` / `API_KEYS` / `WS_KEYS` / `RATE_LIMIT` 等,数组/对象用 JSON 字符串),优先级:**环境变量 > setting.json > 默认值**(远端配置 `REMOTE_CONFIG_URL` 高于两者,详见 [配置文件](/guide/config) 的「远端配置」一节)。
需要守护运行时,参考 [后端节点部署](/guide/deploy-node) 的「方案五:一键安装」一节的 systemd 管理方式(`ExecStart` 指向中间件二进制,`WorkingDirectory` 指向 `middleware-go/setting.json` 所在目录)。
## WS 通道(拨测数据经 WebSocket 传输)
@@ -61,10 +74,11 @@ middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入
```json
{
"port": "8091",
"apiBaseUrls": [
{ "label": "上海 腾讯云 BGP", "id": "tencent-sh", "url": "", "ws": true }
],
"TCPing": { "DualStack": [], "IPv4": [], "IPv6": [ { "label": "河北 秦皇岛 联通", "id": "cn-hebei-qinhuangdao", "url": "https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/", "ws": true } ] }
"APIBaseURL": {
"DualStack": [ { "label": "上海 腾讯云 BGP", "id": "tencent-sh", "url": "", "ws": true } ],
"IPv4": [],
"IPv6": [ { "label": "河北 秦皇岛 联通", "id": "cn-hebei-qinhuangdao", "url": "https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/", "ws": true } ]
}
}
```
@@ -76,33 +90,35 @@ middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入
| 方向 | type | data 说明 |
|------|------|-----------|
| 节点 → middleware | `register` | `{ "nodeId": "...", "key": "..." }`,连接后首条消息,同 id 新连接顶掉旧连接;`key` 为注册凭证,与 setting.json `apiKeys` 配置比对 |
| 节点 → 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`
2. 首条消息发 `register { "nodeId": "<与中间件配置一致的节点 id>", "key": "<注册凭证>" }`(节点在 setting.json `apiKeys` 里配了 key 就必须传对;未配置 key 的开放节点可不传)
1. 连接 `ws://<中间件IP>:8092/ws`(本仓库后端节点接入方式见 [后端节点部署](/guide/deploy-node) 的「WS 通道接入」一节,配置 `WS_URL` / `NODE_ID` / `NODE_KEY` 即可自动连接注册;`WS_URL` 支持逗号分隔多个中间件地址,节点会**同时连接全部(多活)**,任一断开只重连自己)
2. 首条消息发 `register { "nodeId": "<与中间件配置一致的节点 id>", "key": "<注册凭证>" }`(节点在 setting.json `ws-keys` 里配了 key 就必须传对;未配置 key 的开放节点可不传)
3. 收到 `probe` → 执行拨测 → 回 `probe_result`(必须携带原 `requestId`,支持乱序返回,同一连接可并发多个拨测)
4. 收到 `ping` → 回 `pong`;`status` 可忽略或记录
5. 断线后重连并重新 `register` 即可
### 注册 key 校验
节点注册时中间件按 `apiKeys` 配置校验 key:
节点注册时中间件按 `ws-keys` 配置校验 key(与 `api-keys` HTTP 转发鉴权相互独立):
- 节点在 `apiKeys` 配置了 key(非空)→ `register` 必须携带正确 key,不传或传错返回 `register_error { "code": 401, "command": "invalid key" }` 并**断开连接**,不进入数据阶段
- 节点未配置 key(`apiKeys` 无该项或为空)→ 开放注册,不传 key 即可
- 节点在 `ws-keys` 配置了 key(非空)→ `register` 必须携带正确 key,不传或传错返回 `register_error { "code": 401, "command": "invalid key" }` 并**断开连接**,不进入数据阶段
- 节点未配置 key(`ws-keys` 无该项或为空)→ 开放注册,不传 key 即可
与 HTTP 通道的鉴权语义一致(配置了 key 就必须带凭据,留空 = 开放)。
### 说明
- 注册阶段按节点 `apiKeys` 配置校验 key(配了 key 必须传对,否则 401 断开;未配置则开放,详见上文「注册 key 校验」);拨测请求经 WS 转发时不携带 `Authorization` 头(后续由节点侧自行决定是否需要额外鉴权)
- 注册阶段按节点 `ws-keys` 配置校验 key(配了 key 必须传对,否则 401 断开;未配置则开放,详见上文「注册 key 校验」);拨测请求经 WS 转发时不携带 `Authorization` 头(后续由节点侧自行决定是否需要额外鉴权)
- `ws:true` 且 WS 未启用(`WS_PORT=0`)时,自动回退原 HTTP 转发
+185 -14
View File
@@ -4,57 +4,228 @@
## 方案一:EdgeOne
腾讯云 EdgeOne 支持 Go 边缘函数,点击下方按钮通过 **EdgeOne Makers** 一键部署 `edgeone/` 版本(无需本地构建):
腾讯云 EdgeOne 支持 Go 边缘函数,点击下方按钮通过 **EdgeOne Makers** 一键部署 `serverless/edgeone/` 版本(无需本地构建):
[![使用 EdgeOne Makers 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://console.cloud.tencent.com/edgeone/makers/new?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=edgeone)
[![使用 EdgeOne Makers 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://console.cloud.tencent.com/edgeone/makers/new?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=serverless%2Fedgeone)
也可以使用 EdgeOne CLI 手动部署:
```bash
cd edgeone
cd serverless/edgeone
npx edgeone pages deploy -n ipw-cn -t $EDGEONE_API_TOKEN
```
`edgeone/` 版本的配置来源为 环境变量 + 远端配置(`REMOTE_CONFIG_URL`)。
`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
点击下方按钮一键导入仓库(部署目标为 `edgeone/cloud-functions` 目录,即 Go 边缘函数版本):
点击下方按钮一键导入仓库(部署目标为 `serverless/edgeone/cloud-functions` 目录,即 Go 边缘函数版本):
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=edgeone%2Fcloud-functions)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=serverless%2Fedgeone%2Fcloud-functions)
> [!IMPORTANT]
> **Serverless 节点均为 IPv4-only**(EdgeOne、Vercel 等平台的出站网络只提供 IPv4)。部署后需要:
>
> 1. 给节点配置 `SINGLE_STACK=ipv4`(声明节点为 IPv4 单栈),跳过 IPv6 相关测试,避免无意义的错误日志与超时
> 2. 在前端配置 `config/index.ts` 中,**该节点只能填入 `TCPing.IPv4` / `SpeedTest.IPv4` 等 IPv4 分组**,不要放进 `DualStack` / `IPv6` 分组
> 2. 在前端配置 `config/index.ts` 中,**该节点只能填入 `APIBaseURL.IPv4`**(拨测节点池的 IPv4 栈),不要放进 `DualStack` / `IPv6` 栈
## 方案三:Docker
仓库根目录提供 `Dockerfile`(多阶段构建,产物基于 Alpine):
`src/` 目录提供 `Dockerfile`(多阶段构建,产物基于 Alpine,支持多架构):
```bash
cd src
docker build -t lemon-ipw .
docker run -d -p 8080:8080 \
-v $(pwd)/setting.json:/app/setting.json \
docker run -d --restart unless-stopped -p 8080:8080 \
-v $(pwd)/setting.json:/home/appuser/setting.json \
lemon-ipw
```
配置通过挂载 `setting.json` 提供,也可用环境变量覆盖(`PORTS` / `CORS` / `ACCESS_TOKEN` 等)。
> `--restart unless-stopped`:主进程意外退出时 Docker 自动拉起容器;手动 `docker stop` 不会触发重启。
配置通过挂载 `setting.json` 提供(注意挂载到容器工作目录 `/home/appuser`),也可用环境变量覆盖(`PORTS` / `CORS` / `TRUSTED_PROXIES` / `ACCESS_TOKEN` 等)。
> [!TIP]
> 容器内 OTA 替换二进制意义有限(重建容器即回滚到镜像版本),如需彻底关闭可设 `NODE_OTA=false`;镜像**不含 IP 库数据**,首次启动若 `ipdb` 未关闭会现场拉取约 450MB 到容器可写层,轻量部署建议直接 `IPDB=false`。
**多架构**:Dockerfile 已适配 buildx——构建阶段固定在构建机原生平台交叉编译(`TARGETOS`/`TARGETARCH`),目标架构无需 QEMU 模拟。本机为其他架构构建:
```bash
cd src
docker buildx build --platform linux/arm64 -t lemon-ipw:arm64 --load .
```
打 `v*` 版本标签发版时,CI(`.github/workflows/docker.yml`)会自动构建 `linux/amd64` / `linux/arm64` / `linux/arm/v7` 三架构镜像(amd64 在 x64 实例上原生构建,arm64/armv7 在 GitHub 原生 ARM 实例 `ubuntu-24.04-arm` 上交叉构建,全程无 QEMU),推送 GHCR 与 Docker Hub:
- GHCR:`ghcr.io/<owner>/<repo>/lemon-ipw`(自动)
- Docker Hub:`docker.io/<namespace>/lemon-ipw`(**workflow 中暂未启用**——`.github/workflows/docker.yml` 已预留注释,取消注释并配置 `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` Secrets 即可开启)
均带 `版本` 与 `major.minor` 标签。部署直接:
```bash
docker run -d -p 8080:8080 \
-v $(pwd)/setting.json:/home/appuser/setting.json \
ghcr.io/nomdn/ipw-cn/lemon-ipw:v1.2.3
```
Docker 会按宿主机架构自动拉取 manifest 中对应的镜像变体。
## 方案四:二进制
```bash
# 编译
go build -o lemonipw main.go
# 编译(Go 后端在 src/ 目录)
cd src && go build -o lemonipw .
# 运行(与 setting.json 同目录)
./lemonipw
```
首次启动自动下载 IP 数据库(约 200MB),之后每 24 小时自动更新一次。需要守护运行时,参考 [快速入门](/guide/getting-started) 中的 systemd 配置。
首次启动自动下载 IP 数据库(约 450MB),之后每 24 小时自动更新一次。需要守护运行时,可参考下方「方案五」生成的 systemd 服务,或手动创建 service 文件(`ExecStart` 指向二进制,`WorkingDirectory` 指向 `setting.json` 所在目录)。
## 方案五:一键安装(install.sh)
仓库根目录提供 `install.sh` 一键安装脚本:自动检测架构 → 下载最新 release 二进制 → **交互式输入配置**(无需准备 setting.json,配置以环境变量注入)→ 生成 systemd 服务并守护进程。
```bash
# 先下载脚本到本地(勿用 curl | bash 管道执行,避免下载中断导致语法解析错误)
git clone https://github.com/nomdn/ipw-cn && cd ipw-cn # 或
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o install.sh
sudo bash install.sh
```
**交互式配置项**(直接回车使用默认值):
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| 安装目录 | `/opt/lemon-ipw` | 二进制与工作目录 |
| 监听端口 | `8080` | `PORTS` |
| 单栈模式 | 双栈 | `SINGLE_STACK`,`ipv4` / `ipv6` / 留空 |
| access-token | 留空 | 留空 = 不启用鉴权 |
| DNS 服务器 | 留空 | 主从逗号分隔(`119.28.28.28:53,223.5.5.5:53`);留空 = 启动时自动探测系统 DNS |
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server;都留空 = 自动探测系统 DNS |
| IP 数据库 | `Y` | `IPDB`,首次启动下载约 450MB |
| CORS | 留空 | 逗号分隔允许来源 |
| 远端配置地址 | 留空 | `REMOTE_CONFIG_URL` |
| WS 通道接入 | `N` | 选 `y` 后继续输入 WS_URL / NODE_ID / NODE_KEY |
| 其他环境变量 | 无 | 每行一个 `K=V`,空行结束。如需可信代理在此输入 `TRUSTED_PROXIES=<IP/CIDR 逗号分隔>`(配置后归属地接口只信任这些代理转发的 X-Forwarded-For,防伪造) |
**WS 接入特殊规则**:
- `WS_URL` 留空时自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址时须为**完整路径**(含 `wss://` 前缀与 `/ws` 路径,如 `wss://host:8092/ws`),逗号分隔多个则**同时连接全部(多活)**
- `NODE_ID` **强制自动生成 UUID**(无需输入,如 `4580ea9d-2a16-4b8e-8090-5f2e7f0a2229`),适合作为节点唯一标识
- `NODE_KEY` **必填**:不加 key 禁止启用 WS(中间件 `ws-keys` 必须包含该节点,否则注册被拒 401);安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 setting.json 的 `ws-keys`
**安装完成后自动生成并启动 systemd 服务**(`lemon-ipw.service`),配置以 `Environment="K=V"` 注入,无需 setting.json。常用管理命令:
```bash
systemctl status lemon-ipw # 状态
journalctl -u lemon-ipw -f # 日志
sudo systemctl restart lemon-ipw # 重启(改配置后)
```
修改配置:`sudo systemctl edit --full lemon-ipw` 编辑环境变量,保存后 `sudo systemctl daemon-reload && sudo systemctl restart lemon-ipw` 生效。
> [!WARNING]
> **版本兼容提醒**:v1.1.0 及更早的 release 二进制读的是旧环境变量名 `IPW_WS_URL` / `IPW_NODE_ID` / `IPW_NODE_KEY`(8-20 及之前构建)。install.sh 生成的 service 用的是新名 `WS_URL` / `NODE_ID` / `NODE_KEY`——**旧二进制 + 新 service 会因读不到配置而完全不启用 WS**(启动日志无任何 ws 输出)。升级到包含改名后的新 release 即可;急用可临时把 service 环境变量改回 `IPW_` 前缀。
## 配置
后端配置见 [配置文件](/guide/config):`setting.json` 或环境变量,支持远端配置(`REMOTE_CONFIG_URL`,优先级:远端 > 环境变量 > setting.json)。
## WS 通道接入(可选)
后端节点可作为 WS 客户端接入 [独立中间件](/guide/deploy-middleware) 的 WS 通道,拨测请求经 WebSocket 转发,节点本地执行探针后回传。不配置则完全走原 HTTP 接口。
**配置三个环境变量**(或 setting.json 的 `ws-url` / `node-id` / `node-key`,env 优先;也支持远端配置覆盖,除非列入 `remote-ignore-config`):
```bash
WS_URL=ws://<中间件IP>:8092/ws \
NODE_ID=<节点id,与中间件 api-base-url / ip-location-api 池的节点 id 一致> \
NODE_KEY=<注册key,与中间件 ws-keys[节点id] 一致;节点未配置 key 可留空> \
./lemonipw
```
- `WS_URL` 支持**逗号分隔多个中间件**(`ws://中间件1:8092/ws,ws://中间件2:8092/ws`),**同时连接全部(多活)**:每个地址独立注册并保持连接,任一断开只重连自己,不影响其他中间件;注意多个地址指向同一中间件实例时,同 `nodeId` 后注册的连接会顶掉先前的
- **双向心跳**:节点每 10s 发 ping,中间件回 pong;心跳发送连续失败 3 次判定中间件不可用,断开后 3s 重试(注册被拒 30s 重试)
- 收到 `probe` 后节点直调探针函数(带缓存)并回 `probe_result`,结果与 HTTP 通道一致
- 节点注册带 `NODE_KEY`:中间件 `ws-keys` 里配了 key 就必须传对,否则返回 401 并断开;未配置 key 的节点开放注册
**注意**:`NODE_ID` 必须与中间件 `APIBaseURL` / `IPLocationAPI` 池中的节点 `id` 一致,且该节点需配置 `"ws": true` 才会走 WS 通道。未连接中间件时,`ws:true` 节点的拨测会返回 502。
## 运行时配置管理(可选)
节点暴露 `/v1/config`,供收集中心读写该节点**当前生效**的配置(内存值),是「控制台改配置不用登机器」的基础:
| 动作 | HTTP | WS | 说明 |
|------|------|----|------|
| 读取 | `GET /v1/config` | `config` + `action=get` | 返回生效配置快照 + `secretKeys` + `restartRequiredKeys` |
| 修改 | `PATCH /v1/config`(`?persist=1` 写回本地文件) | `config` + `action=patch` | 只应用传入的键,其余不动 |
| 刷新远端 | `POST /v1/config/refresh` | `config` + `action=refresh` | 重新拉 `remote-config-url` 并应用 |
三条重要约定:
- **凭据类键不回显明文**:`access-token` / `node-key` / `report-token` 在快照中为 `***`。调用方下发配置时必须剔除这些键,否则会把真值覆盖成三个星号。收集中心控制台已自动剔除。
- **节点不自行重启**:`port` / `cors` / `ipdb` / `report-interval-seconds` / `trusted-proxies` / `node-id` / `node-key` / `access-token` 这些启动期固定的键,改动后在应答的 `restartRequired` 中如实列出,但**不会自动重启进程**——重启会中断在途服务,时机由运维 / 编排层掌握。`ws-url` 是例外:热生效,控制器按新地址多退少补。
- **只 PATCH 内存的改动会被重启顶掉**:节点侧优先级是 远端 > 环境变量 > `setting.json`,长期生效需写回托管配置或本地文件,详见 [配置文件](/guide/config) 的「运行时配置管理」一节。
> [!NOTE]
> **HTTP 管理面需要凭据**:节点未配置 `access-token` 时,`/v1/config` 与 `/v1/ota` 整组返回 `403`(此时业务接口也无鉴权,开放配置读写等于公网可改配置)。**WS 通道不受此限制**——节点是 WS 客户端,指令只来自已通过中间件 `ws-keys` 校验的连接。
## OTA 升级(收集中心下发,可选)
节点支持由收集中心下发**一次性升级任务**:下载新二进制 → 校验 → 预检 → 原子替换 → 重启,全程无需登录节点主机。
**触发方式**:收集中心经 WS `ota` 消息下发(HTTP 回退 `POST /v1/ota`)。请求体三选一指定下载源:
| 字段 | 说明 |
|------|------|
| `version` | 按版本号下发。节点按自身平台拼资产名 `lemonipw-{goos}-{goarch}[.exe]`,到资产基址 `{assetBase}/{tag}/` 下取;`assetBase` 缺省为 GitHub Releases,自建镜像源可覆盖 |
| `url` | 直发下载地址(完全自定义) |
| `sha256` | 可选,提供即强校验(hex64,兼容 `sha256:` 前缀) |
**执行流程**:下载到与目标同分区的临时文件(保证 `rename` 原子)→ 体积下限 1MB(防错误页)→ sha256 校验 → **预检**(试运行新二进制 `-v`,确认架构匹配、可执行,在停机前拦下损坏文件)→ 当前二进制改名 `.old` → 新文件就位 → 重启。任一步失败都会回滚 `.old`。
**进度回传**:经 WS `ota_result` 依次回报 `accepted` → `downloading` → `verifying` → `installing` → `restarting`,失败时带 `error`。重启后连接必然断开,**最终结果由收集中心以重连注册上报的新版本号判定**(15 分钟未观测到新版本即判超时失败)。
**并发与开关**:
- 同一时刻只允许一个 OTA 任务,重复下发直接拒绝。
- `node-ota: false`(env `NODE_OTA`)可完全禁用:节点拒绝一切 OTA 指令并回传原因。**只读文件系统 / 编排托管**(Docker Swarm、K8s 等自行管理镜像)的部署建议关闭。
- 部署方式受限导致替换失败(如容器只读层)时,升级会失败并回滚,此时仍按传统方式人工升级。
手动更新(OTA 不可用时):
```bash
# Docker:拉取新镜像后重建容器
docker pull lemon-ipw && docker stop lemon-ipw && docker run ... lemon-ipw
# systemd:替换二进制后重启服务
systemctl restart lemon-ipw
```
## 版本与能力上报
节点无论是否接入 WS,都会上报自身版本与支持的管理能力:
- **版本号**来自构建时注入的 `VERSION`,随 WS `register` 报文上报(字段 `version`);纯 HTTP 节点(无 WS 连接)由收集中心探活 `GET /` 时一并取回(响应体含 `version`)。
- **能力清单**(`capabilities`)取值为 `probe` / `report` / `config` / `ota`,用于让收集中心在下发管理指令前判断该节点能否理解——老版本节点的消息循环没有对应分支,收到管理消息会**静默忽略**,只能靠超时暴露。
- 收集中心「节点状态」页展示每个节点的版本号,并据此判断哪些节点需要升级;节点升级部署后重新注册即为新版本。
能力清单的取值与三态判定规则见 [节点 API 与协议参考](/guide/node-api) 的「能力清单」一节。
+63 -142
View File
@@ -1,155 +1,76 @@
# 快速开始
> 本章节将指导您以最低成本部署一个功能完整的测试站
## 尝试一下
我们的开发团队已经部署了一个功能完整的预览站:
我们的开发团队已经部署了一个功能完整的预览站:
[预览站点](https://ipw.wsmdn.top/)
## 前置准备
1. Git
2. Node.js 24+
3. Go1.26
4. 一台双栈网络环境的服务器(Linux+Systemd)
- 一台 Linux 服务器(需 root 权限 + systemd)
- 服务器上可访问 GitHub(下载 release 二进制)
- 无需本地编译环境,无需准备配置文件
## 开始部署
### 构建后端
1. 拉取代码
```bash
git clone https://github.com/nomdn/ipw-cn && cd ipw-cn
```
2. 安装依赖
```bash
go mod tidy
```
3. 构建程序
```bash
go build
```
### 构建中间层(可选)
> [!TIP]
> 如果你的服务器到测速节点的链接延迟不大,可以跳过这一步
1. 切换目录
```bash
cd middleware-go
```
2. 安装依赖
```bash
go mod tidy
```
3. 构建程序
```bash
go build
```
### 构建前端
1. 切换目录
```bash
cd frontend-ssr
```
2. 安装依赖
```bash
pnpm install
```
3. 本地开发(可选)
```bash
pnpm dev
```
4. 生产构建
```bash
pnpm build
```
> 前端部署方式(Cloudflare Workers / EdgeOne Makers 等)见 [前端部署](/guide/deploy-frontend)。
### 配置文件
> 完整字段说明、模板与远端配置用法见 [配置文件](/guide/config)。以下为三个组件的最小示例。
**后端**(`setting.json`,env 可覆盖 `PORTS` / `DNS_SERVER` / `CORS` / `ACCESS_TOKEN` 等):
```json
{
"port": 8080,
"dns-server": "119.28.28.28:53",
"cors": "",
"access_token": ""
}
```
**前端**(`frontend-ssr/config/index.ts`,节点 `id` 与中间件 `apiKeys` 键对应):
```ts
const config = {
siteUrl: "https://your-domain.com/",
siteName: "你的站点名称",
EnableInternalMiddleware: true,
rateLimitPerMinute: 120,
Middleware: <string[]>[],
umamiHost: "", umamiScriptUrl: "", umamiWebsiteId: "",
ICP: "", GongAn: "",
noindex: false,
v4OnlyAPI: "https://4.wsmdn.top", v6OnlyAPI: "https://6.wsmdn.top", DualStackAPI: "https://test.wsmdn.top",
apiBaseUrls: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
],
IPLocationAPIs: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
],
TCPing: { DualStack: [], IPv4: [ { label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" } ], IPv6: [] },
SpeedTest: { DualStack: [], IPv4: [ { label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" } ], IPv6: [] },
NSLookup: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
]
}
export { config }
```
**独立中间件**(`middleware-go/setting.json`):
```json
{
"port": "8091",
"cors": "",
"rate-limit": 120,
"remote-config-url": "",
"apiBaseUrls": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"apiKeys": { "cn-jiangsu": "" }
}
```
> 完整字段(`http-timeout-seconds` / `ws-port` / `remote-ingore-config` 等)见 [配置文件 - 独立中间件](/guide/config#独立中间件middleware-go)。**敏感凭据(`apiKeys` / `access_token`)不得上传远端配置,仅放本地或环境变量。**
前端内置中间件通过环境变量 **`NUXT_APIKEYS`**(JSON 字符串,如 `'{"cn-jiangsu":"your-key"}'`)注入 key。
### systemd 守护进程
以 `/opt/lemon-ipw` 为安装目录(二进制与 `setting.json` 放同一目录),创建服务文件:
```ini
# /etc/systemd/system/lemon-ipw.service
[Unit]
Description=Lemon IPW Backend
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/lemon-ipw
ExecStart=/opt/lemon-ipw/lemonipw
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
> `WorkingDirectory` 必须指向 `setting.json` 所在目录,后端从当前目录读取配置。
启动与管理:
### 交互式一键部署(推荐)
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now lemon-ipw
sudo systemctl status lemon-ipw
sudo journalctl -u lemon-ipw -f
# 获取安装脚本(任选其一)
git clone https://github.com/nomdn/ipw-cn && cd ipw-cn # 或
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o install.sh
# 执行安装(root 权限)
sudo bash install.sh
```
验证:`curl http://127.0.0.1:8080/` 返回 `{"status":"ok"}` 即正常。中间件也可用同样的方式守护(`ExecStart` 指向中间件二进制,`WorkingDirectory` 指向 `middleware-go/setting.json` 所在目录)。
> [!WARNING]
> 请勿使用 `curl ... | sudo bash` 管道方式执行:脚本经管道边下载边执行,网络慢/抖动时 bash 会读到不完整的脚本内容,报 `syntax error near unexpected token` 类错误(脚本本身无语法问题)。务必**先下载完整文件再执行**。
脚本会自动完成:检测架构 → 下载最新 release 二进制 → **交互式输入配置** → 生成并启动 systemd 守护进程。
**交互式配置项**(直接回车使用默认值):
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| 安装目录 | `/opt/lemon-ipw` | 二进制与工作目录 |
| 监听端口 | `8080` | 后端 HTTP 端口 |
| 单栈模式 | 双栈 | `SINGLE_STACK`,`ipv4` / `ipv6` / 留空 |
| access-token | 留空 | 留空 = 不启用鉴权 |
| DNS 服务器 | 留空 | 主从逗号分隔(如 `119.28.28.28:53,223.5.5.5:53`);留空 = 启动时自动探测系统 DNS |
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server;都留空 = 自动探测系统 DNS |
| IP 数据库 | `Y` | 首次启动下载约 450MB |
| CORS | 留空 | 逗号分隔允许来源 |
| 远端配置地址 | 留空 | `REMOTE_CONFIG_URL` |
| WS 通道接入 | `N` | 选 `y` 后输入 WS_URL / NODE_ID / NODE_KEY |
| 其他环境变量 | 无 | 每行一个 `K=V`,空行结束。如需可信代理在此输入 `TRUSTED_PROXIES=<IP/CIDR 逗号分隔>`(配置后归属地接口只信任这些代理转发的 X-Forwarded-For,防伪造) |
**WS 通道接入规则**(选 `y` 时):
- `WS_URL` 留空自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址须为完整路径(`wss://host:8092/ws` 形式);逗号分隔多个则**同时连接全部(多活)**
- `NODE_ID` **强制自动生成 UUID**(无需输入)
- `NODE_KEY` **必填**:不加 key 禁止启用 WS;安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 `ws-keys`
### 验证安装
```bash
systemctl status lemon-ipw # 服务状态(active running)
curl http://127.0.0.1:8080/ # 应返回 {"status":"ok"}
journalctl -u lemon-ipw -f # 实时日志
```
### 修改配置
```bash
sudo systemctl edit --full lemon-ipw # 编辑环境变量
sudo systemctl daemon-reload && sudo systemctl restart lemon-ipw
```
## 下一步
- 部署**独立中间件**(WS 通道 / 多节点转发):见 [中间件部署](/guide/deploy-middleware)
- 部署**前端**(Cloudflare Workers / EdgeOne Makers 等):见 [前端部署](/guide/deploy-frontend)
- 完整**配置说明**(后端 / 前端 / 中间件 / 远端配置):见 [配置文件](/guide/config)
- 手动**编译部署**(源码构建三个组件):见 [后端节点部署](/guide/deploy-node) / [中间件部署](/guide/deploy-middleware) / [前端部署](/guide/deploy-frontend)
+2 -2
View File
@@ -14,7 +14,7 @@ Lemon IPW 采用前后端分离架构,**后端节点、独立中间件、前
## 快速开始
想用最低成本部署一套功能完整的测试站,直接看 [快速入门](/guide/getting-started):后端构建 → 前端构建 → 配置文件 → systemd 守护。
想用最低成本部署一套功能完整的测试站,直接看 [快速入门](/guide/getting-started):交互式一键部署(install.sh)→ systemd 守护。
## CI/CD
@@ -23,5 +23,5 @@ Lemon IPW 采用前后端分离架构,**后端节点、独立中间件、前
| 工作流 | 部署目标 |
|--------|----------|
| `frontend-ssr.yml` | SSR 前端 → Cloudflare Workers |
| `edgeone-backend.yml` | `edgeone/` → EdgeOne Pages |
| `edgeone-backend.yml` | `serverless/edgeone/` → EdgeOne Pages |
| `build_and_release.yml` | 后端多平台构建与发布(Release 附带配置文件) |
+207
View File
@@ -0,0 +1,207 @@
# 节点 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` | 不启用鉴权,公开可访问 |
| 健康检查 `/` | 无 | — |
| 管理接口 `/v1/config`、`/v1/ota`(HTTP) | 同业务接口,复用 `tokenCheck` | **整组关闭**,一律返回 `403`(节点未配 `access-token` 时业务接口本身也无鉴权,暴露配置读写等于公网可改配置) |
| WS 通道 | 中间件 `ws-keys` 校验注册报文 | WS **常开**:节点是客户端,指令只来自已通过中间件校验的连接,与节点是否配 `access-token` 无关 |
> 结论:**HTTP 管理面是"有凭据才开"的**;只想用 WS 通道管理节点时,节点无需配置 `access-token`。
---
## 一、健康检查
```
GET / → {"status":"ok","version":"v1.2.3","capabilities":["probe","report","config","ota"]}
```
无鉴权、不计入统计。除存活外同时回报**版本号**与**能力清单**:收集中心对纯 HTTP 节点(无 WS 连接)探活时一并取回,用于节点状态页展示版本、以及下发管理指令前判断该节点能否理解对应指令。
---
## 二、业务探测接口
全部挂在 `/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 /` 中上报自身支持的管理能力,取值为 `probe` / `report` / `config` / `ota` 的数组。
收集中心据此做**三态判定**,绝不能把"未知"当作"不支持":
| 上报情况 | 收集中心行为 |
|----------|--------------|
| 清单含该能力 | 正常下发对应指令 |
| 清单明确不含 | 该能力确实没有:立刻拒绝,不发起指令、不等超时 |
| 清单为空(未上报) | 老版本节点,能力**未知**:仍尝试下发;超时后主动探活,区分"节点活着但静默忽略(程序过旧)"与"连接已僵死" |
新增一类管理指令时应在此追加能力标识,并保持取值稳定(收集中心按字符串比对)。
---
## 七、缓存与并发
业务探测结果在节点侧缓存,避免同 key 洪打把上游打崩:
| 缓存 | 键 | TTL |
|------|-----|-----|
| `detail` | 归一化 URL | 5 分钟 |
| `ssl` | URL | 5 分钟 |
| `speed` | `url:version` | 5 分钟 |
| `tcping` | `host:port:count` | 5 分钟 |
| `whois` | 域名 | 5 分钟 |
| `asn whois` | `AS<asn>` | 5 分钟 |
| Bilibili 归属地(在线 API) | IP | 24 小时 |
- **失败结果 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(普通用户只见自己的) |
+4
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和阿里云函数计算
---
+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 Makers、Vercel、AWS Lambda、阿里云函数计算等(均为 IPv4-only,需声明 `SINGLE_STACK=ipv4`) |
各形态的详细步骤见 [后端节点部署](/guide/deploy-node)。
+3
View File
@@ -20,6 +20,9 @@ Lemon IPW(柠檬味 ipw.cn 替代品)是一个开源的分布式站点测试
- TCPing(IPv4/IPv6 双栈)
- 网站测速
- 网站截图
- 远程运维(版本 / 能力上报、运行时配置下发、一次性 OTA 升级)
完整的能力矩阵、IP 数据源与可靠性设计见 [功能总览](/info/features)。
## 架构
+2 -1
View File
@@ -95,8 +95,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)');
+18 -11
View File
@@ -10,10 +10,10 @@ const route = useRoute()
useHead({
title: `ASN查询 | IP自治系统号查询 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP双数据源对比,同时集成WHOIS解析获取ASN详细信息,包括组织名称、国家、注册日期等,助力网络运维和路由分析' },
{ name: 'description', content: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP、IP2Location 多数据源对比,同时集成WHOIS解析获取ASN详细信息,包括组织名称、国家、注册日期等,助力网络运维和路由分析' },
{ name: 'keywords', content: 'asn查询,自治系统号,asn lookup,ip asn,asn whois,网络自治系统,运营商asn,ip归属asn' },
{ property: 'og:title', content: 'ASN自治系统查询工具 - IP ASN号与组织信息查询' },
{ property: 'og:description', content: '多数据源ASN查询,支持Maxmind GEOLite2和DB-IP,集成WHOIS解析获取ASN详细信息' },
{ property: 'og:description', content: '多数据源ASN查询,支持Maxmind GEOLite2、DB-IP、IP2Location,集成WHOIS解析获取ASN详细信息' },
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
{ property: 'og:type', content: 'website' },
],
@@ -24,7 +24,7 @@ useHead({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'ASN自治系统查询工具',
description: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP双数据源对比,集成WHOIS解析。',
description: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP、IP2Location 多数据源对比,集成WHOIS解析。',
url: config.siteUrl + 'asn',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
@@ -46,6 +46,7 @@ interface ASNResult {
ip: string
geolite2_asn?: { asn: string; org: string }
dbip_asn?: { asn: string; org: string }
ip2location_asn?: { asn: string; as: string }
whois?: any
}
@@ -55,12 +56,12 @@ const result = ref<ASNResult | null>(null)
const error = ref('')
const userIP = ref('')
const apiList = config.IPLocationAPIs
const apiList = config.IPLocationAPI
const currentApiIndex = ref(0)
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
const { data: asnData, error: asnError, execute: executeASN } = useMiddlewareFetch<ASNResult>(() => '/middleware/' + backendID.value + '/asn/' + tmpIP.value, {
const { data: asnData, error: asnError, execute: executeASN } = useMiddlewareFetch<ASNResult>(() => '/middleware/' + backendID.value + '/asn/' + encodeURIComponent(tmpIP.value), {
key: 'asn-fetch',
immediate: false,
watch: false,
@@ -118,11 +119,11 @@ onMounted(() => {
})
async function getUserIP() {
await $fetch<string>(config.DualStackAPI).then(
function (data) {
userIP.value = data
}
)
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
} catch {
// 获取失败保持为空,避免未处理的 Promise 拒绝
}
return userIP.value
}
@@ -185,6 +186,13 @@ function getStatusClass(status: string): string {
<span v-if="result.dbip_asn.org" style="color: #999; font-size: 0.9em;">{{ result.dbip_asn.org }}</span>
</td>
</tr>
<tr v-if="result.ip2location_asn?.asn">
<td class="table-label">IP2Location ASN</td>
<td class="table-value">
AS{{ result.ip2location_asn.asn }}
<span v-if="result.ip2location_asn.as" style="color: #999; font-size: 0.9em;">{{ result.ip2location_asn.as }}</span>
</td>
</tr>
<tr v-if="result.whois?.asNumber">
<td class="table-label">ASN</td>
<td class="table-value">AS{{ result.whois.asNumber }}</td>
@@ -226,7 +234,6 @@ function getStatusClass(status: string): string {
</div>
<blockquote>
<a href="/doc/user/asn" target="_blank">ASN 查询原理介绍</a><br/>
ASN(Autonomous System Number)是互联网中每个自治系统的唯一标识符。<br/>
数据来源:Maxmind GEOLite2、DB-IP,部分节点集成 WHOIS 进一步解析 ASN 详情。<br/>
<a href="/location" target="_blank">IP 归属地查询</a> | <a href="/whois" target="_blank">Whois 查询</a><br/>
+38 -15
View File
@@ -50,13 +50,15 @@ const results = ref<any>([])
const isloading = ref(false)
const nowRecordType = ref('')
const userIP = ref('')
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
async function getUserIP(){
await $fetch<string>(config.DualStackAPI).then(
function (data){
userIP.value = data
})
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
} catch {
// 获取失败保持为空,避免未处理的 Promise 拒绝
}
return userIP.value
}
@@ -72,8 +74,12 @@ const recordTypes = [
{ value: 'ptr', label: 'PTR 记录' }
]
const dnsServerFetches = config.NSLookup.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/dns/" + recordType.value + "/" + domain.value);
const dnsServerFetches = [
...config.APIBaseURL.DualStack,
...config.APIBaseURL.IPv4,
...config.APIBaseURL.IPv6
].map((server) => {
const url = computed(() => "/middleware/" + server.id + "/dns/" + recordType.value + "/" + encodeURIComponent(domain.value));
const { data, error, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
@@ -85,7 +91,9 @@ async function queryDNS() {
isloading.value = true
domain.value = tmpDomain.value
await nextTick()
const id = ++queryId
// 初始化结果数组,保持响应式结构
results.value = dnsServerFetches.map(fetch => ({
server: fetch.label,
@@ -97,7 +105,20 @@ async function queryDNS() {
const promises = dnsServerFetches.map(async (fetch, index) => {
try {
await fetch.execute();
if (id !== queryId) return null
// execute() 不会抛错,全部候选失败时以 error.value 为准(否则故障节点渲染成空白)
if (fetch.error.value) {
results.value[index] = {
server: fetch.label,
loading: false,
data: null,
error: fetch.error.value
};
return { server: fetch.label, error: fetch.error.value };
}
// 直接更新对应索引的结果,保持响应式
results.value[index] = {
server: fetch.label,
@@ -105,12 +126,13 @@ async function queryDNS() {
data: fetch.data.value,
error: null
};
return {
server: fetch.label,
data: fetch.data.value
};
} catch (err) {
if (id !== queryId) return null
// 错误时也更新对应索引
results.value[index] = {
server: fetch.label,
@@ -118,7 +140,7 @@ async function queryDNS() {
data: null,
error: err
};
return {
server: fetch.label,
error: err
@@ -126,10 +148,11 @@ async function queryDNS() {
}
});
nowRecordType.value = recordType.value
const promiseResults = await Promise.all(promises)
console.log(promiseResults)
isloading.value = false
const promiseResults = (await Promise.all(promises)).filter(Boolean)
if (id === queryId) {
isloading.value = false
}
return promiseResults
}
+29 -13
View File
@@ -68,11 +68,17 @@ const tmpDomain = ref('www.zakoflare.com')
const loading = ref(false)
const serverResults = ref<ServerResult[]>([])
const userIP = ref('')
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
const apiList = config.NSLookup
const apiList = [
...config.APIBaseURL.DualStack,
...config.APIBaseURL.IPv4,
...config.APIBaseURL.IPv6
]
const dnssecFetches = apiList.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/dnssec/" + tmpDomain.value)
const url = computed(() => "/middleware/" + server.id + "/dnssec/" + encodeURIComponent(tmpDomain.value.trim()))
const { data, error, execute } = useMiddlewareFetch<DNSSECResult>(url, {
immediate: false,
watch: false,
@@ -93,6 +99,8 @@ async function checkDNSSEC() {
loading.value = true
const id = ++queryId
serverResults.value.forEach((result) => {
result.loading = true
result.data = undefined
@@ -101,9 +109,15 @@ async function checkDNSSEC() {
const promises = dnssecFetches.map(async (fetch, index) => {
try {
await fetch.execute()
if (id !== queryId) return
const result = serverResults.value[index]
if (result) {
result.data = fetch.data.value
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成 '-')
if (fetch.error.value) {
result.error = (fetch.error.value as any)?.message || '请求失败'
} else {
result.data = fetch.data.value
}
}
} catch (err) {
console.error(err)
@@ -113,14 +127,16 @@ async function checkDNSSEC() {
}
} finally {
const result = serverResults.value[index]
if (result) {
if (result && id === queryId) {
result.loading = false
}
}
})
Promise.allSettled(promises).finally(function () {
loading.value = false
if (id === queryId) {
loading.value = false
}
})
}
@@ -130,8 +146,8 @@ function getValidationClass(result: DNSSECResult): string {
return 'status-error'
}
function formatDuration(ms: number): string {
return ms.toFixed(2) + ' ms'
function formatDuration(ms?: number): string {
return typeof ms === 'number' && isFinite(ms) ? ms.toFixed(2) + ' ms' : '-'
}
onMounted(() => {
@@ -145,11 +161,11 @@ onMounted(() => {
})
async function getUserIP() {
await $fetch<string>(config.DualStackAPI).then(
function (data) {
userIP.value = data
}
)
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
} catch {
// 获取失败保持为空,避免未处理的 Promise 拒绝
}
return userIP.value
}
</script>
@@ -240,7 +256,7 @@ async function getUserIP() {
</div>
<blockquote>
<a href="/doc/user/dnssec" target="_blank">DNSSEC 原理介绍</a><br/>
DNSSEC 原理介绍即将上线<br/>
DNSSEC 通过 DNSKEY、RRSIG、DS 记录为 DNS 数据提供完整性验证,防止 DNS 欺骗攻击。<br/>
如果显示 "验证失败",可能原因:域名未配置 DNSSEC、DNSKEY 密钥不匹配、DS 记录缺失等。<br/>
<a href="/dns" target="_blank">DNS 解析查询</a> | <a href="/whois" target="_blank">Whois 查询</a><br/>
+1 -1
View File
@@ -129,5 +129,5 @@ html.dark .content {
</style>
<style>
@import "github-markdown-css/github-markdown-light.css";
@import "/github-markdown-dark.css";
@import "../../github-markdown-dark.css";
</style>
+1 -1
View File
@@ -128,7 +128,7 @@ html.dark .content {
</style>
<style>
@import "github-markdown-css/github-markdown-light.css";
@import "/github-markdown-dark.css";
@import "../../github-markdown-dark.css";
@import "/style.css"
</style>
+2 -2
View File
@@ -122,8 +122,8 @@ onMounted(async () => {
<b>IPv6</b>&nbsp<p v-if="yourIPv6">{{ yourIPv6 }}</p><RouterLink :to="`/location?ip=${yourIPv6}`" target="_blank" v-if="yourIPv6">&nbsp查询归属地</RouterLink><RouterLink v-else to="/doc/user/enable_ipv6" target="_blank">没有IPv6地址,查看如何开启IPv6</RouterLink>
</div>
<div style="font-size: 1.5em;">
<h3 v-if="(ipAddress && isIPv6(ipAddress)) || isIPv6(yourIPv6)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h3>
<h3 v-else-if="(ipAddress && isIPv4(ipAddress)) || isIPv6(yourIPv6)"><el-icon><CircleCloseFilled style="color: red;"/></el-icon>您的网络IPv4优先</h3>
<h3 v-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>
<blockquote>
+14 -384
View File
@@ -1,396 +1,26 @@
<script setup lang="ts">
import { ref, onMounted, computed, nextTick, watch } from 'vue';
import { isIPv6 } from 'is-ip';
import {config} from '../../config/index';
import { useMiddlewareFetch } from '../../utils/middleware';
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
import { useRoute } from 'vue-router'
import { highlightCode } from '../../utils/shiki'
import { isIPv4 } from '../../utils/tools';
import { onMounted } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const loading = ref(false);
useHead({
title: `IPv6/IPv4地址查询工具 | IP归属地定位 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的IPv6/IPv4地址查询工具,支持IPv4和IPv6地址归属地查询,提供BiliBili Live、GeoCN、IP2Region、Maxmind等多种数据源对比,精确定位IP地理位置、运营商信息,支持中国大陆及境外地址查询,助力IPv6普及与应用' },
{ property: 'og:title', content: `IPv6/IPv4地址归属地查询工具 - ${config.siteName}` },
{ property: 'og:description', content: '多数据源IP地址归属地查询,支持IPv4和IPv6,提供地理位置、运营商等详细信息' },
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
{ property: 'og:type', content: 'website' },
],
});
interface IPLocationType {
bilibili?: any;
ip?: string;
geocn?: any;
ip2region?: any;
maxmind_asn?: any;
maxmind_city?: any;
qqwry?: any;
[key: string]: any;
}
const code = `
# 请勿用于商业用途,仅供个人测试学习之用,请遵守中国法律法规
# 查询本机外网 IPv4 地址
curl ${config.v4OnlyAPI}
# 查询本机外网 IPv6 地址
curl ${config.v6OnlyAPI}
# 测试网络是 IPv4 还是 IPv6 访问优先
# (访问 IPv4/IPv6 双栈站点,如果返回 IPv6 地址,则 IPv6 访问优先)
curl ${config.DualStackAPI}
`.trim();
const html = ref('');
const apiList = config.IPLocationAPIs
const currentApiIndex = ref(0)
const backendID = computed(() => apiList[currentApiIndex.value]!.id)
const ipAddress = ref('');
const IPLocation = ref<IPLocationType>({});
const UserIP = ref('');
const locationUrl = computed(() => "/middleware/" + backendID.value + "/location/" + ipAddress.value);
const { data: locationData, error: locationError, execute: executeLocation } = useMiddlewareFetch<IPLocationType>(locationUrl, {
immediate: false,
watch: false,
});
watch(locationData, (newData) => {
if (newData) {
IPLocation.value = newData;
loading.value = false;
}
});
watch(locationError, (newError) => {
if (newError) {
console.error('Error fetching IP location:', newError);
loading.value = false;
}
});
function locateIP(IP: string){
ipAddress.value = IP;
loading.value = true;
nextTick(() => {
executeLocation();
});
}
onMounted(async () => {
const urlParam = route.query.ip;
await navigateTo({ path: '/location', query: { ip: urlParam } });
html.value = await highlightCode(code, 'bash')
if (urlParam) {
ipAddress.value = urlParam as string;
locateIP(urlParam as string);
}else{
$fetch<string>(config.DualStackAPI).then(
function(data) {
ipAddress.value = data;
UserIP.value = data;
}
).catch(
function(error) {
console.error('Error fetching IP address:', error);
}
);
}
// ip 参数缺失时不要拼出 ?ip=undefined
await navigateTo(urlParam ? { path: '/location', query: { ip: urlParam } } : { path: '/location' });
});
</script>
<template>
<div class="title">
<header>
<h1>IPv6/IPv4地址归属地查询</h1>
<p>极简的IPv6地址查询工具,致力于普及 IPv6</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="ipAddress"
placeholder="请输入IP地址"
/>
<el-button
@click="locateIP(ipAddress)"
type="primary"
:loading="loading"
>
查询
</el-button>
</div>
<div class="location">
<div class="one-line" style="height: 40px;">
<b>IP</b>&nbsp<p>{{ ipAddress }}</p>
</div>
<div v-if="IPLocation" class="result-section">
<table class="result-table">
<tbody>
<tr v-if="IPLocation.bilibili && (IPLocation.bilibili.administrative_area || IPLocation.bilibili.city)">
<td class="table-label">bilibili Live接口</td>
<td class="table-value">
<span>
{{ IPLocation.bilibili?.country }}&nbsp;{{ IPLocation.bilibili?.administrative_area }}&nbsp;{{ IPLocation.bilibili?.city }}
</span>
</td>
<td class="table-value">
<span>
{{ IPLocation.bilibili?.isp }}
</span>
</td>
</tr>
<tr v-if="IPLocation.ip2region && IPLocation.ip2region.split('|').length >= 4">
<td class="table-label">IP2Region</td>
<td class="table-value">{{ IPLocation.ip2region?.split("|")[0] }}&nbsp;{{ IPLocation.ip2region?.split("|")[1] }}&nbsp;{{ IPLocation.ip2region?.split("|")[2] }}</td>
<td class="table-value">{{ IPLocation.ip2region?.split("|")[3] }}</td>
</tr>
<tr v-if="IPLocation.geocn && (IPLocation.geocn.administrative_area || IPLocation.geocn.city || IPLocation.geocn.district)">
<td class="table-label">GeoCN(仅中国大陆)</td>
<td class="table-value">{{ IPLocation.geocn?.administrative_area }}&nbsp;{{ IPLocation.geocn?.city }}&nbsp;{{ IPLocation.geocn?.district }}</td>
<td class="table-value">{{ IPLocation.geocn?.isp }}</td>
</tr>
<tr v-if="IPLocation.maxmind_city && IPLocation.maxmind_asn && (IPLocation.maxmind_city.country || IPLocation.maxmind_city.city)">
<td class="table-label">Maxmind GEOLite2 City</td>
<td class="table-value">{{ IPLocation.maxmind_city?.country }}&nbsp;{{ IPLocation.maxmind_city?.administrative_area }}&nbsp;{{ IPLocation.maxmind_city?.city }}</td>
<td class="table-value">{{ IPLocation.maxmind_asn?.org }}</td>
</tr>
<tr v-if="IPLocation.qqwry && (IPLocation.qqwry.country || IPLocation.qqwry.administrative_area || IPLocation.qqwry.city)">
<td class="table-label">纯真社区库</td>
<td class="table-value">{{ IPLocation.qqwry?.country }}&nbsp;{{ IPLocation.qqwry?.administrative_area }}&nbsp;{{ IPLocation.qqwry?.city }}</td>
<td class="table-value">{{ IPLocation.qqwry?.isp }}</td>
</tr>
<tr v-if="IPLocation.dbip_city && (IPLocation.dbip_city.administrative_area || IPLocation.dbip_city.city)">
<td class="table-label">DB-IP City</td>
<td class="table-value">{{ IPLocation.dbip_city?.country }}&nbsp;{{ IPLocation.dbip_city?.administrative_area }}&nbsp;{{ IPLocation.dbip_city?.city }}</td>
<td class="table-value">{{IPLocation.dbip_asn?.org || '--'}}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div style="font-size: 1.5em;">
<h3 v-if="isIPv6(UserIP)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h3>
<h3 v-else-if="isIPv4(UserIP)"><el-icon><CircleCloseFilled style="color: red;"/></el-icon>您的网络IPv4优先</h3>
</div>
<blockquote>
精度参照表:<br>
中国大陆:BiliBili Live > GeoCN > IP2Region > 纯真社区库 > Maxmind GEOLite2 City ≈ DB-IP<br>
中国大陆 IPv6 地址: BiliBili Live > GeoCN > IP2Region > Maxmind GEOLite2 City ≈ DB-IP > 纯真社区库<br>
境外及港澳台地址: Maxmind GEOLite2 City ≈ DB-IP > BiliBili Live > > IP2Region > 纯真社区库<br>
手机默认开启 IPv6,宽带开启 IPv6 请自行搜索<br>
访客IP: {{UserIP}},<p v-if="isIPv4(UserIP)">您的网络IPv4优先</p><p v-else-if="isIPv6(UserIP)">您的网络IPv6优先</p>
</blockquote>
<div v-html="html"></div>
</div>
<div class="redirecting">正在跳转到 IP 归属地查询…</div>
</template>
<style scoped>
@import "../style.css";
@import "../../assets/css/tool-common.css";
</style>
<style>
@import "../style.css";
:root {
--el-color-primary: #3EAF7C;
}
.el-icon{
font-size: 1.3em;
}
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
.el-input {
width: 420px;
height: 50px;
font: 1.3em sans-serif;
margin-right: 10px;
}
.el-button {
width: 165px;
height: 50px;
font: 1.3em sans-serif;
}
.result-section {
margin-top: 30px;
}
.result-table {
width: 100%;
border-collapse: collapse;
background: #fff;
border: 1px solid #dcdfe6;
}
html.dark .result-table {
width: 100%;
border-collapse: collapse;
background: #2e2d2d;
border: 1px solid #2e2e2e;
}
.result-table thead tr {
background-color: #c0c4cc;
}
html.dark .result-table thead tr {
background: #2e2d2d;
}
.result-table .table-header {
padding: 12px 15px;
font-weight: 600;
color: #303133;
text-align: left;
border: 1px solid #dcdfe6;
}
html.dark .result-table .table-header {
padding: 12px 15px;
font-weight: 600;
color: #cfcfcf;
text-align: left;
border: 1px solid #1a1919;
}
.result-table tbody tr {
border-bottom: 1px solid #dcdfe6;
}
html.dark .result-table tbody tr {
border-bottom: 1px solid #1a1919;
}
.result-table tbody tr:last-child {
border-bottom: none;
}
.result-table tbody tr:hover {
background-color: #f5f7fa;
}
html.dark .result-table tbody tr:hover {
background-color: #393a3a;
}
.result-table .table-label {
padding: 12px 15px;
font-weight: 600;
color: #606266;
width: 150px;
text-align: left;
border: 1px solid #dcdfe6;
}
html.dark .result-table .table-label {
color: #c0c4cc;
border: 1px solid #1a1919;
}
.result-table .table-value {
padding: 12px 15px;
color: #303133;
border: 1px solid #dcdfe6;
}
html.dark .result-table .table-value {
color: #cfcfcf;
border: 1px solid #1a1919;
}
.valid {
color: #67C23A;
font-weight: 600;
}
.expired {
color: #F56C6C;
font-weight: 600;
}
.status-code {
font-weight: 600;
padding: 4px 12px;
border-radius: 4px;
}
.status-success {
color: #67C23A;
background: #f0f9eb;
}
.status-warning {
color: #E6A23C;
background: #fdf6ec;
}
.status-error {
color: #F56C6C;
background: #fef0f0;
}
.error-message {
margin-top: 20px;
padding: 15px;
background: #fef0f0;
color: #F56C6C;
border-radius: 6px;
text-align: center;
font-size: 1.1em;
}
pre {
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
overflow-x: auto;
white-space: pre;
max-width: 100%;
}
pre code {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.9em;
color: #303133;
}
html.dark pre {
background: #303133;
}
html.dark pre code {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
color: #f8f9fa;
}
.badge-section {
.redirecting {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 20px;
align-items: center;
justify-content: center;
min-height: 50vh;
color: #999;
}
.badge-item {
background: #fff;
padding: 20px;
border-radius: 8px;
border: 1px solid #e4e7ed;
}
.badge-item h4 {
margin: 0 0 15px 0;
color: #3EAF7C;
font-size: 1.2em;
}
.badge-item img {
display: block;
margin-bottom: 15px;
max-width: 200px;
}
</style>
</style>
+22 -7
View File
@@ -57,6 +57,9 @@ interface ServerResult {
data: any
}
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
/** 预构建的服务器列表,页面挂载即展示,用户可提前看到测速节点 */
const serverResults = ref<ServerResult[]>([])
@@ -68,8 +71,8 @@ const serverResults = ref<ServerResult[]>([])
// =============================================
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + domain.value);
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + encodeURIComponent(domain.value));
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
@@ -78,8 +81,8 @@ const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
});
/** IPv6 专用服务器:仅支持 IPv6 的节点 */
const ipv6Fetches = config.SpeedTest.IPv6.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + domain.value);
const ipv6Fetches = config.APIBaseURL.IPv6.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + encodeURIComponent(domain.value));
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
@@ -116,6 +119,8 @@ async function SpeedTest() {
error.value = ''
await nextTick()
const id = ++queryId
// 重置所有行状态为加载中
serverResults.value.forEach((row) => {
row.loading = true
@@ -130,16 +135,26 @@ async function SpeedTest() {
try {
await fetch.execute();
row.data = fetch.data.value;
if (id !== queryId) return
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成空白)
if (fetch.error.value) {
row.error = fetch.error.value?.data?.error || fetch.error.value?.message || '请求失败';
} else {
row.data = fetch.data.value;
}
} catch (err: any) {
row.error = err?.data?.error || err?.message || '请求失败';
} finally {
row.loading = false;
if (id === queryId) {
row.loading = false;
}
}
});
Promise.all(promises).finally(() => {
loading.value = false
if (id === queryId) {
loading.value = false
}
})
}
+35 -23
View File
@@ -77,14 +77,16 @@ const port = ref('80')
const userIP = ref('')
const loading = ref(false)
const serverResults = ref<ServerResult[]>([])
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
const allServers = [
...config.TCPing.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
...config.TCPing.IPv6.map((s: ServerConfig) => ({ ...s, type: 'IPv6' }))
...config.APIBaseURL.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
...config.APIBaseURL.IPv6.map((s: ServerConfig) => ({ ...s, type: 'IPv6' }))
];
const tcpingFetches = allServers.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/tcping/" + extractHost(tmpDomain.value) + "?port=" + port.value);
const url = computed(() => "/middleware/" + server.id + "/tcping/" + encodeURIComponent(extractHost(tmpDomain.value)) + "?port=" + encodeURIComponent(port.value));
const { data, error: fetchError, execute } = useMiddlewareFetch<TCPingResponse>(url, {
immediate: false,
watch: false,
@@ -95,7 +97,7 @@ const tcpingFetches = allServers.map((server) => {
function initServerResults() {
const results: ServerResult[] = []
config.TCPing.DualStack.forEach((server: ServerConfig) => {
config.APIBaseURL.DualStack.forEach((server: ServerConfig) => {
results.push({
label: server.label,
loading: false,
@@ -103,7 +105,7 @@ function initServerResults() {
})
})
config.TCPing.IPv6.forEach((server: ServerConfig) => {
config.APIBaseURL.IPv6.forEach((server: ServerConfig) => {
results.push({
label: server.label,
loading: false,
@@ -117,23 +119,31 @@ function initServerResults() {
function TCPingAll() {
const host = extractHost(tmpDomain.value)
if (!host) return
loading.value = true
const id = ++queryId
serverResults.value.forEach((result) => {
result.loading = true
result.error = ''
result.ipv4 = undefined
result.ipv6 = undefined
})
const promises = tcpingFetches.map(async (fetch, index) => {
try {
await fetch.execute();
if (id !== queryId) return
const result = serverResults.value[index];
if (result) {
result.ipv4 = fetch.data.value?.ipv4;
result.ipv6 = fetch.data.value?.ipv6;
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成 '-')
if (fetch.error.value) {
result.error = (fetch.error.value as any)?.message || '请求失败';
} else {
result.ipv4 = fetch.data.value?.ipv4;
result.ipv6 = fetch.data.value?.ipv6;
}
}
} catch (err) {
console.error(err);
@@ -143,22 +153,24 @@ function TCPingAll() {
}
} finally {
const result = serverResults.value[index];
if (result) {
if (result && id === queryId) {
result.loading = false;
}
}
})
Promise.all(promises).finally(function () {
loading.value = false
if (id === queryId) {
loading.value = false
}
})
}
async function getUserIP(){
await $fetch<string>(config.DualStackAPI).then(
function (data){
userIP.value = data
})
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
} catch {
// 获取失败保持为空,避免未处理的 Promise 拒绝
}
return userIP.value
}
onMounted(() => {
@@ -221,13 +233,13 @@ onMounted(() => {
<td class="table-value">{{ server.ipv6.sent }}</td>
<td class="table-value">{{ server.ipv6.success }}</td>
<td class="table-value">
<span :class="server.ipv6.loss_rate > 0 ? 'loss-warning' : 'loss-ok'">
{{ server.ipv6.loss_rate.toFixed(1) }}
<span :class="(server.ipv6?.loss_rate ?? 0) > 0 ? 'loss-warning' : 'loss-ok'">
{{ server.ipv6?.loss_rate?.toFixed(1) ?? '-' }}
</span>
</td>
<td class="table-value">{{ server.ipv6.max_rtt.toFixed(2) }}</td>
<td class="table-value">{{ server.ipv6.min_rtt.toFixed(2) }}</td>
<td class="table-value">{{ server.ipv6.avg_rtt.toFixed(2) }}</td>
<td class="table-value">{{ server.ipv6?.max_rtt?.toFixed(2) ?? '-' }}</td>
<td class="table-value">{{ server.ipv6?.min_rtt?.toFixed(2) ?? '-' }}</td>
<td class="table-value">{{ server.ipv6?.avg_rtt?.toFixed(2) ?? '-' }}</td>
</tr>
<tr v-else-if="server.loading">
<td class="table-label">{{ server.label }}</td>
+10 -2
View File
@@ -63,7 +63,7 @@ interface PerformanceCheckItem {
download_speed: number
is_reachable: boolean
}
const apiList = config.apiBaseUrls
const apiList = config.APIBaseURL.DualStack
const currentApiIndex = ref(0)
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
const tmpDomain = ref('https://www.zakoflare.com')
@@ -72,6 +72,8 @@ const loading = ref(false)
const error = ref('')
const result = ref<PerformanceCheckResponse | null>(null)
const checkUrl = computed(() => "/middleware/" + backendID.value + "/detail/" + testDomain.value);
// testDomain 是完整 URL(含 https:// 与路径斜杠),服务端 [...slug].get.ts 依赖
// 分段重组协议部分,这里刻意不做 encodeURIComponent
const { data: checkData, error: checkError, execute: executeCheck } = useMiddlewareFetch<PerformanceCheckResponse>(checkUrl, {
immediate: false,
@@ -162,7 +164,7 @@ onMounted(() => {
<tr>
<td class="table-label">主机记录</td>
<td class="table-value"><div class="one-line"><RouterLink :to="`/ipv6?ip=${result.ipv4!.host_record}`">{{ result.ipv4!.host_record }}</RouterLink><el-icon><Position /></el-icon></div></td>
<td class="table-value"><div class="one-line"><RouterLink :to="`/ipv6?ip=${result.ipv6?.host_record}`">{{ result.ipv6?.host_record || '-' }}</RouterLink><el-icon><Position /></el-icon></div></td>
<td class="table-value"><div class="one-line"><RouterLink v-if="result.ipv6?.host_record" :to="`/ipv6?ip=${result.ipv6!.host_record}`">{{ result.ipv6!.host_record }}</RouterLink><span v-else>-</span><el-icon><Position /></el-icon></div></td>
</tr>
<tr>
<td class="table-label">HTTP 状态码</td>
@@ -275,6 +277,12 @@ onMounted(() => {
<h2>...</h2>
<el-image src="/jingya.jpg"></el-image>
</div>
<div v-else-if="result && result.ipv4 && result.ipv6 && !result.ipv4.is_reachable && result.ipv6.is_reachable">
<h3>结论:<CircleCloseFilled style="width: 1.3em;color: red;"/>网站{{ extractHost(testDomain) }} 不支持IPv4访问 </h3>
<h2>全球来看,越南、捷克等部分国家已提出了单栈发展目标。尽管我国网络IPv6流量占比大幅提升,但网络中仍存在大量的NAT(网络地址转换)设备和IPv4依赖应用。</h2>
<h2 style="text-align: right;">----中国《IPv6发展报告》2026</h2>
<el-image src="/jingya.jpg"></el-image>
</div>
<blockquote>
网站不支持 IPv6可能原因:<br/>
<br/>
+12 -5
View File
@@ -49,6 +49,8 @@ interface IPLocationType {
ip?: string;
geocn?: any;
ip2region?: any;
ip2location?: any;
ip2location_asn?: any;
maxmind_asn?: any;
maxmind_city?: any;
qqwry?: any;
@@ -67,14 +69,14 @@ curl ${config.v6OnlyAPI}
curl ${config.DualStackAPI}
`.trim();
const html = ref('');
const apiList = config.IPLocationAPIs
const apiList = config.IPLocationAPI
const currentApiIndex = ref(0)
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
const ipAddress = ref('');
const IPLocation = ref<IPLocationType>({});
const UserIP = ref('');
const error = ref('')
const locationUrl = computed(() => "/middleware/" + backendID.value + "/location/" + ipAddress.value);
const locationUrl = computed(() => "/middleware/" + backendID.value + "/location/" + encodeURIComponent(ipAddress.value));
const { data: locationData, error: locationError, execute: executeLocation } = useMiddlewareFetch<IPLocationType>(locationUrl, {
immediate: false,
@@ -182,10 +184,15 @@ onMounted(async () => {
</span>
</td>
</tr>
<tr v-if="IPLocation.ip2region && IPLocation.ip2region.split('|').length >= 4">
<tr v-if="IPLocation.ip2region && typeof IPLocation.ip2region === 'object' && (IPLocation.ip2region.administrative_area || IPLocation.ip2region.city)">
<td class="table-label">IP2Region</td>
<td class="table-value">{{ IPLocation.ip2region?.split("|")[0] }}&nbsp;{{ IPLocation.ip2region?.split("|")[1] }}&nbsp;{{ IPLocation.ip2region?.split("|")[2] }}</td>
<td class="table-value">{{ IPLocation.ip2region?.split("|")[3] }}</td>
<td class="table-value">{{ IPLocation.ip2region?.country }}&nbsp;{{ IPLocation.ip2region?.administrative_area }}&nbsp;{{ IPLocation.ip2region?.city }}</td>
<td class="table-value">{{ IPLocation.ip2region?.isp }}</td>
</tr>
<tr v-if="IPLocation.ip2location && typeof IPLocation.ip2location === 'object' && (IPLocation.ip2location.administrative_area || IPLocation.ip2location.city)">
<td class="table-label">IP2Location</td>
<td class="table-value">{{ IPLocation.ip2location?.country }}&nbsp;{{ IPLocation.ip2location?.administrative_area }}&nbsp;{{ IPLocation.ip2location?.city }}</td>
<td class="table-value">{{ IPLocation.ip2location_asn?.as }}</td>
</tr>
<tr v-if="IPLocation.geocn && (IPLocation.geocn.administrative_area || IPLocation.geocn.city || IPLocation.geocn.district)">
<td class="table-label">GeoCN(仅中国大陆)</td>
+9 -8
View File
@@ -32,17 +32,18 @@ let detectionImg: HTMLImageElement | null = null
let detectionTimeout: ReturnType<typeof setTimeout> | null = null
let currentRequestId = 0
const userIP = ref('')
// WordPress mshots default error image dimensions
const DEFAULT_MSHOTS_WIDTH = 400
const DEFAULT_MSHOTS_HEIGHT = 300
// WordPress mshots 默认占位图(生成失败/重定向目标)的尺寸是 1200x900,
// 与下方注释及判定逻辑一致;真实网站截图可以是任意尺寸
const DEFAULT_MSHOTS_WIDTH = 1200
const DEFAULT_MSHOTS_HEIGHT = 900
const LOADING_TIMEOUT = 15000 // 15 seconds
async function getUserIP(){
await $fetch<string>(config.DualStackAPI).then(
function (data){
userIP.value = data
})
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
} catch {
// 获取失败保持为空,避免未处理的 Promise 拒绝
}
return userIP.value
}
function cleanupDetection() {
+22 -7
View File
@@ -57,6 +57,9 @@ interface ServerResult {
data: any
}
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
/** 预构建的服务器列表,页面挂载即展示,用户可提前看到测速节点 */
const serverResults = ref<ServerResult[]>([])
@@ -68,8 +71,8 @@ const serverResults = ref<ServerResult[]>([])
// =============================================
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + domain.value);
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + encodeURIComponent(domain.value));
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
@@ -78,8 +81,8 @@ const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
});
/** IPv4 专用服务器:仅支持 IPv4 的节点 */
const ipv4Fetches = config.SpeedTest.IPv4.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + domain.value);
const ipv4Fetches = config.APIBaseURL.IPv4.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + encodeURIComponent(domain.value));
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
@@ -116,6 +119,8 @@ async function SpeedTest() {
error.value = ''
await nextTick()
const id = ++queryId
// 重置所有行状态为加载中
serverResults.value.forEach((row) => {
row.loading = true
@@ -130,16 +135,26 @@ async function SpeedTest() {
try {
await fetch.execute()
row.data = fetch.data.value
if (id !== queryId) return
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成空白)
if (fetch.error.value) {
row.error = fetch.error.value?.data?.error || fetch.error.value?.message || '请求失败'
} else {
row.data = fetch.data.value
}
} catch (err: any) {
row.error = err?.data?.error || err?.message || '请求失败'
} finally {
row.loading = false
if (id === queryId) {
row.loading = false
}
}
})
Promise.all(promises).finally(() => {
loading.value = false
if (id === queryId) {
loading.value = false
}
})
}
+2 -2
View File
@@ -65,7 +65,7 @@ interface SSLCheckItem {
is_expired: boolean
is_reachable: boolean
}
const apiList = config.apiBaseUrls
const apiList = config.APIBaseURL.DualStack
const currentApiIndex = ref(0)
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
const tmpDomain = ref('https://www.zakoflare.com')
@@ -73,7 +73,7 @@ const testDomain = ref('')
const loading = ref(false)
const error = ref('')
const result = ref<SSLCheckResponse | null>(null)
const sslUrl = computed(() => "/middleware/" + backendID.value + "/ssl/" + testDomain.value);
const sslUrl = computed(() => "/middleware/" + backendID.value + "/ssl/" + encodeURIComponent(testDomain.value));
const { data: sslData, error: sslError, execute: executeSSL } = useMiddlewareFetch<SSLCheckResponse>(sslUrl, {
immediate: false,
+30 -18
View File
@@ -76,14 +76,16 @@ const tmpDomain = ref('www.zakoflare.com')
const port = ref('80')
const loading = ref(false)
const serverResults = ref<ServerResult[]>([])
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
const allServers = [
...config.TCPing.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
...config.TCPing.IPv4.map((s: ServerConfig) => ({ ...s, type: 'IPv4' }))
...config.APIBaseURL.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
...config.APIBaseURL.IPv4.map((s: ServerConfig) => ({ ...s, type: 'IPv4' }))
];
const tcpingFetches = allServers.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/tcping/" + extractHost(tmpDomain.value) + "?port=" + port.value);
const url = computed(() => "/middleware/" + server.id + "/tcping/" + encodeURIComponent(extractHost(tmpDomain.value)) + "?port=" + encodeURIComponent(port.value));
const { data, error: fetchError, execute } = useMiddlewareFetch<TCPingResponse>(url, {
immediate: false,
watch: false,
@@ -94,7 +96,7 @@ const tcpingFetches = allServers.map((server) => {
function initServerResults() {
const results: ServerResult[] = []
config.TCPing.DualStack.forEach((server: ServerConfig) => {
config.APIBaseURL.DualStack.forEach((server: ServerConfig) => {
results.push({
label: server.label,
loading: false,
@@ -102,7 +104,7 @@ function initServerResults() {
})
})
config.TCPing.IPv4.forEach((server: ServerConfig) => {
config.APIBaseURL.IPv4.forEach((server: ServerConfig) => {
results.push({
label: server.label,
loading: false,
@@ -116,23 +118,31 @@ function initServerResults() {
function TCPingAll() {
const host = extractHost(tmpDomain.value)
if (!host) return
loading.value = true
const id = ++queryId
serverResults.value.forEach((result) => {
result.loading = true
result.error = ''
result.ipv4 = undefined
result.ipv6 = undefined
})
const promises = tcpingFetches.map(async (fetch, index) => {
try {
await fetch.execute();
if (id !== queryId) return
const result = serverResults.value[index];
if (result) {
result.ipv4 = fetch.data.value?.ipv4;
result.ipv6 = fetch.data.value?.ipv6;
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成 '-')
if (fetch.error.value) {
result.error = (fetch.error.value as any)?.message || '请求失败';
} else {
result.ipv4 = fetch.data.value?.ipv4;
result.ipv6 = fetch.data.value?.ipv6;
}
}
} catch (err) {
console.error(err);
@@ -142,14 +152,16 @@ function TCPingAll() {
}
} finally {
const result = serverResults.value[index];
if (result) {
if (result && id === queryId) {
result.loading = false;
}
}
})
Promise.all(promises).finally(function () {
loading.value = false
if (id === queryId) {
loading.value = false
}
})
}
@@ -212,13 +224,13 @@ onMounted(() => {
<td class="table-value">{{ server.ipv4?.sent }}</td>
<td class="table-value">{{ server.ipv4?.success }}</td>
<td class="table-value">
<span :class="server.ipv4.loss_rate > 0 ? 'loss-warning' : 'loss-ok'">
{{ server.ipv4.loss_rate.toFixed(1) }}
<span :class="(server.ipv4?.loss_rate ?? 0) > 0 ? 'loss-warning' : 'loss-ok'">
{{ server.ipv4?.loss_rate?.toFixed(1) ?? '-' }}
</span>
</td>
<td class="table-value">{{ server.ipv4?.max_rtt.toFixed(2) }}</td>
<td class="table-value">{{ server.ipv4?.min_rtt.toFixed(2) }}</td>
<td class="table-value">{{ server.ipv4?.avg_rtt.toFixed(2) }}</td>
<td class="table-value">{{ server.ipv4?.max_rtt?.toFixed(2) ?? '-' }}</td>
<td class="table-value">{{ server.ipv4?.min_rtt?.toFixed(2) ?? '-' }}</td>
<td class="table-value">{{ server.ipv4?.avg_rtt?.toFixed(2) ?? '-' }}</td>
</tr>
<tr v-else-if="server.loading">
<td class="table-label">{{ server.label }}</td>
+7 -7
View File
@@ -64,10 +64,10 @@ const result = ref<any>(null)
const error = ref('')
const userIP = ref('')
const apiList = config.apiBaseUrls
const apiList = config.APIBaseURL.DualStack
const currentApiIndex = ref(0)
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
const whoisUrl = computed(() => '/middleware/' + backendID.value + '/whois/' + domain.value)
const whoisUrl = computed(() => '/middleware/' + backendID.value + '/whois/' + encodeURIComponent(domain.value))
const { data: whoisData, error: whoisError, execute: executeWhois } = useMiddlewareFetch<any>(whoisUrl, {
immediate: false,
@@ -124,11 +124,11 @@ onMounted(() => {
})
async function getUserIP(){
await $fetch<string>(config.DualStackAPI).then(
function (data){
userIP.value = data
}
)
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
} catch {
// 获取失败保持为空,避免未处理的 Promise 拒绝
}
return userIP.value
}
+39 -251
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 本地转发)。
@@ -19,7 +19,7 @@ const config = {
// 未配置或为空数组时,直接使用前端自带中间件。
Middleware: <string[]>[
"https://middleware-1.api-ipw.wsmdn.top/"
// "http://127.0.0.1:8091/",
// "http://127.0.0.1:8092/",
// "https://middleware-2.wsmdn.top/",
],
// Umami 统计
@@ -27,37 +27,26 @@ const config = {
umamiScriptUrl: "https://umami.wsmdn.top/zako.js",
umamiWebsiteId: "69a91329-b110-4cf7-a04a-be4360b1a8d3",
// 中华人民共和国备案系统
ICP: "苏ICP备2026012471号",
GongAn: "苏公网安备32132402000813号",
ICP: "",
GongAn: "",
// 全站是否禁止搜索引擎索引
noindex: false,
// Worker IP查询接口
v4OnlyAPI: "https://4.wsmdn.top",
v6OnlyAPI: "https://6.wsmdn.top",
DualStackAPI: "https://test.wsmdn.top",
apiBaseUrls: [
// ---- 节点池结构 ----
// IPLocationAPI:IP 归属查询(location / asn)上游节点池(纯数组,无栈区分)
IPLocationAPI: [
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
},
{
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
id: "cn-shenzhen",
url: "https://cn-shenzhen.api-ipw.wsmdn.top/"
},
// IP 直连新节点:URL 留空,仅经独立中间件转发
{
label: "上海 腾讯云 BGP",
id: "tencent-sh",
label: "中国 湖北 武汉 电信",
id: "cn-wuhan-chinatelecom",
url: ""
}
],
IPLocationAPIs: [
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
},
{
label: "中国 四川 沙渠 电信[ZFC]",
@@ -65,283 +54,82 @@ const config = {
url: "https://cn2-sichuan.api-ipw.wsmdn.top/"
}
],
TCPing:{
DualStack: [
// APIBaseURL:其余拨测(whois / ssl / detail / dns / dnssec / tcping / speed)上游节点池,含 { IPv6, IPv4, DualStack } 三栈
APIBaseURL: {
IPv6: [
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
url :"https://cn-jiangsu.api-ipw.wsmdn.top/",
label: "中国 四川 沙渠 电信[ZFC]",
id: "cn2-sichuan",
url: "https://cn2-sichuan.api-ipw.wsmdn.top/"
},
{
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
id: "cn-shenzhen",
url :"https://cn-shenzhen.api-ipw.wsmdn.top/",
},
// IP 直连新节点:URL 留空,仅经独立中间件转发
{
label: "上海 腾讯云 BGP",
id: "tencent-sh",
url :"",
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
id: "lntl-cn-hk-kowloon",
url: "https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/"
},
],
IPv4: [
{
label: "中国 广东 广州 腾讯云",
id: "cn-guangzhou",
url :"https://cn-guangzhou.api-ipw.wsmdn.top/",
url: "https://cn-guangzhou.api-ipw.wsmdn.top/"
},
{
label: "新加坡 腾讯云",
id: "sg-1",
url :"https://sg-1.api-ipw.wsmdn.top/",
url: "https://sg-1.api-ipw.wsmdn.top/"
},
{
label: "中国 香港 新界 西贡区 将军澳 MICC[ZFC]",
id: "hk-shatin",
url :"https://china-hk-kowloon-shatindistrict-newcloud.ipwapi.zfap.wsmdn.top/",
url: "https://china-hk-kowloon-shatindistrict-newcloud.ipwapi.zfap.wsmdn.top/"
},
{
label: "中国 陕西 西安 北经济技术开发区 未央区凤城 中国电信[ZFC]",
id: "cn-xian",
url :"https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/",
url: "https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/"
},
{
label: "美国/加利福尼亚州/洛杉矶/蒙特雷帕克/USCD[ZFC]",
label: "美国/加利福尼亚州/洛杉矶/蒙特利奇帕克/USCD[ZFC]",
id: "us-la",
url :"https://america-california-losangeles.ipwapi.zfap.wsmdn.top/",
url: "https://america-california-losangeles.ipwapi.zfap.wsmdn.top/"
},
// IP 直连新节点:URL 留空,仅经独立中间件转发
{
label: "山东 枣庄 双线",
id: "zaozhuang",
url :"",
},
{
label: "湖北 十堰 电信",
id: "shiyan",
url :"",
},
{
label: "香港 VpsQuan",
id: "hongkong2",
url :"",
},
{
label: "北京 京东云 BGP",
id: "jdcloud",
url :"",
},
{
label: "陕西 西安二 电信",
id: "xian2",
url :"",
url: ""
},
{
label: "香港 Cogent",
id: "hongkong",
url :"",
url: ""
},
// WS 通道节点:URL 留空,拨测经中间件 WS 转发
{
label: "呼和浩特移动",
id: "2faa15e6-18a7-4355-b422-ca53277d8d77",
url: ""
}
],
IPv6: [
{
label: "中国 四川 沙渠 电信[ZFC]",
id: "cn2-sichuan",
url:"https://cn2-sichuan.api-ipw.wsmdn.top/",
},
{
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
id: "lntl-cn-hk-kowloon",
url:"https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/",
},
{
label: "中国 河北 秦皇岛 联通",
id: "cn-hebei-qinhuangdao",
url :"https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/",
}
]
},
SpeedTest:{
DualStack: [
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
url :"https://cn-jiangsu.api-ipw.wsmdn.top/",
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
},
{
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
id: "cn-shenzhen",
url :"https://cn-shenzhen.api-ipw.wsmdn.top/",
},
// IP 直连新节点:URL 留空,仅经独立中间件转发
{
label: "上海 腾讯云 BGP",
id: "tencent-sh",
url :"",
},
],
IPv4: [
{
label: "中国 广东 广州 腾讯云",
id: "cn-guangzhou",
url :"https://cn-guangzhou.api-ipw.wsmdn.top/",
url: "https://cn-shenzhen.api-ipw.wsmdn.top/"
},
{
label: "新加坡 腾讯云",
id: "sg-1",
url :"https://sg-1.api-ipw.wsmdn.top/",
label: "中国 湖北 武汉 电信",
id: "cn-wuhan-chinatelecom",
url: ""
},
{
label: "中国 香港 新界 西贡区 将军澳 MICC[ZFC]",
id: "hk-shatin",
url :"https://china-hk-kowloon-shatindistrict-newcloud.ipwapi.zfap.wsmdn.top/",
},
{
label: "中国 陕西 西安 北经济技术开发区 未央区凤城 中国电信[ZFC]",
id: "cn-xian",
url :"https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/",
},
{
label: "美国/加利福尼亚州/洛杉矶/蒙特雷帕克/USCD[ZFC]",
id: "us-la",
url :"https://america-california-losangeles.ipwapi.zfap.wsmdn.top/",
},
// IP 直连新节点:URL 留空,仅经独立中间件转发
{
label: "山东 枣庄 双线",
id: "zaozhuang",
url :"",
},
{
label: "湖北 十堰 电信",
id: "shiyan",
url :"",
},
{
label: "香港 VpsQuan",
id: "hongkong2",
url :"",
},
{
label: "北京 京东云 BGP",
id: "jdcloud",
url :"",
},
{
label: "陕西 西安二 电信",
id: "xian2",
url :"",
},
{
label: "香港 Cogent",
id: "hongkong",
url :"",
}
],
IPv6: [
{
label: "中国 四川 沙渠 电信[ZFC]",
id: "cn2-sichuan",
url:"https://cn2-sichuan.api-ipw.wsmdn.top/",
},
{
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
id: "lntl-cn-hk-kowloon",
url:"https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/",
},
{
label: "中国 河北 秦皇岛 联通",
id: "cn-hebei-qinhuangdao",
url :"https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/",
}
]
},
NSLookup:[
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
url :"https://cn-jiangsu.api-ipw.wsmdn.top/",
},
{
label: "中国 广州 腾讯云",
id: "cn-guangzhou",
url :"https://cn-guangzhou.api-ipw.wsmdn.top/",
},
{
label: "新加坡 腾讯云",
id: "sg-1",
url :"https://sg-1.api-ipw.wsmdn.top/",
},
{
label: "中国 四川 沙渠 电信[ZFC]",
id: "cn2-sichuan",
url:"https://cn2-sichuan.api-ipw.wsmdn.top/",
},
{
label: "中国 陕西 西安 北经济技术开发区 未央区凤城 中国电信[ZFC]",
id: "cn-xian",
url :"https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/",
},
{
label: "中国 香港 新界 西贡区 将军澳 MICC[ZFC]",
id: "hk-shatin",
url :"https://china-hk-kowloon-shatindistrict-newcloud.ipwapi.zfap.wsmdn.top/",
},
{
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
id: "lntl-cn-hk-kowloon",
url:"https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/",
},
{
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
id: "cn-shenzhen",
url :"https://cn-shenzhen.api-ipw.wsmdn.top/",
},
{
label: "美国/加利福尼亚州/洛杉矶/蒙特雷帕克/USCD[ZFC]",
id: "us-la",
url :"https://america-california-losangeles.ipwapi.zfap.wsmdn.top/",
},
// IP 直连新节点:URL 留空,仅经独立中间件转发
{
label: "上海 腾讯云 BGP",
id: "tencent-sh",
url :"",
},
{
label: "山东 枣庄 双线",
id: "zaozhuang",
url :"",
},
{
label: "湖北 十堰 电信",
id: "shiyan",
url :"",
},
{
label: "香港 VpsQuan",
id: "hongkong2",
url :"",
},
{
label: "北京 京东云 BGP",
id: "jdcloud",
url :"",
},
{
label: "陕西 西安二 电信",
id: "xian2",
url :"",
},
{
label: "香港 Cogent",
id: "hongkong",
url :"",
},
{
label: "中国 河北 秦皇岛 联通",
id: "cn-hebei-qinhuangdao",
url :"https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/",
}
]
}
}
export { config }
+162 -1760
View File
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,9 @@
import { defineEventHandler, createError } from 'h3'
const IANA_BOOTSTRAP_URL = 'https://data.iana.org/rdap/dns.json'
// bootstrap 数据基本不变,按进程缓存 24h,避免每个请求都重新下载
const BOOTSTRAP_TTL = 24 * 60 * 60 * 1000
let bootstrapCache: { map: Map<string, string>; at: number } | null = null
function encodeDomain(input: string): string {
try {
@@ -21,8 +24,12 @@ function getTLDCandidates(hostname: string): string[] {
}
async function fetchBootstrap(): Promise<Map<string, string>> {
if (bootstrapCache && Date.now() - bootstrapCache.at < BOOTSTRAP_TTL) {
return bootstrapCache.map
}
const res = await fetch(IANA_BOOTSTRAP_URL, {
headers: { accept: 'application/json' },
signal: AbortSignal.timeout(10_000),
})
if (!res.ok) {
throw createError({
@@ -42,6 +49,7 @@ async function fetchBootstrap(): Promise<Map<string, string>> {
map.set(String(tld), baseUrl)
}
}
bootstrapCache = { map, at: Date.now() }
return map
}
@@ -214,6 +222,7 @@ export default defineEventHandler(async (event) => {
const primaryRes = await fetch(rdapUrl, {
headers: { accept: 'application/rdap+json' },
signal: AbortSignal.timeout(15_000),
})
if (!primaryRes.ok) {
@@ -233,6 +242,7 @@ export default defineEventHandler(async (event) => {
try {
const thickRes = await fetch(relatedLink.href, {
headers: { accept: 'application/rdap+json' },
signal: AbortSignal.timeout(15_000),
})
if (thickRes.ok) {
thickData = await thickRes.json()
+149
View File
@@ -0,0 +1,149 @@
import { defineEventHandler, createError, setResponseStatus, setResponseHeader, getRequestHeader, getRequestIP } from 'h3'
import { config } from '../../../config/index'
import { useMiddlewareFetch } from '../../../utils/middleware'
// GET /ip/<ip> → text/plain 输出指定 IP 的归属地(支持 IPv4 / IPv6,如 /ip/2001:db8::1)
//
// IP: 2001:db8::1
// bilibili: 中国 江苏 南京 电信
// geocn: 江苏省 南京市 江宁区 电信 宽带
// ip2region: 中国 江苏 南京 电信
// maxmind_city: 中国 江苏 南京
// maxmind_asn: AS4134 中国电信
// qqwry: 中国 江苏省 南京市 电信
//
// 复用 utils/middleware.ts 的 useMiddlewareFetch():候选依次为 config.Middleware 外部中间层 + 前端自带中间件兜底,
// 仅当节点无法连接或上游 401/403/418/502 时切换;IPLocationAPI 配置了多个后端时逐个重试。
// ---- 简单限流(每 IP 每分钟次数上限,进程内内存计数;limit 0 表示不限流) ----
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
function checkRateLimit(ip: string, limit: number): boolean {
const now = Date.now()
const entry = rateLimitMap.get(ip)
if (!entry || entry.resetAt <= now) {
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 })
// 顺带清理过期条目,避免 Map 无限增长
if (rateLimitMap.size > 1000) {
for (const [k, v] of rateLimitMap) {
if (v.resetAt <= now) rateLimitMap.delete(k)
}
}
return true
}
entry.count++
return entry.count <= limit
}
// ---- 文本美化:把单个数据源的归属地对象拼成可读字符串 ----
function objectToText(obj: any): string {
const parts: string[] = []
// 位置字段(按国家 → 省/区域 → 城市 → 区县)
for (const k of ['country', 'administrative_area', 'region', 'city', 'district', 'province']) {
const v = obj[k]
if (typeof v === 'string' && v) parts.push(v)
}
// ASN 编号(如 "AS4134")
if (typeof obj.asn === 'string' && obj.asn) {
parts.push(obj.asn.startsWith('AS') ? obj.asn : `AS${obj.asn}`)
}
// 运营商 / 组织 / 类型
for (const k of ['isp', 'org', 'type']) {
const v = obj[k]
if (typeof v === 'string' && v) parts.push(v)
}
return [...new Set(parts)].join(' ')
}
// ---- 文本美化:多数据源逐行输出(跳过后端未加载的库) ----
function formatLocationText(data: any, ip: string): string {
const lines: string[] = [`IP: ${ip}`]
const sources: Array<[string, any]> = [
['bilibili', data?.bilibili],
['geocn', data?.geocn],
['ip2region', data?.ip2region],
['ip2location', data?.ip2location],
['maxmind_city', data?.maxmind_city],
['maxmind_asn', data?.maxmind_asn],
['dbip_city', data?.dbip_city],
['dbip_asn', data?.dbip_asn],
['qqwry', data?.qqwry],
]
for (const [name, value] of sources) {
if (value === undefined || value === null) continue
if (typeof value === 'string') {
// 纯字符串结果(error 等)原样输出,"not loaded" 跳过
if (value && value !== 'not loaded') {
lines.push(`${name}: ${value}`)
}
continue
}
if (typeof value === 'object') {
const text = objectToText(value)
if (text) lines.push(`${name}: ${text}`)
}
}
return lines.join('\n')
}
// ---- 核心:查询指定 IP 的归属地(IPLocationAPI 多后端逐个重试) ----
async function locate(event: any, ip: string) {
const backends = config.IPLocationAPI
if (!backends || backends.length === 0) {
throw createError({ statusCode: 500, statusMessage: 'No location backend configured' })
}
let lastError: any = null
for (const backend of backends) {
const path = `/middleware/${backend.id}/location/${ip}`
const { data, error, execute } = useMiddlewareFetch(path)
await execute()
if (error.value) {
lastError = error.value
console.error(`[ip] backend "${backend.label}" failed for ${ip}:`, error.value?.message || error.value)
continue
}
// 成功:location 响应必含 ip 字段;缺失视为失败(如误返回错误体),换下一个后端
const res = data.value
if (res && typeof res === 'object' && (res as any).ip) {
return formatLocationText(res, ip)
}
lastError = new Error('invalid location response')
}
// 全部后端失败:透传最后一次错误(text/plain)
const errStatus = lastError?.status ?? lastError?.statusCode ?? 502
const status = typeof errStatus === 'number' ? errStatus : 502
setResponseStatus(event, status)
return `Error ${status}: ${lastError?.message || 'Backend unreachable'}`
}
export default defineEventHandler(async (event) => {
// 0) 统一输出 text/plain(不是 JSON)
setResponseHeader(event, 'content-type', 'text/plain; charset=utf-8')
// 1) Origin / Referer 校验:跨域浏览器调用一律拒绝
const siteOrigin = new URL(config.siteUrl).origin
const origin = getRequestHeader(event, 'origin')
const referer = getRequestHeader(event, 'referer')
if (origin && origin !== siteOrigin) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden: cross-origin request' })
}
if (referer && referer !== siteOrigin && !referer.startsWith(siteOrigin + '/')) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden: invalid referer' })
}
// 2) 每 IP 每分钟限流(次数取 config.rateLimitPerMinute,0 表示不限流)
const rateLimit = config.rateLimitPerMinute || 0
if (rateLimit > 0 && !checkRateLimit(getRequestIP(event) || 'unknown', rateLimit)) {
throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
}
// 3) 取路径参数 IP
const ipParam: string = event.context.params?.ip as any
let ip = ipParam ? ipParam.trim() : ''
if (!ip) {
throw createError({ statusCode: 400, statusMessage: 'Missing IP parameter' })
}
return locate(event, ip)
})
+153
View File
@@ -0,0 +1,153 @@
import { defineEventHandler, getQuery, createError, setResponseStatus, setResponseHeader, getRequestHeader, getRequestIP } from 'h3'
import { config } from '../../../config/index'
import { useMiddlewareFetch } from '../../../utils/middleware'
// GET /ip → text/plain 输出 IP 归属地(?ip=<ip> 优先,否则查请求方 IP)
//
// IP: 1.2.3.4
// bilibili: 中国 江苏 南京 电信
// geocn: 江苏省 南京市 江宁区 电信 宽带
// ip2region: 中国 江苏 南京 电信
// maxmind_city: 中国 江苏 南京
// maxmind_asn: AS4134 中国电信
// qqwry: 中国 江苏省 南京市 电信
//
// 复用 utils/middleware.ts 的 useMiddlewareFetch():候选依次为 config.Middleware 外部中间层 + 前端自带中间件兜底,
// 仅当节点无法连接或上游 401/403/418/502 时切换;IPLocationAPI 配置了多个后端时逐个重试。
// ---- 简单限流(每 IP 每分钟次数上限,进程内内存计数;limit 0 表示不限流) ----
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
function checkRateLimit(ip: string, limit: number): boolean {
const now = Date.now()
const entry = rateLimitMap.get(ip)
if (!entry || entry.resetAt <= now) {
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 })
// 顺带清理过期条目,避免 Map 无限增长
if (rateLimitMap.size > 1000) {
for (const [k, v] of rateLimitMap) {
if (v.resetAt <= now) rateLimitMap.delete(k)
}
}
return true
}
entry.count++
return entry.count <= limit
}
// ---- 文本美化:把单个数据源的归属地对象拼成可读字符串 ----
function objectToText(obj: any): string {
const parts: string[] = []
// 位置字段(按国家 → 省/区域 → 城市 → 区县)
for (const k of ['country', 'administrative_area', 'region', 'city', 'district', 'province']) {
const v = obj[k]
if (typeof v === 'string' && v) parts.push(v)
}
// ASN 编号(如 "AS4134")
if (typeof obj.asn === 'string' && obj.asn) {
parts.push(obj.asn.startsWith('AS') ? obj.asn : `AS${obj.asn}`)
}
// 运营商 / 组织 / 类型
for (const k of ['isp', 'org', 'type']) {
const v = obj[k]
if (typeof v === 'string' && v) parts.push(v)
}
return [...new Set(parts)].join(' ')
}
// ---- 文本美化:多数据源逐行输出(跳过后端未加载的库) ----
function formatLocationText(data: any, ip: string): string {
const lines: string[] = [`IP: ${ip}`]
const sources: Array<[string, any]> = [
['bilibili', data?.bilibili],
['geocn', data?.geocn],
['ip2region', data?.ip2region],
['ip2location', data?.ip2location],
['maxmind_city', data?.maxmind_city],
['maxmind_asn', data?.maxmind_asn],
['dbip_city', data?.dbip_city],
['dbip_asn', data?.dbip_asn],
['qqwry', data?.qqwry],
]
for (const [name, value] of sources) {
if (value === undefined || value === null) continue
if (typeof value === 'string') {
// 纯字符串结果(error 等)原样输出,"not loaded" 跳过
if (value && value !== 'not loaded') {
lines.push(`${name}: ${value}`)
}
continue
}
if (typeof value === 'object') {
const text = objectToText(value)
if (text) lines.push(`${name}: ${text}`)
}
}
return lines.join('\n')
}
// ---- 核心:查询指定 IP 的归属地(IPLocationAPI 多后端逐个重试) ----
async function locate(event: any, ip: string) {
const backends = config.IPLocationAPI
if (!backends || backends.length === 0) {
throw createError({ statusCode: 500, statusMessage: 'No location backend configured' })
}
let lastError: any = null
for (const backend of backends) {
const path = `/middleware/${backend.id}/location/${ip}`
const { data, error, execute } = useMiddlewareFetch(path)
await execute()
if (error.value) {
lastError = error.value
console.error(`[ip] backend "${backend.label}" failed for ${ip}:`, error.value?.message || error.value)
continue
}
// 成功:location 响应必含 ip 字段;缺失视为失败(如误返回错误体),换下一个后端
const res = data.value
if (res && typeof res === 'object' && (res as any).ip) {
return formatLocationText(res, ip)
}
lastError = new Error('invalid location response')
}
// 全部后端失败:透传最后一次错误(text/plain)
const errStatus = lastError?.status ?? lastError?.statusCode ?? 502
const status = typeof errStatus === 'number' ? errStatus : 502
setResponseStatus(event, status)
return `Error ${status}: ${lastError?.message || 'Backend unreachable'}`
}
export default defineEventHandler(async (event) => {
// 0) 统一输出 text/plain(不是 JSON)
setResponseHeader(event, 'content-type', 'text/plain; charset=utf-8')
// 1) Origin / Referer 校验:跨域浏览器调用一律拒绝
const siteOrigin = new URL(config.siteUrl).origin
const origin = getRequestHeader(event, 'origin')
const referer = getRequestHeader(event, 'referer')
if (origin && origin !== siteOrigin) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden: cross-origin request' })
}
if (referer && referer !== siteOrigin && !referer.startsWith(siteOrigin + '/')) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden: invalid referer' })
}
// 2) 每 IP 每分钟限流(次数取 config.rateLimitPerMinute,0 表示不限流)
const rateLimit = config.rateLimitPerMinute || 0
if (rateLimit > 0 && !checkRateLimit(getRequestIP(event) || 'unknown', rateLimit)) {
throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
}
// 3) 确定要查询的 IP:?ip=<ip> 优先,否则查请求方 IP
const query = getQuery(event)
let ip = typeof query.ip === 'string' ? query.ip.trim() : ''
if (!ip) {
ip = getRequestIP(event, { xForwardedFor: true }) || ''
}
ip = ip.trim()
if (!ip) {
throw createError({ statusCode: 400, statusMessage: 'Unable to determine IP address' })
}
return locate(event, ip)
})
@@ -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 调用与命令行请求无法区分,仅靠下方限流约束。
@@ -71,10 +73,15 @@ export default defineEventHandler(async (event) => {
if (!backendID || !apiType || !raw) {
throw createError({ statusCode: 400, statusMessage: 'Missing parameters in slug' })
}
// 阻断路径穿越:raw 里的 ".." 会被上游 URL 规范化解析掉,
// 把请求(连同节点的 Authorization 头)带到 /v1 之外的任意路径
if (raw.split('/').some(seg => seg === '.' || seg === '..')) {
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>
@@ -85,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}`
@@ -93,30 +100,21 @@ 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) {
case 'whois':
apiBaseUrls = [...config.apiBaseUrls]
case 'ssl':
case 'detail':
apiBaseUrls = [...config.APIBaseURL.DualStack]
break
case 'dns':
apiBaseUrls = [...config.NSLookup]
case 'dnssec':
apiBaseUrls = [...config.APIBaseURL.DualStack, ...config.APIBaseURL.IPv4, ...config.APIBaseURL.IPv6]
break
case 'location':
apiBaseUrls = [...config.IPLocationAPIs]
break
case 'ssl':
apiBaseUrls = [...config.apiBaseUrls]
break
case 'asn':
apiBaseUrls = [...config.IPLocationAPIs]
break
case 'dnssec':
apiBaseUrls = [...config.NSLookup]
break
case 'detail':
apiBaseUrls = [...config.apiBaseUrls]
apiBaseUrls = [...config.IPLocationAPI]
break
default:
throw createError({ statusCode: 400, statusMessage: 'Invalid API type' })
@@ -126,8 +124,9 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: 'Invalid backend ID' })
}
let apiBaseUrl = map.get(backendID)
if (apiBaseUrl === undefined) {
throw createError({ statusCode: 400, statusMessage: 'API base URL not found for the given backend ID' })
// url 为空的节点无法转发(会变成对前端自身的请求),直接报 502 而不是浪费一次自请求
if (!apiBaseUrl) {
throw createError({ statusCode: 502, statusMessage: 'Backend URL not configured' })
}
let data: any = {}
@@ -137,6 +136,7 @@ export default defineEventHandler(async (event) => {
data = await $fetch(`${apiBaseUrl}v1/${apiType}/${raw}`, {
method: 'GET',
headers: authHeaders,
timeout: 15_000,
}).catch((error: any) => {
console.error(`Error fetching from ${apiBaseUrl}:`, error)
const errStatus = error?.status ?? error?.statusCode
@@ -151,28 +151,19 @@ export default defineEventHandler(async (event) => {
})
return data
}else if (apiType === 'tcping' || apiType === 'udping' || apiType === 'speed') {
let apiBaseUrls:Array<{ id: string, url: string ,label: string}> = []
let apiTypeConfig: any = {}
switch (apiType) {
case 'tcping':
apiTypeConfig = config.TCPing
apiBaseUrls = [...apiTypeConfig.DualStack, ...apiTypeConfig.IPv4, ...apiTypeConfig.IPv6]
break
case 'speed':
apiTypeConfig = config.SpeedTest
apiBaseUrls = [...apiTypeConfig.DualStack, ...apiTypeConfig.IPv4, ...apiTypeConfig.IPv6]
break
default:
throw createError({ statusCode: 400, statusMessage: 'Invalid API type' })
}
// tcping/udping/speed 统一走 APIBaseURL 节点池(平铺三栈)
const apiBaseUrls: Array<{ id: string, url: string ,label: string}> = [
...config.APIBaseURL.DualStack,
...config.APIBaseURL.IPv4,
...config.APIBaseURL.IPv6
]
const map = new Map<string, string>(apiBaseUrls.map(api => [api.id, api.url]))
if (!map.has(backendID)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid backend ID' })
}
let apiBaseUrl = map.get(backendID)
if (apiBaseUrl === undefined) {
throw createError({ statusCode: 400, statusMessage: 'API base URL not found for the given backend ID' })
if (!apiBaseUrl) {
throw createError({ statusCode: 502, statusMessage: 'Backend URL not configured' })
}
let data: any = {}
@@ -187,6 +178,7 @@ export default defineEventHandler(async (event) => {
data = await $fetch(`${apiBaseUrl}v1/${apiType}/${raw}${queryString ? '?' + queryString : ''}`, {
method: 'GET',
headers: authHeaders,
timeout: 30_000, // tcping/测速上游本身较慢,给更长超时
}).catch((error: any) => {
console.error(`Error fetching from ${apiBaseUrl}:`, error)
const errStatus = error?.status ?? error?.statusCode
+4 -1
View File
@@ -64,9 +64,12 @@ export function useMiddlewareFetch<T = any>(url: MaybeRefOrGetter<string>, optio
try {
for (let i = 0; i < candidates.value.length; i++) {
if (id !== requestId) return undefined // 已被更新的请求取代
const candidate = candidates.value[i]
// noUncheckedIndexedAccess 下索引访问为 string | undefined,防御性跳过
if (candidate === undefined) continue
try {
// $fetch 为 Nuxt 全局注入;候选失败会抛 FetchError(含 status)
const res = await $fetch<T>(candidates.value[i], fetchOptions)
const res = await $fetch<T>(candidate, fetchOptions)
if (id !== requestId) return undefined
data.value = res
return res
+8 -7
View File
@@ -5,21 +5,21 @@ export function extractHost(url: string): string {
return match?.groups?.host ?? url;
}
export function formatTime(ms: number): string {
if (ms == null || ms <= 0) return '-'
export function formatTime(ms?: number | null): string {
if (ms == null || typeof ms !== 'number' || !isFinite(ms) || ms <= 0) return '-'
if (ms < 1000) {
return `${ms.toFixed(2)} ms`
}
return `${(ms / 1000).toFixed(2)} s`
}
export function formatSpeed(speed: number): string {
if (speed == null) return '-'
export function formatSpeed(speed?: number | null): string {
if (speed == null || typeof speed !== 'number' || !isFinite(speed)) return '-'
return `${speed.toFixed(2)} KB/s`
}
export function formatSize(bytes: number): string {
if (bytes == null) return '-'
export function formatSize(bytes?: number | null): string {
if (bytes == null || typeof bytes !== 'number' || !isFinite(bytes)) return '-'
if (bytes < 1024) {
return `${bytes} B`
}
@@ -29,7 +29,8 @@ export function formatSize(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
}
export function getStatusCodeClass(code: number): string {
export function getStatusCodeClass(code?: number | null): string {
if (code == null || typeof code !== 'number') return 'status-error'
if (code >= 200 && code < 300) return 'status-success'
if (code >= 300 && code < 400) return 'status-warning'
return 'status-error'
+271
View File
@@ -0,0 +1,271 @@
#!/usr/bin/bash
# ============================================================
# 后端节点一键安装脚本
# - 自动检测架构并下载最新 release 二进制
# - 交互式输入配置(环境变量注入,无需 setting.json)
# - 生成并启用 systemd 守护进程
# 用法:sudo bash install.sh
# ============================================================
text="
██╗██████╗ ██╗ ██╗ ███╗ ██╗ ██████╗ ██████╗ ███████╗
██║██╔══██╗██║ ██║ ████╗ ██║██╔═══██╗██╔══██╗██╔════╝
██║██████╔╝██║ █╗ ██║█████╗██╔██╗ ██║██║ ██║██║ ██║█████╗
██║██╔═══╝ ██║███╗██║╚════╝██║╚██╗██║██║ ██║██║ ██║██╔══╝
██║██║ ╚███╔███╔╝ ██║ ╚████║╚██████╔╝██████╔╝███████╗
╚═╝╚═╝ ╚══╝╚══╝ ╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚══════╝
"
echo "$text"
set -e
# ---------- 前置检查 ----------
if [ "$(id -u)" -ne 0 ]; then
echo "错误:需要 root 权限(写入 /etc/systemd/system 与安装目录),请用 sudo 运行" >&2
exit 1
fi
if ! command -v systemctl >/dev/null 2>&1; then
echo "错误:未检测到 systemd(systemctl 不存在),无法创建守护进程" >&2
exit 1
fi
if ! command -v wget >/dev/null 2>&1 && ! command -v curl >/dev/null 2>&1; then
echo "错误:未找到 wget 或 curl,无法下载" >&2
exit 1
fi
# ---------- 架构检测 ----------
systemArch=$(uname -m)
case "$systemArch" in
x86_64) BINARY="lemonipw-linux-amd64" ;;
i386|i486|i586|i686) BINARY="lemonipw-linux-386" ;;
aarch64|arm64) BINARY="lemonipw-linux-arm64" ;;
armv7l|armv7hl) BINARY="lemonipw-linux-armv7" ;;
armv6l) BINARY="lemonipw-linux-armv6" ;;
loongarch64) BINARY="lemonipw-linux-loong64" ;;
*)
echo "错误:不支持的架构 '$systemArch'" >&2
exit 1
;;
esac
echo "检测到架构 $systemArch,将下载 $BINARY"
# ---------- 交互式配置输入 ----------
# 生成 UUID(优先 /proc,其次 uuidgen,最后 /dev/urandom)
gen_uuid() {
if [ -r /proc/sys/kernel/random/uuid ]; then
cat /proc/sys/kernel/random/uuid
elif command -v uuidgen >/dev/null 2>&1; then
uuidgen | tr 'A-Z' 'a-z'
else
od -An -N16 -tx1 /dev/urandom | tr -d ' \n' | sed 's/\(........\)\(....\)\(....\)\(....\)\(............\)/\1-\2-\3-\4-\5/'
fi
}
echo ""
echo "========================================"
echo " 配置后端节点(直接回车使用默认值)"
echo "========================================"
read -r -p "安装目录 [/opt/lemon-ipw]: " INSTALL_DIR
INSTALL_DIR=${INSTALL_DIR:-/opt/lemon-ipw}
read -r -p "监听端口 [8080]: " PORTS
PORTS=${PORTS:-8080}
read -r -p "单栈模式 SINGLE_STACK(留空=双栈,ipv4 或 ipv6): " SINGLE_STACK
read -r -p "访问令牌 access-token(留空=不启用鉴权): " ACCESS_TOKEN
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
read -r -p "启用 IP 数据库 ipdb(首次启动下载约 450MB)[Y/n]: " IPDB_CHOICE
case "${IPDB_CHOICE,,}" in
n|no) IPDB="false" ;;
*) IPDB="true" ;;
esac
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_KEY=""
WS_USED_DEFAULT=""
case "${WS_CHOICE,,}" in
y|yes)
read -r -p " 中间件 WS 完整地址(含 wss:// 前缀与 /ws 路径,如 wss://host:8092/ws;留空=贡献节点给柠檬 wss://boce-api.api-ipw.wsmdn.top/ws;逗号分隔多个将同时连接全部): " WS_URL
if [ -z "$WS_URL" ]; then
WS_URL="wss://boce-api.api-ipw.wsmdn.top/ws"
WS_USED_DEFAULT="true"
fi
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
;;
esac
echo ""
echo "--- 其他环境变量(可选)---"
echo "输入额外的 Environment= 项,每行一个(如 GH_PROXY=https://ghproxy.com/),空行结束:"
EXTRA_ENVS=""
while true; do
read -r -p " > " EXTRA
[ -z "$EXTRA" ] && break
if [ -z "$EXTRA_ENVS" ]; then
EXTRA_ENVS="$EXTRA"
else
EXTRA_ENVS="$EXTRA_ENVS
$EXTRA"
fi
done
# ---------- 汇总展示 ----------
echo ""
echo "========================================"
echo " 配置汇总"
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:-自动探测(系统 DNS)}"
echo "ipdb: $IPDB"
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
n|no) echo "已取消" ; exit 0 ;;
*) : ;;
esac
# ---------- 下载二进制 ----------
echo ""
echo "正在获取最新版本..."
if command -v curl >/dev/null 2>&1; then
LATEST_TAG=$(curl -s https://api.github.com/repos/nomdn/ipw-cn/releases/latest | grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4)
else
LATEST_TAG=$(wget -qO- https://api.github.com/repos/nomdn/ipw-cn/releases/latest | grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4)
fi
if [ -z "$LATEST_TAG" ]; then
echo "错误:无法获取最新版本号(检查网络或 GitHub API 速率限制)" >&2
exit 1
fi
echo "最新版本:$LATEST_TAG"
DOWNLOAD_URL="https://github.com/nomdn/ipw-cn/releases/download/$LATEST_TAG/$BINARY"
mkdir -p "$INSTALL_DIR"
echo "下载 $BINARY ..."
if command -v wget >/dev/null 2>&1; then
wget -q -O "$INSTALL_DIR/lemonipw" "$DOWNLOAD_URL"
else
# -f 让 404/4xx 直接失败,避免把 GitHub 的 "Not Found" 页面存成二进制
curl -fsSL -o "$INSTALL_DIR/lemonipw" "$DOWNLOAD_URL"
fi
chmod +x "$INSTALL_DIR/lemonipw"
echo "二进制已安装到 $INSTALL_DIR/lemonipw"
# ---------- 生成 systemd 服务 ----------
SERVICE_FILE="/etc/systemd/system/lemon-ipw.service"
# 收集环境变量(仅非空的写入,避免空值覆盖默认)
# 值里的双引号/反斜杠需要转义,否则会提前终止 systemd 的引号包裹、损坏 unit
ENV_LINES=""
add_env() {
local name="$1" value="$2"
[ -n "$value" ] || return 0
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
ENV_LINES="${ENV_LINES}Environment=\"${name}=${value}\"
"
}
add_env PORTS "$PORTS"
add_env SINGLE_STACK "$SINGLE_STACK"
add_env ACCESS_TOKEN "$ACCESS_TOKEN"
add_env DNS_SERVER "$DNS_SERVER"
add_env DNSSEC_DNS_SERVER "$DNSSEC_DNS_SERVER"
add_env IPDB "$IPDB"
add_env CORS "$CORS"
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"
if [ -n "$EXTRA_ENVS" ]; then
while IFS= read -r line; do
[ -n "$line" ] || continue
add_env "${line%%=*}" "${line#*=}"
done <<< "$EXTRA_ENVS"
fi
cat > "$SERVICE_FILE" << EOF
[Unit]
Description=Lemon IPW Backend Node
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory="${INSTALL_DIR}"
ExecStart="${INSTALL_DIR}/lemonipw"
${ENV_LINES}Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
echo "已生成服务文件 $SERVICE_FILE"
# ---------- 启动服务 ----------
systemctl daemon-reload
# restart 而非 enable --now:重跑安装改配置后旧进程必须重启才会生效
systemctl enable lemon-ipw
systemctl restart lemon-ipw
systemctl status lemon-ipw --no-pager | head -15
echo ""
echo "========================================"
echo " 安装完成"
echo "========================================"
echo "常用命令:"
echo " systemctl status lemon-ipw # 查看状态"
echo " journalctl -u lemon-ipw -f # 查看日志"
echo " systemctl restart lemon-ipw # 重启(改配置后)"
echo "验证:curl http://127.0.0.1:$PORTS/ 应返回 {\"status\":\"ok\"}"
if [ -n "$WS_URL" ]; then
echo ""
echo "WS 通道已启用,请把节点注册信息加入中间件 setting.json 的 ws-keys:"
echo " \"$NODE_ID\": \"$NODE_KEY\""
fi
if [ -n "$WS_USED_DEFAULT" ]; then
echo ""
echo "========================================"
echo "感谢您的贡献,请向 iduhih777@outlook.com 提交您的节点信息,谢谢!"
echo " 节点 id: $NODE_ID"
echo " 注册 key: $NODE_KEY"
echo " WS 地址: $WS_URL"
echo " 监听端口: $PORTS"
echo " 单栈模式: ${SINGLE_STACK:-双栈}"
echo " access-token: ${ACCESS_TOKEN:+已设置 (隐藏)}"
echo " CORS: ${CORS:-不限}"
echo " 地区-运营商 请自行填写"
echo "========================================"
fi
echo "========================================"
+17 -5
View File
@@ -1,13 +1,25 @@
FROM golang:1.26-alpine AS build
# 多架构构建:buildx 会注入 TARGETOS/TARGETARCH,构建阶段固定在构建机原生平台
# (--platform=$BUILDPLATFORM)交叉编译,目标架构无需 QEMU 模拟;未用 buildx 时
# TARGETOS/TARGETARCH 为空,Go 默认取宿主机平台,普通 docker build 仍然可用
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
ENV GO111MODULE=on \
CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64
GOOS=${TARGETOS} \
GOARCH=${TARGETARCH}
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY main.go ./
RUN go build -o main .
COPY *.go ./
# TARGETVARIANT 是 buildx 的 "v7"/"v6" 写法,Go 的 GOARM 只要数字;不映射时 GOARCH=arm 默认按 GOARM=6 编译
RUN set -eux; \
case "$TARGETVARIANT" in \
v7) export GOARM=7 ;; \
v6) export GOARM=6 ;; \
esac; \
go build -o main .
# 运行阶段
FROM alpine:latest
+227 -132
View File
@@ -8,8 +8,10 @@ import (
"net/http"
"net/url"
"os"
"runtime"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/spf13/viper"
@@ -21,7 +23,7 @@ import (
// ==================== 全局变量 ====================
// apiInfo 对应 setting.json apiBaseUrls 等数组中的 { label, id, url } 配置项
// apiInfo 对应 setting.json 节点池中的 { label, id, url } 配置项
type apiInfo struct {
Label string `json:"label"`
ID string `json:"id"`
@@ -49,7 +51,7 @@ func (w *wsFlag) UnmarshalJSON(b []byte) error {
// UseWS 判断该节点是否走 WS 通道
func (a *apiInfo) UseWS() bool { return a != nil && bool(a.WS) }
// stackConfig 对应 setting.json TCPing / SpeedTest
// stackConfig 对应节点池中的 { DualStack, IPv4, IPv6 } 三栈
type stackConfig struct {
DualStack []apiInfo `json:"DualStack"`
IPv4 []apiInfo `json:"IPv4"`
@@ -57,20 +59,24 @@ type stackConfig struct {
}
// middlewareConfig 仅用于解析 setting.json(JSON 键名统一用连接线,如 http-timeout-seconds)
// 节点池结构:APIBaseURL 含 {IPv6,IPv4,DualStack} 三栈;IPLocationAPI 为纯数组(无栈区分)。
// 原 apiBaseUrls/whois·ssl·detail、NSLookup/dns·dnssec、TCPing、SpeedTest 统一并入 APIBaseURL,
// 原 IPLocationAPI/location·asn 并入 IPLocationAPI。
type middlewareConfig struct {
Port stringOrNumber `json:"port"`
HTTPTimeoutSeconds int `json:"http-timeout-seconds"`
RateLimit *int `json:"rate-limit"` // 单 IP 每分钟限流次数;缺省=默认120,0=不限流
WSPort stringOrNumber `json:"ws-port"` // WS 服务端口:兼容 "8092" 与 8092;缺省=8092,"0"/0=关闭 WS 通道
WSPort stringOrNumber `json:"ws-port"` // WS 端口:兼容 "8092" 与 8092;缺省=8092,"0"/0=关闭 WS 通道
RemoteConfigURL string `json:"remote-config-url"`
RemoteIngoreConfig []string `json:"remote-ingore-config"` // 不被远端覆盖的配置项列表
RemoteIgnoreConfig []string `json:"remote-ignore-config"` // 不被远端覆盖的配置项列表
Cors string `json:"cors"`
APIBaseURLs []apiInfo `json:"apiBaseUrls"`
IPLocationAPIs []apiInfo `json:"IPLocationAPIs"`
TCPing stackConfig `json:"TCPing"`
SpeedTest stackConfig `json:"SpeedTest"`
NSLookup []apiInfo `json:"NSLookup"`
APIKeys map[string]string `json:"apiKeys"`
TrustedProxies string `json:"trusted-proxies"` // 可信代理 IP/CIDR(逗号分隔),c.IP() 仅信任这些来源的 X-Forwarded-For
GhProxy string `json:"gh-proxy"` // GitHub 代理前缀(OTA 下载加速),可空
Ota string `json:"ota"` // OTA 自更新开关,"true" 启用
APIBaseURL stackConfig `json:"api-base-url"` // whois/ssl/detail/dns/dnssec/tcping/speed 上游节点池
IPLocationAPI []apiInfo `json:"ip-location-api"` // location/asn 上游节点池(纯数组,无栈)
APIKeys map[string]string `json:"api-keys"` // HTTP 转发鉴权:backendID → token(注入 Authorization: Bearer)
WSKeys map[string]string `json:"ws-keys"` // WS 注册校验:nodeID → key(节点 register 的 key 须匹配)
}
// stringOrNumber 兼容 JSON 中的字符串与数字(如 "8080" 或 8080)
@@ -78,23 +84,31 @@ type stringOrNumber string
// 全局配置变量,由 readConfig 从 setting.json 填充
var (
VERSION string // 版本号(构建时由 ldflags 注入)
COMMIT string // 提交哈希(构建时由 ldflags 注入)
BUILD_TIME string // 构建时间(构建时由 ldflags 注入)
PORT string // 监听端口
HTTP_TIMEOUT int // 上游请求超时(秒)
CORS string // CORS 配置原始字符串(逗号分隔的允许域名,可空)
ACCEPT_DOMAINS []string // 允许跨域的域名列表(由 CORS 拆分而来,空则允许所有)
API_BASE_URLS []apiInfo // whois/ssl/detail 上游列表
IP_LOCATION_APIS []apiInfo // location/asn 上游列表
TCPING stackConfig // tcping 上游列表(DualStack/IPv4/IPv6)
SPEED_TEST stackConfig // speed 上游列表(DualStack/IPv4/IPv6)
NS_LOOKUP []apiInfo // dns/dnssec 上游列表
API_KEYS map[string]string // backendID → token
CONFIG_SOURCE string // 配置文件路径
RATE_LIMIT int // 单 IP 每分钟限流次数(0 表示不限流),默认 120
WS_PORT int // WS 服务端口(0 = 关闭),缺省 8092,由 readConfig 统一解析
REMOTE_INGORE_CONFIG []string // 不被远端覆盖的配置项列表(remote-ingore-config / REMOTE_INGORE_CONFIG)
VERSION string // 版本号(构建时由 ldflags 注入)
COMMIT string // 提交哈希(构建时由 ldflags 注入)
BUILD_TIME string // 构建时间(构建时由 ldflags 注入)
PORT string // 监听端口
HTTP_TIMEOUT int // 上游请求超时(秒)
CORS string // CORS 配置原始字符串(逗号分隔的允许域名,可空)
ACCEPT_DOMAINS []string // 允许跨域的域名列表(由 CORS 拆分而来,空则允许所有)
API_BASE_URLS stackConfig // whois/ssl/detail/dns/dnssec/tcping/speed 上游节点池(DualStack/IPv4/IPv6)
IP_LOCATION_APIS []apiInfo // location/asn 上游节点池(纯数组,无栈)
API_KEYS map[string]string // backendID → HTTP 转发 token(注入 Authorization: Bearer)
WS_KEYS map[string]string // backendID → WS 注册校验 key
CONFIG_SOURCE string // 配置文件路径
RATE_LIMIT int // 单 IP 每分钟限流次数(0 表示不限流),默认 120
TRUSTED_PROXIES string // 可信代理 IP/CIDR(逗号分隔),供 c.IP() 判定,空 = 不信任 XFF
GH_PROXY string // GitHub 代理前缀(gh-proxy / GH_PROXY),OTA 下载用,可空
OTA string // OTA 自更新开关(ota / OTA),"true" 时启用
WS_PORT int // WS 服务端口(0 = 关闭),缺省 8092,由 readConfig 统一解析
REMOTE_IGNORE_CONFIG []string // 不被远端覆盖的配置项列表(remote-ignore-config / REMOTE_IGNORE_CONFIG)
)
// OTA 优雅交接支持(Windows 路径):见 ota.go
var (
fiberApp *fiber.App // 显式持有的 Fiber 应用(gracefulShutdown 需要)
otaHandoverWait chan struct{} // 非 nil 表示 Windows OTA 优雅重启模式:Listen 关闭后 main 停在此等待老进程退出
otaShutdownStarted atomic.Bool // gracefulShutdown 已启动(Listen 退出时区分绑定失败与优雅关闭)
)
// HTTP_CLIENT 用于转发上游请求(等价于 TS 中的 $fetch),超时由 HTTP_TIMEOUT 决定
@@ -106,6 +120,18 @@ var wsSrv *wsServer
// ==================== 工具函数 ====================
// splitAndTrim 按逗号切分并去掉每段首尾空白与空段(对齐根 main.go 的同名函数)
func splitAndTrim(s, sep string) []string {
parts := strings.Split(s, sep)
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// envJSON 从环境变量读取 JSON 字符串并反序列化到 target(用于数组/对象等映射表类配置项)。
// 环境变量为空时不做任何事;解析失败返回错误,避免静默使用空配置。
func envJSON(key string, target any) error {
@@ -174,6 +200,20 @@ func findNode(list []apiInfo, id string) *apiInfo {
return nil
}
// flattenStack 将节点池三栈展开为一个节点列表(DualStack + IPv4 + IPv6)
func flattenStack(s stackConfig) []apiInfo {
out := make([]apiInfo, 0, len(s.DualStack)+len(s.IPv4)+len(s.IPv6))
out = append(out, s.DualStack...)
out = append(out, s.IPv4...)
out = append(out, s.IPv6...)
return out
}
// stackNonEmpty 判断节点池三栈是否至少有一项
func stackNonEmpty(s stackConfig) bool {
return len(s.DualStack) > 0 || len(s.IPv4) > 0 || len(s.IPv6) > 0
}
func keysOf(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
@@ -224,12 +264,12 @@ func forwardUpstream(c fiber.Ctx, apiBaseUrl string, target string, headers map[
}
// lookupAPIKey 查找 backendID 对应的 token。
// 优先级: setting.json apiKeys > 环境变量 APIKEYS (JSON 字符串)
// 优先级: setting.json api-keys > 环境变量 API_KEYS (JSON 字符串)
func lookupAPIKey(backendID string) string {
var apiKeys map[string]string
if len(API_KEYS) > 0 {
apiKeys = API_KEYS
} else if raw := os.Getenv("APIKEYS"); raw != "" {
} else if raw := os.Getenv("API_KEYS"); raw != "" {
if err := json.Unmarshal([]byte(raw), &apiKeys); err != nil {
return ""
}
@@ -242,6 +282,26 @@ func lookupAPIKey(backendID string) string {
return apiKey
}
// lookupWSKey 查找 nodeID 对应的 WS 注册校验 key。
// 优先级: setting.json ws-keys > 环境变量 WS_KEYS (JSON 字符串)
// 与 lookupAPIKey 相互独立:api-keys 用于 HTTP 转发鉴权,ws-keys 用于 WS 注册校验。
func lookupWSKey(nodeID string) string {
var wsKeys map[string]string
if len(WS_KEYS) > 0 {
wsKeys = WS_KEYS
} else if raw := os.Getenv("WS_KEYS"); raw != "" {
if err := json.Unmarshal([]byte(raw), &wsKeys); err != nil {
return ""
}
} else {
return ""
}
wsKey := wsKeys[nodeID]
return wsKey
}
// ==================== 业务接口 ====================
// middlewareHandler 对应前端 server/routes/middleware/[...slug].get.ts
@@ -288,7 +348,7 @@ func middlewareHandler(c fiber.Ctx) error {
query := c.Queries()
// 从 API_KEYS 查找 token,未配置时回退到环境变量 APIKEYS (JSON 字符串)
// 从 API_KEYS 查找 token,未配置时回退到环境变量 API_KEYS (JSON 字符串)
apiKey := lookupAPIKey(backendID)
// 构造转发给上游的请求头:不透传客户端 Origin,避免上游 CORS 误判
@@ -299,14 +359,13 @@ func middlewareHandler(c fiber.Ctx) error {
switch apiType {
case "whois", "dns", "location", "ssl", "asn", "dnssec", "detail":
// 节点池结构:location/asn 走 IPLocationAPI(纯数组,无栈),其余走 APIBaseURL(三栈平铺)
var apiBaseUrls []apiInfo
switch apiType {
case "whois", "ssl", "detail":
apiBaseUrls = API_BASE_URLS
case "dns", "dnssec":
apiBaseUrls = NS_LOOKUP
case "location", "asn":
apiBaseUrls = IP_LOCATION_APIS
default:
apiBaseUrls = flattenStack(API_BASE_URLS)
}
node := findNode(apiBaseUrls, backendID)
@@ -314,12 +373,17 @@ func middlewareHandler(c fiber.Ctx) error {
return badRequest(c, "Invalid backend ID")
}
// ws:true 节点:拨测请求经 WS 通道转发(数据上传走 WS),失败返回 502
if node.UseWS() && wsSrv != nil {
if node.UseWS() {
if wsSrv == nil {
// 不再静默落回空 URL 的 HTTP 转发(必然 502 且掩盖真实配置错误)
return c.Status(fiber.StatusBadGateway).SendString("Node " + backendID + " is WS-only but WS channel is disabled (ws-port=0 or bind failed)")
}
status, body, err := wsSrv.RequestProbe(backendID, apiType, raw, nil, wsProbeTimeout())
if err != nil {
return c.Status(fiber.StatusBadGateway).SendString("WS probe failed: " + err.Error())
}
return c.Status(status).Send(body)
// 节点上报的 body 为 JSON,显式指定类型(fiber 默认 text/plain)
return c.Status(status).Type("json").Send(body)
}
apiBaseUrl := node.URL
if !strings.HasSuffix(apiBaseUrl, "/") {
@@ -330,20 +394,8 @@ func middlewareHandler(c fiber.Ctx) error {
return forwardUpstream(c, apiBaseUrl, apiBaseUrl+"v1/"+apiType+"/"+raw, authHeaders, nil)
case "tcping", "udping", "speed":
var apiTypeConfig stackConfig
switch apiType {
case "tcping":
apiTypeConfig = TCPING
case "speed":
apiTypeConfig = SPEED_TEST
default:
return badRequest(c, "Invalid API type")
}
apiBaseUrls := make([]apiInfo, 0, len(apiTypeConfig.DualStack)+len(apiTypeConfig.IPv4)+len(apiTypeConfig.IPv6))
apiBaseUrls = append(apiBaseUrls, apiTypeConfig.DualStack...)
apiBaseUrls = append(apiBaseUrls, apiTypeConfig.IPv4...)
apiBaseUrls = append(apiBaseUrls, apiTypeConfig.IPv6...)
// tcping/udping/speed 统一走 APIBaseURL 节点池
apiBaseUrls := flattenStack(API_BASE_URLS)
// 转发 query 参数(过滤空值,等价于 TS 中 URLSearchParams 过滤 undefined)
queryString := url.Values{}
@@ -360,12 +412,16 @@ func middlewareHandler(c fiber.Ctx) error {
return badRequest(c, "Invalid backend ID")
}
// ws:true 节点:拨测请求经 WS 通道转发
if node.UseWS() && wsSrv != nil {
if node.UseWS() {
if wsSrv == nil {
return c.Status(fiber.StatusBadGateway).SendString("Node " + backendID + " is WS-only but WS channel is disabled (ws-port=0 or bind failed)")
}
status, body, err := wsSrv.RequestProbe(backendID, apiType, raw, queryMap, wsProbeTimeout())
if err != nil {
return c.Status(fiber.StatusBadGateway).SendString("WS probe failed: " + err.Error())
}
return c.Status(status).Send(body)
// 节点上报的 body 为 JSON,显式指定类型(fiber 默认 text/plain)
return c.Status(status).Type("json").Send(body)
}
apiBaseUrl := node.URL
if !strings.HasSuffix(apiBaseUrl, "/") {
@@ -396,7 +452,8 @@ func viperValue(key string, target any) error {
}
// fetchRemoteConfig 从远端 URL 拉取配置内容(middleware setting.json 格式)
func fetchRemoteConfig(url string) ([]byte, error) { client := &http.Client{Timeout: 10 * time.Second}
func fetchRemoteConfig(url string) ([]byte, error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, err
@@ -431,7 +488,7 @@ func applyRemoteConfig(mw *middlewareConfig) error {
}
// ignore 列表:数组中的配置项不被远端覆盖(逐键判断跳过)
ignored := func(key string) bool {
for _, k := range mw.RemoteIngoreConfig {
for _, k := range mw.RemoteIgnoreConfig {
if k == key {
return true
}
@@ -456,22 +513,23 @@ func applyRemoteConfig(mw *middlewareConfig) error {
if !ignored("cors") && remote.Cors != "" {
mw.Cors = remote.Cors
}
if !ignored("apiBaseUrls") && len(remote.APIBaseURLs) > 0 {
mw.APIBaseURLs = remote.APIBaseURLs
if !ignored("trusted-proxies") && remote.TrustedProxies != "" {
mw.TrustedProxies = remote.TrustedProxies
}
if !ignored("IPLocationAPIs") && len(remote.IPLocationAPIs) > 0 {
mw.IPLocationAPIs = remote.IPLocationAPIs
if !ignored("gh-proxy") && remote.GhProxy != "" {
mw.GhProxy = remote.GhProxy
}
if !ignored("TCPing") && (len(remote.TCPing.DualStack) > 0 || len(remote.TCPing.IPv4) > 0 || len(remote.TCPing.IPv6) > 0) {
mw.TCPing = remote.TCPing
if !ignored("ota") && remote.Ota != "" {
mw.Ota = remote.Ota
}
if !ignored("SpeedTest") && (len(remote.SpeedTest.DualStack) > 0 || len(remote.SpeedTest.IPv4) > 0 || len(remote.SpeedTest.IPv6) > 0) {
mw.SpeedTest = remote.SpeedTest
// 节点池结构:api-base-url 与 ip-location-api(远端非空栈时覆盖本地)
if !ignored("api-base-url") && stackNonEmpty(remote.APIBaseURL) {
mw.APIBaseURL = remote.APIBaseURL
}
if !ignored("NSLookup") && len(remote.NSLookup) > 0 {
mw.NSLookup = remote.NSLookup
if !ignored("ip-location-api") && len(remote.IPLocationAPI) > 0 {
mw.IPLocationAPI = remote.IPLocationAPI
}
// apiKeys 强制忽略:密钥凭据不随远端配置覆盖(与后端 access_token 一致),只从本地 setting.json / env 读取
// api-keys / ws-keys 强制忽略:密钥凭据不随远端配置覆盖(与后端 access-token 一致),只从本地 setting.json / env 读取
log.Printf("[middleware] remote config applied from %s", url)
return nil
}
@@ -518,6 +576,24 @@ func readConfig() error {
} else {
mw.Cors = viper.GetString("cors")
}
// trusted-proxies:可信代理 IP/CIDR(逗号分隔),env > setting.json
if v := os.Getenv("TRUSTED_PROXIES"); v != "" {
mw.TrustedProxies = v
} else {
mw.TrustedProxies = viper.GetString("trusted-proxies")
}
// gh-proxy:GitHub 代理前缀(OTA 下载用),env > setting.json
if v := os.Getenv("GH_PROXY"); v != "" {
mw.GhProxy = v
} else {
mw.GhProxy = viper.GetString("gh-proxy")
}
// ota:OTA 自更新开关("true" 启用),env > setting.json
if v := os.Getenv("OTA"); v != "" {
mw.Ota = v
} else {
mw.Ota = viper.GetString("ota")
}
if v := os.Getenv("REMOTE_CONFIG_URL"); v != "" {
mw.RemoteConfigURL = v
} else {
@@ -540,64 +616,49 @@ func readConfig() error {
} else if viper.IsSet("ws-port") {
mw.WSPort = stringOrNumber(viper.GetString("ws-port"))
}
// remote-ingore-config:不被远端覆盖的配置项列表(env JSON 数组 > setting.json 数组)
if raw := os.Getenv("REMOTE_INGORE_CONFIG"); raw != "" {
// remote-ignore-config:不被远端覆盖的配置项列表(env JSON 数组 > setting.json 数组)
if raw := os.Getenv("REMOTE_IGNORE_CONFIG"); raw != "" {
var list []string
if err := json.Unmarshal([]byte(raw), &list); err != nil {
return fmt.Errorf("parse env REMOTE_INGORE_CONFIG: %w", err)
return fmt.Errorf("parse env REMOTE_IGNORE_CONFIG: %w", err)
}
mw.RemoteIngoreConfig = list
mw.RemoteIgnoreConfig = list
} else {
mw.RemoteIngoreConfig = viper.GetStringSlice("remote-ingore-config")
mw.RemoteIgnoreConfig = viper.GetStringSlice("remote-ignore-config")
}
// 数组/对象类配置:先 Getenv(JSON 字符串),再 viper.Get(经 JSON 中转反序列化到 typed 结构)
if err := envJSON("API_BASE_URLS", &mw.APIBaseURLs); err != nil {
// 节点池结构:api-base-url 与 ip-location-api(env JSON > setting.json)
if err := envJSON("API_BASE_URLS", &mw.APIBaseURL); err != nil {
return err
}
if len(mw.APIBaseURLs) == 0 {
if err := viperValue("apiBaseUrls", &mw.APIBaseURLs); err != nil {
return fmt.Errorf("parse apiBaseUrls: %w", err)
if !stackNonEmpty(mw.APIBaseURL) && viper.Get("api-base-url") != nil {
if err := viperValue("api-base-url", &mw.APIBaseURL); err != nil {
return fmt.Errorf("parse api-base-url: %w", err)
}
}
if err := envJSON("IP_LOCATION_APIS", &mw.IPLocationAPIs); err != nil {
if err := envJSON("IP_LOCATION_APIS", &mw.IPLocationAPI); err != nil {
return err
}
if len(mw.IPLocationAPIs) == 0 {
if err := viperValue("IPLocationAPIs", &mw.IPLocationAPIs); err != nil {
return fmt.Errorf("parse IPLocationAPIs: %w", err)
if len(mw.IPLocationAPI) == 0 && viper.Get("ip-location-api") != nil {
if err := viperValue("ip-location-api", &mw.IPLocationAPI); err != nil {
return fmt.Errorf("parse ip-location-api: %w", err)
}
}
if err := envJSON("TCPING", &mw.TCPing); err != nil {
return err
}
if viper.Get("TCPing") != nil && len(mw.TCPing.DualStack)+len(mw.TCPing.IPv4)+len(mw.TCPing.IPv6) == 0 {
if err := viperValue("TCPing", &mw.TCPing); err != nil {
return fmt.Errorf("parse TCPing: %w", err)
}
}
if err := envJSON("SPEED_TEST", &mw.SpeedTest); err != nil {
return err
}
if viper.Get("SpeedTest") != nil && len(mw.SpeedTest.DualStack)+len(mw.SpeedTest.IPv4)+len(mw.SpeedTest.IPv6) == 0 {
if err := viperValue("SpeedTest", &mw.SpeedTest); err != nil {
return fmt.Errorf("parse SpeedTest: %w", err)
}
}
if err := envJSON("NS_LOOKUP", &mw.NSLookup); err != nil {
return err
}
if len(mw.NSLookup) == 0 {
if err := viperValue("NSLookup", &mw.NSLookup); err != nil {
return fmt.Errorf("parse NSLookup: %w", err)
}
}
if err := envJSON("APIKEYS", &mw.APIKeys); err != nil {
if err := envJSON("API_KEYS", &mw.APIKeys); err != nil {
return err
}
if len(mw.APIKeys) == 0 {
if err := viperValue("apiKeys", &mw.APIKeys); err != nil {
return fmt.Errorf("parse apiKeys: %w", err)
if err := viperValue("api-keys", &mw.APIKeys); err != nil {
return fmt.Errorf("parse api-keys: %w", err)
}
}
// ws-keys:WS 注册校验表(nodeID → key),优先级 env WS_KEYS > setting.json ws-keys,与 api-keys 相互独立
if err := envJSON("WS_KEYS", &mw.WSKeys); err != nil {
return err
}
if len(mw.WSKeys) == 0 {
if err := viperValue("ws-keys", &mw.WSKeys); err != nil {
return fmt.Errorf("parse ws-keys: %w", err)
}
}
@@ -607,11 +668,8 @@ func readConfig() error {
}
// 校验配置存在(防止在错误的文件上静默空跑)
hasEndpoints := len(mw.APIBaseURLs) > 0 || len(mw.NSLookup) > 0 || len(mw.IPLocationAPIs) > 0 ||
len(mw.TCPing.DualStack) > 0 || len(mw.TCPing.IPv4) > 0 || len(mw.TCPing.IPv6) > 0 ||
len(mw.SpeedTest.DualStack) > 0 || len(mw.SpeedTest.IPv4) > 0 || len(mw.SpeedTest.IPv6) > 0
if !hasEndpoints {
return fmt.Errorf("invalid config in %s: missing endpoint lists (expected flat middleware config)", path)
if !stackNonEmpty(mw.APIBaseURL) && len(mw.IPLocationAPI) == 0 {
return fmt.Errorf("invalid config in %s: missing endpoint pools (expected api-base-url / ip-location-api node pools)", path)
}
// 将配置内容写入全局变量
@@ -633,18 +691,20 @@ func readConfig() error {
}
WS_PORT = n
}
REMOTE_INGORE_CONFIG = mw.RemoteIngoreConfig
REMOTE_IGNORE_CONFIG = mw.RemoteIgnoreConfig
CORS = mw.Cors
// 逗号分隔的 CORS 域名列表(对齐根 main.go 的 ACCEPT_DOMAINS 用法)
// 逗号分隔的 CORS 域名列表(对齐根 main.go 的 ACCEPT_DOMAINS 用法);
// 带空格的配置 "a.com, b.com" 直接 Split 会产生带前导空格的 origin,永远匹配不上,须 trim
if CORS != "" {
ACCEPT_DOMAINS = strings.Split(CORS, ",")
ACCEPT_DOMAINS = splitAndTrim(CORS, ",")
}
API_BASE_URLS = mw.APIBaseURLs
IP_LOCATION_APIS = mw.IPLocationAPIs
TCPING = mw.TCPing
SPEED_TEST = mw.SpeedTest
NS_LOOKUP = mw.NSLookup
TRUSTED_PROXIES = mw.TrustedProxies
GH_PROXY = mw.GhProxy
OTA = mw.Ota
API_BASE_URLS = mw.APIBaseURL
IP_LOCATION_APIS = mw.IPLocationAPI
API_KEYS = mw.APIKeys
WS_KEYS = mw.WSKeys
CONFIG_SOURCE = viper.ConfigFileUsed()
return nil
}
@@ -668,6 +728,9 @@ func main() {
}
log.Printf("[middleware] config loaded from %s", CONFIG_SOURCE)
// OTA 自更新:定期检查 GitHub Release 并替换二进制(配置 ota / OTA 启用)
initOTA(GH_PROXY)
timeout := HTTP_TIMEOUT
if timeout <= 0 {
timeout = 30
@@ -677,7 +740,7 @@ func main() {
// WS 服务端(端口由配置 wsPort / 环境变量 WS_PORT 决定,缺省 8092,0 = 关闭;统一走 readConfig)。
// 后端节点作为 WS 客户端连入;节点配置 "ws": true 时拨测请求改走 WS 通道。现有 HTTP 路由不受影响。
if WS_PORT > 0 {
wsSrv = newWSServer(handleWSCommand)
wsSrv = newWSServer()
go wsSrv.Start(fmt.Sprintf(":%d", WS_PORT))
go wsSrv.maintenanceLoop()
log.Printf("[ws] ws channel enabled on port %d (node flag \"ws\": true to use)", WS_PORT)
@@ -685,8 +748,24 @@ func main() {
log.Printf("[ws] ws channel disabled (wsPort=0)")
}
app := fiber.New()
// TRUSTED_PROXIES / trusted-proxies:逗号分隔的可信代理 IP/CIDR。配置后 c.IP()(限流按它分桶)
// 只信任这些来源转发的 X-Forwarded-For,反代/CDN 后限流才按真实客户端 IP 计数;
// 未配置保持默认(不信任 XFF 头)
fiberConf := fiber.Config{}
if strings.TrimSpace(TRUSTED_PROXIES) != "" {
var proxies []string
for _, p := range strings.Split(TRUSTED_PROXIES, ",") {
if p = strings.TrimSpace(p); p != "" {
proxies = append(proxies, p)
}
}
fiberConf.TrustProxy = true
fiberConf.TrustProxyConfig = fiber.TrustProxyConfig{Proxies: proxies}
fiberConf.ProxyHeader = "X-Forwarded-For"
log.Printf("[middleware] trusted proxies: %v", proxies)
}
fiberApp = fiber.New(fiberConf)
app := fiberApp
// 单 IP 限流(fiber 内置中间件,默认按客户端 IP 计数):次数由配置 rateLimit / 环境变量 RATE_LIMIT 决定(默认 120 次/分钟),
// 0 表示不限流。必须放在所有路由注册之前,fiber 中间件只对注册在其后的路由生效。
if RATE_LIMIT > 0 {
@@ -720,19 +799,35 @@ func main() {
app.Get("/v1/*", middlewareHandler)
app.Get("/middleware/*", middlewareHandler)
// 监听地址,优先级: 环境变量 PORT > setting.json port > 默认 8080
port := os.Getenv("PORT")
if port == "" {
port = PORT
// 监听地址:readConfig 已按 env PORT > setting.json port 填好全局 PORT,
// 这里只在为空时补默认 8080——不要再用 Getenv 覆盖,否则 setting.json 的 port 失效。
// waitChildReady 的健康检查地址要用同一个端口
if PORT == "" {
PORT = "8080"
}
if port == "" {
port = "8080"
}
listenAddr := port
listenAddr := PORT
if !strings.Contains(listenAddr, ":") {
listenAddr = ":" + listenAddr
}
if err := app.Listen(listenAddr); err != nil {
log.Fatal(err)
// Windows OTA 优雅交接:Listen 被 gracefulShutdown 关闭后,main 停在停车位,
// 由 restartSelf 在新进程健康检查通过后 os.Exit 结束整个老进程
if otaEnabled() && runtime.GOOS == "windows" {
otaHandoverWait = make(chan struct{})
}
listenErr := make(chan error, 1)
go func() { listenErr <- app.Listen(listenAddr) }()
err := <-listenErr
if err != nil {
if otaShutdownStarted.Load() {
// 优雅关闭过程中的退出属预期,主流程已在停车位等待交接
log.Printf("[ota] listen exited during graceful shutdown: %v", err)
} else {
log.Fatalf("listen failed: %v", err)
}
}
if otaHandoverWait != nil {
<-otaHandoverWait
}
}
+459
View File
@@ -0,0 +1,459 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
// OTA 自更新:定期检查 GitHub Release,下载与当前平台匹配的新版二进制替换自身并重启。
// 默认关闭,需显式开启(环境变量 OTA 或 setting.json 的 ota)。
// 交接逻辑与后端 src/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 // 下载文件最小体积,防止拿到错误页/占位文件
)
var (
otaMu sync.Mutex
otaLastRun time.Time
)
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(OTA)) {
case "true", "1", "yes", "on":
return true
}
return false
}
// initOTA 在后台启动 OTA 检查循环,未启用时直接返回
func initOTA(ghproxy string) {
if !otaEnabled() {
return
}
log.Printf("[ota] enabled, current_version=%s check_interval=%s", VERSION, otaCheckInterval)
go func() {
// 首次延迟,避免启动瞬间与节点建连/数据库等启动任务抢资源
time.Sleep(otaFirstDelay)
for {
checkOTAUpdate(ghproxy)
jitter := time.Duration(rand.Int63n(int64(time.Hour)))
time.Sleep(otaCheckInterval + jitter)
}
}()
}
// otaAssetNames 按优先级返回当前平台可能的发布资产名(与 build_and_release.yml 命名一致)
func otaAssetNames() []string {
suffix := ""
if runtime.GOOS == "windows" {
suffix = ".exe"
}
arch := runtime.GOARCH
// GOARCH=arm 时无法在运行时区分 GOARM(armv7/armv6),依次尝试
if runtime.GOOS == "linux" && arch == "arm" {
return []string{
"middleware-go-linux-armv7",
"middleware-go-linux-armv6",
"middleware-go-linux-arm",
}
}
return []string{fmt.Sprintf("middleware-go-%s-%s%s", runtime.GOOS, arch, 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]
}
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])
}
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
}
}
}
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]
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, false
}
return n, true
}
lm, lok := parseMajor(latest)
cm, cok := parseMajor(current)
if !lok || !cok {
return true
}
return lm == cm
}
// fetchLatestRelease 查询 GitHub 最新 Release 信息
func fetchLatestRelease() (*otaRelease, error) {
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest(http.MethodGet, otaReleaseAPI, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "middleware-go-ota")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
var rel otaRelease
if err := json.NewDecoder(resp.Body).Decode(&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 {
log.Printf("[ota] WARN check failed: %v", err)
return
}
if compareVersion(rel.TagName, VERSION) <= 0 {
return
}
// major 版本变化(如 3.x → 4.x)通常含破坏性变更,不自动更新,需人工升级
if !majorSame(rel.TagName, VERSION) {
log.Printf("[ota] skipped: major version upgrade requires manual action (latest=%s current=%s)", rel.TagName, VERSION)
return
}
assetURL, assetName, assetSize := findAssetURL(rel)
if assetURL == "" {
log.Printf("[ota] WARN no matching asset for this platform (goos=%s goarch=%s)", runtime.GOOS, runtime.GOARCH)
return
}
log.Printf("[ota] new version found: tag=%s current=%s asset=%s", rel.TagName, VERSION, assetName)
exePath, err := os.Executable()
if err != nil {
log.Printf("[ota] ERROR cannot locate executable: %v", err)
return
}
exePath, err = filepath.EvalSymlinks(exePath)
if err != nil {
log.Printf("[ota] ERROR cannot resolve executable path: %v", err)
return
}
// 下载到同目录(保证与目标同分区,rename 原子替换),
// 临时文件使用远端资产名 + .tmp 后缀,避免与运行中的 exe 冲突
tmpPath := filepath.Join(filepath.Dir(exePath), assetName+".tmp")
if err := downloadOTA(ghproxy+assetURL, tmpPath, assetSize); err != nil {
log.Printf("[ota] ERROR download failed: %v", err)
os.Remove(tmpPath)
return
}
// 预检:停机前先试运行新二进制(-v 打印版本即退出),确认文件可执行、架构匹配。
// 损坏/被杀毒隔离的文件若直到交接时才发现,子进程会秒退导致服务中断
if err := preflightBinary(tmpPath); err != nil {
log.Printf("[ota] ERROR preflight failed, keep current version: %v", err)
os.Remove(tmpPath)
return
}
if err := replaceBinary(tmpPath, exePath); err != nil {
log.Printf("[ota] ERROR replace failed: %v", err)
return
}
log.Printf("[ota] binary replaced, restarting (tag=%s)", rel.TagName)
restartSelf(exePath)
}
// 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()
}
// downloadOTA 下载二进制到临时文件,并做体积校验(Release 声明的资产大小精确比对 + 最小体积兜底)。
// 注意:GitHub Release 未提供签名/哈希清单,无法做内容级认证,体积校验只能防截断与错装文件。
func downloadOTA(url, dst string, expectSize int64) error {
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return err
}
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
}
_, copyErr := io.Copy(f, 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 {
os.Remove(dst)
return fmt.Errorf("downloaded size mismatch: got %d, want %d", fi.Size(), expectSize)
}
return nil
}
// replaceBinary 用新二进制替换当前可执行文件:
// 1) 当前二进制改名为 .old(Windows 下运行中的 exe 不能删除,但可以重命名)
// 2) 新文件 rename 到原位置;失败则回滚
func replaceBinary(tmpPath, exePath string) error {
oldPath := exePath + ".old"
_ = os.Remove(oldPath)
if err := os.Rename(exePath, oldPath); err != nil {
return fmt.Errorf("backup current binary: %w", err)
}
if err := os.Rename(tmpPath, exePath); err != nil {
_ = os.Rename(oldPath, exePath) // 回滚
return fmt.Errorf("install new binary: %w", err)
}
if runtime.GOOS != "windows" {
if err := os.Chmod(exePath, 0o755); err != nil {
log.Printf("[ota] WARN chmod failed: %v", err)
}
}
return nil
}
// restartSelf 用新二进制重启进程:
// - Unix:syscall.Exec 原地替换进程镜像(PID 不变,systemd/Docker 无感;Go 的 socket 带
// CLOEXEC,exec 瞬间旧监听关闭、新进程正常重绑)
// - Windows:运行中的 exe 只能重命名不能替换,采用优雅交接:
// 1) 优雅停机:停止接收新请求并等待在途请求完成,端口释放
// 2) 拉起新进程:端口已空闲,子进程可正常绑定
// 3) 轮询子进程健康检查通过后老进程才退出;失败回滚 .old 恢复服务
func restartSelf(exePath string) {
if runtime.GOOS == "windows" {
gracefulShutdown()
cmd := exec.Command(exePath, os.Args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
if err := cmd.Start(); err != nil {
log.Printf("[ota] ERROR restart failed, waiting for supervisor: %v", err)
os.Exit(1)
}
if waitChildReady(cmd, 10*time.Minute) {
log.Printf("[ota] new process ready, old process exiting (new_pid=%d)", cmd.Process.Pid)
os.Exit(0)
}
log.Printf("[ota] ERROR new process not ready (exited or timed out), rolling back (new_pid=%d)", cmd.Process.Pid)
// 回滚:新版本起不来,用 .old 备份恢复服务,而不是陪它一起下线
if rollbackToOld(exePath) {
log.Printf("[ota] WARN 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 {
log.Printf("[ota] ERROR exec failed, wait for supervisor to restart: %v", err)
os.Exit(1)
}
}
// gracefulShutdown 优雅停止 HTTP 服务:停止接收新请求,等待在途请求完成(上限 30s)。
// 供 Windows OTA 在拉起新进程前调用,确保监听端口先释放、子进程能正常绑定。
func gracefulShutdown() {
if fiberApp == nil {
return
}
otaShutdownStarted.Store(true)
log.Printf("[ota] graceful shutdown started, waiting for in-flight requests")
if err := fiberApp.ShutdownWithTimeout(30 * time.Second); err != nil {
log.Printf("[ota] WARN graceful shutdown: %v", err)
}
}
// waitChildReady 轮询新进程的健康检查接口(GET /),确认其完成端口绑定并对外服务。
// 返回 true = 就绪;false = 子进程启动期间退出(立即判定失败)或超时未就绪。
// 判定失败的依据是"子进程退出"(进程死了立即返回),而不是固定超时——
// 10 分钟兜底只为覆盖慢启动场景。
func waitChildReady(cmd *exec.Cmd, timeout time.Duration) bool {
client := &http.Client{Timeout: 2 * time.Second}
url := "http://127.0.0.1:" + PORT + "/"
deadline := time.Now().Add(timeout)
// 监听子进程退出:起不来直接失败,不用干等超时
exited := make(chan struct{})
go func() {
_ = cmd.Wait()
close(exited)
}()
for {
if resp, err := client.Get(url); err == nil {
resp.Body.Close()
return true
}
select {
case <-exited:
return false
case <-time.After(300 * time.Millisecond):
}
if time.Now().After(deadline) {
return false
}
}
}
// rollbackToOld 用 .old 备份恢复服务:新版本起不来时,把旧二进制换回原位并重新拉起。
// 返回 true 表示旧版本已就绪。恢复失败时尽力把新二进制挪回原位后返回 false。
func rollbackToOld(exePath string) bool {
oldPath := exePath + ".old"
if _, err := os.Stat(oldPath); err != nil {
log.Printf("[ota] ERROR rollback skipped: no .old backup: %v", err)
return false
}
failed := exePath + ".failed"
_ = os.Remove(failed)
if err := os.Rename(exePath, failed); err != nil {
log.Printf("[ota] ERROR rollback: cannot move failed binary: %v", err)
return false
}
if err := os.Rename(oldPath, exePath); err != nil {
log.Printf("[ota] ERROR rollback: cannot restore old binary: %v", err)
_ = os.Rename(failed, exePath) // 尽力恢复现场
return false
}
cmd := exec.Command(exePath, os.Args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
if err := cmd.Start(); err != nil {
log.Printf("[ota] ERROR rollback: cannot start old binary: %v", err)
return false
}
if waitChildReady(cmd, 10*time.Minute) {
log.Printf("[ota] WARN rollback serving traffic on previous version (pid=%d)", cmd.Process.Pid)
return true
}
log.Printf("[ota] ERROR rollback: old binary also failed to become ready")
return false
}
+9 -30
View File
@@ -4,23 +4,12 @@
"rate-limit": 120,
"ws-port": 8092,
"remote-config-url": "",
"remote-ingore-config": [],
"remote-ignore-config": [],
"cors": "",
"apiBaseUrls": [
{
"label": "中国 江苏 移动",
"id": "cn-jiangsu",
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
}
],
"IPLocationAPIs": [
{
"label": "中国 江苏 移动",
"id": "cn-jiangsu",
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
}
],
"TCPing": {
"trusted-proxies": "",
"gh-proxy": "",
"ota": "false",
"api-base-url": {
"DualStack": [
{
"label": "中国 江苏 移动",
@@ -31,23 +20,13 @@
"IPv4": [],
"IPv6": []
},
"SpeedTest": {
"DualStack": [
{
"label": "中国 江苏 移动",
"id": "cn-jiangsu",
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
}
],
"IPv4": [],
"IPv6": []
},
"NSLookup": [
"ip-location-api": [
{
"label": "中国 江苏 移动",
"id": "cn-jiangsu",
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
}
],
"apiKeys": {}
}
"api-keys": {},
"ws-keys": {}
}
+46 -46
View File
@@ -7,6 +7,7 @@ import (
"log"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
@@ -19,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"} }
@@ -54,7 +57,9 @@ type wsCommand struct {
type wsPeer struct {
conn *websocket.Conn
id string
last time.Time
// lastAtomic 最后收到消息的时间(unix nano)。读循环写、maintenanceLoop 读,
// 跨 goroutine 且不在 s.mu 保护内,必须原子访问
lastAtomic atomic.Int64
}
type wsServer struct {
@@ -64,9 +69,6 @@ type wsServer struct {
probeMu sync.Mutex
probeWait map[string]chan wsProbeResult
// 指令回调(后端节点下发 command 时触发),由 main 注入
onCommand func(cmd wsCommand)
// 统计
statMu sync.Mutex
totalReqs int64
@@ -74,11 +76,10 @@ type wsServer struct {
startedAt time.Time
}
func newWSServer(onCommand func(cmd wsCommand)) *wsServer {
func newWSServer() *wsServer {
return &wsServer{
peers: make(map[string]*wsPeer),
probeWait: make(map[string]chan wsProbeResult),
onCommand: onCommand,
startedAt: time.Now(),
}
}
@@ -95,6 +96,7 @@ func (s *wsServer) Handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
peer := &wsPeer{conn: c}
peer.lastAtomic.Store(time.Now().UnixNano())
registered := false
// 读循环
@@ -114,14 +116,14 @@ func (s *wsServer) Handler(w http.ResponseWriter, r *http.Request) {
log.Printf("[ws] invalid message from %s: %v", peer.id, err)
continue
}
peer.last = time.Now()
peer.lastAtomic.Store(time.Now().UnixNano())
// ===== 数据阶段 =====
switch msg.Type {
case "register":
var reg struct {
NodeID string `json:"nodeId"`
Key string `json:"key"` // 注册凭证:与 setting.json apiKeys 配置比对
Key string `json:"key"` // 注册凭证:与 setting.json wsKeys 配置比对
}
_ = json.Unmarshal(msg.Data, &reg)
if reg.NodeID == "" {
@@ -132,9 +134,9 @@ func (s *wsServer) Handler(w http.ResponseWriter, r *http.Request) {
s.sendJSON(c, wsMessage{Type: "register_error", Data: mustRaw(wsCommand{Command: "already registered"})})
continue
}
// 注册 key 校验:节点配置了 apiKeys[节点id] 就必须传对 key,否则 401 并断开;
// 注册 key 校验:节点配置了 wsKeys[节点id] 就必须传对 key,否则 401 并断开;
// 未配置 key 的节点(开放)无需传 key
if expected := lookupAPIKey(reg.NodeID); expected != "" && reg.Key != expected {
if expected := lookupWSKey(reg.NodeID); expected != "" && reg.Key != expected {
log.Printf("[ws] AUTH FAILED for node %s: invalid key", reg.NodeID)
s.sendJSON(c, wsMessage{Type: "register_error", Data: mustRaw(struct {
Code int `json:"code"`
@@ -183,19 +185,14 @@ func (s *wsServer) Handler(w http.ResponseWriter, r *http.Request) {
s.sendJSON(c, wsMessage{Type: "pong", NodeID: peer.id, TS: time.Now().Unix()})
}
case "command":
if !registered {
continue
}
var cmd wsCommand
if err := json.Unmarshal(msg.Data, &cmd); err != nil {
log.Printf("[ws] bad command from %s: %v", peer.id, err)
continue
}
log.Printf("[ws] command from %s: %s", peer.id, cmd.Command)
if s.onCommand != nil {
s.onCommand(cmd)
}
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)
@@ -212,10 +209,11 @@ func (s *wsServer) Handler(w http.ResponseWriter, r *http.Request) {
}
}
func (s *wsServer) sendJSON(c *websocket.Conn, msg wsMessage) {
// sendJSON 发送 JSON 消息并返回写错误(写失败意味着对端连接已不可用,调用方应尽快失败)
func (s *wsServer) sendJSON(c *websocket.Conn, msg wsMessage) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = c.Write(ctx, websocket.MessageText, mustRaw(msg))
return c.Write(ctx, websocket.MessageText, mustRaw(msg))
}
// RequestProbe 通过 WS 通道向指定节点发送拨测请求并等待结果(超时返回错误)。
@@ -239,7 +237,13 @@ func (s *wsServer) RequestProbe(nodeID, apiType, raw string, query map[string]st
}()
payload := wsProbeRequest{RequestID: reqID, APIType: apiType, Raw: raw, Query: query}
s.sendJSON(peer.conn, wsMessage{Type: "probe", NodeID: nodeID, TS: time.Now().Unix(), Data: mustRaw(payload)})
if err := s.sendJSON(peer.conn, wsMessage{Type: "probe", NodeID: nodeID, TS: time.Now().Unix(), Data: mustRaw(payload)}); err != nil {
// 连接已死(写失败立刻可知),直接返回错误,不再让请求干等满超时
s.statMu.Lock()
s.errReqs++
s.statMu.Unlock()
return 0, nil, fmt.Errorf("ws probe send failed for node %s: %w", nodeID, err)
}
s.statMu.Lock()
s.totalReqs++
@@ -310,6 +314,21 @@ func (s *wsServer) maintenanceLoop() {
s.statMu.Unlock()
for _, p := range peers {
// 空闲剔除:超过 75s(约 3 个心跳周期)没有任何消息的节点视为僵死。
// 不剔除的话死连接会永久占位,路由到它的探测全部干等超时
s.mu.Lock()
idle := time.Since(time.Unix(0, p.lastAtomic.Load()))
s.mu.Unlock()
if idle > 75*time.Second {
log.Printf("[ws] node %s idle for %s, evicting", p.id, idle.Round(time.Second))
p.conn.Close(websocket.StatusPolicyViolation, "idle timeout")
s.mu.Lock()
if cur, ok := s.peers[p.id]; ok && cur == p {
delete(s.peers, p.id)
}
s.mu.Unlock()
continue
}
// 心跳
s.sendJSON(p.conn, wsMessage{Type: "ping", NodeID: p.id, TS: time.Now().Unix()})
// 状态/统计上报
@@ -347,22 +366,3 @@ func wsProbeTimeout() time.Duration {
}
return time.Duration(t) * time.Second
}
// handleWSCommand 处理后端节点经 WS 下发的指令(配置/指令下发)。
// 注意:reload_config 会整体重写配置全局变量,运行中重载期间存在与并发请求的竞态,
// 生产环境仍建议以重启方式应用配置;此命令主要用于调试与低峰期热更新。
func handleWSCommand(cmd wsCommand) {
switch cmd.Command {
case "reload_config":
log.Printf("[ws] command reload_config received, reloading config...")
if err := readConfig(); err != nil {
log.Printf("[ws] reload_config FAILED: %v", err)
return
}
log.Printf("[ws] config reloaded from %s", CONFIG_SOURCE)
case "ping":
// 兼容节点发来的 ping(视为心跳)
default:
log.Printf("[ws] unknown command: %s", cmd.Command)
}
}
File renamed without changes.
File renamed without changes.
@@ -33,14 +33,14 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
@@ -61,8 +61,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/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.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -88,8 +88,8 @@ golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -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 {
@@ -241,6 +246,9 @@ func initHttpClient() {
V4Client.SetTransport(setTransport("tcp4"))
V6Client.SetTimeout(10 * time.Second)
V4Client.SetTimeout(10 * time.Second)
// 响应体上限:探针会读取任意用户提供的 URL,无上限时 10s 窗口内可灌入数 GB 导致 OOM
V6Client.SetResponseBodyLimit(10 * 1024 * 1024)
V4Client.SetResponseBodyLimit(10 * 1024 * 1024)
V6Client.SetRedirectPolicy(resty.RedirectPolicyFunc(ssrf.SecureCheckRedirect))
V4Client.SetRedirectPolicy(resty.RedirectPolicyFunc(ssrf.SecureCheckRedirect))
V6Client.AddContentDecompresser("zstd", decompressZstd)
@@ -555,13 +563,15 @@ func cleanHostRecord(addr string) string {
func normalizeURL(input string) string {
input = strings.TrimSpace(input)
input = strings.TrimPrefix(input, "/")
if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") {
return input
}
// 协议相对路径必须先于剥前导斜杠判断,否则 "//example.com" 被剥成 "/example.com",
// 该分支永远不会命中,最终拼出空 host 的 "https:///example.com"
if strings.HasPrefix(input, "//") {
return "https:" + input
}
input = strings.TrimPrefix(input, "/")
return "https://" + input
}
@@ -581,8 +591,8 @@ func checkWebsiteHandler(c *gin.Context) {
testUrl = normalizeURL(testUrl)
parsedURL, _ := url.Parse(testUrl)
if ssrf.HasLocalOrPrivateIP(parsedURL.Hostname()) {
parsedURL, err := url.Parse(testUrl)
if err == nil && ssrf.HasLocalOrPrivateIP(parsedURL.Hostname()) {
c.JSON(200, &WebsiteCheckResult{
IPv4: fakePerfectWebsiteResult(testUrl),
IPv6: fakePerfectWebsiteResult(testUrl),
@@ -682,8 +692,8 @@ func sslCheckHandler(c *gin.Context) {
testUrl = normalizeURL(testUrl)
parsedURL, _ := url.Parse(testUrl)
if ssrf.HasLocalOrPrivateIP(parsedURL.Hostname()) {
parsedURL, err := url.Parse(testUrl)
if err == nil && ssrf.HasLocalOrPrivateIP(parsedURL.Hostname()) {
c.JSON(200, &SSLCheckResult{
IPv4: fakeInvalidSSLResult(parsedURL.Hostname()),
IPv6: fakeInvalidSSLResult(parsedURL.Hostname()),
@@ -1178,8 +1188,25 @@ 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 = strings.Split(CORS, ",")
ACCEPT_DOMAINS = splitAndTrim(CORS, ",")
}
slog.Info("Remote config applied", "url", url)
}
@@ -1208,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()
@@ -1216,17 +1258,76 @@ func readConfig() {
PORTS = "8080"
}
if CORS != "" {
ACCEPT_DOMAINS = strings.Split(CORS, ",")
ACCEPT_DOMAINS = splitAndTrim(CORS, ",")
}
slog.Info("SSRF protection initialized", "blockPrivateIPs", ssrf.Enabled())
}
// splitAndTrim 按逗号切分并去掉每段首尾空白:CORS 配置 "a.com, b.com" 带空格时,
// 直接 Split 会产生带前导空格的 origin,永远匹配不上请求头
func splitAndTrim(s, sep string) []string {
parts := strings.Split(s, sep)
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// sweepExpiredCaches 按条目时间戳清扫各 sync.Map 缓存(TTL 均为 5 分钟,清扫窗口取 10 分钟),
// 缓存条目只在同 key 重访时惰性淘汰,公网端点被唯一 key 洪打时内存会无限增长
func sweepExpiredCaches() {
cutoff := time.Now().Add(-10 * time.Minute)
sweep := func(m *sync.Map) {
m.Range(func(k, v any) bool {
var ts time.Time
switch e := v.(type) {
case websiteCacheEntry:
ts = e.timestamp
case sslCacheEntry:
ts = e.timestamp
case pingCacheEntry:
ts = e.timestamp
case speedCacheEntry:
ts = e.timestamp
case whoisCacheEntry:
ts = e.timestamp
default:
return true
}
if ts.Before(cutoff) {
m.Delete(k)
}
return true
})
}
sweep(&websiteCache)
sweep(&sslCache)
sweep(&pingCache)
sweep(&speedCache)
sweep(&whoisCache)
}
func main() {
readConfig()
initHttpClient()
webtest.SetDNSServer(DNS_SERVER)
webtest.SetDNSSecServer(DNSSEC_DNS_SERVER)
slog.Info("Starting server", "port", PORTS, "single_stack", SINGLE_STACK)
// 数据上报:周期把本节点的统计与拨测明细上报到收集中心(详见 report.go)
startNodeReporter()
// 缓存清扫:定期淘汰过期条目
go func() {
for {
time.Sleep(10 * time.Minute)
sweepExpiredCaches()
}
}()
r := gin.Default()
corsConfig := cors.DefaultConfig()
if len(ACCEPT_DOMAINS) > 0{
@@ -1235,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)
}
@@ -45,24 +45,49 @@ func SetEnabled(v bool) {
blockPrivateIPs = v
}
// Go 标准库 IsPrivate 只覆盖 RFC1918 与 fd00::/8,这里补齐其余不应出网的内部/特殊网段
var extraPrivateNets = mustParseCIDRs(
"100.64.0.0/10", // CGNAT:运营商级 NAT、云内网、Tailscale 等
"198.18.0.0/15", // 网络基准测试保留段
"192.0.0.0/24", // IETF 协议分配
"fc00::/7", // IPv6 ULA(IsPrivate 只认 fd00::/8)
"64:ff9b::/96", // NAT64:内嵌 IPv4 会被网关转换
)
func mustParseCIDRs(cidrs ...string) []*net.IPNet {
nets := make([]*net.IPNet, 0, len(cidrs))
for _, c := range cidrs {
_, ipNet, err := net.ParseCIDR(c)
if err != nil {
panic(fmt.Sprintf("invalid CIDR %q: %v", c, err))
}
nets = append(nets, ipNet)
}
return nets
}
// IsPrivateIP checks if the given IP address belongs to a private or internal range.
// IsPrivateIP 检查给定的 IP 地址是否属于私有或内部地址段。
// It covers RFC 1918 private addresses, loopback, link-local, and unspecified addresses.
// 涵盖 RFC 1918 私有地址、回环地址、链路本地地址和未指定地址。
// It covers RFC 1918 private addresses, loopback, link-local, and unspecified addresses,
// plus CGNAT/ULA/NAT64/benchmark/multicast ranges that Go's IsPrivate does not cover.
// 涵盖 RFC 1918 私有地址、回环地址、链路本地地址和未指定地址,
// 以及 Go 标准库未覆盖的 CGNAT/ULA/NAT64/基准测试/组播段。
// This is the core check used to block SSRF attacks targeting internal networks.
// 这是用于阻止针对内部网络的 SSRF 攻击的核心检查。
func IsPrivateIP(ip net.IP) bool {
if ip.IsPrivate() {
if ip == nil {
return false
}
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() || ip.IsMulticast() {
return true
}
if ip.IsLoopback() {
if ip.Equal(net.IPv4bcast) {
return true
}
if ip.IsLinkLocalUnicast() {
return true
}
if ip.IsUnspecified() {
return true
for _, n := range extraPrivateNets {
if n.Contains(ip) {
return true
}
}
return false
}
@@ -133,36 +158,35 @@ func ValidateOutboundTarget(ctx context.Context, targetURL string) (context.Cont
// SecureCheckRedirect is a redirect policy that prevents SSRF via HTTP redirect chains.
// SecureCheckRedirect 是一个重定向策略,防止通过 HTTP 重定向链进行 SSRF 攻击。
//
// When an HTTP client follows redirects (301, 302, 307, 308), each intermediate target
// 当 HTTP 客户端跟随重定向(301、302、307、308)时,会校验每个中间目标
// is resolved and checked against private/internal IP ranges.
// 的解析结果,并检查是否指向私有/内部 IP 段。
//
// This blocks attackers from:
// 这阻止攻击者通过以下方式绕过防护:
// - Using an initial public redirect that points to an internal service.
// 使用指向内部服务的初始公开重定向。
// - Chaining multiple redirects to eventually reach a private IP.
// 链式多个重定向最终到达私有 IP。
// req 是即将跳转的目标(via 是已访问的链)。必须校验目标本身:
// 只检查 via 会漏掉重定向链的最后一跳。每一跳的目标都会被解析并检查
// 是否指向私有/内部 IP 段,同时限制重定向深度。
// 这阻止攻击者通过链式重定向最终到达私有 IP。
func SecureCheckRedirect(req *http.Request, via []*http.Request) error {
if !blockPrivateIPs {
return nil
}
for _, r := range via {
redirectURL := r.URL
host := redirectURL.Hostname()
if host == "" {
continue
}
ips, err := net.LookupIP(host)
if err != nil {
return err
}
for _, ip := range ips {
if IsPrivateIP(ip) {
slog.Warn("Blocked redirect to private IP", "host", host, "ip", ip)
return fmt.Errorf("redirect to private/internal address is not allowed")
}
if req == nil || req.URL == nil {
return fmt.Errorf("invalid redirect target")
}
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
return fmt.Errorf("invalid redirect scheme: %s", req.URL.Scheme)
}
host := req.URL.Hostname()
if host == "" {
return fmt.Errorf("redirect target has empty host")
}
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
ips, err := net.LookupIP(host)
if err != nil {
return err
}
for _, ip := range ips {
if IsPrivateIP(ip) {
slog.Warn("Blocked redirect to private IP", "host", host, "ip", ip)
return fmt.Errorf("redirect to private/internal address is not allowed")
}
}
return nil
@@ -5,6 +5,8 @@ import (
"math"
"net"
"time"
"edgeone-cloud-functions/ssrf"
)
type TCPingResult struct {
@@ -37,6 +39,13 @@ func TCPing(host string, port string, version string, timeout time.Duration) (*T
if err != nil {
return nil, err
}
// SSRF 防护:内网/云元数据段(127.0.0.1、10.x、169.254.169.254 等)直接拒绝,
// 否则 tcping 接口会被当作内网端口扫描器
if ssrf.Enabled() {
if pip := net.ParseIP(ip); pip != nil && ssrf.IsPrivateIP(pip) {
return nil, fmt.Errorf("request to private/internal address is not allowed")
}
}
addr := ""
switch version {
case "v4":
@@ -1,6 +1,7 @@
package webtest
import (
"bytes"
"context"
"fmt"
"net"
@@ -343,19 +344,29 @@ func rawWhoisQueryCtx(ctx context.Context, domain, server, port string) (string,
}
}
// 3. 读取响应
// 3. 读取响应:循环读到 EOF/超时。单次 Read 只能拿到一个 TCP 段,会截断大多数 WHOIS 响应
buf := make([]byte, 65536)
n, readErr := conn.Read(buf)
// 有数据就读到了,哪怕 readErr != nil 也先返回数据
if n > 0 {
return string(buf[:n]), nil
}
select {
case <-ctx.Done():
return "", ctx.Err()
default:
return "", readErr
var out bytes.Buffer
for {
n, readErr := conn.Read(buf)
if n > 0 {
out.Write(buf[:n])
}
if readErr != nil {
// 有数据就返回(EOF/超时都视为读完),哪怕 readErr != nil
if out.Len() > 0 {
return out.String(), nil
}
select {
case <-ctx.Done():
return "", ctx.Err()
default:
return "", readErr
}
}
if out.Len() > 1<<20 { // 防御上限 1MB,避免异常响应无限读
return out.String(), nil
}
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File diff suppressed because it is too large. Load diff
@@ -10,12 +10,12 @@
"cf-typegen": "wrangler types"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.18.5",
"@cloudflare/vitest-pool-workers": "^0.22.0",
"typescript": "^5.5.2",
"vitest": "~4.1.10",
"wrangler": "^4.111.0"
"vitest": "~4.1.11",
"wrangler": "^4.124.0"
},
"dependencies": {
"hono": "^4.12.34"
"hono": "^4.13.5"
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
-15
View File
@@ -1,15 +0,0 @@
{
"port": 8080,
"gh-proxy": "",
"single-stack": "",
"dns-server": "",
"dnssec-server": "",
"block-private-ips": true,
"ipdb": true,
"cors": "",
"remote-config-url": "",
"remote-ingore-config": [],
"access_token": "",
"ipw-ws-url": "",
"ipw-node-id": ""
}
+49
View File
@@ -0,0 +1,49 @@
# 多架构构建:buildx 会注入 TARGETOS/TARGETARCH,构建阶段固定在构建机原生平台
# (--platform=$BUILDPLATFORM)交叉编译,目标架构无需 QEMU 模拟;未用 buildx 时
# TARGETOS/TARGETARCH 为空,Go 默认取宿主机平台,普通 docker build 仍然可用
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} \
GOARCH=${TARGETARCH}
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY *.go ./
COPY webtest/ ./webtest/
COPY ipdb/ ./ipdb/
COPY ssrf/ ./ssrf/
# TARGETVARIANT 是 buildx 的 "v7"/"v6" 写法,Go 的 GOARM 只要数字;不映射时 GOARCH=arm 默认按 GOARM=6 编译
RUN set -eux; \
case "$TARGETVARIANT" in \
v7) export GOARM=7 ;; \
v6) export GOARM=6 ;; \
esac; \
go build -ldflags "-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}" -o main .
# 运行阶段
FROM alpine:latest
# 添加必要的证书
RUN apk --no-cache add ca-certificates
# 创建非root用户
RUN adduser -D appuser
USER appuser
WORKDIR /home/appuser
# 从构建阶段复制二进制文件
COPY --from=build /app/main .
EXPOSE 8080
CMD ["./main"]
+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)})
}
+7 -4
View File
@@ -1,11 +1,13 @@
module lemon-ipw
go 1.26.2
go 1.26.6
require (
github.com/AdguardTeam/golibs v0.35.16
github.com/coder/websocket v1.8.15
github.com/gin-contrib/cors v1.7.7
github.com/gin-gonic/gin v1.12.0
github.com/ip2location/ip2location-go/v9 v9.8.0
github.com/ipipdotnet/ipdb-go v1.3.3
github.com/klauspost/compress v1.19.1
github.com/likexian/whois v1.15.7
@@ -54,10 +56,11 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.47.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
golang.org/x/tools v0.48.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
)
+18 -10
View File
@@ -1,3 +1,5 @@
github.com/AdguardTeam/golibs v0.35.16 h1:e9dyk7jfzpxMcyVZ8RktKV6JMeiQ9MpEhp/Ahm3j/R4=
github.com/AdguardTeam/golibs v0.35.16/go.mod h1:lGsRwlo3R3ma5o8mgdDv3I+6DYTiCHfrhNyUamqhbK8=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
@@ -9,8 +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=
@@ -40,6 +43,8 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/ip2location/ip2location-go/v9 v9.8.0 h1:drPzGjj1EBl45I33ErMHFtIfsQ3mR85dAQbqMDbi9mc=
github.com/ip2location/ip2location-go/v9 v9.8.0/go.mod h1:MPLnsKxwQlvd2lBNcQCsLoyzJLDBFizuO67wXXdzoyI=
github.com/ipipdotnet/ipdb-go v1.3.3 h1:GLSAW9ypLUd6EF9QNK2Uhxew9Jzs4XMJ9gOZEFnJm7U=
github.com/ipipdotnet/ipdb-go v1.3.3/go.mod h1:yZ+8puwe3R37a/3qRftXo40nZVQbxYDLqls9o5foexs=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -75,14 +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=
@@ -122,8 +128,8 @@ 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=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
@@ -133,15 +139,17 @@ 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=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
resty.dev/v3 v3.0.0-beta.6 h1:ghRdNpoE8/wBCv+kTKIOauW1aCrSIeTq7GxtfYgtevU=
resty.dev/v3 v3.0.0-beta.6/go.mod h1:NTOerrC/4T7/FE6tXIZGIysXXBdgNqwMZuKtxpea9NM=
+85 -3
View File
@@ -1,6 +1,7 @@
package ipdb
import (
"archive/zip"
"compress/gzip"
"fmt"
"io"
@@ -31,6 +32,8 @@ func PullDatabase(ghproxy string) error {
{ghproxy + "https://github.com/ljxi/GeoCN/releases/latest/download/GeoCN.mmdb", "GeoCN.mmdb"},
{ghproxy + "https://raw.githubusercontent.com/wp-statistics/DbIP-City-lite/master/dbip-city-lite.mmdb.gz", "dbip-city-lite.mmdb.gz"},
{ghproxy + "https://github.com/jcjc-dev/mmdb-latest/releases/download/dbip-latest/dbip-asn-lite.mmdb", "dbip-asn-lite.mmdb"},
{ghproxy + "https://github.com/nomdn/ip2location/releases/latest/download/IP2LOCATION-LITE-DB11.IPV6.BIN.ZIP", "IP2LOCATION-LITE-DB11.IPV6.BIN.ZIP"},
{ghproxy + "https://github.com/nomdn/ip2location/releases/latest/download/IP2LOCATION-LITE-ASN.IPV6.BIN.zip", "IP2LOCATION-LITE-ASN.IPV6.BIN.zip"},
}
tmpDir := "./tmp"
@@ -62,7 +65,12 @@ func PullDatabase(ghproxy string) error {
}
slog.Info("Copying downloaded files to working directory...")
for _, t := range tasks {
for i, t := range tasks {
if errs[i] != nil {
// 下载失败(含传输中断留下的残缺文件)不覆盖现有数据库
slog.Warn("Skipping failed download, keep existing file", "file", t.name)
continue
}
src := filepath.Join(tmpDir, t.name)
if _, err := os.Stat(src); os.IsNotExist(err) {
slog.Warn("Skipping missing file", "file", t.name)
@@ -73,14 +81,25 @@ func PullDatabase(ghproxy string) error {
slog.Error("Failed to copy file", "file", t.name, "error", err)
continue
}
if strings.HasSuffix(t.name, ".gz") {
outName := strings.TrimSuffix(t.name, ".gz")
lower := strings.ToLower(t.name)
// 后缀判断用小写比较:IP2LOCATION 的资产名是大写 .ZIP,原来 HasSuffix(".zip") 永远匹配不上,
// 导致该数据库从不解压就被删除
if strings.HasSuffix(lower, ".gz") {
outName := t.name[:len(t.name)-3]
slog.Info("Decompressing...", "file", t.name, "output", outName)
if err := gunzipFile(dst, "./"+outName); err != nil {
slog.Error("Failed to decompress", "file", t.name, "error", err)
}
os.Remove(dst)
}
if strings.HasSuffix(lower, ".zip") {
outName := t.name[:len(t.name)-4]
slog.Info("Unzipping...", "file", t.name, "output", outName)
if err := unzipFile(dst, "./"+outName); err != nil {
slog.Error("Failed to unzip", "file", t.name, "error", err)
}
os.Remove(dst)
}
}
os.RemoveAll(tmpDir)
@@ -107,6 +126,8 @@ func downloadWithRetry(url, name string) error {
if err != nil {
lastErr = fmt.Errorf("request failed: %w", err)
slog.Warn("Download attempt failed", "file", name, "attempt", attempt, "error", err)
// 传输中断会在输出文件里留下残缺内容,删掉避免被当作下载成功覆盖现有数据库
os.Remove(filepath.Join("./tmp", name))
time.Sleep(time.Duration(attempt) * time.Second)
continue
}
@@ -205,3 +226,64 @@ func gunzipFile(src, dst string) error {
return nil
}
// unzipFile 解压 zip 中与目标同名(或同后缀)的文件到 dst(dst 如 ./IP2LOCATION-LITE-DB11.IPV6.BIN);
// 找不到匹配文件时回退解压第一个非目录文件。
func unzipFile(src, dst string) error {
r, err := zip.OpenReader(src)
if err != nil {
return err
}
defer r.Close()
target := filepath.Base(dst)
var fallback *zip.File
for _, f := range r.File {
if f.FileInfo().IsDir() {
continue
}
if fallback == nil {
fallback = f
}
if f.Name == target || strings.EqualFold(f.Name, target) || strings.HasSuffix(strings.ToLower(f.Name), strings.ToLower(target)) {
return extractZipEntry(f, dst)
}
}
if fallback == nil {
return fmt.Errorf("no file found in zip archive %s", src)
}
return extractZipEntry(fallback, dst)
}
func extractZipEntry(f *zip.File, dst string) error {
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
tmp := dst + ".tmp"
out, err := os.Create(tmp)
if err != nil {
return err
}
if _, err := io.Copy(out, rc); err != nil {
out.Close()
os.Remove(tmp)
return err
}
if err := out.Sync(); err != nil {
out.Close()
os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
os.Remove(tmp)
return err
}
if err := os.Rename(tmp, dst); err != nil {
os.Remove(tmp)
return err
}
return nil
}
+176 -4
View File
@@ -15,6 +15,7 @@ import (
"time"
ipdbgo "github.com/ipipdotnet/ipdb-go"
"github.com/ip2location/ip2location-go/v9"
"github.com/lionsoul2014/ip2region/binding/golang/service"
maxminddb "github.com/oschwald/maxminddb-golang/v2"
"resty.dev/v3"
@@ -23,6 +24,10 @@ import (
var (
ip2region *service.Ip2Region
ip2regionMu sync.RWMutex
ip2locDB *ip2location.DB
ip2locMu sync.RWMutex
ip2locASNDB *ip2location.DB
ip2locASNMu sync.RWMutex
qqwryDB *ipdbgo.City
qqwryMu sync.RWMutex
mmdbDBs map[string]*maxminddb.Reader
@@ -163,7 +168,6 @@ func loadIp2Region() error {
ip2region.Close()
slog.Info("Previous ip2region closed")
}
v4Config, err := service.NewV4Config(service.VIndexCache, "ip2region_v4.xdb", 20)
if err != nil {
slog.Error("failed to create v4 config", "error", err)
@@ -186,6 +190,48 @@ func loadIp2Region() error {
return nil
}
// loadIP2Location 加载 IP2Location LITE DB11 数据库(.BIN 格式,官方 ip2location-go 库读取)
func loadIP2Location() error {
ip2locMu.Lock()
defer ip2locMu.Unlock()
if ip2locDB != nil {
ip2locDB.Close()
ip2locDB = nil
slog.Info("Previous ip2location closed")
}
db, err := ip2location.OpenDB("./IP2LOCATION-LITE-DB11.IPV6.BIN")
if err != nil {
slog.Error("failed to open ip2location db", "error", err)
return err
}
ip2locDB = db
slog.Info("ip2location loaded successfully")
return nil
}
// loadIP2LocationASN 加载 IP2Location LITE ASN 数据库(仅 asn / as 字段)
func loadIP2LocationASN() error {
ip2locASNMu.Lock()
defer ip2locASNMu.Unlock()
if ip2locASNDB != nil {
ip2locASNDB.Close()
ip2locASNDB = nil
slog.Info("Previous ip2location asn closed")
}
db, err := ip2location.OpenDB("./IP2LOCATION-LITE-ASN.IPV6.BIN")
if err != nil {
slog.Error("failed to open ip2location asn db", "error", err)
return err
}
ip2locASNDB = db
slog.Info("ip2location asn loaded successfully")
return nil
}
func loadQQWry() error {
qqwryMu.Lock()
defer qqwryMu.Unlock()
@@ -503,7 +549,7 @@ func searchBilibili(ip string) (*BilibiliResult, error) {
bilibiliCache.Delete(ip)
}
client := resty.New()
client := resty.New().SetTimeout(10 * time.Second)
defer client.Close()
resp, err := client.R().
SetQueryParam("ip", ip).
@@ -529,6 +575,18 @@ func searchBilibili(ip string) (*BilibiliResult, error) {
return result, nil
}
// SweepBilibiliCache 清理过期的 Bilibili 归属地缓存(由外部定时调用,
// 防止公网端点被唯一 key 洪打时缓存内存无限增长)
func SweepBilibiliCache() {
cutoff := time.Now().Add(-24 * time.Hour)
bilibiliCache.Range(func(k, v any) bool {
if e, ok := v.(bilibiliCacheEntry); ok && e.timestamp.Before(cutoff) {
bilibiliCache.Delete(k)
}
return true
})
}
func loadAll() {
if err := loadIp2Region(); err != nil {
slog.Error("Error loading ip2region", "error", err)
@@ -552,6 +610,20 @@ func closeAllDBs() {
}
ip2regionMu.Unlock()
ip2locMu.Lock()
if ip2locDB != nil {
ip2locDB.Close()
ip2locDB = nil
}
ip2locMu.Unlock()
ip2locASNMu.Lock()
if ip2locASNDB != nil {
ip2locASNDB.Close()
ip2locASNDB = nil
}
ip2locASNMu.Unlock()
qqwryMu.Lock()
qqwryDB = nil
qqwryMu.Unlock()
@@ -569,6 +641,12 @@ func reloadAll() {
if err := loadIp2Region(); err != nil {
slog.Error("Error loading ip2region", "error", err)
}
if err := loadIP2Location(); err != nil {
slog.Error("Error loading ip2location", "error", err)
}
if err := loadIP2LocationASN(); err != nil {
slog.Error("Error loading ip2location asn", "error", err)
}
if err := reloadQQWry(); err != nil {
slog.Error("Error reloading qqwry.ipdb", "error", err)
}
@@ -587,6 +665,14 @@ func Init(ghproxy string) {
slog.Warn("Local ip2region not available", "error", err)
localOK = false
}
if err := loadIP2Location(); err != nil {
slog.Warn("Local ip2location not available", "error", err)
localOK = false
}
if err := loadIP2LocationASN(); err != nil {
slog.Warn("Local ip2location asn not available", "error", err)
localOK = false
}
if err := loadQQWry(); err != nil {
slog.Warn("Local qqwry.ipdb not available", "error", err)
localOK = false
@@ -627,7 +713,7 @@ func Init(ghproxy string) {
}()
}
var allDatabases = []string{"ip2region", "qqwry", "maxmind_city", "maxmind_asn", "dbip_asn", "geocn", "dbip_city", "bilibili"}
var allDatabases = []string{"ip2region", "ip2location", "ip2location_asn", "qqwry", "maxmind_city", "maxmind_asn", "dbip_asn", "geocn", "dbip_city", "bilibili"}
func SearchIP(ip string, databases ...string) map[string]interface{} {
if len(databases) == 0 {
@@ -645,13 +731,74 @@ func SearchIP(ip string, databases ...string) map[string]interface{} {
if err != nil {
result["ip2region"] = "error: " + err.Error()
} else {
result["ip2region"] = region
// 预分割为结构化 JSON,前端无需再按 "|" 拆字符串
result["ip2region"] = parseIP2Region(region)
}
} else {
result["ip2region"] = "not loaded"
}
ip2regionMu.RUnlock()
case "ip2location":
ip2locMu.RLock()
if ip2locDB != nil {
rec, err := ip2locDB.Get_all(ip)
if err != nil {
result["ip2location"] = "error: " + err.Error()
} else {
// LITE 免费版不提供 asn/isp 等字段,库会返回占位提示串,统一过滤为空
clean := func(s string) string {
if s == "" || strings.HasPrefix(s, "This parameter is unavailable") {
return ""
}
return s
}
loc := map[string]string{
"country": clean(rec.Country_long),
"country_code": clean(rec.Country_short),
"administrative_area": clean(rec.Region),
"city": clean(rec.City),
"isp": clean(rec.Isp),
"asn": clean(rec.Asn),
"district": clean(rec.District),
"zipcode": clean(rec.Zipcode),
"timezone": clean(rec.Timezone),
"usagetype": clean(rec.Usagetype),
}
if rec.Latitude != 0 || rec.Longitude != 0 {
loc["latitude"] = strconv.FormatFloat(float64(rec.Latitude), 'f', -1, 32)
loc["longitude"] = strconv.FormatFloat(float64(rec.Longitude), 'f', -1, 32)
}
result["ip2location"] = loc
}
} else {
result["ip2location"] = "not loaded"
}
ip2locMu.RUnlock()
case "ip2location_asn":
ip2locASNMu.RLock()
if ip2locASNDB != nil {
rec, err := ip2locASNDB.Get_all(ip)
if err != nil {
result["ip2location_asn"] = "error: " + err.Error()
} else {
clean := func(s string) string {
if s == "" || strings.HasPrefix(s, "This parameter is unavailable") {
return ""
}
return s
}
result["ip2location_asn"] = map[string]string{
"asn": clean(rec.Asn),
"as": clean(rec.As),
}
}
} else {
result["ip2location_asn"] = "not loaded"
}
ip2locASNMu.RUnlock()
case "qqwry":
qqwryMu.RLock()
if qqwryDB != nil {
@@ -727,3 +874,28 @@ func SearchIP(ip string, databases ...string) map[string]interface{} {
return result
}
// parseIP2Region 将 ip2region xdb 的管道分隔串预分割为结构化 JSON。
// 实测 v4/v6 xdb 格式:国家|省份|城市|ISP(或"0")|国家代码(5 段,v4 的 ISP 段多为 "0");
// 兼容老 4 段格式:国家|省份|城市|ISP。
// 输出字段与 bilibili / geocn / maxmind_city 对齐:country / administrative_area / city / isp。
func parseIP2Region(s string) map[string]string {
parts := strings.Split(s, "|")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
get := func(i int) string {
if i < len(parts) {
return parts[i]
}
return ""
}
res := map[string]string{"country": get(0)}
// 5 段:国家|省份|城市|ISP(可能为"0")|国家代码;4 段及以下:国家|省份|城市|ISP
res["administrative_area"] = get(1)
res["city"] = get(2)
if isp := get(3); isp != "" && isp != "0" {
res["isp"] = isp
}
return res
}
+282 -109
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"lemon-ipw/ipdb"
@@ -14,11 +15,14 @@ import (
"net/http"
"net/url"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/AdguardTeam/golibs/netutil/sysresolv"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/klauspost/compress/zstd"
@@ -214,33 +218,39 @@ func (s *Setting) PortString() string {
// Global variables and structs
// 全局变量与结构体
var (
PORTS string
GH_PROXY string
LOG_LEVEL string
websiteCache sync.Map
SINGLE_STACK string
DNS_SERVER string
DNSSEC_DNS_SERVER string // DNSSEC 专用 DNS 服务器(dnssec-server / DNSSEC_DNS_SERVER),留空沿用 DNS_SERVER
sslCache sync.Map
pingCache sync.Map
speedCache sync.Map
whoisCache sync.Map
asnWhoisCache sync.Map
sfGroup singleflight.Group
V6Client *resty.Client
V4Client *resty.Client
IPDB string
CORS string
ACCEPT_DOMAINS []string
ACCESS_TOKEN string
REMOTE_CONFIG_URL string
REMOTE_INGORE_CONFIG []string // 不被远端配置覆盖的配置项列表(remote-ingore-config / REMOTE_INGORE_CONFIG)
WS_URL string // WS 客户端:中间件 WS 地址(ws://host:port/ws)
WS_NODE_ID string // WS 客户端:节点 id(与中间件 apiKeys 键一致)
WS_NODE_KEY string // WS 客户端:注册 key(与中间件 apiKeys[节点id] 一致,可空)
VERSION string
COMMIT string
BUILD_TIME string
PORTS string
GH_PROXY string
LOG_LEVEL string
websiteCache sync.Map
SINGLE_STACK string
DNS_SERVER string
DNSSEC_DNS_SERVER string // DNSSEC 专用 DNS 服务器(dnssec-server / DNSSEC_DNS_SERVER),留空沿用 DNS_SERVER
sslCache sync.Map
pingCache sync.Map
speedCache sync.Map
whoisCache sync.Map
asnWhoisCache sync.Map
sfGroup singleflight.Group
V6Client *resty.Client
V4Client *resty.Client
IPDB string
CORS string
ACCEPT_DOMAINS []string
ACCESS_TOKEN string
REMOTE_CONFIG_URL string
REMOTE_IGNORE_CONFIG []string // 不被远端配置覆盖的配置项列表(remote-ignore-config / REMOTE_IGNORE_CONFIG)
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] 一致,可空)
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 判定
NODE_OTA bool // OTA 升级开关(node-ota / NODE_OTA):缺省允许收集中心下发;只读容器等不可自更新部署显式设 false,节点拒绝指令并回传原因
httpServer *http.Server // 显式持有的 HTTP 服务(收到退出信号时优雅停机需要先 Shutdown)
VERSION string
COMMIT string
BUILD_TIME string
)
type websiteCacheEntry struct {
@@ -278,8 +288,6 @@ type WebsiteCheckResult struct {
IPv6 *webtest.WebsiteCheckDetail `json:"ipv6"`
}
type SSLCheckResult struct {
IPv4 *webtest.SSLCheckDetail `json:"ipv4"`
IPv6 *webtest.SSLCheckDetail `json:"ipv6"`
@@ -292,10 +300,6 @@ type TCPingResult struct {
// Business Endpoints
// 业务端点
func checkWebsiteHandler(c *gin.Context) {
testUrl := c.Param("url")
if testUrl == "" {
@@ -307,8 +311,8 @@ func checkWebsiteHandler(c *gin.Context) {
testUrl = normalizeURL(testUrl)
parsedURL, _ := url.Parse(testUrl)
if ssrf.HasLocalOrPrivateIP(parsedURL.Hostname()) {
parsedURL, err := url.Parse(testUrl)
if err == nil && ssrf.HasLocalOrPrivateIP(parsedURL.Hostname()) {
c.JSON(200, &WebsiteCheckResult{
IPv4: fakePerfectWebsiteResult(testUrl),
IPv6: fakePerfectWebsiteResult(testUrl),
@@ -387,7 +391,17 @@ func checkWebsiteHandler(c *gin.Context) {
websiteCache.Store(testUrl, websiteCacheEntry{result: result, timestamp: time.Now()})
if (result.IPv4 != nil && !result.IPv4.IsReachable) || (result.IPv6 != nil && !result.IPv6.IsReachable) {
// 单栈模式下被跳过的一侧 IsReachable 恒为 false,不能据此把成功结果也判定为失败提前清缓存
failed := false
switch SINGLE_STACK {
case "ipv4":
failed = result.IPv4 != nil && !result.IPv4.IsReachable
case "ipv6":
failed = result.IPv6 != nil && !result.IPv6.IsReachable
default:
failed = (result.IPv4 != nil && !result.IPv4.IsReachable) || (result.IPv6 != nil && !result.IPv6.IsReachable)
}
if failed {
go func() {
time.Sleep(30 * time.Second)
websiteCache.Delete(testUrl)
@@ -485,8 +499,8 @@ func sslCheckHandler(c *gin.Context) {
testUrl = normalizeURL(testUrl)
parsedURL, _ := url.Parse(testUrl)
if ssrf.HasLocalOrPrivateIP(parsedURL.Hostname()) {
parsedURL, err := url.Parse(testUrl)
if err == nil && ssrf.HasLocalOrPrivateIP(parsedURL.Hostname()) {
c.JSON(200, &SSLCheckResult{
IPv4: fakeInvalidSSLResult(parsedURL.Hostname()),
IPv6: fakeInvalidSSLResult(parsedURL.Hostname()),
@@ -571,7 +585,17 @@ func sslCheckHandler(c *gin.Context) {
sslCache.Store(testUrl, sslCacheEntry{result: result, timestamp: time.Now()})
if (result.IPv4 != nil && !result.IPv4.IsReachable) || (result.IPv6 != nil && !result.IPv6.IsReachable) {
// 单栈模式下被跳过的一侧 IsReachable 恒为 false,不能据此把成功结果也判定为失败提前清缓存
failed := false
switch SINGLE_STACK {
case "ipv4":
failed = result.IPv4 != nil && !result.IPv4.IsReachable
case "ipv6":
failed = result.IPv6 != nil && !result.IPv6.IsReachable
default:
failed = (result.IPv4 != nil && !result.IPv4.IsReachable) || (result.IPv6 != nil && !result.IPv6.IsReachable)
}
if failed {
go func() {
time.Sleep(30 * time.Second)
sslCache.Delete(testUrl)
@@ -605,12 +629,23 @@ func asnLookupHandler(c *gin.Context) {
return
}
result := ipdb.SearchIP(ip, "maxmind_asn", "dbip_asn")
result := ipdb.SearchIP(ip, "maxmind_asn", "dbip_asn", "ip2location_asn")
asnResult := map[string]interface{}{
"ip": ip,
}
if ip2locASN, ok := result["ip2location_asn"].(map[string]string); ok && ip2locASN["asn"] != "" {
asnResult["ip2location_asn"] = map[string]string{
"asn": ip2locASN["asn"],
"as": ip2locASN["as"],
}
} else if errStr, ok := result["ip2location_asn"].(string); ok {
asnResult["ip2location_asn"] = map[string]string{
"error": errStr,
}
}
if maxmindASN, ok := result["maxmind_asn"].(*ipdb.MMDBASNResult); ok {
asnResult["geolite2_asn"] = map[string]string{
"asn": maxmindASN.ASN,
@@ -825,6 +860,8 @@ func pingHandler(c *gin.Context) {
})
return
}
// 拨号用归一化后的端口:Atoi 会放行 "+80"/"0080",原样拼接会导致拨号必败
port = strconv.Itoa(portNum)
count := 4
if countStr := c.Query("count"); countStr != "" {
@@ -920,9 +957,15 @@ func pingHandler(c *gin.Context) {
c.JSON(200, rawResult.(*TCPingResult))
}
// healchCheck 健康检查(GET /)。除 status 外回报版本号:中间件对 HTTP 版节点(无 WS 连接)
// 正是靠这个接口探活,顺带取版本用于节点状态页展示。
func healchCheck(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"version": VERSION,
// 能力清单同 register 报文:中间件对纯 HTTP 节点探活时一并取回,
// 用于在下发 config 前判断该节点能否理解对应指令(见 ws.go nodeCapabilities)
"capabilities": nodeCapabilities(),
})
}
func tokenCheck() gin.HandlerFunc {
@@ -935,6 +978,7 @@ func tokenCheck() gin.HandlerFunc {
}
}
}
// fetchRemoteConfig 从远端 URL 拉取配置文件(遵守 setting.json 的 JSON 格式)。
// 拉取或解析失败时返回错误,调用方应回退到本地配置。
func fetchRemoteConfig(url string) (map[string]any, error) {
@@ -980,7 +1024,7 @@ func configValue(CONFIG map[string]any, key string) string {
// applyRemoteConfig 从远端配置 URL(REMOTE_CONFIG_URL,可由环境变量或 setting.json
// 的 remote-config-url 提供)拉取配置并覆盖本地配置。
// 优先级:远端配置 > 环境变量 > setting.json。
// access_token 例外:保持原有优先级(环境变量 > setting.json),不随远端配置覆盖。
// access-token 例外:保持原有优先级(环境变量 > setting.json),不随远端配置覆盖。
func applyRemoteConfig() {
url := REMOTE_CONFIG_URL
if url == "" {
@@ -991,55 +1035,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_INGORE_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, "ipw-ws-url"); v != "" && !ignored("ipw-ws-url") {
WS_URL = v
}
if v := configValue(CONFIG, "ipw-node-id"); v != "" && !ignored("ipw-node-id") {
WS_NODE_ID = v
}
if v := configValue(CONFIG, "ipw-node-key"); v != "" && !ignored("ipw-node-key") {
WS_NODE_KEY = 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 = strings.Split(CORS, ",")
ACCEPT_DOMAINS = splitAndTrim(CORS, ",")
}
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)
}
slog.Info("Remote config applied", "url", url)
}
func readConfig() {
@@ -1054,7 +1061,7 @@ func readConfig() {
IPDB = os.Getenv("IPDB")
CORS = os.Getenv("CORS")
ACCESS_TOKEN = os.Getenv("ACCESS_TOKEN")
ssrf.SetEnabled(os.Getenv("BLOCK_PRIVATE_IPS") != "false" && os.Getenv("BLOCK_PRIVATE_IPS") != "0")
TRUSTED_PROXIES = os.Getenv("TRUSTED_PROXIES")
// SINGLE_STACK is intentionally excluded: empty string is a valid value (dual-stack).
@@ -1085,48 +1092,150 @@ func readConfig() {
if CORS == "" {
CORS = viper.GetString("cors")
}
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"
}
if CORS != "" {
ACCEPT_DOMAINS = strings.Split(CORS, ",")
ACCEPT_DOMAINS = splitAndTrim(CORS, ",")
}
if ACCESS_TOKEN == "" {
ACCESS_TOKEN = viper.GetString("access_token")
ACCESS_TOKEN = viper.GetString("access-token")
}
// REMOTE_CONFIG_URL 优先级:环境变量 > setting.json 的 remote-config-url
REMOTE_CONFIG_URL = os.Getenv("REMOTE_CONFIG_URL")
if REMOTE_CONFIG_URL == "" {
REMOTE_CONFIG_URL = viper.GetString("remote-config-url")
}
// WS 客户端(接入中间件 WS 通道):IPW_WS_URL / IPW_NODE_ID / IPW_NODE_KEY,env 优先
WS_URL = os.Getenv("IPW_WS_URL")
// WS 客户端(接入中间件 WS 通道):WS_URL / NODE_ID / NODE_KEY,env 优先
WS_URL = os.Getenv("WS_URL")
if WS_URL == "" {
WS_URL = viper.GetString("ipw-ws-url")
WS_URL = viper.GetString("ws-url")
}
WS_NODE_ID = os.Getenv("IPW_NODE_ID")
WS_NODE_ID = os.Getenv("NODE_ID")
if WS_NODE_ID == "" {
WS_NODE_ID = viper.GetString("ipw-node-id")
WS_NODE_ID = viper.GetString("node-id")
}
WS_NODE_KEY = os.Getenv("IPW_NODE_KEY")
WS_NODE_KEY = os.Getenv("NODE_KEY")
if WS_NODE_KEY == "" {
WS_NODE_KEY = viper.GetString("ipw-node-key")
WS_NODE_KEY = viper.GetString("node-key")
}
// REMOTE_INGORE_CONFIG:不被远端覆盖的配置项列表(JSON 数组字符串,env 优先)
if raw := os.Getenv("REMOTE_INGORE_CONFIG"); raw != "" {
// 数据上报(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 != "" {
var list []string
if err := json.Unmarshal([]byte(raw), &list); err == nil {
REMOTE_INGORE_CONFIG = list
REMOTE_IGNORE_CONFIG = list
} else {
slog.Warn("invalid REMOTE_INGORE_CONFIG, ignored", "error", err)
slog.Warn("invalid REMOTE_IGNORE_CONFIG, ignored", "error", err)
}
} else {
REMOTE_INGORE_CONFIG = viper.GetStringSlice("remote-ingore-config")
REMOTE_IGNORE_CONFIG = viper.GetStringSlice("remote-ignore-config")
}
applyRemoteConfig()
var SYSTEM_DNS string
if DNS_SERVER == "" || DNSSEC_DNS_SERVER == "" {
sysDNS, err := sysresolv.NewSystemResolvers(nil, 53)
if err != nil {
// 失败时 sysDNS 为 nil,绝不能再调用 Refresh(),否则启动即 panic;
// SYSTEM_DNS 留空,交给下游的内置默认 DNS 兜底
slog.Warn("Cannot setup system resolvers, DNS_SERVER will fall back to webtest defaults", "error", err)
} else {
sysDNS.Refresh()
SYSTEM_DNS = webtest.AddrPortsToCSV(sysDNS.Addrs())
}
}
if DNSSEC_DNS_SERVER == "" {
DNSSEC_DNS_SERVER = SYSTEM_DNS
}
if DNS_SERVER == "" {
DNS_SERVER = SYSTEM_DNS
}
slog.Info("SSRF protection initialized", "blockPrivateIPs", ssrf.Enabled())
}
// splitAndTrim 按逗号切分并去掉每段首尾空白:CORS 配置 "a.com, b.com" 带空格时,
// 直接 Split 会产生带前导空格的 origin,永远匹配不上请求头
func splitAndTrim(s, sep string) []string {
parts := strings.Split(s, sep)
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// sweepExpiredCaches 按条目时间戳清扫各 sync.Map 缓存(TTL 均为 5 分钟,清扫窗口取 10 分钟)
func sweepExpiredCaches() {
cutoff := time.Now().Add(-10 * time.Minute)
sweep := func(m *sync.Map) {
m.Range(func(k, v any) bool {
var ts time.Time
switch e := v.(type) {
case websiteCacheEntry:
ts = e.timestamp
case sslCacheEntry:
ts = e.timestamp
case pingCacheEntry:
ts = e.timestamp
case speedCacheEntry:
ts = e.timestamp
case whoisCacheEntry:
ts = e.timestamp
case asnWhoisCacheEntry:
ts = e.timestamp
default:
return true
}
if ts.Before(cutoff) {
m.Delete(k)
}
return true
})
}
sweep(&websiteCache)
sweep(&sslCache)
sweep(&pingCache)
sweep(&speedCache)
sweep(&whoisCache)
sweep(&asnWhoisCache)
ipdb.SweepBilibiliCache()
}
func main() {
for _, vaule := range os.Args {
if vaule == "-v" || vaule == "--version" || vaule == "version" {
@@ -1150,13 +1259,37 @@ 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 通道(配置 IPW_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()
// 数据上报:WS 在线走 WS 广播,否则 HTTP POST 收集中心 /report(详见 report.go)
startNodeReporter()
// 缓存清扫:各 sync.Map 只在同 key 重访时惰性淘汰过期条目,
// 公网端点被唯一 key 洪打时内存会无限增长,这里定期清扫
go func() {
for {
time.Sleep(10 * time.Minute)
sweepExpiredCaches()
}
}()
r := gin.Default()
// TRUSTED_PROXIES / trusted-proxies:逗号分隔的可信代理(IP/CIDR)。配置后 ClientIP 只信任这些
// 代理转发的 X-Forwarded-For;未配置时保持 gin 默认行为(信任所有代理)
if strings.TrimSpace(TRUSTED_PROXIES) != "" {
var proxies []string
for _, p := range strings.Split(TRUSTED_PROXIES, ",") {
if p = strings.TrimSpace(p); p != "" {
proxies = append(proxies, p)
}
}
if err := r.SetTrustedProxies(proxies); err != nil {
slog.Warn("Invalid TRUSTED_PROXIES, keep gin default", "error", err)
}
}
corsConfig := cors.DefaultConfig()
if len(ACCEPT_DOMAINS) > 0 {
@@ -1170,6 +1303,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)
@@ -1186,9 +1320,48 @@ func main() {
}
}
// 运行时配置接口(/v1/config):鉴权同业务接口,但不计入拨测统计
registerConfigRoutes(r)
// OTA 升级接口(POST /v1/ota):收集中心 HTTP 回退通道,鉴权语义同 config(见 ota.go)
registerOTARoute(r)
r.GET("/", healchCheck)
if err := r.Run(":" + PORTS); err != nil {
// 显式持有 http.Server:收到退出信号时先 Shutdown 释放端口,避免 r.Run 的隐式 Server 无法受控关闭
httpServer = &http.Server{Handler: r}
ln, err := net.Listen("tcp", ":"+PORTS)
if err != nil {
slog.Error("Server failed to start", "error", err)
os.Exit(1)
}
go func() {
// 优雅重启时 Shutdown 会正常关闭 Server,返回 ErrServerClosed 属预期
if err := httpServer.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("Server failed to start", "error", err)
os.Exit(1)
}
}()
// main 必须阻塞,否则 Server goroutine 随 main 返回而消亡,进程启动后立即退出。
// 阻塞在退出信号上,收到后走优雅停机。
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
<-sigCh
gracefulShutdown()
}
// gracefulShutdown 优雅停止 HTTP 服务:停止接收新请求,等待在途请求完成(上限 30s)。
// 供收到退出信号时调用,确保监听端口先释放再退出进程。
func gracefulShutdown() {
if httpServer == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
slog.Info("Graceful shutdown started, waiting for in-flight requests")
if err := httpServer.Shutdown(ctx); err != nil {
slog.Warn("Graceful shutdown timed out, forcing close", "error", err)
httpServer.Close()
}
}
+472
View File
@@ -0,0 +1,472 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"time"
"github.com/coder/websocket"
"github.com/gin-gonic/gin"
)
// ==================== 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 (
otaMinSize = 1 * 1024 * 1024 // 下载文件最小体积,防止拿到错误页/占位文件
otaDownloadTimeout = 10 * time.Minute // 单次下载超时
)
// otaAssetBaseDefault 按版本下发且未指定 assetBase 时的默认发布地址
const otaAssetBaseDefault = "https://github.com/nomdn/ipw-cn/releases/download"
// 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 true, false
}
// 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
}
// 本地开关(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)
})
}
// 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 命名一致)
func otaAssetNames() []string {
suffix := ""
if runtime.GOOS == "windows" {
suffix = ".exe"
}
// GOARCH=arm 时无法在运行时区分 GOARM(armv7/armv6),依次尝试
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, runtime.GOARCH, suffix)}
}
// 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
}
tag := req.Version
if !strings.HasPrefix(tag, "v") {
tag = "v" + tag // 本仓库 release 一律 v 前缀(CI 由 v* 标签触发),宽容无前缀写法
}
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)
}
}
var lastErr error
for _, u := range urls {
err := otaDownloadOne(u, dst, req.SHA256)
if err == nil {
return nil
}
slog.Warn("[ota] download candidate failed", "url", u, "error", err)
lastErr = err
_ = os.Remove(dst)
}
return lastErr
}
// 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
}
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 wantSHA != "" {
got := hex.EncodeToString(hash.Sum(nil))
if !strings.EqualFold(got, wantSHA) {
_ = os.Remove(dst)
return fmt.Errorf("sha256 mismatch: got %s", got)
}
}
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 到原位置;失败则回滚
func replaceBinary(tmpPath, exePath string) error {
oldPath := exePath + ".old"
_ = os.Remove(oldPath)
if err := os.Rename(exePath, oldPath); err != nil {
return fmt.Errorf("backup current binary: %w", err)
}
if err := os.Rename(tmpPath, exePath); err != nil {
_ = os.Rename(oldPath, exePath) // 回滚
return fmt.Errorf("install new binary: %w", err)
}
if runtime.GOOS != "windows" {
if err := os.Chmod(exePath, 0o755); err != nil {
slog.Warn("[ota] chmod failed", "error", err)
}
}
return nil
}
// restartSelf 用新二进制重启进程:
// - Unix:syscall.Exec 原地替换进程镜像(PID 不变,systemd/Docker 无感)
// - Windows:运行中的 exe 只能重命名不能替换,采用优雅交接:
// 优雅停机(端口释放)→ 拉起新进程 → 健康检查通过后老进程退出;失败回滚 .old
func restartSelf(exePath string) {
if runtime.GOOS == "windows" {
gracefulShutdown()
cmd := exec.Command(exePath, os.Args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
if err := cmd.Start(); err != nil {
slog.Error("[ota] restart failed, waiting for supervisor", "error", err)
os.Exit(1)
}
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)
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)
os.Exit(1)
}
}
// otaWaitChildReady 轮询新进程的健康检查接口(GET /),确认其完成端口绑定并对外服务。
// 返回 true = 就绪;false = 子进程启动期间退出(立即判定失败)或超时未就绪。
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)
// 监听子进程退出:起不来直接失败,不用干等超时
exited := make(chan struct{})
go func() {
_ = cmd.Wait()
close(exited)
}()
for {
if resp, err := client.Get(url); err == nil {
resp.Body.Close()
return true
}
select {
case <-exited:
return false
case <-time.After(300 * time.Millisecond):
}
if time.Now().After(deadline) {
return false
}
}
}
// 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)
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)
return false
}
if err := os.Rename(oldPath, exePath); err != nil {
slog.Error("[ota] rollback: cannot restore old binary", "error", err)
_ = os.Rename(failed, exePath) // 尽力恢复现场
return false
}
cmd := exec.Command(exePath, os.Args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
if err := cmd.Start(); err != nil {
slog.Error("[ota] rollback: cannot start old binary", "error", err)
return false
}
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")
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)
}
+21
View File
@@ -0,0 +1,21 @@
{
"port": 8080,
"gh-proxy": "",
"single-stack": "",
"dns-server": "",
"dnssec-server": "",
"block-private-ips": true,
"ipdb": "false",
"cors": "",
"trusted-proxies": "",
"remote-config-url": "",
"remote-ignore-config": [],
"access-token": "",
"ws-url": "",
"node-id": "",
"node-key": "",
"node-ota": "true",
"report-url": "",
"report-token": "",
"report-interval-seconds": 15
}
@@ -45,24 +45,49 @@ func SetEnabled(v bool) {
blockPrivateIPs = v
}
// Go 标准库 IsPrivate 只覆盖 RFC1918 与 fd00::/8,这里补齐其余不应出网的内部/特殊网段
var extraPrivateNets = mustParseCIDRs(
"100.64.0.0/10", // CGNAT:运营商级 NAT、云内网、Tailscale 等
"198.18.0.0/15", // 网络基准测试保留段
"192.0.0.0/24", // IETF 协议分配
"fc00::/7", // IPv6 ULA(IsPrivate 只认 fd00::/8)
"64:ff9b::/96", // NAT64:内嵌 IPv4 会被网关转换
)
func mustParseCIDRs(cidrs ...string) []*net.IPNet {
nets := make([]*net.IPNet, 0, len(cidrs))
for _, c := range cidrs {
_, ipNet, err := net.ParseCIDR(c)
if err != nil {
panic(fmt.Sprintf("invalid CIDR %q: %v", c, err))
}
nets = append(nets, ipNet)
}
return nets
}
// IsPrivateIP checks if the given IP address belongs to a private or internal range.
// IsPrivateIP 检查给定的 IP 地址是否属于私有或内部地址段。
// It covers RFC 1918 private addresses, loopback, link-local, and unspecified addresses.
// 涵盖 RFC 1918 私有地址、回环地址、链路本地地址和未指定地址。
// It covers RFC 1918 private addresses, loopback, link-local, and unspecified addresses,
// plus CGNAT/ULA/NAT64/benchmark/multicast ranges that Go's IsPrivate does not cover.
// 涵盖 RFC 1918 私有地址、回环地址、链路本地地址和未指定地址,
// 以及 Go 标准库未覆盖的 CGNAT/ULA/NAT64/基准测试/组播段。
// This is the core check used to block SSRF attacks targeting internal networks.
// 这是用于阻止针对内部网络的 SSRF 攻击的核心检查。
func IsPrivateIP(ip net.IP) bool {
if ip.IsPrivate() {
if ip == nil {
return false
}
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() || ip.IsMulticast() {
return true
}
if ip.IsLoopback() {
if ip.Equal(net.IPv4bcast) {
return true
}
if ip.IsLinkLocalUnicast() {
return true
}
if ip.IsUnspecified() {
return true
for _, n := range extraPrivateNets {
if n.Contains(ip) {
return true
}
}
return false
}
@@ -133,36 +158,35 @@ func ValidateOutboundTarget(ctx context.Context, targetURL string) (context.Cont
// SecureCheckRedirect is a redirect policy that prevents SSRF via HTTP redirect chains.
// SecureCheckRedirect 是一个重定向策略,防止通过 HTTP 重定向链进行 SSRF 攻击。
//
// When an HTTP client follows redirects (301, 302, 307, 308), each intermediate target
// 当 HTTP 客户端跟随重定向(301、302、307、308)时,会校验每个中间目标
// is resolved and checked against private/internal IP ranges.
// 的解析结果,并检查是否指向私有/内部 IP 段。
//
// This blocks attackers from:
// 这阻止攻击者通过以下方式绕过防护:
// - Using an initial public redirect that points to an internal service.
// 使用指向内部服务的初始公开重定向。
// - Chaining multiple redirects to eventually reach a private IP.
// 链式多个重定向最终到达私有 IP。
// req 是即将跳转的目标(via 是已访问的链)。必须校验目标本身:
// 只检查 via 会漏掉重定向链的最后一跳。每一跳的目标都会被解析并检查
// 是否指向私有/内部 IP 段,同时限制重定向深度。
// 这阻止攻击者通过链式重定向最终到达私有 IP。
func SecureCheckRedirect(req *http.Request, via []*http.Request) error {
if !blockPrivateIPs {
return nil
}
for _, r := range via {
redirectURL := r.URL
host := redirectURL.Hostname()
if host == "" {
continue
}
ips, err := net.LookupIP(host)
if err != nil {
return err
}
for _, ip := range ips {
if IsPrivateIP(ip) {
slog.Warn("Blocked redirect to private IP", "host", host, "ip", ip)
return fmt.Errorf("redirect to private/internal address is not allowed")
}
if req == nil || req.URL == nil {
return fmt.Errorf("invalid redirect target")
}
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
return fmt.Errorf("invalid redirect scheme: %s", req.URL.Scheme)
}
host := req.URL.Hostname()
if host == "" {
return fmt.Errorf("redirect target has empty host")
}
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
ips, err := net.LookupIP(host)
if err != nil {
return err
}
for _, ip := range ips {
if IsPrivateIP(ip) {
slog.Warn("Blocked redirect to private IP", "host", host, "ip", ip)
return fmt.Errorf("redirect to private/internal address is not allowed")
}
}
return nil
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,6 +1,7 @@
package webtest
import (
"net/netip"
"net/url"
"strings"
"time"
@@ -42,3 +43,13 @@ func measureDNSTime(urlStr string, version string) float64 {
}
return time.Since(start).Seconds() * 1000
}
func AddrPortsToCSV(addrs []netip.AddrPort) string {
if len(addrs) == 0 {
return ""
}
parts := make([]string, len(addrs))
for i, addr := range addrs {
parts[i] = addr.String() // 自动格式化为 "IP:Port"
}
return strings.Join(parts, ",")
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+24 -18
View File
@@ -1,6 +1,7 @@
package webtest
import (
"bytes"
"context"
"fmt"
"net"
@@ -200,7 +201,6 @@ func happyEyeballsWhoisQuery(domain string, v4IPs, v6IPs []string, port string)
}
// 有缓冲:保证每个 goroutine 写入不被阻塞,避免 goroutine 泄漏
ch := make(chan outcome, len(v4IPs)+len(v6IPs))
pending := 0
tryIP := func(ip string) {
raw, err := rawWhoisQueryCtx(ctx, domain, ip, port)
@@ -209,7 +209,6 @@ func happyEyeballsWhoisQuery(domain string, v4IPs, v6IPs []string, port string)
// 1) 立即启动所有 v4
for _, ip := range v4IPs {
pending++
go tryIP(ip)
}
@@ -218,7 +217,6 @@ func happyEyeballsWhoisQuery(domain string, v4IPs, v6IPs []string, port string)
if len(v6IPs) > 0 {
v6Timer = time.AfterFunc(happyEyeballsV6Delay, func() {
for _, ip := range v6IPs {
pending++
go tryIP(ip)
}
})
@@ -241,9 +239,7 @@ func happyEyeballsWhoisQuery(domain string, v4IPs, v6IPs []string, port string)
case <-ctx.Done():
return "", ctx.Err()
}
// 注意:pending 是在 goroutine 内自增,这里用固定上限做兜底计数
// 如果 v6 还没启动完,但 v4 已经全部失败也没有成功,需要等待 pending 全部发完
// 简化起见:若 i 到达 len(v4IPs) 但 v6 还在 pending,我们再等一会
// v4 全部失败且无成功时,给延迟启动的 v6 goroutine 留一个启动 + 写入窗口
if i == len(v4IPs)-1 && v6Timer != nil && lastErr != nil {
// 给 v6 一个启动 + 写入窗口
time.Sleep(happyEyeballsV6Delay + 20*time.Millisecond)
@@ -336,19 +332,29 @@ func rawWhoisQueryCtx(ctx context.Context, domain, server, port string) (string,
}
}
// 3. 读取响应
// 3. 读取响应:循环读到 EOF/超时。单次 Read 只能拿到一个 TCP 段,会截断大多数 WHOIS 响应
buf := make([]byte, 65536)
n, readErr := conn.Read(buf)
// 有数据就读到了,哪怕 readErr != nil 也先返回数据
if n > 0 {
return string(buf[:n]), nil
}
select {
case <-ctx.Done():
return "", ctx.Err()
default:
return "", readErr
var out bytes.Buffer
for {
n, readErr := conn.Read(buf)
if n > 0 {
out.Write(buf[:n])
}
if readErr != nil {
// 有数据就返回(EOF/超时都视为读完),哪怕 readErr != nil
if out.Len() > 0 {
return out.String(), nil
}
select {
case <-ctx.Done():
return "", ctx.Err()
default:
return "", readErr
}
}
if out.Len() > 1<<20 { // 防御上限 1MB,避免异常响应无限读
return out.String(), nil
}
}
}
+164 -39
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
"sync"
"time"
@@ -18,15 +19,15 @@ import (
//
// 配置(readConfig 解析,环境变量优先,其次 setting.json,远端配置可覆盖):
//
// IPW_WS_URL ws://<中间件>:8092/ws,ws://<备中间件>:8092/ws (逗号分隔多个中间件,主失败自动切换下一个;不配置 = 不启用)
// IPW_NODE_ID 节点 id(与中间件 apiKeys 键 / 前端配置 id 一致)
// IPW_NODE_KEY 注册 key(与中间件 apiKeys[节点id] 一致;中间件未配置该节点 key 时留空)
// WS_URL ws://<中间件>:8092/ws,ws://<备中间件>:8092/ws (逗号分隔多个中间件,同时连接全部,多活;不配置 = 不启用)
// NODE_ID 节点 id(与中间件 ws-keys 键 / 前端配置 id 一致)
// NODE_KEY 注册 key(与中间件 ws-keys[节点id] 一致;中间件未配置该节点 key 时留空)
//
// 与 HTTP handler 共用同一批 webtest 探针函数和缓存:收到 probe 后按 apiType 取数(带缓存),
// 返回处理在本文件内完成,结果经 probe_result 上报。节点 HTTP 接口完全不变。
//
// 心跳:NODE 与 MIDDLEWARE 双向心跳。本端每 10s 发一次 ping,发送失败重试,连续失败 3 次
// 判定当前中间件不可用,断开并切换下一个 IPW_WS_URL;收到中间件 ping 回 pong。
// 判定当前中间件不可用,断开该连接并按同一 URL 独立重连(不影响其他中间件连接)。
// wsProbe 学 HTTP handler:从函数拿数据 + 缓存,返回 (status, body)。
// raw 与 query 来自中间件 probe 消息(等价于路由参数)。
@@ -106,6 +107,12 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
if port == "" {
port = "80"
}
// 与 HTTP handler 同样校验端口/次数,避免垃圾参数直接进拨号
p, err := strconv.Atoi(port)
if err != nil || p < 1 || p > 65535 {
return 400, map[string]string{"error": "Invalid port number"}
}
port = strconv.Itoa(p)
count := query["count"]
n := 4
if count != "" {
@@ -126,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()})
@@ -200,8 +209,13 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
if raw == "" {
return 400, map[string]string{"error": "IP parameter is required"}
}
result := ipdb.SearchIP(raw, "maxmind_asn", "dbip_asn")
result := ipdb.SearchIP(raw, "maxmind_asn", "dbip_asn", "ip2location_asn")
asnResult := map[string]any{"ip": raw}
if ip2locASN, ok := result["ip2location_asn"].(map[string]string); ok && ip2locASN["asn"] != "" {
asnResult["ip2location_asn"] = map[string]string{"asn": ip2locASN["asn"], "as": ip2locASN["as"]}
} else if errStr, ok := result["ip2location_asn"].(string); ok {
asnResult["ip2location_asn"] = map[string]string{"error": errStr}
}
if maxmindASN, ok := result["maxmind_asn"].(*ipdb.MMDBASNResult); ok {
asnResult["geolite2_asn"] = map[string]string{"asn": maxmindASN.ASN, "org": maxmindASN.Org}
} else if errStr, ok := result["maxmind_asn"].(string); ok {
@@ -322,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"`
@@ -360,46 +385,80 @@ const (
wsHeartbeatTimeout = 5 * time.Second
)
// wsClientOnce 连接并服务单个中间件 URL:
// - 连接/注册失败 → 返回 true(上层切换下一个中间件)
// - 心跳连续失败 wsHeartbeatRetry 次 → 主动断开,返回 true(切换下一个中间件)
// - 读循环断开 → 返回 true
// - 返回 false = 配置缺失(不再重试)
func wsClientOnce(url string) bool {
ctx := context.Background()
// nodeCapabilities 本节点支持的远程管理能力清单,随 register 报文上报给中间件,
// 同时也在 HTTP 健康检查 `/` 里返回(供中间件对纯 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 = 配置缺失(不再重试)
//
// 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 true
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
}
wsSend(c, wsMsg{Type: "register", Data: wsRaw(reg)})
_, data, err := c.Read(ctx)
// 注册应答限时读取:中间件半死(TCP 通但不回包)时 goroutine 会永久阻塞在 Read 上
regCtx, regCancel := context.WithTimeout(ctx, 15*time.Second)
defer regCancel()
_, data, err := c.Read(regCtx)
if err != nil {
slog.Warn("ws client register read failed", "url", url, "error", err)
return true
return 3 * time.Second
}
var m wsMsg
if json.Unmarshal(data, &m) != nil {
return true
return 3 * time.Second
}
if m.Type == "register_error" {
slog.Warn("ws client register rejected", "nodeId", WS_NODE_ID, "url", url, "data", string(m.Data))
return true
return 30 * time.Second
}
if m.Type != "register_ok" {
slog.Warn("ws client unexpected message", "type", m.Type)
return true
return 3 * time.Second
}
slog.Info("ws client registered", "nodeId", WS_NODE_ID, "url", url)
wsActiveConns.Store(url, c)
// 心跳 goroutine:每 10s 发 ping,失败重试,连续 3 次失败主动断开(上层切换下一个中间件)
// 心跳 goroutine:每 10s 发 ping,失败重试,连续 3 次失败主动断开(per-URL 循环重连同一中间件)
stopHeartbeat := make(chan struct{})
go func() {
fail := 0
@@ -413,7 +472,7 @@ func wsClientOnce(url string) bool {
fail++
slog.Warn("ws heartbeat send failed", "url", url, "fail", fail, "error", err)
if fail >= wsHeartbeatRetry {
slog.Warn("ws heartbeat failed too many times, switching middleware", "url", url)
slog.Warn("ws heartbeat failed too many times, closing connection (will retry same url)", "url", url)
c.Close(websocket.StatusPolicyViolation, "heartbeat timeout")
return
}
@@ -428,7 +487,7 @@ func wsClientOnce(url string) bool {
_, data, err := c.Read(ctx)
if err != nil {
slog.Warn("ws client disconnected", "url", url, "error", err)
return true
return 3 * time.Second
}
var msg wsMsg
if json.Unmarshal(data, &msg) != nil {
@@ -437,6 +496,10 @@ func wsClientOnce(url string) bool {
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":
@@ -445,22 +508,84 @@ func wsClientOnce(url string) bool {
}
}
// wsClientLoop 主循环:依次尝试所有配置的中间件 URL(逗号分隔,第一个为主),
// 当前中间件连接失败或心跳超时 → 3s 后切换下一个;全部失败循环重试。
func wsClientLoop() {
urls := splitWSURLs(WS_URL)
if len(urls) == 0 {
slog.Info("ws client disabled (IPW_WS_URL not set)")
return
// 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()
}
// 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 {
for _, url := range urls {
shouldRetry := wsClientOnce(url)
if !shouldRetry {
slog.Info("ws client stopped")
return
}
time.Sleep(3 * time.Second)
retryDelay := wsClientOnce(ctx, url)
if retryDelay == 0 {
slog.Info("ws client stopped", "url", url)
return
}
select {
case <-ctx.Done():
slog.Info("ws client stopped", "url", url)
return
case <-time.After(retryDelay):
}
}
}