Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
805b65bc07 | ||
|
|
682983e411 | ||
|
|
19fe134762 | ||
|
|
5445b4df64 | ||
|
|
4f6cf7fac7 | ||
|
|
243060a32a | ||
|
|
924b9bcc36 | ||
|
|
611caa30f6 | ||
|
|
a8f7f2bd17 | ||
|
|
ff5caddab3 | ||
|
|
a00e42a896 | ||
|
|
031c1e8b69 |
No files matched your search
@@ -1,6 +1,11 @@
|
||||
name: Build and Release
|
||||
|
||||
|
||||
# 并行构建:原来 8 个 Build step 串行跑在一个 job 里,现在拆成 8 个矩阵任务同时跑
|
||||
# (2 个程序 × 4 个目标系统),构建完各自上传 artifact,最后统一发 Release。
|
||||
#
|
||||
# 元数据(VERSION / COMMIT / BUILD_TIME)由 meta job 算一次分发下去:
|
||||
# 若放到每个矩阵任务里各算一遍,BUILD_TIME 会互不相同,同一个 Release 里的
|
||||
# 二进制时间戳就对不上了。
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
@@ -8,95 +13,131 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
# 只负责算版本元数据,秒级完成,不产出 artifact
|
||||
meta:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
ldflags: ${{ steps.meta.outputs.ldflags }}
|
||||
steps:
|
||||
- name: Compute version metadata
|
||||
id: meta
|
||||
run: |
|
||||
echo "ldflags=-X main.VERSION=${GITHUB_REF_NAME} -X main.COMMIT=${GITHUB_SHA} -X main.BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
needs: meta
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
# 一个目标挂了不影响其余目标,全部跑完再统一看结果
|
||||
fail-fast: false
|
||||
# 纯 include 模式:每条自带全部字段、各自独立成一个任务。
|
||||
# 不要把 app 写成顶层数组再和 os 组合——GitHub 会把能匹配现有组合的
|
||||
# include 条目合并进组合本身,任务数被折叠(docker.yml 里踩过,见 v3.3.0 事故)
|
||||
matrix:
|
||||
include:
|
||||
- app: lemonipw
|
||||
dir: src
|
||||
os: linux
|
||||
- app: lemonipw
|
||||
dir: src
|
||||
os: windows
|
||||
- app: lemonipw
|
||||
dir: src
|
||||
os: darwin
|
||||
- app: lemonipw
|
||||
dir: src
|
||||
os: freebsd
|
||||
- app: middleware-go
|
||||
dir: middleware-go
|
||||
os: linux
|
||||
- app: middleware-go
|
||||
dir: middleware-go
|
||||
os: windows
|
||||
- app: middleware-go
|
||||
dir: middleware-go
|
||||
os: darwin
|
||||
- app: middleware-go
|
||||
dir: middleware-go
|
||||
os: freebsd
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
# v4 基于 Node 16,GitHub 已不再支持该运行时(actionlint 会报 runner too old)
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26.6' # 与 src/go.mod 声明保持一致,避免依赖 GOTOOLCHAIN 隐式下载
|
||||
- name: Set version metadata
|
||||
run: |
|
||||
echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV
|
||||
echo "COMMIT=${GITHUB_SHA}" >> $GITHUB_ENV
|
||||
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_ENV
|
||||
|
||||
- name: Build for Linux
|
||||
working-directory: src
|
||||
# 产物统一写到仓库根目录(working-directory 的上一级),命名规则与原串行版完全一致
|
||||
- name: Build ${{ matrix.app }} for ${{ matrix.os }}
|
||||
working-directory: ${{ matrix.dir }}
|
||||
env:
|
||||
APP: ${{ matrix.app }}
|
||||
LDFLAGS: ${{ needs.meta.outputs.ldflags }}
|
||||
run: |
|
||||
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-386
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-amd64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-arm64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-armv7
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-armv6
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=loong64 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-loong64
|
||||
- name: Build for Windows
|
||||
working-directory: src
|
||||
run: |
|
||||
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=386 go build -ldflags "$LDFLAGS" -o ../lemonipw-windows-386.exe
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-windows-amd64.exe
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-windows-arm64.exe
|
||||
- name: Build for macOS
|
||||
working-directory: src
|
||||
run: |
|
||||
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-darwin-amd64
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-darwin-arm64
|
||||
- name: Build for FreeBSD
|
||||
working-directory: src
|
||||
run: |
|
||||
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags "$LDFLAGS" -o ../lemonipw-freebsd-386
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-freebsd-amd64
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-freebsd-arm64
|
||||
- name: Build middleware-go for Linux
|
||||
working-directory: middleware-go
|
||||
run: |
|
||||
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-386
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-amd64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-arm64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-armv7
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-armv6
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=loong64 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-loong64
|
||||
- name: Build middleware-go for Windows
|
||||
working-directory: middleware-go
|
||||
run: |
|
||||
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=386 go build -ldflags "$LDFLAGS" -o ../middleware-go-windows-386.exe
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-windows-amd64.exe
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-windows-arm64.exe
|
||||
- name: Build middleware-go for macOS
|
||||
working-directory: middleware-go
|
||||
run: |
|
||||
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-darwin-amd64
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-darwin-arm64
|
||||
- name: Build middleware-go for FreeBSD
|
||||
working-directory: middleware-go
|
||||
run: |
|
||||
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags "$LDFLAGS" -o ../middleware-go-freebsd-386
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-freebsd-amd64
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-freebsd-arm64
|
||||
set -euo pipefail
|
||||
case "${{ matrix.os }}" in
|
||||
linux)
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags "$LDFLAGS" -o "../${APP}-linux-386"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o "../${APP}-linux-amd64"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o "../${APP}-linux-arm64"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$LDFLAGS" -o "../${APP}-linux-armv7"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$LDFLAGS" -o "../${APP}-linux-armv6"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=loong64 go build -ldflags "$LDFLAGS" -o "../${APP}-linux-loong64"
|
||||
;;
|
||||
windows)
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=386 go build -ldflags "$LDFLAGS" -o "../${APP}-windows-386.exe"
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o "../${APP}-windows-amd64.exe"
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o "../${APP}-windows-arm64.exe"
|
||||
;;
|
||||
darwin)
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o "../${APP}-darwin-amd64"
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o "../${APP}-darwin-arm64"
|
||||
;;
|
||||
freebsd)
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags "$LDFLAGS" -o "../${APP}-freebsd-386"
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o "../${APP}-freebsd-amd64"
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o "../${APP}-freebsd-arm64"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Upload ${{ matrix.app }}-${{ matrix.os }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.app }}-${{ matrix.os }}
|
||||
# 只捞本任务自己产出的二进制;middleware-go/ 目录名不带连字符,不会误匹配
|
||||
path: ${{ matrix.app }}-*
|
||||
if-no-files-found: error
|
||||
|
||||
# 只在打 tag 时发 Release;workflow_dispatch 手动跑则只构建、可从 run 页面下载 artifact
|
||||
release:
|
||||
needs: build
|
||||
if: github.ref_type == 'tag'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Prepare config files for release
|
||||
run: |
|
||||
# 后端:发布 src/ 下的 setting.json.example;中间件:只发布 setting.json.example
|
||||
cp src/setting.json.example setting.json.example
|
||||
cp middleware-go/setting.json.example middleware.setting.json.example
|
||||
cp src/setting.json.example dist/setting.json.example
|
||||
cp middleware-go/setting.json.example dist/middleware.setting.json.example
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
if: github.ref_type == 'tag'
|
||||
with:
|
||||
name: Release ${{ github.ref_name }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
token: ${{ secrets.MINT_TOKEN }}
|
||||
|
||||
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
## 配置文件说明
|
||||
@@ -116,34 +157,4 @@ jobs:
|
||||
cp middleware.setting.json.example middleware-go/setting.json
|
||||
```
|
||||
|
||||
files: |
|
||||
lemonipw-linux-386
|
||||
lemonipw-linux-amd64
|
||||
lemonipw-linux-arm64
|
||||
lemonipw-linux-armv7
|
||||
lemonipw-linux-armv6
|
||||
lemonipw-linux-loong64
|
||||
lemonipw-windows-386.exe
|
||||
lemonipw-windows-amd64.exe
|
||||
lemonipw-windows-arm64.exe
|
||||
lemonipw-darwin-amd64
|
||||
lemonipw-darwin-arm64
|
||||
lemonipw-freebsd-386
|
||||
lemonipw-freebsd-amd64
|
||||
lemonipw-freebsd-arm64
|
||||
middleware-go-linux-386
|
||||
middleware-go-linux-amd64
|
||||
middleware-go-linux-arm64
|
||||
middleware-go-linux-armv7
|
||||
middleware-go-linux-armv6
|
||||
middleware-go-linux-loong64
|
||||
middleware-go-windows-386.exe
|
||||
middleware-go-windows-amd64.exe
|
||||
middleware-go-windows-arm64.exe
|
||||
middleware-go-darwin-amd64
|
||||
middleware-go-darwin-arm64
|
||||
middleware-go-freebsd-386
|
||||
middleware-go-freebsd-amd64
|
||||
middleware-go-freebsd-arm64
|
||||
setting.json.example
|
||||
middleware.setting.json.example
|
||||
files: dist/*
|
||||
@@ -1,11 +1,15 @@
|
||||
name: Build and Deploy to Cloudflare Workers
|
||||
name: (已停用自动部署) frontend-ssr → Cloudflare Workers
|
||||
|
||||
# 前端已迁移到 frontend-svelte(SvelteKit + shadcn-svelte,包管理器 bun),
|
||||
# 自动部署改由 .github/workflows/frontend-svelte.yml 负责。
|
||||
#
|
||||
# 这里**刻意去掉 push 触发**:两个 workflow 都部署同名 Worker(wrangler.jsonc 里的 "ipw-cn"),
|
||||
# 若继续按 push 触发,改 frontend-ssr 会把新前端的线上版本覆盖掉。
|
||||
# 保留 workflow_dispatch 只是为了万一还需要临时用旧站顶一下(手动触发)。
|
||||
# 新前端确认稳定后,这个文件可以直接删除。
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- 'frontend-ssr/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Build and Deploy frontend-svelte to Cloudflare Workers
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- 'frontend-svelte/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 包管理器是 bun(见 frontend-svelte/bun.lock),不再用 pnpm/npm
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
# 部署到 Cloudflare Workers
|
||||
# --keep-vars:保留 Worker 上已配置的环境变量(如 API_KEYS),别被本次部署清掉
|
||||
- name: Deploy to Cloudflare Workers
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
cd frontend-svelte
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
bunx wrangler deploy --keep-vars
|
||||
+23
-3
@@ -22,20 +22,40 @@
|
||||
- `dnssec-server`:**DNSSEC 专用** DNS 服务器(同样支持主从逗号分隔);留空则沿用 `dns-server`;`dns-server` 与 `dnssec-server` **都留空时自动探测系统 DNS** 兜底
|
||||
- `block-private-ips`:SSRF 防护开关(拒绝出站连接内网/私有地址;探测目标命中私有网段时直接返回伪造结果,禁止跨跳重定向)
|
||||
- `single-stack`:单栈模式,`ipv4` / `ipv6` / 留空(双栈)。声明单栈的节点跳过另一栈的探测(直接返回 `Skipped due to SINGLE_STACK=...`),EdgeOne / Vercel 等 IPv4-only 环境建议设为 `ipv4`
|
||||
- `ipdb`:IP 数据库开关。**不配置时默认启用**(首次启动自动下载约 450MB,之后每 24h 更新);仓库的 `setting.json.example` 里显式写为 `"false"`,即**示例配置是关闭状态**——关闭后不加载任何数据库,`/v1/location`、`/v1/asn` 路由不注册,适合只做网络拨测的轻量节点
|
||||
- `ipdb`:IP 数据库开关。**不配置时默认启用**(首次启动自动下载约 450MB,之后每 24h 更新);仓库的 `setting.json.example` 里显式写为 `false`,即**示例配置是关闭状态**——关闭后不加载任何数据库,`/v1/location`、`/v1/asn` 路由不注册,适合只做网络拨测的轻量节点
|
||||
- `cors`:允许的请求来源(逗号分隔)
|
||||
- `trusted-proxies`:可信代理 IP/CIDR(逗号分隔,如 `"10.0.0.0/8,173.245.48.0/20"`)。配置后 `ClientIP` 与 `/v1/location` 归属地接口只信任这些代理转发的 `X-Forwarded-For`,直连公网的节点可防止访客伪造 XFF;**留空保持默认行为(信任所有代理,XFF 可被伪造)**
|
||||
- `access-token`:API 访问令牌,留空则不启用鉴权。**它同时是 HTTP 管理面的开关**:留空时 `/v1/config`、`/v1/ota` 整组返回 `403`,只能走 WS 通道管理
|
||||
- `gh-proxy`:GitHub 下载代理前缀(如 `"https://ghproxy.com/"`),用于 IP 数据库拉取与 OTA 下载加速;留空直连
|
||||
- `node-ota`:OTA 开关,缺省 `true`(允许收集中心下发)。设为 `false` 时节点拒绝一切 OTA 指令并回传原因,适合只读文件系统 / 编排托管(升级走各自部署渠道)的部署。**支持运行时热改**(`PATCH /v1/config` 或 WS `config` 下发即时生效,改后按新值判定后续 OTA 指令)
|
||||
- `access-log`:访问日志开关,缺省 `true`(每个请求输出一条 `http` 记录:method / path / status / latency_ms / ip)。设为 `false` 后不再输出访问日志,**适合高吞吐节点**——访问日志是每请求一次同步写,压测中即便业务逻辑极轻(`/` 健康检查)吞吐也会被它压住。关闭后 panic 兜底与其余日志不受影响,仍会记录 500 与堆栈。**支持运行时热改**(`PATCH /v1/config` 或 WS `config` 下发即时生效,无需重启)
|
||||
- `ws-url`:WS 通道地址——接入独立中间件的 WebSocket 地址(如 `"wss://middleware-1.api-ipw.wsmdn.top/ws"`,须含 `wss://` 前缀与 `/ws` 路径);支持**逗号分隔多个中间件,同时连接全部(多活)**,任一断开只重连自己,不影响其他连接;留空 = 不启用 WS 客户端,走原 HTTP 接口
|
||||
- `node-id`:WS 节点 id(与中间件 `api-keys`/`ws-keys` 键、前端配置的节点 `id` 一致;建议用 UUID 唯一标识)
|
||||
- `node-key`:WS 注册 key(与中间件 `ws-keys[节点id]` 一致;**必填**——节点未配置该 key 时中间件拒绝注册并返回 401)
|
||||
- `report-url`:**数据上报收集中心** HTTP 基址(如 `"https://collector.example.com"`,即 ipw-boce 中间件)。节点把自身观测的统计/拨测按 `report-interval-seconds` 周期上报;`ws-url` 已启用时优先走 WS `report` 消息(所有已连接中间件广播),WS 未启用或全部掉线时 HTTP POST 到 `<report-url>/report` 兜底;远端配置可覆盖(在 `remote-ignore-config` 里加上 `report-url` 可禁止覆盖)
|
||||
- `report-token`:收集中心 `/report` 鉴权 token(与中间件 `report-token` 配置一致);属凭据类键,GET 快照里遮蔽为 `***`;**不随远端配置覆盖**(硬编码保护,清单见本页末的「远端配置」一节)
|
||||
- `report-interval-seconds`:上报周期秒,缺省 15
|
||||
- `max-response-body`:单次拨测可读的响应体上限,**按解压后大小**算,缺省 `32MB`(33554432 字节);支持裸数字与 `K`/`M`/`G` 后缀(`32MB` / `8M` / `1g` / `8MiB` 均可,大小写不敏感)。`detail` / `speed` / `ssl` 只需要一个字节数,正常网页远小于默认值;超过上限**直接返回错误,而不是截断上报**——报一个"截断后的大小/速度"比报错更误导人。**支持运行时热改**(`PATCH /v1/config` 下发即应用到出站客户端)
|
||||
- `memory-limit`:Go 运行时的**软**内存上限(等价 `GOMEMLIMIT`),字节数,写法同上;**留空(=0)表示自动取容器内存限额的 90%**,探测不到容器限额就不设置、保持 Go 默认 GC 行为。不设时 Go 只按 `GOGC` 比例回收,够不到突增,小内存机器上堆会一路涨到被内核 OOM kill,中间没有减速带。**软上限不是硬闸**:必要时运行时仍会越过它,只是付出更多 GC 代价。**支持运行时热改**
|
||||
|
||||
以上字段均可用环境变量覆盖(`PORTS` / `SINGLE_STACK` / `DNS_SERVER` / `DNSSEC_DNS_SERVER` / `BLOCK_PRIVATE_IPS` / `IPDB` / `CORS` / `TRUSTED_PROXIES` / `GH_PROXY` / `ACCESS_TOKEN` / `WS_URL` / `NODE_ID` / `NODE_KEY` / `NODE_OTA` / `REPORT_URL` / `REPORT_TOKEN` / `REPORT_INTERVAL_SECONDS`)。需要从远端拉取配置时,设置 `remote-config-url` 或环境变量 `REMOTE_CONFIG_URL`(优先级:远端 > 环境变量 > setting.json);`remote-ignore-config` 对应的环境变量为 `REMOTE_IGNORE_CONFIG`(JSON 数组字符串)。
|
||||
以上字段均可用环境变量覆盖(`PORTS` / `SINGLE_STACK` / `DNS_SERVER` / `DNSSEC_DNS_SERVER` / `BLOCK_PRIVATE_IPS` / `ACCESS_LOG` / `IPDB` / `CORS` / `TRUSTED_PROXIES` / `GH_PROXY` / `ACCESS_TOKEN` / `WS_URL` / `NODE_ID` / `NODE_KEY` / `NODE_OTA` / `REPORT_URL` / `REPORT_TOKEN` / `REPORT_INTERVAL_SECONDS` / `MAX_RESPONSE_BODY` / `MEMORY_LIMIT`)。需要从远端拉取配置时,设置 `remote-config-url` 或环境变量 `REMOTE_CONFIG_URL`(优先级:远端 > 环境变量 > setting.json);`remote-ignore-config` 对应的环境变量为 `REMOTE_IGNORE_CONFIG`(JSON 数组字符串)。
|
||||
|
||||
> [!TIP]
|
||||
> 后端字段共 22 个,`src/setting.json.example` 只列**最小可用集**(11 个:端口 / 代理 / 单栈 / DNS / IP 库 / CORS / 令牌 / WS 身份)——其余按需加,完整定义以本页为准。
|
||||
|
||||
<a id="bool-switches"></a>
|
||||
|
||||
> [!NOTE]
|
||||
> **四个开关(`ipdb` / `block-private-ips` / `node-ota` / `access-log`)共用一套字面值口径**(忽略大小写与首尾空白):
|
||||
>
|
||||
> - 开:`true` / `1` / `yes` / `on` / `enable` / `enabled`
|
||||
> - 关:`false` / `0` / `no` / `off` / `disable` / `disabled`
|
||||
>
|
||||
> 不配置(空值)时各用各的默认值(`block-private-ips` / `node-ota` / `access-log` / `ipdb` 都缺省**开**)。JSON 里写布尔 `true` 或字符串 `"true"` 均可,数字 `1` / `0` 也可以 —— 四条来源(`setting.json` / 环境变量 / `PATCH /v1/config` / 远端下发)会先归一成同一张字面量表再判定,所以布尔与字符串的结果必然一致。
|
||||
>
|
||||
> **认不出来的值不再静默当成"开"**:启动阶段告警并按默认值处理;`PATCH /v1/config` 与远端下发会把它计入应答的 `unknown` 并保留原值,便于发现拼错的键值。
|
||||
>
|
||||
> 历史差异(升级时注意):旧版本里只有 `node-ota` 认 `yes`/`no`/`off`,在 `ipdb` / `block-private-ips` / `access-log` 上写 `no` / `off` 会被当成**开**,`ipdb` 还大小写敏感(`"False"` 也是开)。现在四个开关统一按上表判定。
|
||||
|
||||
### 运行时配置管理
|
||||
|
||||
@@ -51,7 +71,7 @@
|
||||
|
||||
- **凭据类键不回显明文**:`access-token` / `node-key` / `report-token` 在快照里是 `***`。下发时必须剔除这些键,否则会把真值覆盖成三个星号(收集中心控制台已自动剔除)。
|
||||
- **凭据类键中 `access-token` / `report-token` 不受远端配置影响**:它们被硬编码进节点侧的保护名单(`configRemoteProtectedKeys`),远端配置里写了也会被跳过,并在应答 `protectedIgnored` 与日志中如实回报;本地 PATCH 不受此限。原因见本页末的「远端配置」一节。
|
||||
- **节点不自行重启**:`port` / `cors` / `ipdb` / `report-interval-seconds` / `trusted-proxies` / `node-id` / `node-key` / `access-token` 是启动期固定的,改动后在应答 `restartRequired` 里如实列出,但**不会自动重启**;只有"值真的变了"才报,避免每次全量下发都误报。**`ws-url` 是例外,热生效**(控制器按新地址多退少补);`dns-server` / `dnssec-server` / `block-private-ips` / `single-stack` / `node-ota` 每次消费重读,同样即时生效。
|
||||
- **节点不自行重启**:`port` / `cors` / `ipdb` / `report-interval-seconds` / `trusted-proxies` / `node-id` / `node-key` / `access-token` 是启动期固定的,改动后在应答 `restartRequired` 里如实列出,但**不会自动重启**;只有"值真的变了"才报,避免每次全量下发都误报。**`ws-url` 是例外,热生效**(控制器按新地址多退少补);`dns-server` / `dnssec-server` / `block-private-ips` / `single-stack` / `node-ota` / `access-log` / `max-response-body` / `memory-limit` 每次消费重读,同样即时生效。
|
||||
- **内存改动会被重启顶掉**:节点侧优先级为 远端 > 环境变量 > `setting.json`。只 PATCH 内存(或 `persist` 写本地文件)时,重启后仍可能被 ENV 顶掉;要长期生效应 **`refresh`**(改远端配置)或把改动合并进托管配置。
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
@@ -103,6 +103,10 @@ node .output/server/index.mjs # 默认监听 3000 端口
|
||||
- **所需 Secrets**:`CLOUDFLARE_API_TOKEN`、`CLOUDFLARE_ACCOUNT_ID`
|
||||
- **注意**:工作流设置了 `NITRO_PRESET: cloudflare_module`
|
||||
|
||||
## 缓存
|
||||
|
||||
缓存头不在应用侧设置,全部交由前置 CDN(EdgeOne)控制。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- **部署后接口 403 / 跨域**:检查后端 `cors` 配置(独立中间件见 `middleware-go/setting.json` 的 `cors` 字段,逗号分隔允许域名);服务端转发请求不带 `Origin`,浏览器直接调用才受 CORS 限制。
|
||||
|
||||
+129
-14
@@ -1,10 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, onBeforeUnmount, ref } from 'vue';
|
||||
import { useDark, useToggle } from '@vueuse/core';
|
||||
import { Moon, Sunny, Expand } from '@element-plus/icons-vue';
|
||||
import { config } from '../config/index';
|
||||
|
||||
const isNarrow = ref(false);
|
||||
// ==================== 窄屏判定(水合前就必须定下来) ====================
|
||||
// 为什么不能等到 onMounted 才算:SSR 不知道视口宽度,只能按宽屏输出 ——
|
||||
// 于是整条桌面版顶栏菜单(10 个条目 + 分隔线 + 3 个下拉)会被下发给手机;
|
||||
// 水合时 isNarrow 还是 false,手机就先把这一整套水合一遍
|
||||
// (实测 375×812:header-menu 区域 268 次 DOM 变更、70 次事件绑定),
|
||||
// 紧接着 onMounted 把它改成窄屏、再整棵销毁重建 —— 这一整轮纯属白干。
|
||||
// 所以改成「服务端按请求头判定、客户端直接沿用服务端序列化下来的结论」,
|
||||
// 两边输入一致 ⇒ 结论一致 ⇒ 水合期不产生任何分歧,桌面上仍是原来的形态。
|
||||
// 权威值依旧由 onMounted 的 matchMedia 覆盖,管「桌面窗口被拖窄」这类请求头判不出的情形。
|
||||
//
|
||||
// 注意:**不要**在客户端重新嗅探 navigator.userAgent。SSR 页两边大概率巧合一致,
|
||||
// 但预渲染页没有请求头(服务端恒判宽屏),客户端一嗅探就必然分叉 ——
|
||||
// 实测那会让整个 /doc/** 在真手机上水合失配、Nuxt 直接渲染 500 页。
|
||||
// 判定逻辑与文档见 app/composables/useShellNarrow.ts。
|
||||
const isNarrow = useShellNarrow()
|
||||
let mediaQueryList: MediaQueryList | null = null;
|
||||
const drawer = ref(false);
|
||||
|
||||
@@ -90,9 +104,24 @@ const hidden = computed<Array<NavItem | NavGroup>>(
|
||||
entry.kind !== 'divider' && viewportWidth.value < entry.minWidth),
|
||||
)
|
||||
|
||||
// EP 的 Menu.init() 会在挂载时(以及菜单项集合变化时)遍历顶栏 <ul> 的**每个直接子元素**、
|
||||
// 统一写 tabindex="0" —— 连纯装饰的分隔条也不放过。调用栈实测:
|
||||
// Menu.init → Array.forEach → new MenuItem(el).init → setAttribute('tabindex','0')
|
||||
// 分隔条是 aria-hidden 的装饰元素,留着 tabindex="0" 会被 axe 判为 aria-hidden-focus
|
||||
// ("Focusable content should have tabindex=-1 or be removed from the DOM")——
|
||||
// 这一条比它替换掉的 aria-required-attr 更靠后,所以必须显式改回去。
|
||||
// 改的时机:子组件 mounted 早于父组件,onMounted 里 EP 已经 init 完;
|
||||
// 但要等 DOM 更新落定 —— updateViewport() 会改 visible、让 el-menu 重渲染并再 init 一遍。
|
||||
function fixDividerTabindex() {
|
||||
document.querySelectorAll('.menu-divider').forEach((el) => {
|
||||
el.setAttribute('tabindex', '-1')
|
||||
})
|
||||
}
|
||||
|
||||
// 实时同步视口宽度(resize 触发,驱动折叠)
|
||||
function updateViewport() {
|
||||
viewportWidth.value = window.innerWidth
|
||||
nextTick(fixDividerTabindex)
|
||||
}
|
||||
let umamiScript: HTMLScriptElement | null = null
|
||||
useHead({
|
||||
@@ -123,13 +152,22 @@ onMounted(() => {
|
||||
window.removeEventListener('resize', updateViewport);
|
||||
});
|
||||
|
||||
if (!umamiScript && config.umamiScriptUrl) {
|
||||
// 统计脚本延迟到浏览器空闲再注入:umami 源响应慢(实测 ~1.6s),
|
||||
// 挂在 onMounted 里会和首页首屏的拨测请求抢慢速网络的带宽,
|
||||
// 拖累 Speed Index;统计本身不需要抢首屏前的任何窗口。
|
||||
const loadUmami = () => {
|
||||
if (umamiScript || !config.umamiScriptUrl) return
|
||||
umamiScript = document.createElement('script')
|
||||
umamiScript.src = config.umamiScriptUrl
|
||||
umamiScript.async = true
|
||||
umamiScript.setAttribute('data-website-id', config.umamiWebsiteId)
|
||||
document.head.appendChild(umamiScript)
|
||||
}
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(loadUmami, { timeout: 4000 })
|
||||
} else {
|
||||
setTimeout(loadUmami, 2000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -177,12 +215,25 @@ onMounted(() => {
|
||||
:ellipsis="false"
|
||||
|
||||
>
|
||||
<el-menu-item index="0" aria-label="返回首页">
|
||||
<!-- 宽屏不给 aria-label:这个 <li> 里有可见文本(右侧的 h2 站名),而 axe 的
|
||||
label-content-name-mismatch 要求 aria-label 必须把可见文本包含进去 ——
|
||||
写死「返回首页」只会被判不匹配(Lighthouse 桌面独有项,权重 0 不扣分,但语义是错的)。
|
||||
窄屏 h2 被 v-if 隐藏、img 的 alt 留空、el-icon 也没有文本 ⇒ 那时才需要
|
||||
aria-label 兜底,否则承载 role="menuitem" 的 <li> 就没有可访问名(aria-command-name)。
|
||||
里面那个 <router-link> 同理(它自己也是一个可访问名有冲突的目标),
|
||||
宽屏靠 h2 的「柠檬味ipw.cn」当名字,窄屏才补「返回首页」。 -->
|
||||
<el-menu-item index="0" :aria-label="isNarrow ? '返回首页' : undefined">
|
||||
<el-icon v-if="isNarrow" @click="drawer = !drawer"><Expand /></el-icon>
|
||||
<router-link to="/" aria-label="返回首页">
|
||||
<router-link to="/" :aria-label="isNarrow ? '返回首页' : undefined">
|
||||
<!-- alt 留空是有意的:logo 属装饰图,可访问名由外层链接提供。
|
||||
若把 alt 写成站名,宽屏下会与旁边的 h2 一起被读两遍。 -->
|
||||
<el-image src="/favicon.svg" alt="" style="margin-top: 20px;" />
|
||||
若把 alt 写成站名,宽屏下会与旁边的 h2 一起被读两遍。
|
||||
这里用原生 <img> 而不是 el-image:el-image 默认 lazy,SSR 只输出一个
|
||||
`.el-image__placeholder` 空壳,真正的 <img> 要等客户端水合后由
|
||||
IntersectionObserver 插进去 ⇒ logo 在首屏 HTML 里不存在(无 JS 时永不显示,
|
||||
有 JS 时也要闪一下)。原生 <img> 直接进 HTML,还顺带省掉 el-image
|
||||
及其 image-viewer 的样式。宽高属性声明固有比例,显示宽度由下文
|
||||
:deep(.el-menu-item a img) 定为 50px。 -->
|
||||
<img src="/favicon.svg" alt="" width="50" height="50" style="margin-top: 20px;" />
|
||||
<h2 style="display: inline-block; margin-left: 10px" v-if="!isNarrow">{{ config.siteName }}</h2>
|
||||
</router-link>
|
||||
</el-menu-item>
|
||||
@@ -199,7 +250,13 @@ onMounted(() => {
|
||||
<router-link :to="child.to"><p class="menu-item-text">{{ child.label }}</p></router-link>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-divider v-else style="margin-top: 20px;height: 1.2em;" direction="vertical"/>
|
||||
<!-- 分组之间的竖分隔条。原来是 <el-divider direction="vertical">,但它在客户端水合后
|
||||
会被 EP 补上 tabindex="0"(SSR HTML 里干净、没有这个属性),于是 axe 把
|
||||
role="separator" 当成「可聚焦控件」并要求它带 aria-valuenow —— 这就是桌面端
|
||||
aria-required-attr 那一条(Lighthouse 权重 10,是桌面 a11y 掉 12 分里的最大单项)。
|
||||
换成 aria-hidden 的原生 <span>:装饰元素不参与焦点,也不会被 EP 改写属性。
|
||||
视觉与 .el-divider--vertical 等价,数值见下方 .menu-divider 的注释。 -->
|
||||
<span v-else class="menu-divider" aria-hidden="true"></span>
|
||||
</template>
|
||||
<!-- 折叠项:统一收进「更多」子菜单(分组用 el-menu-item-group 保留组名) -->
|
||||
<el-sub-menu v-if="hidden.length" index="more">
|
||||
@@ -236,12 +293,19 @@ onMounted(() => {
|
||||
且窄屏下与侧栏抽屉的可滚动区域互相挤压。 -->
|
||||
<footer v-if="!isDocRoute">
|
||||
<div class="one-line">
|
||||
Copyright © nomdn & IP 查询 2026 | <img src="/ipv6-s1.svg" alt="IPv6 相关标识"/> | <img src="/ssl-s1.svg" alt="SSL 相关标识"/> | All right reserved
|
||||
<!-- 这两张 svg 补上 width/height 属性(= 各自的固有尺寸 190×36 / 85×36):
|
||||
浏览器要在图下载完之前就知道占位比例,否则会先把整行按 0 高度排一遍、
|
||||
图到了再重排(Lighthouse 的 unsized-images 就是这条)。
|
||||
属性只声明固有比例,不改变显示尺寸:桌面端本来就是按固有尺寸渲染,
|
||||
窄屏由 style.css 的 `footer .one-line img { height:1.2em; width:auto }` 接管。 -->
|
||||
Copyright © nomdn & IP 查询 2026 | <img src="/ipv6-s1.svg" alt="IPv6 相关标识" width="190" height="36"/> | <img src="/ssl-s1.svg" alt="SSL 相关标识" width="85" height="36"/> | All right reserved
|
||||
</div>
|
||||
<div class="one-line">
|
||||
<a v-if="config.ICP" href="https://beian.miit.gov.cn/" target="_blank" rel="noreferrer" >{{ config.ICP }}</a>
|
||||
<span v-if="config.ICP"> | </span>
|
||||
<el-image v-if="config.GongAn" style="height: 1em; width: 1em;" src="/备案图标.png" />
|
||||
<!-- 同上:原生 <img> 而非 el-image(1em 见方的小图标,用不着 lazy/预览器)。
|
||||
显示尺寸由行内 style 定死;width/height 属性只声明固有比例(36×40),防抖动。 -->
|
||||
<img v-if="config.GongAn" src="/备案图标.png" alt="" width="36" height="40" style="height: 1em; width: 1em;" />
|
||||
<a v-if="config.GongAn" :href="'https://beian.mps.gov.cn/#/query/webSearch?code=' + cleanChineseCharacters(config.GongAn)" target="_blank" rel="noreferrer" >{{ config.GongAn }}</a>
|
||||
<span v-if="config.GongAn"> | </span>
|
||||
<a href="https://www.china-ipv6.cn/" target="_blank" rel="noreferrer" >国家IPv6发展监测平台</a>
|
||||
@@ -288,10 +352,27 @@ onMounted(() => {
|
||||
margin-bottom: 20px;
|
||||
|
||||
}
|
||||
/* 顶栏分组之间的竖分隔条(template 里那个 aria-hidden 的 span)。
|
||||
数值抄自 element-plus 的 .el-divider--vertical:1px 左边框、1.2em 高、
|
||||
margin 左右各 8px、vertical-align: middle;再加上原来行内 style 的 margin-top: 20px
|
||||
与 height: 1.2em,保证与替换前的排布一致。 */
|
||||
.menu-divider {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1px;
|
||||
height: 1.2em;
|
||||
margin: 20px 8px 0;
|
||||
vertical-align: middle;
|
||||
border-left: 1px solid var(--el-border-color);
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
/* 正文/页脚链接色:亮色白底需 ≥4.5:1(axe color-contrast 审计线)。
|
||||
#299764 白底只有 3.68:1,加深到 #1d7a4a = 5.34:1,留足余量。
|
||||
主题色(按钮/高亮等 --el-color-primary 消费方)保持 #299764 不动,只分流链接。 */
|
||||
--link-color: #1d7a4a;
|
||||
}
|
||||
html.dark {
|
||||
/* 暗色模式下根元素的文字色与页面底色。
|
||||
@@ -300,9 +381,10 @@ html.dark {
|
||||
`html.dark[data-v-xxxx]` —— 整条规则永不匹配,底色只能靠浏览器的 color-scheme 兜底。 */
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
/* 暗底 #242424 上 #299764 对比 ~4.2:1,且再加深只会更差 —— 暗色链接保持原色 */
|
||||
--link-color: #299764;
|
||||
}
|
||||
|
||||
/* Drawer 内部链接占满一行。
|
||||
注意用直接子选择器(> a):文档菜单(DocMenu)里的 <a> 嵌在 el-menu-item 里,
|
||||
不能被这套「整行大按钮」规则吃掉,否则菜单项会变成上下带 1em 内边距的块。
|
||||
@@ -370,7 +452,7 @@ html.dark {
|
||||
/* 窄屏(≤768px,与 script 中 isNarrow 的 matchMedia 断点一致):
|
||||
在水合前由 CSS 兜底隐藏宽屏菜单项,避免窄屏设备闪现宽屏布局 */
|
||||
@media (max-width: 768px) {
|
||||
.el-menu--horizontal > .el-divider {
|
||||
.el-menu--horizontal > .menu-divider {
|
||||
display: none !important;
|
||||
}
|
||||
.el-menu--horizontal > .el-menu-item[index="1"],
|
||||
@@ -392,6 +474,39 @@ html.dark {
|
||||
--el-menu-bg-color: transparent !important;
|
||||
}
|
||||
|
||||
/* 顶栏菜单项的点击目标:让内层 <a> 撑满整个 <li>。
|
||||
axe 的 target-size(WCAG 2.2 的 2.5.8,24×24 最小目标)会把 <li role="menuitem">
|
||||
减掉内层 <a> 之后剩下的「侧翼」算成被遮挡的空间:EP 默认给 li 左右各 20px 内边距,
|
||||
而 <a> 只占中间 97px 宽,于是报
|
||||
"partially obscured (smallest space is 20px by 59px)" —— 桌面 7 条、权重 7。
|
||||
有两条路:① 把 --el-menu-base-level-padding 提到 >24px —— 顶栏整体就变宽,
|
||||
1350px 下直接横向溢出 45px(各档折叠阈值还得跟着重算);② 让 <a> 撑满 li —— 本条。
|
||||
选②:li 宽度、文字位置、分隔条位置与改动前【逐像素一致】(CDP 量 rect 比对过),
|
||||
可点区域却从 97×59 变成整项 137×59,远超 24×24。
|
||||
- flex + align-items:center 是为了复现原来 inline 盒子靠 line-height 得到的垂直居中
|
||||
(logo 的 img 顶边仍在 li 顶 +4.5px 处,改完不位移);
|
||||
- 负 margin 抵消 li 的左右内边距,与 padding 取同一个变量,改内边距时不会脱节;
|
||||
- height:100% 成立是因为 EP 给 .el-menu-item 写了确定的 height(--el-menu-item-height)。
|
||||
必须写在这个【非 scoped】块里:el-menu 是 fragment 根,scope 属性在它自己身上而不是
|
||||
祖先,<style scoped> 里的 :deep(.el-menu--horizontal) 选不到它。
|
||||
只作用于顶栏这条 ul —— sub-menu 的弹层被 teleport 到 body,跑不到这个选择器下。 */
|
||||
.el-menu--horizontal > .el-menu-item > a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
margin: 0 calc(-1 * var(--el-menu-base-level-padding));
|
||||
padding: 0 var(--el-menu-base-level-padding);
|
||||
}
|
||||
|
||||
/* logo 那个 <a> 单独放开 overflow(选择器多一层 :nth-child(1),免得靠书写顺序
|
||||
去压 scoped 块里 :deep(.el-menu-item a) 的 overflow:hidden)。
|
||||
它的 img 带 20px 上下外边距、总高 90px 塞在 59px 的 li 里:原来 <a> 是 inline
|
||||
元素、overflow 本来就不生效才没被裁;上面改成 flex 后若不显式放开,logo 会被切掉。 */
|
||||
.el-menu--horizontal > .el-menu-item:nth-child(1) > a {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.el-menu--horizontal > .el-menu-item:nth-child(1) {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 顶栏 / 抽屉「宽屏还是窄屏」的唯一判定入口(服务端算一次,客户端沿用)。
|
||||
*
|
||||
* 为什么客户端不自己嗅探 navigator.userAgent:
|
||||
* SSR 出的 HTML 是跟着【服务端】的判定走的。客户端若各算各的,只要两边结论不一致,
|
||||
* Vue 水合就会整棵树失配 —— 实测在预渲染的 /doc/** 上是直接抛
|
||||
* `Cannot read properties of null (reading 'nodeType')`,然后被 Nuxt 渲染成 500 页
|
||||
* (真手机打开文档站必然复现:预渲染时没有请求头 ⇒ 服务端恒判宽屏)。
|
||||
* 把服务端的判定序列化进 payload 当客户端的初值,首帧 vdom 与 HTML 必然一致;
|
||||
* 顺带还免疫「CDN 把桌面版缓存回给手机」——那种情况下客户端也会乖乖按 HTML 走宽屏。
|
||||
*
|
||||
* 权威值仍由 app.vue 的 onMounted 用 matchMedia 覆盖,管「桌面窗口被拖窄」这类
|
||||
* 请求头判不出的情形。窄屏判定是全局共享状态(useState),文档页据此决定要不要
|
||||
* 渲染侧栏,所以两边读的必须是同一个 ref。
|
||||
*/
|
||||
const CRAWLER_RE =
|
||||
/bot|crawler|spider|slurp|bingbot|baiduspider|yandex|duckduckbot|facebookexternalhit|preview/i
|
||||
|
||||
export function uaIsNarrow(ua: string, chMobile?: string) {
|
||||
if (!ua && !chMobile) return false // 两个都拿不到(含预渲染):按宽屏,与改造前一致
|
||||
if (CRAWLER_RE.test(ua)) return false // 抓取端一律宽屏,否则顶栏导航链接会从 HTML 里消失
|
||||
if (chMobile) return chMobile === '?1' // Chromium 的 Client Hint,最准
|
||||
return /Android|iPhone|iPod|Windows Phone|webOS|BlackBerry|Opera Mini|IEMobile|Mobile/i.test(ua)
|
||||
}
|
||||
|
||||
export function useShellNarrow() {
|
||||
// 只有服务端拿得到请求头;客户端分支只为类型与「init 被意外调用」兜底。
|
||||
const headers: Record<string, string | undefined> = import.meta.server
|
||||
? useRequestHeaders(['user-agent', 'sec-ch-ua-mobile'])
|
||||
: {}
|
||||
return useState<boolean>('ipw-shell-narrow', () =>
|
||||
uaIsNarrow(headers['user-agent'] || '', headers['sec-ch-ua-mobile']),
|
||||
)
|
||||
}
|
||||
@@ -9,10 +9,10 @@ html.dark .markdown-body {
|
||||
/* GitHub 暗色主题的核心颜色 */
|
||||
--color-fg-default: #e6edf3;
|
||||
--color-fg-muted: #8b949e;
|
||||
--color-fg-accent: #3EAF7C;
|
||||
--color-fg-accent: #299764;
|
||||
--color-bg-default: #242424;
|
||||
--color-bg-muted: #080707;
|
||||
--color-bg-accent: #3EAF7C;
|
||||
--color-bg-accent: #299764;
|
||||
--color-border-default: #242424;
|
||||
--color-border-muted: #242424;
|
||||
|
||||
|
||||
@@ -261,10 +261,10 @@ function getStatusClass(status: string): string {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -201,7 +201,8 @@ const doc = page.value;
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入域名(如:example.com)"
|
||||
/>
|
||||
<el-select v-model="recordType" style="width: 150px;" class="custom-height-select">
|
||||
<!-- aria-label 落到 EP select 内部的原生 input 上,否则 Lighthouse 的 label 审计必挂 -->
|
||||
<el-select v-model="recordType" style="width: 150px;" class="custom-height-select" aria-label="DNS 记录类型">
|
||||
<el-option
|
||||
v-for="item in recordTypes"
|
||||
:key="item.value"
|
||||
@@ -297,7 +298,7 @@ const doc = page.value;
|
||||
}
|
||||
|
||||
.markdown :deep(a) {
|
||||
color: #3EAF7C !important;
|
||||
color: #299764 !important;
|
||||
font-size: 1.3em;
|
||||
text-decoration: none
|
||||
}
|
||||
@@ -305,11 +306,11 @@ const doc = page.value;
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
|
||||
@@ -284,10 +284,10 @@ async function getUserIP() {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { getDocMeta } from '../../../config/doc'
|
||||
|
||||
// 窄屏下这条侧栏被 CSS 隐藏(导航在顶栏抽屉里),但照样要花钱水合 ——
|
||||
// 详见 app/pages/doc/index.vue 的同名注释。
|
||||
const isNarrow = useShellNarrow()
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const path = computed(() => route.path)
|
||||
@@ -18,7 +22,7 @@ useHead({
|
||||
<div class="box">
|
||||
<!-- 文档导航只有一份实现(app/components/DocMenu.vue)。
|
||||
宽屏:就是这条左侧固定侧栏;窄屏:本侧栏隐藏,改由顶部抽屉菜单承载 -->
|
||||
<DocMenu class="sidebar-menu" />
|
||||
<DocMenu v-if="!isNarrow" class="sidebar-menu" />
|
||||
<div class="content">
|
||||
<div class="markdown-body" v-if="page">
|
||||
<div v-html="page" />
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { getDocMeta } from '../../../config/doc'
|
||||
|
||||
// 窄屏(≤768px)下这条侧栏被 CSS 整个隐藏,导航改由顶栏抽屉承载。
|
||||
// 但「只是藏起来」不等于不花钱:Vue 依旧会把整棵 40 项的 el-menu 水合一遍
|
||||
// (实测手机端 doc-sidebar 区域 53 次事件绑定)。所以直接按判定结果不渲染。
|
||||
// 这里用的是全局共享的窄屏状态(服务端按请求头判定并随 payload 下发),
|
||||
// 与 app.vue 同一份 —— 首帧与服务端 HTML 一致,不会有水合分歧。
|
||||
const isNarrow = useShellNarrow()
|
||||
|
||||
const { data: page } = await useAsyncData('doc-index', () =>
|
||||
$fetch('/api/markdown/doc/index')
|
||||
)
|
||||
@@ -18,7 +25,7 @@ useHead({
|
||||
<div class="box">
|
||||
<!-- 文档导航只有一份实现(app/components/DocMenu.vue)。
|
||||
宽屏:就是这条左侧固定侧栏;窄屏:本侧栏隐藏,改由顶部抽屉菜单承载 -->
|
||||
<DocMenu class="sidebar-menu" />
|
||||
<DocMenu v-if="!isNarrow" class="sidebar-menu" />
|
||||
<div class="content">
|
||||
<div class="markdown-body" v-html="doc"></div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,32 @@ useHead({
|
||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||
],
|
||||
script: [
|
||||
{
|
||||
// —— IP 探测提前到 HTML 解析期(LCP 优化)——
|
||||
// 原来三个探测请求在 onMounted(水合完成后)才发出,模拟链路是
|
||||
// 「水合 → 发请求 → 等服务端 → 回填渲染」,IPv6 行作为 LCP 元素被整段串行拖住
|
||||
// (mobile 模拟 LCP 2275ms,满分线约 1600ms)。
|
||||
// 这里在 head 里用内联脚本立刻发起,与 render-blocking CSS 的下载、
|
||||
// hydration 三路并行;结果挂 window.__ipProbe,完成时派发 ip-probe 事件,
|
||||
// onMounted 只负责消费(见下方),不再等水合才起步。
|
||||
// 用原生 fetch:此刻 ofetch 还没加载;不带 retry 的语义与旧版 retry:false 一致
|
||||
// (底层 fetch 本来就不会重试)。
|
||||
innerHTML: [
|
||||
'window.__ipProbe = {};',
|
||||
'(function () {',
|
||||
` function emit(key) {`,
|
||||
` return function (text) {`,
|
||||
` window.__ipProbe[key] = text;`,
|
||||
` document.dispatchEvent(new CustomEvent('ip-probe', { detail: key }));`,
|
||||
` };`,
|
||||
` }`,
|
||||
// 与 onMounted 的语义一致:三接口各自独立、失败静默(保持「查询中」占位)
|
||||
` fetch(${JSON.stringify(config.v6OnlyAPI)}).then(function (r) { return r.text(); }).then(emit('v6')).catch(function () {});`,
|
||||
` fetch(${JSON.stringify(config.v4OnlyAPI)}).then(function (r) { return r.text(); }).then(emit('v4')).catch(function () {});`,
|
||||
` fetch(${JSON.stringify(config.DualStackAPI)}).then(function (r) { return r.text(); }).then(emit('dual')).catch(function () {});`,
|
||||
'})();',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
@@ -74,56 +100,91 @@ curl ${config.v6OnlyAPI}
|
||||
# (访问 IPv4/IPv6 双栈站点,如果返回 IPv6 地址,则 IPv6 访问优先)
|
||||
curl ${config.DualStackAPI}
|
||||
`.trim(); // 关键:去掉首尾多余的空行
|
||||
const highlightedCode = ref('');
|
||||
|
||||
// 代码高亮放在 SSR 期算:HTML 下发时就已经是带 <span> 的高亮结果,
|
||||
// 于是 ①客户端不必再下载/执行 shiki(引擎 + 语言 + 主题 ≈ 164KB);
|
||||
// ②不会再有"先出纯文本兜底、若干秒后才换成高亮"的那次块高变化(可见重排)。
|
||||
//
|
||||
// 这段代码是构建期常量(只依赖 config,不随请求变化),所以结果可以安全地随 HTML 一起缓存。
|
||||
//
|
||||
// import 仍然写在 handler 内部(而不是文件顶层):动态 import 会把它切成独立 chunk,
|
||||
// 只在真正要高亮时加载,不会像顶层静态 import 那样被塞进首屏的 modulepreload 列表。
|
||||
// 首次进入是 SSR 执行的;客户端水合直接读 payload(Nuxt 会序列化 useAsyncData 的结果),
|
||||
// 只有客户端软导航到本页时才在浏览器里跑一次。
|
||||
const { data: highlightedCode } = await useAsyncData(
|
||||
'home-curl-code',
|
||||
async () => {
|
||||
const { highlightCode } = await import('../../utils/shiki');
|
||||
return await highlightCode(code, 'bash');
|
||||
},
|
||||
// 默认值必须留空:模板是 v-if="highlightedCode" / v-else 的双分支,
|
||||
// 给成 code 会让 v-if 命中、把纯文本当 HTML 塞进 v-html(fallback 分支永远走不到)。
|
||||
// 留空则失败时自然落到 v-else 的 code-block--fallback,与改动前 catch 里清空的行为一致。
|
||||
{ default: () => '' },
|
||||
);
|
||||
|
||||
const ipAddress = ref('');
|
||||
const yourIPv4 = ref('');
|
||||
const yourIPv6 = ref('');
|
||||
|
||||
onMounted(() => {
|
||||
// 代码高亮与三个 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 = '';
|
||||
});
|
||||
// (代码高亮已移到 setup 顶部的 useAsyncData,随 SSR 一起产出。)
|
||||
|
||||
// 三个地址各自独立请求、各自渲染:谁先回来谁先上屏。
|
||||
// 不用 Promise.allSettled 包起来等齐——那样只要有一个慢(例如纯 IPv4 网络下 v6 接口要等超时),
|
||||
// 已经拿到的 IPv4 与双栈结果也得一起干等。
|
||||
// —— 三个 IP 探测:请求已由 head 内联脚本提前发出(见 useHead),这里只消费 ——
|
||||
// 内联脚本把结果挂 window.__ipProbe[key] 并派发 ip-probe 事件。三种时序都要覆盖:
|
||||
// ① 值已就位(事件在 onMounted 之前派发过)→ 同步读 store 直接上屏;
|
||||
// ② 值未到 → 挂监听等事件(收到匹配 key 才移除,不能 once——
|
||||
// 三个 key 共用同一事件名,once 会把别人的事件当自己的吃掉);
|
||||
// ③ window.__ipProbe 不存在(内联脚本被 CSP 拦掉/异常)→ 退回自己发请求,行为同旧版。
|
||||
//
|
||||
// retry: false —— ofetch 默认会对 GET 重发一次:网络层失败时它拿不到 response,就按 500 计
|
||||
// (500 在默认重试白名单里),且默认延迟为 0,立即重发。于是"这个节点连不上"会被原样重做一遍,
|
||||
// 用户白等一倍时间(实测 ofetch 1.5.1:裸调用触发 2 次真实 fetch,传 retry:false 后只剩 1 次;
|
||||
// 浏览器自身不会重试,底层 fetch 对同一请求只被调用过一次)。
|
||||
// 浏览器自身不会重试,底层 fetch 对同一请求只被调用过一次)。仅 ③ 的兜底路径会走到 $fetch。
|
||||
const ipFetchOptions = { retry: false } as const;
|
||||
const probeStore = (window as any).__ipProbe as Record<string, string> | undefined;
|
||||
|
||||
$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(() => {});
|
||||
const consumeProbe = (key: string, apply: (ip: string) => void): boolean => {
|
||||
if (!probeStore) return false; // ③ 内联脚本没跑起来
|
||||
if (typeof probeStore[key] === 'string') {
|
||||
apply(probeStore[key]); // ① 值已就位
|
||||
return true;
|
||||
}
|
||||
const onProbe = (e: Event) => {
|
||||
if ((e as CustomEvent).detail !== key) return;
|
||||
document.removeEventListener('ip-probe', onProbe);
|
||||
apply(probeStore[key]); // ② 事件到达
|
||||
};
|
||||
document.addEventListener('ip-probe', onProbe);
|
||||
return true;
|
||||
};
|
||||
|
||||
// 三个地址各自独立请求、各自渲染:谁先回来谁先上屏。
|
||||
// 不用 Promise.allSettled 包起来等齐——那样只要有一个慢(例如纯 IPv4 网络下 v6 接口要等超时),
|
||||
// 已经拿到的 IPv4 与双栈结果也得一起干等。
|
||||
if (!consumeProbe('dual', (ip) => { ipAddress.value = ip; })) {
|
||||
$fetch<string>(config.DualStackAPI, ipFetchOptions)
|
||||
.then((ip) => {
|
||||
ipAddress.value = ip;
|
||||
})
|
||||
.catch(() => {
|
||||
// 单个接口失败不影响其它两行展示,保持"查询中"占位
|
||||
});
|
||||
}
|
||||
if (!consumeProbe('v4', (ip) => { yourIPv4.value = ip; })) {
|
||||
$fetch<string>(config.v4OnlyAPI, ipFetchOptions)
|
||||
.then((ip) => {
|
||||
yourIPv4.value = ip;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
if (!consumeProbe('v6', (ip) => { yourIPv6.value = ip; })) {
|
||||
$fetch<string>(config.v6OnlyAPI, ipFetchOptions)
|
||||
.then((ip) => {
|
||||
yourIPv6.value = ip;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -136,10 +197,10 @@ onMounted(() => {
|
||||
</header>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="one-line">
|
||||
<div class="one-line ip-row">
|
||||
<b>IPv4</b> <p>{{ yourIPv4 }} </p> <RouterLink :to="`/location?ip=${yourIPv4}`" target="_blank">查询归属地</RouterLink>
|
||||
</div>
|
||||
<div class="one-line">
|
||||
<div class="one-line ip-row ip-row-v6">
|
||||
<b>IPv6</b> <p v-if="yourIPv6">{{ yourIPv6 }}</p><RouterLink :to="`/location?ip=${yourIPv6}`" target="_blank" v-if="yourIPv6"> 查询归属地</RouterLink><RouterLink v-else to="/doc/user/enable_ipv6" target="_blank">没有IPv6地址,查看如何开启IPv6</RouterLink>
|
||||
</div>
|
||||
<div class="ip-priority">
|
||||
@@ -173,6 +234,20 @@ onMounted(() => {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
/* 两行 IP 记录预留确定高度,消除 CLS。
|
||||
实测(CDP 逐帧采样 + CSS 变体对照,填值后量终态几何):
|
||||
这两个 div 的文本是 onMounted 之后才由 $fetch 填上的。IPv6 地址(39 字符)
|
||||
在窄屏下把 .one-line 从 1 行撑到 3 行,下面 .ip-priority / 代码块 / blockquote /
|
||||
footer 被整体顶下 50px —— Lighthouse 记的 `div.code-block` 那条 CLS 0.0359
|
||||
(占全页 88%)就是这么来的,而满分门槛只有 0.0396,差 3% 就被扣分。
|
||||
修法是让高度与「填完之后」一致。终态高度按视口分档(CDP 实测,不是估算):
|
||||
移动 412 宽 → ip2 终态 75px(3 行);桌面 1350 宽 → 30px(1 行,.content 宽 60%)
|
||||
所以必须分媒体查询给值:一刀切 75px 会让桌面凭空多出 45px 空白。
|
||||
代价是接口 pending 时这里空着,但换来 CLS 归零。 */
|
||||
.ip-row {
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
@@ -191,17 +266,30 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ip-priority h2 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.code-block {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.ip-priority h2 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.code-block {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
/* 窄屏下 IPv6 地址换 3 行(CDP 实测 412 宽终态 h=75px),见上面 .ip-row 的注释。
|
||||
75px 只给 IPv6 行:IPv4 地址窄屏仍是 1 行(~30px),一刀切 75px 会让
|
||||
IPv4 行下部空出 ~45px,两块 IP 之间出现一大段空白。
|
||||
IPv4 回填前后都是 1 行 30px,高度不变 → 不引入新的 CLS。 */
|
||||
.ip-row {
|
||||
/* style.css 窄屏给 .one-line 加了 0.5em 上下 margin(两块之间叠 16px),
|
||||
IP 行的间距由行高本身决定,这里归零。scoped 属性选择器特异性更高,稳赢。 */
|
||||
margin: 0;
|
||||
}
|
||||
.ip-row-v6 {
|
||||
min-height: 75px;
|
||||
align-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
</style>
|
||||
@@ -272,10 +272,10 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -287,11 +287,11 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
|
||||
@@ -305,10 +305,10 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -67,7 +67,22 @@ curl ${config.v6OnlyAPI}
|
||||
# (访问 IPv4/IPv6 双栈站点,如果返回 IPv6 地址,则 IPv6 访问优先)
|
||||
curl ${config.DualStackAPI}
|
||||
`.trim();
|
||||
const html = ref('');
|
||||
|
||||
// 代码高亮放 SSR 期算(与首页同款做法):HTML 里直接是带 <span> 的高亮结果,
|
||||
// 客户端不必再下载/执行 shiki(引擎 + 语言 + 主题 ≈ 164KB),也不会出现
|
||||
// "先渲染纯文本、稍后换成高亮"的块高变化。代码是构建期常量,结果可随 HTML 一起缓存。
|
||||
// import 写在 handler 内部(动态 import 单独成 chunk),首屏不会预载它。
|
||||
const { data: html } = await useAsyncData(
|
||||
'location-curl-code',
|
||||
async () => {
|
||||
const { highlightCode } = await import('../../utils/shiki');
|
||||
return await highlightCode(code, 'bash');
|
||||
},
|
||||
// 默认值给 code(不是空串):这里模板只有单个 v-html,没有 fallback 分支,
|
||||
// 高亮失败时正好退回原始代码文本,别让代码块空着。
|
||||
// (首页那处模板有 v-else 分支,所以那边默认值必须留空,两处不可照抄。)
|
||||
{ default: () => code },
|
||||
);
|
||||
const apiList = config.IPLocationAPI
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
@@ -117,18 +132,7 @@ function locateIP(IP: string){
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
// (代码高亮已移到 setup 顶部的 useAsyncData,随 SSR 一起产出,这里只剩定位查询。)
|
||||
|
||||
const urlParam = route.query.ip;
|
||||
if (urlParam) {
|
||||
@@ -257,7 +261,7 @@ onMounted(async () => {
|
||||
<style>
|
||||
@import "../style.css";
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
@@ -440,7 +444,7 @@ html.dark pre code {
|
||||
|
||||
.badge-item h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #3EAF7C;
|
||||
color: #299764;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
|
||||
@@ -286,7 +286,7 @@ html.dark .loading-overlay p {
|
||||
}
|
||||
|
||||
.markdown :deep(a) {
|
||||
color: #3EAF7C !important;
|
||||
color: #299764 !important;
|
||||
font-size: 1.3em;
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
@@ -272,10 +272,10 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -389,7 +389,7 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -271,11 +271,11 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
|
||||
@@ -281,7 +281,7 @@ function getStatusClass(status: string): string {
|
||||
@import "../style.css";
|
||||
@import "../../assets/css/tool-common.css";
|
||||
.markdown :deep(a) {
|
||||
color: #3EAF7C !important;
|
||||
color: #299764 !important;
|
||||
font-size: 1.3em;
|
||||
text-decoration: none
|
||||
}
|
||||
@@ -319,10 +319,10 @@ function getStatusClass(status: string): string {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -31,7 +31,10 @@ h3{
|
||||
}
|
||||
.title p{
|
||||
font-size: 25.6px;
|
||||
color: #6A8BAD;
|
||||
/* 首页副标题。原来 #6A8BAD 在白底只有 3.55:1(axe color-contrast,
|
||||
是移动端 12 条里唯一不是链接/代码的那条);加深到 #4D7396 = 4.99:1,
|
||||
色相不变、只是明度降一档。 */
|
||||
color: #4D7396;
|
||||
margin-right: 20px;
|
||||
margin-left: 20px;
|
||||
text-align: center;
|
||||
@@ -106,14 +109,14 @@ blockquote p {
|
||||
margin: 0.5em;
|
||||
}
|
||||
blockquote a{
|
||||
color: #3EAF7C;
|
||||
color: var(--link-color);
|
||||
font-size: 1em;
|
||||
/* 正文里的链接仅靠颜色区分不够(绿 #3EAF7C 与周围正文的对比度远低于 3:1),
|
||||
/* 正文里的链接仅靠颜色区分不够(绿 #299764 与周围正文的对比度远低于 3:1),
|
||||
补下划线提供「非颜色」的视觉区分(link-in-text-block) */
|
||||
text-decoration: underline
|
||||
}
|
||||
a {
|
||||
color: #3EAF7C;
|
||||
color: var(--link-color);
|
||||
font-size: 1.3em;
|
||||
text-decoration: none
|
||||
|
||||
@@ -129,7 +132,7 @@ footer .one-line {
|
||||
footer .one-line a {
|
||||
font-size: 1em;
|
||||
text-align: center;
|
||||
color: #3EAF7C;
|
||||
color: var(--link-color);
|
||||
text-decoration: none
|
||||
}
|
||||
footer {
|
||||
@@ -204,7 +207,7 @@ footer {
|
||||
}
|
||||
blockquote a{
|
||||
|
||||
color: #3EAF7C;
|
||||
color: var(--link-color);
|
||||
font-size: 1em;
|
||||
/* 与上面的非窄屏规则保持一致:窄屏同样靠下划线做非颜色区分。
|
||||
此处若仍写 none,因其位置在后、特异性相同,会把桌面的 underline 盖掉。 */
|
||||
@@ -269,7 +272,7 @@ h4>.header-anchor,
|
||||
h5>.header-anchor,
|
||||
h6>.header-anchor {
|
||||
opacity: 1;
|
||||
color: #3EAF7C;
|
||||
color: var(--link-color);
|
||||
}
|
||||
|
||||
h1:hover>.header-anchor,
|
||||
@@ -282,5 +285,5 @@ h6:hover>.header-anchor {
|
||||
}
|
||||
.header-anchor::before {
|
||||
content: "#";
|
||||
color: #3EAF7C;
|
||||
color: var(--link-color);
|
||||
}
|
||||
@@ -226,7 +226,7 @@ html.dark pre code {
|
||||
|
||||
.badge-item h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #3EAF7C;
|
||||
color: #299764;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
|
||||
+85
-14
@@ -1,5 +1,4 @@
|
||||
import {config} from "./config/index";
|
||||
import { docConfig } from "./config/doc";
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
const extractDomains = (obj: any): string[] => {
|
||||
// 将对象转为 JSON 字符串,用正则匹配所有 https:// 开头的域名部分
|
||||
@@ -10,9 +9,42 @@ const extractDomains = (obj: any): string[] => {
|
||||
};
|
||||
|
||||
const allowedDomains = extractDomains(config);
|
||||
|
||||
// 首屏「IPv4 / IPv6 / 优先级」三行的数据源(见 app/pages/index.vue 的 onMounted):
|
||||
// 三个接口分属三个独立源站,且都要等水合完成后才发起 —— 那时才做 DNS+TCP+TLS 太晚。
|
||||
// 把它们抽出来做 preconnect,让握手在 HTML 解析期就开始。
|
||||
const ipProbeOrigins = [
|
||||
config.DualStackAPI,
|
||||
config.v4OnlyAPI,
|
||||
config.v6OnlyAPI,
|
||||
].map((url) => new URL(url).origin);
|
||||
// 中间件(工具页 /middleware/* 的上游):只在部分页面用到,DNS 预解析即可,不占连接。
|
||||
const middlewareOrigin = config.Middleware?.[0]
|
||||
? new URL(config.Middleware[0]).origin
|
||||
: null;
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
experimental: {
|
||||
// 关闭 app manifest 版本检查:水合后 Nuxt 会在 requestIdleCallback 里请求
|
||||
// /_nuxt/builds/meta/<buildId>.json,buildId 对不上(本地「服务旧、磁盘新」、
|
||||
// 或线上发版切换瞬间)就抛 NUXT_E5002 污染 console,还拖累 Best Practices 分。
|
||||
// 本项目没依赖 appManifest 的客户端能力(无客户端 routeRules、无 prerender
|
||||
// 判定),关掉少一个请求,console 永远干净。
|
||||
appManifest: false,
|
||||
// 注:inlineStyles: true 实测是负优化——它只内联 page 组件样式(+12KB HTML),
|
||||
// entry/EP 等共享样式仍是 render-blocking 外链,关键请求一个没省。勿开。
|
||||
defaults: {
|
||||
nuxtLink: {
|
||||
// NuxtLink 默认「链接进入视口就预取目标页 JS」——顶栏 15 个菜单项会让
|
||||
// 首屏把其它工具页的 chunk(含 87KB 的校验库等重依赖)全拉下来,
|
||||
// 白吃带宽还挤占首屏关键请求。改为「悬停/聚焦才预取」:真正要去
|
||||
// 哪个页面时再拉那一页,首屏只加载当前页需要的代码。
|
||||
prefetchOn: { interaction: true, visibility: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
modules: [
|
||||
"nitro-cloudflare-dev",
|
||||
'@element-plus/nuxt',
|
||||
@@ -31,6 +63,24 @@ export default defineNuxtConfig({
|
||||
]
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
// 掐掉「当前路由的动态 import 预取」。
|
||||
// Nuxt 的 vue-bundle-renderer 会把 manifest 里带 prefetch 标记的动态 chunk
|
||||
// 渲染成 `<link rel="prefetch" as="script">`,于是首页一边解析 HTML 一边就把
|
||||
// shiki 高亮器那一整块(实测 165 KB,占首页字节 25%)下下来了 —— 而高亮已经在
|
||||
// SSR 期算完写进 HTML,客户端根本不会执行它,纯属白下。
|
||||
// 把 manifest 上的标记抹掉即可:动态 chunk 回到「真正 import 时才加载」。
|
||||
// 静态闭包走的是 preload 分支(modulepreload),不受影响。
|
||||
// —— 2026-10-04 曾试把 preload 也置 false:FCP 确实 2333→1404(少了 22 个请求的
|
||||
// 下载竞争),但 LCP 2387→2933、TBT 86→280(权重 25%/30% 远大于 FCP 的 10%),
|
||||
// 总分 94→89 净亏。原因:这 22 个 modulepreload 是 entry 的静态依赖闭包,全掐掉后
|
||||
// hydration 推迟、长任务落进 LCP/TBT 窗口。故只掐 prefetch,preload 必须留。
|
||||
'build:manifest': (manifest) => {
|
||||
for (const chunk of Object.values(manifest) as unknown as Array<Record<string, unknown>>) {
|
||||
if (chunk.prefetch) chunk.prefetch = false
|
||||
}
|
||||
},
|
||||
},
|
||||
site: {
|
||||
url: config.siteUrl,
|
||||
name: 'Lemon IPW'
|
||||
@@ -66,7 +116,17 @@ export default defineNuxtConfig({
|
||||
}
|
||||
],
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.svg' }
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.svg' },
|
||||
// preconnect 三个 IP 探测源站。crossorigin 不能省:$fetch 走 fetch() 的跨域
|
||||
// (CORS)模式,不带它预热出来的是 no-cors 连接,真正的 fetch 用不上、还得重连。
|
||||
...ipProbeOrigins.map((href) => ({
|
||||
rel: 'preconnect',
|
||||
href,
|
||||
crossorigin: 'anonymous',
|
||||
} as const)),
|
||||
...(middlewareOrigin
|
||||
? [{ rel: 'dns-prefetch', href: middlewareOrigin } as const]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -75,15 +135,22 @@ export default defineNuxtConfig({
|
||||
apiKeys: '',
|
||||
public: {
|
||||
siteUrl: config.siteUrl,
|
||||
docConfig: docConfig,
|
||||
|
||||
// 这里**不要**再放 docConfig:runtimeConfig.public 会被整份序列化进
|
||||
// 每个页面 HTML 的 `window.__NUXT__.config`,而这份文档树(32 条 title+description)
|
||||
// 实测占 4571 字节 —— 也就是每一页的 HTML 都白白多背 4.5KB(首页 HTML 的 10%)。
|
||||
// 它全站只有一个消费方,且是服务端路由(server/api/__sitemap__/urls.ts),
|
||||
// 那里改成直接 import 构建期常量即可;文档页本来就用 `getDocMeta` 直接 import。
|
||||
},
|
||||
},
|
||||
routeRules: {
|
||||
// /doc 文档站改为 SSG:构建时预渲染为静态 HTML,由边缘静态资源直接响应
|
||||
'/doc/**': { prerender: true },
|
||||
// 缓存头一律不在这里设,交由 CDN(EdgeOne)侧控制。
|
||||
// /doc/** 曾经是 SSG(prerender),已撤回改为 SSR,原因见下方 nitro.prerender 处的注释。
|
||||
},
|
||||
nitro: {
|
||||
// 构建期给 public/ 静态资源生成 .gz/.br 预压缩副本,按 Accept-Encoding 直接回;
|
||||
// SSR HTML 的压缩在 server/plugins/compress-html.ts。两者合起来让本地直连的
|
||||
// 传输体积与线上(EdgeOne 会压)一致,Lighthouse 的 Lantern 模拟才不会虚高。
|
||||
compressPublicAssets: true,
|
||||
publicAssets: [
|
||||
{
|
||||
dir: 'public',
|
||||
@@ -95,17 +162,21 @@ export default defineNuxtConfig({
|
||||
target: 'es2022' // 明确告诉 Nitro 使用 es2022 进行打包
|
||||
}
|
||||
},
|
||||
prerender: {
|
||||
// 显式列出所有 doc 路由(来自 config/doc.ts 的 docConfig 键),
|
||||
// 让动态 [...slug] 页面在构建时全部预渲染为静态 HTML
|
||||
routes: ['/doc', ...Object.keys(docConfig).filter((p) => p !== '/doc')],
|
||||
},
|
||||
// 这里曾用 prerender.routes 把整份 docConfig 的文档页在构建期预渲染成静态 HTML。
|
||||
// 已撤回,改回 SSR —— 因为预渲染与「按请求头决定渲染形态」根本冲突:
|
||||
// 构建期没有请求头,窄屏判定恒为宽屏,于是预渲染产物里永远躺着
|
||||
// 「15 条桌面顶栏菜单 + 被 CSS 隐藏的 40 项文档侧栏」;真手机打开时客户端判定为窄屏,
|
||||
// 首帧 vdom 与 HTML 对不上,Vue 水合直接抛
|
||||
// `Cannot read properties of null (reading 'nodeType')`,整页变成 500。
|
||||
// 顺带收益:/doc 现在也走运行时安全头(预渲染页拿不到 CSP,见下方 security.ssg)。
|
||||
// 代价只是源站多跑一层 SSR,前面有 EdgeOne 缓存兜着。
|
||||
},
|
||||
security: {
|
||||
ssg: {
|
||||
// /doc 预渲染页的 CSP 会因 SRI modulepreload 哈希全量拼入而单行超 2000 字符,
|
||||
// 触发 Cloudflare _headers 限制;关闭静态页安全头写入 _headers。
|
||||
// SSR 页面仍由运行时中间件下发完整安全头,不受影响。
|
||||
// 预渲染页的 CSP 会因 SRI modulepreload 哈希全量拼入而单行超 2000 字符,
|
||||
// 触发 Cloudflare _headers 限制,故关掉静态页的安全头写入。
|
||||
// 当前没有预渲染路由(/doc/** 已改回 SSR),这条是留给将来再开 prerender 时的开关:
|
||||
// 一旦重新启用 prerender,那些页就会退回「无 CSP」状态。
|
||||
nitroHeaders: false,
|
||||
},
|
||||
headers: {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { defineSitemapEventHandler } from '#imports'
|
||||
// 直接 import 构建期常量,而不是走 runtimeConfig.public.docConfig:
|
||||
// 后者会被 Nuxt 序列化进每个页面的 HTML(4571 字节 / 页),而本路由是全站唯一消费方。
|
||||
// 相对路径与 server/routes/middleware/[...slug].get.ts 保持一致(`~` 在 Nuxt 4 里指向 app/,指不到根目录的 config/)。
|
||||
import { docConfig } from '../../../config/doc'
|
||||
|
||||
|
||||
export default defineSitemapEventHandler(async () => {
|
||||
const config = useRuntimeConfig();
|
||||
console.log('Generating sitemap URLs from docConfig:', config.public.docConfig);
|
||||
|
||||
const urls = config.public.docConfig ? Object.keys(config.public.docConfig).map(path => ({ path })) : [];
|
||||
const urls = Object.keys(docConfig).map(path => ({ path }));
|
||||
|
||||
return urls.map(url => ({
|
||||
loc: url.path,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { brotliCompressSync, gzipSync, constants } from 'node:zlib'
|
||||
import type { NitroApp } from 'nitro/types'
|
||||
|
||||
/**
|
||||
* SSR HTML 压缩。
|
||||
*
|
||||
* ## 为什么要压
|
||||
*
|
||||
* nitro 不压 SSR 响应体(静态资源另有 compressPublicAssets 预压缩),
|
||||
* 本地直连 / Lighthouse 测到的 HTML 就是裸传的。生产链路前面有 EdgeOne 兜底,
|
||||
* 但源站自己压是标准做法,也让本地测量贴近线上。
|
||||
*
|
||||
* 只压 SSR 渲染出的 text/html:body 是字符串、体积 > 1KB、对方 Accept-Encoding
|
||||
* 支持才压,按客户端优先级选 br(brotli)否则 gzip。
|
||||
*
|
||||
* ## 为什么必须显式指定 brotli quality = 4(别删这行)
|
||||
*
|
||||
* `brotliCompressSync` 不带 params 时默认 **quality 11**,那是给「离线压一次、
|
||||
* 反复复用」准备的(压一次存盘,读一万次)。SSR 是**每请求现压**:
|
||||
*
|
||||
* quality 33KB HTML 68KB HTML
|
||||
* 11 123ms / 7.2KB 169ms / 11.0KB
|
||||
* 6 4ms / 7.8KB 5ms / 12.2KB
|
||||
* 4 2ms / 8.6KB 3ms / 13.5KB
|
||||
*
|
||||
* Node 是单线程,这段同步压缩**全程阻塞事件循环,直接进 TTFB**。
|
||||
* 首页 TTFB 实测:q11 时 225~260ms,改 q4 后 55~105ms —— 差的就是这 120~170ms。
|
||||
* q4 只多 2.5KB,在 1.6Mbps 上是 12ms,换回 166ms,净赚。
|
||||
*
|
||||
* 教训:凡「每请求现压」的同步压缩都要显式指定质量,别吃库默认值。
|
||||
*/
|
||||
export default defineNitroPlugin((nitroApp: NitroApp) => {
|
||||
nitroApp.hooks.hook('render:response', (response, { event }) => {
|
||||
const headers = (response.headers ??= {})
|
||||
const type = String(
|
||||
headers['content-type'] ?? event.node.res.getHeader('content-type') ?? '',
|
||||
)
|
||||
if (!type.includes('text/html')) return
|
||||
if (typeof response.body !== 'string' || response.body.length < 1024) return
|
||||
if (headers['content-encoding']) return // 已有编码(异常路径),不叠加
|
||||
|
||||
// —— modulepreload 降优先级(2026-10-04 实测,mobile LH 三档对比后选定 low)——
|
||||
// entry 的 26 个静态依赖 chunk 默认 High 优先级,和 4 个 render-blocking CSS 一起
|
||||
// 排队(本地 H1 只有 6 个连接),CSS 被挤到后面 ⇒ Lantern 模拟 FCP 2345ms。
|
||||
// 加 fetchpriority="low" 后 CSS 先行:FCP 2345→1109(-1236ms,首次过 1800 满分线),
|
||||
// desktop 三轮全 100。代价是 JS 下载推迟 → hydration 落到 FCP 之后,TBT 86→213。
|
||||
// 三档实测(mobile perf):无 fp 94/95,medium 92/93(medium 仍挤 CSS,FCP 2253,
|
||||
// 收益尽失),low 94/95 —— low 总分最优。
|
||||
// TBT 的 213 是这项改动的全部代价:FCP 提前后,原本在 FCP 前不计账的 hydration
|
||||
// 长任务进了 TBT 窗口(FCP 每提前 ~500ms,TBT 约 +55,两者在当前架构下互斥)。
|
||||
if (response.body.includes('rel="modulepreload"')) {
|
||||
response.body = response.body.replaceAll(
|
||||
'rel="modulepreload"',
|
||||
'rel="modulepreload" fetchpriority="low"',
|
||||
)
|
||||
}
|
||||
|
||||
const accept = String(event.node.req.headers['accept-encoding'] ?? '')
|
||||
let encoding: 'br' | 'gzip'
|
||||
if (/\bbr\b/.test(accept)) {
|
||||
response.body = brotliCompressSync(Buffer.from(response.body), {
|
||||
params: { [constants.BROTLI_PARAM_QUALITY]: 4 },
|
||||
})
|
||||
encoding = 'br'
|
||||
} else if (/\bgzip\b/.test(accept)) {
|
||||
response.body = gzipSync(Buffer.from(response.body))
|
||||
encoding = 'gzip'
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
headers['content-encoding'] = encoding
|
||||
// 体积变了,content-length 必须跟着改,否则连接会被截断/挂起
|
||||
headers['content-length'] = String((response.body as Buffer).length)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,14 @@ import { codeToHtml } from './shiki.bundle'
|
||||
|
||||
export async function highlightCode(code: string, lang: string) {
|
||||
try {
|
||||
return await codeToHtml(code, { lang, theme: 'github-dark' })
|
||||
return await codeToHtml(code, {
|
||||
lang,
|
||||
theme: 'github-dark',
|
||||
// github-dark 的注释色 #6A737D 在它自带底色 #24292E 上只有 3.04:1(axe 的 color-contrast,
|
||||
// 首页 12 条里有 5 条都是它)。用 shiki 原生的颜色替换提亮到 #8B949E = 4.77:1 ——
|
||||
// 只动这一个 token,换主题(比如已在 bundle 里的 vitesse-dark)会把整块代码的配色一起换掉。
|
||||
colorReplacements: { '#6a737d': '#8b949e' },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error highlighting code:', error)
|
||||
return code
|
||||
|
||||
+28
-7
@@ -113,7 +113,8 @@ func protectedKeysIn(cfg map[string]any) []string {
|
||||
// (ws.go 的 reconcileWSClient)按新地址多退少补,连接集合随配置变化,无需重启。
|
||||
//
|
||||
// 不在其中的键即为热生效:`dns-server` / `dnssec-server` / `block-private-ips` /
|
||||
// `single-stack` / `node-ota` 每次消费时重新读变量。
|
||||
// `single-stack` / `node-ota` / `access-log` 每次消费时重新读变量
|
||||
// (`access-log` 的中间件虽在启动时挂载,但开关判定在请求路径上,因此同样热生效)。
|
||||
//
|
||||
// 判定口径:只有**值真的变了**才算需重启(见 applyConfigMap)——远端/托管配置每次都带全部键,
|
||||
// 若按"键出现过"判定,每次 refresh 都会把节点误报成"需重启"。
|
||||
@@ -184,13 +185,23 @@ func applyConfigMap(cfg map[string]any, ignore []string) (applied, unknown, rest
|
||||
webtest.SetDNSSecServer(v)
|
||||
case "ipdb":
|
||||
// 仅改变量不够:ipdb.Init 与 location/asn 路由注册都在启动时按该值判定,运行中不重建
|
||||
IPDB = v
|
||||
enabled, known := parseBoolSwitch(v)
|
||||
if !known {
|
||||
unknown = append(unknown, key)
|
||||
continue
|
||||
}
|
||||
IPDB_ENABLED = enabled
|
||||
case "cors":
|
||||
CORS = v
|
||||
ACCEPT_DOMAINS = splitAndTrim(CORS, ",")
|
||||
case "block-private-ips":
|
||||
// 与启动口径共用 configDisabled(main.go),避免"启动认 false、热更新认 FALSE"这类漂移
|
||||
ssrf.SetEnabled(!configDisabled(v))
|
||||
// 与启动口径共用 parseBoolSwitch(main.go),避免"启动认 false、热更新认 FALSE"这类漂移
|
||||
enabled, known := parseBoolSwitch(v)
|
||||
if !known {
|
||||
unknown = append(unknown, key) // 值非法:不覆盖原值,明确报给调用方
|
||||
continue
|
||||
}
|
||||
ssrf.SetEnabled(enabled)
|
||||
case "trusted-proxies":
|
||||
TRUSTED_PROXIES = v
|
||||
case "remote-config-url":
|
||||
@@ -231,12 +242,21 @@ func applyConfigMap(cfg map[string]any, ignore []string) (applied, unknown, rest
|
||||
REPORT_INTERVAL = n
|
||||
case "node-ota":
|
||||
// 热生效:下发 OTA 指令时才读 NODE_OTA(见 ota.go),故不在 configRestartKeys
|
||||
enabled, known := parseOTASwitch(v)
|
||||
enabled, known := parseBoolSwitch(v)
|
||||
if !known {
|
||||
unknown = append(unknown, key) // 值非法:不覆盖原值,明确报给调用方
|
||||
continue
|
||||
}
|
||||
NODE_OTA = enabled
|
||||
case "access-log":
|
||||
// 热生效:中间件在请求路径上实时读 ACCESS_LOG(见 accessLogMiddleware),
|
||||
// 中间件本身在启动时就已挂好,所以这里改完即生效、无需重启。
|
||||
enabled, known := parseBoolSwitch(v)
|
||||
if !known {
|
||||
unknown = append(unknown, key) // 值非法:不覆盖原值,明确报给调用方
|
||||
continue
|
||||
}
|
||||
ACCESS_LOG = enabled
|
||||
case "max-response-body":
|
||||
// 热生效:改完即应用到出站客户端(applyResponseBodyLimit 内部有锁)
|
||||
n, ok := parseByteSize(v)
|
||||
@@ -296,7 +316,7 @@ func restartKeyValue(key string) string {
|
||||
case "cors":
|
||||
return CORS
|
||||
case "ipdb":
|
||||
return IPDB
|
||||
return strconv.FormatBool(IPDB_ENABLED)
|
||||
case "trusted-proxies":
|
||||
return TRUSTED_PROXIES
|
||||
case "ws-url":
|
||||
@@ -321,7 +341,7 @@ func configSnapshot() map[string]any {
|
||||
"single-stack": SINGLE_STACK,
|
||||
"dns-server": DNS_SERVER,
|
||||
"dnssec-server": DNSSEC_DNS_SERVER,
|
||||
"ipdb": IPDB,
|
||||
"ipdb": IPDB_ENABLED,
|
||||
"cors": CORS,
|
||||
"block-private-ips": ssrf.Enabled(),
|
||||
"trusted-proxies": TRUSTED_PROXIES,
|
||||
@@ -330,6 +350,7 @@ func configSnapshot() map[string]any {
|
||||
"ws-url": WS_URL,
|
||||
"node-id": WS_NODE_ID,
|
||||
"node-ota": NODE_OTA,
|
||||
"access-log": ACCESS_LOG,
|
||||
"max-response-body": MAX_RESPONSE_BODY,
|
||||
"memory-limit": MEMORY_LIMIT,
|
||||
"report-url": REPORT_URL,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func contains(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// literalString 是两条读取路径共用的「字面量 → 字符串」表,"布尔与字符串等价"就靠它。
|
||||
func TestLiteralString(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{"nil(未配置 / null)", nil, ""},
|
||||
{"bool false", false, "false"},
|
||||
{"bool true", true, "true"},
|
||||
{"字符串去首尾空白", " no \n", "no"},
|
||||
{"JSON 数字(float64)", float64(443), "443"},
|
||||
{"int 0", 0, "0"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := literalString(c.in); got != c.want {
|
||||
t.Errorf("%s: literalString(%#v) = %q, want %q", c.name, c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseBoolSwitch 是四个开关唯一的字面值口径。
|
||||
func TestParseBoolSwitch(t *testing.T) {
|
||||
for _, s := range []string{"true", "TRUE", " True ", "1", "yes", "ON", "enable", "enabled"} {
|
||||
if v, ok := parseBoolSwitch(s); !ok || !v {
|
||||
t.Errorf("parseBoolSwitch(%q) = (%v, %v), want (true, true)", s, v, ok)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"false", "FALSE", " False ", "0", "no", "off", "disable", "disabled"} {
|
||||
if v, ok := parseBoolSwitch(s); !ok || v {
|
||||
t.Errorf("parseBoolSwitch(%q) = (%v, %v), want (false, true)", s, v, ok)
|
||||
}
|
||||
}
|
||||
// 认不出来的一律 known=false —— 绝不静默当"开"
|
||||
for _, s := range []string{"", " ", "banana", "tru", "2", "on!", "是", "否"} {
|
||||
if v, ok := parseBoolSwitch(s); ok || v {
|
||||
t.Errorf("parseBoolSwitch(%q) = (%v, %v), want (false, false)", s, v, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 启动路径:setting.json 里把开关写成 JSON 布尔 / 数字,读出来必须与字符串写法等价,
|
||||
// 且不能被 configString 的"空值"判断误吞(false 变成"未配置"就等于静默变回默认的"开")。
|
||||
func TestConfigStringAcceptsJSONBoolAndNumber(t *testing.T) {
|
||||
viper.Reset()
|
||||
defer viper.Reset()
|
||||
viper.SetConfigType("json")
|
||||
if err := viper.ReadConfig(strings.NewReader(`{"ipdb": false, "access-log": 0, "port": 443, "cors": ""}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := configString("ZZ_NO_IPDB", "ipdb", "true"); got != "false" {
|
||||
t.Errorf("configString(ipdb) = %q, want \"false\"", got)
|
||||
}
|
||||
if configBool("ZZ_NO_IPDB", "ipdb", true) {
|
||||
t.Error("configBool(ipdb) = true,JSON 布尔 false 被当成了「开」")
|
||||
}
|
||||
if configBool("ZZ_NO_ACCESS_LOG", "access-log", true) {
|
||||
t.Error("configBool(access-log) = true,数字 0 被当成了「开」")
|
||||
}
|
||||
if got := configString("ZZ_NO_PORT", "port", "8080"); got != "443" {
|
||||
t.Errorf("configString(port) = %q, want \"443\"", got)
|
||||
}
|
||||
if got := configString("ZZ_NO_CORS", "cors", "fallback"); got != "fallback" {
|
||||
t.Errorf("configString(cors) = %q, want \"fallback\"(空串应视为未配置)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 运行路径:远端下发 / PATCH 收到布尔与字符串,行为必须一致;
|
||||
// 非法值计入 unknown 且不覆盖原值。
|
||||
func TestApplyConfigMapAcceptsBoolAndString(t *testing.T) {
|
||||
ipdb0, access0, ota0 := IPDB_ENABLED, ACCESS_LOG, NODE_OTA
|
||||
defer func() { IPDB_ENABLED, ACCESS_LOG, NODE_OTA = ipdb0, access0, ota0 }()
|
||||
|
||||
IPDB_ENABLED = true // 先置"开",才能验证布尔 false 真的把它关掉
|
||||
ACCESS_LOG = false // 反向同理
|
||||
NODE_OTA = true // 非法值不该动它
|
||||
|
||||
applied, unknown, _ := applyConfigMap(map[string]any{
|
||||
"ipdb": false, // JSON 布尔
|
||||
"access-log": "true", // 字符串
|
||||
"node-ota": "banana", // 非法值
|
||||
}, nil)
|
||||
|
||||
if IPDB_ENABLED {
|
||||
t.Error("ipdb:布尔 false 未生效(仍是开)")
|
||||
}
|
||||
if !ACCESS_LOG {
|
||||
t.Error("access-log:字符串 \"true\" 未生效(仍是关)")
|
||||
}
|
||||
if !NODE_OTA {
|
||||
t.Error("node-ota:收到非法值却把原值 true 改掉了")
|
||||
}
|
||||
if !contains(unknown, "node-ota") {
|
||||
t.Errorf("unknown = %v,期望包含 node-ota", unknown)
|
||||
}
|
||||
for _, k := range []string{"ipdb", "access-log"} {
|
||||
if !contains(applied, k) {
|
||||
t.Errorf("applied = %v,期望包含 %s", applied, k)
|
||||
}
|
||||
}
|
||||
|
||||
// 反向对称:布尔 true 必须能打开、字符串 "false" 必须能关闭。
|
||||
// 这里专门覆盖「布尔」和「字符串」两个方向,防止只测了一个方向就以为等价。
|
||||
IPDB_ENABLED = false
|
||||
ACCESS_LOG = true
|
||||
applied, unknown, _ = applyConfigMap(map[string]any{
|
||||
"ipdb": true,
|
||||
"access-log": "false",
|
||||
}, nil)
|
||||
|
||||
if !IPDB_ENABLED {
|
||||
t.Error("ipdb:布尔 true 未生效(仍是关)")
|
||||
}
|
||||
if ACCESS_LOG {
|
||||
t.Error(`access-log:字符串 "false" 未生效(仍是开)`)
|
||||
}
|
||||
if len(unknown) != 0 {
|
||||
t.Errorf("unknown = %v,期望为空", unknown)
|
||||
}
|
||||
for _, k := range []string{"ipdb", "access-log"} {
|
||||
if !contains(applied, k) {
|
||||
t.Errorf("applied = %v,期望包含 %s", applied, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
-43
@@ -251,7 +251,7 @@ var (
|
||||
sfGroup singleflight.Group
|
||||
V6Client *resty.Client
|
||||
V4Client *resty.Client
|
||||
IPDB string
|
||||
IPDB_ENABLED bool // IP 数据库开关(ipdb / IPDB):缺省开;关闭则不加载数据库、不注册 /v1/location 与 /v1/asn
|
||||
CORS string
|
||||
ACCEPT_DOMAINS []string
|
||||
ACCESS_TOKEN string
|
||||
@@ -267,6 +267,7 @@ var (
|
||||
NODE_OTA bool // OTA 升级开关(node-ota / NODE_OTA):缺省允许收集中心下发;只读容器等不可自更新部署显式设 false,节点拒绝指令并回传原因
|
||||
MAX_RESPONSE_BODY int64 // 单次拨测可读的响应体上限(字节,解压后;max-response-body / MAX_RESPONSE_BODY),<=0 不限制,详见 resources.go
|
||||
MEMORY_LIMIT int64 // Go 运行时软内存上限(字节;memory-limit / MEMORY_LIMIT),<=0 表示自动探测 cgroup,详见 resources.go
|
||||
ACCESS_LOG bool // 访问日志开关(access-log / ACCESS_LOG):缺省开;显式 false 后每个请求不再写一条 slog(panic 兜底与其余日志不受影响),可热更新
|
||||
fiberApp *fiber.App // 显式持有的 Fiber 应用(收到退出信号时优雅停机需要先 Shutdown)
|
||||
VERSION string
|
||||
COMMIT string
|
||||
@@ -648,6 +649,78 @@ func locateUserIP(c fiber.Ctx) error {
|
||||
return c.Status(http.StatusOK).JSON(ipdb.SearchIP(ip))
|
||||
}
|
||||
|
||||
// normalizeASN 把各数据源的 ASN 字符串统一成 "AS"+数字。
|
||||
// 数据源格式不一:maxmind/dbip 的 MMDBASNResult.ASN 自带 "AS" 前缀(searchMMDBASN 拼的),
|
||||
// ip2location 的 asn 是裸数字。含非数字字符时返回空串(视为无效)。
|
||||
func normalizeASN(s string) string {
|
||||
s = strings.TrimSpace(strings.ToUpper(s))
|
||||
s = strings.TrimSpace(strings.TrimPrefix(s, "AS"))
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return "AS" + s
|
||||
}
|
||||
|
||||
// resolveASN 从 SearchIP 结果中按优先级提取可用 ASN(maxmind → dbip → ip2location),返回 "ASxxxx"。
|
||||
//
|
||||
// 之所以要兜底:此前 whois 段只认 maxmind_asn,某个 IP 一旦未命中该库,整段 ASN WHOIS 就凭空消失,
|
||||
// 哪怕 dbip_asn / ip2location_asn 明明有数据。
|
||||
func resolveASN(result map[string]interface{}) string {
|
||||
for _, key := range []string{"maxmind_asn", "dbip_asn"} {
|
||||
if r, ok := result[key].(*ipdb.MMDBASNResult); ok {
|
||||
if asn := normalizeASN(r.ASN); asn != "" {
|
||||
return asn
|
||||
}
|
||||
}
|
||||
}
|
||||
if m, ok := result["ip2location_asn"].(map[string]string); ok {
|
||||
if asn := normalizeASN(m["asn"]); asn != "" {
|
||||
return asn
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// asnWhoisFailed 判定 ASN WHOIS 结果是否算失败,决定缓存 TTL(失败 30s / 成功 5min)。
|
||||
// 注意 likexian/whois 出错时也返回 (result, nil),错误塞在 result.Error 里,不能只看 err 参数。
|
||||
func asnWhoisFailed(r *webtest.ASNWhoisResult) bool {
|
||||
return r == nil || r.Error != "" || (r.ASName == "" && r.OrgName == "")
|
||||
}
|
||||
|
||||
// lookupASNWhois 带缓存的 ASN WHOIS 查询,handler 与 ws 两条路径共用。
|
||||
// 缓存键取归一化后的 ASN("AS13335"):历史上是 "AS"+已带前缀的 ASN,实际拼成了 "ASAS13335"。
|
||||
func lookupASNWhois(asn string) (*webtest.ASNWhoisResult, bool) {
|
||||
asn = normalizeASN(asn)
|
||||
if asn == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if cached, ok := asnWhoisCache.Load(asn); ok {
|
||||
if entry, ok := cached.(asnWhoisCacheEntry); ok && entry.result != nil {
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
return entry.result, true
|
||||
}
|
||||
}
|
||||
asnWhoisCache.Delete(asn)
|
||||
}
|
||||
|
||||
whoisData, err := webtest.QueryASNWhois(asn)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
asnWhoisCache.Store(asn, asnWhoisCacheEntry{
|
||||
result: whoisData,
|
||||
timestamp: time.Now(),
|
||||
failed: asnWhoisFailed(whoisData),
|
||||
})
|
||||
return whoisData, true
|
||||
}
|
||||
|
||||
func asnLookupHandler(c fiber.Ctx) error {
|
||||
ip := c.Params("ip")
|
||||
if ip == "" {
|
||||
@@ -695,22 +768,10 @@ func asnLookupHandler(c fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 WHOIS 对 ASN 进行进一步解析
|
||||
if maxmindASN, ok := result["maxmind_asn"].(*ipdb.MMDBASNResult); ok {
|
||||
asnKey := "AS" + maxmindASN.ASN
|
||||
if cached, ok := asnWhoisCache.Load(asnKey); ok {
|
||||
entry := cached.(asnWhoisCacheEntry)
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
asnResult["whois"] = entry.result
|
||||
} else {
|
||||
asnWhoisCache.Delete(asnKey)
|
||||
}
|
||||
} else {
|
||||
whoisData, err := webtest.QueryASNWhois(maxmindASN.ASN)
|
||||
if err == nil {
|
||||
asnResult["whois"] = whoisData
|
||||
asnWhoisCache.Store(asnKey, asnWhoisCacheEntry{result: whoisData, timestamp: time.Now()})
|
||||
}
|
||||
// 使用 WHOIS 对 ASN 进行进一步解析(maxmind 未命中时回落到 dbip / ip2location)
|
||||
if asn := resolveASN(result); asn != "" {
|
||||
if whoisData, ok := lookupASNWhois(asn); ok {
|
||||
asnResult["whois"] = whoisData
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1055,8 +1116,16 @@ func nodeReportMiddleware() fiber.Handler {
|
||||
// 对应 gin.Default() 里那个 Logger。这里用 fiber 官方的 logger 中间件承接,但把输出改投 slog:
|
||||
// 节点其余日志都走 slog(默认落 stderr),访问日志并进同一路输出,journalctl / 日志采集侧才是统一的;
|
||||
// fiber 内置格式还会按终端能力加 ANSI 颜色,重定向到文件时是转义乱码。
|
||||
//
|
||||
// 开关(access-log / ACCESS_LOG,缺省开):**在请求路径上实时判定**,而不是在启动时决定挂不挂,
|
||||
// 于是 PATCH /v1/config 或远端下发改完即生效、无需重启。关掉后连 logger 的计时与字段拼接都不做
|
||||
// (直接 c.Next() 放行)——访问日志是每请求一次同步写,正是压测里把节点卡住的头号嫌疑,
|
||||
// 关掉才能拿到真实的业务吞吐。
|
||||
//
|
||||
// 注意:它只管访问日志。panic 兜底(recoverMiddleware)与业务/启动日志都不受影响,
|
||||
// 关掉访问日志不会让节点变成"出事无迹可查"。
|
||||
func accessLogMiddleware() fiber.Handler {
|
||||
return logger.New(logger.Config{
|
||||
logHandler := logger.New(logger.Config{
|
||||
// 保留默认模板:它同时决定中间件是否开启 ${latency} 计时(fiber logger.New 里按模板里
|
||||
// 有没有 ${latency} 判断)。真正的输出由下面的 LoggerFunc 接管,不走模板渲染。
|
||||
Format: logger.DefaultFormat,
|
||||
@@ -1081,6 +1150,14 @@ func accessLogMiddleware() fiber.Handler {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
// 关掉时整条中间件短路,不进 logger(见上方注释)。
|
||||
return func(c fiber.Ctx) error {
|
||||
if !ACCESS_LOG {
|
||||
return c.Next()
|
||||
}
|
||||
return logHandler(c)
|
||||
}
|
||||
}
|
||||
|
||||
// recoverMiddleware 兜住 handler 里的 panic:记一条带栈的日志,把响应收敛成 500,而不是让整个进程死掉。
|
||||
@@ -1147,25 +1224,43 @@ func fetchRemoteConfig(url string) (map[string]any, error) {
|
||||
return CONFIG, nil
|
||||
}
|
||||
|
||||
// configValue 返回配置 map 中指定 key 的非空字符串值;
|
||||
// key 不存在或值为空时返回 ""(表示不覆盖本地配置)。
|
||||
func configValue(CONFIG map[string]any, key string) string {
|
||||
v, ok := CONFIG[key]
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
// literalString 把配置源里的**任意字面量**归一成字符串 —— 两条读取路径唯一的转换表:
|
||||
//
|
||||
// 启动:setting.json(经 viper)/ 环境变量 → configString / configBool / configInt / configBytes
|
||||
// 运行:远端下发 / PATCH 的 map[string]any → configValue → applyConfigMap
|
||||
//
|
||||
// 两条路径共用本函数,所以同一个值无论写成 JSON 布尔 `false`、数字 `0` 还是字符串 `"false"`,
|
||||
// 读出来**完全一致**。旧实现里启动路径靠 viper 的隐式 cast、运行路径靠自己的 type switch,
|
||||
// 两套规则只是"碰巧"一致 —— 只要有一边不认布尔,`ipdb: false` 就会被静默当成"开"。
|
||||
//
|
||||
// nil → ""(键不存在或显式 null,一律视为"未配置")
|
||||
// string → 去首尾空白
|
||||
// bool → "true" / "false"
|
||||
// float64 → 十进制整数形式(JSON 数字统一解析成 float64,443.0 要还原成 "443")
|
||||
// 其它 → fmt.Sprint 兜底(数组 / 对象只可能出现在非字符串类键上)
|
||||
func literalString(v any) string {
|
||||
switch s := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return strings.TrimSpace(s)
|
||||
case float64:
|
||||
return fmt.Sprintf("%.0f", s)
|
||||
case bool:
|
||||
return strconv.FormatBool(s)
|
||||
case float64:
|
||||
return fmt.Sprintf("%.0f", s)
|
||||
case int:
|
||||
return strconv.Itoa(s)
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
|
||||
// configValue 返回配置 map 中指定 key 的非空字符串值;
|
||||
// key 不存在或值为空时返回 ""(表示不覆盖本地配置)。
|
||||
func configValue(CONFIG map[string]any, key string) string {
|
||||
return literalString(CONFIG[key])
|
||||
}
|
||||
|
||||
// applyRemoteConfig 从远端配置 URL(REMOTE_CONFIG_URL,可由环境变量或 setting.json
|
||||
// 的 remote-config-url 提供)拉取配置并覆盖本地配置。
|
||||
// 优先级:远端配置 > 环境变量 > setting.json。
|
||||
@@ -1206,11 +1301,13 @@ func applyRemoteConfig() {
|
||||
// 它不是读配置文件;需重启键、热生效键都在那边定义(见 configRestartKeys)。
|
||||
|
||||
// configString 读取字符串配置:环境变量 > setting.json > def。返回值两侧空白已去除。
|
||||
// setting.json 一侧经 literalString 归一,因此布尔 / 数字字面量(`false` / `0` / `443`)
|
||||
// 与字符串写法完全等价(见 literalString)。
|
||||
func configString(envKey, cfgKey, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(envKey)); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(viper.GetString(cfgKey)); v != "" {
|
||||
if v := literalString(viper.Get(cfgKey)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
@@ -1262,17 +1359,54 @@ func configStringSlice(envKey, cfgKey string) []string {
|
||||
return list
|
||||
}
|
||||
|
||||
// configDisabled 判断开关类配置是否被显式关闭:仅 "false" / "0"(大小写不敏感)。
|
||||
// 其余值(含空串、拼错的值)一律视为开启 —— 与组件历史口径一致:安全开关默认开,
|
||||
// 只有"确实写对了关闭值"才关。
|
||||
// parseBoolSwitch 开关类字面值的**唯一**解析表:四个开关(ipdb / block-private-ips /
|
||||
// node-ota / access-log)在启动读取、远端下发、运行时 PATCH 三条路径上都走这里。
|
||||
//
|
||||
// 用到的地方:block-private-ips(启动在这里、热更新在 applyConfigMap)、node-ota。
|
||||
func configDisabled(v string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "false", "0":
|
||||
return true
|
||||
// 统一之前的四种口径(同一个 "no" 在 node-ota 上是关、在另外三个上静默当开,运维侧推不出来):
|
||||
//
|
||||
// ipdb IPDB != "false" —— 大小写敏感,连 "False" 都算开
|
||||
// block-private-ips 仅 "false"/"0" 算关 —— "no"/"off" 静默当开
|
||||
// access-log 仅 "false"/"0" 算关 —— 同上
|
||||
// node-ota true/1/yes/on + false/0/no/off —— 唯一认 yes/no 的
|
||||
//
|
||||
// 现在四条共用一张表(忽略大小写与首尾空白):
|
||||
//
|
||||
// 真:true / 1 / yes / on / enable / enabled
|
||||
// 假:false / 0 / no / off / disable / disabled
|
||||
//
|
||||
// 入参是**已归一的字符串**:启动路径由 configString 给出、运行路径由 configValue 给出,
|
||||
// 两者都先过 literalString —— 所以 setting.json 里写布尔 `false` 到这里就是 "false",
|
||||
// 与手写字符串 "false" 走完全相同的判定,不需要本函数再判类型。
|
||||
//
|
||||
// 空串(未配置)与不认识的串一律返回 known=false —— 本函数只做"字面值 → 布尔"的翻译,
|
||||
// 不替调用方决定默认值。由调用方处置:启动阶段告警并落默认值,PATCH / 远端下发计入 unknown
|
||||
// 并保留原值 —— 关键点是**绝不静默把拼错的值当成"开"**,那正是旧口径最难查的地方。
|
||||
func parseBoolSwitch(raw string) (val, known bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "true", "1", "yes", "on", "enable", "enabled":
|
||||
return true, true
|
||||
case "false", "0", "no", "off", "disable", "disabled":
|
||||
return false, true
|
||||
}
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
|
||||
// configBool 读取开关类配置(取值口径 env > setting.json > def),raw 经 configString 归一
|
||||
// (因此 JSON 布尔 / 数字字面量同样识别),字面值交 parseBoolSwitch。
|
||||
// 值不认识的告警并落 def —— 启动阶段没有"回报给请求方"的通道,只能告警。
|
||||
// 运行中的 PATCH 不走这里,走 applyConfigMap 里的 parseBoolSwitch 以便计入 unknown。
|
||||
func configBool(envKey, cfgKey string, def bool) bool {
|
||||
raw := configString(envKey, cfgKey, "")
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return def
|
||||
}
|
||||
val, known := parseBoolSwitch(raw)
|
||||
if !known {
|
||||
slog.Warn("Invalid boolean config, falling back to default",
|
||||
"key", cfgKey, "value", raw, "default", def)
|
||||
return def
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// readConfig 启动配置的唯一读取入口(取值口径与职责边界见上方注释块)。
|
||||
@@ -1288,7 +1422,10 @@ func readConfig() {
|
||||
PORTS = configString("PORTS", "port", "8080")
|
||||
GH_PROXY = configString("GH_PROXY", "gh-proxy", "")
|
||||
CORS = configString("CORS", "cors", "")
|
||||
IPDB = configString("IPDB", "ipdb", "")
|
||||
// IP 数据库开关:缺省开启(首次启动自动下载约 450MB)。
|
||||
// 与另外三个开关同一口径(configBool),不再用 `IPDB != "false"` 那种大小写敏感的字符串比较
|
||||
// —— 旧写法下 `ipdb: "False"` / `"no"` 都会被当成"开",同一个值跟 node-ota 的判定还相反。
|
||||
IPDB_ENABLED = configBool("IPDB", "ipdb", true)
|
||||
TRUSTED_PROXIES = configString("TRUSTED_PROXIES", "trusted-proxies", "")
|
||||
ACCESS_TOKEN = configString("ACCESS_TOKEN", "access-token", "")
|
||||
|
||||
@@ -1303,9 +1440,13 @@ func readConfig() {
|
||||
// SSRF 防护开关:缺省开启(见 ssrf 包)。
|
||||
// 注意必须在 viper.ReadInConfig 之后读:早期版本只读 ENV,setting.json 里的同名键在启动阶段
|
||||
// 被静默忽略(只有运行中 patch / 远端配置才生效),与其余键的"env > setting.json"口径不一致。
|
||||
ssrf.SetEnabled(!configDisabled(configString("BLOCK_PRIVATE_IPS", "block-private-ips", "true")))
|
||||
// OTA 升级开关:仅显式关闭才禁用(缺省允许收集中心下发 OTA,见 ota.go)
|
||||
NODE_OTA, _ = parseOTASwitch(configString("NODE_OTA", "node-ota", "true"))
|
||||
ssrf.SetEnabled(configBool("BLOCK_PRIVATE_IPS", "block-private-ips", true))
|
||||
// OTA 升级开关:缺省允许收集中心下发 OTA(见 ota.go)
|
||||
NODE_OTA = configBool("NODE_OTA", "node-ota", true)
|
||||
|
||||
// 访问日志开关:缺省开启(见 accessLogMiddleware)。
|
||||
// 判定在请求路径上做,故运行中热改即时生效。
|
||||
ACCESS_LOG = configBool("ACCESS_LOG", "access-log", true)
|
||||
|
||||
// —— 远端配置源 ——
|
||||
REMOTE_CONFIG_URL = configString("REMOTE_CONFIG_URL", "remote-config-url", "")
|
||||
@@ -1500,7 +1641,7 @@ func newFiberApp() *fiber.App {
|
||||
bizGet(v1, "/whois/:domain", whoisHandler)
|
||||
bizGet(v1, "/speed/:version/*", websiteSpeedTestHandler)
|
||||
|
||||
if IPDB != "false" {
|
||||
if IPDB_ENABLED {
|
||||
bizGet(v1, "/location/:ip", locateIP)
|
||||
bizGet(v1, "/location", locateUserIP)
|
||||
bizGet(v1, "/asn/:ip", asnLookupHandler)
|
||||
@@ -1558,7 +1699,7 @@ func main() {
|
||||
initHTTPClients()
|
||||
// 注入出站 HTTP 客户端到 webtest(探针函数内部按版本取用)
|
||||
webtest.SetHTTPClient(V4Client, V6Client)
|
||||
if IPDB != "false" {
|
||||
if IPDB_ENABLED {
|
||||
ipdb.Init(GH_PROXY)
|
||||
}
|
||||
|
||||
|
||||
+5
-15
@@ -51,22 +51,12 @@ const (
|
||||
// otaAssetBaseDefault 按版本下发且未指定 assetBase 时的默认发布地址
|
||||
const otaAssetBaseDefault = "https://github.com/nomdn/ipw-cn/releases/download"
|
||||
|
||||
// parseOTASwitch 解析 OTA 开关的字面值——启动加载(ENV / setting.json)、远端下发、运行时 PATCH
|
||||
// 三处共用,保证什么算关闭的口径只有一份。
|
||||
// OTA 开关(node-ota)的字面值解析已并入 main.go 的 parseBoolSwitch —— 原先这里有一份
|
||||
// 独立的 parseOTASwitch(唯一认 yes/no 的),与 ipdb / block-private-ips / access-log
|
||||
// 各写一套,同一个字面值在四个开关上判定不一致。现在四条路径共用一张表。
|
||||
//
|
||||
// 语义是缺省允许、显式关闭(见本文件头):true/1/yes/on 与空值都算开启,
|
||||
// false/0/no/off 算关闭。known=false 表示写法认不出来——运行时 PATCH 据此回
|
||||
// unknown 且不覆盖原值,启动期与远端下发则按缺省(允许)处理,不因一个手滑的
|
||||
// 字面值把节点锁死。
|
||||
func parseOTASwitch(raw string) (enabled, known bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "", "true", "1", "yes", "on":
|
||||
return true, true
|
||||
case "false", "0", "no", "off":
|
||||
return false, true
|
||||
}
|
||||
return true, false
|
||||
}
|
||||
// 语义不变:缺省允许、显式关闭。启动时写法认不出来按缺省(允许)处理并告警,
|
||||
// 运行时 PATCH 则回 unknown 且不覆盖原值。
|
||||
|
||||
// otaMu 单飞:同一时刻只允许一个 OTA 任务(重复下发直接拒绝)
|
||||
var otaMu sync.Mutex
|
||||
|
||||
@@ -4,20 +4,10 @@
|
||||
"single-stack": "",
|
||||
"dns-server": "",
|
||||
"dnssec-server": "",
|
||||
"block-private-ips": true,
|
||||
"ipdb": "false",
|
||||
"ipdb": false,
|
||||
"cors": "",
|
||||
"trusted-proxies": "",
|
||||
"remote-config-url": "",
|
||||
"remote-ignore-config": [],
|
||||
"access-token": "",
|
||||
"ws-url": "",
|
||||
"node-id": "",
|
||||
"node-key": "",
|
||||
"node-ota": "true",
|
||||
"report-url": "",
|
||||
"report-token": "",
|
||||
"report-interval-seconds": 15,
|
||||
"max-response-body": "32MB",
|
||||
"memory-limit": ""
|
||||
}
|
||||
"node-key": ""
|
||||
}
|
||||
+173
-52
@@ -614,63 +614,184 @@ type ASNWhoisResult struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// asnFieldPatterns 用于从原始 ASN WHOIS 文本中提取字段
|
||||
var asnFieldPatterns = map[string]*regexp.Regexp{
|
||||
"asNumber": regexp.MustCompile(`(?i)^\s*ASNumber\s*[:=]\s*(.+?)\s*$`),
|
||||
"asName": regexp.MustCompile(`(?i)^\s*ASName\s*[:=]\s*(.+?)\s*$`),
|
||||
"asHandle": regexp.MustCompile(`(?i)^\s*ASHandle\s*[:=]\s*(.+?)\s*$`),
|
||||
"regDate": regexp.MustCompile(`(?i)^\s*RegDate\s*[:=]\s*(.+?)\s*$`),
|
||||
"updated": regexp.MustCompile(`(?i)^\s*Updated\s*[:=]\s*(.+?)\s*$`),
|
||||
"orgName": regexp.MustCompile(`(?i)^\s*OrgName\s*[:=]\s*(.+?)\s*$`),
|
||||
"orgId": regexp.MustCompile(`(?i)^\s*OrgId\s*[:=]\s*(.+?)\s*$`),
|
||||
"country": regexp.MustCompile(`(?i)^\s*Country\s*[:=]\s*([A-Z]{2})\s*$`),
|
||||
"abuseName": regexp.MustCompile(`(?i)^\s*OrgAbuseName\s*[:=]\s*(.+?)\s*$`),
|
||||
"abuseEmail": regexp.MustCompile(`(?i)^\s*OrgAbuseEmail\s*[:=]\s*(.+?)\s*$`),
|
||||
"abusePhone": regexp.MustCompile(`(?i)^\s*OrgAbusePhone\s*[:=]\s*(.+?)\s*$`),
|
||||
// ==================== ASN WHOIS 多 RIR 解析 ====================
|
||||
//
|
||||
// QueryASNWhois 走 likexian/whois 的默认链路:先问 IANA(IANA 按 ASN 给出归属 RIR),
|
||||
// 再取该 RIR 的记录;若 ARIN 只回了「转介桩」,库会再跟一跳取到真实记录。
|
||||
// 因此 raw 里可能同时存在两段内容,且字段名随 RIR 而异:
|
||||
//
|
||||
// ARIN : ASNumber / ASName / OrgName / OrgId / Country / RegDate / Updated / OrgAbuse*
|
||||
// APNIC : aut-num / as-name / descr / country / org / last-modified(无 org-name 时组织名在 descr)
|
||||
// RIPE : aut-num / as-name / org / org-name / country / created / last-modified
|
||||
// LACNIC : aut-num / owner / ownerid / country / created / changed
|
||||
//
|
||||
// 改造前只认 ARIN 字段 ⇒ 除 ARIN 注册的 ASN(Cloudflare AS13335、Google AS15169 等)外,
|
||||
// 其余 RIR 的 ASN 全部解析为空(如中国电信 AS4134 只拿到 ARIN 转介桩的 "APNIC-4134")。
|
||||
//
|
||||
// 现在的规则:raw 里**存在 RIR 原生 aut-num 记录时一律以它为准,并整体忽略 ARIN 段**
|
||||
// (ARIN 对非 ARIN 的 ASN 只回桩,其 ASName/OrgName/OrgAbuseEmail 都是误导信息);
|
||||
// 没有原生记录时才按 ARIN 格式解析。
|
||||
var (
|
||||
// 原生(APNIC/RIPE/LACNIC/AFRINIC)字段
|
||||
reASNAutNum = regexp.MustCompile(`(?im)^\s*aut-num:\s*AS?(\d+)\s*$`)
|
||||
reASNAsName = regexp.MustCompile(`(?im)^\s*as-name:\s*(.+?)\s*$`)
|
||||
reASNOrgName = regexp.MustCompile(`(?im)^\s*org-name:\s*(.+?)\s*$`)
|
||||
reASNOwner = regexp.MustCompile(`(?im)^\s*owner:\s*(.+?)\s*$`)
|
||||
reASNOwnerID = regexp.MustCompile(`(?im)^\s*ownerid:\s*(.+?)\s*$`)
|
||||
reASNOrgRef = regexp.MustCompile(`(?im)^\s*org:\s*(.+?)\s*$`)
|
||||
reASNDescr = regexp.MustCompile(`(?im)^\s*descr:\s*(.+?)\s*$`)
|
||||
reASNCreated = regexp.MustCompile(`(?im)^\s*created:\s*(.+?)\s*$`)
|
||||
reASNRegistered = regexp.MustCompile(`(?im)^\s*registered:\s*(.+?)\s*$`)
|
||||
reASNLastMod = regexp.MustCompile(`(?im)^\s*last-modified:\s*(.+?)\s*$`)
|
||||
reASNChanged = regexp.MustCompile(`(?im)^\s*changed:\s*(.+?)\s*$`)
|
||||
reASNMailbox = regexp.MustCompile(`(?im)^\s*abuse-mailbox:\s*(\S+@\S+)\s*$`)
|
||||
// APNIC/RIPE 会在响应顶部给一行注释:% Abuse contact for 'AS37963' is 'xxx@yyy'
|
||||
reASNCommentAbuse = regexp.MustCompile(`(?i)%\s*Abuse contact for '?\s*AS\d+\s*'?\s*is\s*'?([^'\s]+)'?`)
|
||||
|
||||
// ARIN 字段(保持改造前语义;注意:这些正则在「多行整段」文本上用,必须带 m 标志)
|
||||
reASNNumberArin = regexp.MustCompile(`(?im)^\s*ASNumber\s*[:=]\s*(.+?)\s*$`)
|
||||
reASNNameArin = regexp.MustCompile(`(?im)^\s*ASName\s*[:=]\s*(.+?)\s*$`)
|
||||
reASNHandleArin = regexp.MustCompile(`(?im)^\s*ASHandle\s*[:=]\s*(.+?)\s*$`)
|
||||
reASNRegDateArin = regexp.MustCompile(`(?im)^\s*RegDate\s*[:=]\s*(.+?)\s*$`)
|
||||
reASNUpdatedArin = regexp.MustCompile(`(?im)^\s*Updated\s*[:=]\s*(.+?)\s*$`)
|
||||
reASNOrgNameArin = regexp.MustCompile(`(?im)^\s*OrgName\s*[:=]\s*(.+?)\s*$`)
|
||||
reASNOrgIDArin = regexp.MustCompile(`(?im)^\s*OrgId\s*[:=]\s*(.+?)\s*$`)
|
||||
reASNCountryArin = regexp.MustCompile(`(?im)^\s*Country\s*[:=]\s*([A-Z]{2})\s*$`)
|
||||
reASNAbuseName = regexp.MustCompile(`(?im)^\s*OrgAbuseName\s*[:=]\s*(.+?)\s*$`)
|
||||
reASNAbuseEmail = regexp.MustCompile(`(?im)^\s*OrgAbuseEmail\s*[:=]\s*(.+?)\s*$`)
|
||||
reASNAbusePhone = regexp.MustCompile(`(?im)^\s*OrgAbusePhone\s*[:=]\s*(.+?)\s*$`)
|
||||
|
||||
// RIR 通用的「非 ASN 记录块」噪音(as-block 对象的 descr,如 "APNIC ASN block")
|
||||
reASNBlockNoise = regexp.MustCompile(`(?i)(^|\s)ASN? block\s*$`)
|
||||
// 滥用联系人的对象名:RIPE 用 irt:,APNIC 的 abuse-c 对象用 role:
|
||||
reASNRoleName = regexp.MustCompile(`(?im)^\s*(?:irt|role):\s*(.+?)\s*$`)
|
||||
reASNPhone = regexp.MustCompile(`(?im)^\s*phone:\s*(.+?)\s*$`)
|
||||
)
|
||||
|
||||
// splitWhoisBlocks 按空行把响应切成记录块(每个 RIR 对象一段)
|
||||
func splitWhoisBlocks(raw string) []string {
|
||||
raw = strings.ReplaceAll(raw, "\r\n", "\n")
|
||||
return strings.Split(raw, "\n\n")
|
||||
}
|
||||
|
||||
// parseASNWhoisRaw 从原始 ASN WHOIS 响应中解析结构化数据
|
||||
func parseASNWhoisRaw(raw string) *ASNWhoisResult {
|
||||
result := &ASNWhoisResult{Raw: raw}
|
||||
lines := strings.Split(raw, "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
for field, pattern := range asnFieldPatterns {
|
||||
if matches := pattern.FindStringSubmatch(line); len(matches) == 2 {
|
||||
switch field {
|
||||
case "asNumber":
|
||||
result.ASNumber = matches[1]
|
||||
case "asName":
|
||||
result.ASName = matches[1]
|
||||
case "asHandle":
|
||||
if result.ASNumber == "" {
|
||||
result.ASNumber = strings.TrimPrefix(matches[1], "AS")
|
||||
}
|
||||
case "regDate":
|
||||
result.RegDate = matches[1]
|
||||
case "updated":
|
||||
result.Updated = matches[1]
|
||||
case "orgName":
|
||||
result.OrgName = matches[1]
|
||||
case "orgId":
|
||||
result.OrgID = matches[1]
|
||||
case "country":
|
||||
result.Country = matches[1]
|
||||
case "abuseName":
|
||||
result.AbuseName = matches[1]
|
||||
case "abuseEmail":
|
||||
result.AbuseEmail = matches[1]
|
||||
case "abusePhone":
|
||||
result.AbusePhone = matches[1]
|
||||
}
|
||||
// pickField 按给定优先级在文本中取第一个命中的字段值;全不命中返回 ""
|
||||
func pickField(text string, patterns ...*regexp.Regexp) string {
|
||||
for _, pattern := range patterns {
|
||||
if m := pattern.FindStringSubmatch(text); len(m) == 2 {
|
||||
if v := strings.TrimSpace(m[1]); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findASNNativeBlock 找出含 aut-num 的原生记录块;没有返回 ""
|
||||
func findASNNativeBlock(raw string) string {
|
||||
for _, block := range splitWhoisBlocks(raw) {
|
||||
if reASNAutNum.MatchString(block) {
|
||||
return block
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findASNOrgBlock 按原生块里的 org: 句柄定位对应的 organisation/owner 对象
|
||||
// (RIPE 的 org-name 与 country 不在 aut-num 对象里,而在 organisation 对象里)
|
||||
func findASNOrgBlock(raw, nativeBlock string) string {
|
||||
handle := pickField(nativeBlock, reASNOrgRef)
|
||||
if handle == "" {
|
||||
return ""
|
||||
}
|
||||
for _, block := range splitWhoisBlocks(raw) {
|
||||
if strings.Contains(block, handle) && (reASNOrgName.MatchString(block) || reASNOwner.MatchString(block)) {
|
||||
return block
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findASNOrgName 取组织名:organisation 对象的 org-name/owner(RIPE/APNIC/JPNIC)
|
||||
// → 原生块首行 descr(APNIC 常见,如 "Hangzhou Alibaba Advertising Co.,Ltd."),并过滤 block 噪音
|
||||
func findASNOrgName(orgBlock, nativeBlock string) string {
|
||||
if v := pickField(orgBlock, reASNOrgName, reASNOwner); v != "" {
|
||||
return v
|
||||
}
|
||||
for _, m := range reASNDescr.FindAllStringSubmatch(nativeBlock, -1) {
|
||||
v := strings.TrimSpace(m[1])
|
||||
if v == "" || reASNBlockNoise.MatchString(v) {
|
||||
continue
|
||||
}
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findASNCommentAbuseEmail 取响应顶部注释里的 Abuse 邮箱(APNIC/RIPE 提供)
|
||||
func findASNCommentAbuseEmail(raw string) string {
|
||||
if m := reASNCommentAbuse.FindStringSubmatch(raw); len(m) == 2 {
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findASNMailboxAbuse 取含 abuse-mailbox 的记录块里的联系人名与电话(RIPE 的 irt:、APNIC 的 role:)
|
||||
func findASNMailboxAbuse(raw string) (name, email, phone string) {
|
||||
for _, block := range splitWhoisBlocks(raw) {
|
||||
mailbox := pickField(block, reASNMailbox)
|
||||
if mailbox == "" {
|
||||
continue
|
||||
}
|
||||
return pickField(block, reASNRoleName), mailbox, pickField(block, reASNPhone)
|
||||
}
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
// parseASNWhoisRaw 从原始 ASN WHOIS 响应中解析结构化数据(多 RIR)
|
||||
func parseASNWhoisRaw(raw string) *ASNWhoisResult {
|
||||
result := &ASNWhoisResult{Raw: raw}
|
||||
|
||||
// 情况一:有 RIR 原生记录(APNIC/RIPE/LACNIC/AFRINIC),只认它
|
||||
if nativeBlock := findASNNativeBlock(raw); nativeBlock != "" {
|
||||
orgBlock := findASNOrgBlock(raw, nativeBlock)
|
||||
result.ASNumber = pickField(nativeBlock, reASNAutNum, reASNNumberArin)
|
||||
result.ASName = pickField(nativeBlock, reASNAsName)
|
||||
result.OrgName = findASNOrgName(orgBlock, nativeBlock)
|
||||
result.OrgID = pickField(nativeBlock, reASNOrgRef, reASNOwnerID)
|
||||
result.Country = pickField(nativeBlock, reASNCountryArin)
|
||||
if result.Country == "" {
|
||||
result.Country = pickField(orgBlock, reASNCountryArin)
|
||||
}
|
||||
result.RegDate = pickField(nativeBlock, reASNCreated, reASNRegistered, reASNRegDateArin)
|
||||
result.Updated = pickField(nativeBlock, reASNLastMod, reASNUpdatedArin, reASNChanged)
|
||||
|
||||
// 滥用联系人:先用注释行(最权威),再用 abuse-mailbox 所在对象
|
||||
result.AbuseEmail = findASNCommentAbuseEmail(raw)
|
||||
name, email, phone := findASNMailboxAbuse(raw)
|
||||
if result.AbuseName == "" {
|
||||
result.AbuseName = name
|
||||
}
|
||||
if result.AbuseEmail == "" {
|
||||
result.AbuseEmail = email
|
||||
}
|
||||
result.AbusePhone = phone
|
||||
return result
|
||||
}
|
||||
|
||||
// 情况二:ARIN 格式(ARIN 自有的 ASN),保持改造前行为
|
||||
result.ASNumber = pickField(raw, reASNNumberArin)
|
||||
if result.ASNumber == "" {
|
||||
result.ASNumber = strings.TrimPrefix(pickField(raw, reASNHandleArin), "AS")
|
||||
}
|
||||
result.ASName = pickField(raw, reASNNameArin)
|
||||
result.OrgName = pickField(raw, reASNOrgNameArin)
|
||||
result.OrgID = pickField(raw, reASNOrgIDArin)
|
||||
result.Country = pickField(raw, reASNCountryArin)
|
||||
result.RegDate = pickField(raw, reASNRegDateArin)
|
||||
result.Updated = pickField(raw, reASNUpdatedArin)
|
||||
result.AbuseName = pickField(raw, reASNAbuseName)
|
||||
result.AbuseEmail = pickField(raw, reASNAbuseEmail)
|
||||
result.AbusePhone = pickField(raw, reASNAbusePhone)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -234,9 +234,9 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
} else if errStr, ok := result["dbip_asn"].(string); ok {
|
||||
asnResult["dbip_asn"] = map[string]string{"error": errStr}
|
||||
}
|
||||
if maxmindASN, ok := result["maxmind_asn"].(*ipdb.MMDBASNResult); ok {
|
||||
whoisData, err := webtest.QueryASNWhois(maxmindASN.ASN)
|
||||
if err == nil {
|
||||
// WHOIS 进一步解析(与 /v1/asn/:ip 走同一套兜底与缓存)
|
||||
if asn := resolveASN(result); asn != "" {
|
||||
if whoisData, ok := lookupASNWhois(asn); ok {
|
||||
asnResult["whois"] = whoisData
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user