Compare commits

...
6 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
62 changed files with 190 additions and 150 deletions

No files matched your search

+20 -15
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,7 +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
# 后端:发布 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 -6
View File
@@ -22,12 +22,9 @@ curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o ins
```
ipw-cn/
├── main.go # Go 后端入口(Gin,自托管)
├── webtest/ # NODE模块 网络测试工具库
├── ipdb/ # NODE模块 IP 数据库模块
├── ssrf/ # NODE模块 SSRF 防护
├── src/ # Go 后端
├── middleware-go/ # 独立组件 转发中间件(Fiber v3,多节点候选)
├── edgeone/ # 独立组件 腾讯 EdgeOne 边缘函数后端
├── serverless/ # 独立组件 边缘/Serverless(edgeone、edgeone-getip、lemon-getip)
├── frontend-ssr/ # Nuxt 4 SSR 前端
├── docs/ # 文档站
└── install.sh # 一键安装脚本
@@ -35,7 +32,7 @@ ipw-cn/
## 友情链接
### 来自社区的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节点
[ZAKOFLARE/ipw-cn-rust](https://github.com/ZAKOFLARE/ipw-cn-rust) 一个生锈的IPW节点[开发成本过高,已存档]
[KFCV50TK/ipw-cn](https://github.com/KFCV50TK/ipw-cn) 一个有许多扩展功能的IPW分支
## 技术栈
+21 -21
View File
@@ -25,8 +25,8 @@
- `cors`:允许的请求来源(逗号分隔)
- `access-token`:API 访问令牌,留空则不启用鉴权
- `ws-url`:WS 通道地址——接入独立中间件的 WebSocket 地址(如 `"wss://middleware-1.api-ipw.wsmdn.top/ws"`,须含 `wss://` 前缀与 `/ws` 路径);支持**逗号分隔多个中间件,同时连接全部(多活)**,任一断开只重连自己,不影响其他连接;留空 = 不启用 WS 客户端,走原 HTTP 接口
- `node-id`:WS 节点 id(与中间件 `apiKeys`/`wsKeys` 键、前端配置的节点 `id` 一致;建议用 UUID 唯一标识)
- `node-key`:WS 注册 key(与中间件 `wsKeys[节点id]` 一致;**必填**——节点未配置该 key 时中间件拒绝注册并返回 401)
- `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)。
@@ -92,7 +92,7 @@ const config = {
export { config }
```
> 节点 `id` 需与后端节点 / 中间件 `apiKeys` 的键保持一致,中间件按 `id` 注入 `Authorization: Bearer <key>`;WS 通道的节点注册校验另见中间件 `wsKeys`(与后端 `node-key` 对应)。
> 节点 `id` 需与后端节点 / 中间件 `api-keys` 的键保持一致,中间件按 `id` 注入 `Authorization: Bearer <key>`;WS 通道的节点注册校验另见中间件 `ws-keys`(与后端 `node-key` 对应)。
节点数组项均为 `{ label, id, url }` 结构,例如:
@@ -106,7 +106,7 @@ APIBaseURL: {
}
```
`label` 为节点展示名,`id` 为节点唯一标识(中间件 `apiKeys` 按此 id 注入 HTTP 转发 key,WS 注册校验另见 `wsKeys`),`url` 为节点 base URL。节点池结构如下:`APIBaseURL` 为拨测节点池,含 `DualStack` / `IPv4` / `IPv6` 三栈(分别对应双栈 / 单栈测试时使用的节点列表,IPv4-only 节点只能放 `IPv4` 栈,IPv6-only 只能放 `IPv6` 栈,不能放 `DualStack`);`IPLocationAPI` 为 IP 归属地 / ASN 查询节点池,为纯数组(无栈区分)。
`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)
@@ -153,24 +153,24 @@ APIBaseURL: {
"rate-limit": 120,
"ws-port": 8092,
"remote-config-url": "",
"remote-ingore-config": [],
"APIBaseURL": {
"remote-ignore-config": [],
"api-base-url": {
"DualStack": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"IPv4": [],
"IPv6": []
},
"IPLocationAPI": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"apiKeys": { "cn-jiangsu": "" },
"wsKeys": { "cn-jiangsu": "" }
"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 字符串)。**敏感凭据,强制忽略,不随远端配置覆盖**
- `wsKeys`:WS 注册校验表(`节点id → key`):节点经 WS 注册时,`register` 消息的 `key` 必须与 `wsKeys[节点id]` 一致,否则返回 401 并断开;优先级:setting.json `wsKeys` > 环境变量 `WSKEYS`(JSON 字符串)。**敏感凭据,强制忽略,不随远端配置覆盖**。与 `apiKeys` **相互独立**——`apiKeys` 管 HTTP 转发鉴权,`wsKeys` 管 WS 注册校验,可分别配置不同 key(例如 WS 节点单独换 key 不影响 HTTP 转发)
- `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 通道配置一览
@@ -180,10 +180,10 @@ WS 通道涉及**中间件(服务端)**与**后端节点(客户端)**两
| 角色 | 配置项 | 环境变量 | 说明 |
|------|--------|----------|------|
| 中间件(服务端) | `ws-port` | `WS_PORT` | WS 监听端口,默认 `8092`,`0`=关闭;节点连 `ws(s)://<中间件>:<ws-port>/ws` |
| 中间件(服务端) | `wsKeys` | `WSKEYS` | WS 注册校验表:`{"<node-id>": "<key>"}`,节点 `register` 的 key 必须匹配 |
| 中间件(服务端) | `ws-keys` | `WS_KEYS` | WS 注册校验表:`{"<node-id>": "<key>"}`,节点 `register` 的 key 必须匹配 |
| 节点(客户端) | `ws-url` | `WS_URL` | 中间件 WS 完整地址(含 `/ws` 路径),逗号分隔多个则**同时连接全部(多活)** |
| 节点(客户端) | `node-id` | `NODE_ID` | 节点 id,与中间件 `apiKeys`/`wsKeys` 键一致(建议 UUID) |
| 节点(客户端) | `node-key` | `NODE_KEY` | 注册 key,与中间件 `wsKeys[节点id]` 一致,**必填** |
| 节点(客户端) | `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-通道接入可选)。
@@ -197,8 +197,8 @@ WS 通道涉及**中间件(服务端)**与**后端节点(客户端)**两
| 组件 | 触发方式 | 远端配置格式 |
|------|----------|--------------|
| 后端(主线 `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` 格式 |
**合并规则**(重要,逐字段生效):
@@ -208,8 +208,8 @@ WS 通道涉及**中间件(服务端)**与**后端节点(客户端)**两
- 部分字段缺省不会冲掉本地配置——远端只需包含要覆盖的字段
- **敏感凭据强制忽略,永远不被远端覆盖**(代码写死,无需配置):
- 后端 `access-token`:不随远端覆盖(保持 环境变量 > setting.json),远端里的 `access-token` 会被忽略
- 中间件 `apiKeys` / `wsKeys`:不随远端覆盖,远端里的 `apiKeys`/`wsKeys` 会被忽略
- `remote-ingore-config`(后端键名 `remote-ingore-config` / env `REMOTE_INGORE_CONFIG`):额外指定**不被远端覆盖的配置项列表**,数组中的键即使远端下发也不生效。适用于 access-token / apiKeys / wsKeys 之外的敏感项(如 `node-key`、`dns-server` 等),也可覆盖"非空才覆盖"规则
- 中间件 `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` 等),也可覆盖"非空才覆盖"规则
**拉取行为**:
@@ -253,7 +253,7 @@ REMOTE_CONFIG_URL=https://example.com/ipw-config.json ./lemonipw
```
> [!WARNING]
> 敏感凭据不得上传远端:`apiKeys`/`wsKeys`(及后端 `access-token`、节点 key 等密钥)**禁止**写入远端配置——即使写了也会被强制忽略。凭据只放本地 setting.json 或环境变量。
> 敏感凭据不得上传远端:`api-keys`/`ws-keys`(及后端 `access-token`、节点 key 等密钥)**禁止**写入远端配置——即使写了也会被强制忽略。凭据只放本地 setting.json 或环境变量。
部署时:
@@ -271,7 +271,7 @@ systemd 中同样用 `Environment="REMOTE_CONFIG_URL=..."` 配置。
**安全与排查**:
- **敏感凭据不得上传远端**:远端配置(Gist / 对象存储 / 静态托管等)**禁止包含任何密钥**——`apiKeys`/`wsKeys`、`access-token`、节点 key(`node-key`)等。这些值会被强制忽略,但远端文件本身一旦泄露等于直接暴露凭据;凭据一律放本地 `setting.json` 或环境变量注入
- **敏感凭据不得上传远端**:远端配置(Gist / 对象存储 / 静态托管等)**禁止包含任何密钥**——`api-keys`/`ws-keys`、`access-token`、节点 key(`node-key`)等。这些值会被强制忽略,但远端文件本身一旦泄露等于直接暴露凭据;凭据一律放本地 `setting.json` 或环境变量注入
- 远端 URL 必须能被节点访问;优先使用 **HTTPS** 地址
- 远端配置文件如果放在公开可访问的位置(公开 Gist、无鉴权对象存储),即便不含密钥,也等于把节点拓扑、节点 id 等敏感信息公之于众,建议放在私有仓库 / 带鉴权的对象存储 / 内部服务上
- 常见失败排查:
+9 -9
View File
@@ -13,7 +13,7 @@
> cp middleware.setting.json.example setting.json
> ```
>
> 复制后按需修改 `APIBaseURL` / `IPLocationAPI` / `apiKeys` / `wsKeys` / `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` 提供(`APIBaseURL` / `IPLocationAPI` / `apiKeys` / `wsKeys` / `cors` / `rate-limit` / `remote-config-url` 等;`rate-limit` 为单 IP 每分钟限流次数,默认 120,0 表示不限流,可用环境变量 `RATE_LIMIT` 覆盖;`remote-config-url` 为远端配置地址,可用环境变量 `REMOTE_CONFIG_URL` 覆盖;**`apiKeys` / `wsKeys` 为敏感凭据,不随远端配置覆盖**——`apiKeys` 管 HTTP 转发鉴权,`wsKeys` 管 WS 注册校验,两者相互独立)。
配置通过挂载 `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` / `WSKEYS` / `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` 所在目录)。
@@ -77,7 +77,7 @@ middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入
| 方向 | type | data 说明 |
|------|------|-----------|
| 节点 → middleware | `register` | `{ "nodeId": "...", "key": "..." }`,连接后首条消息,同 id 新连接顶掉旧连接;`key` 为注册凭证,与 setting.json `wsKeys` 配置比对 |
| 节点 → 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) |
@@ -89,21 +89,21 @@ middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入
### 节点侧接入(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 `wsKeys` 里配了 key 就必须传对;未配置 key 的开放节点可不传)
2. 首条消息发 `register { "nodeId": "<与中间件配置一致的节点 id>", "key": "<注册凭证>" }`(节点在 setting.json `ws-keys` 里配了 key 就必须传对;未配置 key 的开放节点可不传)
3. 收到 `probe` → 执行拨测 → 回 `probe_result`(必须携带原 `requestId`,支持乱序返回,同一连接可并发多个拨测)
4. 收到 `ping` → 回 `pong`;`status` 可忽略或记录
5. 断线后重连并重新 `register` 即可
### 注册 key 校验
节点注册时中间件按 `wsKeys` 配置校验 key(与 `apiKeys` HTTP 转发鉴权相互独立):
节点注册时中间件按 `ws-keys` 配置校验 key(与 `api-keys` HTTP 转发鉴权相互独立):
- 节点在 `wsKeys` 配置了 key(非空)→ `register` 必须携带正确 key,不传或传错返回 `register_error { "code": 401, "command": "invalid key" }` 并**断开连接**,不进入数据阶段
- 节点未配置 key(`wsKeys` 无该项或为空)→ 开放注册,不传 key 即可
- 节点在 `ws-keys` 配置了 key(非空)→ `register` 必须携带正确 key,不传或传错返回 `register_error { "code": 401, "command": "invalid key" }` 并**断开连接**,不进入数据阶段
- 节点未配置 key(`ws-keys` 无该项或为空)→ 开放注册,不传 key 即可
与 HTTP 通道的鉴权语义一致(配置了 key 就必须带凭据,留空 = 开放)。
### 说明
- 注册阶段按节点 `wsKeys` 配置校验 key(配了 key 必须传对,否则 401 断开;未配置则开放,详见上文「注册 key 校验」);拨测请求经 WS 转发时不携带 `Authorization` 头(后续由节点侧自行决定是否需要额外鉴权)
- 注册阶段按节点 `ws-keys` 配置校验 key(配了 key 必须传对,否则 401 断开;未配置则开放,详见上文「注册 key 校验」);拨测请求经 WS 转发时不携带 `Authorization` 头(后续由节点侧自行决定是否需要额外鉴权)
- `ws:true` 且 WS 未启用(`WS_PORT=0`)时,自动回退原 HTTP 转发
+13 -13
View File
@@ -4,25 +4,25 @@
## 方案一: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)。部署后需要:
@@ -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
@@ -86,7 +86,7 @@ sudo bash install.sh
- `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(中间件 `wsKeys` 必须包含该节点,否则注册被拒 401);安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 setting.json 的 `wsKeys`
- `NODE_KEY` **必填**:不加 key 禁止启用 WS(中间件 `ws-keys` 必须包含该节点,否则注册被拒 401);安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 setting.json 的 `ws-keys`
**安装完成后自动生成并启动 systemd 服务**(`lemon-ipw.service`),配置以 `Environment="K=V"` 注入,无需 setting.json。常用管理命令:
@@ -109,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,与中间件 APIBaseURL / IPLocationAPI 池的节点 id 一致> \
NODE_KEY=<注册key,与中间件 wsKeys[节点id] 一致;节点未配置 key 可留空> \
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`:中间件 `wsKeys` 里配了 key 就必须传对,否则返回 401 并断开;未配置 key 的节点开放注册
- 节点注册带 `NODE_KEY`:中间件 `ws-keys` 里配了 key 就必须传对,否则返回 401 并断开;未配置 key 的节点开放注册
**注意**:`NODE_ID` 必须与中间件 `APIBaseURL` / `IPLocationAPI` 池中的节点 `id` 一致,且该节点需配置 `"ws": true` 才会走 WS 通道。未连接中间件时,`ws:true` 节点的拨测会返回 502。
+1 -1
View File
@@ -50,7 +50,7 @@ sudo bash install.sh
- `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>"` 加入中间件 `wsKeys`
- `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 附带配置文件) |
+3 -3
View File
@@ -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>
+3 -3
View File
@@ -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>
+17 -1
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 统计
@@ -43,6 +43,11 @@ const config = {
id: "cn-jiangsu",
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
},
{
label: "中国 湖北 武汉 电信",
id: "cn-wuhan-chinatelecom",
url: ""
},
{
label: "中国 四川 沙渠 电信[ZFC]",
id: "cn2-sichuan",
@@ -124,9 +129,20 @@ const config = {
label: "香港 Cogent",
id: "hongkong",
url: ""
},
// WS 通道节点:URL 留空,拨测经中间件 WS 转发
{
label: "呼和浩特移动",
id: "2faa15e6-18a7-4355-b422-ca53277d8d77",
url: ""
}
],
DualStack: [
{
label: "中国 湖北 武汉 电信",
id: "cn-wuhan-chinatelecom",
url: ""
},
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
+2 -6
View File
@@ -71,12 +71,8 @@ function formatLocationText(data: any, ip: string): string {
for (const [name, value] of sources) {
if (value === undefined || value === null) continue
if (typeof value === 'string') {
// ip2region 为 "国家|省份|城市|运营商" 管道分隔格式
if (value.includes('|')) {
const text = value.split('|').filter(Boolean).join(' ')
if (text) lines.push(`${name}: ${text}`)
} else if (value && value !== 'not loaded') {
// 纯字符串结果(error 等)原样输出,"not loaded" 跳过
// 纯字符串结果(error 等)原样输出,"not loaded" 跳过
if (value && value !== 'not loaded') {
lines.push(`${name}: ${value}`)
}
continue
+2 -6
View File
@@ -71,12 +71,8 @@ function formatLocationText(data: any, ip: string): string {
for (const [name, value] of sources) {
if (value === undefined || value === null) continue
if (typeof value === 'string') {
// ip2region 为 "国家|省份|城市|运营商" 管道分隔格式
if (value.includes('|')) {
const text = value.split('|').filter(Boolean).join(' ')
if (text) lines.push(`${name}: ${text}`)
} else if (value && value !== 'not loaded') {
// 纯字符串结果(error 等)原样输出,"not loaded" 跳过
// 纯字符串结果(error 等)原样输出,"not loaded" 跳过
if (value && value !== 'not loaded') {
lines.push(`${name}: ${value}`)
}
continue
+2 -2
View File
@@ -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(必填,中间件 wsKeys 必须包含此节点,否则注册被拒 401): " NODE_KEY
read -r -p " 注册 key(必填,中间件 ws-keys 必须包含此节点,否则注册被拒 401): " NODE_KEY
if [ -z "$NODE_KEY" ]; then
echo " 错误:不加 key 禁止启用 WS,必须提供注册 key"
fi
@@ -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 的 wsKeys:"
echo "WS 通道已启用,请把节点注册信息加入中间件 setting.json 的 ws-keys:"
echo " \"$NODE_ID\": \"$NODE_KEY\""
fi
if [ -n "$WS_USED_DEFAULT" ]; then
+38 -38
View File
@@ -66,12 +66,12 @@ type middlewareConfig struct {
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"` // 不被远端覆盖的配置项列表
RemoteIgnoreConfig []string `json:"remote-ignore-config"` // 不被远端覆盖的配置项列表
Cors string `json:"cors"`
APIBaseURL stackConfig `json:"APIBaseURL"` // whois/ssl/detail/dns/dnssec/tcping/speed 上游节点池
IPLocationAPI []apiInfo `json:"IPLocationAPI"` // location/asn 上游节点池(纯数组,无栈)
APIKeys map[string]string `json:"apiKeys"` // HTTP 转发鉴权:backendID → token(注入 Authorization: Bearer)
WSKeys map[string]string `json:"wsKeys"` // WS 注册校验:nodeID → key(节点 register 的 key 须匹配)
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)
@@ -93,7 +93,7 @@ var (
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 决定
@@ -237,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 ""
}
@@ -256,13 +256,13 @@ func lookupAPIKey(backendID string) string {
}
// lookupWSKey 查找 nodeID 对应的 WS 注册校验 key。
// 优先级: setting.json wsKeys > 环境变量 WSKEYS (JSON 字符串)
// 与 lookupAPIKey 相互独立:apiKeys 用于 HTTP 转发鉴权,wsKeys 用于 WS 注册校验。
// 优先级: 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("WSKEYS"); raw != "" {
} else if raw := os.Getenv("WS_KEYS"); raw != "" {
if err := json.Unmarshal([]byte(raw), &wsKeys); err != nil {
return ""
}
@@ -321,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 误判
@@ -453,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
}
@@ -478,14 +478,14 @@ func applyRemoteConfig(mw *middlewareConfig) error {
if !ignored("cors") && remote.Cors != "" {
mw.Cors = remote.Cors
}
// 节点池结构:APIBaseURL 与 IPLocationAPI(远端非空栈时覆盖本地)
if !ignored("APIBaseURL") && stackNonEmpty(remote.APIBaseURL) {
// 节点池结构:api-base-url 与 ip-location-api(远端非空栈时覆盖本地)
if !ignored("api-base-url") && stackNonEmpty(remote.APIBaseURL) {
mw.APIBaseURL = remote.APIBaseURL
}
if !ignored("IPLocationAPI") && len(remote.IPLocationAPI) > 0 {
if !ignored("ip-location-api") && len(remote.IPLocationAPI) > 0 {
mw.IPLocationAPI = remote.IPLocationAPI
}
// apiKeys / wsKeys 强制忽略:密钥凭据不随远端配置覆盖(与后端 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
}
@@ -554,49 +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")
}
// 节点池结构:APIBaseURL 与 IPLocationAPI(env JSON > setting.json)
// 节点池结构:api-base-url 与 ip-location-api(env JSON > setting.json)
if err := envJSON("API_BASE_URLS", &mw.APIBaseURL); err != nil {
return err
}
if !stackNonEmpty(mw.APIBaseURL) && viper.Get("APIBaseURL") != nil {
if err := viperValue("APIBaseURL", &mw.APIBaseURL); err != nil {
return fmt.Errorf("parse APIBaseURL: %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.IPLocationAPI); err != nil {
return err
}
if len(mw.IPLocationAPI) == 0 && viper.Get("IPLocationAPI") != nil {
if err := viperValue("IPLocationAPI", &mw.IPLocationAPI); err != nil {
return fmt.Errorf("parse IPLocationAPI: %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("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)
}
}
// wsKeys:WS 注册校验表(nodeID → key),优先级 env WSKEYS > setting.json wsKeys,与 apiKeys 相互独立
if err := envJSON("WSKEYS", &mw.WSKeys); err != nil {
// 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("wsKeys", &mw.WSKeys); err != nil {
return fmt.Errorf("parse wsKeys: %w", err)
if err := viperValue("ws-keys", &mw.WSKeys); err != nil {
return fmt.Errorf("parse ws-keys: %w", err)
}
}
@@ -607,7 +607,7 @@ func readConfig() error {
// 校验配置存在(防止在错误的文件上静默空跑)
if !stackNonEmpty(mw.APIBaseURL) && len(mw.IPLocationAPI) == 0 {
return fmt.Errorf("invalid config in %s: missing endpoint pools (expected APIBaseURL / IPLocationAPI node pools)", path)
return fmt.Errorf("invalid config in %s: missing endpoint pools (expected api-base-url / ip-location-api node pools)", path)
}
// 将配置内容写入全局变量
@@ -629,7 +629,7 @@ 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 != "" {
+5 -5
View File
@@ -4,9 +4,9 @@
"rate-limit": 120,
"ws-port": 8092,
"remote-config-url": "",
"remote-ingore-config": [],
"remote-ignore-config": [],
"cors": "",
"APIBaseURL": {
"api-base-url": {
"DualStack": [
{
"label": "中国 江苏 移动",
@@ -17,13 +17,13 @@
"IPv4": [],
"IPv6": []
},
"IPLocationAPI": [
"ip-location-api": [
{
"label": "中国 江苏 移动",
"id": "cn-jiangsu",
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
}
],
"apiKeys": {},
"wsKeys": {}
"api-keys": {},
"ws-keys": {}
}
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
}
+9 -9
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(与中间件 wsKeys 键一致)
WS_NODE_KEY string // WS 客户端:注册 key(与中间件 wsKeys[节点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
@@ -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
}
@@ -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())
@@ -8,7 +8,7 @@
"ipdb": "false",
"cors": "",
"remote-config-url": "",
"remote-ingore-config": [],
"remote-ignore-config": [],
"access-token": "",
"ws-url": "",
"node-id": "",
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.
+2 -2
View File
@@ -19,8 +19,8 @@ import (
// 配置(readConfig 解析,环境变量优先,其次 setting.json,远端配置可覆盖):
//
// WS_URL ws://<中间件>:8092/ws,ws://<备中间件>:8092/ws (逗号分隔多个中间件,同时连接全部,多活;不配置 = 不启用)
// NODE_ID 节点 id(与中间件 wsKeys 键 / 前端配置 id 一致)
// NODE_KEY 注册 key(与中间件 wsKeys[节点id] 一致;中间件未配置该节点 key 时留空)
// NODE_ID 节点 id(与中间件 ws-keys 键 / 前端配置 id 一致)
// NODE_KEY 注册 key(与中间件 ws-keys[节点id] 一致;中间件未配置该节点 key 时留空)
//
// 与 HTTP handler 共用同一批 webtest 探针函数和缓存:收到 probe 后按 apiType 取数(带缓存),
// 返回处理在本文件内完成,结果经 probe_result 上报。节点 HTTP 接口完全不变。