Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8f7f2bd17 | ||
|
|
ff5caddab3 | ||
|
|
a00e42a896 | ||
|
|
031c1e8b69 | ||
|
|
bb02b0c5bd | ||
|
|
e7365e3005 | ||
|
|
1b0f2646fe | ||
|
|
31d725ff84 | ||
|
|
21fa445ff8 | ||
|
|
e7cccfbd99 | ||
|
|
f12c97cf8b | ||
|
|
cc8690d264 | ||
|
|
b705dbdc18 | ||
|
|
862717a47e | ||
|
|
88984719c5 | ||
|
|
684292de76 | ||
|
|
293157b3f8 | ||
|
|
d6fdf67bc9 | ||
|
|
3449babbe0 | ||
|
|
ba0286bd07 | ||
|
|
e3d5eb0200 | ||
|
|
25a8d7a651 | ||
|
|
dbaf6a0da9 | ||
|
|
9a4c7d57a8 | ||
|
|
8522fd5c23 | ||
|
|
08f2a6780d | ||
|
|
b03d0a05ee | ||
|
|
f44da9a467 | ||
|
|
7beb66ee2b | ||
|
|
5ff430214d | ||
|
|
1588896fac | ||
|
|
edc8f97acd | ||
|
|
4dd807d99c | ||
|
|
2cf31260ae | ||
|
|
d7df245b75 | ||
|
|
345422b60f | ||
|
|
972ca4c744 | ||
|
|
12c91ed2d3 | ||
|
|
4fceb8421e |
No files matched your search
@@ -1,6 +1,11 @@
|
||||
name: Build and Release
|
||||
|
||||
|
||||
# 并行构建:原来 8 个 Build step 串行跑在一个 job 里,现在拆成 8 个矩阵任务同时跑
|
||||
# (2 个程序 × 4 个目标系统),构建完各自上传 artifact,最后统一发 Release。
|
||||
#
|
||||
# 元数据(VERSION / COMMIT / BUILD_TIME)由 meta job 算一次分发下去:
|
||||
# 若放到每个矩阵任务里各算一遍,BUILD_TIME 会互不相同,同一个 Release 里的
|
||||
# 二进制时间戳就对不上了。
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
@@ -8,95 +13,131 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
# 只负责算版本元数据,秒级完成,不产出 artifact
|
||||
meta:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
ldflags: ${{ steps.meta.outputs.ldflags }}
|
||||
steps:
|
||||
- name: Compute version metadata
|
||||
id: meta
|
||||
run: |
|
||||
echo "ldflags=-X main.VERSION=${GITHUB_REF_NAME} -X main.COMMIT=${GITHUB_SHA} -X main.BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
needs: meta
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
# 一个目标挂了不影响其余目标,全部跑完再统一看结果
|
||||
fail-fast: false
|
||||
# 纯 include 模式:每条自带全部字段、各自独立成一个任务。
|
||||
# 不要把 app 写成顶层数组再和 os 组合——GitHub 会把能匹配现有组合的
|
||||
# include 条目合并进组合本身,任务数被折叠(docker.yml 里踩过,见 v3.3.0 事故)
|
||||
matrix:
|
||||
include:
|
||||
- app: lemonipw
|
||||
dir: src
|
||||
os: linux
|
||||
- app: lemonipw
|
||||
dir: src
|
||||
os: windows
|
||||
- app: lemonipw
|
||||
dir: src
|
||||
os: darwin
|
||||
- app: lemonipw
|
||||
dir: src
|
||||
os: freebsd
|
||||
- app: middleware-go
|
||||
dir: middleware-go
|
||||
os: linux
|
||||
- app: middleware-go
|
||||
dir: middleware-go
|
||||
os: windows
|
||||
- app: middleware-go
|
||||
dir: middleware-go
|
||||
os: darwin
|
||||
- app: middleware-go
|
||||
dir: middleware-go
|
||||
os: freebsd
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
# v4 基于 Node 16,GitHub 已不再支持该运行时(actionlint 会报 runner too old)
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26.6' # 与 src/go.mod 声明保持一致,避免依赖 GOTOOLCHAIN 隐式下载
|
||||
- name: Set version metadata
|
||||
run: |
|
||||
echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV
|
||||
echo "COMMIT=${GITHUB_SHA}" >> $GITHUB_ENV
|
||||
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_ENV
|
||||
|
||||
- name: Build for Linux
|
||||
working-directory: src
|
||||
# 产物统一写到仓库根目录(working-directory 的上一级),命名规则与原串行版完全一致
|
||||
- name: Build ${{ matrix.app }} for ${{ matrix.os }}
|
||||
working-directory: ${{ matrix.dir }}
|
||||
env:
|
||||
APP: ${{ matrix.app }}
|
||||
LDFLAGS: ${{ needs.meta.outputs.ldflags }}
|
||||
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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- name: Build middleware-go for Linux
|
||||
working-directory: middleware-go
|
||||
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 ../middleware-go-linux-386
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-amd64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-arm64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-armv7
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-armv6
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=loong64 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-loong64
|
||||
- name: Build middleware-go for Windows
|
||||
working-directory: middleware-go
|
||||
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 ../middleware-go-windows-386.exe
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-windows-amd64.exe
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-windows-arm64.exe
|
||||
- name: Build middleware-go for macOS
|
||||
working-directory: middleware-go
|
||||
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 ../middleware-go-darwin-amd64
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-darwin-arm64
|
||||
- name: Build middleware-go for FreeBSD
|
||||
working-directory: middleware-go
|
||||
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 ../middleware-go-freebsd-386
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-freebsd-amd64
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-freebsd-arm64
|
||||
set -euo pipefail
|
||||
case "${{ matrix.os }}" in
|
||||
linux)
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags "$LDFLAGS" -o "../${APP}-linux-386"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o "../${APP}-linux-amd64"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o "../${APP}-linux-arm64"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$LDFLAGS" -o "../${APP}-linux-armv7"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$LDFLAGS" -o "../${APP}-linux-armv6"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=loong64 go build -ldflags "$LDFLAGS" -o "../${APP}-linux-loong64"
|
||||
;;
|
||||
windows)
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=386 go build -ldflags "$LDFLAGS" -o "../${APP}-windows-386.exe"
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o "../${APP}-windows-amd64.exe"
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o "../${APP}-windows-arm64.exe"
|
||||
;;
|
||||
darwin)
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o "../${APP}-darwin-amd64"
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o "../${APP}-darwin-arm64"
|
||||
;;
|
||||
freebsd)
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags "$LDFLAGS" -o "../${APP}-freebsd-386"
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o "../${APP}-freebsd-amd64"
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o "../${APP}-freebsd-arm64"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Upload ${{ matrix.app }}-${{ matrix.os }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.app }}-${{ matrix.os }}
|
||||
# 只捞本任务自己产出的二进制;middleware-go/ 目录名不带连字符,不会误匹配
|
||||
path: ${{ matrix.app }}-*
|
||||
if-no-files-found: error
|
||||
|
||||
# 只在打 tag 时发 Release;workflow_dispatch 手动跑则只构建、可从 run 页面下载 artifact
|
||||
release:
|
||||
needs: build
|
||||
if: github.ref_type == 'tag'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Prepare config files for release
|
||||
run: |
|
||||
# 后端:发布 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
|
||||
cp src/setting.json.example dist/setting.json.example
|
||||
cp middleware-go/setting.json.example dist/middleware.setting.json.example
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
if: github.ref_type == 'tag'
|
||||
with:
|
||||
name: Release ${{ github.ref_name }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
token: ${{ secrets.MINT_TOKEN }}
|
||||
|
||||
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
## 配置文件说明
|
||||
@@ -116,34 +157,4 @@ jobs:
|
||||
cp middleware.setting.json.example middleware-go/setting.json
|
||||
```
|
||||
|
||||
files: |
|
||||
lemonipw-linux-386
|
||||
lemonipw-linux-amd64
|
||||
lemonipw-linux-arm64
|
||||
lemonipw-linux-armv7
|
||||
lemonipw-linux-armv6
|
||||
lemonipw-linux-loong64
|
||||
lemonipw-windows-386.exe
|
||||
lemonipw-windows-amd64.exe
|
||||
lemonipw-windows-arm64.exe
|
||||
lemonipw-darwin-amd64
|
||||
lemonipw-darwin-arm64
|
||||
lemonipw-freebsd-386
|
||||
lemonipw-freebsd-amd64
|
||||
lemonipw-freebsd-arm64
|
||||
middleware-go-linux-386
|
||||
middleware-go-linux-amd64
|
||||
middleware-go-linux-arm64
|
||||
middleware-go-linux-armv7
|
||||
middleware-go-linux-armv6
|
||||
middleware-go-linux-loong64
|
||||
middleware-go-windows-386.exe
|
||||
middleware-go-windows-amd64.exe
|
||||
middleware-go-windows-arm64.exe
|
||||
middleware-go-darwin-amd64
|
||||
middleware-go-darwin-arm64
|
||||
middleware-go-freebsd-386
|
||||
middleware-go-freebsd-amd64
|
||||
middleware-go-freebsd-arm64
|
||||
setting.json.example
|
||||
middleware.setting.json.example
|
||||
files: dist/*
|
||||
@@ -25,3 +25,6 @@ docs/.vitepress/*.timestamp-*
|
||||
tmp/
|
||||
setting.json
|
||||
*.BIN
|
||||
|
||||
# 本地调试脚本与实测产物(不入库,与 ipw-boce 的约定一致)
|
||||
tmp-smoke/
|
||||
@@ -21,6 +21,39 @@ curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o ins
|
||||
- [节点 API 与协议](docs/guide/node-api.md)(业务接口、管理接口、WS 协议、上报协议)
|
||||
- [公开 API(/api/v1)](docs/guide/public-api.md)(收集中心程序化接口:鉴权、端点与响应示例、公开状态接口)
|
||||
|
||||
## 贡献节点
|
||||
|
||||
把一台闲置的 Linux 机器(VPS、软路由、小主机)贡献为公共拨测节点:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/contribute.sh -o contribute.sh && sudo bash contribute.sh
|
||||
```
|
||||
|
||||
脚本静默完成全部步骤,中途无需任何交互:
|
||||
|
||||
- 自动挑选一个空闲高位端口(20000-65000),节点标识(节点 id / 注册 key / access-token)随机生成
|
||||
- 默认以 WS 方式**同时接入官方两个中间件**(多活,节点主动出站连接,无需公网放行端口)
|
||||
- 安装到 `/opt/lemon-ipw`,注册为 systemd 服务 `lemon-ipw` 并设置开机自启
|
||||
- 结束时打印节点信息(同时备份在 `/opt/lemon-ipw/node-info.txt`)
|
||||
|
||||
把打印出来的信息发给管理员登记,节点即可上线(联系方式见文末)。
|
||||
|
||||
| 选项 | 说明 |
|
||||
| --- | --- |
|
||||
| `--ipdb` | 启用 IP 数据库(首次启动后台下载约 450MB,默认关闭) |
|
||||
| `--port <n>` | 指定监听端口(默认随机空闲端口) |
|
||||
| `--ws-url <url>` | 改为接入自建中间件,逗号分隔可填多个(默认官方两个) |
|
||||
| `--version <tag>` | 指定版本(默认取 GitHub 最新 release) |
|
||||
| `--gh-proxy <p>` | GitHub 下载加速前缀,如 `https://ghfast.top/` |
|
||||
| `--dry-run` | 只检查环境并打印信息,不下载、不写 systemd |
|
||||
|
||||
日常运维:
|
||||
|
||||
```bash
|
||||
systemctl status lemon-ipw # 查看状态
|
||||
journalctl -u lemon-ipw -f # 跟踪日志
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
@@ -30,7 +63,8 @@ ipw-cn/
|
||||
├── serverless/ # 独立组件 边缘/Serverless(edgeone、edgeone-getip、lemon-getip)
|
||||
├── frontend-ssr/ # Nuxt 4 SSR 前端
|
||||
├── docs/ # 文档站
|
||||
└── install.sh # 一键安装脚本
|
||||
├── install.sh # 一键安装脚本(自建完整部署)
|
||||
└── contribute.sh # 贡献节点脚本(一台闲置机器接为公共节点)
|
||||
```
|
||||
## 友情链接
|
||||
### 来自社区的IPW版本
|
||||
@@ -49,6 +83,12 @@ ipw-cn/
|
||||
| DNS 查询 | miekg/dns(DoH / UDP 双通道 + 主从 failover) |
|
||||
| 部署 | Docker、Cloudflare Workers、EdgeOne Pages、systemd |
|
||||
|
||||
## 联系方式
|
||||
|
||||
- 邮箱:[iduhih777@outlook.com](mailto:iduhih777@outlook.com)
|
||||
- GitHub:[@nomdn](https://github.com/nomdn)
|
||||
- 站点:[nipw.cn](https://nipw.cn) | 拨测控制台:[boce.wsmdn.top](https://boce.wsmdn.top)
|
||||
|
||||
## 许可证
|
||||
|
||||
[GPL-3.0](LICENSE)
|
||||
@@ -0,0 +1,541 @@
|
||||
#!/usr/bin/bash
|
||||
# ============================================================
|
||||
# 节点贡献脚本(静默安装)
|
||||
#
|
||||
# 用途:把一台闲置 Linux 机器贡献为拨测节点。
|
||||
# - 全程无交互:端口自动挑一个空闲高位端口,节点标识随机生成
|
||||
# - 命名与官方 install.sh 保持一致:二进制 lemonipw / 目录 /opt/lemon-ipw / 服务 lemon-ipw
|
||||
# - 默认同时接入柠檬的两个中间件 WS 通道(多活,节点主动出站,无需公网放行端口)
|
||||
# - 安装完成后打印节点信息,提交给管理员登记即可上线
|
||||
#
|
||||
# 用法:
|
||||
# sudo bash contribute.sh # 全默认:系统级安装 + 自动端口 + 接 WS
|
||||
# sudo bash contribute.sh --ipdb # 启用 IP 数据库(首启后台下载 ~450MB)
|
||||
# sudo bash contribute.sh --port 34567 # 指定端口
|
||||
# bash contribute.sh # 非 root:自动装成 systemd --user 用户级服务(不询问)
|
||||
# bash contribute.sh --user # 显式指定用户级(与上面的自动降级等价)
|
||||
# sudo bash contribute.sh --dry-run # 只生成并打印信息,不下载、不落盘
|
||||
# ============================================================
|
||||
|
||||
set -e
|
||||
|
||||
# 默认同时接入的两个中间件(逗号分隔 = 节点侧同时连接全部、多活;
|
||||
# 任一断开只重连自己,不影响另一条连接)。顺序无关,收集中心放前面便于阅读。
|
||||
WS_URL_DEFAULT="wss://boce-api.api-ipw.wsmdn.top/ws,wss://middleware-1.api-ipw.wsmdn.top/ws"
|
||||
CONTACT_EMAIL="iduhih777@outlook.com"
|
||||
PORT_MIN=20000
|
||||
PORT_MAX=65000
|
||||
|
||||
OPT_PORT=""
|
||||
OPT_WS_URL=""
|
||||
OPT_VERSION=""
|
||||
OPT_GH_PROXY=""
|
||||
OPT_IPDB="false"
|
||||
OPT_SCOPE=""
|
||||
DRY_RUN="false"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法: sudo bash contribute.sh [选项]
|
||||
|
||||
--port <n> 监听端口(默认:20000-65000 中的随机空闲端口)
|
||||
--ws-url <url> 中间件 WS 地址,逗号分隔可多个(默认:柠檬两个中间件,同时连接)
|
||||
--version <tag> 指定版本(默认:取 GitHub 最新 release)
|
||||
--gh-proxy <p> GitHub 下载加速前缀,如 https://ghfast.top/
|
||||
--ipdb 启用 IP 数据库(首次启动后台下载,约 450MB;默认关闭)
|
||||
--user 用户级安装(systemd --user,unit 写 ~/.config/systemd/user,无需 root)
|
||||
--system 系统级安装(unit 写 /etc/systemd/system,需 root;非 root 下指定会直接报错)
|
||||
--dry-run 只做检查与信息生成,不下载、不写 systemd
|
||||
-h, --help 显示本帮助
|
||||
EOF
|
||||
}
|
||||
|
||||
# ---------- 参数解析 ----------
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
key="$1"
|
||||
case "$key" in
|
||||
--ipdb) OPT_IPDB="true"; shift; continue ;;
|
||||
--user) OPT_SCOPE="user"; shift; continue ;;
|
||||
--system) OPT_SCOPE="system"; shift; continue ;;
|
||||
--dry-run) DRY_RUN="true"; shift; continue ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
--port|--ws-url|--version|--gh-proxy) ;;
|
||||
*) echo "错误:未知参数 $key(用 --help 查看用法)" >&2; exit 1 ;;
|
||||
esac
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "错误:$key 缺少参数值(用 --help 查看用法)" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$key" in
|
||||
--port) OPT_PORT="$2" ;;
|
||||
--ws-url) OPT_WS_URL="$2" ;;
|
||||
--version) OPT_VERSION="$2" ;;
|
||||
--gh-proxy) OPT_GH_PROXY="$2" ;;
|
||||
esac
|
||||
shift 2
|
||||
done
|
||||
|
||||
# ---------- 随机值生成 ----------
|
||||
|
||||
# 生成 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
|
||||
}
|
||||
|
||||
# 32 位十六进制令牌(去横线的 UUID)
|
||||
gen_token() { gen_uuid | tr -d '-'; }
|
||||
|
||||
# ---------- 端口 ----------
|
||||
|
||||
# 列出本机监听端口(ss 优先,netstat 次之)
|
||||
listening_ports() {
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -ltn 2>/dev/null | awk 'NR>1 {print $4}'
|
||||
elif command -v netstat >/dev/null 2>&1; then
|
||||
netstat -ltn 2>/dev/null | awk 'NR>2 {print $4}'
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 0 = 端口已被占用,1 = 空闲
|
||||
port_in_use() {
|
||||
local p="$1" ports
|
||||
ports=$(listening_ports || true)
|
||||
if [ -n "$ports" ]; then
|
||||
if printf '%s\n' "$ports" | sed 's/.*[.:]//' | grep -qx "$p"; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
# 兜底:连得上即视为被占用(无 ss / netstat 的极简系统)
|
||||
if (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then
|
||||
exec 3>&-
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# 随机高位端口(20000-65000),连续试探直到落到空闲端口
|
||||
pick_port() {
|
||||
local i p
|
||||
for i in $(seq 1 50); do
|
||||
p=$(( (RANDOM * 32768 + RANDOM) % (PORT_MAX - PORT_MIN + 1) + PORT_MIN ))
|
||||
if ! port_in_use "$p"; then
|
||||
printf '%s' "$p"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo "错误:连续 50 次未找到空闲的高位端口,请用 --port 指定" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---------- 权限与 systemd 作用域 ----------
|
||||
|
||||
# system = 系统级:写 /etc/systemd/system,开机自启走 multi-user.target(需 root)
|
||||
# user = 用户级:写 ~/.config/systemd/user,开机自启走 default.target(systemd --user)
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
case "$OPT_SCOPE" in
|
||||
system)
|
||||
# dry-run 只在本地打印信息,不落盘,不必真有 root
|
||||
if [ "$DRY_RUN" != "true" ]; then
|
||||
echo "错误:--system 需要 root 权限(写 /etc/systemd/system),请用 sudo 运行或改用 --user" >&2
|
||||
exit 1
|
||||
fi
|
||||
SYSTEMD_MODE="system"
|
||||
;;
|
||||
user)
|
||||
SYSTEMD_MODE="user"
|
||||
;;
|
||||
*)
|
||||
# 非 root 且未指定作用域:本脚本定位是"静默安装",直接降为用户级,不打断询问
|
||||
SYSTEMD_MODE="user"
|
||||
echo "提示:当前不是 root 环境,自动改用 systemd --user(用户级服务,仅对当前用户生效)。" >&2
|
||||
echo " 如需系统级安装,请用 sudo 重新运行。" >&2
|
||||
;;
|
||||
esac
|
||||
else
|
||||
SYSTEMD_MODE="system"
|
||||
if [ "$OPT_SCOPE" = "user" ]; then
|
||||
SYSTEMD_MODE="user"
|
||||
echo "提示:以 root 身份使用 --user,unit 会写到 root 的用户级目录(~/.config/systemd/user)" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------- 环境检查 ----------
|
||||
|
||||
if [ "$DRY_RUN" != "true" ]; then
|
||||
if ! command -v systemctl >/dev/null 2>&1; then
|
||||
echo "错误:未检测到 systemd(systemctl 不存在),无法创建守护进程" >&2
|
||||
exit 1
|
||||
fi
|
||||
# 用户级实例依赖登录会话与 DBus,容器 / cron 等环境常常拿不到,提前拦掉
|
||||
if [ "$SYSTEMD_MODE" = "user" ] && ! systemctl --user list-units >/dev/null 2>&1; then
|
||||
echo "错误:当前环境无法使用 systemd --user(无用户级 systemd 实例或缺少 DBus 会话)" >&2
|
||||
echo " 可改用 sudo bash contribute.sh 做系统级安装" >&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
|
||||
fi
|
||||
|
||||
# 运维命令按作用域切换(用户级必须带 --user,否则查的是系统级命名空间)
|
||||
if [ "$SYSTEMD_MODE" = "user" ]; then
|
||||
CTL_CMD="systemctl --user"
|
||||
JRN_CMD="journalctl --user -u"
|
||||
SCOPE_LABEL="用户级 systemd --user"
|
||||
else
|
||||
CTL_CMD="systemctl"
|
||||
JRN_CMD="journalctl -u"
|
||||
SCOPE_LABEL="系统级 systemd"
|
||||
fi
|
||||
sc() { if [ "$SYSTEMD_MODE" = "user" ]; then systemctl --user "$@"; else systemctl "$@"; fi; }
|
||||
jc() { if [ "$SYSTEMD_MODE" = "user" ]; then journalctl --user "$@"; else journalctl "$@"; fi; }
|
||||
|
||||
# ---------- 架构检测 ----------
|
||||
case "$(uname -m)" in
|
||||
x86_64) ARCH_TAG="amd64" ;;
|
||||
i386|i486|i586|i686) ARCH_TAG="386" ;;
|
||||
aarch64|arm64) ARCH_TAG="arm64" ;;
|
||||
armv7l|armv7hl) ARCH_TAG="armv7" ;;
|
||||
armv6l) ARCH_TAG="armv6" ;;
|
||||
loongarch64) ARCH_TAG="loong64" ;;
|
||||
*)
|
||||
echo "错误:不支持的架构 '$(uname -m)'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
RELEASE_ASSET="lemonipw-linux-${ARCH_TAG}"
|
||||
|
||||
# ---------- 命名(与 install.sh 保持一致) ----------
|
||||
|
||||
# 二进制 / 安装目录 / 服务名统一固定为 lemon 系列,便于运维识别与排查;
|
||||
# 一台机器只跑一份,重跑本脚本 = 覆盖同名服务与目录。
|
||||
BIN_BASE="lemonipw"
|
||||
SERVICE_NAME="lemon-ipw"
|
||||
if [ "$SYSTEMD_MODE" = "user" ]; then
|
||||
INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/lemon-ipw"
|
||||
SERVICE_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/${SERVICE_NAME}.service"
|
||||
UNIT_USER_LINE=""
|
||||
UNIT_NETWORK_LINES=""
|
||||
UNIT_WANTED_BY="default.target"
|
||||
else
|
||||
INSTALL_DIR="/opt/lemon-ipw"
|
||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
# 系统级 unit 的 User= 行与 network-online 依赖;用户级没有这两个概念
|
||||
UNIT_USER_LINE="User=root
|
||||
"
|
||||
UNIT_NETWORK_LINES="After=network-online.target
|
||||
Wants=network-online.target
|
||||
"
|
||||
UNIT_WANTED_BY="multi-user.target"
|
||||
fi
|
||||
|
||||
# 同名服务已存在(例如这台机器已经跑着一个节点):再跑一次会用新生成的
|
||||
# 节点标识覆盖旧配置,旧节点在收集中心的记录就失效了,先提醒一句。
|
||||
if [ "$DRY_RUN" != "true" ] && [ -f "$SERVICE_FILE" ]; then
|
||||
echo "提示:已存在服务 ${SERVICE_NAME},本次会覆盖其配置并生成新的节点标识(旧标识在收集中心将失效)" >&2
|
||||
fi
|
||||
|
||||
PORTS="${OPT_PORT:-$(pick_port)}"
|
||||
case "$PORTS" in
|
||||
""|*[!0-9]*)
|
||||
echo "错误:端口必须是数字(当前值:$PORTS)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# 10# 前缀强制十进制:避免 "0800" 这类带前导 0 的值被当成八进制解析
|
||||
PORTS=$((10#$PORTS))
|
||||
if [ "$PORTS" -lt 1 ] || [ "$PORTS" -gt 65535 ]; then
|
||||
echo "错误:端口超出范围 1-65535(当前值:$PORTS)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$OPT_PORT" ] && port_in_use "$PORTS"; then
|
||||
echo "警告:端口 $PORTS 当前已被占用,节点可能启动失败" >&2
|
||||
fi
|
||||
|
||||
WS_URL="${OPT_WS_URL:-$WS_URL_DEFAULT}"
|
||||
# 逐段校验(逗号分隔可多个),并去掉段内空格:节点侧虽会 trim,
|
||||
# 但空格会原样进 systemd 的 Environment 值,还是在这里清掉更干净。
|
||||
WS_URL_SEGMENTS=""
|
||||
_old_ifs="$IFS"; IFS=','
|
||||
for _seg in $WS_URL; do
|
||||
# 去前导/尾随空白(IFS 只含逗号,空白不会被自动去掉)
|
||||
_seg="${_seg#"${_seg%%[![:space:]]*}"}"
|
||||
_seg="${_seg%"${_seg##*[![:space:]]}"}"
|
||||
[ -n "$_seg" ] || continue
|
||||
case "$_seg" in
|
||||
ws://*|wss://*) ;;
|
||||
*)
|
||||
IFS="$_old_ifs"
|
||||
echo "错误:WS 地址的每一段都需以 ws:// 或 wss:// 开头(问题段:${_seg})" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$_seg" in
|
||||
*/ws|*/ws/) ;;
|
||||
*) echo "警告:WS 地址 ${_seg} 看起来不含 /ws 路径,节点可能连不上(节点默认连 <地址>/ws)" >&2 ;;
|
||||
esac
|
||||
WS_URL_SEGMENTS="${WS_URL_SEGMENTS:+${WS_URL_SEGMENTS},}${_seg}"
|
||||
done
|
||||
IFS="$_old_ifs"
|
||||
if [ -z "$WS_URL_SEGMENTS" ]; then
|
||||
echo "错误:WS 地址为空(当前值:${WS_URL})" >&2
|
||||
exit 1
|
||||
fi
|
||||
WS_URL="$WS_URL_SEGMENTS"
|
||||
|
||||
NODE_ID=$(gen_uuid)
|
||||
NODE_KEY=$(gen_token)
|
||||
ACCESS_TOKEN=$(gen_token)
|
||||
|
||||
# ---------- 公网地址探测(失败不阻断) ----------
|
||||
|
||||
detect_ip() {
|
||||
local url="$1" out=""
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
out=$(curl -fsS -m 5 "$url" 2>/dev/null || true)
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
out=$(wget -qO- --timeout=5 "$url" 2>/dev/null || true)
|
||||
fi
|
||||
printf '%s' "$out" | tr -d ' \t\r\n'
|
||||
}
|
||||
|
||||
is_ipv4() { printf '%s' "$1" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}$'; }
|
||||
is_ipv6() {
|
||||
printf '%s' "$1" | grep -qE '^[0-9A-Fa-f:]+$' || return 1
|
||||
printf '%s' "$1" | grep -q ':' || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
IPV4=$(detect_ip "https://4.wsmdn.top/")
|
||||
is_ipv4 "$IPV4" || IPV4=""
|
||||
IPV6=$(detect_ip "https://6.wsmdn.top/")
|
||||
is_ipv6 "$IPV6" || IPV6=""
|
||||
|
||||
# 单栈判定:只有一侧能出网就按单栈跑,避免另一协议的拨测全失败刷错误日志
|
||||
SINGLE_STACK=""
|
||||
STACK_LABEL="双栈"
|
||||
if [ -n "$IPV4" ] && [ -z "$IPV6" ]; then
|
||||
SINGLE_STACK="ipv4"
|
||||
STACK_LABEL="IPv4"
|
||||
elif [ -z "$IPV4" ] && [ -n "$IPV6" ]; then
|
||||
SINGLE_STACK="ipv6"
|
||||
STACK_LABEL="IPv6"
|
||||
fi
|
||||
|
||||
# ---------- 版本与下载地址 ----------
|
||||
|
||||
fetch_latest_version() {
|
||||
local api="https://api.github.com/repos/nomdn/ipw-cn/releases/latest" raw=""
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
raw=$(curl -fsS -m 20 "$api" 2>/dev/null || true)
|
||||
else
|
||||
raw=$(wget -qO- --timeout=20 "$api" 2>/dev/null || true)
|
||||
fi
|
||||
printf '%s' "$raw" | grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4
|
||||
}
|
||||
|
||||
VERSION="$OPT_VERSION"
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(fetch_latest_version || true)
|
||||
fi
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "错误:无法获取最新版本号(GitHub API 不可达或限流)。可用 --version <tag> 指定,例如 --version v3.7.0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DOWNLOAD_URL="https://github.com/nomdn/ipw-cn/releases/download/${VERSION}/${RELEASE_ASSET}"
|
||||
if [ -n "$OPT_GH_PROXY" ]; then
|
||||
DOWNLOAD_URL="${OPT_GH_PROXY%/}/${DOWNLOAD_URL}"
|
||||
fi
|
||||
|
||||
# ---------- 输出节点信息 ----------
|
||||
|
||||
print_info() {
|
||||
local dry_note=""
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
dry_note="(dry-run,未实际安装)"
|
||||
fi
|
||||
local ipv4_disp="${IPV4:-检测失败}"
|
||||
local ipv6_disp="${IPV6:-检测失败}"
|
||||
local ipdb_disp="未启用(仅影响 IP 归属地/ASN 类拨测,需要时用 --ipdb 重装启用)"
|
||||
if [ "$OPT_IPDB" = "true" ]; then
|
||||
ipdb_disp="已启用(首次启动后台下载,约 450MB)"
|
||||
fi
|
||||
# WS 地址逐个列出(可能接多个中间件),首个带标签、其余对齐缩进
|
||||
local ws_block=""
|
||||
local _first="true" _u=""
|
||||
local _old_ifs="$IFS"; IFS=','
|
||||
for _u in $WS_URL; do
|
||||
if [ "$_first" = "true" ]; then
|
||||
ws_block=" WS 地址: ${_u}"
|
||||
_first="false"
|
||||
else
|
||||
ws_block="${ws_block}
|
||||
${_u}"
|
||||
fi
|
||||
done
|
||||
IFS="$_old_ifs"
|
||||
cat << EOF
|
||||
|
||||
========================================
|
||||
贡献节点${dry_note}已就绪,请把以下信息提交给管理员
|
||||
========================================
|
||||
节点 id: ${NODE_ID}
|
||||
注册 key: ${NODE_KEY}
|
||||
${ws_block}
|
||||
监听端口: ${PORTS}
|
||||
单栈模式: ${STACK_LABEL}
|
||||
access-token: ${ACCESS_TOKEN}
|
||||
CORS: 不限
|
||||
公网 IPv4: ${ipv4_disp}
|
||||
公网 IPv6: ${ipv6_disp}
|
||||
地区-运营商: 请自行填写
|
||||
----------------------------------------
|
||||
中间件 ws-keys(**每个 WS 地址对应的中间件都要登记**,缺一个该条连接会被拒 401):
|
||||
"${NODE_ID}": "${NODE_KEY}"
|
||||
中心 / 中间件 api-keys(HTTP 直连本节点时的鉴权):
|
||||
"${NODE_ID}": "${ACCESS_TOKEN}"
|
||||
----------------------------------------
|
||||
提交邮箱: ${CONTACT_EMAIL}
|
||||
说明:
|
||||
1. 本节点默认以 WS 方式同时接入上面的中间件(节点主动出站连接),无需公网放行 ${PORTS}/TCP。
|
||||
2. 若接收方希望以 HTTP 方式直连本节点,需放行 ${PORTS}/TCP 且该端口公网可达。
|
||||
3. IP 数据库: ${ipdb_disp}
|
||||
----------------------------------------
|
||||
运维命令(${SCOPE_LABEL}):
|
||||
状态: ${CTL_CMD} status ${SERVICE_NAME}
|
||||
日志: ${JRN_CMD} ${SERVICE_NAME} -f
|
||||
重启: ${CTL_CMD} restart ${SERVICE_NAME}
|
||||
卸载: ${CTL_CMD} disable --now ${SERVICE_NAME} && rm -f ${SERVICE_FILE} && rm -rf ${INSTALL_DIR}
|
||||
节点信息备份: ${INSTALL_DIR}/node-info.txt
|
||||
========================================
|
||||
EOF
|
||||
}
|
||||
|
||||
# ---------- dry-run:只展示 ----------
|
||||
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo "架构: $(uname -m) → ${RELEASE_ASSET}"
|
||||
echo "版本: ${VERSION}"
|
||||
echo "下载地址: ${DOWNLOAD_URL}"
|
||||
echo "安装目录: ${INSTALL_DIR}"
|
||||
echo "服务名: ${SERVICE_NAME}"
|
||||
echo "作用域: ${SCOPE_LABEL}"
|
||||
echo "随机: 端口=${PORTS} 节点id=${NODE_ID}"
|
||||
print_info
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------- 下载二进制 ----------
|
||||
|
||||
echo "正在下载 ${VERSION} / ${RELEASE_ASSET} ..."
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
wget -q -O "${INSTALL_DIR}/${BIN_BASE}" "$DOWNLOAD_URL"
|
||||
else
|
||||
# -f 让 4xx 直接失败,避免把 GitHub 的 Not Found 页面存成二进制
|
||||
curl -fsSL -o "${INSTALL_DIR}/${BIN_BASE}" "$DOWNLOAD_URL"
|
||||
fi
|
||||
chmod +x "${INSTALL_DIR}/${BIN_BASE}"
|
||||
|
||||
# ---------- 生成 systemd 服务 ----------
|
||||
|
||||
# 仅非空项写入,避免空值覆盖节点自身默认值。
|
||||
# 值内的双引号 / 反斜杠 / % 必须转义:前两者会破坏 systemd 的引号包裹,
|
||||
# % 是 unit 说明符(要写成 %% 才是字面量)。
|
||||
ENV_LINES=""
|
||||
add_env() {
|
||||
local name="$1" value="$2"
|
||||
[ -n "$value" ] || return 0
|
||||
value="${value//\\/\\\\}"
|
||||
value="${value//\"/\\\"}"
|
||||
value="${value//%/%%}"
|
||||
ENV_LINES="${ENV_LINES}Environment=\"${name}=${value}\"
|
||||
"
|
||||
}
|
||||
add_env PORTS "$PORTS"
|
||||
add_env SINGLE_STACK "$SINGLE_STACK"
|
||||
add_env WS_URL "$WS_URL"
|
||||
add_env NODE_ID "$NODE_ID"
|
||||
add_env NODE_KEY "$NODE_KEY"
|
||||
add_env ACCESS_TOKEN "$ACCESS_TOKEN"
|
||||
add_env IPDB "$OPT_IPDB"
|
||||
|
||||
# 用户级 unit 目录(~/.config/systemd/user)默认可能不存在
|
||||
mkdir -p "$(dirname "$SERVICE_FILE")"
|
||||
|
||||
cat > "$SERVICE_FILE" << EOF
|
||||
[Unit]
|
||||
Description=Lemon IPW Backend Node
|
||||
${UNIT_NETWORK_LINES}
|
||||
[Service]
|
||||
Type=simple
|
||||
${UNIT_USER_LINE}WorkingDirectory=${INSTALL_DIR}
|
||||
ExecStart=${INSTALL_DIR}/${BIN_BASE}
|
||||
${ENV_LINES}Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=${UNIT_WANTED_BY}
|
||||
EOF
|
||||
|
||||
# ---------- 启动服务 ----------
|
||||
|
||||
sc daemon-reload
|
||||
sc enable --now "$SERVICE_NAME" >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
if ! sc is-active --quiet "$SERVICE_NAME"; then
|
||||
echo "错误:服务未能启动,最近日志:" >&2
|
||||
jc -u "$SERVICE_NAME" --no-pager -n 20 >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 用户级服务默认随最后一个会话结束而停止,注销后要继续跑必须开 linger
|
||||
if [ "$SYSTEMD_MODE" = "user" ]; then
|
||||
if loginctl show-user "$(id -un)" -p Linger 2>/dev/null | grep -q 'Linger=yes'; then
|
||||
echo "linger 已启用:用户注销后服务继续运行"
|
||||
elif loginctl enable-linger "$(id -un)" >/dev/null 2>&1; then
|
||||
echo "已启用 linger:用户注销后服务继续运行"
|
||||
else
|
||||
echo "提示:未能自动启用 linger,注销后服务会被停止。请手动执行:" >&2
|
||||
echo " sudo loginctl enable-linger $(id -un)" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------- 落一份信息备份(节点标识随机,记不住) ----------
|
||||
|
||||
{
|
||||
echo "node_id=${NODE_ID}"
|
||||
echo "node_key=${NODE_KEY}"
|
||||
echo "access_token=${ACCESS_TOKEN}"
|
||||
echo "ws_url=${WS_URL}"
|
||||
echo "port=${PORTS}"
|
||||
echo "single_stack=${SINGLE_STACK:-双栈}"
|
||||
echo "service=${SERVICE_NAME}"
|
||||
echo "install_dir=${INSTALL_DIR}"
|
||||
echo "version=${VERSION}"
|
||||
} > "${INSTALL_DIR}/node-info.txt"
|
||||
chmod 600 "${INSTALL_DIR}/node-info.txt"
|
||||
|
||||
# 本地存活自检(只打本机回环,不依赖接收方是否已登记)
|
||||
LOCAL_CHECK="失败"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if curl -fsS -m 5 "http://127.0.0.1:${PORTS}/" >/dev/null 2>&1; then
|
||||
LOCAL_CHECK='正常({"status":"ok"})'
|
||||
fi
|
||||
fi
|
||||
echo "本地自检: ${LOCAL_CHECK}"
|
||||
|
||||
print_info
|
||||
@@ -22,20 +22,40 @@
|
||||
- `dnssec-server`:**DNSSEC 专用** DNS 服务器(同样支持主从逗号分隔);留空则沿用 `dns-server`;`dns-server` 与 `dnssec-server` **都留空时自动探测系统 DNS** 兜底
|
||||
- `block-private-ips`:SSRF 防护开关(拒绝出站连接内网/私有地址;探测目标命中私有网段时直接返回伪造结果,禁止跨跳重定向)
|
||||
- `single-stack`:单栈模式,`ipv4` / `ipv6` / 留空(双栈)。声明单栈的节点跳过另一栈的探测(直接返回 `Skipped due to SINGLE_STACK=...`),EdgeOne / Vercel 等 IPv4-only 环境建议设为 `ipv4`
|
||||
- `ipdb`:IP 数据库开关。**不配置时默认启用**(首次启动自动下载约 450MB,之后每 24h 更新);仓库的 `setting.json.example` 里显式写为 `"false"`,即**示例配置是关闭状态**——关闭后不加载任何数据库,`/v1/location`、`/v1/asn` 路由不注册,适合只做网络拨测的轻量节点
|
||||
- `ipdb`:IP 数据库开关。**不配置时默认启用**(首次启动自动下载约 450MB,之后每 24h 更新);仓库的 `setting.json.example` 里显式写为 `false`,即**示例配置是关闭状态**——关闭后不加载任何数据库,`/v1/location`、`/v1/asn` 路由不注册,适合只做网络拨测的轻量节点
|
||||
- `cors`:允许的请求来源(逗号分隔)
|
||||
- `trusted-proxies`:可信代理 IP/CIDR(逗号分隔,如 `"10.0.0.0/8,173.245.48.0/20"`)。配置后 `ClientIP` 与 `/v1/location` 归属地接口只信任这些代理转发的 `X-Forwarded-For`,直连公网的节点可防止访客伪造 XFF;**留空保持默认行为(信任所有代理,XFF 可被伪造)**
|
||||
- `access-token`:API 访问令牌,留空则不启用鉴权。**它同时是 HTTP 管理面的开关**:留空时 `/v1/config`、`/v1/ota` 整组返回 `403`,只能走 WS 通道管理
|
||||
- `gh-proxy`:GitHub 下载代理前缀(如 `"https://ghproxy.com/"`),用于 IP 数据库拉取与 OTA 下载加速;留空直连
|
||||
- `node-ota`:OTA 开关,缺省 `true`(允许收集中心下发)。设为 `false` 时节点拒绝一切 OTA 指令并回传原因,适合只读文件系统 / 编排托管(升级走各自部署渠道)的部署。**支持运行时热改**(`PATCH /v1/config` 或 WS `config` 下发即时生效,改后按新值判定后续 OTA 指令)
|
||||
- `access-log`:访问日志开关,缺省 `true`(每个请求输出一条 `http` 记录:method / path / status / latency_ms / ip)。设为 `false` 后不再输出访问日志,**适合高吞吐节点**——访问日志是每请求一次同步写,压测中即便业务逻辑极轻(`/` 健康检查)吞吐也会被它压住。关闭后 panic 兜底与其余日志不受影响,仍会记录 500 与堆栈。**支持运行时热改**(`PATCH /v1/config` 或 WS `config` 下发即时生效,无需重启)
|
||||
- `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)
|
||||
- `report-url`:**数据上报收集中心** HTTP 基址(如 `"https://collector.example.com"`,即 ipw-boce 中间件)。节点把自身观测的统计/拨测按 `report-interval-seconds` 周期上报;`ws-url` 已启用时优先走 WS `report` 消息(所有已连接中间件广播),WS 未启用或全部掉线时 HTTP POST 到 `<report-url>/report` 兜底;远端配置可覆盖(在 `remote-ignore-config` 里加上 `report-url` 可禁止覆盖)
|
||||
- `report-token`:收集中心 `/report` 鉴权 token(与中间件 `report-token` 配置一致);属凭据类键,GET 快照里遮蔽为 `***`;**不随远端配置覆盖**(硬编码保护,清单见本页末的「远端配置」一节)
|
||||
- `report-interval-seconds`:上报周期秒,缺省 15
|
||||
- `max-response-body`:单次拨测可读的响应体上限,**按解压后大小**算,缺省 `32MB`(33554432 字节);支持裸数字与 `K`/`M`/`G` 后缀(`32MB` / `8M` / `1g` / `8MiB` 均可,大小写不敏感)。`detail` / `speed` / `ssl` 只需要一个字节数,正常网页远小于默认值;超过上限**直接返回错误,而不是截断上报**——报一个"截断后的大小/速度"比报错更误导人。**支持运行时热改**(`PATCH /v1/config` 下发即应用到出站客户端)
|
||||
- `memory-limit`:Go 运行时的**软**内存上限(等价 `GOMEMLIMIT`),字节数,写法同上;**留空(=0)表示自动取容器内存限额的 90%**,探测不到容器限额就不设置、保持 Go 默认 GC 行为。不设时 Go 只按 `GOGC` 比例回收,够不到突增,小内存机器上堆会一路涨到被内核 OOM kill,中间没有减速带。**软上限不是硬闸**:必要时运行时仍会越过它,只是付出更多 GC 代价。**支持运行时热改**
|
||||
|
||||
以上字段均可用环境变量覆盖(`PORTS` / `SINGLE_STACK` / `DNS_SERVER` / `DNSSEC_DNS_SERVER` / `BLOCK_PRIVATE_IPS` / `IPDB` / `CORS` / `TRUSTED_PROXIES` / `GH_PROXY` / `ACCESS_TOKEN` / `WS_URL` / `NODE_ID` / `NODE_KEY` / `NODE_OTA` / `REPORT_URL` / `REPORT_TOKEN` / `REPORT_INTERVAL_SECONDS`)。需要从远端拉取配置时,设置 `remote-config-url` 或环境变量 `REMOTE_CONFIG_URL`(优先级:远端 > 环境变量 > setting.json);`remote-ignore-config` 对应的环境变量为 `REMOTE_IGNORE_CONFIG`(JSON 数组字符串)。
|
||||
以上字段均可用环境变量覆盖(`PORTS` / `SINGLE_STACK` / `DNS_SERVER` / `DNSSEC_DNS_SERVER` / `BLOCK_PRIVATE_IPS` / `ACCESS_LOG` / `IPDB` / `CORS` / `TRUSTED_PROXIES` / `GH_PROXY` / `ACCESS_TOKEN` / `WS_URL` / `NODE_ID` / `NODE_KEY` / `NODE_OTA` / `REPORT_URL` / `REPORT_TOKEN` / `REPORT_INTERVAL_SECONDS` / `MAX_RESPONSE_BODY` / `MEMORY_LIMIT`)。需要从远端拉取配置时,设置 `remote-config-url` 或环境变量 `REMOTE_CONFIG_URL`(优先级:远端 > 环境变量 > setting.json);`remote-ignore-config` 对应的环境变量为 `REMOTE_IGNORE_CONFIG`(JSON 数组字符串)。
|
||||
|
||||
> [!TIP]
|
||||
> 后端字段共 22 个,`src/setting.json.example` 只列**最小可用集**(11 个:端口 / 代理 / 单栈 / DNS / IP 库 / CORS / 令牌 / WS 身份)——其余按需加,完整定义以本页为准。
|
||||
|
||||
<a id="bool-switches"></a>
|
||||
|
||||
> [!NOTE]
|
||||
> **四个开关(`ipdb` / `block-private-ips` / `node-ota` / `access-log`)共用一套字面值口径**(忽略大小写与首尾空白):
|
||||
>
|
||||
> - 开:`true` / `1` / `yes` / `on` / `enable` / `enabled`
|
||||
> - 关:`false` / `0` / `no` / `off` / `disable` / `disabled`
|
||||
>
|
||||
> 不配置(空值)时各用各的默认值(`block-private-ips` / `node-ota` / `access-log` / `ipdb` 都缺省**开**)。JSON 里写布尔 `true` 或字符串 `"true"` 均可,数字 `1` / `0` 也可以 —— 四条来源(`setting.json` / 环境变量 / `PATCH /v1/config` / 远端下发)会先归一成同一张字面量表再判定,所以布尔与字符串的结果必然一致。
|
||||
>
|
||||
> **认不出来的值不再静默当成"开"**:启动阶段告警并按默认值处理;`PATCH /v1/config` 与远端下发会把它计入应答的 `unknown` 并保留原值,便于发现拼错的键值。
|
||||
>
|
||||
> 历史差异(升级时注意):旧版本里只有 `node-ota` 认 `yes`/`no`/`off`,在 `ipdb` / `block-private-ips` / `access-log` 上写 `no` / `off` 会被当成**开**,`ipdb` 还大小写敏感(`"False"` 也是开)。现在四个开关统一按上表判定。
|
||||
|
||||
### 运行时配置管理
|
||||
|
||||
@@ -51,7 +71,7 @@
|
||||
|
||||
- **凭据类键不回显明文**:`access-token` / `node-key` / `report-token` 在快照里是 `***`。下发时必须剔除这些键,否则会把真值覆盖成三个星号(收集中心控制台已自动剔除)。
|
||||
- **凭据类键中 `access-token` / `report-token` 不受远端配置影响**:它们被硬编码进节点侧的保护名单(`configRemoteProtectedKeys`),远端配置里写了也会被跳过,并在应答 `protectedIgnored` 与日志中如实回报;本地 PATCH 不受此限。原因见本页末的「远端配置」一节。
|
||||
- **节点不自行重启**:`port` / `cors` / `ipdb` / `report-interval-seconds` / `trusted-proxies` / `node-id` / `node-key` / `access-token` 是启动期固定的,改动后在应答 `restartRequired` 里如实列出,但**不会自动重启**;只有"值真的变了"才报,避免每次全量下发都误报。**`ws-url` 是例外,热生效**(控制器按新地址多退少补);`dns-server` / `dnssec-server` / `block-private-ips` / `single-stack` / `node-ota` 每次消费重读,同样即时生效。
|
||||
- **节点不自行重启**:`port` / `cors` / `ipdb` / `report-interval-seconds` / `trusted-proxies` / `node-id` / `node-key` / `access-token` 是启动期固定的,改动后在应答 `restartRequired` 里如实列出,但**不会自动重启**;只有"值真的变了"才报,避免每次全量下发都误报。**`ws-url` 是例外,热生效**(控制器按新地址多退少补);`dns-server` / `dnssec-server` / `block-private-ips` / `single-stack` / `node-ota` / `access-log` / `max-response-body` / `memory-limit` 每次消费重读,同样即时生效。
|
||||
- **内存改动会被重启顶掉**:节点侧优先级为 远端 > 环境变量 > `setting.json`。只 PATCH 内存(或 `persist` 写本地文件)时,重启后仍可能被 ENV 顶掉;要长期生效应 **`refresh`**(改远端配置)或把改动合并进托管配置。
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
@@ -103,6 +103,10 @@ node .output/server/index.mjs # 默认监听 3000 端口
|
||||
- **所需 Secrets**:`CLOUDFLARE_API_TOKEN`、`CLOUDFLARE_ACCOUNT_ID`
|
||||
- **注意**:工作流设置了 `NITRO_PRESET: cloudflare_module`
|
||||
|
||||
## 缓存
|
||||
|
||||
缓存头不在应用侧设置,全部交由前置 CDN(EdgeOne)控制。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- **部署后接口 403 / 跨域**:检查后端 `cors` 配置(独立中间件见 `middleware-go/setting.json` 的 `cors` 字段,逗号分隔允许域名);服务端转发请求不带 `Origin`,浏览器直接调用才受 CORS 限制。
|
||||
|
||||
@@ -90,14 +90,16 @@ middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入
|
||||
|
||||
| 方向 | type | data 说明 |
|
||||
|------|------|-----------|
|
||||
| 节点 → middleware | `register` | `{ "nodeId": "...", "key": "..." }`,连接后首条消息,同 id 新连接顶掉旧连接;`key` 为注册凭证,与 setting.json `ws-keys` 配置比对 |
|
||||
| 节点 → middleware | `register` | `{ "nodeId": "...", "key": "...", "version": "...", "capabilities": [...] }`,连接后首条消息,同 id 新连接顶掉旧连接;`key` 为注册凭证,与 setting.json `ws-keys` 配置比对 |
|
||||
| 节点 → middleware | `probe_result` | `{ "requestId": "...", "status": 200, "body": <JSON 值> }`(body 为 JSON 字符串时按原文透传) |
|
||||
| 节点 → middleware | `ping` / `pong` | 心跳:节点每 10s 发 `ping`(middleware 回 `pong`);`pong` 为对 middleware `ping` 的应答 |
|
||||
| 节点 → middleware | `report` | 统计 + 拨测明细的周期上报(同 `/report` body)。节点会**向所有已连接中间件广播**(多活),消费者是收集中心 ipw-boce;middleware-go 只做转发与拨测代理、不在转发路径采集,收到后**静默丢弃**(不打"未知消息"日志) |
|
||||
| 节点 → middleware | `config_result` / `ota_result` | 运行时配置 / OTA 指令的应答。只有对端下发过 `config` / `ota` 才会出现;middleware-go 不下发这类指令(远程运维归收集中心),正常收不到,收到同样静默丢弃 |
|
||||
| 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 → 节点 | `ping` | 心跳(每 20s),节点回 `pong` |
|
||||
| middleware → 节点 | `status` | 状态/统计上报:`{ "uptimeSeconds": ..., "totalRequests": ..., "errorRequests": ... }`(每 20s) |
|
||||
| 节点 → middleware | `command` | 指令下发,如 `{ "command": "reload_config" }`(重读配置;与并发请求存在竞态,生产建议重启生效) |
|
||||
|
||||
### 节点侧接入(WS 客户端)
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ systemctl restart lemon-ipw
|
||||
|
||||
节点无论是否接入 WS,都会上报自身版本与支持的管理能力:
|
||||
|
||||
- **版本号**来自构建时注入的 `VERSION`,随 WS `register` 报文上报(字段 `version`);纯 HTTP 节点(无 WS 连接)由收集中心探活 `GET /` 时一并取回(响应体含 `version`)。
|
||||
- **版本号**来自构建时注入的 `VERSION`,随 WS `register` 报文上报(字段 `version`);纯 HTTP 主节点(无 WS 连接)由收集中心探活后再请求 `GET /info` 取回(`GET /` 只返回 `{"status":"ok"}`)。边缘函数版本不提供 `/info`。
|
||||
- **能力清单**(`capabilities`)取值为 `probe` / `report` / `config` / `ota`,用于让收集中心在下发管理指令前判断该节点能否理解——老版本节点的消息循环没有对应分支,收到管理消息会**静默忽略**,只能靠超时暴露。
|
||||
- 收集中心「节点状态」页展示每个节点的版本号,并据此判断哪些节点需要升级;节点升级部署后重新注册即为新版本。
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
| 通道 | 鉴权方式 | 未配置凭据时 |
|
||||
|------|----------|--------------|
|
||||
| 业务探测 `/v1/*` | `access-token` 非空时校验 `Authorization: Bearer <access-token>`,不符返回 `401` | 不启用鉴权,公开可访问 |
|
||||
| 健康检查 `/` | 无 | — |
|
||||
| 健康检查 `/`、节点信息 `/info` | 无 | — |
|
||||
| 管理接口 `/v1/config`、`/v1/ota`(HTTP) | 同业务接口,复用 `tokenCheck` | **整组关闭**,一律返回 `403`(节点未配 `access-token` 时业务接口本身也无鉴权,暴露配置读写等于公网可改配置) |
|
||||
| WS 通道 | 中间件 `ws-keys` 校验注册报文 | WS **常开**:节点是客户端,指令只来自已通过中间件校验的连接,与节点是否配 `access-token` 无关 |
|
||||
|
||||
@@ -17,13 +17,19 @@
|
||||
|
||||
---
|
||||
|
||||
## 一、健康检查
|
||||
## 一、健康检查与节点信息
|
||||
|
||||
```
|
||||
GET / → {"status":"ok","version":"v1.2.3","capabilities":["probe","report","config","ota"]}
|
||||
GET / → {"status":"ok"}
|
||||
GET /info → {"version":"v1.2.3","capabilities":["probe","report","config","ota"]}
|
||||
```
|
||||
|
||||
无鉴权、不计入统计。除存活外同时回报**版本号**与**能力清单**:收集中心对纯 HTTP 节点(无 WS 连接)探活时一并取回,用于节点状态页展示版本、以及下发管理指令前判断该节点能否理解对应指令。
|
||||
两个端点都无鉴权、不计入统计,且都注册在 `/v1` 组之外——因此既不受 `access-token` 约束,也不经过业务统计中间件。
|
||||
|
||||
- **健康检查 `/`**:只回答"进程是否活着"。给负载均衡、OTA 就绪探测(`otaWaitChildReady`)与运维 `curl` 用。**不回报版本**:它是免鉴权的对外端点,回版本号等于向公网公开节点版本。
|
||||
- **节点信息 `/info`**:回报节点身份。`version` 为版本号,`capabilities` 为能力清单(与 WS `register` 报文同源,见 `ws.go nodeCapabilities`)。收集中心对纯 HTTP 节点(无 WS 连接)探活后单独取回,用于节点状态页展示版本、以及下发管理指令前判断该节点能否理解对应指令。
|
||||
|
||||
> 老版本节点没有 `/info`(返回 `404`),边缘函数版节点同样不提供;收集中心取不到就留空,不影响判活。
|
||||
|
||||
---
|
||||
|
||||
@@ -174,7 +180,7 @@ POST /v1/ota
|
||||
|
||||
## 六、能力清单(capabilities)
|
||||
|
||||
节点在 `register` 报文与 `GET /` 中上报自身支持的管理能力,取值为 `probe` / `report` / `config` / `ota` 的数组。
|
||||
节点在 `register` 报文与主节点的 `GET /info` 中上报自身支持的管理能力,取值为 `probe` / `report` / `config` / `ota` 的数组。边缘函数版本不提供 `/info`,也不回报能力清单。
|
||||
|
||||
收集中心据此做**三态判定**,绝不能把"未知"当作"不支持":
|
||||
|
||||
|
||||
@@ -31,5 +31,5 @@ features:
|
||||
- title: 远程运维
|
||||
details: 节点上报版本号与能力清单,收集中心展示版本、远程读写运行时配置,并可下发一次性 OTA 升级任务(下载 → 校验 → 替换 → 重启,失败自动回滚)。
|
||||
- title: 多平台部署
|
||||
details: 支持二进制,Docker,乃至AWS Lambda,EdgeOne Makers,Vercel和阿里云函数计算
|
||||
details: 支持二进制、Docker,以及 EdgeOne Makers 和 Vercel 部署
|
||||
---
|
||||
@@ -74,6 +74,6 @@
|
||||
|------|------|
|
||||
| 二进制 / systemd | `install.sh` 一键安装,配置以环境变量注入 |
|
||||
| Docker | `src/Dockerfile` 多阶段构建,多架构(amd64 / arm64 / armv7);镜像不含 IP 库,首次运行现场拉取 |
|
||||
| Serverless | EdgeOne Makers、Vercel、AWS Lambda、阿里云函数计算等(均为 IPv4-only,需声明 `SINGLE_STACK=ipv4`) |
|
||||
| Serverless | EdgeOne Go 边缘函数、Vercel 部署入口(仓库提供实现;均为 IPv4-only,需声明 `SINGLE_STACK=ipv4`) |
|
||||
|
||||
各形态的详细步骤见 [后端节点部署](/guide/deploy-node)。
|
||||
@@ -32,7 +32,7 @@ Lemon IPW(柠檬味 ipw.cn 替代品)是一个开源的分布式站点测试
|
||||
- 后端(Go)提供全部检测 API,可在任意地区部署测试节点,是分布式查询的核心
|
||||
- 独立中间件(middleware-go)使用Fiber技术栈 负责请求转发和 Key 注入
|
||||
|
||||
后端支持二进制、Docker 自托管,也支持 AWS Lambda、EdgeOne Makers、Vercel、阿里云函数计算等 Serverless 平台部署。
|
||||
后端支持二进制、Docker 自托管,以及仓库中提供的 EdgeOne Go 边缘函数和对应的 Vercel 部署入口。AWS Lambda、阿里云函数计算目前没有对应的实现或部署配置。
|
||||
|
||||
## 技术栈
|
||||
|
||||
|
||||
@@ -839,8 +839,8 @@ packages:
|
||||
vfile@6.0.3:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
vite@5.4.14:
|
||||
resolution: {integrity: sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==}
|
||||
vite@5.4.21:
|
||||
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -1302,9 +1302,9 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.3': {}
|
||||
|
||||
'@vitejs/plugin-vue@5.2.1(vite@5.4.14)(vue@3.5.41)':
|
||||
'@vitejs/plugin-vue@5.2.1(vite@5.4.21)(vue@3.5.41)':
|
||||
dependencies:
|
||||
vite: 5.4.14
|
||||
vite: 5.4.21
|
||||
vue: 3.5.41
|
||||
|
||||
'@vue/compiler-core@3.5.41':
|
||||
@@ -1733,7 +1733,7 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
vite@5.4.14:
|
||||
vite@5.4.21:
|
||||
dependencies:
|
||||
esbuild: 0.21.3
|
||||
postcss: 8.5.26
|
||||
@@ -1806,7 +1806,7 @@ snapshots:
|
||||
'@shikijs/transformers': 2.1.0
|
||||
'@shikijs/types': 2.1.0
|
||||
'@types/markdown-it': 14.1.2
|
||||
'@vitejs/plugin-vue': 5.2.1(vite@5.4.14)(vue@3.5.41)
|
||||
'@vitejs/plugin-vue': 5.2.1(vite@5.4.21)(vue@3.5.41)
|
||||
'@vue/devtools-api': 7.7.10
|
||||
'@vue/shared': 3.5.41
|
||||
'@vueuse/core': 12.4.0
|
||||
@@ -1815,7 +1815,7 @@ snapshots:
|
||||
mark.js: 8.11.1
|
||||
minisearch: 7.2.0
|
||||
shiki: 2.1.0
|
||||
vite: 5.4.14
|
||||
vite: 5.4.21
|
||||
vue: 3.5.41
|
||||
optionalDependencies:
|
||||
postcss: 8.5.26
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, onBeforeUnmount, ref } from 'vue';
|
||||
import { useDark, useToggle } from '@vueuse/core';
|
||||
import { Moon, Sunny, Expand } from '@element-plus/icons-vue';
|
||||
import { config } from '../config/index';
|
||||
@@ -11,6 +11,12 @@ const drawer = ref(false);
|
||||
const isDark = useDark();
|
||||
const toggleDark = useToggle(isDark);
|
||||
|
||||
// ==================== 抽屉里的文档菜单 ====================
|
||||
// 窄屏下 /doc 页面自己的左侧侧栏会被隐藏(见 app/pages/doc/*.vue),
|
||||
// 文档导航改由这里的抽屉承载;宽屏不渲染抽屉,一切照旧。
|
||||
const route = useRoute()
|
||||
const isDocRoute = computed(() => route.path === '/doc' || route.path.startsWith('/doc/'))
|
||||
|
||||
function cleanChineseCharacters(str:string) {
|
||||
return str.replace(/[\u4e00-\u9fa5]/g, '');
|
||||
}
|
||||
@@ -84,9 +90,24 @@ const hidden = computed<Array<NavItem | NavGroup>>(
|
||||
entry.kind !== 'divider' && viewportWidth.value < entry.minWidth),
|
||||
)
|
||||
|
||||
// EP 的 Menu.init() 会在挂载时(以及菜单项集合变化时)遍历顶栏 <ul> 的**每个直接子元素**、
|
||||
// 统一写 tabindex="0" —— 连纯装饰的分隔条也不放过。调用栈实测:
|
||||
// Menu.init → Array.forEach → new MenuItem(el).init → setAttribute('tabindex','0')
|
||||
// 分隔条是 aria-hidden 的装饰元素,留着 tabindex="0" 会被 axe 判为 aria-hidden-focus
|
||||
// ("Focusable content should have tabindex=-1 or be removed from the DOM")——
|
||||
// 这一条比它替换掉的 aria-required-attr 更靠后,所以必须显式改回去。
|
||||
// 改的时机:子组件 mounted 早于父组件,onMounted 里 EP 已经 init 完;
|
||||
// 但要等 DOM 更新落定 —— updateViewport() 会改 visible、让 el-menu 重渲染并再 init 一遍。
|
||||
function fixDividerTabindex() {
|
||||
document.querySelectorAll('.menu-divider').forEach((el) => {
|
||||
el.setAttribute('tabindex', '-1')
|
||||
})
|
||||
}
|
||||
|
||||
// 实时同步视口宽度(resize 触发,驱动折叠)
|
||||
function updateViewport() {
|
||||
viewportWidth.value = window.innerWidth
|
||||
nextTick(fixDividerTabindex)
|
||||
}
|
||||
let umamiScript: HTMLScriptElement | null = null
|
||||
useHead({
|
||||
@@ -95,8 +116,9 @@ useHead({
|
||||
{ name: 'robots', content: 'noindex, nofollow' },
|
||||
{ name: 'googlebot', content: 'noindex, nofollow' },
|
||||
{ name: 'bingbot', content: 'noindex, nofollow' },
|
||||
{ name:'baidu-site-verification', content: 'codeva-xzdbvF8gQu'}
|
||||
]
|
||||
: [],
|
||||
: [{ name:'baidu-site-verification', content: 'codeva-xzdbvF8gQu'}],
|
||||
});
|
||||
onMounted(() => {
|
||||
mediaQueryList = window.matchMedia('(max-width: 768px)');
|
||||
@@ -128,7 +150,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
|
||||
<el-drawer v-if="isNarrow" v-model="drawer" direction="ltr" style="height: 100%;" size="50%">
|
||||
<el-drawer v-if="isNarrow" v-model="drawer" direction="ltr" style="height: 100%;" :size="isDocRoute ? '80%' : '60%'">
|
||||
<router-link to="/ipv6webcheck">
|
||||
<p class="menu-item-text">IPv6 网站检测</p>
|
||||
</router-link>
|
||||
@@ -149,16 +171,46 @@ onMounted(() => {
|
||||
<router-link to="/whois"><p class="menu-item-text">Whois查询</p></router-link>
|
||||
<router-link to="/asn"><p class="menu-item-text">ASN查询</p></router-link>
|
||||
<router-link to="/dnssec"><p class="menu-item-text">DNSSEC验证</p></router-link>
|
||||
<!-- 窄屏唯一的文档入口:非文档页面抽屉里不挂整份文档菜单(太长),
|
||||
只留这一个链接,对应宽屏顶栏 index 9 的「文档」项。
|
||||
进入文档页后,抽屉里才会出现完整的文档导航。 -->
|
||||
<router-link v-if="!isDocRoute" to="/doc" @click="drawer = false"><p class="menu-item-text">文档</p></router-link>
|
||||
<!-- 文档导航并进同一个左侧菜单:与 /doc 页面用的是同一个组件。
|
||||
只在文档路由下出现(非文档页面抽屉里就只留工具链接);
|
||||
文档路由下分组默认展开(手机上没 hover,省一次点击)。
|
||||
:key 是为了让分组开合重新初始化 —— el-menu 只在挂载时读一次 default-openeds,
|
||||
抽屉一旦打开过就不会重挂,不加 key 会停在首次打开时的开合状态。 -->
|
||||
<DocMenu
|
||||
v-if="isDocRoute"
|
||||
:key="'doc-expanded'"
|
||||
expand-all
|
||||
@select="drawer = false"
|
||||
/>
|
||||
</el-drawer>
|
||||
<el-menu
|
||||
mode="horizontal"
|
||||
:ellipsis="false"
|
||||
|
||||
>
|
||||
<el-menu-item index="0">
|
||||
<!-- 宽屏不给 aria-label:这个 <li> 里有可见文本(右侧的 h2 站名),而 axe 的
|
||||
label-content-name-mismatch 要求 aria-label 必须把可见文本包含进去 ——
|
||||
写死「返回首页」只会被判不匹配(Lighthouse 桌面独有项,权重 0 不扣分,但语义是错的)。
|
||||
窄屏 h2 被 v-if 隐藏、img 的 alt 留空、el-icon 也没有文本 ⇒ 那时才需要
|
||||
aria-label 兜底,否则承载 role="menuitem" 的 <li> 就没有可访问名(aria-command-name)。
|
||||
里面那个 <router-link> 同理(它自己也是一个可访问名有冲突的目标),
|
||||
宽屏靠 h2 的「柠檬味ipw.cn」当名字,窄屏才补「返回首页」。 -->
|
||||
<el-menu-item index="0" :aria-label="isNarrow ? '返回首页' : undefined">
|
||||
<el-icon v-if="isNarrow" @click="drawer = !drawer"><Expand /></el-icon>
|
||||
<router-link to="/">
|
||||
<el-image src="/favicon.svg" style="margin-top: 20px;" />
|
||||
<router-link to="/" :aria-label="isNarrow ? '返回首页' : undefined">
|
||||
<!-- alt 留空是有意的:logo 属装饰图,可访问名由外层链接提供。
|
||||
若把 alt 写成站名,宽屏下会与旁边的 h2 一起被读两遍。
|
||||
这里用原生 <img> 而不是 el-image:el-image 默认 lazy,SSR 只输出一个
|
||||
`.el-image__placeholder` 空壳,真正的 <img> 要等客户端水合后由
|
||||
IntersectionObserver 插进去 ⇒ logo 在首屏 HTML 里不存在(无 JS 时永不显示,
|
||||
有 JS 时也要闪一下)。原生 <img> 直接进 HTML,还顺带省掉 el-image
|
||||
及其 image-viewer 的样式。宽高属性声明固有比例,显示宽度由下文
|
||||
:deep(.el-menu-item a img) 定为 50px。 -->
|
||||
<img src="/favicon.svg" alt="" width="50" height="50" style="margin-top: 20px;" />
|
||||
<h2 style="display: inline-block; margin-left: 10px" v-if="!isNarrow">{{ config.siteName }}</h2>
|
||||
</router-link>
|
||||
</el-menu-item>
|
||||
@@ -175,7 +227,13 @@ onMounted(() => {
|
||||
<router-link :to="child.to"><p class="menu-item-text">{{ child.label }}</p></router-link>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-divider v-else style="margin-top: 20px;height: 1.2em;" direction="vertical"/>
|
||||
<!-- 分组之间的竖分隔条。原来是 <el-divider direction="vertical">,但它在客户端水合后
|
||||
会被 EP 补上 tabindex="0"(SSR HTML 里干净、没有这个属性),于是 axe 把
|
||||
role="separator" 当成「可聚焦控件」并要求它带 aria-valuenow —— 这就是桌面端
|
||||
aria-required-attr 那一条(Lighthouse 权重 10,是桌面 a11y 掉 12 分里的最大单项)。
|
||||
换成 aria-hidden 的原生 <span>:装饰元素不参与焦点,也不会被 EP 改写属性。
|
||||
视觉与 .el-divider--vertical 等价,数值见下方 .menu-divider 的注释。 -->
|
||||
<span v-else class="menu-divider" aria-hidden="true"></span>
|
||||
</template>
|
||||
<!-- 折叠项:统一收进「更多」子菜单(分组用 el-menu-item-group 保留组名) -->
|
||||
<el-sub-menu v-if="hidden.length" index="more">
|
||||
@@ -192,7 +250,8 @@ onMounted(() => {
|
||||
</template>
|
||||
</el-sub-menu>
|
||||
</template>
|
||||
<el-menu-item index="10">
|
||||
<!-- 纯图标菜单项:无可访问名,补 aria-label(图标本身不带文字) -->
|
||||
<el-menu-item index="10" aria-label="切换深色/浅色模式">
|
||||
<ClientOnly>
|
||||
<el-icon @click="toggleDark()" v-if="isDark" style="cursor: pointer;"><Moon style="height: 20px; width: 20px;"/></el-icon>
|
||||
<el-icon @click="toggleDark()" v-else style="cursor: pointer;"><Sunny style="height: 20px; width: 20px;"/></el-icon>
|
||||
@@ -207,15 +266,24 @@ onMounted(() => {
|
||||
<NuxtPage />
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<!-- 文档路由不渲染页脚:文档页是阅读页,正文底部加备案/版权块会打断阅读,
|
||||
且窄屏下与侧栏抽屉的可滚动区域互相挤压。 -->
|
||||
<footer v-if="!isDocRoute">
|
||||
<div class="one-line">
|
||||
Copyright © nomdn & IP 查询 2026 | <img src="/ipv6-s1.svg" alt="IPv6 相关标识"/> | <img src="/ssl-s1.svg" alt="SSL 相关标识"/> | All right reserved
|
||||
<!-- 这两张 svg 补上 width/height 属性(= 各自的固有尺寸 190×36 / 85×36):
|
||||
浏览器要在图下载完之前就知道占位比例,否则会先把整行按 0 高度排一遍、
|
||||
图到了再重排(Lighthouse 的 unsized-images 就是这条)。
|
||||
属性只声明固有比例,不改变显示尺寸:桌面端本来就是按固有尺寸渲染,
|
||||
窄屏由 style.css 的 `footer .one-line img { height:1.2em; width:auto }` 接管。 -->
|
||||
Copyright © nomdn & IP 查询 2026 | <img src="/ipv6-s1.svg" alt="IPv6 相关标识" width="190" height="36"/> | <img src="/ssl-s1.svg" alt="SSL 相关标识" width="85" height="36"/> | All right reserved
|
||||
</div>
|
||||
<div class="one-line">
|
||||
<a v-if="config.ICP" href="https://beian.miit.gov.cn/" target="_blank" rel="noreferrer" >{{ config.ICP }}</a>
|
||||
<span v-if="config.ICP"> | </span>
|
||||
<el-image v-if="config.GongAn" style="height: 1em; width: 1em;" src="/备案图标.png" />
|
||||
<a :href="'https://beian.mps.gov.cn/#/query/webSearch?code=' + cleanChineseCharacters(config.GongAn)" target="_blank" rel="noreferrer" >{{ config.GongAn }}</a>
|
||||
<!-- 同上:原生 <img> 而非 el-image(1em 见方的小图标,用不着 lazy/预览器)。
|
||||
显示尺寸由行内 style 定死;width/height 属性只声明固有比例(36×40),防抖动。 -->
|
||||
<img v-if="config.GongAn" src="/备案图标.png" alt="" width="36" height="40" style="height: 1em; width: 1em;" />
|
||||
<a v-if="config.GongAn" :href="'https://beian.mps.gov.cn/#/query/webSearch?code=' + cleanChineseCharacters(config.GongAn)" target="_blank" rel="noreferrer" >{{ config.GongAn }}</a>
|
||||
<span v-if="config.GongAn"> | </span>
|
||||
<a href="https://www.china-ipv6.cn/" target="_blank" rel="noreferrer" >国家IPv6发展监测平台</a>
|
||||
| 请遵守中国法律法规 |
|
||||
@@ -261,41 +329,115 @@ onMounted(() => {
|
||||
margin-bottom: 20px;
|
||||
|
||||
}
|
||||
/* 顶栏分组之间的竖分隔条(template 里那个 aria-hidden 的 span)。
|
||||
数值抄自 element-plus 的 .el-divider--vertical:1px 左边框、1.2em 高、
|
||||
margin 左右各 8px、vertical-align: middle;再加上原来行内 style 的 margin-top: 20px
|
||||
与 height: 1.2em,保证与替换前的排布一致。 */
|
||||
.menu-divider {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1px;
|
||||
height: 1.2em;
|
||||
margin: 20px 8px 0;
|
||||
vertical-align: middle;
|
||||
border-left: 1px solid var(--el-border-color);
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
}
|
||||
html.dark {
|
||||
/* 暗色模式下根元素的文字色与页面底色。
|
||||
这两条必须在【非 scoped】块里:<html> 永远不会带上组件 scope 属性,
|
||||
若写在 <style scoped>(含被 scoped @import 进来的 style.css)里,会被改写成
|
||||
`html.dark[data-v-xxxx]` —— 整条规则永不匹配,底色只能靠浏览器的 color-scheme 兜底。 */
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
--el-color-primary: #3EAF7C;
|
||||
}
|
||||
/* 文字链接按模式拆色:亮色下 #3EAF7C 在白底只有 2.75:1、在引用块底 #f9f9f9 上 2.61:1
|
||||
(axe 的 color-contrast,权重 7,是移动端 a11y 唯一的扣分项);换成同色系加深的
|
||||
#2F7D5A 后分别是 5.00:1 / 4.75:1。暗色底上 #3EAF7C 本来就有 5.64:1 ⇒ 保留品牌色。
|
||||
必须写在这个【非 scoped】块里:style.css 是被 <style scoped> @import 进来的,
|
||||
根元素选择器会被加上 scope 属性而永不匹配(同上面底色那两条的原因)。
|
||||
另外这三条要逐一列出而不是只写 `html.dark a`:footer .one-line a 的特异性是 (0,1,2),
|
||||
比 `html.dark a` 的 (0,1,1) 高,不显式覆盖的话页脚链接在暗色下会留在深绿。 */
|
||||
html.dark a,
|
||||
html.dark blockquote a,
|
||||
html.dark footer .one-line a,
|
||||
html.dark .header-anchor {
|
||||
color: #3EAF7C;
|
||||
}
|
||||
|
||||
/* Drawer 内部链接占满一行 */
|
||||
/* Drawer 内部链接占满一行。
|
||||
注意用直接子选择器(> a):文档菜单(DocMenu)里的 <a> 嵌在 el-menu-item 里,
|
||||
不能被这套「整行大按钮」规则吃掉,否则菜单项会变成上下带 1em 内边距的块。
|
||||
间距三层叠加会挤没文字(body 20px + a 1em + p margin-left 10px),
|
||||
所以 body 归零、a 只留一层、p 的 margin 清掉,字号定死 15px 保证最长的
|
||||
「IPv6/IPv4 地址查询」在 320px 屏的 60% 抽屉里也放得下。 */
|
||||
.el-drawer__body {
|
||||
overflow-x: hidden;
|
||||
padding: 6px 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
.el-drawer__body a {
|
||||
.el-drawer__body > a {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 1em;
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.el-drawer__body a p {
|
||||
.el-drawer__body > a p.menu-item-text {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 并进抽屉的文档菜单:与上面的工具链接视觉上分段,并去掉菜单自带底色与右边框
|
||||
(.el-menu 默认 border-right,竖排菜单是给页面侧栏用的,在抽屉里会多出一条竖线) */
|
||||
.el-drawer__body .el-menu {
|
||||
background-color: transparent;
|
||||
border-right: none;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
padding-top: 8px;
|
||||
}
|
||||
/* 手机上条目多,行高压到 44px 让一屏能多看几项(默认 56px)。
|
||||
文档标题可能很长(如「Windows 10/11 设置 IPv4/IPv6 访问优先级」),
|
||||
截断会丢信息 → 允许换行,高度改 auto 只保 44px 最小行高。 */
|
||||
/* 手机上条目多,行高压到 44px 让一屏能多看几项(默认 56px)。
|
||||
注意:element-plus 竖排菜单规则是
|
||||
`.el-menu--vertical:not(..)×3 .el-menu-item`(特异性 5 个类),
|
||||
这里必须 !important 才压得住 height / line-height / white-space。
|
||||
文档标题可能很长(如「Windows 10/11 设置 IPv4/IPv6 访问优先级」),
|
||||
截断会丢信息 → 允许换行,高度改 auto 只保 44px 最小行高。 */
|
||||
.el-drawer__body .el-menu .el-menu-item,
|
||||
.el-drawer__body .el-menu .el-sub-menu__title {
|
||||
height: auto !important;
|
||||
min-height: 44px;
|
||||
line-height: 1.45 !important;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
white-space: normal !important; /* 长标题裸文本在 li 上,必须解掉 nowrap */
|
||||
}
|
||||
.el-drawer__body .el-menu .el-menu-item a,
|
||||
.el-drawer__body .el-menu .el-menu-item p {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 窄屏(≤768px,与 script 中 isNarrow 的 matchMedia 断点一致):
|
||||
在水合前由 CSS 兜底隐藏宽屏菜单项,避免窄屏设备闪现宽屏布局 */
|
||||
@media (max-width: 768px) {
|
||||
.el-menu--horizontal > .el-divider {
|
||||
.el-menu--horizontal > .menu-divider {
|
||||
display: none !important;
|
||||
}
|
||||
.el-menu--horizontal > .el-menu-item[index="1"],
|
||||
@@ -317,6 +459,39 @@ html.dark {
|
||||
--el-menu-bg-color: transparent !important;
|
||||
}
|
||||
|
||||
/* 顶栏菜单项的点击目标:让内层 <a> 撑满整个 <li>。
|
||||
axe 的 target-size(WCAG 2.2 的 2.5.8,24×24 最小目标)会把 <li role="menuitem">
|
||||
减掉内层 <a> 之后剩下的「侧翼」算成被遮挡的空间:EP 默认给 li 左右各 20px 内边距,
|
||||
而 <a> 只占中间 97px 宽,于是报
|
||||
"partially obscured (smallest space is 20px by 59px)" —— 桌面 7 条、权重 7。
|
||||
有两条路:① 把 --el-menu-base-level-padding 提到 >24px —— 顶栏整体就变宽,
|
||||
1350px 下直接横向溢出 45px(各档折叠阈值还得跟着重算);② 让 <a> 撑满 li —— 本条。
|
||||
选②:li 宽度、文字位置、分隔条位置与改动前【逐像素一致】(CDP 量 rect 比对过),
|
||||
可点区域却从 97×59 变成整项 137×59,远超 24×24。
|
||||
- flex + align-items:center 是为了复现原来 inline 盒子靠 line-height 得到的垂直居中
|
||||
(logo 的 img 顶边仍在 li 顶 +4.5px 处,改完不位移);
|
||||
- 负 margin 抵消 li 的左右内边距,与 padding 取同一个变量,改内边距时不会脱节;
|
||||
- height:100% 成立是因为 EP 给 .el-menu-item 写了确定的 height(--el-menu-item-height)。
|
||||
必须写在这个【非 scoped】块里:el-menu 是 fragment 根,scope 属性在它自己身上而不是
|
||||
祖先,<style scoped> 里的 :deep(.el-menu--horizontal) 选不到它。
|
||||
只作用于顶栏这条 ul —— sub-menu 的弹层被 teleport 到 body,跑不到这个选择器下。 */
|
||||
.el-menu--horizontal > .el-menu-item > a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
margin: 0 calc(-1 * var(--el-menu-base-level-padding));
|
||||
padding: 0 var(--el-menu-base-level-padding);
|
||||
}
|
||||
|
||||
/* logo 那个 <a> 单独放开 overflow(选择器多一层 :nth-child(1),免得靠书写顺序
|
||||
去压 scoped 块里 :deep(.el-menu-item a) 的 overflow:hidden)。
|
||||
它的 img 带 20px 上下外边距、总高 90px 塞在 59px 的 li 里:原来 <a> 是 inline
|
||||
元素、overflow 本来就不生效才没被裁;上面改成 flex 后若不显式放开,logo 会被切掉。 */
|
||||
.el-menu--horizontal > .el-menu-item:nth-child(1) > a {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.el-menu--horizontal > .el-menu-item:nth-child(1) {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文档导航菜单(全站唯一实现,结构别在别处再抄一份)
|
||||
*
|
||||
* - 宽屏:由 /doc 与 /doc/** 页面直接渲染成左侧固定侧边栏(由页面传 class="sidebar-menu")
|
||||
* - 窄屏:页面那边把侧边栏整个隐藏,改由根布局的左侧抽屉菜单挂同一份本组件,
|
||||
* 这样手机上不需要两个菜单,也不至于没入口进文档。
|
||||
*/
|
||||
const route = useRoute()
|
||||
const activeIndex = computed(() => route.path)
|
||||
|
||||
// 所有可展开分组的 index(含嵌套的「最佳实践」)。
|
||||
// 抽屉里给展开态用:手机上没有 hover,分组默认收着的话用户还得先点开一层才能看到目的地。
|
||||
const ALL_GROUPS = [
|
||||
'IPv6 用户端',
|
||||
'最佳实践',
|
||||
'云服务器配置 IPv6',
|
||||
'IPv6 协议规范',
|
||||
'API 接口',
|
||||
]
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** true = 所有分组初始展开(抽屉用);false = 保持 element-plus 默认的收起态(页面侧栏用) */
|
||||
expandAll?: boolean
|
||||
}>(), { expandAll: false })
|
||||
|
||||
// undefined 让 element-plus 走自己的默认值,宽屏下行为与改造前完全一致
|
||||
const openeds = computed(() => (props.expandAll ? ALL_GROUPS : undefined))
|
||||
|
||||
const emit = defineEmits<{ select: [index: string] }>()
|
||||
</script>
|
||||
<template>
|
||||
<el-menu
|
||||
:default-active="activeIndex"
|
||||
:default-openeds="openeds"
|
||||
router
|
||||
@select="(index: string) => emit('select', index)"
|
||||
>
|
||||
<!-- 1. 独立菜单项 -->
|
||||
<el-menu-item index="/doc">
|
||||
<span>IPv6 工具箱使用文档</span>
|
||||
</el-menu-item>
|
||||
|
||||
<!-- 2. IPv6 用户端 -->
|
||||
<el-sub-menu index="IPv6 用户端">
|
||||
<template #title>
|
||||
<span>IPv6 用户端</span>
|
||||
</template>
|
||||
|
||||
<el-menu-item index="/doc/user/enable_ipv6">个人宽带如何开启IPv6网络访问</el-menu-item>
|
||||
<el-menu-item index="/doc/user/cmd_bash_disable_ipv6">命令行禁用/启用IPv6本地网络</el-menu-item>
|
||||
<el-menu-item index="/doc/user/cmd_getip">命令行(curl)获取 IPv4 和 IPv6 地址</el-menu-item>
|
||||
<el-menu-item index="/doc/user/view_ipv6_adress_url">浏览器访问 IPv6 地址</el-menu-item>
|
||||
<el-menu-item index="/doc/user/ipv4_ipv6_prefix_precedence">Windows 10/11 设置 IPv4/IPv6 访问优先级</el-menu-item>
|
||||
<el-menu-item index="/doc/user/ipv6_daohang">国内 IPv6 资源导航</el-menu-item>
|
||||
<el-menu-item index="/doc/user/pure_ipv6_website">国内纯 IPv6 网站导航</el-menu-item>
|
||||
<el-menu-item index="/doc/user/dns">全国各省 DNS 服务器列表</el-menu-item>
|
||||
<el-menu-item index="/doc/user/ipv6_dns">IPv6 DNS 地址列表</el-menu-item>
|
||||
<el-menu-item index="/doc/user/code_getip">Python/Go 获取 IPv4 和 IPv6 地址</el-menu-item>
|
||||
<el-menu-item index="/doc/user/dns_dig">DNS 解析流程</el-menu-item>
|
||||
|
||||
<!-- 嵌套子菜单:最佳实践 -->
|
||||
<el-sub-menu index="最佳实践">
|
||||
<template #title>
|
||||
<span>最佳实践</span>
|
||||
</template>
|
||||
<el-menu-item index="/doc/user/AliyunAuthorizeSecurityGroup">阿里云自动化添加安全组</el-menu-item>
|
||||
<el-menu-item index="/doc/user/TencentCloudAddSecurityGroup">腾讯云自动化添加安全组</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 3. 云服务器配置 IPv6 -->
|
||||
<el-sub-menu index="云服务器配置 IPv6">
|
||||
<template #title>
|
||||
<span>云服务器配置 IPv6</span>
|
||||
</template>
|
||||
<el-menu-item index="/doc/server/website_enable_ipv6">网站开启 IPv6 的三种方式</el-menu-item>
|
||||
<el-menu-item index="/doc/server/tencent_cloud_cvm_ipv6">腾讯云 cvm 开启 IPv6</el-menu-item>
|
||||
<el-menu-item index="/doc/server/nginx_ipv6">Nginx 开启 IPv6</el-menu-item>
|
||||
<el-menu-item index="/doc/server/ipv6webcheck">如何确认一个网站是否开启 IPv6</el-menu-item>
|
||||
<el-menu-item index="/doc/server/ipv6_sign">网站增加支持IPv6访问标识</el-menu-item>
|
||||
<el-menu-item index="/doc/server/ipv6_domain_record">如何为域名添加 IPv6 解析记录</el-menu-item>
|
||||
<el-menu-item index="/doc/server/http2">网站如何开启 HTTP/2</el-menu-item>
|
||||
<el-menu-item index="/doc/server/check_userip_ipv4_ipv6">JS 检查网络是 IPv4 还是 IPv6</el-menu-item>
|
||||
<el-menu-item index="/doc/server/tls">网站如何开启 TLS</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 4. IPv6 协议规范 -->
|
||||
<el-sub-menu index="IPv6 协议规范">
|
||||
<template #title>
|
||||
<span>IPv6 协议规范</span>
|
||||
</template>
|
||||
<el-menu-item index="/doc/rfc/rfc8200">IPv6 RFC8200 解读</el-menu-item>
|
||||
<el-menu-item index="/doc/rfc/ipv6_address_format">IPv6 地址标识方法</el-menu-item>
|
||||
<el-menu-item index="/doc/user/tcpdump_ipv6">tcpdump 分析IPv6包</el-menu-item>
|
||||
<el-menu-item index="/doc/user/wireshark_ipv6">WireShark 分析IPv6包头</el-menu-item>
|
||||
<el-menu-item index="/doc/user/ipv6_ping">IPv6 Ping 原理</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 5. API 接口 -->
|
||||
<el-sub-menu index="API 接口">
|
||||
<template #title>
|
||||
<span>API 接口</span>
|
||||
</template>
|
||||
<el-menu-item index="/doc/api/ip/locate">获取客户端公网 IP 及位置</el-menu-item>
|
||||
<el-menu-item index="/doc/api/ip/myip">获取客户端公网 IP 地址</el-menu-item>
|
||||
<el-menu-item index="/doc/api/ip/query">查询指定 IP 地址的位置信息</el-menu-item>
|
||||
<el-menu-item index="/doc/api/whois/query">查询域名的 WHOIS 信息</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-menu>
|
||||
</template>
|
||||
@@ -182,14 +182,14 @@ function getStatusClass(status: string): string {
|
||||
<tr v-if="result.dbip_asn?.asn">
|
||||
<td class="table-label">DB-IP ASN</td>
|
||||
<td class="table-value">
|
||||
AS{{ result.dbip_asn.asn }}
|
||||
{{ result.dbip_asn.asn }}
|
||||
<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 }}
|
||||
{{ 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>
|
||||
|
||||
@@ -157,6 +157,16 @@ async function queryDNS() {
|
||||
}
|
||||
|
||||
|
||||
// 行内「数据格是否该渲染」判定。
|
||||
//
|
||||
// 结果表共 6 列(服务器 / 类型 / 记录 / 记录数 / 耗时 / TTL);后三列(记录数、耗时、TTL)
|
||||
// 只在真正拿到结果时才有意义。加载中与失败两种状态由「记录」那格用 colspan=4 横跨到表尾,
|
||||
// 若此时再渲染后三格,整行就会变成 2+4+3=9 格 —— 表格按最大列数排版,凭空多出 3 列,
|
||||
// 表头只剩 6 列、右侧留出空档,错误行也跟着错位。
|
||||
function hasData(result: any) {
|
||||
return !result?.loading && !result?.error
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
const domainParam = route.query.domain as string
|
||||
@@ -242,9 +252,11 @@ const doc = page.value;
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="table-value" v-if="!result.loading">{{result.data?.record?.length || 0}}</td>
|
||||
<td class="table-value" v-if="!result.loading">{{formatTime(result.data?.duration)}}</td>
|
||||
<td class="table-value" v-if="!result.loading">{{result.data?.ttl}}</td>
|
||||
<!-- 后三格必须与「记录」格的 colspan 互斥:加载中/失败时该格已跨满到表尾,
|
||||
这里再渲染就会多出 3 列(见 hasData 注释) -->
|
||||
<td class="table-value" v-if="hasData(result)">{{result.data?.record?.length || 0}}</td>
|
||||
<td class="table-value" v-if="hasData(result)">{{formatTime(result.data?.duration)}}</td>
|
||||
<td class="table-value" v-if="hasData(result)">{{result.data?.ttl}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ const route = useRoute();
|
||||
|
||||
const path = computed(() => route.path)
|
||||
const { data: page } = await useFetch(() => `/api/markdown${path.value}`)
|
||||
const activeIndex = computed(() => route.path)
|
||||
|
||||
const meta = computed(() => getDocMeta(route.path))
|
||||
useHead({
|
||||
@@ -17,69 +16,9 @@ useHead({
|
||||
</script>
|
||||
<template>
|
||||
<div class="box">
|
||||
<el-menu
|
||||
:default-active="activeIndex"
|
||||
class="sidebar-menu"
|
||||
router
|
||||
>
|
||||
<!-- 1. 独立菜单项 -->
|
||||
<el-menu-item index="/doc">
|
||||
<span>IPv6 工具箱使用文档</span>
|
||||
</el-menu-item>
|
||||
|
||||
<!-- 2. IPv6 用户端 -->
|
||||
<el-sub-menu index="IPv6 用户端">
|
||||
<template #title>
|
||||
<span>IPv6 用户端</span>
|
||||
</template>
|
||||
|
||||
<el-menu-item index="/doc/user/enable_ipv6">个人宽带如何开启IPv6网络访问</el-menu-item>
|
||||
<el-menu-item index="/doc/user/cmd_bash_disable_ipv6">命令行禁用/启用IPv6本地网络</el-menu-item>
|
||||
<el-menu-item index="/doc/user/cmd_getip">命令行(curl)获取 IPv4 和 IPv6 地址</el-menu-item>
|
||||
<el-menu-item index="/doc/user/view_ipv6_adress_url">浏览器访问 IPv6 地址</el-menu-item>
|
||||
<el-menu-item index="/doc/user/ipv4_ipv6_prefix_precedence">Windows 10/11 设置 IPv4/IPv6 访问优先级</el-menu-item>
|
||||
<el-menu-item index="/doc/user/ipv6_daohang">国内 IPv6 资源导航</el-menu-item>
|
||||
<el-menu-item index="/doc/user/pure_ipv6_website">国内纯 IPv6 网站导航</el-menu-item>
|
||||
<el-menu-item index="/doc/user/dns">全国各省 DNS 服务器列表</el-menu-item>
|
||||
<el-menu-item index="/doc/user/ipv6_dns">IPv6 DNS 地址列表</el-menu-item>
|
||||
|
||||
<!-- 嵌套子菜单:最佳实践 -->
|
||||
<el-sub-menu index="最佳实践">
|
||||
<template #title>
|
||||
<span>最佳实践</span>
|
||||
</template>
|
||||
<el-menu-item index="/doc/user/AliyunAuthorizeSecurityGroup">阿里云自动化添加安全组</el-menu-item>
|
||||
<el-menu-item index="/doc/user/TencentCloudAddSecurityGroup">腾讯云自动化添加安全组</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 3. 云服务器配置 IPv6 -->
|
||||
<el-sub-menu index="云服务器配置 IPv6">
|
||||
<template #title>
|
||||
<span>云服务器配置 IPv6</span>
|
||||
</template>
|
||||
<el-menu-item index="/doc/server/website_enable_ipv6">网站开启 IPv6 的三种方式</el-menu-item>
|
||||
<el-menu-item index="/doc/server/tencent_cloud_cvm_ipv6">腾讯云 cvm 开启 IPv6</el-menu-item>
|
||||
<el-menu-item index="/doc/server/nginx_ipv6">Nginx 开启 IPv6</el-menu-item>
|
||||
<el-menu-item index="/doc/server/ipv6webcheck">如何确认一个网站是否开启 IPv6</el-menu-item>
|
||||
<el-menu-item index="/doc/server/ipv6_sign">网站增加支持IPv6访问标识</el-menu-item>
|
||||
<el-menu-item index="/doc/server/ipv6_domain_record">如何为域名添加 IPv6 解析记录</el-menu-item>
|
||||
<el-menu-item index="/doc/server/http2">网站如何开启 HTTP/2</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 4. IPv6 协议规范 -->
|
||||
<el-sub-menu index="IPv6 协议规范">
|
||||
<template #title>
|
||||
<span>IPv6 协议规范</span>
|
||||
</template>
|
||||
<el-menu-item index="/doc/rfc/rfc8200">IPv6 RFC8200 解读</el-menu-item>
|
||||
<el-menu-item index="/doc/rfc/ipv6_address_format">IPv6 地址标识方法</el-menu-item>
|
||||
<el-menu-item index="/doc/user/tcpdump_ipv6">tcpdump 分析IPv6包</el-menu-item>
|
||||
<el-menu-item index="/doc/user/wireshark_ipv6">WireShark 分析IPv6包头</el-menu-item>
|
||||
<el-menu-item index="/doc/user/ipv6_ping">IPv6 Ping 原理</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
</el-menu>
|
||||
<!-- 文档导航只有一份实现(app/components/DocMenu.vue)。
|
||||
宽屏:就是这条左侧固定侧栏;窄屏:本侧栏隐藏,改由顶部抽屉菜单承载 -->
|
||||
<DocMenu class="sidebar-menu" />
|
||||
<div class="content">
|
||||
<div class="markdown-body" v-if="page">
|
||||
<div v-html="page" />
|
||||
@@ -119,8 +58,10 @@ html.dark .content {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* 窄屏不再挤一条窄侧栏(原先 100px,长标题全被截断):
|
||||
文档导航已并入左侧抽屉菜单,这里直接撤掉,正文铺满整屏 */
|
||||
.sidebar-menu {
|
||||
width: 100px;
|
||||
display: none;
|
||||
}
|
||||
.content {
|
||||
padding: 10px;
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { getDocMeta } from '../../../config/doc'
|
||||
|
||||
const route = useRoute();
|
||||
const { data: page } = await useAsyncData('doc-index', () =>
|
||||
$fetch('/api/markdown/doc/index')
|
||||
)
|
||||
const doc = page.value;
|
||||
const activeIndex = computed(() => route.path)
|
||||
|
||||
const meta = getDocMeta('/doc')
|
||||
useHead({
|
||||
@@ -18,69 +16,9 @@ useHead({
|
||||
</script>
|
||||
<template>
|
||||
<div class="box">
|
||||
<el-menu
|
||||
:default-active="activeIndex"
|
||||
class="sidebar-menu"
|
||||
router
|
||||
>
|
||||
<!-- 1. 独立菜单项 -->
|
||||
<el-menu-item index="/doc">
|
||||
<span>IPv6 工具箱使用文档</span>
|
||||
</el-menu-item>
|
||||
|
||||
<!-- 2. IPv6 用户端 -->
|
||||
<el-sub-menu index="IPv6 用户端">
|
||||
<template #title>
|
||||
<span>IPv6 用户端</span>
|
||||
</template>
|
||||
|
||||
<el-menu-item index="/doc/user/enable_ipv6">个人宽带如何开启IPv6网络访问</el-menu-item>
|
||||
<el-menu-item index="/doc/user/cmd_bash_disable_ipv6">命令行禁用/启用IPv6本地网络</el-menu-item>
|
||||
<el-menu-item index="/doc/user/cmd_getip">命令行(curl)获取 IPv4 和 IPv6 地址</el-menu-item>
|
||||
<el-menu-item index="/doc/user/view_ipv6_adress_url">浏览器访问 IPv6 地址</el-menu-item>
|
||||
<el-menu-item index="/doc/user/ipv4_ipv6_prefix_precedence">Windows 10/11 设置 IPv4/IPv6 访问优先级</el-menu-item>
|
||||
<el-menu-item index="/doc/user/ipv6_daohang">国内 IPv6 资源导航</el-menu-item>
|
||||
<el-menu-item index="/doc/user/pure_ipv6_website">国内纯 IPv6 网站导航</el-menu-item>
|
||||
<el-menu-item index="/doc/user/dns">全国各省 DNS 服务器列表</el-menu-item>
|
||||
<el-menu-item index="/doc/user/ipv6_dns">IPv6 DNS 地址列表</el-menu-item>
|
||||
|
||||
<!-- 嵌套子菜单:最佳实践 -->
|
||||
<el-sub-menu index="最佳实践">
|
||||
<template #title>
|
||||
<span>最佳实践</span>
|
||||
</template>
|
||||
<el-menu-item index="/doc/user/AliyunAuthorizeSecurityGroup">阿里云自动化添加安全组</el-menu-item>
|
||||
<el-menu-item index="/doc/user/TencentCloudAddSecurityGroup">腾讯云自动化添加安全组</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 3. 云服务器配置 IPv6 -->
|
||||
<el-sub-menu index="云服务器配置 IPv6">
|
||||
<template #title>
|
||||
<span>云服务器配置 IPv6</span>
|
||||
</template>
|
||||
<el-menu-item index="/doc/server/website_enable_ipv6">网站开启 IPv6 的三种方式</el-menu-item>
|
||||
<el-menu-item index="/doc/server/tencent_cloud_cvm_ipv6">腾讯云 cvm 开启 IPv6</el-menu-item>
|
||||
<el-menu-item index="/doc/server/nginx_ipv6">Nginx 开启 IPv6</el-menu-item>
|
||||
<el-menu-item index="/doc/server/ipv6webcheck">如何确认一个网站是否开启 IPv6</el-menu-item>
|
||||
<el-menu-item index="/doc/server/ipv6_sign">网站增加支持IPv6访问标识</el-menu-item>
|
||||
<el-menu-item index="/doc/server/ipv6_domain_record">如何为域名添加 IPv6 解析记录</el-menu-item>
|
||||
<el-menu-item index="/doc/server/http2">网站如何开启 HTTP/2</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 4. IPv6 协议规范 -->
|
||||
<el-sub-menu index="IPv6 协议规范">
|
||||
<template #title>
|
||||
<span>IPv6 协议规范</span>
|
||||
</template>
|
||||
<el-menu-item index="/doc/rfc/rfc8200">IPv6 RFC8200 解读</el-menu-item>
|
||||
<el-menu-item index="/doc/rfc/ipv6_address_format">IPv6 地址标识方法</el-menu-item>
|
||||
<el-menu-item index="/doc/user/tcpdump_ipv6">tcpdump 分析IPv6包</el-menu-item>
|
||||
<el-menu-item index="/doc/user/wireshark_ipv6">WireShark 分析IPv6包头</el-menu-item>
|
||||
<el-menu-item index="/doc/user/ipv6_ping">IPv6 Ping 原理</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
</el-menu>
|
||||
<!-- 文档导航只有一份实现(app/components/DocMenu.vue)。
|
||||
宽屏:就是这条左侧固定侧栏;窄屏:本侧栏隐藏,改由顶部抽屉菜单承载 -->
|
||||
<DocMenu class="sidebar-menu" />
|
||||
<div class="content">
|
||||
<div class="markdown-body" v-html="doc"></div>
|
||||
</div>
|
||||
@@ -118,8 +56,10 @@ html.dark .content {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* 窄屏不再挤一条窄侧栏(原先 100px,长标题全被截断):
|
||||
文档导航已并入左侧抽屉菜单,这里直接撤掉,正文铺满整屏 */
|
||||
.sidebar-menu {
|
||||
width: 100px;
|
||||
display: none;
|
||||
}
|
||||
.content {
|
||||
padding: 10px;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed, ref, onMounted } from 'vue';
|
||||
import { isIPv6 } from 'is-ip';
|
||||
import { config } from '../../config/index';
|
||||
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
|
||||
import { highlightCode } from '../../utils/shiki';
|
||||
import { isIPv4 } from '../../utils/tools';
|
||||
const route = useRoute();
|
||||
const canonicalUrl = computed(() => new URL(route.path, config.siteUrl).toString());
|
||||
@@ -75,34 +74,63 @@ curl ${config.v6OnlyAPI}
|
||||
# (访问 IPv4/IPv6 双栈站点,如果返回 IPv6 地址,则 IPv6 访问优先)
|
||||
curl ${config.DualStackAPI}
|
||||
`.trim(); // 关键:去掉首尾多余的空行
|
||||
const highlightedCode = ref('');
|
||||
|
||||
// 代码高亮放在 SSR 期算:HTML 下发时就已经是带 <span> 的高亮结果,
|
||||
// 于是 ①客户端不必再下载/执行 shiki(引擎 + 语言 + 主题 ≈ 164KB);
|
||||
// ②不会再有"先出纯文本兜底、若干秒后才换成高亮"的那次块高变化(可见重排)。
|
||||
//
|
||||
// 这段代码是构建期常量(只依赖 config,不随请求变化),所以结果可以安全地随 HTML 一起缓存。
|
||||
//
|
||||
// import 仍然写在 handler 内部(而不是文件顶层):动态 import 会把它切成独立 chunk,
|
||||
// 只在真正要高亮时加载,不会像顶层静态 import 那样被塞进首屏的 modulepreload 列表。
|
||||
// 首次进入是 SSR 执行的;客户端水合直接读 payload(Nuxt 会序列化 useAsyncData 的结果),
|
||||
// 只有客户端软导航到本页时才在浏览器里跑一次。
|
||||
const { data: highlightedCode } = await useAsyncData(
|
||||
'home-curl-code',
|
||||
async () => {
|
||||
const { highlightCode } = await import('../../utils/shiki');
|
||||
return await highlightCode(code, 'bash');
|
||||
},
|
||||
// 默认值必须留空:模板是 v-if="highlightedCode" / v-else 的双分支,
|
||||
// 给成 code 会让 v-if 命中、把纯文本当 HTML 塞进 v-html(fallback 分支永远走不到)。
|
||||
// 留空则失败时自然落到 v-else 的 code-block--fallback,与改动前 catch 里清空的行为一致。
|
||||
{ default: () => '' },
|
||||
);
|
||||
|
||||
const ipAddress = ref('');
|
||||
const yourIPv4 = ref('');
|
||||
const yourIPv6 = ref('');
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
highlightedCode.value = await highlightCode(code, 'bash');
|
||||
} catch {
|
||||
highlightedCode.value = '';
|
||||
}
|
||||
onMounted(() => {
|
||||
// (代码高亮已移到 setup 顶部的 useAsyncData,随 SSR 一起产出,这里只剩三个 IP 查询。)
|
||||
|
||||
const [dualStack, ipV4, ipV6] = await Promise.allSettled([
|
||||
$fetch<string>(config.DualStackAPI),
|
||||
$fetch<string>(config.v4OnlyAPI),
|
||||
$fetch<string>(config.v6OnlyAPI)
|
||||
]);
|
||||
// 三个地址各自独立请求、各自渲染:谁先回来谁先上屏。
|
||||
// 不用 Promise.allSettled 包起来等齐——那样只要有一个慢(例如纯 IPv4 网络下 v6 接口要等超时),
|
||||
// 已经拿到的 IPv4 与双栈结果也得一起干等。
|
||||
//
|
||||
// retry: false —— ofetch 默认会对 GET 重发一次:网络层失败时它拿不到 response,就按 500 计
|
||||
// (500 在默认重试白名单里),且默认延迟为 0,立即重发。于是"这个节点连不上"会被原样重做一遍,
|
||||
// 用户白等一倍时间(实测 ofetch 1.5.1:裸调用触发 2 次真实 fetch,传 retry:false 后只剩 1 次;
|
||||
// 浏览器自身不会重试,底层 fetch 对同一请求只被调用过一次)。
|
||||
const ipFetchOptions = { retry: false } as const;
|
||||
|
||||
if (dualStack.status === 'fulfilled') {
|
||||
ipAddress.value = dualStack.value;
|
||||
}
|
||||
if (ipV4.status === 'fulfilled') {
|
||||
yourIPv4.value = ipV4.value;
|
||||
}
|
||||
if (ipV6.status === 'fulfilled') {
|
||||
yourIPv6.value = ipV6.value;
|
||||
}
|
||||
$fetch<string>(config.DualStackAPI, ipFetchOptions)
|
||||
.then((ip) => {
|
||||
ipAddress.value = ip;
|
||||
})
|
||||
.catch(() => {
|
||||
// 单个接口失败不影响其它两行展示,保持"查询中"占位
|
||||
});
|
||||
$fetch<string>(config.v4OnlyAPI, ipFetchOptions)
|
||||
.then((ip) => {
|
||||
yourIPv4.value = ip;
|
||||
})
|
||||
.catch(() => {});
|
||||
$fetch<string>(config.v6OnlyAPI, ipFetchOptions)
|
||||
.then((ip) => {
|
||||
yourIPv6.value = ip;
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -121,10 +149,10 @@ onMounted(async () => {
|
||||
<div class="one-line">
|
||||
<b>IPv6</b> <p v-if="yourIPv6">{{ yourIPv6 }}</p><RouterLink :to="`/location?ip=${yourIPv6}`" target="_blank" v-if="yourIPv6"> 查询归属地</RouterLink><RouterLink v-else to="/doc/user/enable_ipv6" target="_blank">没有IPv6地址,查看如何开启IPv6</RouterLink>
|
||||
</div>
|
||||
<div style="font-size: 1.5em;">
|
||||
<h3 v-if="ipAddress && isIPv6(ipAddress)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h3>
|
||||
<h3 v-else-if="ipAddress && isIPv4(ipAddress)"><el-icon><CircleCloseFilled style="color: red;"/></el-icon>您的网络IPv4优先</h3>
|
||||
<h3 v-else><el-icon><CircleCloseFilled /></el-icon>查询中,请稍后</h3>
|
||||
<div class="ip-priority">
|
||||
<h2 v-if="ipAddress && isIPv6(ipAddress)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h2>
|
||||
<h2 v-else-if="ipAddress && isIPv4(ipAddress)"><el-icon><CircleCloseFilled style="color: red;"/></el-icon>您的网络IPv4优先</h2>
|
||||
<h2 v-else><el-icon><CircleCloseFilled /></el-icon>查询中,请稍后</h2>
|
||||
</div>
|
||||
<blockquote>
|
||||
手机默认开启 IPv6,宽带开启 IPv6 请参阅<a href="/doc/user/enable_ipv6" target="_blank">文档</a>
|
||||
@@ -141,6 +169,17 @@ onMounted(async () => {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* 网络优先级提示:原来是「div 1.5em × h3」,但 h1 之后直跳 h3 违反标题层级(heading-order),
|
||||
语义上应为 h2。style.css 给 h3 显式定了 1.3em / 窄屏 1.1em,h2 只有 1.5em 一档,
|
||||
所以这里把字号按原样补回,避免语义修正顺带改了视觉(含 margin:UA 的 h3 是 1em,h2 是 0.83em)。 */
|
||||
.ip-priority {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.ip-priority h2 {
|
||||
font-size: 1.3em;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
@@ -159,6 +198,9 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ip-priority h2 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.code-block {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.8em;
|
||||
|
||||
@@ -5,7 +5,6 @@ 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';
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
@@ -68,7 +67,22 @@ curl ${config.v6OnlyAPI}
|
||||
# (访问 IPv4/IPv6 双栈站点,如果返回 IPv6 地址,则 IPv6 访问优先)
|
||||
curl ${config.DualStackAPI}
|
||||
`.trim();
|
||||
const html = ref('');
|
||||
|
||||
// 代码高亮放 SSR 期算(与首页同款做法):HTML 里直接是带 <span> 的高亮结果,
|
||||
// 客户端不必再下载/执行 shiki(引擎 + 语言 + 主题 ≈ 164KB),也不会出现
|
||||
// "先渲染纯文本、稍后换成高亮"的块高变化。代码是构建期常量,结果可随 HTML 一起缓存。
|
||||
// import 写在 handler 内部(动态 import 单独成 chunk),首屏不会预载它。
|
||||
const { data: html } = await useAsyncData(
|
||||
'location-curl-code',
|
||||
async () => {
|
||||
const { highlightCode } = await import('../../utils/shiki');
|
||||
return await highlightCode(code, 'bash');
|
||||
},
|
||||
// 默认值给 code(不是空串):这里模板只有单个 v-html,没有 fallback 分支,
|
||||
// 高亮失败时正好退回原始代码文本,别让代码块空着。
|
||||
// (首页那处模板有 v-else 分支,所以那边默认值必须留空,两处不可照抄。)
|
||||
{ default: () => code },
|
||||
);
|
||||
const apiList = config.IPLocationAPI
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
@@ -118,7 +132,8 @@ function locateIP(IP: string){
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
html.value = await highlightCode(code, 'bash')
|
||||
// (代码高亮已移到 setup 顶部的 useAsyncData,随 SSR 一起产出,这里只剩定位查询。)
|
||||
|
||||
const urlParam = route.query.ip;
|
||||
if (urlParam) {
|
||||
ipAddress.value = urlParam as string;
|
||||
|
||||
@@ -31,7 +31,10 @@ h3{
|
||||
}
|
||||
.title p{
|
||||
font-size: 25.6px;
|
||||
color: #6A8BAD;
|
||||
/* 首页副标题。原来 #6A8BAD 在白底只有 3.55:1(axe color-contrast,
|
||||
是移动端 12 条里唯一不是链接/代码的那条);加深到 #4D7396 = 4.99:1,
|
||||
色相不变、只是明度降一档。 */
|
||||
color: #4D7396;
|
||||
margin-right: 20px;
|
||||
margin-left: 20px;
|
||||
text-align: center;
|
||||
@@ -106,13 +109,14 @@ blockquote p {
|
||||
margin: 0.5em;
|
||||
}
|
||||
blockquote a{
|
||||
color: #3EAF7C;
|
||||
color: #2F7D5A;
|
||||
font-size: 1em;
|
||||
text-decoration: none
|
||||
|
||||
/* 正文里的链接仅靠颜色区分不够(绿 #2F7D5A 与周围正文的对比度远低于 3:1),
|
||||
补下划线提供「非颜色」的视觉区分(link-in-text-block) */
|
||||
text-decoration: underline
|
||||
}
|
||||
a {
|
||||
color: #3EAF7C;
|
||||
color: #2F7D5A;
|
||||
font-size: 1.3em;
|
||||
text-decoration: none
|
||||
|
||||
@@ -128,7 +132,7 @@ footer .one-line {
|
||||
footer .one-line a {
|
||||
font-size: 1em;
|
||||
text-align: center;
|
||||
color: #3EAF7C;
|
||||
color: #2F7D5A;
|
||||
text-decoration: none
|
||||
}
|
||||
footer {
|
||||
@@ -203,17 +207,17 @@ footer {
|
||||
}
|
||||
blockquote a{
|
||||
|
||||
color: #3EAF7C;
|
||||
color: #2F7D5A;
|
||||
font-size: 1em;
|
||||
text-decoration: none
|
||||
/* 与上面的非窄屏规则保持一致:窄屏同样靠下划线做非颜色区分。
|
||||
此处若仍写 none,因其位置在后、特异性相同,会把桌面的 underline 盖掉。 */
|
||||
text-decoration: underline
|
||||
}
|
||||
}
|
||||
html.dark {
|
||||
/* 在暗色模式下,覆盖根元素的文字颜色和背景色 */
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
--el-color-primary: #3EAF7C;
|
||||
}
|
||||
/* 注:暗色模式的「根元素」规则(html.dark 的文字色 / 背景色 / 主色变量)已上移到
|
||||
app.vue 的【非 scoped】<style> 块。本文件是被 <style scoped> @import 进来的,
|
||||
凡是作用于 <html> 自身的规则都会被加上 scope 属性而永不匹配。
|
||||
这里只保留作用于「html 后代元素」的规则(后代元素带 scope 属性,能正常命中)。 */
|
||||
html.dark blockquote {
|
||||
/* 在暗色模式下,覆盖根元素的文字颜色和背景色 */
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
@@ -268,7 +272,7 @@ h4>.header-anchor,
|
||||
h5>.header-anchor,
|
||||
h6>.header-anchor {
|
||||
opacity: 1;
|
||||
color: #3EAF7C;
|
||||
color: #2F7D5A;
|
||||
}
|
||||
|
||||
h1:hover>.header-anchor,
|
||||
@@ -281,5 +285,5 @@ h6:hover>.header-anchor {
|
||||
}
|
||||
.header-anchor::before {
|
||||
content: "#";
|
||||
color: #3EAF7C;
|
||||
color: #2F7D5A;
|
||||
}
|
||||
@@ -6,99 +6,131 @@ export interface DocMeta {
|
||||
export const docConfig: Record<string, DocMeta> = {
|
||||
'/doc': {
|
||||
title: 'IPv6 工具箱使用文档',
|
||||
description: 'IPv6 工具箱使用文档'
|
||||
description: '致力于普及 IPv6,推进 IPv6 规模部署和应用,以全面推进 IPv6 技术创新与融合应用为主线,以提升应用广度深度为主攻方向。支持 IPv6 本地网络(宽带)开启验证、IPv6 网站开启检测、IPv6 Ping 延迟测试、IPv6 网站测速、IPv6 地址查询、IPv6 DNS 解析。'
|
||||
},
|
||||
'/doc/user/enable_ipv6': {
|
||||
title: '个人宽带如何开启IPv6网络访问',
|
||||
description: '介绍个人宽带如何开启IPv6网络访问'
|
||||
description: 'IPv6 是大势所趋,就在前段时间湖南联通发布公告,对家庭宽带提供 IPv6 地址,不再提供 IPv4 地址,那本文就介绍个人宽带如何开启 IPv6 网络访问。'
|
||||
},
|
||||
'/doc/user/cmd_bash_disable_ipv6': {
|
||||
title: '命令行禁用/启用IPv6本地网络',
|
||||
description: 'macOS和Window10命令行禁用/启用IPv6本地网络'
|
||||
description: '图形化禁用 IPv6 网络比较繁琐,以下是 macOS 和 Window 10 命令行下禁用/启用 IPv6 本地网络的方法。'
|
||||
},
|
||||
'/doc/user/cmd_getip': {
|
||||
title: '命令行(curl)获取 IPv4 和 IPv6 地址',
|
||||
description: '使用命令行curl获取IPv4和IPv6地址'
|
||||
description: '通过 curl 命令获取公网 IPv4 和 IPv6 地址,还可以返回是 IPv4 还是 IPv6 访问优先。'
|
||||
},
|
||||
'/doc/user/view_ipv6_adress_url': {
|
||||
title: '浏览器访问 IPv6 地址',
|
||||
description: '如何在浏览器中访问IPv6地址'
|
||||
description: '在 IPv6 地址两边加上中括号,就可以通过浏览器访问了,比如 http://[2402:4e00:1013:e500:0:9671:f018:4947]/'
|
||||
},
|
||||
'/doc/user/ipv4_ipv6_prefix_precedence': {
|
||||
title: 'Windows 10/11 设置 IPv4/IPv6 访问优先级',
|
||||
description: 'Windows 10/11 设置 IPv4/IPv6 访问优先级'
|
||||
description: 'Windows 10/11 开启 IPv6 后默认 IPv6 访问优先(以访问 IPv4/IPv6 双栈站点为例,操作系统会优先访问 IPv6),如果期望 IPv4 访问优先,可以通过 netsh 命令调整。'
|
||||
},
|
||||
'/doc/user/ipv6_daohang': {
|
||||
title: '国内 IPv6 资源导航',
|
||||
description: '国内IPv6资源导航列表'
|
||||
description: 'IPv6 网络开启成功后,可以访问一些 IPv6 网站资源,来体验下 IPv6 的魅力。不过目前纯 IPv6 网站比较少,目前网站支持 IPv6 的主流方式为 IPv4/IPv6 双栈访问,毕竟要兼顾 IPv4 网络用户的访问需求。'
|
||||
},
|
||||
'/doc/user/pure_ipv6_website': {
|
||||
title: '国内纯 IPv6 网站导航',
|
||||
description: '国内纯IPv6网站导航列表'
|
||||
description: 'IPv6 网络开启成功后,可以访问一些纯 IPv6 网站资源,来体验下 IPv6 的魅力。'
|
||||
},
|
||||
'/doc/user/dns': {
|
||||
title: '全国各省 DNS 服务器列表',
|
||||
description: '全国各省DNS服务器列表'
|
||||
description: '全国各省电信、联通、移动的首选 IPv4 DNS 服务器和备用 DNS 服务器,以及公共 DNS 服务器列表,最近更新为 2021-07-25。'
|
||||
},
|
||||
'/doc/user/ipv6_dns': {
|
||||
title: 'IPv6 DNS 地址列表',
|
||||
description: 'IPv6 DNS地址列表'
|
||||
description: 'IPv6 DNS 地址列表,请求 IPv6 网络需要设置 IPv6 DNS。'
|
||||
},
|
||||
'/doc/user/AliyunAuthorizeSecurityGroup': {
|
||||
title: '阿里云自动化添加安全组',
|
||||
description: '阿里云自动化添加安全组'
|
||||
description: 'Golang 代码实操阿里云自动化添加安全组'
|
||||
},
|
||||
'/doc/user/TencentCloudAddSecurityGroup': {
|
||||
title: '腾讯云自动化添加安全组',
|
||||
description: '腾讯云自动化添加安全组'
|
||||
description: 'Golang 代码实操腾讯云自动化添加安全组'
|
||||
},
|
||||
'/doc/server/website_enable_ipv6': {
|
||||
title: '网站开启 IPv6 的三种方式',
|
||||
description: '从传统二进制部署的Nginx,到云原生部署的K8S、Istio,分别介绍网站开启IPv6的三种方式'
|
||||
description: '从传统二进制部署的 Nginx,到云原生部署的 K8S、Istio,分别介绍网站开启 IPv6 的三种方式。'
|
||||
},
|
||||
'/doc/server/tencent_cloud_cvm_ipv6': {
|
||||
title: '腾讯云 cvm 开启 IPv6',
|
||||
description: '一文看懂在腾讯云CVM上开启IPv6,只需7步'
|
||||
description: '一文看懂在腾讯云 CVM 上开启 IPv6,只需 7 步。'
|
||||
},
|
||||
'/doc/server/nginx_ipv6': {
|
||||
title: 'Nginx 开启 IPv6',
|
||||
description: '一文看懂Nginx中开启IPv6,包含设置IPv6 SSL证书'
|
||||
description: '一文看懂 Nginx 中开启 IPv6,包含设置 IPv6 SSL 证书。'
|
||||
},
|
||||
'/doc/server/ipv6webcheck': {
|
||||
title: '如何确认一个网站是否开启 IPv6',
|
||||
description: '如何确认一个网站是否开启IPv6'
|
||||
description: '通过 IPv6 网站检测工具,一键检查网站是否开启 IPv6 访问。'
|
||||
},
|
||||
'/doc/server/ipv6_sign': {
|
||||
title: '网站增加支持IPv6访问标识',
|
||||
description: 'HTML+CSS代码,可以提醒访客本网站支持IPv6访问'
|
||||
description: 'HTML+CSS 代码,可以提醒访客本网站支持 IPv6 访问,同时可以跳转到 IPv6 网站检测页面。'
|
||||
},
|
||||
'/doc/server/ipv6_domain_record': {
|
||||
title: '如何为域名添加 IPv6 解析记录',
|
||||
description: '如何为域名添加IPv6解析记录'
|
||||
description: 'IPv6 网站准备好了后,接下来可以为网站域名添加 IPv6 解析记录(AAAA)。'
|
||||
},
|
||||
'/doc/server/http2': {
|
||||
title: '原创网站如何开启 HTTP/2',
|
||||
description: '介绍网站如何开启HTTP/2协议'
|
||||
title: '网站如何开启 HTTP/2',
|
||||
description: 'HTTP/2 通过引入字段压缩和允许在同一连接上进行多个并发交换,使网络资源得到更有效的利用,并减少了延迟。'
|
||||
},
|
||||
'/doc/rfc/rfc8200': {
|
||||
title: 'IPv6 RFC8200 解读',
|
||||
description: 'RFC8200(Internet Protocol, Version 6 (IPv6) Specification) 是IPv6的权威定义'
|
||||
description: 'RFC8200(Internet Protocol, Version 6 (IPv6) Specification)是 IPv6 的权威定义,接下来在解读 RFC8200 文档中的过程中,加深对 IPv6 的理解。'
|
||||
},
|
||||
'/doc/rfc/ipv6_address_format': {
|
||||
title: 'IPv6 地址标识方法',
|
||||
description: '首选格式、压缩格式、内嵌IPv4地址的IPv6地址格式'
|
||||
description: '首选格式、压缩格式、内嵌 IPv4 地址的 IPv6 地址格式。'
|
||||
},
|
||||
'/doc/user/tcpdump_ipv6': {
|
||||
title: 'tcpdump 分析IPv6包',
|
||||
description: '使用tcpdump分析IPv6包'
|
||||
title: 'tcpdump 分析 IPv6 包',
|
||||
description: 'tcpdump 分析 IPv6 包'
|
||||
},
|
||||
'/doc/user/wireshark_ipv6': {
|
||||
title: 'WireShark 分析IPv6包头',
|
||||
description: '使用WireShark分析IPv6包头'
|
||||
description: '使用 WireShark 分析 IPv6 包头'
|
||||
},
|
||||
'/doc/user/ipv6_ping': {
|
||||
title: 'IPv6 Ping 检测原理',
|
||||
description: 'IPv6 Ping检测原理'
|
||||
description: '协议标准:Internet Control Message Protocol (ICMPv6) for the Internet Protocol Version 6 (IPv6) Specification'
|
||||
},
|
||||
'/doc/api/ip/locate': {
|
||||
title: '获取客户端公网 IP 及位置',
|
||||
description: '调用本项目的 /v1/location 接口,一次取回访问者自己的公网 IP 与 10 套 IP 库的归属地结果。'
|
||||
},
|
||||
'/doc/api/ip/myip': {
|
||||
title: '获取客户端公网 IP 地址',
|
||||
description: '用自带边缘函数获取客户端公网 IP,纯文本返回、支持跨域,可按 IPv4 / IPv6 / 双栈分别部署。'
|
||||
},
|
||||
'/doc/api/ip/query': {
|
||||
title: '查询指定 IP 地址的位置信息',
|
||||
description: '调用本项目的 /v1/location/<ip> 接口查询任意 IPv4 / IPv6 地址的归属地,返回多源聚合结果。'
|
||||
},
|
||||
'/doc/api/whois/query': {
|
||||
title: '查询域名的 WHOIS 信息',
|
||||
description: '调用本项目的 /v1/whois/<domain> 接口查询域名注册信息:IANA → 注册局 → 注册商转介三跳,返回状态、注册商、关键日期与 NS。'
|
||||
},
|
||||
'/doc/server/check_userip_ipv4_ipv6': {
|
||||
title: 'JS 检查网络是 IPv4 还是 IPv6',
|
||||
description: '通过 JS 代码检查客户端是 IPv4 还是 IPv6 访问优先。'
|
||||
},
|
||||
'/doc/server/tls': {
|
||||
title: '网站如何开启 TLS',
|
||||
description: '用 Wireshark 抓包分析 TLS 1.3 的 TCP 三次握手与 TLS 协商全过程。'
|
||||
},
|
||||
'/doc/user/code_getip': {
|
||||
title: 'Python/Go 获取 IPv4 和 IPv6 地址',
|
||||
description: '通过 Python/Golang 获取公网 IPv4 和 IPv6 地址,还可以返回是 IPv4 还是 IPv6 访问优先。'
|
||||
},
|
||||
'/doc/user/dns_dig': {
|
||||
title: 'DNS 解析流程',
|
||||
description: '剖析 DNS 解析流程'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
前端的一系列配置
|
||||
*/
|
||||
const config = {
|
||||
siteUrl: "https://ipw.wsmdn.top/",
|
||||
siteUrl: "https://nipw.cn/",
|
||||
// 站点名称:用于页面标题 / 描述 / 页脚品牌展示
|
||||
siteName: "柠檬味ipw.cn",
|
||||
// 是否启用前端内置中间件(server/routes/middleware/[...slug].get.ts 本地转发)。
|
||||
@@ -27,8 +27,8 @@ const config = {
|
||||
umamiScriptUrl: "https://umami.wsmdn.top/zako.js",
|
||||
umamiWebsiteId: "69a91329-b110-4cf7-a04a-be4360b1a8d3",
|
||||
// 中华人民共和国备案系统
|
||||
ICP: "苏ICP备2026012471号",
|
||||
GongAn: "苏公网安备32132402000813号",
|
||||
ICP: "",
|
||||
GongAn: "",
|
||||
// 全站是否禁止搜索引擎索引
|
||||
noindex: false,
|
||||
// Worker IP查询接口
|
||||
@@ -43,11 +43,11 @@ const config = {
|
||||
id: "cn-jiangsu",
|
||||
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 湖北 武汉 电信",
|
||||
id: "cn-wuhan-chinatelecom",
|
||||
url: ""
|
||||
},
|
||||
// {
|
||||
// label: "中国 湖北 武汉 电信",
|
||||
// id: "cn-wuhan-chinatelecom",
|
||||
// url: ""
|
||||
// },
|
||||
{
|
||||
label: "中国 四川 沙渠 电信[ZFC]",
|
||||
id: "cn2-sichuan",
|
||||
@@ -67,11 +67,6 @@ const config = {
|
||||
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: [
|
||||
{
|
||||
@@ -105,27 +100,17 @@ const config = {
|
||||
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: ""
|
||||
}
|
||||
// // WS 通道节点:URL 留空,拨测经中间件 WS 转发
|
||||
// {
|
||||
// label: "呼和浩特移动",
|
||||
// id: "2faa15e6-18a7-4355-b422-ca53277d8d77",
|
||||
// url: ""
|
||||
// }
|
||||
],
|
||||
DualStack: [
|
||||
|
||||
@@ -140,16 +125,14 @@ const config = {
|
||||
url: "https://cn-shenzhen.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 湖北 武汉 电信",
|
||||
id: "cn-wuhan-chinatelecom",
|
||||
url: ""
|
||||
},
|
||||
// IP 直连的节点:URL 留空,仅经独立中间件转发
|
||||
{
|
||||
label: "上海 腾讯云 BGP",
|
||||
id: "tencent-sh",
|
||||
url: ""
|
||||
label: "河南 安阳 联通",
|
||||
"id": "anyang-cu"
|
||||
}
|
||||
// {
|
||||
// label: "中国 湖北 武汉 电信",
|
||||
// id: "cn-wuhan-chinatelecom",
|
||||
// url: ""
|
||||
// },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,20 @@ const extractDomains = (obj: any): string[] => {
|
||||
};
|
||||
|
||||
const allowedDomains = extractDomains(config);
|
||||
|
||||
// 首屏「IPv4 / IPv6 / 优先级」三行的数据源(见 app/pages/index.vue 的 onMounted):
|
||||
// 三个接口分属三个独立源站,且都要等水合完成后才发起 —— 那时才做 DNS+TCP+TLS 太晚。
|
||||
// 把它们抽出来做 preconnect,让握手在 HTML 解析期就开始。
|
||||
const ipProbeOrigins = [
|
||||
config.DualStackAPI,
|
||||
config.v4OnlyAPI,
|
||||
config.v6OnlyAPI,
|
||||
].map((url) => new URL(url).origin);
|
||||
// 中间件(工具页 /middleware/* 的上游):只在部分页面用到,DNS 预解析即可,不占连接。
|
||||
const middlewareOrigin = config.Middleware?.[0]
|
||||
? new URL(config.Middleware[0]).origin
|
||||
: null;
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
@@ -26,22 +40,45 @@ export default defineNuxtConfig({
|
||||
include: [
|
||||
'is-ip',
|
||||
'shiki',
|
||||
'dayjs',
|
||||
'lodash-unified'
|
||||
]
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
// 掐掉「当前路由的动态 import 预取」。
|
||||
// Nuxt 的 vue-bundle-renderer 会把 manifest 里带 prefetch 标记的动态 chunk
|
||||
// 渲染成 `<link rel="prefetch" as="script">`,于是首页一边解析 HTML 一边就把
|
||||
// shiki 高亮器那一整块(实测 165 KB,占首页字节 25%)下下来了 —— 而高亮已经在
|
||||
// SSR 期算完写进 HTML,客户端根本不会执行它,纯属白下。
|
||||
// 把 manifest 上的标记抹掉即可:动态 chunk 回到「真正 import 时才加载」。
|
||||
// 静态闭包走的是 preload 分支(modulepreload),不受影响。
|
||||
'build:manifest': (manifest) => {
|
||||
for (const chunk of Object.values(manifest) as Array<Record<string, unknown>>) {
|
||||
if (chunk.prefetch) chunk.prefetch = false
|
||||
}
|
||||
},
|
||||
},
|
||||
site: {
|
||||
url: config.siteUrl,
|
||||
name: 'Lemon IPW'
|
||||
},
|
||||
// Element Plus 样式走按需引入:@element-plus/nuxt 默认 importStyle: 'css',
|
||||
// 模板里用到的每个组件都会各自带上 `element-plus/es/components/<组件>/style/css`
|
||||
// (其内部再链到 theme-chalk/base.css,`:root` 的 --el-* 变量就在那里定义)。
|
||||
// 所以这里**不要**再引 element-plus/dist/index.css —— 那是整库样式(原始 360KB),
|
||||
// 而首页实测 98% 的规则用不到,却全压在关键路径上。
|
||||
css: [
|
||||
// 1. 引入 Element Plus 基础样式 (如果你还没有引入的话)
|
||||
'element-plus/dist/index.css',
|
||||
|
||||
// 2. 🌟 关键:引入 Element Plus 官方的暗黑模式 CSS 变量文件
|
||||
// 暗黑模式的 CSS 变量覆盖(html.dark 下的 --el-* 值),按需引入不含它,必须显式引
|
||||
'element-plus/theme-chalk/dark/css-vars.css',
|
||||
],
|
||||
app:{
|
||||
head: {
|
||||
// 站点内容为简体中文。不写时 Nuxt 输出的就是裸 <html>,
|
||||
// 屏幕阅读器会按系统语言瞎猜发音,Lighthouse 的 html-has-lang 也会失败。
|
||||
htmlAttrs: {
|
||||
lang: 'zh-CN',
|
||||
},
|
||||
script: [
|
||||
{
|
||||
// 必须 innerHTML,不能 src(否则异步加载)
|
||||
@@ -57,7 +94,17 @@ export default defineNuxtConfig({
|
||||
}
|
||||
],
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.svg' }
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.svg' },
|
||||
// preconnect 三个 IP 探测源站。crossorigin 不能省:$fetch 走 fetch() 的跨域
|
||||
// (CORS)模式,不带它预热出来的是 no-cors 连接,真正的 fetch 用不上、还得重连。
|
||||
...ipProbeOrigins.map((href) => ({
|
||||
rel: 'preconnect',
|
||||
href,
|
||||
crossorigin: 'anonymous',
|
||||
})),
|
||||
...(middlewareOrigin
|
||||
? [{ rel: 'dns-prefetch', href: middlewareOrigin }]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -66,11 +113,15 @@ export default defineNuxtConfig({
|
||||
apiKeys: '',
|
||||
public: {
|
||||
siteUrl: config.siteUrl,
|
||||
docConfig: docConfig,
|
||||
|
||||
// 这里**不要**再放 docConfig:runtimeConfig.public 会被整份序列化进
|
||||
// 每个页面 HTML 的 `window.__NUXT__.config`,而这份文档树(32 条 title+description)
|
||||
// 实测占 4571 字节 —— 也就是每一页的 HTML 都白白多背 4.5KB(首页 HTML 的 10%)。
|
||||
// 它全站只有一个消费方,且是服务端路由(server/api/__sitemap__/urls.ts),
|
||||
// 那里改成直接 import 构建期常量即可;文档页本来就用 `getDocMeta` 直接 import。
|
||||
},
|
||||
},
|
||||
routeRules: {
|
||||
// 缓存头一律不在这里设,交由 CDN(EdgeOne)侧控制。
|
||||
// /doc 文档站改为 SSG:构建时预渲染为静态 HTML,由边缘静态资源直接响应
|
||||
'/doc/**': { prerender: true },
|
||||
},
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"@vueuse/nuxt": "^14.4.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"element-plus": "^2.14.3",
|
||||
"github-markdown-css": "^5.9.0",
|
||||
"github-slugger": "^2.0.0",
|
||||
|
||||
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 718 KiB |
|
After Width: | Height: | Size: 652 KiB |
|
After Width: | Height: | Size: 675 KiB |
|
After Width: | Height: | Size: 635 KiB |
|
After Width: | Height: | Size: 642 KiB |
|
After Width: | Height: | Size: 636 KiB |
|
After Width: | Height: | Size: 622 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -2,7 +2,7 @@
|
||||
<svg width="85px" height="36px" viewBox="0 0 85 36" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>ssl-s1</title>
|
||||
<g id="ssl-s1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="ipw.cn" transform="translate(5.000000, 4.000000)">
|
||||
<g id="nipw.cn" transform="translate(5.000000, 4.000000)">
|
||||
<rect stroke="#07C160" x="0.5" y="0.5" width="74" height="27" rx="5"></rect>
|
||||
<g transform="translate(15.000000, 4.000000)" fill="#07C160">
|
||||
<text id="SSL" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
@@ -2,7 +2,7 @@
|
||||
<svg width="85px" height="36px" viewBox="0 0 85 36" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>ssl-s2</title>
|
||||
<g id="ssl-s2" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="ipw.cn" transform="translate(5.000000, 4.000000)">
|
||||
<g id="nipw.cn" transform="translate(5.000000, 4.000000)">
|
||||
<rect stroke="#61BD13" x="0.5" y="0.5" width="74" height="27" rx="5"></rect>
|
||||
<g transform="translate(15.000000, 4.000000)" fill="#61BD13">
|
||||
<text id="SSL" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
@@ -2,7 +2,7 @@
|
||||
<svg width="85px" height="36px" viewBox="0 0 85 36" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>ssl-s3</title>
|
||||
<g id="ssl-s3" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="ipw.cn" transform="translate(5.000000, 4.000000)">
|
||||
<g id="nipw.cn" transform="translate(5.000000, 4.000000)">
|
||||
<rect id="矩形备份" stroke="#1A42A0" x="0.5" y="0.5" width="74" height="27" rx="5"></rect>
|
||||
<text id="SSL" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500" fill="#1A42A0">
|
||||
<tspan x="34" y="19">SSL</tspan>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -2,7 +2,7 @@
|
||||
<svg width="85px" height="36px" viewBox="0 0 85 36" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>ssl-s4</title>
|
||||
<g id="ssl-s4" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="ipw.cn" transform="translate(5.000000, 4.000000)">
|
||||
<g id="nipw.cn" transform="translate(5.000000, 4.000000)">
|
||||
<rect stroke="#3A84FF" x="0.5" y="0.5" width="74" height="27" rx="5"></rect>
|
||||
<g transform="translate(15.000000, 4.000000)" fill="#3A84FF">
|
||||
<text id="SSL" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -2,7 +2,7 @@
|
||||
<svg width="85px" height="36px" viewBox="0 0 85 36" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>ssl-s5</title>
|
||||
<g id="ssl-s5" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="ipw.cn" transform="translate(5.000000, 4.000000)">
|
||||
<g id="nipw.cn" transform="translate(5.000000, 4.000000)">
|
||||
<rect stroke="#313238" x="0.5" y="0.5" width="74" height="27" rx="5"></rect>
|
||||
<text id="SSL" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500" fill="#313238">
|
||||
<tspan x="34" y="19">SSL</tspan>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -2,7 +2,7 @@
|
||||
<svg width="85px" height="36px" viewBox="0 0 85 36" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>ssl-s6</title>
|
||||
<g id="ssl-s6" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="ipw.cn" transform="translate(5.000000, 4.000000)">
|
||||
<g id="nipw.cn" transform="translate(5.000000, 4.000000)">
|
||||
<rect stroke="#757575" x="0.5" y="0.5" width="74" height="27" rx="5"></rect>
|
||||
<text id="SSL" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500" fill="#757575">
|
||||
<tspan x="34" y="19">SSL</tspan>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -1,11 +1,12 @@
|
||||
import { defineSitemapEventHandler } from '#imports'
|
||||
// 直接 import 构建期常量,而不是走 runtimeConfig.public.docConfig:
|
||||
// 后者会被 Nuxt 序列化进每个页面的 HTML(4571 字节 / 页),而本路由是全站唯一消费方。
|
||||
// 相对路径与 server/routes/middleware/[...slug].get.ts 保持一致(`~` 在 Nuxt 4 里指向 app/,指不到根目录的 config/)。
|
||||
import { docConfig } from '../../../config/doc'
|
||||
|
||||
|
||||
export default defineSitemapEventHandler(async () => {
|
||||
const config = useRuntimeConfig();
|
||||
console.log('Generating sitemap URLs from docConfig:', config.public.docConfig);
|
||||
|
||||
const urls = config.public.docConfig ? Object.keys(config.public.docConfig).map(path => ({ path })) : [];
|
||||
const urls = Object.keys(docConfig).map(path => ({ path }));
|
||||
|
||||
return urls.map(url => ({
|
||||
loc: url.path,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# [API] 获取客户端公网 IP 及位置
|
||||
|
||||
## 1. 接口描述
|
||||
|
||||
> IP 库来源于网络,本项目不对结果的准确性负责,请在中国法律许可范围内使用.
|
||||
|
||||
接口地址: `https://cn-jiangsu.api-ipw.wsmdn.top/v1/location`(公开实例,见 1.1;换成你自己节点的域名同样可用)
|
||||
|
||||
请求方法:`GET`
|
||||
|
||||
用途:取**请求方自己**的公网 IP,并给出 IP 归属地。
|
||||
|
||||
- **不带参数**:用服务端看到的客户端地址(Go `gin` 的 `ClientIP()`),因此"查自己"只需这一个请求。
|
||||
- **多源聚合,不合并**:返回 10 个 IP 库各自的结论,按源名分组,便于交叉比对;本项目不替你做"以哪个源为准"的判断。
|
||||
- **只查本地库**:不依赖任何第三方在线查询接口(唯一例外是 `bilibili` 源,24 小时缓存)。
|
||||
- **免鉴权**:节点未配 `access-token` 时公开可访问;配了则需带 `Authorization: Bearer <access-token>`。
|
||||
|
||||
> 本接口属于"业务探测"端点,请求会被节点计入统计并周期上报收集中心(不落库明细)。
|
||||
|
||||
### 1.1 公开实例
|
||||
|
||||
本项目对外开放了一个江苏节点,可以直接用它把本接口调通,**无需令牌**:
|
||||
|
||||
| 方式 | 地址 |
|
||||
| --- | --- |
|
||||
| 直连节点(推荐) | `https://cn-jiangsu.api-ipw.wsmdn.top/v1/location` |
|
||||
| 经中间件转发 | **不适用**——中间件路径的最后一段(拨测目标)必须非空,而本接口没有目标参数 |
|
||||
|
||||
> 公共实例是**共享**的,调试与小流量接入没问题,请勿高频轮询、批量抓取,也不要拿它当线上服务的依赖。
|
||||
|
||||
### 1.2 还有别的节点 —— 拨测控制台
|
||||
|
||||
上面的江苏节点只是节点池里的一个。节点池的入口是拨测控制台 <https://boce.wsmdn.top>:
|
||||
登录后进「可用节点」页,能看到**全部已启用节点**的节点标识、归属地、归属池(定位 / 拨测)、
|
||||
协议栈与在线状态,并附上每个节点的调用前缀。
|
||||
|
||||
比自己搭一套省事的地方:
|
||||
|
||||
- **节点现成** —— 名单里的节点都是已经部署好的,不用自己找机器、装程序、配隧道。
|
||||
- **入口现成** —— 统一走 HTTPS 转发,证书与反向代理不用自己操心。
|
||||
- **一套规则调所有节点** —— 接口路径与返回结构完全一致,换节点只换地址。
|
||||
- **池与栈标得很清楚** —— 定位类请求走定位池,拨测类走拨测池,两个池都有的节点两种都能接。
|
||||
- **名单会自己更新** —— 新接入的节点自动出现在列表里,不需要改代码。
|
||||
|
||||
> 本接口("查自己")**只能直连节点**:中间件路径的最后一段必须是拨测目标,而"查自己"没有目标参数,
|
||||
> 所以走中间件转发不适用。换别的节点时,把上例的 `cn-jiangsu.api-ipw.wsmdn.top`
|
||||
> 换成该节点的接入域名即可(节点标识见控制台的「可用节点」页)。
|
||||
>
|
||||
> 节点有上下线,列表中会标出在线状态;离线节点调用会失败。
|
||||
|
||||
## 2. 输入参数
|
||||
|
||||
无。
|
||||
|
||||
## 3. 输出参数
|
||||
|
||||
顶层是「源名 → 该源结果」的字典,另有 `ip` 回显请求方地址:
|
||||
|
||||
| 字段 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `ip` | String | 请求方公网 IP(本节点视角) |
|
||||
| `ip2region` | Object | `country`/`administrative_area`/`city`(部分记录另带 `isp`) |
|
||||
| `ip2location` | Object | `country`/`country_code`/`administrative_area`/`city`/`district`/`zipcode`/`latitude`/`longitude`/`timezone`/`isp`/`asn`/`usagetype` |
|
||||
| `ip2location_asn` | Object | `asn`/`as` |
|
||||
| `qqwry` | Object | `country`/`country_code`/`administrative_area`/`city`/`isp` |
|
||||
| `maxmind_city` | Object | `country`/`country_code`/`administrative_area`/`city`/`latitude`/`longitude` |
|
||||
| `maxmind_asn` | Object | `asn`/`org` |
|
||||
| `dbip_city` | Object | `country`/`country_code`/`administrative_area`/`city`/`latitude`/`longitude` |
|
||||
| `dbip_asn` | Object | `asn`/`org` |
|
||||
| `geocn` | Object | `division_code`(中国行政区划码)/`administrative_area`/`city`/`isp`/`type` |
|
||||
| `bilibili` | Object | `country`/`administrative_area`/`city`/`isp`/`latitude`/`longitude`(在线源) |
|
||||
|
||||
取值约定:
|
||||
|
||||
- 某源**未加载**时该键为字符串 `"not loaded"`;该源**查询出错**时为字符串 `"error: ..."`。
|
||||
单个源失败**不会**让整个请求失败——HTTP 状态码仍是 `200`。
|
||||
- 库里没有的字段返回空串 `""`。IP2Location LITE 免费版缺失的字段(如 `isp`、`asn`)已统一过滤,不会出现 `"This parameter is unavailable"`。
|
||||
- 经纬度类型各源不一致:`ip2location` 是**字符串**,`maxmind_*` / `dbip_*` 是**数字**;`bilibili` 也是字符串。
|
||||
- ASN 带前缀与否各源不一致:`maxmind_asn` / `dbip_asn` 形如 `AS13335`,`ip2location_asn.asn` 是裸数字 `13335`。
|
||||
|
||||
## 4. 示例
|
||||
|
||||
### 4.1 cURL 获取本机公网 IP 及位置
|
||||
|
||||
```bash
|
||||
curl https://cn-jiangsu.api-ipw.wsmdn.top/v1/location
|
||||
```
|
||||
|
||||
输出示例(节选):
|
||||
|
||||
```json
|
||||
{
|
||||
"ip": "1.1.1.1",
|
||||
"ip2region": { "country": "Australia", "administrative_area": "Queensland", "city": "Brisbane" },
|
||||
"ip2location": {
|
||||
"country": "Australia", "country_code": "AU", "administrative_area": "Queensland",
|
||||
"city": "Brisbane", "zipcode": "4000", "timezone": "+10:00",
|
||||
"latitude": "-27.46754", "longitude": "153.02809"
|
||||
},
|
||||
"maxmind_asn": { "asn": "AS13335", "org": "Cloudflare, Inc." },
|
||||
"dbip_city": {
|
||||
"country": "澳大利亚", "country_code": "AU", "administrative_area": "New South Wales",
|
||||
"city": "Sydney", "latitude": -33.8688, "longitude": 151.209
|
||||
},
|
||||
"qqwry": { "country": "澳大利亚", "country_code": "AU", "administrative_area": "", "city": "", "isp": "APNIC" },
|
||||
"geocn": { "division_code": "0", "isp": "", "type": "" },
|
||||
"bilibili": { "country": "CLOUDFLARE.COM", "administrative_area": "CLOUDFLARE.COM", "city": "", "isp": "" }
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Python
|
||||
|
||||
```python
|
||||
#!/usr/bin/python3
|
||||
|
||||
import json
|
||||
import requests
|
||||
|
||||
r = requests.get('https://cn-jiangsu.api-ipw.wsmdn.top/v1/location')
|
||||
data = json.loads(r.text)
|
||||
|
||||
print(data['ip']) # 本机公网 IP
|
||||
print(data['ip2location']['country'],
|
||||
data['ip2location']['administrative_area'],
|
||||
data['ip2location']['city']) # 取某一个源
|
||||
print(data['maxmind_asn']['org']) # 运营商 / 组织
|
||||
```
|
||||
|
||||
### 4.3 Golang
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
resp, err := http.Get("https://cn-jiangsu.api-ipw.wsmdn.top/v1/location")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// 各源字段不一致,逐源取用;此处只解出需要的部分
|
||||
var result map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
var ip string
|
||||
_ = json.Unmarshal(result["ip"], &ip)
|
||||
|
||||
var loc struct {
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
}
|
||||
_ = json.Unmarshal(result["ip2location"], &loc)
|
||||
|
||||
fmt.Println(ip, loc.Country, loc.City)
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 错误码
|
||||
|
||||
| 现象 | 说明 |
|
||||
| --- | --- |
|
||||
| `404 page not found` | 该节点 `ipdb` 已关闭:IP 库不加载时 `/v1/location`、`/v1/asn/:ip` 路由不注册 |
|
||||
| `401` | 节点配了 `access-token` 而未携带正确的 `Authorization: Bearer <access-token>` |
|
||||
| 全部源都是 `"error: ..."` / `"not loaded"` | IP 库仍在下载/加载,或本地库文件缺失。节点启动时自动拉取全套库(首次约 450MB),之后每 24h 更新一次 |
|
||||
| `ip` 是内网地址(如 `10.x`) | 直连节点、未经代理时看到的就是内网地址,属预期 |
|
||||
| `ip` 是代理/CDN 的地址 | 节点前置了 Nginx/CDN 但未配 `trusted-proxies`,`X-Forwarded-For` 不被信任。配置方式见《节点 API 与协议参考》 |
|
||||
@@ -0,0 +1,129 @@
|
||||
# [API] 获取客户端公网 IP 地址
|
||||
|
||||
## 1. 接口描述
|
||||
|
||||
> 该接口用于网络排障,请在中国法律许可范围内使用.
|
||||
|
||||
接口地址: `https://test.wsmdn.top/`(本项目公共实例,双栈;另有仅 IPv4 / 仅 IPv6 的实例,见下文)
|
||||
|
||||
请求方法:`GET`
|
||||
|
||||
用途:**原样**返回访问者的公网 IP——纯文本、无 JSON 包裹、无参数、无鉴权、不查任何 IP 库。
|
||||
|
||||
实现是本项目自带的两个边缘函数(任选其一部署,都只做这一件事):
|
||||
|
||||
| 形态 | 代码 | 取 IP 的方式 |
|
||||
| --- | --- | --- |
|
||||
| Cloudflare Workers | `serverless/lemon-getip` | `CF-Connecting-IP` 请求头 |
|
||||
| EdgeOne Edge Functions | `serverless/edgeone-getip` | `request.eo.clientIp` |
|
||||
|
||||
两者都返回 `content-type: text/plain` 并带 `Access-Control-Allow-Origin: *`,所以浏览器里可以直接跨域取、也可以 `curl`。
|
||||
|
||||
**按协议栈部署三份**,域名的解析记录决定拿到哪一族的地址:
|
||||
|
||||
| 用途 | DNS 记录 | 行为 |
|
||||
| --- | --- | --- |
|
||||
| 双栈 | A + AAAA | 返回客户端**实际使用**的那一族地址 |
|
||||
| 仅 IPv4 | 只配 A | 强制走 v4 出口,返回 IPv4 地址 |
|
||||
| 仅 IPv6 | 只配 AAAA | 纯 IPv6 网络下返回 IPv6 地址;本机没有 IPv6 出口时域名**解析不出来**(请求直接失败,而不是返回空) |
|
||||
|
||||
本项目公共实例即按此部署:`4.wsmdn.top`(仅 IPv4)、`6.wsmdn.top`(仅 IPv6)、`test.wsmdn.top`(双栈)。
|
||||
|
||||
> 想把"本机 IP"和"归属地"一次拿全,用同一套体系里的 `/v1/location`(见「获取客户端公网 IP 及位置」),
|
||||
> 它的 `ip` 字段与这里返回的是同一个地址。江苏节点公开实例:
|
||||
> `curl https://cn-jiangsu.api-ipw.wsmdn.top/v1/location`(无需令牌;该接口没有目标参数,
|
||||
> 只能直连节点,见该页的「公开实例」)。
|
||||
|
||||
## 2. 输入参数
|
||||
|
||||
无。
|
||||
|
||||
## 3. 输出参数
|
||||
|
||||
| 参数名称 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| 无 | String | 响应体**本身就是**客户端公网 IP 文本,例如 `223.68.219.100` |
|
||||
|
||||
判定要点:
|
||||
|
||||
- 只有 IP 一个 token,没有换行、没有 JSON 包裹;末尾是否带 `\n` 取决于边缘平台,取值前先 `strip()`。
|
||||
- 不查库 ⇒ 不会出现 `"not loaded"` 之类的占位串,也没有归属地信息。
|
||||
- 边缘函数按平台提供的客户端地址取值,**不解析 `X-Forwarded-For`**:它本身就是直接面向公网的入口。若你在它前面又套了一层自建代理,拿到的是代理地址。
|
||||
|
||||
## 4. 示例
|
||||
|
||||
### 4.1 cURL 获取本机公网 IP
|
||||
|
||||
```bash
|
||||
# 双栈(返回实际使用的栈)
|
||||
curl https://test.wsmdn.top
|
||||
|
||||
# 只要 IPv4 / 只要 IPv6
|
||||
curl https://4.wsmdn.top
|
||||
curl https://6.wsmdn.top
|
||||
```
|
||||
|
||||
输出示例:
|
||||
|
||||
```
|
||||
223.68.219.100
|
||||
```
|
||||
|
||||
### 4.2 Python
|
||||
|
||||
```python
|
||||
#!/usr/bin/python3
|
||||
|
||||
import requests
|
||||
|
||||
r = requests.get('https://test.wsmdn.top')
|
||||
clientIP = r.text.strip()
|
||||
|
||||
print(clientIP)
|
||||
```
|
||||
|
||||
### 4.3 Golang
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
resp, err := http.Get("https://test.wsmdn.top")
|
||||
if err != nil {
|
||||
fmt.Println("获取外网 IP 失败,请检查网络")
|
||||
panic(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
clientIP := strings.TrimSpace(string(body))
|
||||
|
||||
fmt.Println(clientIP)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 浏览器里直接取(CORS 已放开)
|
||||
|
||||
```js
|
||||
const ip = await fetch('https://test.wsmdn.top').then(r => r.text())
|
||||
console.log(ip.trim())
|
||||
```
|
||||
|
||||
> 要判断"当前访问更偏向 IPv4 还是 IPv6",可以同时取双栈域名与两个单栈域名做对比,
|
||||
> 思路见「JS 检查网络是 IPv4 还是 IPv6」。
|
||||
|
||||
## 5. 错误码
|
||||
|
||||
| 现象 | 说明 |
|
||||
| --- | --- |
|
||||
| 域名解析失败 / 连接超时 | 用的是**仅 IPv6** 的域名,而本机没有 IPv6 出口。改用双栈或仅 IPv4 的域名 |
|
||||
| 返回 `Unable to determine IP address` | 边缘平台没有提供客户端地址。常见于本地直连调试(`wrangler dev` / EdgeOne 本地调试)或自建前置代理未透传 |
|
||||
| 返回的是代理地址 | 你在边缘函数前面又套了一层代理;该函数只认平台提供的客户端地址,不解析 `X-Forwarded-For` |
|
||||
| 拿到私有地址(如 `192.168.x.x`) | 本地/内网调试环境,边缘平台看到的就是内网出口 |
|
||||
@@ -0,0 +1,189 @@
|
||||
# [API] 查询指定 IP 地址的位置信息
|
||||
|
||||
## 1. 接口描述
|
||||
|
||||
> IP 库来源于网络,本项目不对结果的准确性负责,请在中国法律许可范围内使用.
|
||||
|
||||
接口地址: `https://cn-jiangsu.api-ipw.wsmdn.top/v1/location/<ip>`(公开实例,见 1.1)
|
||||
|
||||
请求方法:`GET`
|
||||
|
||||
用途:查询**任意 IP**(IPv4 或 IPv6)的归属地,返回与本项目其他查询同一套多源聚合结果。
|
||||
|
||||
- IP 写在**路径**里,不是查询串:`/v1/location/106.224.145.147`。IPv6 直接用标准写法(含 `:`)即可,无需 URL 编码以外的特殊处理。
|
||||
- 与「获取客户端公网 IP 及位置」共用同一个处理器与同一份 IP 库,**区别只是"查谁"**:不带路径参数 = 查请求方自己,带 = 查指定地址。
|
||||
- 出参结构与「获取客户端公网 IP 及位置」**完全一致**,见该页的字段表,此处不重复。
|
||||
- 免鉴权规则同所有 `/v1/*` 端点:节点配了 `access-token` 才需要 `Authorization: Bearer`。
|
||||
- 查 IP 归属地**不需要**目标是本项目节点、也不做连通性探测,只查库;因此查任何公网 IP 都是同样耗时。
|
||||
|
||||
### 1.1 公开实例
|
||||
|
||||
本项目对外开放了一个江苏节点,可以直接用它把本接口调通,**无需令牌**:
|
||||
|
||||
| 方式 | 地址 |
|
||||
| --- | --- |
|
||||
| 直连节点(推荐) | `https://cn-jiangsu.api-ipw.wsmdn.top/v1/location/<ip>` |
|
||||
| 经中间件转发 | `https://middleware-1.api-ipw.wsmdn.top/v1/cn-jiangsu/location/<ip>` |
|
||||
|
||||
两种写法都是「节点 / 接口 / 目标」三段:直连时端点本身就是节点,经中间件时多一段节点标识。
|
||||
中间件入口的 `/v1/` 前缀与 `/middleware/` 前缀等价,用哪个都行。
|
||||
|
||||
> 公共实例是**共享**的,调试与小流量接入没问题,请勿高频轮询、批量抓取,也不要拿它当线上服务的依赖。
|
||||
|
||||
### 1.2 还有别的节点 —— 拨测控制台
|
||||
|
||||
上面的江苏节点只是节点池里的一个。节点池的入口是拨测控制台 <https://boce.wsmdn.top>:
|
||||
登录后进「可用节点」页,能看到**全部已启用节点**的节点标识、归属地、归属池(定位 / 拨测)、
|
||||
协议栈与在线状态,每行都能一键复制调用前缀 —— 把上面那个中间件地址换成控制台给你的入口域名,
|
||||
再接上 `/v1/<节点标识>/` 就是完整前缀,**换节点只动中间那一段标识**。
|
||||
|
||||
比自己搭一套省事的地方:
|
||||
|
||||
- **节点现成** —— 名单里的节点都是已经部署好的,不用自己找机器、装程序、配隧道。
|
||||
- **入口现成** —— 统一走 HTTPS 转发,证书与反向代理不用自己操心。
|
||||
- **一套地址调所有节点** —— 换节点只改标识,接口路径、参数、返回结构都不变。
|
||||
- **池与栈标得很清楚** —— 定位类请求走定位池,拨测类走拨测池,两个池都有的节点两种都能接。
|
||||
- **名单会自己更新** —— 新接入的节点自动出现在列表里,不需要改代码。
|
||||
|
||||
用它调本接口(可直接粘贴执行):
|
||||
|
||||
```bash
|
||||
# 用列表里的江苏节点查一个 IP
|
||||
curl https://middleware-1.api-ipw.wsmdn.top/v1/cn-jiangsu/location/106.224.145.147
|
||||
|
||||
# 换成别的节点:只改中间那一段标识
|
||||
curl https://middleware-1.api-ipw.wsmdn.top/v1/<节点标识>/location/106.224.145.147
|
||||
```
|
||||
|
||||
> 节点有上下线,**离线的节点调用会失败**(返回 `502`)——名单上标了在线状态,挑在线的用。
|
||||
|
||||
## 2. 输入参数
|
||||
|
||||
| 参数名称 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `ip`(必选,路径) | String | 要查询的 IP,IPv4 或 IPv6 均可,例如 `/v1/location/106.224.145.147` |
|
||||
|
||||
> 路径中的 IP 不要带方括号。IPv6 形如 `/v1/location/2400:3200::1`。
|
||||
|
||||
## 3. 输出参数
|
||||
|
||||
与「获取客户端公网 IP 及位置」相同:顶层为「源名 → 该源结果」的字典,`ip` 字段回显被查询的地址。取值约定(未加载、出错、空字段、经纬度类型)也一致。
|
||||
|
||||
**非法 IP 是"看起来成功"的**:IP 传错时 HTTP 仍返回 `200`,但每个源会各自报错,形如:
|
||||
|
||||
```json
|
||||
{
|
||||
"ip": "not-an-ip",
|
||||
"ip2region": "error: parse ip not-an-ip: invalid ip address: not-an-ip",
|
||||
"maxmind_city": "error: ParseAddr(\"not-an-ip\"): unable to parse IP",
|
||||
"ip2location": { "country": "Invalid IP address.", "city": "Invalid IP address." },
|
||||
"ip2location_asn": { "asn": "Invalid IP address.", "as": "Invalid IP address." }
|
||||
}
|
||||
```
|
||||
|
||||
所以**判断成功与否要看字段内容,不能只看状态码**:字段是对象才有结论;是 `"error: ..."` 字符串或 `"Invalid IP address."` 就是没查到。建议调用前先自行校验 IP 格式。
|
||||
|
||||
## 4. 示例
|
||||
|
||||
### 4.1 cURL 查询指定 IP 的位置
|
||||
|
||||
```bash
|
||||
curl https://cn-jiangsu.api-ipw.wsmdn.top/v1/location/106.224.145.147
|
||||
|
||||
# IPv6 同样直接拼:curl https://cn-jiangsu.api-ipw.wsmdn.top/v1/location/2400:3200::1
|
||||
|
||||
# 经公共中间件转发(见 1.1):
|
||||
# curl https://middleware-1.api-ipw.wsmdn.top/v1/cn-jiangsu/location/106.224.145.147
|
||||
```
|
||||
|
||||
输出示例(节选,真实响应):
|
||||
|
||||
```json
|
||||
{
|
||||
"ip": "106.224.145.147",
|
||||
"ip2region": { "country": "中国", "administrative_area": "江西省", "city": "南昌市", "isp": "电信" },
|
||||
"ip2location": {
|
||||
"country": "China", "country_code": "CN", "administrative_area": "Jiangxi",
|
||||
"city": "Ji'an", "zipcode": "343000", "timezone": "+08:00",
|
||||
"latitude": "27.11716", "longitude": "114.97927"
|
||||
},
|
||||
"qqwry": { "country": "中国", "country_code": "CN", "administrative_area": "江西", "city": "吉安", "isp": "电信" },
|
||||
"geocn": { "country": "中国", "administrative_area": "江西省", "city": "南昌市", "division_code": "360100", "isp": "电信" },
|
||||
"maxmind_asn": { "asn": "AS4134", "org": "Chinanet" }
|
||||
}
|
||||
```
|
||||
|
||||
> 上面同一 IP 出现了**南昌 / 吉安**两种结论:这就是"多源并列、不做合并"的含义——
|
||||
> 各库口径与更新批次不同,写法也不统一(`江西省` / `Jiangxi`、`南昌市` / `Nanchang`)。
|
||||
> 想要单一结论请自行选源或做交叉投票。
|
||||
|
||||
### 4.2 Python
|
||||
|
||||
```python
|
||||
#!/usr/bin/python3
|
||||
|
||||
import requests
|
||||
|
||||
queryIP = '106.224.145.147'
|
||||
r = requests.get('https://cn-jiangsu.api-ipw.wsmdn.top/v1/location/' + queryIP)
|
||||
data = r.json()
|
||||
|
||||
qqwry = data.get('qqwry', {})
|
||||
if isinstance(qqwry, dict):
|
||||
print(qqwry.get('country'), qqwry.get('administrative_area'),
|
||||
qqwry.get('city'), qqwry.get('isp'))
|
||||
else:
|
||||
print('该源不可用:', qqwry)
|
||||
```
|
||||
|
||||
### 4.3 Golang
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
queryIP := "106.224.145.147"
|
||||
|
||||
resp, err := http.Get("https://cn-jiangsu.api-ipw.wsmdn.top/v1/location/" + queryIP)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
var loc struct {
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
Isp string `json:"isp"`
|
||||
}
|
||||
if err := json.Unmarshal(result["qqwry"], &loc); err != nil {
|
||||
fmt.Println("该源不可用,原始值:", string(result["qqwry"]))
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s %s\n", loc.Country, loc.City, loc.Isp)
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 错误码
|
||||
|
||||
| 现象 | 说明 |
|
||||
| --- | --- |
|
||||
| 字段是 `"Invalid IP address."`(`ip2location` 系列) | 路径里的 IP 不合法。该库对非法输入仍返回对象,值里写明原因 |
|
||||
| 字段是 `"error: ... unable to parse IP"` | 同上,其它源对非法输入直接返回错误字符串 |
|
||||
| `404 page not found` | 该节点 `ipdb` 已关闭,`/v1/location/:ip` 未注册 |
|
||||
| `401` | 节点配了 `access-token` 但未携带正确的 `Authorization: Bearer <access-token>` |
|
||||
| 结果与常识不符 | 免费 IP 库本身有偏差;本项目是**多源并列**,可交叉比对,不保证单一源正确 |
|
||||
@@ -0,0 +1,228 @@
|
||||
# [API] 查询域名的 WHOIS 信息
|
||||
|
||||
## 1. 接口描述
|
||||
|
||||
> 该接口用于学习域名基础信息,请在中国法律许可范围内使用.
|
||||
|
||||
接口地址: `https://cn-jiangsu.api-ipw.wsmdn.top/v1/whois/<domain>`(公开实例,见 1.1)
|
||||
|
||||
请求方法:`GET`
|
||||
|
||||
用途:查询域名注册信息——注册局记录 + 注册商数据,结构化返回,同时附带原始文本。
|
||||
|
||||
本项目自有的查询流程(不是简单地把库调用包一层):
|
||||
|
||||
1. **先问 IANA**:用域名后缀查 `whois.iana.org`,拿到该后缀的注册局服务器(不猜、不硬编码后缀表);
|
||||
2. **只查注册局**:关闭 whois 库自带的转介链,避免被不可达的注册商服务器拖到超时;
|
||||
3. **补查注册商**:若注册局响应里给出注册商转介服务器(如 `.com` 的 thick 注册局),**限时**再查一次,把结果追加进 `raw`;这一步失败不影响已到手的注册局数据;
|
||||
4. **解析失败不算请求失败**:查询/解析的错误放进响应体的 `error` 字段,HTTP 状态码仍是 `200`。
|
||||
|
||||
其他特性:
|
||||
|
||||
- **结果缓存 5 分钟**。缓存键是**传入的原始字符串**,未做归一化:`QQ.com` 与 `qq.com` 各占一份。
|
||||
- **IPv4 / IPv6 双栈解析** whois 服务器地址(Happy Eyeballs,v6 延迟 150ms 起步),某一栈不通时另一栈仍可用。
|
||||
- **超时预算是分层的**:库单跳 6s(主路径 IANA + 注册局两跳),注册商转介补查另有 3s 总预算且失败即弃。
|
||||
- **入参不做归一化**:不剥 `www.`、不吃 `host:port`。要查的是"注册的那个域名"本身。
|
||||
- `QQ.COM`、`qq.com` 都可以(大小写不敏感,结果里 `domain` 统一大写);
|
||||
- `www.qq.com` 会去查这个**子域**,注册信息为空——不是接口坏了;
|
||||
- `qq.com:43` 不支持,返回 `whois: no whois server found for domain: qq.com:43`。
|
||||
- 免鉴权规则同所有 `/v1/*` 端点:节点配了 `access-token` 才需要 `Authorization: Bearer`。
|
||||
|
||||
### 1.1 公开实例
|
||||
|
||||
本项目对外开放了一个江苏节点,可以直接用它把本接口调通,**无需令牌**:
|
||||
|
||||
| 方式 | 地址 |
|
||||
| --- | --- |
|
||||
| 直连节点(推荐) | `https://cn-jiangsu.api-ipw.wsmdn.top/v1/whois/<domain>` |
|
||||
| 经中间件转发 | `https://middleware-1.api-ipw.wsmdn.top/v1/cn-jiangsu/whois/<domain>` |
|
||||
|
||||
两种写法都是「节点 / 接口 / 目标」三段:直连时端点本身就是节点,经中间件时多一段节点标识。
|
||||
中间件入口的 `/v1/` 前缀与 `/middleware/` 前缀等价,用哪个都行。
|
||||
|
||||
> 公共实例是**共享**的,调试与小流量接入没问题,请勿高频轮询、批量抓取,也不要拿它当线上服务的依赖。
|
||||
|
||||
### 1.2 还有别的节点 —— 拨测控制台
|
||||
|
||||
上面的江苏节点只是节点池里的一个。节点池的入口是拨测控制台 <https://boce.wsmdn.top>:
|
||||
登录后进「可用节点」页,能看到**全部已启用节点**的节点标识、归属地、归属池(定位 / 拨测)、
|
||||
协议栈与在线状态,每行都能一键复制调用前缀 —— 把上面那个中间件地址换成控制台给你的入口域名,
|
||||
再接上 `/v1/<节点标识>/` 就是完整前缀,**换节点只动中间那一段标识**。
|
||||
|
||||
比自己搭一套省事的地方:
|
||||
|
||||
- **节点现成** —— 名单里的节点都是已经部署好的,不用自己找机器、装程序、配隧道。
|
||||
- **入口现成** —— 统一走 HTTPS 转发,证书与反向代理不用自己操心。
|
||||
- **一套地址调所有节点** —— 换节点只改标识,接口路径、参数、返回结构都不变。
|
||||
- **池与栈标得很清楚** —— 定位类请求走定位池,拨测类走拨测池,两个池都有的节点两种都能接。
|
||||
- **名单会自己更新** —— 新接入的节点自动出现在列表里,不需要改代码。
|
||||
|
||||
用它调本接口(可直接粘贴执行):
|
||||
|
||||
```bash
|
||||
# 用列表里的江苏节点查一个域名
|
||||
curl https://middleware-1.api-ipw.wsmdn.top/v1/cn-jiangsu/whois/qq.com
|
||||
|
||||
# 换成别的节点:只改中间那一段标识
|
||||
curl https://middleware-1.api-ipw.wsmdn.top/v1/<节点标识>/whois/qq.com
|
||||
```
|
||||
|
||||
> 节点有上下线,**离线的节点调用会失败**(返回 `502`)——名单上标了在线状态,挑在线的用。
|
||||
|
||||
## 2. 输入参数
|
||||
|
||||
| 参数名称 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `domain`(必选,路径) | String | 要查询的域名,例如 `/v1/whois/qq.com`。写注册域名本身,不要带子域 |
|
||||
|
||||
## 3. 输出参数
|
||||
|
||||
| 参数名称 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `domain` | String | 归一化后的域名(**大写**,如 `QQ.COM`) |
|
||||
| `status` | Array \| null | EPP 状态码列表,如 `["clientTransferProhibited"]`;**没拿到时为 `null`**(不是空数组) |
|
||||
| `registrar.name` | String | 注册商名称 |
|
||||
| `registrar.ianaId` | String | 注册商 IANA ID |
|
||||
| `registrant` | Object | 注册人:`name`/`org`/`phone`/`email`/`province`/`contactUri` |
|
||||
| `technical` | Object | 技术联系人,字段同上 |
|
||||
| `abuseContact` | Object | 滥用投诉联系人,字段同上 |
|
||||
| `dates.registration` | String | 注册时间(RFC3339,缺失为 `""`) |
|
||||
| `dates.expiration` | String | 到期时间(RFC3339,缺失为 `""`) |
|
||||
| `dates.lastChanged` | String | 最后变更时间(RFC3339,缺失为 `""`) |
|
||||
| `nameservers` | Array | NS 列表(保留注册局原始大小写) |
|
||||
| `whoisServer` | String | 最终实际查询的 whois 服务器;未找到后缀对应服务器时为空串 |
|
||||
| `raw` | String | 注册局响应原文(含补查到的注册商内容),排查用 |
|
||||
| `error` | String | 查询/解析错误信息;成功时为空串 `""` |
|
||||
|
||||
判定要点:
|
||||
|
||||
- **字段缺失一律返回空串,不会省略键**;所以判断"有没有数据"要看内容而不是键是否存在。
|
||||
- `status` 为 `null` 与 `[]` 含义不同:前者是**没取到**,后者是取到了但确实没有状态。
|
||||
- 隐私保护(GDPR、注册商隐私服务)会让 `registrant` / `technical` 的字段为空或提示串(例如要求到注册商网页填表才能看邮箱),这是上游数据的样子,不是解析失败。
|
||||
- 想拿"能不能注册 / 什么时候释放",以 `dates.expiration` 与 `status`(是否 `pendingDelete`、`redemptionPeriod`)为准。
|
||||
|
||||
## 4. 示例
|
||||
|
||||
### 4.1 cURL 查询域名注册信息
|
||||
|
||||
```bash
|
||||
curl https://cn-jiangsu.api-ipw.wsmdn.top/v1/whois/qq.com
|
||||
|
||||
# 经公共中间件转发(见 1.1):
|
||||
# curl https://middleware-1.api-ipw.wsmdn.top/v1/cn-jiangsu/whois/qq.com
|
||||
```
|
||||
|
||||
输出示例(真实响应,节选):
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "QQ.COM",
|
||||
"status": [
|
||||
"clientDeleteProhibited", "clientTransferProhibited", "clientUpdateProhibited",
|
||||
"serverDeleteProhibited", "serverTransferProhibited", "serverUpdateProhibited"
|
||||
],
|
||||
"registrar": { "name": "MarkMonitor Information Technology (Shanghai) Co., Ltd.", "ianaId": "3838" },
|
||||
"registrant": {
|
||||
"name": "",
|
||||
"org": "深圳市腾讯计算机系统有限公司",
|
||||
"phone": "",
|
||||
"email": "select request email form at https://domains.markmonitor.com/whois/qq.com",
|
||||
"province": "",
|
||||
"contactUri": ""
|
||||
},
|
||||
"abuseContact": { "name": "", "org": "", "phone": "+1.2086851750", "email": "abusecomplaints@markmonitor.com" },
|
||||
"dates": {
|
||||
"registration": "1995-05-04T04:00:00Z",
|
||||
"expiration": "2034-07-27T02:09:19Z",
|
||||
"lastChanged": "2026-09-23T10:00:17Z"
|
||||
},
|
||||
"nameservers": ["ns1.qq.com", "ns2.qq.com", "ns3.qq.com", "ns4.qq.com"],
|
||||
"whoisServer": "whois.markmonitor.com",
|
||||
"error": ""
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Python
|
||||
|
||||
```python
|
||||
#!/usr/bin/python3
|
||||
|
||||
import requests
|
||||
|
||||
queryDomain = 'qq.com'
|
||||
r = requests.get('https://cn-jiangsu.api-ipw.wsmdn.top/v1/whois/' + queryDomain)
|
||||
data = r.json()
|
||||
|
||||
if data.get('error'):
|
||||
print('查询出错:', data['error'])
|
||||
else:
|
||||
print('Domain Name: %s' % data['domain'])
|
||||
print('Status: %s' % data.get('status'))
|
||||
print('Registrar: %s' % data['registrar']['name'])
|
||||
print('Registration Time: %s' % data['dates']['registration'])
|
||||
print('Expiration Time: %s' % data['dates']['expiration'])
|
||||
```
|
||||
|
||||
### 4.3 Golang
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
queryDomain := "qq.com"
|
||||
|
||||
resp, err := http.Get("https://cn-jiangsu.api-ipw.wsmdn.top/v1/whois/" + queryDomain)
|
||||
if err != nil {
|
||||
fmt.Println("请检查网络")
|
||||
panic(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result struct {
|
||||
Domain string `json:"domain"`
|
||||
Status []string `json:"status"`
|
||||
Nameservers []string `json:"nameservers"`
|
||||
Dates struct {
|
||||
Registration string `json:"registration"`
|
||||
Expiration string `json:"expiration"`
|
||||
} `json:"dates"`
|
||||
Registrar struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"registrar"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
fmt.Println("查询出错:", result.Error)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Domain: %s\n", result.Domain)
|
||||
fmt.Printf("Registrar: %s\n", result.Registrar.Name)
|
||||
fmt.Printf("Expiration: %s\n", result.Dates.Expiration)
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 错误码
|
||||
|
||||
| 现象 | 原因与处理 |
|
||||
| --- | --- |
|
||||
| `400 {"error":"Domain parameter is required"}` | 路径里没有域名 |
|
||||
| `401` | 节点配了 `access-token` 而未携带正确的 `Authorization: Bearer <access-token>` |
|
||||
| `500 {"error":"..."}` | 上游查询异常(网络不可达等),可稍后重试 |
|
||||
| `200` 且 `error` 为 `whois: no whois server found for domain: xxx` | 该后缀**没有 whois 服务器**(国家后缀常见)或不支持。换用该后缀的注册局查询入口 |
|
||||
| `200` 且各字段全空、`status` 为 `null` | 域名格式不合法(如 `not_a_domain`),IANA 查不到对应后缀;或传了子域(如 `www.qq.com`),因注册信息不在子域上故为空 |
|
||||
| `200` 但 `registrant`/`technical` 为空或提示串 | 上游做了隐私保护;`raw` 里能看到原文措辞 |
|
||||
| 改了域名的注册信息但查询结果是旧的 | 命中 5 分钟缓存;等待或用另一个节点查(缓存是**节点本地**的) |
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
原版支持 IPv6 本地网络(宽带)开启验证、IPv6 网站开启检测、IPv6 TCPing 延迟测试、IPv6 网站测速、IPv6地址查询、IPv6 DNS解析。
|
||||
镜像站点由于开发者财力限制,测速、TCPing、DNS等需要多地测速节点的功能无重现计划
|
||||
本文档并不一定完全和ipw.cn文档相同,部分文段有删改
|
||||
本文档并不一定完全和原版文档相同,部分文段有删改
|
||||
|
||||
## 本地网络(宽带)开启IPv6验证
|
||||
工具地址:[IPv6 地址查询](https://ipw.wsmdn.top/)。
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# <span style="background-color: #b95442;color: white;font-size: 0.43em;border-radius: 5px;padding: 2px 5px;">转载</span> JS:检查网络是IPv4还是IPv6
|
||||
|
||||
> 仅供个人测试学习之用,本站不对稳定性负责。
|
||||
|
||||
通过一段 js 代码可以显示用户请求网络是 IPv4 访问优先,还是 IPv6 访问优先。
|
||||
|
||||
## 效果
|
||||
|
||||
> 如果你没有 IPv6 的网络环境,可以先用手机流量访问该页面。
|
||||
|
||||
## 代码
|
||||
|
||||
```html
|
||||
<template>
|
||||
<div>
|
||||
<a href='https://nipw.cn' target='_blank'> <b>访客IP:</b> {{IP}},您的网络 {{IPVersion}} 访问优先</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Axios from "axios";
|
||||
|
||||
export default {
|
||||
name: "Home",
|
||||
data(){
|
||||
return{
|
||||
IP: "",
|
||||
IPVersion: "",
|
||||
}
|
||||
},
|
||||
headers:
|
||||
{
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Access-Control-Allow-Origin': "*"
|
||||
},
|
||||
|
||||
methods:{
|
||||
getData(){
|
||||
var that=this
|
||||
// 双栈接口:按本次请求实际走的协议,返回纯文本的 IPv4 或 IPv6 地址
|
||||
var api = "https://test.wsmdn.top";
|
||||
|
||||
Axios.get(api)
|
||||
.then(function (res) {
|
||||
// handle success
|
||||
console.log(res.data);
|
||||
var ip = String(res.data).trim();
|
||||
console.log(ip);
|
||||
that.IP = ip
|
||||
// 返回的地址含冒号即 IPv6,否则为 IPv4
|
||||
that.IPVersion = ip.indexOf(':') > -1 ? "IPv6" : "IPv4"
|
||||
console.log(that.IPVersion);
|
||||
})
|
||||
.catch(function (error) {
|
||||
// handle error
|
||||
console.log(error);
|
||||
})
|
||||
.then(function () {
|
||||
// always executed
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted(){
|
||||
this.getData();
|
||||
},
|
||||
}
|
||||
</script>
|
||||
```
|
||||
@@ -35,7 +35,7 @@
|
||||
查看 Response Header 来确认服务器支持的 HTTP 的版本。
|
||||
|
||||
```
|
||||
$ curl -I https://ipw.cn
|
||||
$ curl -I https://nipw.cn
|
||||
HTTP/2 200
|
||||
server: nginx
|
||||
content-type: text/html
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# <span style="background-color: #b95442;color: white;font-size: 0.43em;border-radius: 5px;padding: 2px 5px;">转载</span> 网站如何开启 TLS
|
||||
|
||||
## TLS是什么?
|
||||
|
||||
[RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3](https://datatracker.ietf.org/doc/html/rfc8446)
|
||||
|
||||
TLS allows client/server applications to communicate over the Internet in a way that is designed to prevent eavesdropping, tampering, and message forgery.
|
||||
|
||||
### Major Differences from TLS 1.2
|
||||
|
||||
## Wireshark 抓包分析
|
||||
|
||||
### TCP 三次握手
|
||||
|
||||
- SYN 
|
||||
- SYN ACK 
|
||||
- ACK 
|
||||
|
||||
### TLS 协商
|
||||
|
||||
以 TLS 1.3 为例
|
||||
|
||||
- Client Hello 
|
||||
- Server Hello, Change Cipher Spec, Encrypted Extensions 
|
||||
- Certificate, Certificate Verify, Finished 
|
||||
- Change Cipher Spec, Finished 
|
||||
@@ -12,7 +12,7 @@
|
||||
- 禁用 IPv6 本地网络
|
||||
```sh
|
||||
# 验证是否可以访问 IPv6 网络
|
||||
$ curl 6.ipw.cn
|
||||
$ curl 6.wsmdn.top
|
||||
2408:824c:200::2b8b:336f:cc9c
|
||||
|
||||
# 找到网络设备的名字,这里用的是 Wi-Fi (因为 macbook 用的是无线网络访问,即 Wi-Fi)
|
||||
@@ -25,12 +25,12 @@ Bluetooth PAN
|
||||
$ networksetup -setv6off Wi-Fi
|
||||
|
||||
# 请求 IPv4/IPv6 双栈网络,返回 IPv4 地址,证明 IPv4 访问优先。
|
||||
$ curl test.ipw.cn
|
||||
$ curl test.wsmdn.top
|
||||
159.75.190.197
|
||||
|
||||
# 访问 IPv6 网络失败
|
||||
$ curl 6.ipw.cn
|
||||
curl: (6) Could not resolve host: 6.ipw.cn
|
||||
$ curl 6.wsmdn.top
|
||||
curl: (6) Could not resolve host: 6.wsmdn.top
|
||||
```
|
||||
- 启用 IPv6 本地网络
|
||||
```sh
|
||||
@@ -38,7 +38,7 @@ curl: (6) Could not resolve host: 6.ipw.cn
|
||||
$ networksetup -setv6automatic Wi-Fi
|
||||
|
||||
# 验证本地 IPv6 网络访问是否成功
|
||||
$ curl test.ipw.cn
|
||||
$ curl test.wsmdn.top
|
||||
2408:824c:200::2b8b:336f:cc9c
|
||||
```
|
||||
## Window10 禁用或启用 IPv6 本地网络
|
||||
@@ -55,7 +55,7 @@ WLAN Internet 协议版本 6 (TCP/IPv6)
|
||||
以太网 Internet 协议版本 6 (TCP/IPv6) ms_tcpip6 True
|
||||
|
||||
# 验证本地 IPv6 网络是否访问成功
|
||||
PS C:\WINDOWS\system32> curl 6.ipw.cn
|
||||
PS C:\WINDOWS\system32> curl 6.wsmdn.top
|
||||
Content : 2408:824c:200::2b8b:336f:cc9c
|
||||
|
||||
# 禁用 WLAN 设备的本地 IPv6 网络(因为笔记本用的是 无线,即 WLAN)
|
||||
@@ -70,10 +70,10 @@ WLAN Internet 协议版本 6 (TCP/IPv6)
|
||||
以太网 Internet 协议版本 6 (TCP/IPv6) ms_tcpip6 True
|
||||
|
||||
# 无法访问 IPv6 网络
|
||||
PS C:\WINDOWS\system32> curl 6.ipw.cn
|
||||
PS C:\WINDOWS\system32> curl 6.wsmdn.top
|
||||
curl : 无法连接到远程服务器
|
||||
|
||||
PS C:\WINDOWS\system32> curl 4.ipw.cn
|
||||
PS C:\WINDOWS\system32> curl 4.wsmdn.top
|
||||
Content : 159.75.190.197
|
||||
```
|
||||
- 启用 IPv6 本地网络
|
||||
@@ -82,7 +82,7 @@ Content : 159.75.190.197
|
||||
PS C:\WINDOWS\system32> Enable-NetAdapterBinding -Name "WLAN" -ComponentID ms_tcpip6
|
||||
|
||||
# 验证 IPv6 网络是否访问成功
|
||||
PS C:\WINDOWS\system32> curl 6.ipw.cn
|
||||
PS C:\WINDOWS\system32> curl 6.wsmdn.top
|
||||
|
||||
Content : 2408:824c:200::2b8b:336f:cc9c
|
||||
```
|
||||
@@ -97,15 +97,15 @@ Content : 2408:824c:200::2b8b:336f:cc9c
|
||||
$ networksetup -setv4off Wi-Fi
|
||||
|
||||
# 访问 IPv4 网络失败
|
||||
$ curl 4.ipw.cn
|
||||
curl: (6) Could not resolve host: 4.ipw.cn
|
||||
$ curl 4.wsmdn.top
|
||||
curl: (6) Could not resolve host: 4.wsmdn.top
|
||||
```
|
||||
|
||||
- 启用 IPv4 本地网络
|
||||
```sh
|
||||
$ networksetup -setdhcp Wi-Fi
|
||||
|
||||
$ curl 4.ipw.cn
|
||||
$ curl 4.wsmdn.top
|
||||
159.75.190.197
|
||||
```
|
||||
### Window10 禁用/启用IPv4本地网络
|
||||
@@ -115,13 +115,13 @@ $ curl 4.ipw.cn
|
||||
```sh
|
||||
PS C:\WINDOWS\system32> Disable-NetAdapterBinding -Name "WLAN" -ComponentID ms_tcpip
|
||||
|
||||
PS C:\WINDOWS\system32> curl 4.ipw.cn
|
||||
PS C:\WINDOWS\system32> curl 4.wsmdn.top
|
||||
curl : 无法连接到远程服务器
|
||||
```
|
||||
- 启用 IPv4 本地网络
|
||||
```sh
|
||||
PS C:\WINDOWS\system32> Enable-NetAdapterBinding -Name "WLAN" -ComponentID ms_tcpip
|
||||
|
||||
PS C:\WINDOWS\system32> curl 4.ipw.cn
|
||||
PS C:\WINDOWS\system32> curl 4.wsmdn.top
|
||||
159.75.190.197
|
||||
```
|
||||
@@ -0,0 +1,218 @@
|
||||
# <span style="background-color: #b95442;color: white;font-size: 0.43em;border-radius: 5px;padding: 2px 5px;">转载</span> Python/Golang获取 IPv4 和 IPv6 地址
|
||||
|
||||
> 本站是个人站点,用于作者实战学习前端和后台知识,请勿用于商业用途,仅供个人测试学习之用,请遵守中国法律法规
|
||||
|
||||
通过 Python/Golang 获取公网 IPv4 和 IPv6 地址,还可以返回是 IPv4 还是 IPv6 访问优先。
|
||||
|
||||
## 1. Python/Golang获取 IPv4 地址
|
||||
|
||||
### 1.1 Python 获取本机公网IPv4地址
|
||||
|
||||
- 输入示例
|
||||
|
||||
```python
|
||||
#!/usr/bin/python3
|
||||
|
||||
import requests
|
||||
|
||||
# 获取 IPv4 地址接口; https://4.wsmdn.top
|
||||
r = requests.get('https://4.wsmdn.top')
|
||||
|
||||
clientIP = r.text
|
||||
|
||||
print(clientIP)
|
||||
```
|
||||
|
||||
- 输出示例
|
||||
|
||||
```
|
||||
106.224.145.147
|
||||
```
|
||||
|
||||
### 1.2 Golang 获取本机公网IPv4 地址
|
||||
|
||||
- 输入示例
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
// 获取 IPv4 地址接口; https://4.wsmdn.top
|
||||
responseClient, errClient := http.Get("https://4.wsmdn.top")
|
||||
|
||||
if errClient != nil {
|
||||
fmt.Printf("获取外网 IP 失败,请检查网络\n")
|
||||
panic(errClient)
|
||||
}
|
||||
defer responseClient.Body.Close()
|
||||
|
||||
// 获取 http response 的 body
|
||||
body, _ := ioutil.ReadAll(responseClient.Body)
|
||||
clientIP := string(body)
|
||||
|
||||
print(clientIP)
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
- 输出示例
|
||||
|
||||
```
|
||||
106.224.145.147
|
||||
```
|
||||
|
||||
## 2. Python/Golang获取 IPv6 地址
|
||||
|
||||
### 2.1 Python 获取本机公网IPv6地址
|
||||
|
||||
- 输入示例
|
||||
|
||||
```python
|
||||
#!/usr/bin/python3
|
||||
|
||||
import requests
|
||||
|
||||
r = requests.get('https://6.wsmdn.top')
|
||||
|
||||
clientIP = r.text
|
||||
|
||||
print(clientIP)
|
||||
```
|
||||
|
||||
- 输出示例
|
||||
|
||||
```
|
||||
2408:824c:200::2b8b:336f:cc9c
|
||||
```
|
||||
|
||||
### 2.2 Golang 获取本机公网IPv6地址
|
||||
|
||||
- 输入示例
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
responseClient, errClient := http.Get("https://6.wsmdn.top")
|
||||
|
||||
if errClient != nil {
|
||||
fmt.Printf("获取外网 IP 失败,请检查网络\n")
|
||||
panic(errClient)
|
||||
}
|
||||
defer responseClient.Body.Close()
|
||||
|
||||
// 获取 http response 的 body
|
||||
body, _ := ioutil.ReadAll(responseClient.Body)
|
||||
clientIP := string(body)
|
||||
|
||||
print(clientIP)
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
- 输出示例
|
||||
|
||||
```
|
||||
2408:824c:200::2b8b:336f:cc9c
|
||||
```
|
||||
|
||||
## 3. 测试网络是IPv4还是IPv6访问优先
|
||||
|
||||
### 3.1 Python 测试网络是IPv4还是IPv6访问优先
|
||||
|
||||
- 输入示例
|
||||
|
||||
```python
|
||||
#!/usr/bin/python3
|
||||
|
||||
import requests
|
||||
|
||||
## HTTP GET,返回的是纯文本地址
|
||||
r = requests.get('https://test.wsmdn.top')
|
||||
|
||||
IP = r.text.strip()
|
||||
|
||||
## 返回的地址含冒号即 IPv6,否则为 IPv4
|
||||
IPVersion = 'IPv6' if ':' in IP else 'IPv4'
|
||||
|
||||
## 打印
|
||||
print('IP:',IP)
|
||||
print('IPVersion:',IPVersion)
|
||||
```
|
||||
|
||||
- 输出示例
|
||||
|
||||
```bash
|
||||
## 返回示例1(IPv4 访问优先)
|
||||
IP: 106.224.145.147
|
||||
IPVersion: IPv4
|
||||
|
||||
## 返回示例2(IPv6 访问优先)
|
||||
IP: 2408:824c:200::2b8b:336f:cc9c
|
||||
IPVersion: IPv6
|
||||
```
|
||||
|
||||
### 3.2 Golang 测试网络是IPv4还是IPv6访问优先
|
||||
|
||||
- 输入示例
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
responseClient, errClient := http.Get("https://test.wsmdn.top") // 获取外网 IP
|
||||
if errClient != nil {
|
||||
fmt.Printf("获取外网 IP 失败,请检查网络\n")
|
||||
panic(errClient)
|
||||
}
|
||||
// 程序在使用完 response 后必须关闭 response 的主体。
|
||||
defer responseClient.Body.Close()
|
||||
|
||||
body, _ := ioutil.ReadAll(responseClient.Body)
|
||||
|
||||
IP := strings.TrimSpace(string(body))
|
||||
|
||||
// 返回的地址含冒号即 IPv6,否则为 IPv4
|
||||
IPVersion := "IPv4"
|
||||
if strings.Contains(IP, ":") {
|
||||
IPVersion = "IPv6"
|
||||
}
|
||||
|
||||
fmt.Printf("IP: %s\n", IP)
|
||||
fmt.Printf("IPVersion: %s\n", IPVersion)
|
||||
}
|
||||
```
|
||||
|
||||
- 输出示例
|
||||
|
||||
```bash
|
||||
## 返回示例1(IPv4 访问优先)
|
||||
IP: 106.224.145.147
|
||||
IPVersion: IPv4
|
||||
|
||||
## 返回示例2(IPv6 访问优先)
|
||||
IP: 2408:824c:200::2b8b:336f:cc9c
|
||||
IPVersion: IPv6
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
# <span style="background-color: #b95442;color: white;font-size: 0.43em;border-radius: 5px;padding: 2px 5px;">转载</span> DNS解析流程
|
||||
|
||||
## SRV 记录
|
||||
|
||||
SRV 记录:找到要添加 SRV 记录的位置,然后输入:
|
||||
|
||||
端口 协议 名称 服务 优先级 权重 目标 443 _tcp @ _autodiscover 100 1 autodiscover.secureserver.net
|
||||
|
||||

|
||||
|
||||
### 用途
|
||||
|
||||
SRV记录,全称为服务定位(Service Location)记录,是一种在DNS(域名系统)中指定服务位置的记录。它允许用户不仅可以通过域名找到一个服务的地址,还可以找到该服务在特定主机上运行的端口号以及该服务的一些额外信息。SRV记录在多种网络服务中起着关键作用,尤其是在需要定位特定类型服务的应用场景中,如电子邮件、即时通讯、VoIP(Voice over Internet Protocol)等。
|
||||
|
||||
根据您提供的信息,我们可以详细解读这条SRV记录的各个组成部分及其用途:
|
||||
|
||||
- **端口(Port):**`443`。这是服务监听的端口号。在这个例子中,端口443通常用于HTTPS服务,表示这是一个通过安全的方式提供服务的端口。
|
||||
- **协议(Protocol):**`_tcp`。这指定了服务使用的协议类型。在这里,`_tcp`表示该服务使用TCP(传输控制协议)进行通信。SRV记录中的协议通常以`_`开头。
|
||||
- **名称(Name):**`@`。这通常表示当前域,即将这条SRV记录应用于整个域。有时,这里可以指定子域或特定主机。
|
||||
- **服务(Service):**`_autodiscover`。这指定了服务的类型。在这个例子中,`_autodiscover`服务通常与电子邮件相关,用于自动发现邮件服务器的配置信息,使得用户能够仅通过电子邮件地址和密码自动配置他们的邮件客户端。
|
||||
- **优先级(Priority):**`100`。这个数字表示了多个SRV记录之间的优先级。数字越小,优先级越高。在有多个记录指向不同服务器的情况下,客户端会首先尝试优先级最高的服务器。
|
||||
- **权重(Weight):**`1`。当有多个具有相同优先级的SRV记录时,权重决定了这些记录的相对处理顺序。权重越高,该服务器被选中的可能性就越大。权重用于在具有相同优先级的服务器之间分配负载。
|
||||
- **目标(Target):**`autodiscover.secureserver.net`。这是提供服务的服务器的域名。客户端将使用这个地址来访问服务。
|
||||
|
||||
总的来说,这条SRV记录的用途是指导客户端如何找到并连接到提供`_autodiscover`服务的服务器。这对于自动配置电子邮件客户端尤其重要,因为它简化了用户设置邮箱的过程,使用户无需手动输入服务器地址和端口信息。
|
||||
@@ -86,13 +86,13 @@ IPv6 网络开启成功后,我们需要验证下是否真正开启成功。
|
||||
>该站点仅支持 IPv6 网络访问,IPv4 网络无法访问。
|
||||
|
||||
```json
|
||||
// https://6.ipw.cn/
|
||||
// https://6.wsmdn.dpdns.org/
|
||||
240e:3b7:3b7:3b7::3b7
|
||||
```
|
||||
打开 [https://test.wsmdn.dpdns.org/](https://test.wsmdn.dpdns.org/),**如果返回的 IPVersion 字段为 IPv6,则当前网络 IPv6 访问优先,**如果返回的 IPVersion 字段为 IPv4,则当前网络 IPv4 访问优先。
|
||||
打开 [https://test.wsmdn.dpdns.org/](https://test.wsmdn.dpdns.org/),**如果返回的是 IPv6 地址,则当前网络 IPv6 访问优先,**如果返回的是 IPv4 地址,则当前网络 IPv4 访问优先。
|
||||
>[test.wsmdn.dpdns.org](https://test.wsmdn.dpdns.org/) 支持 IPv4/IPv6双栈访问。
|
||||
```json
|
||||
// https://test.ipw.cn/
|
||||
// https://test.wsmdn.dpdns.org/
|
||||
240e:3b7:3b7:3b7::3b7
|
||||
```
|
||||
|
||||
@@ -197,7 +197,7 @@ PING6(56=40+8+8 bytes) --> 2402:4e00:40:40::2:331
|
||||
16 bytes from 2402:4e00:40:40::2:331, icmp_seq=1 hlim=53 time=16.393 ms
|
||||
16 bytes from 2402:4e00:40:40::2:331, icmp_seq=2 hlim=53 time=14.736 ms
|
||||
16 bytes from 2402:4e00:40:40::2:331, icmp_seq=3 hlim=53 time=14.062 ms
|
||||
--- ipw.cn ping6 statistics ---
|
||||
--- ipw.wsmdn.top ping6 statistics ---
|
||||
4 packets transmitted, 4 packets received, 0.0% packet loss
|
||||
round-trip min/avg/max/std-dev = 14.062/15.811/18.052/1.547 ms
|
||||
```
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
访问 `http://[2402:4e00:1013:e500:0:9671:f018:4947]` ,会返回本机 IPv6 IP地址。
|
||||
|
||||

|
||||

|
||||
|
||||
如果有端口,在 IP 地址后面添加端口,比如
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
# <span style="background-color: #b95442;color: white;font-size: 0.43em;border-radius: 5px;padding: 2px 5px;">转载</span> Wireshare 分析 IPv6 包
|
||||
|
||||
>[WARNING]
|
||||
>本文的部分URL已失效,但为了保留原文,未作修改
|
||||
|
||||
## 场景
|
||||
|
||||
执行 `curl 6.ipw.cn`,查看本机的 IPv6 通信包。
|
||||
执行 `curl 6.wsmdn.top`,查看本机的 IPv6 通信包。
|
||||
|
||||
> 如果有 DNS 缓存,可以执行 `sudo killall -HUP mDNSResponder;sudo killall mDNSResponderHelper;sudo dscacheutil -flushcache` 清理缓存
|
||||
|
||||
|
||||
@@ -137,6 +137,9 @@ export default defineEventHandler(async (event) => {
|
||||
method: 'GET',
|
||||
headers: authHeaders,
|
||||
timeout: 15_000,
|
||||
// 关闭 ofetch 内置重试:上游超时/5xx 时它会在同一节点立即重打一遍(retryDelay 默认 0,无退避),
|
||||
// 把 15s 超时变成 30s。重试统一由前端候选循环承担。
|
||||
retry: false,
|
||||
}).catch((error: any) => {
|
||||
console.error(`Error fetching from ${apiBaseUrl}:`, error)
|
||||
const errStatus = error?.status ?? error?.statusCode
|
||||
@@ -179,6 +182,7 @@ export default defineEventHandler(async (event) => {
|
||||
method: 'GET',
|
||||
headers: authHeaders,
|
||||
timeout: 30_000, // tcping/测速上游本身较慢,给更长超时
|
||||
retry: false, // 同上:不让 ofetch 把 30s 超时放大成 60s
|
||||
}).catch((error: any) => {
|
||||
console.error(`Error fetching from ${apiBaseUrl}:`, error)
|
||||
const errStatus = error?.status ?? error?.statusCode
|
||||
|
||||
@@ -31,11 +31,19 @@ function isConnectionError(e: any): boolean {
|
||||
// 401 未授权、403 被 CDN/WAF 风控拦截、418 被反爬/限流标记、502 中间件/上游不可用 —— 换一个节点往往就能恢复
|
||||
const RETRY_STATUS_CODES = new Set([401, 403, 418, 502])
|
||||
|
||||
// 过滤掉 useFetch 专属选项(对 $fetch 无意义),其余(method/query/params/headers/body/timeout 等)原样透传
|
||||
// 过滤掉 useFetch 专属选项(对 $fetch 无意义),其余(method/query/params/headers/body/timeout 等)原样透传。
|
||||
//
|
||||
// 默认关闭 ofetch 内置重试(retry: false):
|
||||
// ofetch 对 GET 默认重试 1 次,且「拿不到响应(网络层错误)」会按 500 参与重试(见 ofetch onError:
|
||||
// responseCode = response.status || 500)。于是 502、429、超时、"连不上"这几种最贵的失败,
|
||||
// 都会在**同一个节点**上被立即重打一遍(retryDelay 默认 0,无退避),
|
||||
// 请求数与等待时间同时 ×2 —— 而外层候选循环本来就会换节点重试,这层纯属重复。
|
||||
// 调用方若显式传 retry,以调用方为准(rest 在后覆盖)。
|
||||
const DEFAULT_RETRY = false
|
||||
|
||||
function toFetchOptions(options?: any) {
|
||||
if (!options) return undefined
|
||||
const { immediate, watch, key, deep, server, lazy, default: _default, transform, pick, getCachedData, dedupe, ...rest } = options
|
||||
return rest
|
||||
const { immediate, watch, key, deep, server, lazy, default: _default, transform, pick, getCachedData, dedupe, ...rest } = options || {}
|
||||
return { retry: DEFAULT_RETRY, ...rest }
|
||||
}
|
||||
|
||||
// useMiddlewareFetch:在 Vue 页面中调用中间件(替代直接 useFetch('/middleware/...'))。
|
||||
@@ -44,6 +52,7 @@ function toFetchOptions(options?: any) {
|
||||
// Nuxt useAsyncData 在 key 变化时会重新绑定 data/error 状态,导致切换后的数据无法可靠渲染):
|
||||
// - 依次尝试 config.Middleware 中的外部节点 + 前端自带中间件兜底;
|
||||
// - 仅当"节点无法连接"或上游返回 401/403/418 时切下一个候选;
|
||||
// - 每次 $fetch 默认 retry: false,同一条通道不会被重打第二遍(重试统一由本循环承担);
|
||||
// - 成功后 data 立即更新为最终节点的数据,调用方 await execute() 后可直接读取并渲染;
|
||||
// - 全部候选失败时 error 为最终错误。
|
||||
export function useMiddlewareFetch<T = any>(url: MaybeRefOrGetter<string>, options?: any) {
|
||||
|
||||
@@ -2,7 +2,14 @@ import { codeToHtml } from './shiki.bundle'
|
||||
|
||||
export async function highlightCode(code: string, lang: string) {
|
||||
try {
|
||||
return await codeToHtml(code, { lang, theme: 'github-dark' })
|
||||
return await codeToHtml(code, {
|
||||
lang,
|
||||
theme: 'github-dark',
|
||||
// github-dark 的注释色 #6A737D 在它自带底色 #24292E 上只有 3.04:1(axe 的 color-contrast,
|
||||
// 首页 12 条里有 5 条都是它)。用 shiki 原生的颜色替换提亮到 #8B949E = 4.77:1 ——
|
||||
// 只动这一个 token,换主题(比如已在 bundle 里的 vitesse-dark)会把整块代码的配色一起换掉。
|
||||
colorReplacements: { '#6a737d': '#8b949e' },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error highlighting code:', error)
|
||||
return code
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/bash
|
||||
# ============================================================
|
||||
# 1IPW.CN 后端节点一键安装脚本
|
||||
# 后端节点一键安装脚本
|
||||
# - 自动检测架构并下载最新 release 二进制
|
||||
# - 交互式输入配置(环境变量注入,无需 setting.json)
|
||||
# - 生成并启用 systemd 守护进程
|
||||
# 用法:sudo bash install.sh
|
||||
# - 非 root 运行时会提示是否改用 systemd --user(用户级服务)
|
||||
# 用法:sudo bash install.sh # 系统级安装(推荐,开机自启、独立于登录会话)
|
||||
# bash install.sh # 非 root:确认后走 systemd --user,仅对当前用户生效
|
||||
# ============================================================
|
||||
|
||||
text="
|
||||
@@ -20,15 +22,58 @@ echo "$text"
|
||||
|
||||
set -e
|
||||
|
||||
# ---------- 前置检查 ----------
|
||||
# ---------- 前置检查与 systemd 作用域 ----------
|
||||
|
||||
# system = 系统级:写 /etc/systemd/system,开机自启走 multi-user.target(需 root)
|
||||
# user = 用户级:写 ~/.config/systemd/user,开机自启走 default.target(systemd --user,无需 root)
|
||||
SYSTEMD_MODE="system"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "错误:需要 root 权限(写入 /etc/systemd/system 与安装目录),请用 sudo 运行" >&2
|
||||
exit 1
|
||||
echo "提示:当前不是 root 环境,无法写入 /etc/systemd/system 与系统安装目录。"
|
||||
read -r -p "是否改用 systemd --user(用户级服务,仅对当前用户生效)?[y/N]: " USE_USER_SYSTEMD
|
||||
case "${USE_USER_SYSTEMD,,}" in
|
||||
y|yes) SYSTEMD_MODE="user" ;;
|
||||
*)
|
||||
echo "已取消:如需系统级安装,请用 sudo 重新运行" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if ! command -v systemctl >/dev/null 2>&1; then
|
||||
echo "错误:未检测到 systemd(systemctl 不存在),无法创建守护进程" >&2
|
||||
exit 1
|
||||
fi
|
||||
# 用户级实例依赖登录会话与 DBus,容器 / cron 等环境常常拿不到,提前拦掉
|
||||
if [ "$SYSTEMD_MODE" = "user" ] && ! systemctl --user list-units >/dev/null 2>&1; then
|
||||
echo "错误:当前环境无法使用 systemd --user(无用户级 systemd 实例或缺少 DBus 会话)" >&2
|
||||
echo " 可改用 sudo bash install.sh 做系统级安装" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="lemon-ipw"
|
||||
if [ "$SYSTEMD_MODE" = "user" ]; then
|
||||
SERVICE_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/${SERVICE_NAME}.service"
|
||||
DEFAULT_INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/lemon-ipw"
|
||||
UNIT_USER_LINE=""
|
||||
UNIT_NETWORK_LINES=""
|
||||
UNIT_WANTED_BY="default.target"
|
||||
else
|
||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
DEFAULT_INSTALL_DIR="/opt/lemon-ipw"
|
||||
# 系统级 unit 的 User= 行与 network-online 依赖;用户级没有这两个概念
|
||||
UNIT_USER_LINE="User=root
|
||||
"
|
||||
UNIT_NETWORK_LINES="After=network-online.target
|
||||
Wants=network-online.target
|
||||
"
|
||||
UNIT_WANTED_BY="multi-user.target"
|
||||
fi
|
||||
|
||||
# 统一 systemctl / journalctl 调用(用户级自动补 --user)
|
||||
sc() { if [ "$SYSTEMD_MODE" = "user" ]; then systemctl --user "$@"; else systemctl "$@"; fi; }
|
||||
jc() { if [ "$SYSTEMD_MODE" = "user" ]; then journalctl --user "$@"; else journalctl "$@"; fi; }
|
||||
|
||||
if ! command -v wget >/dev/null 2>&1 && ! command -v curl >/dev/null 2>&1; then
|
||||
echo "错误:未找到 wget 或 curl,无法下载" >&2
|
||||
exit 1
|
||||
@@ -68,8 +113,16 @@ echo "========================================"
|
||||
echo " 配置后端节点(直接回车使用默认值)"
|
||||
echo "========================================"
|
||||
|
||||
read -r -p "安装目录 [/opt/lemon-ipw]: " INSTALL_DIR
|
||||
INSTALL_DIR=${INSTALL_DIR:-/opt/lemon-ipw}
|
||||
read -r -p "安装目录 [$DEFAULT_INSTALL_DIR]: " INSTALL_DIR
|
||||
INSTALL_DIR=${INSTALL_DIR:-$DEFAULT_INSTALL_DIR}
|
||||
# unit 里 WorkingDirectory/ExecStart 按 systemd 语法裸写(不加引号),
|
||||
# 路径含空白会被拆成多个参数导致启动失败,这里提前拦掉
|
||||
case "$INSTALL_DIR" in
|
||||
*[[:space:]]*)
|
||||
echo "错误:安装目录不能包含空格或制表符(当前值:$INSTALL_DIR)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
read -r -p "监听端口 [8080]: " PORTS
|
||||
PORTS=${PORTS:-8080}
|
||||
@@ -78,46 +131,68 @@ read -r -p "单栈模式 SINGLE_STACK(留空=双栈,ipv4 或 ipv6): " SING
|
||||
|
||||
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 "DNS 服务器(留空=启动时自动探测系统 DNS;主从逗号分隔,如 119.28.28.28:53,223.5.5.5:53): " DNS_SERVER
|
||||
|
||||
read -r -p "DNSSEC 专用 DNS(留空=沿用上面 dns-server): " DNSSEC_DNS_SERVER
|
||||
|
||||
read -r -p "启用 IP 数据库 ipdb(首次启动下载约 450MB)[Y/n]: " IPDB_CHOICE
|
||||
read -r -p "启用 IP 数据库 ipdb(首次启动下载约 450MB,用于 IP 归属地/ASN 类拨测)[y/N]: " IPDB_CHOICE
|
||||
case "${IPDB_CHOICE,,}" in
|
||||
n|no) IPDB="false" ;;
|
||||
*) IPDB="true" ;;
|
||||
y|yes) IPDB="true" ;;
|
||||
*) IPDB="false" ;;
|
||||
esac
|
||||
|
||||
read -r -p "CORS 允许来源(逗号分隔,留空=不限): " CORS
|
||||
|
||||
read -r -p "远端配置地址 remote-config-url(留空=不启用): " REMOTE_CONFIG_URL
|
||||
|
||||
read -r -p "节点 id NODE_ID(回车自动生成 UUID;WS 注册与 HTTP 上报共用): " NODE_ID
|
||||
if [ -z "$NODE_ID" ]; then
|
||||
NODE_ID=$(gen_uuid)
|
||||
echo "节点 id(自动生成 UUID): $NODE_ID"
|
||||
fi
|
||||
|
||||
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
|
||||
read -r -p " 中间件 WS 完整地址(含 wss:// 前缀与 /ws 路径,如 wss://host:8092/ws;多个用逗号分隔将同时连接全部;留空=贡献节点给柠檬的两条公共中间件): " WS_URL
|
||||
if [ -z "$WS_URL" ]; then
|
||||
WS_URL="wss://middleware-1.api-ipw.wsmdn.top/ws"
|
||||
WS_URL="wss://boce-api.api-ipw.wsmdn.top/ws,wss://middleware-1.api-ipw.wsmdn.top/ws"
|
||||
WS_USED_DEFAULT="true"
|
||||
fi
|
||||
read -r -p " 节点 id(回车自动生成 UUID): " NODE_ID
|
||||
if [ -z "$NODE_ID" ]; then
|
||||
NODE_ID=$(gen_uuid)
|
||||
echo " 节点 id(自动生成 UUID): $NODE_ID"
|
||||
fi
|
||||
while [ -z "$NODE_KEY" ]; do
|
||||
read -r -p " 注册 key(必填,中间件 ws-keys 必须包含此节点,否则注册被拒 401): " NODE_KEY
|
||||
if [ -z "$NODE_KEY" ]; then
|
||||
echo " 错误:不加 key 禁止启用 WS,必须提供注册 key"
|
||||
fi
|
||||
# 多地址(逗号分隔):逐段去掉首尾空白并校验 scheme。
|
||||
# 不清理的话空格会原样写进 unit 的 Environment,节点侧解析失败后只会反复重连,很难排查。
|
||||
_ws_norm=""
|
||||
_old_ifs="$IFS"; IFS=','
|
||||
for _seg in $WS_URL; do
|
||||
_seg="${_seg#"${_seg%%[![:space:]]*}"}"
|
||||
_seg="${_seg%"${_seg##*[![:space:]]}"}"
|
||||
case "$_seg" in
|
||||
ws://*|wss://*) ;;
|
||||
"")
|
||||
IFS="$_old_ifs"
|
||||
echo "错误:WS 地址含空段(检查是否多写了逗号)" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
IFS="$_old_ifs"
|
||||
echo "错误:WS 地址的每一段都需以 ws:// 或 wss:// 开头(问题段:${_seg})" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
_ws_norm="${_ws_norm:+${_ws_norm},}${_seg}"
|
||||
done
|
||||
IFS="$_old_ifs"
|
||||
WS_URL="$_ws_norm"
|
||||
read -r -p " 注册 key(留空自动生成,中间件 ws-keys 必须包含此节点,否则注册被拒 401): " NODE_KEY
|
||||
if [ -z "$NODE_KEY" ]; then
|
||||
NODE_KEY=$(gen_uuid | tr -d '-')
|
||||
echo " 注册 key(自动生成): $NODE_KEY"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -143,12 +218,18 @@ echo " 配置汇总"
|
||||
echo "========================================"
|
||||
echo "安装目录: $INSTALL_DIR"
|
||||
echo "服务名: lemon-ipw"
|
||||
if [ "$SYSTEMD_MODE" = "user" ]; then
|
||||
echo "服务作用域: 用户级(systemd --user,仅对当前用户生效)"
|
||||
else
|
||||
echo "服务作用域: 系统级(systemd)"
|
||||
fi
|
||||
echo "监听端口: $PORTS"
|
||||
echo "节点 id: $NODE_ID"
|
||||
echo "单栈模式: ${SINGLE_STACK:-双栈}"
|
||||
echo "access-token: ${ACCESS_TOKEN:+已设置 (隐藏)}"
|
||||
echo "DNS: $DNS_SERVER"
|
||||
echo "DNS: ${DNS_SERVER:-自动探测(系统 DNS)}"
|
||||
echo "ipdb: $IPDB"
|
||||
echo "WS 接入: $(if [ -n "$WS_URL" ]; then printf '%s (id=%s, key=%s)' "$WS_URL" "$NODE_ID" "${NODE_KEY:+已设置}"; else printf '未启用'; fi)"
|
||||
echo "WS 接入: $(if [ -n "$WS_URL" ]; then printf '%s (注册 key=%s)' "$WS_URL" "$NODE_KEY"; else printf '未启用'; fi)"
|
||||
echo "========================================"
|
||||
read -r -p "确认安装?[Y/n]: " CONFIRM
|
||||
case "${CONFIRM,,}" in
|
||||
@@ -183,7 +264,9 @@ chmod +x "$INSTALL_DIR/lemonipw"
|
||||
echo "二进制已安装到 $INSTALL_DIR/lemonipw"
|
||||
|
||||
# ---------- 生成 systemd 服务 ----------
|
||||
SERVICE_FILE="/etc/systemd/system/lemon-ipw.service"
|
||||
|
||||
# 用户级 unit 目录(~/.config/systemd/user)默认可能不存在
|
||||
mkdir -p "$(dirname "$SERVICE_FILE")"
|
||||
|
||||
# 收集环境变量(仅非空的写入,避免空值覆盖默认)
|
||||
# 值里的双引号/反斜杠需要转义,否则会提前终止 systemd 的引号包裹、损坏 unit
|
||||
@@ -217,43 +300,66 @@ fi
|
||||
cat > "$SERVICE_FILE" << EOF
|
||||
[Unit]
|
||||
Description=Lemon IPW Backend Node
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
${UNIT_NETWORK_LINES}
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory="${INSTALL_DIR}"
|
||||
ExecStart="${INSTALL_DIR}/lemonipw"
|
||||
${UNIT_USER_LINE}WorkingDirectory=${INSTALL_DIR}
|
||||
ExecStart=${INSTALL_DIR}/lemonipw
|
||||
${ENV_LINES}Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
WantedBy=${UNIT_WANTED_BY}
|
||||
EOF
|
||||
|
||||
echo "已生成服务文件 $SERVICE_FILE"
|
||||
|
||||
# ---------- 启动服务 ----------
|
||||
systemctl daemon-reload
|
||||
sc daemon-reload
|
||||
# restart 而非 enable --now:重跑安装改配置后旧进程必须重启才会生效
|
||||
systemctl enable lemon-ipw
|
||||
systemctl restart lemon-ipw
|
||||
systemctl status lemon-ipw --no-pager | head -15
|
||||
sc enable lemon-ipw
|
||||
sc restart lemon-ipw
|
||||
sc status lemon-ipw --no-pager | head -15
|
||||
|
||||
# 用户级服务默认随最后一个会话结束而停止,注销后要继续跑必须开 linger
|
||||
if [ "$SYSTEMD_MODE" = "user" ]; then
|
||||
if loginctl show-user "$(id -un)" -p Linger 2>/dev/null | grep -q 'Linger=yes'; then
|
||||
echo "linger 已启用:用户注销后服务继续运行"
|
||||
elif loginctl enable-linger "$(id -un)" >/dev/null 2>&1; then
|
||||
echo "已启用 linger:用户注销后服务继续运行"
|
||||
else
|
||||
echo "提示:未能自动启用 linger,注销后服务会被停止。请手动执行:" >&2
|
||||
echo " sudo loginctl enable-linger $(id -un)" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " 安装完成"
|
||||
echo "========================================"
|
||||
# 用户级服务的运维命令都要带 --user,否则看到的是系统级命名空间(空)
|
||||
if [ "$SYSTEMD_MODE" = "user" ]; then
|
||||
CTL_CMD="systemctl --user"
|
||||
JRN_CMD="journalctl --user -u"
|
||||
else
|
||||
CTL_CMD="systemctl"
|
||||
JRN_CMD="journalctl -u"
|
||||
fi
|
||||
echo "常用命令:"
|
||||
echo " systemctl status lemon-ipw # 查看状态"
|
||||
echo " journalctl -u lemon-ipw -f # 查看日志"
|
||||
echo " systemctl restart lemon-ipw # 重启(改配置后)"
|
||||
echo " $CTL_CMD status lemon-ipw # 查看状态"
|
||||
echo " $JRN_CMD lemon-ipw -f # 查看日志"
|
||||
echo " $CTL_CMD 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 "WS 通道已启用,请把节点注册信息加入每一个对应中间件 setting.json 的 ws-keys(缺一个该条连接会被拒 401):"
|
||||
echo " \"$NODE_ID\": \"$NODE_KEY\""
|
||||
echo " 地址清单:"
|
||||
_old_ifs="$IFS"; IFS=','
|
||||
for _seg in $WS_URL; do
|
||||
echo " - $_seg"
|
||||
done
|
||||
IFS="$_old_ifs"
|
||||
fi
|
||||
if [ -n "$WS_USED_DEFAULT" ]; then
|
||||
echo ""
|
||||
|
||||
@@ -20,7 +20,9 @@ import (
|
||||
// 现有 HTTP 接口(/v1/* /middleware/*)完全不变,WS 是独立端口上的新增数据面。
|
||||
//
|
||||
// 消息信封(JSON 文本帧):{ "type": "...", "nodeId": "...", "ts": <unix秒>, "data": {...} }
|
||||
// - 节点 → middleware:register / probe_result / pong / command
|
||||
// - 节点 → middleware:register / ping / pong / probe_result / report / config_result / ota_result
|
||||
// 其中 report(统计与拨测明细上报)与 config_result / ota_result(远程运维应答)的消费者是
|
||||
// 收集中心 ipw-boce,本中间件只是转发与拨测代理,收到即丢弃(见下方 switch 内注释)。
|
||||
// - middleware → 节点:register_ok / register_error / probe / ping / status
|
||||
//
|
||||
// probe 消息 data:{ "requestId": "...", "apiType": "tcping", "raw": "qq.com", "query": {"port":"443"} }
|
||||
@@ -183,6 +185,15 @@ func (s *wsServer) Handler(w http.ResponseWriter, r *http.Request) {
|
||||
s.sendJSON(c, wsMessage{Type: "pong", NodeID: peer.id, TS: time.Now().Unix()})
|
||||
}
|
||||
|
||||
case "report", "config_result", "ota_result":
|
||||
// 协议已知、但本中间件不消费的帧 —— 刻意显式列出、收到即丢弃,绝不能落到 default:
|
||||
// - report:节点按 report-interval-seconds 把统计与拨测明细**广播给所有在线 ws 连接**(多活),
|
||||
// 它的消费者是收集中心 ipw-boce(中间件不在转发路径上采集,不落库也不计数)。
|
||||
// - config_result / ota_result:只在对端下发过 config / ota 指令后才会出现,本中间件不下发
|
||||
// 这类指令(远程运维归收集中心),因此正常情形收不到。
|
||||
// 注意:这几个帧曾落到 default 被当成"未知消息"打印,节点每个上报周期刷一行
|
||||
// (如 "unknown message type from cn-jiangsu: report"),日志噪音随节点数 × 上报频率线性增长。
|
||||
|
||||
default:
|
||||
log.Printf("[ws] unknown message type from %s: %s", peer.id, msg.Type)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package webtest
|
||||
|
||||
// EO(EdgeOne)特供版的 DNS 模块:与主线 ipw-cn/src/webtest/dns.go 同源,
|
||||
// 但保留 EO 部署形态的两点差异:
|
||||
// 1. DoH / UDP 双通道互备(单地址配置下主通道失败自动回退另一通道);
|
||||
// 2. 查询耗时沿调用链透出(float64 毫秒),ResolveIP 供 tcping / speed 复用。
|
||||
// 主线对 dns.go 的行为修正(Rcode 透出、服务器地址格式校验)已同步至此。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
@@ -7,6 +13,8 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -29,11 +37,20 @@ var dnsServers []string
|
||||
// dnssecServers DNSSEC 专用服务器列表(未配置时沿用 dnsServers)
|
||||
var dnssecServers []string
|
||||
|
||||
// defaultUDPServer 兜底 UDP 服务器
|
||||
const defaultUDPServer = "119.28.28.28:53"
|
||||
|
||||
// DNSResult 统一的 DNS 查询结果格式。
|
||||
//
|
||||
// Rcode / RcodeText 与主线及各节点保持一致:解析器明确应答 NXDOMAIN 时,属于「域名不存在」
|
||||
// 这个业务结论,而不是节点故障 —— 前端据此区分两者,别把负响应误显示成拨测失败。
|
||||
type DNSResult struct {
|
||||
Domain string `json:"domain"`
|
||||
Duration float64 `json:"duration"`
|
||||
Record []string `json:"record"`
|
||||
TTL uint32 `json:"ttl"`
|
||||
Domain string `json:"domain"`
|
||||
Duration float64 `json:"duration"`
|
||||
Record []string `json:"record"`
|
||||
TTL uint32 `json:"ttl"`
|
||||
Rcode int `json:"rcode"` // 解析器应答的 Rcode(0=Success, 3=NXDOMAIN)
|
||||
RcodeText string `json:"rcode_text"` // Rcode 可读名称,如 NXDOMAIN / SERVFAIL
|
||||
}
|
||||
|
||||
// SetDNSServer 设置 DNS 服务器:支持逗号分隔多地址主从 failover(第一个为主,主失败自动切换后续从服务器)。
|
||||
@@ -42,15 +59,22 @@ func SetDNSServer(server string) {
|
||||
if server == "" {
|
||||
return
|
||||
}
|
||||
list := splitServers(server)
|
||||
// dns-server 既能由本地管理面 PATCH 改,也能由远端托管配置下发 —— 都是外部可控输入。
|
||||
// 在这里先挡掉畸形值,总比留到查询时才以"莫名其妙的超时/报错"暴露好排查;
|
||||
// 全部非法时保留原有配置(与"空串不覆盖"的既有语义一致)。
|
||||
list := validServers(splitServers(server))
|
||||
if len(list) == 0 {
|
||||
slog.Warn("all DNS server addresses invalid, keeping previous config", "server", server)
|
||||
return
|
||||
}
|
||||
dnsServers = list
|
||||
// 兼容单地址的旧语义:URL → DoH 主通道;ip:port → UDP 主通道
|
||||
if len(list) == 1 {
|
||||
if strings.HasPrefix(server, "http://") || strings.HasPrefix(server, "https://") {
|
||||
dohEndpoint = server
|
||||
if strings.HasPrefix(list[0], "http://") || strings.HasPrefix(list[0], "https://") {
|
||||
dohEndpoint = list[0]
|
||||
dnsMode = "doh"
|
||||
} else {
|
||||
dnsServer = server
|
||||
dnsServer = list[0]
|
||||
dnsMode = "udp"
|
||||
}
|
||||
}
|
||||
@@ -58,7 +82,7 @@ func SetDNSServer(server string) {
|
||||
|
||||
// SetDNSSecServer 设置 DNSSEC 专用 DNS 服务器(逗号分隔主从;留空 = 沿用 dns-server 配置)
|
||||
func SetDNSSecServer(server string) {
|
||||
if list := splitServers(server); len(list) > 0 {
|
||||
if list := validServers(splitServers(server)); len(list) > 0 {
|
||||
dnssecServers = list
|
||||
}
|
||||
}
|
||||
@@ -75,6 +99,50 @@ func splitServers(s string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// validServers 过滤掉格式非法的地址(保留原顺序)。
|
||||
// 全部非法时返回空列表,调用方据此保留原有配置。
|
||||
func validServers(list []string) []string {
|
||||
out := make([]string, 0, len(list))
|
||||
for _, s := range list {
|
||||
if err := validateServerAddr(s); err != nil {
|
||||
slog.Warn("ignored invalid DNS server address", "server", s, "error", err)
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validateServerAddr 校验单个 DNS 服务器地址的格式:
|
||||
// - DoH URL:必须是 http/https 且 host 非空(**不限制私网** —— 节点自建内网 DoH 是合理部署);
|
||||
// - UDP/TCP:必须是 host:port 且端口合法。
|
||||
//
|
||||
// 注意:UDP 形式**不禁止私有地址** —— 节点上跑本地缓存解析器(127.0.0.1:53、内网 10.x:53)是常见部署,
|
||||
// 它是节点侧基础设施配置、不是被拨测目标,一并堵掉会直接让 DNS 拨测失效。
|
||||
func validateServerAddr(server string) error {
|
||||
if strings.HasPrefix(server, "http://") || strings.HasPrefix(server, "https://") {
|
||||
u, err := url.Parse(server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Hostname() == "" {
|
||||
return fmt.Errorf("missing host")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
host, port, err := net.SplitHostPort(server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(host) == "" {
|
||||
return fmt.Errorf("missing host")
|
||||
}
|
||||
if p, err := strconv.Atoi(port); err != nil || p < 1 || p > 65535 {
|
||||
return fmt.Errorf("invalid port %q", port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ⭐️ 底层核心:DoH + UDP 双通道 DNS 查询
|
||||
// ==========================================
|
||||
@@ -139,9 +207,6 @@ func queryWithServers(msg *dns.Msg, servers []string) (*dns.Msg, float64, error)
|
||||
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}
|
||||
@@ -154,7 +219,12 @@ func queryUDPMsg(msg *dns.Msg, server string) (*dns.Msg, float64, error) {
|
||||
return resp, duration, nil
|
||||
}
|
||||
|
||||
// queryDoHMsg 通过 DoH(RFC 8484,POST application/dns-message)向指定端点发送查询
|
||||
// queryDoHMsg 通过 DoH(RFC 8484,POST application/dns-message)向指定端点发送查询。
|
||||
//
|
||||
// 这里**刻意不做私网/回环拦截**(与 website / ssl / speed 那三处不同):
|
||||
// endpoint 来自 dns-server 配置,而节点上自建内网 DoH(127.0.0.1 / 10.x)是合理部署,
|
||||
// 堵私网会直接打死功能;配置又是管理员凭据才能改的,不构成匿名可达攻击面。
|
||||
// 地址**格式**校验已在 SetDNSServer 入口由 validateServerAddr 完成(http/https + host 非空)。
|
||||
func queryDoHMsg(msg *dns.Msg, endpoint string) (*dns.Msg, float64, error) {
|
||||
packedMsg, err := msg.Pack()
|
||||
if err != nil {
|
||||
@@ -192,209 +262,136 @@ func queryDoHMsg(msg *dns.Msg, endpoint string) (*dns.Msg, float64, error) {
|
||||
// 业务层:构造查询并经双通道执行,提取特定记录
|
||||
// ==========================================
|
||||
|
||||
// executeDoHQuery 构造标准 DNS 请求报文并经双通道执行。
|
||||
//
|
||||
// **不再把 Rcode 非 Success 当错误**:解析器明确应答(哪怕 NXDOMAIN / SERVFAIL)说明网络与
|
||||
// 解析链路是通的,属合法业务结果,由上层透出 Rcode;只有传输/网络层失败才返回 error。
|
||||
// 若在此处返回 error,上层 dnsQueryHandler 会回 500,前端把「域名不存在」显示成「节点故障」。
|
||||
func executeDoHQuery(domain string, qtype uint16) (*dns.Msg, float64, error) {
|
||||
// 1. 构造标准的 DNS 请求报文
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(dns.Fqdn(domain), qtype)
|
||||
msg.RecursionDesired = true // 请求递归解析
|
||||
|
||||
// 2. 双通道查询(DoH 主 + UDP 备,或反之)
|
||||
responseMsg, duration, err := queryDNSMsg(msg)
|
||||
if err != nil {
|
||||
return nil, duration, err
|
||||
}
|
||||
|
||||
// 3. 检查 DNS 响应码 (Rcode)
|
||||
if responseMsg.Rcode != dns.RcodeSuccess {
|
||||
return responseMsg, duration, fmt.Errorf("DNS query failed with Rcode %d", responseMsg.Rcode)
|
||||
}
|
||||
|
||||
return responseMsg, duration, nil
|
||||
return queryDNSMsg(msg)
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 业务层:调用底层函数并提取特定记录
|
||||
// ==========================================
|
||||
|
||||
func ResolveARecord(domain string) (DNSResult, error) {
|
||||
// resolveTyped 通用记录查询:成功收到解析器应答(即便 Rcode 非 Success,如 NXDOMAIN/SERVFAIL)
|
||||
// 即视为一次有效查询,将 Rcode 透出并返回 200;仅在传输/网络层失败(超时、服务器不可达、
|
||||
// DoH 异常)时才返回 error(上层据此返回 500)。避免把合法的负响应误判为节点故障。
|
||||
// extract 从一个 RR 中抽取记录字符串(TXT 可能含多条),返回 (values, ttl, matched)。
|
||||
func resolveTyped(domain, name string, qtype uint16, extract func(dns.RR) ([]string, uint32, bool)) (DNSResult, error) {
|
||||
result := DNSResult{Domain: domain, Record: []string{}}
|
||||
responseMsg, duration, err := executeDoHQuery(domain, dns.TypeA)
|
||||
response, duration, err := executeDoHQuery(name, qtype)
|
||||
result.Duration = duration
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query A", "domain", domain, "err", err)
|
||||
slog.Warn("Failed to query DNS", "domain", domain, "qtype", qtype, "error", err)
|
||||
return result, err
|
||||
}
|
||||
for _, ans := range responseMsg.Answer {
|
||||
if aRecord, ok := ans.(*dns.A); ok {
|
||||
result.Record = append(result.Record, aRecord.A.String())
|
||||
|
||||
// 解析器已应答:无论 Rcode 是否为 Success,都属合法结果,不再当 error 返回
|
||||
result.Rcode = response.Rcode
|
||||
result.RcodeText = dns.RcodeToString[response.Rcode]
|
||||
for _, ans := range response.Answer {
|
||||
if values, ttl, ok := extract(ans); ok {
|
||||
result.Record = append(result.Record, values...)
|
||||
if result.TTL == 0 {
|
||||
result.TTL = aRecord.Header().Ttl
|
||||
result.TTL = ttl
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ResolveARecord(domain string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeA, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if a, ok := rr.(*dns.A); ok {
|
||||
return []string{a.A.String()}, a.Header().Ttl, true
|
||||
}
|
||||
return nil, 0, false
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveAAAARecord(domain string) (DNSResult, error) {
|
||||
result := DNSResult{Domain: domain, Record: []string{}}
|
||||
responseMsg, duration, err := executeDoHQuery(domain, dns.TypeAAAA)
|
||||
result.Duration = duration
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query AAAA", "domain", domain, "err", err)
|
||||
return result, err
|
||||
}
|
||||
for _, ans := range responseMsg.Answer {
|
||||
if aRecord, ok := ans.(*dns.AAAA); ok {
|
||||
result.Record = append(result.Record, aRecord.AAAA.String())
|
||||
if result.TTL == 0 {
|
||||
result.TTL = aRecord.Header().Ttl
|
||||
}
|
||||
return resolveTyped(domain, domain, dns.TypeAAAA, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if a, ok := rr.(*dns.AAAA); ok {
|
||||
return []string{a.AAAA.String()}, a.Header().Ttl, true
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
return nil, 0, false
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveTXTRecord(domain string) (DNSResult, error) {
|
||||
result := DNSResult{Domain: domain, Record: []string{}}
|
||||
responseMsg, duration, err := executeDoHQuery(domain, dns.TypeTXT)
|
||||
result.Duration = duration
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query TXT", "domain", domain, "err", err)
|
||||
return result, err
|
||||
}
|
||||
for _, ans := range responseMsg.Answer {
|
||||
if txtRecord, ok := ans.(*dns.TXT); ok {
|
||||
result.Record = append(result.Record, txtRecord.Txt...)
|
||||
if result.TTL == 0 {
|
||||
result.TTL = txtRecord.Header().Ttl
|
||||
}
|
||||
return resolveTyped(domain, domain, dns.TypeTXT, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if txt, ok := rr.(*dns.TXT); ok {
|
||||
// TXT 一条 RR 可能含多段字符串,保持原有「逐段单独成项」的行为
|
||||
return txt.Txt, txt.Header().Ttl, true
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
return nil, 0, false
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveNSRecord(domain string) (DNSResult, error) {
|
||||
result := DNSResult{Domain: domain, Record: []string{}}
|
||||
responseMsg, duration, err := executeDoHQuery(domain, dns.TypeNS)
|
||||
result.Duration = duration
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query NS", "domain", domain, "err", err)
|
||||
return result, err
|
||||
}
|
||||
for _, ans := range responseMsg.Answer {
|
||||
if nsRecord, ok := ans.(*dns.NS); ok {
|
||||
result.Record = append(result.Record, nsRecord.Ns)
|
||||
if result.TTL == 0 {
|
||||
result.TTL = nsRecord.Header().Ttl
|
||||
}
|
||||
return resolveTyped(domain, domain, dns.TypeNS, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if ns, ok := rr.(*dns.NS); ok {
|
||||
return []string{ns.Ns}, ns.Header().Ttl, true
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
return nil, 0, false
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveCNAMERecord(domain string) (DNSResult, error) {
|
||||
result := DNSResult{Domain: domain, Record: []string{}}
|
||||
responseMsg, duration, err := executeDoHQuery(domain, dns.TypeCNAME)
|
||||
result.Duration = duration
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query CNAME", "domain", domain, "err", err)
|
||||
return result, err
|
||||
}
|
||||
for _, ans := range responseMsg.Answer {
|
||||
if cnameRecord, ok := ans.(*dns.CNAME); ok {
|
||||
result.Record = append(result.Record, cnameRecord.Target)
|
||||
if result.TTL == 0 {
|
||||
result.TTL = cnameRecord.Header().Ttl
|
||||
}
|
||||
return resolveTyped(domain, domain, dns.TypeCNAME, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if cname, ok := rr.(*dns.CNAME); ok {
|
||||
return []string{cname.Target}, cname.Header().Ttl, true
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
return nil, 0, false
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveMXRecord(domain string) (DNSResult, error) {
|
||||
result := DNSResult{Domain: domain, Record: []string{}}
|
||||
responseMsg, duration, err := executeDoHQuery(domain, dns.TypeMX)
|
||||
result.Duration = duration
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query MX", "domain", domain, "err", err)
|
||||
return result, err
|
||||
}
|
||||
for _, ans := range responseMsg.Answer {
|
||||
if mxRecord, ok := ans.(*dns.MX); ok {
|
||||
result.Record = append(result.Record, mxRecord.Mx)
|
||||
if result.TTL == 0 {
|
||||
result.TTL = mxRecord.Header().Ttl
|
||||
}
|
||||
return resolveTyped(domain, domain, dns.TypeMX, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if mx, ok := rr.(*dns.MX); ok {
|
||||
return []string{mx.Mx}, mx.Header().Ttl, true
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
return nil, 0, false
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveSRVRecord(domain string) (DNSResult, error) {
|
||||
result := DNSResult{Domain: domain, Record: []string{}}
|
||||
responseMsg, duration, err := executeDoHQuery(domain, dns.TypeSRV)
|
||||
result.Duration = duration
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query SRV", "domain", domain, "err", err)
|
||||
return result, err
|
||||
}
|
||||
for _, ans := range responseMsg.Answer {
|
||||
if srvRecord, ok := ans.(*dns.SRV); ok {
|
||||
result.Record = append(result.Record, srvRecord.Target)
|
||||
if result.TTL == 0 {
|
||||
result.TTL = srvRecord.Header().Ttl
|
||||
}
|
||||
return resolveTyped(domain, domain, dns.TypeSRV, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if srv, ok := rr.(*dns.SRV); ok {
|
||||
return []string{srv.Target}, srv.Header().Ttl, true
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ResolvePTRRecord(ip string) (DNSResult, error) {
|
||||
result := DNSResult{Domain: ip, Record: []string{}}
|
||||
ptrName, err := dns.ReverseAddr(ip)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("invalid IP: %v", err)
|
||||
}
|
||||
|
||||
responseMsg, duration, err := executeDoHQuery(ptrName, dns.TypePTR)
|
||||
result.Duration = duration
|
||||
result.Domain = ip // 保持返回的 Domain 为原始 IP
|
||||
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query PTR", "ip", ip, "err", err)
|
||||
return result, err
|
||||
}
|
||||
for _, ans := range responseMsg.Answer {
|
||||
if ptrRecord, ok := ans.(*dns.PTR); ok {
|
||||
result.Record = append(result.Record, ptrRecord.Ptr)
|
||||
if result.TTL == 0 {
|
||||
result.TTL = ptrRecord.Header().Ttl
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
return nil, 0, false
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveCAARecord(domain string) (DNSResult, error) {
|
||||
result := DNSResult{Domain: domain, Record: []string{}}
|
||||
responseMsg, duration, err := executeDoHQuery(domain, dns.TypeCAA)
|
||||
result.Duration = duration
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query CAA", "domain", domain, "err", err)
|
||||
return result, err
|
||||
}
|
||||
for _, ans := range responseMsg.Answer {
|
||||
if caaRecord, ok := ans.(*dns.CAA); ok {
|
||||
result.Record = append(result.Record, caaRecord.Value)
|
||||
if result.TTL == 0 {
|
||||
result.TTL = caaRecord.Header().Ttl
|
||||
}
|
||||
return resolveTyped(domain, domain, dns.TypeCAA, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if caa, ok := rr.(*dns.CAA); ok {
|
||||
return []string{caa.Value}, caa.Header().Ttl, true
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
return nil, 0, false
|
||||
})
|
||||
}
|
||||
|
||||
// ResolveIP 通过 DoH 解析域名,返回指定版本(v4/v6)的 IP 地址字符串
|
||||
// ResolvePTRRecord 反查 IP 的 PTR 记录(返回的 Domain 保持为原始 IP,便于前端直接展示)
|
||||
func ResolvePTRRecord(ip string) (DNSResult, error) {
|
||||
ptrName, err := dns.ReverseAddr(ip)
|
||||
if err != nil {
|
||||
slog.Warn("Invalid IP address for PTR query", "ip", ip, "error", err)
|
||||
return DNSResult{Domain: ip, Record: []string{}}, fmt.Errorf("invalid IP address: %v", err)
|
||||
}
|
||||
return resolveTyped(ip, ptrName, dns.TypePTR, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if ptr, ok := rr.(*dns.PTR); ok {
|
||||
return []string{ptr.Ptr}, ptr.Header().Ttl, true
|
||||
}
|
||||
return nil, 0, false
|
||||
})
|
||||
}
|
||||
|
||||
// ResolveIP 通过双通道解析域名,返回指定版本(v4/v6)的 IP 地址字符串。
|
||||
// EO 特供:供 tcping / speed 等需要"先把 host 归一化成 IP"的拨测复用。
|
||||
func ResolveIP(host string, version string) (string, error) {
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if version == "v4" && ip.To4() != nil {
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
"name": "lemon-getip",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"hono": "^4.12.34"
|
||||
"hono": "^4.13.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
||||
"typescript": "^5.5.2",
|
||||
"vitest": "~4.1.10",
|
||||
"vitest": "~4.1.11",
|
||||
"wrangler": "^4.124.0"
|
||||
}
|
||||
},
|
||||
@@ -160,18 +160,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
@@ -183,17 +171,6 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
@@ -1239,33 +1216,14 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
|
||||
"version": "0.149.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz",
|
||||
"integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
"url": "https://github.com/sponsors/oxc-project"
|
||||
}
|
||||
},
|
||||
"node_modules/@poppinss/colors": {
|
||||
@@ -1297,10 +1255,27 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz",
|
||||
"integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz",
|
||||
"integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1315,9 +1290,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz",
|
||||
"integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1332,9 +1307,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz",
|
||||
"integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1349,9 +1324,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz",
|
||||
"integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1366,9 +1341,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
|
||||
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz",
|
||||
"integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1383,9 +1358,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz",
|
||||
"integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1403,9 +1378,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz",
|
||||
"integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1423,9 +1398,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz",
|
||||
"integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1443,9 +1418,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz",
|
||||
"integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1463,9 +1438,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz",
|
||||
"integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1483,9 +1458,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz",
|
||||
"integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1503,9 +1478,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz",
|
||||
"integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1519,40 +1494,10 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
|
||||
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.11.1",
|
||||
"@emnapi/runtime": "1.11.1",
|
||||
"@napi-rs/wasm-runtime": "^1.1.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz",
|
||||
"integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1567,9 +1512,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz",
|
||||
"integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1617,17 +1562,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -1654,16 +1588,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
||||
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
|
||||
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -1672,13 +1606,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
|
||||
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
|
||||
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -1699,9 +1633,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
|
||||
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
|
||||
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1712,13 +1646,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
|
||||
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
|
||||
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.10",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -1726,14 +1660,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
|
||||
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
|
||||
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -1742,9 +1676,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
|
||||
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
|
||||
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -1752,13 +1686,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
|
||||
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
|
||||
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -1944,9 +1878,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.34",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.34.tgz",
|
||||
"integrity": "sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==",
|
||||
"version": "4.13.5",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz",
|
||||
"integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -1963,9 +1897,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
@@ -1979,23 +1913,23 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
"lightningcss-android-arm64": "1.33.0",
|
||||
"lightningcss-darwin-arm64": "1.33.0",
|
||||
"lightningcss-darwin-x64": "1.33.0",
|
||||
"lightningcss-freebsd-x64": "1.33.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.33.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.33.0",
|
||||
"lightningcss-linux-arm64-musl": "1.33.0",
|
||||
"lightningcss-linux-x64-gnu": "1.33.0",
|
||||
"lightningcss-linux-x64-musl": "1.33.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.33.0",
|
||||
"lightningcss-win32-x64-msvc": "1.33.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2014,9 +1948,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2035,9 +1969,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2056,9 +1990,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2077,9 +2011,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
|
||||
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2098,9 +2032,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2122,9 +2056,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2146,9 +2080,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2170,9 +2104,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2194,9 +2128,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2215,9 +2149,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2264,9 +2198,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2318,9 +2252,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2331,9 +2265,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.25",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2351,7 +2285,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.16",
|
||||
"nanoid": "^3.3.18",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -2360,13 +2294,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz",
|
||||
"integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.139.0",
|
||||
"@oxc-project/types": "=0.149.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -2376,21 +2310,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-x64": "1.1.5",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.5",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.5",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.5",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.5",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.5",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.5"
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.8",
|
||||
"@rolldown/binding-android-arm64": "1.2.8",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.8",
|
||||
"@rolldown/binding-darwin-x64": "1.2.8",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.8",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.8",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.8",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.8",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.8",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.8",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.8",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.8",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.8",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.8",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
@@ -2582,16 +2516,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
|
||||
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.17",
|
||||
"rolldown": "~1.1.5",
|
||||
"postcss": "^8.5.26",
|
||||
"rolldown": "~1.2.4",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -2608,7 +2542,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.3.0",
|
||||
"@vitejs/devtools": "^0.4.0 || ^0.5.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
@@ -2660,19 +2594,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
|
||||
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
|
||||
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.10",
|
||||
"@vitest/mocker": "4.1.10",
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/runner": "4.1.10",
|
||||
"@vitest/snapshot": "4.1.10",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"@vitest/expect": "4.1.11",
|
||||
"@vitest/mocker": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/runner": "4.1.11",
|
||||
"@vitest/snapshot": "4.1.11",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -2700,12 +2634,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.10",
|
||||
"@vitest/browser-preview": "4.1.10",
|
||||
"@vitest/browser-webdriverio": "4.1.10",
|
||||
"@vitest/coverage-istanbul": "4.1.10",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"@vitest/ui": "4.1.10",
|
||||
"@vitest/browser-playwright": "4.1.11",
|
||||
"@vitest/browser-preview": "4.1.11",
|
||||
"@vitest/browser-webdriverio": "4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.11",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@vitest/ui": "4.1.11",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
||||
"typescript": "^5.5.2",
|
||||
"vitest": "~4.1.10",
|
||||
"vitest": "~4.1.11",
|
||||
"wrangler": "^4.124.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.12.34"
|
||||
"hono": "^4.13.5"
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/spf13/viper"
|
||||
"lemon-ipw/ssrf"
|
||||
"lemon-ipw/webtest"
|
||||
@@ -101,7 +101,8 @@ func protectedKeysIn(cfg map[string]any) []string {
|
||||
|
||||
// configRestartKeys 改了内存变量但需重启进程才完全生效的键:
|
||||
//
|
||||
// - port / trusted-proxies / cors:监听地址与 gin 中间件在启动时构建并固定
|
||||
// - port / trusted-proxies / cors:监听地址与 fiber 应用配置(buildFiberConfig,含 CORS
|
||||
// 中间件)都在启动时构建并固定
|
||||
// - ipdb:ipdb.Init 与 location / asn 路由注册都按启动时的值判定
|
||||
// - report-interval-seconds:上报 ticker 在启动时按该值创建
|
||||
// - node-id / node-key:WS 注册身份在启动时确立;运行中改动只改内存变量,
|
||||
@@ -112,7 +113,8 @@ func protectedKeysIn(cfg map[string]any) []string {
|
||||
// (ws.go 的 reconcileWSClient)按新地址多退少补,连接集合随配置变化,无需重启。
|
||||
//
|
||||
// 不在其中的键即为热生效:`dns-server` / `dnssec-server` / `block-private-ips` /
|
||||
// `single-stack` / `node-ota` 每次消费时重新读变量。
|
||||
// `single-stack` / `node-ota` / `access-log` 每次消费时重新读变量
|
||||
// (`access-log` 的中间件虽在启动时挂载,但开关判定在请求路径上,因此同样热生效)。
|
||||
//
|
||||
// 判定口径:只有**值真的变了**才算需重启(见 applyConfigMap)——远端/托管配置每次都带全部键,
|
||||
// 若按"键出现过"判定,每次 refresh 都会把节点误报成"需重启"。
|
||||
@@ -183,12 +185,23 @@ func applyConfigMap(cfg map[string]any, ignore []string) (applied, unknown, rest
|
||||
webtest.SetDNSSecServer(v)
|
||||
case "ipdb":
|
||||
// 仅改变量不够:ipdb.Init 与 location/asn 路由注册都在启动时按该值判定,运行中不重建
|
||||
IPDB = v
|
||||
enabled, known := parseBoolSwitch(v)
|
||||
if !known {
|
||||
unknown = append(unknown, key)
|
||||
continue
|
||||
}
|
||||
IPDB_ENABLED = enabled
|
||||
case "cors":
|
||||
CORS = v
|
||||
ACCEPT_DOMAINS = splitAndTrim(CORS, ",")
|
||||
case "block-private-ips":
|
||||
ssrf.SetEnabled(v != "false" && v != "0")
|
||||
// 与启动口径共用 parseBoolSwitch(main.go),避免"启动认 false、热更新认 FALSE"这类漂移
|
||||
enabled, known := parseBoolSwitch(v)
|
||||
if !known {
|
||||
unknown = append(unknown, key) // 值非法:不覆盖原值,明确报给调用方
|
||||
continue
|
||||
}
|
||||
ssrf.SetEnabled(enabled)
|
||||
case "trusted-proxies":
|
||||
TRUSTED_PROXIES = v
|
||||
case "remote-config-url":
|
||||
@@ -229,12 +242,40 @@ func applyConfigMap(cfg map[string]any, ignore []string) (applied, unknown, rest
|
||||
REPORT_INTERVAL = n
|
||||
case "node-ota":
|
||||
// 热生效:下发 OTA 指令时才读 NODE_OTA(见 ota.go),故不在 configRestartKeys
|
||||
enabled, known := parseOTASwitch(v)
|
||||
enabled, known := parseBoolSwitch(v)
|
||||
if !known {
|
||||
unknown = append(unknown, key) // 值非法:不覆盖原值,明确报给调用方
|
||||
continue
|
||||
}
|
||||
NODE_OTA = enabled
|
||||
case "access-log":
|
||||
// 热生效:中间件在请求路径上实时读 ACCESS_LOG(见 accessLogMiddleware),
|
||||
// 中间件本身在启动时就已挂好,所以这里改完即生效、无需重启。
|
||||
enabled, known := parseBoolSwitch(v)
|
||||
if !known {
|
||||
unknown = append(unknown, key) // 值非法:不覆盖原值,明确报给调用方
|
||||
continue
|
||||
}
|
||||
ACCESS_LOG = enabled
|
||||
case "max-response-body":
|
||||
// 热生效:改完即应用到出站客户端(applyResponseBodyLimit 内部有锁)
|
||||
n, ok := parseByteSize(v)
|
||||
if !ok {
|
||||
unknown = append(unknown, key) // 值非法:不覆盖原值,明确报给调用方
|
||||
continue
|
||||
}
|
||||
MAX_RESPONSE_BODY = n
|
||||
applyResponseBodyLimit()
|
||||
case "memory-limit":
|
||||
// 热生效:debug.SetMemoryLimit 支持运行时调整。
|
||||
// 0 表示"回到自动探测 cgroup",与启动口径一致(见 resources.go)。
|
||||
n, ok := parseByteSize(v)
|
||||
if !ok {
|
||||
unknown = append(unknown, key)
|
||||
continue
|
||||
}
|
||||
MEMORY_LIMIT = n
|
||||
applyMemoryLimit()
|
||||
default:
|
||||
unknown = append(unknown, key)
|
||||
continue
|
||||
@@ -275,7 +316,7 @@ func restartKeyValue(key string) string {
|
||||
case "cors":
|
||||
return CORS
|
||||
case "ipdb":
|
||||
return IPDB
|
||||
return strconv.FormatBool(IPDB_ENABLED)
|
||||
case "trusted-proxies":
|
||||
return TRUSTED_PROXIES
|
||||
case "ws-url":
|
||||
@@ -300,7 +341,7 @@ func configSnapshot() map[string]any {
|
||||
"single-stack": SINGLE_STACK,
|
||||
"dns-server": DNS_SERVER,
|
||||
"dnssec-server": DNSSEC_DNS_SERVER,
|
||||
"ipdb": IPDB,
|
||||
"ipdb": IPDB_ENABLED,
|
||||
"cors": CORS,
|
||||
"block-private-ips": ssrf.Enabled(),
|
||||
"trusted-proxies": TRUSTED_PROXIES,
|
||||
@@ -309,6 +350,9 @@ func configSnapshot() map[string]any {
|
||||
"ws-url": WS_URL,
|
||||
"node-id": WS_NODE_ID,
|
||||
"node-ota": NODE_OTA,
|
||||
"access-log": ACCESS_LOG,
|
||||
"max-response-body": MAX_RESPONSE_BODY,
|
||||
"memory-limit": MEMORY_LIMIT,
|
||||
"report-url": REPORT_URL,
|
||||
"report-interval-seconds": REPORT_INTERVAL,
|
||||
}
|
||||
@@ -388,28 +432,28 @@ func refreshRemoteConfig() (map[string]any, error) {
|
||||
// access-token 未配置时**关闭整个 HTTP 管理面**:此时节点业务接口本身也无鉴权,
|
||||
// 再暴露配置读写等于公网任何人可改节点配置。WS 通道不受影响——它由节点主动外连中间件,
|
||||
// 注册需通过中间件的 ws-keys 校验,指令只发给已注册连接,天然有鉴权。
|
||||
func registerConfigRoutes(r *gin.Engine) {
|
||||
func registerConfigRoutes(r *fiber.App) {
|
||||
g := r.Group("/v1/config")
|
||||
if ACCESS_TOKEN == "" {
|
||||
slog.Warn("config HTTP API disabled: access-token not set, use WS channel instead")
|
||||
disabled := func(c *gin.Context) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
disabled := func(c fiber.Ctx) error {
|
||||
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "配置管理接口已关闭:节点未配置 access-token,请通过 WS 通道操作",
|
||||
})
|
||||
}
|
||||
g.GET("", disabled)
|
||||
g.PATCH("", disabled)
|
||||
g.POST("/refresh", disabled)
|
||||
g.Get("", disabled)
|
||||
g.Patch("", disabled)
|
||||
g.Post("/refresh", disabled)
|
||||
return
|
||||
}
|
||||
g.Use(tokenCheck())
|
||||
g.GET("", configGetHandler)
|
||||
g.PATCH("", configPatchHandler)
|
||||
g.POST("/refresh", configRefreshHandler)
|
||||
g.Get("", configGetHandler)
|
||||
g.Patch("", configPatchHandler)
|
||||
g.Post("/refresh", configRefreshHandler)
|
||||
}
|
||||
|
||||
func configGetHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
func configGetHandler(c fiber.Ctx) error {
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"config": configSnapshot(),
|
||||
"secretKeys": sortedKeysBool(configSecretKeys),
|
||||
"restartRequiredKeys": sortedKeysBool(configRestartKeys),
|
||||
@@ -417,15 +461,13 @@ func configGetHandler(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func configPatchHandler(c *gin.Context) {
|
||||
func configPatchHandler(c fiber.Ctx) error {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体须为 JSON 对象:" + err.Error()})
|
||||
return
|
||||
if err := c.Bind().JSON(&body); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "请求体须为 JSON 对象:" + err.Error()})
|
||||
}
|
||||
if len(body) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体为空"})
|
||||
return
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "请求体为空"})
|
||||
}
|
||||
// 本地显式 PATCH 不受 remote-ignore-config 限制:那是"防远端覆盖"的白名单
|
||||
applied, unknown, restart := applyConfigMap(body, nil)
|
||||
@@ -433,17 +475,16 @@ func configPatchHandler(c *gin.Context) {
|
||||
persisted := false
|
||||
if p := c.Query("persist"); p == "1" || strings.EqualFold(p, "true") {
|
||||
if err := persistConfig(body, applied); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "写回 setting.json 失败:" + err.Error(),
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "写回 setting.json 失败:" + err.Error(),
|
||||
"applied": applied,
|
||||
})
|
||||
return
|
||||
}
|
||||
persisted = true
|
||||
}
|
||||
// 未要求持久化时只改内存:需重启的键不落盘,进程重启后会回到 setting.json / 远端配置的值。
|
||||
// 这是刻意的语义——PATCH 是"当前进程内的即时改动",要不要固化由调用方用 persist 明确决定。
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"applied": applied,
|
||||
"unknown": unknown,
|
||||
"restartRequired": restart,
|
||||
@@ -452,17 +493,15 @@ func configPatchHandler(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func configRefreshHandler(c *gin.Context) {
|
||||
func configRefreshHandler(c fiber.Ctx) error {
|
||||
res, err := refreshRemoteConfig()
|
||||
if err == errNoRemoteURL {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "remote-config-url 未配置,无法刷新"})
|
||||
return
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "remote-config-url 未配置,无法刷新"})
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "拉取远端配置失败:" + err.Error(), "source": REMOTE_CONFIG_URL})
|
||||
return
|
||||
return c.Status(http.StatusBadGateway).JSON(fiber.Map{"error": "拉取远端配置失败:" + err.Error(), "source": REMOTE_CONFIG_URL})
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
return c.Status(http.StatusOK).JSON(res)
|
||||
}
|
||||
|
||||
// sortedKeysBool 取 map 键的升序列表(用于向调用方说明哪些键是凭据/需重启)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func contains(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// literalString 是两条读取路径共用的「字面量 → 字符串」表,"布尔与字符串等价"就靠它。
|
||||
func TestLiteralString(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{"nil(未配置 / null)", nil, ""},
|
||||
{"bool false", false, "false"},
|
||||
{"bool true", true, "true"},
|
||||
{"字符串去首尾空白", " no \n", "no"},
|
||||
{"JSON 数字(float64)", float64(443), "443"},
|
||||
{"int 0", 0, "0"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := literalString(c.in); got != c.want {
|
||||
t.Errorf("%s: literalString(%#v) = %q, want %q", c.name, c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseBoolSwitch 是四个开关唯一的字面值口径。
|
||||
func TestParseBoolSwitch(t *testing.T) {
|
||||
for _, s := range []string{"true", "TRUE", " True ", "1", "yes", "ON", "enable", "enabled"} {
|
||||
if v, ok := parseBoolSwitch(s); !ok || !v {
|
||||
t.Errorf("parseBoolSwitch(%q) = (%v, %v), want (true, true)", s, v, ok)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"false", "FALSE", " False ", "0", "no", "off", "disable", "disabled"} {
|
||||
if v, ok := parseBoolSwitch(s); !ok || v {
|
||||
t.Errorf("parseBoolSwitch(%q) = (%v, %v), want (false, true)", s, v, ok)
|
||||
}
|
||||
}
|
||||
// 认不出来的一律 known=false —— 绝不静默当"开"
|
||||
for _, s := range []string{"", " ", "banana", "tru", "2", "on!", "是", "否"} {
|
||||
if v, ok := parseBoolSwitch(s); ok || v {
|
||||
t.Errorf("parseBoolSwitch(%q) = (%v, %v), want (false, false)", s, v, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 启动路径:setting.json 里把开关写成 JSON 布尔 / 数字,读出来必须与字符串写法等价,
|
||||
// 且不能被 configString 的"空值"判断误吞(false 变成"未配置"就等于静默变回默认的"开")。
|
||||
func TestConfigStringAcceptsJSONBoolAndNumber(t *testing.T) {
|
||||
viper.Reset()
|
||||
defer viper.Reset()
|
||||
viper.SetConfigType("json")
|
||||
if err := viper.ReadConfig(strings.NewReader(`{"ipdb": false, "access-log": 0, "port": 443, "cors": ""}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := configString("ZZ_NO_IPDB", "ipdb", "true"); got != "false" {
|
||||
t.Errorf("configString(ipdb) = %q, want \"false\"", got)
|
||||
}
|
||||
if configBool("ZZ_NO_IPDB", "ipdb", true) {
|
||||
t.Error("configBool(ipdb) = true,JSON 布尔 false 被当成了「开」")
|
||||
}
|
||||
if configBool("ZZ_NO_ACCESS_LOG", "access-log", true) {
|
||||
t.Error("configBool(access-log) = true,数字 0 被当成了「开」")
|
||||
}
|
||||
if got := configString("ZZ_NO_PORT", "port", "8080"); got != "443" {
|
||||
t.Errorf("configString(port) = %q, want \"443\"", got)
|
||||
}
|
||||
if got := configString("ZZ_NO_CORS", "cors", "fallback"); got != "fallback" {
|
||||
t.Errorf("configString(cors) = %q, want \"fallback\"(空串应视为未配置)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 运行路径:远端下发 / PATCH 收到布尔与字符串,行为必须一致;
|
||||
// 非法值计入 unknown 且不覆盖原值。
|
||||
func TestApplyConfigMapAcceptsBoolAndString(t *testing.T) {
|
||||
ipdb0, access0, ota0 := IPDB_ENABLED, ACCESS_LOG, NODE_OTA
|
||||
defer func() { IPDB_ENABLED, ACCESS_LOG, NODE_OTA = ipdb0, access0, ota0 }()
|
||||
|
||||
IPDB_ENABLED = true // 先置"开",才能验证布尔 false 真的把它关掉
|
||||
ACCESS_LOG = false // 反向同理
|
||||
NODE_OTA = true // 非法值不该动它
|
||||
|
||||
applied, unknown, _ := applyConfigMap(map[string]any{
|
||||
"ipdb": false, // JSON 布尔
|
||||
"access-log": "true", // 字符串
|
||||
"node-ota": "banana", // 非法值
|
||||
}, nil)
|
||||
|
||||
if IPDB_ENABLED {
|
||||
t.Error("ipdb:布尔 false 未生效(仍是开)")
|
||||
}
|
||||
if !ACCESS_LOG {
|
||||
t.Error("access-log:字符串 \"true\" 未生效(仍是关)")
|
||||
}
|
||||
if !NODE_OTA {
|
||||
t.Error("node-ota:收到非法值却把原值 true 改掉了")
|
||||
}
|
||||
if !contains(unknown, "node-ota") {
|
||||
t.Errorf("unknown = %v,期望包含 node-ota", unknown)
|
||||
}
|
||||
for _, k := range []string{"ipdb", "access-log"} {
|
||||
if !contains(applied, k) {
|
||||
t.Errorf("applied = %v,期望包含 %s", applied, k)
|
||||
}
|
||||
}
|
||||
|
||||
// 反向对称:布尔 true 必须能打开、字符串 "false" 必须能关闭。
|
||||
// 这里专门覆盖「布尔」和「字符串」两个方向,防止只测了一个方向就以为等价。
|
||||
IPDB_ENABLED = false
|
||||
ACCESS_LOG = true
|
||||
applied, unknown, _ = applyConfigMap(map[string]any{
|
||||
"ipdb": true,
|
||||
"access-log": "false",
|
||||
}, nil)
|
||||
|
||||
if !IPDB_ENABLED {
|
||||
t.Error("ipdb:布尔 true 未生效(仍是关)")
|
||||
}
|
||||
if ACCESS_LOG {
|
||||
t.Error(`access-log:字符串 "false" 未生效(仍是开)`)
|
||||
}
|
||||
if len(unknown) != 0 {
|
||||
t.Errorf("unknown = %v,期望为空", unknown)
|
||||
}
|
||||
for _, k := range []string{"ipdb", "access-log"} {
|
||||
if !contains(applied, k) {
|
||||
t.Errorf("applied = %v,期望包含 %s", applied, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,7 @@ go 1.26.6
|
||||
require (
|
||||
github.com/AdguardTeam/golibs v0.35.16
|
||||
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/gofiber/fiber/v3 v3.4.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
|
||||
@@ -21,46 +20,33 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
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
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.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/likexian/gokit v0.25.16 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // 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/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.1 // 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/subosito/gotenv v1.6.0 // 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
|
||||
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/arch v0.23.0 // indirect
|
||||
golang.org/x/crypto v0.54.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.48.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
)
|
||||
@@ -1,64 +1,42 @@
|
||||
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=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
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/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
|
||||
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
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/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
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=
|
||||
github.com/gofiber/schema v1.8.0/go.mod h1:lmbXPQ8hvzXSLkdS2DS7pb4kpunC2Roh7Sj3HMjGfzA=
|
||||
github.com/gofiber/utils/v2 v2.1.1 h1:kGnoGjwEnFW6w0x45W+kLlmMJvqBGkuUA4oMWKn/T/I=
|
||||
github.com/gofiber/utils/v2 v2.1.1/go.mod h1:DdOgEVwQTi8cou/AKWPqhXOR4fHGRVhA/rEWL3IXG7Q=
|
||||
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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
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=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/likexian/gokit v0.25.16 h1:wwBeUIN/OdoPp6t00xTnZE8Di/+s969Bl5N2Kw6bzP8=
|
||||
github.com/likexian/gokit v0.25.16/go.mod h1:Wqd4f+iifV0qxA1N3MqePJTUsmRy/lpst9/yXriDx/4=
|
||||
github.com/likexian/whois v1.15.7 h1:sajjDhi2bVD71AHJhjV7jLYxN92H4AWhTwxM8hmj7c0=
|
||||
@@ -67,30 +45,26 @@ github.com/likexian/whois-parser v1.24.21 h1:MxsrGRxDOiZIVp7q7N/yAIbKuN4QAkGjCpO
|
||||
github.com/likexian/whois-parser v1.24.21/go.mod h1:o3DUruO65Pb8WXCJCTlSVkTbwuYVrBCeoMTw2q0mxY4=
|
||||
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260417083004-03cc433717a7 h1:OOVHPQGWDqC8tMrwFXd4Y4VrxXEL+y77YD1Rh2dLjc0=
|
||||
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260417083004-03cc433717a7/go.mod h1:sj5LMpsqB4IWdwIrcmmBJM6m+rW/uOQLSGUPhKkqdh8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
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/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.3.0 h1:PnXjMGjkSQlwOBSyZ7hk6Fd75t7erkAhJNJgEhA3MQU=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.3.0/go.mod h1:NSQvgFwPxODpBTJI5+5Ns1AAucnx7ggW9PSRRifAT1s=
|
||||
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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
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.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/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.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
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=
|
||||
@@ -101,31 +75,22 @@ 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/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=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
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/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
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/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
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.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
@@ -134,19 +99,15 @@ 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=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
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=
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// ==================== OTA 升级(收集中心下发,节点执行) ====================
|
||||
@@ -51,22 +51,12 @@ const (
|
||||
// otaAssetBaseDefault 按版本下发且未指定 assetBase 时的默认发布地址
|
||||
const otaAssetBaseDefault = "https://github.com/nomdn/ipw-cn/releases/download"
|
||||
|
||||
// parseOTASwitch 解析 OTA 开关的字面值——启动加载(ENV / setting.json)、远端下发、运行时 PATCH
|
||||
// 三处共用,保证什么算关闭的口径只有一份。
|
||||
// OTA 开关(node-ota)的字面值解析已并入 main.go 的 parseBoolSwitch —— 原先这里有一份
|
||||
// 独立的 parseOTASwitch(唯一认 yes/no 的),与 ipdb / block-private-ips / access-log
|
||||
// 各写一套,同一个字面值在四个开关上判定不一致。现在四条路径共用一张表。
|
||||
//
|
||||
// 语义是缺省允许、显式关闭(见本文件头):true/1/yes/on 与空值都算开启,
|
||||
// false/0/no/off 算关闭。known=false 表示写法认不出来——运行时 PATCH 据此回
|
||||
// unknown 且不覆盖原值,启动期与远端下发则按缺省(允许)处理,不因一个手滑的
|
||||
// 字面值把节点锁死。
|
||||
func parseOTASwitch(raw string) (enabled, known bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "", "true", "1", "yes", "on":
|
||||
return true, true
|
||||
case "false", "0", "no", "off":
|
||||
return false, true
|
||||
}
|
||||
return true, false
|
||||
}
|
||||
// 语义不变:缺省允许、显式关闭。启动时写法认不出来按缺省(允许)处理并告警,
|
||||
// 运行时 PATCH 则回 unknown 且不覆盖原值。
|
||||
|
||||
// otaMu 单飞:同一时刻只允许一个 OTA 任务(重复下发直接拒绝)
|
||||
var otaMu sync.Mutex
|
||||
@@ -120,35 +110,32 @@ func otaReport(c *websocket.Conn, requestID string, ok bool, stage, errMsg strin
|
||||
|
||||
// registerOTARoute 注册 POST /v1/ota(收集中心 HTTP 回退通道)。
|
||||
// 鉴权与 /v1/config 一致:access-token 未配置时整个 HTTP OTA 管理面关闭(RCE 面不能裸奔)。
|
||||
func registerOTARoute(r *gin.Engine) {
|
||||
func registerOTARoute(r *fiber.App) {
|
||||
g := r.Group("/v1/ota")
|
||||
if ACCESS_TOKEN == "" {
|
||||
slog.Warn("ota HTTP API disabled: access-token not set, use WS channel instead")
|
||||
g.POST("", func(c *gin.Context) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
g.Post("", func(c fiber.Ctx) error {
|
||||
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "OTA 接口已关闭:节点未配置 access-token,请通过 WS 通道下发",
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
g.Use(tokenCheck())
|
||||
g.POST("", func(c *gin.Context) {
|
||||
g.Post("", func(c fiber.Ctx) error {
|
||||
if !NODE_OTA {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "节点已禁用 OTA(node-ota=false)"})
|
||||
return
|
||||
return c.Status(http.StatusForbidden).JSON(fiber.Map{"error": "节点已禁用 OTA(node-ota=false)"})
|
||||
}
|
||||
var req otaRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
|
||||
return
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid payload: " + err.Error()})
|
||||
}
|
||||
if err := otaValidate(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
// 异步执行:下载可能耗时数分钟,HTTP 无进度通道,结果看节点日志与重连后的版本号
|
||||
go otaRun(req, nil)
|
||||
c.JSON(http.StatusAccepted, gin.H{"ok": true, "started": true})
|
||||
return c.Status(http.StatusAccepted).JSON(fiber.Map{"ok": true, "started": true})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -337,6 +324,15 @@ func otaDownloadOne(url, dst, wantSHA string) error {
|
||||
return fmt.Errorf("sha256 mismatch: got %s", got)
|
||||
}
|
||||
}
|
||||
// 下载文件默认不带执行位(os.Create 跟随 umask,通常 0644),而 preflightBinary
|
||||
// 在 replaceBinary 的 chmod 之前就要试运行它;Linux 下不显式加 +x 会 EACCES(Permission denied)。
|
||||
// 故此处下载落盘后即补执行位,确保 preflight 与最终 replace 都能 exec。
|
||||
if runtime.GOOS != "windows" {
|
||||
if err := os.Chmod(dst, 0o755); err != nil {
|
||||
_ = os.Remove(dst)
|
||||
return fmt.Errorf("chmod downloaded binary: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// ==================== 数据上报(节点 → 收集中心 ipw-boce) ====================
|
||||
@@ -63,7 +63,9 @@ type nodeProbeRec struct {
|
||||
// dnssec 属功能性校验(结果是否签名/可信),不逐条进明细,只进统计聚合。
|
||||
func nodeIsProbeType(apiType string) bool {
|
||||
switch apiType {
|
||||
case "tcping", "speed", "detail", "ssl", "dns":
|
||||
// 与中心 store.go 的 isProbeType 白名单对齐:location 归属地查询也逐条进明细,
|
||||
// 否则节点只上报 location 统计、不上报明细,导致中心大盘有 Location 统计而明细为空。
|
||||
case "tcping", "speed", "detail", "ssl", "dns", "location":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -81,35 +83,8 @@ func startNodeReporter() {
|
||||
slog.Info("node reporter enabled", "ws", WS_URL != "", "httpFallback", REPORT_URL, "interval", REPORT_INTERVAL)
|
||||
}
|
||||
|
||||
// nodeReportMiddleware /v1 组统计中间件:归属规则过滤 → 计数 → 拨测类补明细
|
||||
func nodeReportMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
|
||||
// 收集中心(ipw-boce)主动调度下发的拨测带 X-Scheduler-Probe 头:
|
||||
// 该次执行由收集中心侧本地落库(source=sched/biz),本节点跳过计数与明细上报,避免双算。
|
||||
// 真实业务请求(用户/中间件转发)不带此头,仍按"节点是唯一记录者"正常上报。
|
||||
if c.Request.Header.Get("X-Scheduler-Probe") != "" {
|
||||
return
|
||||
}
|
||||
|
||||
apiType, raw := nodeAPIFromPath(c)
|
||||
if apiType == "" {
|
||||
return
|
||||
}
|
||||
latency := time.Since(start)
|
||||
status := c.Writer.Status()
|
||||
nodeRecordAPI(apiType, status, latency)
|
||||
if nodeIsProbeType(apiType) {
|
||||
nodeRecordProbe(nodeProbeRec{
|
||||
NodeID: nodeReportNodeID(), APIType: apiType, Raw: raw,
|
||||
Query: c.Request.URL.RawQuery, Status: status, LatencyMs: latency.Milliseconds(),
|
||||
Source: "http", CreatedAt: time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// nodeReportMiddleware 已移至 main.go(与 tokenCheck 放在一起,见该文件「鉴权 / 上报中间件」段)。
|
||||
// 这里保留的只有上报的数据面:计数、拨测明细缓冲与周期上报。
|
||||
|
||||
// nodeRecordWSProbe WS 拨测出口(ws.go wsHandleProbe 调用)。
|
||||
// 中间件已不在转发路径上计数,WS 下发的拨测同样由本节点上报(唯一记录者);
|
||||
@@ -183,9 +158,18 @@ func nodeReportNodeID() string {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// nodeAPIFromPath 从 gin 路由模板提取 apiType 与拨测目标(对齐 WS probe 的 raw 格式)
|
||||
func nodeAPIFromPath(c *gin.Context) (apiType, raw string) {
|
||||
path := c.FullPath() // 形如 /v1/tcping/:ip
|
||||
// nodeAPIFromPath 从 fiber 路由模板提取 apiType 与拨测目标(对齐 WS probe 的 raw 格式)。
|
||||
//
|
||||
// 路由模板经 c.Route().Path 取(形如 /v1/tcping/:ip)。fiber 的通配符不支持命名,
|
||||
// 统一是 `*`,取值走 c.Params("*")。两个 fiber 特有的坑:
|
||||
//
|
||||
// 1. c.Params / c.Route 返回的是**零拷贝字符串**(直接指向请求缓冲)。handler 执行期间
|
||||
// 这份缓冲会被复用,所以调用方必须在 c.Next() 之前取值,这里再 Clone 一道双保险。
|
||||
// 2. 通配符值**不带**前导斜杠(gin 的 `*url` 带)。原来靠 catch-all 自带 `/` 拼出
|
||||
// "a/example.com"、"v4/example.com" 的 raw 格式,这里显式补回斜杠;detail / ssl
|
||||
// 侧原本就 TrimPrefix("/"),fiber 下该操作变成无操作,结果一致。
|
||||
func nodeAPIFromPath(c fiber.Ctx) (apiType, raw string) {
|
||||
path := c.Route().Path
|
||||
if !strings.HasPrefix(path, "/v1/") {
|
||||
return "", ""
|
||||
}
|
||||
@@ -196,20 +180,21 @@ func nodeAPIFromPath(c *gin.Context) (apiType, raw string) {
|
||||
apiType = seg[0]
|
||||
switch apiType {
|
||||
case "tcping", "dnssec", "whois":
|
||||
raw = c.Param("ip")
|
||||
raw = c.Params("ip")
|
||||
if raw == "" {
|
||||
raw = c.Param("domain")
|
||||
raw = c.Params("domain")
|
||||
}
|
||||
case "speed":
|
||||
raw = c.Param("version") + c.Param("url") // v4/example.com(对齐 WS probe raw 格式)
|
||||
raw = c.Params("version") + "/" + c.Params("*") // v4/example.com(对齐 WS probe raw 格式)
|
||||
case "dns":
|
||||
raw = c.Param("type") + c.Param("domain") // a/example.com
|
||||
raw = c.Params("type") + "/" + c.Params("*") // a/example.com
|
||||
case "detail", "ssl":
|
||||
raw = strings.TrimPrefix(c.Param("url"), "/")
|
||||
raw = strings.TrimPrefix(c.Params("*"), "/")
|
||||
case "location", "asn":
|
||||
raw = c.Param("ip")
|
||||
raw = c.Params("ip")
|
||||
}
|
||||
return apiType, raw
|
||||
// Clone:切断对请求缓冲的引用(见上方说明 1)
|
||||
return strings.Clone(apiType), strings.Clone(raw)
|
||||
}
|
||||
|
||||
// nodeReportLoop 周期上报:WS 在线走 WS 广播(所有已注册中间件),否则 HTTP POST 收集中心
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"resty.dev/v3"
|
||||
)
|
||||
|
||||
// 本文件集中放"资源上限"的处理:单次拨测的响应体上限、Go 运行时的软内存上限。
|
||||
// 起因:1c1g 节点被单个请求打爆(响应体整块进堆,见 webtest.readBodySize 注释)。
|
||||
//
|
||||
// **本文件不读配置源**(不碰 os.Getenv / viper):配置的读取口径统一在 main.go 的 readConfig,
|
||||
// 这里只负责"拿到值之后怎么用" —— 字节后缀解析(parseByteSize)与应用到运行时(apply*)。
|
||||
|
||||
// defaultMaxResponseBody 单次拨测响应体的默认上限(32 MiB,解压后)。
|
||||
//
|
||||
// 取 32MB 的理由:detail/speed/ssl 都只需要一个字节数,正常网页远小于此值;
|
||||
// 而节点 RAM 只有 GB 级,没有上限时一个几百 MB 的响应体就能吃掉全部内存。
|
||||
// 超过上限不是截断上报,而是直接返回错误 —— 报一个"截断后的大小/速度"比报错更误导人。
|
||||
const defaultMaxResponseBody int64 = 32 << 20
|
||||
|
||||
// zstdMaxWindow zstd 解码窗口上限(8 MiB)。
|
||||
//
|
||||
// klauspost/compress 默认允许 512MB 窗口、64GiB 解码量,默认并发 4。窗口是在解析帧头时
|
||||
// 就按流里声明的值分配的,早于任何输出,所以 max-response-body 拦不住它 ——
|
||||
// 一条声明超大窗口的恶意 zstd 流即可让节点瞬间吃掉几百 MB。
|
||||
// HTTP 场景下正常服务端的窗口远小于此值(RFC 8878 同样建议流式场景限制窗口)。
|
||||
const zstdMaxWindow = 8 << 20
|
||||
|
||||
// parseByteSize 解析字节数:支持裸数字与 K/M/G 后缀(KB/MB/GB、KiB/MiB/GiB 亦可),大小写不敏感。
|
||||
// 例:"33554432"、"32MB"、"8MiB"、"900M"。
|
||||
func parseByteSize(s string) (int64, bool) {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
s = strings.TrimSuffix(s, "B") // 32MB -> 32M
|
||||
s = strings.TrimSuffix(s, "I") // 32Mi -> 32M
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
mult := int64(1)
|
||||
switch s[len(s)-1] {
|
||||
case 'K':
|
||||
mult, s = 1<<10, s[:len(s)-1]
|
||||
case 'M':
|
||||
mult, s = 1<<20, s[:len(s)-1]
|
||||
case 'G':
|
||||
mult, s = 1<<30, s[:len(s)-1]
|
||||
}
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n * mult, true
|
||||
}
|
||||
|
||||
// applyResponseBodyLimit 把当前 MAX_RESPONSE_BODY 应用到出站客户端(启动与配置热更新都走这里)。
|
||||
// SetResponseBodyLimit 内部有锁,可与在途请求并发调用。
|
||||
func applyResponseBodyLimit() {
|
||||
for _, c := range []*resty.Client{V4Client, V6Client} {
|
||||
if c != nil {
|
||||
c.SetResponseBodyLimit(MAX_RESPONSE_BODY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyMemoryLimit 设置 Go 运行时的软内存上限(等价于 GOMEMLIMIT,可用 debug.SetMemoryLimit 热改)。
|
||||
//
|
||||
// 取值口径:读全局 MEMORY_LIMIT(启动时由 resolveByteLimit 填好,配置热更新时由
|
||||
// applyConfigMap 直接赋值)。**不要在这里再解析一次配置源** —— 那样配置热更新刚写进
|
||||
// MEMORY_LIMIT 的值会被重新解析覆盖成旧值,PATCH 静默失效。
|
||||
//
|
||||
// MEMORY_LIMIT<=0 时回落到自动探测容器限额的 90%;仍探测不到就不设置(保持 Go 默认)。
|
||||
// 目的:不设时 Go 只按 GOGC 比例回收,够不到"突增";小内存机器上堆会一路涨到被内核 OOM kill,
|
||||
// 中间没有减速带。设了软上限后,逼近限额时 GC 会提前加密。
|
||||
// 注意软上限不是硬闸:必要时运行时仍会越过它,只是付出更多 GC 代价。
|
||||
func applyMemoryLimit() {
|
||||
limit := MEMORY_LIMIT
|
||||
if limit <= 0 {
|
||||
if cg := detectCgroupMemoryLimit(); cg > 0 {
|
||||
limit = cg / 10 * 9
|
||||
}
|
||||
}
|
||||
if limit <= 0 {
|
||||
debug.SetMemoryLimit(math.MaxInt64)
|
||||
slog.Info("No memory limit configured or detected, runtime keeps default GC behavior")
|
||||
return
|
||||
}
|
||||
debug.SetMemoryLimit(limit)
|
||||
slog.Info("Runtime soft memory limit applied", "bytes", limit, "mb", limit>>20)
|
||||
}
|
||||
|
||||
// detectCgroupMemoryLimit 读容器内存限额(字节)。不在容器里 / 未限额 / 读不到时返回 0。
|
||||
// cgroup v2: /sys/fs/cgroup/memory.max(无限时值为 "max")
|
||||
// cgroup v1: /sys/fs/cgroup/memory/memory.limit_in_bytes(无限时是接近 int64 上限的大数)
|
||||
func detectCgroupMemoryLimit() int64 {
|
||||
for _, p := range []string{
|
||||
"/sys/fs/cgroup/memory.max",
|
||||
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
|
||||
} {
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if s == "" || s == "max" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
continue
|
||||
}
|
||||
if n >= 1<<62 { // "无限制"的常见写法
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -4,18 +4,10 @@
|
||||
"single-stack": "",
|
||||
"dns-server": "",
|
||||
"dnssec-server": "",
|
||||
"block-private-ips": true,
|
||||
"ipdb": "false",
|
||||
"ipdb": false,
|
||||
"cors": "",
|
||||
"trusted-proxies": "",
|
||||
"remote-config-url": "",
|
||||
"remote-ignore-config": [],
|
||||
"access-token": "",
|
||||
"ws-url": "",
|
||||
"node-id": "",
|
||||
"node-key": "",
|
||||
"node-ota": "true",
|
||||
"report-url": "",
|
||||
"report-token": "",
|
||||
"report-interval-seconds": 15
|
||||
}
|
||||
"node-key": ""
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
package webtest
|
||||
|
||||
import "resty.dev/v3"
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"resty.dev/v3"
|
||||
)
|
||||
|
||||
// 出站 HTTP 客户端(定义与初始化仍在 main.go,启动时经 SetHTTPClient 注入)
|
||||
var v4Client, v6Client *resty.Client
|
||||
@@ -18,3 +25,68 @@ func httpClient(version string) *resty.Client {
|
||||
}
|
||||
return v4Client
|
||||
}
|
||||
|
||||
// newProbeRequest 构造拨测请求:开 trace(要 DNS/TCP/TLS 各段计时)、绑 context,
|
||||
// 并关掉 resty 的自动解析(SetDoNotParseResponse)—— 这一条是关键,见 readBodySize。
|
||||
//
|
||||
// 必须走这个构造函数,不要在调用点手写请求链:漏掉 DoNotParseResponse 时
|
||||
// resty 的 AutoParseResponseMiddleware 会替我们把整个响应体读进 bodyBytes
|
||||
// (Content-Type 不是 JSON/XML 时它就 readAll),响应体上限和流式计数全部失效。
|
||||
func newProbeRequest(version string, ctx context.Context) *resty.Request {
|
||||
return httpClient(version).R().
|
||||
EnableTrace().
|
||||
SetContext(ctx).
|
||||
SetDoNotParseResponse(true)
|
||||
}
|
||||
|
||||
// readBodySize 流式读完响应体,返回**解压后**的字节数。
|
||||
//
|
||||
// 不用 resp.Bytes():它只是把 resty 已经缓存的 bodyBytes 返回给调用方,而缓存动作
|
||||
// 发生在 Get() 内部 —— resty 的 AutoParseResponseMiddleware 对没注册解码器的
|
||||
// Content-Type(text/html、text/plain、octet-stream……差不多就是全部被测站点)
|
||||
// 会直接 res.readAll(),等价于 io.ReadAll,整个响应体留在堆上。
|
||||
// detail / speed / ssl 要的只是一个计数(page_size、download_speed),而响应体大小
|
||||
// 完全由被测站点决定:一个压缩炸弹(Content-Encoding: gzip/zstd)能把几百 KB 的响应
|
||||
// 放大到几百 MB,1c1g 节点单次请求即可 OOM。
|
||||
// 配合 newProbeRequest 的 DoNotParseResponse,这里边读边丢,内存恒定,
|
||||
// 与响应体大小无关。
|
||||
//
|
||||
// 兜底上限由客户端侧的 SetResponseBodyLimit(配置项 max-response-body)提供:
|
||||
// 超限时 resty 的 limitReadCloser 会返回 ErrReadExceedsThresholdLimit,
|
||||
// 这里转成可读的错误,而不是悄悄把截断的字节数当成页面大小报出去。
|
||||
func readBodySize(resp *resty.Response) (int64, error) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return 0, nil
|
||||
}
|
||||
n, err := io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
if errors.Is(err, resty.ErrReadExceedsThresholdLimit) {
|
||||
return n, fmt.Errorf("response body exceeds the limit of %d bytes (max-response-body)", responseBodyLimit(resp))
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// responseBodyLimit 取本次请求实际生效的响应体上限(<=0 表示不限制)
|
||||
func responseBodyLimit(resp *resty.Response) int64 {
|
||||
if resp == nil || resp.Request == nil {
|
||||
return 0
|
||||
}
|
||||
return resp.Request.ResponseBodyLimit
|
||||
}
|
||||
|
||||
// closeResponseBody 尽力释放失败请求的响应体。
|
||||
//
|
||||
// 为什么需要:resty 除了传输错误,还会在**响应体解压失败**时提前返回
|
||||
// (例如目标回了未注册的 Content-Encoding,client.go 里 wrapContentDecompresser 直接返回
|
||||
// ErrContentDecompresserNotFound)。这时 Response.Body 已经挂上原始响应体但没有任何人关闭,
|
||||
// 而我们随后就走 `if err != nil { return nil, err }` 把 Response 丢掉 ——
|
||||
// 每个这样的请求漏一个连接/FD,且因为 DisableKeepAlives,要等 GC 终结器才回收。
|
||||
// 目标站点只要固定回一个没注册的 Content-Encoding,就能一点点耗掉节点的 FD。
|
||||
func closeResponseBody(resp *resty.Response) {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -23,14 +26,14 @@ var (
|
||||
// SetDNSServer 设置DNS服务器地址(逗号分隔多地址:第一个为主,主失败自动切换后续从服务器)。
|
||||
// 每项支持 "ip:port"(UDP)或 DoH URL http(s)://...
|
||||
func SetDNSServer(server string) {
|
||||
if list := splitServers(server); len(list) > 0 {
|
||||
if list := validServers(splitServers(server)); len(list) > 0 {
|
||||
dnsServers = list
|
||||
}
|
||||
}
|
||||
|
||||
// SetDNSSecServer 设置DNSSEC专用DNS服务器(逗号分隔主从;留空 = 沿用 dns-server 配置)
|
||||
func SetDNSSecServer(server string) {
|
||||
if list := splitServers(server); len(list) > 0 {
|
||||
if list := validServers(splitServers(server)); len(list) > 0 {
|
||||
dnssecServers = list
|
||||
}
|
||||
}
|
||||
@@ -47,6 +50,53 @@ func splitServers(s string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// validServers 过滤掉格式非法的地址(保留原顺序)。
|
||||
//
|
||||
// dns-server / dnssec-server 既能由本地管理面 PATCH 改,也能由远端托管配置下发 —— 都是外部可控输入。
|
||||
// 在这里先挡掉畸形值,总比留到查询时才以"莫名其妙的超时/报错"暴露好排查。
|
||||
// 全部非法时返回空列表,调用方据此保留原有配置(与"空串不覆盖"的既有语义一致)。
|
||||
func validServers(list []string) []string {
|
||||
out := make([]string, 0, len(list))
|
||||
for _, s := range list {
|
||||
if err := validateServerAddr(s); err != nil {
|
||||
slog.Warn("ignored invalid DNS server address", "server", s, "error", err)
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validateServerAddr 校验单个 DNS 服务器地址的格式:
|
||||
// - DoH URL:必须是 http/https 且 host 非空(**不限制私网** —— 节点自建内网 DoH 是合理部署);
|
||||
// - UDP/TCP:必须是 host:port 且端口合法。
|
||||
//
|
||||
// 注意:UDP 形式**不禁止私有地址** —— 节点上跑本地缓存解析器(127.0.0.1:53、内网 10.x:53)是常见部署,
|
||||
// 它是节点侧基础设施配置、不是被拨测目标,一并堵掉会直接让 DNS 拨测失效。
|
||||
func validateServerAddr(server string) error {
|
||||
if strings.HasPrefix(server, "http://") || strings.HasPrefix(server, "https://") {
|
||||
u, err := url.Parse(server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Hostname() == "" {
|
||||
return fmt.Errorf("missing host")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
host, port, err := net.SplitHostPort(server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(host) == "" {
|
||||
return fmt.Errorf("missing host")
|
||||
}
|
||||
if p, err := strconv.Atoi(port); err != nil || p < 1 || p > 65535 {
|
||||
return fmt.Errorf("invalid port %q", port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
defaultUDPServer = "119.28.28.28:53"
|
||||
defaultDoHEndpoint = "https://doh.pub/dns-query"
|
||||
@@ -104,7 +154,13 @@ func queryUDPMsg(msg *dns.Msg, server string) (*dns.Msg, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// queryDoHMsg 通过 DoH(RFC 8484,POST application/dns-message)向指定端点发送查询
|
||||
// queryDoHMsg 通过 DoH(RFC 8484,POST application/dns-message)向指定端点发送查询。
|
||||
//
|
||||
// 这里**刻意不做私网/回环拦截**(与 website / ssl / speed 那三处不同):
|
||||
// endpoint 来自 dns-server 配置,而节点上自建内网 DoH(127.0.0.1 / 10.x)是合理部署,
|
||||
// 堵私网会直接打死功能;配置又是管理员凭据才能改的,不构成匿名可达攻击面。
|
||||
// 地址**格式**校验已在 SetDNSServer 入口由 validateServerAddr 完成(http/https + host 非空)。
|
||||
// 对应 CodeQL go/request-forgery(#24)按误报处理。
|
||||
func queryDoHMsg(msg *dns.Msg, endpoint string) (*dns.Msg, error) {
|
||||
packedMsg, err := msg.Pack()
|
||||
if err != nil {
|
||||
@@ -138,333 +194,132 @@ func queryDoHMsg(msg *dns.Msg, endpoint string) (*dns.Msg, error) {
|
||||
|
||||
// DNSResult 统一的DNS查询结果格式
|
||||
type DNSResult struct {
|
||||
Domain string `json:"domain"`
|
||||
Record []string `json:"record"`
|
||||
TTL uint32 `json:"ttl"`
|
||||
Duration float64 `json:"duration"`
|
||||
Domain string `json:"domain"`
|
||||
Record []string `json:"record"`
|
||||
TTL uint32 `json:"ttl"`
|
||||
Duration float64 `json:"duration"`
|
||||
Rcode int `json:"rcode"` // 解析器应答的 Rcode(0=Success, 3=NXDOMAIN)
|
||||
RcodeText string `json:"rcode_text"` // Rcode 可读名称,如 NXDOMAIN / SERVFAIL
|
||||
}
|
||||
|
||||
// resolveTyped 通用记录查询:成功收到解析器应答(即便 Rcode 非 Success,如 NXDOMAIN/SERVFAIL)
|
||||
// 即视为一次有效查询,将 Rcode 透出并返回 200;仅在传输/网络层失败(超时、服务器不可达、
|
||||
// DoH 异常)时才返回 error(上层据此返回 500)。避免把合法的负响应误判为节点故障。
|
||||
// extract 从一个 RR 中抽取记录字符串(TXT 可能含多条),返回 (values, ttl, matched)。
|
||||
func resolveTyped(domain, name string, qtype uint16, extract func(dns.RR) ([]string, uint32, bool)) (DNSResult, error) {
|
||||
start := time.Now()
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(dns.Fqdn(name), qtype)
|
||||
result := DNSResult{Domain: domain}
|
||||
|
||||
response, err := queryDNS(msg)
|
||||
result.Duration = time.Since(start).Seconds() * 1000
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query DNS", "domain", domain, "qtype", qtype, "error", err)
|
||||
result.Record = []string{}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// 解析器已应答:无论 Rcode 是否为 Success,都属合法结果,不再当 error 返回
|
||||
result.Rcode = response.Rcode
|
||||
result.RcodeText = dns.RcodeToString[response.Rcode]
|
||||
result.Record = []string{}
|
||||
for _, ans := range response.Answer {
|
||||
if values, ttl, ok := extract(ans); ok {
|
||||
result.Record = append(result.Record, values...)
|
||||
if result.TTL == 0 {
|
||||
result.TTL = ttl
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ResolveARecord(domain string) (DNSResult, error) {
|
||||
start := time.Now()
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(dns.Fqdn(domain), dns.TypeA)
|
||||
result := DNSResult{Domain: domain}
|
||||
|
||||
response, err := queryDNS(msg)
|
||||
duration := time.Since(start).Seconds() * 1000
|
||||
result.Duration = duration
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query DNS", "domain", domain, "error", err)
|
||||
result.Record = []string{}
|
||||
return result, err
|
||||
}
|
||||
|
||||
if response.Rcode != dns.RcodeSuccess {
|
||||
slog.Warn("DNS query failed with Rcode", "rcode", response.Rcode)
|
||||
return result, fmt.Errorf("DNS query failed with Rcode %d", response.Rcode)
|
||||
}
|
||||
|
||||
for _, ans := range response.Answer {
|
||||
if aRecord, ok := ans.(*dns.A); ok {
|
||||
result.Record = append(result.Record, aRecord.A.String())
|
||||
if result.TTL == 0 {
|
||||
result.TTL = aRecord.Header().Ttl
|
||||
}
|
||||
return resolveTyped(domain, domain, dns.TypeA, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if a, ok := rr.(*dns.A); ok {
|
||||
return []string{a.A.String()}, a.Header().Ttl, true
|
||||
}
|
||||
}
|
||||
if result.Record == nil {
|
||||
result.Record = []string{}
|
||||
}
|
||||
return result, nil
|
||||
return nil, 0, false
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveAAAARecord(domain string) (DNSResult, error) {
|
||||
start := time.Now()
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(dns.Fqdn(domain), dns.TypeAAAA)
|
||||
|
||||
result := DNSResult{Domain: domain}
|
||||
|
||||
response, err := queryDNS(msg)
|
||||
duration := time.Since(start).Seconds() * 1000
|
||||
result.Duration = duration
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query DNS", "domain", domain, "error", err)
|
||||
result.Record = []string{}
|
||||
return result, err
|
||||
}
|
||||
|
||||
if response.Rcode != dns.RcodeSuccess {
|
||||
slog.Warn("DNS query failed with Rcode", "rcode", response.Rcode)
|
||||
return result, fmt.Errorf("DNS query failed with Rcode %d", response.Rcode)
|
||||
}
|
||||
|
||||
for _, ans := range response.Answer {
|
||||
if aRecord, ok := ans.(*dns.AAAA); ok {
|
||||
result.Record = append(result.Record, aRecord.AAAA.String())
|
||||
if result.TTL == 0 {
|
||||
result.TTL = aRecord.Header().Ttl
|
||||
}
|
||||
return resolveTyped(domain, domain, dns.TypeAAAA, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if a, ok := rr.(*dns.AAAA); ok {
|
||||
return []string{a.AAAA.String()}, a.Header().Ttl, true
|
||||
}
|
||||
}
|
||||
if result.Record == nil {
|
||||
result.Record = []string{}
|
||||
}
|
||||
return result, nil
|
||||
return nil, 0, false
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveTXTRecord(domain string) (DNSResult, error) {
|
||||
start := time.Now()
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(dns.Fqdn(domain), dns.TypeTXT)
|
||||
|
||||
result := DNSResult{Domain: domain}
|
||||
|
||||
response, err := queryDNS(msg)
|
||||
duration := time.Since(start).Seconds() * 1000
|
||||
result.Duration = duration
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query DNS", "domain", domain, "error", err)
|
||||
result.Record = []string{}
|
||||
return result, err
|
||||
}
|
||||
|
||||
if response.Rcode != dns.RcodeSuccess {
|
||||
slog.Warn("DNS query failed with Rcode", "rcode", response.Rcode)
|
||||
return result, fmt.Errorf("DNS query failed with Rcode %d", response.Rcode)
|
||||
}
|
||||
|
||||
for _, ans := range response.Answer {
|
||||
if aRecord, ok := ans.(*dns.TXT); ok {
|
||||
for _, txt := range aRecord.Txt {
|
||||
result.Record = append(result.Record, txt)
|
||||
}
|
||||
if result.TTL == 0 {
|
||||
result.TTL = aRecord.Header().Ttl
|
||||
}
|
||||
return resolveTyped(domain, domain, dns.TypeTXT, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if txt, ok := rr.(*dns.TXT); ok {
|
||||
// TXT 一条 RR 可能含多段字符串,保持原有「逐段单独成项」的行为
|
||||
return txt.Txt, txt.Header().Ttl, true
|
||||
}
|
||||
}
|
||||
if result.Record == nil {
|
||||
result.Record = []string{}
|
||||
}
|
||||
return result, nil
|
||||
return nil, 0, false
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveNSRecord(domain string) (DNSResult, error) {
|
||||
start := time.Now()
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(dns.Fqdn(domain), dns.TypeNS)
|
||||
|
||||
result := DNSResult{Domain: domain}
|
||||
|
||||
response, err := queryDNS(msg)
|
||||
duration := time.Since(start).Seconds() * 1000
|
||||
result.Duration = duration
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query DNS", "domain", domain, "error", err)
|
||||
result.Record = []string{}
|
||||
return result, err
|
||||
}
|
||||
|
||||
if response.Rcode != dns.RcodeSuccess {
|
||||
slog.Warn("DNS query failed with Rcode", "rcode", response.Rcode)
|
||||
return result, fmt.Errorf("DNS query failed with Rcode %d", response.Rcode)
|
||||
}
|
||||
|
||||
for _, ans := range response.Answer {
|
||||
if aRecord, ok := ans.(*dns.NS); ok {
|
||||
result.Record = append(result.Record, aRecord.Ns)
|
||||
if result.TTL == 0 {
|
||||
result.TTL = aRecord.Header().Ttl
|
||||
}
|
||||
return resolveTyped(domain, domain, dns.TypeNS, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if ns, ok := rr.(*dns.NS); ok {
|
||||
return []string{ns.Ns}, ns.Header().Ttl, true
|
||||
}
|
||||
}
|
||||
if result.Record == nil {
|
||||
result.Record = []string{}
|
||||
}
|
||||
return result, nil
|
||||
return nil, 0, false
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveCNAMERecord(domain string) (DNSResult, error) {
|
||||
start := time.Now()
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(dns.Fqdn(domain), dns.TypeCNAME)
|
||||
|
||||
result := DNSResult{Domain: domain}
|
||||
|
||||
response, err := queryDNS(msg)
|
||||
duration := time.Since(start).Seconds() * 1000
|
||||
result.Duration = duration
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query CNAME", "domain", domain, "error", err)
|
||||
result.Record = []string{}
|
||||
return result, err
|
||||
}
|
||||
|
||||
if response.Rcode != dns.RcodeSuccess {
|
||||
slog.Warn("CNAME query failed with Rcode", "rcode", response.Rcode)
|
||||
return result, fmt.Errorf("CNAME query failed with Rcode %d", response.Rcode)
|
||||
}
|
||||
|
||||
for _, ans := range response.Answer {
|
||||
if cnameRecord, ok := ans.(*dns.CNAME); ok {
|
||||
result.Record = append(result.Record, cnameRecord.Target)
|
||||
if result.TTL == 0 {
|
||||
result.TTL = cnameRecord.Header().Ttl
|
||||
}
|
||||
return resolveTyped(domain, domain, dns.TypeCNAME, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if cname, ok := rr.(*dns.CNAME); ok {
|
||||
return []string{cname.Target}, cname.Header().Ttl, true
|
||||
}
|
||||
}
|
||||
if result.Record == nil {
|
||||
result.Record = []string{}
|
||||
}
|
||||
return result, nil
|
||||
return nil, 0, false
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveMXRecord(domain string) (DNSResult, error) {
|
||||
start := time.Now()
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(dns.Fqdn(domain), dns.TypeMX)
|
||||
|
||||
result := DNSResult{Domain: domain}
|
||||
|
||||
response, err := queryDNS(msg)
|
||||
duration := time.Since(start).Seconds() * 1000
|
||||
result.Duration = duration
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query MX", "domain", domain, "error", err)
|
||||
result.Record = []string{}
|
||||
return result, err
|
||||
}
|
||||
|
||||
if response.Rcode != dns.RcodeSuccess {
|
||||
slog.Warn("MX query failed with Rcode", "rcode", response.Rcode)
|
||||
return result, fmt.Errorf("MX query failed with Rcode %d", response.Rcode)
|
||||
}
|
||||
|
||||
for _, ans := range response.Answer {
|
||||
if mxRecord, ok := ans.(*dns.MX); ok {
|
||||
result.Record = append(result.Record, mxRecord.Mx)
|
||||
if result.TTL == 0 {
|
||||
result.TTL = mxRecord.Header().Ttl
|
||||
}
|
||||
return resolveTyped(domain, domain, dns.TypeMX, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if mx, ok := rr.(*dns.MX); ok {
|
||||
return []string{mx.Mx}, mx.Header().Ttl, true
|
||||
}
|
||||
}
|
||||
if result.Record == nil {
|
||||
result.Record = []string{}
|
||||
}
|
||||
return result, nil
|
||||
return nil, 0, false
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveSRVRecord(domain string) (DNSResult, error) {
|
||||
start := time.Now()
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(dns.Fqdn(domain), dns.TypeSRV)
|
||||
|
||||
result := DNSResult{Domain: domain}
|
||||
|
||||
response, err := queryDNS(msg)
|
||||
duration := time.Since(start).Seconds() * 1000
|
||||
result.Duration = duration
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query SRV", "domain", domain, "error", err)
|
||||
result.Record = []string{}
|
||||
return result, err
|
||||
}
|
||||
|
||||
if response.Rcode != dns.RcodeSuccess {
|
||||
slog.Warn("SRV query failed with Rcode", "rcode", response.Rcode)
|
||||
return result, fmt.Errorf("SRV query failed with Rcode %d", response.Rcode)
|
||||
}
|
||||
|
||||
for _, ans := range response.Answer {
|
||||
if srvRecord, ok := ans.(*dns.SRV); ok {
|
||||
result.Record = append(result.Record, srvRecord.Target)
|
||||
if result.TTL == 0 {
|
||||
result.TTL = srvRecord.Header().Ttl
|
||||
}
|
||||
return resolveTyped(domain, domain, dns.TypeSRV, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if srv, ok := rr.(*dns.SRV); ok {
|
||||
return []string{srv.Target}, srv.Header().Ttl, true
|
||||
}
|
||||
}
|
||||
if result.Record == nil {
|
||||
result.Record = []string{}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ResolvePTRRecord(ip string) (DNSResult, error) {
|
||||
start := time.Now()
|
||||
ptrName, err := dns.ReverseAddr(ip)
|
||||
if err != nil {
|
||||
slog.Warn("Invalid IP address for PTR query", "ip", ip, "error", err)
|
||||
result := DNSResult{Domain: ip, Record: []string{}}
|
||||
return result, fmt.Errorf("invalid IP address: %v", err)
|
||||
}
|
||||
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(ptrName, dns.TypePTR)
|
||||
|
||||
result := DNSResult{Domain: ip}
|
||||
|
||||
response, err := queryDNS(msg)
|
||||
duration := time.Since(start).Seconds() * 1000
|
||||
result.Duration = duration
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query PTR", "ip", ip, "error", err)
|
||||
result.Record = []string{}
|
||||
return result, err
|
||||
}
|
||||
|
||||
if response.Rcode != dns.RcodeSuccess {
|
||||
slog.Warn("PTR query failed with Rcode", "rcode", response.Rcode)
|
||||
return result, fmt.Errorf("PTR query failed with Rcode %d", response.Rcode)
|
||||
}
|
||||
|
||||
for _, ans := range response.Answer {
|
||||
if ptrRecord, ok := ans.(*dns.PTR); ok {
|
||||
result.Record = append(result.Record, ptrRecord.Ptr)
|
||||
if result.TTL == 0 {
|
||||
result.TTL = ptrRecord.Header().Ttl
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.Record == nil {
|
||||
result.Record = []string{}
|
||||
}
|
||||
return result, nil
|
||||
return nil, 0, false
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveCAARecord(domain string) (DNSResult, error) {
|
||||
start := time.Now()
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(dns.Fqdn(domain), dns.TypeCAA)
|
||||
|
||||
result := DNSResult{Domain: domain}
|
||||
|
||||
response, err := queryDNS(msg)
|
||||
duration := time.Since(start).Seconds() * 1000
|
||||
result.Duration = duration
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query CAA", "domain", domain, "error", err)
|
||||
result.Record = []string{}
|
||||
return result, err
|
||||
}
|
||||
|
||||
if response.Rcode != dns.RcodeSuccess {
|
||||
slog.Warn("CAA query failed with Rcode", "rcode", response.Rcode)
|
||||
return result, fmt.Errorf("CAA query failed with Rcode %d", response.Rcode)
|
||||
}
|
||||
|
||||
for _, ans := range response.Answer {
|
||||
if caaRecord, ok := ans.(*dns.CAA); ok {
|
||||
result.Record = append(result.Record, caaRecord.Value)
|
||||
if result.TTL == 0 {
|
||||
result.TTL = caaRecord.Header().Ttl
|
||||
}
|
||||
return resolveTyped(domain, domain, dns.TypeCAA, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if caa, ok := rr.(*dns.CAA); ok {
|
||||
return []string{caa.Value}, caa.Header().Ttl, true
|
||||
}
|
||||
return nil, 0, false
|
||||
})
|
||||
}
|
||||
|
||||
func ResolvePTRRecord(ip string) (DNSResult, error) {
|
||||
ptrName, err := dns.ReverseAddr(ip)
|
||||
if err != nil {
|
||||
slog.Warn("Invalid IP address for PTR query", "ip", ip, "error", err)
|
||||
return DNSResult{Domain: ip, Record: []string{}}, fmt.Errorf("invalid IP address: %v", err)
|
||||
}
|
||||
if result.Record == nil {
|
||||
result.Record = []string{}
|
||||
}
|
||||
return result, nil
|
||||
return resolveTyped(ip, ptrName, dns.TypePTR, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if ptr, ok := rr.(*dns.PTR); ok {
|
||||
return []string{ptr.Ptr}, ptr.Header().Ttl, true
|
||||
}
|
||||
return nil, 0, false
|
||||
})
|
||||
}
|
||||
|
||||
// DNSFullResult 完整的DNS查询结果(不包含PTR,PTR需单独查询)
|
||||
|
||||
@@ -19,22 +19,33 @@ func SpeedTest(url string, version string) (*WebsiteSpeedTestResult, error) {
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
resp, err := httpClient(version).R().EnableTrace().SetContext(ctx).Get(url)
|
||||
resp, err := newProbeRequest(version, ctx).Get(url)
|
||||
|
||||
fallbackToHTTP := false
|
||||
if err != nil && strings.HasPrefix(url, "https://") {
|
||||
closeResponseBody(resp) // 丢弃本次失败的响应,避免连接/FD 悬挂(见 closeResponseBody)
|
||||
httpURL := strings.Replace(url, "https://", "http://", 1)
|
||||
startTime = time.Now()
|
||||
resp, err = httpClient(version).R().EnableTrace().SetContext(ctx).Get(httpURL)
|
||||
resp, err = newProbeRequest(version, ctx).Get(httpURL)
|
||||
fallbackToHTTP = true
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
closeResponseBody(resp)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 流式计数,不把响应体留在堆上(详见 readBodySize)。
|
||||
// 必须放在 endTime 之前:改造前 resty 是在 Get() 内部读完整个响应体的,
|
||||
// 所以 total_time 一直包含响应体下载耗时,download_speed 才是"字节数/总耗时"。
|
||||
// 读体挪到 Get() 之外后若不在此处计入,total_time 会只剩到首字节的时间,
|
||||
// download_speed 会算出几百 GB/s 这种离谱值。
|
||||
bodySize, err := readBodySize(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endTime := time.Now()
|
||||
|
||||
body := resp.Bytes()
|
||||
trace := resp.Request.TraceInfo()
|
||||
|
||||
hostRecord := CleanHostRecord(trace.RemoteAddr)
|
||||
@@ -50,7 +61,7 @@ func SpeedTest(url string, version string) (*WebsiteSpeedTestResult, error) {
|
||||
totalTime := float64(endTime.Sub(startTime).Milliseconds())
|
||||
var downloadSpeed float64
|
||||
if totalTime > 0 {
|
||||
downloadSpeed = float64(len(body)) / 1024.0 / (totalTime / 1000.0)
|
||||
downloadSpeed = float64(bodySize) / 1024.0 / (totalTime / 1000.0)
|
||||
}
|
||||
dumpBytes, _ := httputil.DumpResponse(resp.RawResponse, false)
|
||||
httpStatus := resp.StatusCode()
|
||||
@@ -69,7 +80,7 @@ func SpeedTest(url string, version string) (*WebsiteSpeedTestResult, error) {
|
||||
HTTPConnectTime: httpConnectTime,
|
||||
FirstByteTime: firstByteTime,
|
||||
TotalTime: totalTime,
|
||||
PageSize: int64(len(body)),
|
||||
PageSize: bodySize,
|
||||
DownloadSpeed: downloadSpeed,
|
||||
IsReachable: true,
|
||||
}
|
||||
|
||||
@@ -19,7 +19,16 @@ func CheckSSL(url string, version string) (*SSLCheckDetail, error) {
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
resp, err := httpClient(version).R().EnableTrace().SetContext(ctx).Get(url)
|
||||
resp, err := newProbeRequest(version, ctx).Get(url)
|
||||
if err != nil {
|
||||
closeResponseBody(resp)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 流式计数,不把响应体留在堆上(详见 readBodySize)。
|
||||
// 放在 endTime 之前:改造前 resty 在 Get() 内部就读完了整个响应体,
|
||||
// total_time 一直包含响应体下载耗时,见 speed.go 同处注释。
|
||||
bodySize, err := readBodySize(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -29,10 +38,9 @@ func CheckSSL(url string, version string) (*SSLCheckDetail, error) {
|
||||
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)
|
||||
downloadSpeed = float64(bodySize) / 1024.0 / (totalTime / 1000.0)
|
||||
}
|
||||
|
||||
rawResp := resp.RawResponse
|
||||
|
||||
@@ -18,23 +18,32 @@ func CheckWebsite(url string, version string) (*WebsiteCheckDetail, error) {
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
resp, err := httpClient(version).R().EnableTrace().SetContext(ctx).Get(url)
|
||||
resp, err := newProbeRequest(version, ctx).Get(url)
|
||||
|
||||
// HTTPS 请求失败时 fallback 到 HTTP
|
||||
fallbackToHTTP := false
|
||||
if err != nil && strings.HasPrefix(url, "https://") {
|
||||
closeResponseBody(resp) // 丢弃本次失败的响应,避免连接/FD 悬挂(见 closeResponseBody)
|
||||
httpURL := strings.Replace(url, "https://", "http://", 1)
|
||||
startTime = time.Now()
|
||||
resp, err = httpClient(version).R().EnableTrace().SetContext(ctx).Get(httpURL)
|
||||
resp, err = newProbeRequest(version, ctx).Get(httpURL)
|
||||
fallbackToHTTP = true
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
closeResponseBody(resp)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 流式计数,不把响应体留在堆上(详见 readBodySize)。
|
||||
// 放在 endTime 之前:改造前 resty 在 Get() 内部就读完了整个响应体,
|
||||
// total_time 一直包含响应体下载耗时,见 speed.go 同处注释。
|
||||
bodySize, err := readBodySize(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endTime := time.Now()
|
||||
|
||||
body := resp.Bytes()
|
||||
trace := resp.Request.TraceInfo()
|
||||
|
||||
hostRecord := CleanHostRecord(trace.RemoteAddr)
|
||||
@@ -50,7 +59,7 @@ func CheckWebsite(url string, version string) (*WebsiteCheckDetail, error) {
|
||||
totalTime := float64(endTime.Sub(startTime).Milliseconds())
|
||||
var downloadSpeed float64
|
||||
if totalTime > 0 {
|
||||
downloadSpeed = float64(len(body)) / 1024.0 / (totalTime / 1000.0)
|
||||
downloadSpeed = float64(bodySize) / 1024.0 / (totalTime / 1000.0)
|
||||
}
|
||||
|
||||
httpStatus := resp.StatusCode()
|
||||
@@ -68,7 +77,7 @@ func CheckWebsite(url string, version string) (*WebsiteCheckDetail, error) {
|
||||
HTTPConnectTime: httpConnectTime,
|
||||
FirstByteTime: firstByteTime,
|
||||
TotalTime: totalTime,
|
||||
PageSize: int64(len(body)),
|
||||
PageSize: bodySize,
|
||||
DownloadSpeed: downloadSpeed,
|
||||
IsReachable: true,
|
||||
}
|
||||
|
||||
@@ -89,20 +89,138 @@ const happyEyeballsV6Delay = 150 * time.Millisecond
|
||||
// whoisConnectTimeout 单 IP 连接 + 读写超时上限
|
||||
const whoisConnectTimeout = 10 * time.Second
|
||||
|
||||
// whoisLibTimeout likexian/whois 单跳超时上限(库的超时按「跳」计,同一跳内读写共享该预算)。
|
||||
// 库默认 30s:一旦注册商转介指向不可达地址(如 Cloudflare 后的网站域名,43 端口被丢弃),
|
||||
// 单跳干等 30s,整条查询必然超过中心拨测默认 30s 超时。
|
||||
// 主路径只剩 IANA + 注册局两跳(转介已改为独立限时补查),2×6s 仍在中心超时之内。
|
||||
const whoisLibTimeout = 6 * time.Second
|
||||
|
||||
// whoisReferralTimeout 自行补查注册商转介的总超时。
|
||||
// 转介只是「锦上添花」,失败即放弃,绝不阻塞已到手的注册局数据。
|
||||
const whoisReferralTimeout = 3 * time.Second
|
||||
|
||||
// QueryWhois 执行 WHOIS 查询并解析结构化数据
|
||||
// 使用 likexian/whois 库查询原始响应,再用 whois-parser 解析为结构化数据
|
||||
// 首次失败后 fallback:内置 TLD 映射 → IANA 查询服务器地址 →
|
||||
// 用 ResolveA/AAAA(自定义DNS) 解析IP → Happy Eyeballs 双栈竞争拨号
|
||||
// 主路径:likexian/whois 走「IANA → 注册局」两跳,**不追注册商转介**(转介由 chaseReferral 单独限时补),
|
||||
// 这样即便转介服务器不可达也只在 ~1s 内拿到注册局记录,不会干等满库的 30s 超时。
|
||||
// 兜底:主路径拿不到内容时才 fallback(内置 TLD 映射 → IANA 查服务器 → 自定义 DNS 解析 IP →
|
||||
// Happy Eyeballs 双栈竞争拨号)。
|
||||
func QueryWhois(domain string) (*WhoisResult, error) {
|
||||
raw, err := whois.Whois(domain)
|
||||
if err != nil {
|
||||
raw, err = whoisRetryWithFallback(domain, err)
|
||||
raw, err := queryWhoisRegistry(domain)
|
||||
|
||||
// 库在「连接成功但读失败」等场景会同时返回部分数据与错误,此时数据不能丢;
|
||||
// 因此只在「内容为空」或「带错误」时让 fallback 再兜一次,并取内容更长的一方。
|
||||
if err != nil || raw == "" {
|
||||
if fb, fbErr := whoisRetryWithFallback(domain, err); len(fb) > len(raw) {
|
||||
raw, err = fb, fbErr
|
||||
}
|
||||
}
|
||||
|
||||
// 仅当注册局响应干净(err == nil)时才补查注册商转介
|
||||
if err == nil && raw != "" {
|
||||
if extra := chaseReferral(domain, raw); extra != "" {
|
||||
raw += extra
|
||||
}
|
||||
}
|
||||
|
||||
result := parseWhoisResult(domain, raw)
|
||||
result.Error = errString(err)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// queryWhoisRegistry 走 IANA → 注册局两跳取注册局记录,关闭库的转介链
|
||||
// (转介改由 chaseReferral 限时补查,避免被不可达的转介服务器拖满 30s)
|
||||
func queryWhoisRegistry(domain string) (string, error) {
|
||||
return whois.NewClient().
|
||||
SetDisableReferral(true).
|
||||
SetTimeout(whoisLibTimeout).
|
||||
Whois(domain)
|
||||
}
|
||||
|
||||
// chaseReferral 从注册局响应里取出注册商转介服务器,限时补查一次
|
||||
// 任何失败(无转介 / 不可达 / 超时)都返回空串,绝不影响已到手的注册局数据
|
||||
func chaseReferral(domain, raw string) string {
|
||||
server, port := extractReferralServer(raw)
|
||||
if server == "" {
|
||||
return ""
|
||||
}
|
||||
// 转介指向的仍是刚查过的注册局服务器(库自带判断的同款保护)
|
||||
if ext := getExtension(domain); strings.EqualFold(tldWhoisServers[ext], server) {
|
||||
return ""
|
||||
}
|
||||
|
||||
v4IPs, v6IPs, err := resolveWhoisServerIPs(server)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), whoisReferralTimeout)
|
||||
defer cancel()
|
||||
|
||||
data, err := happyEyeballsWhoisQuery(ctx, domain, v4IPs, v6IPs, port)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// referralKeys 指向「下一个 WHOIS 服务器」的字段名(小写,按优先级前缀匹配)
|
||||
var referralKeys = []string{"registrar whois server", "referralserver", "whois", "referral"}
|
||||
|
||||
// normalizeReferralValue 规整转介地址:去协议前缀 / 路径 / 认证尾巴,拆出 host 与 port
|
||||
// 返回 "" 表示不是可用的 whois 转介。rwhois 是另一种协议,拨过去同样是白等,直接跳过
|
||||
func normalizeReferralValue(value string) (string, string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || strings.HasPrefix(strings.ToLower(value), "rwhois:") {
|
||||
return "", ""
|
||||
}
|
||||
for _, prefix := range []string{"http://", "https://", "whois://"} {
|
||||
value = strings.TrimPrefix(value, prefix)
|
||||
}
|
||||
if i := strings.IndexAny(value, "/,"); i != -1 {
|
||||
value = value[:i]
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
host, port := value, "43"
|
||||
if h, p, err := net.SplitHostPort(value); err == nil {
|
||||
host, port = h, p
|
||||
}
|
||||
// 合法性:必须是形如 whois.example.com 的主机名,排除 "Server: xxx" 之类的误匹配
|
||||
if host == "" || strings.ContainsAny(host, " \t:/,") || !strings.Contains(host, ".") {
|
||||
return "", ""
|
||||
}
|
||||
return host, port
|
||||
}
|
||||
|
||||
// extractReferralServer 从注册局响应中解析注册商转介的 WHOIS 服务器与端口,取不到返回 ("", "")
|
||||
// 覆盖两种写法:字段式(Registrar WHOIS Server: / ReferralServer: / whois:)
|
||||
// 与注释式(%referral whois://host:4321/auth-area=.)
|
||||
func extractReferralServer(raw string) (string, string) {
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
// 归一化:去掉 % / # 注释前缀
|
||||
line = strings.TrimLeft(strings.TrimSpace(line), "%# \t")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(line)
|
||||
for _, key := range referralKeys {
|
||||
if !strings.HasPrefix(lower, key) {
|
||||
continue
|
||||
}
|
||||
// 从 lower 切分,避免 ToLower 改变长度时错位(主机名大小写不敏感)
|
||||
rest := strings.TrimSpace(lower[len(key):])
|
||||
rest = strings.TrimSpace(strings.TrimPrefix(rest, ":"))
|
||||
if rest == "" {
|
||||
continue
|
||||
}
|
||||
if server, port := normalizeReferralValue(rest); server != "" {
|
||||
return server, port
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// errString safely converts an error to string
|
||||
func errString(err error) string {
|
||||
if err == nil {
|
||||
@@ -187,12 +305,13 @@ func resolveWhoisServerIPs(server string) (v4IPs, v6IPs []string, err error) {
|
||||
// - 等待 happyEyeballsV6Delay 后并发拨号所有 IPv6 IP
|
||||
// - 取第一个成功完成 WHOIS 查询的结果返回
|
||||
// - 其他仍在运行的 goroutine 通过 context 取消尽快退出
|
||||
func happyEyeballsWhoisQuery(domain string, v4IPs, v6IPs []string, port string) (string, error) {
|
||||
// parent 带 deadline 时(追注册商转介),到点即整体放弃
|
||||
func happyEyeballsWhoisQuery(parent context.Context, domain string, v4IPs, v6IPs []string, port string) (string, error) {
|
||||
if len(v4IPs) == 0 && len(v6IPs) == 0 {
|
||||
return "", fmt.Errorf("no IPs available for WHOIS query")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
defer cancel()
|
||||
|
||||
type outcome struct {
|
||||
@@ -269,7 +388,7 @@ func whoisRetryWithFallback(domain string, firstErr error) (string, error) {
|
||||
}
|
||||
|
||||
// Happy Eyeballs 双栈竞争拨号 + 并发多 IP
|
||||
raw, err := happyEyeballsWhoisQuery(domain, v4IPs, v6IPs, "43")
|
||||
raw, err := happyEyeballsWhoisQuery(context.Background(), domain, v4IPs, v6IPs, "43")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
testURL := normalizeURL(raw)
|
||||
if cached, ok := websiteCache.Load(testURL); ok {
|
||||
entry := cached.(websiteCacheEntry)
|
||||
if time.Since(entry.timestamp) < 5*time.Minute {
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
return 200, entry.result
|
||||
}
|
||||
websiteCache.Delete(testURL)
|
||||
@@ -52,14 +52,17 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
},
|
||||
)
|
||||
result := &WebsiteCheckResult{IPv4: ipv4, IPv6: ipv6}
|
||||
websiteCache.Store(testURL, websiteCacheEntry{result: result, timestamp: time.Now()})
|
||||
failed := dualAnyFailed(result.IPv4, result.IPv6, func(d *webtest.WebsiteCheckDetail) bool {
|
||||
return d != nil && !d.IsReachable
|
||||
})
|
||||
websiteCache.Store(testURL, websiteCacheEntry{result: result, timestamp: time.Now(), failed: failed})
|
||||
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 {
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
return 200, entry.result
|
||||
}
|
||||
sslCache.Delete(testURL)
|
||||
@@ -74,7 +77,10 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
},
|
||||
)
|
||||
result := &SSLCheckResult{IPv4: ipv4, IPv6: ipv6}
|
||||
sslCache.Store(testURL, sslCacheEntry{result: result, timestamp: time.Now()})
|
||||
failed := dualAnyFailed(result.IPv4, result.IPv6, func(d *webtest.SSLCheckDetail) bool {
|
||||
return d != nil && !d.IsReachable
|
||||
})
|
||||
sslCache.Store(testURL, sslCacheEntry{result: result, timestamp: time.Now(), failed: failed})
|
||||
return 200, result
|
||||
|
||||
case "speed":
|
||||
@@ -87,7 +93,7 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
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 {
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
return 200, entry.result
|
||||
}
|
||||
speedCache.Delete(cacheKey)
|
||||
@@ -95,7 +101,7 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
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()})
|
||||
speedCache.Store(cacheKey, speedCacheEntry{result: errorResult, timestamp: time.Now(), failed: true})
|
||||
return 200, errorResult
|
||||
}
|
||||
speedCache.Store(cacheKey, speedCacheEntry{result: r, timestamp: time.Now()})
|
||||
@@ -123,7 +129,7 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
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 {
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
return 200, entry.result
|
||||
}
|
||||
pingCache.Delete(cacheKey)
|
||||
@@ -138,7 +144,9 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
},
|
||||
)
|
||||
result := &TCPingResult{IPv4: ipv4, IPv6: ipv6}
|
||||
pingCache.Store(cacheKey, pingCacheEntry{result: result, timestamp: time.Now()})
|
||||
// 只有双栈都失败才算整体失败,单侧失败仍返回了可用的延迟数据(口径同 HTTP handler)
|
||||
failed := tcpingFailed(ipv4) && tcpingFailed(ipv6)
|
||||
pingCache.Store(cacheKey, pingCacheEntry{result: result, timestamp: time.Now(), failed: failed})
|
||||
return 200, result
|
||||
|
||||
case "dns":
|
||||
@@ -190,7 +198,7 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
}
|
||||
if cached, ok := whoisCache.Load(raw); ok {
|
||||
entry := cached.(whoisCacheEntry)
|
||||
if time.Since(entry.timestamp) < 5*time.Minute {
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
return 200, entry.result
|
||||
}
|
||||
whoisCache.Delete(raw)
|
||||
@@ -386,7 +394,7 @@ const (
|
||||
)
|
||||
|
||||
// nodeCapabilities 本节点支持的远程管理能力清单,随 register 报文上报给中间件,
|
||||
// 同时也在 HTTP 健康检查 `/` 里返回(供中间件对纯 HTTP 节点探活时一并取回)。
|
||||
// 同时也在 HTTP `GET /info` 里返回(供中间件对纯 HTTP 节点取回,健康检查 `/` 只回存活)。
|
||||
//
|
||||
// 存在的意义:中间件下发 config 指令前先查这份清单,
|
||||
// - 清单里没有该能力 → 立刻给出明确错误(旧版节点会静默忽略这类消息,只能白白等满超时)
|
||||
|
||||