Compare commits

...
24 Commits
Author SHA1 Message Date
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
54 changed files with 3391 additions and 1081 deletions

No files matched your search

-1
View File
@@ -83,7 +83,6 @@ jobs:
- name: Prepare config files for release
run: |
# 后端:发布真实 setting.json(重命名为 setting.json.example);中间件:只发布 setting.json.example
cp setting.json setting.json.example
cp middleware-go/setting.json.example middleware.setting.json.example
- name: Release
uses: softprops/action-gh-release@v3
+3 -2
View File
@@ -15,9 +15,10 @@ 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
+35 -260
View File
@@ -1,278 +1,53 @@
# Lemon IPW
ipw.cn 替代品,提供 IP 查询、网站检测、SSL 检查、DNS 解析、TCPing 测速、Whois、DNSSEC、ASN 查询、网站截图等功能。
文档编写中....
ipw.cn 替代品,提供 IP 查询、网站检测、SSL 检查、DNS 解析、TCPing 测速、Whois、DNSSEC、ASN 查询、网站截图等功能。
## 功能
**[预览站点](https://ipw.wsmdn.top/)** · **[文档](https://github.com/nomdn/ipw-cn/tree/main/docs)**
- **IP 地址查询** — 支持 IPv4/IPv6,集成 ip2region、qqwry、MaxMind GeoIP、GeoCN、DbIP、Bilibili 等多数据源
- **ASN 自治系统查询** — 查询 IP 所属自治系统号,支持 MaxMind GeoLite2、DbIP 双数据源,集成 WHOIS 解析
- **网站检测** — HTTP 状态码、响应时间、Host 记录
- **SSL 证书检查** — 证书有效期、颁发机构、剩余天数
- **DNS 解析** — 多节点并发查询,支持 A/AAAA/CNAME/MX/TXT/NS/SRV/PTR/CAA 等记录类型
- **DNSSEC 验证** — 检测域名 DNSSEC 签名状态,验证 DNSKEY、RRSIG、DS 记录链式信任关系
- **Whois 查询** — 域名注册信息、注册商、到期时间等
- **TCPing** — TCP 连接延迟测试,支持 IPv4/IPv6 双栈
- **网站测速** — 下载速度、响应头、Host 记录
- **网站截图** — 在线网站页面截图
- **SSRF 防护** — 自动拦截对内网/私有 IP 的出站请求
- **暗色模式** — 支持明暗主题切换
## 快速开始
```bash
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o install.sh && sudo bash install.sh
```
完整部署(后端节点 / 独立中间件 / 前端)与配置说明见 [文档](https://github.com/nomdn/ipw-cn/tree/main/docs):
- [快速开始](docs/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
├── main.go # Go 后端入口(Gin,自托管)
├── webtest/ # NODE模块 网络测试工具库
├── ipdb/ # NODE模块 IP 数据库模块
├── ssrf/ # NODE模块 SSRF 防护
├── middleware-go/ # 独立组件 转发中间件(Fiber v3,多节点候选)
├── edgeone/ # 独立组件 腾讯 EdgeOne 边缘函数后端
├── 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)
- `dnssec-server`:**DNSSEC 专用** DNS 服务器(同样支持主从逗号分隔);留空则沿用 `dns-server`
- `block-private-ips`:SSRF 防护开关
- `ipdb`:IP 数据库开关(首次启动自动下载约 200MB,之后每 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 服务),部署时只需设置一个环境变量,之后改配置**不用重新部署、不用重启**,下次启动自动拉取最新配置。适合多节点统一管理、批量更新节点配置的场景。
**支持的组件**(三个组件均已支持):
| 组件 | 触发方式 | 远端配置格式 |
|------|----------|--------------|
| 后端(主线 `main.go`) | `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` | 后端 `setting.json` 格式 |
| 后端(`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** 一键部署 `edgeone/` 版本(无需本地构建):
[![使用 EdgeOne Makers 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://console.cloud.tencent.com/edgeone/makers/new?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=edgeone)
也可以使用 EdgeOne CLI 手动部署:
```bash
cd edgeone
npx edgeone pages deploy -n ipw-cn -t $EDGEONE_API_TOKEN
```
`edgeone/` 版本的配置来源为 环境变量 + 远端配置(`REMOTE_CONFIG_URL`)。
## 方案二:Vercel
点击下方按钮一键导入仓库(部署目标为 `edgeone/cloud-functions` 目录,即 Go 边缘函数版本):
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=edgeone%2Fcloud-functions)
> [!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 build -o lemonipw main.go
# 运行(与 setting.json 同目录)
./lemonipw
```
首次启动自动下载 IP 数据库(约 200MB),之后每 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` | 主从逗号分隔(`119.28.28.28:53,223.5.5.5:53`) |
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server |
| IP 数据库 | `Y` | `IPDB`,首次启动下载约 200MB |
| 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` | 主从逗号分隔(如 `119.28.28.28:53,223.5.5.5:53`) |
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server |
| IP 数据库 | `Y` | 首次启动下载约 200MB |
| 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)
+10 -54
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` | 后端多平台构建与发布 |
| `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

+8
View File
@@ -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()
+94 -20
View File
@@ -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}
+2 -2
View File
@@ -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
}
+1 -1
View File
@@ -55,7 +55,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 || '')
+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)
+4 -4
View File
@@ -43,7 +43,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('');
@@ -144,10 +144,10 @@ 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.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/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,
+1 -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')
+4 -4
View File
@@ -67,7 +67,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 +182,10 @@ 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.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)
+78 -127
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,112 @@ 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: "香港 VpsQuan",
id: "hongkong2",
url: ""
},
{
label: "北京 京东云 BGP",
id: "jdcloud",
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-wuhan-chinatelecom",
url: ""
},
{
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/",
url: "https://cn-shenzhen.api-ipw.wsmdn.top/"
},
],
IPv4: [
// IP 直连的节点:URL 留空,仅经独立中间件转发
{
label: "中国 广东 广州 腾讯云",
id: "cn-guangzhou",
url :"https://cn-guangzhou.api-ipw.wsmdn.top/",
},
{
label: "新加坡 腾讯云",
id: "sg-1",
url :"https://sg-1.api-ipw.wsmdn.top/",
},
{
label: "中国 香港 新界 西贡区 将军澳 MICC[ZFC]",
id: "hk-shatin",
url :"https://china-hk-kowloon-shatindistrict-newcloud.ipwapi.zfap.wsmdn.top/",
},
{
label: "中国 陕西 西安 北经济技术开发区 未央区凤城 中国电信[ZFC]",
id: "cn-xian",
url :"https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/",
},
{
label: "美国/加利福尼亚州/洛杉矶/蒙特雷帕克/USCD[ZFC]",
id: "us-la",
url :"https://america-california-losangeles.ipwapi.zfap.wsmdn.top/",
}
],
IPv6: [
{
label: "中国 四川 沙渠 电信[ZFC]",
id: "cn2-sichuan",
url:"https://cn2-sichuan.api-ipw.wsmdn.top/",
},
{
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
id: "lntl-cn-hk-kowloon",
url:"https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/",
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 }
+148
View File
@@ -0,0 +1,148 @@
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],
['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)
})
+152
View File
@@ -0,0 +1,152 @@
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],
['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
+1
View File
@@ -3,6 +3,7 @@ module lemon-ipw
go 1.26.2
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/ipipdotnet/ipdb-go v1.3.3
+2
View File
@@ -6,6 +6,8 @@ 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=
+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 "========================================"
+31 -1
View File
@@ -645,7 +645,8 @@ 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"
@@ -727,3 +728,32 @@ func SearchIP(ip string, databases ...string) map[string]interface{} {
return result
}
// parseIP2Region 将 ip2region 的 "国家|区域|省份|城市|ISP" 管道分隔串预分割为结构化 JSON。
// 兼容 5 段(标准)与 4 段(无区域段)格式;区域段(通常为 "0")丢弃。
// 输出字段与 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)}
if len(parts) >= 5 {
// 标准格式:国家|区域|省份|城市|ISP
res["administrative_area"] = get(2)
res["city"] = get(3)
res["isp"] = get(4)
} else {
// 4 段及以下:国家|省份|城市|ISP(无区域段)
res["administrative_area"] = get(1)
res["city"] = get(2)
res["isp"] = get(3)
}
return res
}
+109 -344
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"
@@ -85,10 +83,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 +101,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 +168,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。
@@ -242,6 +220,7 @@ var (
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
@@ -255,6 +234,10 @@ var (
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
@@ -276,7 +259,7 @@ type pingCacheEntry struct {
}
type speedCacheEntry struct {
result *WebsiteSpeedTestResult
result *webtest.WebsiteSpeedTestResult
timestamp time.Time
}
@@ -291,304 +274,27 @@ type asnWhoisCacheEntry struct {
}
type WebsiteCheckResult struct {
IPv4 *WebsiteCheckDetail `json:"ipv4"`
IPv6 *WebsiteCheckDetail `json:"ipv6"`
IPv4 *webtest.WebsiteCheckDetail `json:"ipv4"`
IPv6 *webtest.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"`
}
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")
@@ -623,28 +329,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 +360,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 +372,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 +414,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 +422,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 +443,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 +463,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 +507,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 +542,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 +555,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,
@@ -1274,7 +980,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 +991,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 +1050,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 +1076,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,13 +1092,37 @@ 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()
slog.Info("SSRF protection initialized", "blockPrivateIPs", ssrf.Enabled())
}
@@ -1384,13 +1140,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()
+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
}
+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": ""
}
+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 {
+44
View File
@@ -0,0 +1,44 @@
package webtest
import (
"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
}
+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
}
+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
}
+490
View File
@@ -0,0 +1,490 @@
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")
asnResult := map[string]any{"ip": raw}
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
}