Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
130ac61df0 | ||
|
|
e1a00236ee | ||
|
|
71ec93f068 | ||
|
|
c0f0640075 | ||
|
|
778ed2bd29 | ||
|
|
2ec182fb08 | ||
|
|
e8ec3757fd | ||
|
|
d398c4085c | ||
|
|
d04a90b20c | ||
|
|
92a8c1f308 | ||
|
|
d2e3601f20 | ||
|
|
6a4a3821de | ||
|
|
069847e11f | ||
|
|
30ba61bc11 | ||
|
|
94f03ffa85 | ||
|
|
3a3eeab9f9 | ||
|
|
fa5c2329fe | ||
|
|
957ac5a4c8 | ||
|
|
63c42627f6 | ||
|
|
6512a46596 | ||
|
|
e261666efc | ||
|
|
0d1a9e5bbd | ||
|
|
279f663694 | ||
|
|
3af1076492 | ||
|
|
10f48f8619 | ||
|
|
694ddac6eb |
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,8 +86,8 @@ jobs:
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-freebsd-arm64
|
||||
- name: Prepare config files for release
|
||||
run: |
|
||||
# 后端:发布真实 setting.json(重命名为 setting.json.example);中间件:只发布 setting.json.example
|
||||
cp setting.json setting.json.example
|
||||
# 后端:发布 src/ 下的 setting.json.example;中间件:只发布 setting.json.example
|
||||
cp src/setting.json.example setting.json.example
|
||||
cp middleware-go/setting.json.example middleware.setting.json.example
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
|
||||
@@ -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
|
||||
+3
-2
@@ -15,9 +15,10 @@ lemon-ipw
|
||||
*.tmp
|
||||
*.log
|
||||
node_modules/
|
||||
setting.json
|
||||
.workbuddy/
|
||||
# VitePress 构建产物
|
||||
docs/.vitepress/.temp/
|
||||
docs/.vitepress/cache/
|
||||
docs/.vitepress/dist/
|
||||
docs/.vitepress/dist/
|
||||
tmp/
|
||||
setting.json
|
||||
@@ -1,278 +1,50 @@
|
||||
# Lemon IPW
|
||||
|
||||
ipw.cn 替代品,提供 IP 查询、网站检测、SSL 检查、DNS 解析、TCPing 测速、Whois、DNSSEC、ASN 查询、网站截图等功能。
|
||||
文档编写中....
|
||||
ipw.cn 替代品,提供 IP 查询、网站检测、SSL 检查、DNS 解析、TCPing 测速、Whois、DNSSEC、ASN 查询、网站截图等功能。
|
||||
|
||||
## 功能
|
||||
**[预览站点](https://ipw.wsmdn.top/)** · **[文档](https://github.com/nomdn/ipw-cn/tree/main/docs)**
|
||||
|
||||
- **IP 地址查询** — 支持 IPv4/IPv6,集成 ip2region、qqwry、MaxMind GeoIP、GeoCN、DbIP、Bilibili 等多数据源
|
||||
- **ASN 自治系统查询** — 查询 IP 所属自治系统号,支持 MaxMind GeoLite2、DbIP 双数据源,集成 WHOIS 解析
|
||||
- **网站检测** — HTTP 状态码、响应时间、Host 记录
|
||||
- **SSL 证书检查** — 证书有效期、颁发机构、剩余天数
|
||||
- **DNS 解析** — 多节点并发查询,支持 A/AAAA/CNAME/MX/TXT/NS/SRV/PTR/CAA 等记录类型
|
||||
- **DNSSEC 验证** — 检测域名 DNSSEC 签名状态,验证 DNSKEY、RRSIG、DS 记录链式信任关系
|
||||
- **Whois 查询** — 域名注册信息、注册商、到期时间等
|
||||
- **TCPing** — TCP 连接延迟测试,支持 IPv4/IPv6 双栈
|
||||
- **网站测速** — 下载速度、响应头、Host 记录
|
||||
- **网站截图** — 在线网站页面截图
|
||||
- **SSRF 防护** — 自动拦截对内网/私有 IP 的出站请求
|
||||
- **暗色模式** — 支持明暗主题切换
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o install.sh && sudo bash install.sh
|
||||
```
|
||||
|
||||
完整部署(后端节点 / 独立中间件 / 前端)与配置说明见 [文档](https://github.com/nomdn/ipw-cn/tree/main/docs):
|
||||
|
||||
- [快速开始](docs/guide/getting-started.md)
|
||||
- [后端节点部署](docs/guide/deploy-node.md)
|
||||
- [中间件部署](docs/guide/deploy-middleware.md)
|
||||
- [前端部署](docs/guide/deploy-frontend.md)
|
||||
- [配置文件](docs/guide/config.md)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
ipw-cn/
|
||||
├── main.go # Go 后端入口(Gin 框架,自托管)
|
||||
├── go.mod # Go 模块定义
|
||||
├── Dockerfile # 后端 Docker 镜像
|
||||
├── setting.json # 后端运行配置
|
||||
│
|
||||
├── ipdb/ # IP 数据库查询模块
|
||||
│ ├── searchip.go # 多数据源 IP 查询(ip2region/qqwry/MaxMind/GeoCN/DbIP/Bilibili)
|
||||
│ └── ipdb.go # 数据库下载与更新
|
||||
│
|
||||
├── webtest/ # 网络测试工具
|
||||
│ ├── dns.go # DNS 查询(基于 miekg/dns)
|
||||
│ ├── dnssec.go # DNSSEC 验证
|
||||
│ ├── tcping.go # TCP 连接测试
|
||||
│ └── whois.go # WHOIS 域名查询
|
||||
│
|
||||
├── ssrf/ # SSRF 防护模块
|
||||
│ └── ssrf.go # 私有 IP 拦截、安全跳转校验
|
||||
│
|
||||
├── middleware-go/ # 独立转发中间件(Go Fiber v3,可单独部署)
|
||||
│ ├── main.go # 中间件入口(/v1/* 与 /middleware/* 路由)
|
||||
│ ├── setting.json # 中间件配置(节点列表、apiKeys、cors 等)
|
||||
│ ├── setting.json.example # 中间件配置示例
|
||||
│ ├── Dockerfile # 中间件 Docker 镜像
|
||||
│ └── middleware-go-linux-arm64 # Linux ARM64 预编译二进制
|
||||
│
|
||||
├── frontend-ssr/ # Nuxt 4 SSR 前端(主版本,部署至 Cloudflare Workers)
|
||||
│ ├── app/ # Nuxt 应用源码
|
||||
│ │ └── pages/ # 页面组件(location/dns/tcping/ssl/speedtest/whois/dnssec/asn/screenshot)
|
||||
│ ├── config/ # 前端配置(API 地址、节点列表)
|
||||
│ ├── nuxt.config.ts # Nuxt 配置
|
||||
│ ├── package.json
|
||||
│ ├── pnpm-workspace.yaml
|
||||
│ └── wrangler.jsonc # Cloudflare Workers 部署配置
|
||||
│
|
||||
├── edgeone/ # 腾讯 EdgeOne 边缘函数版本后端
|
||||
│ ├── cloud-functions/ # Go 边缘函数源码
|
||||
│ │ ├── index.go # 后端入口(Gin 框架)
|
||||
│ │ ├── webtest/ # 网络测试工具
|
||||
│ │ ├── ssrf/ # SSRF 防护
|
||||
│ │ └── go.mod
|
||||
│ └── .edgeone/ # EdgeOne 部署配置
|
||||
│
|
||||
├── lemon-getip/ # Cloudflare Workers IP 查询服务(Hono + TypeScript)
|
||||
│ ├── src/index.ts
|
||||
│ ├── wrangler.jsonc
|
||||
│ ├── package.json
|
||||
│ └── test/
|
||||
│
|
||||
├── edgeone-getip/ # EdgeOne Pages IP 查询服务(TypeScript 边缘函数)
|
||||
│ ├── edge-functions/index.ts
|
||||
│ └── .edgeone/
|
||||
│
|
||||
├── .github/workflows/ # CI/CD 流水线
|
||||
│ ├── frontend-ssr.yml # 部署至 Cloudflare Workers
|
||||
│ ├── workers.yml # 部署 lemon-getip 至 Cloudflare Workers
|
||||
│ ├── edgeone-backend.yml # 部署 edgeone 至 EdgeOne Pages
|
||||
│ └── edgeone-getip.yml # 部署 edgeone-getip 至 EdgeOne Pages
|
||||
│
|
||||
├── tmp/ # IP 数据库下载临时目录
|
||||
├── short.txt # GeoCN 行政区划短表(省市级)
|
||||
├── full.txt # GeoCN 行政区划全表(区县级)
|
||||
├── ipw-backend.exe # Windows 预编译后端二进制
|
||||
├── .node-version # Node.js 版本(v22)
|
||||
└── LICENSE # GPL-3.0
|
||||
├── src/ # Go 后端
|
||||
├── middleware-go/ # 独立组件 转发中间件(Fiber v3,多节点候选)
|
||||
├── 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分支
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 组件 | 技术 |
|
||||
|------|------|
|
||||
| 后端(自托管) | Go 1.26 + Gin + Resty |
|
||||
| 独立中间件 | Go 1.26 + Fiber v3(转发中间件,多节点候选) |
|
||||
| 边缘后端(EdgeOne) | Go(边缘函数) |
|
||||
| SSR 前端 | Nuxt 4 + Vue 3 + Element Plus + VueUse |
|
||||
| Cloudflare Workers | Hono + TypeScript + Wrangler |
|
||||
| EdgeOne Pages | TypeScript 边缘函数 |
|
||||
| IP 数据库 | ip2region、qqwry.ipdb、MaxMind GeoLite2、GeoCN、DbIP、Bilibili API |
|
||||
| DNS 查询 | miekg/dns(Go)/ 原生 DNS-over-HTTPS(EdgeOne) |
|
||||
| 其他 | SSRF 防护、Zstandard 压缩、WordPress mshots 截图 |
|
||||
| 部署 | Docker、Cloudflare Workers、EdgeOne Pages |
|
||||
|
||||
## 一键部署到 EdgeOne(Nuxt 原生支持)
|
||||
|
||||
前端 `frontend-ssr/` 基于 Nuxt,腾讯云 EdgeOne 原生支持 Nuxt 构建,点击下方按钮即可通过 EdgeOne Makers 一键部署前端:
|
||||
|
||||
[](https://console.cloud.tencent.com/edgeone/makers/new?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=frontend-ssr&install-command=pnpm%20install&build-command=pnpm%20run%20build&output-directory=.output)
|
||||
|
||||
| 按钮参数 | 值 | 说明 |
|
||||
|----------|-----|------|
|
||||
| `repository-url` | `https://github.com/nomdn/ipw-cn` | GitHub 仓库地址 |
|
||||
| `root-directory` | `frontend-ssr` | 构建根目录(前端源码所在目录) |
|
||||
| `install-command` | `pnpm install` | 依赖安装命令 |
|
||||
| `build-command` | `pnpm run build` | 构建命令(Nuxt 默认 SSR 构建) |
|
||||
| `output-directory` | `.output` | 构建产物输出目录(Nitro SSR 产物) |
|
||||
|
||||
> 说明:EdgeOne Makers 原生支持 Nuxt 部署,SSR / SSG / ISR(SWR) / 中间件 / 流式传输均支持(Nuxt 3.16+,推荐 Nuxt 4),按钮默认以 **SSR** 方式构建。如需纯静态部署,可将 `build-command` 改为 `pnpm run generate`、`output-directory` 改为 `.output/public`。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 后端(自托管)
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
go mod download
|
||||
|
||||
# 配置 setting.json(可选)
|
||||
# {
|
||||
# "port": 8080,
|
||||
# "gh-proxy": "https://fastgit.cc/",
|
||||
# "single-stack": "", # "ipv4" 或 "ipv6" 仅启用单栈
|
||||
# "dns-server": "", # 自定义 DNS 服务器
|
||||
# "block-private-ips": true,# SSRF 防护开关
|
||||
# "ipdb": true, # IP 数据库开关
|
||||
# "cors": "", # 允许的请求来源,详见 https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Guides/CORS
|
||||
# "access_token": "" # API 访问令牌(可选)
|
||||
# }
|
||||
|
||||
# 运行
|
||||
go run main.go
|
||||
```
|
||||
|
||||
首次启动时会自动下载 IP 数据库文件(约 200MB),之后每 24 小时自动更新。
|
||||
|
||||
### 独立中间件(middleware-go)
|
||||
|
||||
转发中间件,供 SSR 前端在多个上游节点之间做候选重试,可独立部署。
|
||||
|
||||
```bash
|
||||
cd middleware-go
|
||||
|
||||
# 构建(Linux ARM64 示例)
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o middleware-go-linux-arm64 .
|
||||
|
||||
# 运行(需配置文件,可用 SETTING_FILE 指定路径)
|
||||
./middleware-go-linux-arm64
|
||||
|
||||
# 查看版本
|
||||
./middleware-go-linux-arm64 --version
|
||||
```
|
||||
|
||||
所有配置均可通过环境变量覆盖,无需修改配置文件(详见下方「中间件配置」)。
|
||||
|
||||
### SSR 前端(部署至 Cloudflare Workers)
|
||||
|
||||
```bash
|
||||
cd frontend-ssr
|
||||
|
||||
# 安装依赖(需要 pnpm)
|
||||
pnpm install
|
||||
|
||||
# 开发
|
||||
pnpm dev
|
||||
|
||||
# 构建并部署
|
||||
pnpm deploy
|
||||
```
|
||||
|
||||
### SPA 前端(旧版本,部署至 Cloudflare Pages)
|
||||
|
||||
> [!WARNING]
|
||||
> 原生 SPA 已停止支持。要部署 SPA 版本前端请使用 Nuxt SPA 版本。
|
||||
|
||||
### Docker 部署
|
||||
|
||||
```bash
|
||||
docker build -t lemon-ipw .
|
||||
docker run -p 8080:8080 -v $(pwd)/setting.json:/app/setting.json lemon-ipw
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/v1/location/:ip` | 查询指定 IP 地理位置 |
|
||||
| GET | `/v1/location` | 查询请求者 IP 地理位置 |
|
||||
| GET | `/v1/asn/:ip` | 查询 IP 所属自治系统号(ASN) |
|
||||
| GET | `/v1/detail/*url` | 检测网站可达性(HTTP 状态码、响应时间) |
|
||||
| GET | `/v1/ssl/*url` | 检查 SSL 证书信息 |
|
||||
| GET | `/v1/speed/:version/*url` | 网站测速(version: v4/v6/dual) |
|
||||
| GET | `/v1/tcping/:ip?port=80&count=4` | TCP 连接测试 |
|
||||
| GET | `/v1/dns/:type/*domain` | DNS 解析(type: A/AAAA/CNAME/MX/TXT/NS/SRV/PTR/CAA) |
|
||||
| GET | `/v1/dnssec/:domain` | DNSSEC 验证 |
|
||||
| GET | `/v1/whois/:domain` | Whois 域名查询 |
|
||||
| GET | `/` | 健康检查 |
|
||||
|
||||
## 部署架构
|
||||
|
||||
本项目支持多平台部署,前端和后端可独立部署到不同平台:
|
||||
|
||||
- **自托管后端**:使用 Docker 或直接运行 Go 二进制,配合 `setting.json` 配置
|
||||
- **独立转发中间件**:`middleware-go/` 使用 Go Fiber v3,提供 `/v1/*` 与 `/middleware/*` 转发路由,供前端做多节点候选重试
|
||||
- **Cloudflare Workers 后端**:`lemon-getip/` 使用 Hono 框架部署到 Cloudflare Workers
|
||||
- **EdgeOne 边缘后端**:`edgeone/` 和 `edgeone-getip/` 部署到腾讯 EdgeOne Pages
|
||||
- **SSR 前端**:`frontend-ssr/` 使用 Nuxt 4 + Wrangler 部署到 Cloudflare Workers
|
||||
|
||||
## 配置
|
||||
|
||||
### 后端环境变量
|
||||
|
||||
| 配置项 | 环境变量 | 默认值 | 说明 |
|
||||
|--------|----------|--------|------|
|
||||
| `port` | `PORT` | `8080` | 监听端口 |
|
||||
| `gh-proxy` | `GH_PROXY` | `""` | GitHub 文件代理(用于下载数据库) |
|
||||
| `single-stack` | `SINGLE_STACK` | `""` | 单栈模式:`ipv4` 或 `ipv6` |
|
||||
| `dns-server` | `DNS_SERVER` | `119.28.28.28:53` | DNS 服务器地址 |
|
||||
| `block-private-ips` | `BLOCK_PRIVATE_IPS` | `true` | SSRF 防护开关 |
|
||||
| `ipdb` | `IPDB` | `true` | IP 数据库开关 |
|
||||
| `access_token` | `ACCESS_TOKEN` | `""` | API 访问令牌 |
|
||||
| `cors` | `CORS` | `""` | 允许的请求来源 |
|
||||
| 远端配置 | `REMOTE_CONFIG_URL` | `""` | 远端配置文件 URL(遵守 `setting.json` 格式),优先级最高 |
|
||||
|
||||
> 配置优先级:**远端配置(`REMOTE_CONFIG_URL`)> 环境变量 > setting.json**。远端配置 URL 可通过环境变量 `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` 提供(环境变量优先);远端配置本身需遵守 `setting.json` 的 JSON 格式(`port` / `gh-proxy` / `single-stack` / `dns-server` / `block-private-ips` / `ipdb` / `cors` 等),拉取或解析失败时自动回退到本地配置并打印警告。`access_token` 例外:**不随远端配置覆盖**,保持原有优先级(环境变量 > setting.json)。EdgeOne 版本(`edgeone/`)配置来源为 环境变量 + 远端配置。
|
||||
|
||||
### 前端配置
|
||||
|
||||
前端 API 地址和多节点配置位于各前端项目的 `config/` 目录中。
|
||||
|
||||
### 中间件配置(middleware-go)
|
||||
|
||||
配置来源优先级:**环境变量 > `setting.json` > 默认值**。`setting.json` 采用平铺结构(键名与前端 `config/index.ts` 一致),也可用环境变量覆盖任意配置项;数组/对象类配置项从环境变量读取 **JSON 字符串**解析。
|
||||
|
||||
```json
|
||||
{
|
||||
"port": "8091",
|
||||
"httpTimeoutSeconds": 30,
|
||||
"cors": "https://ipw.wsmdn.top",
|
||||
"apiBaseUrls": [{ "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" }],
|
||||
"IPLocationAPIs": [],
|
||||
"TCPing": { "DualStack": [], "IPv4": [], "IPv6": [] },
|
||||
"SpeedTest": { "DualStack": [], "IPv4": [], "IPv6": [] },
|
||||
"NSLookup": [],
|
||||
"apiKeys": { "cn-jiangsu": "token" }
|
||||
}
|
||||
```
|
||||
|
||||
| 配置项 | 环境变量 | 类型 | 说明 |
|
||||
|--------|----------|------|------|
|
||||
| `port` | `PORT` | 数字/字符串 | 监听端口 |
|
||||
| `httpTimeoutSeconds` | `HTTP_TIMEOUT` | 数字 | 上游请求超时(秒) |
|
||||
| `cors` | `CORS` | 字符串 | 逗号分隔的允许域名;为空则允许所有(`*`) |
|
||||
| `apiBaseUrls` | `API_BASE_URLS` | JSON 数组 | whois/ssl/detail 上游节点 |
|
||||
| `IPLocationAPIs` | `IP_LOCATION_APIS` | JSON 数组 | location/asn 上游节点 |
|
||||
| `TCPing` | `TCPING` | JSON 对象 | tcping 节点(DualStack/IPv4/IPv6) |
|
||||
| `SpeedTest` | `SPEED_TEST` | JSON 对象 | speed 节点(DualStack/IPv4/IPv6) |
|
||||
| `NSLookup` | `NS_LOOKUP` | JSON 数组 | dns/dnssec 上游节点 |
|
||||
| `apiKeys` | `APIKEYS` | JSON 对象 | `{"backendID": "token"}`,转发时加 `Authorization: Bearer <token>` |
|
||||
| 配置文件路径 | `SETTING_FILE` | 字符串 | 指定 setting.json 路径(默认 `./setting.json` 或 `../setting.json`) |
|
||||
|
||||
**路由格式**:`/{prefix}/{backendID}/{apiType}/{raw...}`,其中 `prefix` 为 `/v1` 或 `/middleware`,`apiType` 支持 `whois/dns/location/ssl/asn/dnssec/detail/tcping/speed`。响应透传上游状态码与 body;上游网络不可达返回 502。
|
||||
|
||||
**版本信息**:通过 ldflags 注入 `main.VERSION / main.COMMIT / main.BUILD_TIME`,运行 `--version` 查看。
|
||||
| 后端(自托管) | Go + Gin + Resty |
|
||||
| 独立中间件 | Go + Fiber v3(WS 通道:coder/websocket) |
|
||||
| 边缘后端(EdgeOne) | Go 边缘函数 |
|
||||
| SSR 前端 | Nuxt 4 + Vue 3 + Element Plus |
|
||||
| DNS 查询 | miekg/dns(DoH / UDP 双通道 + 主从 failover) |
|
||||
| 部署 | Docker、Cloudflare Workers、EdgeOne Pages、systemd |
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
+62
-47
@@ -13,7 +13,7 @@
|
||||
"block-private-ips": true,
|
||||
"ipdb": true,
|
||||
"cors": "",
|
||||
"access_token": ""
|
||||
"access-token": ""
|
||||
}
|
||||
```
|
||||
|
||||
@@ -23,9 +23,12 @@
|
||||
- `block-private-ips`:SSRF 防护开关
|
||||
- `ipdb`:IP 数据库开关(首次启动自动下载约 200MB,之后每 24h 更新)
|
||||
- `cors`:允许的请求来源(逗号分隔)
|
||||
- `access_token`:API 访问令牌,留空则不启用鉴权
|
||||
- `access-token`:API 访问令牌,留空则不启用鉴权
|
||||
- `ws-url`:WS 通道地址——接入独立中间件的 WebSocket 地址(如 `"wss://middleware-1.api-ipw.wsmdn.top/ws"`,须含 `wss://` 前缀与 `/ws` 路径);支持**逗号分隔多个中间件,同时连接全部(多活)**,任一断开只重连自己,不影响其他连接;留空 = 不启用 WS 客户端,走原 HTTP 接口
|
||||
- `node-id`:WS 节点 id(与中间件 `api-keys`/`ws-keys` 键、前端配置的节点 `id` 一致;建议用 UUID 唯一标识)
|
||||
- `node-key`:WS 注册 key(与中间件 `ws-keys[节点id]` 一致;**必填**——节点未配置该 key 时中间件拒绝注册并返回 401)
|
||||
|
||||
以上字段均可用环境变量覆盖(`PORTS` / `DNS_SERVER` / `DNSSEC_DNS_SERVER` / `BLOCK_PRIVATE_IPS` / `IPDB` / `CORS` / `ACCESS_TOKEN`)。需要从远端拉取配置时,设置 `remote-config-url` 或环境变量 `REMOTE_CONFIG_URL`(优先级:远端 > 环境变量 > setting.json)。
|
||||
以上字段均可用环境变量覆盖(`PORTS` / `DNS_SERVER` / `DNSSEC_DNS_SERVER` / `BLOCK_PRIVATE_IPS` / `IPDB` / `CORS` / `ACCESS_TOKEN` / `WS_URL` / `NODE_ID` / `NODE_KEY`)。需要从远端拉取配置时,设置 `remote-config-url` 或环境变量 `REMOTE_CONFIG_URL`(优先级:远端 > 环境变量 > setting.json)。
|
||||
|
||||
---
|
||||
|
||||
@@ -44,11 +47,8 @@
|
||||
| `ICP` / `GongAn` | string | 网站备案号(页脚展示) |
|
||||
| `noindex` | boolean | 全站是否禁止搜索引擎索引 |
|
||||
| `v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` | string | IPv4 / IPv6 / 双栈 IP 查询接口 |
|
||||
| `apiBaseUrls` | 节点数组 | 基础 API 节点列表(detail / ssl / whois 等) |
|
||||
| `IPLocationAPIs` | 节点数组 | IP 归属地 / ASN 查询节点列表 |
|
||||
| `TCPing` | 节点分组 | TCPing 节点,按 `DualStack` / `IPv4` / `IPv6` 分组 |
|
||||
| `SpeedTest` | 节点分组 | 测速节点,按 `DualStack` / `IPv4` / `IPv6` 分组 |
|
||||
| `NSLookup` | 节点数组 | DNS 解析节点列表 |
|
||||
| `APIBaseURL` | 节点池 | 拨测上游节点池(whois / ssl / detail / dns / dnssec / tcping / speed),含 `DualStack` / `IPv4` / `IPv6` 三栈 |
|
||||
| `IPLocationAPI` | 节点数组 | IP 归属地 / ASN 查询节点池(纯数组,无栈区分) |
|
||||
|
||||
完整模板(复制到 `frontend-ssr/config/index.ts`,替换占位值即可):
|
||||
|
||||
@@ -75,49 +75,38 @@ const config = {
|
||||
v4OnlyAPI: "https://4.wsmdn.top",
|
||||
v6OnlyAPI: "https://6.wsmdn.top",
|
||||
DualStackAPI: "https://test.wsmdn.top",
|
||||
// 基础 API 节点(detail / ssl / whois 等)
|
||||
apiBaseUrls: [
|
||||
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
|
||||
],
|
||||
// IP 归属地 / ASN 查询节点
|
||||
IPLocationAPIs: [
|
||||
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
|
||||
],
|
||||
// TCPing 节点:IPv4-only 节点只能放 IPv4 分组,不能放 DualStack / IPv6
|
||||
TCPing: {
|
||||
// 拨测节点池(whois / ssl / detail / dns / dnssec / tcping / speed)
|
||||
// IPv4-only 节点只能放 IPv4 栈,IPv6-only 放 IPv6 栈,双栈放 DualStack
|
||||
APIBaseURL: {
|
||||
DualStack: [],
|
||||
IPv4: [
|
||||
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
|
||||
],
|
||||
IPv6: []
|
||||
},
|
||||
// 测速节点:同上,IPv4-only 节点只能放 IPv4 分组
|
||||
SpeedTest: {
|
||||
DualStack: [],
|
||||
IPv4: [
|
||||
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
|
||||
],
|
||||
IPv6: []
|
||||
},
|
||||
// DNS 解析节点
|
||||
NSLookup: [
|
||||
// IP 归属地 / ASN 查询节点池(纯数组,无栈区分)
|
||||
IPLocationAPI: [
|
||||
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
|
||||
]
|
||||
}
|
||||
export { config }
|
||||
```
|
||||
|
||||
> 节点 `id` 需与后端节点 / 中间件 `apiKeys` 的键保持一致,中间件按 `id` 注入 `Authorization: Bearer <key>`。
|
||||
> 节点 `id` 需与后端节点 / 中间件 `api-keys` 的键保持一致,中间件按 `id` 注入 `Authorization: Bearer <key>`;WS 通道的节点注册校验另见中间件 `ws-keys`(与后端 `node-key` 对应)。
|
||||
|
||||
节点数组项均为 `{ label, id, url }` 结构,例如:
|
||||
|
||||
```ts
|
||||
apiBaseUrls: [
|
||||
{ label: "中国 江苏 移动", id: "cn-jiangsu", url: "https://cn-jiangsu.api-ipw.wsmdn.top/" }
|
||||
]
|
||||
APIBaseURL: {
|
||||
DualStack: [
|
||||
{ label: "中国 江苏 移动", id: "cn-jiangsu", url: "https://cn-jiangsu.api-ipw.wsmdn.top/" }
|
||||
],
|
||||
IPv4: [],
|
||||
IPv6: []
|
||||
}
|
||||
```
|
||||
|
||||
`label` 为节点展示名,`id` 为节点唯一标识(中间件 `apiKeys` 也按此 id 注入 key),`url` 为节点 base URL。`TCPing` / `SpeedTest` 节点再多一层 `DualStack` / `IPv4` / `IPv6` 分组,分别对应双栈 / 单栈测试时使用的节点列表。
|
||||
`label` 为节点展示名,`id` 为节点唯一标识(中间件 `api-keys` 按此 id 注入 HTTP 转发 key,WS 注册校验另见 `ws-keys`),`url` 为节点 base URL。节点池结构如下:`APIBaseURL` 为拨测节点池,含 `DualStack` / `IPv4` / `IPv6` 三栈(分别对应双栈 / 单栈测试时使用的节点列表,IPv4-only 节点只能放 `IPv4` 栈,IPv6-only 只能放 `IPv6` 栈,不能放 `DualStack`);`IPLocationAPI` 为 IP 归属地 / ASN 查询节点池,为纯数组(无栈区分)。
|
||||
|
||||
### 出站 IP 检测接口(v4OnlyAPI / v6OnlyAPI / DualStackAPI)
|
||||
|
||||
@@ -164,18 +153,39 @@ apiBaseUrls: [
|
||||
"rate-limit": 120,
|
||||
"ws-port": 8092,
|
||||
"remote-config-url": "",
|
||||
"remote-ingore-config": [],
|
||||
"apiBaseUrls": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
|
||||
"apiKeys": { "cn-jiangsu": "" }
|
||||
"remote-ignore-config": [],
|
||||
"api-base-url": {
|
||||
"DualStack": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
|
||||
"IPv4": [],
|
||||
"IPv6": []
|
||||
},
|
||||
"ip-location-api": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
|
||||
"api-keys": { "cn-jiangsu": "" },
|
||||
"ws-keys": { "cn-jiangsu": "" }
|
||||
}
|
||||
```
|
||||
|
||||
- `rate-limit`:单 IP 每分钟限流次数,默认 `120`,`0` 表示不限流(也可用环境变量 `RATE_LIMIT` 覆盖)
|
||||
- `ws-port`:WS 服务端口,默认 `8092`,`0` 表示关闭 WS 通道(也可用环境变量 `WS_PORT` 覆盖;优先级:远端配置 > 环境变量 > setting.json)
|
||||
- `remote-config-url`:远端配置地址,也可用环境变量 `REMOTE_CONFIG_URL`(环境变量优先)
|
||||
- `remote-ingore-config`:**不被远端配置覆盖的配置项列表**(数组),如 `["port", "rate-limit"]`;也可用环境变量 `REMOTE_INGORE_CONFIG`(JSON 数组字符串,优先于 setting.json)
|
||||
- `apiKeys`:用于向后端注入 `Authorization: Bearer <key>`,优先级:setting.json `apiKeys` > 环境变量 `APIKEYS`(JSON 字符串)。**敏感凭据,强制忽略,不随远端配置覆盖**
|
||||
- 节点数组项(`apiBaseUrls` / `IPLocationAPIs` / `TCPing` / `SpeedTest` / `NSLookup`)与前端 `config/index.ts` 结构一致;节点加 `"ws": true` 可让该节点走 WS 通道通信,详见 [中间件部署 - WS 通道](/guide/deploy-middleware#ws-通道拨测数据经-websocket-传输)
|
||||
- `remote-ignore-config`:**不被远端配置覆盖的配置项列表**(数组),如 `["port", "rate-limit"]`;也可用环境变量 `REMOTE_IGNORE_CONFIG`(JSON 数组字符串,优先于 setting.json)
|
||||
- `api-keys`:用于向后端注入 `Authorization: Bearer <key>`,优先级:setting.json `api-keys` > 环境变量 `API_KEYS`(JSON 字符串)。**敏感凭据,强制忽略,不随远端配置覆盖**
|
||||
- `ws-keys`:WS 注册校验表(`节点id → key`):节点经 WS 注册时,`register` 消息的 `key` 必须与 `ws-keys[节点id]` 一致,否则返回 401 并断开;优先级:setting.json `ws-keys` > 环境变量 `WS_KEYS`(JSON 字符串)。**敏感凭据,强制忽略,不随远端配置覆盖**。与 `api-keys` **相互独立**——`api-keys` 管 HTTP 转发鉴权,`ws-keys` 管 WS 注册校验,可分别配置不同 key(例如 WS 节点单独换 key 不影响 HTTP 转发)
|
||||
- 节点池与前端 `config/index.ts` 结构一致:`APIBaseURL` 为拨测节点池(含 `DualStack` / `IPv4` / `IPv6` 三栈),`IPLocationAPI` 为 IP 归属地 / ASN 节点池(纯数组,无栈);节点加 `"ws": true` 可让该节点走 WS 通道通信,详见 [中间件部署 - WS 通道](/guide/deploy-middleware#ws-通道拨测数据经-websocket-传输)
|
||||
|
||||
### WS 通道配置一览
|
||||
|
||||
WS 通道涉及**中间件(服务端)**与**后端节点(客户端)**两侧配置:
|
||||
|
||||
| 角色 | 配置项 | 环境变量 | 说明 |
|
||||
|------|--------|----------|------|
|
||||
| 中间件(服务端) | `ws-port` | `WS_PORT` | WS 监听端口,默认 `8092`,`0`=关闭;节点连 `ws(s)://<中间件>:<ws-port>/ws` |
|
||||
| 中间件(服务端) | `ws-keys` | `WS_KEYS` | WS 注册校验表:`{"<node-id>": "<key>"}`,节点 `register` 的 key 必须匹配 |
|
||||
| 节点(客户端) | `ws-url` | `WS_URL` | 中间件 WS 完整地址(含 `/ws` 路径),逗号分隔多个则**同时连接全部(多活)** |
|
||||
| 节点(客户端) | `node-id` | `NODE_ID` | 节点 id,与中间件 `api-keys`/`ws-keys` 键一致(建议 UUID) |
|
||||
| 节点(客户端) | `node-key` | `NODE_KEY` | 注册 key,与中间件 `ws-keys[节点id]` 一致,**必填** |
|
||||
|
||||
**链路**:中间件收到 `ws:true` 节点的拨测请求 → 经 WS 发 `probe` → 节点执行探针(直调 webtest 函数 + 缓存)→ `probe_result` 回传 → 中间件返回 HTTP 响应(`Content-Type: application/json`)。节点侧接入步骤见 [后端节点部署 - WS 通道接入](/guide/deploy-node#ws-通道接入可选)。
|
||||
|
||||
---
|
||||
|
||||
@@ -187,8 +197,8 @@ apiBaseUrls: [
|
||||
|
||||
| 组件 | 触发方式 | 远端配置格式 |
|
||||
|------|----------|--------------|
|
||||
| 后端(主线 `main.go`) | `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` | 后端 `setting.json` 格式 |
|
||||
| 后端(`edgeone/`) | `REMOTE_CONFIG_URL` | 后端 `setting.json` 格式 |
|
||||
| 后端(主线 `src/main.go`) | `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` | 后端 `setting.json` 格式 |
|
||||
| 后端(`serverless/edgeone/`) | `REMOTE_CONFIG_URL` | 后端 `setting.json` 格式 |
|
||||
| 独立中间件(middleware-go) | `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` | 中间件 `setting.json` 格式 |
|
||||
|
||||
**合并规则**(重要,逐字段生效):
|
||||
@@ -197,9 +207,9 @@ apiBaseUrls: [
|
||||
- 远端 JSON 中**非空的字段才覆盖**本地值:字符串非空、数组非空、数字非 0(`rate-limit` 为指针语义,缺省=不覆盖,显式 0=不限流)
|
||||
- 部分字段缺省不会冲掉本地配置——远端只需包含要覆盖的字段
|
||||
- **敏感凭据强制忽略,永远不被远端覆盖**(代码写死,无需配置):
|
||||
- 后端 `access_token`:不随远端覆盖(保持 环境变量 > setting.json),远端里的 `access_token` 会被忽略
|
||||
- 中间件 `apiKeys`:不随远端覆盖,远端里的 `apiKeys` 会被忽略
|
||||
- `remote-ingore-config`(后端键名 `remote-ingore-config` / env `REMOTE_INGORE_CONFIG`):额外指定**不被远端覆盖的配置项列表**,数组中的键即使远端下发也不生效。适用于 access_token / apiKeys 之外的敏感项(如 `ipw-node-key`、`dns-server` 等),也可覆盖"非空才覆盖"规则
|
||||
- 后端 `access-token`:不随远端覆盖(保持 环境变量 > setting.json),远端里的 `access-token` 会被忽略
|
||||
- 中间件 `api-keys` / `ws-keys`:不随远端覆盖,远端里的 `api-keys`/`ws-keys` 会被忽略
|
||||
- `remote-ignore-config`(后端键名 `remote-ignore-config` / env `REMOTE_IGNORE_CONFIG`):额外指定**不被远端覆盖的配置项列表**,数组中的键即使远端下发也不生效。适用于 access-token / api-keys / ws-keys 之外的敏感项(如 `node-key`、`dns-server` 等),也可覆盖"非空才覆盖"规则
|
||||
|
||||
**拉取行为**:
|
||||
|
||||
@@ -234,11 +244,16 @@ REMOTE_CONFIG_URL=https://example.com/ipw-config.json ./lemonipw
|
||||
"port": "8091",
|
||||
"cors": "",
|
||||
"rate-limit": 120,
|
||||
"apiBaseUrls": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ]
|
||||
"APIBaseURL": {
|
||||
"DualStack": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
|
||||
"IPv4": [],
|
||||
"IPv6": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **⚠️ 敏感凭据不得上传远端**:`apiKeys`(及后端 `access_token`、节点 key 等密钥)**禁止**写入远端配置——即使写了也会被强制忽略。凭据只放本地 setting.json 或环境变量。
|
||||
> [!WARNING]
|
||||
> 敏感凭据不得上传远端:`api-keys`/`ws-keys`(及后端 `access-token`、节点 key 等密钥)**禁止**写入远端配置——即使写了也会被强制忽略。凭据只放本地 setting.json 或环境变量。
|
||||
|
||||
部署时:
|
||||
|
||||
@@ -256,7 +271,7 @@ systemd 中同样用 `Environment="REMOTE_CONFIG_URL=..."` 配置。
|
||||
|
||||
**安全与排查**:
|
||||
|
||||
- **⚠️ 敏感凭据不得上传远端**:远端配置(Gist / 对象存储 / 静态托管等)**禁止包含任何密钥**——`apiKeys`、`access_token`、节点 key(`ipw-node-key`)等。这些值会被强制忽略,但远端文件本身一旦泄露等于直接暴露凭据;凭据一律放本地 `setting.json` 或环境变量注入
|
||||
- **敏感凭据不得上传远端**:远端配置(Gist / 对象存储 / 静态托管等)**禁止包含任何密钥**——`api-keys`/`ws-keys`、`access-token`、节点 key(`node-key`)等。这些值会被强制忽略,但远端文件本身一旦泄露等于直接暴露凭据;凭据一律放本地 `setting.json` 或环境变量注入
|
||||
- 远端 URL 必须能被节点访问;优先使用 **HTTPS** 地址
|
||||
- 远端配置文件如果放在公开可访问的位置(公开 Gist、无鉴权对象存储),即便不含密钥,也等于把节点拓扑、节点 id 等敏感信息公之于众,建议放在私有仓库 / 带鉴权的对象存储 / 内部服务上
|
||||
- 常见失败排查:
|
||||
|
||||
@@ -32,9 +32,8 @@ pnpm dev
|
||||
|--------|------|
|
||||
| `siteUrl` | 站点对外地址 |
|
||||
| `siteName` | 站点名称(页面标题 / 描述 / 页脚品牌) |
|
||||
| `apiBaseUrls` | 后端上游节点列表(whois / ssl / detail) |
|
||||
| `IPLocationAPIs` | IP 归属地 / ASN 上游节点列表 |
|
||||
| `TCPing` / `SpeedTest` / `NSLookup` | 对应功能的多节点候选(DualStack / IPv4 / IPv6) |
|
||||
| `APIBaseURL` | 拨测上游节点池(whois / ssl / detail / dns / dnssec / tcping / speed),含 `DualStack` / `IPv4` / `IPv6` 三栈 |
|
||||
| `IPLocationAPI` | IP 归属地 / ASN 上游节点池(纯数组,无栈区分) |
|
||||
| `Middleware` | 外部独立中间件 base URL 列表,`/middleware/*` 请求依次尝试、失败重试下一个 |
|
||||
| `EnableInternalMiddleware` | 是否启用前端内置中间件(本地转发,作为候选列表最后一位兜底),默认 `true` |
|
||||
| `rateLimitPerMinute` | 内置中间件单 IP 限流次数(次/分钟),默认 `120`,`0` 表示不限流 |
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
> cp middleware.setting.json.example setting.json
|
||||
> ```
|
||||
>
|
||||
> 复制后按需修改 `apiBaseUrls` / `apiKeys` / `cors` 等配置项。
|
||||
> 复制后按需修改 `api-base-url` / `ip-location-api` / `api-keys` / `ws-keys` / `cors` 等配置项。
|
||||
|
||||
## 方案一:Docker
|
||||
|
||||
@@ -27,7 +27,7 @@ docker run -d -p 8091:8091 \
|
||||
middleware-go
|
||||
```
|
||||
|
||||
配置通过挂载 `setting.json` 提供(`apiBaseUrls` / `apiKeys` / `cors` / `rate-limit` / `remote-config-url` 等;`rate-limit` 为单 IP 每分钟限流次数,默认 120,0 表示不限流,可用环境变量 `RATE_LIMIT` 覆盖;`remote-config-url` 为远端配置地址,可用环境变量 `REMOTE_CONFIG_URL` 覆盖;**`apiKeys` 为敏感凭据,不随远端配置覆盖**)。
|
||||
配置通过挂载 `setting.json` 提供(`api-base-url` / `ip-location-api` / `api-keys` / `ws-keys` / `cors` / `rate-limit` / `remote-config-url` 等;`rate-limit` 为单 IP 每分钟限流次数,默认 120,0 表示不限流,可用环境变量 `RATE_LIMIT` 覆盖;`remote-config-url` 为远端配置地址,可用环境变量 `REMOTE_CONFIG_URL` 覆盖;**`api-keys` / `ws-keys` 为敏感凭据,不随远端配置覆盖**——`api-keys` 管 HTTP 转发鉴权,`ws-keys` 管 WS 注册校验,两者相互独立)。
|
||||
|
||||
## 方案二:二进制
|
||||
|
||||
@@ -37,9 +37,9 @@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o middleware-go-linux-amd64 .
|
||||
./middleware-go-linux-amd64 # 运行(与 setting.json 同目录)
|
||||
```
|
||||
|
||||
所有配置可用环境变量覆盖(`API_BASE_URLS` / `IP_LOCATION_APIS` / `CORS` / `APIKEYS` / `RATE_LIMIT` 等,数组/对象用 JSON 字符串),优先级:**环境变量 > setting.json > 默认值**(远端配置 `REMOTE_CONFIG_URL` 高于两者,详见 [配置文件 - 远端配置](/guide/config#远端配置remoteconfigurl))。
|
||||
所有配置可用环境变量覆盖(`API_BASE_URLS` / `IP_LOCATION_APIS` / `CORS` / `API_KEYS` / `WS_KEYS` / `RATE_LIMIT` 等,数组/对象用 JSON 字符串),优先级:**环境变量 > setting.json > 默认值**(远端配置 `REMOTE_CONFIG_URL` 高于两者,详见 [配置文件 - 远端配置](/guide/config#远端配置remoteconfigurl))。
|
||||
|
||||
需要守护运行时,参考 [快速入门](/guide/getting-started) 中的 systemd 配置(`WorkingDirectory` 指向 `middleware-go/setting.json` 所在目录)。
|
||||
需要守护运行时,参考 [后端节点部署 - 方案五:一键安装](/guide/deploy-node#方案五一键安装installsh) 的 systemd 管理方式(`ExecStart` 指向中间件二进制,`WorkingDirectory` 指向 `middleware-go/setting.json` 所在目录)。
|
||||
|
||||
## WS 通道(拨测数据经 WebSocket 传输)
|
||||
|
||||
@@ -61,10 +61,11 @@ middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入
|
||||
```json
|
||||
{
|
||||
"port": "8091",
|
||||
"apiBaseUrls": [
|
||||
{ "label": "上海 腾讯云 BGP", "id": "tencent-sh", "url": "", "ws": true }
|
||||
],
|
||||
"TCPing": { "DualStack": [], "IPv4": [], "IPv6": [ { "label": "河北 秦皇岛 联通", "id": "cn-hebei-qinhuangdao", "url": "https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/", "ws": true } ] }
|
||||
"APIBaseURL": {
|
||||
"DualStack": [ { "label": "上海 腾讯云 BGP", "id": "tencent-sh", "url": "", "ws": true } ],
|
||||
"IPv4": [],
|
||||
"IPv6": [ { "label": "河北 秦皇岛 联通", "id": "cn-hebei-qinhuangdao", "url": "https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/", "ws": true } ]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -76,7 +77,7 @@ middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入
|
||||
|
||||
| 方向 | type | data 说明 |
|
||||
|------|------|-----------|
|
||||
| 节点 → middleware | `register` | `{ "nodeId": "...", "key": "..." }`,连接后首条消息,同 id 新连接顶掉旧连接;`key` 为注册凭证,与 setting.json `apiKeys` 配置比对 |
|
||||
| 节点 → middleware | `register` | `{ "nodeId": "...", "key": "..." }`,连接后首条消息,同 id 新连接顶掉旧连接;`key` 为注册凭证,与 setting.json `ws-keys` 配置比对 |
|
||||
| middleware → 节点 | `register_ok` | `{ "heartbeatSeconds": 20 }` |
|
||||
| middleware → 节点 | `register_error` | `{ "code": 401, "command": "invalid key" }`(注册凭证错误时返回并断开连接) |
|
||||
| middleware → 节点 | `probe` | `{ "requestId": "...", "apiType": "tcping", "raw": "qq.com", "query": {"port":"443"} }`,拨测请求(HTTP/WS 双通道时前端请求仍走 HTTP,middleware 内部转 WS) |
|
||||
@@ -87,22 +88,22 @@ middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入
|
||||
|
||||
### 节点侧接入(WS 客户端)
|
||||
|
||||
1. 连接 `ws://<中间件IP>:8092/ws`
|
||||
2. 首条消息发 `register { "nodeId": "<与中间件配置一致的节点 id>", "key": "<注册凭证>" }`(节点在 setting.json `apiKeys` 里配了 key 就必须传对;未配置 key 的开放节点可不传)
|
||||
1. 连接 `ws://<中间件IP>:8092/ws`(本仓库后端节点接入方式见 [后端节点部署 - WS 通道接入](/guide/deploy-node#ws-通道接入可选),配置 `WS_URL` / `NODE_ID` / `NODE_KEY` 即可自动连接注册;`WS_URL` 支持逗号分隔多个中间件地址,节点会**同时连接全部(多活)**,任一断开只重连自己)
|
||||
2. 首条消息发 `register { "nodeId": "<与中间件配置一致的节点 id>", "key": "<注册凭证>" }`(节点在 setting.json `ws-keys` 里配了 key 就必须传对;未配置 key 的开放节点可不传)
|
||||
3. 收到 `probe` → 执行拨测 → 回 `probe_result`(必须携带原 `requestId`,支持乱序返回,同一连接可并发多个拨测)
|
||||
4. 收到 `ping` → 回 `pong`;`status` 可忽略或记录
|
||||
5. 断线后重连并重新 `register` 即可
|
||||
|
||||
### 注册 key 校验
|
||||
|
||||
节点注册时中间件按 `apiKeys` 配置校验 key:
|
||||
节点注册时中间件按 `ws-keys` 配置校验 key(与 `api-keys` HTTP 转发鉴权相互独立):
|
||||
|
||||
- 节点在 `apiKeys` 配置了 key(非空)→ `register` 必须携带正确 key,不传或传错返回 `register_error { "code": 401, "command": "invalid key" }` 并**断开连接**,不进入数据阶段
|
||||
- 节点未配置 key(`apiKeys` 无该项或为空)→ 开放注册,不传 key 即可
|
||||
- 节点在 `ws-keys` 配置了 key(非空)→ `register` 必须携带正确 key,不传或传错返回 `register_error { "code": 401, "command": "invalid key" }` 并**断开连接**,不进入数据阶段
|
||||
- 节点未配置 key(`ws-keys` 无该项或为空)→ 开放注册,不传 key 即可
|
||||
|
||||
与 HTTP 通道的鉴权语义一致(配置了 key 就必须带凭据,留空 = 开放)。
|
||||
|
||||
### 说明
|
||||
|
||||
- 注册阶段按节点 `apiKeys` 配置校验 key(配了 key 必须传对,否则 401 断开;未配置则开放,详见上文「注册 key 校验」);拨测请求经 WS 转发时不携带 `Authorization` 头(后续由节点侧自行决定是否需要额外鉴权)
|
||||
- 注册阶段按节点 `ws-keys` 配置校验 key(配了 key 必须传对,否则 401 断开;未配置则开放,详见上文「注册 key 校验」);拨测请求经 WS 转发时不携带 `Authorization` 头(后续由节点侧自行决定是否需要额外鉴权)
|
||||
- `ws:true` 且 WS 未启用(`WS_PORT=0`)时,自动回退原 HTTP 转发
|
||||
+76
-10
@@ -4,31 +4,31 @@
|
||||
|
||||
## 方案一: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)。部署后需要:
|
||||
>
|
||||
> 1. 给节点配置 `SINGLE_STACK=ipv4`(声明节点为 IPv4 单栈),跳过 IPv6 相关测试,避免无意义的错误日志与超时
|
||||
> 2. 在前端配置 `config/index.ts` 中,**该节点只能填入 `TCPing.IPv4` / `SpeedTest.IPv4` 等 IPv4 分组**,不要放进 `DualStack` / `IPv6` 分组
|
||||
> 2. 在前端配置 `config/index.ts` 中,**该节点只能填入 `APIBaseURL.IPv4`**(拨测节点池的 IPv4 栈),不要放进 `DualStack` / `IPv6` 栈
|
||||
|
||||
## 方案三:Docker
|
||||
|
||||
@@ -46,15 +46,81 @@ 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 小时自动更新一次。需要守护运行时,参考 [快速入门](/guide/getting-started) 中的 systemd 配置。
|
||||
首次启动自动下载 IP 数据库(约 200MB),之后每 24 小时自动更新一次。需要守护运行时,可参考下方「方案五」生成的 systemd 服务,或手动创建 service 文件(`ExecStart` 指向二进制,`WorkingDirectory` 指向 `setting.json` 所在目录)。
|
||||
|
||||
## 方案五:一键安装(install.sh)
|
||||
|
||||
仓库根目录提供 `install.sh` 一键安装脚本:自动检测架构 → 下载最新 release 二进制 → **交互式输入配置**(无需准备 setting.json,配置以环境变量注入)→ 生成 systemd 服务并守护进程。
|
||||
|
||||
```bash
|
||||
# 先下载脚本到本地(勿用 curl | bash 管道执行,避免下载中断导致语法解析错误)
|
||||
git clone https://github.com/nomdn/ipw-cn && cd ipw-cn # 或
|
||||
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o install.sh
|
||||
|
||||
sudo bash install.sh
|
||||
```
|
||||
|
||||
**交互式配置项**(直接回车使用默认值):
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| 安装目录 | `/opt/lemon-ipw` | 二进制与工作目录 |
|
||||
| 监听端口 | `8080` | `PORTS` |
|
||||
| access-token | 留空 | 留空 = 不启用鉴权 |
|
||||
| DNS 服务器 | `119.28.28.28:53` | 主从逗号分隔(`119.28.28.28:53,223.5.5.5:53`) |
|
||||
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server |
|
||||
| IP 数据库 | `Y` | `IPDB`,首次启动下载约 200MB |
|
||||
| CORS | 留空 | 逗号分隔允许来源 |
|
||||
| 远端配置地址 | 留空 | `REMOTE_CONFIG_URL` |
|
||||
| WS 通道接入 | `N` | 选 `y` 后继续输入 WS_URL / NODE_ID / NODE_KEY |
|
||||
| 其他环境变量 | 无 | 每行一个 `K=V`,空行结束 |
|
||||
|
||||
**WS 接入特殊规则**:
|
||||
|
||||
- `WS_URL` 留空时自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址时须为**完整路径**(含 `wss://` 前缀与 `/ws` 路径,如 `wss://host:8092/ws`),逗号分隔多个则**同时连接全部(多活)**
|
||||
- `NODE_ID` **强制自动生成 UUID**(无需输入,如 `4580ea9d-2a16-4b8e-8090-5f2e7f0a2229`),适合作为节点唯一标识
|
||||
- `NODE_KEY` **必填**:不加 key 禁止启用 WS(中间件 `ws-keys` 必须包含该节点,否则注册被拒 401);安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 setting.json 的 `ws-keys`
|
||||
|
||||
**安装完成后自动生成并启动 systemd 服务**(`lemon-ipw.service`),配置以 `Environment="K=V"` 注入,无需 setting.json。常用管理命令:
|
||||
|
||||
```bash
|
||||
systemctl status lemon-ipw # 状态
|
||||
journalctl -u lemon-ipw -f # 日志
|
||||
sudo systemctl restart lemon-ipw # 重启(改配置后)
|
||||
```
|
||||
|
||||
修改配置:`sudo systemctl edit --full lemon-ipw` 编辑环境变量,保存后 `sudo systemctl daemon-reload && sudo systemctl restart lemon-ipw` 生效。
|
||||
|
||||
> [!WARNING]
|
||||
> **版本兼容提醒**:v1.1.0 及更早的 release 二进制读的是旧环境变量名 `IPW_WS_URL` / `IPW_NODE_ID` / `IPW_NODE_KEY`(8-20 及之前构建)。install.sh 生成的 service 用的是新名 `WS_URL` / `NODE_ID` / `NODE_KEY`——**旧二进制 + 新 service 会因读不到配置而完全不启用 WS**(启动日志无任何 ws 输出)。升级到包含改名后的新 release 即可;急用可临时把 service 环境变量改回 `IPW_` 前缀。
|
||||
|
||||
## 配置
|
||||
|
||||
后端配置见 [配置文件](/guide/config):`setting.json` 或环境变量,支持远端配置(`REMOTE_CONFIG_URL`,优先级:远端 > 环境变量 > setting.json)。
|
||||
|
||||
## WS 通道接入(可选)
|
||||
|
||||
后端节点可作为 WS 客户端接入 [独立中间件](/guide/deploy-middleware) 的 WS 通道,拨测请求经 WebSocket 转发,节点本地执行探针后回传。不配置则完全走原 HTTP 接口。
|
||||
|
||||
**配置三个环境变量**(或 setting.json 的 `ws-url` / `node-id` / `node-key`,env 优先;也支持远端配置覆盖,除非列入 `remote-ignore-config`):
|
||||
|
||||
```bash
|
||||
WS_URL=ws://<中间件IP>:8092/ws \
|
||||
NODE_ID=<节点id,与中间件 api-base-url / ip-location-api 池的节点 id 一致> \
|
||||
NODE_KEY=<注册key,与中间件 ws-keys[节点id] 一致;节点未配置 key 可留空> \
|
||||
./lemonipw
|
||||
```
|
||||
|
||||
- `WS_URL` 支持**逗号分隔多个中间件**(`ws://中间件1:8092/ws,ws://中间件2:8092/ws`),**同时连接全部(多活)**:每个地址独立注册并保持连接,任一断开只重连自己,不影响其他中间件;注意多个地址指向同一中间件实例时,同 `nodeId` 后注册的连接会顶掉先前的
|
||||
- **双向心跳**:节点每 10s 发 ping,中间件回 pong;心跳发送连续失败 3 次判定中间件不可用,断开后 3s 重试(注册被拒 30s 重试)
|
||||
- 收到 `probe` 后节点直调探针函数(带缓存)并回 `probe_result`,结果与 HTTP 通道一致
|
||||
- 节点注册带 `NODE_KEY`:中间件 `ws-keys` 里配了 key 就必须传对,否则返回 401 并断开;未配置 key 的节点开放注册
|
||||
|
||||
**注意**:`NODE_ID` 必须与中间件 `APIBaseURL` / `IPLocationAPI` 池中的节点 `id` 一致,且该节点需配置 `"ws": true` 才会走 WS 通道。未连接中间件时,`ws:true` 节点的拨测会返回 502。
|
||||
+62
-142
@@ -1,155 +1,75 @@
|
||||
# 快速开始
|
||||
|
||||
> 本章节将指导您以最低成本部署一个功能完整的测试站
|
||||
|
||||
## 尝试一下
|
||||
我们的开发团队已经部署了一个功能完整的预览站:
|
||||
|
||||
我们的开发团队已经部署了一个功能完整的预览站:
|
||||
[预览站点](https://ipw.wsmdn.top/)
|
||||
|
||||
## 前置准备
|
||||
1. Git
|
||||
2. Node.js 24+
|
||||
3. Go1.26
|
||||
4. 一台双栈网络环境的服务器(Linux+Systemd)
|
||||
|
||||
- 一台 Linux 服务器(需 root 权限 + systemd)
|
||||
- 服务器上可访问 GitHub(下载 release 二进制)
|
||||
- 无需本地编译环境,无需准备配置文件
|
||||
|
||||
## 开始部署
|
||||
### 构建后端
|
||||
1. 拉取代码
|
||||
```bash
|
||||
git clone https://github.com/nomdn/ipw-cn && cd ipw-cn
|
||||
```
|
||||
2. 安装依赖
|
||||
```bash
|
||||
go mod tidy
|
||||
```
|
||||
3. 构建程序
|
||||
```bash
|
||||
go build
|
||||
```
|
||||
### 构建中间层(可选)
|
||||
> [!TIP]
|
||||
> 如果你的服务器到测速节点的链接延迟不大,可以跳过这一步
|
||||
1. 切换目录
|
||||
```bash
|
||||
cd middleware-go
|
||||
```
|
||||
2. 安装依赖
|
||||
```bash
|
||||
go mod tidy
|
||||
```
|
||||
3. 构建程序
|
||||
```bash
|
||||
go build
|
||||
```
|
||||
### 构建前端
|
||||
1. 切换目录
|
||||
```bash
|
||||
cd frontend-ssr
|
||||
```
|
||||
2. 安装依赖
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
3. 本地开发(可选)
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
4. 生产构建
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
> 前端部署方式(Cloudflare Workers / EdgeOne Makers 等)见 [前端部署](/guide/deploy-frontend)。
|
||||
|
||||
### 配置文件
|
||||
|
||||
> 完整字段说明、模板与远端配置用法见 [配置文件](/guide/config)。以下为三个组件的最小示例。
|
||||
|
||||
**后端**(`setting.json`,env 可覆盖 `PORTS` / `DNS_SERVER` / `CORS` / `ACCESS_TOKEN` 等):
|
||||
|
||||
```json
|
||||
{
|
||||
"port": 8080,
|
||||
"dns-server": "119.28.28.28:53",
|
||||
"cors": "",
|
||||
"access_token": ""
|
||||
}
|
||||
```
|
||||
|
||||
**前端**(`frontend-ssr/config/index.ts`,节点 `id` 与中间件 `apiKeys` 键对应):
|
||||
|
||||
```ts
|
||||
const config = {
|
||||
siteUrl: "https://your-domain.com/",
|
||||
siteName: "你的站点名称",
|
||||
EnableInternalMiddleware: true,
|
||||
rateLimitPerMinute: 120,
|
||||
Middleware: <string[]>[],
|
||||
umamiHost: "", umamiScriptUrl: "", umamiWebsiteId: "",
|
||||
ICP: "", GongAn: "",
|
||||
noindex: false,
|
||||
v4OnlyAPI: "https://4.wsmdn.top", v6OnlyAPI: "https://6.wsmdn.top", DualStackAPI: "https://test.wsmdn.top",
|
||||
apiBaseUrls: [
|
||||
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
|
||||
],
|
||||
IPLocationAPIs: [
|
||||
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
|
||||
],
|
||||
TCPing: { DualStack: [], IPv4: [ { label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" } ], IPv6: [] },
|
||||
SpeedTest: { DualStack: [], IPv4: [ { label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" } ], IPv6: [] },
|
||||
NSLookup: [
|
||||
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
|
||||
]
|
||||
}
|
||||
export { config }
|
||||
```
|
||||
|
||||
**独立中间件**(`middleware-go/setting.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"port": "8091",
|
||||
"cors": "",
|
||||
"rate-limit": 120,
|
||||
"remote-config-url": "",
|
||||
"apiBaseUrls": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
|
||||
"apiKeys": { "cn-jiangsu": "" }
|
||||
}
|
||||
```
|
||||
|
||||
> 完整字段(`http-timeout-seconds` / `ws-port` / `remote-ingore-config` 等)见 [配置文件 - 独立中间件](/guide/config#独立中间件middleware-go)。**敏感凭据(`apiKeys` / `access_token`)不得上传远端配置,仅放本地或环境变量。**
|
||||
|
||||
前端内置中间件通过环境变量 **`NUXT_APIKEYS`**(JSON 字符串,如 `'{"cn-jiangsu":"your-key"}'`)注入 key。
|
||||
|
||||
### systemd 守护进程
|
||||
|
||||
以 `/opt/lemon-ipw` 为安装目录(二进制与 `setting.json` 放同一目录),创建服务文件:
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/lemon-ipw.service
|
||||
[Unit]
|
||||
Description=Lemon IPW Backend
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/lemon-ipw
|
||||
ExecStart=/opt/lemon-ipw/lemonipw
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
> `WorkingDirectory` 必须指向 `setting.json` 所在目录,后端从当前目录读取配置。
|
||||
|
||||
启动与管理:
|
||||
### 交互式一键部署(推荐)
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now lemon-ipw
|
||||
sudo systemctl status lemon-ipw
|
||||
sudo journalctl -u lemon-ipw -f
|
||||
# 获取安装脚本(任选其一)
|
||||
git clone https://github.com/nomdn/ipw-cn && cd ipw-cn # 或
|
||||
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o install.sh
|
||||
|
||||
# 执行安装(root 权限)
|
||||
sudo bash install.sh
|
||||
```
|
||||
|
||||
验证:`curl http://127.0.0.1:8080/` 返回 `{"status":"ok"}` 即正常。中间件也可用同样的方式守护(`ExecStart` 指向中间件二进制,`WorkingDirectory` 指向 `middleware-go/setting.json` 所在目录)。
|
||||
> [!WARNING]
|
||||
> 请勿使用 `curl ... | sudo bash` 管道方式执行:脚本经管道边下载边执行,网络慢/抖动时 bash 会读到不完整的脚本内容,报 `syntax error near unexpected token` 类错误(脚本本身无语法问题)。务必**先下载完整文件再执行**。
|
||||
|
||||
脚本会自动完成:检测架构 → 下载最新 release 二进制 → **交互式输入配置** → 生成并启动 systemd 守护进程。
|
||||
|
||||
**交互式配置项**(直接回车使用默认值):
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| 安装目录 | `/opt/lemon-ipw` | 二进制与工作目录 |
|
||||
| 监听端口 | `8080` | 后端 HTTP 端口 |
|
||||
| access-token | 留空 | 留空 = 不启用鉴权 |
|
||||
| DNS 服务器 | `119.28.28.28:53` | 主从逗号分隔(如 `119.28.28.28:53,223.5.5.5:53`) |
|
||||
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server |
|
||||
| IP 数据库 | `Y` | 首次启动下载约 200MB |
|
||||
| CORS | 留空 | 逗号分隔允许来源 |
|
||||
| 远端配置地址 | 留空 | `REMOTE_CONFIG_URL` |
|
||||
| WS 通道接入 | `N` | 选 `y` 后输入 WS_URL / NODE_ID / NODE_KEY |
|
||||
| 其他环境变量 | 无 | 每行一个 `K=V`,空行结束 |
|
||||
|
||||
**WS 通道接入规则**(选 `y` 时):
|
||||
|
||||
- `WS_URL` 留空自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址须为完整路径(`wss://host:8092/ws` 形式);逗号分隔多个则**同时连接全部(多活)**
|
||||
- `NODE_ID` **强制自动生成 UUID**(无需输入)
|
||||
- `NODE_KEY` **必填**:不加 key 禁止启用 WS;安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 `ws-keys`
|
||||
|
||||
### 验证安装
|
||||
|
||||
```bash
|
||||
systemctl status lemon-ipw # 服务状态(active running)
|
||||
curl http://127.0.0.1:8080/ # 应返回 {"status":"ok"}
|
||||
journalctl -u lemon-ipw -f # 实时日志
|
||||
```
|
||||
|
||||
### 修改配置
|
||||
|
||||
```bash
|
||||
sudo systemctl edit --full lemon-ipw # 编辑环境变量
|
||||
sudo systemctl daemon-reload && sudo systemctl restart lemon-ipw
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
- 部署**独立中间件**(WS 通道 / 多节点转发):见 [中间件部署](/guide/deploy-middleware)
|
||||
- 部署**前端**(Cloudflare Workers / EdgeOne Makers 等):见 [前端部署](/guide/deploy-frontend)
|
||||
- 完整**配置说明**(后端 / 前端 / 中间件 / 远端配置):见 [配置文件](/guide/config)
|
||||
- 手动**编译部署**(源码构建三个组件):见 [后端节点部署](/guide/deploy-node) / [中间件部署](/guide/deploy-middleware) / [前端部署](/guide/deploy-frontend)
|
||||
+2
-2
@@ -14,7 +14,7 @@ Lemon IPW 采用前后端分离架构,**后端节点、独立中间件、前
|
||||
|
||||
## 快速开始
|
||||
|
||||
想用最低成本部署一套功能完整的测试站,直接看 [快速入门](/guide/getting-started):后端构建 → 前端构建 → 配置文件 → systemd 守护。
|
||||
想用最低成本部署一套功能完整的测试站,直接看 [快速入门](/guide/getting-started):交互式一键部署(install.sh)→ systemd 守护。
|
||||
|
||||
## CI/CD
|
||||
|
||||
@@ -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 附带配置文件) |
|
||||
@@ -55,7 +55,7 @@ const result = ref<ASNResult | null>(null)
|
||||
const error = ref('')
|
||||
const userIP = ref('')
|
||||
|
||||
const apiList = config.IPLocationAPIs
|
||||
const apiList = config.IPLocationAPI
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
|
||||
|
||||
@@ -72,7 +72,11 @@ const recordTypes = [
|
||||
{ value: 'ptr', label: 'PTR 记录' }
|
||||
]
|
||||
|
||||
const dnsServerFetches = config.NSLookup.map((server) => {
|
||||
const dnsServerFetches = [
|
||||
...config.APIBaseURL.DualStack,
|
||||
...config.APIBaseURL.IPv4,
|
||||
...config.APIBaseURL.IPv6
|
||||
].map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/dns/" + recordType.value + "/" + domain.value);
|
||||
const { data, error, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
|
||||
@@ -69,7 +69,11 @@ const loading = ref(false)
|
||||
const serverResults = ref<ServerResult[]>([])
|
||||
const userIP = ref('')
|
||||
|
||||
const apiList = config.NSLookup
|
||||
const apiList = [
|
||||
...config.APIBaseURL.DualStack,
|
||||
...config.APIBaseURL.IPv4,
|
||||
...config.APIBaseURL.IPv6
|
||||
]
|
||||
|
||||
const dnssecFetches = apiList.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/dnssec/" + tmpDomain.value)
|
||||
|
||||
@@ -43,7 +43,7 @@ curl ${config.v6OnlyAPI}
|
||||
curl ${config.DualStackAPI}
|
||||
`.trim();
|
||||
const html = ref('');
|
||||
const apiList = config.IPLocationAPIs
|
||||
const apiList = config.IPLocationAPI
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]!.id)
|
||||
const ipAddress = ref('');
|
||||
@@ -144,10 +144,10 @@ onMounted(async () => {
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.ip2region && IPLocation.ip2region.split('|').length >= 4">
|
||||
<tr v-if="IPLocation.ip2region && typeof IPLocation.ip2region === 'object' && (IPLocation.ip2region.administrative_area || IPLocation.ip2region.city)">
|
||||
<td class="table-label">IP2Region</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.split("|")[0] }} {{ 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.geocn && (IPLocation.geocn.administrative_area || IPLocation.geocn.city || IPLocation.geocn.district)">
|
||||
<td class="table-label">GeoCN(仅中国大陆)</td>
|
||||
|
||||
@@ -68,7 +68,7 @@ const serverResults = ref<ServerResult[]>([])
|
||||
// =============================================
|
||||
|
||||
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
|
||||
const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
|
||||
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + domain.value);
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
@@ -78,7 +78,7 @@ const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
|
||||
});
|
||||
|
||||
/** IPv6 专用服务器:仅支持 IPv6 的节点 */
|
||||
const ipv6Fetches = config.SpeedTest.IPv6.map((server) => {
|
||||
const ipv6Fetches = config.APIBaseURL.IPv6.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + domain.value);
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
|
||||
@@ -79,8 +79,8 @@ const loading = ref(false)
|
||||
const serverResults = ref<ServerResult[]>([])
|
||||
|
||||
const allServers = [
|
||||
...config.TCPing.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
|
||||
...config.TCPing.IPv6.map((s: ServerConfig) => ({ ...s, type: 'IPv6' }))
|
||||
...config.APIBaseURL.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
|
||||
...config.APIBaseURL.IPv6.map((s: ServerConfig) => ({ ...s, type: 'IPv6' }))
|
||||
];
|
||||
|
||||
const tcpingFetches = allServers.map((server) => {
|
||||
@@ -95,7 +95,7 @@ const tcpingFetches = allServers.map((server) => {
|
||||
function initServerResults() {
|
||||
const results: ServerResult[] = []
|
||||
|
||||
config.TCPing.DualStack.forEach((server: ServerConfig) => {
|
||||
config.APIBaseURL.DualStack.forEach((server: ServerConfig) => {
|
||||
results.push({
|
||||
label: server.label,
|
||||
loading: false,
|
||||
@@ -103,7 +103,7 @@ function initServerResults() {
|
||||
})
|
||||
})
|
||||
|
||||
config.TCPing.IPv6.forEach((server: ServerConfig) => {
|
||||
config.APIBaseURL.IPv6.forEach((server: ServerConfig) => {
|
||||
results.push({
|
||||
label: server.label,
|
||||
loading: false,
|
||||
|
||||
@@ -63,7 +63,7 @@ interface PerformanceCheckItem {
|
||||
download_speed: number
|
||||
is_reachable: boolean
|
||||
}
|
||||
const apiList = config.apiBaseUrls
|
||||
const apiList = config.APIBaseURL.DualStack
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
const tmpDomain = ref('https://www.zakoflare.com')
|
||||
|
||||
@@ -67,7 +67,7 @@ curl ${config.v6OnlyAPI}
|
||||
curl ${config.DualStackAPI}
|
||||
`.trim();
|
||||
const html = ref('');
|
||||
const apiList = config.IPLocationAPIs
|
||||
const apiList = config.IPLocationAPI
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
const ipAddress = ref('');
|
||||
@@ -182,10 +182,10 @@ onMounted(async () => {
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.ip2region && IPLocation.ip2region.split('|').length >= 4">
|
||||
<tr v-if="IPLocation.ip2region && typeof IPLocation.ip2region === 'object' && (IPLocation.ip2region.administrative_area || IPLocation.ip2region.city)">
|
||||
<td class="table-label">IP2Region</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.split("|")[0] }} {{ 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.geocn && (IPLocation.geocn.administrative_area || IPLocation.geocn.city || IPLocation.geocn.district)">
|
||||
<td class="table-label">GeoCN(仅中国大陆)</td>
|
||||
|
||||
@@ -68,7 +68,7 @@ const serverResults = ref<ServerResult[]>([])
|
||||
// =============================================
|
||||
|
||||
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
|
||||
const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
|
||||
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + domain.value);
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
@@ -78,7 +78,7 @@ const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
|
||||
});
|
||||
|
||||
/** IPv4 专用服务器:仅支持 IPv4 的节点 */
|
||||
const ipv4Fetches = config.SpeedTest.IPv4.map((server) => {
|
||||
const ipv4Fetches = config.APIBaseURL.IPv4.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + domain.value);
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
|
||||
@@ -65,7 +65,7 @@ interface SSLCheckItem {
|
||||
is_expired: boolean
|
||||
is_reachable: boolean
|
||||
}
|
||||
const apiList = config.apiBaseUrls
|
||||
const apiList = config.APIBaseURL.DualStack
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
const tmpDomain = ref('https://www.zakoflare.com')
|
||||
|
||||
@@ -78,8 +78,8 @@ const loading = ref(false)
|
||||
const serverResults = ref<ServerResult[]>([])
|
||||
|
||||
const allServers = [
|
||||
...config.TCPing.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
|
||||
...config.TCPing.IPv4.map((s: ServerConfig) => ({ ...s, type: 'IPv4' }))
|
||||
...config.APIBaseURL.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
|
||||
...config.APIBaseURL.IPv4.map((s: ServerConfig) => ({ ...s, type: 'IPv4' }))
|
||||
];
|
||||
|
||||
const tcpingFetches = allServers.map((server) => {
|
||||
@@ -94,7 +94,7 @@ const tcpingFetches = allServers.map((server) => {
|
||||
function initServerResults() {
|
||||
const results: ServerResult[] = []
|
||||
|
||||
config.TCPing.DualStack.forEach((server: ServerConfig) => {
|
||||
config.APIBaseURL.DualStack.forEach((server: ServerConfig) => {
|
||||
results.push({
|
||||
label: server.label,
|
||||
loading: false,
|
||||
@@ -102,7 +102,7 @@ function initServerResults() {
|
||||
})
|
||||
})
|
||||
|
||||
config.TCPing.IPv4.forEach((server: ServerConfig) => {
|
||||
config.APIBaseURL.IPv4.forEach((server: ServerConfig) => {
|
||||
results.push({
|
||||
label: server.label,
|
||||
loading: false,
|
||||
|
||||
@@ -64,7 +64,7 @@ const result = ref<any>(null)
|
||||
const error = ref('')
|
||||
const userIP = ref('')
|
||||
|
||||
const apiList = config.apiBaseUrls
|
||||
const apiList = config.APIBaseURL.DualStack
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
const whoisUrl = computed(() => '/middleware/' + backendID.value + '/whois/' + domain.value)
|
||||
|
||||
+47
-229
@@ -19,7 +19,7 @@ const config = {
|
||||
// 未配置或为空数组时,直接使用前端自带中间件。
|
||||
Middleware: <string[]>[
|
||||
"https://middleware-1.api-ipw.wsmdn.top/"
|
||||
// "http://127.0.0.1:8091/",
|
||||
// "http://127.0.0.1:8092/",
|
||||
// "https://middleware-2.wsmdn.top/",
|
||||
],
|
||||
// Umami 统计
|
||||
@@ -35,29 +35,18 @@ const config = {
|
||||
v4OnlyAPI: "https://4.wsmdn.top",
|
||||
v6OnlyAPI: "https://6.wsmdn.top",
|
||||
DualStackAPI: "https://test.wsmdn.top",
|
||||
apiBaseUrls: [
|
||||
// ---- 节点池结构 ----
|
||||
// IPLocationAPI:IP 归属查询(location / asn)上游节点池(纯数组,无栈区分)
|
||||
IPLocationAPI: [
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
id: "cn-jiangsu",
|
||||
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
|
||||
id: "cn-shenzhen",
|
||||
url: "https://cn-shenzhen.api-ipw.wsmdn.top/"
|
||||
},
|
||||
// IP 直连新节点:URL 留空,仅经独立中间件转发
|
||||
{
|
||||
label: "上海 腾讯云 BGP",
|
||||
id: "tencent-sh",
|
||||
label: "中国 湖北 武汉 电信",
|
||||
id: "cn-wuhan-chinatelecom",
|
||||
url: ""
|
||||
}
|
||||
],
|
||||
IPLocationAPIs: [
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
id: "cn-jiangsu",
|
||||
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 四川 沙渠 电信[ZFC]",
|
||||
@@ -65,283 +54,112 @@ const config = {
|
||||
url: "https://cn2-sichuan.api-ipw.wsmdn.top/"
|
||||
}
|
||||
],
|
||||
TCPing:{
|
||||
DualStack: [
|
||||
// APIBaseURL:其余拨测(whois / ssl / detail / dns / dnssec / tcping / speed)上游节点池,含 { IPv6, IPv4, DualStack } 三栈
|
||||
APIBaseURL: {
|
||||
IPv6: [
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
id: "cn-jiangsu",
|
||||
url :"https://cn-jiangsu.api-ipw.wsmdn.top/",
|
||||
label: "中国 四川 沙渠 电信[ZFC]",
|
||||
id: "cn2-sichuan",
|
||||
url: "https://cn2-sichuan.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
|
||||
id: "cn-shenzhen",
|
||||
url :"https://cn-shenzhen.api-ipw.wsmdn.top/",
|
||||
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
|
||||
id: "lntl-cn-hk-kowloon",
|
||||
url: "https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/"
|
||||
},
|
||||
// IP 直连新节点:URL 留空,仅经独立中间件转发
|
||||
{
|
||||
label: "上海 腾讯云 BGP",
|
||||
id: "tencent-sh",
|
||||
url :"",
|
||||
},
|
||||
label: "中国 河北 秦皇岛 联通",
|
||||
id: "cn-hebei-qinhuangdao",
|
||||
url: "https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/"
|
||||
}
|
||||
],
|
||||
IPv4: [
|
||||
{
|
||||
label: "中国 广东 广州 腾讯云",
|
||||
id: "cn-guangzhou",
|
||||
url :"https://cn-guangzhou.api-ipw.wsmdn.top/",
|
||||
url: "https://cn-guangzhou.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "新加坡 腾讯云",
|
||||
id: "sg-1",
|
||||
url :"https://sg-1.api-ipw.wsmdn.top/",
|
||||
url: "https://sg-1.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 香港 新界 西贡区 将军澳 MICC[ZFC]",
|
||||
id: "hk-shatin",
|
||||
url :"https://china-hk-kowloon-shatindistrict-newcloud.ipwapi.zfap.wsmdn.top/",
|
||||
url: "https://china-hk-kowloon-shatindistrict-newcloud.ipwapi.zfap.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 陕西 西安 北经济技术开发区 未央区凤城 中国电信[ZFC]",
|
||||
id: "cn-xian",
|
||||
url :"https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/",
|
||||
url: "https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "美国/加利福尼亚州/洛杉矶/蒙特雷帕克/USCD[ZFC]",
|
||||
label: "美国/加利福尼亚州/洛杉矶/蒙特利奇帕克/USCD[ZFC]",
|
||||
id: "us-la",
|
||||
url :"https://america-california-losangeles.ipwapi.zfap.wsmdn.top/",
|
||||
url: "https://america-california-losangeles.ipwapi.zfap.wsmdn.top/"
|
||||
},
|
||||
// IP 直连新节点:URL 留空,仅经独立中间件转发
|
||||
{
|
||||
label: "山东 枣庄 双线",
|
||||
id: "zaozhuang",
|
||||
url :"",
|
||||
url: ""
|
||||
},
|
||||
{
|
||||
label: "湖北 十堰 电信",
|
||||
id: "shiyan",
|
||||
url :"",
|
||||
url: ""
|
||||
},
|
||||
{
|
||||
label: "香港 VpsQuan",
|
||||
id: "hongkong2",
|
||||
url :"",
|
||||
url: ""
|
||||
},
|
||||
{
|
||||
label: "北京 京东云 BGP",
|
||||
id: "jdcloud",
|
||||
url :"",
|
||||
url: ""
|
||||
},
|
||||
{
|
||||
label: "陕西 西安二 电信",
|
||||
id: "xian2",
|
||||
url :"",
|
||||
url: ""
|
||||
},
|
||||
{
|
||||
label: "香港 Cogent",
|
||||
id: "hongkong",
|
||||
url :"",
|
||||
url: ""
|
||||
},
|
||||
// WS 通道节点:URL 留空,拨测经中间件 WS 转发
|
||||
{
|
||||
label: "呼和浩特移动",
|
||||
id: "2faa15e6-18a7-4355-b422-ca53277d8d77",
|
||||
url: ""
|
||||
}
|
||||
],
|
||||
IPv6: [
|
||||
{
|
||||
label: "中国 四川 沙渠 电信[ZFC]",
|
||||
id: "cn2-sichuan",
|
||||
url:"https://cn2-sichuan.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
|
||||
id: "lntl-cn-hk-kowloon",
|
||||
url:"https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 河北 秦皇岛 联通",
|
||||
id: "cn-hebei-qinhuangdao",
|
||||
url :"https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/",
|
||||
}
|
||||
]
|
||||
},
|
||||
SpeedTest:{
|
||||
DualStack: [
|
||||
{
|
||||
label: "中国 湖北 武汉 电信",
|
||||
id: "cn-wuhan-chinatelecom",
|
||||
url: ""
|
||||
},
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
id: "cn-jiangsu",
|
||||
url :"https://cn-jiangsu.api-ipw.wsmdn.top/",
|
||||
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
|
||||
id: "cn-shenzhen",
|
||||
url :"https://cn-shenzhen.api-ipw.wsmdn.top/",
|
||||
url: "https://cn-shenzhen.api-ipw.wsmdn.top/"
|
||||
},
|
||||
// IP 直连新节点:URL 留空,仅经独立中间件转发
|
||||
// IP 直连的节点:URL 留空,仅经独立中间件转发
|
||||
{
|
||||
label: "上海 腾讯云 BGP",
|
||||
id: "tencent-sh",
|
||||
url :"",
|
||||
},
|
||||
],
|
||||
IPv4: [
|
||||
{
|
||||
label: "中国 广东 广州 腾讯云",
|
||||
id: "cn-guangzhou",
|
||||
url :"https://cn-guangzhou.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "新加坡 腾讯云",
|
||||
id: "sg-1",
|
||||
url :"https://sg-1.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 香港 新界 西贡区 将军澳 MICC[ZFC]",
|
||||
id: "hk-shatin",
|
||||
url :"https://china-hk-kowloon-shatindistrict-newcloud.ipwapi.zfap.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 陕西 西安 北经济技术开发区 未央区凤城 中国电信[ZFC]",
|
||||
id: "cn-xian",
|
||||
url :"https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "美国/加利福尼亚州/洛杉矶/蒙特雷帕克/USCD[ZFC]",
|
||||
id: "us-la",
|
||||
url :"https://america-california-losangeles.ipwapi.zfap.wsmdn.top/",
|
||||
},
|
||||
// IP 直连新节点:URL 留空,仅经独立中间件转发
|
||||
{
|
||||
label: "山东 枣庄 双线",
|
||||
id: "zaozhuang",
|
||||
url :"",
|
||||
},
|
||||
{
|
||||
label: "湖北 十堰 电信",
|
||||
id: "shiyan",
|
||||
url :"",
|
||||
},
|
||||
{
|
||||
label: "香港 VpsQuan",
|
||||
id: "hongkong2",
|
||||
url :"",
|
||||
},
|
||||
{
|
||||
label: "北京 京东云 BGP",
|
||||
id: "jdcloud",
|
||||
url :"",
|
||||
},
|
||||
{
|
||||
label: "陕西 西安二 电信",
|
||||
id: "xian2",
|
||||
url :"",
|
||||
},
|
||||
{
|
||||
label: "香港 Cogent",
|
||||
id: "hongkong",
|
||||
url :"",
|
||||
}
|
||||
],
|
||||
IPv6: [
|
||||
{
|
||||
label: "中国 四川 沙渠 电信[ZFC]",
|
||||
id: "cn2-sichuan",
|
||||
url:"https://cn2-sichuan.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
|
||||
id: "lntl-cn-hk-kowloon",
|
||||
url:"https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 河北 秦皇岛 联通",
|
||||
id: "cn-hebei-qinhuangdao",
|
||||
url :"https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/",
|
||||
url: ""
|
||||
}
|
||||
]
|
||||
},
|
||||
NSLookup:[
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
id: "cn-jiangsu",
|
||||
url :"https://cn-jiangsu.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 广州 腾讯云",
|
||||
id: "cn-guangzhou",
|
||||
url :"https://cn-guangzhou.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "新加坡 腾讯云",
|
||||
id: "sg-1",
|
||||
url :"https://sg-1.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 四川 沙渠 电信[ZFC]",
|
||||
id: "cn2-sichuan",
|
||||
url:"https://cn2-sichuan.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 陕西 西安 北经济技术开发区 未央区凤城 中国电信[ZFC]",
|
||||
id: "cn-xian",
|
||||
url :"https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 香港 新界 西贡区 将军澳 MICC[ZFC]",
|
||||
id: "hk-shatin",
|
||||
url :"https://china-hk-kowloon-shatindistrict-newcloud.ipwapi.zfap.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
|
||||
id: "lntl-cn-hk-kowloon",
|
||||
url:"https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
|
||||
id: "cn-shenzhen",
|
||||
url :"https://cn-shenzhen.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "美国/加利福尼亚州/洛杉矶/蒙特雷帕克/USCD[ZFC]",
|
||||
id: "us-la",
|
||||
url :"https://america-california-losangeles.ipwapi.zfap.wsmdn.top/",
|
||||
},
|
||||
// IP 直连新节点:URL 留空,仅经独立中间件转发
|
||||
{
|
||||
label: "上海 腾讯云 BGP",
|
||||
id: "tencent-sh",
|
||||
url :"",
|
||||
},
|
||||
{
|
||||
label: "山东 枣庄 双线",
|
||||
id: "zaozhuang",
|
||||
url :"",
|
||||
},
|
||||
{
|
||||
label: "湖北 十堰 电信",
|
||||
id: "shiyan",
|
||||
url :"",
|
||||
},
|
||||
{
|
||||
label: "香港 VpsQuan",
|
||||
id: "hongkong2",
|
||||
url :"",
|
||||
},
|
||||
{
|
||||
label: "北京 京东云 BGP",
|
||||
id: "jdcloud",
|
||||
url :"",
|
||||
},
|
||||
{
|
||||
label: "陕西 西安二 电信",
|
||||
id: "xian2",
|
||||
url :"",
|
||||
},
|
||||
{
|
||||
label: "香港 Cogent",
|
||||
id: "hongkong",
|
||||
url :"",
|
||||
},
|
||||
{
|
||||
label: "中国 河北 秦皇岛 联通",
|
||||
id: "cn-hebei-qinhuangdao",
|
||||
url :"https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
export { config }
|
||||
@@ -0,0 +1,148 @@
|
||||
import { defineEventHandler, createError, setResponseStatus, setResponseHeader, getRequestHeader, getRequestIP } from 'h3'
|
||||
import { config } from '../../../config/index'
|
||||
import { useMiddlewareFetch } from '../../../utils/middleware'
|
||||
|
||||
// GET /ip/<ip> → text/plain 输出指定 IP 的归属地(支持 IPv4 / IPv6,如 /ip/2001:db8::1)
|
||||
//
|
||||
// IP: 2001:db8::1
|
||||
// bilibili: 中国 江苏 南京 电信
|
||||
// geocn: 江苏省 南京市 江宁区 电信 宽带
|
||||
// ip2region: 中国 江苏 南京 电信
|
||||
// maxmind_city: 中国 江苏 南京
|
||||
// maxmind_asn: AS4134 中国电信
|
||||
// qqwry: 中国 江苏省 南京市 电信
|
||||
//
|
||||
// 复用 utils/middleware.ts 的 useMiddlewareFetch():候选依次为 config.Middleware 外部中间层 + 前端自带中间件兜底,
|
||||
// 仅当节点无法连接或上游 401/403/418/502 时切换;IPLocationAPI 配置了多个后端时逐个重试。
|
||||
|
||||
// ---- 简单限流(每 IP 每分钟次数上限,进程内内存计数;limit 0 表示不限流) ----
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
function checkRateLimit(ip: string, limit: number): boolean {
|
||||
const now = Date.now()
|
||||
const entry = rateLimitMap.get(ip)
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 })
|
||||
// 顺带清理过期条目,避免 Map 无限增长
|
||||
if (rateLimitMap.size > 1000) {
|
||||
for (const [k, v] of rateLimitMap) {
|
||||
if (v.resetAt <= now) rateLimitMap.delete(k)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
entry.count++
|
||||
return entry.count <= limit
|
||||
}
|
||||
|
||||
// ---- 文本美化:把单个数据源的归属地对象拼成可读字符串 ----
|
||||
function objectToText(obj: any): string {
|
||||
const parts: string[] = []
|
||||
// 位置字段(按国家 → 省/区域 → 城市 → 区县)
|
||||
for (const k of ['country', 'administrative_area', 'region', 'city', 'district', 'province']) {
|
||||
const v = obj[k]
|
||||
if (typeof v === 'string' && v) parts.push(v)
|
||||
}
|
||||
// ASN 编号(如 "AS4134")
|
||||
if (typeof obj.asn === 'string' && obj.asn) {
|
||||
parts.push(obj.asn.startsWith('AS') ? obj.asn : `AS${obj.asn}`)
|
||||
}
|
||||
// 运营商 / 组织 / 类型
|
||||
for (const k of ['isp', 'org', 'type']) {
|
||||
const v = obj[k]
|
||||
if (typeof v === 'string' && v) parts.push(v)
|
||||
}
|
||||
return [...new Set(parts)].join(' ')
|
||||
}
|
||||
|
||||
// ---- 文本美化:多数据源逐行输出(跳过后端未加载的库) ----
|
||||
function formatLocationText(data: any, ip: string): string {
|
||||
const lines: string[] = [`IP: ${ip}`]
|
||||
const sources: Array<[string, any]> = [
|
||||
['bilibili', data?.bilibili],
|
||||
['geocn', data?.geocn],
|
||||
['ip2region', data?.ip2region],
|
||||
['maxmind_city', data?.maxmind_city],
|
||||
['maxmind_asn', data?.maxmind_asn],
|
||||
['dbip_city', data?.dbip_city],
|
||||
['dbip_asn', data?.dbip_asn],
|
||||
['qqwry', data?.qqwry],
|
||||
]
|
||||
for (const [name, value] of sources) {
|
||||
if (value === undefined || value === null) continue
|
||||
if (typeof value === 'string') {
|
||||
// 纯字符串结果(error 等)原样输出,"not loaded" 跳过
|
||||
if (value && value !== 'not loaded') {
|
||||
lines.push(`${name}: ${value}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const text = objectToText(value)
|
||||
if (text) lines.push(`${name}: ${text}`)
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ---- 核心:查询指定 IP 的归属地(IPLocationAPI 多后端逐个重试) ----
|
||||
async function locate(event: any, ip: string) {
|
||||
const backends = config.IPLocationAPI
|
||||
if (!backends || backends.length === 0) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'No location backend configured' })
|
||||
}
|
||||
let lastError: any = null
|
||||
for (const backend of backends) {
|
||||
const path = `/middleware/${backend.id}/location/${ip}`
|
||||
const { data, error, execute } = useMiddlewareFetch(path)
|
||||
await execute()
|
||||
if (error.value) {
|
||||
lastError = error.value
|
||||
console.error(`[ip] backend "${backend.label}" failed for ${ip}:`, error.value?.message || error.value)
|
||||
continue
|
||||
}
|
||||
// 成功:location 响应必含 ip 字段;缺失视为失败(如误返回错误体),换下一个后端
|
||||
const res = data.value
|
||||
if (res && typeof res === 'object' && (res as any).ip) {
|
||||
return formatLocationText(res, ip)
|
||||
}
|
||||
lastError = new Error('invalid location response')
|
||||
}
|
||||
|
||||
// 全部后端失败:透传最后一次错误(text/plain)
|
||||
const errStatus = lastError?.status ?? lastError?.statusCode ?? 502
|
||||
const status = typeof errStatus === 'number' ? errStatus : 502
|
||||
setResponseStatus(event, status)
|
||||
return `Error ${status}: ${lastError?.message || 'Backend unreachable'}`
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 0) 统一输出 text/plain(不是 JSON)
|
||||
setResponseHeader(event, 'content-type', 'text/plain; charset=utf-8')
|
||||
|
||||
// 1) Origin / Referer 校验:跨域浏览器调用一律拒绝
|
||||
const siteOrigin = new URL(config.siteUrl).origin
|
||||
const origin = getRequestHeader(event, 'origin')
|
||||
const referer = getRequestHeader(event, 'referer')
|
||||
if (origin && origin !== siteOrigin) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: cross-origin request' })
|
||||
}
|
||||
if (referer && referer !== siteOrigin && !referer.startsWith(siteOrigin + '/')) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: invalid referer' })
|
||||
}
|
||||
|
||||
// 2) 每 IP 每分钟限流(次数取 config.rateLimitPerMinute,0 表示不限流)
|
||||
const rateLimit = config.rateLimitPerMinute || 0
|
||||
if (rateLimit > 0 && !checkRateLimit(getRequestIP(event) || 'unknown', rateLimit)) {
|
||||
throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
|
||||
}
|
||||
|
||||
// 3) 取路径参数 IP
|
||||
const ipParam: string = event.context.params?.ip as any
|
||||
let ip = ipParam ? ipParam.trim() : ''
|
||||
if (!ip) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing IP parameter' })
|
||||
}
|
||||
|
||||
return locate(event, ip)
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import { defineEventHandler, getQuery, createError, setResponseStatus, setResponseHeader, getRequestHeader, getRequestIP } from 'h3'
|
||||
import { config } from '../../../config/index'
|
||||
import { useMiddlewareFetch } from '../../../utils/middleware'
|
||||
|
||||
// GET /ip → text/plain 输出 IP 归属地(?ip=<ip> 优先,否则查请求方 IP)
|
||||
//
|
||||
// IP: 1.2.3.4
|
||||
// bilibili: 中国 江苏 南京 电信
|
||||
// geocn: 江苏省 南京市 江宁区 电信 宽带
|
||||
// ip2region: 中国 江苏 南京 电信
|
||||
// maxmind_city: 中国 江苏 南京
|
||||
// maxmind_asn: AS4134 中国电信
|
||||
// qqwry: 中国 江苏省 南京市 电信
|
||||
//
|
||||
// 复用 utils/middleware.ts 的 useMiddlewareFetch():候选依次为 config.Middleware 外部中间层 + 前端自带中间件兜底,
|
||||
// 仅当节点无法连接或上游 401/403/418/502 时切换;IPLocationAPI 配置了多个后端时逐个重试。
|
||||
|
||||
// ---- 简单限流(每 IP 每分钟次数上限,进程内内存计数;limit 0 表示不限流) ----
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
function checkRateLimit(ip: string, limit: number): boolean {
|
||||
const now = Date.now()
|
||||
const entry = rateLimitMap.get(ip)
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 })
|
||||
// 顺带清理过期条目,避免 Map 无限增长
|
||||
if (rateLimitMap.size > 1000) {
|
||||
for (const [k, v] of rateLimitMap) {
|
||||
if (v.resetAt <= now) rateLimitMap.delete(k)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
entry.count++
|
||||
return entry.count <= limit
|
||||
}
|
||||
|
||||
// ---- 文本美化:把单个数据源的归属地对象拼成可读字符串 ----
|
||||
function objectToText(obj: any): string {
|
||||
const parts: string[] = []
|
||||
// 位置字段(按国家 → 省/区域 → 城市 → 区县)
|
||||
for (const k of ['country', 'administrative_area', 'region', 'city', 'district', 'province']) {
|
||||
const v = obj[k]
|
||||
if (typeof v === 'string' && v) parts.push(v)
|
||||
}
|
||||
// ASN 编号(如 "AS4134")
|
||||
if (typeof obj.asn === 'string' && obj.asn) {
|
||||
parts.push(obj.asn.startsWith('AS') ? obj.asn : `AS${obj.asn}`)
|
||||
}
|
||||
// 运营商 / 组织 / 类型
|
||||
for (const k of ['isp', 'org', 'type']) {
|
||||
const v = obj[k]
|
||||
if (typeof v === 'string' && v) parts.push(v)
|
||||
}
|
||||
return [...new Set(parts)].join(' ')
|
||||
}
|
||||
|
||||
// ---- 文本美化:多数据源逐行输出(跳过后端未加载的库) ----
|
||||
function formatLocationText(data: any, ip: string): string {
|
||||
const lines: string[] = [`IP: ${ip}`]
|
||||
const sources: Array<[string, any]> = [
|
||||
['bilibili', data?.bilibili],
|
||||
['geocn', data?.geocn],
|
||||
['ip2region', data?.ip2region],
|
||||
['maxmind_city', data?.maxmind_city],
|
||||
['maxmind_asn', data?.maxmind_asn],
|
||||
['dbip_city', data?.dbip_city],
|
||||
['dbip_asn', data?.dbip_asn],
|
||||
['qqwry', data?.qqwry],
|
||||
]
|
||||
for (const [name, value] of sources) {
|
||||
if (value === undefined || value === null) continue
|
||||
if (typeof value === 'string') {
|
||||
// 纯字符串结果(error 等)原样输出,"not loaded" 跳过
|
||||
if (value && value !== 'not loaded') {
|
||||
lines.push(`${name}: ${value}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const text = objectToText(value)
|
||||
if (text) lines.push(`${name}: ${text}`)
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ---- 核心:查询指定 IP 的归属地(IPLocationAPI 多后端逐个重试) ----
|
||||
async function locate(event: any, ip: string) {
|
||||
const backends = config.IPLocationAPI
|
||||
if (!backends || backends.length === 0) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'No location backend configured' })
|
||||
}
|
||||
let lastError: any = null
|
||||
for (const backend of backends) {
|
||||
const path = `/middleware/${backend.id}/location/${ip}`
|
||||
const { data, error, execute } = useMiddlewareFetch(path)
|
||||
await execute()
|
||||
if (error.value) {
|
||||
lastError = error.value
|
||||
console.error(`[ip] backend "${backend.label}" failed for ${ip}:`, error.value?.message || error.value)
|
||||
continue
|
||||
}
|
||||
// 成功:location 响应必含 ip 字段;缺失视为失败(如误返回错误体),换下一个后端
|
||||
const res = data.value
|
||||
if (res && typeof res === 'object' && (res as any).ip) {
|
||||
return formatLocationText(res, ip)
|
||||
}
|
||||
lastError = new Error('invalid location response')
|
||||
}
|
||||
|
||||
// 全部后端失败:透传最后一次错误(text/plain)
|
||||
const errStatus = lastError?.status ?? lastError?.statusCode ?? 502
|
||||
const status = typeof errStatus === 'number' ? errStatus : 502
|
||||
setResponseStatus(event, status)
|
||||
return `Error ${status}: ${lastError?.message || 'Backend unreachable'}`
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 0) 统一输出 text/plain(不是 JSON)
|
||||
setResponseHeader(event, 'content-type', 'text/plain; charset=utf-8')
|
||||
|
||||
// 1) Origin / Referer 校验:跨域浏览器调用一律拒绝
|
||||
const siteOrigin = new URL(config.siteUrl).origin
|
||||
const origin = getRequestHeader(event, 'origin')
|
||||
const referer = getRequestHeader(event, 'referer')
|
||||
if (origin && origin !== siteOrigin) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: cross-origin request' })
|
||||
}
|
||||
if (referer && referer !== siteOrigin && !referer.startsWith(siteOrigin + '/')) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: invalid referer' })
|
||||
}
|
||||
|
||||
// 2) 每 IP 每分钟限流(次数取 config.rateLimitPerMinute,0 表示不限流)
|
||||
const rateLimit = config.rateLimitPerMinute || 0
|
||||
if (rateLimit > 0 && !checkRateLimit(getRequestIP(event) || 'unknown', rateLimit)) {
|
||||
throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
|
||||
}
|
||||
|
||||
// 3) 确定要查询的 IP:?ip=<ip> 优先,否则查请求方 IP
|
||||
const query = getQuery(event)
|
||||
let ip = typeof query.ip === 'string' ? query.ip.trim() : ''
|
||||
if (!ip) {
|
||||
ip = getRequestIP(event, { xForwardedFor: true }) || ''
|
||||
}
|
||||
ip = ip.trim()
|
||||
if (!ip) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Unable to determine IP address' })
|
||||
}
|
||||
|
||||
return locate(event, ip)
|
||||
})
|
||||
@@ -98,25 +98,17 @@ export default defineEventHandler(async (event) => {
|
||||
let apiBaseUrls: Array<{ id: string, url: string ,label: string}> = []
|
||||
switch (apiType) {
|
||||
case 'whois':
|
||||
apiBaseUrls = [...config.apiBaseUrls]
|
||||
case 'ssl':
|
||||
case 'detail':
|
||||
apiBaseUrls = [...config.APIBaseURL.DualStack]
|
||||
break
|
||||
case 'dns':
|
||||
apiBaseUrls = [...config.NSLookup]
|
||||
case 'dnssec':
|
||||
apiBaseUrls = [...config.APIBaseURL.DualStack, ...config.APIBaseURL.IPv4, ...config.APIBaseURL.IPv6]
|
||||
break
|
||||
case 'location':
|
||||
apiBaseUrls = [...config.IPLocationAPIs]
|
||||
break
|
||||
case 'ssl':
|
||||
apiBaseUrls = [...config.apiBaseUrls]
|
||||
break
|
||||
case 'asn':
|
||||
apiBaseUrls = [...config.IPLocationAPIs]
|
||||
break
|
||||
case 'dnssec':
|
||||
apiBaseUrls = [...config.NSLookup]
|
||||
break
|
||||
case 'detail':
|
||||
apiBaseUrls = [...config.apiBaseUrls]
|
||||
apiBaseUrls = [...config.IPLocationAPI]
|
||||
break
|
||||
default:
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid API type' })
|
||||
@@ -151,21 +143,12 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
return data
|
||||
}else if (apiType === 'tcping' || apiType === 'udping' || apiType === 'speed') {
|
||||
let apiBaseUrls:Array<{ id: string, url: string ,label: string}> = []
|
||||
let apiTypeConfig: any = {}
|
||||
switch (apiType) {
|
||||
case 'tcping':
|
||||
apiTypeConfig = config.TCPing
|
||||
apiBaseUrls = [...apiTypeConfig.DualStack, ...apiTypeConfig.IPv4, ...apiTypeConfig.IPv6]
|
||||
break
|
||||
case 'speed':
|
||||
apiTypeConfig = config.SpeedTest
|
||||
apiBaseUrls = [...apiTypeConfig.DualStack, ...apiTypeConfig.IPv4, ...apiTypeConfig.IPv6]
|
||||
break
|
||||
default:
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid API type' })
|
||||
|
||||
}
|
||||
// tcping/udping/speed 统一走 APIBaseURL 节点池(平铺三栈)
|
||||
const apiBaseUrls: Array<{ id: string, url: string ,label: string}> = [
|
||||
...config.APIBaseURL.DualStack,
|
||||
...config.APIBaseURL.IPv4,
|
||||
...config.APIBaseURL.IPv6
|
||||
]
|
||||
const map = new Map<string, string>(apiBaseUrls.map(api => [api.id, api.url]))
|
||||
if (!map.has(backendID)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid backend ID' })
|
||||
|
||||
@@ -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
|
||||
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/bash
|
||||
# ============================================================
|
||||
# 1IPW.CN 后端节点一键安装脚本
|
||||
# - 自动检测架构并下载最新 release 二进制
|
||||
# - 交互式输入配置(环境变量注入,无需 setting.json)
|
||||
# - 生成并启用 systemd 守护进程
|
||||
# 用法:sudo bash install.sh
|
||||
# ============================================================
|
||||
|
||||
text="
|
||||
██╗██████╗ ██╗ ██╗ ███╗ ██╗ ██████╗ ██████╗ ███████╗
|
||||
██║██╔══██╗██║ ██║ ████╗ ██║██╔═══██╗██╔══██╗██╔════╝
|
||||
██║██████╔╝██║ █╗ ██║█████╗██╔██╗ ██║██║ ██║██║ ██║█████╗
|
||||
██║██╔═══╝ ██║███╗██║╚════╝██║╚██╗██║██║ ██║██║ ██║██╔══╝
|
||||
██║██║ ╚███╔███╔╝ ██║ ╚████║╚██████╔╝██████╔╝███████╗
|
||||
╚═╝╚═╝ ╚══╝╚══╝ ╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚══════╝
|
||||
|
||||
"
|
||||
echo "$text"
|
||||
|
||||
set -e
|
||||
|
||||
# ---------- 前置检查 ----------
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "错误:需要 root 权限(写入 /etc/systemd/system 与安装目录),请用 sudo 运行" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v systemctl >/dev/null 2>&1; then
|
||||
echo "错误:未检测到 systemd(systemctl 不存在),无法创建守护进程" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v wget >/dev/null 2>&1 && ! command -v curl >/dev/null 2>&1; then
|
||||
echo "错误:未找到 wget 或 curl,无法下载" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------- 架构检测 ----------
|
||||
systemArch=$(uname -m)
|
||||
case "$systemArch" in
|
||||
x86_64) BINARY="lemonipw-linux-amd64" ;;
|
||||
i386|i486|i586|i686) BINARY="lemonipw-linux-386" ;;
|
||||
aarch64|arm64) BINARY="lemonipw-linux-arm64" ;;
|
||||
armv7l|armv7hl) BINARY="lemonipw-linux-armv7" ;;
|
||||
armv6l) BINARY="lemonipw-linux-armv6" ;;
|
||||
loongarch64) BINARY="lemonipw-linux-loong64" ;;
|
||||
*)
|
||||
echo "错误:不支持的架构 '$systemArch'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "检测到架构 $systemArch,将下载 $BINARY"
|
||||
|
||||
# ---------- 交互式配置输入 ----------
|
||||
|
||||
# 生成 UUID(优先 /proc,其次 uuidgen,最后 /dev/urandom)
|
||||
gen_uuid() {
|
||||
if [ -r /proc/sys/kernel/random/uuid ]; then
|
||||
cat /proc/sys/kernel/random/uuid
|
||||
elif command -v uuidgen >/dev/null 2>&1; then
|
||||
uuidgen | tr 'A-Z' 'a-z'
|
||||
else
|
||||
od -An -N16 -tx1 /dev/urandom | tr -d ' \n' | sed 's/\(........\)\(....\)\(....\)\(....\)\(............\)/\1-\2-\3-\4-\5/'
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " 配置后端节点(直接回车使用默认值)"
|
||||
echo "========================================"
|
||||
|
||||
read -r -p "安装目录 [/opt/lemon-ipw]: " INSTALL_DIR
|
||||
INSTALL_DIR=${INSTALL_DIR:-/opt/lemon-ipw}
|
||||
|
||||
read -r -p "监听端口 [8080]: " PORTS
|
||||
PORTS=${PORTS:-8080}
|
||||
|
||||
read -r -p "单栈模式 SINGLE_STACK(留空=双栈,ipv4 或 ipv6): " SINGLE_STACK
|
||||
|
||||
read -r -p "访问令牌 access-token(留空=不启用鉴权): " ACCESS_TOKEN
|
||||
|
||||
read -r -p "DNS 服务器 [119.28.28.28:53](主从逗号分隔,如 119.28.28.28:53,223.5.5.5:53): " DNS_SERVER
|
||||
DNS_SERVER=${DNS_SERVER:-119.28.28.28:53}
|
||||
|
||||
read -r -p "DNSSEC 专用 DNS(留空=沿用上面 dns-server): " DNSSEC_DNS_SERVER
|
||||
|
||||
read -r -p "启用 IP 数据库 ipdb(首次启动下载约 200MB)[Y/n]: " IPDB_CHOICE
|
||||
case "${IPDB_CHOICE,,}" in
|
||||
n|no) IPDB="false" ;;
|
||||
*) IPDB="true" ;;
|
||||
esac
|
||||
|
||||
read -r -p "CORS 允许来源(逗号分隔,留空=不限): " CORS
|
||||
|
||||
read -r -p "远端配置地址 remote-config-url(留空=不启用): " REMOTE_CONFIG_URL
|
||||
|
||||
echo ""
|
||||
echo "--- WS 通道接入(可选,接入独立中间件)---"
|
||||
read -r -p "接入中间件 WS 通道?[y/N]: " WS_CHOICE
|
||||
WS_URL=""
|
||||
NODE_ID=""
|
||||
NODE_KEY=""
|
||||
WS_USED_DEFAULT=""
|
||||
case "${WS_CHOICE,,}" in
|
||||
y|yes)
|
||||
read -r -p " 中间件 WS 完整地址(含 wss:// 前缀与 /ws 路径,如 wss://host:8092/ws;留空=贡献节点给柠檬 wss://middleware-1.api-ipw.wsmdn.top/ws;逗号分隔多个将同时连接全部): " WS_URL
|
||||
if [ -z "$WS_URL" ]; then
|
||||
WS_URL="wss://middleware-1.api-ipw.wsmdn.top/ws"
|
||||
WS_USED_DEFAULT="true"
|
||||
fi
|
||||
NODE_ID=$(gen_uuid)
|
||||
echo " 节点 id(自动生成 UUID): $NODE_ID"
|
||||
while [ -z "$NODE_KEY" ]; do
|
||||
read -r -p " 注册 key(必填,中间件 ws-keys 必须包含此节点,否则注册被拒 401): " NODE_KEY
|
||||
if [ -z "$NODE_KEY" ]; then
|
||||
echo " 错误:不加 key 禁止启用 WS,必须提供注册 key"
|
||||
fi
|
||||
done
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "--- 其他环境变量(可选)---"
|
||||
echo "输入额外的 Environment= 项,每行一个(如 GH_PROXY=https://ghproxy.com/),空行结束:"
|
||||
EXTRA_ENVS=""
|
||||
while true; do
|
||||
read -r -p " > " EXTRA
|
||||
[ -z "$EXTRA" ] && break
|
||||
if [ -z "$EXTRA_ENVS" ]; then
|
||||
EXTRA_ENVS="$EXTRA"
|
||||
else
|
||||
EXTRA_ENVS="$EXTRA_ENVS
|
||||
$EXTRA"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---------- 汇总展示 ----------
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " 配置汇总"
|
||||
echo "========================================"
|
||||
echo "安装目录: $INSTALL_DIR"
|
||||
echo "服务名: lemon-ipw"
|
||||
echo "监听端口: $PORTS"
|
||||
echo "单栈模式: ${SINGLE_STACK:-双栈}"
|
||||
echo "access-token: ${ACCESS_TOKEN:+已设置 (隐藏)}"
|
||||
echo "DNS: $DNS_SERVER"
|
||||
echo "ipdb: $IPDB"
|
||||
echo "WS 接入: ${WS_URL:+$WS_URL (id=$NODE_ID, key=${NODE_KEY:+已设置})}${WS_URL:-未启用}"
|
||||
echo "========================================"
|
||||
read -r -p "确认安装?[Y/n]: " CONFIRM
|
||||
case "${CONFIRM,,}" in
|
||||
n|no) echo "已取消" ; exit 0 ;;
|
||||
*) : ;;
|
||||
esac
|
||||
|
||||
# ---------- 下载二进制 ----------
|
||||
echo ""
|
||||
echo "正在获取最新版本..."
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
LATEST_TAG=$(curl -s https://api.github.com/repos/nomdn/ipw-cn/releases/latest | grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
else
|
||||
LATEST_TAG=$(wget -qO- https://api.github.com/repos/nomdn/ipw-cn/releases/latest | grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
fi
|
||||
if [ -z "$LATEST_TAG" ]; then
|
||||
echo "错误:无法获取最新版本号(检查网络或 GitHub API 速率限制)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "最新版本:$LATEST_TAG"
|
||||
DOWNLOAD_URL="https://github.com/nomdn/ipw-cn/releases/download/$LATEST_TAG/$BINARY"
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
echo "下载 $BINARY ..."
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
wget -q -O "$INSTALL_DIR/lemonipw" "$DOWNLOAD_URL"
|
||||
else
|
||||
curl -sL -o "$INSTALL_DIR/lemonipw" "$DOWNLOAD_URL"
|
||||
fi
|
||||
chmod +x "$INSTALL_DIR/lemonipw"
|
||||
echo "二进制已安装到 $INSTALL_DIR/lemonipw"
|
||||
|
||||
# ---------- 生成 systemd 服务 ----------
|
||||
SERVICE_FILE="/etc/systemd/system/lemon-ipw.service"
|
||||
|
||||
# 收集环境变量(仅非空的写入,避免空值覆盖默认)
|
||||
ENV_LINES=""
|
||||
[ -n "$PORTS" ] && ENV_LINES="${ENV_LINES}Environment=\"PORTS=$PORTS\"
|
||||
"
|
||||
[ -n "$SINGLE_STACK" ] && ENV_LINES="${ENV_LINES}Environment=\"SINGLE_STACK=$SINGLE_STACK\"
|
||||
"
|
||||
[ -n "$ACCESS_TOKEN" ] && ENV_LINES="${ENV_LINES}Environment=\"ACCESS_TOKEN=$ACCESS_TOKEN\"
|
||||
"
|
||||
[ -n "$DNS_SERVER" ] && ENV_LINES="${ENV_LINES}Environment=\"DNS_SERVER=$DNS_SERVER\"
|
||||
"
|
||||
[ -n "$DNSSEC_DNS_SERVER" ] && ENV_LINES="${ENV_LINES}Environment=\"DNSSEC_DNS_SERVER=$DNSSEC_DNS_SERVER\"
|
||||
"
|
||||
[ -n "$IPDB" ] && ENV_LINES="${ENV_LINES}Environment=\"IPDB=$IPDB\"
|
||||
"
|
||||
[ -n "$CORS" ] && ENV_LINES="${ENV_LINES}Environment=\"CORS=$CORS\"
|
||||
"
|
||||
[ -n "$REMOTE_CONFIG_URL" ] && ENV_LINES="${ENV_LINES}Environment=\"REMOTE_CONFIG_URL=$REMOTE_CONFIG_URL\"
|
||||
"
|
||||
[ -n "$WS_URL" ] && ENV_LINES="${ENV_LINES}Environment=\"WS_URL=$WS_URL\"
|
||||
"
|
||||
[ -n "$NODE_ID" ] && ENV_LINES="${ENV_LINES}Environment=\"NODE_ID=$NODE_ID\"
|
||||
"
|
||||
[ -n "$NODE_KEY" ] && ENV_LINES="${ENV_LINES}Environment=\"NODE_KEY=$NODE_KEY\"
|
||||
"
|
||||
if [ -n "$EXTRA_ENVS" ]; then
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && ENV_LINES="${ENV_LINES}Environment=\"$line\"
|
||||
"
|
||||
done <<< "$EXTRA_ENVS"
|
||||
fi
|
||||
|
||||
cat > "$SERVICE_FILE" << EOF
|
||||
[Unit]
|
||||
Description=Lemon IPW Backend Node
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=$INSTALL_DIR
|
||||
ExecStart=$INSTALL_DIR/lemonipw
|
||||
${ENV_LINES}Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
echo "已生成服务文件 $SERVICE_FILE"
|
||||
|
||||
# ---------- 启动服务 ----------
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now lemon-ipw
|
||||
systemctl status lemon-ipw --no-pager | head -15
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " 安装完成"
|
||||
echo "========================================"
|
||||
echo "常用命令:"
|
||||
echo " systemctl status lemon-ipw # 查看状态"
|
||||
echo " journalctl -u lemon-ipw -f # 查看日志"
|
||||
echo " systemctl restart lemon-ipw # 重启(改配置后)"
|
||||
echo "验证:curl http://127.0.0.1:$PORTS/ 应返回 {\"status\":\"ok\"}"
|
||||
if [ -n "$WS_URL" ]; then
|
||||
echo ""
|
||||
echo "WS 通道已启用,请把节点注册信息加入中间件 setting.json 的 ws-keys:"
|
||||
echo " \"$NODE_ID\": \"$NODE_KEY\""
|
||||
fi
|
||||
if [ -n "$WS_USED_DEFAULT" ]; then
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "感谢您的贡献,请向 iduhih777@outlook.com 提交您的节点信息,谢谢!"
|
||||
echo " 节点 id: $NODE_ID"
|
||||
echo " 注册 key: $NODE_KEY"
|
||||
echo " WS 地址: $WS_URL"
|
||||
echo " 监听端口: $PORTS"
|
||||
echo " 单栈模式: ${SINGLE_STACK:-双栈}"
|
||||
echo " access-token: ${ACCESS_TOKEN:+已设置 (隐藏)}"
|
||||
echo " CORS: ${CORS:-不限}"
|
||||
echo " 地区-运营商 请自行填写"
|
||||
echo "========================================"
|
||||
fi
|
||||
echo "========================================"
|
||||
+107
-113
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
// ==================== 全局变量 ====================
|
||||
|
||||
// apiInfo 对应 setting.json apiBaseUrls 等数组中的 { label, id, url } 配置项
|
||||
// apiInfo 对应 setting.json 节点池中的 { label, id, url } 配置项
|
||||
type apiInfo struct {
|
||||
Label string `json:"label"`
|
||||
ID string `json:"id"`
|
||||
@@ -49,7 +49,7 @@ func (w *wsFlag) UnmarshalJSON(b []byte) error {
|
||||
// UseWS 判断该节点是否走 WS 通道
|
||||
func (a *apiInfo) UseWS() bool { return a != nil && bool(a.WS) }
|
||||
|
||||
// stackConfig 对应 setting.json TCPing / SpeedTest
|
||||
// stackConfig 对应节点池中的 { DualStack, IPv4, IPv6 } 三栈
|
||||
type stackConfig struct {
|
||||
DualStack []apiInfo `json:"DualStack"`
|
||||
IPv4 []apiInfo `json:"IPv4"`
|
||||
@@ -57,20 +57,21 @@ type stackConfig struct {
|
||||
}
|
||||
|
||||
// middlewareConfig 仅用于解析 setting.json(JSON 键名统一用连接线,如 http-timeout-seconds)
|
||||
// 节点池结构:APIBaseURL 含 {IPv6,IPv4,DualStack} 三栈;IPLocationAPI 为纯数组(无栈区分)。
|
||||
// 原 apiBaseUrls/whois·ssl·detail、NSLookup/dns·dnssec、TCPing、SpeedTest 统一并入 APIBaseURL,
|
||||
// 原 IPLocationAPI/location·asn 并入 IPLocationAPI。
|
||||
type middlewareConfig struct {
|
||||
Port stringOrNumber `json:"port"`
|
||||
HTTPTimeoutSeconds int `json:"http-timeout-seconds"`
|
||||
RateLimit *int `json:"rate-limit"` // 单 IP 每分钟限流次数;缺省=默认120,0=不限流
|
||||
WSPort stringOrNumber `json:"ws-port"` // WS 服务端口:兼容 "8092" 与 8092;缺省=8092,"0"/0=关闭 WS 通道
|
||||
RemoteConfigURL string `json:"remote-config-url"`
|
||||
RemoteIngoreConfig []string `json:"remote-ingore-config"` // 不被远端覆盖的配置项列表
|
||||
Cors string `json:"cors"`
|
||||
APIBaseURLs []apiInfo `json:"apiBaseUrls"`
|
||||
IPLocationAPIs []apiInfo `json:"IPLocationAPIs"`
|
||||
TCPing stackConfig `json:"TCPing"`
|
||||
SpeedTest stackConfig `json:"SpeedTest"`
|
||||
NSLookup []apiInfo `json:"NSLookup"`
|
||||
APIKeys map[string]string `json:"apiKeys"`
|
||||
Port stringOrNumber `json:"port"`
|
||||
HTTPTimeoutSeconds int `json:"http-timeout-seconds"`
|
||||
RateLimit *int `json:"rate-limit"` // 单 IP 每分钟限流次数;缺省=默认120,0=不限流
|
||||
WSPort stringOrNumber `json:"ws-port"` // WS 端口:兼容 "8092" 与 8092;缺省=8092,"0"/0=关闭 WS 通道
|
||||
RemoteConfigURL string `json:"remote-config-url"`
|
||||
RemoteIgnoreConfig []string `json:"remote-ignore-config"` // 不被远端覆盖的配置项列表
|
||||
Cors string `json:"cors"`
|
||||
APIBaseURL stackConfig `json:"api-base-url"` // whois/ssl/detail/dns/dnssec/tcping/speed 上游节点池
|
||||
IPLocationAPI []apiInfo `json:"ip-location-api"` // location/asn 上游节点池(纯数组,无栈)
|
||||
APIKeys map[string]string `json:"api-keys"` // HTTP 转发鉴权:backendID → token(注入 Authorization: Bearer)
|
||||
WSKeys map[string]string `json:"ws-keys"` // WS 注册校验:nodeID → key(节点 register 的 key 须匹配)
|
||||
}
|
||||
|
||||
// stringOrNumber 兼容 JSON 中的字符串与数字(如 "8080" 或 8080)
|
||||
@@ -85,16 +86,14 @@ var (
|
||||
HTTP_TIMEOUT int // 上游请求超时(秒)
|
||||
CORS string // CORS 配置原始字符串(逗号分隔的允许域名,可空)
|
||||
ACCEPT_DOMAINS []string // 允许跨域的域名列表(由 CORS 拆分而来,空则允许所有)
|
||||
API_BASE_URLS []apiInfo // whois/ssl/detail 上游列表
|
||||
IP_LOCATION_APIS []apiInfo // location/asn 上游列表
|
||||
TCPING stackConfig // tcping 上游列表(DualStack/IPv4/IPv6)
|
||||
SPEED_TEST stackConfig // speed 上游列表(DualStack/IPv4/IPv6)
|
||||
NS_LOOKUP []apiInfo // dns/dnssec 上游列表
|
||||
API_KEYS map[string]string // backendID → token
|
||||
API_BASE_URLS stackConfig // whois/ssl/detail/dns/dnssec/tcping/speed 上游节点池(DualStack/IPv4/IPv6)
|
||||
IP_LOCATION_APIS []apiInfo // location/asn 上游节点池(纯数组,无栈)
|
||||
API_KEYS map[string]string // backendID → HTTP 转发 token(注入 Authorization: Bearer)
|
||||
WS_KEYS map[string]string // backendID → WS 注册校验 key
|
||||
CONFIG_SOURCE string // 配置文件路径
|
||||
RATE_LIMIT int // 单 IP 每分钟限流次数(0 表示不限流),默认 120
|
||||
WS_PORT int // WS 服务端口(0 = 关闭),缺省 8092,由 readConfig 统一解析
|
||||
REMOTE_INGORE_CONFIG []string // 不被远端覆盖的配置项列表(remote-ingore-config / REMOTE_INGORE_CONFIG)
|
||||
REMOTE_IGNORE_CONFIG []string // 不被远端覆盖的配置项列表(remote-ignore-config / REMOTE_IGNORE_CONFIG)
|
||||
)
|
||||
|
||||
// HTTP_CLIENT 用于转发上游请求(等价于 TS 中的 $fetch),超时由 HTTP_TIMEOUT 决定
|
||||
@@ -174,6 +173,20 @@ func findNode(list []apiInfo, id string) *apiInfo {
|
||||
return nil
|
||||
}
|
||||
|
||||
// flattenStack 将节点池三栈展开为一个节点列表(DualStack + IPv4 + IPv6)
|
||||
func flattenStack(s stackConfig) []apiInfo {
|
||||
out := make([]apiInfo, 0, len(s.DualStack)+len(s.IPv4)+len(s.IPv6))
|
||||
out = append(out, s.DualStack...)
|
||||
out = append(out, s.IPv4...)
|
||||
out = append(out, s.IPv6...)
|
||||
return out
|
||||
}
|
||||
|
||||
// stackNonEmpty 判断节点池三栈是否至少有一项
|
||||
func stackNonEmpty(s stackConfig) bool {
|
||||
return len(s.DualStack) > 0 || len(s.IPv4) > 0 || len(s.IPv6) > 0
|
||||
}
|
||||
|
||||
func keysOf(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
@@ -224,12 +237,12 @@ func forwardUpstream(c fiber.Ctx, apiBaseUrl string, target string, headers map[
|
||||
}
|
||||
|
||||
// lookupAPIKey 查找 backendID 对应的 token。
|
||||
// 优先级: setting.json apiKeys > 环境变量 APIKEYS (JSON 字符串)
|
||||
// 优先级: setting.json api-keys > 环境变量 API_KEYS (JSON 字符串)
|
||||
func lookupAPIKey(backendID string) string {
|
||||
var apiKeys map[string]string
|
||||
if len(API_KEYS) > 0 {
|
||||
apiKeys = API_KEYS
|
||||
} else if raw := os.Getenv("APIKEYS"); raw != "" {
|
||||
} else if raw := os.Getenv("API_KEYS"); raw != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &apiKeys); err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -242,6 +255,26 @@ func lookupAPIKey(backendID string) string {
|
||||
return apiKey
|
||||
}
|
||||
|
||||
// lookupWSKey 查找 nodeID 对应的 WS 注册校验 key。
|
||||
// 优先级: setting.json ws-keys > 环境变量 WS_KEYS (JSON 字符串)
|
||||
// 与 lookupAPIKey 相互独立:api-keys 用于 HTTP 转发鉴权,ws-keys 用于 WS 注册校验。
|
||||
func lookupWSKey(nodeID string) string {
|
||||
var wsKeys map[string]string
|
||||
if len(WS_KEYS) > 0 {
|
||||
wsKeys = WS_KEYS
|
||||
} else if raw := os.Getenv("WS_KEYS"); raw != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &wsKeys); err != nil {
|
||||
return ""
|
||||
}
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
|
||||
wsKey := wsKeys[nodeID]
|
||||
|
||||
return wsKey
|
||||
}
|
||||
|
||||
// ==================== 业务接口 ====================
|
||||
|
||||
// middlewareHandler 对应前端 server/routes/middleware/[...slug].get.ts
|
||||
@@ -288,7 +321,7 @@ func middlewareHandler(c fiber.Ctx) error {
|
||||
|
||||
query := c.Queries()
|
||||
|
||||
// 从 API_KEYS 查找 token,未配置时回退到环境变量 APIKEYS (JSON 字符串)
|
||||
// 从 API_KEYS 查找 token,未配置时回退到环境变量 API_KEYS (JSON 字符串)
|
||||
apiKey := lookupAPIKey(backendID)
|
||||
|
||||
// 构造转发给上游的请求头:不透传客户端 Origin,避免上游 CORS 误判
|
||||
@@ -299,14 +332,13 @@ func middlewareHandler(c fiber.Ctx) error {
|
||||
|
||||
switch apiType {
|
||||
case "whois", "dns", "location", "ssl", "asn", "dnssec", "detail":
|
||||
// 节点池结构:location/asn 走 IPLocationAPI(纯数组,无栈),其余走 APIBaseURL(三栈平铺)
|
||||
var apiBaseUrls []apiInfo
|
||||
switch apiType {
|
||||
case "whois", "ssl", "detail":
|
||||
apiBaseUrls = API_BASE_URLS
|
||||
case "dns", "dnssec":
|
||||
apiBaseUrls = NS_LOOKUP
|
||||
case "location", "asn":
|
||||
apiBaseUrls = IP_LOCATION_APIS
|
||||
default:
|
||||
apiBaseUrls = flattenStack(API_BASE_URLS)
|
||||
}
|
||||
|
||||
node := findNode(apiBaseUrls, backendID)
|
||||
@@ -319,7 +351,8 @@ func middlewareHandler(c fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadGateway).SendString("WS probe failed: " + err.Error())
|
||||
}
|
||||
return c.Status(status).Send(body)
|
||||
// 节点上报的 body 为 JSON,显式指定类型(fiber 默认 text/plain)
|
||||
return c.Status(status).Type("json").Send(body)
|
||||
}
|
||||
apiBaseUrl := node.URL
|
||||
if !strings.HasSuffix(apiBaseUrl, "/") {
|
||||
@@ -330,20 +363,8 @@ func middlewareHandler(c fiber.Ctx) error {
|
||||
return forwardUpstream(c, apiBaseUrl, apiBaseUrl+"v1/"+apiType+"/"+raw, authHeaders, nil)
|
||||
|
||||
case "tcping", "udping", "speed":
|
||||
var apiTypeConfig stackConfig
|
||||
switch apiType {
|
||||
case "tcping":
|
||||
apiTypeConfig = TCPING
|
||||
case "speed":
|
||||
apiTypeConfig = SPEED_TEST
|
||||
default:
|
||||
return badRequest(c, "Invalid API type")
|
||||
}
|
||||
|
||||
apiBaseUrls := make([]apiInfo, 0, len(apiTypeConfig.DualStack)+len(apiTypeConfig.IPv4)+len(apiTypeConfig.IPv6))
|
||||
apiBaseUrls = append(apiBaseUrls, apiTypeConfig.DualStack...)
|
||||
apiBaseUrls = append(apiBaseUrls, apiTypeConfig.IPv4...)
|
||||
apiBaseUrls = append(apiBaseUrls, apiTypeConfig.IPv6...)
|
||||
// tcping/udping/speed 统一走 APIBaseURL 节点池
|
||||
apiBaseUrls := flattenStack(API_BASE_URLS)
|
||||
|
||||
// 转发 query 参数(过滤空值,等价于 TS 中 URLSearchParams 过滤 undefined)
|
||||
queryString := url.Values{}
|
||||
@@ -365,7 +386,8 @@ func middlewareHandler(c fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadGateway).SendString("WS probe failed: " + err.Error())
|
||||
}
|
||||
return c.Status(status).Send(body)
|
||||
// 节点上报的 body 为 JSON,显式指定类型(fiber 默认 text/plain)
|
||||
return c.Status(status).Type("json").Send(body)
|
||||
}
|
||||
apiBaseUrl := node.URL
|
||||
if !strings.HasSuffix(apiBaseUrl, "/") {
|
||||
@@ -431,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
|
||||
}
|
||||
@@ -456,22 +478,14 @@ func applyRemoteConfig(mw *middlewareConfig) error {
|
||||
if !ignored("cors") && remote.Cors != "" {
|
||||
mw.Cors = remote.Cors
|
||||
}
|
||||
if !ignored("apiBaseUrls") && len(remote.APIBaseURLs) > 0 {
|
||||
mw.APIBaseURLs = remote.APIBaseURLs
|
||||
// 节点池结构:api-base-url 与 ip-location-api(远端非空栈时覆盖本地)
|
||||
if !ignored("api-base-url") && stackNonEmpty(remote.APIBaseURL) {
|
||||
mw.APIBaseURL = remote.APIBaseURL
|
||||
}
|
||||
if !ignored("IPLocationAPIs") && len(remote.IPLocationAPIs) > 0 {
|
||||
mw.IPLocationAPIs = remote.IPLocationAPIs
|
||||
if !ignored("ip-location-api") && len(remote.IPLocationAPI) > 0 {
|
||||
mw.IPLocationAPI = remote.IPLocationAPI
|
||||
}
|
||||
if !ignored("TCPing") && (len(remote.TCPing.DualStack) > 0 || len(remote.TCPing.IPv4) > 0 || len(remote.TCPing.IPv6) > 0) {
|
||||
mw.TCPing = remote.TCPing
|
||||
}
|
||||
if !ignored("SpeedTest") && (len(remote.SpeedTest.DualStack) > 0 || len(remote.SpeedTest.IPv4) > 0 || len(remote.SpeedTest.IPv6) > 0) {
|
||||
mw.SpeedTest = remote.SpeedTest
|
||||
}
|
||||
if !ignored("NSLookup") && len(remote.NSLookup) > 0 {
|
||||
mw.NSLookup = remote.NSLookup
|
||||
}
|
||||
// apiKeys 强制忽略:密钥凭据不随远端配置覆盖(与后端 access_token 一致),只从本地 setting.json / env 读取
|
||||
// api-keys / ws-keys 强制忽略:密钥凭据不随远端配置覆盖(与后端 access-token 一致),只从本地 setting.json / env 读取
|
||||
log.Printf("[middleware] remote config applied from %s", url)
|
||||
return nil
|
||||
}
|
||||
@@ -540,64 +554,49 @@ func readConfig() error {
|
||||
} else if viper.IsSet("ws-port") {
|
||||
mw.WSPort = stringOrNumber(viper.GetString("ws-port"))
|
||||
}
|
||||
// remote-ingore-config:不被远端覆盖的配置项列表(env JSON 数组 > setting.json 数组)
|
||||
if raw := os.Getenv("REMOTE_INGORE_CONFIG"); raw != "" {
|
||||
// remote-ignore-config:不被远端覆盖的配置项列表(env JSON 数组 > setting.json 数组)
|
||||
if raw := os.Getenv("REMOTE_IGNORE_CONFIG"); raw != "" {
|
||||
var list []string
|
||||
if err := json.Unmarshal([]byte(raw), &list); err != nil {
|
||||
return fmt.Errorf("parse env REMOTE_INGORE_CONFIG: %w", err)
|
||||
return fmt.Errorf("parse env REMOTE_IGNORE_CONFIG: %w", err)
|
||||
}
|
||||
mw.RemoteIngoreConfig = list
|
||||
mw.RemoteIgnoreConfig = list
|
||||
} else {
|
||||
mw.RemoteIngoreConfig = viper.GetStringSlice("remote-ingore-config")
|
||||
mw.RemoteIgnoreConfig = viper.GetStringSlice("remote-ignore-config")
|
||||
}
|
||||
|
||||
// 数组/对象类配置:先 Getenv(JSON 字符串),再 viper.Get(经 JSON 中转反序列化到 typed 结构)
|
||||
if err := envJSON("API_BASE_URLS", &mw.APIBaseURLs); err != nil {
|
||||
// 节点池结构:api-base-url 与 ip-location-api(env JSON > setting.json)
|
||||
if err := envJSON("API_BASE_URLS", &mw.APIBaseURL); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(mw.APIBaseURLs) == 0 {
|
||||
if err := viperValue("apiBaseUrls", &mw.APIBaseURLs); err != nil {
|
||||
return fmt.Errorf("parse apiBaseUrls: %w", err)
|
||||
if !stackNonEmpty(mw.APIBaseURL) && viper.Get("api-base-url") != nil {
|
||||
if err := viperValue("api-base-url", &mw.APIBaseURL); err != nil {
|
||||
return fmt.Errorf("parse api-base-url: %w", err)
|
||||
}
|
||||
}
|
||||
if err := envJSON("IP_LOCATION_APIS", &mw.IPLocationAPIs); err != nil {
|
||||
if err := envJSON("IP_LOCATION_APIS", &mw.IPLocationAPI); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(mw.IPLocationAPIs) == 0 {
|
||||
if err := viperValue("IPLocationAPIs", &mw.IPLocationAPIs); err != nil {
|
||||
return fmt.Errorf("parse IPLocationAPIs: %w", err)
|
||||
if len(mw.IPLocationAPI) == 0 && viper.Get("ip-location-api") != nil {
|
||||
if err := viperValue("ip-location-api", &mw.IPLocationAPI); err != nil {
|
||||
return fmt.Errorf("parse ip-location-api: %w", err)
|
||||
}
|
||||
}
|
||||
if err := envJSON("TCPING", &mw.TCPing); err != nil {
|
||||
return err
|
||||
}
|
||||
if viper.Get("TCPing") != nil && len(mw.TCPing.DualStack)+len(mw.TCPing.IPv4)+len(mw.TCPing.IPv6) == 0 {
|
||||
if err := viperValue("TCPing", &mw.TCPing); err != nil {
|
||||
return fmt.Errorf("parse TCPing: %w", err)
|
||||
}
|
||||
}
|
||||
if err := envJSON("SPEED_TEST", &mw.SpeedTest); err != nil {
|
||||
return err
|
||||
}
|
||||
if viper.Get("SpeedTest") != nil && len(mw.SpeedTest.DualStack)+len(mw.SpeedTest.IPv4)+len(mw.SpeedTest.IPv6) == 0 {
|
||||
if err := viperValue("SpeedTest", &mw.SpeedTest); err != nil {
|
||||
return fmt.Errorf("parse SpeedTest: %w", err)
|
||||
}
|
||||
}
|
||||
if err := envJSON("NS_LOOKUP", &mw.NSLookup); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(mw.NSLookup) == 0 {
|
||||
if err := viperValue("NSLookup", &mw.NSLookup); err != nil {
|
||||
return fmt.Errorf("parse NSLookup: %w", err)
|
||||
}
|
||||
}
|
||||
if err := envJSON("APIKEYS", &mw.APIKeys); err != nil {
|
||||
if err := envJSON("API_KEYS", &mw.APIKeys); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(mw.APIKeys) == 0 {
|
||||
if err := viperValue("apiKeys", &mw.APIKeys); err != nil {
|
||||
return fmt.Errorf("parse apiKeys: %w", err)
|
||||
if err := viperValue("api-keys", &mw.APIKeys); err != nil {
|
||||
return fmt.Errorf("parse api-keys: %w", err)
|
||||
}
|
||||
}
|
||||
// ws-keys:WS 注册校验表(nodeID → key),优先级 env WS_KEYS > setting.json ws-keys,与 api-keys 相互独立
|
||||
if err := envJSON("WS_KEYS", &mw.WSKeys); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(mw.WSKeys) == 0 {
|
||||
if err := viperValue("ws-keys", &mw.WSKeys); err != nil {
|
||||
return fmt.Errorf("parse ws-keys: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,11 +606,8 @@ func readConfig() error {
|
||||
}
|
||||
|
||||
// 校验配置存在(防止在错误的文件上静默空跑)
|
||||
hasEndpoints := len(mw.APIBaseURLs) > 0 || len(mw.NSLookup) > 0 || len(mw.IPLocationAPIs) > 0 ||
|
||||
len(mw.TCPing.DualStack) > 0 || len(mw.TCPing.IPv4) > 0 || len(mw.TCPing.IPv6) > 0 ||
|
||||
len(mw.SpeedTest.DualStack) > 0 || len(mw.SpeedTest.IPv4) > 0 || len(mw.SpeedTest.IPv6) > 0
|
||||
if !hasEndpoints {
|
||||
return fmt.Errorf("invalid config in %s: missing endpoint lists (expected flat middleware config)", path)
|
||||
if !stackNonEmpty(mw.APIBaseURL) && len(mw.IPLocationAPI) == 0 {
|
||||
return fmt.Errorf("invalid config in %s: missing endpoint pools (expected api-base-url / ip-location-api node pools)", path)
|
||||
}
|
||||
|
||||
// 将配置内容写入全局变量
|
||||
@@ -633,18 +629,16 @@ func readConfig() error {
|
||||
}
|
||||
WS_PORT = n
|
||||
}
|
||||
REMOTE_INGORE_CONFIG = mw.RemoteIngoreConfig
|
||||
REMOTE_IGNORE_CONFIG = mw.RemoteIgnoreConfig
|
||||
CORS = mw.Cors
|
||||
// 逗号分隔的 CORS 域名列表(对齐根 main.go 的 ACCEPT_DOMAINS 用法)
|
||||
if CORS != "" {
|
||||
ACCEPT_DOMAINS = strings.Split(CORS, ",")
|
||||
}
|
||||
API_BASE_URLS = mw.APIBaseURLs
|
||||
IP_LOCATION_APIS = mw.IPLocationAPIs
|
||||
TCPING = mw.TCPing
|
||||
SPEED_TEST = mw.SpeedTest
|
||||
NS_LOOKUP = mw.NSLookup
|
||||
API_BASE_URLS = mw.APIBaseURL
|
||||
IP_LOCATION_APIS = mw.IPLocationAPI
|
||||
API_KEYS = mw.APIKeys
|
||||
WS_KEYS = mw.WSKeys
|
||||
CONFIG_SOURCE = viper.ConfigFileUsed()
|
||||
return nil
|
||||
}
|
||||
@@ -677,7 +671,7 @@ func main() {
|
||||
// WS 服务端(端口由配置 wsPort / 环境变量 WS_PORT 决定,缺省 8092,0 = 关闭;统一走 readConfig)。
|
||||
// 后端节点作为 WS 客户端连入;节点配置 "ws": true 时拨测请求改走 WS 通道。现有 HTTP 路由不受影响。
|
||||
if WS_PORT > 0 {
|
||||
wsSrv = newWSServer(handleWSCommand)
|
||||
wsSrv = newWSServer()
|
||||
go wsSrv.Start(fmt.Sprintf(":%d", WS_PORT))
|
||||
go wsSrv.maintenanceLoop()
|
||||
log.Printf("[ws] ws channel enabled on port %d (node flag \"ws\": true to use)", WS_PORT)
|
||||
|
||||
@@ -4,23 +4,9 @@
|
||||
"rate-limit": 120,
|
||||
"ws-port": 8092,
|
||||
"remote-config-url": "",
|
||||
"remote-ingore-config": [],
|
||||
"remote-ignore-config": [],
|
||||
"cors": "",
|
||||
"apiBaseUrls": [
|
||||
{
|
||||
"label": "中国 江苏 移动",
|
||||
"id": "cn-jiangsu",
|
||||
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
}
|
||||
],
|
||||
"IPLocationAPIs": [
|
||||
{
|
||||
"label": "中国 江苏 移动",
|
||||
"id": "cn-jiangsu",
|
||||
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
}
|
||||
],
|
||||
"TCPing": {
|
||||
"api-base-url": {
|
||||
"DualStack": [
|
||||
{
|
||||
"label": "中国 江苏 移动",
|
||||
@@ -31,23 +17,13 @@
|
||||
"IPv4": [],
|
||||
"IPv6": []
|
||||
},
|
||||
"SpeedTest": {
|
||||
"DualStack": [
|
||||
{
|
||||
"label": "中国 江苏 移动",
|
||||
"id": "cn-jiangsu",
|
||||
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
}
|
||||
],
|
||||
"IPv4": [],
|
||||
"IPv6": []
|
||||
},
|
||||
"NSLookup": [
|
||||
"ip-location-api": [
|
||||
{
|
||||
"label": "中国 江苏 移动",
|
||||
"id": "cn-jiangsu",
|
||||
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
}
|
||||
],
|
||||
"apiKeys": {}
|
||||
}
|
||||
"api-keys": {},
|
||||
"ws-keys": {}
|
||||
}
|
||||
+4
-41
@@ -64,9 +64,6 @@ type wsServer struct {
|
||||
probeMu sync.Mutex
|
||||
probeWait map[string]chan wsProbeResult
|
||||
|
||||
// 指令回调(后端节点下发 command 时触发),由 main 注入
|
||||
onCommand func(cmd wsCommand)
|
||||
|
||||
// 统计
|
||||
statMu sync.Mutex
|
||||
totalReqs int64
|
||||
@@ -74,11 +71,10 @@ type wsServer struct {
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
func newWSServer(onCommand func(cmd wsCommand)) *wsServer {
|
||||
func newWSServer() *wsServer {
|
||||
return &wsServer{
|
||||
peers: make(map[string]*wsPeer),
|
||||
probeWait: make(map[string]chan wsProbeResult),
|
||||
onCommand: onCommand,
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
@@ -121,7 +117,7 @@ func (s *wsServer) Handler(w http.ResponseWriter, r *http.Request) {
|
||||
case "register":
|
||||
var reg struct {
|
||||
NodeID string `json:"nodeId"`
|
||||
Key string `json:"key"` // 注册凭证:与 setting.json apiKeys 配置比对
|
||||
Key string `json:"key"` // 注册凭证:与 setting.json wsKeys 配置比对
|
||||
}
|
||||
_ = json.Unmarshal(msg.Data, ®)
|
||||
if reg.NodeID == "" {
|
||||
@@ -132,9 +128,9 @@ func (s *wsServer) Handler(w http.ResponseWriter, r *http.Request) {
|
||||
s.sendJSON(c, wsMessage{Type: "register_error", Data: mustRaw(wsCommand{Command: "already registered"})})
|
||||
continue
|
||||
}
|
||||
// 注册 key 校验:节点配置了 apiKeys[节点id] 就必须传对 key,否则 401 并断开;
|
||||
// 注册 key 校验:节点配置了 wsKeys[节点id] 就必须传对 key,否则 401 并断开;
|
||||
// 未配置 key 的节点(开放)无需传 key
|
||||
if expected := lookupAPIKey(reg.NodeID); expected != "" && reg.Key != expected {
|
||||
if expected := lookupWSKey(reg.NodeID); expected != "" && reg.Key != expected {
|
||||
log.Printf("[ws] AUTH FAILED for node %s: invalid key", reg.NodeID)
|
||||
s.sendJSON(c, wsMessage{Type: "register_error", Data: mustRaw(struct {
|
||||
Code int `json:"code"`
|
||||
@@ -183,20 +179,6 @@ func (s *wsServer) Handler(w http.ResponseWriter, r *http.Request) {
|
||||
s.sendJSON(c, wsMessage{Type: "pong", NodeID: peer.id, TS: time.Now().Unix()})
|
||||
}
|
||||
|
||||
case "command":
|
||||
if !registered {
|
||||
continue
|
||||
}
|
||||
var cmd wsCommand
|
||||
if err := json.Unmarshal(msg.Data, &cmd); err != nil {
|
||||
log.Printf("[ws] bad command from %s: %v", peer.id, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[ws] command from %s: %s", peer.id, cmd.Command)
|
||||
if s.onCommand != nil {
|
||||
s.onCommand(cmd)
|
||||
}
|
||||
|
||||
default:
|
||||
log.Printf("[ws] unknown message type from %s: %s", peer.id, msg.Type)
|
||||
}
|
||||
@@ -347,22 +329,3 @@ func wsProbeTimeout() time.Duration {
|
||||
}
|
||||
return time.Duration(t) * time.Second
|
||||
}
|
||||
|
||||
// handleWSCommand 处理后端节点经 WS 下发的指令(配置/指令下发)。
|
||||
// 注意:reload_config 会整体重写配置全局变量,运行中重载期间存在与并发请求的竞态,
|
||||
// 生产环境仍建议以重启方式应用配置;此命令主要用于调试与低峰期热更新。
|
||||
func handleWSCommand(cmd wsCommand) {
|
||||
switch cmd.Command {
|
||||
case "reload_config":
|
||||
log.Printf("[ws] command reload_config received, reloading config...")
|
||||
if err := readConfig(); err != nil {
|
||||
log.Printf("[ws] reload_config FAILED: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[ws] config reloaded from %s", CONFIG_SOURCE)
|
||||
case "ping":
|
||||
// 兼容节点发来的 ping(视为心跳)
|
||||
default:
|
||||
log.Printf("[ws] unknown command: %s", cmd.Command)
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -645,7 +645,8 @@ func SearchIP(ip string, databases ...string) map[string]interface{} {
|
||||
if err != nil {
|
||||
result["ip2region"] = "error: " + err.Error()
|
||||
} else {
|
||||
result["ip2region"] = region
|
||||
// 预分割为结构化 JSON,前端无需再按 "|" 拆字符串
|
||||
result["ip2region"] = parseIP2Region(region)
|
||||
}
|
||||
} else {
|
||||
result["ip2region"] = "not loaded"
|
||||
@@ -727,3 +728,32 @@ func SearchIP(ip string, databases ...string) map[string]interface{} {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseIP2Region 将 ip2region 的 "国家|区域|省份|城市|ISP" 管道分隔串预分割为结构化 JSON。
|
||||
// 兼容 5 段(标准)与 4 段(无区域段)格式;区域段(通常为 "0")丢弃。
|
||||
// 输出字段与 bilibili / geocn / maxmind_city 对齐:country / administrative_area / city / isp。
|
||||
func parseIP2Region(s string) map[string]string {
|
||||
parts := strings.Split(s, "|")
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
get := func(i int) string {
|
||||
if i < len(parts) {
|
||||
return parts[i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
res := map[string]string{"country": get(0)}
|
||||
if len(parts) >= 5 {
|
||||
// 标准格式:国家|区域|省份|城市|ISP
|
||||
res["administrative_area"] = get(2)
|
||||
res["city"] = get(3)
|
||||
res["isp"] = get(4)
|
||||
} else {
|
||||
// 4 段及以下:国家|省份|城市|ISP(无区域段)
|
||||
res["administrative_area"] = get(1)
|
||||
res["city"] = get(2)
|
||||
res["isp"] = get(3)
|
||||
}
|
||||
return res
|
||||
}
|
||||
+23
-23
@@ -234,10 +234,10 @@ var (
|
||||
ACCEPT_DOMAINS []string
|
||||
ACCESS_TOKEN string
|
||||
REMOTE_CONFIG_URL string
|
||||
REMOTE_INGORE_CONFIG []string // 不被远端配置覆盖的配置项列表(remote-ingore-config / REMOTE_INGORE_CONFIG)
|
||||
REMOTE_IGNORE_CONFIG []string // 不被远端配置覆盖的配置项列表(remote-ignore-config / REMOTE_IGNORE_CONFIG)
|
||||
WS_URL string // WS 客户端:中间件 WS 地址(ws://host:port/ws)
|
||||
WS_NODE_ID string // WS 客户端:节点 id(与中间件 apiKeys 键一致)
|
||||
WS_NODE_KEY string // WS 客户端:注册 key(与中间件 apiKeys[节点id] 一致,可空)
|
||||
WS_NODE_ID string // WS 客户端:节点 id(与中间件 ws-keys 键一致)
|
||||
WS_NODE_KEY string // WS 客户端:注册 key(与中间件 ws-keys[节点id] 一致,可空)
|
||||
VERSION string
|
||||
COMMIT string
|
||||
BUILD_TIME string
|
||||
@@ -980,7 +980,7 @@ func configValue(CONFIG map[string]any, key string) string {
|
||||
// applyRemoteConfig 从远端配置 URL(REMOTE_CONFIG_URL,可由环境变量或 setting.json
|
||||
// 的 remote-config-url 提供)拉取配置并覆盖本地配置。
|
||||
// 优先级:远端配置 > 环境变量 > setting.json。
|
||||
// access_token 例外:保持原有优先级(环境变量 > setting.json),不随远端配置覆盖。
|
||||
// access-token 例外:保持原有优先级(环境变量 > setting.json),不随远端配置覆盖。
|
||||
func applyRemoteConfig() {
|
||||
url := REMOTE_CONFIG_URL
|
||||
if url == "" {
|
||||
@@ -993,7 +993,7 @@ func applyRemoteConfig() {
|
||||
}
|
||||
// ignore 列表:数组中的配置项不被远端覆盖(逐键判断跳过)
|
||||
ignored := func(key string) bool {
|
||||
for _, k := range REMOTE_INGORE_CONFIG {
|
||||
for _, k := range REMOTE_IGNORE_CONFIG {
|
||||
if k == key {
|
||||
return true
|
||||
}
|
||||
@@ -1026,16 +1026,16 @@ func applyRemoteConfig() {
|
||||
ssrf.SetEnabled(v != "false" && v != "0")
|
||||
}
|
||||
// WS 客户端配置(远端可覆盖,除非在 ignore 列表中)
|
||||
if v := configValue(CONFIG, "ipw-ws-url"); v != "" && !ignored("ipw-ws-url") {
|
||||
if v := configValue(CONFIG, "ws-url"); v != "" && !ignored("ws-url") {
|
||||
WS_URL = v
|
||||
}
|
||||
if v := configValue(CONFIG, "ipw-node-id"); v != "" && !ignored("ipw-node-id") {
|
||||
if v := configValue(CONFIG, "node-id"); v != "" && !ignored("node-id") {
|
||||
WS_NODE_ID = v
|
||||
}
|
||||
if v := configValue(CONFIG, "ipw-node-key"); v != "" && !ignored("ipw-node-key") {
|
||||
if v := configValue(CONFIG, "node-key"); v != "" && !ignored("node-key") {
|
||||
WS_NODE_KEY = v
|
||||
}
|
||||
// access_token 不在此覆盖:保持原有优先级(环境变量 > setting.json)
|
||||
// access-token 不在此覆盖:保持原有优先级(环境变量 > setting.json)
|
||||
if CORS != "" {
|
||||
ACCEPT_DOMAINS = strings.Split(CORS, ",")
|
||||
}
|
||||
@@ -1092,36 +1092,36 @@ func readConfig() {
|
||||
ACCEPT_DOMAINS = strings.Split(CORS, ",")
|
||||
}
|
||||
if ACCESS_TOKEN == "" {
|
||||
ACCESS_TOKEN = viper.GetString("access_token")
|
||||
ACCESS_TOKEN = viper.GetString("access-token")
|
||||
}
|
||||
// REMOTE_CONFIG_URL 优先级:环境变量 > setting.json 的 remote-config-url
|
||||
REMOTE_CONFIG_URL = os.Getenv("REMOTE_CONFIG_URL")
|
||||
if REMOTE_CONFIG_URL == "" {
|
||||
REMOTE_CONFIG_URL = viper.GetString("remote-config-url")
|
||||
}
|
||||
// WS 客户端(接入中间件 WS 通道):IPW_WS_URL / IPW_NODE_ID / IPW_NODE_KEY,env 优先
|
||||
WS_URL = os.Getenv("IPW_WS_URL")
|
||||
// WS 客户端(接入中间件 WS 通道):WS_URL / NODE_ID / NODE_KEY,env 优先
|
||||
WS_URL = os.Getenv("WS_URL")
|
||||
if WS_URL == "" {
|
||||
WS_URL = viper.GetString("ipw-ws-url")
|
||||
WS_URL = viper.GetString("ws-url")
|
||||
}
|
||||
WS_NODE_ID = os.Getenv("IPW_NODE_ID")
|
||||
WS_NODE_ID = os.Getenv("NODE_ID")
|
||||
if WS_NODE_ID == "" {
|
||||
WS_NODE_ID = viper.GetString("ipw-node-id")
|
||||
WS_NODE_ID = viper.GetString("node-id")
|
||||
}
|
||||
WS_NODE_KEY = os.Getenv("IPW_NODE_KEY")
|
||||
WS_NODE_KEY = os.Getenv("NODE_KEY")
|
||||
if WS_NODE_KEY == "" {
|
||||
WS_NODE_KEY = viper.GetString("ipw-node-key")
|
||||
WS_NODE_KEY = viper.GetString("node-key")
|
||||
}
|
||||
// REMOTE_INGORE_CONFIG:不被远端覆盖的配置项列表(JSON 数组字符串,env 优先)
|
||||
if raw := os.Getenv("REMOTE_INGORE_CONFIG"); raw != "" {
|
||||
// REMOTE_IGNORE_CONFIG:不被远端覆盖的配置项列表(JSON 数组字符串,env 优先)
|
||||
if raw := os.Getenv("REMOTE_IGNORE_CONFIG"); raw != "" {
|
||||
var list []string
|
||||
if err := json.Unmarshal([]byte(raw), &list); err == nil {
|
||||
REMOTE_INGORE_CONFIG = list
|
||||
REMOTE_IGNORE_CONFIG = list
|
||||
} else {
|
||||
slog.Warn("invalid REMOTE_INGORE_CONFIG, ignored", "error", err)
|
||||
slog.Warn("invalid REMOTE_IGNORE_CONFIG, ignored", "error", err)
|
||||
}
|
||||
} else {
|
||||
REMOTE_INGORE_CONFIG = viper.GetStringSlice("remote-ingore-config")
|
||||
REMOTE_IGNORE_CONFIG = viper.GetStringSlice("remote-ignore-config")
|
||||
}
|
||||
applyRemoteConfig()
|
||||
slog.Info("SSRF protection initialized", "blockPrivateIPs", ssrf.Enabled())
|
||||
@@ -1150,7 +1150,7 @@ func main() {
|
||||
|
||||
slog.Info("Starting server", "port", PORTS, "gh_proxy", GH_PROXY, "single_stack", SINGLE_STACK, "dns_server", DNS_SERVER, "CORS_ACCEPT", ACCEPT_DOMAINS)
|
||||
|
||||
// WS 客户端:接入中间件 WS 通道(配置 IPW_WS_URL 时启用,HTTP 接口不变)
|
||||
// WS 客户端:接入中间件 WS 通道(配置 WS_URL 时启用,HTTP 接口不变)
|
||||
if WS_URL != "" {
|
||||
go wsClientLoop()
|
||||
slog.Info("WS client enabled", "url", WS_URL, "nodeId", WS_NODE_ID)
|
||||
@@ -5,11 +5,12 @@
|
||||
"dns-server": "",
|
||||
"dnssec-server": "",
|
||||
"block-private-ips": true,
|
||||
"ipdb": true,
|
||||
"ipdb": "false",
|
||||
"cors": "",
|
||||
"remote-config-url": "",
|
||||
"remote-ingore-config": [],
|
||||
"access_token": "",
|
||||
"ipw-ws-url": "",
|
||||
"ipw-node-id": ""
|
||||
"remote-ignore-config": [],
|
||||
"access-token": "",
|
||||
"ws-url": "",
|
||||
"node-id": "",
|
||||
"node-key": ""
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+40
-28
@@ -18,15 +18,15 @@ import (
|
||||
//
|
||||
// 配置(readConfig 解析,环境变量优先,其次 setting.json,远端配置可覆盖):
|
||||
//
|
||||
// IPW_WS_URL ws://<中间件>:8092/ws,ws://<备中间件>:8092/ws (逗号分隔多个中间件,主失败自动切换下一个;不配置 = 不启用)
|
||||
// IPW_NODE_ID 节点 id(与中间件 apiKeys 键 / 前端配置 id 一致)
|
||||
// IPW_NODE_KEY 注册 key(与中间件 apiKeys[节点id] 一致;中间件未配置该节点 key 时留空)
|
||||
// WS_URL ws://<中间件>:8092/ws,ws://<备中间件>:8092/ws (逗号分隔多个中间件,同时连接全部,多活;不配置 = 不启用)
|
||||
// NODE_ID 节点 id(与中间件 ws-keys 键 / 前端配置 id 一致)
|
||||
// NODE_KEY 注册 key(与中间件 ws-keys[节点id] 一致;中间件未配置该节点 key 时留空)
|
||||
//
|
||||
// 与 HTTP handler 共用同一批 webtest 探针函数和缓存:收到 probe 后按 apiType 取数(带缓存),
|
||||
// 返回处理在本文件内完成,结果经 probe_result 上报。节点 HTTP 接口完全不变。
|
||||
//
|
||||
// 心跳:NODE 与 MIDDLEWARE 双向心跳。本端每 10s 发一次 ping,发送失败重试,连续失败 3 次
|
||||
// 判定当前中间件不可用,断开并切换下一个 IPW_WS_URL;收到中间件 ping 回 pong。
|
||||
// 判定当前中间件不可用,断开该连接并按同一 URL 独立重连(不影响其他中间件连接)。
|
||||
|
||||
// wsProbe 学 HTTP handler:从函数拿数据 + 缓存,返回 (status, body)。
|
||||
// raw 与 query 来自中间件 probe 消息(等价于路由参数)。
|
||||
@@ -360,17 +360,17 @@ const (
|
||||
wsHeartbeatTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// wsClientOnce 连接并服务单个中间件 URL:
|
||||
// - 连接/注册失败 → 返回 true(上层切换下一个中间件)
|
||||
// - 心跳连续失败 wsHeartbeatRetry 次 → 主动断开,返回 true(切换下一个中间件)
|
||||
// - 读循环断开 → 返回 true
|
||||
// - 返回 false = 配置缺失(不再重试)
|
||||
func wsClientOnce(url string) bool {
|
||||
// wsClientOnce 连接并服务单个中间件 URL,返回下次重试前的等待时长(由 per-URL 连接循环调用):
|
||||
// - 连接/读循环断开 → 3s(重试同一 URL)
|
||||
// - 注册被拒(register_error)→ 30s(多为 key 配置错误,加大间隔避免空转刷日志)
|
||||
// - 心跳连续失败 wsHeartbeatRetry 次 → 主动断开,3s
|
||||
// - 返回 0 = 配置缺失(不再重试)
|
||||
func wsClientOnce(url string) time.Duration {
|
||||
ctx := context.Background()
|
||||
c, _, err := websocket.Dial(ctx, url, nil)
|
||||
if err != nil {
|
||||
slog.Warn("ws client dial failed", "url", url, "error", err)
|
||||
return true
|
||||
return 3 * time.Second
|
||||
}
|
||||
defer c.CloseNow()
|
||||
|
||||
@@ -383,23 +383,23 @@ func wsClientOnce(url string) bool {
|
||||
_, data, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("ws client register read failed", "url", url, "error", err)
|
||||
return true
|
||||
return 3 * time.Second
|
||||
}
|
||||
var m wsMsg
|
||||
if json.Unmarshal(data, &m) != nil {
|
||||
return true
|
||||
return 3 * time.Second
|
||||
}
|
||||
if m.Type == "register_error" {
|
||||
slog.Warn("ws client register rejected", "nodeId", WS_NODE_ID, "url", url, "data", string(m.Data))
|
||||
return true
|
||||
return 30 * time.Second
|
||||
}
|
||||
if m.Type != "register_ok" {
|
||||
slog.Warn("ws client unexpected message", "type", m.Type)
|
||||
return true
|
||||
return 3 * time.Second
|
||||
}
|
||||
slog.Info("ws client registered", "nodeId", WS_NODE_ID, "url", url)
|
||||
|
||||
// 心跳 goroutine:每 10s 发 ping,失败重试,连续 3 次失败主动断开(上层切换下一个中间件)
|
||||
// 心跳 goroutine:每 10s 发 ping,失败重试,连续 3 次失败主动断开(per-URL 循环重连同一中间件)
|
||||
stopHeartbeat := make(chan struct{})
|
||||
go func() {
|
||||
fail := 0
|
||||
@@ -413,7 +413,7 @@ func wsClientOnce(url string) bool {
|
||||
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
|
||||
}
|
||||
@@ -428,7 +428,7 @@ func wsClientOnce(url string) bool {
|
||||
_, data, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("ws client disconnected", "url", url, "error", err)
|
||||
return true
|
||||
return 3 * time.Second
|
||||
}
|
||||
var msg wsMsg
|
||||
if json.Unmarshal(data, &msg) != nil {
|
||||
@@ -445,23 +445,35 @@ func wsClientOnce(url string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// wsClientLoop 主循环:依次尝试所有配置的中间件 URL(逗号分隔,第一个为主),
|
||||
// 当前中间件连接失败或心跳超时 → 3s 后切换下一个;全部失败循环重试。
|
||||
// wsClientLoop 同时连接所有配置的中间件 URL(逗号分隔,多活):
|
||||
// 每个 URL 由独立 goroutine 负责,各自注册并保持连接,任一断开只重连自己,不影响其他中间件。
|
||||
// 注意:多个 URL 指向同一中间件实例时,中间件按 nodeId 单连接,后注册的连接会顶掉先前的。
|
||||
func wsClientLoop() {
|
||||
urls := splitWSURLs(WS_URL)
|
||||
if len(urls) == 0 {
|
||||
slog.Info("ws client disabled (IPW_WS_URL not set)")
|
||||
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 {
|
||||
shouldRetry := wsClientOnce(url)
|
||||
if !shouldRetry {
|
||||
slog.Info("ws client stopped")
|
||||
return
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
retryDelay := wsClientOnce(url)
|
||||
if retryDelay == 0 {
|
||||
slog.Info("ws client stopped", "url", url)
|
||||
return
|
||||
}
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user