Compare commits

...
9 Commits
Author SHA1 Message Date
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
29 changed files with 285 additions and 500 deletions

No files matched your search

-1
View File
@@ -83,7 +83,6 @@ jobs:
- name: Prepare config files for release
run: |
# 后端:发布真实 setting.json(重命名为 setting.json.example);中间件:只发布 setting.json.example
cp setting.json setting.json.example
cp middleware-go/setting.json.example middleware.setting.json.example
- name: Release
uses: softprops/action-gh-release@v3
+3 -2
View File
@@ -15,9 +15,10 @@ lemon-ipw
*.tmp
*.log
node_modules/
setting.json
.workbuddy/
# VitePress 构建产物
docs/.vitepress/.temp/
docs/.vitepress/cache/
docs/.vitepress/dist/
docs/.vitepress/dist/
tmp/
setting.json
+6 -6
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):
@@ -23,12 +23,12 @@ curl -o- https://cdn.jsdelivr.net/gh/nomdn/ipw-cn@main/install.sh | bash
```
ipw-cn/
├── main.go # Go 后端入口(Gin,自托管)
├── middleware-go/ # 独立转发中间件(Fiber v3,多节点候选)
├── webtest/ # NODE模块 网络测试工具库
├── ipdb/ # NODE模块 IP 数据库模块
├── ssrf/ # NODE模块 SSRF 防护
├── middleware-go/ # 独立组件 转发中间件(Fiber v3,多节点候选)
├── edgeone/ # 独立组件 腾讯 EdgeOne 边缘函数后端
├── frontend-ssr/ # Nuxt 4 SSR 前端
├── edgeone/ # 腾讯 EdgeOne 边缘函数后端
├── webtest/ # 网络测试工具库
├── ipdb/ # IP 数据库模块
├── ssrf/ # SSRF 防护
├── docs/ # 文档站
└── install.sh # 一键安装脚本
```
+57 -42
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(与中间件 `apiKeys`/`wsKeys` 键、前端配置的节点 `id` 一致;建议用 UUID 唯一标识)
- `node-key`:WS 注册 key(与中间件 `wsKeys[节点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` 需与后端节点 / 中间件 `apiKeys` 的键保持一致,中间件按 `id` 注入 `Authorization: Bearer <key>`;WS 通道的节点注册校验另见中间件 `wsKeys`(与后端 `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` 为节点唯一标识(中间件 `apiKeys` 按此 id 注入 HTTP 转发 key,WS 注册校验另见 `wsKeys`),`url` 为节点 base URL。节点池结构如下:`APIBaseURL` 为拨测节点池,含 `DualStack` / `IPv4` / `IPv6` 三栈(分别对应双栈 / 单栈测试时使用的节点列表,IPv4-only 节点只能放 `IPv4` 栈,IPv6-only 只能放 `IPv6` 栈,不能放 `DualStack`);`IPLocationAPI` 为 IP 归属地 / ASN 查询节点池,为纯数组(无栈区分)。
### 出站 IP 检测接口(v4OnlyAPI / v6OnlyAPI / DualStackAPI)
@@ -165,8 +154,14 @@ apiBaseUrls: [
"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": "" }
"APIBaseURL": {
"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": "" }
}
```
@@ -175,7 +170,22 @@ apiBaseUrls: [
- `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-传输)
- `wsKeys`:WS 注册校验表(`节点id → key`):节点经 WS 注册时,`register` 消息的 `key` 必须与 `wsKeys[节点id]` 一致,否则返回 401 并断开;优先级:setting.json `wsKeys` > 环境变量 `WSKEYS`(JSON 字符串)。**敏感凭据,强制忽略,不随远端配置覆盖**。与 `apiKeys` **相互独立**——`apiKeys` 管 HTTP 转发鉴权,`wsKeys` 管 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` |
| 中间件(服务端) | `wsKeys` | `WSKEYS` | 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]` 一致,**必填** |
**链路**:中间件收到 `ws:true` 节点的拨测请求 → 经 WS 发 `probe` → 节点执行探针(直调 webtest 函数 + 缓存)→ `probe_result` 回传 → 中间件返回 HTTP 响应(`Content-Type: application/json`)。节点侧接入步骤见 [后端节点部署 - WS 通道接入](/guide/deploy-node#ws-通道接入可选)。
---
@@ -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` 会被忽略
- 中间件 `apiKeys` / `wsKeys`:不随远端覆盖,远端里的 `apiKeys`/`wsKeys` 会被忽略
- `remote-ingore-config`(后端键名 `remote-ingore-config` / env `REMOTE_INGORE_CONFIG`):额外指定**不被远端覆盖的配置项列表**,数组中的键即使远端下发也不生效。适用于 access-token / apiKeys / wsKeys 之外的敏感项(如 `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]
> 敏感凭据不得上传远端:`apiKeys`/`wsKeys`(及后端 `access-token`、节点 key 等密钥)**禁止**写入远端配置——即使写了也会被强制忽略。凭据只放本地 setting.json 或环境变量。
部署时:
@@ -256,7 +271,7 @@ systemd 中同样用 `Environment="REMOTE_CONFIG_URL=..."` 配置。
**安全与排查**:
- **⚠️ 敏感凭据不得上传远端**:远端配置(Gist / 对象存储 / 静态托管等)**禁止包含任何密钥**——`apiKeys`、`access_token`、节点 key(`node-key`)等。这些值会被强制忽略,但远端文件本身一旦泄露等于直接暴露凭据;凭据一律放本地 `setting.json` 或环境变量注入
- **敏感凭据不得上传远端**:远端配置(Gist / 对象存储 / 静态托管等)**禁止包含任何密钥**——`apiKeys`/`wsKeys`、`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` 表示不限流 |
+14 -13
View File
@@ -13,7 +13,7 @@
> cp middleware.setting.json.example setting.json
> ```
>
> 复制后按需修改 `apiBaseUrls` / `apiKeys` / `cors` 等配置项。
> 复制后按需修改 `APIBaseURL` / `IPLocationAPI` / `apiKeys` / `wsKeys` / `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` 提供(`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 注册校验,两者相互独立)。
## 方案二:二进制
@@ -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` / `APIKEYS` / `WSKEYS` / `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 `wsKeys` 配置比对 |
| 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) |
@@ -88,21 +89,21 @@ 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 的开放节点可不传)
2. 首条消息发 `register { "nodeId": "<与中间件配置一致的节点 id>", "key": "<注册凭证>" }`(节点在 setting.json `wsKeys` 里配了 key 就必须传对;未配置 key 的开放节点可不传)
3. 收到 `probe` → 执行拨测 → 回 `probe_result`(必须携带原 `requestId`,支持乱序返回,同一连接可并发多个拨测)
4. 收到 `ping` → 回 `pong`;`status` 可忽略或记录
5. 断线后重连并重新 `register` 即可
### 注册 key 校验
节点注册时中间件按 `apiKeys` 配置校验 key:
节点注册时中间件按 `wsKeys` 配置校验 key(与 `apiKeys` HTTP 转发鉴权相互独立):
- 节点在 `apiKeys` 配置了 key(非空)→ `register` 必须携带正确 key,不传或传错返回 `register_error { "code": 401, "command": "invalid key" }` 并**断开连接**,不进入数据阶段
- 节点未配置 key(`apiKeys` 无该项或为空)→ 开放注册,不传 key 即可
- 节点在 `wsKeys` 配置了 key(非空)→ `register` 必须携带正确 key,不传或传错返回 `register_error { "code": 401, "command": "invalid key" }` 并**断开连接**,不进入数据阶段
- 节点未配置 key(`wsKeys` 无该项或为空)→ 开放注册,不传 key 即可
与 HTTP 通道的鉴权语义一致(配置了 key 就必须带凭据,留空 = 开放)。
### 说明
- 注册阶段按节点 `apiKeys` 配置校验 key(配了 key 必须传对,否则 401 断开;未配置则开放,详见上文「注册 key 校验」);拨测请求经 WS 转发时不携带 `Authorization` 头(后续由节点侧自行决定是否需要额外鉴权)
- 注册阶段按节点 `wsKeys` 配置校验 key(配了 key 必须传对,否则 401 断开;未配置则开放,详见上文「注册 key 校验」);拨测请求经 WS 转发时不携带 `Authorization` 头(后续由节点侧自行决定是否需要额外鉴权)
- `ws:true` 且 WS 未启用(`WS_PORT=0`)时,自动回退原 HTTP 转发
+12 -7
View File
@@ -28,7 +28,7 @@ npx edgeone pages deploy -n ipw-cn -t $EDGEONE_API_TOKEN
> **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
@@ -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 |
@@ -82,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(中间件 `apiKeys` 必须包含该节点,否则注册被拒 401);安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 setting.json 的 `apiKeys`
- `NODE_KEY` **必填**:不加 key 禁止启用 WS(中间件 `wsKeys` 必须包含该节点,否则注册被拒 401);安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 setting.json 的 `wsKeys`
**安装完成后自动生成并启动 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_` 前缀。
## 配置
@@ -108,14 +113,14 @@ sudo systemctl restart lemon-ipw # 重启(改配置后)
```bash
WS_URL=ws://<中间件IP>:8092/ws \
NODE_ID=<节点id,与中间件 apiBaseUrls 的节点 id 一致> \
NODE_KEY=<注册key,与中间件 apiKeys[节点id] 一致;节点未配置 key 可留空> \
NODE_ID=<节点id,与中间件 APIBaseURL / IPLocationAPI 池的节点 id 一致> \
NODE_KEY=<注册key,与中间件 wsKeys[节点id] 一致;节点未配置 key 可留空> \
./lemonipw
```
- `WS_URL` 支持**逗号分隔多个中间件**(`ws://主:8092/ws,ws://备:8092/ws`),第一个为主,主故障自动切换下一个
- **双向心跳**:节点每 10s 发 ping,中间件回 pong;心跳发送连续失败 3 次判定中间件不可用,断开后 3s 重试(注册被拒 30s 重试)
- 收到 `probe` 后节点直调探针函数(带缓存)并回 `probe_result`,结果与 HTTP 通道一致
- 节点注册带 `NODE_KEY`:中间件 `apiKeys` 里配了 key 就必须传对,否则返回 401 并断开;未配置 key 的节点开放注册
- 节点注册带 `NODE_KEY`:中间件 `wsKeys` 里配了 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。
+6 -3
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 |
@@ -47,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>"` 加入中间件 `apiKeys`
- `NODE_KEY` **必填**:不加 key 禁止启用 WS;安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 `wsKeys`
### 验证安装
+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)
+1 -1
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('');
+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')
+1 -1
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('');
+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)
+33 -231
View File
@@ -35,25 +35,9 @@ const config = {
v4OnlyAPI: "https://4.wsmdn.top",
v6OnlyAPI: "https://6.wsmdn.top",
DualStackAPI: "https://test.wsmdn.top",
apiBaseUrls: [
{
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",
url: ""
}
],
IPLocationAPIs: [
// ---- 节点池结构 ----
// IPLocationAPI:IP 归属查询(location / asn)上游节点池(纯数组,无栈区分)
IPLocationAPI: [
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
@@ -65,283 +49,101 @@ 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: ""
}
],
IPv6: [
{
label: "中国 四川 沙渠 电信[ZFC]",
id: "cn2-sichuan",
url:"https://cn2-sichuan.api-ipw.wsmdn.top/",
},
{
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
id: "lntl-cn-hk-kowloon",
url:"https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/",
},
{
label: "中国 河北 秦皇岛 联通",
id: "cn-hebei-qinhuangdao",
url :"https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/",
}
]
},
SpeedTest:{
DualStack: [
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
url :"https://cn-jiangsu.api-ipw.wsmdn.top/",
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
},
{
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
id: "cn-shenzhen",
url :"https://cn-shenzhen.api-ipw.wsmdn.top/",
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 }
@@ -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' })
+5 -5
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}
@@ -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(必填,中间件 wsKeys 必须包含此节点,否则注册被拒 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 的 wsKeys:"
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 "========================================"
+5 -5
View File
@@ -236,8 +236,8 @@ var (
REMOTE_CONFIG_URL string
REMOTE_INGORE_CONFIG []string // 不被远端配置覆盖的配置项列表(remote-ingore-config / REMOTE_INGORE_CONFIG)
WS_URL string // WS 客户端:中间件 WS 地址(ws://host:port/ws)
WS_NODE_ID string // WS 客户端:节点 id(与中间件 apiKeys 键一致)
WS_NODE_KEY string // WS 客户端:注册 key(与中间件 apiKeys[节点id] 一致,可空)
WS_NODE_ID string // WS 客户端:节点 id(与中间件 wsKeys 键一致)
WS_NODE_KEY string // WS 客户端:注册 key(与中间件 wsKeys[节点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 == "" {
@@ -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")
+88 -96
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"`
RemoteIngoreConfig []string `json:"remote-ingore-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 须匹配)
}
// stringOrNumber 兼容 JSON 中的字符串与数字(如 "8080" 或 8080)
@@ -85,12 +86,10 @@ 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 统一解析
@@ -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 {
@@ -242,6 +255,26 @@ func lookupAPIKey(backendID string) string {
return apiKey
}
// lookupWSKey 查找 nodeID 对应的 WS 注册校验 key。
// 优先级: setting.json wsKeys > 环境变量 WSKEYS (JSON 字符串)
// 与 lookupAPIKey 相互独立:apiKeys 用于 HTTP 转发鉴权,wsKeys 用于 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 != "" {
if err := json.Unmarshal([]byte(raw), &wsKeys); err != nil {
return ""
}
} else {
return ""
}
wsKey := wsKeys[nodeID]
return wsKey
}
// ==================== 业务接口 ====================
// middlewareHandler 对应前端 server/routes/middleware/[...slug].get.ts
@@ -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{}
@@ -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
// 节点池结构:APIBaseURL 与 IPLocationAPI(远端非空栈时覆盖本地)
if !ignored("APIBaseURL") && stackNonEmpty(remote.APIBaseURL) {
mw.APIBaseURL = remote.APIBaseURL
}
if !ignored("IPLocationAPIs") && len(remote.IPLocationAPIs) > 0 {
mw.IPLocationAPIs = remote.IPLocationAPIs
if !ignored("IPLocationAPI") && 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 读取
// apiKeys / wsKeys 强制忽略:密钥凭据不随远端配置覆盖(与后端 access-token 一致),只从本地 setting.json / env 读取
log.Printf("[middleware] remote config applied from %s", url)
return nil
}
@@ -553,45 +565,21 @@ func readConfig() error {
mw.RemoteIngoreConfig = viper.GetStringSlice("remote-ingore-config")
}
// 数组/对象类配置:先 Getenv(JSON 字符串),再 viper.Get(经 JSON 中转反序列化到 typed 结构)
if err := envJSON("API_BASE_URLS", &mw.APIBaseURLs); err != nil {
// 节点池结构:APIBaseURL 与 IPLocationAPI(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("APIBaseURL") != nil {
if err := viperValue("APIBaseURL", &mw.APIBaseURL); err != nil {
return fmt.Errorf("parse APIBaseURL: %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 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 len(mw.IPLocationAPI) == 0 && viper.Get("IPLocationAPI") != nil {
if err := viperValue("IPLocationAPI", &mw.IPLocationAPI); err != nil {
return fmt.Errorf("parse IPLocationAPI: %w", err)
}
}
if err := envJSON("APIKEYS", &mw.APIKeys); err != nil {
@@ -602,6 +590,15 @@ func readConfig() error {
return fmt.Errorf("parse apiKeys: %w", err)
}
}
// wsKeys:WS 注册校验表(nodeID → key),优先级 env WSKEYS > setting.json wsKeys,与 apiKeys 相互独立
if err := envJSON("WSKEYS", &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)
}
}
// 远端配置(最高优先级):REMOTE_CONFIG_URL 拉取后覆盖(优先级:远端 > 环境变量 > setting.json)
if err := applyRemoteConfig(&mw); err != nil {
@@ -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 APIBaseURL / IPLocationAPI node pools)", path)
}
// 将配置内容写入全局变量
@@ -641,12 +635,10 @@ func readConfig() error {
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
}
+5 -29
View File
@@ -6,21 +6,7 @@
"remote-config-url": "",
"remote-ingore-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": {
"APIBaseURL": {
"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": [
"IPLocationAPI": [
{
"label": "中国 江苏 移动",
"id": "cn-jiangsu",
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
}
],
"apiKeys": {}
}
"apiKeys": {},
"wsKeys": {}
}
+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"`
+4 -3
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": "",
"access-token": "",
"ws-url": "",
"node-id": ""
"node-id": "",
"node-key": ""
}
+2 -2
View File
@@ -19,8 +19,8 @@ import (
// 配置(readConfig 解析,环境变量优先,其次 setting.json,远端配置可覆盖):
//
// WS_URL ws://<中间件>:8092/ws,ws://<备中间件>:8092/ws (逗号分隔多个中间件,主失败自动切换下一个;不配置 = 不启用)
// NODE_ID 节点 id(与中间件 apiKeys 键 / 前端配置 id 一致)
// NODE_KEY 注册 key(与中间件 apiKeys[节点id] 一致;中间件未配置该节点 key 时留空)
// NODE_ID 节点 id(与中间件 wsKeys 键 / 前端配置 id 一致)
// NODE_KEY 注册 key(与中间件 wsKeys[节点id] 一致;中间件未配置该节点 key 时留空)
//
// 与 HTTP handler 共用同一批 webtest 探针函数和缓存:收到 probe 后按 apiType 取数(带缓存),
// 返回处理在本文件内完成,结果经 probe_result 上报。节点 HTTP 接口完全不变。