Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa9b83c951 | ||
|
|
c81f92bbd4 | ||
|
|
a411d0bb72 | ||
|
|
a93e74a8c7 | ||
|
|
4cf136b367 | ||
|
|
b350413902 | ||
|
|
08877871af | ||
|
|
d942ff0c5c | ||
|
|
5aaf30dc16 | ||
|
|
789968069b | ||
|
|
87fd2fd4df | ||
|
|
57b5824370 | ||
|
|
52796aa089 | ||
|
|
62b9f686e9 | ||
|
|
7e5bc92aea | ||
|
|
ee28aa2cd6 | ||
|
|
a95519428b | ||
|
|
aa65f23ac9 | ||
|
|
0dfbf7cbdf | ||
|
|
130ac61df0 | ||
|
|
e1a00236ee | ||
|
|
71ec93f068 | ||
|
|
c0f0640075 | ||
|
|
778ed2bd29 | ||
|
|
2ec182fb08 | ||
|
|
e8ec3757fd | ||
|
|
d398c4085c | ||
|
|
d04a90b20c | ||
|
|
92a8c1f308 | ||
|
|
d2e3601f20 | ||
|
|
6a4a3821de | ||
|
|
069847e11f | ||
|
|
30ba61bc11 | ||
|
|
94f03ffa85 | ||
|
|
3a3eeab9f9 | ||
|
|
fa5c2329fe | ||
|
|
957ac5a4c8 | ||
|
|
63c42627f6 | ||
|
|
6512a46596 | ||
|
|
e261666efc | ||
|
|
0d1a9e5bbd | ||
|
|
279f663694 | ||
|
|
3af1076492 | ||
|
|
10f48f8619 | ||
|
|
694ddac6eb | ||
|
|
73ecf67c4e | ||
|
|
b4a18e0fbf |
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
|
||||
+4
-2
@@ -15,9 +15,11 @@ 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
|
||||
*.BIN
|
||||
@@ -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 |
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@ export default defineConfig({
|
||||
lang: "zh-CN",
|
||||
cleanUrls: true,
|
||||
|
||||
head: [
|
||||
['link', { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }],
|
||||
['link', { rel: 'icon', href: '/favicon.ico' }],
|
||||
],
|
||||
|
||||
themeConfig: {
|
||||
// 合并 Teek 的 themeConfig(如 teekHome 等开关)
|
||||
...teekConfig.themeConfig,
|
||||
@@ -35,13 +40,30 @@ export default defineConfig({
|
||||
{ text: 'GitHub', link: 'https://github.com/nomdn/ipw-cn', target: '_blank' },
|
||||
],
|
||||
|
||||
// 右侧目录:显示 h2 + h3(默认只显示 h2)
|
||||
outline: {
|
||||
level: [2, 3],
|
||||
label: '本页目录',
|
||||
},
|
||||
|
||||
sidebar: [
|
||||
// 上级分组:部署(新增页面时在 items 里补一条即可)
|
||||
// 简介
|
||||
{
|
||||
text: '简介',
|
||||
items: [
|
||||
{ text: '概述', link: '/info/overview' },
|
||||
{ text: '快速入门', link: '/guide/getting-started' },
|
||||
{ text: '配置文件', link: '/guide/config' },
|
||||
],
|
||||
},
|
||||
// 部署
|
||||
{
|
||||
text: '部署',
|
||||
items: [
|
||||
{ text: '部署指南', link: '/guide/' },
|
||||
{ text: '前端部署', link: '/guide/deploy-frontend' },
|
||||
{ text: '后端节点', link: '/guide/deploy-node' },
|
||||
{ text: '中间件', link: '/guide/deploy-middleware' },
|
||||
{ text: '前端', link: '/guide/deploy-frontend' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
# 配置文件
|
||||
|
||||
各组件配置说明与完整字段参考。快速上手示例见 [快速开始](/guide/getting-started)。
|
||||
|
||||
## 后端(setting.json)
|
||||
|
||||
后端从当前目录读取 `setting.json`,不存在时使用默认值。最小配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"port": 8080,
|
||||
"dns-server": "119.28.28.28:53",
|
||||
"block-private-ips": true,
|
||||
"ipdb": true,
|
||||
"cors": "",
|
||||
"access-token": ""
|
||||
}
|
||||
```
|
||||
|
||||
- `port`:监听端口
|
||||
- `dns-server`:DNS 解析服务器,支持**主从 failover**——逗号分隔多地址(如 `"119.28.28.28:53,223.5.5.5:53"`),第一个为主,主查询失败(超时/网络错误)自动切换下一个;每项支持 `ip:port`(UDP)或 `https://` URL(DoH);**留空则启动时自动探测系统 DNS**(`IP:Port` 逗号分隔串)兜底
|
||||
- `dnssec-server`:**DNSSEC 专用** DNS 服务器(同样支持主从逗号分隔);留空则沿用 `dns-server`;`dns-server` 与 `dnssec-server` **都留空时自动探测系统 DNS** 兜底
|
||||
- `block-private-ips`:SSRF 防护开关
|
||||
- `ipdb`:IP 数据库开关(首次启动自动下载约 450MB,之后每 24h 更新)
|
||||
- `cors`:允许的请求来源(逗号分隔)
|
||||
- `access-token`:API 访问令牌,留空则不启用鉴权
|
||||
- `ws-url`:WS 通道地址——接入独立中间件的 WebSocket 地址(如 `"wss://middleware-1.api-ipw.wsmdn.top/ws"`,须含 `wss://` 前缀与 `/ws` 路径);支持**逗号分隔多个中间件,同时连接全部(多活)**,任一断开只重连自己,不影响其他连接;留空 = 不启用 WS 客户端,走原 HTTP 接口
|
||||
- `node-id`:WS 节点 id(与中间件 `api-keys`/`ws-keys` 键、前端配置的节点 `id` 一致;建议用 UUID 唯一标识)
|
||||
- `node-key`:WS 注册 key(与中间件 `ws-keys[节点id]` 一致;**必填**——节点未配置该 key 时中间件拒绝注册并返回 401)
|
||||
|
||||
以上字段均可用环境变量覆盖(`PORTS` / `DNS_SERVER` / `DNSSEC_DNS_SERVER` / `BLOCK_PRIVATE_IPS` / `IPDB` / `CORS` / `ACCESS_TOKEN` / `WS_URL` / `NODE_ID` / `NODE_KEY`)。需要从远端拉取配置时,设置 `remote-config-url` 或环境变量 `REMOTE_CONFIG_URL`(优先级:远端 > 环境变量 > setting.json)。
|
||||
|
||||
---
|
||||
|
||||
## 前端(config/index.ts)
|
||||
|
||||
前端配置在 `frontend-ssr/config/index.ts`,部署前按需修改。完整配置项如下:
|
||||
|
||||
| 配置项 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| `siteUrl` | string | 站点对外地址 |
|
||||
| `siteName` | string | 站点名称(页面标题 / 描述 / 页脚品牌) |
|
||||
| `EnableInternalMiddleware` | boolean | 是否启用前端内置中间件(作为 `/middleware/*` 候选最后一位兜底),默认 `true` |
|
||||
| `rateLimitPerMinute` | number | 内置中间件单 IP 限流次数(次/分钟),默认 `120`,`0` 表示不限流 |
|
||||
| `Middleware` | string[] | 外部独立中间件 base URL 列表,`/middleware/*` 请求依次尝试、出错重试下一个 |
|
||||
| `umamiHost` / `umamiScriptUrl` / `umamiWebsiteId` | string | Umami 统计配置 |
|
||||
| `ICP` / `GongAn` | string | 网站备案号(页脚展示) |
|
||||
| `noindex` | boolean | 全站是否禁止搜索引擎索引 |
|
||||
| `v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` | string | IPv4 / IPv6 / 双栈 IP 查询接口 |
|
||||
| `APIBaseURL` | 节点池 | 拨测上游节点池(whois / ssl / detail / dns / dnssec / tcping / speed),含 `DualStack` / `IPv4` / `IPv6` 三栈 |
|
||||
| `IPLocationAPI` | 节点数组 | IP 归属地 / ASN 查询节点池(纯数组,无栈区分) |
|
||||
|
||||
完整模板(复制到 `frontend-ssr/config/index.ts`,替换占位值即可):
|
||||
|
||||
```ts
|
||||
const config = {
|
||||
siteUrl: "https://your-domain.com/", // 站点对外地址
|
||||
siteName: "你的站点名称", // 站点名称(页脚展示)
|
||||
// 是否启用前端内置中间件(本地转发,作为候选最后一位兜底)
|
||||
EnableInternalMiddleware: true,
|
||||
// 内置中间件单 IP 限流次数(次/分钟),0 表示不限流
|
||||
rateLimitPerMinute: 120,
|
||||
// 外部独立中间件 base URL 列表(可留空,仅用内置中间件)
|
||||
Middleware: <string[]>[],
|
||||
// Umami 统计(不需要留空)
|
||||
umamiHost: "",
|
||||
umamiScriptUrl: "",
|
||||
umamiWebsiteId: "",
|
||||
// 备案号(页脚展示,不需要留空)
|
||||
ICP: "",
|
||||
GongAn: "",
|
||||
// 是否禁止搜索引擎索引
|
||||
noindex: false,
|
||||
// 出站 IP 检测接口(页面直连需 CORS,建议 wsmdn.top 的三个)
|
||||
v4OnlyAPI: "https://4.wsmdn.top",
|
||||
v6OnlyAPI: "https://6.wsmdn.top",
|
||||
DualStackAPI: "https://test.wsmdn.top",
|
||||
// 拨测节点池(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: []
|
||||
},
|
||||
// IP 归属地 / ASN 查询节点池(纯数组,无栈区分)
|
||||
IPLocationAPI: [
|
||||
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
|
||||
]
|
||||
}
|
||||
export { config }
|
||||
```
|
||||
|
||||
> 节点 `id` 需与后端节点 / 中间件 `api-keys` 的键保持一致,中间件按 `id` 注入 `Authorization: Bearer <key>`;WS 通道的节点注册校验另见中间件 `ws-keys`(与后端 `node-key` 对应)。
|
||||
|
||||
节点数组项均为 `{ label, id, url }` 结构,例如:
|
||||
|
||||
```ts
|
||||
APIBaseURL: {
|
||||
DualStack: [
|
||||
{ label: "中国 江苏 移动", id: "cn-jiangsu", url: "https://cn-jiangsu.api-ipw.wsmdn.top/" }
|
||||
],
|
||||
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)
|
||||
|
||||
`v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` 用于获取请求方的出站 IP(分别对应 IPv4、IPv6、双栈),首页与部分页面会直接请求。可直接使用的接口:
|
||||
|
||||
| 接口 | 类型 |
|
||||
|------|------|
|
||||
| `https://4.wsmdn.top` | IPv4 |
|
||||
| `https://6.wsmdn.top` | IPv6 |
|
||||
| `https://test.wsmdn.top` | 双栈 |
|
||||
| `https://ipv4.ip.sb` | IPv4 |
|
||||
| `https://ipv6.ip.sb` | IPv6 |
|
||||
| `https://ip.sb` | 双栈 |
|
||||
| `https://4.itdog.cn` | IPv4 |
|
||||
| `https://6.itdog.cn` | IPv6 |
|
||||
| `https://v.itdog.cn` | 双栈 |
|
||||
|
||||
> **CORS 说明**:以上接口仅 `4.wsmdn.top` / `6.wsmdn.top` / `test.wsmdn.top` 返回 `Access-Control-Allow-Origin: *`,其余(ip.sb、itdog.cn)不带 CORS 头。前端页面在客户端导航时会从浏览器直接请求这些接口,**没有 CORS 会被浏览器拦截**,因此页面直连检测建议使用 wsmdn.top 的三个;ip.sb / itdog.cn 适合在服务端或命令行场景使用。
|
||||
|
||||
---
|
||||
|
||||
## 前端内置中间件(NUXT_APIKEYS)
|
||||
|
||||
前端内置中间件(`frontend-ssr/server/routes/middleware/[...slug].get.ts`)作为 `/middleware/*` 候选列表的最后一位兜底,转发时也会注入 key:
|
||||
|
||||
- 读取方式:`useRuntimeConfig(event).apiKeys`,按请求的 `backendID`(节点 `id`)查找
|
||||
- 环境变量:**`NUXT_APIKEYS`**(Nuxt 约定 `NUXT_` + 配置键 `apiKeys`)
|
||||
- 格式:JSON 字符串,例如 `NUXT_APIKEYS='{"cn-jiangsu":"your-key"}'`
|
||||
- 注入方式:转发请求头 `Authorization: Bearer <key>`
|
||||
|
||||
也可以在 `nuxt.config.ts` 的 `runtimeConfig.apiKeys` 里直接写死(不推荐,密钥不应进仓库)。
|
||||
|
||||
---
|
||||
|
||||
## 独立中间件(middleware-go)
|
||||
|
||||
中间件配置在 `middleware-go/setting.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"port": "8091",
|
||||
"http-timeout-seconds": 30,
|
||||
"cors": "",
|
||||
"rate-limit": 120,
|
||||
"ws-port": 8092,
|
||||
"remote-config-url": "",
|
||||
"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-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-通道接入可选)。
|
||||
|
||||
---
|
||||
|
||||
## 远端配置(REMOTE_CONFIG_URL)
|
||||
|
||||
**什么是远端配置**:把配置文件放到一个可访问的 URL 上(如 Gist、对象存储、静态托管、任意 HTTP 服务),部署时只需设置一个环境变量,之后改配置**不用重新部署、不用重启**,下次启动自动拉取最新配置。适合多节点统一管理、批量更新节点配置的场景。
|
||||
|
||||
**支持的组件**(三个组件均已支持):
|
||||
|
||||
| 组件 | 触发方式 | 远端配置格式 |
|
||||
|------|----------|--------------|
|
||||
| 后端(主线 `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` 格式 |
|
||||
|
||||
**合并规则**(重要,逐字段生效):
|
||||
|
||||
- **优先级:远端配置 > 环境变量 > setting.json**
|
||||
- 远端 JSON 中**非空的字段才覆盖**本地值:字符串非空、数组非空、数字非 0(`rate-limit` 为指针语义,缺省=不覆盖,显式 0=不限流)
|
||||
- 部分字段缺省不会冲掉本地配置——远端只需包含要覆盖的字段
|
||||
- **敏感凭据强制忽略,永远不被远端覆盖**(代码写死,无需配置):
|
||||
- 后端 `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` 等),也可覆盖"非空才覆盖"规则
|
||||
|
||||
**拉取行为**:
|
||||
|
||||
- 在**进程启动时的配置加载阶段**一次性拉取
|
||||
- HTTP 超时 **10 秒**,仅接受 **200** 响应(404/5xx 视为失败)
|
||||
- 拉取或 JSON 解析失败:打印警告日志 `WARN failed to fetch remote config, fallback to local: ...`,**自动回退本地配置**,服务正常启动,不会崩溃
|
||||
- 拉取成功:启动日志打印 `remote config applied from <url>`
|
||||
- **没有重试、没有定时刷新**:改远端配置后需要重启进程才会重新拉取
|
||||
|
||||
**示例一:后端**(主线,远端 JSON 放在 `https://example.com/ipw-config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"port": 8080,
|
||||
"single-stack": "ipv4",
|
||||
"dns-server": "119.28.28.28:53",
|
||||
"cors": "https://ipw.wsmdn.top",
|
||||
"remote-config-url": ""
|
||||
}
|
||||
```
|
||||
|
||||
部署时:
|
||||
|
||||
```bash
|
||||
REMOTE_CONFIG_URL=https://example.com/ipw-config.json ./lemonipw
|
||||
```
|
||||
|
||||
**示例二:独立中间件**(middleware-go,远端 JSON 放在 `https://example.com/mw-config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"port": "8091",
|
||||
"cors": "",
|
||||
"rate-limit": 120,
|
||||
"APIBaseURL": {
|
||||
"DualStack": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
|
||||
"IPv4": [],
|
||||
"IPv6": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> 敏感凭据不得上传远端:`api-keys`/`ws-keys`(及后端 `access-token`、节点 key 等密钥)**禁止**写入远端配置——即使写了也会被强制忽略。凭据只放本地 setting.json 或环境变量。
|
||||
|
||||
部署时:
|
||||
|
||||
```bash
|
||||
REMOTE_CONFIG_URL=https://example.com/mw-config.json ./middleware-go-linux-amd64
|
||||
```
|
||||
|
||||
systemd 中同样用 `Environment="REMOTE_CONFIG_URL=..."` 配置。
|
||||
|
||||
**如何验证生效**:
|
||||
|
||||
- 启动日志出现 `remote config applied from <url>` 即拉取成功
|
||||
- 用 `curl -i <远端URL>` 确认远端返回 `200` 且是合法 JSON
|
||||
- 实际操作验证:在远端把 `port` 改成另一个值,重启进程,观察监听端口是否变化
|
||||
|
||||
**安全与排查**:
|
||||
|
||||
- **敏感凭据不得上传远端**:远端配置(Gist / 对象存储 / 静态托管等)**禁止包含任何密钥**——`api-keys`/`ws-keys`、`access-token`、节点 key(`node-key`)等。这些值会被强制忽略,但远端文件本身一旦泄露等于直接暴露凭据;凭据一律放本地 `setting.json` 或环境变量注入
|
||||
- 远端 URL 必须能被节点访问;优先使用 **HTTPS** 地址
|
||||
- 远端配置文件如果放在公开可访问的位置(公开 Gist、无鉴权对象存储),即便不含密钥,也等于把节点拓扑、节点 id 等敏感信息公之于众,建议放在私有仓库 / 带鉴权的对象存储 / 内部服务上
|
||||
- 常见失败排查:
|
||||
|
||||
| 现象 | 原因 | 排查 |
|
||||
|------|------|------|
|
||||
| 日志 `WARN ... status 404` | URL 路径错误或远端文件不存在 | `curl -i <URL>` 检查返回码 |
|
||||
| 日志 `WARN ... timeout` / 连接失败 | 网络不通或超时 | 确认节点可访问该 URL |
|
||||
| 日志 `WARN ... invalid ... JSON` | 远端内容不是合法 JSON 或格式不符 | 检查 JSON 合法性、字段名与组件格式一致 |
|
||||
| 启动日志无 remote 相关输出 | `REMOTE_CONFIG_URL` 未设置 | `env \| grep REMOTE` 确认环境变量已注入 |
|
||||
@@ -1,6 +1,6 @@
|
||||
# 前端部署
|
||||
|
||||
前端(`frontend-ssr/`)是全项目的核心,负责数据可视化与接口调用。基于 **Nuxt 4 SSR** 构建,部署到 **Cloudflare Workers**(nitro `cloudflare_module` preset)。
|
||||
前端(`frontend-ssr/`)是全项目的核心,负责数据可视化与接口调用。基于 **Nuxt 4 SSR** 构建。
|
||||
|
||||
## 架构与部署目标
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
| 技术栈 | Nuxt 4 + Vue 3 + Element Plus + VueUse |
|
||||
| 运行时 | Node.js 22(见 `.node-version`)、包管理器 pnpm 10 |
|
||||
| 构建产物 | `.output/server/index.mjs` + `.output/public/`(静态资源) |
|
||||
| 部署目标 | Cloudflare Workers(SSR),也可部署到其他符合 Serverless 标准的平台 |
|
||||
| 部署配置 | `frontend-ssr/wrangler.jsonc` |
|
||||
| 构建命令 | `pnpm build`(Nitro SSR 构建) |
|
||||
|
||||
## 准备工作
|
||||
|
||||
@@ -27,40 +26,46 @@ pnpm dev
|
||||
|
||||
## 配置说明
|
||||
|
||||
前端所有配置集中在 `frontend-ssr/config/index.ts`:
|
||||
前端所有配置集中在 `frontend-ssr/config/index.ts`,完整配置项见 [配置文件 - 前端](/guide/config#前端-configindexts):
|
||||
|
||||
| 配置项 | 说明 |
|
||||
|--------|------|
|
||||
| `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` |
|
||||
| `v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` | Worker IP 查询接口 |
|
||||
| `rateLimitPerMinute` | 内置中间件单 IP 限流次数(次/分钟),默认 `120`,`0` 表示不限流 |
|
||||
| `v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` | 出站 IP 检测接口(页面直连建议使用带 CORS 的 wsmdn.top 接口) |
|
||||
| `umamiHost` 等 | Umami 统计配置 |
|
||||
| `ICP` / `GongAn` | 网站备案号(页脚展示) |
|
||||
| `noindex` | 是否禁止搜索引擎索引 |
|
||||
|
||||
> 生产环境建议通过构建时的环境变量或部署平台配置覆盖敏感项,不要把密钥写进 `config/index.ts` 提交到仓库。
|
||||
> 生产环境建议通过运行时的环境变量或部署平台配置覆盖敏感项,不要把密钥写进 `config/index.ts` 提交到仓库。
|
||||
|
||||
## 构建与部署
|
||||
## 部署方案
|
||||
|
||||
### 本地构建
|
||||
### 方案一:EdgeOne
|
||||
|
||||
腾讯云 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)
|
||||
|
||||
|
||||
|
||||
### 方案二:Vercel
|
||||
|
||||
点击下方按钮一键导入并部署到 Vercel(自动识别 Nuxt 项目,`root-directory` 已预置为 `frontend-ssr`):
|
||||
|
||||
[](https://vercel.com/new/clone?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)
|
||||
### 方案三:Cloudflare Workers
|
||||
|
||||
使用 wrangler 手动部署:
|
||||
|
||||
```bash
|
||||
# 构建(默认 preset 为 cloudflare_module,可由 NITRO_PRESET 覆盖)
|
||||
cd frontend-ssr
|
||||
pnpm build
|
||||
|
||||
# 本地预览(构建 + wrangler dev)
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
### 部署到 Cloudflare Workers
|
||||
|
||||
```bash
|
||||
# 构建 + 发布
|
||||
pnpm deploy # 等价于 pnpm build && wrangler deploy
|
||||
```
|
||||
|
||||
@@ -75,34 +80,19 @@ export CLOUDFLARE_ACCOUNT_ID=your_account_id
|
||||
npx wrangler login
|
||||
```
|
||||
|
||||
`wrangler.jsonc` 关键配置:
|
||||
> Cloudflare 部署按钮不支持子目录,而前端位于仓库 `frontend-ssr/` 子目录,因此不提供一键部署按钮;使用上方 wrangler 命令或仓库 CI/CD 工作流部署。
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "ipw-cn", // Worker 名称
|
||||
"main": "./.output/server/index.mjs", // SSR 入口
|
||||
"assets": { "binding": "ASSETS", "directory": "./.output/public/" },
|
||||
"compatibility_flags": ["nodejs_compat"] // 需要 Node.js 兼容层
|
||||
}
|
||||
### 方案四:直接运行(Node 自托管)
|
||||
|
||||
前端是 Node.js 应用,构建产物为 JS 脚本 + 静态资源(非二进制),可直接用 Node 运行:
|
||||
|
||||
```bash
|
||||
cd frontend-ssr
|
||||
pnpm build
|
||||
node .output/server/index.mjs # 默认监听 3000 端口
|
||||
```
|
||||
|
||||
## 一键部署到 EdgeOne Makers
|
||||
|
||||
腾讯云 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`。
|
||||
生产环境建议使用进程守护(systemd / pm2 等)保持常驻。
|
||||
|
||||
## CI/CD 自动部署
|
||||
|
||||
@@ -111,14 +101,9 @@ npx wrangler login
|
||||
- **触发条件**:push 到 `main` 且改动路径为 `frontend-ssr/**`;或手动 `workflow_dispatch`
|
||||
- **步骤**:checkout → Node 22 + pnpm 10 → `pnpm install` → `pnpm run deploy --keep-vars`
|
||||
- **所需 Secrets**:`CLOUDFLARE_API_TOKEN`、`CLOUDFLARE_ACCOUNT_ID`
|
||||
- **注意**:工作流设置了 `NITRO_PRESET: cloudflare_module`,如本地构建异常可参考此值
|
||||
- **注意**:工作流设置了 `NITRO_PRESET: cloudflare_module`
|
||||
|
||||
## 常见问题
|
||||
|
||||
- **构建报 preset 相关错误**:确认构建环境变量 `NITRO_PRESET=cloudflare_module`。
|
||||
- **本地 `pnpm preview` 与线上行为不一致**:预览使用 `wrangler dev` 模拟 Workers 运行时,请确保 `nodejs_compat` 兼容标志已启用。
|
||||
- **部署后接口 403 / 跨域**:检查后端 `cors` 配置(独立中间件见 `middleware-go/setting.json` 的 `cors` 字段,逗号分隔允许域名);服务端转发请求不带 `Origin`,浏览器直接调用才受 CORS 限制。
|
||||
|
||||
## 其他部署目标
|
||||
|
||||
本项目前端同样支持部署到其他**符合 Serverless 标准**的平台(无状态、事件驱动、自动扩缩容),如 EdgeOne Pages 等。只需按目标平台的约定调整 nitro preset(例如 `nitro preset` 切换)并配置对应产物入口即可。
|
||||
- **出站 IP 检测接口被浏览器拦截**:页面直连的 IP 检测接口需要 CORS,详见 [配置文件 - 出站 IP 检测接口](/guide/config#出站-ip-检测接口v4onlyapi--v6onlyapi--dualstackapi)。
|
||||
@@ -0,0 +1,109 @@
|
||||
# 中间件部署
|
||||
|
||||
独立中间件(middleware-go)只负责**请求转发和 Key 注入**,不参与业务计算。前端将 `/middleware/*` 请求转发到中间件,由中间件转发到对应后端节点并注入 `Authorization: Bearer <key>`。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 项目只提供配置示例,**使用前必须先复制为 `setting.json`**(GitHub Releases 中对应文件为 `middleware.setting.json.example`):
|
||||
>
|
||||
> ```bash
|
||||
> # 仓库内
|
||||
> cp middleware-go/setting.json.example middleware-go/setting.json
|
||||
>
|
||||
> # 从 Release 下载后
|
||||
> cp middleware.setting.json.example setting.json
|
||||
> ```
|
||||
>
|
||||
> 复制后按需修改 `api-base-url` / `ip-location-api` / `api-keys` / `ws-keys` / `cors` 等配置项。
|
||||
|
||||
## 方案一:Docker
|
||||
|
||||
`middleware-go/` 目录提供 `Dockerfile`:
|
||||
|
||||
```bash
|
||||
cd middleware-go
|
||||
docker build -t middleware-go .
|
||||
docker run -d -p 8091:8091 \
|
||||
-v $(pwd)/setting.json:/app/setting.json \
|
||||
middleware-go
|
||||
```
|
||||
|
||||
配置通过挂载 `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 注册校验,两者相互独立)。
|
||||
|
||||
## 方案二:二进制
|
||||
|
||||
```bash
|
||||
cd middleware-go
|
||||
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` / `API_KEYS` / `WS_KEYS` / `RATE_LIMIT` 等,数组/对象用 JSON 字符串),优先级:**环境变量 > setting.json > 默认值**(远端配置 `REMOTE_CONFIG_URL` 高于两者,详见 [配置文件 - 远端配置](/guide/config#远端配置remoteconfigurl))。
|
||||
|
||||
需要守护运行时,参考 [后端节点部署 - 方案五:一键安装](/guide/deploy-node#方案五一键安装installsh) 的 systemd 管理方式(`ExecStart` 指向中间件二进制,`WorkingDirectory` 指向 `middleware-go/setting.json` 所在目录)。
|
||||
|
||||
## WS 通道(拨测数据经 WebSocket 传输)
|
||||
|
||||
middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入,拨测数据上传改用 WS 通道传输(HTTP 转发保留,双通道并存)。**现有 HTTP 接口(`/v1/*` / `/middleware/*`)完全不变**。
|
||||
|
||||
### 架构
|
||||
|
||||
```
|
||||
前端(HTTP) → middleware-go(8091,HTTP 转发)→ 后端节点(ws:false 时)
|
||||
↘ middleware-go(8092,WS 服务端)→ 后端节点(ws:true 时,WS 客户端连入)
|
||||
```
|
||||
|
||||
- WS 服务端跑在**独立端口**(配置 `ws-port`,默认 `8092`,路径 `/ws`;`ws-port: 0` 或环境变量 `WS_PORT=0` 关闭),与 fiber HTTP 服务完全分离;端口走统一配置管道(远端配置 > 环境变量 > setting.json)
|
||||
- 节点配置加 `"ws": true`(兼容 `"ws": "true"` 字符串写法)→ 该节点的拨测请求改走 WS 通道;缺省 `false` → 原 HTTP 转发不变
|
||||
- ws:true 节点未连接或拨测超时 → 返回 502,不影响其他节点
|
||||
|
||||
### 配置示例
|
||||
|
||||
```json
|
||||
{
|
||||
"port": "8091",
|
||||
"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 } ]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
启动后日志会打印 `ws channel enabled on port 8092`。
|
||||
|
||||
### 消息协议
|
||||
|
||||
所有消息为 JSON 文本帧,信封:`{ "type": "...", "nodeId": "...", "ts": <unix秒>, "data": {...} }`
|
||||
|
||||
| 方向 | type | data 说明 |
|
||||
|------|------|-----------|
|
||||
| 节点 → 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) |
|
||||
| 节点 → middleware | `probe_result` | `{ "requestId": "...", "status": 200, "body": <JSON 值> }`(body 为 JSON 字符串时按原文透传) |
|
||||
| middleware → 节点 | `ping` | 心跳,节点回 `pong` |
|
||||
| middleware → 节点 | `status` | 状态/统计上报:`{ "uptimeSeconds": ..., "totalRequests": ..., "errorRequests": ... }`(每 20s) |
|
||||
| 节点 → middleware | `command` | 指令下发,如 `{ "command": "reload_config" }`(重读配置;与并发请求存在竞态,生产建议重启生效) |
|
||||
|
||||
### 节点侧接入(WS 客户端)
|
||||
|
||||
1. 连接 `ws://<中间件IP>:8092/ws`(本仓库后端节点接入方式见 [后端节点部署 - WS 通道接入](/guide/deploy-node#ws-通道接入可选),配置 `WS_URL` / `NODE_ID` / `NODE_KEY` 即可自动连接注册;`WS_URL` 支持逗号分隔多个中间件地址,节点会**同时连接全部(多活)**,任一断开只重连自己)
|
||||
2. 首条消息发 `register { "nodeId": "<与中间件配置一致的节点 id>", "key": "<注册凭证>" }`(节点在 setting.json `ws-keys` 里配了 key 就必须传对;未配置 key 的开放节点可不传)
|
||||
3. 收到 `probe` → 执行拨测 → 回 `probe_result`(必须携带原 `requestId`,支持乱序返回,同一连接可并发多个拨测)
|
||||
4. 收到 `ping` → 回 `pong`;`status` 可忽略或记录
|
||||
5. 断线后重连并重新 `register` 即可
|
||||
|
||||
### 注册 key 校验
|
||||
|
||||
节点注册时中间件按 `ws-keys` 配置校验 key(与 `api-keys` HTTP 转发鉴权相互独立):
|
||||
|
||||
- 节点在 `ws-keys` 配置了 key(非空)→ `register` 必须携带正确 key,不传或传错返回 `register_error { "code": 401, "command": "invalid key" }` 并**断开连接**,不进入数据阶段
|
||||
- 节点未配置 key(`ws-keys` 无该项或为空)→ 开放注册,不传 key 即可
|
||||
|
||||
与 HTTP 通道的鉴权语义一致(配置了 key 就必须带凭据,留空 = 开放)。
|
||||
|
||||
### 说明
|
||||
|
||||
- 注册阶段按节点 `ws-keys` 配置校验 key(配了 key 必须传对,否则 401 断开;未配置则开放,详见上文「注册 key 校验」);拨测请求经 WS 转发时不携带 `Authorization` 头(后续由节点侧自行决定是否需要额外鉴权)
|
||||
- `ws:true` 且 WS 未启用(`WS_PORT=0`)时,自动回退原 HTTP 转发
|
||||
@@ -0,0 +1,126 @@
|
||||
# 后端节点部署
|
||||
|
||||
后端节点提供全部检测 API(IP 归属地 / ASN / SSL / DNS / DNSSEC / Whois / TCPing / 测速 / 截图等),可在任意地区部署,组成去中心化测试网络。
|
||||
|
||||
## 方案一: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=serverless%2Fedgeone)
|
||||
|
||||
|
||||
也可以使用 EdgeOne CLI 手动部署:
|
||||
|
||||
```bash
|
||||
cd serverless/edgeone
|
||||
npx edgeone pages deploy -n ipw-cn -t $EDGEONE_API_TOKEN
|
||||
```
|
||||
|
||||
`serverless/edgeone/` 版本的配置来源为 环境变量 + 远端配置(`REMOTE_CONFIG_URL`)。
|
||||
|
||||
## 方案二:Vercel
|
||||
|
||||
点击下方按钮一键导入仓库(部署目标为 `serverless/edgeone/cloud-functions` 目录,即 Go 边缘函数版本):
|
||||
|
||||
[](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` 中,**该节点只能填入 `APIBaseURL.IPv4`**(拨测节点池的 IPv4 栈),不要放进 `DualStack` / `IPv6` 栈
|
||||
|
||||
## 方案三:Docker
|
||||
|
||||
仓库根目录提供 `Dockerfile`(多阶段构建,产物基于 Alpine):
|
||||
|
||||
```bash
|
||||
docker build -t lemon-ipw .
|
||||
docker run -d -p 8080:8080 \
|
||||
-v $(pwd)/setting.json:/app/setting.json \
|
||||
lemon-ipw
|
||||
```
|
||||
|
||||
配置通过挂载 `setting.json` 提供,也可用环境变量覆盖(`PORTS` / `CORS` / `ACCESS_TOKEN` 等)。
|
||||
|
||||
## 方案四:二进制
|
||||
|
||||
```bash
|
||||
# 编译(Go 后端在 src/ 目录)
|
||||
cd src && go build -o lemonipw .
|
||||
|
||||
# 运行(与 setting.json 同目录)
|
||||
./lemonipw
|
||||
```
|
||||
|
||||
首次启动自动下载 IP 数据库(约 450MB),之后每 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,223.5.5.5:53`);留空 = 启动时自动探测系统 DNS |
|
||||
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server;都留空 = 自动探测系统 DNS |
|
||||
| IP 数据库 | `Y` | `IPDB`,首次启动下载约 450MB |
|
||||
| CORS | 留空 | 逗号分隔允许来源 |
|
||||
| 远端配置地址 | 留空 | `REMOTE_CONFIG_URL` |
|
||||
| WS 通道接入 | `N` | 选 `y` 后继续输入 WS_URL / NODE_ID / NODE_KEY |
|
||||
| 其他环境变量 | 无 | 每行一个 `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。
|
||||
@@ -0,0 +1,75 @@
|
||||
# 快速开始
|
||||
|
||||
> 本章节将指导您以最低成本部署一个功能完整的测试站
|
||||
|
||||
## 尝试一下
|
||||
|
||||
我们的开发团队已经部署了一个功能完整的预览站:
|
||||
[预览站点](https://ipw.wsmdn.top/)
|
||||
|
||||
## 前置准备
|
||||
|
||||
- 一台 Linux 服务器(需 root 权限 + systemd)
|
||||
- 服务器上可访问 GitHub(下载 release 二进制)
|
||||
- 无需本地编译环境,无需准备配置文件
|
||||
|
||||
## 开始部署
|
||||
|
||||
### 交互式一键部署(推荐)
|
||||
|
||||
```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
|
||||
|
||||
# 执行安装(root 权限)
|
||||
sudo bash install.sh
|
||||
```
|
||||
|
||||
> [!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,223.5.5.5:53`);留空 = 启动时自动探测系统 DNS |
|
||||
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server;都留空 = 自动探测系统 DNS |
|
||||
| IP 数据库 | `Y` | 首次启动下载约 450MB |
|
||||
| CORS | 留空 | 逗号分隔允许来源 |
|
||||
| 远端配置地址 | 留空 | `REMOTE_CONFIG_URL` |
|
||||
| WS 通道接入 | `N` | 选 `y` 后输入 WS_URL / NODE_ID / NODE_KEY |
|
||||
| 其他环境变量 | 无 | 每行一个 `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)
|
||||
+11
-55
@@ -2,61 +2,19 @@
|
||||
|
||||
### 本章节将一步步指导您自部署柠檬味IPW
|
||||
|
||||
Lemon IPW 采用前后端分离架构,前端、后端、独立中间件可各自独立部署,并按需组合:
|
||||
Lemon IPW 采用前后端分离架构,**后端节点、独立中间件、前端**三个组件可各自独立部署,并按需组合:
|
||||
|
||||
```
|
||||
┌─────────────────┐ /v1/* ┌──────────────────┐
|
||||
│ SSR 前端 │ ──────────────▶ │ 后端 / 独立中间件 │
|
||||
│ (Cloudflare │ /middleware/* │ (自托管 / EdgeOne)│
|
||||
│ Workers 等) │ ──────────────▶ │ 多节点候选重试 │
|
||||
└─────────────────┘ └──────────────────┘
|
||||
```
|
||||
| 组件 | 职责 | 部署方案 |
|
||||
|------|------|----------|
|
||||
| [后端节点](/guide/deploy-node) | 提供全部检测 API | EdgeOne / Vercel / Docker / 二进制 |
|
||||
| [独立中间件](/guide/deploy-middleware) | 请求转发 + Key 注入 | Docker / 二进制 |
|
||||
| [前端](/guide/deploy-frontend) | 数据可视化与接口调用 | EdgeOne / Vercel / Cloudflare Workers / 直接运行 |
|
||||
|
||||
## 前端
|
||||
组件之间通过 `/v1/*`(直连后端)与 `/middleware/*`(经中间件转发)两类接口通信,前端按节点列表轮询、失败自动重试下一个节点。
|
||||
|
||||
前端是全项目的核心,负责数据可视化和接口调用,部署到 Cloudflare Workers(也可部署到其他符合 Serverless 标准的平台)。
|
||||
## 快速开始
|
||||
|
||||
[▶ 前端部署](/guide/deploy-frontend)
|
||||
|
||||
## 后端(自托管)
|
||||
|
||||
Go 后端提供全部检测 API(IP 归属地 / ASN / SSL / DNS / DNSSEC / Whois / TCPing / 测速 / 截图等),可 Docker 部署或直接运行二进制:
|
||||
|
||||
```bash
|
||||
# 方式一:Docker
|
||||
docker build -t lemon-ipw .
|
||||
docker run -p 8080:8080 -v $(pwd)/setting.json:/app/setting.json lemon-ipw
|
||||
|
||||
# 方式二:直接运行
|
||||
go run main.go # 首次启动自动下载 IP 数据库(约 200MB),之后每 24h 自动更新
|
||||
```
|
||||
|
||||
配置项见根目录 `setting.json` 及环境变量表(`PORT` / `CORS` / `BLOCK_PRIVATE_IPS` / `DNS_SERVER` 等)。
|
||||
|
||||
## 独立中间件(middleware-go)
|
||||
|
||||
转发中间件,供前端在多个上游节点之间做候选重试,可独立部署:
|
||||
|
||||
```bash
|
||||
cd middleware-go
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o middleware-go-linux-arm64 .
|
||||
./middleware-go-linux-arm64 # 运行(可用 SETTING_FILE 指定配置路径)
|
||||
./middleware-go-linux-arm64 --version # 查看版本
|
||||
```
|
||||
|
||||
所有配置可通过环境变量覆盖(`API_BASE_URLS` / `IP_LOCATION_APIS` / `CORS` / `APIKEYS` 等,数组/对象用 JSON 字符串),配置优先级:**环境变量 > setting.json > 默认值**。
|
||||
|
||||
## 部署平台
|
||||
|
||||
本项目支持**一切符合 Serverless 标准的部署平台**,同时也支持容器 / 二进制自托管:
|
||||
|
||||
| 组件 | 可部署目标 |
|
||||
|------|-----------|
|
||||
| SSR 前端 | Cloudflare Workers、EdgeOne Pages 及任意 Serverless 平台 |
|
||||
| 后端(自托管) | Docker / 任意容器或裸机 |
|
||||
| 独立中间件 | 常规服务 / Serverless 函数 |
|
||||
| 边缘后端 | EdgeOne Pages(`edgeone/`、`edgeone-getip/`) |
|
||||
| IP 查询服务 | Cloudflare Workers(`lemon-getip/`,Hono) |
|
||||
想用最低成本部署一套功能完整的测试站,直接看 [快速入门](/guide/getting-started):交互式一键部署(install.sh)→ systemd 守护。
|
||||
|
||||
## CI/CD
|
||||
|
||||
@@ -65,7 +23,5 @@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o middleware-go-linux-arm64 .
|
||||
| 工作流 | 部署目标 |
|
||||
|--------|----------|
|
||||
| `frontend-ssr.yml` | SSR 前端 → Cloudflare Workers |
|
||||
| `workers.yml` | `lemon-getip/` → Cloudflare Workers |
|
||||
| `edgeone-backend.yml` | `edgeone/` → EdgeOne Pages |
|
||||
| `edgeone-getip.yml` | `edgeone-getip/` → EdgeOne Pages |
|
||||
| `build_and_release.yml` | 后端多平台构建与发布 |
|
||||
| `edgeone-backend.yml` | `serverless/edgeone/` → EdgeOne Pages |
|
||||
| `build_and_release.yml` | 后端多平台构建与发布(Release 附带配置文件) |
|
||||
+2
-2
@@ -8,8 +8,8 @@ hero:
|
||||
tagline: IP 查询 · 网站检测 · SSL · DNS · DNSSEC · Whois · ASN · 截图
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 部署指南
|
||||
link: /guide/
|
||||
text: 快速开始
|
||||
link: /guide/getting-started
|
||||
- theme: alt
|
||||
text: GitHub
|
||||
link: https://github.com/nomdn/ipw-cn
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# 柠檬味IPW
|
||||
|
||||
柠檬味ipw是一个基于Golang的分布式站点测试平台,旨在构建一个去中心化的,共建贡献的测试平台。
|
||||
> [!TIP]
|
||||
> 尝试一下?这里是官方站点:[链接](https://ipw.wsmdn.top)
|
||||
|
||||
## 简介
|
||||
|
||||
Lemon IPW(柠檬味 ipw.cn 替代品)是一个开源的分布式站点测试平台。与中心化测试服务不同,本项目鼓励社区成员部署自己的测试节点,前端多节点轮询、后端多地区部署,组成一张去中心化的测试网络,单一节点故障不影响整体可用性。
|
||||
|
||||
## 功能
|
||||
|
||||
- IP 地址查询(IPv4/IPv6,集成 ip2region、qqwry、MaxMind、GeoCN、DbIP 等多数据源)
|
||||
- ASN 自治系统查询(MaxMind、DbIP,集成 WHOIS 解析)
|
||||
- 网站检测(HTTP 状态码、响应时间)
|
||||
- SSL 证书检查(有效期、颁发机构)
|
||||
- DNS 解析(A/AAAA/CNAME/MX/TXT/NS/SRV/PTR/CAA 等,多节点并发)
|
||||
- DNSSEC 验证(DNSKEY、RRSIG、DS 链式信任)
|
||||
- Whois 查询
|
||||
- TCPing(IPv4/IPv6 双栈)
|
||||
- 网站测速
|
||||
- 网站截图
|
||||
|
||||
## 架构
|
||||
|
||||
前后端分离,可独立部署、自由组合:
|
||||
|
||||
- 前端(Nuxt 4 SSR)负责数据可视化与接口调用
|
||||
- 后端(Go)提供全部检测 API,可在任意地区部署测试节点,是分布式查询的核心
|
||||
- 独立中间件(middleware-go)使用Fiber技术栈 负责请求转发和 Key 注入
|
||||
|
||||
后端支持二进制、Docker 自托管,也支持 AWS Lambda、EdgeOne Makers、Vercel、阿里云函数计算等 Serverless 平台部署。
|
||||
|
||||
## 技术栈
|
||||
|
||||
Go、Gin、Nuxt 4、Vue 3、Element Plus。
|
||||
|
||||
## 下一步
|
||||
|
||||
- 想自己部署一套?前往 [部署指南](/guide/)
|
||||
- 想了解前端如何部署?阅读 [前端部署](/guide/deploy-frontend)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1626616227481" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2469" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M512 64a448 448 0 1 0 0 896A448 448 0 0 0 512 64z m0 820.032A372.032 372.032 0 0 1 512 139.968a372.032 372.032 0 0 1 0 744.064z" fill="#07c160" p-id="2470"></path><path d="M382.208 731.264V320H320v411.264h62.208z m144.576 0V571.136h106.56c98.496 0 148.032-42.048 148.032-126.144C781.376 361.472 732.416 320 634.496 320H464v411.264h62.784z m102.528-213.696H526.784v-144h102.528c30.528 0 52.992 5.76 67.392 17.28 14.4 10.368 21.888 28.8 21.888 54.144 0 25.344-7.488 43.776-21.312 55.296-14.4 11.52-36.864 17.28-67.968 17.28z" fill="#07c160" p-id="2471"></path></svg>
|
||||
|
After Width: | Height: | Size: 941 B |
@@ -10,10 +10,10 @@ const route = useRoute()
|
||||
useHead({
|
||||
title: `ASN查询 | IP自治系统号查询 | ${config.siteName}`,
|
||||
meta: [
|
||||
{ name: 'description', content: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP双数据源对比,同时集成WHOIS解析获取ASN详细信息,包括组织名称、国家、注册日期等,助力网络运维和路由分析' },
|
||||
{ name: 'description', content: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP、IP2Location 多数据源对比,同时集成WHOIS解析获取ASN详细信息,包括组织名称、国家、注册日期等,助力网络运维和路由分析' },
|
||||
{ name: 'keywords', content: 'asn查询,自治系统号,asn lookup,ip asn,asn whois,网络自治系统,运营商asn,ip归属asn' },
|
||||
{ property: 'og:title', content: 'ASN自治系统查询工具 - IP ASN号与组织信息查询' },
|
||||
{ property: 'og:description', content: '多数据源ASN查询,支持Maxmind GEOLite2和DB-IP,集成WHOIS解析获取ASN详细信息' },
|
||||
{ property: 'og:description', content: '多数据源ASN查询,支持Maxmind GEOLite2、DB-IP、IP2Location,集成WHOIS解析获取ASN详细信息' },
|
||||
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
],
|
||||
@@ -24,7 +24,7 @@ useHead({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebApplication',
|
||||
name: 'ASN自治系统查询工具',
|
||||
description: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP双数据源对比,集成WHOIS解析。',
|
||||
description: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP、IP2Location 多数据源对比,集成WHOIS解析。',
|
||||
url: config.siteUrl + 'asn',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
operatingSystem: 'Web',
|
||||
@@ -46,6 +46,7 @@ interface ASNResult {
|
||||
ip: string
|
||||
geolite2_asn?: { asn: string; org: string }
|
||||
dbip_asn?: { asn: string; org: string }
|
||||
ip2location_asn?: { asn: string; as: string }
|
||||
whois?: any
|
||||
}
|
||||
|
||||
@@ -55,7 +56,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 || '')
|
||||
|
||||
@@ -185,6 +186,13 @@ function getStatusClass(status: string): string {
|
||||
<span v-if="result.dbip_asn.org" style="color: #999; font-size: 0.9em;">{{ result.dbip_asn.org }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="result.ip2location_asn?.asn">
|
||||
<td class="table-label">IP2Location ASN</td>
|
||||
<td class="table-value">
|
||||
AS{{ result.ip2location_asn.asn }}
|
||||
<span v-if="result.ip2location_asn.as" style="color: #999; font-size: 0.9em;">{{ result.ip2location_asn.as }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="result.whois?.asNumber">
|
||||
<td class="table-label">ASN</td>
|
||||
<td class="table-value">AS{{ result.whois.asNumber }}</td>
|
||||
|
||||
@@ -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)
|
||||
|
||||
+12
-383
@@ -1,396 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick, watch } from 'vue';
|
||||
import { isIPv6 } from 'is-ip';
|
||||
import {config} from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
|
||||
import { useRoute } from 'vue-router'
|
||||
import { highlightCode } from '../../utils/shiki'
|
||||
import { isIPv4 } from '../../utils/tools';
|
||||
import { onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
|
||||
useHead({
|
||||
title: `IPv6/IPv4地址查询工具 | IP归属地定位 | ${config.siteName}`,
|
||||
meta: [
|
||||
{ name: 'description', content: '专业的IPv6/IPv4地址查询工具,支持IPv4和IPv6地址归属地查询,提供BiliBili Live、GeoCN、IP2Region、Maxmind等多种数据源对比,精确定位IP地理位置、运营商信息,支持中国大陆及境外地址查询,助力IPv6普及与应用' },
|
||||
{ property: 'og:title', content: `IPv6/IPv4地址归属地查询工具 - ${config.siteName}` },
|
||||
{ property: 'og:description', content: '多数据源IP地址归属地查询,支持IPv4和IPv6,提供地理位置、运营商等详细信息' },
|
||||
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
],
|
||||
});
|
||||
interface IPLocationType {
|
||||
bilibili?: any;
|
||||
ip?: string;
|
||||
geocn?: any;
|
||||
ip2region?: any;
|
||||
maxmind_asn?: any;
|
||||
maxmind_city?: any;
|
||||
qqwry?: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
const code = `
|
||||
# 请勿用于商业用途,仅供个人测试学习之用,请遵守中国法律法规
|
||||
# 查询本机外网 IPv4 地址
|
||||
curl ${config.v4OnlyAPI}
|
||||
|
||||
# 查询本机外网 IPv6 地址
|
||||
curl ${config.v6OnlyAPI}
|
||||
|
||||
# 测试网络是 IPv4 还是 IPv6 访问优先
|
||||
# (访问 IPv4/IPv6 双栈站点,如果返回 IPv6 地址,则 IPv6 访问优先)
|
||||
curl ${config.DualStackAPI}
|
||||
`.trim();
|
||||
const html = ref('');
|
||||
const apiList = config.IPLocationAPIs
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]!.id)
|
||||
const ipAddress = ref('');
|
||||
const IPLocation = ref<IPLocationType>({});
|
||||
const UserIP = ref('');
|
||||
const locationUrl = computed(() => "/middleware/" + backendID.value + "/location/" + ipAddress.value);
|
||||
|
||||
const { data: locationData, error: locationError, execute: executeLocation } = useMiddlewareFetch<IPLocationType>(locationUrl, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
|
||||
watch(locationData, (newData) => {
|
||||
if (newData) {
|
||||
IPLocation.value = newData;
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
watch(locationError, (newError) => {
|
||||
if (newError) {
|
||||
console.error('Error fetching IP location:', newError);
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function locateIP(IP: string){
|
||||
ipAddress.value = IP;
|
||||
loading.value = true;
|
||||
nextTick(() => {
|
||||
executeLocation();
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
const urlParam = route.query.ip;
|
||||
await navigateTo({ path: '/location', query: { ip: urlParam } });
|
||||
html.value = await highlightCode(code, 'bash')
|
||||
if (urlParam) {
|
||||
ipAddress.value = urlParam as string;
|
||||
locateIP(urlParam as string);
|
||||
}else{
|
||||
$fetch<string>(config.DualStackAPI).then(
|
||||
function(data) {
|
||||
ipAddress.value = data;
|
||||
|
||||
UserIP.value = data;
|
||||
}
|
||||
).catch(
|
||||
function(error) {
|
||||
console.error('Error fetching IP address:', error);
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="title">
|
||||
<header>
|
||||
<h1>IPv6/IPv4地址归属地查询</h1>
|
||||
<p>极简的IPv6地址查询工具,致力于普及 IPv6</p>
|
||||
</header>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="ipAddress"
|
||||
placeholder="请输入IP地址"
|
||||
/>
|
||||
<el-button
|
||||
@click="locateIP(ipAddress)"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
|
||||
>
|
||||
查询
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="location">
|
||||
<div class="one-line" style="height: 40px;">
|
||||
<b>IP</b> <p>{{ ipAddress }}</p>
|
||||
</div>
|
||||
<div v-if="IPLocation" class="result-section">
|
||||
<table class="result-table">
|
||||
<tbody>
|
||||
<tr v-if="IPLocation.bilibili && (IPLocation.bilibili.administrative_area || IPLocation.bilibili.city)">
|
||||
<td class="table-label">bilibili Live接口</td>
|
||||
<td class="table-value">
|
||||
<span>
|
||||
{{ IPLocation.bilibili?.country }} {{ IPLocation.bilibili?.administrative_area }} {{ IPLocation.bilibili?.city }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="table-value">
|
||||
<span>
|
||||
{{ IPLocation.bilibili?.isp }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.ip2region && IPLocation.ip2region.split('|').length >= 4">
|
||||
<td class="table-label">IP2Region</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.split("|")[0] }} {{ IPLocation.ip2region?.split("|")[1] }} {{ IPLocation.ip2region?.split("|")[2] }}</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.split("|")[3] }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.geocn && (IPLocation.geocn.administrative_area || IPLocation.geocn.city || IPLocation.geocn.district)">
|
||||
<td class="table-label">GeoCN(仅中国大陆)</td>
|
||||
<td class="table-value">{{ IPLocation.geocn?.administrative_area }} {{ IPLocation.geocn?.city }} {{ IPLocation.geocn?.district }}</td>
|
||||
<td class="table-value">{{ IPLocation.geocn?.isp }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.maxmind_city && IPLocation.maxmind_asn && (IPLocation.maxmind_city.country || IPLocation.maxmind_city.city)">
|
||||
<td class="table-label">Maxmind GEOLite2 City</td>
|
||||
<td class="table-value">{{ IPLocation.maxmind_city?.country }} {{ IPLocation.maxmind_city?.administrative_area }} {{ IPLocation.maxmind_city?.city }}</td>
|
||||
<td class="table-value">{{ IPLocation.maxmind_asn?.org }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.qqwry && (IPLocation.qqwry.country || IPLocation.qqwry.administrative_area || IPLocation.qqwry.city)">
|
||||
<td class="table-label">纯真社区库</td>
|
||||
<td class="table-value">{{ IPLocation.qqwry?.country }} {{ IPLocation.qqwry?.administrative_area }} {{ IPLocation.qqwry?.city }}</td>
|
||||
<td class="table-value">{{ IPLocation.qqwry?.isp }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.dbip_city && (IPLocation.dbip_city.administrative_area || IPLocation.dbip_city.city)">
|
||||
<td class="table-label">DB-IP City</td>
|
||||
<td class="table-value">{{ IPLocation.dbip_city?.country }} {{ IPLocation.dbip_city?.administrative_area }} {{ IPLocation.dbip_city?.city }}</td>
|
||||
<td class="table-value">{{IPLocation.dbip_asn?.org || '--'}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 1.5em;">
|
||||
<h3 v-if="isIPv6(UserIP)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h3>
|
||||
<h3 v-else-if="isIPv4(UserIP)"><el-icon><CircleCloseFilled style="color: red;"/></el-icon>您的网络IPv4优先</h3>
|
||||
</div>
|
||||
<blockquote>
|
||||
精度参照表:<br>
|
||||
中国大陆:BiliBili Live > GeoCN > IP2Region > 纯真社区库 > Maxmind GEOLite2 City ≈ DB-IP<br>
|
||||
中国大陆 IPv6 地址: BiliBili Live > GeoCN > IP2Region > Maxmind GEOLite2 City ≈ DB-IP > 纯真社区库<br>
|
||||
境外及港澳台地址: Maxmind GEOLite2 City ≈ DB-IP > BiliBili Live > > IP2Region > 纯真社区库<br>
|
||||
|
||||
手机默认开启 IPv6,宽带开启 IPv6 请自行搜索<br>
|
||||
访客IP: {{UserIP}},<p v-if="isIPv4(UserIP)">您的网络IPv4优先</p><p v-else-if="isIPv6(UserIP)">您的网络IPv6优先</p>
|
||||
</blockquote>
|
||||
|
||||
<div v-html="html"></div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="redirecting">正在跳转到 IP 归属地查询…</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../style.css";
|
||||
@import "../../assets/css/tool-common.css";
|
||||
</style>
|
||||
<style>
|
||||
@import "../style.css";
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.el-menu--horizontal > .el-menu-item:nth-child(1) {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
width: 420px;
|
||||
height: 50px;
|
||||
font: 1.3em sans-serif;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
width: 165px;
|
||||
height: 50px;
|
||||
font: 1.3em sans-serif;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.result-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
html.dark .result-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #2e2d2d;
|
||||
border: 1px solid #2e2e2e;
|
||||
}
|
||||
.result-table thead tr {
|
||||
background-color: #c0c4cc;
|
||||
}
|
||||
html.dark .result-table thead tr {
|
||||
background: #2e2d2d;
|
||||
}
|
||||
|
||||
.result-table .table-header {
|
||||
padding: 12px 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
text-align: left;
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
html.dark .result-table .table-header {
|
||||
padding: 12px 15px;
|
||||
font-weight: 600;
|
||||
color: #cfcfcf;
|
||||
text-align: left;
|
||||
border: 1px solid #1a1919;
|
||||
}
|
||||
|
||||
.result-table tbody tr {
|
||||
border-bottom: 1px solid #dcdfe6;
|
||||
}
|
||||
html.dark .result-table tbody tr {
|
||||
border-bottom: 1px solid #1a1919;
|
||||
}
|
||||
|
||||
|
||||
.result-table tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.result-table tbody tr:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
html.dark .result-table tbody tr:hover {
|
||||
background-color: #393a3a;
|
||||
}
|
||||
|
||||
.result-table .table-label {
|
||||
padding: 12px 15px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
width: 150px;
|
||||
text-align: left;
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
html.dark .result-table .table-label {
|
||||
color: #c0c4cc;
|
||||
border: 1px solid #1a1919;
|
||||
}
|
||||
|
||||
.result-table .table-value {
|
||||
padding: 12px 15px;
|
||||
color: #303133;
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
html.dark .result-table .table-value {
|
||||
color: #cfcfcf;
|
||||
border: 1px solid #1a1919;
|
||||
}
|
||||
.valid {
|
||||
color: #67C23A;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.expired {
|
||||
color: #F56C6C;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-code {
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
color: #67C23A;
|
||||
background: #f0f9eb;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
color: #E6A23C;
|
||||
background: #fdf6ec;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #F56C6C;
|
||||
background: #fef0f0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #fef0f0;
|
||||
color: #F56C6C;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
pre code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 0.9em;
|
||||
color: #303133;
|
||||
}
|
||||
html.dark pre {
|
||||
background: #303133;
|
||||
}
|
||||
|
||||
html.dark pre code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.badge-section {
|
||||
.redirecting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 50vh;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.badge-item {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.badge-item h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #3EAF7C;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.badge-item img {
|
||||
display: block;
|
||||
margin-bottom: 15px;
|
||||
max-width: 200px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -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')
|
||||
@@ -275,6 +275,12 @@ onMounted(() => {
|
||||
<h2>...</h2>
|
||||
<el-image src="/jingya.jpg"></el-image>
|
||||
</div>
|
||||
<div v-else-if="result && result.ipv4 && result.ipv6 && !result.ipv4.is_reachable && result.ipv6.is_reachable">
|
||||
<h3>结论:<CircleCloseFilled style="width: 1.3em;color: red;"/>网站{{ extractHost(testDomain) }} 不支持IPv4访问 </h3>
|
||||
<h2>全球来看,越南、捷克等部分国家已提出了单栈发展目标。尽管我国网络IPv6流量占比大幅提升,但网络中仍存在大量的NAT(网络地址转换)设备和IPv4依赖应用。</h2>
|
||||
<h2 style="text-align: right;">----中国《IPv6发展报告》2026</h2>
|
||||
<el-image src="/jingya.jpg"></el-image>
|
||||
</div>
|
||||
<blockquote>
|
||||
网站不支持 IPv6可能原因:<br/>
|
||||
<br/>
|
||||
|
||||
@@ -49,6 +49,8 @@ interface IPLocationType {
|
||||
ip?: string;
|
||||
geocn?: any;
|
||||
ip2region?: any;
|
||||
ip2location?: any;
|
||||
ip2location_asn?: any;
|
||||
maxmind_asn?: any;
|
||||
maxmind_city?: any;
|
||||
qqwry?: any;
|
||||
@@ -67,7 +69,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 +184,15 @@ onMounted(async () => {
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.ip2region && IPLocation.ip2region.split('|').length >= 4">
|
||||
<tr v-if="IPLocation.ip2region && typeof IPLocation.ip2region === 'object' && (IPLocation.ip2region.administrative_area || IPLocation.ip2region.city)">
|
||||
<td class="table-label">IP2Region</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.split("|")[0] }} {{ IPLocation.ip2region?.split("|")[1] }} {{ IPLocation.ip2region?.split("|")[2] }}</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.split("|")[3] }}</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.country }} {{ IPLocation.ip2region?.administrative_area }} {{ IPLocation.ip2region?.city }}</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.isp }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.ip2location && typeof IPLocation.ip2location === 'object' && (IPLocation.ip2location.administrative_area || IPLocation.ip2location.city)">
|
||||
<td class="table-label">IP2Location</td>
|
||||
<td class="table-value">{{ IPLocation.ip2location?.country }} {{ IPLocation.ip2location?.administrative_area }} {{ IPLocation.ip2location?.city }}</td>
|
||||
<td class="table-value">{{ IPLocation.ip2location_asn?.as }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.geocn && (IPLocation.geocn.administrative_area || IPLocation.geocn.city || IPLocation.geocn.district)">
|
||||
<td class="table-label">GeoCN(仅中国大陆)</td>
|
||||
|
||||
@@ -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)
|
||||
|
||||
+67
-125
@@ -10,6 +10,8 @@ const config = {
|
||||
// true(默认):/middleware/* 候选为「外部节点… + 内置中间件(最后一位兜底)」;
|
||||
// false:仅使用外部节点,候选列表不含内置中间件。
|
||||
EnableInternalMiddleware: true,
|
||||
// 内置中间件单 IP 限流次数(次/分钟);0 表示不限流。默认 120。
|
||||
rateLimitPerMinute: 120,
|
||||
// 外部独立中间件服务列表(base URL)。
|
||||
// 与前端自带中间件(server/routes/middleware/[...slug].get.ts 本地转发)同级,
|
||||
// 前端 /middleware/* 请求会依次尝试这些节点(出错重试下一个),
|
||||
@@ -17,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 统计
|
||||
@@ -33,23 +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/"
|
||||
}
|
||||
],
|
||||
IPLocationAPIs: [
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
id: "cn-jiangsu",
|
||||
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
label: "中国 湖北 武汉 电信",
|
||||
id: "cn-wuhan-chinatelecom",
|
||||
url: ""
|
||||
},
|
||||
{
|
||||
label: "中国 四川 沙渠 电信[ZFC]",
|
||||
@@ -57,158 +54,103 @@ 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/"
|
||||
},
|
||||
{
|
||||
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: ""
|
||||
},
|
||||
{
|
||||
label: "湖北 十堰 电信",
|
||||
id: "shiyan",
|
||||
url: ""
|
||||
},
|
||||
{
|
||||
label: "陕西 西安二 电信",
|
||||
id: "xian2",
|
||||
url: ""
|
||||
},
|
||||
{
|
||||
label: "香港 Cogent",
|
||||
id: "hongkong",
|
||||
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/",
|
||||
}
|
||||
]
|
||||
},
|
||||
SpeedTest:{
|
||||
DualStack: [
|
||||
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
id: "cn-jiangsu",
|
||||
url :"https://cn-jiangsu.api-ipw.wsmdn.top/",
|
||||
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
|
||||
id: "cn-shenzhen",
|
||||
url :"https://cn-shenzhen.api-ipw.wsmdn.top/",
|
||||
},
|
||||
],
|
||||
IPv4: [
|
||||
{
|
||||
label: "中国 广东 广州 腾讯云",
|
||||
id: "cn-guangzhou",
|
||||
url :"https://cn-guangzhou.api-ipw.wsmdn.top/",
|
||||
url: "https://cn-shenzhen.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "新加坡 腾讯云",
|
||||
id: "sg-1",
|
||||
url :"https://sg-1.api-ipw.wsmdn.top/",
|
||||
label: "中国 湖北 武汉 电信",
|
||||
id: "cn-wuhan-chinatelecom",
|
||||
url: ""
|
||||
},
|
||||
// IP 直连的节点:URL 留空,仅经独立中间件转发
|
||||
{
|
||||
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/",
|
||||
}
|
||||
],
|
||||
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: "上海 腾讯云 BGP",
|
||||
id: "tencent-sh",
|
||||
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/",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
export { config }
|
||||
@@ -0,0 +1,149 @@
|
||||
import { defineEventHandler, createError, setResponseStatus, setResponseHeader, getRequestHeader, getRequestIP } from 'h3'
|
||||
import { config } from '../../../config/index'
|
||||
import { useMiddlewareFetch } from '../../../utils/middleware'
|
||||
|
||||
// GET /ip/<ip> → text/plain 输出指定 IP 的归属地(支持 IPv4 / IPv6,如 /ip/2001:db8::1)
|
||||
//
|
||||
// IP: 2001:db8::1
|
||||
// bilibili: 中国 江苏 南京 电信
|
||||
// geocn: 江苏省 南京市 江宁区 电信 宽带
|
||||
// ip2region: 中国 江苏 南京 电信
|
||||
// maxmind_city: 中国 江苏 南京
|
||||
// maxmind_asn: AS4134 中国电信
|
||||
// qqwry: 中国 江苏省 南京市 电信
|
||||
//
|
||||
// 复用 utils/middleware.ts 的 useMiddlewareFetch():候选依次为 config.Middleware 外部中间层 + 前端自带中间件兜底,
|
||||
// 仅当节点无法连接或上游 401/403/418/502 时切换;IPLocationAPI 配置了多个后端时逐个重试。
|
||||
|
||||
// ---- 简单限流(每 IP 每分钟次数上限,进程内内存计数;limit 0 表示不限流) ----
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
function checkRateLimit(ip: string, limit: number): boolean {
|
||||
const now = Date.now()
|
||||
const entry = rateLimitMap.get(ip)
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 })
|
||||
// 顺带清理过期条目,避免 Map 无限增长
|
||||
if (rateLimitMap.size > 1000) {
|
||||
for (const [k, v] of rateLimitMap) {
|
||||
if (v.resetAt <= now) rateLimitMap.delete(k)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
entry.count++
|
||||
return entry.count <= limit
|
||||
}
|
||||
|
||||
// ---- 文本美化:把单个数据源的归属地对象拼成可读字符串 ----
|
||||
function objectToText(obj: any): string {
|
||||
const parts: string[] = []
|
||||
// 位置字段(按国家 → 省/区域 → 城市 → 区县)
|
||||
for (const k of ['country', 'administrative_area', 'region', 'city', 'district', 'province']) {
|
||||
const v = obj[k]
|
||||
if (typeof v === 'string' && v) parts.push(v)
|
||||
}
|
||||
// ASN 编号(如 "AS4134")
|
||||
if (typeof obj.asn === 'string' && obj.asn) {
|
||||
parts.push(obj.asn.startsWith('AS') ? obj.asn : `AS${obj.asn}`)
|
||||
}
|
||||
// 运营商 / 组织 / 类型
|
||||
for (const k of ['isp', 'org', 'type']) {
|
||||
const v = obj[k]
|
||||
if (typeof v === 'string' && v) parts.push(v)
|
||||
}
|
||||
return [...new Set(parts)].join(' ')
|
||||
}
|
||||
|
||||
// ---- 文本美化:多数据源逐行输出(跳过后端未加载的库) ----
|
||||
function formatLocationText(data: any, ip: string): string {
|
||||
const lines: string[] = [`IP: ${ip}`]
|
||||
const sources: Array<[string, any]> = [
|
||||
['bilibili', data?.bilibili],
|
||||
['geocn', data?.geocn],
|
||||
['ip2region', data?.ip2region],
|
||||
['ip2location', data?.ip2location],
|
||||
['maxmind_city', data?.maxmind_city],
|
||||
['maxmind_asn', data?.maxmind_asn],
|
||||
['dbip_city', data?.dbip_city],
|
||||
['dbip_asn', data?.dbip_asn],
|
||||
['qqwry', data?.qqwry],
|
||||
]
|
||||
for (const [name, value] of sources) {
|
||||
if (value === undefined || value === null) continue
|
||||
if (typeof value === 'string') {
|
||||
// 纯字符串结果(error 等)原样输出,"not loaded" 跳过
|
||||
if (value && value !== 'not loaded') {
|
||||
lines.push(`${name}: ${value}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const text = objectToText(value)
|
||||
if (text) lines.push(`${name}: ${text}`)
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ---- 核心:查询指定 IP 的归属地(IPLocationAPI 多后端逐个重试) ----
|
||||
async function locate(event: any, ip: string) {
|
||||
const backends = config.IPLocationAPI
|
||||
if (!backends || backends.length === 0) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'No location backend configured' })
|
||||
}
|
||||
let lastError: any = null
|
||||
for (const backend of backends) {
|
||||
const path = `/middleware/${backend.id}/location/${ip}`
|
||||
const { data, error, execute } = useMiddlewareFetch(path)
|
||||
await execute()
|
||||
if (error.value) {
|
||||
lastError = error.value
|
||||
console.error(`[ip] backend "${backend.label}" failed for ${ip}:`, error.value?.message || error.value)
|
||||
continue
|
||||
}
|
||||
// 成功:location 响应必含 ip 字段;缺失视为失败(如误返回错误体),换下一个后端
|
||||
const res = data.value
|
||||
if (res && typeof res === 'object' && (res as any).ip) {
|
||||
return formatLocationText(res, ip)
|
||||
}
|
||||
lastError = new Error('invalid location response')
|
||||
}
|
||||
|
||||
// 全部后端失败:透传最后一次错误(text/plain)
|
||||
const errStatus = lastError?.status ?? lastError?.statusCode ?? 502
|
||||
const status = typeof errStatus === 'number' ? errStatus : 502
|
||||
setResponseStatus(event, status)
|
||||
return `Error ${status}: ${lastError?.message || 'Backend unreachable'}`
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 0) 统一输出 text/plain(不是 JSON)
|
||||
setResponseHeader(event, 'content-type', 'text/plain; charset=utf-8')
|
||||
|
||||
// 1) Origin / Referer 校验:跨域浏览器调用一律拒绝
|
||||
const siteOrigin = new URL(config.siteUrl).origin
|
||||
const origin = getRequestHeader(event, 'origin')
|
||||
const referer = getRequestHeader(event, 'referer')
|
||||
if (origin && origin !== siteOrigin) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: cross-origin request' })
|
||||
}
|
||||
if (referer && referer !== siteOrigin && !referer.startsWith(siteOrigin + '/')) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: invalid referer' })
|
||||
}
|
||||
|
||||
// 2) 每 IP 每分钟限流(次数取 config.rateLimitPerMinute,0 表示不限流)
|
||||
const rateLimit = config.rateLimitPerMinute || 0
|
||||
if (rateLimit > 0 && !checkRateLimit(getRequestIP(event) || 'unknown', rateLimit)) {
|
||||
throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
|
||||
}
|
||||
|
||||
// 3) 取路径参数 IP
|
||||
const ipParam: string = event.context.params?.ip as any
|
||||
let ip = ipParam ? ipParam.trim() : ''
|
||||
if (!ip) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing IP parameter' })
|
||||
}
|
||||
|
||||
return locate(event, ip)
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { defineEventHandler, getQuery, createError, setResponseStatus, setResponseHeader, getRequestHeader, getRequestIP } from 'h3'
|
||||
import { config } from '../../../config/index'
|
||||
import { useMiddlewareFetch } from '../../../utils/middleware'
|
||||
|
||||
// GET /ip → text/plain 输出 IP 归属地(?ip=<ip> 优先,否则查请求方 IP)
|
||||
//
|
||||
// IP: 1.2.3.4
|
||||
// bilibili: 中国 江苏 南京 电信
|
||||
// geocn: 江苏省 南京市 江宁区 电信 宽带
|
||||
// ip2region: 中国 江苏 南京 电信
|
||||
// maxmind_city: 中国 江苏 南京
|
||||
// maxmind_asn: AS4134 中国电信
|
||||
// qqwry: 中国 江苏省 南京市 电信
|
||||
//
|
||||
// 复用 utils/middleware.ts 的 useMiddlewareFetch():候选依次为 config.Middleware 外部中间层 + 前端自带中间件兜底,
|
||||
// 仅当节点无法连接或上游 401/403/418/502 时切换;IPLocationAPI 配置了多个后端时逐个重试。
|
||||
|
||||
// ---- 简单限流(每 IP 每分钟次数上限,进程内内存计数;limit 0 表示不限流) ----
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
function checkRateLimit(ip: string, limit: number): boolean {
|
||||
const now = Date.now()
|
||||
const entry = rateLimitMap.get(ip)
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 })
|
||||
// 顺带清理过期条目,避免 Map 无限增长
|
||||
if (rateLimitMap.size > 1000) {
|
||||
for (const [k, v] of rateLimitMap) {
|
||||
if (v.resetAt <= now) rateLimitMap.delete(k)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
entry.count++
|
||||
return entry.count <= limit
|
||||
}
|
||||
|
||||
// ---- 文本美化:把单个数据源的归属地对象拼成可读字符串 ----
|
||||
function objectToText(obj: any): string {
|
||||
const parts: string[] = []
|
||||
// 位置字段(按国家 → 省/区域 → 城市 → 区县)
|
||||
for (const k of ['country', 'administrative_area', 'region', 'city', 'district', 'province']) {
|
||||
const v = obj[k]
|
||||
if (typeof v === 'string' && v) parts.push(v)
|
||||
}
|
||||
// ASN 编号(如 "AS4134")
|
||||
if (typeof obj.asn === 'string' && obj.asn) {
|
||||
parts.push(obj.asn.startsWith('AS') ? obj.asn : `AS${obj.asn}`)
|
||||
}
|
||||
// 运营商 / 组织 / 类型
|
||||
for (const k of ['isp', 'org', 'type']) {
|
||||
const v = obj[k]
|
||||
if (typeof v === 'string' && v) parts.push(v)
|
||||
}
|
||||
return [...new Set(parts)].join(' ')
|
||||
}
|
||||
|
||||
// ---- 文本美化:多数据源逐行输出(跳过后端未加载的库) ----
|
||||
function formatLocationText(data: any, ip: string): string {
|
||||
const lines: string[] = [`IP: ${ip}`]
|
||||
const sources: Array<[string, any]> = [
|
||||
['bilibili', data?.bilibili],
|
||||
['geocn', data?.geocn],
|
||||
['ip2region', data?.ip2region],
|
||||
['ip2location', data?.ip2location],
|
||||
['maxmind_city', data?.maxmind_city],
|
||||
['maxmind_asn', data?.maxmind_asn],
|
||||
['dbip_city', data?.dbip_city],
|
||||
['dbip_asn', data?.dbip_asn],
|
||||
['qqwry', data?.qqwry],
|
||||
]
|
||||
for (const [name, value] of sources) {
|
||||
if (value === undefined || value === null) continue
|
||||
if (typeof value === 'string') {
|
||||
// 纯字符串结果(error 等)原样输出,"not loaded" 跳过
|
||||
if (value && value !== 'not loaded') {
|
||||
lines.push(`${name}: ${value}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const text = objectToText(value)
|
||||
if (text) lines.push(`${name}: ${text}`)
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ---- 核心:查询指定 IP 的归属地(IPLocationAPI 多后端逐个重试) ----
|
||||
async function locate(event: any, ip: string) {
|
||||
const backends = config.IPLocationAPI
|
||||
if (!backends || backends.length === 0) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'No location backend configured' })
|
||||
}
|
||||
let lastError: any = null
|
||||
for (const backend of backends) {
|
||||
const path = `/middleware/${backend.id}/location/${ip}`
|
||||
const { data, error, execute } = useMiddlewareFetch(path)
|
||||
await execute()
|
||||
if (error.value) {
|
||||
lastError = error.value
|
||||
console.error(`[ip] backend "${backend.label}" failed for ${ip}:`, error.value?.message || error.value)
|
||||
continue
|
||||
}
|
||||
// 成功:location 响应必含 ip 字段;缺失视为失败(如误返回错误体),换下一个后端
|
||||
const res = data.value
|
||||
if (res && typeof res === 'object' && (res as any).ip) {
|
||||
return formatLocationText(res, ip)
|
||||
}
|
||||
lastError = new Error('invalid location response')
|
||||
}
|
||||
|
||||
// 全部后端失败:透传最后一次错误(text/plain)
|
||||
const errStatus = lastError?.status ?? lastError?.statusCode ?? 502
|
||||
const status = typeof errStatus === 'number' ? errStatus : 502
|
||||
setResponseStatus(event, status)
|
||||
return `Error ${status}: ${lastError?.message || 'Backend unreachable'}`
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 0) 统一输出 text/plain(不是 JSON)
|
||||
setResponseHeader(event, 'content-type', 'text/plain; charset=utf-8')
|
||||
|
||||
// 1) Origin / Referer 校验:跨域浏览器调用一律拒绝
|
||||
const siteOrigin = new URL(config.siteUrl).origin
|
||||
const origin = getRequestHeader(event, 'origin')
|
||||
const referer = getRequestHeader(event, 'referer')
|
||||
if (origin && origin !== siteOrigin) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: cross-origin request' })
|
||||
}
|
||||
if (referer && referer !== siteOrigin && !referer.startsWith(siteOrigin + '/')) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: invalid referer' })
|
||||
}
|
||||
|
||||
// 2) 每 IP 每分钟限流(次数取 config.rateLimitPerMinute,0 表示不限流)
|
||||
const rateLimit = config.rateLimitPerMinute || 0
|
||||
if (rateLimit > 0 && !checkRateLimit(getRequestIP(event) || 'unknown', rateLimit)) {
|
||||
throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
|
||||
}
|
||||
|
||||
// 3) 确定要查询的 IP:?ip=<ip> 优先,否则查请求方 IP
|
||||
const query = getQuery(event)
|
||||
let ip = typeof query.ip === 'string' ? query.ip.trim() : ''
|
||||
if (!ip) {
|
||||
ip = getRequestIP(event, { xForwardedFor: true }) || ''
|
||||
}
|
||||
ip = ip.trim()
|
||||
if (!ip) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Unable to determine IP address' })
|
||||
}
|
||||
|
||||
return locate(event, ip)
|
||||
})
|
||||
@@ -1,6 +1,44 @@
|
||||
import { defineEventHandler, getQuery, createError, setResponseStatus } from 'h3'
|
||||
import { defineEventHandler, getQuery, createError, setResponseStatus, getRequestHeader, getRequestIP } from 'h3'
|
||||
import { config } from '../../../config/index'
|
||||
|
||||
// ---- 方案 C:简单限流(每 IP 每分钟次数上限,进程内内存计数;次数取 config.rateLimitPerMinute,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
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 1) Origin / Referer 校验:跨域浏览器调用一律拒绝。
|
||||
// 注意:无 Origin/Referer 的服务端 SSR 调用与命令行请求无法区分,仅靠下方限流约束。
|
||||
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' })
|
||||
}
|
||||
|
||||
const slugString: string = event.context.params?.slug as any
|
||||
if (!slugString) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing slug parameter' })
|
||||
@@ -47,9 +85,8 @@ export default defineEventHandler(async (event) => {
|
||||
} catch (e) {
|
||||
console.debug('[middleware debug] JSON parse FAILED:', e)
|
||||
}
|
||||
const authHeaders: Record<string, string> = {
|
||||
'Origin': config.siteUrl.replace(/\/$/, ''),
|
||||
}
|
||||
// 转发头:不透传 Origin,避免上游 CORS 误判;仅按需注入 Authorization
|
||||
const authHeaders: Record<string, string> = {}
|
||||
if (apiKey) {
|
||||
authHeaders['Authorization'] = `Bearer ${apiKey}`
|
||||
console.debug('[middleware debug] authHeaders SET Authorization:', `Bearer ${apiKey.slice(0, 1)}...`)
|
||||
@@ -61,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' })
|
||||
@@ -114,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' })
|
||||
|
||||
@@ -28,8 +28,8 @@ function isConnectionError(e: any): boolean {
|
||||
}
|
||||
|
||||
// 需要切换节点重试的上游状态码:
|
||||
// 401 未授权、403 被 CDN/WAF 风控拦截、418 被反爬/限流标记 —— 换一个节点往往就能绕过
|
||||
const RETRY_STATUS_CODES = new Set([401, 403, 418])
|
||||
// 401 未授权、403 被 CDN/WAF 风控拦截、418 被反爬/限流标记、502 中间件/上游不可用 —— 换一个节点往往就能恢复
|
||||
const RETRY_STATUS_CODES = new Set([401, 403, 418, 502])
|
||||
|
||||
// 过滤掉 useFetch 专属选项(对 $fetch 无意义),其余(method/query/params/headers/body/timeout 等)原样透传
|
||||
function toFetchOptions(options?: any) {
|
||||
@@ -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 "========================================"
|
||||
+16
-1
@@ -2,19 +2,34 @@ module github.com/nomdn/ipw-cn/middleware-go
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/gofiber/fiber/v3 v3.4.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||
github.com/gofiber/fiber/v3 v3.4.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/gofiber/schema v1.8.0 // indirect
|
||||
github.com/gofiber/utils/v2 v2.1.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.19.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spf13/viper v1.21.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.72.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gofiber/fiber/v3 v3.4.0 h1:F0aND4vwZF7dR7cbvSwFQQEpBU902XHKWxrLsFBkVqw=
|
||||
github.com/gofiber/fiber/v3 v3.4.0/go.mod h1:nAhJfdxUIJJph2tPWPmqWf8QDIN2iiqQiQf3lENZpdk=
|
||||
github.com/gofiber/schema v1.8.0 h1:NGsC9toPHmj8Xg4KpznuXBzNmHG6V5YV0tXKpKMcmis=
|
||||
@@ -14,14 +24,42 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/shamaton/msgpack/v3 v3.1.2 h1:d5gWAIyMU4M0WgDjz6IFSCuXJUA2dFwRHBpDclE8CLw=
|
||||
github.com/shamaton/msgpack/v3 v3.1.2/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M=
|
||||
github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
@@ -30,3 +68,6 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
+346
-90
@@ -12,37 +12,66 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/cors"
|
||||
"github.com/gofiber/fiber/v3/middleware/limiter"
|
||||
)
|
||||
|
||||
// ==================== 全局变量 ====================
|
||||
|
||||
// apiInfo 对应 setting.json apiBaseUrls 等数组中的 { label, id, url } 配置项
|
||||
// apiInfo 对应 setting.json 节点池中的 { label, id, url } 配置项
|
||||
type apiInfo struct {
|
||||
Label string `json:"label"`
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
WS wsFlag `json:"ws"` // 是否经 WS 通道通信(缺省 false = HTTP);支持 "ws": true 或 "ws": "true"
|
||||
}
|
||||
|
||||
// stackConfig 对应 setting.json TCPing / SpeedTest
|
||||
// wsFlag 兼容 JSON 布尔与字符串两种写法
|
||||
type wsFlag bool
|
||||
|
||||
func (w *wsFlag) UnmarshalJSON(b []byte) error {
|
||||
s := strings.TrimSpace(string(b))
|
||||
s = strings.Trim(s, `"`)
|
||||
switch s {
|
||||
case "true", "1", "yes":
|
||||
*w = true
|
||||
case "false", "0", "", "no":
|
||||
*w = false
|
||||
default:
|
||||
return fmt.Errorf("invalid ws flag %q", s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UseWS 判断该节点是否走 WS 通道
|
||||
func (a *apiInfo) UseWS() bool { return a != nil && bool(a.WS) }
|
||||
|
||||
// stackConfig 对应节点池中的 { DualStack, IPv4, IPv6 } 三栈
|
||||
type stackConfig struct {
|
||||
DualStack []apiInfo `json:"DualStack"`
|
||||
IPv4 []apiInfo `json:"IPv4"`
|
||||
IPv6 []apiInfo `json:"IPv6"`
|
||||
}
|
||||
|
||||
// middlewareConfig 仅用于解析 setting.json(键名与前端 config/index.ts 保持一致)
|
||||
// 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:"httpTimeoutSeconds"`
|
||||
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)
|
||||
@@ -57,19 +86,23 @@ 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_IGNORE_CONFIG []string // 不被远端覆盖的配置项列表(remote-ignore-config / REMOTE_IGNORE_CONFIG)
|
||||
)
|
||||
|
||||
// HTTP_CLIENT 用于转发上游请求(等价于 TS 中的 $fetch),超时由 HTTP_TIMEOUT 决定
|
||||
// HTTP_CLIENT forwards requests to upstream backends (equivalent to $fetch in the TS version).
|
||||
var HTTP_CLIENT = &http.Client{}
|
||||
|
||||
// wsSrv 为全局 WS 服务端(WS_PORT>0 时启动;nil = 未启用)。见 ws.go
|
||||
var wsSrv *wsServer
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
// envJSON 从环境变量读取 JSON 字符串并反序列化到 target(用于数组/对象等映射表类配置项)。
|
||||
@@ -130,6 +163,30 @@ func findURL(list []apiInfo, id string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// findNode 按 id 在节点列表中查找(返回指针;找不到返回 nil)
|
||||
func findNode(list []apiInfo, id string) *apiInfo {
|
||||
for i := range list {
|
||||
if list[i].ID == id {
|
||||
return &list[i]
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -180,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 ""
|
||||
}
|
||||
@@ -198,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
|
||||
@@ -244,34 +321,40 @@ func middlewareHandler(c fiber.Ctx) error {
|
||||
|
||||
query := c.Queries()
|
||||
|
||||
// 从 API_KEYS 查找 token,未配置时回退到环境变量 APIKEYS (JSON 字符串)
|
||||
// 从 API_KEYS 查找 token,未配置时回退到环境变量 API_KEYS (JSON 字符串)
|
||||
apiKey := lookupAPIKey(backendID)
|
||||
|
||||
// 构造转发给上游的请求头:所有接口统一透传客户端请求的 Origin,没有 Origin 头则不传
|
||||
// 构造转发给上游的请求头:不透传客户端 Origin,避免上游 CORS 误判
|
||||
authHeaders := map[string]string{}
|
||||
if origin := c.Get("Origin"); origin != "" {
|
||||
authHeaders["Origin"] = origin
|
||||
}
|
||||
if apiKey != "" {
|
||||
authHeaders["Authorization"] = "Bearer " + apiKey
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
apiBaseUrl := findURL(apiBaseUrls, backendID)
|
||||
if apiBaseUrl == "" {
|
||||
node := findNode(apiBaseUrls, backendID)
|
||||
if node == nil || (!node.UseWS() && node.URL == "") {
|
||||
return badRequest(c, "Invalid backend ID")
|
||||
}
|
||||
// ws:true 节点:拨测请求经 WS 通道转发(数据上传走 WS),失败返回 502
|
||||
if node.UseWS() && wsSrv != nil {
|
||||
status, body, err := wsSrv.RequestProbe(backendID, apiType, raw, nil, wsProbeTimeout())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadGateway).SendString("WS probe failed: " + err.Error())
|
||||
}
|
||||
// 节点上报的 body 为 JSON,显式指定类型(fiber 默认 text/plain)
|
||||
return c.Status(status).Type("json").Send(body)
|
||||
}
|
||||
apiBaseUrl := node.URL
|
||||
if !strings.HasSuffix(apiBaseUrl, "/") {
|
||||
apiBaseUrl += "/"
|
||||
}
|
||||
@@ -280,37 +363,37 @@ 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...)
|
||||
|
||||
apiBaseUrl := findURL(apiBaseUrls, backendID)
|
||||
if apiBaseUrl == "" {
|
||||
return badRequest(c, "Invalid backend ID")
|
||||
}
|
||||
if !strings.HasSuffix(apiBaseUrl, "/") {
|
||||
apiBaseUrl += "/"
|
||||
}
|
||||
// tcping/udping/speed 统一走 APIBaseURL 节点池
|
||||
apiBaseUrls := flattenStack(API_BASE_URLS)
|
||||
|
||||
// 转发 query 参数(过滤空值,等价于 TS 中 URLSearchParams 过滤 undefined)
|
||||
queryString := url.Values{}
|
||||
queryMap := make(map[string]string)
|
||||
for k, v := range query {
|
||||
if v != "" {
|
||||
queryString.Set(k, v)
|
||||
queryMap[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
node := findNode(apiBaseUrls, backendID)
|
||||
if node == nil || (!node.UseWS() && node.URL == "") {
|
||||
return badRequest(c, "Invalid backend ID")
|
||||
}
|
||||
// ws:true 节点:拨测请求经 WS 通道转发
|
||||
if node.UseWS() && wsSrv != nil {
|
||||
status, body, err := wsSrv.RequestProbe(backendID, apiType, raw, queryMap, wsProbeTimeout())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadGateway).SendString("WS probe failed: " + err.Error())
|
||||
}
|
||||
// 节点上报的 body 为 JSON,显式指定类型(fiber 默认 text/plain)
|
||||
return c.Status(status).Type("json").Send(body)
|
||||
}
|
||||
apiBaseUrl := node.URL
|
||||
if !strings.HasSuffix(apiBaseUrl, "/") {
|
||||
apiBaseUrl += "/"
|
||||
}
|
||||
|
||||
// 上游错误原样透传(状态码 + body),网络错误返回 502
|
||||
return forwardUpstream(c, apiBaseUrl, apiBaseUrl+"v1/"+apiType+"/"+raw, authHeaders, queryString)
|
||||
|
||||
@@ -321,37 +404,119 @@ func middlewareHandler(c fiber.Ctx) error {
|
||||
|
||||
// ==================== readConfig ====================
|
||||
|
||||
// viperValue 从 viper 读取配置值并反序列化到 target(数组/对象经 JSON 中转,支持自定义类型如 wsFlag)
|
||||
func viperValue(key string, target any) error {
|
||||
v := viper.Get(key)
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(b, target)
|
||||
}
|
||||
|
||||
// fetchRemoteConfig 从远端 URL 拉取配置内容(middleware setting.json 格式)
|
||||
func fetchRemoteConfig(url string) ([]byte, error) { client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("remote config returned status %d", resp.StatusCode)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// applyRemoteConfig 应用远端配置,优先级:远端 > 环境变量 > setting.json。
|
||||
// 远端地址来源:环境变量 REMOTE_CONFIG_URL 优先,其次 setting.json 的 remote-config-url。
|
||||
// 远端字段为空时不覆盖本地值;拉取或解析失败时打印警告并回退本地配置。
|
||||
func applyRemoteConfig(mw *middlewareConfig) error {
|
||||
url := os.Getenv("REMOTE_CONFIG_URL")
|
||||
if url == "" {
|
||||
url = mw.RemoteConfigURL
|
||||
}
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
body, err := fetchRemoteConfig(url)
|
||||
if err != nil {
|
||||
log.Printf("[middleware] WARN failed to fetch remote config, fallback to local: %v", err)
|
||||
return nil
|
||||
}
|
||||
var remote middlewareConfig
|
||||
if err := json.Unmarshal(body, &remote); err != nil {
|
||||
log.Printf("[middleware] WARN invalid remote config JSON, fallback to local: %v", err)
|
||||
return nil
|
||||
}
|
||||
// ignore 列表:数组中的配置项不被远端覆盖(逐键判断跳过)
|
||||
ignored := func(key string) bool {
|
||||
for _, k := range mw.RemoteIgnoreConfig {
|
||||
if k == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !ignored("port") && string(remote.Port) != "" {
|
||||
mw.Port = remote.Port
|
||||
}
|
||||
if !ignored("http-timeout-seconds") && remote.HTTPTimeoutSeconds != 0 {
|
||||
mw.HTTPTimeoutSeconds = remote.HTTPTimeoutSeconds
|
||||
}
|
||||
if !ignored("rate-limit") && remote.RateLimit != nil {
|
||||
mw.RateLimit = remote.RateLimit
|
||||
}
|
||||
if !ignored("ws-port") && remote.WSPort != "" {
|
||||
mw.WSPort = remote.WSPort
|
||||
}
|
||||
if !ignored("remote-config-url") && remote.RemoteConfigURL != "" {
|
||||
mw.RemoteConfigURL = remote.RemoteConfigURL
|
||||
}
|
||||
if !ignored("cors") && remote.Cors != "" {
|
||||
mw.Cors = remote.Cors
|
||||
}
|
||||
// 节点池结构:api-base-url 与 ip-location-api(远端非空栈时覆盖本地)
|
||||
if !ignored("api-base-url") && stackNonEmpty(remote.APIBaseURL) {
|
||||
mw.APIBaseURL = remote.APIBaseURL
|
||||
}
|
||||
if !ignored("ip-location-api") && len(remote.IPLocationAPI) > 0 {
|
||||
mw.IPLocationAPI = remote.IPLocationAPI
|
||||
}
|
||||
// api-keys / ws-keys 强制忽略:密钥凭据不随远端配置覆盖(与后端 access-token 一致),只从本地 setting.json / env 读取
|
||||
log.Printf("[middleware] remote config applied from %s", url)
|
||||
return nil
|
||||
}
|
||||
|
||||
// readConfig 从 setting.json 读取配置并写入全局配置变量。
|
||||
// 查找顺序: 环境变量 SETTING_FILE > ./setting.json > ../setting.json
|
||||
func readConfig() error {
|
||||
// 统一用 viper 读取 setting.json(SETTING_FILE 指定路径,否则查找 ./ 与 ../)
|
||||
path := os.Getenv("SETTING_FILE")
|
||||
if path == "" {
|
||||
for _, candidate := range []string{"setting.json", "../setting.json"} {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
path = candidate
|
||||
break
|
||||
}
|
||||
viper.SetConfigType("json")
|
||||
if path != "" {
|
||||
viper.SetConfigFile(path)
|
||||
} else {
|
||||
viper.SetConfigName("setting")
|
||||
viper.AddConfigPath(".")
|
||||
viper.AddConfigPath("../")
|
||||
}
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
if path == "" {
|
||||
return fmt.Errorf("setting.json not found (set SETTING_FILE to specify its path)")
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
return fmt.Errorf("setting.json not found (set SETTING_FILE to specify its path)")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var mw middlewareConfig
|
||||
if err := json.Unmarshal(data, &mw); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
|
||||
// 环境变量覆盖配置(优先级: 环境变量 > setting.json > 默认值):
|
||||
// 标量直接读;映射表(数组/对象)从环境变量读 JSON 字符串解析。
|
||||
// 部署时无需改动配置文件,用环境变量即可覆盖任意节点/密钥/端口等。
|
||||
// 统一读取模式:先 Getenv,再 viper.GetString(env 优先 > setting.json > 默认值)
|
||||
if v := os.Getenv("PORT"); v != "" {
|
||||
mw.Port = stringOrNumber(v)
|
||||
} else {
|
||||
mw.Port = stringOrNumber(viper.GetString("port"))
|
||||
}
|
||||
if v := os.Getenv("HTTP_TIMEOUT"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
@@ -359,52 +524,122 @@ func readConfig() error {
|
||||
return fmt.Errorf("parse env HTTP_TIMEOUT: %w", err)
|
||||
}
|
||||
mw.HTTPTimeoutSeconds = n
|
||||
} else {
|
||||
mw.HTTPTimeoutSeconds = viper.GetInt("http-timeout-seconds")
|
||||
}
|
||||
if v := os.Getenv("CORS"); v != "" {
|
||||
mw.Cors = v
|
||||
} else {
|
||||
mw.Cors = viper.GetString("cors")
|
||||
}
|
||||
if err := envJSON("API_BASE_URLS", &mw.APIBaseURLs); err != nil {
|
||||
if v := os.Getenv("REMOTE_CONFIG_URL"); v != "" {
|
||||
mw.RemoteConfigURL = v
|
||||
} else {
|
||||
mw.RemoteConfigURL = viper.GetString("remote-config-url")
|
||||
}
|
||||
// rateLimit(*int:env > setting(IsSet) > 默认120;0 = 不限流)
|
||||
if v := os.Getenv("RATE_LIMIT"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse env RATE_LIMIT: %w", err)
|
||||
}
|
||||
mw.RateLimit = &n
|
||||
} else if viper.IsSet("rate-limit") {
|
||||
n := viper.GetInt("rate-limit")
|
||||
mw.RateLimit = &n
|
||||
}
|
||||
// ws-port(stringOrNumber:env > setting(IsSet) > 默认8092;"0"/0 = 关闭 WS 通道)
|
||||
if v := os.Getenv("WS_PORT"); v != "" {
|
||||
mw.WSPort = stringOrNumber(v)
|
||||
} else if viper.IsSet("ws-port") {
|
||||
mw.WSPort = stringOrNumber(viper.GetString("ws-port"))
|
||||
}
|
||||
// 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_IGNORE_CONFIG: %w", err)
|
||||
}
|
||||
mw.RemoteIgnoreConfig = list
|
||||
} else {
|
||||
mw.RemoteIgnoreConfig = viper.GetStringSlice("remote-ignore-config")
|
||||
}
|
||||
|
||||
// 节点池结构:api-base-url 与 ip-location-api(env JSON > setting.json)
|
||||
if err := envJSON("API_BASE_URLS", &mw.APIBaseURL); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := envJSON("IP_LOCATION_APIS", &mw.IPLocationAPIs); err != nil {
|
||||
if !stackNonEmpty(mw.APIBaseURL) && viper.Get("api-base-url") != nil {
|
||||
if err := viperValue("api-base-url", &mw.APIBaseURL); err != nil {
|
||||
return fmt.Errorf("parse api-base-url: %w", err)
|
||||
}
|
||||
}
|
||||
if err := envJSON("IP_LOCATION_APIS", &mw.IPLocationAPI); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := envJSON("TCPING", &mw.TCPing); err != nil {
|
||||
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("API_KEYS", &mw.APIKeys); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := envJSON("SPEED_TEST", &mw.SpeedTest); err != nil {
|
||||
if len(mw.APIKeys) == 0 {
|
||||
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 err := envJSON("NS_LOOKUP", &mw.NSLookup); 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)
|
||||
}
|
||||
}
|
||||
if err := envJSON("APIKEYS", &mw.APIKeys); err != nil {
|
||||
|
||||
// 远端配置(最高优先级):REMOTE_CONFIG_URL 拉取后覆盖(优先级:远端 > 环境变量 > setting.json)
|
||||
if err := applyRemoteConfig(&mw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 校验配置存在(防止在错误的文件上静默空跑)
|
||||
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)
|
||||
}
|
||||
|
||||
// 将配置内容写入全局变量
|
||||
PORT = string(mw.Port)
|
||||
HTTP_TIMEOUT = mw.HTTPTimeoutSeconds
|
||||
// rateLimit:缺省默认 120 次/分钟;显式 0 表示不限流
|
||||
if mw.RateLimit == nil {
|
||||
RATE_LIMIT = 120
|
||||
} else {
|
||||
RATE_LIMIT = *mw.RateLimit
|
||||
}
|
||||
// ws-port:缺省默认 8092;显式 "0"/0 表示关闭 WS 通道(兼容字符串与数字写法)
|
||||
if mw.WSPort == "" {
|
||||
WS_PORT = 8092
|
||||
} else {
|
||||
n, err := strconv.Atoi(string(mw.WSPort))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse ws-port %q: %w", mw.WSPort, err)
|
||||
}
|
||||
WS_PORT = n
|
||||
}
|
||||
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
|
||||
CONFIG_SOURCE = path
|
||||
WS_KEYS = mw.WSKeys
|
||||
CONFIG_SOURCE = viper.ConfigFileUsed()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -433,8 +668,28 @@ func main() {
|
||||
}
|
||||
HTTP_CLIENT.Timeout = time.Duration(timeout) * time.Second
|
||||
|
||||
// WS 服务端(端口由配置 wsPort / 环境变量 WS_PORT 决定,缺省 8092,0 = 关闭;统一走 readConfig)。
|
||||
// 后端节点作为 WS 客户端连入;节点配置 "ws": true 时拨测请求改走 WS 通道。现有 HTTP 路由不受影响。
|
||||
if WS_PORT > 0 {
|
||||
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)
|
||||
} else {
|
||||
log.Printf("[ws] ws channel disabled (wsPort=0)")
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
|
||||
// 单 IP 限流(fiber 内置中间件,默认按客户端 IP 计数):次数由配置 rateLimit / 环境变量 RATE_LIMIT 决定(默认 120 次/分钟),
|
||||
// 0 表示不限流。必须放在所有路由注册之前,fiber 中间件只对注册在其后的路由生效。
|
||||
if RATE_LIMIT > 0 {
|
||||
app.Use(limiter.New(limiter.Config{
|
||||
Max: RATE_LIMIT,
|
||||
Expiration: time.Minute,
|
||||
}))
|
||||
}
|
||||
|
||||
app.Get("/", func(c fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"status": "ok",
|
||||
@@ -455,6 +710,7 @@ func main() {
|
||||
corsConfig.AllowOrigins = []string{"*"}
|
||||
}
|
||||
app.Use(cors.New(corsConfig))
|
||||
|
||||
app.Get("/v1/*", middlewareHandler)
|
||||
app.Get("/middleware/*", middlewareHandler)
|
||||
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
{
|
||||
"port": "8091",
|
||||
"httpTimeoutSeconds": 30,
|
||||
"http-timeout-seconds": 30,
|
||||
"rate-limit": 120,
|
||||
"ws-port": 8092,
|
||||
"remote-config-url": "",
|
||||
"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": "中国 江苏 移动",
|
||||
@@ -24,31 +14,16 @@
|
||||
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
}
|
||||
],
|
||||
"IPv4": [
|
||||
],
|
||||
"IPv6": [
|
||||
]
|
||||
"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": {}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// ==================== WS 通信(middleware 作服务端) ====================
|
||||
//
|
||||
// 后端节点作为 WS 客户端连接 middleware 的 WS 服务端(默认端口 8092,路径 /ws,环境变量 WS_PORT 覆盖,0 关闭)。
|
||||
// 节点配置项 `"ws": true`(或 "true")时,该节点的拨测请求改走 WS 通道转发;缺省 false 保持原有 HTTP 转发。
|
||||
// 现有 HTTP 接口(/v1/* /middleware/*)完全不变,WS 是独立端口上的新增数据面。
|
||||
//
|
||||
// 消息信封(JSON 文本帧):{ "type": "...", "nodeId": "...", "ts": <unix秒>, "data": {...} }
|
||||
// - 节点 → middleware:register / probe_result / pong / command
|
||||
// - middleware → 节点:register_ok / register_error / probe / ping / status
|
||||
//
|
||||
// probe 消息 data:{ "requestId": "...", "apiType": "tcping", "raw": "qq.com", "query": {"port":"443"} }
|
||||
// probe_result data:{ "requestId": "...", "status": 200, "body": <JSON 值> }(body 为 JSON 字符串时按原文透传)
|
||||
|
||||
type wsMessage struct {
|
||||
Type string `json:"type"`
|
||||
NodeID string `json:"nodeId,omitempty"`
|
||||
TS int64 `json:"ts"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type wsProbeRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
APIType string `json:"apiType"`
|
||||
Raw string `json:"raw"`
|
||||
Query map[string]string `json:"query,omitempty"`
|
||||
}
|
||||
|
||||
type wsProbeResult struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Status int `json:"status"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type wsCommand struct {
|
||||
Command string `json:"command"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type wsPeer struct {
|
||||
conn *websocket.Conn
|
||||
id string
|
||||
last time.Time
|
||||
}
|
||||
|
||||
type wsServer struct {
|
||||
mu sync.Mutex
|
||||
peers map[string]*wsPeer
|
||||
|
||||
probeMu sync.Mutex
|
||||
probeWait map[string]chan wsProbeResult
|
||||
|
||||
// 统计
|
||||
statMu sync.Mutex
|
||||
totalReqs int64
|
||||
errReqs int64
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
func newWSServer() *wsServer {
|
||||
return &wsServer{
|
||||
peers: make(map[string]*wsPeer),
|
||||
probeWait: make(map[string]chan wsProbeResult),
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wsServer) Handler(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
OriginPatterns: []string{"*"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[ws] accept failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer c.Close(websocket.StatusInternalError, "closed")
|
||||
|
||||
ctx := r.Context()
|
||||
peer := &wsPeer{conn: c}
|
||||
registered := false
|
||||
|
||||
// 读循环
|
||||
for {
|
||||
_, data, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
if !registered {
|
||||
log.Printf("[ws] peer closed before register")
|
||||
} else {
|
||||
log.Printf("[ws] node %s disconnected: %v", peer.id, err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
var msg wsMessage
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
log.Printf("[ws] invalid message from %s: %v", peer.id, err)
|
||||
continue
|
||||
}
|
||||
peer.last = time.Now()
|
||||
|
||||
// ===== 数据阶段 =====
|
||||
switch msg.Type {
|
||||
case "register":
|
||||
var reg struct {
|
||||
NodeID string `json:"nodeId"`
|
||||
Key string `json:"key"` // 注册凭证:与 setting.json wsKeys 配置比对
|
||||
}
|
||||
_ = json.Unmarshal(msg.Data, ®)
|
||||
if reg.NodeID == "" {
|
||||
s.sendJSON(c, wsMessage{Type: "register_error", Data: mustRaw(wsCommand{Command: "empty nodeId"})})
|
||||
continue
|
||||
}
|
||||
if registered {
|
||||
s.sendJSON(c, wsMessage{Type: "register_error", Data: mustRaw(wsCommand{Command: "already registered"})})
|
||||
continue
|
||||
}
|
||||
// 注册 key 校验:节点配置了 wsKeys[节点id] 就必须传对 key,否则 401 并断开;
|
||||
// 未配置 key 的节点(开放)无需传 key
|
||||
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"`
|
||||
Command string `json:"command"`
|
||||
}{Code: 401, Command: "invalid key"})})
|
||||
c.Close(websocket.StatusPolicyViolation, "invalid key")
|
||||
return
|
||||
}
|
||||
registered = true
|
||||
peer.id = reg.NodeID
|
||||
s.mu.Lock()
|
||||
// 同 id 新连接替换旧连接(旧连接由读循环自然退出)
|
||||
if old, ok := s.peers[reg.NodeID]; ok && old.conn != c {
|
||||
old.conn.Close(websocket.StatusPolicyViolation, "replaced by new connection")
|
||||
}
|
||||
s.peers[reg.NodeID] = peer
|
||||
s.mu.Unlock()
|
||||
log.Printf("[ws] node registered: %s", reg.NodeID)
|
||||
s.sendJSON(c, wsMessage{Type: "register_ok", NodeID: reg.NodeID, TS: time.Now().Unix(), Data: mustRaw(struct {
|
||||
Heartbeat int `json:"heartbeatSeconds"`
|
||||
}{Heartbeat: 20})})
|
||||
|
||||
case "probe_result":
|
||||
if !registered {
|
||||
continue
|
||||
}
|
||||
var res wsProbeResult
|
||||
if err := json.Unmarshal(msg.Data, &res); err != nil {
|
||||
log.Printf("[ws] bad probe_result from %s: %v", peer.id, err)
|
||||
continue
|
||||
}
|
||||
s.probeMu.Lock()
|
||||
ch, ok := s.probeWait[res.RequestID]
|
||||
delete(s.probeWait, res.RequestID)
|
||||
s.probeMu.Unlock()
|
||||
if ok {
|
||||
ch <- res
|
||||
}
|
||||
|
||||
case "pong":
|
||||
// 心跳应答,仅刷新 last
|
||||
|
||||
case "ping":
|
||||
// 节点主动心跳:回 pong
|
||||
if registered {
|
||||
s.sendJSON(c, wsMessage{Type: "pong", NodeID: peer.id, TS: time.Now().Unix()})
|
||||
}
|
||||
|
||||
default:
|
||||
log.Printf("[ws] unknown message type from %s: %s", peer.id, msg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// 断开清理
|
||||
if registered {
|
||||
s.mu.Lock()
|
||||
if s.peers[peer.id] == peer {
|
||||
delete(s.peers, peer.id)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wsServer) sendJSON(c *websocket.Conn, msg wsMessage) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = c.Write(ctx, websocket.MessageText, mustRaw(msg))
|
||||
}
|
||||
|
||||
// RequestProbe 通过 WS 通道向指定节点发送拨测请求并等待结果(超时返回错误)。
|
||||
func (s *wsServer) RequestProbe(nodeID, apiType, raw string, query map[string]string, timeout time.Duration) (int, []byte, error) {
|
||||
s.mu.Lock()
|
||||
peer, ok := s.peers[nodeID]
|
||||
s.mu.Unlock()
|
||||
if !ok || peer.conn == nil {
|
||||
return 0, nil, fmt.Errorf("ws node %s not connected", nodeID)
|
||||
}
|
||||
|
||||
reqID := fmt.Sprintf("m%d-%d", time.Now().UnixNano(), genSeq())
|
||||
ch := make(chan wsProbeResult, 1)
|
||||
s.probeMu.Lock()
|
||||
s.probeWait[reqID] = ch
|
||||
s.probeMu.Unlock()
|
||||
defer func() {
|
||||
s.probeMu.Lock()
|
||||
delete(s.probeWait, reqID)
|
||||
s.probeMu.Unlock()
|
||||
}()
|
||||
|
||||
payload := wsProbeRequest{RequestID: reqID, APIType: apiType, Raw: raw, Query: query}
|
||||
s.sendJSON(peer.conn, wsMessage{Type: "probe", NodeID: nodeID, TS: time.Now().Unix(), Data: mustRaw(payload)})
|
||||
|
||||
s.statMu.Lock()
|
||||
s.totalReqs++
|
||||
s.statMu.Unlock()
|
||||
|
||||
select {
|
||||
case res := <-ch:
|
||||
if res.Error != "" {
|
||||
s.statMu.Lock()
|
||||
s.errReqs++
|
||||
s.statMu.Unlock()
|
||||
return 0, nil, fmt.Errorf("ws probe error: %s", res.Error)
|
||||
}
|
||||
body := res.Body
|
||||
// body 为 JSON 字符串时按原文透传(兼容文本响应)
|
||||
if len(body) > 0 && body[0] == '"' {
|
||||
var str string
|
||||
if json.Unmarshal(body, &str) == nil {
|
||||
body = []byte(str)
|
||||
}
|
||||
}
|
||||
return res.Status, body, nil
|
||||
case <-time.After(timeout):
|
||||
s.statMu.Lock()
|
||||
s.errReqs++
|
||||
s.statMu.Unlock()
|
||||
return 0, nil, fmt.Errorf("ws probe timeout for node %s", nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动 WS 服务端(阻塞)。
|
||||
func (s *wsServer) Start(addr string) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/ws", s.Handler)
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
log.Printf("[ws] server listening on %s/ws", addr)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
log.Printf("[ws] server stopped: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// maintenanceLoop 心跳 + 状态上报(middleware → 节点)。
|
||||
func (s *wsServer) maintenanceLoop() {
|
||||
for {
|
||||
time.Sleep(20 * time.Second)
|
||||
s.mu.Lock()
|
||||
peers := make([]*wsPeer, 0, len(s.peers))
|
||||
for _, p := range s.peers {
|
||||
peers = append(peers, p)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
s.statMu.Lock()
|
||||
stats := struct {
|
||||
UptimeSeconds int64 `json:"uptimeSeconds"`
|
||||
TotalRequests int64 `json:"totalRequests"`
|
||||
ErrorRequests int64 `json:"errorRequests"`
|
||||
ConnectedAt int64 `json:"connectedAt"`
|
||||
}{
|
||||
UptimeSeconds: int64(time.Since(s.startedAt).Seconds()),
|
||||
TotalRequests: s.totalReqs,
|
||||
ErrorRequests: s.errReqs,
|
||||
}
|
||||
s.statMu.Unlock()
|
||||
|
||||
for _, p := range peers {
|
||||
// 心跳
|
||||
s.sendJSON(p.conn, wsMessage{Type: "ping", NodeID: p.id, TS: time.Now().Unix()})
|
||||
// 状态/统计上报
|
||||
s.sendJSON(p.conn, wsMessage{Type: "status", NodeID: p.id, TS: time.Now().Unix(), Data: mustRaw(stats)})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// genSeq 自增序号(进程内)。
|
||||
var seqMu sync.Mutex
|
||||
var seq int64
|
||||
|
||||
func genSeq() int64 {
|
||||
seqMu.Lock()
|
||||
defer seqMu.Unlock()
|
||||
seq++
|
||||
return seq
|
||||
}
|
||||
|
||||
func mustRaw(v any) json.RawMessage {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return json.RawMessage("{}")
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ==================== 指令处理与超时 ====================
|
||||
|
||||
// wsProbeTimeout 拨测超时:复用 HTTP_TIMEOUT(默认 30s)
|
||||
func wsProbeTimeout() time.Duration {
|
||||
t := HTTP_TIMEOUT
|
||||
if t <= 0 {
|
||||
t = 30
|
||||
}
|
||||
return time.Duration(t) * time.Second
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -33,14 +33,14 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
@@ -61,8 +61,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -88,8 +88,8 @@ golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -55,6 +55,7 @@ var (
|
||||
speedCache sync.Map
|
||||
whoisCache sync.Map
|
||||
DNS_SERVER string
|
||||
DNSSEC_DNS_SERVER string // DNSSEC 专用 DNS 服务器(dnssec-server / DNSSEC_DNS_SERVER),留空沿用 DNS_SERVER
|
||||
defaultPort = fmt.Sprintf("%d", 5<<4)
|
||||
V6Client *resty.Client
|
||||
V4Client *resty.Client
|
||||
@@ -1168,6 +1169,9 @@ func applyRemoteConfig() {
|
||||
if v := configValue(CONFIG, "dns-server"); v != "" {
|
||||
DNS_SERVER = v
|
||||
}
|
||||
if v := configValue(CONFIG, "dnssec-server"); v != "" {
|
||||
DNSSEC_DNS_SERVER = v
|
||||
}
|
||||
if v := configValue(CONFIG, "cors"); v != "" {
|
||||
CORS = v
|
||||
}
|
||||
@@ -1195,6 +1199,9 @@ func readConfig() {
|
||||
if v := os.Getenv("DNS_SERVER"); v != "" {
|
||||
DNS_SERVER = v
|
||||
}
|
||||
if v := os.Getenv("DNSSEC_DNS_SERVER"); v != "" {
|
||||
DNSSEC_DNS_SERVER = v
|
||||
}
|
||||
if v := os.Getenv("CORS"); v != "" {
|
||||
CORS = v
|
||||
}
|
||||
@@ -1218,6 +1225,7 @@ func main() {
|
||||
readConfig()
|
||||
initHttpClient()
|
||||
webtest.SetDNSServer(DNS_SERVER)
|
||||
webtest.SetDNSSecServer(DNSSEC_DNS_SERVER)
|
||||
slog.Info("Starting server", "port", PORTS, "single_stack", SINGLE_STACK)
|
||||
r := gin.Default()
|
||||
corsConfig := cors.DefaultConfig()
|
||||
File renamed without changes.
+94
-20
@@ -23,6 +23,12 @@ var dnsServer = "119.28.28.28:53"
|
||||
// dnsMode 主通道:dns-server 配置为 URL 时为 "doh",为 ip:port 时为 "udp"
|
||||
var dnsMode = "doh"
|
||||
|
||||
// dnsServers 普通 DNS 查询服务器列表(主从:第一个为主,主失败自动切换后续从服务器)
|
||||
var dnsServers []string
|
||||
|
||||
// dnssecServers DNSSEC 专用服务器列表(未配置时沿用 dnsServers)
|
||||
var dnssecServers []string
|
||||
|
||||
type DNSResult struct {
|
||||
Domain string `json:"domain"`
|
||||
Duration float64 `json:"duration"`
|
||||
@@ -30,44 +36,112 @@ type DNSResult struct {
|
||||
TTL uint32 `json:"ttl"`
|
||||
}
|
||||
|
||||
// SetDNSServer 设置 DNS 服务器:URL(http/https)→ DoH 主通道;ip:port → UDP 主通道
|
||||
// SetDNSServer 设置 DNS 服务器:支持逗号分隔多地址主从 failover(第一个为主,主失败自动切换后续从服务器)。
|
||||
// 每项支持 "ip:port"(UDP)或 DoH URL http(s)://...;单地址时保持原有语义。
|
||||
func SetDNSServer(server string) {
|
||||
if server == "" {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(server, "http://") || strings.HasPrefix(server, "https://") {
|
||||
dohEndpoint = server
|
||||
dnsMode = "doh"
|
||||
} else {
|
||||
dnsServer = server
|
||||
dnsMode = "udp"
|
||||
list := splitServers(server)
|
||||
dnsServers = list
|
||||
// 兼容单地址的旧语义:URL → DoH 主通道;ip:port → UDP 主通道
|
||||
if len(list) == 1 {
|
||||
if strings.HasPrefix(server, "http://") || strings.HasPrefix(server, "https://") {
|
||||
dohEndpoint = server
|
||||
dnsMode = "doh"
|
||||
} else {
|
||||
dnsServer = server
|
||||
dnsMode = "udp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetDNSSecServer 设置 DNSSEC 专用 DNS 服务器(逗号分隔主从;留空 = 沿用 dns-server 配置)
|
||||
func SetDNSSecServer(server string) {
|
||||
if list := splitServers(server); len(list) > 0 {
|
||||
dnssecServers = list
|
||||
}
|
||||
}
|
||||
|
||||
// splitServers 逗号分隔解析为地址列表(去空白、去空项)
|
||||
func splitServers(s string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ⭐️ 底层核心:DoH + UDP 双通道 DNS 查询
|
||||
// ==========================================
|
||||
|
||||
// queryDNSMsg 统一 DNS 查询入口:支持 DoH 与 UDP 双通道自动互备。
|
||||
// dnsMode 为 "doh" 时优先 DoH(dohEndpoint),失败回退 UDP(dnsServer);
|
||||
// dnsMode 为 "udp" 时优先 UDP(dnsServer),失败回退 DoH(dohEndpoint)。
|
||||
// queryDNSMsg 普通 DNS 查询:主从 failover——按配置顺序依次尝试每个服务器,
|
||||
// 当前服务器查询失败(网络错误/超时)自动切换下一个;全部失败返回最后一个错误。
|
||||
// 单个服务器:URL 走 DoH,ip:port 走 UDP。
|
||||
func queryDNSMsg(msg *dns.Msg) (*dns.Msg, float64, error) {
|
||||
if dnsMode == "doh" {
|
||||
resp, dur, err := queryDoHMsg(msg, dohEndpoint)
|
||||
servers := dnsServers
|
||||
if len(servers) == 0 {
|
||||
// 未配置主从列表时回退单地址语义(保持原 DoH/UDP 双通道互备)
|
||||
if dnsMode == "doh" {
|
||||
resp, dur, err := queryDoHMsg(msg, dohEndpoint)
|
||||
if err == nil {
|
||||
return resp, dur, nil
|
||||
}
|
||||
slog.Warn("DoH query failed, falling back to UDP", "endpoint", dohEndpoint, "error", err)
|
||||
return queryUDPMsg(msg, dnsServer)
|
||||
}
|
||||
resp, dur, err := queryUDPMsg(msg, dnsServer)
|
||||
if err == nil {
|
||||
return resp, dur, nil
|
||||
}
|
||||
slog.Warn("DoH query failed, falling back to UDP", "endpoint", dohEndpoint, "error", err)
|
||||
return queryUDPMsg(msg, dnsServer)
|
||||
slog.Warn("UDP query failed, falling back to DoH", "server", dnsServer, "error", err)
|
||||
return queryDoHMsg(msg, dohEndpoint)
|
||||
}
|
||||
resp, dur, err := queryUDPMsg(msg, dnsServer)
|
||||
if err == nil {
|
||||
return resp, dur, nil
|
||||
}
|
||||
slog.Warn("UDP query failed, falling back to DoH", "server", dnsServer, "error", err)
|
||||
return queryDoHMsg(msg, dohEndpoint)
|
||||
return queryWithServers(msg, servers)
|
||||
}
|
||||
|
||||
// queryDNSSECMsg DNSSEC 查询:优先使用专用服务器(dnssec-server),未配置则沿用普通 dns-server
|
||||
func queryDNSSECMsg(msg *dns.Msg) (*dns.Msg, float64, error) {
|
||||
if len(dnssecServers) > 0 {
|
||||
return queryWithServers(msg, dnssecServers)
|
||||
}
|
||||
return queryDNSMsg(msg)
|
||||
}
|
||||
|
||||
// queryWithServers 依次尝试服务器列表,主失败切从;每个服务器按类型走 DoH 或 UDP
|
||||
func queryWithServers(msg *dns.Msg, servers []string) (*dns.Msg, float64, error) {
|
||||
if len(servers) == 0 {
|
||||
servers = []string{defaultUDPServer}
|
||||
}
|
||||
var lastErr error
|
||||
for i, srv := range servers {
|
||||
var resp *dns.Msg
|
||||
var dur float64
|
||||
var err error
|
||||
if strings.HasPrefix(srv, "http://") || strings.HasPrefix(srv, "https://") {
|
||||
resp, dur, err = queryDoHMsg(msg, srv)
|
||||
} else {
|
||||
resp, dur, err = queryUDPMsg(msg, srv)
|
||||
}
|
||||
if err == nil {
|
||||
return resp, dur, nil
|
||||
}
|
||||
lastErr = err
|
||||
if i < len(servers)-1 {
|
||||
slog.Warn("DNS query failed, switching to next server", "server", srv, "error", err)
|
||||
}
|
||||
}
|
||||
slog.Warn("all DNS servers failed", "servers", servers, "error", lastErr)
|
||||
return nil, 0, lastErr
|
||||
}
|
||||
|
||||
// defaultUDPServer 兜底 UDP 服务器
|
||||
const defaultUDPServer = "119.28.28.28:53"
|
||||
|
||||
// queryUDPMsg 通过 UDP/TCP 向指定 DNS 服务器发送查询(miekg/dns 自动处理大响应切 TCP)
|
||||
func queryUDPMsg(msg *dns.Msg, server string) (*dns.Msg, float64, error) {
|
||||
client := &dns.Client{Timeout: 5 * time.Second}
|
||||
+2
-2
@@ -255,9 +255,9 @@ func ResolveDNSSECForRecord(domain string, recordType uint16) (*DNSSECResult, er
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// executeDoHQueryMsg 通过 queryDNSMsg(DoH+UDP 双通道)发送 DNS 查询并返回解析后的响应消息
|
||||
// executeDoHQueryMsg 通过 queryDNSSECMsg(DNSSEC 专用 DNS,主从 failover;未配置专用则沿用 dns-server)发送 DNS 查询并返回解析后的响应消息
|
||||
func executeDoHQueryMsg(msg *dns.Msg) (*dns.Msg, float64, error) {
|
||||
responseMsg, duration, err := queryDNSMsg(msg)
|
||||
responseMsg, duration, err := queryDNSSECMsg(msg)
|
||||
if err != nil {
|
||||
return nil, duration, err
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+706
-1156
File diff suppressed because it is too large.
Load diff
@@ -10,10 +10,10 @@
|
||||
"cf-typegen": "wrangler types"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.18.5",
|
||||
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
||||
"typescript": "^5.5.2",
|
||||
"vitest": "~4.1.10",
|
||||
"wrangler": "^4.111.0"
|
||||
"wrangler": "^4.124.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.12.34"
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+8
-4
@@ -1,10 +1,12 @@
|
||||
module lemon-ipw
|
||||
|
||||
go 1.26.2
|
||||
go 1.26.6
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/gin-contrib/cors v1.7.7
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/ip2location/ip2location-go/v9 v9.8.0
|
||||
github.com/ipipdotnet/ipdb-go v1.3.3
|
||||
github.com/klauspost/compress v1.19.1
|
||||
github.com/likexian/whois v1.15.7
|
||||
@@ -18,6 +20,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/AdguardTeam/golibs v0.35.16 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
@@ -53,10 +56,11 @@ require (
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
)
|
||||
@@ -1,3 +1,5 @@
|
||||
github.com/AdguardTeam/golibs v0.35.16 h1:e9dyk7jfzpxMcyVZ8RktKV6JMeiQ9MpEhp/Ahm3j/R4=
|
||||
github.com/AdguardTeam/golibs v0.35.16/go.mod h1:lGsRwlo3R3ma5o8mgdDv3I+6DYTiCHfrhNyUamqhbK8=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
@@ -6,9 +8,12 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
@@ -38,6 +43,8 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/ip2location/ip2location-go/v9 v9.8.0 h1:drPzGjj1EBl45I33ErMHFtIfsQ3mR85dAQbqMDbi9mc=
|
||||
github.com/ip2location/ip2location-go/v9 v9.8.0/go.mod h1:MPLnsKxwQlvd2lBNcQCsLoyzJLDBFizuO67wXXdzoyI=
|
||||
github.com/ipipdotnet/ipdb-go v1.3.3 h1:GLSAW9ypLUd6EF9QNK2Uhxew9Jzs4XMJ9gOZEFnJm7U=
|
||||
github.com/ipipdotnet/ipdb-go v1.3.3/go.mod h1:yZ+8puwe3R37a/3qRftXo40nZVQbxYDLqls9o5foexs=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
@@ -75,12 +82,14 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
@@ -122,6 +131,8 @@ golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
@@ -133,13 +144,19 @@ golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
resty.dev/v3 v3.0.0-beta.6 h1:ghRdNpoE8/wBCv+kTKIOauW1aCrSIeTq7GxtfYgtevU=
|
||||
resty.dev/v3 v3.0.0-beta.6/go.mod h1:NTOerrC/4T7/FE6tXIZGIysXXBdgNqwMZuKtxpea9NM=
|
||||
@@ -1,6 +1,7 @@
|
||||
package ipdb
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -31,6 +32,8 @@ func PullDatabase(ghproxy string) error {
|
||||
{ghproxy + "https://github.com/ljxi/GeoCN/releases/latest/download/GeoCN.mmdb", "GeoCN.mmdb"},
|
||||
{ghproxy + "https://raw.githubusercontent.com/wp-statistics/DbIP-City-lite/master/dbip-city-lite.mmdb.gz", "dbip-city-lite.mmdb.gz"},
|
||||
{ghproxy + "https://github.com/jcjc-dev/mmdb-latest/releases/download/dbip-latest/dbip-asn-lite.mmdb", "dbip-asn-lite.mmdb"},
|
||||
{ghproxy + "https://github.com/nomdn/ip2location/releases/latest/download/IP2LOCATION-LITE-DB11.IPV6.BIN.ZIP", "IP2LOCATION-LITE-DB11.IPV6.BIN.ZIP"},
|
||||
{ghproxy + "https://github.com/nomdn/ip2location/releases/latest/download/IP2LOCATION-LITE-ASN.IPV6.BIN.zip", "IP2LOCATION-LITE-ASN.IPV6.BIN.zip"},
|
||||
}
|
||||
|
||||
tmpDir := "./tmp"
|
||||
@@ -81,6 +84,14 @@ func PullDatabase(ghproxy string) error {
|
||||
}
|
||||
os.Remove(dst)
|
||||
}
|
||||
if strings.HasSuffix(t.name, ".zip") {
|
||||
outName := strings.TrimSuffix(t.name, ".zip")
|
||||
slog.Info("Unzipping...", "file", t.name, "output", outName)
|
||||
if err := unzipFile(dst, "./"+outName); err != nil {
|
||||
slog.Error("Failed to unzip", "file", t.name, "error", err)
|
||||
}
|
||||
os.Remove(dst)
|
||||
}
|
||||
}
|
||||
|
||||
os.RemoveAll(tmpDir)
|
||||
@@ -205,3 +216,64 @@ func gunzipFile(src, dst string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// unzipFile 解压 zip 中与目标同名(或同后缀)的文件到 dst(dst 如 ./IP2LOCATION-LITE-DB11.IPV6.BIN);
|
||||
// 找不到匹配文件时回退解压第一个非目录文件。
|
||||
func unzipFile(src, dst string) error {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
target := filepath.Base(dst)
|
||||
var fallback *zip.File
|
||||
for _, f := range r.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
if fallback == nil {
|
||||
fallback = f
|
||||
}
|
||||
if f.Name == target || strings.EqualFold(f.Name, target) || strings.HasSuffix(strings.ToLower(f.Name), strings.ToLower(target)) {
|
||||
return extractZipEntry(f, dst)
|
||||
}
|
||||
}
|
||||
if fallback == nil {
|
||||
return fmt.Errorf("no file found in zip archive %s", src)
|
||||
}
|
||||
return extractZipEntry(fallback, dst)
|
||||
}
|
||||
|
||||
func extractZipEntry(f *zip.File, dst string) error {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
tmp := dst + ".tmp"
|
||||
out, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, rc); err != nil {
|
||||
out.Close()
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := out.Sync(); err != nil {
|
||||
out.Close()
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
ipdbgo "github.com/ipipdotnet/ipdb-go"
|
||||
"github.com/ip2location/ip2location-go/v9"
|
||||
"github.com/lionsoul2014/ip2region/binding/golang/service"
|
||||
maxminddb "github.com/oschwald/maxminddb-golang/v2"
|
||||
"resty.dev/v3"
|
||||
@@ -23,6 +24,10 @@ import (
|
||||
var (
|
||||
ip2region *service.Ip2Region
|
||||
ip2regionMu sync.RWMutex
|
||||
ip2locDB *ip2location.DB
|
||||
ip2locMu sync.RWMutex
|
||||
ip2locASNDB *ip2location.DB
|
||||
ip2locASNMu sync.RWMutex
|
||||
qqwryDB *ipdbgo.City
|
||||
qqwryMu sync.RWMutex
|
||||
mmdbDBs map[string]*maxminddb.Reader
|
||||
@@ -163,7 +168,6 @@ func loadIp2Region() error {
|
||||
ip2region.Close()
|
||||
slog.Info("Previous ip2region closed")
|
||||
}
|
||||
|
||||
v4Config, err := service.NewV4Config(service.VIndexCache, "ip2region_v4.xdb", 20)
|
||||
if err != nil {
|
||||
slog.Error("failed to create v4 config", "error", err)
|
||||
@@ -186,6 +190,48 @@ func loadIp2Region() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadIP2Location 加载 IP2Location LITE DB11 数据库(.BIN 格式,官方 ip2location-go 库读取)
|
||||
func loadIP2Location() error {
|
||||
ip2locMu.Lock()
|
||||
defer ip2locMu.Unlock()
|
||||
|
||||
if ip2locDB != nil {
|
||||
ip2locDB.Close()
|
||||
ip2locDB = nil
|
||||
slog.Info("Previous ip2location closed")
|
||||
}
|
||||
|
||||
db, err := ip2location.OpenDB("./IP2LOCATION-LITE-DB11.IPV6.BIN")
|
||||
if err != nil {
|
||||
slog.Error("failed to open ip2location db", "error", err)
|
||||
return err
|
||||
}
|
||||
ip2locDB = db
|
||||
slog.Info("ip2location loaded successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadIP2LocationASN 加载 IP2Location LITE ASN 数据库(仅 asn / as 字段)
|
||||
func loadIP2LocationASN() error {
|
||||
ip2locASNMu.Lock()
|
||||
defer ip2locASNMu.Unlock()
|
||||
|
||||
if ip2locASNDB != nil {
|
||||
ip2locASNDB.Close()
|
||||
ip2locASNDB = nil
|
||||
slog.Info("Previous ip2location asn closed")
|
||||
}
|
||||
|
||||
db, err := ip2location.OpenDB("./IP2LOCATION-LITE-ASN.IPV6.BIN")
|
||||
if err != nil {
|
||||
slog.Error("failed to open ip2location asn db", "error", err)
|
||||
return err
|
||||
}
|
||||
ip2locASNDB = db
|
||||
slog.Info("ip2location asn loaded successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadQQWry() error {
|
||||
qqwryMu.Lock()
|
||||
defer qqwryMu.Unlock()
|
||||
@@ -552,6 +598,20 @@ func closeAllDBs() {
|
||||
}
|
||||
ip2regionMu.Unlock()
|
||||
|
||||
ip2locMu.Lock()
|
||||
if ip2locDB != nil {
|
||||
ip2locDB.Close()
|
||||
ip2locDB = nil
|
||||
}
|
||||
ip2locMu.Unlock()
|
||||
|
||||
ip2locASNMu.Lock()
|
||||
if ip2locASNDB != nil {
|
||||
ip2locASNDB.Close()
|
||||
ip2locASNDB = nil
|
||||
}
|
||||
ip2locASNMu.Unlock()
|
||||
|
||||
qqwryMu.Lock()
|
||||
qqwryDB = nil
|
||||
qqwryMu.Unlock()
|
||||
@@ -569,6 +629,12 @@ func reloadAll() {
|
||||
if err := loadIp2Region(); err != nil {
|
||||
slog.Error("Error loading ip2region", "error", err)
|
||||
}
|
||||
if err := loadIP2Location(); err != nil {
|
||||
slog.Error("Error loading ip2location", "error", err)
|
||||
}
|
||||
if err := loadIP2LocationASN(); err != nil {
|
||||
slog.Error("Error loading ip2location asn", "error", err)
|
||||
}
|
||||
if err := reloadQQWry(); err != nil {
|
||||
slog.Error("Error reloading qqwry.ipdb", "error", err)
|
||||
}
|
||||
@@ -587,6 +653,14 @@ func Init(ghproxy string) {
|
||||
slog.Warn("Local ip2region not available", "error", err)
|
||||
localOK = false
|
||||
}
|
||||
if err := loadIP2Location(); err != nil {
|
||||
slog.Warn("Local ip2location not available", "error", err)
|
||||
localOK = false
|
||||
}
|
||||
if err := loadIP2LocationASN(); err != nil {
|
||||
slog.Warn("Local ip2location asn not available", "error", err)
|
||||
localOK = false
|
||||
}
|
||||
if err := loadQQWry(); err != nil {
|
||||
slog.Warn("Local qqwry.ipdb not available", "error", err)
|
||||
localOK = false
|
||||
@@ -627,7 +701,7 @@ func Init(ghproxy string) {
|
||||
}()
|
||||
}
|
||||
|
||||
var allDatabases = []string{"ip2region", "qqwry", "maxmind_city", "maxmind_asn", "dbip_asn", "geocn", "dbip_city", "bilibili"}
|
||||
var allDatabases = []string{"ip2region", "ip2location", "ip2location_asn", "qqwry", "maxmind_city", "maxmind_asn", "dbip_asn", "geocn", "dbip_city", "bilibili"}
|
||||
|
||||
func SearchIP(ip string, databases ...string) map[string]interface{} {
|
||||
if len(databases) == 0 {
|
||||
@@ -645,13 +719,74 @@ func SearchIP(ip string, databases ...string) map[string]interface{} {
|
||||
if err != nil {
|
||||
result["ip2region"] = "error: " + err.Error()
|
||||
} else {
|
||||
result["ip2region"] = region
|
||||
// 预分割为结构化 JSON,前端无需再按 "|" 拆字符串
|
||||
result["ip2region"] = parseIP2Region(region)
|
||||
}
|
||||
} else {
|
||||
result["ip2region"] = "not loaded"
|
||||
}
|
||||
ip2regionMu.RUnlock()
|
||||
|
||||
case "ip2location":
|
||||
ip2locMu.RLock()
|
||||
if ip2locDB != nil {
|
||||
rec, err := ip2locDB.Get_all(ip)
|
||||
if err != nil {
|
||||
result["ip2location"] = "error: " + err.Error()
|
||||
} else {
|
||||
// LITE 免费版不提供 asn/isp 等字段,库会返回占位提示串,统一过滤为空
|
||||
clean := func(s string) string {
|
||||
if s == "" || strings.HasPrefix(s, "This parameter is unavailable") {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
loc := map[string]string{
|
||||
"country": clean(rec.Country_long),
|
||||
"country_code": clean(rec.Country_short),
|
||||
"administrative_area": clean(rec.Region),
|
||||
"city": clean(rec.City),
|
||||
"isp": clean(rec.Isp),
|
||||
"asn": clean(rec.Asn),
|
||||
"district": clean(rec.District),
|
||||
"zipcode": clean(rec.Zipcode),
|
||||
"timezone": clean(rec.Timezone),
|
||||
"usagetype": clean(rec.Usagetype),
|
||||
}
|
||||
if rec.Latitude != 0 || rec.Longitude != 0 {
|
||||
loc["latitude"] = strconv.FormatFloat(float64(rec.Latitude), 'f', -1, 32)
|
||||
loc["longitude"] = strconv.FormatFloat(float64(rec.Longitude), 'f', -1, 32)
|
||||
}
|
||||
result["ip2location"] = loc
|
||||
}
|
||||
} else {
|
||||
result["ip2location"] = "not loaded"
|
||||
}
|
||||
ip2locMu.RUnlock()
|
||||
|
||||
case "ip2location_asn":
|
||||
ip2locASNMu.RLock()
|
||||
if ip2locASNDB != nil {
|
||||
rec, err := ip2locASNDB.Get_all(ip)
|
||||
if err != nil {
|
||||
result["ip2location_asn"] = "error: " + err.Error()
|
||||
} else {
|
||||
clean := func(s string) string {
|
||||
if s == "" || strings.HasPrefix(s, "This parameter is unavailable") {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
result["ip2location_asn"] = map[string]string{
|
||||
"asn": clean(rec.Asn),
|
||||
"as": clean(rec.As),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result["ip2location_asn"] = "not loaded"
|
||||
}
|
||||
ip2locASNMu.RUnlock()
|
||||
|
||||
case "qqwry":
|
||||
qqwryMu.RLock()
|
||||
if qqwryDB != nil {
|
||||
@@ -727,3 +862,28 @@ func SearchIP(ip string, databases ...string) map[string]interface{} {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseIP2Region 将 ip2region xdb 的管道分隔串预分割为结构化 JSON。
|
||||
// 实测 v4/v6 xdb 格式:国家|省份|城市|ISP(或"0")|国家代码(5 段,v4 的 ISP 段多为 "0");
|
||||
// 兼容老 4 段格式:国家|省份|城市|ISP。
|
||||
// 输出字段与 bilibili / geocn / maxmind_city 对齐:country / administrative_area / city / isp。
|
||||
func parseIP2Region(s string) map[string]string {
|
||||
parts := strings.Split(s, "|")
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
get := func(i int) string {
|
||||
if i < len(parts) {
|
||||
return parts[i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
res := map[string]string{"country": get(0)}
|
||||
// 5 段:国家|省份|城市|ISP(可能为"0")|国家代码;4 段及以下:国家|省份|城市|ISP
|
||||
res["administrative_area"] = get(1)
|
||||
res["city"] = get(2)
|
||||
if isp := get(3); isp != "" && isp != "0" {
|
||||
res["isp"] = isp
|
||||
}
|
||||
return res
|
||||
}
|
||||
+161
-373
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -21,6 +19,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AdguardTeam/golibs/netutil/sysresolv"
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
@@ -85,10 +84,10 @@ func initHTTPClients() {
|
||||
|
||||
}
|
||||
|
||||
func fakePerfectWebsiteResult(host string) *WebsiteCheckDetail {
|
||||
func fakePerfectWebsiteResult(host string) *webtest.WebsiteCheckDetail {
|
||||
cleanHost := strings.TrimPrefix(host, "https://")
|
||||
cleanHost = strings.TrimPrefix(cleanHost, "http://")
|
||||
return &WebsiteCheckDetail{
|
||||
return &webtest.WebsiteCheckDetail{
|
||||
HostRecord: cleanHost,
|
||||
HTTPStatusCode: 200,
|
||||
HTTPSSStatusCode: 200,
|
||||
@@ -103,8 +102,8 @@ func fakePerfectWebsiteResult(host string) *WebsiteCheckDetail {
|
||||
}
|
||||
}
|
||||
|
||||
func fakeInvalidSSLResult(host string) *SSLCheckDetail {
|
||||
return &SSLCheckDetail{
|
||||
func fakeInvalidSSLResult(host string) *webtest.SSLCheckDetail {
|
||||
return &webtest.SSLCheckDetail{
|
||||
CertValidityDays: 0,
|
||||
IsExpired: true,
|
||||
CertStartTime: time.Time{},
|
||||
@@ -170,26 +169,6 @@ func (b *zstdReader) Close() error {
|
||||
})
|
||||
return b.closeErr
|
||||
}
|
||||
func cleanHostRecord(addr string) string {
|
||||
if strings.HasPrefix(addr, "[") {
|
||||
rightBracket := strings.Index(addr, "]")
|
||||
if rightBracket != -1 {
|
||||
return addr[1:rightBracket]
|
||||
}
|
||||
}
|
||||
|
||||
if idx := strings.LastIndex(addr, ":"); idx != -1 {
|
||||
colonCount := strings.Count(addr, ":")
|
||||
if colonCount > 1 {
|
||||
return addr[:idx]
|
||||
}
|
||||
if colonCount == 1 {
|
||||
return addr[:idx]
|
||||
}
|
||||
}
|
||||
|
||||
return addr
|
||||
}
|
||||
|
||||
// normalizeURL normalizes the input URL by ensuring it has a scheme (http or https).
|
||||
// normalizeURL 通过确保输入 URL 具有方案(http 或 https)来规范化输入 URL。
|
||||
@@ -236,28 +215,33 @@ func (s *Setting) PortString() string {
|
||||
// Global variables and structs
|
||||
// 全局变量与结构体
|
||||
var (
|
||||
PORTS string
|
||||
GH_PROXY string
|
||||
LOG_LEVEL string
|
||||
websiteCache sync.Map
|
||||
SINGLE_STACK string
|
||||
DNS_SERVER string
|
||||
sslCache sync.Map
|
||||
pingCache sync.Map
|
||||
speedCache sync.Map
|
||||
whoisCache sync.Map
|
||||
asnWhoisCache sync.Map
|
||||
sfGroup singleflight.Group
|
||||
V6Client *resty.Client
|
||||
V4Client *resty.Client
|
||||
IPDB string
|
||||
CORS string
|
||||
ACCEPT_DOMAINS []string
|
||||
ACCESS_TOKEN string
|
||||
REMOTE_CONFIG_URL string
|
||||
VERSION string
|
||||
COMMIT string
|
||||
BUILD_TIME string
|
||||
PORTS string
|
||||
GH_PROXY string
|
||||
LOG_LEVEL string
|
||||
websiteCache sync.Map
|
||||
SINGLE_STACK string
|
||||
DNS_SERVER string
|
||||
DNSSEC_DNS_SERVER string // DNSSEC 专用 DNS 服务器(dnssec-server / DNSSEC_DNS_SERVER),留空沿用 DNS_SERVER
|
||||
sslCache sync.Map
|
||||
pingCache sync.Map
|
||||
speedCache sync.Map
|
||||
whoisCache sync.Map
|
||||
asnWhoisCache sync.Map
|
||||
sfGroup singleflight.Group
|
||||
V6Client *resty.Client
|
||||
V4Client *resty.Client
|
||||
IPDB string
|
||||
CORS string
|
||||
ACCEPT_DOMAINS []string
|
||||
ACCESS_TOKEN string
|
||||
REMOTE_CONFIG_URL string
|
||||
REMOTE_IGNORE_CONFIG []string // 不被远端配置覆盖的配置项列表(remote-ignore-config / REMOTE_IGNORE_CONFIG)
|
||||
WS_URL string // WS 客户端:中间件 WS 地址(ws://host:port/ws)
|
||||
WS_NODE_ID string // WS 客户端:节点 id(与中间件 ws-keys 键一致)
|
||||
WS_NODE_KEY string // WS 客户端:注册 key(与中间件 ws-keys[节点id] 一致,可空)
|
||||
VERSION string
|
||||
COMMIT string
|
||||
BUILD_TIME string
|
||||
)
|
||||
|
||||
type websiteCacheEntry struct {
|
||||
@@ -276,7 +260,7 @@ type pingCacheEntry struct {
|
||||
}
|
||||
|
||||
type speedCacheEntry struct {
|
||||
result *WebsiteSpeedTestResult
|
||||
result *webtest.WebsiteSpeedTestResult
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
@@ -291,305 +275,22 @@ type asnWhoisCacheEntry struct {
|
||||
}
|
||||
|
||||
type WebsiteCheckResult struct {
|
||||
IPv4 *WebsiteCheckDetail `json:"ipv4"`
|
||||
IPv6 *WebsiteCheckDetail `json:"ipv6"`
|
||||
}
|
||||
|
||||
type WebsiteCheckDetail struct {
|
||||
HostRecord string `json:"host_record"`
|
||||
HTTPStatusCode int `json:"http_status_code"`
|
||||
HTTPSSStatusCode int `json:"https_status_code"`
|
||||
DNSLookupTime float64 `json:"dns_lookup_time"`
|
||||
TCPConnectTime float64 `json:"tcp_connect_time"`
|
||||
HTTPConnectTime float64 `json:"http_connect_time"`
|
||||
FirstByteTime float64 `json:"first_byte_time"`
|
||||
TotalTime float64 `json:"total_time"`
|
||||
PageSize int64 `json:"page_size"`
|
||||
DownloadSpeed float64 `json:"download_speed"`
|
||||
IsReachable bool `json:"is_reachable"`
|
||||
}
|
||||
|
||||
type SSLCheckDetail struct {
|
||||
CertValidityDays int `json:"cert_validity_days"`
|
||||
CertStartTime time.Time `json:"cert_start_time"`
|
||||
CertEndTime time.Time `json:"cert_end_time"`
|
||||
HTTPVersion string `json:"http_version"`
|
||||
HostRecord string `json:"host_record"`
|
||||
HTTPSSStatusCode int `json:"https_status_code"`
|
||||
TotalTime float64 `json:"total_time"`
|
||||
DownloadSpeed float64 `json:"download_speed"`
|
||||
Domain string `json:"domain"`
|
||||
IssuerOrganization []string `json:"issuer_organization"`
|
||||
IssuerCommonName string `json:"issuer_common_name"`
|
||||
SubjectCommonName string `json:"subject_common_name"`
|
||||
IsExpired bool `json:"is_expired"`
|
||||
IsReachable bool `json:"is_reachable"`
|
||||
IPv4 *webtest.WebsiteCheckDetail `json:"ipv4"`
|
||||
IPv6 *webtest.WebsiteCheckDetail `json:"ipv6"`
|
||||
}
|
||||
|
||||
type SSLCheckResult struct {
|
||||
IPv4 *SSLCheckDetail `json:"ipv4"`
|
||||
IPv6 *SSLCheckDetail `json:"ipv6"`
|
||||
IPv4 *webtest.SSLCheckDetail `json:"ipv4"`
|
||||
IPv6 *webtest.SSLCheckDetail `json:"ipv6"`
|
||||
}
|
||||
type TCPingResult struct {
|
||||
IPv4 *webtest.TCPingStats `json:"ipv4"`
|
||||
IPv6 *webtest.TCPingStats `json:"ipv6"`
|
||||
}
|
||||
type WebsiteSpeedTestResult struct {
|
||||
Version string `json:"version"`
|
||||
HostRecord string `json:"host_record"`
|
||||
HTTPStatusCode int `json:"http_status_code"`
|
||||
HTTPSSStatusCode int `json:"https_status_code"`
|
||||
DNSLookupTime float64 `json:"dns_lookup_time"`
|
||||
TCPConnectTime float64 `json:"tcp_connect_time"`
|
||||
HTTPConnectTime float64 `json:"http_connect_time"`
|
||||
FirstByteTime float64 `json:"first_byte_time"`
|
||||
TotalTime float64 `json:"total_time"`
|
||||
PageSize int64 `json:"page_size"`
|
||||
DownloadSpeed float64 `json:"download_speed"`
|
||||
Message string `json:"message"`
|
||||
Headers string `json:"headers"`
|
||||
IsReachable bool `json:"is_reachable"`
|
||||
}
|
||||
|
||||
// Business Endpoints
|
||||
// 业务端点
|
||||
|
||||
func checkWebsite(url string, version string) (*WebsiteCheckDetail, error) {
|
||||
ctx := context.Background()
|
||||
var err error
|
||||
ctx, err = ssrf.ValidateOutboundTarget(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := V4Client
|
||||
if version == "v6" {
|
||||
client = V6Client
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
resp, err := client.R().EnableTrace().SetContext(ctx).Get(url)
|
||||
|
||||
// HTTPS 请求失败时 fallback 到 HTTP
|
||||
fallbackToHTTP := false
|
||||
if err != nil && strings.HasPrefix(url, "https://") {
|
||||
httpURL := strings.Replace(url, "https://", "http://", 1)
|
||||
startTime = time.Now()
|
||||
resp, err = client.R().EnableTrace().SetContext(ctx).Get(httpURL)
|
||||
fallbackToHTTP = true
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endTime := time.Now()
|
||||
|
||||
body := resp.Bytes()
|
||||
trace := resp.Request.TraceInfo()
|
||||
|
||||
hostRecord := cleanHostRecord(trace.RemoteAddr)
|
||||
|
||||
dnsLookupTime := trace.DNSLookup.Seconds() * 1000
|
||||
if dnsLookupTime == 0 {
|
||||
dnsLookupTime = measureDNSTime(url, version)
|
||||
}
|
||||
tcpConnectTime := trace.TCPConnTime.Seconds() * 1000
|
||||
httpConnectTime := trace.ConnTime.Seconds() * 1000
|
||||
firstByteTime := trace.ServerTime.Seconds() * 1000
|
||||
|
||||
totalTime := float64(endTime.Sub(startTime).Milliseconds())
|
||||
var downloadSpeed float64
|
||||
if totalTime > 0 {
|
||||
downloadSpeed = float64(len(body)) / 1024.0 / (totalTime / 1000.0)
|
||||
}
|
||||
|
||||
httpStatus := resp.StatusCode()
|
||||
httpsStatus := resp.StatusCode()
|
||||
if fallbackToHTTP {
|
||||
httpsStatus = 0
|
||||
}
|
||||
|
||||
result := &WebsiteCheckDetail{
|
||||
HostRecord: hostRecord,
|
||||
HTTPStatusCode: httpStatus,
|
||||
HTTPSSStatusCode: httpsStatus,
|
||||
DNSLookupTime: dnsLookupTime,
|
||||
TCPConnectTime: tcpConnectTime,
|
||||
HTTPConnectTime: httpConnectTime,
|
||||
FirstByteTime: firstByteTime,
|
||||
TotalTime: totalTime,
|
||||
PageSize: int64(len(body)),
|
||||
DownloadSpeed: downloadSpeed,
|
||||
IsReachable: true,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func measureDNSTime(urlStr string, version string) float64 {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return 0
|
||||
}
|
||||
start := time.Now()
|
||||
var dnsErr error
|
||||
if version == "v6" {
|
||||
_, dnsErr = webtest.ResolveAAAARecord(host)
|
||||
} else {
|
||||
_, dnsErr = webtest.ResolveARecord(host)
|
||||
}
|
||||
if dnsErr != nil {
|
||||
return 0
|
||||
}
|
||||
return time.Since(start).Seconds() * 1000
|
||||
}
|
||||
|
||||
func websiteSpeed(url string, version string) (*WebsiteSpeedTestResult, error) {
|
||||
ctx := context.Background()
|
||||
var err error
|
||||
ctx, err = ssrf.ValidateOutboundTarget(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := V4Client
|
||||
if version == "v6" {
|
||||
client = V6Client
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
resp, err := client.R().EnableTrace().SetContext(ctx).Get(url)
|
||||
|
||||
fallbackToHTTP := false
|
||||
if err != nil && strings.HasPrefix(url, "https://") {
|
||||
httpURL := strings.Replace(url, "https://", "http://", 1)
|
||||
startTime = time.Now()
|
||||
resp, err = client.R().EnableTrace().SetContext(ctx).Get(httpURL)
|
||||
fallbackToHTTP = true
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endTime := time.Now()
|
||||
|
||||
body := resp.Bytes()
|
||||
trace := resp.Request.TraceInfo()
|
||||
|
||||
hostRecord := cleanHostRecord(trace.RemoteAddr)
|
||||
|
||||
dnsLookupTime := trace.DNSLookup.Seconds() * 1000
|
||||
if dnsLookupTime == 0 {
|
||||
dnsLookupTime = measureDNSTime(url, version)
|
||||
}
|
||||
tcpConnectTime := trace.TCPConnTime.Seconds() * 1000
|
||||
httpConnectTime := trace.ConnTime.Seconds() * 1000
|
||||
firstByteTime := trace.ServerTime.Seconds() * 1000
|
||||
|
||||
totalTime := float64(endTime.Sub(startTime).Milliseconds())
|
||||
var downloadSpeed float64
|
||||
if totalTime > 0 {
|
||||
downloadSpeed = float64(len(body)) / 1024.0 / (totalTime / 1000.0)
|
||||
}
|
||||
dumpBytes, _ := httputil.DumpResponse(resp.RawResponse, false)
|
||||
httpStatus := resp.StatusCode()
|
||||
httpsStatus := resp.StatusCode()
|
||||
if fallbackToHTTP {
|
||||
httpsStatus = 0
|
||||
}
|
||||
result := &WebsiteSpeedTestResult{
|
||||
Version: version,
|
||||
Headers: string(dumpBytes),
|
||||
HostRecord: hostRecord,
|
||||
HTTPStatusCode: httpStatus,
|
||||
HTTPSSStatusCode: httpsStatus,
|
||||
DNSLookupTime: dnsLookupTime,
|
||||
TCPConnectTime: tcpConnectTime,
|
||||
HTTPConnectTime: httpConnectTime,
|
||||
FirstByteTime: firstByteTime,
|
||||
TotalTime: totalTime,
|
||||
PageSize: int64(len(body)),
|
||||
DownloadSpeed: downloadSpeed,
|
||||
IsReachable: true,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func checkSSL(url string, version string) (*SSLCheckDetail, error) {
|
||||
ctx := context.Background()
|
||||
var err error
|
||||
ctx, err = ssrf.ValidateOutboundTarget(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := V4Client
|
||||
if version == "v6" {
|
||||
client = V6Client
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
resp, err := client.R().EnableTrace().SetContext(ctx).Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endTime := time.Now()
|
||||
|
||||
trace := resp.Request.TraceInfo()
|
||||
hostRecord := cleanHostRecord(trace.RemoteAddr)
|
||||
|
||||
totalTime := float64(endTime.Sub(startTime).Milliseconds())
|
||||
body := resp.Bytes()
|
||||
var downloadSpeed float64
|
||||
if totalTime > 0 {
|
||||
downloadSpeed = float64(len(body)) / 1024.0 / (totalTime / 1000.0)
|
||||
}
|
||||
|
||||
rawResp := resp.RawResponse
|
||||
var cert *x509.Certificate
|
||||
var remainingDays int
|
||||
var isExpired bool
|
||||
var issuerOrganization []string
|
||||
var issuerCommonName, subjectCommonName, domain string
|
||||
|
||||
if rawResp.TLS != nil && len(rawResp.TLS.PeerCertificates) > 0 {
|
||||
cert = rawResp.TLS.PeerCertificates[0]
|
||||
now := time.Now()
|
||||
remainingDays = int(cert.NotAfter.Sub(now).Hours() / 24)
|
||||
isExpired = now.After(cert.NotAfter) || now.Before(cert.NotBefore)
|
||||
issuerOrganization = cert.Issuer.Organization
|
||||
issuerCommonName = cert.Issuer.CommonName
|
||||
subjectCommonName = cert.Subject.CommonName
|
||||
domain = cleanHostRecord(cert.Subject.CommonName)
|
||||
} else {
|
||||
return nil, fmt.Errorf("no SSL certificate found")
|
||||
}
|
||||
|
||||
result := &SSLCheckDetail{
|
||||
CertValidityDays: remainingDays,
|
||||
IsExpired: isExpired,
|
||||
CertStartTime: cert.NotBefore,
|
||||
CertEndTime: cert.NotAfter,
|
||||
HTTPVersion: resp.Proto(),
|
||||
HostRecord: hostRecord,
|
||||
HTTPSSStatusCode: resp.StatusCode(),
|
||||
TotalTime: totalTime,
|
||||
DownloadSpeed: downloadSpeed,
|
||||
Domain: domain,
|
||||
IssuerOrganization: issuerOrganization,
|
||||
IssuerCommonName: issuerCommonName,
|
||||
SubjectCommonName: subjectCommonName,
|
||||
IsReachable: true,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func checkWebsiteHandler(c *gin.Context) {
|
||||
testUrl := c.Param("url")
|
||||
if testUrl == "" {
|
||||
@@ -623,28 +324,28 @@ func checkWebsiteHandler(c *gin.Context) {
|
||||
result := &WebsiteCheckResult{}
|
||||
switch SINGLE_STACK {
|
||||
case "ipv4":
|
||||
ipv4, errV4 := checkWebsite(testUrl, "v4")
|
||||
ipv4, errV4 := webtest.CheckWebsite(testUrl, "v4")
|
||||
if errV4 != nil {
|
||||
ipv4 = &WebsiteCheckDetail{
|
||||
ipv4 = &webtest.WebsiteCheckDetail{
|
||||
HostRecord: "Error: " + errV4.Error(),
|
||||
IsReachable: false,
|
||||
}
|
||||
}
|
||||
result.IPv4 = ipv4
|
||||
result.IPv6 = &WebsiteCheckDetail{
|
||||
result.IPv6 = &webtest.WebsiteCheckDetail{
|
||||
HostRecord: "Skipped due to SINGLE_STACK=ipv4",
|
||||
IsReachable: false,
|
||||
}
|
||||
case "ipv6":
|
||||
ipv6, errV6 := checkWebsite(testUrl, "v6")
|
||||
ipv6, errV6 := webtest.CheckWebsite(testUrl, "v6")
|
||||
if errV6 != nil {
|
||||
ipv6 = &WebsiteCheckDetail{
|
||||
ipv6 = &webtest.WebsiteCheckDetail{
|
||||
HostRecord: "Error: " + errV6.Error(),
|
||||
IsReachable: false,
|
||||
}
|
||||
}
|
||||
result.IPv6 = ipv6
|
||||
result.IPv4 = &WebsiteCheckDetail{
|
||||
result.IPv4 = &webtest.WebsiteCheckDetail{
|
||||
HostRecord: "Skipped due to SINGLE_STACK=ipv6",
|
||||
IsReachable: false,
|
||||
}
|
||||
@@ -654,9 +355,9 @@ func checkWebsiteHandler(c *gin.Context) {
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ipv6, errV6 := checkWebsite(testUrl, "v6")
|
||||
ipv6, errV6 := webtest.CheckWebsite(testUrl, "v6")
|
||||
if errV6 != nil {
|
||||
ipv6 = &WebsiteCheckDetail{
|
||||
ipv6 = &webtest.WebsiteCheckDetail{
|
||||
HostRecord: "Error: " + errV6.Error(),
|
||||
IsReachable: false,
|
||||
}
|
||||
@@ -666,9 +367,9 @@ func checkWebsiteHandler(c *gin.Context) {
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ipv4, errV4 := checkWebsite(testUrl, "v4")
|
||||
ipv4, errV4 := webtest.CheckWebsite(testUrl, "v4")
|
||||
if errV4 != nil {
|
||||
ipv4 = &WebsiteCheckDetail{
|
||||
ipv4 = &webtest.WebsiteCheckDetail{
|
||||
HostRecord: "Error: " + errV4.Error(),
|
||||
IsReachable: false,
|
||||
}
|
||||
@@ -708,7 +409,7 @@ func websiteSpeedTestHandler(c *gin.Context) {
|
||||
switch SINGLE_STACK {
|
||||
case "ipv4":
|
||||
if version != "v4" {
|
||||
c.JSON(http.StatusBadRequest, &WebsiteSpeedTestResult{
|
||||
c.JSON(http.StatusBadRequest, &webtest.WebsiteSpeedTestResult{
|
||||
Version: "v4",
|
||||
HostRecord: "Skipped due to SINGLE_STACK=ipv4",
|
||||
})
|
||||
@@ -716,7 +417,7 @@ func websiteSpeedTestHandler(c *gin.Context) {
|
||||
}
|
||||
case "ipv6":
|
||||
if version != "v6" {
|
||||
c.JSON(http.StatusBadRequest, &WebsiteSpeedTestResult{
|
||||
c.JSON(http.StatusBadRequest, &webtest.WebsiteSpeedTestResult{
|
||||
Version: "v6",
|
||||
HostRecord: "Skipped due to SINGLE_STACK=ipv6",
|
||||
})
|
||||
@@ -737,14 +438,14 @@ func websiteSpeedTestHandler(c *gin.Context) {
|
||||
speedCache.Delete(cacheKey)
|
||||
}
|
||||
|
||||
var result *WebsiteSpeedTestResult
|
||||
var result *webtest.WebsiteSpeedTestResult
|
||||
|
||||
switch version {
|
||||
case "v6", "v4":
|
||||
rawResult, _, _ := sfGroup.Do(cacheKey, func() (interface{}, error) {
|
||||
r, e := websiteSpeed(url, version)
|
||||
r, e := webtest.SpeedTest(url, version)
|
||||
if e != nil {
|
||||
errorResult := &WebsiteSpeedTestResult{
|
||||
errorResult := &webtest.WebsiteSpeedTestResult{
|
||||
HostRecord: "Error: " + e.Error(),
|
||||
}
|
||||
speedCache.Store(cacheKey, speedCacheEntry{result: errorResult, timestamp: time.Now()})
|
||||
@@ -757,7 +458,7 @@ func websiteSpeedTestHandler(c *gin.Context) {
|
||||
speedCache.Store(cacheKey, speedCacheEntry{result: r, timestamp: time.Now()})
|
||||
return r, nil
|
||||
})
|
||||
result = rawResult.(*WebsiteSpeedTestResult)
|
||||
result = rawResult.(*webtest.WebsiteSpeedTestResult)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid version",
|
||||
@@ -801,31 +502,31 @@ func sslCheckHandler(c *gin.Context) {
|
||||
result := &SSLCheckResult{}
|
||||
switch SINGLE_STACK {
|
||||
case "ipv4":
|
||||
ipv4, errV4 := checkSSL(testUrl, "v4")
|
||||
ipv4, errV4 := webtest.CheckSSL(testUrl, "v4")
|
||||
if errV4 != nil {
|
||||
ipv4 = &SSLCheckDetail{
|
||||
ipv4 = &webtest.SSLCheckDetail{
|
||||
HostRecord: "Error: " + errV4.Error(),
|
||||
IsExpired: true,
|
||||
IsReachable: false,
|
||||
}
|
||||
}
|
||||
result.IPv4 = ipv4
|
||||
result.IPv6 = &SSLCheckDetail{
|
||||
result.IPv6 = &webtest.SSLCheckDetail{
|
||||
HostRecord: "Skipped due to SINGLE_STACK=ipv4",
|
||||
IsExpired: true,
|
||||
IsReachable: false,
|
||||
}
|
||||
case "ipv6":
|
||||
ipv6, errV6 := checkSSL(testUrl, "v6")
|
||||
ipv6, errV6 := webtest.CheckSSL(testUrl, "v6")
|
||||
if errV6 != nil {
|
||||
ipv6 = &SSLCheckDetail{
|
||||
ipv6 = &webtest.SSLCheckDetail{
|
||||
HostRecord: "Error: " + errV6.Error(),
|
||||
IsExpired: true,
|
||||
IsReachable: false,
|
||||
}
|
||||
}
|
||||
result.IPv6 = ipv6
|
||||
result.IPv4 = &SSLCheckDetail{
|
||||
result.IPv4 = &webtest.SSLCheckDetail{
|
||||
HostRecord: "Skipped due to SINGLE_STACK=ipv6",
|
||||
IsExpired: true,
|
||||
IsReachable: false,
|
||||
@@ -836,9 +537,9 @@ func sslCheckHandler(c *gin.Context) {
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ipv6, errV6 := checkSSL(testUrl, "v6")
|
||||
ipv6, errV6 := webtest.CheckSSL(testUrl, "v6")
|
||||
if errV6 != nil {
|
||||
ipv6 = &SSLCheckDetail{
|
||||
ipv6 = &webtest.SSLCheckDetail{
|
||||
HostRecord: "Error: " + errV6.Error(),
|
||||
IsExpired: true,
|
||||
IsReachable: false,
|
||||
@@ -849,9 +550,9 @@ func sslCheckHandler(c *gin.Context) {
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ipv4, errV4 := checkSSL(testUrl, "v4")
|
||||
ipv4, errV4 := webtest.CheckSSL(testUrl, "v4")
|
||||
if errV4 != nil {
|
||||
ipv4 = &SSLCheckDetail{
|
||||
ipv4 = &webtest.SSLCheckDetail{
|
||||
HostRecord: "Error: " + errV4.Error(),
|
||||
IsExpired: true,
|
||||
IsReachable: false,
|
||||
@@ -899,12 +600,23 @@ func asnLookupHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result := ipdb.SearchIP(ip, "maxmind_asn", "dbip_asn")
|
||||
result := ipdb.SearchIP(ip, "maxmind_asn", "dbip_asn", "ip2location_asn")
|
||||
|
||||
asnResult := map[string]interface{}{
|
||||
"ip": ip,
|
||||
}
|
||||
|
||||
if ip2locASN, ok := result["ip2location_asn"].(map[string]string); ok && ip2locASN["asn"] != "" {
|
||||
asnResult["ip2location_asn"] = map[string]string{
|
||||
"asn": ip2locASN["asn"],
|
||||
"as": ip2locASN["as"],
|
||||
}
|
||||
} else if errStr, ok := result["ip2location_asn"].(string); ok {
|
||||
asnResult["ip2location_asn"] = map[string]string{
|
||||
"error": errStr,
|
||||
}
|
||||
}
|
||||
|
||||
if maxmindASN, ok := result["maxmind_asn"].(*ipdb.MMDBASNResult); ok {
|
||||
asnResult["geolite2_asn"] = map[string]string{
|
||||
"asn": maxmindASN.ASN,
|
||||
@@ -1229,6 +941,7 @@ func tokenCheck() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fetchRemoteConfig 从远端 URL 拉取配置文件(遵守 setting.json 的 JSON 格式)。
|
||||
// 拉取或解析失败时返回错误,调用方应回退到本地配置。
|
||||
func fetchRemoteConfig(url string) (map[string]any, error) {
|
||||
@@ -1274,7 +987,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 == "" {
|
||||
@@ -1285,29 +998,51 @@ func applyRemoteConfig() {
|
||||
slog.Warn("Failed to fetch remote config, falling back to local config", "url", url, "error", err)
|
||||
return
|
||||
}
|
||||
if v := configValue(CONFIG, "port"); v != "" {
|
||||
// ignore 列表:数组中的配置项不被远端覆盖(逐键判断跳过)
|
||||
ignored := func(key string) bool {
|
||||
for _, k := range REMOTE_IGNORE_CONFIG {
|
||||
if k == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if v := configValue(CONFIG, "port"); v != "" && !ignored("port") {
|
||||
PORTS = v
|
||||
}
|
||||
if v := configValue(CONFIG, "gh-proxy"); v != "" {
|
||||
if v := configValue(CONFIG, "gh-proxy"); v != "" && !ignored("gh-proxy") {
|
||||
GH_PROXY = v
|
||||
}
|
||||
if v := configValue(CONFIG, "single-stack"); v != "" {
|
||||
if v := configValue(CONFIG, "single-stack"); v != "" && !ignored("single-stack") {
|
||||
SINGLE_STACK = strings.ToLower(v)
|
||||
}
|
||||
if v := configValue(CONFIG, "dns-server"); v != "" {
|
||||
if v := configValue(CONFIG, "dns-server"); v != "" && !ignored("dns-server") {
|
||||
DNS_SERVER = v
|
||||
}
|
||||
if v := configValue(CONFIG, "ipdb"); v != "" {
|
||||
if v := configValue(CONFIG, "dnssec-server"); v != "" && !ignored("dnssec-server") {
|
||||
DNSSEC_DNS_SERVER = v
|
||||
}
|
||||
if v := configValue(CONFIG, "ipdb"); v != "" && !ignored("ipdb") {
|
||||
IPDB = v
|
||||
}
|
||||
if v := configValue(CONFIG, "cors"); v != "" {
|
||||
if v := configValue(CONFIG, "cors"); v != "" && !ignored("cors") {
|
||||
CORS = v
|
||||
}
|
||||
// block-private-ips 与 setting.json 格式一致,允许远端覆盖
|
||||
if v := configValue(CONFIG, "block-private-ips"); v != "" {
|
||||
if v := configValue(CONFIG, "block-private-ips"); v != "" && !ignored("block-private-ips") {
|
||||
ssrf.SetEnabled(v != "false" && v != "0")
|
||||
}
|
||||
// access_token 不在此覆盖:保持原有优先级(环境变量 > setting.json)
|
||||
// WS 客户端配置(远端可覆盖,除非在 ignore 列表中)
|
||||
if v := configValue(CONFIG, "ws-url"); v != "" && !ignored("ws-url") {
|
||||
WS_URL = v
|
||||
}
|
||||
if v := configValue(CONFIG, "node-id"); v != "" && !ignored("node-id") {
|
||||
WS_NODE_ID = v
|
||||
}
|
||||
if v := configValue(CONFIG, "node-key"); v != "" && !ignored("node-key") {
|
||||
WS_NODE_KEY = v
|
||||
}
|
||||
// access-token 不在此覆盖:保持原有优先级(环境变量 > setting.json)
|
||||
if CORS != "" {
|
||||
ACCEPT_DOMAINS = strings.Split(CORS, ",")
|
||||
}
|
||||
@@ -1322,6 +1057,7 @@ func readConfig() {
|
||||
// 如果当前测速节点机器是单栈网络,建议设置 SINGLE_STACK 环境变量来跳过另一个协议的测试,以避免不必要的错误日志和延迟
|
||||
SINGLE_STACK = strings.ToLower(strings.TrimSpace(os.Getenv("SINGLE_STACK")))
|
||||
DNS_SERVER = os.Getenv("DNS_SERVER")
|
||||
DNSSEC_DNS_SERVER = os.Getenv("DNSSEC_DNS_SERVER")
|
||||
IPDB = os.Getenv("IPDB")
|
||||
CORS = os.Getenv("CORS")
|
||||
ACCESS_TOKEN = os.Getenv("ACCESS_TOKEN")
|
||||
@@ -1347,6 +1083,9 @@ func readConfig() {
|
||||
if DNS_SERVER == "" {
|
||||
DNS_SERVER = viper.GetString("dns-server")
|
||||
}
|
||||
if DNSSEC_DNS_SERVER == "" {
|
||||
DNSSEC_DNS_SERVER = viper.GetString("dnssec-server")
|
||||
}
|
||||
if IPDB == "" {
|
||||
IPDB = viper.GetString("ipdb")
|
||||
}
|
||||
@@ -1360,14 +1099,54 @@ 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 通道):WS_URL / NODE_ID / NODE_KEY,env 优先
|
||||
WS_URL = os.Getenv("WS_URL")
|
||||
if WS_URL == "" {
|
||||
WS_URL = viper.GetString("ws-url")
|
||||
}
|
||||
WS_NODE_ID = os.Getenv("NODE_ID")
|
||||
if WS_NODE_ID == "" {
|
||||
WS_NODE_ID = viper.GetString("node-id")
|
||||
}
|
||||
WS_NODE_KEY = os.Getenv("NODE_KEY")
|
||||
if WS_NODE_KEY == "" {
|
||||
WS_NODE_KEY = viper.GetString("node-key")
|
||||
}
|
||||
// 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_IGNORE_CONFIG = list
|
||||
} else {
|
||||
slog.Warn("invalid REMOTE_IGNORE_CONFIG, ignored", "error", err)
|
||||
}
|
||||
} else {
|
||||
REMOTE_IGNORE_CONFIG = viper.GetStringSlice("remote-ignore-config")
|
||||
}
|
||||
applyRemoteConfig()
|
||||
var SYSTEM_DNS string
|
||||
if DNS_SERVER == "" || DNSSEC_DNS_SERVER == "" {
|
||||
sysDNS, err := sysresolv.NewSystemResolvers(nil, 53)
|
||||
if err != nil {
|
||||
slog.Warn("Cannot setup reslovers")
|
||||
|
||||
}
|
||||
sysDNS.Refresh()
|
||||
SYSTEM_DNS = webtest.AddrPortsToCSV(sysDNS.Addrs())
|
||||
}
|
||||
if DNSSEC_DNS_SERVER == "" {
|
||||
DNSSEC_DNS_SERVER = SYSTEM_DNS
|
||||
}
|
||||
if DNS_SERVER == "" {
|
||||
DNS_SERVER = SYSTEM_DNS
|
||||
}
|
||||
slog.Info("SSRF protection initialized", "blockPrivateIPs", ssrf.Enabled())
|
||||
}
|
||||
|
||||
@@ -1384,13 +1163,22 @@ func main() {
|
||||
slog.Info("LEMON IPW TEST NODE GOLANG VERSION", "version", VERSION, "commit", COMMIT, "build_time", BUILD_TIME)
|
||||
readConfig()
|
||||
webtest.SetDNSServer(DNS_SERVER)
|
||||
webtest.SetDNSSecServer(DNSSEC_DNS_SERVER)
|
||||
initHTTPClients()
|
||||
// 注入出站 HTTP 客户端到 webtest(探针函数内部按版本取用)
|
||||
webtest.SetHTTPClient(V4Client, V6Client)
|
||||
if IPDB != "false" {
|
||||
ipdb.Init(GH_PROXY)
|
||||
}
|
||||
|
||||
slog.Info("Starting server", "port", PORTS, "gh_proxy", GH_PROXY, "single_stack", SINGLE_STACK, "dns_server", DNS_SERVER, "CORS_ACCEPT", ACCEPT_DOMAINS)
|
||||
|
||||
// WS 客户端:接入中间件 WS 通道(配置 WS_URL 时启用,HTTP 接口不变)
|
||||
if WS_URL != "" {
|
||||
go wsClientLoop()
|
||||
slog.Info("WS client enabled", "url", WS_URL, "nodeId", WS_NODE_ID)
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
corsConfig := cors.DefaultConfig()
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
"gh-proxy": "",
|
||||
"single-stack": "",
|
||||
"dns-server": "",
|
||||
"dnssec-server": "",
|
||||
"block-private-ips": true,
|
||||
"ipdb": true,
|
||||
"ipdb": "false",
|
||||
"cors": "",
|
||||
"remote-config-url": "",
|
||||
"access_token": ""
|
||||
"remote-ignore-config": [],
|
||||
"access-token": "",
|
||||
"ws-url": "",
|
||||
"node-id": "",
|
||||
"node-key": ""
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,20 @@
|
||||
package webtest
|
||||
|
||||
import "resty.dev/v3"
|
||||
|
||||
// 出站 HTTP 客户端(定义与初始化仍在 main.go,启动时经 SetHTTPClient 注入)
|
||||
var v4Client, v6Client *resty.Client
|
||||
|
||||
// SetHTTPClient 注入 v4 / v6 出站客户端(main.go 启动时调用)
|
||||
func SetHTTPClient(v4, v6 *resty.Client) {
|
||||
v4Client = v4
|
||||
v6Client = v6
|
||||
}
|
||||
|
||||
// httpClient 按版本返回出站客户端
|
||||
func httpClient(version string) *resty.Client {
|
||||
if version == "v6" {
|
||||
return v6Client
|
||||
}
|
||||
return v4Client
|
||||
}
|
||||
@@ -14,39 +14,84 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
dnsServer = "119.28.28.28:53"
|
||||
// dnsServers 普通 DNS 查询服务器列表(主从:第一个为主,主失败自动切换后续从服务器)
|
||||
dnsServers = []string{"119.28.28.28:53"}
|
||||
// dnssecServers DNSSEC 专用服务器列表(未配置时沿用 dnsServers)
|
||||
dnssecServers []string
|
||||
)
|
||||
|
||||
// SetDNSServer 设置DNS服务器地址(格式: "ip:port",也支持 DoH URL http(s)://...)
|
||||
// SetDNSServer 设置DNS服务器地址(逗号分隔多地址:第一个为主,主失败自动切换后续从服务器)。
|
||||
// 每项支持 "ip:port"(UDP)或 DoH URL http(s)://...
|
||||
func SetDNSServer(server string) {
|
||||
if server != "" {
|
||||
dnsServer = server
|
||||
if list := splitServers(server); len(list) > 0 {
|
||||
dnsServers = list
|
||||
}
|
||||
}
|
||||
|
||||
// SetDNSSecServer 设置DNSSEC专用DNS服务器(逗号分隔主从;留空 = 沿用 dns-server 配置)
|
||||
func SetDNSSecServer(server string) {
|
||||
if list := splitServers(server); len(list) > 0 {
|
||||
dnssecServers = list
|
||||
}
|
||||
}
|
||||
|
||||
// splitServers 逗号分隔解析为地址列表(去空白、去空项)
|
||||
func splitServers(s string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const (
|
||||
defaultUDPServer = "119.28.28.28:53"
|
||||
defaultDoHEndpoint = "https://doh.pub/dns-query"
|
||||
)
|
||||
|
||||
// queryDNS 统一 DNS 查询入口:支持 DoH 与 UDP 双通道自动互备。
|
||||
// dnsServer 配置为 URL(http/https)时优先 DoH,失败回退 UDP(defaultUDPServer);
|
||||
// 配置为 ip:port 时优先 UDP,失败回退 DoH(defaultDoHEndpoint)。
|
||||
// queryDNS 普通 DNS 查询:主从 failover——按配置顺序依次尝试每个服务器,
|
||||
// 当前服务器查询失败(网络错误/超时)自动切换下一个;全部失败返回最后一个错误。
|
||||
func queryDNS(msg *dns.Msg) (*dns.Msg, error) {
|
||||
if strings.HasPrefix(dnsServer, "http://") || strings.HasPrefix(dnsServer, "https://") {
|
||||
resp, err := queryDoHMsg(msg, dnsServer)
|
||||
return queryWithServers(msg, dnsServers)
|
||||
}
|
||||
|
||||
// queryDNSSEC DNSSEC 查询:优先使用专用服务器(dnssec-server),未配置则沿用普通 dns-server
|
||||
func queryDNSSEC(msg *dns.Msg) (*dns.Msg, error) {
|
||||
if len(dnssecServers) > 0 {
|
||||
return queryWithServers(msg, dnssecServers)
|
||||
}
|
||||
return queryWithServers(msg, dnsServers)
|
||||
}
|
||||
|
||||
// queryWithServers 依次尝试服务器列表,主失败切从
|
||||
func queryWithServers(msg *dns.Msg, servers []string) (*dns.Msg, error) {
|
||||
if len(servers) == 0 {
|
||||
servers = []string{defaultUDPServer}
|
||||
}
|
||||
var lastErr error
|
||||
for i, srv := range servers {
|
||||
resp, err := queryOne(msg, srv)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
slog.Warn("DoH query failed, falling back to UDP", "endpoint", dnsServer, "error", err)
|
||||
return queryUDPMsg(msg, defaultUDPServer)
|
||||
lastErr = err
|
||||
if i < len(servers)-1 {
|
||||
slog.Warn("DNS query failed, switching to next server", "server", srv, "error", err)
|
||||
}
|
||||
}
|
||||
resp, err := queryUDPMsg(msg, dnsServer)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
slog.Warn("all DNS servers failed", "servers", servers, "error", lastErr)
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// queryOne 向单个 DNS 服务器查询:配置为 URL(http/https)走 DoH,否则走 UDP
|
||||
func queryOne(msg *dns.Msg, server string) (*dns.Msg, error) {
|
||||
if strings.HasPrefix(server, "http://") || strings.HasPrefix(server, "https://") {
|
||||
return queryDoHMsg(msg, server)
|
||||
}
|
||||
slog.Warn("UDP query failed, falling back to DoH", "server", dnsServer, "error", err)
|
||||
return queryDoHMsg(msg, defaultDoHEndpoint)
|
||||
return queryUDPMsg(msg, server)
|
||||
}
|
||||
|
||||
// queryUDPMsg 通过 UDP/TCP 向指定 DNS 服务器发送查询(miekg/dns 自动处理大响应切 TCP)
|
||||
@@ -41,7 +41,7 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
|
||||
msgDNSKEY.SetQuestion(dns.Fqdn(domain), dns.TypeDNSKEY)
|
||||
msgDNSKEY.SetEdns0(4096, true)
|
||||
|
||||
responseDNSKEY, err := queryDNS(msgDNSKEY)
|
||||
responseDNSKEY, err := queryDNSSEC(msgDNSKEY)
|
||||
result.Duration = time.Since(start).Seconds() * 1000
|
||||
|
||||
if err != nil {
|
||||
@@ -74,7 +74,7 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
|
||||
msgA.SetQuestion(dns.Fqdn(domain), dns.TypeA)
|
||||
msgA.SetEdns0(4096, true)
|
||||
|
||||
responseA, err := queryDNS(msgA)
|
||||
responseA, err := queryDNSSEC(msgA)
|
||||
if err == nil && responseA.Rcode == dns.RcodeSuccess {
|
||||
var aRRset []dns.RR
|
||||
for _, ans := range responseA.Answer {
|
||||
@@ -117,7 +117,7 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
|
||||
// 4. 查询 DS 记录
|
||||
msgDS := new(dns.Msg)
|
||||
msgDS.SetQuestion(dns.Fqdn(domain), dns.TypeDS)
|
||||
responseDS, _ := queryDNS(msgDS)
|
||||
responseDS, _ := queryDNSSEC(msgDS)
|
||||
if responseDS != nil && responseDS.Rcode == dns.RcodeSuccess {
|
||||
for _, ans := range responseDS.Answer {
|
||||
if _, ok := ans.(*dns.DS); ok {
|
||||
@@ -159,7 +159,7 @@ func ResolveDNSSECForRecord(domain string, recordType uint16) (*DNSSECResult, er
|
||||
msg.SetQuestion(dns.Fqdn(domain), recordType)
|
||||
msg.SetEdns0(4096, true)
|
||||
|
||||
response, err := queryDNS(msg)
|
||||
response, err := queryDNSSEC(msg)
|
||||
result.Duration = time.Since(start).Seconds() * 1000
|
||||
|
||||
if err != nil {
|
||||
@@ -204,7 +204,7 @@ func ResolveDNSSECForRecord(domain string, recordType uint16) (*DNSSECResult, er
|
||||
msgDNSKEY.SetQuestion(dns.Fqdn(domain), dns.TypeDNSKEY)
|
||||
msgDNSKEY.SetEdns0(4096, true)
|
||||
|
||||
responseDNSKEY, err := queryDNS(msgDNSKEY)
|
||||
responseDNSKEY, err := queryDNSSEC(msgDNSKEY)
|
||||
if err == nil && responseDNSKEY.Rcode == dns.RcodeSuccess {
|
||||
var dnskeyRRset []dns.RR
|
||||
for _, ans := range responseDNSKEY.Answer {
|
||||
@@ -237,7 +237,7 @@ func ResolveDNSSECForRecord(domain string, recordType uint16) (*DNSSECResult, er
|
||||
// 查询 DS 记录
|
||||
msgDS := new(dns.Msg)
|
||||
msgDS.SetQuestion(dns.Fqdn(domain), dns.TypeDS)
|
||||
responseDS, _ := queryDNS(msgDS)
|
||||
responseDS, _ := queryDNSSEC(msgDS)
|
||||
if responseDS != nil && responseDS.Rcode == dns.RcodeSuccess {
|
||||
for _, ans := range responseDS.Answer {
|
||||
if _, ok := ans.(*dns.DS); ok {
|
||||
@@ -0,0 +1,55 @@
|
||||
package webtest
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CleanHostRecord 从 trace 的 RemoteAddr 提取纯主机名(兼容 IPv6 方括号)
|
||||
func CleanHostRecord(addr string) string {
|
||||
if strings.HasPrefix(addr, "[") {
|
||||
rightBracket := strings.Index(addr, "]")
|
||||
if rightBracket != -1 {
|
||||
return addr[1:rightBracket]
|
||||
}
|
||||
}
|
||||
if idx := strings.LastIndex(addr, ":"); idx != -1 {
|
||||
return addr[:idx]
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
// measureDNSTime 当 trace 未提供 DNS 耗时时的兜底测量
|
||||
func measureDNSTime(urlStr string, version string) float64 {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return 0
|
||||
}
|
||||
start := time.Now()
|
||||
var dnsErr error
|
||||
if version == "v6" {
|
||||
_, dnsErr = ResolveAAAARecord(host)
|
||||
} else {
|
||||
_, dnsErr = ResolveARecord(host)
|
||||
}
|
||||
if dnsErr != nil {
|
||||
return 0
|
||||
}
|
||||
return time.Since(start).Seconds() * 1000
|
||||
}
|
||||
func AddrPortsToCSV(addrs []netip.AddrPort) string {
|
||||
if len(addrs) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, len(addrs))
|
||||
for i, addr := range addrs {
|
||||
parts[i] = addr.String() // 自动格式化为 "IP:Port"
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package webtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httputil"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
lemon_ssrf "lemon-ipw/ssrf"
|
||||
)
|
||||
|
||||
// SpeedTest 网站测速(speed)。客户端由 main.go 经 SetHTTPClient 注入。
|
||||
func SpeedTest(url string, version string) (*WebsiteSpeedTestResult, error) {
|
||||
ctx := context.Background()
|
||||
var err error
|
||||
ctx, err = lemon_ssrf.ValidateOutboundTarget(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
resp, err := httpClient(version).R().EnableTrace().SetContext(ctx).Get(url)
|
||||
|
||||
fallbackToHTTP := false
|
||||
if err != nil && strings.HasPrefix(url, "https://") {
|
||||
httpURL := strings.Replace(url, "https://", "http://", 1)
|
||||
startTime = time.Now()
|
||||
resp, err = httpClient(version).R().EnableTrace().SetContext(ctx).Get(httpURL)
|
||||
fallbackToHTTP = true
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endTime := time.Now()
|
||||
|
||||
body := resp.Bytes()
|
||||
trace := resp.Request.TraceInfo()
|
||||
|
||||
hostRecord := CleanHostRecord(trace.RemoteAddr)
|
||||
|
||||
dnsLookupTime := trace.DNSLookup.Seconds() * 1000
|
||||
if dnsLookupTime == 0 {
|
||||
dnsLookupTime = measureDNSTime(url, version)
|
||||
}
|
||||
tcpConnectTime := trace.TCPConnTime.Seconds() * 1000
|
||||
httpConnectTime := trace.ConnTime.Seconds() * 1000
|
||||
firstByteTime := trace.ServerTime.Seconds() * 1000
|
||||
|
||||
totalTime := float64(endTime.Sub(startTime).Milliseconds())
|
||||
var downloadSpeed float64
|
||||
if totalTime > 0 {
|
||||
downloadSpeed = float64(len(body)) / 1024.0 / (totalTime / 1000.0)
|
||||
}
|
||||
dumpBytes, _ := httputil.DumpResponse(resp.RawResponse, false)
|
||||
httpStatus := resp.StatusCode()
|
||||
httpsStatus := resp.StatusCode()
|
||||
if fallbackToHTTP {
|
||||
httpsStatus = 0
|
||||
}
|
||||
result := &WebsiteSpeedTestResult{
|
||||
Version: version,
|
||||
Headers: string(dumpBytes),
|
||||
HostRecord: hostRecord,
|
||||
HTTPStatusCode: httpStatus,
|
||||
HTTPSSStatusCode: httpsStatus,
|
||||
DNSLookupTime: dnsLookupTime,
|
||||
TCPConnectTime: tcpConnectTime,
|
||||
HTTPConnectTime: httpConnectTime,
|
||||
FirstByteTime: firstByteTime,
|
||||
TotalTime: totalTime,
|
||||
PageSize: int64(len(body)),
|
||||
DownloadSpeed: downloadSpeed,
|
||||
IsReachable: true,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package webtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
lemon_ssrf "lemon-ipw/ssrf"
|
||||
)
|
||||
|
||||
// CheckSSL SSL 检查(ssl)。客户端由 main.go 经 SetHTTPClient 注入。
|
||||
func CheckSSL(url string, version string) (*SSLCheckDetail, error) {
|
||||
ctx := context.Background()
|
||||
var err error
|
||||
ctx, err = lemon_ssrf.ValidateOutboundTarget(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
resp, err := httpClient(version).R().EnableTrace().SetContext(ctx).Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endTime := time.Now()
|
||||
|
||||
trace := resp.Request.TraceInfo()
|
||||
hostRecord := CleanHostRecord(trace.RemoteAddr)
|
||||
|
||||
totalTime := float64(endTime.Sub(startTime).Milliseconds())
|
||||
body := resp.Bytes()
|
||||
var downloadSpeed float64
|
||||
if totalTime > 0 {
|
||||
downloadSpeed = float64(len(body)) / 1024.0 / (totalTime / 1000.0)
|
||||
}
|
||||
|
||||
rawResp := resp.RawResponse
|
||||
var cert *x509.Certificate
|
||||
var remainingDays int
|
||||
var isExpired bool
|
||||
var issuerOrganization []string
|
||||
var issuerCommonName, subjectCommonName, domain string
|
||||
|
||||
if rawResp.TLS != nil && len(rawResp.TLS.PeerCertificates) > 0 {
|
||||
cert = rawResp.TLS.PeerCertificates[0]
|
||||
now := time.Now()
|
||||
remainingDays = int(cert.NotAfter.Sub(now).Hours() / 24)
|
||||
isExpired = now.After(cert.NotAfter) || now.Before(cert.NotBefore)
|
||||
issuerOrganization = cert.Issuer.Organization
|
||||
issuerCommonName = cert.Issuer.CommonName
|
||||
subjectCommonName = cert.Subject.CommonName
|
||||
domain = CleanHostRecord(cert.Subject.CommonName)
|
||||
} else {
|
||||
return nil, fmt.Errorf("no SSL certificate found")
|
||||
}
|
||||
|
||||
result := &SSLCheckDetail{
|
||||
CertValidityDays: remainingDays,
|
||||
IsExpired: isExpired,
|
||||
CertStartTime: cert.NotBefore,
|
||||
CertEndTime: cert.NotAfter,
|
||||
HTTPVersion: resp.Proto(),
|
||||
HostRecord: hostRecord,
|
||||
HTTPSSStatusCode: resp.StatusCode(),
|
||||
TotalTime: totalTime,
|
||||
DownloadSpeed: downloadSpeed,
|
||||
Domain: domain,
|
||||
IssuerOrganization: issuerOrganization,
|
||||
IssuerCommonName: issuerCommonName,
|
||||
SubjectCommonName: subjectCommonName,
|
||||
IsReachable: true,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,53 @@
|
||||
package webtest
|
||||
|
||||
import "time"
|
||||
|
||||
// 站点类探测的公共类型(CheckWebsite / SpeedTest / CheckSSL 共用)
|
||||
|
||||
type WebsiteCheckDetail struct {
|
||||
HostRecord string `json:"host_record"`
|
||||
HTTPStatusCode int `json:"http_status_code"`
|
||||
HTTPSSStatusCode int `json:"https_status_code"`
|
||||
DNSLookupTime float64 `json:"dns_lookup_time"`
|
||||
TCPConnectTime float64 `json:"tcp_connect_time"`
|
||||
HTTPConnectTime float64 `json:"http_connect_time"`
|
||||
FirstByteTime float64 `json:"first_byte_time"`
|
||||
TotalTime float64 `json:"total_time"`
|
||||
PageSize int64 `json:"page_size"`
|
||||
DownloadSpeed float64 `json:"download_speed"`
|
||||
IsReachable bool `json:"is_reachable"`
|
||||
}
|
||||
|
||||
type SSLCheckDetail struct {
|
||||
CertValidityDays int `json:"cert_validity_days"`
|
||||
CertStartTime time.Time `json:"cert_start_time"`
|
||||
CertEndTime time.Time `json:"cert_end_time"`
|
||||
HTTPVersion string `json:"http_version"`
|
||||
HostRecord string `json:"host_record"`
|
||||
HTTPSSStatusCode int `json:"https_status_code"`
|
||||
TotalTime float64 `json:"total_time"`
|
||||
DownloadSpeed float64 `json:"download_speed"`
|
||||
Domain string `json:"domain"`
|
||||
IssuerOrganization []string `json:"issuer_organization"`
|
||||
IssuerCommonName string `json:"issuer_common_name"`
|
||||
SubjectCommonName string `json:"subject_common_name"`
|
||||
IsExpired bool `json:"is_expired"`
|
||||
IsReachable bool `json:"is_reachable"`
|
||||
}
|
||||
|
||||
type WebsiteSpeedTestResult struct {
|
||||
Version string `json:"version"`
|
||||
HostRecord string `json:"host_record"`
|
||||
HTTPStatusCode int `json:"http_status_code"`
|
||||
HTTPSSStatusCode int `json:"https_status_code"`
|
||||
DNSLookupTime float64 `json:"dns_lookup_time"`
|
||||
TCPConnectTime float64 `json:"tcp_connect_time"`
|
||||
HTTPConnectTime float64 `json:"http_connect_time"`
|
||||
FirstByteTime float64 `json:"first_byte_time"`
|
||||
TotalTime float64 `json:"total_time"`
|
||||
PageSize int64 `json:"page_size"`
|
||||
DownloadSpeed float64 `json:"download_speed"`
|
||||
Message string `json:"message"`
|
||||
Headers string `json:"headers"`
|
||||
IsReachable bool `json:"is_reachable"`
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package webtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
lemon_ssrf "lemon-ipw/ssrf"
|
||||
)
|
||||
|
||||
// CheckWebsite 网站检测(detail)。客户端由 main.go 经 SetHTTPClient 注入。
|
||||
func CheckWebsite(url string, version string) (*WebsiteCheckDetail, error) {
|
||||
ctx := context.Background()
|
||||
var err error
|
||||
ctx, err = lemon_ssrf.ValidateOutboundTarget(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
resp, err := httpClient(version).R().EnableTrace().SetContext(ctx).Get(url)
|
||||
|
||||
// HTTPS 请求失败时 fallback 到 HTTP
|
||||
fallbackToHTTP := false
|
||||
if err != nil && strings.HasPrefix(url, "https://") {
|
||||
httpURL := strings.Replace(url, "https://", "http://", 1)
|
||||
startTime = time.Now()
|
||||
resp, err = httpClient(version).R().EnableTrace().SetContext(ctx).Get(httpURL)
|
||||
fallbackToHTTP = true
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endTime := time.Now()
|
||||
|
||||
body := resp.Bytes()
|
||||
trace := resp.Request.TraceInfo()
|
||||
|
||||
hostRecord := CleanHostRecord(trace.RemoteAddr)
|
||||
|
||||
dnsLookupTime := trace.DNSLookup.Seconds() * 1000
|
||||
if dnsLookupTime == 0 {
|
||||
dnsLookupTime = measureDNSTime(url, version)
|
||||
}
|
||||
tcpConnectTime := trace.TCPConnTime.Seconds() * 1000
|
||||
httpConnectTime := trace.ConnTime.Seconds() * 1000
|
||||
firstByteTime := trace.ServerTime.Seconds() * 1000
|
||||
|
||||
totalTime := float64(endTime.Sub(startTime).Milliseconds())
|
||||
var downloadSpeed float64
|
||||
if totalTime > 0 {
|
||||
downloadSpeed = float64(len(body)) / 1024.0 / (totalTime / 1000.0)
|
||||
}
|
||||
|
||||
httpStatus := resp.StatusCode()
|
||||
httpsStatus := resp.StatusCode()
|
||||
if fallbackToHTTP {
|
||||
httpsStatus = 0
|
||||
}
|
||||
|
||||
result := &WebsiteCheckDetail{
|
||||
HostRecord: hostRecord,
|
||||
HTTPStatusCode: httpStatus,
|
||||
HTTPSSStatusCode: httpsStatus,
|
||||
DNSLookupTime: dnsLookupTime,
|
||||
TCPConnectTime: tcpConnectTime,
|
||||
HTTPConnectTime: httpConnectTime,
|
||||
FirstByteTime: firstByteTime,
|
||||
TotalTime: totalTime,
|
||||
PageSize: int64(len(body)),
|
||||
DownloadSpeed: downloadSpeed,
|
||||
IsReachable: true,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,495 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"lemon-ipw/ipdb"
|
||||
"lemon-ipw/webtest"
|
||||
)
|
||||
|
||||
// ==================== WS 客户端(测速节点接入中间件 WS 通道) ====================
|
||||
//
|
||||
// 配置(readConfig 解析,环境变量优先,其次 setting.json,远端配置可覆盖):
|
||||
//
|
||||
// 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 次
|
||||
// 判定当前中间件不可用,断开该连接并按同一 URL 独立重连(不影响其他中间件连接)。
|
||||
|
||||
// wsProbe 学 HTTP handler:从函数拿数据 + 缓存,返回 (status, body)。
|
||||
// raw 与 query 来自中间件 probe 消息(等价于路由参数)。
|
||||
func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
switch apiType {
|
||||
case "detail":
|
||||
testURL := normalizeURL(raw)
|
||||
if cached, ok := websiteCache.Load(testURL); ok {
|
||||
entry := cached.(websiteCacheEntry)
|
||||
if time.Since(entry.timestamp) < 5*time.Minute {
|
||||
return 200, entry.result
|
||||
}
|
||||
websiteCache.Delete(testURL)
|
||||
}
|
||||
ipv4, ipv6 := wsDual[webtest.WebsiteCheckDetail](
|
||||
func(v string) (*webtest.WebsiteCheckDetail, error) { return webtest.CheckWebsite(testURL, v) },
|
||||
func(err error) *webtest.WebsiteCheckDetail {
|
||||
return &webtest.WebsiteCheckDetail{HostRecord: "Error: " + err.Error(), IsReachable: false}
|
||||
},
|
||||
func(v string) *webtest.WebsiteCheckDetail {
|
||||
return &webtest.WebsiteCheckDetail{HostRecord: "Skipped due to SINGLE_STACK=" + v, IsReachable: false}
|
||||
},
|
||||
)
|
||||
result := &WebsiteCheckResult{IPv4: ipv4, IPv6: ipv6}
|
||||
websiteCache.Store(testURL, websiteCacheEntry{result: result, timestamp: time.Now()})
|
||||
return 200, result
|
||||
|
||||
case "ssl":
|
||||
testURL := normalizeURL(raw)
|
||||
if cached, ok := sslCache.Load(testURL); ok {
|
||||
entry := cached.(sslCacheEntry)
|
||||
if time.Since(entry.timestamp) < 5*time.Minute {
|
||||
return 200, entry.result
|
||||
}
|
||||
sslCache.Delete(testURL)
|
||||
}
|
||||
ipv4, ipv6 := wsDual[webtest.SSLCheckDetail](
|
||||
func(v string) (*webtest.SSLCheckDetail, error) { return webtest.CheckSSL(testURL, v) },
|
||||
func(err error) *webtest.SSLCheckDetail {
|
||||
return &webtest.SSLCheckDetail{HostRecord: "Error: " + err.Error(), IsExpired: true, IsReachable: false}
|
||||
},
|
||||
func(v string) *webtest.SSLCheckDetail {
|
||||
return &webtest.SSLCheckDetail{HostRecord: "Skipped due to SINGLE_STACK=" + v, IsExpired: true, IsReachable: false}
|
||||
},
|
||||
)
|
||||
result := &SSLCheckResult{IPv4: ipv4, IPv6: ipv6}
|
||||
sslCache.Store(testURL, sslCacheEntry{result: result, timestamp: time.Now()})
|
||||
return 200, result
|
||||
|
||||
case "speed":
|
||||
// raw 形如 "v4/example.com":首段为版本,其余为 URL
|
||||
version, testURL := splitRaw(raw)
|
||||
if version == "" {
|
||||
version = "v4"
|
||||
}
|
||||
testURL = normalizeURL(testURL)
|
||||
cacheKey := fmt.Sprintf("%s:%s", testURL, version)
|
||||
if cached, ok := speedCache.Load(cacheKey); ok {
|
||||
entry := cached.(speedCacheEntry)
|
||||
if time.Since(entry.timestamp) < 5*time.Minute {
|
||||
return 200, entry.result
|
||||
}
|
||||
speedCache.Delete(cacheKey)
|
||||
}
|
||||
r, e := webtest.SpeedTest(testURL, version)
|
||||
if e != nil {
|
||||
errorResult := &webtest.WebsiteSpeedTestResult{HostRecord: "Error: " + e.Error()}
|
||||
speedCache.Store(cacheKey, speedCacheEntry{result: errorResult, timestamp: time.Now()})
|
||||
return 200, errorResult
|
||||
}
|
||||
speedCache.Store(cacheKey, speedCacheEntry{result: r, timestamp: time.Now()})
|
||||
return 200, r
|
||||
|
||||
case "tcping":
|
||||
host := raw
|
||||
port := query["port"]
|
||||
if port == "" {
|
||||
port = "80"
|
||||
}
|
||||
count := query["count"]
|
||||
n := 4
|
||||
if count != "" {
|
||||
if c, err := parseInt(count); err == nil && c >= 1 && c <= 20 {
|
||||
n = c
|
||||
}
|
||||
}
|
||||
cacheKey := fmt.Sprintf("%s:%s:%d", host, port, n)
|
||||
if cached, ok := pingCache.Load(cacheKey); ok {
|
||||
entry := cached.(pingCacheEntry)
|
||||
if time.Since(entry.timestamp) < 5*time.Minute {
|
||||
return 200, entry.result
|
||||
}
|
||||
pingCache.Delete(cacheKey)
|
||||
}
|
||||
ipv4, ipv6 := wsDual[webtest.TCPingStats](
|
||||
func(v string) (*webtest.TCPingStats, error) {
|
||||
return webtest.TCPingRun(host, port, n, v, 10*time.Second, 100*time.Millisecond)
|
||||
},
|
||||
func(err error) *webtest.TCPingStats { return &webtest.TCPingStats{IP: "Error: " + err.Error()} },
|
||||
func(v string) *webtest.TCPingStats { return &webtest.TCPingStats{IP: "Skipped due to SINGLE_STACK=" + v} },
|
||||
)
|
||||
result := &TCPingResult{IPv4: ipv4, IPv6: ipv6}
|
||||
pingCache.Store(cacheKey, pingCacheEntry{result: result, timestamp: time.Now()})
|
||||
return 200, result
|
||||
|
||||
case "dns":
|
||||
// raw 形如 "a/example.com":首段为记录类型
|
||||
recodeType, domain := splitRaw(raw)
|
||||
parsedURL, err := parseURL(domain)
|
||||
if err != nil {
|
||||
return 400, map[string]string{"error": "Invalid domain"}
|
||||
}
|
||||
domain = parsedURL.Host
|
||||
var result any
|
||||
switch recodeType {
|
||||
case "a":
|
||||
result, err = webtest.ResolveARecord(domain)
|
||||
case "aaaa":
|
||||
result, err = webtest.ResolveAAAARecord(domain)
|
||||
case "cname":
|
||||
result, err = webtest.ResolveCNAMERecord(domain)
|
||||
case "mx":
|
||||
result, err = webtest.ResolveMXRecord(domain)
|
||||
case "ns":
|
||||
result, err = webtest.ResolveNSRecord(domain)
|
||||
case "txt":
|
||||
result, err = webtest.ResolveTXTRecord(domain)
|
||||
case "srv":
|
||||
result, err = webtest.ResolveSRVRecord(domain)
|
||||
case "ptr":
|
||||
result, err = webtest.ResolvePTRRecord(domain)
|
||||
case "caa":
|
||||
result, err = webtest.ResolveCAARecord(domain)
|
||||
default:
|
||||
return 400, map[string]string{"error": "Unsupported record type: " + recodeType}
|
||||
}
|
||||
if err != nil {
|
||||
return 500, map[string]string{"error": err.Error()}
|
||||
}
|
||||
return 200, result
|
||||
|
||||
case "dnssec":
|
||||
result, err := webtest.ResolveDNSSEC(raw)
|
||||
if err != nil {
|
||||
return 500, map[string]string{"error": err.Error()}
|
||||
}
|
||||
return 200, result
|
||||
|
||||
case "whois":
|
||||
if raw == "" {
|
||||
return 400, map[string]string{"error": "Domain parameter is required"}
|
||||
}
|
||||
if cached, ok := whoisCache.Load(raw); ok {
|
||||
entry := cached.(whoisCacheEntry)
|
||||
if time.Since(entry.timestamp) < 5*time.Minute {
|
||||
return 200, entry.result
|
||||
}
|
||||
whoisCache.Delete(raw)
|
||||
}
|
||||
result, err := webtest.QueryWhois(raw)
|
||||
if err != nil {
|
||||
return 500, map[string]string{"error": err.Error()}
|
||||
}
|
||||
whoisCache.Store(raw, whoisCacheEntry{result: result, timestamp: time.Now()})
|
||||
return 200, result
|
||||
|
||||
case "location":
|
||||
return 200, ipdb.SearchIP(raw)
|
||||
|
||||
case "asn":
|
||||
if raw == "" {
|
||||
return 400, map[string]string{"error": "IP parameter is required"}
|
||||
}
|
||||
result := ipdb.SearchIP(raw, "maxmind_asn", "dbip_asn", "ip2location_asn")
|
||||
asnResult := map[string]any{"ip": raw}
|
||||
if ip2locASN, ok := result["ip2location_asn"].(map[string]string); ok && ip2locASN["asn"] != "" {
|
||||
asnResult["ip2location_asn"] = map[string]string{"asn": ip2locASN["asn"], "as": ip2locASN["as"]}
|
||||
} else if errStr, ok := result["ip2location_asn"].(string); ok {
|
||||
asnResult["ip2location_asn"] = map[string]string{"error": errStr}
|
||||
}
|
||||
if maxmindASN, ok := result["maxmind_asn"].(*ipdb.MMDBASNResult); ok {
|
||||
asnResult["geolite2_asn"] = map[string]string{"asn": maxmindASN.ASN, "org": maxmindASN.Org}
|
||||
} else if errStr, ok := result["maxmind_asn"].(string); ok {
|
||||
asnResult["geolite2_asn"] = map[string]string{"error": errStr}
|
||||
}
|
||||
if dbipASN, ok := result["dbip_asn"].(*ipdb.MMDBASNResult); ok {
|
||||
asnResult["dbip_asn"] = map[string]string{"asn": dbipASN.ASN, "org": dbipASN.Org}
|
||||
} else if errStr, ok := result["dbip_asn"].(string); ok {
|
||||
asnResult["dbip_asn"] = map[string]string{"error": errStr}
|
||||
}
|
||||
if maxmindASN, ok := result["maxmind_asn"].(*ipdb.MMDBASNResult); ok {
|
||||
whoisData, err := webtest.QueryASNWhois(maxmindASN.ASN)
|
||||
if err == nil {
|
||||
asnResult["whois"] = whoisData
|
||||
}
|
||||
}
|
||||
return 200, asnResult
|
||||
|
||||
default:
|
||||
return 400, map[string]string{"error": "Unsupported apiType: " + apiType}
|
||||
}
|
||||
}
|
||||
|
||||
// splitRaw 按首个 "/" 拆分为 (type/version, 剩余)
|
||||
func splitRaw(raw string) (string, string) {
|
||||
i := strings.Index(raw, "/")
|
||||
if i == -1 {
|
||||
return raw, ""
|
||||
}
|
||||
return raw[:i], raw[i+1:]
|
||||
}
|
||||
|
||||
// wsDual 双栈编排(ws.go 内部,学 main.go handler 的 SINGLE_STACK 处理)
|
||||
func wsDual[T any](run func(version string) (*T, error), mkErr func(err error) *T, mkSkip func(version string) *T) (ipv4, ipv6 *T) {
|
||||
switch SINGLE_STACK {
|
||||
case "ipv4":
|
||||
v4, err := run("v4")
|
||||
if err != nil {
|
||||
v4 = mkErr(err)
|
||||
}
|
||||
return v4, mkSkip("ipv4")
|
||||
case "ipv6":
|
||||
v6, err := run("v6")
|
||||
if err != nil {
|
||||
v6 = mkErr(err)
|
||||
}
|
||||
return mkSkip("ipv6"), v6
|
||||
default:
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
v4, err := run("v4")
|
||||
if err != nil {
|
||||
v4 = mkErr(err)
|
||||
}
|
||||
ipv4 = v4
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
v6, err := run("v6")
|
||||
if err != nil {
|
||||
v6 = mkErr(err)
|
||||
}
|
||||
ipv6 = v6
|
||||
}()
|
||||
wg.Wait()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// parseInt 简易整数解析
|
||||
func parseInt(s string) (int, error) {
|
||||
n := 0
|
||||
neg := false
|
||||
for i, ch := range s {
|
||||
if i == 0 && ch == '-' {
|
||||
neg = true
|
||||
continue
|
||||
}
|
||||
if ch < '0' || ch > '9' {
|
||||
return 0, fmt.Errorf("invalid number %q", s)
|
||||
}
|
||||
n = n*10 + int(ch-'0')
|
||||
}
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ---- 连接与消息 ----
|
||||
|
||||
type wsMsg struct {
|
||||
Type string `json:"type"`
|
||||
NodeID string `json:"nodeId,omitempty"`
|
||||
TS int64 `json:"ts"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func wsSend(c *websocket.Conn, m wsMsg) {
|
||||
_ = wsSendErr(c, m)
|
||||
}
|
||||
|
||||
// wsSendErr 发送消息并返回错误(心跳用)
|
||||
func wsSendErr(c *websocket.Conn, m wsMsg) error {
|
||||
b, _ := json.Marshal(m)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return c.Write(ctx, websocket.MessageText, b)
|
||||
}
|
||||
|
||||
func wsRaw(v any) json.RawMessage {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return json.RawMessage("{}")
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// wsHandleProbe 处理中间件下发的拨测请求(并发执行):取数 → probe_result
|
||||
func wsHandleProbe(c *websocket.Conn, data json.RawMessage) {
|
||||
var req struct {
|
||||
RequestID string `json:"requestId"`
|
||||
APIType string `json:"apiType"`
|
||||
Raw string `json:"raw"`
|
||||
Query map[string]string `json:"query"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &req); err != nil || req.RequestID == "" || req.APIType == "" {
|
||||
slog.Warn("ws client bad probe message")
|
||||
return
|
||||
}
|
||||
status, body := wsProbe(req.APIType, req.Raw, req.Query)
|
||||
payload := struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Status int `json:"status"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}{req.RequestID, status, nil}
|
||||
if json.Valid(mustMarshal(body)) {
|
||||
payload.Body = mustMarshal(body)
|
||||
} else {
|
||||
payload.Body, _ = json.Marshal(string(mustMarshal(body)))
|
||||
}
|
||||
wsSend(c, wsMsg{Type: "probe_result", Data: wsRaw(payload)})
|
||||
}
|
||||
|
||||
func mustMarshal(v any) json.RawMessage {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
|
||||
// 心跳配置:每 10s 发一次 ping;发送失败重试,连续失败 3 次判定中间件不可用
|
||||
const (
|
||||
wsHeartbeatInterval = 10 * time.Second
|
||||
wsHeartbeatRetry = 3
|
||||
wsHeartbeatTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// 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 3 * time.Second
|
||||
}
|
||||
defer c.CloseNow()
|
||||
|
||||
reg := map[string]string{"nodeId": WS_NODE_ID}
|
||||
if WS_NODE_KEY != "" {
|
||||
reg["key"] = WS_NODE_KEY
|
||||
}
|
||||
wsSend(c, wsMsg{Type: "register", Data: wsRaw(reg)})
|
||||
|
||||
_, data, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("ws client register read failed", "url", url, "error", err)
|
||||
return 3 * time.Second
|
||||
}
|
||||
var m wsMsg
|
||||
if json.Unmarshal(data, &m) != nil {
|
||||
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 30 * time.Second
|
||||
}
|
||||
if m.Type != "register_ok" {
|
||||
slog.Warn("ws client unexpected message", "type", m.Type)
|
||||
return 3 * time.Second
|
||||
}
|
||||
slog.Info("ws client registered", "nodeId", WS_NODE_ID, "url", url)
|
||||
|
||||
// 心跳 goroutine:每 10s 发 ping,失败重试,连续 3 次失败主动断开(per-URL 循环重连同一中间件)
|
||||
stopHeartbeat := make(chan struct{})
|
||||
go func() {
|
||||
fail := 0
|
||||
for {
|
||||
select {
|
||||
case <-stopHeartbeat:
|
||||
return
|
||||
case <-time.After(wsHeartbeatInterval):
|
||||
}
|
||||
if err := wsSendErr(c, wsMsg{Type: "ping"}); err != nil {
|
||||
fail++
|
||||
slog.Warn("ws heartbeat send failed", "url", url, "fail", fail, "error", err)
|
||||
if fail >= wsHeartbeatRetry {
|
||||
slog.Warn("ws heartbeat failed too many times, closing connection (will retry same url)", "url", url)
|
||||
c.Close(websocket.StatusPolicyViolation, "heartbeat timeout")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
fail = 0
|
||||
}
|
||||
}
|
||||
}()
|
||||
defer close(stopHeartbeat)
|
||||
|
||||
for {
|
||||
_, data, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("ws client disconnected", "url", url, "error", err)
|
||||
return 3 * time.Second
|
||||
}
|
||||
var msg wsMsg
|
||||
if json.Unmarshal(data, &msg) != nil {
|
||||
continue
|
||||
}
|
||||
switch msg.Type {
|
||||
case "probe":
|
||||
go wsHandleProbe(c, msg.Data)
|
||||
case "ping":
|
||||
wsSend(c, wsMsg{Type: "pong"})
|
||||
case "status":
|
||||
slog.Debug("ws client status", "data", string(msg.Data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wsClientLoop 同时连接所有配置的中间件 URL(逗号分隔,多活):
|
||||
// 每个 URL 由独立 goroutine 负责,各自注册并保持连接,任一断开只重连自己,不影响其他中间件。
|
||||
// 注意:多个 URL 指向同一中间件实例时,中间件按 nodeId 单连接,后注册的连接会顶掉先前的。
|
||||
func wsClientLoop() {
|
||||
urls := splitWSURLs(WS_URL)
|
||||
if len(urls) == 0 {
|
||||
slog.Info("ws client disabled (WS_URL not set)")
|
||||
return
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for _, url := range urls {
|
||||
wg.Add(1)
|
||||
go func(u string) {
|
||||
defer wg.Done()
|
||||
wsClientOnceLoop(u)
|
||||
}(url)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// wsClientOnceLoop 单个中间件 URL 的持续连接循环:连接 → 注册 → 心跳/读循环 → 断开重试
|
||||
func wsClientOnceLoop(url string) {
|
||||
for {
|
||||
retryDelay := wsClientOnce(url)
|
||||
if retryDelay == 0 {
|
||||
slog.Info("ws client stopped", "url", url)
|
||||
return
|
||||
}
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
}
|
||||
|
||||
// splitWSURLs 逗号分隔解析多个中间件 URL
|
||||
func splitWSURLs(s string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user