Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c655bf58e | ||
|
|
818517ba1f | ||
|
|
81936ea7f7 | ||
|
|
a2be64c1cc | ||
|
|
2a4e417b28 | ||
|
|
65001d1eff | ||
|
|
4d2273c885 | ||
|
|
a837f38c8f | ||
|
|
5f202a3a2b | ||
|
|
df9f7857ed | ||
|
|
61788fa7ae | ||
|
|
c9d1f21fa7 | ||
|
|
fbf5c9e3b0 | ||
|
|
af0b5e1ea0 | ||
|
|
4d608a6697 | ||
|
|
afd9df87c1 | ||
|
|
4987cea8e0 | ||
|
|
0dce0fef14 | ||
|
|
fac8be622c | ||
|
|
8ae615d0e3 | ||
|
|
f8482414e4 | ||
|
|
05093190b9 | ||
|
|
7debff02fe | ||
|
|
25a46b8e13 | ||
|
|
0bbaf443e1 | ||
|
|
f82731eb77 | ||
|
|
8f57649832 | ||
|
|
b72136a461 | ||
|
|
2a65abb8c3 | ||
|
|
4c0f29918c | ||
|
|
5de97082aa | ||
|
|
8b094bcb36 | ||
|
|
62b80d06dc | ||
|
|
81a61c0836 | ||
|
|
cee22230ea | ||
|
|
2947ad2125 | ||
|
|
1235ccffbd | ||
|
|
d688e9bb8e | ||
|
|
805b65bc07 | ||
|
|
682983e411 | ||
|
|
4abe6e4d1a | ||
|
|
19fe134762 | ||
|
|
5445b4df64 | ||
|
|
4f6cf7fac7 | ||
|
|
243060a32a | ||
|
|
924b9bcc36 | ||
|
|
611caa30f6 | ||
|
|
a8f7f2bd17 | ||
|
|
ff5caddab3 | ||
|
|
a00e42a896 | ||
|
|
165caa9127 | ||
|
|
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 | ||
|
|
b36314174c | ||
|
|
a3b7d9375f | ||
|
|
21989b06ce | ||
|
|
c4d796868a | ||
|
|
5e4d7a6a18 | ||
|
|
e5357b8bab | ||
|
|
fd17ff4b5b | ||
|
|
4882c09f73 | ||
|
|
9c5872910f | ||
|
|
e2d8968c6e | ||
|
|
7daca97785 | ||
|
|
554890a502 | ||
|
|
9240558800 | ||
|
|
07dacb2ac7 | ||
|
|
918cf272a5 | ||
|
|
718e4d9136 | ||
|
|
a2e39dd3a8 | ||
|
|
8edb679685 | ||
|
|
27b53a2ac9 | ||
|
|
e64738da64 | ||
|
|
cf76d50fa8 | ||
|
|
e2359a6320 | ||
|
|
2c67b55001 | ||
|
|
723b025586 | ||
|
|
640c8b6a38 | ||
|
|
06f6808b7a | ||
|
|
de0ce85ef4 | ||
|
|
cc122f253f | ||
|
|
7be4517086 | ||
|
|
fd657feb22 | ||
|
|
23939960f1 | ||
|
|
0246f8db44 | ||
|
|
b4eadc1e36 |
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,60 +13,148 @@ 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 版本
|
||||
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: |
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -o lemonipw-linux-386
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o lemonipw-linux-amd64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o lemonipw-linux-arm64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o lemonipw-linux-armv7
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -o lemonipw-linux-armv6
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=loong64 go build -o lemonipw-linux-loong64
|
||||
- name: Build for Windows
|
||||
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: |
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=386 go build -o lemonipw-windows-386.exe
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o lemonipw-windows-amd64.exe
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -o lemonipw-windows-arm64.exe
|
||||
- name: Build for macOS
|
||||
run: |
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o lemonipw-darwin-amd64
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o lemonipw-darwin-arm64
|
||||
- name: Build for FreeBSD
|
||||
run: |
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -o lemonipw-freebsd-386
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -o lemonipw-freebsd-amd64
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -o lemonipw-freebsd-arm64
|
||||
# 后端:发布 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
|
||||
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
|
||||
body: |
|
||||
## 配置文件说明
|
||||
|
||||
Release 附带两份配置文件(**.example** 后缀用于标识,需重命名后使用):
|
||||
|
||||
| 发布文件 | 重命名为 | 放置位置 |
|
||||
|----------|----------|----------|
|
||||
| `setting.json.example` | `setting.json` | 主程序(后端测试节点)同目录 |
|
||||
| `middleware.setting.json.example` | `setting.json` | `middleware-go/` 目录 |
|
||||
|
||||
```bash
|
||||
# 后端测试节点
|
||||
cp setting.json.example setting.json
|
||||
|
||||
# 独立中间件
|
||||
cp middleware.setting.json.example middleware-go/setting.json
|
||||
```
|
||||
|
||||
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
|
||||
@@ -27,7 +31,17 @@ jobs:
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
NITRO_PRESET: cloudflare_module
|
||||
run: |
|
||||
cd frontend-ssr
|
||||
pnpm install
|
||||
pnpm run deploy
|
||||
pnpm run deploy --keep-vars
|
||||
|
||||
# 部署成功后把 sitemap 全量提交给 IndexNow(Bing/Yandex/Seznam 等共用端点),
|
||||
# 新页面/新文档以天级收录代替等爬虫回访。
|
||||
# key 是公开所有权凭证(对应 frontend-ssr/public/<key>.txt,IndexNow 靠抓取它验证)。
|
||||
- name: Submit sitemap to IndexNow
|
||||
if: success()
|
||||
run: |
|
||||
cd frontend-ssr
|
||||
node scripts/indexnow-submit.mjs --site https://nipw.cn --key d49c90fb2fb160cdd2cd4b09532bc380
|
||||
@@ -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
|
||||
@@ -13,4 +13,23 @@ short.txt
|
||||
lemon-ipw
|
||||
.mmdb.tmp
|
||||
*.tmp
|
||||
*.log
|
||||
*.log
|
||||
node_modules/
|
||||
.workbuddy/
|
||||
# VitePress 构建产物
|
||||
docs/.vitepress/.temp/
|
||||
docs/.vitepress/cache/
|
||||
docs/.vitepress/dist/
|
||||
# 构建期临时配置文件(vitepress 每次构建生成,用完即弃)
|
||||
docs/.vitepress/*.timestamp-*
|
||||
tmp/
|
||||
setting.json
|
||||
*.BIN
|
||||
|
||||
# 本地调试脚本与实测产物(不入库,与 ipw-boce 的约定一致)
|
||||
tmp-smoke/
|
||||
|
||||
# Agent 工具产物(本机技能安装/会话状态,不入库)
|
||||
.agents/
|
||||
.zcode/
|
||||
skills-lock.json
|
||||
@@ -0,0 +1,74 @@
|
||||
# Changelog
|
||||
|
||||
本文件记录面向用户/运维的版本变更。格式参照 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本(SemVer)。
|
||||
|
||||
## [v4.3.1] - 2026-11-01
|
||||
|
||||
### 🛠 修复
|
||||
|
||||
- **resolveASN 优先级调整**:改回 `maxmind_asn` 打头(whois 段的 AS 以 GeoLite2-ASN 为各家共认基准),`lemondb_asn` 退居未命中补位,链路为 maxmind_asn → lemondb_asn → dbip_asn → ip2location_asn
|
||||
|
||||
## [v4.3.0] - 2026-11-01
|
||||
|
||||
> 接入自家 LemonDB 全球库(定位 + ASN 双源)、归属地可视化地图、GeoCN 镜像链修复。
|
||||
|
||||
### ✨ 新功能
|
||||
|
||||
- **接入 LemonDB 作为定位与 ASN 源**:`/v1/location` 与 `/v1/asn` 全链路新增 `lemondb` / `lemondb_asn` 输出。LemonDB(nomdn/LemonDB 月度构建)为全球打底 + 中国省市县高精度层:中国段含省/市/区县 + 运营商 + 网络类型 + GB/T 2260 区划码,v6 部分段可到区县;坐标全库统一 WGS-84,`coord_system` 字段透传
|
||||
- **ASN 解析优先级重排**:`resolveASN` 改为 lemondb_asn → maxmind_asn → dbip_asn → ip2location_asn(补齐未命中兜底),whois 段与 ws 同步
|
||||
- **LemonDB 自动下载**:`PullDatabase` 新增 `LEMON-LOCATION.mmdb` / `LEMON-ASN.mmdb` 任务,走 ghproxy 镜像链;启动即拉 + 每日 ETag 条件请求,节点中断后恢复可在一天内追平
|
||||
- **归属地地图**:location 页新增地图(高德瓦片直连 + Leaflet + 免 key),多源打点直观对比各库定位差异与离散度;坐标按源静态标记或 `coord_system` 动态判断(LemonDB),经 `wgs84ToGcj02` 统一转 GCJ-02 与底图对齐
|
||||
- **地图数据源补充**:DB-IP City、GeoCN(含区划政府驻地坐标打点)
|
||||
- **ASN 页节点池**:改为「双栈 ∩ IPLocationAPI」节点交集,只展示两个接口都通的节点
|
||||
- **前端 LemonDB 展示**:location 表格首行 + asn 页 LemonDB ASN 行;locate.md 补字段说明
|
||||
|
||||
### 🛠 修复
|
||||
|
||||
- **GeoCN 行政区划下载单点依赖**:jsdelivr 被墙时数据永久缺失,补镜像列表兜底
|
||||
- **地图最大缩放瓦片空白**:高德栅格瓦片原生上限约 17 级,声明 `maxNativeZoom=17` 后更高缩放自动放大 17 级瓦片(轻度模糊但不再空白)
|
||||
- **MaxMind/DB-IP 地图打点无效**:字段名与实际 JSON 不符(小写 `latitude`/`longitude`),修正后打点恢复
|
||||
- 节点池恢复西安电信节点(cn-xian)
|
||||
|
||||
### ⚠️ 行为变化
|
||||
|
||||
- `/v1/location` 输出新增 `lemondb` 键(全源之首);`/v1/asn` 与 ws 的 asn 分支新增 `lemondb_asn`
|
||||
- ASN whois 段的解析库优先级变化(见上),lemondb 未命中的 IP 会依次落到 maxmind / dbip
|
||||
- ipdb 目录新增约 70MB 的 LemonDB 两库磁盘占用(首次启动后台自动拉取,不阻塞服务)
|
||||
|
||||
## [v4.2.0] - 2026-10-25
|
||||
|
||||
> DNSSEC 从不可用变为可用、请求级解析能力同步 EO 特供、全站文档图灯箱、SEO 与部署链路整体升级。
|
||||
|
||||
### ✨ 新功能
|
||||
|
||||
- **DNSSEC 验证重写**:默认解析器内置境外 DO 友好列表(1.1.1.1 / 8.8.8.8 等,IPv4/IPv6 双栈 14 地址);新增 DNSKEY 自验签(TLD 与无 A 记录域名可验);DS 与 A 并行查询;解析器不支持 DNSSEC 时给出配置提示
|
||||
- **SSL 检查新增 SAN 字段**:返回证书全部主体别名(DNS + IP)
|
||||
- **IndexNow 接入**:key 静态文件 + 提交端点(GET 全量 / POST 增量)+ 部署后自动向 Bing/Yandex 提交 sitemap
|
||||
- **文档图灯箱**:全站文档图片点击放大(el-image-viewer),支持多图切换、滚轮缩放、旋转、Esc 关闭、路由切换自动关闭
|
||||
- **高级选项输入规整**:DNS 服务器填纯域名自动补全为 `https://域名/dns-query`,缺 scheme/路径/端口的写法自动补齐
|
||||
- **EO 特供获得请求级解析**:`dns=` / `dns4=` / `dns6=` / `resolve=` 全套参数与主线对齐,DialContext DNS 接管 + SSRF 预检同源
|
||||
- **ipdb 后台初始化**:首次启动不再被 450MB 下载阻塞,服务降级运行、库就绪一个上线一个
|
||||
- **DNSSEC 原理文档**:/dnssec 页新增原理介绍、验证链图(OpenAI 风)与字段说明
|
||||
|
||||
### 🛠 修复
|
||||
|
||||
- **DNSSEC「一直用不了」**:国内公共解析器带 DO 位也不返回 RRSIG(根因,实测 6 家);TLD/无 A 记录域没有验签路径;DS 只在失败路径查询——三项全修
|
||||
- **构建 EBUSY 静默失败**:运行中的服务锁住 `.output` 导致产物不更新,`.gitignore` 同步补齐 agent 工具目录
|
||||
- **内置中间件**:h3 不解码路径 `%2F`,带协议头的拨测目标经兜底转发必然拼坏——已与 fiber 语义对齐
|
||||
- **/ipv6 空壳页**:改为服务端 301(消除无 h1/无 title 的低质量页)
|
||||
- **sitemap**:全站显式枚举 + 权重标注
|
||||
- whois 摘除自定义解析后回归系统解析器;EO 同步
|
||||
|
||||
### ⚡ 优化(ipdb)
|
||||
|
||||
- 条件下载(ETag/Last-Modified):日更不再全量重拉 450MB
|
||||
- 单文件下载超时 60s → 10 分钟(大文件慢链路不再必然失败)
|
||||
- bilibili 并行查询 + 负缓存 + singleflight(/v1/location 首查 292ms → 复查 1.7ms)
|
||||
- 数据库加载器原子换指针:日更重载零服务真空窗
|
||||
|
||||
### ⚠️ 行为变化
|
||||
|
||||
- `/v1/whois` 不再接受 `dns=` 参数(静默忽略)
|
||||
- `/ipv6` 改为服务端 301 到 `/location`
|
||||
- DNSSEC 默认走境外解析器:网络屏蔽/劫持这些 UDP 53 时需配置 `dnssec-server`
|
||||
- 部署/构建前需停掉 3000/4000 端口的服务进程(EBUSY 会静默失败,本次已验证此坑)
|
||||
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
@@ -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,173 +1,93 @@
|
||||
# Lemon IPW
|
||||
|
||||
ipw.cn 替代品,提供 IP 查询、网站检测、SSL 检查、DNS 解析、TCPing 测速等功能。
|
||||
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 等多数据源
|
||||
- **网站检测** — HTTP 状态码、响应时间、Host 记录
|
||||
- **SSL 证书检查** — 证书有效期、颁发机构、剩余天数
|
||||
- **DNS 解析** — 多节点并发查询,支持 A/AAAA/CNAME/MX/TXT/NS/SRV/PTR/CAA 等记录类型
|
||||
- **TCPing** — TCP 连接延迟测试,支持 IPv4/IPv6 双栈
|
||||
- **网站测速** — 下载速度、响应头、Host 记录
|
||||
- **暗色模式** — 支持明暗主题切换
|
||||
## 快速开始
|
||||
|
||||
```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)
|
||||
│ └── tcping.go # TCP 连接测试
|
||||
│
|
||||
├── frontend-ssr/ # Nuxt 4 SSR 前端(主版本,部署至 Cloudflare Workers)
|
||||
│ ├── app/ # Nuxt 应用源码
|
||||
│ ├── config/ # 前端配置(API 地址、节点列表)
|
||||
│ ├── nuxt.config.ts # Nuxt 配置
|
||||
│ ├── package.json
|
||||
│ ├── pnpm-workspace.yaml
|
||||
│ └── wrangler.jsonc # Cloudflare Workers 部署配置
|
||||
│
|
||||
│
|
||||
├── edgeone/ # 腾讯 EdgeOne 边缘函数版本后端
|
||||
│ ├── cloud-functions/ # Go 边缘函数源码
|
||||
│ │ ├── index.go # 后端入口(Gin 框架)
|
||||
│ │ ├── webtest/ # 网络测试工具
|
||||
│ │ └── 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 数据库下载临时目录
|
||||
│
|
||||
├── 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 |
|
||||
| 边缘后端(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) |
|
||||
| 部署 | 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 |
|
||||
|
||||
## 快速开始
|
||||
## 联系方式
|
||||
|
||||
### 后端(自托管)
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
go mod download
|
||||
|
||||
# 配置 setting.json(可选)
|
||||
# {
|
||||
# "port": 8080,
|
||||
# "gh-proxy": "https://fastgit.cc/",
|
||||
# "single-stack": "", # "ipv4" 或 "ipv6" 仅启用单栈
|
||||
# "dns-server": "" # 自定义 DNS 服务器
|
||||
# }
|
||||
|
||||
# 运行
|
||||
go run main.go
|
||||
```
|
||||
|
||||
首次启动时会自动下载 IP 数据库文件(约 200MB),之后每 24 小时自动更新。
|
||||
|
||||
### 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/detail/*url` | 检测网站可达性(HTTP 状态码、响应时间) |
|
||||
| GET | `/v1/ssl/*url` | 检查 SSL 证书信息 |
|
||||
| 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/speed/:version/*url` | 网站测速(version: v4/v6/dual) |
|
||||
| GET | `/` | 健康检查 |
|
||||
|
||||
## 部署架构
|
||||
|
||||
本项目支持多平台部署,前端和后端可独立部署到不同平台:
|
||||
|
||||
- **自托管后端**:使用 Docker 或直接运行 Go 二进制,配合 `setting.json` 配置
|
||||
- **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 服务器地址 |
|
||||
|
||||
### 前端配置
|
||||
|
||||
前端 API 地址和多节点配置位于各前端项目的 `config/` 目录中。
|
||||
- 邮箱:[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
|
||||
@@ -0,0 +1,88 @@
|
||||
import { defineConfig } from 'vitepress'
|
||||
// .vitepress/config.mts
|
||||
import { defineTeekConfig } from "vitepress-theme-teek/config";
|
||||
|
||||
// Teek 主题配置:defineTeekConfig 返回的对象必须合并进 defineConfig 才会生效。
|
||||
// teekHome:false + vpHome:true 使用 VitePress 标准首页布局,避免出现空荡的博客风格右侧卡片。
|
||||
const teekConfig = defineTeekConfig({
|
||||
teekHome: false,
|
||||
vpHome: true,
|
||||
vitePlugins: {
|
||||
// 关闭文章统计插件,不再扫描/统计文档
|
||||
docAnalysis: false,
|
||||
// 关闭自动生成侧边栏插件,侧边栏由下方 themeConfig.sidebar 手动维护
|
||||
sidebar: false,
|
||||
},
|
||||
});
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
// 合并 Teek 注入的 vite / markdown / ignoreDeadLinks 等配置
|
||||
...teekConfig,
|
||||
|
||||
title: "LEMON IPW",
|
||||
description: "柠檬味 ipw.cn 替代品 · 文档(占位)",
|
||||
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,
|
||||
|
||||
nav: [
|
||||
{ text: 'Home', link: '/' },
|
||||
{ text: '部署', link: '/guide/' },
|
||||
{ text: 'GitHub', link: 'https://github.com/nomdn/ipw-cn', target: '_blank' },
|
||||
],
|
||||
|
||||
// 右侧目录:显示 h2 + h3(默认只显示 h2)
|
||||
outline: {
|
||||
level: [2, 3],
|
||||
label: '本页目录',
|
||||
},
|
||||
|
||||
sidebar: [
|
||||
// 简介
|
||||
{
|
||||
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-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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
socialLinks: [
|
||||
{ icon: 'github', link: 'https://github.com/nomdn/ipw-cn' },
|
||||
],
|
||||
|
||||
search: {
|
||||
provider: 'local',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineTeekConfig } from "vitepress-theme-teek/config";
|
||||
|
||||
export const teekConfig = defineTeekConfig({
|
||||
homeCardListPosition: false,
|
||||
teekHome: false,
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
// .vitepress/theme/index.ts
|
||||
import Teek from "vitepress-theme-teek";
|
||||
import "vitepress-theme-teek/index.css";
|
||||
|
||||
export default {
|
||||
extends: Teek,
|
||||
};
|
||||
@@ -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` 确认环境变量已注入 |
|
||||
@@ -0,0 +1,113 @@
|
||||
# 前端部署
|
||||
|
||||
前端(`frontend-ssr/`)是全项目的核心,负责数据可视化与接口调用。基于 **Nuxt 4 SSR** 构建。
|
||||
|
||||
## 架构与部署目标
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 技术栈 | Nuxt 4 + Vue 3 + Element Plus + VueUse |
|
||||
| 运行时 | Node.js 22(见 `.node-version`)、包管理器 pnpm 10 |
|
||||
| 构建产物 | `.output/server/index.mjs` + `.output/public/`(静态资源) |
|
||||
| 构建命令 | `pnpm build`(Nitro SSR 构建) |
|
||||
|
||||
## 准备工作
|
||||
|
||||
```bash
|
||||
# 1. 进入前端目录
|
||||
cd frontend-ssr
|
||||
|
||||
# 2. 安装依赖(需要 Node 22 + pnpm 10)
|
||||
pnpm install
|
||||
|
||||
# 3. 本地开发
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
前端所有配置集中在 `frontend-ssr/config/index.ts`,完整配置项见 [配置文件](/guide/config) 的「前端」一节:
|
||||
|
||||
| 配置项 | 说明 |
|
||||
|--------|------|
|
||||
| `siteUrl` | 站点对外地址 |
|
||||
| `siteName` | 站点名称(页面标题 / 描述 / 页脚品牌) |
|
||||
| `APIBaseURL` | 拨测上游节点池(whois / ssl / detail / dns / dnssec / tcping / speed),含 `DualStack` / `IPv4` / `IPv6` 三栈 |
|
||||
| `IPLocationAPI` | IP 归属地 / ASN 上游节点池(纯数组,无栈区分) |
|
||||
| `Middleware` | 外部独立中间件 base URL 列表,`/middleware/*` 请求依次尝试、失败重试下一个 |
|
||||
| `EnableInternalMiddleware` | 是否启用前端内置中间件(本地转发,作为候选列表最后一位兜底),默认 `true` |
|
||||
| `rateLimitPerMinute` | 内置中间件单 IP 限流次数(次/分钟),默认 `120`,`0` 表示不限流 |
|
||||
| `v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` | 出站 IP 检测接口(页面直连建议使用带 CORS 的 wsmdn.top 接口) |
|
||||
| `umamiHost` 等 | Umami 统计配置 |
|
||||
| `ICP` / `GongAn` | 网站备案号(页脚展示) |
|
||||
| `noindex` | 是否禁止搜索引擎索引 |
|
||||
|
||||
> 生产环境建议通过运行时的环境变量或部署平台配置覆盖敏感项,不要把密钥写进 `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
|
||||
cd frontend-ssr
|
||||
pnpm build
|
||||
pnpm deploy # 等价于 pnpm build && wrangler deploy
|
||||
```
|
||||
|
||||
部署前需要配置 wrangler 认证(二选一):
|
||||
|
||||
```bash
|
||||
# 方式一:环境变量(CI 中使用)
|
||||
export CLOUDFLARE_API_TOKEN=your_api_token
|
||||
export CLOUDFLARE_ACCOUNT_ID=your_account_id
|
||||
|
||||
# 方式二:wrangler login(本地交互登录)
|
||||
npx wrangler login
|
||||
```
|
||||
|
||||
> Cloudflare 部署按钮不支持子目录,而前端位于仓库 `frontend-ssr/` 子目录,因此不提供一键部署按钮;使用上方 wrangler 命令或仓库 CI/CD 工作流部署。
|
||||
|
||||
### 方案四:直接运行(Node 自托管)
|
||||
|
||||
前端是 Node.js 应用,构建产物为 JS 脚本 + 静态资源(非二进制),可直接用 Node 运行:
|
||||
|
||||
```bash
|
||||
cd frontend-ssr
|
||||
pnpm build
|
||||
node .output/server/index.mjs # 默认监听 3000 端口
|
||||
```
|
||||
|
||||
生产环境建议使用进程守护(systemd / pm2 等)保持常驻。
|
||||
|
||||
## CI/CD 自动部署
|
||||
|
||||
仓库内置工作流 `.github/workflows/frontend-ssr.yml`:
|
||||
|
||||
- **触发条件**: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`
|
||||
|
||||
## 缓存
|
||||
|
||||
缓存头不在应用侧设置,全部交由前置 CDN(EdgeOne)控制。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- **部署后接口 403 / 跨域**:检查后端 `cors` 配置(独立中间件见 `middleware-go/setting.json` 的 `cors` 字段,逗号分隔允许域名);服务端转发请求不带 `Origin`,浏览器直接调用才受 CORS 限制。
|
||||
- **出站 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)
|
||||
@@ -0,0 +1,27 @@
|
||||
# 部署指南
|
||||
|
||||
### 本章节将一步步指导您自部署柠檬味IPW
|
||||
|
||||
Lemon IPW 采用前后端分离架构,**后端节点、独立中间件、前端**三个组件可各自独立部署,并按需组合:
|
||||
|
||||
| 组件 | 职责 | 部署方案 |
|
||||
|------|------|----------|
|
||||
| [后端节点](/guide/deploy-node) | 提供全部检测 API | EdgeOne / Vercel / Docker / 二进制 |
|
||||
| [独立中间件](/guide/deploy-middleware) | 请求转发 + Key 注入 | Docker / 二进制 |
|
||||
| [前端](/guide/deploy-frontend) | 数据可视化与接口调用 | EdgeOne / Vercel / Cloudflare Workers / 直接运行 |
|
||||
|
||||
组件之间通过 `/v1/*`(直连后端)与 `/middleware/*`(经中间件转发)两类接口通信,前端按节点列表轮询、失败自动重试下一个节点。
|
||||
|
||||
## 快速开始
|
||||
|
||||
想用最低成本部署一套功能完整的测试站,直接看 [快速入门](/guide/getting-started):交互式一键部署(install.sh)→ systemd 守护。
|
||||
|
||||
## CI/CD
|
||||
|
||||
仓库内置 GitHub Actions 流水线,push 到 `main` 时按改动路径自动部署:
|
||||
|
||||
| 工作流 | 部署目标 |
|
||||
|--------|----------|
|
||||
| `frontend-ssr.yml` | SSR 前端 → Cloudflare Workers |
|
||||
| `edgeone-backend.yml` | `serverless/edgeone/` → EdgeOne Pages |
|
||||
| `build_and_release.yml` | 后端多平台构建与发布(Release 附带配置文件) |
|
||||
@@ -0,0 +1,273 @@
|
||||
# 节点 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)、`version`(见下文「请求级参数」) | 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、禁止跨跳重定向。`tcping` 是裸 TCP、不经过出站客户端,故在 `webtest.ResolveHostWith` 内单独把关:**字面 IP**、**DNS 解析结果**、**`resolve*` 改写后的目标**三者只要落在私网/回环/链路本地/组播网段一律拒绝,该栈返回 `Error: connection to private/internal address <IP> is not allowed`(`sent` 仍为请求次数、`success=0`、`loss_rate=100`);关闭开关时三者一并放行。
|
||||
|
||||
### 请求级参数
|
||||
|
||||
除路径参数与常规 query 外,`/v1/tcping/:ip` 与所有走 DNS 的探测接口还接受一组**请求级参数**,用于按次覆盖解析行为。参数统一由 `webtest.ParseReqResolve` 解析,HTTP 拨测经 context、`tcping` 经形参透传到解析层;HTTP 与 WS 两条通道语义一致。
|
||||
|
||||
| 参数 | 取值 | 作用接口 | 说明 |
|
||||
|------|------|----------|------|
|
||||
| `dns` | 逗号分隔的 `ip:port` 或 `http(s)://` DoH URL | `detail` / `ssl` / `speed` / `tcping` / `whois` / `dns` | 本次探测改用指定 DNS 服务器,取代配置的 `dns-server` |
|
||||
| `dns4` | 同上 | 同上 | 只覆盖 IPv4 族解析,**优先于 `dns`** |
|
||||
| `dns6` | 同上 | 同上 | 只覆盖 IPv6 族解析,**优先于 `dns`** |
|
||||
| `resolve` | 逗号分隔的 `域名:目标`,目标为 IP 或域名 | `detail` / `ssl` / `speed` / `tcping` | 强制改写某域名的解析结果(两栈通用) |
|
||||
| `resolve4` | 同上 | 同上 | 只影响 IPv4 侧;非空时 IPv4 侧**只用这张表**(与 `dns4` 同口径) |
|
||||
| `resolve6` | 同上 | 同上 | 只影响 IPv6 侧;非空时 IPv6 侧**只用这张表** |
|
||||
| `version` | `ipv4` / `ipv6`(兼容 `v4` / `v6`,大小写与首尾空格不敏感) | `tcping` | 请求级指定单栈 |
|
||||
|
||||
`dns*` 与 `resolve*` 是两类正交的覆盖:前者「换服务器去问」,后者「直接改答案」,可同时使用。
|
||||
|
||||
**`resolve*` 的目标可以是 IP,也可以是域名**:
|
||||
|
||||
- 目标是 **IP**(`resolve4=example.com:1.2.3.4`,IPv6 可写 `[::1]` 或裸地址)→ 拨号阶段不再查 DNS,直接对该 IP 做私网校验后连接;
|
||||
- 目标是 **域名**(`resolve=www.example.com:origin.internal`)→ 把它当作新的 host 继续走解析,解析服务器仍取 `dns*`/配置,且**不再查映射表**(因此不会绕成环)。
|
||||
|
||||
行为要点:
|
||||
|
||||
- **取值与配置侧同一套校验**(`NormalizeServers`):合法项为 `ip:port`(UDP)或 DoH URL,非法项被丢弃;**参数存在但所有项都非法时返回 `400`**,不静默忽略——避免「看起来指定了、实际按默认跑」。`resolve*` 同理:每组必须形如 `域名:目标`,目标为 IP 或形如域名(不含端口、空格),非法项丢弃、全非法返回 `400`。
|
||||
- **`version` 不越权单栈配置**:节点 `single-stack` 非空时一律以配置为准(单栈机指定另一栈也跑不起来);仅当配置为空(双栈机)时 `version` 才生效。跳过文案借此区分来源:配置造成记 `SINGLE_STACK`,参数造成记 `version`。
|
||||
- **作用止于「域名 → IP」这一步**:`dns*` 只更换解析服务器、`resolve*` 只改写解析结果,都不改变探测目标与端口;`dnssec` / `location` / `asn` 三类接口不接受上述参数,`whois` / `dns` 只取 `dns*`、忽略 `resolve*`(`dns` 拨测问的就是真实解析,改写会自相矛盾)。
|
||||
- **SSRF 预检同源**:开启 `block-private-ips` 时,预检校验的是**拨号真正会连的地址**(即 `resolve*` 改写后的目标),不会拿原域名的解析结果去判定。`tcping` 没有独立的预检步骤,同一判断落在 `ResolveHostWith` 进入拨号之前,口径与上述一致。
|
||||
- **带覆盖时按独立缓存键缓存**:规范化后的参数会拼进缓存键(见「七、缓存与并发」),不同服务器/不同映射的结果各占一条,既不串味、也不牺牲命中率。
|
||||
|
||||
---
|
||||
|
||||
## 三、管理接口
|
||||
|
||||
### 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 | 30s / 5s |
|
||||
| `ssl` | URL | 30s / 5s |
|
||||
| `speed` | `url:version` | 30s / 5s |
|
||||
| `tcping` | `host:port:count:stack` | 30s / 5s |
|
||||
| `whois` | 域名 | 30s / 5s |
|
||||
| `asn whois` | `AS<asn>` | 30s / 5s |
|
||||
| Bilibili 归属地(在线 API) | IP | 24 小时(不分成败) |
|
||||
|
||||
- **TTL 按结果分档**(`cacheTTL`):成功 30s、失败 5s。失败结果不缓存太久,避免故障恢复后长时间返回旧结论;TTL 记在条目里、读时判断,无需后台协程唤醒。
|
||||
- **缓存键含生效栈**:`tcping` 的键为 `host:port:count:stack`,`version=ipv6` 的结果与默认双栈不会互相污染。
|
||||
- **singleflight 合并**:`detail` / `ssl` / `tcping` / `speed` 的并发同 key 请求合并为一次真实探测(合并键同缓存键)。
|
||||
- **请求级解析参数进键**:`dns*` 与 `resolve*` 一并拼入,详见下一节。
|
||||
- **定时清扫**:每 1 分钟清理过期缓存条目,防止只靠「同 key 重访才淘汰」时内存无限增长。
|
||||
|
||||
### 7.1 请求级解析参数如何进入缓存键
|
||||
|
||||
缓存键的基础部分只含**目标**维度(URL / host),但探测结果依赖**怎么解析**——同一域名经不同服务器、或经 `resolve*` 改写后可能指向完全不同的 IP(企业内网、CDN 调度、分线路权威、上线前的强制指向)。若把两者混在一条键里,会双向串味且无从察觉:
|
||||
|
||||
- 带 `dns=1.1.1.1:53` 的结果写进 `example.com` 这条 → 随后的普通请求(用配置的 `dns-server`)读出来,用户以为是自己默认 DNS 的结果;
|
||||
- 反过来,普通请求的结果也会被带 `dns=` 的请求读走——用户明明指定了服务器,却拿到别人用另一台服务器查的结果;
|
||||
- `resolve*` 更直接:强制指向 A 与强制指向 B 是两次不同探测,共用一条键等于互相覆盖。
|
||||
|
||||
因此把**规范化后的参数片段拼在键末尾**(`ReqResolve.CacheKey()`),两件事同时成立:不同解析配置各占一条,相同配置仍能命中。
|
||||
|
||||
```
|
||||
https://example.com ← 无请求级参数
|
||||
https://example.com;dns=1.1.1.1:53 ← dns=
|
||||
https://example.com:v4;dns=8.8.8.8:53;dns6=[2400::1]:53 ← speed,按栈覆盖
|
||||
qq.com:80:4:ipv4;dns=1.1.1.1:53 ← tcping
|
||||
https://example.com;resolve4=example.com:127.0.0.1 ← resolve4,强制解析
|
||||
```
|
||||
|
||||
片段规则:
|
||||
|
||||
- **字段顺序固定**为 `dns` → `dns4` → `dns6` → `resolve` → `resolve4` → `resolve6`。所以参数在 URL 里谁先谁后不影响键(`?dns6=…&dns=…` 与 `?dns=…&dns6=…` 是同一个键)。
|
||||
- **`dns*` 内部按输入顺序,`resolve*` 内部按域名排序**:映射表本质是 map,迭代顺序随机;不排序的话同一请求每次算出的键都不同,缓存永远命不中。
|
||||
- **无参数时片段为空串** ⇒ 键与未引入该特性时**逐字节一致**,升级后旧缓存条目照常命中。
|
||||
- **按规范化结果拼**:非法项在校验阶段已被丢弃,故 `dns=1.1.1.1:53,garbage` 与 `dns=1.1.1.1:53` 落在同一个键上;`resolve4=Example.COM.:1.2.3.4` 与 `resolve4=example.com:1.2.3.4` 同理(域名统一小写、去尾点)。
|
||||
- **键空间随组合增长**:每条仍受 30s / 5s 的 TTL 约束,由每分钟的清扫兜住,不会无限堆积。
|
||||
- `asn` / `location` / `dnssec` 不接这些参数,键与以前相同。
|
||||
|
||||
> 已知残留(与本次改动无关):`detail` 与 `ssl` 的 singleflight 合并键都是「URL + 片段」,两种探测**并发**打同一 URL 时会撞在一起,后到者按另一类型断言会 panic(被 `recover` 接住 → 500)。修法是给合并键加探测类型前缀,暂未处理。
|
||||
@@ -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(普通用户只见自己的) |
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
# 首页(VitePress 标准布局,已在 config 中设置 vpHome: true / teekHome: false)
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: "LEMON IPW"
|
||||
text: "LEMON IPW官方文档"
|
||||
tagline: IP 查询 · 网站检测 · SSL · DNS · DNSSEC · Whois · ASN · 截图
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 快速开始
|
||||
link: /guide/getting-started
|
||||
- theme: alt
|
||||
text: GitHub
|
||||
link: https://github.com/nomdn/ipw-cn
|
||||
target: _blank
|
||||
|
||||
features:
|
||||
- title: 多源 IP 查询
|
||||
details: 支持 IPv4/IPv6,集成 ip2region、qqwry、MaxMind GeoIP、GeoCN、DbIP、Bilibili 等多数据源,覆盖国内外归属地。
|
||||
- title: ASN 与 Whois
|
||||
details: 查询 IP 所属自治系统号(多数据源 + WHOIS 解析),以及域名注册信息、注册商、到期时间等。
|
||||
- title: 网站检测全家桶
|
||||
details: HTTP 状态码与响应时间、SSL 证书有效期与颁发机构、TCPing 延迟、下载测速、在线页面截图一站式完成。
|
||||
- title: DNS 解析
|
||||
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,以及 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)
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"scripts": {
|
||||
"docs:dev": "vitepress dev",
|
||||
"docs:build": "vitepress build",
|
||||
"docs:preview": "vitepress preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitepress": "^1.6.4",
|
||||
"vitepress-theme-teek": "^1.6.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
|
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,349 +0,0 @@
|
||||
package webtest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// 腾讯云标准 DoH 端点
|
||||
var dohEndpoint string = "https://doh.pub/dns-query"
|
||||
|
||||
type DNSResult struct {
|
||||
Domain string `json:"domain"`
|
||||
Duration float64 `json:"duration"`
|
||||
Record []string `json:"record"`
|
||||
TTL uint32 `json:"ttl"`
|
||||
}
|
||||
|
||||
func SetDNSServer(server string) {
|
||||
if server == "" {
|
||||
server = "https://doh.pub/dns-query"
|
||||
}
|
||||
dohEndpoint = server
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ⭐️ 底层核心:构造、发送并解析二进制 DNS 报文
|
||||
// ==========================================
|
||||
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. 将 DNS 报文打包成二进制字节流
|
||||
packedMsg, err := msg.Pack()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to pack DNS message: %v", err)
|
||||
}
|
||||
|
||||
// 3. 构造 HTTP POST 请求,Body 为二进制报文
|
||||
req, err := http.NewRequest("POST", dohEndpoint, bytes.NewReader(packedMsg))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// ⭐️ 灵魂 Header:RFC 8484 标准规定的 MIME 类型
|
||||
req.Header.Set("Content-Type", "application/dns-message")
|
||||
req.Header.Set("Accept", "application/dns-message")
|
||||
|
||||
// 4. 发起 HTTPS 请求 (走 443 端口)
|
||||
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)
|
||||
}
|
||||
|
||||
// 5. 读取返回的二进制响应报文
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, duration, fmt.Errorf("failed to read response body: %v", err)
|
||||
}
|
||||
|
||||
// 6. 使用 miekg/dns 解包二进制响应
|
||||
responseMsg := new(dns.Msg)
|
||||
if err := responseMsg.Unpack(bodyBytes); err != nil {
|
||||
return nil, duration, fmt.Errorf("failed to unpack DNS response: %v", err)
|
||||
}
|
||||
|
||||
// 7. 检查 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,136 +0,0 @@
|
||||
package webtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TCPingResult struct {
|
||||
IP string `json:"ip"`
|
||||
Port string `json:"port"`
|
||||
Success bool `json:"success"`
|
||||
RTT float64 `json:"rtt"`
|
||||
Error string `json:"error"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
type TCPingStats struct {
|
||||
IP string `json:"ip"`
|
||||
Port string `json:"port"`
|
||||
Sent int `json:"sent"`
|
||||
Success int `json:"success"`
|
||||
LossRate float64 `json:"loss_rate"`
|
||||
MaxRTT float64 `json:"max_rtt"`
|
||||
MinRTT float64 `json:"min_rtt"`
|
||||
AvgRTT float64 `json:"avg_rtt"`
|
||||
Results []TCPingResult `json:"results"`
|
||||
}
|
||||
|
||||
func resolveHost(host string, version string) (string, error) {
|
||||
return ResolveIP(host, version)
|
||||
}
|
||||
|
||||
func TCPing(host string, port string, version string, timeout time.Duration) (*TCPingResult, error) {
|
||||
ip, err := resolveHost(host, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addr := ""
|
||||
switch version {
|
||||
case "v4":
|
||||
addr = fmt.Sprintf("%s:%s", ip, port)
|
||||
case "v6":
|
||||
addr = fmt.Sprintf("[%s]:%s", ip, port)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||
rtt := time.Since(start)
|
||||
|
||||
result := &TCPingResult{
|
||||
IP: ip,
|
||||
Port: port,
|
||||
Timestamp: start,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
result.Success = false
|
||||
|
||||
result.RTT = -1
|
||||
result.Error = err.Error()
|
||||
} else {
|
||||
conn.Close()
|
||||
result.Success = true
|
||||
result.RTT = float64(rtt.Microseconds()) / 1000.0
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func TCPingRun(host string, port string, count int, version string, timeout time.Duration, interval time.Duration) (*TCPingStats, error) {
|
||||
ip, err := resolveHost(host, version)
|
||||
if err != nil {
|
||||
return &TCPingStats{
|
||||
IP: "Error: " + err.Error(),
|
||||
Port: port,
|
||||
Sent: count,
|
||||
Success: 0,
|
||||
Results: nil,
|
||||
MinRTT: -1,
|
||||
MaxRTT: -1,
|
||||
AvgRTT: -1,
|
||||
LossRate: 100,
|
||||
}, nil
|
||||
}
|
||||
|
||||
stats := &TCPingStats{
|
||||
IP: ip,
|
||||
Port: port,
|
||||
Sent: count,
|
||||
MinRTT: math.MaxFloat64,
|
||||
Results: make([]TCPingResult, 0, count),
|
||||
}
|
||||
|
||||
var totalRTT float64
|
||||
successCount := 0
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
result, err := TCPing(host, port, version, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats.Results = append(stats.Results, *result)
|
||||
|
||||
if result.Success {
|
||||
successCount++
|
||||
totalRTT += result.RTT
|
||||
if result.RTT > stats.MaxRTT {
|
||||
stats.MaxRTT = result.RTT
|
||||
}
|
||||
if result.RTT < stats.MinRTT {
|
||||
stats.MinRTT = result.RTT
|
||||
}
|
||||
}
|
||||
|
||||
if i < count-1 && interval > 0 {
|
||||
time.Sleep(interval)
|
||||
}
|
||||
}
|
||||
|
||||
stats.Success = successCount
|
||||
stats.LossRate = math.Round(float64(count-successCount)*10000/float64(count)) / 100
|
||||
|
||||
if successCount > 0 {
|
||||
stats.AvgRTT = math.Round(totalRTT*100/float64(successCount)) / 100
|
||||
} else {
|
||||
stats.MinRTT = -1
|
||||
stats.MaxRTT = -1
|
||||
stats.AvgRTT = -1
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -1,31 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import { 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);
|
||||
|
||||
function Announcement() {
|
||||
ElMessage({
|
||||
showClose: true,
|
||||
message: '您好,这里是LEMONIPW开发团队,我们很重视您对本项目的意见,诚邀您加入官方交流群进行讨论,欢迎您的加入!QQ:<a href="https://qm.qq.com/q/E1CGjkqgG6" >点我</a>',
|
||||
duration: 3000,
|
||||
dangerouslyUseHTMLString: true,
|
||||
});
|
||||
// ==================== 抽屉里的文档菜单 ====================
|
||||
// 窄屏下 /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, '');
|
||||
}
|
||||
function cleanChineseCharacters(input: string): string {
|
||||
// 使用正则表达式匹配中文字符
|
||||
const chineseRegex = /[\u4e00-\u9fa5]/g;
|
||||
// 将中文字符替换为空字符串
|
||||
return input.replace(chineseRegex, '');
|
||||
|
||||
// ==================== 响应式折叠菜单 ====================
|
||||
// 映射表:菜单按显示顺序排在一个数组里,每项自带 minWidth(px)折叠阈值。
|
||||
// 规则:视口宽度 >= minWidth 显示,低于则折叠进「更多」子菜单。
|
||||
// 断点从右往左递减(最右侧 index 9 先折叠),数值可按需调整。
|
||||
// divider 也带 minWidth(取其后第一项的值):所在段全部折叠时,分隔线一并隐藏。
|
||||
interface NavItem {
|
||||
kind: 'item'
|
||||
index: string
|
||||
label: string
|
||||
to: string
|
||||
minWidth: number
|
||||
}
|
||||
interface NavGroup {
|
||||
kind: 'group'
|
||||
index: string
|
||||
label: string
|
||||
minWidth: number
|
||||
children: { index: string; label: string; to: string }[]
|
||||
}
|
||||
interface NavDivider {
|
||||
kind: 'divider'
|
||||
minWidth: number
|
||||
}
|
||||
type MenuEntry = NavItem | NavGroup | NavDivider
|
||||
|
||||
const menu: MenuEntry[] = [
|
||||
// 断点设计基准(从右往左折叠):全显示约 1480px,1366 屏可用约 1350px,
|
||||
// 故 1366 屏折叠 index 7/8/9(显示 1-6 +「更多」,约 1220px 宽裕);1440 折叠 8/9;1500+ 全显示。
|
||||
{ kind: 'item', index: '1', label: 'IPv6 网站检测', to: '/ipv6webcheck', minWidth: 980 },
|
||||
{ kind: 'item', index: '2', label: 'IPv6/IPv4 地址查询', to: '/location', minWidth: 1040 },
|
||||
{ kind: 'item', index: '3', label: 'IPv6 TCPing测试', to: '/ipv6tcping', minWidth: 1100 },
|
||||
{ kind: 'divider', minWidth: 1160 }, // 与 index 4 相同:4-6 段全折叠时隐藏
|
||||
{ kind: 'item', index: '4', label: 'IPv6 DNS解析', to: '/dns', minWidth: 1160 },
|
||||
{ kind: 'item', index: '5', label: 'IPv6 SSL检查', to: '/ssl', minWidth: 1220 },
|
||||
{ kind: 'item', index: '6', label: 'IPv6 网站测速', to: '/ipv6speedtest', minWidth: 1280 },
|
||||
{ kind: 'divider', minWidth: 1380 }, // 与 index 7 相同:7-9 段全折叠时隐藏
|
||||
{
|
||||
kind: 'group', index: '7', label: 'IPv4工具箱', minWidth: 1380,
|
||||
children: [
|
||||
{ index: '7-0', label: 'IPv4 网站测速', to: '/speedtest' },
|
||||
{ index: '7-1', label: 'IPv4 TCPing测试', to: '/tcping' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'group', index: '8', label: '其他工具', minWidth: 1420,
|
||||
children: [
|
||||
{ index: '8-0', label: '网站截图', to: '/screenshot' },
|
||||
{ index: '8-1', label: 'Whois查询', to: '/whois' },
|
||||
{ index: '8-3', label: 'ASN查询', to: '/asn' },
|
||||
{ index: '8-4', label: 'DNSSEC验证', to: '/dnssec' },
|
||||
],
|
||||
},
|
||||
{ kind: 'item', index: '9', label: '文档', to: '/doc', minWidth: 1500 },
|
||||
]
|
||||
|
||||
// SSR 阶段无 window,默认按宽屏输出全部菜单项;onMounted 后更新为真实视口宽度
|
||||
const viewportWidth = ref(1920)
|
||||
|
||||
// 可见项(含分隔线):视口宽度达标即显示
|
||||
const visible = computed<MenuEntry[]>(() =>
|
||||
menu.filter((entry) => viewportWidth.value >= entry.minWidth),
|
||||
)
|
||||
// 折叠项(不含分隔线):视口宽度不足即收进「更多」。
|
||||
// 用类型谓词收窄为 NavItem | NavGroup,模板里才能安全访问 entry.index
|
||||
const hidden = computed<Array<NavItem | NavGroup>>(
|
||||
() => menu.filter((entry): entry is 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
|
||||
|
||||
// ==================== 全站级 SEO 兜底 ====================
|
||||
// 以下标签对每一页生效,页面级 useHead 不必再重复声明(index/whois 原有的
|
||||
// canonical / og:url / twitter:card 已上收到这里,避免 head 出现两条 canonical)。
|
||||
//
|
||||
// canonical 按纯路径(不含 query):工具页普遍接受 ?ip= / ?site= 等参数,
|
||||
// 同一内容会以无限多组 query 变体出现,SEO 审计由此判定「sitemap 缺少重要页面」;
|
||||
// 统一收敛到裸路径,爬虫就不会把 ?ip=x 当独立页面收录。
|
||||
const siteOrigin = config.siteUrl.endsWith('/') ? config.siteUrl : config.siteUrl + '/'
|
||||
const canonicalUrl = computed(() => new URL(route.path, siteOrigin).href)
|
||||
// 站点级结构化数据(WebSite + Organization):给搜索引擎的实体锚点,
|
||||
// 各工具页的 WebApplication 与文档页的 BreadcrumbList 通过 url 与之关联。
|
||||
const siteJsonLd = JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
{
|
||||
'@type': 'WebSite',
|
||||
'@id': siteOrigin + '#website',
|
||||
name: config.siteName,
|
||||
alternateName: 'Lemon IPW',
|
||||
url: siteOrigin,
|
||||
inLanguage: 'zh-CN',
|
||||
description: '专业IP查询与网络测试工具平台:IPv4/IPv6地址查询、归属地定位、网站测速、DNS解析、SSL证书检测、TCPing、Whois查询。'
|
||||
},
|
||||
{
|
||||
'@type': 'Organization',
|
||||
'@id': siteOrigin + '#organization',
|
||||
name: config.siteName,
|
||||
url: siteOrigin,
|
||||
logo: siteOrigin + 'favicon.svg',
|
||||
sameAs: ['https://github.com/nomdn/ipw-cn']
|
||||
}
|
||||
]
|
||||
})
|
||||
useHead({
|
||||
// 页面没写 title 时的兜底(正常页面各自已带完整标题)
|
||||
titleTemplate: (title?: string) => title || config.siteName,
|
||||
link: [
|
||||
{ rel: 'canonical', href: canonicalUrl },
|
||||
],
|
||||
meta: config.noindex
|
||||
? [
|
||||
{ name: 'robots', content: 'noindex, nofollow' },
|
||||
{ name: 'googlebot', content: 'noindex, nofollow' },
|
||||
{ name: 'bingbot', content: 'noindex, nofollow' },
|
||||
{ name:'baidu-site-verification', content: 'codeva-xzdbvF8gQu'},
|
||||
{ property: 'og:url', content: canonicalUrl },
|
||||
{ property: 'og:site_name', content: config.siteName },
|
||||
{ property: 'og:locale', content: 'zh_CN' },
|
||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||
]
|
||||
: [
|
||||
{ name:'baidu-site-verification', content: 'codeva-xzdbvF8gQu'},
|
||||
{ property: 'og:url', content: canonicalUrl },
|
||||
{ property: 'og:site_name', content: config.siteName },
|
||||
{ property: 'og:locale', content: 'zh_CN' },
|
||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||
],
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: siteJsonLd,
|
||||
}
|
||||
]
|
||||
});
|
||||
onMounted(() => {
|
||||
mediaQueryList = window.matchMedia('(max-width: 768px)');
|
||||
isNarrow.value = mediaQueryList.matches;
|
||||
@@ -36,108 +199,137 @@ onMounted(() => {
|
||||
|
||||
mediaQueryList.addEventListener('change', handler);
|
||||
|
||||
updateViewport();
|
||||
window.addEventListener('resize', updateViewport);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
mediaQueryList?.removeEventListener('change', handler);
|
||||
window.removeEventListener('resize', updateViewport);
|
||||
});
|
||||
});
|
||||
|
||||
useHead({
|
||||
// 在 HTML 解析前同步执行,防止明暗切换和页面闪烁
|
||||
script: [
|
||||
{
|
||||
defer: true,
|
||||
src: config.umamiScriptUrl,
|
||||
'data-website-id': config.umamiWebsiteId,
|
||||
},
|
||||
],
|
||||
meta: config.noindex
|
||||
? [
|
||||
{ name: 'robots', content: 'noindex, nofollow' },
|
||||
{ name: 'googlebot', content: 'noindex, nofollow' },
|
||||
{ name: 'bingbot', content: 'noindex, nofollow' },
|
||||
]
|
||||
: [],
|
||||
});
|
||||
// 统计脚本延迟到浏览器空闲再注入: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-model="drawer" direction="ltr" style="height: 100%;" size="50%">
|
||||
<router-link to="/ipv6webcheck" style="font-size: 1em;">
|
||||
<p style="display: inline-block; margin-left: 10px">IPv6 网站检测</p>
|
||||
<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>
|
||||
<router-link to="/location" style="font-size: 1em;">
|
||||
<p style="display: inline-block; margin-left: 10px">IPv6/IPv4 地址查询</p>
|
||||
<router-link to="/location">
|
||||
<p class="menu-item-text">IPv6/IPv4 地址查询</p>
|
||||
</router-link>
|
||||
<router-link to="/ipv6tcping" style="font-size: 1em;">
|
||||
<p style="display: inline-block; margin-left: 10px">IPv6 TCPing</p>
|
||||
<router-link to="/ipv6tcping">
|
||||
<p class="menu-item-text">IPv6 TCPing</p>
|
||||
</router-link>
|
||||
<router-link to="/dns" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">IPv6 DNS解析</p></router-link>
|
||||
<router-link to="/ssl" style="font-size: 1em;">
|
||||
<p style="display: inline-block; margin-left: 10px">IPv6 SSL检查</p>
|
||||
<router-link to="/dns"><p class="menu-item-text">IPv6 DNS解析</p></router-link>
|
||||
<router-link to="/ssl">
|
||||
<p class="menu-item-text">IPv6 SSL检查</p>
|
||||
</router-link>
|
||||
<a href="/ipv6speedtest" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">IPv6 网站测速</p></a>
|
||||
<a href="/speedtest" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">IPv4 网站测速</p></a>
|
||||
<a href="/tcping" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">IPv4 TCPing</p></a>
|
||||
<router-link to="/ipv6speedtest"><p class="menu-item-text">IPv6 网站测速</p></router-link>
|
||||
<router-link to="/speedtest"><p class="menu-item-text">IPv4 网站测速</p></router-link>
|
||||
<router-link to="/tcping"><p class="menu-item-text">IPv4 TCPing</p></router-link>
|
||||
<router-link to="/screenshot"><p class="menu-item-text">网站截图</p></router-link>
|
||||
<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="/">
|
||||
<img
|
||||
src="/favicon.svg"
|
||||
alt="IPW logo"
|
||||
width="48"
|
||||
height="48"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
<h2 style="display: inline-block; margin-left: 10px">柠檬味ipw.cn</h2>
|
||||
<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>
|
||||
|
||||
<el-menu-item index="1" v-if="!isNarrow">
|
||||
<router-link to="/ipv6webcheck" style="font-size: 1em;">
|
||||
<p style="display: inline-block; margin-left: 10px">IPv6 网站检测</p>
|
||||
</router-link>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="2" v-if="!isNarrow">
|
||||
<router-link to="/location">
|
||||
<p style="display: inline-block; margin-left: 10px">IPv6/IPv4 地址查询</p>
|
||||
</router-link>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="3" v-if="!isNarrow">
|
||||
<router-link to="/ipv6tcping" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">IPv6 TCPing测试</p></router-link>
|
||||
</el-menu-item>
|
||||
|
||||
<el-divider style="margin-top: 20px;height: 1.2em;" direction="vertical" v-if="!isNarrow"/>
|
||||
|
||||
<el-menu-item index="4" v-if="!isNarrow">
|
||||
<router-link to="/dns" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">IPv6 DNS解析</p></router-link>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="5" v-if="!isNarrow">
|
||||
<router-link to="/ssl">
|
||||
<p style="display: inline-block; margin-left: 10px">IPv6 SSL检查</p>
|
||||
</router-link>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="6" v-if="!isNarrow">
|
||||
<router-link to="/ipv6speedtest" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">IPv6 网站测速</p></router-link>
|
||||
</el-menu-item>
|
||||
<el-divider style="margin-top: 20px;height: 1.2em;" direction="vertical" v-if="!isNarrow"/>
|
||||
<el-sub-menu index="7" v-if="!isNarrow">
|
||||
<template #title>IPv4工具箱</template>
|
||||
<el-menu-item index="7-0">
|
||||
<router-link to="/speedtest" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">IPv4 网站测速</p></router-link>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="7-1">
|
||||
<router-link to="/tcping" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">IPv4 TCPing测试</p></router-link>
|
||||
<template v-if="!isNarrow">
|
||||
<!-- 可见菜单项:按映射表顺序渲染 item / group / divider -->
|
||||
<template v-for="entry in visible" :key="entry.kind === 'divider' ? 'div-' + entry.minWidth : entry.index">
|
||||
<el-menu-item v-if="entry.kind === 'item'" :index="entry.index">
|
||||
<router-link :to="entry.to"><p class="menu-item-text">{{ entry.label }}</p></router-link>
|
||||
</el-menu-item>
|
||||
<el-sub-menu v-else-if="entry.kind === 'group'" :index="entry.index">
|
||||
<template #title>{{ entry.label }}</template>
|
||||
<el-menu-item v-for="child in entry.children" :key="child.index" :index="child.index">
|
||||
<router-link :to="child.to"><p class="menu-item-text">{{ child.label }}</p></router-link>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<!-- 分组之间的竖分隔条。原来是 <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">
|
||||
<template #title>更多</template>
|
||||
<template v-for="entry in hidden" :key="entry.index">
|
||||
<el-menu-item v-if="entry.kind === 'item'" :index="'m-' + entry.index">
|
||||
<router-link :to="entry.to"><p class="menu-item-text">{{ entry.label }}</p></router-link>
|
||||
</el-menu-item>
|
||||
<el-menu-item-group v-else :title="entry.label">
|
||||
<el-menu-item v-for="child in entry.children" :key="child.index" :index="'m-' + child.index">
|
||||
<router-link :to="child.to"><p class="menu-item-text">{{ child.label }}</p></router-link>
|
||||
</el-menu-item>
|
||||
</el-menu-item-group>
|
||||
</template>
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="9">
|
||||
</template>
|
||||
<!-- 纯图标菜单项:无可访问名,补 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>
|
||||
@@ -152,15 +344,24 @@ useHead({
|
||||
<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>
|
||||
| 请遵守中国法律法规 |
|
||||
@@ -172,11 +373,15 @@ useHead({
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- 全站文档图片灯箱:委托拦截 .markdown / .markdown-body 内的图片点击 -->
|
||||
<ImageLightbox />
|
||||
</template>
|
||||
<style scoped>
|
||||
@import "~/style.css";
|
||||
.el-menu--horizontal > .el-menu-item:nth-child(1) {
|
||||
margin-right: auto;
|
||||
/* 菜单项统一文本样式(替代原内联 style="display: inline-block; margin-left: 10px") */
|
||||
.menu-item-text {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
}
|
||||
:deep(.shiki span) {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Monaco', 'Courier New', monospace !important;
|
||||
@@ -186,40 +391,208 @@ useHead({
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item a) {
|
||||
font-size: 1em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
:deep(.el-menu-item a p) {
|
||||
font-size: 1em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
:deep(.el-menu-item a img) {
|
||||
width: 50px;
|
||||
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 内部链接占满一行。
|
||||
注意用直接子选择器(> 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 {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 防止窄屏设备在 Vue 水合前出现宽屏布局闪烁 */
|
||||
html.is-narrow .el-drawer__container {
|
||||
/* 并进抽屉的文档菜单:与上面的工具链接视觉上分段,并去掉菜单自带底色与右边框
|
||||
(.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 > .menu-divider {
|
||||
display: none !important;
|
||||
}
|
||||
.el-menu--horizontal > .el-menu-item[index="1"],
|
||||
.el-menu--horizontal > .el-menu-item[index="2"],
|
||||
.el-menu--horizontal > .el-menu-item[index="3"],
|
||||
.el-menu--horizontal > .el-menu-item[index="4"],
|
||||
.el-menu--horizontal > .el-menu-item[index="5"],
|
||||
.el-menu--horizontal > .el-menu-item[index="6"],
|
||||
.el-menu--horizontal > .el-sub-menu[index="7"],
|
||||
.el-menu--horizontal > .el-sub-menu[index="8"],
|
||||
.el-menu--horizontal > .el-menu-item[index="9"],
|
||||
.el-menu--horizontal > .el-sub-menu[index="more"] {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
.el-menu--horizontal {
|
||||
--el-menu-hover-bg-color: transparent !important;
|
||||
--el-menu-active-color: var(--el-text-color-primary) !important;
|
||||
--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;
|
||||
}
|
||||
|
||||
/* 去除选中强调和下划线 */
|
||||
.el-menu--horizontal > .el-menu-item.is-active {
|
||||
color: var(--el-text-color-primary) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.el-menu--horizontal > .el-menu-item:hover {
|
||||
color: var(--el-text-color-primary) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* 去除所有可能的边框和下划线 */
|
||||
.el-menu--horizontal::after {
|
||||
display: none !important;
|
||||
}
|
||||
html.is-narrow .el-menu--horizontal > .el-divider {
|
||||
|
||||
.el-menu--horizontal > .el-menu-item {
|
||||
border-bottom: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.el-menu--horizontal > .el-menu-item.is-active::after {
|
||||
display: none !important;
|
||||
}
|
||||
html.is-narrow .el-menu--horizontal > .el-menu-item[index="1"],
|
||||
html.is-narrow .el-menu--horizontal > .el-menu-item[index="2"],
|
||||
html.is-narrow .el-menu--horizontal > .el-menu-item[index="3"],
|
||||
html.is-narrow .el-menu--horizontal > .el-menu-item[index="4"],
|
||||
html.is-narrow .el-menu--horizontal > .el-menu-item[index="5"],
|
||||
html.is-narrow .el-menu--horizontal > .el-menu-item[index="6"],
|
||||
html.is-narrow .el-menu--horizontal > .el-sub-menu[index="7"] {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
/* 覆盖选中、悬停和聚焦状态的高亮样式 */
|
||||
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,267 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { isIPv6 } from 'is-ip'
|
||||
|
||||
/**
|
||||
* 高级选项:请求级解析参数(与 ipw-ak 的 AdvancedOptions 同源)
|
||||
*
|
||||
* - 展开后按地址族分列:IPv4 → dns4 / resolve4,IPv6 → dns6 / resolve6
|
||||
* - stack 非空时(单栈页)只渲染对应栈那一列,且不显示 dns / resolve 通用行:
|
||||
* 单栈页里两者与 dns4=/dns6= 等价,双份输入框只会让人纠结该填哪个;
|
||||
* tcping / speed 的前端两个栈是分开的页面,另一列的参数永远吃不到。
|
||||
* - dns / resolve 是双栈页的两栈通用兜底,dns4/dns6、resolve4/resolve6 非空时覆盖它们。
|
||||
*
|
||||
* 用法:
|
||||
* <AdvancedOptions ref="adv" /> 双栈页(dns4/dns6 + resolve4/resolve6)
|
||||
* <AdvancedOptions ref="adv" stack="ipv4" /> 单栈页,只给 IPv4 一列
|
||||
* <AdvancedOptions ref="adv" :show-resolve="false" /> 仅 dns4/dns6(dns、whois 页不支持强制解析)
|
||||
*
|
||||
* 页面里取参数:
|
||||
* withQuery(baseUrl, adv.value?.query() ?? {})
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 是否显示 resolve / resolve4 / resolve6(dns、whois 页不支持强制解析) */
|
||||
showResolve?: boolean
|
||||
/** 只显示某一栈的配置项;单栈页面(tcping / speed 的 v4、v6 页)传,双栈页面不传 */
|
||||
stack?: 'ipv4' | 'ipv6' | ''
|
||||
}>(),
|
||||
{ showResolve: true, stack: '' },
|
||||
)
|
||||
|
||||
const expanded = ref(false)
|
||||
/** 两栈通用的 DNS 服务器:dns4/dns6 非空时覆盖它 */
|
||||
const dns = ref('')
|
||||
const dns4 = ref('')
|
||||
const dns6 = ref('')
|
||||
/** 两栈通用的强制解析:resolve4/resolve6 非空时覆盖它 */
|
||||
const resolve = ref('')
|
||||
const resolve4 = ref('')
|
||||
const resolve6 = ref('')
|
||||
|
||||
/** 域名 HOST 判定:含点、标签为字母数字/连字符/下划线(宽松匹配,畸形值交给后端报错) */
|
||||
const DOMAIN_RE = /^[a-z0-9_](?:[a-z0-9_-]*[a-z0-9_])?(?:\.[a-z0-9_](?:[a-z0-9_-]*[a-z0-9_])?)+$/i
|
||||
const IPV4_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/
|
||||
|
||||
/**
|
||||
* 把用户输入的单个 DNS 服务器条目规整成后端认可的形式:
|
||||
* - 已带协议的 URL:路径为空或仅 "/" 时补上 DoH 标准路径 /dns-query
|
||||
* - 无协议但含 "/"(缺了 scheme 的 DoH 地址):补 https:// 前缀,路径按上条处理
|
||||
* - 纯域名 HOST(无端口无路径):包裹为 https://域名/dns-query
|
||||
* - 无端口的裸 IP(v4 / 裸 v6):补默认端口 53(v6 顺手加方括号)
|
||||
* - 其余(host:port、[v6]:53 等)原样返回,交给后端校验
|
||||
*/
|
||||
function normalizeDnsEntry(raw: string): string {
|
||||
const v = raw.trim()
|
||||
if (!v) return ''
|
||||
|
||||
// 1) 已带协议的 URL
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) return completeDohPath(v)
|
||||
// 2) 无协议但含 "/":缺 scheme 的 DoH 地址
|
||||
if (v.includes('/')) return completeDohPath('https://' + v)
|
||||
// 3) 带方括号的 v6(含/含端口均原样,端口要求交后端校验)
|
||||
if (v.startsWith('[')) return v
|
||||
// 4) host:port:IP 或域名都按 UDP 语义原样(写了端口就尊重端口)
|
||||
if (/^[^:]+:\d+$/.test(v)) return v
|
||||
// 5) 无方括号的裸 v6:补 [..]:53
|
||||
if (isIPv6(v)) return `[${v}]:53`
|
||||
// 6) 裸 IPv4:补 :53
|
||||
if (IPV4_RE.test(v)) return `${v}:53`
|
||||
// 7) 纯域名 HOST:包裹为标准 DoH URL
|
||||
if (DOMAIN_RE.test(v)) return `https://${v}/dns-query`
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
/** 补齐 DoH URL 的标准路径:空路径/根路径 → /dns-query;自带路径(如 /custom)原样保留 */
|
||||
function completeDohPath(url: string): string {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
if (!u.hostname) return url
|
||||
if (u.pathname === '/' || u.pathname === '') u.pathname = '/dns-query'
|
||||
return u.toString()
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/** 逗号分隔列表逐项规整(空项丢弃) */
|
||||
function normalizeDnsList(value: string): string {
|
||||
return value
|
||||
.split(',')
|
||||
.map(p => normalizeDnsEntry(p))
|
||||
.filter(Boolean)
|
||||
.join(',')
|
||||
}
|
||||
|
||||
/** 组装请求级解析参数(空值不下发;DNS 服务器逐项规整) */
|
||||
function query(): Record<string, string> {
|
||||
const q: Record<string, string> = {}
|
||||
if (dns.value.trim()) q.dns = normalizeDnsList(dns.value)
|
||||
if (dns4.value.trim()) q.dns4 = normalizeDnsList(dns4.value)
|
||||
if (dns6.value.trim()) q.dns6 = normalizeDnsList(dns6.value)
|
||||
if (props.showResolve) {
|
||||
if (resolve.value.trim()) q.resolve = resolve.value.trim()
|
||||
if (resolve4.value.trim()) q.resolve4 = resolve4.value.trim()
|
||||
if (resolve6.value.trim()) q.resolve6 = resolve6.value.trim()
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
defineExpose({ query })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="adv">
|
||||
<!-- 用原生 button:页面的全局 .el-button{width:165px} 会把 el-button 撑成实心大按钮 -->
|
||||
<button type="button" class="adv__toggle" :aria-expanded="expanded" @click="expanded = !expanded">
|
||||
<span class="adv__caret" :class="{ 'is-open': expanded }">▸</span>
|
||||
<span>高级选项</span>
|
||||
</button>
|
||||
|
||||
<div v-if="expanded" class="adv__body">
|
||||
<!-- 两栈通用的 DNS 服务器(dns4/dns6 非空时覆盖它)。仅双栈页显示:
|
||||
单栈页里 dns= 与 dns4=/dns6= 对该栈请求效果等价(后端按栈取值),
|
||||
再给一个"两栈通用"行只会让人纠结该填哪个 —— 单栈只留对应栈那一列。 -->
|
||||
<div v-if="!stack" class="adv__field adv__field--wide">
|
||||
<span class="adv__label">DNS 服务器(dns,两栈通用)</span>
|
||||
<el-input v-model="dns" clearable placeholder="如 1.1.1.1:53 / doh.pub / dns.google,逗号分隔" />
|
||||
</div>
|
||||
|
||||
<!-- 两栈通用的强制解析(resolve4/6 非空时覆盖它),同上仅双栈页显示 -->
|
||||
<div v-if="!stack && showResolve" class="adv__field adv__field--wide">
|
||||
<span class="adv__label">强制解析(resolve,两栈通用)</span>
|
||||
<el-input v-model="resolve" clearable placeholder="如 example.com:1.2.3.4,逗号分隔" />
|
||||
</div>
|
||||
|
||||
<div class="adv__grid" :class="{ 'is-single': !!stack }">
|
||||
<section v-if="!stack || stack === 'ipv4'" class="adv__col">
|
||||
<h4 class="adv__col-title">IPv4</h4>
|
||||
<div class="adv__field">
|
||||
<span class="adv__label">DNS 服务器(dns4)</span>
|
||||
<el-input v-model="dns4" clearable placeholder="如 1.1.1.1:53 / doh.pub,逗号分隔" />
|
||||
</div>
|
||||
<div v-if="showResolve" class="adv__field">
|
||||
<span class="adv__label">强制解析(resolve4)</span>
|
||||
<el-input v-model="resolve4" clearable placeholder="如 example.com:1.2.3.4" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="!stack || stack === 'ipv6'" class="adv__col">
|
||||
<h4 class="adv__col-title">IPv6</h4>
|
||||
<div class="adv__field">
|
||||
<span class="adv__label">DNS 服务器(dns6)</span>
|
||||
<el-input v-model="dns6" clearable placeholder="如 [2400:3200::1]:53,逗号分隔" />
|
||||
</div>
|
||||
<div v-if="showResolve" class="adv__field">
|
||||
<span class="adv__label">强制解析(resolve6)</span>
|
||||
<el-input v-model="resolve6" clearable placeholder="如 example.com:[::1]" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p class="adv__hint">
|
||||
DNS 服务器可填 <code>ip:port</code>、DoH 地址或纯域名(自动补全为
|
||||
<code>https://域名/dns-query</code>),逗号分隔多个;强制解析格式
|
||||
<code>域名:IP</code> 或 <code>域名:CNAME</code>,IPv6 目标写 <code>[::1]</code>。{{
|
||||
stack ? '参数直接作用于本栈解析;双栈页的 dns / resolve 通用项在此页不展示。'
|
||||
: 'dns4 / dns6、resolve4 / resolve6 非空时覆盖 dns / resolve,分别只作用于对应栈。'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.adv {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.adv__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--el-color-primary, #299764);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.adv__caret {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.adv__caret.is-open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.adv__body {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border: 1px solid var(--el-border-color-lighter, #ebeef5);
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-lighter, #fafafa);
|
||||
}
|
||||
|
||||
.adv__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 1rem;
|
||||
}
|
||||
|
||||
/* 单栈页只渲染一列:撑满,别再留半幅空白 */
|
||||
.adv__grid.is-single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.adv__col-title {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--el-text-color-secondary, #909399);
|
||||
}
|
||||
|
||||
.adv__field {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.adv__field--wide {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.adv__label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--el-text-color-secondary, #909399);
|
||||
}
|
||||
|
||||
.adv__hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-secondary, #909399);
|
||||
}
|
||||
|
||||
.adv__hint code {
|
||||
padding: 0 0.2rem;
|
||||
border-radius: 2px;
|
||||
background: var(--el-fill-color, #f0f2f5);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.adv__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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,78 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
/**
|
||||
* 全站文档图片灯箱:全局点击委托 + Element Plus 自带查看器(el-image-viewer)。
|
||||
*
|
||||
* 在 app.vue 挂载一次即覆盖所有 v-html 文档容器(.markdown / .markdown-body),
|
||||
* 页面无需任何接线。命中规则:容器内的 <img>,且不在 <a> 链接里(放行导航)。
|
||||
* 查看器自带 Esc 关闭、滚轮缩放、旋转工具栏;hide-on-click-modal 允许点遮罩关闭。
|
||||
*/
|
||||
const visible = ref(false)
|
||||
const urlList = ref<string[]>([])
|
||||
const initialIndex = ref(0)
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
const img = target.closest?.<HTMLImageElement>('.markdown img, .markdown-body img')
|
||||
if (!img) return
|
||||
if (img.closest('a')) return // 图片套在链接里 → 放行导航,不开灯箱
|
||||
if (img.complete && img.naturalWidth === 0) return // 加载失败的裂图不进灯箱
|
||||
const src = img.currentSrc || img.src
|
||||
if (!src) return
|
||||
e.preventDefault()
|
||||
|
||||
// 收集同一容器内的全部图片:EP 查看器由此获得左右箭头/键盘多图切换
|
||||
// (doc 页一篇文章带几十张截图,单图模式等于白瞎了查看器的切换能力)
|
||||
const container = img.closest('.markdown, .markdown-body')
|
||||
const imgs = container ? Array.from(container.querySelectorAll('img')) : [img]
|
||||
const list = imgs
|
||||
.filter(i => !(i.complete && i.naturalWidth === 0))
|
||||
.map(i => i.currentSrc || i.src)
|
||||
.filter(Boolean)
|
||||
if (!list.length) return
|
||||
|
||||
urlList.value = list
|
||||
initialIndex.value = Math.max(0, list.indexOf(src))
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
// 打开期间锁 body 滚动(EP 查看器自带 Esc 关闭与缩放/旋转工具栏)
|
||||
watch(visible, (v) => {
|
||||
document.body.style.overflow = v ? 'hidden' : ''
|
||||
})
|
||||
|
||||
// 路由切换时强制关闭:灯箱开着按浏览器后退,查看器不能跨页面残留
|
||||
const route = useRoute()
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
visible.value = false
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => document.addEventListener('click', onClick))
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onClick)
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-image-viewer
|
||||
v-if="visible"
|
||||
:url-list="urlList"
|
||||
:initial-index="initialIndex"
|
||||
:z-index="3000"
|
||||
hide-on-click-modal
|
||||
@close="visible = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 文档图片可点的视觉暗示(非 scoped:v-html 内容拿不到 scope id) */
|
||||
.markdown img,
|
||||
.markdown-body img {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
<script setup lang="ts">
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { ref, onMounted, computed, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index'
|
||||
import { isIPv6 } from 'is-ip'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
useHead({
|
||||
title: `ASN查询 | IP自治系统号查询 | ${config.siteName}`,
|
||||
meta: [
|
||||
{ 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、IP2Location,集成WHOIS解析获取ASN详细信息' },
|
||||
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
],
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebApplication',
|
||||
name: 'ASN自治系统查询工具',
|
||||
description: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP、IP2Location 多数据源对比,集成WHOIS解析。',
|
||||
url: config.siteUrl + 'asn',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
operatingSystem: 'Web',
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'CNY'
|
||||
},
|
||||
provider: {
|
||||
'@type': 'Organization',
|
||||
name: config.siteName
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
interface ASNResult {
|
||||
ip: string
|
||||
lemondb_asn?: { asn: string; org: string }
|
||||
geolite2_asn?: { asn: string; org: string }
|
||||
dbip_asn?: { asn: string; org: string }
|
||||
ip2location_asn?: { asn: string; as: string }
|
||||
whois?: any
|
||||
}
|
||||
|
||||
const tmpIP = ref('1.1.1.1')
|
||||
const loading = ref(false)
|
||||
const result = ref<ASNResult | null>(null)
|
||||
const error = ref('')
|
||||
const userIP = ref('')
|
||||
|
||||
// ASN 页节点池:DualStack ∩ IPLocationAPI 的交集(按 id 取两个池都有的节点)。
|
||||
// 交集保证:节点在 IPLocationAPI 里(中间件 asn 分支按它解析转发),
|
||||
// 同时也是双栈池在册的全功能节点;任一池单独加节点都不会进本页。
|
||||
const apiList = config.IPLocationAPI.filter((loc) =>
|
||||
config.APIBaseURL.DualStack.some((d) => d.id === loc.id)
|
||||
)
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
|
||||
|
||||
const { data: asnData, error: asnError, execute: executeASN } = useMiddlewareFetch<ASNResult>(() => '/middleware/' + backendID.value + '/asn/' + encodeURIComponent(tmpIP.value), {
|
||||
key: 'asn-fetch',
|
||||
immediate: false,
|
||||
watch: false,
|
||||
deep: false,
|
||||
server: false,
|
||||
lazy: true,
|
||||
})
|
||||
|
||||
watch(asnData, (newData) => {
|
||||
if (newData) {
|
||||
result.value = newData
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
watch(asnError, async (newError) => {
|
||||
if (newError) {
|
||||
console.log(newError)
|
||||
if (currentApiIndex.value < apiList.length - 1) {
|
||||
const nextIndex = currentApiIndex.value + 1
|
||||
error.value = `${(newError as any).message || '请求失败'},正在重试 ${apiList[nextIndex]?.label || ''}...`
|
||||
currentApiIndex.value = nextIndex
|
||||
await nextTick()
|
||||
executeASN()
|
||||
} else {
|
||||
error.value = '请求失败,请检查IP或网络'
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function queryASN() {
|
||||
currentApiIndex.value = 0
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
result.value = null
|
||||
tmpIP.value = tmpIP.value.trim()
|
||||
if (!tmpIP.value) {
|
||||
error.value = '请输入IP地址'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
executeASN()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getUserIP()
|
||||
const ipParam = route.query.ip as string
|
||||
if (ipParam) {
|
||||
tmpIP.value = ipParam
|
||||
queryASN()
|
||||
}
|
||||
})
|
||||
|
||||
async function getUserIP() {
|
||||
try {
|
||||
userIP.value = await $fetch<string>(config.DualStackAPI)
|
||||
} catch {
|
||||
// 获取失败保持为空,避免未处理的 Promise 拒绝
|
||||
}
|
||||
return userIP.value
|
||||
}
|
||||
|
||||
function getStatusClass(status: string): string {
|
||||
if (status.includes('prohibited') || status.includes('lock')) return 'status-warning'
|
||||
return 'status-success'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="title">
|
||||
<header>
|
||||
<h1>ASN 自治系统查询</h1>
|
||||
<p>查询 IP 地址所属的自治系统号(ASN)和组织信息</p>
|
||||
</header>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="tmpIP"
|
||||
placeholder="请输入IP地址(如:1.1.1.1)"
|
||||
clearable
|
||||
/>
|
||||
<el-button
|
||||
@click="queryASN()"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
>
|
||||
查询
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="result" class="result-section">
|
||||
<table class="result-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="table-header">项目</th>
|
||||
<th class="table-header">信息</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="table-label">IP 地址</td>
|
||||
<td class="table-value">{{ result.ip }}</td>
|
||||
</tr>
|
||||
<tr v-if="result.lemondb_asn?.asn">
|
||||
<td class="table-label">LemonDB ASN</td>
|
||||
<td class="table-value">
|
||||
{{ result.lemondb_asn.asn }}
|
||||
<span v-if="result.lemondb_asn.org" style="color: #999; font-size: 0.9em;">{{ result.lemondb_asn.org }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="result.geolite2_asn?.asn">
|
||||
<td class="table-label">Maxmind ASN</td>
|
||||
<td class="table-value">
|
||||
{{ result.geolite2_asn.asn }}
|
||||
<span v-if="result.geolite2_asn.org" style="color: #999; font-size: 0.9em;">{{ result.geolite2_asn.org }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="result.dbip_asn?.asn">
|
||||
<td class="table-label">DB-IP ASN</td>
|
||||
<td class="table-value">
|
||||
{{ result.dbip_asn.asn }}
|
||||
<span v-if="result.dbip_asn.org" style="color: #999; font-size: 0.9em;">{{ result.dbip_asn.org }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="result.ip2location_asn?.asn">
|
||||
<td class="table-label">IP2Location ASN</td>
|
||||
<td class="table-value">
|
||||
AS{{ result.ip2location_asn.asn }}
|
||||
<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>
|
||||
</tr>
|
||||
<tr v-if="result.whois?.asName">
|
||||
<td class="table-label">AS 名称</td>
|
||||
<td class="table-value">{{ result.whois.asName }}</td>
|
||||
</tr>
|
||||
<tr v-if="result.whois?.orgName">
|
||||
<td class="table-label">组织名称</td>
|
||||
<td class="table-value">{{ result.whois.orgName }}</td>
|
||||
</tr>
|
||||
<tr v-if="result.whois?.orgId">
|
||||
<td class="table-label">组织 ID</td>
|
||||
<td class="table-value">{{ result.whois.orgId }}</td>
|
||||
</tr>
|
||||
<tr v-if="result.whois?.country">
|
||||
<td class="table-label">国家</td>
|
||||
<td class="table-value">{{ result.whois.country }}</td>
|
||||
</tr>
|
||||
<tr v-if="result.whois?.regDate">
|
||||
<td class="table-label">注册日期</td>
|
||||
<td class="table-value">{{ result.whois.regDate }}</td>
|
||||
</tr>
|
||||
<tr v-if="result.whois?.updated">
|
||||
<td class="table-label">更新日期</td>
|
||||
<td class="table-value">{{ result.whois.updated }}</td>
|
||||
</tr>
|
||||
<tr v-if="result.whois?.abuseEmail">
|
||||
<td class="table-label">Abuse 邮箱</td>
|
||||
<td class="table-value">{{ result.whois.abuseEmail }}</td>
|
||||
</tr>
|
||||
<tr v-if="result.whois?.abusePhone">
|
||||
<td class="table-label">Abuse 电话</td>
|
||||
<td class="table-value">{{ result.whois.abusePhone }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<blockquote>
|
||||
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/>
|
||||
访客IP: {{ userIP || '获取中...' }},您的网络{{ isIPv6(userIP) ? 'IPv6' : 'IPv4' }}优先<br/>
|
||||
</blockquote>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../style.css";
|
||||
@import "../../assets/css/tool-common.css";
|
||||
|
||||
.el-menu--horizontal > .el-menu-item:nth-child(1) {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.result-table .table-label {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #F56C6C;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
}
|
||||
</style>
|
||||
@@ -2,13 +2,14 @@
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { isIPv6 } from 'is-ip';
|
||||
import { renderMarkdown } from "../../utils/markdown";
|
||||
import { formatTime } from '../../utils/tools';
|
||||
import { formatTime, withQuery } from '../../utils/tools';
|
||||
import AdvancedOptions from '../components/AdvancedOptions.vue';
|
||||
const route = useRoute()
|
||||
|
||||
useHead({
|
||||
title: 'DNS查询工具 | 多节点域名解析检测 | 柠檬味ipw.cn',
|
||||
title: `DNS查询工具 | 多节点域名解析检测 | ${config.siteName}`,
|
||||
meta: [
|
||||
{ name: 'description', content: '专业的多节点DNS查询工具,支持A记录、AAAA记录、CNAME记录、MX记录、NS记录、TXT记录、SRV记录、CAA记录等多种DNS解析记录查询,提供全国多节点并发检测,快速返回DNS解析结果,助力域名解析问题排查与优化' },
|
||||
{ name: 'keywords', content: 'dns查询,dns解析,域名解析,a记录查询,aaaa记录,cname记录,mx记录,ns记录,txt记录,srv记录,dns服务器,域名dns检测' },
|
||||
@@ -35,7 +36,7 @@ useHead({
|
||||
},
|
||||
provider: {
|
||||
'@type': 'Organization',
|
||||
name: '柠檬味ipw.cn'
|
||||
name: config.siteName
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -50,13 +51,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,9 +75,16 @@ const recordTypes = [
|
||||
{ value: 'ptr', label: 'PTR 记录' }
|
||||
]
|
||||
|
||||
const dnsServerFetches = config.NSLookup.map((server) => {
|
||||
const url = computed(() => server.url + 'v1/dns/' + recordType.value + "/" + domain.value);
|
||||
const { data, error, execute } = useFetch(url, {
|
||||
// 高级选项:DNS 拨测问的就是真实解析结果,只取 DNS 服务器(dns4/dns6),不支持强制解析
|
||||
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
|
||||
|
||||
const dnsServerFetches = [
|
||||
...config.APIBaseURL.DualStack,
|
||||
...config.APIBaseURL.IPv4,
|
||||
...config.APIBaseURL.IPv6
|
||||
].map((server) => {
|
||||
const url = computed(() => withQuery("/middleware/" + server.id + "/dns/" + recordType.value + "/" + encodeURIComponent(domain.value), adv.value?.query() ?? {}));
|
||||
const { data, error, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
@@ -85,7 +95,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 +109,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 +130,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 +144,7 @@ async function queryDNS() {
|
||||
data: null,
|
||||
error: err
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
server: fetch.label,
|
||||
error: err
|
||||
@@ -126,14 +152,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
|
||||
@@ -166,9 +203,11 @@ const doc = page.value;
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入域名(如:example.com)"
|
||||
placeholder="请输入域名(如:example.com)"
|
||||
clearable
|
||||
/>
|
||||
<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"
|
||||
@@ -185,6 +224,9 @@ const doc = page.value;
|
||||
查询
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" :show-resolve="false" />
|
||||
|
||||
<div class="result-section">
|
||||
<table class="result-table" v-if="results.length > 0">
|
||||
<thead>
|
||||
@@ -204,7 +246,10 @@ const doc = page.value;
|
||||
<td class="table-label">{{result.server}}</td>
|
||||
<td class="table-value">{{nowRecordType.toUpperCase() || '--'}}</td>
|
||||
<td class="table-value" v-if="result.loading" colspan="4" style="text-align: left;">加载中...</td>
|
||||
<td class="table-value" v-if="!result.loading">
|
||||
<td class="table-value" v-else-if="result.error" colspan="4" style="color: #F56C6C;">
|
||||
{{ result.error?.message || '请求失败' }}
|
||||
</td>
|
||||
<td class="table-value" v-else-if="!result.loading">
|
||||
<template v-if="result && result.data?.record">
|
||||
<div v-for="(ip, index) in result.data.record.slice(0, 5)" :key="index" class="ip-address">
|
||||
<span>{{ ip }}</span>
|
||||
@@ -216,9 +261,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>
|
||||
|
||||
@@ -239,7 +286,7 @@ const doc = page.value;
|
||||
|
||||
<span class="quate">NS</span> 域名的 DNS 服务器地址,例如 <span class="quate">ns3.dnsv2.com</span>,推荐 华为云DNS.<br/>
|
||||
|
||||
网站开启IPv6检测 网站开启IPv6检测 | SSL证书在线检查<br/>
|
||||
<a href="/ipv6webcheck" target="_blank">网站开启IPv6检测</a> | <a href="/ssl" target="_blank">SSL证书在线检查</a><br/>
|
||||
|
||||
访客IP: {{userIP }},您的网络 {{ isIPv6(userIP) ? 'IPv6' : 'IPv4'}} 访问优先<br/>
|
||||
</blockquote>
|
||||
@@ -254,12 +301,30 @@ const doc = page.value;
|
||||
@import "../style.css";
|
||||
@import "../../assets/css/tool-common.css";
|
||||
|
||||
/* 本页行内容比通用行宽(input 420 + select 150 + button 165 + 间距 ≈ 765px),
|
||||
换行点更高:容器低于 780px(≈自然换行点)时换行元素铺满整行,
|
||||
消除 615~780px 之间「换行但仍固定宽」的夹缝(tool-common 的通用阈值只覆盖到 615px)。
|
||||
select 的 width 是行内 style,需 !important 才能覆盖。 */
|
||||
@container (max-width: 780px) {
|
||||
.el-input,
|
||||
.el-select,
|
||||
.el-button {
|
||||
width: 100% !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.el-input,
|
||||
.el-select {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu--horizontal > .el-menu-item:nth-child(1) {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.markdown :deep(a) {
|
||||
color: #3EAF7C !important;
|
||||
color: #299764 !important;
|
||||
font-size: 1.3em;
|
||||
text-decoration: none
|
||||
}
|
||||
@@ -267,11 +332,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 {
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
<script setup lang="ts">
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index'
|
||||
import { isIPv6 } from 'is-ip'
|
||||
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
useHead({
|
||||
title: `DNSSEC查询 | 域名DNSSEC验证检测 | ${config.siteName}`,
|
||||
meta: [
|
||||
{ name: 'description', content: '专业的DNSSEC查询工具,支持域名DNSSEC验证检测,检查域名是否启用DNSSEC签名,验证DNSKEY、RRSIG、DS记录链式信任关系,检测域名DNS数据完整性和真实性,防止DNS欺骗和中间人攻击,保障域名解析安全' },
|
||||
{ name: 'keywords', content: 'dnssec查询,dnssec验证,dnskey,rrsig,ds记录,域名安全,链式信任,dns欺骗防护,域名解析安全,dns签名验证' },
|
||||
{ property: 'og:title', content: 'DNSSEC查询工具 - 域名DNSSEC验证与安全检测' },
|
||||
{ property: 'og:description', content: 'DNSSEC验证检测工具,检查域名DNSSEC签名状态,验证DNSKEY、RRSIG、DS记录链式信任关系' },
|
||||
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
],
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebApplication',
|
||||
name: 'DNSSEC验证查询工具',
|
||||
description: '专业的DNSSEC验证查询工具,检测域名DNSSEC签名状态,验证DNSKEY、RRSIG、DS记录链式信任关系,保障域名解析安全。',
|
||||
url: config.siteUrl + 'dnssec',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
operatingSystem: 'Web',
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'CNY'
|
||||
},
|
||||
provider: {
|
||||
'@type': 'Organization',
|
||||
name: config.siteName
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
interface DNSSECResult {
|
||||
domain: string
|
||||
enabled: boolean
|
||||
valid: boolean
|
||||
has_rrsig: boolean
|
||||
has_dnskey: boolean
|
||||
has_ds: boolean
|
||||
algorithm: number
|
||||
key_tag: number
|
||||
signer_name: string
|
||||
validation: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
interface ServerResult {
|
||||
label: string
|
||||
loading: boolean
|
||||
error?: string
|
||||
data?: DNSSECResult
|
||||
}
|
||||
|
||||
const tmpDomain = ref('www.zakoflare.com')
|
||||
const loading = ref(false)
|
||||
const serverResults = ref<ServerResult[]>([])
|
||||
const userIP = ref('')
|
||||
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
|
||||
let queryId = 0
|
||||
|
||||
const apiList = [
|
||||
...config.APIBaseURL.DualStack,
|
||||
...config.APIBaseURL.IPv4,
|
||||
...config.APIBaseURL.IPv6
|
||||
]
|
||||
|
||||
const dnssecFetches = apiList.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/dnssec/" + encodeURIComponent(tmpDomain.value.trim()))
|
||||
const { data, error, execute } = useMiddlewareFetch<DNSSECResult>(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
})
|
||||
return { label: server.label, data, error, execute }
|
||||
})
|
||||
|
||||
function initServerResults() {
|
||||
serverResults.value = apiList.map((server) => ({
|
||||
label: server.label,
|
||||
loading: false,
|
||||
}))
|
||||
}
|
||||
|
||||
async function checkDNSSEC() {
|
||||
const domain = tmpDomain.value.trim()
|
||||
if (!domain) return
|
||||
|
||||
loading.value = true
|
||||
|
||||
const id = ++queryId
|
||||
|
||||
serverResults.value.forEach((result) => {
|
||||
result.loading = true
|
||||
result.data = undefined
|
||||
})
|
||||
|
||||
const promises = dnssecFetches.map(async (fetch, index) => {
|
||||
try {
|
||||
await fetch.execute()
|
||||
if (id !== queryId) return
|
||||
const result = serverResults.value[index]
|
||||
if (result) {
|
||||
// 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)
|
||||
const result = serverResults.value[index]
|
||||
if (result) {
|
||||
result.error = (err as any)?.message || '请求失败'
|
||||
}
|
||||
} finally {
|
||||
const result = serverResults.value[index]
|
||||
if (result && id === queryId) {
|
||||
result.loading = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Promise.allSettled(promises).finally(function () {
|
||||
if (id === queryId) {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getValidationClass(result: DNSSECResult): string {
|
||||
if (result.valid) return 'status-success'
|
||||
if (result.has_dnskey || result.has_rrsig) return 'status-warning'
|
||||
return 'status-error'
|
||||
}
|
||||
|
||||
function formatDuration(ms?: number): string {
|
||||
return typeof ms === 'number' && isFinite(ms) ? ms.toFixed(2) + ' ms' : '-'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initServerResults()
|
||||
getUserIP()
|
||||
const domainParam = route.query.domain as string
|
||||
if (domainParam) {
|
||||
tmpDomain.value = domainParam
|
||||
checkDNSSEC()
|
||||
}
|
||||
})
|
||||
|
||||
async function getUserIP() {
|
||||
try {
|
||||
userIP.value = await $fetch<string>(config.DualStackAPI)
|
||||
} catch {
|
||||
// 获取失败保持为空,避免未处理的 Promise 拒绝
|
||||
}
|
||||
return userIP.value
|
||||
}
|
||||
|
||||
const { data: page, error } = await useAsyncData('dnssec-page', () =>
|
||||
$fetch('/api/markdown/dnssec')
|
||||
)
|
||||
const doc = page.value;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="title">
|
||||
<header>
|
||||
<h1>DNSSEC 验证查询</h1>
|
||||
<p>检测域名 DNSSEC 签名状态和链式信任验证</p>
|
||||
</header>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入域名(如:example.com)"
|
||||
clearable
|
||||
/>
|
||||
<el-button
|
||||
@click="checkDNSSEC()"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
>
|
||||
验证
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="result-section" v-if="serverResults.some(r => r.data)">
|
||||
<table class="result-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="table-header">DNS 服务器</th>
|
||||
<th class="table-header">DNSSEC</th>
|
||||
<th class="table-header">DNSKEY</th>
|
||||
<th class="table-header">RRSIG</th>
|
||||
<th class="table-header">DS</th>
|
||||
<th class="table-header">验证结果</th>
|
||||
<th class="table-header">耗时</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="(server, index) in serverResults" :key="index">
|
||||
<tr v-if="server.data">
|
||||
<td class="table-label">{{ server.label }}</td>
|
||||
<td class="table-value">
|
||||
<span :class="server.data.enabled ? 'status-success' : 'status-error'" class="status-code">
|
||||
{{ server.data.enabled ? '已启用' : '未启用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="table-value">
|
||||
<span :class="server.data.has_dnskey ? 'status-success' : 'status-error'" class="status-code">
|
||||
{{ server.data.has_dnskey ? '有' : '无' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="table-value">
|
||||
<span :class="server.data.has_rrsig ? 'status-success' : 'status-error'" class="status-code">
|
||||
{{ server.data.has_rrsig ? '有' : '无' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="table-value">
|
||||
<span :class="server.data.has_ds ? 'status-success' : 'status-error'" class="status-code">
|
||||
{{ server.data.has_ds ? '有' : '无' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="table-value">
|
||||
<span :class="getValidationClass(server.data)" class="status-code">
|
||||
{{ server.data.valid ? '验证通过' : '验证失败' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="table-value">{{ formatDuration(server.data.duration) }}</td>
|
||||
</tr>
|
||||
<tr v-else-if="server.loading">
|
||||
<td class="table-label">{{ server.label }}</td>
|
||||
<td class="table-value" colspan="6">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else-if="server.error">
|
||||
<td class="table-label">{{ server.label }}</td>
|
||||
<td class="table-value error-text" colspan="6">{{ server.error }}</td>
|
||||
</tr>
|
||||
<tr v-else-if="!server.data">
|
||||
<td class="table-label">{{ server.label }}</td>
|
||||
<td class="table-value" colspan="6">-</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="markdown">
|
||||
<div v-html="doc"></div>
|
||||
</div>
|
||||
|
||||
<blockquote>
|
||||
如果显示 "验证失败",可能原因:域名未配置 DNSSEC、DNSKEY 密钥不匹配、DS 记录缺失等(详见上方原理介绍)。<br/>
|
||||
<a href="/dns" target="_blank">DNS 解析查询</a> | <a href="/whois" target="_blank">Whois 查询</a><br/>
|
||||
访客IP: {{ userIP || '获取中...' }},您的网络{{ isIPv6(userIP) ? 'IPv6' : 'IPv4' }}优先<br/>
|
||||
</blockquote>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../style.css";
|
||||
@import "../../assets/css/tool-common.css";
|
||||
|
||||
.markdown :deep(a) {
|
||||
color: #299764 !important;
|
||||
font-size: 1.3em;
|
||||
text-decoration: none
|
||||
}
|
||||
/* 1240px 宽的验证链图必须缩进 60% 的内容列,桌面端也要约束(仅移动端限制不够) */
|
||||
.markdown :deep(img) {
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.markdown :deep(a) {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.markdown :deep(img) {
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu--horizontal > .el-menu-item:nth-child(1) {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.result-table .table-label {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #F56C6C;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,85 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { getDocMeta } from '../../../config/doc'
|
||||
import { config } from '../../../config/index'
|
||||
|
||||
// 窄屏下这条侧栏被 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))
|
||||
// <title>/meta 优先取 seoXxx 加长版(Bing 审计判文档页标题与描述过短);
|
||||
// 侧栏菜单与卡片仍读原字段展示,不受影响。
|
||||
const seoTitle = computed(() => meta.value.seoTitle ?? meta.value.title)
|
||||
const seoDescription = computed(() => meta.value.seoDescription ?? meta.value.description)
|
||||
const docUrl = computed(() => new URL(route.path, config.siteUrl).href)
|
||||
useHead({
|
||||
title: computed(() => meta.value.title),
|
||||
title: seoTitle,
|
||||
titleTemplate: computed(() => `%s | ${config.siteName}`),
|
||||
meta: [
|
||||
{ name: 'description', content: computed(() => meta.value.description) }
|
||||
{ name: 'description', content: seoDescription },
|
||||
{ property: 'og:title', content: computed(() => `${seoTitle.value} | ${config.siteName}`) },
|
||||
{ property: 'og:description', content: seoDescription },
|
||||
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
|
||||
{ property: 'og:type', content: 'article' },
|
||||
],
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: computed(() => JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: '首页', item: config.siteUrl },
|
||||
{ '@type': 'ListItem', position: 2, name: '文档', item: new URL('/doc', config.siteUrl).href },
|
||||
{ '@type': 'ListItem', position: 3, name: meta.value.title, item: docUrl.value }
|
||||
]
|
||||
}))
|
||||
}
|
||||
]
|
||||
})
|
||||
</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 +87,10 @@ html.dark .content {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* 窄屏不再挤一条窄侧栏(原先 100px,长标题全被截断):
|
||||
文档导航已并入左侧抽屉菜单,这里直接撤掉,正文铺满整屏 */
|
||||
.sidebar-menu {
|
||||
width: 100px;
|
||||
display: none;
|
||||
}
|
||||
.content {
|
||||
padding: 10px;
|
||||
@@ -129,5 +99,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,86 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { getDocMeta } from '../../../config/doc'
|
||||
import { config } from '../../../config/index'
|
||||
|
||||
// 窄屏(≤768px)下这条侧栏被 CSS 整个隐藏,导航改由顶栏抽屉承载。
|
||||
// 但「只是藏起来」不等于不花钱:Vue 依旧会把整棵 40 项的 el-menu 水合一遍
|
||||
// (实测手机端 doc-sidebar 区域 53 次事件绑定)。所以直接按判定结果不渲染。
|
||||
// 这里用的是全局共享的窄屏状态(服务端按请求头判定并随 payload 下发),
|
||||
// 与 app.vue 同一份 —— 首帧与服务端 HTML 一致,不会有水合分歧。
|
||||
const isNarrow = useShellNarrow()
|
||||
|
||||
const route = useRoute();
|
||||
const { data: page } = await useAsyncData('doc-index', () =>
|
||||
$fetch('/api/markdown/doc/index')
|
||||
)
|
||||
const doc = page.value;
|
||||
const activeIndex = computed(() => route.path)
|
||||
|
||||
const meta = getDocMeta('/doc')
|
||||
// <title>/meta 优先取 seoXxx 加长版(Bing 审计判文档页标题与描述过短);
|
||||
// 侧栏菜单与卡片仍读原字段展示,不受影响。
|
||||
const seoTitle = meta.seoTitle ?? meta.title
|
||||
const seoDescription = meta.seoDescription ?? meta.description
|
||||
useHead({
|
||||
title: meta.title,
|
||||
title: seoTitle,
|
||||
titleTemplate: `%s | ${config.siteName}`,
|
||||
meta: [
|
||||
{ name: 'description', content: meta.description }
|
||||
{ name: 'description', content: seoDescription },
|
||||
{ property: 'og:title', content: `${seoTitle} | ${config.siteName}` },
|
||||
{ property: 'og:description', content: seoDescription },
|
||||
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
],
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: '首页', item: config.siteUrl },
|
||||
{ '@type': 'ListItem', position: 2, name: meta.title, item: new URL('/doc', config.siteUrl).href }
|
||||
]
|
||||
})
|
||||
}
|
||||
]
|
||||
})
|
||||
</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 +86,10 @@ html.dark .content {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* 窄屏不再挤一条窄侧栏(原先 100px,长标题全被截断):
|
||||
文档导航已并入左侧抽屉菜单,这里直接撤掉,正文铺满整屏 */
|
||||
.sidebar-menu {
|
||||
width: 100px;
|
||||
display: none;
|
||||
}
|
||||
.content {
|
||||
padding: 10px;
|
||||
@@ -128,7 +98,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,36 +3,58 @@ 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());
|
||||
// canonical 由 app.vue 全局下发(见下方 useHead 注释)。
|
||||
|
||||
useHead({
|
||||
title: '柠檬味ipw.cn | IP查询工具 | IPv4/IPv6地址查询与网络测试平台',
|
||||
title: `${config.siteName} | IP查询工具 | IPv4/IPv6地址查询与网络测试平台`,
|
||||
titleTemplate: '%s',
|
||||
link: [
|
||||
{ rel: 'canonical', href: canonicalUrl.value }
|
||||
],
|
||||
// canonical / og:url / twitter:card 由 app.vue 全局下发(按纯路径去 query),
|
||||
// 这里不再重复声明,避免 head 里出现两条 canonical。
|
||||
meta: [
|
||||
{ name: 'description', content: '柠檬味ipw.cn提供专业的IP查询服务,支持IPv4和IPv6地址在线查询、归属地定位、网络测速、DNS解析、SSL证书检测、TCPing测试等多种网络工具,致力于推进IPv6规模部署和应用,打造去中心化的IP查询平台' },
|
||||
{ name: 'description', content: `${config.siteName}提供专业的IP查询服务,支持IPv4和IPv6地址在线查询、归属地定位、网络测速、DNS解析、SSL证书检测、TCPing测试等多种网络工具,致力于推进IPv6规模部署和应用,打造去中心化的IP查询平台` },
|
||||
{ name: 'keywords', content: 'ipv6,ipv4,ip查询,ipv6查询,ipv4查询,ipv6地址查询,ipv4地址查询,网络测速,DNS查询,SSL检测,TCPing,IP归属地,IPv6优先' },
|
||||
{ property: 'og:title', content: '柠檬味ipw.cn - 专业IP查询与网络测试工具平台' },
|
||||
{ property: 'og:title', content: `${config.siteName} - 专业IP查询与网络测试工具平台` },
|
||||
{ property: 'og:description', content: '提供IPv4/IPv6地址查询、网络测速、DNS解析、SSL检测等全方位网络诊断工具,助力IPv6普及与部署' },
|
||||
{ property: 'og:image', content: `${config.siteUrl}favicon.svg` },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
{ property: 'og:url', content: canonicalUrl.value },
|
||||
{ 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({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebApplication',
|
||||
name: '柠檬味ipw.cn',
|
||||
description: '柠檬味ipw.cn提供专业的IP查询服务,支持IPv4和IPv6地址在线查询、归属地定位、网络测速、DNS解析、SSL证书检测、TCPing测试等多种网络工具,致力于推进IPv6规模部署和应用,打造去中心化的IP查询平台',
|
||||
url: canonicalUrl.value,
|
||||
name: config.siteName,
|
||||
description: `${config.siteName}提供专业的IP查询服务,支持IPv4和IPv6地址在线查询、归属地定位、网络测速、DNS解析、SSL证书检测、TCPing测试等多种网络工具,致力于推进IPv6规模部署和应用,打造去中心化的IP查询平台`,
|
||||
url: new URL(route.path, config.siteUrl).toString(),
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
operatingSystem: 'Any',
|
||||
offers: {
|
||||
@@ -75,33 +97,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,19 +194,19 @@ 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)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h3>
|
||||
<h3 v-else-if="ipAddress && isIPv4(ipAddress)"><el-icon><CircleCloseFilled style="color: red;"/></el-icon>您的网络IPv4优先</h3>
|
||||
<h3 v-else><el-icon><CircleCloseFilled /></el-icon>查询中,请稍后</h3>
|
||||
<div class="ip-priority">
|
||||
<h2 v-if="ipAddress && isIPv6(ipAddress)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h2>
|
||||
<h2 v-else-if="ipAddress && isIPv4(ipAddress)"><el-icon><CircleCloseFilled style="color: red;"/></el-icon>您的网络IPv4优先</h2>
|
||||
<h2 v-else><el-icon><CircleCloseFilled /></el-icon>查询中,请稍后</h2>
|
||||
</div>
|
||||
<blockquote>
|
||||
手机默认开启 IPv6,宽带开启 IPv6 请自行搜索 (我们没有文档)
|
||||
手机默认开启 IPv6,宽带开启 IPv6 请参阅<a href="/doc/user/enable_ipv6" target="_blank">文档</a>
|
||||
</blockquote>
|
||||
|
||||
<div v-if="highlightedCode" v-html="highlightedCode" class="code-block"></div>
|
||||
@@ -141,6 +220,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 +263,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,395 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick, watch } from 'vue';
|
||||
import { isIPv6 } from 'is-ip';
|
||||
import {config} from '../../config/index';
|
||||
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
|
||||
import { useRoute } from 'vue-router'
|
||||
import { highlightCode } from '../../utils/shiki'
|
||||
import { extractHost, isIPv4 } from '../../utils/tools';
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
|
||||
useHead({
|
||||
title: 'IPv6/IPv4地址查询工具 | IP归属地定位 | 柠檬味ipw.cn',
|
||||
meta: [
|
||||
{ name: 'description', content: '专业的IPv6/IPv4地址查询工具,支持IPv4和IPv6地址归属地查询,提供BiliBili Live、GeoCN、IP2Region、Maxmind等多种数据源对比,精确定位IP地理位置、运营商信息,支持中国大陆及境外地址查询,助力IPv6普及与应用' },
|
||||
{ property: 'og:title', content: 'IPv6/IPv4地址归属地查询工具 - 柠檬味ipw.cn' },
|
||||
{ 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 remoteAPI = computed(() => apiList[currentApiIndex.value].url)
|
||||
const ipAddress = ref('');
|
||||
const IPLocation = ref<IPLocationType>({});
|
||||
const UserIP = ref('');
|
||||
const locationUrl = computed(() => remoteAPI.value + "v1/location/" + ipAddress.value);
|
||||
|
||||
const { data: locationData, error: locationError, execute: executeLocation } = useFetch<IPLocationType>(locationUrl, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
|
||||
watch(locationData, (newData) => {
|
||||
if (newData) {
|
||||
IPLocation.value = newData;
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
watch(locationError, (newError) => {
|
||||
if (newError) {
|
||||
console.error('Error fetching IP location:', newError);
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function locateIP(IP: string){
|
||||
ipAddress.value = IP;
|
||||
loading.value = true;
|
||||
nextTick(() => {
|
||||
executeLocation();
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
const urlParam = route.query.ip;
|
||||
await navigateTo({ path: '/location', query: { ip: urlParam } });
|
||||
html.value = await highlightCode(code, 'bash')
|
||||
if (urlParam) {
|
||||
ipAddress.value = urlParam as string;
|
||||
locateIP(urlParam as string);
|
||||
}else{
|
||||
$fetch<string>(config.DualStackAPI).then(
|
||||
function(data) {
|
||||
ipAddress.value = data;
|
||||
|
||||
UserIP.value = data;
|
||||
}
|
||||
).catch(
|
||||
function(error) {
|
||||
console.error('Error fetching IP address:', error);
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="title">
|
||||
<header>
|
||||
<h1>IPv6/IPv4地址归属地查询</h1>
|
||||
<p>极简的IPv6地址查询工具,致力于普及 IPv6</p>
|
||||
</header>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="ipAddress"
|
||||
placeholder="请输入IP地址"
|
||||
/>
|
||||
<el-button
|
||||
@click="locateIP(ipAddress)"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
|
||||
>
|
||||
查询
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="location">
|
||||
<div class="one-line" style="height: 40px;">
|
||||
<b>IP</b> <p>{{ ipAddress }}</p>
|
||||
</div>
|
||||
<div v-if="IPLocation" class="result-section">
|
||||
<table class="result-table">
|
||||
<tbody>
|
||||
<tr v-if="IPLocation.bilibili && (IPLocation.bilibili.administrative_area || IPLocation.bilibili.city)">
|
||||
<td class="table-label">bilibili Live接口</td>
|
||||
<td class="table-value">
|
||||
<span>
|
||||
{{ IPLocation.bilibili?.country }} {{ IPLocation.bilibili?.administrative_area }} {{ IPLocation.bilibili?.city }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="table-value">
|
||||
<span>
|
||||
{{ IPLocation.bilibili?.isp }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.ip2region && IPLocation.ip2region.split('|').length >= 4">
|
||||
<td class="table-label">IP2Region</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.split("|")[0] }} {{ IPLocation.ip2region?.split("|")[1] }} {{ IPLocation.ip2region?.split("|")[2] }}</td>
|
||||
<td class="table-value">{{ IPLocation.ip2region?.split("|")[3] }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.geocn && (IPLocation.geocn.administrative_area || IPLocation.geocn.city || IPLocation.geocn.district)">
|
||||
<td class="table-label">GeoCN(仅中国大陆)</td>
|
||||
<td class="table-value">{{ IPLocation.geocn?.administrative_area }} {{ IPLocation.geocn?.city }} {{ IPLocation.geocn?.district }}</td>
|
||||
<td class="table-value">{{ IPLocation.geocn?.isp }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.maxmind_city && IPLocation.maxmind_asn && (IPLocation.maxmind_city.country || IPLocation.maxmind_city.city)">
|
||||
<td class="table-label">Maxmind GEOLite2 City</td>
|
||||
<td class="table-value">{{ IPLocation.maxmind_city?.country }} {{ IPLocation.maxmind_city?.administrative_area }} {{ IPLocation.maxmind_city?.city }}</td>
|
||||
<td class="table-value">{{ IPLocation.maxmind_asn?.org }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.qqwry && (IPLocation.qqwry.country || IPLocation.qqwry.administrative_area || IPLocation.qqwry.city)">
|
||||
<td class="table-label">纯真社区库</td>
|
||||
<td class="table-value">{{ IPLocation.qqwry?.country }} {{ IPLocation.qqwry?.administrative_area }} {{ IPLocation.qqwry?.city }}</td>
|
||||
<td class="table-value">{{ IPLocation.qqwry?.isp }}</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.dbip_city && (IPLocation.dbip_city.administrative_area || IPLocation.dbip_city.city)">
|
||||
<td class="table-label">DB-IP City</td>
|
||||
<td class="table-value">{{ IPLocation.dbip_city?.country }} {{ IPLocation.dbip_city?.administrative_area }} {{ IPLocation.dbip_city?.city }}</td>
|
||||
<td class="table-value">--</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>
|
||||
|
||||
|
||||
|
||||
</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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -2,13 +2,15 @@
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { extractHost, formatTime, formatSpeed, formatSize, getStatusCodeClass } from '../../utils/tools';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { extractHost, formatTime, formatSpeed, formatSize, getStatusCodeClass, withQuery } from '../../utils/tools';
|
||||
import AdvancedOptions from '../components/AdvancedOptions.vue';
|
||||
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
useHead({
|
||||
title: 'IPv6网站测速工具 | 全国多节点并发测速 | 柠檬味ipw.cn',
|
||||
title: `IPv6网站测速工具 | 全国多节点并发测速 | ${config.siteName}`,
|
||||
meta: [
|
||||
{ name: 'description', content: '专业的IPv6网站测速工具,提供全国多节点并发测速服务,1秒内快速返回网站响应时间、下载速度、页面大小、DNS解析时间、HTTP连接时间等详细信息,支持IPv6网站性能检测与优化,助力IPv6网站性能监控与用户体验提升' },
|
||||
{ name: 'keywords', content: 'ipv6网站测速,ipv6测速,网站速度测试,ipv6性能检测,网站响应时间,ipv6下载速度,ipv6性能优化,ipv6网站监控' },
|
||||
@@ -35,7 +37,7 @@ useHead({
|
||||
},
|
||||
provider: {
|
||||
'@type': 'Organization',
|
||||
name: '柠檬味ipw.cn'
|
||||
name: config.siteName
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -47,15 +49,20 @@ const loading = ref(false)
|
||||
const result = ref<any[]>([])
|
||||
const error = ref('')
|
||||
const domain = ref('')
|
||||
// 高级选项(dns6 / resolve / resolve6):用户填了才拼进请求
|
||||
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
|
||||
|
||||
/** 服务器结果接口 */
|
||||
interface ServerResult {
|
||||
label: string
|
||||
loading: boolean
|
||||
error: string
|
||||
error?: string | Error
|
||||
data: any
|
||||
}
|
||||
|
||||
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
|
||||
let queryId = 0
|
||||
|
||||
/** 预构建的服务器列表,页面挂载即展示,用户可提前看到测速节点 */
|
||||
const serverResults = ref<ServerResult[]>([])
|
||||
|
||||
@@ -67,9 +74,9 @@ const serverResults = ref<ServerResult[]>([])
|
||||
// =============================================
|
||||
|
||||
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
|
||||
const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
|
||||
const url = computed(() => server.url + 'v1/speed/v6/' + domain.value);
|
||||
const { data, error: fetchError, execute } = useFetch(url, {
|
||||
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
|
||||
const url = computed(() => withQuery("/middleware/" + server.id + "/speed/v6/" + encodeURIComponent(domain.value), adv.value?.query() ?? {}));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
@@ -77,9 +84,9 @@ const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
|
||||
});
|
||||
|
||||
/** IPv6 专用服务器:仅支持 IPv6 的节点 */
|
||||
const ipv6Fetches = config.SpeedTest.IPv6.map((server) => {
|
||||
const url = computed(() => server.url + 'v1/speed/v6/' + domain.value);
|
||||
const { data, error: fetchError, execute } = useFetch(url, {
|
||||
const ipv6Fetches = config.APIBaseURL.IPv6.map((server) => {
|
||||
const url = computed(() => withQuery("/middleware/" + server.id + "/speed/v6/" + encodeURIComponent(domain.value), adv.value?.query() ?? {}));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
@@ -115,6 +122,8 @@ async function SpeedTest() {
|
||||
error.value = ''
|
||||
await nextTick()
|
||||
|
||||
const id = ++queryId
|
||||
|
||||
// 重置所有行状态为加载中
|
||||
serverResults.value.forEach((row) => {
|
||||
row.loading = true
|
||||
@@ -129,16 +138,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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -164,7 +183,8 @@ onMounted(() => {
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入域名(如:https://zakoflare.com)"
|
||||
placeholder="请输入域名(如:https://zakoflare.com)"
|
||||
clearable
|
||||
/>
|
||||
<el-button
|
||||
@click="SpeedTest()"
|
||||
@@ -175,6 +195,8 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" stack="ipv6" />
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
@@ -256,10 +278,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;
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { isIPv6 } from 'is-ip';
|
||||
import { extractHost } from '../../utils/tools';
|
||||
import { extractHost, withQuery } from '../../utils/tools';
|
||||
import AdvancedOptions from '../components/AdvancedOptions.vue';
|
||||
const route = useRoute()
|
||||
|
||||
useHead({
|
||||
title: 'IPv6 TCPing测试工具 | IPv6服务器连通性检测 | 柠檬味ipw.cn',
|
||||
title: `IPv6 TCPing测试工具 | IPv6服务器连通性检测 | ${config.siteName}`,
|
||||
meta: [
|
||||
{ name: 'description', content: '专业的IPv6 TCPing测试工具,提供多节点IPv6 TCP连通性检测服务,支持自定义端口测试,实时检测IPv6服务器丢包率、平均延迟、最大最小响应时间,助力IPv6网络质量诊断与优化,推进IPv6规模部署和应用' },
|
||||
{ name: 'keywords', content: 'ipv6 tcping测试,ipv6连通性检测,ipv6服务器延迟,ipv6丢包率,ipv6端口测试,ipv6网络质量,ipv6服务器测试,ipv6网络诊断' },
|
||||
@@ -34,7 +36,7 @@ useHead({
|
||||
},
|
||||
provider: {
|
||||
'@type': 'Organization',
|
||||
name: '柠檬味ipw.cn'
|
||||
name: config.siteName
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -58,6 +60,7 @@ interface TCPingResponse {
|
||||
}
|
||||
|
||||
interface ServerConfig {
|
||||
id: string
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
@@ -75,15 +78,19 @@ const port = ref('80')
|
||||
const userIP = ref('')
|
||||
const loading = ref(false)
|
||||
const serverResults = ref<ServerResult[]>([])
|
||||
// 高级选项(dns6 / resolve / resolve6):用户填了才拼进请求
|
||||
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
|
||||
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
|
||||
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(() => server.url + 'v1/tcping/' + extractHost(tmpDomain.value) + '?port=' + port.value);
|
||||
const { data, error: fetchError, execute } = useFetch<TCPingResponse>(url, {
|
||||
const url = computed(() => withQuery("/middleware/" + server.id + "/tcping/" + encodeURIComponent(extractHost(tmpDomain.value)) + "?port=" + encodeURIComponent(port.value) + "&version=ipv6", adv.value?.query() ?? {}));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch<TCPingResponse>(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
@@ -93,7 +100,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,
|
||||
@@ -101,7 +108,7 @@ function initServerResults() {
|
||||
})
|
||||
})
|
||||
|
||||
config.TCPing.IPv6.forEach((server: ServerConfig) => {
|
||||
config.APIBaseURL.IPv6.forEach((server: ServerConfig) => {
|
||||
results.push({
|
||||
label: server.label,
|
||||
loading: false,
|
||||
@@ -115,23 +122,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);
|
||||
@@ -141,22 +156,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(() => {
|
||||
@@ -181,11 +198,13 @@ onMounted(() => {
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入域名(如:example.com)"
|
||||
placeholder="请输入域名(如:example.com)"
|
||||
clearable
|
||||
/>
|
||||
<el-input
|
||||
v-model="port"
|
||||
placeholder="端口号(默认 80)"
|
||||
placeholder="端口号(默认 80)"
|
||||
clearable
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<el-button
|
||||
@@ -197,6 +216,8 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" stack="ipv6" />
|
||||
|
||||
<div class="result-section">
|
||||
<table class="result-table">
|
||||
<thead>
|
||||
@@ -219,13 +240,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>
|
||||
@@ -253,7 +274,7 @@ onMounted(() => {
|
||||
macOS 或 Linux: ping6 ipw.wsmdn.top<br/>
|
||||
<strong>2. 服务器 IPv6 Ping 失败可能原因:</strong><br/>
|
||||
服务器已开启 IPv6,但防火墙(又名安全组)未对源地址是 IPv6 地址(::/0)的 ICMPv6协议 开放访问,<br/>
|
||||
服务器未开启 IPv6,请参考 服务器开启 IPv6<br/>
|
||||
服务器未开启 IPv6,请参考 <a href="/doc/server/website_enable_ipv6" target="_blank">服务器开启 IPv6</a><br/>
|
||||
<a href="/tcping" target="_blank">IPv4 TCPing 测试</a> | <a href="/ipv6speedtest" target="_blank">IPv6 网站测速</a> | <a href="/ipv6webcheck">网站开启IPv6检测</a> | <a href="/dns">DNS解析查询</a> <br/>
|
||||
|
||||
访客IP: {{ userIP }},您的网络{{ isIPv6(userIP) ? 'IPv6' : 'IPv4'}}访问优先
|
||||
@@ -266,6 +287,20 @@ onMounted(() => {
|
||||
@import "../style.css";
|
||||
@import "../../assets/css/tool-common.css";
|
||||
|
||||
/* 本页行是双输入框(420×2 + 按钮 165 + 间距 ≈ 1035px),换行点远高于通用阈值的
|
||||
615px:容器低于 1050px(≈自然换行点)时换行元素铺满整行,消除中间的夹缝区。 */
|
||||
@container (max-width: 1050px) {
|
||||
.el-input,
|
||||
.el-button {
|
||||
width: 100% !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu--horizontal > .el-menu-item:nth-child(1) {
|
||||
margin-right: auto;
|
||||
}
|
||||
@@ -273,11 +308,11 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
|
||||
@@ -2,19 +2,23 @@
|
||||
import { ref, onMounted, computed, nextTick, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { CircleCheckFilled, CircleCloseFilled,InfoFilled,Position } from '@element-plus/icons-vue';
|
||||
import { extractHost, getStatusCodeClass, formatTime, formatSpeed, formatSize } from '../../utils/tools';
|
||||
import { extractHost, getStatusCodeClass, formatTime, formatSize, formatSpeed, withQuery } from '../../utils/tools';
|
||||
import AdvancedOptions from '../components/AdvancedOptions.vue';
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const siteUrlWithSlash = computed(() => config.siteUrl.replace(/\/$/, '') + '/')
|
||||
|
||||
useHead({
|
||||
title: 'IPv6网站检测工具 | IPv6访问支持检查 | 柠檬味ipw.cn',
|
||||
title: `IPv6网站检测工具 | IPv6访问支持检查 | ${config.siteName}`,
|
||||
meta: [
|
||||
{ name: 'description', content: '专业的IPv6网站检测工具,全面检查网站是否支持IPv6访问,提供IPv4和IPv6双栈HTTP/HTTPS状态码、DNS解析时间、TCP连接时间、下载速度等详细对比数据,帮助网站管理员确认IPv6部署状态,提供IPv6徽标认证,推进IPv6规模部署和应用' },
|
||||
{ name: 'keywords', content: 'ipv6网站检测,ipv6访问检测,ipv6支持检查,ipv6双栈检测,ipv6 http检测,ipv6 https检测,ipv6网站认证,ipv6徽标,ipv6部署' },
|
||||
{ property: 'og:title', content: 'IPv6网站检测 - IPv6访问支持状态检查与认证' },
|
||||
{ property: 'og:description', content: '全面检测网站IPv6访问支持情况,提供详细性能对比与IPv6徽标认证' },
|
||||
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
|
||||
{ property: 'og:image', content: siteUrlWithSlash.value + 'favicon.svg' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
],
|
||||
script: [
|
||||
@@ -25,7 +29,7 @@ useHead({
|
||||
'@type': 'WebApplication',
|
||||
name: 'IPv6网站访问支持检测',
|
||||
description: '专业的IPv6网站检测工具,检查网站是否支持IPv6访问,提供IPv4和IPv6双栈HTTP/HTTPS状态码、DNS解析时间、TCP连接时间等详细对比数据,提供IPv6徽标认证。',
|
||||
url: config.siteUrl + 'ipv6webcheck',
|
||||
url: siteUrlWithSlash.value + 'ipv6webcheck',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
operatingSystem: 'Web',
|
||||
offers: {
|
||||
@@ -35,7 +39,7 @@ useHead({
|
||||
},
|
||||
provider: {
|
||||
'@type': 'Organization',
|
||||
name: '柠檬味ipw.cn'
|
||||
name: config.siteName
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -60,17 +64,23 @@ interface PerformanceCheckItem {
|
||||
download_speed: number
|
||||
is_reachable: boolean
|
||||
}
|
||||
const apiList = config.apiBaseUrls
|
||||
const apiList = [
|
||||
...config.APIBaseURL.DualStack,
|
||||
]
|
||||
const currentApiIndex = ref(0)
|
||||
const remoteAPI = computed(() => apiList[currentApiIndex.value].url)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
const tmpDomain = ref('https://www.zakoflare.com')
|
||||
const testDomain = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const result = ref<PerformanceCheckResponse | null>(null)
|
||||
const checkUrl = computed(() => remoteAPI.value + 'v1/detail/' + testDomain.value);
|
||||
// 高级选项(dns4/dns6 + resolve/resolve4/resolve6):本页一次探测两栈
|
||||
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
|
||||
const checkUrl = computed(() => withQuery("/middleware/" + backendID.value + "/detail/" + testDomain.value, adv.value?.query() ?? {}));
|
||||
// testDomain 是完整 URL(含 https:// 与路径斜杠),服务端 [...slug].get.ts 依赖
|
||||
// 分段重组协议部分,这里刻意不做 encodeURIComponent
|
||||
|
||||
const { data: checkData, error: checkError, execute: executeCheck } = useFetch<PerformanceCheckResponse>(checkUrl, {
|
||||
const { data: checkData, error: checkError, execute: executeCheck } = useMiddlewareFetch<PerformanceCheckResponse>(checkUrl, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
@@ -87,9 +97,8 @@ watch(checkError, async (newError) => {
|
||||
if (newError) {
|
||||
console.log(newError);
|
||||
if (currentApiIndex.value < apiList.length - 1) {
|
||||
|
||||
const nextIndex = currentApiIndex.value + 1
|
||||
error.value = `${(newError as any).message || '请求失败'},正在重试 ${apiList[nextIndex].label}...`
|
||||
error.value = `${(newError as any).message || '请求失败'},正在重试 ${apiList[nextIndex]?.label || ''}...`
|
||||
currentApiIndex.value = nextIndex
|
||||
console.log(currentApiIndex.value,nextIndex,apiList)
|
||||
await nextTick()
|
||||
@@ -101,7 +110,7 @@ watch(checkError, async (newError) => {
|
||||
}
|
||||
});
|
||||
|
||||
function checkSSL() {
|
||||
function checkWeb() {
|
||||
testDomain.value = tmpDomain.value
|
||||
currentApiIndex.value = 0
|
||||
loading.value = true
|
||||
@@ -116,7 +125,7 @@ onMounted(() => {
|
||||
const urlParam = route.query.site as string
|
||||
if (urlParam) {
|
||||
tmpDomain.value = urlParam
|
||||
checkSSL()
|
||||
checkWeb()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -132,10 +141,11 @@ onMounted(() => {
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入域名(如:https://zakoflare.com)"
|
||||
placeholder="请输入域名(如:https://zakoflare.com)"
|
||||
clearable
|
||||
/>
|
||||
<el-button
|
||||
@click="checkSSL()"
|
||||
@click="checkWeb()"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
>
|
||||
@@ -143,6 +153,8 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" />
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
@@ -160,7 +172,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>
|
||||
@@ -232,17 +244,17 @@ onMounted(() => {
|
||||
<h3>结论:<el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>网站{{ extractHost(testDomain) }} 支持IPv6访问 </h3>
|
||||
<p><el-icon><InfoFilled style="color: lightgreen;"/></el-icon>请把下方代码贴到网站底部,把这个好消息告诉你的用户,以便用户核验。</p>
|
||||
<img src="/ipv6-s1.svg"/>
|
||||
<pre><code><a href="{{ config.siteUrl }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="https://ipw.wsmdn.dpdns.org/ipv6-s1.svg"></a></code></pre>
|
||||
<pre><code><a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-s1.svg"></a></code></pre>
|
||||
<img src="/ipv6-s2.svg"/>
|
||||
<pre><code><a href="{{ config.siteUrl }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="https://ipw.wsmdn.dpdns.org/ipv6-s2.svg"></a></code></pre>
|
||||
<pre><code><a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-s2.svg"></a></code></pre>
|
||||
<img src="/ipv6-s3.svg"/>
|
||||
<pre><code><a href="{{ config.siteUrl }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="https://ipw.wsmdn.dpdns.org/ipv6-s3.svg"></a></code></pre>
|
||||
<pre><code><a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-s3.svg"></a></code></pre>
|
||||
<img src="/ipv6-s4.svg"/>
|
||||
<pre><code><a href="{{ config.siteUrl }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="https://ipw.wsmdn.dpdns.org/ipv6-s4.svg"></a></code></pre>
|
||||
<pre><code><a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-s4.svg"></a></code></pre>
|
||||
<img src="/ipv6-s5.svg"/>
|
||||
<pre><code><a href="{{ config.siteUrl }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="https://ipw.wsmdn.dpdns.org/ipv6-s5.svg"></a></code></pre>
|
||||
<pre><code><a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-s5.svg"></a></code></pre>
|
||||
<img src="/ipv6-s6.svg"/>
|
||||
<pre><code><a href="{{ config.siteUrl }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="https://ipw.wsmdn.dpdns.org/ipv6-s6.svg"></a></code></pre>
|
||||
<pre><code><a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-s6.svg"></a></code></pre>
|
||||
<div class="one-line">
|
||||
<img src="/ipv6-certified-lite-s1.svg"/>
|
||||
<img src="/ipv6-certified-lite-s2.svg"/>
|
||||
@@ -251,7 +263,7 @@ onMounted(() => {
|
||||
<img src="/ipv6-certified-lite-s5.svg"/>
|
||||
<img src="/ipv6-certified-lite-s6.svg"/>
|
||||
</div>
|
||||
<pre><code><a href="{{ config.siteUrl }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="https://ipw.wsmdn.dpdns.org/ipv6-certified-lite-s1.svg"></a></code></pre>
|
||||
<pre><code><a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-certified-lite-s1.svg"></a></code></pre>
|
||||
<div class="one-line">
|
||||
<img src="/ipv6-certified-s1.svg"/>
|
||||
<img src="/ipv6-certified-s2.svg"/>
|
||||
@@ -260,7 +272,7 @@ onMounted(() => {
|
||||
<img src="/ipv6-certified-s5.svg"/>
|
||||
<img src="/ipv6-certified-s6.svg"/>
|
||||
</div>
|
||||
<pre><code><a href="{{ config.siteUrl }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="https://ipw.wsmdn.dpdns.org/ipv6-certified-s1.svg"></a></code></pre>
|
||||
<pre><code><a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-certified-s1.svg"></a></code></pre>
|
||||
<p>提示:修改IPv6徽标文件名,可修改对应样式</p>
|
||||
</div>
|
||||
<div v-else-if="result && result.ipv4 && result.ipv6 && result.ipv4.is_reachable && !result.ipv6.is_reachable">
|
||||
@@ -273,12 +285,18 @@ 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/>
|
||||
1. 网站所在服务器未开启 IPv6,请参考 网站开启 IPv6 的三种方式<br/>
|
||||
1. 网站所在服务器未开启 IPv6,请参考 <a href="/doc/server/website_enable_ipv6" target="_blank">网站开启 IPv6 的三种方式</a><br/>
|
||||
2. 网站所在服务器已开启 IPv6,但防火墙未对源地址是 IPv6 地址(::/0)的 443(HTTPS)端口开放访问<br/>
|
||||
3. 网站所在服务器已开启 IPv6,但未开启SSL证书,请参考 Nginx 开启 IPv6 SSL<br/>
|
||||
3. 网站所在服务器已开启 IPv6,但未开启SSL证书,请参考 <a href="/doc/server/nginx_ipv6" target="_blank">Nginx 开启 IPv6 SSL</a><br/>
|
||||
</blockquote>
|
||||
|
||||
</div>
|
||||
@@ -295,10 +313,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;
|
||||
|
||||
@@ -2,19 +2,20 @@
|
||||
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 { extractHost, isIPv4 } from '../../utils/tools';
|
||||
import { isIPv4 } from '../../utils/tools';
|
||||
import { wgs84ToGcj02 } from '../../utils/gcj02';
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
|
||||
useHead({
|
||||
title: 'IPv6/IPv4地址查询工具 | IP归属地定位 | 柠檬味ipw.cn',
|
||||
title: `IPv6/IPv4地址查询工具 | IP归属地定位 | ${config.siteName}`,
|
||||
meta: [
|
||||
{ name: 'description', content: '专业的IPv6/IPv4地址查询工具,支持IPv4和IPv6地址归属地查询,提供BiliBili Live、GeoCN、IP2Region、Maxmind等多种数据源对比,精确定位IP地理位置、运营商信息,支持中国大陆及境外地址查询,助力IPv6普及与应用' },
|
||||
{ name: 'keywords', content: 'ipv6地址查询,ipv4地址查询,ip归属地,ip地理位置,ip定位,运营商查询,ipv6归属地,ipv4归属地,maxmind,ip2region,geocn' },
|
||||
{ property: 'og:title', content: 'IPv6/IPv4地址归属地查询工具 - 柠檬味ipw.cn' },
|
||||
{ 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' },
|
||||
@@ -37,7 +38,7 @@ useHead({
|
||||
},
|
||||
provider: {
|
||||
'@type': 'Organization',
|
||||
name: '柠檬味ipw.cn'
|
||||
name: config.siteName
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -47,7 +48,11 @@ interface IPLocationType {
|
||||
bilibili?: any;
|
||||
ip?: string;
|
||||
geocn?: any;
|
||||
lemondb?: any;
|
||||
lemondb_asn?: any;
|
||||
ip2region?: any;
|
||||
ip2location?: any;
|
||||
ip2location_asn?: any;
|
||||
maxmind_asn?: any;
|
||||
maxmind_city?: any;
|
||||
qqwry?: any;
|
||||
@@ -65,17 +70,114 @@ 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 remoteAPI = computed(() => apiList[currentApiIndex.value].url)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
const ipAddress = ref('');
|
||||
const IPLocation = ref<IPLocationType>({});
|
||||
const UserIP = ref('');
|
||||
const error = ref('')
|
||||
const locationUrl = computed(() => remoteAPI.value + "v1/location/" + ipAddress.value);
|
||||
|
||||
const { data: locationData, error: locationError, execute: executeLocation } = useFetch<IPLocationType>(locationUrl, {
|
||||
// —— 归属地地图:高德瓦片(GCJ-02)+ Leaflet,免 key ——
|
||||
// 每个有坐标的来源各画一个点(bilibili / geocn 坐标已是 GCJ-02 直接用;
|
||||
// maxmind / ip2location / dbip / lemondb 为 WGS84 → 转 GCJ-02 与底图对齐),
|
||||
// 多点同时展示可直观看出各库定位的差异与离散度。
|
||||
const mapContainer = ref<HTMLElement | null>(null)
|
||||
let mapInstance: any = null
|
||||
let markerGroup: any = null
|
||||
|
||||
const SOURCE_DEFS = [
|
||||
{ key: 'lemondb', lat: 'latitude', lng: 'longitude', gcj: false, label: 'LemonDB', color: '#ffab00' },
|
||||
{ key: 'bilibili', lat: 'latitude', lng: 'longitude', gcj: true, label: 'bilibili Live', color: '#299764' },
|
||||
{ key: 'maxmind_city', lat: 'latitude', lng: 'longitude', gcj: false, label: 'MaxMind', color: '#1d4ed8' },
|
||||
{ key: 'ip2location', lat: 'latitude', lng: 'longitude', gcj: false, label: 'IP2Location', color: '#f97316' },
|
||||
{ key: 'dbip_city', lat: 'latitude', lng: 'longitude', gcj: false, label: 'DB-IP', color: '#8b5cf6' },
|
||||
{ key: 'geocn', lat: 'latitude', lng: 'longitude', gcj: true, label: 'GeoCN', color: '#0ea5e9' },
|
||||
]
|
||||
|
||||
const mapPoints = computed(() => {
|
||||
const r = IPLocation.value
|
||||
if (!r) return []
|
||||
const pts: Array<{ lat: number; lng: number; label: string; color: string }> = []
|
||||
for (const s of SOURCE_DEFS) {
|
||||
const lat = parseFloat(r[s.key]?.[s.lat])
|
||||
const lng = parseFloat(r[s.key]?.[s.lng])
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
|
||||
// LemonDB 全库统一 WGS-84(coord_system 恒为 wgs84,这里留个动态判断作前向兼容,
|
||||
// 若哪天源库换坐标系标注会自动跟随);其余源维持静态标记
|
||||
const gcj = s.gcj || r[s.key]?.coord_system === 'gcj02'
|
||||
const p = gcj ? { lat, lng } : wgs84ToGcj02(lat, lng)
|
||||
pts.push({ ...p, label: s.label, color: s.color })
|
||||
}
|
||||
}
|
||||
return pts
|
||||
})
|
||||
|
||||
let leafletModule: any = null
|
||||
async function renderLocationMap() {
|
||||
if (!import.meta.client || !mapPoints.value.length || !mapContainer.value) return
|
||||
const pts = mapPoints.value
|
||||
leafletModule = leafletModule || (await import('leaflet'))
|
||||
const L = leafletModule.default ?? leafletModule
|
||||
if (!mapInstance) {
|
||||
mapInstance = L.map(mapContainer.value, { attributionControl: true })
|
||||
L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', {
|
||||
subdomains: ['1', '2', '3', '4'],
|
||||
maxZoom: 18,
|
||||
// 高德栅格瓦片原生层级上限约 17:部分区域(尤其县乡)z=18 会返回纯米色
|
||||
// "无数据"空瓦片。声明 maxNativeZoom 后,Leaflet 对更高缩放自动放大
|
||||
// 17 级瓦片(轻度模糊但绝不变空白)。
|
||||
maxNativeZoom: 17,
|
||||
attribution: '© <a href="https://www.amap.com/" target="_blank" rel="noreferrer">高德地图</a>',
|
||||
}).addTo(mapInstance)
|
||||
}
|
||||
if (markerGroup) markerGroup.remove()
|
||||
markerGroup = L.featureGroup()
|
||||
for (const p of pts) {
|
||||
L.circleMarker([p.lat, p.lng], {
|
||||
radius: 8,
|
||||
color: p.color,
|
||||
fillColor: p.color,
|
||||
fillOpacity: 0.85,
|
||||
weight: 2,
|
||||
})
|
||||
.bindPopup(`<b>${p.label}</b><br>${ipAddress.value || UserIP.value}<br>${p.lat.toFixed(5)}, ${p.lng.toFixed(5)}`)
|
||||
.addTo(markerGroup)
|
||||
}
|
||||
markerGroup.addTo(mapInstance)
|
||||
// 多点:视野自适应包住全部点;单点:定位到 10 级
|
||||
if (pts.length > 1) {
|
||||
mapInstance.fitBounds(markerGroup.getBounds().pad(0.3))
|
||||
} else {
|
||||
mapInstance.setView([pts[0].lat, pts[0].lng], 10)
|
||||
}
|
||||
}
|
||||
|
||||
watch(IPLocation, async () => {
|
||||
if (mapPoints.value.length) {
|
||||
await nextTick()
|
||||
await renderLocationMap()
|
||||
}
|
||||
})
|
||||
const locationUrl = computed(() => "/middleware/" + backendID.value + "/location/" + encodeURIComponent(ipAddress.value));
|
||||
|
||||
const { data: locationData, error: locationError, execute: executeLocation } = useMiddlewareFetch<IPLocationType>(locationUrl, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
@@ -93,7 +195,7 @@ watch(locationError, async (newError) => {
|
||||
console.error('Error fetching IP location:', newError);
|
||||
if (currentApiIndex.value < apiList.length - 1) {
|
||||
const nextIndex = currentApiIndex.value + 1
|
||||
error.value = `${(newError as any).message || '请求失败'},正在重试 ${apiList[nextIndex].label}...`
|
||||
error.value = `${(newError as any).message || '请求失败'},正在重试 ${apiList[nextIndex]?.label || ''}...`
|
||||
currentApiIndex.value = nextIndex
|
||||
await nextTick()
|
||||
executeLocation()
|
||||
@@ -115,7 +217,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;
|
||||
@@ -147,7 +250,8 @@ onMounted(async () => {
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="ipAddress"
|
||||
placeholder="请输入IP地址"
|
||||
placeholder="请输入IP地址"
|
||||
clearable
|
||||
/>
|
||||
<el-button
|
||||
@click="locateIP(ipAddress)"
|
||||
@@ -163,11 +267,22 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div class="location">
|
||||
<div class="ip-info" style="height: 40px;">
|
||||
<b>IP</b> <p>{{ ipAddress }}</p>
|
||||
<b>IP</b> <span>{{ ipAddress }}</span>
|
||||
</div>
|
||||
<div v-if="IPLocation" class="result-section">
|
||||
<table class="result-table">
|
||||
<tbody>
|
||||
<tr v-if="IPLocation.lemondb && (IPLocation.lemondb.country || IPLocation.lemondb.administrative_area || IPLocation.lemondb.city)">
|
||||
<td class="table-label">LemonDB</td>
|
||||
<td class="table-value">
|
||||
{{ IPLocation.lemondb?.country }} {{ IPLocation.lemondb?.administrative_area }} {{ IPLocation.lemondb?.city }} {{ IPLocation.lemondb?.district }}
|
||||
</td>
|
||||
<td class="table-value">
|
||||
<span>
|
||||
{{ IPLocation.lemondb?.isp }} {{ IPLocation.lemondb?.type }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.bilibili && (IPLocation.bilibili.administrative_area || IPLocation.bilibili.city)">
|
||||
<td class="table-label">bilibili Live接口</td>
|
||||
<td class="table-value">
|
||||
@@ -181,15 +296,20 @@ 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>
|
||||
<td class="table-value">{{ IPLocation.geocn?.administrative_area }} {{ IPLocation.geocn?.city }} {{ IPLocation.geocn?.district }}</td>
|
||||
<td class="table-value">{{ IPLocation.geocn?.isp }} {{IPLocation.geocn?.type}}</td>
|
||||
<td class="table-value">{{ IPLocation.geocn?.isp }} {{IPLocation.geocn?.type}}</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>
|
||||
@@ -209,6 +329,8 @@ onMounted(async () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- 归属地地图:高德瓦片(GCJ-02)+ Leaflet,每个有坐标的来源各画一点 -->
|
||||
<div v-if="mapPoints.length" ref="mapContainer" class="location-map"></div>
|
||||
</div>
|
||||
<div style="font-size: 1.5em;">
|
||||
<h3 v-if="isIPv6(UserIP)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h3>
|
||||
@@ -221,7 +343,7 @@ onMounted(async () => {
|
||||
境外及港澳台地址: 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>
|
||||
访客IP: {{UserIP}},<span v-if="isIPv4(UserIP)">您的网络IPv4优先</span><span v-else-if="isIPv6(UserIP)">您的网络IPv6优先</span>
|
||||
</blockquote>
|
||||
|
||||
<div v-html="html"></div>
|
||||
@@ -234,11 +356,22 @@ onMounted(async () => {
|
||||
<style scoped>
|
||||
@import "../style.css";
|
||||
@import "../../assets/css/tool-common.css";
|
||||
/* leaflet 基础样式必须非 scoped 引入(动态创建的窗格拿不到 data-v 属性) */
|
||||
</style>
|
||||
<style>
|
||||
@import "leaflet/dist/leaflet.css";
|
||||
.location-map {
|
||||
height: 320px;
|
||||
margin-top: 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-border-color-lighter, #ebeef5);
|
||||
z-index: 0; /* 压住 leaflet 内部窗格的层级,避免盖过下拉/弹窗 */
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
@import "../style.css";
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
@@ -421,7 +554,7 @@ html.dark pre code {
|
||||
|
||||
.badge-item h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #3EAF7C;
|
||||
color: #299764;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { extractHost } from '../../utils/tools'
|
||||
import { isIPv6 } from 'is-ip'
|
||||
|
||||
const route = useRoute()
|
||||
const url = ref('')
|
||||
|
||||
useHead({
|
||||
// 原标题「网站截图工具 - 站名」默认态过短;描述不足 50 字被审计判过短,一并加长。
|
||||
title: route.query.site
|
||||
? `网站截图工具 - ${route.query.site} 的网页快照 | ${config.siteName}`
|
||||
: `网站截图工具 - 在线网页快照生成 | ${config.siteName}`,
|
||||
meta: [
|
||||
{ name: 'description', content: '在线网站截图工具:输入网址即可获取网页实时快照,支持任意公开网站的整页截图与预览,查看网页布局、设计效果与渲染状态,可用于页面监控、设计走查与内容存档比对,免费无需注册' },
|
||||
{ name: 'keywords', content: '网站截图,网页快照,在线截图,网页预览,网站布局查看,截图工具,整页截图,网页快照生成' },
|
||||
{ property: 'og:title', content: '网站截图工具 - 在线网页快照生成' },
|
||||
{ property: 'og:description', content: '输入网址即可获取网页实时快照,查看网页布局与设计效果,支持任意公开网站,免费无需注册' },
|
||||
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
],
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebApplication',
|
||||
name: '网站截图工具',
|
||||
description: '在线网站截图工具,输入网址即可获取网页实时快照,支持任意公开网站的整页截图与预览。',
|
||||
url: new URL('/screenshot', config.siteUrl).href,
|
||||
applicationCategory: 'UtilitiesApplication',
|
||||
operatingSystem: 'Web',
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'CNY'
|
||||
},
|
||||
provider: {
|
||||
'@type': 'Organization',
|
||||
name: config.siteName
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const tmpDomain = ref('https://www.zakoflare.com')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const screenshotUrl = ref('')
|
||||
const imgLoaded = ref(false)
|
||||
// 主通道(thum.io)经 fetch 下载后以 blob URL 喂给 <img>;换新截图前要 revoke 旧的
|
||||
const blobUrl = ref('')
|
||||
|
||||
|
||||
let detectionImg: HTMLImageElement | null = null
|
||||
let detectionTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let currentRequestId = 0
|
||||
const userIP = ref('')
|
||||
// WordPress mshots 默认占位图(生成失败/重定向目标)的尺寸是 1200x900,
|
||||
// 与下方注释及判定逻辑一致;真实网站截图可以是任意尺寸
|
||||
const DEFAULT_MSHOTS_WIDTH = 1200
|
||||
const DEFAULT_MSHOTS_HEIGHT = 900
|
||||
const LOADING_TIMEOUT = 15000 // 15 seconds
|
||||
|
||||
// —— 主通道:thum.io(同步出图、CORS 全开)——
|
||||
// content-type 即结果信号:image/png|jpeg = 成品;image/gif = thum.io 的
|
||||
// 「生成中/目标不可达」动画(对没缓存且生成慢的站首访可能出现)→ 等片刻重试一次,
|
||||
// 仍 GIF 就放弃。网络层失败(被墙/CSP/超时)→ 立即回退 mshots。
|
||||
const THUM_TIMEOUT = 10_000
|
||||
const THUM_RETRY_DELAY = 4_000
|
||||
|
||||
async function takeThumio(target: string, requestId: number): Promise<string | null> {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
if (requestId !== currentRequestId) return null
|
||||
try {
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), THUM_TIMEOUT)
|
||||
const res = await fetch(`${config.screenshotAPI}/get/width/1280/${target}`, { signal: ctrl.signal })
|
||||
clearTimeout(timer)
|
||||
if (requestId !== currentRequestId) return null
|
||||
const type = res.headers.get('content-type') || ''
|
||||
if (res.ok && /^image\/(png|jpe?g)\b/.test(type)) {
|
||||
return URL.createObjectURL(await res.blob())
|
||||
}
|
||||
if (attempt === 0) {
|
||||
await new Promise(r => setTimeout(r, THUM_RETRY_DELAY))
|
||||
continue
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function releaseBlob() {
|
||||
if (blobUrl.value) {
|
||||
URL.revokeObjectURL(blobUrl.value)
|
||||
blobUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserIP(){
|
||||
try {
|
||||
userIP.value = await $fetch<string>(config.DualStackAPI)
|
||||
} catch {
|
||||
// 获取失败保持为空,避免未处理的 Promise 拒绝
|
||||
}
|
||||
return userIP.value
|
||||
}
|
||||
function cleanupDetection() {
|
||||
if (detectionImg) {
|
||||
detectionImg.onload = null
|
||||
detectionImg.onerror = null
|
||||
detectionImg = null
|
||||
}
|
||||
if (detectionTimeout) {
|
||||
clearTimeout(detectionTimeout)
|
||||
detectionTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
function showError(message: string) {
|
||||
cleanupDetection()
|
||||
error.value = message
|
||||
loading.value = false
|
||||
screenshotUrl.value = ''
|
||||
imgLoaded.value = false
|
||||
}
|
||||
|
||||
function takeScreenshot() {
|
||||
url.value = tmpDomain.value.trim()
|
||||
if (!url.value) {
|
||||
error.value = '请输入网址'
|
||||
return
|
||||
}
|
||||
|
||||
if (!url.value.startsWith('http://') && !url.value.startsWith('https://')) {
|
||||
url.value = 'https://' + url.value
|
||||
}
|
||||
|
||||
cleanupDetection()
|
||||
error.value = ''
|
||||
imgLoaded.value = false
|
||||
loading.value = true
|
||||
screenshotUrl.value = ''
|
||||
releaseBlob()
|
||||
|
||||
const requestId = ++currentRequestId
|
||||
|
||||
// Set the screenshot URL on next tick so Vue can update the displayed <img>
|
||||
nextTick(async () => {
|
||||
// Check if this request was superseded
|
||||
if (requestId !== currentRequestId) return
|
||||
|
||||
// —— 主通道 thum.io:fetch 下载成 blob 直接展示(同步出图,无「请稍后」体验)——
|
||||
const blob = await takeThumio(url.value, requestId)
|
||||
if (requestId !== currentRequestId) {
|
||||
if (blob) URL.revokeObjectURL(blob)
|
||||
return
|
||||
}
|
||||
if (blob) {
|
||||
blobUrl.value = blob
|
||||
screenshotUrl.value = blob
|
||||
// blob 已完整在手,无需等 <img> 的 load 事件再揭开
|
||||
imgLoaded.value = true
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// —— 回退 mshots:异步生成,先重定向到占位图,需要隐藏 <img> 探测真伪 ——
|
||||
screenshotUrl.value = `https://s0.wp.com/mshots/v1/${encodeURIComponent(url.value)}`
|
||||
|
||||
// Create a hidden detection image to check for default WordPress error image
|
||||
// The mshots API returns a 302 redirect to a default image (1200x900) when
|
||||
// the target site is unreachable or generation times out.
|
||||
detectionImg = new Image()
|
||||
|
||||
detectionImg.onload = () => {
|
||||
if (requestId !== currentRequestId) return
|
||||
|
||||
// 307 Temporary Redirect to mshots default — target is still generating
|
||||
if (detectionImg!.currentSrc?.endsWith('/mshots/v1/default')) {
|
||||
cleanupDetection()
|
||||
loading.value = false
|
||||
error.value = '正在生成截图中,请稍后...'
|
||||
return
|
||||
}
|
||||
|
||||
// WordPress mshots default error images are exactly 1200x900.
|
||||
// Real website screenshots can be any dimensions.
|
||||
if (
|
||||
detectionImg!.naturalWidth === DEFAULT_MSHOTS_WIDTH &&
|
||||
detectionImg!.naturalHeight === DEFAULT_MSHOTS_HEIGHT
|
||||
) {
|
||||
showError('截图加载失败,可能是目标站点无法访问或生成超时')
|
||||
return
|
||||
}
|
||||
|
||||
// Passed detection - screenshot is valid
|
||||
cleanupDetection()
|
||||
imgLoaded.value = true
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
detectionImg.onerror = () => {
|
||||
if (requestId !== currentRequestId) return
|
||||
showError('截图加载失败,请检查网址是否正确')
|
||||
}
|
||||
|
||||
detectionImg.src = screenshotUrl.value
|
||||
|
||||
// Timeout: if detection doesn't complete within LOADING_TIMEOUT ms
|
||||
detectionTimeout = setTimeout(() => {
|
||||
if (requestId !== currentRequestId) return
|
||||
showError('截图加载失败,可能是目标站点无法访问或生成超时')
|
||||
}, LOADING_TIMEOUT)
|
||||
})
|
||||
}
|
||||
|
||||
// The displayed <img> may or may not fire its own load event depending on
|
||||
// browser behavior with 302 redirects. The hidden detection image above
|
||||
// handles the primary success/failure determination.
|
||||
function onImageLoad() {
|
||||
if (detectionImg && detectionImg.currentSrc?.endsWith('/mshots/v1/default')) {
|
||||
error.value = '正在生成截图中,请稍后...'
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onImageError() {
|
||||
showError('截图加载失败,请检查网址是否正确')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupDetection()
|
||||
releaseBlob()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const urlParam = route.query.site as string
|
||||
if (urlParam) {
|
||||
tmpDomain.value = urlParam
|
||||
takeScreenshot()
|
||||
}
|
||||
getUserIP()
|
||||
})
|
||||
|
||||
const { data: page } = await useAsyncData('screenshot-doc', () =>
|
||||
$fetch('/api/markdown/screenshot')
|
||||
)
|
||||
const doc = page.value
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="title">
|
||||
<header>
|
||||
<h1>网站截图</h1>
|
||||
<p>输入网址,自动截图网站页面</p>
|
||||
</header>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入网址(如:https://example.com)"
|
||||
clearable
|
||||
@keyup.enter="takeScreenshot"
|
||||
/>
|
||||
<el-button
|
||||
@click="takeScreenshot"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
>
|
||||
网站截图
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="screenshotUrl" class="result-section">
|
||||
<div class="screenshot-container">
|
||||
<div v-if="loading && !imgLoaded" class="loading-overlay">
|
||||
<el-icon class="is-loading" :size="40">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<p>正在截图中...</p>
|
||||
</div>
|
||||
<img
|
||||
:src="screenshotUrl"
|
||||
alt="网站截图"
|
||||
class="screenshot-img"
|
||||
:class="{ 'img-loaded': imgLoaded }"
|
||||
@load="onImageLoad"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="doc" class="markdown">
|
||||
<div v-html="doc"></div>
|
||||
</div>
|
||||
<blockquote>
|
||||
|
||||
访客IP: {{userIP }},您的网络 {{ isIPv6(userIP) ? 'IPv6' : 'IPv4'}} 访问优先<br/>
|
||||
</blockquote>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../style.css";
|
||||
@import "../../assets/css/tool-common.css";
|
||||
|
||||
.screenshot-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
background: #f5f5f5;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.screenshot-img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.screenshot-img.img-loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
html.dark .screenshot-container {
|
||||
background: #1a1919;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
html.dark .loading-overlay {
|
||||
background: #1a1919;
|
||||
color: #adbac7;
|
||||
}
|
||||
|
||||
.loading-overlay p {
|
||||
margin-top: 12px;
|
||||
color: #606266;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
html.dark .loading-overlay p {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.markdown :deep(a) {
|
||||
color: #299764 !important;
|
||||
font-size: 1.3em;
|
||||
text-decoration: none
|
||||
}
|
||||
</style>
|
||||
@@ -2,13 +2,15 @@
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { extractHost, formatTime, formatSpeed, formatSize, getStatusCodeClass } from '../../utils/tools';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { extractHost, formatTime, formatSpeed, formatSize, getStatusCodeClass, withQuery } from '../../utils/tools';
|
||||
import AdvancedOptions from '../components/AdvancedOptions.vue';
|
||||
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
useHead({
|
||||
title: 'IPv4网站测速工具 | 全国多节点并发测速 | 柠檬味ipw.cn',
|
||||
title: `IPv4网站测速工具 | 全国多节点并发测速 | ${config.siteName}`,
|
||||
meta: [
|
||||
{ name: 'description', content: '专业的IPv4网站测速工具,提供全国多节点并发测速服务,快速返回网站响应时间、下载速度、页面大小、DNS解析时间、HTTP连接时间等详细性能指标,支持IPv4网站性能检测与优化,助力网站性能监控与用户体验改善' },
|
||||
{ name: 'keywords', content: 'ipv4网站测速,ipv4测速,网站速度测试,ipv4性能检测,网站响应时间,ipv4下载速度,ipv4性能优化,网站性能监控' },
|
||||
@@ -35,7 +37,7 @@ useHead({
|
||||
},
|
||||
provider: {
|
||||
'@type': 'Organization',
|
||||
name: '柠檬味ipw.cn'
|
||||
name: config.siteName
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -47,15 +49,20 @@ const loading = ref(false)
|
||||
const result = ref<any[]>([])
|
||||
const error = ref('')
|
||||
const domain = ref('')
|
||||
// 高级选项(dns4 / resolve / resolve4):用户填了才拼进请求
|
||||
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
|
||||
|
||||
/** 服务器结果接口 */
|
||||
interface ServerResult {
|
||||
label: string
|
||||
loading: boolean
|
||||
error: string
|
||||
error?: string | Error
|
||||
data: any
|
||||
}
|
||||
|
||||
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
|
||||
let queryId = 0
|
||||
|
||||
/** 预构建的服务器列表,页面挂载即展示,用户可提前看到测速节点 */
|
||||
const serverResults = ref<ServerResult[]>([])
|
||||
|
||||
@@ -67,9 +74,9 @@ const serverResults = ref<ServerResult[]>([])
|
||||
// =============================================
|
||||
|
||||
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
|
||||
const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
|
||||
const url = computed(() => server.url + 'v1/speed/v4/' + domain.value);
|
||||
const { data, error: fetchError, execute } = useFetch(url, {
|
||||
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
|
||||
const url = computed(() => withQuery("/middleware/" + server.id + "/speed/v4/" + encodeURIComponent(domain.value), adv.value?.query() ?? {}));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
@@ -77,9 +84,9 @@ const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
|
||||
});
|
||||
|
||||
/** IPv4 专用服务器:仅支持 IPv4 的节点 */
|
||||
const ipv4Fetches = config.SpeedTest.IPv4.map((server) => {
|
||||
const url = computed(() => server.url + 'v1/speed/v4/' + domain.value);
|
||||
const { data, error: fetchError, execute } = useFetch(url, {
|
||||
const ipv4Fetches = config.APIBaseURL.IPv4.map((server) => {
|
||||
const url = computed(() => withQuery("/middleware/" + server.id + "/speed/v4/" + encodeURIComponent(domain.value), adv.value?.query() ?? {}));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
@@ -115,6 +122,8 @@ async function SpeedTest() {
|
||||
error.value = ''
|
||||
await nextTick()
|
||||
|
||||
const id = ++queryId
|
||||
|
||||
// 重置所有行状态为加载中
|
||||
serverResults.value.forEach((row) => {
|
||||
row.loading = true
|
||||
@@ -129,16 +138,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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -164,7 +183,8 @@ onMounted(() => {
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入域名(如:https://zakoflare.com)"
|
||||
placeholder="请输入域名(如:https://zakoflare.com)"
|
||||
clearable
|
||||
/>
|
||||
<el-button
|
||||
@click="SpeedTest()"
|
||||
@@ -175,6 +195,8 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" stack="ipv4" />
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
@@ -256,10 +278,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;
|
||||
|
||||
@@ -3,17 +3,21 @@ import { ref, onMounted, computed, nextTick, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { CircleCheckFilled, CircleCloseFilled,InfoFilled } from '@element-plus/icons-vue';
|
||||
import { config } from '../../config/index';
|
||||
import { extractHost, formatTime, formatSpeed, getStatusCodeClass } from '../../utils/tools';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { extractHost, getStatusCodeClass, formatTime, formatSpeed, withQuery } from '../../utils/tools';
|
||||
import AdvancedOptions from '../components/AdvancedOptions.vue';
|
||||
const route = useRoute()
|
||||
|
||||
const siteUrlWithSlash = computed(() => config.siteUrl.replace(/\/$/, '') + '/')
|
||||
|
||||
useHead({
|
||||
title: 'SSL证书检测工具 | IPv4/IPv6证书检查 | 柠檬味ipw.cn',
|
||||
title: `SSL证书检测工具 | IPv4/IPv6证书检查 | ${config.siteName}`,
|
||||
meta: [
|
||||
{ name: 'description', content: '专业的SSL证书检测工具,全面检查网站的IPv4和IPv6 SSL证书状态、有效期、签发机构、HTTP版本等信息,支持HTTPS状态码检测、下载速度测试,帮助网站管理员及时发现证书问题,确保网站安全访问' },
|
||||
{ name: 'keywords', content: 'ssl证书检测,ssl检查,https证书,ipv6 ssl,ipv4 ssl,证书有效期,ssl状态,https检测,网站安全,证书签发机构' },
|
||||
{ property: 'og:title', content: 'SSL证书检测 - IPv4/IPv6双栈证书状态检查工具' },
|
||||
{ property: 'og:description', content: '全面检测网站SSL证书状态,支持IPv4和IPv6双栈检测,提供证书有效期、签发机构等详细信息' },
|
||||
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
|
||||
{ property: 'og:image', content: siteUrlWithSlash.value + 'favicon.svg' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
],
|
||||
script: [
|
||||
@@ -24,7 +28,7 @@ useHead({
|
||||
'@type': 'WebApplication',
|
||||
name: 'SSL证书检测工具',
|
||||
description: '专业的SSL证书检测工具,支持IPv4和IPv6 SSL证书状态、有效期、签发机构、HTTP版本等检测,提供HTTPS状态码检测、下载速度测试。',
|
||||
url: config.siteUrl + 'ssl',
|
||||
url: siteUrlWithSlash.value + 'ssl',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
operatingSystem: 'Web',
|
||||
offers: {
|
||||
@@ -34,7 +38,7 @@ useHead({
|
||||
},
|
||||
provider: {
|
||||
'@type': 'Organization',
|
||||
name: '柠檬味ipw.cn'
|
||||
name: config.siteName
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -59,20 +63,32 @@ interface SSLCheckItem {
|
||||
issuer_organization: string[] | null
|
||||
issuer_common_name: string
|
||||
subject_common_name: string
|
||||
san: string[] | null
|
||||
is_expired: boolean
|
||||
is_reachable: boolean
|
||||
}
|
||||
const apiList = config.apiBaseUrls
|
||||
const apiList = [
|
||||
...config.APIBaseURL.DualStack,
|
||||
]
|
||||
const currentApiIndex = ref(0)
|
||||
const remoteAPI = computed(() => apiList[currentApiIndex.value].url)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
const tmpDomain = ref('https://www.zakoflare.com')
|
||||
const testDomain = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const result = ref<SSLCheckResponse | null>(null)
|
||||
const sslUrl = computed(() => remoteAPI.value + 'v1/ssl/' + testDomain.value);
|
||||
// SAN 行只在「至少一个栈拿到非空 SAN」时渲染:两栈都空(节点未升级返回无 san 字段、
|
||||
// 或证书本就没有 SAN)时整行隐藏,而不是渲染一行「- -」
|
||||
const showSanRow = computed(() => {
|
||||
const r = result.value
|
||||
if (!r) return false
|
||||
return !!((r.ipv4?.is_reachable && r.ipv4.san?.length) || (r.ipv6?.is_reachable && r.ipv6.san?.length))
|
||||
})
|
||||
// 高级选项(dns4/dns6 + resolve/resolve4/resolve6):本页一次探测两栈
|
||||
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
|
||||
const sslUrl = computed(() => withQuery("/middleware/" + backendID.value + "/ssl/" + encodeURIComponent(testDomain.value), adv.value?.query() ?? {}));
|
||||
|
||||
const { data: sslData, error: sslError, execute: executeSSL } = useFetch<SSLCheckResponse>(sslUrl, {
|
||||
const { data: sslData, error: sslError, execute: executeSSL } = useMiddlewareFetch<SSLCheckResponse>(sslUrl, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
@@ -90,7 +106,7 @@ watch(sslError, async (newError) => {
|
||||
console.log(newError);
|
||||
if (currentApiIndex.value < apiList.length - 1) {
|
||||
const nextIndex = currentApiIndex.value + 1
|
||||
error.value = `${(newError as any).message || '请求失败'},正在重试 ${apiList[nextIndex].label}...`
|
||||
error.value = `${(newError as any).message || '请求失败'},正在重试 ${apiList[nextIndex]?.label || ''}...`
|
||||
currentApiIndex.value = nextIndex
|
||||
await nextTick()
|
||||
executeSSL()
|
||||
@@ -143,7 +159,8 @@ onMounted(() => {
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入域名(如:https://zakoflare.com)"
|
||||
placeholder="请输入域名(如:https://zakoflare.com)"
|
||||
clearable
|
||||
/>
|
||||
<el-button
|
||||
@click="checkSSL()"
|
||||
@@ -154,6 +171,8 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" />
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
@@ -198,6 +217,17 @@ onMounted(() => {
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="showSanRow">
|
||||
<td class="table-label">SAN(备用域名)</td>
|
||||
<td class="table-value">
|
||||
<template v-if="result.ipv4!.is_reachable && result.ipv4!.san?.length">{{ result.ipv4!.san.join(', ') }}</template>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="table-value">
|
||||
<template v-if="result.ipv6 && result.ipv6.is_reachable && result.ipv6.san?.length">{{ result.ipv6.san.join(', ') }}</template>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="table-label">签发者</td>
|
||||
<td class="table-value">
|
||||
@@ -322,34 +352,34 @@ onMounted(() => {
|
||||
<h3>结论:<el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>网站{{ extractHost(testDomain) }} 证书有效 </h3>
|
||||
<p><el-icon><InfoFilled style="color: lightgreen;"/></el-icon>请把下方代码贴到网站底部,把这个好消息告诉你的用户,以便用户核验。</p>
|
||||
<img src="/ssl-s1.svg"/>
|
||||
<pre><code><a href="{{ config.siteUrl }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="https://ipw.wsmdn.dpdns.org/ssl-s1.svg"></a></code></pre>
|
||||
<pre><code><a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s1.svg"></a></code></pre>
|
||||
<img src="/ssl-s2.svg"/>
|
||||
<pre><code><a href="{{ config.siteUrl }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="https://ipw.wsmdn.dpdns.org/ssl-s2.svg"></a></code></pre>
|
||||
<pre><code><a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s2.svg"></a></code></pre>
|
||||
<img src="/ssl-s3.svg"/>
|
||||
<pre><code><a href="{{ config.siteUrl }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="https://ipw.wsmdn.dpdns.org/ssl-s3.svg"></a></code></pre>
|
||||
<pre><code><a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s3.svg"></a></code></pre>
|
||||
<img src="/ssl-s4.svg"/>
|
||||
<pre><code><a href="{{ config.siteUrl }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="https://ipw.wsmdn.dpdns.org/ssl-s4.svg"></a></code></pre>
|
||||
<pre><code><a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s4.svg"></a></code></pre>
|
||||
<img src="/ssl-s5.svg"/>
|
||||
<pre><code><a href="{{ config.siteUrl }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="https://ipw.wsmdn.dpdns.org/ssl-s5.svg"></a></code></pre>
|
||||
<pre><code><a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s5.svg"></a></code></pre>
|
||||
<img src="/ssl-s6.svg"/>
|
||||
<pre><code><a href="{{ config.siteUrl }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="https://ipw.wsmdn.dpdns.org/ssl-s6.svg"></a></code></pre>
|
||||
<pre><code><a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s6.svg"></a></code></pre>
|
||||
|
||||
</div>
|
||||
<div v-if="result && result.ipv4 && result.ipv4.is_reachable && !result.ipv4.is_expired && result.ipv6 && result.ipv6.is_reachable && result.ipv6.is_expired">
|
||||
<h3>结论:<el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>网站{{ extractHost(testDomain) }} 证书有效,但不支持IPv6访问 </h3>
|
||||
<p><el-icon><InfoFilled style="color: lightgreen;"/></el-icon>请把下方代码贴到网站底部,把这个好消息告诉你的用户,以便用户核验。</p>
|
||||
<img src="/ssl-s1.svg"/>
|
||||
<pre><code><a href="{{ config.siteUrl }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="https://ipw.wsmdn.dpdns.org/ssl-s1.svg"></a></code></pre>
|
||||
<pre><code><a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s1.svg"></a></code></pre>
|
||||
<img src="/ssl-s2.svg"/>
|
||||
<pre><code><a href="{{ config.siteUrl }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="https://ipw.wsmdn.dpdns.org/ssl-s2.svg"></a></code></pre>
|
||||
<pre><code><a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s2.svg"></a></code></pre>
|
||||
<img src="/ssl-s3.svg"/>
|
||||
<pre><code><a href="{{ config.siteUrl }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="https://ipw.wsmdn.dpdns.org/ssl-s3.svg"></a></code></pre>
|
||||
<pre><code><a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s3.svg"></a></code></pre>
|
||||
<img src="/ssl-s4.svg"/>
|
||||
<pre><code><a href="{{ config.siteUrl }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="https://ipw.wsmdn.dpdns.org/ssl-s4.svg"></a></code></pre>
|
||||
<pre><code><a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s4.svg"></a></code></pre>
|
||||
<img src="/ssl-s5.svg"/>
|
||||
<pre><code><a href="{{ config.siteUrl }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="https://ipw.wsmdn.dpdns.org/ssl-s5.svg"></a></code></pre>
|
||||
<pre><code><a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s5.svg"></a></code></pre>
|
||||
<img src="/ssl-s6.svg"/>
|
||||
<pre><code><a href="{{ config.siteUrl }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="https://ipw.wsmdn.dpdns.org/ssl-s6.svg"></a></code></pre>
|
||||
<pre><code><a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'><img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s6.svg"></a></code></pre>
|
||||
|
||||
</div>
|
||||
<div v-else-if="result && result.ipv4 && result.ipv4.is_reachable && result.ipv4.is_expired">
|
||||
@@ -386,7 +416,7 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { extractHost } from '../../utils/tools';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { extractHost, withQuery } from '../../utils/tools';
|
||||
import AdvancedOptions from '../components/AdvancedOptions.vue';
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
useHead({
|
||||
title: 'TCPing测试工具 | IPv4服务器连通性检测 | 柠檬味ipw.cn',
|
||||
title: `TCPing测试工具 | IPv4服务器连通性检测 | ${config.siteName}`,
|
||||
meta: [
|
||||
{ name: 'description', content: '专业的IPv4 TCPing测试工具,提供多节点TCP连通性检测服务,支持自定义端口测试,实时检测服务器丢包率、平均延迟、最大最小响应时间,帮助运维人员快速诊断服务器网络质量,确保服务稳定运行' },
|
||||
{ name: 'keywords', content: 'tcping测试,tcp连通性检测,ipv4 tcping,服务器延迟测试,丢包率检测,端口连通性,服务器网络测试,网络质量检测' },
|
||||
@@ -34,7 +36,7 @@ useHead({
|
||||
},
|
||||
provider: {
|
||||
'@type': 'Organization',
|
||||
name: '柠檬味ipw.cn'
|
||||
name: config.siteName
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -58,6 +60,7 @@ interface TCPingResponse {
|
||||
}
|
||||
|
||||
interface ServerConfig {
|
||||
id: string
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
@@ -74,15 +77,19 @@ const tmpDomain = ref('www.zakoflare.com')
|
||||
const port = ref('80')
|
||||
const loading = ref(false)
|
||||
const serverResults = ref<ServerResult[]>([])
|
||||
// 高级选项(dns4 / resolve / resolve4):用户填了才拼进请求
|
||||
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
|
||||
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
|
||||
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(() => server.url + 'v1/tcping/' + extractHost(tmpDomain.value) + '?port=' + port.value);
|
||||
const { data, error: fetchError, execute } = useFetch<TCPingResponse>(url, {
|
||||
const url = computed(() => withQuery("/middleware/" + server.id + "/tcping/" + encodeURIComponent(extractHost(tmpDomain.value)) + "?port=" + encodeURIComponent(port.value) + "&version=ipv4", adv.value?.query() ?? {}));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch<TCPingResponse>(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
@@ -91,8 +98,9 @@ const tcpingFetches = allServers.map((server) => {
|
||||
|
||||
function initServerResults() {
|
||||
const results: ServerResult[] = []
|
||||
|
||||
config.TCPing.DualStack.forEach((server: ServerConfig) => {
|
||||
|
||||
// 与 allServers 顺序保持一致:DualStack → IPv4
|
||||
config.APIBaseURL.DualStack.forEach((server: ServerConfig) => {
|
||||
results.push({
|
||||
label: server.label,
|
||||
loading: false,
|
||||
@@ -100,7 +108,7 @@ function initServerResults() {
|
||||
})
|
||||
})
|
||||
|
||||
config.TCPing.IPv4.forEach((server: ServerConfig) => {
|
||||
config.APIBaseURL.IPv4.forEach((server: ServerConfig) => {
|
||||
results.push({
|
||||
label: server.label,
|
||||
loading: false,
|
||||
@@ -114,40 +122,50 @@ 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);
|
||||
const result = serverResults.value[index];
|
||||
if (result) {
|
||||
result.error = '请求失败';
|
||||
result.error = (err as any)?.message || '请求失败';
|
||||
}
|
||||
} 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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -172,11 +190,13 @@ onMounted(() => {
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入域名(如:example.com)"
|
||||
placeholder="请输入域名(如:example.com)"
|
||||
clearable
|
||||
/>
|
||||
<el-input
|
||||
v-model="port"
|
||||
placeholder="端口号(默认 80)"
|
||||
placeholder="端口号(默认 80)"
|
||||
clearable
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<el-button
|
||||
@@ -188,6 +208,8 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" stack="ipv4" />
|
||||
|
||||
<div class="result-section">
|
||||
<table class="result-table">
|
||||
<thead>
|
||||
@@ -210,13 +232,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>
|
||||
@@ -242,6 +264,20 @@ onMounted(() => {
|
||||
@import "../style.css";
|
||||
@import "../../assets/css/tool-common.css";
|
||||
|
||||
/* 本页行是双输入框(420×2 + 按钮 165 + 间距 ≈ 1035px),换行点远高于通用阈值的
|
||||
615px:容器低于 1050px(≈自然换行点)时换行元素铺满整行,消除中间的夹缝区。 */
|
||||
@container (max-width: 1050px) {
|
||||
.el-input,
|
||||
.el-button {
|
||||
width: 100% !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu--horizontal > .el-menu-item:nth-child(1) {
|
||||
margin-right: auto;
|
||||
}
|
||||
@@ -257,11 +293,11 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
<script setup lang="ts">
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { ref, onMounted, computed, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index'
|
||||
import { withQuery } from '../../utils/tools'
|
||||
import AdvancedOptions from '../components/AdvancedOptions.vue'
|
||||
import { isIPv6 } from 'is-ip'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
useHead({
|
||||
// 原标题「Whois查询 | 站名」只有十几个字符,被 SEO 审计判为过短;
|
||||
// 带查询参数时展示的是具体域名,单独给一个含关键词的标题。
|
||||
title: route.query.site
|
||||
? `Whois查询结果 - ${route.query.site} 的域名注册信息 | ${config.siteName}`
|
||||
: `Whois域名查询工具 | 域名注册信息与到期时间查询 | ${config.siteName}`,
|
||||
titleTemplate: '%s',
|
||||
meta: [
|
||||
{ name: 'description', content: '专业的Whois域名查询工具,支持.com、.net、.cn等国内外域名WHOIS信息在线查询,提供域名注册商、注册时间、到期时间、DNS服务器、域名状态等详细信息,支持IPv4/IPv6多节点检测,助力域名管理与交易决策' },
|
||||
{ name: 'keywords', content: 'whois查询,域名whois,whois信息查询,域名注册信息,域名到期时间,域名注册商,dns服务器查询,域名whois工具' },
|
||||
{ property: 'og:title', content: 'Whois域名查询工具 - 域名注册信息在线查询' },
|
||||
{ property: 'og:description', content: '专业的Whois域名查询工具,支持国内外域名WHOIS信息查询,提供域名注册商、注册时间、到期时间等详细信息' },
|
||||
{ property: 'og:image', content: `${config.siteUrl}favicon.svg` },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
],
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebApplication',
|
||||
name: 'Whois域名查询工具',
|
||||
description: '专业的Whois域名查询工具,支持国内外域名WHOIS信息查询,提供域名注册商、注册时间、到期时间、DNS服务器等详细信息',
|
||||
url: config.siteUrl + 'whois',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
operatingSystem: 'Any',
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'CNY'
|
||||
},
|
||||
featureList: 'Whois查询,域名注册信息,域名到期时间,域名注册商查询,DNS服务器查询',
|
||||
about: [
|
||||
{
|
||||
'@type': 'Thing',
|
||||
name: 'Whois查询',
|
||||
description: '通过Whois协议查询域名的注册信息,包括注册商、注册时间、到期时间、DNS服务器等技术服务。'
|
||||
},
|
||||
{
|
||||
'@type': 'Thing',
|
||||
name: '域名管理',
|
||||
description: '通过Whois查询结果,域名持有者可以了解域名到期时间,及时续费避免域名被释放或高价赎回。'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const domain = ref('')
|
||||
const tmpdomain = ref('zakoflare.com')
|
||||
const loading = ref(false)
|
||||
const result = ref<any>(null)
|
||||
const error = ref('')
|
||||
const userIP = ref('')
|
||||
|
||||
const apiList = [
|
||||
...config.APIBaseURL.DualStack,
|
||||
]
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
// 高级选项:whois 只取 DNS 服务器(dns4/dns6),不支持强制解析
|
||||
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
|
||||
const whoisUrl = computed(() => withQuery('/middleware/' + backendID.value + '/whois/' + encodeURIComponent(domain.value), adv.value?.query() ?? {}))
|
||||
|
||||
const { data: whoisData, error: whoisError, execute: executeWhois } = useMiddlewareFetch<any>(whoisUrl, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
})
|
||||
|
||||
watch(whoisData, (newData) => {
|
||||
if (newData) {
|
||||
result.value = newData
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
watch(whoisError, async (newError) => {
|
||||
if (newError) {
|
||||
console.log(newError)
|
||||
if (currentApiIndex.value < apiList.length - 1) {
|
||||
const nextIndex = currentApiIndex.value + 1
|
||||
error.value = `${(newError as any).message || '请求失败'},正在重试 ${apiList[nextIndex]?.label || ''}...`
|
||||
currentApiIndex.value = nextIndex
|
||||
await nextTick()
|
||||
executeWhois()
|
||||
} else {
|
||||
error.value = '请求失败,请检查域名或网络'
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function queryWhois() {
|
||||
currentApiIndex.value = 0
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
result.value = null
|
||||
domain.value = tmpdomain.value.trim()
|
||||
if (!domain.value) {
|
||||
error.value = '请输入域名'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
nextTick(() => {
|
||||
executeWhois()
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getUserIP()
|
||||
const whoisParam = route.query.site as string
|
||||
if (whoisParam) {
|
||||
tmpdomain.value = whoisParam
|
||||
queryWhois()
|
||||
}
|
||||
})
|
||||
|
||||
async function getUserIP(){
|
||||
try {
|
||||
userIP.value = await $fetch<string>(config.DualStackAPI)
|
||||
} catch {
|
||||
// 获取失败保持为空,避免未处理的 Promise 拒绝
|
||||
}
|
||||
return userIP.value
|
||||
}
|
||||
|
||||
const { data: page } = await useAsyncData('whois-doc', () =>
|
||||
$fetch('/api/markdown/whois')
|
||||
)
|
||||
const doc = page.value
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '-'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function getStatusClass(status: string): string {
|
||||
if (status.includes('prohibited') || status.includes('lock')) return 'status-warning'
|
||||
return 'status-success'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="title">
|
||||
<header>
|
||||
<h1>Whois 域名查询</h1>
|
||||
<p>查询域名注册信息、注册商、到期时间等 Whois 数据</p>
|
||||
</header>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="tmpdomain"
|
||||
placeholder="请输入域名(如:example.com)"
|
||||
clearable
|
||||
/>
|
||||
<el-button
|
||||
@click="queryWhois()"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
>
|
||||
查询
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" :show-resolve="false" />
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="result" class="result-section">
|
||||
<table class="result-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="table-header">项目</th>
|
||||
<th class="table-header">信息</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="table-label">域名</td>
|
||||
<td class="table-value">{{ result.domain }}</td>
|
||||
</tr>
|
||||
<tr v-if="result.status?.length">
|
||||
<td class="table-label">域名状态</td>
|
||||
<td class="table-value">
|
||||
<span v-for="(s, i) in result.status" :key="i" :class="getStatusClass(s)" class="status-code" style="margin-right: 4px;">
|
||||
{{ s }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="result.registrar?.name">
|
||||
<td class="table-label">注册商</td>
|
||||
<td class="table-value">
|
||||
{{ result.registrar.name }}
|
||||
<span v-if="result.registrar.ianaId" style="color: #999; font-size: 0.9em;">(IANA ID: {{ result.registrar.ianaId }})</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="result.registrant && Object.keys(result.registrant).length">
|
||||
<td class="table-label">注册人</td>
|
||||
<td class="table-value">
|
||||
<div v-if="result.registrant.name">{{ result.registrant.name }}</div>
|
||||
<div v-if="result.registrant.org">{{ result.registrant.org }}</div>
|
||||
<div v-if="result.registrant.phone">{{ result.registrant.phone }}</div>
|
||||
<div v-if="result.registrant.email">{{ result.registrant.email }}</div>
|
||||
<div v-if="result.registrant.province">{{ result.registrant.province }}</div>
|
||||
<a v-if="result.registrant.contactUri" :href="result.registrant.contactUri" target="_blank" rel="noreferrer">查看注册信息</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="result.technical && Object.keys(result.technical).length">
|
||||
<td class="table-label">技术联系人</td>
|
||||
<td class="table-value">
|
||||
<div v-if="result.technical.name">{{ result.technical.name }}</div>
|
||||
<div v-if="result.technical.org">{{ result.technical.org }}</div>
|
||||
<div v-if="result.technical.phone">{{ result.technical.phone }}</div>
|
||||
<div v-if="result.technical.email">{{ result.technical.email }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="result.abuseContact && Object.keys(result.abuseContact).length">
|
||||
<td class="table-label">abuse 联系人</td>
|
||||
<td class="table-value">
|
||||
<div v-if="result.abuseContact.name">{{ result.abuseContact.name }}</div>
|
||||
<div v-if="result.abuseContact.phone">{{ result.abuseContact.phone }}</div>
|
||||
<div v-if="result.abuseContact.email">{{ result.abuseContact.email }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="result.dates?.registration">
|
||||
<td class="table-label">注册时间</td>
|
||||
<td class="table-value">{{ formatDate(result.dates.registration) }}</td>
|
||||
</tr>
|
||||
<tr v-if="result.dates?.expiration">
|
||||
<td class="table-label">到期时间</td>
|
||||
<td class="table-value">{{ formatDate(result.dates.expiration) }}</td>
|
||||
</tr>
|
||||
<tr v-if="result.dates?.lastChanged">
|
||||
<td class="table-label">最后修改</td>
|
||||
<td class="table-value">{{ formatDate(result.dates.lastChanged) }}</td>
|
||||
</tr>
|
||||
<tr v-if="result.nameservers?.length">
|
||||
<td class="table-label">DNS 服务器</td>
|
||||
<td class="table-value">
|
||||
<div v-for="ns in result.nameservers" :key="ns">{{ ns }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="result.whoisServer">
|
||||
<td class="table-label">Whois 服务器</td>
|
||||
<td class="table-value">{{ result.whoisServer }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<blockquote>
|
||||
数据来源:后端 WHOIS 服务,通过 IANA 注册局 bootstrap 文件自动匹配对应 WHOIS 服务器。<br/>
|
||||
部分注册局对注册人信息做了隐私保护,将显示为 "Redacted for Privacy"。<br/>
|
||||
如需查看完整注册信息,可点击 "查看注册信息" 链接跳转到注册局官网查询。<br/>
|
||||
访客IP: {{ userIP || '获取中...' }} 您的网络{{ isIPv6(userIP) ? 'IPv6' : 'IPv4' }}优先<br/>
|
||||
</blockquote>
|
||||
|
||||
<div v-if="doc" class="markdown">
|
||||
<div v-html="doc"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../style.css";
|
||||
@import "../../assets/css/tool-common.css";
|
||||
.markdown :deep(a) {
|
||||
color: #299764 !important;
|
||||
font-size: 1.3em;
|
||||
text-decoration: none
|
||||
}
|
||||
/* 1240px 宽的生命周期图需缩进内容列,桌面端也要约束 */
|
||||
.markdown :deep(img) {
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.markdown :deep(a) {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.markdown :deep(img) {
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
.el-menu--horizontal > .el-menu-item:nth-child(1) {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.status-code {
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #fef0f0;
|
||||
color: #F56C6C;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,11 @@ body{
|
||||
margin: 0 auto;
|
||||
|
||||
}
|
||||
/* 在样式开头添加 */
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.title{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -21,11 +26,18 @@ h2{
|
||||
font-size: 1.5em;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
h3{
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.content{
|
||||
display: flex;
|
||||
@@ -34,7 +46,7 @@ h2{
|
||||
width: 60%;
|
||||
min-width: 400px;
|
||||
margin: 0 auto;
|
||||
margin-top: 60px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -48,7 +60,10 @@ h2{
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
flex-wrap: wrap;
|
||||
word-break: break-all;
|
||||
hyphens: none; /* 防止自动添加连字符 */
|
||||
margin-top: 1em;
|
||||
}
|
||||
.ip-info{
|
||||
display: flex;
|
||||
@@ -94,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
|
||||
|
||||
@@ -116,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 {
|
||||
@@ -134,11 +150,15 @@ footer {
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
font-size: 1.9em;
|
||||
}
|
||||
h3{
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
|
||||
.title p {
|
||||
font-size: 0.8em;
|
||||
font-size: 0.9em;
|
||||
margin-right: 10px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
@@ -164,9 +184,17 @@ footer {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.one-line {
|
||||
flex-wrap: wrap;
|
||||
word-break: break-all;
|
||||
hyphens: none; /* 防止自动添加连字符 */
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.one-line p{
|
||||
font-size:1.1em
|
||||
}
|
||||
.one-line b{
|
||||
font-size:1.1em
|
||||
}
|
||||
.one-line a{
|
||||
font-size:1.1em
|
||||
}
|
||||
blockquote {
|
||||
font-size:0.9em;
|
||||
@@ -179,17 +207,17 @@ footer {
|
||||
}
|
||||
blockquote a{
|
||||
|
||||
color: #3EAF7C;
|
||||
font-size: 0.5em;
|
||||
text-decoration: none
|
||||
color: var(--link-color);
|
||||
font-size: 1em;
|
||||
/* 与上面的非窄屏规则保持一致:窄屏同样靠下划线做非颜色区分。
|
||||
此处若仍写 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);
|
||||
@@ -244,7 +272,7 @@ h4>.header-anchor,
|
||||
h5>.header-anchor,
|
||||
h6>.header-anchor {
|
||||
opacity: 1;
|
||||
color: #3EAF7C;
|
||||
color: var(--link-color);
|
||||
}
|
||||
|
||||
h1:hover>.header-anchor,
|
||||
@@ -257,5 +285,5 @@ h6:hover>.header-anchor {
|
||||
}
|
||||
.header-anchor::before {
|
||||
content: "#";
|
||||
color: #3EAF7C;
|
||||
color: var(--link-color);
|
||||
}
|
||||
@@ -49,6 +49,8 @@
|
||||
/* 结果区域 */
|
||||
.result-section {
|
||||
margin-top: 30px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
@@ -224,7 +226,7 @@ html.dark pre code {
|
||||
|
||||
.badge-item h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #3EAF7C;
|
||||
color: #299764;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
@@ -234,6 +236,30 @@ html.dark pre code {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
/* —— 行内换行自适应(容器查询)——
|
||||
.one-line 里的输入框/按钮是固定宽度,容器放不下时 flex 只换行不改宽,
|
||||
出现「换行了却不铺满」的夹缝态:换行点 ~605px(input 420 + 按钮 165 + 间距)、
|
||||
铺满点 768px(下方媒体查询断点),手机视口直接落在断点内看不出来,
|
||||
桌面缩窗在 768~换行点之间全是夹缝。容器查询补齐:容器低于行内容的
|
||||
自然换行点 ⇒ 换行的元素铺满整行。阈值略高于换行点保证无缝衔接;
|
||||
行内容更宽的页面(tcping 双输入框 1035px、dns 带 select 765px)
|
||||
在各自 scoped 样式里用同名容器再写一条更高阈值的规则。 */
|
||||
.one-line {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
@container (max-width: 615px) {
|
||||
.el-input,
|
||||
.el-button {
|
||||
width: 100% !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 窄屏响应式:所有组件缩小 */
|
||||
@media (max-width: 768px) {
|
||||
.el-input,
|
||||
|
||||
@@ -1,104 +1,206 @@
|
||||
export interface DocMeta {
|
||||
title: string
|
||||
description: string
|
||||
// —— SEO 专用字段(可选)——
|
||||
// 审计(Bing 站长工具)判定 10 个页面标题过短、13 个页面 meta description 过短,
|
||||
// 全是文档页:上面的 title/description 同时用于侧栏菜单与卡片展示,不适合拉长,
|
||||
// 因此文档页的 <title> 与 meta 优先取这里的加长版(见 doc 两个页面的 useHead)。
|
||||
seoTitle?: string
|
||||
seoDescription?: string
|
||||
}
|
||||
|
||||
export const docConfig: Record<string, DocMeta> = {
|
||||
'/doc': {
|
||||
title: 'IPv6 工具箱使用文档',
|
||||
description: 'IPv6 工具箱使用文档'
|
||||
description: '致力于普及 IPv6,推进 IPv6 规模部署和应用,以全面推进 IPv6 技术创新与融合应用为主线,以提升应用广度深度为主攻方向。支持 IPv6 本地网络(宽带)开启验证、IPv6 网站开启检测、IPv6 Ping 延迟测试、IPv6 网站测速、IPv6 地址查询、IPv6 DNS 解析。',
|
||||
seoTitle: 'IPv6 工具箱使用文档:宽带开启、网站检测、测速与 DNS 解析教程',
|
||||
seoDescription: 'IPv6 工具箱完整使用文档:涵盖家庭宽带开启 IPv6、网站 IPv6 支持检测、IPv6 Ping 延迟测试、IPv6 网站测速、IP 归属地查询与 IPv6 DNS 解析等教程,致力于普及 IPv6、推进 IPv6 规模部署和应用。'
|
||||
},
|
||||
'/doc/user/enable_ipv6': {
|
||||
title: '个人宽带如何开启IPv6网络访问',
|
||||
description: '介绍个人宽带如何开启IPv6网络访问'
|
||||
description: 'IPv6 是大势所趋,就在前段时间湖南联通发布公告,对家庭宽带提供 IPv6 地址,不再提供 IPv4 地址,那本文就介绍个人宽带如何开启 IPv6 网络访问。',
|
||||
seoTitle: '个人宽带如何开启 IPv6 网络访问:光猫与路由器设置教程',
|
||||
seoDescription: '手把手教你为家庭宽带开启 IPv6 网络访问:从光猫与路由器的设置,到向运营商申请(湖南联通等已对家庭宽带提供 IPv6 地址、不再提供 IPv4),附开启后的 IPv6 地址验证方法与常见问题排查。'
|
||||
},
|
||||
'/doc/user/cmd_bash_disable_ipv6': {
|
||||
title: '命令行禁用/启用IPv6本地网络',
|
||||
description: 'macOS和Window10命令行禁用/启用IPv6本地网络'
|
||||
description: '图形化禁用 IPv6 网络比较繁琐,以下是 macOS 和 Window 10 命令行下禁用/启用 IPv6 本地网络的方法。',
|
||||
seoTitle: 'macOS / Windows 命令行禁用或启用 IPv6 本地网络',
|
||||
seoDescription: '图形化开关 IPv6 网络比较繁琐,本文整理 macOS(networksetup)与 Windows 10/11(netsh、PowerShell)命令行下禁用/启用 IPv6 本地网络的完整命令,可直接复制执行,便于脚本化批量管理多台机器。'
|
||||
},
|
||||
'/doc/user/cmd_getip': {
|
||||
title: '命令行(curl)获取 IPv4 和 IPv6 地址',
|
||||
description: '使用命令行curl获取IPv4和IPv6地址'
|
||||
description: '通过 curl 命令获取公网 IPv4 和 IPv6 地址,还可以返回是 IPv4 还是 IPv6 访问优先。',
|
||||
seoTitle: 'curl 命令获取公网 IPv4 / IPv6 地址及访问优先级',
|
||||
seoDescription: '通过 curl 命令查询本机公网 IPv4 和 IPv6 地址:分别指定 -4 / -6 强制协议栈,还可一键返回当前网络是 IPv4 还是 IPv6 访问优先。适用于 Linux、macOS、Windows 终端以及监控脚本与 CI 环境调用。'
|
||||
},
|
||||
'/doc/user/view_ipv6_adress_url': {
|
||||
title: '浏览器访问 IPv6 地址',
|
||||
description: '如何在浏览器中访问IPv6地址'
|
||||
description: '在 IPv6 地址两边加上中括号,就可以通过浏览器访问了,比如 http://[2402:4e00:1013:e500:0:9671:f018:4947]/',
|
||||
seoTitle: '浏览器访问 IPv6 地址:中括号写法与端口注意事项',
|
||||
seoDescription: '在 IPv6 地址两边加上中括号即可用浏览器访问,例如 http://[2402:4e00:1013:e500:0:9671:f018:4947]/。本文介绍浏览器访问 IPv6 地址的正确写法、端口号的书写位置,以及无法访问时的排查思路。'
|
||||
},
|
||||
'/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 命令调整。',
|
||||
seoTitle: 'Windows 10/11 设置 IPv4/IPv6 访问优先级(netsh 前缀策略)',
|
||||
seoDescription: 'Windows 10/11 开启 IPv6 后默认 IPv6 访问优先(访问 IPv4/IPv6 双栈站点时操作系统会优先走 IPv6)。如果期望 IPv4 访问优先,可通过 netsh interface ipv6 修改前缀策略调整,本文提供可直接复制的命令与恢复默认的方法。'
|
||||
},
|
||||
'/doc/user/ipv6_daohang': {
|
||||
title: '国内 IPv6 资源导航',
|
||||
description: '国内IPv6资源导航列表'
|
||||
description: 'IPv6 网络开启成功后,可以访问一些 IPv6 网站资源,来体验下 IPv6 的魅力。不过目前纯 IPv6 网站比较少,目前网站支持 IPv6 的主流方式为 IPv4/IPv6 双栈访问,毕竟要兼顾 IPv4 网络用户的访问需求。',
|
||||
seoTitle: '国内 IPv6 资源导航:支持 IPv6 访问的网站大全',
|
||||
seoDescription: 'IPv6 网络开启成功后,可以访问这些国内 IPv6 网站资源体验下一代互联网。目前纯 IPv6 网站还比较少,主流网站支持 IPv6 的方式为 IPv4/IPv6 双栈访问,以兼顾 IPv4 网络用户的访问需求。'
|
||||
},
|
||||
'/doc/user/pure_ipv6_website': {
|
||||
title: '国内纯 IPv6 网站导航',
|
||||
description: '国内纯IPv6网站导航列表'
|
||||
description: 'IPv6 网络开启成功后,可以访问一些纯 IPv6 网站资源,来体验下 IPv6 的魅力。',
|
||||
seoTitle: '国内纯 IPv6 网站导航:仅支持 IPv6 访问的站点合集',
|
||||
seoDescription: '收录一批仅提供 IPv6 访问的国内纯 IPv6 网站:家庭宽带开启 IPv6 网络后即可直接访问,用来验证本机 IPv6 连通性,或体验真正的 IPv6-only 互联网服务,列表持续更新。'
|
||||
},
|
||||
'/doc/user/dns': {
|
||||
title: '全国各省 DNS 服务器列表',
|
||||
description: '全国各省DNS服务器列表'
|
||||
description: '全国各省电信、联通、移动的首选 IPv4 DNS 服务器和备用 DNS 服务器,以及公共 DNS 服务器列表,最近更新为 2021-07-25。',
|
||||
seoTitle: '全国各省电信/联通/移动 DNS 服务器地址列表(IPv4)',
|
||||
seoDescription: '全国各省电信、联通、移动的首选与备用 IPv4 DNS 服务器地址汇总,外加 114DNS、阿里 DNS、DNSPod 等公共 DNS 列表,按省份与运营商分类整理,方便网络排障时就近选用延迟最低的 DNS 服务器。'
|
||||
},
|
||||
'/doc/user/ipv6_dns': {
|
||||
title: 'IPv6 DNS 地址列表',
|
||||
description: 'IPv6 DNS地址列表'
|
||||
description: 'IPv6 DNS 地址列表,请求 IPv6 网络需要设置 IPv6 DNS。',
|
||||
seoTitle: 'IPv6 DNS 服务器地址列表:公共 IPv6 DNS 推荐',
|
||||
seoDescription: 'IPv6 网络需要设置 IPv6 DNS 才能正常解析域名。本文汇总国内外常用 IPv6 DNS 服务器地址:包括阿里、DNSPod、Google、Cloudflare 等公共 IPv6 DNS,可直接复制到光猫、路由器或网卡设置中。'
|
||||
},
|
||||
'/doc/user/AliyunAuthorizeSecurityGroup': {
|
||||
title: '阿里云自动化添加安全组',
|
||||
description: '阿里云自动化添加安全组'
|
||||
description: 'Golang 代码实操阿里云自动化添加安全组',
|
||||
seoTitle: 'Golang 实现阿里云 ECS 安全组规则自动化添加',
|
||||
seoDescription: '使用 Golang 调用阿里云 ECS OpenAPI 实现安全组放行规则的自动化添加:包含 SDK 初始化、AccessKey 鉴权、AuthorizeSecurityGroup 接口的参数说明与完整示例代码,适合批量管理多台 ECS 实例的场景。'
|
||||
},
|
||||
'/doc/user/TencentCloudAddSecurityGroup': {
|
||||
title: '腾讯云自动化添加安全组',
|
||||
description: '腾讯云自动化添加安全组'
|
||||
description: 'Golang 代码实操腾讯云自动化添加安全组',
|
||||
seoTitle: 'Golang 实现腾讯云 CVM 安全组规则自动化添加',
|
||||
seoDescription: '使用 Golang 调用腾讯云 CVM OpenAPI 实现安全组规则的自动化添加:涵盖 SDK 安装、密钥配置、安全组策略接口的调用示例与完整源码,便于在脚本中批量放行端口、管理大量云主机实例。'
|
||||
},
|
||||
'/doc/server/website_enable_ipv6': {
|
||||
title: '网站开启 IPv6 的三种方式',
|
||||
description: '从传统二进制部署的Nginx,到云原生部署的K8S、Istio,分别介绍网站开启IPv6的三种方式'
|
||||
description: '从传统二进制部署的 Nginx,到云原生部署的 K8S、Istio,分别介绍网站开启 IPv6 的三种方式。',
|
||||
seoTitle: '网站开启 IPv6 的三种方式:Nginx、K8s 与 Istio',
|
||||
seoDescription: '从传统二进制部署的 Nginx 监听 IPv6 地址,到云原生部署的 K8s Service 双栈与 Istio 网关,一文讲清网站开启 IPv6 的三种方式及各自适用场景,附关键配置片段与开启后的验证方法。'
|
||||
},
|
||||
'/doc/server/tencent_cloud_cvm_ipv6': {
|
||||
title: '腾讯云 cvm 开启 IPv6',
|
||||
description: '一文看懂在腾讯云CVM上开启IPv6,只需7步'
|
||||
description: '一文看懂在腾讯云 CVM 上开启 IPv6,只需 7 步。',
|
||||
seoTitle: '腾讯云 CVM 开启 IPv6 完整教程(只需 7 步)',
|
||||
seoDescription: '一文看懂在腾讯云 CVM 上开启 IPv6:从 VPC 与子网启用 IPv6、给实例分配 IPv6 地址、安全组放行,到系统内网卡配置与连通性验证,共 7 步完成,附每步操作说明与常见报错的处理办法。'
|
||||
},
|
||||
'/doc/server/nginx_ipv6': {
|
||||
title: 'Nginx 开启 IPv6',
|
||||
description: '一文看懂Nginx中开启IPv6,包含设置IPv6 SSL证书'
|
||||
description: '一文看懂 Nginx 中开启 IPv6,包含设置 IPv6 SSL 证书。',
|
||||
seoTitle: 'Nginx 开启 IPv6:listen 监听与 IPv6 SSL 证书配置',
|
||||
seoDescription: '一文看懂 Nginx 中开启 IPv6:listen [::] 监听写法、与 IPv4 共存的配置示例、IPv6 SSL 证书的设置,以及防火墙放行 IPv6 流量,配置完成后可用 IPv6 网站检测工具一键验证。'
|
||||
},
|
||||
'/doc/server/ipv6webcheck': {
|
||||
title: '如何确认一个网站是否开启 IPv6',
|
||||
description: '如何确认一个网站是否开启IPv6'
|
||||
description: '通过 IPv6 网站检测工具,一键检查网站是否开启 IPv6 访问。',
|
||||
seoTitle: '如何确认一个网站是否开启 IPv6:一键检测方法',
|
||||
seoDescription: '使用本站的 IPv6 网站检测工具,输入域名即可一键检查网站是否开启 IPv6 访问:返回 IPv4 与 IPv6 双栈的 HTTP/HTTPS 状态码、DNS 解析与连接耗时对比数据,并可申请 IPv6 徽标认证。'
|
||||
},
|
||||
'/doc/server/ipv6_sign': {
|
||||
title: '网站增加支持IPv6访问标识',
|
||||
description: 'HTML+CSS代码,可以提醒访客本网站支持IPv6访问'
|
||||
description: 'HTML+CSS 代码,可以提醒访客本网站支持 IPv6 访问,同时可以跳转到 IPv6 网站检测页面。',
|
||||
seoTitle: '网站添加「支持 IPv6 访问」标识(HTML+CSS 代码)',
|
||||
seoDescription: '提供一段现成的 HTML+CSS 代码,在网站页脚展示「本网站支持 IPv6 访问」标识,点击即可跳转到 IPv6 网站检测页面实时验证,让访客直观感知网站的 IPv6 支持状态。'
|
||||
},
|
||||
'/doc/server/ipv6_domain_record': {
|
||||
title: '如何为域名添加 IPv6 解析记录',
|
||||
description: '如何为域名添加IPv6解析记录'
|
||||
description: 'IPv6 网站准备好了后,接下来可以为网站域名添加 IPv6 解析记录(AAAA)。',
|
||||
seoTitle: '如何为域名添加 IPv6 解析记录(AAAA 记录)',
|
||||
seoDescription: '网站支持 IPv6 后,还需为域名添加 IPv6 解析记录(AAAA)。本文演示在常见 DNS 服务商控制台添加 AAAA 记录的操作步骤、TTL 建议值,以及添加后用 dig / nslookup 验证解析是否生效。'
|
||||
},
|
||||
'/doc/server/http2': {
|
||||
title: '原创网站如何开启 HTTP/2',
|
||||
description: '介绍网站如何开启HTTP/2协议'
|
||||
title: '网站如何开启 HTTP/2',
|
||||
description: 'HTTP/2 通过引入字段压缩和允许在同一连接上进行多个并发交换,使网络资源得到更有效的利用,并减少了延迟。',
|
||||
seoTitle: '网站如何开启 HTTP/2:Nginx 配置与抓包验证',
|
||||
seoDescription: 'HTTP/2 通过引入字段压缩和允许在同一连接上进行多个并发交换,使网络资源得到更有效的利用并减少延迟。本文介绍网站在 Nginx 中开启 HTTP/2 的配置方法,并用浏览器与 Wireshark 抓包验证协商结果。'
|
||||
},
|
||||
'/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 的理解。',
|
||||
seoTitle: 'IPv6 RFC 8200 解读:IPv6 协议规范逐节精读',
|
||||
seoDescription: 'RFC 8200(Internet Protocol, Version 6 (IPv6) Specification)是 IPv6 协议的权威定义。本文逐节解读其报文格式、扩展报头、地址语义等核心内容,在精读文档的过程中建立对 IPv6 的完整理解。'
|
||||
},
|
||||
'/doc/rfc/ipv6_address_format': {
|
||||
title: 'IPv6 地址标识方法',
|
||||
description: '首选格式、压缩格式、内嵌IPv4地址的IPv6地址格式'
|
||||
description: '首选格式、压缩格式、内嵌 IPv4 地址的 IPv6 地址格式。',
|
||||
seoTitle: 'IPv6 地址表示方法:首选格式、压缩格式与内嵌 IPv4',
|
||||
seoDescription: 'IPv6 地址共 128 位,本文讲解三种标准表示方法:首选格式(8 组 4 位十六进制数)、零压缩格式(:: 缩写规则)以及内嵌 IPv4 地址的混合表示法,并给出每组示例与常见的书写误区。'
|
||||
},
|
||||
'/doc/user/tcpdump_ipv6': {
|
||||
title: 'tcpdump 分析IPv6包',
|
||||
description: '使用tcpdump分析IPv6包'
|
||||
title: 'tcpdump 分析 IPv6 包',
|
||||
description: 'tcpdump 分析 IPv6 包',
|
||||
seoTitle: 'tcpdump 抓包分析 IPv6 数据包与常用过滤命令',
|
||||
seoDescription: '使用 tcpdump 抓取并分析 IPv6 数据包:整理 ip6、icmp6、TCP/UDP 端口等常用过滤表达式,配合 -nn -vv 等参数看清 IPv6 报文结构,适用于排查 IPv6 连通性与邻居发现(NDP)协议问题。'
|
||||
},
|
||||
'/doc/user/wireshark_ipv6': {
|
||||
title: 'WireShark 分析IPv6包头',
|
||||
description: '使用WireShark分析IPv6包头'
|
||||
description: '使用 WireShark 分析 IPv6 包头',
|
||||
seoTitle: 'WireShark 分析 IPv6 包头:各字段逐项讲解',
|
||||
seoDescription: '使用 WireShark 逐字段分析 IPv6 包头:版本号、流量类别(Traffic Class)、流标签(Flow Label)、有效载荷长度、下一报头、跳数限制与源/目的地址,配合显示过滤器快速定位 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',
|
||||
seoTitle: 'IPv6 Ping 检测原理:ICMPv6 回显请求与应答',
|
||||
seoDescription: 'IPv6 Ping 基于 ICMPv6 协议(Internet Control Message Protocol for IPv6)的回显请求(Echo Request)与应答(Echo Reply)实现。本文讲解 ICMPv6 报文结构与类型字段,以及 IPv6 为何用多播取代广播探测。'
|
||||
},
|
||||
'/doc/api/ip/locate': {
|
||||
title: '获取客户端公网 IP 及位置',
|
||||
description: '调用本项目的 /v1/location 接口,一次取回访问者自己的公网 IP 与 10 套 IP 库的归属地结果。',
|
||||
seoTitle: 'API 文档:获取客户端公网 IP 与多库归属地结果',
|
||||
seoDescription: '调用本项目的 /v1/location 接口,一次取回访问者自己的公网 IPv4/IPv6 地址与 10 套 IP 库(MaxMind、IP2Region、GeoCN 等)的归属地结果,返回 JSON、支持跨域,附请求示例与响应字段说明。'
|
||||
},
|
||||
'/doc/api/ip/myip': {
|
||||
title: '获取客户端公网 IP 地址',
|
||||
description: '用自带边缘函数获取客户端公网 IP,纯文本返回、支持跨域,可按 IPv4 / IPv6 / 双栈分别部署。',
|
||||
seoTitle: 'API 文档:边缘函数获取客户端公网 IP(纯文本跨域)',
|
||||
seoDescription: '使用本项目自带的边缘函数获取客户端公网 IP:纯文本直接返回、支持 CORS 跨域,可按 IPv4-only / IPv6-only / 双栈三种方式分别部署,适合脚本、服务器监控与 CI 环境快速取公网 IP。'
|
||||
},
|
||||
'/doc/api/ip/query': {
|
||||
title: '查询指定 IP 地址的位置信息',
|
||||
description: '调用本项目的 /v1/location/<ip> 接口查询任意 IPv4 / IPv6 地址的归属地,返回多源聚合结果。',
|
||||
seoTitle: 'API 文档:查询指定 IPv4/IPv6 地址的归属地信息',
|
||||
seoDescription: '调用本项目的 /v1/location/<ip> 接口查询任意 IPv4 / IPv6 地址的归属地:返回多套 IP 数据库聚合的地理位置、运营商、ASN 等结果,支持节点直连与中间件转发两种调用方式,附完整响应示例。'
|
||||
},
|
||||
'/doc/api/whois/query': {
|
||||
title: '查询域名的 WHOIS 信息',
|
||||
description: '调用本项目的 /v1/whois/<domain> 接口查询域名注册信息:IANA → 注册局 → 注册商转介三跳,返回状态、注册商、关键日期与 NS。',
|
||||
seoTitle: 'API 文档:查询域名 WHOIS 注册信息(三跳转介)',
|
||||
seoDescription: '调用本项目的 /v1/whois/<domain> 接口查询域名注册信息:按 IANA → 注册局 → 注册商三跳转介获取完整 WHOIS 数据,返回查询状态、注册商、注册/到期等关键日期与 NS 服务器字段。'
|
||||
},
|
||||
'/doc/server/check_userip_ipv4_ipv6': {
|
||||
title: 'JS 检查网络是 IPv4 还是 IPv6',
|
||||
description: '通过 JS 代码检查客户端是 IPv4 还是 IPv6 访问优先。',
|
||||
seoTitle: 'JS 检查客户端网络是 IPv4 还是 IPv6 访问优先',
|
||||
seoDescription: '通过一段前端 JS 代码检查客户端当前是 IPv4 还是 IPv6 访问、以及双栈网络下的访问优先级:分别向 IPv4/IPv6 探测端点发起请求并按可达性判定,可嵌入任意网页实时展示访客的网络栈状态。'
|
||||
},
|
||||
'/doc/server/tls': {
|
||||
title: '网站如何开启 TLS',
|
||||
description: '用 Wireshark 抓包分析 TLS 1.3 的 TCP 三次握手与 TLS 协商全过程。',
|
||||
seoTitle: '网站如何开启 TLS:TLS 1.3 握手过程抓包分析',
|
||||
seoDescription: '用 Wireshark 抓包分析 TLS 1.3 的 TCP 三次握手与 TLS 协商全过程:ClientHello、密钥共享(Key Share)、证书验证与 Finished 报文逐个拆解,理解网站开启 TLS 加密后的完整建连链路。'
|
||||
},
|
||||
'/doc/user/code_getip': {
|
||||
title: 'Python/Go 获取 IPv4 和 IPv6 地址',
|
||||
description: '通过 Python/Golang 获取公网 IPv4 和 IPv6 地址,还可以返回是 IPv4 还是 IPv6 访问优先。',
|
||||
seoTitle: 'Python / Golang 代码获取公网 IPv4 与 IPv6 地址',
|
||||
seoDescription: '用 Python 和 Golang 获取本机公网 IPv4 和 IPv6 地址:分别请求 IPv4、IPv6 探测端点并判定当前网络的访问优先栈,附可直接运行的完整示例代码,适合嵌入监控脚本或后端服务使用。'
|
||||
},
|
||||
'/doc/user/dns_dig': {
|
||||
title: 'DNS 解析流程',
|
||||
description: '剖析 DNS 解析流程',
|
||||
seoTitle: 'DNS 解析流程详解:递归查询与迭代查询全过程',
|
||||
seoDescription: '剖析一次完整的 DNS 解析流程:浏览器缓存 → 操作系统缓存 → 本地 DNS 服务器的递归查询 → 根域名 / 顶级域 / 权威服务器的迭代过程,配合 dig 命令逐步演示,理解域名解析为何时快时慢。'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,142 +2,169 @@
|
||||
/*
|
||||
前端的一系列配置
|
||||
*/
|
||||
|
||||
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/* 请求会依次尝试这些节点(出错重试下一个),
|
||||
// 前端自带中间件始终放在候选列表最后一位兜底。
|
||||
// 未配置或为空数组时,直接使用前端自带中间件。
|
||||
Middleware: <string[]>[
|
||||
"https://middleware-1.api-ipw.wsmdn.top/",
|
||||
"https://boce-api.api-ipw.wsmdn.top/"
|
||||
|
||||
// "https://middleware-2.wsmdn.top/",
|
||||
],
|
||||
// Umami 统计
|
||||
umamiHost: "https://umami.wsmdn.top/",
|
||||
umamiScriptUrl: "https://umami.wsmdn.top/zako.js",
|
||||
umamiWebsiteId: "69a91329-b110-4cf7-a04a-be4360b1a8d3",
|
||||
// 中华人民共和国备案系统
|
||||
ICP: "苏ICP备2026012471号",
|
||||
GongAn: "苏公网安备32132402000813号",
|
||||
// Worker IP查询接口
|
||||
v4OnlyAPI: "https://4.wsmdn.dpdns.org/",
|
||||
v6OnlyAPI: "https://6.wsmdn.dpdns.org/",
|
||||
DualStackAPI: "https://test.wsmdn.dpdns.org/",
|
||||
apiBaseUrls: [
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
|
||||
url: "https://cn-shenzhen.api-ipw.wsmdn.top/"
|
||||
}
|
||||
],
|
||||
IPLocationAPIs: [
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 四川 沙渠 电信[ZFC]",
|
||||
url: "https://cn2-sichuan.api-ipw.wsmdn.top/"
|
||||
}
|
||||
],
|
||||
ICP: "",
|
||||
GongAn: "",
|
||||
// 全站是否禁止搜索引擎索引
|
||||
noindex: false,
|
||||
TCPing:{
|
||||
DualStack: [
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
url :"https://cn-jiangsu.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
|
||||
url :"https://cn-shenzhen.api-ipw.wsmdn.top/",
|
||||
},
|
||||
],
|
||||
IPv4: [
|
||||
{
|
||||
label: "中国 广东 广州 腾讯云",
|
||||
url :"https://cn-guangzhou.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "新加坡 腾讯云",
|
||||
url :"https://sg-1.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 陕西 西安 北经济技术开发区 未央区凤城 中国电信[ZFC]",
|
||||
url :"https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/",
|
||||
},
|
||||
],
|
||||
IPv6: [
|
||||
{
|
||||
label: "中国 四川 沙渠 电信[ZFC]",
|
||||
url:"https://cn2-sichuan.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
|
||||
url:"https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/",
|
||||
}
|
||||
]
|
||||
},
|
||||
SpeedTest:{
|
||||
DualStack: [
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
url :"https://cn-jiangsu.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
|
||||
url :"https://cn-shenzhen.api-ipw.wsmdn.top/",
|
||||
},
|
||||
],
|
||||
IPv4: [
|
||||
{
|
||||
label: "中国 广东 广州 腾讯云",
|
||||
url :"https://cn-guangzhou.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "新加坡 腾讯云",
|
||||
url :"https://sg-1.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 陕西 西安 北经济技术开发区 未央区凤城 中国电信[ZFC]",
|
||||
url :"https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/",
|
||||
},
|
||||
],
|
||||
IPv6: [
|
||||
{
|
||||
label: "中国 四川 沙渠 电信[ZFC]",
|
||||
url:"https://cn2-sichuan.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
|
||||
url:"https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/",
|
||||
}
|
||||
]
|
||||
},
|
||||
NSLookup:[
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
url :"https://cn-jiangsu.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 广州 腾讯云",
|
||||
url :"https://cn-guangzhou.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "新加坡 腾讯云",
|
||||
url :"https://sg-1.api-ipw.wsmdn.top/",
|
||||
},
|
||||
// Worker IP查询接口
|
||||
v4OnlyAPI: "https://4.wsmdn.top",
|
||||
v6OnlyAPI: "https://6.wsmdn.top",
|
||||
DualStackAPI: "https://test.wsmdn.top",
|
||||
// 网站截图服务(screenshot 页主通道):thum.io 同步出图、CORS 全开,
|
||||
// 响应 content-type 即结果信号(PNG/JPEG=成品,GIF=生成中或目标不可达),
|
||||
// 失败自动回退 WordPress mshots(s0.wp.com,见 screenshot.vue)。
|
||||
// 写进 config 的另一个作用:nuxt.config 的 extractDomains 会把它收进
|
||||
// CSP connect-src 白名单,页面里的 fetch 才能通过安全头。
|
||||
screenshotAPI: "https://image.thum.io",
|
||||
// ---- 节点池结构 ----
|
||||
// IPLocationAPI:IP 归属查询(location / asn)上游节点池(纯数组,无栈区分)
|
||||
IPLocationAPI: [
|
||||
{
|
||||
label: "中国 四川 沙渠 电信[ZFC]",
|
||||
url:"https://cn2-sichuan.api-ipw.wsmdn.top/",
|
||||
id: "cn2-sichuan",
|
||||
url: "https://cn2-sichuan.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 陕西 西安 北经济技术开发区 未央区凤城 中国电信[ZFC]",
|
||||
url :"https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
|
||||
url:"https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/",
|
||||
},
|
||||
{
|
||||
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
|
||||
url :"https://cn-shenzhen.api-ipw.wsmdn.top/",
|
||||
label: "中国 江苏 移动",
|
||||
id: "cn-jiangsu",
|
||||
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
}
|
||||
]
|
||||
// {
|
||||
// label: "中国 湖北 武汉 电信",
|
||||
// id: "cn-wuhan-chinatelecom",
|
||||
// url: ""
|
||||
// },
|
||||
|
||||
],
|
||||
// APIBaseURL:其余拨测(whois / ssl / detail / dns / dnssec / tcping / speed)上游节点池,含 { IPv6, IPv4, DualStack } 三栈
|
||||
APIBaseURL: {
|
||||
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/"
|
||||
},
|
||||
],
|
||||
IPv4: [
|
||||
{
|
||||
label: "中国 广东 广州 腾讯云",
|
||||
id: "cn-guangzhou",
|
||||
url: "https://cn-guangzhou.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "新加坡 腾讯云",
|
||||
id: "sg-1",
|
||||
url: "https://sg-1.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 香港 新界 西贡区 将军澳 MICC[ZFC]",
|
||||
id: "hk-shatin",
|
||||
url: "https://china-hk-kowloon-shatindistrict-newcloud.ipwapi.zfap.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 陕西 西安 北经济技术开发区 未央区凤城 中国电信[ZFC]",
|
||||
id: "cn-xian",
|
||||
url: "https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "美国/加利福尼亚州/洛杉矶/蒙特利奇帕克/USCD[ZFC]",
|
||||
id: "us-la",
|
||||
url: "https://america-california-losangeles.ipwapi.zfap.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
// 与 IPLocationAPI 池共享的节点:ASN 页取两池交集,
|
||||
// 只有同时出现在两个池里的节点才会显示(cn-xian 仅双栈池故不显示)
|
||||
label: "中国 四川 沙渠 电信[ZFC]",
|
||||
id: "cn2-sichuan",
|
||||
url: "https://cn2-sichuan.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
id: "cn-jiangsu",
|
||||
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
},
|
||||
// IP 直连新节点:URL 留空,仅经独立中间件转发
|
||||
{
|
||||
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/"
|
||||
},
|
||||
{
|
||||
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
|
||||
id: "cn-shenzhen",
|
||||
url: "https://cn-shenzhen.api-ipw.wsmdn.top/"
|
||||
},
|
||||
// {
|
||||
// label: "河南 安阳 联通",
|
||||
// "id": "anyang-cu"
|
||||
// },
|
||||
// 仅经独立中间件转发:URL 留空,内置中间件会直接报 502 "Backend URL not configured"
|
||||
{
|
||||
label: "上海市 上海市 奉贤区 移动",
|
||||
id: "70dc5411-0cb6-425d-838f-a9bc81984ed4",
|
||||
url: ""
|
||||
},
|
||||
{
|
||||
label: "江西 南昌 移动",
|
||||
id: "97d8873f-f0bb-4e3a-bf41-0b78878b8f34",
|
||||
url: ""
|
||||
},
|
||||
{
|
||||
label: "中国 广东 梅州 电信",
|
||||
id: "b3e06025-d9bd-458d-b1c4-cbba27f2a537",
|
||||
url: ""
|
||||
}
|
||||
// {
|
||||
// label: "中国 湖北 武汉 电信",
|
||||
// id: "cn-wuhan-chinatelecom",
|
||||
// url: ""
|
||||
// },
|
||||
]
|
||||
}
|
||||
}
|
||||
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',
|
||||
@@ -24,27 +56,51 @@ export default defineNuxtConfig({
|
||||
vite: {
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'dayjs',
|
||||
'dayjs/plugin/*.js',
|
||||
'is-ip',
|
||||
'lodash-unified',
|
||||
'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(否则异步加载)
|
||||
@@ -60,20 +116,44 @@ 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]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
},
|
||||
runtimeConfig: {
|
||||
indexnowKey: '',
|
||||
// IndexNow 所有权凭证:与 public/<同名>.txt 一致(IndexNow 靠抓取该文件验证),
|
||||
// key 本身是公开凭证不是机密;环境变量 NUXT_INDEXNOW_KEY 可覆盖。
|
||||
// 消费方:server/api/indexnow.*.ts(运行时提交端点)。
|
||||
indexnowKey: 'd49c90fb2fb160cdd2cd4b09532bc380',
|
||||
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。
|
||||
},
|
||||
},
|
||||
nitro: {
|
||||
preset: 'cloudflare-module',
|
||||
routeRules: {
|
||||
// 缓存头一律不在这里设,交由 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',
|
||||
@@ -84,9 +164,24 @@ nitro: {
|
||||
options: {
|
||||
target: 'es2022' // 明确告诉 Nitro 使用 es2022 进行打包
|
||||
}
|
||||
}
|
||||
},
|
||||
// 这里曾用 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: {
|
||||
// 预渲染页的 CSP 会因 SRI modulepreload 哈希全量拼入而单行超 2000 字符,
|
||||
// 触发 Cloudflare _headers 限制,故关掉静态页的安全头写入。
|
||||
// 当前没有预渲染路由(/doc/** 已改回 SSR),这条是留给将来再开 prerender 时的开关:
|
||||
// 一旦重新启用 prerender,那些页就会退回「无 CSP」状态。
|
||||
nitroHeaders: false,
|
||||
},
|
||||
headers: {
|
||||
contentSecurityPolicy: {
|
||||
|
||||
@@ -104,6 +199,7 @@ nitro: {
|
||||
],
|
||||
|
||||
'style-src': ["'self'", 'https:', "'unsafe-inline'"],
|
||||
'img-src': ["'self'", 'https://s0.wp.com', 'data:', 'blob:', 'https:'],
|
||||
'font-src': ["'self'", 'https:', 'data:'],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,32 +14,37 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@element-plus/nuxt": "^1.1.5",
|
||||
"@nuxt/content": "3.14.0",
|
||||
"@nuxtjs/robots": "^6.1.1",
|
||||
"@nuxtjs/sitemap": "^8.2.1",
|
||||
"@nuxtjs/robots": "^6.1.3",
|
||||
"@nuxtjs/sitemap": "^8.3.2",
|
||||
"@nuxtjs/tailwindcss": "6.14.0",
|
||||
"@shikijs/engine-javascript": "^4.3.1",
|
||||
"@shikijs/core": "^4.4.1",
|
||||
"@shikijs/engine-javascript": "^4.4.1",
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"@vueuse/nuxt": "^14.3.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"element-plus": "^2.14.2",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"@vueuse/nuxt": "^14.4.0",
|
||||
"element-plus": "^2.14.3",
|
||||
"github-markdown-css": "^5.9.0",
|
||||
"github-slugger": "^2.0.0",
|
||||
"is-ip": "^5.0.1",
|
||||
"markdown-it": "^14.2.0",
|
||||
"leaflet": "1.9.4",
|
||||
"markdown-it": "^14.3.0",
|
||||
"markdown-it-anchor": "^9.2.1",
|
||||
"nuxt": "^4.4.8",
|
||||
"nuxt": "^4.5.1",
|
||||
"nuxt-security": "2.6.0",
|
||||
"vue": "^3.5.35",
|
||||
"vue-router": "^5.1.0"
|
||||
"vue": "^3.5.40",
|
||||
"vue-router": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@shikijs/markdown-it": "^4.3.0",
|
||||
"@shikijs/langs": "^4.4.1",
|
||||
"@shikijs/markdown-it": "^4.4.1",
|
||||
"@shikijs/themes": "^4.4.1",
|
||||
"@shikijs/types": "^4.4.1",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"nitro-cloudflare-dev": "^0.2.2",
|
||||
"shiki": "^4.2.0",
|
||||
"wrangler": "^4.107.0"
|
||||
"sass-embedded": "^1.100.0",
|
||||
"shiki": "^4.4.1",
|
||||
"wrangler": "^4.118.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
d49c90fb2fb160cdd2cd4b09532bc380
|
||||
@@ -0,0 +1,154 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1240 640" width="1240" height="640" data-generator="fireworks-tech-graph" data-schema-version="1" data-text-metrics="heuristic-v1" data-style-id="7" data-visual-theme="OpenAI" data-diagram-type="architecture" data-motion-scene="" data-semantic-profile="generic" data-semantic-valid="true" data-quality-profile="standard" data-max-bends-per-edge="12" data-max-total-bends="100" data-max-route-stretch="5.0" data-max-bridged-crossings="8" data-min-node-gap="0.0" data-min-container-gutter="0.0" data-min-label-clearance="2.0" data-min-segment-length="0.0">
|
||||
<defs>
|
||||
<marker id="arrowA" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#10a37f"/>
|
||||
</marker>
|
||||
<marker id="arrowB" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#0f766e"/>
|
||||
</marker>
|
||||
<marker id="arrowC" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#0891b2"/>
|
||||
</marker>
|
||||
<marker id="arrowE" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#f59e0b"/>
|
||||
</marker>
|
||||
<marker id="arrowF" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#64748b"/>
|
||||
</marker>
|
||||
<marker id="arrowG" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#475569"/>
|
||||
</marker>
|
||||
<marker id="arrowH" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#94a3b8"/>
|
||||
</marker>
|
||||
<style>
|
||||
text { font-family: 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', 'Noto Sans CJK SC', 'Microsoft YaHei', 'Microsoft JhengHei', 'SimHei', sans-serif; }
|
||||
.title { font-size: 24px; font-weight: 700; fill: #0f172a; }
|
||||
.subtitle { font-size: 13px; font-weight: 500; fill: #64748b; }
|
||||
.section { font-size: 13px; font-weight: 700; fill: #087f5b; letter-spacing: 1.4px; }
|
||||
.section-sub { font-size: 12px; font-weight: 500; fill: #64748b; }
|
||||
.node-title { font-weight: 700; fill: #0f172a; }
|
||||
.node-sub { font-weight: 500; fill: #475569; }
|
||||
.node-type { font-size: 11px; font-weight: 700; fill: #64748b; letter-spacing: 0.08em; }
|
||||
.arrow-label { font-size: 12px; font-weight: 600; fill: #475569; }
|
||||
.legend { font-size: 12px; font-weight: 500; fill: #475569; }
|
||||
.footnote { font-size: 12px; font-weight: 500; fill: #64748b; }
|
||||
.metric-label { font-size: 8.5px; font-weight: 700; fill: #64748b; text-transform: uppercase; }
|
||||
.metric-value { font-size: 9.5px; font-weight: 700; fill: #0f172a; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect data-graph-role="background" width="1240.0" height="640.0" fill="#ffffff"/>
|
||||
<text x="48.0" y="48" text-anchor="start" class="title">DNSSEC 验证链与本地验签</text>
|
||||
<text x="48.0" y="72" text-anchor="start" class="subtitle">DS 信任链自根向下逐级建立,递归侧以 DNSKEY 验证 RRset 签名</text>
|
||||
<line x1="48" y1="100" x2="1192.0" y2="100" stroke="#e2e8f0" stroke-width="1"/>
|
||||
<g id="trust" data-graph-role="container" data-container-id="trust" data-semantic-role="boundary" data-graph-bounds="40,110,1200,260">
|
||||
<rect data-graph-role="container" x="40.0" y="110.0" width="1160.0" height="150.0" rx="16.0" fill="none" stroke="#e2e8f0" stroke-width="1.4" stroke-dasharray="5 4"/>
|
||||
<text x="58.0" y="134.0" class="section">信任链 · 由根向下逐级签发</text>
|
||||
<rect id="trust-header" data-graph-role="reserved" data-reserved-kind="container-header" x="48" y="116" width="189.9" height="30" fill="none" stroke="none"/>
|
||||
</g>
|
||||
<g id="verify" data-graph-role="container" data-container-id="verify" data-semantic-role="boundary" data-graph-bounds="40,310,1200,560">
|
||||
<rect data-graph-role="container" x="40.0" y="310.0" width="1160.0" height="250.0" rx="16.0" fill="none" stroke="#e2e8f0" stroke-width="1.4" stroke-dasharray="5 4"/>
|
||||
<text x="58.0" y="334.0" class="section">递归解析侧 · 本地验签</text>
|
||||
<rect id="verify-header" data-graph-role="reserved" data-reserved-kind="container-header" x="48" y="316" width="163.9" height="30" fill="none" stroke="none"/>
|
||||
</g>
|
||||
<path id="ds-tld" data-graph-role="edge" data-edge-id="ds-tld" data-source="root" data-target="tld" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 330,195 L 495,195" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="ds-auth" data-graph-role="edge" data-edge-id="ds-auth" data-source="tld" data-target="auth" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 745,195 L 905,195" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="pub-key" data-graph-role="edge" data-edge-id="pub-key" data-source="auth" data-target="dnskey" data-edge-kind="flow" data-topic-id="" data-flow="write" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="" d="M 1023.5,225 L 1023.5,346 L 205,346 L 205,370" fill="none" stroke="#0f766e" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowB)"/>
|
||||
<path id="sign" data-graph-role="edge" data-edge-id="sign" data-source="auth" data-target="rrset" data-edge-kind="flow" data-topic-id="" data-flow="write" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="6" data-route-stretch="1.872" data-bridges="" d="M 1041.5,225 L 1041.5,245.4 L 1184,245.4 L 1184,454 L 745,454 L 745,349.6 L 620,349.6 L 620,370" fill="none" stroke="#0f766e" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowB)"/>
|
||||
<path id="fetch-key-bridge-mask" data-graph-role="bridge-mask" data-owner="fetch-key" d="M 330,400 L 350.4,400 L 350.4,461 L 884.6,461 L 884.6,391 L 905,391" fill="none" stroke="#ffffff" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="fetch-key" data-graph-role="edge" data-edge-id="fetch-key" data-source="dnskey" data-target="resolver" data-edge-kind="flow" data-topic-id="" data-flow="read" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="1.209" data-bridges="884.6,454" d="M 330,400 L 350.4,400 L 350.4,461 L 884.6,461 L 884.6,391 L 905,391" fill="none" stroke="#0891b2" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowC)"/>
|
||||
<path id="fetch-rr-bridge-mask" data-graph-role="bridge-mask" data-owner="fetch-rr" d="M 745,400 L 748.28,400 L 748.28,409 L 879.6,409 A 5 5 0 0 0 889.6,409 L 905,409" fill="none" stroke="#ffffff" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="fetch-rr" data-graph-role="edge" data-edge-id="fetch-rr" data-source="rrset" data-target="resolver" data-edge-kind="flow" data-topic-id="" data-flow="read" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="745,400;884.6,409" d="M 745,400 L 748.28,400 L 748.28,409 L 879.6,409 A 5 5 0 0 0 889.6,409 L 905,409" fill="none" stroke="#0891b2" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowC)"/>
|
||||
<path id="a-check-bridge-mask" data-graph-role="bridge-mask" data-owner="a-check" d="M 1032.5,430 L 1032.5,450.4 L 1044,450.4 L 1044,565 L 590,565 L 590,485 L 445,485 L 445,485" fill="none" stroke="#ffffff" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="a-check" data-graph-role="edge" data-edge-id="a-check" data-source="resolver" data-target="check" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="5" data-route-stretch="1.285" data-bridges="1044,454" d="M 1032.5,430 L 1032.5,450.4 L 1044,450.4 L 1044,565 L 590,565 L 590,485 L 445,485 L 445,485" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="a-pass-bridge-mask" data-graph-role="bridge-mask" data-owner="a-pass" d="M 590,513 L 730,513" fill="none" stroke="#ffffff" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="a-pass" data-graph-role="edge" data-edge-id="a-pass" data-source="check" data-target="pass" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="590,513" d="M 590,513 L 730,513" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<g id="node-root" data-graph-role="node" data-node-id="root" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="80,165,330,225">
|
||||
<rect x="80.0" y="165.0" width="250.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="205.0" y="183.0" text-anchor="middle" class="node-type">ROOT · 信任锚</text>
|
||||
<text data-text-role="title" data-full-text="根 DNS(内置信任锚)" data-text-max-width="226.0" x="205.0" y="201.0" text-anchor="middle" class="node-title" font-size="18">根 DNS(内置信任锚)</text>
|
||||
</g>
|
||||
<g id="node-tld" data-graph-role="node" data-node-id="tld" data-semantic-role="double_rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="495,165,745,225">
|
||||
<rect x="495.0" y="165.0" width="250.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#10a37f" stroke-width="2.0"/>
|
||||
<rect x="501.0" y="171.0" width="238.0" height="48.0" rx="11.0" fill="none" stroke="#10a37f" stroke-width="1.2" opacity="0.65"/>
|
||||
<text x="620.0" y="183.0" text-anchor="middle" class="node-type">TLD · .top</text>
|
||||
<text data-text-role="title" data-full-text="顶级 DNS(发布 DS)" data-text-max-width="218.0" x="620.0" y="201.0" text-anchor="middle" class="node-title" font-size="18">顶级 DNS(发布 DS)</text>
|
||||
</g>
|
||||
<g id="node-auth" data-graph-role="node" data-node-id="auth" data-semantic-role="double_rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="905,165,1160,225">
|
||||
<rect x="905.0" y="165.0" width="255.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#10a37f" stroke-width="2.0"/>
|
||||
<rect x="911.0" y="171.0" width="243.0" height="48.0" rx="11.0" fill="none" stroke="#10a37f" stroke-width="1.2" opacity="0.65"/>
|
||||
<text x="1032.5" y="183.0" text-anchor="middle" class="node-type">AUTHORITATIVE</text>
|
||||
<text data-text-role="title" data-full-text="权威 DNS(发布 DNSKEY)" data-text-max-width="223.0" x="1032.5" y="201.0" text-anchor="middle" class="node-title" font-size="17.29">权威 DNS(发布 DNSKEY)</text>
|
||||
</g>
|
||||
<g id="node-dnskey" data-graph-role="node" data-node-id="dnskey" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="80,370,330,430">
|
||||
<rect x="80.0" y="370.0" width="250.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="205.0" y="388.0" text-anchor="middle" class="node-type">DNSKEY</text>
|
||||
<text data-text-role="title" data-full-text="KSK / ZSK 密钥" data-text-max-width="226.0" x="205.0" y="406.0" text-anchor="middle" class="node-title" font-size="18">KSK / ZSK 密钥</text>
|
||||
</g>
|
||||
<g id="node-rrset" data-graph-role="node" data-node-id="rrset" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="495,370,745,430">
|
||||
<rect x="495.0" y="370.0" width="250.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="620.0" y="388.0" text-anchor="middle" class="node-type">RRSET + RRSIG</text>
|
||||
<text data-text-role="title" data-full-text="记录数据与签名" data-text-max-width="226.0" x="620.0" y="406.0" text-anchor="middle" class="node-title" font-size="18">记录数据与签名</text>
|
||||
</g>
|
||||
<g id="node-resolver" data-graph-role="node" data-node-id="resolver" data-semantic-role="double_rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="905,370,1160,430">
|
||||
<rect x="905.0" y="370.0" width="255.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#1d4ed8" stroke-width="2.0"/>
|
||||
<rect x="911.0" y="376.0" width="243.0" height="48.0" rx="11.0" fill="none" stroke="#1d4ed8" stroke-width="1.2" opacity="0.65"/>
|
||||
<text x="1032.5" y="388.0" text-anchor="middle" class="node-type">RECURSIVE · DO=1</text>
|
||||
<text data-text-role="title" data-full-text="递归解析器(带 DO 查询)" data-text-max-width="223.0" x="1032.5" y="406.0" text-anchor="middle" class="node-title" font-size="17.38">递归解析器(带 DO 查询)</text>
|
||||
</g>
|
||||
<g id="node-check" data-graph-role="node" data-node-id="check" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="300,485,590,541">
|
||||
<rect x="300.0" y="485.0" width="290.0" height="56.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="445.0" y="503.0" text-anchor="middle" class="node-type">VERIFY</text>
|
||||
<text data-text-role="title" data-full-text="RRSIG.Verify 本地验签" data-text-max-width="266.0" x="445.0" y="519.0" text-anchor="middle" class="node-title" font-size="18">RRSIG.Verify 本地验签</text>
|
||||
</g>
|
||||
<g id="node-pass" data-graph-role="node" data-node-id="pass" data-semantic-role="double_rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="730,485,1020,541">
|
||||
<rect x="730.0" y="485.0" width="290.0" height="56.0" rx="14.0" fill="#ffffff" stroke="#10a37f" stroke-width="2.0"/>
|
||||
<rect x="736.0" y="491.0" width="278.0" height="44.0" rx="11.0" fill="none" stroke="#10a37f" stroke-width="1.2" opacity="0.65"/>
|
||||
<text x="875.0" y="503.0" text-anchor="middle" class="node-type">RESULT</text>
|
||||
<text data-text-role="title" data-full-text="验证通过 · 数据完整可信" data-text-max-width="258.0" x="875.0" y="519.0" text-anchor="middle" class="node-title" font-size="18">验证通过 · 数据完整可信</text>
|
||||
</g>
|
||||
<g id="ds-tld-label" data-graph-role="label" data-owner="ds-tld" data-graph-bounds="384.38,164,440.62,184">
|
||||
<rect x="384.38" y="164.0" width="56.24" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="412.5" y="178.0" text-anchor="middle" class="arrow-label">DS 签发</text>
|
||||
</g>
|
||||
<g id="ds-auth-label" data-graph-role="label" data-owner="ds-auth" data-graph-bounds="772.28,164,877.72,184">
|
||||
<rect x="772.28" y="164.0" width="105.44" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="825.0" y="178.0" text-anchor="middle" class="arrow-label">DS 指向下级 KEY</text>
|
||||
</g>
|
||||
<g id="pub-key-label" data-graph-role="label" data-owner="pub-key" data-graph-bounds="583.25,315,645.25,335">
|
||||
<rect x="583.25" y="315.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="614.25" y="329.0" text-anchor="middle" class="arrow-label">发布密钥</text>
|
||||
</g>
|
||||
<g id="sign-label" data-graph-role="label" data-owner="sign" data-graph-bounds="933.5,441,995.5,461">
|
||||
<rect x="933.5" y="441.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="964.5" y="455.0" text-anchor="middle" class="arrow-label">私钥签名</text>
|
||||
</g>
|
||||
<g id="fetch-key-label" data-graph-role="label" data-owner="fetch-key" data-graph-bounds="575.46,434,659.54,454">
|
||||
<rect x="575.46" y="434.0" width="84.08" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="617.5" y="448.0" text-anchor="middle" class="arrow-label">取回 DNSKEY</text>
|
||||
</g>
|
||||
<g id="fetch-rr-label" data-graph-role="label" data-owner="fetch-rr" data-graph-bounds="789.64,378,863.64,398">
|
||||
<rect x="789.64" y="378.0" width="74.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="826.64" y="392.0" text-anchor="middle" class="arrow-label">带签名应答</text>
|
||||
</g>
|
||||
<g id="a-check-label" data-graph-role="label" data-owner="a-check" data-graph-bounds="772.92,552,861.08,572">
|
||||
<rect x="772.92" y="552.0" width="88.16" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="817.0" y="566.0" text-anchor="middle" class="arrow-label">RRSIG.Verify</text>
|
||||
</g>
|
||||
<g id="a-pass-label" data-graph-role="label" data-owner="a-pass" data-graph-bounds="629,482,691,502">
|
||||
<rect x="629.0" y="482.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="660.0" y="496.0" text-anchor="middle" class="arrow-label">验签通过</text>
|
||||
</g>
|
||||
<g id="legend" data-graph-role="legend">
|
||||
<rect id="legend-zone" data-graph-role="reserved" data-reserved-kind="legend" x="32" y="516" width="183.6" height="90" rx="10" fill="none" stroke="none"/>
|
||||
<line data-graph-role="decoration" x1="42.0" y1="530.0" x2="72.0" y2="530.0" stroke="#10a37f" stroke-width="2.0" marker-end="url(#arrowA)"/>
|
||||
<text data-graph-role="decoration" x="82.0" y="534.0" class="legend">信任链 / 主验证路径</text>
|
||||
<line data-graph-role="decoration" x1="42.0" y1="552.0" x2="72.0" y2="552.0" stroke="#0891b2" stroke-width="2.0" marker-end="url(#arrowC)"/>
|
||||
<text data-graph-role="decoration" x="82.0" y="556.0" class="legend">数据获取</text>
|
||||
<line data-graph-role="decoration" x1="42.0" y1="574.0" x2="72.0" y2="574.0" stroke="#0f766e" stroke-width="2.0" marker-end="url(#arrowB)"/>
|
||||
<text data-graph-role="decoration" x="82.0" y="578.0" class="legend">权威侧写操作</text>
|
||||
</g>
|
||||
<g id="footer" data-graph-role="reserved" data-graph-bounds="48,613,391,629">
|
||||
<text x="48.0" y="625.0" class="footnote">Style 7 · OpenAI Official · DNSSEC chain of trust</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
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 |
@@ -0,0 +1,198 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1240 880" width="1240" height="880" data-generator="fireworks-tech-graph" data-schema-version="1" data-text-metrics="heuristic-v1" data-style-id="7" data-visual-theme="OpenAI" data-diagram-type="architecture" data-motion-scene="" data-semantic-profile="generic" data-semantic-valid="true" data-quality-profile="standard" data-max-bends-per-edge="4" data-max-total-bends="30" data-max-route-stretch="5.0" data-max-bridged-crossings="14" data-min-node-gap="0.0" data-min-container-gutter="0.0" data-min-label-clearance="2.0" data-min-segment-length="0.0">
|
||||
<defs>
|
||||
<marker id="arrowA" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#10a37f"/>
|
||||
</marker>
|
||||
<marker id="arrowB" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#0f766e"/>
|
||||
</marker>
|
||||
<marker id="arrowC" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#0891b2"/>
|
||||
</marker>
|
||||
<marker id="arrowE" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#f59e0b"/>
|
||||
</marker>
|
||||
<marker id="arrowF" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#64748b"/>
|
||||
</marker>
|
||||
<marker id="arrowG" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#475569"/>
|
||||
</marker>
|
||||
<marker id="arrowH" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#94a3b8"/>
|
||||
</marker>
|
||||
<style>
|
||||
text { font-family: 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', 'Noto Sans CJK SC', 'Microsoft YaHei', 'Microsoft JhengHei', 'SimHei', sans-serif; }
|
||||
.title { font-size: 24px; font-weight: 700; fill: #0f172a; }
|
||||
.subtitle { font-size: 13px; font-weight: 500; fill: #64748b; }
|
||||
.section { font-size: 13px; font-weight: 700; fill: #087f5b; letter-spacing: 1.4px; }
|
||||
.section-sub { font-size: 12px; font-weight: 500; fill: #64748b; }
|
||||
.node-title { font-weight: 700; fill: #0f172a; }
|
||||
.node-sub { font-weight: 500; fill: #475569; }
|
||||
.node-type { font-size: 11px; font-weight: 700; fill: #64748b; letter-spacing: 0.08em; }
|
||||
.arrow-label { font-size: 12px; font-weight: 600; fill: #475569; }
|
||||
.legend { font-size: 12px; font-weight: 500; fill: #475569; }
|
||||
.footnote { font-size: 12px; font-weight: 500; fill: #64748b; }
|
||||
.metric-label { font-size: 8.5px; font-weight: 700; fill: #64748b; text-transform: uppercase; }
|
||||
.metric-value { font-size: 9.5px; font-weight: 700; fill: #0f172a; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect data-graph-role="background" width="1240.0" height="880.0" fill="#ffffff"/>
|
||||
<text x="48.0" y="48" text-anchor="start" class="title">域名生命周期状态机</text>
|
||||
<text x="48.0" y="72" text-anchor="start" class="subtitle">从可注册到释放的完整 EPP 状态流转(REGISTRY / REGISTRAR 管理方标注)</text>
|
||||
<line x1="48" y1="100" x2="1192.0" y2="100" stroke="#e2e8f0" stroke-width="1"/>
|
||||
<g id="reg" data-graph-role="container" data-container-id="reg" data-semantic-role="boundary" data-graph-bounds="40,70,1200,290">
|
||||
<rect data-graph-role="container" x="40.0" y="70.0" width="1160.0" height="220.0" rx="16.0" fill="none" stroke="#e2e8f0" stroke-width="1.4" stroke-dasharray="5 4"/>
|
||||
<text x="58.0" y="94.0" class="section">注册与活跃</text>
|
||||
<rect id="reg-header" data-graph-role="reserved" data-reserved-kind="container-header" x="48" y="76" width="95" height="30" fill="none" stroke="none"/>
|
||||
</g>
|
||||
<g id="grace" data-graph-role="container" data-container-id="grace" data-semantic-role="boundary" data-graph-bounds="40,330,1200,470">
|
||||
<rect data-graph-role="container" x="40.0" y="330.0" width="1160.0" height="140.0" rx="16.0" fill="none" stroke="#e2e8f0" stroke-width="1.4" stroke-dasharray="5 4"/>
|
||||
<text x="58.0" y="354.0" class="section">续费 · 到期 · 转移中</text>
|
||||
<rect id="grace-header" data-graph-role="reserved" data-reserved-kind="container-header" x="48" y="336" width="154.8" height="30" fill="none" stroke="none"/>
|
||||
</g>
|
||||
<g id="end" data-graph-role="container" data-container-id="end" data-semantic-role="boundary" data-graph-bounds="40,510,1200,810">
|
||||
<rect data-graph-role="container" x="40.0" y="510.0" width="1160.0" height="300.0" rx="16.0" fill="none" stroke="#e2e8f0" stroke-width="1.4" stroke-dasharray="5 4"/>
|
||||
<text x="58.0" y="534.0" class="section">赎回 · 释放 · 转移完成</text>
|
||||
<rect id="end-header" data-graph-role="reserved" data-reserved-kind="container-header" x="48" y="516" width="167.8" height="30" fill="none" stroke="none"/>
|
||||
</g>
|
||||
<path id="a-reg" data-graph-role="edge" data-edge-id="a-reg" data-source="available" data-target="addperiod" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 270,170 L 380,170" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="a-ok" data-graph-role="edge" data-edge-id="a-ok" data-source="addperiod" data-target="active" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="" d="M 600,170 L 620.4,170 L 620.4,179 L 700,179" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="a-hold-c" data-graph-role="edge" data-edge-id="a-hold-c" data-source="active" data-target="clienthold" data-edge-kind="flow" data-topic-id="" data-flow="feedback" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="" d="M 910,152 L 930.4,152 L 930.4,137 L 990,137" fill="none" stroke="#475569" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowG)"/>
|
||||
<path id="a-hold-s" data-graph-role="edge" data-edge-id="a-hold-s" data-source="active" data-target="serverhold" data-edge-kind="flow" data-topic-id="" data-flow="feedback" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="" d="M 910,170 L 930.4,170 L 930.4,232 L 990,232" fill="none" stroke="#475569" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowG)"/>
|
||||
<path id="a-renew" data-graph-role="edge" data-edge-id="a-renew" data-source="active" data-target="renewperiod" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="" d="M 787,200 L 787,220.4 L 796,220.4 L 796,380" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="a-renew-back" data-graph-role="edge" data-edge-id="a-renew-back" data-source="renewperiod" data-target="active" data-edge-kind="flow" data-topic-id="" data-flow="feedback" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="1.032" data-bridges="" d="M 814,380 L 814,359.6 L 817,359.6 L 817,220.4 L 805,220.4 L 805,200" fill="none" stroke="#475569" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowG)"/>
|
||||
<path id="a-expire-bridge-mask" data-graph-role="bridge-mask" data-owner="a-expire" d="M 700,161 L 692.86,161 L 692.86,174 A 5 5 0 0 1 692.86,184 L 692.86,224 L 510,224 L 510,380" fill="none" stroke="#ffffff" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="a-expire" data-graph-role="edge" data-edge-id="a-expire" data-source="active" data-target="autorenew" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="3" data-route-stretch="1.0" data-bridges="692.86,179" d="M 700,161 L 692.86,161 L 692.86,174 A 5 5 0 0 1 692.86,184 L 692.86,224 L 510,224 L 510,380" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="a-no-pay" data-graph-role="edge" data-edge-id="a-no-pay" data-source="autorenew" data-target="redemption" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 510,440 L 510,560" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="a-redemption-end" data-graph-role="edge" data-edge-id="a-redemption-end" data-source="redemption" data-target="pendingdelete" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 380,590 L 310,590" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="a-release" data-graph-role="edge" data-edge-id="a-release" data-source="pendingdelete" data-target="released" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="2.625" data-bridges="" d="M 190,620 L 190,640.4 L 305.6,640.4 L 305.6,732.86 L 200,732.86 L 200,740" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="a-transfer" data-graph-role="edge" data-edge-id="a-transfer" data-source="active" data-target="pendingtransfer" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="1.0" data-bridges="" d="M 910,188 L 930,188 L 930,356 L 959.6,356 L 959.6,410 L 980,410" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="a-transfer-done" data-graph-role="edge" data-edge-id="a-transfer-done" data-source="pendingtransfer" data-target="transferperiod" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="3" data-route-stretch="1.078" data-bridges="" d="M 1080,440 L 1080,464 L 679.6,464 L 679.6,581 L 700,581" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="a-transfer-ok-bridge-mask" data-graph-role="bridge-mask" data-owner="a-transfer-ok" d="M 700,599 L 676,599 L 676,230.4 L 700,230.4 L 700,200 L 782,200 A 5 5 0 0 0 792,200 L 800,200 A 5 5 0 0 0 810,200 L 823,200 L 823,200" fill="none" stroke="#ffffff" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="a-transfer-ok" data-graph-role="edge" data-edge-id="a-transfer-ok" data-source="transferperiod" data-target="active" data-edge-kind="flow" data-topic-id="" data-flow="feedback" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="1.092" data-bridges="787,200;805,200" d="M 700,599 L 676,599 L 676,230.4 L 700,230.4 L 700,200 L 782,200 A 5 5 0 0 0 792,200 L 800,200 A 5 5 0 0 0 810,200 L 823,200 L 823,200" fill="none" stroke="#475569" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowG)"/>
|
||||
<g id="node-available" data-graph-role="node" data-node-id="available" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="70,140,270,200">
|
||||
<rect x="70.0" y="140.0" width="200.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="170.0" y="158.0" text-anchor="middle" class="node-type">REGISTRY</text>
|
||||
<text data-text-role="title" data-full-text="Available · 域名可注册" data-text-max-width="176.0" x="170.0" y="176.0" text-anchor="middle" class="node-title" font-size="15.17">Available · 域名可注册</text>
|
||||
</g>
|
||||
<g id="node-addperiod" data-graph-role="node" data-node-id="addperiod" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="380,140,600,200">
|
||||
<rect x="380.0" y="140.0" width="220.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="490.0" y="158.0" text-anchor="middle" class="node-type">REGISTRY</text>
|
||||
<text data-text-role="title" data-full-text="AddPeriod · 注册完成 5天" data-text-max-width="196.0" x="490.0" y="176.0" text-anchor="middle" class="node-title" font-size="14.87">AddPeriod · 注册完成 5天</text>
|
||||
</g>
|
||||
<g id="node-active" data-graph-role="node" data-node-id="active" data-semantic-role="double_rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="700,140,910,200">
|
||||
<rect x="700.0" y="140.0" width="210.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#10a37f" stroke-width="2.0"/>
|
||||
<rect x="706.0" y="146.0" width="198.0" height="48.0" rx="11.0" fill="none" stroke="#10a37f" stroke-width="1.2" opacity="0.65"/>
|
||||
<text x="805.0" y="158.0" text-anchor="middle" class="node-type">REGISTRAR · Active</text>
|
||||
<text data-text-role="title" data-full-text="Active (ok) · 正常解析" data-text-max-width="178.0" x="805.0" y="176.0" text-anchor="middle" class="node-title" font-size="14.71">Active (ok) · 正常解析</text>
|
||||
</g>
|
||||
<g id="node-clienthold" data-graph-role="node" data-node-id="clienthold" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="990,110,1190,164">
|
||||
<rect x="990.0" y="110.0" width="200.0" height="54.0" rx="14.0" fill="#ffffff" stroke="#f97316" stroke-width="1.8"/>
|
||||
<text x="1090.0" y="128.0" text-anchor="middle" class="node-type">REGISTRAR</text>
|
||||
<text data-text-role="title" data-full-text="ClientHold · 客户暂停" data-text-max-width="176.0" x="1090.0" y="143.0" text-anchor="middle" class="node-title" font-size="15.79">ClientHold · 客户暂停</text>
|
||||
</g>
|
||||
<g id="node-serverhold" data-graph-role="node" data-node-id="serverhold" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="990,205,1190,259">
|
||||
<rect x="990.0" y="205.0" width="200.0" height="54.0" rx="14.0" fill="#ffffff" stroke="#f97316" stroke-width="1.8"/>
|
||||
<text x="1090.0" y="223.0" text-anchor="middle" class="node-type">REGISTRY</text>
|
||||
<text data-text-role="title" data-full-text="ServerHold · 注册局暂停" data-text-max-width="176.0" x="1090.0" y="238.0" text-anchor="middle" class="node-title" font-size="13.76">ServerHold · 注册局暂停</text>
|
||||
</g>
|
||||
<g id="node-renewperiod" data-graph-role="node" data-node-id="renewperiod" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="700,380,910,440">
|
||||
<rect x="700.0" y="380.0" width="210.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="805.0" y="398.0" text-anchor="middle" class="node-type">REGISTRY</text>
|
||||
<text data-text-role="title" data-full-text="RenewPeriod · 手动续费 5天" data-text-max-width="186.0" x="805.0" y="416.0" text-anchor="middle" class="node-title" font-size="12.89">RenewPeriod · 手动续费 5天</text>
|
||||
</g>
|
||||
<g id="node-autorenew" data-graph-role="node" data-node-id="autorenew" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="380,380,640,440">
|
||||
<rect x="380.0" y="380.0" width="260.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="510.0" y="398.0" text-anchor="middle" class="node-type">REGISTRY</text>
|
||||
<text data-text-role="title" data-full-text="AutoRenewPeriod · 等待扣费" data-text-max-width="236.0" x="510.0" y="416.0" text-anchor="middle" class="node-title" font-size="15.9">AutoRenewPeriod · 等待扣费</text>
|
||||
</g>
|
||||
<g id="node-pendingtransfer" data-graph-role="node" data-node-id="pendingtransfer" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="980,380,1180,440">
|
||||
<rect x="980.0" y="380.0" width="200.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="1080.0" y="398.0" text-anchor="middle" class="node-type">REGISTRAR</text>
|
||||
<text data-text-role="title" data-full-text="PendingTransfer · 转移中" data-text-max-width="176.0" x="1080.0" y="416.0" text-anchor="middle" class="node-title" font-size="12.79">PendingTransfer · 转移中</text>
|
||||
</g>
|
||||
<g id="node-transferperiod" data-graph-role="node" data-node-id="transferperiod" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="700,560,910,620">
|
||||
<rect x="700.0" y="560.0" width="210.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="805.0" y="578.0" text-anchor="middle" class="node-type">REGISTRAR</text>
|
||||
<text data-text-role="title" data-full-text="TransferPeriod · 5天" data-text-max-width="186.0" x="805.0" y="596.0" text-anchor="middle" class="node-title" font-size="16.03">TransferPeriod · 5天</text>
|
||||
</g>
|
||||
<g id="node-redemption" data-graph-role="node" data-node-id="redemption" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="380,560,640,620">
|
||||
<rect x="380.0" y="560.0" width="260.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="510.0" y="578.0" text-anchor="middle" class="node-type">REGISTRY</text>
|
||||
<text data-text-role="title" data-full-text="RedemptionPeriod · 赎回 30天" data-text-max-width="236.0" x="510.0" y="596.0" text-anchor="middle" class="node-title" font-size="14.98">RedemptionPeriod · 赎回 30天</text>
|
||||
</g>
|
||||
<g id="node-pendingdelete" data-graph-role="node" data-node-id="pendingdelete" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="70,560,310,620">
|
||||
<rect x="70.0" y="560.0" width="240.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="190.0" y="578.0" text-anchor="middle" class="node-type">REGISTRY</text>
|
||||
<text data-text-role="title" data-full-text="PendingDelete · 删除 5天" data-text-max-width="216.0" x="190.0" y="596.0" text-anchor="middle" class="node-title" font-size="16.31">PendingDelete · 删除 5天</text>
|
||||
</g>
|
||||
<g id="node-released" data-graph-role="node" data-node-id="released" data-semantic-role="double_rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="70,740,330,800">
|
||||
<rect x="70.0" y="740.0" width="260.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#1d4ed8" stroke-width="2.0"/>
|
||||
<rect x="76.0" y="746.0" width="248.0" height="48.0" rx="11.0" fill="none" stroke="#1d4ed8" stroke-width="1.2" opacity="0.65"/>
|
||||
<text x="200.0" y="758.0" text-anchor="middle" class="node-type">END</text>
|
||||
<text data-text-role="title" data-full-text="已释放 · 重新开放注册" data-text-max-width="228.0" x="200.0" y="776.0" text-anchor="middle" class="node-title" font-size="18">已释放 · 重新开放注册</text>
|
||||
</g>
|
||||
<g id="a-reg-label" data-graph-role="label" data-owner="a-reg" data-graph-bounds="306,139,344,159">
|
||||
<rect x="306.0" y="139.0" width="38.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="325.0" y="153.0" text-anchor="middle" class="arrow-label">注册</text>
|
||||
</g>
|
||||
<g id="a-ok-label" data-graph-role="label" data-owner="a-ok" data-graph-bounds="629.2,148,691.2,168">
|
||||
<rect x="629.2" y="148.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="660.2" y="162.0" text-anchor="middle" class="arrow-label">注册完成</text>
|
||||
</g>
|
||||
<g id="a-hold-c-label" data-graph-role="label" data-owner="a-hold-c" data-graph-bounds="911.2,106,973.2,126">
|
||||
<rect x="911.2" y="106.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="942.2" y="120.0" text-anchor="middle" class="arrow-label">暂停解析</text>
|
||||
</g>
|
||||
<g id="a-hold-s-label" data-graph-role="label" data-owner="a-hold-s" data-graph-bounds="865.4,205,927.4,225">
|
||||
<rect x="865.4" y="205.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="896.4" y="219.0" text-anchor="middle" class="arrow-label">违规暂停</text>
|
||||
</g>
|
||||
<g id="a-renew-label" data-graph-role="label" data-owner="a-renew" data-graph-bounds="755,286.2,793,306.2">
|
||||
<rect x="755.0" y="286.2" width="38.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="774.0" y="300.2" text-anchor="middle" class="arrow-label">续费</text>
|
||||
</g>
|
||||
<g id="a-renew-back-label" data-graph-role="label" data-owner="a-renew-back" data-graph-bounds="820,276,906,296">
|
||||
<rect x="820.0" y="276.0" width="86.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="863.0" y="290.0" text-anchor="middle" class="arrow-label">期满回到正常</text>
|
||||
</g>
|
||||
<g id="a-expire-label" data-graph-role="label" data-owner="a-expire" data-graph-bounds="582.43,211,620.43,231">
|
||||
<rect x="582.43" y="211.0" width="38.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="601.43" y="225.0" text-anchor="middle" class="arrow-label">到期</text>
|
||||
</g>
|
||||
<g id="a-no-pay-label" data-graph-role="label" data-owner="a-no-pay" data-graph-bounds="457,486,507,506">
|
||||
<rect x="457.0" y="486.0" width="50.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="482.0" y="500.0" text-anchor="middle" class="arrow-label">未支付</text>
|
||||
</g>
|
||||
<g id="a-redemption-end-label" data-graph-role="label" data-owner="a-redemption-end" data-graph-bounds="314,559,376,579">
|
||||
<rect x="314.0" y="559.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="345.0" y="573.0" text-anchor="middle" class="arrow-label">赎回期满</text>
|
||||
</g>
|
||||
<g id="a-release-label" data-graph-role="label" data-owner="a-release" data-graph-bounds="228.8,627.4,266.8,647.4">
|
||||
<rect x="228.8" y="627.4" width="38.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="247.8" y="641.4" text-anchor="middle" class="arrow-label">释放</text>
|
||||
</g>
|
||||
<g id="a-transfer-label" data-graph-role="label" data-owner="a-transfer" data-graph-bounds="889,248,927,268">
|
||||
<rect x="889.0" y="248.0" width="38.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="908.0" y="262.0" text-anchor="middle" class="arrow-label">转移</text>
|
||||
</g>
|
||||
<g id="a-transfer-done-label" data-graph-role="label" data-owner="a-transfer-done" data-graph-bounds="848.8,451,910.8,471">
|
||||
<rect x="848.8" y="451.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="879.8" y="465.0" text-anchor="middle" class="arrow-label">转移完成</text>
|
||||
</g>
|
||||
<g id="a-transfer-ok-label" data-graph-role="label" data-owner="a-transfer-ok" data-graph-bounds="611,474.42,673,494.42">
|
||||
<rect x="611.0" y="474.42" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="642.0" y="488.42" text-anchor="middle" class="arrow-label">转移生效</text>
|
||||
</g>
|
||||
<g id="legend" data-graph-role="legend">
|
||||
<rect id="legend-zone" data-graph-role="reserved" data-reserved-kind="legend" x="32" y="684" width="273.6" height="46" rx="10" fill="none" stroke="none"/>
|
||||
<line data-graph-role="decoration" x1="42.0" y1="698.0" x2="72.0" y2="698.0" stroke="#10a37f" stroke-width="2.0" marker-end="url(#arrowA)"/>
|
||||
<text data-graph-role="decoration" x="82.0" y="702.0" class="legend">状态流转</text>
|
||||
<line data-graph-role="decoration" x1="158.0" y1="698.0" x2="188.0" y2="698.0" stroke="#475569" stroke-width="2.0" marker-end="url(#arrowG)"/>
|
||||
<text data-graph-role="decoration" x="198.0" y="702.0" class="legend">回到活跃 / 暂停</text>
|
||||
</g>
|
||||
<g id="footer" data-graph-role="reserved" data-graph-bounds="48,833,433,849">
|
||||
<text x="48.0" y="845.0" class="footnote">Style 7 · OpenAI Official · EPP domain lifecycle states</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 27 KiB |
@@ -3,7 +3,7 @@
|
||||
<title>ipv6-certified-lite-s1</title>
|
||||
<g id="ipv6-certified-lite-s1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="编组-2备份" transform="translate(18.000000, 8.000000)" fill="#07C160">
|
||||
<text id="IPv6" font-family="PingFangSC-Semibold, PingFang SC" font-size="14" font-weight="500">
|
||||
<text id="IPv6" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500">
|
||||
<tspan x="19" y="15">IPv6</tspan>
|
||||
</text>
|
||||
<g id="编组备份" transform="translate(0.000000, 1.000000)" fill-rule="nonzero">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -3,7 +3,7 @@
|
||||
<title>ipv6-certified-lite-s2</title>
|
||||
<g id="ipv6-certified-lite-s2" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="编组-2备份" transform="translate(18.000000, 8.000000)" fill="#61BD13">
|
||||
<text id="IPv6" font-family="PingFangSC-Semibold, PingFang SC" font-size="14" font-weight="500">
|
||||
<text id="IPv6" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500">
|
||||
<tspan x="19" y="15">IPv6</tspan>
|
||||
</text>
|
||||
<g id="编组备份" transform="translate(0.000000, 1.000000)" fill-rule="nonzero">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -3,7 +3,7 @@
|
||||
<title>ipv6-certified-lite-s3</title>
|
||||
<g id="ipv6-certified-lite-s3" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="编组-2备份" transform="translate(18.000000, 8.000000)" fill="#1A42A0">
|
||||
<text id="IPv6" font-family="PingFangSC-Semibold, PingFang SC" font-size="14" font-weight="500">
|
||||
<text id="IPv6" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500">
|
||||
<tspan x="19" y="15">IPv6</tspan>
|
||||
</text>
|
||||
<g id="编组备份" transform="translate(0.000000, 1.000000)" fill-rule="nonzero">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -3,7 +3,7 @@
|
||||
<title>ipv6-certified-lite-s4</title>
|
||||
<g id="ipv6-certified-lite-s4" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="编组-2备份" transform="translate(18.000000, 8.000000)" fill="#3A84FF">
|
||||
<text id="IPv6" font-family="PingFangSC-Semibold, PingFang SC" font-size="14" font-weight="500">
|
||||
<text id="IPv6" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500">
|
||||
<tspan x="19" y="15">IPv6</tspan>
|
||||
</text>
|
||||
<g id="编组备份" transform="translate(0.000000, 1.000000)" fill-rule="nonzero">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -3,7 +3,7 @@
|
||||
<title>ipv6-certified-lite-s5</title>
|
||||
<g id="ipv6-certified-lite-s5" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="编组-2备份" transform="translate(18.000000, 8.000000)" fill="#313238">
|
||||
<text id="IPv6" font-family="PingFangSC-Semibold, PingFang SC" font-size="14" font-weight="500">
|
||||
<text id="IPv6" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500">
|
||||
<tspan x="19" y="15">IPv6</tspan>
|
||||
</text>
|
||||
<g id="编组备份" transform="translate(0.000000, 1.000000)" fill-rule="nonzero">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -3,7 +3,7 @@
|
||||
<title>ipv6-certified-lite-s6</title>
|
||||
<g id="ipv6-certified-lite-s6" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="编组-2备份" transform="translate(18.000000, 8.000000)" fill="#757575">
|
||||
<text id="IPv6" font-family="PingFangSC-Semibold, PingFang SC" font-size="14" font-weight="500">
|
||||
<text id="IPv6" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500">
|
||||
<tspan x="19" y="15">IPv6</tspan>
|
||||
</text>
|
||||
<g id="编组备份" transform="translate(0.000000, 1.000000)" fill-rule="nonzero">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -4,7 +4,7 @@
|
||||
<g id="ipv6-certified-s1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="编组-2备份" transform="translate(5.000000, 4.000000)">
|
||||
<rect id="矩形备份" stroke="#07C160" x="0.5" y="0.5" width="74" height="27" rx="5"></rect>
|
||||
<text id="IPv6" font-family="PingFangSC-Semibold, PingFang SC" font-size="14" font-weight="500" fill="#07C160">
|
||||
<text id="IPv6" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500" fill="#07C160">
|
||||
<tspan x="32" y="19">IPv6</tspan>
|
||||
</text>
|
||||
<g id="编组备份" transform="translate(13.000000, 5.000000)" fill="#07C160" fill-rule="nonzero">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -4,7 +4,7 @@
|
||||
<g id="ipv6-certified-s2" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="编组-2备份" transform="translate(5.000000, 4.000000)">
|
||||
<rect id="矩形备份" stroke="#61BD13" x="0.5" y="0.5" width="74" height="27" rx="5"></rect>
|
||||
<text id="IPv6" font-family="PingFangSC-Semibold, PingFang SC" font-size="14" font-weight="500" fill="#61BD13">
|
||||
<text id="IPv6" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500" fill="#61BD13">
|
||||
<tspan x="32" y="19">IPv6</tspan>
|
||||
</text>
|
||||
<g id="编组备份" transform="translate(13.000000, 5.000000)" fill="#61BD13" fill-rule="nonzero">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -4,7 +4,7 @@
|
||||
<g id="ipv6-certified-s3" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="编组-2备份" 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="IPv6" font-family="PingFangSC-Semibold, PingFang SC" font-size="14" font-weight="500" fill="#1A42A0">
|
||||
<text id="IPv6" 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="32" y="19">IPv6</tspan>
|
||||
</text>
|
||||
<g id="编组备份" transform="translate(13.000000, 5.000000)" fill="#1A42A0" fill-rule="nonzero">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -4,7 +4,7 @@
|
||||
<g id="ipv6-certified-s4" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="编组-2备份" transform="translate(5.000000, 4.000000)">
|
||||
<rect id="矩形备份" stroke="#3A84FF" x="0.5" y="0.5" width="74" height="27" rx="5"></rect>
|
||||
<text id="IPv6" font-family="PingFangSC-Semibold, PingFang SC" font-size="14" font-weight="500" fill="#3A84FF">
|
||||
<text id="IPv6" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500" fill="#3A84FF">
|
||||
<tspan x="32" y="19">IPv6</tspan>
|
||||
</text>
|
||||
<g id="编组备份" transform="translate(13.000000, 5.000000)" fill="#3A84FF" fill-rule="nonzero">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
@@ -4,7 +4,7 @@
|
||||
<g id="ipv6-certified-s5" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="编组-2备份" transform="translate(5.000000, 4.000000)">
|
||||
<rect id="矩形备份" stroke="#313238" x="0.5" y="0.5" width="74" height="27" rx="5"></rect>
|
||||
<text id="IPv6" font-family="PingFangSC-Semibold, PingFang SC" font-size="14" font-weight="500" fill="#313238">
|
||||
<text id="IPv6" 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="32" y="19">IPv6</tspan>
|
||||
</text>
|
||||
<g id="编组备份" transform="translate(13.000000, 5.000000)" fill="#313238" fill-rule="nonzero">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -4,7 +4,7 @@
|
||||
<g id="ipv6-certified-s6" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="编组-2备份" transform="translate(5.000000, 4.000000)">
|
||||
<rect id="矩形备份" stroke="#757575" x="0.5" y="0.5" width="74" height="27" rx="5"></rect>
|
||||
<text id="IPv6" font-family="PingFangSC-Semibold, PingFang SC" font-size="14" font-weight="500" fill="#757575">
|
||||
<text id="IPv6" 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="32" y="19">IPv6</tspan>
|
||||
</text>
|
||||
<g id="编组备份" transform="translate(13.000000, 5.000000)" fill="#757575" fill-rule="nonzero">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -4,9 +4,9 @@
|
||||
<g id="07C160-ipv6" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="编组-2备份" transform="translate(5.000000, 4.000000)">
|
||||
<rect id="矩形备份" stroke="#07C160" x="0.5" y="0.5" width="180" height="27" rx="5"></rect>
|
||||
<text id="本站已支持-IPv6-访问" font-family="PingFangSC-Regular, PingFang SC" font-size="14" font-weight="normal" fill="#07C160">
|
||||
<text id="本站已支持-IPv6-访问" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="normal" fill="#07C160">
|
||||
<tspan x="32" y="19">本站已支持 </tspan>
|
||||
<tspan x="106.662" y="19" font-family="PingFangSC-Semibold, PingFang SC" font-weight="500">IPv6 </tspan>
|
||||
<tspan x="106.662" y="19" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-weight="500">IPv6 </tspan>
|
||||
<tspan x="139.576" y="19">访问</tspan>
|
||||
</text>
|
||||
<g id="编组备份" transform="translate(13.000000, 5.000000)" fill="#07C160" fill-rule="nonzero">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -4,9 +4,9 @@
|
||||
<g id="61BD13-ipv6" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="编组-2备份" transform="translate(5.000000, 4.000000)">
|
||||
<rect id="矩形备份" stroke="#61BD13" x="0.5" y="0.5" width="180" height="27" rx="5"></rect>
|
||||
<text id="本站已支持-IPv6-访问" font-family="PingFangSC-Regular, PingFang SC" font-size="14" font-weight="normal" fill="#61BD13">
|
||||
<text id="本站已支持-IPv6-访问" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="normal" fill="#61BD13">
|
||||
<tspan x="32" y="19">本站已支持 </tspan>
|
||||
<tspan x="106.662" y="19" font-family="PingFangSC-Semibold, PingFang SC" font-weight="500">IPv6 </tspan>
|
||||
<tspan x="106.662" y="19" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-weight="500">IPv6 </tspan>
|
||||
<tspan x="139.576" y="19">访问</tspan>
|
||||
</text>
|
||||
<g id="编组备份" transform="translate(13.000000, 5.000000)" fill="#61BD13" fill-rule="nonzero">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -4,9 +4,9 @@
|
||||
<g id="1A42A0-ipv6" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="编组-2备份" transform="translate(5.000000, 4.000000)">
|
||||
<rect id="矩形备份" stroke="#1A42A0" x="0.5" y="0.5" width="180" height="27" rx="5"></rect>
|
||||
<text id="本站已支持-IPv6-访问" font-family="PingFangSC-Regular, PingFang SC" font-size="14" font-weight="normal" fill="#1A42A0">
|
||||
<text id="本站已支持-IPv6-访问" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="normal" fill="#1A42A0">
|
||||
<tspan x="32" y="19">本站已支持 </tspan>
|
||||
<tspan x="106.662" y="19" font-family="PingFangSC-Semibold, PingFang SC" font-weight="500">IPv6 </tspan>
|
||||
<tspan x="106.662" y="19" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-weight="500">IPv6 </tspan>
|
||||
<tspan x="139.576" y="19">访问</tspan>
|
||||
</text>
|
||||
<g id="编组备份" transform="translate(13.000000, 5.000000)" fill="#1A42A0" fill-rule="nonzero">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -4,9 +4,9 @@
|
||||
<g id="3A84FF-ipv6" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="编组-2备份" transform="translate(5.000000, 4.000000)">
|
||||
<rect id="矩形备份" stroke="#3A84FF" x="0.5" y="0.5" width="180" height="27" rx="5"></rect>
|
||||
<text id="本站已支持-IPv6-访问" font-family="PingFangSC-Regular, PingFang SC" font-size="14" font-weight="normal" fill="#3A84FF">
|
||||
<text id="本站已支持-IPv6-访问" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="normal" fill="#3A84FF">
|
||||
<tspan x="32" y="19">本站已支持 </tspan>
|
||||
<tspan x="106.662" y="19" font-family="PingFangSC-Semibold, PingFang SC" font-weight="500">IPv6 </tspan>
|
||||
<tspan x="106.662" y="19" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-weight="500">IPv6 </tspan>
|
||||
<tspan x="139.576" y="19">访问</tspan>
|
||||
</text>
|
||||
<g id="编组备份" transform="translate(13.000000, 5.000000)" fill="#3A84FF" fill-rule="nonzero">
|
||||
|
||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.1 KiB |
@@ -4,9 +4,9 @@
|
||||
<g id="313238-ipv6" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="编组-2备份" transform="translate(5.000000, 4.000000)">
|
||||
<rect id="矩形备份" stroke="#313238" x="0.5" y="0.5" width="180" height="27" rx="5"></rect>
|
||||
<text id="本站已支持-IPv6-访问" font-family="PingFangSC-Regular, PingFang SC" font-size="14" font-weight="normal" fill="#313238">
|
||||
<text id="本站已支持-IPv6-访问" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="normal" fill="#313238">
|
||||
<tspan x="32" y="19">本站已支持 </tspan>
|
||||
<tspan x="106.662" y="19" font-family="PingFangSC-Semibold, PingFang SC" font-weight="500">IPv6 </tspan>
|
||||
<tspan x="106.662" y="19" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-weight="500">IPv6 </tspan>
|
||||
<tspan x="139.576" y="19">访问</tspan>
|
||||
</text>
|
||||
<g id="编组备份" transform="translate(13.000000, 5.000000)" fill="#313238" fill-rule="nonzero">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -4,9 +4,9 @@
|
||||
<g id="757575-ipv6" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="编组-2备份" transform="translate(5.000000, 4.000000)">
|
||||
<rect id="矩形备份" stroke="#757575" x="0.5" y="0.5" width="180" height="27" rx="5"></rect>
|
||||
<text id="本站已支持-IPv6-访问" font-family="PingFangSC-Regular, PingFang SC" font-size="14" font-weight="normal" fill="#757575">
|
||||
<text id="本站已支持-IPv6-访问" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="normal" fill="#757575">
|
||||
<tspan x="32" y="19">本站已支持 </tspan>
|
||||
<tspan x="106.662" y="19" font-family="PingFangSC-Semibold, PingFang SC" font-weight="500">IPv6 </tspan>
|
||||
<tspan x="106.662" y="19" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-weight="500">IPv6 </tspan>
|
||||
<tspan x="139.576" y="19">访问</tspan>
|
||||
</text>
|
||||
<g id="编组备份" transform="translate(13.000000, 5.000000)" fill="#757575" fill-rule="nonzero">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -2,10 +2,10 @@
|
||||
<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="PingFangSC-Semibold, PingFang SC" font-size="14" font-weight="500">
|
||||
<text id="SSL" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500">
|
||||
<tspan x="19" y="15">SSL</tspan>
|
||||
</text>
|
||||
<g id="编组备份" transform="translate(0.000000, 1.000000)" fill-rule="nonzero">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.8 KiB |
@@ -2,10 +2,10 @@
|
||||
<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="PingFangSC-Semibold, PingFang SC" font-size="14" font-weight="500">
|
||||
<text id="SSL" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500">
|
||||
<tspan x="19" y="15">SSL</tspan>
|
||||
</text>
|
||||
<g id="编组备份" transform="translate(0.000000, 1.000000)" fill-rule="nonzero">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.8 KiB |
@@ -2,9 +2,9 @@
|
||||
<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="PingFangSC-Semibold, PingFang SC" font-size="14" font-weight="500" fill="#1A42A0">
|
||||
<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>
|
||||
</text>
|
||||
<g transform="translate(15.000000, 5.000000)" fill="#1A42A0" fill-rule="nonzero" id="形状">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -2,10 +2,10 @@
|
||||
<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="PingFangSC-Semibold, PingFang SC" font-size="14" font-weight="500">
|
||||
<text id="SSL" font-family="PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans SC, sans-serif" font-size="14" font-weight="500">
|
||||
<tspan x="19" y="15">SSL</tspan>
|
||||
</text>
|
||||
<path d="M12.8909648,7.38528817 L13.2863576,6.05666477 C13.360741,5.8069981 13.2883511,5.53671816 13.0991387,5.35765114 L12.0922832,4.40467174 L11.7705341,3.05628057 C11.7097987,2.80289896 11.5121022,2.60499523 11.2587206,2.5446932 L9.91053677,2.22271805 L8.95755735,1.21606982 C8.82709871,1.07818885 8.64566177,1 8.45584445,1 C8.39009639,1 8.32351917,1.00929039 8.25856256,1.02864356 L6.93003341,1.42424374 L5.60142886,1.02864356 C5.53753346,1.009612 5.47121222,1 5.40454272,1 C5.21464003,1 5.03309282,1.07808237 4.90262252,1.21606982 L3.94962425,2.22271805 L2.60123311,2.54467436 C2.34784311,2.60516196 2.15003067,2.80305455 2.08964574,3.05646904 L1.76768941,4.4046529 L0.761041195,5.3576323 C0.571805883,5.53657858 0.499594189,5.80688245 0.573841061,6.05664593 L0.969233959,7.38526931 L0.573841061,8.71368542 C0.499516327,8.96340772 0.571884893,9.23370802 0.761041195,9.4128875 L1.76768941,10.3656785 L2.08964574,11.7138812 C2.15005445,11.9672824 2.3478564,12.1651645 2.60123311,12.2256758 L3.94962425,12.5476322 L4.90262252,13.5542992 C5.03309931,13.6922728 5.21464567,13.7703318 5.40454272,13.7703318 C5.47030962,13.7703318 5.53647225,13.7610787 5.60142886,13.7414994 L6.93005225,13.3461065 L8.25858142,13.7414994 C8.32257884,13.7607032 8.38904675,13.7703318 8.45586328,13.7703318 C8.64567406,13.7703318 8.82710848,13.6921626 8.9575762,13.5542992 L9.91055561,12.5476322 L11.2587395,12.2256758 C11.5121371,12.1651204 11.7099882,11.9672765 11.770553,11.7138812 L12.092302,10.3656785 L13.0989691,9.4128875 C13.2881856,9.23375278 13.3608118,8.96341122 13.2863576,8.71368542 L12.8909648,7.38528817 Z M6.93003341,3.959247 C8.8371229,3.959247 10.3831916,5.50512731 10.3831916,7.41231102 C10.3831916,9.31930627 8.8371229,10.8653939 6.93003341,10.8653939 C5.02283085,10.8653939 3.47715782,9.31932513 3.47715782,7.41231102 C3.47715782,5.50512731 5.02284969,3.959247 6.93003341,3.959247 Z M1.71682841,13.3636694 L0.105369645,17.2678303 C0.105369645,17.2678303 -0.359427121,18.1354824 0.663370735,18.0735597 C1.68575403,18.0116371 2.55361334,18.0425042 2.55361334,18.0425042 L3.85478046,19.3130115 C3.85478046,19.3130115 4.35065161,19.9638023 4.72263979,19.3130115 C5.09423223,18.6622395 6.6128824,14.6959298 6.6128824,14.6959298 C6.6128824,14.6959298 4.81544839,15.4705848 3.63812656,14.3552044 C2.46042786,13.239598 1.71682841,13.3636694 1.71682841,13.3636694 L1.71682841,13.3636694 Z M12.2831579,13.3636694 L13.8946167,17.2678303 C13.8946167,17.2678303 14.3593947,18.1354824 13.3368041,18.0735597 C12.3141947,18.0116371 11.4467688,18.0425042 11.4467688,18.0425042 L10.1451871,19.3130115 C10.1451871,19.3130115 9.64931591,19.9638023 9.27772346,19.3130115 C8.90571646,18.6622395 7.38748086,14.6959298 7.38748086,14.6959298 C7.38748086,14.6959298 9.18451914,15.4705848 10.3622367,14.3552044 C11.5395585,13.239598 12.2831579,13.3636694 12.2831579,13.3636694 L12.2831579,13.3636694 Z" id="形状" fill-rule="nonzero"></path>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -2,9 +2,9 @@
|
||||
<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="PingFangSC-Semibold, PingFang SC" font-size="14" font-weight="500" fill="#313238">
|
||||
<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>
|
||||
</text>
|
||||
<g transform="translate(15.000000, 5.000000)" fill="#313238" fill-rule="nonzero" id="形状">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -2,9 +2,9 @@
|
||||
<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="PingFangSC-Semibold, PingFang SC" font-size="14" font-weight="500" fill="#757575">
|
||||
<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>
|
||||
</text>
|
||||
<g transform="translate(15.000000, 5.000000)" fill="#757575" fill-rule="nonzero" id="形状">
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
// CI 部署完成后一次性提交 sitemap 全部 URL 到 IndexNow(Bing / Yandex / Seznam / Yep 共用端点)。
|
||||
// 用法:node scripts/indexnow-submit.mjs --site https://nipw.cn --key <key>
|
||||
//
|
||||
// key 必须与 frontend-ssr/public/<key>.txt 完全一致 —— IndexNow 的所有权校验就是抓取
|
||||
// 这个文件比对,所以 key 本身是公开凭证、写在 CI 里并不泄密。
|
||||
// 逻辑与 server/utils/indexnow.ts 保持一致(那边是运行时端点,这边是构建机直跑)。
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
function argOf(name) {
|
||||
const i = args.indexOf(name)
|
||||
return i >= 0 ? args[i + 1] : undefined
|
||||
}
|
||||
|
||||
const site = (argOf('--site') || 'https://nipw.cn').replace(/\/$/, '')
|
||||
const key = argOf('--key')
|
||||
|
||||
if (!key) {
|
||||
console.error('usage: node scripts/indexnow-submit.mjs --site <url> --key <key>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/IndexNow'
|
||||
const sitemapUrl = `${site}/sitemap.xml`
|
||||
|
||||
const res = await fetch(sitemapUrl)
|
||||
if (!res.ok) {
|
||||
console.error(`sitemap fetch failed: ${res.status} ${sitemapUrl}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const xml = await res.text()
|
||||
const urls = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map(m => m[1].trim())
|
||||
if (!urls.length) {
|
||||
console.error('sitemap has no <loc> entries, nothing to submit')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// IndexNow 单请求最多 1 万条,分片兜底
|
||||
const CHUNK = 10_000
|
||||
let ok = 0
|
||||
for (let i = 0; i < urls.length; i += CHUNK) {
|
||||
const urlList = urls.slice(i, i + CHUNK)
|
||||
const r = await fetch(INDEXNOW_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({
|
||||
host: new URL(site).host,
|
||||
key,
|
||||
keyLocation: `${site}/${key}.txt`,
|
||||
urlList,
|
||||
}),
|
||||
})
|
||||
const body = await r.text().catch(() => '')
|
||||
console.log(`IndexNow chunk ${i / CHUNK + 1}: HTTP ${r.status} (${urlList.length} urls) ${body.slice(0, 120)}`)
|
||||
if (r.ok || r.status === 202) ok += urlList.length
|
||||
}
|
||||
console.log(`submitted ${ok}/${urls.length} urls from ${sitemapUrl}`)
|
||||
// IndexNow 202 = 已受理;4xx 校验失败会让 CI 亮红,属预期失败信号
|
||||
if (!ok) process.exit(1)
|
||||