Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
805b65bc07 | ||
|
|
682983e411 | ||
|
|
19fe134762 | ||
|
|
5445b4df64 | ||
|
|
4f6cf7fac7 | ||
|
|
243060a32a | ||
|
|
924b9bcc36 | ||
|
|
611caa30f6 | ||
|
|
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 | ||
|
|
d7cc9d45e7 | ||
|
|
972ca4c744 | ||
|
|
12c91ed2d3 | ||
|
|
4fceb8421e | ||
|
|
56d6bc6c08 | ||
|
|
981b2fec9c | ||
|
|
f1790742a3 | ||
|
|
6b9f6b6b54 | ||
|
|
cfabb06f73 | ||
|
|
372cc62473 | ||
|
|
e9b3a75f02 | ||
|
|
4ce9637918 | ||
|
|
56c08f3629 | ||
|
|
949f71ca16 | ||
|
|
c0a8f7e55c | ||
|
|
fa9b83c951 | ||
|
|
c81f92bbd4 | ||
|
|
a411d0bb72 | ||
|
|
a93e74a8c7 | ||
|
|
4cf136b367 | ||
|
|
b350413902 | ||
|
|
08877871af | ||
|
|
d942ff0c5c | ||
|
|
5aaf30dc16 | ||
|
|
789968069b | ||
|
|
87fd2fd4df | ||
|
|
57b5824370 | ||
|
|
52796aa089 | ||
|
|
62b9f686e9 | ||
|
|
7e5bc92aea | ||
|
|
ee28aa2cd6 | ||
|
|
a95519428b | ||
|
|
aa65f23ac9 | ||
|
|
0dfbf7cbdf | ||
|
|
130ac61df0 | ||
|
|
e1a00236ee | ||
|
|
71ec93f068 | ||
|
|
c0f0640075 | ||
|
|
778ed2bd29 | ||
|
|
2ec182fb08 | ||
|
|
e8ec3757fd | ||
|
|
d398c4085c | ||
|
|
d04a90b20c | ||
|
|
92a8c1f308 | ||
|
|
d2e3601f20 | ||
|
|
6a4a3821de | ||
|
|
069847e11f | ||
|
|
30ba61bc11 | ||
|
|
94f03ffa85 | ||
|
|
3a3eeab9f9 | ||
|
|
fa5c2329fe | ||
|
|
957ac5a4c8 | ||
|
|
63c42627f6 | ||
|
|
6512a46596 | ||
|
|
e261666efc | ||
|
|
0d1a9e5bbd | ||
|
|
279f663694 | ||
|
|
3af1076492 | ||
|
|
10f48f8619 | ||
|
|
694ddac6eb | ||
|
|
73ecf67c4e | ||
|
|
b4a18e0fbf |
No files matched your search
@@ -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,91 +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.2' # 选择适合的 Go 版本
|
||||
- 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
|
||||
go-version: '1.26.6' # 与 src/go.mod 声明保持一致,避免依赖 GOTOOLCHAIN 隐式下载
|
||||
|
||||
- name: Build for Linux
|
||||
# 产物统一写到仓库根目录(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
|
||||
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
|
||||
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
|
||||
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: |
|
||||
# 后端:发布真实 setting.json(重命名为 setting.json.example);中间件:只发布 setting.json.example
|
||||
cp setting.json setting.json.example
|
||||
cp middleware-go/setting.json.example middleware.setting.json.example
|
||||
# 后端:发布 src/ 下的 setting.json.example;中间件:只发布 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: |
|
||||
## 配置文件说明
|
||||
@@ -112,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/*
|
||||
@@ -0,0 +1,203 @@
|
||||
name: Docker Multi-Arch
|
||||
|
||||
# 打 v* 标签发版时构建多架构镜像并推送到 GHCR + Docker Hub。
|
||||
# ARM 用 GitHub 原生 arm64 实例(ubuntu-24.04-arm,公开仓库免费)交叉编译,全程无 QEMU:
|
||||
# - ubuntu-latest → linux/amd64(原生)
|
||||
# - ubuntu-24.04-arm → linux/arm64(原生)、linux/arm/v7(Go 交叉编译,GOARM 由 Dockerfile 处理)
|
||||
# 各平台按 digest 推到 GHCR,最后由 merge job 用 buildx imagetools 合并成多架构
|
||||
# manifest list,同时打上 GHCR 与 Docker Hub 的标签。
|
||||
#
|
||||
# Docker Hub 推送为可选:需要仓库 Secrets 配置 DOCKERHUB_USERNAME / DOCKERHUB_TOKEN,
|
||||
# 未配置时只推 GHCR,不影响流程。
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# 纯 include 模式:每条自带全部字段,独立成一个任务。
|
||||
# 注意不能把 image 写成顶层数组再用 include 拆——GitHub 会把能匹配
|
||||
# 现有组合的 include 条目合并进组合本身,导致任务被折叠(见 v3.3.0 事故)
|
||||
include:
|
||||
- image: lemon-ipw
|
||||
context: src
|
||||
runner: ubuntu-latest
|
||||
platforms: linux/amd64
|
||||
platform_id: amd64
|
||||
- image: lemon-ipw
|
||||
context: src
|
||||
runner: ubuntu-24.04-arm
|
||||
platforms: linux/arm64
|
||||
platform_id: arm64
|
||||
- image: lemon-ipw
|
||||
context: src
|
||||
runner: ubuntu-24.04-arm
|
||||
platforms: linux/arm/v7
|
||||
platform_id: armv7
|
||||
- image: lemon-ipw-middleware
|
||||
context: middleware-go
|
||||
runner: ubuntu-latest
|
||||
platforms: linux/amd64
|
||||
platform_id: amd64
|
||||
- image: lemon-ipw-middleware
|
||||
context: middleware-go
|
||||
runner: ubuntu-24.04-arm
|
||||
platforms: linux/arm64
|
||||
platform_id: arm64
|
||||
- image: lemon-ipw-middleware
|
||||
context: middleware-go
|
||||
runner: ubuntu-24.04-arm
|
||||
platforms: linux/arm/v7
|
||||
platform_id: armv7
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 版本注入 src/Dockerfile(build-args 见下方构建步骤):
|
||||
# VERSION 取 ref 名——打 v* 标签时与 build_and_release.yml 的二进制产物一致(带 v 前缀),
|
||||
# 手动触发时为分支名(与 merge 阶段打的 main 标签对应)
|
||||
- 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: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}/${{ matrix.image }}
|
||||
|
||||
# push-by-digest 模式:不传 tags(新 buildx 禁止 tagged ref 按 digest 推送),
|
||||
# 仓库名通过 outputs 的 name= 传入,最终 tag 由 merge 阶段统一打
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
# 只有 src/Dockerfile 声明了这三个 ARG;middleware-go 未声明,BuildKit 会静默忽略
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
COMMIT=${{ env.COMMIT }}
|
||||
BUILD_TIME=${{ env.BUILD_TIME }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=ghcr.io/${{ github.repository }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.image }}-${{ matrix.platform_id }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.image }}-${{ matrix.platform_id }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p "${{ runner.temp }}/digests"
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
# 文件名自带镜像与平台元数据:merge 阶段 merge-multiple 平铺下载后
|
||||
# 按前缀收集,不依赖 artifact 的目录结构
|
||||
touch "${{ runner.temp }}/digests/${{ matrix.image }}-${{ matrix.platform_id }}-${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ matrix.image }}-${{ matrix.platform_id }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# ===== Docker Hub 推送暂时停用 =====
|
||||
# 需要启用时:取消注释本步骤与下方 merge 脚本中的 docker.io 标签行,
|
||||
# 并在仓库 Secrets 配置 DOCKERHUB_USERNAME / DOCKERHUB_TOKEN
|
||||
# - name: Log in to Docker Hub
|
||||
# if: ${{ secrets.DOCKERHUB_USERNAME != '' }}
|
||||
# uses: docker/login-action@v3
|
||||
# with:
|
||||
# username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
# password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create and push multi-arch manifest lists
|
||||
# DOCKERHUB_USERNAME 暂不注入(Docker Hub 推送停用,见上方登录步骤注释)
|
||||
# env:
|
||||
# DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DIGESTS_DIR="${{ runner.temp }}/digests"
|
||||
GHCR_REPO="ghcr.io/${GITHUB_REPOSITORY,,}"
|
||||
|
||||
# 标签:v1.2.3 → ["1.2.3", "1.2"];手动触发 → ["main"]
|
||||
if [[ "${GITHUB_REF_NAME}" == v* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
MINOR="$(echo "${VERSION}" | cut -d. -f1,2)"
|
||||
TAGS=("${VERSION}" "${MINOR}")
|
||||
else
|
||||
TAGS=("main")
|
||||
fi
|
||||
|
||||
for IMG in lemon-ipw lemon-ipw-middleware; do
|
||||
ARGS=()
|
||||
for TAG in "${TAGS[@]}"; do
|
||||
ARGS+=(-t "${GHCR_REPO}/${IMG}:${TAG}")
|
||||
# Docker Hub 推送停用;启用时取消注释并恢复上方 DOCKERHUB_USERNAME env
|
||||
# if [ -n "${DOCKERHUB_USERNAME}" ]; then
|
||||
# ARGS+=(-t "docker.io/${DOCKERHUB_USERNAME,,}/${IMG}:${TAG}")
|
||||
# fi
|
||||
done
|
||||
# 各平台单架构 manifest(GHCR 上按 digest 推送的产物)合并为一份
|
||||
# 多架构 manifest list;imagetools create 会按 -t 目标推送到 GHCR
|
||||
shopt -s nullglob
|
||||
for PLATFORM_ID in amd64 arm64 armv7; do
|
||||
PREFIX="${IMG}-${PLATFORM_ID}-"
|
||||
FILES=("${DIGESTS_DIR}"/${PREFIX}*)
|
||||
if [ ${#FILES[@]} -eq 0 ]; then
|
||||
echo "ERROR: no digest file for ${IMG} (${PLATFORM_ID}); dir contents:" >&2
|
||||
ls -la "${DIGESTS_DIR}" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
for f in "${FILES[@]}"; do
|
||||
hex="$(basename "${f}")"
|
||||
ARGS+=("${GHCR_REPO}/${IMG}@sha256:${hex#${PREFIX}}")
|
||||
done
|
||||
done
|
||||
shopt -u nullglob
|
||||
echo "Merging ${#ARGS[@]} refs for ${IMG}:"; printf ' %s\n' "${ARGS[@]}"
|
||||
docker buildx imagetools create "${ARGS[@]}"
|
||||
done
|
||||
echo "Pushed multi-arch manifests: ${TAGS[*]}"
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'edgeone/**'
|
||||
- 'serverless/edgeone/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -24,10 +24,10 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm install -g edgeone
|
||||
- name: Deploy to EdgeOne Pages
|
||||
run: cd edgeone && npx edgeone pages deploy -n ipw-cn -t ${{ secrets.EDGEONE_API_TOKEN }}
|
||||
run: cd serverless/edgeone && npx edgeone pages deploy -n ipw-cn -t ${{ secrets.EDGEONE_API_TOKEN }}
|
||||
env:
|
||||
EDGEONE_API_TOKEN: ${{ secrets.EDGEONE_API_TOKEN }}
|
||||
- name: Deploy to EdgeOne Pages
|
||||
run: cd edgeone && npx edgeone pages deploy -n ipw-cn2 -t ${{ secrets.EDGEONE_API_TOKEN }}
|
||||
run: cd serverless/edgeone && npx edgeone pages deploy -n ipw-cn2 -t ${{ secrets.EDGEONE_API_TOKEN }}
|
||||
env:
|
||||
EDGEONE_API_TOKEN: ${{ secrets.EDGEONE_API_TOKEN }}
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'edgeone-getip/**'
|
||||
- 'serverless/edgeone-getip/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -24,6 +24,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm install -g edgeone
|
||||
- name: Deploy to EdgeOne Pages
|
||||
run: cd edgeone-getip && npx edgeone pages deploy -n edgeone-getip -t ${{ secrets.EDGEONE_API_TOKEN }}
|
||||
run: cd serverless/edgeone-getip && npx edgeone pages deploy -n edgeone-getip -t ${{ secrets.EDGEONE_API_TOKEN }}
|
||||
env:
|
||||
EDGEONE_API_TOKEN: ${{ secrets.EDGEONE_API_TOKEN }}
|
||||
@@ -1,11 +1,15 @@
|
||||
name: Build and Deploy to Cloudflare Workers
|
||||
name: (已停用自动部署) frontend-ssr → Cloudflare Workers
|
||||
|
||||
# 前端已迁移到 frontend-svelte(SvelteKit + shadcn-svelte,包管理器 bun),
|
||||
# 自动部署改由 .github/workflows/frontend-svelte.yml 负责。
|
||||
#
|
||||
# 这里**刻意去掉 push 触发**:两个 workflow 都部署同名 Worker(wrangler.jsonc 里的 "ipw-cn"),
|
||||
# 若继续按 push 触发,改 frontend-ssr 会把新前端的线上版本覆盖掉。
|
||||
# 保留 workflow_dispatch 只是为了万一还需要临时用旧站顶一下(手动触发)。
|
||||
# 新前端确认稳定后,这个文件可以直接删除。
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- 'frontend-ssr/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Build and Deploy frontend-svelte to Cloudflare Workers
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- 'frontend-svelte/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 包管理器是 bun(见 frontend-svelte/bun.lock),不再用 pnpm/npm
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
# 部署到 Cloudflare Workers
|
||||
# --keep-vars:保留 Worker 上已配置的环境变量(如 API_KEYS),别被本次部署清掉
|
||||
- name: Deploy to Cloudflare Workers
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
cd frontend-svelte
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
bunx wrangler deploy --keep-vars
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- 'lemon-getip/**'
|
||||
- 'serverless/lemon-getip/**'
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
deploy:
|
||||
@@ -25,6 +25,6 @@ jobs:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
cd lemon-getip
|
||||
cd serverless/lemon-getip
|
||||
npm install
|
||||
npm run deploy
|
||||
@@ -15,9 +15,16 @@ lemon-ipw
|
||||
*.tmp
|
||||
*.log
|
||||
node_modules/
|
||||
setting.json
|
||||
.workbuddy/
|
||||
# VitePress 构建产物
|
||||
docs/.vitepress/.temp/
|
||||
docs/.vitepress/cache/
|
||||
docs/.vitepress/dist/
|
||||
docs/.vitepress/dist/
|
||||
# 构建期临时配置文件(vitepress 每次构建生成,用完即弃)
|
||||
docs/.vitepress/*.timestamp-*
|
||||
tmp/
|
||||
setting.json
|
||||
*.BIN
|
||||
|
||||
# 本地调试脚本与实测产物(不入库,与 ipw-boce 的约定一致)
|
||||
tmp-smoke/
|
||||
@@ -1,31 +0,0 @@
|
||||
FROM golang:1.26-alpine AS build
|
||||
ENV GO111MODULE=on \
|
||||
CGO_ENABLED=0 \
|
||||
GOOS=linux \
|
||||
GOARCH=amd64
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY main.go ./
|
||||
COPY webtest/ ./webtest/
|
||||
COPY ipdb/ ./ipdb/
|
||||
COPY ssrf/ ./ssrf/
|
||||
RUN go build -o main .
|
||||
|
||||
# 运行阶段
|
||||
FROM alpine:latest
|
||||
|
||||
# 添加必要的证书
|
||||
RUN apk --no-cache add ca-certificates
|
||||
|
||||
# 创建非root用户
|
||||
RUN adduser -D appuser
|
||||
USER appuser
|
||||
|
||||
WORKDIR /home/appuser
|
||||
|
||||
# 从构建阶段复制二进制文件
|
||||
COPY --from=build /app/main .
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["./main"]
|
||||
@@ -1,278 +1,93 @@
|
||||
# Lemon IPW
|
||||
|
||||
ipw.cn 替代品,提供 IP 查询、网站检测、SSL 检查、DNS 解析、TCPing 测速、Whois、DNSSEC、ASN 查询、网站截图等功能。
|
||||
文档编写中....
|
||||
ipw.cn 替代品,提供 IP 查询、网站检测、SSL 检查、DNS 解析、TCPing 测速、Whois、DNSSEC、ASN 查询、网站截图等功能。
|
||||
|
||||
## 功能
|
||||
**[预览站点](https://ipw.wsmdn.top/)** · **[文档](https://github.com/nomdn/ipw-cn/tree/main/docs)**
|
||||
|
||||
- **IP 地址查询** — 支持 IPv4/IPv6,集成 ip2region、qqwry、MaxMind GeoIP、GeoCN、DbIP、Bilibili 等多数据源
|
||||
- **ASN 自治系统查询** — 查询 IP 所属自治系统号,支持 MaxMind GeoLite2、DbIP 双数据源,集成 WHOIS 解析
|
||||
- **网站检测** — HTTP 状态码、响应时间、Host 记录
|
||||
- **SSL 证书检查** — 证书有效期、颁发机构、剩余天数
|
||||
- **DNS 解析** — 多节点并发查询,支持 A/AAAA/CNAME/MX/TXT/NS/SRV/PTR/CAA 等记录类型
|
||||
- **DNSSEC 验证** — 检测域名 DNSSEC 签名状态,验证 DNSKEY、RRSIG、DS 记录链式信任关系
|
||||
- **Whois 查询** — 域名注册信息、注册商、到期时间等
|
||||
- **TCPing** — TCP 连接延迟测试,支持 IPv4/IPv6 双栈
|
||||
- **网站测速** — 下载速度、响应头、Host 记录
|
||||
- **网站截图** — 在线网站页面截图
|
||||
- **SSRF 防护** — 自动拦截对内网/私有 IP 的出站请求
|
||||
- **暗色模式** — 支持明暗主题切换
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o install.sh && sudo bash install.sh
|
||||
```
|
||||
|
||||
完整部署(后端节点 / 独立中间件 / 前端)与配置说明见 [文档](https://github.com/nomdn/ipw-cn/tree/main/docs):
|
||||
|
||||
- [功能总览](docs/info/features.md)(探测能力、IP 数据源、可靠性设计)
|
||||
- [快速开始](docs/guide/getting-started.md)
|
||||
- [后端节点部署](docs/guide/deploy-node.md)
|
||||
- [中间件部署](docs/guide/deploy-middleware.md)
|
||||
- [前端部署](docs/guide/deploy-frontend.md)
|
||||
- [配置文件](docs/guide/config.md)
|
||||
- [节点 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 # 跟踪日志
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
ipw-cn/
|
||||
├── main.go # Go 后端入口(Gin 框架,自托管)
|
||||
├── go.mod # Go 模块定义
|
||||
├── Dockerfile # 后端 Docker 镜像
|
||||
├── setting.json # 后端运行配置
|
||||
│
|
||||
├── ipdb/ # IP 数据库查询模块
|
||||
│ ├── searchip.go # 多数据源 IP 查询(ip2region/qqwry/MaxMind/GeoCN/DbIP/Bilibili)
|
||||
│ └── ipdb.go # 数据库下载与更新
|
||||
│
|
||||
├── webtest/ # 网络测试工具
|
||||
│ ├── dns.go # DNS 查询(基于 miekg/dns)
|
||||
│ ├── dnssec.go # DNSSEC 验证
|
||||
│ ├── tcping.go # TCP 连接测试
|
||||
│ └── whois.go # WHOIS 域名查询
|
||||
│
|
||||
├── ssrf/ # SSRF 防护模块
|
||||
│ └── ssrf.go # 私有 IP 拦截、安全跳转校验
|
||||
│
|
||||
├── middleware-go/ # 独立转发中间件(Go Fiber v3,可单独部署)
|
||||
│ ├── main.go # 中间件入口(/v1/* 与 /middleware/* 路由)
|
||||
│ ├── setting.json # 中间件配置(节点列表、apiKeys、cors 等)
|
||||
│ ├── setting.json.example # 中间件配置示例
|
||||
│ ├── Dockerfile # 中间件 Docker 镜像
|
||||
│ └── middleware-go-linux-arm64 # Linux ARM64 预编译二进制
|
||||
│
|
||||
├── frontend-ssr/ # Nuxt 4 SSR 前端(主版本,部署至 Cloudflare Workers)
|
||||
│ ├── app/ # Nuxt 应用源码
|
||||
│ │ └── pages/ # 页面组件(location/dns/tcping/ssl/speedtest/whois/dnssec/asn/screenshot)
|
||||
│ ├── config/ # 前端配置(API 地址、节点列表)
|
||||
│ ├── nuxt.config.ts # Nuxt 配置
|
||||
│ ├── package.json
|
||||
│ ├── pnpm-workspace.yaml
|
||||
│ └── wrangler.jsonc # Cloudflare Workers 部署配置
|
||||
│
|
||||
├── edgeone/ # 腾讯 EdgeOne 边缘函数版本后端
|
||||
│ ├── cloud-functions/ # Go 边缘函数源码
|
||||
│ │ ├── index.go # 后端入口(Gin 框架)
|
||||
│ │ ├── webtest/ # 网络测试工具
|
||||
│ │ ├── ssrf/ # SSRF 防护
|
||||
│ │ └── go.mod
|
||||
│ └── .edgeone/ # EdgeOne 部署配置
|
||||
│
|
||||
├── lemon-getip/ # Cloudflare Workers IP 查询服务(Hono + TypeScript)
|
||||
│ ├── src/index.ts
|
||||
│ ├── wrangler.jsonc
|
||||
│ ├── package.json
|
||||
│ └── test/
|
||||
│
|
||||
├── edgeone-getip/ # EdgeOne Pages IP 查询服务(TypeScript 边缘函数)
|
||||
│ ├── edge-functions/index.ts
|
||||
│ └── .edgeone/
|
||||
│
|
||||
├── .github/workflows/ # CI/CD 流水线
|
||||
│ ├── frontend-ssr.yml # 部署至 Cloudflare Workers
|
||||
│ ├── workers.yml # 部署 lemon-getip 至 Cloudflare Workers
|
||||
│ ├── edgeone-backend.yml # 部署 edgeone 至 EdgeOne Pages
|
||||
│ └── edgeone-getip.yml # 部署 edgeone-getip 至 EdgeOne Pages
|
||||
│
|
||||
├── tmp/ # IP 数据库下载临时目录
|
||||
├── short.txt # GeoCN 行政区划短表(省市级)
|
||||
├── full.txt # GeoCN 行政区划全表(区县级)
|
||||
├── ipw-backend.exe # Windows 预编译后端二进制
|
||||
├── .node-version # Node.js 版本(v22)
|
||||
└── LICENSE # GPL-3.0
|
||||
├── src/ # Go 后端
|
||||
├── middleware-go/ # 独立组件 转发中间件(Fiber v3,多节点候选)
|
||||
├── serverless/ # 独立组件 边缘/Serverless(edgeone、edgeone-getip、lemon-getip)
|
||||
├── frontend-ssr/ # Nuxt 4 SSR 前端
|
||||
├── docs/ # 文档站
|
||||
├── install.sh # 一键安装脚本(自建完整部署)
|
||||
└── contribute.sh # 贡献节点脚本(一台闲置机器接为公共节点)
|
||||
```
|
||||
## 友情链接
|
||||
### 来自社区的IPW版本
|
||||
[ZAKOFLARE/ipw-cn-php](https://github.com/ZAKOFLARE/ipw-cn-php) 一个基于PHP的IPW节点
|
||||
[ZAKOFLARE/ipw-cn-rust](https://github.com/ZAKOFLARE/ipw-cn-rust) 一个生锈的IPW节点[开发成本过高,已存档]
|
||||
[KFCV50TK/ipw-cn](https://github.com/KFCV50TK/ipw-cn) 一个有许多扩展功能的IPW分支
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 组件 | 技术 |
|
||||
|------|------|
|
||||
| 后端(自托管) | Go 1.26 + Gin + Resty |
|
||||
| 独立中间件 | Go 1.26 + Fiber v3(转发中间件,多节点候选) |
|
||||
| 边缘后端(EdgeOne) | Go(边缘函数) |
|
||||
| SSR 前端 | Nuxt 4 + Vue 3 + Element Plus + VueUse |
|
||||
| Cloudflare Workers | Hono + TypeScript + Wrangler |
|
||||
| EdgeOne Pages | TypeScript 边缘函数 |
|
||||
| IP 数据库 | ip2region、qqwry.ipdb、MaxMind GeoLite2、GeoCN、DbIP、Bilibili API |
|
||||
| DNS 查询 | miekg/dns(Go)/ 原生 DNS-over-HTTPS(EdgeOne) |
|
||||
| 其他 | SSRF 防护、Zstandard 压缩、WordPress mshots 截图 |
|
||||
| 部署 | Docker、Cloudflare Workers、EdgeOne Pages |
|
||||
| 后端(自托管) | Go + Gin + Resty |
|
||||
| 独立中间件 | Go + Fiber v3(WS 通道:coder/websocket) |
|
||||
| 边缘后端(EdgeOne) | Go 边缘函数 |
|
||||
| SSR 前端 | Nuxt 4 + Vue 3 + Element Plus |
|
||||
| DNS 查询 | miekg/dns(DoH / UDP 双通道 + 主从 failover) |
|
||||
| 部署 | Docker、Cloudflare Workers、EdgeOne Pages、systemd |
|
||||
|
||||
## 一键部署到 EdgeOne(Nuxt 原生支持)
|
||||
## 联系方式
|
||||
|
||||
前端 `frontend-ssr/` 基于 Nuxt,腾讯云 EdgeOne 原生支持 Nuxt 构建,点击下方按钮即可通过 EdgeOne Makers 一键部署前端:
|
||||
|
||||
[](https://console.cloud.tencent.com/edgeone/makers/new?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=frontend-ssr&install-command=pnpm%20install&build-command=pnpm%20run%20build&output-directory=.output)
|
||||
|
||||
| 按钮参数 | 值 | 说明 |
|
||||
|----------|-----|------|
|
||||
| `repository-url` | `https://github.com/nomdn/ipw-cn` | GitHub 仓库地址 |
|
||||
| `root-directory` | `frontend-ssr` | 构建根目录(前端源码所在目录) |
|
||||
| `install-command` | `pnpm install` | 依赖安装命令 |
|
||||
| `build-command` | `pnpm run build` | 构建命令(Nuxt 默认 SSR 构建) |
|
||||
| `output-directory` | `.output` | 构建产物输出目录(Nitro SSR 产物) |
|
||||
|
||||
> 说明:EdgeOne Makers 原生支持 Nuxt 部署,SSR / SSG / ISR(SWR) / 中间件 / 流式传输均支持(Nuxt 3.16+,推荐 Nuxt 4),按钮默认以 **SSR** 方式构建。如需纯静态部署,可将 `build-command` 改为 `pnpm run generate`、`output-directory` 改为 `.output/public`。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 后端(自托管)
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
go mod download
|
||||
|
||||
# 配置 setting.json(可选)
|
||||
# {
|
||||
# "port": 8080,
|
||||
# "gh-proxy": "https://fastgit.cc/",
|
||||
# "single-stack": "", # "ipv4" 或 "ipv6" 仅启用单栈
|
||||
# "dns-server": "", # 自定义 DNS 服务器
|
||||
# "block-private-ips": true,# SSRF 防护开关
|
||||
# "ipdb": true, # IP 数据库开关
|
||||
# "cors": "", # 允许的请求来源,详见 https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Guides/CORS
|
||||
# "access_token": "" # API 访问令牌(可选)
|
||||
# }
|
||||
|
||||
# 运行
|
||||
go run main.go
|
||||
```
|
||||
|
||||
首次启动时会自动下载 IP 数据库文件(约 200MB),之后每 24 小时自动更新。
|
||||
|
||||
### 独立中间件(middleware-go)
|
||||
|
||||
转发中间件,供 SSR 前端在多个上游节点之间做候选重试,可独立部署。
|
||||
|
||||
```bash
|
||||
cd middleware-go
|
||||
|
||||
# 构建(Linux ARM64 示例)
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o middleware-go-linux-arm64 .
|
||||
|
||||
# 运行(需配置文件,可用 SETTING_FILE 指定路径)
|
||||
./middleware-go-linux-arm64
|
||||
|
||||
# 查看版本
|
||||
./middleware-go-linux-arm64 --version
|
||||
```
|
||||
|
||||
所有配置均可通过环境变量覆盖,无需修改配置文件(详见下方「中间件配置」)。
|
||||
|
||||
### SSR 前端(部署至 Cloudflare Workers)
|
||||
|
||||
```bash
|
||||
cd frontend-ssr
|
||||
|
||||
# 安装依赖(需要 pnpm)
|
||||
pnpm install
|
||||
|
||||
# 开发
|
||||
pnpm dev
|
||||
|
||||
# 构建并部署
|
||||
pnpm deploy
|
||||
```
|
||||
|
||||
### SPA 前端(旧版本,部署至 Cloudflare Pages)
|
||||
|
||||
> [!WARNING]
|
||||
> 原生 SPA 已停止支持。要部署 SPA 版本前端请使用 Nuxt SPA 版本。
|
||||
|
||||
### Docker 部署
|
||||
|
||||
```bash
|
||||
docker build -t lemon-ipw .
|
||||
docker run -p 8080:8080 -v $(pwd)/setting.json:/app/setting.json lemon-ipw
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/v1/location/:ip` | 查询指定 IP 地理位置 |
|
||||
| GET | `/v1/location` | 查询请求者 IP 地理位置 |
|
||||
| GET | `/v1/asn/:ip` | 查询 IP 所属自治系统号(ASN) |
|
||||
| GET | `/v1/detail/*url` | 检测网站可达性(HTTP 状态码、响应时间) |
|
||||
| GET | `/v1/ssl/*url` | 检查 SSL 证书信息 |
|
||||
| GET | `/v1/speed/:version/*url` | 网站测速(version: v4/v6/dual) |
|
||||
| GET | `/v1/tcping/:ip?port=80&count=4` | TCP 连接测试 |
|
||||
| GET | `/v1/dns/:type/*domain` | DNS 解析(type: A/AAAA/CNAME/MX/TXT/NS/SRV/PTR/CAA) |
|
||||
| GET | `/v1/dnssec/:domain` | DNSSEC 验证 |
|
||||
| GET | `/v1/whois/:domain` | Whois 域名查询 |
|
||||
| GET | `/` | 健康检查 |
|
||||
|
||||
## 部署架构
|
||||
|
||||
本项目支持多平台部署,前端和后端可独立部署到不同平台:
|
||||
|
||||
- **自托管后端**:使用 Docker 或直接运行 Go 二进制,配合 `setting.json` 配置
|
||||
- **独立转发中间件**:`middleware-go/` 使用 Go Fiber v3,提供 `/v1/*` 与 `/middleware/*` 转发路由,供前端做多节点候选重试
|
||||
- **Cloudflare Workers 后端**:`lemon-getip/` 使用 Hono 框架部署到 Cloudflare Workers
|
||||
- **EdgeOne 边缘后端**:`edgeone/` 和 `edgeone-getip/` 部署到腾讯 EdgeOne Pages
|
||||
- **SSR 前端**:`frontend-ssr/` 使用 Nuxt 4 + Wrangler 部署到 Cloudflare Workers
|
||||
|
||||
## 配置
|
||||
|
||||
### 后端环境变量
|
||||
|
||||
| 配置项 | 环境变量 | 默认值 | 说明 |
|
||||
|--------|----------|--------|------|
|
||||
| `port` | `PORT` | `8080` | 监听端口 |
|
||||
| `gh-proxy` | `GH_PROXY` | `""` | GitHub 文件代理(用于下载数据库) |
|
||||
| `single-stack` | `SINGLE_STACK` | `""` | 单栈模式:`ipv4` 或 `ipv6` |
|
||||
| `dns-server` | `DNS_SERVER` | `119.28.28.28:53` | DNS 服务器地址 |
|
||||
| `block-private-ips` | `BLOCK_PRIVATE_IPS` | `true` | SSRF 防护开关 |
|
||||
| `ipdb` | `IPDB` | `true` | IP 数据库开关 |
|
||||
| `access_token` | `ACCESS_TOKEN` | `""` | API 访问令牌 |
|
||||
| `cors` | `CORS` | `""` | 允许的请求来源 |
|
||||
| 远端配置 | `REMOTE_CONFIG_URL` | `""` | 远端配置文件 URL(遵守 `setting.json` 格式),优先级最高 |
|
||||
|
||||
> 配置优先级:**远端配置(`REMOTE_CONFIG_URL`)> 环境变量 > setting.json**。远端配置 URL 可通过环境变量 `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` 提供(环境变量优先);远端配置本身需遵守 `setting.json` 的 JSON 格式(`port` / `gh-proxy` / `single-stack` / `dns-server` / `block-private-ips` / `ipdb` / `cors` 等),拉取或解析失败时自动回退到本地配置并打印警告。`access_token` 例外:**不随远端配置覆盖**,保持原有优先级(环境变量 > setting.json)。EdgeOne 版本(`edgeone/`)配置来源为 环境变量 + 远端配置。
|
||||
|
||||
### 前端配置
|
||||
|
||||
前端 API 地址和多节点配置位于各前端项目的 `config/` 目录中。
|
||||
|
||||
### 中间件配置(middleware-go)
|
||||
|
||||
配置来源优先级:**环境变量 > `setting.json` > 默认值**。`setting.json` 采用平铺结构(键名与前端 `config/index.ts` 一致),也可用环境变量覆盖任意配置项;数组/对象类配置项从环境变量读取 **JSON 字符串**解析。
|
||||
|
||||
```json
|
||||
{
|
||||
"port": "8091",
|
||||
"httpTimeoutSeconds": 30,
|
||||
"cors": "https://ipw.wsmdn.top",
|
||||
"apiBaseUrls": [{ "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" }],
|
||||
"IPLocationAPIs": [],
|
||||
"TCPing": { "DualStack": [], "IPv4": [], "IPv6": [] },
|
||||
"SpeedTest": { "DualStack": [], "IPv4": [], "IPv6": [] },
|
||||
"NSLookup": [],
|
||||
"apiKeys": { "cn-jiangsu": "token" }
|
||||
}
|
||||
```
|
||||
|
||||
| 配置项 | 环境变量 | 类型 | 说明 |
|
||||
|--------|----------|------|------|
|
||||
| `port` | `PORT` | 数字/字符串 | 监听端口 |
|
||||
| `httpTimeoutSeconds` | `HTTP_TIMEOUT` | 数字 | 上游请求超时(秒) |
|
||||
| `cors` | `CORS` | 字符串 | 逗号分隔的允许域名;为空则允许所有(`*`) |
|
||||
| `apiBaseUrls` | `API_BASE_URLS` | JSON 数组 | whois/ssl/detail 上游节点 |
|
||||
| `IPLocationAPIs` | `IP_LOCATION_APIS` | JSON 数组 | location/asn 上游节点 |
|
||||
| `TCPing` | `TCPING` | JSON 对象 | tcping 节点(DualStack/IPv4/IPv6) |
|
||||
| `SpeedTest` | `SPEED_TEST` | JSON 对象 | speed 节点(DualStack/IPv4/IPv6) |
|
||||
| `NSLookup` | `NS_LOOKUP` | JSON 数组 | dns/dnssec 上游节点 |
|
||||
| `apiKeys` | `APIKEYS` | JSON 对象 | `{"backendID": "token"}`,转发时加 `Authorization: Bearer <token>` |
|
||||
| 配置文件路径 | `SETTING_FILE` | 字符串 | 指定 setting.json 路径(默认 `./setting.json` 或 `../setting.json`) |
|
||||
|
||||
**路由格式**:`/{prefix}/{backendID}/{apiType}/{raw...}`,其中 `prefix` 为 `/v1` 或 `/middleware`,`apiType` 支持 `whois/dns/location/ssl/asn/dnssec/detail/tcping/speed`。响应透传上游状态码与 body;上游网络不可达返回 502。
|
||||
|
||||
**版本信息**:通过 ldflags 注入 `main.VERSION / main.COMMIT / main.BUILD_TIME`,运行 `--version` 查看。
|
||||
- 邮箱:[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)
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -25,6 +25,11 @@ export default defineConfig({
|
||||
lang: "zh-CN",
|
||||
cleanUrls: true,
|
||||
|
||||
head: [
|
||||
['link', { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }],
|
||||
['link', { rel: 'icon', href: '/favicon.ico' }],
|
||||
],
|
||||
|
||||
themeConfig: {
|
||||
// 合并 Teek 的 themeConfig(如 teekHome 等开关)
|
||||
...teekConfig.themeConfig,
|
||||
@@ -35,13 +40,39 @@ export default defineConfig({
|
||||
{ text: 'GitHub', link: 'https://github.com/nomdn/ipw-cn', target: '_blank' },
|
||||
],
|
||||
|
||||
// 右侧目录:显示 h2 + h3(默认只显示 h2)
|
||||
outline: {
|
||||
level: [2, 3],
|
||||
label: '本页目录',
|
||||
},
|
||||
|
||||
sidebar: [
|
||||
// 上级分组:部署(新增页面时在 items 里补一条即可)
|
||||
// 简介
|
||||
{
|
||||
text: '简介',
|
||||
items: [
|
||||
{ text: '概述', link: '/info/overview' },
|
||||
{ text: '功能总览', link: '/info/features' },
|
||||
{ text: '快速入门', link: '/guide/getting-started' },
|
||||
{ text: '配置文件', link: '/guide/config' },
|
||||
],
|
||||
},
|
||||
// 部署
|
||||
{
|
||||
text: '部署',
|
||||
items: [
|
||||
{ text: '部署指南', link: '/guide/' },
|
||||
{ text: '前端部署', link: '/guide/deploy-frontend' },
|
||||
{ text: '后端节点', link: '/guide/deploy-node' },
|
||||
{ text: '中间件', link: '/guide/deploy-middleware' },
|
||||
{ text: '前端', link: '/guide/deploy-frontend' },
|
||||
],
|
||||
},
|
||||
// 参考
|
||||
{
|
||||
text: '参考',
|
||||
items: [
|
||||
{ text: '节点 API 与协议', link: '/guide/node-api' },
|
||||
{ text: '公开 API(/api/v1)', link: '/guide/public-api' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
# 配置文件
|
||||
|
||||
各组件配置说明与完整字段参考。快速上手示例见 [快速开始](/guide/getting-started)。
|
||||
|
||||
## 后端(setting.json)
|
||||
|
||||
后端从当前目录读取 `setting.json`,不存在时使用默认值。最小配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"port": 8080,
|
||||
"dns-server": "119.28.28.28:53",
|
||||
"block-private-ips": true,
|
||||
"ipdb": true,
|
||||
"cors": "",
|
||||
"access-token": ""
|
||||
}
|
||||
```
|
||||
|
||||
- `port`:监听端口
|
||||
- `dns-server`:DNS 解析服务器,支持**主从 failover**——逗号分隔多地址(如 `"119.28.28.28:53,223.5.5.5:53"`),第一个为主,主查询失败(超时/网络错误)自动切换下一个;每项支持 `ip:port`(UDP)或 `https://` URL(DoH);**留空则启动时自动探测系统 DNS**(`IP:Port` 逗号分隔串)兜底
|
||||
- `dnssec-server`:**DNSSEC 专用** DNS 服务器(同样支持主从逗号分隔);留空则沿用 `dns-server`;`dns-server` 与 `dnssec-server` **都留空时自动探测系统 DNS** 兜底
|
||||
- `block-private-ips`:SSRF 防护开关(拒绝出站连接内网/私有地址;探测目标命中私有网段时直接返回伪造结果,禁止跨跳重定向)
|
||||
- `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` 路由不注册,适合只做网络拨测的轻量节点
|
||||
- `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` / `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"` 也是开)。现在四个开关统一按上表判定。
|
||||
|
||||
### 运行时配置管理
|
||||
|
||||
除启动加载外,节点(v1.4+)还支持被**远程读写当前生效配置**——收集中心控制台可以改配置而不必登录节点主机,节点也可以在运行中刷新远端配置。
|
||||
|
||||
| 动作 | HTTP | WS | 说明 |
|
||||
|------|------|----|------|
|
||||
| 读取 | `GET /v1/config` | `config` + `action=get` | 返回生效配置快照 + `secretKeys` + `restartRequiredKeys` |
|
||||
| 修改 | `PATCH /v1/config`(`?persist=1` 时写回本地 setting.json) | `config` + `action=patch` | 逐键应用,只改传入的键 |
|
||||
| 刷新远端 | `POST /v1/config/refresh` | `config` + `action=refresh` | 重新拉取 `remote-config-url` 并应用 |
|
||||
|
||||
三条约定:
|
||||
|
||||
- **凭据类键不回显明文**:`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` / `access-log` / `max-response-body` / `memory-limit` 每次消费重读,同样即时生效。
|
||||
- **内存改动会被重启顶掉**:节点侧优先级为 远端 > 环境变量 > `setting.json`。只 PATCH 内存(或 `persist` 写本地文件)时,重启后仍可能被 ENV 顶掉;要长期生效应 **`refresh`**(改远端配置)或把改动合并进托管配置。
|
||||
|
||||
> [!NOTE]
|
||||
> **与托管配置的区别**:托管配置(收集中心的 `node_configs`,经 `GET /remote-config/:nodeId` 下发)是**节点启动时拉的远端配置**,是持久来源;运行时配置读写的是**节点进程内存里当前生效的值**。前者决定"重启后是什么",后者反映"现在是什么"。
|
||||
|
||||
接口细节见 [节点 API 与协议参考](/guide/node-api) 的「管理接口」一节。
|
||||
|
||||
---
|
||||
|
||||
## 前端(config/index.ts)
|
||||
|
||||
前端配置在 `frontend-ssr/config/index.ts`,部署前按需修改。完整配置项如下:
|
||||
|
||||
| 配置项 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| `siteUrl` | string | 站点对外地址 |
|
||||
| `siteName` | string | 站点名称(页面标题 / 描述 / 页脚品牌) |
|
||||
| `EnableInternalMiddleware` | boolean | 是否启用前端内置中间件(作为 `/middleware/*` 候选最后一位兜底),默认 `true` |
|
||||
| `rateLimitPerMinute` | number | 内置中间件单 IP 限流次数(次/分钟),默认 `120`,`0` 表示不限流 |
|
||||
| `Middleware` | string[] | 外部独立中间件 base URL 列表,`/middleware/*` 请求依次尝试、出错重试下一个 |
|
||||
| `umamiHost` / `umamiScriptUrl` / `umamiWebsiteId` | string | Umami 统计配置 |
|
||||
| `ICP` / `GongAn` | string | 网站备案号(页脚展示) |
|
||||
| `noindex` | boolean | 全站是否禁止搜索引擎索引 |
|
||||
| `v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` | string | IPv4 / IPv6 / 双栈 IP 查询接口 |
|
||||
| `APIBaseURL` | 节点池 | 拨测上游节点池(whois / ssl / detail / dns / dnssec / tcping / speed),含 `DualStack` / `IPv4` / `IPv6` 三栈 |
|
||||
| `IPLocationAPI` | 节点数组 | IP 归属地 / ASN 查询节点池(纯数组,无栈区分) |
|
||||
|
||||
完整模板(复制到 `frontend-ssr/config/index.ts`,替换占位值即可):
|
||||
|
||||
```ts
|
||||
const config = {
|
||||
siteUrl: "https://your-domain.com/", // 站点对外地址
|
||||
siteName: "你的站点名称", // 站点名称(页脚展示)
|
||||
// 是否启用前端内置中间件(本地转发,作为候选最后一位兜底)
|
||||
EnableInternalMiddleware: true,
|
||||
// 内置中间件单 IP 限流次数(次/分钟),0 表示不限流
|
||||
rateLimitPerMinute: 120,
|
||||
// 外部独立中间件 base URL 列表(可留空,仅用内置中间件)
|
||||
Middleware: <string[]>[],
|
||||
// Umami 统计(不需要留空)
|
||||
umamiHost: "",
|
||||
umamiScriptUrl: "",
|
||||
umamiWebsiteId: "",
|
||||
// 备案号(页脚展示,不需要留空)
|
||||
ICP: "",
|
||||
GongAn: "",
|
||||
// 是否禁止搜索引擎索引
|
||||
noindex: false,
|
||||
// 出站 IP 检测接口(页面直连需 CORS,建议 wsmdn.top 的三个)
|
||||
v4OnlyAPI: "https://4.wsmdn.top",
|
||||
v6OnlyAPI: "https://6.wsmdn.top",
|
||||
DualStackAPI: "https://test.wsmdn.top",
|
||||
// 拨测节点池(whois / ssl / detail / dns / dnssec / tcping / speed)
|
||||
// IPv4-only 节点只能放 IPv4 栈,IPv6-only 放 IPv6 栈,双栈放 DualStack
|
||||
APIBaseURL: {
|
||||
DualStack: [],
|
||||
IPv4: [
|
||||
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
|
||||
],
|
||||
IPv6: []
|
||||
},
|
||||
// IP 归属地 / ASN 查询节点池(纯数组,无栈区分)
|
||||
IPLocationAPI: [
|
||||
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
|
||||
]
|
||||
}
|
||||
export { config }
|
||||
```
|
||||
|
||||
> 节点 `id` 需与后端节点 / 中间件 `api-keys` 的键保持一致,中间件按 `id` 注入 `Authorization: Bearer <key>`;WS 通道的节点注册校验另见中间件 `ws-keys`(与后端 `node-key` 对应)。
|
||||
|
||||
节点数组项均为 `{ label, id, url }` 结构,例如:
|
||||
|
||||
```ts
|
||||
APIBaseURL: {
|
||||
DualStack: [
|
||||
{ label: "中国 江苏 移动", id: "cn-jiangsu", url: "https://cn-jiangsu.api-ipw.wsmdn.top/" }
|
||||
],
|
||||
IPv4: [],
|
||||
IPv6: []
|
||||
}
|
||||
```
|
||||
|
||||
`label` 为节点展示名,`id` 为节点唯一标识(中间件 `api-keys` 按此 id 注入 HTTP 转发 key,WS 注册校验另见 `ws-keys`),`url` 为节点 base URL。节点池结构如下:`APIBaseURL` 为拨测节点池,含 `DualStack` / `IPv4` / `IPv6` 三栈(分别对应双栈 / 单栈测试时使用的节点列表,IPv4-only 节点只能放 `IPv4` 栈,IPv6-only 只能放 `IPv6` 栈,不能放 `DualStack`);`IPLocationAPI` 为 IP 归属地 / ASN 查询节点池,为纯数组(无栈区分)。
|
||||
|
||||
### 出站 IP 检测接口(v4OnlyAPI / v6OnlyAPI / DualStackAPI)
|
||||
|
||||
`v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` 用于获取请求方的出站 IP(分别对应 IPv4、IPv6、双栈),首页与部分页面会直接请求。可直接使用的接口:
|
||||
|
||||
| 接口 | 类型 |
|
||||
|------|------|
|
||||
| `https://4.wsmdn.top` | IPv4 |
|
||||
| `https://6.wsmdn.top` | IPv6 |
|
||||
| `https://test.wsmdn.top` | 双栈 |
|
||||
| `https://ipv4.ip.sb` | IPv4 |
|
||||
| `https://ipv6.ip.sb` | IPv6 |
|
||||
| `https://ip.sb` | 双栈 |
|
||||
| `https://4.itdog.cn` | IPv4 |
|
||||
| `https://6.itdog.cn` | IPv6 |
|
||||
| `https://v.itdog.cn` | 双栈 |
|
||||
|
||||
> **CORS 说明**:以上接口仅 `4.wsmdn.top` / `6.wsmdn.top` / `test.wsmdn.top` 返回 `Access-Control-Allow-Origin: *`,其余(ip.sb、itdog.cn)不带 CORS 头。前端页面在客户端导航时会从浏览器直接请求这些接口,**没有 CORS 会被浏览器拦截**,因此页面直连检测建议使用 wsmdn.top 的三个;ip.sb / itdog.cn 适合在服务端或命令行场景使用。
|
||||
|
||||
---
|
||||
|
||||
## 前端内置中间件(NUXT_APIKEYS)
|
||||
|
||||
前端内置中间件(`frontend-ssr/server/routes/middleware/[...slug].get.ts`)作为 `/middleware/*` 候选列表的最后一位兜底,转发时也会注入 key:
|
||||
|
||||
- 读取方式:`useRuntimeConfig(event).apiKeys`,按请求的 `backendID`(节点 `id`)查找
|
||||
- 环境变量:**`NUXT_APIKEYS`**(Nuxt 约定 `NUXT_` + 配置键 `apiKeys`)
|
||||
- 格式:JSON 字符串,例如 `NUXT_APIKEYS='{"cn-jiangsu":"your-key"}'`
|
||||
- 注入方式:转发请求头 `Authorization: Bearer <key>`
|
||||
|
||||
也可以在 `nuxt.config.ts` 的 `runtimeConfig.apiKeys` 里直接写死(不推荐,密钥不应进仓库)。
|
||||
|
||||
### 内置中间件数据上报(调一次上报一次)
|
||||
|
||||
内置中间件可作为数据上报入口:每转发一次请求,向收集中心(ipw-boce 中间件)`POST /report` 上报一次统计与拨测明细。**配置全部经环境变量注入**(敏感项不进仓库):
|
||||
|
||||
| 环境变量 | 说明 |
|
||||
|------|------|
|
||||
| `NUXT_BOCE_REPORT_URL` | 收集中心基址(如 `https://collector.example.com`);**留空 = 不上报**(此时转发也不带标记头,由节点兜底记账) |
|
||||
| `NUXT_BOCE_REPORT_TOKEN` | 收集中心 `/report` 鉴权 token(敏感,与收集中心 `report-token` 配置一致) |
|
||||
| `NUXT_BOCE_REPORT_INSTANCE` | 上报方标识(存收集库 `probe_results.origin`);默认 `frontend-ssr` |
|
||||
|
||||
行为约定:
|
||||
|
||||
- 配置了上报后,内置中间件转发请求会带 `X-Boce-Reporter` 标记头——后端节点据此跳过这些请求的统计(防双算,见后端节点"数据上报"一节)
|
||||
- 上报为 fire-and-forget:失败静默丢弃、不重试(统计是增量累加语义,重试会双算),不影响转发响应
|
||||
- 上报失败不影响主链路;超时 5 秒
|
||||
|
||||
---
|
||||
|
||||
## 独立中间件(middleware-go)
|
||||
|
||||
中间件配置在 `middleware-go/setting.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"port": "8091",
|
||||
"http-timeout-seconds": 30,
|
||||
"cors": "",
|
||||
"trusted-proxies": "",
|
||||
"gh-proxy": "",
|
||||
"ota": "false",
|
||||
"rate-limit": 120,
|
||||
"ws-port": 8092,
|
||||
"remote-config-url": "",
|
||||
"remote-ignore-config": [],
|
||||
"api-base-url": {
|
||||
"DualStack": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
|
||||
"IPv4": [],
|
||||
"IPv6": []
|
||||
},
|
||||
"ip-location-api": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
|
||||
"api-keys": { "cn-jiangsu": "" },
|
||||
"ws-keys": { "cn-jiangsu": "" }
|
||||
}
|
||||
```
|
||||
|
||||
- `rate-limit`:单 IP 每分钟限流次数,默认 `120`,`0` 表示不限流(也可用环境变量 `RATE_LIMIT` 覆盖)
|
||||
- `trusted-proxies`:可信代理 IP/CIDR(逗号分隔)。配置后 `c.IP()`(限流按它分桶)只信任这些代理转发的 `X-Forwarded-For`,反代/CDN 后限流才按真实客户端 IP 计数;留空保持默认(不信任 XFF 头,按直连地址限流)。也可用环境变量 `TRUSTED_PROXIES` 覆盖
|
||||
- `gh-proxy`:GitHub 代理前缀(OTA 下载加速,如 `"https://ghproxy.com/"`),可空;也可用环境变量 `GH_PROXY` 覆盖
|
||||
- `ota`:**OTA 自更新开关**(`"true"` 启用)。启用后中间件定期检查本仓库 GitHub Release,发现新版本时下载匹配的 `middleware-go-*` 资产、预检、**替换自身并重启**(Windows 下为优雅交接,失败自动回滚旧版;major 版本升级需人工);默认关闭。也可用环境变量 `OTA` 覆盖
|
||||
- `ws-port`:WS 服务端口,默认 `8092`,`0` 表示关闭 WS 通道(也可用环境变量 `WS_PORT` 覆盖;优先级:远端配置 > 环境变量 > setting.json)
|
||||
- `remote-config-url`:远端配置地址,也可用环境变量 `REMOTE_CONFIG_URL`(环境变量优先)
|
||||
- `remote-ignore-config`:**不被远端配置覆盖的配置项列表**(数组),如 `["port", "rate-limit"]`;也可用环境变量 `REMOTE_IGNORE_CONFIG`(JSON 数组字符串,优先于 setting.json)
|
||||
- `api-keys`:用于向后端注入 `Authorization: Bearer <key>`,优先级:setting.json `api-keys` > 环境变量 `API_KEYS`(JSON 字符串)。**敏感凭据,强制忽略,不随远端配置覆盖**
|
||||
- `ws-keys`:WS 注册校验表(`节点id → key`):节点经 WS 注册时,`register` 消息的 `key` 必须与 `ws-keys[节点id]` 一致,否则返回 401 并断开;优先级:setting.json `ws-keys` > 环境变量 `WS_KEYS`(JSON 字符串)。**敏感凭据,强制忽略,不随远端配置覆盖**。与 `api-keys` **相互独立**——`api-keys` 管 HTTP 转发鉴权,`ws-keys` 管 WS 注册校验,可分别配置不同 key(例如 WS 节点单独换 key 不影响 HTTP 转发)
|
||||
- 节点池与前端 `config/index.ts` 结构一致:`APIBaseURL` 为拨测节点池(含 `DualStack` / `IPv4` / `IPv6` 三栈),`IPLocationAPI` 为 IP 归属地 / ASN 节点池(纯数组,无栈);节点加 `"ws": true` 可让该节点走 WS 通道通信,详见 [中间件部署](/guide/deploy-middleware) 的「WS 通道」一节
|
||||
|
||||
### WS 通道配置一览
|
||||
|
||||
WS 通道涉及**中间件(服务端)**与**后端节点(客户端)**两侧配置:
|
||||
|
||||
| 角色 | 配置项 | 环境变量 | 说明 |
|
||||
|------|--------|----------|------|
|
||||
| 中间件(服务端) | `ws-port` | `WS_PORT` | WS 监听端口,默认 `8092`,`0`=关闭;节点连 `ws(s)://<中间件>:<ws-port>/ws` |
|
||||
| 中间件(服务端) | `ws-keys` | `WS_KEYS` | WS 注册校验表:`{"<node-id>": "<key>"}`,节点 `register` 的 key 必须匹配 |
|
||||
| 节点(客户端) | `ws-url` | `WS_URL` | 中间件 WS 完整地址(含 `/ws` 路径),逗号分隔多个则**同时连接全部(多活)** |
|
||||
| 节点(客户端) | `node-id` | `NODE_ID` | 节点 id,与中间件 `api-keys`/`ws-keys` 键一致(建议 UUID) |
|
||||
| 节点(客户端) | `node-key` | `NODE_KEY` | 注册 key,与中间件 `ws-keys[节点id]` 一致,**必填** |
|
||||
|
||||
**数据上报通道**(节点把自身观测的统计/拨测上报到收集中心,归属规则防双算——中间件转发的请求由中间件上报,节点只报直连/前端/边缘流量):
|
||||
|
||||
| 角色 | 配置项 | 环境变量 | 说明 |
|
||||
|------|--------|----------|------|
|
||||
| 节点(客户端) | `report-url` | `REPORT_URL` | 收集中心(ipw-boce 中间件)HTTP 基址;WS 在线时优先走 WS 广播,掉线时 POST `<report-url>/report` 兜底 |
|
||||
| 节点(客户端) | `report-token` | `REPORT_TOKEN` | `/report` 鉴权 token,与收集中心 `report-token` 一致;属凭据类键,快照遮蔽为 `***`,**不随远端配置覆盖** |
|
||||
| 节点(客户端) | `report-interval-seconds` | `REPORT_INTERVAL_SECONDS` | 上报周期秒,缺省 15 |
|
||||
|
||||
**链路**:中间件收到 `ws:true` 节点的拨测请求 → 经 WS 发 `probe` → 节点执行探针(直调 webtest 函数 + 缓存)→ `probe_result` 回传 → 中间件返回 HTTP 响应(`Content-Type: application/json`)。节点侧接入步骤见 [后端节点部署](/guide/deploy-node) 的「WS 通道接入」一节。
|
||||
|
||||
---
|
||||
|
||||
## 远端配置(REMOTE_CONFIG_URL)
|
||||
|
||||
**什么是远端配置**:把配置文件放到一个可访问的 URL 上(如 Gist、对象存储、静态托管、任意 HTTP 服务),部署时只需设置一个环境变量,之后改配置**不用重新部署、不用重启**,下次启动自动拉取最新配置。适合多节点统一管理、批量更新节点配置的场景。
|
||||
|
||||
**支持的组件**(三个组件均已支持):
|
||||
|
||||
| 组件 | 触发方式 | 远端配置格式 |
|
||||
|------|----------|--------------|
|
||||
| 后端(主线 `src/main.go`) | `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` | 后端 `setting.json` 格式 |
|
||||
| 后端(`serverless/edgeone/`) | `REMOTE_CONFIG_URL` | 后端 `setting.json` 格式 |
|
||||
| 独立中间件(middleware-go) | `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` | 中间件 `setting.json` 格式 |
|
||||
|
||||
**合并规则**(重要,逐字段生效):
|
||||
|
||||
- **优先级:远端配置 > 环境变量 > setting.json**
|
||||
- 远端 JSON 中**非空的字段才覆盖**本地值:字符串非空、数组非空、数字非 0(`rate-limit` 为指针语义,缺省=不覆盖,显式 0=不限流)
|
||||
- 部分字段缺省不会冲掉本地配置——远端只需包含要覆盖的字段
|
||||
- **敏感凭据强制忽略,永远不被远端覆盖**(代码写死,无需配置):
|
||||
- 后端 `access-token`:不随远端覆盖(保持 环境变量 > setting.json),远端里的 `access-token` 会被忽略
|
||||
- 后端 `report-token`:同上。收集中心的托管配置里写了也不生效,节点会在日志与控制台应答的 `protectedIgnored` 里列出被跳过的键
|
||||
- 中间件 `api-keys` / `ws-keys`:不随远端覆盖,远端里的 `api-keys`/`ws-keys` 会被忽略
|
||||
|
||||
为什么把这两项写死:它们被远端改错时的故障形态都是「远端一错、本地失联、且无法再远程修回」——`report-token` 被改 → 上报被收集中心 `/report` 拒收,控制台上节点仍显示在线、数据却静默断了;`access-token` 被改 → 控制台再也连不上该节点的管理面,连改回去的通道都没了。所以这层解耦必须由节点自己兜底,不能依赖「配置源别写错」。`node-key` 有意**不在**保护名单里(托管部署下常由远端统一下发节点身份),需要一并锁死时把它写进 `remote-ignore-config`。保护名单只增不减,且**只约束远端下发**,本地 `PATCH` 不受影响。
|
||||
- `remote-ignore-config`(后端键名 `remote-ignore-config` / env `REMOTE_IGNORE_CONFIG`):额外指定**不被远端覆盖的配置项列表**,数组中的键即使远端下发也不生效。适用于 Node 身份类与其它不想被远端左右的项(如 `node-key`、`dns-server` 等),也可覆盖"非空才覆盖"规则
|
||||
|
||||
**拉取行为**:
|
||||
|
||||
- 在**进程启动时的配置加载阶段**一次性拉取
|
||||
- HTTP 超时 **10 秒**,仅接受 **200** 响应(404/5xx 视为失败)
|
||||
- 拉取或 JSON 解析失败:打印警告日志 `WARN failed to fetch remote config, fallback to local: ...`,**自动回退本地配置**,服务正常启动,不会崩溃
|
||||
- 拉取成功:启动日志打印 `remote config applied from <url>`
|
||||
- **启动时拉取一次,运行中可手动刷新**:进程启动的配置加载阶段拉取一次;此后不会自动轮询远端,但可经 `POST /v1/config/refresh`(或 WS `config` + `action=refresh`)**主动重新拉取并应用**,无需重启——收集中心控制台的「刷新远端」按钮即走这条路
|
||||
|
||||
**示例一:后端**(主线,远端 JSON 放在 `https://example.com/ipw-config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"port": 8080,
|
||||
"single-stack": "ipv4",
|
||||
"dns-server": "119.28.28.28:53",
|
||||
"cors": "https://ipw.wsmdn.top",
|
||||
"remote-config-url": ""
|
||||
}
|
||||
```
|
||||
|
||||
部署时:
|
||||
|
||||
```bash
|
||||
REMOTE_CONFIG_URL=https://example.com/ipw-config.json ./lemonipw
|
||||
```
|
||||
|
||||
**示例二:独立中间件**(middleware-go,远端 JSON 放在 `https://example.com/mw-config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"port": "8091",
|
||||
"cors": "",
|
||||
"trusted-proxies": "",
|
||||
"rate-limit": 120,
|
||||
"api-base-url": {
|
||||
"DualStack": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
|
||||
"IPv4": [],
|
||||
"IPv6": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> 敏感凭据不得上传远端:`api-keys`/`ws-keys`(及后端 `access-token`、节点 key 等密钥)**禁止**写入远端配置——即使写了也会被强制忽略。凭据只放本地 setting.json 或环境变量。
|
||||
|
||||
部署时:
|
||||
|
||||
```bash
|
||||
REMOTE_CONFIG_URL=https://example.com/mw-config.json ./middleware-go-linux-amd64
|
||||
```
|
||||
|
||||
systemd 中同样用 `Environment="REMOTE_CONFIG_URL=..."` 配置。
|
||||
|
||||
**如何验证生效**:
|
||||
|
||||
- 启动日志出现 `remote config applied from <url>` 即拉取成功
|
||||
- 用 `curl -i <远端URL>` 确认远端返回 `200` 且是合法 JSON
|
||||
- 实际操作验证:在远端把 `port` 改成另一个值,重启进程,观察监听端口是否变化
|
||||
|
||||
**安全与排查**:
|
||||
|
||||
- **敏感凭据不得上传远端**:远端配置(Gist / 对象存储 / 静态托管等)**禁止包含任何密钥**——`api-keys`/`ws-keys`、`access-token`、节点 key(`node-key`)等。这些值会被强制忽略,但远端文件本身一旦泄露等于直接暴露凭据;凭据一律放本地 `setting.json` 或环境变量注入
|
||||
- 远端 URL 必须能被节点访问;优先使用 **HTTPS** 地址
|
||||
- 远端配置文件如果放在公开可访问的位置(公开 Gist、无鉴权对象存储),即便不含密钥,也等于把节点拓扑、节点 id 等敏感信息公之于众,建议放在私有仓库 / 带鉴权的对象存储 / 内部服务上
|
||||
- 常见失败排查:
|
||||
|
||||
| 现象 | 原因 | 排查 |
|
||||
|------|------|------|
|
||||
| 日志 `WARN ... status 404` | URL 路径错误或远端文件不存在 | `curl -i <URL>` 检查返回码 |
|
||||
| 日志 `WARN ... timeout` / 连接失败 | 网络不通或超时 | 确认节点可访问该 URL |
|
||||
| 日志 `WARN ... invalid ... JSON` | 远端内容不是合法 JSON 或格式不符 | 检查 JSON 合法性、字段名与组件格式一致 |
|
||||
| 启动日志无 remote 相关输出 | `REMOTE_CONFIG_URL` 未设置 | `env \| grep REMOTE` 确认环境变量已注入 |
|
||||
@@ -1,6 +1,6 @@
|
||||
# 前端部署
|
||||
|
||||
前端(`frontend-ssr/`)是全项目的核心,负责数据可视化与接口调用。基于 **Nuxt 4 SSR** 构建,部署到 **Cloudflare Workers**(nitro `cloudflare_module` preset)。
|
||||
前端(`frontend-ssr/`)是全项目的核心,负责数据可视化与接口调用。基于 **Nuxt 4 SSR** 构建。
|
||||
|
||||
## 架构与部署目标
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
| 技术栈 | Nuxt 4 + Vue 3 + Element Plus + VueUse |
|
||||
| 运行时 | Node.js 22(见 `.node-version`)、包管理器 pnpm 10 |
|
||||
| 构建产物 | `.output/server/index.mjs` + `.output/public/`(静态资源) |
|
||||
| 部署目标 | Cloudflare Workers(SSR),也可部署到其他符合 Serverless 标准的平台 |
|
||||
| 部署配置 | `frontend-ssr/wrangler.jsonc` |
|
||||
| 构建命令 | `pnpm build`(Nitro SSR 构建) |
|
||||
|
||||
## 准备工作
|
||||
|
||||
@@ -27,40 +26,46 @@ pnpm dev
|
||||
|
||||
## 配置说明
|
||||
|
||||
前端所有配置集中在 `frontend-ssr/config/index.ts`:
|
||||
前端所有配置集中在 `frontend-ssr/config/index.ts`,完整配置项见 [配置文件](/guide/config) 的「前端」一节:
|
||||
|
||||
| 配置项 | 说明 |
|
||||
|--------|------|
|
||||
| `siteUrl` | 站点对外地址 |
|
||||
| `siteName` | 站点名称(页面标题 / 描述 / 页脚品牌) |
|
||||
| `apiBaseUrls` | 后端上游节点列表(whois / ssl / detail) |
|
||||
| `IPLocationAPIs` | IP 归属地 / ASN 上游节点列表 |
|
||||
| `TCPing` / `SpeedTest` / `NSLookup` | 对应功能的多节点候选(DualStack / IPv4 / IPv6) |
|
||||
| `APIBaseURL` | 拨测上游节点池(whois / ssl / detail / dns / dnssec / tcping / speed),含 `DualStack` / `IPv4` / `IPv6` 三栈 |
|
||||
| `IPLocationAPI` | IP 归属地 / ASN 上游节点池(纯数组,无栈区分) |
|
||||
| `Middleware` | 外部独立中间件 base URL 列表,`/middleware/*` 请求依次尝试、失败重试下一个 |
|
||||
| `EnableInternalMiddleware` | 是否启用前端内置中间件(本地转发,作为候选列表最后一位兜底),默认 `true` |
|
||||
| `v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` | Worker IP 查询接口 |
|
||||
| `rateLimitPerMinute` | 内置中间件单 IP 限流次数(次/分钟),默认 `120`,`0` 表示不限流 |
|
||||
| `v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` | 出站 IP 检测接口(页面直连建议使用带 CORS 的 wsmdn.top 接口) |
|
||||
| `umamiHost` 等 | Umami 统计配置 |
|
||||
| `ICP` / `GongAn` | 网站备案号(页脚展示) |
|
||||
| `noindex` | 是否禁止搜索引擎索引 |
|
||||
|
||||
> 生产环境建议通过构建时的环境变量或部署平台配置覆盖敏感项,不要把密钥写进 `config/index.ts` 提交到仓库。
|
||||
> 生产环境建议通过运行时的环境变量或部署平台配置覆盖敏感项,不要把密钥写进 `config/index.ts` 提交到仓库。
|
||||
|
||||
## 构建与部署
|
||||
## 部署方案
|
||||
|
||||
### 本地构建
|
||||
### 方案一:EdgeOne
|
||||
|
||||
腾讯云 EdgeOne 原生支持 Nuxt,点击下方按钮即可通过 **EdgeOne Makers** 一键部署(无需手动配置构建):
|
||||
|
||||
[](https://console.cloud.tencent.com/edgeone/makers/new?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=frontend-ssr&install-command=pnpm%20install&build-command=pnpm%20run%20build&output-directory=.output)
|
||||
|
||||
|
||||
|
||||
### 方案二:Vercel
|
||||
|
||||
点击下方按钮一键导入并部署到 Vercel(自动识别 Nuxt 项目,`root-directory` 已预置为 `frontend-ssr`):
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=frontend-ssr&install-command=pnpm%20install&build-command=pnpm%20run%20build&output-directory=.output)
|
||||
### 方案三:Cloudflare Workers
|
||||
|
||||
使用 wrangler 手动部署:
|
||||
|
||||
```bash
|
||||
# 构建(默认 preset 为 cloudflare_module,可由 NITRO_PRESET 覆盖)
|
||||
cd frontend-ssr
|
||||
pnpm build
|
||||
|
||||
# 本地预览(构建 + wrangler dev)
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
### 部署到 Cloudflare Workers
|
||||
|
||||
```bash
|
||||
# 构建 + 发布
|
||||
pnpm deploy # 等价于 pnpm build && wrangler deploy
|
||||
```
|
||||
|
||||
@@ -75,34 +80,19 @@ export CLOUDFLARE_ACCOUNT_ID=your_account_id
|
||||
npx wrangler login
|
||||
```
|
||||
|
||||
`wrangler.jsonc` 关键配置:
|
||||
> Cloudflare 部署按钮不支持子目录,而前端位于仓库 `frontend-ssr/` 子目录,因此不提供一键部署按钮;使用上方 wrangler 命令或仓库 CI/CD 工作流部署。
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "ipw-cn", // Worker 名称
|
||||
"main": "./.output/server/index.mjs", // SSR 入口
|
||||
"assets": { "binding": "ASSETS", "directory": "./.output/public/" },
|
||||
"compatibility_flags": ["nodejs_compat"] // 需要 Node.js 兼容层
|
||||
}
|
||||
### 方案四:直接运行(Node 自托管)
|
||||
|
||||
前端是 Node.js 应用,构建产物为 JS 脚本 + 静态资源(非二进制),可直接用 Node 运行:
|
||||
|
||||
```bash
|
||||
cd frontend-ssr
|
||||
pnpm build
|
||||
node .output/server/index.mjs # 默认监听 3000 端口
|
||||
```
|
||||
|
||||
## 一键部署到 EdgeOne Makers
|
||||
|
||||
腾讯云 EdgeOne 原生支持 Nuxt,点击下方按钮即可通过 **EdgeOne Makers** 一键部署本前端(无需手动配置构建):
|
||||
|
||||
[](https://console.cloud.tencent.com/edgeone/makers/new?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=frontend-ssr&install-command=pnpm%20install&build-command=pnpm%20run%20build&output-directory=.output)
|
||||
|
||||
按钮已预置以下参数:
|
||||
|
||||
| 参数 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| `repository-url` | `https://github.com/nomdn/ipw-cn` | GitHub 仓库地址 |
|
||||
| `root-directory` | `frontend-ssr` | 构建根目录 |
|
||||
| `install-command` | `pnpm install` | 依赖安装命令 |
|
||||
| `build-command` | `pnpm run build` | 构建命令(Nuxt 默认 SSR 构建) |
|
||||
| `output-directory` | `.output` | 构建产物输出目录(Nitro SSR 产物) |
|
||||
|
||||
> 说明:EdgeOne Makers 原生支持 Nuxt 部署,**SSR / SSG / ISR(SWR) / 中间件 / 流式传输**均支持(Nuxt 3.16+,推荐 Nuxt 4),按钮默认以 **SSR** 方式构建。如需纯静态部署,可将 `build-command` 改为 `pnpm run generate`、`output-directory` 改为 `.output/public`。
|
||||
生产环境建议使用进程守护(systemd / pm2 等)保持常驻。
|
||||
|
||||
## CI/CD 自动部署
|
||||
|
||||
@@ -111,14 +101,13 @@ npx wrangler login
|
||||
- **触发条件**:push 到 `main` 且改动路径为 `frontend-ssr/**`;或手动 `workflow_dispatch`
|
||||
- **步骤**:checkout → Node 22 + pnpm 10 → `pnpm install` → `pnpm run deploy --keep-vars`
|
||||
- **所需 Secrets**:`CLOUDFLARE_API_TOKEN`、`CLOUDFLARE_ACCOUNT_ID`
|
||||
- **注意**:工作流设置了 `NITRO_PRESET: cloudflare_module`,如本地构建异常可参考此值
|
||||
- **注意**:工作流设置了 `NITRO_PRESET: cloudflare_module`
|
||||
|
||||
## 缓存
|
||||
|
||||
缓存头不在应用侧设置,全部交由前置 CDN(EdgeOne)控制。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- **构建报 preset 相关错误**:确认构建环境变量 `NITRO_PRESET=cloudflare_module`。
|
||||
- **本地 `pnpm preview` 与线上行为不一致**:预览使用 `wrangler dev` 模拟 Workers 运行时,请确保 `nodejs_compat` 兼容标志已启用。
|
||||
- **部署后接口 403 / 跨域**:检查后端 `cors` 配置(独立中间件见 `middleware-go/setting.json` 的 `cors` 字段,逗号分隔允许域名);服务端转发请求不带 `Origin`,浏览器直接调用才受 CORS 限制。
|
||||
|
||||
## 其他部署目标
|
||||
|
||||
本项目前端同样支持部署到其他**符合 Serverless 标准**的平台(无状态、事件驱动、自动扩缩容),如 EdgeOne Pages 等。只需按目标平台的约定调整 nitro preset(例如 `nitro preset` 切换)并配置对应产物入口即可。
|
||||
- **出站 IP 检测接口被浏览器拦截**:页面直连的 IP 检测接口需要 CORS,详见 [配置文件](/guide/config) 的「出站 IP 检测接口」一节。
|
||||
@@ -0,0 +1,124 @@
|
||||
# 中间件部署
|
||||
|
||||
独立中间件(middleware-go)只负责**请求转发和 Key 注入**,不参与业务计算。前端将 `/middleware/*` 请求转发到中间件,由中间件转发到对应后端节点并注入 `Authorization: Bearer <key>`。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 项目只提供配置示例,**使用前必须先复制为 `setting.json`**(GitHub Releases 中对应文件为 `middleware.setting.json.example`):
|
||||
>
|
||||
> ```bash
|
||||
> # 仓库内
|
||||
> cp middleware-go/setting.json.example middleware-go/setting.json
|
||||
>
|
||||
> # 从 Release 下载后
|
||||
> cp middleware.setting.json.example setting.json
|
||||
> ```
|
||||
>
|
||||
> 复制后按需修改 `api-base-url` / `ip-location-api` / `api-keys` / `ws-keys` / `cors` 等配置项。
|
||||
|
||||
## 方案一:Docker
|
||||
|
||||
`middleware-go/` 目录提供 `Dockerfile`(多阶段构建,产物基于 Alpine,支持多架构):
|
||||
|
||||
```bash
|
||||
cd middleware-go
|
||||
docker build -t middleware-go .
|
||||
docker run -d -p 8091:8091 \
|
||||
-v $(pwd)/setting.json:/home/appuser/setting.json \
|
||||
middleware-go
|
||||
```
|
||||
|
||||
> 配置挂载注意:容器工作目录是 `/home/appuser`(非 root 用户运行),`setting.json` 要挂到这个路径下。
|
||||
|
||||
**多架构**:Dockerfile 已适配 buildx 交叉编译,本机构建其他架构:
|
||||
|
||||
```bash
|
||||
cd middleware-go
|
||||
docker buildx build --platform linux/arm64 -t middleware-go:arm64 --load .
|
||||
```
|
||||
|
||||
打 `v*` 版本标签发版时,CI 会自动构建 `linux/amd64` / `linux/arm64` / `linux/arm/v7` 三架构镜像(amd64 原生构建,arm64/armv7 在 GitHub 原生 ARM 实例上交叉构建,无 QEMU),推送 GHCR(`ghcr.io/<owner>/<repo>/lemon-ipw-middleware`;Docker Hub 推送在 workflow 中暂未启用,预留了注释可随时开启),Docker 按宿主机架构自动拉取对应变体。
|
||||
|
||||
配置通过挂载 `setting.json` 提供(`api-base-url` / `ip-location-api` / `api-keys` / `ws-keys` / `cors` / `trusted-proxies` / `rate-limit` / `remote-config-url` 等;`rate-limit` 为单 IP 每分钟限流次数,默认 120,0 表示不限流,可用环境变量 `RATE_LIMIT` 覆盖;`remote-config-url` 为远端配置地址,可用环境变量 `REMOTE_CONFIG_URL` 覆盖;**`api-keys` / `ws-keys` 为敏感凭据,不随远端配置覆盖**——`api-keys` 管 HTTP 转发鉴权,`ws-keys` 管 WS 注册校验,两者相互独立)。
|
||||
|
||||
## 方案二:二进制
|
||||
|
||||
```bash
|
||||
cd middleware-go
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o middleware-go-linux-amd64 .
|
||||
./middleware-go-linux-amd64 # 运行(与 setting.json 同目录)
|
||||
```
|
||||
|
||||
**OTA 自更新**(可选,默认关闭):配置 `"ota": "true"`(或环境变量 `OTA=true`)后,中间件定期检查本仓库 Release,下载与当前平台匹配的 `middleware-go-*` 资产并替换自身重启。下载加速可配 `"gh-proxy": ""`(或 `GH_PROXY` 环境变量)。交接流程与后端节点一致:预检 → 原子替换 → 优雅停机 → 拉起新进程 → 健康检查确认;失败自动回滚 `.old` 备份。
|
||||
|
||||
所有配置可用环境变量覆盖(`API_BASE_URLS` / `IP_LOCATION_APIS` / `CORS` / `TRUSTED_PROXIES` / `API_KEYS` / `WS_KEYS` / `RATE_LIMIT` 等,数组/对象用 JSON 字符串),优先级:**环境变量 > setting.json > 默认值**(远端配置 `REMOTE_CONFIG_URL` 高于两者,详见 [配置文件](/guide/config) 的「远端配置」一节)。
|
||||
|
||||
需要守护运行时,参考 [后端节点部署](/guide/deploy-node) 的「方案五:一键安装」一节的 systemd 管理方式(`ExecStart` 指向中间件二进制,`WorkingDirectory` 指向 `middleware-go/setting.json` 所在目录)。
|
||||
|
||||
## WS 通道(拨测数据经 WebSocket 传输)
|
||||
|
||||
middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入,拨测数据上传改用 WS 通道传输(HTTP 转发保留,双通道并存)。**现有 HTTP 接口(`/v1/*` / `/middleware/*`)完全不变**。
|
||||
|
||||
### 架构
|
||||
|
||||
```
|
||||
前端(HTTP) → middleware-go(8091,HTTP 转发)→ 后端节点(ws:false 时)
|
||||
↘ middleware-go(8092,WS 服务端)→ 后端节点(ws:true 时,WS 客户端连入)
|
||||
```
|
||||
|
||||
- WS 服务端跑在**独立端口**(配置 `ws-port`,默认 `8092`,路径 `/ws`;`ws-port: 0` 或环境变量 `WS_PORT=0` 关闭),与 fiber HTTP 服务完全分离;端口走统一配置管道(远端配置 > 环境变量 > setting.json)
|
||||
- 节点配置加 `"ws": true`(兼容 `"ws": "true"` 字符串写法)→ 该节点的拨测请求改走 WS 通道;缺省 `false` → 原 HTTP 转发不变
|
||||
- ws:true 节点未连接或拨测超时 → 返回 502,不影响其他节点
|
||||
|
||||
### 配置示例
|
||||
|
||||
```json
|
||||
{
|
||||
"port": "8091",
|
||||
"APIBaseURL": {
|
||||
"DualStack": [ { "label": "上海 腾讯云 BGP", "id": "tencent-sh", "url": "", "ws": true } ],
|
||||
"IPv4": [],
|
||||
"IPv6": [ { "label": "河北 秦皇岛 联通", "id": "cn-hebei-qinhuangdao", "url": "https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/", "ws": true } ]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
启动后日志会打印 `ws channel enabled on port 8092`。
|
||||
|
||||
### 消息协议
|
||||
|
||||
所有消息为 JSON 文本帧,信封:`{ "type": "...", "nodeId": "...", "ts": <unix秒>, "data": {...} }`
|
||||
|
||||
| 方向 | type | data 说明 |
|
||||
|------|------|-----------|
|
||||
| 节点 → middleware | `register` | `{ "nodeId": "...", "key": "...", "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 → 节点 | `ping` | 心跳(每 20s),节点回 `pong` |
|
||||
| middleware → 节点 | `status` | 状态/统计上报:`{ "uptimeSeconds": ..., "totalRequests": ..., "errorRequests": ... }`(每 20s) |
|
||||
|
||||
### 节点侧接入(WS 客户端)
|
||||
|
||||
1. 连接 `ws://<中间件IP>:8092/ws`(本仓库后端节点接入方式见 [后端节点部署](/guide/deploy-node) 的「WS 通道接入」一节,配置 `WS_URL` / `NODE_ID` / `NODE_KEY` 即可自动连接注册;`WS_URL` 支持逗号分隔多个中间件地址,节点会**同时连接全部(多活)**,任一断开只重连自己)
|
||||
2. 首条消息发 `register { "nodeId": "<与中间件配置一致的节点 id>", "key": "<注册凭证>" }`(节点在 setting.json `ws-keys` 里配了 key 就必须传对;未配置 key 的开放节点可不传)
|
||||
3. 收到 `probe` → 执行拨测 → 回 `probe_result`(必须携带原 `requestId`,支持乱序返回,同一连接可并发多个拨测)
|
||||
4. 收到 `ping` → 回 `pong`;`status` 可忽略或记录
|
||||
5. 断线后重连并重新 `register` 即可
|
||||
|
||||
### 注册 key 校验
|
||||
|
||||
节点注册时中间件按 `ws-keys` 配置校验 key(与 `api-keys` HTTP 转发鉴权相互独立):
|
||||
|
||||
- 节点在 `ws-keys` 配置了 key(非空)→ `register` 必须携带正确 key,不传或传错返回 `register_error { "code": 401, "command": "invalid key" }` 并**断开连接**,不进入数据阶段
|
||||
- 节点未配置 key(`ws-keys` 无该项或为空)→ 开放注册,不传 key 即可
|
||||
|
||||
与 HTTP 通道的鉴权语义一致(配置了 key 就必须带凭据,留空 = 开放)。
|
||||
|
||||
### 说明
|
||||
|
||||
- 注册阶段按节点 `ws-keys` 配置校验 key(配了 key 必须传对,否则 401 断开;未配置则开放,详见上文「注册 key 校验」);拨测请求经 WS 转发时不携带 `Authorization` 头(后续由节点侧自行决定是否需要额外鉴权)
|
||||
- `ws:true` 且 WS 未启用(`WS_PORT=0`)时,自动回退原 HTTP 转发
|
||||
@@ -0,0 +1,231 @@
|
||||
# 后端节点部署
|
||||
|
||||
后端节点提供全部检测 API(IP 归属地 / ASN / SSL / DNS / DNSSEC / Whois / TCPing / 测速 / 截图等),可在任意地区部署,组成去中心化测试网络。
|
||||
|
||||
## 方案一:EdgeOne
|
||||
|
||||
腾讯云 EdgeOne 支持 Go 边缘函数,点击下方按钮通过 **EdgeOne Makers** 一键部署 `serverless/edgeone/` 版本(无需本地构建):
|
||||
|
||||
[](https://console.cloud.tencent.com/edgeone/makers/new?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=serverless%2Fedgeone)
|
||||
|
||||
|
||||
也可以使用 EdgeOne CLI 手动部署:
|
||||
|
||||
```bash
|
||||
cd serverless/edgeone
|
||||
npx edgeone pages deploy -n ipw-cn -t $EDGEONE_API_TOKEN
|
||||
```
|
||||
|
||||
`serverless/edgeone/` 版本的配置来源为 环境变量 + 远端配置(`REMOTE_CONFIG_URL`)。
|
||||
|
||||
### 数据上报(可选)
|
||||
|
||||
`serverless/edgeone/` 版本**没有 WS 客户端**(边缘函数不维持长连接),因此上报只走 HTTP 一条路:按周期把统计与拨测明细 `POST <report-url>/report` 到收集中心。下列键同样支持环境变量与远端配置两种来源(远端配置优先):
|
||||
|
||||
| 配置键 | 环境变量 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `node-id` | `NODE_ID` | 上报身份(写入 `probe_results.origin`);留空回退 hostname |
|
||||
| `report-url` | `REPORT_URL` | 收集中心基址;留空则不上报 |
|
||||
| `report-token` | `REPORT_TOKEN` | `/report` 鉴权,请求头 `Authorization: Bearer <token>`;收集中心未配 token 时可留空 |
|
||||
| `report-interval-seconds` | `REPORT_INTERVAL_SECONDS` | 上报间隔秒,缺省 15 |
|
||||
|
||||
记账口径与主节点完全一致(节点是唯一记录者、`stats` 为增量累加、上报 at-most-once),报文格式见 [节点 API 与协议](/guide/node-api) 的「数据上报协议」一节。
|
||||
|
||||
> [!NOTE]
|
||||
> 上报依赖进程存活期内的内存计数。若所用平台会把实例缩容到零,缓冲区会随实例回收丢失——上报只在实例存活期内累积。长驻部署(Docker / 二进制)不受此限。
|
||||
|
||||
## 方案二:Vercel
|
||||
|
||||
点击下方按钮一键导入仓库(部署目标为 `serverless/edgeone/cloud-functions` 目录,即 Go 边缘函数版本):
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=serverless%2Fedgeone%2Fcloud-functions)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Serverless 节点均为 IPv4-only**(EdgeOne、Vercel 等平台的出站网络只提供 IPv4)。部署后需要:
|
||||
>
|
||||
> 1. 给节点配置 `SINGLE_STACK=ipv4`(声明节点为 IPv4 单栈),跳过 IPv6 相关测试,避免无意义的错误日志与超时
|
||||
> 2. 在前端配置 `config/index.ts` 中,**该节点只能填入 `APIBaseURL.IPv4`**(拨测节点池的 IPv4 栈),不要放进 `DualStack` / `IPv6` 栈
|
||||
|
||||
## 方案三:Docker
|
||||
|
||||
`src/` 目录提供 `Dockerfile`(多阶段构建,产物基于 Alpine,支持多架构):
|
||||
|
||||
```bash
|
||||
cd src
|
||||
docker build -t lemon-ipw .
|
||||
docker run -d --restart unless-stopped -p 8080:8080 \
|
||||
-v $(pwd)/setting.json:/home/appuser/setting.json \
|
||||
lemon-ipw
|
||||
```
|
||||
|
||||
> `--restart unless-stopped`:主进程意外退出时 Docker 自动拉起容器;手动 `docker stop` 不会触发重启。
|
||||
|
||||
配置通过挂载 `setting.json` 提供(注意挂载到容器工作目录 `/home/appuser`),也可用环境变量覆盖(`PORTS` / `CORS` / `TRUSTED_PROXIES` / `ACCESS_TOKEN` 等)。
|
||||
|
||||
> [!TIP]
|
||||
> 容器内 OTA 替换二进制意义有限(重建容器即回滚到镜像版本),如需彻底关闭可设 `NODE_OTA=false`;镜像**不含 IP 库数据**,首次启动若 `ipdb` 未关闭会现场拉取约 450MB 到容器可写层,轻量部署建议直接 `IPDB=false`。
|
||||
|
||||
**多架构**:Dockerfile 已适配 buildx——构建阶段固定在构建机原生平台交叉编译(`TARGETOS`/`TARGETARCH`),目标架构无需 QEMU 模拟。本机为其他架构构建:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
docker buildx build --platform linux/arm64 -t lemon-ipw:arm64 --load .
|
||||
```
|
||||
|
||||
打 `v*` 版本标签发版时,CI(`.github/workflows/docker.yml`)会自动构建 `linux/amd64` / `linux/arm64` / `linux/arm/v7` 三架构镜像(amd64 在 x64 实例上原生构建,arm64/armv7 在 GitHub 原生 ARM 实例 `ubuntu-24.04-arm` 上交叉构建,全程无 QEMU),推送 GHCR 与 Docker Hub:
|
||||
|
||||
- GHCR:`ghcr.io/<owner>/<repo>/lemon-ipw`(自动)
|
||||
- Docker Hub:`docker.io/<namespace>/lemon-ipw`(**workflow 中暂未启用**——`.github/workflows/docker.yml` 已预留注释,取消注释并配置 `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` Secrets 即可开启)
|
||||
|
||||
均带 `版本` 与 `major.minor` 标签。部署直接:
|
||||
|
||||
```bash
|
||||
docker run -d -p 8080:8080 \
|
||||
-v $(pwd)/setting.json:/home/appuser/setting.json \
|
||||
ghcr.io/nomdn/ipw-cn/lemon-ipw:v1.2.3
|
||||
```
|
||||
|
||||
Docker 会按宿主机架构自动拉取 manifest 中对应的镜像变体。
|
||||
|
||||
## 方案四:二进制
|
||||
|
||||
```bash
|
||||
# 编译(Go 后端在 src/ 目录)
|
||||
cd src && go build -o lemonipw .
|
||||
|
||||
# 运行(与 setting.json 同目录)
|
||||
./lemonipw
|
||||
```
|
||||
|
||||
首次启动自动下载 IP 数据库(约 450MB),之后每 24 小时自动更新一次。需要守护运行时,可参考下方「方案五」生成的 systemd 服务,或手动创建 service 文件(`ExecStart` 指向二进制,`WorkingDirectory` 指向 `setting.json` 所在目录)。
|
||||
|
||||
## 方案五:一键安装(install.sh)
|
||||
|
||||
仓库根目录提供 `install.sh` 一键安装脚本:自动检测架构 → 下载最新 release 二进制 → **交互式输入配置**(无需准备 setting.json,配置以环境变量注入)→ 生成 systemd 服务并守护进程。
|
||||
|
||||
```bash
|
||||
# 先下载脚本到本地(勿用 curl | bash 管道执行,避免下载中断导致语法解析错误)
|
||||
git clone https://github.com/nomdn/ipw-cn && cd ipw-cn # 或
|
||||
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o install.sh
|
||||
|
||||
sudo bash install.sh
|
||||
```
|
||||
|
||||
**交互式配置项**(直接回车使用默认值):
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| 安装目录 | `/opt/lemon-ipw` | 二进制与工作目录 |
|
||||
| 监听端口 | `8080` | `PORTS` |
|
||||
| 单栈模式 | 双栈 | `SINGLE_STACK`,`ipv4` / `ipv6` / 留空 |
|
||||
| access-token | 留空 | 留空 = 不启用鉴权 |
|
||||
| DNS 服务器 | 留空 | 主从逗号分隔(`119.28.28.28:53,223.5.5.5:53`);留空 = 启动时自动探测系统 DNS |
|
||||
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server;都留空 = 自动探测系统 DNS |
|
||||
| IP 数据库 | `Y` | `IPDB`,首次启动下载约 450MB |
|
||||
| CORS | 留空 | 逗号分隔允许来源 |
|
||||
| 远端配置地址 | 留空 | `REMOTE_CONFIG_URL` |
|
||||
| WS 通道接入 | `N` | 选 `y` 后继续输入 WS_URL / NODE_ID / NODE_KEY |
|
||||
| 其他环境变量 | 无 | 每行一个 `K=V`,空行结束。如需可信代理在此输入 `TRUSTED_PROXIES=<IP/CIDR 逗号分隔>`(配置后归属地接口只信任这些代理转发的 X-Forwarded-For,防伪造) |
|
||||
|
||||
**WS 接入特殊规则**:
|
||||
|
||||
- `WS_URL` 留空时自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址时须为**完整路径**(含 `wss://` 前缀与 `/ws` 路径,如 `wss://host:8092/ws`),逗号分隔多个则**同时连接全部(多活)**
|
||||
- `NODE_ID` **强制自动生成 UUID**(无需输入,如 `4580ea9d-2a16-4b8e-8090-5f2e7f0a2229`),适合作为节点唯一标识
|
||||
- `NODE_KEY` **必填**:不加 key 禁止启用 WS(中间件 `ws-keys` 必须包含该节点,否则注册被拒 401);安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 setting.json 的 `ws-keys`
|
||||
|
||||
**安装完成后自动生成并启动 systemd 服务**(`lemon-ipw.service`),配置以 `Environment="K=V"` 注入,无需 setting.json。常用管理命令:
|
||||
|
||||
```bash
|
||||
systemctl status lemon-ipw # 状态
|
||||
journalctl -u lemon-ipw -f # 日志
|
||||
sudo systemctl restart lemon-ipw # 重启(改配置后)
|
||||
```
|
||||
|
||||
修改配置:`sudo systemctl edit --full lemon-ipw` 编辑环境变量,保存后 `sudo systemctl daemon-reload && sudo systemctl restart lemon-ipw` 生效。
|
||||
|
||||
> [!WARNING]
|
||||
> **版本兼容提醒**:v1.1.0 及更早的 release 二进制读的是旧环境变量名 `IPW_WS_URL` / `IPW_NODE_ID` / `IPW_NODE_KEY`(8-20 及之前构建)。install.sh 生成的 service 用的是新名 `WS_URL` / `NODE_ID` / `NODE_KEY`——**旧二进制 + 新 service 会因读不到配置而完全不启用 WS**(启动日志无任何 ws 输出)。升级到包含改名后的新 release 即可;急用可临时把 service 环境变量改回 `IPW_` 前缀。
|
||||
|
||||
## 配置
|
||||
|
||||
后端配置见 [配置文件](/guide/config):`setting.json` 或环境变量,支持远端配置(`REMOTE_CONFIG_URL`,优先级:远端 > 环境变量 > setting.json)。
|
||||
|
||||
## WS 通道接入(可选)
|
||||
|
||||
后端节点可作为 WS 客户端接入 [独立中间件](/guide/deploy-middleware) 的 WS 通道,拨测请求经 WebSocket 转发,节点本地执行探针后回传。不配置则完全走原 HTTP 接口。
|
||||
|
||||
**配置三个环境变量**(或 setting.json 的 `ws-url` / `node-id` / `node-key`,env 优先;也支持远端配置覆盖,除非列入 `remote-ignore-config`):
|
||||
|
||||
```bash
|
||||
WS_URL=ws://<中间件IP>:8092/ws \
|
||||
NODE_ID=<节点id,与中间件 api-base-url / ip-location-api 池的节点 id 一致> \
|
||||
NODE_KEY=<注册key,与中间件 ws-keys[节点id] 一致;节点未配置 key 可留空> \
|
||||
./lemonipw
|
||||
```
|
||||
|
||||
- `WS_URL` 支持**逗号分隔多个中间件**(`ws://中间件1:8092/ws,ws://中间件2:8092/ws`),**同时连接全部(多活)**:每个地址独立注册并保持连接,任一断开只重连自己,不影响其他中间件;注意多个地址指向同一中间件实例时,同 `nodeId` 后注册的连接会顶掉先前的
|
||||
- **双向心跳**:节点每 10s 发 ping,中间件回 pong;心跳发送连续失败 3 次判定中间件不可用,断开后 3s 重试(注册被拒 30s 重试)
|
||||
- 收到 `probe` 后节点直调探针函数(带缓存)并回 `probe_result`,结果与 HTTP 通道一致
|
||||
- 节点注册带 `NODE_KEY`:中间件 `ws-keys` 里配了 key 就必须传对,否则返回 401 并断开;未配置 key 的节点开放注册
|
||||
|
||||
**注意**:`NODE_ID` 必须与中间件 `APIBaseURL` / `IPLocationAPI` 池中的节点 `id` 一致,且该节点需配置 `"ws": true` 才会走 WS 通道。未连接中间件时,`ws:true` 节点的拨测会返回 502。
|
||||
|
||||
## 运行时配置管理(可选)
|
||||
|
||||
节点暴露 `/v1/config`,供收集中心读写该节点**当前生效**的配置(内存值),是「控制台改配置不用登机器」的基础:
|
||||
|
||||
| 动作 | HTTP | WS | 说明 |
|
||||
|------|------|----|------|
|
||||
| 读取 | `GET /v1/config` | `config` + `action=get` | 返回生效配置快照 + `secretKeys` + `restartRequiredKeys` |
|
||||
| 修改 | `PATCH /v1/config`(`?persist=1` 写回本地文件) | `config` + `action=patch` | 只应用传入的键,其余不动 |
|
||||
| 刷新远端 | `POST /v1/config/refresh` | `config` + `action=refresh` | 重新拉 `remote-config-url` 并应用 |
|
||||
|
||||
三条重要约定:
|
||||
|
||||
- **凭据类键不回显明文**:`access-token` / `node-key` / `report-token` 在快照中为 `***`。调用方下发配置时必须剔除这些键,否则会把真值覆盖成三个星号。收集中心控制台已自动剔除。
|
||||
- **节点不自行重启**:`port` / `cors` / `ipdb` / `report-interval-seconds` / `trusted-proxies` / `node-id` / `node-key` / `access-token` 这些启动期固定的键,改动后在应答的 `restartRequired` 中如实列出,但**不会自动重启进程**——重启会中断在途服务,时机由运维 / 编排层掌握。`ws-url` 是例外:热生效,控制器按新地址多退少补。
|
||||
- **只 PATCH 内存的改动会被重启顶掉**:节点侧优先级是 远端 > 环境变量 > `setting.json`,长期生效需写回托管配置或本地文件,详见 [配置文件](/guide/config) 的「运行时配置管理」一节。
|
||||
|
||||
> [!NOTE]
|
||||
> **HTTP 管理面需要凭据**:节点未配置 `access-token` 时,`/v1/config` 与 `/v1/ota` 整组返回 `403`(此时业务接口也无鉴权,开放配置读写等于公网可改配置)。**WS 通道不受此限制**——节点是 WS 客户端,指令只来自已通过中间件 `ws-keys` 校验的连接。
|
||||
|
||||
## OTA 升级(收集中心下发,可选)
|
||||
|
||||
节点支持由收集中心下发**一次性升级任务**:下载新二进制 → 校验 → 预检 → 原子替换 → 重启,全程无需登录节点主机。
|
||||
|
||||
**触发方式**:收集中心经 WS `ota` 消息下发(HTTP 回退 `POST /v1/ota`)。请求体三选一指定下载源:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `version` | 按版本号下发。节点按自身平台拼资产名 `lemonipw-{goos}-{goarch}[.exe]`,到资产基址 `{assetBase}/{tag}/` 下取;`assetBase` 缺省为 GitHub Releases,自建镜像源可覆盖 |
|
||||
| `url` | 直发下载地址(完全自定义) |
|
||||
| `sha256` | 可选,提供即强校验(hex64,兼容 `sha256:` 前缀) |
|
||||
|
||||
**执行流程**:下载到与目标同分区的临时文件(保证 `rename` 原子)→ 体积下限 1MB(防错误页)→ sha256 校验 → **预检**(试运行新二进制 `-v`,确认架构匹配、可执行,在停机前拦下损坏文件)→ 当前二进制改名 `.old` → 新文件就位 → 重启。任一步失败都会回滚 `.old`。
|
||||
|
||||
**进度回传**:经 WS `ota_result` 依次回报 `accepted` → `downloading` → `verifying` → `installing` → `restarting`,失败时带 `error`。重启后连接必然断开,**最终结果由收集中心以重连注册上报的新版本号判定**(15 分钟未观测到新版本即判超时失败)。
|
||||
|
||||
**并发与开关**:
|
||||
|
||||
- 同一时刻只允许一个 OTA 任务,重复下发直接拒绝。
|
||||
- `node-ota: false`(env `NODE_OTA`)可完全禁用:节点拒绝一切 OTA 指令并回传原因。**只读文件系统 / 编排托管**(Docker Swarm、K8s 等自行管理镜像)的部署建议关闭。
|
||||
- 部署方式受限导致替换失败(如容器只读层)时,升级会失败并回滚,此时仍按传统方式人工升级。
|
||||
|
||||
手动更新(OTA 不可用时):
|
||||
|
||||
```bash
|
||||
# Docker:拉取新镜像后重建容器
|
||||
docker pull lemon-ipw && docker stop lemon-ipw && docker run ... lemon-ipw
|
||||
# systemd:替换二进制后重启服务
|
||||
systemctl restart lemon-ipw
|
||||
```
|
||||
|
||||
## 版本与能力上报
|
||||
|
||||
节点无论是否接入 WS,都会上报自身版本与支持的管理能力:
|
||||
|
||||
- **版本号**来自构建时注入的 `VERSION`,随 WS `register` 报文上报(字段 `version`);纯 HTTP 主节点(无 WS 连接)由收集中心探活后再请求 `GET /info` 取回(`GET /` 只返回 `{"status":"ok"}`)。边缘函数版本不提供 `/info`。
|
||||
- **能力清单**(`capabilities`)取值为 `probe` / `report` / `config` / `ota`,用于让收集中心在下发管理指令前判断该节点能否理解——老版本节点的消息循环没有对应分支,收到管理消息会**静默忽略**,只能靠超时暴露。
|
||||
- 收集中心「节点状态」页展示每个节点的版本号,并据此判断哪些节点需要升级;节点升级部署后重新注册即为新版本。
|
||||
|
||||
能力清单的取值与三态判定规则见 [节点 API 与协议参考](/guide/node-api) 的「能力清单」一节。
|
||||
@@ -0,0 +1,76 @@
|
||||
# 快速开始
|
||||
|
||||
> 本章节将指导您以最低成本部署一个功能完整的测试站
|
||||
|
||||
## 尝试一下
|
||||
|
||||
我们的开发团队已经部署了一个功能完整的预览站:
|
||||
[预览站点](https://ipw.wsmdn.top/)
|
||||
|
||||
## 前置准备
|
||||
|
||||
- 一台 Linux 服务器(需 root 权限 + systemd)
|
||||
- 服务器上可访问 GitHub(下载 release 二进制)
|
||||
- 无需本地编译环境,无需准备配置文件
|
||||
|
||||
## 开始部署
|
||||
|
||||
### 交互式一键部署(推荐)
|
||||
|
||||
```bash
|
||||
# 获取安装脚本(任选其一)
|
||||
git clone https://github.com/nomdn/ipw-cn && cd ipw-cn # 或
|
||||
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o install.sh
|
||||
|
||||
# 执行安装(root 权限)
|
||||
sudo bash install.sh
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> 请勿使用 `curl ... | sudo bash` 管道方式执行:脚本经管道边下载边执行,网络慢/抖动时 bash 会读到不完整的脚本内容,报 `syntax error near unexpected token` 类错误(脚本本身无语法问题)。务必**先下载完整文件再执行**。
|
||||
|
||||
脚本会自动完成:检测架构 → 下载最新 release 二进制 → **交互式输入配置** → 生成并启动 systemd 守护进程。
|
||||
|
||||
**交互式配置项**(直接回车使用默认值):
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| 安装目录 | `/opt/lemon-ipw` | 二进制与工作目录 |
|
||||
| 监听端口 | `8080` | 后端 HTTP 端口 |
|
||||
| 单栈模式 | 双栈 | `SINGLE_STACK`,`ipv4` / `ipv6` / 留空 |
|
||||
| access-token | 留空 | 留空 = 不启用鉴权 |
|
||||
| DNS 服务器 | 留空 | 主从逗号分隔(如 `119.28.28.28:53,223.5.5.5:53`);留空 = 启动时自动探测系统 DNS |
|
||||
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server;都留空 = 自动探测系统 DNS |
|
||||
| IP 数据库 | `Y` | 首次启动下载约 450MB |
|
||||
| CORS | 留空 | 逗号分隔允许来源 |
|
||||
| 远端配置地址 | 留空 | `REMOTE_CONFIG_URL` |
|
||||
| WS 通道接入 | `N` | 选 `y` 后输入 WS_URL / NODE_ID / NODE_KEY |
|
||||
| 其他环境变量 | 无 | 每行一个 `K=V`,空行结束。如需可信代理在此输入 `TRUSTED_PROXIES=<IP/CIDR 逗号分隔>`(配置后归属地接口只信任这些代理转发的 X-Forwarded-For,防伪造) |
|
||||
|
||||
**WS 通道接入规则**(选 `y` 时):
|
||||
|
||||
- `WS_URL` 留空自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址须为完整路径(`wss://host:8092/ws` 形式);逗号分隔多个则**同时连接全部(多活)**
|
||||
- `NODE_ID` **强制自动生成 UUID**(无需输入)
|
||||
- `NODE_KEY` **必填**:不加 key 禁止启用 WS;安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 `ws-keys`
|
||||
|
||||
### 验证安装
|
||||
|
||||
```bash
|
||||
systemctl status lemon-ipw # 服务状态(active running)
|
||||
curl http://127.0.0.1:8080/ # 应返回 {"status":"ok"}
|
||||
journalctl -u lemon-ipw -f # 实时日志
|
||||
```
|
||||
|
||||
### 修改配置
|
||||
|
||||
```bash
|
||||
sudo systemctl edit --full lemon-ipw # 编辑环境变量
|
||||
sudo systemctl daemon-reload && sudo systemctl restart lemon-ipw
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
- 部署**独立中间件**(WS 通道 / 多节点转发):见 [中间件部署](/guide/deploy-middleware)
|
||||
- 部署**前端**(Cloudflare Workers / EdgeOne Makers 等):见 [前端部署](/guide/deploy-frontend)
|
||||
- 完整**配置说明**(后端 / 前端 / 中间件 / 远端配置):见 [配置文件](/guide/config)
|
||||
- 手动**编译部署**(源码构建三个组件):见 [后端节点部署](/guide/deploy-node) / [中间件部署](/guide/deploy-middleware) / [前端部署](/guide/deploy-frontend)
|
||||
@@ -2,61 +2,19 @@
|
||||
|
||||
### 本章节将一步步指导您自部署柠檬味IPW
|
||||
|
||||
Lemon IPW 采用前后端分离架构,前端、后端、独立中间件可各自独立部署,并按需组合:
|
||||
Lemon IPW 采用前后端分离架构,**后端节点、独立中间件、前端**三个组件可各自独立部署,并按需组合:
|
||||
|
||||
```
|
||||
┌─────────────────┐ /v1/* ┌──────────────────┐
|
||||
│ SSR 前端 │ ──────────────▶ │ 后端 / 独立中间件 │
|
||||
│ (Cloudflare │ /middleware/* │ (自托管 / EdgeOne)│
|
||||
│ Workers 等) │ ──────────────▶ │ 多节点候选重试 │
|
||||
└─────────────────┘ └──────────────────┘
|
||||
```
|
||||
| 组件 | 职责 | 部署方案 |
|
||||
|------|------|----------|
|
||||
| [后端节点](/guide/deploy-node) | 提供全部检测 API | EdgeOne / Vercel / Docker / 二进制 |
|
||||
| [独立中间件](/guide/deploy-middleware) | 请求转发 + Key 注入 | Docker / 二进制 |
|
||||
| [前端](/guide/deploy-frontend) | 数据可视化与接口调用 | EdgeOne / Vercel / Cloudflare Workers / 直接运行 |
|
||||
|
||||
## 前端
|
||||
组件之间通过 `/v1/*`(直连后端)与 `/middleware/*`(经中间件转发)两类接口通信,前端按节点列表轮询、失败自动重试下一个节点。
|
||||
|
||||
前端是全项目的核心,负责数据可视化和接口调用,部署到 Cloudflare Workers(也可部署到其他符合 Serverless 标准的平台)。
|
||||
## 快速开始
|
||||
|
||||
[▶ 前端部署](/guide/deploy-frontend)
|
||||
|
||||
## 后端(自托管)
|
||||
|
||||
Go 后端提供全部检测 API(IP 归属地 / ASN / SSL / DNS / DNSSEC / Whois / TCPing / 测速 / 截图等),可 Docker 部署或直接运行二进制:
|
||||
|
||||
```bash
|
||||
# 方式一:Docker
|
||||
docker build -t lemon-ipw .
|
||||
docker run -p 8080:8080 -v $(pwd)/setting.json:/app/setting.json lemon-ipw
|
||||
|
||||
# 方式二:直接运行
|
||||
go run main.go # 首次启动自动下载 IP 数据库(约 200MB),之后每 24h 自动更新
|
||||
```
|
||||
|
||||
配置项见根目录 `setting.json` 及环境变量表(`PORT` / `CORS` / `BLOCK_PRIVATE_IPS` / `DNS_SERVER` 等)。
|
||||
|
||||
## 独立中间件(middleware-go)
|
||||
|
||||
转发中间件,供前端在多个上游节点之间做候选重试,可独立部署:
|
||||
|
||||
```bash
|
||||
cd middleware-go
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o middleware-go-linux-arm64 .
|
||||
./middleware-go-linux-arm64 # 运行(可用 SETTING_FILE 指定配置路径)
|
||||
./middleware-go-linux-arm64 --version # 查看版本
|
||||
```
|
||||
|
||||
所有配置可通过环境变量覆盖(`API_BASE_URLS` / `IP_LOCATION_APIS` / `CORS` / `APIKEYS` 等,数组/对象用 JSON 字符串),配置优先级:**环境变量 > setting.json > 默认值**。
|
||||
|
||||
## 部署平台
|
||||
|
||||
本项目支持**一切符合 Serverless 标准的部署平台**,同时也支持容器 / 二进制自托管:
|
||||
|
||||
| 组件 | 可部署目标 |
|
||||
|------|-----------|
|
||||
| SSR 前端 | Cloudflare Workers、EdgeOne Pages 及任意 Serverless 平台 |
|
||||
| 后端(自托管) | Docker / 任意容器或裸机 |
|
||||
| 独立中间件 | 常规服务 / Serverless 函数 |
|
||||
| 边缘后端 | EdgeOne Pages(`edgeone/`、`edgeone-getip/`) |
|
||||
| IP 查询服务 | Cloudflare Workers(`lemon-getip/`,Hono) |
|
||||
想用最低成本部署一套功能完整的测试站,直接看 [快速入门](/guide/getting-started):交互式一键部署(install.sh)→ systemd 守护。
|
||||
|
||||
## CI/CD
|
||||
|
||||
@@ -65,7 +23,5 @@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o middleware-go-linux-arm64 .
|
||||
| 工作流 | 部署目标 |
|
||||
|--------|----------|
|
||||
| `frontend-ssr.yml` | SSR 前端 → Cloudflare Workers |
|
||||
| `workers.yml` | `lemon-getip/` → Cloudflare Workers |
|
||||
| `edgeone-backend.yml` | `edgeone/` → EdgeOne Pages |
|
||||
| `edgeone-getip.yml` | `edgeone-getip/` → EdgeOne Pages |
|
||||
| `build_and_release.yml` | 后端多平台构建与发布 |
|
||||
| `edgeone-backend.yml` | `serverless/edgeone/` → EdgeOne Pages |
|
||||
| `build_and_release.yml` | 后端多平台构建与发布(Release 附带配置文件) |
|
||||
@@ -0,0 +1,213 @@
|
||||
# 节点 API 与协议参考
|
||||
|
||||
后端节点(`src/`)对外暴露的全部接口与协议。配置项说明见 [配置文件](/guide/config),部署方式见 [后端节点部署](/guide/deploy-node)。
|
||||
|
||||
> 本文描述 `src/` 主节点。边缘函数版本(`serverless/edgeone/`)是**精简形态**:只有 `/v1/*` 探测端点与 `GET /`(仅返回 `{"status":"ok"}`,不回报版本与能力),没有 WS 通道、管理接口与 OTA;但同样支持 HTTP 数据上报,见下文「数据上报协议」。
|
||||
|
||||
## 鉴权总则
|
||||
|
||||
| 通道 | 鉴权方式 | 未配置凭据时 |
|
||||
|------|----------|--------------|
|
||||
| 业务探测 `/v1/*` | `access-token` 非空时校验 `Authorization: Bearer <access-token>`,不符返回 `401` | 不启用鉴权,公开可访问 |
|
||||
| 健康检查 `/`、节点信息 `/info` | 无 | — |
|
||||
| 管理接口 `/v1/config`、`/v1/ota`(HTTP) | 同业务接口,复用 `tokenCheck` | **整组关闭**,一律返回 `403`(节点未配 `access-token` 时业务接口本身也无鉴权,暴露配置读写等于公网可改配置) |
|
||||
| WS 通道 | 中间件 `ws-keys` 校验注册报文 | WS **常开**:节点是客户端,指令只来自已通过中间件校验的连接,与节点是否配 `access-token` 无关 |
|
||||
|
||||
> 结论:**HTTP 管理面是"有凭据才开"的**;只想用 WS 通道管理节点时,节点无需配置 `access-token`。
|
||||
|
||||
---
|
||||
|
||||
## 一、健康检查与节点信息
|
||||
|
||||
```
|
||||
GET / → {"status":"ok"}
|
||||
GET /info → {"version":"v1.2.3","capabilities":["probe","report","config","ota"]}
|
||||
```
|
||||
|
||||
两个端点都无鉴权、不计入统计,且都注册在 `/v1` 组之外——因此既不受 `access-token` 约束,也不经过业务统计中间件。
|
||||
|
||||
- **健康检查 `/`**:只回答"进程是否活着"。给负载均衡、OTA 就绪探测(`otaWaitChildReady`)与运维 `curl` 用。**不回报版本**:它是免鉴权的对外端点,回版本号等于向公网公开节点版本。
|
||||
- **节点信息 `/info`**:回报节点身份。`version` 为版本号,`capabilities` 为能力清单(与 WS `register` 报文同源,见 `ws.go nodeCapabilities`)。收集中心对纯 HTTP 节点(无 WS 连接)探活后单独取回,用于节点状态页展示版本、以及下发管理指令前判断该节点能否理解对应指令。
|
||||
|
||||
> 老版本节点没有 `/info`(返回 `404`),边缘函数版节点同样不提供;收集中心取不到就留空,不影响判活。
|
||||
|
||||
---
|
||||
|
||||
## 二、业务探测接口
|
||||
|
||||
全部挂在 `/v1` 组下,共用 CORS + 可选 `tokenCheck` + 统计中间件(`nodeReportMiddleware`,见下文「数据上报」)。
|
||||
|
||||
| 方法 | 路径 | 参数 | 说明 |
|
||||
|------|------|------|------|
|
||||
| GET | `/v1/detail/*url` | 目标 URL(路径传入,自动补全协议) | 网站探测明细:DNS/TCP/HTTP 各阶段耗时、状态码、页面体积、下载速度、可达性;双栈分别返回 |
|
||||
| GET | `/v1/ssl/*url` | 目标 URL | SSL 证书检查:剩余有效天数、颁发/主体、TLS 版本、是否过期;双栈分别返回 |
|
||||
| GET | `/v1/speed/:version/*url` | `version` = `v4` / `v6`,目标 URL | 下载测速 |
|
||||
| GET | `/v1/tcping/:ip` | `port`(默认 80)、`count`(默认 4,上限 20) | TCP 连通性与 RTT 统计 |
|
||||
| GET | `/v1/dns/:type/*domain` | `type` ∈ `a`/`aaaa`/`cname`/`mx`/`ns`/`ptr`/`srv`/`txt`/`caa` | DNS 记录查询(`dns-server` 支持主从 failover 与 DoH) |
|
||||
| GET | `/v1/dnssec/:domain` | 域名 | DNSSEC 链式验证(`dnssec-server`,留空沿用 `dns-server`) |
|
||||
| GET | `/v1/whois/:domain` | 域名 | Whois 注册信息 |
|
||||
| GET | `/v1/location/:ip` | IP | 多源 IP 归属地聚合(见 [功能总览](/info/features)) |
|
||||
| GET | `/v1/location` | 无(取请求方 IP) | 查询访问者自身归属地;配置 `trusted-proxies` 后只信任这些代理转发的 `X-Forwarded-For` |
|
||||
| GET | `/v1/asn/:ip` | IP | ASN 查询(三套 ASN 库 + ASN Whois 解析) |
|
||||
|
||||
**条件注册**:`/v1/location/*`、`/v1/asn/:ip` 仅在 `ipdb` 未关闭时注册——IP 库未加载时这些接口无意义。
|
||||
|
||||
**单栈模式**:`single-stack` 配为 `ipv4` / `ipv6` 时只跑对应栈,另一栈直接返回 `Skipped due to SINGLE_STACK=...`,不发起无意义的探测与超时。
|
||||
|
||||
**SSRF 防护**:`block-private-ips` 开启时,`detail` / `ssl` 对解析到内网/私有地址的目标直接返回伪造结果,出站客户端同时拒绝连接私有 IP、禁止跨跳重定向。
|
||||
|
||||
---
|
||||
|
||||
## 三、管理接口
|
||||
|
||||
### 3.1 运行时配置 `/v1/config`
|
||||
|
||||
读写节点**当前生效**的配置(内存值),与收集中心的「托管配置」是两回事——后者是节点启动时拉的远端配置,见 [配置文件](/guide/config) 的「运行时配置管理」一节。
|
||||
|
||||
| 方法 | 路径 | 作用 |
|
||||
|------|------|------|
|
||||
| GET | `/v1/config` | 返回当前生效配置快照 + `secretKeys` + `restartRequiredKeys` + `remoteProtectedKeys` |
|
||||
| PATCH | `/v1/config` | 按下发 map 逐键应用;`?persist=1` 时同时写回本地 `setting.json` |
|
||||
| POST | `/v1/config/refresh` | 重新拉取 `remote-config-url` 并应用(远端是持久来源,不写回本地文件) |
|
||||
|
||||
行为要点:
|
||||
|
||||
- **凭据遮蔽**:`access-token` / `node-key` / `report-token` 在 GET 快照里回显 `***`,不泄露明文;下发时原样提交会把真值覆盖成三个星号,因此调用方必须**剔除这些键**(收集中心控制台已自动剔除)。
|
||||
- **`access-token` / `report-token` 不随远端配置覆盖**:这两项硬编码在保护名单 `configRemoteProtectedKeys` 里,`remote-config-url`(含收集中心托管配置)下发时会被跳过,并在 `refresh` 应答的 `protectedIgnored` 与节点日志里列出被跳过的键。**只约束远端下发**,本地 `PATCH` 照常可改。理由与清单见 [配置文件](/guide/config) 的「远端配置」一节。
|
||||
- **未持久化的改动会被重启顶掉**:节点侧优先级是 远端 > 环境变量 > `setting.json`,只 PATCH 内存时重启即回到旧值。需要长期生效应 `refresh`(改远端配置)或把改动合并进托管配置——受保护凭据例外,只能落到本地 ENV / `setting.json`。
|
||||
- **`restartRequired` 只如实回报**:`port` / `cors` / `ipdb` / `report-interval-seconds` / `trusted-proxies` / `node-id` / `node-key` / `access-token` 这些启动期固定的键,改动后节点**不会自行重启**,只在应答里列出键名,重启时机交给运维。
|
||||
- **`ws-url` 例外,热生效**:WS 客户端控制器按新地址"多退少补",无需重启。 `node-ota` 同样即时生效:该开关在每次下发 OTA 指令时才读取,不属需重启类键。
|
||||
|
||||
### 3.2 OTA 升级 `/v1/ota`
|
||||
|
||||
收集中心 HTTP 回退通道(WS 在线时优先走 WS `ota` 消息)。
|
||||
|
||||
```
|
||||
POST /v1/ota
|
||||
{
|
||||
"requestId": "可选,回执关联用",
|
||||
"version": "v1.4.0", // 二选一:按版本号下发
|
||||
"url": "https://.../lemonipw-linux-amd64", // 二选一:直发下载地址
|
||||
"assetBase": "https://.../releases/download", // 可选,配合 version 使用
|
||||
"sha256": "可选,hex64(兼容 sha256: 前缀),提供则强校验"
|
||||
}
|
||||
→ 202 {"ok":true,"started":true}
|
||||
```
|
||||
|
||||
响应仅为**受理回执**(下载可能数分钟,HTTP 无进度通道)。执行阶段见 [后端节点部署](/guide/deploy-node) 的「OTA 升级」一节。
|
||||
|
||||
---
|
||||
|
||||
## 四、WebSocket 协议
|
||||
|
||||
节点作为 **WS 客户端**连接收集中心(或独立中间件)的 `/ws` 端点:`ws(s)://<中间件>:<ws-port>/ws`。`ws-url` 支持逗号分隔多个地址,**同时连接全部(多活)**,由 per-URL 连接控制器统一收敛。
|
||||
|
||||
### 4.1 消息信封
|
||||
|
||||
```json
|
||||
{ "type": "...", "nodeId": "...", "ts": 1710000000, "data": { } }
|
||||
```
|
||||
|
||||
### 4.2 消息类型
|
||||
|
||||
**节点 → 中间件**
|
||||
|
||||
| type | data 字段 | 作用 |
|
||||
|------|-----------|------|
|
||||
| `register` | `nodeId` / `key`(配了 ws-keys 时必填)/ `version` / `capabilities[]` | 注册接入,建立身份 |
|
||||
| `ping` | — | 心跳,每 10s |
|
||||
| `pong` | — | 应答中间件 ping |
|
||||
| `probe_result` | `requestId` / `status` / `body` | 拨测结果回传 |
|
||||
| `report` | 统计 + 拨测明细(同 `/report` body) | 周期数据上报 |
|
||||
| `config_result` | `requestId` / `action` / `ok` / `error` / 配置快照 / `applied` / `unknown` / `restartRequired` / `persisted` | 配置指令应答 |
|
||||
| `ota_result` | `requestId` / `ok` / `stage` / `error` | OTA 进度与失败原因 |
|
||||
|
||||
**中间件 → 节点**
|
||||
|
||||
| type | data 字段 | 作用 |
|
||||
|------|-----------|------|
|
||||
| `register_ok` | `heartbeatSeconds` | 注册通过,告知心跳周期 |
|
||||
| `register_error` | `code` / `command` | 注册被拒(如 key 不匹配),30s 后重试 |
|
||||
| `probe` | `requestId` / `apiType` / `raw` / `query` / `scheduler` | 下发拨测 |
|
||||
| `config` | `requestId` / `action` / `config` / `persist` | 下发配置指令 |
|
||||
| `ota` | `requestId` / `url` 或 `version`+`assetBase` / `sha256` | 下发升级任务 |
|
||||
| `ping` | — | 中间件心跳(节点回 `pong`) |
|
||||
| `status` | 统计字段 | 中间件状态,节点仅记录日志 |
|
||||
|
||||
### 4.3 心跳与重连
|
||||
|
||||
- 节点每 **10s** 发 `ping`;发送连续失败 **3 次**判定通道不可用,主动断开并在 **3s** 后重连。
|
||||
- 注册被拒(key 错误等)时 **30s** 后重试。
|
||||
- 中间件侧每 20s 发 `ping` + `status`,空闲 **75s** 未收到任何报文即剔除该节点并记离线事件。
|
||||
|
||||
### 4.4 OTA 进度阶段
|
||||
|
||||
节点经 `ota_result` 依次回报:`accepted` → `downloading` → `verifying` → `installing` → `restarting`;任一阶段失败即终止并带 `error`。重启后连接必然断开,"最终结果"由收集中心以**重连注册上报的新版本号**判定。
|
||||
|
||||
---
|
||||
|
||||
## 五、数据上报协议
|
||||
|
||||
统计与拨测明细**由节点自己上报**,收集中心不在转发路径上采集——节点是唯一记录者,不存在双算。
|
||||
|
||||
| 通道 | 条件 | 形式 |
|
||||
|------|------|------|
|
||||
| WS `report` 消息 | 已连接中间件 | 向所有在线中间件广播 |
|
||||
| HTTP `POST <report-url>/report` | WS 未启用或全部掉线 | 携带 `Authorization: Bearer <report-token>` |
|
||||
|
||||
```json
|
||||
{
|
||||
"instance": "cn-jiangsu",
|
||||
"stats": [
|
||||
{ "nodeId": "cn-jiangsu", "apiType": "tcping",
|
||||
"total": 1, "errors": 0, "latencySumMs": 12, "latencyMaxMs": 12, "minute": 29821676 }
|
||||
],
|
||||
"probes": [
|
||||
{ "nodeId": "cn-jiangsu", "apiType": "tcping", "raw": "qq.com",
|
||||
"status": 200, "latencyMs": 33, "body": { } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- 上报周期由 `report-interval-seconds` 控制(缺省 15s)。
|
||||
- `stats` 是**增量**,收集器按 `(分钟 × 节点 × apiType)` 累加;上报为 at-most-once(失败丢弃并记日志,不重试——重试会双算)。
|
||||
- 单次报文上限 500 条 stats / 500 条 probes / 4MB;超出或队列(缓冲 2048)打满时丢弃并计数。
|
||||
- **只上报第一方观测**:节点自己处理的请求才记账,不转播从别处收到的数据。
|
||||
- `minute` 是 unix 分钟桶(按节点时钟);上报方传 `0` 表示"由收集器按自身时钟入桶",收集端另有"不接受未来 1 小时以外"的漂移容忍。
|
||||
- **部署形态差异**:`serverless/edgeone/` 版本不维持长连接(无 WS 客户端),只走 HTTP `POST /report` 一条通道,配置方式见 [后端节点部署](/guide/deploy-node) 的 EdgeOne 一节。
|
||||
|
||||
---
|
||||
|
||||
## 六、能力清单(capabilities)
|
||||
|
||||
节点在 `register` 报文与主节点的 `GET /info` 中上报自身支持的管理能力,取值为 `probe` / `report` / `config` / `ota` 的数组。边缘函数版本不提供 `/info`,也不回报能力清单。
|
||||
|
||||
收集中心据此做**三态判定**,绝不能把"未知"当作"不支持":
|
||||
|
||||
| 上报情况 | 收集中心行为 |
|
||||
|----------|--------------|
|
||||
| 清单含该能力 | 正常下发对应指令 |
|
||||
| 清单明确不含 | 该能力确实没有:立刻拒绝,不发起指令、不等超时 |
|
||||
| 清单为空(未上报) | 老版本节点,能力**未知**:仍尝试下发;超时后主动探活,区分"节点活着但静默忽略(程序过旧)"与"连接已僵死" |
|
||||
|
||||
新增一类管理指令时应在此追加能力标识,并保持取值稳定(收集中心按字符串比对)。
|
||||
|
||||
---
|
||||
|
||||
## 七、缓存与并发
|
||||
|
||||
业务探测结果在节点侧缓存,避免同 key 洪打把上游打崩:
|
||||
|
||||
| 缓存 | 键 | TTL |
|
||||
|------|-----|-----|
|
||||
| `detail` | 归一化 URL | 5 分钟 |
|
||||
| `ssl` | URL | 5 分钟 |
|
||||
| `speed` | `url:version` | 5 分钟 |
|
||||
| `tcping` | `host:port:count` | 5 分钟 |
|
||||
| `whois` | 域名 | 5 分钟 |
|
||||
| `asn whois` | `AS<asn>` | 5 分钟 |
|
||||
| Bilibili 归属地(在线 API) | IP | 24 小时 |
|
||||
|
||||
- **失败结果 30s 后删除**:探测失败(如目标不可达)不缓存整 5 分钟,避免故障恢复后长时间返回旧结论。
|
||||
- **singleflight 合并**:`detail` / `ssl` / `tcping` / `speed` 的并发同 key 请求合并为一次真实探测。
|
||||
- **定时清扫**:每 10 分钟清理过期缓存条目与 Bilibili 缓存,防止只靠"同 key 重访才淘汰"时内存无限增长。
|
||||
@@ -0,0 +1,361 @@
|
||||
# 公开 API(/api/v1)
|
||||
|
||||
收集中心(`ipw-boce` 中间件)对外提供的**程序化接口**,资源按 REST 规范组织:名词复数、裸资源 + HTTP 状态码、无信封。
|
||||
|
||||
- **面向任何持有凭据的调用方**:控制台用户生成一枚个人 API Token 即可用,适合脚本、巡检、看板、CI 取数。
|
||||
- 与内部 `/admin/*` 共用**同一套鉴权与可见性**,只是对外形态更规范。
|
||||
- 拨测**节点**自身的接口不在这里,见 [节点 API 与协议](/guide/node-api)。
|
||||
|
||||
> 下文 `<collector>` 指收集中心实例的 origin,例如 `https://collector.example.com`。
|
||||
|
||||
## 端点一览
|
||||
|
||||
| 方法 | 路径 | 作用 |
|
||||
|------|------|------|
|
||||
| GET | `/api/v1/tasks` | 任务列表(分页 + 标签过滤) |
|
||||
| GET | `/api/v1/tasks/:id` | 单任务详情 |
|
||||
| GET | `/api/v1/tasks/:id/sla` | 任务 SLA 聚合(顶层汇总 + 按节点明细) |
|
||||
| GET | `/api/v1/probes` | 拨测明细(分页 + 过滤) |
|
||||
| POST | `/api/v1/probes` | 一键拨测(同步聚合;本层唯一的写操作) |
|
||||
| GET | `/api/v1/nodes` | 节点池简表(脱敏) |
|
||||
| GET | `/api/v1/usage` | 用量聚合 |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 1) 取一枚个人 API Token(控制台「个人资料 → 个人 API Token」同效)
|
||||
curl -sX POST "https://<collector>/admin/me/token" -H "Authorization: Bearer <你的 JWT>"
|
||||
# → {"token":"ipt_9f3c...e1a7","hint":"e1a7","createdAt":"2026-09-13T07:00:00Z"}
|
||||
|
||||
# 2) 调用公开 API
|
||||
curl -s -H "Authorization: Bearer ipt_9f3c...e1a7" \
|
||||
"https://<collector>/api/v1/tasks?page=1&pageSize=20"
|
||||
```
|
||||
|
||||
- Token **明文只在生成时返回一次**(`token` 字段);库里只存 bcrypt 哈希,`hint` 是末 4 位供列表辨识。
|
||||
- 再次 `POST /admin/me/token` 会**立即作废旧 Token**;`DELETE /admin/me/token` 吊销。
|
||||
- 静态 `admin-token` 没有账号,不能生成个人 Token(返回 400)。
|
||||
|
||||
---
|
||||
|
||||
## 鉴权
|
||||
|
||||
三轨,与 `/admin/*` 完全一致:
|
||||
|
||||
| 凭据 | 特征 | 身份 | 适用场景 |
|
||||
|------|------|------|----------|
|
||||
| 个人 API Token | `ipt_` 前缀 | 其账号(角色随账号) | 程序化访问的推荐方式,可随时吊销 |
|
||||
| JWT | 无前缀 | 其账号(角色随账号) | `POST /admin/login` 签发,有过期时间 |
|
||||
| 静态 `admin-token` | 无前缀 | 恒为 admin(`uid=0`) | 向后兼容;无归属,脚本慎用 |
|
||||
|
||||
请求头固定 `Authorization: Bearer <凭据>`。失败一律 `401`:
|
||||
|
||||
| message | 场景 |
|
||||
|---------|------|
|
||||
| `missing bearer token` | 未携带 `Authorization: Bearer ` 前缀 |
|
||||
| `invalid or revoked token` | `ipt_` 开头但库中查不到(未生成 / 已吊销 / 被轮换) |
|
||||
| `invalid credentials` | 其它凭据无效 |
|
||||
|
||||
> **API Token 等价于其账号登录**:权限随账号角色。普通用户只看得见自己的资源。
|
||||
> 若收集中心既没配 `admin-token` 也没启用 JWT,则管理面**不鉴权**(仅限内网/联调部署,勿用于公网)。
|
||||
|
||||
---
|
||||
|
||||
## 通用约定
|
||||
|
||||
### 错误体
|
||||
|
||||
非 2xx 统一为:
|
||||
|
||||
```json
|
||||
{ "error": { "code": "not_found", "message": "task not found" } }
|
||||
```
|
||||
|
||||
| code | HTTP | 含义 |
|
||||
|------|------|------|
|
||||
| `unauthorized` | 401 | 缺凭据 / 凭据无效或已吊销 |
|
||||
| `forbidden` | 403 | 凭据有效但无权访问该资源(如别人的任务) |
|
||||
| `not_found` | 404 | 资源不存在(任务、分享令牌) |
|
||||
| `bad_request` | 400 | 参数非法 |
|
||||
| `rate_limited` | 429 | 请求过于频繁(仅免登录的公开状态接口) |
|
||||
| `internal` | 500 | 服务端错误 |
|
||||
|
||||
### 分页
|
||||
|
||||
两套参数,**互不通用**:
|
||||
|
||||
| 端点 | 参数 | 缺省 | 边界处理 | 返回字段 |
|
||||
|------|------|------|----------|----------|
|
||||
| `/tasks` | `page`(1 起)、`pageSize`(别名 `limit`) | 1 / 50 | `page<1` → 1;`pageSize` 夹在 1~200 | `{items,total,page,pageSize}` |
|
||||
| `/probes` | `limit`、`offset` | 50 / 0 | `limit` 夹在 1~500;`offset<0` → 0 | `{items,total,limit,offset}` |
|
||||
|
||||
越界值是**夹到边界**而非报错(`pageSize=9999` → 200,`page=0` → 1)。`total` 为符合过滤条件的总数,与当前页无关。两个列表均按 `id` 倒序(最新在前)。
|
||||
|
||||
### 可见性
|
||||
|
||||
| 角色 | 任务 | 拨测明细 |
|
||||
|------|------|----------|
|
||||
| admin / 静态 token | 全量 | 全量 |
|
||||
| 普通用户 | 仅 `ownerId` = 本人 | 定时样本按「本人任务」归属、业务拨测按发起者 |
|
||||
|
||||
访问**单个**他人的任务返回 `403`(而非 404),便于区分"不存在"与"不是你的"。
|
||||
|
||||
### 限流
|
||||
|
||||
`/api/v1` **不挂限流**——限流只作用于转发口 `/v1` 与 `/middleware`。唯一例外是免登录的公开状态接口(见文末)。
|
||||
|
||||
---
|
||||
|
||||
## 一、任务
|
||||
|
||||
### 1.1 `GET /api/v1/tasks`
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `page` | 否 | 页码,1 起,缺省 1 |
|
||||
| `pageSize` | 否 | 每页条数,缺省 50,上限 200(`limit` 为别名) |
|
||||
| `tag` | 否 | 按标签**子串**过滤(标签以逗号分隔存储) |
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": 5, "name": "官网可用性", "ownerId": 2, "ownerUsername": "alice",
|
||||
"enabled": true, "apiType": "detail", "target": "example.com", "stack": "",
|
||||
"recordType": "", "nodeScope": "all", "nodeIds": "", "intervalSec": 60,
|
||||
"slowMs": 800, "expectStatus": "2xx", "bothProtocols": false,
|
||||
"requireAllStacks": true, "certExpiredDown": false, "notifyRecover": true,
|
||||
"quietHours": "23:00-07:00", "hideTarget": false, "tags": "prod,官网",
|
||||
"updatedAt": "2026-09-13T06:12:00Z"
|
||||
}
|
||||
],
|
||||
"total": 1, "page": 1, "pageSize": 50
|
||||
}
|
||||
```
|
||||
|
||||
任务对象字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | number | 任务 id |
|
||||
| `name` | string | 任务名 |
|
||||
| `ownerId` | number | 所有者用户 id;`0` = 无归属(静态 token 建的旧任务) |
|
||||
| `ownerUsername` | string | 派生字段:所有者用户名(无归属为空串) |
|
||||
| `enabled` | bool | 是否启用调度 |
|
||||
| `apiType` | string | `tcping` / `speed` / `ssl` / `detail` / `dns` |
|
||||
| `target` | string | `detail`/`ssl`/`dns` = 域名;`tcping` = `host[:port]`;`speed` = `v4/`或`v6/`+URL |
|
||||
| `stack` | string | `""`(按类型) / `v4` / `v6`,仅 `speed` 想固定单栈时用 |
|
||||
| `recordType` | string | `dns` 专用:`a`/`aaaa`/`cname`/`mx`/`ns`/`ptr`/`srv`/`txt`/`caa`(缺省 `a`) |
|
||||
| `nodeScope` | string | `all`(全池)/ `custom`(指定) |
|
||||
| `nodeIds` | string | `custom` 时的逗号分隔节点 id;`all` 忽略 |
|
||||
| `intervalSec` | number | 调度间隔(秒),最小 10 |
|
||||
| `slowMs` | number | 慢阈值(ms),超过计一次"未达标";0 = 不启用 |
|
||||
| `expectStatus` | string | 期望状态码:`"2xx"` 或 `"200"` / `"200,301,302"` |
|
||||
| `bothProtocols` | bool | `detail`:http 与 https 都要命中才算成功 |
|
||||
| `requireAllStacks` | bool | 双栈全通才算该节点可用 |
|
||||
| `certExpiredDown` | bool | `ssl`:证书过期视为不可用 |
|
||||
| `notifyRecover` | bool | 从 down 恢复时也通知一次 |
|
||||
| `quietHours` | string | 免打扰时段 `"HH:MM-HH:MM"`(服务器本地时区,支持跨零点) |
|
||||
| `hideTarget` | bool | 公开状态页隐藏探测目标 |
|
||||
| `tags` | string | 逗号分隔标签 |
|
||||
| `updatedAt` | string | 最后更新时间(RFC3339) |
|
||||
|
||||
> 分享令牌 `shareToken` **不在 JSON 里**(`json:"-"`),只经管理接口读写。
|
||||
|
||||
### 1.2 `GET /api/v1/tasks/:id`
|
||||
|
||||
返回单个任务对象(字段同 1.1)。
|
||||
|
||||
| 状态 | 场景 |
|
||||
|------|------|
|
||||
| `404 not_found` | `task not found` |
|
||||
| `403 forbidden` | `not your task`(普通用户访问他人任务) |
|
||||
|
||||
### 1.3 `GET /api/v1/tasks/:id/sla?hours=24`
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `hours` | 否 | 统计窗口(小时),缺省 24,夹在 1 ~ 2160(90 天) |
|
||||
|
||||
```json
|
||||
{
|
||||
"taskId": 5, "hours": 24,
|
||||
"window": { "from": "2026-09-12T07:00:00Z", "to": "2026-09-13T07:00:00Z" },
|
||||
"samples": 1440, "up": 1432, "down": 8,
|
||||
"availability": 99.44, "avgMs": 128, "maxMs": 2103, "p95Ms": 402,
|
||||
"byNode": [
|
||||
{ "nodeId": "cn-jiangsu", "up": 720, "down": 0, "availability": 100,
|
||||
"avgMs": 96, "p95Ms": 180, "latestMs": 88 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- **只统计 `source=sched` 的定时样本**:节点自主上报与手动一键拨测不进 SLA(详见 [节点 API 与协议](/guide/node-api) 的「数据上报协议」一节)。
|
||||
- 判定配置(`expectStatus` / `bothProtocols` / `requireAllStacks` / `certExpiredDown`)在**读取时**生效,改配置立刻作用于历史样本,无需回填重算。
|
||||
- `availability` 是**百分数**(0~100,两位小数)。两处口径略有差异:
|
||||
- 顶层 `availability` = `up/(up+down)`;
|
||||
- `byNode[].availability` = `up/samples`(分母含无法判定的 `invalid` 样本,故通常略低)。
|
||||
- `byNode[].latestMs` 是该节点窗口内最新一条可达样本的延迟。
|
||||
|
||||
---
|
||||
|
||||
## 二、拨测明细
|
||||
|
||||
### 2.1 `GET /api/v1/probes`
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `node` | 否 | 节点 id 精确匹配 |
|
||||
| `type` | 否 | `apiType` 精确匹配(`detail`/`ssl`/`dns`/`tcping`/`speed` …) |
|
||||
| `source` | 否 | `sched`(定时)或 `biz`(业务拨测);**其它取值返回 400** |
|
||||
| `since` | 否 | 起始时间,**RFC3339**(如 `2026-09-13T00:00:00Z`),非法返回 400 |
|
||||
| `limit` | 否 | 缺省 50,上限 500 |
|
||||
| `offset` | 否 | 缺省 0 |
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "nodeId": "cn-jiangsu", "apiType": "tcping", "raw": "example.com:443",
|
||||
"status": 200, "latencyMs": 33, "source": "sched", "error": "",
|
||||
"createdAt": "2026-09-13T07:01:12Z" }
|
||||
],
|
||||
"total": 1440, "limit": 50, "offset": 0
|
||||
}
|
||||
```
|
||||
|
||||
- 为控制体积,`items` **精简且不含 `body`**(原始响应体)。需要看原始 body 请用控制台「拨测明细」页。
|
||||
- 不传 `source` 时按可见性返回全部来源:admin 含节点上报的 `ws`/`http` 样本,普通用户仅自己的 `sched` + `biz`。
|
||||
- **`source=biz` 的边界**:admin 身份下等价于「非 `sched` 的全部来源」(含节点上报的 `ws`/`http`);普通用户下严格等于「自己发起的 `biz`」。要精确取节点上报样本,请按 `node` / `type` 过滤。
|
||||
|
||||
### 2.2 `POST /api/v1/probes`
|
||||
|
||||
一键拨测:对节点池(或指定节点)并发发起一轮探测,**同步**聚合后一次性返回。这是公开 API 上唯一的写操作——结果会落库(`source=biz`)并归属 Token 主人。
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiType": "detail",
|
||||
"raw": "example.com",
|
||||
"query": { "port": "443" },
|
||||
"nodes": ["cn-jiangsu", "cn2-sichuan"]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `apiType` | 是 | 探测类型;`location`/`asn` 走 IP 定位池,其余走 API 节点池 |
|
||||
| `raw` | 是 | 探测目标;前导 `/` 会被去掉 |
|
||||
| `query` | 否 | 透传给节点的查询参数对象(空值项被忽略) |
|
||||
| `nodes` | 否 | 限定节点 id 列表;缺省 = 池内全部节点 |
|
||||
|
||||
```json
|
||||
{
|
||||
"apiType": "detail", "raw": "example.com",
|
||||
"targeted": 2, "ok": 2, "failed": 0, "unknown": [], "persisted": 2,
|
||||
"results": [
|
||||
{ "nodeId": "cn-jiangsu", "label": "中国 江苏 移动", "channel": "http",
|
||||
"status": 200, "latencyMs": 412, "body": { "ipv4": { "http_status_code": 200 } } },
|
||||
{ "nodeId": "cn2-sichuan", "label": "中国 四川 电信", "channel": "ws",
|
||||
"status": 0, "latencyMs": 15000, "error": "probe timeout" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- **HTTP 200 即便部分节点失败**:单节点成败看 `results[i].status` 与 `results[i].error`。`results[i].channel` 为 `ws` / `http`,表示该节点实际走的通道。
|
||||
- `unknown`:`nodes` 里指定了但不在池中的节点 id。
|
||||
- `persisted`:实际落库条数。仅 `detail`/`ssl`/`dns`/`tcping`/`speed` 这类拨测型入库;`whois`/`dnssec`/`location`/`asn` 等诊断型不入库。
|
||||
- **400** 的几种情况:池为空、`nodes` 全部不在池中(错误信息里会列出已知节点 id)、`apiType` 与 `raw` 缺失。
|
||||
- 这是**并发同步**调用,耗时取决于最慢的节点(WS 通道有独立超时)。客户端超时应放宽到 **30s 以上**。
|
||||
|
||||
---
|
||||
|
||||
## 三、节点与用量
|
||||
|
||||
### 3.1 `GET /api/v1/nodes`
|
||||
|
||||
节点池简表,**脱敏**——只有启用中的节点,不含上游地址。
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "nodeId": "cn-jiangsu", "label": "中国 江苏 移动",
|
||||
"ws": false, "pools": "api,location", "stack": "", "online": true, "version": "v1.4.0" }
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `ws` | 该节点拨测是否走 WS 通道 |
|
||||
| `pools` | 归属池,逗号分隔:`api` / `location` / `api,location` |
|
||||
| `stack` | 仅 `api` 池有意义:`DualStack` / `IPv4` / `IPv6` |
|
||||
| `online` | 是否在线(来自节点在线快照) |
|
||||
| `version` | 节点上报的版本号;从未上线则为 `""` |
|
||||
|
||||
### 3.2 `GET /api/v1/usage?hours=24`
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `hours` | 否 | 统计窗口(小时),缺省 24,夹在 1 ~ 2160 |
|
||||
|
||||
```json
|
||||
{
|
||||
"hours": 24, "total": 1440, "up": 1432, "down": 8, "invalid": 0,
|
||||
"byType": [
|
||||
{ "apiType": "detail", "total": 1200, "up": 1194, "down": 6, "invalid": 0, "avgMs": 128 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- 口径:admin = 全站(`sched` + `biz` 样本);普通用户 = 自己任务的定时样本 + 自己发起的业务拨测。
|
||||
- `byType` 按 `total` 倒序;`invalid` = 无法判定的样本,不计入成败。
|
||||
|
||||
---
|
||||
|
||||
## 附:免登录的公开状态接口
|
||||
|
||||
SLA 任务的**分享组**对外开放状态页,无需任何凭据。它不属于 `/api/v1`(不挂鉴权),但因同样面向公众而并列在此。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/s/:token` | 只读状态页(Vue SPA,30s 自刷新;需托管侧提供 SPA fallback) |
|
||||
| GET | `/api/public/status/:token` | 同数据的 JSON(页面用),参数 `hours` 同上 |
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "a1b2c3", "hours": 24,
|
||||
"tasks": [
|
||||
{ "taskId": 5, "name": "官网可用性", "apiType": "detail", "target": "example.com",
|
||||
"hours": 24, "samples": 1440, "up": 1432, "down": 8, "availability": 99.44,
|
||||
"avgMs": 128, "maxMs": 2103, "p95Ms": 402,
|
||||
"byNode": [ { "nodeId": "cn-jiangsu", "up": 720, "down": 0,
|
||||
"availability": 100, "avgMs": 96, "p95Ms": 180, "latestMs": 88 } ],
|
||||
"series": [ { "time": "2026-09-13T07:00:00Z", "minute": 29758260, "samples": 10,
|
||||
"up": 10, "down": 0, "availability": 100, "avgMs": 120.5 } ] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- 同一令牌的任务**同页展示**(令牌即"分享组");令牌为 3~32 位小写字母/数字/连字符,可自定义,管理接口见控制台「SLA 监控 → 分享」。
|
||||
- **暴露面刻意收窄**:只有任务名/类型/目标 + 窗口聚合与延迟时序(`series` 为每轮多节点平均;多节点时另给 `nodeSeries` 分节点曲线);不含 owner、上游地址、内部字段。任务勾选 `hideTarget` 时,`target` 字段**整体不出现在 JSON 里**。
|
||||
- 令牌未知或已关闭分享 → `404 not_found`(`invalid share link`)。
|
||||
- **防枚举限流**:每 IP 60 次/分,超限 `429 rate_limited`(随机令牌仅 24-bit 熵,必须有这层保护);响应带 `Cache-Control: no-store`。
|
||||
|
||||
---
|
||||
|
||||
## 排错
|
||||
|
||||
| 现象 | 原因与处理 |
|
||||
|------|------------|
|
||||
| `401 missing bearer token` | 没带 `Authorization: Bearer ` 前缀(注意 `Bearer` 后有一个空格) |
|
||||
| `401 invalid or revoked token` | Token 被重新生成过或已吊销 → 重新生成一枚 |
|
||||
| `403 not your task` | 用普通用户的 Token 去读别人的任务 |
|
||||
| `400 source 只能是 sched / biz` | `?source=` 传了其它取值 |
|
||||
| `400 since 须为 RFC3339 时间` | 时间必须带时区,如 `2026-09-13T00:00:00Z` |
|
||||
| `POST /probes` 400 且提示 known nodes | `nodes` 里的 id 都不在节点池 → 先用 `GET /api/v1/nodes` 取真实 id |
|
||||
| `POST /probes` 耗时很久 | 同步聚合要等最慢节点 → 客户端超时设到 30s 以上 |
|
||||
| 列表查不到自己刚建的任务 | 列表按 `id` 倒序;确认用的是同一个账号的 Token(普通用户只见自己的) |
|
||||
@@ -8,8 +8,8 @@ hero:
|
||||
tagline: IP 查询 · 网站检测 · SSL · DNS · DNSSEC · Whois · ASN · 截图
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 部署指南
|
||||
link: /guide/
|
||||
text: 快速开始
|
||||
link: /guide/getting-started
|
||||
- theme: alt
|
||||
text: GitHub
|
||||
link: https://github.com/nomdn/ipw-cn
|
||||
@@ -26,6 +26,10 @@ features:
|
||||
details: 多节点并发查询,支持 A / AAAA / CNAME / MX / TXT / NS / SRV / PTR / CAA 等记录类型,并提供 DNSSEC 链式信任验证。
|
||||
- title: SSRF 防护
|
||||
details: 自动拦截对内网 / 私有 IP 的出站请求,校验安全跳转,避免服务端请求伪造风险。
|
||||
- title: 分布式节点与 WS 通道
|
||||
details: 节点可作为客户端接入中间件,拨测请求经 WebSocket 下发与回传;支持多地址同时连接(多活)、断线自动重连,`ws-url` 改动热生效无需重启。
|
||||
- title: 远程运维
|
||||
details: 节点上报版本号与能力清单,收集中心展示版本、远程读写运行时配置,并可下发一次性 OTA 升级任务(下载 → 校验 → 替换 → 重启,失败自动回滚)。
|
||||
- title: 多平台部署
|
||||
details: 支持二进制,Docker,乃至AWS Lambda,EdgeOne Makers,Vercel和阿里云函数计算
|
||||
details: 支持二进制、Docker,以及 EdgeOne Makers 和 Vercel 部署
|
||||
---
|
||||
@@ -0,0 +1,79 @@
|
||||
# 功能总览
|
||||
|
||||
后端节点(`src/`,Go + Gin)承担全部检测能力,可在任意地区部署组成去中心化测试网络。接口清单见 [节点 API 与协议参考](/guide/node-api)。
|
||||
|
||||
## 探测能力矩阵
|
||||
|
||||
| 能力 | 路由 | 依赖 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 网站探测明细 | `/v1/detail/*url` | 出站 HTTP(v4/v6 双客户端) | DNS 解析、TCP 连接、TLS 握手、首字节、总耗时、状态码、页面体积、下载速度、可达性 |
|
||||
| SSL 证书检查 | `/v1/ssl/*url` | TLS 握手 | 证书有效期/剩余天数、颁发机构、主体、TLS 版本、是否过期 |
|
||||
| 网站测速 | `/v1/speed/:version/*url` | 出站下载 | 指定栈的下载速度测试 |
|
||||
| TCPing | `/v1/tcping/:ip` | TCP 拨号 | 端口连通性 + RTT 统计(次数可配) |
|
||||
| DNS 解析 | `/v1/dns/:type/*domain` | `dns-server` | 9 种记录类型;DNS 服务器支持主从 failover 与 DoH |
|
||||
| DNSSEC 验证 | `/v1/dnssec/:domain` | `dnssec-server` | DNSKEY / RRSIG / DS 链式信任校验 |
|
||||
| Whois | `/v1/whois/:domain` | 上游 whois 服务器 | 域名注册商、注册/到期时间等 |
|
||||
| IP 归属地 | `/v1/location`、`/v1/location/:ip` | IP 数据库(10 源) | 多源聚合,含国家/省/市/ISP/经纬度/时区 |
|
||||
| ASN 查询 | `/v1/asn/:ip` | 3 套 ASN 库 + Whois | 自治系统号与所属组织 |
|
||||
|
||||
**双栈**:除 IP 归属地/ASN 外的能力均按 IPv4 / IPv6 分别执行并同时返回(`single-stack` 可声明单栈节点以跳过另一栈)。
|
||||
**统计口径**:所有 `/v1/*` 请求经统计中间件计数,由节点按周期上报收集中心(见 [节点 API · 数据上报](/guide/node-api))。
|
||||
|
||||
## IP 数据源
|
||||
|
||||
`ipdb` 未关闭时启动加载,共 10 个源;任一本地文件缺失即从远端拉取全部数据库(首次约 450MB,之后每 24h + 随机抖动更新一次)。
|
||||
|
||||
| 源 | 用途 | 形式 |
|
||||
|----|------|------|
|
||||
| ip2region | 国家 / 省 / 市 / ISP | 本地 xdb(v4 + v6) |
|
||||
| IP2Location LITE DB11 | 国家 / 省 / 市 / ISP / 经纬度 / 时区 | 本地 BIN |
|
||||
| IP2Location LITE ASN | ASN | 本地 BIN |
|
||||
| qqwry | 国家 / 省 / 市 / ISP / 国家码 | 本地 ipdb |
|
||||
| MaxMind GeoLite2 City | 国家 / 省 / 市 / 经纬度 | 本地 MMDB |
|
||||
| MaxMind GeoLite2 ASN | ASN / 组织 | 本地 MMDB |
|
||||
| DbIP City Lite | 国家 / 省 / 市 / 经纬度 | 本地 MMDB |
|
||||
| DbIP ASN Lite | ASN / 组织 | 本地 MMDB |
|
||||
| GeoCN | 中国行政区划码 + ISP + 类型 | 本地 MMDB + 行政区划表 |
|
||||
| Bilibili | 国家 / 省 / 市 / ISP / 经纬度 | **在线 API**,24h 缓存 |
|
||||
|
||||
- `/v1/location` 聚合全部 10 源(结果按源分组返回,便于交叉比对);`/v1/asn` 只查三套 ASN 库并对 MaxMind ASN 做 Whois 解析。
|
||||
- `gh-proxy` 可给 GitHub 下载加代理前缀加速;GeoCN 走 jsDelivr CDN。
|
||||
- `ipdb=false` 时整体关闭:不加载数据库,`/v1/location`、`/v1/asn` 路由不注册,适合只做网络拨测的轻量节点。
|
||||
|
||||
## 可靠性设计
|
||||
|
||||
- **结果缓存**:7 类缓存(6 个业务缓存 5 分钟 + Bilibili 24 小时),失败结果 30s 后即失效;每 10 分钟定时清扫过期条目。详见 [节点 API · 缓存与并发](/guide/node-api)。
|
||||
- **singleflight**:`detail` / `ssl` / `tcping` / `speed` 的并发同 key 请求合并为一次真实探测。
|
||||
- **SSRF 防护**(`block-private-ips`):拒绝出站连接私有/内网地址,探测目标命中私有网段时直接返回伪造结果,禁止跨跳重定向;`trusted-proxies` 控制 `X-Forwarded-For` 信任范围,防止访客伪造来源 IP。
|
||||
- **DNS 兜底**:`dns-server` / `dnssec-server` 留空时自动探测系统 DNS,避免容器环境下解析不可用。
|
||||
|
||||
## 与收集中心 / 中间件的关系
|
||||
|
||||
```
|
||||
┌───────────────────────────────┐
|
||||
浏览器 / 前端 ───▶│ 中间件(ipw-cn/middleware-go │
|
||||
│ 或 ipw-boce 收集中心) │
|
||||
└───────┬───────────────┬───────┘
|
||||
HTTP │ │ WS(/ws)
|
||||
转发 ▼ ▼
|
||||
┌───────────────────────────────┐
|
||||
│ 后端节点(本仓库) │
|
||||
│ 执行探测 → 结果原路回传 │
|
||||
│ 统计/明细 → 周期上报收集中心 │
|
||||
└───────────────────────────────┘
|
||||
```
|
||||
|
||||
- **转发链路**:前端不直连节点,而是经中间件按池轮询转发;节点也可以直接对外提供 `/v1/*`(自建纯节点模式)。
|
||||
- **WS 通道**:节点作为客户端接入中间件,拨测请求经 WS 下发、结果经 WS 回传;同时承载配置管理(`config`)与升级(`ota`)指令。
|
||||
- **数据上报**:统计与拨测明细只由节点上报,中间件不在转发路径上采集,避免双算。
|
||||
- **版本与能力上报**:节点上报版本号与能力清单,收集中心据此展示版本、判断能否下发管理指令。
|
||||
|
||||
## 部署形态
|
||||
|
||||
| 形态 | 说明 |
|
||||
|------|------|
|
||||
| 二进制 / systemd | `install.sh` 一键安装,配置以环境变量注入 |
|
||||
| Docker | `src/Dockerfile` 多阶段构建,多架构(amd64 / arm64 / armv7);镜像不含 IP 库,首次运行现场拉取 |
|
||||
| Serverless | EdgeOne Go 边缘函数、Vercel 部署入口(仓库提供实现;均为 IPv4-only,需声明 `SINGLE_STACK=ipv4`) |
|
||||
|
||||
各形态的详细步骤见 [后端节点部署](/guide/deploy-node)。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 柠檬味IPW
|
||||
|
||||
柠檬味ipw是一个基于Golang的分布式站点测试平台,旨在构建一个去中心化的,共建贡献的测试平台。
|
||||
> [!TIP]
|
||||
> 尝试一下?这里是官方站点:[链接](https://ipw.wsmdn.top)
|
||||
|
||||
## 简介
|
||||
|
||||
Lemon IPW(柠檬味 ipw.cn 替代品)是一个开源的分布式站点测试平台。与中心化测试服务不同,本项目鼓励社区成员部署自己的测试节点,前端多节点轮询、后端多地区部署,组成一张去中心化的测试网络,单一节点故障不影响整体可用性。
|
||||
|
||||
## 功能
|
||||
|
||||
- IP 地址查询(IPv4/IPv6,集成 ip2region、qqwry、MaxMind、GeoCN、DbIP 等多数据源)
|
||||
- ASN 自治系统查询(MaxMind、DbIP,集成 WHOIS 解析)
|
||||
- 网站检测(HTTP 状态码、响应时间)
|
||||
- SSL 证书检查(有效期、颁发机构)
|
||||
- DNS 解析(A/AAAA/CNAME/MX/TXT/NS/SRV/PTR/CAA 等,多节点并发)
|
||||
- DNSSEC 验证(DNSKEY、RRSIG、DS 链式信任)
|
||||
- Whois 查询
|
||||
- TCPing(IPv4/IPv6 双栈)
|
||||
- 网站测速
|
||||
- 网站截图
|
||||
- 远程运维(版本 / 能力上报、运行时配置下发、一次性 OTA 升级)
|
||||
|
||||
完整的能力矩阵、IP 数据源与可靠性设计见 [功能总览](/info/features)。
|
||||
|
||||
## 架构
|
||||
|
||||
前后端分离,可独立部署、自由组合:
|
||||
|
||||
- 前端(Nuxt 4 SSR)负责数据可视化与接口调用
|
||||
- 后端(Go)提供全部检测 API,可在任意地区部署测试节点,是分布式查询的核心
|
||||
- 独立中间件(middleware-go)使用Fiber技术栈 负责请求转发和 Key 注入
|
||||
|
||||
后端支持二进制、Docker 自托管,以及仓库中提供的 EdgeOne Go 边缘函数和对应的 Vercel 部署入口。AWS Lambda、阿里云函数计算目前没有对应的实现或部署配置。
|
||||
|
||||
## 技术栈
|
||||
|
||||
Go、Gin、Nuxt 4、Vue 3、Element Plus。
|
||||
|
||||
## 下一步
|
||||
|
||||
- 想自己部署一套?前往 [部署指南](/guide/)
|
||||
- 想了解前端如何部署?阅读 [前端部署](/guide/deploy-frontend)
|
||||
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 264 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1626616227481" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2469" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M512 64a448 448 0 1 0 0 896A448 448 0 0 0 512 64z m0 820.032A372.032 372.032 0 0 1 512 139.968a372.032 372.032 0 0 1 0 744.064z" fill="#07c160" p-id="2470"></path><path d="M382.208 731.264V320H320v411.264h62.208z m144.576 0V571.136h106.56c98.496 0 148.032-42.048 148.032-126.144C781.376 361.472 732.416 320 634.496 320H464v411.264h62.784z m102.528-213.696H526.784v-144h102.528c30.528 0 52.992 5.76 67.392 17.28 14.4 10.368 21.888 28.8 21.888 54.144 0 25.344-7.488 43.776-21.312 55.296-14.4 11.52-36.864 17.28-67.968 17.28z" fill="#07c160" p-id="2471"></path></svg>
|
||||
|
After Width: | Height: | Size: 941 B |
@@ -1,396 +0,0 @@
|
||||
package webtest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// 腾讯云标准 DoH 端点
|
||||
var dohEndpoint string = "https://doh.pub/dns-query"
|
||||
|
||||
// UDP DNS 服务器(DoH 不可用时的回退;dns-server 配置为 ip:port 时作为主通道)
|
||||
var dnsServer = "119.28.28.28:53"
|
||||
|
||||
// dnsMode 主通道:dns-server 配置为 URL 时为 "doh",为 ip:port 时为 "udp"
|
||||
var dnsMode = "doh"
|
||||
|
||||
type DNSResult struct {
|
||||
Domain string `json:"domain"`
|
||||
Duration float64 `json:"duration"`
|
||||
Record []string `json:"record"`
|
||||
TTL uint32 `json:"ttl"`
|
||||
}
|
||||
|
||||
// SetDNSServer 设置 DNS 服务器:URL(http/https)→ DoH 主通道;ip:port → UDP 主通道
|
||||
func SetDNSServer(server string) {
|
||||
if server == "" {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(server, "http://") || strings.HasPrefix(server, "https://") {
|
||||
dohEndpoint = server
|
||||
dnsMode = "doh"
|
||||
} else {
|
||||
dnsServer = server
|
||||
dnsMode = "udp"
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ⭐️ 底层核心:DoH + UDP 双通道 DNS 查询
|
||||
// ==========================================
|
||||
|
||||
// queryDNSMsg 统一 DNS 查询入口:支持 DoH 与 UDP 双通道自动互备。
|
||||
// dnsMode 为 "doh" 时优先 DoH(dohEndpoint),失败回退 UDP(dnsServer);
|
||||
// dnsMode 为 "udp" 时优先 UDP(dnsServer),失败回退 DoH(dohEndpoint)。
|
||||
func queryDNSMsg(msg *dns.Msg) (*dns.Msg, float64, error) {
|
||||
if dnsMode == "doh" {
|
||||
resp, dur, err := queryDoHMsg(msg, dohEndpoint)
|
||||
if err == nil {
|
||||
return resp, dur, nil
|
||||
}
|
||||
slog.Warn("DoH query failed, falling back to UDP", "endpoint", dohEndpoint, "error", err)
|
||||
return queryUDPMsg(msg, dnsServer)
|
||||
}
|
||||
resp, dur, err := queryUDPMsg(msg, dnsServer)
|
||||
if err == nil {
|
||||
return resp, dur, nil
|
||||
}
|
||||
slog.Warn("UDP query failed, falling back to DoH", "server", dnsServer, "error", err)
|
||||
return queryDoHMsg(msg, dohEndpoint)
|
||||
}
|
||||
|
||||
// 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}
|
||||
start := time.Now()
|
||||
resp, _, err := client.Exchange(msg, server)
|
||||
duration := time.Since(start).Seconds() * 1000
|
||||
if err != nil {
|
||||
return nil, duration, err
|
||||
}
|
||||
return resp, duration, nil
|
||||
}
|
||||
|
||||
// queryDoHMsg 通过 DoH(RFC 8484,POST application/dns-message)向指定端点发送查询
|
||||
func queryDoHMsg(msg *dns.Msg, endpoint string) (*dns.Msg, float64, error) {
|
||||
packedMsg, err := msg.Pack()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to pack DNS message: %v", err)
|
||||
}
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(packedMsg))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/dns-message")
|
||||
req.Header.Set("Accept", "application/dns-message")
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
start := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
duration := time.Since(start).Seconds() * 1000
|
||||
if err != nil {
|
||||
return nil, duration, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, duration, fmt.Errorf("DoH API returned status %d", resp.StatusCode)
|
||||
}
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, duration, fmt.Errorf("failed to read response body: %v", err)
|
||||
}
|
||||
responseMsg := new(dns.Msg)
|
||||
if err := responseMsg.Unpack(bodyBytes); err != nil {
|
||||
return nil, duration, fmt.Errorf("failed to unpack DNS response: %v", err)
|
||||
}
|
||||
return responseMsg, duration, nil
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 业务层:构造查询并经双通道执行,提取特定记录
|
||||
// ==========================================
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 业务层:调用底层函数并提取特定记录
|
||||
// ==========================================
|
||||
|
||||
func ResolveARecord(domain string) (DNSResult, error) {
|
||||
result := DNSResult{Domain: domain, Record: []string{}}
|
||||
responseMsg, duration, err := executeDoHQuery(domain, dns.TypeA)
|
||||
result.Duration = duration
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query A", "domain", domain, "err", err)
|
||||
return result, err
|
||||
}
|
||||
for _, ans := range responseMsg.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 result, nil
|
||||
}
|
||||
|
||||
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 result, nil
|
||||
}
|
||||
|
||||
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 result, nil
|
||||
}
|
||||
|
||||
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 result, nil
|
||||
}
|
||||
|
||||
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 result, nil
|
||||
}
|
||||
|
||||
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 result, nil
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
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 result, nil
|
||||
}
|
||||
|
||||
// ResolveIP 通过 DoH 解析域名,返回指定版本(v4/v6)的 IP 地址字符串
|
||||
func ResolveIP(host string, version string) (string, error) {
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if version == "v4" && ip.To4() != nil {
|
||||
return ip.String(), nil
|
||||
}
|
||||
if version == "v6" && ip.To4() == nil && ip.To16() != nil {
|
||||
return ip.String(), nil
|
||||
}
|
||||
return "", fmt.Errorf("no %s address found for %s", version, host)
|
||||
}
|
||||
|
||||
var qtype uint16
|
||||
switch version {
|
||||
case "v6":
|
||||
qtype = dns.TypeAAAA
|
||||
default:
|
||||
qtype = dns.TypeA
|
||||
}
|
||||
|
||||
responseMsg, _, err := executeDoHQuery(host, qtype)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, ans := range responseMsg.Answer {
|
||||
switch v := ans.(type) {
|
||||
case *dns.A:
|
||||
if qtype == dns.TypeA {
|
||||
return v.A.String(), nil
|
||||
}
|
||||
case *dns.AAAA:
|
||||
if qtype == dns.TypeAAAA {
|
||||
return v.AAAA.String(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no %s record found for %s", version, host)
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 并发查询所有记录
|
||||
// ==========================================
|
||||
|
||||
type DNSFullResult struct {
|
||||
Domain string `json:"domain"`
|
||||
A DNSResult `json:"a"`
|
||||
AAAA DNSResult `json:"aaaa"`
|
||||
CNAME DNSResult `json:"cname"`
|
||||
MX DNSResult `json:"mx"`
|
||||
NS DNSResult `json:"ns"`
|
||||
TXT DNSResult `json:"txt"`
|
||||
SRV DNSResult `json:"srv"`
|
||||
CAA DNSResult `json:"caa"`
|
||||
}
|
||||
|
||||
func ResolveARecordllDNSRecords(domain string) DNSFullResult {
|
||||
result := DNSFullResult{Domain: domain}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(8)
|
||||
|
||||
go func() { defer wg.Done(); result.A, _ = ResolveARecord(domain) }()
|
||||
go func() { defer wg.Done(); result.AAAA, _ = ResolveAAAARecord(domain) }()
|
||||
go func() { defer wg.Done(); result.CNAME, _ = ResolveCNAMERecord(domain) }()
|
||||
go func() { defer wg.Done(); result.MX, _ = ResolveMXRecord(domain) }()
|
||||
go func() { defer wg.Done(); result.NS, _ = ResolveNSRecord(domain) }()
|
||||
go func() { defer wg.Done(); result.TXT, _ = ResolveTXTRecord(domain) }()
|
||||
go func() { defer wg.Done(); result.SRV, _ = ResolveSRVRecord(domain) }()
|
||||
go func() { defer wg.Done(); result.CAA, _ = ResolveCAARecord(domain) }()
|
||||
|
||||
wg.Wait()
|
||||
return result
|
||||
}
|
||||
@@ -1,16 +1,36 @@
|
||||
<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';
|
||||
|
||||
const isNarrow = ref(false);
|
||||
// ==================== 窄屏判定(水合前就必须定下来) ====================
|
||||
// 为什么不能等到 onMounted 才算:SSR 不知道视口宽度,只能按宽屏输出 ——
|
||||
// 于是整条桌面版顶栏菜单(10 个条目 + 分隔线 + 3 个下拉)会被下发给手机;
|
||||
// 水合时 isNarrow 还是 false,手机就先把这一整套水合一遍
|
||||
// (实测 375×812:header-menu 区域 268 次 DOM 变更、70 次事件绑定),
|
||||
// 紧接着 onMounted 把它改成窄屏、再整棵销毁重建 —— 这一整轮纯属白干。
|
||||
// 所以改成「服务端按请求头判定、客户端直接沿用服务端序列化下来的结论」,
|
||||
// 两边输入一致 ⇒ 结论一致 ⇒ 水合期不产生任何分歧,桌面上仍是原来的形态。
|
||||
// 权威值依旧由 onMounted 的 matchMedia 覆盖,管「桌面窗口被拖窄」这类请求头判不出的情形。
|
||||
//
|
||||
// 注意:**不要**在客户端重新嗅探 navigator.userAgent。SSR 页两边大概率巧合一致,
|
||||
// 但预渲染页没有请求头(服务端恒判宽屏),客户端一嗅探就必然分叉 ——
|
||||
// 实测那会让整个 /doc/** 在真手机上水合失配、Nuxt 直接渲染 500 页。
|
||||
// 判定逻辑与文档见 app/composables/useShellNarrow.ts。
|
||||
const isNarrow = useShellNarrow()
|
||||
let mediaQueryList: MediaQueryList | null = null;
|
||||
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 +104,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 +130,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)');
|
||||
@@ -116,19 +152,28 @@ onMounted(() => {
|
||||
window.removeEventListener('resize', updateViewport);
|
||||
});
|
||||
|
||||
if (!umamiScript && config.umamiScriptUrl) {
|
||||
// 统计脚本延迟到浏览器空闲再注入:umami 源响应慢(实测 ~1.6s),
|
||||
// 挂在 onMounted 里会和首页首屏的拨测请求抢慢速网络的带宽,
|
||||
// 拖累 Speed Index;统计本身不需要抢首屏前的任何窗口。
|
||||
const loadUmami = () => {
|
||||
if (umamiScript || !config.umamiScriptUrl) return
|
||||
umamiScript = document.createElement('script')
|
||||
umamiScript.src = config.umamiScriptUrl
|
||||
umamiScript.async = true
|
||||
umamiScript.setAttribute('data-website-id', config.umamiWebsiteId)
|
||||
document.head.appendChild(umamiScript)
|
||||
}
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(loadUmami, { timeout: 4000 })
|
||||
} else {
|
||||
setTimeout(loadUmami, 2000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<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 +194,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 +250,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 +273,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 +289,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 +352,107 @@ 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;
|
||||
--el-color-primary: #299764;
|
||||
/* 正文/页脚链接色:亮色白底需 ≥4.5:1(axe color-contrast 审计线)。
|
||||
#299764 白底只有 3.68:1,加深到 #1d7a4a = 5.34:1,留足余量。
|
||||
主题色(按钮/高亮等 --el-color-primary 消费方)保持 #299764 不动,只分流链接。 */
|
||||
--link-color: #1d7a4a;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
/* 暗色模式下根元素的文字色与页面底色。
|
||||
这两条必须在【非 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: #299764;
|
||||
/* 暗底 #242424 上 #299764 对比 ~4.2:1,且再加深只会更差 —— 暗色链接保持原色 */
|
||||
--link-color: #299764;
|
||||
}
|
||||
|
||||
/* 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 +474,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>
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 顶栏 / 抽屉「宽屏还是窄屏」的唯一判定入口(服务端算一次,客户端沿用)。
|
||||
*
|
||||
* 为什么客户端不自己嗅探 navigator.userAgent:
|
||||
* SSR 出的 HTML 是跟着【服务端】的判定走的。客户端若各算各的,只要两边结论不一致,
|
||||
* Vue 水合就会整棵树失配 —— 实测在预渲染的 /doc/** 上是直接抛
|
||||
* `Cannot read properties of null (reading 'nodeType')`,然后被 Nuxt 渲染成 500 页
|
||||
* (真手机打开文档站必然复现:预渲染时没有请求头 ⇒ 服务端恒判宽屏)。
|
||||
* 把服务端的判定序列化进 payload 当客户端的初值,首帧 vdom 与 HTML 必然一致;
|
||||
* 顺带还免疫「CDN 把桌面版缓存回给手机」——那种情况下客户端也会乖乖按 HTML 走宽屏。
|
||||
*
|
||||
* 权威值仍由 app.vue 的 onMounted 用 matchMedia 覆盖,管「桌面窗口被拖窄」这类
|
||||
* 请求头判不出的情形。窄屏判定是全局共享状态(useState),文档页据此决定要不要
|
||||
* 渲染侧栏,所以两边读的必须是同一个 ref。
|
||||
*/
|
||||
const CRAWLER_RE =
|
||||
/bot|crawler|spider|slurp|bingbot|baiduspider|yandex|duckduckbot|facebookexternalhit|preview/i
|
||||
|
||||
export function uaIsNarrow(ua: string, chMobile?: string) {
|
||||
if (!ua && !chMobile) return false // 两个都拿不到(含预渲染):按宽屏,与改造前一致
|
||||
if (CRAWLER_RE.test(ua)) return false // 抓取端一律宽屏,否则顶栏导航链接会从 HTML 里消失
|
||||
if (chMobile) return chMobile === '?1' // Chromium 的 Client Hint,最准
|
||||
return /Android|iPhone|iPod|Windows Phone|webOS|BlackBerry|Opera Mini|IEMobile|Mobile/i.test(ua)
|
||||
}
|
||||
|
||||
export function useShellNarrow() {
|
||||
// 只有服务端拿得到请求头;客户端分支只为类型与「init 被意外调用」兜底。
|
||||
const headers: Record<string, string | undefined> = import.meta.server
|
||||
? useRequestHeaders(['user-agent', 'sec-ch-ua-mobile'])
|
||||
: {}
|
||||
return useState<boolean>('ipw-shell-narrow', () =>
|
||||
uaIsNarrow(headers['user-agent'] || '', headers['sec-ch-ua-mobile']),
|
||||
)
|
||||
}
|
||||
@@ -9,10 +9,10 @@ html.dark .markdown-body {
|
||||
/* GitHub 暗色主题的核心颜色 */
|
||||
--color-fg-default: #e6edf3;
|
||||
--color-fg-muted: #8b949e;
|
||||
--color-fg-accent: #3EAF7C;
|
||||
--color-fg-accent: #299764;
|
||||
--color-bg-default: #242424;
|
||||
--color-bg-muted: #080707;
|
||||
--color-bg-accent: #3EAF7C;
|
||||
--color-bg-accent: #299764;
|
||||
--color-border-default: #242424;
|
||||
--color-border-muted: #242424;
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ const route = useRoute()
|
||||
useHead({
|
||||
title: `ASN查询 | IP自治系统号查询 | ${config.siteName}`,
|
||||
meta: [
|
||||
{ name: 'description', content: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP双数据源对比,同时集成WHOIS解析获取ASN详细信息,包括组织名称、国家、注册日期等,助力网络运维和路由分析' },
|
||||
{ name: 'description', content: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP、IP2Location 多数据源对比,同时集成WHOIS解析获取ASN详细信息,包括组织名称、国家、注册日期等,助力网络运维和路由分析' },
|
||||
{ name: 'keywords', content: 'asn查询,自治系统号,asn lookup,ip asn,asn whois,网络自治系统,运营商asn,ip归属asn' },
|
||||
{ property: 'og:title', content: 'ASN自治系统查询工具 - IP ASN号与组织信息查询' },
|
||||
{ property: 'og:description', content: '多数据源ASN查询,支持Maxmind GEOLite2和DB-IP,集成WHOIS解析获取ASN详细信息' },
|
||||
{ property: 'og:description', content: '多数据源ASN查询,支持Maxmind GEOLite2、DB-IP、IP2Location,集成WHOIS解析获取ASN详细信息' },
|
||||
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
],
|
||||
@@ -24,7 +24,7 @@ useHead({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebApplication',
|
||||
name: 'ASN自治系统查询工具',
|
||||
description: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP双数据源对比,集成WHOIS解析。',
|
||||
description: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP、IP2Location 多数据源对比,集成WHOIS解析。',
|
||||
url: config.siteUrl + 'asn',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
operatingSystem: 'Web',
|
||||
@@ -46,6 +46,7 @@ interface ASNResult {
|
||||
ip: string
|
||||
geolite2_asn?: { asn: string; org: string }
|
||||
dbip_asn?: { asn: string; org: string }
|
||||
ip2location_asn?: { asn: string; as: string }
|
||||
whois?: any
|
||||
}
|
||||
|
||||
@@ -55,12 +56,12 @@ const result = ref<ASNResult | null>(null)
|
||||
const error = ref('')
|
||||
const userIP = ref('')
|
||||
|
||||
const apiList = config.IPLocationAPIs
|
||||
const apiList = config.IPLocationAPI
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
|
||||
|
||||
const { data: asnData, error: asnError, execute: executeASN } = useMiddlewareFetch<ASNResult>(() => '/middleware/' + backendID.value + '/asn/' + tmpIP.value, {
|
||||
const { data: asnData, error: asnError, execute: executeASN } = useMiddlewareFetch<ASNResult>(() => '/middleware/' + backendID.value + '/asn/' + encodeURIComponent(tmpIP.value), {
|
||||
key: 'asn-fetch',
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -118,11 +119,11 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
async function getUserIP() {
|
||||
await $fetch<string>(config.DualStackAPI).then(
|
||||
function (data) {
|
||||
userIP.value = data
|
||||
}
|
||||
)
|
||||
try {
|
||||
userIP.value = await $fetch<string>(config.DualStackAPI)
|
||||
} catch {
|
||||
// 获取失败保持为空,避免未处理的 Promise 拒绝
|
||||
}
|
||||
return userIP.value
|
||||
}
|
||||
|
||||
@@ -181,10 +182,17 @@ 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">
|
||||
{{ result.ip2location_asn.asn }}
|
||||
<span v-if="result.ip2location_asn.as" style="color: #999; font-size: 0.9em;">{{ result.ip2location_asn.as }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="result.whois?.asNumber">
|
||||
<td class="table-label">ASN</td>
|
||||
<td class="table-value">AS{{ result.whois.asNumber }}</td>
|
||||
@@ -226,7 +234,6 @@ function getStatusClass(status: string): string {
|
||||
</div>
|
||||
|
||||
<blockquote>
|
||||
<a href="/doc/user/asn" target="_blank">ASN 查询原理介绍</a><br/>
|
||||
ASN(Autonomous System Number)是互联网中每个自治系统的唯一标识符。<br/>
|
||||
数据来源:Maxmind GEOLite2、DB-IP,部分节点集成 WHOIS 进一步解析 ASN 详情。<br/>
|
||||
<a href="/location" target="_blank">IP 归属地查询</a> | <a href="/whois" target="_blank">Whois 查询</a><br/>
|
||||
@@ -254,10 +261,10 @@ function getStatusClass(status: string): string {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -50,13 +50,15 @@ const results = ref<any>([])
|
||||
const isloading = ref(false)
|
||||
const nowRecordType = ref('')
|
||||
const userIP = ref('')
|
||||
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
|
||||
let queryId = 0
|
||||
|
||||
async function getUserIP(){
|
||||
|
||||
await $fetch<string>(config.DualStackAPI).then(
|
||||
function (data){
|
||||
userIP.value = data
|
||||
})
|
||||
try {
|
||||
userIP.value = await $fetch<string>(config.DualStackAPI)
|
||||
} catch {
|
||||
// 获取失败保持为空,避免未处理的 Promise 拒绝
|
||||
}
|
||||
return userIP.value
|
||||
}
|
||||
|
||||
@@ -72,8 +74,12 @@ const recordTypes = [
|
||||
{ value: 'ptr', label: 'PTR 记录' }
|
||||
]
|
||||
|
||||
const dnsServerFetches = config.NSLookup.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/dns/" + recordType.value + "/" + domain.value);
|
||||
const dnsServerFetches = [
|
||||
...config.APIBaseURL.DualStack,
|
||||
...config.APIBaseURL.IPv4,
|
||||
...config.APIBaseURL.IPv6
|
||||
].map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/dns/" + recordType.value + "/" + encodeURIComponent(domain.value));
|
||||
const { data, error, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -85,7 +91,9 @@ async function queryDNS() {
|
||||
isloading.value = true
|
||||
domain.value = tmpDomain.value
|
||||
await nextTick()
|
||||
|
||||
|
||||
const id = ++queryId
|
||||
|
||||
// 初始化结果数组,保持响应式结构
|
||||
results.value = dnsServerFetches.map(fetch => ({
|
||||
server: fetch.label,
|
||||
@@ -97,7 +105,20 @@ async function queryDNS() {
|
||||
const promises = dnsServerFetches.map(async (fetch, index) => {
|
||||
try {
|
||||
await fetch.execute();
|
||||
|
||||
|
||||
if (id !== queryId) return null
|
||||
|
||||
// execute() 不会抛错,全部候选失败时以 error.value 为准(否则故障节点渲染成空白)
|
||||
if (fetch.error.value) {
|
||||
results.value[index] = {
|
||||
server: fetch.label,
|
||||
loading: false,
|
||||
data: null,
|
||||
error: fetch.error.value
|
||||
};
|
||||
return { server: fetch.label, error: fetch.error.value };
|
||||
}
|
||||
|
||||
// 直接更新对应索引的结果,保持响应式
|
||||
results.value[index] = {
|
||||
server: fetch.label,
|
||||
@@ -105,12 +126,13 @@ async function queryDNS() {
|
||||
data: fetch.data.value,
|
||||
error: null
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
server: fetch.label,
|
||||
data: fetch.data.value
|
||||
};
|
||||
} catch (err) {
|
||||
if (id !== queryId) return null
|
||||
// 错误时也更新对应索引
|
||||
results.value[index] = {
|
||||
server: fetch.label,
|
||||
@@ -118,7 +140,7 @@ async function queryDNS() {
|
||||
data: null,
|
||||
error: err
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
server: fetch.label,
|
||||
error: err
|
||||
@@ -126,14 +148,25 @@ async function queryDNS() {
|
||||
}
|
||||
});
|
||||
nowRecordType.value = recordType.value
|
||||
const promiseResults = await Promise.all(promises)
|
||||
console.log(promiseResults)
|
||||
isloading.value = false
|
||||
|
||||
const promiseResults = (await Promise.all(promises)).filter(Boolean)
|
||||
if (id === queryId) {
|
||||
isloading.value = false
|
||||
}
|
||||
|
||||
return promiseResults
|
||||
}
|
||||
|
||||
|
||||
// 行内「数据格是否该渲染」判定。
|
||||
//
|
||||
// 结果表共 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
|
||||
@@ -168,7 +201,8 @@ const doc = page.value;
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入域名(如:example.com)"
|
||||
/>
|
||||
<el-select v-model="recordType" style="width: 150px;" class="custom-height-select">
|
||||
<!-- aria-label 落到 EP select 内部的原生 input 上,否则 Lighthouse 的 label 审计必挂 -->
|
||||
<el-select v-model="recordType" style="width: 150px;" class="custom-height-select" aria-label="DNS 记录类型">
|
||||
<el-option
|
||||
v-for="item in recordTypes"
|
||||
:key="item.value"
|
||||
@@ -219,9 +253,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>
|
||||
|
||||
@@ -262,7 +298,7 @@ const doc = page.value;
|
||||
}
|
||||
|
||||
.markdown :deep(a) {
|
||||
color: #3EAF7C !important;
|
||||
color: #299764 !important;
|
||||
font-size: 1.3em;
|
||||
text-decoration: none
|
||||
}
|
||||
@@ -270,11 +306,11 @@ const doc = page.value;
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
|
||||
@@ -68,11 +68,17 @@ const tmpDomain = ref('www.zakoflare.com')
|
||||
const loading = ref(false)
|
||||
const serverResults = ref<ServerResult[]>([])
|
||||
const userIP = ref('')
|
||||
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
|
||||
let queryId = 0
|
||||
|
||||
const apiList = config.NSLookup
|
||||
const apiList = [
|
||||
...config.APIBaseURL.DualStack,
|
||||
...config.APIBaseURL.IPv4,
|
||||
...config.APIBaseURL.IPv6
|
||||
]
|
||||
|
||||
const dnssecFetches = apiList.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/dnssec/" + tmpDomain.value)
|
||||
const url = computed(() => "/middleware/" + server.id + "/dnssec/" + encodeURIComponent(tmpDomain.value.trim()))
|
||||
const { data, error, execute } = useMiddlewareFetch<DNSSECResult>(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -93,6 +99,8 @@ async function checkDNSSEC() {
|
||||
|
||||
loading.value = true
|
||||
|
||||
const id = ++queryId
|
||||
|
||||
serverResults.value.forEach((result) => {
|
||||
result.loading = true
|
||||
result.data = undefined
|
||||
@@ -101,9 +109,15 @@ async function checkDNSSEC() {
|
||||
const promises = dnssecFetches.map(async (fetch, index) => {
|
||||
try {
|
||||
await fetch.execute()
|
||||
if (id !== queryId) return
|
||||
const result = serverResults.value[index]
|
||||
if (result) {
|
||||
result.data = fetch.data.value
|
||||
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成 '-')
|
||||
if (fetch.error.value) {
|
||||
result.error = (fetch.error.value as any)?.message || '请求失败'
|
||||
} else {
|
||||
result.data = fetch.data.value
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
@@ -113,14 +127,16 @@ async function checkDNSSEC() {
|
||||
}
|
||||
} finally {
|
||||
const result = serverResults.value[index]
|
||||
if (result) {
|
||||
if (result && id === queryId) {
|
||||
result.loading = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Promise.allSettled(promises).finally(function () {
|
||||
loading.value = false
|
||||
if (id === queryId) {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -130,8 +146,8 @@ function getValidationClass(result: DNSSECResult): string {
|
||||
return 'status-error'
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
return ms.toFixed(2) + ' ms'
|
||||
function formatDuration(ms?: number): string {
|
||||
return typeof ms === 'number' && isFinite(ms) ? ms.toFixed(2) + ' ms' : '-'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -145,11 +161,11 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
async function getUserIP() {
|
||||
await $fetch<string>(config.DualStackAPI).then(
|
||||
function (data) {
|
||||
userIP.value = data
|
||||
}
|
||||
)
|
||||
try {
|
||||
userIP.value = await $fetch<string>(config.DualStackAPI)
|
||||
} catch {
|
||||
// 获取失败保持为空,避免未处理的 Promise 拒绝
|
||||
}
|
||||
return userIP.value
|
||||
}
|
||||
</script>
|
||||
@@ -240,7 +256,7 @@ async function getUserIP() {
|
||||
</div>
|
||||
|
||||
<blockquote>
|
||||
<a href="/doc/user/dnssec" target="_blank">DNSSEC 原理介绍</a><br/>
|
||||
DNSSEC 原理介绍即将上线<br/>
|
||||
DNSSEC 通过 DNSKEY、RRSIG、DS 记录为 DNS 数据提供完整性验证,防止 DNS 欺骗攻击。<br/>
|
||||
如果显示 "验证失败",可能原因:域名未配置 DNSSEC、DNSKEY 密钥不匹配、DS 记录缺失等。<br/>
|
||||
<a href="/dns" target="_blank">DNS 解析查询</a> | <a href="/whois" target="_blank">Whois 查询</a><br/>
|
||||
@@ -268,10 +284,10 @@ async function getUserIP() {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { getDocMeta } from '../../../config/doc'
|
||||
|
||||
// 窄屏下这条侧栏被 CSS 隐藏(导航在顶栏抽屉里),但照样要花钱水合 ——
|
||||
// 详见 app/pages/doc/index.vue 的同名注释。
|
||||
const isNarrow = useShellNarrow()
|
||||
|
||||
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 +20,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 v-if="!isNarrow" class="sidebar-menu" />
|
||||
<div class="content">
|
||||
<div class="markdown-body" v-if="page">
|
||||
<div v-html="page" />
|
||||
@@ -119,8 +62,10 @@ html.dark .content {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* 窄屏不再挤一条窄侧栏(原先 100px,长标题全被截断):
|
||||
文档导航已并入左侧抽屉菜单,这里直接撤掉,正文铺满整屏 */
|
||||
.sidebar-menu {
|
||||
width: 100px;
|
||||
display: none;
|
||||
}
|
||||
.content {
|
||||
padding: 10px;
|
||||
@@ -129,5 +74,5 @@ html.dark .content {
|
||||
</style>
|
||||
<style>
|
||||
@import "github-markdown-css/github-markdown-light.css";
|
||||
@import "/github-markdown-dark.css";
|
||||
@import "../../github-markdown-dark.css";
|
||||
</style>
|
||||
@@ -1,12 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { getDocMeta } from '../../../config/doc'
|
||||
|
||||
const route = useRoute();
|
||||
// 窄屏(≤768px)下这条侧栏被 CSS 整个隐藏,导航改由顶栏抽屉承载。
|
||||
// 但「只是藏起来」不等于不花钱:Vue 依旧会把整棵 40 项的 el-menu 水合一遍
|
||||
// (实测手机端 doc-sidebar 区域 53 次事件绑定)。所以直接按判定结果不渲染。
|
||||
// 这里用的是全局共享的窄屏状态(服务端按请求头判定并随 payload 下发),
|
||||
// 与 app.vue 同一份 —— 首帧与服务端 HTML 一致,不会有水合分歧。
|
||||
const isNarrow = useShellNarrow()
|
||||
|
||||
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 +23,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 v-if="!isNarrow" class="sidebar-menu" />
|
||||
<div class="content">
|
||||
<div class="markdown-body" v-html="doc"></div>
|
||||
</div>
|
||||
@@ -118,8 +63,10 @@ html.dark .content {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* 窄屏不再挤一条窄侧栏(原先 100px,长标题全被截断):
|
||||
文档导航已并入左侧抽屉菜单,这里直接撤掉,正文铺满整屏 */
|
||||
.sidebar-menu {
|
||||
width: 100px;
|
||||
display: none;
|
||||
}
|
||||
.content {
|
||||
padding: 10px;
|
||||
@@ -128,7 +75,7 @@ html.dark .content {
|
||||
</style>
|
||||
<style>
|
||||
@import "github-markdown-css/github-markdown-light.css";
|
||||
@import "/github-markdown-dark.css";
|
||||
@import "../../github-markdown-dark.css";
|
||||
@import "/style.css"
|
||||
|
||||
</style>
|
||||
@@ -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());
|
||||
@@ -25,6 +24,32 @@ useHead({
|
||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||
],
|
||||
script: [
|
||||
{
|
||||
// —— IP 探测提前到 HTML 解析期(LCP 优化)——
|
||||
// 原来三个探测请求在 onMounted(水合完成后)才发出,模拟链路是
|
||||
// 「水合 → 发请求 → 等服务端 → 回填渲染」,IPv6 行作为 LCP 元素被整段串行拖住
|
||||
// (mobile 模拟 LCP 2275ms,满分线约 1600ms)。
|
||||
// 这里在 head 里用内联脚本立刻发起,与 render-blocking CSS 的下载、
|
||||
// hydration 三路并行;结果挂 window.__ipProbe,完成时派发 ip-probe 事件,
|
||||
// onMounted 只负责消费(见下方),不再等水合才起步。
|
||||
// 用原生 fetch:此刻 ofetch 还没加载;不带 retry 的语义与旧版 retry:false 一致
|
||||
// (底层 fetch 本来就不会重试)。
|
||||
innerHTML: [
|
||||
'window.__ipProbe = {};',
|
||||
'(function () {',
|
||||
` function emit(key) {`,
|
||||
` return function (text) {`,
|
||||
` window.__ipProbe[key] = text;`,
|
||||
` document.dispatchEvent(new CustomEvent('ip-probe', { detail: key }));`,
|
||||
` };`,
|
||||
` }`,
|
||||
// 与 onMounted 的语义一致:三接口各自独立、失败静默(保持「查询中」占位)
|
||||
` fetch(${JSON.stringify(config.v6OnlyAPI)}).then(function (r) { return r.text(); }).then(emit('v6')).catch(function () {});`,
|
||||
` fetch(${JSON.stringify(config.v4OnlyAPI)}).then(function (r) { return r.text(); }).then(emit('v4')).catch(function () {});`,
|
||||
` fetch(${JSON.stringify(config.DualStackAPI)}).then(function (r) { return r.text(); }).then(emit('dual')).catch(function () {});`,
|
||||
'})();',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
@@ -75,33 +100,90 @@ 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 一起产出。)
|
||||
|
||||
const [dualStack, ipV4, ipV6] = await Promise.allSettled([
|
||||
$fetch<string>(config.DualStackAPI),
|
||||
$fetch<string>(config.v4OnlyAPI),
|
||||
$fetch<string>(config.v6OnlyAPI)
|
||||
]);
|
||||
// —— 三个 IP 探测:请求已由 head 内联脚本提前发出(见 useHead),这里只消费 ——
|
||||
// 内联脚本把结果挂 window.__ipProbe[key] 并派发 ip-probe 事件。三种时序都要覆盖:
|
||||
// ① 值已就位(事件在 onMounted 之前派发过)→ 同步读 store 直接上屏;
|
||||
// ② 值未到 → 挂监听等事件(收到匹配 key 才移除,不能 once——
|
||||
// 三个 key 共用同一事件名,once 会把别人的事件当自己的吃掉);
|
||||
// ③ window.__ipProbe 不存在(内联脚本被 CSP 拦掉/异常)→ 退回自己发请求,行为同旧版。
|
||||
//
|
||||
// retry: false —— ofetch 默认会对 GET 重发一次:网络层失败时它拿不到 response,就按 500 计
|
||||
// (500 在默认重试白名单里),且默认延迟为 0,立即重发。于是"这个节点连不上"会被原样重做一遍,
|
||||
// 用户白等一倍时间(实测 ofetch 1.5.1:裸调用触发 2 次真实 fetch,传 retry:false 后只剩 1 次;
|
||||
// 浏览器自身不会重试,底层 fetch 对同一请求只被调用过一次)。仅 ③ 的兜底路径会走到 $fetch。
|
||||
const ipFetchOptions = { retry: false } as const;
|
||||
const probeStore = (window as any).__ipProbe as Record<string, string> | undefined;
|
||||
|
||||
if (dualStack.status === 'fulfilled') {
|
||||
ipAddress.value = dualStack.value;
|
||||
const consumeProbe = (key: string, apply: (ip: string) => void): boolean => {
|
||||
if (!probeStore) return false; // ③ 内联脚本没跑起来
|
||||
if (typeof probeStore[key] === 'string') {
|
||||
apply(probeStore[key]); // ① 值已就位
|
||||
return true;
|
||||
}
|
||||
const onProbe = (e: Event) => {
|
||||
if ((e as CustomEvent).detail !== key) return;
|
||||
document.removeEventListener('ip-probe', onProbe);
|
||||
apply(probeStore[key]); // ② 事件到达
|
||||
};
|
||||
document.addEventListener('ip-probe', onProbe);
|
||||
return true;
|
||||
};
|
||||
|
||||
// 三个地址各自独立请求、各自渲染:谁先回来谁先上屏。
|
||||
// 不用 Promise.allSettled 包起来等齐——那样只要有一个慢(例如纯 IPv4 网络下 v6 接口要等超时),
|
||||
// 已经拿到的 IPv4 与双栈结果也得一起干等。
|
||||
if (!consumeProbe('dual', (ip) => { ipAddress.value = ip; })) {
|
||||
$fetch<string>(config.DualStackAPI, ipFetchOptions)
|
||||
.then((ip) => {
|
||||
ipAddress.value = ip;
|
||||
})
|
||||
.catch(() => {
|
||||
// 单个接口失败不影响其它两行展示,保持"查询中"占位
|
||||
});
|
||||
}
|
||||
if (ipV4.status === 'fulfilled') {
|
||||
yourIPv4.value = ipV4.value;
|
||||
if (!consumeProbe('v4', (ip) => { yourIPv4.value = ip; })) {
|
||||
$fetch<string>(config.v4OnlyAPI, ipFetchOptions)
|
||||
.then((ip) => {
|
||||
yourIPv4.value = ip;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
if (ipV6.status === 'fulfilled') {
|
||||
yourIPv6.value = ipV6.value;
|
||||
if (!consumeProbe('v6', (ip) => { yourIPv6.value = ip; })) {
|
||||
$fetch<string>(config.v6OnlyAPI, ipFetchOptions)
|
||||
.then((ip) => {
|
||||
yourIPv6.value = ip;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -115,16 +197,16 @@ onMounted(async () => {
|
||||
</header>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="one-line">
|
||||
<div class="one-line ip-row">
|
||||
<b>IPv4</b> <p>{{ yourIPv4 }} </p> <RouterLink :to="`/location?ip=${yourIPv4}`" target="_blank">查询归属地</RouterLink>
|
||||
</div>
|
||||
<div class="one-line">
|
||||
<div class="one-line ip-row ip-row-v6">
|
||||
<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)) || isIPv6(yourIPv6)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h3>
|
||||
<h3 v-else-if="(ipAddress && isIPv4(ipAddress)) || isIPv6(yourIPv6)"><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 +223,31 @@ 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;
|
||||
}
|
||||
|
||||
/* 两行 IP 记录预留确定高度,消除 CLS。
|
||||
实测(CDP 逐帧采样 + CSS 变体对照,填值后量终态几何):
|
||||
这两个 div 的文本是 onMounted 之后才由 $fetch 填上的。IPv6 地址(39 字符)
|
||||
在窄屏下把 .one-line 从 1 行撑到 3 行,下面 .ip-priority / 代码块 / blockquote /
|
||||
footer 被整体顶下 50px —— Lighthouse 记的 `div.code-block` 那条 CLS 0.0359
|
||||
(占全页 88%)就是这么来的,而满分门槛只有 0.0396,差 3% 就被扣分。
|
||||
修法是让高度与「填完之后」一致。终态高度按视口分档(CDP 实测,不是估算):
|
||||
移动 412 宽 → ip2 终态 75px(3 行);桌面 1350 宽 → 30px(1 行,.content 宽 60%)
|
||||
所以必须分媒体查询给值:一刀切 75px 会让桌面凭空多出 45px 空白。
|
||||
代价是接口 pending 时这里空着,但换来 CLS 归零。 */
|
||||
.ip-row {
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
@@ -159,14 +266,30 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.code-block {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.ip-priority h2 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.code-block {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
/* 窄屏下 IPv6 地址换 3 行(CDP 实测 412 宽终态 h=75px),见上面 .ip-row 的注释。
|
||||
75px 只给 IPv6 行:IPv4 地址窄屏仍是 1 行(~30px),一刀切 75px 会让
|
||||
IPv4 行下部空出 ~45px,两块 IP 之间出现一大段空白。
|
||||
IPv4 回填前后都是 1 行 30px,高度不变 → 不引入新的 CLS。 */
|
||||
.ip-row {
|
||||
/* style.css 窄屏给 .one-line 加了 0.5em 上下 margin(两块之间叠 16px),
|
||||
IP 行的间距由行高本身决定,这里归零。scoped 属性选择器特异性更高,稳赢。 */
|
||||
margin: 0;
|
||||
}
|
||||
.ip-row-v6 {
|
||||
min-height: 75px;
|
||||
align-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
</style>
|
||||
@@ -1,396 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick, watch } from 'vue';
|
||||
import { isIPv6 } from 'is-ip';
|
||||
import {config} from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
|
||||
import { useRoute } from 'vue-router'
|
||||
import { highlightCode } from '../../utils/shiki'
|
||||
import { isIPv4 } from '../../utils/tools';
|
||||
import { onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
|
||||
useHead({
|
||||
title: `IPv6/IPv4地址查询工具 | IP归属地定位 | ${config.siteName}`,
|
||||
meta: [
|
||||
{ name: 'description', content: '专业的IPv6/IPv4地址查询工具,支持IPv4和IPv6地址归属地查询,提供BiliBili Live、GeoCN、IP2Region、Maxmind等多种数据源对比,精确定位IP地理位置、运营商信息,支持中国大陆及境外地址查询,助力IPv6普及与应用' },
|
||||
{ property: 'og:title', content: `IPv6/IPv4地址归属地查询工具 - ${config.siteName}` },
|
||||
{ property: 'og:description', content: '多数据源IP地址归属地查询,支持IPv4和IPv6,提供地理位置、运营商等详细信息' },
|
||||
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
],
|
||||
});
|
||||
interface IPLocationType {
|
||||
bilibili?: any;
|
||||
ip?: string;
|
||||
geocn?: any;
|
||||
ip2region?: any;
|
||||
maxmind_asn?: any;
|
||||
maxmind_city?: any;
|
||||
qqwry?: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
const code = `
|
||||
# 请勿用于商业用途,仅供个人测试学习之用,请遵守中国法律法规
|
||||
# 查询本机外网 IPv4 地址
|
||||
curl ${config.v4OnlyAPI}
|
||||
|
||||
# 查询本机外网 IPv6 地址
|
||||
curl ${config.v6OnlyAPI}
|
||||
|
||||
# 测试网络是 IPv4 还是 IPv6 访问优先
|
||||
# (访问 IPv4/IPv6 双栈站点,如果返回 IPv6 地址,则 IPv6 访问优先)
|
||||
curl ${config.DualStackAPI}
|
||||
`.trim();
|
||||
const html = ref('');
|
||||
const apiList = config.IPLocationAPIs
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]!.id)
|
||||
const ipAddress = ref('');
|
||||
const IPLocation = ref<IPLocationType>({});
|
||||
const UserIP = ref('');
|
||||
const locationUrl = computed(() => "/middleware/" + backendID.value + "/location/" + ipAddress.value);
|
||||
|
||||
const { data: locationData, error: locationError, execute: executeLocation } = useMiddlewareFetch<IPLocationType>(locationUrl, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
|
||||
watch(locationData, (newData) => {
|
||||
if (newData) {
|
||||
IPLocation.value = newData;
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
watch(locationError, (newError) => {
|
||||
if (newError) {
|
||||
console.error('Error fetching IP location:', newError);
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function locateIP(IP: string){
|
||||
ipAddress.value = IP;
|
||||
loading.value = true;
|
||||
nextTick(() => {
|
||||
executeLocation();
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
const urlParam = route.query.ip;
|
||||
await navigateTo({ path: '/location', query: { ip: urlParam } });
|
||||
html.value = await highlightCode(code, 'bash')
|
||||
if (urlParam) {
|
||||
ipAddress.value = urlParam as string;
|
||||
locateIP(urlParam as string);
|
||||
}else{
|
||||
$fetch<string>(config.DualStackAPI).then(
|
||||
function(data) {
|
||||
ipAddress.value = data;
|
||||
|
||||
UserIP.value = data;
|
||||
}
|
||||
).catch(
|
||||
function(error) {
|
||||
console.error('Error fetching IP address:', error);
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
// ip 参数缺失时不要拼出 ?ip=undefined
|
||||
await navigateTo(urlParam ? { path: '/location', query: { ip: urlParam } } : { path: '/location' });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="title">
|
||||
<header>
|
||||
<h1>IPv6/IPv4地址归属地查询</h1>
|
||||
<p>极简的IPv6地址查询工具,致力于普及 IPv6</p>
|
||||
</header>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="ipAddress"
|
||||
placeholder="请输入IP地址"
|
||||
/>
|
||||
<el-button
|
||||
@click="locateIP(ipAddress)"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
|
||||
>
|
||||
查询
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="location">
|
||||
<div class="one-line" style="height: 40px;">
|
||||
<b>IP</b> <p>{{ ipAddress }}</p>
|
||||
</div>
|
||||
<div v-if="IPLocation" class="result-section">
|
||||
<table class="result-table">
|
||||
<tbody>
|
||||
<tr v-if="IPLocation.bilibili && (IPLocation.bilibili.administrative_area || IPLocation.bilibili.city)">
|
||||
<td class="table-label">bilibili Live接口</td>
|
||||
<td class="table-value">
|
||||
<span>
|
||||
{{ IPLocation.bilibili?.country }} {{ IPLocation.bilibili?.administrative_area }} {{ IPLocation.bilibili?.city }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="table-value">
|
||||
<span>
|
||||
{{ IPLocation.bilibili?.isp }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.ip2region && IPLocation.ip2region.split('|').length >= 4">
|
||||
<td class="table-label">IP2Region</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.split("|")[0] }} {{ IPLocation.ip2region?.split("|")[1] }} {{ IPLocation.ip2region?.split("|")[2] }}</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.split("|")[3] }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.geocn && (IPLocation.geocn.administrative_area || IPLocation.geocn.city || IPLocation.geocn.district)">
|
||||
<td class="table-label">GeoCN(仅中国大陆)</td>
|
||||
<td class="table-value">{{ IPLocation.geocn?.administrative_area }} {{ IPLocation.geocn?.city }} {{ IPLocation.geocn?.district }}</td>
|
||||
<td class="table-value">{{ IPLocation.geocn?.isp }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.maxmind_city && IPLocation.maxmind_asn && (IPLocation.maxmind_city.country || IPLocation.maxmind_city.city)">
|
||||
<td class="table-label">Maxmind GEOLite2 City</td>
|
||||
<td class="table-value">{{ IPLocation.maxmind_city?.country }} {{ IPLocation.maxmind_city?.administrative_area }} {{ IPLocation.maxmind_city?.city }}</td>
|
||||
<td class="table-value">{{ IPLocation.maxmind_asn?.org }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.qqwry && (IPLocation.qqwry.country || IPLocation.qqwry.administrative_area || IPLocation.qqwry.city)">
|
||||
<td class="table-label">纯真社区库</td>
|
||||
<td class="table-value">{{ IPLocation.qqwry?.country }} {{ IPLocation.qqwry?.administrative_area }} {{ IPLocation.qqwry?.city }}</td>
|
||||
<td class="table-value">{{ IPLocation.qqwry?.isp }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.dbip_city && (IPLocation.dbip_city.administrative_area || IPLocation.dbip_city.city)">
|
||||
<td class="table-label">DB-IP City</td>
|
||||
<td class="table-value">{{ IPLocation.dbip_city?.country }} {{ IPLocation.dbip_city?.administrative_area }} {{ IPLocation.dbip_city?.city }}</td>
|
||||
<td class="table-value">{{IPLocation.dbip_asn?.org || '--'}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 1.5em;">
|
||||
<h3 v-if="isIPv6(UserIP)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h3>
|
||||
<h3 v-else-if="isIPv4(UserIP)"><el-icon><CircleCloseFilled style="color: red;"/></el-icon>您的网络IPv4优先</h3>
|
||||
</div>
|
||||
<blockquote>
|
||||
精度参照表:<br>
|
||||
中国大陆:BiliBili Live > GeoCN > IP2Region > 纯真社区库 > Maxmind GEOLite2 City ≈ DB-IP<br>
|
||||
中国大陆 IPv6 地址: BiliBili Live > GeoCN > IP2Region > Maxmind GEOLite2 City ≈ DB-IP > 纯真社区库<br>
|
||||
境外及港澳台地址: Maxmind GEOLite2 City ≈ DB-IP > BiliBili Live > > IP2Region > 纯真社区库<br>
|
||||
|
||||
手机默认开启 IPv6,宽带开启 IPv6 请自行搜索<br>
|
||||
访客IP: {{UserIP}},<p v-if="isIPv4(UserIP)">您的网络IPv4优先</p><p v-else-if="isIPv6(UserIP)">您的网络IPv6优先</p>
|
||||
</blockquote>
|
||||
|
||||
<div v-html="html"></div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="redirecting">正在跳转到 IP 归属地查询…</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../style.css";
|
||||
@import "../../assets/css/tool-common.css";
|
||||
</style>
|
||||
<style>
|
||||
@import "../style.css";
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.el-menu--horizontal > .el-menu-item:nth-child(1) {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
width: 420px;
|
||||
height: 50px;
|
||||
font: 1.3em sans-serif;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
width: 165px;
|
||||
height: 50px;
|
||||
font: 1.3em sans-serif;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.result-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
html.dark .result-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #2e2d2d;
|
||||
border: 1px solid #2e2e2e;
|
||||
}
|
||||
.result-table thead tr {
|
||||
background-color: #c0c4cc;
|
||||
}
|
||||
html.dark .result-table thead tr {
|
||||
background: #2e2d2d;
|
||||
}
|
||||
|
||||
.result-table .table-header {
|
||||
padding: 12px 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
text-align: left;
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
html.dark .result-table .table-header {
|
||||
padding: 12px 15px;
|
||||
font-weight: 600;
|
||||
color: #cfcfcf;
|
||||
text-align: left;
|
||||
border: 1px solid #1a1919;
|
||||
}
|
||||
|
||||
.result-table tbody tr {
|
||||
border-bottom: 1px solid #dcdfe6;
|
||||
}
|
||||
html.dark .result-table tbody tr {
|
||||
border-bottom: 1px solid #1a1919;
|
||||
}
|
||||
|
||||
|
||||
.result-table tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.result-table tbody tr:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
html.dark .result-table tbody tr:hover {
|
||||
background-color: #393a3a;
|
||||
}
|
||||
|
||||
.result-table .table-label {
|
||||
padding: 12px 15px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
width: 150px;
|
||||
text-align: left;
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
html.dark .result-table .table-label {
|
||||
color: #c0c4cc;
|
||||
border: 1px solid #1a1919;
|
||||
}
|
||||
|
||||
.result-table .table-value {
|
||||
padding: 12px 15px;
|
||||
color: #303133;
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
html.dark .result-table .table-value {
|
||||
color: #cfcfcf;
|
||||
border: 1px solid #1a1919;
|
||||
}
|
||||
.valid {
|
||||
color: #67C23A;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.expired {
|
||||
color: #F56C6C;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-code {
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
color: #67C23A;
|
||||
background: #f0f9eb;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
color: #E6A23C;
|
||||
background: #fdf6ec;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #F56C6C;
|
||||
background: #fef0f0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #fef0f0;
|
||||
color: #F56C6C;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
pre code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 0.9em;
|
||||
color: #303133;
|
||||
}
|
||||
html.dark pre {
|
||||
background: #303133;
|
||||
}
|
||||
|
||||
html.dark pre code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.badge-section {
|
||||
.redirecting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 50vh;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.badge-item {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.badge-item h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #3EAF7C;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.badge-item img {
|
||||
display: block;
|
||||
margin-bottom: 15px;
|
||||
max-width: 200px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -57,6 +57,9 @@ interface ServerResult {
|
||||
data: any
|
||||
}
|
||||
|
||||
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
|
||||
let queryId = 0
|
||||
|
||||
/** 预构建的服务器列表,页面挂载即展示,用户可提前看到测速节点 */
|
||||
const serverResults = ref<ServerResult[]>([])
|
||||
|
||||
@@ -68,8 +71,8 @@ const serverResults = ref<ServerResult[]>([])
|
||||
// =============================================
|
||||
|
||||
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
|
||||
const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + domain.value);
|
||||
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + encodeURIComponent(domain.value));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -78,8 +81,8 @@ const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
|
||||
});
|
||||
|
||||
/** IPv6 专用服务器:仅支持 IPv6 的节点 */
|
||||
const ipv6Fetches = config.SpeedTest.IPv6.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + domain.value);
|
||||
const ipv6Fetches = config.APIBaseURL.IPv6.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + encodeURIComponent(domain.value));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -116,6 +119,8 @@ async function SpeedTest() {
|
||||
error.value = ''
|
||||
await nextTick()
|
||||
|
||||
const id = ++queryId
|
||||
|
||||
// 重置所有行状态为加载中
|
||||
serverResults.value.forEach((row) => {
|
||||
row.loading = true
|
||||
@@ -130,16 +135,26 @@ async function SpeedTest() {
|
||||
|
||||
try {
|
||||
await fetch.execute();
|
||||
row.data = fetch.data.value;
|
||||
if (id !== queryId) return
|
||||
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成空白)
|
||||
if (fetch.error.value) {
|
||||
row.error = fetch.error.value?.data?.error || fetch.error.value?.message || '请求失败';
|
||||
} else {
|
||||
row.data = fetch.data.value;
|
||||
}
|
||||
} catch (err: any) {
|
||||
row.error = err?.data?.error || err?.message || '请求失败';
|
||||
} finally {
|
||||
row.loading = false;
|
||||
if (id === queryId) {
|
||||
row.loading = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Promise.all(promises).finally(() => {
|
||||
loading.value = false
|
||||
if (id === queryId) {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -257,10 +272,10 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -77,14 +77,16 @@ const port = ref('80')
|
||||
const userIP = ref('')
|
||||
const loading = ref(false)
|
||||
const serverResults = ref<ServerResult[]>([])
|
||||
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
|
||||
let queryId = 0
|
||||
|
||||
const allServers = [
|
||||
...config.TCPing.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
|
||||
...config.TCPing.IPv6.map((s: ServerConfig) => ({ ...s, type: 'IPv6' }))
|
||||
...config.APIBaseURL.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
|
||||
...config.APIBaseURL.IPv6.map((s: ServerConfig) => ({ ...s, type: 'IPv6' }))
|
||||
];
|
||||
|
||||
const tcpingFetches = allServers.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/tcping/" + extractHost(tmpDomain.value) + "?port=" + port.value);
|
||||
const url = computed(() => "/middleware/" + server.id + "/tcping/" + encodeURIComponent(extractHost(tmpDomain.value)) + "?port=" + encodeURIComponent(port.value));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch<TCPingResponse>(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -95,7 +97,7 @@ const tcpingFetches = allServers.map((server) => {
|
||||
function initServerResults() {
|
||||
const results: ServerResult[] = []
|
||||
|
||||
config.TCPing.DualStack.forEach((server: ServerConfig) => {
|
||||
config.APIBaseURL.DualStack.forEach((server: ServerConfig) => {
|
||||
results.push({
|
||||
label: server.label,
|
||||
loading: false,
|
||||
@@ -103,7 +105,7 @@ function initServerResults() {
|
||||
})
|
||||
})
|
||||
|
||||
config.TCPing.IPv6.forEach((server: ServerConfig) => {
|
||||
config.APIBaseURL.IPv6.forEach((server: ServerConfig) => {
|
||||
results.push({
|
||||
label: server.label,
|
||||
loading: false,
|
||||
@@ -117,23 +119,31 @@ function initServerResults() {
|
||||
function TCPingAll() {
|
||||
const host = extractHost(tmpDomain.value)
|
||||
if (!host) return
|
||||
|
||||
|
||||
loading.value = true
|
||||
|
||||
|
||||
const id = ++queryId
|
||||
|
||||
serverResults.value.forEach((result) => {
|
||||
result.loading = true
|
||||
result.error = ''
|
||||
result.ipv4 = undefined
|
||||
result.ipv6 = undefined
|
||||
})
|
||||
|
||||
|
||||
const promises = tcpingFetches.map(async (fetch, index) => {
|
||||
try {
|
||||
await fetch.execute();
|
||||
if (id !== queryId) return
|
||||
const result = serverResults.value[index];
|
||||
if (result) {
|
||||
result.ipv4 = fetch.data.value?.ipv4;
|
||||
result.ipv6 = fetch.data.value?.ipv6;
|
||||
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成 '-')
|
||||
if (fetch.error.value) {
|
||||
result.error = (fetch.error.value as any)?.message || '请求失败';
|
||||
} else {
|
||||
result.ipv4 = fetch.data.value?.ipv4;
|
||||
result.ipv6 = fetch.data.value?.ipv6;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -143,22 +153,24 @@ function TCPingAll() {
|
||||
}
|
||||
} finally {
|
||||
const result = serverResults.value[index];
|
||||
if (result) {
|
||||
if (result && id === queryId) {
|
||||
result.loading = false;
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Promise.all(promises).finally(function () {
|
||||
loading.value = false
|
||||
if (id === queryId) {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
async function getUserIP(){
|
||||
|
||||
await $fetch<string>(config.DualStackAPI).then(
|
||||
function (data){
|
||||
userIP.value = data
|
||||
})
|
||||
try {
|
||||
userIP.value = await $fetch<string>(config.DualStackAPI)
|
||||
} catch {
|
||||
// 获取失败保持为空,避免未处理的 Promise 拒绝
|
||||
}
|
||||
return userIP.value
|
||||
}
|
||||
onMounted(() => {
|
||||
@@ -221,13 +233,13 @@ onMounted(() => {
|
||||
<td class="table-value">{{ server.ipv6.sent }}</td>
|
||||
<td class="table-value">{{ server.ipv6.success }}</td>
|
||||
<td class="table-value">
|
||||
<span :class="server.ipv6.loss_rate > 0 ? 'loss-warning' : 'loss-ok'">
|
||||
{{ server.ipv6.loss_rate.toFixed(1) }}
|
||||
<span :class="(server.ipv6?.loss_rate ?? 0) > 0 ? 'loss-warning' : 'loss-ok'">
|
||||
{{ server.ipv6?.loss_rate?.toFixed(1) ?? '-' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="table-value">{{ server.ipv6.max_rtt.toFixed(2) }}</td>
|
||||
<td class="table-value">{{ server.ipv6.min_rtt.toFixed(2) }}</td>
|
||||
<td class="table-value">{{ server.ipv6.avg_rtt.toFixed(2) }}</td>
|
||||
<td class="table-value">{{ server.ipv6?.max_rtt?.toFixed(2) ?? '-' }}</td>
|
||||
<td class="table-value">{{ server.ipv6?.min_rtt?.toFixed(2) ?? '-' }}</td>
|
||||
<td class="table-value">{{ server.ipv6?.avg_rtt?.toFixed(2) ?? '-' }}</td>
|
||||
</tr>
|
||||
<tr v-else-if="server.loading">
|
||||
<td class="table-label">{{ server.label }}</td>
|
||||
@@ -275,11 +287,11 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
|
||||
@@ -63,7 +63,7 @@ interface PerformanceCheckItem {
|
||||
download_speed: number
|
||||
is_reachable: boolean
|
||||
}
|
||||
const apiList = config.apiBaseUrls
|
||||
const apiList = config.APIBaseURL.DualStack
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
const tmpDomain = ref('https://www.zakoflare.com')
|
||||
@@ -72,6 +72,8 @@ const loading = ref(false)
|
||||
const error = ref('')
|
||||
const result = ref<PerformanceCheckResponse | null>(null)
|
||||
const checkUrl = computed(() => "/middleware/" + backendID.value + "/detail/" + testDomain.value);
|
||||
// testDomain 是完整 URL(含 https:// 与路径斜杠),服务端 [...slug].get.ts 依赖
|
||||
// 分段重组协议部分,这里刻意不做 encodeURIComponent
|
||||
|
||||
const { data: checkData, error: checkError, execute: executeCheck } = useMiddlewareFetch<PerformanceCheckResponse>(checkUrl, {
|
||||
immediate: false,
|
||||
@@ -162,7 +164,7 @@ onMounted(() => {
|
||||
<tr>
|
||||
<td class="table-label">主机记录</td>
|
||||
<td class="table-value"><div class="one-line"><RouterLink :to="`/ipv6?ip=${result.ipv4!.host_record}`">{{ result.ipv4!.host_record }}</RouterLink><el-icon><Position /></el-icon></div></td>
|
||||
<td class="table-value"><div class="one-line"><RouterLink :to="`/ipv6?ip=${result.ipv6?.host_record}`">{{ result.ipv6?.host_record || '-' }}</RouterLink><el-icon><Position /></el-icon></div></td>
|
||||
<td class="table-value"><div class="one-line"><RouterLink v-if="result.ipv6?.host_record" :to="`/ipv6?ip=${result.ipv6!.host_record}`">{{ result.ipv6!.host_record }}</RouterLink><span v-else>-</span><el-icon><Position /></el-icon></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="table-label">HTTP 状态码</td>
|
||||
@@ -275,6 +277,12 @@ onMounted(() => {
|
||||
<h2>...</h2>
|
||||
<el-image src="/jingya.jpg"></el-image>
|
||||
</div>
|
||||
<div v-else-if="result && result.ipv4 && result.ipv6 && !result.ipv4.is_reachable && result.ipv6.is_reachable">
|
||||
<h3>结论:<CircleCloseFilled style="width: 1.3em;color: red;"/>网站{{ extractHost(testDomain) }} 不支持IPv4访问 </h3>
|
||||
<h2>全球来看,越南、捷克等部分国家已提出了单栈发展目标。尽管我国网络IPv6流量占比大幅提升,但网络中仍存在大量的NAT(网络地址转换)设备和IPv4依赖应用。</h2>
|
||||
<h2 style="text-align: right;">----中国《IPv6发展报告》2026</h2>
|
||||
<el-image src="/jingya.jpg"></el-image>
|
||||
</div>
|
||||
<blockquote>
|
||||
网站不支持 IPv6可能原因:<br/>
|
||||
<br/>
|
||||
@@ -297,10 +305,10 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -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);
|
||||
@@ -49,6 +48,8 @@ interface IPLocationType {
|
||||
ip?: string;
|
||||
geocn?: any;
|
||||
ip2region?: any;
|
||||
ip2location?: any;
|
||||
ip2location_asn?: any;
|
||||
maxmind_asn?: any;
|
||||
maxmind_city?: any;
|
||||
qqwry?: any;
|
||||
@@ -66,15 +67,30 @@ curl ${config.v6OnlyAPI}
|
||||
# (访问 IPv4/IPv6 双栈站点,如果返回 IPv6 地址,则 IPv6 访问优先)
|
||||
curl ${config.DualStackAPI}
|
||||
`.trim();
|
||||
const html = ref('');
|
||||
const apiList = config.IPLocationAPIs
|
||||
|
||||
// 代码高亮放 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 || '')
|
||||
const ipAddress = ref('');
|
||||
const IPLocation = ref<IPLocationType>({});
|
||||
const UserIP = ref('');
|
||||
const error = ref('')
|
||||
const locationUrl = computed(() => "/middleware/" + backendID.value + "/location/" + ipAddress.value);
|
||||
const locationUrl = computed(() => "/middleware/" + backendID.value + "/location/" + encodeURIComponent(ipAddress.value));
|
||||
|
||||
const { data: locationData, error: locationError, execute: executeLocation } = useMiddlewareFetch<IPLocationType>(locationUrl, {
|
||||
immediate: false,
|
||||
@@ -116,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;
|
||||
@@ -182,10 +199,15 @@ onMounted(async () => {
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.ip2region && IPLocation.ip2region.split('|').length >= 4">
|
||||
<tr v-if="IPLocation.ip2region && typeof IPLocation.ip2region === 'object' && (IPLocation.ip2region.administrative_area || IPLocation.ip2region.city)">
|
||||
<td class="table-label">IP2Region</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.split("|")[0] }} {{ IPLocation.ip2region?.split("|")[1] }} {{ IPLocation.ip2region?.split("|")[2] }}</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.split("|")[3] }}</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.country }} {{ IPLocation.ip2region?.administrative_area }} {{ IPLocation.ip2region?.city }}</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.isp }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.ip2location && typeof IPLocation.ip2location === 'object' && (IPLocation.ip2location.administrative_area || IPLocation.ip2location.city)">
|
||||
<td class="table-label">IP2Location</td>
|
||||
<td class="table-value">{{ IPLocation.ip2location?.country }} {{ IPLocation.ip2location?.administrative_area }} {{ IPLocation.ip2location?.city }}</td>
|
||||
<td class="table-value">{{ IPLocation.ip2location_asn?.as }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.geocn && (IPLocation.geocn.administrative_area || IPLocation.geocn.city || IPLocation.geocn.district)">
|
||||
<td class="table-label">GeoCN(仅中国大陆)</td>
|
||||
@@ -239,7 +261,7 @@ onMounted(async () => {
|
||||
<style>
|
||||
@import "../style.css";
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
@@ -422,7 +444,7 @@ html.dark pre code {
|
||||
|
||||
.badge-item h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #3EAF7C;
|
||||
color: #299764;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,17 +32,18 @@ let detectionImg: HTMLImageElement | null = null
|
||||
let detectionTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let currentRequestId = 0
|
||||
const userIP = ref('')
|
||||
// WordPress mshots default error image dimensions
|
||||
const DEFAULT_MSHOTS_WIDTH = 400
|
||||
const DEFAULT_MSHOTS_HEIGHT = 300
|
||||
// WordPress mshots 默认占位图(生成失败/重定向目标)的尺寸是 1200x900,
|
||||
// 与下方注释及判定逻辑一致;真实网站截图可以是任意尺寸
|
||||
const DEFAULT_MSHOTS_WIDTH = 1200
|
||||
const DEFAULT_MSHOTS_HEIGHT = 900
|
||||
const LOADING_TIMEOUT = 15000 // 15 seconds
|
||||
|
||||
async function getUserIP(){
|
||||
|
||||
await $fetch<string>(config.DualStackAPI).then(
|
||||
function (data){
|
||||
userIP.value = data
|
||||
})
|
||||
try {
|
||||
userIP.value = await $fetch<string>(config.DualStackAPI)
|
||||
} catch {
|
||||
// 获取失败保持为空,避免未处理的 Promise 拒绝
|
||||
}
|
||||
return userIP.value
|
||||
}
|
||||
function cleanupDetection() {
|
||||
@@ -285,7 +286,7 @@ html.dark .loading-overlay p {
|
||||
}
|
||||
|
||||
.markdown :deep(a) {
|
||||
color: #3EAF7C !important;
|
||||
color: #299764 !important;
|
||||
font-size: 1.3em;
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@ interface ServerResult {
|
||||
data: any
|
||||
}
|
||||
|
||||
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
|
||||
let queryId = 0
|
||||
|
||||
/** 预构建的服务器列表,页面挂载即展示,用户可提前看到测速节点 */
|
||||
const serverResults = ref<ServerResult[]>([])
|
||||
|
||||
@@ -68,8 +71,8 @@ const serverResults = ref<ServerResult[]>([])
|
||||
// =============================================
|
||||
|
||||
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
|
||||
const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + domain.value);
|
||||
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + encodeURIComponent(domain.value));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -78,8 +81,8 @@ const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
|
||||
});
|
||||
|
||||
/** IPv4 专用服务器:仅支持 IPv4 的节点 */
|
||||
const ipv4Fetches = config.SpeedTest.IPv4.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + domain.value);
|
||||
const ipv4Fetches = config.APIBaseURL.IPv4.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + encodeURIComponent(domain.value));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -116,6 +119,8 @@ async function SpeedTest() {
|
||||
error.value = ''
|
||||
await nextTick()
|
||||
|
||||
const id = ++queryId
|
||||
|
||||
// 重置所有行状态为加载中
|
||||
serverResults.value.forEach((row) => {
|
||||
row.loading = true
|
||||
@@ -130,16 +135,26 @@ async function SpeedTest() {
|
||||
|
||||
try {
|
||||
await fetch.execute()
|
||||
row.data = fetch.data.value
|
||||
if (id !== queryId) return
|
||||
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成空白)
|
||||
if (fetch.error.value) {
|
||||
row.error = fetch.error.value?.data?.error || fetch.error.value?.message || '请求失败'
|
||||
} else {
|
||||
row.data = fetch.data.value
|
||||
}
|
||||
} catch (err: any) {
|
||||
row.error = err?.data?.error || err?.message || '请求失败'
|
||||
} finally {
|
||||
row.loading = false
|
||||
if (id === queryId) {
|
||||
row.loading = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Promise.all(promises).finally(() => {
|
||||
loading.value = false
|
||||
if (id === queryId) {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -257,10 +272,10 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -65,7 +65,7 @@ interface SSLCheckItem {
|
||||
is_expired: boolean
|
||||
is_reachable: boolean
|
||||
}
|
||||
const apiList = config.apiBaseUrls
|
||||
const apiList = config.APIBaseURL.DualStack
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
const tmpDomain = ref('https://www.zakoflare.com')
|
||||
@@ -73,7 +73,7 @@ const testDomain = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const result = ref<SSLCheckResponse | null>(null)
|
||||
const sslUrl = computed(() => "/middleware/" + backendID.value + "/ssl/" + testDomain.value);
|
||||
const sslUrl = computed(() => "/middleware/" + backendID.value + "/ssl/" + encodeURIComponent(testDomain.value));
|
||||
|
||||
const { data: sslData, error: sslError, execute: executeSSL } = useMiddlewareFetch<SSLCheckResponse>(sslUrl, {
|
||||
immediate: false,
|
||||
@@ -389,7 +389,7 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -76,14 +76,16 @@ const tmpDomain = ref('www.zakoflare.com')
|
||||
const port = ref('80')
|
||||
const loading = ref(false)
|
||||
const serverResults = ref<ServerResult[]>([])
|
||||
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
|
||||
let queryId = 0
|
||||
|
||||
const allServers = [
|
||||
...config.TCPing.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
|
||||
...config.TCPing.IPv4.map((s: ServerConfig) => ({ ...s, type: 'IPv4' }))
|
||||
...config.APIBaseURL.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
|
||||
...config.APIBaseURL.IPv4.map((s: ServerConfig) => ({ ...s, type: 'IPv4' }))
|
||||
];
|
||||
|
||||
const tcpingFetches = allServers.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/tcping/" + extractHost(tmpDomain.value) + "?port=" + port.value);
|
||||
const url = computed(() => "/middleware/" + server.id + "/tcping/" + encodeURIComponent(extractHost(tmpDomain.value)) + "?port=" + encodeURIComponent(port.value));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch<TCPingResponse>(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -94,7 +96,7 @@ const tcpingFetches = allServers.map((server) => {
|
||||
function initServerResults() {
|
||||
const results: ServerResult[] = []
|
||||
|
||||
config.TCPing.DualStack.forEach((server: ServerConfig) => {
|
||||
config.APIBaseURL.DualStack.forEach((server: ServerConfig) => {
|
||||
results.push({
|
||||
label: server.label,
|
||||
loading: false,
|
||||
@@ -102,7 +104,7 @@ function initServerResults() {
|
||||
})
|
||||
})
|
||||
|
||||
config.TCPing.IPv4.forEach((server: ServerConfig) => {
|
||||
config.APIBaseURL.IPv4.forEach((server: ServerConfig) => {
|
||||
results.push({
|
||||
label: server.label,
|
||||
loading: false,
|
||||
@@ -116,23 +118,31 @@ function initServerResults() {
|
||||
function TCPingAll() {
|
||||
const host = extractHost(tmpDomain.value)
|
||||
if (!host) return
|
||||
|
||||
|
||||
loading.value = true
|
||||
|
||||
|
||||
const id = ++queryId
|
||||
|
||||
serverResults.value.forEach((result) => {
|
||||
result.loading = true
|
||||
result.error = ''
|
||||
result.ipv4 = undefined
|
||||
result.ipv6 = undefined
|
||||
})
|
||||
|
||||
|
||||
const promises = tcpingFetches.map(async (fetch, index) => {
|
||||
try {
|
||||
await fetch.execute();
|
||||
if (id !== queryId) return
|
||||
const result = serverResults.value[index];
|
||||
if (result) {
|
||||
result.ipv4 = fetch.data.value?.ipv4;
|
||||
result.ipv6 = fetch.data.value?.ipv6;
|
||||
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成 '-')
|
||||
if (fetch.error.value) {
|
||||
result.error = (fetch.error.value as any)?.message || '请求失败';
|
||||
} else {
|
||||
result.ipv4 = fetch.data.value?.ipv4;
|
||||
result.ipv6 = fetch.data.value?.ipv6;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -142,14 +152,16 @@ function TCPingAll() {
|
||||
}
|
||||
} finally {
|
||||
const result = serverResults.value[index];
|
||||
if (result) {
|
||||
if (result && id === queryId) {
|
||||
result.loading = false;
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Promise.all(promises).finally(function () {
|
||||
loading.value = false
|
||||
if (id === queryId) {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -212,13 +224,13 @@ onMounted(() => {
|
||||
<td class="table-value">{{ server.ipv4?.sent }}</td>
|
||||
<td class="table-value">{{ server.ipv4?.success }}</td>
|
||||
<td class="table-value">
|
||||
<span :class="server.ipv4.loss_rate > 0 ? 'loss-warning' : 'loss-ok'">
|
||||
{{ server.ipv4.loss_rate.toFixed(1) }}
|
||||
<span :class="(server.ipv4?.loss_rate ?? 0) > 0 ? 'loss-warning' : 'loss-ok'">
|
||||
{{ server.ipv4?.loss_rate?.toFixed(1) ?? '-' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="table-value">{{ server.ipv4?.max_rtt.toFixed(2) }}</td>
|
||||
<td class="table-value">{{ server.ipv4?.min_rtt.toFixed(2) }}</td>
|
||||
<td class="table-value">{{ server.ipv4?.avg_rtt.toFixed(2) }}</td>
|
||||
<td class="table-value">{{ server.ipv4?.max_rtt?.toFixed(2) ?? '-' }}</td>
|
||||
<td class="table-value">{{ server.ipv4?.min_rtt?.toFixed(2) ?? '-' }}</td>
|
||||
<td class="table-value">{{ server.ipv4?.avg_rtt?.toFixed(2) ?? '-' }}</td>
|
||||
</tr>
|
||||
<tr v-else-if="server.loading">
|
||||
<td class="table-label">{{ server.label }}</td>
|
||||
@@ -259,11 +271,11 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
|
||||
@@ -64,10 +64,10 @@ const result = ref<any>(null)
|
||||
const error = ref('')
|
||||
const userIP = ref('')
|
||||
|
||||
const apiList = config.apiBaseUrls
|
||||
const apiList = config.APIBaseURL.DualStack
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
const whoisUrl = computed(() => '/middleware/' + backendID.value + '/whois/' + domain.value)
|
||||
const whoisUrl = computed(() => '/middleware/' + backendID.value + '/whois/' + encodeURIComponent(domain.value))
|
||||
|
||||
const { data: whoisData, error: whoisError, execute: executeWhois } = useMiddlewareFetch<any>(whoisUrl, {
|
||||
immediate: false,
|
||||
@@ -124,11 +124,11 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
async function getUserIP(){
|
||||
await $fetch<string>(config.DualStackAPI).then(
|
||||
function (data){
|
||||
userIP.value = data
|
||||
}
|
||||
)
|
||||
try {
|
||||
userIP.value = await $fetch<string>(config.DualStackAPI)
|
||||
} catch {
|
||||
// 获取失败保持为空,避免未处理的 Promise 拒绝
|
||||
}
|
||||
return userIP.value
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ function getStatusClass(status: string): string {
|
||||
@import "../style.css";
|
||||
@import "../../assets/css/tool-common.css";
|
||||
.markdown :deep(a) {
|
||||
color: #3EAF7C !important;
|
||||
color: #299764 !important;
|
||||
font-size: 1.3em;
|
||||
text-decoration: none
|
||||
}
|
||||
@@ -319,10 +319,10 @@ function getStatusClass(status: string): string {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -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: var(--link-color);
|
||||
font-size: 1em;
|
||||
text-decoration: none
|
||||
|
||||
/* 正文里的链接仅靠颜色区分不够(绿 #299764 与周围正文的对比度远低于 3:1),
|
||||
补下划线提供「非颜色」的视觉区分(link-in-text-block) */
|
||||
text-decoration: underline
|
||||
}
|
||||
a {
|
||||
color: #3EAF7C;
|
||||
color: var(--link-color);
|
||||
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: var(--link-color);
|
||||
text-decoration: none
|
||||
}
|
||||
footer {
|
||||
@@ -203,17 +207,17 @@ footer {
|
||||
}
|
||||
blockquote a{
|
||||
|
||||
color: #3EAF7C;
|
||||
color: var(--link-color);
|
||||
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: var(--link-color);
|
||||
}
|
||||
|
||||
h1:hover>.header-anchor,
|
||||
@@ -281,5 +285,5 @@ h6:hover>.header-anchor {
|
||||
}
|
||||
.header-anchor::before {
|
||||
content: "#";
|
||||
color: #3EAF7C;
|
||||
color: var(--link-color);
|
||||
}
|
||||
@@ -226,7 +226,7 @@ html.dark pre code {
|
||||
|
||||
.badge-item h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #3EAF7C;
|
||||
color: #299764;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,13 +3,15 @@
|
||||
前端的一系列配置
|
||||
*/
|
||||
const config = {
|
||||
siteUrl: "https://ipw.wsmdn.top/",
|
||||
siteUrl: "https://nipw.cn/",
|
||||
// 站点名称:用于页面标题 / 描述 / 页脚品牌展示
|
||||
siteName: "柠檬味ipw.cn",
|
||||
// 是否启用前端内置中间件(server/routes/middleware/[...slug].get.ts 本地转发)。
|
||||
// true(默认):/middleware/* 候选为「外部节点… + 内置中间件(最后一位兜底)」;
|
||||
// false:仅使用外部节点,候选列表不含内置中间件。
|
||||
EnableInternalMiddleware: true,
|
||||
// 内置中间件单 IP 限流次数(次/分钟);0 表示不限流。默认 120。
|
||||
rateLimitPerMinute: 120,
|
||||
// 外部独立中间件服务列表(base URL)。
|
||||
// 与前端自带中间件(server/routes/middleware/[...slug].get.ts 本地转发)同级,
|
||||
// 前端 /middleware/* 请求会依次尝试这些节点(出错重试下一个),
|
||||
@@ -17,7 +19,7 @@ const config = {
|
||||
// 未配置或为空数组时,直接使用前端自带中间件。
|
||||
Middleware: <string[]>[
|
||||
"https://middleware-1.api-ipw.wsmdn.top/"
|
||||
// "http://127.0.0.1:8091/",
|
||||
// "http://127.0.0.1:8092/",
|
||||
// "https://middleware-2.wsmdn.top/",
|
||||
],
|
||||
// Umami 统计
|
||||
@@ -25,190 +27,113 @@ 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查询接口
|
||||
v4OnlyAPI: "https://4.wsmdn.top",
|
||||
v6OnlyAPI: "https://6.wsmdn.top",
|
||||
DualStackAPI: "https://test.wsmdn.top",
|
||||
apiBaseUrls: [
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
id: "cn-jiangsu",
|
||||
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
|
||||
id: "cn-shenzhen",
|
||||
url: "https://cn-shenzhen.api-ipw.wsmdn.top/"
|
||||
}
|
||||
],
|
||||
IPLocationAPIs: [
|
||||
// ---- 节点池结构 ----
|
||||
// IPLocationAPI:IP 归属查询(location / asn)上游节点池(纯数组,无栈区分)
|
||||
IPLocationAPI: [
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
id: "cn-jiangsu",
|
||||
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
},
|
||||
// {
|
||||
// label: "中国 湖北 武汉 电信",
|
||||
// id: "cn-wuhan-chinatelecom",
|
||||
// url: ""
|
||||
// },
|
||||
{
|
||||
label: "中国 四川 沙渠 电信[ZFC]",
|
||||
id: "cn2-sichuan",
|
||||
url: "https://cn2-sichuan.api-ipw.wsmdn.top/"
|
||||
}
|
||||
],
|
||||
TCPing:{
|
||||
DualStack: [
|
||||
// APIBaseURL:其余拨测(whois / ssl / detail / dns / dnssec / tcping / speed)上游节点池,含 { IPv6, IPv4, DualStack } 三栈
|
||||
APIBaseURL: {
|
||||
IPv6: [
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
id: "cn-jiangsu",
|
||||
url :"https://cn-jiangsu.api-ipw.wsmdn.top/",
|
||||
label: "中国 四川 沙渠 电信[ZFC]",
|
||||
id: "cn2-sichuan",
|
||||
url: "https://cn2-sichuan.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
|
||||
id: "cn-shenzhen",
|
||||
url :"https://cn-shenzhen.api-ipw.wsmdn.top/",
|
||||
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
|
||||
id: "lntl-cn-hk-kowloon",
|
||||
url: "https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/"
|
||||
},
|
||||
],
|
||||
IPv4: [
|
||||
{
|
||||
label: "中国 广东 广州 腾讯云",
|
||||
id: "cn-guangzhou",
|
||||
url :"https://cn-guangzhou.api-ipw.wsmdn.top/",
|
||||
url: "https://cn-guangzhou.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "新加坡 腾讯云",
|
||||
id: "sg-1",
|
||||
url :"https://sg-1.api-ipw.wsmdn.top/",
|
||||
url: "https://sg-1.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 香港 新界 西贡区 将军澳 MICC[ZFC]",
|
||||
id: "hk-shatin",
|
||||
url :"https://china-hk-kowloon-shatindistrict-newcloud.ipwapi.zfap.wsmdn.top/",
|
||||
url: "https://china-hk-kowloon-shatindistrict-newcloud.ipwapi.zfap.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 陕西 西安 北经济技术开发区 未央区凤城 中国电信[ZFC]",
|
||||
id: "cn-xian",
|
||||
url :"https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/",
|
||||
url: "https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "美国/加利福尼亚州/洛杉矶/蒙特雷帕克/USCD[ZFC]",
|
||||
label: "美国/加利福尼亚州/洛杉矶/蒙特利奇帕克/USCD[ZFC]",
|
||||
id: "us-la",
|
||||
url :"https://america-california-losangeles.ipwapi.zfap.wsmdn.top/",
|
||||
}
|
||||
],
|
||||
IPv6: [
|
||||
url: "https://america-california-losangeles.ipwapi.zfap.wsmdn.top/"
|
||||
},
|
||||
// IP 直连新节点:URL 留空,仅经独立中间件转发
|
||||
{
|
||||
label: "中国 四川 沙渠 电信[ZFC]",
|
||||
id: "cn2-sichuan",
|
||||
url:"https://cn2-sichuan.api-ipw.wsmdn.top/",
|
||||
label: "山东 枣庄 双线",
|
||||
id: "zaozhuang",
|
||||
url: ""
|
||||
},
|
||||
{
|
||||
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
|
||||
id: "lntl-cn-hk-kowloon",
|
||||
url:"https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/",
|
||||
}
|
||||
]
|
||||
},
|
||||
SpeedTest:{
|
||||
label: "香港 Cogent",
|
||||
id: "hongkong",
|
||||
url: ""
|
||||
},
|
||||
// // WS 通道节点:URL 留空,拨测经中间件 WS 转发
|
||||
// {
|
||||
// label: "呼和浩特移动",
|
||||
// id: "2faa15e6-18a7-4355-b422-ca53277d8d77",
|
||||
// url: ""
|
||||
// }
|
||||
],
|
||||
DualStack: [
|
||||
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
id: "cn-jiangsu",
|
||||
url :"https://cn-jiangsu.api-ipw.wsmdn.top/",
|
||||
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
|
||||
id: "cn-shenzhen",
|
||||
url :"https://cn-shenzhen.api-ipw.wsmdn.top/",
|
||||
},
|
||||
],
|
||||
IPv4: [
|
||||
{
|
||||
label: "中国 广东 广州 腾讯云",
|
||||
id: "cn-guangzhou",
|
||||
url :"https://cn-guangzhou.api-ipw.wsmdn.top/",
|
||||
url: "https://cn-shenzhen.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "新加坡 腾讯云",
|
||||
id: "sg-1",
|
||||
url :"https://sg-1.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 香港 新界 西贡区 将军澳 MICC[ZFC]",
|
||||
id: "hk-shatin",
|
||||
url :"https://china-hk-kowloon-shatindistrict-newcloud.ipwapi.zfap.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 陕西 西安 北经济技术开发区 未央区凤城 中国电信[ZFC]",
|
||||
id: "cn-xian",
|
||||
url :"https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "美国/加利福尼亚州/洛杉矶/蒙特雷帕克/USCD[ZFC]",
|
||||
id: "us-la",
|
||||
url :"https://america-california-losangeles.ipwapi.zfap.wsmdn.top/",
|
||||
}
|
||||
],
|
||||
IPv6: [
|
||||
{
|
||||
label: "中国 四川 沙渠 电信[ZFC]",
|
||||
id: "cn2-sichuan",
|
||||
url:"https://cn2-sichuan.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
|
||||
id: "lntl-cn-hk-kowloon",
|
||||
url:"https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/",
|
||||
label: "河南 安阳 联通",
|
||||
"id": "anyang-cu"
|
||||
}
|
||||
// {
|
||||
// label: "中国 湖北 武汉 电信",
|
||||
// id: "cn-wuhan-chinatelecom",
|
||||
// url: ""
|
||||
// },
|
||||
]
|
||||
},
|
||||
NSLookup:[
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
id: "cn-jiangsu",
|
||||
url :"https://cn-jiangsu.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 广州 腾讯云",
|
||||
id: "cn-guangzhou",
|
||||
url :"https://cn-guangzhou.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "新加坡 腾讯云",
|
||||
id: "sg-1",
|
||||
url :"https://sg-1.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 四川 沙渠 电信[ZFC]",
|
||||
id: "cn2-sichuan",
|
||||
url:"https://cn2-sichuan.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 陕西 西安 北经济技术开发区 未央区凤城 中国电信[ZFC]",
|
||||
id: "cn-xian",
|
||||
url :"https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 香港 新界 西贡区 将军澳 MICC[ZFC]",
|
||||
id: "hk-shatin",
|
||||
url :"https://china-hk-kowloon-shatindistrict-newcloud.ipwapi.zfap.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
|
||||
id: "lntl-cn-hk-kowloon",
|
||||
url:"https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
|
||||
id: "cn-shenzhen",
|
||||
url :"https://cn-shenzhen.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "美国/加利福尼亚州/洛杉矶/蒙特雷帕克/USCD[ZFC]",
|
||||
id: "us-la",
|
||||
url :"https://america-california-losangeles.ipwapi.zfap.wsmdn.top/",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
export { config }
|
||||
@@ -1,5 +1,4 @@
|
||||
import {config} from "./config/index";
|
||||
import { docConfig } from "./config/doc";
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
const extractDomains = (obj: any): string[] => {
|
||||
// 将对象转为 JSON 字符串,用正则匹配所有 https:// 开头的域名部分
|
||||
@@ -10,9 +9,42 @@ 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 },
|
||||
experimental: {
|
||||
// 关闭 app manifest 版本检查:水合后 Nuxt 会在 requestIdleCallback 里请求
|
||||
// /_nuxt/builds/meta/<buildId>.json,buildId 对不上(本地「服务旧、磁盘新」、
|
||||
// 或线上发版切换瞬间)就抛 NUXT_E5002 污染 console,还拖累 Best Practices 分。
|
||||
// 本项目没依赖 appManifest 的客户端能力(无客户端 routeRules、无 prerender
|
||||
// 判定),关掉少一个请求,console 永远干净。
|
||||
appManifest: false,
|
||||
// 注:inlineStyles: true 实测是负优化——它只内联 page 组件样式(+12KB HTML),
|
||||
// entry/EP 等共享样式仍是 render-blocking 外链,关键请求一个没省。勿开。
|
||||
defaults: {
|
||||
nuxtLink: {
|
||||
// NuxtLink 默认「链接进入视口就预取目标页 JS」——顶栏 15 个菜单项会让
|
||||
// 首屏把其它工具页的 chunk(含 87KB 的校验库等重依赖)全拉下来,
|
||||
// 白吃带宽还挤占首屏关键请求。改为「悬停/聚焦才预取」:真正要去
|
||||
// 哪个页面时再拉那一页,首屏只加载当前页需要的代码。
|
||||
prefetchOn: { interaction: true, visibility: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
modules: [
|
||||
"nitro-cloudflare-dev",
|
||||
'@element-plus/nuxt',
|
||||
@@ -26,22 +58,49 @@ 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),不受影响。
|
||||
// —— 2026-10-04 曾试把 preload 也置 false:FCP 确实 2333→1404(少了 22 个请求的
|
||||
// 下载竞争),但 LCP 2387→2933、TBT 86→280(权重 25%/30% 远大于 FCP 的 10%),
|
||||
// 总分 94→89 净亏。原因:这 22 个 modulepreload 是 entry 的静态依赖闭包,全掐掉后
|
||||
// hydration 推迟、长任务落进 LCP/TBT 窗口。故只掐 prefetch,preload 必须留。
|
||||
'build:manifest': (manifest) => {
|
||||
for (const chunk of Object.values(manifest) as unknown 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 +116,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',
|
||||
} as const)),
|
||||
...(middlewareOrigin
|
||||
? [{ rel: 'dns-prefetch', href: middlewareOrigin } as const]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -66,15 +135,22 @@ 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: {
|
||||
// /doc 文档站改为 SSG:构建时预渲染为静态 HTML,由边缘静态资源直接响应
|
||||
'/doc/**': { prerender: true },
|
||||
// 缓存头一律不在这里设,交由 CDN(EdgeOne)侧控制。
|
||||
// /doc/** 曾经是 SSG(prerender),已撤回改为 SSR,原因见下方 nitro.prerender 处的注释。
|
||||
},
|
||||
nitro: {
|
||||
// 构建期给 public/ 静态资源生成 .gz/.br 预压缩副本,按 Accept-Encoding 直接回;
|
||||
// SSR HTML 的压缩在 server/plugins/compress-html.ts。两者合起来让本地直连的
|
||||
// 传输体积与线上(EdgeOne 会压)一致,Lighthouse 的 Lantern 模拟才不会虚高。
|
||||
compressPublicAssets: true,
|
||||
publicAssets: [
|
||||
{
|
||||
dir: 'public',
|
||||
@@ -86,17 +162,21 @@ export default defineNuxtConfig({
|
||||
target: 'es2022' // 明确告诉 Nitro 使用 es2022 进行打包
|
||||
}
|
||||
},
|
||||
prerender: {
|
||||
// 显式列出所有 doc 路由(来自 config/doc.ts 的 docConfig 键),
|
||||
// 让动态 [...slug] 页面在构建时全部预渲染为静态 HTML
|
||||
routes: ['/doc', ...Object.keys(docConfig).filter((p) => p !== '/doc')],
|
||||
},
|
||||
// 这里曾用 prerender.routes 把整份 docConfig 的文档页在构建期预渲染成静态 HTML。
|
||||
// 已撤回,改回 SSR —— 因为预渲染与「按请求头决定渲染形态」根本冲突:
|
||||
// 构建期没有请求头,窄屏判定恒为宽屏,于是预渲染产物里永远躺着
|
||||
// 「15 条桌面顶栏菜单 + 被 CSS 隐藏的 40 项文档侧栏」;真手机打开时客户端判定为窄屏,
|
||||
// 首帧 vdom 与 HTML 对不上,Vue 水合直接抛
|
||||
// `Cannot read properties of null (reading 'nodeType')`,整页变成 500。
|
||||
// 顺带收益:/doc 现在也走运行时安全头(预渲染页拿不到 CSP,见下方 security.ssg)。
|
||||
// 代价只是源站多跑一层 SSR,前面有 EdgeOne 缓存兜着。
|
||||
},
|
||||
security: {
|
||||
ssg: {
|
||||
// /doc 预渲染页的 CSP 会因 SRI modulepreload 哈希全量拼入而单行超 2000 字符,
|
||||
// 触发 Cloudflare _headers 限制;关闭静态页安全头写入 _headers。
|
||||
// SSR 页面仍由运行时中间件下发完整安全头,不受影响。
|
||||
// 预渲染页的 CSP 会因 SRI modulepreload 哈希全量拼入而单行超 2000 字符,
|
||||
// 触发 Cloudflare _headers 限制,故关掉静态页的安全头写入。
|
||||
// 当前没有预渲染路由(/doc/** 已改回 SSR),这条是留给将来再开 prerender 时的开关:
|
||||
// 一旦重新启用 prerender,那些页就会退回「无 CSP」状态。
|
||||
nitroHeaders: false,
|
||||
},
|
||||
headers: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { defineEventHandler, createError } from 'h3'
|
||||
|
||||
const IANA_BOOTSTRAP_URL = 'https://data.iana.org/rdap/dns.json'
|
||||
// bootstrap 数据基本不变,按进程缓存 24h,避免每个请求都重新下载
|
||||
const BOOTSTRAP_TTL = 24 * 60 * 60 * 1000
|
||||
let bootstrapCache: { map: Map<string, string>; at: number } | null = null
|
||||
|
||||
function encodeDomain(input: string): string {
|
||||
try {
|
||||
@@ -21,8 +24,12 @@ function getTLDCandidates(hostname: string): string[] {
|
||||
}
|
||||
|
||||
async function fetchBootstrap(): Promise<Map<string, string>> {
|
||||
if (bootstrapCache && Date.now() - bootstrapCache.at < BOOTSTRAP_TTL) {
|
||||
return bootstrapCache.map
|
||||
}
|
||||
const res = await fetch(IANA_BOOTSTRAP_URL, {
|
||||
headers: { accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw createError({
|
||||
@@ -42,6 +49,7 @@ async function fetchBootstrap(): Promise<Map<string, string>> {
|
||||
map.set(String(tld), baseUrl)
|
||||
}
|
||||
}
|
||||
bootstrapCache = { map, at: Date.now() }
|
||||
return map
|
||||
}
|
||||
|
||||
@@ -214,6 +222,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const primaryRes = await fetch(rdapUrl, {
|
||||
headers: { accept: 'application/rdap+json' },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
|
||||
if (!primaryRes.ok) {
|
||||
@@ -233,6 +242,7 @@ export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const thickRes = await fetch(relatedLink.href, {
|
||||
headers: { accept: 'application/rdap+json' },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
if (thickRes.ok) {
|
||||
thickData = await thickRes.json()
|
||||
|
||||
@@ -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` 清理缓存
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { brotliCompressSync, gzipSync, constants } from 'node:zlib'
|
||||
import type { NitroApp } from 'nitro/types'
|
||||
|
||||
/**
|
||||
* SSR HTML 压缩。
|
||||
*
|
||||
* ## 为什么要压
|
||||
*
|
||||
* nitro 不压 SSR 响应体(静态资源另有 compressPublicAssets 预压缩),
|
||||
* 本地直连 / Lighthouse 测到的 HTML 就是裸传的。生产链路前面有 EdgeOne 兜底,
|
||||
* 但源站自己压是标准做法,也让本地测量贴近线上。
|
||||
*
|
||||
* 只压 SSR 渲染出的 text/html:body 是字符串、体积 > 1KB、对方 Accept-Encoding
|
||||
* 支持才压,按客户端优先级选 br(brotli)否则 gzip。
|
||||
*
|
||||
* ## 为什么必须显式指定 brotli quality = 4(别删这行)
|
||||
*
|
||||
* `brotliCompressSync` 不带 params 时默认 **quality 11**,那是给「离线压一次、
|
||||
* 反复复用」准备的(压一次存盘,读一万次)。SSR 是**每请求现压**:
|
||||
*
|
||||
* quality 33KB HTML 68KB HTML
|
||||
* 11 123ms / 7.2KB 169ms / 11.0KB
|
||||
* 6 4ms / 7.8KB 5ms / 12.2KB
|
||||
* 4 2ms / 8.6KB 3ms / 13.5KB
|
||||
*
|
||||
* Node 是单线程,这段同步压缩**全程阻塞事件循环,直接进 TTFB**。
|
||||
* 首页 TTFB 实测:q11 时 225~260ms,改 q4 后 55~105ms —— 差的就是这 120~170ms。
|
||||
* q4 只多 2.5KB,在 1.6Mbps 上是 12ms,换回 166ms,净赚。
|
||||
*
|
||||
* 教训:凡「每请求现压」的同步压缩都要显式指定质量,别吃库默认值。
|
||||
*/
|
||||
export default defineNitroPlugin((nitroApp: NitroApp) => {
|
||||
nitroApp.hooks.hook('render:response', (response, { event }) => {
|
||||
const headers = (response.headers ??= {})
|
||||
const type = String(
|
||||
headers['content-type'] ?? event.node.res.getHeader('content-type') ?? '',
|
||||
)
|
||||
if (!type.includes('text/html')) return
|
||||
if (typeof response.body !== 'string' || response.body.length < 1024) return
|
||||
if (headers['content-encoding']) return // 已有编码(异常路径),不叠加
|
||||
|
||||
// —— modulepreload 降优先级(2026-10-04 实测,mobile LH 三档对比后选定 low)——
|
||||
// entry 的 26 个静态依赖 chunk 默认 High 优先级,和 4 个 render-blocking CSS 一起
|
||||
// 排队(本地 H1 只有 6 个连接),CSS 被挤到后面 ⇒ Lantern 模拟 FCP 2345ms。
|
||||
// 加 fetchpriority="low" 后 CSS 先行:FCP 2345→1109(-1236ms,首次过 1800 满分线),
|
||||
// desktop 三轮全 100。代价是 JS 下载推迟 → hydration 落到 FCP 之后,TBT 86→213。
|
||||
// 三档实测(mobile perf):无 fp 94/95,medium 92/93(medium 仍挤 CSS,FCP 2253,
|
||||
// 收益尽失),low 94/95 —— low 总分最优。
|
||||
// TBT 的 213 是这项改动的全部代价:FCP 提前后,原本在 FCP 前不计账的 hydration
|
||||
// 长任务进了 TBT 窗口(FCP 每提前 ~500ms,TBT 约 +55,两者在当前架构下互斥)。
|
||||
if (response.body.includes('rel="modulepreload"')) {
|
||||
response.body = response.body.replaceAll(
|
||||
'rel="modulepreload"',
|
||||
'rel="modulepreload" fetchpriority="low"',
|
||||
)
|
||||
}
|
||||
|
||||
const accept = String(event.node.req.headers['accept-encoding'] ?? '')
|
||||
let encoding: 'br' | 'gzip'
|
||||
if (/\bbr\b/.test(accept)) {
|
||||
response.body = brotliCompressSync(Buffer.from(response.body), {
|
||||
params: { [constants.BROTLI_PARAM_QUALITY]: 4 },
|
||||
})
|
||||
encoding = 'br'
|
||||
} else if (/\bgzip\b/.test(accept)) {
|
||||
response.body = gzipSync(Buffer.from(response.body))
|
||||
encoding = 'gzip'
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
headers['content-encoding'] = encoding
|
||||
// 体积变了,content-length 必须跟着改,否则连接会被截断/挂起
|
||||
headers['content-length'] = String((response.body as Buffer).length)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import { defineEventHandler, createError, setResponseStatus, setResponseHeader, getRequestHeader, getRequestIP } from 'h3'
|
||||
import { config } from '../../../config/index'
|
||||
import { useMiddlewareFetch } from '../../../utils/middleware'
|
||||
|
||||
// GET /ip/<ip> → text/plain 输出指定 IP 的归属地(支持 IPv4 / IPv6,如 /ip/2001:db8::1)
|
||||
//
|
||||
// IP: 2001:db8::1
|
||||
// bilibili: 中国 江苏 南京 电信
|
||||
// geocn: 江苏省 南京市 江宁区 电信 宽带
|
||||
// ip2region: 中国 江苏 南京 电信
|
||||
// maxmind_city: 中国 江苏 南京
|
||||
// maxmind_asn: AS4134 中国电信
|
||||
// qqwry: 中国 江苏省 南京市 电信
|
||||
//
|
||||
// 复用 utils/middleware.ts 的 useMiddlewareFetch():候选依次为 config.Middleware 外部中间层 + 前端自带中间件兜底,
|
||||
// 仅当节点无法连接或上游 401/403/418/502 时切换;IPLocationAPI 配置了多个后端时逐个重试。
|
||||
|
||||
// ---- 简单限流(每 IP 每分钟次数上限,进程内内存计数;limit 0 表示不限流) ----
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
function checkRateLimit(ip: string, limit: number): boolean {
|
||||
const now = Date.now()
|
||||
const entry = rateLimitMap.get(ip)
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 })
|
||||
// 顺带清理过期条目,避免 Map 无限增长
|
||||
if (rateLimitMap.size > 1000) {
|
||||
for (const [k, v] of rateLimitMap) {
|
||||
if (v.resetAt <= now) rateLimitMap.delete(k)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
entry.count++
|
||||
return entry.count <= limit
|
||||
}
|
||||
|
||||
// ---- 文本美化:把单个数据源的归属地对象拼成可读字符串 ----
|
||||
function objectToText(obj: any): string {
|
||||
const parts: string[] = []
|
||||
// 位置字段(按国家 → 省/区域 → 城市 → 区县)
|
||||
for (const k of ['country', 'administrative_area', 'region', 'city', 'district', 'province']) {
|
||||
const v = obj[k]
|
||||
if (typeof v === 'string' && v) parts.push(v)
|
||||
}
|
||||
// ASN 编号(如 "AS4134")
|
||||
if (typeof obj.asn === 'string' && obj.asn) {
|
||||
parts.push(obj.asn.startsWith('AS') ? obj.asn : `AS${obj.asn}`)
|
||||
}
|
||||
// 运营商 / 组织 / 类型
|
||||
for (const k of ['isp', 'org', 'type']) {
|
||||
const v = obj[k]
|
||||
if (typeof v === 'string' && v) parts.push(v)
|
||||
}
|
||||
return [...new Set(parts)].join(' ')
|
||||
}
|
||||
|
||||
// ---- 文本美化:多数据源逐行输出(跳过后端未加载的库) ----
|
||||
function formatLocationText(data: any, ip: string): string {
|
||||
const lines: string[] = [`IP: ${ip}`]
|
||||
const sources: Array<[string, any]> = [
|
||||
['bilibili', data?.bilibili],
|
||||
['geocn', data?.geocn],
|
||||
['ip2region', data?.ip2region],
|
||||
['ip2location', data?.ip2location],
|
||||
['maxmind_city', data?.maxmind_city],
|
||||
['maxmind_asn', data?.maxmind_asn],
|
||||
['dbip_city', data?.dbip_city],
|
||||
['dbip_asn', data?.dbip_asn],
|
||||
['qqwry', data?.qqwry],
|
||||
]
|
||||
for (const [name, value] of sources) {
|
||||
if (value === undefined || value === null) continue
|
||||
if (typeof value === 'string') {
|
||||
// 纯字符串结果(error 等)原样输出,"not loaded" 跳过
|
||||
if (value && value !== 'not loaded') {
|
||||
lines.push(`${name}: ${value}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const text = objectToText(value)
|
||||
if (text) lines.push(`${name}: ${text}`)
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ---- 核心:查询指定 IP 的归属地(IPLocationAPI 多后端逐个重试) ----
|
||||
async function locate(event: any, ip: string) {
|
||||
const backends = config.IPLocationAPI
|
||||
if (!backends || backends.length === 0) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'No location backend configured' })
|
||||
}
|
||||
let lastError: any = null
|
||||
for (const backend of backends) {
|
||||
const path = `/middleware/${backend.id}/location/${ip}`
|
||||
const { data, error, execute } = useMiddlewareFetch(path)
|
||||
await execute()
|
||||
if (error.value) {
|
||||
lastError = error.value
|
||||
console.error(`[ip] backend "${backend.label}" failed for ${ip}:`, error.value?.message || error.value)
|
||||
continue
|
||||
}
|
||||
// 成功:location 响应必含 ip 字段;缺失视为失败(如误返回错误体),换下一个后端
|
||||
const res = data.value
|
||||
if (res && typeof res === 'object' && (res as any).ip) {
|
||||
return formatLocationText(res, ip)
|
||||
}
|
||||
lastError = new Error('invalid location response')
|
||||
}
|
||||
|
||||
// 全部后端失败:透传最后一次错误(text/plain)
|
||||
const errStatus = lastError?.status ?? lastError?.statusCode ?? 502
|
||||
const status = typeof errStatus === 'number' ? errStatus : 502
|
||||
setResponseStatus(event, status)
|
||||
return `Error ${status}: ${lastError?.message || 'Backend unreachable'}`
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 0) 统一输出 text/plain(不是 JSON)
|
||||
setResponseHeader(event, 'content-type', 'text/plain; charset=utf-8')
|
||||
|
||||
// 1) Origin / Referer 校验:跨域浏览器调用一律拒绝
|
||||
const siteOrigin = new URL(config.siteUrl).origin
|
||||
const origin = getRequestHeader(event, 'origin')
|
||||
const referer = getRequestHeader(event, 'referer')
|
||||
if (origin && origin !== siteOrigin) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: cross-origin request' })
|
||||
}
|
||||
if (referer && referer !== siteOrigin && !referer.startsWith(siteOrigin + '/')) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: invalid referer' })
|
||||
}
|
||||
|
||||
// 2) 每 IP 每分钟限流(次数取 config.rateLimitPerMinute,0 表示不限流)
|
||||
const rateLimit = config.rateLimitPerMinute || 0
|
||||
if (rateLimit > 0 && !checkRateLimit(getRequestIP(event) || 'unknown', rateLimit)) {
|
||||
throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
|
||||
}
|
||||
|
||||
// 3) 取路径参数 IP
|
||||
const ipParam: string = event.context.params?.ip as any
|
||||
let ip = ipParam ? ipParam.trim() : ''
|
||||
if (!ip) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing IP parameter' })
|
||||
}
|
||||
|
||||
return locate(event, ip)
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { defineEventHandler, getQuery, createError, setResponseStatus, setResponseHeader, getRequestHeader, getRequestIP } from 'h3'
|
||||
import { config } from '../../../config/index'
|
||||
import { useMiddlewareFetch } from '../../../utils/middleware'
|
||||
|
||||
// GET /ip → text/plain 输出 IP 归属地(?ip=<ip> 优先,否则查请求方 IP)
|
||||
//
|
||||
// IP: 1.2.3.4
|
||||
// bilibili: 中国 江苏 南京 电信
|
||||
// geocn: 江苏省 南京市 江宁区 电信 宽带
|
||||
// ip2region: 中国 江苏 南京 电信
|
||||
// maxmind_city: 中国 江苏 南京
|
||||
// maxmind_asn: AS4134 中国电信
|
||||
// qqwry: 中国 江苏省 南京市 电信
|
||||
//
|
||||
// 复用 utils/middleware.ts 的 useMiddlewareFetch():候选依次为 config.Middleware 外部中间层 + 前端自带中间件兜底,
|
||||
// 仅当节点无法连接或上游 401/403/418/502 时切换;IPLocationAPI 配置了多个后端时逐个重试。
|
||||
|
||||
// ---- 简单限流(每 IP 每分钟次数上限,进程内内存计数;limit 0 表示不限流) ----
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
function checkRateLimit(ip: string, limit: number): boolean {
|
||||
const now = Date.now()
|
||||
const entry = rateLimitMap.get(ip)
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 })
|
||||
// 顺带清理过期条目,避免 Map 无限增长
|
||||
if (rateLimitMap.size > 1000) {
|
||||
for (const [k, v] of rateLimitMap) {
|
||||
if (v.resetAt <= now) rateLimitMap.delete(k)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
entry.count++
|
||||
return entry.count <= limit
|
||||
}
|
||||
|
||||
// ---- 文本美化:把单个数据源的归属地对象拼成可读字符串 ----
|
||||
function objectToText(obj: any): string {
|
||||
const parts: string[] = []
|
||||
// 位置字段(按国家 → 省/区域 → 城市 → 区县)
|
||||
for (const k of ['country', 'administrative_area', 'region', 'city', 'district', 'province']) {
|
||||
const v = obj[k]
|
||||
if (typeof v === 'string' && v) parts.push(v)
|
||||
}
|
||||
// ASN 编号(如 "AS4134")
|
||||
if (typeof obj.asn === 'string' && obj.asn) {
|
||||
parts.push(obj.asn.startsWith('AS') ? obj.asn : `AS${obj.asn}`)
|
||||
}
|
||||
// 运营商 / 组织 / 类型
|
||||
for (const k of ['isp', 'org', 'type']) {
|
||||
const v = obj[k]
|
||||
if (typeof v === 'string' && v) parts.push(v)
|
||||
}
|
||||
return [...new Set(parts)].join(' ')
|
||||
}
|
||||
|
||||
// ---- 文本美化:多数据源逐行输出(跳过后端未加载的库) ----
|
||||
function formatLocationText(data: any, ip: string): string {
|
||||
const lines: string[] = [`IP: ${ip}`]
|
||||
const sources: Array<[string, any]> = [
|
||||
['bilibili', data?.bilibili],
|
||||
['geocn', data?.geocn],
|
||||
['ip2region', data?.ip2region],
|
||||
['ip2location', data?.ip2location],
|
||||
['maxmind_city', data?.maxmind_city],
|
||||
['maxmind_asn', data?.maxmind_asn],
|
||||
['dbip_city', data?.dbip_city],
|
||||
['dbip_asn', data?.dbip_asn],
|
||||
['qqwry', data?.qqwry],
|
||||
]
|
||||
for (const [name, value] of sources) {
|
||||
if (value === undefined || value === null) continue
|
||||
if (typeof value === 'string') {
|
||||
// 纯字符串结果(error 等)原样输出,"not loaded" 跳过
|
||||
if (value && value !== 'not loaded') {
|
||||
lines.push(`${name}: ${value}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const text = objectToText(value)
|
||||
if (text) lines.push(`${name}: ${text}`)
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ---- 核心:查询指定 IP 的归属地(IPLocationAPI 多后端逐个重试) ----
|
||||
async function locate(event: any, ip: string) {
|
||||
const backends = config.IPLocationAPI
|
||||
if (!backends || backends.length === 0) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'No location backend configured' })
|
||||
}
|
||||
let lastError: any = null
|
||||
for (const backend of backends) {
|
||||
const path = `/middleware/${backend.id}/location/${ip}`
|
||||
const { data, error, execute } = useMiddlewareFetch(path)
|
||||
await execute()
|
||||
if (error.value) {
|
||||
lastError = error.value
|
||||
console.error(`[ip] backend "${backend.label}" failed for ${ip}:`, error.value?.message || error.value)
|
||||
continue
|
||||
}
|
||||
// 成功:location 响应必含 ip 字段;缺失视为失败(如误返回错误体),换下一个后端
|
||||
const res = data.value
|
||||
if (res && typeof res === 'object' && (res as any).ip) {
|
||||
return formatLocationText(res, ip)
|
||||
}
|
||||
lastError = new Error('invalid location response')
|
||||
}
|
||||
|
||||
// 全部后端失败:透传最后一次错误(text/plain)
|
||||
const errStatus = lastError?.status ?? lastError?.statusCode ?? 502
|
||||
const status = typeof errStatus === 'number' ? errStatus : 502
|
||||
setResponseStatus(event, status)
|
||||
return `Error ${status}: ${lastError?.message || 'Backend unreachable'}`
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 0) 统一输出 text/plain(不是 JSON)
|
||||
setResponseHeader(event, 'content-type', 'text/plain; charset=utf-8')
|
||||
|
||||
// 1) Origin / Referer 校验:跨域浏览器调用一律拒绝
|
||||
const siteOrigin = new URL(config.siteUrl).origin
|
||||
const origin = getRequestHeader(event, 'origin')
|
||||
const referer = getRequestHeader(event, 'referer')
|
||||
if (origin && origin !== siteOrigin) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: cross-origin request' })
|
||||
}
|
||||
if (referer && referer !== siteOrigin && !referer.startsWith(siteOrigin + '/')) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: invalid referer' })
|
||||
}
|
||||
|
||||
// 2) 每 IP 每分钟限流(次数取 config.rateLimitPerMinute,0 表示不限流)
|
||||
const rateLimit = config.rateLimitPerMinute || 0
|
||||
if (rateLimit > 0 && !checkRateLimit(getRequestIP(event) || 'unknown', rateLimit)) {
|
||||
throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
|
||||
}
|
||||
|
||||
// 3) 确定要查询的 IP:?ip=<ip> 优先,否则查请求方 IP
|
||||
const query = getQuery(event)
|
||||
let ip = typeof query.ip === 'string' ? query.ip.trim() : ''
|
||||
if (!ip) {
|
||||
ip = getRequestIP(event, { xForwardedFor: true }) || ''
|
||||
}
|
||||
ip = ip.trim()
|
||||
if (!ip) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Unable to determine IP address' })
|
||||
}
|
||||
|
||||
return locate(event, ip)
|
||||
})
|
||||
@@ -1,6 +1,46 @@
|
||||
import { defineEventHandler, getQuery, createError, setResponseStatus } from 'h3'
|
||||
import { defineEventHandler, getQuery, createError, setResponseStatus, getRequestHeader, getRequestIP } from 'h3'
|
||||
import { config } from '../../../config/index'
|
||||
|
||||
// ---- 方案 C:简单限流(每 IP 每分钟次数上限,进程内内存计数;次数取 config.rateLimitPerMinute,0 表示不限流) ----
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
function checkRateLimit(ip: string, limit: number): boolean {
|
||||
const now = Date.now()
|
||||
const entry = rateLimitMap.get(ip)
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 })
|
||||
// 顺带清理过期条目,避免 Map 无限增长
|
||||
if (rateLimitMap.size > 1000) {
|
||||
for (const [k, v] of rateLimitMap) {
|
||||
if (v.resetAt <= now) rateLimitMap.delete(k)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
entry.count++
|
||||
return entry.count <= limit
|
||||
}
|
||||
|
||||
// 数据上报已移除:统计与拨测明细统一由下游节点(lemon-ipw/src)自行上报,
|
||||
// 本转发入口不再计数、不再 POST /report,转发请求也不再携带 X-Boce-Reporter 归属头(防双算协议已废除)。
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 1) Origin / Referer 校验:跨域浏览器调用一律拒绝。
|
||||
// 注意:无 Origin/Referer 的服务端 SSR 调用与命令行请求无法区分,仅靠下方限流约束。
|
||||
const siteOrigin = new URL(config.siteUrl).origin
|
||||
const origin = getRequestHeader(event, 'origin')
|
||||
const referer = getRequestHeader(event, 'referer')
|
||||
if (origin && origin !== siteOrigin) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: cross-origin request' })
|
||||
}
|
||||
if (referer && referer !== siteOrigin && !referer.startsWith(siteOrigin + '/')) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: invalid referer' })
|
||||
}
|
||||
// 2) 每 IP 每分钟限流(次数取 config.rateLimitPerMinute,0 表示不限流)
|
||||
const rateLimit = config.rateLimitPerMinute || 0
|
||||
if (rateLimit > 0 && !checkRateLimit(getRequestIP(event) || 'unknown', rateLimit)) {
|
||||
throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
|
||||
}
|
||||
|
||||
const slugString: string = event.context.params?.slug as any
|
||||
if (!slugString) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing slug parameter' })
|
||||
@@ -33,10 +73,15 @@ export default defineEventHandler(async (event) => {
|
||||
if (!backendID || !apiType || !raw) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing parameters in slug' })
|
||||
}
|
||||
// 阻断路径穿越:raw 里的 ".." 会被上游 URL 规范化解析掉,
|
||||
// 把请求(连同节点的 Authorization 头)带到 /v1 之外的任意路径
|
||||
if (raw.split('/').some(seg => seg === '.' || seg === '..')) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid path' })
|
||||
}
|
||||
const query = getQuery(event)
|
||||
const runtimeConfig = useRuntimeConfig(event)
|
||||
|
||||
// 从运行时变量读取 APIKEYS (JSON字符串),解析后按 backendID 查找 token
|
||||
const runtimeConfig = useRuntimeConfig(event)
|
||||
let apiKey: string | undefined
|
||||
try {
|
||||
const apiKeysMap: Record<string, string> = (runtimeConfig.apiKeys || {}) as Record<string, string>
|
||||
@@ -47,39 +92,29 @@ export default defineEventHandler(async (event) => {
|
||||
} catch (e) {
|
||||
console.debug('[middleware debug] JSON parse FAILED:', e)
|
||||
}
|
||||
const authHeaders: Record<string, string> = {
|
||||
'Origin': config.siteUrl.replace(/\/$/, ''),
|
||||
}
|
||||
// 转发头:不透传 Origin,避免上游 CORS 误判;仅按需注入 Authorization。
|
||||
const authHeaders: Record<string, string> = {}
|
||||
if (apiKey) {
|
||||
authHeaders['Authorization'] = `Bearer ${apiKey}`
|
||||
console.debug('[middleware debug] authHeaders SET Authorization:', `Bearer ${apiKey.slice(0, 1)}...`)
|
||||
} else {
|
||||
console.debug('[middleware debug] authHeaders NO Authorization header')
|
||||
}
|
||||
|
||||
if (apiType === 'whois' || apiType === 'dns' || apiType === 'location' || apiType === 'ssl' || apiType === 'asn' || apiType === 'dnssec' || apiType === 'detail') {
|
||||
let apiBaseUrls: Array<{ id: string, url: string ,label: string}> = []
|
||||
switch (apiType) {
|
||||
case 'whois':
|
||||
apiBaseUrls = [...config.apiBaseUrls]
|
||||
case 'ssl':
|
||||
case 'detail':
|
||||
apiBaseUrls = [...config.APIBaseURL.DualStack]
|
||||
break
|
||||
case 'dns':
|
||||
apiBaseUrls = [...config.NSLookup]
|
||||
case 'dnssec':
|
||||
apiBaseUrls = [...config.APIBaseURL.DualStack, ...config.APIBaseURL.IPv4, ...config.APIBaseURL.IPv6]
|
||||
break
|
||||
case 'location':
|
||||
apiBaseUrls = [...config.IPLocationAPIs]
|
||||
break
|
||||
case 'ssl':
|
||||
apiBaseUrls = [...config.apiBaseUrls]
|
||||
break
|
||||
case 'asn':
|
||||
apiBaseUrls = [...config.IPLocationAPIs]
|
||||
break
|
||||
case 'dnssec':
|
||||
apiBaseUrls = [...config.NSLookup]
|
||||
break
|
||||
case 'detail':
|
||||
apiBaseUrls = [...config.apiBaseUrls]
|
||||
apiBaseUrls = [...config.IPLocationAPI]
|
||||
break
|
||||
default:
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid API type' })
|
||||
@@ -89,8 +124,9 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid backend ID' })
|
||||
}
|
||||
let apiBaseUrl = map.get(backendID)
|
||||
if (apiBaseUrl === undefined) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'API base URL not found for the given backend ID' })
|
||||
// url 为空的节点无法转发(会变成对前端自身的请求),直接报 502 而不是浪费一次自请求
|
||||
if (!apiBaseUrl) {
|
||||
throw createError({ statusCode: 502, statusMessage: 'Backend URL not configured' })
|
||||
}
|
||||
let data: any = {}
|
||||
|
||||
@@ -100,6 +136,10 @@ export default defineEventHandler(async (event) => {
|
||||
data = await $fetch(`${apiBaseUrl}v1/${apiType}/${raw}`, {
|
||||
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
|
||||
@@ -114,28 +154,19 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
return data
|
||||
}else if (apiType === 'tcping' || apiType === 'udping' || apiType === 'speed') {
|
||||
let apiBaseUrls:Array<{ id: string, url: string ,label: string}> = []
|
||||
let apiTypeConfig: any = {}
|
||||
switch (apiType) {
|
||||
case 'tcping':
|
||||
apiTypeConfig = config.TCPing
|
||||
apiBaseUrls = [...apiTypeConfig.DualStack, ...apiTypeConfig.IPv4, ...apiTypeConfig.IPv6]
|
||||
break
|
||||
case 'speed':
|
||||
apiTypeConfig = config.SpeedTest
|
||||
apiBaseUrls = [...apiTypeConfig.DualStack, ...apiTypeConfig.IPv4, ...apiTypeConfig.IPv6]
|
||||
break
|
||||
default:
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid API type' })
|
||||
|
||||
}
|
||||
// tcping/udping/speed 统一走 APIBaseURL 节点池(平铺三栈)
|
||||
const apiBaseUrls: Array<{ id: string, url: string ,label: string}> = [
|
||||
...config.APIBaseURL.DualStack,
|
||||
...config.APIBaseURL.IPv4,
|
||||
...config.APIBaseURL.IPv6
|
||||
]
|
||||
const map = new Map<string, string>(apiBaseUrls.map(api => [api.id, api.url]))
|
||||
if (!map.has(backendID)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid backend ID' })
|
||||
}
|
||||
let apiBaseUrl = map.get(backendID)
|
||||
if (apiBaseUrl === undefined) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'API base URL not found for the given backend ID' })
|
||||
if (!apiBaseUrl) {
|
||||
throw createError({ statusCode: 502, statusMessage: 'Backend URL not configured' })
|
||||
}
|
||||
let data: any = {}
|
||||
|
||||
@@ -150,6 +181,8 @@ export default defineEventHandler(async (event) => {
|
||||
data = await $fetch(`${apiBaseUrl}v1/${apiType}/${raw}${queryString ? '?' + queryString : ''}`, {
|
||||
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
|
||||
|
||||
@@ -28,14 +28,22 @@ function isConnectionError(e: any): boolean {
|
||||
}
|
||||
|
||||
// 需要切换节点重试的上游状态码:
|
||||
// 401 未授权、403 被 CDN/WAF 风控拦截、418 被反爬/限流标记 —— 换一个节点往往就能绕过
|
||||
const RETRY_STATUS_CODES = new Set([401, 403, 418])
|
||||
// 401 未授权、403 被 CDN/WAF 风控拦截、418 被反爬/限流标记、502 中间件/上游不可用 —— 换一个节点往往就能恢复
|
||||
const RETRY_STATUS_CODES = new Set([401, 403, 418, 502])
|
||||
|
||||
// 过滤掉 useFetch 专属选项(对 $fetch 无意义),其余(method/query/params/headers/body/timeout 等)原样透传。
|
||||
//
|
||||
// 默认关闭 ofetch 内置重试(retry: false):
|
||||
// ofetch 对 GET 默认重试 1 次,且「拿不到响应(网络层错误)」会按 500 参与重试(见 ofetch onError:
|
||||
// responseCode = response.status || 500)。于是 502、429、超时、"连不上"这几种最贵的失败,
|
||||
// 都会在**同一个节点**上被立即重打一遍(retryDelay 默认 0,无退避),
|
||||
// 请求数与等待时间同时 ×2 —— 而外层候选循环本来就会换节点重试,这层纯属重复。
|
||||
// 调用方若显式传 retry,以调用方为准(rest 在后覆盖)。
|
||||
const DEFAULT_RETRY = false
|
||||
|
||||
// 过滤掉 useFetch 专属选项(对 $fetch 无意义),其余(method/query/params/headers/body/timeout 等)原样透传
|
||||
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) {
|
||||
@@ -64,9 +73,12 @@ export function useMiddlewareFetch<T = any>(url: MaybeRefOrGetter<string>, optio
|
||||
try {
|
||||
for (let i = 0; i < candidates.value.length; i++) {
|
||||
if (id !== requestId) return undefined // 已被更新的请求取代
|
||||
const candidate = candidates.value[i]
|
||||
// noUncheckedIndexedAccess 下索引访问为 string | undefined,防御性跳过
|
||||
if (candidate === undefined) continue
|
||||
try {
|
||||
// $fetch 为 Nuxt 全局注入;候选失败会抛 FetchError(含 status)
|
||||
const res = await $fetch<T>(candidates.value[i], fetchOptions)
|
||||
const res = await $fetch<T>(candidate, fetchOptions)
|
||||
if (id !== requestId) return undefined
|
||||
data.value = res
|
||||
return res
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,21 +5,21 @@ export function extractHost(url: string): string {
|
||||
return match?.groups?.host ?? url;
|
||||
}
|
||||
|
||||
export function formatTime(ms: number): string {
|
||||
if (ms == null || ms <= 0) return '-'
|
||||
export function formatTime(ms?: number | null): string {
|
||||
if (ms == null || typeof ms !== 'number' || !isFinite(ms) || ms <= 0) return '-'
|
||||
if (ms < 1000) {
|
||||
return `${ms.toFixed(2)} ms`
|
||||
}
|
||||
return `${(ms / 1000).toFixed(2)} s`
|
||||
}
|
||||
|
||||
export function formatSpeed(speed: number): string {
|
||||
if (speed == null) return '-'
|
||||
export function formatSpeed(speed?: number | null): string {
|
||||
if (speed == null || typeof speed !== 'number' || !isFinite(speed)) return '-'
|
||||
return `${speed.toFixed(2)} KB/s`
|
||||
}
|
||||
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes == null) return '-'
|
||||
export function formatSize(bytes?: number | null): string {
|
||||
if (bytes == null || typeof bytes !== 'number' || !isFinite(bytes)) return '-'
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`
|
||||
}
|
||||
@@ -29,7 +29,8 @@ export function formatSize(bytes: number): string {
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
export function getStatusCodeClass(code: number): string {
|
||||
export function getStatusCodeClass(code?: number | null): string {
|
||||
if (code == null || typeof code !== 'number') return 'status-error'
|
||||
if (code >= 200 && code < 300) return 'status-success'
|
||||
if (code >= 300 && code < 400) return 'status-warning'
|
||||
return 'status-error'
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
module lemon-ipw
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.7.7
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/ipipdotnet/ipdb-go v1.3.3
|
||||
github.com/klauspost/compress v1.19.1
|
||||
github.com/likexian/whois v1.15.7
|
||||
github.com/likexian/whois-parser v1.24.21
|
||||
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260417083004-03cc433717a7
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.3.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/sync v0.22.0
|
||||
resty.dev/v3 v3.0.0-beta.6
|
||||
)
|
||||
|
||||
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/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/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/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/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
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
@@ -1,145 +0,0 @@
|
||||
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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/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/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/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/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.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
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=
|
||||
github.com/likexian/whois v1.15.7/go.mod h1:kdPQtYb+7SQVftBEbCblDadUkycN7Mg1k1/Li/rwvmc=
|
||||
github.com/likexian/whois-parser v1.24.21 h1:MxsrGRxDOiZIVp7q7N/yAIbKuN4QAkGjCpOtTDA5OsM=
|
||||
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/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=
|
||||
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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
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.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/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=
|
||||
resty.dev/v3 v3.0.0-beta.6 h1:ghRdNpoE8/wBCv+kTKIOauW1aCrSIeTq7GxtfYgtevU=
|
||||
resty.dev/v3 v3.0.0-beta.6/go.mod h1:NTOerrC/4T7/FE6tXIZGIysXXBdgNqwMZuKtxpea9NM=
|
||||
@@ -0,0 +1,378 @@
|
||||
#!/usr/bin/bash
|
||||
# ============================================================
|
||||
# 后端节点一键安装脚本
|
||||
# - 自动检测架构并下载最新 release 二进制
|
||||
# - 交互式输入配置(环境变量注入,无需 setting.json)
|
||||
# - 生成并启用 systemd 守护进程
|
||||
# - 非 root 运行时会提示是否改用 systemd --user(用户级服务)
|
||||
# 用法:sudo bash install.sh # 系统级安装(推荐,开机自启、独立于登录会话)
|
||||
# bash install.sh # 非 root:确认后走 systemd --user,仅对当前用户生效
|
||||
# ============================================================
|
||||
|
||||
text="
|
||||
██╗██████╗ ██╗ ██╗ ███╗ ██╗ ██████╗ ██████╗ ███████╗
|
||||
██║██╔══██╗██║ ██║ ████╗ ██║██╔═══██╗██╔══██╗██╔════╝
|
||||
██║██████╔╝██║ █╗ ██║█████╗██╔██╗ ██║██║ ██║██║ ██║█████╗
|
||||
██║██╔═══╝ ██║███╗██║╚════╝██║╚██╗██║██║ ██║██║ ██║██╔══╝
|
||||
██║██║ ╚███╔███╔╝ ██║ ╚████║╚██████╔╝██████╔╝███████╗
|
||||
╚═╝╚═╝ ╚══╝╚══╝ ╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚══════╝
|
||||
|
||||
"
|
||||
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 与系统安装目录。"
|
||||
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
|
||||
fi
|
||||
|
||||
# ---------- 架构检测 ----------
|
||||
systemArch=$(uname -m)
|
||||
case "$systemArch" in
|
||||
x86_64) BINARY="lemonipw-linux-amd64" ;;
|
||||
i386|i486|i586|i686) BINARY="lemonipw-linux-386" ;;
|
||||
aarch64|arm64) BINARY="lemonipw-linux-arm64" ;;
|
||||
armv7l|armv7hl) BINARY="lemonipw-linux-armv7" ;;
|
||||
armv6l) BINARY="lemonipw-linux-armv6" ;;
|
||||
loongarch64) BINARY="lemonipw-linux-loong64" ;;
|
||||
*)
|
||||
echo "错误:不支持的架构 '$systemArch'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "检测到架构 $systemArch,将下载 $BINARY"
|
||||
|
||||
# ---------- 交互式配置输入 ----------
|
||||
|
||||
# 生成 UUID(优先 /proc,其次 uuidgen,最后 /dev/urandom)
|
||||
gen_uuid() {
|
||||
if [ -r /proc/sys/kernel/random/uuid ]; then
|
||||
cat /proc/sys/kernel/random/uuid
|
||||
elif command -v uuidgen >/dev/null 2>&1; then
|
||||
uuidgen | tr 'A-Z' 'a-z'
|
||||
else
|
||||
od -An -N16 -tx1 /dev/urandom | tr -d ' \n' | sed 's/\(........\)\(....\)\(....\)\(....\)\(............\)/\1-\2-\3-\4-\5/'
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " 配置后端节点(直接回车使用默认值)"
|
||||
echo "========================================"
|
||||
|
||||
read -r -p "安装目录 [$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}
|
||||
|
||||
read -r -p "单栈模式 SINGLE_STACK(留空=双栈,ipv4 或 ipv6): " SINGLE_STACK
|
||||
|
||||
read -r -p "访问令牌 access-token(留空=不启用鉴权): " ACCESS_TOKEN
|
||||
|
||||
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,用于 IP 归属地/ASN 类拨测)[y/N]: " IPDB_CHOICE
|
||||
case "${IPDB_CHOICE,,}" in
|
||||
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_KEY=""
|
||||
WS_USED_DEFAULT=""
|
||||
case "${WS_CHOICE,,}" in
|
||||
y|yes)
|
||||
read -r -p " 中间件 WS 完整地址(含 wss:// 前缀与 /ws 路径,如 wss://host:8092/ws;多个用逗号分隔将同时连接全部;留空=贡献节点给柠檬的两条公共中间件): " WS_URL
|
||||
if [ -z "$WS_URL" ]; then
|
||||
WS_URL="wss://boce-api.api-ipw.wsmdn.top/ws,wss://middleware-1.api-ipw.wsmdn.top/ws"
|
||||
WS_USED_DEFAULT="true"
|
||||
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
|
||||
|
||||
echo ""
|
||||
echo "--- 其他环境变量(可选)---"
|
||||
echo "输入额外的 Environment= 项,每行一个(如 GH_PROXY=https://ghproxy.com/),空行结束:"
|
||||
EXTRA_ENVS=""
|
||||
while true; do
|
||||
read -r -p " > " EXTRA
|
||||
[ -z "$EXTRA" ] && break
|
||||
if [ -z "$EXTRA_ENVS" ]; then
|
||||
EXTRA_ENVS="$EXTRA"
|
||||
else
|
||||
EXTRA_ENVS="$EXTRA_ENVS
|
||||
$EXTRA"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---------- 汇总展示 ----------
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " 配置汇总"
|
||||
echo "========================================"
|
||||
echo "安装目录: $INSTALL_DIR"
|
||||
echo "服务名: lemon-ipw"
|
||||
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:-自动探测(系统 DNS)}"
|
||||
echo "ipdb: $IPDB"
|
||||
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
|
||||
n|no) echo "已取消" ; exit 0 ;;
|
||||
*) : ;;
|
||||
esac
|
||||
|
||||
# ---------- 下载二进制 ----------
|
||||
echo ""
|
||||
echo "正在获取最新版本..."
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
LATEST_TAG=$(curl -s https://api.github.com/repos/nomdn/ipw-cn/releases/latest | grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
else
|
||||
LATEST_TAG=$(wget -qO- https://api.github.com/repos/nomdn/ipw-cn/releases/latest | grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
fi
|
||||
if [ -z "$LATEST_TAG" ]; then
|
||||
echo "错误:无法获取最新版本号(检查网络或 GitHub API 速率限制)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "最新版本:$LATEST_TAG"
|
||||
DOWNLOAD_URL="https://github.com/nomdn/ipw-cn/releases/download/$LATEST_TAG/$BINARY"
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
echo "下载 $BINARY ..."
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
wget -q -O "$INSTALL_DIR/lemonipw" "$DOWNLOAD_URL"
|
||||
else
|
||||
# -f 让 404/4xx 直接失败,避免把 GitHub 的 "Not Found" 页面存成二进制
|
||||
curl -fsSL -o "$INSTALL_DIR/lemonipw" "$DOWNLOAD_URL"
|
||||
fi
|
||||
chmod +x "$INSTALL_DIR/lemonipw"
|
||||
echo "二进制已安装到 $INSTALL_DIR/lemonipw"
|
||||
|
||||
# ---------- 生成 systemd 服务 ----------
|
||||
|
||||
# 用户级 unit 目录(~/.config/systemd/user)默认可能不存在
|
||||
mkdir -p "$(dirname "$SERVICE_FILE")"
|
||||
|
||||
# 收集环境变量(仅非空的写入,避免空值覆盖默认)
|
||||
# 值里的双引号/反斜杠需要转义,否则会提前终止 systemd 的引号包裹、损坏 unit
|
||||
ENV_LINES=""
|
||||
add_env() {
|
||||
local name="$1" value="$2"
|
||||
[ -n "$value" ] || return 0
|
||||
value="${value//\\/\\\\}"
|
||||
value="${value//\"/\\\"}"
|
||||
ENV_LINES="${ENV_LINES}Environment=\"${name}=${value}\"
|
||||
"
|
||||
}
|
||||
add_env PORTS "$PORTS"
|
||||
add_env SINGLE_STACK "$SINGLE_STACK"
|
||||
add_env ACCESS_TOKEN "$ACCESS_TOKEN"
|
||||
add_env DNS_SERVER "$DNS_SERVER"
|
||||
add_env DNSSEC_DNS_SERVER "$DNSSEC_DNS_SERVER"
|
||||
add_env IPDB "$IPDB"
|
||||
add_env CORS "$CORS"
|
||||
add_env REMOTE_CONFIG_URL "$REMOTE_CONFIG_URL"
|
||||
add_env WS_URL "$WS_URL"
|
||||
add_env NODE_ID "$NODE_ID"
|
||||
add_env NODE_KEY "$NODE_KEY"
|
||||
if [ -n "$EXTRA_ENVS" ]; then
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] || continue
|
||||
add_env "${line%%=*}" "${line#*=}"
|
||||
done <<< "$EXTRA_ENVS"
|
||||
fi
|
||||
|
||||
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}/lemonipw
|
||||
${ENV_LINES}Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=${UNIT_WANTED_BY}
|
||||
EOF
|
||||
|
||||
echo "已生成服务文件 $SERVICE_FILE"
|
||||
|
||||
# ---------- 启动服务 ----------
|
||||
sc daemon-reload
|
||||
# restart 而非 enable --now:重跑安装改配置后旧进程必须重启才会生效
|
||||
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 " $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(缺一个该条连接会被拒 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 ""
|
||||
echo "========================================"
|
||||
echo "感谢您的贡献,请向 iduhih777@outlook.com 提交您的节点信息,谢谢!"
|
||||
echo " 节点 id: $NODE_ID"
|
||||
echo " 注册 key: $NODE_KEY"
|
||||
echo " WS 地址: $WS_URL"
|
||||
echo " 监听端口: $PORTS"
|
||||
echo " 单栈模式: ${SINGLE_STACK:-双栈}"
|
||||
echo " access-token: ${ACCESS_TOKEN:+已设置 (隐藏)}"
|
||||
echo " CORS: ${CORS:-不限}"
|
||||
echo " 地区-运营商 请自行填写"
|
||||
echo "========================================"
|
||||
fi
|
||||
echo "========================================"
|
||||
@@ -1,13 +1,25 @@
|
||||
FROM golang:1.26-alpine AS build
|
||||
# 多架构构建:buildx 会注入 TARGETOS/TARGETARCH,构建阶段固定在构建机原生平台
|
||||
# (--platform=$BUILDPLATFORM)交叉编译,目标架构无需 QEMU 模拟;未用 buildx 时
|
||||
# TARGETOS/TARGETARCH 为空,Go 默认取宿主机平台,普通 docker build 仍然可用
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ENV GO111MODULE=on \
|
||||
CGO_ENABLED=0 \
|
||||
GOOS=linux \
|
||||
GOARCH=amd64
|
||||
GOOS=${TARGETOS} \
|
||||
GOARCH=${TARGETARCH}
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY main.go ./
|
||||
RUN go build -o main .
|
||||
COPY *.go ./
|
||||
# TARGETVARIANT 是 buildx 的 "v7"/"v6" 写法,Go 的 GOARM 只要数字;不映射时 GOARCH=arm 默认按 GOARM=6 编译
|
||||
RUN set -eux; \
|
||||
case "$TARGETVARIANT" in \
|
||||
v7) export GOARM=7 ;; \
|
||||
v6) export GOARM=6 ;; \
|
||||
esac; \
|
||||
go build -o main .
|
||||
|
||||
# 运行阶段
|
||||
FROM alpine:latest
|
||||
|
||||
@@ -2,19 +2,34 @@ module github.com/nomdn/ipw-cn/middleware-go
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/gofiber/fiber/v3 v3.4.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||
github.com/gofiber/fiber/v3 v3.4.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/gofiber/schema v1.8.0 // indirect
|
||||
github.com/gofiber/utils/v2 v2.1.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.19.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spf13/viper v1.21.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.72.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gofiber/fiber/v3 v3.4.0 h1:F0aND4vwZF7dR7cbvSwFQQEpBU902XHKWxrLsFBkVqw=
|
||||
github.com/gofiber/fiber/v3 v3.4.0/go.mod h1:nAhJfdxUIJJph2tPWPmqWf8QDIN2iiqQiQf3lENZpdk=
|
||||
github.com/gofiber/schema v1.8.0 h1:NGsC9toPHmj8Xg4KpznuXBzNmHG6V5YV0tXKpKMcmis=
|
||||
@@ -14,14 +24,42 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/shamaton/msgpack/v3 v3.1.2 h1:d5gWAIyMU4M0WgDjz6IFSCuXJUA2dFwRHBpDclE8CLw=
|
||||
github.com/shamaton/msgpack/v3 v3.1.2/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M=
|
||||
github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
@@ -30,3 +68,6 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -8,41 +8,75 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/cors"
|
||||
"github.com/gofiber/fiber/v3/middleware/limiter"
|
||||
)
|
||||
|
||||
// ==================== 全局变量 ====================
|
||||
|
||||
// apiInfo 对应 setting.json apiBaseUrls 等数组中的 { label, id, url } 配置项
|
||||
// apiInfo 对应 setting.json 节点池中的 { label, id, url } 配置项
|
||||
type apiInfo struct {
|
||||
Label string `json:"label"`
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
WS wsFlag `json:"ws"` // 是否经 WS 通道通信(缺省 false = HTTP);支持 "ws": true 或 "ws": "true"
|
||||
}
|
||||
|
||||
// stackConfig 对应 setting.json TCPing / SpeedTest
|
||||
// wsFlag 兼容 JSON 布尔与字符串两种写法
|
||||
type wsFlag bool
|
||||
|
||||
func (w *wsFlag) UnmarshalJSON(b []byte) error {
|
||||
s := strings.TrimSpace(string(b))
|
||||
s = strings.Trim(s, `"`)
|
||||
switch s {
|
||||
case "true", "1", "yes":
|
||||
*w = true
|
||||
case "false", "0", "", "no":
|
||||
*w = false
|
||||
default:
|
||||
return fmt.Errorf("invalid ws flag %q", s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UseWS 判断该节点是否走 WS 通道
|
||||
func (a *apiInfo) UseWS() bool { return a != nil && bool(a.WS) }
|
||||
|
||||
// stackConfig 对应节点池中的 { DualStack, IPv4, IPv6 } 三栈
|
||||
type stackConfig struct {
|
||||
DualStack []apiInfo `json:"DualStack"`
|
||||
IPv4 []apiInfo `json:"IPv4"`
|
||||
IPv6 []apiInfo `json:"IPv6"`
|
||||
}
|
||||
|
||||
// middlewareConfig 仅用于解析 setting.json(键名与前端 config/index.ts 保持一致)
|
||||
// middlewareConfig 仅用于解析 setting.json(JSON 键名统一用连接线,如 http-timeout-seconds)
|
||||
// 节点池结构:APIBaseURL 含 {IPv6,IPv4,DualStack} 三栈;IPLocationAPI 为纯数组(无栈区分)。
|
||||
// 原 apiBaseUrls/whois·ssl·detail、NSLookup/dns·dnssec、TCPing、SpeedTest 统一并入 APIBaseURL,
|
||||
// 原 IPLocationAPI/location·asn 并入 IPLocationAPI。
|
||||
type middlewareConfig struct {
|
||||
Port stringOrNumber `json:"port"`
|
||||
HTTPTimeoutSeconds int `json:"httpTimeoutSeconds"`
|
||||
HTTPTimeoutSeconds int `json:"http-timeout-seconds"`
|
||||
RateLimit *int `json:"rate-limit"` // 单 IP 每分钟限流次数;缺省=默认120,0=不限流
|
||||
WSPort stringOrNumber `json:"ws-port"` // WS 端口:兼容 "8092" 与 8092;缺省=8092,"0"/0=关闭 WS 通道
|
||||
RemoteConfigURL string `json:"remote-config-url"`
|
||||
RemoteIgnoreConfig []string `json:"remote-ignore-config"` // 不被远端覆盖的配置项列表
|
||||
Cors string `json:"cors"`
|
||||
APIBaseURLs []apiInfo `json:"apiBaseUrls"`
|
||||
IPLocationAPIs []apiInfo `json:"IPLocationAPIs"`
|
||||
TCPing stackConfig `json:"TCPing"`
|
||||
SpeedTest stackConfig `json:"SpeedTest"`
|
||||
NSLookup []apiInfo `json:"NSLookup"`
|
||||
APIKeys map[string]string `json:"apiKeys"`
|
||||
TrustedProxies string `json:"trusted-proxies"` // 可信代理 IP/CIDR(逗号分隔),c.IP() 仅信任这些来源的 X-Forwarded-For
|
||||
GhProxy string `json:"gh-proxy"` // GitHub 代理前缀(OTA 下载加速),可空
|
||||
Ota string `json:"ota"` // OTA 自更新开关,"true" 启用
|
||||
APIBaseURL stackConfig `json:"api-base-url"` // whois/ssl/detail/dns/dnssec/tcping/speed 上游节点池
|
||||
IPLocationAPI []apiInfo `json:"ip-location-api"` // location/asn 上游节点池(纯数组,无栈)
|
||||
APIKeys map[string]string `json:"api-keys"` // HTTP 转发鉴权:backendID → token(注入 Authorization: Bearer)
|
||||
WSKeys map[string]string `json:"ws-keys"` // WS 注册校验:nodeID → key(节点 register 的 key 须匹配)
|
||||
}
|
||||
|
||||
// stringOrNumber 兼容 JSON 中的字符串与数字(如 "8080" 或 8080)
|
||||
@@ -50,28 +84,54 @@ type stringOrNumber string
|
||||
|
||||
// 全局配置变量,由 readConfig 从 setting.json 填充
|
||||
var (
|
||||
VERSION string // 版本号(构建时由 ldflags 注入)
|
||||
COMMIT string // 提交哈希(构建时由 ldflags 注入)
|
||||
BUILD_TIME string // 构建时间(构建时由 ldflags 注入)
|
||||
PORT string // 监听端口
|
||||
HTTP_TIMEOUT int // 上游请求超时(秒)
|
||||
CORS string // CORS 配置原始字符串(逗号分隔的允许域名,可空)
|
||||
ACCEPT_DOMAINS []string // 允许跨域的域名列表(由 CORS 拆分而来,空则允许所有)
|
||||
API_BASE_URLS []apiInfo // whois/ssl/detail 上游列表
|
||||
IP_LOCATION_APIS []apiInfo // location/asn 上游列表
|
||||
TCPING stackConfig // tcping 上游列表(DualStack/IPv4/IPv6)
|
||||
SPEED_TEST stackConfig // speed 上游列表(DualStack/IPv4/IPv6)
|
||||
NS_LOOKUP []apiInfo // dns/dnssec 上游列表
|
||||
API_KEYS map[string]string // backendID → token
|
||||
CONFIG_SOURCE string // 配置文件路径
|
||||
VERSION string // 版本号(构建时由 ldflags 注入)
|
||||
COMMIT string // 提交哈希(构建时由 ldflags 注入)
|
||||
BUILD_TIME string // 构建时间(构建时由 ldflags 注入)
|
||||
PORT string // 监听端口
|
||||
HTTP_TIMEOUT int // 上游请求超时(秒)
|
||||
CORS string // CORS 配置原始字符串(逗号分隔的允许域名,可空)
|
||||
ACCEPT_DOMAINS []string // 允许跨域的域名列表(由 CORS 拆分而来,空则允许所有)
|
||||
API_BASE_URLS stackConfig // whois/ssl/detail/dns/dnssec/tcping/speed 上游节点池(DualStack/IPv4/IPv6)
|
||||
IP_LOCATION_APIS []apiInfo // location/asn 上游节点池(纯数组,无栈)
|
||||
API_KEYS map[string]string // backendID → HTTP 转发 token(注入 Authorization: Bearer)
|
||||
WS_KEYS map[string]string // backendID → WS 注册校验 key
|
||||
CONFIG_SOURCE string // 配置文件路径
|
||||
RATE_LIMIT int // 单 IP 每分钟限流次数(0 表示不限流),默认 120
|
||||
TRUSTED_PROXIES string // 可信代理 IP/CIDR(逗号分隔),供 c.IP() 判定,空 = 不信任 XFF
|
||||
GH_PROXY string // GitHub 代理前缀(gh-proxy / GH_PROXY),OTA 下载用,可空
|
||||
OTA string // OTA 自更新开关(ota / OTA),"true" 时启用
|
||||
WS_PORT int // WS 服务端口(0 = 关闭),缺省 8092,由 readConfig 统一解析
|
||||
REMOTE_IGNORE_CONFIG []string // 不被远端覆盖的配置项列表(remote-ignore-config / REMOTE_IGNORE_CONFIG)
|
||||
)
|
||||
|
||||
// OTA 优雅交接支持(Windows 路径):见 ota.go
|
||||
var (
|
||||
fiberApp *fiber.App // 显式持有的 Fiber 应用(gracefulShutdown 需要)
|
||||
otaHandoverWait chan struct{} // 非 nil 表示 Windows OTA 优雅重启模式:Listen 关闭后 main 停在此等待老进程退出
|
||||
otaShutdownStarted atomic.Bool // gracefulShutdown 已启动(Listen 退出时区分绑定失败与优雅关闭)
|
||||
)
|
||||
|
||||
// HTTP_CLIENT 用于转发上游请求(等价于 TS 中的 $fetch),超时由 HTTP_TIMEOUT 决定
|
||||
// HTTP_CLIENT forwards requests to upstream backends (equivalent to $fetch in the TS version).
|
||||
var HTTP_CLIENT = &http.Client{}
|
||||
|
||||
// wsSrv 为全局 WS 服务端(WS_PORT>0 时启动;nil = 未启用)。见 ws.go
|
||||
var wsSrv *wsServer
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
// splitAndTrim 按逗号切分并去掉每段首尾空白与空段(对齐根 main.go 的同名函数)
|
||||
func splitAndTrim(s, sep string) []string {
|
||||
parts := strings.Split(s, sep)
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// envJSON 从环境变量读取 JSON 字符串并反序列化到 target(用于数组/对象等映射表类配置项)。
|
||||
// 环境变量为空时不做任何事;解析失败返回错误,避免静默使用空配置。
|
||||
func envJSON(key string, target any) error {
|
||||
@@ -130,6 +190,30 @@ func findURL(list []apiInfo, id string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// findNode 按 id 在节点列表中查找(返回指针;找不到返回 nil)
|
||||
func findNode(list []apiInfo, id string) *apiInfo {
|
||||
for i := range list {
|
||||
if list[i].ID == id {
|
||||
return &list[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// flattenStack 将节点池三栈展开为一个节点列表(DualStack + IPv4 + IPv6)
|
||||
func flattenStack(s stackConfig) []apiInfo {
|
||||
out := make([]apiInfo, 0, len(s.DualStack)+len(s.IPv4)+len(s.IPv6))
|
||||
out = append(out, s.DualStack...)
|
||||
out = append(out, s.IPv4...)
|
||||
out = append(out, s.IPv6...)
|
||||
return out
|
||||
}
|
||||
|
||||
// stackNonEmpty 判断节点池三栈是否至少有一项
|
||||
func stackNonEmpty(s stackConfig) bool {
|
||||
return len(s.DualStack) > 0 || len(s.IPv4) > 0 || len(s.IPv6) > 0
|
||||
}
|
||||
|
||||
func keysOf(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
@@ -180,12 +264,12 @@ func forwardUpstream(c fiber.Ctx, apiBaseUrl string, target string, headers map[
|
||||
}
|
||||
|
||||
// lookupAPIKey 查找 backendID 对应的 token。
|
||||
// 优先级: setting.json apiKeys > 环境变量 APIKEYS (JSON 字符串)
|
||||
// 优先级: setting.json api-keys > 环境变量 API_KEYS (JSON 字符串)
|
||||
func lookupAPIKey(backendID string) string {
|
||||
var apiKeys map[string]string
|
||||
if len(API_KEYS) > 0 {
|
||||
apiKeys = API_KEYS
|
||||
} else if raw := os.Getenv("APIKEYS"); raw != "" {
|
||||
} else if raw := os.Getenv("API_KEYS"); raw != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &apiKeys); err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -198,6 +282,26 @@ func lookupAPIKey(backendID string) string {
|
||||
return apiKey
|
||||
}
|
||||
|
||||
// lookupWSKey 查找 nodeID 对应的 WS 注册校验 key。
|
||||
// 优先级: setting.json ws-keys > 环境变量 WS_KEYS (JSON 字符串)
|
||||
// 与 lookupAPIKey 相互独立:api-keys 用于 HTTP 转发鉴权,ws-keys 用于 WS 注册校验。
|
||||
func lookupWSKey(nodeID string) string {
|
||||
var wsKeys map[string]string
|
||||
if len(WS_KEYS) > 0 {
|
||||
wsKeys = WS_KEYS
|
||||
} else if raw := os.Getenv("WS_KEYS"); raw != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &wsKeys); err != nil {
|
||||
return ""
|
||||
}
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
|
||||
wsKey := wsKeys[nodeID]
|
||||
|
||||
return wsKey
|
||||
}
|
||||
|
||||
// ==================== 业务接口 ====================
|
||||
|
||||
// middlewareHandler 对应前端 server/routes/middleware/[...slug].get.ts
|
||||
@@ -244,34 +348,44 @@ func middlewareHandler(c fiber.Ctx) error {
|
||||
|
||||
query := c.Queries()
|
||||
|
||||
// 从 API_KEYS 查找 token,未配置时回退到环境变量 APIKEYS (JSON 字符串)
|
||||
// 从 API_KEYS 查找 token,未配置时回退到环境变量 API_KEYS (JSON 字符串)
|
||||
apiKey := lookupAPIKey(backendID)
|
||||
|
||||
// 构造转发给上游的请求头:所有接口统一透传客户端请求的 Origin,没有 Origin 头则不传
|
||||
// 构造转发给上游的请求头:不透传客户端 Origin,避免上游 CORS 误判
|
||||
authHeaders := map[string]string{}
|
||||
if origin := c.Get("Origin"); origin != "" {
|
||||
authHeaders["Origin"] = origin
|
||||
}
|
||||
if apiKey != "" {
|
||||
authHeaders["Authorization"] = "Bearer " + apiKey
|
||||
}
|
||||
|
||||
switch apiType {
|
||||
case "whois", "dns", "location", "ssl", "asn", "dnssec", "detail":
|
||||
// 节点池结构:location/asn 走 IPLocationAPI(纯数组,无栈),其余走 APIBaseURL(三栈平铺)
|
||||
var apiBaseUrls []apiInfo
|
||||
switch apiType {
|
||||
case "whois", "ssl", "detail":
|
||||
apiBaseUrls = API_BASE_URLS
|
||||
case "dns", "dnssec":
|
||||
apiBaseUrls = NS_LOOKUP
|
||||
case "location", "asn":
|
||||
apiBaseUrls = IP_LOCATION_APIS
|
||||
default:
|
||||
apiBaseUrls = flattenStack(API_BASE_URLS)
|
||||
}
|
||||
|
||||
apiBaseUrl := findURL(apiBaseUrls, backendID)
|
||||
if apiBaseUrl == "" {
|
||||
node := findNode(apiBaseUrls, backendID)
|
||||
if node == nil || (!node.UseWS() && node.URL == "") {
|
||||
return badRequest(c, "Invalid backend ID")
|
||||
}
|
||||
// ws:true 节点:拨测请求经 WS 通道转发(数据上传走 WS),失败返回 502
|
||||
if node.UseWS() {
|
||||
if wsSrv == nil {
|
||||
// 不再静默落回空 URL 的 HTTP 转发(必然 502 且掩盖真实配置错误)
|
||||
return c.Status(fiber.StatusBadGateway).SendString("Node " + backendID + " is WS-only but WS channel is disabled (ws-port=0 or bind failed)")
|
||||
}
|
||||
status, body, err := wsSrv.RequestProbe(backendID, apiType, raw, nil, wsProbeTimeout())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadGateway).SendString("WS probe failed: " + err.Error())
|
||||
}
|
||||
// 节点上报的 body 为 JSON,显式指定类型(fiber 默认 text/plain)
|
||||
return c.Status(status).Type("json").Send(body)
|
||||
}
|
||||
apiBaseUrl := node.URL
|
||||
if !strings.HasSuffix(apiBaseUrl, "/") {
|
||||
apiBaseUrl += "/"
|
||||
}
|
||||
@@ -280,37 +394,40 @@ func middlewareHandler(c fiber.Ctx) error {
|
||||
return forwardUpstream(c, apiBaseUrl, apiBaseUrl+"v1/"+apiType+"/"+raw, authHeaders, nil)
|
||||
|
||||
case "tcping", "udping", "speed":
|
||||
var apiTypeConfig stackConfig
|
||||
switch apiType {
|
||||
case "tcping":
|
||||
apiTypeConfig = TCPING
|
||||
case "speed":
|
||||
apiTypeConfig = SPEED_TEST
|
||||
default:
|
||||
return badRequest(c, "Invalid API type")
|
||||
}
|
||||
|
||||
apiBaseUrls := make([]apiInfo, 0, len(apiTypeConfig.DualStack)+len(apiTypeConfig.IPv4)+len(apiTypeConfig.IPv6))
|
||||
apiBaseUrls = append(apiBaseUrls, apiTypeConfig.DualStack...)
|
||||
apiBaseUrls = append(apiBaseUrls, apiTypeConfig.IPv4...)
|
||||
apiBaseUrls = append(apiBaseUrls, apiTypeConfig.IPv6...)
|
||||
|
||||
apiBaseUrl := findURL(apiBaseUrls, backendID)
|
||||
if apiBaseUrl == "" {
|
||||
return badRequest(c, "Invalid backend ID")
|
||||
}
|
||||
if !strings.HasSuffix(apiBaseUrl, "/") {
|
||||
apiBaseUrl += "/"
|
||||
}
|
||||
// tcping/udping/speed 统一走 APIBaseURL 节点池
|
||||
apiBaseUrls := flattenStack(API_BASE_URLS)
|
||||
|
||||
// 转发 query 参数(过滤空值,等价于 TS 中 URLSearchParams 过滤 undefined)
|
||||
queryString := url.Values{}
|
||||
queryMap := make(map[string]string)
|
||||
for k, v := range query {
|
||||
if v != "" {
|
||||
queryString.Set(k, v)
|
||||
queryMap[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
node := findNode(apiBaseUrls, backendID)
|
||||
if node == nil || (!node.UseWS() && node.URL == "") {
|
||||
return badRequest(c, "Invalid backend ID")
|
||||
}
|
||||
// ws:true 节点:拨测请求经 WS 通道转发
|
||||
if node.UseWS() {
|
||||
if wsSrv == nil {
|
||||
return c.Status(fiber.StatusBadGateway).SendString("Node " + backendID + " is WS-only but WS channel is disabled (ws-port=0 or bind failed)")
|
||||
}
|
||||
status, body, err := wsSrv.RequestProbe(backendID, apiType, raw, queryMap, wsProbeTimeout())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadGateway).SendString("WS probe failed: " + err.Error())
|
||||
}
|
||||
// 节点上报的 body 为 JSON,显式指定类型(fiber 默认 text/plain)
|
||||
return c.Status(status).Type("json").Send(body)
|
||||
}
|
||||
apiBaseUrl := node.URL
|
||||
if !strings.HasSuffix(apiBaseUrl, "/") {
|
||||
apiBaseUrl += "/"
|
||||
}
|
||||
|
||||
// 上游错误原样透传(状态码 + body),网络错误返回 502
|
||||
return forwardUpstream(c, apiBaseUrl, apiBaseUrl+"v1/"+apiType+"/"+raw, authHeaders, queryString)
|
||||
|
||||
@@ -321,37 +438,129 @@ func middlewareHandler(c fiber.Ctx) error {
|
||||
|
||||
// ==================== readConfig ====================
|
||||
|
||||
// viperValue 从 viper 读取配置值并反序列化到 target(数组/对象经 JSON 中转,支持自定义类型如 wsFlag)
|
||||
func viperValue(key string, target any) error {
|
||||
v := viper.Get(key)
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(b, target)
|
||||
}
|
||||
|
||||
// fetchRemoteConfig 从远端 URL 拉取配置内容(middleware setting.json 格式)
|
||||
func fetchRemoteConfig(url string) ([]byte, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("remote config returned status %d", resp.StatusCode)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// applyRemoteConfig 应用远端配置,优先级:远端 > 环境变量 > setting.json。
|
||||
// 远端地址来源:环境变量 REMOTE_CONFIG_URL 优先,其次 setting.json 的 remote-config-url。
|
||||
// 远端字段为空时不覆盖本地值;拉取或解析失败时打印警告并回退本地配置。
|
||||
func applyRemoteConfig(mw *middlewareConfig) error {
|
||||
url := os.Getenv("REMOTE_CONFIG_URL")
|
||||
if url == "" {
|
||||
url = mw.RemoteConfigURL
|
||||
}
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
body, err := fetchRemoteConfig(url)
|
||||
if err != nil {
|
||||
log.Printf("[middleware] WARN failed to fetch remote config, fallback to local: %v", err)
|
||||
return nil
|
||||
}
|
||||
var remote middlewareConfig
|
||||
if err := json.Unmarshal(body, &remote); err != nil {
|
||||
log.Printf("[middleware] WARN invalid remote config JSON, fallback to local: %v", err)
|
||||
return nil
|
||||
}
|
||||
// ignore 列表:数组中的配置项不被远端覆盖(逐键判断跳过)
|
||||
ignored := func(key string) bool {
|
||||
for _, k := range mw.RemoteIgnoreConfig {
|
||||
if k == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !ignored("port") && string(remote.Port) != "" {
|
||||
mw.Port = remote.Port
|
||||
}
|
||||
if !ignored("http-timeout-seconds") && remote.HTTPTimeoutSeconds != 0 {
|
||||
mw.HTTPTimeoutSeconds = remote.HTTPTimeoutSeconds
|
||||
}
|
||||
if !ignored("rate-limit") && remote.RateLimit != nil {
|
||||
mw.RateLimit = remote.RateLimit
|
||||
}
|
||||
if !ignored("ws-port") && remote.WSPort != "" {
|
||||
mw.WSPort = remote.WSPort
|
||||
}
|
||||
if !ignored("remote-config-url") && remote.RemoteConfigURL != "" {
|
||||
mw.RemoteConfigURL = remote.RemoteConfigURL
|
||||
}
|
||||
if !ignored("cors") && remote.Cors != "" {
|
||||
mw.Cors = remote.Cors
|
||||
}
|
||||
if !ignored("trusted-proxies") && remote.TrustedProxies != "" {
|
||||
mw.TrustedProxies = remote.TrustedProxies
|
||||
}
|
||||
if !ignored("gh-proxy") && remote.GhProxy != "" {
|
||||
mw.GhProxy = remote.GhProxy
|
||||
}
|
||||
if !ignored("ota") && remote.Ota != "" {
|
||||
mw.Ota = remote.Ota
|
||||
}
|
||||
// 节点池结构:api-base-url 与 ip-location-api(远端非空栈时覆盖本地)
|
||||
if !ignored("api-base-url") && stackNonEmpty(remote.APIBaseURL) {
|
||||
mw.APIBaseURL = remote.APIBaseURL
|
||||
}
|
||||
if !ignored("ip-location-api") && len(remote.IPLocationAPI) > 0 {
|
||||
mw.IPLocationAPI = remote.IPLocationAPI
|
||||
}
|
||||
// api-keys / ws-keys 强制忽略:密钥凭据不随远端配置覆盖(与后端 access-token 一致),只从本地 setting.json / env 读取
|
||||
log.Printf("[middleware] remote config applied from %s", url)
|
||||
return nil
|
||||
}
|
||||
|
||||
// readConfig 从 setting.json 读取配置并写入全局配置变量。
|
||||
// 查找顺序: 环境变量 SETTING_FILE > ./setting.json > ../setting.json
|
||||
func readConfig() error {
|
||||
// 统一用 viper 读取 setting.json(SETTING_FILE 指定路径,否则查找 ./ 与 ../)
|
||||
path := os.Getenv("SETTING_FILE")
|
||||
if path == "" {
|
||||
for _, candidate := range []string{"setting.json", "../setting.json"} {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
path = candidate
|
||||
break
|
||||
}
|
||||
viper.SetConfigType("json")
|
||||
if path != "" {
|
||||
viper.SetConfigFile(path)
|
||||
} else {
|
||||
viper.SetConfigName("setting")
|
||||
viper.AddConfigPath(".")
|
||||
viper.AddConfigPath("../")
|
||||
}
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
if path == "" {
|
||||
return fmt.Errorf("setting.json not found (set SETTING_FILE to specify its path)")
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
return fmt.Errorf("setting.json not found (set SETTING_FILE to specify its path)")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var mw middlewareConfig
|
||||
if err := json.Unmarshal(data, &mw); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
|
||||
// 环境变量覆盖配置(优先级: 环境变量 > setting.json > 默认值):
|
||||
// 标量直接读;映射表(数组/对象)从环境变量读 JSON 字符串解析。
|
||||
// 部署时无需改动配置文件,用环境变量即可覆盖任意节点/密钥/端口等。
|
||||
// 统一读取模式:先 Getenv,再 viper.GetString(env 优先 > setting.json > 默认值)
|
||||
if v := os.Getenv("PORT"); v != "" {
|
||||
mw.Port = stringOrNumber(v)
|
||||
} else {
|
||||
mw.Port = stringOrNumber(viper.GetString("port"))
|
||||
}
|
||||
if v := os.Getenv("HTTP_TIMEOUT"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
@@ -359,52 +568,144 @@ func readConfig() error {
|
||||
return fmt.Errorf("parse env HTTP_TIMEOUT: %w", err)
|
||||
}
|
||||
mw.HTTPTimeoutSeconds = n
|
||||
} else {
|
||||
mw.HTTPTimeoutSeconds = viper.GetInt("http-timeout-seconds")
|
||||
}
|
||||
if v := os.Getenv("CORS"); v != "" {
|
||||
mw.Cors = v
|
||||
} else {
|
||||
mw.Cors = viper.GetString("cors")
|
||||
}
|
||||
if err := envJSON("API_BASE_URLS", &mw.APIBaseURLs); err != nil {
|
||||
// trusted-proxies:可信代理 IP/CIDR(逗号分隔),env > setting.json
|
||||
if v := os.Getenv("TRUSTED_PROXIES"); v != "" {
|
||||
mw.TrustedProxies = v
|
||||
} else {
|
||||
mw.TrustedProxies = viper.GetString("trusted-proxies")
|
||||
}
|
||||
// gh-proxy:GitHub 代理前缀(OTA 下载用),env > setting.json
|
||||
if v := os.Getenv("GH_PROXY"); v != "" {
|
||||
mw.GhProxy = v
|
||||
} else {
|
||||
mw.GhProxy = viper.GetString("gh-proxy")
|
||||
}
|
||||
// ota:OTA 自更新开关("true" 启用),env > setting.json
|
||||
if v := os.Getenv("OTA"); v != "" {
|
||||
mw.Ota = v
|
||||
} else {
|
||||
mw.Ota = viper.GetString("ota")
|
||||
}
|
||||
if v := os.Getenv("REMOTE_CONFIG_URL"); v != "" {
|
||||
mw.RemoteConfigURL = v
|
||||
} else {
|
||||
mw.RemoteConfigURL = viper.GetString("remote-config-url")
|
||||
}
|
||||
// rateLimit(*int:env > setting(IsSet) > 默认120;0 = 不限流)
|
||||
if v := os.Getenv("RATE_LIMIT"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse env RATE_LIMIT: %w", err)
|
||||
}
|
||||
mw.RateLimit = &n
|
||||
} else if viper.IsSet("rate-limit") {
|
||||
n := viper.GetInt("rate-limit")
|
||||
mw.RateLimit = &n
|
||||
}
|
||||
// ws-port(stringOrNumber:env > setting(IsSet) > 默认8092;"0"/0 = 关闭 WS 通道)
|
||||
if v := os.Getenv("WS_PORT"); v != "" {
|
||||
mw.WSPort = stringOrNumber(v)
|
||||
} else if viper.IsSet("ws-port") {
|
||||
mw.WSPort = stringOrNumber(viper.GetString("ws-port"))
|
||||
}
|
||||
// remote-ignore-config:不被远端覆盖的配置项列表(env JSON 数组 > setting.json 数组)
|
||||
if raw := os.Getenv("REMOTE_IGNORE_CONFIG"); raw != "" {
|
||||
var list []string
|
||||
if err := json.Unmarshal([]byte(raw), &list); err != nil {
|
||||
return fmt.Errorf("parse env REMOTE_IGNORE_CONFIG: %w", err)
|
||||
}
|
||||
mw.RemoteIgnoreConfig = list
|
||||
} else {
|
||||
mw.RemoteIgnoreConfig = viper.GetStringSlice("remote-ignore-config")
|
||||
}
|
||||
|
||||
// 节点池结构:api-base-url 与 ip-location-api(env JSON > setting.json)
|
||||
if err := envJSON("API_BASE_URLS", &mw.APIBaseURL); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := envJSON("IP_LOCATION_APIS", &mw.IPLocationAPIs); err != nil {
|
||||
if !stackNonEmpty(mw.APIBaseURL) && viper.Get("api-base-url") != nil {
|
||||
if err := viperValue("api-base-url", &mw.APIBaseURL); err != nil {
|
||||
return fmt.Errorf("parse api-base-url: %w", err)
|
||||
}
|
||||
}
|
||||
if err := envJSON("IP_LOCATION_APIS", &mw.IPLocationAPI); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := envJSON("TCPING", &mw.TCPing); err != nil {
|
||||
if len(mw.IPLocationAPI) == 0 && viper.Get("ip-location-api") != nil {
|
||||
if err := viperValue("ip-location-api", &mw.IPLocationAPI); err != nil {
|
||||
return fmt.Errorf("parse ip-location-api: %w", err)
|
||||
}
|
||||
}
|
||||
if err := envJSON("API_KEYS", &mw.APIKeys); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := envJSON("SPEED_TEST", &mw.SpeedTest); err != nil {
|
||||
if len(mw.APIKeys) == 0 {
|
||||
if err := viperValue("api-keys", &mw.APIKeys); err != nil {
|
||||
return fmt.Errorf("parse api-keys: %w", err)
|
||||
}
|
||||
}
|
||||
// ws-keys:WS 注册校验表(nodeID → key),优先级 env WS_KEYS > setting.json ws-keys,与 api-keys 相互独立
|
||||
if err := envJSON("WS_KEYS", &mw.WSKeys); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := envJSON("NS_LOOKUP", &mw.NSLookup); err != nil {
|
||||
return err
|
||||
if len(mw.WSKeys) == 0 {
|
||||
if err := viperValue("ws-keys", &mw.WSKeys); err != nil {
|
||||
return fmt.Errorf("parse ws-keys: %w", err)
|
||||
}
|
||||
}
|
||||
if err := envJSON("APIKEYS", &mw.APIKeys); err != nil {
|
||||
|
||||
// 远端配置(最高优先级):REMOTE_CONFIG_URL 拉取后覆盖(优先级:远端 > 环境变量 > setting.json)
|
||||
if err := applyRemoteConfig(&mw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 校验配置存在(防止在错误的文件上静默空跑)
|
||||
hasEndpoints := len(mw.APIBaseURLs) > 0 || len(mw.NSLookup) > 0 || len(mw.IPLocationAPIs) > 0 ||
|
||||
len(mw.TCPing.DualStack) > 0 || len(mw.TCPing.IPv4) > 0 || len(mw.TCPing.IPv6) > 0 ||
|
||||
len(mw.SpeedTest.DualStack) > 0 || len(mw.SpeedTest.IPv4) > 0 || len(mw.SpeedTest.IPv6) > 0
|
||||
if !hasEndpoints {
|
||||
return fmt.Errorf("invalid config in %s: missing endpoint lists (expected flat middleware config)", path)
|
||||
if !stackNonEmpty(mw.APIBaseURL) && len(mw.IPLocationAPI) == 0 {
|
||||
return fmt.Errorf("invalid config in %s: missing endpoint pools (expected api-base-url / ip-location-api node pools)", path)
|
||||
}
|
||||
|
||||
// 将配置内容写入全局变量
|
||||
PORT = string(mw.Port)
|
||||
HTTP_TIMEOUT = mw.HTTPTimeoutSeconds
|
||||
CORS = mw.Cors
|
||||
// 逗号分隔的 CORS 域名列表(对齐根 main.go 的 ACCEPT_DOMAINS 用法)
|
||||
if CORS != "" {
|
||||
ACCEPT_DOMAINS = strings.Split(CORS, ",")
|
||||
// rateLimit:缺省默认 120 次/分钟;显式 0 表示不限流
|
||||
if mw.RateLimit == nil {
|
||||
RATE_LIMIT = 120
|
||||
} else {
|
||||
RATE_LIMIT = *mw.RateLimit
|
||||
}
|
||||
API_BASE_URLS = mw.APIBaseURLs
|
||||
IP_LOCATION_APIS = mw.IPLocationAPIs
|
||||
TCPING = mw.TCPing
|
||||
SPEED_TEST = mw.SpeedTest
|
||||
NS_LOOKUP = mw.NSLookup
|
||||
// ws-port:缺省默认 8092;显式 "0"/0 表示关闭 WS 通道(兼容字符串与数字写法)
|
||||
if mw.WSPort == "" {
|
||||
WS_PORT = 8092
|
||||
} else {
|
||||
n, err := strconv.Atoi(string(mw.WSPort))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse ws-port %q: %w", mw.WSPort, err)
|
||||
}
|
||||
WS_PORT = n
|
||||
}
|
||||
REMOTE_IGNORE_CONFIG = mw.RemoteIgnoreConfig
|
||||
CORS = mw.Cors
|
||||
// 逗号分隔的 CORS 域名列表(对齐根 main.go 的 ACCEPT_DOMAINS 用法);
|
||||
// 带空格的配置 "a.com, b.com" 直接 Split 会产生带前导空格的 origin,永远匹配不上,须 trim
|
||||
if CORS != "" {
|
||||
ACCEPT_DOMAINS = splitAndTrim(CORS, ",")
|
||||
}
|
||||
TRUSTED_PROXIES = mw.TrustedProxies
|
||||
GH_PROXY = mw.GhProxy
|
||||
OTA = mw.Ota
|
||||
API_BASE_URLS = mw.APIBaseURL
|
||||
IP_LOCATION_APIS = mw.IPLocationAPI
|
||||
API_KEYS = mw.APIKeys
|
||||
CONFIG_SOURCE = path
|
||||
WS_KEYS = mw.WSKeys
|
||||
CONFIG_SOURCE = viper.ConfigFileUsed()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -427,13 +728,52 @@ func main() {
|
||||
}
|
||||
log.Printf("[middleware] config loaded from %s", CONFIG_SOURCE)
|
||||
|
||||
// OTA 自更新:定期检查 GitHub Release 并替换二进制(配置 ota / OTA 启用)
|
||||
initOTA(GH_PROXY)
|
||||
|
||||
timeout := HTTP_TIMEOUT
|
||||
if timeout <= 0 {
|
||||
timeout = 30
|
||||
}
|
||||
HTTP_CLIENT.Timeout = time.Duration(timeout) * time.Second
|
||||
|
||||
app := fiber.New()
|
||||
// WS 服务端(端口由配置 wsPort / 环境变量 WS_PORT 决定,缺省 8092,0 = 关闭;统一走 readConfig)。
|
||||
// 后端节点作为 WS 客户端连入;节点配置 "ws": true 时拨测请求改走 WS 通道。现有 HTTP 路由不受影响。
|
||||
if WS_PORT > 0 {
|
||||
wsSrv = newWSServer()
|
||||
go wsSrv.Start(fmt.Sprintf(":%d", WS_PORT))
|
||||
go wsSrv.maintenanceLoop()
|
||||
log.Printf("[ws] ws channel enabled on port %d (node flag \"ws\": true to use)", WS_PORT)
|
||||
} else {
|
||||
log.Printf("[ws] ws channel disabled (wsPort=0)")
|
||||
}
|
||||
|
||||
// TRUSTED_PROXIES / trusted-proxies:逗号分隔的可信代理 IP/CIDR。配置后 c.IP()(限流按它分桶)
|
||||
// 只信任这些来源转发的 X-Forwarded-For,反代/CDN 后限流才按真实客户端 IP 计数;
|
||||
// 未配置保持默认(不信任 XFF 头)
|
||||
fiberConf := fiber.Config{}
|
||||
if strings.TrimSpace(TRUSTED_PROXIES) != "" {
|
||||
var proxies []string
|
||||
for _, p := range strings.Split(TRUSTED_PROXIES, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
proxies = append(proxies, p)
|
||||
}
|
||||
}
|
||||
fiberConf.TrustProxy = true
|
||||
fiberConf.TrustProxyConfig = fiber.TrustProxyConfig{Proxies: proxies}
|
||||
fiberConf.ProxyHeader = "X-Forwarded-For"
|
||||
log.Printf("[middleware] trusted proxies: %v", proxies)
|
||||
}
|
||||
fiberApp = fiber.New(fiberConf)
|
||||
app := fiberApp
|
||||
// 单 IP 限流(fiber 内置中间件,默认按客户端 IP 计数):次数由配置 rateLimit / 环境变量 RATE_LIMIT 决定(默认 120 次/分钟),
|
||||
// 0 表示不限流。必须放在所有路由注册之前,fiber 中间件只对注册在其后的路由生效。
|
||||
if RATE_LIMIT > 0 {
|
||||
app.Use(limiter.New(limiter.Config{
|
||||
Max: RATE_LIMIT,
|
||||
Expiration: time.Minute,
|
||||
}))
|
||||
}
|
||||
|
||||
app.Get("/", func(c fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
@@ -455,22 +795,39 @@ func main() {
|
||||
corsConfig.AllowOrigins = []string{"*"}
|
||||
}
|
||||
app.Use(cors.New(corsConfig))
|
||||
|
||||
app.Get("/v1/*", middlewareHandler)
|
||||
app.Get("/middleware/*", middlewareHandler)
|
||||
|
||||
// 监听地址,优先级: 环境变量 PORT > setting.json port > 默认 8080
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = PORT
|
||||
// 监听地址:readConfig 已按 env PORT > setting.json port 填好全局 PORT,
|
||||
// 这里只在为空时补默认 8080——不要再用 Getenv 覆盖,否则 setting.json 的 port 失效。
|
||||
// waitChildReady 的健康检查地址要用同一个端口
|
||||
if PORT == "" {
|
||||
PORT = "8080"
|
||||
}
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
listenAddr := port
|
||||
listenAddr := PORT
|
||||
if !strings.Contains(listenAddr, ":") {
|
||||
listenAddr = ":" + listenAddr
|
||||
}
|
||||
if err := app.Listen(listenAddr); err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
// Windows OTA 优雅交接:Listen 被 gracefulShutdown 关闭后,main 停在停车位,
|
||||
// 由 restartSelf 在新进程健康检查通过后 os.Exit 结束整个老进程
|
||||
if otaEnabled() && runtime.GOOS == "windows" {
|
||||
otaHandoverWait = make(chan struct{})
|
||||
}
|
||||
|
||||
listenErr := make(chan error, 1)
|
||||
go func() { listenErr <- app.Listen(listenAddr) }()
|
||||
err := <-listenErr
|
||||
if err != nil {
|
||||
if otaShutdownStarted.Load() {
|
||||
// 优雅关闭过程中的退出属预期,主流程已在停车位等待交接
|
||||
log.Printf("[ota] listen exited during graceful shutdown: %v", err)
|
||||
} else {
|
||||
log.Fatalf("listen failed: %v", err)
|
||||
}
|
||||
}
|
||||
if otaHandoverWait != nil {
|
||||
<-otaHandoverWait
|
||||
}
|
||||
}
|
||||