Compare commits

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

- 快速开始命令补全 git clone 步骤(install.sh 需先从仓库获取)
2026-08-22 15:32:29 +08:00
nomdn 63c42627f6 Fix installation command URL in README
Updated installation command in README to use raw.githubusercontent.com.
2026-08-22 15:00:54 +08:00
77 changed files with 740 additions and 587 deletions

No files matched your search

+20 -16
View File
@@ -25,31 +25,35 @@ jobs:
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_ENV
- name: Build for Linux
working-directory: src
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags "$LDFLAGS" -o lemonipw-linux-386
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o lemonipw-linux-amd64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o lemonipw-linux-arm64
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$LDFLAGS" -o lemonipw-linux-armv7
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$LDFLAGS" -o lemonipw-linux-armv6
CGO_ENABLED=0 GOOS=linux GOARCH=loong64 go build -ldflags "$LDFLAGS" -o lemonipw-linux-loong64
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-386
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-amd64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-arm64
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-armv7
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-armv6
CGO_ENABLED=0 GOOS=linux GOARCH=loong64 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-loong64
- name: Build for Windows
working-directory: src
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=windows GOARCH=386 go build -ldflags "$LDFLAGS" -o lemonipw-windows-386.exe
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o lemonipw-windows-amd64.exe
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o lemonipw-windows-arm64.exe
CGO_ENABLED=0 GOOS=windows GOARCH=386 go build -ldflags "$LDFLAGS" -o ../lemonipw-windows-386.exe
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-windows-amd64.exe
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-windows-arm64.exe
- name: Build for macOS
working-directory: src
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o lemonipw-darwin-amd64
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o lemonipw-darwin-arm64
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-darwin-amd64
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-darwin-arm64
- name: Build for FreeBSD
working-directory: src
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags "$LDFLAGS" -o lemonipw-freebsd-386
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o lemonipw-freebsd-amd64
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o lemonipw-freebsd-arm64
CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags "$LDFLAGS" -o ../lemonipw-freebsd-386
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-freebsd-amd64
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-freebsd-arm64
- name: Build middleware-go for Linux
working-directory: middleware-go
run: |
@@ -82,8 +86,8 @@ jobs:
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-freebsd-arm64
- name: Prepare config files for release
run: |
# 后端:发布真实 setting.json(重命名为 setting.json.example);中间件:只发布 setting.json.example
cp setting.json setting.json.example
# 后端:发布 src/ 下的 setting.json.example;中间件:只发布 setting.json.example
cp src/setting.json.example setting.json.example
cp middleware-go/setting.json.example middleware.setting.json.example
- name: Release
uses: softprops/action-gh-release@v3
+3 -3
View File
@@ -5,7 +5,7 @@ on:
branches:
- main
paths:
- 'edgeone/**'
- 'serverless/edgeone/**'
workflow_dispatch:
jobs:
@@ -24,10 +24,10 @@ jobs:
- name: Install dependencies
run: npm install -g edgeone
- name: Deploy to EdgeOne Pages
run: cd edgeone && npx edgeone pages deploy -n ipw-cn -t ${{ secrets.EDGEONE_API_TOKEN }}
run: cd serverless/edgeone && npx edgeone pages deploy -n ipw-cn -t ${{ secrets.EDGEONE_API_TOKEN }}
env:
EDGEONE_API_TOKEN: ${{ secrets.EDGEONE_API_TOKEN }}
- name: Deploy to EdgeOne Pages
run: cd edgeone && npx edgeone pages deploy -n ipw-cn2 -t ${{ secrets.EDGEONE_API_TOKEN }}
run: cd serverless/edgeone && npx edgeone pages deploy -n ipw-cn2 -t ${{ secrets.EDGEONE_API_TOKEN }}
env:
EDGEONE_API_TOKEN: ${{ secrets.EDGEONE_API_TOKEN }}
+2 -2
View File
@@ -5,7 +5,7 @@ on:
branches:
- main
paths:
- 'edgeone-getip/**'
- 'serverless/edgeone-getip/**'
workflow_dispatch:
jobs:
@@ -24,6 +24,6 @@ jobs:
- name: Install dependencies
run: npm install -g edgeone
- name: Deploy to EdgeOne Pages
run: cd edgeone-getip && npx edgeone pages deploy -n edgeone-getip -t ${{ secrets.EDGEONE_API_TOKEN }}
run: cd serverless/edgeone-getip && npx edgeone pages deploy -n edgeone-getip -t ${{ secrets.EDGEONE_API_TOKEN }}
env:
EDGEONE_API_TOKEN: ${{ secrets.EDGEONE_API_TOKEN }}
+2 -2
View File
@@ -4,7 +4,7 @@ on:
push:
branches: ['main']
paths:
- 'lemon-getip/**'
- 'serverless/lemon-getip/**'
workflow_dispatch:
jobs:
deploy:
@@ -25,6 +25,6 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
cd lemon-getip
cd serverless/lemon-getip
npm install
npm run deploy
+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
+9 -7
View File
@@ -7,7 +7,7 @@ ipw.cn 替代品,提供 IP 查询、网站检测、SSL 检查、DNS 解析、T
## 快速开始
```bash
curl -o- https://cdn.jsdelivr.net/gh/nomdn/ipw-cn@main/install.sh | 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):
@@ -22,16 +22,18 @@ curl -o- https://cdn.jsdelivr.net/gh/nomdn/ipw-cn@main/install.sh | bash
```
ipw-cn/
├── main.go # Go 后端入口(Gin,自托管)
├── middleware-go/ # 独立转发中间件(Fiber v3,多节点候选)
├── src/ # Go 后端
├── middleware-go/ # 独立组件 转发中间件(Fiber v3,多节点候选)
├── serverless/ # 独立组件 边缘/Serverless(edgeone、edgeone-getip、lemon-getip)
├── frontend-ssr/ # Nuxt 4 SSR 前端
├── edgeone/ # 腾讯 EdgeOne 边缘函数后端
├── webtest/ # 网络测试工具库
├── ipdb/ # IP 数据库模块
├── ssrf/ # SSRF 防护
├── 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分支
## 技术栈
+62 -47
View File
@@ -13,7 +13,7 @@
"block-private-ips": true,
"ipdb": true,
"cors": "",
"access_token": ""
"access-token": ""
}
```
@@ -23,9 +23,12 @@
- `block-private-ips`:SSRF 防护开关
- `ipdb`:IP 数据库开关(首次启动自动下载约 200MB,之后每 24h 更新)
- `cors`:允许的请求来源(逗号分隔)
- `access_token`:API 访问令牌,留空则不启用鉴权
- `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`)。需要从远端拉取配置时,设置 `remote-config-url` 或环境变量 `REMOTE_CONFIG_URL`(优先级:远端 > 环境变量 > setting.json)。
以上字段均可用环境变量覆盖(`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)。
---
@@ -44,11 +47,8 @@
| `ICP` / `GongAn` | string | 网站备案号(页脚展示) |
| `noindex` | boolean | 全站是否禁止搜索引擎索引 |
| `v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` | string | IPv4 / IPv6 / 双栈 IP 查询接口 |
| `apiBaseUrls` | 节点数组 | 基础 API 节点列表(detail / ssl / whois 等) |
| `IPLocationAPIs` | 节点数组 | IP 归属地 / ASN 查询节点列表 |
| `TCPing` | 节点分组 | TCPing 节点,按 `DualStack` / `IPv4` / `IPv6` 分组 |
| `SpeedTest` | 节点分组 | 测速节点,按 `DualStack` / `IPv4` / `IPv6` 分组 |
| `NSLookup` | 节点数组 | DNS 解析节点列表 |
| `APIBaseURL` | 节点池 | 拨测上游节点池(whois / ssl / detail / dns / dnssec / tcping / speed),含 `DualStack` / `IPv4` / `IPv6` 三栈 |
| `IPLocationAPI` | 节点数组 | IP 归属地 / ASN 查询节点池(纯数组,无栈区分) |
完整模板(复制到 `frontend-ssr/config/index.ts`,替换占位值即可):
@@ -75,49 +75,38 @@ const config = {
v4OnlyAPI: "https://4.wsmdn.top",
v6OnlyAPI: "https://6.wsmdn.top",
DualStackAPI: "https://test.wsmdn.top",
// 基础 API 节点(detail / ssl / whois 等)
apiBaseUrls: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
],
// IP 归属地 / ASN 查询节点
IPLocationAPIs: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
],
// TCPing 节点:IPv4-only 节点只能放 IPv4 分组,不能放 DualStack / IPv6
TCPing: {
// 拨测节点池(whois / ssl / detail / dns / dnssec / tcping / speed)
// IPv4-only 节点只能放 IPv4 栈,IPv6-only 放 IPv6 栈,双栈放 DualStack
APIBaseURL: {
DualStack: [],
IPv4: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
],
IPv6: []
},
// 测速节点:同上,IPv4-only 节点只能放 IPv4 分组
SpeedTest: {
DualStack: [],
IPv4: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
],
IPv6: []
},
// DNS 解析节点
NSLookup: [
// IP 归属地 / ASN 查询节点池(纯数组,无栈区分)
IPLocationAPI: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
]
}
export { config }
```
> 节点 `id` 需与后端节点 / 中间件 `apiKeys` 的键保持一致,中间件按 `id` 注入 `Authorization: Bearer <key>`。
> 节点 `id` 需与后端节点 / 中间件 `api-keys` 的键保持一致,中间件按 `id` 注入 `Authorization: Bearer <key>`;WS 通道的节点注册校验另见中间件 `ws-keys`(与后端 `node-key` 对应)。
节点数组项均为 `{ label, id, url }` 结构,例如:
```ts
apiBaseUrls: [
{ label: "中国 江苏 移动", id: "cn-jiangsu", url: "https://cn-jiangsu.api-ipw.wsmdn.top/" }
]
APIBaseURL: {
DualStack: [
{ label: "中国 江苏 移动", id: "cn-jiangsu", url: "https://cn-jiangsu.api-ipw.wsmdn.top/" }
],
IPv4: [],
IPv6: []
}
```
`label` 为节点展示名,`id` 为节点唯一标识(中间件 `apiKeys` 也按此 id 注入 key),`url` 为节点 base URL。`TCPing` / `SpeedTest` 节点再多一层 `DualStack` / `IPv4` / `IPv6` 分组,分别对应双栈 / 单栈测试时使用的节点列表。
`label` 为节点展示名,`id` 为节点唯一标识(中间件 `api-keys` 按此 id 注入 HTTP 转发 key,WS 注册校验另见 `ws-keys`),`url` 为节点 base URL。节点池结构如下:`APIBaseURL` 为拨测节点池,含 `DualStack` / `IPv4` / `IPv6` 三栈(分别对应双栈 / 单栈测试时使用的节点列表,IPv4-only 节点只能放 `IPv4` 栈,IPv6-only 只能放 `IPv6` 栈,不能放 `DualStack`);`IPLocationAPI` 为 IP 归属地 / ASN 查询节点池,为纯数组(无栈区分)。
### 出站 IP 检测接口(v4OnlyAPI / v6OnlyAPI / DualStackAPI)
@@ -164,18 +153,39 @@ apiBaseUrls: [
"rate-limit": 120,
"ws-port": 8092,
"remote-config-url": "",
"remote-ingore-config": [],
"apiBaseUrls": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"apiKeys": { "cn-jiangsu": "" }
"remote-ignore-config": [],
"api-base-url": {
"DualStack": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"IPv4": [],
"IPv6": []
},
"ip-location-api": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"api-keys": { "cn-jiangsu": "" },
"ws-keys": { "cn-jiangsu": "" }
}
```
- `rate-limit`:单 IP 每分钟限流次数,默认 `120`,`0` 表示不限流(也可用环境变量 `RATE_LIMIT` 覆盖)
- `ws-port`:WS 服务端口,默认 `8092`,`0` 表示关闭 WS 通道(也可用环境变量 `WS_PORT` 覆盖;优先级:远端配置 > 环境变量 > setting.json)
- `remote-config-url`:远端配置地址,也可用环境变量 `REMOTE_CONFIG_URL`(环境变量优先)
- `remote-ingore-config`:**不被远端配置覆盖的配置项列表**(数组),如 `["port", "rate-limit"]`;也可用环境变量 `REMOTE_INGORE_CONFIG`(JSON 数组字符串,优先于 setting.json)
- `apiKeys`:用于向后端注入 `Authorization: Bearer <key>`,优先级:setting.json `apiKeys` > 环境变量 `APIKEYS`(JSON 字符串)。**敏感凭据,强制忽略,不随远端配置覆盖**
- 节点数组项(`apiBaseUrls` / `IPLocationAPIs` / `TCPing` / `SpeedTest` / `NSLookup`)与前端 `config/index.ts` 结构一致;节点加 `"ws": true` 可让该节点走 WS 通道通信,详见 [中间件部署 - WS 通道](/guide/deploy-middleware#ws-通道拨测数据经-websocket-传输)
- `remote-ignore-config`:**不被远端配置覆盖的配置项列表**(数组),如 `["port", "rate-limit"]`;也可用环境变量 `REMOTE_IGNORE_CONFIG`(JSON 数组字符串,优先于 setting.json)
- `api-keys`:用于向后端注入 `Authorization: Bearer <key>`,优先级:setting.json `api-keys` > 环境变量 `API_KEYS`(JSON 字符串)。**敏感凭据,强制忽略,不随远端配置覆盖**
- `ws-keys`:WS 注册校验表(`节点id → key`):节点经 WS 注册时,`register` 消息的 `key` 必须与 `ws-keys[节点id]` 一致,否则返回 401 并断开;优先级:setting.json `ws-keys` > 环境变量 `WS_KEYS`(JSON 字符串)。**敏感凭据,强制忽略,不随远端配置覆盖**。与 `api-keys` **相互独立**——`api-keys` 管 HTTP 转发鉴权,`ws-keys` 管 WS 注册校验,可分别配置不同 key(例如 WS 节点单独换 key 不影响 HTTP 转发)
- 节点池与前端 `config/index.ts` 结构一致:`APIBaseURL` 为拨测节点池(含 `DualStack` / `IPv4` / `IPv6` 三栈),`IPLocationAPI` 为 IP 归属地 / ASN 节点池(纯数组,无栈);节点加 `"ws": true` 可让该节点走 WS 通道通信,详见 [中间件部署 - 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-通道接入可选)。
---
@@ -187,8 +197,8 @@ apiBaseUrls: [
| 组件 | 触发方式 | 远端配置格式 |
|------|----------|--------------|
| 后端(主线 `main.go`) | `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` | 后端 `setting.json` 格式 |
| 后端(`edgeone/`) | `REMOTE_CONFIG_URL` | 后端 `setting.json` 格式 |
| 后端(主线 `src/main.go`) | `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` | 后端 `setting.json` 格式 |
| 后端(`serverless/edgeone/`) | `REMOTE_CONFIG_URL` | 后端 `setting.json` 格式 |
| 独立中间件(middleware-go) | `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` | 中间件 `setting.json` 格式 |
**合并规则**(重要,逐字段生效):
@@ -197,9 +207,9 @@ apiBaseUrls: [
- 远端 JSON 中**非空的字段才覆盖**本地值:字符串非空、数组非空、数字非 0(`rate-limit` 为指针语义,缺省=不覆盖,显式 0=不限流)
- 部分字段缺省不会冲掉本地配置——远端只需包含要覆盖的字段
- **敏感凭据强制忽略,永远不被远端覆盖**(代码写死,无需配置):
- 后端 `access_token`:不随远端覆盖(保持 环境变量 > setting.json),远端里的 `access_token` 会被忽略
- 中间件 `apiKeys`:不随远端覆盖,远端里的 `apiKeys` 会被忽略
- `remote-ingore-config`(后端键名 `remote-ingore-config` / env `REMOTE_INGORE_CONFIG`):额外指定**不被远端覆盖的配置项列表**,数组中的键即使远端下发也不生效。适用于 access_token / apiKeys 之外的敏感项(如 `node-key`、`dns-server` 等),也可覆盖"非空才覆盖"规则
- 后端 `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` 等),也可覆盖"非空才覆盖"规则
**拉取行为**:
@@ -234,11 +244,16 @@ REMOTE_CONFIG_URL=https://example.com/ipw-config.json ./lemonipw
"port": "8091",
"cors": "",
"rate-limit": 120,
"apiBaseUrls": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ]
"APIBaseURL": {
"DualStack": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"IPv4": [],
"IPv6": []
}
}
```
> **⚠️ 敏感凭据不得上传远端**:`apiKeys`(及后端 `access_token`、节点 key 等密钥)**禁止**写入远端配置——即使写了也会被强制忽略。凭据只放本地 setting.json 或环境变量。
> [!WARNING]
> 敏感凭据不得上传远端:`api-keys`/`ws-keys`(及后端 `access-token`、节点 key 等密钥)**禁止**写入远端配置——即使写了也会被强制忽略。凭据只放本地 setting.json 或环境变量。
部署时:
@@ -256,7 +271,7 @@ systemd 中同样用 `Environment="REMOTE_CONFIG_URL=..."` 配置。
**安全与排查**:
- **⚠️ 敏感凭据不得上传远端**:远端配置(Gist / 对象存储 / 静态托管等)**禁止包含任何密钥**——`apiKeys`、`access_token`、节点 key(`node-key`)等。这些值会被强制忽略,但远端文件本身一旦泄露等于直接暴露凭据;凭据一律放本地 `setting.json` 或环境变量注入
- **敏感凭据不得上传远端**:远端配置(Gist / 对象存储 / 静态托管等)**禁止包含任何密钥**——`api-keys`/`ws-keys`、`access-token`、节点 key(`node-key`)等。这些值会被强制忽略,但远端文件本身一旦泄露等于直接暴露凭据;凭据一律放本地 `setting.json` 或环境变量注入
- 远端 URL 必须能被节点访问;优先使用 **HTTPS** 地址
- 远端配置文件如果放在公开可访问的位置(公开 Gist、无鉴权对象存储),即便不含密钥,也等于把节点拓扑、节点 id 等敏感信息公之于众,建议放在私有仓库 / 带鉴权的对象存储 / 内部服务上
- 常见失败排查:
+2 -3
View File
@@ -32,9 +32,8 @@ pnpm dev
|--------|------|
| `siteUrl` | 站点对外地址 |
| `siteName` | 站点名称(页面标题 / 描述 / 页脚品牌) |
| `apiBaseUrls` | 后端上游节点列表(whois / ssl / detail) |
| `IPLocationAPIs` | IP 归属地 / ASN 上游节点列表 |
| `TCPing` / `SpeedTest` / `NSLookup` | 对应功能的多节点候选(DualStack / IPv4 / IPv6) |
| `APIBaseURL` | 拨测上游节点池(whois / ssl / detail / dns / dnssec / tcping / speed),含 `DualStack` / `IPv4` / `IPv6` 三栈 |
| `IPLocationAPI` | IP 归属地 / ASN 上游节点池(纯数组,无栈区分) |
| `Middleware` | 外部独立中间件 base URL 列表,`/middleware/*` 请求依次尝试、失败重试下一个 |
| `EnableInternalMiddleware` | 是否启用前端内置中间件(本地转发,作为候选列表最后一位兜底),默认 `true` |
| `rateLimitPerMinute` | 内置中间件单 IP 限流次数(次/分钟),默认 `120`,`0` 表示不限流 |
+15 -14
View File
@@ -13,7 +13,7 @@
> cp middleware.setting.json.example setting.json
> ```
>
> 复制后按需修改 `apiBaseUrls` / `apiKeys` / `cors` 等配置项。
> 复制后按需修改 `api-base-url` / `ip-location-api` / `api-keys` / `ws-keys` / `cors` 等配置项。
## 方案一:Docker
@@ -27,7 +27,7 @@ docker run -d -p 8091:8091 \
middleware-go
```
配置通过挂载 `setting.json` 提供(`apiBaseUrls` / `apiKeys` / `cors` / `rate-limit` / `remote-config-url` 等;`rate-limit` 为单 IP 每分钟限流次数,默认 120,0 表示不限流,可用环境变量 `RATE_LIMIT` 覆盖;`remote-config-url` 为远端配置地址,可用环境变量 `REMOTE_CONFIG_URL` 覆盖;**`apiKeys` 为敏感凭据,不随远端配置覆盖**)。
配置通过挂载 `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 注册校验,两者相互独立)。
## 方案二:二进制
@@ -37,7 +37,7 @@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o middleware-go-linux-amd64 .
./middleware-go-linux-amd64 # 运行(与 setting.json 同目录)
```
所有配置可用环境变量覆盖(`API_BASE_URLS` / `IP_LOCATION_APIS` / `CORS` / `APIKEYS` / `RATE_LIMIT` 等,数组/对象用 JSON 字符串),优先级:**环境变量 > setting.json > 默认值**(远端配置 `REMOTE_CONFIG_URL` 高于两者,详见 [配置文件 - 远端配置](/guide/config#远端配置remoteconfigurl))。
所有配置可用环境变量覆盖(`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` 所在目录)。
@@ -61,10 +61,11 @@ middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入
```json
{
"port": "8091",
"apiBaseUrls": [
{ "label": "上海 腾讯云 BGP", "id": "tencent-sh", "url": "", "ws": true }
],
"TCPing": { "DualStack": [], "IPv4": [], "IPv6": [ { "label": "河北 秦皇岛 联通", "id": "cn-hebei-qinhuangdao", "url": "https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/", "ws": true } ] }
"APIBaseURL": {
"DualStack": [ { "label": "上海 腾讯云 BGP", "id": "tencent-sh", "url": "", "ws": true } ],
"IPv4": [],
"IPv6": [ { "label": "河北 秦皇岛 联通", "id": "cn-hebei-qinhuangdao", "url": "https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/", "ws": true } ]
}
}
```
@@ -76,7 +77,7 @@ middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入
| 方向 | type | data 说明 |
|------|------|-----------|
| 节点 → middleware | `register` | `{ "nodeId": "...", "key": "..." }`,连接后首条消息,同 id 新连接顶掉旧连接;`key` 为注册凭证,与 setting.json `apiKeys` 配置比对 |
| 节点 → 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) |
@@ -87,22 +88,22 @@ middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入
### 节点侧接入(WS 客户端)
1. 连接 `ws://<中间件IP>:8092/ws`(本仓库后端节点接入方式见 [后端节点部署 - WS 通道接入](/guide/deploy-node#ws-通道接入可选),配置 `WS_URL` / `NODE_ID` / `NODE_KEY` 即可自动连接注册)
2. 首条消息发 `register { "nodeId": "<与中间件配置一致的节点 id>", "key": "<注册凭证>" }`(节点在 setting.json `apiKeys` 里配了 key 就必须传对;未配置 key 的开放节点可不传)
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 校验
节点注册时中间件按 `apiKeys` 配置校验 key:
节点注册时中间件按 `ws-keys` 配置校验 key(与 `api-keys` HTTP 转发鉴权相互独立):
- 节点在 `apiKeys` 配置了 key(非空)→ `register` 必须携带正确 key,不传或传错返回 `register_error { "code": 401, "command": "invalid key" }` 并**断开连接**,不进入数据阶段
- 节点未配置 key(`apiKeys` 无该项或为空)→ 开放注册,不传 key 即可
- 节点在 `ws-keys` 配置了 key(非空)→ `register` 必须携带正确 key,不传或传错返回 `register_error { "code": 401, "command": "invalid key" }` 并**断开连接**,不进入数据阶段
- 节点未配置 key(`ws-keys` 无该项或为空)→ 开放注册,不传 key 即可
与 HTTP 通道的鉴权语义一致(配置了 key 就必须带凭据,留空 = 开放)。
### 说明
- 注册阶段按节点 `apiKeys` 配置校验 key(配了 key 必须传对,否则 401 断开;未配置则开放,详见上文「注册 key 校验」);拨测请求经 WS 转发时不携带 `Authorization` 头(后续由节点侧自行决定是否需要额外鉴权)
- 注册阶段按节点 `ws-keys` 配置校验 key(配了 key 必须传对,否则 401 断开;未配置则开放,详见上文「注册 key 校验」);拨测请求经 WS 转发时不携带 `Authorization` 头(后续由节点侧自行决定是否需要额外鉴权)
- `ws:true` 且 WS 未启用(`WS_PORT=0`)时,自动回退原 HTTP 转发
+23 -18
View File
@@ -4,31 +4,31 @@
## 方案一:EdgeOne
腾讯云 EdgeOne 支持 Go 边缘函数,点击下方按钮通过 **EdgeOne Makers** 一键部署 `edgeone/` 版本(无需本地构建):
腾讯云 EdgeOne 支持 Go 边缘函数,点击下方按钮通过 **EdgeOne Makers** 一键部署 `serverless/edgeone/` 版本(无需本地构建):
[![使用 EdgeOne Makers 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://console.cloud.tencent.com/edgeone/makers/new?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=edgeone)
[![使用 EdgeOne Makers 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://console.cloud.tencent.com/edgeone/makers/new?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=serverless%2Fedgeone)
也可以使用 EdgeOne CLI 手动部署:
```bash
cd edgeone
cd serverless/edgeone
npx edgeone pages deploy -n ipw-cn -t $EDGEONE_API_TOKEN
```
`edgeone/` 版本的配置来源为 环境变量 + 远端配置(`REMOTE_CONFIG_URL`)。
`serverless/edgeone/` 版本的配置来源为 环境变量 + 远端配置(`REMOTE_CONFIG_URL`)。
## 方案二:Vercel
点击下方按钮一键导入仓库(部署目标为 `edgeone/cloud-functions` 目录,即 Go 边缘函数版本):
点击下方按钮一键导入仓库(部署目标为 `serverless/edgeone/cloud-functions` 目录,即 Go 边缘函数版本):
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=edgeone%2Fcloud-functions)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=serverless%2Fedgeone%2Fcloud-functions)
> [!IMPORTANT]
> **Serverless 节点均为 IPv4-only**(EdgeOne、Vercel 等平台的出站网络只提供 IPv4)。部署后需要:
>
> 1. 给节点配置 `SINGLE_STACK=ipv4`(声明节点为 IPv4 单栈),跳过 IPv6 相关测试,避免无意义的错误日志与超时
> 2. 在前端配置 `config/index.ts` 中,**该节点只能填入 `TCPing.IPv4` / `SpeedTest.IPv4` 等 IPv4 分组**,不要放进 `DualStack` / `IPv6` 分组
> 2. 在前端配置 `config/index.ts` 中,**该节点只能填入 `APIBaseURL.IPv4`**(拨测节点池的 IPv4 栈),不要放进 `DualStack` / `IPv6` 栈
## 方案三:Docker
@@ -46,8 +46,8 @@ docker run -d -p 8080:8080 \
## 方案四:二进制
```bash
# 编译
go build -o lemonipw main.go
# 编译(Go 后端在 src/ 目录)
cd src && go build -o lemonipw .
# 运行(与 setting.json 同目录)
./lemonipw
@@ -60,6 +60,10 @@ go build -o lemonipw main.go
仓库根目录提供 `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
```
@@ -69,7 +73,7 @@ sudo bash install.sh
|--------|--------|------|
| 安装目录 | `/opt/lemon-ipw` | 二进制与工作目录 |
| 监听端口 | `8080` | `PORTS` |
| access_token | 留空 | 留空 = 不启用鉴权 |
| 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 |
@@ -80,9 +84,9 @@ sudo bash install.sh
**WS 接入特殊规则**:
- `WS_URL` 留空时自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址时须为**完整路径**(含 `wss://` 前缀与 `/ws` 路径,如 `wss://host:8092/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(中间件 `apiKeys` 必须包含该节点,否则注册被拒 401);安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 setting.json 的 `apiKeys`
- `NODE_KEY` **必填**:不加 key 禁止启用 WS(中间件 `ws-keys` 必须包含该节点,否则注册被拒 401);安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 setting.json 的 `ws-keys`
**安装完成后自动生成并启动 systemd 服务**(`lemon-ipw.service`),配置以 `Environment="K=V"` 注入,无需 setting.json。常用管理命令:
@@ -94,6 +98,7 @@ 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_` 前缀。
## 配置
@@ -104,18 +109,18 @@ sudo systemctl restart lemon-ipw # 重启(改配置后)
后端节点可作为 WS 客户端接入 [独立中间件](/guide/deploy-middleware) 的 WS 通道,拨测请求经 WebSocket 转发,节点本地执行探针后回传。不配置则完全走原 HTTP 接口。
**配置三个环境变量**(或 setting.json 的 `ws-url` / `node-id` / `node-key`,env 优先;也支持远端配置覆盖,除非列入 `remote-ingore-config`):
**配置三个环境变量**(或 setting.json 的 `ws-url` / `node-id` / `node-key`,env 优先;也支持远端配置覆盖,除非列入 `remote-ignore-config`):
```bash
WS_URL=ws://<中间件IP>:8092/ws \
NODE_ID=<节点id,与中间件 apiBaseUrls 的节点 id 一致> \
NODE_KEY=<注册key,与中间件 apiKeys[节点id] 一致;节点未配置 key 可留空> \
NODE_ID=<节点id,与中间件 api-base-url / ip-location-api 池的节点 id 一致> \
NODE_KEY=<注册key,与中间件 ws-keys[节点id] 一致;节点未配置 key 可留空> \
./lemonipw
```
- `WS_URL` 支持**逗号分隔多个中间件**(`ws://主:8092/ws,ws://备:8092/ws`),第一个为主,主故障自动切换下一个
- `WS_URL` 支持**逗号分隔多个中间件**(`ws://中间件1:8092/ws,ws://中间件2:8092/ws`),**同时连接全部(多活)**:每个地址独立注册并保持连接,任一断开只重连自己,不影响其他中间件;注意多个地址指向同一中间件实例时,同 `nodeId` 后注册的连接会顶掉先前的
- **双向心跳**:节点每 10s 发 ping,中间件回 pong;心跳发送连续失败 3 次判定中间件不可用,断开后 3s 重试(注册被拒 30s 重试)
- 收到 `probe` 后节点直调探针函数(带缓存)并回 `probe_result`,结果与 HTTP 通道一致
- 节点注册带 `NODE_KEY`:中间件 `apiKeys` 里配了 key 就必须传对,否则返回 401 并断开;未配置 key 的节点开放注册
- 节点注册带 `NODE_KEY`:中间件 `ws-keys` 里配了 key 就必须传对,否则返回 401 并断开;未配置 key 的节点开放注册
**注意**:`NODE_ID` 必须与中间件 `apiBaseUrls` / `TCPing` / `SpeedTest` 等配置中的节点 `id` 一致,且该节点需配置 `"ws": true` 才会走 WS 通道。未连接中间件时,`ws:true` 节点的拨测会返回 502。
**注意**:`NODE_ID` 必须与中间件 `APIBaseURL` / `IPLocationAPI` 池中的节点 `id` 一致,且该节点需配置 `"ws": true` 才会走 WS 通道。未连接中间件时,`ws:true` 节点的拨测会返回 502。
+7 -4
View File
@@ -20,12 +20,15 @@
```bash
# 获取安装脚本(任选其一)
git clone https://github.com/nomdn/ipw-cn && cd ipw-cn # 或
curl -LO https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh
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 守护进程。
**交互式配置项**(直接回车使用默认值):
@@ -34,7 +37,7 @@ sudo bash install.sh
|--------|--------|------|
| 安装目录 | `/opt/lemon-ipw` | 二进制与工作目录 |
| 监听端口 | `8080` | 后端 HTTP 端口 |
| access_token | 留空 | 留空 = 不启用鉴权 |
| 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 |
@@ -45,9 +48,9 @@ sudo bash install.sh
**WS 通道接入规则**(选 `y` 时):
- `WS_URL` 留空自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址须为完整路径(`wss://host:8092/ws` 形式)
- `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>"` 加入中间件 `apiKeys`
- `NODE_KEY` **必填**:不加 key 禁止启用 WS;安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 `ws-keys`
### 验证安装
+1 -1
View File
@@ -23,5 +23,5 @@ Lemon IPW 采用前后端分离架构,**后端节点、独立中间件、前
| 工作流 | 部署目标 |
|--------|----------|
| `frontend-ssr.yml` | SSR 前端 → Cloudflare Workers |
| `edgeone-backend.yml` | `edgeone/` → EdgeOne Pages |
| `edgeone-backend.yml` | `serverless/edgeone/` → EdgeOne Pages |
| `build_and_release.yml` | 后端多平台构建与发布(Release 附带配置文件) |
+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)
+47 -229
View File
@@ -19,7 +19,7 @@ const config = {
// 未配置或为空数组时,直接使用前端自带中间件。
Middleware: <string[]>[
"https://middleware-1.api-ipw.wsmdn.top/"
// "http://127.0.0.1:8091/",
// "http://127.0.0.1:8092/",
// "https://middleware-2.wsmdn.top/",
],
// Umami 统计
@@ -35,29 +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/"
},
// IP 直连新节点:URL 留空,仅经独立中间件转发
{
label: "上海 腾讯云 BGP",
id: "tencent-sh",
label: "中国 湖北 武汉 电信",
id: "cn-wuhan-chinatelecom",
url: ""
}
],
IPLocationAPIs: [
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
},
{
label: "中国 四川 沙渠 电信[ZFC]",
@@ -65,283 +54,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/"
},
// IP 直连新节点:URL 留空,仅经独立中间件转发
{
label: "上海 腾讯云 BGP",
id: "tencent-sh",
url :"",
},
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 :"",
url: ""
},
{
label: "湖北 十堰 电信",
id: "shiyan",
url :"",
url: ""
},
{
label: "香港 VpsQuan",
id: "hongkong2",
url :"",
url: ""
},
{
label: "北京 京东云 BGP",
id: "jdcloud",
url :"",
url: ""
},
{
label: "陕西 西安二 电信",
id: "xian2",
url :"",
url: ""
},
{
label: "香港 Cogent",
id: "hongkong",
url :"",
url: ""
},
// WS 通道节点:URL 留空,拨测经中间件 WS 转发
{
label: "呼和浩特移动",
id: "2faa15e6-18a7-4355-b422-ca53277d8d77",
url: ""
}
],
IPv6: [
{
label: "中国 四川 沙渠 电信[ZFC]",
id: "cn2-sichuan",
url:"https://cn2-sichuan.api-ipw.wsmdn.top/",
},
{
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
id: "lntl-cn-hk-kowloon",
url:"https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/",
},
{
label: "中国 河北 秦皇岛 联通",
id: "cn-hebei-qinhuangdao",
url :"https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/",
}
]
},
SpeedTest:{
DualStack: [
{
label: "中国 湖北 武汉 电信",
id: "cn-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/"
},
// IP 直连新节点:URL 留空,仅经独立中间件转发
// IP 直连的节点:URL 留空,仅经独立中间件转发
{
label: "上海 腾讯云 BGP",
id: "tencent-sh",
url :"",
},
],
IPv4: [
{
label: "中国 广东 广州 腾讯云",
id: "cn-guangzhou",
url :"https://cn-guangzhou.api-ipw.wsmdn.top/",
},
{
label: "新加坡 腾讯云",
id: "sg-1",
url :"https://sg-1.api-ipw.wsmdn.top/",
},
{
label: "中国 香港 新界 西贡区 将军澳 MICC[ZFC]",
id: "hk-shatin",
url :"https://china-hk-kowloon-shatindistrict-newcloud.ipwapi.zfap.wsmdn.top/",
},
{
label: "中国 陕西 西安 北经济技术开发区 未央区凤城 中国电信[ZFC]",
id: "cn-xian",
url :"https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/",
},
{
label: "美国/加利福尼亚州/洛杉矶/蒙特雷帕克/USCD[ZFC]",
id: "us-la",
url :"https://america-california-losangeles.ipwapi.zfap.wsmdn.top/",
},
// IP 直连新节点:URL 留空,仅经独立中间件转发
{
label: "山东 枣庄 双线",
id: "zaozhuang",
url :"",
},
{
label: "湖北 十堰 电信",
id: "shiyan",
url :"",
},
{
label: "香港 VpsQuan",
id: "hongkong2",
url :"",
},
{
label: "北京 京东云 BGP",
id: "jdcloud",
url :"",
},
{
label: "陕西 西安二 电信",
id: "xian2",
url :"",
},
{
label: "香港 Cogent",
id: "hongkong",
url :"",
}
],
IPv6: [
{
label: "中国 四川 沙渠 电信[ZFC]",
id: "cn2-sichuan",
url:"https://cn2-sichuan.api-ipw.wsmdn.top/",
},
{
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
id: "lntl-cn-hk-kowloon",
url:"https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/",
},
{
label: "中国 河北 秦皇岛 联通",
id: "cn-hebei-qinhuangdao",
url :"https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/",
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/",
},
// IP 直连新节点:URL 留空,仅经独立中间件转发
{
label: "上海 腾讯云 BGP",
id: "tencent-sh",
url :"",
},
{
label: "山东 枣庄 双线",
id: "zaozhuang",
url :"",
},
{
label: "湖北 十堰 电信",
id: "shiyan",
url :"",
},
{
label: "香港 VpsQuan",
id: "hongkong2",
url :"",
},
{
label: "北京 京东云 BGP",
id: "jdcloud",
url :"",
},
{
label: "陕西 西安二 电信",
id: "xian2",
url :"",
},
{
label: "香港 Cogent",
id: "hongkong",
url :"",
},
{
label: "中国 河北 秦皇岛 联通",
id: "cn-hebei-qinhuangdao",
url :"https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/",
}
]
}
}
export { config }
+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)
})
@@ -98,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' })
@@ -151,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' })
+4 -1
View File
@@ -64,9 +64,12 @@ export function useMiddlewareFetch<T = any>(url: MaybeRefOrGetter<string>, optio
try {
for (let i = 0; i < candidates.value.length; i++) {
if (id !== requestId) return undefined // 已被更新的请求取代
const candidate = candidates.value[i]
// noUncheckedIndexedAccess 下索引访问为 string | undefined,防御性跳过
if (candidate === undefined) continue
try {
// $fetch 为 Nuxt 全局注入;候选失败会抛 FetchError(含 status)
const res = await $fetch<T>(candidates.value[i], fetchOptions)
const res = await $fetch<T>(candidate, fetchOptions)
if (id !== requestId) return undefined
data.value = res
return res
+6 -6
View File
@@ -76,7 +76,7 @@ PORTS=${PORTS:-8080}
read -r -p "单栈模式 SINGLE_STACK(留空=双栈,ipv4 或 ipv6): " SINGLE_STACK
read -r -p "访问令牌 access_token(留空=不启用鉴权): " ACCESS_TOKEN
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}
@@ -102,7 +102,7 @@ NODE_KEY=""
WS_USED_DEFAULT=""
case "${WS_CHOICE,,}" in
y|yes)
read -r -p " 中间件 WS 完整地址(含 wss:// 前缀与 /ws 路径,如 wss://host:8092/ws;留空=默认 wss://middleware-1.api-ipw.wsmdn.top/ws;逗号分隔可多备): " WS_URL
read -r -p " 中间件 WS 完整地址(含 wss:// 前缀与 /ws 路径,如 wss://host:8092/ws;留空=贡献节点给柠檬 wss://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"
@@ -110,7 +110,7 @@ case "${WS_CHOICE,,}" in
NODE_ID=$(gen_uuid)
echo " 节点 id(自动生成 UUID): $NODE_ID"
while [ -z "$NODE_KEY" ]; do
read -r -p " 注册 key(必填,中间件 apiKeys 必须包含此节点,否则注册被拒 401): " NODE_KEY
read -r -p " 注册 key(必填,中间件 ws-keys 必须包含此节点,否则注册被拒 401): " NODE_KEY
if [ -z "$NODE_KEY" ]; then
echo " 错误:不加 key 禁止启用 WS,必须提供注册 key"
fi
@@ -142,7 +142,7 @@ echo "安装目录: $INSTALL_DIR"
echo "服务名: lemon-ipw"
echo "监听端口: $PORTS"
echo "单栈模式: ${SINGLE_STACK:-双栈}"
echo "access_token: ${ACCESS_TOKEN:+已设置 (隐藏)}"
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:-未启用}"
@@ -248,7 +248,7 @@ 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 的 apiKeys:"
echo "WS 通道已启用,请把节点注册信息加入中间件 setting.json 的 ws-keys:"
echo " \"$NODE_ID\": \"$NODE_KEY\""
fi
if [ -n "$WS_USED_DEFAULT" ]; then
@@ -260,7 +260,7 @@ if [ -n "$WS_USED_DEFAULT" ]; then
echo " WS 地址: $WS_URL"
echo " 监听端口: $PORTS"
echo " 单栈模式: ${SINGLE_STACK:-双栈}"
echo " access_token: ${ACCESS_TOKEN:+已设置 (隐藏)}"
echo " access-token: ${ACCESS_TOKEN:+已设置 (隐藏)}"
echo " CORS: ${CORS:-不限}"
echo " 地区-运营商 请自行填写"
echo "========================================"
+102 -110
View File
@@ -21,7 +21,7 @@ import (
// ==================== 全局变量 ====================
// apiInfo 对应 setting.json apiBaseUrls 等数组中的 { label, id, url } 配置项
// apiInfo 对应 setting.json 节点池中的 { label, id, url } 配置项
type apiInfo struct {
Label string `json:"label"`
ID string `json:"id"`
@@ -49,7 +49,7 @@ func (w *wsFlag) UnmarshalJSON(b []byte) error {
// UseWS 判断该节点是否走 WS 通道
func (a *apiInfo) UseWS() bool { return a != nil && bool(a.WS) }
// stackConfig 对应 setting.json TCPing / SpeedTest
// stackConfig 对应节点池中的 { DualStack, IPv4, IPv6 } 三栈
type stackConfig struct {
DualStack []apiInfo `json:"DualStack"`
IPv4 []apiInfo `json:"IPv4"`
@@ -57,20 +57,21 @@ type stackConfig struct {
}
// middlewareConfig 仅用于解析 setting.json(JSON 键名统一用连接线,如 http-timeout-seconds)
// 节点池结构:APIBaseURL 含 {IPv6,IPv4,DualStack} 三栈;IPLocationAPI 为纯数组(无栈区分)。
// 原 apiBaseUrls/whois·ssl·detail、NSLookup/dns·dnssec、TCPing、SpeedTest 统一并入 APIBaseURL,
// 原 IPLocationAPI/location·asn 并入 IPLocationAPI。
type middlewareConfig struct {
Port stringOrNumber `json:"port"`
HTTPTimeoutSeconds int `json:"http-timeout-seconds"`
RateLimit *int `json:"rate-limit"` // 单 IP 每分钟限流次数;缺省=默认120,0=不限流
WSPort stringOrNumber `json:"ws-port"` // WS 服务端口:兼容 "8092" 与 8092;缺省=8092,"0"/0=关闭 WS 通道
RemoteConfigURL string `json:"remote-config-url"`
RemoteIngoreConfig []string `json:"remote-ingore-config"` // 不被远端覆盖的配置项列表
Cors string `json:"cors"`
APIBaseURLs []apiInfo `json:"apiBaseUrls"`
IPLocationAPIs []apiInfo `json:"IPLocationAPIs"`
TCPing stackConfig `json:"TCPing"`
SpeedTest stackConfig `json:"SpeedTest"`
NSLookup []apiInfo `json:"NSLookup"`
APIKeys map[string]string `json:"apiKeys"`
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)
@@ -85,16 +86,14 @@ 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_INGORE_CONFIG []string // 不被远端覆盖的配置项列表(remote-ingore-config / REMOTE_INGORE_CONFIG)
REMOTE_IGNORE_CONFIG []string // 不被远端覆盖的配置项列表(remote-ignore-config / REMOTE_IGNORE_CONFIG)
)
// HTTP_CLIENT 用于转发上游请求(等价于 TS 中的 $fetch),超时由 HTTP_TIMEOUT 决定
@@ -174,6 +173,20 @@ func findNode(list []apiInfo, id string) *apiInfo {
return nil
}
// flattenStack 将节点池三栈展开为一个节点列表(DualStack + IPv4 + IPv6)
func flattenStack(s stackConfig) []apiInfo {
out := make([]apiInfo, 0, len(s.DualStack)+len(s.IPv4)+len(s.IPv6))
out = append(out, s.DualStack...)
out = append(out, s.IPv4...)
out = append(out, s.IPv6...)
return out
}
// stackNonEmpty 判断节点池三栈是否至少有一项
func stackNonEmpty(s stackConfig) bool {
return len(s.DualStack) > 0 || len(s.IPv4) > 0 || len(s.IPv6) > 0
}
func keysOf(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
@@ -224,12 +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 ""
}
@@ -242,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
@@ -288,7 +321,7 @@ func middlewareHandler(c fiber.Ctx) error {
query := c.Queries()
// 从 API_KEYS 查找 token,未配置时回退到环境变量 APIKEYS (JSON 字符串)
// 从 API_KEYS 查找 token,未配置时回退到环境变量 API_KEYS (JSON 字符串)
apiKey := lookupAPIKey(backendID)
// 构造转发给上游的请求头:不透传客户端 Origin,避免上游 CORS 误判
@@ -299,14 +332,13 @@ func middlewareHandler(c fiber.Ctx) error {
switch apiType {
case "whois", "dns", "location", "ssl", "asn", "dnssec", "detail":
// 节点池结构:location/asn 走 IPLocationAPI(纯数组,无栈),其余走 APIBaseURL(三栈平铺)
var apiBaseUrls []apiInfo
switch apiType {
case "whois", "ssl", "detail":
apiBaseUrls = API_BASE_URLS
case "dns", "dnssec":
apiBaseUrls = NS_LOOKUP
case "location", "asn":
apiBaseUrls = IP_LOCATION_APIS
default:
apiBaseUrls = flattenStack(API_BASE_URLS)
}
node := findNode(apiBaseUrls, backendID)
@@ -331,20 +363,8 @@ func middlewareHandler(c fiber.Ctx) error {
return forwardUpstream(c, apiBaseUrl, apiBaseUrl+"v1/"+apiType+"/"+raw, authHeaders, nil)
case "tcping", "udping", "speed":
var apiTypeConfig stackConfig
switch apiType {
case "tcping":
apiTypeConfig = TCPING
case "speed":
apiTypeConfig = SPEED_TEST
default:
return badRequest(c, "Invalid API type")
}
apiBaseUrls := make([]apiInfo, 0, len(apiTypeConfig.DualStack)+len(apiTypeConfig.IPv4)+len(apiTypeConfig.IPv6))
apiBaseUrls = append(apiBaseUrls, apiTypeConfig.DualStack...)
apiBaseUrls = append(apiBaseUrls, apiTypeConfig.IPv4...)
apiBaseUrls = append(apiBaseUrls, apiTypeConfig.IPv6...)
// tcping/udping/speed 统一走 APIBaseURL 节点池
apiBaseUrls := flattenStack(API_BASE_URLS)
// 转发 query 参数(过滤空值,等价于 TS 中 URLSearchParams 过滤 undefined)
queryString := url.Values{}
@@ -433,7 +453,7 @@ func applyRemoteConfig(mw *middlewareConfig) error {
}
// ignore 列表:数组中的配置项不被远端覆盖(逐键判断跳过)
ignored := func(key string) bool {
for _, k := range mw.RemoteIngoreConfig {
for _, k := range mw.RemoteIgnoreConfig {
if k == key {
return true
}
@@ -458,22 +478,14 @@ func applyRemoteConfig(mw *middlewareConfig) error {
if !ignored("cors") && remote.Cors != "" {
mw.Cors = remote.Cors
}
if !ignored("apiBaseUrls") && len(remote.APIBaseURLs) > 0 {
mw.APIBaseURLs = remote.APIBaseURLs
// 节点池结构:api-base-url 与 ip-location-api(远端非空栈时覆盖本地)
if !ignored("api-base-url") && stackNonEmpty(remote.APIBaseURL) {
mw.APIBaseURL = remote.APIBaseURL
}
if !ignored("IPLocationAPIs") && len(remote.IPLocationAPIs) > 0 {
mw.IPLocationAPIs = remote.IPLocationAPIs
if !ignored("ip-location-api") && len(remote.IPLocationAPI) > 0 {
mw.IPLocationAPI = remote.IPLocationAPI
}
if !ignored("TCPing") && (len(remote.TCPing.DualStack) > 0 || len(remote.TCPing.IPv4) > 0 || len(remote.TCPing.IPv6) > 0) {
mw.TCPing = remote.TCPing
}
if !ignored("SpeedTest") && (len(remote.SpeedTest.DualStack) > 0 || len(remote.SpeedTest.IPv4) > 0 || len(remote.SpeedTest.IPv6) > 0) {
mw.SpeedTest = remote.SpeedTest
}
if !ignored("NSLookup") && len(remote.NSLookup) > 0 {
mw.NSLookup = remote.NSLookup
}
// apiKeys 强制忽略:密钥凭据不随远端配置覆盖(与后端 access_token 一致),只从本地 setting.json / env 读取
// api-keys / ws-keys 强制忽略:密钥凭据不随远端配置覆盖(与后端 access-token 一致),只从本地 setting.json / env 读取
log.Printf("[middleware] remote config applied from %s", url)
return nil
}
@@ -542,64 +554,49 @@ func readConfig() error {
} else if viper.IsSet("ws-port") {
mw.WSPort = stringOrNumber(viper.GetString("ws-port"))
}
// remote-ingore-config:不被远端覆盖的配置项列表(env JSON 数组 > setting.json 数组)
if raw := os.Getenv("REMOTE_INGORE_CONFIG"); raw != "" {
// remote-ignore-config:不被远端覆盖的配置项列表(env JSON 数组 > setting.json 数组)
if raw := os.Getenv("REMOTE_IGNORE_CONFIG"); raw != "" {
var list []string
if err := json.Unmarshal([]byte(raw), &list); err != nil {
return fmt.Errorf("parse env REMOTE_INGORE_CONFIG: %w", err)
return fmt.Errorf("parse env REMOTE_IGNORE_CONFIG: %w", err)
}
mw.RemoteIngoreConfig = list
mw.RemoteIgnoreConfig = list
} else {
mw.RemoteIngoreConfig = viper.GetStringSlice("remote-ingore-config")
mw.RemoteIgnoreConfig = viper.GetStringSlice("remote-ignore-config")
}
// 数组/对象类配置:先 Getenv(JSON 字符串),再 viper.Get(经 JSON 中转反序列化到 typed 结构)
if err := envJSON("API_BASE_URLS", &mw.APIBaseURLs); err != nil {
// 节点池结构:api-base-url 与 ip-location-api(env JSON > setting.json)
if err := envJSON("API_BASE_URLS", &mw.APIBaseURL); err != nil {
return err
}
if len(mw.APIBaseURLs) == 0 {
if err := viperValue("apiBaseUrls", &mw.APIBaseURLs); err != nil {
return fmt.Errorf("parse apiBaseUrls: %w", err)
if !stackNonEmpty(mw.APIBaseURL) && viper.Get("api-base-url") != nil {
if err := viperValue("api-base-url", &mw.APIBaseURL); err != nil {
return fmt.Errorf("parse api-base-url: %w", err)
}
}
if err := envJSON("IP_LOCATION_APIS", &mw.IPLocationAPIs); err != nil {
if err := envJSON("IP_LOCATION_APIS", &mw.IPLocationAPI); err != nil {
return err
}
if len(mw.IPLocationAPIs) == 0 {
if err := viperValue("IPLocationAPIs", &mw.IPLocationAPIs); err != nil {
return fmt.Errorf("parse IPLocationAPIs: %w", err)
if len(mw.IPLocationAPI) == 0 && viper.Get("ip-location-api") != nil {
if err := viperValue("ip-location-api", &mw.IPLocationAPI); err != nil {
return fmt.Errorf("parse ip-location-api: %w", err)
}
}
if err := envJSON("TCPING", &mw.TCPing); err != nil {
return err
}
if viper.Get("TCPing") != nil && len(mw.TCPing.DualStack)+len(mw.TCPing.IPv4)+len(mw.TCPing.IPv6) == 0 {
if err := viperValue("TCPing", &mw.TCPing); err != nil {
return fmt.Errorf("parse TCPing: %w", err)
}
}
if err := envJSON("SPEED_TEST", &mw.SpeedTest); err != nil {
return err
}
if viper.Get("SpeedTest") != nil && len(mw.SpeedTest.DualStack)+len(mw.SpeedTest.IPv4)+len(mw.SpeedTest.IPv6) == 0 {
if err := viperValue("SpeedTest", &mw.SpeedTest); err != nil {
return fmt.Errorf("parse SpeedTest: %w", err)
}
}
if err := envJSON("NS_LOOKUP", &mw.NSLookup); err != nil {
return err
}
if len(mw.NSLookup) == 0 {
if err := viperValue("NSLookup", &mw.NSLookup); err != nil {
return fmt.Errorf("parse NSLookup: %w", err)
}
}
if err := envJSON("APIKEYS", &mw.APIKeys); err != nil {
if err := envJSON("API_KEYS", &mw.APIKeys); err != nil {
return err
}
if len(mw.APIKeys) == 0 {
if err := viperValue("apiKeys", &mw.APIKeys); err != nil {
return fmt.Errorf("parse apiKeys: %w", err)
if err := viperValue("api-keys", &mw.APIKeys); err != nil {
return fmt.Errorf("parse api-keys: %w", err)
}
}
// ws-keys:WS 注册校验表(nodeID → key),优先级 env WS_KEYS > setting.json ws-keys,与 api-keys 相互独立
if err := envJSON("WS_KEYS", &mw.WSKeys); err != nil {
return err
}
if len(mw.WSKeys) == 0 {
if err := viperValue("ws-keys", &mw.WSKeys); err != nil {
return fmt.Errorf("parse ws-keys: %w", err)
}
}
@@ -609,11 +606,8 @@ func readConfig() error {
}
// 校验配置存在(防止在错误的文件上静默空跑)
hasEndpoints := len(mw.APIBaseURLs) > 0 || len(mw.NSLookup) > 0 || len(mw.IPLocationAPIs) > 0 ||
len(mw.TCPing.DualStack) > 0 || len(mw.TCPing.IPv4) > 0 || len(mw.TCPing.IPv6) > 0 ||
len(mw.SpeedTest.DualStack) > 0 || len(mw.SpeedTest.IPv4) > 0 || len(mw.SpeedTest.IPv6) > 0
if !hasEndpoints {
return fmt.Errorf("invalid config in %s: missing endpoint lists (expected flat middleware config)", path)
if !stackNonEmpty(mw.APIBaseURL) && len(mw.IPLocationAPI) == 0 {
return fmt.Errorf("invalid config in %s: missing endpoint pools (expected api-base-url / ip-location-api node pools)", path)
}
// 将配置内容写入全局变量
@@ -635,18 +629,16 @@ func readConfig() error {
}
WS_PORT = n
}
REMOTE_INGORE_CONFIG = mw.RemoteIngoreConfig
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
WS_KEYS = mw.WSKeys
CONFIG_SOURCE = viper.ConfigFileUsed()
return nil
}
+6 -30
View File
@@ -4,23 +4,9 @@
"rate-limit": 120,
"ws-port": 8092,
"remote-config-url": "",
"remote-ingore-config": [],
"remote-ignore-config": [],
"cors": "",
"apiBaseUrls": [
{
"label": "中国 江苏 移动",
"id": "cn-jiangsu",
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
}
],
"IPLocationAPIs": [
{
"label": "中国 江苏 移动",
"id": "cn-jiangsu",
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
}
],
"TCPing": {
"api-base-url": {
"DualStack": [
{
"label": "中国 江苏 移动",
@@ -31,23 +17,13 @@
"IPv4": [],
"IPv6": []
},
"SpeedTest": {
"DualStack": [
{
"label": "中国 江苏 移动",
"id": "cn-jiangsu",
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
}
],
"IPv4": [],
"IPv6": []
},
"NSLookup": [
"ip-location-api": [
{
"label": "中国 江苏 移动",
"id": "cn-jiangsu",
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
}
],
"apiKeys": {}
}
"api-keys": {},
"ws-keys": {}
}
+3 -3
View File
@@ -117,7 +117,7 @@ func (s *wsServer) Handler(w http.ResponseWriter, r *http.Request) {
case "register":
var reg struct {
NodeID string `json:"nodeId"`
Key string `json:"key"` // 注册凭证:与 setting.json apiKeys 配置比对
Key string `json:"key"` // 注册凭证:与 setting.json wsKeys 配置比对
}
_ = json.Unmarshal(msg.Data, &reg)
if reg.NodeID == "" {
@@ -128,9 +128,9 @@ func (s *wsServer) Handler(w http.ResponseWriter, r *http.Request) {
s.sendJSON(c, wsMessage{Type: "register_error", Data: mustRaw(wsCommand{Command: "already registered"})})
continue
}
// 注册 key 校验:节点配置了 apiKeys[节点id] 就必须传对 key,否则 401 并断开;
// 注册 key 校验:节点配置了 wsKeys[节点id] 就必须传对 key,否则 401 并断开;
// 未配置 key 的节点(开放)无需传 key
if expected := lookupAPIKey(reg.NodeID); expected != "" && reg.Key != expected {
if expected := lookupWSKey(reg.NodeID); expected != "" && reg.Key != expected {
log.Printf("[ws] AUTH FAILED for node %s: invalid key", reg.NodeID)
s.sendJSON(c, wsMessage{Type: "register_error", Data: mustRaw(struct {
Code int `json:"code"`
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
+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
}
+12 -12
View File
@@ -234,10 +234,10 @@ var (
ACCEPT_DOMAINS []string
ACCESS_TOKEN string
REMOTE_CONFIG_URL string
REMOTE_INGORE_CONFIG []string // 不被远端配置覆盖的配置项列表(remote-ingore-config / REMOTE_INGORE_CONFIG)
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(与中间件 apiKeys 键一致)
WS_NODE_KEY string // WS 客户端:注册 key(与中间件 apiKeys[节点id] 一致,可空)
WS_NODE_ID string // WS 客户端:节点 id(与中间件 ws-keys 键一致)
WS_NODE_KEY string // WS 客户端:注册 key(与中间件 ws-keys[节点id] 一致,可空)
VERSION string
COMMIT string
BUILD_TIME string
@@ -980,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 == "" {
@@ -993,7 +993,7 @@ func applyRemoteConfig() {
}
// ignore 列表:数组中的配置项不被远端覆盖(逐键判断跳过)
ignored := func(key string) bool {
for _, k := range REMOTE_INGORE_CONFIG {
for _, k := range REMOTE_IGNORE_CONFIG {
if k == key {
return true
}
@@ -1035,7 +1035,7 @@ func applyRemoteConfig() {
if v := configValue(CONFIG, "node-key"); v != "" && !ignored("node-key") {
WS_NODE_KEY = v
}
// access_token 不在此覆盖:保持原有优先级(环境变量 > setting.json)
// access-token 不在此覆盖:保持原有优先级(环境变量 > setting.json)
if CORS != "" {
ACCEPT_DOMAINS = strings.Split(CORS, ",")
}
@@ -1092,7 +1092,7 @@ 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")
@@ -1112,16 +1112,16 @@ func readConfig() {
if WS_NODE_KEY == "" {
WS_NODE_KEY = viper.GetString("node-key")
}
// REMOTE_INGORE_CONFIG:不被远端覆盖的配置项列表(JSON 数组字符串,env 优先)
if raw := os.Getenv("REMOTE_INGORE_CONFIG"); raw != "" {
// REMOTE_IGNORE_CONFIG:不被远端覆盖的配置项列表(JSON 数组字符串,env 优先)
if raw := os.Getenv("REMOTE_IGNORE_CONFIG"); raw != "" {
var list []string
if err := json.Unmarshal([]byte(raw), &list); err == nil {
REMOTE_INGORE_CONFIG = list
REMOTE_IGNORE_CONFIG = list
} else {
slog.Warn("invalid REMOTE_INGORE_CONFIG, ignored", "error", err)
slog.Warn("invalid REMOTE_IGNORE_CONFIG, ignored", "error", err)
}
} else {
REMOTE_INGORE_CONFIG = viper.GetStringSlice("remote-ingore-config")
REMOTE_IGNORE_CONFIG = viper.GetStringSlice("remote-ignore-config")
}
applyRemoteConfig()
slog.Info("SSRF protection initialized", "blockPrivateIPs", ssrf.Enabled())
+5 -4
View File
@@ -5,11 +5,12 @@
"dns-server": "",
"dnssec-server": "",
"block-private-ips": true,
"ipdb": true,
"ipdb": "false",
"cors": "",
"remote-config-url": "",
"remote-ingore-config": [],
"access_token": "",
"remote-ignore-config": [],
"access-token": "",
"ws-url": "",
"node-id": ""
"node-id": "",
"node-key": ""
}
View File
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+29 -17
View File
@@ -18,15 +18,15 @@ import (
//
// 配置(readConfig 解析,环境变量优先,其次 setting.json,远端配置可覆盖):
//
// WS_URL ws://<中间件>:8092/ws,ws://<备中间件>:8092/ws (逗号分隔多个中间件,主失败自动切换下一个;不配置 = 不启用)
// NODE_ID 节点 id(与中间件 apiKeys 键 / 前端配置 id 一致)
// NODE_KEY 注册 key(与中间件 apiKeys[节点id] 一致;中间件未配置该节点 key 时留空)
// WS_URL ws://<中间件>:8092/ws,ws://<备中间件>:8092/ws (逗号分隔多个中间件,同时连接全部,多活;不配置 = 不启用)
// NODE_ID 节点 id(与中间件 ws-keys 键 / 前端配置 id 一致)
// NODE_KEY 注册 key(与中间件 ws-keys[节点id] 一致;中间件未配置该节点 key 时留空)
//
// 与 HTTP handler 共用同一批 webtest 探针函数和缓存:收到 probe 后按 apiType 取数(带缓存),
// 返回处理在本文件内完成,结果经 probe_result 上报。节点 HTTP 接口完全不变。
//
// 心跳:NODE 与 MIDDLEWARE 双向心跳。本端每 10s 发一次 ping,发送失败重试,连续失败 3 次
// 判定当前中间件不可用,断开并切换下一个 WS_URL;收到中间件 ping 回 pong。
// 判定当前中间件不可用,断开该连接并按同一 URL 独立重连(不影响其他中间件连接)。
// wsProbe 学 HTTP handler:从函数拿数据 + 缓存,返回 (status, body)。
// raw 与 query 来自中间件 probe 消息(等价于路由参数)。
@@ -360,8 +360,8 @@ const (
wsHeartbeatTimeout = 5 * time.Second
)
// wsClientOnce 连接并服务单个中间件 URL,返回下次重试前的等待时长:
// - 连接/读循环断开 → 3s(切换下一个中间件)
// wsClientOnce 连接并服务单个中间件 URL,返回下次重试前的等待时长(由 per-URL 连接循环调用):
// - 连接/读循环断开 → 3s(重试同一 URL)
// - 注册被拒(register_error)→ 30s(多为 key 配置错误,加大间隔避免空转刷日志)
// - 心跳连续失败 wsHeartbeatRetry 次 → 主动断开,3s
// - 返回 0 = 配置缺失(不再重试)
@@ -399,7 +399,7 @@ func wsClientOnce(url string) time.Duration {
}
slog.Info("ws client registered", "nodeId", WS_NODE_ID, "url", url)
// 心跳 goroutine:每 10s 发 ping,失败重试,连续 3 次失败主动断开(上层切换下一个中间件)
// 心跳 goroutine:每 10s 发 ping,失败重试,连续 3 次失败主动断开(per-URL 循环重连同一中间件)
stopHeartbeat := make(chan struct{})
go func() {
fail := 0
@@ -413,7 +413,7 @@ func wsClientOnce(url string) time.Duration {
fail++
slog.Warn("ws heartbeat send failed", "url", url, "fail", fail, "error", err)
if fail >= wsHeartbeatRetry {
slog.Warn("ws heartbeat failed too many times, switching middleware", "url", url)
slog.Warn("ws heartbeat failed too many times, closing connection (will retry same url)", "url", url)
c.Close(websocket.StatusPolicyViolation, "heartbeat timeout")
return
}
@@ -445,23 +445,35 @@ func wsClientOnce(url string) time.Duration {
}
}
// wsClientLoop 主循环:依次尝试所有配置的中间件 URL(逗号分隔,第一个为主),
// 当前中间件连接失败或心跳超时 → 3s 后切换下一个;注册被拒 → 30s;全部失败循环重试。
// 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 {
for _, url := range urls {
retryDelay := wsClientOnce(url)
if retryDelay == 0 {
slog.Info("ws client stopped")
return
}
time.Sleep(retryDelay)
retryDelay := wsClientOnce(url)
if retryDelay == 0 {
slog.Info("ws client stopped", "url", url)
return
}
time.Sleep(retryDelay)
}
}