Compare commits

...
47 Commits
Author SHA1 Message Date
nomdn fa9b83c951 docs(deploy-node): 修正 IP 数据库下载体积 200MB→450MB 2026-08-28 19:57:24 +08:00
nomdn c81f92bbd4 docs: DNS 自动探测兜底与数据库体积说明更新
- config.md / deploy-node.md / getting-started.md:dns-server/dnssec-server
  留空时启动自动探测系统 DNS 的说明
- 顺带修正 IP 数据库下载体积 200MB→450MB(含 IP2Location DB11+ASN)
2026-08-28 19:57:01 +08:00
nomdn a411d0bb72 feat(node): DNS/DNSSEC 服务器留空时自动探测系统 DNS 兜底
- readConfig:DNS_SERVER / DNSSEC_DNS_SERVER 未配置时,
  用 sysresolv.NewSystemResolvers 读取系统 DNS,
  经 AddrPortsToCSV 转 'IP:Port' 逗号分隔串后回填
- webtest/helpers.go 新增 AddrPortsToCSV([]netip.AddrPort) string
- go.mod:go 1.26.2→1.26.6,引入 AdguardTeam/golibs(间接依赖)
- main.go 全局变量区 gofmt 对齐
2026-08-28 19:55:36 +08:00
nomdn a93e74a8c7 refactor(ipv6): ipv6.vue 只保留跳转 /location,其余删除
- 页面简化为 onMounted 携带 ?ip= 跳转到 /location(归属地查询页)
- 删除原完整模板/脚本/样式(399 行 → 22 行)
2026-08-28 14:24:07 +08:00
nomdn 4cf136b367 feat(location): IP2Location 行右侧列显示 ASN 组织信息
- location.vue / ipv6.vue 的 IP2Location 行第三列显示 ip2location_asn.as(组织名),
  与 Maxmind(org) / DB-IP(org) 行结构对齐
- 补 IPLocationType 的 ip2location / ip2location_asn 字段
2026-08-28 14:22:11 +08:00
nomdn b350413902 style(location): IP2Location 行去掉邮编与时区展示 2026-08-28 14:16:52 +08:00
nomdn 08877871af feat(ipdb): 装载 IP2Location LITE ASN 数据库
- 新增 ip2location_asn 数据源:IP2LOCATION-LITE-ASN.IPV6.BIN(277MB,v4/v6 全覆盖)
- /v1/asn 与 WS asn 查询加入该源,输出 { asn, as },LITE 占位串过滤为空
- PullDatabase 自动下载(ghproxy 前缀)+ zip 解压复用
- 前端 asn.vue 展示 IP2Location ASN 行,描述更新为多数据源
2026-08-28 14:07:08 +08:00
nomdn d942ff0c5c feat(ipdb): 装载 IP2Location LITE DB11 数据库
- 新增 ip2location 数据源:官方 ip2location-go/v9 读取 .BIN(IPV6 双栈)
- PullDatabase 自动下载(ghproxy 前缀的 nomdn/ip2location release ZIP)+ 新增 zip 解压支持
- SearchIP 输出 {country, country_code, administrative_area, city, zipcode,
  timezone, latitude, longitude, asn, isp...};LITE 版不可用字段(isp/asn 等)过滤为空
- 前端 location/ipv6 页面与 /ip 路由展示 IP2Location 行
- .gitignore 忽略 *.BIN(229MB 数据不入库,靠自动下载)
2026-08-28 14:01:35 +08:00
nomdn 5aaf30dc16 fix(ipdb): 修正 ip2region 管道串解析错位
实测 v4/v6 xdb 返回格式为 国家|省份|城市|ISP(或0)|国家代码,
原实现按 国家|区域|省份|城市|ISP 切分导致省/市/ISP/国家代码全部错位。
- administrative_area/city 改取第 1/2 段
- isp 取第 3 段并过滤 "0"(v4 xdb 无运营商数据)
- 兼容老 4 段格式
2026-08-28 11:09:07 +08:00
nomdn 789968069b feat(ipv6webcheck): 增加网站不支持 IPv4 的检测结论分支
- 反向场景:v4 不可达但 v6 可达时提示『网站不支持 IPv4 访问』
- 配《IPv6发展报告》2026 文案与 jingya 图
2026-08-28 11:03:05 +08:00
nomdn 87fd2fd4df fix: 移除重复的节点配置并调整节点顺序 2026-08-27 13:39:21 +08:00
nomdn 57b5824370 Merge pull request #27 from nomdn/dependabot/go_modules/serverless/edgeone/cloud-functions/golang.org/x/net-0.55.0
chore(deps): bump golang.org/x/net from 0.54.0 to 0.55.0 in /serverless/edgeone/cloud-functions
2026-08-27 10:37:57 +08:00
nomdn 52796aa089 Merge pull request #29 from nomdn/dependabot/npm_and_yarn/serverless/lemon-getip/multi-d2d58c24ab
chore(deps): bump undici, @cloudflare/vitest-pool-workers and wrangler in /serverless/lemon-getip
2026-08-27 10:37:36 +08:00
dependabot[bot] 62b9f686e9 chore(deps): bump undici, @cloudflare/vitest-pool-workers and wrangler
Bumps [undici](https://github.com/nodejs/undici) to 7.29.0 and updates ancestor dependencies [undici](https://github.com/nodejs/undici), [@cloudflare/vitest-pool-workers](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/vitest-pool-workers) and [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler). These dependencies need to be updated together.


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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

## 修复

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

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

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

## 冒烟验证

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

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

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

## 文档

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

## 其他

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

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

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

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

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

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

## 文档

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

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

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

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

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

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

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

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

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

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

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

## 前端

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

## 文档

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

冒烟验证:WS 端到端(注册 key 四场景、拨测 200、断线切换中间件)、DNS 主从
failover、dnssec-server 专用、ws-port 三写法均通过;go build/vet 双端通过。
2026-08-20 22:54:08 +08:00
nomdn b4a18e0fbf feat: 文档站新增简介/部署方案页与 favicon,中间件不再透传 Origin
- docs: 新增 info/overview(项目概述)、guide/getting-started(快速入门:构建/配置/systemd)、deploy-node(EO/Vercel/Docker/二进制 + Serverless IPv4-only 说明)、deploy-middleware(Docker/二进制 + example 复制提示)
- docs: 前端部署页重构为 EdgeOne/Vercel/Cloudflare Workers/直接运行(去掉 Docker,去掉 CF 一键按钮);部署总览改三组件表;首页按钮指向快速开始;侧边栏分简介/部署;移植 favicon.svg/ico
- middleware-go / 前端内置中间件:转发不再携带 Origin,避免上游 CORS 误判(保留 Authorization Bearer 注入)
2026-08-19 16:10:39 +08:00
84 changed files with 4448 additions and 2683 deletions

No files matched your search

+20 -16
View File
@@ -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
+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
+4 -2
View File
@@ -15,9 +15,11 @@ lemon-ipw
*.tmp
*.log
node_modules/
setting.json
.workbuddy/
# VitePress 构建产物
docs/.vitepress/.temp/
docs/.vitepress/cache/
docs/.vitepress/dist/
docs/.vitepress/dist/
tmp/
setting.json
*.BIN
+32 -260
View File
@@ -1,278 +1,50 @@
# 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/guide/getting-started.md)
- [后端节点部署](docs/guide/deploy-node.md)
- [中间件部署](docs/guide/deploy-middleware.md)
- [前端部署](docs/guide/deploy-frontend.md)
- [配置文件](docs/guide/config.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 |
## 许可证
+24 -2
View File
@@ -25,6 +25,11 @@ export default defineConfig({
lang: "zh-CN",
cleanUrls: true,
head: [
['link', { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }],
['link', { rel: 'icon', href: '/favicon.ico' }],
],
themeConfig: {
// 合并 Teek 的 themeConfig(如 teekHome 等开关)
...teekConfig.themeConfig,
@@ -35,13 +40,30 @@ export default defineConfig({
{ text: 'GitHub', link: 'https://github.com/nomdn/ipw-cn', target: '_blank' },
],
// 右侧目录:显示 h2 + h3(默认只显示 h2)
outline: {
level: [2, 3],
label: '本页目录',
},
sidebar: [
// 上级分组:部署(新增页面时在 items 里补一条即可)
// 简介
{
text: '简介',
items: [
{ text: '概述', link: '/info/overview' },
{ text: '快速入门', link: '/guide/getting-started' },
{ text: '配置文件', link: '/guide/config' },
],
},
// 部署
{
text: '部署',
items: [
{ text: '部署指南', link: '/guide/' },
{ text: '前端部署', link: '/guide/deploy-frontend' },
{ text: '后端节点', link: '/guide/deploy-node' },
{ text: '中间件', link: '/guide/deploy-middleware' },
{ text: '前端', link: '/guide/deploy-frontend' },
],
},
],
+284
View File
@@ -0,0 +1,284 @@
# 配置文件
各组件配置说明与完整字段参考。快速上手示例见 [快速开始](/guide/getting-started)。
## 后端(setting.json)
后端从当前目录读取 `setting.json`,不存在时使用默认值。最小配置:
```json
{
"port": 8080,
"dns-server": "119.28.28.28:53",
"block-private-ips": true,
"ipdb": true,
"cors": "",
"access-token": ""
}
```
- `port`:监听端口
- `dns-server`:DNS 解析服务器,支持**主从 failover**——逗号分隔多地址(如 `"119.28.28.28:53,223.5.5.5:53"`),第一个为主,主查询失败(超时/网络错误)自动切换下一个;每项支持 `ip:port`(UDP)或 `https://` URL(DoH);**留空则启动时自动探测系统 DNS**(`IP:Port` 逗号分隔串)兜底
- `dnssec-server`:**DNSSEC 专用** DNS 服务器(同样支持主从逗号分隔);留空则沿用 `dns-server`;`dns-server` 与 `dnssec-server` **都留空时自动探测系统 DNS** 兜底
- `block-private-ips`:SSRF 防护开关
- `ipdb`:IP 数据库开关(首次启动自动下载约 450MB,之后每 24h 更新)
- `cors`:允许的请求来源(逗号分隔)
- `access-token`:API 访问令牌,留空则不启用鉴权
- `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)
以上字段均可用环境变量覆盖(`PORTS` / `DNS_SERVER` / `DNSSEC_DNS_SERVER` / `BLOCK_PRIVATE_IPS` / `IPDB` / `CORS` / `ACCESS_TOKEN` / `WS_URL` / `NODE_ID` / `NODE_KEY`)。需要从远端拉取配置时,设置 `remote-config-url` 或环境变量 `REMOTE_CONFIG_URL`(优先级:远端 > 环境变量 > setting.json)。
---
## 前端(config/index.ts)
前端配置在 `frontend-ssr/config/index.ts`,部署前按需修改。完整配置项如下:
| 配置项 | 类型 | 说明 |
|--------|------|------|
| `siteUrl` | string | 站点对外地址 |
| `siteName` | string | 站点名称(页面标题 / 描述 / 页脚品牌) |
| `EnableInternalMiddleware` | boolean | 是否启用前端内置中间件(作为 `/middleware/*` 候选最后一位兜底),默认 `true` |
| `rateLimitPerMinute` | number | 内置中间件单 IP 限流次数(次/分钟),默认 `120`,`0` 表示不限流 |
| `Middleware` | string[] | 外部独立中间件 base URL 列表,`/middleware/*` 请求依次尝试、出错重试下一个 |
| `umamiHost` / `umamiScriptUrl` / `umamiWebsiteId` | string | Umami 统计配置 |
| `ICP` / `GongAn` | string | 网站备案号(页脚展示) |
| `noindex` | boolean | 全站是否禁止搜索引擎索引 |
| `v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` | string | IPv4 / IPv6 / 双栈 IP 查询接口 |
| `APIBaseURL` | 节点池 | 拨测上游节点池(whois / ssl / detail / dns / dnssec / tcping / speed),含 `DualStack` / `IPv4` / `IPv6` 三栈 |
| `IPLocationAPI` | 节点数组 | IP 归属地 / ASN 查询节点池(纯数组,无栈区分) |
完整模板(复制到 `frontend-ssr/config/index.ts`,替换占位值即可):
```ts
const config = {
siteUrl: "https://your-domain.com/", // 站点对外地址
siteName: "你的站点名称", // 站点名称(页脚展示)
// 是否启用前端内置中间件(本地转发,作为候选最后一位兜底)
EnableInternalMiddleware: true,
// 内置中间件单 IP 限流次数(次/分钟),0 表示不限流
rateLimitPerMinute: 120,
// 外部独立中间件 base URL 列表(可留空,仅用内置中间件)
Middleware: <string[]>[],
// Umami 统计(不需要留空)
umamiHost: "",
umamiScriptUrl: "",
umamiWebsiteId: "",
// 备案号(页脚展示,不需要留空)
ICP: "",
GongAn: "",
// 是否禁止搜索引擎索引
noindex: false,
// 出站 IP 检测接口(页面直连需 CORS,建议 wsmdn.top 的三个)
v4OnlyAPI: "https://4.wsmdn.top",
v6OnlyAPI: "https://6.wsmdn.top",
DualStackAPI: "https://test.wsmdn.top",
// 拨测节点池(whois / ssl / detail / dns / dnssec / tcping / speed)
// IPv4-only 节点只能放 IPv4 栈,IPv6-only 放 IPv6 栈,双栈放 DualStack
APIBaseURL: {
DualStack: [],
IPv4: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
],
IPv6: []
},
// IP 归属地 / ASN 查询节点池(纯数组,无栈区分)
IPLocationAPI: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
]
}
export { config }
```
> 节点 `id` 需与后端节点 / 中间件 `api-keys` 的键保持一致,中间件按 `id` 注入 `Authorization: Bearer <key>`;WS 通道的节点注册校验另见中间件 `ws-keys`(与后端 `node-key` 对应)。
节点数组项均为 `{ label, id, url }` 结构,例如:
```ts
APIBaseURL: {
DualStack: [
{ label: "中国 江苏 移动", id: "cn-jiangsu", url: "https://cn-jiangsu.api-ipw.wsmdn.top/" }
],
IPv4: [],
IPv6: []
}
```
`label` 为节点展示名,`id` 为节点唯一标识(中间件 `api-keys` 按此 id 注入 HTTP 转发 key,WS 注册校验另见 `ws-keys`),`url` 为节点 base URL。节点池结构如下:`APIBaseURL` 为拨测节点池,含 `DualStack` / `IPv4` / `IPv6` 三栈(分别对应双栈 / 单栈测试时使用的节点列表,IPv4-only 节点只能放 `IPv4` 栈,IPv6-only 只能放 `IPv6` 栈,不能放 `DualStack`);`IPLocationAPI` 为 IP 归属地 / ASN 查询节点池,为纯数组(无栈区分)。
### 出站 IP 检测接口(v4OnlyAPI / v6OnlyAPI / DualStackAPI)
`v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` 用于获取请求方的出站 IP(分别对应 IPv4、IPv6、双栈),首页与部分页面会直接请求。可直接使用的接口:
| 接口 | 类型 |
|------|------|
| `https://4.wsmdn.top` | IPv4 |
| `https://6.wsmdn.top` | IPv6 |
| `https://test.wsmdn.top` | 双栈 |
| `https://ipv4.ip.sb` | IPv4 |
| `https://ipv6.ip.sb` | IPv6 |
| `https://ip.sb` | 双栈 |
| `https://4.itdog.cn` | IPv4 |
| `https://6.itdog.cn` | IPv6 |
| `https://v.itdog.cn` | 双栈 |
> **CORS 说明**:以上接口仅 `4.wsmdn.top` / `6.wsmdn.top` / `test.wsmdn.top` 返回 `Access-Control-Allow-Origin: *`,其余(ip.sb、itdog.cn)不带 CORS 头。前端页面在客户端导航时会从浏览器直接请求这些接口,**没有 CORS 会被浏览器拦截**,因此页面直连检测建议使用 wsmdn.top 的三个;ip.sb / itdog.cn 适合在服务端或命令行场景使用。
---
## 前端内置中间件(NUXT_APIKEYS)
前端内置中间件(`frontend-ssr/server/routes/middleware/[...slug].get.ts`)作为 `/middleware/*` 候选列表的最后一位兜底,转发时也会注入 key:
- 读取方式:`useRuntimeConfig(event).apiKeys`,按请求的 `backendID`(节点 `id`)查找
- 环境变量:**`NUXT_APIKEYS`**(Nuxt 约定 `NUXT_` + 配置键 `apiKeys`)
- 格式:JSON 字符串,例如 `NUXT_APIKEYS='{"cn-jiangsu":"your-key"}'`
- 注入方式:转发请求头 `Authorization: Bearer <key>`
也可以在 `nuxt.config.ts` 的 `runtimeConfig.apiKeys` 里直接写死(不推荐,密钥不应进仓库)。
---
## 独立中间件(middleware-go)
中间件配置在 `middleware-go/setting.json`:
```json
{
"port": "8091",
"http-timeout-seconds": 30,
"cors": "",
"rate-limit": 120,
"ws-port": 8092,
"remote-config-url": "",
"remote-ignore-config": [],
"api-base-url": {
"DualStack": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"IPv4": [],
"IPv6": []
},
"ip-location-api": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"api-keys": { "cn-jiangsu": "" },
"ws-keys": { "cn-jiangsu": "" }
}
```
- `rate-limit`:单 IP 每分钟限流次数,默认 `120`,`0` 表示不限流(也可用环境变量 `RATE_LIMIT` 覆盖)
- `ws-port`:WS 服务端口,默认 `8092`,`0` 表示关闭 WS 通道(也可用环境变量 `WS_PORT` 覆盖;优先级:远端配置 > 环境变量 > setting.json)
- `remote-config-url`:远端配置地址,也可用环境变量 `REMOTE_CONFIG_URL`(环境变量优先)
- `remote-ignore-config`:**不被远端配置覆盖的配置项列表**(数组),如 `["port", "rate-limit"]`;也可用环境变量 `REMOTE_IGNORE_CONFIG`(JSON 数组字符串,优先于 setting.json)
- `api-keys`:用于向后端注入 `Authorization: Bearer <key>`,优先级:setting.json `api-keys` > 环境变量 `API_KEYS`(JSON 字符串)。**敏感凭据,强制忽略,不随远端配置覆盖**
- `ws-keys`:WS 注册校验表(`节点id → key`):节点经 WS 注册时,`register` 消息的 `key` 必须与 `ws-keys[节点id]` 一致,否则返回 401 并断开;优先级:setting.json `ws-keys` > 环境变量 `WS_KEYS`(JSON 字符串)。**敏感凭据,强制忽略,不随远端配置覆盖**。与 `api-keys` **相互独立**——`api-keys` 管 HTTP 转发鉴权,`ws-keys` 管 WS 注册校验,可分别配置不同 key(例如 WS 节点单独换 key 不影响 HTTP 转发)
- 节点池与前端 `config/index.ts` 结构一致:`APIBaseURL` 为拨测节点池(含 `DualStack` / `IPv4` / `IPv6` 三栈),`IPLocationAPI` 为 IP 归属地 / ASN 节点池(纯数组,无栈);节点加 `"ws": true` 可让该节点走 WS 通道通信,详见 [中间件部署 - WS 通道](/guide/deploy-middleware#ws-通道拨测数据经-websocket-传输)
### 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]` 一致,**必填** |
**链路**:中间件收到 `ws:true` 节点的拨测请求 → 经 WS 发 `probe` → 节点执行探针(直调 webtest 函数 + 缓存)→ `probe_result` 回传 → 中间件返回 HTTP 响应(`Content-Type: application/json`)。节点侧接入步骤见 [后端节点部署 - WS 通道接入](/guide/deploy-node#ws-通道接入可选)。
---
## 远端配置(REMOTE_CONFIG_URL)
**什么是远端配置**:把配置文件放到一个可访问的 URL 上(如 Gist、对象存储、静态托管、任意 HTTP 服务),部署时只需设置一个环境变量,之后改配置**不用重新部署、不用重启**,下次启动自动拉取最新配置。适合多节点统一管理、批量更新节点配置的场景。
**支持的组件**(三个组件均已支持):
| 组件 | 触发方式 | 远端配置格式 |
|------|----------|--------------|
| 后端(主线 `src/main.go`) | `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` | 后端 `setting.json` 格式 |
| 后端(`serverless/edgeone/`) | `REMOTE_CONFIG_URL` | 后端 `setting.json` 格式 |
| 独立中间件(middleware-go) | `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` | 中间件 `setting.json` 格式 |
**合并规则**(重要,逐字段生效):
- **优先级:远端配置 > 环境变量 > setting.json**
- 远端 JSON 中**非空的字段才覆盖**本地值:字符串非空、数组非空、数字非 0(`rate-limit` 为指针语义,缺省=不覆盖,显式 0=不限流)
- 部分字段缺省不会冲掉本地配置——远端只需包含要覆盖的字段
- **敏感凭据强制忽略,永远不被远端覆盖**(代码写死,无需配置):
- 后端 `access-token`:不随远端覆盖(保持 环境变量 > setting.json),远端里的 `access-token` 会被忽略
- 中间件 `api-keys` / `ws-keys`:不随远端覆盖,远端里的 `api-keys`/`ws-keys` 会被忽略
- `remote-ignore-config`(后端键名 `remote-ignore-config` / env `REMOTE_IGNORE_CONFIG`):额外指定**不被远端覆盖的配置项列表**,数组中的键即使远端下发也不生效。适用于 access-token / api-keys / ws-keys 之外的敏感项(如 `node-key`、`dns-server` 等),也可覆盖"非空才覆盖"规则
**拉取行为**:
- 在**进程启动时的配置加载阶段**一次性拉取
- HTTP 超时 **10 秒**,仅接受 **200** 响应(404/5xx 视为失败)
- 拉取或 JSON 解析失败:打印警告日志 `WARN failed to fetch remote config, fallback to local: ...`,**自动回退本地配置**,服务正常启动,不会崩溃
- 拉取成功:启动日志打印 `remote config applied from <url>`
- **没有重试、没有定时刷新**:改远端配置后需要重启进程才会重新拉取
**示例一:后端**(主线,远端 JSON 放在 `https://example.com/ipw-config.json`):
```json
{
"port": 8080,
"single-stack": "ipv4",
"dns-server": "119.28.28.28:53",
"cors": "https://ipw.wsmdn.top",
"remote-config-url": ""
}
```
部署时:
```bash
REMOTE_CONFIG_URL=https://example.com/ipw-config.json ./lemonipw
```
**示例二:独立中间件**(middleware-go,远端 JSON 放在 `https://example.com/mw-config.json`):
```json
{
"port": "8091",
"cors": "",
"rate-limit": 120,
"APIBaseURL": {
"DualStack": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"IPv4": [],
"IPv6": []
}
}
```
> [!WARNING]
> 敏感凭据不得上传远端:`api-keys`/`ws-keys`(及后端 `access-token`、节点 key 等密钥)**禁止**写入远端配置——即使写了也会被强制忽略。凭据只放本地 setting.json 或环境变量。
部署时:
```bash
REMOTE_CONFIG_URL=https://example.com/mw-config.json ./middleware-go-linux-amd64
```
systemd 中同样用 `Environment="REMOTE_CONFIG_URL=..."` 配置。
**如何验证生效**:
- 启动日志出现 `remote config applied from <url>` 即拉取成功
- 用 `curl -i <远端URL>` 确认远端返回 `200` 且是合法 JSON
- 实际操作验证:在远端把 `port` 改成另一个值,重启进程,观察监听端口是否变化
**安全与排查**:
- **敏感凭据不得上传远端**:远端配置(Gist / 对象存储 / 静态托管等)**禁止包含任何密钥**——`api-keys`/`ws-keys`、`access-token`、节点 key(`node-key`)等。这些值会被强制忽略,但远端文件本身一旦泄露等于直接暴露凭据;凭据一律放本地 `setting.json` 或环境变量注入
- 远端 URL 必须能被节点访问;优先使用 **HTTPS** 地址
- 远端配置文件如果放在公开可访问的位置(公开 Gist、无鉴权对象存储),即便不含密钥,也等于把节点拓扑、节点 id 等敏感信息公之于众,建议放在私有仓库 / 带鉴权的对象存储 / 内部服务上
- 常见失败排查:
| 现象 | 原因 | 排查 |
|------|------|------|
| 日志 `WARN ... status 404` | URL 路径错误或远端文件不存在 | `curl -i <URL>` 检查返回码 |
| 日志 `WARN ... timeout` / 连接失败 | 网络不通或超时 | 确认节点可访问该 URL |
| 日志 `WARN ... invalid ... JSON` | 远端内容不是合法 JSON 或格式不符 | 检查 JSON 合法性、字段名与组件格式一致 |
| 启动日志无 remote 相关输出 | `REMOTE_CONFIG_URL` 未设置 | `env \| grep REMOTE` 确认环境变量已注入 |
+38 -53
View File
@@ -1,6 +1,6 @@
# 前端部署
前端(`frontend-ssr/`)是全项目的核心,负责数据可视化与接口调用。基于 **Nuxt 4 SSR** 构建,部署到 **Cloudflare Workers**(nitro `cloudflare_module` preset)。
前端(`frontend-ssr/`)是全项目的核心,负责数据可视化与接口调用。基于 **Nuxt 4 SSR** 构建。
## 架构与部署目标
@@ -9,8 +9,7 @@
| 技术栈 | Nuxt 4 + Vue 3 + Element Plus + VueUse |
| 运行时 | Node.js 22(见 `.node-version`)、包管理器 pnpm 10 |
| 构建产物 | `.output/server/index.mjs` + `.output/public/`(静态资源) |
| 部署目标 | Cloudflare Workers(SSR),也可部署到其他符合 Serverless 标准的平台 |
| 部署配置 | `frontend-ssr/wrangler.jsonc` |
| 构建命令 | `pnpm build`(Nitro SSR 构建) |
## 准备工作
@@ -27,40 +26,46 @@ pnpm dev
## 配置说明
前端所有配置集中在 `frontend-ssr/config/index.ts`:
前端所有配置集中在 `frontend-ssr/config/index.ts`,完整配置项见 [配置文件 - 前端](/guide/config#前端-configindexts):
| 配置项 | 说明 |
|--------|------|
| `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` |
| `v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` | Worker IP 查询接口 |
| `rateLimitPerMinute` | 内置中间件单 IP 限流次数(次/分钟),默认 `120`,`0` 表示不限流 |
| `v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` | 出站 IP 检测接口(页面直连建议使用带 CORS 的 wsmdn.top 接口) |
| `umamiHost` 等 | Umami 统计配置 |
| `ICP` / `GongAn` | 网站备案号(页脚展示) |
| `noindex` | 是否禁止搜索引擎索引 |
> 生产环境建议通过构建时的环境变量或部署平台配置覆盖敏感项,不要把密钥写进 `config/index.ts` 提交到仓库。
> 生产环境建议通过运行时的环境变量或部署平台配置覆盖敏感项,不要把密钥写进 `config/index.ts` 提交到仓库。
## 构建与部署
## 部署方案
### 本地构建
### 方案一:EdgeOne
腾讯云 EdgeOne 原生支持 Nuxt,点击下方按钮即可通过 **EdgeOne Makers** 一键部署(无需手动配置构建):
[![使用 EdgeOne Makers 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://console.cloud.tencent.com/edgeone/makers/new?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=frontend-ssr&install-command=pnpm%20install&build-command=pnpm%20run%20build&output-directory=.output)
### 方案二:Vercel
点击下方按钮一键导入并部署到 Vercel(自动识别 Nuxt 项目,`root-directory` 已预置为 `frontend-ssr`):
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=frontend-ssr&install-command=pnpm%20install&build-command=pnpm%20run%20build&output-directory=.output)
### 方案三:Cloudflare Workers
使用 wrangler 手动部署:
```bash
# 构建(默认 preset 为 cloudflare_module,可由 NITRO_PRESET 覆盖)
cd frontend-ssr
pnpm build
# 本地预览(构建 + wrangler dev)
pnpm preview
```
### 部署到 Cloudflare Workers
```bash
# 构建 + 发布
pnpm deploy # 等价于 pnpm build && wrangler deploy
```
@@ -75,34 +80,19 @@ export CLOUDFLARE_ACCOUNT_ID=your_account_id
npx wrangler login
```
`wrangler.jsonc` 关键配置:
> Cloudflare 部署按钮不支持子目录,而前端位于仓库 `frontend-ssr/` 子目录,因此不提供一键部署按钮;使用上方 wrangler 命令或仓库 CI/CD 工作流部署。
```jsonc
{
"name": "ipw-cn", // Worker 名称
"main": "./.output/server/index.mjs", // SSR 入口
"assets": { "binding": "ASSETS", "directory": "./.output/public/" },
"compatibility_flags": ["nodejs_compat"] // 需要 Node.js 兼容层
}
### 方案四:直接运行(Node 自托管)
前端是 Node.js 应用,构建产物为 JS 脚本 + 静态资源(非二进制),可直接用 Node 运行:
```bash
cd frontend-ssr
pnpm build
node .output/server/index.mjs # 默认监听 3000 端口
```
## 一键部署到 EdgeOne Makers
腾讯云 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`。
生产环境建议使用进程守护(systemd / pm2 等)保持常驻。
## CI/CD 自动部署
@@ -111,14 +101,9 @@ npx wrangler login
- **触发条件**:push 到 `main` 且改动路径为 `frontend-ssr/**`;或手动 `workflow_dispatch`
- **步骤**:checkout → Node 22 + pnpm 10 → `pnpm install` → `pnpm run deploy --keep-vars`
- **所需 Secrets**:`CLOUDFLARE_API_TOKEN`、`CLOUDFLARE_ACCOUNT_ID`
- **注意**:工作流设置了 `NITRO_PRESET: cloudflare_module`,如本地构建异常可参考此值
- **注意**:工作流设置了 `NITRO_PRESET: cloudflare_module`
## 常见问题
- **构建报 preset 相关错误**:确认构建环境变量 `NITRO_PRESET=cloudflare_module`。
- **本地 `pnpm preview` 与线上行为不一致**:预览使用 `wrangler dev` 模拟 Workers 运行时,请确保 `nodejs_compat` 兼容标志已启用。
- **部署后接口 403 / 跨域**:检查后端 `cors` 配置(独立中间件见 `middleware-go/setting.json` 的 `cors` 字段,逗号分隔允许域名);服务端转发请求不带 `Origin`,浏览器直接调用才受 CORS 限制。
## 其他部署目标
本项目前端同样支持部署到其他**符合 Serverless 标准**的平台(无状态、事件驱动、自动扩缩容),如 EdgeOne Pages 等。只需按目标平台的约定调整 nitro preset(例如 `nitro preset` 切换)并配置对应产物入口即可。
- **出站 IP 检测接口被浏览器拦截**:页面直连的 IP 检测接口需要 CORS,详见 [配置文件 - 出站 IP 检测接口](/guide/config#出站-ip-检测接口v4onlyapi--v6onlyapi--dualstackapi)。
+109
View File
@@ -0,0 +1,109 @@
# 中间件部署
独立中间件(middleware-go)只负责**请求转发和 Key 注入**,不参与业务计算。前端将 `/middleware/*` 请求转发到中间件,由中间件转发到对应后端节点并注入 `Authorization: Bearer <key>`。
> [!IMPORTANT]
> 项目只提供配置示例,**使用前必须先复制为 `setting.json`**(GitHub Releases 中对应文件为 `middleware.setting.json.example`):
>
> ```bash
> # 仓库内
> cp middleware-go/setting.json.example middleware-go/setting.json
>
> # 从 Release 下载后
> cp middleware.setting.json.example setting.json
> ```
>
> 复制后按需修改 `api-base-url` / `ip-location-api` / `api-keys` / `ws-keys` / `cors` 等配置项。
## 方案一:Docker
`middleware-go/` 目录提供 `Dockerfile`:
```bash
cd middleware-go
docker build -t middleware-go .
docker run -d -p 8091:8091 \
-v $(pwd)/setting.json:/app/setting.json \
middleware-go
```
配置通过挂载 `setting.json` 提供(`api-base-url` / `ip-location-api` / `api-keys` / `ws-keys` / `cors` / `rate-limit` / `remote-config-url` 等;`rate-limit` 为单 IP 每分钟限流次数,默认 120,0 表示不限流,可用环境变量 `RATE_LIMIT` 覆盖;`remote-config-url` 为远端配置地址,可用环境变量 `REMOTE_CONFIG_URL` 覆盖;**`api-keys` / `ws-keys` 为敏感凭据,不随远端配置覆盖**——`api-keys` 管 HTTP 转发鉴权,`ws-keys` 管 WS 注册校验,两者相互独立)。
## 方案二:二进制
```bash
cd middleware-go
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o middleware-go-linux-amd64 .
./middleware-go-linux-amd64 # 运行(与 setting.json 同目录)
```
所有配置可用环境变量覆盖(`API_BASE_URLS` / `IP_LOCATION_APIS` / `CORS` / `API_KEYS` / `WS_KEYS` / `RATE_LIMIT` 等,数组/对象用 JSON 字符串),优先级:**环境变量 > setting.json > 默认值**(远端配置 `REMOTE_CONFIG_URL` 高于两者,详见 [配置文件 - 远端配置](/guide/config#远端配置remoteconfigurl))。
需要守护运行时,参考 [后端节点部署 - 方案五:一键安装](/guide/deploy-node#方案五一键安装installsh) 的 systemd 管理方式(`ExecStart` 指向中间件二进制,`WorkingDirectory` 指向 `middleware-go/setting.json` 所在目录)。
## WS 通道(拨测数据经 WebSocket 传输)
middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入,拨测数据上传改用 WS 通道传输(HTTP 转发保留,双通道并存)。**现有 HTTP 接口(`/v1/*` / `/middleware/*`)完全不变**。
### 架构
```
前端(HTTP) → middleware-go(8091,HTTP 转发)→ 后端节点(ws:false 时)
↘ middleware-go(8092,WS 服务端)→ 后端节点(ws:true 时,WS 客户端连入)
```
- WS 服务端跑在**独立端口**(配置 `ws-port`,默认 `8092`,路径 `/ws`;`ws-port: 0` 或环境变量 `WS_PORT=0` 关闭),与 fiber HTTP 服务完全分离;端口走统一配置管道(远端配置 > 环境变量 > setting.json)
- 节点配置加 `"ws": true`(兼容 `"ws": "true"` 字符串写法)→ 该节点的拨测请求改走 WS 通道;缺省 `false` → 原 HTTP 转发不变
- ws:true 节点未连接或拨测超时 → 返回 502,不影响其他节点
### 配置示例
```json
{
"port": "8091",
"APIBaseURL": {
"DualStack": [ { "label": "上海 腾讯云 BGP", "id": "tencent-sh", "url": "", "ws": true } ],
"IPv4": [],
"IPv6": [ { "label": "河北 秦皇岛 联通", "id": "cn-hebei-qinhuangdao", "url": "https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/", "ws": true } ]
}
}
```
启动后日志会打印 `ws channel enabled on port 8092`。
### 消息协议
所有消息为 JSON 文本帧,信封:`{ "type": "...", "nodeId": "...", "ts": <unix秒>, "data": {...} }`
| 方向 | type | data 说明 |
|------|------|-----------|
| 节点 → middleware | `register` | `{ "nodeId": "...", "key": "..." }`,连接后首条消息,同 id 新连接顶掉旧连接;`key` 为注册凭证,与 setting.json `ws-keys` 配置比对 |
| 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 → 节点 | `status` | 状态/统计上报:`{ "uptimeSeconds": ..., "totalRequests": ..., "errorRequests": ... }`(每 20s) |
| 节点 → middleware | `command` | 指令下发,如 `{ "command": "reload_config" }`(重读配置;与并发请求存在竞态,生产建议重启生效) |
### 节点侧接入(WS 客户端)
1. 连接 `ws://<中间件IP>:8092/ws`(本仓库后端节点接入方式见 [后端节点部署 - WS 通道接入](/guide/deploy-node#ws-通道接入可选),配置 `WS_URL` / `NODE_ID` / `NODE_KEY` 即可自动连接注册;`WS_URL` 支持逗号分隔多个中间件地址,节点会**同时连接全部(多活)**,任一断开只重连自己)
2. 首条消息发 `register { "nodeId": "<与中间件配置一致的节点 id>", "key": "<注册凭证>" }`(节点在 setting.json `ws-keys` 里配了 key 就必须传对;未配置 key 的开放节点可不传)
3. 收到 `probe` → 执行拨测 → 回 `probe_result`(必须携带原 `requestId`,支持乱序返回,同一连接可并发多个拨测)
4. 收到 `ping` → 回 `pong`;`status` 可忽略或记录
5. 断线后重连并重新 `register` 即可
### 注册 key 校验
节点注册时中间件按 `ws-keys` 配置校验 key(与 `api-keys` HTTP 转发鉴权相互独立):
- 节点在 `ws-keys` 配置了 key(非空)→ `register` 必须携带正确 key,不传或传错返回 `register_error { "code": 401, "command": "invalid key" }` 并**断开连接**,不进入数据阶段
- 节点未配置 key(`ws-keys` 无该项或为空)→ 开放注册,不传 key 即可
与 HTTP 通道的鉴权语义一致(配置了 key 就必须带凭据,留空 = 开放)。
### 说明
- 注册阶段按节点 `ws-keys` 配置校验 key(配了 key 必须传对,否则 401 断开;未配置则开放,详见上文「注册 key 校验」);拨测请求经 WS 转发时不携带 `Authorization` 头(后续由节点侧自行决定是否需要额外鉴权)
- `ws:true` 且 WS 未启用(`WS_PORT=0`)时,自动回退原 HTTP 转发
+126
View File
@@ -0,0 +1,126 @@
# 后端节点部署
后端节点提供全部检测 API(IP 归属地 / ASN / SSL / DNS / DNSSEC / Whois / TCPing / 测速 / 截图等),可在任意地区部署,组成去中心化测试网络。
## 方案一:EdgeOne
腾讯云 EdgeOne 支持 Go 边缘函数,点击下方按钮通过 **EdgeOne Makers** 一键部署 `serverless/edgeone/` 版本(无需本地构建):
[![使用 EdgeOne Makers 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://console.cloud.tencent.com/edgeone/makers/new?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=serverless%2Fedgeone)
也可以使用 EdgeOne CLI 手动部署:
```bash
cd serverless/edgeone
npx edgeone pages deploy -n ipw-cn -t $EDGEONE_API_TOKEN
```
`serverless/edgeone/` 版本的配置来源为 环境变量 + 远端配置(`REMOTE_CONFIG_URL`)。
## 方案二:Vercel
点击下方按钮一键导入仓库(部署目标为 `serverless/edgeone/cloud-functions` 目录,即 Go 边缘函数版本):
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=serverless%2Fedgeone%2Fcloud-functions)
> [!IMPORTANT]
> **Serverless 节点均为 IPv4-only**(EdgeOne、Vercel 等平台的出站网络只提供 IPv4)。部署后需要:
>
> 1. 给节点配置 `SINGLE_STACK=ipv4`(声明节点为 IPv4 单栈),跳过 IPv6 相关测试,避免无意义的错误日志与超时
> 2. 在前端配置 `config/index.ts` 中,**该节点只能填入 `APIBaseURL.IPv4`**(拨测节点池的 IPv4 栈),不要放进 `DualStack` / `IPv6` 栈
## 方案三:Docker
仓库根目录提供 `Dockerfile`(多阶段构建,产物基于 Alpine):
```bash
docker build -t lemon-ipw .
docker run -d -p 8080:8080 \
-v $(pwd)/setting.json:/app/setting.json \
lemon-ipw
```
配置通过挂载 `setting.json` 提供,也可用环境变量覆盖(`PORTS` / `CORS` / `ACCESS_TOKEN` 等)。
## 方案四:二进制
```bash
# 编译(Go 后端在 src/ 目录)
cd src && go build -o lemonipw .
# 运行(与 setting.json 同目录)
./lemonipw
```
首次启动自动下载 IP 数据库(约 450MB),之后每 24 小时自动更新一次。需要守护运行时,可参考下方「方案五」生成的 systemd 服务,或手动创建 service 文件(`ExecStart` 指向二进制,`WorkingDirectory` 指向 `setting.json` 所在目录)。
## 方案五:一键安装(install.sh)
仓库根目录提供 `install.sh` 一键安装脚本:自动检测架构 → 下载最新 release 二进制 → **交互式输入配置**(无需准备 setting.json,配置以环境变量注入)→ 生成 systemd 服务并守护进程。
```bash
# 先下载脚本到本地(勿用 curl | bash 管道执行,避免下载中断导致语法解析错误)
git clone https://github.com/nomdn/ipw-cn && cd ipw-cn # 或
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o install.sh
sudo bash install.sh
```
**交互式配置项**(直接回车使用默认值):
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| 安装目录 | `/opt/lemon-ipw` | 二进制与工作目录 |
| 监听端口 | `8080` | `PORTS` |
| 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`,空行结束 |
**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。
+75
View File
@@ -0,0 +1,75 @@
# 快速开始
> 本章节将指导您以最低成本部署一个功能完整的测试站
## 尝试一下
我们的开发团队已经部署了一个功能完整的预览站:
[预览站点](https://ipw.wsmdn.top/)
## 前置准备
- 一台 Linux 服务器(需 root 权限 + systemd)
- 服务器上可访问 GitHub(下载 release 二进制)
- 无需本地编译环境,无需准备配置文件
## 开始部署
### 交互式一键部署(推荐)
```bash
# 获取安装脚本(任选其一)
git clone https://github.com/nomdn/ipw-cn && cd ipw-cn # 或
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o install.sh
# 执行安装(root 权限)
sudo bash install.sh
```
> [!WARNING]
> 请勿使用 `curl ... | sudo bash` 管道方式执行:脚本经管道边下载边执行,网络慢/抖动时 bash 会读到不完整的脚本内容,报 `syntax error near unexpected token` 类错误(脚本本身无语法问题)。务必**先下载完整文件再执行**。
脚本会自动完成:检测架构 → 下载最新 release 二进制 → **交互式输入配置** → 生成并启动 systemd 守护进程。
**交互式配置项**(直接回车使用默认值):
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| 安装目录 | `/opt/lemon-ipw` | 二进制与工作目录 |
| 监听端口 | `8080` | 后端 HTTP 端口 |
| 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`,空行结束 |
**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)
+11 -55
View File
@@ -2,61 +2,19 @@
### 本章节将一步步指导您自部署柠檬味IPW
Lemon IPW 采用前后端分离架构,前端、后端、独立中间件可各自独立部署,并按需组合:
Lemon IPW 采用前后端分离架构,**后端节点、独立中间件、前端**三个组件可各自独立部署,并按需组合:
```
┌─────────────────┐ /v1/* ┌──────────────────┐
│ SSR 前端 │ ──────────────▶ │ 后端 / 独立中间件 │
│ (Cloudflare │ /middleware/* │ (自托管 / EdgeOne)│
│ Workers 等) │ ──────────────▶ │ 多节点候选重试 │
└─────────────────┘ └──────────────────┘
```
| 组件 | 职责 | 部署方案 |
|------|------|----------|
| [后端节点](/guide/deploy-node) | 提供全部检测 API | EdgeOne / Vercel / Docker / 二进制 |
| [独立中间件](/guide/deploy-middleware) | 请求转发 + Key 注入 | Docker / 二进制 |
| [前端](/guide/deploy-frontend) | 数据可视化与接口调用 | EdgeOne / Vercel / Cloudflare Workers / 直接运行 |
## 前端
组件之间通过 `/v1/*`(直连后端)与 `/middleware/*`(经中间件转发)两类接口通信,前端按节点列表轮询、失败自动重试下一个节点。
前端是全项目的核心,负责数据可视化和接口调用,部署到 Cloudflare Workers(也可部署到其他符合 Serverless 标准的平台)。
## 快速开始
[▶ 前端部署](/guide/deploy-frontend)
## 后端(自托管)
Go 后端提供全部检测 API(IP 归属地 / ASN / SSL / DNS / DNSSEC / Whois / TCPing / 测速 / 截图等),可 Docker 部署或直接运行二进制:
```bash
# 方式一:Docker
docker build -t lemon-ipw .
docker run -p 8080:8080 -v $(pwd)/setting.json:/app/setting.json lemon-ipw
# 方式二:直接运行
go run main.go # 首次启动自动下载 IP 数据库(约 200MB),之后每 24h 自动更新
```
配置项见根目录 `setting.json` 及环境变量表(`PORT` / `CORS` / `BLOCK_PRIVATE_IPS` / `DNS_SERVER` 等)。
## 独立中间件(middleware-go)
转发中间件,供前端在多个上游节点之间做候选重试,可独立部署:
```bash
cd middleware-go
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o middleware-go-linux-arm64 .
./middleware-go-linux-arm64 # 运行(可用 SETTING_FILE 指定配置路径)
./middleware-go-linux-arm64 --version # 查看版本
```
所有配置可通过环境变量覆盖(`API_BASE_URLS` / `IP_LOCATION_APIS` / `CORS` / `APIKEYS` 等,数组/对象用 JSON 字符串),配置优先级:**环境变量 > setting.json > 默认值**。
## 部署平台
本项目支持**一切符合 Serverless 标准的部署平台**,同时也支持容器 / 二进制自托管:
| 组件 | 可部署目标 |
|------|-----------|
| SSR 前端 | Cloudflare Workers、EdgeOne Pages 及任意 Serverless 平台 |
| 后端(自托管) | Docker / 任意容器或裸机 |
| 独立中间件 | 常规服务 / Serverless 函数 |
| 边缘后端 | EdgeOne Pages(`edgeone/`、`edgeone-getip/`) |
| IP 查询服务 | Cloudflare Workers(`lemon-getip/`,Hono) |
想用最低成本部署一套功能完整的测试站,直接看 [快速入门](/guide/getting-started):交互式一键部署(install.sh)→ systemd 守护。
## CI/CD
@@ -65,7 +23,5 @@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o middleware-go-linux-arm64 .
| 工作流 | 部署目标 |
|--------|----------|
| `frontend-ssr.yml` | SSR 前端 → Cloudflare Workers |
| `workers.yml` | `lemon-getip/` → Cloudflare Workers |
| `edgeone-backend.yml` | `edgeone/` → EdgeOne Pages |
| `edgeone-getip.yml` | `edgeone-getip/` → EdgeOne Pages |
| `build_and_release.yml` | 后端多平台构建与发布 |
| `edgeone-backend.yml` | `serverless/edgeone/` → EdgeOne Pages |
| `build_and_release.yml` | 后端多平台构建与发布(Release 附带配置文件) |
+2 -2
View File
@@ -8,8 +8,8 @@ hero:
tagline: IP 查询 · 网站检测 · SSL · DNS · DNSSEC · Whois · ASN · 截图
actions:
- theme: brand
text: 部署指南
link: /guide/
text: 快速开始
link: /guide/getting-started
- theme: alt
text: GitHub
link: https://github.com/nomdn/ipw-cn
+41
View File
@@ -0,0 +1,41 @@
# 柠檬味IPW
柠檬味ipw是一个基于Golang的分布式站点测试平台,旨在构建一个去中心化的,共建贡献的测试平台。
> [!TIP]
> 尝试一下?这里是官方站点:[链接](https://ipw.wsmdn.top)
## 简介
Lemon IPW(柠檬味 ipw.cn 替代品)是一个开源的分布式站点测试平台。与中心化测试服务不同,本项目鼓励社区成员部署自己的测试节点,前端多节点轮询、后端多地区部署,组成一张去中心化的测试网络,单一节点故障不影响整体可用性。
## 功能
- IP 地址查询(IPv4/IPv6,集成 ip2region、qqwry、MaxMind、GeoCN、DbIP 等多数据源)
- ASN 自治系统查询(MaxMind、DbIP,集成 WHOIS 解析)
- 网站检测(HTTP 状态码、响应时间)
- SSL 证书检查(有效期、颁发机构)
- DNS 解析(A/AAAA/CNAME/MX/TXT/NS/SRV/PTR/CAA 等,多节点并发)
- DNSSEC 验证(DNSKEY、RRSIG、DS 链式信任)
- Whois 查询
- TCPing(IPv4/IPv6 双栈)
- 网站测速
- 网站截图
## 架构
前后端分离,可独立部署、自由组合:
- 前端(Nuxt 4 SSR)负责数据可视化与接口调用
- 后端(Go)提供全部检测 API,可在任意地区部署测试节点,是分布式查询的核心
- 独立中间件(middleware-go)使用Fiber技术栈 负责请求转发和 Key 注入
后端支持二进制、Docker 自托管,也支持 AWS Lambda、EdgeOne Makers、Vercel、阿里云函数计算等 Serverless 平台部署。
## 技术栈
Go、Gin、Nuxt 4、Vue 3、Element Plus。
## 下一步
- 想自己部署一套?前往 [部署指南](/guide/)
- 想了解前端如何部署?阅读 [前端部署](/guide/deploy-frontend)
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

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

After

Width:  |  Height:  |  Size: 941 B

+12 -4
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,7 +56,7 @@ 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 || '')
@@ -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>
+5 -1
View File
@@ -72,7 +72,11 @@ const recordTypes = [
{ value: 'ptr', label: 'PTR 记录' }
]
const dnsServerFetches = config.NSLookup.map((server) => {
const dnsServerFetches = [
...config.APIBaseURL.DualStack,
...config.APIBaseURL.IPv4,
...config.APIBaseURL.IPv6
].map((server) => {
const url = computed(() => "/middleware/" + server.id + "/dns/" + recordType.value + "/" + domain.value);
const { data, error, execute } = useMiddlewareFetch(url, {
immediate: false,
+5 -1
View File
@@ -69,7 +69,11 @@ const loading = ref(false)
const serverResults = ref<ServerResult[]>([])
const userIP = ref('')
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)
+12 -383
View File
@@ -1,396 +1,25 @@
<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);
}
);
}
});
</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>
+2 -2
View File
@@ -68,7 +68,7 @@ const serverResults = ref<ServerResult[]>([])
// =============================================
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + domain.value);
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
immediate: false,
@@ -78,7 +78,7 @@ const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
});
/** IPv6 专用服务器:仅支持 IPv6 的节点 */
const ipv6Fetches = config.SpeedTest.IPv6.map((server) => {
const ipv6Fetches = config.APIBaseURL.IPv6.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + domain.value);
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
immediate: false,
+4 -4
View File
@@ -79,8 +79,8 @@ const loading = ref(false)
const serverResults = ref<ServerResult[]>([])
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) => {
@@ -95,7 +95,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 +103,7 @@ function initServerResults() {
})
})
config.TCPing.IPv6.forEach((server: ServerConfig) => {
config.APIBaseURL.IPv6.forEach((server: ServerConfig) => {
results.push({
label: server.label,
loading: false,
+7 -1
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')
@@ -275,6 +275,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/>
+11 -4
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,7 +69,7 @@ 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('');
@@ -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>
+2 -2
View File
@@ -68,7 +68,7 @@ const serverResults = ref<ServerResult[]>([])
// =============================================
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + domain.value);
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
immediate: false,
@@ -78,7 +78,7 @@ const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
});
/** IPv4 专用服务器:仅支持 IPv4 的节点 */
const ipv4Fetches = config.SpeedTest.IPv4.map((server) => {
const ipv4Fetches = config.APIBaseURL.IPv4.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + domain.value);
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
immediate: false,
+1 -1
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')
+4 -4
View File
@@ -78,8 +78,8 @@ const loading = ref(false)
const serverResults = ref<ServerResult[]>([])
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) => {
@@ -94,7 +94,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 +102,7 @@ function initServerResults() {
})
})
config.TCPing.IPv4.forEach((server: ServerConfig) => {
config.APIBaseURL.IPv4.forEach((server: ServerConfig) => {
results.push({
label: server.label,
loading: false,
+1 -1
View File
@@ -64,7 +64,7 @@ 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)
+67 -125
View File
@@ -10,6 +10,8 @@ const config = {
// true(默认):/middleware/* 候选为「外部节点… + 内置中间件(最后一位兜底)」;
// false:仅使用外部节点,候选列表不含内置中间件。
EnableInternalMiddleware: true,
// 内置中间件单 IP 限流次数(次/分钟);0 表示不限流。默认 120。
rateLimitPerMinute: 120,
// 外部独立中间件服务列表(base URL)。
// 与前端自带中间件(server/routes/middleware/[...slug].get.ts 本地转发)同级,
// 前端 /middleware/* 请求会依次尝试这些节点(出错重试下一个),
@@ -17,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 统计
@@ -33,23 +35,18 @@ const config = {
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/"
}
],
IPLocationAPIs: [
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
label: "中国 湖北 武汉 电信",
id: "cn-wuhan-chinatelecom",
url: ""
},
{
label: "中国 四川 沙渠 电信[ZFC]",
@@ -57,158 +54,103 @@ 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/",
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/"
}
],
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: "陕西 西安二 电信",
id: "xian2",
url: ""
},
{
label: "香港 Cogent",
id: "hongkong",
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/",
}
]
},
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/",
},
],
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: ""
},
// IP 直连的节点: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/",
}
],
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: "上海 腾讯云 BGP",
id: "tencent-sh",
url: ""
}
]
},
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/",
}
]
}
}
export { config }
+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)
})
@@ -1,6 +1,44 @@
import { defineEventHandler, getQuery, createError, setResponseStatus } from 'h3'
import { defineEventHandler, getQuery, createError, setResponseStatus, getRequestHeader, getRequestIP } from 'h3'
import { config } from '../../../config/index'
// ---- 方案 C:简单限流(每 IP 每分钟次数上限,进程内内存计数;次数取 config.rateLimitPerMinute,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
}
export default defineEventHandler(async (event) => {
// 1) Origin / Referer 校验:跨域浏览器调用一律拒绝。
// 注意:无 Origin/Referer 的服务端 SSR 调用与命令行请求无法区分,仅靠下方限流约束。
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' })
}
const slugString: string = event.context.params?.slug as any
if (!slugString) {
throw createError({ statusCode: 400, statusMessage: 'Missing slug parameter' })
@@ -47,9 +85,8 @@ export default defineEventHandler(async (event) => {
} catch (e) {
console.debug('[middleware debug] JSON parse FAILED:', e)
}
const authHeaders: Record<string, string> = {
'Origin': config.siteUrl.replace(/\/$/, ''),
}
// 转发头:不透传 Origin,避免上游 CORS 误判;仅按需注入 Authorization
const authHeaders: Record<string, string> = {}
if (apiKey) {
authHeaders['Authorization'] = `Bearer ${apiKey}`
console.debug('[middleware debug] authHeaders SET Authorization:', `Bearer ${apiKey.slice(0, 1)}...`)
@@ -61,25 +98,17 @@ export default defineEventHandler(async (event) => {
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' })
@@ -114,21 +143,12 @@ 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' })
+6 -3
View File
@@ -28,8 +28,8 @@ function isConnectionError(e: any): boolean {
}
// 需要切换节点重试的上游状态码:
// 401 未授权、403 被 CDN/WAF 风控拦截、418 被反爬/限流标记 —— 换一个节点往往就能绕过
const RETRY_STATUS_CODES = new Set([401, 403, 418])
// 401 未授权、403 被 CDN/WAF 风控拦截、418 被反爬/限流标记、502 中间件/上游不可用 —— 换一个节点往往就能恢复
const RETRY_STATUS_CODES = new Set([401, 403, 418, 502])
// 过滤掉 useFetch 专属选项(对 $fetch 无意义),其余(method/query/params/headers/body/timeout 等)原样透传
function toFetchOptions(options?: any) {
@@ -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
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/bash
# ============================================================
# 1IPW.CN 后端节点一键安装脚本
# - 自动检测架构并下载最新 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 服务器 [119.28.28.28:53](主从逗号分隔,如 119.28.28.28:53,223.5.5.5:53): " DNS_SERVER
DNS_SERVER=${DNS_SERVER:-119.28.28.28:53}
read -r -p "DNSSEC 专用 DNS(留空=沿用上面 dns-server): " DNSSEC_DNS_SERVER
read -r -p "启用 IP 数据库 ipdb(首次启动下载约 200MB)[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
echo ""
echo "--- WS 通道接入(可选,接入独立中间件)---"
read -r -p "接入中间件 WS 通道?[y/N]: " WS_CHOICE
WS_URL=""
NODE_ID=""
NODE_KEY=""
WS_USED_DEFAULT=""
case "${WS_CHOICE,,}" in
y|yes)
read -r -p " 中间件 WS 完整地址(含 wss:// 前缀与 /ws 路径,如 wss://host:8092/ws;留空=贡献节点给柠檬 wss://middleware-1.api-ipw.wsmdn.top/ws;逗号分隔多个将同时连接全部): " WS_URL
if [ -z "$WS_URL" ]; then
WS_URL="wss://middleware-1.api-ipw.wsmdn.top/ws"
WS_USED_DEFAULT="true"
fi
NODE_ID=$(gen_uuid)
echo " 节点 id(自动生成 UUID): $NODE_ID"
while [ -z "$NODE_KEY" ]; do
read -r -p " 注册 key(必填,中间件 ws-keys 必须包含此节点,否则注册被拒 401): " NODE_KEY
if [ -z "$NODE_KEY" ]; then
echo " 错误:不加 key 禁止启用 WS,必须提供注册 key"
fi
done
;;
esac
echo ""
echo "--- 其他环境变量(可选)---"
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 "单栈模式: ${SINGLE_STACK:-双栈}"
echo "access-token: ${ACCESS_TOKEN:+已设置 (隐藏)}"
echo "DNS: $DNS_SERVER"
echo "ipdb: $IPDB"
echo "WS 接入: ${WS_URL:+$WS_URL (id=$NODE_ID, key=${NODE_KEY:+已设置})}${WS_URL:-未启用}"
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
curl -sL -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"
# 收集环境变量(仅非空的写入,避免空值覆盖默认)
ENV_LINES=""
[ -n "$PORTS" ] && ENV_LINES="${ENV_LINES}Environment=\"PORTS=$PORTS\"
"
[ -n "$SINGLE_STACK" ] && ENV_LINES="${ENV_LINES}Environment=\"SINGLE_STACK=$SINGLE_STACK\"
"
[ -n "$ACCESS_TOKEN" ] && ENV_LINES="${ENV_LINES}Environment=\"ACCESS_TOKEN=$ACCESS_TOKEN\"
"
[ -n "$DNS_SERVER" ] && ENV_LINES="${ENV_LINES}Environment=\"DNS_SERVER=$DNS_SERVER\"
"
[ -n "$DNSSEC_DNS_SERVER" ] && ENV_LINES="${ENV_LINES}Environment=\"DNSSEC_DNS_SERVER=$DNSSEC_DNS_SERVER\"
"
[ -n "$IPDB" ] && ENV_LINES="${ENV_LINES}Environment=\"IPDB=$IPDB\"
"
[ -n "$CORS" ] && ENV_LINES="${ENV_LINES}Environment=\"CORS=$CORS\"
"
[ -n "$REMOTE_CONFIG_URL" ] && ENV_LINES="${ENV_LINES}Environment=\"REMOTE_CONFIG_URL=$REMOTE_CONFIG_URL\"
"
[ -n "$WS_URL" ] && ENV_LINES="${ENV_LINES}Environment=\"WS_URL=$WS_URL\"
"
[ -n "$NODE_ID" ] && ENV_LINES="${ENV_LINES}Environment=\"NODE_ID=$NODE_ID\"
"
[ -n "$NODE_KEY" ] && ENV_LINES="${ENV_LINES}Environment=\"NODE_KEY=$NODE_KEY\"
"
if [ -n "$EXTRA_ENVS" ]; then
while IFS= read -r line; do
[ -n "$line" ] && ENV_LINES="${ENV_LINES}Environment=\"$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
systemctl enable --now 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 "========================================"
+16 -1
View File
@@ -2,19 +2,34 @@ module github.com/nomdn/ipw-cn/middleware-go
go 1.26.2
require (
github.com/coder/websocket v1.8.15
github.com/gofiber/fiber/v3 v3.4.0
)
require (
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/gofiber/fiber/v3 v3.4.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gofiber/schema v1.8.0 // indirect
github.com/gofiber/utils/v2 v2.1.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.19.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.72.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.46.0 // indirect
+41
View File
@@ -1,5 +1,15 @@
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gofiber/fiber/v3 v3.4.0 h1:F0aND4vwZF7dR7cbvSwFQQEpBU902XHKWxrLsFBkVqw=
github.com/gofiber/fiber/v3 v3.4.0/go.mod h1:nAhJfdxUIJJph2tPWPmqWf8QDIN2iiqQiQf3lENZpdk=
github.com/gofiber/schema v1.8.0 h1:NGsC9toPHmj8Xg4KpznuXBzNmHG6V5YV0tXKpKMcmis=
@@ -14,14 +24,42 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/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/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/shamaton/msgpack/v3 v3.1.2 h1:d5gWAIyMU4M0WgDjz6IFSCuXJUA2dFwRHBpDclE8CLw=
github.com/shamaton/msgpack/v3 v3.1.2/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M=
github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
@@ -30,3 +68,6 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+346 -90
View File
@@ -12,37 +12,66 @@ import (
"strings"
"time"
"github.com/spf13/viper"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/cors"
"github.com/gofiber/fiber/v3/middleware/limiter"
)
// ==================== 全局变量 ====================
// apiInfo 对应 setting.json apiBaseUrls 等数组中的 { label, id, url } 配置项
// apiInfo 对应 setting.json 节点池中的 { label, id, url } 配置项
type apiInfo struct {
Label string `json:"label"`
ID string `json:"id"`
URL string `json:"url"`
WS wsFlag `json:"ws"` // 是否经 WS 通道通信(缺省 false = HTTP);支持 "ws": true 或 "ws": "true"
}
// stackConfig 对应 setting.json TCPing / SpeedTest
// wsFlag 兼容 JSON 布尔与字符串两种写法
type wsFlag bool
func (w *wsFlag) UnmarshalJSON(b []byte) error {
s := strings.TrimSpace(string(b))
s = strings.Trim(s, `"`)
switch s {
case "true", "1", "yes":
*w = true
case "false", "0", "", "no":
*w = false
default:
return fmt.Errorf("invalid ws flag %q", s)
}
return nil
}
// UseWS 判断该节点是否走 WS 通道
func (a *apiInfo) UseWS() bool { return a != nil && bool(a.WS) }
// stackConfig 对应节点池中的 { DualStack, IPv4, IPv6 } 三栈
type stackConfig struct {
DualStack []apiInfo `json:"DualStack"`
IPv4 []apiInfo `json:"IPv4"`
IPv6 []apiInfo `json:"IPv6"`
}
// middlewareConfig 仅用于解析 setting.json(键名与前端 config/index.ts 保持一致)
// 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:"httpTimeoutSeconds"`
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"`
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 通道
RemoteConfigURL string `json:"remote-config-url"`
RemoteIgnoreConfig []string `json:"remote-ignore-config"` // 不被远端覆盖的配置项列表
Cors string `json:"cors"`
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)
@@ -57,19 +86,23 @@ var (
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
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
WS_PORT int // WS 服务端口(0 = 关闭),缺省 8092,由 readConfig 统一解析
REMOTE_IGNORE_CONFIG []string // 不被远端覆盖的配置项列表(remote-ignore-config / REMOTE_IGNORE_CONFIG)
)
// HTTP_CLIENT 用于转发上游请求(等价于 TS 中的 $fetch),超时由 HTTP_TIMEOUT 决定
// HTTP_CLIENT forwards requests to upstream backends (equivalent to $fetch in the TS version).
var HTTP_CLIENT = &http.Client{}
// wsSrv 为全局 WS 服务端(WS_PORT>0 时启动;nil = 未启用)。见 ws.go
var wsSrv *wsServer
// ==================== 工具函数 ====================
// envJSON 从环境变量读取 JSON 字符串并反序列化到 target(用于数组/对象等映射表类配置项)。
@@ -130,6 +163,30 @@ func findURL(list []apiInfo, id string) string {
return ""
}
// findNode 按 id 在节点列表中查找(返回指针;找不到返回 nil)
func findNode(list []apiInfo, id string) *apiInfo {
for i := range list {
if list[i].ID == id {
return &list[i]
}
}
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 {
@@ -180,12 +237,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 ""
}
@@ -198,6 +255,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
@@ -244,34 +321,40 @@ func middlewareHandler(c fiber.Ctx) error {
query := c.Queries()
// 从 API_KEYS 查找 token,未配置时回退到环境变量 APIKEYS (JSON 字符串)
// 从 API_KEYS 查找 token,未配置时回退到环境变量 API_KEYS (JSON 字符串)
apiKey := lookupAPIKey(backendID)
// 构造转发给上游的请求头:所有接口统一透传客户端请求的 Origin,没有 Origin 头则不传
// 构造转发给上游的请求头:不透传客户端 Origin,避免上游 CORS 误判
authHeaders := map[string]string{}
if origin := c.Get("Origin"); origin != "" {
authHeaders["Origin"] = origin
}
if apiKey != "" {
authHeaders["Authorization"] = "Bearer " + apiKey
}
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)
}
apiBaseUrl := findURL(apiBaseUrls, backendID)
if apiBaseUrl == "" {
node := findNode(apiBaseUrls, backendID)
if node == nil || (!node.UseWS() && node.URL == "") {
return badRequest(c, "Invalid backend ID")
}
// ws:true 节点:拨测请求经 WS 通道转发(数据上传走 WS),失败返回 502
if node.UseWS() && wsSrv != nil {
status, body, err := wsSrv.RequestProbe(backendID, apiType, raw, nil, wsProbeTimeout())
if err != nil {
return c.Status(fiber.StatusBadGateway).SendString("WS probe failed: " + err.Error())
}
// 节点上报的 body 为 JSON,显式指定类型(fiber 默认 text/plain)
return c.Status(status).Type("json").Send(body)
}
apiBaseUrl := node.URL
if !strings.HasSuffix(apiBaseUrl, "/") {
apiBaseUrl += "/"
}
@@ -280,37 +363,37 @@ 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...)
apiBaseUrl := findURL(apiBaseUrls, backendID)
if apiBaseUrl == "" {
return badRequest(c, "Invalid backend ID")
}
if !strings.HasSuffix(apiBaseUrl, "/") {
apiBaseUrl += "/"
}
// tcping/udping/speed 统一走 APIBaseURL 节点池
apiBaseUrls := flattenStack(API_BASE_URLS)
// 转发 query 参数(过滤空值,等价于 TS 中 URLSearchParams 过滤 undefined)
queryString := url.Values{}
queryMap := make(map[string]string)
for k, v := range query {
if v != "" {
queryString.Set(k, v)
queryMap[k] = v
}
}
node := findNode(apiBaseUrls, backendID)
if node == nil || (!node.UseWS() && node.URL == "") {
return badRequest(c, "Invalid backend ID")
}
// ws:true 节点:拨测请求经 WS 通道转发
if node.UseWS() && wsSrv != nil {
status, body, err := wsSrv.RequestProbe(backendID, apiType, raw, queryMap, wsProbeTimeout())
if err != nil {
return c.Status(fiber.StatusBadGateway).SendString("WS probe failed: " + err.Error())
}
// 节点上报的 body 为 JSON,显式指定类型(fiber 默认 text/plain)
return c.Status(status).Type("json").Send(body)
}
apiBaseUrl := node.URL
if !strings.HasSuffix(apiBaseUrl, "/") {
apiBaseUrl += "/"
}
// 上游错误原样透传(状态码 + body),网络错误返回 502
return forwardUpstream(c, apiBaseUrl, apiBaseUrl+"v1/"+apiType+"/"+raw, authHeaders, queryString)
@@ -321,37 +404,119 @@ func middlewareHandler(c fiber.Ctx) error {
// ==================== readConfig ====================
// viperValue 从 viper 读取配置值并反序列化到 target(数组/对象经 JSON 中转,支持自定义类型如 wsFlag)
func viperValue(key string, target any) error {
v := viper.Get(key)
if v == nil {
return nil
}
b, err := json.Marshal(v)
if err != nil {
return err
}
return json.Unmarshal(b, target)
}
// fetchRemoteConfig 从远端 URL 拉取配置内容(middleware setting.json 格式)
func fetchRemoteConfig(url string) ([]byte, error) { client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("remote config returned status %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// applyRemoteConfig 应用远端配置,优先级:远端 > 环境变量 > setting.json。
// 远端地址来源:环境变量 REMOTE_CONFIG_URL 优先,其次 setting.json 的 remote-config-url。
// 远端字段为空时不覆盖本地值;拉取或解析失败时打印警告并回退本地配置。
func applyRemoteConfig(mw *middlewareConfig) error {
url := os.Getenv("REMOTE_CONFIG_URL")
if url == "" {
url = mw.RemoteConfigURL
}
if url == "" {
return nil
}
body, err := fetchRemoteConfig(url)
if err != nil {
log.Printf("[middleware] WARN failed to fetch remote config, fallback to local: %v", err)
return nil
}
var remote middlewareConfig
if err := json.Unmarshal(body, &remote); err != nil {
log.Printf("[middleware] WARN invalid remote config JSON, fallback to local: %v", err)
return nil
}
// ignore 列表:数组中的配置项不被远端覆盖(逐键判断跳过)
ignored := func(key string) bool {
for _, k := range mw.RemoteIgnoreConfig {
if k == key {
return true
}
}
return false
}
if !ignored("port") && string(remote.Port) != "" {
mw.Port = remote.Port
}
if !ignored("http-timeout-seconds") && remote.HTTPTimeoutSeconds != 0 {
mw.HTTPTimeoutSeconds = remote.HTTPTimeoutSeconds
}
if !ignored("rate-limit") && remote.RateLimit != nil {
mw.RateLimit = remote.RateLimit
}
if !ignored("ws-port") && remote.WSPort != "" {
mw.WSPort = remote.WSPort
}
if !ignored("remote-config-url") && remote.RemoteConfigURL != "" {
mw.RemoteConfigURL = remote.RemoteConfigURL
}
if !ignored("cors") && remote.Cors != "" {
mw.Cors = remote.Cors
}
// 节点池结构:api-base-url 与 ip-location-api(远端非空栈时覆盖本地)
if !ignored("api-base-url") && stackNonEmpty(remote.APIBaseURL) {
mw.APIBaseURL = remote.APIBaseURL
}
if !ignored("ip-location-api") && len(remote.IPLocationAPI) > 0 {
mw.IPLocationAPI = remote.IPLocationAPI
}
// api-keys / ws-keys 强制忽略:密钥凭据不随远端配置覆盖(与后端 access-token 一致),只从本地 setting.json / env 读取
log.Printf("[middleware] remote config applied from %s", url)
return nil
}
// readConfig 从 setting.json 读取配置并写入全局配置变量。
// 查找顺序: 环境变量 SETTING_FILE > ./setting.json > ../setting.json
func readConfig() error {
// 统一用 viper 读取 setting.json(SETTING_FILE 指定路径,否则查找 ./ 与 ../)
path := os.Getenv("SETTING_FILE")
if path == "" {
for _, candidate := range []string{"setting.json", "../setting.json"} {
if _, err := os.Stat(candidate); err == nil {
path = candidate
break
}
viper.SetConfigType("json")
if path != "" {
viper.SetConfigFile(path)
} else {
viper.SetConfigName("setting")
viper.AddConfigPath(".")
viper.AddConfigPath("../")
}
if err := viper.ReadInConfig(); err != nil {
if path == "" {
return fmt.Errorf("setting.json not found (set SETTING_FILE to specify its path)")
}
}
if path == "" {
return fmt.Errorf("setting.json not found (set SETTING_FILE to specify its path)")
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
var mw middlewareConfig
if err := json.Unmarshal(data, &mw); err != nil {
return fmt.Errorf("parse %s: %w", path, err)
}
// 环境变量覆盖配置(优先级: 环境变量 > setting.json > 默认值):
// 标量直接读;映射表(数组/对象)从环境变量读 JSON 字符串解析。
// 部署时无需改动配置文件,用环境变量即可覆盖任意节点/密钥/端口等。
// 统一读取模式:先 Getenv,再 viper.GetString(env 优先 > setting.json > 默认值)
if v := os.Getenv("PORT"); v != "" {
mw.Port = stringOrNumber(v)
} else {
mw.Port = stringOrNumber(viper.GetString("port"))
}
if v := os.Getenv("HTTP_TIMEOUT"); v != "" {
n, err := strconv.Atoi(v)
@@ -359,52 +524,122 @@ func readConfig() error {
return fmt.Errorf("parse env HTTP_TIMEOUT: %w", err)
}
mw.HTTPTimeoutSeconds = n
} else {
mw.HTTPTimeoutSeconds = viper.GetInt("http-timeout-seconds")
}
if v := os.Getenv("CORS"); v != "" {
mw.Cors = v
} else {
mw.Cors = viper.GetString("cors")
}
if err := envJSON("API_BASE_URLS", &mw.APIBaseURLs); err != nil {
if v := os.Getenv("REMOTE_CONFIG_URL"); v != "" {
mw.RemoteConfigURL = v
} else {
mw.RemoteConfigURL = viper.GetString("remote-config-url")
}
// rateLimit(*int:env > setting(IsSet) > 默认120;0 = 不限流)
if v := os.Getenv("RATE_LIMIT"); v != "" {
n, err := strconv.Atoi(v)
if err != nil {
return fmt.Errorf("parse env RATE_LIMIT: %w", err)
}
mw.RateLimit = &n
} else if viper.IsSet("rate-limit") {
n := viper.GetInt("rate-limit")
mw.RateLimit = &n
}
// ws-port(stringOrNumber:env > setting(IsSet) > 默认8092;"0"/0 = 关闭 WS 通道)
if v := os.Getenv("WS_PORT"); v != "" {
mw.WSPort = stringOrNumber(v)
} else if viper.IsSet("ws-port") {
mw.WSPort = stringOrNumber(viper.GetString("ws-port"))
}
// 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_IGNORE_CONFIG: %w", err)
}
mw.RemoteIgnoreConfig = list
} else {
mw.RemoteIgnoreConfig = viper.GetStringSlice("remote-ignore-config")
}
// 节点池结构:api-base-url 与 ip-location-api(env JSON > setting.json)
if err := envJSON("API_BASE_URLS", &mw.APIBaseURL); err != nil {
return err
}
if err := envJSON("IP_LOCATION_APIS", &mw.IPLocationAPIs); err != nil {
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.IPLocationAPI); err != nil {
return err
}
if err := envJSON("TCPING", &mw.TCPing); err != nil {
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("API_KEYS", &mw.APIKeys); err != nil {
return err
}
if err := envJSON("SPEED_TEST", &mw.SpeedTest); err != nil {
if len(mw.APIKeys) == 0 {
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 err := envJSON("NS_LOOKUP", &mw.NSLookup); 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)
}
}
if err := envJSON("APIKEYS", &mw.APIKeys); err != nil {
// 远端配置(最高优先级):REMOTE_CONFIG_URL 拉取后覆盖(优先级:远端 > 环境变量 > setting.json)
if err := applyRemoteConfig(&mw); err != nil {
return err
}
// 校验配置存在(防止在错误的文件上静默空跑)
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)
}
// 将配置内容写入全局变量
PORT = string(mw.Port)
HTTP_TIMEOUT = mw.HTTPTimeoutSeconds
// rateLimit:缺省默认 120 次/分钟;显式 0 表示不限流
if mw.RateLimit == nil {
RATE_LIMIT = 120
} else {
RATE_LIMIT = *mw.RateLimit
}
// ws-port:缺省默认 8092;显式 "0"/0 表示关闭 WS 通道(兼容字符串与数字写法)
if mw.WSPort == "" {
WS_PORT = 8092
} else {
n, err := strconv.Atoi(string(mw.WSPort))
if err != nil {
return fmt.Errorf("parse ws-port %q: %w", mw.WSPort, err)
}
WS_PORT = n
}
REMOTE_IGNORE_CONFIG = mw.RemoteIgnoreConfig
CORS = mw.Cors
// 逗号分隔的 CORS 域名列表(对齐根 main.go 的 ACCEPT_DOMAINS 用法)
if CORS != "" {
ACCEPT_DOMAINS = strings.Split(CORS, ",")
}
API_BASE_URLS = mw.APIBaseURLs
IP_LOCATION_APIS = mw.IPLocationAPIs
TCPING = mw.TCPing
SPEED_TEST = mw.SpeedTest
NS_LOOKUP = mw.NSLookup
API_BASE_URLS = mw.APIBaseURL
IP_LOCATION_APIS = mw.IPLocationAPI
API_KEYS = mw.APIKeys
CONFIG_SOURCE = path
WS_KEYS = mw.WSKeys
CONFIG_SOURCE = viper.ConfigFileUsed()
return nil
}
@@ -433,8 +668,28 @@ func main() {
}
HTTP_CLIENT.Timeout = time.Duration(timeout) * time.Second
// WS 服务端(端口由配置 wsPort / 环境变量 WS_PORT 决定,缺省 8092,0 = 关闭;统一走 readConfig)。
// 后端节点作为 WS 客户端连入;节点配置 "ws": true 时拨测请求改走 WS 通道。现有 HTTP 路由不受影响。
if WS_PORT > 0 {
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)
} else {
log.Printf("[ws] ws channel disabled (wsPort=0)")
}
app := fiber.New()
// 单 IP 限流(fiber 内置中间件,默认按客户端 IP 计数):次数由配置 rateLimit / 环境变量 RATE_LIMIT 决定(默认 120 次/分钟),
// 0 表示不限流。必须放在所有路由注册之前,fiber 中间件只对注册在其后的路由生效。
if RATE_LIMIT > 0 {
app.Use(limiter.New(limiter.Config{
Max: RATE_LIMIT,
Expiration: time.Minute,
}))
}
app.Get("/", func(c fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"status": "ok",
@@ -455,6 +710,7 @@ func main() {
corsConfig.AllowOrigins = []string{"*"}
}
app.Use(cors.New(corsConfig))
app.Get("/v1/*", middlewareHandler)
app.Get("/middleware/*", middlewareHandler)
+11 -36
View File
@@ -1,22 +1,12 @@
{
"port": "8091",
"httpTimeoutSeconds": 30,
"http-timeout-seconds": 30,
"rate-limit": 120,
"ws-port": 8092,
"remote-config-url": "",
"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": {
"api-base-url": {
"DualStack": [
{
"label": "中国 江苏 移动",
@@ -24,31 +14,16 @@
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
}
],
"IPv4": [
],
"IPv6": [
]
"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": {}
}
+331
View File
@@ -0,0 +1,331 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
"github.com/coder/websocket"
)
// ==================== WS 通信(middleware 作服务端) ====================
//
// 后端节点作为 WS 客户端连接 middleware 的 WS 服务端(默认端口 8092,路径 /ws,环境变量 WS_PORT 覆盖,0 关闭)。
// 节点配置项 `"ws": true`(或 "true")时,该节点的拨测请求改走 WS 通道转发;缺省 false 保持原有 HTTP 转发。
// 现有 HTTP 接口(/v1/* /middleware/*)完全不变,WS 是独立端口上的新增数据面。
//
// 消息信封(JSON 文本帧):{ "type": "...", "nodeId": "...", "ts": <unix秒>, "data": {...} }
// - 节点 → middleware:register / probe_result / pong / command
// - middleware → 节点:register_ok / register_error / probe / ping / status
//
// probe 消息 data:{ "requestId": "...", "apiType": "tcping", "raw": "qq.com", "query": {"port":"443"} }
// probe_result data:{ "requestId": "...", "status": 200, "body": <JSON 值> }(body 为 JSON 字符串时按原文透传)
type wsMessage struct {
Type string `json:"type"`
NodeID string `json:"nodeId,omitempty"`
TS int64 `json:"ts"`
Data json.RawMessage `json:"data,omitempty"`
}
type wsProbeRequest struct {
RequestID string `json:"requestId"`
APIType string `json:"apiType"`
Raw string `json:"raw"`
Query map[string]string `json:"query,omitempty"`
}
type wsProbeResult struct {
RequestID string `json:"requestId"`
Status int `json:"status"`
Body json.RawMessage `json:"body"`
Error string `json:"error,omitempty"`
}
type wsCommand struct {
Command string `json:"command"`
Data json.RawMessage `json:"data,omitempty"`
}
type wsPeer struct {
conn *websocket.Conn
id string
last time.Time
}
type wsServer struct {
mu sync.Mutex
peers map[string]*wsPeer
probeMu sync.Mutex
probeWait map[string]chan wsProbeResult
// 统计
statMu sync.Mutex
totalReqs int64
errReqs int64
startedAt time.Time
}
func newWSServer() *wsServer {
return &wsServer{
peers: make(map[string]*wsPeer),
probeWait: make(map[string]chan wsProbeResult),
startedAt: time.Now(),
}
}
func (s *wsServer) Handler(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: []string{"*"},
})
if err != nil {
log.Printf("[ws] accept failed: %v", err)
return
}
defer c.Close(websocket.StatusInternalError, "closed")
ctx := r.Context()
peer := &wsPeer{conn: c}
registered := false
// 读循环
for {
_, data, err := c.Read(ctx)
if err != nil {
if !registered {
log.Printf("[ws] peer closed before register")
} else {
log.Printf("[ws] node %s disconnected: %v", peer.id, err)
}
break
}
var msg wsMessage
if err := json.Unmarshal(data, &msg); err != nil {
log.Printf("[ws] invalid message from %s: %v", peer.id, err)
continue
}
peer.last = time.Now()
// ===== 数据阶段 =====
switch msg.Type {
case "register":
var reg struct {
NodeID string `json:"nodeId"`
Key string `json:"key"` // 注册凭证:与 setting.json wsKeys 配置比对
}
_ = json.Unmarshal(msg.Data, &reg)
if reg.NodeID == "" {
s.sendJSON(c, wsMessage{Type: "register_error", Data: mustRaw(wsCommand{Command: "empty nodeId"})})
continue
}
if registered {
s.sendJSON(c, wsMessage{Type: "register_error", Data: mustRaw(wsCommand{Command: "already registered"})})
continue
}
// 注册 key 校验:节点配置了 wsKeys[节点id] 就必须传对 key,否则 401 并断开;
// 未配置 key 的节点(开放)无需传 key
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"`
Command string `json:"command"`
}{Code: 401, Command: "invalid key"})})
c.Close(websocket.StatusPolicyViolation, "invalid key")
return
}
registered = true
peer.id = reg.NodeID
s.mu.Lock()
// 同 id 新连接替换旧连接(旧连接由读循环自然退出)
if old, ok := s.peers[reg.NodeID]; ok && old.conn != c {
old.conn.Close(websocket.StatusPolicyViolation, "replaced by new connection")
}
s.peers[reg.NodeID] = peer
s.mu.Unlock()
log.Printf("[ws] node registered: %s", reg.NodeID)
s.sendJSON(c, wsMessage{Type: "register_ok", NodeID: reg.NodeID, TS: time.Now().Unix(), Data: mustRaw(struct {
Heartbeat int `json:"heartbeatSeconds"`
}{Heartbeat: 20})})
case "probe_result":
if !registered {
continue
}
var res wsProbeResult
if err := json.Unmarshal(msg.Data, &res); err != nil {
log.Printf("[ws] bad probe_result from %s: %v", peer.id, err)
continue
}
s.probeMu.Lock()
ch, ok := s.probeWait[res.RequestID]
delete(s.probeWait, res.RequestID)
s.probeMu.Unlock()
if ok {
ch <- res
}
case "pong":
// 心跳应答,仅刷新 last
case "ping":
// 节点主动心跳:回 pong
if registered {
s.sendJSON(c, wsMessage{Type: "pong", NodeID: peer.id, TS: time.Now().Unix()})
}
default:
log.Printf("[ws] unknown message type from %s: %s", peer.id, msg.Type)
}
}
// 断开清理
if registered {
s.mu.Lock()
if s.peers[peer.id] == peer {
delete(s.peers, peer.id)
}
s.mu.Unlock()
}
}
func (s *wsServer) sendJSON(c *websocket.Conn, msg wsMessage) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = c.Write(ctx, websocket.MessageText, mustRaw(msg))
}
// RequestProbe 通过 WS 通道向指定节点发送拨测请求并等待结果(超时返回错误)。
func (s *wsServer) RequestProbe(nodeID, apiType, raw string, query map[string]string, timeout time.Duration) (int, []byte, error) {
s.mu.Lock()
peer, ok := s.peers[nodeID]
s.mu.Unlock()
if !ok || peer.conn == nil {
return 0, nil, fmt.Errorf("ws node %s not connected", nodeID)
}
reqID := fmt.Sprintf("m%d-%d", time.Now().UnixNano(), genSeq())
ch := make(chan wsProbeResult, 1)
s.probeMu.Lock()
s.probeWait[reqID] = ch
s.probeMu.Unlock()
defer func() {
s.probeMu.Lock()
delete(s.probeWait, reqID)
s.probeMu.Unlock()
}()
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)})
s.statMu.Lock()
s.totalReqs++
s.statMu.Unlock()
select {
case res := <-ch:
if res.Error != "" {
s.statMu.Lock()
s.errReqs++
s.statMu.Unlock()
return 0, nil, fmt.Errorf("ws probe error: %s", res.Error)
}
body := res.Body
// body 为 JSON 字符串时按原文透传(兼容文本响应)
if len(body) > 0 && body[0] == '"' {
var str string
if json.Unmarshal(body, &str) == nil {
body = []byte(str)
}
}
return res.Status, body, nil
case <-time.After(timeout):
s.statMu.Lock()
s.errReqs++
s.statMu.Unlock()
return 0, nil, fmt.Errorf("ws probe timeout for node %s", nodeID)
}
}
// Start 启动 WS 服务端(阻塞)。
func (s *wsServer) Start(addr string) {
mux := http.NewServeMux()
mux.HandleFunc("/ws", s.Handler)
srv := &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
}
log.Printf("[ws] server listening on %s/ws", addr)
if err := srv.ListenAndServe(); err != nil {
log.Printf("[ws] server stopped: %v", err)
}
}
// maintenanceLoop 心跳 + 状态上报(middleware → 节点)。
func (s *wsServer) maintenanceLoop() {
for {
time.Sleep(20 * time.Second)
s.mu.Lock()
peers := make([]*wsPeer, 0, len(s.peers))
for _, p := range s.peers {
peers = append(peers, p)
}
s.mu.Unlock()
s.statMu.Lock()
stats := struct {
UptimeSeconds int64 `json:"uptimeSeconds"`
TotalRequests int64 `json:"totalRequests"`
ErrorRequests int64 `json:"errorRequests"`
ConnectedAt int64 `json:"connectedAt"`
}{
UptimeSeconds: int64(time.Since(s.startedAt).Seconds()),
TotalRequests: s.totalReqs,
ErrorRequests: s.errReqs,
}
s.statMu.Unlock()
for _, p := range peers {
// 心跳
s.sendJSON(p.conn, wsMessage{Type: "ping", NodeID: p.id, TS: time.Now().Unix()})
// 状态/统计上报
s.sendJSON(p.conn, wsMessage{Type: "status", NodeID: p.id, TS: time.Now().Unix(), Data: mustRaw(stats)})
}
}
}
// genSeq 自增序号(进程内)。
var seqMu sync.Mutex
var seq int64
func genSeq() int64 {
seqMu.Lock()
defer seqMu.Unlock()
seq++
return seq
}
func mustRaw(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
return json.RawMessage("{}")
}
return b
}
// ==================== 指令处理与超时 ====================
// wsProbeTimeout 拨测超时:复用 HTTP_TIMEOUT(默认 30s)
func wsProbeTimeout() time.Duration {
t := HTTP_TIMEOUT
if t <= 0 {
t = 30
}
return time.Duration(t) * time.Second
}
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=
@@ -55,6 +55,7 @@ var (
speedCache sync.Map
whoisCache sync.Map
DNS_SERVER string
DNSSEC_DNS_SERVER string // DNSSEC 专用 DNS 服务器(dnssec-server / DNSSEC_DNS_SERVER),留空沿用 DNS_SERVER
defaultPort = fmt.Sprintf("%d", 5<<4)
V6Client *resty.Client
V4Client *resty.Client
@@ -1168,6 +1169,9 @@ func applyRemoteConfig() {
if v := configValue(CONFIG, "dns-server"); v != "" {
DNS_SERVER = v
}
if v := configValue(CONFIG, "dnssec-server"); v != "" {
DNSSEC_DNS_SERVER = v
}
if v := configValue(CONFIG, "cors"); v != "" {
CORS = v
}
@@ -1195,6 +1199,9 @@ func readConfig() {
if v := os.Getenv("DNS_SERVER"); v != "" {
DNS_SERVER = v
}
if v := os.Getenv("DNSSEC_DNS_SERVER"); v != "" {
DNSSEC_DNS_SERVER = v
}
if v := os.Getenv("CORS"); v != "" {
CORS = v
}
@@ -1218,6 +1225,7 @@ func main() {
readConfig()
initHttpClient()
webtest.SetDNSServer(DNS_SERVER)
webtest.SetDNSSecServer(DNSSEC_DNS_SERVER)
slog.Info("Starting server", "port", PORTS, "single_stack", SINGLE_STACK)
r := gin.Default()
corsConfig := cors.DefaultConfig()
@@ -23,6 +23,12 @@ var dnsServer = "119.28.28.28:53"
// dnsMode 主通道:dns-server 配置为 URL 时为 "doh",为 ip:port 时为 "udp"
var dnsMode = "doh"
// dnsServers 普通 DNS 查询服务器列表(主从:第一个为主,主失败自动切换后续从服务器)
var dnsServers []string
// dnssecServers DNSSEC 专用服务器列表(未配置时沿用 dnsServers)
var dnssecServers []string
type DNSResult struct {
Domain string `json:"domain"`
Duration float64 `json:"duration"`
@@ -30,44 +36,112 @@ type DNSResult struct {
TTL uint32 `json:"ttl"`
}
// SetDNSServer 设置 DNS 服务器:URL(http/https)→ DoH 主通道;ip:port → UDP 主通道
// SetDNSServer 设置 DNS 服务器:支持逗号分隔多地址主从 failover(第一个为主,主失败自动切换后续从服务器)。
// 每项支持 "ip:port"(UDP)或 DoH URL http(s)://...;单地址时保持原有语义。
func SetDNSServer(server string) {
if server == "" {
return
}
if strings.HasPrefix(server, "http://") || strings.HasPrefix(server, "https://") {
dohEndpoint = server
dnsMode = "doh"
} else {
dnsServer = server
dnsMode = "udp"
list := splitServers(server)
dnsServers = list
// 兼容单地址的旧语义:URL → DoH 主通道;ip:port → UDP 主通道
if len(list) == 1 {
if strings.HasPrefix(server, "http://") || strings.HasPrefix(server, "https://") {
dohEndpoint = server
dnsMode = "doh"
} else {
dnsServer = server
dnsMode = "udp"
}
}
}
// SetDNSSecServer 设置 DNSSEC 专用 DNS 服务器(逗号分隔主从;留空 = 沿用 dns-server 配置)
func SetDNSSecServer(server string) {
if list := splitServers(server); len(list) > 0 {
dnssecServers = list
}
}
// splitServers 逗号分隔解析为地址列表(去空白、去空项)
func splitServers(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
// ==========================================
// ⭐️ 底层核心:DoH + UDP 双通道 DNS 查询
// ==========================================
// queryDNSMsg 统一 DNS 查询入口:支持 DoH 与 UDP 双通道自动互备。
// dnsMode 为 "doh" 时优先 DoH(dohEndpoint),失败回退 UDP(dnsServer);
// dnsMode 为 "udp" 时优先 UDP(dnsServer),失败回退 DoH(dohEndpoint)。
// queryDNSMsg 普通 DNS 查询:主从 failover——按配置顺序依次尝试每个服务器,
// 当前服务器查询失败(网络错误/超时)自动切换下一个;全部失败返回最后一个错误。
// 单个服务器:URL 走 DoH,ip:port 走 UDP。
func queryDNSMsg(msg *dns.Msg) (*dns.Msg, float64, error) {
if dnsMode == "doh" {
resp, dur, err := queryDoHMsg(msg, dohEndpoint)
servers := dnsServers
if len(servers) == 0 {
// 未配置主从列表时回退单地址语义(保持原 DoH/UDP 双通道互备)
if dnsMode == "doh" {
resp, dur, err := queryDoHMsg(msg, dohEndpoint)
if err == nil {
return resp, dur, nil
}
slog.Warn("DoH query failed, falling back to UDP", "endpoint", dohEndpoint, "error", err)
return queryUDPMsg(msg, dnsServer)
}
resp, dur, err := queryUDPMsg(msg, dnsServer)
if err == nil {
return resp, dur, nil
}
slog.Warn("DoH query failed, falling back to UDP", "endpoint", dohEndpoint, "error", err)
return queryUDPMsg(msg, dnsServer)
slog.Warn("UDP query failed, falling back to DoH", "server", dnsServer, "error", err)
return queryDoHMsg(msg, dohEndpoint)
}
resp, dur, err := queryUDPMsg(msg, dnsServer)
if err == nil {
return resp, dur, nil
}
slog.Warn("UDP query failed, falling back to DoH", "server", dnsServer, "error", err)
return queryDoHMsg(msg, dohEndpoint)
return queryWithServers(msg, servers)
}
// queryDNSSECMsg DNSSEC 查询:优先使用专用服务器(dnssec-server),未配置则沿用普通 dns-server
func queryDNSSECMsg(msg *dns.Msg) (*dns.Msg, float64, error) {
if len(dnssecServers) > 0 {
return queryWithServers(msg, dnssecServers)
}
return queryDNSMsg(msg)
}
// queryWithServers 依次尝试服务器列表,主失败切从;每个服务器按类型走 DoH 或 UDP
func queryWithServers(msg *dns.Msg, servers []string) (*dns.Msg, float64, error) {
if len(servers) == 0 {
servers = []string{defaultUDPServer}
}
var lastErr error
for i, srv := range servers {
var resp *dns.Msg
var dur float64
var err error
if strings.HasPrefix(srv, "http://") || strings.HasPrefix(srv, "https://") {
resp, dur, err = queryDoHMsg(msg, srv)
} else {
resp, dur, err = queryUDPMsg(msg, srv)
}
if err == nil {
return resp, dur, nil
}
lastErr = err
if i < len(servers)-1 {
slog.Warn("DNS query failed, switching to next server", "server", srv, "error", err)
}
}
slog.Warn("all DNS servers failed", "servers", servers, "error", lastErr)
return nil, 0, lastErr
}
// defaultUDPServer 兜底 UDP 服务器
const defaultUDPServer = "119.28.28.28:53"
// queryUDPMsg 通过 UDP/TCP 向指定 DNS 服务器发送查询(miekg/dns 自动处理大响应切 TCP)
func queryUDPMsg(msg *dns.Msg, server string) (*dns.Msg, float64, error) {
client := &dns.Client{Timeout: 5 * time.Second}
@@ -255,9 +255,9 @@ func ResolveDNSSECForRecord(domain string, recordType uint16) (*DNSSECResult, er
return result, nil
}
// executeDoHQueryMsg 通过 queryDNSMsg(DoH+UDP 双通道)发送 DNS 查询并返回解析后的响应消息
// executeDoHQueryMsg 通过 queryDNSSECMsg(DNSSEC 专用 DNS,主从 failover;未配置专用则沿用 dns-server)发送 DNS 查询并返回解析后的响应消息
func executeDoHQueryMsg(msg *dns.Msg) (*dns.Msg, float64, error) {
responseMsg, duration, err := queryDNSMsg(msg)
responseMsg, duration, err := queryDNSSECMsg(msg)
if err != nil {
return nil, duration, err
}
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,10 +10,10 @@
"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"
"wrangler": "^4.124.0"
},
"dependencies": {
"hono": "^4.12.34"
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.
View File
File renamed without changes.
+8 -4
View File
@@ -1,10 +1,12 @@
module lemon-ipw
go 1.26.2
go 1.26.6
require (
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
@@ -18,6 +20,7 @@ require (
)
require (
github.com/AdguardTeam/golibs v0.35.16 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
@@ -53,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
)
+17
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=
@@ -6,9 +8,12 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/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/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=
@@ -38,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,12 +82,14 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
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/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/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,6 +131,8 @@ 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,13 +144,19 @@ golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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=
+72
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"
@@ -81,6 +84,14 @@ func PullDatabase(ghproxy string) error {
}
os.Remove(dst)
}
if strings.HasSuffix(t.name, ".zip") {
outName := strings.TrimSuffix(t.name, ".zip")
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)
@@ -205,3 +216,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
}
+163 -3
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()
@@ -552,6 +598,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 +629,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 +653,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 +701,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 +719,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 +862,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
}
+161 -373
View File
@@ -3,7 +3,6 @@ package main
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
@@ -13,7 +12,6 @@ import (
"log/slog"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strconv"
@@ -21,6 +19,7 @@ import (
"sync"
"time"
"github.com/AdguardTeam/golibs/netutil/sysresolv"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/klauspost/compress/zstd"
@@ -85,10 +84,10 @@ func initHTTPClients() {
}
func fakePerfectWebsiteResult(host string) *WebsiteCheckDetail {
func fakePerfectWebsiteResult(host string) *webtest.WebsiteCheckDetail {
cleanHost := strings.TrimPrefix(host, "https://")
cleanHost = strings.TrimPrefix(cleanHost, "http://")
return &WebsiteCheckDetail{
return &webtest.WebsiteCheckDetail{
HostRecord: cleanHost,
HTTPStatusCode: 200,
HTTPSSStatusCode: 200,
@@ -103,8 +102,8 @@ func fakePerfectWebsiteResult(host string) *WebsiteCheckDetail {
}
}
func fakeInvalidSSLResult(host string) *SSLCheckDetail {
return &SSLCheckDetail{
func fakeInvalidSSLResult(host string) *webtest.SSLCheckDetail {
return &webtest.SSLCheckDetail{
CertValidityDays: 0,
IsExpired: true,
CertStartTime: time.Time{},
@@ -170,26 +169,6 @@ func (b *zstdReader) Close() error {
})
return b.closeErr
}
func cleanHostRecord(addr string) string {
if strings.HasPrefix(addr, "[") {
rightBracket := strings.Index(addr, "]")
if rightBracket != -1 {
return addr[1:rightBracket]
}
}
if idx := strings.LastIndex(addr, ":"); idx != -1 {
colonCount := strings.Count(addr, ":")
if colonCount > 1 {
return addr[:idx]
}
if colonCount == 1 {
return addr[:idx]
}
}
return addr
}
// normalizeURL normalizes the input URL by ensuring it has a scheme (http or https).
// normalizeURL 通过确保输入 URL 具有方案(http 或 https)来规范化输入 URL。
@@ -236,28 +215,33 @@ 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
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
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] 一致,可空)
VERSION string
COMMIT string
BUILD_TIME string
)
type websiteCacheEntry struct {
@@ -276,7 +260,7 @@ type pingCacheEntry struct {
}
type speedCacheEntry struct {
result *WebsiteSpeedTestResult
result *webtest.WebsiteSpeedTestResult
timestamp time.Time
}
@@ -291,305 +275,22 @@ type asnWhoisCacheEntry struct {
}
type WebsiteCheckResult struct {
IPv4 *WebsiteCheckDetail `json:"ipv4"`
IPv6 *WebsiteCheckDetail `json:"ipv6"`
}
type WebsiteCheckDetail struct {
HostRecord string `json:"host_record"`
HTTPStatusCode int `json:"http_status_code"`
HTTPSSStatusCode int `json:"https_status_code"`
DNSLookupTime float64 `json:"dns_lookup_time"`
TCPConnectTime float64 `json:"tcp_connect_time"`
HTTPConnectTime float64 `json:"http_connect_time"`
FirstByteTime float64 `json:"first_byte_time"`
TotalTime float64 `json:"total_time"`
PageSize int64 `json:"page_size"`
DownloadSpeed float64 `json:"download_speed"`
IsReachable bool `json:"is_reachable"`
}
type SSLCheckDetail struct {
CertValidityDays int `json:"cert_validity_days"`
CertStartTime time.Time `json:"cert_start_time"`
CertEndTime time.Time `json:"cert_end_time"`
HTTPVersion string `json:"http_version"`
HostRecord string `json:"host_record"`
HTTPSSStatusCode int `json:"https_status_code"`
TotalTime float64 `json:"total_time"`
DownloadSpeed float64 `json:"download_speed"`
Domain string `json:"domain"`
IssuerOrganization []string `json:"issuer_organization"`
IssuerCommonName string `json:"issuer_common_name"`
SubjectCommonName string `json:"subject_common_name"`
IsExpired bool `json:"is_expired"`
IsReachable bool `json:"is_reachable"`
IPv4 *webtest.WebsiteCheckDetail `json:"ipv4"`
IPv6 *webtest.WebsiteCheckDetail `json:"ipv6"`
}
type SSLCheckResult struct {
IPv4 *SSLCheckDetail `json:"ipv4"`
IPv6 *SSLCheckDetail `json:"ipv6"`
IPv4 *webtest.SSLCheckDetail `json:"ipv4"`
IPv6 *webtest.SSLCheckDetail `json:"ipv6"`
}
type TCPingResult struct {
IPv4 *webtest.TCPingStats `json:"ipv4"`
IPv6 *webtest.TCPingStats `json:"ipv6"`
}
type WebsiteSpeedTestResult struct {
Version string `json:"version"`
HostRecord string `json:"host_record"`
HTTPStatusCode int `json:"http_status_code"`
HTTPSSStatusCode int `json:"https_status_code"`
DNSLookupTime float64 `json:"dns_lookup_time"`
TCPConnectTime float64 `json:"tcp_connect_time"`
HTTPConnectTime float64 `json:"http_connect_time"`
FirstByteTime float64 `json:"first_byte_time"`
TotalTime float64 `json:"total_time"`
PageSize int64 `json:"page_size"`
DownloadSpeed float64 `json:"download_speed"`
Message string `json:"message"`
Headers string `json:"headers"`
IsReachable bool `json:"is_reachable"`
}
// Business Endpoints
// 业务端点
func checkWebsite(url string, version string) (*WebsiteCheckDetail, error) {
ctx := context.Background()
var err error
ctx, err = ssrf.ValidateOutboundTarget(ctx, url)
if err != nil {
return nil, err
}
client := V4Client
if version == "v6" {
client = V6Client
}
startTime := time.Now()
resp, err := client.R().EnableTrace().SetContext(ctx).Get(url)
// HTTPS 请求失败时 fallback 到 HTTP
fallbackToHTTP := false
if err != nil && strings.HasPrefix(url, "https://") {
httpURL := strings.Replace(url, "https://", "http://", 1)
startTime = time.Now()
resp, err = client.R().EnableTrace().SetContext(ctx).Get(httpURL)
fallbackToHTTP = true
}
if err != nil {
return nil, err
}
endTime := time.Now()
body := resp.Bytes()
trace := resp.Request.TraceInfo()
hostRecord := cleanHostRecord(trace.RemoteAddr)
dnsLookupTime := trace.DNSLookup.Seconds() * 1000
if dnsLookupTime == 0 {
dnsLookupTime = measureDNSTime(url, version)
}
tcpConnectTime := trace.TCPConnTime.Seconds() * 1000
httpConnectTime := trace.ConnTime.Seconds() * 1000
firstByteTime := trace.ServerTime.Seconds() * 1000
totalTime := float64(endTime.Sub(startTime).Milliseconds())
var downloadSpeed float64
if totalTime > 0 {
downloadSpeed = float64(len(body)) / 1024.0 / (totalTime / 1000.0)
}
httpStatus := resp.StatusCode()
httpsStatus := resp.StatusCode()
if fallbackToHTTP {
httpsStatus = 0
}
result := &WebsiteCheckDetail{
HostRecord: hostRecord,
HTTPStatusCode: httpStatus,
HTTPSSStatusCode: httpsStatus,
DNSLookupTime: dnsLookupTime,
TCPConnectTime: tcpConnectTime,
HTTPConnectTime: httpConnectTime,
FirstByteTime: firstByteTime,
TotalTime: totalTime,
PageSize: int64(len(body)),
DownloadSpeed: downloadSpeed,
IsReachable: true,
}
return result, nil
}
func measureDNSTime(urlStr string, version string) float64 {
parsed, err := url.Parse(urlStr)
if err != nil {
return 0
}
host := parsed.Hostname()
if host == "" {
return 0
}
start := time.Now()
var dnsErr error
if version == "v6" {
_, dnsErr = webtest.ResolveAAAARecord(host)
} else {
_, dnsErr = webtest.ResolveARecord(host)
}
if dnsErr != nil {
return 0
}
return time.Since(start).Seconds() * 1000
}
func websiteSpeed(url string, version string) (*WebsiteSpeedTestResult, error) {
ctx := context.Background()
var err error
ctx, err = ssrf.ValidateOutboundTarget(ctx, url)
if err != nil {
return nil, err
}
client := V4Client
if version == "v6" {
client = V6Client
}
startTime := time.Now()
resp, err := client.R().EnableTrace().SetContext(ctx).Get(url)
fallbackToHTTP := false
if err != nil && strings.HasPrefix(url, "https://") {
httpURL := strings.Replace(url, "https://", "http://", 1)
startTime = time.Now()
resp, err = client.R().EnableTrace().SetContext(ctx).Get(httpURL)
fallbackToHTTP = true
}
if err != nil {
return nil, err
}
endTime := time.Now()
body := resp.Bytes()
trace := resp.Request.TraceInfo()
hostRecord := cleanHostRecord(trace.RemoteAddr)
dnsLookupTime := trace.DNSLookup.Seconds() * 1000
if dnsLookupTime == 0 {
dnsLookupTime = measureDNSTime(url, version)
}
tcpConnectTime := trace.TCPConnTime.Seconds() * 1000
httpConnectTime := trace.ConnTime.Seconds() * 1000
firstByteTime := trace.ServerTime.Seconds() * 1000
totalTime := float64(endTime.Sub(startTime).Milliseconds())
var downloadSpeed float64
if totalTime > 0 {
downloadSpeed = float64(len(body)) / 1024.0 / (totalTime / 1000.0)
}
dumpBytes, _ := httputil.DumpResponse(resp.RawResponse, false)
httpStatus := resp.StatusCode()
httpsStatus := resp.StatusCode()
if fallbackToHTTP {
httpsStatus = 0
}
result := &WebsiteSpeedTestResult{
Version: version,
Headers: string(dumpBytes),
HostRecord: hostRecord,
HTTPStatusCode: httpStatus,
HTTPSSStatusCode: httpsStatus,
DNSLookupTime: dnsLookupTime,
TCPConnectTime: tcpConnectTime,
HTTPConnectTime: httpConnectTime,
FirstByteTime: firstByteTime,
TotalTime: totalTime,
PageSize: int64(len(body)),
DownloadSpeed: downloadSpeed,
IsReachable: true,
}
return result, nil
}
func checkSSL(url string, version string) (*SSLCheckDetail, error) {
ctx := context.Background()
var err error
ctx, err = ssrf.ValidateOutboundTarget(ctx, url)
if err != nil {
return nil, err
}
client := V4Client
if version == "v6" {
client = V6Client
}
startTime := time.Now()
resp, err := client.R().EnableTrace().SetContext(ctx).Get(url)
if err != nil {
return nil, err
}
endTime := time.Now()
trace := resp.Request.TraceInfo()
hostRecord := cleanHostRecord(trace.RemoteAddr)
totalTime := float64(endTime.Sub(startTime).Milliseconds())
body := resp.Bytes()
var downloadSpeed float64
if totalTime > 0 {
downloadSpeed = float64(len(body)) / 1024.0 / (totalTime / 1000.0)
}
rawResp := resp.RawResponse
var cert *x509.Certificate
var remainingDays int
var isExpired bool
var issuerOrganization []string
var issuerCommonName, subjectCommonName, domain string
if rawResp.TLS != nil && len(rawResp.TLS.PeerCertificates) > 0 {
cert = rawResp.TLS.PeerCertificates[0]
now := time.Now()
remainingDays = int(cert.NotAfter.Sub(now).Hours() / 24)
isExpired = now.After(cert.NotAfter) || now.Before(cert.NotBefore)
issuerOrganization = cert.Issuer.Organization
issuerCommonName = cert.Issuer.CommonName
subjectCommonName = cert.Subject.CommonName
domain = cleanHostRecord(cert.Subject.CommonName)
} else {
return nil, fmt.Errorf("no SSL certificate found")
}
result := &SSLCheckDetail{
CertValidityDays: remainingDays,
IsExpired: isExpired,
CertStartTime: cert.NotBefore,
CertEndTime: cert.NotAfter,
HTTPVersion: resp.Proto(),
HostRecord: hostRecord,
HTTPSSStatusCode: resp.StatusCode(),
TotalTime: totalTime,
DownloadSpeed: downloadSpeed,
Domain: domain,
IssuerOrganization: issuerOrganization,
IssuerCommonName: issuerCommonName,
SubjectCommonName: subjectCommonName,
IsReachable: true,
}
return result, nil
}
func checkWebsiteHandler(c *gin.Context) {
testUrl := c.Param("url")
if testUrl == "" {
@@ -623,28 +324,28 @@ func checkWebsiteHandler(c *gin.Context) {
result := &WebsiteCheckResult{}
switch SINGLE_STACK {
case "ipv4":
ipv4, errV4 := checkWebsite(testUrl, "v4")
ipv4, errV4 := webtest.CheckWebsite(testUrl, "v4")
if errV4 != nil {
ipv4 = &WebsiteCheckDetail{
ipv4 = &webtest.WebsiteCheckDetail{
HostRecord: "Error: " + errV4.Error(),
IsReachable: false,
}
}
result.IPv4 = ipv4
result.IPv6 = &WebsiteCheckDetail{
result.IPv6 = &webtest.WebsiteCheckDetail{
HostRecord: "Skipped due to SINGLE_STACK=ipv4",
IsReachable: false,
}
case "ipv6":
ipv6, errV6 := checkWebsite(testUrl, "v6")
ipv6, errV6 := webtest.CheckWebsite(testUrl, "v6")
if errV6 != nil {
ipv6 = &WebsiteCheckDetail{
ipv6 = &webtest.WebsiteCheckDetail{
HostRecord: "Error: " + errV6.Error(),
IsReachable: false,
}
}
result.IPv6 = ipv6
result.IPv4 = &WebsiteCheckDetail{
result.IPv4 = &webtest.WebsiteCheckDetail{
HostRecord: "Skipped due to SINGLE_STACK=ipv6",
IsReachable: false,
}
@@ -654,9 +355,9 @@ func checkWebsiteHandler(c *gin.Context) {
go func() {
defer wg.Done()
ipv6, errV6 := checkWebsite(testUrl, "v6")
ipv6, errV6 := webtest.CheckWebsite(testUrl, "v6")
if errV6 != nil {
ipv6 = &WebsiteCheckDetail{
ipv6 = &webtest.WebsiteCheckDetail{
HostRecord: "Error: " + errV6.Error(),
IsReachable: false,
}
@@ -666,9 +367,9 @@ func checkWebsiteHandler(c *gin.Context) {
go func() {
defer wg.Done()
ipv4, errV4 := checkWebsite(testUrl, "v4")
ipv4, errV4 := webtest.CheckWebsite(testUrl, "v4")
if errV4 != nil {
ipv4 = &WebsiteCheckDetail{
ipv4 = &webtest.WebsiteCheckDetail{
HostRecord: "Error: " + errV4.Error(),
IsReachable: false,
}
@@ -708,7 +409,7 @@ func websiteSpeedTestHandler(c *gin.Context) {
switch SINGLE_STACK {
case "ipv4":
if version != "v4" {
c.JSON(http.StatusBadRequest, &WebsiteSpeedTestResult{
c.JSON(http.StatusBadRequest, &webtest.WebsiteSpeedTestResult{
Version: "v4",
HostRecord: "Skipped due to SINGLE_STACK=ipv4",
})
@@ -716,7 +417,7 @@ func websiteSpeedTestHandler(c *gin.Context) {
}
case "ipv6":
if version != "v6" {
c.JSON(http.StatusBadRequest, &WebsiteSpeedTestResult{
c.JSON(http.StatusBadRequest, &webtest.WebsiteSpeedTestResult{
Version: "v6",
HostRecord: "Skipped due to SINGLE_STACK=ipv6",
})
@@ -737,14 +438,14 @@ func websiteSpeedTestHandler(c *gin.Context) {
speedCache.Delete(cacheKey)
}
var result *WebsiteSpeedTestResult
var result *webtest.WebsiteSpeedTestResult
switch version {
case "v6", "v4":
rawResult, _, _ := sfGroup.Do(cacheKey, func() (interface{}, error) {
r, e := websiteSpeed(url, version)
r, e := webtest.SpeedTest(url, version)
if e != nil {
errorResult := &WebsiteSpeedTestResult{
errorResult := &webtest.WebsiteSpeedTestResult{
HostRecord: "Error: " + e.Error(),
}
speedCache.Store(cacheKey, speedCacheEntry{result: errorResult, timestamp: time.Now()})
@@ -757,7 +458,7 @@ func websiteSpeedTestHandler(c *gin.Context) {
speedCache.Store(cacheKey, speedCacheEntry{result: r, timestamp: time.Now()})
return r, nil
})
result = rawResult.(*WebsiteSpeedTestResult)
result = rawResult.(*webtest.WebsiteSpeedTestResult)
default:
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid version",
@@ -801,31 +502,31 @@ func sslCheckHandler(c *gin.Context) {
result := &SSLCheckResult{}
switch SINGLE_STACK {
case "ipv4":
ipv4, errV4 := checkSSL(testUrl, "v4")
ipv4, errV4 := webtest.CheckSSL(testUrl, "v4")
if errV4 != nil {
ipv4 = &SSLCheckDetail{
ipv4 = &webtest.SSLCheckDetail{
HostRecord: "Error: " + errV4.Error(),
IsExpired: true,
IsReachable: false,
}
}
result.IPv4 = ipv4
result.IPv6 = &SSLCheckDetail{
result.IPv6 = &webtest.SSLCheckDetail{
HostRecord: "Skipped due to SINGLE_STACK=ipv4",
IsExpired: true,
IsReachable: false,
}
case "ipv6":
ipv6, errV6 := checkSSL(testUrl, "v6")
ipv6, errV6 := webtest.CheckSSL(testUrl, "v6")
if errV6 != nil {
ipv6 = &SSLCheckDetail{
ipv6 = &webtest.SSLCheckDetail{
HostRecord: "Error: " + errV6.Error(),
IsExpired: true,
IsReachable: false,
}
}
result.IPv6 = ipv6
result.IPv4 = &SSLCheckDetail{
result.IPv4 = &webtest.SSLCheckDetail{
HostRecord: "Skipped due to SINGLE_STACK=ipv6",
IsExpired: true,
IsReachable: false,
@@ -836,9 +537,9 @@ func sslCheckHandler(c *gin.Context) {
go func() {
defer wg.Done()
ipv6, errV6 := checkSSL(testUrl, "v6")
ipv6, errV6 := webtest.CheckSSL(testUrl, "v6")
if errV6 != nil {
ipv6 = &SSLCheckDetail{
ipv6 = &webtest.SSLCheckDetail{
HostRecord: "Error: " + errV6.Error(),
IsExpired: true,
IsReachable: false,
@@ -849,9 +550,9 @@ func sslCheckHandler(c *gin.Context) {
go func() {
defer wg.Done()
ipv4, errV4 := checkSSL(testUrl, "v4")
ipv4, errV4 := webtest.CheckSSL(testUrl, "v4")
if errV4 != nil {
ipv4 = &SSLCheckDetail{
ipv4 = &webtest.SSLCheckDetail{
HostRecord: "Error: " + errV4.Error(),
IsExpired: true,
IsReachable: false,
@@ -899,12 +600,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,
@@ -1229,6 +941,7 @@ func tokenCheck() gin.HandlerFunc {
}
}
}
// fetchRemoteConfig 从远端 URL 拉取配置文件(遵守 setting.json 的 JSON 格式)。
// 拉取或解析失败时返回错误,调用方应回退到本地配置。
func fetchRemoteConfig(url string) (map[string]any, error) {
@@ -1274,7 +987,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 == "" {
@@ -1285,29 +998,51 @@ func applyRemoteConfig() {
slog.Warn("Failed to fetch remote config, falling back to local config", "url", url, "error", err)
return
}
if v := configValue(CONFIG, "port"); v != "" {
// ignore 列表:数组中的配置项不被远端覆盖(逐键判断跳过)
ignored := func(key string) bool {
for _, k := range REMOTE_IGNORE_CONFIG {
if k == key {
return true
}
}
return false
}
if v := configValue(CONFIG, "port"); v != "" && !ignored("port") {
PORTS = v
}
if v := configValue(CONFIG, "gh-proxy"); v != "" {
if v := configValue(CONFIG, "gh-proxy"); v != "" && !ignored("gh-proxy") {
GH_PROXY = v
}
if v := configValue(CONFIG, "single-stack"); v != "" {
if v := configValue(CONFIG, "single-stack"); v != "" && !ignored("single-stack") {
SINGLE_STACK = strings.ToLower(v)
}
if v := configValue(CONFIG, "dns-server"); v != "" {
if v := configValue(CONFIG, "dns-server"); v != "" && !ignored("dns-server") {
DNS_SERVER = v
}
if v := configValue(CONFIG, "ipdb"); 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 != "" {
if v := configValue(CONFIG, "cors"); v != "" && !ignored("cors") {
CORS = v
}
// block-private-ips 与 setting.json 格式一致,允许远端覆盖
if v := configValue(CONFIG, "block-private-ips"); v != "" {
if v := configValue(CONFIG, "block-private-ips"); v != "" && !ignored("block-private-ips") {
ssrf.SetEnabled(v != "false" && v != "0")
}
// access_token 不在此覆盖:保持原有优先级(环境变量 > setting.json)
// WS 客户端配置(远端可覆盖,除非在 ignore 列表中)
if v := configValue(CONFIG, "ws-url"); v != "" && !ignored("ws-url") {
WS_URL = v
}
if v := configValue(CONFIG, "node-id"); v != "" && !ignored("node-id") {
WS_NODE_ID = v
}
if v := configValue(CONFIG, "node-key"); v != "" && !ignored("node-key") {
WS_NODE_KEY = v
}
// access-token 不在此覆盖:保持原有优先级(环境变量 > setting.json)
if CORS != "" {
ACCEPT_DOMAINS = strings.Split(CORS, ",")
}
@@ -1322,6 +1057,7 @@ func readConfig() {
// 如果当前测速节点机器是单栈网络,建议设置 SINGLE_STACK 环境变量来跳过另一个协议的测试,以避免不必要的错误日志和延迟
SINGLE_STACK = strings.ToLower(strings.TrimSpace(os.Getenv("SINGLE_STACK")))
DNS_SERVER = os.Getenv("DNS_SERVER")
DNSSEC_DNS_SERVER = os.Getenv("DNSSEC_DNS_SERVER")
IPDB = os.Getenv("IPDB")
CORS = os.Getenv("CORS")
ACCESS_TOKEN = os.Getenv("ACCESS_TOKEN")
@@ -1347,6 +1083,9 @@ func readConfig() {
if DNS_SERVER == "" {
DNS_SERVER = viper.GetString("dns-server")
}
if DNSSEC_DNS_SERVER == "" {
DNSSEC_DNS_SERVER = viper.GetString("dnssec-server")
}
if IPDB == "" {
IPDB = viper.GetString("ipdb")
}
@@ -1360,14 +1099,54 @@ func readConfig() {
ACCEPT_DOMAINS = strings.Split(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 通道):WS_URL / NODE_ID / NODE_KEY,env 优先
WS_URL = os.Getenv("WS_URL")
if WS_URL == "" {
WS_URL = viper.GetString("ws-url")
}
WS_NODE_ID = os.Getenv("NODE_ID")
if WS_NODE_ID == "" {
WS_NODE_ID = viper.GetString("node-id")
}
WS_NODE_KEY = os.Getenv("NODE_KEY")
if WS_NODE_KEY == "" {
WS_NODE_KEY = viper.GetString("node-key")
}
// 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_IGNORE_CONFIG = list
} else {
slog.Warn("invalid REMOTE_IGNORE_CONFIG, ignored", "error", err)
}
} else {
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 {
slog.Warn("Cannot setup reslovers")
}
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())
}
@@ -1384,13 +1163,22 @@ func main() {
slog.Info("LEMON IPW TEST NODE GOLANG VERSION", "version", VERSION, "commit", COMMIT, "build_time", BUILD_TIME)
readConfig()
webtest.SetDNSServer(DNS_SERVER)
webtest.SetDNSSecServer(DNSSEC_DNS_SERVER)
initHTTPClients()
// 注入出站 HTTP 客户端到 webtest(探针函数内部按版本取用)
webtest.SetHTTPClient(V4Client, V6Client)
if IPDB != "false" {
ipdb.Init(GH_PROXY)
}
slog.Info("Starting server", "port", PORTS, "gh_proxy", GH_PROXY, "single_stack", SINGLE_STACK, "dns_server", DNS_SERVER, "CORS_ACCEPT", ACCEPT_DOMAINS)
// WS 客户端:接入中间件 WS 通道(配置 WS_URL 时启用,HTTP 接口不变)
if WS_URL != "" {
go wsClientLoop()
slog.Info("WS client enabled", "url", WS_URL, "nodeId", WS_NODE_ID)
}
r := gin.Default()
corsConfig := cors.DefaultConfig()
+7 -2
View File
@@ -3,9 +3,14 @@
"gh-proxy": "",
"single-stack": "",
"dns-server": "",
"dnssec-server": "",
"block-private-ips": true,
"ipdb": true,
"ipdb": "false",
"cors": "",
"remote-config-url": "",
"access_token": ""
"remote-ignore-config": [],
"access-token": "",
"ws-url": "",
"node-id": "",
"node-key": ""
}
View File
File renamed without changes.
+20
View File
@@ -0,0 +1,20 @@
package webtest
import "resty.dev/v3"
// 出站 HTTP 客户端(定义与初始化仍在 main.go,启动时经 SetHTTPClient 注入)
var v4Client, v6Client *resty.Client
// SetHTTPClient 注入 v4 / v6 出站客户端(main.go 启动时调用)
func SetHTTPClient(v4, v6 *resty.Client) {
v4Client = v4
v6Client = v6
}
// httpClient 按版本返回出站客户端
func httpClient(version string) *resty.Client {
if version == "v6" {
return v6Client
}
return v4Client
}
+61 -16
View File
@@ -14,39 +14,84 @@ import (
)
var (
dnsServer = "119.28.28.28:53"
// dnsServers 普通 DNS 查询服务器列表(主从:第一个为主,主失败自动切换后续从服务器)
dnsServers = []string{"119.28.28.28:53"}
// dnssecServers DNSSEC 专用服务器列表(未配置时沿用 dnsServers)
dnssecServers []string
)
// SetDNSServer 设置DNS服务器地址(格式: "ip:port",也支持 DoH URL http(s)://...)
// SetDNSServer 设置DNS服务器地址(逗号分隔多地址:第一个为主,主失败自动切换后续从服务器)。
// 每项支持 "ip:port"(UDP)或 DoH URL http(s)://...
func SetDNSServer(server string) {
if server != "" {
dnsServer = server
if list := splitServers(server); len(list) > 0 {
dnsServers = list
}
}
// SetDNSSecServer 设置DNSSEC专用DNS服务器(逗号分隔主从;留空 = 沿用 dns-server 配置)
func SetDNSSecServer(server string) {
if list := splitServers(server); len(list) > 0 {
dnssecServers = list
}
}
// splitServers 逗号分隔解析为地址列表(去空白、去空项)
func splitServers(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
const (
defaultUDPServer = "119.28.28.28:53"
defaultDoHEndpoint = "https://doh.pub/dns-query"
)
// queryDNS 统一 DNS 查询入口:支持 DoH 与 UDP 双通道自动互备。
// dnsServer 配置为 URL(http/https)时优先 DoH,失败回退 UDP(defaultUDPServer);
// 配置为 ip:port 时优先 UDP,失败回退 DoH(defaultDoHEndpoint)。
// queryDNS 普通 DNS 查询:主从 failover——按配置顺序依次尝试每个服务器,
// 当前服务器查询失败(网络错误/超时)自动切换下一个;全部失败返回最后一个错误。
func queryDNS(msg *dns.Msg) (*dns.Msg, error) {
if strings.HasPrefix(dnsServer, "http://") || strings.HasPrefix(dnsServer, "https://") {
resp, err := queryDoHMsg(msg, dnsServer)
return queryWithServers(msg, dnsServers)
}
// queryDNSSEC DNSSEC 查询:优先使用专用服务器(dnssec-server),未配置则沿用普通 dns-server
func queryDNSSEC(msg *dns.Msg) (*dns.Msg, error) {
if len(dnssecServers) > 0 {
return queryWithServers(msg, dnssecServers)
}
return queryWithServers(msg, dnsServers)
}
// queryWithServers 依次尝试服务器列表,主失败切从
func queryWithServers(msg *dns.Msg, servers []string) (*dns.Msg, error) {
if len(servers) == 0 {
servers = []string{defaultUDPServer}
}
var lastErr error
for i, srv := range servers {
resp, err := queryOne(msg, srv)
if err == nil {
return resp, nil
}
slog.Warn("DoH query failed, falling back to UDP", "endpoint", dnsServer, "error", err)
return queryUDPMsg(msg, defaultUDPServer)
lastErr = err
if i < len(servers)-1 {
slog.Warn("DNS query failed, switching to next server", "server", srv, "error", err)
}
}
resp, err := queryUDPMsg(msg, dnsServer)
if err == nil {
return resp, nil
slog.Warn("all DNS servers failed", "servers", servers, "error", lastErr)
return nil, lastErr
}
// queryOne 向单个 DNS 服务器查询:配置为 URL(http/https)走 DoH,否则走 UDP
func queryOne(msg *dns.Msg, server string) (*dns.Msg, error) {
if strings.HasPrefix(server, "http://") || strings.HasPrefix(server, "https://") {
return queryDoHMsg(msg, server)
}
slog.Warn("UDP query failed, falling back to DoH", "server", dnsServer, "error", err)
return queryDoHMsg(msg, defaultDoHEndpoint)
return queryUDPMsg(msg, server)
}
// queryUDPMsg 通过 UDP/TCP 向指定 DNS 服务器发送查询(miekg/dns 自动处理大响应切 TCP)
+6 -6
View File
@@ -41,7 +41,7 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
msgDNSKEY.SetQuestion(dns.Fqdn(domain), dns.TypeDNSKEY)
msgDNSKEY.SetEdns0(4096, true)
responseDNSKEY, err := queryDNS(msgDNSKEY)
responseDNSKEY, err := queryDNSSEC(msgDNSKEY)
result.Duration = time.Since(start).Seconds() * 1000
if err != nil {
@@ -74,7 +74,7 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
msgA.SetQuestion(dns.Fqdn(domain), dns.TypeA)
msgA.SetEdns0(4096, true)
responseA, err := queryDNS(msgA)
responseA, err := queryDNSSEC(msgA)
if err == nil && responseA.Rcode == dns.RcodeSuccess {
var aRRset []dns.RR
for _, ans := range responseA.Answer {
@@ -117,7 +117,7 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
// 4. 查询 DS 记录
msgDS := new(dns.Msg)
msgDS.SetQuestion(dns.Fqdn(domain), dns.TypeDS)
responseDS, _ := queryDNS(msgDS)
responseDS, _ := queryDNSSEC(msgDS)
if responseDS != nil && responseDS.Rcode == dns.RcodeSuccess {
for _, ans := range responseDS.Answer {
if _, ok := ans.(*dns.DS); ok {
@@ -159,7 +159,7 @@ func ResolveDNSSECForRecord(domain string, recordType uint16) (*DNSSECResult, er
msg.SetQuestion(dns.Fqdn(domain), recordType)
msg.SetEdns0(4096, true)
response, err := queryDNS(msg)
response, err := queryDNSSEC(msg)
result.Duration = time.Since(start).Seconds() * 1000
if err != nil {
@@ -204,7 +204,7 @@ func ResolveDNSSECForRecord(domain string, recordType uint16) (*DNSSECResult, er
msgDNSKEY.SetQuestion(dns.Fqdn(domain), dns.TypeDNSKEY)
msgDNSKEY.SetEdns0(4096, true)
responseDNSKEY, err := queryDNS(msgDNSKEY)
responseDNSKEY, err := queryDNSSEC(msgDNSKEY)
if err == nil && responseDNSKEY.Rcode == dns.RcodeSuccess {
var dnskeyRRset []dns.RR
for _, ans := range responseDNSKEY.Answer {
@@ -237,7 +237,7 @@ func ResolveDNSSECForRecord(domain string, recordType uint16) (*DNSSECResult, er
// 查询 DS 记录
msgDS := new(dns.Msg)
msgDS.SetQuestion(dns.Fqdn(domain), dns.TypeDS)
responseDS, _ := queryDNS(msgDS)
responseDS, _ := queryDNSSEC(msgDS)
if responseDS != nil && responseDS.Rcode == dns.RcodeSuccess {
for _, ans := range responseDS.Answer {
if _, ok := ans.(*dns.DS); ok {
+55
View File
@@ -0,0 +1,55 @@
package webtest
import (
"net/netip"
"net/url"
"strings"
"time"
)
// CleanHostRecord 从 trace 的 RemoteAddr 提取纯主机名(兼容 IPv6 方括号)
func CleanHostRecord(addr string) string {
if strings.HasPrefix(addr, "[") {
rightBracket := strings.Index(addr, "]")
if rightBracket != -1 {
return addr[1:rightBracket]
}
}
if idx := strings.LastIndex(addr, ":"); idx != -1 {
return addr[:idx]
}
return addr
}
// measureDNSTime 当 trace 未提供 DNS 耗时时的兜底测量
func measureDNSTime(urlStr string, version string) float64 {
parsed, err := url.Parse(urlStr)
if err != nil {
return 0
}
host := parsed.Hostname()
if host == "" {
return 0
}
start := time.Now()
var dnsErr error
if version == "v6" {
_, dnsErr = ResolveAAAARecord(host)
} else {
_, dnsErr = ResolveARecord(host)
}
if dnsErr != nil {
return 0
}
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, ",")
}
+78
View File
@@ -0,0 +1,78 @@
package webtest
import (
"context"
"net/http/httputil"
"strings"
"time"
lemon_ssrf "lemon-ipw/ssrf"
)
// SpeedTest 网站测速(speed)。客户端由 main.go 经 SetHTTPClient 注入。
func SpeedTest(url string, version string) (*WebsiteSpeedTestResult, error) {
ctx := context.Background()
var err error
ctx, err = lemon_ssrf.ValidateOutboundTarget(ctx, url)
if err != nil {
return nil, err
}
startTime := time.Now()
resp, err := httpClient(version).R().EnableTrace().SetContext(ctx).Get(url)
fallbackToHTTP := false
if err != nil && strings.HasPrefix(url, "https://") {
httpURL := strings.Replace(url, "https://", "http://", 1)
startTime = time.Now()
resp, err = httpClient(version).R().EnableTrace().SetContext(ctx).Get(httpURL)
fallbackToHTTP = true
}
if err != nil {
return nil, err
}
endTime := time.Now()
body := resp.Bytes()
trace := resp.Request.TraceInfo()
hostRecord := CleanHostRecord(trace.RemoteAddr)
dnsLookupTime := trace.DNSLookup.Seconds() * 1000
if dnsLookupTime == 0 {
dnsLookupTime = measureDNSTime(url, version)
}
tcpConnectTime := trace.TCPConnTime.Seconds() * 1000
httpConnectTime := trace.ConnTime.Seconds() * 1000
firstByteTime := trace.ServerTime.Seconds() * 1000
totalTime := float64(endTime.Sub(startTime).Milliseconds())
var downloadSpeed float64
if totalTime > 0 {
downloadSpeed = float64(len(body)) / 1024.0 / (totalTime / 1000.0)
}
dumpBytes, _ := httputil.DumpResponse(resp.RawResponse, false)
httpStatus := resp.StatusCode()
httpsStatus := resp.StatusCode()
if fallbackToHTTP {
httpsStatus = 0
}
result := &WebsiteSpeedTestResult{
Version: version,
Headers: string(dumpBytes),
HostRecord: hostRecord,
HTTPStatusCode: httpStatus,
HTTPSSStatusCode: httpsStatus,
DNSLookupTime: dnsLookupTime,
TCPConnectTime: tcpConnectTime,
HTTPConnectTime: httpConnectTime,
FirstByteTime: firstByteTime,
TotalTime: totalTime,
PageSize: int64(len(body)),
DownloadSpeed: downloadSpeed,
IsReachable: true,
}
return result, nil
}
+76
View File
@@ -0,0 +1,76 @@
package webtest
import (
"context"
"crypto/x509"
"fmt"
"time"
lemon_ssrf "lemon-ipw/ssrf"
)
// CheckSSL SSL 检查(ssl)。客户端由 main.go 经 SetHTTPClient 注入。
func CheckSSL(url string, version string) (*SSLCheckDetail, error) {
ctx := context.Background()
var err error
ctx, err = lemon_ssrf.ValidateOutboundTarget(ctx, url)
if err != nil {
return nil, err
}
startTime := time.Now()
resp, err := httpClient(version).R().EnableTrace().SetContext(ctx).Get(url)
if err != nil {
return nil, err
}
endTime := time.Now()
trace := resp.Request.TraceInfo()
hostRecord := CleanHostRecord(trace.RemoteAddr)
totalTime := float64(endTime.Sub(startTime).Milliseconds())
body := resp.Bytes()
var downloadSpeed float64
if totalTime > 0 {
downloadSpeed = float64(len(body)) / 1024.0 / (totalTime / 1000.0)
}
rawResp := resp.RawResponse
var cert *x509.Certificate
var remainingDays int
var isExpired bool
var issuerOrganization []string
var issuerCommonName, subjectCommonName, domain string
if rawResp.TLS != nil && len(rawResp.TLS.PeerCertificates) > 0 {
cert = rawResp.TLS.PeerCertificates[0]
now := time.Now()
remainingDays = int(cert.NotAfter.Sub(now).Hours() / 24)
isExpired = now.After(cert.NotAfter) || now.Before(cert.NotBefore)
issuerOrganization = cert.Issuer.Organization
issuerCommonName = cert.Issuer.CommonName
subjectCommonName = cert.Subject.CommonName
domain = CleanHostRecord(cert.Subject.CommonName)
} else {
return nil, fmt.Errorf("no SSL certificate found")
}
result := &SSLCheckDetail{
CertValidityDays: remainingDays,
IsExpired: isExpired,
CertStartTime: cert.NotBefore,
CertEndTime: cert.NotAfter,
HTTPVersion: resp.Proto(),
HostRecord: hostRecord,
HTTPSSStatusCode: resp.StatusCode(),
TotalTime: totalTime,
DownloadSpeed: downloadSpeed,
Domain: domain,
IssuerOrganization: issuerOrganization,
IssuerCommonName: issuerCommonName,
SubjectCommonName: subjectCommonName,
IsReachable: true,
}
return result, nil
}
File renamed without changes.
+53
View File
@@ -0,0 +1,53 @@
package webtest
import "time"
// 站点类探测的公共类型(CheckWebsite / SpeedTest / CheckSSL 共用)
type WebsiteCheckDetail struct {
HostRecord string `json:"host_record"`
HTTPStatusCode int `json:"http_status_code"`
HTTPSSStatusCode int `json:"https_status_code"`
DNSLookupTime float64 `json:"dns_lookup_time"`
TCPConnectTime float64 `json:"tcp_connect_time"`
HTTPConnectTime float64 `json:"http_connect_time"`
FirstByteTime float64 `json:"first_byte_time"`
TotalTime float64 `json:"total_time"`
PageSize int64 `json:"page_size"`
DownloadSpeed float64 `json:"download_speed"`
IsReachable bool `json:"is_reachable"`
}
type SSLCheckDetail struct {
CertValidityDays int `json:"cert_validity_days"`
CertStartTime time.Time `json:"cert_start_time"`
CertEndTime time.Time `json:"cert_end_time"`
HTTPVersion string `json:"http_version"`
HostRecord string `json:"host_record"`
HTTPSSStatusCode int `json:"https_status_code"`
TotalTime float64 `json:"total_time"`
DownloadSpeed float64 `json:"download_speed"`
Domain string `json:"domain"`
IssuerOrganization []string `json:"issuer_organization"`
IssuerCommonName string `json:"issuer_common_name"`
SubjectCommonName string `json:"subject_common_name"`
IsExpired bool `json:"is_expired"`
IsReachable bool `json:"is_reachable"`
}
type WebsiteSpeedTestResult struct {
Version string `json:"version"`
HostRecord string `json:"host_record"`
HTTPStatusCode int `json:"http_status_code"`
HTTPSSStatusCode int `json:"https_status_code"`
DNSLookupTime float64 `json:"dns_lookup_time"`
TCPConnectTime float64 `json:"tcp_connect_time"`
HTTPConnectTime float64 `json:"http_connect_time"`
FirstByteTime float64 `json:"first_byte_time"`
TotalTime float64 `json:"total_time"`
PageSize int64 `json:"page_size"`
DownloadSpeed float64 `json:"download_speed"`
Message string `json:"message"`
Headers string `json:"headers"`
IsReachable bool `json:"is_reachable"`
}
+77
View File
@@ -0,0 +1,77 @@
package webtest
import (
"context"
"strings"
"time"
lemon_ssrf "lemon-ipw/ssrf"
)
// CheckWebsite 网站检测(detail)。客户端由 main.go 经 SetHTTPClient 注入。
func CheckWebsite(url string, version string) (*WebsiteCheckDetail, error) {
ctx := context.Background()
var err error
ctx, err = lemon_ssrf.ValidateOutboundTarget(ctx, url)
if err != nil {
return nil, err
}
startTime := time.Now()
resp, err := httpClient(version).R().EnableTrace().SetContext(ctx).Get(url)
// HTTPS 请求失败时 fallback 到 HTTP
fallbackToHTTP := false
if err != nil && strings.HasPrefix(url, "https://") {
httpURL := strings.Replace(url, "https://", "http://", 1)
startTime = time.Now()
resp, err = httpClient(version).R().EnableTrace().SetContext(ctx).Get(httpURL)
fallbackToHTTP = true
}
if err != nil {
return nil, err
}
endTime := time.Now()
body := resp.Bytes()
trace := resp.Request.TraceInfo()
hostRecord := CleanHostRecord(trace.RemoteAddr)
dnsLookupTime := trace.DNSLookup.Seconds() * 1000
if dnsLookupTime == 0 {
dnsLookupTime = measureDNSTime(url, version)
}
tcpConnectTime := trace.TCPConnTime.Seconds() * 1000
httpConnectTime := trace.ConnTime.Seconds() * 1000
firstByteTime := trace.ServerTime.Seconds() * 1000
totalTime := float64(endTime.Sub(startTime).Milliseconds())
var downloadSpeed float64
if totalTime > 0 {
downloadSpeed = float64(len(body)) / 1024.0 / (totalTime / 1000.0)
}
httpStatus := resp.StatusCode()
httpsStatus := resp.StatusCode()
if fallbackToHTTP {
httpsStatus = 0
}
result := &WebsiteCheckDetail{
HostRecord: hostRecord,
HTTPStatusCode: httpStatus,
HTTPSSStatusCode: httpsStatus,
DNSLookupTime: dnsLookupTime,
TCPConnectTime: tcpConnectTime,
HTTPConnectTime: httpConnectTime,
FirstByteTime: firstByteTime,
TotalTime: totalTime,
PageSize: int64(len(body)),
DownloadSpeed: downloadSpeed,
IsReachable: true,
}
return result, nil
}
File renamed without changes.
+495
View File
@@ -0,0 +1,495 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"lemon-ipw/ipdb"
"lemon-ipw/webtest"
)
// ==================== WS 客户端(测速节点接入中间件 WS 通道) ====================
//
// 配置(readConfig 解析,环境变量优先,其次 setting.json,远端配置可覆盖):
//
// 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 次
// 判定当前中间件不可用,断开该连接并按同一 URL 独立重连(不影响其他中间件连接)。
// wsProbe 学 HTTP handler:从函数拿数据 + 缓存,返回 (status, body)。
// raw 与 query 来自中间件 probe 消息(等价于路由参数)。
func wsProbe(apiType, raw string, query map[string]string) (int, any) {
switch apiType {
case "detail":
testURL := normalizeURL(raw)
if cached, ok := websiteCache.Load(testURL); ok {
entry := cached.(websiteCacheEntry)
if time.Since(entry.timestamp) < 5*time.Minute {
return 200, entry.result
}
websiteCache.Delete(testURL)
}
ipv4, ipv6 := wsDual[webtest.WebsiteCheckDetail](
func(v string) (*webtest.WebsiteCheckDetail, error) { return webtest.CheckWebsite(testURL, v) },
func(err error) *webtest.WebsiteCheckDetail {
return &webtest.WebsiteCheckDetail{HostRecord: "Error: " + err.Error(), IsReachable: false}
},
func(v string) *webtest.WebsiteCheckDetail {
return &webtest.WebsiteCheckDetail{HostRecord: "Skipped due to SINGLE_STACK=" + v, IsReachable: false}
},
)
result := &WebsiteCheckResult{IPv4: ipv4, IPv6: ipv6}
websiteCache.Store(testURL, websiteCacheEntry{result: result, timestamp: time.Now()})
return 200, result
case "ssl":
testURL := normalizeURL(raw)
if cached, ok := sslCache.Load(testURL); ok {
entry := cached.(sslCacheEntry)
if time.Since(entry.timestamp) < 5*time.Minute {
return 200, entry.result
}
sslCache.Delete(testURL)
}
ipv4, ipv6 := wsDual[webtest.SSLCheckDetail](
func(v string) (*webtest.SSLCheckDetail, error) { return webtest.CheckSSL(testURL, v) },
func(err error) *webtest.SSLCheckDetail {
return &webtest.SSLCheckDetail{HostRecord: "Error: " + err.Error(), IsExpired: true, IsReachable: false}
},
func(v string) *webtest.SSLCheckDetail {
return &webtest.SSLCheckDetail{HostRecord: "Skipped due to SINGLE_STACK=" + v, IsExpired: true, IsReachable: false}
},
)
result := &SSLCheckResult{IPv4: ipv4, IPv6: ipv6}
sslCache.Store(testURL, sslCacheEntry{result: result, timestamp: time.Now()})
return 200, result
case "speed":
// raw 形如 "v4/example.com":首段为版本,其余为 URL
version, testURL := splitRaw(raw)
if version == "" {
version = "v4"
}
testURL = normalizeURL(testURL)
cacheKey := fmt.Sprintf("%s:%s", testURL, version)
if cached, ok := speedCache.Load(cacheKey); ok {
entry := cached.(speedCacheEntry)
if time.Since(entry.timestamp) < 5*time.Minute {
return 200, entry.result
}
speedCache.Delete(cacheKey)
}
r, e := webtest.SpeedTest(testURL, version)
if e != nil {
errorResult := &webtest.WebsiteSpeedTestResult{HostRecord: "Error: " + e.Error()}
speedCache.Store(cacheKey, speedCacheEntry{result: errorResult, timestamp: time.Now()})
return 200, errorResult
}
speedCache.Store(cacheKey, speedCacheEntry{result: r, timestamp: time.Now()})
return 200, r
case "tcping":
host := raw
port := query["port"]
if port == "" {
port = "80"
}
count := query["count"]
n := 4
if count != "" {
if c, err := parseInt(count); err == nil && c >= 1 && c <= 20 {
n = c
}
}
cacheKey := fmt.Sprintf("%s:%s:%d", host, port, n)
if cached, ok := pingCache.Load(cacheKey); ok {
entry := cached.(pingCacheEntry)
if time.Since(entry.timestamp) < 5*time.Minute {
return 200, entry.result
}
pingCache.Delete(cacheKey)
}
ipv4, ipv6 := wsDual[webtest.TCPingStats](
func(v string) (*webtest.TCPingStats, error) {
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} },
)
result := &TCPingResult{IPv4: ipv4, IPv6: ipv6}
pingCache.Store(cacheKey, pingCacheEntry{result: result, timestamp: time.Now()})
return 200, result
case "dns":
// raw 形如 "a/example.com":首段为记录类型
recodeType, domain := splitRaw(raw)
parsedURL, err := parseURL(domain)
if err != nil {
return 400, map[string]string{"error": "Invalid domain"}
}
domain = parsedURL.Host
var result any
switch recodeType {
case "a":
result, err = webtest.ResolveARecord(domain)
case "aaaa":
result, err = webtest.ResolveAAAARecord(domain)
case "cname":
result, err = webtest.ResolveCNAMERecord(domain)
case "mx":
result, err = webtest.ResolveMXRecord(domain)
case "ns":
result, err = webtest.ResolveNSRecord(domain)
case "txt":
result, err = webtest.ResolveTXTRecord(domain)
case "srv":
result, err = webtest.ResolveSRVRecord(domain)
case "ptr":
result, err = webtest.ResolvePTRRecord(domain)
case "caa":
result, err = webtest.ResolveCAARecord(domain)
default:
return 400, map[string]string{"error": "Unsupported record type: " + recodeType}
}
if err != nil {
return 500, map[string]string{"error": err.Error()}
}
return 200, result
case "dnssec":
result, err := webtest.ResolveDNSSEC(raw)
if err != nil {
return 500, map[string]string{"error": err.Error()}
}
return 200, result
case "whois":
if raw == "" {
return 400, map[string]string{"error": "Domain parameter is required"}
}
if cached, ok := whoisCache.Load(raw); ok {
entry := cached.(whoisCacheEntry)
if time.Since(entry.timestamp) < 5*time.Minute {
return 200, entry.result
}
whoisCache.Delete(raw)
}
result, err := webtest.QueryWhois(raw)
if err != nil {
return 500, map[string]string{"error": err.Error()}
}
whoisCache.Store(raw, whoisCacheEntry{result: result, timestamp: time.Now()})
return 200, result
case "location":
return 200, ipdb.SearchIP(raw)
case "asn":
if raw == "" {
return 400, map[string]string{"error": "IP parameter is required"}
}
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 {
asnResult["geolite2_asn"] = map[string]string{"error": errStr}
}
if dbipASN, ok := result["dbip_asn"].(*ipdb.MMDBASNResult); ok {
asnResult["dbip_asn"] = map[string]string{"asn": dbipASN.ASN, "org": dbipASN.Org}
} else if errStr, ok := result["dbip_asn"].(string); ok {
asnResult["dbip_asn"] = map[string]string{"error": errStr}
}
if maxmindASN, ok := result["maxmind_asn"].(*ipdb.MMDBASNResult); ok {
whoisData, err := webtest.QueryASNWhois(maxmindASN.ASN)
if err == nil {
asnResult["whois"] = whoisData
}
}
return 200, asnResult
default:
return 400, map[string]string{"error": "Unsupported apiType: " + apiType}
}
}
// splitRaw 按首个 "/" 拆分为 (type/version, 剩余)
func splitRaw(raw string) (string, string) {
i := strings.Index(raw, "/")
if i == -1 {
return raw, ""
}
return raw[:i], raw[i+1:]
}
// wsDual 双栈编排(ws.go 内部,学 main.go handler 的 SINGLE_STACK 处理)
func wsDual[T any](run func(version string) (*T, error), mkErr func(err error) *T, mkSkip func(version string) *T) (ipv4, ipv6 *T) {
switch SINGLE_STACK {
case "ipv4":
v4, err := run("v4")
if err != nil {
v4 = mkErr(err)
}
return v4, mkSkip("ipv4")
case "ipv6":
v6, err := run("v6")
if err != nil {
v6 = mkErr(err)
}
return mkSkip("ipv6"), v6
default:
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
v4, err := run("v4")
if err != nil {
v4 = mkErr(err)
}
ipv4 = v4
}()
go func() {
defer wg.Done()
v6, err := run("v6")
if err != nil {
v6 = mkErr(err)
}
ipv6 = v6
}()
wg.Wait()
return
}
}
// parseInt 简易整数解析
func parseInt(s string) (int, error) {
n := 0
neg := false
for i, ch := range s {
if i == 0 && ch == '-' {
neg = true
continue
}
if ch < '0' || ch > '9' {
return 0, fmt.Errorf("invalid number %q", s)
}
n = n*10 + int(ch-'0')
}
if neg {
n = -n
}
return n, nil
}
// ---- 连接与消息 ----
type wsMsg struct {
Type string `json:"type"`
NodeID string `json:"nodeId,omitempty"`
TS int64 `json:"ts"`
Data json.RawMessage `json:"data,omitempty"`
}
func wsSend(c *websocket.Conn, m wsMsg) {
_ = wsSendErr(c, m)
}
// wsSendErr 发送消息并返回错误(心跳用)
func wsSendErr(c *websocket.Conn, m wsMsg) error {
b, _ := json.Marshal(m)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return c.Write(ctx, websocket.MessageText, b)
}
func wsRaw(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
return json.RawMessage("{}")
}
return b
}
// wsHandleProbe 处理中间件下发的拨测请求(并发执行):取数 → probe_result
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"`
}
if err := json.Unmarshal(data, &req); err != nil || req.RequestID == "" || req.APIType == "" {
slog.Warn("ws client bad probe message")
return
}
status, body := wsProbe(req.APIType, req.Raw, req.Query)
payload := struct {
RequestID string `json:"requestId"`
Status int `json:"status"`
Body json.RawMessage `json:"body"`
}{req.RequestID, status, nil}
if json.Valid(mustMarshal(body)) {
payload.Body = mustMarshal(body)
} else {
payload.Body, _ = json.Marshal(string(mustMarshal(body)))
}
wsSend(c, wsMsg{Type: "probe_result", Data: wsRaw(payload)})
}
func mustMarshal(v any) json.RawMessage {
b, _ := json.Marshal(v)
return b
}
// 心跳配置:每 10s 发一次 ping;发送失败重试,连续失败 3 次判定中间件不可用
const (
wsHeartbeatInterval = 10 * time.Second
wsHeartbeatRetry = 3
wsHeartbeatTimeout = 5 * time.Second
)
// wsClientOnce 连接并服务单个中间件 URL,返回下次重试前的等待时长(由 per-URL 连接循环调用):
// - 连接/读循环断开 → 3s(重试同一 URL)
// - 注册被拒(register_error)→ 30s(多为 key 配置错误,加大间隔避免空转刷日志)
// - 心跳连续失败 wsHeartbeatRetry 次 → 主动断开,3s
// - 返回 0 = 配置缺失(不再重试)
func wsClientOnce(url string) time.Duration {
ctx := context.Background()
c, _, err := websocket.Dial(ctx, url, nil)
if err != nil {
slog.Warn("ws client dial failed", "url", url, "error", err)
return 3 * time.Second
}
defer c.CloseNow()
reg := map[string]string{"nodeId": WS_NODE_ID}
if WS_NODE_KEY != "" {
reg["key"] = WS_NODE_KEY
}
wsSend(c, wsMsg{Type: "register", Data: wsRaw(reg)})
_, data, err := c.Read(ctx)
if err != nil {
slog.Warn("ws client register read failed", "url", url, "error", err)
return 3 * time.Second
}
var m wsMsg
if json.Unmarshal(data, &m) != nil {
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 30 * time.Second
}
if m.Type != "register_ok" {
slog.Warn("ws client unexpected message", "type", m.Type)
return 3 * time.Second
}
slog.Info("ws client registered", "nodeId", WS_NODE_ID, "url", url)
// 心跳 goroutine:每 10s 发 ping,失败重试,连续 3 次失败主动断开(per-URL 循环重连同一中间件)
stopHeartbeat := make(chan struct{})
go func() {
fail := 0
for {
select {
case <-stopHeartbeat:
return
case <-time.After(wsHeartbeatInterval):
}
if err := wsSendErr(c, wsMsg{Type: "ping"}); err != nil {
fail++
slog.Warn("ws heartbeat send failed", "url", url, "fail", fail, "error", err)
if fail >= wsHeartbeatRetry {
slog.Warn("ws heartbeat failed too many times, closing connection (will retry same url)", "url", url)
c.Close(websocket.StatusPolicyViolation, "heartbeat timeout")
return
}
} else {
fail = 0
}
}
}()
defer close(stopHeartbeat)
for {
_, data, err := c.Read(ctx)
if err != nil {
slog.Warn("ws client disconnected", "url", url, "error", err)
return 3 * time.Second
}
var msg wsMsg
if json.Unmarshal(data, &msg) != nil {
continue
}
switch msg.Type {
case "probe":
go wsHandleProbe(c, msg.Data)
case "ping":
wsSend(c, wsMsg{Type: "pong"})
case "status":
slog.Debug("ws client status", "data", string(msg.Data))
}
}
}
// wsClientLoop 同时连接所有配置的中间件 URL(逗号分隔,多活):
// 每个 URL 由独立 goroutine 负责,各自注册并保持连接,任一断开只重连自己,不影响其他中间件。
// 注意:多个 URL 指向同一中间件实例时,中间件按 nodeId 单连接,后注册的连接会顶掉先前的。
func wsClientLoop() {
urls := splitWSURLs(WS_URL)
if len(urls) == 0 {
slog.Info("ws client disabled (WS_URL not set)")
return
}
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
wsClientOnceLoop(u)
}(url)
}
wg.Wait()
}
// wsClientOnceLoop 单个中间件 URL 的持续连接循环:连接 → 注册 → 心跳/读循环 → 断开重试
func wsClientOnceLoop(url string) {
for {
retryDelay := wsClientOnce(url)
if retryDelay == 0 {
slog.Info("ws client stopped", "url", url)
return
}
time.Sleep(retryDelay)
}
}
// splitWSURLs 逗号分隔解析多个中间件 URL
func splitWSURLs(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}