Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1790742a3 | ||
|
|
6b9f6b6b54 | ||
|
|
cfabb06f73 | ||
|
|
372cc62473 | ||
|
|
e9b3a75f02 | ||
|
|
4ce9637918 | ||
|
|
56c08f3629 | ||
|
|
949f71ca16 | ||
|
|
c0a8f7e55c |
No files matched your search
@@ -17,7 +17,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.26.2' # 选择适合的 Go 版本
|
||||
go-version: '1.26.6' # 与 src/go.mod 声明保持一致,避免依赖 GOTOOLCHAIN 隐式下载
|
||||
- name: Set version metadata
|
||||
run: |
|
||||
echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
name: Docker Multi-Arch
|
||||
|
||||
# 打 v* 标签发版时构建多架构镜像并推送到 GHCR + Docker Hub。
|
||||
# ARM 用 GitHub 原生 arm64 实例(ubuntu-24.04-arm,公开仓库免费)交叉编译,全程无 QEMU:
|
||||
# - ubuntu-latest → linux/amd64(原生)
|
||||
# - ubuntu-24.04-arm → linux/arm64(原生)、linux/arm/v7(Go 交叉编译,GOARM 由 Dockerfile 处理)
|
||||
# 各平台按 digest 推到 GHCR,最后由 merge job 用 buildx imagetools 合并成多架构
|
||||
# manifest list,同时打上 GHCR 与 Docker Hub 的标签。
|
||||
#
|
||||
# Docker Hub 推送为可选:需要仓库 Secrets 配置 DOCKERHUB_USERNAME / DOCKERHUB_TOKEN,
|
||||
# 未配置时只推 GHCR,不影响流程。
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# 纯 include 模式:每条自带全部字段,独立成一个任务。
|
||||
# 注意不能把 image 写成顶层数组再用 include 拆——GitHub 会把能匹配
|
||||
# 现有组合的 include 条目合并进组合本身,导致任务被折叠(见 v3.3.0 事故)
|
||||
include:
|
||||
- image: lemon-ipw
|
||||
context: src
|
||||
runner: ubuntu-latest
|
||||
platforms: linux/amd64
|
||||
platform_id: amd64
|
||||
- image: lemon-ipw
|
||||
context: src
|
||||
runner: ubuntu-24.04-arm
|
||||
platforms: linux/arm64
|
||||
platform_id: arm64
|
||||
- image: lemon-ipw
|
||||
context: src
|
||||
runner: ubuntu-24.04-arm
|
||||
platforms: linux/arm/v7
|
||||
platform_id: armv7
|
||||
- image: lemon-ipw-middleware
|
||||
context: middleware-go
|
||||
runner: ubuntu-latest
|
||||
platforms: linux/amd64
|
||||
platform_id: amd64
|
||||
- image: lemon-ipw-middleware
|
||||
context: middleware-go
|
||||
runner: ubuntu-24.04-arm
|
||||
platforms: linux/arm64
|
||||
platform_id: arm64
|
||||
- image: lemon-ipw-middleware
|
||||
context: middleware-go
|
||||
runner: ubuntu-24.04-arm
|
||||
platforms: linux/arm/v7
|
||||
platform_id: armv7
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}/${{ matrix.image }}
|
||||
|
||||
# push-by-digest 模式:不传 tags(新 buildx 禁止 tagged ref 按 digest 推送),
|
||||
# 仓库名通过 outputs 的 name= 传入,最终 tag 由 merge 阶段统一打
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=ghcr.io/${{ github.repository }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.image }}-${{ matrix.platform_id }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.image }}-${{ matrix.platform_id }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p "${{ runner.temp }}/digests"
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
# 文件名自带镜像与平台元数据:merge 阶段 merge-multiple 平铺下载后
|
||||
# 按前缀收集,不依赖 artifact 的目录结构
|
||||
touch "${{ runner.temp }}/digests/${{ matrix.image }}-${{ matrix.platform_id }}-${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ matrix.image }}-${{ matrix.platform_id }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# ===== Docker Hub 推送暂时停用 =====
|
||||
# 需要启用时:取消注释本步骤与下方 merge 脚本中的 docker.io 标签行,
|
||||
# 并在仓库 Secrets 配置 DOCKERHUB_USERNAME / DOCKERHUB_TOKEN
|
||||
# - name: Log in to Docker Hub
|
||||
# if: ${{ secrets.DOCKERHUB_USERNAME != '' }}
|
||||
# uses: docker/login-action@v3
|
||||
# with:
|
||||
# username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
# password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create and push multi-arch manifest lists
|
||||
# DOCKERHUB_USERNAME 暂不注入(Docker Hub 推送停用,见上方登录步骤注释)
|
||||
# env:
|
||||
# DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DIGESTS_DIR="${{ runner.temp }}/digests"
|
||||
GHCR_REPO="ghcr.io/${GITHUB_REPOSITORY,,}"
|
||||
|
||||
# 标签:v1.2.3 → ["1.2.3", "1.2"];手动触发 → ["main"]
|
||||
if [[ "${GITHUB_REF_NAME}" == v* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
MINOR="$(echo "${VERSION}" | cut -d. -f1,2)"
|
||||
TAGS=("${VERSION}" "${MINOR}")
|
||||
else
|
||||
TAGS=("main")
|
||||
fi
|
||||
|
||||
for IMG in lemon-ipw lemon-ipw-middleware; do
|
||||
ARGS=()
|
||||
for TAG in "${TAGS[@]}"; do
|
||||
ARGS+=(-t "${GHCR_REPO}/${IMG}:${TAG}")
|
||||
# Docker Hub 推送停用;启用时取消注释并恢复上方 DOCKERHUB_USERNAME env
|
||||
# if [ -n "${DOCKERHUB_USERNAME}" ]; then
|
||||
# ARGS+=(-t "docker.io/${DOCKERHUB_USERNAME,,}/${IMG}:${TAG}")
|
||||
# fi
|
||||
done
|
||||
# 各平台单架构 manifest(GHCR 上按 digest 推送的产物)合并为一份
|
||||
# 多架构 manifest list;imagetools create 会按 -t 目标推送到 GHCR
|
||||
shopt -s nullglob
|
||||
for PLATFORM_ID in amd64 arm64 armv7; do
|
||||
PREFIX="${IMG}-${PLATFORM_ID}-"
|
||||
FILES=("${DIGESTS_DIR}"/${PREFIX}*)
|
||||
if [ ${#FILES[@]} -eq 0 ]; then
|
||||
echo "ERROR: no digest file for ${IMG} (${PLATFORM_ID}); dir contents:" >&2
|
||||
ls -la "${DIGESTS_DIR}" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
for f in "${FILES[@]}"; do
|
||||
hex="$(basename "${f}")"
|
||||
ARGS+=("${GHCR_REPO}/${IMG}@sha256:${hex#${PREFIX}}")
|
||||
done
|
||||
done
|
||||
shopt -u nullglob
|
||||
echo "Merging ${#ARGS[@]} refs for ${IMG}:"; printf ' %s\n' "${ARGS[@]}"
|
||||
docker buildx imagetools create "${ARGS[@]}"
|
||||
done
|
||||
echo "Pushed multi-arch manifests: ${TAGS[*]}"
|
||||
+11
-2
@@ -23,12 +23,14 @@
|
||||
- `block-private-ips`:SSRF 防护开关
|
||||
- `ipdb`:IP 数据库开关(首次启动自动下载约 450MB,之后每 24h 更新)
|
||||
- `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 访问令牌,留空则不启用鉴权
|
||||
- `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)
|
||||
- `node-ota`:**OTA 自更新开关**(`"true"` 启用)。启用后节点定期检查本仓库 GitHub Release,发现新版本时下载与当前平台匹配的二进制、**替换自身并重启**(旧二进制保留为 `<程序名>.old` 便于回滚);默认关闭
|
||||
|
||||
以上字段均可用环境变量覆盖(`PORTS` / `DNS_SERVER` / `DNSSEC_DNS_SERVER` / `BLOCK_PRIVATE_IPS` / `IPDB` / `CORS` / `ACCESS_TOKEN` / `WS_URL` / `NODE_ID` / `NODE_KEY`)。需要从远端拉取配置时,设置 `remote-config-url` 或环境变量 `REMOTE_CONFIG_URL`(优先级:远端 > 环境变量 > setting.json)。
|
||||
以上字段均可用环境变量覆盖(`PORTS` / `DNS_SERVER` / `DNSSEC_DNS_SERVER` / `BLOCK_PRIVATE_IPS` / `IPDB` / `CORS` / `TRUSTED_PROXIES` / `ACCESS_TOKEN` / `WS_URL` / `NODE_ID` / `NODE_KEY` / `NODE_OTA`)。需要从远端拉取配置时,设置 `remote-config-url` 或环境变量 `REMOTE_CONFIG_URL`(优先级:远端 > 环境变量 > setting.json)。
|
||||
|
||||
---
|
||||
|
||||
@@ -150,6 +152,9 @@ APIBaseURL: {
|
||||
"port": "8091",
|
||||
"http-timeout-seconds": 30,
|
||||
"cors": "",
|
||||
"trusted-proxies": "",
|
||||
"gh-proxy": "",
|
||||
"ota": "false",
|
||||
"rate-limit": 120,
|
||||
"ws-port": 8092,
|
||||
"remote-config-url": "",
|
||||
@@ -166,6 +171,9 @@ APIBaseURL: {
|
||||
```
|
||||
|
||||
- `rate-limit`:单 IP 每分钟限流次数,默认 `120`,`0` 表示不限流(也可用环境变量 `RATE_LIMIT` 覆盖)
|
||||
- `trusted-proxies`:可信代理 IP/CIDR(逗号分隔)。配置后 `c.IP()`(限流按它分桶)只信任这些代理转发的 `X-Forwarded-For`,反代/CDN 后限流才按真实客户端 IP 计数;留空保持默认(不信任 XFF 头,按直连地址限流)。也可用环境变量 `TRUSTED_PROXIES` 覆盖
|
||||
- `gh-proxy`:GitHub 代理前缀(OTA 下载加速,如 `"https://ghproxy.com/"`),可空;也可用环境变量 `GH_PROXY` 覆盖
|
||||
- `ota`:**OTA 自更新开关**(`"true"` 启用)。启用后中间件定期检查本仓库 GitHub Release,发现新版本时下载匹配的 `middleware-go-*` 资产、预检、**替换自身并重启**(Windows 下为优雅交接,失败自动回滚旧版;major 版本升级需人工);默认关闭。也可用环境变量 `OTA` 覆盖
|
||||
- `ws-port`:WS 服务端口,默认 `8092`,`0` 表示关闭 WS 通道(也可用环境变量 `WS_PORT` 覆盖;优先级:远端配置 > 环境变量 > setting.json)
|
||||
- `remote-config-url`:远端配置地址,也可用环境变量 `REMOTE_CONFIG_URL`(环境变量优先)
|
||||
- `remote-ignore-config`:**不被远端配置覆盖的配置项列表**(数组),如 `["port", "rate-limit"]`;也可用环境变量 `REMOTE_IGNORE_CONFIG`(JSON 数组字符串,优先于 setting.json)
|
||||
@@ -243,8 +251,9 @@ REMOTE_CONFIG_URL=https://example.com/ipw-config.json ./lemonipw
|
||||
{
|
||||
"port": "8091",
|
||||
"cors": "",
|
||||
"trusted-proxies": "",
|
||||
"rate-limit": 120,
|
||||
"APIBaseURL": {
|
||||
"api-base-url": {
|
||||
"DualStack": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
|
||||
"IPv4": [],
|
||||
"IPv6": []
|
||||
|
||||
@@ -17,17 +17,28 @@
|
||||
|
||||
## 方案一:Docker
|
||||
|
||||
`middleware-go/` 目录提供 `Dockerfile`:
|
||||
`middleware-go/` 目录提供 `Dockerfile`(多阶段构建,产物基于 Alpine,支持多架构):
|
||||
|
||||
```bash
|
||||
cd middleware-go
|
||||
docker build -t middleware-go .
|
||||
docker run -d -p 8091:8091 \
|
||||
-v $(pwd)/setting.json:/app/setting.json \
|
||||
-v $(pwd)/setting.json:/home/appuser/setting.json \
|
||||
middleware-go
|
||||
```
|
||||
|
||||
配置通过挂载 `setting.json` 提供(`api-base-url` / `ip-location-api` / `api-keys` / `ws-keys` / `cors` / `rate-limit` / `remote-config-url` 等;`rate-limit` 为单 IP 每分钟限流次数,默认 120,0 表示不限流,可用环境变量 `RATE_LIMIT` 覆盖;`remote-config-url` 为远端配置地址,可用环境变量 `REMOTE_CONFIG_URL` 覆盖;**`api-keys` / `ws-keys` 为敏感凭据,不随远端配置覆盖**——`api-keys` 管 HTTP 转发鉴权,`ws-keys` 管 WS 注册校验,两者相互独立)。
|
||||
> 配置挂载注意:容器工作目录是 `/home/appuser`(非 root 用户运行),`setting.json` 要挂到这个路径下。
|
||||
|
||||
**多架构**:Dockerfile 已适配 buildx 交叉编译,本机构建其他架构:
|
||||
|
||||
```bash
|
||||
cd middleware-go
|
||||
docker buildx build --platform linux/arm64 -t middleware-go:arm64 --load .
|
||||
```
|
||||
|
||||
打 `v*` 版本标签发版时,CI 会自动构建 `linux/amd64` / `linux/arm64` / `linux/arm/v7` 三架构镜像(amd64 原生构建,arm64/armv7 在 GitHub 原生 ARM 实例上交叉构建,无 QEMU),推送 GHCR(`ghcr.io/<owner>/<repo>/lemon-ipw-middleware`;Docker Hub 推送在 workflow 中暂未启用,预留了注释可随时开启),Docker 按宿主机架构自动拉取对应变体。
|
||||
|
||||
配置通过挂载 `setting.json` 提供(`api-base-url` / `ip-location-api` / `api-keys` / `ws-keys` / `cors` / `trusted-proxies` / `rate-limit` / `remote-config-url` 等;`rate-limit` 为单 IP 每分钟限流次数,默认 120,0 表示不限流,可用环境变量 `RATE_LIMIT` 覆盖;`remote-config-url` 为远端配置地址,可用环境变量 `REMOTE_CONFIG_URL` 覆盖;**`api-keys` / `ws-keys` 为敏感凭据,不随远端配置覆盖**——`api-keys` 管 HTTP 转发鉴权,`ws-keys` 管 WS 注册校验,两者相互独立)。
|
||||
|
||||
## 方案二:二进制
|
||||
|
||||
@@ -37,7 +48,9 @@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o middleware-go-linux-amd64 .
|
||||
./middleware-go-linux-amd64 # 运行(与 setting.json 同目录)
|
||||
```
|
||||
|
||||
所有配置可用环境变量覆盖(`API_BASE_URLS` / `IP_LOCATION_APIS` / `CORS` / `API_KEYS` / `WS_KEYS` / `RATE_LIMIT` 等,数组/对象用 JSON 字符串),优先级:**环境变量 > setting.json > 默认值**(远端配置 `REMOTE_CONFIG_URL` 高于两者,详见 [配置文件 - 远端配置](/guide/config#远端配置remoteconfigurl))。
|
||||
**OTA 自更新**(可选,默认关闭):配置 `"ota": "true"`(或环境变量 `OTA=true`)后,中间件定期检查本仓库 Release,下载与当前平台匹配的 `middleware-go-*` 资产并替换自身重启。下载加速可配 `"gh-proxy": ""`(或 `GH_PROXY` 环境变量)。交接流程与后端节点一致:预检 → 原子替换 → 优雅停机 → 拉起新进程 → 健康检查确认;失败自动回滚 `.old` 备份。
|
||||
|
||||
所有配置可用环境变量覆盖(`API_BASE_URLS` / `IP_LOCATION_APIS` / `CORS` / `TRUSTED_PROXIES` / `API_KEYS` / `WS_KEYS` / `RATE_LIMIT` 等,数组/对象用 JSON 字符串),优先级:**环境变量 > setting.json > 默认值**(远端配置 `REMOTE_CONFIG_URL` 高于两者,详见 [配置文件 - 远端配置](/guide/config#远端配置remoteconfigurl))。
|
||||
|
||||
需要守护运行时,参考 [后端节点部署 - 方案五:一键安装](/guide/deploy-node#方案五一键安装installsh) 的 systemd 管理方式(`ExecStart` 指向中间件二进制,`WorkingDirectory` 指向 `middleware-go/setting.json` 所在目录)。
|
||||
|
||||
|
||||
@@ -32,16 +32,41 @@ npx edgeone pages deploy -n ipw-cn -t $EDGEONE_API_TOKEN
|
||||
|
||||
## 方案三:Docker
|
||||
|
||||
仓库根目录提供 `Dockerfile`(多阶段构建,产物基于 Alpine):
|
||||
`src/` 目录提供 `Dockerfile`(多阶段构建,产物基于 Alpine,支持多架构):
|
||||
|
||||
```bash
|
||||
cd src
|
||||
docker build -t lemon-ipw .
|
||||
docker run -d -p 8080:8080 \
|
||||
-v $(pwd)/setting.json:/app/setting.json \
|
||||
docker run -d --restart unless-stopped -p 8080:8080 \
|
||||
-v $(pwd)/setting.json:/home/appuser/setting.json \
|
||||
lemon-ipw
|
||||
```
|
||||
|
||||
配置通过挂载 `setting.json` 提供,也可用环境变量覆盖(`PORTS` / `CORS` / `ACCESS_TOKEN` 等)。
|
||||
> `--restart unless-stopped`:主进程退出时 Docker 自动拉起容器(OTA 更新失败自愈依赖它);手动 `docker stop` 不会触发重启。
|
||||
|
||||
配置通过挂载 `setting.json` 提供(注意挂载到容器工作目录 `/home/appuser`),也可用环境变量覆盖(`PORTS` / `CORS` / `TRUSTED_PROXIES` / `ACCESS_TOKEN` 等)。
|
||||
|
||||
**多架构**:Dockerfile 已适配 buildx——构建阶段固定在构建机原生平台交叉编译(`TARGETOS`/`TARGETARCH`),目标架构无需 QEMU 模拟。本机为其他架构构建:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
docker buildx build --platform linux/arm64 -t lemon-ipw:arm64 --load .
|
||||
```
|
||||
|
||||
打 `v*` 版本标签发版时,CI(`.github/workflows/docker.yml`)会自动构建 `linux/amd64` / `linux/arm64` / `linux/arm/v7` 三架构镜像(amd64 在 x64 实例上原生构建,arm64/armv7 在 GitHub 原生 ARM 实例 `ubuntu-24.04-arm` 上交叉构建,全程无 QEMU),推送 GHCR 与 Docker Hub:
|
||||
|
||||
- GHCR:`ghcr.io/<owner>/<repo>/lemon-ipw`(自动)
|
||||
- Docker Hub:`docker.io/<namespace>/lemon-ipw`(**workflow 中暂未启用**——`.github/workflows/docker.yml` 已预留注释,取消注释并配置 `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` Secrets 即可开启)
|
||||
|
||||
均带 `版本` 与 `major.minor` 标签。部署直接:
|
||||
|
||||
```bash
|
||||
docker run -d -p 8080:8080 \
|
||||
-v $(pwd)/setting.json:/home/appuser/setting.json \
|
||||
ghcr.io/nomdn/ipw-cn/lemon-ipw:v1.2.3
|
||||
```
|
||||
|
||||
Docker 会按宿主机架构自动拉取 manifest 中对应的镜像变体。
|
||||
|
||||
## 方案四:二进制
|
||||
|
||||
@@ -73,6 +98,7 @@ sudo bash install.sh
|
||||
|--------|--------|------|
|
||||
| 安装目录 | `/opt/lemon-ipw` | 二进制与工作目录 |
|
||||
| 监听端口 | `8080` | `PORTS` |
|
||||
| 单栈模式 | 双栈 | `SINGLE_STACK`,`ipv4` / `ipv6` / 留空 |
|
||||
| access-token | 留空 | 留空 = 不启用鉴权 |
|
||||
| DNS 服务器 | 留空 | 主从逗号分隔(`119.28.28.28:53,223.5.5.5:53`);留空 = 启动时自动探测系统 DNS |
|
||||
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server;都留空 = 自动探测系统 DNS |
|
||||
@@ -80,7 +106,7 @@ sudo bash install.sh
|
||||
| CORS | 留空 | 逗号分隔允许来源 |
|
||||
| 远端配置地址 | 留空 | `REMOTE_CONFIG_URL` |
|
||||
| WS 通道接入 | `N` | 选 `y` 后继续输入 WS_URL / NODE_ID / NODE_KEY |
|
||||
| 其他环境变量 | 无 | 每行一个 `K=V`,空行结束 |
|
||||
| 其他环境变量 | 无 | 每行一个 `K=V`,空行结束。如需可信代理在此输入 `TRUSTED_PROXIES=<IP/CIDR 逗号分隔>`(配置后归属地接口只信任这些代理转发的 X-Forwarded-For,防伪造) |
|
||||
|
||||
**WS 接入特殊规则**:
|
||||
|
||||
@@ -124,3 +150,23 @@ NODE_KEY=<注册key,与中间件 ws-keys[节点id] 一致;节点未配置 ke
|
||||
- 节点注册带 `NODE_KEY`:中间件 `ws-keys` 里配了 key 就必须传对,否则返回 401 并断开;未配置 key 的节点开放注册
|
||||
|
||||
**注意**:`NODE_ID` 必须与中间件 `APIBaseURL` / `IPLocationAPI` 池中的节点 `id` 一致,且该节点需配置 `"ws": true` 才会走 WS 通道。未连接中间件时,`ws:true` 节点的拨测会返回 502。
|
||||
|
||||
## OTA 自更新(可选)
|
||||
|
||||
节点支持**自动升级**:检测到本仓库有新 Release 时,下载对应平台的二进制、**替换自身并重启**。
|
||||
|
||||
```bash
|
||||
# 启用(环境变量,或 setting.json 的 "node-ota": "true")
|
||||
NODE_OTA=true ./lemonipw
|
||||
```
|
||||
|
||||
- **默认关闭**,需显式开启;远端配置(`node-ota`)也可统一开关,除非列入 `remote-ignore-config`
|
||||
- **检查时机**:启动 5 分钟后首次检查,之后每 6 小时 + 随机 0~1h 抖动
|
||||
- **major 变化不自动更新**:仅 minor/patch 升级自动跟进(如 3.1.0 → 3.2.0);主版本变化(如 3.x → 4.x,通常含破坏性变更)会跳过并告警,需人工升级
|
||||
- **更新流程**:查询 GitHub 最新 Release → 按 `GOOS/GOARCH` 匹配资产(如 `lemonipw-linux-amd64`、`lemonipw-windows-amd64.exe`)→ 下载到同目录临时文件(经 `gh-proxy` 前缀加速,文件过小则丢弃)→ 当前二进制改名备份为 `<程序名>.old` → 新二进制就位 → 重启
|
||||
- **重启方式**:Linux/macOS 用 `exec` 原地替换进程镜像(PID 不变,systemd / Docker 无感);Windows 另起新进程后退出当前进程
|
||||
- **失败处理**:下载/替换失败仅记录日志并继续运行,下次周期重试;替换过程中断会自动回滚到 `.old`
|
||||
- **注意事项**:
|
||||
- 二进制所在目录需**可写**(Docker 只读层或只读挂载会导致替换失败)
|
||||
- 建议保留 `.old` 备份以便手动回滚:`mv lemonipw.old lemonipw`
|
||||
- systemd 部署时若 OTA 重启未被托管,可配 `Restart=always` 兜底
|
||||
@@ -37,6 +37,7 @@ sudo bash install.sh
|
||||
|--------|--------|------|
|
||||
| 安装目录 | `/opt/lemon-ipw` | 二进制与工作目录 |
|
||||
| 监听端口 | `8080` | 后端 HTTP 端口 |
|
||||
| 单栈模式 | 双栈 | `SINGLE_STACK`,`ipv4` / `ipv6` / 留空 |
|
||||
| access-token | 留空 | 留空 = 不启用鉴权 |
|
||||
| DNS 服务器 | 留空 | 主从逗号分隔(如 `119.28.28.28:53,223.5.5.5:53`);留空 = 启动时自动探测系统 DNS |
|
||||
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server;都留空 = 自动探测系统 DNS |
|
||||
@@ -44,7 +45,7 @@ sudo bash install.sh
|
||||
| CORS | 留空 | 逗号分隔允许来源 |
|
||||
| 远端配置地址 | 留空 | `REMOTE_CONFIG_URL` |
|
||||
| WS 通道接入 | `N` | 选 `y` 后输入 WS_URL / NODE_ID / NODE_KEY |
|
||||
| 其他环境变量 | 无 | 每行一个 `K=V`,空行结束 |
|
||||
| 其他环境变量 | 无 | 每行一个 `K=V`,空行结束。如需可信代理在此输入 `TRUSTED_PROXIES=<IP/CIDR 逗号分隔>`(配置后归属地接口只信任这些代理转发的 X-Forwarded-For,防伪造) |
|
||||
|
||||
**WS 通道接入规则**(选 `y` 时):
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
|
||||
|
||||
const { data: asnData, error: asnError, execute: executeASN } = useMiddlewareFetch<ASNResult>(() => '/middleware/' + backendID.value + '/asn/' + tmpIP.value, {
|
||||
const { data: asnData, error: asnError, execute: executeASN } = useMiddlewareFetch<ASNResult>(() => '/middleware/' + backendID.value + '/asn/' + encodeURIComponent(tmpIP.value), {
|
||||
key: 'asn-fetch',
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -119,11 +119,11 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
async function getUserIP() {
|
||||
await $fetch<string>(config.DualStackAPI).then(
|
||||
function (data) {
|
||||
userIP.value = data
|
||||
}
|
||||
)
|
||||
try {
|
||||
userIP.value = await $fetch<string>(config.DualStackAPI)
|
||||
} catch {
|
||||
// 获取失败保持为空,避免未处理的 Promise 拒绝
|
||||
}
|
||||
return userIP.value
|
||||
}
|
||||
|
||||
@@ -234,7 +234,6 @@ function getStatusClass(status: string): string {
|
||||
</div>
|
||||
|
||||
<blockquote>
|
||||
<a href="/doc/user/asn" target="_blank">ASN 查询原理介绍</a><br/>
|
||||
ASN(Autonomous System Number)是互联网中每个自治系统的唯一标识符。<br/>
|
||||
数据来源:Maxmind GEOLite2、DB-IP,部分节点集成 WHOIS 进一步解析 ASN 详情。<br/>
|
||||
<a href="/location" target="_blank">IP 归属地查询</a> | <a href="/whois" target="_blank">Whois 查询</a><br/>
|
||||
|
||||
@@ -50,13 +50,15 @@ const results = ref<any>([])
|
||||
const isloading = ref(false)
|
||||
const nowRecordType = ref('')
|
||||
const userIP = ref('')
|
||||
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
|
||||
let queryId = 0
|
||||
|
||||
async function getUserIP(){
|
||||
|
||||
await $fetch<string>(config.DualStackAPI).then(
|
||||
function (data){
|
||||
userIP.value = data
|
||||
})
|
||||
try {
|
||||
userIP.value = await $fetch<string>(config.DualStackAPI)
|
||||
} catch {
|
||||
// 获取失败保持为空,避免未处理的 Promise 拒绝
|
||||
}
|
||||
return userIP.value
|
||||
}
|
||||
|
||||
@@ -77,7 +79,7 @@ const dnsServerFetches = [
|
||||
...config.APIBaseURL.IPv4,
|
||||
...config.APIBaseURL.IPv6
|
||||
].map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/dns/" + recordType.value + "/" + domain.value);
|
||||
const url = computed(() => "/middleware/" + server.id + "/dns/" + recordType.value + "/" + encodeURIComponent(domain.value));
|
||||
const { data, error, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -89,7 +91,9 @@ async function queryDNS() {
|
||||
isloading.value = true
|
||||
domain.value = tmpDomain.value
|
||||
await nextTick()
|
||||
|
||||
|
||||
const id = ++queryId
|
||||
|
||||
// 初始化结果数组,保持响应式结构
|
||||
results.value = dnsServerFetches.map(fetch => ({
|
||||
server: fetch.label,
|
||||
@@ -101,7 +105,20 @@ async function queryDNS() {
|
||||
const promises = dnsServerFetches.map(async (fetch, index) => {
|
||||
try {
|
||||
await fetch.execute();
|
||||
|
||||
|
||||
if (id !== queryId) return null
|
||||
|
||||
// execute() 不会抛错,全部候选失败时以 error.value 为准(否则故障节点渲染成空白)
|
||||
if (fetch.error.value) {
|
||||
results.value[index] = {
|
||||
server: fetch.label,
|
||||
loading: false,
|
||||
data: null,
|
||||
error: fetch.error.value
|
||||
};
|
||||
return { server: fetch.label, error: fetch.error.value };
|
||||
}
|
||||
|
||||
// 直接更新对应索引的结果,保持响应式
|
||||
results.value[index] = {
|
||||
server: fetch.label,
|
||||
@@ -109,12 +126,13 @@ async function queryDNS() {
|
||||
data: fetch.data.value,
|
||||
error: null
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
server: fetch.label,
|
||||
data: fetch.data.value
|
||||
};
|
||||
} catch (err) {
|
||||
if (id !== queryId) return null
|
||||
// 错误时也更新对应索引
|
||||
results.value[index] = {
|
||||
server: fetch.label,
|
||||
@@ -122,7 +140,7 @@ async function queryDNS() {
|
||||
data: null,
|
||||
error: err
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
server: fetch.label,
|
||||
error: err
|
||||
@@ -130,10 +148,11 @@ async function queryDNS() {
|
||||
}
|
||||
});
|
||||
nowRecordType.value = recordType.value
|
||||
const promiseResults = await Promise.all(promises)
|
||||
console.log(promiseResults)
|
||||
isloading.value = false
|
||||
|
||||
const promiseResults = (await Promise.all(promises)).filter(Boolean)
|
||||
if (id === queryId) {
|
||||
isloading.value = false
|
||||
}
|
||||
|
||||
return promiseResults
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,8 @@ const tmpDomain = ref('www.zakoflare.com')
|
||||
const loading = ref(false)
|
||||
const serverResults = ref<ServerResult[]>([])
|
||||
const userIP = ref('')
|
||||
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
|
||||
let queryId = 0
|
||||
|
||||
const apiList = [
|
||||
...config.APIBaseURL.DualStack,
|
||||
@@ -76,7 +78,7 @@ const apiList = [
|
||||
]
|
||||
|
||||
const dnssecFetches = apiList.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/dnssec/" + tmpDomain.value)
|
||||
const url = computed(() => "/middleware/" + server.id + "/dnssec/" + encodeURIComponent(tmpDomain.value.trim()))
|
||||
const { data, error, execute } = useMiddlewareFetch<DNSSECResult>(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -97,6 +99,8 @@ async function checkDNSSEC() {
|
||||
|
||||
loading.value = true
|
||||
|
||||
const id = ++queryId
|
||||
|
||||
serverResults.value.forEach((result) => {
|
||||
result.loading = true
|
||||
result.data = undefined
|
||||
@@ -105,9 +109,15 @@ async function checkDNSSEC() {
|
||||
const promises = dnssecFetches.map(async (fetch, index) => {
|
||||
try {
|
||||
await fetch.execute()
|
||||
if (id !== queryId) return
|
||||
const result = serverResults.value[index]
|
||||
if (result) {
|
||||
result.data = fetch.data.value
|
||||
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成 '-')
|
||||
if (fetch.error.value) {
|
||||
result.error = (fetch.error.value as any)?.message || '请求失败'
|
||||
} else {
|
||||
result.data = fetch.data.value
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
@@ -117,14 +127,16 @@ async function checkDNSSEC() {
|
||||
}
|
||||
} finally {
|
||||
const result = serverResults.value[index]
|
||||
if (result) {
|
||||
if (result && id === queryId) {
|
||||
result.loading = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Promise.allSettled(promises).finally(function () {
|
||||
loading.value = false
|
||||
if (id === queryId) {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -134,8 +146,8 @@ function getValidationClass(result: DNSSECResult): string {
|
||||
return 'status-error'
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
return ms.toFixed(2) + ' ms'
|
||||
function formatDuration(ms?: number): string {
|
||||
return typeof ms === 'number' && isFinite(ms) ? ms.toFixed(2) + ' ms' : '-'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -149,11 +161,11 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
async function getUserIP() {
|
||||
await $fetch<string>(config.DualStackAPI).then(
|
||||
function (data) {
|
||||
userIP.value = data
|
||||
}
|
||||
)
|
||||
try {
|
||||
userIP.value = await $fetch<string>(config.DualStackAPI)
|
||||
} catch {
|
||||
// 获取失败保持为空,避免未处理的 Promise 拒绝
|
||||
}
|
||||
return userIP.value
|
||||
}
|
||||
</script>
|
||||
@@ -244,7 +256,7 @@ async function getUserIP() {
|
||||
</div>
|
||||
|
||||
<blockquote>
|
||||
<a href="/doc/user/dnssec" target="_blank">DNSSEC 原理介绍</a><br/>
|
||||
DNSSEC 原理介绍即将上线<br/>
|
||||
DNSSEC 通过 DNSKEY、RRSIG、DS 记录为 DNS 数据提供完整性验证,防止 DNS 欺骗攻击。<br/>
|
||||
如果显示 "验证失败",可能原因:域名未配置 DNSSEC、DNSKEY 密钥不匹配、DS 记录缺失等。<br/>
|
||||
<a href="/dns" target="_blank">DNS 解析查询</a> | <a href="/whois" target="_blank">Whois 查询</a><br/>
|
||||
|
||||
@@ -129,5 +129,5 @@ html.dark .content {
|
||||
</style>
|
||||
<style>
|
||||
@import "github-markdown-css/github-markdown-light.css";
|
||||
@import "/github-markdown-dark.css";
|
||||
@import "../../github-markdown-dark.css";
|
||||
</style>
|
||||
@@ -128,7 +128,7 @@ html.dark .content {
|
||||
</style>
|
||||
<style>
|
||||
@import "github-markdown-css/github-markdown-light.css";
|
||||
@import "/github-markdown-dark.css";
|
||||
@import "../../github-markdown-dark.css";
|
||||
@import "/style.css"
|
||||
|
||||
</style>
|
||||
@@ -122,8 +122,8 @@ onMounted(async () => {
|
||||
<b>IPv6</b> <p v-if="yourIPv6">{{ yourIPv6 }}</p><RouterLink :to="`/location?ip=${yourIPv6}`" target="_blank" v-if="yourIPv6"> 查询归属地</RouterLink><RouterLink v-else to="/doc/user/enable_ipv6" target="_blank">没有IPv6地址,查看如何开启IPv6</RouterLink>
|
||||
</div>
|
||||
<div style="font-size: 1.5em;">
|
||||
<h3 v-if="(ipAddress && isIPv6(ipAddress)) || isIPv6(yourIPv6)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h3>
|
||||
<h3 v-else-if="(ipAddress && isIPv4(ipAddress)) || isIPv6(yourIPv6)"><el-icon><CircleCloseFilled style="color: red;"/></el-icon>您的网络IPv4优先</h3>
|
||||
<h3 v-if="ipAddress && isIPv6(ipAddress)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h3>
|
||||
<h3 v-else-if="ipAddress && isIPv4(ipAddress)"><el-icon><CircleCloseFilled style="color: red;"/></el-icon>您的网络IPv4优先</h3>
|
||||
<h3 v-else><el-icon><CircleCloseFilled /></el-icon>查询中,请稍后</h3>
|
||||
</div>
|
||||
<blockquote>
|
||||
|
||||
@@ -6,7 +6,8 @@ const route = useRoute();
|
||||
|
||||
onMounted(async () => {
|
||||
const urlParam = route.query.ip;
|
||||
await navigateTo({ path: '/location', query: { ip: urlParam } });
|
||||
// ip 参数缺失时不要拼出 ?ip=undefined
|
||||
await navigateTo(urlParam ? { path: '/location', query: { ip: urlParam } } : { path: '/location' });
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -57,6 +57,9 @@ interface ServerResult {
|
||||
data: any
|
||||
}
|
||||
|
||||
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
|
||||
let queryId = 0
|
||||
|
||||
/** 预构建的服务器列表,页面挂载即展示,用户可提前看到测速节点 */
|
||||
const serverResults = ref<ServerResult[]>([])
|
||||
|
||||
@@ -69,7 +72,7 @@ const serverResults = ref<ServerResult[]>([])
|
||||
|
||||
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
|
||||
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + domain.value);
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + encodeURIComponent(domain.value));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -79,7 +82,7 @@ const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
|
||||
|
||||
/** IPv6 专用服务器:仅支持 IPv6 的节点 */
|
||||
const ipv6Fetches = config.APIBaseURL.IPv6.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + domain.value);
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + encodeURIComponent(domain.value));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -116,6 +119,8 @@ async function SpeedTest() {
|
||||
error.value = ''
|
||||
await nextTick()
|
||||
|
||||
const id = ++queryId
|
||||
|
||||
// 重置所有行状态为加载中
|
||||
serverResults.value.forEach((row) => {
|
||||
row.loading = true
|
||||
@@ -130,16 +135,26 @@ async function SpeedTest() {
|
||||
|
||||
try {
|
||||
await fetch.execute();
|
||||
row.data = fetch.data.value;
|
||||
if (id !== queryId) return
|
||||
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成空白)
|
||||
if (fetch.error.value) {
|
||||
row.error = fetch.error.value?.data?.error || fetch.error.value?.message || '请求失败';
|
||||
} else {
|
||||
row.data = fetch.data.value;
|
||||
}
|
||||
} catch (err: any) {
|
||||
row.error = err?.data?.error || err?.message || '请求失败';
|
||||
} finally {
|
||||
row.loading = false;
|
||||
if (id === queryId) {
|
||||
row.loading = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Promise.all(promises).finally(() => {
|
||||
loading.value = false
|
||||
if (id === queryId) {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ const port = ref('80')
|
||||
const userIP = ref('')
|
||||
const loading = ref(false)
|
||||
const serverResults = ref<ServerResult[]>([])
|
||||
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
|
||||
let queryId = 0
|
||||
|
||||
const allServers = [
|
||||
...config.APIBaseURL.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
|
||||
@@ -84,7 +86,7 @@ const allServers = [
|
||||
];
|
||||
|
||||
const tcpingFetches = allServers.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/tcping/" + extractHost(tmpDomain.value) + "?port=" + port.value);
|
||||
const url = computed(() => "/middleware/" + server.id + "/tcping/" + encodeURIComponent(extractHost(tmpDomain.value)) + "?port=" + encodeURIComponent(port.value));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch<TCPingResponse>(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -117,23 +119,31 @@ function initServerResults() {
|
||||
function TCPingAll() {
|
||||
const host = extractHost(tmpDomain.value)
|
||||
if (!host) return
|
||||
|
||||
|
||||
loading.value = true
|
||||
|
||||
|
||||
const id = ++queryId
|
||||
|
||||
serverResults.value.forEach((result) => {
|
||||
result.loading = true
|
||||
result.error = ''
|
||||
result.ipv4 = undefined
|
||||
result.ipv6 = undefined
|
||||
})
|
||||
|
||||
|
||||
const promises = tcpingFetches.map(async (fetch, index) => {
|
||||
try {
|
||||
await fetch.execute();
|
||||
if (id !== queryId) return
|
||||
const result = serverResults.value[index];
|
||||
if (result) {
|
||||
result.ipv4 = fetch.data.value?.ipv4;
|
||||
result.ipv6 = fetch.data.value?.ipv6;
|
||||
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成 '-')
|
||||
if (fetch.error.value) {
|
||||
result.error = (fetch.error.value as any)?.message || '请求失败';
|
||||
} else {
|
||||
result.ipv4 = fetch.data.value?.ipv4;
|
||||
result.ipv6 = fetch.data.value?.ipv6;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -143,22 +153,24 @@ function TCPingAll() {
|
||||
}
|
||||
} finally {
|
||||
const result = serverResults.value[index];
|
||||
if (result) {
|
||||
if (result && id === queryId) {
|
||||
result.loading = false;
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Promise.all(promises).finally(function () {
|
||||
loading.value = false
|
||||
if (id === queryId) {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
async function getUserIP(){
|
||||
|
||||
await $fetch<string>(config.DualStackAPI).then(
|
||||
function (data){
|
||||
userIP.value = data
|
||||
})
|
||||
try {
|
||||
userIP.value = await $fetch<string>(config.DualStackAPI)
|
||||
} catch {
|
||||
// 获取失败保持为空,避免未处理的 Promise 拒绝
|
||||
}
|
||||
return userIP.value
|
||||
}
|
||||
onMounted(() => {
|
||||
@@ -221,13 +233,13 @@ onMounted(() => {
|
||||
<td class="table-value">{{ server.ipv6.sent }}</td>
|
||||
<td class="table-value">{{ server.ipv6.success }}</td>
|
||||
<td class="table-value">
|
||||
<span :class="server.ipv6.loss_rate > 0 ? 'loss-warning' : 'loss-ok'">
|
||||
{{ server.ipv6.loss_rate.toFixed(1) }}
|
||||
<span :class="(server.ipv6?.loss_rate ?? 0) > 0 ? 'loss-warning' : 'loss-ok'">
|
||||
{{ server.ipv6?.loss_rate?.toFixed(1) ?? '-' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="table-value">{{ server.ipv6.max_rtt.toFixed(2) }}</td>
|
||||
<td class="table-value">{{ server.ipv6.min_rtt.toFixed(2) }}</td>
|
||||
<td class="table-value">{{ server.ipv6.avg_rtt.toFixed(2) }}</td>
|
||||
<td class="table-value">{{ server.ipv6?.max_rtt?.toFixed(2) ?? '-' }}</td>
|
||||
<td class="table-value">{{ server.ipv6?.min_rtt?.toFixed(2) ?? '-' }}</td>
|
||||
<td class="table-value">{{ server.ipv6?.avg_rtt?.toFixed(2) ?? '-' }}</td>
|
||||
</tr>
|
||||
<tr v-else-if="server.loading">
|
||||
<td class="table-label">{{ server.label }}</td>
|
||||
|
||||
@@ -72,6 +72,8 @@ const loading = ref(false)
|
||||
const error = ref('')
|
||||
const result = ref<PerformanceCheckResponse | null>(null)
|
||||
const checkUrl = computed(() => "/middleware/" + backendID.value + "/detail/" + testDomain.value);
|
||||
// testDomain 是完整 URL(含 https:// 与路径斜杠),服务端 [...slug].get.ts 依赖
|
||||
// 分段重组协议部分,这里刻意不做 encodeURIComponent
|
||||
|
||||
const { data: checkData, error: checkError, execute: executeCheck } = useMiddlewareFetch<PerformanceCheckResponse>(checkUrl, {
|
||||
immediate: false,
|
||||
@@ -162,7 +164,7 @@ onMounted(() => {
|
||||
<tr>
|
||||
<td class="table-label">主机记录</td>
|
||||
<td class="table-value"><div class="one-line"><RouterLink :to="`/ipv6?ip=${result.ipv4!.host_record}`">{{ result.ipv4!.host_record }}</RouterLink><el-icon><Position /></el-icon></div></td>
|
||||
<td class="table-value"><div class="one-line"><RouterLink :to="`/ipv6?ip=${result.ipv6?.host_record}`">{{ result.ipv6?.host_record || '-' }}</RouterLink><el-icon><Position /></el-icon></div></td>
|
||||
<td class="table-value"><div class="one-line"><RouterLink v-if="result.ipv6?.host_record" :to="`/ipv6?ip=${result.ipv6!.host_record}`">{{ result.ipv6!.host_record }}</RouterLink><span v-else>-</span><el-icon><Position /></el-icon></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="table-label">HTTP 状态码</td>
|
||||
|
||||
@@ -76,7 +76,7 @@ const ipAddress = ref('');
|
||||
const IPLocation = ref<IPLocationType>({});
|
||||
const UserIP = ref('');
|
||||
const error = ref('')
|
||||
const locationUrl = computed(() => "/middleware/" + backendID.value + "/location/" + ipAddress.value);
|
||||
const locationUrl = computed(() => "/middleware/" + backendID.value + "/location/" + encodeURIComponent(ipAddress.value));
|
||||
|
||||
const { data: locationData, error: locationError, execute: executeLocation } = useMiddlewareFetch<IPLocationType>(locationUrl, {
|
||||
immediate: false,
|
||||
|
||||
@@ -32,17 +32,18 @@ let detectionImg: HTMLImageElement | null = null
|
||||
let detectionTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let currentRequestId = 0
|
||||
const userIP = ref('')
|
||||
// WordPress mshots default error image dimensions
|
||||
const DEFAULT_MSHOTS_WIDTH = 400
|
||||
const DEFAULT_MSHOTS_HEIGHT = 300
|
||||
// WordPress mshots 默认占位图(生成失败/重定向目标)的尺寸是 1200x900,
|
||||
// 与下方注释及判定逻辑一致;真实网站截图可以是任意尺寸
|
||||
const DEFAULT_MSHOTS_WIDTH = 1200
|
||||
const DEFAULT_MSHOTS_HEIGHT = 900
|
||||
const LOADING_TIMEOUT = 15000 // 15 seconds
|
||||
|
||||
async function getUserIP(){
|
||||
|
||||
await $fetch<string>(config.DualStackAPI).then(
|
||||
function (data){
|
||||
userIP.value = data
|
||||
})
|
||||
try {
|
||||
userIP.value = await $fetch<string>(config.DualStackAPI)
|
||||
} catch {
|
||||
// 获取失败保持为空,避免未处理的 Promise 拒绝
|
||||
}
|
||||
return userIP.value
|
||||
}
|
||||
function cleanupDetection() {
|
||||
|
||||
@@ -57,6 +57,9 @@ interface ServerResult {
|
||||
data: any
|
||||
}
|
||||
|
||||
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
|
||||
let queryId = 0
|
||||
|
||||
/** 预构建的服务器列表,页面挂载即展示,用户可提前看到测速节点 */
|
||||
const serverResults = ref<ServerResult[]>([])
|
||||
|
||||
@@ -69,7 +72,7 @@ const serverResults = ref<ServerResult[]>([])
|
||||
|
||||
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
|
||||
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + domain.value);
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + encodeURIComponent(domain.value));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -79,7 +82,7 @@ const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
|
||||
|
||||
/** IPv4 专用服务器:仅支持 IPv4 的节点 */
|
||||
const ipv4Fetches = config.APIBaseURL.IPv4.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + domain.value);
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + encodeURIComponent(domain.value));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -116,6 +119,8 @@ async function SpeedTest() {
|
||||
error.value = ''
|
||||
await nextTick()
|
||||
|
||||
const id = ++queryId
|
||||
|
||||
// 重置所有行状态为加载中
|
||||
serverResults.value.forEach((row) => {
|
||||
row.loading = true
|
||||
@@ -130,16 +135,26 @@ async function SpeedTest() {
|
||||
|
||||
try {
|
||||
await fetch.execute()
|
||||
row.data = fetch.data.value
|
||||
if (id !== queryId) return
|
||||
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成空白)
|
||||
if (fetch.error.value) {
|
||||
row.error = fetch.error.value?.data?.error || fetch.error.value?.message || '请求失败'
|
||||
} else {
|
||||
row.data = fetch.data.value
|
||||
}
|
||||
} catch (err: any) {
|
||||
row.error = err?.data?.error || err?.message || '请求失败'
|
||||
} finally {
|
||||
row.loading = false
|
||||
if (id === queryId) {
|
||||
row.loading = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Promise.all(promises).finally(() => {
|
||||
loading.value = false
|
||||
if (id === queryId) {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ const testDomain = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const result = ref<SSLCheckResponse | null>(null)
|
||||
const sslUrl = computed(() => "/middleware/" + backendID.value + "/ssl/" + testDomain.value);
|
||||
const sslUrl = computed(() => "/middleware/" + backendID.value + "/ssl/" + encodeURIComponent(testDomain.value));
|
||||
|
||||
const { data: sslData, error: sslError, execute: executeSSL } = useMiddlewareFetch<SSLCheckResponse>(sslUrl, {
|
||||
immediate: false,
|
||||
|
||||
@@ -76,6 +76,8 @@ const tmpDomain = ref('www.zakoflare.com')
|
||||
const port = ref('80')
|
||||
const loading = ref(false)
|
||||
const serverResults = ref<ServerResult[]>([])
|
||||
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
|
||||
let queryId = 0
|
||||
|
||||
const allServers = [
|
||||
...config.APIBaseURL.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
|
||||
@@ -83,7 +85,7 @@ const allServers = [
|
||||
];
|
||||
|
||||
const tcpingFetches = allServers.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/tcping/" + extractHost(tmpDomain.value) + "?port=" + port.value);
|
||||
const url = computed(() => "/middleware/" + server.id + "/tcping/" + encodeURIComponent(extractHost(tmpDomain.value)) + "?port=" + encodeURIComponent(port.value));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch<TCPingResponse>(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -116,23 +118,31 @@ function initServerResults() {
|
||||
function TCPingAll() {
|
||||
const host = extractHost(tmpDomain.value)
|
||||
if (!host) return
|
||||
|
||||
|
||||
loading.value = true
|
||||
|
||||
|
||||
const id = ++queryId
|
||||
|
||||
serverResults.value.forEach((result) => {
|
||||
result.loading = true
|
||||
result.error = ''
|
||||
result.ipv4 = undefined
|
||||
result.ipv6 = undefined
|
||||
})
|
||||
|
||||
|
||||
const promises = tcpingFetches.map(async (fetch, index) => {
|
||||
try {
|
||||
await fetch.execute();
|
||||
if (id !== queryId) return
|
||||
const result = serverResults.value[index];
|
||||
if (result) {
|
||||
result.ipv4 = fetch.data.value?.ipv4;
|
||||
result.ipv6 = fetch.data.value?.ipv6;
|
||||
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成 '-')
|
||||
if (fetch.error.value) {
|
||||
result.error = (fetch.error.value as any)?.message || '请求失败';
|
||||
} else {
|
||||
result.ipv4 = fetch.data.value?.ipv4;
|
||||
result.ipv6 = fetch.data.value?.ipv6;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -142,14 +152,16 @@ function TCPingAll() {
|
||||
}
|
||||
} finally {
|
||||
const result = serverResults.value[index];
|
||||
if (result) {
|
||||
if (result && id === queryId) {
|
||||
result.loading = false;
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Promise.all(promises).finally(function () {
|
||||
loading.value = false
|
||||
if (id === queryId) {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -212,13 +224,13 @@ onMounted(() => {
|
||||
<td class="table-value">{{ server.ipv4?.sent }}</td>
|
||||
<td class="table-value">{{ server.ipv4?.success }}</td>
|
||||
<td class="table-value">
|
||||
<span :class="server.ipv4.loss_rate > 0 ? 'loss-warning' : 'loss-ok'">
|
||||
{{ server.ipv4.loss_rate.toFixed(1) }}
|
||||
<span :class="(server.ipv4?.loss_rate ?? 0) > 0 ? 'loss-warning' : 'loss-ok'">
|
||||
{{ server.ipv4?.loss_rate?.toFixed(1) ?? '-' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="table-value">{{ server.ipv4?.max_rtt.toFixed(2) }}</td>
|
||||
<td class="table-value">{{ server.ipv4?.min_rtt.toFixed(2) }}</td>
|
||||
<td class="table-value">{{ server.ipv4?.avg_rtt.toFixed(2) }}</td>
|
||||
<td class="table-value">{{ server.ipv4?.max_rtt?.toFixed(2) ?? '-' }}</td>
|
||||
<td class="table-value">{{ server.ipv4?.min_rtt?.toFixed(2) ?? '-' }}</td>
|
||||
<td class="table-value">{{ server.ipv4?.avg_rtt?.toFixed(2) ?? '-' }}</td>
|
||||
</tr>
|
||||
<tr v-else-if="server.loading">
|
||||
<td class="table-label">{{ server.label }}</td>
|
||||
|
||||
@@ -67,7 +67,7 @@ const userIP = ref('')
|
||||
const apiList = config.APIBaseURL.DualStack
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
const whoisUrl = computed(() => '/middleware/' + backendID.value + '/whois/' + domain.value)
|
||||
const whoisUrl = computed(() => '/middleware/' + backendID.value + '/whois/' + encodeURIComponent(domain.value))
|
||||
|
||||
const { data: whoisData, error: whoisError, execute: executeWhois } = useMiddlewareFetch<any>(whoisUrl, {
|
||||
immediate: false,
|
||||
@@ -124,11 +124,11 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
async function getUserIP(){
|
||||
await $fetch<string>(config.DualStackAPI).then(
|
||||
function (data){
|
||||
userIP.value = data
|
||||
}
|
||||
)
|
||||
try {
|
||||
userIP.value = await $fetch<string>(config.DualStackAPI)
|
||||
} catch {
|
||||
// 获取失败保持为空,避免未处理的 Promise 拒绝
|
||||
}
|
||||
return userIP.value
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { defineEventHandler, createError } from 'h3'
|
||||
|
||||
const IANA_BOOTSTRAP_URL = 'https://data.iana.org/rdap/dns.json'
|
||||
// bootstrap 数据基本不变,按进程缓存 24h,避免每个请求都重新下载
|
||||
const BOOTSTRAP_TTL = 24 * 60 * 60 * 1000
|
||||
let bootstrapCache: { map: Map<string, string>; at: number } | null = null
|
||||
|
||||
function encodeDomain(input: string): string {
|
||||
try {
|
||||
@@ -21,8 +24,12 @@ function getTLDCandidates(hostname: string): string[] {
|
||||
}
|
||||
|
||||
async function fetchBootstrap(): Promise<Map<string, string>> {
|
||||
if (bootstrapCache && Date.now() - bootstrapCache.at < BOOTSTRAP_TTL) {
|
||||
return bootstrapCache.map
|
||||
}
|
||||
const res = await fetch(IANA_BOOTSTRAP_URL, {
|
||||
headers: { accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw createError({
|
||||
@@ -42,6 +49,7 @@ async function fetchBootstrap(): Promise<Map<string, string>> {
|
||||
map.set(String(tld), baseUrl)
|
||||
}
|
||||
}
|
||||
bootstrapCache = { map, at: Date.now() }
|
||||
return map
|
||||
}
|
||||
|
||||
@@ -214,6 +222,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const primaryRes = await fetch(rdapUrl, {
|
||||
headers: { accept: 'application/rdap+json' },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
|
||||
if (!primaryRes.ok) {
|
||||
@@ -233,6 +242,7 @@ export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const thickRes = await fetch(relatedLink.href, {
|
||||
headers: { accept: 'application/rdap+json' },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
if (thickRes.ok) {
|
||||
thickData = await thickRes.json()
|
||||
|
||||
@@ -71,6 +71,11 @@ export default defineEventHandler(async (event) => {
|
||||
if (!backendID || !apiType || !raw) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing parameters in slug' })
|
||||
}
|
||||
// 阻断路径穿越:raw 里的 ".." 会被上游 URL 规范化解析掉,
|
||||
// 把请求(连同节点的 Authorization 头)带到 /v1 之外的任意路径
|
||||
if (raw.split('/').some(seg => seg === '.' || seg === '..')) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid path' })
|
||||
}
|
||||
const query = getQuery(event)
|
||||
|
||||
// 从运行时变量读取 APIKEYS (JSON字符串),解析后按 backendID 查找 token
|
||||
@@ -118,8 +123,9 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid backend ID' })
|
||||
}
|
||||
let apiBaseUrl = map.get(backendID)
|
||||
if (apiBaseUrl === undefined) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'API base URL not found for the given backend ID' })
|
||||
// url 为空的节点无法转发(会变成对前端自身的请求),直接报 502 而不是浪费一次自请求
|
||||
if (!apiBaseUrl) {
|
||||
throw createError({ statusCode: 502, statusMessage: 'Backend URL not configured' })
|
||||
}
|
||||
let data: any = {}
|
||||
|
||||
@@ -129,6 +135,7 @@ export default defineEventHandler(async (event) => {
|
||||
data = await $fetch(`${apiBaseUrl}v1/${apiType}/${raw}`, {
|
||||
method: 'GET',
|
||||
headers: authHeaders,
|
||||
timeout: 15_000,
|
||||
}).catch((error: any) => {
|
||||
console.error(`Error fetching from ${apiBaseUrl}:`, error)
|
||||
const errStatus = error?.status ?? error?.statusCode
|
||||
@@ -154,8 +161,8 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid backend ID' })
|
||||
}
|
||||
let apiBaseUrl = map.get(backendID)
|
||||
if (apiBaseUrl === undefined) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'API base URL not found for the given backend ID' })
|
||||
if (!apiBaseUrl) {
|
||||
throw createError({ statusCode: 502, statusMessage: 'Backend URL not configured' })
|
||||
}
|
||||
let data: any = {}
|
||||
|
||||
@@ -170,6 +177,7 @@ export default defineEventHandler(async (event) => {
|
||||
data = await $fetch(`${apiBaseUrl}v1/${apiType}/${raw}${queryString ? '?' + queryString : ''}`, {
|
||||
method: 'GET',
|
||||
headers: authHeaders,
|
||||
timeout: 30_000, // tcping/测速上游本身较慢,给更长超时
|
||||
}).catch((error: any) => {
|
||||
console.error(`Error fetching from ${apiBaseUrl}:`, error)
|
||||
const errStatus = error?.status ?? error?.statusCode
|
||||
|
||||
@@ -5,21 +5,21 @@ export function extractHost(url: string): string {
|
||||
return match?.groups?.host ?? url;
|
||||
}
|
||||
|
||||
export function formatTime(ms: number): string {
|
||||
if (ms == null || ms <= 0) return '-'
|
||||
export function formatTime(ms?: number | null): string {
|
||||
if (ms == null || typeof ms !== 'number' || !isFinite(ms) || ms <= 0) return '-'
|
||||
if (ms < 1000) {
|
||||
return `${ms.toFixed(2)} ms`
|
||||
}
|
||||
return `${(ms / 1000).toFixed(2)} s`
|
||||
}
|
||||
|
||||
export function formatSpeed(speed: number): string {
|
||||
if (speed == null) return '-'
|
||||
export function formatSpeed(speed?: number | null): string {
|
||||
if (speed == null || typeof speed !== 'number' || !isFinite(speed)) return '-'
|
||||
return `${speed.toFixed(2)} KB/s`
|
||||
}
|
||||
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes == null) return '-'
|
||||
export function formatSize(bytes?: number | null): string {
|
||||
if (bytes == null || typeof bytes !== 'number' || !isFinite(bytes)) return '-'
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`
|
||||
}
|
||||
@@ -29,7 +29,8 @@ export function formatSize(bytes: number): string {
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
export function getStatusCodeClass(code: number): string {
|
||||
export function getStatusCodeClass(code?: number | null): string {
|
||||
if (code == null || typeof code !== 'number') return 'status-error'
|
||||
if (code >= 200 && code < 300) return 'status-success'
|
||||
if (code >= 300 && code < 400) return 'status-warning'
|
||||
return 'status-error'
|
||||
|
||||
+45
-31
@@ -83,7 +83,7 @@ DNS_SERVER=${DNS_SERVER:-119.28.28.28:53}
|
||||
|
||||
read -r -p "DNSSEC 专用 DNS(留空=沿用上面 dns-server): " DNSSEC_DNS_SERVER
|
||||
|
||||
read -r -p "启用 IP 数据库 ipdb(首次启动下载约 200MB)[Y/n]: " IPDB_CHOICE
|
||||
read -r -p "启用 IP 数据库 ipdb(首次启动下载约 450MB)[Y/n]: " IPDB_CHOICE
|
||||
case "${IPDB_CHOICE,,}" in
|
||||
n|no) IPDB="false" ;;
|
||||
*) IPDB="true" ;;
|
||||
@@ -107,8 +107,11 @@ case "${WS_CHOICE,,}" in
|
||||
WS_URL="wss://middleware-1.api-ipw.wsmdn.top/ws"
|
||||
WS_USED_DEFAULT="true"
|
||||
fi
|
||||
NODE_ID=$(gen_uuid)
|
||||
echo " 节点 id(自动生成 UUID): $NODE_ID"
|
||||
read -r -p " 节点 id(回车自动生成 UUID): " NODE_ID
|
||||
if [ -z "$NODE_ID" ]; then
|
||||
NODE_ID=$(gen_uuid)
|
||||
echo " 节点 id(自动生成 UUID): $NODE_ID"
|
||||
fi
|
||||
while [ -z "$NODE_KEY" ]; do
|
||||
read -r -p " 注册 key(必填,中间件 ws-keys 必须包含此节点,否则注册被拒 401): " NODE_KEY
|
||||
if [ -z "$NODE_KEY" ]; then
|
||||
@@ -118,6 +121,14 @@ case "${WS_CHOICE,,}" in
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "--- OTA 自更新(可选)---"
|
||||
read -r -p "启用 OTA 自动升级(跟随 GitHub Release,major 版本不自动升)?[y/N]: " OTA_CHOICE
|
||||
NODE_OTA=""
|
||||
case "${OTA_CHOICE,,}" in
|
||||
y|yes) NODE_OTA="true" ;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "--- 其他环境变量(可选)---"
|
||||
echo "输入额外的 Environment= 项,每行一个(如 GH_PROXY=https://ghproxy.com/),空行结束:"
|
||||
@@ -145,7 +156,8 @@ echo "单栈模式: ${SINGLE_STACK:-双栈}"
|
||||
echo "access-token: ${ACCESS_TOKEN:+已设置 (隐藏)}"
|
||||
echo "DNS: $DNS_SERVER"
|
||||
echo "ipdb: $IPDB"
|
||||
echo "WS 接入: ${WS_URL:+$WS_URL (id=$NODE_ID, key=${NODE_KEY:+已设置})}${WS_URL:-未启用}"
|
||||
echo "WS 接入: $(if [ -n "$WS_URL" ]; then printf '%s (id=%s, key=%s)' "$WS_URL" "$NODE_ID" "${NODE_KEY:+已设置}"; else printf '未启用'; fi)"
|
||||
echo "OTA 自更新: ${NODE_OTA:-未启用}"
|
||||
echo "========================================"
|
||||
read -r -p "确认安装?[Y/n]: " CONFIRM
|
||||
case "${CONFIRM,,}" in
|
||||
@@ -173,7 +185,8 @@ echo "下载 $BINARY ..."
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
wget -q -O "$INSTALL_DIR/lemonipw" "$DOWNLOAD_URL"
|
||||
else
|
||||
curl -sL -o "$INSTALL_DIR/lemonipw" "$DOWNLOAD_URL"
|
||||
# -f 让 404/4xx 直接失败,避免把 GitHub 的 "Not Found" 页面存成二进制
|
||||
curl -fsSL -o "$INSTALL_DIR/lemonipw" "$DOWNLOAD_URL"
|
||||
fi
|
||||
chmod +x "$INSTALL_DIR/lemonipw"
|
||||
echo "二进制已安装到 $INSTALL_DIR/lemonipw"
|
||||
@@ -182,33 +195,32 @@ echo "二进制已安装到 $INSTALL_DIR/lemonipw"
|
||||
SERVICE_FILE="/etc/systemd/system/lemon-ipw.service"
|
||||
|
||||
# 收集环境变量(仅非空的写入,避免空值覆盖默认)
|
||||
# 值里的双引号/反斜杠需要转义,否则会提前终止 systemd 的引号包裹、损坏 unit
|
||||
ENV_LINES=""
|
||||
[ -n "$PORTS" ] && ENV_LINES="${ENV_LINES}Environment=\"PORTS=$PORTS\"
|
||||
"
|
||||
[ -n "$SINGLE_STACK" ] && ENV_LINES="${ENV_LINES}Environment=\"SINGLE_STACK=$SINGLE_STACK\"
|
||||
"
|
||||
[ -n "$ACCESS_TOKEN" ] && ENV_LINES="${ENV_LINES}Environment=\"ACCESS_TOKEN=$ACCESS_TOKEN\"
|
||||
"
|
||||
[ -n "$DNS_SERVER" ] && ENV_LINES="${ENV_LINES}Environment=\"DNS_SERVER=$DNS_SERVER\"
|
||||
"
|
||||
[ -n "$DNSSEC_DNS_SERVER" ] && ENV_LINES="${ENV_LINES}Environment=\"DNSSEC_DNS_SERVER=$DNSSEC_DNS_SERVER\"
|
||||
"
|
||||
[ -n "$IPDB" ] && ENV_LINES="${ENV_LINES}Environment=\"IPDB=$IPDB\"
|
||||
"
|
||||
[ -n "$CORS" ] && ENV_LINES="${ENV_LINES}Environment=\"CORS=$CORS\"
|
||||
"
|
||||
[ -n "$REMOTE_CONFIG_URL" ] && ENV_LINES="${ENV_LINES}Environment=\"REMOTE_CONFIG_URL=$REMOTE_CONFIG_URL\"
|
||||
"
|
||||
[ -n "$WS_URL" ] && ENV_LINES="${ENV_LINES}Environment=\"WS_URL=$WS_URL\"
|
||||
"
|
||||
[ -n "$NODE_ID" ] && ENV_LINES="${ENV_LINES}Environment=\"NODE_ID=$NODE_ID\"
|
||||
"
|
||||
[ -n "$NODE_KEY" ] && ENV_LINES="${ENV_LINES}Environment=\"NODE_KEY=$NODE_KEY\"
|
||||
add_env() {
|
||||
local name="$1" value="$2"
|
||||
[ -n "$value" ] || return 0
|
||||
value="${value//\\/\\\\}"
|
||||
value="${value//\"/\\\"}"
|
||||
ENV_LINES="${ENV_LINES}Environment=\"${name}=${value}\"
|
||||
"
|
||||
}
|
||||
add_env PORTS "$PORTS"
|
||||
add_env SINGLE_STACK "$SINGLE_STACK"
|
||||
add_env ACCESS_TOKEN "$ACCESS_TOKEN"
|
||||
add_env DNS_SERVER "$DNS_SERVER"
|
||||
add_env DNSSEC_DNS_SERVER "$DNSSEC_DNS_SERVER"
|
||||
add_env IPDB "$IPDB"
|
||||
add_env CORS "$CORS"
|
||||
add_env REMOTE_CONFIG_URL "$REMOTE_CONFIG_URL"
|
||||
add_env WS_URL "$WS_URL"
|
||||
add_env NODE_ID "$NODE_ID"
|
||||
add_env NODE_KEY "$NODE_KEY"
|
||||
add_env NODE_OTA "$NODE_OTA"
|
||||
if [ -n "$EXTRA_ENVS" ]; then
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && ENV_LINES="${ENV_LINES}Environment=\"$line\"
|
||||
"
|
||||
[ -n "$line" ] || continue
|
||||
add_env "${line%%=*}" "${line#*=}"
|
||||
done <<< "$EXTRA_ENVS"
|
||||
fi
|
||||
|
||||
@@ -221,8 +233,8 @@ Wants=network-online.target
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=$INSTALL_DIR
|
||||
ExecStart=$INSTALL_DIR/lemonipw
|
||||
WorkingDirectory="${INSTALL_DIR}"
|
||||
ExecStart="${INSTALL_DIR}/lemonipw"
|
||||
${ENV_LINES}Restart=always
|
||||
RestartSec=5
|
||||
|
||||
@@ -234,7 +246,9 @@ echo "已生成服务文件 $SERVICE_FILE"
|
||||
|
||||
# ---------- 启动服务 ----------
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now lemon-ipw
|
||||
# restart 而非 enable --now:重跑安装改配置后旧进程必须重启才会生效
|
||||
systemctl enable lemon-ipw
|
||||
systemctl restart lemon-ipw
|
||||
systemctl status lemon-ipw --no-pager | head -15
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
FROM golang:1.26-alpine AS build
|
||||
# 多架构构建:buildx 会注入 TARGETOS/TARGETARCH,构建阶段固定在构建机原生平台
|
||||
# (--platform=$BUILDPLATFORM)交叉编译,目标架构无需 QEMU 模拟;未用 buildx 时
|
||||
# TARGETOS/TARGETARCH 为空,Go 默认取宿主机平台,普通 docker build 仍然可用
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ENV GO111MODULE=on \
|
||||
CGO_ENABLED=0 \
|
||||
GOOS=linux \
|
||||
GOARCH=amd64
|
||||
GOOS=${TARGETOS} \
|
||||
GOARCH=${TARGETARCH}
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY main.go ./
|
||||
RUN go build -o main .
|
||||
COPY *.go ./
|
||||
# TARGETVARIANT 是 buildx 的 "v7"/"v6" 写法,Go 的 GOARM 只要数字;不映射时 GOARCH=arm 默认按 GOARM=6 编译
|
||||
RUN set -eux; \
|
||||
case "$TARGETVARIANT" in \
|
||||
v7) export GOARM=7 ;; \
|
||||
v6) export GOARM=6 ;; \
|
||||
esac; \
|
||||
go build -o main .
|
||||
|
||||
# 运行阶段
|
||||
FROM alpine:latest
|
||||
|
||||
+144
-43
@@ -8,8 +8,10 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
@@ -61,17 +63,20 @@ type stackConfig struct {
|
||||
// 原 apiBaseUrls/whois·ssl·detail、NSLookup/dns·dnssec、TCPing、SpeedTest 统一并入 APIBaseURL,
|
||||
// 原 IPLocationAPI/location·asn 并入 IPLocationAPI。
|
||||
type middlewareConfig struct {
|
||||
Port stringOrNumber `json:"port"`
|
||||
HTTPTimeoutSeconds int `json:"http-timeout-seconds"`
|
||||
RateLimit *int `json:"rate-limit"` // 单 IP 每分钟限流次数;缺省=默认120,0=不限流
|
||||
WSPort stringOrNumber `json:"ws-port"` // WS 端口:兼容 "8092" 与 8092;缺省=8092,"0"/0=关闭 WS 通道
|
||||
RemoteConfigURL string `json:"remote-config-url"`
|
||||
RemoteIgnoreConfig []string `json:"remote-ignore-config"` // 不被远端覆盖的配置项列表
|
||||
Cors string `json:"cors"`
|
||||
APIBaseURL stackConfig `json:"api-base-url"` // whois/ssl/detail/dns/dnssec/tcping/speed 上游节点池
|
||||
IPLocationAPI []apiInfo `json:"ip-location-api"` // location/asn 上游节点池(纯数组,无栈)
|
||||
APIKeys map[string]string `json:"api-keys"` // HTTP 转发鉴权:backendID → token(注入 Authorization: Bearer)
|
||||
WSKeys map[string]string `json:"ws-keys"` // WS 注册校验:nodeID → key(节点 register 的 key 须匹配)
|
||||
Port stringOrNumber `json:"port"`
|
||||
HTTPTimeoutSeconds int `json:"http-timeout-seconds"`
|
||||
RateLimit *int `json:"rate-limit"` // 单 IP 每分钟限流次数;缺省=默认120,0=不限流
|
||||
WSPort stringOrNumber `json:"ws-port"` // WS 端口:兼容 "8092" 与 8092;缺省=8092,"0"/0=关闭 WS 通道
|
||||
RemoteConfigURL string `json:"remote-config-url"`
|
||||
RemoteIgnoreConfig []string `json:"remote-ignore-config"` // 不被远端覆盖的配置项列表
|
||||
Cors string `json:"cors"`
|
||||
TrustedProxies string `json:"trusted-proxies"` // 可信代理 IP/CIDR(逗号分隔),c.IP() 仅信任这些来源的 X-Forwarded-For
|
||||
GhProxy string `json:"gh-proxy"` // GitHub 代理前缀(OTA 下载加速),可空
|
||||
Ota string `json:"ota"` // OTA 自更新开关,"true" 启用
|
||||
APIBaseURL stackConfig `json:"api-base-url"` // whois/ssl/detail/dns/dnssec/tcping/speed 上游节点池
|
||||
IPLocationAPI []apiInfo `json:"ip-location-api"` // location/asn 上游节点池(纯数组,无栈)
|
||||
APIKeys map[string]string `json:"api-keys"` // HTTP 转发鉴权:backendID → token(注入 Authorization: Bearer)
|
||||
WSKeys map[string]string `json:"ws-keys"` // WS 注册校验:nodeID → key(节点 register 的 key 须匹配)
|
||||
}
|
||||
|
||||
// stringOrNumber 兼容 JSON 中的字符串与数字(如 "8080" 或 8080)
|
||||
@@ -79,21 +84,31 @@ type stringOrNumber string
|
||||
|
||||
// 全局配置变量,由 readConfig 从 setting.json 填充
|
||||
var (
|
||||
VERSION string // 版本号(构建时由 ldflags 注入)
|
||||
COMMIT string // 提交哈希(构建时由 ldflags 注入)
|
||||
BUILD_TIME string // 构建时间(构建时由 ldflags 注入)
|
||||
PORT string // 监听端口
|
||||
HTTP_TIMEOUT int // 上游请求超时(秒)
|
||||
CORS string // CORS 配置原始字符串(逗号分隔的允许域名,可空)
|
||||
ACCEPT_DOMAINS []string // 允许跨域的域名列表(由 CORS 拆分而来,空则允许所有)
|
||||
API_BASE_URLS stackConfig // whois/ssl/detail/dns/dnssec/tcping/speed 上游节点池(DualStack/IPv4/IPv6)
|
||||
IP_LOCATION_APIS []apiInfo // location/asn 上游节点池(纯数组,无栈)
|
||||
API_KEYS map[string]string // backendID → HTTP 转发 token(注入 Authorization: Bearer)
|
||||
WS_KEYS map[string]string // backendID → WS 注册校验 key
|
||||
CONFIG_SOURCE string // 配置文件路径
|
||||
RATE_LIMIT int // 单 IP 每分钟限流次数(0 表示不限流),默认 120
|
||||
WS_PORT int // WS 服务端口(0 = 关闭),缺省 8092,由 readConfig 统一解析
|
||||
REMOTE_IGNORE_CONFIG []string // 不被远端覆盖的配置项列表(remote-ignore-config / REMOTE_IGNORE_CONFIG)
|
||||
VERSION string // 版本号(构建时由 ldflags 注入)
|
||||
COMMIT string // 提交哈希(构建时由 ldflags 注入)
|
||||
BUILD_TIME string // 构建时间(构建时由 ldflags 注入)
|
||||
PORT string // 监听端口
|
||||
HTTP_TIMEOUT int // 上游请求超时(秒)
|
||||
CORS string // CORS 配置原始字符串(逗号分隔的允许域名,可空)
|
||||
ACCEPT_DOMAINS []string // 允许跨域的域名列表(由 CORS 拆分而来,空则允许所有)
|
||||
API_BASE_URLS stackConfig // whois/ssl/detail/dns/dnssec/tcping/speed 上游节点池(DualStack/IPv4/IPv6)
|
||||
IP_LOCATION_APIS []apiInfo // location/asn 上游节点池(纯数组,无栈)
|
||||
API_KEYS map[string]string // backendID → HTTP 转发 token(注入 Authorization: Bearer)
|
||||
WS_KEYS map[string]string // backendID → WS 注册校验 key
|
||||
CONFIG_SOURCE string // 配置文件路径
|
||||
RATE_LIMIT int // 单 IP 每分钟限流次数(0 表示不限流),默认 120
|
||||
TRUSTED_PROXIES string // 可信代理 IP/CIDR(逗号分隔),供 c.IP() 判定,空 = 不信任 XFF
|
||||
GH_PROXY string // GitHub 代理前缀(gh-proxy / GH_PROXY),OTA 下载用,可空
|
||||
OTA string // OTA 自更新开关(ota / OTA),"true" 时启用
|
||||
WS_PORT int // WS 服务端口(0 = 关闭),缺省 8092,由 readConfig 统一解析
|
||||
REMOTE_IGNORE_CONFIG []string // 不被远端覆盖的配置项列表(remote-ignore-config / REMOTE_IGNORE_CONFIG)
|
||||
)
|
||||
|
||||
// OTA 优雅交接支持(Windows 路径):见 ota.go
|
||||
var (
|
||||
fiberApp *fiber.App // 显式持有的 Fiber 应用(gracefulShutdown 需要)
|
||||
otaHandoverWait chan struct{} // 非 nil 表示 Windows OTA 优雅重启模式:Listen 关闭后 main 停在此等待老进程退出
|
||||
otaShutdownStarted atomic.Bool // gracefulShutdown 已启动(Listen 退出时区分绑定失败与优雅关闭)
|
||||
)
|
||||
|
||||
// HTTP_CLIENT 用于转发上游请求(等价于 TS 中的 $fetch),超时由 HTTP_TIMEOUT 决定
|
||||
@@ -105,6 +120,18 @@ var wsSrv *wsServer
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
// splitAndTrim 按逗号切分并去掉每段首尾空白与空段(对齐根 main.go 的同名函数)
|
||||
func splitAndTrim(s, sep string) []string {
|
||||
parts := strings.Split(s, sep)
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// envJSON 从环境变量读取 JSON 字符串并反序列化到 target(用于数组/对象等映射表类配置项)。
|
||||
// 环境变量为空时不做任何事;解析失败返回错误,避免静默使用空配置。
|
||||
func envJSON(key string, target any) error {
|
||||
@@ -346,7 +373,11 @@ func middlewareHandler(c fiber.Ctx) error {
|
||||
return badRequest(c, "Invalid backend ID")
|
||||
}
|
||||
// ws:true 节点:拨测请求经 WS 通道转发(数据上传走 WS),失败返回 502
|
||||
if node.UseWS() && wsSrv != nil {
|
||||
if node.UseWS() {
|
||||
if wsSrv == nil {
|
||||
// 不再静默落回空 URL 的 HTTP 转发(必然 502 且掩盖真实配置错误)
|
||||
return c.Status(fiber.StatusBadGateway).SendString("Node " + backendID + " is WS-only but WS channel is disabled (ws-port=0 or bind failed)")
|
||||
}
|
||||
status, body, err := wsSrv.RequestProbe(backendID, apiType, raw, nil, wsProbeTimeout())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadGateway).SendString("WS probe failed: " + err.Error())
|
||||
@@ -381,7 +412,10 @@ func middlewareHandler(c fiber.Ctx) error {
|
||||
return badRequest(c, "Invalid backend ID")
|
||||
}
|
||||
// ws:true 节点:拨测请求经 WS 通道转发
|
||||
if node.UseWS() && wsSrv != nil {
|
||||
if node.UseWS() {
|
||||
if wsSrv == nil {
|
||||
return c.Status(fiber.StatusBadGateway).SendString("Node " + backendID + " is WS-only but WS channel is disabled (ws-port=0 or bind failed)")
|
||||
}
|
||||
status, body, err := wsSrv.RequestProbe(backendID, apiType, raw, queryMap, wsProbeTimeout())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadGateway).SendString("WS probe failed: " + err.Error())
|
||||
@@ -418,7 +452,8 @@ func viperValue(key string, target any) error {
|
||||
}
|
||||
|
||||
// fetchRemoteConfig 从远端 URL 拉取配置内容(middleware setting.json 格式)
|
||||
func fetchRemoteConfig(url string) ([]byte, error) { client := &http.Client{Timeout: 10 * time.Second}
|
||||
func fetchRemoteConfig(url string) ([]byte, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -478,6 +513,15 @@ func applyRemoteConfig(mw *middlewareConfig) error {
|
||||
if !ignored("cors") && remote.Cors != "" {
|
||||
mw.Cors = remote.Cors
|
||||
}
|
||||
if !ignored("trusted-proxies") && remote.TrustedProxies != "" {
|
||||
mw.TrustedProxies = remote.TrustedProxies
|
||||
}
|
||||
if !ignored("gh-proxy") && remote.GhProxy != "" {
|
||||
mw.GhProxy = remote.GhProxy
|
||||
}
|
||||
if !ignored("ota") && remote.Ota != "" {
|
||||
mw.Ota = remote.Ota
|
||||
}
|
||||
// 节点池结构:api-base-url 与 ip-location-api(远端非空栈时覆盖本地)
|
||||
if !ignored("api-base-url") && stackNonEmpty(remote.APIBaseURL) {
|
||||
mw.APIBaseURL = remote.APIBaseURL
|
||||
@@ -532,6 +576,24 @@ func readConfig() error {
|
||||
} else {
|
||||
mw.Cors = viper.GetString("cors")
|
||||
}
|
||||
// trusted-proxies:可信代理 IP/CIDR(逗号分隔),env > setting.json
|
||||
if v := os.Getenv("TRUSTED_PROXIES"); v != "" {
|
||||
mw.TrustedProxies = v
|
||||
} else {
|
||||
mw.TrustedProxies = viper.GetString("trusted-proxies")
|
||||
}
|
||||
// gh-proxy:GitHub 代理前缀(OTA 下载用),env > setting.json
|
||||
if v := os.Getenv("GH_PROXY"); v != "" {
|
||||
mw.GhProxy = v
|
||||
} else {
|
||||
mw.GhProxy = viper.GetString("gh-proxy")
|
||||
}
|
||||
// ota:OTA 自更新开关("true" 启用),env > setting.json
|
||||
if v := os.Getenv("OTA"); v != "" {
|
||||
mw.Ota = v
|
||||
} else {
|
||||
mw.Ota = viper.GetString("ota")
|
||||
}
|
||||
if v := os.Getenv("REMOTE_CONFIG_URL"); v != "" {
|
||||
mw.RemoteConfigURL = v
|
||||
} else {
|
||||
@@ -631,10 +693,14 @@ func readConfig() error {
|
||||
}
|
||||
REMOTE_IGNORE_CONFIG = mw.RemoteIgnoreConfig
|
||||
CORS = mw.Cors
|
||||
// 逗号分隔的 CORS 域名列表(对齐根 main.go 的 ACCEPT_DOMAINS 用法)
|
||||
// 逗号分隔的 CORS 域名列表(对齐根 main.go 的 ACCEPT_DOMAINS 用法);
|
||||
// 带空格的配置 "a.com, b.com" 直接 Split 会产生带前导空格的 origin,永远匹配不上,须 trim
|
||||
if CORS != "" {
|
||||
ACCEPT_DOMAINS = strings.Split(CORS, ",")
|
||||
ACCEPT_DOMAINS = splitAndTrim(CORS, ",")
|
||||
}
|
||||
TRUSTED_PROXIES = mw.TrustedProxies
|
||||
GH_PROXY = mw.GhProxy
|
||||
OTA = mw.Ota
|
||||
API_BASE_URLS = mw.APIBaseURL
|
||||
IP_LOCATION_APIS = mw.IPLocationAPI
|
||||
API_KEYS = mw.APIKeys
|
||||
@@ -662,6 +728,9 @@ func main() {
|
||||
}
|
||||
log.Printf("[middleware] config loaded from %s", CONFIG_SOURCE)
|
||||
|
||||
// OTA 自更新:定期检查 GitHub Release 并替换二进制(配置 ota / OTA 启用)
|
||||
initOTA(GH_PROXY)
|
||||
|
||||
timeout := HTTP_TIMEOUT
|
||||
if timeout <= 0 {
|
||||
timeout = 30
|
||||
@@ -679,8 +748,24 @@ func main() {
|
||||
log.Printf("[ws] ws channel disabled (wsPort=0)")
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
|
||||
// TRUSTED_PROXIES / trusted-proxies:逗号分隔的可信代理 IP/CIDR。配置后 c.IP()(限流按它分桶)
|
||||
// 只信任这些来源转发的 X-Forwarded-For,反代/CDN 后限流才按真实客户端 IP 计数;
|
||||
// 未配置保持默认(不信任 XFF 头)
|
||||
fiberConf := fiber.Config{}
|
||||
if strings.TrimSpace(TRUSTED_PROXIES) != "" {
|
||||
var proxies []string
|
||||
for _, p := range strings.Split(TRUSTED_PROXIES, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
proxies = append(proxies, p)
|
||||
}
|
||||
}
|
||||
fiberConf.TrustProxy = true
|
||||
fiberConf.TrustProxyConfig = fiber.TrustProxyConfig{Proxies: proxies}
|
||||
fiberConf.ProxyHeader = "X-Forwarded-For"
|
||||
log.Printf("[middleware] trusted proxies: %v", proxies)
|
||||
}
|
||||
fiberApp = fiber.New(fiberConf)
|
||||
app := fiberApp
|
||||
// 单 IP 限流(fiber 内置中间件,默认按客户端 IP 计数):次数由配置 rateLimit / 环境变量 RATE_LIMIT 决定(默认 120 次/分钟),
|
||||
// 0 表示不限流。必须放在所有路由注册之前,fiber 中间件只对注册在其后的路由生效。
|
||||
if RATE_LIMIT > 0 {
|
||||
@@ -714,19 +799,35 @@ func main() {
|
||||
app.Get("/v1/*", middlewareHandler)
|
||||
app.Get("/middleware/*", middlewareHandler)
|
||||
|
||||
// 监听地址,优先级: 环境变量 PORT > setting.json port > 默认 8080
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = PORT
|
||||
// 监听地址:readConfig 已按 env PORT > setting.json port 填好全局 PORT,
|
||||
// 这里只在为空时补默认 8080——不要再用 Getenv 覆盖,否则 setting.json 的 port 失效。
|
||||
// waitChildReady 的健康检查地址要用同一个端口
|
||||
if PORT == "" {
|
||||
PORT = "8080"
|
||||
}
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
listenAddr := port
|
||||
listenAddr := PORT
|
||||
if !strings.Contains(listenAddr, ":") {
|
||||
listenAddr = ":" + listenAddr
|
||||
}
|
||||
if err := app.Listen(listenAddr); err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
// Windows OTA 优雅交接:Listen 被 gracefulShutdown 关闭后,main 停在停车位,
|
||||
// 由 restartSelf 在新进程健康检查通过后 os.Exit 结束整个老进程
|
||||
if otaEnabled() && runtime.GOOS == "windows" {
|
||||
otaHandoverWait = make(chan struct{})
|
||||
}
|
||||
|
||||
listenErr := make(chan error, 1)
|
||||
go func() { listenErr <- app.Listen(listenAddr) }()
|
||||
err := <-listenErr
|
||||
if err != nil {
|
||||
if otaShutdownStarted.Load() {
|
||||
// 优雅关闭过程中的退出属预期,主流程已在停车位等待交接
|
||||
log.Printf("[ota] listen exited during graceful shutdown: %v", err)
|
||||
} else {
|
||||
log.Fatalf("listen failed: %v", err)
|
||||
}
|
||||
}
|
||||
if otaHandoverWait != nil {
|
||||
<-otaHandoverWait
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OTA 自更新:定期检查 GitHub Release,下载与当前平台匹配的新版二进制替换自身并重启。
|
||||
// 默认关闭,需显式开启(环境变量 OTA 或 setting.json 的 ota)。
|
||||
// 交接逻辑与后端 src/ota.go 保持一致:
|
||||
// 预检 → 原子替换 → 优雅停机 → 拉起新进程 → 健康检查确认 → 老进程退出;失败回滚 .old。
|
||||
|
||||
const (
|
||||
otaReleaseAPI = "https://api.github.com/repos/nomdn/ipw-cn/releases/latest"
|
||||
otaFirstDelay = 5 * time.Minute // 启动后首次检查的延迟(错开启动高峰)
|
||||
otaCheckInterval = 6 * time.Hour // 常规检查间隔
|
||||
otaMinSize = 1 * 1024 * 1024 // 下载文件最小体积,防止拿到错误页/占位文件
|
||||
)
|
||||
|
||||
var (
|
||||
otaMu sync.Mutex
|
||||
otaLastRun time.Time
|
||||
)
|
||||
|
||||
type otaRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
Size int64 `json:"size"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
// otaEnabled 判断是否启用 OTA("true" / "1" / "yes" / "on" 视为开启)
|
||||
func otaEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(OTA)) {
|
||||
case "true", "1", "yes", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// initOTA 在后台启动 OTA 检查循环,未启用时直接返回
|
||||
func initOTA(ghproxy string) {
|
||||
if !otaEnabled() {
|
||||
return
|
||||
}
|
||||
log.Printf("[ota] enabled, current_version=%s check_interval=%s", VERSION, otaCheckInterval)
|
||||
|
||||
go func() {
|
||||
// 首次延迟,避免启动瞬间与节点建连/数据库等启动任务抢资源
|
||||
time.Sleep(otaFirstDelay)
|
||||
for {
|
||||
checkOTAUpdate(ghproxy)
|
||||
jitter := time.Duration(rand.Int63n(int64(time.Hour)))
|
||||
time.Sleep(otaCheckInterval + jitter)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// otaAssetNames 按优先级返回当前平台可能的发布资产名(与 build_and_release.yml 命名一致)
|
||||
func otaAssetNames() []string {
|
||||
suffix := ""
|
||||
if runtime.GOOS == "windows" {
|
||||
suffix = ".exe"
|
||||
}
|
||||
arch := runtime.GOARCH
|
||||
// GOARCH=arm 时无法在运行时区分 GOARM(armv7/armv6),依次尝试
|
||||
if runtime.GOOS == "linux" && arch == "arm" {
|
||||
return []string{
|
||||
"middleware-go-linux-armv7",
|
||||
"middleware-go-linux-armv6",
|
||||
"middleware-go-linux-arm",
|
||||
}
|
||||
}
|
||||
return []string{fmt.Sprintf("middleware-go-%s-%s%s", runtime.GOOS, arch, suffix)}
|
||||
}
|
||||
|
||||
// compareVersion 比较版本号:latest > current 返回正数,相等返回 0,否则返回负数;
|
||||
// 非数字段按字典序比较。latest 为空(无法获取远端版本)时返回 0,不做更新。
|
||||
func compareVersion(latest, current string) int {
|
||||
if strings.TrimSpace(latest) == "" {
|
||||
return 0
|
||||
}
|
||||
trim := func(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "v")
|
||||
s = strings.TrimPrefix(s, "V")
|
||||
if i := strings.IndexAny(s, "-+"); i >= 0 { // 忽略预发布后缀
|
||||
s = s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
a := strings.Split(trim(latest), ".")
|
||||
b := strings.Split(trim(current), ".")
|
||||
for i := 0; i < len(a) || i < len(b); i++ {
|
||||
var x, y int
|
||||
var xe, ye error
|
||||
if i < len(a) {
|
||||
x, xe = strconv.Atoi(a[i])
|
||||
}
|
||||
if i < len(b) {
|
||||
y, ye = strconv.Atoi(b[i])
|
||||
}
|
||||
switch {
|
||||
case xe == nil && ye == nil:
|
||||
if x != y {
|
||||
return x - y
|
||||
}
|
||||
case xe == nil: // 只有当前版本非数字
|
||||
return 1
|
||||
case ye == nil:
|
||||
return -1
|
||||
default: // 两段都非数字,按字典序
|
||||
if a[i] != b[i] {
|
||||
if a[i] > b[i] {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// majorSame 判断 latest 与 current 的 major 段是否相同。
|
||||
// 任一段无法解析(如本地构建 VERSION 为空)时返回 true(放行,不拦截更新)。
|
||||
func majorSame(latest, current string) bool {
|
||||
parseMajor := func(s string) (int, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "v")
|
||||
s = strings.TrimPrefix(s, "V")
|
||||
if i := strings.IndexAny(s, "-+."); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
lm, lok := parseMajor(latest)
|
||||
cm, cok := parseMajor(current)
|
||||
if !lok || !cok {
|
||||
return true
|
||||
}
|
||||
return lm == cm
|
||||
}
|
||||
|
||||
// fetchLatestRelease 查询 GitHub 最新 Release 信息
|
||||
func fetchLatestRelease() (*otaRelease, error) {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, otaReleaseAPI, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "middleware-go-ota")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var rel otaRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rel.TagName == "" {
|
||||
return nil, fmt.Errorf("empty tag_name")
|
||||
}
|
||||
return &rel, nil
|
||||
}
|
||||
|
||||
// findAssetURL 在 Release 资产中查找当前平台的下载链接,同时返回资产大小用于完整性校验
|
||||
func findAssetURL(rel *otaRelease) (string, string, int64) {
|
||||
for _, want := range otaAssetNames() {
|
||||
for _, a := range rel.Assets {
|
||||
if a.Name == want {
|
||||
return a.BrowserDownloadURL, a.Name, a.Size
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", 0
|
||||
}
|
||||
|
||||
// checkOTAUpdate 执行一次检查:有新版本则下载替换并重启
|
||||
func checkOTAUpdate(ghproxy string) {
|
||||
otaMu.Lock()
|
||||
defer otaMu.Unlock()
|
||||
otaLastRun = time.Now()
|
||||
|
||||
rel, err := fetchLatestRelease()
|
||||
if err != nil {
|
||||
log.Printf("[ota] WARN check failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if compareVersion(rel.TagName, VERSION) <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// major 版本变化(如 3.x → 4.x)通常含破坏性变更,不自动更新,需人工升级
|
||||
if !majorSame(rel.TagName, VERSION) {
|
||||
log.Printf("[ota] skipped: major version upgrade requires manual action (latest=%s current=%s)", rel.TagName, VERSION)
|
||||
return
|
||||
}
|
||||
|
||||
assetURL, assetName, assetSize := findAssetURL(rel)
|
||||
if assetURL == "" {
|
||||
log.Printf("[ota] WARN no matching asset for this platform (goos=%s goarch=%s)", runtime.GOOS, runtime.GOARCH)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[ota] new version found: tag=%s current=%s asset=%s", rel.TagName, VERSION, assetName)
|
||||
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Printf("[ota] ERROR cannot locate executable: %v", err)
|
||||
return
|
||||
}
|
||||
exePath, err = filepath.EvalSymlinks(exePath)
|
||||
if err != nil {
|
||||
log.Printf("[ota] ERROR cannot resolve executable path: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 下载到同目录(保证与目标同分区,rename 原子替换),
|
||||
// 临时文件使用远端资产名 + .tmp 后缀,避免与运行中的 exe 冲突
|
||||
tmpPath := filepath.Join(filepath.Dir(exePath), assetName+".tmp")
|
||||
if err := downloadOTA(ghproxy+assetURL, tmpPath, assetSize); err != nil {
|
||||
log.Printf("[ota] ERROR download failed: %v", err)
|
||||
os.Remove(tmpPath)
|
||||
return
|
||||
}
|
||||
|
||||
// 预检:停机前先试运行新二进制(-v 打印版本即退出),确认文件可执行、架构匹配。
|
||||
// 损坏/被杀毒隔离的文件若直到交接时才发现,子进程会秒退导致服务中断
|
||||
if err := preflightBinary(tmpPath); err != nil {
|
||||
log.Printf("[ota] ERROR preflight failed, keep current version: %v", err)
|
||||
os.Remove(tmpPath)
|
||||
return
|
||||
}
|
||||
|
||||
if err := replaceBinary(tmpPath, exePath); err != nil {
|
||||
log.Printf("[ota] ERROR replace failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[ota] binary replaced, restarting (tag=%s)", rel.TagName)
|
||||
restartSelf(exePath)
|
||||
}
|
||||
|
||||
// preflightBinary 试运行新二进制(-v 自检:打印版本后立即退出,不读配置不占端口),
|
||||
// 确认文件可执行、架构匹配。10s 超时防损坏文件卡死。
|
||||
func preflightBinary(path string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
// Stdout/Stderr 为 nil 时输出直接丢弃
|
||||
return exec.CommandContext(ctx, path, "-v").Run()
|
||||
}
|
||||
|
||||
// downloadOTA 下载二进制到临时文件,并做体积校验(Release 声明的资产大小精确比对 + 最小体积兜底)。
|
||||
// 注意:GitHub Release 未提供签名/哈希清单,无法做内容级认证,体积校验只能防截断与错装文件。
|
||||
func downloadOTA(url, dst string, expectSize int64) error {
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
f, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(f, resp.Body)
|
||||
closeErr := f.Close()
|
||||
if copyErr != nil || closeErr != nil {
|
||||
os.Remove(dst) // 残缺文件不留
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
fi, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fi.Size() < otaMinSize {
|
||||
os.Remove(dst)
|
||||
return fmt.Errorf("downloaded file too small: %d bytes", fi.Size())
|
||||
}
|
||||
if expectSize > 0 && fi.Size() != expectSize {
|
||||
os.Remove(dst)
|
||||
return fmt.Errorf("downloaded size mismatch: got %d, want %d", fi.Size(), expectSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaceBinary 用新二进制替换当前可执行文件:
|
||||
// 1) 当前二进制改名为 .old(Windows 下运行中的 exe 不能删除,但可以重命名)
|
||||
// 2) 新文件 rename 到原位置;失败则回滚
|
||||
func replaceBinary(tmpPath, exePath string) error {
|
||||
oldPath := exePath + ".old"
|
||||
_ = os.Remove(oldPath)
|
||||
|
||||
if err := os.Rename(exePath, oldPath); err != nil {
|
||||
return fmt.Errorf("backup current binary: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, exePath); err != nil {
|
||||
_ = os.Rename(oldPath, exePath) // 回滚
|
||||
return fmt.Errorf("install new binary: %w", err)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
if err := os.Chmod(exePath, 0o755); err != nil {
|
||||
log.Printf("[ota] WARN chmod failed: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// restartSelf 用新二进制重启进程:
|
||||
// - Unix:syscall.Exec 原地替换进程镜像(PID 不变,systemd/Docker 无感;Go 的 socket 带
|
||||
// CLOEXEC,exec 瞬间旧监听关闭、新进程正常重绑)
|
||||
// - Windows:运行中的 exe 只能重命名不能替换,采用优雅交接:
|
||||
// 1) 优雅停机:停止接收新请求并等待在途请求完成,端口释放
|
||||
// 2) 拉起新进程:端口已空闲,子进程可正常绑定
|
||||
// 3) 轮询子进程健康检查通过后老进程才退出;失败回滚 .old 恢复服务
|
||||
func restartSelf(exePath string) {
|
||||
if runtime.GOOS == "windows" {
|
||||
gracefulShutdown()
|
||||
|
||||
cmd := exec.Command(exePath, os.Args[1:]...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = os.Environ()
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Printf("[ota] ERROR restart failed, waiting for supervisor: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if waitChildReady(cmd, 10*time.Minute) {
|
||||
log.Printf("[ota] new process ready, old process exiting (new_pid=%d)", cmd.Process.Pid)
|
||||
os.Exit(0)
|
||||
}
|
||||
log.Printf("[ota] ERROR new process not ready (exited or timed out), rolling back (new_pid=%d)", cmd.Process.Pid)
|
||||
|
||||
// 回滚:新版本起不来,用 .old 备份恢复服务,而不是陪它一起下线
|
||||
if rollbackToOld(exePath) {
|
||||
log.Printf("[ota] WARN rolled back to previous version, old process exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := syscall.Exec(exePath, os.Args, os.Environ()); err != nil {
|
||||
log.Printf("[ota] ERROR exec failed, wait for supervisor to restart: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// gracefulShutdown 优雅停止 HTTP 服务:停止接收新请求,等待在途请求完成(上限 30s)。
|
||||
// 供 Windows OTA 在拉起新进程前调用,确保监听端口先释放、子进程能正常绑定。
|
||||
func gracefulShutdown() {
|
||||
if fiberApp == nil {
|
||||
return
|
||||
}
|
||||
otaShutdownStarted.Store(true)
|
||||
log.Printf("[ota] graceful shutdown started, waiting for in-flight requests")
|
||||
if err := fiberApp.ShutdownWithTimeout(30 * time.Second); err != nil {
|
||||
log.Printf("[ota] WARN graceful shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// waitChildReady 轮询新进程的健康检查接口(GET /),确认其完成端口绑定并对外服务。
|
||||
// 返回 true = 就绪;false = 子进程启动期间退出(立即判定失败)或超时未就绪。
|
||||
// 判定失败的依据是"子进程退出"(进程死了立即返回),而不是固定超时——
|
||||
// 10 分钟兜底只为覆盖慢启动场景。
|
||||
func waitChildReady(cmd *exec.Cmd, timeout time.Duration) bool {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
url := "http://127.0.0.1:" + PORT + "/"
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
// 监听子进程退出:起不来直接失败,不用干等超时
|
||||
exited := make(chan struct{})
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
close(exited)
|
||||
}()
|
||||
|
||||
for {
|
||||
if resp, err := client.Get(url); err == nil {
|
||||
resp.Body.Close()
|
||||
return true
|
||||
}
|
||||
select {
|
||||
case <-exited:
|
||||
return false
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// rollbackToOld 用 .old 备份恢复服务:新版本起不来时,把旧二进制换回原位并重新拉起。
|
||||
// 返回 true 表示旧版本已就绪。恢复失败时尽力把新二进制挪回原位后返回 false。
|
||||
func rollbackToOld(exePath string) bool {
|
||||
oldPath := exePath + ".old"
|
||||
if _, err := os.Stat(oldPath); err != nil {
|
||||
log.Printf("[ota] ERROR rollback skipped: no .old backup: %v", err)
|
||||
return false
|
||||
}
|
||||
failed := exePath + ".failed"
|
||||
_ = os.Remove(failed)
|
||||
if err := os.Rename(exePath, failed); err != nil {
|
||||
log.Printf("[ota] ERROR rollback: cannot move failed binary: %v", err)
|
||||
return false
|
||||
}
|
||||
if err := os.Rename(oldPath, exePath); err != nil {
|
||||
log.Printf("[ota] ERROR rollback: cannot restore old binary: %v", err)
|
||||
_ = os.Rename(failed, exePath) // 尽力恢复现场
|
||||
return false
|
||||
}
|
||||
cmd := exec.Command(exePath, os.Args[1:]...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = os.Environ()
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Printf("[ota] ERROR rollback: cannot start old binary: %v", err)
|
||||
return false
|
||||
}
|
||||
if waitChildReady(cmd, 10*time.Minute) {
|
||||
log.Printf("[ota] WARN rollback serving traffic on previous version (pid=%d)", cmd.Process.Pid)
|
||||
return true
|
||||
}
|
||||
log.Printf("[ota] ERROR rollback: old binary also failed to become ready")
|
||||
return false
|
||||
}
|
||||
@@ -6,6 +6,9 @@
|
||||
"remote-config-url": "",
|
||||
"remote-ignore-config": [],
|
||||
"cors": "",
|
||||
"trusted-proxies": "",
|
||||
"gh-proxy": "",
|
||||
"ota": "false",
|
||||
"api-base-url": {
|
||||
"DualStack": [
|
||||
{
|
||||
|
||||
+31
-5
@@ -7,6 +7,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
@@ -54,7 +55,9 @@ type wsCommand struct {
|
||||
type wsPeer struct {
|
||||
conn *websocket.Conn
|
||||
id string
|
||||
last time.Time
|
||||
// lastAtomic 最后收到消息的时间(unix nano)。读循环写、maintenanceLoop 读,
|
||||
// 跨 goroutine 且不在 s.mu 保护内,必须原子访问
|
||||
lastAtomic atomic.Int64
|
||||
}
|
||||
|
||||
type wsServer struct {
|
||||
@@ -91,6 +94,7 @@ func (s *wsServer) Handler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
ctx := r.Context()
|
||||
peer := &wsPeer{conn: c}
|
||||
peer.lastAtomic.Store(time.Now().UnixNano())
|
||||
registered := false
|
||||
|
||||
// 读循环
|
||||
@@ -110,7 +114,7 @@ func (s *wsServer) Handler(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[ws] invalid message from %s: %v", peer.id, err)
|
||||
continue
|
||||
}
|
||||
peer.last = time.Now()
|
||||
peer.lastAtomic.Store(time.Now().UnixNano())
|
||||
|
||||
// ===== 数据阶段 =====
|
||||
switch msg.Type {
|
||||
@@ -194,10 +198,11 @@ func (s *wsServer) Handler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wsServer) sendJSON(c *websocket.Conn, msg wsMessage) {
|
||||
// sendJSON 发送 JSON 消息并返回写错误(写失败意味着对端连接已不可用,调用方应尽快失败)
|
||||
func (s *wsServer) sendJSON(c *websocket.Conn, msg wsMessage) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = c.Write(ctx, websocket.MessageText, mustRaw(msg))
|
||||
return c.Write(ctx, websocket.MessageText, mustRaw(msg))
|
||||
}
|
||||
|
||||
// RequestProbe 通过 WS 通道向指定节点发送拨测请求并等待结果(超时返回错误)。
|
||||
@@ -221,7 +226,13 @@ func (s *wsServer) RequestProbe(nodeID, apiType, raw string, query map[string]st
|
||||
}()
|
||||
|
||||
payload := wsProbeRequest{RequestID: reqID, APIType: apiType, Raw: raw, Query: query}
|
||||
s.sendJSON(peer.conn, wsMessage{Type: "probe", NodeID: nodeID, TS: time.Now().Unix(), Data: mustRaw(payload)})
|
||||
if err := s.sendJSON(peer.conn, wsMessage{Type: "probe", NodeID: nodeID, TS: time.Now().Unix(), Data: mustRaw(payload)}); err != nil {
|
||||
// 连接已死(写失败立刻可知),直接返回错误,不再让请求干等满超时
|
||||
s.statMu.Lock()
|
||||
s.errReqs++
|
||||
s.statMu.Unlock()
|
||||
return 0, nil, fmt.Errorf("ws probe send failed for node %s: %w", nodeID, err)
|
||||
}
|
||||
|
||||
s.statMu.Lock()
|
||||
s.totalReqs++
|
||||
@@ -292,6 +303,21 @@ func (s *wsServer) maintenanceLoop() {
|
||||
s.statMu.Unlock()
|
||||
|
||||
for _, p := range peers {
|
||||
// 空闲剔除:超过 75s(约 3 个心跳周期)没有任何消息的节点视为僵死。
|
||||
// 不剔除的话死连接会永久占位,路由到它的探测全部干等超时
|
||||
s.mu.Lock()
|
||||
idle := time.Since(time.Unix(0, p.lastAtomic.Load()))
|
||||
s.mu.Unlock()
|
||||
if idle > 75*time.Second {
|
||||
log.Printf("[ws] node %s idle for %s, evicting", p.id, idle.Round(time.Second))
|
||||
p.conn.Close(websocket.StatusPolicyViolation, "idle timeout")
|
||||
s.mu.Lock()
|
||||
if cur, ok := s.peers[p.id]; ok && cur == p {
|
||||
delete(s.peers, p.id)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
// 心跳
|
||||
s.sendJSON(p.conn, wsMessage{Type: "ping", NodeID: p.id, TS: time.Now().Unix()})
|
||||
// 状态/统计上报
|
||||
|
||||
@@ -241,6 +241,9 @@ func initHttpClient() {
|
||||
V4Client.SetTransport(setTransport("tcp4"))
|
||||
V6Client.SetTimeout(10 * time.Second)
|
||||
V4Client.SetTimeout(10 * time.Second)
|
||||
// 响应体上限:探针会读取任意用户提供的 URL,无上限时 10s 窗口内可灌入数 GB 导致 OOM
|
||||
V6Client.SetResponseBodyLimit(10 * 1024 * 1024)
|
||||
V4Client.SetResponseBodyLimit(10 * 1024 * 1024)
|
||||
V6Client.SetRedirectPolicy(resty.RedirectPolicyFunc(ssrf.SecureCheckRedirect))
|
||||
V4Client.SetRedirectPolicy(resty.RedirectPolicyFunc(ssrf.SecureCheckRedirect))
|
||||
V6Client.AddContentDecompresser("zstd", decompressZstd)
|
||||
@@ -555,13 +558,15 @@ func cleanHostRecord(addr string) string {
|
||||
|
||||
func normalizeURL(input string) string {
|
||||
input = strings.TrimSpace(input)
|
||||
input = strings.TrimPrefix(input, "/")
|
||||
if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") {
|
||||
return input
|
||||
}
|
||||
// 协议相对路径必须先于剥前导斜杠判断,否则 "//example.com" 被剥成 "/example.com",
|
||||
// 该分支永远不会命中,最终拼出空 host 的 "https:///example.com"
|
||||
if strings.HasPrefix(input, "//") {
|
||||
return "https:" + input
|
||||
}
|
||||
input = strings.TrimPrefix(input, "/")
|
||||
return "https://" + input
|
||||
}
|
||||
|
||||
@@ -581,8 +586,8 @@ func checkWebsiteHandler(c *gin.Context) {
|
||||
|
||||
testUrl = normalizeURL(testUrl)
|
||||
|
||||
parsedURL, _ := url.Parse(testUrl)
|
||||
if ssrf.HasLocalOrPrivateIP(parsedURL.Hostname()) {
|
||||
parsedURL, err := url.Parse(testUrl)
|
||||
if err == nil && ssrf.HasLocalOrPrivateIP(parsedURL.Hostname()) {
|
||||
c.JSON(200, &WebsiteCheckResult{
|
||||
IPv4: fakePerfectWebsiteResult(testUrl),
|
||||
IPv6: fakePerfectWebsiteResult(testUrl),
|
||||
@@ -682,8 +687,8 @@ func sslCheckHandler(c *gin.Context) {
|
||||
|
||||
testUrl = normalizeURL(testUrl)
|
||||
|
||||
parsedURL, _ := url.Parse(testUrl)
|
||||
if ssrf.HasLocalOrPrivateIP(parsedURL.Hostname()) {
|
||||
parsedURL, err := url.Parse(testUrl)
|
||||
if err == nil && ssrf.HasLocalOrPrivateIP(parsedURL.Hostname()) {
|
||||
c.JSON(200, &SSLCheckResult{
|
||||
IPv4: fakeInvalidSSLResult(parsedURL.Hostname()),
|
||||
IPv6: fakeInvalidSSLResult(parsedURL.Hostname()),
|
||||
@@ -1179,7 +1184,7 @@ func applyRemoteConfig() {
|
||||
ssrf.SetEnabled(v != "false" && v != "0")
|
||||
}
|
||||
if CORS != "" {
|
||||
ACCEPT_DOMAINS = strings.Split(CORS, ",")
|
||||
ACCEPT_DOMAINS = splitAndTrim(CORS, ",")
|
||||
}
|
||||
slog.Info("Remote config applied", "url", url)
|
||||
}
|
||||
@@ -1216,17 +1221,73 @@ func readConfig() {
|
||||
PORTS = "8080"
|
||||
}
|
||||
if CORS != "" {
|
||||
ACCEPT_DOMAINS = strings.Split(CORS, ",")
|
||||
ACCEPT_DOMAINS = splitAndTrim(CORS, ",")
|
||||
}
|
||||
slog.Info("SSRF protection initialized", "blockPrivateIPs", ssrf.Enabled())
|
||||
}
|
||||
|
||||
// splitAndTrim 按逗号切分并去掉每段首尾空白:CORS 配置 "a.com, b.com" 带空格时,
|
||||
// 直接 Split 会产生带前导空格的 origin,永远匹配不上请求头
|
||||
func splitAndTrim(s, sep string) []string {
|
||||
parts := strings.Split(s, sep)
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sweepExpiredCaches 按条目时间戳清扫各 sync.Map 缓存(TTL 均为 5 分钟,清扫窗口取 10 分钟),
|
||||
// 缓存条目只在同 key 重访时惰性淘汰,公网端点被唯一 key 洪打时内存会无限增长
|
||||
func sweepExpiredCaches() {
|
||||
cutoff := time.Now().Add(-10 * time.Minute)
|
||||
sweep := func(m *sync.Map) {
|
||||
m.Range(func(k, v any) bool {
|
||||
var ts time.Time
|
||||
switch e := v.(type) {
|
||||
case websiteCacheEntry:
|
||||
ts = e.timestamp
|
||||
case sslCacheEntry:
|
||||
ts = e.timestamp
|
||||
case pingCacheEntry:
|
||||
ts = e.timestamp
|
||||
case speedCacheEntry:
|
||||
ts = e.timestamp
|
||||
case whoisCacheEntry:
|
||||
ts = e.timestamp
|
||||
default:
|
||||
return true
|
||||
}
|
||||
if ts.Before(cutoff) {
|
||||
m.Delete(k)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
sweep(&websiteCache)
|
||||
sweep(&sslCache)
|
||||
sweep(&pingCache)
|
||||
sweep(&speedCache)
|
||||
sweep(&whoisCache)
|
||||
}
|
||||
|
||||
func main() {
|
||||
readConfig()
|
||||
initHttpClient()
|
||||
webtest.SetDNSServer(DNS_SERVER)
|
||||
webtest.SetDNSSecServer(DNSSEC_DNS_SERVER)
|
||||
slog.Info("Starting server", "port", PORTS, "single_stack", SINGLE_STACK)
|
||||
|
||||
// 缓存清扫:定期淘汰过期条目
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(10 * time.Minute)
|
||||
sweepExpiredCaches()
|
||||
}
|
||||
}()
|
||||
|
||||
r := gin.Default()
|
||||
corsConfig := cors.DefaultConfig()
|
||||
if len(ACCEPT_DOMAINS) > 0{
|
||||
|
||||
@@ -45,24 +45,49 @@ func SetEnabled(v bool) {
|
||||
blockPrivateIPs = v
|
||||
}
|
||||
|
||||
// Go 标准库 IsPrivate 只覆盖 RFC1918 与 fd00::/8,这里补齐其余不应出网的内部/特殊网段
|
||||
var extraPrivateNets = mustParseCIDRs(
|
||||
"100.64.0.0/10", // CGNAT:运营商级 NAT、云内网、Tailscale 等
|
||||
"198.18.0.0/15", // 网络基准测试保留段
|
||||
"192.0.0.0/24", // IETF 协议分配
|
||||
"fc00::/7", // IPv6 ULA(IsPrivate 只认 fd00::/8)
|
||||
"64:ff9b::/96", // NAT64:内嵌 IPv4 会被网关转换
|
||||
)
|
||||
|
||||
func mustParseCIDRs(cidrs ...string) []*net.IPNet {
|
||||
nets := make([]*net.IPNet, 0, len(cidrs))
|
||||
for _, c := range cidrs {
|
||||
_, ipNet, err := net.ParseCIDR(c)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("invalid CIDR %q: %v", c, err))
|
||||
}
|
||||
nets = append(nets, ipNet)
|
||||
}
|
||||
return nets
|
||||
}
|
||||
|
||||
// IsPrivateIP checks if the given IP address belongs to a private or internal range.
|
||||
// IsPrivateIP 检查给定的 IP 地址是否属于私有或内部地址段。
|
||||
// It covers RFC 1918 private addresses, loopback, link-local, and unspecified addresses.
|
||||
// 涵盖 RFC 1918 私有地址、回环地址、链路本地地址和未指定地址。
|
||||
// It covers RFC 1918 private addresses, loopback, link-local, and unspecified addresses,
|
||||
// plus CGNAT/ULA/NAT64/benchmark/multicast ranges that Go's IsPrivate does not cover.
|
||||
// 涵盖 RFC 1918 私有地址、回环地址、链路本地地址和未指定地址,
|
||||
// 以及 Go 标准库未覆盖的 CGNAT/ULA/NAT64/基准测试/组播段。
|
||||
// This is the core check used to block SSRF attacks targeting internal networks.
|
||||
// 这是用于阻止针对内部网络的 SSRF 攻击的核心检查。
|
||||
func IsPrivateIP(ip net.IP) bool {
|
||||
if ip.IsPrivate() {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() || ip.IsMulticast() {
|
||||
return true
|
||||
}
|
||||
if ip.IsLoopback() {
|
||||
if ip.Equal(net.IPv4bcast) {
|
||||
return true
|
||||
}
|
||||
if ip.IsLinkLocalUnicast() {
|
||||
return true
|
||||
}
|
||||
if ip.IsUnspecified() {
|
||||
return true
|
||||
for _, n := range extraPrivateNets {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -133,36 +158,35 @@ func ValidateOutboundTarget(ctx context.Context, targetURL string) (context.Cont
|
||||
// SecureCheckRedirect is a redirect policy that prevents SSRF via HTTP redirect chains.
|
||||
// SecureCheckRedirect 是一个重定向策略,防止通过 HTTP 重定向链进行 SSRF 攻击。
|
||||
//
|
||||
// When an HTTP client follows redirects (301, 302, 307, 308), each intermediate target
|
||||
// 当 HTTP 客户端跟随重定向(301、302、307、308)时,会校验每个中间目标
|
||||
// is resolved and checked against private/internal IP ranges.
|
||||
// 的解析结果,并检查是否指向私有/内部 IP 段。
|
||||
//
|
||||
// This blocks attackers from:
|
||||
// 这阻止攻击者通过以下方式绕过防护:
|
||||
// - Using an initial public redirect that points to an internal service.
|
||||
// 使用指向内部服务的初始公开重定向。
|
||||
// - Chaining multiple redirects to eventually reach a private IP.
|
||||
// 链式多个重定向最终到达私有 IP。
|
||||
// req 是即将跳转的目标(via 是已访问的链)。必须校验目标本身:
|
||||
// 只检查 via 会漏掉重定向链的最后一跳。每一跳的目标都会被解析并检查
|
||||
// 是否指向私有/内部 IP 段,同时限制重定向深度。
|
||||
// 这阻止攻击者通过链式重定向最终到达私有 IP。
|
||||
func SecureCheckRedirect(req *http.Request, via []*http.Request) error {
|
||||
if !blockPrivateIPs {
|
||||
return nil
|
||||
}
|
||||
for _, r := range via {
|
||||
redirectURL := r.URL
|
||||
host := redirectURL.Hostname()
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if IsPrivateIP(ip) {
|
||||
slog.Warn("Blocked redirect to private IP", "host", host, "ip", ip)
|
||||
return fmt.Errorf("redirect to private/internal address is not allowed")
|
||||
}
|
||||
if req == nil || req.URL == nil {
|
||||
return fmt.Errorf("invalid redirect target")
|
||||
}
|
||||
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
|
||||
return fmt.Errorf("invalid redirect scheme: %s", req.URL.Scheme)
|
||||
}
|
||||
host := req.URL.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("redirect target has empty host")
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("stopped after 10 redirects")
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if IsPrivateIP(ip) {
|
||||
slog.Warn("Blocked redirect to private IP", "host", host, "ip", ip)
|
||||
return fmt.Errorf("redirect to private/internal address is not allowed")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"math"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"edgeone-cloud-functions/ssrf"
|
||||
)
|
||||
|
||||
type TCPingResult struct {
|
||||
@@ -37,6 +39,13 @@ func TCPing(host string, port string, version string, timeout time.Duration) (*T
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// SSRF 防护:内网/云元数据段(127.0.0.1、10.x、169.254.169.254 等)直接拒绝,
|
||||
// 否则 tcping 接口会被当作内网端口扫描器
|
||||
if ssrf.Enabled() {
|
||||
if pip := net.ParseIP(ip); pip != nil && ssrf.IsPrivateIP(pip) {
|
||||
return nil, fmt.Errorf("request to private/internal address is not allowed")
|
||||
}
|
||||
}
|
||||
addr := ""
|
||||
switch version {
|
||||
case "v4":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package webtest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -343,19 +344,29 @@ func rawWhoisQueryCtx(ctx context.Context, domain, server, port string) (string,
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 读取响应
|
||||
// 3. 读取响应:循环读到 EOF/超时。单次 Read 只能拿到一个 TCP 段,会截断大多数 WHOIS 响应
|
||||
buf := make([]byte, 65536)
|
||||
n, readErr := conn.Read(buf)
|
||||
|
||||
// 有数据就读到了,哪怕 readErr != nil 也先返回数据
|
||||
if n > 0 {
|
||||
return string(buf[:n]), nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
default:
|
||||
return "", readErr
|
||||
var out bytes.Buffer
|
||||
for {
|
||||
n, readErr := conn.Read(buf)
|
||||
if n > 0 {
|
||||
out.Write(buf[:n])
|
||||
}
|
||||
if readErr != nil {
|
||||
// 有数据就返回(EOF/超时都视为读完),哪怕 readErr != nil
|
||||
if out.Len() > 0 {
|
||||
return out.String(), nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
default:
|
||||
return "", readErr
|
||||
}
|
||||
}
|
||||
if out.Len() > 1<<20 { // 防御上限 1MB,避免异常响应无限读
|
||||
return out.String(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-5
@@ -1,16 +1,28 @@
|
||||
FROM golang:1.26-alpine AS build
|
||||
# 多架构构建:buildx 会注入 TARGETOS/TARGETARCH,构建阶段固定在构建机原生平台
|
||||
# (--platform=$BUILDPLATFORM)交叉编译,目标架构无需 QEMU 模拟;未用 buildx 时
|
||||
# TARGETOS/TARGETARCH 为空,Go 默认取宿主机平台,普通 docker build 仍然可用
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ENV GO111MODULE=on \
|
||||
CGO_ENABLED=0 \
|
||||
GOOS=linux \
|
||||
GOARCH=amd64
|
||||
GOOS=${TARGETOS} \
|
||||
GOARCH=${TARGETARCH}
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY main.go ./
|
||||
COPY *.go ./
|
||||
COPY webtest/ ./webtest/
|
||||
COPY ipdb/ ./ipdb/
|
||||
COPY ssrf/ ./ssrf/
|
||||
RUN go build -o main .
|
||||
# TARGETVARIANT 是 buildx 的 "v7"/"v6" 写法,Go 的 GOARM 只要数字;不映射时 GOARCH=arm 默认按 GOARM=6 编译
|
||||
RUN set -eux; \
|
||||
case "$TARGETVARIANT" in \
|
||||
v7) export GOARM=7 ;; \
|
||||
v6) export GOARM=6 ;; \
|
||||
esac; \
|
||||
go build -o main .
|
||||
|
||||
# 运行阶段
|
||||
FROM alpine:latest
|
||||
|
||||
+15
-5
@@ -65,7 +65,12 @@ func PullDatabase(ghproxy string) error {
|
||||
}
|
||||
|
||||
slog.Info("Copying downloaded files to working directory...")
|
||||
for _, t := range tasks {
|
||||
for i, t := range tasks {
|
||||
if errs[i] != nil {
|
||||
// 下载失败(含传输中断留下的残缺文件)不覆盖现有数据库
|
||||
slog.Warn("Skipping failed download, keep existing file", "file", t.name)
|
||||
continue
|
||||
}
|
||||
src := filepath.Join(tmpDir, t.name)
|
||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||
slog.Warn("Skipping missing file", "file", t.name)
|
||||
@@ -76,16 +81,19 @@ func PullDatabase(ghproxy string) error {
|
||||
slog.Error("Failed to copy file", "file", t.name, "error", err)
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(t.name, ".gz") {
|
||||
outName := strings.TrimSuffix(t.name, ".gz")
|
||||
lower := strings.ToLower(t.name)
|
||||
// 后缀判断用小写比较:IP2LOCATION 的资产名是大写 .ZIP,原来 HasSuffix(".zip") 永远匹配不上,
|
||||
// 导致该数据库从不解压就被删除
|
||||
if strings.HasSuffix(lower, ".gz") {
|
||||
outName := t.name[:len(t.name)-3]
|
||||
slog.Info("Decompressing...", "file", t.name, "output", outName)
|
||||
if err := gunzipFile(dst, "./"+outName); err != nil {
|
||||
slog.Error("Failed to decompress", "file", t.name, "error", err)
|
||||
}
|
||||
os.Remove(dst)
|
||||
}
|
||||
if strings.HasSuffix(t.name, ".zip") {
|
||||
outName := strings.TrimSuffix(t.name, ".zip")
|
||||
if strings.HasSuffix(lower, ".zip") {
|
||||
outName := t.name[:len(t.name)-4]
|
||||
slog.Info("Unzipping...", "file", t.name, "output", outName)
|
||||
if err := unzipFile(dst, "./"+outName); err != nil {
|
||||
slog.Error("Failed to unzip", "file", t.name, "error", err)
|
||||
@@ -118,6 +126,8 @@ func downloadWithRetry(url, name string) error {
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("request failed: %w", err)
|
||||
slog.Warn("Download attempt failed", "file", name, "attempt", attempt, "error", err)
|
||||
// 传输中断会在输出文件里留下残缺内容,删掉避免被当作下载成功覆盖现有数据库
|
||||
os.Remove(filepath.Join("./tmp", name))
|
||||
time.Sleep(time.Duration(attempt) * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
+13
-1
@@ -549,7 +549,7 @@ func searchBilibili(ip string) (*BilibiliResult, error) {
|
||||
bilibiliCache.Delete(ip)
|
||||
}
|
||||
|
||||
client := resty.New()
|
||||
client := resty.New().SetTimeout(10 * time.Second)
|
||||
defer client.Close()
|
||||
resp, err := client.R().
|
||||
SetQueryParam("ip", ip).
|
||||
@@ -575,6 +575,18 @@ func searchBilibili(ip string) (*BilibiliResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SweepBilibiliCache 清理过期的 Bilibili 归属地缓存(由外部定时调用,
|
||||
// 防止公网端点被唯一 key 洪打时缓存内存无限增长)
|
||||
func SweepBilibiliCache() {
|
||||
cutoff := time.Now().Add(-24 * time.Hour)
|
||||
bilibiliCache.Range(func(k, v any) bool {
|
||||
if e, ok := v.(bilibiliCacheEntry); ok && e.timestamp.Before(cutoff) {
|
||||
bilibiliCache.Delete(k)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func loadAll() {
|
||||
if err := loadIp2Region(); err != nil {
|
||||
slog.Error("Error loading ip2region", "error", err)
|
||||
|
||||
+173
-17
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"lemon-ipw/ipdb"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -235,10 +237,14 @@ var (
|
||||
ACCEPT_DOMAINS []string
|
||||
ACCESS_TOKEN string
|
||||
REMOTE_CONFIG_URL string
|
||||
REMOTE_IGNORE_CONFIG []string // 不被远端配置覆盖的配置项列表(remote-ignore-config / REMOTE_IGNORE_CONFIG)
|
||||
WS_URL string // WS 客户端:中间件 WS 地址(ws://host:port/ws)
|
||||
WS_NODE_ID string // WS 客户端:节点 id(与中间件 ws-keys 键一致)
|
||||
WS_NODE_KEY string // WS 客户端:注册 key(与中间件 ws-keys[节点id] 一致,可空)
|
||||
REMOTE_IGNORE_CONFIG []string // 不被远端配置覆盖的配置项列表(remote-ignore-config / REMOTE_IGNORE_CONFIG)
|
||||
WS_URL string // WS 客户端:中间件 WS 地址(ws://host:port/ws)
|
||||
WS_NODE_ID string // WS 客户端:节点 id(与中间件 ws-keys 键一致)
|
||||
WS_NODE_KEY string // WS 客户端:注册 key(与中间件 ws-keys[节点id] 一致,可空)
|
||||
NODE_OTA string // OTA 自更新开关(node-ota / NODE_OTA),"true" 时启用
|
||||
TRUSTED_PROXIES string // 可信代理列表(trusted-proxies / TRUSTED_PROXIES),逗号分隔 IP/CIDR,供 ClientIP 判定
|
||||
httpServer *http.Server // 显式持有的 HTTP 服务(Windows OTA 优雅重启需要先 Shutdown)
|
||||
otaHandoverWait chan struct{} // 非 nil 表示 Windows OTA 优雅重启模式:Serve 关闭后 main 停在此等待老进程退出
|
||||
VERSION string
|
||||
COMMIT string
|
||||
BUILD_TIME string
|
||||
@@ -302,8 +308,8 @@ func checkWebsiteHandler(c *gin.Context) {
|
||||
|
||||
testUrl = normalizeURL(testUrl)
|
||||
|
||||
parsedURL, _ := url.Parse(testUrl)
|
||||
if ssrf.HasLocalOrPrivateIP(parsedURL.Hostname()) {
|
||||
parsedURL, err := url.Parse(testUrl)
|
||||
if err == nil && ssrf.HasLocalOrPrivateIP(parsedURL.Hostname()) {
|
||||
c.JSON(200, &WebsiteCheckResult{
|
||||
IPv4: fakePerfectWebsiteResult(testUrl),
|
||||
IPv6: fakePerfectWebsiteResult(testUrl),
|
||||
@@ -382,7 +388,17 @@ func checkWebsiteHandler(c *gin.Context) {
|
||||
|
||||
websiteCache.Store(testUrl, websiteCacheEntry{result: result, timestamp: time.Now()})
|
||||
|
||||
if (result.IPv4 != nil && !result.IPv4.IsReachable) || (result.IPv6 != nil && !result.IPv6.IsReachable) {
|
||||
// 单栈模式下被跳过的一侧 IsReachable 恒为 false,不能据此把成功结果也判定为失败提前清缓存
|
||||
failed := false
|
||||
switch SINGLE_STACK {
|
||||
case "ipv4":
|
||||
failed = result.IPv4 != nil && !result.IPv4.IsReachable
|
||||
case "ipv6":
|
||||
failed = result.IPv6 != nil && !result.IPv6.IsReachable
|
||||
default:
|
||||
failed = (result.IPv4 != nil && !result.IPv4.IsReachable) || (result.IPv6 != nil && !result.IPv6.IsReachable)
|
||||
}
|
||||
if failed {
|
||||
go func() {
|
||||
time.Sleep(30 * time.Second)
|
||||
websiteCache.Delete(testUrl)
|
||||
@@ -480,8 +496,8 @@ func sslCheckHandler(c *gin.Context) {
|
||||
|
||||
testUrl = normalizeURL(testUrl)
|
||||
|
||||
parsedURL, _ := url.Parse(testUrl)
|
||||
if ssrf.HasLocalOrPrivateIP(parsedURL.Hostname()) {
|
||||
parsedURL, err := url.Parse(testUrl)
|
||||
if err == nil && ssrf.HasLocalOrPrivateIP(parsedURL.Hostname()) {
|
||||
c.JSON(200, &SSLCheckResult{
|
||||
IPv4: fakeInvalidSSLResult(parsedURL.Hostname()),
|
||||
IPv6: fakeInvalidSSLResult(parsedURL.Hostname()),
|
||||
@@ -566,7 +582,17 @@ func sslCheckHandler(c *gin.Context) {
|
||||
|
||||
sslCache.Store(testUrl, sslCacheEntry{result: result, timestamp: time.Now()})
|
||||
|
||||
if (result.IPv4 != nil && !result.IPv4.IsReachable) || (result.IPv6 != nil && !result.IPv6.IsReachable) {
|
||||
// 单栈模式下被跳过的一侧 IsReachable 恒为 false,不能据此把成功结果也判定为失败提前清缓存
|
||||
failed := false
|
||||
switch SINGLE_STACK {
|
||||
case "ipv4":
|
||||
failed = result.IPv4 != nil && !result.IPv4.IsReachable
|
||||
case "ipv6":
|
||||
failed = result.IPv6 != nil && !result.IPv6.IsReachable
|
||||
default:
|
||||
failed = (result.IPv4 != nil && !result.IPv4.IsReachable) || (result.IPv6 != nil && !result.IPv6.IsReachable)
|
||||
}
|
||||
if failed {
|
||||
go func() {
|
||||
time.Sleep(30 * time.Second)
|
||||
sslCache.Delete(testUrl)
|
||||
@@ -831,6 +857,8 @@ func pingHandler(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
// 拨号用归一化后的端口:Atoi 会放行 "+80"/"0080",原样拼接会导致拨号必败
|
||||
port = strconv.Itoa(portNum)
|
||||
|
||||
count := 4
|
||||
if countStr := c.Query("count"); countStr != "" {
|
||||
@@ -1042,9 +1070,17 @@ func applyRemoteConfig() {
|
||||
if v := configValue(CONFIG, "node-key"); v != "" && !ignored("node-key") {
|
||||
WS_NODE_KEY = v
|
||||
}
|
||||
// OTA 自更新开关(远端可统一开启/关闭节点的自更新)
|
||||
if v := configValue(CONFIG, "node-ota"); v != "" && !ignored("node-ota") {
|
||||
NODE_OTA = v
|
||||
}
|
||||
// 可信代理列表(远端可统一下发)
|
||||
if v := configValue(CONFIG, "trusted-proxies"); v != "" && !ignored("trusted-proxies") {
|
||||
TRUSTED_PROXIES = v
|
||||
}
|
||||
// access-token 不在此覆盖:保持原有优先级(环境变量 > setting.json)
|
||||
if CORS != "" {
|
||||
ACCEPT_DOMAINS = strings.Split(CORS, ",")
|
||||
ACCEPT_DOMAINS = splitAndTrim(CORS, ",")
|
||||
}
|
||||
slog.Info("Remote config applied", "url", url)
|
||||
}
|
||||
@@ -1061,6 +1097,7 @@ func readConfig() {
|
||||
IPDB = os.Getenv("IPDB")
|
||||
CORS = os.Getenv("CORS")
|
||||
ACCESS_TOKEN = os.Getenv("ACCESS_TOKEN")
|
||||
TRUSTED_PROXIES = os.Getenv("TRUSTED_PROXIES")
|
||||
ssrf.SetEnabled(os.Getenv("BLOCK_PRIVATE_IPS") != "false" && os.Getenv("BLOCK_PRIVATE_IPS") != "0")
|
||||
|
||||
// SINGLE_STACK is intentionally excluded: empty string is a valid value (dual-stack).
|
||||
@@ -1092,11 +1129,14 @@ func readConfig() {
|
||||
if CORS == "" {
|
||||
CORS = viper.GetString("cors")
|
||||
}
|
||||
if TRUSTED_PROXIES == "" {
|
||||
TRUSTED_PROXIES = viper.GetString("trusted-proxies")
|
||||
}
|
||||
if PORTS == "" {
|
||||
PORTS = "8080"
|
||||
}
|
||||
if CORS != "" {
|
||||
ACCEPT_DOMAINS = strings.Split(CORS, ",")
|
||||
ACCEPT_DOMAINS = splitAndTrim(CORS, ",")
|
||||
}
|
||||
if ACCESS_TOKEN == "" {
|
||||
ACCESS_TOKEN = viper.GetString("access-token")
|
||||
@@ -1119,6 +1159,11 @@ func readConfig() {
|
||||
if WS_NODE_KEY == "" {
|
||||
WS_NODE_KEY = viper.GetString("node-key")
|
||||
}
|
||||
// NODE_OTA:OTA 自更新开关(环境变量 NODE_OTA 或 setting.json 的 node-ota)
|
||||
NODE_OTA = os.Getenv("NODE_OTA")
|
||||
if NODE_OTA == "" {
|
||||
NODE_OTA = viper.GetString("node-ota")
|
||||
}
|
||||
// REMOTE_IGNORE_CONFIG:不被远端覆盖的配置项列表(JSON 数组字符串,env 优先)
|
||||
if raw := os.Getenv("REMOTE_IGNORE_CONFIG"); raw != "" {
|
||||
var list []string
|
||||
@@ -1135,11 +1180,13 @@ func readConfig() {
|
||||
if DNS_SERVER == "" || DNSSEC_DNS_SERVER == "" {
|
||||
sysDNS, err := sysresolv.NewSystemResolvers(nil, 53)
|
||||
if err != nil {
|
||||
slog.Warn("Cannot setup reslovers")
|
||||
|
||||
// 失败时 sysDNS 为 nil,绝不能再调用 Refresh(),否则启动即 panic;
|
||||
// SYSTEM_DNS 留空,交给下游的内置默认 DNS 兜底
|
||||
slog.Warn("Cannot setup system resolvers, DNS_SERVER will fall back to webtest defaults", "error", err)
|
||||
} else {
|
||||
sysDNS.Refresh()
|
||||
SYSTEM_DNS = webtest.AddrPortsToCSV(sysDNS.Addrs())
|
||||
}
|
||||
sysDNS.Refresh()
|
||||
SYSTEM_DNS = webtest.AddrPortsToCSV(sysDNS.Addrs())
|
||||
}
|
||||
if DNSSEC_DNS_SERVER == "" {
|
||||
DNSSEC_DNS_SERVER = SYSTEM_DNS
|
||||
@@ -1150,6 +1197,56 @@ func readConfig() {
|
||||
slog.Info("SSRF protection initialized", "blockPrivateIPs", ssrf.Enabled())
|
||||
}
|
||||
|
||||
// splitAndTrim 按逗号切分并去掉每段首尾空白:CORS 配置 "a.com, b.com" 带空格时,
|
||||
// 直接 Split 会产生带前导空格的 origin,永远匹配不上请求头
|
||||
func splitAndTrim(s, sep string) []string {
|
||||
parts := strings.Split(s, sep)
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sweepExpiredCaches 按条目时间戳清扫各 sync.Map 缓存(TTL 均为 5 分钟,清扫窗口取 10 分钟)
|
||||
func sweepExpiredCaches() {
|
||||
cutoff := time.Now().Add(-10 * time.Minute)
|
||||
sweep := func(m *sync.Map) {
|
||||
m.Range(func(k, v any) bool {
|
||||
var ts time.Time
|
||||
switch e := v.(type) {
|
||||
case websiteCacheEntry:
|
||||
ts = e.timestamp
|
||||
case sslCacheEntry:
|
||||
ts = e.timestamp
|
||||
case pingCacheEntry:
|
||||
ts = e.timestamp
|
||||
case speedCacheEntry:
|
||||
ts = e.timestamp
|
||||
case whoisCacheEntry:
|
||||
ts = e.timestamp
|
||||
case asnWhoisCacheEntry:
|
||||
ts = e.timestamp
|
||||
default:
|
||||
return true
|
||||
}
|
||||
if ts.Before(cutoff) {
|
||||
m.Delete(k)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
sweep(&websiteCache)
|
||||
sweep(&sslCache)
|
||||
sweep(&pingCache)
|
||||
sweep(&speedCache)
|
||||
sweep(&whoisCache)
|
||||
sweep(&asnWhoisCache)
|
||||
ipdb.SweepBilibiliCache()
|
||||
}
|
||||
|
||||
func main() {
|
||||
for _, vaule := range os.Args {
|
||||
if vaule == "-v" || vaule == "--version" || vaule == "version" {
|
||||
@@ -1179,7 +1276,32 @@ func main() {
|
||||
slog.Info("WS client enabled", "url", WS_URL, "nodeId", WS_NODE_ID)
|
||||
}
|
||||
|
||||
// OTA 自更新:定期检查 GitHub Release 并替换二进制(配置 NODE_OTA / node-ota 启用)
|
||||
initOTA(GH_PROXY)
|
||||
|
||||
// 缓存清扫:各 sync.Map 只在同 key 重访时惰性淘汰过期条目,
|
||||
// 公网端点被唯一 key 洪打时内存会无限增长,这里定期清扫
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(10 * time.Minute)
|
||||
sweepExpiredCaches()
|
||||
}
|
||||
}()
|
||||
|
||||
r := gin.Default()
|
||||
// TRUSTED_PROXIES / trusted-proxies:逗号分隔的可信代理(IP/CIDR)。配置后 ClientIP 只信任这些
|
||||
// 代理转发的 X-Forwarded-For;未配置时保持 gin 默认行为(信任所有代理)
|
||||
if strings.TrimSpace(TRUSTED_PROXIES) != "" {
|
||||
var proxies []string
|
||||
for _, p := range strings.Split(TRUSTED_PROXIES, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
proxies = append(proxies, p)
|
||||
}
|
||||
}
|
||||
if err := r.SetTrustedProxies(proxies); err != nil {
|
||||
slog.Warn("Invalid TRUSTED_PROXIES, keep gin default", "error", err)
|
||||
}
|
||||
}
|
||||
corsConfig := cors.DefaultConfig()
|
||||
|
||||
if len(ACCEPT_DOMAINS) > 0 {
|
||||
@@ -1211,7 +1333,41 @@ func main() {
|
||||
|
||||
r.GET("/", healchCheck)
|
||||
|
||||
if err := r.Run(":" + PORTS); err != nil {
|
||||
// 显式持有 http.Server:Windows OTA 优雅重启需要先 Shutdown 释放端口再拉起新进程,
|
||||
// 避免 r.Run 的隐式 Server 无法受控关闭、父子进程抢端口
|
||||
httpServer = &http.Server{Handler: r}
|
||||
ln, err := net.Listen("tcp", ":"+PORTS)
|
||||
if err != nil {
|
||||
slog.Error("Server failed to start", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
go func() {
|
||||
// 优雅重启时 Shutdown 会正常关闭 Server,返回 ErrServerClosed 属预期
|
||||
if err := httpServer.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
slog.Error("Server failed to start", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
// Windows OTA:Server 被 restartSelf 的优雅停机关闭后,main 停在这里不退出,
|
||||
// 由 restartSelf 在新进程健康检查通过后 os.Exit 结束整个老进程
|
||||
if otaEnabled() && runtime.GOOS == "windows" {
|
||||
otaHandoverWait = make(chan struct{})
|
||||
<-otaHandoverWait
|
||||
}
|
||||
}
|
||||
|
||||
// gracefulShutdown 优雅停止 HTTP 服务:停止接收新请求,等待在途请求完成(上限 30s)。
|
||||
// 供 Windows OTA 在拉起新进程前调用,确保监听端口先释放、子进程能正常绑定。
|
||||
func gracefulShutdown() {
|
||||
if httpServer == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
slog.Info("Graceful shutdown started, waiting for in-flight requests")
|
||||
if err := httpServer.Shutdown(ctx); err != nil {
|
||||
slog.Warn("Graceful shutdown timed out, forcing close", "error", err)
|
||||
httpServer.Close()
|
||||
}
|
||||
}
|
||||
+436
@@ -0,0 +1,436 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"resty.dev/v3"
|
||||
)
|
||||
|
||||
// OTA 自更新:定期检查 GitHub Release,下载与当前平台匹配的新版二进制替换自身并重启。
|
||||
// 默认关闭,需显式开启(环境变量 NODE_OTA 或 setting.json 的 node-ota)。
|
||||
|
||||
const (
|
||||
otaReleaseAPI = "https://api.github.com/repos/nomdn/ipw-cn/releases/latest"
|
||||
otaFirstDelay = 5 * time.Minute // 启动后首次检查的延迟(避免与数据库下载冲突)
|
||||
otaCheckInterval = 6 * time.Hour // 常规检查间隔
|
||||
otaMinSize = 1 * 1024 * 1024 // 下载文件最小体积,防止拿到错误页/占位文件
|
||||
)
|
||||
|
||||
var (
|
||||
otaMu sync.Mutex
|
||||
otaLastRun time.Time
|
||||
)
|
||||
|
||||
type otaRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
Size int64 `json:"size"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
// otaEnabled 判断是否启用 OTA("true" / "1" / "yes" / "on" 视为开启)
|
||||
func otaEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(NODE_OTA)) {
|
||||
case "true", "1", "yes", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// initOTA 在后台启动 OTA 检查循环,未启用时直接返回
|
||||
func initOTA(ghproxy string) {
|
||||
if !otaEnabled() {
|
||||
slog.Debug("OTA disabled")
|
||||
return
|
||||
}
|
||||
slog.Info("OTA enabled", "current_version", VERSION, "check_interval", otaCheckInterval.String())
|
||||
|
||||
go func() {
|
||||
// 首次延迟,避免启动瞬间与数据库下载抢占带宽或触发重启
|
||||
time.Sleep(otaFirstDelay)
|
||||
for {
|
||||
checkOTAUpdate(ghproxy)
|
||||
jitter := time.Duration(rand.Int63n(int64(time.Hour)))
|
||||
time.Sleep(otaCheckInterval + jitter)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// otaAssetNames 按优先级返回当前平台可能的发布资产名(与 build_and_release.yml 命名一致)
|
||||
func otaAssetNames() []string {
|
||||
suffix := ""
|
||||
if runtime.GOOS == "windows" {
|
||||
suffix = ".exe"
|
||||
}
|
||||
arch := runtime.GOARCH
|
||||
// GOARCH=arm 时无法在运行时区分 GOARM(armv7/armv6),依次尝试
|
||||
if runtime.GOOS == "linux" && arch == "arm" {
|
||||
return []string{
|
||||
"lemonipw-linux-armv7",
|
||||
"lemonipw-linux-armv6",
|
||||
"lemonipw-linux-arm",
|
||||
}
|
||||
}
|
||||
return []string{fmt.Sprintf("lemonipw-%s-%s%s", runtime.GOOS, arch, suffix)}
|
||||
}
|
||||
|
||||
// compareVersion 比较版本号:latest > current 返回正数,相等返回 0,否则返回负数;
|
||||
// 非数字段按字典序比较。latest 为空(无法获取远端版本)时返回 0,不做更新。
|
||||
func compareVersion(latest, current string) int {
|
||||
if strings.TrimSpace(latest) == "" {
|
||||
return 0
|
||||
}
|
||||
trim := func(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "v")
|
||||
s = strings.TrimPrefix(s, "V")
|
||||
if i := strings.IndexAny(s, "-+"); i >= 0 { // 忽略预发布后缀
|
||||
s = s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
a := strings.Split(trim(latest), ".")
|
||||
b := strings.Split(trim(current), ".")
|
||||
for i := 0; i < len(a) || i < len(b); i++ {
|
||||
var x, y int
|
||||
var xe, ye error
|
||||
if i < len(a) {
|
||||
x, xe = strconv.Atoi(a[i])
|
||||
}
|
||||
if i < len(b) {
|
||||
y, ye = strconv.Atoi(b[i])
|
||||
}
|
||||
switch {
|
||||
case xe == nil && ye == nil:
|
||||
if x != y {
|
||||
return x - y
|
||||
}
|
||||
case xe == nil: // 只有当前版本非数字
|
||||
return 1
|
||||
case ye == nil:
|
||||
return -1
|
||||
default: // 两段都非数字,按字典序
|
||||
if a[i] != b[i] {
|
||||
if a[i] > b[i] {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// majorSame 判断 latest 与 current 的 major 段是否相同。
|
||||
// 任一段无法解析(如本地构建 VERSION 为空)时返回 true(放行,不拦截更新)。
|
||||
func majorSame(latest, current string) bool {
|
||||
parseMajor := func(s string) (int, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "v")
|
||||
s = strings.TrimPrefix(s, "V")
|
||||
if i := strings.IndexAny(s, "-+."); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
lm, lok := parseMajor(latest)
|
||||
cm, cok := parseMajor(current)
|
||||
if !lok || !cok {
|
||||
return true
|
||||
}
|
||||
return lm == cm
|
||||
}
|
||||
|
||||
// fetchLatestRelease 查询 GitHub 最新 Release 信息
|
||||
func fetchLatestRelease() (*otaRelease, error) {
|
||||
client := resty.New().SetTimeout(30 * time.Second)
|
||||
defer client.Close()
|
||||
|
||||
resp, err := client.R().
|
||||
SetHeader("Accept", "application/vnd.github+json").
|
||||
SetHeader("User-Agent", "lemon-ipw-ota").
|
||||
Get(otaReleaseAPI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.IsError() {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode())
|
||||
}
|
||||
var rel otaRelease
|
||||
if err := json.Unmarshal(resp.Bytes(), &rel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rel.TagName == "" {
|
||||
return nil, fmt.Errorf("empty tag_name")
|
||||
}
|
||||
return &rel, nil
|
||||
}
|
||||
|
||||
// findAssetURL 在 Release 资产中查找当前平台的下载链接,同时返回资产大小用于完整性校验
|
||||
func findAssetURL(rel *otaRelease) (string, string, int64) {
|
||||
for _, want := range otaAssetNames() {
|
||||
for _, a := range rel.Assets {
|
||||
if a.Name == want {
|
||||
return a.BrowserDownloadURL, a.Name, a.Size
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", 0
|
||||
}
|
||||
|
||||
// checkOTAUpdate 执行一次检查:有新版本则下载替换并重启
|
||||
func checkOTAUpdate(ghproxy string) {
|
||||
otaMu.Lock()
|
||||
defer otaMu.Unlock()
|
||||
otaLastRun = time.Now()
|
||||
|
||||
rel, err := fetchLatestRelease()
|
||||
if err != nil {
|
||||
slog.Warn("OTA check failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
cmp := compareVersion(rel.TagName, VERSION)
|
||||
if cmp <= 0 {
|
||||
slog.Debug("OTA already up to date", "latest", rel.TagName, "current", VERSION)
|
||||
return
|
||||
}
|
||||
|
||||
// major 版本变化(如 3.x → 4.x)通常含破坏性变更,不自动更新,需人工升级
|
||||
if !majorSame(rel.TagName, VERSION) {
|
||||
slog.Warn("OTA skipped: major version upgrade requires manual action",
|
||||
"latest", rel.TagName, "current", VERSION)
|
||||
return
|
||||
}
|
||||
|
||||
assetURL, assetName, assetSize := findAssetURL(rel)
|
||||
if assetURL == "" {
|
||||
slog.Warn("OTA no matching asset for this platform",
|
||||
"tag", rel.TagName, "goos", runtime.GOOS, "goarch", runtime.GOARCH)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("OTA new version found", "tag", rel.TagName, "current", VERSION, "asset", assetName)
|
||||
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
slog.Error("OTA cannot locate executable", "error", err)
|
||||
return
|
||||
}
|
||||
exePath, err = filepath.EvalSymlinks(exePath)
|
||||
if err != nil {
|
||||
slog.Error("OTA cannot resolve executable path", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 下载到同目录(保证与目标同分区,rename 原子替换),
|
||||
// 临时文件使用远端资产名(如 lemonipw-linux-amd64)+ .tmp 后缀,避免与运行中的 exe 冲突
|
||||
tmpPath := filepath.Join(filepath.Dir(exePath), assetName+".tmp")
|
||||
if err := downloadOTA(ghproxy+assetURL, tmpPath, assetSize); err != nil {
|
||||
slog.Error("OTA download failed", "error", err)
|
||||
os.Remove(tmpPath)
|
||||
return
|
||||
}
|
||||
|
||||
// 预检:停机前先试运行新二进制(-v 打印版本即退出),确认文件可执行、架构匹配。
|
||||
// 损坏/被杀毒隔离的文件若直到交接时才发现,子进程会秒退导致服务中断
|
||||
if err := preflightBinary(tmpPath); err != nil {
|
||||
slog.Error("OTA preflight failed, keep current version", "error", err)
|
||||
os.Remove(tmpPath)
|
||||
return
|
||||
}
|
||||
|
||||
if err := replaceBinary(tmpPath, exePath); err != nil {
|
||||
slog.Error("OTA replace failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("OTA binary replaced, restarting", "tag", rel.TagName)
|
||||
restartSelf(exePath)
|
||||
}
|
||||
|
||||
// downloadOTA 下载二进制到临时文件,并做体积校验(Release 声明的资产大小精确比对 + 最小体积兜底)。
|
||||
// 注意:GitHub Release 未提供签名/哈希清单,无法做内容级认证,体积校验只能防截断与错装文件。
|
||||
func downloadOTA(url, dst string, expectSize int64) error {
|
||||
client := resty.New().SetTimeout(10 * time.Minute)
|
||||
defer client.Close()
|
||||
|
||||
resp, err := client.R().SetOutputFileName(dst).SetSaveResponse(true).Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.IsError() {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode())
|
||||
}
|
||||
fi, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fi.Size() < otaMinSize {
|
||||
return fmt.Errorf("downloaded file too small: %d bytes", fi.Size())
|
||||
}
|
||||
if expectSize > 0 && fi.Size() != expectSize {
|
||||
return fmt.Errorf("downloaded size mismatch: got %d, want %d", fi.Size(), expectSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaceBinary 用新二进制替换当前可执行文件:
|
||||
// 1) 当前二进制改名为 .old(Windows 下运行中的 exe 不能删除,但可以重命名)
|
||||
// 2) 新文件 rename 到原位置;失败则回滚
|
||||
func replaceBinary(tmpPath, exePath string) error {
|
||||
oldPath := exePath + ".old"
|
||||
_ = os.Remove(oldPath)
|
||||
|
||||
if err := os.Rename(exePath, oldPath); err != nil {
|
||||
return fmt.Errorf("backup current binary: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, exePath); err != nil {
|
||||
_ = os.Rename(oldPath, exePath) // 回滚
|
||||
return fmt.Errorf("install new binary: %w", err)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
if err := os.Chmod(exePath, 0o755); err != nil {
|
||||
slog.Warn("OTA chmod failed", "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// restartSelf 用新二进制重启进程:
|
||||
// - Unix:syscall.Exec 原地替换进程镜像(PID 不变,systemd/Docker 无感;Go 的 socket 带
|
||||
// CLOEXEC,exec 瞬间旧监听关闭、新进程正常重绑)
|
||||
// - Windows:运行中的 exe 只能重命名不能替换,采用优雅交接:
|
||||
// 1) 优雅停机:停止接收新请求并等待在途请求完成,端口释放
|
||||
// 2) 拉起新进程:端口已空闲,子进程可正常绑定(旧实现先 os.Exit 再赌子进程抢端口,
|
||||
// 输了就"Server failed to start"退出且 Windows 无守护接管,节点直接下线)
|
||||
// 3) 轮询子进程健康检查通过后老进程才退出;超时则以退出码 1 结束
|
||||
func restartSelf(exePath string) {
|
||||
if runtime.GOOS == "windows" {
|
||||
gracefulShutdown()
|
||||
|
||||
cmd := exec.Command(exePath, os.Args[1:]...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = os.Environ()
|
||||
if err := cmd.Start(); err != nil {
|
||||
slog.Error("OTA restart failed, waiting for supervisor", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if waitChildReady(cmd, 10*time.Minute) {
|
||||
slog.Info("OTA new process ready, old process exiting", "new_pid", cmd.Process.Pid)
|
||||
os.Exit(0)
|
||||
}
|
||||
slog.Error("OTA new process not ready (exited or timed out), rolling back", "new_pid", cmd.Process.Pid)
|
||||
|
||||
// 回滚:新版本起不来,用 .old 备份恢复服务,而不是陪它一起下线
|
||||
if rollbackToOld(exePath) {
|
||||
slog.Warn("OTA rolled back to previous version, old process exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := syscall.Exec(exePath, os.Args, os.Environ()); err != nil {
|
||||
slog.Error("OTA exec failed, wait for supervisor to restart", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// preflightBinary 试运行新二进制(-v 自检:打印版本后立即退出,不读配置不占端口),
|
||||
// 确认文件可执行、架构匹配。10s 超时防损坏文件卡死。
|
||||
func preflightBinary(path string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
// Stdout/Stderr 为 nil 时输出直接丢弃
|
||||
return exec.CommandContext(ctx, path, "-v").Run()
|
||||
}
|
||||
|
||||
// waitChildReady 轮询新进程的健康检查接口(GET /),确认其完成端口绑定并对外服务。
|
||||
// 返回 true = 就绪;false = 子进程启动期间退出(立即判定失败)或超时未就绪。
|
||||
// 超时给足 10 分钟:子进程在数据库文件缺失时(首次启动/文件损坏)会同步拉取约 450MB
|
||||
// 库文件后才监听端口,属正常慢启动而非故障——所以判定失败的依据是"子进程退出"
|
||||
// (进程死了立即返回),而不是固定超时。
|
||||
func waitChildReady(cmd *exec.Cmd, timeout time.Duration) bool {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
url := "http://127.0.0.1:" + PORTS + "/"
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
// 监听子进程退出:起不来直接失败,不用干等超时
|
||||
exited := make(chan struct{})
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
close(exited)
|
||||
}()
|
||||
|
||||
for {
|
||||
if resp, err := client.Get(url); err == nil {
|
||||
resp.Body.Close()
|
||||
return true
|
||||
}
|
||||
select {
|
||||
case <-exited:
|
||||
return false
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// rollbackToOld 用 .old 备份恢复服务:新版本起不来时,把旧二进制换回原位并重新拉起。
|
||||
// 返回 true 表示旧版本已就绪。恢复失败时尽力把新二进制挪回原位后返回 false。
|
||||
func rollbackToOld(exePath string) bool {
|
||||
oldPath := exePath + ".old"
|
||||
if _, err := os.Stat(oldPath); err != nil {
|
||||
slog.Error("OTA rollback skipped: no .old backup", "error", err)
|
||||
return false
|
||||
}
|
||||
failed := exePath + ".failed"
|
||||
_ = os.Remove(failed)
|
||||
if err := os.Rename(exePath, failed); err != nil {
|
||||
slog.Error("OTA rollback: cannot move failed binary", "error", err)
|
||||
return false
|
||||
}
|
||||
if err := os.Rename(oldPath, exePath); err != nil {
|
||||
slog.Error("OTA rollback: cannot restore old binary", "error", err)
|
||||
_ = os.Rename(failed, exePath) // 尽力恢复现场
|
||||
return false
|
||||
}
|
||||
cmd := exec.Command(exePath, os.Args[1:]...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = os.Environ()
|
||||
if err := cmd.Start(); err != nil {
|
||||
slog.Error("OTA rollback: cannot start old binary", "error", err)
|
||||
return false
|
||||
}
|
||||
if waitChildReady(cmd, 10*time.Minute) {
|
||||
slog.Warn("OTA rollback serving traffic on previous version", "pid", cmd.Process.Pid)
|
||||
return true
|
||||
}
|
||||
slog.Error("OTA rollback: old binary also failed to become ready")
|
||||
return false
|
||||
}
|
||||
@@ -7,10 +7,12 @@
|
||||
"block-private-ips": true,
|
||||
"ipdb": "false",
|
||||
"cors": "",
|
||||
"trusted-proxies": "",
|
||||
"remote-config-url": "",
|
||||
"remote-ignore-config": [],
|
||||
"access-token": "",
|
||||
"ws-url": "",
|
||||
"node-id": "",
|
||||
"node-key": ""
|
||||
"node-key": "",
|
||||
"node-ota": "false"
|
||||
}
|
||||
+59
-35
@@ -45,24 +45,49 @@ func SetEnabled(v bool) {
|
||||
blockPrivateIPs = v
|
||||
}
|
||||
|
||||
// Go 标准库 IsPrivate 只覆盖 RFC1918 与 fd00::/8,这里补齐其余不应出网的内部/特殊网段
|
||||
var extraPrivateNets = mustParseCIDRs(
|
||||
"100.64.0.0/10", // CGNAT:运营商级 NAT、云内网、Tailscale 等
|
||||
"198.18.0.0/15", // 网络基准测试保留段
|
||||
"192.0.0.0/24", // IETF 协议分配
|
||||
"fc00::/7", // IPv6 ULA(IsPrivate 只认 fd00::/8)
|
||||
"64:ff9b::/96", // NAT64:内嵌 IPv4 会被网关转换
|
||||
)
|
||||
|
||||
func mustParseCIDRs(cidrs ...string) []*net.IPNet {
|
||||
nets := make([]*net.IPNet, 0, len(cidrs))
|
||||
for _, c := range cidrs {
|
||||
_, ipNet, err := net.ParseCIDR(c)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("invalid CIDR %q: %v", c, err))
|
||||
}
|
||||
nets = append(nets, ipNet)
|
||||
}
|
||||
return nets
|
||||
}
|
||||
|
||||
// IsPrivateIP checks if the given IP address belongs to a private or internal range.
|
||||
// IsPrivateIP 检查给定的 IP 地址是否属于私有或内部地址段。
|
||||
// It covers RFC 1918 private addresses, loopback, link-local, and unspecified addresses.
|
||||
// 涵盖 RFC 1918 私有地址、回环地址、链路本地地址和未指定地址。
|
||||
// It covers RFC 1918 private addresses, loopback, link-local, and unspecified addresses,
|
||||
// plus CGNAT/ULA/NAT64/benchmark/multicast ranges that Go's IsPrivate does not cover.
|
||||
// 涵盖 RFC 1918 私有地址、回环地址、链路本地地址和未指定地址,
|
||||
// 以及 Go 标准库未覆盖的 CGNAT/ULA/NAT64/基准测试/组播段。
|
||||
// This is the core check used to block SSRF attacks targeting internal networks.
|
||||
// 这是用于阻止针对内部网络的 SSRF 攻击的核心检查。
|
||||
func IsPrivateIP(ip net.IP) bool {
|
||||
if ip.IsPrivate() {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() || ip.IsMulticast() {
|
||||
return true
|
||||
}
|
||||
if ip.IsLoopback() {
|
||||
if ip.Equal(net.IPv4bcast) {
|
||||
return true
|
||||
}
|
||||
if ip.IsLinkLocalUnicast() {
|
||||
return true
|
||||
}
|
||||
if ip.IsUnspecified() {
|
||||
return true
|
||||
for _, n := range extraPrivateNets {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -133,36 +158,35 @@ func ValidateOutboundTarget(ctx context.Context, targetURL string) (context.Cont
|
||||
// SecureCheckRedirect is a redirect policy that prevents SSRF via HTTP redirect chains.
|
||||
// SecureCheckRedirect 是一个重定向策略,防止通过 HTTP 重定向链进行 SSRF 攻击。
|
||||
//
|
||||
// When an HTTP client follows redirects (301, 302, 307, 308), each intermediate target
|
||||
// 当 HTTP 客户端跟随重定向(301、302、307、308)时,会校验每个中间目标
|
||||
// is resolved and checked against private/internal IP ranges.
|
||||
// 的解析结果,并检查是否指向私有/内部 IP 段。
|
||||
//
|
||||
// This blocks attackers from:
|
||||
// 这阻止攻击者通过以下方式绕过防护:
|
||||
// - Using an initial public redirect that points to an internal service.
|
||||
// 使用指向内部服务的初始公开重定向。
|
||||
// - Chaining multiple redirects to eventually reach a private IP.
|
||||
// 链式多个重定向最终到达私有 IP。
|
||||
// req 是即将跳转的目标(via 是已访问的链)。必须校验目标本身:
|
||||
// 只检查 via 会漏掉重定向链的最后一跳。每一跳的目标都会被解析并检查
|
||||
// 是否指向私有/内部 IP 段,同时限制重定向深度。
|
||||
// 这阻止攻击者通过链式重定向最终到达私有 IP。
|
||||
func SecureCheckRedirect(req *http.Request, via []*http.Request) error {
|
||||
if !blockPrivateIPs {
|
||||
return nil
|
||||
}
|
||||
for _, r := range via {
|
||||
redirectURL := r.URL
|
||||
host := redirectURL.Hostname()
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if IsPrivateIP(ip) {
|
||||
slog.Warn("Blocked redirect to private IP", "host", host, "ip", ip)
|
||||
return fmt.Errorf("redirect to private/internal address is not allowed")
|
||||
}
|
||||
if req == nil || req.URL == nil {
|
||||
return fmt.Errorf("invalid redirect target")
|
||||
}
|
||||
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
|
||||
return fmt.Errorf("invalid redirect scheme: %s", req.URL.Scheme)
|
||||
}
|
||||
host := req.URL.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("redirect target has empty host")
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("stopped after 10 redirects")
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if IsPrivateIP(ip) {
|
||||
slog.Warn("Blocked redirect to private IP", "host", host, "ip", ip)
|
||||
return fmt.Errorf("redirect to private/internal address is not allowed")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
+24
-18
@@ -1,6 +1,7 @@
|
||||
package webtest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -200,7 +201,6 @@ func happyEyeballsWhoisQuery(domain string, v4IPs, v6IPs []string, port string)
|
||||
}
|
||||
// 有缓冲:保证每个 goroutine 写入不被阻塞,避免 goroutine 泄漏
|
||||
ch := make(chan outcome, len(v4IPs)+len(v6IPs))
|
||||
pending := 0
|
||||
|
||||
tryIP := func(ip string) {
|
||||
raw, err := rawWhoisQueryCtx(ctx, domain, ip, port)
|
||||
@@ -209,7 +209,6 @@ func happyEyeballsWhoisQuery(domain string, v4IPs, v6IPs []string, port string)
|
||||
|
||||
// 1) 立即启动所有 v4
|
||||
for _, ip := range v4IPs {
|
||||
pending++
|
||||
go tryIP(ip)
|
||||
}
|
||||
|
||||
@@ -218,7 +217,6 @@ func happyEyeballsWhoisQuery(domain string, v4IPs, v6IPs []string, port string)
|
||||
if len(v6IPs) > 0 {
|
||||
v6Timer = time.AfterFunc(happyEyeballsV6Delay, func() {
|
||||
for _, ip := range v6IPs {
|
||||
pending++
|
||||
go tryIP(ip)
|
||||
}
|
||||
})
|
||||
@@ -241,9 +239,7 @@ func happyEyeballsWhoisQuery(domain string, v4IPs, v6IPs []string, port string)
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
// 注意:pending 是在 goroutine 内自增,这里用固定上限做兜底计数
|
||||
// 如果 v6 还没启动完,但 v4 已经全部失败也没有成功,需要等待 pending 全部发完
|
||||
// 简化起见:若 i 到达 len(v4IPs) 但 v6 还在 pending,我们再等一会
|
||||
// v4 全部失败且无成功时,给延迟启动的 v6 goroutine 留一个启动 + 写入窗口
|
||||
if i == len(v4IPs)-1 && v6Timer != nil && lastErr != nil {
|
||||
// 给 v6 一个启动 + 写入窗口
|
||||
time.Sleep(happyEyeballsV6Delay + 20*time.Millisecond)
|
||||
@@ -336,19 +332,29 @@ func rawWhoisQueryCtx(ctx context.Context, domain, server, port string) (string,
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 读取响应
|
||||
// 3. 读取响应:循环读到 EOF/超时。单次 Read 只能拿到一个 TCP 段,会截断大多数 WHOIS 响应
|
||||
buf := make([]byte, 65536)
|
||||
n, readErr := conn.Read(buf)
|
||||
|
||||
// 有数据就读到了,哪怕 readErr != nil 也先返回数据
|
||||
if n > 0 {
|
||||
return string(buf[:n]), nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
default:
|
||||
return "", readErr
|
||||
var out bytes.Buffer
|
||||
for {
|
||||
n, readErr := conn.Read(buf)
|
||||
if n > 0 {
|
||||
out.Write(buf[:n])
|
||||
}
|
||||
if readErr != nil {
|
||||
// 有数据就返回(EOF/超时都视为读完),哪怕 readErr != nil
|
||||
if out.Len() > 0 {
|
||||
return out.String(), nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
default:
|
||||
return "", readErr
|
||||
}
|
||||
}
|
||||
if out.Len() > 1<<20 { // 防御上限 1MB,避免异常响应无限读
|
||||
return out.String(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -106,6 +107,12 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
if port == "" {
|
||||
port = "80"
|
||||
}
|
||||
// 与 HTTP handler 同样校验端口/次数,避免垃圾参数直接进拨号
|
||||
p, err := strconv.Atoi(port)
|
||||
if err != nil || p < 1 || p > 65535 {
|
||||
return 400, map[string]string{"error": "Invalid port number"}
|
||||
}
|
||||
port = strconv.Itoa(p)
|
||||
count := query["count"]
|
||||
n := 4
|
||||
if count != "" {
|
||||
@@ -385,7 +392,10 @@ func wsClientOnce(url string) time.Duration {
|
||||
}
|
||||
wsSend(c, wsMsg{Type: "register", Data: wsRaw(reg)})
|
||||
|
||||
_, data, err := c.Read(ctx)
|
||||
// 注册应答限时读取:中间件半死(TCP 通但不回包)时 goroutine 会永久阻塞在 Read 上
|
||||
regCtx, regCancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer regCancel()
|
||||
_, data, err := c.Read(regCtx)
|
||||
if err != nil {
|
||||
slog.Warn("ws client register read failed", "url", url, "error", err)
|
||||
return 3 * time.Second
|
||||
|
||||
Reference in new issue
Block a user