Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa9b83c951 | ||
|
|
c81f92bbd4 | ||
|
|
a411d0bb72 | ||
|
|
a93e74a8c7 | ||
|
|
4cf136b367 | ||
|
|
b350413902 | ||
|
|
08877871af | ||
|
|
d942ff0c5c | ||
|
|
5aaf30dc16 | ||
|
|
789968069b | ||
|
|
87fd2fd4df | ||
|
|
57b5824370 | ||
|
|
52796aa089 | ||
|
|
62b9f686e9 | ||
|
|
7e5bc92aea | ||
|
|
ee28aa2cd6 | ||
|
|
a95519428b | ||
|
|
aa65f23ac9 | ||
|
|
0dfbf7cbdf | ||
|
|
130ac61df0 | ||
|
|
e1a00236ee | ||
|
|
71ec93f068 | ||
|
|
c0f0640075 | ||
|
|
778ed2bd29 | ||
|
|
2ec182fb08 | ||
|
|
e8ec3757fd | ||
|
|
d398c4085c | ||
|
|
d04a90b20c | ||
|
|
92a8c1f308 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -22,3 +22,4 @@ docs/.vitepress/cache/
|
||||
docs/.vitepress/dist/
|
||||
tmp/
|
||||
setting.json
|
||||
*.BIN
|
||||
@@ -22,16 +22,18 @@ 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 # 一键安装脚本
|
||||
```
|
||||
## 友情链接
|
||||
### 来自社区的IPW版本
|
||||
[ZAKOFLARE/ipw-cn-php](https://github.com/ZAKOFLARE/ipw-cn-php) 一个基于PHP的IPW节点
|
||||
[ZAKOFLARE/ipw-cn-rust](https://github.com/ZAKOFLARE/ipw-cn-rust) 一个生锈的IPW节点[开发成本过高,已存档]
|
||||
[KFCV50TK/ipw-cn](https://github.com/KFCV50TK/ipw-cn) 一个有许多扩展功能的IPW分支
|
||||
|
||||
## 技术栈
|
||||
|
||||
|
||||
+26
-26
@@ -18,15 +18,15 @@
|
||||
```
|
||||
|
||||
- `port`:监听端口
|
||||
- `dns-server`:DNS 解析服务器,支持**主从 failover**——逗号分隔多地址(如 `"119.28.28.28:53,223.5.5.5:53"`),第一个为主,主查询失败(超时/网络错误)自动切换下一个;每项支持 `ip:port`(UDP)或 `https://` URL(DoH)
|
||||
- `dnssec-server`:**DNSSEC 专用** DNS 服务器(同样支持主从逗号分隔);留空则沿用 `dns-server`
|
||||
- `dns-server`:DNS 解析服务器,支持**主从 failover**——逗号分隔多地址(如 `"119.28.28.28:53,223.5.5.5:53"`),第一个为主,主查询失败(超时/网络错误)自动切换下一个;每项支持 `ip:port`(UDP)或 `https://` URL(DoH);**留空则启动时自动探测系统 DNS**(`IP:Port` 逗号分隔串)兜底
|
||||
- `dnssec-server`:**DNSSEC 专用** DNS 服务器(同样支持主从逗号分隔);留空则沿用 `dns-server`;`dns-server` 与 `dnssec-server` **都留空时自动探测系统 DNS** 兜底
|
||||
- `block-private-ips`:SSRF 防护开关
|
||||
- `ipdb`:IP 数据库开关(首次启动自动下载约 200MB,之后每 24h 更新)
|
||||
- `ipdb`:IP 数据库开关(首次启动自动下载约 450MB,之后每 24h 更新)
|
||||
- `cors`:允许的请求来源(逗号分隔)
|
||||
- `access-token`:API 访问令牌,留空则不启用鉴权
|
||||
- `ws-url`:WS 通道地址——接入独立中间件的 WebSocket 地址(如 `"wss://middleware-1.api-ipw.wsmdn.top/ws"`,须含 `wss://` 前缀与 `/ws` 路径);支持**逗号分隔多备**(第一个为主,主故障自动切换下一个);留空 = 不启用 WS 客户端,走原 HTTP 接口
|
||||
- `node-id`:WS 节点 id(与中间件 `apiKeys`/`wsKeys` 键、前端配置的节点 `id` 一致;建议用 UUID 唯一标识)
|
||||
- `node-key`:WS 注册 key(与中间件 `wsKeys[节点id]` 一致;**必填**——节点未配置该 key 时中间件拒绝注册并返回 401)
|
||||
- `ws-url`:WS 通道地址——接入独立中间件的 WebSocket 地址(如 `"wss://middleware-1.api-ipw.wsmdn.top/ws"`,须含 `wss://` 前缀与 `/ws` 路径);支持**逗号分隔多个中间件,同时连接全部(多活)**,任一断开只重连自己,不影响其他连接;留空 = 不启用 WS 客户端,走原 HTTP 接口
|
||||
- `node-id`:WS 节点 id(与中间件 `api-keys`/`ws-keys` 键、前端配置的节点 `id` 一致;建议用 UUID 唯一标识)
|
||||
- `node-key`:WS 注册 key(与中间件 `ws-keys[节点id]` 一致;**必填**——节点未配置该 key 时中间件拒绝注册并返回 401)
|
||||
|
||||
以上字段均可用环境变量覆盖(`PORTS` / `DNS_SERVER` / `DNSSEC_DNS_SERVER` / `BLOCK_PRIVATE_IPS` / `IPDB` / `CORS` / `ACCESS_TOKEN` / `WS_URL` / `NODE_ID` / `NODE_KEY`)。需要从远端拉取配置时,设置 `remote-config-url` 或环境变量 `REMOTE_CONFIG_URL`(优先级:远端 > 环境变量 > setting.json)。
|
||||
|
||||
@@ -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-url` | `WS_URL` | 中间件 WS 完整地址(含 `/ws` 路径),逗号分隔多备主备切换 |
|
||||
| 节点(客户端) | `node-id` | `NODE_ID` | 节点 id,与中间件 `apiKeys`/`wsKeys` 键一致(建议 UUID) |
|
||||
| 节点(客户端) | `node-key` | `NODE_KEY` | 注册 key,与中间件 `wsKeys[节点id]` 一致,**必填** |
|
||||
| 中间件(服务端) | `ws-keys` | `WS_KEYS` | WS 注册校验表:`{"<node-id>": "<key>"}`,节点 `register` 的 key 必须匹配 |
|
||||
| 节点(客户端) | `ws-url` | `WS_URL` | 中间件 WS 完整地址(含 `/ws` 路径),逗号分隔多个则**同时连接全部(多活)** |
|
||||
| 节点(客户端) | `node-id` | `NODE_ID` | 节点 id,与中间件 `api-keys`/`ws-keys` 键一致(建议 UUID) |
|
||||
| 节点(客户端) | `node-key` | `NODE_KEY` | 注册 key,与中间件 `ws-keys[节点id]` 一致,**必填** |
|
||||
|
||||
**链路**:中间件收到 `ws:true` 节点的拨测请求 → 经 WS 发 `probe` → 节点执行探针(直调 webtest 函数 + 缓存)→ `probe_result` 回传 → 中间件返回 HTTP 响应(`Content-Type: application/json`)。节点侧接入步骤见 [后端节点部署 - WS 通道接入](/guide/deploy-node#ws-通道接入可选)。
|
||||
|
||||
@@ -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 等敏感信息公之于众,建议放在私有仓库 / 带鉴权的对象存储 / 内部服务上
|
||||
- 常见失败排查:
|
||||
|
||||
@@ -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) |
|
||||
@@ -88,22 +88,22 @@ middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入
|
||||
|
||||
### 节点侧接入(WS 客户端)
|
||||
|
||||
1. 连接 `ws://<中间件IP>:8092/ws`(本仓库后端节点接入方式见 [后端节点部署 - WS 通道接入](/guide/deploy-node#ws-通道接入可选),配置 `WS_URL` / `NODE_ID` / `NODE_KEY` 即可自动连接注册)
|
||||
2. 首条消息发 `register { "nodeId": "<与中间件配置一致的节点 id>", "key": "<注册凭证>" }`(节点在 setting.json `wsKeys` 里配了 key 就必须传对;未配置 key 的开放节点可不传)
|
||||
1. 连接 `ws://<中间件IP>:8092/ws`(本仓库后端节点接入方式见 [后端节点部署 - WS 通道接入](/guide/deploy-node#ws-通道接入可选),配置 `WS_URL` / `NODE_ID` / `NODE_KEY` 即可自动连接注册;`WS_URL` 支持逗号分隔多个中间件地址,节点会**同时连接全部(多活)**,任一断开只重连自己)
|
||||
2. 首条消息发 `register { "nodeId": "<与中间件配置一致的节点 id>", "key": "<注册凭证>" }`(节点在 setting.json `ws-keys` 里配了 key 就必须传对;未配置 key 的开放节点可不传)
|
||||
3. 收到 `probe` → 执行拨测 → 回 `probe_result`(必须携带原 `requestId`,支持乱序返回,同一连接可并发多个拨测)
|
||||
4. 收到 `ping` → 回 `pong`;`status` 可忽略或记录
|
||||
5. 断线后重连并重新 `register` 即可
|
||||
|
||||
### 注册 key 校验
|
||||
|
||||
节点注册时中间件按 `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 转发
|
||||
+19
-19
@@ -4,25 +4,25 @@
|
||||
|
||||
## 方案一:EdgeOne
|
||||
|
||||
腾讯云 EdgeOne 支持 Go 边缘函数,点击下方按钮通过 **EdgeOne Makers** 一键部署 `edgeone/` 版本(无需本地构建):
|
||||
腾讯云 EdgeOne 支持 Go 边缘函数,点击下方按钮通过 **EdgeOne Makers** 一键部署 `serverless/edgeone/` 版本(无需本地构建):
|
||||
|
||||
[](https://console.cloud.tencent.com/edgeone/makers/new?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=edgeone)
|
||||
[](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 边缘函数版本):
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=edgeone%2Fcloud-functions)
|
||||
[](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,14 +46,14 @@ docker run -d -p 8080:8080 \
|
||||
## 方案四:二进制
|
||||
|
||||
```bash
|
||||
# 编译
|
||||
go build -o lemonipw main.go
|
||||
# 编译(Go 后端在 src/ 目录)
|
||||
cd src && go build -o lemonipw .
|
||||
|
||||
# 运行(与 setting.json 同目录)
|
||||
./lemonipw
|
||||
```
|
||||
|
||||
首次启动自动下载 IP 数据库(约 200MB),之后每 24 小时自动更新一次。需要守护运行时,可参考下方「方案五」生成的 systemd 服务,或手动创建 service 文件(`ExecStart` 指向二进制,`WorkingDirectory` 指向 `setting.json` 所在目录)。
|
||||
首次启动自动下载 IP 数据库(约 450MB),之后每 24 小时自动更新一次。需要守护运行时,可参考下方「方案五」生成的 systemd 服务,或手动创建 service 文件(`ExecStart` 指向二进制,`WorkingDirectory` 指向 `setting.json` 所在目录)。
|
||||
|
||||
## 方案五:一键安装(install.sh)
|
||||
|
||||
@@ -74,9 +74,9 @@ sudo bash install.sh
|
||||
| 安装目录 | `/opt/lemon-ipw` | 二进制与工作目录 |
|
||||
| 监听端口 | `8080` | `PORTS` |
|
||||
| access-token | 留空 | 留空 = 不启用鉴权 |
|
||||
| DNS 服务器 | `119.28.28.28:53` | 主从逗号分隔(`119.28.28.28:53,223.5.5.5:53`) |
|
||||
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server |
|
||||
| IP 数据库 | `Y` | `IPDB`,首次启动下载约 200MB |
|
||||
| DNS 服务器 | 留空 | 主从逗号分隔(`119.28.28.28:53,223.5.5.5:53`);留空 = 启动时自动探测系统 DNS |
|
||||
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server;都留空 = 自动探测系统 DNS |
|
||||
| IP 数据库 | `Y` | `IPDB`,首次启动下载约 450MB |
|
||||
| CORS | 留空 | 逗号分隔允许来源 |
|
||||
| 远端配置地址 | 留空 | `REMOTE_CONFIG_URL` |
|
||||
| WS 通道接入 | `N` | 选 `y` 后继续输入 WS_URL / NODE_ID / NODE_KEY |
|
||||
@@ -84,9 +84,9 @@ sudo bash install.sh
|
||||
|
||||
**WS 接入特殊规则**:
|
||||
|
||||
- `WS_URL` 留空时自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址时须为**完整路径**(含 `wss://` 前缀与 `/ws` 路径,如 `wss://host:8092/ws`),逗号分隔多备
|
||||
- `WS_URL` 留空时自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址时须为**完整路径**(含 `wss://` 前缀与 `/ws` 路径,如 `wss://host:8092/ws`),逗号分隔多个则**同时连接全部(多活)**
|
||||
- `NODE_ID` **强制自动生成 UUID**(无需输入,如 `4580ea9d-2a16-4b8e-8090-5f2e7f0a2229`),适合作为节点唯一标识
|
||||
- `NODE_KEY` **必填**:不加 key 禁止启用 WS(中间件 `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://主:8092/ws,ws://备:8092/ws`),第一个为主,主故障自动切换下一个
|
||||
- `WS_URL` 支持**逗号分隔多个中间件**(`ws://中间件1:8092/ws,ws://中间件2:8092/ws`),**同时连接全部(多活)**:每个地址独立注册并保持连接,任一断开只重连自己,不影响其他中间件;注意多个地址指向同一中间件实例时,同 `nodeId` 后注册的连接会顶掉先前的
|
||||
- **双向心跳**:节点每 10s 发 ping,中间件回 pong;心跳发送连续失败 3 次判定中间件不可用,断开后 3s 重试(注册被拒 30s 重试)
|
||||
- 收到 `probe` 后节点直调探针函数(带缓存)并回 `probe_result`,结果与 HTTP 通道一致
|
||||
- 节点注册带 `NODE_KEY`:中间件 `wsKeys` 里配了 key 就必须传对,否则返回 401 并断开;未配置 key 的节点开放注册
|
||||
- 节点注册带 `NODE_KEY`:中间件 `ws-keys` 里配了 key 就必须传对,否则返回 401 并断开;未配置 key 的节点开放注册
|
||||
|
||||
**注意**:`NODE_ID` 必须与中间件 `APIBaseURL` / `IPLocationAPI` 池中的节点 `id` 一致,且该节点需配置 `"ws": true` 才会走 WS 通道。未连接中间件时,`ws:true` 节点的拨测会返回 502。
|
||||
@@ -38,9 +38,9 @@ sudo bash install.sh
|
||||
| 安装目录 | `/opt/lemon-ipw` | 二进制与工作目录 |
|
||||
| 监听端口 | `8080` | 后端 HTTP 端口 |
|
||||
| access-token | 留空 | 留空 = 不启用鉴权 |
|
||||
| DNS 服务器 | `119.28.28.28:53` | 主从逗号分隔(如 `119.28.28.28:53,223.5.5.5:53`) |
|
||||
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server |
|
||||
| IP 数据库 | `Y` | 首次启动下载约 200MB |
|
||||
| DNS 服务器 | 留空 | 主从逗号分隔(如 `119.28.28.28:53,223.5.5.5:53`);留空 = 启动时自动探测系统 DNS |
|
||||
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server;都留空 = 自动探测系统 DNS |
|
||||
| IP 数据库 | `Y` | 首次启动下载约 450MB |
|
||||
| CORS | 留空 | 逗号分隔允许来源 |
|
||||
| 远端配置地址 | 留空 | `REMOTE_CONFIG_URL` |
|
||||
| WS 通道接入 | `N` | 选 `y` 后输入 WS_URL / NODE_ID / NODE_KEY |
|
||||
@@ -48,9 +48,9 @@ sudo bash install.sh
|
||||
|
||||
**WS 通道接入规则**(选 `y` 时):
|
||||
|
||||
- `WS_URL` 留空自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址须为完整路径(`wss://host:8092/ws` 形式)
|
||||
- `WS_URL` 留空自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址须为完整路径(`wss://host:8092/ws` 形式);逗号分隔多个则**同时连接全部(多活)**
|
||||
- `NODE_ID` **强制自动生成 UUID**(无需输入)
|
||||
- `NODE_KEY` **必填**:不加 key 禁止启用 WS;安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 `wsKeys`
|
||||
- `NODE_KEY` **必填**:不加 key 禁止启用 WS;安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 `ws-keys`
|
||||
|
||||
### 验证安装
|
||||
|
||||
|
||||
+1
-1
@@ -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 附带配置文件) |
|
||||
@@ -10,10 +10,10 @@ const route = useRoute()
|
||||
useHead({
|
||||
title: `ASN查询 | IP自治系统号查询 | ${config.siteName}`,
|
||||
meta: [
|
||||
{ name: 'description', content: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP双数据源对比,同时集成WHOIS解析获取ASN详细信息,包括组织名称、国家、注册日期等,助力网络运维和路由分析' },
|
||||
{ name: 'description', content: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP、IP2Location 多数据源对比,同时集成WHOIS解析获取ASN详细信息,包括组织名称、国家、注册日期等,助力网络运维和路由分析' },
|
||||
{ name: 'keywords', content: 'asn查询,自治系统号,asn lookup,ip asn,asn whois,网络自治系统,运营商asn,ip归属asn' },
|
||||
{ property: 'og:title', content: 'ASN自治系统查询工具 - IP ASN号与组织信息查询' },
|
||||
{ property: 'og:description', content: '多数据源ASN查询,支持Maxmind GEOLite2和DB-IP,集成WHOIS解析获取ASN详细信息' },
|
||||
{ property: 'og:description', content: '多数据源ASN查询,支持Maxmind GEOLite2、DB-IP、IP2Location,集成WHOIS解析获取ASN详细信息' },
|
||||
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
],
|
||||
@@ -24,7 +24,7 @@ useHead({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebApplication',
|
||||
name: 'ASN自治系统查询工具',
|
||||
description: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP双数据源对比,集成WHOIS解析。',
|
||||
description: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP、IP2Location 多数据源对比,集成WHOIS解析。',
|
||||
url: config.siteUrl + 'asn',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
operatingSystem: 'Web',
|
||||
@@ -46,6 +46,7 @@ interface ASNResult {
|
||||
ip: string
|
||||
geolite2_asn?: { asn: string; org: string }
|
||||
dbip_asn?: { asn: string; org: string }
|
||||
ip2location_asn?: { asn: string; as: string }
|
||||
whois?: any
|
||||
}
|
||||
|
||||
@@ -185,6 +186,13 @@ function getStatusClass(status: string): string {
|
||||
<span v-if="result.dbip_asn.org" style="color: #999; font-size: 0.9em;">{{ result.dbip_asn.org }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="result.ip2location_asn?.asn">
|
||||
<td class="table-label">IP2Location ASN</td>
|
||||
<td class="table-value">
|
||||
AS{{ result.ip2location_asn.asn }}
|
||||
<span v-if="result.ip2location_asn.as" style="color: #999; font-size: 0.9em;">{{ result.ip2location_asn.as }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="result.whois?.asNumber">
|
||||
<td class="table-label">ASN</td>
|
||||
<td class="table-value">AS{{ result.whois.asNumber }}</td>
|
||||
|
||||
+12
-383
@@ -1,396 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick, watch } from 'vue';
|
||||
import { isIPv6 } from 'is-ip';
|
||||
import {config} from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
|
||||
import { useRoute } from 'vue-router'
|
||||
import { highlightCode } from '../../utils/shiki'
|
||||
import { isIPv4 } from '../../utils/tools';
|
||||
import { onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
|
||||
useHead({
|
||||
title: `IPv6/IPv4地址查询工具 | IP归属地定位 | ${config.siteName}`,
|
||||
meta: [
|
||||
{ name: 'description', content: '专业的IPv6/IPv4地址查询工具,支持IPv4和IPv6地址归属地查询,提供BiliBili Live、GeoCN、IP2Region、Maxmind等多种数据源对比,精确定位IP地理位置、运营商信息,支持中国大陆及境外地址查询,助力IPv6普及与应用' },
|
||||
{ property: 'og:title', content: `IPv6/IPv4地址归属地查询工具 - ${config.siteName}` },
|
||||
{ property: 'og:description', content: '多数据源IP地址归属地查询,支持IPv4和IPv6,提供地理位置、运营商等详细信息' },
|
||||
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
],
|
||||
});
|
||||
interface IPLocationType {
|
||||
bilibili?: any;
|
||||
ip?: string;
|
||||
geocn?: any;
|
||||
ip2region?: any;
|
||||
maxmind_asn?: any;
|
||||
maxmind_city?: any;
|
||||
qqwry?: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
const code = `
|
||||
# 请勿用于商业用途,仅供个人测试学习之用,请遵守中国法律法规
|
||||
# 查询本机外网 IPv4 地址
|
||||
curl ${config.v4OnlyAPI}
|
||||
|
||||
# 查询本机外网 IPv6 地址
|
||||
curl ${config.v6OnlyAPI}
|
||||
|
||||
# 测试网络是 IPv4 还是 IPv6 访问优先
|
||||
# (访问 IPv4/IPv6 双栈站点,如果返回 IPv6 地址,则 IPv6 访问优先)
|
||||
curl ${config.DualStackAPI}
|
||||
`.trim();
|
||||
const html = ref('');
|
||||
const apiList = config.IPLocationAPI
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]!.id)
|
||||
const ipAddress = ref('');
|
||||
const IPLocation = ref<IPLocationType>({});
|
||||
const UserIP = ref('');
|
||||
const locationUrl = computed(() => "/middleware/" + backendID.value + "/location/" + ipAddress.value);
|
||||
|
||||
const { data: locationData, error: locationError, execute: executeLocation } = useMiddlewareFetch<IPLocationType>(locationUrl, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
|
||||
watch(locationData, (newData) => {
|
||||
if (newData) {
|
||||
IPLocation.value = newData;
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
watch(locationError, (newError) => {
|
||||
if (newError) {
|
||||
console.error('Error fetching IP location:', newError);
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function locateIP(IP: string){
|
||||
ipAddress.value = IP;
|
||||
loading.value = true;
|
||||
nextTick(() => {
|
||||
executeLocation();
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
const urlParam = route.query.ip;
|
||||
await navigateTo({ path: '/location', query: { ip: urlParam } });
|
||||
html.value = await highlightCode(code, 'bash')
|
||||
if (urlParam) {
|
||||
ipAddress.value = urlParam as string;
|
||||
locateIP(urlParam as string);
|
||||
}else{
|
||||
$fetch<string>(config.DualStackAPI).then(
|
||||
function(data) {
|
||||
ipAddress.value = data;
|
||||
|
||||
UserIP.value = data;
|
||||
}
|
||||
).catch(
|
||||
function(error) {
|
||||
console.error('Error fetching IP address:', error);
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="title">
|
||||
<header>
|
||||
<h1>IPv6/IPv4地址归属地查询</h1>
|
||||
<p>极简的IPv6地址查询工具,致力于普及 IPv6</p>
|
||||
</header>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="ipAddress"
|
||||
placeholder="请输入IP地址"
|
||||
/>
|
||||
<el-button
|
||||
@click="locateIP(ipAddress)"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
|
||||
>
|
||||
查询
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="location">
|
||||
<div class="one-line" style="height: 40px;">
|
||||
<b>IP</b> <p>{{ ipAddress }}</p>
|
||||
</div>
|
||||
<div v-if="IPLocation" class="result-section">
|
||||
<table class="result-table">
|
||||
<tbody>
|
||||
<tr v-if="IPLocation.bilibili && (IPLocation.bilibili.administrative_area || IPLocation.bilibili.city)">
|
||||
<td class="table-label">bilibili Live接口</td>
|
||||
<td class="table-value">
|
||||
<span>
|
||||
{{ IPLocation.bilibili?.country }} {{ IPLocation.bilibili?.administrative_area }} {{ IPLocation.bilibili?.city }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="table-value">
|
||||
<span>
|
||||
{{ IPLocation.bilibili?.isp }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.ip2region && IPLocation.ip2region.split('|').length >= 4">
|
||||
<td class="table-label">IP2Region</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.split("|")[0] }} {{ IPLocation.ip2region?.split("|")[1] }} {{ IPLocation.ip2region?.split("|")[2] }}</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.split("|")[3] }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.geocn && (IPLocation.geocn.administrative_area || IPLocation.geocn.city || IPLocation.geocn.district)">
|
||||
<td class="table-label">GeoCN(仅中国大陆)</td>
|
||||
<td class="table-value">{{ IPLocation.geocn?.administrative_area }} {{ IPLocation.geocn?.city }} {{ IPLocation.geocn?.district }}</td>
|
||||
<td class="table-value">{{ IPLocation.geocn?.isp }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.maxmind_city && IPLocation.maxmind_asn && (IPLocation.maxmind_city.country || IPLocation.maxmind_city.city)">
|
||||
<td class="table-label">Maxmind GEOLite2 City</td>
|
||||
<td class="table-value">{{ IPLocation.maxmind_city?.country }} {{ IPLocation.maxmind_city?.administrative_area }} {{ IPLocation.maxmind_city?.city }}</td>
|
||||
<td class="table-value">{{ IPLocation.maxmind_asn?.org }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.qqwry && (IPLocation.qqwry.country || IPLocation.qqwry.administrative_area || IPLocation.qqwry.city)">
|
||||
<td class="table-label">纯真社区库</td>
|
||||
<td class="table-value">{{ IPLocation.qqwry?.country }} {{ IPLocation.qqwry?.administrative_area }} {{ IPLocation.qqwry?.city }}</td>
|
||||
<td class="table-value">{{ IPLocation.qqwry?.isp }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.dbip_city && (IPLocation.dbip_city.administrative_area || IPLocation.dbip_city.city)">
|
||||
<td class="table-label">DB-IP City</td>
|
||||
<td class="table-value">{{ IPLocation.dbip_city?.country }} {{ IPLocation.dbip_city?.administrative_area }} {{ IPLocation.dbip_city?.city }}</td>
|
||||
<td class="table-value">{{IPLocation.dbip_asn?.org || '--'}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 1.5em;">
|
||||
<h3 v-if="isIPv6(UserIP)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h3>
|
||||
<h3 v-else-if="isIPv4(UserIP)"><el-icon><CircleCloseFilled style="color: red;"/></el-icon>您的网络IPv4优先</h3>
|
||||
</div>
|
||||
<blockquote>
|
||||
精度参照表:<br>
|
||||
中国大陆:BiliBili Live > GeoCN > IP2Region > 纯真社区库 > Maxmind GEOLite2 City ≈ DB-IP<br>
|
||||
中国大陆 IPv6 地址: BiliBili Live > GeoCN > IP2Region > Maxmind GEOLite2 City ≈ DB-IP > 纯真社区库<br>
|
||||
境外及港澳台地址: Maxmind GEOLite2 City ≈ DB-IP > BiliBili Live > > IP2Region > 纯真社区库<br>
|
||||
|
||||
手机默认开启 IPv6,宽带开启 IPv6 请自行搜索<br>
|
||||
访客IP: {{UserIP}},<p v-if="isIPv4(UserIP)">您的网络IPv4优先</p><p v-else-if="isIPv6(UserIP)">您的网络IPv6优先</p>
|
||||
</blockquote>
|
||||
|
||||
<div v-html="html"></div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="redirecting">正在跳转到 IP 归属地查询…</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../style.css";
|
||||
@import "../../assets/css/tool-common.css";
|
||||
</style>
|
||||
<style>
|
||||
@import "../style.css";
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.el-menu--horizontal > .el-menu-item:nth-child(1) {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
width: 420px;
|
||||
height: 50px;
|
||||
font: 1.3em sans-serif;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
width: 165px;
|
||||
height: 50px;
|
||||
font: 1.3em sans-serif;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.result-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
html.dark .result-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #2e2d2d;
|
||||
border: 1px solid #2e2e2e;
|
||||
}
|
||||
.result-table thead tr {
|
||||
background-color: #c0c4cc;
|
||||
}
|
||||
html.dark .result-table thead tr {
|
||||
background: #2e2d2d;
|
||||
}
|
||||
|
||||
.result-table .table-header {
|
||||
padding: 12px 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
text-align: left;
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
html.dark .result-table .table-header {
|
||||
padding: 12px 15px;
|
||||
font-weight: 600;
|
||||
color: #cfcfcf;
|
||||
text-align: left;
|
||||
border: 1px solid #1a1919;
|
||||
}
|
||||
|
||||
.result-table tbody tr {
|
||||
border-bottom: 1px solid #dcdfe6;
|
||||
}
|
||||
html.dark .result-table tbody tr {
|
||||
border-bottom: 1px solid #1a1919;
|
||||
}
|
||||
|
||||
|
||||
.result-table tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.result-table tbody tr:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
html.dark .result-table tbody tr:hover {
|
||||
background-color: #393a3a;
|
||||
}
|
||||
|
||||
.result-table .table-label {
|
||||
padding: 12px 15px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
width: 150px;
|
||||
text-align: left;
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
html.dark .result-table .table-label {
|
||||
color: #c0c4cc;
|
||||
border: 1px solid #1a1919;
|
||||
}
|
||||
|
||||
.result-table .table-value {
|
||||
padding: 12px 15px;
|
||||
color: #303133;
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
html.dark .result-table .table-value {
|
||||
color: #cfcfcf;
|
||||
border: 1px solid #1a1919;
|
||||
}
|
||||
.valid {
|
||||
color: #67C23A;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.expired {
|
||||
color: #F56C6C;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-code {
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
color: #67C23A;
|
||||
background: #f0f9eb;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
color: #E6A23C;
|
||||
background: #fdf6ec;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #F56C6C;
|
||||
background: #fef0f0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #fef0f0;
|
||||
color: #F56C6C;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
pre code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 0.9em;
|
||||
color: #303133;
|
||||
}
|
||||
html.dark pre {
|
||||
background: #303133;
|
||||
}
|
||||
|
||||
html.dark pre code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.badge-section {
|
||||
.redirecting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 50vh;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.badge-item {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.badge-item h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #3EAF7C;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.badge-item img {
|
||||
display: block;
|
||||
margin-bottom: 15px;
|
||||
max-width: 200px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -275,6 +275,12 @@ onMounted(() => {
|
||||
<h2>...</h2>
|
||||
<el-image src="/jingya.jpg"></el-image>
|
||||
</div>
|
||||
<div v-else-if="result && result.ipv4 && result.ipv6 && !result.ipv4.is_reachable && result.ipv6.is_reachable">
|
||||
<h3>结论:<CircleCloseFilled style="width: 1.3em;color: red;"/>网站{{ extractHost(testDomain) }} 不支持IPv4访问 </h3>
|
||||
<h2>全球来看,越南、捷克等部分国家已提出了单栈发展目标。尽管我国网络IPv6流量占比大幅提升,但网络中仍存在大量的NAT(网络地址转换)设备和IPv4依赖应用。</h2>
|
||||
<h2 style="text-align: right;">----中国《IPv6发展报告》2026</h2>
|
||||
<el-image src="/jingya.jpg"></el-image>
|
||||
</div>
|
||||
<blockquote>
|
||||
网站不支持 IPv6可能原因:<br/>
|
||||
<br/>
|
||||
|
||||
@@ -49,6 +49,8 @@ interface IPLocationType {
|
||||
ip?: string;
|
||||
geocn?: any;
|
||||
ip2region?: any;
|
||||
ip2location?: any;
|
||||
ip2location_asn?: any;
|
||||
maxmind_asn?: any;
|
||||
maxmind_city?: any;
|
||||
qqwry?: any;
|
||||
@@ -182,10 +184,15 @@ onMounted(async () => {
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.ip2region && IPLocation.ip2region.split('|').length >= 4">
|
||||
<tr v-if="IPLocation.ip2region && typeof IPLocation.ip2region === 'object' && (IPLocation.ip2region.administrative_area || IPLocation.ip2region.city)">
|
||||
<td class="table-label">IP2Region</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.split("|")[0] }} {{ IPLocation.ip2region?.split("|")[1] }} {{ IPLocation.ip2region?.split("|")[2] }}</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.split("|")[3] }}</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.country }} {{ IPLocation.ip2region?.administrative_area }} {{ IPLocation.ip2region?.city }}</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.isp }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.ip2location && typeof IPLocation.ip2location === 'object' && (IPLocation.ip2location.administrative_area || IPLocation.ip2location.city)">
|
||||
<td class="table-label">IP2Location</td>
|
||||
<td class="table-value">{{ IPLocation.ip2location?.country }} {{ IPLocation.ip2location?.administrative_area }} {{ IPLocation.ip2location?.city }}</td>
|
||||
<td class="table-value">{{ IPLocation.ip2location_asn?.as }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.geocn && (IPLocation.geocn.administrative_area || IPLocation.geocn.city || IPLocation.geocn.district)">
|
||||
<td class="table-label">GeoCN(仅中国大陆)</td>
|
||||
|
||||
@@ -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",
|
||||
@@ -105,16 +110,6 @@ const config = {
|
||||
id: "shiyan",
|
||||
url: ""
|
||||
},
|
||||
{
|
||||
label: "香港 VpsQuan",
|
||||
id: "hongkong2",
|
||||
url: ""
|
||||
},
|
||||
{
|
||||
label: "北京 京东云 BGP",
|
||||
id: "jdcloud",
|
||||
url: ""
|
||||
},
|
||||
{
|
||||
label: "陕西 西安二 电信",
|
||||
id: "xian2",
|
||||
@@ -124,9 +119,16 @@ const config = {
|
||||
label: "香港 Cogent",
|
||||
id: "hongkong",
|
||||
url: ""
|
||||
},
|
||||
// WS 通道节点:URL 留空,拨测经中间件 WS 转发
|
||||
{
|
||||
label: "呼和浩特移动",
|
||||
id: "2faa15e6-18a7-4355-b422-ca53277d8d77",
|
||||
url: ""
|
||||
}
|
||||
],
|
||||
DualStack: [
|
||||
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
id: "cn-jiangsu",
|
||||
@@ -137,6 +139,11 @@ const config = {
|
||||
id: "cn-shenzhen",
|
||||
url: "https://cn-shenzhen.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 湖北 武汉 电信",
|
||||
id: "cn-wuhan-chinatelecom",
|
||||
url: ""
|
||||
},
|
||||
// IP 直连的节点:URL 留空,仅经独立中间件转发
|
||||
{
|
||||
label: "上海 腾讯云 BGP",
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { defineEventHandler, createError, setResponseStatus, setResponseHeader, getRequestHeader, getRequestIP } from 'h3'
|
||||
import { config } from '../../../config/index'
|
||||
import { useMiddlewareFetch } from '../../../utils/middleware'
|
||||
|
||||
// GET /ip/<ip> → text/plain 输出指定 IP 的归属地(支持 IPv4 / IPv6,如 /ip/2001:db8::1)
|
||||
//
|
||||
// IP: 2001:db8::1
|
||||
// bilibili: 中国 江苏 南京 电信
|
||||
// geocn: 江苏省 南京市 江宁区 电信 宽带
|
||||
// ip2region: 中国 江苏 南京 电信
|
||||
// maxmind_city: 中国 江苏 南京
|
||||
// maxmind_asn: AS4134 中国电信
|
||||
// qqwry: 中国 江苏省 南京市 电信
|
||||
//
|
||||
// 复用 utils/middleware.ts 的 useMiddlewareFetch():候选依次为 config.Middleware 外部中间层 + 前端自带中间件兜底,
|
||||
// 仅当节点无法连接或上游 401/403/418/502 时切换;IPLocationAPI 配置了多个后端时逐个重试。
|
||||
|
||||
// ---- 简单限流(每 IP 每分钟次数上限,进程内内存计数;limit 0 表示不限流) ----
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
function checkRateLimit(ip: string, limit: number): boolean {
|
||||
const now = Date.now()
|
||||
const entry = rateLimitMap.get(ip)
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 })
|
||||
// 顺带清理过期条目,避免 Map 无限增长
|
||||
if (rateLimitMap.size > 1000) {
|
||||
for (const [k, v] of rateLimitMap) {
|
||||
if (v.resetAt <= now) rateLimitMap.delete(k)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
entry.count++
|
||||
return entry.count <= limit
|
||||
}
|
||||
|
||||
// ---- 文本美化:把单个数据源的归属地对象拼成可读字符串 ----
|
||||
function objectToText(obj: any): string {
|
||||
const parts: string[] = []
|
||||
// 位置字段(按国家 → 省/区域 → 城市 → 区县)
|
||||
for (const k of ['country', 'administrative_area', 'region', 'city', 'district', 'province']) {
|
||||
const v = obj[k]
|
||||
if (typeof v === 'string' && v) parts.push(v)
|
||||
}
|
||||
// ASN 编号(如 "AS4134")
|
||||
if (typeof obj.asn === 'string' && obj.asn) {
|
||||
parts.push(obj.asn.startsWith('AS') ? obj.asn : `AS${obj.asn}`)
|
||||
}
|
||||
// 运营商 / 组织 / 类型
|
||||
for (const k of ['isp', 'org', 'type']) {
|
||||
const v = obj[k]
|
||||
if (typeof v === 'string' && v) parts.push(v)
|
||||
}
|
||||
return [...new Set(parts)].join(' ')
|
||||
}
|
||||
|
||||
// ---- 文本美化:多数据源逐行输出(跳过后端未加载的库) ----
|
||||
function formatLocationText(data: any, ip: string): string {
|
||||
const lines: string[] = [`IP: ${ip}`]
|
||||
const sources: Array<[string, any]> = [
|
||||
['bilibili', data?.bilibili],
|
||||
['geocn', data?.geocn],
|
||||
['ip2region', data?.ip2region],
|
||||
['ip2location', data?.ip2location],
|
||||
['maxmind_city', data?.maxmind_city],
|
||||
['maxmind_asn', data?.maxmind_asn],
|
||||
['dbip_city', data?.dbip_city],
|
||||
['dbip_asn', data?.dbip_asn],
|
||||
['qqwry', data?.qqwry],
|
||||
]
|
||||
for (const [name, value] of sources) {
|
||||
if (value === undefined || value === null) continue
|
||||
if (typeof value === 'string') {
|
||||
// 纯字符串结果(error 等)原样输出,"not loaded" 跳过
|
||||
if (value && value !== 'not loaded') {
|
||||
lines.push(`${name}: ${value}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const text = objectToText(value)
|
||||
if (text) lines.push(`${name}: ${text}`)
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ---- 核心:查询指定 IP 的归属地(IPLocationAPI 多后端逐个重试) ----
|
||||
async function locate(event: any, ip: string) {
|
||||
const backends = config.IPLocationAPI
|
||||
if (!backends || backends.length === 0) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'No location backend configured' })
|
||||
}
|
||||
let lastError: any = null
|
||||
for (const backend of backends) {
|
||||
const path = `/middleware/${backend.id}/location/${ip}`
|
||||
const { data, error, execute } = useMiddlewareFetch(path)
|
||||
await execute()
|
||||
if (error.value) {
|
||||
lastError = error.value
|
||||
console.error(`[ip] backend "${backend.label}" failed for ${ip}:`, error.value?.message || error.value)
|
||||
continue
|
||||
}
|
||||
// 成功:location 响应必含 ip 字段;缺失视为失败(如误返回错误体),换下一个后端
|
||||
const res = data.value
|
||||
if (res && typeof res === 'object' && (res as any).ip) {
|
||||
return formatLocationText(res, ip)
|
||||
}
|
||||
lastError = new Error('invalid location response')
|
||||
}
|
||||
|
||||
// 全部后端失败:透传最后一次错误(text/plain)
|
||||
const errStatus = lastError?.status ?? lastError?.statusCode ?? 502
|
||||
const status = typeof errStatus === 'number' ? errStatus : 502
|
||||
setResponseStatus(event, status)
|
||||
return `Error ${status}: ${lastError?.message || 'Backend unreachable'}`
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 0) 统一输出 text/plain(不是 JSON)
|
||||
setResponseHeader(event, 'content-type', 'text/plain; charset=utf-8')
|
||||
|
||||
// 1) Origin / Referer 校验:跨域浏览器调用一律拒绝
|
||||
const siteOrigin = new URL(config.siteUrl).origin
|
||||
const origin = getRequestHeader(event, 'origin')
|
||||
const referer = getRequestHeader(event, 'referer')
|
||||
if (origin && origin !== siteOrigin) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: cross-origin request' })
|
||||
}
|
||||
if (referer && referer !== siteOrigin && !referer.startsWith(siteOrigin + '/')) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: invalid referer' })
|
||||
}
|
||||
|
||||
// 2) 每 IP 每分钟限流(次数取 config.rateLimitPerMinute,0 表示不限流)
|
||||
const rateLimit = config.rateLimitPerMinute || 0
|
||||
if (rateLimit > 0 && !checkRateLimit(getRequestIP(event) || 'unknown', rateLimit)) {
|
||||
throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
|
||||
}
|
||||
|
||||
// 3) 取路径参数 IP
|
||||
const ipParam: string = event.context.params?.ip as any
|
||||
let ip = ipParam ? ipParam.trim() : ''
|
||||
if (!ip) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing IP parameter' })
|
||||
}
|
||||
|
||||
return locate(event, ip)
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { defineEventHandler, getQuery, createError, setResponseStatus, setResponseHeader, getRequestHeader, getRequestIP } from 'h3'
|
||||
import { config } from '../../../config/index'
|
||||
import { useMiddlewareFetch } from '../../../utils/middleware'
|
||||
|
||||
// GET /ip → text/plain 输出 IP 归属地(?ip=<ip> 优先,否则查请求方 IP)
|
||||
//
|
||||
// IP: 1.2.3.4
|
||||
// bilibili: 中国 江苏 南京 电信
|
||||
// geocn: 江苏省 南京市 江宁区 电信 宽带
|
||||
// ip2region: 中国 江苏 南京 电信
|
||||
// maxmind_city: 中国 江苏 南京
|
||||
// maxmind_asn: AS4134 中国电信
|
||||
// qqwry: 中国 江苏省 南京市 电信
|
||||
//
|
||||
// 复用 utils/middleware.ts 的 useMiddlewareFetch():候选依次为 config.Middleware 外部中间层 + 前端自带中间件兜底,
|
||||
// 仅当节点无法连接或上游 401/403/418/502 时切换;IPLocationAPI 配置了多个后端时逐个重试。
|
||||
|
||||
// ---- 简单限流(每 IP 每分钟次数上限,进程内内存计数;limit 0 表示不限流) ----
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
function checkRateLimit(ip: string, limit: number): boolean {
|
||||
const now = Date.now()
|
||||
const entry = rateLimitMap.get(ip)
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 })
|
||||
// 顺带清理过期条目,避免 Map 无限增长
|
||||
if (rateLimitMap.size > 1000) {
|
||||
for (const [k, v] of rateLimitMap) {
|
||||
if (v.resetAt <= now) rateLimitMap.delete(k)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
entry.count++
|
||||
return entry.count <= limit
|
||||
}
|
||||
|
||||
// ---- 文本美化:把单个数据源的归属地对象拼成可读字符串 ----
|
||||
function objectToText(obj: any): string {
|
||||
const parts: string[] = []
|
||||
// 位置字段(按国家 → 省/区域 → 城市 → 区县)
|
||||
for (const k of ['country', 'administrative_area', 'region', 'city', 'district', 'province']) {
|
||||
const v = obj[k]
|
||||
if (typeof v === 'string' && v) parts.push(v)
|
||||
}
|
||||
// ASN 编号(如 "AS4134")
|
||||
if (typeof obj.asn === 'string' && obj.asn) {
|
||||
parts.push(obj.asn.startsWith('AS') ? obj.asn : `AS${obj.asn}`)
|
||||
}
|
||||
// 运营商 / 组织 / 类型
|
||||
for (const k of ['isp', 'org', 'type']) {
|
||||
const v = obj[k]
|
||||
if (typeof v === 'string' && v) parts.push(v)
|
||||
}
|
||||
return [...new Set(parts)].join(' ')
|
||||
}
|
||||
|
||||
// ---- 文本美化:多数据源逐行输出(跳过后端未加载的库) ----
|
||||
function formatLocationText(data: any, ip: string): string {
|
||||
const lines: string[] = [`IP: ${ip}`]
|
||||
const sources: Array<[string, any]> = [
|
||||
['bilibili', data?.bilibili],
|
||||
['geocn', data?.geocn],
|
||||
['ip2region', data?.ip2region],
|
||||
['ip2location', data?.ip2location],
|
||||
['maxmind_city', data?.maxmind_city],
|
||||
['maxmind_asn', data?.maxmind_asn],
|
||||
['dbip_city', data?.dbip_city],
|
||||
['dbip_asn', data?.dbip_asn],
|
||||
['qqwry', data?.qqwry],
|
||||
]
|
||||
for (const [name, value] of sources) {
|
||||
if (value === undefined || value === null) continue
|
||||
if (typeof value === 'string') {
|
||||
// 纯字符串结果(error 等)原样输出,"not loaded" 跳过
|
||||
if (value && value !== 'not loaded') {
|
||||
lines.push(`${name}: ${value}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const text = objectToText(value)
|
||||
if (text) lines.push(`${name}: ${text}`)
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ---- 核心:查询指定 IP 的归属地(IPLocationAPI 多后端逐个重试) ----
|
||||
async function locate(event: any, ip: string) {
|
||||
const backends = config.IPLocationAPI
|
||||
if (!backends || backends.length === 0) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'No location backend configured' })
|
||||
}
|
||||
let lastError: any = null
|
||||
for (const backend of backends) {
|
||||
const path = `/middleware/${backend.id}/location/${ip}`
|
||||
const { data, error, execute } = useMiddlewareFetch(path)
|
||||
await execute()
|
||||
if (error.value) {
|
||||
lastError = error.value
|
||||
console.error(`[ip] backend "${backend.label}" failed for ${ip}:`, error.value?.message || error.value)
|
||||
continue
|
||||
}
|
||||
// 成功:location 响应必含 ip 字段;缺失视为失败(如误返回错误体),换下一个后端
|
||||
const res = data.value
|
||||
if (res && typeof res === 'object' && (res as any).ip) {
|
||||
return formatLocationText(res, ip)
|
||||
}
|
||||
lastError = new Error('invalid location response')
|
||||
}
|
||||
|
||||
// 全部后端失败:透传最后一次错误(text/plain)
|
||||
const errStatus = lastError?.status ?? lastError?.statusCode ?? 502
|
||||
const status = typeof errStatus === 'number' ? errStatus : 502
|
||||
setResponseStatus(event, status)
|
||||
return `Error ${status}: ${lastError?.message || 'Backend unreachable'}`
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 0) 统一输出 text/plain(不是 JSON)
|
||||
setResponseHeader(event, 'content-type', 'text/plain; charset=utf-8')
|
||||
|
||||
// 1) Origin / Referer 校验:跨域浏览器调用一律拒绝
|
||||
const siteOrigin = new URL(config.siteUrl).origin
|
||||
const origin = getRequestHeader(event, 'origin')
|
||||
const referer = getRequestHeader(event, 'referer')
|
||||
if (origin && origin !== siteOrigin) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: cross-origin request' })
|
||||
}
|
||||
if (referer && referer !== siteOrigin && !referer.startsWith(siteOrigin + '/')) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: invalid referer' })
|
||||
}
|
||||
|
||||
// 2) 每 IP 每分钟限流(次数取 config.rateLimitPerMinute,0 表示不限流)
|
||||
const rateLimit = config.rateLimitPerMinute || 0
|
||||
if (rateLimit > 0 && !checkRateLimit(getRequestIP(event) || 'unknown', rateLimit)) {
|
||||
throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
|
||||
}
|
||||
|
||||
// 3) 确定要查询的 IP:?ip=<ip> 优先,否则查请求方 IP
|
||||
const query = getQuery(event)
|
||||
let ip = typeof query.ip === 'string' ? query.ip.trim() : ''
|
||||
if (!ip) {
|
||||
ip = getRequestIP(event, { xForwardedFor: true }) || ''
|
||||
}
|
||||
ip = ip.trim()
|
||||
if (!ip) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Unable to determine IP address' })
|
||||
}
|
||||
|
||||
return locate(event, ip)
|
||||
})
|
||||
@@ -64,9 +64,12 @@ export function useMiddlewareFetch<T = any>(url: MaybeRefOrGetter<string>, optio
|
||||
try {
|
||||
for (let i = 0; i < candidates.value.length; i++) {
|
||||
if (id !== requestId) return undefined // 已被更新的请求取代
|
||||
const candidate = candidates.value[i]
|
||||
// noUncheckedIndexedAccess 下索引访问为 string | undefined,防御性跳过
|
||||
if (candidate === undefined) continue
|
||||
try {
|
||||
// $fetch 为 Nuxt 全局注入;候选失败会抛 FetchError(含 status)
|
||||
const res = await $fetch<T>(candidates.value[i], fetchOptions)
|
||||
const res = await $fetch<T>(candidate, fetchOptions)
|
||||
if (id !== requestId) return undefined
|
||||
data.value = res
|
||||
return res
|
||||
|
||||
+3
-3
@@ -102,7 +102,7 @@ NODE_KEY=""
|
||||
WS_USED_DEFAULT=""
|
||||
case "${WS_CHOICE,,}" in
|
||||
y|yes)
|
||||
read -r -p " 中间件 WS 完整地址(含 wss:// 前缀与 /ws 路径,如 wss://host:8092/ws;留空=默认 wss://middleware-1.api-ipw.wsmdn.top/ws;逗号分隔可多备): " WS_URL
|
||||
read -r -p " 中间件 WS 完整地址(含 wss:// 前缀与 /ws 路径,如 wss://host:8092/ws;留空=贡献节点给柠檬 wss://middleware-1.api-ipw.wsmdn.top/ws;逗号分隔多个将同时连接全部): " WS_URL
|
||||
if [ -z "$WS_URL" ]; then
|
||||
WS_URL="wss://middleware-1.api-ipw.wsmdn.top/ws"
|
||||
WS_USED_DEFAULT="true"
|
||||
@@ -110,7 +110,7 @@ case "${WS_CHOICE,,}" in
|
||||
NODE_ID=$(gen_uuid)
|
||||
echo " 节点 id(自动生成 UUID): $NODE_ID"
|
||||
while [ -z "$NODE_KEY" ]; do
|
||||
read -r -p " 注册 key(必填,中间件 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
@@ -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 != "" {
|
||||
|
||||
@@ -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.
@@ -33,14 +33,14 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
@@ -61,8 +61,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -88,8 +88,8 @@ golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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.
+706
-1156
File diff suppressed because it is too large.
Load diff
@@ -10,10 +10,10 @@
|
||||
"cf-typegen": "wrangler types"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.18.5",
|
||||
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
||||
"typescript": "^5.5.2",
|
||||
"vitest": "~4.1.10",
|
||||
"wrangler": "^4.111.0"
|
||||
"wrangler": "^4.124.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.12.34"
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+7
-4
@@ -1,11 +1,12 @@
|
||||
module lemon-ipw
|
||||
|
||||
go 1.26.2
|
||||
go 1.26.6
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/gin-contrib/cors v1.7.7
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/ip2location/ip2location-go/v9 v9.8.0
|
||||
github.com/ipipdotnet/ipdb-go v1.3.3
|
||||
github.com/klauspost/compress v1.19.1
|
||||
github.com/likexian/whois v1.15.7
|
||||
@@ -19,6 +20,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/AdguardTeam/golibs v0.35.16 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
@@ -54,10 +56,11 @@ require (
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
)
|
||||
@@ -1,3 +1,5 @@
|
||||
github.com/AdguardTeam/golibs v0.35.16 h1:e9dyk7jfzpxMcyVZ8RktKV6JMeiQ9MpEhp/Ahm3j/R4=
|
||||
github.com/AdguardTeam/golibs v0.35.16/go.mod h1:lGsRwlo3R3ma5o8mgdDv3I+6DYTiCHfrhNyUamqhbK8=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
@@ -11,6 +13,7 @@ github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6p
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
@@ -40,6 +43,8 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/ip2location/ip2location-go/v9 v9.8.0 h1:drPzGjj1EBl45I33ErMHFtIfsQ3mR85dAQbqMDbi9mc=
|
||||
github.com/ip2location/ip2location-go/v9 v9.8.0/go.mod h1:MPLnsKxwQlvd2lBNcQCsLoyzJLDBFizuO67wXXdzoyI=
|
||||
github.com/ipipdotnet/ipdb-go v1.3.3 h1:GLSAW9ypLUd6EF9QNK2Uhxew9Jzs4XMJ9gOZEFnJm7U=
|
||||
github.com/ipipdotnet/ipdb-go v1.3.3/go.mod h1:yZ+8puwe3R37a/3qRftXo40nZVQbxYDLqls9o5foexs=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
@@ -77,12 +82,14 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
@@ -124,6 +131,8 @@ golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
@@ -135,13 +144,19 @@ golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
resty.dev/v3 v3.0.0-beta.6 h1:ghRdNpoE8/wBCv+kTKIOauW1aCrSIeTq7GxtfYgtevU=
|
||||
resty.dev/v3 v3.0.0-beta.6/go.mod h1:NTOerrC/4T7/FE6tXIZGIysXXBdgNqwMZuKtxpea9NM=
|
||||
@@ -1,6 +1,7 @@
|
||||
package ipdb
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -31,6 +32,8 @@ func PullDatabase(ghproxy string) error {
|
||||
{ghproxy + "https://github.com/ljxi/GeoCN/releases/latest/download/GeoCN.mmdb", "GeoCN.mmdb"},
|
||||
{ghproxy + "https://raw.githubusercontent.com/wp-statistics/DbIP-City-lite/master/dbip-city-lite.mmdb.gz", "dbip-city-lite.mmdb.gz"},
|
||||
{ghproxy + "https://github.com/jcjc-dev/mmdb-latest/releases/download/dbip-latest/dbip-asn-lite.mmdb", "dbip-asn-lite.mmdb"},
|
||||
{ghproxy + "https://github.com/nomdn/ip2location/releases/latest/download/IP2LOCATION-LITE-DB11.IPV6.BIN.ZIP", "IP2LOCATION-LITE-DB11.IPV6.BIN.ZIP"},
|
||||
{ghproxy + "https://github.com/nomdn/ip2location/releases/latest/download/IP2LOCATION-LITE-ASN.IPV6.BIN.zip", "IP2LOCATION-LITE-ASN.IPV6.BIN.zip"},
|
||||
}
|
||||
|
||||
tmpDir := "./tmp"
|
||||
@@ -81,6 +84,14 @@ func PullDatabase(ghproxy string) error {
|
||||
}
|
||||
os.Remove(dst)
|
||||
}
|
||||
if strings.HasSuffix(t.name, ".zip") {
|
||||
outName := strings.TrimSuffix(t.name, ".zip")
|
||||
slog.Info("Unzipping...", "file", t.name, "output", outName)
|
||||
if err := unzipFile(dst, "./"+outName); err != nil {
|
||||
slog.Error("Failed to unzip", "file", t.name, "error", err)
|
||||
}
|
||||
os.Remove(dst)
|
||||
}
|
||||
}
|
||||
|
||||
os.RemoveAll(tmpDir)
|
||||
@@ -205,3 +216,64 @@ func gunzipFile(src, dst string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// unzipFile 解压 zip 中与目标同名(或同后缀)的文件到 dst(dst 如 ./IP2LOCATION-LITE-DB11.IPV6.BIN);
|
||||
// 找不到匹配文件时回退解压第一个非目录文件。
|
||||
func unzipFile(src, dst string) error {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
target := filepath.Base(dst)
|
||||
var fallback *zip.File
|
||||
for _, f := range r.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
if fallback == nil {
|
||||
fallback = f
|
||||
}
|
||||
if f.Name == target || strings.EqualFold(f.Name, target) || strings.HasSuffix(strings.ToLower(f.Name), strings.ToLower(target)) {
|
||||
return extractZipEntry(f, dst)
|
||||
}
|
||||
}
|
||||
if fallback == nil {
|
||||
return fmt.Errorf("no file found in zip archive %s", src)
|
||||
}
|
||||
return extractZipEntry(fallback, dst)
|
||||
}
|
||||
|
||||
func extractZipEntry(f *zip.File, dst string) error {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
tmp := dst + ".tmp"
|
||||
out, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, rc); err != nil {
|
||||
out.Close()
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := out.Sync(); err != nil {
|
||||
out.Close()
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
ipdbgo "github.com/ipipdotnet/ipdb-go"
|
||||
"github.com/ip2location/ip2location-go/v9"
|
||||
"github.com/lionsoul2014/ip2region/binding/golang/service"
|
||||
maxminddb "github.com/oschwald/maxminddb-golang/v2"
|
||||
"resty.dev/v3"
|
||||
@@ -23,6 +24,10 @@ import (
|
||||
var (
|
||||
ip2region *service.Ip2Region
|
||||
ip2regionMu sync.RWMutex
|
||||
ip2locDB *ip2location.DB
|
||||
ip2locMu sync.RWMutex
|
||||
ip2locASNDB *ip2location.DB
|
||||
ip2locASNMu sync.RWMutex
|
||||
qqwryDB *ipdbgo.City
|
||||
qqwryMu sync.RWMutex
|
||||
mmdbDBs map[string]*maxminddb.Reader
|
||||
@@ -163,7 +168,6 @@ func loadIp2Region() error {
|
||||
ip2region.Close()
|
||||
slog.Info("Previous ip2region closed")
|
||||
}
|
||||
|
||||
v4Config, err := service.NewV4Config(service.VIndexCache, "ip2region_v4.xdb", 20)
|
||||
if err != nil {
|
||||
slog.Error("failed to create v4 config", "error", err)
|
||||
@@ -186,6 +190,48 @@ func loadIp2Region() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadIP2Location 加载 IP2Location LITE DB11 数据库(.BIN 格式,官方 ip2location-go 库读取)
|
||||
func loadIP2Location() error {
|
||||
ip2locMu.Lock()
|
||||
defer ip2locMu.Unlock()
|
||||
|
||||
if ip2locDB != nil {
|
||||
ip2locDB.Close()
|
||||
ip2locDB = nil
|
||||
slog.Info("Previous ip2location closed")
|
||||
}
|
||||
|
||||
db, err := ip2location.OpenDB("./IP2LOCATION-LITE-DB11.IPV6.BIN")
|
||||
if err != nil {
|
||||
slog.Error("failed to open ip2location db", "error", err)
|
||||
return err
|
||||
}
|
||||
ip2locDB = db
|
||||
slog.Info("ip2location loaded successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadIP2LocationASN 加载 IP2Location LITE ASN 数据库(仅 asn / as 字段)
|
||||
func loadIP2LocationASN() error {
|
||||
ip2locASNMu.Lock()
|
||||
defer ip2locASNMu.Unlock()
|
||||
|
||||
if ip2locASNDB != nil {
|
||||
ip2locASNDB.Close()
|
||||
ip2locASNDB = nil
|
||||
slog.Info("Previous ip2location asn closed")
|
||||
}
|
||||
|
||||
db, err := ip2location.OpenDB("./IP2LOCATION-LITE-ASN.IPV6.BIN")
|
||||
if err != nil {
|
||||
slog.Error("failed to open ip2location asn db", "error", err)
|
||||
return err
|
||||
}
|
||||
ip2locASNDB = db
|
||||
slog.Info("ip2location asn loaded successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadQQWry() error {
|
||||
qqwryMu.Lock()
|
||||
defer qqwryMu.Unlock()
|
||||
@@ -552,6 +598,20 @@ func closeAllDBs() {
|
||||
}
|
||||
ip2regionMu.Unlock()
|
||||
|
||||
ip2locMu.Lock()
|
||||
if ip2locDB != nil {
|
||||
ip2locDB.Close()
|
||||
ip2locDB = nil
|
||||
}
|
||||
ip2locMu.Unlock()
|
||||
|
||||
ip2locASNMu.Lock()
|
||||
if ip2locASNDB != nil {
|
||||
ip2locASNDB.Close()
|
||||
ip2locASNDB = nil
|
||||
}
|
||||
ip2locASNMu.Unlock()
|
||||
|
||||
qqwryMu.Lock()
|
||||
qqwryDB = nil
|
||||
qqwryMu.Unlock()
|
||||
@@ -569,6 +629,12 @@ func reloadAll() {
|
||||
if err := loadIp2Region(); err != nil {
|
||||
slog.Error("Error loading ip2region", "error", err)
|
||||
}
|
||||
if err := loadIP2Location(); err != nil {
|
||||
slog.Error("Error loading ip2location", "error", err)
|
||||
}
|
||||
if err := loadIP2LocationASN(); err != nil {
|
||||
slog.Error("Error loading ip2location asn", "error", err)
|
||||
}
|
||||
if err := reloadQQWry(); err != nil {
|
||||
slog.Error("Error reloading qqwry.ipdb", "error", err)
|
||||
}
|
||||
@@ -587,6 +653,14 @@ func Init(ghproxy string) {
|
||||
slog.Warn("Local ip2region not available", "error", err)
|
||||
localOK = false
|
||||
}
|
||||
if err := loadIP2Location(); err != nil {
|
||||
slog.Warn("Local ip2location not available", "error", err)
|
||||
localOK = false
|
||||
}
|
||||
if err := loadIP2LocationASN(); err != nil {
|
||||
slog.Warn("Local ip2location asn not available", "error", err)
|
||||
localOK = false
|
||||
}
|
||||
if err := loadQQWry(); err != nil {
|
||||
slog.Warn("Local qqwry.ipdb not available", "error", err)
|
||||
localOK = false
|
||||
@@ -627,7 +701,7 @@ func Init(ghproxy string) {
|
||||
}()
|
||||
}
|
||||
|
||||
var allDatabases = []string{"ip2region", "qqwry", "maxmind_city", "maxmind_asn", "dbip_asn", "geocn", "dbip_city", "bilibili"}
|
||||
var allDatabases = []string{"ip2region", "ip2location", "ip2location_asn", "qqwry", "maxmind_city", "maxmind_asn", "dbip_asn", "geocn", "dbip_city", "bilibili"}
|
||||
|
||||
func SearchIP(ip string, databases ...string) map[string]interface{} {
|
||||
if len(databases) == 0 {
|
||||
@@ -645,13 +719,74 @@ func SearchIP(ip string, databases ...string) map[string]interface{} {
|
||||
if err != nil {
|
||||
result["ip2region"] = "error: " + err.Error()
|
||||
} else {
|
||||
result["ip2region"] = region
|
||||
// 预分割为结构化 JSON,前端无需再按 "|" 拆字符串
|
||||
result["ip2region"] = parseIP2Region(region)
|
||||
}
|
||||
} else {
|
||||
result["ip2region"] = "not loaded"
|
||||
}
|
||||
ip2regionMu.RUnlock()
|
||||
|
||||
case "ip2location":
|
||||
ip2locMu.RLock()
|
||||
if ip2locDB != nil {
|
||||
rec, err := ip2locDB.Get_all(ip)
|
||||
if err != nil {
|
||||
result["ip2location"] = "error: " + err.Error()
|
||||
} else {
|
||||
// LITE 免费版不提供 asn/isp 等字段,库会返回占位提示串,统一过滤为空
|
||||
clean := func(s string) string {
|
||||
if s == "" || strings.HasPrefix(s, "This parameter is unavailable") {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
loc := map[string]string{
|
||||
"country": clean(rec.Country_long),
|
||||
"country_code": clean(rec.Country_short),
|
||||
"administrative_area": clean(rec.Region),
|
||||
"city": clean(rec.City),
|
||||
"isp": clean(rec.Isp),
|
||||
"asn": clean(rec.Asn),
|
||||
"district": clean(rec.District),
|
||||
"zipcode": clean(rec.Zipcode),
|
||||
"timezone": clean(rec.Timezone),
|
||||
"usagetype": clean(rec.Usagetype),
|
||||
}
|
||||
if rec.Latitude != 0 || rec.Longitude != 0 {
|
||||
loc["latitude"] = strconv.FormatFloat(float64(rec.Latitude), 'f', -1, 32)
|
||||
loc["longitude"] = strconv.FormatFloat(float64(rec.Longitude), 'f', -1, 32)
|
||||
}
|
||||
result["ip2location"] = loc
|
||||
}
|
||||
} else {
|
||||
result["ip2location"] = "not loaded"
|
||||
}
|
||||
ip2locMu.RUnlock()
|
||||
|
||||
case "ip2location_asn":
|
||||
ip2locASNMu.RLock()
|
||||
if ip2locASNDB != nil {
|
||||
rec, err := ip2locASNDB.Get_all(ip)
|
||||
if err != nil {
|
||||
result["ip2location_asn"] = "error: " + err.Error()
|
||||
} else {
|
||||
clean := func(s string) string {
|
||||
if s == "" || strings.HasPrefix(s, "This parameter is unavailable") {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
result["ip2location_asn"] = map[string]string{
|
||||
"asn": clean(rec.Asn),
|
||||
"as": clean(rec.As),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result["ip2location_asn"] = "not loaded"
|
||||
}
|
||||
ip2locASNMu.RUnlock()
|
||||
|
||||
case "qqwry":
|
||||
qqwryMu.RLock()
|
||||
if qqwryDB != nil {
|
||||
@@ -727,3 +862,28 @@ func SearchIP(ip string, databases ...string) map[string]interface{} {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseIP2Region 将 ip2region xdb 的管道分隔串预分割为结构化 JSON。
|
||||
// 实测 v4/v6 xdb 格式:国家|省份|城市|ISP(或"0")|国家代码(5 段,v4 的 ISP 段多为 "0");
|
||||
// 兼容老 4 段格式:国家|省份|城市|ISP。
|
||||
// 输出字段与 bilibili / geocn / maxmind_city 对齐:country / administrative_area / city / isp。
|
||||
func parseIP2Region(s string) map[string]string {
|
||||
parts := strings.Split(s, "|")
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
get := func(i int) string {
|
||||
if i < len(parts) {
|
||||
return parts[i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
res := map[string]string{"country": get(0)}
|
||||
// 5 段:国家|省份|城市|ISP(可能为"0")|国家代码;4 段及以下:国家|省份|城市|ISP
|
||||
res["administrative_area"] = get(1)
|
||||
res["city"] = get(2)
|
||||
if isp := get(3); isp != "" && isp != "0" {
|
||||
res["isp"] = isp
|
||||
}
|
||||
return res
|
||||
}
|
||||
+63
-40
@@ -19,6 +19,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AdguardTeam/golibs/netutil/sysresolv"
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
@@ -214,33 +215,33 @@ func (s *Setting) PortString() string {
|
||||
// Global variables and structs
|
||||
// 全局变量与结构体
|
||||
var (
|
||||
PORTS string
|
||||
GH_PROXY string
|
||||
LOG_LEVEL string
|
||||
websiteCache sync.Map
|
||||
SINGLE_STACK string
|
||||
DNS_SERVER string
|
||||
DNSSEC_DNS_SERVER string // DNSSEC 专用 DNS 服务器(dnssec-server / DNSSEC_DNS_SERVER),留空沿用 DNS_SERVER
|
||||
sslCache sync.Map
|
||||
pingCache sync.Map
|
||||
speedCache sync.Map
|
||||
whoisCache sync.Map
|
||||
asnWhoisCache sync.Map
|
||||
sfGroup singleflight.Group
|
||||
V6Client *resty.Client
|
||||
V4Client *resty.Client
|
||||
IPDB string
|
||||
CORS string
|
||||
ACCEPT_DOMAINS []string
|
||||
ACCESS_TOKEN string
|
||||
REMOTE_CONFIG_URL string
|
||||
REMOTE_INGORE_CONFIG []string // 不被远端配置覆盖的配置项列表(remote-ingore-config / REMOTE_INGORE_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] 一致,可空)
|
||||
VERSION string
|
||||
COMMIT string
|
||||
BUILD_TIME string
|
||||
PORTS string
|
||||
GH_PROXY string
|
||||
LOG_LEVEL string
|
||||
websiteCache sync.Map
|
||||
SINGLE_STACK string
|
||||
DNS_SERVER string
|
||||
DNSSEC_DNS_SERVER string // DNSSEC 专用 DNS 服务器(dnssec-server / DNSSEC_DNS_SERVER),留空沿用 DNS_SERVER
|
||||
sslCache sync.Map
|
||||
pingCache sync.Map
|
||||
speedCache sync.Map
|
||||
whoisCache sync.Map
|
||||
asnWhoisCache sync.Map
|
||||
sfGroup singleflight.Group
|
||||
V6Client *resty.Client
|
||||
V4Client *resty.Client
|
||||
IPDB string
|
||||
CORS string
|
||||
ACCEPT_DOMAINS []string
|
||||
ACCESS_TOKEN string
|
||||
REMOTE_CONFIG_URL string
|
||||
REMOTE_IGNORE_CONFIG []string // 不被远端配置覆盖的配置项列表(remote-ignore-config / REMOTE_IGNORE_CONFIG)
|
||||
WS_URL string // WS 客户端:中间件 WS 地址(ws://host:port/ws)
|
||||
WS_NODE_ID string // WS 客户端:节点 id(与中间件 ws-keys 键一致)
|
||||
WS_NODE_KEY string // WS 客户端:注册 key(与中间件 ws-keys[节点id] 一致,可空)
|
||||
VERSION string
|
||||
COMMIT string
|
||||
BUILD_TIME string
|
||||
)
|
||||
|
||||
type websiteCacheEntry struct {
|
||||
@@ -278,8 +279,6 @@ type WebsiteCheckResult struct {
|
||||
IPv6 *webtest.WebsiteCheckDetail `json:"ipv6"`
|
||||
}
|
||||
|
||||
|
||||
|
||||
type SSLCheckResult struct {
|
||||
IPv4 *webtest.SSLCheckDetail `json:"ipv4"`
|
||||
IPv6 *webtest.SSLCheckDetail `json:"ipv6"`
|
||||
@@ -292,10 +291,6 @@ type TCPingResult struct {
|
||||
// Business Endpoints
|
||||
// 业务端点
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
func checkWebsiteHandler(c *gin.Context) {
|
||||
testUrl := c.Param("url")
|
||||
if testUrl == "" {
|
||||
@@ -605,12 +600,23 @@ func asnLookupHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result := ipdb.SearchIP(ip, "maxmind_asn", "dbip_asn")
|
||||
result := ipdb.SearchIP(ip, "maxmind_asn", "dbip_asn", "ip2location_asn")
|
||||
|
||||
asnResult := map[string]interface{}{
|
||||
"ip": ip,
|
||||
}
|
||||
|
||||
if ip2locASN, ok := result["ip2location_asn"].(map[string]string); ok && ip2locASN["asn"] != "" {
|
||||
asnResult["ip2location_asn"] = map[string]string{
|
||||
"asn": ip2locASN["asn"],
|
||||
"as": ip2locASN["as"],
|
||||
}
|
||||
} else if errStr, ok := result["ip2location_asn"].(string); ok {
|
||||
asnResult["ip2location_asn"] = map[string]string{
|
||||
"error": errStr,
|
||||
}
|
||||
}
|
||||
|
||||
if maxmindASN, ok := result["maxmind_asn"].(*ipdb.MMDBASNResult); ok {
|
||||
asnResult["geolite2_asn"] = map[string]string{
|
||||
"asn": maxmindASN.ASN,
|
||||
@@ -935,6 +941,7 @@ func tokenCheck() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fetchRemoteConfig 从远端 URL 拉取配置文件(遵守 setting.json 的 JSON 格式)。
|
||||
// 拉取或解析失败时返回错误,调用方应回退到本地配置。
|
||||
func fetchRemoteConfig(url string) (map[string]any, error) {
|
||||
@@ -993,7 +1000,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,18 +1119,34 @@ 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()
|
||||
var SYSTEM_DNS string
|
||||
if DNS_SERVER == "" || DNSSEC_DNS_SERVER == "" {
|
||||
sysDNS, err := sysresolv.NewSystemResolvers(nil, 53)
|
||||
if err != nil {
|
||||
slog.Warn("Cannot setup reslovers")
|
||||
|
||||
}
|
||||
sysDNS.Refresh()
|
||||
SYSTEM_DNS = webtest.AddrPortsToCSV(sysDNS.Addrs())
|
||||
}
|
||||
if DNSSEC_DNS_SERVER == "" {
|
||||
DNSSEC_DNS_SERVER = SYSTEM_DNS
|
||||
}
|
||||
if DNS_SERVER == "" {
|
||||
DNS_SERVER = SYSTEM_DNS
|
||||
}
|
||||
slog.Info("SSRF protection initialized", "blockPrivateIPs", ssrf.Enabled())
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"ipdb": "false",
|
||||
"cors": "",
|
||||
"remote-config-url": "",
|
||||
"remote-ingore-config": [],
|
||||
"remote-ignore-config": [],
|
||||
"access-token": "",
|
||||
"ws-url": "",
|
||||
"node-id": "",
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,6 +1,7 @@
|
||||
package webtest
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -42,3 +43,13 @@ func measureDNSTime(urlStr string, version string) float64 {
|
||||
}
|
||||
return time.Since(start).Seconds() * 1000
|
||||
}
|
||||
func AddrPortsToCSV(addrs []netip.AddrPort) string {
|
||||
if len(addrs) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, len(addrs))
|
||||
for i, addr := range addrs {
|
||||
parts[i] = addr.String() // 自动格式化为 "IP:Port"
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+35
-18
@@ -18,15 +18,15 @@ import (
|
||||
//
|
||||
// 配置(readConfig 解析,环境变量优先,其次 setting.json,远端配置可覆盖):
|
||||
//
|
||||
// WS_URL ws://<中间件>:8092/ws,ws://<备中间件>:8092/ws (逗号分隔多个中间件,主失败自动切换下一个;不配置 = 不启用)
|
||||
// NODE_ID 节点 id(与中间件 wsKeys 键 / 前端配置 id 一致)
|
||||
// NODE_KEY 注册 key(与中间件 wsKeys[节点id] 一致;中间件未配置该节点 key 时留空)
|
||||
// WS_URL ws://<中间件>:8092/ws,ws://<备中间件>:8092/ws (逗号分隔多个中间件,同时连接全部,多活;不配置 = 不启用)
|
||||
// NODE_ID 节点 id(与中间件 ws-keys 键 / 前端配置 id 一致)
|
||||
// NODE_KEY 注册 key(与中间件 ws-keys[节点id] 一致;中间件未配置该节点 key 时留空)
|
||||
//
|
||||
// 与 HTTP handler 共用同一批 webtest 探针函数和缓存:收到 probe 后按 apiType 取数(带缓存),
|
||||
// 返回处理在本文件内完成,结果经 probe_result 上报。节点 HTTP 接口完全不变。
|
||||
//
|
||||
// 心跳:NODE 与 MIDDLEWARE 双向心跳。本端每 10s 发一次 ping,发送失败重试,连续失败 3 次
|
||||
// 判定当前中间件不可用,断开并切换下一个 WS_URL;收到中间件 ping 回 pong。
|
||||
// 判定当前中间件不可用,断开该连接并按同一 URL 独立重连(不影响其他中间件连接)。
|
||||
|
||||
// wsProbe 学 HTTP handler:从函数拿数据 + 缓存,返回 (status, body)。
|
||||
// raw 与 query 来自中间件 probe 消息(等价于路由参数)。
|
||||
@@ -200,8 +200,13 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
if raw == "" {
|
||||
return 400, map[string]string{"error": "IP parameter is required"}
|
||||
}
|
||||
result := ipdb.SearchIP(raw, "maxmind_asn", "dbip_asn")
|
||||
result := ipdb.SearchIP(raw, "maxmind_asn", "dbip_asn", "ip2location_asn")
|
||||
asnResult := map[string]any{"ip": raw}
|
||||
if ip2locASN, ok := result["ip2location_asn"].(map[string]string); ok && ip2locASN["asn"] != "" {
|
||||
asnResult["ip2location_asn"] = map[string]string{"asn": ip2locASN["asn"], "as": ip2locASN["as"]}
|
||||
} else if errStr, ok := result["ip2location_asn"].(string); ok {
|
||||
asnResult["ip2location_asn"] = map[string]string{"error": errStr}
|
||||
}
|
||||
if maxmindASN, ok := result["maxmind_asn"].(*ipdb.MMDBASNResult); ok {
|
||||
asnResult["geolite2_asn"] = map[string]string{"asn": maxmindASN.ASN, "org": maxmindASN.Org}
|
||||
} else if errStr, ok := result["maxmind_asn"].(string); ok {
|
||||
@@ -360,8 +365,8 @@ const (
|
||||
wsHeartbeatTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// wsClientOnce 连接并服务单个中间件 URL,返回下次重试前的等待时长:
|
||||
// - 连接/读循环断开 → 3s(切换下一个中间件)
|
||||
// wsClientOnce 连接并服务单个中间件 URL,返回下次重试前的等待时长(由 per-URL 连接循环调用):
|
||||
// - 连接/读循环断开 → 3s(重试同一 URL)
|
||||
// - 注册被拒(register_error)→ 30s(多为 key 配置错误,加大间隔避免空转刷日志)
|
||||
// - 心跳连续失败 wsHeartbeatRetry 次 → 主动断开,3s
|
||||
// - 返回 0 = 配置缺失(不再重试)
|
||||
@@ -399,7 +404,7 @@ func wsClientOnce(url string) time.Duration {
|
||||
}
|
||||
slog.Info("ws client registered", "nodeId", WS_NODE_ID, "url", url)
|
||||
|
||||
// 心跳 goroutine:每 10s 发 ping,失败重试,连续 3 次失败主动断开(上层切换下一个中间件)
|
||||
// 心跳 goroutine:每 10s 发 ping,失败重试,连续 3 次失败主动断开(per-URL 循环重连同一中间件)
|
||||
stopHeartbeat := make(chan struct{})
|
||||
go func() {
|
||||
fail := 0
|
||||
@@ -413,7 +418,7 @@ func wsClientOnce(url string) time.Duration {
|
||||
fail++
|
||||
slog.Warn("ws heartbeat send failed", "url", url, "fail", fail, "error", err)
|
||||
if fail >= wsHeartbeatRetry {
|
||||
slog.Warn("ws heartbeat failed too many times, switching middleware", "url", url)
|
||||
slog.Warn("ws heartbeat failed too many times, closing connection (will retry same url)", "url", url)
|
||||
c.Close(websocket.StatusPolicyViolation, "heartbeat timeout")
|
||||
return
|
||||
}
|
||||
@@ -445,23 +450,35 @@ func wsClientOnce(url string) time.Duration {
|
||||
}
|
||||
}
|
||||
|
||||
// wsClientLoop 主循环:依次尝试所有配置的中间件 URL(逗号分隔,第一个为主),
|
||||
// 当前中间件连接失败或心跳超时 → 3s 后切换下一个;注册被拒 → 30s;全部失败循环重试。
|
||||
// wsClientLoop 同时连接所有配置的中间件 URL(逗号分隔,多活):
|
||||
// 每个 URL 由独立 goroutine 负责,各自注册并保持连接,任一断开只重连自己,不影响其他中间件。
|
||||
// 注意:多个 URL 指向同一中间件实例时,中间件按 nodeId 单连接,后注册的连接会顶掉先前的。
|
||||
func wsClientLoop() {
|
||||
urls := splitWSURLs(WS_URL)
|
||||
if len(urls) == 0 {
|
||||
slog.Info("ws client disabled (WS_URL not set)")
|
||||
return
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for _, url := range urls {
|
||||
wg.Add(1)
|
||||
go func(u string) {
|
||||
defer wg.Done()
|
||||
wsClientOnceLoop(u)
|
||||
}(url)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// wsClientOnceLoop 单个中间件 URL 的持续连接循环:连接 → 注册 → 心跳/读循环 → 断开重试
|
||||
func wsClientOnceLoop(url string) {
|
||||
for {
|
||||
for _, url := range urls {
|
||||
retryDelay := wsClientOnce(url)
|
||||
if retryDelay == 0 {
|
||||
slog.Info("ws client stopped")
|
||||
return
|
||||
}
|
||||
time.Sleep(retryDelay)
|
||||
retryDelay := wsClientOnce(url)
|
||||
if retryDelay == 0 {
|
||||
slog.Info("ws client stopped", "url", url)
|
||||
return
|
||||
}
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user