Compare commits

..
Author SHA1 Message Date
cloudflare-workers-and-pages[bot] 00d309ffa0 Add Cloudflare Workers configuration 2026-04-12 10:59:04 +00:00
nomdn 9f46341769 first commit 2026-04-12 18:41:38 +08:00
nomdn 4b8b370efb Initial commit 2026-04-12 18:36:45 +08:00
278 changed files with 7677 additions and 43410 deletions

No files matched your search

-37
View File
@@ -1,37 +0,0 @@
---
name: Bug report
about: 提交一个BUG以帮助我们改进
title: BUG
labels: ''
assignees: ''
---
**介绍您的问题**
**复现流程**
复现问题的步骤:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
-20
View File
@@ -1,20 +0,0 @@
---
name: Feature request
about: 对于本项目的一些想法
title: Feature
labels: ''
assignees: ''
---
**你的功能请求是否是为了解决某个痛点?请详细描述。**
请清晰、简明地描述该痛点。例如:我认为[...]的设计并不好,我觉得这样做会更优秀。
**描述你期望的解决方案**
请清晰、简明地描述你期望实现的效果或功能。
**描述你考虑过的替代方案**
请清晰、简明地描述你考虑过的其他替代方案或功能。
**补充信息**
请在此处补充关于该功能请求的其他背景信息或相关截图。
-10
View File
@@ -1,10 +0,0 @@
---
name: 其他问题
about: 请尽可能详细的描述您的问题,或者直接加入项目官方交流群询问
title: ''
labels: ''
assignees: ''
---
-149
View File
@@ -1,149 +0,0 @@
name: Build and Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: '1.26.6' # 与 src/go.mod 声明保持一致,避免依赖 GOTOOLCHAIN 隐式下载
- name: Set version metadata
run: |
echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV
echo "COMMIT=${GITHUB_SHA}" >> $GITHUB_ENV
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_ENV
- name: Build for Linux
working-directory: src
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-386
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-amd64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-arm64
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-armv7
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-armv6
CGO_ENABLED=0 GOOS=linux GOARCH=loong64 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-loong64
- name: Build for Windows
working-directory: src
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=windows GOARCH=386 go build -ldflags "$LDFLAGS" -o ../lemonipw-windows-386.exe
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-windows-amd64.exe
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-windows-arm64.exe
- name: Build for macOS
working-directory: src
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-darwin-amd64
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-darwin-arm64
- name: Build for FreeBSD
working-directory: src
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags "$LDFLAGS" -o ../lemonipw-freebsd-386
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-freebsd-amd64
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-freebsd-arm64
- name: Build middleware-go for Linux
working-directory: middleware-go
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-386
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-amd64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-arm64
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-armv7
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-armv6
CGO_ENABLED=0 GOOS=linux GOARCH=loong64 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-loong64
- name: Build middleware-go for Windows
working-directory: middleware-go
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=windows GOARCH=386 go build -ldflags "$LDFLAGS" -o ../middleware-go-windows-386.exe
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-windows-amd64.exe
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-windows-arm64.exe
- name: Build middleware-go for macOS
working-directory: middleware-go
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-darwin-amd64
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-darwin-arm64
- name: Build middleware-go for FreeBSD
working-directory: middleware-go
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags "$LDFLAGS" -o ../middleware-go-freebsd-386
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-freebsd-amd64
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-freebsd-arm64
- name: Prepare config files for release
run: |
# 后端:发布 src/ 下的 setting.json.example;中间件:只发布 setting.json.example
cp src/setting.json.example setting.json.example
cp middleware-go/setting.json.example middleware.setting.json.example
- name: Release
uses: softprops/action-gh-release@v3
if: github.ref_type == 'tag'
with:
name: Release ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}
token: ${{ secrets.MINT_TOKEN }}
generate_release_notes: true
body: |
## 配置文件说明
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: |
lemonipw-linux-386
lemonipw-linux-amd64
lemonipw-linux-arm64
lemonipw-linux-armv7
lemonipw-linux-armv6
lemonipw-linux-loong64
lemonipw-windows-386.exe
lemonipw-windows-amd64.exe
lemonipw-windows-arm64.exe
lemonipw-darwin-amd64
lemonipw-darwin-arm64
lemonipw-freebsd-386
lemonipw-freebsd-amd64
lemonipw-freebsd-arm64
middleware-go-linux-386
middleware-go-linux-amd64
middleware-go-linux-arm64
middleware-go-linux-armv7
middleware-go-linux-armv6
middleware-go-linux-loong64
middleware-go-windows-386.exe
middleware-go-windows-amd64.exe
middleware-go-windows-arm64.exe
middleware-go-darwin-amd64
middleware-go-darwin-arm64
middleware-go-freebsd-386
middleware-go-freebsd-amd64
middleware-go-freebsd-arm64
setting.json.example
middleware.setting.json.example
-94
View File
@@ -1,94 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL Advanced"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '22 10 * * 4'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: go
build-mode: autobuild
- language: javascript-typescript
build-mode: none
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"
-203
View File
@@ -1,203 +0,0 @@
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[*]}"
-33
View File
@@ -1,33 +0,0 @@
name: Build and Deploy
on:
push:
branches:
- main
paths:
- 'serverless/edgeone/**'
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24' # 选择适合的 Node.js 版本
- name: Install dependencies
run: npm install -g edgeone
- name: Deploy to EdgeOne Pages
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 serverless/edgeone && npx edgeone pages deploy -n ipw-cn2 -t ${{ secrets.EDGEONE_API_TOKEN }}
env:
EDGEONE_API_TOKEN: ${{ secrets.EDGEONE_API_TOKEN }}
-29
View File
@@ -1,29 +0,0 @@
name: Build and Deploy
on:
push:
branches:
- main
paths:
- 'serverless/edgeone-getip/**'
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24' # 选择适合的 Node.js 版本
- name: Install dependencies
run: npm install -g edgeone
- name: Deploy to EdgeOne Pages
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 }}
-34
View File
@@ -1,34 +0,0 @@
name: Build and Deploy to Cloudflare Workers
on:
push:
branches: ['main']
paths:
- 'frontend-ssr/**'
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '22'
- uses: pnpm/action-setup@v6
with:
version: 10
# 部署到 Cloudflare Workers
- name: Deploy to Cloudflare Workers
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 --keep-vars
+2 -2
View File
@@ -4,7 +4,7 @@ on:
push:
branches: ['main']
paths:
- 'serverless/lemon-getip/**'
- '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 serverless/lemon-getip
cd lemon-getip
npm install
npm run deploy
-30
View File
@@ -1,30 +0,0 @@
main.exe
ip2region_v4.xdb
ip2region_v6.xdb
GeoCN.mmdb
GeoLite2-City.mmdb
GeoLite2-ASN.mmdb
qqwry.ipdb
*.gz
full.txt
short.txt
*.mmdb
*.exe
lemon-ipw
.mmdb.tmp
*.tmp
*.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/
-1
View File
@@ -1 +0,0 @@
24
-128
View File
@@ -1,128 +0,0 @@
# 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.
+2 -54
View File
@@ -1,54 +1,2 @@
# Lemon IPW
ipw.cn 替代品,提供 IP 查询、网站检测、SSL 检查、DNS 解析、TCPing 测速、Whois、DNSSEC、ASN 查询、网站截图等功能。
**[预览站点](https://ipw.wsmdn.top/)** · **[文档](https://github.com/nomdn/ipw-cn/tree/main/docs)**
## 快速开始
```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)(收集中心程序化接口:鉴权、端点与响应示例、公开状态接口)
## 项目结构
```
ipw-cn/
├── src/ # Go 后端
├── middleware-go/ # 独立组件 转发中间件(Fiber v3,多节点候选)
├── serverless/ # 独立组件 边缘/Serverless(edgeone、edgeone-getip、lemon-getip)
├── frontend-ssr/ # Nuxt 4 SSR 前端
├── docs/ # 文档站
└── install.sh # 一键安装脚本
```
## 友情链接
### 来自社区的IPW版本
[ZAKOFLARE/ipw-cn-php](https://github.com/ZAKOFLARE/ipw-cn-php) 一个基于PHP的IPW节点
[ZAKOFLARE/ipw-cn-rust](https://github.com/ZAKOFLARE/ipw-cn-rust) 一个生锈的IPW节点[开发成本过高,已存档]
[KFCV50TK/ipw-cn](https://github.com/KFCV50TK/ipw-cn) 一个有许多扩展功能的IPW分支
## 技术栈
| 组件 | 技术 |
|------|------|
| 后端(自托管) | Go + 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 |
## 许可证
[GPL-3.0](LICENSE)
# ipw.cn
ipw.cn替代品,用于ipw.cn宕机等情况
-88
View File
@@ -1,88 +0,0 @@
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',
},
},
})
-6
View File
@@ -1,6 +0,0 @@
import { defineTeekConfig } from "vitepress-theme-teek/config";
export const teekConfig = defineTeekConfig({
homeCardListPosition: false,
teekHome: false,
});
-7
View File
@@ -1,7 +0,0 @@
// .vitepress/theme/index.ts
import Teek from "vitepress-theme-teek";
import "vitepress-theme-teek/index.css";
export default {
extends: Teek,
};
-347
View File
@@ -1,347 +0,0 @@
# 配置文件
各组件配置说明与完整字段参考。快速上手示例见 [快速开始](/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 指令)
- `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
以上字段均可用环境变量覆盖(`PORTS` / `SINGLE_STACK` / `DNS_SERVER` / `DNSSEC_DNS_SERVER` / `BLOCK_PRIVATE_IPS` / `IPDB` / `CORS` / `TRUSTED_PROXIES` / `GH_PROXY` / `ACCESS_TOKEN` / `WS_URL` / `NODE_ID` / `NODE_KEY` / `NODE_OTA` / `REPORT_URL` / `REPORT_TOKEN` / `REPORT_INTERVAL_SECONDS`)。需要从远端拉取配置时,设置 `remote-config-url` 或环境变量 `REMOTE_CONFIG_URL`(优先级:远端 > 环境变量 > setting.json);`remote-ignore-config` 对应的环境变量为 `REMOTE_IGNORE_CONFIG`(JSON 数组字符串)。
### 运行时配置管理
除启动加载外,节点(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` 每次消费重读,同样即时生效。
- **内存改动会被重启顶掉**:节点侧优先级为 远端 > 环境变量 > `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` 确认环境变量已注入 |
-109
View File
@@ -1,109 +0,0 @@
# 前端部署
前端(`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** 一键部署(无需手动配置构建):
[![使用 EdgeOne Makers 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](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`):
[![Deploy with Vercel](https://vercel.com/button)](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`
## 常见问题
- **部署后接口 403 / 跨域**:检查后端 `cors` 配置(独立中间件见 `middleware-go/setting.json` 的 `cors` 字段,逗号分隔允许域名);服务端转发请求不带 `Origin`,浏览器直接调用才受 CORS 限制。
- **出站 IP 检测接口被浏览器拦截**:页面直连的 IP 检测接口需要 CORS,详见 [配置文件](/guide/config) 的「出站 IP 检测接口」一节。
-124
View File
@@ -1,124 +0,0 @@
# 中间件部署
独立中间件(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 转发
-231
View File
@@ -1,231 +0,0 @@
# 后端节点部署
后端节点提供全部检测 API(IP 归属地 / ASN / SSL / DNS / DNSSEC / Whois / TCPing / 测速 / 截图等),可在任意地区部署,组成去中心化测试网络。
## 方案一:EdgeOne
腾讯云 EdgeOne 支持 Go 边缘函数,点击下方按钮通过 **EdgeOne Makers** 一键部署 `serverless/edgeone/` 版本(无需本地构建):
[![使用 EdgeOne Makers 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](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 边缘函数版本):
[![Deploy with Vercel](https://vercel.com/button)](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) 的「能力清单」一节。
-76
View File
@@ -1,76 +0,0 @@
# 快速开始
> 本章节将指导您以最低成本部署一个功能完整的测试站
## 尝试一下
我们的开发团队已经部署了一个功能完整的预览站:
[预览站点](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)
-27
View File
@@ -1,27 +0,0 @@
# 部署指南
### 本章节将一步步指导您自部署柠檬味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 附带配置文件) |
-213
View File
@@ -1,213 +0,0 @@
# 节点 API 与协议参考
后端节点(`src/`)对外暴露的全部接口与协议。配置项说明见 [配置文件](/guide/config),部署方式见 [后端节点部署](/guide/deploy-node)。
> 本文描述 `src/` 主节点。边缘函数版本(`serverless/edgeone/`)是**精简形态**:只有 `/v1/*` 探测端点与 `GET /`(仅返回 `{"status":"ok"}`,不回报版本与能力),没有 WS 通道、管理接口与 OTA;但同样支持 HTTP 数据上报,见下文「数据上报协议」。
## 鉴权总则
| 通道 | 鉴权方式 | 未配置凭据时 |
|------|----------|--------------|
| 业务探测 `/v1/*` | `access-token` 非空时校验 `Authorization: Bearer <access-token>`,不符返回 `401` | 不启用鉴权,公开可访问 |
| 健康检查 `/`、节点信息 `/info` | 无 | — |
| 管理接口 `/v1/config`、`/v1/ota`(HTTP) | 同业务接口,复用 `tokenCheck` | **整组关闭**,一律返回 `403`(节点未配 `access-token` 时业务接口本身也无鉴权,暴露配置读写等于公网可改配置) |
| WS 通道 | 中间件 `ws-keys` 校验注册报文 | WS **常开**:节点是客户端,指令只来自已通过中间件校验的连接,与节点是否配 `access-token` 无关 |
> 结论:**HTTP 管理面是"有凭据才开"的**;只想用 WS 通道管理节点时,节点无需配置 `access-token`。
---
## 一、健康检查与节点信息
```
GET / → {"status":"ok"}
GET /info → {"version":"v1.2.3","capabilities":["probe","report","config","ota"]}
```
两个端点都无鉴权、不计入统计,且都注册在 `/v1` 组之外——因此既不受 `access-token` 约束,也不经过业务统计中间件。
- **健康检查 `/`**:只回答"进程是否活着"。给负载均衡、OTA 就绪探测(`otaWaitChildReady`)与运维 `curl` 用。**不回报版本**:它是免鉴权的对外端点,回版本号等于向公网公开节点版本。
- **节点信息 `/info`**:回报节点身份。`version` 为版本号,`capabilities` 为能力清单(与 WS `register` 报文同源,见 `ws.go nodeCapabilities`)。收集中心对纯 HTTP 节点(无 WS 连接)探活后单独取回,用于节点状态页展示版本、以及下发管理指令前判断该节点能否理解对应指令。
> 老版本节点没有 `/info`(返回 `404`),边缘函数版节点同样不提供;收集中心取不到就留空,不影响判活。
---
## 二、业务探测接口
全部挂在 `/v1` 组下,共用 CORS + 可选 `tokenCheck` + 统计中间件(`nodeReportMiddleware`,见下文「数据上报」)。
| 方法 | 路径 | 参数 | 说明 |
|------|------|------|------|
| GET | `/v1/detail/*url` | 目标 URL(路径传入,自动补全协议) | 网站探测明细:DNS/TCP/HTTP 各阶段耗时、状态码、页面体积、下载速度、可达性;双栈分别返回 |
| GET | `/v1/ssl/*url` | 目标 URL | SSL 证书检查:剩余有效天数、颁发/主体、TLS 版本、是否过期;双栈分别返回 |
| GET | `/v1/speed/:version/*url` | `version` = `v4` / `v6`,目标 URL | 下载测速 |
| GET | `/v1/tcping/:ip` | `port`(默认 80)、`count`(默认 4,上限 20) | TCP 连通性与 RTT 统计 |
| GET | `/v1/dns/:type/*domain` | `type` ∈ `a`/`aaaa`/`cname`/`mx`/`ns`/`ptr`/`srv`/`txt`/`caa` | DNS 记录查询(`dns-server` 支持主从 failover 与 DoH) |
| GET | `/v1/dnssec/:domain` | 域名 | DNSSEC 链式验证(`dnssec-server`,留空沿用 `dns-server`) |
| GET | `/v1/whois/:domain` | 域名 | Whois 注册信息 |
| GET | `/v1/location/:ip` | IP | 多源 IP 归属地聚合(见 [功能总览](/info/features)) |
| GET | `/v1/location` | 无(取请求方 IP) | 查询访问者自身归属地;配置 `trusted-proxies` 后只信任这些代理转发的 `X-Forwarded-For` |
| GET | `/v1/asn/:ip` | IP | ASN 查询(三套 ASN 库 + ASN Whois 解析) |
**条件注册**:`/v1/location/*`、`/v1/asn/:ip` 仅在 `ipdb` 未关闭时注册——IP 库未加载时这些接口无意义。
**单栈模式**:`single-stack` 配为 `ipv4` / `ipv6` 时只跑对应栈,另一栈直接返回 `Skipped due to SINGLE_STACK=...`,不发起无意义的探测与超时。
**SSRF 防护**:`block-private-ips` 开启时,`detail` / `ssl` 对解析到内网/私有地址的目标直接返回伪造结果,出站客户端同时拒绝连接私有 IP、禁止跨跳重定向。
---
## 三、管理接口
### 3.1 运行时配置 `/v1/config`
读写节点**当前生效**的配置(内存值),与收集中心的「托管配置」是两回事——后者是节点启动时拉的远端配置,见 [配置文件](/guide/config) 的「运行时配置管理」一节。
| 方法 | 路径 | 作用 |
|------|------|------|
| GET | `/v1/config` | 返回当前生效配置快照 + `secretKeys` + `restartRequiredKeys` + `remoteProtectedKeys` |
| PATCH | `/v1/config` | 按下发 map 逐键应用;`?persist=1` 时同时写回本地 `setting.json` |
| POST | `/v1/config/refresh` | 重新拉取 `remote-config-url` 并应用(远端是持久来源,不写回本地文件) |
行为要点:
- **凭据遮蔽**:`access-token` / `node-key` / `report-token` 在 GET 快照里回显 `***`,不泄露明文;下发时原样提交会把真值覆盖成三个星号,因此调用方必须**剔除这些键**(收集中心控制台已自动剔除)。
- **`access-token` / `report-token` 不随远端配置覆盖**:这两项硬编码在保护名单 `configRemoteProtectedKeys` 里,`remote-config-url`(含收集中心托管配置)下发时会被跳过,并在 `refresh` 应答的 `protectedIgnored` 与节点日志里列出被跳过的键。**只约束远端下发**,本地 `PATCH` 照常可改。理由与清单见 [配置文件](/guide/config) 的「远端配置」一节。
- **未持久化的改动会被重启顶掉**:节点侧优先级是 远端 > 环境变量 > `setting.json`,只 PATCH 内存时重启即回到旧值。需要长期生效应 `refresh`(改远端配置)或把改动合并进托管配置——受保护凭据例外,只能落到本地 ENV / `setting.json`。
- **`restartRequired` 只如实回报**:`port` / `cors` / `ipdb` / `report-interval-seconds` / `trusted-proxies` / `node-id` / `node-key` / `access-token` 这些启动期固定的键,改动后节点**不会自行重启**,只在应答里列出键名,重启时机交给运维。
- **`ws-url` 例外,热生效**:WS 客户端控制器按新地址"多退少补",无需重启。 `node-ota` 同样即时生效:该开关在每次下发 OTA 指令时才读取,不属需重启类键。
### 3.2 OTA 升级 `/v1/ota`
收集中心 HTTP 回退通道(WS 在线时优先走 WS `ota` 消息)。
```
POST /v1/ota
{
"requestId": "可选,回执关联用",
"version": "v1.4.0", // 二选一:按版本号下发
"url": "https://.../lemonipw-linux-amd64", // 二选一:直发下载地址
"assetBase": "https://.../releases/download", // 可选,配合 version 使用
"sha256": "可选,hex64(兼容 sha256: 前缀),提供则强校验"
}
→ 202 {"ok":true,"started":true}
```
响应仅为**受理回执**(下载可能数分钟,HTTP 无进度通道)。执行阶段见 [后端节点部署](/guide/deploy-node) 的「OTA 升级」一节。
---
## 四、WebSocket 协议
节点作为 **WS 客户端**连接收集中心(或独立中间件)的 `/ws` 端点:`ws(s)://<中间件>:<ws-port>/ws`。`ws-url` 支持逗号分隔多个地址,**同时连接全部(多活)**,由 per-URL 连接控制器统一收敛。
### 4.1 消息信封
```json
{ "type": "...", "nodeId": "...", "ts": 1710000000, "data": { } }
```
### 4.2 消息类型
**节点 → 中间件**
| type | data 字段 | 作用 |
|------|-----------|------|
| `register` | `nodeId` / `key`(配了 ws-keys 时必填)/ `version` / `capabilities[]` | 注册接入,建立身份 |
| `ping` | — | 心跳,每 10s |
| `pong` | — | 应答中间件 ping |
| `probe_result` | `requestId` / `status` / `body` | 拨测结果回传 |
| `report` | 统计 + 拨测明细(同 `/report` body) | 周期数据上报 |
| `config_result` | `requestId` / `action` / `ok` / `error` / 配置快照 / `applied` / `unknown` / `restartRequired` / `persisted` | 配置指令应答 |
| `ota_result` | `requestId` / `ok` / `stage` / `error` | OTA 进度与失败原因 |
**中间件 → 节点**
| type | data 字段 | 作用 |
|------|-----------|------|
| `register_ok` | `heartbeatSeconds` | 注册通过,告知心跳周期 |
| `register_error` | `code` / `command` | 注册被拒(如 key 不匹配),30s 后重试 |
| `probe` | `requestId` / `apiType` / `raw` / `query` / `scheduler` | 下发拨测 |
| `config` | `requestId` / `action` / `config` / `persist` | 下发配置指令 |
| `ota` | `requestId` / `url` 或 `version`+`assetBase` / `sha256` | 下发升级任务 |
| `ping` | — | 中间件心跳(节点回 `pong`) |
| `status` | 统计字段 | 中间件状态,节点仅记录日志 |
### 4.3 心跳与重连
- 节点每 **10s** 发 `ping`;发送连续失败 **3 次**判定通道不可用,主动断开并在 **3s** 后重连。
- 注册被拒(key 错误等)时 **30s** 后重试。
- 中间件侧每 20s 发 `ping` + `status`,空闲 **75s** 未收到任何报文即剔除该节点并记离线事件。
### 4.4 OTA 进度阶段
节点经 `ota_result` 依次回报:`accepted` → `downloading` → `verifying` → `installing` → `restarting`;任一阶段失败即终止并带 `error`。重启后连接必然断开,"最终结果"由收集中心以**重连注册上报的新版本号**判定。
---
## 五、数据上报协议
统计与拨测明细**由节点自己上报**,收集中心不在转发路径上采集——节点是唯一记录者,不存在双算。
| 通道 | 条件 | 形式 |
|------|------|------|
| WS `report` 消息 | 已连接中间件 | 向所有在线中间件广播 |
| HTTP `POST <report-url>/report` | WS 未启用或全部掉线 | 携带 `Authorization: Bearer <report-token>` |
```json
{
"instance": "cn-jiangsu",
"stats": [
{ "nodeId": "cn-jiangsu", "apiType": "tcping",
"total": 1, "errors": 0, "latencySumMs": 12, "latencyMaxMs": 12, "minute": 29821676 }
],
"probes": [
{ "nodeId": "cn-jiangsu", "apiType": "tcping", "raw": "qq.com",
"status": 200, "latencyMs": 33, "body": { } }
]
}
```
- 上报周期由 `report-interval-seconds` 控制(缺省 15s)。
- `stats` 是**增量**,收集器按 `(分钟 × 节点 × apiType)` 累加;上报为 at-most-once(失败丢弃并记日志,不重试——重试会双算)。
- 单次报文上限 500 条 stats / 500 条 probes / 4MB;超出或队列(缓冲 2048)打满时丢弃并计数。
- **只上报第一方观测**:节点自己处理的请求才记账,不转播从别处收到的数据。
- `minute` 是 unix 分钟桶(按节点时钟);上报方传 `0` 表示"由收集器按自身时钟入桶",收集端另有"不接受未来 1 小时以外"的漂移容忍。
- **部署形态差异**:`serverless/edgeone/` 版本不维持长连接(无 WS 客户端),只走 HTTP `POST /report` 一条通道,配置方式见 [后端节点部署](/guide/deploy-node) 的 EdgeOne 一节。
---
## 六、能力清单(capabilities)
节点在 `register` 报文与主节点的 `GET /info` 中上报自身支持的管理能力,取值为 `probe` / `report` / `config` / `ota` 的数组。边缘函数版本不提供 `/info`,也不回报能力清单。
收集中心据此做**三态判定**,绝不能把"未知"当作"不支持":
| 上报情况 | 收集中心行为 |
|----------|--------------|
| 清单含该能力 | 正常下发对应指令 |
| 清单明确不含 | 该能力确实没有:立刻拒绝,不发起指令、不等超时 |
| 清单为空(未上报) | 老版本节点,能力**未知**:仍尝试下发;超时后主动探活,区分"节点活着但静默忽略(程序过旧)"与"连接已僵死" |
新增一类管理指令时应在此追加能力标识,并保持取值稳定(收集中心按字符串比对)。
---
## 七、缓存与并发
业务探测结果在节点侧缓存,避免同 key 洪打把上游打崩:
| 缓存 | 键 | TTL |
|------|-----|-----|
| `detail` | 归一化 URL | 5 分钟 |
| `ssl` | URL | 5 分钟 |
| `speed` | `url:version` | 5 分钟 |
| `tcping` | `host:port:count` | 5 分钟 |
| `whois` | 域名 | 5 分钟 |
| `asn whois` | `AS<asn>` | 5 分钟 |
| Bilibili 归属地(在线 API) | IP | 24 小时 |
- **失败结果 30s 后删除**:探测失败(如目标不可达)不缓存整 5 分钟,避免故障恢复后长时间返回旧结论。
- **singleflight 合并**:`detail` / `ssl` / `tcping` / `speed` 的并发同 key 请求合并为一次真实探测。
- **定时清扫**:每 10 分钟清理过期缓存条目与 Bilibili 缓存,防止只靠"同 key 重访才淘汰"时内存无限增长。
-361
View File
@@ -1,361 +0,0 @@
# 公开 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(普通用户只见自己的) |
-35
View File
@@ -1,35 +0,0 @@
---
# 首页(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 部署
---
-79
View File
@@ -1,79 +0,0 @@
# 功能总览
后端节点(`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)。
-44
View File
@@ -1,44 +0,0 @@
# 柠檬味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)
-11
View File
@@ -1,11 +0,0 @@
{
"scripts": {
"docs:dev": "vitepress dev",
"docs:build": "vitepress build",
"docs:preview": "vitepress preview"
},
"devDependencies": {
"vitepress": "^1.6.4",
"vitepress-theme-teek": "^1.6.2"
}
}
-1858
View File
File diff suppressed because it is too large. Load diff
-2
View File
@@ -1,2 +0,0 @@
allowBuilds:
esbuild: true
-29
View File
@@ -1,29 +0,0 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
# cloudflare
.wrangler
.dev.vars*
!.dev.vars.example
-75
View File
@@ -1,75 +0,0 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
-426
View File
@@ -1,426 +0,0 @@
<script setup lang="ts">
import { computed, 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);
let mediaQueryList: MediaQueryList | null = null;
const drawer = ref(false);
const isDark = useDark();
const toggleDark = useToggle(isDark);
// ==================== 抽屉里的文档菜单 ====================
// 窄屏下 /doc 页面自己的左侧侧栏会被隐藏(见 app/pages/doc/*.vue),
// 文档导航改由这里的抽屉承载;宽屏不渲染抽屉,一切照旧。
const route = useRoute()
const isDocRoute = computed(() => route.path === '/doc' || route.path.startsWith('/doc/'))
function cleanChineseCharacters(str:string) {
return str.replace(/[\u4e00-\u9fa5]/g, '');
}
// ==================== 响应式折叠菜单 ====================
// 映射表:菜单按显示顺序排在一个数组里,每项自带 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),
)
// 实时同步视口宽度(resize 触发,驱动折叠)
function updateViewport() {
viewportWidth.value = window.innerWidth
}
let umamiScript: HTMLScriptElement | null = null
useHead({
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'}
]
: [{ name:'baidu-site-verification', content: 'codeva-xzdbvF8gQu'}],
});
onMounted(() => {
mediaQueryList = window.matchMedia('(max-width: 768px)');
isNarrow.value = mediaQueryList.matches;
const handler = (e: MediaQueryListEvent) => {
isNarrow.value = e.matches;
};
mediaQueryList.addEventListener('change', handler);
updateViewport();
window.addEventListener('resize', updateViewport);
onBeforeUnmount(() => {
mediaQueryList?.removeEventListener('change', handler);
window.removeEventListener('resize', updateViewport);
});
if (!umamiScript && config.umamiScriptUrl) {
umamiScript = document.createElement('script')
umamiScript.src = config.umamiScriptUrl
umamiScript.async = true
umamiScript.setAttribute('data-website-id', config.umamiWebsiteId)
document.head.appendChild(umamiScript)
}
})
</script>
<template>
<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">
<p class="menu-item-text">IPv6/IPv4 地址查询</p>
</router-link>
<router-link to="/ipv6tcping">
<p class="menu-item-text">IPv6 TCPing</p>
</router-link>
<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>
<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="返回首页">
<el-icon v-if="isNarrow" @click="drawer = !drawer"><Expand /></el-icon>
<router-link to="/" aria-label="返回首页">
<!-- alt 留空是有意的:logo 属装饰图,可访问名由外层链接提供。
若把 alt 写成站名,宽屏下会与旁边的 h2 一起被读两遍。 -->
<el-image src="/favicon.svg" alt="" style="margin-top: 20px;" />
<h2 style="display: inline-block; margin-left: 10px" v-if="!isNarrow">{{ config.siteName }}</h2>
</router-link>
</el-menu-item>
<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 v-else style="margin-top: 20px;height: 1.2em;" direction="vertical"/>
</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>
</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>
</ClientOnly>
</el-menu-item>
</el-menu>
<NuxtLoadingIndicator />
<main id="main-content" role="main">
<NuxtPage />
</main>
<!-- 文档路由不渲染页脚:文档页是阅读页,正文底部加备案/版权块会打断阅读,
且窄屏下与侧栏抽屉的可滚动区域互相挤压。 -->
<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
</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">&nbsp;|&nbsp;</span>
<el-image v-if="config.GongAn" style="height: 1em; width: 1em;" src="/备案图标.png" />
<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">&nbsp;|&nbsp;</span>
<a href="https://www.china-ipv6.cn/" target="_blank" rel="noreferrer" >国家IPv6发展监测平台</a>
&nbsp;|&nbsp;请遵守中国法律法规&nbsp;|&nbsp;
<a href="https://github.com/nomdn/ipw-cn" target="_blank" rel="noreferrer" >Github</a>&nbsp;|&nbsp;
<a href="https://qm.qq.com/q/E1CGjkqgG6" target="_blank" rel="noreferrer" >QQ用户交流群</a>
</div>
<div class="one-line">
致力于普及IPv6,推进IPv6规模部署和应用,以全面推进IPv6技术创新与融合应用为主线,以提升应用广度深度为主攻方向
</div>
</footer>
</template>
<style scoped>
@import "~/style.css";
/* 菜单项统一文本样式(替代原内联 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;
word-wrap:break-word;
}
:deep(.shiki){
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;
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
html.dark {
/* 暗色模式下根元素的文字色与页面底色。
这两条必须在【非 scoped】块里:<html> 永远不会带上组件 scope 属性,
若写在 <style scoped>(含被 scoped @import 进来的 style.css)里,会被改写成
`html.dark[data-v-xxxx]` —— 整条规则永不匹配,底色只能靠浏览器的 color-scheme 兜底。 */
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
--el-color-primary: #3EAF7C;
}
/* 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;
}
/* 并进抽屉的文档菜单:与上面的工具链接视觉上分段,并去掉菜单自带底色与右边框
(.el-menu 默认 border-right,竖排菜单是给页面侧栏用的,在抽屉里会多出一条竖线) */
.el-drawer__body .el-menu {
background-color: transparent;
border-right: none;
border-top: 1px solid var(--el-border-color-lighter);
padding-top: 8px;
}
/* 手机上条目多,行高压到 44px 让一屏能多看几项(默认 56px)。
文档标题可能很长(如「Windows 10/11 设置 IPv4/IPv6 访问优先级」),
截断会丢信息 → 允许换行,高度改 auto 只保 44px 最小行高。 */
/* 手机上条目多,行高压到 44px 让一屏能多看几项(默认 56px)。
注意:element-plus 竖排菜单规则是
`.el-menu--vertical:not(..)×3 .el-menu-item`(特异性 5 个类),
这里必须 !important 才压得住 height / line-height / white-space。
文档标题可能很长(如「Windows 10/11 设置 IPv4/IPv6 访问优先级」),
截断会丢信息 → 允许换行,高度改 auto 只保 44px 最小行高。 */
.el-drawer__body .el-menu .el-menu-item,
.el-drawer__body .el-menu .el-sub-menu__title {
height: auto !important;
min-height: 44px;
line-height: 1.45 !important;
padding-top: 6px;
padding-bottom: 6px;
white-space: normal !important; /* 长标题裸文本在 li 上,必须解掉 nowrap */
}
.el-drawer__body .el-menu .el-menu-item a,
.el-drawer__body .el-menu .el-menu-item p {
white-space: normal;
word-break: break-word;
}
/* 窄屏(≤768px,与 script 中 isNarrow 的 matchMedia 断点一致):
在水合前由 CSS 兜底隐藏宽屏菜单项,避免窄屏设备闪现宽屏布局 */
@media (max-width: 768px) {
.el-menu--horizontal > .el-divider {
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;
}
.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;
}
.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;
}
/* 覆盖选中、悬停和聚焦状态的高亮样式 */
</style>
-111
View File
@@ -1,111 +0,0 @@
<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>
-78
View File
@@ -1,78 +0,0 @@
/* 当 html 有 .dark 类名时,切换为暗色主题 */
html.dark {
/* 强制覆盖所有颜色变量 */
color-scheme: dark;
}
/* 暗色模式下的背景色和文字颜色 */
html.dark .markdown-body {
/* GitHub 暗色主题的核心颜色 */
--color-fg-default: #e6edf3;
--color-fg-muted: #8b949e;
--color-fg-accent: #3EAF7C;
--color-bg-default: #242424;
--color-bg-muted: #080707;
--color-bg-accent: #3EAF7C;
--color-border-default: #242424;
--color-border-muted: #242424;
background-color: var(--color-bg-default);
color: var(--color-fg-default);
}
/* 暗色模式下的标题颜色 */
html.dark .markdown-body h1,
html.dark .markdown-body h2,
html.dark .markdown-body h3,
html.dark .markdown-body h4,
html.dark .markdown-body h5,
html.dark .markdown-body h6 {
color: var(--color-fg-default);
}
/* 暗色模式下的链接颜色 */
html.dark .markdown-body a {
color: var(--color-fg-accent);
}
/* 暗色模式下的代码块 */
html.dark .markdown-body pre {
background-color: var(--color-bg-muted);
border: 1px solid var(--color-border-default);
}
html.dark .markdown-body code {
background-color: var(--color-bg-muted);
color: var(--color-fg-default);
}
html.dark .markdown-body pre.shiki,
html.dark .markdown-body pre.shiki code {
background-color: transparent;
}
/* 暗色模式下的引用块 */
html.dark .markdown-body blockquote {
border-left-color: var(--color-border-muted);
color: var(--color-fg-muted);
}
/* 暗色模式下的表格 */
html.dark .markdown-body table tr {
background-color: var(--color-bg-default);
border-top-color: var(--color-border-muted);
}
html.dark .markdown-body table th,
html.dark .markdown-body table td {
border-color: var(--color-border-muted);
}
html.dark .markdown-body table tr:nth-child(2n) {
background-color: var(--color-bg-muted);
}
/* 暗色模式下的分割线 */
html.dark .markdown-body hr {
border-color: var(--color-border-muted);
}
-272
View File
@@ -1,272 +0,0 @@
<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
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('')
const apiList = config.IPLocationAPI
const currentApiIndex = ref(0)
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
const { data: asnData, error: asnError, execute: executeASN } = useMiddlewareFetch<ASNResult>(() => '/middleware/' + backendID.value + '/asn/' + 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)"
/>
<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.geolite2_asn?.asn">
<td class="table-label">Maxmind ASN</td>
<td class="table-value">
AS{{ 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">
AS{{ 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: #3EAF7C;
}
html.dark {
--el-color-primary: #3EAF7C;
}
.el-icon{
font-size: 1.3em;
}
</style>
-318
View File
@@ -1,318 +0,0 @@
<script setup lang="ts">
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 { formatTime } from '../../utils/tools';
const route = useRoute()
useHead({
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检测' },
{ property: 'og:title', content: 'DNS查询工具 - 多节点域名解析记录检测' },
{ property: 'og:description', content: '多节点DNS查询工具,支持多种DNS记录类型查询,快速检测域名解析状态' },
{ 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: 'DNS查询与解析检测工具',
description: '专业的多节点DNS查询工具,支持A、AAAA、CNAME、MX、NS、TXT、SRV、CAA等多种记录类型,全国多节点并发检测。',
url: config.siteUrl + 'dns',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY'
},
provider: {
'@type': 'Organization',
name: config.siteName
}
})
}
]
});
const tmpDomain = ref('www.zakoflare.com')
const domain = ref('')
const recordType = ref('a')
const loading = ref(false)
const results = ref<any>([])
const isloading = ref(false)
const nowRecordType = ref('')
const userIP = ref('')
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
async function getUserIP(){
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
} catch {
// 获取失败保持为空,避免未处理的 Promise 拒绝
}
return userIP.value
}
const recordTypes = [
{ value: 'a', label: 'A 记录' },
{ value: 'aaaa', label: 'AAAA 记录' },
{ value: 'cname', label: 'CNAME 记录' },
{ value: 'mx', label: 'MX 记录' },
{ value: 'ns', label: 'NS 记录' },
{ value: 'txt', label: 'TXT 记录' },
{ value: 'srv', label: 'SRV 记录' },
{ value: 'caa', label: 'CAA 记录' },
{ value: 'ptr', label: 'PTR 记录' }
]
const dnsServerFetches = [
...config.APIBaseURL.DualStack,
...config.APIBaseURL.IPv4,
...config.APIBaseURL.IPv6
].map((server) => {
const url = computed(() => "/middleware/" + server.id + "/dns/" + recordType.value + "/" + encodeURIComponent(domain.value));
const { data, error, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
});
return { label: server.label, data, error, execute };
});
async function queryDNS() {
isloading.value = true
domain.value = tmpDomain.value
await nextTick()
const id = ++queryId
// 初始化结果数组,保持响应式结构
results.value = dnsServerFetches.map(fetch => ({
server: fetch.label,
loading: true,
data: null,
error: null
}));
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,
loading: false,
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,
loading: false,
data: null,
error: err
};
return {
server: fetch.label,
error: err
};
}
});
nowRecordType.value = recordType.value
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
const typeParam = route.query.type as string
if (domainParam) {
tmpDomain.value = domainParam
}
if (typeParam && recordTypes.some(t => t.value === typeParam)) {
recordType.value = typeParam
}
if (domainParam) {
queryDNS()
}
getUserIP()
})
const { data: page, error } = await useAsyncData('dns-page', () =>
$fetch('/api/markdown/dns')
)
const doc = page.value;
</script>
<template>
<div class="title">
<header>
<h1>DNS查询</h1>
<p>多节点 DNS 查询,检测域名解析记录</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:example.com)"
/>
<el-select v-model="recordType" style="width: 150px;" class="custom-height-select">
<el-option
v-for="item in recordTypes"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button
@click="queryDNS()"
type="primary"
:loading="isloading"
>
查询
</el-button>
</div>
<div class="result-section">
<table class="result-table" v-if="results.length > 0">
<thead>
<tr>
<th class="table-header">服务器</th>
<th class="table-header">类型</th>
<th class="table-header">记录</th>
<th class="table-header">记录数</th>
<th class="table-header">耗时</th>
<th class="table-header">TTL (S)</th>
</tr>
</thead>
<tbody>
<tr v-for="(result) in results" :key="result.server">
<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-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>
</div>
</template>
<span v-else-if="!result.loading && result.data?.record?.length === 0" class="status-code" style="color: #F56C6C; background: #fef0f0;">
解析失败
</span>
</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>
</table>
</div>
<blockquote>
<span class="quate">A</span> 将域名指向一个 IPv4 地址,如 <span class="quate">106.55.75.123</span>。 A记录归属地是柠檬超绝大杂烩免费接口,随便用。<br/>
<span class="quate">AAAA</span> 将域名指向一个 IPv6 地址,如 <span class="quate">2402:4e00:1013:e500:0:9671:f018:4947</span>。同一个主机名可以同时解析到 IPv4(A记录)地址 和 IPv6(AAAA 记录)地址上,当只有IPv4 地址的用户会解析到 IPv4 地址,一般情况下有 IPv6 地址的用户会优先解析到 IPv6 地址。<br/>
<span class="quate">CNAME</span> 将域名指向另一个域名地址,与其保持相同解析,如 <span class="quate">ipw.wsmdn.top</span> 别名到 <span class="quate">ipw.wsmdn.top.eo.dnse1.com</span>.<br/>
<span class="quate">MX</span> 用于邮件服务器,一般由邮件注册商提供,如 <span class="quate">mxbiz1.qq.com</span>。如果邮箱格式为 test@<span class="quate">wsmdn.top</span> 则输入 <span class="quate">wsmdn.top</span> 查询。如果邮箱格式为 test@<span class="quate">mail.wsmdn.top</span>则输入<span class="quate">mail.wsmdn.top</span>查询。推荐2个免费的企业邮箱:腾讯企业邮、网易免费企业邮。<br/>
<span class="quate">TXT</span> 附加文本信息,常用于域名所有权验证,如在申请 HTTPS 证书时需要增加记录、<br/>
<span class="quate">PTR</span> IP 的反向解析记录,例如 <span class="quate">159.75.190.197</span> 反解析到 <span class="quate">wsmdn.top</span>,一般用于提升自建域名邮件服务器的可信度,可提单找云服务商添加。<br/>
<span class="quate">NS</span> 域名的 DNS 服务器地址,例如 <span class="quate">ns3.dnsv2.com</span>,推荐 华为云DNS.<br/>
<a href="/ipv6webcheck" target="_blank">网站开启IPv6检测</a> | <a href="/ssl" target="_blank">SSL证书在线检查</a><br/>
访客IP: {{userIP }},您的网络 {{ isIPv6(userIP) ? 'IPv6' : 'IPv4'}} 访问优先<br/>
</blockquote>
<div class="markdown">
<div v-html="doc"></div>
</div>
</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;
}
.markdown :deep(a) {
color: #3EAF7C !important;
font-size: 1.3em;
text-decoration: none
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
html.dark {
--el-color-primary: #3EAF7C;
}
.el-icon {
font-size: 1.3em;
}
</style>
-295
View File
@@ -1,295 +0,0 @@
<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
}
</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)"
/>
<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>
<blockquote>
DNSSEC 原理介绍即将上线<br/>
DNSSEC 通过 DNSKEY、RRSIG、DS 记录为 DNS 数据提供完整性验证,防止 DNS 欺骗攻击。<br/>
如果显示 "验证失败",可能原因:域名未配置 DNSSEC、DNSKEY 密钥不匹配、DS 记录缺失等。<br/>
<a href="/dns" target="_blank">DNS 解析查询</a> | <a href="/whois" target="_blank">Whois 查询</a><br/>
访客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: 180px;
}
.error-text {
color: #F56C6C;
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
html.dark {
--el-color-primary: #3EAF7C;
}
.el-icon{
font-size: 1.3em;
}
</style>
-74
View File
@@ -1,74 +0,0 @@
<script setup lang="ts">
import { getDocMeta } from '../../../config/doc'
const route = useRoute();
const path = computed(() => route.path)
const { data: page } = await useFetch(() => `/api/markdown${path.value}`)
const meta = computed(() => getDocMeta(route.path))
useHead({
title: computed(() => meta.value.title),
meta: [
{ name: 'description', content: computed(() => meta.value.description) }
]
})
</script>
<template>
<div class="box">
<!-- 文档导航只有一份实现(app/components/DocMenu.vue)。
宽屏:就是这条左侧固定侧栏;窄屏:本侧栏隐藏,改由顶部抽屉菜单承载 -->
<DocMenu class="sidebar-menu" />
<div class="content">
<div class="markdown-body" v-if="page">
<div v-html="page" />
</div>
</div>
</div>
</template>
<style scoped>
/* 核心布局容器 */
.box {
display: flex;
flex-direction: row; /* 改为 row,实现左右排列 */
height: 100vh; /* 占满整个屏幕高度,防止出现页面级滚动条 */
width: 100%;
background-color: #fff; /* 可选:设置整体背景色 */
}
html.dark .box {
background-color: #242424; /* 可选:暗黑模式下的整体背景色 */
}
/* 左侧侧边栏样式 */
.sidebar-menu {
width: 260px; /* 固定侧边栏宽度 */
height: 100%; /* 高度撑满父容器 */
flex-shrink: 0; /* 防止被右侧内容挤压变形 */
overflow-y: auto; /* 菜单项过多时,侧边栏内部出现滚动条 */
border-right: 1px solid #e4e7ed; /* 添加右侧分割线(可选) */
}
.content {
flex: 1; /* 右侧内容区域占据剩余空间 */
padding: 20px; /* 内边距,避免内容贴边 */
overflow-y: auto; /* 内容过多时,右侧区域出现滚动条 */
background-color: #fff; /* 可选:设置右侧内容区域背景色 */
}
html.dark .content {
background-color: #242424; /* 可选:暗黑模式下的背景色 */
}
@media (max-width: 768px) {
/* 窄屏不再挤一条窄侧栏(原先 100px,长标题全被截断):
文档导航已并入左侧抽屉菜单,这里直接撤掉,正文铺满整屏 */
.sidebar-menu {
display: none;
}
.content {
padding: 10px;
}
}
</style>
<style>
@import "github-markdown-css/github-markdown-light.css";
@import "../../github-markdown-dark.css";
</style>
-74
View File
@@ -1,74 +0,0 @@
<script setup lang="ts">
import { getDocMeta } from '../../../config/doc'
const { data: page } = await useAsyncData('doc-index', () =>
$fetch('/api/markdown/doc/index')
)
const doc = page.value;
const meta = getDocMeta('/doc')
useHead({
title: meta.title,
meta: [
{ name: 'description', content: meta.description }
]
})
</script>
<template>
<div class="box">
<!-- 文档导航只有一份实现(app/components/DocMenu.vue)。
宽屏:就是这条左侧固定侧栏;窄屏:本侧栏隐藏,改由顶部抽屉菜单承载 -->
<DocMenu class="sidebar-menu" />
<div class="content">
<div class="markdown-body" v-html="doc"></div>
</div>
</div>
</template>
<style scoped>
/* 核心布局容器 */
.box {
display: flex;
flex-direction: row; /* 改为 row,实现左右排列 */
height: 100vh; /* 占满整个屏幕高度,防止出现页面级滚动条 */
width: 100%;
background-color: #fff; /* 可选:设置整体背景色 */
}
html.dark .box {
background-color: #242424; /* 可选:暗黑模式下的整体背景色 */
}
/* 左侧侧边栏样式 */
.sidebar-menu {
width: 260px; /* 固定侧边栏宽度 */
height: 100%; /* 高度撑满父容器 */
flex-shrink: 0; /* 防止被右侧内容挤压变形 */
overflow-y: auto; /* 菜单项过多时,侧边栏内部出现滚动条 */
border-right: 1px solid #e4e7ed; /* 添加右侧分割线(可选) */
}
.content {
flex: 1; /* 右侧内容区域占据剩余空间 */
padding: 20px; /* 内边距,避免内容贴边 */
overflow-y: auto; /* 内容过多时,右侧区域出现滚动条 */
background-color: #fff; /* 可选:设置右侧内容区域背景色 */
}
html.dark .content {
background-color: #242424; /* 可选:暗黑模式下的背景色 */
}
@media (max-width: 768px) {
/* 窄屏不再挤一条窄侧栏(原先 100px,长标题全被截断):
文档导航已并入左侧抽屉菜单,这里直接撤掉,正文铺满整屏 */
.sidebar-menu {
display: none;
}
.content {
padding: 10px;
}
}
</style>
<style>
@import "github-markdown-css/github-markdown-light.css";
@import "../../github-markdown-dark.css";
@import "/style.css"
</style>
-207
View File
@@ -1,207 +0,0 @@
<script setup lang="ts">
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 { isIPv4 } from '../../utils/tools';
const route = useRoute();
const canonicalUrl = computed(() => new URL(route.path, config.siteUrl).toString());
useHead({
title: `${config.siteName} | IP查询工具 | IPv4/IPv6地址查询与网络测试平台`,
titleTemplate: '%s',
link: [
{ rel: 'canonical', href: canonicalUrl.value }
],
meta: [
{ 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: `${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: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: config.siteName,
description: `${config.siteName}提供专业的IP查询服务,支持IPv4和IPv6地址在线查询、归属地定位、网络测速、DNS解析、SSL证书检测、TCPing测试等多种网络工具,致力于推进IPv6规模部署和应用,打造去中心化的IP查询平台`,
url: canonicalUrl.value,
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Any',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY'
},
featureList: 'IPv4地址查询,IPv6地址查询,IP归属地定位,网络测速,DNS解析,SSL证书检测,TCPing测试,IPv6优先检测',
about: [
{
'@type': 'Thing',
name: 'IP地址查询',
description: '通过特定的IP地址获取相关的地理位置、运营商、网络类型等信息的技术服务。IPv4地址是32位地址格式,IPv6地址是128位地址格式,IPv6能够提供更大的地址空间,解决IPv4地址枯竭问题。'
},
{
'@type': 'Thing',
name: 'IPv6优先检测',
description: '当访问一个同时支持IPv4和IPv6的双栈网站时,如果网络IPv6优先,系统会优先使用IPv6地址进行连接。通过访问双栈测试域名来判断网络优先级。'
},
{
'@type': 'Thing',
name: 'IPv6部署',
description: 'IPv6是全球下一代互联网协议标准,相比IPv4具有更大的地址空间、更好的安全性、更高的网络效率。国家正在大力推进IPv6规模部署和应用,以适应未来互联网发展需求。'
}
]
})
}
]
});
const code = `
# 请勿用于商业用途,仅供个人测试学习之用,请遵守中国法律法规
# 查询本机外网 IPv4 地址
curl ${config.v4OnlyAPI}
# 查询本机外网 IPv6 地址
curl ${config.v6OnlyAPI}
# 测试网络是 IPv4 还是 IPv6 访问优先
# (访问 IPv4/IPv6 双栈站点,如果返回 IPv6 地址,则 IPv6 访问优先)
curl ${config.DualStackAPI}
`.trim(); // 关键:去掉首尾多余的空行
const highlightedCode = ref('');
const ipAddress = ref('');
const yourIPv4 = ref('');
const yourIPv6 = ref('');
onMounted(() => {
// 代码高亮与三个 IP 查询互不依赖,一起发出即可。
// 注意别写成"先 await 高亮再发请求":shiki 首次要把语言包拉下来,会把三次查询整体推后数百毫秒。
//
// 用动态 import 而非顶层静态 import:shiki 的引擎核心 + 语言/主题注册表约 160KB,
// 首页只为这 4 行 curl 示例需要它。静态 import 会把它拽进首屏同步加载的关键路径
// (构建后体现为一个 164KB 的 chunk 出现在 modulepreload 列表);动态 import 后
// 它单独成 chunk,首屏渲染不再等它。
void import('../../utils/shiki')
.then(({ highlightCode }) => highlightCode(code, 'bash'))
.then((html) => {
highlightedCode.value = html;
})
.catch(() => {
highlightedCode.value = '';
});
// 三个地址各自独立请求、各自渲染:谁先回来谁先上屏。
// 不用 Promise.allSettled 包起来等齐——那样只要有一个慢(例如纯 IPv4 网络下 v6 接口要等超时),
// 已经拿到的 IPv4 与双栈结果也得一起干等。
//
// retry: false —— ofetch 默认会对 GET 重发一次:网络层失败时它拿不到 response,就按 500 计
// (500 在默认重试白名单里),且默认延迟为 0,立即重发。于是"这个节点连不上"会被原样重做一遍,
// 用户白等一倍时间(实测 ofetch 1.5.1:裸调用触发 2 次真实 fetch,传 retry:false 后只剩 1 次;
// 浏览器自身不会重试,底层 fetch 对同一请求只被调用过一次)。
const ipFetchOptions = { retry: false } as const;
$fetch<string>(config.DualStackAPI, ipFetchOptions)
.then((ip) => {
ipAddress.value = ip;
})
.catch(() => {
// 单个接口失败不影响其它两行展示,保持"查询中"占位
});
$fetch<string>(config.v4OnlyAPI, ipFetchOptions)
.then((ip) => {
yourIPv4.value = ip;
})
.catch(() => {});
$fetch<string>(config.v6OnlyAPI, ipFetchOptions)
.then((ip) => {
yourIPv6.value = ip;
})
.catch(() => {});
});
</script>
<template>
<div class="title">
<header>
<h1>IP查询</h1>
<p>致力于IP查询去中心化,推进 IPv6 规模部署和应用</p>
</header>
</div>
<div class="content">
<div class="one-line">
<b>IPv4</b>&nbsp<p>{{ yourIPv4 }} </p>&nbsp<RouterLink :to="`/location?ip=${yourIPv4}`" target="_blank">查询归属地</RouterLink>
</div>
<div class="one-line">
<b>IPv6</b>&nbsp<p v-if="yourIPv6">{{ yourIPv6 }}</p><RouterLink :to="`/location?ip=${yourIPv6}`" target="_blank" v-if="yourIPv6">&nbsp查询归属地</RouterLink><RouterLink v-else to="/doc/user/enable_ipv6" target="_blank">没有IPv6地址,查看如何开启IPv6</RouterLink>
</div>
<div class="ip-priority">
<h2 v-if="ipAddress && isIPv6(ipAddress)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h2>
<h2 v-else-if="ipAddress && isIPv4(ipAddress)"><el-icon><CircleCloseFilled style="color: red;"/></el-icon>您的网络IPv4优先</h2>
<h2 v-else><el-icon><CircleCloseFilled /></el-icon>查询中,请稍后</h2>
</div>
<blockquote>
手机默认开启 IPv6,宽带开启 IPv6 请参阅<a href="/doc/user/enable_ipv6" target="_blank">文档</a>
</blockquote>
<div v-if="highlightedCode" v-html="highlightedCode" class="code-block"></div>
<div v-else class="code-block code-block--fallback">{{ code }}</div>
</div>
</template>
<style scoped>
@import "../style.css";
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
/* 网络优先级提示:原来是「div 1.5em × h3」,但 h1 之后直跳 h3 违反标题层级(heading-order),
语义上应为 h2。style.css 给 h3 显式定了 1.3em / 窄屏 1.1em,h2 只有 1.5em 一档,
所以这里把字号按原样补回,避免语义修正顺带改了视觉(含 margin:UA 的 h3 是 1em,h2 是 0.83em)。 */
.ip-priority {
font-size: 1.5em;
}
.ip-priority h2 {
font-size: 1.3em;
margin: 1em 0;
}
.code-block {
margin-top: 1rem;
padding: 1rem;
border-radius: 0.75rem;
overflow-x: auto;
max-width: 100%;
white-space: pre-wrap;
overflow-wrap: break-word;
}
.code-block--fallback {
background: rgb(48, 46, 46);
border: 1px solid rgba(62, 175, 124, 0.18);
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Monaco', 'Courier New', monospace !important;
color: rgb(255, 255, 255);
}
@media (max-width: 768px) {
.ip-priority h2 {
font-size: 1.1em;
}
.code-block {
padding: 0.75rem;
font-size: 0.8em;
}
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
</style>
-26
View File
@@ -1,26 +0,0 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
onMounted(async () => {
const urlParam = route.query.ip;
// ip 参数缺失时不要拼出 ?ip=undefined
await navigateTo(urlParam ? { path: '/location', query: { ip: urlParam } } : { path: '/location' });
});
</script>
<template>
<div class="redirecting">正在跳转到 IP 归属地查询…</div>
</template>
<style scoped>
.redirecting {
display: flex;
align-items: center;
justify-content: center;
min-height: 50vh;
color: #999;
}
</style>
-283
View File
@@ -1,283 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { config } from '../../config/index';
import { useMiddlewareFetch } from '../../utils/middleware';
import { extractHost, formatTime, formatSpeed, formatSize, getStatusCodeClass } from '../../utils/tools';
const route = useRoute()
useHead({
title: `IPv6网站测速工具 | 全国多节点并发测速 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的IPv6网站测速工具,提供全国多节点并发测速服务,1秒内快速返回网站响应时间、下载速度、页面大小、DNS解析时间、HTTP连接时间等详细信息,支持IPv6网站性能检测与优化,助力IPv6网站性能监控与用户体验提升' },
{ name: 'keywords', content: 'ipv6网站测速,ipv6测速,网站速度测试,ipv6性能检测,网站响应时间,ipv6下载速度,ipv6性能优化,ipv6网站监控' },
{ property: 'og:title', content: 'IPv6网站测速 - 全国多节点并发性能检测工具' },
{ property: 'og:description', content: '全国多节点IPv6网站测速,快速获取响应时间、下载速度等性能数据' },
{ 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: 'IPv6网站性能测速工具',
description: '专业的IPv6网站测速工具,全国多节点并发测速,提供响应时间、下载速度、页面大小、DNS解析时间等详细性能指标。',
url: config.siteUrl + 'ipv6speedtest',
applicationCategory: 'DeveloperApplication',
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 result = ref<any[]>([])
const error = ref('')
const domain = ref('')
/** 服务器结果接口 */
interface ServerResult {
label: string
loading: boolean
error?: string | Error
data: any
}
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
/** 预构建的服务器列表,页面挂载即展示,用户可提前看到测速节点 */
const serverResults = ref<ServerResult[]>([])
// 提前构建服务器列表(学 tcping.vue 策略)
// 在组件初始化时就创建好 useFetch 实例,
// 设置 immediate: false + watch: false,
// 只在用户点击"开始测试"时才调用 execute() 发起请求,
// 避免页面加载时自动发请求浪费资源。
// =============================================
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + encodeURIComponent(domain.value));
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
});
return { label: server.label, data, error: fetchError, execute };
});
/** IPv6 专用服务器:仅支持 IPv6 的节点 */
const ipv6Fetches = config.APIBaseURL.IPv6.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + encodeURIComponent(domain.value));
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
});
return { label: server.label, data, error: fetchError, execute };
});
/** 合并所有服务器 fetch 列表 */
const allFetches = [
...dualStackFetches.map(fetch => ({ ...fetch, type: 'DualStack' })),
...ipv6Fetches.map(fetch => ({ ...fetch, type: 'IPv6' }))
];
/**
* 初始化服务器结果列表
* 页面挂载时调用,让用户提前看到所有测速节点
*/
function initServerResults() {
serverResults.value = allFetches.map((fetch) => ({
label: fetch.label,
loading: false,
error: '',
data: null
}))
}
/**
* 执行全站测速
* 并发调用所有服务器的 execute() 方法,逐个更新对应行的结果
*/
async function SpeedTest() {
domain.value = extractHost(tmpDomain.value)
loading.value = true
error.value = ''
await nextTick()
const id = ++queryId
// 重置所有行状态为加载中
serverResults.value.forEach((row) => {
row.loading = true
row.error = ''
row.data = null
})
// 并发请求所有服务器,每个请求完成后立即更新对应行
const promises = allFetches.map(async (fetch, index) => {
const row = serverResults.value[index]
if (!row) return
try {
await fetch.execute();
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 {
if (id === queryId) {
row.loading = false;
}
}
});
Promise.all(promises).finally(() => {
if (id === queryId) {
loading.value = false
}
})
}
// 页面挂载后,初始化服务器列表并检查 URL 参数
onMounted(() => {
initServerResults()
const urlParam = route.query.site as string
if (urlParam) {
tmpDomain.value = urlParam
SpeedTest()
}
})
</script>
<template>
<div class="title">
<header>
<h1>IPv6 网站测速</h1>
<p>全国并发测速,1s 内快速返回测速结果</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:https://zakoflare.com)"
/>
<el-button
@click="SpeedTest()"
type="primary"
:loading="loading"
>
开始测试
</el-button>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div class="result-section">
<table class="result-table">
<thead>
<tr>
<th class="table-header">测速服务器</th>
<th class="table-header">解析 IP</th>
<th class="table-header">HTTP状态码</th>
<th class="table-header">HTTPS状态码</th>
<th class="table-header">总时间</th>
<th class="table-header">解析时间</th>
<th class="table-header">HTTP连接</th>
<th class="table-header">网页大小</th>
<th class="table-header">下载速度</th>
</tr>
</thead>
<tbody>
<template v-for="(row, index) in serverResults" :key="index">
<!-- 请求失败 -->
<tr v-if="row.error">
<td class="table-label">{{ row.label }}</td>
<td class="table-value" colspan="8">
<span class="status-code status-error">{{ row.error }}</span>
</td>
</tr>
<!-- 加载中 -->
<tr v-else-if="row.loading">
<td class="table-label">{{ row.label }}</td>
<td class="table-value" colspan="8">测速中...</td>
</tr>
<!-- 有结果 -->
<tr v-else-if="row.data">
<td class="table-label">{{ row.label }}</td>
<td class="table-value">{{ row.data?.host_record }}</td>
<td class="table-value">
<span class="status-code" :class="getStatusCodeClass(row.data?.http_status_code)">
{{ row.data?.http_status_code }}
</span>
</td>
<td class="table-value">
<span class="status-code" :class="getStatusCodeClass(row.data?.https_status_code)">
{{ row.data?.https_status_code }}
</span>
</td>
<td class="table-value">{{ formatTime(row.data?.total_time) }}</td>
<td class="table-value">{{ formatTime(row.data?.dns_lookup_time) }}</td>
<td class="table-value">{{ formatTime(row.data?.first_byte_time) }}</td>
<td class="table-value">{{ formatSize(row.data?.page_size) }}</td>
<td class="table-value">{{ formatSpeed(row.data?.download_speed) }}</td>
</tr>
<!-- 等待测速 -->
<tr v-else>
<td class="table-label">{{ row.label }}</td>
<td class="table-value" colspan="8">-</td>
</tr>
</template>
</tbody>
</table>
</div>
</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-header {
font-size: 1.05em;
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
html.dark {
--el-color-primary: #3EAF7C;
}
.el-icon{
font-size: 1.3em;
}
</style>
-300
View File
@@ -1,300 +0,0 @@
<script setup lang="ts">
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';
const route = useRoute()
useHead({
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网络诊断' },
{ property: 'og:title', content: 'IPv6 TCPing测试 - IPv6服务器连通性与延迟检测' },
{ property: 'og:description', content: '多节点IPv6 TCPing测试,检测服务器连通性、丢包率与响应延迟' },
{ 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: 'IPv6 TCPing连通性测试工具',
description: '专业的IPv6 TCPing测试工具,多节点检测IPv6服务器连通性和延迟,支持自定义端口测试,提供丢包率、平均延迟、最大最小响应时间等数据。',
url: config.siteUrl + 'ipv6tcping',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY'
},
provider: {
'@type': 'Organization',
name: config.siteName
}
})
}
]
});
interface TCPingStats {
ip: string
port: string
sent: number
success: number
loss_rate: number
max_rtt: number
min_rtt: number
avg_rtt: number
}
interface TCPingResponse {
ipv4?: TCPingStats
ipv6?: TCPingStats
}
interface ServerConfig {
id: string
label: string
url: string
}
interface ServerResult {
label: string
loading: boolean
error: string
ipv4?: TCPingStats
ipv6?: TCPingStats
}
const tmpDomain = ref('www.zakoflare.com')
const port = ref('80')
const userIP = ref('')
const loading = ref(false)
const serverResults = ref<ServerResult[]>([])
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
const allServers = [
...config.APIBaseURL.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
...config.APIBaseURL.IPv6.map((s: ServerConfig) => ({ ...s, type: 'IPv6' }))
];
const tcpingFetches = allServers.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/tcping/" + encodeURIComponent(extractHost(tmpDomain.value)) + "?port=" + encodeURIComponent(port.value));
const { data, error: fetchError, execute } = useMiddlewareFetch<TCPingResponse>(url, {
immediate: false,
watch: false,
});
return { label: server.label, data, error: fetchError, execute };
});
function initServerResults() {
const results: ServerResult[] = []
config.APIBaseURL.DualStack.forEach((server: ServerConfig) => {
results.push({
label: server.label,
loading: false,
error: ''
})
})
config.APIBaseURL.IPv6.forEach((server: ServerConfig) => {
results.push({
label: server.label,
loading: false,
error: ''
})
})
serverResults.value = results
}
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) {
// 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 = '请求失败';
}
} finally {
const result = serverResults.value[index];
if (result && id === queryId) {
result.loading = false;
}
}
})
Promise.all(promises).finally(function () {
if (id === queryId) {
loading.value = false
}
})
}
async function getUserIP(){
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
} catch {
// 获取失败保持为空,避免未处理的 Promise 拒绝
}
return userIP.value
}
onMounted(() => {
getUserIP()
initServerResults()
const urlParam = route.query.site as string
if (urlParam) {
tmpDomain.value = urlParam
TCPingAll()
}
})
</script>
<template>
<div class="title">
<header>
<h1>IPv6 TCPing 测试</h1>
<p>多节点 TCPing 测试,检测服务器连通性和延迟</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:example.com)"
/>
<el-input
v-model="port"
placeholder="端口号(默认 80)"
style="width: 200px;"
/>
<el-button
@click="TCPingAll()"
type="primary"
:loading="loading"
>
开始测试
</el-button>
</div>
<div class="result-section">
<table class="result-table">
<thead>
<tr>
<th class="table-header">服务器</th>
<th class="table-header">解析 IP</th>
<th class="table-header">发送包</th>
<th class="table-header">接收包</th>
<th class="table-header">丢包率(%)</th>
<th class="table-header">最长时间(ms)</th>
<th class="table-header">最短时间(ms)</th>
<th class="table-header">平均时间(ms)</th>
</tr>
</thead>
<tbody>
<template v-for="(server, index) in serverResults" :key="index">
<tr v-if="server.ipv6">
<td class="table-label">{{ server.label }}</td>
<td class="table-value">{{ server.ipv6.ip || '-' }}</td>
<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) > 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>
</tr>
<tr v-else-if="server.loading">
<td class="table-label">{{ server.label }}</td>
<td class="table-value" colspan="7">加载中...</td>
</tr>
<tr v-else-if="server.error">
<td class="table-label">{{ server.label }}</td>
<td class="table-value error-text" colspan="7">{{ server.error }}</td>
</tr>
<tr v-else>
<td class="table-label">{{ server.label }}</td>
<td class="table-value" colspan="7">-</td>
</tr>
</template>
</tbody>
</table>
</div>
<blockquote>
<a href="/doc/user/ipv6_ping" target="_blank">IPv6 Ping 原理介绍</a><br/>
<strong>注意本页是TCPing,不是ICMPv6 Ping,下列文本仅供参考</strong><br/>
<strong>1. 本地 IPv6 方式</strong><br/>
Windows: ping -6 ipw.wsmdn.top<br/>
macOS 或 Linux: ping6 ipw.wsmdn.top<br/>
<strong>2. 服务器 IPv6 Ping 失败可能原因:</strong><br/>
服务器已开启 IPv6,但防火墙(又名安全组)未对源地址是 IPv6 地址(::/0)的 ICMPv6协议 开放访问,<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'}}访问优先
</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;
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
html.dark {
--el-color-primary: #3EAF7C;
}
.el-icon {
font-size: 1.3em;
}
</style>
-316
View File
@@ -1,316 +0,0 @@
<script setup lang="ts">
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, formatSize, formatSpeed } from '../../utils/tools';
const route = useRoute()
const siteUrlWithSlash = computed(() => config.siteUrl.replace(/\/$/, '') + '/')
useHead({
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: siteUrlWithSlash.value + 'favicon.svg' },
{ property: 'og:type', content: 'website' },
],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'IPv6网站访问支持检测',
description: '专业的IPv6网站检测工具,检查网站是否支持IPv6访问,提供IPv4和IPv6双栈HTTP/HTTPS状态码、DNS解析时间、TCP连接时间等详细对比数据,提供IPv6徽标认证。',
url: siteUrlWithSlash.value + 'ipv6webcheck',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY'
},
provider: {
'@type': 'Organization',
name: config.siteName
}
})
}
]
});
interface PerformanceCheckResponse {
ipv4?: PerformanceCheckItem
ipv6?: PerformanceCheckItem
}
interface PerformanceCheckItem {
host_record: string
http_status_code: number
https_status_code: number
dns_lookup_time: number
tcp_connect_time: number
http_connect_time: number
first_byte_time: number
total_time: number
page_size: number
download_speed: number
is_reachable: boolean
}
const apiList = config.APIBaseURL.DualStack
const currentApiIndex = ref(0)
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(() => "/middleware/" + backendID.value + "/detail/" + testDomain.value);
// testDomain 是完整 URL(含 https:// 与路径斜杠),服务端 [...slug].get.ts 依赖
// 分段重组协议部分,这里刻意不做 encodeURIComponent
const { data: checkData, error: checkError, execute: executeCheck } = useMiddlewareFetch<PerformanceCheckResponse>(checkUrl, {
immediate: false,
watch: false,
});
watch(checkData, (newData) => {
if (newData) {
result.value = newData;
loading.value = false;
error.value = '';
}
});
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 || ''}...`
currentApiIndex.value = nextIndex
console.log(currentApiIndex.value,nextIndex,apiList)
await nextTick()
executeCheck()
} else {
error.value = '请求失败,请检查域名或网络';
loading.value = false;
}
}
});
function checkWeb() {
testDomain.value = tmpDomain.value
currentApiIndex.value = 0
loading.value = true
error.value = ''
result.value = null
nextTick(() => {
executeCheck();
});
}
onMounted(() => {
const urlParam = route.query.site as string
if (urlParam) {
tmpDomain.value = urlParam
checkWeb()
}
})
</script>
<template>
<div class="title">
<header>
<h1>IPv6网站检测</h1>
<p>检查网站是否开启 IPv6 访问,致力于普及IPv6</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:https://zakoflare.com)"
/>
<el-button
@click="checkWeb()"
type="primary"
:loading="loading"
>
开始测试
</el-button>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div v-if="result && result.ipv4" class="result-section">
<table class="result-table">
<thead>
<tr>
<th class="table-header">检测项目</th>
<th class="table-header">IPv4</th>
<th class="table-header">IPv6</th>
</tr>
</thead>
<tbody>
<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 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>
<td class="table-value">
<span :class="getStatusCodeClass(result.ipv4!.http_status_code)" class="status-code">
{{ result.ipv4!.http_status_code }}
</span>
</td>
<td class="table-value">
<span v-if="result.ipv6" :class="getStatusCodeClass(result.ipv6.http_status_code)" class="status-code">
{{ result.ipv6.http_status_code }}
</span>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">HTTPS 状态码</td>
<td class="table-value">
<span :class="getStatusCodeClass(result.ipv4!.https_status_code)" class="status-code">
{{ result.ipv4!.https_status_code }}
</span>
</td>
<td class="table-value">
<span v-if="result.ipv6" :class="getStatusCodeClass(result.ipv6.https_status_code)" class="status-code">
{{ result.ipv6.https_status_code }}
</span>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">DNS 查询耗时</td>
<td class="table-value">{{ formatTime(result.ipv4!.dns_lookup_time) }}</td>
<td class="table-value">{{ result.ipv6?.dns_lookup_time ? formatTime(result.ipv6.dns_lookup_time) : '-' }}</td>
</tr>
<tr>
<td class="table-label">TCP 连接耗时</td>
<td class="table-value">{{ formatTime(result.ipv4!.tcp_connect_time) }}</td>
<td class="table-value">{{ result.ipv6?.tcp_connect_time ? formatTime(result.ipv6.tcp_connect_time) : '-' }}</td>
</tr>
<tr>
<td class="table-label">HTTP 连接耗时</td>
<td class="table-value">{{ formatTime(result.ipv4!.http_connect_time) }}</td>
<td class="table-value">{{ result.ipv6?.http_connect_time ? formatTime(result.ipv6.http_connect_time) : '-' }}</td>
</tr>
<tr>
<td class="table-label">首字节耗时</td>
<td class="table-value">{{ formatTime(result.ipv4!.first_byte_time) }}</td>
<td class="table-value">{{ result.ipv6?.first_byte_time ? formatTime(result.ipv6.first_byte_time) : '-' }}</td>
</tr>
<tr>
<td class="table-label">总耗时</td>
<td class="table-value">{{ formatTime(result.ipv4!.total_time) }}</td>
<td class="table-value">{{ result.ipv6?.total_time ? formatTime(result.ipv6.total_time) : '-' }}</td>
</tr>
<tr>
<td class="table-label">页面大小</td>
<td class="table-value">{{ formatSize(result.ipv4!.page_size) }}</td>
<td class="table-value">{{ result.ipv6?.page_size ? formatSize(result.ipv6.page_size) : '-' }}</td>
</tr>
<tr>
<td class="table-label">下载速度</td>
<td class="table-value">{{ formatSpeed(result.ipv4!.download_speed) }}</td>
<td class="table-value">{{ result.ipv6?.download_speed ? formatSpeed(result.ipv6.download_speed) : '-' }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="result && result.ipv4 && result.ipv6&& result.ipv4.is_reachable && result.ipv6.is_reachable">
<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>&lt;a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-s1.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ipv6-s2.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-s2.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ipv6-s3.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-s3.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ipv6-s4.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-s4.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ipv6-s5.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-s5.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ipv6-s6.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-s6.svg"&gt;&lt;/a&gt;</code></pre>
<div class="one-line">
<img src="/ipv6-certified-lite-s1.svg"/>
<img src="/ipv6-certified-lite-s2.svg"/>
<img src="/ipv6-certified-lite-s3.svg"/>
<img src="/ipv6-certified-lite-s4.svg"/>
<img src="/ipv6-certified-lite-s5.svg"/>
<img src="/ipv6-certified-lite-s6.svg"/>
</div>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-certified-lite-s1.svg"&gt;&lt;/a&gt;</code></pre>
<div class="one-line">
<img src="/ipv6-certified-s1.svg"/>
<img src="/ipv6-certified-s2.svg"/>
<img src="/ipv6-certified-s3.svg"/>
<img src="/ipv6-certified-s4.svg"/>
<img src="/ipv6-certified-s5.svg"/>
<img src="/ipv6-certified-s6.svg"/>
</div>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-certified-s1.svg"&gt;&lt;/a&gt;</code></pre>
<p>提示:修改IPv6徽标文件名,可修改对应样式</p>
</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) }} 不支持IPv6访问 </h3>
<h2>国家正在支持IPv6发展,我建议你赶紧想办法给IPv6适配</h2>
<el-image src="/jingya.jpg"></el-image>
</div>
<div v-else-if="result && (!result.ipv6?.is_reachable && !result.ipv4?.is_reachable)">
<h3>结论:<CircleCloseFilled style="width: 1.3em;color: red;"/>网站{{ extractHost(testDomain) }} 不可达 </h3>
<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,请参考 <a href="/doc/server/website_enable_ipv6" target="_blank">网站开启 IPv6 的三种方式</a><br/>
2. 网站所在服务器已开启 IPv6,但防火墙未对源地址是 IPv6 地址(::/0)的 443(HTTPS)端口开放访问<br/>
3. 网站所在服务器已开启 IPv6,但未开启SSL证书,请参考 <a href="/doc/server/nginx_ipv6" target="_blank">Nginx 开启 IPv6 SSL</a><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;
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
html.dark {
--el-color-primary: #3EAF7C;
}
.el-icon{
font-size: 1.3em;
}
</style>
-452
View File
@@ -1,452 +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 { useMiddlewareFetch } from '../../utils/middleware';
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
import { useRoute } from 'vue-router'
import { isIPv4 } from '../../utils/tools';
const route = useRoute();
const loading = ref(false);
useHead({
title: `IPv6/IPv4地址查询工具 | IP归属地定位 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的IPv6/IPv4地址查询工具,支持IPv4和IPv6地址归属地查询,提供BiliBili Live、GeoCN、IP2Region、Maxmind等多种数据源对比,精确定位IP地理位置、运营商信息,支持中国大陆及境外地址查询,助力IPv6普及与应用' },
{ name: 'keywords', content: 'ipv6地址查询,ipv4地址查询,ip归属地,ip地理位置,ip定位,运营商查询,ipv6归属地,ipv4归属地,maxmind,ip2region,geocn' },
{ 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' },
],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'IPv6/IPv4地址归属地查询工具',
description: '专业的IPv6/IPv4地址归属地查询工具,支持BiliBili Live、GeoCN、IP2Region、Maxmind、纯真社区库等多数据源对比,精确定位IP地理位置、运营商信息。',
url: config.siteUrl + 'ipv6',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY'
},
provider: {
'@type': 'Organization',
name: config.siteName
}
})
}
]
});
interface IPLocationType {
bilibili?: any;
ip?: string;
geocn?: any;
ip2region?: any;
ip2location?: any;
ip2location_asn?: 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.IPLocationAPI
const currentApiIndex = ref(0)
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
const ipAddress = ref('');
const IPLocation = ref<IPLocationType>({});
const UserIP = ref('');
const error = ref('')
const locationUrl = computed(() => "/middleware/" + backendID.value + "/location/" + encodeURIComponent(ipAddress.value));
const { data: locationData, error: locationError, execute: executeLocation } = useMiddlewareFetch<IPLocationType>(locationUrl, {
immediate: false,
watch: false,
});
watch(locationData, (newData) => {
if (newData) {
IPLocation.value = newData;
loading.value = false;
error.value = '';
}
});
watch(locationError, async (newError) => {
if (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 || ''}...`
currentApiIndex.value = nextIndex
await nextTick()
executeLocation()
} else {
error.value = '请求失败,请检查网络或稍后重试'
loading.value = false;
}
}
});
function locateIP(IP: string){
currentApiIndex.value = 0
ipAddress.value = IP;
error.value = ''
loading.value = true;
nextTick(() => {
executeLocation();
});
}
onMounted(async () => {
// 代码高亮与下面的「取本机 IP / 定位查询」互不依赖,别把它摆在流程最前面 await ——
// 否则后面那次 $fetch 要等 160KB 的 shiki 下载完才会发出。
// 动态 import 让 shiki(引擎 + 语言/主题注册表)单独成 chunk,首屏渲染不再等它。
void import('../../utils/shiki')
.then(({ highlightCode }) => highlightCode(code, 'bash'))
.then((h) => {
html.value = h
})
.catch(() => {
// 连 import 本身都失败时退回原始代码,别让代码块空着
html.value = code
})
const urlParam = route.query.ip;
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 v-if="error" class="error-message">
{{ error }}
</div>
<div class="location">
<div class="ip-info" style="height: 40px;">
<b>IP</b>&nbsp;<span>{{ ipAddress }}</span>
</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 }}&nbsp;{{ IPLocation.bilibili?.administrative_area }}&nbsp;{{ IPLocation.bilibili?.city }}
</span>
</td>
<td class="table-value">
<span>
{{ IPLocation.bilibili?.isp }}
</span>
</td>
</tr>
<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?.country }}&nbsp;{{ IPLocation.ip2region?.administrative_area }}&nbsp;{{ 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 }}&nbsp;{{ IPLocation.ip2location?.administrative_area }}&nbsp;{{ 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 }}&nbsp;{{ IPLocation.geocn?.city }}&nbsp;{{ IPLocation.geocn?.district }}</td>
<td class="table-value">{{ IPLocation.geocn?.isp }}&nbsp;{{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>
<td class="table-value">{{ IPLocation.maxmind_city?.country }}&nbsp;{{ IPLocation.maxmind_city?.administrative_area }}&nbsp;{{ 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 }}&nbsp;{{ IPLocation.qqwry?.administrative_area }}&nbsp;{{ 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 }}&nbsp;{{ IPLocation.dbip_city?.administrative_area }}&nbsp;{{ IPLocation.dbip_city?.city }}</td>
<td class="table-value">{{IPLocation.dbip_asn?.org}}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div style="font-size: 1.5em;">
<h3 v-if="isIPv6(UserIP)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h3>
<h3 v-else-if="isIPv4(UserIP)"><el-icon><CircleCloseFilled style="color: red;"/></el-icon>您的网络IPv4优先</h3>
</div>
<blockquote>
精度参照表:<br>
中国大陆:BiliBili Live > GeoCN > IP2Region > 纯真社区库 > Maxmind GEOLite2 City ≈ DB-IP<br>
中国大陆 IPv6 地址: BiliBili Live > GeoCN > IP2Region > Maxmind GEOLite2 City ≈ DB-IP > 纯真社区库<br>
境外及港澳台地址: Maxmind GEOLite2 City ≈ DB-IP > BiliBili Live > > IP2Region > 纯真社区库<br>
手机默认开启 IPv6,宽带开启 IPv6 请自行搜索<br>
访客IP: {{UserIP}},<span v-if="isIPv4(UserIP)">您的网络IPv4优先</span><span v-else-if="isIPv6(UserIP)">您的网络IPv6优先</span>
</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>
-293
View File
@@ -1,293 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, 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({
title: '网站截图工具 - '+(route.query.site || 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' },
]
})
const tmpDomain = ref('https://www.zakoflare.com')
const loading = ref(false)
const error = ref('')
const screenshotUrl = ref('')
const imgLoaded = ref(false)
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
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 = ''
const requestId = ++currentRequestId
// Set the screenshot URL on next tick so Vue can update the displayed <img>
nextTick(() => {
// Check if this request was superseded
if (requestId !== currentRequestId) return
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('截图加载失败,请检查网址是否正确')
}
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)"
@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: #3EAF7C !important;
font-size: 1.3em;
text-decoration: none
}
</style>
-283
View File
@@ -1,283 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { config } from '../../config/index';
import { useMiddlewareFetch } from '../../utils/middleware';
import { extractHost, formatTime, formatSpeed, formatSize, getStatusCodeClass } from '../../utils/tools';
const route = useRoute()
useHead({
title: `IPv4网站测速工具 | 全国多节点并发测速 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的IPv4网站测速工具,提供全国多节点并发测速服务,快速返回网站响应时间、下载速度、页面大小、DNS解析时间、HTTP连接时间等详细性能指标,支持IPv4网站性能检测与优化,助力网站性能监控与用户体验改善' },
{ name: 'keywords', content: 'ipv4网站测速,ipv4测速,网站速度测试,ipv4性能检测,网站响应时间,ipv4下载速度,ipv4性能优化,网站性能监控' },
{ property: 'og:title', content: 'IPv4网站测速 - 全国多节点并发性能检测工具' },
{ property: 'og:description', content: '全国多节点IPv4网站测速,快速获取响应时间、下载速度等性能数据' },
{ 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: 'IPv4网站性能测速工具',
description: '专业的IPv4网站测速工具,全国多节点并发测速,提供响应时间、下载速度、页面大小、DNS解析时间等详细性能指标。',
url: config.siteUrl + 'speedtest',
applicationCategory: 'DeveloperApplication',
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 result = ref<any[]>([])
const error = ref('')
const domain = ref('')
/** 服务器结果接口 */
interface ServerResult {
label: string
loading: boolean
error?: string | Error
data: any
}
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
/** 预构建的服务器列表,页面挂载即展示,用户可提前看到测速节点 */
const serverResults = ref<ServerResult[]>([])
// 提前构建服务器列表(学 tcping.vue 策略)
// 在组件初始化时就创建好 useFetch 实例,
// 设置 immediate: false + watch: false,
// 只在用户点击"开始测试"时才调用 execute() 发起请求,
// 避免页面加载时自动发请求浪费资源。
// =============================================
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + encodeURIComponent(domain.value));
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
});
return { label: server.label, data, error: fetchError, execute };
});
/** IPv4 专用服务器:仅支持 IPv4 的节点 */
const ipv4Fetches = config.APIBaseURL.IPv4.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + encodeURIComponent(domain.value));
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
});
return { label: server.label, data, error: fetchError, execute };
});
/** 合并所有服务器 fetch 列表 */
const allFetches = [
...dualStackFetches.map(fetch => ({ ...fetch, type: 'DualStack' })),
...ipv4Fetches.map(fetch => ({ ...fetch, type: 'IPv4' }))
];
/**
* 初始化服务器结果列表
* 页面挂载时调用,让用户提前看到所有测速节点
*/
function initServerResults() {
serverResults.value = allFetches.map((fetch) => ({
label: fetch.label,
loading: false,
error: '',
data: null
}))
}
/**
* 执行全站测速
* 并发调用所有服务器的 execute() 方法,逐个更新对应行的结果
*/
async function SpeedTest() {
domain.value = extractHost(tmpDomain.value)
loading.value = true
error.value = ''
await nextTick()
const id = ++queryId
// 重置所有行状态为加载中
serverResults.value.forEach((row) => {
row.loading = true
row.error = ''
row.data = null
})
// 并发请求所有服务器,每个请求完成后立即更新对应行
const promises = allFetches.map(async (fetch, index) => {
const row = serverResults.value[index]
if (!row) return
try {
await fetch.execute()
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 {
if (id === queryId) {
row.loading = false
}
}
})
Promise.all(promises).finally(() => {
if (id === queryId) {
loading.value = false
}
})
}
// 页面挂载后,初始化服务器列表并检查 URL 参数
onMounted(() => {
initServerResults()
const urlParam = route.query.site as string
if (urlParam) {
tmpDomain.value = urlParam
SpeedTest()
}
})
</script>
<template>
<div class="title">
<header>
<h1>IPv4 网站测速</h1>
<p>全国并发测速,1s 内快速返回测速结果</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:https://zakoflare.com)"
/>
<el-button
@click="SpeedTest()"
type="primary"
:loading="loading"
>
开始测试
</el-button>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div class="result-section">
<table class="result-table">
<thead>
<tr>
<th class="table-header">测速服务器</th>
<th class="table-header">解析 IP</th>
<th class="table-header">HTTP状态码</th>
<th class="table-header">HTTPS状态码</th>
<th class="table-header">总时间</th>
<th class="table-header">解析时间</th>
<th class="table-header">HTTP连接</th>
<th class="table-header">网页大小</th>
<th class="table-header">下载速度</th>
</tr>
</thead>
<tbody>
<template v-for="(row, index) in serverResults" :key="index">
<!-- 请求失败 -->
<tr v-if="row.error">
<td class="table-label">{{ row.label }}</td>
<td class="table-value" colspan="8">
<span class="status-code status-error">{{ row.error }}</span>
</td>
</tr>
<!-- 加载中 -->
<tr v-else-if="row.loading">
<td class="table-label">{{ row.label }}</td>
<td class="table-value" colspan="8">测速中...</td>
</tr>
<!-- 有结果 -->
<tr v-else-if="row.data">
<td class="table-label">{{ row.label }}</td>
<td class="table-value">{{ row.data?.host_record }}</td>
<td class="table-value">
<span class="status-code" :class="getStatusCodeClass(row.data?.http_status_code)">
{{ row.data?.http_status_code }}
</span>
</td>
<td class="table-value">
<span class="status-code" :class="getStatusCodeClass(row.data?.https_status_code)">
{{ row.data?.https_status_code }}
</span>
</td>
<td class="table-value">{{ formatTime(row.data?.total_time) }}</td>
<td class="table-value">{{ formatTime(row.data?.dns_lookup_time) }}</td>
<td class="table-value">{{ formatTime(row.data?.first_byte_time) }}</td>
<td class="table-value">{{ formatSize(row.data?.page_size) }}</td>
<td class="table-value">{{ formatSpeed(row.data?.download_speed) }}</td>
</tr>
<!-- 等待测速 -->
<tr v-else>
<td class="table-label">{{ row.label }}</td>
<td class="table-value" colspan="8">-</td>
</tr>
</template>
</tbody>
</table>
</div>
</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-header {
font-size: 1.05em;
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
html.dark {
--el-color-primary: #3EAF7C;
}
.el-icon{
font-size: 1.3em;
}
</style>
-397
View File
@@ -1,397 +0,0 @@
<script setup lang="ts">
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 { useMiddlewareFetch } from '../../utils/middleware';
import { extractHost, getStatusCodeClass, formatTime, formatSpeed } from '../../utils/tools';
const route = useRoute()
const siteUrlWithSlash = computed(() => config.siteUrl.replace(/\/$/, '') + '/')
useHead({
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: siteUrlWithSlash.value + 'favicon.svg' },
{ property: 'og:type', content: 'website' },
],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'SSL证书检测工具',
description: '专业的SSL证书检测工具,支持IPv4和IPv6 SSL证书状态、有效期、签发机构、HTTP版本等检测,提供HTTPS状态码检测、下载速度测试。',
url: siteUrlWithSlash.value + 'ssl',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY'
},
provider: {
'@type': 'Organization',
name: config.siteName
}
})
}
]
});
interface SSLCheckResponse {
ipv4: SSLCheckItem
ipv6: SSLCheckItem
}
interface SSLCheckItem {
cert_validity_days: number
cert_start_time: string
cert_end_time: string
http_version: string
host_record: string
https_status_code: number
total_time: number
download_speed: number
domain: string
issuer_organization: string[] | null
issuer_common_name: string
subject_common_name: string
is_expired: boolean
is_reachable: boolean
}
const apiList = config.APIBaseURL.DualStack
const currentApiIndex = ref(0)
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(() => "/middleware/" + backendID.value + "/ssl/" + encodeURIComponent(testDomain.value));
const { data: sslData, error: sslError, execute: executeSSL } = useMiddlewareFetch<SSLCheckResponse>(sslUrl, {
immediate: false,
watch: false,
});
watch(sslData, (newData) => {
if (newData) {
result.value = newData;
loading.value = false;
error.value = '';
}
});
watch(sslError, 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()
executeSSL()
} else {
error.value = '请求失败,请检查域名或网络';
loading.value = false;
}
}
});
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
function checkSSL() {
testDomain.value = extractHost(tmpDomain.value)
currentApiIndex.value = 0
loading.value = true
error.value = ''
result.value = null
nextTick(() => {
executeSSL();
});
}
onMounted(() => {
const urlParam = route.query.site as string
if (urlParam) {
tmpDomain.value = urlParam
checkSSL()
}
})
</script>
<template>
<div class="title">
<header>
<h1>SSL证书检查</h1>
<p>检查网站是否开启 IPv4 和 IPv6 SSL 证书</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:https://zakoflare.com)"
/>
<el-button
@click="checkSSL()"
type="primary"
:loading="loading"
>
SSL 证书检查
</el-button>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div v-if="result && result.ipv4" class="result-section">
<table class="result-table">
<thead>
<tr>
<th class="table-header">检测项目</th>
<th class="table-header">IPv4</th>
<th class="table-header">IPv6</th>
</tr>
</thead>
<tbody>
<tr>
<td class="table-label">证书状态</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable">
<span :class="result.ipv4!.is_expired ? 'expired' : 'valid'">
{{ result.ipv4!.is_expired ? '已过期' : '有效' }}
</span>
</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable">
<span :class="result.ipv6.is_expired ? 'expired' : 'valid'">
{{ result.ipv6.is_expired ? '已过期' : '有效' }}
</span>
</template>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">常用名称</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable">{{ result.ipv4!.subject_common_name || '-'}}</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable">{{ result.ipv6.subject_common_name || '-' }}</template>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">签发者</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable">{{ result.ipv4!.issuer_organization?.join(', ')|| '-' }}</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable">{{ result.ipv6.issuer_organization?.join(', ') || '-' }}</template>
<span v-else>-</span>
</td>
</tr>
<tr v-if="result.ipv6 && result.ipv6.is_reachable && result.ipv4!.is_reachable && result.ipv6.cert_validity_days > 0 && result.ipv4.cert_validity_days > 0">
<td class="table-label">证书有效期 (天)</td>
<td class="table-value">{{ result.ipv4!.cert_validity_days || '-'}} 天</td>
<td class="table-value">{{ result.ipv6?.cert_validity_days || '-' }} 天</td>
</tr>
<tr v-else-if="result.ipv6 && result.ipv6.is_reachable && result.ipv4!.is_reachable && result.ipv6.cert_validity_days <= 0 && result.ipv4.cert_validity_days <= 0">
<td class="table-label">证书已过期(天)</td>
<td class="table-value">{{ Math.abs(result.ipv4.cert_validity_days) || '-'}}</td>
<td class="table-value">{{ Math.abs(result.ipv6.cert_validity_days) || '-' }}</td>
</tr>
<tr v-else-if="result.ipv4!.is_reachable && result.ipv4.cert_validity_days > 0 && (!result.ipv6 || !result.ipv6.is_reachable)">
<td class="table-label">证书有效期 (天)</td>
<td class="table-value">{{ result.ipv4!.cert_validity_days }} 天</td>
<td class="table-value">-</td>
</tr>
<tr v-else-if="result.ipv4!.is_reachable && result.ipv4.cert_validity_days <= 0 && (!result.ipv6 || !result.ipv6.is_reachable)">
<td class="table-label">证书已过期(天)</td>
<td class="table-value">{{ Math.abs(result.ipv4.cert_validity_days) || '-'}}</td>
<td class="table-value">-</td>
</tr>
<tr>
<td class="table-label">证书开始时间</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable && result.ipv4!.cert_start_time">{{ formatDate(result.ipv4!.cert_start_time) }}</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable && result.ipv6.cert_start_time">{{ formatDate(result.ipv6.cert_start_time) }}</template>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">证书结束时间</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable && result.ipv4!.cert_end_time">{{ formatDate(result.ipv4!.cert_end_time) }}</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable && result.ipv6.cert_end_time">{{ formatDate(result.ipv6.cert_end_time) }}</template>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">HTTP 版本</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable">{{ result.ipv4!.http_version || '-'}}</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable">{{ result.ipv6.http_version || '-' }}</template>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">主机记录</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable">{{ result.ipv4!.host_record || '-'}}</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable">{{ result.ipv6.host_record || '-' }}</template>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">HTTPS 访问返回码</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable">
<span :class="getStatusCodeClass(result.ipv4!.https_status_code)" class="status-code">
{{ result.ipv4!.https_status_code }}
</span>
</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable">
<span :class="getStatusCodeClass(result.ipv6.https_status_code)" class="status-code">
{{ result.ipv6.https_status_code }}
</span>
</template>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">总耗时</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable">{{ formatTime(result.ipv4.total_time) }}</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable">{{ formatTime(result.ipv6.total_time) }}</template>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">下载速度</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable">{{ formatSpeed(result.ipv4!.download_speed) }}</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable">{{ formatSpeed(result.ipv6.download_speed) }}</template>
<span v-else>-</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="result && result.ipv4 && result.ipv4.is_reachable && !result.ipv4.is_expired && (!result.ipv6 || (!result.ipv6.is_expired && result.ipv6.is_reachable))">
<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>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s1.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ssl-s2.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s2.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ssl-s3.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s3.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ssl-s4.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s4.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ssl-s5.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s5.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ssl-s6.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s6.svg"&gt;&lt;/a&gt;</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>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s1.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ssl-s2.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s2.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ssl-s3.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s3.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ssl-s4.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s4.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ssl-s5.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s5.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ssl-s6.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s6.svg"&gt;&lt;/a&gt;</code></pre>
</div>
<div v-else-if="result && result.ipv4 && result.ipv4.is_reachable && result.ipv4.is_expired">
<h3>结论:<CircleCloseFilled style="width: 1.3em;color: red;"/>网站{{ testDomain }} 证书无效 </h3>
<h2>都没有证书了这网站还活啥</h2>
<el-image src="/jingya.jpg"></el-image>
</div>
<div v-else-if="result && result.ipv4 && !result.ipv4.is_reachable && result.ipv6 && !result.ipv6.is_reachable">
<h3>结论:<CircleCloseFilled style="width: 1.3em;color: red;"/>网站{{ testDomain }} 不可达 </h3>
<h2>...</h2>
<el-image src="/jingya.jpg"></el-image>
</div>
<blockquote>
网站不支持 IPv6 SSL 可能原因:<br/>
<br/>
1. 网站所在服务器未开启 IPv6,请参考 <a href="/doc/server/website_enable_ipv6" target="_blank">网站开启 IPv6 的三种方式</a><br/>
2. 网站所在服务器已开启 IPv6,但防火墙未对源地址是 IPv6 地址(::/0)的 443(HTTPS)<a href="/doc/server/website_enable_ipv6" target="_blank">端口开放访问</a><br/>
3. 网站所在服务器已开启 IPv6,但未开启SSL证书,请参考 <a href="/doc/server/nginx_ipv6" target="_blank">Nginx 开启 IPv6 SSL</a><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;
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
.el-icon{
font-size: 1.3em;
}
</style>
-284
View File
@@ -1,284 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { config } from '../../config/index';
import { useMiddlewareFetch } from '../../utils/middleware';
import { extractHost } from '../../utils/tools';
const route = useRoute()
useHead({
title: `TCPing测试工具 | IPv4服务器连通性检测 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的IPv4 TCPing测试工具,提供多节点TCP连通性检测服务,支持自定义端口测试,实时检测服务器丢包率、平均延迟、最大最小响应时间,帮助运维人员快速诊断服务器网络质量,确保服务稳定运行' },
{ name: 'keywords', content: 'tcping测试,tcp连通性检测,ipv4 tcping,服务器延迟测试,丢包率检测,端口连通性,服务器网络测试,网络质量检测' },
{ property: 'og:title', content: 'TCPing测试工具 - IPv4服务器连通性与延迟检测' },
{ property: 'og:description', content: '多节点TCPing测试,检测服务器连通性、丢包率与响应延迟' },
{ 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: 'IPv4 TCPing连通性测试工具',
description: '专业的IPv4 TCPing测试工具,多节点检测服务器连通性和延迟,支持自定义端口测试,提供丢包率、平均延迟、最大最小响应时间等数据。',
url: config.siteUrl + 'tcping',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY'
},
provider: {
'@type': 'Organization',
name: config.siteName
}
})
}
]
});
interface TCPingStats {
ip: string
port: string
sent: number
success: number
loss_rate: number
max_rtt: number
min_rtt: number
avg_rtt: number
}
interface TCPingResponse {
ipv4?: TCPingStats
ipv6?: TCPingStats
}
interface ServerConfig {
id: string
label: string
url: string
}
interface ServerResult {
label: string
loading: boolean
error: string
ipv4?: TCPingStats
ipv6?: TCPingStats
}
const tmpDomain = ref('www.zakoflare.com')
const port = ref('80')
const loading = ref(false)
const serverResults = ref<ServerResult[]>([])
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
const allServers = [
...config.APIBaseURL.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
...config.APIBaseURL.IPv4.map((s: ServerConfig) => ({ ...s, type: 'IPv4' }))
];
const tcpingFetches = allServers.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/tcping/" + encodeURIComponent(extractHost(tmpDomain.value)) + "?port=" + encodeURIComponent(port.value));
const { data, error: fetchError, execute } = useMiddlewareFetch<TCPingResponse>(url, {
immediate: false,
watch: false,
});
return { label: server.label, data, error: fetchError, execute };
});
function initServerResults() {
const results: ServerResult[] = []
config.APIBaseURL.DualStack.forEach((server: ServerConfig) => {
results.push({
label: server.label,
loading: false,
error: ''
})
})
config.APIBaseURL.IPv4.forEach((server: ServerConfig) => {
results.push({
label: server.label,
loading: false,
error: ''
})
})
serverResults.value = results
}
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) {
// 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 = (err as any)?.message || '请求失败';
}
} finally {
const result = serverResults.value[index];
if (result && id === queryId) {
result.loading = false;
}
}
})
Promise.all(promises).finally(function () {
if (id === queryId) {
loading.value = false
}
})
}
onMounted(() => {
initServerResults()
const urlParam = route.query.site as string
if (urlParam) {
tmpDomain.value = urlParam
TCPingAll()
}
})
</script>
<template>
<div class="title">
<header>
<h1>IPv4 TCPing 测试</h1>
<p>多节点 TCPing 测试,检测服务器连通性和延迟</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:example.com)"
/>
<el-input
v-model="port"
placeholder="端口号(默认 80)"
style="width: 200px;"
/>
<el-button
@click="TCPingAll()"
type="primary"
:loading="loading"
>
开始测试
</el-button>
</div>
<div class="result-section">
<table class="result-table">
<thead>
<tr>
<th class="table-header">服务器</th>
<th class="table-header">解析 IP</th>
<th class="table-header">发送包</th>
<th class="table-header">接收包</th>
<th class="table-header">丢包率(%)</th>
<th class="table-header">最长时间(ms)</th>
<th class="table-header">最短时间(ms)</th>
<th class="table-header">平均时间(ms)</th>
</tr>
</thead>
<tbody>
<template v-for="(server, index) in serverResults" :key="index">
<tr v-if="server.ipv4">
<td class="table-label">{{ server.label }}</td>
<td class="table-value">{{ server.ipv4?.ip || '-' }}</td>
<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) > 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>
</tr>
<tr v-else-if="server.loading">
<td class="table-label">{{ server.label }}</td>
<td class="table-value" colspan="7">加载中...</td>
</tr>
<tr v-else-if="server.error">
<td class="table-label">{{ server.label }}</td>
<td class="table-value error-text" colspan="7">{{ server.error }}</td>
</tr>
<tr v-else>
<td class="table-label">{{ server.label }}</td>
<td class="table-value" colspan="7">-</td>
</tr>
</template>
</tbody>
</table>
</div>
</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: #3EAF7C;
}
html.dark {
--el-color-primary: #3EAF7C;
}
.el-icon {
font-size: 1.3em;
}
</style>
-330
View File
@@ -1,330 +0,0 @@
<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: 'Whois查询 | ' + (route.query.site || config.siteName),
titleTemplate: '%s',
link: [
{ rel: 'canonical', href: computed(() => new URL(route.path, config.siteUrl).toString()).value }
],
meta: [
{ name: 'description', content: '专业的Whois域名查询工具,支持.com、.net等国内外域名WHOIS信息查询,提供域名注册商、注册时间、到期时间、DNS服务器等详细信息,助力域名管理和交易决策' },
{ 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' },
{ property: 'og:url', content: computed(() => new URL(route.path, config.siteUrl).toString()).value },
{ name: 'twitter:card', content: 'summary_large_image' },
],
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 || '')
const whoisUrl = computed(() => '/middleware/' + backendID.value + '/whois/' + encodeURIComponent(domain.value))
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)"
/>
<el-button
@click="queryWhois()"
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">域名</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: #3EAF7C !important;
font-size: 1.3em;
text-decoration: none
}
@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: #3EAF7C;
}
html.dark {
--el-color-primary: #3EAF7C;
}
.el-icon{
font-size: 1.3em;
}
</style>
-286
View File
@@ -1,286 +0,0 @@
body{
display: flex;
flex-direction: column;
margin: 0 auto;
}
/* 在样式开头添加 */
p {
margin-top: 0;
margin-bottom: 0;
}
.title{
display: flex;
flex-direction: column;
align-items: center;
}
h1 {
font-size: 3em;
text-align: center;
unicode-bidi: isolate;
}
html.dark h1{
color: #adbac7;
}
h2{
font-size: 1.5em;
unicode-bidi: isolate;
}
h3{
font-size: 1.3em;
}
.title p{
font-size: 25.6px;
color: #6A8BAD;
margin-right: 20px;
margin-left: 20px;
text-align: center;
}
.content{
display: flex;
flex-direction: column;
align-items: right;
width: 60%;
min-width: 400px;
margin: 0 auto;
margin-top: 40px;
}
@media (max-width: 768px) {
.content {
width: 95%;
min-width: unset;
}
}
.one-line{
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
flex-wrap: wrap;
word-break: break-all;
hyphens: none; /* 防止自动添加连字符 */
margin-top: 1em;
}
.ip-info{
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
flex-wrap: nowrap;
}
.one-line p{
font-size: 1.3em;
}
.ip-info p{
font-size: 1.3em;
}
b{
color: #2C3E50;
font-size: 1.3em;
}
html.dark b{
color: #adbac7;
}
blockquote p{
color: #999999;
}
blockquote {
background: #f9f9f9;
border-left: 10px solid #ccc;
margin: 1.5em 10px;
padding: 0.5em 10px;
quotes: "\201C""\201D""\2018""\2019";
/* 设置字间距:让文字看起来更透气 */
letter-spacing: 0.5px;
/* 设置行间距:提升多行文本的阅读体验 */
line-height: 2;
/* (可选) 设置单词间距,针对英文内容 */
word-spacing: 1px;
}
blockquote p {
display: inline;
margin: 0.5em;
}
blockquote a{
color: #3EAF7C;
font-size: 1em;
/* 正文里的链接仅靠颜色区分不够(绿 #3EAF7C 与周围正文的对比度远低于 3:1),
补下划线提供「非颜色」的视觉区分(link-in-text-block) */
text-decoration: underline
}
a {
color: #3EAF7C;
font-size: 1.3em;
text-decoration: none
}
a:hover {
color: none;
}
footer .one-line {
font-size: 1em;
text-align: center;
color: #757575;
}
footer .one-line a {
font-size: 1em;
text-align: center;
color: #3EAF7C;
text-decoration: none
}
footer {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 60px;
}
@media (max-width: 768px) {
.content {
max-width: 95%;
min-width: unset;
margin-top: 30px;
}
h1 {
font-size: 1.9em;
}
h3{
font-size: 1.1em;
}
.title p {
font-size: 0.9em;
margin-right: 10px;
margin-left: 10px;
}
footer {
margin-top: 30px;
}
footer .one-line {
font-size: 0.75em;
flex-wrap: wrap;
justify-content: center;
text-align: center;
}
footer .one-line img {
height: 1.2em;
width: auto;
vertical-align: middle;
}
footer .one-line a {
font-size: 0.9em;
}
.one-line {
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;
}
blockquote p {
font-size:0.5em;
display: inline;
margin: 0.5em;
}
blockquote a{
color: #3EAF7C;
font-size: 1em;
/* 与上面的非窄屏规则保持一致:窄屏同样靠下划线做非颜色区分。
此处若仍写 none,因其位置在后、特异性相同,会把桌面的 underline 盖掉。 */
text-decoration: underline
}
}
/* 注:暗色模式的「根元素」规则(html.dark 的文字色 / 背景色 / 主色变量)已上移到
app.vue 的【非 scoped】<style> 块。本文件是被 <style scoped> @import 进来的,
凡是作用于 <html> 自身的规则都会被加上 scope 属性而永不匹配。
这里只保留作用于「html 后代元素」的规则(后代元素带 scope 属性,能正常命中)。 */
html.dark blockquote {
/* 在暗色模式下,覆盖根元素的文字颜色和背景色 */
color: rgba(255, 255, 255, 0.87);
background-color: #1a1919;
border-left: 10px solid #111111;
}
:deep(.shiki span) {
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Monaco', 'Courier New', monospace !important;
}
:deep(.shiki){
padding: 1rem;
border-radius: 10px;
overflow: auto;
max-width: 100%;
}
.spoiler {
position: relative; /* 使得伪元素可以定位 */
background-color: rgba(200, 200, 200, 0.3); /* 浅灰色半透明背景 */
color: rgba(0, 0, 0, 0.5); /* 半透明黑色文字 */
user-select: none; /* 禁止选中文字 */
overflow: hidden; /* 隐藏溢出的内容 */
cursor: pointer; /* 鼠标悬停时光标变为手型 */
filter: blur(3px); /* 添加模糊效果 */
padding: 5px; /* 为文本添加内边距以增强效果 */
transition: filter 0.3s ease; /* 添加平滑过渡效果 */
}
.spoiler:hover {
background-color: inherit; /* 悬停时使用父元素的背景色 */
color: inherit; /* 悬停时显示原色 */
filter: blur(0); /* 悬停消除模糊效果 */
}
.quate{
background-color: #F3F4F5;
color: #4E6E8E;
border-radius: 5px;
padding: 3px ;
}
html.dark .quate{
background-color: #2b313a;
color: #8e9eb0;
}
.header-anchor {
margin-right: .4rem;
text-decoration: none;
transition: opacity .2s;
}
h1>.header-anchor,
h2>.header-anchor,
h3>.header-anchor,
h4>.header-anchor,
h5>.header-anchor,
h6>.header-anchor {
opacity: 1;
color: #3EAF7C;
}
h1:hover>.header-anchor,
h2:hover>.header-anchor,
h3:hover>.header-anchor,
h4:hover>.header-anchor,
h5:hover>.header-anchor,
h6:hover>.header-anchor {
opacity: 1;
}
.header-anchor::before {
content: "#";
color: #3EAF7C;
}
-303
View File
@@ -1,303 +0,0 @@
/* tool-common.css - 网络工具页面公共样式 */
/* 输入框和按钮 */
.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;
}
.el-select {
margin-right: 10px;
}
:deep(.el-select__wrapper) {
height: 50px !important;
min-height: 50px !important;
padding-top: 0 !important;
}
:deep(.el-select__selected-item) {
line-height: 48px !important;
height: 48px !important;
font-size: 16px;
}
:deep(.el-select__caret) {
font-size: 18px;
line-height: 50px !important;
}
:deep(.el-input__wrapper) {
height: 50px !important;
box-shadow: 0 0 0 1px var(--el-border-color) inset !important;
}
:deep(.el-input__inner) {
height: 48px !important;
line-height: 48px !important;
font-size: 16px;
}
/* 结果区域 */
.result-section {
margin-top: 30px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* 表格样式 */
.result-table {
width: 100%;
border-collapse: collapse;
background: #fff;
border: 1px solid #dcdfe6;
}
html.dark .result-table {
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 {
color: #cfcfcf;
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;
}
/* 状态样式 */
.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;
}
.valid {
color: #67C23A;
font-weight: 600;
}
.expired {
color: #F56C6C;
font-weight: 600;
}
.loss-ok {
color: #67C23A;
font-weight: 600;
}
.loss-warning {
color: #F56C6C;
font-weight: 600;
}
/* 错误消息 */
.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 {
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;
}
/* 窄屏响应式:所有组件缩小 */
@media (max-width: 768px) {
.el-input,
.el-button {
width: 100% !important;
height: 40px !important;
font-size: 0.9em !important;
margin-right: 0 !important;
}
.el-input {
margin-bottom: 10px;
}
.el-select {
width: 100% !important;
margin-right: 0 !important;
margin-bottom: 10px;
}
.result-section {
margin-top: 20px;
}
.result-table {
font-size: 0.85em;
}
.result-table .table-label {
width: 80px;
padding: 6px 8px;
}
.result-table .table-value {
padding: 6px 8px;
}
.result-table .table-header {
padding: 8px 10px;
font-size: 0.9em;
}
pre {
padding: 10px;
font-size: 0.8em;
}
.code-block {
padding: 0.75rem;
font-size: 0.8em;
}
.error-message {
font-size: 0.9em;
padding: 10px;
}
.badge-item {
padding: 15px;
}
.badge-item h4 {
font-size: 1em;
}
}
-139
View File
@@ -1,139 +0,0 @@
export interface DocMeta {
title: string
description: string
}
export const docConfig: Record<string, DocMeta> = {
'/doc': {
title: 'IPv6 工具箱使用文档',
description: '致力于普及 IPv6,推进 IPv6 规模部署和应用,以全面推进 IPv6 技术创新与融合应用为主线,以提升应用广度深度为主攻方向。支持 IPv6 本地网络(宽带)开启验证、IPv6 网站开启检测、IPv6 Ping 延迟测试、IPv6 网站测速、IPv6 地址查询、IPv6 DNS 解析。'
},
'/doc/user/enable_ipv6': {
title: '个人宽带如何开启IPv6网络访问',
description: 'IPv6 是大势所趋,就在前段时间湖南联通发布公告,对家庭宽带提供 IPv6 地址,不再提供 IPv4 地址,那本文就介绍个人宽带如何开启 IPv6 网络访问。'
},
'/doc/user/cmd_bash_disable_ipv6': {
title: '命令行禁用/启用IPv6本地网络',
description: '图形化禁用 IPv6 网络比较繁琐,以下是 macOS 和 Window 10 命令行下禁用/启用 IPv6 本地网络的方法。'
},
'/doc/user/cmd_getip': {
title: '命令行(curl)获取 IPv4 和 IPv6 地址',
description: '通过 curl 命令获取公网 IPv4 和 IPv6 地址,还可以返回是 IPv4 还是 IPv6 访问优先。'
},
'/doc/user/view_ipv6_adress_url': {
title: '浏览器访问 IPv6 地址',
description: '在 IPv6 地址两边加上中括号,就可以通过浏览器访问了,比如 http://[2402:4e00:1013:e500:0:9671:f018:4947]/'
},
'/doc/user/ipv4_ipv6_prefix_precedence': {
title: 'Windows 10/11 设置 IPv4/IPv6 访问优先级',
description: 'Windows 10/11 开启 IPv6 后默认 IPv6 访问优先(以访问 IPv4/IPv6 双栈站点为例,操作系统会优先访问 IPv6),如果期望 IPv4 访问优先,可以通过 netsh 命令调整。'
},
'/doc/user/ipv6_daohang': {
title: '国内 IPv6 资源导航',
description: 'IPv6 网络开启成功后,可以访问一些 IPv6 网站资源,来体验下 IPv6 的魅力。不过目前纯 IPv6 网站比较少,目前网站支持 IPv6 的主流方式为 IPv4/IPv6 双栈访问,毕竟要兼顾 IPv4 网络用户的访问需求。'
},
'/doc/user/pure_ipv6_website': {
title: '国内纯 IPv6 网站导航',
description: 'IPv6 网络开启成功后,可以访问一些纯 IPv6 网站资源,来体验下 IPv6 的魅力。'
},
'/doc/user/dns': {
title: '全国各省 DNS 服务器列表',
description: '全国各省电信、联通、移动的首选 IPv4 DNS 服务器和备用 DNS 服务器,以及公共 DNS 服务器列表,最近更新为 2021-07-25。'
},
'/doc/user/ipv6_dns': {
title: 'IPv6 DNS 地址列表',
description: 'IPv6 DNS 地址列表,请求 IPv6 网络需要设置 IPv6 DNS。'
},
'/doc/user/AliyunAuthorizeSecurityGroup': {
title: '阿里云自动化添加安全组',
description: 'Golang 代码实操阿里云自动化添加安全组'
},
'/doc/user/TencentCloudAddSecurityGroup': {
title: '腾讯云自动化添加安全组',
description: 'Golang 代码实操腾讯云自动化添加安全组'
},
'/doc/server/website_enable_ipv6': {
title: '网站开启 IPv6 的三种方式',
description: '从传统二进制部署的 Nginx,到云原生部署的 K8S、Istio,分别介绍网站开启 IPv6 的三种方式。'
},
'/doc/server/tencent_cloud_cvm_ipv6': {
title: '腾讯云 cvm 开启 IPv6',
description: '一文看懂在腾讯云 CVM 上开启 IPv6,只需 7 步。'
},
'/doc/server/nginx_ipv6': {
title: 'Nginx 开启 IPv6',
description: '一文看懂 Nginx 中开启 IPv6,包含设置 IPv6 SSL 证书。'
},
'/doc/server/ipv6webcheck': {
title: '如何确认一个网站是否开启 IPv6',
description: '通过 IPv6 网站检测工具,一键检查网站是否开启 IPv6 访问。'
},
'/doc/server/ipv6_sign': {
title: '网站增加支持IPv6访问标识',
description: 'HTML+CSS 代码,可以提醒访客本网站支持 IPv6 访问,同时可以跳转到 IPv6 网站检测页面。'
},
'/doc/server/ipv6_domain_record': {
title: '如何为域名添加 IPv6 解析记录',
description: 'IPv6 网站准备好了后,接下来可以为网站域名添加 IPv6 解析记录(AAAA)。'
},
'/doc/server/http2': {
title: '网站如何开启 HTTP/2',
description: 'HTTP/2 通过引入字段压缩和允许在同一连接上进行多个并发交换,使网络资源得到更有效的利用,并减少了延迟。'
},
'/doc/rfc/rfc8200': {
title: 'IPv6 RFC8200 解读',
description: 'RFC8200(Internet Protocol, Version 6 (IPv6) Specification)是 IPv6 的权威定义,接下来在解读 RFC8200 文档中的过程中,加深对 IPv6 的理解。'
},
'/doc/rfc/ipv6_address_format': {
title: 'IPv6 地址标识方法',
description: '首选格式、压缩格式、内嵌 IPv4 地址的 IPv6 地址格式。'
},
'/doc/user/tcpdump_ipv6': {
title: 'tcpdump 分析 IPv6 包',
description: 'tcpdump 分析 IPv6 包'
},
'/doc/user/wireshark_ipv6': {
title: 'WireShark 分析IPv6包头',
description: '使用 WireShark 分析 IPv6 包头'
},
'/doc/user/ipv6_ping': {
title: 'IPv6 Ping 检测原理',
description: '协议标准:Internet Control Message Protocol (ICMPv6) for the Internet Protocol Version 6 (IPv6) Specification'
},
'/doc/api/ip/locate': {
title: '获取客户端公网 IP 及位置',
description: '调用本项目的 /v1/location 接口,一次取回访问者自己的公网 IP 与 10 套 IP 库的归属地结果。'
},
'/doc/api/ip/myip': {
title: '获取客户端公网 IP 地址',
description: '用自带边缘函数获取客户端公网 IP,纯文本返回、支持跨域,可按 IPv4 / IPv6 / 双栈分别部署。'
},
'/doc/api/ip/query': {
title: '查询指定 IP 地址的位置信息',
description: '调用本项目的 /v1/location/<ip> 接口查询任意 IPv4 / IPv6 地址的归属地,返回多源聚合结果。'
},
'/doc/api/whois/query': {
title: '查询域名的 WHOIS 信息',
description: '调用本项目的 /v1/whois/<domain> 接口查询域名注册信息:IANA → 注册局 → 注册商转介三跳,返回状态、注册商、关键日期与 NS。'
},
'/doc/server/check_userip_ipv4_ipv6': {
title: 'JS 检查网络是 IPv4 还是 IPv6',
description: '通过 JS 代码检查客户端是 IPv4 还是 IPv6 访问优先。'
},
'/doc/server/tls': {
title: '网站如何开启 TLS',
description: '用 Wireshark 抓包分析 TLS 1.3 的 TCP 三次握手与 TLS 协商全过程。'
},
'/doc/user/code_getip': {
title: 'Python/Go 获取 IPv4 和 IPv6 地址',
description: '通过 Python/Golang 获取公网 IPv4 和 IPv6 地址,还可以返回是 IPv4 还是 IPv6 访问优先。'
},
'/doc/user/dns_dig': {
title: 'DNS 解析流程',
description: '剖析 DNS 解析流程'
}
}
export function getDocMeta(path: string): DocMeta {
return docConfig[path] || { title: '', description: '' }
}
-135
View File
@@ -1,135 +0,0 @@
/*
前端的一系列配置
*/
const config = {
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/"
// "http://127.0.0.1:8092/",
// "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: "",
GongAn: "",
// 全站是否禁止搜索引擎索引
noindex: false,
// Worker IP查询接口
v4OnlyAPI: "https://4.wsmdn.top",
v6OnlyAPI: "https://6.wsmdn.top",
DualStackAPI: "https://test.wsmdn.top",
// ---- 节点池结构 ----
// IPLocationAPI:IP 归属查询(location / asn)上游节点池(纯数组,无栈区分)
IPLocationAPI: [
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
},
// {
// label: "中国 湖北 武汉 电信",
// id: "cn-wuhan-chinatelecom",
// url: ""
// },
{
label: "中国 四川 沙渠 电信[ZFC]",
id: "cn2-sichuan",
url: "https://cn2-sichuan.api-ipw.wsmdn.top/"
}
],
// 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/"
},
// IP 直连新节点:URL 留空,仅经独立中间件转发
{
label: "山东 枣庄 双线",
id: "zaozhuang",
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: "cn-wuhan-chinatelecom",
// url: ""
// },
]
}
}
export { config }
-140
View File
@@ -1,140 +0,0 @@
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:// 开头的域名部分
const urls = JSON.stringify(obj).match(/https?:\/\/[^"\/\\\s]+/g) || [];
// 提取域名 (Origin) 并去重
const domains = [...new Set(urls.map(url => new URL(url).origin))];
return domains;
};
const allowedDomains = extractDomains(config);
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
modules: [
"nitro-cloudflare-dev",
'@element-plus/nuxt',
'@nuxtjs/sitemap',
'@nuxtjs/robots',
'@vueuse/nuxt',
"nuxt-security",
],
vite: {
optimizeDeps: {
include: [
'is-ip',
'shiki',
'dayjs',
'lodash-unified'
]
},
},
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: [
// 暗黑模式的 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(否则异步加载)
innerHTML: `
(function() {
var stored = localStorage.getItem('vueuse-color-scheme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var dark = stored === 'dark' || (!stored && prefersDark);
if (dark) document.documentElement.classList.add('dark');
})();
`,
// 关键:不加 async/defer,确保同步阻塞执行
}
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.svg' }
]
}
},
runtimeConfig: {
indexnowKey: '',
apiKeys: '',
public: {
siteUrl: config.siteUrl,
docConfig: docConfig,
},
},
routeRules: {
// /doc 文档站改为 SSG:构建时预渲染为静态 HTML,由边缘静态资源直接响应
'/doc/**': { prerender: true },
},
nitro: {
publicAssets: [
{
dir: 'public',
maxAge: 0
}
],
esbuild: {
options: {
target: 'es2022' // 明确告诉 Nitro 使用 es2022 进行打包
}
},
prerender: {
// 显式列出所有 doc 路由(来自 config/doc.ts 的 docConfig 键),
// 让动态 [...slug] 页面在构建时全部预渲染为静态 HTML
routes: ['/doc', ...Object.keys(docConfig).filter((p) => p !== '/doc')],
},
},
security: {
ssg: {
// /doc 预渲染页的 CSP 会因 SRI modulepreload 哈希全量拼入而单行超 2000 字符,
// 触发 Cloudflare _headers 限制;关闭静态页安全头写入 _headers。
// SSR 页面仍由运行时中间件下发完整安全头,不受影响。
nitroHeaders: false,
},
headers: {
contentSecurityPolicy: {
'script-src': [
"'self'",
"'strict-dynamic'",
"'nonce-{{nonce}}'",
"'wasm-unsafe-eval'",
...allowedDomains // 允许 Umami 发送数据
],
'connect-src': [
"'self'",
...allowedDomains,// 允许 Umami 发送数据
],
'style-src': ["'self'", 'https:', "'unsafe-inline'"],
'img-src': ["'self'", 'https://s0.wp.com', 'data:', 'https:'],
'font-src': ["'self'", 'https:', 'data:'],
}
}
},
sitemap: {
sources: [
'/api/__sitemap__/urls',
]
}
})
-48
View File
@@ -1,48 +0,0 @@
{
"name": "frontend-ssr",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "pnpm run build && wrangler dev",
"postinstall": "nuxt prepare",
"deploy": "pnpm run build && wrangler deploy",
"cf-typegen": "wrangler types"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@element-plus/nuxt": "^1.1.5",
"@nuxtjs/robots": "^6.1.3",
"@nuxtjs/sitemap": "^8.3.2",
"@nuxtjs/tailwindcss": "6.14.0",
"@shikijs/core": "^4.4.1",
"@shikijs/engine-javascript": "^4.4.1",
"@sindresorhus/slugify": "^3.0.0",
"@tailwindcss/vite": "^4.3.3",
"@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.3.0",
"markdown-it-anchor": "^9.2.1",
"nuxt": "^4.5.1",
"nuxt-security": "2.6.0",
"vue": "^3.5.40",
"vue-router": "^5.2.0"
},
"devDependencies": {
"@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",
"sass-embedded": "^1.100.0",
"shiki": "^4.4.1",
"wrangler": "^4.118.0"
}
}
-10899
View File
File diff suppressed because it is too large. Load diff
-7
View File
@@ -1,7 +0,0 @@
allowBuilds:
'@parcel/watcher': true
better-sqlite3: true
esbuild: true
sharp: true
vue-demi: true
workerd: true
Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 487 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 560 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Loaded 100 of 278 files, more files were not shown because too many files have changed in this diff. Show more