Compare commits
68
Commits
70796e5379
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c377405696 | ||
|
|
83cc9d504b | ||
|
|
36d8b1e464 | ||
|
|
dd40008be8 | ||
|
|
2d0f63517b | ||
|
|
1434cfd72b | ||
|
|
af07ebd462 | ||
|
|
8f78aad19b | ||
|
|
7acf13583b | ||
|
|
26dd84abaa | ||
|
|
3caff9fd8e | ||
|
|
be35fbf546 | ||
|
|
5e995dee28 | ||
|
|
0d7af99a78 | ||
|
|
aefa2a5b20 | ||
|
|
a8a33e87d2 | ||
|
|
66081e93d5 | ||
|
|
8481bc3ac8 | ||
|
|
21efc10536 | ||
|
|
0feb6132d6 | ||
|
|
1ada58af02 | ||
|
|
faf43293e5 | ||
|
|
dfce843b50 | ||
|
|
87c072f5d4 | ||
|
|
c92d06642f | ||
|
|
8c07e64e87 | ||
|
|
4a29bcb249 | ||
|
|
fac9234e4d | ||
|
|
1bec91cc5e | ||
|
|
49d4af27ca | ||
|
|
63538ec5b7 | ||
|
|
454371368c | ||
|
|
4e948f65d1 | ||
|
|
f63c28e10e | ||
|
|
d154aef807 | ||
|
|
3f23610e59 | ||
|
|
bed1e223cc | ||
|
|
da054517c0 | ||
|
|
fa9cdf913f | ||
|
|
6074cfceca | ||
|
|
163df0c5b0 | ||
|
|
32dfe3b6de | ||
|
|
d39f8a7201 | ||
|
|
4c49f6a3b5 | ||
|
|
8d932a50f3 | ||
|
|
70b891c2af | ||
|
|
8f1114753d | ||
|
|
ac5d98330e | ||
|
|
a954bf09a2 | ||
|
|
1309cfef5b | ||
|
|
84842e9359 | ||
|
|
e0202a872f | ||
|
|
fc16fb2dc2 | ||
|
|
dda0eb876e | ||
|
|
2236259a52 | ||
|
|
74d731d0ac | ||
|
|
c8264bf280 | ||
|
|
862442e77f | ||
|
|
8f75e99339 | ||
|
|
6abedbbe42 | ||
|
|
0fce234ef3 | ||
|
|
828be2ea66 | ||
|
|
2f59f5adc4 | ||
|
|
f75e672a45 | ||
|
|
8f6fac802f | ||
|
|
1b30be45a0 | ||
|
|
fd1661622e | ||
|
|
1e915fe7c1 |
+1
-4
@@ -6,11 +6,8 @@ bin
|
|||||||
/data
|
/data
|
||||||
# 本地运行数据与归档,不进镜像(防止把真实数据库/证书/日志带入构建上下文)
|
# 本地运行数据与归档,不进镜像(防止把真实数据库/证书/日志带入构建上下文)
|
||||||
.data
|
.data
|
||||||
# 已弃用归档代码,不参与编译
|
|
||||||
internal/frp-1.0.2
|
|
||||||
web/src/views/FrpView-1.0.2.vue
|
|
||||||
# 文档与本地杂项
|
# 文档与本地杂项
|
||||||
docs
|
docs
|
||||||
# 本地导出/打包产物与日志(预防性:不进入构建上下文)
|
# 本地导出/打包产物与日志(预防性:不进入构建上下文)
|
||||||
*.log
|
*.log
|
||||||
*.rar
|
*.rar
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['**']
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# 顺序不能颠倒:cmd/fonu/main.go 用 //go:embed all:web/dist 嵌入前端,
|
||||||
|
# 干净检出时该目录不存在,go vet / go build 会直接失败。
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
|
- name: Install web dependencies
|
||||||
|
working-directory: web
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Type-check and build web
|
||||||
|
working-directory: web
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Run web unit tests
|
||||||
|
working-directory: web
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Sync web dist into embed directory
|
||||||
|
run: |
|
||||||
|
rm -rf cmd/fonu/web/dist
|
||||||
|
cp -r web/dist cmd/fonu/web/dist
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: go vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: go test
|
||||||
|
run: go test ./... -count=1
|
||||||
|
|
||||||
|
- name: go build
|
||||||
|
run: go build ./...
|
||||||
|
|
||||||
|
# 说明:gofmt 卡口暂未开启——仓库里还有 25 个历史未格式化文件(见 docs/project-audit-2026-09-24.md §八),
|
||||||
|
# 全仓 `gofmt -w` 之后再补一步 `gofmt -l internal cmd` 作为门禁。
|
||||||
+14
@@ -3,10 +3,24 @@ bin/
|
|||||||
web/node_modules/
|
web/node_modules/
|
||||||
web/dist/
|
web/dist/
|
||||||
cmd/fonu/web/dist/
|
cmd/fonu/web/dist/
|
||||||
|
dist/
|
||||||
*.db
|
*.db
|
||||||
/data/
|
/data/
|
||||||
|
# 会话密钥等本地环境变量(.env.example 这类模板可以照常入库)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.cursor/
|
.cursor/
|
||||||
# scripts/ 下的本地辅助脚本不入库,但随仓库分发的 agent 安装脚本需要跟踪
|
# scripts/ 下的本地辅助脚本不入库,但随仓库分发的 agent 安装脚本需要跟踪
|
||||||
scripts/*
|
scripts/*
|
||||||
!scripts/agent-install.sh
|
!scripts/agent-install.sh
|
||||||
|
!scripts/build-linux.ps1
|
||||||
|
!scripts/fonu-install.sh
|
||||||
|
!scripts/upgrade.sh
|
||||||
|
!scripts/upgrade-hub.sh
|
||||||
|
# 本地压缩包与导出产物不入库(如 web/src.rar)
|
||||||
|
*.rar
|
||||||
|
*.zip
|
||||||
|
*.7z
|
||||||
|
*.tar.gz
|
||||||
|
*.log
|
||||||
|
|||||||
+1075
File diff suppressed because it is too large
Load Diff
+31
-6
@@ -1,3 +1,7 @@
|
|||||||
|
# 不写 `# syntax=docker/dockerfile:1`:显式指定会让 BuildKit 额外去拉 docker/dockerfile 镜像,
|
||||||
|
# 在国内加速器(如飞牛的 docker.fnnas.com)上可能直接 401 Unauthorized;
|
||||||
|
# 而下面用到的 RUN --mount=type=cache 由 Docker 内建的 BuildKit frontend 已支持,无需显式指定。
|
||||||
|
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
# 版本单一来源:根目录 VERSION 文件,本地改版本号只需改此文件
|
# 版本单一来源:根目录 VERSION 文件,本地改版本号只需改此文件
|
||||||
ARG VERSION_FILE=VERSION
|
ARG VERSION_FILE=VERSION
|
||||||
@@ -7,6 +11,7 @@ ARG VERSION_FILE=VERSION
|
|||||||
ARG NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine
|
ARG NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine
|
||||||
ARG GO_IMAGE=docker.m.daocloud.io/library/golang:1.23-bookworm
|
ARG GO_IMAGE=docker.m.daocloud.io/library/golang:1.23-bookworm
|
||||||
ARG RUNTIME_IMAGE=docker.m.daocloud.io/library/debian:bookworm-slim
|
ARG RUNTIME_IMAGE=docker.m.daocloud.io/library/debian:bookworm-slim
|
||||||
|
ARG UPDATER_IMAGE=docker.m.daocloud.io/library/docker:27-cli
|
||||||
# Go 依赖代理:国内默认 goproxy.cn(官方 proxy.golang.org 国内不可达),海外可覆盖回 direct
|
# Go 依赖代理:国内默认 goproxy.cn(官方 proxy.golang.org 国内不可达),海外可覆盖回 direct
|
||||||
ARG GOPROXY=https://goproxy.cn,direct
|
ARG GOPROXY=https://goproxy.cn,direct
|
||||||
|
|
||||||
@@ -18,7 +23,7 @@ COPY ${VERSION_FILE} /VERSION
|
|||||||
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS web-builder
|
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS web-builder
|
||||||
WORKDIR /src/web
|
WORKDIR /src/web
|
||||||
COPY web/package.json web/package-lock.json* ./
|
COPY web/package.json web/package-lock.json* ./
|
||||||
RUN npm ci
|
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||||
COPY web/ ./
|
COPY web/ ./
|
||||||
COPY --from=version /VERSION /src/web/.version
|
COPY --from=version /VERSION /src/web/.version
|
||||||
RUN VITE_APP_VERSION=$(tr -d ' \t\n\r' < /src/web/.version) npm run build
|
RUN VITE_APP_VERSION=$(tr -d ' \t\n\r' < /src/web/.version) npm run build
|
||||||
@@ -29,23 +34,41 @@ ARG GOPROXY
|
|||||||
ENV GOPROXY=${GOPROXY}
|
ENV GOPROXY=${GOPROXY}
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=version /VERSION ./VERSION
|
COPY --from=version /VERSION ./VERSION
|
||||||
# 先交叉编译 fonu-agent 到 embed 目录(替换仓库中的占位文件),主程序构建时自动内嵌
|
# 先交叉编译 fonu-agent 到 embed 目录(替换仓库中的占位文件),主程序构建时自动内嵌
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o internal/frp/agentbin/fonu-agent-linux-amd64 ./cmd/fonu-agent \
|
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o internal/frp/agentbin/fonu-agent-linux-amd64 ./cmd/fonu-agent \
|
||||||
&& CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -o internal/frp/agentbin/fonu-agent-linux-arm64 ./cmd/fonu-agent
|
&& CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -o internal/frp/agentbin/fonu-agent-linux-arm64 ./cmd/fonu-agent
|
||||||
COPY --from=web-builder /src/web/dist ./cmd/fonu/web/dist
|
COPY --from=web-builder /src/web/dist ./cmd/fonu/web/dist
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||||
-ldflags "-X github.com/fonu/fonu/internal/version.Version=$(cat /src/VERSION | tr -d ' \t\n\r')" \
|
-ldflags "-X github.com/fonu/fonu/internal/version.Version=$(cat /src/VERSION | tr -d ' \t\n\r')" \
|
||||||
-o /fonu ./cmd/fonu
|
-o /fonu ./cmd/fonu
|
||||||
|
|
||||||
|
# 一键升级 sidecar:独立容器持有 Docker Socket,主程序只通过共享 Unix Socket 调用它
|
||||||
|
FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS updater-builder
|
||||||
|
ARG TARGETARCH
|
||||||
|
ARG GOPROXY
|
||||||
|
ENV GOPROXY=${GOPROXY}
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||||
|
COPY cmd ./cmd
|
||||||
|
COPY internal ./internal
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath -o /fonu-updater ./cmd/fonu-updater
|
||||||
|
|
||||||
|
# updater 运行时:Docker CLI / Compose 与 Git 只在 sidecar 内可用
|
||||||
|
FROM ${UPDATER_IMAGE} AS updater
|
||||||
|
RUN apk add --no-cache git docker-cli-compose docker-cli-buildx ca-certificates
|
||||||
|
COPY --from=updater-builder /fonu-updater /usr/local/bin/fonu-updater
|
||||||
|
ENTRYPOINT ["/usr/local/bin/fonu-updater"]
|
||||||
|
|
||||||
# 运行时 stage
|
# 运行时 stage
|
||||||
FROM ${RUNTIME_IMAGE}
|
FROM ${RUNTIME_IMAGE}
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
# frpc 不预装:需要内网穿透时在「应用配置」弹窗内按需下载(存入数据目录)
|
# frpc 不预装:需要内网穿透时在「应用配置」弹窗内按需下载(存入数据目录)
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends nginx ca-certificates \
|
&& apt-get install -y --no-install-recommends nginx ca-certificates tzdata \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -53,7 +76,9 @@ COPY --from=go-builder /fonu /app/fonu
|
|||||||
COPY --from=go-builder /src/web/assets/image /app/web/assets/image
|
COPY --from=go-builder /src/web/assets/image /app/web/assets/image
|
||||||
COPY migrations /app/migrations
|
COPY migrations /app/migrations
|
||||||
|
|
||||||
ENV FONU_DATA_DIR=/data \
|
# 时区:Go 的 time.Local 取决于 TZ 环境变量,且需要镜像内存在 zoneinfo(故装上 tzdata)
|
||||||
|
ENV TZ=Asia/Shanghai \
|
||||||
|
FONU_DATA_DIR=/data \
|
||||||
FONU_LISTEN=:6893 \
|
FONU_LISTEN=:6893 \
|
||||||
FONU_NGINX_MIME_TYPES=/etc/nginx/mime.types
|
FONU_NGINX_MIME_TYPES=/etc/nginx/mime.types
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,12 @@ docker compose up -d --build
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### Web 一键升级
|
||||||
|
|
||||||
|
`docker-compose.yml` 与 `docker-compose.hub.yml` 会同时启动受限的 `fonu-updater` sidecar。检测到新版本后,侧栏会出现「一键升级」入口,可在管理页面完成构建、重建与健康检查。
|
||||||
|
|
||||||
|
> 从旧版本升级时,需要先手动更新 Compose 文件并执行一次 `docker compose up -d --build` 启动 sidecar。详见 [`docs/system-upgrade.md`](docs/system-upgrade.md)。
|
||||||
|
|
||||||
### 首次登录
|
### 首次登录
|
||||||
|
|
||||||
1. 启动后查看容器/运行日志,获取自动生成的 **admin 初始密码**
|
1. 启动后查看容器/运行日志,获取自动生成的 **admin 初始密码**
|
||||||
@@ -149,12 +155,13 @@ docker compose up -d --build
|
|||||||
| ----------------------- | ------------------------- | ------------------------------------------ |
|
| ----------------------- | ------------------------- | ------------------------------------------ |
|
||||||
| `FONU_LISTEN` | `:6893` | 管理后台监听地址 |
|
| `FONU_LISTEN` | `:6893` | 管理后台监听地址 |
|
||||||
| `FONU_DATA_DIR` | `/data` | 数据目录(数据库、Nginx 配置、证书、日志) |
|
| `FONU_DATA_DIR` | `/data` | 数据目录(数据库、Nginx 配置、证书、日志) |
|
||||||
| `FONU_SESSION_SECRET` | `change-me-in-production` | 会话加密密钥,生产环境请自行修改 |
|
| `FONU_SESSION_SECRET` | 空(自动生成) | 会话与凭据加密密钥;有数据后不要更换 |
|
||||||
| `FONU_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代规则的默认监听端口 |
|
| `FONU_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代规则的默认监听端口 |
|
||||||
| `FONU_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代规则的默认监听端口 |
|
| `FONU_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代规则的默认监听端口 |
|
||||||
| `FONU_NGINX_BIN` | `nginx` | Nginx 可执行文件路径(本地开发可指定) |
|
| `FONU_NGINX_BIN` | `nginx` | Nginx 可执行文件路径(本地开发可指定) |
|
||||||
| `FONU_FRPC_BIN` | `frpc` | frpc 可执行文件路径(Docker 镜像内置 0.71.0) |
|
| `FONU_FRPC_BIN` | `frpc` | frpc 可执行文件路径(镜像不预装,用「应用配置」按需下载或自行放置) |
|
||||||
| `FONU_FRP_PID` | `{DATA_DIR}/frp/frpc.pid` | frpc 进程 PID 文件路径 |
|
| `FONU_FRP_PID` | `{DATA_DIR}/frp/frpc.pid` | frpc 进程 PID 文件路径 |
|
||||||
|
| `FONU_UPDATER_SOCKET` | `/run/fonu-updater/updater.sock` | 一键升级 sidecar 的 Unix Socket;未挂载时自动禁用 |
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -7,13 +7,15 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/fonu/fonu/internal/agent"
|
"github.com/fonu/fonu/internal/agent"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
listen := flag.String("listen", envOr("FONU_AGENT_LISTEN", ":7700"), "agent listen address")
|
listen := flag.String("listen", envOr("FONU_AGENT_LISTEN", "127.0.0.1:7700"), "agent listen address")
|
||||||
|
allowRemote := flag.Bool("allow-remote", envBool("FONU_AGENT_ALLOW_REMOTE"), "allow non-loopback listen address")
|
||||||
token := flag.String("token", os.Getenv("FONU_AGENT_TOKEN"), "agent bearer token")
|
token := flag.String("token", os.Getenv("FONU_AGENT_TOKEN"), "agent bearer token")
|
||||||
nginxDir := flag.String("nginx-conf-dir", envOr("FONU_AGENT_NGINX_CONF_DIR", ""), "managed nginx config directory(留空自动探测 Nginx 实际加载的 vhost 目录)")
|
nginxDir := flag.String("nginx-conf-dir", envOr("FONU_AGENT_NGINX_CONF_DIR", ""), "managed nginx config directory(留空自动探测 Nginx 实际加载的 vhost 目录)")
|
||||||
certsDir := flag.String("certs-dir", envOr("FONU_AGENT_CERTS_DIR", "/var/lib/fonu-agent/certs"), "certificate directory")
|
certsDir := flag.String("certs-dir", envOr("FONU_AGENT_CERTS_DIR", "/var/lib/fonu-agent/certs"), "certificate directory")
|
||||||
@@ -30,7 +32,7 @@ func main() {
|
|||||||
log.Fatal("FONU_AGENT_TOKEN or -token is required")
|
log.Fatal("FONU_AGENT_TOKEN or -token is required")
|
||||||
}
|
}
|
||||||
server, err := agent.NewServer(agent.Config{
|
server, err := agent.NewServer(agent.Config{
|
||||||
ListenAddr: *listen, Token: *token, NginxConfDir: *nginxDir,
|
ListenAddr: *listen, AllowRemote: *allowRemote, Token: *token, NginxConfDir: *nginxDir,
|
||||||
CertsDir: *certsDir, FrpsConfigPath: *frpsConfig, FrpsBinary: *frpsBin, DataDir: *dataDir,
|
CertsDir: *certsDir, FrpsConfigPath: *frpsConfig, FrpsBinary: *frpsBin, DataDir: *dataDir,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -49,3 +51,12 @@ func envOr(key, fallback string) string {
|
|||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func envBool(key string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
server, err := updater.NewServer(updater.Config{
|
||||||
|
SocketPath: envOr("FONU_UPDATER_SOCKET", "/run/fonu-updater/updater.sock"),
|
||||||
|
ProjectDir: envOr("FONU_UPDATE_PROJECT_DIR", "/workspace"),
|
||||||
|
ComposeFile: envOr("FONU_UPDATE_COMPOSE_FILE", "docker-compose.yml"),
|
||||||
|
Service: envOr("FONU_UPDATE_SERVICE", "fonu"),
|
||||||
|
Mode: envOr("FONU_UPDATE_MODE", "local"),
|
||||||
|
RepoURL: envOr("FONU_UPDATE_REPO", ""),
|
||||||
|
Branch: envOr("FONU_UPDATE_BRANCH", "main"),
|
||||||
|
AppURL: envOr("FONU_UPDATE_APP_URL", "http://fonu:6893"),
|
||||||
|
HostProjectDir: envOr("FONU_UPDATE_HOST_DIR", ""),
|
||||||
|
ProjectName: envOr("FONU_UPDATE_PROJECT_NAME", ""),
|
||||||
|
SelfImage: envOr("FONU_UPDATE_SELF_IMAGE", ""),
|
||||||
|
DockerSocket: envOr("FONU_UPDATE_DOCKER_SOCK", "/var/run/docker.sock"),
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
if err := server.Serve(ctx); err != nil && ctx.Err() == nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
+11
-2
@@ -1,5 +1,6 @@
|
|||||||
# 飞牛 NAS 等已占用 80/443 的环境:使用 host 网络 + 高位默认反代端口
|
# 飞牛 NAS 等已占用 80/443 的环境:使用 host 网络 + 高位默认反代端口
|
||||||
# 本地项目:直接用源码构建镜像,不从 Docker Hub 拉取
|
# 本地项目:直接用源码构建镜像,不从 Docker Hub 拉取
|
||||||
|
name: fonu
|
||||||
services:
|
services:
|
||||||
fonu:
|
fonu:
|
||||||
build:
|
build:
|
||||||
@@ -9,15 +10,23 @@ services:
|
|||||||
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
||||||
RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim}
|
RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim}
|
||||||
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
||||||
|
image: fonu:${FONU_VERSION:-local}
|
||||||
container_name: fonu
|
container_name: fonu
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
network_mode: host
|
||||||
volumes:
|
volumes:
|
||||||
- fonu-data:/data
|
# 数据目录:默认用命名卷 fonu-data(由 Docker 管理,位置随 Docker 数据根)。
|
||||||
|
# 想落到指定目录就在同层 .env 里设 FONU_DATA_PATH=/vol1/docker/fonu-data ——
|
||||||
|
# 以 / 或 . 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷。
|
||||||
|
- ${FONU_DATA_PATH:-fonu-data}:/data
|
||||||
environment:
|
environment:
|
||||||
FONU_DATA_DIR: /data
|
FONU_DATA_DIR: /data
|
||||||
FONU_LISTEN: :6893
|
FONU_LISTEN: :6893
|
||||||
FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-change-me-in-production}
|
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
|
||||||
|
# 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换
|
||||||
|
FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-}
|
||||||
|
# 时区:通知消息时间按此显示(默认东八区)
|
||||||
|
TZ: ${TZ:-Asia/Shanghai}
|
||||||
FONU_NGINX_HTTP_PORT: ${FONU_NGINX_HTTP_PORT:-18080}
|
FONU_NGINX_HTTP_PORT: ${FONU_NGINX_HTTP_PORT:-18080}
|
||||||
FONU_NGINX_HTTPS_PORT: ${FONU_NGINX_HTTPS_PORT:-9443}
|
FONU_NGINX_HTTPS_PORT: ${FONU_NGINX_HTTPS_PORT:-9443}
|
||||||
|
|
||||||
|
|||||||
+71
-6
@@ -1,27 +1,92 @@
|
|||||||
# 本地构建并打标签(原为从 Docker Hub 拉取 muxui/fonu,本地项目无需拉取)
|
# 从仓库拉源码构建并启动(适合干净机器:只要这一个文件)
|
||||||
# 构建后镜像名为 fonu:local,可直接 docker save 导出或自行推送
|
#
|
||||||
|
# 用法:
|
||||||
|
# mkdir -p /opt/fonu && cd /opt/fonu
|
||||||
|
# # 取本文件(按你仓库页面上的「原始文件」链接下载,或 git clone 后复制过来):
|
||||||
|
# # curl -fsSLo docker-compose.hub.yml <仓库里这个文件的原始地址>
|
||||||
|
# docker compose up -d --build # 会话密钥由应用首次启动自动生成并持久化在数据卷里
|
||||||
|
#
|
||||||
|
# 想自己指定会话密钥(可选项):在 compose 文件同层建 .env 写入
|
||||||
|
# FONU_SESSION_SECRET=<一串随机字符,openssl rand -hex 32 可生成>
|
||||||
|
# 注意:它同时是凭据加密密钥,数据卷里已经有数据后不要随意更换,否则已存的凭据无法解密。
|
||||||
|
#
|
||||||
|
# 构建上下文用的是 Git 地址:BuildKit 会自己把仓库克隆下来,不需要先 git clone 到本地;
|
||||||
|
# 仓库匿名可读时无需凭据。需要认证时改用 https://<用户名>:<令牌>@<主机>/<路径>.git,
|
||||||
|
# 或先 git clone 后用 docker-compose.yml(本地上下文那份)。
|
||||||
|
#
|
||||||
|
# 首次构建要拉前端与 Go 依赖,比较慢;之后靠 Dockerfile 里的 cache mount 复用缓存。
|
||||||
|
# 80/443 已被占用(如飞牛 NAS)请改用 docker-compose.host.yml。
|
||||||
|
name: fonu
|
||||||
services:
|
services:
|
||||||
fonu:
|
fonu:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: http://45.207.223.190:10082/mrlee/-Fonu.git#main
|
||||||
args:
|
args:
|
||||||
NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine}
|
NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine}
|
||||||
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
||||||
RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim}
|
RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim}
|
||||||
|
UPDATER_IMAGE: ${UPDATER_IMAGE:-docker.m.daocloud.io/library/docker:27-cli}
|
||||||
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
||||||
image: fonu:local
|
image: fonu:${FONU_VERSION:-local}
|
||||||
|
# 有 build 段时 compose 默认会先尝试拉同名镜像(日志里那条 "fonu Pulling" 就是),
|
||||||
|
# 国内直连 Docker Hub 会超时报警;强制走构建,跳过这一步。
|
||||||
|
pull_policy: build
|
||||||
container_name: fonu
|
container_name: fonu
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- updater
|
||||||
ports:
|
ports:
|
||||||
- "6893:6893"
|
- "6893:6893"
|
||||||
- "18080:80"
|
- "18080:80"
|
||||||
- "9443:443"
|
- "9443:443"
|
||||||
volumes:
|
volumes:
|
||||||
- fonu-data:/data
|
# 数据目录:默认用命名卷 fonu-data(由 Docker 管理,位置随 Docker 数据根)。
|
||||||
|
# 想落到指定目录就在同层 .env 里设 FONU_DATA_PATH=/vol1/docker/fonu-data ——
|
||||||
|
# 以 / 或 . 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷。
|
||||||
|
- ${FONU_DATA_PATH:-fonu-data}:/data
|
||||||
|
- fonu-updater-sock:/run/fonu-updater
|
||||||
environment:
|
environment:
|
||||||
FONU_DATA_DIR: /data
|
FONU_DATA_DIR: /data
|
||||||
FONU_LISTEN: :6893
|
FONU_LISTEN: :6893
|
||||||
FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-change-me-in-production}
|
FONU_UPDATER_SOCKET: /run/fonu-updater/updater.sock
|
||||||
|
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
|
||||||
|
# 想自己指定就在同层 .env 里设这个变量;注意它同时是凭据加密密钥,已有数据后不要更换
|
||||||
|
FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-}
|
||||||
|
# 时区:通知消息时间按此显示(默认东八区)
|
||||||
|
TZ: ${TZ:-Asia/Shanghai}
|
||||||
|
|
||||||
|
# 受限升级 sidecar:只有它持有 Docker Socket,主程序通过共享 Unix Socket 触发
|
||||||
|
updater:
|
||||||
|
build:
|
||||||
|
context: http://45.207.223.190:10082/mrlee/-Fonu.git#main
|
||||||
|
target: updater
|
||||||
|
args:
|
||||||
|
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
||||||
|
UPDATER_IMAGE: ${UPDATER_IMAGE:-docker.m.daocloud.io/library/docker:27-cli}
|
||||||
|
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
||||||
|
image: fonu-updater:${FONU_VERSION:-local}
|
||||||
|
pull_policy: build
|
||||||
|
container_name: fonu-updater
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ${DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock
|
||||||
|
- ${FONU_UPDATE_HOST_DIR:-.}:/workspace
|
||||||
|
- fonu-updater-sock:/run/fonu-updater
|
||||||
|
environment:
|
||||||
|
FONU_UPDATER_SOCKET: /run/fonu-updater/updater.sock
|
||||||
|
FONU_UPDATE_PROJECT_DIR: /workspace
|
||||||
|
FONU_UPDATE_COMPOSE_FILE: docker-compose.hub.yml
|
||||||
|
FONU_UPDATE_SERVICE: fonu
|
||||||
|
FONU_UPDATE_MODE: git
|
||||||
|
FONU_UPDATE_REPO: http://45.207.223.190:10082/mrlee/-Fonu.git
|
||||||
|
FONU_UPDATE_BRANCH: main
|
||||||
|
FONU_UPDATE_APP_URL: http://fonu:6893
|
||||||
|
FONU_UPDATE_HOST_DIR: ${FONU_UPDATE_HOST_DIR:-}
|
||||||
|
FONU_UPDATE_PROJECT_NAME: ${FONU_UPDATE_PROJECT_NAME:-fonu}
|
||||||
|
FONU_UPDATE_SELF_IMAGE: fonu-updater:${FONU_VERSION:-local}
|
||||||
|
FONU_UPDATE_DOCKER_SOCK: ${DOCKER_SOCK:-/var/run/docker.sock}
|
||||||
|
TZ: ${TZ:-Asia/Shanghai}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
fonu-data:
|
fonu-data:
|
||||||
|
fonu-updater-sock:
|
||||||
|
|||||||
+52
-4
@@ -1,4 +1,10 @@
|
|||||||
# 本地项目:用源码构建镜像(镜像名 fonu:local,便于导出 / 备份)
|
# 固定项目名:不改的话,改了目录名/项目名后 compose 会把它当成新项目,
|
||||||
|
# fonu-data 会变成新空卷(看起来像「数据丢了」)。
|
||||||
|
name: fonu
|
||||||
|
|
||||||
|
# 本地项目:用源码构建镜像(镜像名随 VERSION 变,便于回滚;也便于导出 / 备份)
|
||||||
|
# 升级:docker compose up -d --build(或在部署机跑 scripts/upgrade.sh)
|
||||||
|
# 回滚:FONU_VERSION=上一个版本号 docker compose up -d(镜像 tag 还在本地)
|
||||||
services:
|
services:
|
||||||
fonu:
|
fonu:
|
||||||
build:
|
build:
|
||||||
@@ -7,20 +13,62 @@ services:
|
|||||||
NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine}
|
NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine}
|
||||||
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
||||||
RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim}
|
RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim}
|
||||||
|
UPDATER_IMAGE: ${UPDATER_IMAGE:-docker.m.daocloud.io/library/docker:27-cli}
|
||||||
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
||||||
image: fonu:local
|
image: fonu:${FONU_VERSION:-local}
|
||||||
container_name: fonu
|
container_name: fonu
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- updater
|
||||||
ports:
|
ports:
|
||||||
- "6893:6893"
|
- "6893:6893"
|
||||||
- "18080:80"
|
- "18080:80"
|
||||||
- "9443:443"
|
- "9443:443"
|
||||||
volumes:
|
volumes:
|
||||||
- fonu-data:/data
|
# 数据目录:默认用命名卷 fonu-data(由 Docker 管理,位置随 Docker 数据根)。
|
||||||
|
# 想落到指定目录就在同层 .env 里设 FONU_DATA_PATH=/vol1/docker/fonu-data ——
|
||||||
|
# 以 / 或 . 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷。
|
||||||
|
- ${FONU_DATA_PATH:-fonu-data}:/data
|
||||||
|
- fonu-updater-sock:/run/fonu-updater
|
||||||
environment:
|
environment:
|
||||||
FONU_DATA_DIR: /data
|
FONU_DATA_DIR: /data
|
||||||
FONU_LISTEN: :6893
|
FONU_LISTEN: :6893
|
||||||
FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-change-me-in-production}
|
FONU_UPDATER_SOCKET: /run/fonu-updater/updater.sock
|
||||||
|
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
|
||||||
|
# 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换
|
||||||
|
FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-}
|
||||||
|
# 时区:通知消息时间按此显示(默认东八区)
|
||||||
|
TZ: ${TZ:-Asia/Shanghai}
|
||||||
|
|
||||||
|
# 受限升级 sidecar:只有它持有 Docker Socket,主程序通过共享 Unix Socket 触发
|
||||||
|
updater:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: updater
|
||||||
|
args:
|
||||||
|
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
||||||
|
UPDATER_IMAGE: ${UPDATER_IMAGE:-docker.m.daocloud.io/library/docker:27-cli}
|
||||||
|
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
||||||
|
image: fonu-updater:${FONU_VERSION:-local}
|
||||||
|
container_name: fonu-updater
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ${DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock
|
||||||
|
- ${FONU_UPDATE_HOST_DIR:-.}:/workspace
|
||||||
|
- fonu-updater-sock:/run/fonu-updater
|
||||||
|
environment:
|
||||||
|
FONU_UPDATER_SOCKET: /run/fonu-updater/updater.sock
|
||||||
|
FONU_UPDATE_PROJECT_DIR: /workspace
|
||||||
|
FONU_UPDATE_COMPOSE_FILE: docker-compose.yml
|
||||||
|
FONU_UPDATE_SERVICE: fonu
|
||||||
|
FONU_UPDATE_MODE: local
|
||||||
|
FONU_UPDATE_APP_URL: http://fonu:6893
|
||||||
|
FONU_UPDATE_HOST_DIR: ${FONU_UPDATE_HOST_DIR:-}
|
||||||
|
FONU_UPDATE_PROJECT_NAME: ${FONU_UPDATE_PROJECT_NAME:-fonu}
|
||||||
|
FONU_UPDATE_SELF_IMAGE: fonu-updater:${FONU_VERSION:-local}
|
||||||
|
FONU_UPDATE_DOCKER_SOCK: ${DOCKER_SOCK:-/var/run/docker.sock}
|
||||||
|
TZ: ${TZ:-Asia/Shanghai}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
fonu-data:
|
fonu-data:
|
||||||
|
fonu-updater-sock:
|
||||||
|
|||||||
+2
-2
@@ -39,7 +39,7 @@
|
|||||||
| Docker | 20.10+(含 compose v2) |
|
| Docker | 20.10+(含 compose v2) |
|
||||||
| 内存 | ≥ 512MB 空闲(构建期建议 ≥ 2GB,前端编译吃内存) |
|
| 内存 | ≥ 512MB 空闲(构建期建议 ≥ 2GB,前端编译吃内存) |
|
||||||
| 磁盘 | ≥ 2GB(镜像约 300MB + 数据目录按日志量增长) |
|
| 磁盘 | ≥ 2GB(镜像约 300MB + 数据目录按日志量增长) |
|
||||||
| 网络 | 构建期需访问 npm registry、Go module proxy、GitHub(下载 frpc 0.71.0);运行期需访问你的 DNS 服务商 API(DDNS/证书) |
|
| 网络 | 构建期需访问 npm registry 与 Go module proxy;运行期需访问 GitHub(按需下载 frpc)与你的 DNS 服务商 API(DDNS/证书) |
|
||||||
| 端口 | 管理端口 6893;反代端口默认 80/443(Bridge)或 18080/9443(飞牛 host 模式) |
|
| 端口 | 管理端口 6893;反代端口默认 80/443(Bridge)或 18080/9443(飞牛 host 模式) |
|
||||||
|
|
||||||
## 2. 构建镜像(所有部署方式的前提)
|
## 2. 构建镜像(所有部署方式的前提)
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
docker compose build
|
docker compose build
|
||||||
```
|
```
|
||||||
|
|
||||||
一次构建同时产出:前端 `dist`、Go 后端二进制、内置 frpc 0.71.0 的运行镜像,标签为 `fonu:local`。
|
一次构建同时产出:前端 `dist`、Go 后端二进制与运行镜像(frpc 不预装,运行期用「应用配置」按需下载),标签为 `fonu:local`。
|
||||||
|
|
||||||
**版本号**:读取根目录 `VERSION` 文件(当前 `1.1.0`)自动注入前后端显示;要改版本号只需编辑该文件后重新 build。
|
**版本号**:读取根目录 `VERSION` 文件(当前 `1.1.0`)自动注入前后端显示;要改版本号只需编辑该文件后重新 build。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Fonu 文档索引
|
||||||
|
|
||||||
|
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
|
||||||
|
> 最近更新:2026-10-01(v1.9.0)
|
||||||
|
|
||||||
|
## 方案与设计(本目录)
|
||||||
|
|
||||||
|
| 文件 | 主题 | 状态 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `project-analysis-2026-09-27.md` | 全项目详细分析(v1.2.19):架构、模块、数据、API、前端、链路、风险与改进路线 | 当前版本快照 |
|
||||||
|
| `improvement-roadmap-2026-09-27.md` | P0-P3 改进路线实施方案:逐项改法、测试、验收、依赖、发布与回滚 | P0-1、P0-3、P0-4 已完成,其余待实施 |
|
||||||
|
| `system-upgrade.md` | Docker Compose 一键升级:sidecar 架构、首次部署、安全边界、回滚、故障排查与验证页 | 已实施(v1.3.8) |
|
||||||
|
| `agent-listen-strategy-plan.md` | Agent 默认监听策略:回环监听、SSH 隧道优先、HTTPS pin 可选与自动迁移方案 | 阶段 1-4 已实施(v1.4.0);2.0.0 兼容清理待后续 |
|
||||||
|
| `cloudflared-integration-plan.md` | Cloudflare Tunnel(cloudflared)与 Fonu 的融合方案:托管模型、复用面、分阶段落地与 v2 优化 | 阶段 1-2 已实施(v1.6.0);后续按 `cloudflared-fonu-fit-plan.md` 推进 |
|
||||||
|
| `cloudflared-feature-plan.md` | Cloudflare 隧道功能全集:官方最新文档、参考项目功能、Fonu 现状差距与 P0-P3 路线 | 查漏用;实施以 `cloudflared-fonu-fit-plan.md` 为准 |
|
||||||
|
| `cloudflared-fonu-fit-plan.md` | Cloudflare 隧道 Fonu 适配版计划:出口抽象、规则同源、体系整合、明确不做项与阶段实施 | 阶段 1-3 主体已实施;剩余项见 `cloudflared-current-features.md` |
|
||||||
|
| `cloudflared-current-features.md` | Cloudflare 隧道当前已实现功能说明:页面、出口、路由、DNS、监控、备份、诊断、API 与限制 | 当前版本功能基线 |
|
||||||
|
| `cloudflared-github-ux-research.md` | GitHub 同类项目调研:Cloudflare 管理面板、反向代理、访问平台、监控与流量检查器的可借鉴体验 | 调研与优化路线 |
|
||||||
|
| `project-audit-2026-09-24.md` | 全项目审查:P0/P1/P2 分级,含精确行号与逐条修法 | **进行中**——P0 已随 v1.2.7 完成,P1 实施中 |
|
||||||
|
| `frp-server-list-merge-plan.md` | FRP 服务端列表并入「公网服务端」页(方案 A,四步) | 步 1/2 已完成(v1.2.6);步 3 文案收尾、步 4 死样式清理未做 |
|
||||||
|
| `agent-capability-plan.md` | fonu-agent 能力扩展(P0/P1/P2/P3) | 已实施(agent 0.11.0);P3 三项不做 |
|
||||||
|
| `agent-view-design.md` | 公网 Agent 页设计方案(Pass 1/2 流程) | 部分过期:顶部下拉已被左列表 + 右详情取代 |
|
||||||
|
| `agent-view-design-v2.md` | 公网 Agent 页设计方案 v2 | 部分过期:同上 |
|
||||||
|
| `agent-view-refactor-plan.md` | 公网 Agent 页四段式(状态 → 控制 → 生命周期 → 输出)整改 | 已实施 |
|
||||||
|
| `proxy-remote-design.md` | 公网反代页设计方案(以本地反代页为参照) | 已实施;列表已改为左服务端 + 右台账 |
|
||||||
|
| `agent-proxy-settings-plan.md` | 公网反代「部署设置」对齐本地反代的语义迁移 | 已实施 |
|
||||||
|
| `agent-proxy-roadmap.md` | 公网反代功能增强规划(含部署踩坑史) | 部分已实施(漂移检测、全量重部署),其余待排期 |
|
||||||
|
| `frps-features.md` | frps 服务端功能实现清单(对照官方 frps_full_example.toml) | 参考 |
|
||||||
|
| `frps服务端管理方案.md` | FRPS 服务端管理方案(fonu-agent 部署在公网服务器) | 已实施(Agent 管理面板) |
|
||||||
|
| `frps管理方案.md` | FRPS 管理方案(早期版本) | 疑似已被 `frps服务端管理方案.md` 取代,保留供追溯(待确认) |
|
||||||
|
| `upstream-sync-plan.md` | 上游 1.0.2 → 1.0.5 对照与可借鉴项 | 部分过期:对 `/frp` 现状的描述基于 2026-09-23 |
|
||||||
|
| `FRP公网暴露操作手册.md` | FRP 公网暴露操作手册(面向使用) | 参考 |
|
||||||
|
| `DEPLOYMENT.md` | 部署安装:裸机 / Docker / 飞牛 host 模式 | 参考;适用 `fonu-1.1.0` 安装包 |
|
||||||
|
|
||||||
|
## 其它位置
|
||||||
|
|
||||||
|
- 仓库根 `../docs/`(`111.md`、`FRP迁移方案-0.1.25到1.0.2.md`、`FRP版本对比-1.0.2-vs-0.1.25.md`)为**临时区**,不随本项目维护;正式文档一律放本目录。
|
||||||
|
- `screenshots/` 供 README 引用。
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
# fonu-agent 能力扩展方案
|
||||||
|
|
||||||
|
> **实施状态(2026-09-24)**:P0/P1/P2 已全部落地(agent 0.11.0);P3 三项按本文约定不做。剩余为真机验收项,见 `docs/project-audit-2026-09-24.md`。
|
||||||
|
|
||||||
|
> 本文盘点 VPS 侧 `fonu-agent` 现有的接口面与数据面,给出「能直接做」「需要新增 agent 端点」「不做」三类功能的详细设计。
|
||||||
|
>
|
||||||
|
> 依据:本仓库 `internal/agent/`、`internal/frp/agent_client.go`、`internal/frp/agent_installer.go`、`internal/frp/ssh_installer.go`、`internal/api/frpmulti_handler.go`、`web/src/views/AgentView.vue`、`web/src/views/ProxyRemoteView.vue`(均为读代码确认,非推测)。
|
||||||
|
> 当前 `AgentVersion = "0.9.2"`(`internal/agent/server.go:34`)。
|
||||||
|
> 更新日期:2026-09-23
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现状盘点
|
||||||
|
|
||||||
|
### 1.1 agent 的接口面(12 个端点)
|
||||||
|
|
||||||
|
`internal/agent/server.go:151-162`:
|
||||||
|
|
||||||
|
| # | 方法与路径 | 作用 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `GET /api/v1/ping` | 存活探测(安装/升级后确认 token 与网络) |
|
||||||
|
| 2 | `GET /api/v1/status` | 一次性返回 Nginx / frps / 端口 / 路由名的快照 |
|
||||||
|
| 3 | `GET /api/v1/routes` | 每条 `fonu-*.conf` 的真实状态:`file_exists` / `syntax_ok` / `loaded` |
|
||||||
|
| 4 | `PUT /api/v1/routes` | 写入(或覆盖)一条域名的 Nginx 片段 |
|
||||||
|
| 5 | `GET /api/v1/routes/{domain}/conf` | 回读某域名实际生效的配置文件内容 |
|
||||||
|
| 6 | `DELETE /api/v1/routes/{domain}` | 删除某域名的片段 |
|
||||||
|
| 7 | `POST /api/v1/nginx/install` | 一键安装 Nginx |
|
||||||
|
| 8 | `PUT /api/v1/china-cidr` | 下发国内地址库(供 `china_only` 规则引用) |
|
||||||
|
| 9 | `PUT /api/v1/frps/config` | 写入 frps.toml |
|
||||||
|
| 10 | `POST /api/v1/frps/install` | 下载并安装指定版本的 frps(幂等,重复调用=升级) |
|
||||||
|
| 11 | `POST /api/v1/frps/action` | frps 的 start / stop / restart |
|
||||||
|
| 12 | `POST /api/v1/certs` | 推送证书(fullchain + privatekey) |
|
||||||
|
|
||||||
|
鉴权:全部走 `s.auth(...)` 包装的 token 校验;agent **只监听 VPS 本地**,由 Fonu 主动发起请求,**不做任何主动上报**。
|
||||||
|
|
||||||
|
### 1.2 agent 的数据面(`status` 已返回、但应用侧只用了一部分)
|
||||||
|
|
||||||
|
`internal/agent/server.go:191` 的 `status` 返回字段:
|
||||||
|
|
||||||
|
| 字段 | 含义 | 应用侧现状 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `agent_version` | agent 自身版本 | 已用(AgentView 版本核对) |
|
||||||
|
| `nginx_ok` / `nginx_missing` / `nginx_running` / `nginx_path` / `nginx_version` / `nginx_error` | Nginx 可用性探测(带缓存) | 已用 |
|
||||||
|
| `nginx_binary` / `nginx_conf_dir` | 配置的二进制与 vhost 目录 | 已用 |
|
||||||
|
| `ports` | 80 / 443 / 7000 / 8080 等端口是否监听 | **只用到了 8080 一项**(`AgentView.vue:359`) |
|
||||||
|
| `frps_active` / `frps_enabled` | systemd 单元是否运行 / 是否开机自启 | 已用 |
|
||||||
|
| `frps_binary` / `frps_config_path` | Fonu 侧配置的路径 | 已用 |
|
||||||
|
| `frps_unit_exec` | systemd 实际执行的启动命令 | 已展示,**未做一致性核对** |
|
||||||
|
| `frps_version` | 探测到的 frps 版本 | 已展示 |
|
||||||
|
| `frps_log_tail` | frps 日志尾部 6 行 | 已展示 |
|
||||||
|
| `dashboard_addr` | 从 frps.toml 解析出的 dashboard 地址 | 已用 |
|
||||||
|
| `routes` | 已部署的域名列表 | 已用(部署台账) |
|
||||||
|
|
||||||
|
### 1.3 应用侧的调用面
|
||||||
|
|
||||||
|
`internal/frp/agent_client.go` 暴露 15 个方法,与上面 12 个端点一一对应(`Ping` / `Status` / `SyncRoute` / `SyncRouteSpec` / `SyncRouteEx` / `RouteConf` / `RoutesDetail` / `DeleteRoute` / `InstallNginx` / `SyncChinaCIDR` / `FRPSAction` / `InstallFRPS` / `PushFRPSConfig` / `PushCertificate`)。
|
||||||
|
|
||||||
|
其中 `RouteSpec`(`agent_client.go:83`)承载了完整部署语义:`Domain / Upstream / CertDir / TLS / WebSocket / RedirectHTTPS / HTTPSDisabled / SecurityHeaders / TLS13Only / VhostPort / AllowIPs / DenyIPs / BasicAuth* / ClientMaxBodySize / ProxyReadTimeout / RateLimit*`。
|
||||||
|
|
||||||
|
### 1.4 安装与升级链路(已闭环)
|
||||||
|
|
||||||
|
- agent 二进制**内嵌在 Fonu 里**(`internal/frp/agentbin`,按 arch 取),通过 **SSH** 上传到 VPS 并注册/重启 systemd 单元(`agent_installer.go` + `ssh_installer.go`)。SSH 连接信息就是迁移 023 加的 `ssh_host / ssh_port / ssh_user / ssh_auth / ssh_secret_enc`。
|
||||||
|
- 因此「升级 Agent」不需要 agent 自己有下载能力——**自升级不是必须项**。
|
||||||
|
- 版本约定:`AgentVersion` 上方按「每项行为变更一行」的注释累积,改动后手动递增(当前 0.9.2)。
|
||||||
|
|
||||||
|
### 1.5 能力边界(缺口所在)
|
||||||
|
|
||||||
|
1. **没有资源采集**:VPS 的 CPU / 内存 / 磁盘 / 负载完全不可见。
|
||||||
|
2. **没有日志读取**:只有 frps 的 6 行 tail,Nginx 的 access / error 日志读不到(排障必须 SSH)。
|
||||||
|
3. **frps 配置只写不读**:`PUT /api/v1/frps/config` 存在,没有对应的 GET;用户手改 VPS 上的 frps.toml 后 Fonu 无从知晓,下次下发直接覆盖。
|
||||||
|
4. **没有证书清单**:只能推送,无法列出 VPS 上现有的证书与到期时间(宝塔自己申请的证书看不到)。
|
||||||
|
5. **部署是逐条的**:`SyncRouteSpec` 按单条规则调用,没有「全量重部署」入口——agent 重装/换机后只能一条条点。
|
||||||
|
6. **写入结果不含 reload 细节**:片段写入成功但 Nginx reload 失败时,用户拿不到失败原因。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、方案总览
|
||||||
|
|
||||||
|
| 批次 | 项 | 改动面 | 前置依赖 | 预估 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| **P0-1** | 端口矩阵 | 前端(AgentView) | 无,`status.ports` 已有 | 0.5h |
|
||||||
|
| **P0-2** | 部署漂移清单 + 一键全量重部署 | 后端 service + 前端 | 无,`GET /routes` 与 `RouteConf` 已有 | 2h |
|
||||||
|
| **P0-3** | frps 运行一致性核对 | 前端 | 无,`status` 已有 | 0.5h |
|
||||||
|
| **P0-4** | 证书推送核对 | 前端 | 无,`RouteHealth` 已有 | 0.5h |
|
||||||
|
| **P1-1** | VPS 资源采集 `GET /api/v1/metrics` | agent + 应用 + 前端 | 需递增 AgentVersion、重编二进制、VPS 上升级 | 2h |
|
||||||
|
| **P1-2** | 公网 Nginx 日志 `GET /api/v1/logs/nginx` | agent + 应用 + 前端 | 同上 | 2.5h |
|
||||||
|
| **P1-3** | frps.toml 回读 `GET /api/v1/frps/config` | agent + 应用 + 前端 | 同上 | 1.5h |
|
||||||
|
| **P2-1** | VPS 证书清单 `GET /api/v1/certs` | agent + 应用 + 前端 | 同上 | 1.5h |
|
||||||
|
| **P2-2** | reload 失败原因回传 | agent + 应用 | 同上 | 1h |
|
||||||
|
| **P3** | 不做:防火墙读写、任意命令执行、agent 自升级 | — | — | — |
|
||||||
|
|
||||||
|
**贯穿约束(所有新增 agent 端点都适用)**:老版本 agent 没有新端点,应用侧调用必须**按「端点不存在」优雅降级**(收到 404/405 时显示「VPS 上的 agent 版本过低,请升级 Agent」而不是报错),否则用户升级了 Fonu 但没升级 agent 时页面会直接报错。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、P0 详细设计(不动 agent,纯应用侧)
|
||||||
|
|
||||||
|
### 3.1 端口矩阵
|
||||||
|
|
||||||
|
- **数据**:`status.ports`(布尔映射)。实现前先以 agent 代码为准列出全部键(当前已知 `80` / `443` / `7000` / `8080`)。
|
||||||
|
- **呈现**:AgentView「运行状态」段内加一张两列小表(端口 / 状态徽标),或把现有「8080 端口」那一项扩成完整列表。
|
||||||
|
- **降级**:agent 版本过旧、`ports` 缺失时整块显示「当前 agent 版本不返回端口信息」。
|
||||||
|
- **验收**:停掉监听 443 的服务后该行转异常色;旧 agent 上不报错。
|
||||||
|
|
||||||
|
### 3.2 部署漂移清单 + 一键全量重部署(价值最高)
|
||||||
|
|
||||||
|
三个集合:`A` = 数据库里启用中的反代规则域名;`B` = `GET /api/v1/routes` 返回的域名;`C` = 其中 `loaded = true` 的域名。
|
||||||
|
|
||||||
|
| 类别 | 定义 | 含义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 未部署 | `A - B` | 库里要部署但 VPS 上没有(agent 重装、换机、手工删过) |
|
||||||
|
| 孤儿配置 | `B - A` | VPS 有但库里没有(手工加的 `fonu-*.conf`、已删规则的残留) |
|
||||||
|
| 未生效 | `A ∩ (B - C)` | 文件在但未被 Nginx 加载(`syntax_ok=false`,或 include 未覆盖) |
|
||||||
|
| 内容不一致 | `RouteConf(domain)` 与「按当前部署设置生成的内容」规范化比对 | 手工改过上游 / 证书路径 / 安全项 |
|
||||||
|
|
||||||
|
**一键全量重部署**:对 `A` 逐条 `SyncRouteSpec`(串行、逐条收集结果、失败不中断,沿用 `Service.Reload` 的隔离报错风格),结束后回读一次 `GET /routes` 复核,结果弹窗汇总「成功 N / 失败 M + 每条失败原因」。
|
||||||
|
|
||||||
|
**前端落点**:公网反代页顶部提示条(有差异时才出现)+「重新部署全部」按钮(二次确认,因为会覆盖手工改动)。
|
||||||
|
|
||||||
|
**验收**:手工删一个 `fonu-*.conf` → 清单出现「未部署」,重部署后消失;手工改一条 conf 的上游 → 出现「内容不一致」;语法错误时归入「未生效」并给出原因。
|
||||||
|
|
||||||
|
### 3.3 frps 运行一致性核对
|
||||||
|
|
||||||
|
- **比对三项**:`frps_unit_exec`(systemd 实际执行)vs Fonu 配置的 `frps_binary`;`frps_version` vs 期望版本;`frps_config_path` vs Fonu 生成路径。
|
||||||
|
- **呈现**:AgentView「frps 控制」段加一行「启动路径一致性」,不一致时并列显示两个路径。
|
||||||
|
- **验收**:手工改过 systemd unit 的 VPS 上显示不一致;一致的机器显示绿色。
|
||||||
|
|
||||||
|
### 3.4 证书推送核对
|
||||||
|
|
||||||
|
- **数据**:已有 `RouteHealth` 的证书段(`cert_ok` / `cert_detail`)+ 推送结果。
|
||||||
|
- **呈现**:公网反代页健康列已展示证书段;补一行「证书来源」(Fonu 推送 / 待推送 / VPS 本地已有)。
|
||||||
|
- **验收**:证书过期时健康列给出证书段失败与到期天数。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、P1 详细设计(新增 agent 端点)
|
||||||
|
|
||||||
|
> 改动流程见 §7.1;以下端点响应都带 `checked_at`,与现有 `status` 保持一致。
|
||||||
|
|
||||||
|
### 4.1 `GET /api/v1/metrics` —— VPS 资源采集
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cpu_percent": 12.5,
|
||||||
|
"mem_used_bytes": 1288490188,
|
||||||
|
"mem_total_bytes": 4294967296,
|
||||||
|
"disk_used_bytes": 21474836480,
|
||||||
|
"disk_total_bytes": 53687091200,
|
||||||
|
"disk_path": "/",
|
||||||
|
"load_avg": [0.42, 0.51, 0.63],
|
||||||
|
"uptime_seconds": 864000,
|
||||||
|
"checked_at": "2026-09-23T19:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **实现要点**:**直接复用仓库里已有的 `internal/sysinfo`**(同一 module,`cmd/fonu-agent` 可直接引用)——`/proc/stat`、`/proc/meminfo`、`Statfs` 以及 Windows 的 kernel32 路径都已写好;agent 跑在 VPS 上,采到的天然是 VPS 自身数值。
|
||||||
|
- **CPU 差值**:`sysinfo.Read(prev)` 需要上次采样;agent 进程内保存上次 `CPUSample`,并对间隔 < 1 秒的重复调用直接返回上次结果(避免连续刷新时数值抖动)。
|
||||||
|
- **`load_avg` / `uptime_seconds`**:Linux 读 `/proc/loadavg`、`/proc/uptime`;非 Linux 省略这两个字段(前端按缺失处理)。
|
||||||
|
- **应用侧链路**:`AgentClient.Metrics(ctx)` → `GET /api/frpmulti/servers/{id}/agent/metrics` → AgentView 新增「VPS 资源」卡(复用本地仪表盘「系统资源」的行样式:名称 / 用量明细 / 4px 进度条 / 百分比,≥75% 转橙、≥90% 转红)。
|
||||||
|
- **降级**:404/405 → 卡片显示「VPS 上的 agent 版本过低(需 ≥ 0.10.0),请升级 Agent」。
|
||||||
|
- **验收**:VPS 上跑 `dd` 或压测时 CPU / 磁盘数值随之上升;磁盘接近写满时进度条转红;旧 agent 上显示升级提示而不是报错。
|
||||||
|
|
||||||
|
### 4.2 `GET /api/v1/logs/nginx` —— 公网 Nginx 日志
|
||||||
|
|
||||||
|
请求:`GET /api/v1/logs/nginx?type=access|error&lines=200`(`lines` 上限 2000,默认 200)
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "access",
|
||||||
|
"path": "/var/log/nginx/access.log",
|
||||||
|
"lines": ["127.0.0.1 - - [23/Sep/2026:19:00:00 +0800] \"GET / HTTP/1.1\" 200 1234"],
|
||||||
|
"truncated": false,
|
||||||
|
"checked_at": "2026-09-23T19:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **路径来源(白名单,不接受任意 path 参数)**:
|
||||||
|
1. 优先从 `nginx -T` 的 dump 里解析 `access_log` / `error_log` 指令(agent 已有 `nginx -T` 解析与缓存经验,见 `resolveNginxConfDir`);
|
||||||
|
2. 解析不到时回落默认路径 `/var/log/nginx/{access,error}.log`;
|
||||||
|
3. 支持环境变量 `FONU_AGENT_NGINX_LOG_DIR` 覆盖(与现有 `FONU_AGENT_NGINX_CONF_DIR` 的回退风格一致)。
|
||||||
|
- **只读尾部**:实现 `tailFile(path, n)`(与现有 `journalTail` 同思路),从文件末尾按块倒读,避免把整个日志读进内存;单行超长(如 4KB)截断。
|
||||||
|
- **错误处理**:文件不存在 → 200 + `lines: []` + 原样返回 `path`(前端显示「未找到日志文件」);无权限 → 403 并给出明确原因。
|
||||||
|
- **应用侧链路**:`AgentClient.NginxLogs(ctx, serverID, type, lines)` → `GET /api/frpmulti/servers/{id}/agent/logs/nginx?...`;日志中心新增「公网 Nginx」页签,复用现有 `LogToolbar` / `LogPagination` / `EmptyState` 与 `logTimeCell`,与「FRP 日志」页签同一套呈现。
|
||||||
|
- **验收**:VPS 上产生一次访问后页签能看到该条 access 记录;agent 无读权限时显示明确错误;`lines` 超过 2000 被夹到上限。
|
||||||
|
|
||||||
|
### 4.3 `GET /api/v1/frps/config` —— frps.toml 回读
|
||||||
|
|
||||||
|
响应:`{ "path": "/etc/frp/frps.toml", "exists": true, "content": "...", "mod_time": "2026-09-23T18:00:00Z", "checked_at": "..." }`
|
||||||
|
|
||||||
|
- **实现要点**:读 `s.cfg.FrpsConfigPath`,限制文件大小(如 256KB),超限返回 `truncated: true`;文件不存在返回 `exists: false` 而不是错误。
|
||||||
|
- **应用侧**:在「公网服务端」的 frps 同源配置弹窗里加「与 VPS 实际文件比对」——把「Fonu 即将下发的内容」与回读内容做规范化 diff(去注释与空白),不一致时提示「VPS 上的 frps.toml 已被手工修改」,并给「重新下发」按钮(复用已有 `PushFRPSConfig`)。
|
||||||
|
- **验收**:在 VPS 上手工改一行 frps.toml → 弹窗提示不一致并高亮差异行;重新下发后恢复一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、P2 详细设计
|
||||||
|
|
||||||
|
### 5.1 `GET /api/v1/certs` —— VPS 证书清单
|
||||||
|
|
||||||
|
响应:`{ "certs": [{ "domain": "7023.36800.cc", "cert_path": "...", "key_path": "...", "not_after": "2026-12-01T00:00:00Z", "days_left": 69 }], "warnings": [], "checked_at": "..." }`
|
||||||
|
|
||||||
|
- **实现要点**:遍历 `s.cfg.CertsDir` 下的域名目录,用标准库 `crypto/x509` 解析 `fullchain.pem` 的 `NotAfter`;解析失败的单条跳过并记入 `warnings`。
|
||||||
|
- **应用侧**:与 Fonu 的证书列表交叉核对,输出「Fonu 有 / VPS 有 / 两边都有但到期时间不同」三类;宝塔自行申请的证书也能被看到。
|
||||||
|
- **验收**:过期证书 `days_left < 0` 并标红;Fonu 未推送过的域名出现在「VPS 本地已有」分组。
|
||||||
|
|
||||||
|
### 5.2 reload 失败原因回传
|
||||||
|
|
||||||
|
- **做法(择一,倾向 A)**:
|
||||||
|
- **A**:`PUT /api/v1/routes` 与 `DELETE /api/v1/routes/{domain}` 的响应体增加 `reload_ok` / `reload_error` 字段——写入成功但 reload 失败时不再只回 200。
|
||||||
|
- **B**:新增 `POST /api/v1/nginx/reload`,返回 `nginx -t` 与 reload 的 stderr。
|
||||||
|
- **应用侧**:部署结果提示里带上 `reload_error`;漂移清单的「未生效」类别直接引用它。
|
||||||
|
- **验收**:故意写一条语法错误片段 → 部署结果明确显示 reload 失败原因,而不是「部署成功」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、明确不做的
|
||||||
|
|
||||||
|
1. **VPS 防火墙规则读写**(ufw / firewalld / 宝塔面板差异大、需要 root,误操作会直接断网)——最多做只读展示。
|
||||||
|
2. **任意命令执行端点**——agent 目前只有 token 鉴权,开放 shell 等于把 VPS 交出去。
|
||||||
|
3. **agent 自升级**——现有链路(Fonu 内嵌二进制 + SSH 上传 + 重启 systemd)已经闭环,自升级只省掉存 SSH 凭据,收益有限,还要额外处理下载源与签名校验。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、改动流程与验收
|
||||||
|
|
||||||
|
### 7.1 agent 侧(每新增一个端点都要走完)
|
||||||
|
|
||||||
|
1. `internal/agent/server.go`:加 handler + `mux.HandleFunc(...)`,并在 `AgentVersion` 上方按现有约定补一行版本说明(例如 `// 0.10.0:新增 /api/v1/metrics 资源采集与 /api/v1/logs/nginx 日志读取。`),把常量递增到 **`0.10.0`**。
|
||||||
|
2. 重编两个架构的内嵌二进制(Fonu 编译期嵌入,必须是真实二进制而不是占位文件):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:GOOS='linux'; $env:CGO_ENABLED='0'; $env:GOARCH='amd64'
|
||||||
|
go build -trimpath -o internal/frp/agentbin/fonu-agent-linux-amd64.new ./cmd/fonu-agent
|
||||||
|
$env:GOARCH='arm64'
|
||||||
|
go build -trimpath -o internal/frp/agentbin/fonu-agent-linux-arm64.new ./cmd/fonu-agent
|
||||||
|
Move-Item -Force internal/frp/agentbin/fonu-agent-linux-amd64.new internal/frp/agentbin/fonu-agent-linux-amd64
|
||||||
|
Move-Item -Force internal/frp/agentbin/fonu-agent-linux-arm64.new internal/frp/agentbin/fonu-agent-linux-arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 重启本机 Fonu(加载新内嵌二进制)→ VPS 上「升级 Agent」→ 用 `GET /api/v1/status` 的 `agent_version` 确认已是 `0.10.0`。
|
||||||
|
|
||||||
|
### 7.2 应用侧(每个端点对应一条链路)
|
||||||
|
|
||||||
|
`internal/frp/agent_client.go` 加方法 → `internal/frp/service.go` 加业务方法(含**端点不存在时的降级**:区分 404/405 与真实错误)→ `internal/api/frpmulti_handler.go` 加 handler → `internal/api/router.go` 注册(沿用现有 `agent/*` 路径风格)→ `web/src/api/{client,types}.ts` → 前端页面。
|
||||||
|
|
||||||
|
### 7.3 验收(每批的检查项)
|
||||||
|
|
||||||
|
- **P0**:手工制造四类漂移各一次,清单分类正确;全量重部署在单条失败时其余仍继续并汇总报错;旧 agent 上端口矩阵降级不报错。
|
||||||
|
- **P1**:`metrics` 数值随 VPS 负载变化且 CPU 不抖动;`logs/nginx` 尾部读取正确且超限被夹到 2000;`frps/config` 回读能检出手工改动。
|
||||||
|
- **P2**:证书清单含宝塔自签证书与到期天数;语法错误片段部署时回传 reload 失败原因。
|
||||||
|
- **通用**:新端点全部走 `s.auth`;`go vet` / `go test ./internal/...` / `vue-tsc` 全绿;agent 版本不匹配时前端一律显示升级提示而不是报错。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、风险与回滚
|
||||||
|
|
||||||
|
| 风险 | 说明 | 应对 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **新老版本错配** | Fonu 已升级、VPS 上的 agent 仍是旧版 | 应用侧对新端点统一做 404/405 降级,前端显示「请升级 Agent」;不改动已有端点的行为 |
|
||||||
|
| **只读端点的权限** | `/var/log/nginx` 与证书目录可能不允许 agent 运行用户读取 | 端点返回明确的 403 + 路径,前端提示「agent 无读取权限」;安装脚本里补充读权限说明 |
|
||||||
|
| **`metrics` 采样抖动** | 高频调用时 CPU 差值失真 | 间隔 < 1 秒直接返回上次结果;前端按 15 秒轮询(与本地仪表盘一致) |
|
||||||
|
| **全量重部署覆盖手工改动** | 会覆盖 VPS 上手工编辑过的 `fonu-*.conf` | 按钮带二次确认 + 差异预览;`RouteConf` 回读内容先展示再覆盖 |
|
||||||
|
| **回滚** | agent 行为变更后需要退回旧版本 | agent 二进制随 Fonu 版本走:用旧版 Fonu 的「升级 Agent」即可把旧二进制重新上传;新增端点均为**纯增量**,不改变已有端点语义,应用侧回滚也无需数据迁移 |
|
||||||
|
|
||||||
|
**总原则**:新增端点一律只读或纯增量、不改变已有端点语义;所有 agent 交互都保持「Fonu 主动拉取、agent 被动应答」的现有模型,不引入 agent 主动上报。
|
||||||
@@ -0,0 +1,757 @@
|
|||||||
|
# Agent 默认监听策略与自动安全接入方案
|
||||||
|
|
||||||
|
> 方案编号:P0-5
|
||||||
|
> 制定日期:2026-09-28
|
||||||
|
> 当前 AgentVersion:0.14.0
|
||||||
|
> 目标:默认不暴露 Agent 管理接口,安装和升级过程自动建立安全连接,不要求用户手工配置端口、隧道或证书
|
||||||
|
|
||||||
|
> 实施状态(2026-09-28):阶段 1-5 主体已在 v1.5.0 完成,包括回环监听、SSH 隧道与后台重连、旧 HTTP 迁移、HTTPS 端口自动选择、证书自动轮换与双 pin 过渡、主机防火墙辅助、传输状态接口与审计记录。剩余:2.0.0 移除旧 HTTP 兼容分支,以及 Debian/Ubuntu/CentOS、宝塔、端口占用、云安全组受限等真机矩阵验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、结论
|
||||||
|
|
||||||
|
推荐采用**自动为主、安全优先、旧模式显式兼容**的三层策略:
|
||||||
|
|
||||||
|
1. **默认模式:SSH 隧道 + 回环监听**
|
||||||
|
- `fonu-agent` 默认只监听 `127.0.0.1:7700`。
|
||||||
|
- 中央 Fonu 使用现有 SSH 凭据建立本地转发:
|
||||||
|
|
||||||
|
```text
|
||||||
|
中央 Fonu 127.0.0.1:<随机本地端口>
|
||||||
|
→ SSH 加密连接
|
||||||
|
→ VPS 127.0.0.1:7700
|
||||||
|
→ fonu-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
- 不开放公网 Agent 端口,不需要域名、证书、云安全组或防火墙修改。
|
||||||
|
|
||||||
|
2. **可选模式:自动 HTTPS 反代 + 证书固定**
|
||||||
|
- Agent 仍只监听回环地址。
|
||||||
|
- HTTPS 不作为首次安装选项;Agent 安全安装完成后,用户可在「公网 Agent」页切换到该高级模式。
|
||||||
|
- 切换时自动配置 Nginx HTTPS 反代,并使用自签名证书。
|
||||||
|
- 中央 Fonu 首次安装时记录证书指纹,后续按指纹校验。
|
||||||
|
- 适用于希望免维护常驻 SSH 隧道、且愿意开放一个 HTTPS 端口的用户。
|
||||||
|
|
||||||
|
3. **兼容模式:显式公网明文 HTTP**
|
||||||
|
- 保留旧部署能力,但必须显式开启。
|
||||||
|
- UI 持续显示高危警告。
|
||||||
|
- 不允许自动降级到该模式。
|
||||||
|
|
||||||
|
默认安装与升级过程不需要用户设置 `FONU_AGENT_LISTEN`、TLS 证书或防火墙规则。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、现状
|
||||||
|
|
||||||
|
### 2.1 Agent 监听
|
||||||
|
|
||||||
|
当前入口:
|
||||||
|
|
||||||
|
```go
|
||||||
|
listenv := flag.String("listen", envOr("FONU_AGENT_LISTEN", ":7700"), ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
`internal/agent.NewServer` 空配置时同样回落到:
|
||||||
|
|
||||||
|
```go
|
||||||
|
cfg.ListenAddr = ":7700"
|
||||||
|
```
|
||||||
|
|
||||||
|
`:7700` 等价于监听所有 IPv4 / IPv6 网卡。
|
||||||
|
|
||||||
|
### 2.2 传输安全
|
||||||
|
|
||||||
|
当前 Agent 使用明文 HTTP:
|
||||||
|
|
||||||
|
- Bearer Token 在网络上直接传输;
|
||||||
|
- Token 已采用常量时间比较;
|
||||||
|
- Agent 默认没有 TLS;
|
||||||
|
- 安装脚本打印:
|
||||||
|
|
||||||
|
```text
|
||||||
|
API: http://<VPS-IP>:7700
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 中央 Fonu 连接
|
||||||
|
|
||||||
|
安装完成后,`AgentInstall` 写入:
|
||||||
|
|
||||||
|
```go
|
||||||
|
AgentURL = fmt.Sprintf("http://%s:%d", server.SSHHost, 7700)
|
||||||
|
```
|
||||||
|
|
||||||
|
中央 Fonu 后续直接访问 VPS 公网 IP 的 7700 端口。
|
||||||
|
|
||||||
|
### 2.4 当前问题
|
||||||
|
|
||||||
|
- 用户需要额外放行或确认 7700;
|
||||||
|
- 如果防火墙忘记放行,Agent 显示不可达;
|
||||||
|
- 如果放行,Token 暴露在明文网络中;
|
||||||
|
- 部署工具和容器环境可能改变监听行为;
|
||||||
|
- “默认安全”没有形成产品行为;
|
||||||
|
- 安全策略主要依赖用户理解和手工操作。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、设计目标
|
||||||
|
|
||||||
|
### 3.1 必须实现
|
||||||
|
|
||||||
|
1. 新安装默认不监听公网网卡。
|
||||||
|
2. 安装过程自动建立中央 Fonu 到 Agent 的安全连接。
|
||||||
|
3. 不需要用户手工配置网络或证书。
|
||||||
|
4. Agent 升级时可自动迁移旧监听配置。
|
||||||
|
5. 旧部署仍可显式使用公网 HTTP。
|
||||||
|
6. 传输失败时给出可执行的诊断信息。
|
||||||
|
7. 中央 Fonu 重启后可自动恢复连接。
|
||||||
|
8. Agent 与中央 Fonu 的版本兼容策略保持清晰。
|
||||||
|
|
||||||
|
### 3.2 非目标
|
||||||
|
|
||||||
|
- 不尝试自动修改云厂商安全组;
|
||||||
|
- 不要求用户申请域名或公网 CA 证书;
|
||||||
|
- 不引入多租户权限模型;
|
||||||
|
- 不把 Agent 管理 API 暴露给家庭用户的普通浏览器;
|
||||||
|
- 不提供一个“自动回退到明文 HTTP”的危险路径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、方案对比
|
||||||
|
|
||||||
|
### 4.1 方案 A:公网 HTTP + Bearer Token(现状)
|
||||||
|
|
||||||
|
| 项目 | 评价 |
|
||||||
|
| --- | --- |
|
||||||
|
| 自动化 | 高 |
|
||||||
|
| 安全性 | 低 |
|
||||||
|
| 防火墙 | 需要放行 7700 |
|
||||||
|
| 证书 | 不需要 |
|
||||||
|
| 复杂度 | 低 |
|
||||||
|
| 主要风险 | Token 明文、端口暴露、被动扫描 |
|
||||||
|
|
||||||
|
结论:只保留为显式兼容模式,不再作为默认。
|
||||||
|
|
||||||
|
### 4.2 方案 B:SSH 隧道 + Agent 回环监听(推荐默认)
|
||||||
|
|
||||||
|
| 项目 | 评价 |
|
||||||
|
| --- | --- |
|
||||||
|
| 自动化 | 很高 |
|
||||||
|
| 安全性 | 高 |
|
||||||
|
| 防火墙 | 不需要新增端口 |
|
||||||
|
| 证书 | 不需要 |
|
||||||
|
| 复杂度 | 中高,需要隧道生命周期管理 |
|
||||||
|
| 依赖 | SSH 可达;本功能安装 Agent 时已经要求 SSH |
|
||||||
|
| 主要优势 | 不暴露端口、不依赖域名和证书 |
|
||||||
|
|
||||||
|
结论:作为默认模式。
|
||||||
|
|
||||||
|
### 4.3 方案 C:Nginx HTTPS + 自签名证书固定
|
||||||
|
|
||||||
|
| 项目 | 评价 |
|
||||||
|
| --- | --- |
|
||||||
|
| 自动化 | 高 |
|
||||||
|
| 安全性 | 高 |
|
||||||
|
| 防火墙 | 需要放行一个 HTTPS 端口 |
|
||||||
|
| 证书 | 自动生成和固定,不需要公网 CA |
|
||||||
|
| 复杂度 | 中 |
|
||||||
|
| 主要优势 | 中央 Fonu 可直接连接,不依赖常驻 SSH |
|
||||||
|
| 主要限制 | 云安全组仍需用户放行端口 |
|
||||||
|
|
||||||
|
结论:作为可选直连模式。
|
||||||
|
|
||||||
|
### 4.4 方案 D:公网域名 + ACME 证书
|
||||||
|
|
||||||
|
| 项目 | 评价 |
|
||||||
|
| --- | --- |
|
||||||
|
| 自动化 | 中 |
|
||||||
|
| 安全性 | 高 |
|
||||||
|
| 防火墙 | 需要 443 或 DNS-01 |
|
||||||
|
| 证书 | 需要域名、DNS 与 CA |
|
||||||
|
| 复杂度 | 高 |
|
||||||
|
| 用户成本 | 高 |
|
||||||
|
|
||||||
|
结论:不作为默认。可作为未来面向高级用户的能力。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、推荐架构
|
||||||
|
|
||||||
|
### 5.1 默认数据流
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Web[Fonu Web] --> API[Fonu 后端]
|
||||||
|
API --> Tunnel[SSH 隧道管理器]
|
||||||
|
Tunnel -->|127.0.0.1:自动分配端口| SSH[SSH 加密连接]
|
||||||
|
SSH --> VPSLoopback[VPS 127.0.0.1:7700]
|
||||||
|
VPSLoopback --> Agent[fonu-agent]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 角色职责
|
||||||
|
|
||||||
|
| 组件 | 职责 |
|
||||||
|
| --- | --- |
|
||||||
|
| Fonu 后端 | 决定 transport、启动隧道、调用 AgentClient |
|
||||||
|
| 隧道管理器 | 建立 SSH 本地转发、保持连接、重连、健康检查 |
|
||||||
|
| fonu-agent | 默认只监听回环,不再负责公网暴露 |
|
||||||
|
| 安装脚本 | 写入 `FONU_AGENT_LISTEN=127.0.0.1:7700`,禁止默认可公开 |
|
||||||
|
| UI | 显示安全模式、连接状态、可选迁移和显式旧模式警告 |
|
||||||
|
|
||||||
|
### 5.3 连接抽象
|
||||||
|
|
||||||
|
中央 Fonu 不再把“公网 IP + 7700”当作 Agent 的唯一定义。新增 transport 概念:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tunnel SSH 本地转发,默认
|
||||||
|
https-pin Nginx HTTPS + 证书指纹固定,可选
|
||||||
|
http 公网明文,仅显式兼容
|
||||||
|
```
|
||||||
|
|
||||||
|
`AgentClient` 仍使用普通 HTTP 客户端接口,但 baseURL 由 transport 管理:
|
||||||
|
|
||||||
|
- tunnel:`http://127.0.0.1:<localPort>`
|
||||||
|
- https-pin:`https://<host>:<port>`
|
||||||
|
- http:`http://<host>:7700`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、默认模式:SSH 隧道
|
||||||
|
|
||||||
|
### 6.1 为什么默认走 SSH
|
||||||
|
|
||||||
|
安装 Agent 已经必须使用 SSH,因此:
|
||||||
|
|
||||||
|
- 不新增用户操作;
|
||||||
|
- 不需要公网开放 7700;
|
||||||
|
- 不需要证书和域名;
|
||||||
|
- 不依赖云安全组修改;
|
||||||
|
- SSH 主机密钥已经使用 TOFU 校验;
|
||||||
|
- Token 继续保留,形成双层认证。
|
||||||
|
|
||||||
|
### 6.2 本地端口分配
|
||||||
|
|
||||||
|
每个 VPS 服务端分配一个稳定的本地回环端口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
127.0.0.1:<agent_local_port>
|
||||||
|
```
|
||||||
|
|
||||||
|
分配方式建议:
|
||||||
|
|
||||||
|
1. 首次安装时在 `frp_servers.agent_local_port` 中保存;
|
||||||
|
2. 可在 `17700-18700` 范围内分配;
|
||||||
|
3. 分配时检查:
|
||||||
|
- 未被其他服务端占用;
|
||||||
|
- 未被本机其他 SSH 隧道占用;
|
||||||
|
- 可以正常绑定。
|
||||||
|
4. 保存后不再变化,便于重启恢复。
|
||||||
|
|
||||||
|
### 6.3 隧道管理器
|
||||||
|
|
||||||
|
新增:
|
||||||
|
|
||||||
|
```text
|
||||||
|
internal/frp/agent_tunnel.go
|
||||||
|
```
|
||||||
|
|
||||||
|
核心能力:
|
||||||
|
|
||||||
|
- `Ensure(serverID)`:确保隧道已启动;
|
||||||
|
- `Stop(serverID)`:停止隧道;
|
||||||
|
- `Restart(serverID)`:重建;
|
||||||
|
- `State(serverID)`:返回 connected / reconnecting / failed;
|
||||||
|
- 应用退出时统一关闭。
|
||||||
|
|
||||||
|
实现建议:
|
||||||
|
|
||||||
|
- 使用现有 `golang.org/x/crypto/ssh`;
|
||||||
|
- 复用 `SSHInstaller` 的 known_hosts 校验;
|
||||||
|
- SSH 连接使用 keepalive;
|
||||||
|
- 本地监听使用 `127.0.0.1`,不绑定 `0.0.0.0`;
|
||||||
|
- 连接断开后指数退避重连;
|
||||||
|
- 每个服务端最多一个隧道实例;
|
||||||
|
- 同一 `AgentClient` 复用 HTTP 连接。
|
||||||
|
|
||||||
|
### 6.4 健康检查
|
||||||
|
|
||||||
|
隧道建立后执行:
|
||||||
|
|
||||||
|
1. 本地端口可以连接;
|
||||||
|
2. `GET /api/v1/ping` 返回 200;
|
||||||
|
3. Bearer Token 验证通过;
|
||||||
|
4. `GET /api/v1/status` 返回 Agent 版本。
|
||||||
|
|
||||||
|
只有四项都通过,才把 transport 标记为 connected。
|
||||||
|
|
||||||
|
### 6.5 连接状态
|
||||||
|
|
||||||
|
前端需要显示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
传输方式:SSH 隧道
|
||||||
|
本地端口:127.0.0.1:177xx
|
||||||
|
Agent 地址:127.0.0.1:7700(VPS 回环)
|
||||||
|
隧道状态:已连接 / 重连中 / 失败
|
||||||
|
最后错误
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、Agent 默认监听与防误开
|
||||||
|
|
||||||
|
### 7.1 默认值
|
||||||
|
|
||||||
|
修改:
|
||||||
|
|
||||||
|
```go
|
||||||
|
const defaultListenAddr = "127.0.0.1:7700"
|
||||||
|
```
|
||||||
|
|
||||||
|
以下位置同步:
|
||||||
|
|
||||||
|
- `cmd/fonu-agent/main.go`
|
||||||
|
- `internal/agent.NewServer`
|
||||||
|
- `scripts/agent-install.sh`
|
||||||
|
- `internal/frp/agent_installer.go` 内嵌安装脚本
|
||||||
|
|
||||||
|
### 7.2 远程监听保护
|
||||||
|
|
||||||
|
如果监听地址不是回环:
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.0.0.0:7700
|
||||||
|
:7700
|
||||||
|
[::]:7700
|
||||||
|
VPS公网IP:7700
|
||||||
|
```
|
||||||
|
|
||||||
|
则必须显式设置:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
FONU_AGENT_ALLOW_REMOTE=1
|
||||||
|
```
|
||||||
|
|
||||||
|
否则 Agent 拒绝启动并输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
检测到 Agent 将监听非回环地址,但未显式允许远程访问。
|
||||||
|
默认安全模式只监听 127.0.0.1:7700。
|
||||||
|
如确认需要公网明文访问,请设置 FONU_AGENT_ALLOW_REMOTE=1。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 旧配置迁移
|
||||||
|
|
||||||
|
旧版本 `agent.env` 可能没有 `FONU_AGENT_LISTEN`,此时:
|
||||||
|
|
||||||
|
1. 新 Agent 默认监听回环;
|
||||||
|
2. 安装脚本显式写入:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
FONU_AGENT_LISTEN=127.0.0.1:7700
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 中央 Fonu 在执行「升级 Agent」时同步建立隧道;
|
||||||
|
4. 升级完成后切换 transport;
|
||||||
|
5. 旧公网地址从 `agent_url` 中移除;
|
||||||
|
6. 不停留手工维护窗口。
|
||||||
|
|
||||||
|
### 7.4 状态接口
|
||||||
|
|
||||||
|
Agent `/api/v1/status` 新增:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"listen_addr": "127.0.0.1:7700",
|
||||||
|
"listen_scope": "loopback",
|
||||||
|
"remote_access_enabled": false,
|
||||||
|
"transport_security": "loopback"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、自动安装流程
|
||||||
|
|
||||||
|
### 8.1 新安装
|
||||||
|
|
||||||
|
1. 用户填写 VPS SSH 信息。
|
||||||
|
2. 中央 Fonu 通过 SSH 探测系统与 Nginx。
|
||||||
|
3. 分配 `agent_local_port`。
|
||||||
|
4. 上传 Agent 二进制。
|
||||||
|
5. 安装脚本写入:
|
||||||
|
- Token;
|
||||||
|
- `FONU_AGENT_LISTEN=127.0.0.1:7700`;
|
||||||
|
- systemd 服务。
|
||||||
|
6. 启动 Agent。
|
||||||
|
7. 通过 SSH 本地转发建立隧道。
|
||||||
|
8. 调用:
|
||||||
|
- `GET /api/v1/ping`
|
||||||
|
- `GET /api/v1/status`
|
||||||
|
9. 保存:
|
||||||
|
- token 密文;
|
||||||
|
- transport=`tunnel`;
|
||||||
|
- local port;
|
||||||
|
- AgentVersion。
|
||||||
|
10. UI 显示“安全模式已启用”。
|
||||||
|
|
||||||
|
### 8.2 自动迁移旧安装
|
||||||
|
|
||||||
|
1. 检测现有 `agent_url` 是否为 `http://公网IP:7700`。
|
||||||
|
2. 显示“可升级到安全模式”,但不强制立即迁移。
|
||||||
|
3. 用户点击「升级 Agent」后:
|
||||||
|
- 安装新二进制;
|
||||||
|
- 重写 `agent.env` 为回环监听;
|
||||||
|
- 重启服务;
|
||||||
|
- 建立隧道;
|
||||||
|
- 验证连通;
|
||||||
|
- 切换 central transport。
|
||||||
|
4. 只有隧道验证成功后,才关闭旧公网连接。
|
||||||
|
5. 失败时保留旧 Agent 配置和旧连接,自动回滚。
|
||||||
|
|
||||||
|
### 8.3 应用重启恢复
|
||||||
|
|
||||||
|
1. 读取启用管理且 transport=tunnel 的服务端。
|
||||||
|
2. 逐个调用 `Tunnel.Ensure`。
|
||||||
|
3. 启动失败不阻塞整个 Fonu 启动。
|
||||||
|
4. UI 显示重连状态。
|
||||||
|
5. 后台按指数退避继续重试。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、可选模式:HTTPS + 证书固定
|
||||||
|
|
||||||
|
### 9.1 适用场景
|
||||||
|
|
||||||
|
- 不希望长期维护 SSH 隧道;
|
||||||
|
- 可以接受开放一个 HTTPS 端口;
|
||||||
|
- 没有域名或不想申请公网证书;
|
||||||
|
- 希望 Agent API 可被中央 Fonu直接访问。
|
||||||
|
|
||||||
|
### 9.2 安装后切换流程
|
||||||
|
|
||||||
|
首次安装默认仍使用 SSH 隧道。只有用户在「公网 Agent」页明确切换到 HTTPS 直连时,才执行以下步骤:
|
||||||
|
|
||||||
|
1. Agent 仍监听 `127.0.0.1:7700`。
|
||||||
|
2. 切换流程生成自签名证书:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/etc/fonu-agent/tls/agent.crt
|
||||||
|
/etc/fonu-agent/tls/agent.key
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 自动寻找可用端口:
|
||||||
|
- 优先 7443;
|
||||||
|
- 其次 8443、9443;
|
||||||
|
- 若 443 空闲且未占用可提供选项,但不默认抢占。
|
||||||
|
4. 生成独立 Nginx server block:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 7443 ssl;
|
||||||
|
ssl_certificate /etc/fonu-agent/tls/agent.crt;
|
||||||
|
ssl_certificate_key /etc/fonu-agent/tls/agent.key;
|
||||||
|
|
||||||
|
location /api/v1/ {
|
||||||
|
proxy_pass http://127.0.0.1:7700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 返回证书 SHA-256 指纹。
|
||||||
|
6. 中央 Fonu 保存 pin,并使用自定义校验。
|
||||||
|
|
||||||
|
### 9.3 TLS 固定
|
||||||
|
|
||||||
|
不能使用:
|
||||||
|
|
||||||
|
```go
|
||||||
|
InsecureSkipVerify: true
|
||||||
|
```
|
||||||
|
|
||||||
|
应实现:
|
||||||
|
|
||||||
|
```go
|
||||||
|
VerifyPeerCertificate(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
||||||
|
// 计算证书 DER 的 SHA-256
|
||||||
|
// 与数据库中保存的 pin 做常量时间比较
|
||||||
|
// 同时校验有效期与 SAN(如可校验)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
首次安装必须通过 SSH 获取指纹,避免首次连接被中间人截获。
|
||||||
|
|
||||||
|
### 9.4 防火墙
|
||||||
|
|
||||||
|
> 实施更新(v1.5.0 / Agent 0.14.0):已支持检测 ufw / firewalld / nftables / iptables;对 ufw 与 firewalld 可在用户明确确认后自动放行,nftables / iptables 仅检测和提示,不会自动改规则。
|
||||||
|
|
||||||
|
自动放行仅在用户明确确认后执行,支持的工具:
|
||||||
|
|
||||||
|
- ufw;
|
||||||
|
- firewalld;
|
||||||
|
- nftables / iptables(只提示,不自动改规则)。
|
||||||
|
|
||||||
|
云厂商安全组始终不能自动修改。UI 必须提示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
请在云控制台放行 TCP 7443。主机防火墙需要你确认后由 Fonu 执行,Fonu 不会修改云安全组。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、兼容模式:显式公网 HTTP
|
||||||
|
|
||||||
|
仅用于旧部署迁移或用户明确要求:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
FONU_AGENT_LISTEN=:7700
|
||||||
|
FONU_AGENT_ALLOW_REMOTE=1
|
||||||
|
```
|
||||||
|
|
||||||
|
中央 Fonu UI 必须:
|
||||||
|
|
||||||
|
- 显示“公网明文传输”红色状态;
|
||||||
|
- 不在新安装中默认选择;
|
||||||
|
- 不支持由自动模式静默回退;
|
||||||
|
- 显示迁移到 SSH 隧道或 HTTPS 的按钮;
|
||||||
|
- 在变更时记录审计事件。
|
||||||
|
|
||||||
|
旧 transport 允许读取和继续连接,但所有新增安装默认使用 tunnel。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、数据模型变更
|
||||||
|
|
||||||
|
建议新增迁移:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE frp_servers ADD COLUMN agent_transport TEXT NOT NULL DEFAULT 'http';
|
||||||
|
ALTER TABLE frp_servers ADD COLUMN agent_local_port INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE frp_servers ADD COLUMN agent_tls_pin TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE frp_servers ADD COLUMN agent_listen_addr TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE frp_servers ADD COLUMN agent_transport_state TEXT NOT NULL DEFAULT 'unknown';
|
||||||
|
ALTER TABLE frp_servers ADD COLUMN agent_transport_error TEXT NOT NULL DEFAULT '';
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `agent_transport` | `tunnel` / `https-pin` / `http` |
|
||||||
|
| `agent_local_port` | 隧道本地回环端口 |
|
||||||
|
| `agent_tls_pin` | HTTPS 证书 DER SHA-256 |
|
||||||
|
| `agent_listen_addr` | Agent 实际监听地址缓存 |
|
||||||
|
| `agent_transport_state` | `connected` / `reconnecting` / `failed` / `unknown` |
|
||||||
|
| `agent_transport_error` | 最近一次连接错误 |
|
||||||
|
|
||||||
|
### 11.1 兼容迁移
|
||||||
|
|
||||||
|
旧数据:
|
||||||
|
|
||||||
|
```text
|
||||||
|
agent_transport = http
|
||||||
|
```
|
||||||
|
|
||||||
|
新安装:
|
||||||
|
|
||||||
|
```text
|
||||||
|
agent_transport = tunnel
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、API 与前端变更
|
||||||
|
|
||||||
|
### 12.1 API
|
||||||
|
|
||||||
|
`FrpServer` JSON 增加:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agent_transport": "tunnel",
|
||||||
|
"agent_transport_state": "connected",
|
||||||
|
"agent_local_port": 17701,
|
||||||
|
"agent_listen_addr": "127.0.0.1:7700",
|
||||||
|
"agent_transport_error": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
新增管理端点:
|
||||||
|
|
||||||
|
| 方法 | 路径 | 作用 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| POST | `/api/frpmulti/servers/{id}/agent/transport` | 切换 tunnel / https-pin / http |
|
||||||
|
| POST | `/api/frpmulti/servers/{id}/agent/tunnel/restart` | 重启隧道 |
|
||||||
|
| GET | `/api/frpmulti/servers/{id}/agent/transport` | 读取详细状态 |
|
||||||
|
|
||||||
|
### 12.2 前端
|
||||||
|
|
||||||
|
「公网 Agent」页新增传输模式卡:
|
||||||
|
|
||||||
|
- SSH 隧道,默认;
|
||||||
|
- HTTPS + 证书固定,可选;
|
||||||
|
- 公网 HTTP 明文,兼容。
|
||||||
|
|
||||||
|
显示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
传输方式:SSH 隧道
|
||||||
|
隧道状态:已连接
|
||||||
|
本地入口:127.0.0.1:17701
|
||||||
|
Agent 监听:127.0.0.1:7700
|
||||||
|
安全等级:安全
|
||||||
|
```
|
||||||
|
|
||||||
|
旧模式显示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
公网明文 HTTP
|
||||||
|
风险:Token 会通过网络明文传输
|
||||||
|
建议迁移到 SSH 隧道
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十三、自动回滚
|
||||||
|
|
||||||
|
迁移或切换 transport 失败时:
|
||||||
|
|
||||||
|
1. 保留旧 transport 与旧 Agent 配置;
|
||||||
|
2. 不覆盖旧 Token;
|
||||||
|
3. 不删除旧公网端口配置;
|
||||||
|
4. 如果已经重写 `agent.env`,恢复旧文件;
|
||||||
|
5. 如果 Agent 已重启但新连接失败,重新启动旧服务;
|
||||||
|
6. 向 UI 返回完整错误和回滚结果。
|
||||||
|
|
||||||
|
任何自动迁移都必须满足:
|
||||||
|
|
||||||
|
```text
|
||||||
|
新连接验证成功
|
||||||
|
→ 才提交数据库 transport
|
||||||
|
→ 才停止旧监听
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十四、测试计划
|
||||||
|
|
||||||
|
### 14.1 单元测试
|
||||||
|
|
||||||
|
- 默认监听地址是回环;
|
||||||
|
- 非回环监听缺少 `FONU_AGENT_ALLOW_REMOTE` 时拒绝启动;
|
||||||
|
- 回环监听无需额外变量;
|
||||||
|
- `agent_transport` 序列化与旧数据兼容;
|
||||||
|
- 隧道本地端口分配、冲突与释放;
|
||||||
|
- 隧道重连、退避和关闭;
|
||||||
|
- 证书 pin 正确、证书变化、pin 不匹配时拒绝;
|
||||||
|
- 旧 HTTP transport 继续可用并产生警告。
|
||||||
|
|
||||||
|
### 14.2 集成测试
|
||||||
|
|
||||||
|
- 在测试 SSH 服务上建立本地转发;
|
||||||
|
- Agent 只监听 127.0.0.1;
|
||||||
|
- 应用重启后隧道自动恢复;
|
||||||
|
- SSH 断开后重连;
|
||||||
|
- 多服务端并发隧道;
|
||||||
|
- 迁移前失败回滚;
|
||||||
|
- 迁移成功后旧公网端口关闭。
|
||||||
|
|
||||||
|
### 14.3 真机验证
|
||||||
|
|
||||||
|
至少覆盖:
|
||||||
|
|
||||||
|
- Debian / Ubuntu / CentOS;
|
||||||
|
- fnOS 中的 Fonu;
|
||||||
|
- 宝塔 Nginx;
|
||||||
|
- 已存在 80 / 443 / 7443 占用的 VPS;
|
||||||
|
- 云安全组只开放 SSH 的 VPS;
|
||||||
|
- 云安全组开放 7443 的可选 HTTPS 模式。
|
||||||
|
|
||||||
|
### 14.4 验收条件
|
||||||
|
|
||||||
|
- 新安装后 `ss -tlnp` 只能看到 Agent 监听 `127.0.0.1:7700`;
|
||||||
|
- 公网扫描不到 7700;
|
||||||
|
- 无需新增云安全组规则;
|
||||||
|
- UI 显示隧道已连接;
|
||||||
|
- Fonu 重启后自动恢复;
|
||||||
|
- 「升级 Agent」可以自动迁移旧配置;
|
||||||
|
- 失败时不会丢失旧连接。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十五、实施阶段
|
||||||
|
|
||||||
|
### 阶段 1:默认回环与防误开
|
||||||
|
|
||||||
|
- Agent 默认监听改为 `127.0.0.1:7700`;
|
||||||
|
- 非回环监听要求 `FONU_AGENT_ALLOW_REMOTE=1`;
|
||||||
|
- 安装脚本显式写入监听地址;
|
||||||
|
- UI 显示监听范围。
|
||||||
|
|
||||||
|
该阶段暂不自动切换中央连接,避免破坏旧部署。
|
||||||
|
|
||||||
|
### 阶段 2:SSH 隧道管理器
|
||||||
|
|
||||||
|
- 新增数据库字段;
|
||||||
|
- 新增隧道管理器和 AgentClient transport;
|
||||||
|
- 安装后自动建立隧道;
|
||||||
|
- 应用重启恢复;
|
||||||
|
- UI 显示 transport 状态。
|
||||||
|
|
||||||
|
### 阶段 3:自动迁移旧 Agent
|
||||||
|
|
||||||
|
- 检测旧公网 HTTP;
|
||||||
|
- 「升级 Agent」时执行安全迁移;
|
||||||
|
- 成功切换后关闭旧入口;
|
||||||
|
- 失败自动回滚。
|
||||||
|
|
||||||
|
### 阶段 4:可选 HTTPS + pin
|
||||||
|
|
||||||
|
- 自动生成证书;
|
||||||
|
- Nginx server block;
|
||||||
|
- TLS pin;
|
||||||
|
- 防火墙辅助;
|
||||||
|
- UI 可选切换。
|
||||||
|
|
||||||
|
### 阶段 5:收紧默认
|
||||||
|
|
||||||
|
- 新安装不再接受公网 HTTP;
|
||||||
|
- 兼容模式需要明确的高风险确认;
|
||||||
|
- 文档和通知引导迁移。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十六、决策结论
|
||||||
|
|
||||||
|
以下决策已于 2026-09-28 确定,后续实施按此执行。
|
||||||
|
|
||||||
|
| 编号 | 决策 | 执行设定 | 理由 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | SSH 隧道作为唯一默认模式 | 首次安装不提供模式选择,统一安装为回环监听 + SSH 隧道;HTTPS 直连只作为安装后的高级切换项 | 现有安装流程已经依赖 SSH;默认路径必须最少输入、最少网络要求 |
|
||||||
|
| 2 | 隧道本地端口使用固定范围 | 固定为 `17700-18700`,绑定 `127.0.0.1`;分配结果持久化;启动时先复用持久化端口,再在范围内顺序查找空闲端口;范围耗尽时明确报错,不使用临时端口 | 固定范围便于排查和审计;持久化避免每次重启变化;不回退临时端口避免地址漂移 |
|
||||||
|
| 3 | 隧道跟随主应用生命周期 | 使用应用内 `agent_tunnel` 管理器,在 Fonu 启动后按服务端恢复;应用退出时统一关闭;断线后指数退避重连;首版不新增独立 systemd 服务 | 当前项目是单进程控制面,调度器、FRP、监控均在同一生命周期内,新增守护进程会扩大部署复杂度 |
|
||||||
|
| 4 | 公网 HTTP 保留整个 1.x 兼容期 | 自 `1.4.0` 起不再允许新安装选择 HTTP;存量连接继续可用但显示高危警告并提供迁移;计划在 `2.0.0` 移除 | 需要给旧 Agent 和环境留出迁移窗口,同时从版本边界上明确技术债的截止点 |
|
||||||
|
| 5 | HTTPS 自签名证书使用短期自动轮换 | ECDSA P-256,有效期 365 天;剩余 30 天时自动轮换;轮换期间同时接受旧 pin 和新 pin,双 pin 窗口 7 天;优先通过现有 SSH 通道获取新指纹后再切换 | 避免长期证书失管;双 pin 防止轮换瞬间把所有中央连接锁死 |
|
||||||
|
| 6 | 首版不自动改主机防火墙 | 首先只检测、展示和提示;SSH 隧道模式本身不需要放行新端口;HTTPS 高级模式后续再提供显式的“一键放行主机防火墙”操作,并始终不修改云厂商安全组 | 防火墙和云安全组属于高风险外部状态;首版先把安全默认值落地,避免自动变更导致 SSH 或现有服务失联 |
|
||||||
|
|
||||||
|
### 16.1 决策对实施阶段的影响
|
||||||
|
|
||||||
|
- 阶段 1 只做 Agent 默认回环和防误开。
|
||||||
|
- 阶段 2 实现 `17700-18700` 的持久化端口分配与应用内隧道管理器。
|
||||||
|
- 阶段 3 完成旧 Agent 自动迁移;迁移失败必须回到旧 transport。
|
||||||
|
- 阶段 4 增加 HTTPS + pin 的高级模式、证书轮换和双 pin 窗口。
|
||||||
|
- `1.4.0` 起前端不再允许新建 HTTP transport。
|
||||||
|
- `2.0.0` 删除 `http` transport 和旧 Agent 兼容分支。
|
||||||
|
- 防火墙变更仅在阶段 4 之后按用户明确操作执行,首版不实现自动写规则。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十七、最终建议
|
||||||
|
|
||||||
|
优先实现前三个阶段:
|
||||||
|
|
||||||
|
1. Agent 默认回环;
|
||||||
|
2. SSH 隧道自动建立与恢复;
|
||||||
|
3. 旧 Agent 自动迁移并支持回滚。
|
||||||
|
|
||||||
|
这三步完成后,默认部署不再暴露公网 7700,也不需要用户修改 Composer 或防火墙。HTTPS + pin 与防火墙辅助放在后续阶段,作为需要直连用户的可选增强。这样可以在不扩大用户操作成本的前提下,完成 P0-5 的安全目标。
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# 公网 Agent 页(`AgentView.vue`)设计方案 v2
|
# 公网 Agent 页(`AgentView.vue`)设计方案 v2
|
||||||
|
|
||||||
|
> **时效说明(2026-09-24)**:文中「顶部下拉选服务端」的结构已被 v1.2.6 的**左列表 + 右详情**取代(页面标题也改为「公网服务端」),现状见 `docs/frp-server-list-merge-plan.md`。
|
||||||
|
|
||||||
> 本文按 `breezell-design` 两遍流程产出,核心是**先列清功能、再谈设计**:
|
> 本文按 `breezell-design` 两遍流程产出,核心是**先列清功能、再谈设计**:
|
||||||
> 第二节把本页应有的 **27 项能力**逐条落位(含 5 处当前页面看不到的能力),第四节按功能分区。
|
> 第二节把本页应有的 **27 项能力**逐条落位(含 5 处当前页面看不到的能力),第四节按功能分区。
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# 公网 Agent 页(`AgentView.vue`)设计方案
|
# 公网 Agent 页(`AgentView.vue`)设计方案
|
||||||
|
|
||||||
|
> **时效说明(2026-09-24)**:文中「顶部下拉选服务端」的结构已被 v1.2.6 的**左列表 + 右详情**取代(页面标题也改为「公网服务端」),现状见 `docs/frp-server-list-merge-plan.md`。
|
||||||
|
|
||||||
> 本文按 `breezell-design` 两遍流程产出:**Pass 1a 检测栈 → Pass 1b 确定设计方向与令牌 → Pass 2 实施规格**,
|
> 本文按 `breezell-design` 两遍流程产出:**Pass 1a 检测栈 → Pass 1b 确定设计方向与令牌 → Pass 2 实施规格**,
|
||||||
> 并结合本项目(Fonu)既有页面与设计系统。落地时以本文为准,`docs/agent-view-refactor-plan.md`(结构整改)被本文吸收。
|
> 并结合本项目(Fonu)既有页面与设计系统。落地时以本文为准,`docs/agent-view-refactor-plan.md`(结构整改)被本文吸收。
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
# Fonu 品牌升级 / 改名方案
|
||||||
|
|
||||||
|
> 日期:2026-09-25 对象:Fonu 1.0.2(VERSION 1.2.12)
|
||||||
|
> 方法:把 "Fonu / fonu / FONU_" 在仓库里**实际出现的位置逐个查出来**(下文带 `文件:行` 的都是读代码得到的),再按「改动代价 × 破坏性」分层给方案。
|
||||||
|
> 结论速览:**能做,而且没有"改不动"的地方;但"全面改名"绝不等价于全局替换——有 3 处一动就会伤到存量用户(数据库文件名、环境变量前缀、远端 agent 凭据路径)。推荐策略 B:分层渐进 + 兼容层,一个版本后清理兼容代码。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 〇、一句话回答
|
||||||
|
|
||||||
|
| 问题 | 回答 |
|
||||||
|
| --- | --- |
|
||||||
|
| 能不能换名? | 能。所有品牌落点都是可枚举的文本/路径/常量,没有第三方注册死锁(GitHub 仓库名可改、镜像名可切、CLI 名随二进制)。 |
|
||||||
|
| 能不能"全面"? | 能,但要分 5 层做。**展示层(用户看见的字)改起来是半天的事,存储层与契约层(数据文件名、环境变量、远端 agent 路径)必须带兼容层**,否则老用户升级后会出现「数据像丢了」「容器起来了但库是空的」「VPS 上的 agent 失联」。 |
|
||||||
|
| 改名量级 | 实测 `Fonu|fonu|FONU` 至少 1912 处、120+ 个文件(分页上限,实际更多);其中约 2/3 是 CSS 变量(`--fonu-*`)、Go import 前缀、文档正文这类**机械替换**,真正的决策点只有 5 个(见 §二)。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、品牌落点盘点(按层,带实测证据)
|
||||||
|
|
||||||
|
### 第 1 层:展示层(用户能看见的字与图)
|
||||||
|
|
||||||
|
| 落点 | 位置 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 浏览器标题 | `web/index.html` `<title>Fonu</title>` | 建议与侧栏名统一 |
|
||||||
|
| 侧栏品牌区 | `web/src/layouts/AppLayout.vue:15,17,34` | logo `alt="Fonu"`、`brand__name` 文字、页脚品牌名;`:132` 引 `../assets/brand/logo.png` |
|
||||||
|
| 品牌标语 | `AppLayout.vue:18` 「让 NAS 访问更简单」 | 换名时建议一并重写(新名 + 新一句话定位) |
|
||||||
|
| 视觉资产 | `web/src/assets/brand/logo.png`、`web/public/favicon.png`、`web/assets/image/logo.png`、`web/assets/image/banner.png` | 图片需重做;`web/assets/image/*` 由 Dockerfile `COPY web/assets/image /app/web/assets/image` 进镜像 |
|
||||||
|
| 登录页 | `web/src/views/LoginView.vue` | 品牌露出最集中的页面之一 |
|
||||||
|
| 公共组件 | `web/src/components/{PageHeader,EmptyState,LoadError,StatusBadge,StatCard,FonuCard}.vue` | 前 5 个是文案/样式里的品牌残留,`FonuCard.vue` **文件名本身含品牌名**(含 import 该组件的多处 view) |
|
||||||
|
| 前端常量 | `web/src/constants/app.ts:1` `GITHUB_REPO = 'O96u/Fonu'` | 对外展示的仓库地址,改名必须同步(见 §六) |
|
||||||
|
| 通知消息 | `internal/notify/format.go`、`internal/notify/{service,store,push}.go` | 邮件/推送正文里的品牌前缀,用户直接在手机上看到 |
|
||||||
|
| 公开状态页 | `internal/api/status_page_handler.go`、`status_handler.go`(各 4~8 处) | `/status` 公开页标题、页脚品牌 |
|
||||||
|
| CSS 变量 | `web/src/styles/variables.css:2` 起 `--fonu-brand`、`--fonu-brand-soft`…(全仓 51 处) | **纯机械替换**,但波及所有 `.vue` 样式;建议一次性做且用 `replace_all` |
|
||||||
|
| 本地偏好 key | `web/src/composables/useTheme.ts:4` `themeKey = 'fonu-theme'` | 改名后老用户主题偏好重置(可一行兼容,见 §五.4) |
|
||||||
|
|
||||||
|
### 第 2 层:交付 / 部署层(用户拿到的东西叫什么)
|
||||||
|
|
||||||
|
| 落点 | 位置 | 风险 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 主程序二进制名 | `Dockerfile`:构建产物 `/fonu` → `COPY --from=go-builder /fonu /app/fonu`、`CMD ["/app/fonu"]`;发布物 `fonu-1.1.0-linux-amd64` | 中:换名即换文件名,用户脚本里的路径要跟着改 |
|
||||||
|
| agent 交叉编译名 | `Dockerfile`:`go build -o internal/frp/agentbin/fonu-agent-linux-{amd64,arm64}` | **高**:与 `internal/frp/agentbin/embed.go` 的 `//go:embed fonu-agent-linux-amd64` 强耦合,改一处必须同批改另一处,否则编译期直接失败 |
|
||||||
|
| Docker 编排 | `docker-compose.yml`、`.host.yml`、`.hub.yml`:service `fonu`、`container_name: fonu`、`image: fonu:local`、volume `fonu-data` | **高**:volume 名一变,`docker compose up` 会建**新空 volume**,用户会认为「数据丢了」;必须写清迁移命令或显式 pin `name:` |
|
||||||
|
| 镜像仓库名 | `.github/workflows/docker-release.yml:63,94` `${DOCKERHUB_USERNAME}/fonu:<ver>-<arch>` | Docker Hub **不支持仓库改名**(只能新建),见 §六 |
|
||||||
|
| 前端 embed 路径 | 根 `.gitignore` 的 `cmd/fonu/web/dist/`、`.github/workflows/ci.yml:19,42,43`、`//go:embed all:web/dist` 所在 `cmd/fonu/main.go` | 中:目录改名会牵动 CI、构建脚本、embed 指令、Dockerfile |
|
||||||
|
| agent 安装布局 | `scripts/agent-install.sh`:`PREFIX=/usr/local/bin/fonu-agent`、`CONF_DIR=/etc/fonu-agent`、`UNIT=/etc/systemd/system/fonu-agent.service`、`/var/lib/fonu-agent/certs` | **高**:涉及已在用户 VPS 上运行的服务,升级路径必须兼容(见 §五.5) |
|
||||||
|
| 环境变量 | `internal/config/config.go:24,29,31~38`(`FONU_DATA_DIR`/`FONU_LISTEN`/`FONU_SESSION_SECRET`/`FONU_NGINX_*`/`FONU_FRPC_BIN`/`FONU_FRP_PID`)+ `Dockerfile` 的 `ENV` 块 + `docker-compose*.yml` + `docs/DEPLOYMENT.md`(大量) | **高**:前缀一改,老用户的 `.env` / compose / `-e` 全部静默失效 → 容器用默认值起来(例如数据目录跑回 `/data` 之外、会话密钥回到 `change-me-in-production`) |
|
||||||
|
|
||||||
|
### 第 3 层:代码标识层(改了不影响运行语义,但波及面最大)
|
||||||
|
|
||||||
|
| 落点 | 位置 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Go module path | `go.mod:1` `module github.com/fonu/fonu` | **每个 .go 文件的 import 块**(`internal/api` 20 个文件、`internal/nginx`、`internal/frp`…);同时 `Dockerfile` 的 `-ldflags "-X github.com/fonu/fonu/internal/version.Version=..."` 必须同步改,否则版本号注入静默失效(二进制显示 `dev`) |
|
||||||
|
| 前端包名 | `web/package.json:2` `"name": "fonu-web"` | 机械替换 |
|
||||||
|
| 内嵌 agent 资产名 | `internal/frp/agentbin/embed.go` | 见上,与 Dockerfile 成对出现 |
|
||||||
|
| 文档 | `README.md`、`CHANGELOG.md`、`docs/*.md`(20+ 篇,其中 `DEPLOYMENT.md` 56 处、`frps服务端管理方案.md` 52 处) | 量大但不危险;建议随代码同批改,避免文档与实现不一致 |
|
||||||
|
|
||||||
|
### 第 4 层:运行期契约层(跨进程/跨版本,最容易漏)
|
||||||
|
|
||||||
|
| 落点 | 位置 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 会话 Cookie 名 | `internal/api/middleware.go:18` `r.Cookie("fonu_session")` | 改名 = **所有已登录用户掉线**(可接受,但要在发布说明写明) |
|
||||||
|
| agent 凭据文件 | `internal/frp/agent_installer.go:35,36,168` + `:145`:服务端通过 SSH 在 VPS 上 `sed` 读 `/etc/fonu-agent/agent.env` 的 `FONU_AGENT_TOKEN` | **服务端与 agent 必须同批发布**:服务端换了路径/变量名,而用户 VPS 上还是旧 agent(或反之),「Agent 探测」会直接失败 |
|
||||||
|
| agent 自述标识 | `internal/agent/server.go:213,221` `{"ok":true,"service":"fonu-agent"}` | 属于对外可见的 API 响应体;若有任何前端/脚本按 `service` 字段判断,改名前要一起查 |
|
||||||
|
| agent 落盘目录 | `internal/agent/server.go:133,145`:`/var/lib/fonu-agent/certs`、`/var/lib/fonu-agent` | 与安装脚本一致 |
|
||||||
|
| 远端 systemd unit 描述 | `internal/agent/server.go:344` `Description=frps service (managed by fonu-agent)` | 远端残留可见文本 |
|
||||||
|
| 远端 nginx 探针/标记 | `internal/agent/server.go:674-675`:写 `fonu-probe.conf`、文件头 `# Managed by fonu-agent. Do not edit.`;`:692` 已有「清理历史残留 `fonu-*.conf`」逻辑 | **改名最容易踩的隐性坑**:老 VPS 上留着旧品牌标记的片段,新版本若只认新标记,会把它们判成「未知文件」→ 漂移检测误报或残留永不清理 |
|
||||||
|
| 远端 frp 安装与漂移 | `internal/frp/ssh_installer.go`、`internal/frp/route_drift.go`(各 4 / 2 处) | 同上:判定「这是我管理的文件」的依据里含品牌标记 |
|
||||||
|
|
||||||
|
### 第 5 层:数据层(最危险,改错就是"丢数据")
|
||||||
|
|
||||||
|
| 落点 | 位置 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 数据库文件名 | `internal/config/config.go:49-51` `DBPath() = <DataDir>/fonu.db` | **一改,程序找不到库 → 建一个空库 → 用户以为数据没了**。必须带「探测旧文件 + 原子重命名」 |
|
||||||
|
| 数据目录默认值 | `config.go:126` `defaultDataDir()`:`/data` 或 `<cwd>/.data` | ✅ **好消息:不含品牌名,无需迁移** |
|
||||||
|
| 各子目录 | `config.go` 的 `NginxDir/LogsDir/CertsDir/FrpDir`…(`nginx`/`logs`/`certs`/`frp`/`ssh_known_hosts`) | ✅ 也都不含品牌名,只有 DB 文件名带品牌 |
|
||||||
|
| 迁移脚本 | `migrations/023_frp_server_agent.sql`(1 处品牌) | SQL 迁移一般不需要改(库文件名才是关键路径) |
|
||||||
|
|
||||||
|
> 盘点的意义:**品牌名"长"在 5 个不同性质的地方**——字、路径、常量、协议、文件名。前两类可以随便改,后三类改之前要先想好兼容。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、三个必须先解决的暗礁
|
||||||
|
|
||||||
|
1. **数据库文件名 `fonu.db`(`internal/config/config.go:50`)**
|
||||||
|
裸改 = 静默新建空库。做法:启动时若「新名库不存在」且「`fonu.db` 存在」→ 同目录 `os.Rename` 到新名(原子、可回滚),失败则**继续用旧路径并在日志/界面提示**,绝不新建第二份库。Windows 上文件被占用时 rename 会失败,所以「失败即回退到旧路径」这条兜底不是可选项。
|
||||||
|
|
||||||
|
2. **环境变量前缀 `FONU_*`(`internal/config/config.go:24,29,31-38`)**
|
||||||
|
裸改 = 老部署的 `FONU_SESSION_SECRET` 失效 → 会话密钥回到公开默认值 `change-me-in-production`(**这是安全问题,不只是体验问题**:拿到默认密钥可伪造管理员会话、解密库里凭据,见 `docs/DEPLOYMENT.md` 对该密钥的说明)。
|
||||||
|
做法:`envOr` 升级为「新前缀 → 旧前缀 → 默认值」三档读取,命中旧前缀时启动日志给一条明确的迁移提示(含新旧变量名对照),至少保留一个 stable 版本后再删旧前缀。**尤其 `FONU_SESSION_SECRET` 与 `FONU_DATA_DIR` 必须双读。**
|
||||||
|
|
||||||
|
3. **远端 agent 的凭据路径与品牌标记(`agent_installer.go:35,36,145,168`;`agent/server.go:674,692`)**
|
||||||
|
服务端是**通过 SSH 去读用户 VPS 上的文件**来判断 agent 状态的。服务端与 agent 不同批发布时:
|
||||||
|
- 探测读旧路径、agent 写新路径 → 「Agent 探测」永远显示未配置;
|
||||||
|
- nginx 探针/片段标记改名 → 旧文件既不被清理也不被识别,漂移检测出现假阳性。
|
||||||
|
做法:探测端**两个路径都试**(新路径优先,旧路径兜底);`agent` 二进制**同时接受新旧 env 变量名**;生成器把新旧品牌标记**都视为"自己生成的文件"**(清理/漂移判定同时匹配),并在 agent 版本号里体现本次变更(本仓既有约定:agent 行为变更需递增 `AgentVersion` 并重编内嵌双架构二进制)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、三种策略与推荐
|
||||||
|
|
||||||
|
| | 策略 A:只换皮 | **策略 B:分层渐进 + 兼容层(推荐)** | 策略 C:一次性硬切 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 范围 | 只做第 1 层(文案/图/CSS 变量/通知) | 第 1→2→4→5→3 层按阶段推进,关键处留兼容 | 5 层全改,不留兼容 |
|
||||||
|
| 代码净度 | 低(`fonu.db`、`fonu-agent`、`FONU_*` 仍是旧名) | 中:过渡期有双读分支,1~2 个版本后清理 | 高 |
|
||||||
|
| 存量用户 | 无感 | 无感(或仅掉一次登录态) | **有损**:需手工迁移数据目录/重建 volume/重装 agent |
|
||||||
|
| 适合 | 只想换个门面、用户量很小 | 已有真实用户、有 Docker 与 VPS 两种部署形态(本仓现状) | 尚未发布 / 可强制清退老版本 |
|
||||||
|
| 代价 | 半天量级,零风险 | 机械替换 + 5 个兼容点 + 一轮双形态回归 | 改动最少但迁移成本转嫁给用户 |
|
||||||
|
|
||||||
|
**推荐 B**,理由:本仓已有 Docker(`docker-compose*.yml`)、host 直装、VPS agent 三种落地面,且 `docs/DEPLOYMENT.md` 里明确把 `FONU_SESSION_SECRET` 当作安全关键项——这些决定了「裸改」的代价远高于「留兼容层」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、推荐执行路线(五阶段)
|
||||||
|
|
||||||
|
> 每阶段的验证沿用本仓既有约定:`go build ./... && go vet ./... && go test ./...` + `cd web && npm run typecheck && npm run build`;涉及 agent 的改动递增 `AgentVersion` 并重编内嵌二进制;用户明确要求提交时,才同批更新 `CHANGELOG.md` 与 `VERSION`。
|
||||||
|
|
||||||
|
### 阶段 0:定名与冻结(不写代码)
|
||||||
|
|
||||||
|
- 定下新品牌名与英文小写标识(CLI/包名/目录用)、显示名(首字母大写形态)。
|
||||||
|
- 检查占用:域名、GitHub org/repo、Docker Hub 命名空间、唯一包名(如 npm/PyPI 若以后要用)、以及**中文语境下的商标检索**。
|
||||||
|
- 决定 GitHub 与 Docker Hub 的走向(§六),因为 `web/src/constants/app.ts:1` 与版本检查 `web/src/utils/version.ts:37` 直接依赖它。
|
||||||
|
- 产出:`旧名 → 新名` 对照表(含:显示名、小写标识、二进制名、镜像名、容器名、env 前缀、cookie 名、DB 文件名、agent 目录名)。
|
||||||
|
|
||||||
|
### 阶段 1:展示层(零风险,先拿到"看着像新品牌"的反馈)
|
||||||
|
|
||||||
|
- 改:`web/index.html` 标题、`AppLayout.vue:15,17,18,34`、`LoginView.vue`、公共组件(含 `FonuCard.vue` 重命名)、`web/src/constants/app.ts:1`、`internal/notify/format.go`、`internal/api/status_page_handler.go`/`status_handler.go` 的品牌文案。
|
||||||
|
- 换图:`web/src/assets/brand/logo.png`、`web/public/favicon.png`、`web/assets/image/{logo,banner}.png`。
|
||||||
|
- CSS 变量 `--fonu-*` → `--<new>-*`(`variables.css` 定义 + 全部 `.vue` 引用,用 `replace_all` 一次完成)。
|
||||||
|
- 验证:`npm run build` 通过 + 前端手点一遍侧栏/登录页/设置页/公开状态页。
|
||||||
|
- 回滚:纯文案与样式,`git revert` 即可。
|
||||||
|
|
||||||
|
### 阶段 2:交付层(二进制、镜像、compose、发布物)
|
||||||
|
|
||||||
|
- 二进制 `fonu` → 新名;`docker-compose*.yml` 的 service/容器名/image/volume;`.github/workflows/docker-release.yml:63,94` 的镜像路径。
|
||||||
|
- **volume 必须处理**:在 compose 里显式声明 `volumes: { <new>-data: { name: <new>-data } }` 并在 `docs/DEPLOYMENT.md` 写「老用户迁移三行命令」(旧 volume 挂到新名,或 `docker volume create` + 复制)。
|
||||||
|
- 老二进制名建议保留一个版本(同镜像内留符号链接,或发布说明里给 rename 命令),避免用户脚本直接报错。
|
||||||
|
- 验证:本地 `docker compose up`,用**旧 volume** 起新容器,确认库被正确打开(这是本阶段的验收核心,不是"能起")。
|
||||||
|
- 回滚:compose 文件与镜像名回退即可;volume 命名要事先确定,避免中途再改一次。
|
||||||
|
|
||||||
|
### 阶段 3:存储层与契约层(暗礁集中区,必须带兼容)
|
||||||
|
|
||||||
|
- `config.go`:`DBPath()` 换新名 + **旧库自动接管**(§二.1);`envOr` 支持旧前缀兜底 + 启动迁移提示(§二.2)。
|
||||||
|
- `middleware.go:18` cookie 名:可选做「新名 cookie 优先、旧名兜底」读一次,或直接换名并在发布说明写「需重新登录」。
|
||||||
|
- `useTheme.ts:4`:读旧 key 回退 + 写回新 key(三行代码,避免用户主题偏好被重置)。
|
||||||
|
- agent 侧:`agent-install.sh`(`/usr/local/bin`、`/etc`、`/var/lib`、systemd unit 名)、`internal/agent/server.go:133,145,213,221,344,674,692`、`internal/frp/agent_installer.go:35,36,145,168`、`ssh_installer.go`、`route_drift.go`。
|
||||||
|
- 安装脚本要能**识别并接管已安装的旧 agent**(停旧 unit、装新 unit、保留/迁移 `/etc/fonu-agent` 的 env 文件),而不是留下两套服务互相抢。
|
||||||
|
- 探测与漂移判定:新旧路径/标记**都算自己人**,至少一个版本。
|
||||||
|
- 验证(关键):造一份**旧版数据目录**,用新二进制启动 → 确认打开的是旧库且数据完整;再用**旧版 agent** 对**新版服务端**跑一次「Agent 探测 / 安装 / 日志」;`go test ./...` 通过(新增的 DB 接管与 env 双读建议各补一个纯函数单测,参照本仓 `maskFRPSSecrets` 那类测试写法)。
|
||||||
|
- 回滚:DB rename 是单向的(必要时提供反向重命名说明);env 双读让回滚无感。
|
||||||
|
|
||||||
|
### 阶段 4:代码标识层(机械但波及全仓)
|
||||||
|
|
||||||
|
- `go.mod:1` module path → 新路径;全仓 import 替换(`gofmt`/`goimports` 后 `go build ./...` 兜底)。
|
||||||
|
- **同步点**:`Dockerfile` 的 `-ldflags "-X github.com/fonu/fonu/internal/version.Version=..."`(漏改则版本号变 `dev`,且不报错);`internal/frp/agentbin/embed.go` 与 Dockerfile 的 agent 输出名;`web/package.json:2`;`cmd/fonu/` → `cmd/<new>/`(连带 `.gitignore`、`ci.yml:19,42,43`、embed 指令)。
|
||||||
|
- 建议与阶段 2/3 分开发布(单独一次提交),便于「编译失败」时快速定位。
|
||||||
|
- 验证:`go build ./... && go vet ./... && go test ./...`;构建后确认 `GET /api/version`(或设置页)显示真实版本号而非 `dev`。
|
||||||
|
|
||||||
|
### 阶段 5:清理(1~2 个版本后)
|
||||||
|
|
||||||
|
- 删除旧 env 前缀兜底、旧 DB 文件名探测、旧 cookie/localStorage key、旧 agent 路径与标记、旧预先/镜像别名。
|
||||||
|
- 用 §九 的自查清单跑一次全仓 grep,确认只剩新名。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、兼容层设计要点(可照做)
|
||||||
|
|
||||||
|
1. **环境变量**:`envOr` → `envOrLayered(newKey, oldKey, default)`;命中旧 key 时启动日志输出一次「检测到旧变量 `FONU_DATA_DIR`,已生效;请迁移到 `<NEW>_DATA_DIR`」。优先级:新 > 旧 > 默认,**不要**用「旧覆盖新」。
|
||||||
|
2. **数据库接管**:判断顺序 = 新库存在? → 用新库;否则旧库存在? → rename 后使用(失败则直接用旧路径 + 明确提示);否则建新库。全程不改动用户其它文件。
|
||||||
|
3. **Docker volume**:`name:` 显式固定新名;给出一条从旧 volume 复制的命令;**不要**靠 compose 自动改名。
|
||||||
|
4. **前端本地状态**:`localStorage.getItem('fonu-theme') ?? localStorage.getItem('<new>-theme')`,读到旧值时写回新 key。
|
||||||
|
5. **agent 平滑升级**:
|
||||||
|
- agent 二进制双读新旧 env 名(`FONU_AGENT_TOKEN` 与 `<NEW>_AGENT_TOKEN`);
|
||||||
|
- 安装脚本 `systemctl disable --now fonu-agent` → 装新 unit → 迁移 `/etc/fonu-agent/agent.env` → 启动新 unit;
|
||||||
|
- 服务端探测命令**两个路径并联**(`test -s A || test -s B`),避免版本错配期间误报;
|
||||||
|
- 远端文件识别同时匹配新旧标记;`server.go:692` 的「历史残留清理」逻辑扩成「新旧品牌前缀都清」。
|
||||||
|
6. **凭据与密钥**:品牌改名**不应触碰**加密数据本身(`secret.Box` 加密的 DDNS/FRP/SMTP 凭据与品牌无关),所以只要 `FONU_SESSION_SECRET` 双读做好,老用户的凭据继续可用——这也是「不要顺手改密钥派生」的理由。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、平台与外部身份
|
||||||
|
|
||||||
|
| 项 | 现状 | 建议做法 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GitHub 仓库 | `web/src/constants/app.ts:1` `O96u/Fonu`;`web/src/utils/version.ts:37` 用 `api.github.com/repos/${GITHUB_REPO}/releases/latest` 做版本检查 | GitHub **支持仓库改名并保留旧地址重定向(含 API 与 release 资产下载)**,但重定向会被「别人用旧名新建仓库」永久打断;对自用/小众项目风险低,仍建议:改名后**保留旧仓库名不被占用**(或在旧名新建一个空仓库做说明页——注意这恰好会打断重定向,二者只能选一)。改名后必须回归一次版本检查(`version.ts` 是前端直连 GitHub API)。 |
|
||||||
|
| Docker Hub | `.github/workflows/docker-release.yml:63,94` `${DOCKERHUB_USERNAME}/fonu` | Docker Hub **不支持仓库改名**,只能新建仓库并重推标签;老用户 `docker pull` 旧名会继续拿到旧标签(不会自动指向新镜像,也不会报错)→ 发布说明里给出新镜像地址 + 迁移命令。 |
|
||||||
|
| 域名 / 文档站 | 仓库内无硬编码站点域名 | 若已注册 `fonu.*` 域名,改名后建议保留旧域名 301 到新域名(同 GitHub 重定向的思路)。 |
|
||||||
|
| 文档 | 20+ 篇 md(`DEPLOYMENT.md` 56 处、`frps服务端管理方案.md` 52 处…) | 与代码同批改;`docs/brand-rename-plan.md`(本文)建议保留,作为改名决策记录。 |
|
||||||
|
| 发布物命名 | 根目录 `fonu-1.1.0-linux-amd64`、`fonu-1.1.0-src.zip`、`fonu-image-build4.log` 等 | 历史产物不必改名;新产物按新名。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、新名候选与自查
|
||||||
|
|
||||||
|
**命名约束(硬性)**:
|
||||||
|
- 小写标识:仅 `[a-z0-9-]`,2~8 字符,无连字符歧义(`<new>-agent`、`<new>.db`、`--<new>-brand`、`<NEW>_DATA_DIR` 都要好看);
|
||||||
|
- 不与现有工具重名(`frp`、`nginx`、`caddy`、`tunnel` 等已占用词避免单独使用);
|
||||||
|
- 域名 / GitHub org / Docker Hub / npm 包名四处可注册;
|
||||||
|
- 中文可读(本项目用户群中文语境,"念出来不别扭"很重要);
|
||||||
|
- 商标自查(至少本国 + 主要市场)。
|
||||||
|
|
||||||
|
**候选清单**(`标识` 一列就是命令行、包名、`<标识>-agent`、`<标识>.db`、`<IDENT>_DATA_DIR` 要用的形态;每一项在落地前都要走 §七.3 的四处自查,下表「备注」只标注**已知**的同名存在,不代表未被占用)
|
||||||
|
|
||||||
|
#### A. 通路 / 门 / 桥(英文语义直白,技术用户第一眼就懂)
|
||||||
|
|
||||||
|
| 显示名 | 标识 | 中文名建议 | 寓意与适配 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Portway | `portway` | 通途 | port(端口)+ way(通路)——产品做的就是"把端口接出去",语义与技术完全对齐;`portway-agent`、`portway.db` 读感都好 |
|
||||||
|
| Viaduct | `viaduct` | 虹桥 | 高架桥:在两个互不相通的网络之间架一条通路;画面感强,适合做几何型 logo |
|
||||||
|
| Causeway | `causeway` | 长堤 | 跨水而过的路——跨过 NAT/"内网之海";8 字符是上限,仍可接受 |
|
||||||
|
| Underpass | `underpass` | 潜通 | 字面"从下面穿过去",暗合 NAT 打洞的本意;比 Underpass 更贴的同类还有 Overpass,但后者已被 OSM 的查询 API 广泛占用 |
|
||||||
|
| Thruway | `thruway` | 直通 | thru 带"穿透"的俚语味,拼写短促;注意美式拼写,正式英文里少见 |
|
||||||
|
| Ponto | `ponto` | 蓬托 | 葡萄牙语/世界语"桥",5 字符、短促好念;同名存在较多(银行、设计工具),必须自查 |
|
||||||
|
|
||||||
|
#### B. 渡 / 舟 / 引渡(中文意象最贴产品,拼音做标识)
|
||||||
|
|
||||||
|
| 显示名 | 标识 | 中文名 | 寓意与适配 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Dukou | `dukou` | 渡口 | **语义完全命中**:把内网的船渡到公网对岸;中文用户一看就懂、一说就记住;`dukou` 5 字符,命令行友好 |
|
||||||
|
| Zhandao | `zhandao` | 栈道 | 在无路可走的险处架出一条路——正是 NAT 之后的处境;名字冷门,检索不打架 |
|
||||||
|
| Yongdao | `yongdao` | 甬道 | 连接两端的地下通道;比"隧道"更雅,避开与 tunnel 这类通用词的碰撞 |
|
||||||
|
| Changdu | `changdu` | 长渡 | "长期摆渡"的意味,适合强调常驻服务;缺点是"长渡"作为词不常见 |
|
||||||
|
| Yinqiao | `yinqiao` | 引桥 | 从匝道引上主桥的那一段——很像"从内网引到出口";亲切、口语化 |
|
||||||
|
|
||||||
|
> 说明:中文意象类在中文用户群里记忆点最强,代价是国际展示偏窄;如果以后要面向海外,建议「英文标识 + 中文名」成对使用(例如 `dukou` / 渡口),而不是只取其一。
|
||||||
|
|
||||||
|
#### C. 延续 Fonu 语音(保留 Fo 音,让改名读起来像"升级"而不是"换了个软件")
|
||||||
|
|
||||||
|
| 显示名 | 标识 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Fonara | `fonara` | 保留 `Fo` 音 + 自造后缀,老用户不会觉得陌生;自造词在域名/商标上的可得性通常好于现成词 |
|
||||||
|
| Fonix | `fonix` | 与 Phoenix(凤凰)谐音,正好呼应"品牌涅槃升级";注意存在 Fönix 等同名公司,需自查 |
|
||||||
|
| Fonia | `fonia` | 希腊语系词根常与"声音/传输"相关(telefonia),5 字符干净;要确认不与电信类品牌混淆 |
|
||||||
|
| Fonli | `fonli` | 最短的延续方案,`fonli-agent` / `FONLI_DATA_DIR` 都短;缺点是语义联想弱,需要靠视觉与标语补 |
|
||||||
|
|
||||||
|
#### D. 自造短词(域名与商标可得性最好,语义靠后续塑造)
|
||||||
|
|
||||||
|
| 显示名 | 标识 | 中文名建议 | 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Nuvio | `nuvio` | 努维 | 发音圆润、无强语义包袱,适合做纯品牌名 |
|
||||||
|
| Tando | `tando` | 坦途 | 与"坦途"天然谐音——自造词的语义可由中文名反向赋义,这个配对意外地贴 |
|
||||||
|
| Kaimu | `kaimu` | 开目 | 意为"把门打开",发音柔和 |
|
||||||
|
| Runa | `runa` | 鲁纳 | 短、好拼、国际化;注意与 Runa 相关的既有品牌 |
|
||||||
|
| Sairo | `sairo` | 赛罗 | 读音有速度感,适合强调链路性能 |
|
||||||
|
| Airlock | `airlock` | 气闸 | 不算自造词,但画面感极强:内网是舱内、公网是舱外,穿透就是"过气闸";独特、好画 logo,且不与 frp/nginx/tunnel 语义重叠 |
|
||||||
|
|
||||||
|
#### D 组补充:再给 10 个自造短词
|
||||||
|
|
||||||
|
**D-1 音节谐音型** —— 读音贴合一个中文词,中文用户不需要解释就记住,中文名与标识互相印证:
|
||||||
|
|
||||||
|
| 显示名 | 标识 | 中文名 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Kaitu | `kaitu` | 开通 | 读音近似「开通」,含义直接落在"把内网服务开通到公网";5 字符,`kaitu-agent`、`KAITU_DATA_DIR` 都短 |
|
||||||
|
| Kailu | `kailu` | 开路 | 与「开路」完全同音——在 NAT 后面开出一条路,痛点隐喻最强;代价是内蒙古开鲁县同名,检索会混入地名 |
|
||||||
|
| Toulu | `toulu` | 透路 | 「透」(穿透)+「路」(通路),两个核心动作各取一字;`toulu` 生僻,检索最干净 |
|
||||||
|
| Chuantu | `chuantu` | 穿途 | 「穿」+「途」,气质偏文学;8 字符正好卡在硬上限,`chuantu-agent` 偏长,适合只在品牌层使用、标识再截短 |
|
||||||
|
|
||||||
|
**D-2 纯自造型** —— 无既有语义包袱,靠 logo 与标语赋义,域名与商标的可得性通常更好:
|
||||||
|
|
||||||
|
| 显示名 | 标识 | 中文名建议 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Kuvo | `kuvo` | 库沃 | 4 字符、K/V 音干脆,读起来就像产品名;注意先锋 DJ 有同名社区平台 KUVO,必须自查 |
|
||||||
|
| Kelo | `kelo` | 松洛 | 芬兰语 kelo 指北极圈的站桩枯松(在恶劣环境里站住的意思),冷僻、科技圈少见 |
|
||||||
|
| Veko | `veko` | 维克 | 纯自造,音形短促,适合做"快"的联想 |
|
||||||
|
| Yomo | `yomo` | 悠陌 | 发音柔和,中文名可往"悠然可达"方向塑造 |
|
||||||
|
| Tovo | `tovo` | 途沃 | 首音节与「途」谐形,自带一点通路感,便于往中文语义靠 |
|
||||||
|
| Kivo | `kivo` | 启沃 | 首音节与「启」(开启)谐形,在图标上可以做"开门"的暗示 |
|
||||||
|
|
||||||
|
> 两条路线的取舍:**D-1 在中文本地传播最省力**(不用解释就懂),**D-2 在域名与商标上更干净**(生僻意味着少撞)。用户群以中文 NAS 玩家为主时 D-1 沟通成本最低;若之后要同时面向海外,D-2 + 一个中文名是更稳的组合。
|
||||||
|
|
||||||
|
#### E. 「本地 / 自家」语义(名字自带"服务跑在本地"的意思)
|
||||||
|
|
||||||
|
**E-1 拉丁语与概念型**("就地、内部、家宅"的直译,气质克制,适合技术向调性)
|
||||||
|
|
||||||
|
| 显示名 | 标识 | 中文名建议 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Insitu | `insitu` | 就地 | 拉丁语 in situ = "在原位、就地"——服务不往外搬,就在它原本那台机器上运行。6 字符,冷僻,检索最干净 |
|
||||||
|
| Intra | `intra` | 内域 | 拉丁语"内部",直接指向内网/局域网;注意 Google Jigsaw 有同名的反审查 DNS 应用,需自查 |
|
||||||
|
| Domus | `domus` | 宅邸 | 拉丁语"家宅",数据与服务都收在自己的房子里;Domus 是欧美建筑媒体的常用名 |
|
||||||
|
| Localis | `localis` | 本地 | local 的拉丁语源,语义最直白;缺点是偏长(7 字符),且与英国一家同名智库撞车 |
|
||||||
|
|
||||||
|
**E-2 跨语言的"家"**(一个字就说清"自己的地方",且大多冷僻)
|
||||||
|
|
||||||
|
| 显示名 | 标识 | 中文名建议 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Kodu | `kodu` | 科杜 | 爱沙尼亚语"家";4 字符、极冷僻,念起来干净利落 |
|
||||||
|
| Sumika | `sumika` | 住屋 | 日语「住処」(すみか)= 栖息之所;6 字符,画面温和,科技圈少见 |
|
||||||
|
| Jitaku | `jitaku` | 自宅 | 日语「自宅」,含义精确到"自己的房子",与 self-hosted 一一对应 |
|
||||||
|
| Uchi | `uchi` | 屋内 | 日语「内 / うち」同时是"内部"和"家";缺点是与美国连锁日料品牌 Uchi 同名 |
|
||||||
|
| Hearth | `hearth` | 炉边 | 英语"炉边",是"家"最温暖的意象;缺点是撞《炉石传说》的简称 Hearth |
|
||||||
|
|
||||||
|
**E-3 中文拼音直觉型**(中文用户零解释成本,代价是通用词难注册)
|
||||||
|
|
||||||
|
| 显示名 | 标识 | 中文名 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Benji | `benji` | 本机 | 拼音「本机」完全对应,语义最准;缺点是与英文人名 Benji 撞车,检索会被淹没 |
|
||||||
|
| Bendi | `bendi` | 本地 | 最直白的一个;正因为通用,作为商品名与商标的注册难度最大 |
|
||||||
|
| Jiudi | `jiudi` | 就地 | 拼音「就地」,是 E-1 里 `insitu` 的中文同义形态,两者可以互为备选 |
|
||||||
|
| Wojia | `wojia` | 我家 | 亲切、有归属感,一句"服务就在我家跑着"把定位说完;注意与房产类品牌谐音 |
|
||||||
|
| Yuannei | `yuannei` | 院内 | "自家院子里那台机器"——NAS 用户共情很强;7 字符偏长 |
|
||||||
|
| Anwo | `anwo` | 安窝 | "给服务安个窝",读音短、意象活泼,适合偏年轻的产品调性 |
|
||||||
|
|
||||||
|
> 两点提醒:
|
||||||
|
> 1. **`homelab` / `self-hosted` / `on-prem` 这几个词恰好描述你的产品,但它们是行业通用术语,不能当商品名**——建议放进标语(例如「为 homelab 而生」),品牌名另取。
|
||||||
|
> 2. "本地"语义与定位最贴,也最容易撞现成词:`bendi`、`benji`、`localis` 这类直白形态被占的概率,明显高于 `insitu`、`kodu`、`sumika` 这类冷僻形态。走这条语义线时,优先在冷僻形态里挑。
|
||||||
|
|
||||||
|
**这条语义线上的首选三个**
|
||||||
|
|
||||||
|
1. **`insitu` / 就地** —— 语义精确(就地运行、不外迁)、拉丁出处让名字有质感、且检索干净,是"本地服务"这层意思里最不像拼凑的一个。
|
||||||
|
2. **`jitaku` / 自宅** —— self-hosted 的日文对应词,含义零歧义,短而冷门,写进 `<标识>-agent`、`<标识>.db` 也好看。
|
||||||
|
3. **`kodu` / 家** —— 4 字符、跨语言都能念,留白最多,便于日后把它讲成"你自己的服务空间"。
|
||||||
|
|
||||||
|
#### F. 「NAS 存储 + 穿透」双关(一个名字同时指向"存着"和"能到")
|
||||||
|
|
||||||
|
**F-1 英文双关型**(同一个词里同时有"盘/存储"和"通路")
|
||||||
|
|
||||||
|
| 显示名 | 标识 | 中文名建议 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Driveway | `driveway` | 盘路 | drive(盘/硬盘)+ way(通路)——字面是"车库前的车道",放在存储语境里刚好是"盘往外走的路",双关最巧;缺点是常见英文词,域名与商标被占概率高,必须先查 |
|
||||||
|
| Baylink | `baylink` | 盘链 | bay(NAS 的"盘位")+ link(连接);7 字符、自造度高,检索干净 |
|
||||||
|
| Bayway | `bayway` | 盘途 | bay + way,5 字符,短而奇;英文里有同名小地名,软件领域少见 |
|
||||||
|
| Portage | `portage` | 搬运 | 指在两条不通的水道之间把船与货搬过去,与"把 NAS 里的东西搬到公网"完全同构;**但 Gentoo 的包管理器就叫 Portage,技术圈认知已被占满,不建议** |
|
||||||
|
|
||||||
|
**F-2 存储器物型**("存"义明确,通路那层交给标语)
|
||||||
|
|
||||||
|
| 显示名 | 标识 | 中文名建议 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Tansu | `tansu` | 箪笥 | 日语「箪笥」=分层抽屉的日式储物柜;冷僻、有质感,画面也很适合做图标 |
|
||||||
|
| Coffer | `coffer` | 藏柜 | 英语"保险箱",5 字符,画面是"锁着的箱子";冷僻、少撞 |
|
||||||
|
| Attic | `attic` | 阁楼 | "东西放阁楼,要用时上去取"——正是家庭 NAS 的真实使用方式,亲切易懂 |
|
||||||
|
| Larder | `larder` | 储藏室 | 家里存食物的小间,冷僻且带生活气 |
|
||||||
|
| Granary | `granary` | 粮仓 | 囤粮之所,画面感最强;7 字符偏长 |
|
||||||
|
| Hako | `hako` | 箱 | 日语「箱」,4 字符、极简,适合走冷淡克制的产品调性 |
|
||||||
|
|
||||||
|
**F-3 中文拼音双关型**(仓/库 + 门/路/口)
|
||||||
|
|
||||||
|
| 显示名 | 标识 | 中文名 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Canglu | `canglu` | 仓路 | cāng lù 完全对应:「仓」是 NAS 里存的东西、「路」是穿透出去的通路——一个名字两层意思,5 字符,`canglu-agent`、`canglu.db` 都顺 |
|
||||||
|
| Cangmen | `cangmen` | 仓门 | 「把自家仓门打开」,直指"在外面访问家里的东西"这个诉求;6 字符 |
|
||||||
|
| Chuku | `chuku` | 出库 | 「出库」=把仓库里的东西送出去,动作感最强,中文用户零解释成本;4 字符 |
|
||||||
|
| Kumen | `kumen` | 库门 | 与 cangmen 同义,更短、更有古意 |
|
||||||
|
| Cangdao | `cangdao` | 仓道 | 「仓中之道」,把存储与通路并列写出来;6 字符 |
|
||||||
|
| Kufang | `kufang` | 库房 | 最直白好懂;正因为通用,商标注册难度最大 |
|
||||||
|
|
||||||
|
**F-4 直白组合型**(NAS + 词:`nasway` / `nasport` / `nasbay`)
|
||||||
|
|
||||||
|
含义最准确、最不用解释,但都含通用缩写 `NAS`,商标几乎不可能注册,看起来也像内部工具名——**建议只用作副标题或仓库简介(例如「nasway — 为 NAS 而生的穿透面板」),不要当品牌名**。
|
||||||
|
|
||||||
|
#### 这条线上的首选三个
|
||||||
|
|
||||||
|
1. **`driveway` / 盘路** —— 双关最巧:drive 是硬盘、way 是通路,英文用户会心一笑,中文名「盘路」也说得通;代价是常见词,必须先核域名与商标。
|
||||||
|
2. **`canglu` / 仓路** —— 中文双关,拼音与标识完全对应,`canglu-agent`、`canglu.db`、`CANGLU_DATA_DIR` 都好念,且不与前几组任何候选重叠。
|
||||||
|
3. **`tansu` / 箪笥** —— 想要"存储器物"的气质又要冷僻好检索时的选择;分层抽屉的柜子本身就是个不错的图标母题。
|
||||||
|
|
||||||
|
> ⚠️ 一个诚实的提醒:让**一个名字同时说清"NAS 里存着"和"外面能到"**,很容易变成拼装词(nasway、cangdao 这类就在这个边缘)。如果只能保住一层意思,建议保"通路",把"NAS/存储"交给标语去说——例如「让家里的 NAS 走出内网」,名字本身留出生长空间。
|
||||||
|
|
||||||
|
#### 建议优先考虑的组合(按这个项目的实际处境排序)
|
||||||
|
|
||||||
|
1. **`portway` / 通途** —— 语义、标识、双语读感三项都最均衡;技术与中文用户都不需要解释。
|
||||||
|
2. **`dukou` / 渡口** —— 中文语境里最容易被记住的一个,产品定位讲一遍就懂;国际化时用英文标识兜住。
|
||||||
|
3. **`zhandao` / 栈道** —— 冷门、好检索、意象有情绪("在无路处开出一条路"),适合做社区故事。
|
||||||
|
4. **`fonara`** —— 想保住 Fo 音、把这次改动讲成"品牌升级"而非"另起炉灶"时的首选。
|
||||||
|
5. **`airlock` / 气闸** —— 想要强识别度的视觉符号(圆环阀门 + 状态灯,天然适合做状态图标)时的首选。
|
||||||
|
|
||||||
|
#### 已知撞名、不建议使用的名字
|
||||||
|
|
||||||
|
| 名字 | 为什么别用 |
|
||||||
|
| --- | --- |
|
||||||
|
| Pangolin / 穿山甲 | 是**同类开源项目本身**(`fosrl/pangolin`,见 `docs/feature-benchmark-2026-09-24.md` 的同类项目盘点),会被直接误认成它 |
|
||||||
|
| Harbor | CNCF 的镜像仓库项目,技术圈认知已被占满 |
|
||||||
|
| Warp / Warpgate / Skyway / Overpass / Lumen | 分别撞 Cloudflare WARP 与 Warp.dev、知名开源堡垒机、NTT 的 P2P 服务、OSM 查询 API、电信运营商与多个产品线 |
|
||||||
|
| Nexo / Zeta / Orbi | 本文初稿曾把它们当作"简洁短词"举例,实际分别撞加密借贷平台、多家同名公司、Netgear 的 Mesh 产品线;写进 `web/src/constants/app.ts` 之前先排除 |
|
||||||
|
| Portalis / Wayfare | 游戏/出行类同名产品较多,检索会被淹没 |
|
||||||
|
|
||||||
|
> ⚠️ 现名 `Fonu` 与罗曼语系词 `fono`(电话)高度接近,换名正好可以顺手绕开这类"念起来像另一个词"的问题——建议把「读起来/拼起来是否与既有词重合」列入上面的硬性约束。
|
||||||
|
|
||||||
|
**改名后自查清单(供阶段 5 使用)**:
|
||||||
|
|
||||||
|
```
|
||||||
|
# 全仓(排除 node_modules / dist / .data)
|
||||||
|
rg -in "fonu" --glob '!web/node_modules/**' --glob '!**/dist/**' --glob '!.data/**'
|
||||||
|
# 分类核查
|
||||||
|
rg -n "FONU_" # 环境变量:是否只剩新前缀
|
||||||
|
rg -n "fonu_session|fonu-theme" # 契约/本地状态
|
||||||
|
rg -n "fonu\\.db|/fonu-agent|fonu-probe" # 存储与远端残留
|
||||||
|
rg -n "O96u/Fonu|/fonu:" # 外部身份与镜像
|
||||||
|
rg -n "github.com/fonu/fonu" # module path(含 Dockerfile ldflags)
|
||||||
|
```
|
||||||
|
|
||||||
|
**正式定名前的四处占用自查(逐个到站内查,别指望搜索引擎)**
|
||||||
|
|
||||||
|
| 查什么 | 怎么查 | 判定标准 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GitHub 组织 / 仓库 | `https://github.com/search?q=<标识>&type=repositories` 与组织页直连 | 组织名被占 → 无法建同名 org;同名仓库若是活跃的同类工具 → 直接换 |
|
||||||
|
| Docker Hub | 站内搜 `<标识>`,并确认 `<你的用户名>/<标识>` 可创建 | 镜像名一旦发布,改名只能新建仓库(见 §六) |
|
||||||
|
| 域名 | `.com` / `.net` / `.io` / `.cn` / `.dev` 的 whois | 核心后缀被竞品或商标方持有 → 减分;仅冷门后缀被占可接受 |
|
||||||
|
| 商标 | 中国商标网 + 目标市场(EUIPO / USPTO),查第 9 类(软件)、第 42 类(技术服务) | 存在同类同名的在先注册 → 直接排除,不改 |
|
||||||
|
|
||||||
|
> 本次实测:泛化的网页检索对"这个名字有没有被占用"几乎无效(只会返回无关内容),所以这四处必须站内查。另外把派生形态也念一遍——`<标识>-agent`、`<标识>.db`、`--<标识>-brand`、`<IDENT>_DATA_DIR`——它们是每天都要看见的东西。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、风险与回滚
|
||||||
|
|
||||||
|
| 风险 | 触发条件 | 缓解 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 老用户"数据丢失" | 只改 `DBPath()` 不做接管 | 阶段 3 的 rename + 失败回退;发布说明写明「首次启动会接管旧库」 |
|
||||||
|
| 会话密钥静默回落默认值 | 只改 env 前缀 | `FONU_SESSION_SECRET` 双读 + 启动告警;保留一个版本的旧前缀 |
|
||||||
|
| VPS agent 失联/漂移误报 | 服务端与 agent 版本错配 | 新旧路径/变量/标记双支持;`AgentVersion` 递增;安装脚本接管旧 unit |
|
||||||
|
| Docker 起来是空库 | volume 名变更 | compose 显式 `name:` + 迁移命令 + 用旧 volume 起新容器做验收 |
|
||||||
|
| 版本号显示 `dev` | 漏改 Dockerfile 的 `-ldflags` module path | 构建后必查版本接口;CI 加一条断言 |
|
||||||
|
| 客户端全部掉登录态 | cookie 改名 | 双读或接受一次重登(发布说明写明) |
|
||||||
|
| 前端版本检查失效 | GitHub 仓库改名后旧名被占/新常量未同步 | 改名后回归一次 `version.ts` 路径 |
|
||||||
|
|
||||||
|
**回滚策略**:阶段 1~3 的回滚以「保留旧名兼容分支」为主(一个 `git revert` 即可,因为兼容层是新增而非替换);数据库重命名是不可逆动作,但方向单一(旧→新),回退版本时提供一条反向重命名说明即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、结论
|
||||||
|
|
||||||
|
- **可以做全面品牌升级,落点是有限且已全部找清的**(见 §一,含 5 层证据)。
|
||||||
|
- **不要全局替换**:`internal/config/config.go:50`(`fonu.db`)、`config.go:24,29,31-38`(`FONU_*`)、`internal/frp/agent_installer.go:35,36`(VPS 上 `/etc/fonu-agent/agent.env`)这三处是"改了会伤用户"的位置,必须先设计兼容再动手。
|
||||||
|
- **建议按 §四 的五阶段推进**,阶段 1 就能拿到"看着焕然一新"的结果,阶段 3 才是真正需要谨慎的工程部分;阶段 5 用 §七 的清单收尾。
|
||||||
|
- 本方案只做分析,未改动任何代码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 说明:本文基于 2026-09-25 的仓库状态(VERSION 1.2.12)实测盘点;外部平台(GitHub 重定向、Docker Hub 不支持改名)的结论来自当日检索,实施前建议再核实一次平台当前行为。
|
||||||
@@ -0,0 +1,654 @@
|
|||||||
|
# Cloudflare 隧道当前功能说明
|
||||||
|
|
||||||
|
> 日期:2026-10-01
|
||||||
|
> 状态:v1.9.0 实测功能基线。本文描述**当前代码实际实现的能力**,不是早期规划稿。
|
||||||
|
> 相关文档:
|
||||||
|
> - `cloudflared-integration-plan.md`:早期融合方案
|
||||||
|
> - `cloudflared-feature-plan.md`:官方与参考项目功能全集,用于查漏
|
||||||
|
> - `cloudflared-fonu-fit-plan.md`:Fonu 适配版阶段计划
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、模块定位
|
||||||
|
|
||||||
|
Cloudflare 隧道在 Fonu 中不是独立的 cloudflared 管理面板,而是公网访问的第二条出口:
|
||||||
|
|
||||||
|
- 本机 Nginx:适合有公网 IP 或已有端口映射的场景。
|
||||||
|
- FRP:适合需要 TCP/UDP、自控 VPS 或大流量的场景。
|
||||||
|
- Cloudflare 隧道:适合没有公网 IP、主要发布 HTTP/HTTPS Web 服务的场景。
|
||||||
|
|
||||||
|
当前模块已经融入 Fonu 的规则、监控、通知、状态页、日志、备份、诊断和升级体系。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、已实现的入口与页面
|
||||||
|
|
||||||
|
### 2.1 Cloudflare 隧道列表页
|
||||||
|
|
||||||
|
页面:`web/src/views/CloudflareView.vue`
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
- 隧道列表显示:
|
||||||
|
- 隧道名称
|
||||||
|
- 状态
|
||||||
|
- 副本 / cloudflared HA 连接数
|
||||||
|
- cloudflared 版本
|
||||||
|
- 路由数量
|
||||||
|
- 指标端口
|
||||||
|
- 全局托管开关:
|
||||||
|
- 关闭:停止全部隧道。
|
||||||
|
- 开启:立即启动全部隧道。
|
||||||
|
- 应用重启:全局托管开启时自动恢复全部隧道。
|
||||||
|
- 新建隧道弹窗:
|
||||||
|
- 自动托管模式。
|
||||||
|
- 手工隧道模式。
|
||||||
|
- 手工隧道暴露规则:
|
||||||
|
- 子域名输入。
|
||||||
|
- 域从 Cloudflare Zone 列表选择。
|
||||||
|
- HTTP / HTTPS / TCP / SSH 服务类型。
|
||||||
|
- `://` 与 URL 分离显示。
|
||||||
|
- 实时显示完整主机名。
|
||||||
|
- 隧道操作:
|
||||||
|
- 详情。
|
||||||
|
- 配置页。
|
||||||
|
- 启动 / 停止 / 重启。
|
||||||
|
- 删除。
|
||||||
|
- 应用配置:
|
||||||
|
- Account ID。
|
||||||
|
- API Token。
|
||||||
|
- Token 验证。
|
||||||
|
- cloudflared 下载镜像。
|
||||||
|
- 默认网络参数。
|
||||||
|
|
||||||
|
### 2.2 隧道配置页
|
||||||
|
|
||||||
|
页面:`web/src/views/CloudflareTunnelConfigView.vue`
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
- 独立路由页面,不再使用编辑弹窗:
|
||||||
|
- `/cloudflare/:id/config`
|
||||||
|
- 指标卡:
|
||||||
|
- 活动副本
|
||||||
|
- 路由数
|
||||||
|
- 状态
|
||||||
|
- 运行时间
|
||||||
|
- 基础配置:
|
||||||
|
- 页面只显示“编辑基础配置”按钮。
|
||||||
|
- 点击后弹窗编辑名称、子域名、Zone、服务类型、URL、自动启动。
|
||||||
|
- 路由列表:
|
||||||
|
- 顺序。
|
||||||
|
- 目标域名。
|
||||||
|
- 路径。
|
||||||
|
- 服务。
|
||||||
|
- 来源。
|
||||||
|
- 操作。
|
||||||
|
- 拓扑图:
|
||||||
|
- 域名节点。
|
||||||
|
- Cloudflare 出口节点。
|
||||||
|
- 隧道节点。
|
||||||
|
- 服务节点。
|
||||||
|
- 点阵画布和曲线连接。
|
||||||
|
- 路由操作:
|
||||||
|
- 添加路由。
|
||||||
|
- 编辑路由。
|
||||||
|
- 删除路由。
|
||||||
|
- 上移 / 下移。
|
||||||
|
- 复制路由。
|
||||||
|
- 测试源服务 TCP 连通性。
|
||||||
|
- 路由弹窗:
|
||||||
|
- 子域名 + Zone 下拉。
|
||||||
|
- HTTP / HTTPS / TCP / SSH + URL。
|
||||||
|
- 完整主机名实时预览。
|
||||||
|
- 全部捕获规则:
|
||||||
|
- 默认显示 `http_status:404`。
|
||||||
|
- 运行控制:
|
||||||
|
- 启动、停止、重启。
|
||||||
|
|
||||||
|
自动托管隧道的路由只读,来源显示为“反向代理规则”,并提示到“反向代理”页维护。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、Cloudflare 账号与二进制
|
||||||
|
|
||||||
|
### 3.1 账号配置
|
||||||
|
|
||||||
|
实现文件:
|
||||||
|
|
||||||
|
- `internal/cloudflared/cloudflare_api.go`
|
||||||
|
- `internal/cloudflared/service.go`
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
- 保存 Account ID + API Token。
|
||||||
|
- API Token 加密存储。
|
||||||
|
- Token 掩码回显。
|
||||||
|
- 调用账号级 `tokens/verify` 验证 Token。
|
||||||
|
- 查询当前 Token 可访问的 Zone。
|
||||||
|
- 根据域名向上查找所属 Zone。
|
||||||
|
- 创建、读取、删除 Cloudflare 隧道。
|
||||||
|
- 获取隧道 Token。
|
||||||
|
- 读取和覆盖远端 ingress。
|
||||||
|
- 创建、更新、删除 DNS CNAME。
|
||||||
|
- 只读检测 Cloudflare Access。
|
||||||
|
|
||||||
|
### 3.2 cloudflared 二进制
|
||||||
|
|
||||||
|
实现文件:
|
||||||
|
|
||||||
|
- `internal/cloudflared/binary.go`
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
- 内置多种下载镜像。
|
||||||
|
- 下载并原子替换本机 cloudflared。
|
||||||
|
- 校验文件大小和可执行性。
|
||||||
|
- 显示当前版本。
|
||||||
|
- 查询 GitHub 最新版本。
|
||||||
|
- 下载指定版本。
|
||||||
|
- 保留上一版并支持回滚。
|
||||||
|
|
||||||
|
版本显示统一为语义版本,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
2026.9.3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、隧道生命周期
|
||||||
|
|
||||||
|
实现文件:
|
||||||
|
|
||||||
|
- `internal/cloudflared/service.go`
|
||||||
|
- `internal/cloudflared/manager.go`
|
||||||
|
- `internal/cloudflared/store.go`
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
- 通过 Cloudflare API 创建命名隧道。
|
||||||
|
- 生成本地凭据:
|
||||||
|
- `creds.json`
|
||||||
|
- `config.yml`
|
||||||
|
- `cloudflared.log`
|
||||||
|
- 支持隧道模式模型:
|
||||||
|
- `account-local`
|
||||||
|
- `token-local`
|
||||||
|
- `token-remote`
|
||||||
|
- 进程托管:
|
||||||
|
- 启动。
|
||||||
|
- 停止。
|
||||||
|
- 重启。
|
||||||
|
- 崩溃后读取日志尾部便于诊断。
|
||||||
|
- 开机启动:
|
||||||
|
- 全局托管开启时,应用启动会恢复全部隧道。
|
||||||
|
- “随 Fonu 自动启动”开关保留为隧道级选项。
|
||||||
|
- 全局总开关:
|
||||||
|
- 开启时启动全部隧道。
|
||||||
|
- 关闭时停止全部隧道。
|
||||||
|
- 凭据刷新:
|
||||||
|
- 重新获取隧道 Token。
|
||||||
|
- 更新本地 `creds.json`。
|
||||||
|
- 运行中时自动重启。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、出口抽象与反向代理规则
|
||||||
|
|
||||||
|
实现文件:
|
||||||
|
|
||||||
|
- `internal/proxy/proxy.go`
|
||||||
|
- `internal/service/proxy.go`
|
||||||
|
- `internal/cloudflared/service.go`
|
||||||
|
|
||||||
|
迁移:
|
||||||
|
|
||||||
|
- `migrations/035_proxy_exits.sql`
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
- 反向代理规则增加出口字段:
|
||||||
|
- `local`
|
||||||
|
- `cloudflare`
|
||||||
|
- 支持同时选择:
|
||||||
|
- 本机 Nginx + Cloudflare。
|
||||||
|
- Cloudflare 出口增加目标隧道:
|
||||||
|
- `cf_tunnel_id`
|
||||||
|
- 规则变化自动同步:
|
||||||
|
- 新增规则。
|
||||||
|
- 修改规则。
|
||||||
|
- 删除规则。
|
||||||
|
- 启停规则。
|
||||||
|
- 托管隧道 ingress 从规则派生:
|
||||||
|
- 每条域名使用自己的回源地址。
|
||||||
|
- 仅同步启用的 Cloudflare 出口规则。
|
||||||
|
- 仅 Cloudflare 出口的规则不生成无用的本机 Nginx server。
|
||||||
|
- 规则保存成功但 Cloudflare 同步失败时:
|
||||||
|
- 本地规则仍保存。
|
||||||
|
- 返回 `sync_warning`。
|
||||||
|
- 前端显示警告而不是把整次保存判为失败。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、路由与 ingress
|
||||||
|
|
||||||
|
实现文件:
|
||||||
|
|
||||||
|
- `internal/cloudflared/routes.go`
|
||||||
|
- `internal/cloudflared/config.go`
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
- 手工隧道路由来自本地 `config.yml`。
|
||||||
|
- 托管隧道路由来自反向代理规则。
|
||||||
|
- 路由列表支持:
|
||||||
|
- 添加。
|
||||||
|
- 编辑。
|
||||||
|
- 删除。
|
||||||
|
- 上移 / 下移。
|
||||||
|
- 复制。
|
||||||
|
- 源服务连通性测试。
|
||||||
|
- 保存路由时:
|
||||||
|
- 重新生成本地 `config.yml`。
|
||||||
|
- 覆盖 Cloudflare 云端 ingress。
|
||||||
|
- 同步 DNS。
|
||||||
|
- 运行中时重启 cloudflared。
|
||||||
|
- 末条 catch-all 固定为:
|
||||||
|
```text
|
||||||
|
http_status:404
|
||||||
|
```
|
||||||
|
- 通配路径处理:
|
||||||
|
- UI 显示 `*`。
|
||||||
|
- 发送到 Cloudflare 时省略 `path`。
|
||||||
|
- 避免 `*` 被当作非法正则。
|
||||||
|
- 支持正则 path 的后端字段,但当前页面主要使用通配路径。
|
||||||
|
|
||||||
|
### P0:接入与调和
|
||||||
|
|
||||||
|
- Cloudflare 页面新增“接入向导”。
|
||||||
|
- 接入预检检查:
|
||||||
|
- cloudflared 二进制。
|
||||||
|
- Account ID。
|
||||||
|
- API Token。
|
||||||
|
- Zone 权限。
|
||||||
|
- 隧道列表新增“调和”列:
|
||||||
|
- 已同步。
|
||||||
|
- 有漂移。
|
||||||
|
- 手工隧道。
|
||||||
|
- 有漂移时可一键修复:
|
||||||
|
- 以本地规则覆盖 Cloudflare 云端配置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、DNS 管理
|
||||||
|
|
||||||
|
实现文件:
|
||||||
|
|
||||||
|
- `internal/cloudflared/cloudflare_api.go`
|
||||||
|
- `internal/cloudflared/service.go`
|
||||||
|
- `internal/cloudflared/store.go`
|
||||||
|
|
||||||
|
迁移:
|
||||||
|
|
||||||
|
- `migrations/035_proxy_exits.sql`
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
- 自动创建 CNAME:
|
||||||
|
```text
|
||||||
|
<hostname> -> <tunnel-id>.cfargotunnel.com
|
||||||
|
```
|
||||||
|
- 开启 Cloudflare 代理(橙云)。
|
||||||
|
- 记录 Fonu 创建或接管的 DNS 记录:
|
||||||
|
- `cf_dns_records`
|
||||||
|
- 删除域名或移除出口时:
|
||||||
|
- 只删除 Fonu 自己接管的记录。
|
||||||
|
- 不覆盖指向其他目标的用户 DNS 记录。
|
||||||
|
- 域名从一条隧道迁移到另一条隧道时支持接管迁移。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、状态、指标与日志
|
||||||
|
|
||||||
|
实现文件:
|
||||||
|
|
||||||
|
- `internal/cloudflared/manager.go`
|
||||||
|
- `internal/cloudflared/health.go`
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
- 从 cloudflared metrics 端口读取:
|
||||||
|
- HA 连接数。
|
||||||
|
- 延迟。
|
||||||
|
- 上下行速率。
|
||||||
|
- 传输协议。
|
||||||
|
- 隧道列表实时状态。
|
||||||
|
- 配置页指标卡。
|
||||||
|
- 日志中心新增“Cloudflare 隧道”页签:
|
||||||
|
- 选择隧道。
|
||||||
|
- 查看 200 / 500 / 2000 行日志。
|
||||||
|
- 关键词筛选。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、健康巡检、通知与状态页
|
||||||
|
|
||||||
|
迁移:
|
||||||
|
|
||||||
|
- `migrations/036_cf_health.sql`
|
||||||
|
|
||||||
|
实现文件:
|
||||||
|
|
||||||
|
- `internal/cloudflared/health.go`
|
||||||
|
- `internal/monitor/monitor.go`
|
||||||
|
- `internal/app/monitor_source.go`
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
- 记录 Cloudflare 域名健康状态变化:
|
||||||
|
- `up`
|
||||||
|
- `down`
|
||||||
|
- `unknown`
|
||||||
|
- 24 小时可用率。
|
||||||
|
- 不可用区间。
|
||||||
|
- 巡检复用现有:
|
||||||
|
- 路由健康开关。
|
||||||
|
- 隧道断开通知开关。
|
||||||
|
- 连续失败阈值。
|
||||||
|
- 连续不通和恢复复用通知事件 `tunnel_down`。
|
||||||
|
- 公开状态页合并:
|
||||||
|
- FRP 路由。
|
||||||
|
- Cloudflare 路由。
|
||||||
|
- 状态页展示每条路由的不可用区间:
|
||||||
|
- 开始时间。
|
||||||
|
- 持续分钟数。
|
||||||
|
- 原因。
|
||||||
|
- 状态页只显示域名、来源、状态和可用率,不泄露隧道 ID 与内网地址。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、仪表盘
|
||||||
|
|
||||||
|
实现文件:
|
||||||
|
|
||||||
|
- `internal/api/status_handler.go`
|
||||||
|
- `web/src/views/DashboardView.vue`
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
- `/api/status` 增加:
|
||||||
|
- Cloudflare 隧道总数。
|
||||||
|
- 在线数。
|
||||||
|
- 状态。
|
||||||
|
- 摘要消息。
|
||||||
|
- 仪表盘健康列表增加“Cloudflare 隧道”条目。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、备份与恢复
|
||||||
|
|
||||||
|
实现文件:
|
||||||
|
|
||||||
|
- `internal/backup/service.go`
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
- 备份内容包含:
|
||||||
|
- `fonu.db`
|
||||||
|
- `certs`
|
||||||
|
- `nginx`
|
||||||
|
- `cloudflared/tunnels`
|
||||||
|
- `cloudflared/tunnels` 包含:
|
||||||
|
- `config.yml`
|
||||||
|
- `creds.json`
|
||||||
|
- 数据库中的 `cf_tunnels`、`cf_settings`、`cf_dns_records` 随 `fonu.db` 恢复。
|
||||||
|
- 恢复后仍建议重启 Fonu,由启动流程恢复全部隧道。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、漂移检测与诊断
|
||||||
|
|
||||||
|
实现文件:
|
||||||
|
|
||||||
|
- `internal/cloudflared/operations.go`
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
### 12.1 漂移检测
|
||||||
|
|
||||||
|
- 比较本地规则派生 ingress 与 Cloudflare 云端 ingress。
|
||||||
|
- 检出:
|
||||||
|
- 本地有、云端缺失。
|
||||||
|
- 云端有、本地已移除。
|
||||||
|
- 回源地址不一致。
|
||||||
|
- 支持“以本地覆盖”:
|
||||||
|
```text
|
||||||
|
POST /api/cloudflare/tunnels/{id}/sync
|
||||||
|
```
|
||||||
|
- 当前未实现“以云端为准拉取”。
|
||||||
|
|
||||||
|
### 12.2 一键诊断
|
||||||
|
|
||||||
|
检查项:
|
||||||
|
|
||||||
|
- cloudflared 二进制。
|
||||||
|
- 本地 `config.yml`。
|
||||||
|
- 隧道进程状态。
|
||||||
|
- Cloudflare API 与云端配置可访问性。
|
||||||
|
- Fonu 接管的 DNS 记录。
|
||||||
|
- Cloudflare edge region1。
|
||||||
|
- Cloudflare edge region2。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十三、NAS 场景能力
|
||||||
|
|
||||||
|
实现文件:
|
||||||
|
|
||||||
|
- `internal/discovery/service.go`
|
||||||
|
- `web/src/views/ProxyView.vue`
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
- 服务发现目录包含:
|
||||||
|
- 飞牛 fnOS
|
||||||
|
- AList
|
||||||
|
- Jellyfin
|
||||||
|
- PhotoPrism
|
||||||
|
- qBittorrent
|
||||||
|
- Portainer
|
||||||
|
- Home Assistant
|
||||||
|
- Immich
|
||||||
|
- AdGuard Home
|
||||||
|
- Synology DSM
|
||||||
|
- 反向代理页“扫描服务”打开发现列表。
|
||||||
|
- 点击“发布”会:
|
||||||
|
- 预填服务名称。
|
||||||
|
- 预填内网 upstream。
|
||||||
|
- 若已有托管隧道,自动选择 Cloudflare 出口。
|
||||||
|
- 打开规则编辑表单等待填写域名。
|
||||||
|
|
||||||
|
当前不是完全一键创建,仍需要用户填写域名并确认保存。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十四、安全与保护提示
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
- Cloudflare Tunnel 条款提示:
|
||||||
|
- 不适合持续大文件/视频分发。
|
||||||
|
- 仅 Cloudflare 出口时:
|
||||||
|
- 提示无需申请本机 HTTPS 证书。
|
||||||
|
- Access 只读检测:
|
||||||
|
- 检测域名是否已有 Access 应用。
|
||||||
|
- 不代配 Access 策略。
|
||||||
|
- API Token 权限提示:
|
||||||
|
- `Account: Cloudflare Tunnel: Edit`
|
||||||
|
- `Zone: Zone: Read`
|
||||||
|
- `Zone: DNS: Edit`
|
||||||
|
- Zone Resources 必须包含待发布根域名。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十五、当前 API
|
||||||
|
|
||||||
|
### 隧道
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/cloudflare/tunnels
|
||||||
|
POST /api/cloudflare/tunnels
|
||||||
|
GET /api/cloudflare/tunnels/{id}
|
||||||
|
PUT /api/cloudflare/tunnels/{id}
|
||||||
|
DELETE /api/cloudflare/tunnels/{id}
|
||||||
|
POST /api/cloudflare/tunnels/{id}/start
|
||||||
|
POST /api/cloudflare/tunnels/{id}/stop
|
||||||
|
POST /api/cloudflare/tunnels/{id}/restart
|
||||||
|
POST /api/cloudflare/tunnels/{id}/credentials/refresh
|
||||||
|
GET /api/cloudflare/tunnels/{id}/status
|
||||||
|
GET /api/cloudflare/tunnels/{id}/logs
|
||||||
|
GET /api/cloudflare/tunnels/{id}/config
|
||||||
|
GET /api/cloudflare/tunnels/{id}/routes
|
||||||
|
PUT /api/cloudflare/tunnels/{id}/routes
|
||||||
|
POST /api/cloudflare/routes/test
|
||||||
|
GET /api/cloudflare/tunnels/{id}/drift
|
||||||
|
POST /api/cloudflare/tunnels/{id}/sync
|
||||||
|
GET /api/cloudflare/tunnels/{id}/diagnostics
|
||||||
|
```
|
||||||
|
|
||||||
|
### 二进制与配置
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/cloudflare/binary
|
||||||
|
POST /api/cloudflare/binary/download
|
||||||
|
GET /api/cloudflare/binary/latest
|
||||||
|
POST /api/cloudflare/binary/rollback
|
||||||
|
GET /api/cloudflare/settings
|
||||||
|
PUT /api/cloudflare/settings
|
||||||
|
POST /api/cloudflare/settings/test
|
||||||
|
POST /api/cloudflare/enabled
|
||||||
|
GET /api/cloudflare/preflight
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zone、候选与 Access
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/cloudflare/ingress-candidates
|
||||||
|
GET /api/cloudflare/zones
|
||||||
|
GET /api/cloudflare/access-status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十六、数据表
|
||||||
|
|
||||||
|
### `cf_tunnels`
|
||||||
|
|
||||||
|
保存隧道基础信息:
|
||||||
|
|
||||||
|
- 名称。
|
||||||
|
- 模式。
|
||||||
|
- Tunnel ID。
|
||||||
|
- Account Tag。
|
||||||
|
- 加密后的凭据。
|
||||||
|
- 配置、凭据、日志路径。
|
||||||
|
- 网络设置。
|
||||||
|
- 运行状态。
|
||||||
|
- metrics 端口。
|
||||||
|
- 自动启动选项。
|
||||||
|
- 是否自动托管。
|
||||||
|
|
||||||
|
### `cf_settings`
|
||||||
|
|
||||||
|
保存应用级配置:
|
||||||
|
|
||||||
|
- Account ID。
|
||||||
|
- 加密后的默认 Token。
|
||||||
|
- 加密后的 API Token。
|
||||||
|
- 下载镜像。
|
||||||
|
- 默认网络设置。
|
||||||
|
- 全局托管开关。
|
||||||
|
|
||||||
|
### `cf_dns_records`
|
||||||
|
|
||||||
|
保存 Fonu 接管的 DNS 记录:
|
||||||
|
|
||||||
|
- 本地隧道 ID。
|
||||||
|
- Zone ID。
|
||||||
|
- DNS Record ID。
|
||||||
|
- Hostname。
|
||||||
|
|
||||||
|
### `cf_health_events`
|
||||||
|
|
||||||
|
保存 Cloudflare 域名健康状态变化:
|
||||||
|
|
||||||
|
- 隧道 ID。
|
||||||
|
- 域名。
|
||||||
|
- 状态。
|
||||||
|
- 原因。
|
||||||
|
- 详情。
|
||||||
|
- 时间。
|
||||||
|
|
||||||
|
### `proxy_rules` 扩展
|
||||||
|
|
||||||
|
- `exits_json`
|
||||||
|
- `cf_tunnel_id`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十七、当前未完成或仅部分实现
|
||||||
|
|
||||||
|
以下能力尚未完成,不能按“已实现”宣传:
|
||||||
|
|
||||||
|
1. 完整审计日志
|
||||||
|
- 尚未记录“谁在何时修改了规则、出口、凭据和设置”。
|
||||||
|
|
||||||
|
2. 云端配置拉取
|
||||||
|
- 漂移检测支持“本地覆盖云端”。
|
||||||
|
- 尚不支持“云端覆盖本地”。
|
||||||
|
|
||||||
|
3. 完整无感令牌轮换
|
||||||
|
- 当前是“刷新/重新获取隧道凭据”。
|
||||||
|
- 尚未实现创建新隧道后无缝迁移全部规则。
|
||||||
|
|
||||||
|
4. Access 策略管理
|
||||||
|
- 只做只读检测。
|
||||||
|
- 不创建、不修改 Access 应用。
|
||||||
|
|
||||||
|
5. 完全一键服务发布
|
||||||
|
- 当前是扫描、预填、打开表单。
|
||||||
|
- 仍需要用户填写域名并确认保存。
|
||||||
|
|
||||||
|
6. 正则路径 UI
|
||||||
|
- 后端路由模型支持 `path`。
|
||||||
|
- 当前页面主要使用通配路径,尚未提供独立的 path 高级编辑控件。
|
||||||
|
|
||||||
|
7. 多 Cloudflare 账号
|
||||||
|
- 当前应用配置只支持一个 Account ID + API Token。
|
||||||
|
- `cert.pem` / 多账号模式尚未实现。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十八、验证基线
|
||||||
|
|
||||||
|
当前实现覆盖以下验证:
|
||||||
|
|
||||||
|
```text
|
||||||
|
go test ./... -count=1
|
||||||
|
go vet ./...
|
||||||
|
cd web && npm run typecheck
|
||||||
|
cd web && npm test -- --run
|
||||||
|
cd web && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
新增或重点测试:
|
||||||
|
|
||||||
|
- 出口默认值、持久化与过滤。
|
||||||
|
- Cloudflare 按规则生成 ingress。
|
||||||
|
- Cloudflare 健康状态与可用率计算。
|
||||||
|
- 手工 config 路由解析。
|
||||||
|
- 通配 path 不发送非法正则。
|
||||||
|
- DNS 记录归属。
|
||||||
|
- 备份包含 cloudflared 隧道配置。
|
||||||
|
- 漂移比较。
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
# Cloudflare 隧道模块全功能规划
|
||||||
|
|
||||||
|
> 调研日期:2026-09-30
|
||||||
|
> 调研对象:Cloudflare 官方文档(developers.cloudflare.com,2026-08/09 更新)+ `F:\WWW\cf\Tunnel-Cloudflared-linux`(Tunnel-Cloudflared-Manager 1.0.1)+ Fonu 1.8.0 现有实现
|
||||||
|
> 目标:把 Fonu 的「Cloudflare 隧道」从「可用的创建与托管」推进到「覆盖官方主要能力、可长期运维」的完整模块。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、官方文档调研结论
|
||||||
|
|
||||||
|
Cloudflare 把 Tunnel 分成两条产品线,Fonu 目前主要落在第一条,规划时需要同时兼顾两条:
|
||||||
|
|
||||||
|
| 产品线 | 文档入口 | 典型用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Zero Trust / 私有网络 | [cloudflare-one Tunnel](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/) | 私有网络、SSH/RDP、VPN 替代、Access 保护 |
|
||||||
|
| 公开发布应用 | [tunnel](https://developers.cloudflare.com/tunnel/) | 把公网域名指向自建服务,替代 VPS 与端口暴露 |
|
||||||
|
|
||||||
|
### 1.1 核心概念(官方)
|
||||||
|
|
||||||
|
- **Tunnel**:一条以 UUID 标识的持久对象,是源站与 Cloudflare 之间的逻辑链路。
|
||||||
|
- **Connector**:一个 `cloudflared` 进程;同一条隧道可以运行多个 connector。
|
||||||
|
- **Replica**:同一隧道的多个 `cloudflared` 实例,用于高可用与零停机更新。
|
||||||
|
- **Remotely-managed**:隧道由 Cloudflare 面板 / API 管理,规则在云端,`cloudflared` 用 token 启动。
|
||||||
|
- **Locally-managed**:规则在本机 `config.yml`,用 `credentials-file` 启动。
|
||||||
|
- **Quick tunnel**:`trycloudflare.com` 临时隧道,无需账号,官方明确仅用于测试与开发。
|
||||||
|
- **Virtual network**:私有网络中用于隔离不同网络的路由域。
|
||||||
|
|
||||||
|
### 1.2 官方关键能力清单
|
||||||
|
|
||||||
|
**隧道与连接器**
|
||||||
|
|
||||||
|
- 创建、删除、列出、按名称或 UUID 查询隧道
|
||||||
|
- 获取隧道 token;token 轮换
|
||||||
|
- 同一隧道部署多个 replica
|
||||||
|
- `cloudflared tunnel info` 查看连接器与连接状态
|
||||||
|
- 每个 connector 默认建立 4 条出站连接,分布在至少两个数据中心
|
||||||
|
- 支持 post-quantum 加密(`--post-quantum`)
|
||||||
|
|
||||||
|
**路由**
|
||||||
|
|
||||||
|
- Published application:`hostname`(可带 `path`)→ `service`
|
||||||
|
- 支持服务协议:`http`、`https`、`ssh`、`rdp`、`smb`、`tcp`、`unix`、`http_status`、`http_host`、TCP over WebSocket 等
|
||||||
|
- DNS:创建 CNAME 指向 `<tunnel-id>.cfargotunnel.com`,需开启代理
|
||||||
|
- 私有网络:CIDR route、WARP routing、virtual network
|
||||||
|
- Load Balancing:公开负载均衡与私有负载均衡
|
||||||
|
|
||||||
|
**配置**
|
||||||
|
|
||||||
|
- `config.yml` 顶层:`tunnel`、`credentials-file`、`ingress`、`warp-routing`、`originRequest`、`protocol`、`edge-ip-version`、`ha-connections`、`retries`、`grace-period`、`post-quantum`、`region`、`tag`、`logfile`、`loglevel`、`metrics`、`pidfile`、`token`、`token-file`
|
||||||
|
- `ingress` 必须末条 catch-all(如 `http_status:404`),按顺序匹配 hostname / path
|
||||||
|
- Origin 参数:TLS(`originServerName`、`matchSNItoHost`、`caPool`、`noTLSVerify`、`tlsTimeout`、`http2Origin`)、HTTP(`httpHostHeader`、`disableChunkedEncoding`)、连接(`connectTimeout`、`noHappyEyeballs`、`proxyType`、`proxyAddress`、`proxyPort`、`keepAliveTimeout`、`keepAliveConnections`、`tcpKeepAlive`)、Access(`access`)
|
||||||
|
- 校验工具:`cloudflared tunnel ingress validate`、`cloudflared tunnel ingress rule <url>`
|
||||||
|
|
||||||
|
**观测**
|
||||||
|
|
||||||
|
- Tunnel health:面板隧道健康、`cloudflared tunnel info`
|
||||||
|
- Cloudflare Notifications:隧道健康事件通知
|
||||||
|
- Logs:Cloudflare 侧日志、remote log streaming、network session logs、本机 `cloudflared` 日志
|
||||||
|
- Metrics:`/metrics`(Prometheus 格式)、`/ready`、`/healthcheck`
|
||||||
|
- Diagnostic logs:`cloudflared tunnel diag` 生成诊断文件
|
||||||
|
|
||||||
|
**运维与部署**
|
||||||
|
|
||||||
|
- Linux systemd、macOS launch agent、Windows service
|
||||||
|
- Docker、Kubernetes、Ansible、Terraform、AWS/Azure/GCP 指南
|
||||||
|
- 更新 cloudflared;多实例零停机更新
|
||||||
|
- 系统要求:出站 7844(TCP/UDP)与 443;ulimit;容量计算
|
||||||
|
- 文件权限要求:`cert.pem`、`<UUID>.json`、`config.yml`
|
||||||
|
|
||||||
|
**权限与安全**
|
||||||
|
|
||||||
|
- API Token 权限:`Account > Cloudflare Tunnel > Edit`、`Zone > DNS > Edit`(查询 Zone 还需 `Zone > Zone > Read`)
|
||||||
|
- Access、Service Auth、mTLS、Keyless SSL、Spectrum、Workers VPC、Hyperdrive 等集成
|
||||||
|
- Tunnel ownership、account-scoped roles、撤销账号证书
|
||||||
|
|
||||||
|
### 1.3 官方版本要点(2026-09)
|
||||||
|
|
||||||
|
- 运行参数文档最近更新 2026-09;`post-quantum`、`token-file`、`region`、`tag`、`dns-resolver-addrs`、`edge-bind-address` 等参数已进入官方参考。
|
||||||
|
- `edge-ip-version` 官方默认值曾由 `4` 调整为 `auto`;Fonu 已显式下发 `4`,与国内链路建议一致。
|
||||||
|
- 官方把「公开发布应用」与「Zero Trust 私有网络」拆成两条文档线,Fonu 需要明确自己的默认定位是公开发布应用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、参考项目功能清单(Tunnel-Cloudflared-Manager 1.0.1)
|
||||||
|
|
||||||
|
参考项目是一个独立面板,功能面比 Fonu 当前实现更宽,但缺少与 Fonu 的规则、健康、通知、状态页体系融合。
|
||||||
|
|
||||||
|
### 2.1 隧道管理
|
||||||
|
|
||||||
|
- 命名隧道:token 启动、config-file 启动
|
||||||
|
- 快速隧道:`--url` 启动、从日志解析 `*.trycloudflare.com` URL
|
||||||
|
- 启动 / 停止 / 停止全部 / 运行列表 / URL 查询
|
||||||
|
- 创建隧道(账号模式,`tunnel create`)
|
||||||
|
- 初始化 token 隧道:解码 token → 写 `creds.json` + `config.yml`
|
||||||
|
- 删除云端隧道
|
||||||
|
|
||||||
|
### 2.2 路由与云端同步
|
||||||
|
|
||||||
|
- 端点列表:读取本地 `config.yml` 的 ingress
|
||||||
|
- 新增 / 更新 / 删除端点(本地)
|
||||||
|
- 新增端点时自动调用 `tunnel route dns` 创建 DNS
|
||||||
|
- 从云端拉取 ingress 到本地
|
||||||
|
- 把本地 ingress 推送到云端
|
||||||
|
- ingress 三条不变式:catch-all、`localhost` 归一化、插入在 catch-all 之前
|
||||||
|
|
||||||
|
### 2.3 账号模式
|
||||||
|
|
||||||
|
- `cert.pem` 登录与 finalize
|
||||||
|
- 列出账号下的隧道
|
||||||
|
- 同步云端隧道
|
||||||
|
- 删除账号
|
||||||
|
|
||||||
|
### 2.4 二进制与镜像
|
||||||
|
|
||||||
|
- 检查 / 下载 / 替换 cloudflared
|
||||||
|
- 下载进度
|
||||||
|
- 镜像管理:列出、新增、更新、删除、测试
|
||||||
|
- 内置官方与多个 gh-proxy 镜像
|
||||||
|
|
||||||
|
### 2.5 指标与日志
|
||||||
|
|
||||||
|
- 每 2 秒抓取 `/metrics`
|
||||||
|
- QUIC:`quic_client_receive_bytes`、`quic_client_sent_bytes`、`quic_client_smoothed_rtt`
|
||||||
|
- HTTP/2:RPC latency、主动 HEAD 探测;带宽用进程 socket 统计(Windows 需要管理员)
|
||||||
|
- 连接数:`cloudflared_tunnel_ha_connections`
|
||||||
|
- 实时日志:WebSocket 推送
|
||||||
|
- 清空日志
|
||||||
|
|
||||||
|
### 2.6 网络与调优
|
||||||
|
|
||||||
|
- `protocol`(auto/http2/quic)、代理环境探测与回落
|
||||||
|
- `edge-ip-version`、`ha-connections`
|
||||||
|
- `originRequest`:`caPool`、`noTLSVerify`、`httpHostHeader`
|
||||||
|
- 网络诊断接口
|
||||||
|
- Windows 子进程隐藏控制台
|
||||||
|
|
||||||
|
### 2.7 运维
|
||||||
|
|
||||||
|
- 版本检查与应用自更新
|
||||||
|
- JWT + bcrypt 登录、失败锁定
|
||||||
|
- 多语言、主题
|
||||||
|
- Docker 与 Linux 安装脚本
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、Fonu 当前实现(v1.8.0)
|
||||||
|
|
||||||
|
| 能力 | 状态 |
|
||||||
|
| --- | --- |
|
||||||
|
| Account ID + API Token 凭据 | ✅ 已实现 |
|
||||||
|
| API Token 验证(`/accounts/{id}/tokens/verify`) | ✅ 已实现 |
|
||||||
|
| 创建 / 删除云端隧道 | ✅ 已实现 |
|
||||||
|
| 获取隧道 token、写 `creds.json` / `config.yml` | ✅ 已实现 |
|
||||||
|
| 推送 ingress 到云端 | ✅ 已实现 |
|
||||||
|
| 自动创建 CNAME DNS | ✅ 已实现 |
|
||||||
|
| 二进制下载与版本检查 | ✅ 已实现(多镜像、大小与可执行校验) |
|
||||||
|
| 进程托管、启停、重启、日志文件 | ✅ 已实现 |
|
||||||
|
| metrics 端口发现、`/ready`、`ha_connections`、QUIC 带宽/RTT | ✅ 已实现(基础) |
|
||||||
|
| 全局托管开关 | ✅ 已实现 |
|
||||||
|
| auto_start | ✅ 已实现 |
|
||||||
|
| 前端页面与弹窗 | ✅ 已实现 |
|
||||||
|
| 云端隧道列表同步 / 导入已有隧道 | ❌ 未实现 |
|
||||||
|
| 端点级 CRUD(path / 协议 / 多服务) | ❌ 未实现 |
|
||||||
|
| 从云端拉取 ingress(pull) | ❌ 未实现 |
|
||||||
|
| 多副本 / 零停机更新 | ❌ 未实现 |
|
||||||
|
| 快速隧道 | ❌ 未实现 |
|
||||||
|
| 账号模式(cert.pem) | ❌ 未实现 |
|
||||||
|
| 实时日志流(WebSocket/SSE) | ❌ 未实现 |
|
||||||
|
| 诊断日志、网络预检、隧道 info | ❌ 未实现 |
|
||||||
|
| 接入 Fonu monitor / 通知 / 状态页 | ❌ 未实现 |
|
||||||
|
| cloudflared 自动更新 | ❌ 未实现 |
|
||||||
|
| 自定义镜像管理、下载进度 | ❌ 未实现 |
|
||||||
|
| Origin 高级参数 UI | ❌ 未实现 |
|
||||||
|
| 令牌轮换、权限审计 | ❌ 未实现 |
|
||||||
|
| 私有网络 / WARP routing / Access / 负载均衡 | ❌ 未实现 |
|
||||||
|
| 边缘 IP 优选、post-quantum、region/tag | ❌ 未实现 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、差距与机会
|
||||||
|
|
||||||
|
### 4.1 必须补齐(否则模块不完整)
|
||||||
|
|
||||||
|
1. **云端隧道列表与导入**:目前 Fonu 只能管理自己创建的隧道,已有隧道无法纳管。
|
||||||
|
2. **端点级 CRUD**:现在是整体覆盖 ingress,无法单独增删改一条 hostname/path。
|
||||||
|
3. **云端配置拉取与漂移检测**:云端被手动修改后,Fonu 无法发现。
|
||||||
|
4. **健康接入 Fonu 体系**:隧道状态没有进入 monitor、通知、状态页、可用率。
|
||||||
|
5. **实时日志**:轮询文件在故障排查时体验差。
|
||||||
|
6. **诊断能力**:缺 `tunnel info`、`tunnel diag`、网络预检、端口要求检查。
|
||||||
|
|
||||||
|
### 4.2 高价值增强
|
||||||
|
|
||||||
|
1. 快速隧道(临时演示、开发调试)
|
||||||
|
2. 多副本与零停机更新
|
||||||
|
3. cloudflared 自动更新与版本策略
|
||||||
|
4. 令牌轮换
|
||||||
|
5. Origin 高级参数(HTTPS 回源、自签证书、Host 头)
|
||||||
|
6. 自定义镜像与下载进度
|
||||||
|
7. 审计日志(谁在何时修改了隧道、端点、DNS)
|
||||||
|
|
||||||
|
### 4.3 远期能力
|
||||||
|
|
||||||
|
1. 账号模式(cert.pem)与多账号管理
|
||||||
|
2. 私有网络 / WARP routing / virtual network
|
||||||
|
3. Cloudflare Access / Service Auth 集成
|
||||||
|
4. 负载均衡集成
|
||||||
|
5. 边缘 IP 优选
|
||||||
|
6. Workers VPC / Hyperdrive / Spectrum 等生态集成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、全量功能规划
|
||||||
|
|
||||||
|
### P0:模块完整性(先补齐)
|
||||||
|
|
||||||
|
**P0-1 云端隧道同步与导入**
|
||||||
|
|
||||||
|
- 目标:列出 Cloudflare 账号下的全部隧道,把未纳管的隧道导入 Fonu。
|
||||||
|
- 实现:`GET /accounts/{id}/cfd_tunnel` 列表;导入时读取 token、写 `creds.json`/`config.yml`、落库。
|
||||||
|
- 验收:账号下已有隧道能在页面看到并导入;重复导入不产生重复记录。
|
||||||
|
|
||||||
|
**P0-2 端点级 CRUD 与 ingress 校验**
|
||||||
|
|
||||||
|
- 目标:不再整体覆盖 ingress,支持单条 hostname/path/service 的增删改。
|
||||||
|
- 实现:本地 `config.yml` 解析与增量修改;云端 `PUT configurations` 同步;`cloudflared tunnel ingress validate` 与 `ingress rule <url>` 自检。
|
||||||
|
- 验收:新增、修改、删除单个端点后本地与云端一致;非法规则被拒绝。
|
||||||
|
|
||||||
|
**P0-3 云端配置拉取与漂移检测**
|
||||||
|
|
||||||
|
- 目标:云端被手动修改后,Fonu 能发现差异并选择拉取或覆盖。
|
||||||
|
- 实现:`GET /accounts/{id}/cfd_tunnel/{tunnel}/configurations`;与本地 config 做 diff;提供「拉取到本地」「推送到云端」。
|
||||||
|
- 验收:diff 可见;两个方向都能执行并留痕。
|
||||||
|
|
||||||
|
**P0-4 健康接入 Fonu 体系**
|
||||||
|
|
||||||
|
- 目标:隧道断连、恢复进入现有 monitor、通知、状态页与可用率。
|
||||||
|
- 实现:新增 cloudflared 的 `monitor.Source`;`/ready` + `ha_connections` 判活;断连/恢复事件复用 notify;状态页增加隧道条目。
|
||||||
|
- 验收:停掉隧道后产生告警;恢复后产生恢复通知;状态页可见。
|
||||||
|
|
||||||
|
**P0-5 实时日志(SSE)**
|
||||||
|
|
||||||
|
- 目标:日志页实时滚动,替代轮询。
|
||||||
|
- 实现:Fonu 现有日志 SSE 模式复用;cloudflared 日志文件 tail + 增量推送;保留轮询回退。
|
||||||
|
- 验收:页面实时看到新日志;切换页面/重连不丢关键行。
|
||||||
|
|
||||||
|
**P0-6 诊断能力**
|
||||||
|
|
||||||
|
- 目标:一键完成常见故障排查。
|
||||||
|
- 实现:`cloudflared tunnel info <name>`、`cloudflared tunnel diag`、`cloudflared tunnel ingress validate`、网络预检(region1/region2、7844、443)、端口与系统要求检查。
|
||||||
|
- 验收:诊断页给出结构化结果与建议,不只是原始输出。
|
||||||
|
|
||||||
|
### P1:运维与安全
|
||||||
|
|
||||||
|
**P1-1 快速隧道**
|
||||||
|
|
||||||
|
- 目标:临时把本地端口发布到 `trycloudflare.com`,用于演示与开发。
|
||||||
|
- 实现:`--url` 启动、日志解析公共 URL、进程随 Fonu 生命周期、默认关闭。
|
||||||
|
- 边界:明确标注不保证 SLA、URL 每次变化。
|
||||||
|
|
||||||
|
**P1-2 多副本与零停机更新**
|
||||||
|
|
||||||
|
- 目标:同一隧道支持多个 connector,更新时不中断。
|
||||||
|
- 实现:replica 配置与进程管理;逐个重启;`tunnel info` 确认新连接注册后再停旧实例。
|
||||||
|
- 验收:更新过程中隧道可用率不下降。
|
||||||
|
|
||||||
|
**P1-3 cloudflared 自动更新与版本策略**
|
||||||
|
|
||||||
|
- 目标:可检查新版本、按策略升级、支持回滚。
|
||||||
|
- 实现:官方 Release 检查;保留旧版本目录;升级前校验;失败回滚。
|
||||||
|
- 验收:升级成功可回滚;版本信息可追溯。
|
||||||
|
|
||||||
|
**P1-4 令牌轮换**
|
||||||
|
|
||||||
|
- 目标:按官方 `tunnel token` 支持重新签发 token。
|
||||||
|
- 实现:`GET /accounts/{id}/cfd_tunnel/{tunnel}/token`;轮换后更新 `creds.json` 并重启连接器;双凭据过渡窗口。
|
||||||
|
- 验收:轮换过程不中断或只短暂闪断。
|
||||||
|
|
||||||
|
**P1-5 Origin 高级参数**
|
||||||
|
|
||||||
|
- 目标:支持 HTTPS 回源、自签证书、Host 头等官方 origin 参数。
|
||||||
|
- 实现:`originRequest` 的 `originServerName`、`matchSNItoHost`、`caPool`、`noTLSVerify`、`httpHostHeader`、`connectTimeout`、`keepAliveTimeout` 等。
|
||||||
|
- 验收:HTTPS 回源与自签证书场景可用;参数写入本地与云端配置。
|
||||||
|
|
||||||
|
**P1-6 审计日志**
|
||||||
|
|
||||||
|
- 目标:记录谁在何时修改了隧道、端点、DNS、令牌与全局开关。
|
||||||
|
- 实现:复用 Fonu 日志/审计基础设施;操作前记录旧值、操作后记录新值。
|
||||||
|
- 验收:关键变更可查询、可导出。
|
||||||
|
|
||||||
|
### P2:体验与增强
|
||||||
|
|
||||||
|
**P2-1 自定义镜像与下载进度**
|
||||||
|
|
||||||
|
- 目标:镜像可增删改测,下载有进度。
|
||||||
|
- 实现:镜像表(URL 模板)+ 连通性测试;SSE 推送下载进度。
|
||||||
|
|
||||||
|
**P2-2 指标图表与历史**
|
||||||
|
|
||||||
|
- 目标:展示隧道延迟、带宽、连接数、可用率历史。
|
||||||
|
- 实现:复用 `metrics_samples`;前端图表;区分 QUIC/HTTP2 可用指标。
|
||||||
|
|
||||||
|
**P2-3 配置版本、diff 与回滚**
|
||||||
|
|
||||||
|
- 目标:隧道 `config.yml` 与云端 ingress 的版本化。
|
||||||
|
- 实现:复用 `fsutil.BackupVersioned`;变更前留版本;支持 diff 与回滚。
|
||||||
|
|
||||||
|
**P2-4 日志导出与清理**
|
||||||
|
|
||||||
|
- 目标:导出、清理单隧道日志;限制日志体积。
|
||||||
|
- 实现:日志文件轮转、下载、清空;保留期设置。
|
||||||
|
|
||||||
|
**P2-5 系统要求与容量检查**
|
||||||
|
|
||||||
|
- 目标:按官方要求检查 ulimit、出站 7844/443、系统资源。
|
||||||
|
- 实现:启动前检查与页面提示;不做自动修改系统参数。
|
||||||
|
|
||||||
|
**P2-6 网络调优扩展**
|
||||||
|
|
||||||
|
- 目标:暴露官方 run parameters。
|
||||||
|
- 实现:`post-quantum`、`region`、`tag`、`dns-resolver-addrs`、`edge-bind-address`、`retries`、`grace-period`、`loglevel`、`logfile`、`pidfile`。
|
||||||
|
|
||||||
|
### P3:平台化
|
||||||
|
|
||||||
|
**P3-1 账号模式(cert.pem)与多账号管理**
|
||||||
|
|
||||||
|
- 支持 `cloudflared tunnel login`、cert.pem 导入、多账号切换、账号下隧道同步。
|
||||||
|
|
||||||
|
**P3-2 私有网络(WARP routing / CIDR / virtual network)**
|
||||||
|
|
||||||
|
- 支持 `warp-routing: enabled: true`、CIDR route、virtual network、私有主机名路由。
|
||||||
|
|
||||||
|
**P3-3 Access / Service Auth**
|
||||||
|
|
||||||
|
- 只做诊断与提示,或在用户显式配置后生成 Access 应用;不代配高风险策略。
|
||||||
|
|
||||||
|
**P3-4 负载均衡集成**
|
||||||
|
|
||||||
|
- 公开/私有负载均衡池与隧道关联;只做只读展示与跳转。
|
||||||
|
|
||||||
|
**P3-5 边缘 IP 优选**
|
||||||
|
|
||||||
|
- 探测官方 IPv4 段的 TCP 7844 延迟,生成两个 region 的优选建议;页面展示,不代写 hosts。
|
||||||
|
|
||||||
|
**P3-6 生态集成**
|
||||||
|
|
||||||
|
- Workers VPC、Hyperdrive、Spectrum、Keyless SSL、Data Localization 等按需评估。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、推荐实施顺序
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 预计 | 完成标志 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 阶段 1 | P0-1 ~ P0-6 | 1-2 周 | 云端同步、端点 CRUD、漂移检测、健康接入、实时日志、诊断全部可用 |
|
||||||
|
| 阶段 2 | P1-1 ~ P1-6 | 2-3 周 | 快速隧道、多副本、自动更新、令牌轮换、Origin 高级参数、审计 |
|
||||||
|
| 阶段 3 | P2-1 ~ P2-6 | 2 周 | 镜像、图表、版本回滚、日志治理、系统检查、网络调优扩展 |
|
||||||
|
| 阶段 4 | P3-1 ~ P3-6 | 按需 | 账号模式、私有网络、Access、负载均衡、边缘优选、生态集成 |
|
||||||
|
|
||||||
|
建议先做阶段 1,因为它直接补齐「已经有隧道但无法纳管」「云端被改无法发现」「故障无法定位」这三类现实问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、数据模型与 API 变更
|
||||||
|
|
||||||
|
### 7.1 数据模型
|
||||||
|
|
||||||
|
- `cf_tunnels`:增加 `source`(created/imported)、`cloud_status`、`connector_count`、`last_sync_at`。
|
||||||
|
- 新增 `cf_tunnel_endpoints`:`tunnel_id`、`hostname`、`path`、`service`、`protocol`、`origin_json`。
|
||||||
|
- 新增 `cf_tunnel_events`:健康事件、审计事件、同步事件。
|
||||||
|
- `cf_settings`:扩展 `enabled`、`auto_update`、`update_channel`、`mirror`。
|
||||||
|
|
||||||
|
### 7.2 API
|
||||||
|
|
||||||
|
- `GET /api/cloudflare/cloud-tunnels`:列出云端隧道
|
||||||
|
- `POST /api/cloudflare/cloud-tunnels/{id}/import`:导入
|
||||||
|
- `GET/POST/PUT/DELETE /api/cloudflare/tunnels/{id}/endpoints`:端点 CRUD
|
||||||
|
- `POST /api/cloudflare/tunnels/{id}/pull`:从云端拉取
|
||||||
|
- `GET /api/cloudflare/tunnels/{id}/drift`:漂移检测
|
||||||
|
- `GET /api/cloudflare/tunnels/{id}/logs/stream`:实时日志
|
||||||
|
- `GET /api/cloudflare/tunnels/{id}/diagnostics`:诊断
|
||||||
|
- `POST /api/cloudflare/tunnels/{id}/token/rotate`:令牌轮换
|
||||||
|
- `GET/POST /api/cloudflare/mirrors`:镜像管理
|
||||||
|
|
||||||
|
### 7.3 前端
|
||||||
|
|
||||||
|
- 隧道详情增加端点列表、diff、诊断、实时日志、指标图表。
|
||||||
|
- 新增「云端隧道」页签或导入入口。
|
||||||
|
- 应用配置增加自动更新策略、镜像管理、系统要求检查结果。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、测试与验收
|
||||||
|
|
||||||
|
**单元测试**
|
||||||
|
|
||||||
|
- ingress 增删改与三条不变式
|
||||||
|
- 云端配置 diff
|
||||||
|
- token 解码、轮换
|
||||||
|
- metrics 解析与降级
|
||||||
|
- 诊断结果解析
|
||||||
|
|
||||||
|
**集成测试**
|
||||||
|
|
||||||
|
- 创建 → 启动 → 健康 → 停止 → 删除全流程
|
||||||
|
- 云端修改后用 diff 发现并拉取
|
||||||
|
- 多副本零停机更新
|
||||||
|
- 自动 DNS 创建/更新
|
||||||
|
|
||||||
|
**前端**
|
||||||
|
|
||||||
|
- 端点编辑、diff 视图、诊断面板、实时日志
|
||||||
|
|
||||||
|
**真机矩阵**
|
||||||
|
|
||||||
|
- Windows、Linux、Docker 容器内、host 网络
|
||||||
|
- 代理环境、QUIC 被拦、7443/7844 被占用、无 IPv6
|
||||||
|
- Cloudflare Access 与无 Access
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、风险与边界
|
||||||
|
|
||||||
|
- **官方接口演进**:Cloudflare API 与 cloudflared 参数会变;所有参数需要可配置 + 默认值 + 可恢复默认。
|
||||||
|
- **权限边界**:API Token 权限不足时只提示,不自动扩权。
|
||||||
|
- **网络边界**:只做本机到 CF 边缘的调优;访客侧不可控。
|
||||||
|
- **合规边界**:大文件/视频分发不在推荐用法内。
|
||||||
|
- **安全边界**:不代配 Access 高风险策略;不写 hosts;不改系统 ulimit/防火墙。
|
||||||
|
- **运行边界**:Fonu 只托管自己启动的 cloudflared 进程;不接管外部 systemd 服务。
|
||||||
|
- **数据边界**:凭据加密存储;预览脱敏;审计日志不记录明文 token。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、结论
|
||||||
|
|
||||||
|
Fonu 的 Cloudflare 隧道模块已经具备「创建、运行、托管、DNS」的闭环。下一阶段的目标是把它从「能用」推进到「可长期运维」:
|
||||||
|
|
||||||
|
1. 先补齐云端同步、端点 CRUD、漂移检测、健康接入、实时日志、诊断(阶段 1)。
|
||||||
|
2. 再补快速隧道、多副本、自动更新、令牌轮换、Origin 高级参数、审计(阶段 2)。
|
||||||
|
3. 最后做镜像、图表、版本回滚、日志治理、系统检查与网络调优扩展(阶段 3)。
|
||||||
|
4. 私有网络、Access、负载均衡、边缘优选等平台化能力按需推进(阶段 4)。
|
||||||
|
|
||||||
|
这样 Fonu 才能在官方能力、参考项目功能与自身产品体系之间取得平衡,而不是停留在「进程开关 + 配置预览」。
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
# Cloudflare 隧道:Fonu 适配版功能计划
|
||||||
|
|
||||||
|
> 日期:2026-09-30
|
||||||
|
> 定位:`cloudflared-feature-plan.md` 是「官方 + 参考项目的功能全集」;本文是**按 Fonu 产品定位筛选后的适配版计划**,实施以本文为准。
|
||||||
|
> 核心原则:Fonu 不是 cloudflared 管理面板,而是「NAS / 家庭服务器的公网访问管理面板」。Cloudflare 隧道只是它的第二条出口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、先明确 Fonu 是什么
|
||||||
|
|
||||||
|
### 1.1 产品定位
|
||||||
|
|
||||||
|
Fonu 面对的是家庭 NAS、小主机、家用服务器的用户,核心价值是:
|
||||||
|
|
||||||
|
- 用域名访问内网服务,而不是记 IP 和端口;
|
||||||
|
- 自动处理证书、反代、穿透、DDNS;
|
||||||
|
- 尽量少配置、少暴露、少维护。
|
||||||
|
|
||||||
|
Fonu 已经有三条主线:
|
||||||
|
|
||||||
|
1. **反向代理**:域名 → 本机 Nginx → 内网服务;
|
||||||
|
2. **FRP 穿透**:本机 frpc → VPS frps → 域名;
|
||||||
|
3. **公网 Agent**:管理 VPS 上的 frps 与 Nginx。
|
||||||
|
|
||||||
|
Cloudflare 隧道的加入,是为了解决一类现有链路解决不了的人:
|
||||||
|
|
||||||
|
> 没有公网 IP、也不想长期租 VPS,但想让内网 Web 服务通过域名安全访问。
|
||||||
|
|
||||||
|
### 1.2 因此有三条不可动摇的原则
|
||||||
|
|
||||||
|
1. **CF 隧道是第二条出口,不是替代 FRP**:TCP/UDP、自主可控、大流量仍走 FRP。
|
||||||
|
2. **规则同源**:用户维护的是「服务发布规则」,不是「隧道 ingress」。CF 只是一个出口选项。
|
||||||
|
3. **融入 Fonu 体系**:监控、通知、状态页、日志、备份、升级、审计都复用现有模块,不另造一套面板。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、目标用户与场景
|
||||||
|
|
||||||
|
### 2.1 适合
|
||||||
|
|
||||||
|
- 家宽没有公网 IPv4,或运营商封 80/443;
|
||||||
|
- 有域名托管在 Cloudflare,或愿意把域名 NS 切到 Cloudflare;
|
||||||
|
- 主要暴露 HTTP/HTTPS 服务:NAS 面板、PhotoPrism、Jellyfin 管理页、Home Assistant、qBittorrent WebUI、OpenWrt 面板等;
|
||||||
|
- 希望「一次配置好,之后自动跟随反代规则」。
|
||||||
|
|
||||||
|
### 2.2 不适合
|
||||||
|
|
||||||
|
- 需要裸 TCP/UDP 端口(游戏服、非 HTTP 协议);
|
||||||
|
- 需要完全自主可控、流量不经过第三方;
|
||||||
|
- 大文件/视频持续分发(Cloudflare 非 Enterprise 条款限制);
|
||||||
|
- 企业级 Zero Trust 私有网络、VPN 替代。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、借鉴与不借鉴
|
||||||
|
|
||||||
|
参考项目 `Tunnel-Cloudflared-Manager 1.0.1` 是一个独立的 cloudflared 面板。以下判断按「是否符合 Fonu 产品形态」给出:
|
||||||
|
|
||||||
|
### 3.1 值得借鉴
|
||||||
|
|
||||||
|
| 参考项目做法 | Fonu 该怎么用 |
|
||||||
|
| --- | --- |
|
||||||
|
| token 解码成本地凭据 | 已用 Account ID + API Token 替代;保留解码能力用于导入 |
|
||||||
|
| metrics 端口从日志发现 | 已实现,继续沿用 |
|
||||||
|
| ingress 三条不变式 | 已实现,继续沿用 |
|
||||||
|
| 云端 / 本地配置互相同步 | 值得做,但要简化为「diff → 拉取或覆盖」 |
|
||||||
|
| 端点级增删改 | 值得做,但必须挂在 Fonu 的「服务发布规则」下 |
|
||||||
|
| 快速隧道 | 只做「临时演示」,不做正式功能 |
|
||||||
|
| 网络诊断 | 值得做,做成 Fonu 的一键诊断 |
|
||||||
|
| 镜像与下载进度 | 值得做,但复用 Fonu 现有镜像表,不做用户自定义镜像管理 |
|
||||||
|
| Windows 隐藏控制台 | 已实现,继续沿用 |
|
||||||
|
|
||||||
|
### 3.2 不借鉴
|
||||||
|
|
||||||
|
| 参考项目做法 | 不借鉴的理由 |
|
||||||
|
| --- | --- |
|
||||||
|
| 自带 JWT + 账号体系 | Fonu 已有会话 + API Token,重复建设 |
|
||||||
|
| 独立主题、多语言 | 与 Fonu 全局设置重复 |
|
||||||
|
| 多账号 cert.pem 登录 | 与 Fonu 的 Account ID + API Token 模型冲突;远期若有需求再评估 |
|
||||||
|
| 私有网络 / WARP routing / virtual network | 面向企业 Zero Trust,NAS 用户不需要 |
|
||||||
|
| Kubernetes / Ansible / Terraform / 云厂商部署指南 | 不是 Fonu 的交付形态 |
|
||||||
|
| 负载均衡 | 家用单机场景不需要 |
|
||||||
|
| Workers VPC / Hyperdrive / Spectrum / Keyless SSL | 超出 Fonu 定位 |
|
||||||
|
| WebSocket 实时日志 | Fonu 已有 SSE 日志模式,不为 CF 单独引入 WSS |
|
||||||
|
| 自定义镜像管理(增删改测) | Fonu 只需内置镜像表 + 自定义 URL,不需要用户维护镜像源 |
|
||||||
|
| 独立 systemd / Windows service 安装 | Fonu 自己托管进程,不接管外部服务 |
|
||||||
|
| 快速隧道作为正式功能 | 官方明确不保证 SLA,只做演示入口 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、符合 Fonu 的功能全景
|
||||||
|
|
||||||
|
按「用户价值」而不是「参考项目有什么」重新分组。
|
||||||
|
|
||||||
|
### 4.1 出口抽象(Fonu 的核心差异化)
|
||||||
|
|
||||||
|
**F-1 服务发布规则增加「出口」字段**
|
||||||
|
|
||||||
|
- 一条规则描述「域名 → 内网服务」,出口可选:本机 Nginx / FRP / Cloudflare 隧道。
|
||||||
|
- 现有反向代理规则作为基础,新增出口选择,不新建第二套规则模型。
|
||||||
|
- 验收:同一条规则可以切换出口,切换后域名与服务保持不变。
|
||||||
|
|
||||||
|
**F-2 CF 隧道 ingress 自动跟随反代规则**
|
||||||
|
|
||||||
|
- 反代规则新增/修改/删除域名时,自动同步到该隧道的 ingress。
|
||||||
|
- 只同步 HTTP/HTTPS 规则;TCP/UDP 规则在出口选择里标为「Cloudflare 不支持」。
|
||||||
|
- 验收:改一条反代规则,CF 隧道配置与云端 ingress 自动更新,无需手工编辑。
|
||||||
|
|
||||||
|
**F-3 一条规则可以同时走 FRP 与 CF**
|
||||||
|
|
||||||
|
- 出口支持多选(FRP + Cloudflare),用于灰度或双保险。
|
||||||
|
- 验收:同一域名可同时存在 FRP 与 CF 入口,互不影响。
|
||||||
|
|
||||||
|
### 4.2 自动化(延续 Fonu「自动为主」)
|
||||||
|
|
||||||
|
**F-4 一次配置,后续全自动**(大部分已完成)
|
||||||
|
|
||||||
|
- Account ID + API Token 一次保存;
|
||||||
|
- 自动创建隧道、写 `creds.json` / `config.yml`、推送 ingress、创建 CNAME;
|
||||||
|
- 自动启动、自动重连、崩溃重启。
|
||||||
|
|
||||||
|
**F-5 反代规则变化自动同步**
|
||||||
|
|
||||||
|
- 规则增删改 → 生成新 ingress → 本地与云端同步 → 需要时重载配置。
|
||||||
|
- 验收:用户不需要理解 ingress,也不需要手工点同步。
|
||||||
|
|
||||||
|
**F-6 自动 DNS 管理**
|
||||||
|
|
||||||
|
- 创建域名时自动 CNAME 到 `<tunnel-id>.cfargotunnel.com`;
|
||||||
|
- 删除域名或隧道时,删除「指向该隧道」的 DNS 记录(只删自己创建的)。
|
||||||
|
- 验收:域名增删与 DNS 一致,不误删用户其他记录。
|
||||||
|
|
||||||
|
**F-7 自动健康与自愈**
|
||||||
|
|
||||||
|
- 进程退出自动重启;
|
||||||
|
- `/ready` 与 `ha_connections` 判活;
|
||||||
|
- 连续失败进入 Fonu 告警;恢复后发通知。
|
||||||
|
|
||||||
|
### 4.3 融入 Fonu 现有体系
|
||||||
|
|
||||||
|
**F-8 监控与可用率**
|
||||||
|
|
||||||
|
- 新增 cloudflared 的 `monitor.Source`;
|
||||||
|
- 隧道健康进入 Fonu 现有可用率与历史指标。
|
||||||
|
|
||||||
|
**F-9 通知**
|
||||||
|
|
||||||
|
- 隧道断连、恢复、证书/DNS 失败复用 `notify`;
|
||||||
|
- 通知文案面向 NAS 用户,而不是 cloudflared 术语。
|
||||||
|
|
||||||
|
**F-10 状态页**
|
||||||
|
|
||||||
|
- 公开状态页增加 CF 隧道域名与可用率;
|
||||||
|
- 只展示域名与状态,不泄露隧道 ID、本机地址。
|
||||||
|
|
||||||
|
**F-11 日志中心**
|
||||||
|
|
||||||
|
- CF 隧道日志进入 Fonu 日志页;
|
||||||
|
- 复用 SSE 增量推送,不单独做 WebSocket。
|
||||||
|
|
||||||
|
**F-12 仪表盘**
|
||||||
|
|
||||||
|
- 仪表盘增加「Cloudflare 隧道」卡片:隧道数、在线数、最近错误。
|
||||||
|
|
||||||
|
**F-13 备份与迁移**
|
||||||
|
|
||||||
|
- `cf_tunnels` 与凭据纳入 Fonu 备份/恢复流程;
|
||||||
|
- 恢复后自动重启隧道。
|
||||||
|
|
||||||
|
**F-14 一键升级与版本策略**
|
||||||
|
|
||||||
|
- cloudflared 版本跟随 Fonu 升级流程;
|
||||||
|
- 提供「检查更新 / 更新 cloudflared / 回滚」;
|
||||||
|
- 不要求用户手工替换二进制。
|
||||||
|
|
||||||
|
**F-15 审计**
|
||||||
|
|
||||||
|
- 记录谁在何时修改了服务发布规则、出口、CF 凭据、全局开关;
|
||||||
|
- 复用 Fonu 的日志/审计基础设施。
|
||||||
|
|
||||||
|
### 4.4 NAS 场景体验
|
||||||
|
|
||||||
|
**F-16 Docker 服务发现一键发布**
|
||||||
|
|
||||||
|
- 复用现有 `discovery`,发现本机 Docker 服务;
|
||||||
|
- 选择服务 → 填域名 → 自动创建反代规则 + CF 出口。
|
||||||
|
|
||||||
|
**F-17 常用 NAS 应用预设**
|
||||||
|
|
||||||
|
- NAS 面板、PhotoPrism、Jellyfin、Home Assistant、qBittorrent 等端口预设;
|
||||||
|
- 用户只需选应用与域名。
|
||||||
|
|
||||||
|
**F-18 域名与 Zone 选择器**
|
||||||
|
|
||||||
|
- 通过 API Token 列出账号下的 Zone 与可用域名;
|
||||||
|
- 用户从列表选择,而不是手输。
|
||||||
|
|
||||||
|
**F-19 证书提示**
|
||||||
|
|
||||||
|
- 使用 CF 出口时,CF 边缘自动提供 HTTPS;
|
||||||
|
- 页面提示「该域名无需在 Fonu 申请证书」,避免用户重复操作。
|
||||||
|
|
||||||
|
### 4.5 运维与安全
|
||||||
|
|
||||||
|
**F-20 端点级编辑**
|
||||||
|
|
||||||
|
- 在 Fonu 规则详情里编辑 hostname、path、service、协议;
|
||||||
|
- 提供 `ingress validate` 与 `ingress rule <url>` 校验。
|
||||||
|
|
||||||
|
**F-21 云端漂移检测**
|
||||||
|
|
||||||
|
- 定期比较本地 ingress 与云端配置;
|
||||||
|
- 发现漂移后提示「以本地为准覆盖」或「以云端为准拉取」。
|
||||||
|
|
||||||
|
**F-22 一键诊断**
|
||||||
|
|
||||||
|
- `tunnel info`、`tunnel diag`、网络预检(region1/region2、7844、443)、系统要求检查;
|
||||||
|
- 结果转成 Fonu 风格的可读结论与建议。
|
||||||
|
|
||||||
|
**F-23 令牌轮换**
|
||||||
|
|
||||||
|
- 提供「重新签发隧道凭据」入口;
|
||||||
|
- 轮换过程尽量不中断,失败自动回滚。
|
||||||
|
|
||||||
|
**F-24 全局托管开关**(已完成)
|
||||||
|
|
||||||
|
- 一键停止全部隧道,重启不自动拉起;
|
||||||
|
- 开启时只恢复 auto_start 的隧道。
|
||||||
|
|
||||||
|
**F-25 条款与用量提示**
|
||||||
|
|
||||||
|
- 页面常驻说明:不适合大文件/视频分发;
|
||||||
|
- 不采集、不上报用户流量到外部。
|
||||||
|
|
||||||
|
**F-26 Access 保护状态检测(只读)**
|
||||||
|
|
||||||
|
- 检测域名是否启用 Cloudflare Access;
|
||||||
|
- 只提示,不代配策略。
|
||||||
|
|
||||||
|
### 4.6 可选增强
|
||||||
|
|
||||||
|
**F-27 快速隧道(临时演示)**
|
||||||
|
|
||||||
|
- 临时把某个本地端口发到 `trycloudflare.com`;
|
||||||
|
- 明确标注不保证 SLA、URL 每次变化、仅用于测试。
|
||||||
|
|
||||||
|
**F-28 多副本**
|
||||||
|
|
||||||
|
- 高级选项,用于「主备两台设备跑同一条隧道」;
|
||||||
|
- 默认关闭,不打扰普通用户。
|
||||||
|
|
||||||
|
**F-29 边缘 IP 优选**
|
||||||
|
|
||||||
|
- 只影响「本机 → CF 边缘」一段;
|
||||||
|
- 页面展示优选建议,不代写 hosts;
|
||||||
|
- 面向国内链路质量差的用户。
|
||||||
|
|
||||||
|
**F-30 账号模式(cert.pem)**
|
||||||
|
|
||||||
|
- 仅当用户明确需要「多账号 / 团队权限」时提供;
|
||||||
|
- 默认仍推荐 Account ID + API Token。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、明确不做的功能
|
||||||
|
|
||||||
|
| 功能 | 结论 | 理由 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 独立登录、JWT、多用户体系 | 不做 | Fonu 已有认证与 API Token |
|
||||||
|
| 独立主题、多语言 | 不做 | 属于 Fonu 全局设置 |
|
||||||
|
| 私有网络 / WARP routing | 不做(远期评估) | 面向 Zero Trust 企业,NAS 用户不需要 |
|
||||||
|
| 负载均衡 | 不做 | 家用单机场景不需要 |
|
||||||
|
| Kubernetes / Ansible / Terraform 指南 | 不做 | 不是 Fonu 交付形态 |
|
||||||
|
| Workers VPC / Hyperdrive / Spectrum | 不做 | 超范围 |
|
||||||
|
| WebSocket 实时日志 | 不做 | 复用 Fonu SSE |
|
||||||
|
| 自定义镜像管理 | 不做 | 内置镜像表 + 自定义 URL 足够 |
|
||||||
|
| 接管外部 systemd / Windows service | 不做 | Fonu 只托管自己启动的进程 |
|
||||||
|
| 快速隧道作为正式功能 | 不做 | 官方不保证 SLA,只做演示 |
|
||||||
|
| 代配 Access 策略 | 不做 | 高风险,只做只读检测 |
|
||||||
|
| 自动扩权 / 自动改系统防火墙 | 不做 | 保持 Fonu 现有安全边界 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、与现有模块的整合点
|
||||||
|
|
||||||
|
| Fonu 模块 | 整合方式 |
|
||||||
|
| --- | --- |
|
||||||
|
| 反向代理规则 | 增加「出口」字段;CF 隧道从规则派生 |
|
||||||
|
| FRP 多服务端 | 与 CF 出口并列,互不影响 |
|
||||||
|
| 证书管理 | CF 出口域名提示无需本地证书 |
|
||||||
|
| DDNS | CF 橙色云代理后本地 IP 变化不再影响访问,可提示用户 |
|
||||||
|
| 监控 / 可用率 | 新增 cloudflared Source |
|
||||||
|
| 通知 | 断连、恢复、DNS 失败复用 |
|
||||||
|
| 公开状态页 | 增加 CF 隧道条目 |
|
||||||
|
| 日志中心 | CF 日志进入现有日志页 |
|
||||||
|
| 仪表盘 | 新增 CF 隧道卡片 |
|
||||||
|
| Docker 服务发现 | 一键发布到 CF |
|
||||||
|
| 备份 / 恢复 | cf_tunnels 与凭据纳入 |
|
||||||
|
| 一键升级 | cloudflared 版本更新纳入 |
|
||||||
|
| 审计 | 规则、出口、凭据变更记录 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、数据模型与 API 变更
|
||||||
|
|
||||||
|
### 7.1 数据模型
|
||||||
|
|
||||||
|
- `frp_proxies`(或服务发布规则表)增加 `exit` 字段:`local` / `frp` / `cloudflare` / 多选;
|
||||||
|
- `cf_tunnels` 增加 `rule_id`(可选,关联服务发布规则)、`source`、`last_sync_at`;
|
||||||
|
- 新增 `cf_endpoint_drift` 或复用现有漂移检测表;
|
||||||
|
- `cf_settings` 增加 `auto_update`、`update_channel`。
|
||||||
|
|
||||||
|
### 7.2 API
|
||||||
|
|
||||||
|
- `PUT /api/proxies/{id}/exit`:切换出口;
|
||||||
|
- `GET /api/cloudflare/tunnels/{id}/drift`:漂移检测;
|
||||||
|
- `POST /api/cloudflare/tunnels/{id}/pull`:从云端拉取;
|
||||||
|
- `POST /api/cloudflare/tunnels/{id}/diagnostics`:一键诊断;
|
||||||
|
- `GET /api/cloudflare/zones`:Zone / 域名列表;
|
||||||
|
- `POST /api/cloudflare/tunnels/{id}/token/rotate`:令牌轮换;
|
||||||
|
- `GET /api/cloudflare/tunnels/{id}/logs/stream`:SSE 日志;
|
||||||
|
- `GET /api/discovery/docker`:Docker 服务发现(若尚未提供)。
|
||||||
|
|
||||||
|
### 7.3 前端
|
||||||
|
|
||||||
|
- 反向代理规则表单增加「出口」选择器;
|
||||||
|
- 规则详情增加「Cloudflare 隧道」状态与操作;
|
||||||
|
- CF 页面聚焦「隧道状态 / 诊断 / 日志 / 全局开关」,弱化「手工建隧道」;
|
||||||
|
- 仪表盘、状态页、日志中心增加 CF 条目。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、阶段计划
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 目标 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 阶段 1 | F-1 ~ F-3 出口抽象;F-5 规则同步;F-6 DNS 管理完善 | 让 CF 成为反代规则的一个出口,而不是独立模块 |
|
||||||
|
| 阶段 2 | F-8 ~ F-15 体系整合;F-20 ~ F-22 编辑与诊断 | 让 CF 隧道进入 Fonu 监控、通知、状态页、日志、备份、审计 |
|
||||||
|
| 阶段 3 | F-16 ~ F-19 NAS 体验;F-23 ~ F-26 运维安全 | 一键发布、域名选择、令牌轮换、条款提示、Access 检测 |
|
||||||
|
| 阶段 4 | F-27 ~ F-30 可选增强 | 快速隧道、多副本、边缘优选、账号模式按需 |
|
||||||
|
|
||||||
|
**优先级建议**:阶段 1 是产品形态的关键,先做;阶段 2 决定「能不能长期运维」;阶段 3 决定「好不好用」;阶段 4 是加分项。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、验收标准
|
||||||
|
|
||||||
|
- 用户只维护「域名 → 服务」的规则,出口切换不需要改域名或端口;
|
||||||
|
- 反代规则变化后,CF 隧道与 DNS 自动跟随;
|
||||||
|
- 隧道断连能进 Fonu 告警与状态页;
|
||||||
|
- 一条规则可以同时走 FRP 与 CF,互不影响;
|
||||||
|
- CF 出口域名不需要本地证书;
|
||||||
|
- 备份恢复后隧道能自动恢复;
|
||||||
|
- 所有 CF 凭据加密存储、预览脱敏;
|
||||||
|
- 明确不做的功能不会出现在界面里,避免用户误解。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、结论
|
||||||
|
|
||||||
|
Fonu 需要的不是「另一个 cloudflared 面板」,而是:
|
||||||
|
|
||||||
|
> 把 Cloudflare 隧道做成反向代理规则的一个出口选项,并让监控、通知、状态页、日志、备份、升级、审计自动覆盖它。
|
||||||
|
|
||||||
|
按本文阶段 1-3 实施,Fonu 的 CF 模块会形成与参考项目不同的产品形态:参考项目面向「管理 cloudflared」,Fonu 面向「管理我的服务怎么上公网」。
|
||||||
|
|
||||||
|
本文与 `cloudflared-feature-plan.md` 的关系:
|
||||||
|
|
||||||
|
- `cloudflared-feature-plan.md`:官方 + 参考项目的功能全集,用于查漏;
|
||||||
|
- 本文:按 Fonu 定位筛选后的实施计划,**实施以本文为准**。
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
# Cloudflare 隧道同类项目调研与 Fonu 体验优化建议
|
||||||
|
|
||||||
|
> 日期:2026-09-30
|
||||||
|
> 调研范围:GitHub 上 Cloudflare Tunnel / cloudflared 管理面板、反向代理管理器、自托管访问平台、状态监测与流量检查器。
|
||||||
|
> 目标:不是照搬功能,而是提取适合 Fonu 的自动化方式、页面结构和长期运维体验。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、调研结论摘要
|
||||||
|
|
||||||
|
同类项目大致分成四类:
|
||||||
|
|
||||||
|
1. **cloudflared 单容器 Web UI**
|
||||||
|
- 代表:Cloudflared-web
|
||||||
|
- 重点:快速设置 Token、启停隧道、查看运行状态。
|
||||||
|
2. **Cloudflare 路由与 Access 自动化平台**
|
||||||
|
- 代表:DockFlare
|
||||||
|
- 重点:Docker 标签、期望状态调和、DNS、ingress、Access 策略统一管理。
|
||||||
|
3. **Homelab 反向代理 / 私有访问平台**
|
||||||
|
- 代表:Nginx Proxy Manager、Pangolin、Cosmos Server
|
||||||
|
- 重点:可视化路由、证书、访问控制、资源入口页、审计。
|
||||||
|
4. **监控、状态页与流量调试**
|
||||||
|
- 代表:Uptime Kuma、Tunnel Inspector
|
||||||
|
- 重点:可用率、状态页、事故记录、请求级观测、重放和抓包体验。
|
||||||
|
|
||||||
|
对 Fonu 最重要的启发不是“再加多少按钮”,而是:
|
||||||
|
|
||||||
|
- 用期望状态和自动调和代替手工点同步。
|
||||||
|
- 让规则、隧道、DNS、证书、监控在同一套模型中管理。
|
||||||
|
- 配置页要面向“服务发布”,而不是面向 cloudflared 参数。
|
||||||
|
- 把诊断、修复、日志和状态页做成闭环。
|
||||||
|
- 高级能力可以保留,但默认路径必须非常短。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、代表性项目
|
||||||
|
|
||||||
|
以下 Star 数据为 2026-09-30 GitHub 查询结果,仅作为热度参考。
|
||||||
|
|
||||||
|
| 项目 | 类型 | 技术栈 | Star | 最值得借鉴 |
|
||||||
|
| --- | --- | --- | ---: | --- |
|
||||||
|
| [cloudflare/cloudflared](https://github.com/cloudflare/cloudflared) | 官方客户端 | Go | 15970 | 官方能力边界、TryCloudflare、版本支持策略 |
|
||||||
|
| [DockFlare](https://github.com/ChrispyBacon-dev/DockFlare) | Tunnel + Access 自动化 | Python | 2471 | Docker 标签、期望状态调和、多主 Agent、备份恢复 |
|
||||||
|
| [Nginx Proxy Manager](https://github.com/NginxProxyManager/nginx-proxy-manager) | 反向代理面板 | JavaScript | 高热度 | Host / Streams / Access Lists / SSL / 用户与审计 |
|
||||||
|
| [cloudflare-tunnel-ingress-controller](https://github.com/STRRL/cloudflare-tunnel-ingress-controller) | Tunnel Ingress Controller | Go | 1197 | 声明式路由、Ingress 到 Tunnel/DNS 自动生成 |
|
||||||
|
| [cftunnel](https://github.com/qingchencloud/cftunnel) | 全协议穿透 CLI | Go | 345 | 快速隧道、Cloud + FRP 双引擎、模板、分享、自动更新 |
|
||||||
|
| [Cloudflared-web](https://github.com/WisdomSky/Cloudflared-web) | 极简 Web UI | Vue | 465 | 单容器安装、Token 管理、启停、低学习成本 |
|
||||||
|
| [Tunnel Cloudflared Desktop](https://github.com/maskodingku/Tunnel-Cloudflared-Desktop) | 桌面管理器 | Tauri + Rust | 55 | 多账号、`cert.pem`、双向同步、实时指标、Quick Tunnel |
|
||||||
|
| [Tunnel Inspector](https://github.com/BlockbusterAndy/cloudflare-tunnel-inspector) | 流量检查器 | TypeScript | 6 | 请求/响应捕获、SSE 实时流、搜索筛选、重放、敏感头脱敏 |
|
||||||
|
| [Pangolin](https://github.com/fosrl/pangolin) | 自托管访问平台 | Go / Web | 高热度 | 资源入口页、统一身份、按资源授权、浏览器访问 SSH/RDP/VNC |
|
||||||
|
| [Uptime Kuma](https://github.com/louislam/uptime-kuma) | 监控与状态页 | Vue / Node | 高热度 | 多状态页、事故记录、证书信息、通知渠道、2FA |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、项目逐个分析
|
||||||
|
|
||||||
|
### 3.1 Cloudflared-web
|
||||||
|
|
||||||
|
项目定位:
|
||||||
|
|
||||||
|
- 一个 Docker 镜像同时打包 cloudflared 和简单 Web UI。
|
||||||
|
- 主要面向“只想管理 Token 和启停隧道”的用户。
|
||||||
|
|
||||||
|
核心体验:
|
||||||
|
|
||||||
|
- Docker 启动后直接打开固定端口网页。
|
||||||
|
- 填写 Token,一键启停。
|
||||||
|
- 支持 BASIC AUTH。
|
||||||
|
- 支持环境变量配置协议、边缘 IP、metrics、重试等。
|
||||||
|
- 数据持久化到 `/config`。
|
||||||
|
|
||||||
|
对 Fonu 的启示:
|
||||||
|
|
||||||
|
- 初次配置应该更短:Account ID、API Token、Zone、Tunnel 四步内完成。
|
||||||
|
- 所有高级网络参数应该折叠到“高级选项”,默认值直接可用。
|
||||||
|
- 当前 Fonu 已覆盖它的主要能力,并且多了 API 托管、DNS、规则同步和监控。
|
||||||
|
|
||||||
|
不建议照搬:
|
||||||
|
|
||||||
|
- 只有 Token 和启停,模型太薄。
|
||||||
|
- 不适合 Fonu 的“服务发布规则”产品定位。
|
||||||
|
|
||||||
|
### 3.2 DockFlare
|
||||||
|
|
||||||
|
项目定位:
|
||||||
|
|
||||||
|
- 面向多变自托管环境的 Cloudflare Tunnel / DNS / Access 控制平面。
|
||||||
|
- 强调“定义一次意图,然后持续调和”。
|
||||||
|
|
||||||
|
核心能力:
|
||||||
|
|
||||||
|
- Docker 标签自动发现服务。
|
||||||
|
- Web UI 手工规则。
|
||||||
|
- 远程 Agent 多主机管理。
|
||||||
|
- Tunnel ingress、DNS、Access 统一调和。
|
||||||
|
- Access group 和可复用策略。
|
||||||
|
- 加密配置、运行时状态和数据的备份恢复。
|
||||||
|
- 多语言 UI。
|
||||||
|
|
||||||
|
对 Fonu 的启示:
|
||||||
|
|
||||||
|
- 当前 Fonu 已经有“反向代理规则派生 Cloudflare ingress”,方向正确。
|
||||||
|
- 下一步应补“期望状态”和“调和结果”:
|
||||||
|
- 本地期望配置。
|
||||||
|
- Cloudflare 实际配置。
|
||||||
|
- 最后同步时间。
|
||||||
|
- 漂移原因。
|
||||||
|
- 一键修复。
|
||||||
|
- Docker 标签自动发布非常适合 NAS。
|
||||||
|
- 多主机 Agent 适合进阶用户,但不应优先于单机体验。
|
||||||
|
- Access 自动化风险较高,Fonu 暂时保持只读更稳。
|
||||||
|
|
||||||
|
### 3.3 Tunnel Ingress Controller
|
||||||
|
|
||||||
|
项目定位:
|
||||||
|
|
||||||
|
- 把 Kubernetes Ingress 转成 Cloudflare Tunnel 和 DNS 配置。
|
||||||
|
|
||||||
|
核心思想:
|
||||||
|
|
||||||
|
- 用户只维护 Kubernetes 原生声明。
|
||||||
|
- Controller 持续把声明同步到 Cloudflare。
|
||||||
|
- 不要求用户手工编辑 cloudflared ingress。
|
||||||
|
|
||||||
|
对 Fonu 的启示:
|
||||||
|
|
||||||
|
- Fonu 的“反向代理规则就是服务入口声明”是同一思路的非 K8s 版本。
|
||||||
|
- 无需引入 K8s,但应借鉴:
|
||||||
|
- 声明与运行状态分离。
|
||||||
|
- 调和结果可见。
|
||||||
|
- 失败原因可追踪。
|
||||||
|
- 不应让用户理解底层 ingress 文件。
|
||||||
|
|
||||||
|
### 3.4 Tunnel Cloudflared Desktop
|
||||||
|
|
||||||
|
项目定位:
|
||||||
|
|
||||||
|
- 面向桌面用户的 Cloudflare Tunnel 管理器。
|
||||||
|
|
||||||
|
核心能力:
|
||||||
|
|
||||||
|
- Quick Tunnel。
|
||||||
|
- 多账号 `cert.pem`。
|
||||||
|
- 本地与 Cloudflare Dashboard 双向同步。
|
||||||
|
- 实时指标。
|
||||||
|
- 实时日志。
|
||||||
|
- 自动下载 cloudflared。
|
||||||
|
|
||||||
|
对 Fonu 的启示:
|
||||||
|
|
||||||
|
- Quick Tunnel 适合“临时演示”,不建议作为正式发布方式。
|
||||||
|
- 双向同步是 Fonu 当前明显缺口:
|
||||||
|
- 当前支持检测漂移并“本地覆盖云端”。
|
||||||
|
- 缺少“云端覆盖本地”。
|
||||||
|
- 多账号模式对 Fonu 属于进阶能力,可放到远期。
|
||||||
|
- 实时日志和指标已具备,继续加强即可。
|
||||||
|
|
||||||
|
### 3.5 cftunnel
|
||||||
|
|
||||||
|
项目定位:
|
||||||
|
|
||||||
|
- 全协议内网穿透 CLI。
|
||||||
|
- Cloud 模式使用 Cloudflare Tunnel。
|
||||||
|
- Relay 模式使用自建 frps。
|
||||||
|
|
||||||
|
核心体验:
|
||||||
|
|
||||||
|
- `quick <port>` 生成临时公网地址。
|
||||||
|
- 可加 Basic Auth。
|
||||||
|
- 自动 DNS。
|
||||||
|
- 预设模板。
|
||||||
|
- 一键分享二维码、地址、Telegram。
|
||||||
|
- 自动更新。
|
||||||
|
|
||||||
|
对 Fonu 的启示:
|
||||||
|
|
||||||
|
- “快速隧道”可以作为临时验证入口,但必须标明不保证 SLA。
|
||||||
|
- 场景模板非常值得借鉴:
|
||||||
|
- Webhook
|
||||||
|
- Home Assistant
|
||||||
|
- 面板
|
||||||
|
- 相册
|
||||||
|
- Jellyfin 管理页
|
||||||
|
- “复制访问地址 / 二维码分享”适合手机用户。
|
||||||
|
- Cloud + FRP 双引擎的概念与 Fonu 的出口抽象一致。
|
||||||
|
|
||||||
|
### 3.6 Nginx Proxy Manager
|
||||||
|
|
||||||
|
项目定位:
|
||||||
|
|
||||||
|
- 最经典的自托管反向代理 Web 面板。
|
||||||
|
|
||||||
|
核心体验:
|
||||||
|
|
||||||
|
- Hosts、Streams、Access Lists、SSL Certificates、Users 分栏。
|
||||||
|
- 每条代理规则有独立状态、SSL、访问控制、高级配置。
|
||||||
|
- 证书申请和续签直接在规则旁完成。
|
||||||
|
- 管理界面简单,新手可按表单完成部署。
|
||||||
|
|
||||||
|
对 Fonu 的启示:
|
||||||
|
|
||||||
|
- 反向代理规则的“编辑页”应成为服务发布中心。
|
||||||
|
- Cloudflare 出口应该是一个选项,而不是另一套孤立页面。
|
||||||
|
- SSL、Access、健康检查、日志应围绕同一条规则组织。
|
||||||
|
- Fonu 当前已有这一方向,但页面还可以更像“资源详情页”。
|
||||||
|
|
||||||
|
### 3.7 Pangolin
|
||||||
|
|
||||||
|
项目定位:
|
||||||
|
|
||||||
|
- 开源自托管 SASE / Zero Trust 访问平台。
|
||||||
|
|
||||||
|
核心体验:
|
||||||
|
|
||||||
|
- 资源入口页。
|
||||||
|
- 用户、角色、资源级授权。
|
||||||
|
- 浏览器直接访问 SSH、RDP、VNC。
|
||||||
|
- 对每个资源做审计和会话记录。
|
||||||
|
- 多 connector 和站点冗余。
|
||||||
|
|
||||||
|
对 Fonu 的启示:
|
||||||
|
|
||||||
|
- 普通 NAS 用户更需要“访问我的服务”,不是“管理网络隧道”。
|
||||||
|
- 可以借鉴资源入口页:
|
||||||
|
- 按应用、域名、出口分组。
|
||||||
|
- 一键打开服务。
|
||||||
|
- 显示维护状态和最近访问。
|
||||||
|
- 不建议 Fonu 当前做企业 RBAC、SSO、PAM;成本过高。
|
||||||
|
|
||||||
|
### 3.8 Uptime Kuma
|
||||||
|
|
||||||
|
项目定位:
|
||||||
|
|
||||||
|
- 自托管监控和状态页。
|
||||||
|
|
||||||
|
核心体验:
|
||||||
|
|
||||||
|
- 多状态页。
|
||||||
|
- 不同域名绑定不同状态页。
|
||||||
|
- Ping 图表、证书信息。
|
||||||
|
- 通知渠道丰富。
|
||||||
|
- 2FA。
|
||||||
|
|
||||||
|
对 Fonu 的启示:
|
||||||
|
|
||||||
|
- 当前公开状态页已有域名和可用率。
|
||||||
|
- 下一步最值得补:
|
||||||
|
- 事故记录。
|
||||||
|
- 维护窗口。
|
||||||
|
- 最近 24h / 7d / 30d 状态条。
|
||||||
|
- 多个状态页。
|
||||||
|
- 自定义标题、Logo、说明。
|
||||||
|
- 这些比继续增加隧道参数更直接影响用户体验。
|
||||||
|
|
||||||
|
### 3.9 Tunnel Inspector
|
||||||
|
|
||||||
|
项目定位:
|
||||||
|
|
||||||
|
- Cloudflare Tunnel 的本地 HTTP 流量检查器。
|
||||||
|
|
||||||
|
核心体验:
|
||||||
|
|
||||||
|
- 实时请求列表。
|
||||||
|
- 方法和状态码筛选。
|
||||||
|
- 请求/响应头、Body、耗时、TTFB。
|
||||||
|
- 响应流式透传。
|
||||||
|
- 敏感头脱敏。
|
||||||
|
- 请求重放和 Copy as cURL。
|
||||||
|
- SSE 实时推送。
|
||||||
|
|
||||||
|
对 Fonu 的启示:
|
||||||
|
|
||||||
|
- 适合做成“Cloudflare 出口调试模式”,不要默认常驻。
|
||||||
|
- 可提供:
|
||||||
|
- 最近请求。
|
||||||
|
- 上游状态码。
|
||||||
|
- 回源耗时。
|
||||||
|
- 错误请求。
|
||||||
|
- 一键复制 curl。
|
||||||
|
- 需要严格限制:
|
||||||
|
- 仅本机访问。
|
||||||
|
- 默认脱敏。
|
||||||
|
- 默认关闭。
|
||||||
|
- 明确数据不离开本机。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、跨项目共同的最佳体验
|
||||||
|
|
||||||
|
### 4.1 安装向导与预检
|
||||||
|
|
||||||
|
优秀项目不会让用户直接面对复杂配置。
|
||||||
|
|
||||||
|
建议 Fonu 增加:
|
||||||
|
|
||||||
|
1. 检查 cloudflared 是否安装。
|
||||||
|
2. 检查 Account ID。
|
||||||
|
3. 检查 API Token 权限。
|
||||||
|
4. 列出可用 Zone。
|
||||||
|
5. 检查目标根域名是否在 Zone 内。
|
||||||
|
6. 检查 DNS 是否已被其他目标占用。
|
||||||
|
7. 检查 7844 / 443 连通性。
|
||||||
|
8. 生成一条测试隧道并验证访问。
|
||||||
|
|
||||||
|
### 4.2 期望状态与调和中心
|
||||||
|
|
||||||
|
用户看到的不应只是“同步按钮”,而是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
期望状态:已发布
|
||||||
|
Cloudflare 实际:缺失
|
||||||
|
最后同步:2 分钟前
|
||||||
|
原因:DNS CNAME 目标不一致
|
||||||
|
操作:修复 / 查看差异
|
||||||
|
```
|
||||||
|
|
||||||
|
这比单纯提示“保存失败”更有价值。
|
||||||
|
|
||||||
|
### 4.3 可视化拓扑
|
||||||
|
|
||||||
|
很多用户不理解 Nginx、frps、cloudflared、DNS、Access 的关系。
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
```text
|
||||||
|
域名 → 出口 → 隧道 → 内网服务
|
||||||
|
```
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
nas.example.com
|
||||||
|
→ Cloudflare
|
||||||
|
→ tunnel: home-nas
|
||||||
|
→ http://192.168.1.10:8001
|
||||||
|
```
|
||||||
|
|
||||||
|
拓扑图可以直接显示:
|
||||||
|
|
||||||
|
- 域名。
|
||||||
|
- 出口。
|
||||||
|
- DNS 状态。
|
||||||
|
- 隧道状态。
|
||||||
|
- 回源状态。
|
||||||
|
- 证书来源。
|
||||||
|
|
||||||
|
### 4.4 路由表体验
|
||||||
|
|
||||||
|
当前 Fonu 已有路由表。建议继续补:
|
||||||
|
|
||||||
|
- 拖拽排序。
|
||||||
|
- 启用/停用。
|
||||||
|
- 复制路由。
|
||||||
|
- 正则路径提示。
|
||||||
|
- “测试此路由”按钮。
|
||||||
|
- 最后匹配结果。
|
||||||
|
- 路由来源标签:
|
||||||
|
- 手工。
|
||||||
|
- 反向代理规则。
|
||||||
|
- 导入。
|
||||||
|
- 修改历史和回滚。
|
||||||
|
|
||||||
|
### 4.5 状态页与事故
|
||||||
|
|
||||||
|
建议把公开状态页从“当前状态列表”升级为:
|
||||||
|
|
||||||
|
- 当前状态。
|
||||||
|
- 最近事故。
|
||||||
|
- 事故持续时间。
|
||||||
|
- 维护窗口。
|
||||||
|
- 最近 24h / 7d / 30d 状态条。
|
||||||
|
- 服务分组。
|
||||||
|
|
||||||
|
### 4.6 流量检查模式
|
||||||
|
|
||||||
|
建议作为高级诊断功能:
|
||||||
|
|
||||||
|
- 默认关闭。
|
||||||
|
- 开启后显示最近请求。
|
||||||
|
- 自动过滤静态资源。
|
||||||
|
- 显示 4xx/5xx。
|
||||||
|
- 回源错误和边缘错误分开。
|
||||||
|
- 可复制为 curl。
|
||||||
|
- 可查看请求/响应头。
|
||||||
|
- 默认隐藏 Authorization、Cookie、Token。
|
||||||
|
|
||||||
|
### 4.7 快速隧道
|
||||||
|
|
||||||
|
适合做“临时演示”:
|
||||||
|
|
||||||
|
- 只暴露单个本地端口。
|
||||||
|
- 生成 `trycloudflare.com` 地址。
|
||||||
|
- 明确标注:
|
||||||
|
- 地址会变化。
|
||||||
|
- 不保证 SLA。
|
||||||
|
- 不用于正式服务。
|
||||||
|
- 可复制地址或生成二维码。
|
||||||
|
|
||||||
|
### 4.8 一键分享与移动体验
|
||||||
|
|
||||||
|
对 NAS / 手机用户很有价值:
|
||||||
|
|
||||||
|
- 复制访问链接。
|
||||||
|
- 生成二维码。
|
||||||
|
- 分享到 Telegram / 微信。
|
||||||
|
- 移动端查看状态页。
|
||||||
|
- PWA 支持可作为远期项。
|
||||||
|
|
||||||
|
### 4.9 多账号与 `cert.pem`
|
||||||
|
|
||||||
|
桌面管理器和 DockFlare 都把多账号作为进阶能力。
|
||||||
|
|
||||||
|
建议 Fonu:
|
||||||
|
|
||||||
|
- 当前继续以 Account ID + API Token 为默认。
|
||||||
|
- 远期可支持多账号。
|
||||||
|
- `cert.pem` 不应替代 API Token,只作为高级兼容模式。
|
||||||
|
- 多账号必须有明确的账号隔离,避免路由误绑。
|
||||||
|
|
||||||
|
### 4.10 审计与回滚
|
||||||
|
|
||||||
|
自托管平台越复杂,回滚越重要。
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 记录规则和出口变更前后值。
|
||||||
|
- 记录操作主体。
|
||||||
|
- 记录 Cloudflare 同步结果。
|
||||||
|
- 支持手动回滚配置。
|
||||||
|
- 敏感字段只记录“已修改”,不记录明文。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、Fonu 不该照搬的功能
|
||||||
|
|
||||||
|
| 功能 | 结论 | 原因 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Kubernetes Controller | 不做 | 不是 NAS 用户场景 |
|
||||||
|
| 企业 SSO / PAM / RBAC 平台 | 暂不做 | 成本高,偏离 Fonu 定位 |
|
||||||
|
| Cloudflare 邮件套件 | 不做 | 与访问管理无关 |
|
||||||
|
| 多语言 13 种起步 | 暂不做 | 先保持中文产品体验 |
|
||||||
|
| 多主机 Agent 优先 | 后置 | 先做好单机 NAS |
|
||||||
|
| 自动配置 Access 策略 | 后置 | 权限风险高 |
|
||||||
|
| 默认常驻流量抓包 | 不做 | 隐私和性能风险 |
|
||||||
|
| 用户自定义 cloudflared 镜像管理 | 不做 | 增加维护复杂度 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、Fonu 建议路线
|
||||||
|
|
||||||
|
> 实施状态:截至 2026-10-01,本节的 P0 已完成第一轮落地,详见 `cloudflared-current-features.md`。
|
||||||
|
|
||||||
|
### P0:最值得先做
|
||||||
|
|
||||||
|
1. **Cloudflare 接入向导**
|
||||||
|
- Account、Token、Zone、Tunnel 一步检查。
|
||||||
|
2. **调和中心**
|
||||||
|
- 展示期望状态、云端状态、漂移原因、最后同步时间。
|
||||||
|
3. **路由表增强**
|
||||||
|
- 排序、启停、复制、测试、来源标签。
|
||||||
|
4. **一键修复**
|
||||||
|
- DNS、ingress、进程、二进制四类修复动作。
|
||||||
|
5. **状态页事故记录**
|
||||||
|
- 事故时间线、持续时长、维护窗口。
|
||||||
|
|
||||||
|
### P1:用户体验提升
|
||||||
|
|
||||||
|
6. **拓扑图**
|
||||||
|
- 域名 → 出口 → 隧道 → 服务。
|
||||||
|
7. **Docker 标签发布**
|
||||||
|
- 容器标签自动生成 Fonu 规则和 Cloudflare 出口。
|
||||||
|
8. **场景模板**
|
||||||
|
- Home Assistant、Immich、Jellyfin、qBittorrent、AdGuard。
|
||||||
|
9. **访问二维码与分享**
|
||||||
|
- 手机用户高频需求。
|
||||||
|
10. **云端覆盖本地**
|
||||||
|
- 完成双向同步,不只支持本地覆盖云端。
|
||||||
|
|
||||||
|
### P2:高级能力
|
||||||
|
|
||||||
|
11. **流量检查模式**
|
||||||
|
12. **多 Cloudflare 账号**
|
||||||
|
13. **Access 策略只读增强**
|
||||||
|
14. **审计日志**
|
||||||
|
15. **Quick Tunnel 临时演示**
|
||||||
|
16. **外部 Agent 多主机发布**
|
||||||
|
|
||||||
|
### P3:远期
|
||||||
|
|
||||||
|
17. PWA / 移动端体验。
|
||||||
|
18. 团队用户与资源级授权。
|
||||||
|
19. 浏览器 SSH / RDP / VNC 网关。
|
||||||
|
20. 多 connector 主备。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、针对当前 Fonu 页面的具体改版建议
|
||||||
|
|
||||||
|
### 7.1 Cloudflare 总览页
|
||||||
|
|
||||||
|
建议结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PageHeader
|
||||||
|
接入状态卡
|
||||||
|
隧道总览卡
|
||||||
|
待处理问题
|
||||||
|
最近变更
|
||||||
|
快速操作
|
||||||
|
```
|
||||||
|
|
||||||
|
待处理问题可以包括:
|
||||||
|
|
||||||
|
- API Token 权限不足。
|
||||||
|
- 根域名不在 Zone。
|
||||||
|
- DNS 被其他记录占用。
|
||||||
|
- 隧道离线。
|
||||||
|
- 本地与云端漂移。
|
||||||
|
- cloudflared 有新版本。
|
||||||
|
|
||||||
|
### 7.2 隧道配置页
|
||||||
|
|
||||||
|
建议分为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
概览
|
||||||
|
路由
|
||||||
|
副本 / 指标
|
||||||
|
DNS
|
||||||
|
诊断
|
||||||
|
日志
|
||||||
|
高级
|
||||||
|
```
|
||||||
|
|
||||||
|
不要把所有内容放在一个长页面里。
|
||||||
|
|
||||||
|
### 7.3 反向代理规则页
|
||||||
|
|
||||||
|
在规则详情中增加:
|
||||||
|
|
||||||
|
```text
|
||||||
|
出口:本机 Nginx + Cloudflare
|
||||||
|
Cloudflare 状态:已同步
|
||||||
|
DNS:已创建
|
||||||
|
访问保护:未启用
|
||||||
|
最近错误:无
|
||||||
|
```
|
||||||
|
|
||||||
|
用户不需要离开规则详情就能知道整条链路是否健康。
|
||||||
|
|
||||||
|
### 7.4 仪表盘
|
||||||
|
|
||||||
|
增加:
|
||||||
|
|
||||||
|
- Cloudflare 在线隧道。
|
||||||
|
- 异常路由。
|
||||||
|
- DNS 漂移。
|
||||||
|
- 最近恢复 / 最近故障。
|
||||||
|
- cloudflared 可更新提示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、最终建议
|
||||||
|
|
||||||
|
Fonu 不应继续横向堆叠 cloudflared 参数。更值得投入的是:
|
||||||
|
|
||||||
|
1. 把 Cloudflare 接入做成向导。
|
||||||
|
2. 把“规则 → 出口 → 隧道 → DNS → 监控”做成同一条链路。
|
||||||
|
3. 把漂移、诊断和修复做成可操作闭环。
|
||||||
|
4. 把状态页做成真正的可分享服务状态页。
|
||||||
|
5. 把 Docker / NAS 服务发布做到自动发现。
|
||||||
|
6. 把高级能力放到高级页,不干扰默认路径。
|
||||||
|
|
||||||
|
这会让 Fonu 与普通 cloudflared 面板产生明确差异:
|
||||||
|
|
||||||
|
> 普通面板管理 cloudflared;Fonu 管理“我的服务如何安全、稳定地访问”。
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
# Cloudflare Tunnel(cloudflared)与 Fonu 的融合方案
|
||||||
|
|
||||||
|
> 日期:2026-09-25,v2 修订 2026-09-28 对象:Fonu 1.5.4(VERSION 1.5.4)
|
||||||
|
> 方法:先查 Cloudflare 官方文档与同类开源项目,再逐个核对本仓库现有可复用面(下文带 `文件:行` 的都是读过代码得出的);v2 逐文件核对了 `F:\WWW\cf\Tunnel-Cloudflared-linux`(Tunnel-Cloudflared-Manager 1.0.1)源码。
|
||||||
|
> 结论速览:**值得做,但建议以「第二条出口」的形态并存,先做 locally-managed + 进程托管,不接 Cloudflare API**。v2 修订了部署位置、模式生效侧、metrics / 健康与二进制校验四处关键细节,详见 §五、§六、§十二;实施以 §十二 为准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、为什么值得做(对 Fonu 的战略意义)
|
||||||
|
|
||||||
|
Fonu 现在的穿透链路**必须有公网服务端**:本机 `frpc` → VPS 上的 `frps` → 域名。用户得有一台带公网 IP 的云主机,还得自己放行端口、签证书、配 DNS。
|
||||||
|
|
||||||
|
Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `cloudflared` **主动向外**建立到边缘的长连接(出站 only),公网侧不需要任何监听端口,也不需要公网 IP——顺带白拿 CF 的 WAF / DDoS 防护与自动 HTTPS。
|
||||||
|
|
||||||
|
对 Fonu 的用户画像(家庭/个人 NAS)来说,这正好补上一类人:**没有实体公网 IP、或不想为穿透长期租 VPS**的人。
|
||||||
|
|
||||||
|
但要说清楚它**替代不了什么**(这决定了它是「并存」而不是「替换」):
|
||||||
|
|
||||||
|
| 场景 | frp | Cloudflare Tunnel |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 内网 HTTP(S) 网站/面板(域名访问) | ✅ | ✅ **更省事**(无需 VPS、无需放行端口) |
|
||||||
|
| 任意 TCP/UDP(游戏服、非 HTTP 协议) | ✅ | ⚠️ 需要访问端也装 `cloudflared`(`cloudflared access tcp`),不是通用 TCP 出口 |
|
||||||
|
| 客户端不需要额外软件的裸 TCP 端口 | ✅ | ❌ |
|
||||||
|
| 自持服务端 / 完全自主可控 | ✅ | ❌(流量必经 CF 边缘) |
|
||||||
|
| 大文件/视频流量 | ✅(自己带宽说话) | ⚠️ CF 条款限制非 Enterprise 的 CDN 视频/大文件用途,见 §七 |
|
||||||
|
|
||||||
|
所以定位是:**HTTP 类公网访问走 Cloudflare 更划算,TCP 类与自主性要求高的仍走 frp**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、同类项目(检索结果)
|
||||||
|
|
||||||
|
| 项目 | 做什么 | 对 Fonu 的借鉴 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [WisdomSky/Cloudflared-web](https://github.com/WisdomSky/Cloudflared-web)(≈451★,Vue) | Docker 镜像里打包 `cloudflared` + 简单 Web UI,用来 start/stop **remotely-managed** 隧道 | **最接近的同位产品**:证明「面板管 cloudflared 进程」这个形态成立;但它的能力止步于开关,规则仍在 CF 面板里——这正是 Fonu 可以做得更好的地方(规则同源生成) |
|
||||||
|
| [adyanth/cloudflare-operator](https://github.com/adyanth/cloudflare-operator)(≈691★) | Kubernetes Operator,用 **CF API** 创建/管理 Tunnel 与 DNS | 演示了「API 驱动」的完整闭环;但需要 API Token + Account/Zone ID,是第二个凭据面(Fonu 第一轮不建议走,见 §六) |
|
||||||
|
| [qingchencloud/cftunnel](https://github.com/qingchencloud/cftunnel)(≈338★) | 轻量隧道控制 | 佐证需求存在 |
|
||||||
|
| [brenner-tobias/addon-cloudflared](https://github.com/brenner-tobias/addon-cloudflared) | Home Assistant 插件,locally-managed 与 remotely-managed 两种模式都支持 | HA 社区的做法可参考:**两种模式并存**,文档把差异讲透 |
|
||||||
|
| [cloudflare/cloudflared](https://github.com/cloudflare/cloudflared) | 官方客户端(Apache-2.0) | 我们的被托管对象;GitHub Release 提供多平台二进制 |
|
||||||
|
| 对照项:[Pangolin](https://github.com/fosrl/pangolin) / Tailscale Funnel / ngrok | 同类「免公网 IP」方案 | Tailscale Funnel 只开 HTTPS 且端口数量有限、ngrok 免费档收紧;CF Tunnel 免费档最宽松,这也是它值得优先支持的原因 |
|
||||||
|
|
||||||
|
`docs/feature-benchmark-2026-09-24.md` 里已经把 Pangolin 列为架构最近的同类项目;Cloudflare Tunnel 的差别在于:**它把「服务端」外包给了 Cloudflare,Fonu 只需管好本机客户端**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、cloudflared 的两种托管模型(选型的关键)
|
||||||
|
|
||||||
|
| | locally-managed(本地管理) | remotely-managed(远程管理) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 凭据 | `cert.pem`(`cloudflared tunnel login` 浏览器授权)+ `<UUID>.json` credentials | 一个 **token**(`eyJ...`,CF 面板生成) |
|
||||||
|
| 规则来源 | 本机 `config.yml` 的 `ingress:` | **CF 面板 / API**(本地 `config.yml` 会被**忽略**) |
|
||||||
|
| 建隧道 | `cloudflared tunnel create <name>` | CF 面板,或 `POST /accounts/{id}/cfd_tunnel` |
|
||||||
|
| DNS | `cloudflared tunnel route dns <tunnel> <hostname>` | CF 面板自动建 CNAME |
|
||||||
|
| 与 Fonu 的价值取向 | ✅ **契合**:反代规则由 Fonu 同源生成(和现在生成 `frps.toml` / nginx vhost 一个路子) | ⚠️ 冲突:规则跑到 CF 面板去了,Fonu 沦为「进程开关」 |
|
||||||
|
|
||||||
|
官方文档确认了这个分界([配置参考](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/configuration-file)),社区也踩过坑:给 remotely-managed 隧道传 `--config` 时配置会被云端覆盖,日志里出现 `No ingress rules were defined ... cloudflared will return 503`([cloudflared#1029](https://github.com/cloudflare/cloudflared/issues/1029))。
|
||||||
|
|
||||||
|
**结论:第一轮只支持 locally-managed。** 若用户只想粘贴 token,提供「仅托管进程」的降级模式并**在界面上明确写清「规则由 Cloudflare 面板管理」**,不要让用户以为 Fonu 生成的规则生效了。
|
||||||
|
|
||||||
|
> 补充(参考 §九 后修正):这个二分法不够细。参考实现证明**token 可以解码**(base64 的 JSON,含 `AccountTag` / `TunnelID` / `TunnelSecret`),用它自己生成 `creds.json` 就能按 locally-managed 跑本地 `config.yml`;再配合「云端 ↔ 本地 ingress 互相同步」,两种模式实际上是可切换的。所以 Fonu 的正确做法不是「二选一」,而是**显式区分并同步**:当前生效的是本地 `config.yml` 还是云端 ingress,以及「推送/拉取」两个动作。**v2 已把模式拆成三种并明确生效侧,见 §12.3。**
|
||||||
|
|
||||||
|
### 健康探测(这点比 frp 更省事)
|
||||||
|
|
||||||
|
`cloudflared tunnel --metrics 127.0.0.1:<port> run --config ...` 会暴露 HTTP 端点,其中 **`/ready` 在有到边缘的活跃连接时返回 200**(CF 官方在 k8s livenessProbe 里就是这么用的)。Fonu 现在为了判断「隧道通不通」要绕 frps dashboard,还专门加过 `tunnel_known` 来区分「判不出来」和「不通」;CF Tunnel 则有明确的就绪端点,可以直接喂给现有健康链路。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、本仓库可复用的面(已核对代码)
|
||||||
|
|
||||||
|
| 能力 | Fonu 现有实现 | 复用方式 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 二进制下载 / 启用 | `Service.ListReleases`(`internal/frp/service.go:112`)、`ListBinaries`(`:120`)、`ActivateBinary`(`:258`);`internal/frp/runtime.go` 的 `fetchOfficialReleases` / `listBinaries` / `readActiveBinary` / `writeActiveBinary` / `versionFromBinaryPath` | cloudflared 同样发 GitHub Release,**同一套框架泛化成「可指定仓库」即可**(现在 URL 拼的是 frp 的 release 页) |
|
||||||
|
| 客户端进程托管 | `internal/frp/manager.go`:`os.OpenFile(logPath, O_CREATE\|O_APPEND\|O_WRONLY, 0600)` + `cmd.Stdout/Stderr` 重定向 + 状态/退出原因 + `tailFile` 读日志尾 | 与 `cloudflared tunnel run` 完全同构,可直接复用(日志追加、不截断的语义也一致) |
|
||||||
|
| 凭据加密与回显 | `secret.Box` + `maskedToken = "********"`(`internal/frp/service.go:23`)+ 「预览脱敏 / 下发原文」两分支约定(v1.2.12 刚修) | token / credentials JSON 存加密字段;**预览一律掩码** |
|
||||||
|
| 配置生成 | `internal/frp/frps_template.go`、`internal/proxy` 的 nginx vhost 模板 | `config.yml` 的 `ingress:` 用同样思路生成,注释与安全提示的风格照搬 |
|
||||||
|
| 健康与可用率 | `internal/monitor`(`Source` 接口 + `HealthRecorder`)、`migrations/026` 的健康事件表、`metrics_samples` | 给 `Source` 加一个 provider(frp / cloudflared),`/ready` 作为判活依据 |
|
||||||
|
| 日志界面 / 公开状态页 | 日志页各页签、`/status` 公开状态页(默认关闭 + 访问码 + 限流) | 新隧道类型直接进现有页面,不用另造一套 |
|
||||||
|
| 规则模型 | `frp.Proxy{ServerID, Type, LocalIP, LocalPort, CustomDomains, Options}`(`internal/frp/model.go:186`) | 不再需要 `ServerID` 时用一个 provider 字段代替(见方案 B) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、三个方案
|
||||||
|
|
||||||
|
### 方案 A:独立模块「Cloudflare 隧道」(推荐第一轮)
|
||||||
|
|
||||||
|
新增一个与「公网服务端(FRP)」并列的模块,**不碰现有 frp 规则模型**:
|
||||||
|
|
||||||
|
- 管理一份 cloudflared 二进制(复用 §四 的下载/启用框架)。
|
||||||
|
- 管理 N 个隧道实例,每个实例 = 一份 `config.yml` + credentials + 日志文件 + 子进程。
|
||||||
|
- `ingress` 规则**由 Fonu 生成**:从本机已有的反代规则里挑「要暴露的域名」,映射到 `http://127.0.0.1:<本地端口>`,模板与 nginx vhost 同源风格;末尾固定给 catch-all `service: http_status:404`(CF 要求最后一条兜底)。
|
||||||
|
- 健康:`--metrics` + `/ready`,写入现有健康与可用率链路;通知复用(隧道断开/恢复)。
|
||||||
|
- 前端:与「公网服务端」同构的列表 + 详情(状态 / 日志 / 配置预览 / 下发)。
|
||||||
|
|
||||||
|
**优点**:改动封闭、不动现有模型、可独立上线、风险低。
|
||||||
|
**代价**:规则要在两处维护(frp 规则 vs CF 隧道规则)——这是方案 B 要解决的。
|
||||||
|
|
||||||
|
### 方案 B:把「隧道出口」抽象成 provider(v2 方向)
|
||||||
|
|
||||||
|
在规则上加 `provider: frp | cloudflare`(`frp.Proxy` 增字段,配套迁移),规则本身只说「哪个域名 → 内网哪个端口」,出口由 provider 决定:
|
||||||
|
|
||||||
|
- 好处:一条规则可在 FRP / CF 之间切换,健康、可用率、状态页、漂移检测天然统一。
|
||||||
|
- 成本:动模型 + 迁移 + 前端多处(规则表单、健康四段、公网反代页),且 `ServerID` 的语义要重新定义(CF 侧没有服务端)。
|
||||||
|
- 建议:**等方案 A 稳定、用户真的需要在两种出口间迁移规则时再做**。
|
||||||
|
|
||||||
|
### 方案 C:只做引导(不接管进程)
|
||||||
|
|
||||||
|
只生成 `config.yml` 与 DNS 指引,让用户自己跑 `cloudflared`。
|
||||||
|
|
||||||
|
**不建议**:不接管进程就没有状态、日志、健康、可用率,等于放弃 Fonu 的全部价值。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、推荐落地路径(分阶段)
|
||||||
|
|
||||||
|
> 本节阶段划分是 v1 口径;v2 修订见 §12.10,实施以 §12.10 为准。
|
||||||
|
|
||||||
|
### 阶段 1:只读与引导(最低风险,先验证需求)
|
||||||
|
|
||||||
|
1. 新增「Cloudflare 隧道」入口:粘贴 **token**(remotely-managed)或上传 **credentials JSON + tunnel UUID**(locally-managed),凭据加密存储。
|
||||||
|
2. 生成 `config.yml` 预览(**脱敏**,沿用 v1.2.12 的预览/下发两分支约定)并把文件写到 `<DataDir>/cloudflared/<name>/config.yml`。
|
||||||
|
3. 界面给出 CF 侧操作指引(建隧道、CNAME、`cloudflared tunnel route dns`),**不接管进程**。
|
||||||
|
4. 明确提示模式差异:locally-managed 才由 Fonu 生成规则。
|
||||||
|
|
||||||
|
### 阶段 2:托管进程与健康(核心价值)
|
||||||
|
|
||||||
|
5. 把 §四 的 release/binaries 框架泛化,支持下载 `cloudflared` 并启用指定版本;进程托管复用 `Manager`(日志、状态、退出原因、`tailFile`)。
|
||||||
|
6. `--metrics 127.0.0.1:<port>` + `/ready` 接入 `internal/monitor`:新增 provider 判活,接入现有通知与 `metrics_samples` 可用率。
|
||||||
|
7. 子命令支持:启动 / 停止 / 重启 / 重载配置 / 查看日志(与「公网服务端」的操作面板一致)。
|
||||||
|
|
||||||
|
### 阶段 3:规则同源与界面完善
|
||||||
|
|
||||||
|
8. 从本机反代规则推导 `ingress`(勾选要暴露的域名),生成后一键下发并重启;末尾自动补 catch-all。
|
||||||
|
9. 前端补齐:状态卡、日志页签、配置预览(脱敏)、与 VPS 实际文件的漂移检测(沿用「配置漂移」的交互)。
|
||||||
|
10. Access(Zero Trust)相关只做**诊断提示**(例如「此域名未启用 Access 保护」),不代配置策略。
|
||||||
|
|
||||||
|
### 阶段 4(可选,需单独评估)
|
||||||
|
|
||||||
|
11. 接 Cloudflare API 自动建隧道与 DNS:需要 API Token(权限 `Account: Cloudflare Tunnel: Edit` + `Zone: DNS: Edit`)+ Account ID + Zone ID。**这是第二个凭据面**,收益是省掉几步手工操作,代价是多一份高权限凭据要保管——建议等前面稳定后再评估,并且只在用户显式开启时启用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、风险与边界(务必写进功能说明)
|
||||||
|
|
||||||
|
**条款与合规**
|
||||||
|
- Tunnel 本身免费、不限条数/带宽,但 Cloudflare 的 CDN 条款限制「用 CDN 提供视频与其他大文件」(非 Enterprise 需购买 Stream/Images 等付费服务)。社区里这条措辞有过调整,但仍应视为风险:**用 CF Tunnel 做影音/大文件分发不在我们的推荐用法内**,界面上要提示。
|
||||||
|
- TryCloudflare(快速隧道:`cloudflared tunnel --url http://localhost:PORT`)官方明确「不保证 SLA 与可用性,仅供测试与开发」,域名随机、每次重启变化——**不作为正式功能**,最多做成「临时演示」按钮。
|
||||||
|
|
||||||
|
**网络可用性**
|
||||||
|
- 中国大陆访问 CF 边缘的延迟与稳定性不稳定,这对 Fonu 的用户群是**现实问题**:建议在文档与界面上对比说明「CF 适合海外访问优先的场景,国内链路以 frp + 自持 VPS 更可控」。
|
||||||
|
- 出站 only 意味着 VPS 上不需要任何放行端口,但也就**读不到**「CF 侧有哪些域名指向了这条隧道」——相关校验只能靠 `cloudflared` 自身命令,不能靠 agent。
|
||||||
|
|
||||||
|
**实现边界**
|
||||||
|
- **不做 remotely-managed 的规则生成**(会被云端覆盖,见 §三)。
|
||||||
|
- **不代配 Access 策略**(只提示状态)。
|
||||||
|
- **不改现有 frp 链路**:CF Tunnel 是并存出口;`internal/frp` 的规则、健康、状态页语义保持不变。
|
||||||
|
- **不做通用 TCP 出口**:需要访问端装 `cloudflared` 的场景超出「NAS 面板」的定位,只做说明。
|
||||||
|
- 凭据全部走 `secret.Box` 加密 + 掩码回显;日志与 API 响应绝不出现明文 token(沿用 `maskedToken` 与「预览脱敏」约定)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、参考实现分析:`cf-tunnel-manager`(用户自有项目,Gitee `li-hua-88/cf-tunnel-manager`)
|
||||||
|
|
||||||
|
> 本节是 2026-09-25 的 v1 分析。2026-09-28 已对照 1.0.1 Linux 源码逐文件复核,补充与修正见 §十二。
|
||||||
|
|
||||||
|
> 项目:**Tunnel-Cloudflared-Manager** v1.0.1,Rust + Actix-web + Tokio + Serde,前端原生 JS + Tailwind(`static/`),JSON 文件存储,MIT;Linux systemd(`tcm`/`cftm` 运维脚本)+ Docker 两套交付。
|
||||||
|
> 本节结论来自逐个读到源码的模块:`src/services/{tunnel,binary,cloudflare_api}.rs`、`src/models/tunnel.rs`、`src/utils/{config_helper,paths}.rs`、`Cargo.toml`。
|
||||||
|
|
||||||
|
### 9.1 它的定位与能力
|
||||||
|
|
||||||
|
| 维度 | 它做了什么 |
|
||||||
|
| --- | --- |
|
||||||
|
| 隧道类型 | **命名隧道**(token 或 config-file 启动)+ **快速隧道**(`--url`) |
|
||||||
|
| 进程管理 | `TunnelProcessManager`:`HashMap<名称, Child>` + 独立的 `login_process` + 快速隧道 URL 缓存 |
|
||||||
|
| 日志 | `tokio::process` 逐行读 stdout/stderr → **WebSocket 广播**(`tunnel-log` 事件),前端实时滚动 |
|
||||||
|
| 指标 | 每 2 秒抓 `cloudflared` 的 Prometheus 端点 → 同样用 WebSocket 广播(`tunnel-metrics`) |
|
||||||
|
| 二进制 | `src/services/binary.rs` 自管下载/替换/检测,不要求用户预装 |
|
||||||
|
| 账号模式 | `--origincert` + `tunnel create` + `tunnel route dns`,把生成的 `creds.json` 搬进自己的 `data/tunnels/<名>/` |
|
||||||
|
| 云端同步 | `src/services/cloudflare_api.rs` 能按 tunnel **拉取/推送** ingress 配置(`fetch_tunnel_config` / `push_tunnel_config`) |
|
||||||
|
| 登录与安全 | JWT + bcrypt 管理员账号,默认 `admin`/`admin666`(首次登录强制改密) |
|
||||||
|
| 网络调优 | `protocol`(auto/http2/quic,Windows 或检测到代理时回落 http2)、`ha-connections`、`edge-ip-version`、`originRequest`(`caPool`/`noTLSVerify`/`httpHostHeader`)、代理环境变量开关 |
|
||||||
|
|
||||||
|
### 9.2 值得直接借鉴的六个做法
|
||||||
|
|
||||||
|
**① token 可以解码成凭据,于是 token 模式也能本地管规则**
|
||||||
|
`initialize_token_tunnel` 把 CF 隧道 token 解成 `{AccountTag, TunnelID, TunnelSecret}` 并写出 `creds.json` + `config.yml`——即「用户只给一个 token,但规则仍由面板本地生成」。这直接提升了 Fonu 方案的上限(§三 的补充已按此修正)。
|
||||||
|
|
||||||
|
**② metrics 端口不去猜,从日志里读出来**
|
||||||
|
命名隧道启动时**故意不传 `--metrics`**,让 cloudflared 用默认端口段(20241-20245),然后从 stderr 解析 `INF starting metrics server on 127.0.0.1:65432/metrics` 拿到真实端口——天然避开端口冲突(Fonu 现在给 frps dashboard 固定端口,多实例时会撞)。
|
||||||
|
|
||||||
|
**③ `localhost` 必须归一化成 `127.0.0.1`**
|
||||||
|
`add_tunnel_endpoint` 里把 `localhost` 换成 `127.0.0.1`,注释写明「避免 Cloudflare 将其转换为本地网络 IP」。这是 CF 侧的真实坑,Fonu 生成 `ingress` 时要照做。
|
||||||
|
|
||||||
|
**④ 始终保留最后一条 catch-all**
|
||||||
|
新增端点插在「数组倒数第一位之前」(`insert_pos = len - 1`),末条永远是 `service: http_status:404`。Fonu 改 `ingress` 时必须沿用这个不变式,否则配置校验不过。
|
||||||
|
|
||||||
|
**⑤ 带宽指标:官方 metrics 没有 HTTP/2 字节数,它做了系统级兜底**
|
||||||
|
- QUIC 下直接读 `quic_client_receive_bytes` / `quic_client_sent_bytes` / `quic_client_smoothed_rtt`。
|
||||||
|
- HTTP/2 下官方无字节数,改用**按进程采 TCP 连接字节**:Linux 走 netlink `SOCK_DIAG_BY_FAMILY` + `/proc/<pid>/fd` 取 socket inode;Windows 走 `GetExtendedTcpTable` + `GetPerTcpConnectionEStats`(需要管理员,失败时给 `windows_estats_requires_admin` 降级原因)。
|
||||||
|
- 并且**只统计公网远端 IP**(过滤私有/回环/链路本地/`100.64/10` CGNAT),延迟兜底用**主动 HEAD 探测** ingress 里的公网 hostname(带 `?__cf_probe=<nonce>` + no-cache)。
|
||||||
|
> 对 Fonu 的意义:这一块可以**先不做**(我们已有规则级流量与可用率),但「降级要给出明确原因而不是空白」这个原则要学。
|
||||||
|
|
||||||
|
**⑥ Windows 下起子进程要隐藏控制台窗口**
|
||||||
|
`creation_flags(CREATE_NO_WINDOW)`(`0x08000000`)。Fonu 本机在 Windows 上跑,托管 `cloudflared.exe` 时必须带上,否则会闪黑窗。
|
||||||
|
|
||||||
|
### 9.3 与 Fonu 的差异(决定「借鉴什么、不借鉴什么」)
|
||||||
|
|
||||||
|
| 维度 | cf-tunnel-manager | Fonu 该怎么做 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 职责 | **专做** Cloudflare Tunnel 的一个独立面板 | 把 CF 作为**第二条出口**并入现有规则/健康/通知/状态页体系 |
|
||||||
|
| 存储 | JSON 文件(`data/*.json`)+ `data/tunnels/<名>/` | 继续用 SQLite + 迁移;凭据进 `secret.Box` 加密字段 |
|
||||||
|
| 规则来源 | 面板自己的 ingress 列表(+ 云端同步) | 从**本机已有反代规则**推导(同源生成),避免第二份手工维护 |
|
||||||
|
| 健康 | metrics 端点 scraping + 主动探测(仅前端展示) | 接入 `internal/monitor` 的 `Source`/`HealthRecorder`,落库可用率 + 复用告警与通知 |
|
||||||
|
| 实时日志 | WebSocket 广播(专为「看着它滚」设计) | 沿用现有「读日志文件 + 日志页」模型(与 frpc 一致,无需引入 WS) |
|
||||||
|
| 多实例 | 多隧道并行(每隧道一个子进程) | 同模;但用 SQLite 建 `tunnels` 表而不是内存 HashMap |
|
||||||
|
| 认证 | 自带 JWT + 账号体系 | 复用 Fonu 现有会话 + API Token |
|
||||||
|
|
||||||
|
### 9.4 对方案本体的修订建议
|
||||||
|
|
||||||
|
1. **方案 A 的第一阶段就加「凭据导入」两条路**:`token`(解码出 creds.json)与 `cert.pem + credentials JSON`(账号模式),二者最终都落到「本地 `config.yml` 由 Fonu 生成」。
|
||||||
|
2. **阶段 2 把「metrics 端口从日志发现」写成实现约定**,不要固定端口。
|
||||||
|
3. **阶段 3 增加「云端 ↔ 本地同步」两个动作**(拉取 / 推送),并在界面上标明当前生效侧——这是参考项目比原方案更完整的地方。
|
||||||
|
4. **`ingress` 生成器锁定三条不变式**:末条 catch-all、`localhost` → `127.0.0.1`、插入位置在 catch-all 之前;并补一个「生成后自检」的纯函数测试(参照本仓 `maskFRPSSecrets` 的测试写法)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、IP 优选:能不能做、能做到哪一步
|
||||||
|
|
||||||
|
### 结论一句话
|
||||||
|
|
||||||
|
**隧道侧(本机 `cloudflared` → CF 边缘)可以优选,访问侧(浏览器 → CF 边缘)不行。** 而国内用户最在意的那一段延迟往往在访问侧——所以不要把它宣传成「提速神器」,它的收益是**可控但有限**的。
|
||||||
|
|
||||||
|
### 隧道侧:三条可用手段(已核对官方文档与变更日志)
|
||||||
|
|
||||||
|
| 手段 | 具体做法 | 适用与代价 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **选协议** | `--protocol quic` / `http2` | 国内 QUIC(UDP 7844)常被 QoS 掉,`http2`(TCP 7844)反而稳;这是**成本最低、收益最直接**的一项 |
|
||||||
|
| **选地址族** | `--edge-ip-version 4` / `6` / `auto`,或环境变量 `TUNNEL_EDGE_IP_VERSION` | ⚠️ **2026.4.0 起默认值由 `4` 改成 `auto`**(官方 CHANGES 里的 Breaking Change)——`auto` 会跟着系统解析器走,国内环境若 IPv6 路由差就可能反而变慢。**建议 Fonu 显式下发 `--edge-ip-version 4`**(除非用户自己有 IPv6 好线路) |
|
||||||
|
| **优选边缘 IP** | 对 `region1.v2.argotunnel.com` / `region2.v2.argotunnel.com` 做延迟测速,选中最快的官方边缘 IP,写 hosts 覆盖解析 | 官方文档给出固定端点与端口:这类域名指向 CF 的 IPv4 段(`198.41.192.7`~`198.41.192.127`、`162.159.234.1`~`162.159.234.10`)与 **端口 7844(TCP/UDP)**。这是社区「Tunnel 优选 IP」的通行做法 |
|
||||||
|
|
||||||
|
**优选时两条不能错的约束**:
|
||||||
|
|
||||||
|
1. **两个 region 都要覆盖**。官方预检文档要求健康部署有**四个活跃 replica、同时连上 `region1` 与 `region2`**(或对应地区域名)——只改其中一个域名,另一半连接会跑到未优选(乃至不通)的路径上,净效果可能是变差。
|
||||||
|
2. **IP 列表不能硬编码**。CF 改过端点与 hostname(例:`proxy-dns` 整条功能已在 2026.2.0 被移除),优选表要么给默认值 + 可配置,要么每次从官方 DNS 拉候选集再测。
|
||||||
|
|
||||||
|
关于实例数:`--ha-connections` 决定每个 region 开几条连接,也是「多路择优」的天然手段(参考实现 §9.1 已把它做成设置项)。
|
||||||
|
|
||||||
|
### 访问侧:Tunnel 模式下做不到
|
||||||
|
|
||||||
|
用 CF Tunnel 时,公域域名必须 CNAME 到 `<tunnel-uuid>.cfargotunnel.com` 并且**开启代理(橙云)**——解析结果由 CF 决定,**用户无法自选边缘 IP**。社区那套「测速 + 自选 IP」之所以常见,是因为他们用的是「CNAME 到优选 IP + 关闭橙云 + 自己回源」的方案;那**恰好是 Tunnel 不能走的路**(关掉代理就没有 Tunnel 了)。
|
||||||
|
|
||||||
|
也就是说:换域名解析 / 让访客改 hosts 都不现实,**访问侧这一段在 Tunnel 架构下就是交给 CF 的**。
|
||||||
|
|
||||||
|
### 对 Fonu 方案的落点
|
||||||
|
|
||||||
|
| 阶段 | 做什么 | 依据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 阶段 2(建议直接做) | 「网络调优」三项做成设置项:`protocol`(默认 http2)、`edge-ip-version`(**默认 4**)、`ha-connections`;以命令行参数下发 | 参考实现已验证可行,且改一项就能治国内 QUIC 被打压的场景 |
|
||||||
|
| 阶段 3(可选) | 「边缘优选」工具:并发探测官方 IPv4 段的 **TCP 7844 握手延迟** → 生成「两个 region 各 1~2 个优选 IP」的 hosts 建议;**页面展示而不是代写 hosts**(Windows 改 hosts 需管理员,Fonu 不应越权),并提供「复制这段内容」+ 生效检测 | 沿用本仓「不越权、降级要说明原因」的口径 |
|
||||||
|
| 阶段 3(可选) | 优选前后对比:用**已有的** `metrics_samples` 延迟/可用率序列展示效果,而不是另造一套测速图 | 复用 §四 的健康与指标链路 |
|
||||||
|
|
||||||
|
> metrics 端口发现、健康判定与网络调优默认值见 §12.5 / §12.7;本节只讨论「本机 → CF 边缘」的优选。
|
||||||
|
|
||||||
|
### 必须写进功能说明的三句实话
|
||||||
|
|
||||||
|
1. 优选只影响「本机 → CF 边缘」这一段;访客到自己那侧 CF 边缘的那段,Fonu 与用户都控制不了。
|
||||||
|
2. 所以它**不是**「翻墙提速」类工具:国内访问 CF 的稳定性受链路与运营商影响,优选只能把「本机出口到边缘」这一段挑好。
|
||||||
|
3. 官方端点会变(见上),因此把所有 IP/域名做成**可配置 + 有默认值 + 可一键恢复默认**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、给用户的一句话结论(可直接对外)
|
||||||
|
|
||||||
|
> Fonu 现在支持两类公网出口:**frp**(自持 VPS,任意 TCP/UDP,完全自主)与 **Cloudflare 隧道**(无需公网 IP 与 VPS,HTTP 类最省事,自带 CF 防护)。
|
||||||
|
> 要裸 TCP 端口、要完全自主,用 frp;要让内网 Web 服务零端口暴露地上公网,用 Cloudflare 隧道。
|
||||||
|
> Cloudflare 隧道还提供网络调优(协议 / 地址族 / 连接数)与**边缘 IP 优选**(仅作用于本机到 CF 边缘这一段,访客侧无法自选)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、方案优化 v2(结合 Tunnel-Cloudflared-Manager 1.0.1 与 Fonu 1.5.4)
|
||||||
|
|
||||||
|
> 本节是 2026-09-28 的优化版落地口径。§一~§十一 保留为背景与论证;出现冲突时以本节为准。
|
||||||
|
|
||||||
|
> 实施状态(2026-09-28,v1.6.0):阶段 1–2 已实施,包含 token-local / token-remote 两种模式、token 解码与 creds.json / config.yml 生成、ingress 生成器、SQLite 存储、cloudflared 二进制下载、进程托管、日志、metrics 端口发现与健康抓取、`/api/cloudflare/*` 接口与前端页面。阶段 3 的账号模式(cert.pem)与云端 fetch / push、阶段 4 的边缘优选与快速隧道尚未实施。
|
||||||
|
|
||||||
|
### 12.1 源码核对新增的关键事实
|
||||||
|
|
||||||
|
逐文件核对 `F:\WWW\cf\Tunnel-Cloudflared-linux` 后,以下事实会直接改变实施细节:
|
||||||
|
|
||||||
|
| 事实 | 源码位置 | 对方案的影响 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| token 解码后的 JSON 字段是 `a` / `t` / `s`,兼容 `AccountTag` / `TunnelID` / `TunnelSecret`;使用标准 base64 并自动补 `=` | `src/services/cloudflare_api.rs` 的 `decode_tunnel_token` / `base64_decode` | token 模式也能本地生成 `creds.json`,从而按 locally-managed 跑本地规则 |
|
||||||
|
| `cert.pem` 内含 `-----BEGIN ARGO TUNNEL TOKEN-----` 块,解出 `accountID` + `apiToken` / `serviceKey` | 同文件 `parse_cert_pem` | 只有账号模式持有云端 API 凭据,才能 push / fetch 云端 ingress |
|
||||||
|
| `start_tunnel` 在 token 非空时优先用 `--token` 启动,即使已经生成了本地 `config.yml` | `src/services/tunnel.rs` 的 `start_tunnel` | **本地规则会被云端覆盖**;Fonu 必须显式选择「本地生效」还是「云端生效」 |
|
||||||
|
| 命名隧道**不传** `--metrics`,从 stderr 解析 `starting metrics server on 127.0.0.1:<port>/metrics`;快速隧道传 `--metrics 127.0.0.1:0` | `start_tunnel` / `start_quick_tunnel` | metrics 端口必须从日志发现,不能固定,否则多实例会撞端口 |
|
||||||
|
| 健康用 `cloudflared_tunnel_ha_connections > 0` 判定已连接;QUIC 有字节指标,HTTP/2 没有 | `start_metrics_scraper` | `/ready` 只适合做就绪判断,连接数与带宽要走 metrics;HTTP/2 带宽要明确降级 |
|
||||||
|
| 二进制下载只校验「文件 >= 10MB」,没有 SHA-256;`get_available_mirrors` 返回的镜像 ID 与实际支持的镜像不一致 | `src/services/binary.rs` | Fonu 必须做哈希 / 版本校验,并统一镜像表 |
|
||||||
|
| ingress 三条不变式:末条固定 `http_status:404`、`localhost` 归一化为 `127.0.0.1`、新规则插在 catch-all 之前 | `add_tunnel_endpoint` / `add_tunnel_endpoint_local_only` | 生成器必须锁定这三条并加纯函数测试 |
|
||||||
|
| Windows 子进程设置 `CREATE_NO_WINDOW` | `start_tunnel` | Fonu 在 Windows 托管 `cloudflared.exe` 时必须带,否则闪黑窗 |
|
||||||
|
| 快速隧道从 stderr 解析 `*.trycloudflare.com` URL 并缓存 | `start_quick_tunnel` | 只能做「临时演示」;URL 每次重启都会变 |
|
||||||
|
| 存储是 JSON 文件 + 内存 `HashMap<String, Child>` | `src/storage/config.rs`、`TunnelProcessManager` | Fonu 用 SQLite + 迁移,多实例与并发状态更稳 |
|
||||||
|
|
||||||
|
### 12.2 部署位置:原方案最大的空白
|
||||||
|
|
||||||
|
原方案默认「本机跑 cloudflared」,但没有回答一个关键问题:**Fonu 是容器化部署,容器里的 `127.0.0.1` 不是宿主机。**
|
||||||
|
|
||||||
|
Fonu 的运行时(`Dockerfile`)在同一个容器里装了主程序与 nginx,`docker-compose.yml` 只映射 `6893` / `80` / `443`。因此 v2 的默认拓扑是:
|
||||||
|
|
||||||
|
> **cloudflared 跑在 Fonu 容器内,与 nginx 同命名空间;CF ingress 统一指向 `http://127.0.0.1:<nginx_http_port>`(默认 80),由 nginx 再转发到上游。**
|
||||||
|
|
||||||
|
这样做有四个直接收益:
|
||||||
|
|
||||||
|
1. 绕开容器网络命名空间的坑:`127.0.0.1` 在容器内确定指向 Fonu nginx,而不是宿主机或上游。
|
||||||
|
2. 复用现有 nginx 的安全、限流、geo、TLS、错误页与 Basic Auth,不在 CF 侧重复一套策略。
|
||||||
|
3. 规则同源:CF 只负责「域名 → Fonu nginx」,nginx 负责「域名 → 上游」;本机反代规则仍是唯一事实来源。
|
||||||
|
4. 只有 HTTP / HTTPS 规则能进 CF;TCP / UDP 规则明确排除,避免用户误解。
|
||||||
|
|
||||||
|
三种拓扑的取舍:
|
||||||
|
|
||||||
|
| 拓扑 | cloudflared 位置 | ingress 目标 | 适用 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **容器内(默认)** | Fonu 容器 | `http://127.0.0.1:80`(容器内 nginx) | 绝大多数部署;规则先接入本机 nginx |
|
||||||
|
| host 网络 | Fonu 容器 `network_mode: host` | `http://127.0.0.1:80`(宿主机 nginx) | 需要直接访问宿主机端口、或已有宿主机 nginx |
|
||||||
|
| VPS Agent(可选) | VPS 上的 `fonu-agent` 托管 | `http://127.0.0.1:<frps vhost 端口>` | 已有 VPS、只想让部分域名走 CF 的高级用户 |
|
||||||
|
|
||||||
|
部署注意事项:
|
||||||
|
|
||||||
|
- cloudflared 是**出站 only**,不要为它额外映射任何入站端口。
|
||||||
|
- 二进制、`creds.json`、`cert.pem`、`config.yml`、日志都必须落在 `/data` 持久卷下,否则容器重建即丢失。
|
||||||
|
- 如果要暴露宿主机或内网服务,推荐先把该服务接入 Fonu 的本机 nginx 反代,再让 CF 指向 nginx;不要在 CF ingress 里直接写宿主机 IP。
|
||||||
|
- host 网络模式只作为显式选项,不默认开启;开启后 `127.0.0.1` 的语义会变化,UI 必须提示。
|
||||||
|
|
||||||
|
### 12.3 凭据与模式:显式区分「谁在生效」
|
||||||
|
|
||||||
|
参考实现证明了 token 可以解码,但也暴露了一个陷阱:**token 启动与本地 `config.yml` 是互斥的**。v2 把模式拆成三种,UI 必须让用户明确选择并显示当前生效侧:
|
||||||
|
|
||||||
|
| 模式 | 凭据 | 启动方式 | 规则来源 | 云端 fetch / push |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| **A. token + 本地规则** | token | 解码 token → 写 `creds.json` → `tunnel --config config.yml run` | Fonu 生成的 `config.yml` | ❌(token 不含 API 凭据) |
|
||||||
|
| **B. token + 云端规则** | token | `tunnel run --token <token>` | Cloudflare Dashboard / 云端 | ❌ |
|
||||||
|
| **C. 账号 + 本地规则 + 云端同步** | `cert.pem` + credentials JSON | `tunnel --config config.yml run` | 本地,可与云端互相同步 | ✅ |
|
||||||
|
|
||||||
|
实施口径:
|
||||||
|
|
||||||
|
- 模式 A 绝不传 `--token`,只用 `--config`,否则本地规则不生效。
|
||||||
|
- 模式 B 必须在 UI 明确写「规则由 Cloudflare 面板管理,Fonu 的本地预览不生效」,不要让用户以为 Fonu 生成的规则已经下发。
|
||||||
|
- 模式 C 才提供「从云端拉取」「推送到云端」两个动作;token 模式不提供,只给 Dashboard 指引。
|
||||||
|
- 所有凭据走 `secret.Box` 加密字段;预览一律掩码,下发时用原文。`creds.json` 权限 `0600`,`config.yml` 权限 `0640`,目录 `0750`。
|
||||||
|
- 状态接口返回 `effective_side: local | cloud`,前端常驻显示,切换模式时要求二次确认。
|
||||||
|
|
||||||
|
### 12.4 数据模型与目录布局(Fonu 版)
|
||||||
|
|
||||||
|
参考实现用 JSON 文件 + 内存 HashMap,适合单进程小工具;Fonu 已有 SQLite 与迁移体系,v2 建议落成表:
|
||||||
|
|
||||||
|
`cf_tunnels`:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `id` / `name` | 主键与用户可见名称 |
|
||||||
|
| `mode` | `token-local` / `token-remote` / `account-local` |
|
||||||
|
| `tunnel_id` / `account_tag` | 从 token 或 credentials 解析 |
|
||||||
|
| `credentials_enc` | token 或 credentials JSON 密文 |
|
||||||
|
| `cert_pem_enc` | 账号模式 `cert.pem` 密文 |
|
||||||
|
| `api_token_enc` | 可选的云端同步 API Token 密文 |
|
||||||
|
| `config_path` / `creds_path` / `log_path` | 运行文件路径 |
|
||||||
|
| `binary_version` | 当前使用的 cloudflared 版本 |
|
||||||
|
| `network_json` | protocol / edge-ip-version / ha-connections / proxy_mode / originRequest |
|
||||||
|
| `status` / `last_error` | `stopped` / `starting` / `connected` / `reconnecting` / `failed` |
|
||||||
|
| `metrics_port` | 从日志发现的端口,仅运行时缓存 |
|
||||||
|
| `auto_start` | Fonu 启动时是否自动拉起 |
|
||||||
|
|
||||||
|
目录布局:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<DataDir>/cloudflared/
|
||||||
|
bin/<version>/cloudflared
|
||||||
|
tunnels/<id>/
|
||||||
|
config.yml
|
||||||
|
creds.json
|
||||||
|
cert.pem
|
||||||
|
cloudflared.log
|
||||||
|
```
|
||||||
|
|
||||||
|
多实例 = 多目录 + 多子进程 + 多日志文件;快速隧道单独用 `mode=quick` 记录,`public_url` 只在运行期有效。
|
||||||
|
|
||||||
|
### 12.5 进程与二进制管理
|
||||||
|
|
||||||
|
建议新建 `internal/cloudflared` 包,沿用 `internal/frp` 的 runtime / manager 模式,但不直接耦合 frp 模型:
|
||||||
|
|
||||||
|
**二进制**
|
||||||
|
|
||||||
|
- 统一镜像表:`official` / `gh-proxy-org` / `gh-proxy-hk` / `gh-proxy-cdn` / `gh-proxy-edgeone` + 自定义 URL 模板;修正参考项目「镜像列表与实现不一致」的问题。
|
||||||
|
- 下载校验:优先官方 checksums(若提供);否则至少校验文件大小、可执行性、`cloudflared --version`;镜像源与官方源使用同一份哈希。
|
||||||
|
- 版本目录 + 原子替换 + 激活指针,复用 `internal/frp/runtime.go` 的 `readActiveBinary` / `writeActiveBinary` 思路。
|
||||||
|
- 支持 linux amd64 / arm64、windows amd64、darwin amd64 / arm64;Fonu 侧用 `runtime.GOOS/GOARCH` 选择。
|
||||||
|
|
||||||
|
**进程**
|
||||||
|
|
||||||
|
- `exec.Command` + stdout / stderr 逐行读取,同时追加写入 `<tunnel>/cloudflared.log`。
|
||||||
|
- Windows 设置 `CREATE_NO_WINDOW`(`0x08000000`)。
|
||||||
|
- 启动参数:
|
||||||
|
- 模式 A / C:`tunnel --config <config.yml> run`
|
||||||
|
- 模式 B:`tunnel run --token <token>`
|
||||||
|
- 快速隧道:`tunnel --metrics 127.0.0.1:0 --url <target>`
|
||||||
|
- 网络参数:`--ha-connections`、`--origin-ca-pool`、`--no-tls-verify`、`--http-host-header`;环境变量 `TUNNEL_TRANSPORT_PROTOCOL`、`TUNNEL_EDGE_IP_VERSION`。
|
||||||
|
|
||||||
|
**metrics 端口发现**
|
||||||
|
|
||||||
|
- 命名隧道不传 `--metrics`;快速隧道传 `--metrics 127.0.0.1:0`。
|
||||||
|
- 从 stderr 用正则解析:`starting metrics server on 127\.0\.0\.1:(\d+)/metrics`。
|
||||||
|
- 解析到端口后轮询 `http://127.0.0.1:<port>/metrics`,最多等待 60 秒;超时给出明确原因,不留下空白状态。
|
||||||
|
|
||||||
|
**健康判定**
|
||||||
|
|
||||||
|
- `/ready` 返回 200:cloudflared 已就绪。
|
||||||
|
- `cloudflared_tunnel_ha_connections > 0`:已连上边缘。
|
||||||
|
- 延迟:QUIC 用 `quic_client_smoothed_rtt`;HTTP/2 用 `cloudflared_rpc_client_latency_secs` 或主动 HEAD 探测。
|
||||||
|
- 带宽:QUIC 直接用 `quic_client_receive_bytes` / `quic_client_sent_bytes` 差值;HTTP/2 官方没有字节指标,Fonu 第一轮明确降级为「不展示带宽」并给出原因,不做参考项目那套进程 socket 统计。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12.6 ingress 生成器(与 nginx 规则同源)
|
||||||
|
|
||||||
|
输入:
|
||||||
|
|
||||||
|
- 本机反代规则中启用且属于 HTTP / HTTPS 的域名;
|
||||||
|
- Fonu nginx 的容器内入口端口(默认 `80`,可用配置覆盖);
|
||||||
|
- 用户勾选要暴露的域名集合。
|
||||||
|
|
||||||
|
输出:`config.yml` 的 `ingress` 数组。三条不变式必须写进生成器并用纯函数测试锁定:
|
||||||
|
|
||||||
|
1. 末条永远是 `- service: http_status:404`;
|
||||||
|
2. `localhost` 一律归一化为 `127.0.0.1`;
|
||||||
|
3. 新规则插在 catch-all 之前,且同域名去重、排序稳定。
|
||||||
|
|
||||||
|
与 nginx 的关系:
|
||||||
|
|
||||||
|
```text
|
||||||
|
访客 → Cloudflare 边缘 → cloudflared(Fonu 容器)→ 127.0.0.1:80(Fonu nginx)→ 上游
|
||||||
|
```
|
||||||
|
|
||||||
|
- CF 侧不重复配置限流、geo、TLS、Basic Auth;这些仍由本机 nginx 负责。
|
||||||
|
- 只允许 HTTP / HTTPS 规则进入 CF ingress;TCP / UDP 规则在界面上明确标为「Cloudflare 隧道不支持」。
|
||||||
|
- 生成后提供「配置预览(脱敏)」与「生成后自检」,自检至少覆盖三条不变式与空 ingress 场景。
|
||||||
|
|
||||||
|
### 12.7 网络调优(默认值与下发方式)
|
||||||
|
|
||||||
|
| 设置 | Fonu 默认 | 下发方式 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `transport_protocol` | `http2` | 环境变量 `TUNNEL_TRANSPORT_PROTOCOL` + `config.yml` 的 `protocol` | 国内 QUIC(UDP 7844)常被 QoS,TCP 更稳;提供 `auto` / `quic` |
|
||||||
|
| `edge_ip_version` | `4` | 环境变量 `TUNNEL_EDGE_IP_VERSION` + `config.yml` 的 `edge-ip-version` | 2026.4.0 起官方默认改为 `auto`,国内 IPv6 路由差时反而变慢;提供 `auto` / `6` |
|
||||||
|
| `ha_connections` | `2` | `--ha-connections` + `config.yml` 的 `ha-connections` | 每个 region 的连接数,1–4 可调 |
|
||||||
|
| `proxy_mode` | `system` | 启动时按需移除 / 保留 `HTTP_PROXY` 等环境变量 | `disabled` 时清空代理变量;检测到代理或 Windows 时协议回落 `http2` |
|
||||||
|
| `originRequest` | 空 | `--origin-ca-pool` / `--no-tls-verify` / `--http-host-header` | 仅高级用户;默认不回源 HTTPS,避免自签证书问题 |
|
||||||
|
|
||||||
|
诊断接口返回:平台、代理环境探测结果、实际协议、实际代理模式、建议。降级必须给出原因,例如 `windows_estats_requires_admin`、`http2_official_metrics_unavailable`。
|
||||||
|
|
||||||
|
边缘优选(阶段 3 可选):
|
||||||
|
|
||||||
|
- 只做「本机 → CF 边缘」的 TCP 7844 延迟探测与展示,不代写 hosts;
|
||||||
|
- 两个 region 都要覆盖,IP 列表可配置、有默认值、可一键恢复默认;
|
||||||
|
- 访客到自己那侧 CF 边缘的一段无法优选,功能说明必须写清楚。
|
||||||
|
|
||||||
|
### 12.8 云端同步(仅账号模式)
|
||||||
|
|
||||||
|
账号模式(`cert.pem`)才提供云端同步,API 细节:
|
||||||
|
|
||||||
|
- 拉取:`GET https://api.cloudflare.com/client/v4/accounts/{account_id}/cfd_tunnel/{tunnel_id}/configurations`
|
||||||
|
- 推送:`PUT` 同一路径,body 为 `{"config":{"ingress":[...]}}`
|
||||||
|
- 推送的 ingress 必须包含末条 catch-all,否则 Cloudflare 可能拒绝或行为不确定。
|
||||||
|
|
||||||
|
冲突处理:
|
||||||
|
|
||||||
|
- UI 常驻显示 `effective_side: local | cloud`;
|
||||||
|
- 「拉取到本地」会覆盖本地 `config.yml`,操作前备份当前版本(复用本仓 `fsutil.BackupVersioned`);
|
||||||
|
- 「推送到云端」会覆盖云端 ingress,操作前展示 diff 并二次确认;
|
||||||
|
- 两个动作都写审计日志(操作者、隧道、方向、结果)。
|
||||||
|
|
||||||
|
token 模式不提供 push / fetch,只给 Dashboard 指引;这是凭据能力的硬边界,不要用「同步失败」这类模糊提示掩盖。
|
||||||
|
|
||||||
|
### 12.9 与 Fonu 现有模块的集成点
|
||||||
|
|
||||||
|
| Fonu 模块 | 复用还是新增 | 具体落点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 二进制下载 / 启用 | 新增 `internal/cloudflared`,参考 `internal/frp/runtime.go` | 镜像表、哈希校验、版本目录、激活指针 |
|
||||||
|
| 进程托管 | 新增 `internal/cloudflared`,参考 `internal/frp/manager.go` | 启停、日志追加、退出原因、`tailFile` |
|
||||||
|
| 健康与可用率 | 复用 `internal/monitor` 的 `Source` / `HealthRecorder` | 新增 CF Tunnel Source;`/ready` + `ha_connections` |
|
||||||
|
| 通知 | 复用 `internal/notify` | 隧道断开 / 恢复事件 |
|
||||||
|
| 状态页 | 复用现有公开状态页 | 新增隧道条目与可用率 |
|
||||||
|
| 规则来源 | 复用 `internal/proxy` 的本机反代规则 | 只取 HTTP / HTTPS 域名,生成 ingress |
|
||||||
|
| 日志界面 | 复用现有日志页模型 | 读 `<tunnel>/cloudflared.log`,不引入 WebSocket |
|
||||||
|
| 凭据加密 | 复用 `internal/secret` | token / cert.pem / credentials JSON 全部加密 |
|
||||||
|
| frp 链路 | **不改** | CF Tunnel 是并存出口,不碰现有 frp 规则、健康与状态语义 |
|
||||||
|
|
||||||
|
### 12.10 修订后的阶段计划
|
||||||
|
|
||||||
|
| 阶段 | 交付物 | 验收标准 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 阶段 1:凭据与配置 | 凭据导入(token / cert.pem)、模式选择、`creds.json` + `config.yml` 生成、脱敏预览 | 三种模式都能生成文件;模式 B 明确提示本地规则不生效;凭据不明文回显 |
|
||||||
|
| 阶段 2:进程与健康 | 二进制下载 / 启用、进程启停、日志、metrics 端口发现、`/ready` + 连接数健康 | 启动后能连上边缘;停止后进程与端口释放;健康状态落库并进通知 |
|
||||||
|
| 阶段 3:规则同源与云端同步 | 从本机反代规则生成 ingress、漂移检测、账号模式 fetch / push、网络调优设置 | ingress 三条不变式测试通过;云端同步有 diff 与审计;网络参数可配置 |
|
||||||
|
| 阶段 4(可选) | 边缘优选、快速隧道、高级 originRequest | 优选只展示不越权;快速隧道明确标注「临时演示」 |
|
||||||
|
|
||||||
|
### 12.11 测试与验收
|
||||||
|
|
||||||
|
单元测试:
|
||||||
|
|
||||||
|
- token 解码:标准 base64、缺填充、非法输入、`a/t/s` 与 `AccountTag/TunnelID/TunnelSecret` 两种字段名;
|
||||||
|
- ingress 生成:catch-all、`localhost` 归一化、插入位置、去重、空规则;
|
||||||
|
- metrics 解析:端口行、QUIC / HTTP/2 指标、连接数、降级原因;
|
||||||
|
- 网络参数构建:protocol / edge-ip-version / ha-connections / proxy_mode;
|
||||||
|
- 镜像 URL 与自定义模板;
|
||||||
|
- 凭据脱敏:token、cert.pem、credentials JSON 不出现在预览与日志。
|
||||||
|
|
||||||
|
集成测试:
|
||||||
|
|
||||||
|
- 启动 / 停止 / 重启 / 自动启动;
|
||||||
|
- metrics 端口发现与健康状态流转;
|
||||||
|
- 日志追加与 tail;
|
||||||
|
- 云端 fetch / push 用 mock API 验证请求体、catch-all 与错误处理;
|
||||||
|
- 容器内 `127.0.0.1:80` 回源链路。
|
||||||
|
|
||||||
|
前端:
|
||||||
|
|
||||||
|
- 隧道列表 / 详情 / 状态 / 日志 / 配置预览;
|
||||||
|
- 模式选择与「当前生效侧」提示;
|
||||||
|
- 脱敏预览与二次确认。
|
||||||
|
|
||||||
|
真机矩阵:Linux amd64 / arm64、Windows、Docker 容器内、host 网络、代理环境、国内与海外链路。
|
||||||
|
|
||||||
|
### 12.12 风险与边界(补充)
|
||||||
|
|
||||||
|
- **容器网络**:默认拓扑是容器内 cloudflared → 容器内 nginx;不要假设 `127.0.0.1` 是宿主机。
|
||||||
|
- **token 覆盖**:模式 B 的本地 `config.yml` 不生效;UI 必须明确,模式 A 才能本地管规则。
|
||||||
|
- **HTTP/2 带宽**:官方 metrics 没有字节指标;第一轮明确降级并说明原因,不做进程 socket 统计。
|
||||||
|
- **Windows**:`CREATE_NO_WINDOW` 必带;若做进程级字节统计还需要管理员权限,Fonu 第一轮不做。
|
||||||
|
- **CF 条款**:视频 / 大文件分发不在推荐用法内;TryCloudflare 不保证 SLA。
|
||||||
|
- **国内链路**:CF 边缘延迟受运营商影响;优选只影响本机到边缘一段。
|
||||||
|
- **API 凭据**:账号模式的 `cert.pem` 与可选 API Token 都是高权限凭据,必须最小化权限并加密存储。
|
||||||
|
- **Access**:只做诊断提示,不代配策略。
|
||||||
|
- **TCP / UDP**:CF Tunnel 不是通用 TCP 出口;需要访问端装 cloudflared 的场景不在首轮范围。
|
||||||
|
- **不改 frp**:现有 frp 链路、健康与状态页语义保持不变。
|
||||||
|
|
||||||
|
### 12.13 结论
|
||||||
|
|
||||||
|
Cloudflare Tunnel 对 Fonu 是**第二条出口**,不是 frp 的替代:它补上「没有公网 IP、也不想长期租 VPS」的 HTTP 类场景。v2 的核心修订是四件事:
|
||||||
|
|
||||||
|
1. cloudflared 跑在 Fonu 容器内,ingress 统一指向容器内 nginx,绕开容器网络坑;
|
||||||
|
2. 显式区分「本地规则生效」与「云端规则生效」,不让 token 模式悄悄覆盖本地配置;
|
||||||
|
3. metrics 端口从日志发现,健康用 `/ready` + `ha_connections`,HTTP/2 带宽明确降级;
|
||||||
|
4. 二进制下载补哈希 / 版本校验,ingress 生成器锁定三条不变式。
|
||||||
|
|
||||||
|
按 §12.10 的阶段 1–3 实施即可形成可用闭环;阶段 4 的边缘优选与快速隧道作为可选增强,不阻塞主线。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 说明:本文只做方案分析,未改动任何代码。实施时沿用本仓库既有约定:每步验证 `go build/vet/test` + `vue-tsc/vite build`、agent 行为变更递增 `AgentVersion`、提交同批更新 `CHANGELOG` 与 `VERSION`、密钥相关内容必须过一遍「预览脱敏 / 下发原文」两头检查。
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# Fonu 容器部署与数据指南
|
||||||
|
|
||||||
|
> 适用版本:v1.2.19 及以后(Docker / 飞牛 fnOS 等 NAS 环境)
|
||||||
|
> 相关文件:`docker-compose.yml`、`docker-compose.hub.yml`、`docker-compose.host.yml`、`Dockerfile`、`scripts/upgrade.sh`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、三个编排文件怎么选
|
||||||
|
|
||||||
|
| 文件 | 构建方式 | 适用场景 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `docker-compose.hub.yml` | **从 Git 仓库拉取源码**构建(`build.context` 是仓库地址) | **干净机器首选**:只要这一个文件,不需要先 `git clone` |
|
||||||
|
| `docker-compose.yml` | 用**本地源码**构建(`context: .`) | 已经有源码目录(改代码后直接重建最方便) |
|
||||||
|
| `docker-compose.host.yml` | 本地源码构建 + `network_mode: host` | 80/443 已被别的服务占用(如飞牛、群晖自带反代) |
|
||||||
|
|
||||||
|
三份都写了 `name: fonu`:**不要删掉这行**。不写它,改了目录名/项目名后 compose 会把它当成新项目,`fonu-data` 变成新的空卷——现象就是「数据全没了」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、干净机器部署(推荐路径)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /opt/fonu && cd /opt/fonu
|
||||||
|
# 从仓库取这一份编排文件(用你仓库页面上的「原始文件」链接,或 git clone 后复制)
|
||||||
|
# curl -fsSLo docker-compose.hub.yml <仓库里该文件的原始地址>
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs -f fonu # 首次启动会初始化数据库、生成会话密钥
|
||||||
|
```
|
||||||
|
|
||||||
|
首次访问 `http://<机器IP>:6893`,用日志里的初始口令登录。初始口令只在**首次启动**生成一次:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs fonu 2>&1 | grep -i password | tail -n 3
|
||||||
|
```
|
||||||
|
|
||||||
|
80/443 被占用的环境改用 host 那份:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
COMPOSE_FILE=docker-compose.host.yml docker compose up -d --build
|
||||||
|
# 反代端口默认 18080/9443(可用 FONU_NGINX_HTTP_PORT / FONU_NGINX_HTTPS_PORT 覆盖)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、数据存在哪
|
||||||
|
|
||||||
|
数据**不在镜像里**,全部在数据目录中(容器内固定为 `/data`,由 `FONU_DATA_DIR` 指定):
|
||||||
|
|
||||||
|
| 路径 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| `fonu.db` | SQLite 主库:反代规则、穿透规则与服务端、DDNS、证书记录、设置、API Token、健康事件、指标历史 |
|
||||||
|
| `session_secret` | 会话与**凭据加密密钥**(v1.2.17 起未配置时自动生成,0600) |
|
||||||
|
| `logs/` | `app.log`(应用日志)、`access.log` / `error.log`(本机 Nginx) |
|
||||||
|
| `certs/` | 证书与私钥 |
|
||||||
|
| `nginx/` | Fonu 生成的 Nginx 配置与 pid |
|
||||||
|
| `frp/` | frpc 二进制、各服务端的 `frpc-server-N.toml` 与日志 |
|
||||||
|
| `ssh_known_hosts` | SSH 主机密钥 TOFU 记录 |
|
||||||
|
| `backups/` | 应用内「数据备份」导出的归档 |
|
||||||
|
|
||||||
|
### 3.1 默认:命名卷
|
||||||
|
|
||||||
|
默认挂在命名卷 `fonu-data`(compose 项目名是 `fonu`,所以完整卷名是 `fonu_fonu-data`),由 Docker 管理,位置随 Docker 数据根。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker volume ls | grep fonu
|
||||||
|
docker volume inspect fonu_fonu-data --format '{{.Mountpoint}}'
|
||||||
|
docker compose exec fonu ls -l /data # 容器内视角,最直观
|
||||||
|
```
|
||||||
|
|
||||||
|
飞牛这类 NAS 的 Docker 数据根常在 `/vol1/docker`,于是实际落在
|
||||||
|
`/vol1/docker/volumes/fonu_fonu-data/_data`。**以 `docker volume inspect` 为准**,各家不同。
|
||||||
|
|
||||||
|
### 3.2 指定宿主机目录(bind mount)
|
||||||
|
|
||||||
|
在同层 `.env` 里写一行即可,以 `/` 或 `.` 开头的路径会被 compose 当成 bind mount:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo 'FONU_DATA_PATH=/vol1/docker/fonu-data' >> .env
|
||||||
|
```
|
||||||
|
|
||||||
|
从**已有命名卷**迁过去(迁移前先停服务):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
sudo mkdir -p /vol1/docker/fonu-data
|
||||||
|
docker run --rm -v fonu_fonu-data:/from -v /vol1/docker/fonu-data:/to alpine sh -c 'cp -av /from/. /to/'
|
||||||
|
docker compose up -d
|
||||||
|
docker compose exec fonu ls -l /data # 确认数据还在
|
||||||
|
```
|
||||||
|
|
||||||
|
反过来(bind → 卷)同理,把两边路径对调即可。**不要**只做一边的拷贝就改配置,否则会看到「空实例」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、备份与恢复
|
||||||
|
|
||||||
|
**整目录备份**(推荐,含密钥与证书):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v fonu_fonu-data:/data -v "$PWD":/backup alpine \
|
||||||
|
tar czf /backup/fonu-data-$(date +%F).tgz -C /data .
|
||||||
|
```
|
||||||
|
|
||||||
|
三个必须知道的点:
|
||||||
|
|
||||||
|
1. 千万别只拷 `fonu.db`:`session_secret` 是凭据的加密密钥,缺了它库里存的 frp token、通知密码都解不开;
|
||||||
|
2. 不要在服务运行时手改 `fonu.db`(SQLite 单连接,容易被写坏)——要改先 `docker compose stop fonu`;
|
||||||
|
3. 应用内「设置 → 数据」也有备份/恢复按钮,归档落在 `backups/`,恢复后需要重启容器。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、升级与回滚
|
||||||
|
|
||||||
|
### 5.1 一条命令(推荐)
|
||||||
|
|
||||||
|
仓库自带 `scripts/upgrade.sh`(在部署机上执行):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash scripts/upgrade.sh # 拉代码 → 构建 → 重建 → 健康检查
|
||||||
|
sudo bash scripts/upgrade.sh --no-pull # 用当前源码重建
|
||||||
|
sudo bash scripts/upgrade.sh --rollback 1.2.18 # 回滚到本地已有的镜像 tag(不重建)
|
||||||
|
COMPOSE_FILE=docker-compose.host.yml sudo bash scripts/upgrade.sh # host 网络那份
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会打印升级前后的镜像 tag,并轮询 `http://127.0.0.1:6893/api/version` 确认起来。
|
||||||
|
|
||||||
|
### 5.2 手动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/fonu && docker compose pull 2>/dev/null; docker compose up -d --build
|
||||||
|
docker images fonu # 看历史上留下哪些 tag,回滚时用
|
||||||
|
```
|
||||||
|
|
||||||
|
镜像 tag 由 `FONU_VERSION` 决定(`scripts/upgrade.sh` 会从仓库 `VERSION` 自动取),不设时是 `fonu:local`。
|
||||||
|
|
||||||
|
镜像是本地构建的,所以升级不需要 `docker pull`;上面那条 `up` 会带上 `--build` 重新构建。
|
||||||
|
|
||||||
|
### 5.3 只有编排文件时(`docker-compose.hub.yml` 部署,推荐路径)
|
||||||
|
|
||||||
|
构建上下文是 Git 地址,所以**每次 `--build` 都会重新解析仓库分支**(日志里那句 `load git source ... refs/heads/main` 就是它),不需要 `git pull`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/fonu
|
||||||
|
docker compose up -d --build # 重新克隆 main → 构建 → 按新镜像重建容器
|
||||||
|
```
|
||||||
|
|
||||||
|
建议**带上版本号**,给镜像留一个固定 tag(否则一直是 `fonu:local`,回滚时找不到旧版):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FONU_VERSION=1.2.19 docker compose up -d --build
|
||||||
|
# 回滚:镜像还在本地,不用重新构建
|
||||||
|
FONU_VERSION=1.2.18 docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
版本号从仓库的 `CHANGELOG.md` 或 `VERSION` 文件取;面板左侧栏底部也会显示当前运行的版本。
|
||||||
|
|
||||||
|
这套部署方式下,`scripts/upgrade.sh` 不太适用(它需要本地源码目录来 `git pull` 与读 `VERSION`)——如果你希望也能一条命令搞定,可以把这两个文件(`scripts/upgrade.sh` + `VERSION`)也放到部署目录,或告诉我,我给 hub 变体加一个专用脚本。
|
||||||
|
|
||||||
|
### 5.4 升级前后的两件小事
|
||||||
|
|
||||||
|
- **升级前备份**(整目录,含密钥):见 §四那条 `tar` 命令;遇到异常可整个目录回滚。
|
||||||
|
- **升级后确认**:`docker compose logs -f fonu` 看启动是否正常(会打印「会话密钥已就绪」),再刷新面板看版本与功能。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、环境变量速查
|
||||||
|
|
||||||
|
| 变量 | 默认 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `FONU_DATA_DIR` | `/data` | 容器内数据目录,一般不用改;要换位置改**挂载** |
|
||||||
|
| `FONU_DATA_PATH` | `fonu-data` | **宿主机侧**数据位置:命名卷名,或绝对路径(bind mount) |
|
||||||
|
| `FONU_LISTEN` | `:6893` | 面板监听地址 |
|
||||||
|
| `FONU_SESSION_SECRET` | 空 | 留空即自动生成并持久化;**它是凭据加密密钥,有数据后不要更换** |
|
||||||
|
| `FONU_NGINX_HTTP_PORT` / `FONU_NGINX_HTTPS_PORT` | `80` / `443` | 反代监听端口(host 网络那份默认 18080/9443) |
|
||||||
|
| `FONU_UPDATER_SOCKET` | `/run/fonu-updater/updater.sock` | 一键升级 sidecar 的 Unix Socket |
|
||||||
|
| `DOCKER_SOCK` | `/var/run/docker.sock` | 宿主机 Docker Socket;rootless Docker 或自定义路径时覆盖 |
|
||||||
|
| `TZ` | `Asia/Shanghai` | 通知与日志显示用的时区 |
|
||||||
|
| `NODE_IMAGE` / `GO_IMAGE` / `RUNTIME_IMAGE` / `UPDATER_IMAGE` | 国内镜像源 | 构建用的基础镜像,被加速器拦截时用来覆盖 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、常见问题
|
||||||
|
|
||||||
|
**构建报 `401 Unauthorized`(`resolve image config for docker-image://docker/dockerfile:1`)**
|
||||||
|
国内加速器(如飞牛的 `docker.fnnas.com`)会拦下 BuildKit 的 frontend 镜像。Dockerfile 已不再显式指定 `# syntax=`,正常不会再遇到;若仍报错,改用加速器能拉的地址:`# syntax=docker.m.daocloud.io/docker/dockerfile:1`,或去掉四处 `--mount=type=cache`。
|
||||||
|
|
||||||
|
**`error while interpolating ... FONU_SESSION_SECRET is missing a value`**
|
||||||
|
这是旧版 compose(把该变量写成必填)留下的行为。新版本是 `${FONU_SESSION_SECRET:-}`:留空即由应用自动生成。重新取一份编排文件即可。
|
||||||
|
|
||||||
|
**构建时 `fonu Pulling` 之后超时告警**
|
||||||
|
有 `build:` 段时 compose 仍会先尝试拉同名镜像;`.hub.yml` 已加 `pull_policy: build` 跳过。其余两份可忽略这条警告。
|
||||||
|
|
||||||
|
**换目录后「数据没了」**
|
||||||
|
几乎没有真的丢:要么新目录是空的(迁移没做),要么项目名变了导致卷名变了。先用 `docker volume ls` 找到旧卷,按 §3.2 拷过去。
|
||||||
|
|
||||||
|
**面板能开但「今日请求/实时流量/性能」没数据**
|
||||||
|
这三张卡读的是**本机 Nginx 访问日志**(`<数据目录>/logs/access.log`),只有在 Fonu 里配了本机反代规则并产生访问后才有数据;VPS 侧的流量在「公网反代」页与「日志中心 → 公网 Nginx」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、与原生安装的分工
|
||||||
|
|
||||||
|
不想用容器时可以用 `scripts/build-linux.ps1` 打出 `fonu-<版本>-linux-<arch>.tar.gz`,在目标机用 `sudo bash install.sh` 安装(装到 `/opt/fonu`,数据在 `/var/lib/fonu`,systemd 单元 `fonu.service`)。两者的数据目录结构完全一致,因此**可以互相迁移**:把一方的数据目录内容整个拷到另一方即可,注意带上 `session_secret`。
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
# 同类项目对标与增补建议
|
||||||
|
|
||||||
|
> 日期:2026-09-24 对象:Fonu 1.0.2(VERSION 1.2.10)
|
||||||
|
> 方法:检索 GitHub 上与本项目同域的开源项目(隧道/反代管理面板、自托管监控、Nginx 面板、DDNS 面板),逐项对照 Fonu 现状给出「可优化 / 可增补」。
|
||||||
|
> 可信度:对标项目的能力来自其首页 / README 摘要与检索片段,**未逐个深读源码**;Fonu 现状来自本仓库代码与 2026-09-24 的全项目审查(`docs/project-audit-2026-09-24.md`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、对标项目
|
||||||
|
|
||||||
|
| 项目 | 定位 | 与 Fonu 的关系 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [fosrl/pangolin](https://github.com/fosrl/pangolin) | 自托管**隧道式反代管理服务**,内置身份与访问控制 + 仪表盘 UI | 架构最接近:VPS 侧服务 + 内网侧隧道客户端 + 统一面板;差别在它主打**身份/访问控制**(SSO、资源级策略) |
|
||||||
|
| [vaala/frp-panel](https://github.com/vaala/frp-panel) | frp 的**中心化控制面板**(Go + gRPC + protobuf,React/Next + shadcn) | 同为「面板 + 多客户端」模型;它管的是多台内网 frpc,Fonu 管的是本机 frpc + 多台公网 frps |
|
||||||
|
| [henrygd/beszel](https://github.com/henrygd/beszel) | 轻量服务器监控,**hub + agent**,含历史数据、Docker 统计、告警、多用户、OAuth、API | 与 Fonu 的 app + fonu-agent 架构同型;它在「历史/告警/账号」上明显更成熟 |
|
||||||
|
| [0xJacky/nginx-ui](https://github.com/0xJacky/nginx-ui) | Nginx 的 Web 管理界面:配置**版本备份/对比/回滚**、集群、证书一键签发与续期、在线编辑、加密配置导出 | Fonu 的反代层与它重叠;它的配置版本管理值得直接借鉴 |
|
||||||
|
| [jeessy2/ddns-go](https://github.com/jeessy2/ddns-go) / [qdm12/ddns-updater](https://github.com/qdm12/ddns-updater) | DDNS 客户端:多服务商、Web UI、多记录类型、通知(ddns-updater 走 Shoutrrr,支持几十种渠道) | Fonu 的 DDNS 模块与其重叠;差距在**通知渠道数量**与记录类型覆盖 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、能力对照(✔ 已有 / ◐ 部分 / ✘ 无)
|
||||||
|
|
||||||
|
| 能力 | pangolin | frp-panel | beszel | nginx-ui | ddns-go | Fonu 现状 |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| 面板 + 内网 agent(SSH 引导安装) | ✔ | ✔ | ✔ | ◐ | ✘ | **✔**(SSH 装 agent、HTTP 管理、TOFU 主机密钥) |
|
||||||
|
| 多台公网服务端 + 多规则类型 | ✘ | ◐ | ✘ | ✘ | ✘ | **✔**(frps 多台、8 种穿透类型、规则按服务端归属) |
|
||||||
|
| 公网反代下发 + 证书推送 | ✔ | ◐ | ✘ | ✔ | ✘ | **✔**(vhost 模板生成、证书推送、部署漂移检测、四段健康检查) |
|
||||||
|
| 配置**版本历史 / 对比 / 回滚** | ◐ | ◐ | ✘ | **✔** | ✘ | **✘**(只有单份 `.bak`) |
|
||||||
|
| 指标**历史趋势** | ◐ | ◐ | **✔** | ◐ | ✘ | **✘**(仅当前值 + 最近 5 分钟采样) |
|
||||||
|
| **阈值型告警**(CPU/磁盘/连续失败) | ◐ | ✘ | **✔** | ✘ | ◐ | **✘**(只有事件型通知) |
|
||||||
|
| 通知渠道数量 | ◐ | ◐ | ✔ | ✘ | ✔ | ◐(邮件 / Webhook / Telegram) |
|
||||||
|
| **API Token**(脚本、HomeAssistant 调用) | ✔ | ✔ | ✔ | ✔ | ✔ | **✘**(仅会话 cookie) |
|
||||||
|
| 多用户 / OIDC / 只读账号 | **✔** | ◐ | ✔ | ◐ | ✘ | **✘**(单管理员) |
|
||||||
|
| 定时自动备份 + 保留策略 | ◐ | ◐ | **✔** | ✔ | ✘ | ◐(手动导出 / 恢复) |
|
||||||
|
| Docker 容器统计 | ✘ | ✘ | **✔** | ✘ | ✘ | **✘** |
|
||||||
|
| 加密配置导出 / 迁移包 | ◐ | ✘ | ✘ | **✔** | ✘ | ◐(脱敏导出,无导入) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、建议增补(按价值 / 成本排序)
|
||||||
|
|
||||||
|
### P0 · 成本低、直接补齐明显短板
|
||||||
|
|
||||||
|
1. **配置版本历史 + 对比 + 回滚**(学 nginx-ui)
|
||||||
|
- 现状:`internal/fsutil.Backup` 只保留一份 `<path>.bak`;公网反代页已有「漂移检测」能看出「VPS 上的与记录不一致」,但没有历史可比。
|
||||||
|
- 做法:把 `.bak` 升级为 `<path>.versions/<时间戳>.conf`(保留 N 份,沿用现有 `pruneBackups` 思路),复用公网反代页「漂移清单」的 UI 模式做「版本列表 + 差异高亮 + 一键回滚」。
|
||||||
|
- 落点:`internal/fsutil`(新增 `BackupVersioned`)、`internal/nginx/custom.go`(已有 `backupFile`/`pruneBackups` 可复用)、`web/src/views/ProxyRemoteView.vue`。
|
||||||
|
- 价值:误改配置后可一键回到上一个可用版本,比现在的「删掉重部署」可靠得多。
|
||||||
|
|
||||||
|
2. **API Token**(学 beszel / nginx-ui)
|
||||||
|
- 现状:97 条 API 路由只有会话 cookie 一种认证;脚本、HomeAssistant、手机快捷指令都调不动。
|
||||||
|
- 做法:`api_tokens` 表(名称 + 哈希 + 权限范围 + 最后使用时间),`RequireAuth` 中间件同时接受 `Authorization: Bearer`,设置页做管理(新建时只显示一次明文,沿用现有「掩码往返」约定)。
|
||||||
|
- 落点:`internal/api/middleware.go`、`internal/auth`、`web/src/views/SettingsView.vue`。
|
||||||
|
- 注意:这是新增认证面,必须走一次安全自查(令牌哈希存储、撤销、只读范围)。
|
||||||
|
|
||||||
|
3. **通知渠道补齐国内常用通道**(学 ddns-go / ddns-updater)
|
||||||
|
- 现状:邮件 / Webhook / Telegram。`web/src/constants/notifyProviders.ts` 里已有 `DEFAULT_BARK_SERVER`、`DEFAULT_NTFY_SERVER` 常量(说明预留了位置)。
|
||||||
|
- 做法:补 Bark、ntfy、企业微信、钉钉、飞书(都是「POST 一个 URL + JSON」模式,可复用现有 Webhook 通道的发送路径)。
|
||||||
|
- 落点:`internal/notify`、`web/src/constants/notifyProviders.ts`、`SettingsView.vue`。
|
||||||
|
- 价值:NAS 用户最常用 Bark / 企业微信,成本极低。
|
||||||
|
|
||||||
|
4. **阈值型告警**(学 beszel)
|
||||||
|
- 现状:通知全是事件型(DDNS 失败、证书到期、IP 频繁访问、登录失败、Nginx 重载失败),没有「CPU 持续 90%」「磁盘 85%」「隧道连续 3 次不通」这类。
|
||||||
|
- 做法:复用 `internal/scheduler`(已有 `recover()` 与间隔校验)加一个指标巡检任务 + 冷却时间,阈值与开关走设置项(现有 `notify_*` 事件开关的模式可直接照搬)。
|
||||||
|
- 落点:`internal/notify`、`internal/scheduler`、`internal/agent`(metrics 端点已存在,v1.2.4 加的)。
|
||||||
|
|
||||||
|
### P1 · 价值高、工作量中等
|
||||||
|
|
||||||
|
5. **指标历史与趋势图**(学 beszel)
|
||||||
|
- 现状:VPS 资源、隧道流量都只有「当前值 + 最近 5 分钟内存采样」,重启即丢。
|
||||||
|
- 做法:新增 `metrics_samples` 表(按 1 分钟落点 + 保留期设置),前端加 1h / 24h / 7d 切换(ECharts 与 `MiniTrafficChart` 已在用)。
|
||||||
|
- 价值:NAS 场景最关心磁盘与带宽趋势,这是当前仪表盘最大的能力缺口。
|
||||||
|
|
||||||
|
6. **健康检查持续化 + 只读状态页**(学 uptime-kuma / beszel)
|
||||||
|
- 现状:四段健康检查是「打开页面才算一次」,页面关掉就没有数据;`internal/agent/insights.go` 已有采样能力。
|
||||||
|
- 做法:后台按间隔对每条反代规则做一轮检查并落库,连续失败 N 次才告警、恢复时再通知一次;可加一个可公开的只读状态页(按规则展示最近 24 小时可用率)。
|
||||||
|
- 注意:状态页是**新增的匿名访问面**,必须单独设计(默认关闭、可设访问码)。
|
||||||
|
|
||||||
|
7. **定时自动备份 + 保留策略**(学 beszel / nginx-ui)
|
||||||
|
- 现状:手动「导出 / 恢复」;恢复路径的校验与权限刚在 v1.2.7 收紧。
|
||||||
|
- 做法:`scheduler` 加每日备份任务(写 `<DataDir>/backups`,保留 N 份,可选 WebDAV/S3 上传),设置页给保留份数。
|
||||||
|
|
||||||
|
8. **一键迁移包(加密导出 + 导入)**(学 nginx-ui 的加密配置导出)
|
||||||
|
- 现状:只有「脱敏配置导出」(给用户看,不含密钥),换机器得手动重填服务端与证书。
|
||||||
|
- 做法:导出含服务端 / 规则 / 证书 / 设置 / 密钥的加密包(口令派生密钥,用现有 `internal/secret`),导入时校验版本并落库。
|
||||||
|
- 价值:换 NAS、重装系统、备份恢复三类场景都会用到。
|
||||||
|
|
||||||
|
9. **Docker 容器统计**(学 beszel)
|
||||||
|
- 做法:agent 加只读 `GET /api/v1/docker`(容器列表 + CPU/内存/网络),公网服务端页加一张卡(`internal/sysinfo` 已有采集框架)。
|
||||||
|
- 注意:agent 侧要显式限制只读、并处理「VPS 上没装 Docker」的降级。
|
||||||
|
|
||||||
|
### P2 · 可选
|
||||||
|
|
||||||
|
10. **只读账号 / OIDC**(学 pangolin):给家人看仪表盘、或接入已有的 SSO。属权限模型改动,建议在 API Token 落地、认证面稳定后再做。
|
||||||
|
11. **DDNS 记录类型扩展**(学 ddns-go):显式支持 AAAA / CNAME / 多记录批量,并在 UI 上区分。
|
||||||
|
12. **日志增强**:按域名 / 规则过滤、导出 CSV(现在有级别筛选、关键词搜索、分页与倒序)。
|
||||||
|
13. **规则模板与克隆**:常用规则(HTTP 反代 + Basic Auth + 限流)存为模板,新建时一键套用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、现有功能的优化点(对标后回看自己)
|
||||||
|
|
||||||
|
| 位置 | 可优化 | 依据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 健康检查 | 现在每轮 7 个接口并行(v1.2.8 已改并行)+ 15 秒轮询;可把「健康检查」单独拉长到 60 秒、其余保持高频 | beszel 对采集频率按指标分层 |
|
||||||
|
| 通知 | 事件型通知缺「冷却时间」,连续事件会连发(登录失败已有窗口统计,可推广到全部事件) | uptime-kuma / beszel 都有冷却与静默期 |
|
||||||
|
| 证书 | 续期失败后的重试与提醒策略值得复核(到期提醒已有) | nginx-ui 强调「自动续期 + 失败可见」 |
|
||||||
|
| 备份 | `internal/backup` 的写入方 `defer Close()` 不检查错误(审查报告 §二已列,P1 未做) | 与 nginx-ui 的「备份可用性」要求一致 |
|
||||||
|
| 配置生成 | 反代配置由模板生成是**优点**(保证同源),不要为了对齐 nginx-ui 而开放全文在线编辑 | 本项目刚在 v1.2.8 收敛模板重复,开放全文编辑会破坏该约束 |
|
||||||
|
| 前端 | 颜色令牌收敛(231 处)与 i18n 起步仍在待办 | 与对标项目无关,属自身债务 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、明确不建议跟进
|
||||||
|
|
||||||
|
1. **多租户 / 组织模型**(pangolin 面向团队):Fonu 是个人 / 家庭 NAS 工具,引入组织、成员、资源级策略会让设置页与数据模型复杂度翻倍。
|
||||||
|
2. **Nginx 配置全文在线编辑**(nginx-ui 的路子):会破坏「反代配置由模板同源生成」这一核心保证;保留现有「自定义片段」入口即可。
|
||||||
|
3. **集群 / 多节点镜像**(nginx-ui 的集群管理):Fonu 的 app + agent 已是多节点模型,再叠一层集群属于重复抽象。
|
||||||
|
4. **MCP / AI 助手接入**(nginx-ui 新功能):与项目定位不符,且每个新接口都是新的攻击面,收益不明确。
|
||||||
|
5. **把 frps 面板做成 frp-panel 那样的「多 frpc 集中管理」**:Fonu 的 frpc 就跑在本机 NAS 上(单一客户端 + 多服务端),不需要再管别人的 frpc。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、建议实施顺序
|
||||||
|
|
||||||
|
1. 配置版本历史 + 回滚(P0-1)——与刚做完的 fsutil 收敛直接衔接,风险最低、收益最直观。
|
||||||
|
2. 通知渠道补齐 + 阈值告警(P0-3、P0-4)——复用现有通知与调度框架,改动集中在 `internal/notify`。
|
||||||
|
3. API Token(P0-2)——单独一轮,配一次安全自查。
|
||||||
|
4. 指标历史与趋势图(P1-5)+ 健康检查持续化(P1-6)——需要新表与后台任务,建议同一轮做(共用落点与保留期设置)。
|
||||||
|
5. 其余按需。
|
||||||
|
|
||||||
|
## 七、第二批方向(第二轮检索补充)
|
||||||
|
|
||||||
|
上一节偏「同域直接对标」;这一批来自相邻领域(零信任访问、状态页、加密备份、NAS 硬件监控、SSO/MFA、服务面板),都是 Fonu 已有模块的**能力延伸**,不需要新架构。
|
||||||
|
|
||||||
|
| 借鉴点 | 出处 | 落到 Fonu 哪里 | 价值 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **API Token 的到期 / 刷新 / 新旧密钥宽限期** | Cloudflare Zero Trust 的 Service Token | 补强 §三-P0-2:令牌加 `expires_at`、可刷新延长、轮换时旧密钥保留一段宽限期 | 让脚本 / HomeAssistant 的接入既可撤销又不会一改就断 |
|
||||||
|
| **状态页 + 事故(incident)记录** | Gatus、Uptime Kuma | 与 §三-P1-6 合并:持续健康检查落库后,除「可用率」再输出「不可用区间」列表(什么时间、哪条规则、多久) | 「昨晚为什么打不开」这类问题有处可查 |
|
||||||
|
| **Prometheus `/metrics` 导出** | Gatus | 新端点,把已有指标(VPS 资源、隧道流量、健康状态、证书剩余天数)按 Prometheus 文本格式暴露 | 一行配置就能接入 Grafana / HomeAssistant,比自建图表性价比高 |
|
||||||
|
| **事件订阅(webhook 推事件)** | Cloudflare 的 Event subscriptions | 现有通知是「发给人」;再加「推给系统」(把规则部署 / 证书续期 / 漂移检测事件 POST 到外部 URL) | 对接自动化(如部署后触发 CDN 刷新) |
|
||||||
|
| **2FA / TOTP + 登录设备与会话列表** | Authelia、Authentik | 补强 §三-P2-10:TOTP 绑定(二维码 + 一次性恢复码)、已登录设备列表与「踢下线」、登录历史 | Fonu 面板暴露在公网时,单密码是最大的残留风险;且与 v1.2.8 的「改密失效其他会话」天然衔接 |
|
||||||
|
| **审计日志**(谁、何时、改了什么) | Authelia、Authentik | 新增 `audit_logs` 表,在服务端 / 规则 / 证书 / 设置的写入口记一条(含来源 IP) | 多设备 / 多人使用时能回溯误操作 |
|
||||||
|
| **备份保留策略(GFS)+ 完整性校验** | restic / kopia | 补强 §三-P1-7:保留策略改成 `keep-daily/weekly/monthly`;恢复前先校验包内条目(数量 / 大小 / 时间)再落盘 | 备份不会因「保留太多份」撑爆磁盘,也不会拿半个包去覆盖现有数据 |
|
||||||
|
| **本机 SMART 磁盘健康 + UPS 状态** | NAS Doctor、Scrutiny、PeaNUT | 扩展 `internal/sysinfo`(本机侧,不经 agent):磁盘 SMART(温度 / 重分配扇区 / 待处理扇区 / 通电小时)、UPS 电量与剩余时间 | NAS 用户的真痛点:硬盘将坏、市电断了;目前仪表盘只有 CPU / 内存 / 磁盘占用 |
|
||||||
|
| **服务入口墙** | Homepage、Homarr、Glance | 仪表盘新增一区:把启用中的反代 / 穿透规则按域名聚合成可点击卡片(域名、类型、健康点),复用现有 `openDomain()` | NAS 用户装面板就是为了「一处点开所有服务」 |
|
||||||
|
| **移动端 PWA + 命令面板** | Homepage、Dashy(kiosk 模式) | `manifest.webmanifest` + 图标,让手机「加到主屏」;再加 Cmd/Ctrl+K 跳页面 / 搜规则 | 手机上用得多,成本很低 |
|
||||||
|
|
||||||
|
**这一批里我建议先做的两项**:
|
||||||
|
|
||||||
|
1. **本机 SMART + UPS**(上表第 8 行)——Fonu 面向 NAS,而当前仪表盘完全没覆盖硬件健康;`internal/sysinfo` 已有平台分文件(`_linux.go` / `_windows.go`)的框架,加采集与一张卡即可,不碰 agent。
|
||||||
|
2. **Prometheus 导出**(第 3 行)——把已有指标换个输出格式就能接 Grafana / HomeAssistant,比自建历史图表(§三-P1-5)成本低一个量级,两者可先做这个。
|
||||||
|
|
||||||
|
**仍然不建议跟的**(补充):把 Fonu 变成通用监控平台(Gatus / Uptime Kuma 那种「任意 URL 探活」)——它的健康检查应该围绕自己下发的反代规则,而不是变成一个通用探活器;引入 Redis 会话存储(Authelia / Authentik 的选择)也没必要,单机 SQLite 足够。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、待评估:读取 VPS 实际放行端口(只读)
|
||||||
|
|
||||||
|
> 背景:现在 Fonu 只能**告诉用户该放行哪些端口**(frps 同源配置弹窗按已启用的 TCP/UDP 规则推导端口列表,`internal/frp/frps_template.go` 也生成 `allowPorts`),但读不到服务器上**实际放行**了什么。隧道连不上时最常见的原因正是端口未放行,而日志不会点明。
|
||||||
|
|
||||||
|
### 结论
|
||||||
|
|
||||||
|
**主机侧防火墙可以读,云厂商安全组读不到。**
|
||||||
|
|
||||||
|
| 来源 | 可行性 | 做法 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ufw`(Debian / Ubuntu) | ✅ | `ufw status numbered`(agent 以 root 运行,无需额外提权) |
|
||||||
|
| `firewalld`(RHEL / CentOS) | ✅ | `firewall-cmd --list-ports` / `--list-services` / `--list-rich-rules` |
|
||||||
|
| `nftables` / `iptables` 兜底 | ◐ | `nft list ruleset` / `iptables-save`——是 ufw/firewalld 的底层,直接读会看到大量实现细节,只作为「没有前两者时」的兑底 |
|
||||||
|
| **云厂商安全组**(本机是 RainYun) | ❌ | 安全组在厂商控制台 / API,**主机侧不可见**;要读就得引入 AccessKey(新凭据面,不建议) |
|
||||||
|
| 上游 NAT / 网络设备 | ❌ | 不在主机视野内 |
|
||||||
|
|
||||||
|
### 设计(只读,不提供任何写操作)
|
||||||
|
|
||||||
|
1. agent 新增 `GET /api/v1/firewall`(沿用 `s.auth`):返回 `{backend, rules: [{port, proto, source, action}], missing_tool, checked_at}`;先探测哪个前端存在,命令全部**固定 argv**(与现有 `systemctl` / `ss -tlnp` 的做法一致,不拼用户输入)。
|
||||||
|
2. 应用侧 `AgentClient.Firewall()` + `GET /api/frpmulti/servers/{id}/agent/firewall`,与现有 agent 路由同构(`internal/api/router.go`)。
|
||||||
|
3. 前端把「需要放行的端口」(现有规则推导逻辑)与「实际已放行」做**交叉对照**:直接标出「已放行 / 未放行」,并固定一行说明「云安全组需在厂商控制台确认(主机侧读不到)」。落点:`web/src/views/AgentView.vue`(frps.toml 弹窗与详情区)与 `FrpView.vue` 的规则区提示。
|
||||||
|
4. 与已有数据交叉验证:agent 现在已在采集 `ss -tlnp` 的监听端口,可额外区分「已放行但无服务监听」与「有服务监听但未放行」。
|
||||||
|
|
||||||
|
### 边界与风险
|
||||||
|
|
||||||
|
- **只读**:不提供任何防火墙写操作,与 agent 能力方案 §六「防火墙规则读写不做(最多只读)」一致。
|
||||||
|
- **降级**:命令不存在 / 无权限 / 无规则三种情况都要能降级成「未检测到防火墙管理工具」而不是报错。
|
||||||
|
- **不引入云 API**:接厂商 API 意味着用户要交出 AccessKey,收益(省一次控制台确认)不值这个代价。
|
||||||
|
- **agent 行为变更**:需递增 `AgentVersion`(0.12.2)并重编双架构内嵌二进制,VPS 上点「升级 Agent」才生效。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、P0 实施状态(2026-09-24)
|
||||||
|
|
||||||
|
### 已完成:配置版本的后端基础
|
||||||
|
|
||||||
|
| 改动 | 位置 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `VersionsDir` / `BackupVersioned` / `pruneVersions` | `internal/fsutil/fsutil.go` | 版本存为 `<path>.versions/<UTC 时间戳.毫秒>-<随机 4 位>.conf`,按文件名倒序即新旧顺序;`keep <= 0` 不剪枝;文件不存在视为无需备份 |
|
||||||
|
| 接入 Nginx 配置写入 | `internal/nginx/nginx.go` 的 `Apply` | 覆盖前先 `.bak`(保留最近一次,兼容既有读取方),再 `.versions/` 保留最近 10 份 |
|
||||||
|
| 测试 | `internal/fsutil/fsutil_test.go` | 新增 3 例:内容正确、剪枝到 keep 份、文件不存在返回空且不报错 |
|
||||||
|
|
||||||
|
验证:`gofmt` 干净、`go build ./...` / `go vet` / `go test ./internal/fsutil/ ./internal/nginx/` 全绿。
|
||||||
|
|
||||||
|
### 已完成:版本列表 / 差异 / 回滚(本机侧与 agent 侧都做了)
|
||||||
|
|
||||||
|
| 改动 | 位置 |
|
||||||
|
| --- | --- |
|
||||||
|
| `ListVersions` / `ReadVersion`(名称校验拒绍上跳)、`BackupVersioned` 对相同内容去重 | `internal/fsutil/fsutil.go` + 4 个测试 |
|
||||||
|
| `ReadConfig` / `ListConfigVersions` / `ReadConfigVersion` / `RollbackConfigVersion`(先语法校验 + `nginx -t`,不过关不覆盖);把 Apply 的「让它生效」抽成 `activate` 供回滚复用 | `internal/nginx/versions.go`、`nginx.go` |
|
||||||
|
| `GET /api/settings/nginx/config/versions`、`GET …/{name}`、`POST …/{name}/rollback` | `internal/api/nginx_handler.go`、`router.go`、`internal/service/nginx_config.go` |
|
||||||
|
| 设置页「Nginx 全局配置」卡内新增「主配置版本与回滚」:版本列表 + 逐行差异 + 二次确认回滚 | `web/src/views/SettingsView.vue` |
|
||||||
|
| agent 侧:写 vhost 前留版本;**校验失败改为恢复上一个可用版本**(此前直接删文件,等于把站点从磁盘上抹掉);新增 `GET /routes/{domain}/versions`、`GET …/versions/{name}`、`POST …/versions/{name}/rollback`;删路由时一并清版本目录 | `internal/agent/versions.go`、`server.go`(`AgentVersion` → **0.12.2**,双架构二进制已重编) |
|
||||||
|
| 应用侧:`AgentClient.RouteVersions/RouteVersion/RollbackRouteVersion` + 三个 `/api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions…` 路由(端点不存在时提示「需 ≥ 0.12.2」) | `internal/frp/agent_client.go`、`internal/api/frpmulti_handler.go`、`router.go` |
|
||||||
|
| 前端:公网反代页「查看配置」弹窗内加版本面板 + 差异高亮 + 回滚二次确认 | `web/src/views/ProxyRemoteView.vue`、`components/ConfigDiffView.vue`、`utils/diff.ts` |
|
||||||
|
|
||||||
|
#### 原计划(已按此实现,保留作对照)
|
||||||
|
|
||||||
|
1. `internal/fsutil`:加 `ListVersions(path) ([]VersionEntry, error)`(名称 + 大小 + 修改时间,新的在前)与 `ReadVersion(path, name) ([]byte, error)`(拒绝带 `/` 或 `..` 的名称)。
|
||||||
|
2. `internal/nginx`:`ListConfigVersions()` / `ReadConfigVersion(name)` / `RollbackConfigVersion(name)`——回滚必须先 `ValidateConfigSyntax` + `m.validate(ctx, tmp)` 通过再写回并 reload,失败不覆盖当前配置(与 Apply 同构)。
|
||||||
|
3. `internal/api`:`GET /api/nginx/versions`、`GET /api/nginx/versions/{name}`、`POST /api/nginx/versions/{name}/rollback`(受保护路由,写入口径同现有 nginx 接口)。
|
||||||
|
4. 前端:本地反代页 Nginx 配置区 + 公网反代页——版本列表(时间 / 大小)+ 逐行差异高亮(复用漂移清单的样式)+ 回滚二次确认。
|
||||||
|
5. 边界:本轮只做**本机侧**;VPS 上的 `fonu-<域名>.conf` 版本历史属于 agent 侧,需单独一轮(agent 写路由时也调 `BackupVersioned`,再配一个只读列表端点)。
|
||||||
|
|
||||||
|
### 已完成:其余三项 P0(明细见文末「本轮完成明细」)
|
||||||
|
|
||||||
|
#### 原计划(已按此实现,保留作对照)
|
||||||
|
|
||||||
|
- **API Token**:表 `api_tokens(id, name, token_hash, scopes, last_used_at, expires_at, created_at)`;`RequireAuth` 同时接受 `Authorization: Bearer`;新建时只显示一次明文(沿用现有「掩码往返」约定);设置页管理;落地前过一遍安全自查(哈希存储、可撤销、只读范围、`last_used_at` 审计)。建议单独一轮。
|
||||||
|
- **通知渠道**:现有 email / webhook / telegram 三通道的抽象(`SaveInput` / `ParseSaveInputFromMap` / `validateSaveInput`)→ 新增 Bark / ntfy / 企业微信 / 钉钉 / 飞书(都是 POST URL + JSON);`web/src/constants/notifyProviders.ts` 已有 `DEFAULT_BARK_SERVER` / `DEFAULT_NTFY_SERVER` 常量可用。
|
||||||
|
- **阈值告警**:`internal/scheduler` 加指标巡检任务(数据源:agent `GET /api/v1/metrics` + 本机 `internal/sysinfo`)→ 阈值 / 开关 / 冷却时间走设置项(照搬 `notify_*` 事件开关模式);冷却状态可先放内存(重启丢可接受)。
|
||||||
|
|
||||||
|
建议顺序:版本回滚 → 通知渠道 → 阈值告警 → API Token(本轮已全部完成)。
|
||||||
|
|
||||||
|
### 本轮完成明细
|
||||||
|
|
||||||
|
**通知渠道补齐**
|
||||||
|
|
||||||
|
- Bark / ntfy / Gotify **本来就有**(上文「只有邮件 / Webhook / Telegram」不准确);本轮补的是**企业微信机器人、钉钉机器人、飞书机器人**:固定路径 + JSON,`Key` 存 `Webhook.Key`、`Server` 可覆盖默认域名。
|
||||||
|
- 钉钉正文固定以「Fonu · 」开头,机器人用「自定义关键词 = Fonu」即可命中(加签方式暂不支持,UI 已注明)。
|
||||||
|
- `providerUsesKey()` 收敛了「凭据放 Key 还是 WebhookSecret」的判定,保存与落库共用一处。
|
||||||
|
- 前端:预设下拉 + 三家各自的字段与提示;**切换预设时服务地址自动换成该预设默认值**(否则会把 `api.day.app` 带到企业微信上)。
|
||||||
|
- 测试:`push_test.go` 锁定三家的端点与 payload 形状。
|
||||||
|
|
||||||
|
**阈值型告警**
|
||||||
|
|
||||||
|
- 新包 `internal/monitor`:`Checker.Run` 每 2 分钟跑一轮(`internal/app/app.go` 注册为 scheduler 任务)。
|
||||||
|
- 资源:CPU / 内存 / 磁盘超阈值告警,同一项 30 分钟静默期,回落到阈值以下即清冷却(再次超标可立即再报)。
|
||||||
|
- 隧道:连续 N 次巡检未在 frps 上注册才告警(默认 3 次),**只在刚好达到阈值与恢复时各发一次**,不刷屏。
|
||||||
|
- 事件与设置项:`on_resource_threshold` / `on_tunnel_down`(默认**关闭**,升级后不改变现有用户收到的通知)+ `notify_cpu_threshold`(90) / `notify_memory_threshold`(90) / `notify_disk_threshold`(85) / `notify_tunnel_fail_threshold`(3);开关关闭时连采集与探测都跳过。
|
||||||
|
- 前端:告警事件新增「资源占用过高」「隧道连续不通」两项与对应阈值输入。
|
||||||
|
- 测试:`monitor_test.go` 覆盖「只报一次 / 恢复报一次 / 未达阈值静默」。
|
||||||
|
|
||||||
|
**API Token**
|
||||||
|
|
||||||
|
- 迁移 `025_api_tokens.sql`:`api_tokens(id, name, token_hash, scope, expires_at, last_used_at, created_at)`,`token_hash` 是明文令牌的 SHA-256(令牌是 `crypto/rand` 的 32 字节随机串,熵足够,不需要 bcrypt);明文只在创建响应里回一次。
|
||||||
|
- `RequireAuth` 同时接受会话 cookie 与 `Authorization: Bearer <token>`;`read` 范围只允许 GET/HEAD,`write` 与会话同权。
|
||||||
|
- **安全自查**:① 令牌管理(`/api/settings/tokens`)与改密 / 退出**只允许登录会话**——否则一个 write 令牌能给自己提权或无限续期;② 撤销即删行、立即失效;③ 可选有效期(最长 3650 天,空为永不过期);④ `last_used_at` 限频回写(1 分钟一次,单连接 SQLite 上不做每请求写)。
|
||||||
|
- 接口:`GET/POST /api/settings/tokens`、`DELETE /api/settings/tokens/{id}`;设置页「高级」新增「API Token」卡:列表(名称 / 权限 / 创建 / 最近使用 / 有效期)+ 创建表单 + 明文一次性展示与复制 + 撤销确认。
|
||||||
|
- 测试:`internal/auth/token_test.go`(生命周期 / 过期 / 非法输入 / 明文不落盘)、`internal/api/middleware_test.go`(只读令牌只读、写令牌可写、令牌不能管理令牌、错误凭据 401)。
|
||||||
|
|
||||||
|
**本轮验证**
|
||||||
|
|
||||||
|
`gofmt`(改动文件干净)、`go build ./...`、`go vet ./...`、`go test ./... -count=1` 全绿;`npx vue-tsc --noEmit` 0 错误、`npx vite build` 成功;`internal/frp/agentbin/` 双架构二进制已重编(含 `0.12.2`)。
|
||||||
|
|
||||||
|
**待用户操作**
|
||||||
|
|
||||||
|
1. **重启本机 `bin\fonu.exe`** 才能看到新接口与新 UI(新迁移在启动时自动执行)。
|
||||||
|
2. **VPS 上点「升级 Agent」** 才会装上 0.12.2,公网反代页的版本历史才会出现(否则提示「需 ≥ 0.12.2」)。
|
||||||
|
3. 已随本轮提交:`CHANGELOG.md` 新增 v1.2.9 段,`VERSION` 与 `web/package.json` 同步到 1.2.9。
|
||||||
|
|
||||||
|
## 十、A 档告警落地(2026-09-25)
|
||||||
|
|
||||||
|
> 承接 §三-P0-4 的阈值告警:把「隧道不通」这一族故障的三个常见成因补齐。**纯应用侧改动,agent 无改动**(`AgentVersion` 保持 0.12.2,无需在 VPS 上重新升级)。
|
||||||
|
|
||||||
|
| 新事件 | 判定 | 默认 | 限流 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 公网服务端掉线 `frps_server_down` | `DesiredState` 为 running/starting 时,进程 stopped/error 或连接 disconnected/error | 关闭 · 连续 2 次 | 达标报一次 + 恢复报一次 |
|
||||||
|
| 公网 agent 不可达 `agent_unreachable` | agent 地址与令牌都可用,但 `GET /api/v1/ping` 连续失败 | 关闭 · 连续 3 次 | 同上 |
|
||||||
|
| 配置漂移 `route_drift` | `RouteDrift` 报告有 items(未部署 / 孤儿配置 / 未生效 / 内容不一致) | 关闭 · 24 小时 | 首次即报 + 每 24h 重复 + 清空报恢复 |
|
||||||
|
|
||||||
|
**避免误报的三条约定**:① 服务端只在「用户要它运行」时判定,手动停掉的不报;② `ConnectionState=unknown` 按健康处理(状态源 `manager+log` 偶尔读不到不代表掉线);③ 没配 agent 的服务端不参与可达性判定。
|
||||||
|
|
||||||
|
**结构变化**:`internal/monitor` 的注入方式从「一个 routes 闭包」改成 `Source` 接口(Routes / Servers / Agents / Drift),frp 侧适配放在新文件 `internal/app/monitor_source.go`——monitor 依旧不依赖 frp 包,新增数据源只需扩接口。三条新检查与原有隧道巡检共用 `failureTransition`(原 `tunnelTransition` 泛化),漂移因不是「连续失败」型而单独用冷却;`notify.Alert` 一律在锁外调用。
|
||||||
|
|
||||||
|
## 十一、P1 实施状态(2026-09-25)
|
||||||
|
|
||||||
|
| 项 | 状态 | 落点与关键决定 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 7 定时自动备份 + 保留策略 | ✅ | `internal/backup`:导出改 `writeArchive` 逐个检查 tar→gzip→文件的 `Close()`(修审查报告 §二);`AutoRunner` 按「最新备份是否在 24h 内」判定,重启不重复备份;导出后回读校验 |
|
||||||
|
| 6 健康检查持续化 + 只读状态页 | ✅ | 先修 `tunnel_known`(判不了 ≠ 不通,修掉了新告警的误报根因);`route_health_events` 只存状态变更(up/down/unknown),可用率与不可用区间由它推导;公开状态页默认关闭、访问码存 SHA-256、按 IP 限流、只暴露域名与可用率 |
|
||||||
|
| 5 指标历史与趋势图 | ✅ | 纵向点表 `(scope, metric, value, at)`,**不上降采样表**(每 2 分钟采样行量极小,查询时按桶取平均);本机资源 + 每规则上下行速率(`SnapshotByHost` 与端点聚合共用 `aggregateHostKeys`);仪表盘趋势卡 1h/24h/7d |
|
||||||
|
| 8 一键迁移包 | ⬜ 未做 | 建议缩范围:先只做「导出加密包 + 只允许导入到空实例」 |
|
||||||
|
| 9 Docker 容器统计 | ⬜ 未做 | 要动 agent(递增 `AgentVersion` + 重编 + 用户升级),建议排在「本机 SMART + UPS」之后 |
|
||||||
|
|
||||||
|
**本轮教训(记一下免得重犯)**:`vue-tsc` 不校验模板标签闭合,模板结构错误只有 `npx vite build` 会拦(本轮搬卡片时标签失衡就是它报的 `Element is missing end tag`)——前端改动两个都要跑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 说明:本文件是**建议清单**,未改动任何代码;实施时按本仓库既有约定(每步验证 `go build/vet/test` + `vue-tsc/vite build`、agent 行为变更递增 `AgentVersion`、提交同批更新 `CHANGELOG` 与 `VERSION`)。
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
# FRP 服务端列表与公网 Agent 页合并方案
|
||||||
|
|
||||||
|
> 问题:`/frp`(内网穿透)页里的「公网服务端」列表,是否应该并入 `/frp-agent`(公网 Agent)页——像 DDNS 页那样「列表 + 详情同页」?
|
||||||
|
>
|
||||||
|
> 依据:本仓库 `web/src/layouts/AppLayout.vue`、`web/src/router/index.ts`、`web/src/views/{FrpView,AgentView,DdnsView,ProxyRemoteView}.vue`(读代码确认,非推测)。
|
||||||
|
> 更新日期:2026-09-24
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现状盘点
|
||||||
|
|
||||||
|
### 1.1 侧边栏与三个 FRP 相关页
|
||||||
|
|
||||||
|
`web/src/layouts/AppLayout.vue:171-179` 的菜单顺序:
|
||||||
|
|
||||||
|
| 菜单 | 路由名 | 视图 | 页面职责 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 内网穿透 | `frp` | `FrpView.vue` | 客户端状态 + **公网服务端列表(增删改查 + 启停/测速/校验/日志)** + 穿透规则列表 + 8 个弹窗 |
|
||||||
|
| 公网 Agent | `frp-agent` | `AgentView.vue` | **单个服务端**的 agent 详情:运行状态 / 环境与依赖(含 VPS 资源、VPS 证书)/ frps 控制 / Agent 生命周期(SSH)/ 输出与日志 |
|
||||||
|
| 公网反代 | `public-proxy` | `ProxyRemoteView.vue` | 单个服务端的公网反代台账(部署 / 移除 / 健康 / 漂移) |
|
||||||
|
|
||||||
|
### 1.2 重叠矩阵(同一件事现在有几处入口)
|
||||||
|
|
||||||
|
| 能力 | 内网穿透页 | 公网 Agent 页 | 公网反代页 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 服务端增删改 | ✅ 列表 + 弹窗表单 | ❌ | ❌ | 唯一入口在 `/frp` |
|
||||||
|
| 服务端列表(名称/主机/端口/延迟/位置/配置/启动状态) | ✅ `n-data-table` 8 列 | ❌(只有顶部下拉选服务端) | ❌(顶部下拉) | **同一份数据的两种呈现** |
|
||||||
|
| 启动 / 停止 frpc | ✅ 行内按钮 | ❌ | ❌ | |
|
||||||
|
| 查看服务端详情 | ✅ 弹窗(代理/服务器状态/诊断) | ✅ 整页 | ❌ | **内容重叠** |
|
||||||
|
| frps 安装 / 启停 / 版本 | ❌ | ✅ | ❌ | |
|
||||||
|
| Agent 安装 / 升级 / 卸载 / SSH 诊断 | ❌(只有「Agent 管理」跳转按钮) | ✅ | ❌ | |
|
||||||
|
| Agent 环境探测 / Nginx 安装 | ❌ | ✅ | ❌ | |
|
||||||
|
| 穿透规则 | ✅ | ❌ | ❌ | 内网穿透页的核心 |
|
||||||
|
| 公网反代规则 | ❌ | ❌ | ✅ | |
|
||||||
|
|
||||||
|
**结论**:服务端本身(连接配置 + 启停)归 `/frp`,而「这台服务端上的 agent 与 frps 状态」归 `/frp-agent`。两页通过 `/frp` 列表行内的「Agent 管理」按钮跳转,跳转后 AgentView 用**顶部下拉**重新选一遍服务端——**同一个对象被选两次**,这是用户感到割裂的根源。
|
||||||
|
|
||||||
|
### 1.3 现有跳转关系(合并时要一并处理)
|
||||||
|
|
||||||
|
| 来源 | 目标 | 位置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/frp` 服务端行「Agent 管理」 | `/frp-agent` | `FrpView.vue` `serverColumns` 操作列 |
|
||||||
|
| `/frp` 穿透规则区(未配置服务端时的空态) | 本页「添加服务端」 | `FrpView.vue` |
|
||||||
|
| `/public-proxy` 空态「尚未配置 FRP 服务端」 | `/frp` | `ProxyRemoteView.vue` |
|
||||||
|
| `/frp-agent` Nginx 卡「去『公网反代』处理」 | `/public-proxy` | `AgentView.vue` |
|
||||||
|
| `/ddns` 顶部提示「FRP 内网穿透已启用」 | `/frp` | `DdnsView.vue` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、DDNS 页作为参照:它是什么模式
|
||||||
|
|
||||||
|
`DdnsView.vue` 的结构(`PageHeader` → `stats-row` → 详情卡 → 弹窗):
|
||||||
|
|
||||||
|
- **一页承载「列表 + 选中项详情」**:页面顶部是全局动作(「全部刷新」)与 5 张统计卡(外网 IP 检测 / 任务总数 / 正常 / 异常 / 最后同步),下面直接是**当前任务的详情分区**(`detail-section` 卡片),新增/编辑走弹窗。
|
||||||
|
- 也就是说:**用户不需要在「列表页」和「详情页」之间跳**,选中的对象在同一页内切换。
|
||||||
|
|
||||||
|
对照到 FRP:`/frp-agent` 现在已经完全是「单对象详情页」的形态(顶部下拉选服务端 + 分区详情),**缺的正是 DDNS 那样的列表**——把 `/frp` 的服务端列表搬进来,形态立刻与 DDNS 一致,而且顶部下拉可以退化为「列表选中行」的结果(或保留下拉作为快捷切换)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、三个方案对比
|
||||||
|
|
||||||
|
| 方案 | 做法 | 优点 | 缺点 | 工作量 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| **A(推荐)** | 把服务端列表并入 `/frp-agent`:页面改为「统计区 + 服务端列表 + 选中服务端的 agent 详情」;`/frp` 只保留客户端状态与穿透规则 | 与 DDNS 形态一致;服务端管理只在一处;列表与详情同屏,不再「选两次」;`/frp` 回归「穿透规则」本职 | 服务端表单弹窗(含 frps 选项 / 远程 / Agent / SSH 四个分栏)要搬到 AgentView;两页文案与跳转需同步 | 中,约 1–1.5 天 |
|
||||||
|
| **B** | 反向合并:把 agent 详情塞进 `/frp` 行内的「查看」弹窗 | 不动列表位置 | AgentView 现有 5 个分区 + 2 张并排卡 + 日志区,弹窗塞不下;等于推倒已成型页面重做 | 大且不划算 |
|
||||||
|
| **C** | 不动结构,只补深链:「Agent 管理」跳转带 `?server=<id>`,AgentView 读 query 自动选中 | 改动最小(约 0.5h);消除「再选一次」的割裂感 | 仍是两个页面,服务端管理仍分散 | 小 |
|
||||||
|
|
||||||
|
**推荐 A,并把 C 作为 A 的第一步**:即使 A 中途暂停,深链带来的收益已经落地,且不会与 A 的后续步骤冲突。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、方案 A 详细设计
|
||||||
|
|
||||||
|
### 4.1 目标页面结构(`/frp-agent`,参照 DDNS)
|
||||||
|
|
||||||
|
```
|
||||||
|
PageHeader「公网服务端」(原「公网 Agent」)
|
||||||
|
actions: [选择服务端 ▾](快捷切换,选项与列表一致) [刷新]
|
||||||
|
├─ stats-row:沿用现有 5 张卡(Agent 状态 / Nginx / frps 服务 / 反代路由 / 端口监听)
|
||||||
|
├─ 服务端列表(从 /frp 迁入)
|
||||||
|
│ n-data-table 8 列:名称 / 主机 / 端口 / 延迟 / 位置 / 配置 / 启动状态 / 操作
|
||||||
|
│ 操作列:启动·停止 · 查看 · 日志 · 测速 · 校验 · 编辑 · 删除
|
||||||
|
│ (去掉「Agent 管理」——它就在本页;行点击即选中)
|
||||||
|
├─ 选中服务端的详情(现有 AgentView 分区原样搬过来)
|
||||||
|
│ 环境与依赖(Fonu Agent / Nginx / frps 运行时 / VPS 资源 / VPS 证书)
|
||||||
|
│ frps 控制 + Agent 生命周期(SSH)
|
||||||
|
│ 输出与日志
|
||||||
|
└─ 弹窗:服务端表单(新增/编辑)、服务端日志、服务端详情、frps 同源配置、导出、FRP 应用配置
|
||||||
|
```
|
||||||
|
|
||||||
|
`/frp`(内网穿透)保留并收敛为:
|
||||||
|
|
||||||
|
- 4 张状态卡(客户端状态 / 启用规则 / frpc 版本 / 最近应用)
|
||||||
|
- 穿透规则列表 + 规则相关弹窗(规则表单、规则详情、FRP 应用配置)
|
||||||
|
- 空态:「尚未配置服务端」→ 改为链接到 `/frp-agent`(原来是本页直接开「添加服务端」弹窗)
|
||||||
|
|
||||||
|
### 4.2 数据流与选中态
|
||||||
|
|
||||||
|
- 列表数据 `api.listFrpServers()`:两页现在各自拉一次,迁入后只拉一次(AgentView 本来就在拉)。
|
||||||
|
- 选中态:沿用 AgentView 的 `selectedId`;新增**行点击选中 + 选中行高亮**。
|
||||||
|
- 深链:`/frp-agent?server=<id>` → 初始化时读 query 决定 `selectedId`(即方案 C,作为第一步落地)。
|
||||||
|
- `/frp` 的「Agent 管理」跳转改为 `router.push({ name: 'frp-agent', query: { server: id } })`。
|
||||||
|
- 列表为空:整页 EmptyState(AgentView 已有)+「添加服务端」按钮直接开弹窗(不再跳去 `/frp`)。
|
||||||
|
|
||||||
|
### 4.3 弹窗归属(关键决定)
|
||||||
|
|
||||||
|
| 弹窗 | 现在位置 | 迁移后 | 判据 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 服务端表单(新增 / 编辑) | `/frp` | `/frp-agent` | 服务端的「家」在这里;`/frp` 不再需要 |
|
||||||
|
| 服务端日志 / 服务端详情 | `/frp` | `/frp-agent` | 都是单服务端视角,与详情区同屏更好 |
|
||||||
|
| frps 同源配置 / 导出脱敏配置 | `/frp` | `/frp-agent` | 依赖服务端的 frps 选项 → 属服务端视角 |
|
||||||
|
| 规则表单 / 规则详情 / FRP 应用配置 | `/frp` | 不动 | 与穿透规则绑定 |
|
||||||
|
|
||||||
|
实现时以「弹窗依赖的是**服务端**还是**规则**」为判据,不要按文件位置机械搬运。
|
||||||
|
|
||||||
|
### 4.4 命名与文案
|
||||||
|
|
||||||
|
- 页面标题与菜单:`公网 Agent` → **`公网服务端`**(或「公网服务端(Agent)」,二者都行,推荐前者,因为它现在同时管服务端与 agent)。
|
||||||
|
- **路由名 `frp-agent` 与路径 `/frp-agent` 保持不变**,避免破坏深链、侧边栏高亮与已有跳转;只改显示文案。
|
||||||
|
- `/frp` 的「公网服务端」卡片整体移除,其 `subtitle`("可添加并管理多个 frps 服务端连接")文案迁到新页描述。
|
||||||
|
|
||||||
|
### 4.5 空态与降级
|
||||||
|
|
||||||
|
- 没有服务端:`/frp-agent` 显示 EmptyState + 「添加服务端」;`/frp` 规则区空态改为「先去『公网服务端』添加服务端」。
|
||||||
|
- 服务端未配 agent/SSH:详情区沿用现有降级(`agentStatusOk`、状态告警),列表「配置」列沿用现有 TLS/Token 标签。
|
||||||
|
- agent 版本过低:沿用 404/405 降级文案,无需新逻辑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、迁移步骤(四步,每步独立可交付 / 可回滚)
|
||||||
|
|
||||||
|
| 步骤 | 内容 | 交付标准 | 预估 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **步 1 · 深链** | `/frp` 的「Agent 管理」改为 `router.push({ name: 'frp-agent', query: { server: id } })`;`AgentView` 初始化读 query 决定 `selectedId` | 从任意一行点进 Agent 页,直接落在该服务端(不再需要再选一次) | 0.5h |
|
||||||
|
| **步 2 · 列表迁入** | 把 `serverColumns`、列表卡片、行操作、服务端表单 / 服务端日志 / 服务端详情弹窗整体搬到 `AgentView`;行点击即选中并高亮;`FrpView` 删除该卡片与相关弹窗、`serverColumns`、`openServerModal` 等 | 新增 / 编辑 / 启停 / 删除 / 测速 / 校验 / 日志全部在新页可用;`/frp` 只剩状态卡 + 规则 | 0.5 天 |
|
||||||
|
| **步 3 · 收尾** | 菜单与页面标题改「公网服务端」;`/frp` 规则区空态改指向新页;`ProxyRemoteView` / `DdnsView` 里「去内网穿透页添加服务端」这类文案改指向 `/frp-agent`(指向规则页的链接不动);核对侧边栏顺序(内网穿透 → 公网服务端 → 公网反代) | 所有入口文案自洽 | 0.25 天 |
|
||||||
|
| **步 4 · 清理** | 删除迁移后残留的死样式与死变量(如 `/frp` 里只服务端用到的 `.server-detail*`、`.frp-detail-card` 等);`grep` 校验无残留引用 | `vue-tsc` + `vite build` 通过,grep 旧类名/旧函数名为 0 | 0.25 天 |
|
||||||
|
|
||||||
|
**实施状态(2026-09-24)**:**步 1、步 2 已完成**。
|
||||||
|
|
||||||
|
- **步 1 · 深链**:`FrpView.vue` 的「Agent 管理」改为 `{ name: 'frp-agent', query: { server: String(row.id) } }`;`AgentView.vue` 新增 `useRoute` 并在 `selectedId` 初始化时读 `?server=<id>`(非法 id 由 `load()` 回落为第一台)。
|
||||||
|
- **步 2 · 列表迁入**:服务端列表卡片、服务端表单 / 日志 / 详情 / frps 同源配置四个弹窗、`serverColumns` 与全部服务端脚本、相关样式整体迁入 `AgentView.vue`(971 → 1935 行),`FrpView.vue` 删除同批代码(2189 → 1425 行)。实施要点:
|
||||||
|
- 行点击即选中(`serverRowProps` + `.frp-server-row--active` 高亮),与顶部下拉共用 `selectedId`;操作列去掉「Agent 管理」,单元格加 `stopPropagation` 避免点按钮时误选。
|
||||||
|
- 列表改为展示**全部**服务端(原先按 `agent_configured || ssh_configured` 过滤),顶部下拉同步放开过滤——否则未配 Agent 的服务端无法在本页编辑 / 删除。
|
||||||
|
- 列表为空时整页 EmptyState + 「添加服务端」直接开表单(不再跳去 `/frp`);`/frp` 规则区在「无服务端」时给出指向 `/frp-agent` 的空态,避免按钮被禁用后无处可去。
|
||||||
|
- 服务端表单里的「打开管理面板」改为「查看服务端详情」(关弹窗 + 页内选中);详情弹窗「客户端」一行所需的 `runtime` 由新增的 `loadRuntimeVersion()` 拉取。
|
||||||
|
- 详情弹窗「代理」表依赖的 `proxyPublicEndpoint` 在 `AgentView` 本地补了一份(原属规则侧);表单里的「下发 frps 配置」复用 `AgentView` 既有的 `run('push-config', …)` 版本,输出进「输出与日志」。
|
||||||
|
- **未迁移**:`/frp` 的「查看脱敏配置」弹窗(`showExport`)留在原页——触发按钮在「穿透规则」卡片头部,导出物是脱敏 frpc.toml,属客户端 / 规则视角。这是按 §4.3 自己的判据(弹窗依赖服务端还是规则)做的取舍,与 §4.3 表格里「导出脱敏配置 → 迁走」一行不同。
|
||||||
|
- 验证:`vue-tsc --noEmit` 0 错误、`vite build` 通过、`FrpView.vue` 内旧标识符 grep 为 0、全仓无 0 字节源文件、`bin\fonu.exe` 已重建。
|
||||||
|
- **踩坑记录(scoped 样式不随模板搬迁)**:`.frp-proxy-modal` 系列规则在 `FrpView.vue` 里分散在多处——1250–1260 的弹窗外壳、1263–1282 的说明栏 / 高级折叠 / 服务端选项区。只搬前一段时,说明栏失去 `width: 300px; flex-shrink: 0`,在 `display: flex` 的弹窗里按内容宽度膨胀,把 `flex: 1` 的表单列挤成一条,表现为标题重叠、页签一字一行。**步 4 清理时注意**:`.proxy-modal*` / `.frp-proxy-modal*` 在 `/frp` 仍被规则表单弹窗使用,不能删。
|
||||||
|
- **布局调整(2026-09-24,用户要求对齐 DDNS 页)**:`/frp-agent` 改为 DDNS 那样的**左列表 + 右详情**——`.agent-layout { grid-template-columns: minmax(260px, 320px) minmax(0, 1fr) }`,≤1100px 收成一列;侧栏结构照抄 DDNS 的 `.ddns-layout` / `.task-sidebar`(类名换成本页语义的 `.server-sidebar*` / `.server-item*`)。
|
||||||
|
- 原 8 列 `n-data-table` 换成紧凑列表项:名称 + 启动状态标签 + `主机:端口` + 延迟 / 位置 + TLS / Token / Agent / SSH 标签;行点击即选中(`--active` 高亮)。
|
||||||
|
- 行内操作移到右侧工具条 `.server-toolbar`(启动·停止 · 查看 · 日志 · 测速 · 校验 · 编辑 · 删除),标题旁显示当前服务端名与端点。
|
||||||
|
- `serverColumns` / `serverRowProps` / 顶部「选择服务端」下拉及其 `serverOptions` 一并删除(左侧列表即选择器);页面标题改「公网服务端」。
|
||||||
|
- 与 §4.1 里「统计区 → 列表 → 详情」的**堆叠**结构不同,这是用户确认后的取舍;右侧内容顺序不变(状态提示 → 公网反代入口 → ① 运行状态 → ② 环境与依赖 → ③④ → ⑤ 输出与日志)。
|
||||||
|
- **`/frp` 布局调整(2026-09-24,用户两次纠正后定稿)**:`/frp` 改成**左:服务端列表 / 右:该服务端的穿透规则**——左侧 `.server-sidebar`(名称 + `boot_status` 状态标签 + `主机:端口` + 规则数 / 启用数 + TLS / Token / Agent / SSH 标签,点行即切换服务端);右侧 `.rule-pane`(头部显示当前服务端与规则条数,含「查看脱敏配置 / 管理该服务端(深链到 `/frp-agent?server=<id>`)/ 添加规则」)+ **原来的穿透规则表格**(恢复 `columns`,`:data="serverProxies"` 按选中服务端过滤;按用户要求删掉冗余的「服务端」列,`:scroll-x` 2020 → 1890);规则详情回到弹窗(表格「详情」按钮 → `openProxyDetail`,速率采样随弹窗开关)。顶部 4 张客户端状态卡保持整宽。左侧列表的状态标签只用列表接口就带的 `boot_status`,不为此重新引入已迁走的 `serverDiagnostics` / `serverStartupText`。
|
||||||
|
- **步 3(文案与入口收尾)、步 4(死样式清理)待做**。
|
||||||
|
|
||||||
|
### 5.1 步 2 执行清单(含 `FrpView.vue` 代码锚点)
|
||||||
|
|
||||||
|
> 行号为 2026-09-24 快照;执行时以标识符为准(行号仅用于快速定位)。
|
||||||
|
|
||||||
|
**模板(迁出 → `AgentView.vue`)**
|
||||||
|
|
||||||
|
| 片段 | 行 | 备注 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 「公网服务端」卡片(含 `serverColumns` 驱动的 `n-data-table`) | 53–77 | 卡片本身整块搬走 |
|
||||||
|
| 服务端表单弹窗 | 约 350–500 | 四个分栏;内含「公网 Agent 管理」段(219–236),其中 `<router-link to="/frp-agent">` 迁入后应改为**页面内选中** |
|
||||||
|
| 服务端日志弹窗 | 512–528 | |
|
||||||
|
| 服务端详情弹窗 | 529–564 | `preset="card"` + `.frp-detail-card` |
|
||||||
|
| 导出脱敏配置弹窗 | 565–576 | 触发点在 81 行按钮 |
|
||||||
|
| frps 同源配置弹窗 | 580–600 | 含 P1-3 的「比对 VPS 实际文件」 |
|
||||||
|
|
||||||
|
**脚本(迁出)**
|
||||||
|
|
||||||
|
- 状态:`servers`(924)、`serverDiagnostics`(932)、`testingId`(935)、`agentActionId`(937)、`savingServer`(939)、`showServerModal`(941)、`showExport`(943)、`showServerDetail`(946)、`showFRPSConfig`(949)、`frpsConfigContent`(950)、`frpsConfigTarget`(992)、`serverFormTab`(996)、`exportContent`(997)、`editingServer`(1015)、`viewingServer`(1016)、`serverForm`(1037)、`showServerLogs`(1408)
|
||||||
|
- 函数:`openServerModal`(1370)、`openServerDetail`(1381)、`openServerLogs`(1413)、`saveServer`(1510)、`testAgent`(1534)、`pushAgentConfig`(1541)、`startServer`(1599)、`stopServer`(1610)、`testServer`(1772)、`openFRPSConfig`(1797)
|
||||||
|
- `serverColumns`(约 1160–1200,其中 1180 行的「Agent 管理」`RouterLink` 迁入后删除)
|
||||||
|
- 一并迁出的辅助:`serverStartupText` / `latencyOf` / `buildServerPayload`(内联在 `saveServer`)/ `ConfiguredSecretField` 组件与相关图标导入
|
||||||
|
|
||||||
|
**留在 `/frp`**:4 张状态卡、穿透规则列表、规则表单 / 规则详情 / FRP 应用配置等规则侧弹窗与逻辑。
|
||||||
|
|
||||||
|
**执行注意**
|
||||||
|
|
||||||
|
1. 迁出后 grep `FrpView.vue` 里旧标识符(`serverForm` / `showServerModal` / `serverColumns` / `openServerLogs` / `frpsConfigTarget` / `showExport` / `editingServer` / `viewingServer`)应为 **0**;
|
||||||
|
2. `busy` / `run()` / `message` 若被规则侧共用,**不要整体迁走**——保留在 `FrpView` 或按需抽成 composable;
|
||||||
|
3. 迁入 `AgentView` 后:行点击 = `selectedId = row.id`,选中行加高亮类;操作列去掉「Agent 管理」;
|
||||||
|
4. 迁完后 `/frp` 的规则区空态改指向 `/frp-agent`(步 3)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、影响面清单
|
||||||
|
|
||||||
|
- **前端**:`FrpView.vue`(删列表与 4 个服务端弹窗 + 死样式)、`AgentView.vue`(加列表与弹窗 + 行选中)、`AppLayout.vue`(菜单文案)、`DdnsView.vue` / `ProxyRemoteView.vue`(指向服务端配置的文案与链接)、`router/index.ts`(**不改**,仅确认)。
|
||||||
|
- **后端**:**零改动**——所有接口都已存在且两页共用(`listFrpServers` / `createFrpServer` / `updateFrpServer` / `deleteFrpServer` / `startFrpServer` / `stopFrpServer` / 诊断与日志等)。
|
||||||
|
- **文档**:`docs/agent-view-design-v2.md` 若把「公网 Agent」定义为「单服务端详情页」,需补一句「现已并入服务端列表」;本文在实施完成后标注状态。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、风险与回滚
|
||||||
|
|
||||||
|
| 风险 | 说明 | 应对 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 弹窗搬迁漏掉入口 | 某个弹窗可能不止一个触发点 | 迁移前对每个弹窗 `grep` 触发点逐个确认归属;搬迁后 grep 旧文件确认无残留引用 |
|
||||||
|
| `selectedId` 语义冲突 | 两页各有一份选中态 | 迁入后 `/frp` 不再持有 `selectedId`,并确认没有别的逻辑依赖它 |
|
||||||
|
| 首屏变慢 | 列表 + 状态 + 资源 + 证书并发请求 | 列表与状态走同一轮 `Promise.all`,资源与证书仍单独异步(沿用现状) |
|
||||||
|
| 深链指向不存在的 id | 手工改 URL 或服务端已删除 | 回落为列表第一个(AgentView 现有逻辑已是「不在列表就取第一个」) |
|
||||||
|
| 回滚 | 步 2 是主要风险点 | 四步各自独立提交:步 1/2/3 可分别 revert,不会互相牵连 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、验收清单
|
||||||
|
|
||||||
|
- `/frp-agent` 一页内完成:选服务端(列表行或顶部下拉)→ 看 Agent / Nginx / frps / VPS 资源 / VPS 证书 → 启停 frpc → 编辑 / 删除服务端 → 看服务端日志、详情与 frps 同源配置。
|
||||||
|
- `/frp` 只剩客户端状态卡 + 穿透规则;规则的新增 / 编辑 / 应用 / 导出不受影响。
|
||||||
|
- 深链:从任意页用 `?server=<id>` 直达该服务端;id 不存在时不报错。
|
||||||
|
- 文案自洽:菜单、页面标题、空态、跨页链接都指向 `/frp-agent`。
|
||||||
|
- 构建:`vue-tsc --noEmit` 0 错误、`vite build` 通过、无残留死代码(grep 旧类名与旧函数名)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、明确不做
|
||||||
|
|
||||||
|
1. **不把「穿透规则」也搬进 AgentView**:规则属于内网穿透页,且 `/frp` 需要保留「状态卡 + 规则」以维持「配置 → 应用 → 查看」的闭环。
|
||||||
|
2. **不把 `/public-proxy` 的反代台账并进来**:它虽按服务端维度,但属另一业务,且已有统计区 + 台账 + 漂移清单的成熟形态。
|
||||||
|
3. **不改路由路径与路由名**(只改显示文案),避免破坏深链、侧边栏高亮与已有跳转。
|
||||||
|
4. **不动后端接口**:本次是纯前端信息架构调整。
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,983 @@
|
|||||||
|
# Fonu 项目详细分析(v1.2.19)
|
||||||
|
|
||||||
|
> 分析日期:2026-09-27
|
||||||
|
> 分析对象:`F:\Fonu-main\Fonu-1.0.2`
|
||||||
|
> 代码版本:`VERSION = 1.2.19`,前端 `package.json = 1.2.19`,内置 Agent `0.12.7`
|
||||||
|
> 分析方式:源码静态阅读、目录与依赖统计、迁移与路由核对、构建级验证
|
||||||
|
> 验证结果:`go test ./... -count=1`、`go vet ./...`、`npm run typecheck`、`npm test` 均通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、结论摘要
|
||||||
|
|
||||||
|
Fonu 是面向飞牛 fnOS 和家庭 NAS 场景的「公网访问管理面板」。它把四类原本分散的运维能力收敛到一个 Go 单体和 Vue 管理后台中:
|
||||||
|
|
||||||
|
1. 本机 Nginx 反向代理与配置版本管理;
|
||||||
|
2. 多家 DNS 服务商的 DDNS 自动同步;
|
||||||
|
3. ACME 证书申请、续期、导入与下载;
|
||||||
|
4. 基于 FRP 的内网穿透,以及部署在公网 VPS 上的 `fonu-agent` 远程管理。
|
||||||
|
|
||||||
|
项目当前形态不是单纯 CRUD 面板,而是已经形成「本地控制面 + 公网 Agent + 定时任务 + SQLite 状态库 + Nginx 配置生成器」的完整运维系统。
|
||||||
|
|
||||||
|
主要优点:
|
||||||
|
|
||||||
|
- **模块边界较清楚**:`internal` 下按 `proxy`、`nginx`、`ddns`、`acme`、`frp`、`agent`、`monitor`、`backup` 等领域拆分,应用装配集中在 `internal/app/app.go`。
|
||||||
|
- **交付链路完整**:同一套源码可构建 Docker 镜像、Linux amd64/arm64 原生安装包,并内嵌双架构 `fonu-agent`。
|
||||||
|
- **关键配置具备回滚能力**:本机 Nginx 主配置、规则自定义配置,以及公网 Agent 上的 vhost 都有版本列表、差异和回滚入口。
|
||||||
|
- **自动化程度较高**:DDNS、证书续期、中国 IP 段更新、健康巡检、指标采样、自动备份均由调度器统一驱动。
|
||||||
|
- **测试基础设施已建立**:后端 90 个 Go 测试文件;前端已有 Vitest、TypeScript 严格模式与 CI 验证流程。
|
||||||
|
|
||||||
|
需要重点关注:
|
||||||
|
|
||||||
|
- **FRP 是系统复杂度中心**:`internal/frp` 约 6049 行,`internal/agent` 约 2079 行,二者共同承担 VPS 配置、进程、Nginx、证书、frps、诊断、版本与漂移检测,修改风险高于其他模块。
|
||||||
|
- **前端巨型组件明显**:`ProxyView.vue` 约 3150 行,`SettingsView.vue` 约 2144 行,`AgentView.vue` 约 1807 行,维护和样式隔离成本较高。
|
||||||
|
- **仍存在历史遗留接口引用**:前端保留 `/api/frp` 旧方法,`DdnsView.vue` 仍调用它,但后端当前只注册 `/api/frpmulti/*`;该调用落入 `catch`,会造成 DDNS 页的 FRP 提示状态始终为关闭。
|
||||||
|
- **初始管理员密码会进入系统日志**:`auth.EnsureDefaultAdmin` 将初始密码写入应用日志,日志中心的系统日志又会读取该文件,安全边界需要收紧。
|
||||||
|
- **仓库工作区有未提交改动**:当前有归档目录删除、内置 Agent 二进制更新、品牌图片更新等本地改动;分析时没有修改或回退这些内容。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、仓库定位与目录边界
|
||||||
|
|
||||||
|
外层目录 `F:\Fonu-main` 不是项目源码仓库,而是工作区容器:
|
||||||
|
|
||||||
|
```text
|
||||||
|
F:\Fonu-main
|
||||||
|
├─ .breezell/ 工作区辅助目录
|
||||||
|
├─ .gocache-agent/ Agent 构建缓存
|
||||||
|
├─ docs/ 外层临时文档
|
||||||
|
├─ Fonu-1.0.2/ 真正的 Git 仓库与源码根目录
|
||||||
|
├─ agent-tsc.log
|
||||||
|
├─ fonu-image-build4.log
|
||||||
|
└─ frps-m1-tsc.log
|
||||||
|
```
|
||||||
|
|
||||||
|
真正的 Git 仓库位于 `F:\Fonu-main\Fonu-1.0.2`:
|
||||||
|
|
||||||
|
- 当前分支:`main`
|
||||||
|
- 远端:`origin/main`
|
||||||
|
- 最近提交:`70b891c feat(scripts): 新增 upgrade-hub.sh,仅编排文件的部署可一条命令升级`
|
||||||
|
- 当前未提交内容:
|
||||||
|
- 删除 `internal/frp-1.0.2/*`;
|
||||||
|
- 删除 `web/src/views/FrpView-1.0.2.vue`;
|
||||||
|
- 修改 `internal/frp/agentbin/fonu-agent-linux-amd64`;
|
||||||
|
- 修改 `internal/frp/agentbin/fonu-agent-linux-arm64`;
|
||||||
|
- 修改 `web/public/favicon.png`、`web/src/assets/brand/logo.png`;
|
||||||
|
- 新增 `web/public/111favicon.png`、`web/src/assets/brand/logo00.png`。
|
||||||
|
|
||||||
|
项目主体目录:
|
||||||
|
|
||||||
|
| 目录 | 作用 |
|
||||||
|
| --- | --- |
|
||||||
|
| `cmd/fonu` | 本地控制面主程序入口,嵌入前端静态资源 |
|
||||||
|
| `cmd/fonu-agent` | 公网 VPS Agent 入口 |
|
||||||
|
| `internal` | 全部业务实现,共 29 个领域包 |
|
||||||
|
| `migrations` | SQLite 迁移,共 27 个 SQL 文件 |
|
||||||
|
| `web` | Vue 3 + Vite 管理后台 |
|
||||||
|
| `deploy` | Nginx MIME 类型等部署资源 |
|
||||||
|
| `scripts` | 原生安装、升级、Agent 安装和 Linux 打包脚本 |
|
||||||
|
| `docs` | 设计、审查、部署与 FRP 文档 |
|
||||||
|
| `.github/workflows` | CI、Docker 发布、GitHub Release |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、总体架构
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Browser[浏览器管理后台] -->|HTTP :6893 / Cookie / Bearer Token| App[fonu 控制面]
|
||||||
|
Public[公网访客] -->|HTTP/HTTPS| Nginx[本机 Nginx]
|
||||||
|
Nginx -->|反向代理| NAS[NAS 内网服务]
|
||||||
|
|
||||||
|
App --> DB[(SQLite /data/fonu.db)]
|
||||||
|
App --> FS[数据目录 /data]
|
||||||
|
App --> NginxCtl[Nginx 配置生成与重载]
|
||||||
|
App --> DDNS[Cloudflare / DNSPod / 阿里云 / 腾讯云 / 火山引擎]
|
||||||
|
App --> ACME[ACME CA]
|
||||||
|
App --> Scheduler[后台调度器]
|
||||||
|
|
||||||
|
App -->|frpc.toml / 进程管理| FRPC[本机 frpc]
|
||||||
|
FRPC -->|隧道| FRPS[公网 VPS frps]
|
||||||
|
FRPS -->|vhost| AgentNginx[VPS Nginx]
|
||||||
|
AgentNginx -->|隧道回源| Nginx
|
||||||
|
|
||||||
|
App -->|Bearer Token API :7700| Agent[fonu-agent]
|
||||||
|
Agent --> AgentNginx
|
||||||
|
Agent --> FRPS
|
||||||
|
Agent --> CertStore[VPS 证书目录]
|
||||||
|
```
|
||||||
|
|
||||||
|
运行角色:
|
||||||
|
|
||||||
|
| 角色 | 运行位置 | 职责 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `fonu` | NAS / Docker / 原生 Linux | 管理后台、本机 Nginx、DDNS、证书、frpc、调度器、数据持久化 |
|
||||||
|
| `fonu-agent` | 公网 VPS | 管理 VPS 上的 Nginx vhost、证书副本、frps 进程与配置,并提供诊断只读接口 |
|
||||||
|
| Nginx | NAS 与 VPS 两端 | NAS 侧负责反代与流量入口;VPS 侧负责公网反代和 frps vhost 回源 |
|
||||||
|
| frpc / frps | NAS / VPS | 在无公网 IP 时建立 TCP、HTTP、HTTPS 等隧道 |
|
||||||
|
| SQLite | NAS 数据目录 | 保存账号、会话、代理规则、DDNS、证书、FRP、指标、健康、备份元数据等 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、技术栈与构建交付
|
||||||
|
|
||||||
|
### 4.1 后端
|
||||||
|
|
||||||
|
`go.mod` 声明 Go 1.23,模块路径为 `github.com/fonu/fonu`。本次验证环境使用 Go 1.26.2,可正常构建与测试。
|
||||||
|
|
||||||
|
关键依赖:
|
||||||
|
|
||||||
|
| 能力 | 依赖 |
|
||||||
|
| --- | --- |
|
||||||
|
| HTTP 服务 | Go 标准库 `net/http` 与 `http.ServeMux` |
|
||||||
|
| SQLite | `modernc.org/sqlite`,纯 Go,不依赖 CGO |
|
||||||
|
| ACME | `github.com/go-acme/lego/v4` |
|
||||||
|
| DNSPod | `github.com/nrdcg/dnspod-go` |
|
||||||
|
| 腾讯云 | `tencentcloud-sdk-go` |
|
||||||
|
| 火山引擎 | `volcengine-go-sdk` |
|
||||||
|
| SSH | `golang.org/x/crypto/ssh` |
|
||||||
|
| 密码哈希 | `golang.org/x/crypto/bcrypt` |
|
||||||
|
| 日志轮转 | `gopkg.in/natefinch/lumberjack.v2` |
|
||||||
|
|
||||||
|
后端有两个入口:
|
||||||
|
|
||||||
|
- `cmd/fonu/main.go`:加载配置、解析迁移目录、嵌入 `web/dist`、启动 `app.New`、监听退出信号。
|
||||||
|
- `cmd/fonu-agent/main.go`:解析监听地址、Token、Nginx 目录、证书目录、frps 路径,启动 Agent HTTP 服务。
|
||||||
|
|
||||||
|
### 4.2 前端
|
||||||
|
|
||||||
|
前端使用 Vue 3.5、TypeScript 5.7、Vite 6:
|
||||||
|
|
||||||
|
| 能力 | 依赖 |
|
||||||
|
| --- | --- |
|
||||||
|
| UI 组件 | Naive UI |
|
||||||
|
| 路由 | Vue Router |
|
||||||
|
| 图表 | ECharts + vue-echarts |
|
||||||
|
| 配置编辑器 | CodeMirror 6 |
|
||||||
|
| 拖拽排序 | SortableJS |
|
||||||
|
| 图标 | `@vicons/ionicons5` |
|
||||||
|
| 测试 | Vitest |
|
||||||
|
|
||||||
|
`web/src` 共 52 个 TypeScript / Vue 源文件。前端测试目前有 2 个 spec、13 条测试,主要集中在格式化与状态映射纯函数。
|
||||||
|
|
||||||
|
### 4.3 Docker 与原生安装
|
||||||
|
|
||||||
|
项目提供三种 Compose:
|
||||||
|
|
||||||
|
| 文件 | 场景 | 网络 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `docker-compose.yml` | 普通 Docker、本地源码构建 | bridge,映射 6893/18080/9443 |
|
||||||
|
| `docker-compose.host.yml` | 飞牛 fnOS 等已占用 80/443 的环境 | host 网络,默认反代端口 18080/9443 |
|
||||||
|
| `docker-compose.hub.yml` | 干净机器只保留一个编排文件 | BuildKit 直接从 Git 仓库拉源码构建 |
|
||||||
|
|
||||||
|
Dockerfile 的主要流程:
|
||||||
|
|
||||||
|
1. 从根目录 `VERSION` 读取版本;
|
||||||
|
2. 构建前端;
|
||||||
|
3. 交叉编译 Linux amd64 / arm64 `fonu-agent`;
|
||||||
|
4. 将前端 `dist` 复制到 `cmd/fonu/web/dist` 供 `go:embed`;
|
||||||
|
5. 编译 `fonu` 主程序;
|
||||||
|
6. 运行时镜像安装 Nginx、CA 证书、tzdata;
|
||||||
|
7. 默认监听 6893,暴露 80/443,数据目录为 `/data`。
|
||||||
|
|
||||||
|
注意:当前 Dockerfile 不预装 `frpc`,需要用户在「应用配置」中按需下载。旧版 README 或部署文档中若仍表述为“镜像内置 frpc”,应按当前实现校正。
|
||||||
|
|
||||||
|
### 4.4 版本一致性
|
||||||
|
|
||||||
|
当前版本事实:
|
||||||
|
|
||||||
|
| 位置 | 值 |
|
||||||
|
| --- | --- |
|
||||||
|
| `VERSION` | `1.2.19` |
|
||||||
|
| `web/package.json` | `1.2.19` |
|
||||||
|
| `AgentVersion` | `0.12.7` |
|
||||||
|
| `internal/version.Version` | 构建时通过 `-ldflags` 注入,默认 `dev` |
|
||||||
|
|
||||||
|
前端构建时优先读取 `VITE_APP_VERSION`,否则回落到 `package.json`。Dockerfile 将 `VERSION` 注入前端和后端。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、后端模块分析
|
||||||
|
|
||||||
|
### 5.1 规模概览
|
||||||
|
|
||||||
|
后端共有 235 个 `.go` 文件,其中 90 个是 `_test.go`。按非测试源码粗略统计,主要模块规模如下:
|
||||||
|
|
||||||
|
| 包 | 文件数 | 约行数 | 职责 |
|
||||||
|
| --- | ---: | ---: | --- |
|
||||||
|
| `frp` | 23 | 6049 | FRP 多服务端、frpc 运行、Agent 调用、诊断、健康、漂移、版本 |
|
||||||
|
| `api` | 22 | 3241 | HTTP 路由、处理器、中间件、响应封装 |
|
||||||
|
| `ddns` | 14 | 2379 | 多服务商 DDNS、记录同步、凭证、Zone 解析 |
|
||||||
|
| `nginx` | 23 | 2186 | Nginx 配置生成、校验、启动、重载、版本与回滚 |
|
||||||
|
| `agent` | 4 | 2079 | 公网 VPS Agent 的全部接口与配置写入 |
|
||||||
|
| `notify` | 7 | 1418 | 通知配置、加密存储、邮件、Webhook、Telegram 与国内 IM |
|
||||||
|
| `acme` | 8 | 1370 | ACME 申请、续期、导入、导出、任务日志 |
|
||||||
|
| `proxy` | 3 | 1015 | 反代规则模型、SQLite Store 与端口冲突校验 |
|
||||||
|
| `service` | 3 | 595 | 反代与 Nginx 配置的业务服务层 |
|
||||||
|
| `monitor` | 1 | 559 | 资源、隧道、服务端、Agent、配置漂移巡检 |
|
||||||
|
| `logstore` | 4 | 685 | 系统日志、访问日志、错误日志读取与显示转换 |
|
||||||
|
| `traffic` | 3 | 421 | Nginx access log 跟踪、流量聚合、在线连接 |
|
||||||
|
| `backup` | 3 | 401 | 导出、恢复、校验、自动备份与保留策略 |
|
||||||
|
| `certificate` | 2 | 353 | 证书元数据 Store 与导入 |
|
||||||
|
| `auth` | 2 | 334 | 管理员、会话、API Token 验证 |
|
||||||
|
| `chinacidr` | 1 | 301 | 中国 IP 段下载、生成与 Nginx 联动 |
|
||||||
|
| `metrics` | 1 | 231 | 指标历史写入、查询与分桶 |
|
||||||
|
| `config` | 1 | 201 | 环境变量、数据目录、会话密钥 |
|
||||||
|
| `settings` | 1 | 193 | 设置项读写、默认值与类型转换 |
|
||||||
|
| `fsutil` | 1 | 164 | 原子写、备份、版本列表与回滚基础 |
|
||||||
|
|
||||||
|
### 5.2 应用装配:`internal/app/app.go`
|
||||||
|
|
||||||
|
`app.New` 是理解整个系统的入口,按顺序完成:
|
||||||
|
|
||||||
|
1. 创建日志目录与 JSON 日志;
|
||||||
|
2. 创建数据、Nginx、日志、证书、FRP 目录;
|
||||||
|
3. 解析或自动生成会话密钥;
|
||||||
|
4. 打开 SQLite 并执行迁移;
|
||||||
|
5. 创建 AES-GCM 凭据加密盒;
|
||||||
|
6. 初始化管理员账号;
|
||||||
|
7. 创建 proxy、settings、ddns、certificate、nginx 等 Store / Service;
|
||||||
|
8. 创建 FRP 多服务端服务;
|
||||||
|
9. 创建 HTTP 路由;
|
||||||
|
10. 创建调度器与流量采集器;
|
||||||
|
11. 启动 DDNS、ACME、中国 IP、巡检、自动备份任务;
|
||||||
|
12. 初始化 Nginx 配置并恢复 frpc 运行状态;
|
||||||
|
13. 启动 HTTP 服务,退出时关闭 Nginx、frpc 和数据库。
|
||||||
|
|
||||||
|
这种集中装配方式让依赖关系清晰,但也使 `app.go` 成为高耦合入口。新增模块时应继续通过构造参数或小型接口注入,避免在 `app.New` 中堆积具体职责。
|
||||||
|
|
||||||
|
### 5.3 配置与数据目录:`internal/config`
|
||||||
|
|
||||||
|
关键环境变量:
|
||||||
|
|
||||||
|
| 变量 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `FONU_LISTEN` | `:6893` | 管理后台监听地址 |
|
||||||
|
| `FONU_DATA_DIR` | `/data` 或当前目录 `.data` | 数据根目录 |
|
||||||
|
| `FONU_SESSION_SECRET` | 空 | 会话签名与凭据加密密钥;空时自动生成 |
|
||||||
|
| `FONU_NGINX_BIN` | `nginx` | Nginx 可执行文件 |
|
||||||
|
| `FONU_NGINX_PID` | `<DataDir>/nginx/nginx.pid` | Nginx PID |
|
||||||
|
| `FONU_NGINX_MIME_TYPES` | `/etc/nginx/mime.types` 或仓库资源 | MIME 类型文件 |
|
||||||
|
| `FONU_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代默认端口 |
|
||||||
|
| `FONU_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代默认端口 |
|
||||||
|
| `FONU_FRPC_BIN` | `frpc` | frpc 可执行文件 |
|
||||||
|
| `FONU_FRPC_PID` | `<DataDir>/frp/frpc.pid` | frpc PID 文件 |
|
||||||
|
|
||||||
|
会话密钥策略值得肯定:
|
||||||
|
|
||||||
|
- 显式配置时优先使用;
|
||||||
|
- 未配置时生成 32 字节随机值;
|
||||||
|
- 持久化为 `<DataDir>/session_secret`,权限 0600;
|
||||||
|
- 使用临时文件加改名写入,避免半文件;
|
||||||
|
- 已经使用历史默认值时只提示,不自动替换,避免历史密文全部无法解密。
|
||||||
|
|
||||||
|
数据目录典型结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<DataDir>/
|
||||||
|
├─ fonu.db
|
||||||
|
├─ session_secret
|
||||||
|
├─ ssh_known_hosts
|
||||||
|
├─ nginx/
|
||||||
|
│ ├─ nginx.conf
|
||||||
|
│ ├─ rules/
|
||||||
|
│ ├─ custom/
|
||||||
|
│ ├─ backups/
|
||||||
|
│ ├─ errors/
|
||||||
|
│ └─ china_cidr.conf
|
||||||
|
├─ certs/
|
||||||
|
├─ frp/
|
||||||
|
│ ├─ frpc.toml
|
||||||
|
│ └─ frpc.pid
|
||||||
|
├─ logs/
|
||||||
|
│ ├─ app.log
|
||||||
|
│ ├─ access.log
|
||||||
|
│ ├─ error.log
|
||||||
|
│ └─ frpc.log
|
||||||
|
└─ backups/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 数据库:`internal/db`
|
||||||
|
|
||||||
|
SQLite 初始化参数:
|
||||||
|
|
||||||
|
- `foreign_keys(1)`:启用外键;
|
||||||
|
- `busy_timeout(5000)`:锁等待 5 秒;
|
||||||
|
- `SetMaxOpenConns(1)`:单连接串行访问。
|
||||||
|
|
||||||
|
单连接是保守且适合 NAS 单机场景的选择,能避免多连接写冲突,但所有 API、调度任务和日志查询都共享一个数据库连接。当历史指标、健康事件和流量查询增大时,响应延迟可能互相影响。
|
||||||
|
|
||||||
|
迁移系统特点:
|
||||||
|
|
||||||
|
- 按文件名前缀数字排序;
|
||||||
|
- 每个迁移在独立事务中执行;
|
||||||
|
- 成功后写入 `schema_migrations`;
|
||||||
|
- 已应用版本跳过;
|
||||||
|
- 迁移文件不嵌入二进制,运行时通过 `ResolveMigrationsDir` 在多个候选路径查找。
|
||||||
|
|
||||||
|
### 5.5 认证与凭据
|
||||||
|
|
||||||
|
认证能力分三类:
|
||||||
|
|
||||||
|
1. 管理员账号:bcrypt 哈希;
|
||||||
|
2. 浏览器会话:32 字节随机 Token,数据库保存,默认 7 天;
|
||||||
|
3. API Token:随机明文只返回一次,数据库保存 SHA-256,支持 `read` / `write` 范围。
|
||||||
|
|
||||||
|
登录保护:
|
||||||
|
|
||||||
|
- 同一 IP 在 5 分钟内失败 5 次后返回 429;
|
||||||
|
- 登录失败会进入通知统计;
|
||||||
|
- 修改密码后删除该账号的其他会话,保留当前会话;
|
||||||
|
- Cookie 使用 `HttpOnly`、`SameSite=Lax`,并根据直连 TLS 或 `X-Forwarded-Proto` 设置 `Secure`。
|
||||||
|
|
||||||
|
凭据加密:
|
||||||
|
|
||||||
|
- 使用会话密钥经 SHA-256 派生 AES-256-GCM;
|
||||||
|
- 加密 DDNS、FRP、通知、SSH、Dashboard 和 OIDC 等敏感字段;
|
||||||
|
- 部分凭据 API 只返回 `has_token` / `has_password` 等状态,不回传明文。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、数据库模型
|
||||||
|
|
||||||
|
### 6.1 表清单
|
||||||
|
|
||||||
|
| 表 | 迁移 | 作用 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `schema_migrations` | 001 | 迁移版本 |
|
||||||
|
| `admins` | 001 | 管理员账号 |
|
||||||
|
| `sessions` | 001 | 浏览器会话 |
|
||||||
|
| `api_tokens` | 025 | API 脚本令牌 |
|
||||||
|
| `proxy_rules` | 001 / 006 / 010-014 | 本机反向代理规则 |
|
||||||
|
| `proxy_hosts` | 006 / 010 | 规则域名与监听端口 |
|
||||||
|
| `proxy_custom_endpoints` | 024 | 自定义 Nginx 规则解析出的域名端口占用 |
|
||||||
|
| `proxy_traffic` | 009 | 规则累计流量 |
|
||||||
|
| `ddns_configs` | 002-008 / 022 | DDNS 任务、记录、凭证、自定义 IP |
|
||||||
|
| `certificates` | 002 / 004 / 005 | 证书元数据、CA、多域名 |
|
||||||
|
| `settings` | 001 | 键值设置 |
|
||||||
|
| `frp_servers` | 015-016 / 020 / 023 | FRP 服务端、认证、Dashboard、Agent、SSH |
|
||||||
|
| `frp_proxies` | 015 / 018 / 021 | FRP 穿透规则与扩展选项 |
|
||||||
|
| `frp_runtime_status` | 015 / 019 | frpc 期望状态、进程状态、连接状态 |
|
||||||
|
| `frp_server_diagnostics` | 017 | 服务端延迟与可用性采样 |
|
||||||
|
| `route_health_events` | 026 | 公网规则状态变更事件 |
|
||||||
|
| `metrics_samples` | 027 | 本机与规则指标历史 |
|
||||||
|
|
||||||
|
### 6.2 数据设计亮点
|
||||||
|
|
||||||
|
- `proxy_hosts(hostname, listen_port)` 唯一,避免同一域名与端口被多条规则占用。
|
||||||
|
- `proxy_custom_endpoints` 单独记录自定义 Nginx 配置的监听端点,保证自定义模式也能参与冲突校验。
|
||||||
|
- `frp_proxies.type` 在 021 迁移中扩展到 8 种类型,并为 TCP / UDP 远程端口分别建立唯一索引。
|
||||||
|
- `frp_runtime_status` 将“用户期望状态”和“实际进程/连接状态”分开,有助于区分手动停止和异常掉线。
|
||||||
|
- `route_health_events` 只记录状态变化,不保存每轮原始采样,降低长期数据量。
|
||||||
|
- `metrics_samples` 采用 `(scope, metric, value, at)` 纵向结构,增加指标不需要改表。
|
||||||
|
|
||||||
|
### 6.3 数据生命周期
|
||||||
|
|
||||||
|
| 数据 | 清理方式 |
|
||||||
|
| --- | --- |
|
||||||
|
| 会话 | 校验过期时删除;修改密码时删除其他会话 |
|
||||||
|
| 健康事件 | 巡检按保留期清理 |
|
||||||
|
| 指标采样 | 巡检按保留期清理 |
|
||||||
|
| 备份归档 | 按设置中的保留份数剪枝 |
|
||||||
|
| Nginx 配置版本 | 默认保留最近 10 个有效变化 |
|
||||||
|
| 日志 | 由 `logstore` 与轮转策略处理 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、HTTP API 与认证面
|
||||||
|
|
||||||
|
### 7.1 路由规模
|
||||||
|
|
||||||
|
当前 `internal/api/router.go` 中:
|
||||||
|
|
||||||
|
- 受保护路由约 113 条:使用 `SessionMiddleware + RequireAuth`;
|
||||||
|
- 匿名路由 5 条:版本、登录状态、登录、退出、公开状态页;
|
||||||
|
- 静态资源与 SPA fallback:非 `/api/` 路由全部交给前端。
|
||||||
|
|
||||||
|
匿名接口:
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/version` | 版本与默认反代端口 |
|
||||||
|
| GET | `/api/auth/status` | 是否初始化、是否已登录 |
|
||||||
|
| POST | `/api/auth/login` | 登录 |
|
||||||
|
| POST | `/api/auth/logout` | 退出 |
|
||||||
|
| GET | `/api/status-page` | 默认关闭的只读状态页 |
|
||||||
|
|
||||||
|
### 7.2 API 分组
|
||||||
|
|
||||||
|
| 分组 | 代表路径 | 能力 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 认证 | `/api/auth/*` | 登录、退出、改密 |
|
||||||
|
| API Token | `/api/settings/tokens` | 创建、列出、撤销 |
|
||||||
|
| 仪表盘 | `/api/status` | 公网 IP、DDNS、证书、反代、资源和请求趋势 |
|
||||||
|
| 本机反代 | `/api/proxies*` | 规则 CRUD、排序、流量、连接、实时日志 |
|
||||||
|
| Nginx 配置 | `/api/proxies/{id}/nginx`、`/api/settings/nginx/*` | 自动/自定义配置、校验、回滚、版本 |
|
||||||
|
| DDNS | `/api/ddns*` | 任务、测试、全部更新、单任务更新 |
|
||||||
|
| 证书 | `/api/certificates*` | CA 选项、申请、SSE 日志、导入、续期、下载、删除 |
|
||||||
|
| 设置 | `/api/settings` | 常规、通知、安全、高级设置 |
|
||||||
|
| 日志 | `/api/logs/*` | 访问、错误、系统、SSE |
|
||||||
|
| 指标 | `/api/metrics/history` | 分桶历史趋势 |
|
||||||
|
| 备份 | `/api/backup*` | 导出、恢复、归档、下载、删除 |
|
||||||
|
| 服务发现 | `/api/discovery/scan` | 扫描常见 NAS 服务端口 |
|
||||||
|
| FRP 多服务端 | `/api/frpmulti/*` | 服务端、frpc、规则、诊断、版本下载 |
|
||||||
|
| 公网 Agent | `/api/frpmulti/servers/{id}/agent/*` | 状态、Nginx、frps、证书、路由、漂移、指标 |
|
||||||
|
|
||||||
|
### 7.3 Agent API
|
||||||
|
|
||||||
|
`fonu-agent` 当前提供 20 个 `/api/v1/*` 接口,全部使用 Bearer Token:
|
||||||
|
|
||||||
|
| 类别 | 接口 |
|
||||||
|
| --- | --- |
|
||||||
|
| 基础 | `ping`、`status` |
|
||||||
|
| 路由 | 列表、写入、详情、删除、版本、回滚 |
|
||||||
|
| Nginx | 安装、重载、日志 |
|
||||||
|
| frps | 配置读取与写入、安装、启停重启 |
|
||||||
|
| 证书 | 写入与列表 |
|
||||||
|
| 中国 IP | `PUT /china-cidr` |
|
||||||
|
| 指标 | `GET /metrics` |
|
||||||
|
|
||||||
|
Agent 侧的重要安全措施:
|
||||||
|
|
||||||
|
- SSH 安装使用 TOFU 主机密钥校验,首次记录,后续变更拒绝;
|
||||||
|
- frps 下载只允许官方地址和固定加速前缀;
|
||||||
|
- frps 安装包下载后校验 SHA-256;
|
||||||
|
- Basic Auth 使用纯 Go apr1,不依赖 VPS 上的 `openssl`;
|
||||||
|
- 写 vhost 前保留版本,`nginx -t` 失败时恢复上一个可用配置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、前端结构
|
||||||
|
|
||||||
|
### 8.1 路由与页面
|
||||||
|
|
||||||
|
前端共 11 条路由定义:
|
||||||
|
|
||||||
|
| 路径 | 名称 | 是否公开 | 页面 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/setup` | - | 重定向 | 跳转登录 |
|
||||||
|
| `/login` | `login` | 是 | 登录 |
|
||||||
|
| `/status` | `status` | 是 | 公开状态页 |
|
||||||
|
| `/dashboard` | `dashboard` | 否 | 仪表盘 |
|
||||||
|
| `/ddns` | `ddns` | 否 | DDNS |
|
||||||
|
| `/certificates` | `certificates` | 否 | 证书管理 |
|
||||||
|
| `/proxies` | `proxies` | 否 | 本机反向代理 |
|
||||||
|
| `/frp` | `frp` | 否 | 内网穿透 |
|
||||||
|
| `/frp-agent` | `frp-agent` | 否 | 公网服务端 |
|
||||||
|
| `/public-proxy` | `public-proxy` | 否 | 公网反代 |
|
||||||
|
| `/logs` | `logs` | 否 | 日志中心 |
|
||||||
|
| `/settings` | `settings` | 否 | 系统设置 |
|
||||||
|
|
||||||
|
路由守卫在每次受保护页面跳转前请求 `/api/auth/status`。公开状态页不做登录检查,避免匿名访问时产生无意义请求。
|
||||||
|
|
||||||
|
### 8.2 页面规模
|
||||||
|
|
||||||
|
| 文件 | 约行数 | 主要职责 |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| `ProxyView.vue` | 3150 | 本机反代规则、详情、日志、Nginx 配置 |
|
||||||
|
| `SettingsView.vue` | 2144 | 常规、通知、安全、高级、备份、Token |
|
||||||
|
| `AgentView.vue` | 1807 | 公网服务端、Agent、frps 生命周期、诊断 |
|
||||||
|
| `CertificatesView.vue` | 1685 | 证书列表、申请、续期、下载、导入 |
|
||||||
|
| `DashboardView.vue` | 1679 | 状态卡、趋势、健康、流量 |
|
||||||
|
| `DdnsView.vue` | 1568 | DDNS 任务、记录、服务商配置 |
|
||||||
|
| `FrpView.vue` | 1406 | frpc 服务端和穿透规则 |
|
||||||
|
| `ProxyRemoteView.vue` | 1150 | 公网反代部署、漂移、健康、版本 |
|
||||||
|
| `LogsView.vue` | 860 | 系统、访问、错误、FRP 日志 |
|
||||||
|
|
||||||
|
这些页面都由 Composition API 直接承载大量状态和逻辑。当前结构适合快速迭代,但需要继续拆分:
|
||||||
|
|
||||||
|
- `ProxyView.vue`:拆规则表单、Nginx 面板、客户端面板、日志面板;
|
||||||
|
- `AgentView.vue`:拆服务端表单、日志弹窗、详情弹窗、延迟图;
|
||||||
|
- `SettingsView.vue`:拆通知、安全、备份、Token、状态页卡片。
|
||||||
|
|
||||||
|
### 8.3 API 客户端
|
||||||
|
|
||||||
|
`web/src/api/client.ts` 统一处理:
|
||||||
|
|
||||||
|
- `credentials: include`;
|
||||||
|
- JSON 请求体;
|
||||||
|
- 30 秒普通请求超时;
|
||||||
|
- 10 分钟证书操作超时;
|
||||||
|
- 错误信息归一化;
|
||||||
|
- Blob 下载与文件名解析。
|
||||||
|
|
||||||
|
`web/src/api/types.ts` 约 923 行,集中定义后端响应结构。类型覆盖较全,但仍保留旧 FRP 单体接口类型,形成历史冗余。
|
||||||
|
|
||||||
|
### 8.4 前端测试
|
||||||
|
|
||||||
|
已有:
|
||||||
|
|
||||||
|
- `web/src/utils/status.spec.ts`:状态值到 UI 状态的映射回归;
|
||||||
|
- `web/src/utils/format.spec.ts`:格式化函数。
|
||||||
|
|
||||||
|
缺口:
|
||||||
|
|
||||||
|
- API 客户端无测试;
|
||||||
|
- 页面组件无测试;
|
||||||
|
- 轮询与可见性行为无测试;
|
||||||
|
- 配置差异、回滚、表单校验等高风险交互仍只能依赖类型检查与人工验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、核心业务链路
|
||||||
|
|
||||||
|
### 9.1 本机反向代理
|
||||||
|
|
||||||
|
数据流:
|
||||||
|
|
||||||
|
```text
|
||||||
|
前端保存规则
|
||||||
|
→ proxy.Store 校验域名与端口唯一性
|
||||||
|
→ 写入 proxy_rules / proxy_hosts
|
||||||
|
→ 生成 Nginx 配置
|
||||||
|
→ nginx -t 校验
|
||||||
|
→ 热重载或重启
|
||||||
|
→ 写入流量采集与日志
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:
|
||||||
|
|
||||||
|
- 规则支持多域名、多端口、IPv4/IPv6 监听、HTTPS 与 HTTP 跳转;
|
||||||
|
- 安全设置保存在 `security_json`,包括 IP 黑白名单、仅中国大陆、Basic Auth、限流、连接数限制、安全响应头等;
|
||||||
|
- 自动模式由模板生成,自定义模式允许编辑但会解析监听端点参与冲突校验;
|
||||||
|
- 配置写入前保留 `.bak` 与 `.versions`,失败不覆盖当前可用配置;
|
||||||
|
- Nginx 配置版本支持列表、读取、差异对比和回滚。
|
||||||
|
|
||||||
|
### 9.2 DDNS
|
||||||
|
|
||||||
|
支持的服务商:
|
||||||
|
|
||||||
|
- Cloudflare;
|
||||||
|
- DNSPod;
|
||||||
|
- 阿里云 DNS;
|
||||||
|
- 腾讯云 DNS;
|
||||||
|
- 火山引擎 DNS。
|
||||||
|
|
||||||
|
能力:
|
||||||
|
|
||||||
|
- 单任务多根域名 / 多记录;
|
||||||
|
- 最长后缀 Zone 匹配;
|
||||||
|
- IPv4 / IPv6;
|
||||||
|
- 自定义固定 IP;
|
||||||
|
- 手动测试与全部更新;
|
||||||
|
- 定时任务按设置间隔运行,默认 5 分钟;
|
||||||
|
- 凭证加密保存;
|
||||||
|
- IP 变化、失败等情况进入通知系统。
|
||||||
|
|
||||||
|
### 9.3 证书
|
||||||
|
|
||||||
|
证书链路:
|
||||||
|
|
||||||
|
```text
|
||||||
|
选择 CA 与域名
|
||||||
|
→ 复用 DDNS 凭证
|
||||||
|
→ DNS-01 挑战
|
||||||
|
→ lego 申请
|
||||||
|
→ 写入证书目录
|
||||||
|
→ 更新 certificates 表
|
||||||
|
→ 重载 Nginx
|
||||||
|
→ 前端通过 SSE 获取任务日志
|
||||||
|
```
|
||||||
|
|
||||||
|
当前 CA 相关能力:
|
||||||
|
|
||||||
|
- Let's Encrypt;
|
||||||
|
- ZeroSSL;
|
||||||
|
- LiteSSL;
|
||||||
|
- LiteSSL 强制 EAB;
|
||||||
|
- 可配置 DNS 递归解析器,避免容器内 `127.0.0.11` 无法解析权威 NS;
|
||||||
|
- 支持导入 PEM、下载 ZIP / 证书 / 私钥;
|
||||||
|
- 每天执行续期巡检,按到期阈值提醒。
|
||||||
|
|
||||||
|
### 9.4 FRP 与公网 Agent
|
||||||
|
|
||||||
|
FRP 模块当前支持:
|
||||||
|
|
||||||
|
- 多台公网 frps;
|
||||||
|
- 8 种穿透类型:tcp、udp、http、https、tcpmux、stcp、sudp、xtcp;
|
||||||
|
- frpc 多版本下载、启用与删除;
|
||||||
|
- frpc 配置生成与进程管理;
|
||||||
|
- frps.toml 同源生成;
|
||||||
|
- 服务端认证方式:token、none、OIDC;
|
||||||
|
- frpc 流量、连接数与速率;
|
||||||
|
- 服务端 TCP / 登录 / 代理 / DNS / 归属地诊断;
|
||||||
|
- SSH 一键安装、升级、卸载 `fonu-agent`;
|
||||||
|
- Agent 状态、Nginx、frps 配置、证书、日志、指标;
|
||||||
|
- 公网反代 vhost 部署、漂移检测、版本与回滚;
|
||||||
|
- 四段健康检查:DNS、证书、隧道、回源服务。
|
||||||
|
|
||||||
|
主要风险点:
|
||||||
|
|
||||||
|
- `internal/frp/service.go` 和 `internal/agent/server.go` 都是超大文件;
|
||||||
|
- 宿主机 Nginx 模板与 Agent Nginx 模板存在两份实现,需要人工保持同步;
|
||||||
|
- Agent 与主程序之间存在版本兼容契约,旧 Agent 通过 404/405 映射升级提示,但接口演进仍需谨慎;
|
||||||
|
- VPS 上的系统环境差异大,尤其是 Nginx 安装位置、systemd、宝塔环境与 PATH。
|
||||||
|
|
||||||
|
### 9.5 监控、健康与通知
|
||||||
|
|
||||||
|
调度器每 2 分钟执行 `monitor`:
|
||||||
|
|
||||||
|
- 本机 CPU、内存、磁盘;
|
||||||
|
- 资源阈值;
|
||||||
|
- 本机规则流量;
|
||||||
|
- 公网规则 DNS / 证书 / 隧道 / 服务四段健康;
|
||||||
|
- frps 服务端掉线;
|
||||||
|
- Agent 不可达;
|
||||||
|
- 公网 vhost 配置漂移。
|
||||||
|
|
||||||
|
通知渠道:
|
||||||
|
|
||||||
|
- SMTP 邮件;
|
||||||
|
- Webhook;
|
||||||
|
- Telegram;
|
||||||
|
- Bark;
|
||||||
|
- ntfy;
|
||||||
|
- Gotify;
|
||||||
|
- 企业微信;
|
||||||
|
- 钉钉;
|
||||||
|
- 飞书。
|
||||||
|
|
||||||
|
告警策略已具备:
|
||||||
|
|
||||||
|
- 连续失败阈值;
|
||||||
|
- 恢复通知;
|
||||||
|
- 资源告警冷却;
|
||||||
|
- 配置漂移冷却;
|
||||||
|
- 登录失败窗口统计。
|
||||||
|
|
||||||
|
### 9.6 指标、日志与流量
|
||||||
|
|
||||||
|
指标:
|
||||||
|
|
||||||
|
- 本机资源与每条公网规则流量写入 `metrics_samples`;
|
||||||
|
- 查询时按桶取平均,前端支持 1 小时、24 小时、7 天趋势;
|
||||||
|
- 避免每加一个指标就改表结构。
|
||||||
|
|
||||||
|
日志:
|
||||||
|
|
||||||
|
- `app.log`:应用系统日志;
|
||||||
|
- `access.log`:本机 Nginx 访问日志;
|
||||||
|
- `error.log`:本机 Nginx 错误日志;
|
||||||
|
- `frpc.log`:FRP 客户端日志;
|
||||||
|
- 日志中心支持分页、筛选和 SSE 实时访问/错误日志。
|
||||||
|
|
||||||
|
流量:
|
||||||
|
|
||||||
|
- `traffic.Collector` 跟踪 access log 增量;
|
||||||
|
- 汇总到规则和域名;
|
||||||
|
- 计算累计上下行、速率与当前连接数;
|
||||||
|
- 与监控巡检共用主机维度聚合算法,减少口径漂移。
|
||||||
|
|
||||||
|
### 9.7 备份与恢复
|
||||||
|
|
||||||
|
能力:
|
||||||
|
|
||||||
|
- 手动导出;
|
||||||
|
- 上传恢复;
|
||||||
|
- 定时自动备份;
|
||||||
|
- 服务端归档列表;
|
||||||
|
- 下载、恢复、删除;
|
||||||
|
- 恢复前校验归档;
|
||||||
|
- 按保留份数剪枝;
|
||||||
|
- 自动备份失败通知。
|
||||||
|
|
||||||
|
恢复会覆盖数据库与证书等关键数据,前端需要二次确认。由于 SQLite 正在运行,恢复后推荐重启 Fonu。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、后台调度
|
||||||
|
|
||||||
|
`internal/app/app.go` 注册的任务:
|
||||||
|
|
||||||
|
| 任务 | 间隔 | 作用 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| DDNS | 默认 5 分钟,可在设置中调整 | 检测公网 IP 并同步 DNS |
|
||||||
|
| ACME | 24 小时 | 证书续期与到期提醒 |
|
||||||
|
| 中国 IP 段 | 默认 24 小时,可在设置中调整 | 下载并生成 Nginx geo 配置 |
|
||||||
|
| monitor | 2 分钟 | 资源、隧道、服务端、Agent、漂移巡检与指标采样 |
|
||||||
|
| backup | 24 小时 | 自动备份并按策略保留 |
|
||||||
|
|
||||||
|
调度器还负责:
|
||||||
|
|
||||||
|
- 任务 panic 恢复;
|
||||||
|
- 非正间隔跳过;
|
||||||
|
- 应用退出时通过 Context 取消。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、部署与运维
|
||||||
|
|
||||||
|
### 11.1 推荐部署形态
|
||||||
|
|
||||||
|
飞牛 fnOS 推荐:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name fonu \
|
||||||
|
--net=host \
|
||||||
|
-v /vol1/docker/fonu-data:/data \
|
||||||
|
--restart unless-stopped \
|
||||||
|
fonu:local
|
||||||
|
```
|
||||||
|
|
||||||
|
管理入口 `http://<NAS-IP>:6893`,默认反代端口 18080/9443,避免和系统 Nginx 抢占 80/443。
|
||||||
|
|
||||||
|
### 11.2 升级方式
|
||||||
|
|
||||||
|
三种方式:
|
||||||
|
|
||||||
|
1. 本地源码:`docker compose up -d --build`;
|
||||||
|
2. 单文件编排:保留 `docker-compose.hub.yml`,重新执行 `docker compose up -d --build`;
|
||||||
|
3. 原生安装:使用 `scripts/build-linux.ps1` 构建包,再由 `scripts/fonu-install.sh` 安装。
|
||||||
|
|
||||||
|
升级脚本:
|
||||||
|
|
||||||
|
- `scripts/upgrade.sh`:拉代码、构建、重建、健康检查,支持 `--no-pull` 和回滚;
|
||||||
|
- `scripts/upgrade-hub.sh`:仅使用编排文件的部署升级;
|
||||||
|
- `scripts/agent-install.sh`:安装 `fonu-agent`。
|
||||||
|
|
||||||
|
### 11.3 数据持久化
|
||||||
|
|
||||||
|
默认使用命名卷 `fonu-data`。如需绑定宿主机目录,在 `.env` 中设置:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
FONU_DATA_PATH=/vol1/docker/fonu-data
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose 约定以 `/` 或 `.` 开头的值解释为 bind mount,其余字符串仍是命名卷。
|
||||||
|
|
||||||
|
`FONU_SESSION_SECRET` 同时承担凭据解密,已有数据后不能随意替换,否则 DDNS、FRP、通知和 SSH 等密文可能无法解密。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、工程化、测试与质量门禁
|
||||||
|
|
||||||
|
### 12.1 CI
|
||||||
|
|
||||||
|
`.github/workflows/ci.yml` 已实现完整验证顺序:
|
||||||
|
|
||||||
|
1. Checkout;
|
||||||
|
2. 安装 Node 22;
|
||||||
|
3. `npm ci`;
|
||||||
|
4. `npm run build`;
|
||||||
|
5. `npm test`;
|
||||||
|
6. 将 `web/dist` 同步到 `cmd/fonu/web/dist`;
|
||||||
|
7. 安装 Go;
|
||||||
|
8. `go vet ./...`;
|
||||||
|
9. `go test ./... -count=1`;
|
||||||
|
10. `go build ./...`。
|
||||||
|
|
||||||
|
顺序不可颠倒,因为 `cmd/fonu/main.go` 使用 `//go:embed all:web/dist`,干净检出时前端产物必须先生成。
|
||||||
|
|
||||||
|
当前 CI 尚未开启 `gofmt -l` 卡口,原因是仓库仍有历史未格式化文件。建议在集中执行一次 `gofmt -w` 后开启。
|
||||||
|
|
||||||
|
### 12.2 发布工作流
|
||||||
|
|
||||||
|
- `docker-release.yml`:tag 或手动触发,构建 amd64/arm64 镜像并生成多架构 manifest;
|
||||||
|
- `github-release.yml`:根据 `CHANGELOG.md` 生成 GitHub Release。
|
||||||
|
|
||||||
|
### 12.3 测试覆盖
|
||||||
|
|
||||||
|
Go 测试分布较广,`nginx` 包测试最多,其次为 `frp`、`acme`、`api`。
|
||||||
|
|
||||||
|
当前没有测试文件的包:
|
||||||
|
|
||||||
|
- `internal/discovery`;
|
||||||
|
- `internal/secret`;
|
||||||
|
- `internal/service`;
|
||||||
|
- `internal/settings`;
|
||||||
|
- `internal/version`;
|
||||||
|
- `cmd/fonu`;
|
||||||
|
- `cmd/fonu-agent`。
|
||||||
|
|
||||||
|
优先级较高的补测:
|
||||||
|
|
||||||
|
1. `internal/service` 的规则保存、Nginx 自定义配置与回滚;
|
||||||
|
2. `internal/settings` 的类型转换、默认值和迁移;
|
||||||
|
3. `internal/secret` 的加解密与密钥错误提示;
|
||||||
|
4. 路由与中间件测试,确保新增受保护接口不会漏鉴权;
|
||||||
|
5. Agent `readLimitState`、`writeLimitState` 与配置回滚;
|
||||||
|
6. 前端 API 客户端和轮询组合式函数。
|
||||||
|
|
||||||
|
### 12.4 本次验证
|
||||||
|
|
||||||
|
本次分析实际执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./... -count=1
|
||||||
|
go vet ./...
|
||||||
|
cd web && npm run typecheck
|
||||||
|
cd web && npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
- Go:所有含测试包通过;
|
||||||
|
- Go vet:无输出,退出码 0;
|
||||||
|
- 前端类型检查:通过;
|
||||||
|
- 前端测试:2 个测试文件、13 条测试全部通过。
|
||||||
|
|
||||||
|
### 12.5 格式化
|
||||||
|
|
||||||
|
仓库目前仍存在历史未格式化文件。本次只做过读取和测试,没有批量执行 `gofmt -w`,以免把格式化噪音混入用户当前未提交的改动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十三、当前风险与技术债
|
||||||
|
|
||||||
|
### 13.1 功能与兼容性
|
||||||
|
|
||||||
|
1. **旧 `/api/frp` 客户端残留**
|
||||||
|
- `web/src/api/client.ts` 仍定义 `getFRP`、`saveFRP`、`getFRPStatus`、`syncFRPDomains`、`getFRPLogs`;
|
||||||
|
- `web/src/views/DdnsView.vue` 在 `init()` 中调用 `api.getFRP()`;
|
||||||
|
- 后端当前只注册 `/api/frpmulti/*`,因此该请求失败后进入 `catch`,`frpEnabled` 被设为 `false`;
|
||||||
|
- 影响:DDNS 页可能无法显示与 FRP 相关的提示;
|
||||||
|
- 建议:删除旧类型与旧方法,或改为调用 `/api/frpmulti/status`。
|
||||||
|
|
||||||
|
2. **Agent 版本兼容需要长期维护**
|
||||||
|
- 主程序通过 404/405 识别旧 Agent,并提示升级;
|
||||||
|
- 新接口应继续沿用最低版本提示,避免前端只看到网络错误。
|
||||||
|
|
||||||
|
3. **宿主机与 Agent 的 Nginx 模板双份实现**
|
||||||
|
- 一边是 `internal/nginx`,一边是 `internal/agent`;
|
||||||
|
- 修改一处忘记另一处会造成本机与公网行为不一致;
|
||||||
|
- 建议抽共享模板包,至少共享安全指令、限流、geo、WebSocket、TLS 片段。
|
||||||
|
|
||||||
|
### 13.2 安全
|
||||||
|
|
||||||
|
1. **初始管理员密码写入应用日志**
|
||||||
|
- `auth.EnsureDefaultAdmin` 记录用户名和密码;
|
||||||
|
- `logstore.appendSystemAttrs` 会把密码原样拼进系统日志;
|
||||||
|
- `GET /api/logs/system` 可读取该系统日志;
|
||||||
|
- 建议只在进程首次启动时输出到终端一次,不写入持久日志;或生成一次性初始密码文件并设置 0600。
|
||||||
|
|
||||||
|
2. **Agent 默认明文 HTTP 监听**
|
||||||
|
- 默认 `:7700`,Bearer Token 直接在网络中传输;
|
||||||
|
- 如果直接暴露公网,需要 HTTPS、内网限制或前置反向代理;
|
||||||
|
- 建议默认绑定 `127.0.0.1`,由 Nginx 或隧道暴露,并把公网绑定作为显式选项。
|
||||||
|
|
||||||
|
3. **Agent Token 比较未使用常量时间**
|
||||||
|
- Agent 使用字符串比较;
|
||||||
|
- 建议使用 `crypto/subtle.ConstantTimeCompare`。
|
||||||
|
|
||||||
|
4. **Agent 限流状态反序列化失败会静默清空**
|
||||||
|
- `readLimitState` 忽略 JSON 错误;
|
||||||
|
- 后续写入可能删除 zone 文件;
|
||||||
|
- 建议解析失败返回错误并保留原文件。
|
||||||
|
|
||||||
|
5. **备份上传仍允许 64MB**
|
||||||
|
- 已有 `CreateTemp` 修复路径穿越;
|
||||||
|
- 仍建议降低上限并增加频率限制。
|
||||||
|
|
||||||
|
### 13.3 性能与并发
|
||||||
|
|
||||||
|
1. **SQLite 单连接**
|
||||||
|
- 当前实现简单可靠,但监控查询、日志、指标与 API 会竞争同一连接;
|
||||||
|
- 可通过缩短事务、减少大查询、为高频读增加轻量缓存来缓解。
|
||||||
|
|
||||||
|
2. **前端大页面与高频轮询**
|
||||||
|
- 多个页面存在 15 秒轮询,仪表盘流量轮询更快;
|
||||||
|
- 轮询请求应加入 in-flight 互斥,并在页面隐藏时暂停;
|
||||||
|
- 可以统一抽 `useVisibilityPolling`。
|
||||||
|
|
||||||
|
3. **巨型后端文件**
|
||||||
|
- `internal/frp/service.go` 约 1805 行;
|
||||||
|
- `internal/agent/server.go` 约 1551 行;
|
||||||
|
- 建议按能力拆分文件,例如 rate limit、nginx template、frps install、route versions、diagnostics。
|
||||||
|
|
||||||
|
### 13.4 维护性
|
||||||
|
|
||||||
|
1. **前端样式与弹窗外壳重复**
|
||||||
|
- 多个页面重复实现左列表、弹窗外壳和状态色;
|
||||||
|
- 建议抽 `SidebarList.vue`、`ModalShell.vue` 和统一状态令牌。
|
||||||
|
|
||||||
|
2. **硬编码颜色仍需收敛**
|
||||||
|
- `variables.css` 已有品牌令牌;
|
||||||
|
- 页面中仍有大量直接色值与重复色板。
|
||||||
|
|
||||||
|
3. **旧文档时效**
|
||||||
|
- `project-audit-2026-09-24.md` 基于 v1.2.6,其中很多 P0/P1 已实施;
|
||||||
|
- 阅读旧审查时应以 CHANGELOG、当前源码和本文档为准。
|
||||||
|
|
||||||
|
4. **归档目录删除尚未提交**
|
||||||
|
- `internal/frp-1.0.2` 与 `FrpView-1.0.2.vue` 在工作树中已删除但未提交;
|
||||||
|
- 分析时保持原样,没有回退或提交。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十四、建议改进路线
|
||||||
|
|
||||||
|
### P0:先处理明确问题
|
||||||
|
|
||||||
|
1. 修正 `DdnsView` 对旧 `/api/frp` 的调用,切到 `/api/frpmulti/status`。
|
||||||
|
2. 初始管理员密码不再写入持久应用日志。
|
||||||
|
3. Agent Token 比较改为常量时间。
|
||||||
|
4. `readLimitState` 解析失败时拒绝覆盖原状态。
|
||||||
|
5. 明确 Agent 默认监听策略,最好默认仅本机或要求 HTTPS。
|
||||||
|
|
||||||
|
### P1:降低核心模块风险
|
||||||
|
|
||||||
|
1. 抽共享 Nginx 模板包,统一本机与 Agent 的安全、限流、geo、TLS 逻辑。
|
||||||
|
2. 拆分 `internal/frp/service.go` 与 `internal/agent/server.go`。
|
||||||
|
3. 拆分 `ProxyView.vue`、`SettingsView.vue`、`AgentView.vue`。
|
||||||
|
4. 为路由鉴权、配置回滚、Agent 版本兼容补测试。
|
||||||
|
5. 前端轮询统一抽 composable,加入互斥与页面可见性控制。
|
||||||
|
|
||||||
|
### P2:工程质量提升
|
||||||
|
|
||||||
|
1. 全仓执行一次 `gofmt -w`,随后在 CI 中开启 `gofmt -l` 卡口。
|
||||||
|
2. 清理旧 FRP 类型、旧 API 方法和未使用样式。
|
||||||
|
3. 收敛颜色令牌与重复弹窗外壳。
|
||||||
|
4. 增加前端 API 客户端测试与关键页面组件测试。
|
||||||
|
5. 为 SQLite 高频查询建立基准,确认单连接是否满足长期指标数据。
|
||||||
|
|
||||||
|
### P3:能力增强
|
||||||
|
|
||||||
|
1. 加密迁移包:导出并导入含敏感凭据的完整实例。
|
||||||
|
2. 审计日志:记录谁在何时修改了规则、服务端、证书和设置。
|
||||||
|
3. 2FA / TOTP 与登录设备管理。
|
||||||
|
4. SMART 磁盘健康与 UPS 状态。
|
||||||
|
5. Prometheus `/metrics` 导出。
|
||||||
|
6. 状态页事故记录与服务入口聚合页。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十五、关键源码索引
|
||||||
|
|
||||||
|
| 目标 | 文件 |
|
||||||
|
| --- | --- |
|
||||||
|
| 应用装配 | `internal/app/app.go` |
|
||||||
|
| 配置 | `internal/config/config.go` |
|
||||||
|
| 数据库迁移 | `internal/db/db.go`、`migrations/*.sql` |
|
||||||
|
| HTTP 路由 | `internal/api/router.go` |
|
||||||
|
| 认证中间件 | `internal/api/middleware.go` |
|
||||||
|
| 登录与会话 | `internal/auth/auth.go`、`internal/api/auth_handler.go` |
|
||||||
|
| 本机反代 | `internal/proxy/proxy.go`、`internal/service/nginx_config.go` |
|
||||||
|
| Nginx 生成与重载 | `internal/nginx/*.go` |
|
||||||
|
| DDNS | `internal/ddns/*.go` |
|
||||||
|
| 证书 | `internal/acme/*.go`、`internal/certificate/*.go` |
|
||||||
|
| FRP 主服务 | `internal/frp/service.go` |
|
||||||
|
| FRP 模型 | `internal/frp/model.go` |
|
||||||
|
| Agent 客户端 | `internal/frp/agent_client.go` |
|
||||||
|
| 公网 Agent | `internal/agent/server.go`、`internal/agent/versions.go` |
|
||||||
|
| 监控巡检 | `internal/monitor/monitor.go` |
|
||||||
|
| 通知 | `internal/notify/*.go` |
|
||||||
|
| 日志 | `internal/logstore/*.go` |
|
||||||
|
| 流量 | `internal/traffic/collector.go` |
|
||||||
|
| 指标 | `internal/metrics/*.go` |
|
||||||
|
| 备份 | `internal/backup/*.go` |
|
||||||
|
| 前端路由 | `web/src/router/index.ts` |
|
||||||
|
| 前端 API | `web/src/api/client.ts`、`web/src/api/types.ts` |
|
||||||
|
| 前端布局 | `web/src/layouts/AppLayout.vue` |
|
||||||
|
| CI | `.github/workflows/ci.yml` |
|
||||||
|
| 容器构建 | `Dockerfile`、`docker-compose*.yml` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十六、总结
|
||||||
|
|
||||||
|
Fonu 已经从一个 Nginx 反代面板发展为覆盖「本地入口、DNS、证书、内网穿透、公网 Agent、监控告警、备份恢复」的 NAS 公网访问控制面。
|
||||||
|
|
||||||
|
它的优势在于:功能完整、模块边界总体清楚、迁移与部署链路成熟、关键配置有版本和回滚、自动化任务覆盖主要运维场景。
|
||||||
|
|
||||||
|
当前最需要做的不是继续横向增加功能,而是先把 FRP / Agent 这条最复杂的链路拆小,把旧接口残留和日志泄密等明确问题清掉,再补上路由鉴权、配置回滚和 Agent 兼容测试。这样后续增加迁移包、审计、2FA、Prometheus 等能力时,才不会把复杂度继续堆进两个超大服务文件。
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
# Fonu 全项目审查报告
|
||||||
|
|
||||||
|
> 审查日期:2026-09-24 对象:`f:\Fonu-main\Fonu-1.0.2`(VERSION 1.2.6;Go 后端 `cmd/` + `internal/` 202 个 `.go`,前端 `web/src` 44 个源文件)
|
||||||
|
> 方法:三路并行静态审查(Go 后端 / 前端 / 工程与部署)+ 高信号 grep 统计。
|
||||||
|
> 可信度说明:§1.1、§5.1 的高危项已逐条读取原文核对;其余结论来自 grep 与源码阅读,未做运行时验证;行号为 2026-09-24 快照,实施时以标识符为准。
|
||||||
|
> 用途:把「哪里要改、哪里要补」按优先级摊开,供逐项实施与勾选。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 〇、结论速览
|
||||||
|
|
||||||
|
| 级别 | 条数 | 内容 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **P0 · 安全** | 4 | 备份恢复的任意文件写;agent 安装 frps 的 SSRF + 二进制无校验;SSH 不校验主机密钥;三处凭据明文回传/落盘 |
|
||||||
|
| **P0 · 用户可见缺陷** | 2 | `/frp-agent` 服务端详情弹窗状态点恒灰;仪表盘 DDNS/证书卡「红灰点 + 正常」文案矛盾 |
|
||||||
|
| **P1 · 正确性** | 6 | 安装失败仍报「已启动」;备份失败仍覆盖唯一副本;限流状态损坏静默清空;请求体解析错误被忽略等 |
|
||||||
|
| **P1 · 工程** | 5 | CI 只发版不校验;三套锁文件并存;版本号双源(本地构建显示 1.0.2);`fonu-agent.exe` 入库;前端零测试设施 |
|
||||||
|
| **P1 · 前端质量** | 4 | 死样式与重复样式块;231 处硬编码色值;轮询无 in-flight 互斥且只有仪表盘处理页面隐藏 |
|
||||||
|
| **P2 · 重构/增补** | 若干 | 5 套「安全写文件」、4 套 `.bak`、agent 与宿主机各写一份 nginx 模板;8 个包零测试;i18n 缺失 |
|
||||||
|
|
||||||
|
**已确认无问题、不必重复投入**:无命令注入(`exec.Command` 全为固定 argv);无路由漏挂鉴权(API 97 条受保护 + 4 条公开,agent 17 条全包在 `s.auth`);无无界 `io.ReadAll`(全配 `io.LimitReader`);无 ticker 泄漏(两处 `NewTicker` 都有 `defer Stop`);无 `console.log`/`debugger`;无 `v-html`;密钥落盘权限普遍 0600;前端定时器清理齐全。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、安全
|
||||||
|
|
||||||
|
### 1.1 P0 高危(已逐条复核原文)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 修法 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `internal/api/backup_handler.go:52-53` | `tmpPath := filepath.Join(os.TempDir(), header.Filename)` 后 `os.Create` —— multipart 文件名完全可控,`../../root/.ssh/authorized_keys` 之类会逃出 `/tmp` 并被截断写入(`filepath.Join` 会 Clean 掉 `..`,不构成防护) | 改 `os.CreateTemp("", "fonu-restore-*.zip")`,或用 `filepath.Base(header.Filename)` |
|
||||||
|
| `internal/agent/server.go:257-260` | `req.Proxy` 未做任何校验就被拼成 `proxy + "/" + downloadURL` 交给 VPS 下载,随后解压安装为 `frps` 可执行文件 → SSRF + 任意二进制安装;且整条路径**不做 SHA-256 校验**(对比 `internal/frp/runtime.go:203` 主程序下载是校验的) | `req.Proxy` 走 `internal/frp/runtime.go:320 downloadProxyOptions` 白名单;下载后校验 `sha256sums` |
|
||||||
|
| `internal/frp/ssh_installer.go:42` | `HostKeyCallback: ssh.InsecureIgnoreHostKey()` —— 接受任意主机密钥,MITM 可截获该 VPS 的 root SSH 凭据 | 用 `knownhosts` 校验;或首次连接回显指纹让用户确认后落库 |
|
||||||
|
| `internal/notify/store.go:400-404` + `internal/api/settings_handler.go:31,50` | `SanitizeSettings` 只删 SMTP 密码 / Webhook Secret / Telegram Token,而 `KeyZeroSSLAPIKey` 在可写白名单里且随 `GET /api/settings` 原样返回 → **ZeroSSL API Key 明文回传浏览器** | 把 `KeyZeroSSLAPIKey` 加入 `SanitizeSettings`,改用掩码(2026-09-24 实施:只回 `MaskedSecret`,未删键——`SettingsView` 与 `CertificatesView` 都用「值非空」判断已配置,删键会让「已配置」状态与 ZeroSSL 申请门槛失效) |
|
||||||
|
|
||||||
|
### 1.2 密钥与凭据(中危)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 修法 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `internal/frp/agent_installer.go:34` → `internal/frp/service.go:694-700` → `POST /api/frpmulti/servers/{id}/agent/probe` | Probe 用 `sed` 从 `/etc/fonu-agent/agent.env` 读出 `FONU_AGENT_TOKEN` 放进返回体,**明文回传前端**(库里同一字段走 `has_token` 掩码,`internal/frp/model.go:102`) | 只返回 `has_token` + 后 4 位 |
|
||||||
|
| `internal/agent/server.go:1243` | 自动生成的 Basic Auth 密码被写成 nginx 注释 `# Basic Auth: user=… password=…`,文件 0640 落在 VPS vhost 目录,且被 `GET /api/v1/routes/{domain}/conf` 原样返回 | 只在 `PUT /api/v1/routes` 响应里返回一次密码,不写注释 |
|
||||||
|
| `internal/auth/auth.go:57-62` | 首启生成的 admin 密码明文写入应用日志(`internal/app/app.go:54-55` 同时进 stdout 与 `app.log`),而 `GET /api/logs/system` 会把该文件内容返回 UI | 仅写 stderr,或改为环境变量 / 一次性文件下发初始密码 |
|
||||||
|
| `internal/agent/server.go:97,892` | 目录缓存文件用 `0o644` | 统一 `0o600` |
|
||||||
|
|
||||||
|
### 1.3 鉴权强度与会话
|
||||||
|
|
||||||
|
| 位置 | 级别 | 问题 | 修法 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `internal/api/auth_handler.go:52-70` | 中 | 登录失败只记录,**无限速/锁定**,可无限爆破 | 按 IP + 用户名做失败计数与退避(`notify` 里已有窗口统计可复用) |
|
||||||
|
| `internal/api/auth_handler.go:90-99` | 中 | 会话 cookie 缺 `Secure`,而应用默认明文 HTTP(`internal/app/app.go:122`) | 依 `X-Forwarded-Proto` 或配置项决定是否加 `Secure` |
|
||||||
|
| `internal/api/auth_handler.go:114` | 低 | `clearSessionCookie` 未设 `SameSite`(其余 cookie 是 Lax) | 补齐 `SameSite=Lax` |
|
||||||
|
| 全局 | 中 | 无 CSRF 令牌,状态变更仅靠 cookie | 校验 `Origin`/`Referer`,或加双提交令牌 |
|
||||||
|
| `internal/auth/auth.go:22,120-138` | 中 | 会话固定 7 天 TTL,改密码**不失效其他会话** | `ChangePassword` 时 `DELETE FROM sessions WHERE admin_id = ?` |
|
||||||
|
| `internal/agent/server.go:191` | 低 | token 比较用 `!=`(非常量时间) | `crypto/subtle.ConstantTimeCompare` |
|
||||||
|
| `internal/agent/server.go:114`、`cmd/fonu-agent/main.go:17` | 中 | agent 默认监听 `:7700` 且为明文 HTTP,Bearer token 在公网裸奔;`-token` 还会进 `ps` 输出 | 默认绑 `127.0.0.1`(Nginx 前置)或加 TLS |
|
||||||
|
| `internal/api/router.go:180` + `:42` | 低 | `SessionMiddleware` 被套两层,重复解析 cookie | 只在外层套一次 |
|
||||||
|
|
||||||
|
### 1.4 路径穿越(除 1.1 外)
|
||||||
|
|
||||||
|
| 位置 | 级别 | 问题 | 修法 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `internal/backup/service.go:88` | 中 | `strings.HasPrefix(cleaned, cleaned(dataDir))` 是**不带分隔符的前缀判断**:`DataDir=/data` 时条目 `../data-other/x` 清理后为 `/data-other/x`,前缀匹配通过 → 写到 DataDir 之外 | 用 `filepath.Rel` + 拒绝 `..`(`internal/acme/download.go:75-89 validateCertFilePath` 已是正确写法,直接复用) |
|
||||||
|
| `internal/backup/service.go:100` | 中 | `os.FileMode(header.Mode)` 未掩码,构造的归档可造出 0777 / 带执行位文件 | `& 0o777`,目录/文件强制 `0o750/0o640` |
|
||||||
|
| `internal/api/backup_handler.go:41` | 低 | 单请求 64MB 且该端点无频率限制 | 降到 16MB 并加限速 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、错误处理与正确性
|
||||||
|
|
||||||
|
| 位置 | 级别 | 问题 | 修法 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `internal/agent/server.go:318` | 高 | `_ = exec.Command("systemctl","enable","--now","frps").Run()` 后无条件 `notes = append(notes, "frps.service 已启动")` —— 启动失败仍报成功 | 判 `err`,失败写 stderr 到 `notes` 并置 `ok:false` |
|
||||||
|
| `internal/frp/service.go:1498` | 中 | `_ = os.WriteFile(path+".bak", current, 0o600)` 失败被忽略,紧接着 `os.Rename` 覆盖唯一副本,可能丢配置 | 备份失败即 return,不覆盖 |
|
||||||
|
| `internal/agent/server.go:1188` 附近 `readLimitState` | 中 | `_ = json.Unmarshal` 失败静默变空 map,`writeLimitState` 随后删掉 zone 文件 → 全部路由限流静默消失 | 反序列化失败返回 error 并保留原文件 |
|
||||||
|
| `internal/frp/service.go:775-779` | 中 | `InstallFRPSOnAgent` 里 `_ = client.PushFRPSConfig(...)` 忽略下发失败,前端只看到「安装成功」 | 把该错误并入返回结果 |
|
||||||
|
| `internal/api/cert_handler.go:47,144`、`ddns_handler.go:122`、`frpmulti_handler.go:609` | 中 | `_ = json.NewDecoder(r.Body).Decode(&req)` 忽略解析错误,继续用零值请求体 | `if err := ...; err != nil { 400 }` |
|
||||||
|
| `internal/backup/service.go:30`、`internal/api/backup_handler.go:53-71` | 中 | 写入方 `defer Close()` 不检查错误:ENOSPC 时半个备份被当有效文件下载;`out.Close()` 失败后仍去 Restore | 写入方改显式 `if err := f.Close(); err != nil` |
|
||||||
|
| `internal/api/logs_handler.go:85,112` | 低 | `_ = logstore.StreamFile(...)` 丢弃 SSE 写出错误 | `slog.Warn` 记录 |
|
||||||
|
| `internal/api/auth_handler.go:98` | 低 | `_ = h.auth.Logout(...)` 忽略删会话失败,客户端仍清 cookie | 失败返回 500 |
|
||||||
|
| `internal/traffic/collector.go:148,151` | 低 | `_ = c.persist(ctx)` 落库失败无痕迹 | 记 warn 并保留脏标记重试 |
|
||||||
|
| `internal/frp/agent_client.go:58` | 低 | `data, _ := io.ReadAll(...)` 忽略读错误,非 2xx 时错误体可能为空 | 判 err |
|
||||||
|
| `internal/frp/agent_installer.go:24-25` | 低 | `Probe` 把所有 SSH 失败吞成空串,与「VPS 上确实没有」无法区分 | 收集失败原因,结果里带 `warnings` |
|
||||||
|
| `internal/frp/ssh_installer.go:125-126` | 低 | `var _ io.Reader = (*os.File)(nil)` / `var _ = filepath.Separator` 是为保留导入的哑声明 | 删两行及两个导入 |
|
||||||
|
| `internal/agent/server.go:229,422` | 低 | `dashboardAddr` 注释自称已废弃,仍被 `status` 调用,每次读一遍 frps.toml | 删函数与调用 |
|
||||||
|
|
||||||
|
**已核查无问题**:`err` 遮蔽(`service.go:1494` 用 `readErr` 是有意为之);无命令注入(22 处 `exec.Command` 全固定 argv;SSH 侧唯一插值点 `uploadBytes:95` 经 `shellPath:123` 单引号转义,传入常量 `/tmp/fonu-agent`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、并发与生命周期
|
||||||
|
|
||||||
|
| 位置 | 级别 | 问题 | 修法 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `internal/frp/service.go:166,170-172` | 中 | `go s.runDownload(version)` 用 `context.Background()` + 10 分钟超时,不随 App 关闭取消,`internal/app/app.go:196-215 Shutdown` 也不等待;任务状态只在内存,重启即丢 | 传 App ctx,`sync.WaitGroup` 在 Shutdown 等待 |
|
||||||
|
| `internal/scheduler/scheduler.go:31,37` | 中 | `job.Run(ctx)` 无 `recover()`,任一 tick panic 会崩掉整个进程;`time.NewTicker(job.Interval)` 在 Interval ≤ 0 时 panic,目前只靠 `internal/app/app.go:135-143` 兜住 | 每个 job 包 `defer recover()`;`New`/`Start` 跳过非正间隔 |
|
||||||
|
| `internal/traffic/collector.go:176-198` | 中 | `ingestLine` 持 `c.mu` 写锁时调 `notify.RecordAccessHit`(DB 写 + 通知判断),**锁内做 I/O**,阻塞 `/api/proxies/traffic`、`/clients` 读 | 锁内只累积 IP,锁外统一 notify |
|
||||||
|
| `internal/frp/service.go:1258` | 中 | `agentClient` 每次调用都 `NewAgentClient` → 新建 `http.Client`/`Transport`,批量同步 N 条路由就建 N 个连接池 | 按 serverID 缓存 client |
|
||||||
|
| `internal/agent/server.go:107 preloadNginxConfDir` | 低 | 启动 `go func()` 一次性任务无 ctx,退出时可能正在跑 `nginx -T` | 加 ctx 或 WaitGroup |
|
||||||
|
| `internal/logstore/stream.go:65-70` | 低 | 每个 SSE 客户端一个 goroutine + 500ms 轮询(ctx 检查正确、无泄漏) | 可接受;量级上去后换 fsnotify |
|
||||||
|
|
||||||
|
**已核查无问题**:全部 `http.Client` 构造点都带 `Timeout`;两处 `time.NewTicker` 都有 `defer Stop()`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、重复逻辑(可收敛点)
|
||||||
|
|
||||||
|
| 重复项 | 证据 | 建议 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **「安全写文件」5 套实现** | `internal/agent/server.go:1428 atomicWrite`、`internal/frp/service.go:1816 writeConfigAtomically`、`internal/nginx/nginx.go:114-141`(固定 `.tmp` 名,0644,且同一段在 `:194`、`:394` 再抄两遍)、`internal/chinacidr/service.go:99-117`、`internal/frp/service.go:1488-1501` | 抽 `internal/fsutil.WriteFileAtomic(path, data, mode)`;`nginx.go` 三处合并为 `applyConfigFile` |
|
||||||
|
| **非原子直写** | `internal/nginx/custom.go:98,144,151,158`、`internal/nginx/errors.go:32,78` | 同上 |
|
||||||
|
| **`.bak` 备份 4 套** | `internal/frp/service.go:1496-1498`、`:1821-1825`、`internal/nginx/nginx.go:132-136`、`internal/nginx/custom.go:190-197` | 统一 `fsutil.Backup(path)` |
|
||||||
|
| **nginx 模板双份** | `internal/agent/server.go`(`nginxRoute:1155`、`sslDirectives:1420`、`zoneKey:1390`、限流/geo 生成)与 `internal/nginx/generate.go`(有 30 个测试文件覆盖)各写一遍 vhost/TLS/限流模板 | 抽到共享包,agent 与宿主机同源;否则改一处必须记得改两处 |
|
||||||
|
| **agent HTTP 客户端双份** | `internal/frp/agent_client.go` 与 `internal/frp/agent_insights.go`、`internal/agent/insights.go` 对同一批端点各自解包 | insights 也走 `AgentClient.request` |
|
||||||
|
| **规则自定义配置两处入口** | `internal/service/nginx_config.go` 与 `internal/nginx/custom.go` | 只保留 `internal/nginx/custom.go` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、前端
|
||||||
|
|
||||||
|
### 5.1 用户可见缺陷(已复核)
|
||||||
|
|
||||||
|
| 位置 | 级别 | 问题 | 修法 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `web/src/views/AgentView.vue:582` | **P0** | `<StatusBadge :value="serverStartupType(viewingServer)" :text="serverStartupText(viewingServer)" />` —— `serverStartupType()`(`:1382-1387`)返回的是 `'success'\|'warning'\|'error'\|'default'`(`n-tag` 的 type 值),却被当状态值传给 `:value`;`statusKind('success')` 与 `statusKind('default')` 都落到 unknown → **服务端详情弹窗「运行中」和未知态都是灰点**(`FrpView.vue:777` 注释记录过同一个坑,那边修了、这边没修) | 改 `:kind="serverStartupType(viewingServer)"`,函数标注返回 `StatusKind` 并把 `'default'` 换成 `'unknown'`;`:49` 的 `n-tag :type` 另拆 `serverStartupTagType()` |
|
||||||
|
| `web/src/views/DashboardView.vue:35,46` | **P1** | `:value="status?.ddns_status"` / `:value="status?.certificate_status"` 配静态 `text="正常"` —— 后端返回 `error`/`none` 时出现「红/灰点 + 正常」自相矛盾 | `text` 改由 `statusLabel(value)` 推导 |
|
||||||
|
| `DdnsView.vue:170,213`、`ProxyView.vue:139`、`DashboardView.vue:22,59,70,162,238,263` | P2 | 目前值恰好是 `statusKind()` 的合法输入,但语义错,后端一改值就静默变灰 | 统一改 `:kind` |
|
||||||
|
| `web/src/views/ProxyRemoteView.vue:489-514` | P1 | `refreshAll` 里 7 个独立接口用 `await track(...)` 串行发,每次刷新耗时是 7 个往返相加;15 秒轮询时同样串行 | 改 `Promise.all`(`track` 已把异常收进 `failures`,并行不影响「单点失败不中断整轮」);健康检查可拉长周期或只在手动刷新时跑 |
|
||||||
|
|
||||||
|
### 5.2 死代码与重复样式(重构遗留)
|
||||||
|
|
||||||
|
| 位置 | 级别 | 问题 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `web/src/views/FrpView.vue:1229-1236`、`:1396-1398`、`:1468-1474`、`:1487`、`:1524` | P1 | `frp-server-table`、`frp-detail-card`、`server-detail*`、`detail-stats`、`server-status-summary`、`status-bars` 等类名在模板与脚本 **0 引用**(活体已迁到 `AgentView.vue:578-610` / `1818-1844`),整块可删 |
|
||||||
|
| `web/src/views/AgentView.vue:1853-1855` | P1 | `.detail-subgroup`/`__title`/`.detail-muted` 与 `:1850-1852` **逐字重复两遍**,删后一组 |
|
||||||
|
| 弹窗外壳 `.proxy-modal__*` | P1 | 在 `FrpView.vue`(`.frp-proxy-modal` 与 `.frp-guide-modal` 各一份)、`ProxyRemoteView.vue:942-958`、`AgentView.vue`、`ProxyView.vue` **四份**,差异仅 1–2 个属性 | 抽 `ProxyModalShell.vue`(header/tabbar/scroll/help 四插槽)或非 scoped 的 `styles/proxy-modal.css` |
|
||||||
|
| 左列表骨架 | P1 | `DdnsView`(`.ddns-layout`/`.task-sidebar`/`.task-item`)与 `AgentView`/`FrpView`/`ProxyRemoteView`(`.server-sidebar`/`.server-item`)两套命名同构重复;`.server-item__name`/`__endpoint` 三条规则在三个文件逐字相同 | 抽 `SidebarList.vue`(items + activeKey + 行插槽) |
|
||||||
|
|
||||||
|
> 说明:`FrpView.vue` 里 `.server-sidebar` / `.server-item`、`.frp-proxy-modal`、`.frp-guide-modal` **仍在模板中使用,不是死代码**,不要一起删。
|
||||||
|
|
||||||
|
### 5.3 硬编码颜色(违反「零硬编码色值」约定)
|
||||||
|
|
||||||
|
grep 计 `#hex`/`rgb()`/`rgba()` 共 267 处 / 19 文件;扣除 `styles/variables.css`(36 处,令牌定义本体)后 231 处。按文件:`useTheme.ts` 45、`CertificatesView.vue` 44、`ProxyView.vue` 34、`ProxyAccessLogBox.vue` 22、`DdnsView.vue` 18、`DashboardView.vue` 15、`MiniTrafficChart.vue` 11、`MiniBarChart.vue` 9、`SettingsView.vue` 8、`StatCard.vue` 6、`MiniDonutChart.vue` 5、`LogsView.vue` 4、`AgentView.vue` 3、`StatusBadge.vue` 3,其余各 1。
|
||||||
|
|
||||||
|
| 优先级 | 位置 | 问题 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| P1 | `DashboardView.vue:1124-1131` | 横幅写死 `#f4fffc/#f1fbff/#eef9ff/#0f172a/#134e4a` 与 `rgba(16,185,129,…)`,而 `variables.css:17,58` 已有 `--fonu-banner-bg`/`--fonu-banner-border` |
|
||||||
|
| P1 | `CertificatesView.vue:1256-1272`、`DdnsView.vue:1263-1279`、`ProxyView.vue:2225-2228` | 同一组状态色 `#3b82f6/#10b981/#f59e0b/#ef4444` 复制三处 | 收敛到 `StatCard` 的 tone 类或 `--fonu-tone-*` 令牌 |
|
||||||
|
| P1 | `CertificatesView.vue:1182-1211`、`ProxyAccessLogBox.vue:149-150` | 同一暗色终端色板写两遍 | 抽 `--fonu-log-*` |
|
||||||
|
| P1 | `StatusBadge.vue:43,48,53`、`StatCard.vue:84-95` | 组件内 `rgba(16,185,129,.12)` 绕过 `variables.css:4` 已有的 `--fonu-brand-soft` | 改用令牌并补 warning/error 的 soft 令牌 |
|
||||||
|
| P2 | `ProxyView.vue`、`DdnsView.vue`、`CertificatesView.vue`、`SettingsView.vue:1251-1282,1601-1613`、`LogsView.vue:854-898` 等 | 零散违规,含 `color-mix(in srgb, #10b981 40%, …)` 与 `var(--n-error-color, #e11d48)` 直接引用 Naive UI 变量 | 逐条换语义令牌 |
|
||||||
|
| P2 | `MiniBarChart`/`MiniDonutChart`/`MiniTrafficChart`/`useTheme.ts` | 图表色板三份重复拷贝,且 `AgentView.vue:1257` 用 `#18a058` 与品牌绿不一致 | `useTheme.ts` 导出 `CHART_COLORS` 供各图表复用 |
|
||||||
|
|
||||||
|
**有意保留**:`FrpView.vue:1385`、`AgentView.vue:1808` 的 `color: #fff`(品牌底色上的文字);ECharts 系列色(按既有约定保留,但建议收敛为常量)。
|
||||||
|
|
||||||
|
### 5.4 类型
|
||||||
|
|
||||||
|
- `api/client.ts:288,331` 的 `Record<string, any>` 是全仓仅有的 2 处 `any` → 定义 `FrpAgentProbeResult`。
|
||||||
|
- `FrpView.vue:778` 用 `as 'success' | 'disabled' | 'warning'` 硬转 → 改 `: StatusKind`。
|
||||||
|
- `DdnsView.vue:669-673` 的 `taskStatusKind()` 名为 Kind 实为状态值,与 `StatusBadge` 的 `kind` 语义相反 → 改名 `taskStatusValue`。
|
||||||
|
- `AgentView.vue:1382-1387` 的 `serverStartupType()` 无返回类型注解,是 §5.1 那条 P0 的根因。
|
||||||
|
|
||||||
|
### 5.5 轮询与页面可见性
|
||||||
|
|
||||||
|
- 清理齐全(9 处 `clearInterval` 都在 `onUnmounted`/`onBeforeUnmount`)。
|
||||||
|
- **只有仪表盘处理了页面隐藏**:`DashboardView.vue:846-856` 只停 traffic(2s)/status(15s),同文件的 `cidrPollTimer`(`:946-974`)与 `loadRouteHealth` 不在暂停范围;`ProxyView.vue:1799,1825`、`FrpView.vue:1207,914`、`LogsView.vue:782`、`ProxyRemoteView.vue:865` 完全没有隐藏页处理 → 抽 `useVisibilityPolling()`。
|
||||||
|
- **轮询无 in-flight 互斥**:`DashboardView.vue:808-812` 每 2s 调 `refreshTraffic()`,体内只有 try/catch,慢链路会堆积并发请求 → 加 `if (loading.value) return` 或改递归 `setTimeout`。
|
||||||
|
- 频率偏高:2s 类(Dashboard/ProxyView)建议降到 5s 或按激活状态降频。
|
||||||
|
|
||||||
|
### 5.6 超大组件
|
||||||
|
|
||||||
|
`ProxyView.vue` 3480、`AgentView.vue` 1966、`CertificatesView.vue` 1875、`DashboardView.vue` 1758、`DdnsView.vue` 1745、`SettingsView.vue` 1672、`FrpView.vue` 1557。
|
||||||
|
|
||||||
|
- `ProxyView.vue` 优先拆 `RuleFormModal.vue`、`NginxSettingsPanel.vue`、`RuleClientsPanel.vue`。
|
||||||
|
- `AgentView.vue`:模板 `440-559` → `ServerFormModal.vue`;`561-577` → `ServerLogsModal.vue`;`578-640` → `ServerDetailModal.vue` + `LatencyChart.vue`。
|
||||||
|
- 搬迁硬约束:**scoped 样式不随模板走,必须整份复制**(`AgentView.vue:1850-1855` 的重复块就是漏搬/重搬的产物)。
|
||||||
|
|
||||||
|
### 5.7 空态 / 可访问性 / i18n
|
||||||
|
|
||||||
|
- `v-html` 0 处;空态覆盖良好(`EmptyState`/`LoadError` 各页 2–11 处);未发现纯图标按钮缺 `aria-label`。
|
||||||
|
- 缺口:`AgentView.vue:609`(代理表)与 `:610`(连接日志)用裸文本兜底,未走 `EmptyState`;`DashboardView.vue:244` 的 `health-item__icon--${tone}` 仅靠颜色传达状态,建议补文字或 `aria-label`。
|
||||||
|
- i18n:无 i18n 设施,用户可见文案全部中文硬编码(含中文的行 1882 行 / 17 文件,含注释故为上限;最多 `AgentView` 388、`FrpView` 352、`ProxyView` 227)。建议先建 `src/locales/zh-CN.ts` 按 views 命名空间搬迁,或至少把高频重复串提为常量(样板:`utils/status.ts:3-11` 的 `LABELS`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、测试缺口
|
||||||
|
|
||||||
|
**Go:71 个 `*_test.go`,但 8 个包零测试** —— `internal/app`、`internal/service`、`internal/scheduler`、`internal/settings`、`internal/backup`、`internal/secret`、`internal/discovery`、`internal/version`,以及 `cmd/fonu`、`cmd/fonu-agent`。
|
||||||
|
|
||||||
|
关键文件零覆盖(按风险排序):
|
||||||
|
|
||||||
|
1. `internal/agent/server.go`(1465 行,VPS 侧所有文件写入/nginx 操作/exec)—— 包内仅 `apr1_test.go`、`insights_test.go`;`validateRoute`、`nginxRoute`、`atomicWrite`、`detectNginxConfDir`、`installFRPS` 全无测试。
|
||||||
|
2. `internal/api/router.go` + `middleware.go` —— 没有任何测试断言「每条路由都在 `RequireAuth` 之后」,新增路由漏 `protect()` 不会有信号。
|
||||||
|
3. `internal/backup/service.go` —— §1.4 的路径校验 medium 无测试;`backup_handler.go` 的临时文件命名也无测试。
|
||||||
|
4. `internal/frp/service.go`(1847 行)—— 仅 `service_diagnostics_test.go` 间接触及。
|
||||||
|
5. `internal/frp/ssh_installer.go`、`agent_installer.go` —— `extractToken`、`isELFBinary`、`shellPath`(含单引号转义)都是纯函数,测试成本极低。
|
||||||
|
|
||||||
|
建议补的最小集:① 路由鉴权覆盖测试(遍历 `NewRouter`,非公开路径断言 401);② `backup.Restore` 的 `../` 与 `../data-other/` 两组用例;③ `agent.nginxRoute` + `validateRoute` 表驱动测试;④ `shellPath`/`extractToken`/`isELFBinary` 纯函数测试;⑤ `scheduler` panic 恢复测试;⑥ `internal/notify` 补「SMTP 密文损坏时 Telegram 仍可发送」。
|
||||||
|
|
||||||
|
**前端:完全没有测试设施** —— `web/package.json` 无 `test` 脚本,devDependencies 无 vitest/jest/@vue/test-utils,`web/src` 无 `*.spec.ts`/`*.test.ts`;唯一质量门是 `npm run build` 内部的 `vue-tsc --noEmit`。建议加 vitest 骨架,从 `src/api` 与格式化纯函数起步。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、工程化 / CI / Docker / 版本
|
||||||
|
|
||||||
|
| 位置 | 级别 | 问题 | 修法 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `.github/workflows/`(`docker-release.yml` 106 行、`github-release.yml` 52 行) | **高** | CI 存在但**只发版不校验**:仅 tag / `workflow_dispatch` 触发,零 `go build`/`vet`/`test`、零 `vue-tsc`/`vite build` | 新增 `.github/workflows/ci.yml`,`on: [push, pull_request]` |
|
||||||
|
| 同上 + `cmd/fonu/main.go:19` | **高** | 步骤顺序不可颠倒:`//go:embed all:web/dist`,而 `web/dist/` 被 `.gitignore` 忽略 → 干净检出下 `go build ./...` 直接失败。CI 必须「前端构建 → 复制 dist 到 `cmd/fonu/web/dist` → `go vet` → `go test ./... -count=1` → `go build`」 | 按此顺序写 `ci.yml` |
|
||||||
|
| `web/package-lock.json` + `pnpm-lock.yaml` + `pnpm-workspace.yaml` | **高** | 三套依赖描述并存,`package.json` 无 `packageManager`,全仓无 pnpm 调用,Dockerfile 用 `npm ci` | 删 `pnpm-lock.yaml` 与 `pnpm-workspace.yaml`,加 `"packageManager": "npm@10"`(2026-09-24 实施:仓库里实际只有 `web/pnpm-workspace.yaml`,无 `pnpm-lock.yaml`;已删该文件并声明 `npm@10.9.4`) |
|
||||||
|
| `VERSION`(1.2.6) vs `web/package.json:4`(1.0.2) vs `web/vite.config.ts:10` | **高** | 版本号双源:Docker 链正确注入 `VERSION`,但本地 `npm run dev/build` 回落 `pkg.version` → UI(`AppLayout.vue:140`)显示 1.0.2 | 同步 `package.json` 版本,或让 `vite.config.ts` 直接读 `../VERSION`;CI 加一致性校验 |
|
||||||
|
| `web/package-lock.json`(如 `:1360`) | 中 | `resolved` 指向 `registry.npmmirror.com`,海外 runner 会从镜像拉包 | 加 `web/.npmrc` 或 CI 用 `npm ci --registry=https://registry.npmjs.org` |
|
||||||
|
| `fonu-agent.exe`(项目根) | 高 | 未被 `.gitignore` 覆盖(规则里没有 `*.exe`) | 加 `*.exe` 或精确到 `/fonu-agent.exe` |
|
||||||
|
| `internal/frp/agentbin/*` | 中 | 是 `//go:embed` 编译必需资源(须入库),但无二进制标记 | `.gitattributes` 加 `internal/frp/agentbin/* binary` |
|
||||||
|
| `docker-release.yml` 的 `build-args: VERSION=…` | 中 | 死配置:Dockerfile 只有 `ARG VERSION_FILE`,从不读 `ARG VERSION`,tag 与文件不一致时静默 | workflow 加一步校验 tag == `VERSION` 文件,或 Dockerfile 支持 `ARG VERSION` 优先 |
|
||||||
|
| `.dockerignore:15` | 中 | ` docs` 带前导空格;`:12-13` 仍排除已不存在的 `internal/frp-1.0.2`、`FrpView-1.0.2.vue` | 去掉空格与两行过期排除项 |
|
||||||
|
| `Dockerfile:21` | 低 | `COPY web/package.json web/package-lock.json* ./` 通配符会在锁文件缺失时静默退化 | 去掉 `*` |
|
||||||
|
| `Dockerfile:53` | 低 | `COPY web/assets/image` 与 `internal/nginx/error_assets_embed.go` 的 embed 可能重复 | 确认运行时用途,若已被 embed 覆盖则移除 |
|
||||||
|
| `README.md:156`、`docs/DEPLOYMENT.md:42,53`、`internal/frp/frps_template.go:45` | 中 | 文档称「镜像内置 frpc 0.71.0」,而 `Dockerfile:46` 明确「frpc 不预装」 | 三处改为「按需下载」 |
|
||||||
|
|
||||||
|
**已确认正确**:镜像链路自洽(`npm ci` → `vite build` → dist 拷入 `cmd/fonu/web/dist` 正好匹配 embed;`web/assets/image` 与 `cmd/fonu/web/dist` 均真实存在,COPY 不会失败);`.data/`、`*.db` 已被忽略。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、文档与仓库卫生
|
||||||
|
|
||||||
|
| 位置 | 级别 | 问题 | 修法 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| git 根 `f:\Fonu-main` | **高** | 仓库根在此且 `No commits yet on main`,`Fonu-1.0.2/` 整目录未跟踪,根无 `.gitignore`;源码与文档全在版本控制之外 | 明确仓库边界:在 `Fonu-1.0.2/` 内独立 `git init`,或根补 `.gitignore` 并首次提交 |
|
||||||
|
| `f:\Fonu-main` 根目录 | **高** | 散落 `fonu-1.1.0-image.tar.gz`、`fonu-1.1.0-linux-amd64.tar.gz`、`fonu-1.1.0-linux-amd64`、`fonu-1.1.0-src.zip`、`agent-tsc.log`、`frps-m1-tsc.log`、`fonu-image-build4.log` | 移出仓库或删除;根 `.gitignore` 加 `*.tar.gz`/`*.zip`/`*.log`/`fonu-*` |
|
||||||
|
| `.breezell/`、`.gocache-agent/` | 中 | 未被 gitignore(`.dockerignore:20` 只挡了前者进镜像) | 根 `.gitignore` 加两行 |
|
||||||
|
| `internal/frp-1.0.2/`、`web/src/views/FrpView-1.0.2.vue` | 中 | 工作树已删除但**尚未提交**(`git status` 显示为未暂存删除),误 `git checkout` 即回滚清理 | 随下次提交一并提交,并在 CHANGELOG 记一条「移除 1.0.2 归档副本」 |
|
||||||
|
| `Fonu-1.0.2/docs/`(14 份 md) | 中 | 无索引;`upstream-sync-plan.md` 对 `/frp` 现状的描述基于 2026-09-23(已被 v1.2.6 重构推翻);`agent-view-design.md` / `-v2.md` 的「顶部下拉」结构已过期;`agent-capability-plan.md` 的 P0/P1/P2 已实施但仍以「方案」语气;`frps管理方案.md` 疑似被 `frps服务端管理方案.md` 取代 | 加 `docs/README.md` 索引表(文件 / 主题 / 状态 / 更新日期);给三份文档加时效或实施状态标注 |
|
||||||
|
| `f:\Fonu-main\docs\` 与 `Fonu-1.0.2\docs\` | 中 | 两套文档根并存(根下 `111.md`、`FRP迁移方案-0.1.25到1.0.2.md`、`FRP版本对比-1.0.2-vs-0.1.25.md`) | 合并,或声明根 docs 为临时区 |
|
||||||
|
| 仓库根 | 低 | 无 README(`Fonu-1.0.2/README.md` 本身 189 行、质量良好) | 上移或放最短指路 README |
|
||||||
|
| `gofmt` | 低 | `internal/api/auth_handler.go:29-32`、`internal/api/settings_handler.go:44-79`、`internal/traffic/collector.go:18-24,31-38` 从列对齐看确定未格式化(**待 `gofmt -l internal cmd` 确认全量**) | 跑 `gofmt -w` 并在 CI 加卡口 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、增补清单(按优先级,可勾选)
|
||||||
|
|
||||||
|
**P0(先做)**
|
||||||
|
|
||||||
|
> **实施状态(2026-09-24)**:以下 8 项已全部完成并随 v1.2.7 提交;agent 侧同时把 `AgentVersion` 递增到 0.12.0 并重编了内嵌双架构二进制(VPS 上需点「升级 Agent」才生效)。两处与本文写法不同的取舍见 §1.1 末行与 §七 pnpm 行。
|
||||||
|
|
||||||
|
- [ ] `internal/api/backup_handler.go:52` 改 `os.CreateTemp`;`internal/backup/service.go:88` 改 `filepath.Rel` 校验;`:100` 掩码 mode
|
||||||
|
- [ ] `internal/agent/server.go:258` 的 `req.Proxy` 走白名单 + 下载后校验 SHA-256
|
||||||
|
- [ ] `internal/frp/ssh_installer.go:42` 换 `knownhosts`(或指纹确认)
|
||||||
|
- [ ] `internal/notify/store.go:400` 增删 `KeyZeroSSLAPIKey`;`agent_installer.go:34` 只回 `has_token`;`agent/server.go:1243` 不写密码注释
|
||||||
|
- [ ] `web/src/views/AgentView.vue:582` 改 `:kind` + 返回类型标注;`DashboardView.vue:35,46` 文案随状态推导
|
||||||
|
- [ ] `.github/workflows/ci.yml`(前端构建 → dist 同步 → vet → test → build)
|
||||||
|
- [ ] 删 `web/pnpm-lock.yaml` + `pnpm-workspace.yaml`;`web/package.json` 加 `packageManager` 并与 `VERSION` 对齐
|
||||||
|
- [ ] 根目录 `.gitignore`(含 `*.exe`、`.breezell/`、`.gocache-agent/`)并清理散落产物
|
||||||
|
|
||||||
|
**P1**
|
||||||
|
|
||||||
|
- [ ] `internal/agent/server.go:318` 假成功、`internal/frp/service.go:1498` 备份失败仍覆盖、`:775-779` 忽略下发失败
|
||||||
|
- [ ] 4 处请求体解析忽略错误改 400(`cert_handler.go:47,144`、`ddns_handler.go:122`、`frpmulti_handler.go:609`)
|
||||||
|
- [ ] 两个 `http.Server` 补 `ReadTimeout`/`IdleTimeout`;`runDownload` 挂 App ctx;`scheduler` 加 `recover()`
|
||||||
|
- [ ] 登录失败退避 + 改密失效旧会话 + cookie `Secure`/`SameSite`
|
||||||
|
- [ ] `ProxyRemoteView.vue:489-514` 改 `Promise.all`
|
||||||
|
- [ ] 清 `FrpView.vue` 四组死样式 + `AgentView.vue:1853-1855` 重复块(即 merge 方案的步 4)
|
||||||
|
- [ ] 抽 `ProxyModalShell.vue` / `SidebarList.vue`;抽 `useVisibilityPolling()`;轮询加 in-flight 互斥
|
||||||
|
- [ ] 修 README/DEPLOYMENT/`frps_template.go:45` 的「内置 frpc」说法;删 `.dockerignore:12-13,15` 问题行
|
||||||
|
- [ ] `docs/README.md` 索引 + 三份文档时效标注
|
||||||
|
|
||||||
|
**P2**
|
||||||
|
|
||||||
|
- [ ] 抽 `internal/fsutil`(原子写 + 备份),合并 5 套写文件 / 4 套 `.bak`
|
||||||
|
- [ ] nginx 模板抽共享包,agent 与宿主机同源
|
||||||
|
- [ ] `traffic/collector.go:176` 锁外 notify;`frp/service.go:1258` 缓存 agent client
|
||||||
|
- [ ] 补测试:路由鉴权覆盖、`backup.Restore` 路径、`agent.nginxRoute`、纯函数组、`scheduler` panic、notify 隔离
|
||||||
|
- [ ] 前端加 vitest;颜色令牌收敛(231 处);i18n 起步
|
||||||
|
- [ ] 拆 `ProxyView.vue`(3480 行)等超大组件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、需用户决策
|
||||||
|
|
||||||
|
1. **仓库边界**:`f:\Fonu-main`(根,未跟踪全部源码)还是在 `Fonu-1.0.2/` 内独立 `git init`?这决定 `.gitignore` 与 README 放哪。
|
||||||
|
2. **包管理器**:确定 npm(删 pnpm 两个文件)还是保留 pnpm(需同步改 Dockerfile/README)。
|
||||||
|
3. **SSH 主机密钥策略**:`knownhosts` 落库(严格、需用户首次确认指纹)还是仅做指纹变更告警(宽松)。
|
||||||
|
4. **agent 监听地址**:改绑 `127.0.0.1` 需确认 VPS 上 Nginx/SSH 隧道的前置方式,属于会改变部署方式的改动。
|
||||||
|
5. **版本号粒度**:v1.2.4/1.2.5/1.2.6 同日三段,是否改为「同日合并一段」。
|
||||||
|
6. **前端 i18n**:是否要真正引入(工作量大),还是只做文案常量集中化。
|
||||||
|
|
||||||
|
## 十一、需真机验证(静态审查无法覆盖)
|
||||||
|
|
||||||
|
- agent 升到 0.11.0 后:VPS 资源数值随负载变化、公网 Nginx 日志、frps.toml 手工改动比对、VPS 证书清单、故意写错配置后点「重载 Nginx」看失败原因。
|
||||||
|
- `/frp-agent`、`/frp`、`/public-proxy` 三页新分栏结构的实际渲染(本地登录墙无凭据,未做视觉验证)。
|
||||||
|
- `bin\fonu.exe` 需重建并重启才能看到本轮前端改动。
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
# Fonu Web 一键升级方案
|
||||||
|
|
||||||
|
> 实施版本:v1.3.0
|
||||||
|
> 适用范围:`docker-compose.yml`、`docker-compose.hub.yml`
|
||||||
|
> 不适用范围:`docker-compose.host.yml`、原生 systemd 安装、Windows 直接运行
|
||||||
|
|
||||||
|
## 1. 设计目标
|
||||||
|
|
||||||
|
在管理后台检测到新版本后,用户点击「一键升级」即可完成:
|
||||||
|
|
||||||
|
1. 读取远端最新版本;
|
||||||
|
2. 构建新的 `fonu` 镜像;
|
||||||
|
3. 只重建 `fonu` 容器;
|
||||||
|
4. 等待 `/api/version` 返回目标版本;
|
||||||
|
5. 失败时尝试回滚到升级前的镜像 tag;
|
||||||
|
6. 把升级过程和结果返回给前端。
|
||||||
|
|
||||||
|
## 2. 安全边界
|
||||||
|
|
||||||
|
主程序容器**不挂载** `/var/run/docker.sock`。
|
||||||
|
|
||||||
|
只有 `fonu-updater` sidecar 持有:
|
||||||
|
|
||||||
|
- Docker Socket;
|
||||||
|
- Docker CLI 与 Compose;
|
||||||
|
- 项目目录;
|
||||||
|
- Git。
|
||||||
|
|
||||||
|
主程序与 sidecar 通过一个共享 Unix Socket 通信:
|
||||||
|
|
||||||
|
```text
|
||||||
|
浏览器
|
||||||
|
→ fonu 后端 /api/system/update/*
|
||||||
|
→ /run/fonu-updater/updater.sock
|
||||||
|
→ fonu-updater
|
||||||
|
→ docker compose build/up
|
||||||
|
```
|
||||||
|
|
||||||
|
这表示:
|
||||||
|
|
||||||
|
- 管理后台被攻破时,攻击者仍需通过 sidecar 的固定接口才能触发升级;
|
||||||
|
- sidecar 只接受固定的 Compose 文件名和固定的 `fonu` 服务名;
|
||||||
|
- 不提供任意命令执行接口;
|
||||||
|
- 不支持自定义 shell 命令。
|
||||||
|
|
||||||
|
## 3. 两种升级模式
|
||||||
|
|
||||||
|
| Compose 文件 | 模式 | 行为 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `docker-compose.yml` | `local` | 在项目目录检查 Git 工作区,干净时执行 `git pull --ff-only`,再构建 |
|
||||||
|
| `docker-compose.hub.yml` | `git` | Compose 的 build context 直接从 Git 仓库拉取,updater 只负责构建与重建 |
|
||||||
|
|
||||||
|
两种模式都会:
|
||||||
|
|
||||||
|
- 读取目标版本;
|
||||||
|
- 构建 `fonu` 镜像;
|
||||||
|
- 执行 `docker compose up -d --no-deps --force-recreate fonu`;
|
||||||
|
- 轮询 `/api/version` 验证升级结果。
|
||||||
|
|
||||||
|
## 4. 首次部署
|
||||||
|
|
||||||
|
一键升级依赖 `fonu-updater` 已经运行。
|
||||||
|
|
||||||
|
旧版本升级到 v1.3.0 时,需要先手动执行一次:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 本地源码部署
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 单文件 Git 构建部署
|
||||||
|
docker compose -f docker-compose.hub.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
完成这一轮后,后续版本即可在 Web 页面一键升级。
|
||||||
|
|
||||||
|
## 5. 环境变量
|
||||||
|
|
||||||
|
### 主程序
|
||||||
|
|
||||||
|
| 变量 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `FONU_UPDATER_SOCKET` | `/run/fonu-updater/updater.sock` | 与 sidecar 通信的 Unix Socket |
|
||||||
|
|
||||||
|
### updater sidecar
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `FONU_UPDATER_SOCKET` | sidecar 监听的 Unix Socket |
|
||||||
|
| `FONU_UPDATE_PROJECT_DIR` | Compose 项目目录,容器内为 `/workspace` |
|
||||||
|
| `FONU_UPDATE_COMPOSE_FILE` | 只允许 `docker-compose.yml`、`docker-compose.hub.yml`、`docker-compose.host.yml` |
|
||||||
|
| `FONU_UPDATE_SERVICE` | 要重建的服务,默认 `fonu` |
|
||||||
|
| `FONU_UPDATE_MODE` | `local` 或 `git` |
|
||||||
|
| `FONU_UPDATE_REPO` | `git` 模式下的仓库地址 |
|
||||||
|
| `FONU_UPDATE_BRANCH` | `git` 模式下的分支,默认 `main` |
|
||||||
|
| `FONU_UPDATE_APP_URL` | 健康检查地址,默认 `http://fonu:6893` |
|
||||||
|
| `DOCKER_SOCK` | 宿主机 Docker Socket,默认 `/var/run/docker.sock` |
|
||||||
|
| `FONU_UPDATE_HOST_DIR` | 宿主机上 Compose 文件所在目录;留空时自动从 Compose 容器标签发现,设置后作为显式覆盖 |
|
||||||
|
|
||||||
|
## 6. 前端行为
|
||||||
|
|
||||||
|
侧栏版本区在满足以下条件时显示「一键升级」:
|
||||||
|
|
||||||
|
- sidecar 可用;
|
||||||
|
- 远端版本高于当前版本;
|
||||||
|
- 当前没有正在执行的升级任务。
|
||||||
|
|
||||||
|
点击后弹窗显示:
|
||||||
|
|
||||||
|
- 当前版本;
|
||||||
|
- 目标版本;
|
||||||
|
- 升级方式;
|
||||||
|
- 当前阶段;
|
||||||
|
- 最近日志。
|
||||||
|
|
||||||
|
升级开始后,页面会持续轮询升级状态。容器重建期间请求会短暂失败,前端会显示「服务正在重启,等待恢复」,恢复成功后自动刷新页面。
|
||||||
|
|
||||||
|
## 7. 回滚
|
||||||
|
|
||||||
|
升级前会记录当前容器镜像。升级后健康检查超时,sidecar 会尝试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FONU_VERSION=<上一个版本> docker compose up -d --no-deps --force-recreate fonu
|
||||||
|
```
|
||||||
|
|
||||||
|
回滚是否成功会写入升级日志。
|
||||||
|
|
||||||
|
## 8. 常见问题
|
||||||
|
|
||||||
|
### 侧栏没有「一键升级」
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
1. 当前是否使用 `docker-compose.yml` 或 `docker-compose.hub.yml`;
|
||||||
|
2. `fonu-updater` 容器是否在运行;
|
||||||
|
3. `FONU_UPDATER_SOCKET` 是否在两侧指向同一路径;
|
||||||
|
4. 共享卷 `fonu-updater-sock` 是否存在。
|
||||||
|
|
||||||
|
如果状态接口返回:
|
||||||
|
|
||||||
|
```text
|
||||||
|
读取远端版本失败:git clone ... signal: killed ... fatal: early EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
说明运行中的 updater 仍是旧版本,仍在用完整克隆读取 `VERSION`。升级到 v1.3.2 后,updater 会改用 Gitea API / raw 地址读取版本文件。由于 sidecar 不能升级自身,需要先手动重建一次:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.hub.yml up -d --build updater
|
||||||
|
|
||||||
|
# 本地源码部署
|
||||||
|
docker compose up -d --build updater
|
||||||
|
```
|
||||||
|
|
||||||
|
### `docker-compose.yml` 模式提示存在未提交改动
|
||||||
|
|
||||||
|
升级前的 Git 工作区检查要求干净,避免覆盖用户修改。先处理工作区,或手动执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 升级时报 `/workspace/docker-compose.hub.yml: no such file or directory`
|
||||||
|
|
||||||
|
v1.3.6 起 updater 会自动从 `fonu` 容器的 Compose 标签读取宿主机目录,并通过 helper 容器执行 Compose。如果自动发现仍失败,可以显式设置目录:
|
||||||
|
|
||||||
|
先查询运行中 `fonu` 容器的 Compose 工作目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker inspect fonu --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}'
|
||||||
|
```
|
||||||
|
|
||||||
|
把输出目录写入 Compose 同层 `.env` 作为覆盖:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
FONU_UPDATE_HOST_DIR=/实际/的/Compose/目录
|
||||||
|
```
|
||||||
|
|
||||||
|
然后只重建 updater:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f /实际/的/Compose/目录/docker-compose.hub.yml up -d --build updater
|
||||||
|
```
|
||||||
|
|
||||||
|
状态接口会明确提示 Compose 文件缺失,修复挂载或设置覆盖后按钮才会重新显示。
|
||||||
|
|
||||||
|
### 报 `container name "/fonu" is already in use`
|
||||||
|
|
||||||
|
这表示 Compose 没有识别到当前容器所属的原项目,可能把升级当成新项目并尝试新建网络与数据卷。v1.3.7 的更新版 updater 会读取现有容器的 `com.docker.compose.project` 标签,并显式复用原项目名。
|
||||||
|
|
||||||
|
如果现有 `fonu` 容器不是由 Compose 管理,updater 会拒绝升级,避免新建 `fonu_fonu-data` 等空数据卷。应先按 Compose 方式重新部署,再使用一键升级。
|
||||||
|
|
||||||
|
### helper 命令里出现 `-v /workspace:/workspace`
|
||||||
|
|
||||||
|
说明 Compose 标签只返回了容器内路径。更新后的 updater 会优先从 `project.config_files` 标签推导宿主机目录,并忽略 `/workspace` 这类容器路径。若标签仍无法提供真实宿主机路径,应检查部署工具的 Compose 标签是否完整;正常情况下无需手工设置目录。
|
||||||
|
|
||||||
|
### `docker-compose.hub.yml` 模式无法读取远端版本
|
||||||
|
|
||||||
|
检查 sidecar 容器内是否可以访问 Git 仓库:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it fonu-updater sh -lc 'git ls-remote http://45.207.223.190:10082/mrlee/-Fonu.git main'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 升级卡在 `running`
|
||||||
|
|
||||||
|
查看 sidecar 日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs -f fonu-updater
|
||||||
|
```
|
||||||
|
|
||||||
|
以及 Compose 输出:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs --tail 100 fonu
|
||||||
|
```
|
||||||
|
|
||||||
|
### 回滚失败
|
||||||
|
|
||||||
|
确认升级前的镜像 tag 仍然存在:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker images fonu
|
||||||
|
```
|
||||||
|
|
||||||
|
然后用明确的版本 tag 手动切回:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 暂不支持
|
||||||
|
|
||||||
|
- `docker-compose.host.yml`:需要确认 host 网络与端口占用后再设计;
|
||||||
|
- 原生 systemd 安装:需要单独的受控升级 helper;
|
||||||
|
- 回滚到远端不存在的镜像:需要额外发布流程;
|
||||||
|
- 升级 `fonu-updater` 自身:当前只重建 `fonu` 服务,sidecar 更新仍需一次手动 Compose 操作。
|
||||||
|
|
||||||
|
## 10. 在线升级验证
|
||||||
|
|
||||||
|
示例页面用于验证升级结果。v1.9.0 的验证流程:
|
||||||
|
|
||||||
|
1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。
|
||||||
|
2. 确认远端 `main` 分支的 `VERSION` 为 `1.9.0`。
|
||||||
|
3. 在侧栏看到「一键升级」后点击并确认。
|
||||||
|
4. 等待升级弹窗显示成功,页面自动刷新。
|
||||||
|
5. 打开侧栏「示例页面」,确认:
|
||||||
|
- 页面路由为 `/example`;
|
||||||
|
- 页面显示的版本为 `1.9.0`;
|
||||||
|
- 页面显示验证标识 `v1.9.0-example-page`;
|
||||||
|
- 页面显示独立的「本次升级验证」区块;
|
||||||
|
- 「升级验证」显示为「通过」。
|
||||||
|
|
||||||
|
如果示例页面可见且版本正确,说明镜像构建、容器重建、后端版本注入和前端静态资源更新均已生效。
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
# 上游同步方案:Fonu 1.0.2 → 1.0.5 对照与可借鉴项
|
||||||
|
|
||||||
|
> **时效说明(2026-09-24)**:文中对 `/frp` 页现状的描述基于 2026-09-23,已被 v1.2.6 的重构推翻(现为**左服务端列表 + 右规则表格**,服务端增删改已并入 `/frp-agent`)。
|
||||||
|
|
||||||
|
> 本项目基于**上游 v1.0.2**(2026-09-18)开发,上游已迭代到 **v1.0.5**(2026-09-23)。
|
||||||
|
> 本文逐版本列出上游 1.0.3 / 1.0.4 / 1.0.5 的全部变更,并逐条核对**本项目代码现状**,
|
||||||
|
> 给出「同步 / 已具备 / 不适用 / 待核对」的判定与落地建议。
|
||||||
|
>
|
||||||
|
> 上游来源:`https://github.com/O96u/Fonu`(CHANGELOG.md + 提交记录)
|
||||||
|
> 核对方式:对本仓库 `f:\Fonu-main\Fonu-1.0.2` 做 grep / 文件树核对(证据写在每条的「本项目现状」里)
|
||||||
|
> 更新日期:2026-09-23
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、结论速览
|
||||||
|
|
||||||
|
| # | 上游项 | 版本 | 本项目现状 | 建议 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | 非 HTTPS 环境复制到剪贴板失败 | 1.0.4 | **同样存在**:6 处 `navigator.clipboard`(5 个文件) | **P0 同步**:抽 `utils/clipboard.ts` 加降级 |
|
||||||
|
| 2 | 非 HTTPS 添加 TCP 隧道失败(`crypto.randomUUID`) | 1.0.4 | **不适用**:全仓 `randomUUID` = 0 处 | 不适用(附一条 `crypto.subtle` 复核项) |
|
||||||
|
| 3 | 日志中心新增「FRP 日志」Tab(解析 frpc 日志) | 1.0.3 | **缺口**:`LogsView.vue` 只有 系统 / 访问 / Nginx 三个 Tab | **P1 同步**(后端接口已具备,纯前端) |
|
||||||
|
| 4 | 通知:单通道密钥解密失败不拖垮其它通道 | 1.0.5 | **确凿缺口**:`store.go:105-108` 解密失败即 return,整份配置加载失败 | **P1 同步修** |
|
||||||
|
| 5 | 通知时间按系统时区显示 | 1.0.4 | **条件缺口**:`format.go` 用 `time.Now()`,但容器未设 `TZ` → Docker 下显示 UTC | P1 补 `TZ` |
|
||||||
|
| 6 | frps 参考配置自动生成 `allowPorts` + 防火墙放行提示 | 1.0.3 | **已具备**:`internal/frp/frps_template.go` 已生成 `allowPorts` | 已具备;仅可补「VPS 防火墙放行」UI 提示 |
|
||||||
|
| 7 | TCP 远程端口手动指定支持 1–65535 | 1.0.5 | **已具备**:`FrpView.vue:336` 已是 `:min="1" :max="65535"` | 已具备;后端随机范围(1024–65535)待核 |
|
||||||
|
| 8 | FRP 页重构为「状态卡 + Web/TCP/高级/运行日志分栏」 | 1.0.3 | **方向已对齐**:本轮刚改为标准统计区 + 卡片分区 | 不照搬(形态差异大,见 §三.1) |
|
||||||
|
| 9 | 独立 TCP 隧道列表 / 纯 TCP 模式无需域名 | 1.0.3 | **功能已具备**:规则列表支持 tcp/udp/http/https 等类型 | 可选做「按类型筛选」,不照搬分栏 |
|
||||||
|
| 10 | 反代:新建入口时校验监听端口占用 | 1.0.5 | **已具备且语义更准**:`ensureHostAvailable` 校验「同域名 + 同端口」;仅缺冲突规则名与 custom 模式覆盖 | P3 收尾(详见 §八) |
|
||||||
|
| 11 | 反代:复制改为「预填表单、不改域名后缀」 | 1.0.5 | **部分对齐**:已是 `openDuplicate` 打开表单,但 `duplicateName()` 仍加「-复制」 | P3 去后缀 |
|
||||||
|
| 12 | 反代:子表撑满卡片 / 域名后紧跟复制·打开 / 状态列仅保留开关 | 1.0.5 | 本地布局不同(本轮未涉及该页) | P3 视觉可选对齐 |
|
||||||
|
| 13 | Telegram 代理支持 socks5 + 自动补 `http://` | 1.0.3 | **代理已具备**(`telegram.go:43-51`,Go 原生支持 socks5);缺保存校验与自动补前缀 | P3 补校验 |
|
||||||
|
| 14 | 通知事件开关保存与前端状态同步更可靠 | 1.0.5 | **未定位到缺陷**:`service.go:210` `send()` 为单一出口,未复现上游描述的问题 | 保留观察 |
|
||||||
|
| 15 | 连接设置:地址与端口合并一行 / 卡片内重启按钮 | 1.0.3 | 本地 FrpView 已有服务端启停按钮 | P3 视觉可选 |
|
||||||
|
|
||||||
|
**优先级**:P0 = 剪贴板降级(真实功能缺陷,且本轮新增的复制按钮也在其中);P1 = FRP 日志 Tab、通知健壮性两条;P2/P3 = 核对项与视觉对齐。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、逐版本明细
|
||||||
|
|
||||||
|
### v1.0.3(2026-09-21)
|
||||||
|
|
||||||
|
**新增**
|
||||||
|
|
||||||
|
1. **FRP:支持独立 TCP 隧道列表(如 SSH、数据库),可与 Web 网关同时使用;纯 TCP 模式无需配置穿透域名**
|
||||||
|
- 本项目现状:`FrpView.vue` 的规则表单已支持 `tcp / udp / http / https / tcpmux / stcp / sudp / xtcp`(`proxyForm.type`),且 `remote_port` 仅对 tcp/udp 显示(`FrpView.vue:336`)→ **能力已具备**。
|
||||||
|
- 差异:上游是「Web / TCP 分栏」的信息架构;本项目是「统一规则列表 + 类型字段」。本项目的公网反代链路(`ProxyRemoteView` → agent → Nginx)依赖 http 规则 + 自定义域名,改成上游式分栏会牵动两页交互。
|
||||||
|
- 建议:**不照搬分栏**;可选做「规则列表按类型筛选」的小改进(纯前端,低风险)。
|
||||||
|
|
||||||
|
2. **FRP:允许先保存连接设置(地址/端口/Token),待配置域名或 TCP 隧道后再启动 frpc**
|
||||||
|
- 本项目现状:服务端保存与 frpc 启动是分离的(`createFrpServer` 与 `startFrpServer` 独立),但未核对「无规则时是否允许启动」。
|
||||||
|
- 建议:列入 §四 待核对;若本地已有拦截,则对齐为「允许保存、启动前给提示」。
|
||||||
|
|
||||||
|
3. **FRP:frps 参考配置自动生成 `allowPorts`,并提示在 VPS 防火墙放行 TCP 远程端口**
|
||||||
|
- 本项目现状:`internal/frp/frps_template.go` **已生成 `allowPorts`**(grep 命中)。
|
||||||
|
- 缺口:缺少「请在 VPS 防火墙放行 TCP 远程端口」的 UI 提示。
|
||||||
|
- 建议:**P2**,在 frps 配置弹窗(`showFRPSConfig`)与规则保存后各加一句提示(纯文案 + 1 个列表推导)。
|
||||||
|
|
||||||
|
4. **日志中心:新增 FRP 日志 Tab,解析 frpc 运行日志(时间、级别、来源、消息)**
|
||||||
|
- 本项目现状:`LogsView.vue` 现有 Tab = `系统日志 / 访问日志 / Nginx 日志`(第 11 / 40 / 72 行)→ **无 FRP Tab**。
|
||||||
|
- 后端:已有 `getFrpLogs` / `getFrpProxyLogs`(`api` 命中),日志内容可得。
|
||||||
|
- 建议:**P1 同步**。落地方式:`LogsView.vue` 增加第 4 个 Tab + 解析器(时间/级别/来源/消息四列,复用 `formatLogTime` / `formatLogLine` 与现有筛选控件),后端若已有 `/api/frp/logs` 则纯前端。
|
||||||
|
|
||||||
|
**改进**
|
||||||
|
|
||||||
|
5. **FRP:内网穿透页重构为「状态卡 + 连接设置 + Web/TCP/高级/运行日志分栏」**
|
||||||
|
- 本项目现状:本轮(见提交 `70796e5`)刚把 4 张自造状态卡改为标准 `.stats-row/.stat-card`,并把 6 个弹窗统一到 `.frp-card-modal` 外壳 → **视觉方向已对齐**。
|
||||||
|
- 建议:不照搬分栏(理由同 §2.1),但可借鉴其「运行日志就近展示」的思路——本地运行日志已在弹窗与日志中心可查。
|
||||||
|
|
||||||
|
6. **FRP:状态区展示已配置 Web 域名与 TCP 隧道,标签单行展示,超出可跳转对应 Tab**
|
||||||
|
- 本项目现状:`stats-row` 第三张卡是「frpc 版本」,未展示「已配置域名/隧道」。上游把这两类信息提到状态区。
|
||||||
|
- 建议:**P2 可选**。若要做,建议把第 4 张卡「最近应用」换成「已配置入口(N 个 Web / M 个 TCP)」,点击跳规则列表并筛选。
|
||||||
|
|
||||||
|
7. **FRP:连接设置中服务器地址与端口合并为一行;左侧卡片内提供重启 frp 服务按钮**
|
||||||
|
- 本项目现状:服务端行操作列已含「启动/停止」(`serverColumns` 操作列),但无「重启」。
|
||||||
|
- 建议:**P3**。加一个「重启」按钮(`stopServer` → `startServer` 串联或后端 action),与上游一致。
|
||||||
|
|
||||||
|
8. **通知:Telegram 代理地址支持 socks5,保存时校验格式并自动补全 `http://` 前缀**
|
||||||
|
- 本项目现状:`internal/notify/telegram.go` 存在;是否支持 socks5 未核对。
|
||||||
|
- 建议:列入 §四 待核对。
|
||||||
|
|
||||||
|
### v1.0.4(2026-09-21)
|
||||||
|
|
||||||
|
9. **修复:非 HTTPS 访问时添加 TCP 隧道失败(`crypto.randomUUID` 不可用)**
|
||||||
|
- 本项目现状:**全仓 `randomUUID` 命中 0 处**(`*.vue` 与全仓两次核对均为 0)→ **不适用**。
|
||||||
|
- 附注:仍需确认无 `crypto.subtle`(同样要求安全上下文)的使用;`crypto.getRandomValues` 不受限。
|
||||||
|
|
||||||
|
10. **修复:非 HTTPS 环境下复制到剪贴板失败(内网穿透、DDNS、反向代理)** ← **本项目同样存在**
|
||||||
|
- 本项目现状:`navigator.clipboard` 共 **6 处 / 5 个文件**:
|
||||||
|
`ProxyRemoteView.vue`(2 处,本轮新增的「复制域名」「复制反代配置」)、
|
||||||
|
`AgentView.vue`(1 处,「配置查看」弹窗的复制)、
|
||||||
|
`DdnsView.vue`(1 处)、`ProxyView.vue`(1 处)、
|
||||||
|
归档文件 `FrpView-1.0.2.vue`(1 处,已弃用代码,可不动)。
|
||||||
|
- 影响:Fonu 常用 `http://内网IP:6893` 访问,属**非安全上下文**,此时 `navigator.clipboard` 为 `undefined` → 复制功能直接失败(本项目现在只弹「复制失败」提示,用户无从下手)。
|
||||||
|
- 建议:**P0 同步**。做法:新增 `web/src/utils/clipboard.ts`:
|
||||||
|
```ts
|
||||||
|
export async function copyText(text: string): Promise<boolean> {
|
||||||
|
// 非安全上下文(http:// 内网 IP)下 navigator.clipboard 不存在,回退到 execCommand
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
try { await navigator.clipboard.writeText(text); return true } catch { /* 落到回退 */ }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const el = document.createElement('textarea')
|
||||||
|
el.value = text; el.setAttribute('readonly', ''); el.style.position = 'fixed'; el.style.opacity = '0'
|
||||||
|
document.body.appendChild(el); el.select(); document.execCommand('copy'); document.body.removeChild(el)
|
||||||
|
return true
|
||||||
|
} catch { return false }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
然后 5 个调用点统一改为 `copyText(...)`,失败提示保留。
|
||||||
|
|
||||||
|
11. **修复:通知消息时间未按系统设置时区显示(如 Telegram 测试通知)**
|
||||||
|
- 本项目现状:`internal/notify/format.go` 存在;是否用 `time.Local` / 配置时区未核对。
|
||||||
|
- 建议:列入 §四 待核对;修法一般是 `time.Now()` → 按系统时区格式化(或统一走一个 `nowInSystemZone()` 帮助函数)。
|
||||||
|
|
||||||
|
### v1.0.5(2026-09-23)
|
||||||
|
|
||||||
|
**新增**
|
||||||
|
|
||||||
|
12. **反代:新建入口时校验监听端口占用,避免重复端口静默冲突**
|
||||||
|
- 本项目现状(已核实):**已具备**。`internal/proxy/proxy.go:499 ensureHostAvailable` 在保存前查 `proxy_hosts` 的「同域名 + 同端口」,`proxy_hosts` 表另有 UNIQUE 约束兜底。
|
||||||
|
- 语义差异:上游按「端口」判冲突,本项目按「端口 + 域名」判冲突——后者才是 Nginx 的真实冲突(同端口不同域名是合法的虚拟主机,按端口判会误报)。
|
||||||
|
- 残留缺口:错误提示不含冲突规则名;`nginx_mode = custom` 的规则不参与校验。详见 §八。
|
||||||
|
|
||||||
|
**改进**
|
||||||
|
|
||||||
|
13. **反代:入口/规则「复制」改为打开表单预填原内容,由用户确认后再保存(不再自动创建、不改域名后缀)**
|
||||||
|
- 本项目现状:`ProxyView.vue` 已有复制相关实现(grep 命中 9 处),行为是否「直接创建」未核对。
|
||||||
|
- 建议:**P2 核对**;若本地是「点击即创建副本」,按上游改为「预填表单」,避免误建规则。
|
||||||
|
|
||||||
|
14. **反代:入口分组子表布局撑满卡片宽度;域名后紧跟复制/打开链接;状态列仅保留开关**
|
||||||
|
- 本项目现状:`ProxyView.vue` 无「入口分组」概念(grep `分组` = 0)→ **子表撑满**项不适用;「域名后紧跟复制/打开链接」与「状态列仅保留开关」是纯视觉项。
|
||||||
|
- 建议:**P3 可选**。注意:本轮刚在 `ProxyRemoteView` 的域名列做了「域名 + 复制 + 打开」的同类交互(见提交 `ca309db`),若要对齐,可复用同一套 `copyText` + 图标按钮样式。
|
||||||
|
|
||||||
|
15. **反代:同端口监听配置冲突提示仅在配置确实不一致时显示**
|
||||||
|
- 本项目现状:未核对本地是否有该提示(`ProxyView.vue` 的冲突提示逻辑)。
|
||||||
|
- 建议:**P2 核对**(误报会干扰用户)。
|
||||||
|
|
||||||
|
16. **FRP:TCP 隧道远程端口手动指定支持 1–65535(0 仍为自动分配,随机范围 1024–65535)**
|
||||||
|
- 本项目现状:**前端已具备**(`FrpView.vue:336` `:min="1" :max="65535"`)。
|
||||||
|
- 待核:后端「0 = 自动分配」的随机范围是否为 1024–65535(`internal/frp` 内)。
|
||||||
|
- 建议:核对后如有差异,改一行常量。
|
||||||
|
|
||||||
|
17. **通知:各通道密钥解密失败不再拖垮其它通道;事件开关保存与前端状态同步更可靠**
|
||||||
|
- 本项目现状:`internal/notify/store.go` 有 10 处解密相关逻辑 → 需要看「单通道解密失败是否被上层吞掉/中断整轮发送」。
|
||||||
|
- 建议:**P1 核对后修**。典型修法:每个通道独立 `try/catch`(Go 里是错误收集而非 return),把失败通道记入日志与结果,继续发其它通道。
|
||||||
|
|
||||||
|
**修复**
|
||||||
|
|
||||||
|
18. **通知:旧 SMTP 等密文无法解密时,Telegram/Webhook 等已配置通道仍无法告警的问题**
|
||||||
|
- 与第 17 条同源,**同一处修改可一并解决**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、不照搬项及原因
|
||||||
|
|
||||||
|
1. **FRP 页「Web / TCP / 高级 / 运行日志」分栏**:本项目的公网反代链路(`ProxyRemoteView` → agent → VPS Nginx)依赖「http 规则 + 自定义域名 + 部署态」这一套语义;照搬分栏会让「规则 → 部署 → 健康」的入口被打散。当前「统计区 + 服务端表 + 规则表」的形态更贴合本项目已有能力。
|
||||||
|
2. **`crypto.randomUUID` 修复**:本项目未使用该 API(全仓 0 处),无需移植。
|
||||||
|
3. **入口分组子表撑满**:本项目 `ProxyView` 没有「入口分组」结构,不适用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、逐项核实结果(A–H,全部已读代码确认,无推测)
|
||||||
|
|
||||||
|
| # | 上游项 | 核实结论 | 证据 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| A | 允许先保存连接设置、待有域名/TCP 隧道再启动 frpc | **已具备**:本项目没有任何「必须有规则」的拦截 | `internal/frp/service.go:385` `StartServer` 仅做 GetServer → setServerRuntime("running") → enrich 返回;`FrpView.vue:1118` 启动按钮无 disabled 条件 |
|
||||||
|
| B | Telegram 代理支持 socks5 + 保存时校验格式、自动补 `http://` | **代理已具备(含 socks5)**;**缺保存校验与自动补全** | `internal/notify/telegram.go:43-51` 已有 `cfg.Telegram.ProxyURL` + `http.Transport{Proxy: http.ProxyURL(parsed)}`(Go 原生支持 `socks5://`) |
|
||||||
|
| C | 通知时间按系统时区显示 | **条件缺口**:代码用 `time.Now()` 格式化(跟随进程 TZ),但**容器未设 TZ** → Docker 部署下显示 UTC | `internal/notify/format.go:9/30`;`Dockerfile` 与 compose 无 `TZ` 环境变量 |
|
||||||
|
| D | 单通道密钥解密失败不拖垮其它通道 | **确凿缺口,与上游同一 bug** | `internal/notify/store.go:105-108`:SMTP 密文解密失败即 `return Config{}, runtimeSecrets{}, secret.DecryptHint(err)` → **整份配置加载失败**,Telegram/Webhook 也拿不到配置 → 无法告警;`store.go:117` 的 webhook 同理 |
|
||||||
|
| E | TCP 远程端口 0=自动分配(随机 1024–65535) | **本项目无此功能**:前端强制手填 1–65535,后端只做范围校验 | `FrpView.vue:336` `:min="1"`;`internal/frp/validate.go:331` `port > 0 && port <= 65535`;全仓未找到随机分配逻辑 |
|
||||||
|
| F | 反代:新建入口时校验监听端口占用 | **已具备(原判为「缺口」是关键词 grep 误判)**:校验存在但文案为「已被其他规则使用」,故搜 `占用`/`in use` 无命中 | `internal/proxy/proxy.go:499-524 ensureHostAvailable/ensureHostsAvailable`(`Create` 调用于 :225、`Update` 调用于 :345/:367);表约束见 `replaceHosts:526` 的 UNIQUE 分支;错误经 `api/proxy_handler.go:87 writeError(400, err.Error())` 直达前端 |
|
||||||
|
| G | 反代:复制改为「预填表单、不改域名后缀」 | **部分对齐**:已是打开表单预填,但仍自动加「-复制」后缀 | `ProxyView.vue:148` `@click="openDuplicate(selectedRule)"`(打开而非直接创建);`ProxyView.vue:1364-1366` `duplicateName()` 追加 `'-复制'` |
|
||||||
|
| H | 是否存在 `crypto.subtle`(同样要求安全上下文) | **不存在**(0 处) | 全仓扫描 0 命中 |
|
||||||
|
|
||||||
|
### 核实后的关键结论
|
||||||
|
|
||||||
|
1. **真正要修的只有两处**:剪贴板降级(P0,6 处隐患)、通知单通道解密失败(P1,`store.go` 直接 return 导致整份配置不可用);
|
||||||
|
2. **两处是「条件性」的**:通知时区(仅容器未设 TZ 时出现)、反代复制后缀(行为差异,非缺陷);
|
||||||
|
3. **四处本项目不缺**:无规则启动、Telegram 代理(含 socks5)、远程端口范围校验、`crypto.subtle`;**一处无对应功能**:远程端口 0=自动分配。
|
||||||
|
|
||||||
|
### 原待核对清单(索引保留)
|
||||||
|
## 五、建议的分批实施
|
||||||
|
|
||||||
|
| 批次 | 内容 | 影响面 | 预计 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **P0** | 剪贴板降级:新增 `utils/clipboard.ts` + 改 5 处调用点 | 前端 6 文件(含本轮新增的复制按钮) | 0.5h |
|
||||||
|
| **P1-a** | 日志中心「FRP 日志」Tab(四列解析 + 复用筛选) | `LogsView.vue`(+ 可能 1 个后端接口确认) | 1.5h |
|
||||||
|
| **P1-b** | 通知健壮性:单通道解密失败隔离 + 时区(第 17/18/11 条) | `internal/notify/*`(后端,需 `go test`) | 1.5h |
|
||||||
|
| **P2** | 待核对 A–H 逐条核实并按结论修(含 frps 防火墙提示、端口占用校验、复制行为、socks5) | 前后端零散 | 2h |
|
||||||
|
| **P3** | 视觉可选对齐(状态卡补「已配置入口」、规则列表类型筛选、反代视觉三项、重启按钮) | 前端 2 页 | 2h |
|
||||||
|
|
||||||
|
**验收**:每批以 `npx vue-tsc --noEmit` + `npx vite build`(前端)与 `go build ./...` + `go test ./internal/...`(后端)为准;涉及通知的后端改动需按现有测试补一条「单通道解密失败仍能发送其它通道」的用例。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、附:上游 1.0.2 及更早(本项目基线之前)
|
||||||
|
|
||||||
|
本项目基线是上游 **v1.0.2(2026-09-18,新增「证书:火山引擎 DNS 的 ACME DNS-01 验证」)**,其上游历史(v1.0.0 通知体系、v1.0.1 DDNS 火山引擎与多层子域)在本项目 `CHANGELOG.md` 中已有对应条目(v1.0.0 / v1.0.1 / v1.0.2 段),本轮无需回溯。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、可增加项清单(本项目确实没有的,按价值排序)
|
||||||
|
|
||||||
|
| 序 | 可增加项 | 依据(上游版本) | 落地要点 | 工作量 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | **剪贴板降级**:`utils/clipboard.ts` + 5 处调用点 | 1.0.4 修复 | `navigator.clipboard` 优先、`execCommand` 回退;非 HTTPS 内网访问下才真正生效 | 0.5h |
|
||||||
|
| 2 | **通知通道解密失败隔离**:单通道失败不拖垮整轮 | 1.0.5 修复 | `internal/notify/store.go` 的 `load()` 改为逐通道收集错误(失败通道标不可用 + 记原因),继续解析其它通道;补一条「SMTP 密文损坏时 Telegram 仍可发送」的测试 | 1.5h |
|
||||||
|
| 3 | **日志中心「FRP 日志」Tab**(解析时间/级别/来源/消息) | 1.0.3 新增 | `LogsView.vue` 增第 4 个 Tab;后端 `getFrpLogs` 已可用 | 1.5h |
|
||||||
|
| 4 | **通知时区**:容器设 `TZ`(并让通知时间走系统时区) | 1.0.4 修复 | `docker-compose*.yml` 加 `TZ: Asia/Shanghai`;`Dockerfile` 可加 `ENV TZ` | 0.5h |
|
||||||
|
| 5 | ~~反代监听端口占用校验~~ → 已具备,降级为「补冲突规则名 + custom 模式覆盖」 | 1.0.5 新增 | 见 §八:错误提示带冲突规则名;custom 规则解析其 `listen`/`server_name` 后纳入校验 | 0.5h + 2h |
|
||||||
|
| 6 | **frps 配置弹窗补「VPS 防火墙放行 TCP 远程端口」提示** | 1.0.3 改进 | 纯文案 + 由规则列表推导端口集合(`allowPorts` 已生成) | 0.5h |
|
||||||
|
| 7 | **Telegram 代理保存校验 + 自动补 `http://`** | 1.0.3 改进 | 保存时校验 URL 格式(允许 `http/https/socks5`),缺 scheme 时补 `http://` | 0.5h |
|
||||||
|
| 8 | **反代复制去掉「-复制」后缀** | 1.0.5 改进 | `duplicateName()` 改为保留原名 + 提示用户改域名 | 0.5h |
|
||||||
|
| 9 | **FRP 状态卡补「已配置入口」**(N 个 Web / M 个 TCP,点击筛选) | 1.0.3 改进 | 替换第 4 张卡「最近应用」或新增一张 | 1h |
|
||||||
|
| 10 | **服务端行加「重启」按钮** | 1.0.3 改进 | 复用 stop/start 串联或后端 action | 0.5h |
|
||||||
|
| 11 | **远程端口 0=自动分配(随机 1024–65535)** | 1.0.5 改进 | 后端随机分配 + 前端允许 0;本项目当前强制手填,属新增能力 | 1h |
|
||||||
|
|
||||||
|
> 前 4 项是「本项目确实缺少且有实际影响」的;第 5–8 项是上游同期新增/改进的缺口;第 9–11 项属可选增强。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、第 5 项详细整理:反代监听端口占用校验
|
||||||
|
|
||||||
|
### 8.1 结论:本项目已有等价实现,且判定语义比上游更准
|
||||||
|
|
||||||
|
上游 1.0.5 的「新建入口时校验监听端口占用,避免重复端口静默冲突」,在本项目**已经具备**。
|
||||||
|
§四.F 当时判为「缺口」,是因为用关键词 `占用` / `in use` 做 grep,而本项目代码里的措辞是
|
||||||
|
「已被其他规则使用」,所以没搜到——这是**关键词误判**,已在上文更正。
|
||||||
|
|
||||||
|
更关键的是**判定语义**:上游按「端口」判冲突,本项目按「端口 + 域名」判冲突。后者才是 Nginx 的真实冲突,
|
||||||
|
前者会把「同一端口挂多个站点」这种最常见的用法误报成冲突。
|
||||||
|
|
||||||
|
### 8.2 Nginx 语义:一条规则到底占用哪些「端口 + 域名」
|
||||||
|
|
||||||
|
端口占用的判定必须与 `internal/nginx/generate.go` 的生成逻辑对齐,否则会误报或漏报。
|
||||||
|
`writeRuleBlocks`(`generate.go:120`)按 `rule.PortGroups()`(`proxy.go:101`)逐组生成 server 块:
|
||||||
|
|
||||||
|
| 规则字段 | 生成的 server 块 | 监听端口来源 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 每个 PortGroup | 至少 1 个 | `host.ListenPort`(host 级覆盖)否则 `rule.ListenPort` |
|
||||||
|
| `https_enabled = true` 且有可用证书 | +1 个(`listen <port> ssl`) | **同组端口**,不额外占端口 |
|
||||||
|
| `http_redirect = true` 且 `useHTTPS` | +1 个(同端口、无 ssl、`return 301`) | **同组端口**,不额外占端口 |
|
||||||
|
|
||||||
|
**关键推论**:一条规则占用的端口集合就是它的 `PortGroups()` 端口集合;
|
||||||
|
`https_enabled` / `http_redirect` **不会**额外占用 80 / 443,因此不必按这两个开关推算端口。
|
||||||
|
|
||||||
|
Nginx 的冲突规则是「**同端口 + 同 `server_name`**」:此时只在 reload 时打一条
|
||||||
|
`conflicting server name ... ignored` 警告,**第一个 server 块生效、后面的静默失效**——
|
||||||
|
这正是上游所说的「重复端口静默冲突」。反过来,「同端口 + 不同域名」是**合法的虚拟主机**,不能报错。
|
||||||
|
|
||||||
|
### 8.3 本项目已有的校验链(逐跳)
|
||||||
|
|
||||||
|
| 跳 | 位置 | 行为 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `web/src/views/ProxyView.vue` 保存表单 | `POST/PUT /api/proxy/rules` |
|
||||||
|
| 2 | `internal/api/proxy_handler.go:54` / `:93` | `svc.Create` / `svc.Update`;出错走 `writeError(400, err.Error())` |
|
||||||
|
| 3 | `internal/proxy/proxy.go:225` / `:345` / `:367` | `ensureHostsAvailable(hosts, listenPort, excludeRuleID)` |
|
||||||
|
| 4 | `internal/proxy/proxy.go:499` | `SELECT rule_id FROM proxy_hosts WHERE hostname = ? COLLATE NOCASE AND listen_port = ?` |
|
||||||
|
| 5 | `internal/proxy/proxy.go:511` | 返回 `前端域名 %s:%d 已被其他规则使用` |
|
||||||
|
| 6 | `internal/proxy/proxy.go:526` `replaceHosts` | 撞 `UNIQUE(hostname, listen_port)` 时给出同一文案(兜底) |
|
||||||
|
|
||||||
|
**三条路径都已覆盖**:新建(`:225`)、改端口(`:345`,仅校验跟随规则端口的 host,host 级独立端口不受影响)、
|
||||||
|
改域名(`:367`)。`excludeRuleID` 保证编辑自身时不误判。前端 `message.error(error.message)` 直接展示后端文案,
|
||||||
|
因此**只改后端文案即可,前端无需改动**。
|
||||||
|
|
||||||
|
### 8.4 三处真实缺口(按价值排序)
|
||||||
|
|
||||||
|
> **实施状态(2026-09-23):缺口 1、2(方案 C)、3 已完成**,落地位置与测试见 §8.7。
|
||||||
|
|
||||||
|
**缺口 1 · 冲突提示不含冲突规则名**(上游明确要求「错误提示写明冲突规则名」)
|
||||||
|
|
||||||
|
- 现状:只说「前端域名 a.com:80 已被其他规则使用」,用户还得自己去列表里找是哪一条。
|
||||||
|
- 改法:`ensureHostAvailable` 的查询 JOIN `proxy_rules` 取 `name`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT h.rule_id, COALESCE(r.name, '') FROM proxy_hosts h
|
||||||
|
LEFT JOIN proxy_rules r ON r.id = h.rule_id
|
||||||
|
WHERE h.hostname = ? COLLATE NOCASE AND h.listen_port = ? LIMIT 1
|
||||||
|
```
|
||||||
|
|
||||||
|
错误串改为 `前端域名 a.com:80 已被规则「名称」占用`;`name` 为空时回落为 `规则 #id`。
|
||||||
|
- 影响面:`proxy.go` 单函数 + `proxy_hosts_test.go` 中断言该文案的用例需同步。
|
||||||
|
- 风险低,0.5h。
|
||||||
|
|
||||||
|
**缺口 2 · `nginx_mode = custom` 的规则完全不参与校验**(真正的盲区)
|
||||||
|
|
||||||
|
- 现状:`Generate`(`generate.go:52`)对 custom 规则走 `writeCustomRuleBlocks`(`generate.go:108`),
|
||||||
|
把 `ruleCustomContent` 原样写入;其自定义配置里的 `listen` / `server_name` **不写入 `proxy_hosts`**,
|
||||||
|
因此与已有规则的「同端口 + 同域名」冲突**保存时无任何提示**,只在 Nginx reload 时打一条 warning。
|
||||||
|
- 改法(先轻后重,二选一):
|
||||||
|
1. **保存 custom 配置时解析**其 `listen <port>` 与 `server_name`,与 `ListEnabled` 的其它规则做
|
||||||
|
「同端口 + 同域名」比对,冲突即报错并写明冲突规则名。可复用 `internal/nginx/custom.go` 中
|
||||||
|
已有的 `listen ` 前缀处理与 `syntax.go` 的行解析思路。
|
||||||
|
2. **把解析结果落库到 `proxy_hosts`**(`hostname` 取 `server_name`、`listen_port` 取端口),
|
||||||
|
这样 8.3 的校验链天然覆盖 custom 规则,不必维护两套逻辑(更推荐,但要处理多域名/通配写法)。
|
||||||
|
- 需处理的写法:`listen 80 default_server;`、`server_name a.com b.com;`、通配 `*.a.com`、
|
||||||
|
`include` 片段、注释行。
|
||||||
|
- 风险中,2h。
|
||||||
|
|
||||||
|
**缺口 3 · 并发下端口变更的 TOCTOU**(低危)
|
||||||
|
|
||||||
|
- 现状:`Update` 的 `:359` 分支裸执行 `UPDATE proxy_hosts SET listen_port = ? ...`,未捕获 UNIQUE;
|
||||||
|
校验(`:345`)与写入之间存在窗口。单用户场景几乎不会触发。
|
||||||
|
- 改法:把该 `UPDATE` 的 UNIQUE 错误映射成与 `replaceHosts` 相同的中文文案,或把校验与写入并入同一事务。
|
||||||
|
- 风险低,0.5h(可随缺口 1 一起做)。
|
||||||
|
|
||||||
|
### 8.5 明确**不做**的两件事
|
||||||
|
|
||||||
|
1. **不按「同端口」判冲突**:同端口不同域名是合法虚拟主机,按端口报错会让「一个端口挂多个站点」
|
||||||
|
这种最常见的用法无法保存。
|
||||||
|
2. **不探测系统层面的端口占用**(`net.Listen` 试占、`ss -ltn`):Fonu 自己就是占用方(Nginx 监听 80/443),
|
||||||
|
且容器与宿主机视角不同,误报率极高。上游所指也是 Nginx 配置内的冲突,而非系统端口。
|
||||||
|
|
||||||
|
### 8.6 验收标准
|
||||||
|
|
||||||
|
- 「同端口 + 同域名」与其它规则冲突 → 400,且提示中**含冲突规则名**;
|
||||||
|
- 「同端口 + 不同域名」→ 正常保存,不误报;
|
||||||
|
- host 级独立端口(`a.com:8081` 与 `b.com:8080`)互不干扰;
|
||||||
|
- 编辑规则自身(不改域名)→ 不误判为与自身冲突(`excludeRuleID` 已保证,需用例固化);
|
||||||
|
- custom 模式规则与其 `listen`/`server_name` 冲突 → 保存时报错(缺口 2 完成后)。
|
||||||
|
|
||||||
|
### 8.7 实施记录(2026-09-23)
|
||||||
|
|
||||||
|
缺口 1、2(方案 C)、3 已落地:
|
||||||
|
|
||||||
|
| 缺口 | 落地位置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 1 · 冲突提示带规则名 | `internal/proxy/proxy.go` 的 `ensureHostAvailable` + 新增 `endpointOwner`(描述优先「备注名 → 该规则首个域名 → `规则 #id`」);文案统一为「域名 a.com:80 已被规则「官网」使用,请编辑现有规则或更换域名/端口」 |
|
||||||
|
| 2 · 方案 C 独立占用表 | 迁移 `migrations/024_proxy_custom_endpoints.sql`(`UNIQUE(rule_id, hostname, listen_port)` + `ON DELETE CASCADE`);解析器 `internal/nginx/endpoints.go` 的 `ParseServerEndpoints`;读写 `internal/proxy/custom_endpoints.go`(`ReplaceCustomEndpoints` / `ClearCustomEndpoints` / `EnsureEndpointsAvailable`);`ensureHostAvailable` 现在合并查两张表 |
|
||||||
|
| 2 · 落库时机 | `internal/service/nginx_config.go`:`SaveRuleNginx` 手动分支(校验占用 → 写文件 → 落库)、切回 `auto` 分支(清理占用)、`RollbackRuleNginx`(回滚后同步占用);删除规则由外键级联清理 |
|
||||||
|
| 3 · TOCTOU | `Update` 的 `UPDATE proxy_hosts SET listen_port` 捕获 UNIQUE 后按域名复检并给出带规则名的原因;`replaceHosts` 同样改为复检;新增 `isUniqueViolation` 供两处复用 |
|
||||||
|
|
||||||
|
**测试**:`internal/nginx/endpoints_test.go`(多 server 块、`[::]` 写法、通配与正则、无 `listen` 默认 80、`include`/变量忽略);
|
||||||
|
`internal/proxy/custom_endpoints_test.go`(走真实迁移建库:冲突含规则名、排除自身、同域名不同端口放行、custom 占用对表单规则可见、级联清理、清理后释放)。
|
||||||
|
|
||||||
|
### 8.8 未覆盖的边界(有意保留)
|
||||||
|
|
||||||
|
1. **全局手动 Nginx 片段**(`SaveGlobalNginx` → `writeGlobalCustomInclude`)里的 `server` 块不参与校验:
|
||||||
|
它没有 rule_id,纳入校验需要额外的归属语义,本轮未做。
|
||||||
|
2. **前端预检**(`ProxyView.vue` 的 `findHostConflict`)只覆盖 `proxy_hosts`(表单规则的 `rule.hosts`),看不到 custom 占用;
|
||||||
|
这类冲突由后端保存时报错兜底,错误消息直接展示给用户。
|
||||||
|
3. 解析器对手写配置里的 `include`、变量、`listen unix:` 一律忽略——宁可漏报也不误报(拦不住保存比误报更可接受)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
+23
-1
@@ -12,11 +12,26 @@ const (
|
|||||||
CALetsEncryptStaging = "letsencrypt-staging"
|
CALetsEncryptStaging = "letsencrypt-staging"
|
||||||
CAZeroSSL = "zerossl"
|
CAZeroSSL = "zerossl"
|
||||||
CABuypass = "buypass"
|
CABuypass = "buypass"
|
||||||
|
// CALiteSSL 亚数 TrustAsia 的公益免费证书(90 天,支持单域名/通配符/多域名,强制 EAB)
|
||||||
|
CALiteSSL = "litessl"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// caRetired 记录已停止签发的 CA:仍然认识这些值(历史证书记录里可能存着,用于展示与清理),
|
||||||
|
// 但不允许再用来申请——否则用户只会看到一个难懂的 ACME 报错而不是「这家 CA 不干了」。
|
||||||
|
var caRetired = map[string]string{
|
||||||
|
CABuypass: "Buypass 已于 2025-10-15 停止签发证书(ACME 服务 2026-04-15 终止),请改用 Let's Encrypt、LiteSSL 或 ZeroSSL",
|
||||||
|
}
|
||||||
|
|
||||||
|
// CAUnavailableReason 返回该 CA 已停用的原因;可用的 CA 返回空字符串。
|
||||||
|
func CAUnavailableReason(ca string) string {
|
||||||
|
return caRetired[NormalizeCA(ca)]
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
zeroSSLDirectoryURL = "https://acme.zerossl.com/v2/DV90"
|
zeroSSLDirectoryURL = "https://acme.zerossl.com/v2/DV90"
|
||||||
buypassDirectoryURL = "https://api.buypass.com/acme/directory"
|
buypassDirectoryURL = "https://api.buypass.com/acme/directory"
|
||||||
|
// liteSSLDirectoryURL 实测 2026-09-27 可访问,目录里 meta.externalAccountRequired = true
|
||||||
|
liteSSLDirectoryURL = "https://acme.litessl.com/acme/v2/directory"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NormalizeCA(ca string) string {
|
func NormalizeCA(ca string) string {
|
||||||
@@ -29,6 +44,8 @@ func NormalizeCA(ca string) string {
|
|||||||
return CAZeroSSL
|
return CAZeroSSL
|
||||||
case CABuypass, "buypass-go":
|
case CABuypass, "buypass-go":
|
||||||
return CABuypass
|
return CABuypass
|
||||||
|
case CALiteSSL, "trustasia":
|
||||||
|
return CALiteSSL
|
||||||
default:
|
default:
|
||||||
return ca
|
return ca
|
||||||
}
|
}
|
||||||
@@ -44,6 +61,8 @@ func DirectoryURL(ca string) (string, error) {
|
|||||||
return zeroSSLDirectoryURL, nil
|
return zeroSSLDirectoryURL, nil
|
||||||
case CABuypass:
|
case CABuypass:
|
||||||
return buypassDirectoryURL, nil
|
return buypassDirectoryURL, nil
|
||||||
|
case CALiteSSL:
|
||||||
|
return liteSSLDirectoryURL, nil
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("不支持的颁发机构:%s", ca)
|
return "", fmt.Errorf("不支持的颁发机构:%s", ca)
|
||||||
}
|
}
|
||||||
@@ -59,6 +78,8 @@ func CALabel(ca string) string {
|
|||||||
return "ZeroSSL"
|
return "ZeroSSL"
|
||||||
case CABuypass:
|
case CABuypass:
|
||||||
return "Buypass"
|
return "Buypass"
|
||||||
|
case CALiteSSL:
|
||||||
|
return "LiteSSL"
|
||||||
default:
|
default:
|
||||||
if ca == "" {
|
if ca == "" {
|
||||||
return "手动导入"
|
return "手动导入"
|
||||||
@@ -72,6 +93,8 @@ func ValidateCA(ca string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateCADomains 校验各 CA 自己的限制。
|
||||||
|
// LiteSSL 官方支持单域名 / 通配符 / 多域名,因此这里不额外限制。
|
||||||
func ValidateCADomains(ca string, domains []string) error {
|
func ValidateCADomains(ca string, domains []string) error {
|
||||||
switch NormalizeCA(ca) {
|
switch NormalizeCA(ca) {
|
||||||
case CABuypass:
|
case CABuypass:
|
||||||
@@ -86,4 +109,3 @@ func ValidateCADomains(ca string, domains []string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package acme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LiteSSL 接替 Buypass 的位置:目录地址实测可用,且强制 EAB。
|
||||||
|
func TestLiteSSLDirectoryAndLabel(t *testing.T) {
|
||||||
|
url, err := DirectoryURL(CALiteSSL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LiteSSL 目录地址解析失败:%v", err)
|
||||||
|
}
|
||||||
|
if url != "https://acme.litessl.com/acme/v2/directory" {
|
||||||
|
t.Fatalf("LiteSSL 目录地址不对:%q", url)
|
||||||
|
}
|
||||||
|
if label := CALabel(CALiteSSL); label != "LiteSSL" {
|
||||||
|
t.Fatalf("LiteSSL 展示名不对:%q", label)
|
||||||
|
}
|
||||||
|
// 别名与归一化
|
||||||
|
if got := NormalizeCA("trustasia"); got != CALiteSSL {
|
||||||
|
t.Fatalf("别名 trustasia 应归一化为 %s,实际 %q", CALiteSSL, got)
|
||||||
|
}
|
||||||
|
if reason := CAUnavailableReason(CALiteSSL); reason != "" {
|
||||||
|
t.Fatalf("LiteSSL 是可用的 CA,不该有停用原因:%q", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiteSSL 支持通配符与多域名,因此 ValidateCADomains 不应拦它。
|
||||||
|
func TestValidateCADomainsLiteSSLAllowsWildcard(t *testing.T) {
|
||||||
|
domains := []string{"*.36800.cc", "a.com", "b.com", "c.com", "d.com", "e.com", "f.com"}
|
||||||
|
if err := ValidateCADomains(CALiteSSL, domains); err != nil {
|
||||||
|
t.Fatalf("LiteSSL 应允许通配符与多域名:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buypass 保留在停用表里(历史记录要能展示),但提示里应给出可用的替代。
|
||||||
|
func TestBuypassIsRetiredWithAlternatives(t *testing.T) {
|
||||||
|
reason := CAUnavailableReason(CABuypass)
|
||||||
|
if reason == "" {
|
||||||
|
t.Fatal("Buypass 应仍被标记为已停用")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reason, "LiteSSL") {
|
||||||
|
t.Fatalf("停用提示里应包含 LiteSSL 这个替代项:%q", reason)
|
||||||
|
}
|
||||||
|
// 旧记录仍能正常取到目录地址(不报「不支持的颁发机构」)
|
||||||
|
if _, err := DirectoryURL(CABuypass); err != nil {
|
||||||
|
t.Fatalf("历史 Buypass 记录的目录地址仍应可解析:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package acme
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseResolverList(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"带端口", "223.5.5.5:53, 1.1.1.1:53", []string{"223.5.5.5:53", "1.1.1.1:53"}},
|
||||||
|
{"缺端口补 53", "8.8.8.8\n9.9.9.9", []string{"8.8.8.8:53", "9.9.9.9:53"}},
|
||||||
|
{"分号与空格", "223.5.5.5;8.8.4.4 1.0.0.1", []string{"223.5.5.5:53", "8.8.4.4:53", "1.0.0.1:53"}},
|
||||||
|
{"空值", " ", nil},
|
||||||
|
{"只留空白项", " , ; \n", nil},
|
||||||
|
}
|
||||||
|
for _, item := range cases {
|
||||||
|
got := parseResolverList(item.raw)
|
||||||
|
if len(got) != len(item.want) {
|
||||||
|
t.Fatalf("%s: 期望 %v,实际 %v", item.name, item.want, got)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != item.want[i] {
|
||||||
|
t.Fatalf("%s: 期望 %v,实际 %v", item.name, item.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseResolverListCapsEntries(t *testing.T) {
|
||||||
|
raw := "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4,1.1.1.5,1.1.1.6,1.1.1.7,1.1.1.8,1.1.1.9,1.1.1.10"
|
||||||
|
if got := parseResolverList(raw); len(got) != 8 {
|
||||||
|
t.Fatalf("最多取前 8 条,实际 %d:%v", len(got), got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内置的默认值必须是 lego 能直接用的 host:port 形式
|
||||||
|
func TestDefaultResolversAreUsable(t *testing.T) {
|
||||||
|
got := parseResolverList(defaultACMEDNSResolvers)
|
||||||
|
if len(got) == 0 {
|
||||||
|
t.Fatalf("默认解析器解析为空:%q", defaultACMEDNSResolvers)
|
||||||
|
}
|
||||||
|
if got[0] != "223.5.5.5:53" {
|
||||||
|
t.Fatalf("默认解析器首项应带端口,实际 %q", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-acme/lego/v4/certcrypto"
|
"github.com/go-acme/lego/v4/certcrypto"
|
||||||
legocert "github.com/go-acme/lego/v4/certificate"
|
legocert "github.com/go-acme/lego/v4/certificate"
|
||||||
|
"github.com/go-acme/lego/v4/challenge/dns01"
|
||||||
"github.com/go-acme/lego/v4/lego"
|
"github.com/go-acme/lego/v4/lego"
|
||||||
"github.com/go-acme/lego/v4/registration"
|
"github.com/go-acme/lego/v4/registration"
|
||||||
|
|
||||||
@@ -47,12 +48,12 @@ type Service struct {
|
|||||||
|
|
||||||
func NewService(cfg config.Config, store *certstore.Store, ddnsSvc *ddns.Service, settings *settings.Store, proxySvc *service.ProxyService, logger *slog.Logger, notifySvc *notify.Service) *Service {
|
func NewService(cfg config.Config, store *certstore.Store, ddnsSvc *ddns.Service, settings *settings.Store, proxySvc *service.ProxyService, logger *slog.Logger, notifySvc *notify.Service) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
store: store,
|
store: store,
|
||||||
ddnsSvc: ddnsSvc,
|
ddnsSvc: ddnsSvc,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
proxySvc: proxySvc,
|
proxySvc: proxySvc,
|
||||||
logger: logger.With("module", "ACME"),
|
logger: logger.With("module", "ACME"),
|
||||||
notify: notifySvc,
|
notify: notifySvc,
|
||||||
jobs: NewJobManager(),
|
jobs: NewJobManager(),
|
||||||
expiryAlerted: map[string]string{},
|
expiryAlerted: map[string]string{},
|
||||||
@@ -388,6 +389,9 @@ func (s *Service) resolveCA(ctx context.Context, ca string) (string, error) {
|
|||||||
if err := ValidateCA(ca); err != nil {
|
if err := ValidateCA(ca); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if reason := CAUnavailableReason(ca); reason != "" {
|
||||||
|
return "", fmt.Errorf("%s", reason)
|
||||||
|
}
|
||||||
return ca, nil
|
return ca, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +416,27 @@ func (s *Service) registerACMEAccount(ctx context.Context, client *lego.Client,
|
|||||||
Kid: kid,
|
Kid: kid,
|
||||||
HmacEncoded: hmac,
|
HmacEncoded: hmac,
|
||||||
})
|
})
|
||||||
|
case CALiteSSL:
|
||||||
|
// LiteSSL 的目录里 meta.externalAccountRequired = true:必须带 EAB。
|
||||||
|
// 与 ZeroSSL 不同,不经过厂商 API 换取,而是用户在 FreeSSL 平台自建 EAB 后直接填 kid/hmac。
|
||||||
|
kid, err := s.settings.Get(ctx, settings.KeyLiteSSLEABKid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hmac, err := s.settings.Get(ctx, settings.KeyLiteSSLEABHMAC)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
kid = strings.TrimSpace(kid)
|
||||||
|
hmac = strings.TrimSpace(hmac)
|
||||||
|
if kid == "" || hmac == "" {
|
||||||
|
return nil, fmt.Errorf("LiteSSL 要求 EAB 凭据:请在设置里填写 EAB Kid 与 EAB HMAC(在 FreeSSL 的「证书自动化 → EAB 管理」创建后原样粘贴)")
|
||||||
|
}
|
||||||
|
return client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{
|
||||||
|
TermsOfServiceAgreed: true,
|
||||||
|
Kid: kid,
|
||||||
|
HmacEncoded: hmac,
|
||||||
|
})
|
||||||
default:
|
default:
|
||||||
return client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
return client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||||
}
|
}
|
||||||
@@ -446,7 +471,19 @@ func (s *Service) obtain(ctx context.Context, job *Job, ca, email, provider stri
|
|||||||
return certstore.Record{}, fmt.Errorf("初始化 DNS Provider 失败:%w", err)
|
return certstore.Record{}, fmt.Errorf("初始化 DNS Provider 失败:%w", err)
|
||||||
}
|
}
|
||||||
dnsProvider = wrapDNSProvider(dnsProvider, job)
|
dnsProvider = wrapDNSProvider(dnsProvider, job)
|
||||||
if err := client.Challenge.SetDNS01Provider(dnsProvider); err != nil {
|
// DNS-01 的传播检查由 lego 自己做,它默认读系统 /etc/resolv.conf;
|
||||||
|
// 容器里那通常是 Docker 的 127.0.0.11,它不返回权威 NS,于是报
|
||||||
|
// 「[zone=example.com.] could not determine authoritative nameservers」。
|
||||||
|
// 所以显式指定可靠递归解析器(设置项可改;设为 system 则显式回到系统解析器)。
|
||||||
|
resolvers, err := s.acmeDNSResolvers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return certstore.Record{}, err
|
||||||
|
}
|
||||||
|
var challengeOpts []dns01.ChallengeOption
|
||||||
|
if len(resolvers) > 0 {
|
||||||
|
challengeOpts = append(challengeOpts, dns01.AddRecursiveNameservers(resolvers))
|
||||||
|
}
|
||||||
|
if err := client.Challenge.SetDNS01Provider(dnsProvider, challengeOpts...); err != nil {
|
||||||
return certstore.Record{}, err
|
return certstore.Record{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,6 +542,49 @@ func (s *Service) resolveACMEEmail(ctx context.Context, email string) (string, e
|
|||||||
return email, nil
|
return email, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultACMEDNSResolvers 默认递归解析器(阿里/Cloudflare/Google)。
|
||||||
|
// 容器里的系统解析器常只指向 127.0.0.11,无法用于 DNS-01 的权威 NS 查询;
|
||||||
|
// 设置项写 system 则回到系统解析器,写其它值则按逗号/空格分隔逐条使用。
|
||||||
|
const defaultACMEDNSResolvers = "223.5.5.5:53,1.1.1.1:53,8.8.8.8:53"
|
||||||
|
|
||||||
|
// acmeDNSResolvers 返回本次申请要用的解析器;空 slice 表示交给 lego 读系统解析器。
|
||||||
|
func (s *Service) acmeDNSResolvers(ctx context.Context) ([]string, error) {
|
||||||
|
raw, err := s.settings.Get(ctx, settings.KeyACMEDNSResolvers)
|
||||||
|
if err != nil || strings.TrimSpace(raw) == "" {
|
||||||
|
raw = defaultACMEDNSResolvers
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(raw), "system") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
resolvers := parseResolverList(raw)
|
||||||
|
if len(resolvers) == 0 {
|
||||||
|
return nil, fmt.Errorf("DNS 解析器配置无效(%q):请填 host 或 host:port,多个用逗号分隔;留空用默认值,填 system 用系统解析器", raw)
|
||||||
|
}
|
||||||
|
return resolvers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseResolverList 解析解析器列表:逗号/空格/换行分隔,未写端口时补 53,最多取前 8 个。
|
||||||
|
func parseResolverList(raw string) []string {
|
||||||
|
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||||
|
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == ';'
|
||||||
|
})
|
||||||
|
out := make([]string, 0, len(fields))
|
||||||
|
for _, item := range fields {
|
||||||
|
item = strings.TrimSpace(item)
|
||||||
|
if item == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(item, ":") {
|
||||||
|
item += ":53"
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
if len(out) >= 8 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func validateACMEEmail(email string) error {
|
func validateACMEEmail(email string) error {
|
||||||
addr, err := mail.ParseAddress(email)
|
addr, err := mail.ParseAddress(email)
|
||||||
if err != nil || addr.Address != email {
|
if err != nil || addr.Address != email {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package acme
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -21,13 +22,16 @@ type zeroSSLEABResponse struct {
|
|||||||
} `json:"error"`
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// zeroSSLEABEndpoint 单独成变量,测试可以替换成本地 httptest 服务。
|
||||||
|
var zeroSSLEABEndpoint = "https://api.zerossl.com/acme/eab-credentials"
|
||||||
|
|
||||||
func zerosslEABCredentials(ctx context.Context, apiKey string) (kid, hmac string, err error) {
|
func zerosslEABCredentials(ctx context.Context, apiKey string) (kid, hmac string, err error) {
|
||||||
apiKey = strings.TrimSpace(apiKey)
|
apiKey = strings.TrimSpace(apiKey)
|
||||||
if apiKey == "" {
|
if apiKey == "" {
|
||||||
return "", "", fmt.Errorf("请在设置中配置 ZeroSSL API Key")
|
return "", "", fmt.Errorf("请在设置中配置 ZeroSSL API Key")
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint := "https://api.zerossl.com/acme/eab-credentials?access_key=" + url.QueryEscape(apiKey)
|
endpoint := zeroSSLEABEndpoint + "?access_key=" + url.QueryEscape(apiKey)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
@@ -36,7 +40,9 @@ func zerosslEABCredentials(ctx context.Context, apiKey string) (kid, hmac string
|
|||||||
client := &http.Client{Timeout: 30 * time.Second}
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("请求 ZeroSSL API 失败:%w", err)
|
// 不能把 url.Error 整个包进错误:它的字符串里是带 access_key 的完整 URL,
|
||||||
|
// 而这条错误会进任务日志与 <数据目录>/logs/app.log,等于把密钥写进日志。
|
||||||
|
return "", "", fmt.Errorf("请求 ZeroSSL API 失败:%s", redactURLError(err))
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
@@ -44,19 +50,45 @@ func zerosslEABCredentials(ctx context.Context, apiKey string) (kid, hmac string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", "", fmt.Errorf("ZeroSSL API 返回 %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload zeroSSLEABResponse
|
var payload zeroSSLEABResponse
|
||||||
if err := json.Unmarshal(body, &payload); err != nil {
|
parseErr := json.Unmarshal(body, &payload)
|
||||||
return "", "", fmt.Errorf("解析 ZeroSSL 响应失败:%w", err)
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
// 422 基本都是 access_key 无效(也可能是账号/权限问题)。ZeroSSL 会在
|
||||||
|
// error.type 与 error.code 里写清原因,所以带上它,而不是只回状态码让用户猜。
|
||||||
|
if parseErr == nil && payload.Error.Type != "" {
|
||||||
|
return "", "", fmt.Errorf("ZeroSSL API 返回 %d:%s(错误码 %d)", resp.StatusCode, payload.Error.Type, payload.Error.Code)
|
||||||
|
}
|
||||||
|
return "", "", fmt.Errorf("ZeroSSL API 返回 %d:%s", resp.StatusCode, snippet(body))
|
||||||
|
}
|
||||||
|
if parseErr != nil {
|
||||||
|
return "", "", fmt.Errorf("解析 ZeroSSL 响应失败:%w", parseErr)
|
||||||
}
|
}
|
||||||
if payload.Success != 1 || payload.EABKid == "" || payload.EABHmacKey == "" {
|
if payload.Success != 1 || payload.EABKid == "" || payload.EABHmacKey == "" {
|
||||||
if payload.Error.Type != "" {
|
if payload.Error.Type != "" {
|
||||||
return "", "", fmt.Errorf("ZeroSSL API 错误:%s", payload.Error.Type)
|
return "", "", fmt.Errorf("ZeroSSL API 错误:%s(错误码 %d)", payload.Error.Type, payload.Error.Code)
|
||||||
}
|
}
|
||||||
return "", "", fmt.Errorf("ZeroSSL API 未返回有效的 EAB 凭据")
|
return "", "", fmt.Errorf("ZeroSSL API 未返回有效的 EAB 凭据")
|
||||||
}
|
}
|
||||||
return payload.EABKid, payload.EABHmacKey, nil
|
return payload.EABKid, payload.EABHmacKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// redactURLError 只保留底层错误原因,避免把带 access_key 的 URL 写进日志。
|
||||||
|
func redactURLError(err error) string {
|
||||||
|
var urlErr *url.Error
|
||||||
|
if errors.As(err, &urlErr) && urlErr.Err != nil {
|
||||||
|
return urlErr.Err.Error()
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// snippet 压平并截断响应正文:既不把整页 HTML 灌进日志,也不让换行破坏排版。
|
||||||
|
func snippet(body []byte) string {
|
||||||
|
text := strings.Join(strings.Fields(string(body)), " ")
|
||||||
|
if text == "" {
|
||||||
|
return "(响应体为空)"
|
||||||
|
}
|
||||||
|
if len(text) > 200 {
|
||||||
|
text = text[:200] + "…"
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package acme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// withEndpoint 把 ZeroSSL 端点指向本地测试服务(用完恢复)。
|
||||||
|
func withEndpoint(t *testing.T, handler http.HandlerFunc) {
|
||||||
|
t.Helper()
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
previous := zeroSSLEABEndpoint
|
||||||
|
zeroSSLEABEndpoint = server.URL
|
||||||
|
t.Cleanup(func() {
|
||||||
|
zeroSSLEABEndpoint = previous
|
||||||
|
server.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZeroSSLEABCredentialsSuccess(t *testing.T) {
|
||||||
|
withEndpoint(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.URL.Query().Get("access_key"); got != "good-key" {
|
||||||
|
t.Fatalf("access_key 应原样传出,实际 %q", got)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"success":1,"eab_kid":"kid-1","eab_hmac_key":"hmac-1"}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
kid, hmac, err := zerosslEABCredentials(context.Background(), "good-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("不应报错:%v", err)
|
||||||
|
}
|
||||||
|
if kid != "kid-1" || hmac != "hmac-1" {
|
||||||
|
t.Fatalf("凭据解析错误:kid=%q hmac=%q", kid, hmac)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 422 时要把 ZeroSSL 给的错误类型带出来,而不是只回一个状态码让用户猜
|
||||||
|
func TestZeroSSLEABCredentialsSurfacesErrorType(t *testing.T) {
|
||||||
|
withEndpoint(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
_, _ = w.Write([]byte(`{"success":false,"error":{"code":2902,"type":"invalid_api_key"}}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
_, _, err := zerosslEABCredentials(context.Background(), "bad-key")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("期望报错")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "422") || !strings.Contains(err.Error(), "invalid_api_key") {
|
||||||
|
t.Fatalf("错误里应含状态码与错误类型,实际:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非 JSON 的失败响应:退回截断正文,便于定位(例如网关返回的 HTML)
|
||||||
|
func TestZeroSSLEABCredentialsFallsBackToBodySnippet(t *testing.T) {
|
||||||
|
withEndpoint(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
_, _ = w.Write([]byte("<html><body>\n 502 Bad Gateway\n</body></html>"))
|
||||||
|
})
|
||||||
|
|
||||||
|
_, _, err := zerosslEABCredentials(context.Background(), "any")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "502 Bad Gateway") {
|
||||||
|
t.Fatalf("应带上响应正文摘要,实际:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 网络错误必须脱敏:url.Error 的字符串里带 access_key,泄漏进日志等于泄密
|
||||||
|
func TestZeroSSLEABCredentialsDoesNotLeakKey(t *testing.T) {
|
||||||
|
const key = "SUPERSECRETACCESSKEY"
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||||
|
server.Close() // 立刻关掉,制造连接失败
|
||||||
|
|
||||||
|
previous := zeroSSLEABEndpoint
|
||||||
|
zeroSSLEABEndpoint = server.URL
|
||||||
|
t.Cleanup(func() { zeroSSLEABEndpoint = previous })
|
||||||
|
|
||||||
|
_, _, err := zerosslEABCredentials(context.Background(), key)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("期望请求失败")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), key) {
|
||||||
|
t.Fatalf("错误信息里泄露了 API Key:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServerAuthToken(t *testing.T) {
|
||||||
|
server := &Server{cfg: Config{Token: "secret-token"}}
|
||||||
|
handler := server.auth(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
header string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{name: "正确 Token", header: "Bearer secret-token", status: http.StatusNoContent},
|
||||||
|
{name: "Bearer 大小写不敏感", header: "bearer secret-token", status: http.StatusNoContent},
|
||||||
|
{name: "多余空白可容忍", header: " Bearer secret-token ", status: http.StatusNoContent},
|
||||||
|
{name: "错误 Token", header: "Bearer wrong-token", status: http.StatusUnauthorized},
|
||||||
|
{name: "长度不同的 Token", header: "Bearer secret-token-longer", status: http.StatusUnauthorized},
|
||||||
|
{name: "缺少 Authorization", status: http.StatusUnauthorized},
|
||||||
|
{name: "认证方案错误", header: "Token secret-token", status: http.StatusUnauthorized},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/ping", nil)
|
||||||
|
if tc.header != "" {
|
||||||
|
req.Header.Set("Authorization", tc.header)
|
||||||
|
}
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
handler(recorder, req)
|
||||||
|
if recorder.Code != tc.status {
|
||||||
|
t.Fatalf("期望状态 %d,实际 %d", tc.status, recorder.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecureTokenEqual(t *testing.T) {
|
||||||
|
if !secureTokenEqual("same-token", "same-token") {
|
||||||
|
t.Fatal("相同 Token 应通过")
|
||||||
|
}
|
||||||
|
if secureTokenEqual("short", "longer-token") {
|
||||||
|
t.Fatal("不同 Token 不应通过")
|
||||||
|
}
|
||||||
|
if secureTokenEqual("", "non-empty") {
|
||||||
|
t.Fatal("空 Token 不应通过")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type firewallStatus struct {
|
||||||
|
Tool string `json:"tool"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
PortOpen bool `json:"port_open"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// firewallStatus 只检测主机防火墙,不修改规则;云安全组无法从 VPS 内部感知。
|
||||||
|
func (s *Server) firewallStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
port := 7443
|
||||||
|
if raw := strings.TrimSpace(r.URL.Query().Get("port")); raw != "" {
|
||||||
|
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 65535 {
|
||||||
|
port = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, detectFirewall(port))
|
||||||
|
}
|
||||||
|
|
||||||
|
// firewallAllow 仅在用户显式确认后调用;只自动处理 ufw 与 firewalld。
|
||||||
|
func (s *Server) firewallAllow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Port <= 0 || req.Port > 65535 {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "端口无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
output, err := allowFirewallPort(req.Port)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "output": output, "status": detectFirewall(req.Port)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectFirewall(port int) firewallStatus {
|
||||||
|
if path, err := exec.LookPath("ufw"); err == nil {
|
||||||
|
if out, err := exec.Command(path, "status").Output(); err == nil {
|
||||||
|
active, open := parseUFWStatus(string(out), port)
|
||||||
|
if active {
|
||||||
|
return firewallStatus{Tool: "ufw", Active: true, PortOpen: open}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if path, err := exec.LookPath("firewall-cmd"); err == nil {
|
||||||
|
if out, err := exec.Command(path, "--state").Output(); err == nil && strings.TrimSpace(string(out)) == "running" {
|
||||||
|
return firewallStatus{Tool: "firewalld", Active: true, PortOpen: firewalldPortOpen(path, port)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("nft"); err == nil {
|
||||||
|
return firewallStatus{Tool: "nftables", Active: true, Message: "nftables 需要手工放行,Agent 不会自动修改规则"}
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("iptables"); err == nil {
|
||||||
|
return firewallStatus{Tool: "iptables", Active: true, Message: "iptables 需要手工放行,Agent 不会自动修改规则"}
|
||||||
|
}
|
||||||
|
return firewallStatus{Tool: "none", Message: "未检测到主机防火墙"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUFWStatus 纯解析 ufw status 输出:是否启用、目标端口是否已放行。
|
||||||
|
func parseUFWStatus(output string, port int) (bool, bool) {
|
||||||
|
active := false
|
||||||
|
open := false
|
||||||
|
needle := strconv.Itoa(port)
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "Status:") {
|
||||||
|
active = strings.EqualFold(strings.TrimSpace(strings.TrimPrefix(line, "Status:")), "active")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(line, needle) || !strings.Contains(line, "ALLOW") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) > 0 && (fields[0] == needle || strings.HasPrefix(fields[0], needle+"/")) {
|
||||||
|
open = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return active, open
|
||||||
|
}
|
||||||
|
|
||||||
|
func firewalldPortOpen(path string, port int) bool {
|
||||||
|
out, err := exec.Command(path, "--list-ports").Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, item := range strings.Fields(string(out)) {
|
||||||
|
if strings.HasPrefix(item, strconv.Itoa(port)+"/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowFirewallPort(port int) (string, error) {
|
||||||
|
status := detectFirewall(port)
|
||||||
|
switch status.Tool {
|
||||||
|
case "ufw":
|
||||||
|
out, err := exec.Command("ufw", "allow", strconv.Itoa(port)+"/tcp").CombinedOutput()
|
||||||
|
return strings.TrimSpace(string(out)), err
|
||||||
|
case "firewalld":
|
||||||
|
first, err := exec.Command("firewall-cmd", "--permanent", "--add-port="+strconv.Itoa(port)+"/tcp").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return strings.TrimSpace(string(first)), err
|
||||||
|
}
|
||||||
|
second, err := exec.Command("firewall-cmd", "--reload").CombinedOutput()
|
||||||
|
return strings.TrimSpace(string(first) + "\n" + string(second)), err
|
||||||
|
case "nftables", "iptables":
|
||||||
|
return "", errors.New("当前防火墙需要手工放行,Agent 不会自动修改 nftables / iptables 规则")
|
||||||
|
default:
|
||||||
|
return "", errors.New("未检测到可自动放行的主机防火墙(仅支持 ufw / firewalld)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseUFWStatus(t *testing.T) {
|
||||||
|
output := `Status: active
|
||||||
|
|
||||||
|
To Action From
|
||||||
|
-- ------ ----
|
||||||
|
22/tcp ALLOW Anywhere
|
||||||
|
7443/tcp ALLOW Anywhere
|
||||||
|
`
|
||||||
|
active, open := parseUFWStatus(output, 7443)
|
||||||
|
if !active || !open {
|
||||||
|
t.Fatalf("应识别出 ufw 已启用且 7443 已放行:active=%v open=%v", active, open)
|
||||||
|
}
|
||||||
|
_, open = parseUFWStatus(output, 9443)
|
||||||
|
if open {
|
||||||
|
t.Fatal("未放行的端口不应判定为已放行")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUFWStatusInactive(t *testing.T) {
|
||||||
|
active, open := parseUFWStatus("Status: inactive\n", 7443)
|
||||||
|
if active || open {
|
||||||
|
t.Fatalf("inactive 时不应判定为启用:active=%v open=%v", active, open)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCandidateHTTPSPorts(t *testing.T) {
|
||||||
|
if got := candidateHTTPSPorts(9443); len(got) != 1 || got[0] != 9443 {
|
||||||
|
t.Fatalf("显式端口应只返回该端口:%v", got)
|
||||||
|
}
|
||||||
|
got := candidateHTTPSPorts(0)
|
||||||
|
if len(got) != 3 || got[0] != 7443 || got[1] != 8443 || got[2] != 9443 {
|
||||||
|
t.Fatalf("自动模式应返回 7443/8443/9443:%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/sysinfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// metricsInterval 两次 CPU 采样的最小间隔:间隔内重复调用直接返回上次结果,避免数值抖动
|
||||||
|
metricsInterval = time.Second
|
||||||
|
// maxLogLineBytes 单行日志截断长度,避免一行超长把响应撑大
|
||||||
|
maxLogLineBytes = 4 << 10
|
||||||
|
// maxLogLines 日志尾部行数上限
|
||||||
|
maxLogLines = 2000
|
||||||
|
// maxFRPSConfigBytes frps.toml 回读上限
|
||||||
|
maxFRPSConfigBytes = 256 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// metrics 采集 VPS 主机资源(CPU / 内存 / 磁盘 / 负载 / 运行时长)。
|
||||||
|
// 复用 internal/sysinfo:agent 跑在 VPS 上,采到的天然是 VPS 自身数值。
|
||||||
|
func (s *Server) metrics(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
s.metricsMu.Lock()
|
||||||
|
defer s.metricsMu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if s.metricsCached != nil && now.Sub(s.metricsAt) < metricsInterval {
|
||||||
|
writeJSON(w, http.StatusOK, s.metricsCached)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, sample := sysinfo.Read(s.metricsSample)
|
||||||
|
s.metricsSample = sample
|
||||||
|
payload := map[string]any{
|
||||||
|
"cpu_percent": math.Round(stats.CPUPercent*10) / 10,
|
||||||
|
"mem_used_bytes": stats.MemUsedBytes,
|
||||||
|
"mem_total_bytes": stats.MemTotalBytes,
|
||||||
|
"disk_used_bytes": stats.DiskUsedBytes,
|
||||||
|
"disk_total_bytes": stats.DiskTotalBytes,
|
||||||
|
// 当前只统计根文件系统(与 internal/sysinfo 的 Linux 实现一致)
|
||||||
|
"disk_path": "/",
|
||||||
|
"checked_at": now.UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if load := sysinfo.LoadAvg(); len(load) == 3 {
|
||||||
|
payload["load_avg"] = load
|
||||||
|
}
|
||||||
|
if uptime := sysinfo.UptimeSeconds(); uptime > 0 {
|
||||||
|
payload["uptime_seconds"] = uptime
|
||||||
|
}
|
||||||
|
|
||||||
|
s.metricsAt = now
|
||||||
|
s.metricsCached = payload
|
||||||
|
writeJSON(w, http.StatusOK, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nginxLogs 读取 VPS 上 Nginx 的 access / error 日志尾部。
|
||||||
|
// 路径不接受请求参数:只从 `nginx -T` 的实际生效配置、默认目录或环境变量目录里取,避免变成任意文件读取。
|
||||||
|
func (s *Server) nginxLogs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
kind := strings.TrimSpace(r.URL.Query().Get("type"))
|
||||||
|
if kind != "access" && kind != "error" {
|
||||||
|
kind = "access"
|
||||||
|
}
|
||||||
|
lines := maxLogLines
|
||||||
|
if raw := strings.TrimSpace(r.URL.Query().Get("lines")); raw != "" {
|
||||||
|
if parsed, err := strconv.Atoi(raw); err == nil {
|
||||||
|
lines = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lines <= 0 || lines > maxLogLines {
|
||||||
|
lines = maxLogLines
|
||||||
|
}
|
||||||
|
|
||||||
|
path := s.nginxLogPath(kind)
|
||||||
|
if path == "" {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"type": kind, "path": "", "lines": []string{}, "truncated": false,
|
||||||
|
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content, truncated, err := tailFile(path, lines, maxLogLineBytes)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// 文件不存在不是错误:返回空列表 + 路径,由前端提示「未找到日志文件」
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"type": kind, "path": path, "lines": []string{}, "truncated": false,
|
||||||
|
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
if os.IsPermission(err) {
|
||||||
|
status = http.StatusForbidden
|
||||||
|
}
|
||||||
|
writeJSON(w, status, map[string]any{"error": fmt.Sprintf("读取日志失败:%v", err), "path": path})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"type": kind, "path": path, "lines": content, "truncated": truncated,
|
||||||
|
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// nginxLogPath 解析日志文件路径:优先 `nginx -T` 实际生效配置里的指令,其次环境变量目录,最后默认目录
|
||||||
|
func (s *Server) nginxLogPath(kind string) string {
|
||||||
|
directive := "access_log"
|
||||||
|
if kind == "error" {
|
||||||
|
directive = "error_log"
|
||||||
|
}
|
||||||
|
if nginx := s.nginxPath(); nginx != "" {
|
||||||
|
if out, err := exec.Command(nginx, "-T").CombinedOutput(); err == nil {
|
||||||
|
if path := logPathFromDump(string(out), directive); path != "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir := strings.TrimSpace(os.Getenv("FONU_AGENT_NGINX_LOG_DIR"))
|
||||||
|
if dir == "" {
|
||||||
|
dir = "/var/log/nginx"
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, kind+".log")
|
||||||
|
}
|
||||||
|
|
||||||
|
// logPathFromDump 从 `nginx -T` 输出里取第一条绝对路径的日志指令(跳过 off / stderr / 含变量的写法)
|
||||||
|
func logPathFromDump(dump, directive string) string {
|
||||||
|
pattern := regexp.MustCompile(`(?m)^\s*` + directive + `\s+([^;\s]+)`)
|
||||||
|
for _, match := range pattern.FindAllStringSubmatch(dump, -1) {
|
||||||
|
candidate := strings.TrimSpace(match[1])
|
||||||
|
if candidate == "" || candidate == "off" || candidate == "stderr" || strings.Contains(candidate, "$") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 只接受绝对路径:agent 只跑在 Linux 上,用前导斜杠判定(filepath.IsAbs 在 Windows 上对 /var/... 返回 false)
|
||||||
|
if strings.HasPrefix(candidate, "/") {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// tailFile 从文件末尾倒读最多 lines 行:按块回溯,避免把整个日志读进内存;单行超长时截断
|
||||||
|
func tailFile(path string, lines int, maxLine int) ([]string, bool, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkSize = 32 * 1024
|
||||||
|
var (
|
||||||
|
offset int64 = info.Size()
|
||||||
|
buffer []byte
|
||||||
|
)
|
||||||
|
for offset > 0 && bytes.Count(buffer, []byte("\n")) <= lines {
|
||||||
|
readSize := int64(chunkSize)
|
||||||
|
if offset < readSize {
|
||||||
|
readSize = offset
|
||||||
|
}
|
||||||
|
offset -= readSize
|
||||||
|
block := make([]byte, readSize)
|
||||||
|
if _, err := file.ReadAt(block, offset); err != nil && err != io.EOF {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
buffer = append(block, buffer...)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := strings.Split(strings.TrimRight(string(buffer), "\n"), "\n")
|
||||||
|
truncated := false
|
||||||
|
if len(rows) > lines {
|
||||||
|
rows = rows[len(rows)-lines:]
|
||||||
|
truncated = true
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if len(row) > maxLine {
|
||||||
|
row = row[:maxLine] + "…"
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
return out, truncated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// vpsCertificate VPS 证书目录里的一张证书
|
||||||
|
// 注意:证书按 <CertsDir>/<域名>/fullchain.pem + privkey.pem 存放(与 putCertificate 一致)。
|
||||||
|
type vpsCertificate struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
CertPath string `json:"cert_path"`
|
||||||
|
KeyPath string `json:"key_path"`
|
||||||
|
NotAfter string `json:"not_after,omitempty"`
|
||||||
|
DaysLeft int `json:"days_left"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// certs 列出 VPS 证书目录下的域名与到期时间:用于与 Fonu 证书库交叉核对,也能看到宝塔自行申请的证书
|
||||||
|
func (s *Server) certs(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
dir := strings.TrimSpace(s.cfg.CertsDir)
|
||||||
|
payload := map[string]any{
|
||||||
|
"certs": []vpsCertificate{}, "warnings": []string{}, "dir": dir,
|
||||||
|
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if dir == "" {
|
||||||
|
writeJSON(w, http.StatusOK, payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
writeJSON(w, http.StatusOK, payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
if os.IsPermission(err) {
|
||||||
|
status = http.StatusForbidden
|
||||||
|
}
|
||||||
|
writeJSON(w, status, map[string]string{"error": fmt.Sprintf("读取证书目录失败:%v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
certs := make([]vpsCertificate, 0, len(entries))
|
||||||
|
warnings := make([]string, 0)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
domain := entry.Name()
|
||||||
|
item := vpsCertificate{
|
||||||
|
Domain: domain,
|
||||||
|
CertPath: filepath.Join(dir, domain, "fullchain.pem"),
|
||||||
|
KeyPath: filepath.Join(dir, domain, "privkey.pem"),
|
||||||
|
}
|
||||||
|
notAfter, err := readCertificateNotAfter(item.CertPath)
|
||||||
|
if err != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("%s:%v", domain, err))
|
||||||
|
} else {
|
||||||
|
item.NotAfter = notAfter.UTC().Format(time.RFC3339)
|
||||||
|
item.DaysLeft = int(notAfter.Sub(now).Hours() / 24)
|
||||||
|
}
|
||||||
|
certs = append(certs, item)
|
||||||
|
}
|
||||||
|
// 到期最快的排在前面,便于直接看出哪张该续了
|
||||||
|
sort.Slice(certs, func(i, j int) bool { return certs[i].DaysLeft < certs[j].DaysLeft })
|
||||||
|
|
||||||
|
payload["certs"] = certs
|
||||||
|
payload["warnings"] = warnings
|
||||||
|
writeJSON(w, http.StatusOK, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCertificateNotAfter 解析 PEM 证书的到期时间
|
||||||
|
func readCertificateNotAfter(path string) (time.Time, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode(data)
|
||||||
|
if block == nil {
|
||||||
|
return time.Time{}, fmt.Errorf("不是有效的 PEM 证书")
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
return cert.NotAfter, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nginxReload 显式校验并重载 Nginx,把校验/重载失败的原因回传。
|
||||||
|
// 这里不加 s.mu:validateNginx / reloadNginx 的调用方约定(见 writeRoute 注释)是外部持锁,
|
||||||
|
// 而本端点是用户显式触发的排查动作,不加锁可避免与路由写入相互阻塞。
|
||||||
|
func (s *Server) nginxReload(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
payload := map[string]any{"checked_at": time.Now().UTC().Format(time.RFC3339)}
|
||||||
|
if err := s.validateNginx(); err != nil {
|
||||||
|
payload["ok"] = false
|
||||||
|
payload["test_ok"] = false
|
||||||
|
payload["test_error"] = err.Error()
|
||||||
|
writeJSON(w, http.StatusOK, payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload["test_ok"] = true
|
||||||
|
if err := s.reloadNginx(); err != nil {
|
||||||
|
payload["ok"] = false
|
||||||
|
payload["reload_ok"] = false
|
||||||
|
payload["reload_error"] = err.Error()
|
||||||
|
writeJSON(w, http.StatusOK, payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 重载成功后让 Nginx 可用性探测的缓存失效,避免页面继续显示旧结论
|
||||||
|
s.invalidateNginxProbe()
|
||||||
|
payload["ok"] = true
|
||||||
|
payload["reload_ok"] = true
|
||||||
|
writeJSON(w, http.StatusOK, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// frpsConfig 回读 VPS 上的 frps.toml,供应用侧与「即将下发的内容」比对,检出被手工修改的情况
|
||||||
|
func (s *Server) frpsConfig(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
path := strings.TrimSpace(s.cfg.FrpsConfigPath)
|
||||||
|
payload := map[string]any{
|
||||||
|
"path": path, "exists": false, "content": "", "truncated": false,
|
||||||
|
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
writeJSON(w, http.StatusOK, payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
if os.IsPermission(err) {
|
||||||
|
status = http.StatusForbidden
|
||||||
|
}
|
||||||
|
writeJSON(w, status, map[string]any{"error": fmt.Sprintf("读取 frps 配置失败:%v", err), "path": path})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(data) > maxFRPSConfigBytes {
|
||||||
|
data = data[:maxFRPSConfigBytes]
|
||||||
|
payload["truncated"] = true
|
||||||
|
}
|
||||||
|
payload["exists"] = true
|
||||||
|
payload["content"] = string(data)
|
||||||
|
payload["mod_time"] = info.ModTime().UTC().Format(time.RFC3339)
|
||||||
|
writeJSON(w, http.StatusOK, payload)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTailFile(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "access.log")
|
||||||
|
if err := os.WriteFile(path, []byte("line1\nline2\nline3\nline4\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, truncated, err := tailFile(path, 2, maxLogLineBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tailFile: %v", err)
|
||||||
|
}
|
||||||
|
// 文件里还有更早的行没返回,因此应当标记为截断
|
||||||
|
if !truncated {
|
||||||
|
t.Fatal("只取两行时应标记为截断(还有更早的日志未返回)")
|
||||||
|
}
|
||||||
|
if len(rows) != 2 || rows[0] != "line3" || rows[1] != "line4" {
|
||||||
|
t.Fatalf("unexpected rows: %#v", rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求行数超过文件内容:返回全部且不标记截断
|
||||||
|
rows, truncated, err = tailFile(path, 100, maxLogLineBytes)
|
||||||
|
if err != nil || truncated || len(rows) != 4 {
|
||||||
|
t.Fatalf("unexpected full read: rows=%d truncated=%v err=%v", len(rows), truncated, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单行超长时截断
|
||||||
|
long := strings.Repeat("x", maxLogLineBytes+10)
|
||||||
|
if err := os.WriteFile(path, []byte(long+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
rows, _, err = tailFile(path, 5, maxLogLineBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tailFile: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || !strings.HasSuffix(rows[0], "…") || len(rows[0]) > maxLogLineBytes+3 {
|
||||||
|
t.Fatalf("超长行未被截断:len=%d", len(rows[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件不存在返回错误,由调用方转成「200 + 空列表」
|
||||||
|
if _, _, err := tailFile(filepath.Join(t.TempDir(), "missing.log"), 10, maxLogLineBytes); err == nil {
|
||||||
|
t.Fatal("文件不存在应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogPathFromDump(t *testing.T) {
|
||||||
|
dump := "# configuration file /etc/nginx/nginx.conf:\naccess_log /var/log/nginx/access.log main;\nerror_log /var/log/nginx/error.log warn;\n"
|
||||||
|
if got := logPathFromDump(dump, "access_log"); got != "/var/log/nginx/access.log" {
|
||||||
|
t.Fatalf("unexpected access log: %q", got)
|
||||||
|
}
|
||||||
|
if got := logPathFromDump(dump, "error_log"); got != "/var/log/nginx/error.log" {
|
||||||
|
t.Fatalf("unexpected error log: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// off / stderr / 相对路径 / 含变量 都不采用
|
||||||
|
skip := "access_log off;\naccess_log stderr;\naccess_log logs/access.log;\naccess_log /var/log/nginx/$host.log;\n"
|
||||||
|
if got := logPathFromDump(skip, "access_log"); got != "" {
|
||||||
|
t.Fatalf("expected empty, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadLimitStateFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
missing, err := readLimitStateFile(filepath.Join(dir, "missing.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("文件不存在应返回空状态: %v", err)
|
||||||
|
}
|
||||||
|
if len(missing) != 0 {
|
||||||
|
t.Fatalf("文件不存在应返回空 map,实际 %#v", missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
validPath := filepath.Join(dir, "valid.json")
|
||||||
|
valid := []byte(`{"a.example.com":{"rate":10,"burst":20,"conn":5}}`)
|
||||||
|
if err := os.WriteFile(validPath, valid, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
state, err := readLimitStateFile(validPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := state["a.example.com"]
|
||||||
|
if got.Rate != 10 || got.Burst != 20 || got.Conn != 5 {
|
||||||
|
t.Fatalf("解析结果不正确: %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
brokenPath := filepath.Join(dir, "broken.json")
|
||||||
|
broken := []byte(`{"a.example.com":`)
|
||||||
|
if err := os.WriteFile(brokenPath, broken, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := readLimitStateFile(brokenPath); err == nil {
|
||||||
|
t.Fatal("损坏 JSON 应返回错误")
|
||||||
|
}
|
||||||
|
after, err := os.ReadFile(brokenPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(after) != string(broken) {
|
||||||
|
t.Fatalf("读取失败不应修改原文件: %q", after)
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyPath := filepath.Join(dir, "empty.json")
|
||||||
|
if err := os.WriteFile(emptyPath, nil, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := readLimitStateFile(emptyPath); err == nil {
|
||||||
|
t.Fatal("空文件应返回错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
nullPath := filepath.Join(dir, "null.json")
|
||||||
|
if err := os.WriteFile(nullPath, []byte("null\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := readLimitStateFile(nullPath); err == nil {
|
||||||
|
t.Fatal("null 内容应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateListenAddr(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
addr string
|
||||||
|
allowRemote bool
|
||||||
|
loopback bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"127.0.0.1:7700", false, true, false},
|
||||||
|
{"localhost:7700", false, true, false},
|
||||||
|
{"[::1]:7700", false, true, false},
|
||||||
|
{":7700", false, false, true},
|
||||||
|
{"0.0.0.0:7700", false, false, true},
|
||||||
|
{"0.0.0.0:7700", true, false, false},
|
||||||
|
{"bad-address", false, false, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
loopback, err := validateListenAddr(tc.addr, tc.allowRemote)
|
||||||
|
if tc.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("%s 应拒绝", tc.addr)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s 不应报错: %v", tc.addr, err)
|
||||||
|
}
|
||||||
|
if loopback != tc.loopback {
|
||||||
|
t.Fatalf("%s loopback=%v, want %v", tc.addr, loopback, tc.loopback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateRoute(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
req routeRequest
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"正常", routeRequest{Domain: "a.example.com", Upstream: "127.0.0.1:8080"}, true},
|
||||||
|
{"非法域名", routeRequest{Domain: "bad domain", Upstream: "127.0.0.1:8080"}, false},
|
||||||
|
{"空上游", routeRequest{Domain: "a.example.com"}, false},
|
||||||
|
{"上游带分号", routeRequest{Domain: "a.example.com", Upstream: "127.0.0.1:80; rm -rf /"}, false},
|
||||||
|
{"相对证书目录", routeRequest{Domain: "a.example.com", Upstream: "127.0.0.1:80", TLS: true, CertDir: "certs"}, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
err := validateRoute(c.req)
|
||||||
|
if c.ok && err != nil {
|
||||||
|
t.Errorf("%s:期望通过,实际 %v", c.name, err)
|
||||||
|
}
|
||||||
|
if !c.ok && err == nil {
|
||||||
|
t.Errorf("%s:期望报错", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNginxRouteRendersUpstream:vhost 模板的关键行(标记、server_name、proxy_pass)。
|
||||||
|
// 不构造 Basic Auth 场景——那条路径会往 /var/lib/fonu-agent 写 htpasswd,不适合在单测里触发。
|
||||||
|
func TestNginxRouteRendersUpstream(t *testing.T) {
|
||||||
|
out := nginxRoute(routeRequest{Domain: "a.example.com", Upstream: "127.0.0.1:8080"}, "")
|
||||||
|
if !strings.HasPrefix(out, "# Managed by fonu-agent.") {
|
||||||
|
t.Errorf("缺少托管标记,其它工具无法识别该文件归属:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "server_name a.example.com;") {
|
||||||
|
t.Errorf("缺少 server_name:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "proxy_pass http://127.0.0.1:8080;") {
|
||||||
|
t.Errorf("缺少 proxy_pass 回源:\n%s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "listen 443") {
|
||||||
|
t.Errorf("未启用 TLS 时不应监听 443:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNginxRouteSharedTemplateSemantics 锁定共享模板迁出后的关键语义与顺序。
|
||||||
|
func TestNginxRouteSharedTemplateSemantics(t *testing.T) {
|
||||||
|
out := nginxRoute(routeRequest{
|
||||||
|
Domain: "a.example.com",
|
||||||
|
Upstream: "127.0.0.1:8080",
|
||||||
|
TLS: true,
|
||||||
|
RedirectTLS: true,
|
||||||
|
SecurityHeaders: true,
|
||||||
|
WebSocket: true,
|
||||||
|
AllowIPs: []string{"192.168.1.0/24"},
|
||||||
|
DenyIPs: []string{"8.8.8.8"},
|
||||||
|
RateLimitRate: 10,
|
||||||
|
RateLimitBurst: 20,
|
||||||
|
ConnLimitMax: 5,
|
||||||
|
ChinaOnly: true,
|
||||||
|
}, "/etc/fonu-agent/certs/a.example.com")
|
||||||
|
for _, want := range []string{
|
||||||
|
"ssl_protocols TLSv1.2 TLSv1.3;",
|
||||||
|
`add_header Referrer-Policy "strict-origin-when-cross-origin" always;`,
|
||||||
|
"deny 8.8.8.8;",
|
||||||
|
"if ($fonu_china_client = 0) { return 403; }",
|
||||||
|
"allow 192.168.1.0/24;",
|
||||||
|
"deny all;",
|
||||||
|
"limit_conn fonu_a_example_com_conn 5;",
|
||||||
|
"limit_req zone=fonu_a_example_com_req burst=20 nodelay;",
|
||||||
|
"proxy_set_header Upgrade $http_upgrade;",
|
||||||
|
`proxy_set_header Connection "upgrade";`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("缺少 %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
denyIdx := strings.Index(out, "deny 8.8.8.8;")
|
||||||
|
allowIdx := strings.Index(out, "allow 192.168.1.0/24;")
|
||||||
|
denyAllIdx := strings.Index(out, "deny all;")
|
||||||
|
if denyIdx < 0 || allowIdx < 0 || denyAllIdx < 0 || !(denyIdx < allowIdx && allowIdx < denyAllIdx) {
|
||||||
|
t.Fatalf("allow / deny 顺序不正确:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileSHA256 覆盖 frps 安装包的校验路径。
|
||||||
|
func TestFileSHA256(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "payload.txt")
|
||||||
|
if err := os.WriteFile(path, []byte("hello"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := fileSHA256(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const want = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("sha256 不符:got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var agentRouteParamPattern = regexp.MustCompile(`\{[^}]+\}`)
|
||||||
|
|
||||||
|
// TestAgentRoutesRequireToken 遍历全部 Agent API:匿名请求必须 401,防止新增路由漏挂 auth。
|
||||||
|
func TestAgentRoutesRequireToken(t *testing.T) {
|
||||||
|
server := &Server{cfg: Config{Token: "secret-token"}}
|
||||||
|
routes := agentRoutes(server)
|
||||||
|
if len(routes) < 20 {
|
||||||
|
t.Fatalf("Agent 路由覆盖不足:%d 条", len(routes))
|
||||||
|
}
|
||||||
|
handler := server.Handler()
|
||||||
|
for _, route := range routes {
|
||||||
|
path := agentRouteParamPattern.ReplaceAllString(route.Pattern, "1")
|
||||||
|
req := httptest.NewRequest(route.Method, path, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("路由 %s %s 匿名请求应返回 401,实际 %d", route.Method, route.Pattern, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+512
-168
@@ -5,6 +5,8 @@ import (
|
|||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -23,6 +25,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/fsutil"
|
||||||
|
"github.com/fonu/fonu/internal/nginxtmpl"
|
||||||
|
"github.com/fonu/fonu/internal/sysinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentVersion 随每次 agent 行为变更手动递增;status/ping 与安装脚本都会回报,用于确认 VPS 上跑的是哪个版本。
|
// AgentVersion 随每次 agent 行为变更手动递增;status/ping 与安装脚本都会回报,用于确认 VPS 上跑的是哪个版本。
|
||||||
@@ -31,11 +37,36 @@ import (
|
|||||||
// 0.9.0:Nginx 可用性显式探测(status 暴露 nginx_ok/nginx_missing)、部署前预检、一键安装 Nginx 端点。
|
// 0.9.0:Nginx 可用性显式探测(status 暴露 nginx_ok/nginx_missing)、部署前预检、一键安装 Nginx 端点。
|
||||||
// 0.9.1:status 补充 nginx_running / nginx_version,供页面展示 Nginx 状态卡片。
|
// 0.9.1:status 补充 nginx_running / nginx_version,供页面展示 Nginx 状态卡片。
|
||||||
// 0.9.2:Basic Auth 哈希改为纯 Go apr1(去掉对 VPS openssl 的依赖);安装脚本 token 改用 /dev/urandom。
|
// 0.9.2:Basic Auth 哈希改为纯 Go apr1(去掉对 VPS openssl 的依赖);安装脚本 token 改用 /dev/urandom。
|
||||||
const AgentVersion = "0.9.2"
|
// 0.10.0:新增 /api/v1/metrics 主机资源采集、/api/v1/logs/nginx 日志读取、/api/v1/frps/config 配置回读。
|
||||||
|
// 0.11.0:新增 /api/v1/certs 证书清单与 /api/v1/nginx/reload 显式重载(回传校验/重载失败原因)。
|
||||||
|
// 0.12.0:frps 安装包的 proxy 参数改白名单 + 下载后校验 SHA-256;Basic Auth 自动生成的密码不再写进
|
||||||
|
// nginx 配置注释(改为只在 PUT 响应里回一次)。
|
||||||
|
// 0.12.1:frps 启停失败如实回报(不再无条件写「已启动」);HTTP 服务补 ReadTimeout/IdleTimeout。
|
||||||
|
// 0.12.2:新增域名 vhost 的版本列表 / 对比 / 回滚端点;写配置前留版本,校验失败时恢复上一个可用版本。
|
||||||
|
// 0.12.3:frps 日志尾巴改为最近 20 行、最新在前,并用 short-iso 时间戳(默认格式不带年份、分不清哪一天)。
|
||||||
|
// 0.12.4:日志行重写成「2026-09-22 18:43:34 进程: 消息」,去掉 T 分隔、时区偏移与每行重复的主机名。
|
||||||
|
// 0.12.5:修正 frps 官方校验文件名(frp 一直叫 frp_sha256_checksums.txt,原代码拼的是 frp_<版本>_sha256sums.txt,导致首次安装报 HTTP 404)。
|
||||||
|
// 0.12.6:nginxPath 探测不到时返回空串(原来回落到配置里的裸名字 "nginx"),
|
||||||
|
// 修掉「VPS 未安装 Nginx」告警与一键安装按钮永远不出现、以及把它误报成「配置校验未通过」的问题。
|
||||||
|
// 0.12.7:vhost 目录探测不再要求被加载文件带 .conf 后缀(Debian 的 sites-enabled/default 不带),
|
||||||
|
// 并在失败信息里带上最后一次的具体原因与常见的显式指定目录。
|
||||||
|
// 0.12.8:Agent Bearer Token 改为常量时间比较,避免认证路径上的长度/内容时序泄漏。
|
||||||
|
// 0.12.9:限流状态文件损坏时拒绝覆盖,避免保存单条规则时清空其他域名的限流配置。
|
||||||
|
// 0.13.0:默认只监听回环地址,非回环监听必须显式允许;状态接口暴露监听范围与传输安全。
|
||||||
|
// 0.13.1:nginx 版本备份目录改为隐藏目录(.<name>.versions)并自动迁移旧目录,
|
||||||
|
// 修掉旧 <name>.versions 目录被 include * 当配置文件读取、nginx -t 报 pread() "Is a directory" 的问题。
|
||||||
|
// 0.13.2:nginx -T 探测失败时先从报错文本解析旧 <name>.versions 目录并迁移后重试,
|
||||||
|
// 修掉「配置目录还没探测出来、迁移逻辑没机会执行」导致 HTTPS 直连持续失败的问题。
|
||||||
|
// 0.13.3:HTTPS 反代改用 default_server 并在 reload 后做本机证书指纹自检,
|
||||||
|
// 证书不匹配时回滚并回报实际指纹与端口占用进程,区分「nginx 未生效」与「公网链路被接管」。
|
||||||
|
// 0.14.0:HTTPS 端口自动选择(7443/8443/9443)、当前证书信息查询、主机防火墙检测与 ufw/firewalld 显式放行。
|
||||||
|
// 0.14.1:vhost 模板迁出到 internal/nginxtmpl,统一 TLS、安全头、allow/deny、WebSocket 与限流输出。
|
||||||
|
const AgentVersion = "0.14.1"
|
||||||
|
|
||||||
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ListenAddr string
|
ListenAddr string
|
||||||
|
AllowRemote bool
|
||||||
Token string
|
Token string
|
||||||
NginxConfDir string
|
NginxConfDir string
|
||||||
NginxBinary string
|
NginxBinary string
|
||||||
@@ -47,12 +78,21 @@ type Config struct {
|
|||||||
|
|
||||||
// Server 暴露 agent HTTP 服务。
|
// Server 暴露 agent HTTP 服务。
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
mu sync.Mutex
|
listenLoopback bool
|
||||||
|
mu sync.Mutex
|
||||||
|
// metrics 采样缓存:CPU 占用率需要两次采样,间隔内的重复请求直接复用上次结果
|
||||||
|
metricsMu sync.Mutex
|
||||||
|
metricsAt time.Time
|
||||||
|
metricsSample sysinfo.CPUSample
|
||||||
|
metricsCached map[string]any
|
||||||
// nginx 可用性探测缓存:status 轮询频繁,避免每次都起 nginx -t 进程
|
// nginx 可用性探测缓存:status 轮询频繁,避免每次都起 nginx -t 进程
|
||||||
nginxStatusMu sync.Mutex
|
nginxStatusMu sync.Mutex
|
||||||
nginxStatusAt time.Time
|
nginxStatusAt time.Time
|
||||||
nginxStatus nginxProbe
|
nginxStatus nginxProbe
|
||||||
|
// 测试注入钩子;生产环境为 nil,走真实 nginx 校验与重载
|
||||||
|
validateNginxFn func() error
|
||||||
|
reloadNginxFn func() error
|
||||||
}
|
}
|
||||||
|
|
||||||
// nginxProbe Nginx 可用性探测结果。
|
// nginxProbe Nginx 可用性探测结果。
|
||||||
@@ -103,7 +143,11 @@ var domainPattern = regexp.MustCompile(`^(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])
|
|||||||
|
|
||||||
func NewServer(cfg Config) (*Server, error) {
|
func NewServer(cfg Config) (*Server, error) {
|
||||||
if strings.TrimSpace(cfg.ListenAddr) == "" {
|
if strings.TrimSpace(cfg.ListenAddr) == "" {
|
||||||
cfg.ListenAddr = ":7700"
|
cfg.ListenAddr = "127.0.0.1:7700"
|
||||||
|
}
|
||||||
|
loopback, err := validateListenAddr(cfg.ListenAddr, cfg.AllowRemote)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(cfg.NginxConfDir) == "" {
|
if strings.TrimSpace(cfg.NginxConfDir) == "" {
|
||||||
// 目录探测失败不阻断启动:agent 照常提供状态接口,路由写入时重试探测(systemd PATH 下可能找不到 nginx,写入前会补全路径)
|
// 目录探测失败不阻断启动:agent 照常提供状态接口,路由写入时重试探测(systemd PATH 下可能找不到 nginx,写入前会补全路径)
|
||||||
@@ -139,32 +183,90 @@ func NewServer(cfg Config) (*Server, error) {
|
|||||||
return nil, fmt.Errorf("create agent directory: %w", err)
|
return nil, fmt.Errorf("create agent directory: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
server := &Server{cfg: cfg}
|
server := &Server{cfg: cfg, listenLoopback: loopback}
|
||||||
// 启动后台预解析目录(不阻断监听);每次启动都会用最新探测逻辑重新解析并覆盖落盘缓存,
|
// 启动后台预解析目录(不阻断监听);每次启动都会用最新探测逻辑重新解析并覆盖落盘缓存,
|
||||||
// 历史错误目录的缓存会被自动修正。
|
// 历史错误目录的缓存会被自动修正。
|
||||||
server.preloadNginxConfDir()
|
server.preloadNginxConfDir()
|
||||||
return server, nil
|
return server, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateListenAddr(addr string, allowRemote bool) (bool, error) {
|
||||||
|
host, _, err := net.SplitHostPort(strings.TrimSpace(addr))
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("Agent 监听地址无效: %w", err)
|
||||||
|
}
|
||||||
|
host = strings.Trim(host, "[]")
|
||||||
|
loopback := strings.EqualFold(host, "localhost")
|
||||||
|
if ip := net.ParseIP(host); ip != nil {
|
||||||
|
loopback = ip.IsLoopback()
|
||||||
|
}
|
||||||
|
if !loopback && !allowRemote {
|
||||||
|
return false, fmt.Errorf("检测到 Agent 将监听非回环地址 %s;默认安全模式只监听 127.0.0.1:7700。如确认需要远程监听,请设置 FONU_AGENT_ALLOW_REMOTE=1", addr)
|
||||||
|
}
|
||||||
|
return loopback, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenScope(loopback bool) string {
|
||||||
|
if loopback {
|
||||||
|
return "loopback"
|
||||||
|
}
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
func transportSecurity(loopback bool) string {
|
||||||
|
if loopback {
|
||||||
|
return "loopback"
|
||||||
|
}
|
||||||
|
return "plaintext-http"
|
||||||
|
}
|
||||||
|
|
||||||
|
type agentRoute struct {
|
||||||
|
Method string
|
||||||
|
Pattern string
|
||||||
|
Handler http.HandlerFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentRoutes 集中声明 Agent API;Handler 统一包 auth,测试据此遍历验证无 Token 必须 401。
|
||||||
|
func agentRoutes(s *Server) []agentRoute {
|
||||||
|
return []agentRoute{
|
||||||
|
{http.MethodGet, "/api/v1/ping", s.ping},
|
||||||
|
{http.MethodGet, "/api/v1/status", s.status},
|
||||||
|
{http.MethodGet, "/api/v1/routes", s.routes},
|
||||||
|
{http.MethodPut, "/api/v1/routes", s.putRoute},
|
||||||
|
{http.MethodGet, "/api/v1/routes/{domain}/conf", s.routeConf},
|
||||||
|
{http.MethodGet, "/api/v1/routes/{domain}/versions", s.routeVersions},
|
||||||
|
{http.MethodGet, "/api/v1/routes/{domain}/versions/{name}", s.routeVersion},
|
||||||
|
{http.MethodPost, "/api/v1/routes/{domain}/versions/{name}/rollback", s.rollbackRoute},
|
||||||
|
{http.MethodPut, "/api/v1/china-cidr", s.putChinaCIDR},
|
||||||
|
{http.MethodPost, "/api/v1/nginx/install", s.installNginx},
|
||||||
|
{http.MethodPut, "/api/v1/transport/https", s.putHTTPSProxy},
|
||||||
|
{http.MethodGet, "/api/v1/transport/https", s.getHTTPSProxy},
|
||||||
|
{http.MethodDelete, "/api/v1/transport/https", s.deleteHTTPSProxy},
|
||||||
|
{http.MethodGet, "/api/v1/firewall", s.firewallStatus},
|
||||||
|
{http.MethodPost, "/api/v1/firewall/allow", s.firewallAllow},
|
||||||
|
{http.MethodDelete, "/api/v1/routes/{domain}", s.deleteRoute},
|
||||||
|
{http.MethodPut, "/api/v1/frps/config", s.putFRPSConfig},
|
||||||
|
{http.MethodPost, "/api/v1/frps/install", s.installFRPS},
|
||||||
|
{http.MethodPost, "/api/v1/frps/action", s.frpsAction},
|
||||||
|
{http.MethodPost, "/api/v1/certs", s.putCertificate},
|
||||||
|
{http.MethodGet, "/api/v1/metrics", s.metrics},
|
||||||
|
{http.MethodGet, "/api/v1/logs/nginx", s.nginxLogs},
|
||||||
|
{http.MethodGet, "/api/v1/frps/config", s.frpsConfig},
|
||||||
|
{http.MethodGet, "/api/v1/certs", s.certs},
|
||||||
|
{http.MethodPost, "/api/v1/nginx/reload", s.nginxReload},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /api/v1/ping", s.auth(s.ping))
|
for _, route := range agentRoutes(s) {
|
||||||
mux.HandleFunc("GET /api/v1/status", s.auth(s.status))
|
mux.HandleFunc(route.Method+" "+route.Pattern, s.auth(route.Handler))
|
||||||
mux.HandleFunc("GET /api/v1/routes", s.auth(s.routes))
|
}
|
||||||
mux.HandleFunc("PUT /api/v1/routes", s.auth(s.putRoute))
|
|
||||||
mux.HandleFunc("GET /api/v1/routes/{domain}/conf", s.auth(s.routeConf))
|
|
||||||
mux.HandleFunc("PUT /api/v1/china-cidr", s.auth(s.putChinaCIDR))
|
|
||||||
mux.HandleFunc("POST /api/v1/nginx/install", s.auth(s.installNginx))
|
|
||||||
mux.HandleFunc("DELETE /api/v1/routes/{domain}", s.auth(s.deleteRoute))
|
|
||||||
mux.HandleFunc("PUT /api/v1/frps/config", s.auth(s.putFRPSConfig))
|
|
||||||
mux.HandleFunc("POST /api/v1/frps/install", s.auth(s.installFRPS))
|
|
||||||
mux.HandleFunc("POST /api/v1/frps/action", s.auth(s.frpsAction))
|
|
||||||
mux.HandleFunc("POST /api/v1/certs", s.auth(s.putCertificate))
|
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ListenAndServe(ctx context.Context) error {
|
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||||
server := &http.Server{Addr: s.cfg.ListenAddr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second}
|
server := &http.Server{Addr: s.cfg.ListenAddr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 60 * time.Second, IdleTimeout: 2 * time.Minute}
|
||||||
go func() {
|
go func() {
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
@@ -176,7 +278,8 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
|||||||
|
|
||||||
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Header.Get("Authorization") != "Bearer "+s.cfg.Token {
|
token := bearerToken(r)
|
||||||
|
if token == "" || !secureTokenEqual(token, s.cfg.Token) {
|
||||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -184,6 +287,21 @@ func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bearerToken 解析 Authorization: Bearer <token>;格式不符时返回空串。
|
||||||
|
func bearerToken(r *http.Request) string {
|
||||||
|
header := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||||
|
const prefix = "Bearer "
|
||||||
|
if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(header[len(prefix):])
|
||||||
|
}
|
||||||
|
|
||||||
|
// secureTokenEqual 使用常量时间比较,避免通过响应时间推测 Token 内容。
|
||||||
|
func secureTokenEqual(got, want string) bool {
|
||||||
|
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) ping(w http.ResponseWriter, _ *http.Request) {
|
func (s *Server) ping(w http.ResponseWriter, _ *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "service": "fonu-agent"})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "service": "fonu-agent"})
|
||||||
}
|
}
|
||||||
@@ -192,29 +310,36 @@ func (s *Server) status(w http.ResponseWriter, _ *http.Request) {
|
|||||||
ports := map[string]bool{"80": portListening(80), "443": portListening(443), "7000": portListening(7000), "8080": portListening(8080)}
|
ports := map[string]bool{"80": portListening(80), "443": portListening(443), "7000": portListening(7000), "8080": portListening(8080)}
|
||||||
nginxProbe := s.probeNginx()
|
nginxProbe := s.probeNginx()
|
||||||
frpsActive := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil
|
frpsActive := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil
|
||||||
|
httpsState, httpsConfigured := readHTTPSProxyState(s.cfg.DataDir)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"service": "fonu-agent",
|
"service": "fonu-agent",
|
||||||
"agent_version": AgentVersion,
|
"agent_version": AgentVersion,
|
||||||
"nginx_binary": s.cfg.NginxBinary,
|
"listen_addr": s.cfg.ListenAddr,
|
||||||
"nginx_conf_dir": s.cfg.NginxConfDir,
|
"listen_scope": listenScope(s.listenLoopback),
|
||||||
"nginx_ok": nginxProbe.OK,
|
"remote_access_enabled": s.cfg.AllowRemote,
|
||||||
"nginx_missing": nginxProbe.Missing,
|
"transport_security": transportSecurity(s.listenLoopback),
|
||||||
"nginx_running": nginxProbe.Running,
|
"https_proxy_configured": httpsConfigured,
|
||||||
"nginx_path": nginxProbe.Path,
|
"https_proxy_port": httpsState.Port,
|
||||||
"nginx_version": nginxProbe.Version,
|
"nginx_binary": s.cfg.NginxBinary,
|
||||||
"nginx_error": nginxProbe.Error,
|
"nginx_conf_dir": s.cfg.NginxConfDir,
|
||||||
"frps_binary": s.cfg.FrpsBinary,
|
"nginx_ok": nginxProbe.OK,
|
||||||
"frps_config_path": s.cfg.FrpsConfigPath,
|
"nginx_missing": nginxProbe.Missing,
|
||||||
"ports": ports,
|
"nginx_running": nginxProbe.Running,
|
||||||
"frps_active": frpsActive,
|
"nginx_path": nginxProbe.Path,
|
||||||
"frps_vhost_hint": "curl -H 'Host: 你的域名' http://127.0.0.1:8080 可测试 frps vhost 是否通",
|
"nginx_version": nginxProbe.Version,
|
||||||
"frps_unit_exec": strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")),
|
"nginx_error": nginxProbe.Error,
|
||||||
"frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil,
|
"frps_binary": s.cfg.FrpsBinary,
|
||||||
"frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)),
|
"frps_config_path": s.cfg.FrpsConfigPath,
|
||||||
"frps_log_tail": strings.TrimSpace(journalTail("frps", 6)),
|
"ports": ports,
|
||||||
"dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)),
|
"frps_active": frpsActive,
|
||||||
"routes": s.routeNames(),
|
"frps_vhost_hint": "curl -H 'Host: 你的域名' http://127.0.0.1:8080 可测试 frps vhost 是否通",
|
||||||
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
"frps_unit_exec": strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")),
|
||||||
|
"frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil,
|
||||||
|
"frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)),
|
||||||
|
"frps_log_tail": strings.TrimSpace(journalTailDesc("frps", frpsLogTailLines)),
|
||||||
|
"dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)),
|
||||||
|
"routes": s.routeNames(),
|
||||||
|
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +348,36 @@ type frpsInstallRequest struct {
|
|||||||
Proxy string `json:"proxy,omitempty"`
|
Proxy string `json:"proxy,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// frps 安装包只允许从官方 release 下载,或经这几个加速前缀。白名单与 internal/frp/runtime.go 的
|
||||||
|
// downloadProxyOptions 同源(agent 不依赖应用侧包,这里保留一份副本,改一处需同步另一处)。
|
||||||
|
var frpsDownloadProxies = map[string]struct{}{
|
||||||
|
"": {},
|
||||||
|
"https://gh-proxy.org/": {},
|
||||||
|
"https://v4.gh-proxy.org/": {},
|
||||||
|
"https://cdn.gh-proxy.org/": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const frpsReleaseBaseURL = "https://github.com/fatedier/frp/releases/download/"
|
||||||
|
|
||||||
|
// fetchChecksumForCandidates 依次尝试官方校验文件名的候选。
|
||||||
|
// frp 的校验文件一直叫 frp_sha256_checksums.txt(不带版本号),旧名字保留兜底,
|
||||||
|
// 避开上游改名后整个安装流程直接 404。
|
||||||
|
func fetchChecksumForCandidates(releaseURL, version, asset string) (string, error) {
|
||||||
|
candidates := []string{
|
||||||
|
"frp_sha256_checksums.txt",
|
||||||
|
"frp_" + version + "_sha256sums.txt",
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
for _, name := range candidates {
|
||||||
|
sum, err := fetchChecksumFor(releaseURL+name, asset)
|
||||||
|
if err == nil {
|
||||||
|
return sum, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("%v(已尝试:%s)", lastErr, strings.Join(candidates, "、"))
|
||||||
|
}
|
||||||
|
|
||||||
// installFRPS 下载指定版本的 frp release、安装 frps 二进制并注册 systemd 服务(幂等,重复调用=升级)。
|
// installFRPS 下载指定版本的 frp release、安装 frps 二进制并注册 systemd 服务(幂等,重复调用=升级)。
|
||||||
func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
|
||||||
var req frpsInstallRequest
|
var req frpsInstallRequest
|
||||||
@@ -240,10 +395,14 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported agent arch: " + runtime.GOARCH})
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported agent arch: " + runtime.GOARCH})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
downloadURL := fmt.Sprintf("https://github.com/fatedier/frp/releases/download/v%s/frp_%s_linux_%s.tar.gz", version, version, arch)
|
proxy := strings.TrimSpace(req.Proxy)
|
||||||
if proxy := strings.TrimSpace(req.Proxy); proxy != "" {
|
if _, ok := frpsDownloadProxies[proxy]; !ok {
|
||||||
downloadURL = strings.TrimRight(proxy, "/") + "/" + downloadURL
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "不支持的下载加速代理"})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
asset := fmt.Sprintf("frp_%s_linux_%s.tar.gz", version, arch)
|
||||||
|
releaseURL := proxy + frpsReleaseBaseURL + "v" + version + "/"
|
||||||
|
downloadURL := releaseURL + asset
|
||||||
workDir := filepath.Join(s.cfg.DataDir, "tmp-frps-"+version)
|
workDir := filepath.Join(s.cfg.DataDir, "tmp-frps-"+version)
|
||||||
_ = os.RemoveAll(workDir)
|
_ = os.RemoveAll(workDir)
|
||||||
defer os.RemoveAll(workDir)
|
defer os.RemoveAll(workDir)
|
||||||
@@ -251,11 +410,26 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 先取官方校验文件,再下载安装包并按 SHA-256 校验:避免装上来路不明的 frps 可执行文件
|
||||||
|
wantSum, err := fetchChecksumForCandidates(releaseURL, version, asset)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "获取 frps 官方校验信息失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
tarball := filepath.Join(workDir, "frp.tar.gz")
|
tarball := filepath.Join(workDir, "frp.tar.gz")
|
||||||
if err := downloadToFile(downloadURL, tarball); err != nil {
|
if err := downloadToFile(downloadURL, tarball); err != nil {
|
||||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "下载 frps 失败: " + err.Error()})
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "下载 frps 失败: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
gotSum, err := fileSHA256(tarball)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "校验 frps 安装包失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(gotSum, wantSum) {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "frps 安装包 SHA-256 校验失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
installPath := s.cfg.FrpsBinary
|
installPath := s.cfg.FrpsBinary
|
||||||
if !filepath.IsAbs(installPath) {
|
if !filepath.IsAbs(installPath) {
|
||||||
installPath = "/usr/local/bin/frps"
|
installPath = "/usr/local/bin/frps"
|
||||||
@@ -264,7 +438,7 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "解压 frps 失败: " + err.Error()})
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "解压 frps 失败: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
notes := []string{"frps 二进制已安装: " + installPath}
|
notes := []string{"frps 二进制已安装: " + installPath, "安装包 SHA-256 校验通过"}
|
||||||
unitPath := "/etc/systemd/system/frps.service"
|
unitPath := "/etc/systemd/system/frps.service"
|
||||||
writeUnit := false
|
writeUnit := false
|
||||||
if _, err := os.Stat(unitPath); errors.Is(err, os.ErrNotExist) {
|
if _, err := os.Stat(unitPath); errors.Is(err, os.ErrNotExist) {
|
||||||
@@ -294,18 +468,27 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = exec.Command("systemctl", "daemon-reload").Run()
|
_ = exec.Command("systemctl", "daemon-reload").Run()
|
||||||
}
|
}
|
||||||
// nohup 手工跑的 frps 让位给 systemd;已在 systemd 下运行则重启以加载新二进制
|
// nohup 手工跑的 frps 让位给 systemd;已在 systemd 下运行则重启以加载新二进制
|
||||||
if exec.Command("systemctl", "is-active", "--quiet", "frps").Run() != nil {
|
active := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil
|
||||||
if exec.Command("pgrep", "-x", "frps").Run() == nil {
|
if !active && exec.Command("pgrep", "-x", "frps").Run() == nil {
|
||||||
_ = exec.Command("pkill", "-x", "frps").Run()
|
_ = exec.Command("pkill", "-x", "frps").Run()
|
||||||
time.Sleep(time.Second)
|
time.Sleep(time.Second)
|
||||||
notes = append(notes, "已停止 nohup 方式运行的旧 frps 进程")
|
notes = append(notes, "已停止 nohup 方式运行的旧 frps 进程")
|
||||||
}
|
|
||||||
_ = exec.Command("systemctl", "enable", "--now", "frps").Run()
|
|
||||||
notes = append(notes, "frps.service 已启动")
|
|
||||||
} else {
|
|
||||||
_ = exec.Command("systemctl", "restart", "frps").Run()
|
|
||||||
notes = append(notes, "frps.service 已重启以加载新版本")
|
|
||||||
}
|
}
|
||||||
|
args := []string{"restart", "frps"}
|
||||||
|
label := "已重启以加载新版本"
|
||||||
|
if !active {
|
||||||
|
args = []string{"enable", "--now", "frps"}
|
||||||
|
label = "已启动"
|
||||||
|
}
|
||||||
|
// 启动失败必须如实回报:之前忽略 err 后无条件写「已启动」,运维会以为服务在跑
|
||||||
|
if out, err := exec.Command("systemctl", args...).CombinedOutput(); err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ok": false, "version": version, "binary": installPath, "notes": notes,
|
||||||
|
"error": fmt.Sprintf("frps 二进制已安装,但 systemctl %s 失败:%v:%s", strings.Join(args, " "), err, tailText(string(out), 400)),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notes = append(notes, "frps.service "+label)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version, "binary": installPath, "notes": notes})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version, "binary": installPath, "notes": notes})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,6 +550,43 @@ func downloadToFile(url, path string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchChecksumFor 取官方 sha256sums.txt 中 asset 对应的校验值(格式:<sha256> <文件名>)。
|
||||||
|
func fetchChecksumFor(url, asset string) (string, error) {
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) >= 2 && strings.TrimPrefix(fields[1], "*") == asset {
|
||||||
|
return fields[0], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("校验文件中未找到 %s", asset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileSHA256(path string) (string, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
hash := sha256.New()
|
||||||
|
if _, err := io.Copy(hash, file); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
// extractFile 从 tar.gz 中取出 name 对应的单个文件写入 dest。
|
// extractFile 从 tar.gz 中取出 name 对应的单个文件写入 dest。
|
||||||
func extractFile(tarball, name, dest string, mode os.FileMode) error {
|
func extractFile(tarball, name, dest string, mode os.FileMode) error {
|
||||||
file, err := os.Open(tarball)
|
file, err := os.Open(tarball)
|
||||||
@@ -436,12 +656,65 @@ func frpsVersion(binary string) string {
|
|||||||
return string(output)
|
return string(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
func journalTail(unit string, lines int) string {
|
// frpsLogTailLines 服务端详情里展示的 frps 日志行数
|
||||||
output, err := exec.Command("journalctl", "-u", unit, "-n", strconv.Itoa(lines), "--no-pager").CombinedOutput()
|
const frpsLogTailLines = 20
|
||||||
|
|
||||||
|
// journalLinePattern 匹配 short-iso 的日志首段:时间戳 + 主机名 + 其余(进程与消息)
|
||||||
|
var journalLinePattern = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:[+-]\d{2}:?\d{2}|Z)\s+\S+\s+(.*)$`)
|
||||||
|
|
||||||
|
// formatJournalLine 把 journalctl 的行重写成「2026-09-22 18:43:34 进程: 消息」。
|
||||||
|
// short-iso 的 T 分隔与 +08:00 偏移看着费力,主机名在单机视图里每行都一样(纯噪声),
|
||||||
|
// 认不出来格式的行原样返回,不丢内容。
|
||||||
|
func formatJournalLine(line string) string {
|
||||||
|
matched := journalLinePattern.FindStringSubmatch(line)
|
||||||
|
if matched == nil {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
return matched[1] + " " + matched[2] + " " + matched[3]
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatJournalLog 逐行重写整段日志
|
||||||
|
func formatJournalLog(text string) string {
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rows := strings.Split(strings.TrimRight(text, "\n"), "\n")
|
||||||
|
for i, row := range rows {
|
||||||
|
rows[i] = formatJournalLine(row)
|
||||||
|
}
|
||||||
|
return strings.Join(rows, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// journalTailDesc 取 unit 的最近 lines 行系统日志,按「最新在前」返回。
|
||||||
|
// 用 short-iso 时间戳:journalctl 默认格式只有 "Sep 25 13:20:04",跨天排查时分不清是哪一天。
|
||||||
|
func journalTailDesc(unit string, lines int) string {
|
||||||
|
if lines <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
output, err := exec.Command("journalctl", "-u", unit, "-n", strconv.Itoa(lines), "-o", "short-iso", "--no-pager").CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return string(output)
|
if strings.TrimSpace(string(output)) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rows := strings.Split(strings.TrimRight(string(output), "\n"), "\n")
|
||||||
|
// journalctl 输出是「旧 → 新」,倒过来让最先看到的是最新一条
|
||||||
|
for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
rows[i], rows[j] = rows[j], rows[i]
|
||||||
|
}
|
||||||
|
for i, row := range rows {
|
||||||
|
rows[i] = formatJournalLine(row)
|
||||||
|
}
|
||||||
|
return strings.Join(rows, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func journalTail(unit string, lines int) string {
|
||||||
|
output, err := exec.Command("journalctl", "-u", unit, "-n", strconv.Itoa(lines), "-o", "short-iso", "--no-pager").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return formatJournalLog(string(output))
|
||||||
}
|
}
|
||||||
|
|
||||||
// nginxBinaryPath 解析实际可用的 nginx 可执行文件:优先传入名称(PATH/绝对路径),
|
// nginxBinaryPath 解析实际可用的 nginx 可执行文件:优先传入名称(PATH/绝对路径),
|
||||||
@@ -483,7 +756,14 @@ func detectNginxConfDir(binary string) (string, error) {
|
|||||||
if nginx == "" {
|
if nginx == "" {
|
||||||
return "", fmt.Errorf("未找到可用的 nginx 可执行文件;请先在 VPS 安装 Nginx(apt install nginx / yum install nginx),或用 FONU_AGENT_NGINX_BIN 显式指定路径")
|
return "", fmt.Errorf("未找到可用的 nginx 可执行文件;请先在 VPS 安装 Nginx(apt install nginx / yum install nginx),或用 FONU_AGENT_NGINX_BIN 显式指定路径")
|
||||||
}
|
}
|
||||||
|
// 旧版 Agent 可能把 <配置>.versions 目录留在 include 范围内,先按常见目录清理,避免 nginx -T 直接失败。
|
||||||
|
for _, dir := range []string{"/etc/nginx/sites-enabled", "/etc/nginx/conf.d", "/etc/nginx"} {
|
||||||
|
migrateLegacyVersionDirs(dir)
|
||||||
|
}
|
||||||
out, err := exec.Command(nginx, "-T").CombinedOutput()
|
out, err := exec.Command(nginx, "-T").CombinedOutput()
|
||||||
|
if err != nil && migrateVersionDirsFromNginxOutput(string(out)) {
|
||||||
|
out, err = exec.Command(nginx, "-T").CombinedOutput()
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("nginx -T 执行失败: %w", err)
|
return "", fmt.Errorf("nginx -T 执行失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -492,9 +772,11 @@ func detectNginxConfDir(binary string) (string, error) {
|
|||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
if value, found := strings.CutPrefix(line, "# configuration file "); found {
|
if value, found := strings.CutPrefix(line, "# configuration file "); found {
|
||||||
p := strings.TrimSuffix(strings.TrimSpace(value), ":")
|
p := strings.TrimSuffix(strings.TrimSpace(value), ":")
|
||||||
if strings.HasSuffix(p, ".conf") {
|
// 不要求 .conf 后缀:Debian/Ubuntu 的 sites-enabled/default 本身不带后缀,
|
||||||
loadedDirs[filepath.Dir(p)] = true
|
// 而 nginx.conf 里是 include /etc/nginx/sites-enabled/*(也无后缀限制);
|
||||||
}
|
// 只认 .conf 会把这个真正该放 vhost 的目录漏掉,只剩主配置目录可试,
|
||||||
|
// 而主配置目录里的新文件并不被 include(表现为「均无法放置完整 server 配置」)。
|
||||||
|
loadedDirs[filepath.Dir(p)] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
delete(loadedDirs, ".")
|
delete(loadedDirs, ".")
|
||||||
@@ -513,13 +795,17 @@ func detectNginxConfDir(binary string) (string, error) {
|
|||||||
}
|
}
|
||||||
return dirs[i] < dirs[j]
|
return dirs[i] < dirs[j]
|
||||||
})
|
})
|
||||||
|
var lastReason string
|
||||||
for _, dir := range dirs {
|
for _, dir := range dirs {
|
||||||
|
migrateLegacyVersionDirs(dir)
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
lastReason = fmt.Sprintf("%s: %v", dir, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
probe := filepath.Join(dir, "fonu-probe.conf")
|
probe := filepath.Join(dir, "fonu-probe.conf")
|
||||||
probeBody := "# Managed by fonu-agent. Do not edit.\nserver {\n listen 127.0.0.1:1;\n server_name fonu-probe.invalid;\n}\n"
|
probeBody := "# Managed by fonu-agent. Do not edit.\nserver {\n listen 127.0.0.1:1;\n server_name fonu-probe.invalid;\n}\n"
|
||||||
if err := atomicWrite(probe, []byte(probeBody), 0o644); err != nil {
|
if err := atomicWrite(probe, []byte(probeBody), 0o644); err != nil {
|
||||||
|
lastReason = fmt.Sprintf("%s: %v", dir, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 两级验证:-t 仅验证语法,不保证文件被 include 加载(主配置目录等无 include 的目录也能通过)。
|
// 两级验证:-t 仅验证语法,不保证文件被 include 加载(主配置目录等无 include 的目录也能通过)。
|
||||||
@@ -531,6 +817,7 @@ func detectNginxConfDir(binary string) (string, error) {
|
|||||||
return dir, nil
|
return dir, nil
|
||||||
}
|
}
|
||||||
// -t 通过但 -T 不含探针:该目录不被 include 加载,删除探针换下一个
|
// -t 通过但 -T 不含探针:该目录不被 include 加载,删除探针换下一个
|
||||||
|
lastReason = fmt.Sprintf("%s: 目录未被 include 加载(nginx -T 输出中无探针文件)", dir)
|
||||||
_ = os.Remove(probe)
|
_ = os.Remove(probe)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -538,13 +825,19 @@ func detectNginxConfDir(binary string) (string, error) {
|
|||||||
// 若 -t 失败信息指向历史残留的 fonu-*.conf,也一并清理(仅限 Agent 生成的文件)。
|
// 若 -t 失败信息指向历史残留的 fonu-*.conf,也一并清理(仅限 Agent 生成的文件)。
|
||||||
_ = os.Remove(probe)
|
_ = os.Remove(probe)
|
||||||
if tOut, tErr := exec.Command(nginx, "-t").CombinedOutput(); tErr != nil {
|
if tOut, tErr := exec.Command(nginx, "-t").CombinedOutput(); tErr != nil {
|
||||||
|
lastReason = fmt.Sprintf("%s: nginx -t 失败:%s", dir, tailText(string(tOut), 300))
|
||||||
cleanupLegacyFonuConf(string(tOut))
|
cleanupLegacyFonuConf(string(tOut))
|
||||||
if err := runCommand(nginx, "-t"); err == nil {
|
if err := runCommand(nginx, "-t"); err == nil {
|
||||||
continue // 清理残留后配置恢复可用,换下一个目录
|
continue // 清理残留后配置恢复可用,换下一个目录
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
lastReason = fmt.Sprintf("%s: 探针写入后 nginx -t 未通过", dir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("在 %d 个被加载目录中均无法放置完整 server 配置(已尝试: %s);可用 FONU_AGENT_NGINX_CONF_DIR 显式指定", len(dirs), strings.Join(dirs, ", "))
|
if lastReason == "" {
|
||||||
|
lastReason = "没有可尝试的目录"
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("在 %d 个被加载目录中均无法放置完整 server 配置(已尝试: %s);最后一次失败原因:%s;也可用 FONU_AGENT_NGINX_CONF_DIR 显式指定(Debian/Ubuntu 通常是 /etc/nginx/conf.d 或 /etc/nginx/sites-enabled)", len(dirs), strings.Join(dirs, ", "), lastReason)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dirHasVhostConf 判断目录下是否已有含 server_name 的 .conf(HTTP vhost 语义)。
|
// dirHasVhostConf 判断目录下是否已有含 server_name 的 .conf(HTTP vhost 语义)。
|
||||||
@@ -678,11 +971,27 @@ func (s *Server) putRoute(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
// Basic Auth 密码留空时首次自动生成:生成的明文只在本次响应里回一次,
|
||||||
|
// 不再写进配置文件注释(否则「查看配置」会永久暴露密码)。已有凭据时返回空串。
|
||||||
|
generatedPassword := ""
|
||||||
|
if user := strings.TrimSpace(req.BasicAuthUser); user != "" && strings.TrimSpace(req.BasicAuthPassword) == "" {
|
||||||
|
if generated, err := ensureBasicAuthSecret(req.Domain, user); err == nil {
|
||||||
|
generatedPassword = generated
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := s.writeRoute(req); err != nil {
|
if err := s.writeRoute(req); err != nil {
|
||||||
|
if generatedPassword != "" {
|
||||||
|
// 部署失败就丢弃刚生成的凭据,否则重试时文件已存在、密码再也拿不到
|
||||||
|
_ = os.Remove(basicAuthFilePath(req.Domain))
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result := map[string]any{"ok": true, "domain": req.Domain, "websocket": req.WebSocket}
|
result := map[string]any{"ok": true, "domain": req.Domain, "websocket": req.WebSocket}
|
||||||
|
if generatedPassword != "" {
|
||||||
|
result["basic_auth_user"] = strings.TrimSpace(req.BasicAuthUser)
|
||||||
|
result["basic_auth_password"] = generatedPassword
|
||||||
|
}
|
||||||
// 部署后回源自检:模拟 Nginx 的回源请求,区分三种状态:
|
// 部署后回源自检:模拟 Nginx 的回源请求,区分三种状态:
|
||||||
// 2xx/3xx = 隧道通且服务响应;404 = 反代/frps 正常但无此域名的隧道注册;0/5xx = 回源失败或内网服务异常
|
// 2xx/3xx = 隧道通且服务响应;404 = 反代/frps 正常但无此域名的隧道注册;0/5xx = 回源失败或内网服务异常
|
||||||
if vhost := req.VhostPort; vhost > 0 {
|
if vhost := req.VhostPort; vhost > 0 {
|
||||||
@@ -751,6 +1060,8 @@ func (s *Server) deleteRoute(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 版本历史跟着 vhost 一起清掉,避免同域名重新部署后看到上一轮部署的旧版本
|
||||||
|
_ = os.RemoveAll(fsutil.VersionsDir(path))
|
||||||
if err := s.reloadNginx(); err != nil {
|
if err := s.reloadNginx(); err != nil {
|
||||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -920,11 +1231,11 @@ func (s *Server) putChinaCIDR(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.validateNginx(); err != nil {
|
if err := s.validateRouteConfig(); err != nil {
|
||||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "nginx config rejected: " + err.Error()})
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "nginx config rejected: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.reloadNginx(); err != nil {
|
if err := s.reloadRouteConfig(); err != nil {
|
||||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -975,13 +1286,9 @@ func (s *Server) writeRoute(req routeRequest) error {
|
|||||||
if err := s.applyLimitState(req); err != nil {
|
if err := s.applyLimitState(req); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := atomicWrite(path, []byte(content), 0o640); err != nil {
|
if err := s.applyRouteContent(path, []byte(content)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.validateNginx(); err != nil {
|
|
||||||
_ = os.Remove(path)
|
|
||||||
return fmt.Errorf("nginx config rejected: %w", err)
|
|
||||||
}
|
|
||||||
return s.reloadNginx()
|
return s.reloadNginx()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1006,15 +1313,83 @@ func (s *Server) routePath(domain string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) validateNginx() error {
|
func (s *Server) validateNginx() error {
|
||||||
|
migrateLegacyVersionDirs(s.cfg.NginxConfDir)
|
||||||
return runCommand(s.nginxPath(), "-t")
|
return runCommand(s.nginxPath(), "-t")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) reloadNginx() error {
|
func (s *Server) validateRouteConfig() error {
|
||||||
return runCommand(s.nginxPath(), "-s", "reload")
|
if s.validateNginxFn != nil {
|
||||||
|
return s.validateNginxFn()
|
||||||
|
}
|
||||||
|
return s.validateNginx()
|
||||||
}
|
}
|
||||||
|
|
||||||
// nginxPath 返回可用的 nginx 绝对路径:配置的路径可用则用之;否则探测常见安装位置
|
func (s *Server) reloadRouteConfig() error {
|
||||||
|
if s.reloadNginxFn != nil {
|
||||||
|
return s.reloadNginxFn()
|
||||||
|
}
|
||||||
|
return s.reloadNginx()
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateLegacyVersionDirs 把 conf 目录下的 <name>.versions 目录迁移为隐藏目录。
|
||||||
|
// 旧布局会被 nginx 的 include * 当成配置文件读取,报 pread() "Is a directory" 导致 nginx -t 失败。
|
||||||
|
func migrateLegacyVersionDirs(dir string) {
|
||||||
|
dir = strings.TrimSpace(dir)
|
||||||
|
if dir == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() || entry.Name() == ".versions" || !strings.HasSuffix(entry.Name(), ".versions") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := strings.TrimSuffix(filepath.Join(dir, entry.Name()), ".versions")
|
||||||
|
_ = fsutil.MigrateLegacyVersions(base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateVersionDirsFromNginxOutput 从 nginx 报错文本里解析被误当配置文件读取的 .versions 目录并迁移。
|
||||||
|
// nginx -T 在探测目录前就失败时,这是拿到具体路径的唯一线索。
|
||||||
|
func migrateVersionDirsFromNginxOutput(output string) bool {
|
||||||
|
changed := false
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
idx := strings.Index(line, ".versions")
|
||||||
|
if idx < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start := strings.LastIndex(line[:idx], "\"")
|
||||||
|
if start < 0 {
|
||||||
|
start = strings.LastIndexAny(line[:idx], " '")
|
||||||
|
}
|
||||||
|
path := line[start+1 : idx+len(".versions")]
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := strings.TrimSuffix(path, ".versions")
|
||||||
|
if err := fsutil.MigrateLegacyVersions(base); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) reloadNginx() error {
|
||||||
|
nginx := s.nginxPath()
|
||||||
|
if nginx == "" {
|
||||||
|
return errors.New("未找到 nginx 可执行文件:请先在「公网反代」页安装 Nginx,或用 FONU_AGENT_NGINX_BIN 指定路径")
|
||||||
|
}
|
||||||
|
return runCommand(nginx, "-s", "reload")
|
||||||
|
}
|
||||||
|
|
||||||
|
// nginxPath 返回**可用的** nginx 可执行路径:配置的路径确实可用才用它,否则探测常见安装位置
|
||||||
// (宝塔的 nginx 不在 systemd PATH 里,直接用名字会 exec 失败)。
|
// (宝塔的 nginx 不在 systemd PATH 里,直接用名字会 exec 失败)。
|
||||||
|
// 探测不到时返回空串。注意:不要回落到配置里的裸名字(如 "nginx"),
|
||||||
|
// 那会让 probeNginx 的 Missing 判定永远不成立——「未安装」会被当成「配置校验失败」
|
||||||
|
// (页面显示 nginx -t 执行失败),且一键安装入口永远不出现。
|
||||||
func (s *Server) nginxPath() string {
|
func (s *Server) nginxPath() string {
|
||||||
if p := strings.TrimSpace(s.cfg.NginxBinary); p != "" {
|
if p := strings.TrimSpace(s.cfg.NginxBinary); p != "" {
|
||||||
if filepath.IsAbs(p) {
|
if filepath.IsAbs(p) {
|
||||||
@@ -1030,7 +1405,7 @@ func (s *Server) nginxPath() string {
|
|||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s.cfg.NginxBinary
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeNginx 真实探测 Nginx 可用性:解析可执行文件并执行 nginx -t;结果缓存 30 秒。
|
// probeNginx 真实探测 Nginx 可用性:解析可执行文件并执行 nginx -t;结果缓存 30 秒。
|
||||||
@@ -1178,14 +1553,10 @@ func nginxRoute(req routeRequest, certDir string) string {
|
|||||||
fmt.Fprintf(&b, " proxy_read_timeout %s;\n", timeout)
|
fmt.Fprintf(&b, " proxy_read_timeout %s;\n", timeout)
|
||||||
}
|
}
|
||||||
// 安全响应头:HSTS 仅在 HTTPS 实际启用时输出
|
// 安全响应头:HSTS 仅在 HTTPS 实际启用时输出
|
||||||
if req.SecurityHeaders {
|
b.WriteString(nginxtmpl.ServerSecurityHeaders(nginxtmpl.SecurityHeaders{
|
||||||
b.WriteString(" add_header X-Content-Type-Options \"nosniff\" always;\n")
|
Enabled: req.SecurityHeaders,
|
||||||
b.WriteString(" add_header X-Frame-Options \"SAMEORIGIN\" always;\n")
|
HTTPS: tlsEnabled,
|
||||||
b.WriteString(" add_header Referrer-Policy \"strict-origin-when-cross-origin\" always;\n")
|
}))
|
||||||
if tlsEnabled {
|
|
||||||
b.WriteString(" add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 限流/连接数的 zone 定义在 fonuagent-limits.conf(http 上下文),此处只声明状态码
|
// 限流/连接数的 zone 定义在 fonuagent-limits.conf(http 上下文),此处只声明状态码
|
||||||
if req.RateLimitRate > 0 {
|
if req.RateLimitRate > 0 {
|
||||||
b.WriteString(" limit_req_status 429;\n")
|
b.WriteString(" limit_req_status 429;\n")
|
||||||
@@ -1195,60 +1566,39 @@ func nginxRoute(req routeRequest, certDir string) string {
|
|||||||
}
|
}
|
||||||
if req.WebSocket {
|
if req.WebSocket {
|
||||||
b.WriteString(" proxy_http_version 1.1;\n")
|
b.WriteString(" proxy_http_version 1.1;\n")
|
||||||
b.WriteString(" proxy_set_header Upgrade $http_upgrade;\n")
|
b.WriteString(nginxtmpl.UpgradeHeaders(" ", "\"upgrade\""))
|
||||||
b.WriteString(" proxy_set_header Connection \"upgrade\";\n")
|
|
||||||
}
|
}
|
||||||
// IP 访问控制:白名单(allow + deny all)与黑名单(deny)可同时生效;
|
// IP 访问控制:白名单(allow + deny all)与黑名单(deny)可同时生效;
|
||||||
// Nginx 按出现顺序匹配,因此 allow 在前、deny 在后。
|
// 顺序由共享模板统一:deny → 仅大陆规则 → allow → deny all。
|
||||||
var acl strings.Builder
|
chinaDeny := ""
|
||||||
for _, ip := range req.AllowIPs {
|
if req.ChinaOnly {
|
||||||
ip = strings.TrimSpace(ip)
|
chinaDeny = " if ($fonu_china_client = 0) { return 403; }\n"
|
||||||
if ip != "" {
|
|
||||||
fmt.Fprintf(&acl, " allow %s;\n", ip)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for _, ip := range req.DenyIPs {
|
aclText := nginxtmpl.AccessDirectives(nginxtmpl.AccessControl{
|
||||||
if ip = strings.TrimSpace(ip); ip != "" {
|
Allow: req.AllowIPs,
|
||||||
fmt.Fprintf(&acl, " deny %s;\n", ip)
|
Deny: req.DenyIPs,
|
||||||
}
|
ChinaDeny: chinaDeny,
|
||||||
}
|
}, " ")
|
||||||
if len(req.AllowIPs) > 0 {
|
// Basic Auth:密码优先用规则表单值(自定义),留空则首次自动生成并复用(htpasswd 格式);
|
||||||
acl.WriteString(" deny all;\n")
|
// 自动生成的明文由 putRoute 在响应里回一次,这里只写 auth_basic 指令,不写密码注释
|
||||||
}
|
|
||||||
// Basic Auth:密码优先用规则表单值(自定义),留空则首次自动生成并复用(htpasswd 格式)
|
|
||||||
authLine := ""
|
authLine := ""
|
||||||
var authNote string
|
|
||||||
if user := strings.TrimSpace(req.BasicAuthUser); user != "" {
|
if user := strings.TrimSpace(req.BasicAuthUser); user != "" {
|
||||||
if password := strings.TrimSpace(req.BasicAuthPassword); password != "" {
|
if password := strings.TrimSpace(req.BasicAuthPassword); password != "" {
|
||||||
if err := writeBasicAuthSecret(req.Domain, user, password); err == nil {
|
if err := writeBasicAuthSecret(req.Domain, user, password); err == nil {
|
||||||
authLine = fmt.Sprintf(" auth_basic \"Fonu Protected\";\n auth_basic_user_file %s;\n", basicAuthFilePath(req.Domain))
|
authLine = fmt.Sprintf(" auth_basic \"Fonu Protected\";\n auth_basic_user_file %s;\n", basicAuthFilePath(req.Domain))
|
||||||
}
|
}
|
||||||
} else if generated, err := ensureBasicAuthSecret(req.Domain, user); err == nil {
|
} else if _, err := ensureBasicAuthSecret(req.Domain, user); err == nil {
|
||||||
authLine = fmt.Sprintf(" auth_basic \"Fonu Protected\";\n auth_basic_user_file %s;\n", basicAuthFilePath(req.Domain))
|
authLine = fmt.Sprintf(" auth_basic \"Fonu Protected\";\n auth_basic_user_file %s;\n", basicAuthFilePath(req.Domain))
|
||||||
if generated != "" {
|
|
||||||
authNote = fmt.Sprintf("# Basic Auth: user=%s password=%s\n", user, generated)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.WriteString(authNote)
|
// 限流 / 连接数(zone 在 http 上下文定义)
|
||||||
// 限流 / 连接数 / 仅中国大陆 IP 的 location 级指令(zone 与 geo 变量在 http 上下文定义)
|
limitText := nginxtmpl.LimitDirectives(nginxtmpl.LimitConfig{
|
||||||
var limitText strings.Builder
|
Zone: nginxtmpl.ZoneName("fonu", req.Domain),
|
||||||
if req.RateLimitRate > 0 {
|
Rate: req.RateLimitRate,
|
||||||
zone := zoneKey(req.Domain)
|
Burst: req.RateLimitBurst,
|
||||||
if req.RateLimitBurst > 0 {
|
Conn: req.ConnLimitMax,
|
||||||
fmt.Fprintf(&limitText, " limit_req zone=%s_req burst=%d nodelay;\n", zone, req.RateLimitBurst)
|
}, " ")
|
||||||
} else {
|
fmt.Fprintf(&b, " location / {\n%s%s%s proxy_pass http://%s;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_http_version 1.1;\n }\n}\n", aclText, authLine, limitText, req.Upstream)
|
||||||
fmt.Fprintf(&limitText, " limit_req zone=%s_req nodelay;\n", zone)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if req.ConnLimitMax > 0 {
|
|
||||||
fmt.Fprintf(&limitText, " limit_conn %s_conn %d;\n", zoneKey(req.Domain), req.ConnLimitMax)
|
|
||||||
}
|
|
||||||
if req.ChinaOnly {
|
|
||||||
limitText.WriteString(" if ($fonu_china_client = 0) { return 403; }\n")
|
|
||||||
}
|
|
||||||
aclText := acl.String()
|
|
||||||
fmt.Fprintf(&b, " location / {\n%s%s%s proxy_pass http://%s;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_http_version 1.1;\n }\n}\n", aclText, authLine, limitText.String(), req.Upstream)
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1257,16 +1607,7 @@ const agentStateDir = "/var/lib/fonu-agent"
|
|||||||
|
|
||||||
// zoneKey 把域名转成合法的 nginx zone 名(仅保留小写字母与数字,其余换下划线)。
|
// zoneKey 把域名转成合法的 nginx zone 名(仅保留小写字母与数字,其余换下划线)。
|
||||||
func zoneKey(domain string) string {
|
func zoneKey(domain string) string {
|
||||||
var b strings.Builder
|
return nginxtmpl.ZoneName("fonu", domain)
|
||||||
b.WriteString("fonu_")
|
|
||||||
for _, r := range strings.ToLower(domain) {
|
|
||||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
|
||||||
b.WriteRune(r)
|
|
||||||
} else {
|
|
||||||
b.WriteByte('_')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// limitsFilePath 返回 zone 定义文件(放在 vhost 目录,顶层指令即 http 上下文)。
|
// limitsFilePath 返回 zone 定义文件(放在 vhost 目录,顶层指令即 http 上下文)。
|
||||||
@@ -1295,14 +1636,29 @@ type routeLimit struct {
|
|||||||
Conn int `json:"conn,omitempty"`
|
Conn int `json:"conn,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func readLimitState() map[string]routeLimit {
|
func readLimitState() (map[string]routeLimit, error) {
|
||||||
|
return readLimitStateFile(limitsStatePath())
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLimitStateFile(path string) (map[string]routeLimit, error) {
|
||||||
state := map[string]routeLimit{}
|
state := map[string]routeLimit{}
|
||||||
data, err := os.ReadFile(limitsStatePath())
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return state
|
return state, nil
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(data, &state)
|
if err != nil {
|
||||||
return state
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(string(data)) == "" {
|
||||||
|
return nil, fmt.Errorf("限流状态文件为空: %s", path)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &state); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析限流状态失败: %w", err)
|
||||||
|
}
|
||||||
|
if state == nil {
|
||||||
|
return nil, fmt.Errorf("限流状态文件内容为空对象: %s", path)
|
||||||
|
}
|
||||||
|
return state, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeLimitState 落盘限流参数并重生成 zone 定义文件;无限流时删除该文件。
|
// writeLimitState 落盘限流参数并重生成 zone 定义文件;无限流时删除该文件。
|
||||||
@@ -1332,10 +1688,10 @@ func (s *Server) writeLimitState(state map[string]routeLimit) error {
|
|||||||
item := state[domain]
|
item := state[domain]
|
||||||
key := zoneKey(domain)
|
key := zoneKey(domain)
|
||||||
if item.Rate > 0 {
|
if item.Rate > 0 {
|
||||||
fmt.Fprintf(&b, "limit_req_zone $binary_remote_addr zone=%s_req:10m rate=%dr/s;\n", key, item.Rate)
|
b.WriteString(nginxtmpl.RateLimitZone(key+"_req", item.Rate))
|
||||||
}
|
}
|
||||||
if item.Conn > 0 {
|
if item.Conn > 0 {
|
||||||
fmt.Fprintf(&b, "limit_conn_zone $binary_remote_addr zone=%s_conn:10m;\n", key)
|
b.WriteString(nginxtmpl.ConnLimitZone(key + "_conn"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return atomicWrite(path, []byte(b.String()), 0o640)
|
return atomicWrite(path, []byte(b.String()), 0o640)
|
||||||
@@ -1343,7 +1699,10 @@ func (s *Server) writeLimitState(state map[string]routeLimit) error {
|
|||||||
|
|
||||||
// applyLimitState 记录本次路由的限流参数并刷新 zone 文件(必须在写 vhost 之前完成)。
|
// applyLimitState 记录本次路由的限流参数并刷新 zone 文件(必须在写 vhost 之前完成)。
|
||||||
func (s *Server) applyLimitState(req routeRequest) error {
|
func (s *Server) applyLimitState(req routeRequest) error {
|
||||||
state := readLimitState()
|
state, err := readLimitState()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("读取限流状态失败,已保留原文件: %w", err)
|
||||||
|
}
|
||||||
if req.RateLimitRate > 0 || req.ConnLimitMax > 0 {
|
if req.RateLimitRate > 0 || req.ConnLimitMax > 0 {
|
||||||
state[req.Domain] = routeLimit{Rate: req.RateLimitRate, Burst: req.RateLimitBurst, Conn: req.ConnLimitMax}
|
state[req.Domain] = routeLimit{Rate: req.RateLimitRate, Burst: req.RateLimitBurst, Conn: req.ConnLimitMax}
|
||||||
} else {
|
} else {
|
||||||
@@ -1354,7 +1713,10 @@ func (s *Server) applyLimitState(req routeRequest) error {
|
|||||||
|
|
||||||
// clearLimitState 删除该域名的限流参数并刷新 zone 文件。
|
// clearLimitState 删除该域名的限流参数并刷新 zone 文件。
|
||||||
func (s *Server) clearLimitState(domain string) error {
|
func (s *Server) clearLimitState(domain string) error {
|
||||||
state := readLimitState()
|
state, err := readLimitState()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("读取限流状态失败,已保留原文件: %w", err)
|
||||||
|
}
|
||||||
if _, ok := state[domain]; !ok {
|
if _, ok := state[domain]; !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1364,12 +1726,11 @@ func (s *Server) clearLimitState(domain string) error {
|
|||||||
|
|
||||||
// sslDirectives 生成证书与 TLS 协议指令;tls13Only 时只允许 TLS 1.3。
|
// sslDirectives 生成证书与 TLS 协议指令;tls13Only 时只允许 TLS 1.3。
|
||||||
func sslDirectives(certDir string, tls13Only bool) string {
|
func sslDirectives(certDir string, tls13Only bool) string {
|
||||||
var b strings.Builder
|
return nginxtmpl.SSLDirectives(nginxtmpl.TLSConfig{
|
||||||
fmt.Fprintf(&b, " ssl_certificate %s;\n ssl_certificate_key %s;\n", filepath.Join(certDir, "fullchain.pem"), filepath.Join(certDir, "privkey.pem"))
|
CertPath: filepath.Join(certDir, "fullchain.pem"),
|
||||||
if tls13Only {
|
KeyPath: filepath.Join(certDir, "privkey.pem"),
|
||||||
b.WriteString(" ssl_protocols TLSv1.3;\n")
|
TLS13Only: tls13Only,
|
||||||
}
|
})
|
||||||
return b.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeBasicAuthSecret 用规则表单提供的密码写入 htpasswd(自定义密码场景)。
|
// writeBasicAuthSecret 用规则表单提供的密码写入 htpasswd(自定义密码场景)。
|
||||||
@@ -1415,24 +1776,7 @@ func atomicWrite(path string, data []byte, mode os.FileMode) error {
|
|||||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".fonu-agent-*")
|
return fsutil.WriteFileAtomic(path, data, mode)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tmpPath := tmp.Name()
|
|
||||||
defer os.Remove(tmpPath)
|
|
||||||
if err := tmp.Chmod(mode); err != nil {
|
|
||||||
tmp.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := tmp.Write(data); err != nil {
|
|
||||||
tmp.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tmp.Close(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.Rename(tmpPath, path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/fsutil"
|
||||||
|
"github.com/fonu/fonu/internal/nginxtmpl"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
httpsProxyConfigName = "fonuagent-https.conf"
|
||||||
|
httpsProxyCertPath = "/etc/fonu-agent/tls/agent.crt"
|
||||||
|
httpsProxyKeyPath = "/etc/fonu-agent/tls/agent.key"
|
||||||
|
)
|
||||||
|
|
||||||
|
type httpsProxyRequest struct {
|
||||||
|
CertPEM string `json:"cert_pem"`
|
||||||
|
KeyPEM string `json:"key_pem"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type httpsProxyState struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) putHTTPSProxy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req httpsProxyRequest
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20)).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Port < 0 || req.Port > 65535 {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "端口需在 0-65535 之间,0 表示自动选择"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := tls.X509KeyPair([]byte(req.CertPEM), []byte(req.KeyPEM)); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "证书或私钥格式无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if probe := s.probeNginx(); probe.Missing {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "VPS 未安装 Nginx,无法启用 HTTPS 直连"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
port, err := s.configureHTTPSProxy(req)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "port": port, "self_check": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteHTTPSProxy(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if err := s.removeHTTPSProxy(); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getHTTPSProxy 返回当前 HTTPS 反代状态与证书到期时间,供中央端同步轮换计划。
|
||||||
|
func (s *Server) getHTTPSProxy(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
state, enabled := readHTTPSProxyState(s.cfg.DataDir)
|
||||||
|
notAfter := ""
|
||||||
|
if t, err := readCertificateNotAfter(httpsProxyCertPath); err == nil {
|
||||||
|
notAfter = t.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"enabled": enabled,
|
||||||
|
"port": state.Port,
|
||||||
|
"not_after": notAfter,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// configureHTTPSProxy 按候选端口逐个尝试;请求端口为 0 时自动选择 7443 / 8443 / 9443。
|
||||||
|
func (s *Server) configureHTTPSProxy(req httpsProxyRequest) (int, error) {
|
||||||
|
var lastErr error
|
||||||
|
for _, port := range candidateHTTPSPorts(req.Port) {
|
||||||
|
if err := s.applyHTTPSProxy(req, port); err != nil {
|
||||||
|
lastErr = fmt.Errorf("端口 %d 不可用: %w", port, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return port, nil
|
||||||
|
}
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = errors.New("没有可用的 HTTPS 端口")
|
||||||
|
}
|
||||||
|
return 0, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateHTTPSPorts(requested int) []int {
|
||||||
|
if requested > 0 {
|
||||||
|
return []int{requested}
|
||||||
|
}
|
||||||
|
return []int{7443, 8443, 9443}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) applyHTTPSProxy(req httpsProxyRequest, port int) error {
|
||||||
|
dir, err := s.ensureNginxDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tlsDir := "/etc/fonu-agent/tls"
|
||||||
|
if err := os.MkdirAll(tlsDir, 0o750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := atomicWrite(httpsProxyCertPath, []byte(req.CertPEM), 0o640); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := atomicWrite(httpsProxyKeyPath, []byte(req.KeyPEM), 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, httpsProxyConfigName)
|
||||||
|
previous, readErr := os.ReadFile(path)
|
||||||
|
if _, err := fsutil.BackupVersioned(path, 10); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
content := renderHTTPSProxy(port, httpsProxyCertPath, httpsProxyKeyPath)
|
||||||
|
if err := atomicWrite(path, []byte(content), 0o640); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.validateNginx(); err != nil {
|
||||||
|
s.restoreHTTPSConfig(path, previous, readErr)
|
||||||
|
return fmt.Errorf("nginx config rejected: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.reloadNginx(); err != nil {
|
||||||
|
s.restoreHTTPSConfig(path, previous, readErr)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pin, err := certificatePin(req.CertPEM)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.verifyHTTPSProxy(port, pin); err != nil {
|
||||||
|
s.restoreHTTPSConfig(path, previous, readErr)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeHTTPSProxyState(s.cfg.DataDir, httpsProxyState{Port: port})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) removeHTTPSProxy() error {
|
||||||
|
dir, err := s.ensureNginxDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, httpsProxyConfigName)
|
||||||
|
previous, readErr := os.ReadFile(path)
|
||||||
|
if errors.Is(readErr, os.ErrNotExist) {
|
||||||
|
_ = os.Remove(httpsProxyStatePath(s.cfg.DataDir))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.validateNginx(); err != nil {
|
||||||
|
s.restoreHTTPSConfig(path, previous, readErr)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.reloadNginx(); err != nil {
|
||||||
|
s.restoreHTTPSConfig(path, previous, readErr)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = os.Remove(httpsProxyStatePath(s.cfg.DataDir))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) restoreHTTPSConfig(path string, previous []byte, readErr error) {
|
||||||
|
if readErr == nil {
|
||||||
|
_ = atomicWrite(path, previous, 0o640)
|
||||||
|
} else {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
}
|
||||||
|
_ = s.reloadNginx()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ensureNginxDir() (string, error) {
|
||||||
|
dir := strings.TrimSpace(s.cfg.NginxConfDir)
|
||||||
|
if dir != "" {
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
dir, err := detectNginxConfDir(s.nginxPath())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s.cfg.NginxConfDir = dir
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderHTTPSProxy(port int, certPath, keyPath string) string {
|
||||||
|
return fmt.Sprintf(`# Managed by fonu-agent. Do not edit.
|
||||||
|
server {
|
||||||
|
listen %d ssl default_server;
|
||||||
|
server_name _;
|
||||||
|
%s
|
||||||
|
|
||||||
|
location /api/v1/ {
|
||||||
|
proxy_pass http://127.0.0.1:7700;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, port, nginxtmpl.SSLDirectives(nginxtmpl.TLSConfig{CertPath: certPath, KeyPath: keyPath}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func certificatePin(certPEM string) (string, error) {
|
||||||
|
block, _ := pem.Decode([]byte(certPEM))
|
||||||
|
if block == nil {
|
||||||
|
return "", errors.New("证书 PEM 解码失败")
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(block.Bytes)
|
||||||
|
return hex.EncodeToString(sum[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyHTTPSProxy 在 reload 后从本机回环地址握手,确认 7443 上真正返回的就是刚写入的证书。
|
||||||
|
// nginx reload 是异步的,这里留出短暂重试窗口,避免旧 worker 造成的瞬时误判。
|
||||||
|
func (s *Server) verifyHTTPSProxy(port int, want string) error {
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
var lastErr error
|
||||||
|
for {
|
||||||
|
lastErr = probeHTTPSPin(port, want)
|
||||||
|
if lastErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeHTTPSPin(port int, want string) error {
|
||||||
|
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
||||||
|
dialer := &net.Dialer{Timeout: 3 * time.Second}
|
||||||
|
conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("本机 https://%s 自检失败:%w;%s", addr, err, portListenerHint(port))
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
certs := conn.ConnectionState().PeerCertificates
|
||||||
|
if len(certs) == 0 {
|
||||||
|
return fmt.Errorf("本机 https://%s 未返回证书;%s", addr, portListenerHint(port))
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(certs[0].Raw)
|
||||||
|
got := hex.EncodeToString(sum[:])
|
||||||
|
if !strings.EqualFold(got, want) {
|
||||||
|
return fmt.Errorf("本机 https://%s 证书指纹不匹配:期望 %s,实际 %s;%s", addr, want, got, portListenerHint(port))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// portListenerHint 把监听该端口的进程信息带进错误里,便于区分 nginx 未生效与端口被其他服务占用。
|
||||||
|
func portListenerHint(port int) string {
|
||||||
|
out, err := exec.Command("ss", "-tlnp").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "无法读取端口监听信息"
|
||||||
|
}
|
||||||
|
suffix := ":" + strconv.Itoa(port)
|
||||||
|
lines := make([]string, 0, 2)
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
if strings.Contains(line, suffix+" ") || strings.HasSuffix(strings.TrimSpace(line), suffix) {
|
||||||
|
lines = append(lines, strings.TrimSpace(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return "未发现监听 " + suffix + " 的进程"
|
||||||
|
}
|
||||||
|
return "监听 " + suffix + ":" + strings.Join(lines, " | ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpsProxyStatePath(dataDir string) string {
|
||||||
|
return filepath.Join(dataDir, "https_transport.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHTTPSProxyState(dataDir string, state httpsProxyState) error {
|
||||||
|
if strings.TrimSpace(dataDir) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(state)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return atomicWrite(httpsProxyStatePath(dataDir), data, 0o640)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readHTTPSProxyState(dataDir string) (httpsProxyState, bool) {
|
||||||
|
data, err := os.ReadFile(httpsProxyStatePath(dataDir))
|
||||||
|
if err != nil {
|
||||||
|
return httpsProxyState{}, false
|
||||||
|
}
|
||||||
|
var state httpsProxyState
|
||||||
|
if err := json.Unmarshal(data, &state); err != nil || state.Port <= 0 {
|
||||||
|
return httpsProxyState{}, false
|
||||||
|
}
|
||||||
|
return state, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/fsutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxRouteVersions 每个域名 vhost 保留的版本份数。内容相同的写入会被 fsutil 去重,
|
||||||
|
// 因此这里对应的是最近 10 次「配置真的变了」的改动。
|
||||||
|
const maxRouteVersions = 10
|
||||||
|
|
||||||
|
type routeVersionInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// routeVersions 返回某域名的当前配置与历史版本,供应用侧把「漂移」与「历史」放在一起看。
|
||||||
|
func (s *Server) routeVersions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
domain := strings.ToLower(strings.TrimSpace(r.PathValue("domain")))
|
||||||
|
if !validDomain(domain) {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid domain"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
path := s.routePath(domain)
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "该域名尚未部署反代"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
versions, err := fsutil.ListVersions(path)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"domain": domain,
|
||||||
|
"content": string(content),
|
||||||
|
"versions": mapRouteVersions(versions),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// routeVersion 返回某个历史版本的内容(前端拿它与当前配置做差异对比)。
|
||||||
|
func (s *Server) routeVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
domain := strings.ToLower(strings.TrimSpace(r.PathValue("domain")))
|
||||||
|
if !validDomain(domain) {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid domain"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := r.PathValue("name")
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
content, err := fsutil.ReadVersion(s.routePath(domain), name)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"domain": domain, "name": name, "content": string(content)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// rollbackRoute 把某域名的 vhost 回退到指定版本。
|
||||||
|
func (s *Server) rollbackRoute(w http.ResponseWriter, r *http.Request) {
|
||||||
|
domain := strings.ToLower(strings.TrimSpace(r.PathValue("domain")))
|
||||||
|
if !validDomain(domain) {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid domain"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := r.PathValue("name")
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
path := s.routePath(domain)
|
||||||
|
content, err := fsutil.ReadVersion(path, name)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.applyRouteContent(path, content); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.reloadRouteConfig(); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "domain": domain, "name": name})
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyRouteContent 写入 vhost 内容并做 nginx -t 校验。
|
||||||
|
//
|
||||||
|
// 写之前先把当前内容留成一个版本;校验失败时恢复回上一个可用版本(此前没有文件才删除)。
|
||||||
|
// 旧行为是校验失败直接删文件——那等于把已经在跑的站点从磁盘上抹掉,只能靠重新部署救回来。
|
||||||
|
func (s *Server) applyRouteContent(path string, content []byte) error {
|
||||||
|
previous, readErr := os.ReadFile(path)
|
||||||
|
if _, err := fsutil.BackupVersioned(path, maxRouteVersions); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := atomicWrite(path, content, 0o640); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.validateRouteConfig(); err != nil {
|
||||||
|
if readErr == nil {
|
||||||
|
_ = atomicWrite(path, previous, 0o640)
|
||||||
|
} else {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("nginx config rejected: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapRouteVersions(entries []fsutil.VersionEntry) []routeVersionInfo {
|
||||||
|
out := make([]routeVersionInfo, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
out = append(out, routeVersionInfo{Name: entry.Name, Size: entry.Size, CreatedAt: entry.CreatedAt})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/fsutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMigrateLegacyVersionDirs(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "fonuagent-https.conf")
|
||||||
|
legacy := path + ".versions"
|
||||||
|
if err := os.MkdirAll(legacy, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
name := "20260101T000000.000-abcd.conf"
|
||||||
|
if err := os.WriteFile(filepath.Join(legacy, name), []byte("old"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrateLegacyVersionDirs(dir)
|
||||||
|
|
||||||
|
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("旧版本目录应被移出 nginx include 范围:%v", err)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(filepath.Join(dir, ".fonuagent-https.conf.versions"))
|
||||||
|
if err != nil || len(entries) != 1 {
|
||||||
|
t.Fatalf("隐藏版本目录应保留 1 个版本:%v %v", entries, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateVersionDirsFromNginxOutput(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
legacy := filepath.Join(dir, "fonuagent-https.conf.versions")
|
||||||
|
if err := os.MkdirAll(legacy, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
output := "2026/09/28 14:38:16 [crit] 6053#6053: pread() \"" + legacy + "\" failed (21: Is a directory)"
|
||||||
|
if !migrateVersionDirsFromNginxOutput(output) {
|
||||||
|
t.Fatal("应从 nginx 报错文本中识别并迁移旧版本目录")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("旧版本目录应被移出 include 范围:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCertificatePin(t *testing.T) {
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
NotBefore: time.Now().Add(-time.Minute),
|
||||||
|
NotAfter: time.Now().Add(time.Hour),
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
block := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||||
|
got, err := certificatePin(string(block))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(der)
|
||||||
|
if want := hex.EncodeToString(sum[:]); got != want {
|
||||||
|
t.Fatalf("certificatePin() = %s, 期望 %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyRouteContentSavesPreviousVersion(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "fonu-a.example.com.conf")
|
||||||
|
if err := os.WriteFile(path, []byte("old config"), 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := &Server{cfg: Config{NginxConfDir: dir}, validateNginxFn: func() error { return nil }}
|
||||||
|
if err := server.applyRouteContent(path, []byte("new config")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
current, err := os.ReadFile(path)
|
||||||
|
if err != nil || string(current) != "new config" {
|
||||||
|
t.Fatalf("当前配置未更新:%q %v", current, err)
|
||||||
|
}
|
||||||
|
versions, err := fsutil.ListVersions(path)
|
||||||
|
if err != nil || len(versions) != 1 {
|
||||||
|
t.Fatalf("写 vhost 前应保存上一版本:%v %v", versions, err)
|
||||||
|
}
|
||||||
|
previous, err := fsutil.ReadVersion(path, versions[0].Name)
|
||||||
|
if err != nil || string(previous) != "old config" {
|
||||||
|
t.Fatalf("历史版本内容不符:%q %v", previous, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyRouteContentRestoresOnValidationFailure(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "fonu-a.example.com.conf")
|
||||||
|
if err := os.WriteFile(path, []byte("old config"), 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := &Server{
|
||||||
|
cfg: Config{NginxConfDir: dir},
|
||||||
|
validateNginxFn: func() error { return errors.New("nginx rejected") },
|
||||||
|
}
|
||||||
|
if err := server.applyRouteContent(path, []byte("new config")); err == nil {
|
||||||
|
t.Fatal("校验失败应返回错误")
|
||||||
|
}
|
||||||
|
current, err := os.ReadFile(path)
|
||||||
|
if err != nil || string(current) != "old config" {
|
||||||
|
t.Fatalf("校验失败后应恢复上一可用版本:%q %v", current, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyRouteContentRemovesNewFileOnValidationFailure(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "fonu-new.example.com.conf")
|
||||||
|
server := &Server{
|
||||||
|
cfg: Config{NginxConfDir: dir},
|
||||||
|
validateNginxFn: func() error { return errors.New("nginx rejected") },
|
||||||
|
}
|
||||||
|
if err := server.applyRouteContent(path, []byte("new config")); err == nil {
|
||||||
|
t.Fatal("校验失败应返回错误")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("首次部署失败应删除新文件,避免留下坏配置:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRollbackRouteReloadsAfterApply(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
reloaded := false
|
||||||
|
server := &Server{
|
||||||
|
cfg: Config{NginxConfDir: dir},
|
||||||
|
validateNginxFn: func() error { return nil },
|
||||||
|
reloadNginxFn: func() error { reloaded = true; return nil },
|
||||||
|
}
|
||||||
|
path := server.routePath("a.example.com")
|
||||||
|
if err := os.WriteFile(path, []byte("healthy"), 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := fsutil.BackupVersioned(path, maxRouteVersions); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
versions, err := fsutil.ListVersions(path)
|
||||||
|
if err != nil || len(versions) != 1 {
|
||||||
|
t.Fatalf("准备版本失败:%v %v", versions, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte("broken"), 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("POST /api/v1/routes/{domain}/versions/{name}/rollback", server.rollbackRoute)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/routes/a.example.com/versions/"+versions[0].Name+"/rollback", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("回滚应成功:%d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !reloaded {
|
||||||
|
t.Fatal("回滚后应执行 nginx reload")
|
||||||
|
}
|
||||||
|
current, err := os.ReadFile(path)
|
||||||
|
if err != nil || string(current) != "healthy" {
|
||||||
|
t.Fatalf("回滚后内容不符:%q %v", current, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fonu/fonu/internal/auth"
|
"github.com/fonu/fonu/internal/auth"
|
||||||
@@ -15,10 +16,67 @@ import (
|
|||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
auth *auth.Service
|
auth *auth.Service
|
||||||
notify *notify.Service
|
notify *notify.Service
|
||||||
|
guard *loginGuard
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthHandler(authSvc *auth.Service, notifySvc *notify.Service) *AuthHandler {
|
func NewAuthHandler(authSvc *auth.Service, notifySvc *notify.Service) *AuthHandler {
|
||||||
return &AuthHandler{auth: authSvc, notify: notifySvc}
|
return &AuthHandler{auth: authSvc, notify: notifySvc, guard: newLoginGuard()}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
loginFailureLimit = 5
|
||||||
|
loginLockoutWindow = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// loginGuard 是进程内的登录失败退避:同一 IP 在窗口内失败到阈值后直接拒绝,
|
||||||
|
// 避免明文 HTTP 部署下被无限爆破;与用于通知的失败统计相互独立。
|
||||||
|
type loginGuard struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
failures map[string][]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLoginGuard() *loginGuard {
|
||||||
|
return &loginGuard{failures: make(map[string][]time.Time)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// blocked 返回该 IP 是否仍在锁定窗口内(顺带清理过期记录)。
|
||||||
|
func (g *loginGuard) blocked(ip string) bool {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
kept := g.recentLocked(ip)
|
||||||
|
g.failures[ip] = kept
|
||||||
|
return len(kept) >= loginFailureLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *loginGuard) fail(ip string) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
if len(g.failures) > 1024 {
|
||||||
|
for key := range g.failures {
|
||||||
|
if len(g.recentLocked(key)) == 0 {
|
||||||
|
delete(g.failures, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.failures[ip] = append(g.recentLocked(ip), time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *loginGuard) reset(ip string) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
delete(g.failures, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recentLocked 返回窗口内的失败时间(调用方需持有锁)。
|
||||||
|
func (g *loginGuard) recentLocked(ip string) []time.Time {
|
||||||
|
now := time.Now()
|
||||||
|
kept := make([]time.Time, 0, len(g.failures[ip]))
|
||||||
|
for _, at := range g.failures[ip] {
|
||||||
|
if now.Sub(at) < loginLockoutWindow {
|
||||||
|
kept = append(kept, at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kept
|
||||||
}
|
}
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
@@ -27,7 +85,7 @@ type loginRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type authStatusResponse struct {
|
type authStatusResponse struct {
|
||||||
Initialized bool `json:"initialized"`
|
Initialized bool `json:"initialized"`
|
||||||
Authenticated bool `json:"authenticated"`
|
Authenticated bool `json:"authenticated"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,11 +113,17 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ip := clientIP(r)
|
||||||
|
if h.guard.blocked(ip) {
|
||||||
|
writeError(r, w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试")
|
||||||
|
return
|
||||||
|
}
|
||||||
sessionID, err := h.auth.Login(r.Context(), req.Username, req.Password)
|
sessionID, err := h.auth.Login(r.Context(), req.Username, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, auth.ErrUnauthorized) {
|
if errors.Is(err, auth.ErrUnauthorized) {
|
||||||
|
h.guard.fail(ip)
|
||||||
if h.notify != nil {
|
if h.notify != nil {
|
||||||
h.notify.RecordLoginFailure(r.Context(), clientIP(r))
|
h.notify.RecordLoginFailure(r.Context(), ip)
|
||||||
}
|
}
|
||||||
writeError(r, w, http.StatusUnauthorized, "用户名或密码错误")
|
writeError(r, w, http.StatusUnauthorized, "用户名或密码错误")
|
||||||
return
|
return
|
||||||
@@ -67,7 +131,8 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(r, w, http.StatusInternalServerError, "登录失败")
|
writeError(r, w, http.StatusInternalServerError, "登录失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSessionCookie(w, sessionID)
|
h.guard.reset(ip)
|
||||||
|
setSessionCookie(w, r, sessionID)
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"message": "登录成功"})
|
writeJSON(w, http.StatusOK, map[string]string{"message": "登录成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +151,7 @@ func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
|
writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.auth.ChangePassword(r.Context(), adminID, req.OldPassword, req.NewPassword); err != nil {
|
if err := h.auth.ChangePassword(r.Context(), adminID, req.OldPassword, req.NewPassword, sessionID); err != nil {
|
||||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -97,21 +162,31 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
if sessionID := sessionIDFromContext(r.Context()); sessionID != "" {
|
if sessionID := sessionIDFromContext(r.Context()); sessionID != "" {
|
||||||
_ = h.auth.Logout(r.Context(), sessionID)
|
_ = h.auth.Logout(r.Context(), sessionID)
|
||||||
}
|
}
|
||||||
clearSessionCookie(w)
|
clearSessionCookie(w, r)
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"message": "已退出登录"})
|
writeJSON(w, http.StatusOK, map[string]string{"message": "已退出登录"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSessionCookie(w http.ResponseWriter, sessionID string) {
|
func setSessionCookie(w http.ResponseWriter, r *http.Request, sessionID string) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "fonu_session",
|
Name: "fonu_session",
|
||||||
Value: sessionID,
|
Value: sessionID,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
|
Secure: requestIsHTTPS(r),
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
MaxAge: int((7 * 24 * time.Hour).Seconds()),
|
MaxAge: int((7 * 24 * time.Hour).Seconds()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requestIsHTTPS:直连 TLS 或经反代(X-Forwarded-Proto)时给会话 cookie 加 Secure;
|
||||||
|
// 默认明文 HTTP 部署下不加,否则浏览器会直接丢弃 cookie 导致登录不了。
|
||||||
|
func requestIsHTTPS(r *http.Request) bool {
|
||||||
|
if r.TLS != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https")
|
||||||
|
}
|
||||||
|
|
||||||
func clientIP(r *http.Request) string {
|
func clientIP(r *http.Request) string {
|
||||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -120,12 +195,14 @@ func clientIP(r *http.Request) string {
|
|||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearSessionCookie(w http.ResponseWriter) {
|
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "fonu_session",
|
Name: "fonu_session",
|
||||||
Value: "",
|
Value: "",
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
|
Secure: requestIsHTTPS(r),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,16 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/fonu/fonu/internal/backup"
|
"github.com/fonu/fonu/internal/backup"
|
||||||
|
"github.com/fonu/fonu/internal/settings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BackupHandler struct {
|
type BackupHandler struct {
|
||||||
svc *backup.Service
|
svc *backup.Service
|
||||||
|
settings *settings.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBackupHandler(svc *backup.Service) *BackupHandler {
|
func NewBackupHandler(svc *backup.Service, settingsStore *settings.Store) *BackupHandler {
|
||||||
return &BackupHandler{svc: svc}
|
return &BackupHandler{svc: svc, settings: settingsStore}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *BackupHandler) Export(w http.ResponseWriter, r *http.Request) {
|
func (h *BackupHandler) Export(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -42,19 +44,20 @@ func (h *BackupHandler) Restore(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(r, w, http.StatusBadRequest, "上传文件无效")
|
writeError(r, w, http.StatusBadRequest, "上传文件无效")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
file, header, err := r.FormFile("file")
|
file, _, err := r.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(r, w, http.StatusBadRequest, "请选择备份文件")
|
writeError(r, w, http.StatusBadRequest, "请选择备份文件")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
tmpPath := filepath.Join(os.TempDir(), header.Filename)
|
// 上传文件名完全由客户端控制:不能拼进临时路径(../ 可逃出临时目录),交给 CreateTemp 生成
|
||||||
out, err := os.Create(tmpPath)
|
out, err := os.CreateTemp("", "fonu-restore-*.tar.gz")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(r, w, http.StatusInternalServerError, "保存上传文件失败")
|
writeError(r, w, http.StatusInternalServerError, "保存上传文件失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
tmpPath := out.Name()
|
||||||
if _, err := io.Copy(out, file); err != nil {
|
if _, err := io.Copy(out, file); err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
_ = os.Remove(tmpPath)
|
_ = os.Remove(tmpPath)
|
||||||
@@ -71,3 +74,94 @@ func (h *BackupHandler) Restore(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = os.Remove(tmpPath)
|
_ = os.Remove(tmpPath)
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Fonu"})
|
writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Fonu"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListArchives 列出留在磁盘上的备份(自动备份 + 手动「立即备份」)。
|
||||||
|
// 与「立即下载」不同:这些文件长期留在 <DataDir>/backups 下。
|
||||||
|
func (h *BackupHandler) ListArchives(w http.ResponseWriter, r *http.Request) {
|
||||||
|
archives, err := h.svc.List()
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusInternalServerError, "读取备份列表失败", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"archives": archives,
|
||||||
|
"keep": backup.KeepCount(r.Context(), h.settings),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateArchive 立即备份一份并保留在服务器上,顺带按保留份数剪枝
|
||||||
|
func (h *BackupHandler) CreateArchive(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path, err := h.svc.Export(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusInternalServerError, "创建备份失败", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := filepath.Base(path)
|
||||||
|
// 写失败的备份不能留在列表里冒充可用备份
|
||||||
|
if _, err := h.svc.Verify(r.Context(), path); err != nil {
|
||||||
|
_ = h.svc.Remove(name)
|
||||||
|
writeError(r, w, http.StatusInternalServerError, "备份校验失败:"+err.Error(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
removed, err := h.svc.Prune(backup.KeepCount(r.Context(), h.settings))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusInternalServerError, "备份已创建,但清理旧备份失败:"+err.Error(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"name": name, "removed": removed})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadArchive 下载指定备份;用 ServeContent 支持断点续传
|
||||||
|
func (h *BackupHandler) DownloadArchive(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path, err := h.svc.Path(r.PathValue("name"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusNotFound, "备份不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusInternalServerError, "读取备份失败", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/gzip")
|
||||||
|
w.Header().Set("Content-Disposition", "attachment; filename="+filepath.Base(path))
|
||||||
|
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreArchive 从指定备份恢复。恢复会覆盖数据库与证书,前端必须二次确认
|
||||||
|
func (h *BackupHandler) RestoreArchive(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path, err := h.svc.Path(r.PathValue("name"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 恢复前先校验:半个包不能拿去覆盖现有数据
|
||||||
|
if _, err := h.svc.Verify(r.Context(), path); err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "该备份不可用:"+err.Error(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.Restore(r.Context(), path); err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, err.Error(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Fonu"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteArchive 删除指定备份
|
||||||
|
func (h *BackupHandler) DeleteArchive(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.svc.Remove(r.PathValue("name")); err != nil {
|
||||||
|
if err.Error() == "备份不存在" {
|
||||||
|
writeError(r, w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ func (h *CertHandler) Options(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, []map[string]string{
|
writeJSON(w, http.StatusOK, []map[string]string{
|
||||||
{"value": acme.CALetsEncrypt, "label": acme.CALabel(acme.CALetsEncrypt)},
|
{"value": acme.CALetsEncrypt, "label": acme.CALabel(acme.CALetsEncrypt)},
|
||||||
{"value": acme.CAZeroSSL, "label": acme.CALabel(acme.CAZeroSSL)},
|
{"value": acme.CAZeroSSL, "label": acme.CALabel(acme.CAZeroSSL)},
|
||||||
{"value": acme.CABuypass, "label": acme.CALabel(acme.CABuypass)},
|
// Buypass 已于 2025-10-15 停止签发、2026-04-15 终止 ACME 服务,不再列为可选 CA
|
||||||
|
// (历史记录仍能展示,直接提交会被 CAUnavailableReason 抦下);LiteSSL 接替其位置。
|
||||||
|
{"value": acme.CALiteSSL, "label": acme.CALabel(acme.CALiteSSL)},
|
||||||
{"value": acme.CALetsEncryptStaging, "label": acme.CALabel(acme.CALetsEncryptStaging)},
|
{"value": acme.CALetsEncryptStaging, "label": acme.CALabel(acme.CALetsEncryptStaging)},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -44,7 +46,10 @@ func (h *CertHandler) Apply(w http.ResponseWriter, r *http.Request) {
|
|||||||
CA string `json:"ca"`
|
CA string `json:"ca"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
}
|
}
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
domains := req.Domains
|
domains := req.Domains
|
||||||
if len(domains) == 0 && strings.TrimSpace(req.Domain) != "" {
|
if len(domains) == 0 && strings.TrimSpace(req.Domain) != "" {
|
||||||
domains = []string{req.Domain}
|
domains = []string{req.Domain}
|
||||||
@@ -141,7 +146,10 @@ func (h *CertHandler) Renew(w http.ResponseWriter, r *http.Request) {
|
|||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
CA string `json:"ca"`
|
CA string `json:"ca"`
|
||||||
}
|
}
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
record, err := h.svc.Renew(r.Context(), req.Domain, req.CA)
|
record, err := h.svc.Renew(r.Context(), req.Domain, req.CA)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/cloudflared"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CloudflareHandler struct {
|
||||||
|
svc *cloudflared.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCloudflareHandler(svc *cloudflared.Service) *CloudflareHandler {
|
||||||
|
return &CloudflareHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
items, err := h.svc.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.svc.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input cloudflared.CreateInput
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&input); err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input.Network = normalizeCloudflareNetwork(input.Network)
|
||||||
|
item, err := h.svc.Create(r.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input cloudflared.CreateInput
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&input); err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input.Network = normalizeCloudflareNetwork(input.Network)
|
||||||
|
item, err := h.svc.Update(r.Context(), id, input)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.Delete(r.Context(), id); err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) Start(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.tunnelAction(w, r, h.svc.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) Stop(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.tunnelAction(w, r, h.svc.Stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) Restart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.tunnelAction(w, r, h.svc.Restart)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) RefreshCredentials(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.tunnelAction(w, r, h.svc.RefreshCredentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) tunnelAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64) (cloudflared.Tunnel, error)) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := fn(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) Status(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status, err := h.svc.RuntimeStatus(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) Logs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lines, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("lines")))
|
||||||
|
output, err := h.svc.Logs(r.Context(), id, lines)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"output": output})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) Config(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content, err := h.svc.PreviewConfig(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"content": content})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) Routes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
routes, err := h.svc.Routes(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, routes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) ReplaceRoutes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request struct {
|
||||||
|
Routes []cloudflared.RouteInput `json:"routes"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
routes, err := h.svc.ReplaceRoutes(r.Context(), id, request.Routes)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, routes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) Drift(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.svc.Drift(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) SyncRoutes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.PushTunnel(r.Context(), id); err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) Diagnostics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.svc.Diagnostics(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) Preflight(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, h.svc.Preflight(r.Context()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) TestRoute(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request struct {
|
||||||
|
Service string `json:"service"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.svc.TestService(r.Context(), request.Service)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) BinaryInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"version": h.svc.BinaryVersion(),
|
||||||
|
"mirrors": cloudflared.DefaultMirrors(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) AppSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
settings, err := h.svc.AppSettings(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) SaveAppSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input cloudflared.AppSettingsInput
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&input); err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input.Network = normalizeCloudflareNetwork(input.Network)
|
||||||
|
settings, err := h.svc.SaveAppSettings(r.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) TestToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message, err := h.svc.TestToken(r.Context(), request.AccountID, request.Token, request.Kind)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": message})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) SetEnabled(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&request); err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings, err := h.svc.SetEnabled(r.Context(), request.Enabled)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) DownloadBinary(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request struct {
|
||||||
|
Mirror string `json:"mirror"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&request); err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
version, err := h.svc.DownloadBinaryVersion(r.Context(), request.Mirror, request.Version)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) LatestBinary(w http.ResponseWriter, r *http.Request) {
|
||||||
|
version, err := h.svc.LatestBinaryVersion(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"version": version})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) RollbackBinary(w http.ResponseWriter, r *http.Request) {
|
||||||
|
version, err := h.svc.RollbackBinary()
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) IngressCandidates(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hosts, err := h.svc.IngressCandidates(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"hostnames": hosts})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) Zones(w http.ResponseWriter, r *http.Request) {
|
||||||
|
zones, err := h.svc.Zones(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"zones": zones})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudflareHandler) AccessStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hostname := strings.TrimSpace(r.URL.Query().Get("hostname"))
|
||||||
|
enabled, name, err := h.svc.AccessStatus(r.Context(), hostname)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"enabled": enabled, "name": name})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCloudflareNetwork(network cloudflared.NetworkSettings) cloudflared.NetworkSettings {
|
||||||
|
defaults := cloudflared.DefaultNetworkSettings()
|
||||||
|
if strings.TrimSpace(network.TransportProtocol) == "" {
|
||||||
|
network.TransportProtocol = defaults.TransportProtocol
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(network.EdgeIPVersion) == "" {
|
||||||
|
network.EdgeIPVersion = defaults.EdgeIPVersion
|
||||||
|
}
|
||||||
|
if network.HAConnections <= 0 {
|
||||||
|
network.HAConnections = defaults.HAConnections
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(network.ProxyMode) == "" {
|
||||||
|
network.ProxyMode = defaults.ProxyMode
|
||||||
|
}
|
||||||
|
return network
|
||||||
|
}
|
||||||
@@ -23,13 +23,13 @@ type ddnsRequest struct {
|
|||||||
RecordNames []string `json:"record_names"`
|
RecordNames []string `json:"record_names"`
|
||||||
Domains []string `json:"domains"`
|
Domains []string `json:"domains"`
|
||||||
IPv4Enabled *bool `json:"ipv4_enabled"`
|
IPv4Enabled *bool `json:"ipv4_enabled"`
|
||||||
IPv6Enabled *bool `json:"ipv6_enabled"`
|
IPv6Enabled *bool `json:"ipv6_enabled"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
CustomIPv4 string `json:"custom_ipv4"`
|
CustomIPv4 string `json:"custom_ipv4"`
|
||||||
CustomIPv6 string `json:"custom_ipv6"`
|
CustomIPv6 string `json:"custom_ipv6"`
|
||||||
APIToken string `json:"api_token"`
|
APIToken string `json:"api_token"`
|
||||||
APITokenID string `json:"api_token_id"`
|
APITokenID string `json:"api_token_id"`
|
||||||
APISecret string `json:"api_secret"`
|
APISecret string `json:"api_secret"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DDNSHandler) toInput(req ddnsRequest) ddns.SaveInput {
|
func (h *DDNSHandler) toInput(req ddnsRequest) ddns.SaveInput {
|
||||||
@@ -119,7 +119,10 @@ func (h *DDNSHandler) Test(w http.ResponseWriter, r *http.Request) {
|
|||||||
APITokenID string `json:"api_token_id"`
|
APITokenID string `json:"api_token_id"`
|
||||||
APISecret string `json:"api_secret"`
|
APISecret string `json:"api_secret"`
|
||||||
}
|
}
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := h.svc.Test(r.Context(), ddns.TestInput{
|
if err := h.svc.Test(r.Context(), ddns.TestInput{
|
||||||
ConfigID: req.ConfigID,
|
ConfigID: req.ConfigID,
|
||||||
Provider: req.Provider,
|
Provider: req.Provider,
|
||||||
|
|||||||
@@ -8,14 +8,17 @@ import (
|
|||||||
"github.com/fonu/fonu/internal/auth"
|
"github.com/fonu/fonu/internal/auth"
|
||||||
"github.com/fonu/fonu/internal/backup"
|
"github.com/fonu/fonu/internal/backup"
|
||||||
"github.com/fonu/fonu/internal/chinacidr"
|
"github.com/fonu/fonu/internal/chinacidr"
|
||||||
|
"github.com/fonu/fonu/internal/cloudflared"
|
||||||
"github.com/fonu/fonu/internal/config"
|
"github.com/fonu/fonu/internal/config"
|
||||||
"github.com/fonu/fonu/internal/ddns"
|
"github.com/fonu/fonu/internal/ddns"
|
||||||
"github.com/fonu/fonu/internal/discovery"
|
"github.com/fonu/fonu/internal/discovery"
|
||||||
"github.com/fonu/fonu/internal/frp"
|
"github.com/fonu/fonu/internal/frp"
|
||||||
|
"github.com/fonu/fonu/internal/metrics"
|
||||||
"github.com/fonu/fonu/internal/notify"
|
"github.com/fonu/fonu/internal/notify"
|
||||||
"github.com/fonu/fonu/internal/service"
|
"github.com/fonu/fonu/internal/service"
|
||||||
"github.com/fonu/fonu/internal/settings"
|
"github.com/fonu/fonu/internal/settings"
|
||||||
"github.com/fonu/fonu/internal/traffic"
|
"github.com/fonu/fonu/internal/traffic"
|
||||||
|
"github.com/fonu/fonu/internal/updater"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Deps struct {
|
type Deps struct {
|
||||||
@@ -28,10 +31,13 @@ type Deps struct {
|
|||||||
Settings *settings.Store
|
Settings *settings.Store
|
||||||
ChinaCIDR *chinacidr.Service
|
ChinaCIDR *chinacidr.Service
|
||||||
Backup *backup.Service
|
Backup *backup.Service
|
||||||
|
Metrics *metrics.Store
|
||||||
Discovery *discovery.Service
|
Discovery *discovery.Service
|
||||||
FRPMulti *frp.Service
|
FRPMulti *frp.Service
|
||||||
|
Cloudflare *cloudflared.Service
|
||||||
Traffic *traffic.Collector
|
Traffic *traffic.Collector
|
||||||
Notify *notify.Service
|
Notify *notify.Service
|
||||||
|
Updater *updater.Client
|
||||||
StaticFS fs.FS
|
StaticFS fs.FS
|
||||||
StartedAt string
|
StartedAt string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,23 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/auth"
|
||||||
"github.com/fonu/fonu/internal/frp"
|
"github.com/fonu/fonu/internal/frp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FRPMultiHandler 承载多服务端 FRP 模块的 HTTP 接口(/api/frpmulti/*),
|
// FRPMultiHandler 承载多服务端 FRP 模块的 HTTP 接口(/api/frpmulti/*),
|
||||||
// 包装 internal/frp 的 Service(0.1.25 核心迁移版)。
|
// 包装 internal/frp 的 Service(0.1.25 核心迁移版)。
|
||||||
type FRPMultiHandler struct {
|
type FRPMultiHandler struct {
|
||||||
svc *frp.Service
|
svc *frp.Service
|
||||||
|
auth *auth.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
type frpServerRequest struct {
|
type frpServerRequest struct {
|
||||||
@@ -63,8 +71,8 @@ type frpDownloadProxyRequest struct {
|
|||||||
Proxy string `json:"proxy"`
|
Proxy string `json:"proxy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFRPMultiHandler(svc *frp.Service) *FRPMultiHandler {
|
func NewFRPMultiHandler(svc *frp.Service, authSvc *auth.Service) *FRPMultiHandler {
|
||||||
return &FRPMultiHandler{svc: svc}
|
return &FRPMultiHandler{svc: svc, auth: authSvc}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *FRPMultiHandler) Status(w http.ResponseWriter, r *http.Request) {
|
func (h *FRPMultiHandler) Status(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -319,6 +327,136 @@ func (h *FRPMultiHandler) AgentTest(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *FRPMultiHandler) AgentTunnelRestart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
server, err := h.svc.RestartAgentTunnel(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, server)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FRPMultiHandler) SetAgentTransport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request struct {
|
||||||
|
Transport string `json:"transport"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var server frp.Server
|
||||||
|
switch strings.TrimSpace(request.Transport) {
|
||||||
|
case "tunnel":
|
||||||
|
server, err = h.svc.SwitchAgentToTunnel(r.Context(), id)
|
||||||
|
case "https-pin":
|
||||||
|
server, err = h.svc.EnableAgentHTTPS(r.Context(), id, request.Port)
|
||||||
|
default:
|
||||||
|
writeError(r, w, http.StatusBadRequest, "不支持的传输方式")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.audit(r, id, "transport", "切换为 "+server.AgentTransport)
|
||||||
|
writeJSON(w, http.StatusOK, server)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FRPMultiHandler) AgentTransportStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status, err := h.svc.AgentTransportStatus(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FRPMultiHandler) AgentTransportRotateCert(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
server, err := h.svc.RotateAgentCertificate(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.audit(r, id, "rotate-tls-cert", server.AgentTLSPin)
|
||||||
|
writeJSON(w, http.StatusOK, server)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FRPMultiHandler) AgentFirewallStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
port, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("port")))
|
||||||
|
status, err := h.svc.AgentFirewallStatus(r.Context(), id, port)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FRPMultiHandler) AgentFirewallAllow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.svc.AllowAgentFirewallPort(r.Context(), id, request.Port)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.audit(r, id, "firewall-allow", strconv.Itoa(request.Port))
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// audit 记录 Agent 传输配置变更;admin_id 取自登录会话,API Token 调用没有会话时记为 0。
|
||||||
|
func (h *FRPMultiHandler) audit(r *http.Request, serverID int64, action, detail string) {
|
||||||
|
adminID := 0
|
||||||
|
if sessionID, ok := r.Context().Value(sessionContextKey).(string); ok && sessionID != "" && h.auth != nil {
|
||||||
|
if id, err := h.auth.AdminIDForSession(r.Context(), sessionID); err == nil {
|
||||||
|
adminID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.Default().Info("审计:Agent 配置变更",
|
||||||
|
"module", "AUDIT",
|
||||||
|
"action", action,
|
||||||
|
"server_id", serverID,
|
||||||
|
"admin_id", adminID,
|
||||||
|
"detail", detail,
|
||||||
|
"remote_addr", r.RemoteAddr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *FRPMultiHandler) AgentStatus(w http.ResponseWriter, r *http.Request) {
|
func (h *FRPMultiHandler) AgentStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := parseID(r.PathValue("id"))
|
id, err := parseID(r.PathValue("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -394,38 +532,122 @@ func (h *FRPMultiHandler) AgentRoutes(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, details)
|
writeJSON(w, http.StatusOK, details)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentRouteConf 返回指定穿透规则(首个域名)当前反代配置内容。
|
// agentRouteTarget 解析服务端与规则归属,返回该规则用于公网反代的首个域名。
|
||||||
func (h *FRPMultiHandler) AgentRouteConf(w http.ResponseWriter, r *http.Request) {
|
// status 非 0 表示校验失败,调用方直接把 status 与 errMsg 写回响应。
|
||||||
id, err := parseID(r.PathValue("id"))
|
func (h *FRPMultiHandler) agentRouteTarget(r *http.Request, serverIDRaw, proxyIDRaw string) (serverID, proxyID int64, domain string, status int, errMsg string) {
|
||||||
|
serverID, err := parseID(serverIDRaw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
return 0, 0, "", http.StatusBadRequest, "FRP 服务端 ID 无效"
|
||||||
return
|
|
||||||
}
|
}
|
||||||
proxyID, err := parseID(r.PathValue("proxyID"))
|
proxyID, err = parseID(proxyIDRaw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
|
return 0, 0, "", http.StatusBadRequest, "FRP 规则 ID 无效"
|
||||||
return
|
|
||||||
}
|
}
|
||||||
proxy, err := h.svc.GetProxy(r.Context(), proxyID)
|
proxy, err := h.svc.GetProxy(r.Context(), proxyID)
|
||||||
if err != nil || proxy.ServerID != id {
|
if err != nil || proxy.ServerID != serverID {
|
||||||
writeError(r, w, http.StatusNotFound, "FRP 规则不存在")
|
return 0, 0, "", http.StatusNotFound, "FRP 规则不存在"
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if len(proxy.CustomDomains) == 0 {
|
if len(proxy.CustomDomains) == 0 {
|
||||||
writeError(r, w, http.StatusBadRequest, "该规则没有自定义域名")
|
return 0, 0, "", http.StatusBadRequest, "该规则没有自定义域名"
|
||||||
|
}
|
||||||
|
return serverID, proxyID, proxy.CustomDomains[0], 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentRouteConf 返回指定穿透规则(首个域名)当前反代配置内容。
|
||||||
|
func (h *FRPMultiHandler) AgentRouteConf(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serverID, proxyID, domain, status, msg := h.agentRouteTarget(r, r.PathValue("id"), r.PathValue("proxyID"))
|
||||||
|
if status != 0 {
|
||||||
|
writeError(r, w, status, msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
client, ok := h.svc.AgentClient(r.Context(), id)
|
client, ok := h.svc.AgentClient(r.Context(), serverID)
|
||||||
if !ok {
|
if !ok {
|
||||||
writeError(r, w, http.StatusBadGateway, "公网 agent 未配置或 Token 未保存")
|
writeError(r, w, http.StatusBadGateway, "公网 agent 未配置或 Token 未保存")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
content, err := client.RouteConf(r.Context(), proxy.CustomDomains[0])
|
content, err := client.RouteConf(r.Context(), domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"proxy_id": proxyID, "domain": proxy.CustomDomains[0], "content": content})
|
writeJSON(w, http.StatusOK, map[string]any{"proxy_id": proxyID, "domain": domain, "content": content})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteUptime 返回每条反代规则在最近窗口内的可用率与不可用区间。
|
||||||
|
// 数据来自后台巡检落库的「状态变更」序列,不做现场探测。
|
||||||
|
func (h *FRPMultiHandler) RouteUptime(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hours := 24
|
||||||
|
if raw := strings.TrimSpace(r.URL.Query().Get("hours")); raw != "" {
|
||||||
|
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 24*7 {
|
||||||
|
hours = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats, err := h.svc.RouteUptime(r.Context(), time.Duration(hours)*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusInternalServerError, "读取可用率失败", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"hours": hours, "items": stats})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentRouteVersions 返回规则首个域名在 VPS 上的当前 vhost 与历史版本,供差异对比与回滚。
|
||||||
|
func (h *FRPMultiHandler) AgentRouteVersions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serverID, _, domain, status, msg := h.agentRouteTarget(r, r.PathValue("id"), r.PathValue("proxyID"))
|
||||||
|
if status != 0 {
|
||||||
|
writeError(r, w, status, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client, ok := h.svc.AgentClient(r.Context(), serverID)
|
||||||
|
if !ok {
|
||||||
|
writeError(r, w, http.StatusBadGateway, "公网 agent 未配置或 Token 未保存")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := client.RouteVersions(r.Context(), domain)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentRouteVersion 读取某个历史版本的内容(与当前 vhost 对比用)。
|
||||||
|
func (h *FRPMultiHandler) AgentRouteVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serverID, _, domain, status, msg := h.agentRouteTarget(r, r.PathValue("id"), r.PathValue("proxyID"))
|
||||||
|
if status != 0 {
|
||||||
|
writeError(r, w, status, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client, ok := h.svc.AgentClient(r.Context(), serverID)
|
||||||
|
if !ok {
|
||||||
|
writeError(r, w, http.StatusBadGateway, "公网 agent 未配置或 Token 未保存")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := client.RouteVersion(r.Context(), domain, r.PathValue("name"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentRollbackRouteVersion 让 VPS 上的 vhost 回退到指定版本(agent 侧校验不过会保持当前配置)。
|
||||||
|
func (h *FRPMultiHandler) AgentRollbackRouteVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serverID, _, domain, status, msg := h.agentRouteTarget(r, r.PathValue("id"), r.PathValue("proxyID"))
|
||||||
|
if status != 0 {
|
||||||
|
writeError(r, w, status, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client, ok := h.svc.AgentClient(r.Context(), serverID)
|
||||||
|
if !ok {
|
||||||
|
writeError(r, w, http.StatusBadGateway, "公网 agent 未配置或 Token 未保存")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := client.RollbackRouteVersion(r.Context(), domain, r.PathValue("name"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentInstallNginx 触发 VPS 上一键安装 Nginx(需 agent 以 root 运行)。
|
// AgentInstallNginx 触发 VPS 上一键安装 Nginx(需 agent 以 root 运行)。
|
||||||
@@ -458,6 +680,122 @@ func (h *FRPMultiHandler) AgentRouteHealth(w http.ResponseWriter, r *http.Reques
|
|||||||
writeJSON(w, http.StatusOK, health)
|
writeJSON(w, http.StatusOK, health)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AgentRouteHealthSummary 汇总所有已启用服务端的公网反代健康状态(仪表盘用,避免前端逐台请求)
|
||||||
|
func (h *FRPMultiHandler) AgentRouteHealthSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
|
summary, err := h.svc.SummarizeRouteHealth(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusInternalServerError, "汇总公网反代健康失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentRouteDrift 比对库里的期望与 VPS 上的实际配置,返回部署漂移清单
|
||||||
|
func (h *FRPMultiHandler) AgentRouteDrift(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
report, err := h.svc.RouteDrift(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, report)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentRedeployRoutes 把该服务端上所有启用中的公网反代规则重新下发一遍
|
||||||
|
func (h *FRPMultiHandler) AgentRedeployRoutes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.svc.RedeployAllRoutes(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentMetrics 返回 VPS 主机资源(需 agent ≥ 0.10.0)
|
||||||
|
func (h *FRPMultiHandler) AgentMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
metrics, err := h.svc.AgentMetrics(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, metrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentNginxLogs 读取 VPS 上 Nginx 的 access / error 日志尾部(需 agent ≥ 0.10.0)
|
||||||
|
func (h *FRPMultiHandler) AgentNginxLogs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lines, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("lines")))
|
||||||
|
logs, err := h.svc.AgentNginxLogs(r.Context(), id, strings.TrimSpace(r.URL.Query().Get("type")), lines)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, logs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentFRPSConfig 回读 VPS 上的 frps.toml(需 agent ≥ 0.10.0)
|
||||||
|
func (h *FRPMultiHandler) AgentFRPSConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config, err := h.svc.AgentFRPSConfig(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentCerts 列出 VPS 证书目录下的域名与到期时间(需 agent ≥ 0.11.0)
|
||||||
|
func (h *FRPMultiHandler) AgentCerts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
certs, err := h.svc.AgentCerts(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, certs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentNginxReload 显式校验并重载 VPS 上的 Nginx(需 agent ≥ 0.11.0)
|
||||||
|
func (h *FRPMultiHandler) AgentNginxReload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.svc.AgentNginxReload(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request) {
|
func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := parseID(r.PathValue("id"))
|
id, err := parseID(r.PathValue("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -488,7 +826,11 @@ func (h *FRPMultiHandler) InstallFRPSOnAgent(w http.ResponseWriter, r *http.Requ
|
|||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Proxy string `json:"proxy"`
|
Proxy string `json:"proxy"`
|
||||||
}
|
}
|
||||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
// 允许空请求体(走默认版本),但 JSON 语法错误要报 400
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
if body.Version == "" {
|
if body.Version == "" {
|
||||||
body.Version = "0.71.0"
|
body.Version = "0.71.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/metrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MetricsHandler struct {
|
||||||
|
store *metrics.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMetricsHandler(store *metrics.Store) *MetricsHandler {
|
||||||
|
return &MetricsHandler{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
// History 返回指标历史:窗口内的序列已按桶取平均,前端拿到就能直接画。
|
||||||
|
// hours 最多 30 天(=保留期),buckets 最多 metrics.MaxBuckets。
|
||||||
|
func (h *MetricsHandler) History(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hours := 24
|
||||||
|
if raw := r.URL.Query().Get("hours"); raw != "" {
|
||||||
|
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 24*30 {
|
||||||
|
hours = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buckets := 48
|
||||||
|
if raw := r.URL.Query().Get("buckets"); raw != "" {
|
||||||
|
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
|
||||||
|
buckets = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if buckets > metrics.MaxBuckets {
|
||||||
|
buckets = metrics.MaxBuckets
|
||||||
|
}
|
||||||
|
series, err := h.store.Query(r.Context(), time.Duration(hours)*time.Hour, buckets)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusInternalServerError, "读取指标历史失败", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"hours": hours,
|
||||||
|
"buckets": buckets,
|
||||||
|
"series": series,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/fonu/fonu/internal/auth"
|
"github.com/fonu/fonu/internal/auth"
|
||||||
)
|
)
|
||||||
@@ -24,9 +25,30 @@ func SessionMiddleware(authSvc *auth.Service) func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RequireAuth 接受两种凭据:浏览器的会话 cookie,以及 Authorization: Bearer <api token>。
|
||||||
|
// 令牌分读写两种范围:read 只允许 GET/HEAD,write 与会话同权。
|
||||||
func RequireAuth(authSvc *auth.Service) func(http.Handler) http.Handler {
|
func RequireAuth(authSvc *auth.Service) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if token := bearerToken(r); token != "" {
|
||||||
|
scope, err := authSvc.ValidateAPIToken(r.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusUnauthorized, "API Token 无效或已过期")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 账号与令牌管理只允许浏览器会话:否则一个 write 令牌可以自己给自己提权
|
||||||
|
if tokenDeniedPath(r.URL.Path) {
|
||||||
|
writeError(r, w, http.StatusForbidden, "该操作需要使用登录会话")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if scope != auth.TokenScopeWrite && r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
writeError(r, w, http.StatusForbidden, "该 Token 只读,不能执行写操作")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
sessionID, _ := r.Context().Value(sessionContextKey).(string)
|
sessionID, _ := r.Context().Value(sessionContextKey).(string)
|
||||||
if err := authSvc.ValidateSession(r.Context(), sessionID); err != nil {
|
if err := authSvc.ValidateSession(r.Context(), sessionID); err != nil {
|
||||||
writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
|
writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
|
||||||
@@ -37,6 +59,25 @@ func RequireAuth(authSvc *auth.Service) func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bearerToken 取出 Authorization: Bearer 里的明文令牌;没有或格式不对返回空串。
|
||||||
|
func bearerToken(r *http.Request) string {
|
||||||
|
header := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||||
|
const prefix = "Bearer "
|
||||||
|
if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(header[len(prefix):])
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenDeniedPath 是只允许浏览器会话的路径(账号与令牌管理)。
|
||||||
|
func tokenDeniedPath(path string) bool {
|
||||||
|
if path == "/api/auth/password" || path == "/api/auth/logout" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(path, "/api/settings/tokens") ||
|
||||||
|
strings.HasPrefix(path, "/api/system/update")
|
||||||
|
}
|
||||||
|
|
||||||
func sessionIDFromContext(ctx context.Context) string {
|
func sessionIDFromContext(ctx context.Context) string {
|
||||||
v, _ := ctx.Value(sessionContextKey).(string)
|
v, _ := ctx.Value(sessionContextKey).(string)
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/auth"
|
||||||
|
"github.com/fonu/fonu/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTokenTestAuth(t *testing.T, scope string) (*auth.Service, string) {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
svc := auth.New(conn)
|
||||||
|
created, err := svc.CreateAPIToken(context.Background(), "test", scope, time.Time{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create token: %v", err)
|
||||||
|
}
|
||||||
|
return svc, created.Token
|
||||||
|
}
|
||||||
|
|
||||||
|
func okTokenHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireAuthAcceptsReadTokenForGet(t *testing.T) {
|
||||||
|
svc, plain := newTokenTestAuth(t, auth.TokenScopeRead)
|
||||||
|
handler := RequireAuth(svc)(okTokenHandler())
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+plain)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("只读令牌应能读,实际 %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireAuthBlocksWriteForReadToken(t *testing.T) {
|
||||||
|
svc, plain := newTokenTestAuth(t, auth.TokenScopeRead)
|
||||||
|
handler := RequireAuth(svc)(okTokenHandler())
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/api/proxies/1", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+plain)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("只读令牌不应能写,实际 %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireAuthAllowsWriteForWriteToken(t *testing.T) {
|
||||||
|
svc, plain := newTokenTestAuth(t, auth.TokenScopeWrite)
|
||||||
|
handler := RequireAuth(svc)(okTokenHandler())
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/proxies", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+plain)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("写令牌应能写,实际 %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireAuthTokenCannotManageTokens(t *testing.T) {
|
||||||
|
// 写令牌也不能管理令牌:否则等于拿令牌给自己提权或无限续期
|
||||||
|
svc, plain := newTokenTestAuth(t, auth.TokenScopeWrite)
|
||||||
|
handler := RequireAuth(svc)(okTokenHandler())
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/settings/tokens", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+plain)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("令牌管理应只允许登录会话,实际 %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireAuthRejectsBadCredentials(t *testing.T) {
|
||||||
|
svc, _ := newTokenTestAuth(t, auth.TokenScopeRead)
|
||||||
|
handler := RequireAuth(svc)(okTokenHandler())
|
||||||
|
|
||||||
|
cases := map[string]string{
|
||||||
|
"未知令牌": "Bearer deadbeef",
|
||||||
|
"错误前缀": "Token abc",
|
||||||
|
"没有凭据": "",
|
||||||
|
}
|
||||||
|
for name, header := range cases {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||||
|
if header != "" {
|
||||||
|
req.Header.Set("Authorization", header)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("%s 应返回 401,实际 %d", name, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerTokenParsing(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"Bearer abc": "abc",
|
||||||
|
"bearer abc": "abc",
|
||||||
|
"Bearer abc": "abc",
|
||||||
|
"Basic abc": "",
|
||||||
|
"Bearer ": "",
|
||||||
|
"": "",
|
||||||
|
}
|
||||||
|
for header, want := range cases {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
if header != "" {
|
||||||
|
req.Header.Set("Authorization", header)
|
||||||
|
}
|
||||||
|
if got := bearerToken(req); got != want {
|
||||||
|
t.Fatalf("Authorization %q 解析为 %q,期望 %q", header, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,6 +123,33 @@ func (h *NginxHandler) RollbackGlobal(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, view)
|
writeJSON(w, http.StatusOK, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *NginxHandler) GetConfigVersions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
view, err := h.proxy.GetNginxConfigVersions()
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(r, w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NginxHandler) GetConfigVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
view, err := h.proxy.ReadNginxConfigVersion(r.PathValue("name"))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(r, w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NginxHandler) RollbackConfigVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
view, err := h.proxy.RollbackNginxConfigVersion(r.Context(), r.PathValue("name"))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(r, w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
func writeServiceError(r *http.Request, w http.ResponseWriter, err error) {
|
func writeServiceError(r *http.Request, w http.ResponseWriter, err error) {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ type proxyRequest struct {
|
|||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Remark *string `json:"remark"` // deprecated alias for name
|
Remark *string `json:"remark"` // deprecated alias for name
|
||||||
Security *securityRequest `json:"security,omitempty"`
|
Security *securityRequest `json:"security,omitempty"`
|
||||||
|
Exits []string `json:"exits"`
|
||||||
|
CFTunnelID *int64 `json:"cf_tunnel_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type proxyReorderRequest struct {
|
type proxyReorderRequest struct {
|
||||||
@@ -80,6 +82,8 @@ func (h *ProxyHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
Enabled: boolDefault(req.Enabled, true),
|
Enabled: boolDefault(req.Enabled, true),
|
||||||
Name: name,
|
Name: name,
|
||||||
Security: req.securityInput(),
|
Security: req.securityInput(),
|
||||||
|
Exits: req.Exits,
|
||||||
|
CFTunnelID: int64Default(req.CFTunnelID, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
rule, err := h.svc.Create(r.Context(), in)
|
rule, err := h.svc.Create(r.Context(), in)
|
||||||
@@ -135,6 +139,12 @@ func (h *ProxyHandler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
sec := req.securityInput()
|
sec := req.securityInput()
|
||||||
in.Security = &sec
|
in.Security = &sec
|
||||||
}
|
}
|
||||||
|
if req.Exits != nil {
|
||||||
|
in.Exits = &req.Exits
|
||||||
|
}
|
||||||
|
if req.CFTunnelID != nil {
|
||||||
|
in.CFTunnelID = req.CFTunnelID
|
||||||
|
}
|
||||||
|
|
||||||
rule, err := h.svc.Update(r.Context(), id, in)
|
rule, err := h.svc.Update(r.Context(), id, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -258,3 +268,10 @@ func boolDefault(v *bool, fallback bool) bool {
|
|||||||
}
|
}
|
||||||
return *v
|
return *v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func int64Default(v *int64, fallback int64) int64 {
|
||||||
|
if v == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return *v
|
||||||
|
}
|
||||||
|
|||||||
+138
-37
@@ -10,37 +10,63 @@ import (
|
|||||||
"github.com/fonu/fonu/internal/auth"
|
"github.com/fonu/fonu/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// routeSpec 是一条 API 路由的注册信息;测试据此遍历全部路由验证鉴权覆盖。
|
||||||
|
type routeSpec struct {
|
||||||
|
Pattern string
|
||||||
|
Handler http.HandlerFunc
|
||||||
|
Public bool
|
||||||
|
}
|
||||||
|
|
||||||
func NewRouter(deps Deps) http.Handler {
|
func NewRouter(deps Deps) http.Handler {
|
||||||
|
handler, _ := newRouterWithSpecs(deps)
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
|
||||||
logsHandler := NewLogsHandler(deps.Config)
|
logsHandler := NewLogsHandler(deps.Config)
|
||||||
r := &Router{
|
r := &Router{
|
||||||
authHandler: NewAuthHandler(deps.Auth, deps.Notify),
|
authHandler: NewAuthHandler(deps.Auth, deps.Notify),
|
||||||
proxyHandler: NewProxyHandler(deps.Config, deps.Proxy, logsHandler, deps.Traffic),
|
proxyHandler: NewProxyHandler(deps.Config, deps.Proxy, logsHandler, deps.Traffic),
|
||||||
nginxHandler: NewNginxHandler(deps.Proxy),
|
nginxHandler: NewNginxHandler(deps.Proxy),
|
||||||
statusHandler: NewStatusHandler(deps.Config, deps.Proxy, deps.DDNS, deps.ACME, deps.StartedAt, deps.Config.NginxPIDFile),
|
statusHandler: NewStatusHandler(deps.Config, deps.Proxy, deps.DDNS, deps.ACME, deps.Cloudflare, deps.StartedAt, deps.Config.NginxPIDFile),
|
||||||
ddnsHandler: NewDDNSHandler(deps.DDNS),
|
ddnsHandler: NewDDNSHandler(deps.DDNS),
|
||||||
certHandler: NewCertHandler(deps.ACME),
|
certHandler: NewCertHandler(deps.ACME),
|
||||||
settingsHandler: NewSettingsHandler(deps.Settings, deps.Proxy, deps.Notify),
|
settingsHandler: NewSettingsHandler(deps.Settings, deps.Proxy, deps.Notify),
|
||||||
chinaCIDRHandler: NewChinaCIDRHandler(deps.ChinaCIDR),
|
chinaCIDRHandler: NewChinaCIDRHandler(deps.ChinaCIDR),
|
||||||
logsHandler: logsHandler,
|
logsHandler: logsHandler,
|
||||||
backupHandler: NewBackupHandler(deps.Backup),
|
backupHandler: NewBackupHandler(deps.Backup, deps.Settings),
|
||||||
discoveryHandler: NewDiscoveryHandler(deps.Discovery),
|
statusPageHandler: NewStatusPageHandler(deps.Settings, deps.FRPMulti, deps.Cloudflare),
|
||||||
frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti),
|
metricsHandler: NewMetricsHandler(deps.Metrics),
|
||||||
authSvc: deps.Auth,
|
discoveryHandler: NewDiscoveryHandler(deps.Discovery),
|
||||||
staticFS: deps.StaticFS,
|
frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti, deps.Auth),
|
||||||
|
cloudflareHandler: NewCloudflareHandler(deps.Cloudflare),
|
||||||
|
updateHandler: NewUpdateHandler(deps.Updater),
|
||||||
|
authSvc: deps.Auth,
|
||||||
|
staticFS: deps.StaticFS,
|
||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
var specs []routeSpec
|
||||||
mux.HandleFunc("GET /api/version", Version(deps.Config))
|
public := func(pattern string, handler http.HandlerFunc) {
|
||||||
mux.HandleFunc("GET /api/auth/status", r.authHandler.Status)
|
specs = append(specs, routeSpec{Pattern: pattern, Handler: handler, Public: true})
|
||||||
mux.HandleFunc("POST /api/auth/login", r.authHandler.Login)
|
}
|
||||||
mux.HandleFunc("POST /api/auth/logout", r.authHandler.Logout)
|
protected := func(pattern string, handler http.HandlerFunc) {
|
||||||
|
specs = append(specs, routeSpec{Pattern: pattern, Handler: handler})
|
||||||
protect := func(pattern string, handler http.HandlerFunc) {
|
|
||||||
mux.Handle(pattern, SessionMiddleware(deps.Auth)(RequireAuth(deps.Auth)(http.HandlerFunc(handler))))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public("GET /api/version", Version(deps.Config))
|
||||||
|
// 唯一一个匿名只读接口:默认关闭、可设访问码、按 IP 限流
|
||||||
|
public("GET /api/status-page", r.statusPageHandler.Get)
|
||||||
|
public("GET /api/auth/status", r.authHandler.Status)
|
||||||
|
public("POST /api/auth/login", r.authHandler.Login)
|
||||||
|
public("POST /api/auth/logout", r.authHandler.Logout)
|
||||||
|
|
||||||
|
protect := protected
|
||||||
|
|
||||||
protect("POST /api/auth/password", r.authHandler.ChangePassword)
|
protect("POST /api/auth/password", r.authHandler.ChangePassword)
|
||||||
|
protect("GET /api/settings/tokens", r.authHandler.ListTokens)
|
||||||
|
protect("POST /api/settings/tokens", r.authHandler.CreateToken)
|
||||||
|
protect("DELETE /api/settings/tokens/{id}", r.authHandler.DeleteToken)
|
||||||
protect("GET /api/status", r.statusHandler.Get)
|
protect("GET /api/status", r.statusHandler.Get)
|
||||||
protect("GET /api/proxies", r.proxyHandler.List)
|
protect("GET /api/proxies", r.proxyHandler.List)
|
||||||
protect("GET /api/proxies/traffic", r.proxyHandler.Traffic)
|
protect("GET /api/proxies/traffic", r.proxyHandler.Traffic)
|
||||||
@@ -56,6 +82,9 @@ func NewRouter(deps Deps) http.Handler {
|
|||||||
protect("GET /api/settings/nginx/global", r.nginxHandler.GetGlobal)
|
protect("GET /api/settings/nginx/global", r.nginxHandler.GetGlobal)
|
||||||
protect("PUT /api/settings/nginx/global", r.nginxHandler.PutGlobal)
|
protect("PUT /api/settings/nginx/global", r.nginxHandler.PutGlobal)
|
||||||
protect("POST /api/settings/nginx/global/rollback", r.nginxHandler.RollbackGlobal)
|
protect("POST /api/settings/nginx/global/rollback", r.nginxHandler.RollbackGlobal)
|
||||||
|
protect("GET /api/settings/nginx/config/versions", r.nginxHandler.GetConfigVersions)
|
||||||
|
protect("GET /api/settings/nginx/config/versions/{name}", r.nginxHandler.GetConfigVersion)
|
||||||
|
protect("POST /api/settings/nginx/config/versions/{name}/rollback", r.nginxHandler.RollbackConfigVersion)
|
||||||
protect("GET /api/ddns", r.ddnsHandler.List)
|
protect("GET /api/ddns", r.ddnsHandler.List)
|
||||||
protect("POST /api/ddns", r.ddnsHandler.Create)
|
protect("POST /api/ddns", r.ddnsHandler.Create)
|
||||||
protect("PUT /api/ddns/{id}", r.ddnsHandler.Update)
|
protect("PUT /api/ddns/{id}", r.ddnsHandler.Update)
|
||||||
@@ -82,9 +111,17 @@ func NewRouter(deps Deps) http.Handler {
|
|||||||
protect("GET /api/logs/access", r.logsHandler.Access)
|
protect("GET /api/logs/access", r.logsHandler.Access)
|
||||||
protect("GET /api/logs/error", r.logsHandler.Error)
|
protect("GET /api/logs/error", r.logsHandler.Error)
|
||||||
protect("GET /api/logs/system", r.logsHandler.System)
|
protect("GET /api/logs/system", r.logsHandler.System)
|
||||||
|
protect("GET /api/metrics/history", r.metricsHandler.History)
|
||||||
protect("GET /api/logs/stream", r.logsHandler.Stream)
|
protect("GET /api/logs/stream", r.logsHandler.Stream)
|
||||||
|
protect("GET /api/system/update/status", r.updateHandler.Status)
|
||||||
|
protect("POST /api/system/update/apply", r.updateHandler.Apply)
|
||||||
protect("GET /api/backup/export", r.backupHandler.Export)
|
protect("GET /api/backup/export", r.backupHandler.Export)
|
||||||
protect("POST /api/backup/restore", r.backupHandler.Restore)
|
protect("POST /api/backup/restore", r.backupHandler.Restore)
|
||||||
|
protect("GET /api/backup/archives", r.backupHandler.ListArchives)
|
||||||
|
protect("POST /api/backup/archives", r.backupHandler.CreateArchive)
|
||||||
|
protect("GET /api/backup/archives/{name}", r.backupHandler.DownloadArchive)
|
||||||
|
protect("POST /api/backup/archives/{name}/restore", r.backupHandler.RestoreArchive)
|
||||||
|
protect("DELETE /api/backup/archives/{name}", r.backupHandler.DeleteArchive)
|
||||||
protect("GET /api/discovery/scan", r.discoveryHandler.Scan)
|
protect("GET /api/discovery/scan", r.discoveryHandler.Scan)
|
||||||
if deps.FRPMulti != nil {
|
if deps.FRPMulti != nil {
|
||||||
frpHandler := r.frpmultiHandler
|
frpHandler := r.frpmultiHandler
|
||||||
@@ -112,11 +149,29 @@ func NewRouter(deps Deps) http.Handler {
|
|||||||
protect("POST /api/frpmulti/servers/{id}/agent/uninstall", frpHandler.AgentUninstall)
|
protect("POST /api/frpmulti/servers/{id}/agent/uninstall", frpHandler.AgentUninstall)
|
||||||
protect("POST /api/frpmulti/servers/{id}/agent/ssh-diagnose", frpHandler.AgentSSHDiagnose)
|
protect("POST /api/frpmulti/servers/{id}/agent/ssh-diagnose", frpHandler.AgentSSHDiagnose)
|
||||||
protect("POST /api/frpmulti/servers/{id}/agent/test", frpHandler.AgentTest)
|
protect("POST /api/frpmulti/servers/{id}/agent/test", frpHandler.AgentTest)
|
||||||
|
protect("POST /api/frpmulti/servers/{id}/agent/tunnel/restart", frpHandler.AgentTunnelRestart)
|
||||||
|
protect("POST /api/frpmulti/servers/{id}/agent/transport", frpHandler.SetAgentTransport)
|
||||||
|
protect("GET /api/frpmulti/servers/{id}/agent/transport", frpHandler.AgentTransportStatus)
|
||||||
|
protect("POST /api/frpmulti/servers/{id}/agent/transport/rotate", frpHandler.AgentTransportRotateCert)
|
||||||
|
protect("GET /api/frpmulti/servers/{id}/agent/firewall", frpHandler.AgentFirewallStatus)
|
||||||
|
protect("POST /api/frpmulti/servers/{id}/agent/firewall/allow", frpHandler.AgentFirewallAllow)
|
||||||
protect("GET /api/frpmulti/servers/{id}/agent/status", frpHandler.AgentStatus)
|
protect("GET /api/frpmulti/servers/{id}/agent/status", frpHandler.AgentStatus)
|
||||||
protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes)
|
protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes)
|
||||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/health", frpHandler.AgentRouteHealth)
|
protect("GET /api/frpmulti/servers/{id}/agent/routes/health", frpHandler.AgentRouteHealth)
|
||||||
|
protect("GET /api/frpmulti/routes/summary", frpHandler.AgentRouteHealthSummary)
|
||||||
|
protect("GET /api/frpmulti/routes/uptime", frpHandler.RouteUptime)
|
||||||
|
protect("GET /api/frpmulti/servers/{id}/agent/routes/drift", frpHandler.AgentRouteDrift)
|
||||||
|
protect("POST /api/frpmulti/servers/{id}/agent/routes/redeploy", frpHandler.AgentRedeployRoutes)
|
||||||
|
protect("GET /api/frpmulti/servers/{id}/agent/metrics", frpHandler.AgentMetrics)
|
||||||
|
protect("GET /api/frpmulti/servers/{id}/agent/logs/nginx", frpHandler.AgentNginxLogs)
|
||||||
|
protect("GET /api/frpmulti/servers/{id}/agent/frps-config", frpHandler.AgentFRPSConfig)
|
||||||
|
protect("GET /api/frpmulti/servers/{id}/agent/certs", frpHandler.AgentCerts)
|
||||||
|
protect("POST /api/frpmulti/servers/{id}/agent/nginx/reload", frpHandler.AgentNginxReload)
|
||||||
protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx)
|
protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx)
|
||||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf)
|
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf)
|
||||||
|
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions", frpHandler.AgentRouteVersions)
|
||||||
|
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions/{name}", frpHandler.AgentRouteVersion)
|
||||||
|
protect("POST /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions/{name}/rollback", frpHandler.AgentRollbackRouteVersion)
|
||||||
protect("GET /api/frpmulti/servers/{id}/agent/route-candidates", frpHandler.AgentRouteCandidates)
|
protect("GET /api/frpmulti/servers/{id}/agent/route-candidates", frpHandler.AgentRouteCandidates)
|
||||||
protect("POST /api/frpmulti/servers/{id}/agent/routes/{proxyID}", frpHandler.DeployAgentRoute)
|
protect("POST /api/frpmulti/servers/{id}/agent/routes/{proxyID}", frpHandler.DeployAgentRoute)
|
||||||
protect("DELETE /api/frpmulti/servers/{id}/agent/routes/{proxyID}", frpHandler.RemoveAgentRoute)
|
protect("DELETE /api/frpmulti/servers/{id}/agent/routes/{proxyID}", frpHandler.RemoveAgentRoute)
|
||||||
@@ -136,32 +191,78 @@ func NewRouter(deps Deps) http.Handler {
|
|||||||
protect("GET /api/frpmulti/config/export", frpHandler.ExportConfig)
|
protect("GET /api/frpmulti/config/export", frpHandler.ExportConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if deps.Cloudflare != nil {
|
||||||
|
cf := r.cloudflareHandler
|
||||||
|
protect("GET /api/cloudflare/tunnels", cf.List)
|
||||||
|
protect("POST /api/cloudflare/tunnels", cf.Create)
|
||||||
|
protect("GET /api/cloudflare/tunnels/{id}", cf.Get)
|
||||||
|
protect("PUT /api/cloudflare/tunnels/{id}", cf.Update)
|
||||||
|
protect("DELETE /api/cloudflare/tunnels/{id}", cf.Delete)
|
||||||
|
protect("POST /api/cloudflare/tunnels/{id}/start", cf.Start)
|
||||||
|
protect("POST /api/cloudflare/tunnels/{id}/stop", cf.Stop)
|
||||||
|
protect("POST /api/cloudflare/tunnels/{id}/restart", cf.Restart)
|
||||||
|
protect("POST /api/cloudflare/tunnels/{id}/credentials/refresh", cf.RefreshCredentials)
|
||||||
|
protect("GET /api/cloudflare/tunnels/{id}/status", cf.Status)
|
||||||
|
protect("GET /api/cloudflare/tunnels/{id}/logs", cf.Logs)
|
||||||
|
protect("GET /api/cloudflare/tunnels/{id}/config", cf.Config)
|
||||||
|
protect("GET /api/cloudflare/tunnels/{id}/routes", cf.Routes)
|
||||||
|
protect("PUT /api/cloudflare/tunnels/{id}/routes", cf.ReplaceRoutes)
|
||||||
|
protect("GET /api/cloudflare/tunnels/{id}/drift", cf.Drift)
|
||||||
|
protect("POST /api/cloudflare/tunnels/{id}/sync", cf.SyncRoutes)
|
||||||
|
protect("GET /api/cloudflare/tunnels/{id}/diagnostics", cf.Diagnostics)
|
||||||
|
protect("GET /api/cloudflare/binary", cf.BinaryInfo)
|
||||||
|
protect("POST /api/cloudflare/binary/download", cf.DownloadBinary)
|
||||||
|
protect("GET /api/cloudflare/binary/latest", cf.LatestBinary)
|
||||||
|
protect("POST /api/cloudflare/binary/rollback", cf.RollbackBinary)
|
||||||
|
protect("GET /api/cloudflare/settings", cf.AppSettings)
|
||||||
|
protect("PUT /api/cloudflare/settings", cf.SaveAppSettings)
|
||||||
|
protect("POST /api/cloudflare/settings/test", cf.TestToken)
|
||||||
|
protect("POST /api/cloudflare/enabled", cf.SetEnabled)
|
||||||
|
protect("GET /api/cloudflare/ingress-candidates", cf.IngressCandidates)
|
||||||
|
protect("GET /api/cloudflare/zones", cf.Zones)
|
||||||
|
protect("GET /api/cloudflare/access-status", cf.AccessStatus)
|
||||||
|
protect("GET /api/cloudflare/preflight", cf.Preflight)
|
||||||
|
protect("POST /api/cloudflare/routes/test", cf.TestRoute)
|
||||||
|
}
|
||||||
|
|
||||||
if deps.StaticFS != nil {
|
if deps.StaticFS != nil {
|
||||||
mux.Handle("/", r.spaHandler())
|
mux.Handle("/", r.spaHandler())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, spec := range specs {
|
||||||
|
if spec.Public {
|
||||||
|
mux.HandleFunc(spec.Pattern, spec.Handler)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mux.Handle(spec.Pattern, SessionMiddleware(deps.Auth)(RequireAuth(deps.Auth)(spec.Handler)))
|
||||||
|
}
|
||||||
|
|
||||||
logger := deps.Logger
|
logger := deps.Logger
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = slog.Default()
|
logger = slog.Default()
|
||||||
}
|
}
|
||||||
return loggingMiddleware(logger, SessionMiddleware(deps.Auth)(mux))
|
return loggingMiddleware(logger, SessionMiddleware(deps.Auth)(mux)), specs
|
||||||
}
|
}
|
||||||
|
|
||||||
type Router struct {
|
type Router struct {
|
||||||
authHandler *AuthHandler
|
authHandler *AuthHandler
|
||||||
proxyHandler *ProxyHandler
|
proxyHandler *ProxyHandler
|
||||||
nginxHandler *NginxHandler
|
nginxHandler *NginxHandler
|
||||||
statusHandler *StatusHandler
|
statusHandler *StatusHandler
|
||||||
ddnsHandler *DDNSHandler
|
ddnsHandler *DDNSHandler
|
||||||
certHandler *CertHandler
|
certHandler *CertHandler
|
||||||
settingsHandler *SettingsHandler
|
settingsHandler *SettingsHandler
|
||||||
chinaCIDRHandler *ChinaCIDRHandler
|
chinaCIDRHandler *ChinaCIDRHandler
|
||||||
logsHandler *LogsHandler
|
logsHandler *LogsHandler
|
||||||
backupHandler *BackupHandler
|
backupHandler *BackupHandler
|
||||||
discoveryHandler *DiscoveryHandler
|
statusPageHandler *StatusPageHandler
|
||||||
frpmultiHandler *FRPMultiHandler
|
metricsHandler *MetricsHandler
|
||||||
authSvc *auth.Service
|
discoveryHandler *DiscoveryHandler
|
||||||
staticFS fs.FS
|
frpmultiHandler *FRPMultiHandler
|
||||||
|
cloudflareHandler *CloudflareHandler
|
||||||
|
updateHandler *UpdateHandler
|
||||||
|
authSvc *auth.Service
|
||||||
|
staticFS fs.FS
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) spaHandler() http.Handler {
|
func (r *Router) spaHandler() http.Handler {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/auth"
|
||||||
|
"github.com/fonu/fonu/internal/certificate"
|
||||||
|
"github.com/fonu/fonu/internal/cloudflared"
|
||||||
|
"github.com/fonu/fonu/internal/config"
|
||||||
|
"github.com/fonu/fonu/internal/db"
|
||||||
|
"github.com/fonu/fonu/internal/frp"
|
||||||
|
"github.com/fonu/fonu/internal/secret"
|
||||||
|
"github.com/fonu/fonu/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var routeParamPattern = regexp.MustCompile(`\{[^}]+\}`)
|
||||||
|
|
||||||
|
// TestProtectedRoutesRequireAuth 遍历路由表:受保护路由匿名必须 401,公开路由不能被鉴权误拦。
|
||||||
|
func TestProtectedRoutesRequireAuth(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
conn, err := db.Open(filepath.Join(dir, "fonu.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authSvc := auth.New(conn)
|
||||||
|
box, err := secret.NewBox("router-auth-test-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := config.Config{DataDir: dir, NginxPIDFile: filepath.Join(dir, "nginx.pid")}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
frpSvc := frp.NewService(cfg, frp.NewStore(conn), box, logger, certificate.NewStore(conn))
|
||||||
|
cfSvc := cloudflared.NewService(cfg, cloudflared.NewStore(conn), box, logger, nil)
|
||||||
|
|
||||||
|
handler, specs := newRouterWithSpecs(Deps{
|
||||||
|
Config: cfg,
|
||||||
|
Auth: authSvc,
|
||||||
|
FRPMulti: frpSvc,
|
||||||
|
Cloudflare: cfSvc,
|
||||||
|
Settings: settings.NewStore(conn),
|
||||||
|
Logger: logger,
|
||||||
|
})
|
||||||
|
|
||||||
|
protected, public := 0, 0
|
||||||
|
for _, spec := range specs {
|
||||||
|
req := httptest.NewRequest(routeMethod(spec.Pattern), sampleRoutePath(spec.Pattern), nil)
|
||||||
|
code := callRouteSafely(handler, req)
|
||||||
|
if spec.Public {
|
||||||
|
public++
|
||||||
|
if code == http.StatusUnauthorized {
|
||||||
|
t.Fatalf("公开路由 %s 不应被鉴权拦截", spec.Pattern)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
protected++
|
||||||
|
if code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("受保护路由 %s 匿名请求应返回 401,实际 %d", spec.Pattern, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if protected < 100 {
|
||||||
|
t.Fatalf("受保护路由覆盖不足:%d 条", protected)
|
||||||
|
}
|
||||||
|
if public < 4 {
|
||||||
|
t.Fatalf("公开路由覆盖不足:%d 条", public)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeMethod(pattern string) string {
|
||||||
|
method, _, _ := strings.Cut(pattern, " ")
|
||||||
|
return method
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleRoutePath(pattern string) string {
|
||||||
|
_, path, _ := strings.Cut(pattern, " ")
|
||||||
|
return routeParamPattern.ReplaceAllString(path, "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// callRouteSafely 在依赖缺失导致 handler panic 时返回 0,避免公开路由的构造依赖影响鉴权断言。
|
||||||
|
func callRouteSafely(handler http.Handler, req *http.Request) (code int) {
|
||||||
|
defer func() {
|
||||||
|
if recover() != nil {
|
||||||
|
code = 0
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
return rec.Code
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/fonu/fonu/internal/notify"
|
"github.com/fonu/fonu/internal/notify"
|
||||||
"github.com/fonu/fonu/internal/service"
|
"github.com/fonu/fonu/internal/service"
|
||||||
@@ -29,6 +30,11 @@ func (h *SettingsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
notify.SanitizeSettings(values)
|
notify.SanitizeSettings(values)
|
||||||
|
// 状态页访问码只回掩码:既不下发明文也不下发哈希
|
||||||
|
if values[settings.KeyStatusPageCodeHash] != "" {
|
||||||
|
values[settings.KeyStatusPageCode] = MaskedStatusPageCode
|
||||||
|
}
|
||||||
|
delete(values, settings.KeyStatusPageCodeHash)
|
||||||
writeJSON(w, http.StatusOK, values)
|
writeJSON(w, http.StatusOK, values)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,25 +45,28 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
allowed := map[string]bool{
|
allowed := map[string]bool{
|
||||||
settings.KeyTheme: true,
|
settings.KeyTheme: true,
|
||||||
settings.KeyTimezone: true,
|
settings.KeyTimezone: true,
|
||||||
settings.KeyLogRetentionDays: true,
|
settings.KeyLogRetentionDays: true,
|
||||||
settings.KeyDDNSCheckInterval: true,
|
settings.KeyDDNSCheckInterval: true,
|
||||||
settings.KeyCertRenewThreshold: true,
|
settings.KeyCertRenewThreshold: true,
|
||||||
settings.KeyRootDomain: true,
|
settings.KeyRootDomain: true,
|
||||||
settings.KeyACMEEmail: true,
|
settings.KeyACMEEmail: true,
|
||||||
settings.KeyACMECA: true,
|
settings.KeyACMECA: true,
|
||||||
settings.KeyZeroSSLAPIKey: true,
|
settings.KeyZeroSSLAPIKey: true,
|
||||||
settings.KeyNotifyWebhookURL: true,
|
settings.KeyACMEDNSResolvers: true,
|
||||||
settings.KeyNotifyType: true,
|
settings.KeyLiteSSLEABKid: true,
|
||||||
settings.KeyNotifyEmailJSON: true,
|
settings.KeyLiteSSLEABHMAC: true,
|
||||||
settings.KeyNotifySMTPPassword: true,
|
settings.KeyNotifyWebhookURL: true,
|
||||||
settings.KeyNotifyWebhookJSON: true,
|
settings.KeyNotifyType: true,
|
||||||
settings.KeyNotifyWebhookSecret: true,
|
settings.KeyNotifyEmailJSON: true,
|
||||||
settings.KeyNotifyTelegramJSON: true,
|
settings.KeyNotifySMTPPassword: true,
|
||||||
settings.KeyNotifyTelegramToken: true,
|
settings.KeyNotifyWebhookJSON: true,
|
||||||
settings.KeyNotifyOnDDNSIPChange: true,
|
settings.KeyNotifyWebhookSecret: true,
|
||||||
settings.KeyNotifyOnDDNSFailure: true,
|
settings.KeyNotifyTelegramJSON: true,
|
||||||
|
settings.KeyNotifyTelegramToken: true,
|
||||||
|
settings.KeyNotifyOnDDNSIPChange: true,
|
||||||
|
settings.KeyNotifyOnDDNSFailure: true,
|
||||||
settings.KeyNotifyOnCertExpiry: true,
|
settings.KeyNotifyOnCertExpiry: true,
|
||||||
settings.KeyNotifyOnCertRenewSuccess: true,
|
settings.KeyNotifyOnCertRenewSuccess: true,
|
||||||
settings.KeyNotifyOnIPFrequentAccess: true,
|
settings.KeyNotifyOnIPFrequentAccess: true,
|
||||||
@@ -68,12 +77,31 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
|
|||||||
settings.KeyNotifyIPFrequentWindowSec: true,
|
settings.KeyNotifyIPFrequentWindowSec: true,
|
||||||
settings.KeyNotifyLoginFailureThreshold: true,
|
settings.KeyNotifyLoginFailureThreshold: true,
|
||||||
settings.KeyNotifyLoginFailureWindowSec: true,
|
settings.KeyNotifyLoginFailureWindowSec: true,
|
||||||
settings.KeyTrustedProxy: true,
|
settings.KeyNotifyOnResourceThreshold: true,
|
||||||
settings.KeyGlobalIPBlacklist: true,
|
settings.KeyNotifyOnTunnelDown: true,
|
||||||
settings.KeyGlobalIPWhitelist: true,
|
settings.KeyNotifyCPUThreshold: true,
|
||||||
settings.KeyChinaCIDRUpdateHours: true,
|
settings.KeyNotifyMemoryThreshold: true,
|
||||||
settings.KeyChinaCIDRSourceV4: true,
|
settings.KeyNotifyDiskThreshold: true,
|
||||||
settings.KeyChinaCIDRSourceV6: true,
|
settings.KeyNotifyTunnelFailThreshold: true,
|
||||||
|
settings.KeyNotifyOnFrpsDown: true,
|
||||||
|
settings.KeyNotifyOnAgentUnreachable: true,
|
||||||
|
settings.KeyNotifyOnRouteDrift: true,
|
||||||
|
settings.KeyNotifyFrpsFailThreshold: true,
|
||||||
|
settings.KeyNotifyAgentFailThreshold: true,
|
||||||
|
settings.KeyNotifyDriftCooldownHours: true,
|
||||||
|
settings.KeyBackupAutoEnabled: true,
|
||||||
|
settings.KeyRouteHealthEnabled: true,
|
||||||
|
settings.KeyStatusPageEnabled: true,
|
||||||
|
settings.KeyMetricsHistoryEnabled: true,
|
||||||
|
settings.KeyStatusPageCode: true,
|
||||||
|
settings.KeyBackupKeepCount: true,
|
||||||
|
settings.KeyNotifyOnBackupFailure: true,
|
||||||
|
settings.KeyTrustedProxy: true,
|
||||||
|
settings.KeyGlobalIPBlacklist: true,
|
||||||
|
settings.KeyGlobalIPWhitelist: true,
|
||||||
|
settings.KeyChinaCIDRUpdateHours: true,
|
||||||
|
settings.KeyChinaCIDRSourceV4: true,
|
||||||
|
settings.KeyChinaCIDRSourceV6: true,
|
||||||
}
|
}
|
||||||
filtered := map[string]string{}
|
filtered := map[string]string{}
|
||||||
for key, value := range req {
|
for key, value := range req {
|
||||||
@@ -86,6 +114,19 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
filtered[key] = value
|
filtered[key] = value
|
||||||
}
|
}
|
||||||
|
// 状态页访问码走「掩码往返」:客户端提交明文,这里落 SHA-256;
|
||||||
|
// 空串 = 清除访问码,掩码值 = 保持不变。哈希字段不接受客户端直接写入。
|
||||||
|
if code, ok := filtered[settings.KeyStatusPageCode]; ok {
|
||||||
|
delete(filtered, settings.KeyStatusPageCode)
|
||||||
|
trimmed := strings.TrimSpace(code)
|
||||||
|
switch {
|
||||||
|
case trimmed == MaskedStatusPageCode:
|
||||||
|
case trimmed == "":
|
||||||
|
filtered[settings.KeyStatusPageCodeHash] = ""
|
||||||
|
default:
|
||||||
|
filtered[settings.KeyStatusPageCodeHash] = hashStatusPageCode(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
if h.notify != nil && h.notify.Store() != nil {
|
if h.notify != nil && h.notify.Store() != nil {
|
||||||
if in, ok := notify.ParseSaveInputFromMap(filtered); ok {
|
if in, ok := notify.ParseSaveInputFromMap(filtered); ok {
|
||||||
if err := h.notify.Store().Save(r.Context(), in); err != nil {
|
if err := h.notify.Store().Save(r.Context(), in); err != nil {
|
||||||
@@ -115,6 +156,11 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
notify.SanitizeSettings(values)
|
notify.SanitizeSettings(values)
|
||||||
|
// 状态页访问码只回掩码:既不下发明文也不下发哈希
|
||||||
|
if values[settings.KeyStatusPageCodeHash] != "" {
|
||||||
|
values[settings.KeyStatusPageCode] = MaskedStatusPageCode
|
||||||
|
}
|
||||||
|
delete(values, settings.KeyStatusPageCodeHash)
|
||||||
writeJSON(w, http.StatusOK, values)
|
writeJSON(w, http.StatusOK, values)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +185,19 @@ func isNotifySettingKey(key string) bool {
|
|||||||
settings.KeyNotifyIPFrequentWindowSec,
|
settings.KeyNotifyIPFrequentWindowSec,
|
||||||
settings.KeyNotifyLoginFailureThreshold,
|
settings.KeyNotifyLoginFailureThreshold,
|
||||||
settings.KeyNotifyLoginFailureWindowSec,
|
settings.KeyNotifyLoginFailureWindowSec,
|
||||||
|
settings.KeyNotifyOnResourceThreshold,
|
||||||
|
settings.KeyNotifyOnTunnelDown,
|
||||||
|
settings.KeyNotifyCPUThreshold,
|
||||||
|
settings.KeyNotifyMemoryThreshold,
|
||||||
|
settings.KeyNotifyDiskThreshold,
|
||||||
|
settings.KeyNotifyTunnelFailThreshold,
|
||||||
|
settings.KeyNotifyOnFrpsDown,
|
||||||
|
settings.KeyNotifyOnAgentUnreachable,
|
||||||
|
settings.KeyNotifyOnRouteDrift,
|
||||||
|
settings.KeyNotifyFrpsFailThreshold,
|
||||||
|
settings.KeyNotifyAgentFailThreshold,
|
||||||
|
settings.KeyNotifyDriftCooldownHours,
|
||||||
|
settings.KeyNotifyOnBackupFailure,
|
||||||
settings.KeyNotifyWebhookURL:
|
settings.KeyNotifyWebhookURL:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
@@ -175,6 +234,13 @@ func (h *SettingsHandler) validateSetting(key, value string) error {
|
|||||||
return fmt.Errorf("统计窗口需在 10–3600 秒之间")
|
return fmt.Errorf("统计窗口需在 10–3600 秒之间")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case settings.KeyBackupKeepCount:
|
||||||
|
if value != "" {
|
||||||
|
n, err := strconv.Atoi(value)
|
||||||
|
if err != nil || n < 1 || n > 60 {
|
||||||
|
return fmt.Errorf("备份保留份数需在 1–60 之间")
|
||||||
|
}
|
||||||
|
}
|
||||||
case settings.KeyNotifyLoginFailureThreshold:
|
case settings.KeyNotifyLoginFailureThreshold:
|
||||||
if value != "" {
|
if value != "" {
|
||||||
n, err := strconv.Atoi(value)
|
n, err := strconv.Atoi(value)
|
||||||
|
|||||||
@@ -8,59 +8,78 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fonu/fonu/internal/acme"
|
"github.com/fonu/fonu/internal/acme"
|
||||||
certstore "github.com/fonu/fonu/internal/certificate"
|
certstore "github.com/fonu/fonu/internal/certificate"
|
||||||
|
"github.com/fonu/fonu/internal/cloudflared"
|
||||||
"github.com/fonu/fonu/internal/config"
|
"github.com/fonu/fonu/internal/config"
|
||||||
"github.com/fonu/fonu/internal/ddns"
|
"github.com/fonu/fonu/internal/ddns"
|
||||||
"github.com/fonu/fonu/internal/logstore"
|
"github.com/fonu/fonu/internal/logstore"
|
||||||
"github.com/fonu/fonu/internal/publicip"
|
"github.com/fonu/fonu/internal/publicip"
|
||||||
"github.com/fonu/fonu/internal/service"
|
"github.com/fonu/fonu/internal/service"
|
||||||
|
"github.com/fonu/fonu/internal/sysinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
type StatusHandler struct {
|
type StatusHandler struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
proxySvc *service.ProxyService
|
proxySvc *service.ProxyService
|
||||||
ddnsSvc *ddns.Service
|
ddnsSvc *ddns.Service
|
||||||
acmeSvc *acme.Service
|
acmeSvc *acme.Service
|
||||||
startedAt string
|
cloudflareSvc *cloudflared.Service
|
||||||
nginxPIDFile string
|
startedAt string
|
||||||
|
nginxPIDFile string
|
||||||
|
cpuMu sync.Mutex
|
||||||
|
cpuSample sysinfo.CPUSample
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStatusHandler(cfg config.Config, proxySvc *service.ProxyService, ddnsSvc *ddns.Service, acmeSvc *acme.Service, startedAt, nginxPIDFile string) *StatusHandler {
|
func NewStatusHandler(cfg config.Config, proxySvc *service.ProxyService, ddnsSvc *ddns.Service, acmeSvc *acme.Service, cloudflareSvc *cloudflared.Service, startedAt, nginxPIDFile string) *StatusHandler {
|
||||||
|
// 先采一次 CPU 计数器,使首次请求就能算出占用率(否则只能返回 0)
|
||||||
|
_, sample := sysinfo.Read(sysinfo.CPUSample{})
|
||||||
return &StatusHandler{
|
return &StatusHandler{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
proxySvc: proxySvc,
|
proxySvc: proxySvc,
|
||||||
ddnsSvc: ddnsSvc,
|
ddnsSvc: ddnsSvc,
|
||||||
acmeSvc: acmeSvc,
|
acmeSvc: acmeSvc,
|
||||||
startedAt: startedAt,
|
cloudflareSvc: cloudflareSvc,
|
||||||
nginxPIDFile: nginxPIDFile,
|
startedAt: startedAt,
|
||||||
|
nginxPIDFile: nginxPIDFile,
|
||||||
|
cpuSample: sample,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type statusResponse struct {
|
type statusResponse struct {
|
||||||
PublicIPv4 string `json:"public_ipv4"`
|
PublicIPv4 string `json:"public_ipv4"`
|
||||||
PublicIPv6 string `json:"public_ipv6"`
|
PublicIPv6 string `json:"public_ipv6"`
|
||||||
PublicIPSource string `json:"public_ip_source"`
|
PublicIPSource string `json:"public_ip_source"`
|
||||||
DDNSStatus string `json:"ddns_status"`
|
DDNSStatus string `json:"ddns_status"`
|
||||||
DDNSCount int `json:"ddns_count"`
|
DDNSCount int `json:"ddns_count"`
|
||||||
DDNSLastUpdated string `json:"ddns_last_updated,omitempty"`
|
DDNSLastUpdated string `json:"ddns_last_updated,omitempty"`
|
||||||
CertificateStatus string `json:"certificate_status"`
|
CertificateStatus string `json:"certificate_status"`
|
||||||
CertificateDays int `json:"certificate_days"`
|
CertificateDays int `json:"certificate_days"`
|
||||||
CertificateCount int `json:"certificate_count"`
|
CertificateCount int `json:"certificate_count"`
|
||||||
CertificateSummary string `json:"certificate_summary,omitempty"`
|
CertificateSummary string `json:"certificate_summary,omitempty"`
|
||||||
ProxyCount int `json:"proxy_count"`
|
ProxyCount int `json:"proxy_count"`
|
||||||
NginxStatus string `json:"nginx_status"`
|
NginxStatus string `json:"nginx_status"`
|
||||||
RequestToday int `json:"request_today"`
|
RequestToday int `json:"request_today"`
|
||||||
RequestYesterday int `json:"request_yesterday"`
|
RequestYesterday int `json:"request_yesterday"`
|
||||||
RequestTrend *int `json:"request_trend,omitempty"`
|
RequestTrend *int `json:"request_trend,omitempty"`
|
||||||
RequestsHourly []int `json:"requests_hourly"`
|
RequestsHourly []int `json:"requests_hourly"`
|
||||||
RequestsHourlyLabels []string `json:"requests_hourly_labels"`
|
RequestsHourlyLabels []string `json:"requests_hourly_labels"`
|
||||||
ErrorToday int `json:"error_today"`
|
ErrorToday int `json:"error_today"`
|
||||||
AvgResponseMs float64 `json:"avg_response_ms"`
|
AvgResponseMs float64 `json:"avg_response_ms"`
|
||||||
StartedAt string `json:"started_at"`
|
CPUPercent float64 `json:"cpu_percent"`
|
||||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
MemUsedBytes uint64 `json:"mem_used_bytes"`
|
||||||
|
MemTotalBytes uint64 `json:"mem_total_bytes"`
|
||||||
|
DiskUsedBytes uint64 `json:"disk_used_bytes"`
|
||||||
|
DiskTotalBytes uint64 `json:"disk_total_bytes"`
|
||||||
|
CloudflareCount int `json:"cloudflare_count"`
|
||||||
|
CloudflareOnline int `json:"cloudflare_online"`
|
||||||
|
CloudflareStatus string `json:"cloudflare_status"`
|
||||||
|
CloudflareMessage string `json:"cloudflare_message,omitempty"`
|
||||||
|
StartedAt string `json:"started_at"`
|
||||||
|
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
|
func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -83,6 +102,13 @@ func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
records, _ := h.acmeSvc.List(r.Context())
|
records, _ := h.acmeSvc.List(r.Context())
|
||||||
certStatus, certDays, certCount, certSummary := summarizeCertificates(records)
|
certStatus, certDays, certCount, certSummary := summarizeCertificates(records)
|
||||||
|
|
||||||
|
resources, cpuSample := sysinfo.Read(h.currentCPUSample())
|
||||||
|
h.setCPUSample(cpuSample)
|
||||||
|
cfSummary := cloudflared.TunnelSummary{Status: "none", Message: "未创建隧道"}
|
||||||
|
if h.cloudflareSvc != nil {
|
||||||
|
cfSummary = h.cloudflareSvc.Summary(r.Context())
|
||||||
|
}
|
||||||
|
|
||||||
accessLog := filepath.Join(h.cfg.LogsDir(), "access.log")
|
accessLog := filepath.Join(h.cfg.LogsDir(), "access.log")
|
||||||
total, errors, avgMs := logstore.CountTodayAccess(accessLog)
|
total, errors, avgMs := logstore.CountTodayAccess(accessLog)
|
||||||
yesterday, _, _ := logstore.CountAccessForDay(accessLog, time.Now().AddDate(0, 0, -1))
|
yesterday, _, _ := logstore.CountAccessForDay(accessLog, time.Now().AddDate(0, 0, -1))
|
||||||
@@ -92,30 +118,52 @@ func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
uptime := time.Since(started).Seconds()
|
uptime := time.Since(started).Seconds()
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, statusResponse{
|
writeJSON(w, http.StatusOK, statusResponse{
|
||||||
PublicIPv4: ipv4,
|
PublicIPv4: ipv4,
|
||||||
PublicIPv6: ipv6,
|
PublicIPv6: ipv6,
|
||||||
PublicIPSource: ipSource,
|
PublicIPSource: ipSource,
|
||||||
DDNSStatus: ddnsStatus,
|
DDNSStatus: ddnsStatus,
|
||||||
DDNSCount: ddnsCount,
|
DDNSCount: ddnsCount,
|
||||||
DDNSLastUpdated: ddnsLastUpdated,
|
DDNSLastUpdated: ddnsLastUpdated,
|
||||||
CertificateStatus: certStatus,
|
CertificateStatus: certStatus,
|
||||||
CertificateDays: certDays,
|
CertificateDays: certDays,
|
||||||
CertificateCount: certCount,
|
CertificateCount: certCount,
|
||||||
CertificateSummary: certSummary,
|
CertificateSummary: certSummary,
|
||||||
ProxyCount: len(rules),
|
ProxyCount: len(rules),
|
||||||
NginxStatus: nginxStatus,
|
NginxStatus: nginxStatus,
|
||||||
RequestToday: total,
|
RequestToday: total,
|
||||||
RequestYesterday: yesterday,
|
RequestYesterday: yesterday,
|
||||||
RequestTrend: requestTrendPercent(total, yesterday),
|
RequestTrend: requestTrendPercent(total, yesterday),
|
||||||
RequestsHourly: hourly,
|
RequestsHourly: hourly,
|
||||||
RequestsHourlyLabels: hourlyLabels,
|
RequestsHourlyLabels: hourlyLabels,
|
||||||
ErrorToday: errors,
|
ErrorToday: errors,
|
||||||
AvgResponseMs: avgMs,
|
AvgResponseMs: avgMs,
|
||||||
StartedAt: h.startedAt,
|
CPUPercent: resources.CPUPercent,
|
||||||
UptimeSeconds: int64(uptime),
|
MemUsedBytes: resources.MemUsedBytes,
|
||||||
|
MemTotalBytes: resources.MemTotalBytes,
|
||||||
|
DiskUsedBytes: resources.DiskUsedBytes,
|
||||||
|
DiskTotalBytes: resources.DiskTotalBytes,
|
||||||
|
CloudflareCount: cfSummary.Count,
|
||||||
|
CloudflareOnline: cfSummary.Online,
|
||||||
|
CloudflareStatus: cfSummary.Status,
|
||||||
|
CloudflareMessage: cfSummary.Message,
|
||||||
|
StartedAt: h.startedAt,
|
||||||
|
UptimeSeconds: int64(uptime),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// currentCPUSample 与 setCPUSample 用锁保护上次采样,/api/status 可能被并发调用
|
||||||
|
func (h *StatusHandler) currentCPUSample() sysinfo.CPUSample {
|
||||||
|
h.cpuMu.Lock()
|
||||||
|
defer h.cpuMu.Unlock()
|
||||||
|
return h.cpuSample
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StatusHandler) setCPUSample(sample sysinfo.CPUSample) {
|
||||||
|
h.cpuMu.Lock()
|
||||||
|
defer h.cpuMu.Unlock()
|
||||||
|
h.cpuSample = sample
|
||||||
|
}
|
||||||
|
|
||||||
func (h *StatusHandler) resolvePublicIPs(ctx context.Context) (ipv4, ipv6, source string) {
|
func (h *StatusHandler) resolvePublicIPs(ctx context.Context) (ipv4, ipv6, source string) {
|
||||||
if ipv4, ipv6, ok := h.ddnsSvc.PublicIPs(ctx); ok {
|
if ipv4, ipv6, ok := h.ddnsSvc.PublicIPs(ctx); ok {
|
||||||
return ipv4, ipv6, "ddns"
|
return ipv4, ipv6, "ddns"
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/cloudflared"
|
||||||
|
"github.com/fonu/fonu/internal/frp"
|
||||||
|
"github.com/fonu/fonu/internal/notify"
|
||||||
|
"github.com/fonu/fonu/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// statusPageWindow / statusPageLimit 匿名访问的限流窗口与配额:
|
||||||
|
// 状态页会被扫描器盯上,这里比登录接口更宽松但仍有限度。
|
||||||
|
const (
|
||||||
|
statusPageWindow = time.Minute
|
||||||
|
statusPageLimit = 60
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatusPageHandler 提供**匿名只读**状态页数据,默认关闭。
|
||||||
|
//
|
||||||
|
// 刻意只暴露域名与可用率:不带内网地址、端口、证书路径、服务端地址——
|
||||||
|
// 状态页是要挂到公网上的东西,泄露拓扑就等于把内网结构摊开给别人看。
|
||||||
|
type StatusPageHandler struct {
|
||||||
|
settings *settings.Store
|
||||||
|
frp *frp.Service
|
||||||
|
cloudflare *cloudflared.Service
|
||||||
|
limiter *statusPageLimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStatusPageHandler(settingsStore *settings.Store, frpSvc *frp.Service, cloudflareSvc *cloudflared.Service) *StatusPageHandler {
|
||||||
|
return &StatusPageHandler{
|
||||||
|
settings: settingsStore,
|
||||||
|
frp: frpSvc,
|
||||||
|
cloudflare: cloudflareSvc,
|
||||||
|
limiter: newStatusPageLimiter(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusPageRoute struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
Uptime float64 `json:"uptime_pct"`
|
||||||
|
DownMinutes int `json:"down_minutes"`
|
||||||
|
UnknownMinutes int `json:"unknown_minutes"`
|
||||||
|
Incidents []statusPageIncident `json:"incidents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusPageIncident struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusPagePayload struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
CodeRequired bool `json:"code_required"`
|
||||||
|
CheckedAt string `json:"checked_at"`
|
||||||
|
Up int `json:"up"`
|
||||||
|
Down int `json:"down"`
|
||||||
|
Unknown int `json:"unknown"`
|
||||||
|
Routes []statusPageRoute `json:"routes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 是唯一的匿名入口:关闭时一律 404(与「不存在」不可区分),
|
||||||
|
// 设了访问码时校验 ?code=,校验失败也回 404。
|
||||||
|
func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h == nil || h.settings == nil {
|
||||||
|
writeError(r, w, http.StatusNotFound, "状态页未开启")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.limiter.allow(clientIPForLimit(r)) {
|
||||||
|
writeError(r, w, http.StatusTooManyRequests, "请求过于频繁")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled, _ := h.settings.GetBool(r.Context(), settings.KeyStatusPageEnabled)
|
||||||
|
if !enabled || (h.frp == nil && h.cloudflare == nil) {
|
||||||
|
writeError(r, w, http.StatusNotFound, "状态页未开启")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
codeHash, err := h.settings.Get(r.Context(), settings.KeyStatusPageCodeHash)
|
||||||
|
if err != nil {
|
||||||
|
codeHash = ""
|
||||||
|
}
|
||||||
|
codeRequired := strings.TrimSpace(codeHash) != ""
|
||||||
|
if codeRequired && hashStatusPageCode(r.URL.Query().Get("code")) != codeHash {
|
||||||
|
writeError(r, w, http.StatusNotFound, "状态页未开启")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := statusPagePayload{
|
||||||
|
Title: "Fonu 服务状态",
|
||||||
|
CodeRequired: codeRequired,
|
||||||
|
CheckedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
Routes: make([]statusPageRoute, 0),
|
||||||
|
}
|
||||||
|
if err := appendFRPStatus(&payload, h.frp, r.Context()); err != nil {
|
||||||
|
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := appendCloudflareStatus(&payload, h.cloudflare, r.Context()); err != nil {
|
||||||
|
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendFRPStatus(payload *statusPagePayload, svc *frp.Service, ctx context.Context) error {
|
||||||
|
if svc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stats, err := svc.RouteUptime(ctx, 24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, stat := range stats {
|
||||||
|
switch stat.State {
|
||||||
|
case frp.RouteHealthUp:
|
||||||
|
payload.Up++
|
||||||
|
case frp.RouteHealthDown:
|
||||||
|
payload.Down++
|
||||||
|
default:
|
||||||
|
payload.Unknown++
|
||||||
|
}
|
||||||
|
payload.Routes = append(payload.Routes, statusPageRoute{
|
||||||
|
Domain: stat.Domain,
|
||||||
|
State: stat.State,
|
||||||
|
Source: "frp",
|
||||||
|
Uptime: stat.Uptime,
|
||||||
|
DownMinutes: stat.DownMinutes,
|
||||||
|
UnknownMinutes: stat.UnknownMinutes,
|
||||||
|
Incidents: mapFRPIncidents(stat.Incidents),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendCloudflareStatus(payload *statusPagePayload, svc *cloudflared.Service, ctx context.Context) error {
|
||||||
|
if svc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stats, err := svc.HealthUptime(ctx, 24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, stat := range stats {
|
||||||
|
switch stat.State {
|
||||||
|
case cloudflared.HealthUp:
|
||||||
|
payload.Up++
|
||||||
|
case cloudflared.HealthDown:
|
||||||
|
payload.Down++
|
||||||
|
default:
|
||||||
|
payload.Unknown++
|
||||||
|
}
|
||||||
|
payload.Routes = append(payload.Routes, statusPageRoute{
|
||||||
|
Domain: stat.Domain,
|
||||||
|
State: stat.State,
|
||||||
|
Source: "cloudflare",
|
||||||
|
Uptime: stat.Uptime,
|
||||||
|
DownMinutes: stat.DownMinutes,
|
||||||
|
UnknownMinutes: stat.UnknownMinutes,
|
||||||
|
Incidents: mapCloudflareIncidents(stat.Incidents),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapFRPIncidents(items []frp.RouteHealthIncident) []statusPageIncident {
|
||||||
|
out := make([]statusPageIncident, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
out = append(out, statusPageIncident{
|
||||||
|
From: item.From, To: item.To, Reason: item.Reason, Detail: item.Detail, Minutes: item.Minutes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapCloudflareIncidents(items []cloudflared.HealthIncident) []statusPageIncident {
|
||||||
|
out := make([]statusPageIncident, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
out = append(out, statusPageIncident{
|
||||||
|
From: item.From, To: item.To, Reason: item.Reason, Detail: item.Detail, Minutes: item.Minutes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashStatusPageCode(code string) string {
|
||||||
|
sum := sha256.Sum256([]byte(strings.TrimSpace(code)))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientIPForLimit 取限流用的客户端 IP
|
||||||
|
func clientIPForLimit(r *http.Request) string {
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusPageLimiter 按 IP 的滑动窗口限流;只给匿名状态页用
|
||||||
|
type statusPageLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
hits map[string][]time.Time
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStatusPageLimiter() *statusPageLimiter {
|
||||||
|
return &statusPageLimiter{hits: map[string][]time.Time{}, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *statusPageLimiter) allow(ip string) bool {
|
||||||
|
now := l.now()
|
||||||
|
cutoff := now.Add(-statusPageWindow)
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
kept := make([]time.Time, 0, len(l.hits[ip])+1)
|
||||||
|
for _, at := range l.hits[ip] {
|
||||||
|
if at.After(cutoff) {
|
||||||
|
kept = append(kept, at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) >= statusPageLimit {
|
||||||
|
l.hits[ip] = kept
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
l.hits[ip] = append(kept, now)
|
||||||
|
// 顺手清理长期不活跃的 IP,避免 map 无限增长
|
||||||
|
if len(l.hits) > 1024 {
|
||||||
|
for key, times := range l.hits {
|
||||||
|
if len(times) == 0 {
|
||||||
|
delete(l.hits, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskedStatusPageCode 供设置接口回显用(与通知模块的掩码约定一致)
|
||||||
|
const MaskedStatusPageCode = notify.MaskedSecret
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStatusPageLimiterBlocksAfterLimit(t *testing.T) {
|
||||||
|
limiter := newStatusPageLimiter()
|
||||||
|
now := time.Date(2026, 9, 25, 10, 0, 0, 0, time.UTC)
|
||||||
|
limiter.now = func() time.Time { return now }
|
||||||
|
|
||||||
|
for i := 0; i < statusPageLimit; i++ {
|
||||||
|
if !limiter.allow("1.2.3.4") {
|
||||||
|
t.Fatalf("第 %d 次请求应放行", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if limiter.allow("1.2.3.4") {
|
||||||
|
t.Fatal("超过配额应拒绝")
|
||||||
|
}
|
||||||
|
// 限流按 IP 计:别的访客不该被牵连
|
||||||
|
if !limiter.allow("5.6.7.8") {
|
||||||
|
t.Fatal("其它 IP 不应被牵连")
|
||||||
|
}
|
||||||
|
// 窗口滑过后恢复
|
||||||
|
now = now.Add(statusPageWindow + time.Second)
|
||||||
|
if !limiter.allow("1.2.3.4") {
|
||||||
|
t.Fatal("窗口过后应恢复放行")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashStatusPageCodeHidesPlaintext(t *testing.T) {
|
||||||
|
hash := hashStatusPageCode("my-secret")
|
||||||
|
if hash == "my-secret" || len(hash) != 64 {
|
||||||
|
t.Fatalf("访问码应以 SHA-256 存储,实际 %q", hash)
|
||||||
|
}
|
||||||
|
if hashStatusPageCode(" my-secret ") != hash {
|
||||||
|
t.Fatal("前后空白裁剪后应得到同一哈希")
|
||||||
|
}
|
||||||
|
if hashStatusPageCode("other") == hash {
|
||||||
|
t.Fatal("不同访问码不应得到同一哈希")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 令牌管理挂在 AuthHandler 上:它已经持有 auth.Service,不必再加一层 handler。
|
||||||
|
const maxTokenExpiryDays = 3650
|
||||||
|
|
||||||
|
type createTokenRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
ExpiresInDays int `json:"expires_in_days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) ListTokens(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tokens, err := h.auth.ListAPITokens(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusInternalServerError, "读取令牌失败", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"tokens": tokens})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateToken 新建令牌;明文只在这一个响应里返回,之后无法再取回。
|
||||||
|
func (h *AuthHandler) CreateToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req createTokenRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scope := strings.TrimSpace(req.Scope)
|
||||||
|
if scope == "" {
|
||||||
|
scope = auth.TokenScopeRead
|
||||||
|
}
|
||||||
|
var expiresAt time.Time
|
||||||
|
if req.ExpiresInDays > 0 {
|
||||||
|
if req.ExpiresInDays > maxTokenExpiryDays {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "有效期最长 3650 天")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expiresAt = time.Now().AddDate(0, 0, req.ExpiresInDays)
|
||||||
|
}
|
||||||
|
token, err := h.auth.CreateAPIToken(r.Context(), req.Name, scope, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) DeleteToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "无效的令牌 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.auth.DeleteAPIToken(r.Context(), id); err != nil {
|
||||||
|
if err.Error() == "令牌不存在" {
|
||||||
|
writeError(r, w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(r, w, http.StatusInternalServerError, "删除令牌失败", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UpdateHandler struct {
|
||||||
|
client *updater.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUpdateHandler(client *updater.Client) *UpdateHandler {
|
||||||
|
return &UpdateHandler{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UpdateHandler) Status(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.client == nil || !h.client.Enabled() {
|
||||||
|
writeJSON(w, http.StatusOK, updater.Status{
|
||||||
|
Enabled: false,
|
||||||
|
Phase: updater.PhaseIdle,
|
||||||
|
Message: "当前部署未启用一键升级",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status, err := h.client.Status(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, "读取升级状态失败", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UpdateHandler) Apply(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.client == nil || !h.client.Enabled() {
|
||||||
|
writeError(r, w, http.StatusServiceUnavailable, "当前部署未启用一键升级")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status, err := h.client.Apply(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, "启动升级失败", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, status)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateHandlerDisabled(t *testing.T) {
|
||||||
|
handler := NewUpdateHandler(nil)
|
||||||
|
|
||||||
|
statusRecorder := httptest.NewRecorder()
|
||||||
|
statusReq := httptest.NewRequest(http.MethodGet, "/api/system/update/status", nil)
|
||||||
|
handler.Status(statusRecorder, statusReq)
|
||||||
|
if statusRecorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("状态接口期望 200,实际 %d", statusRecorder.Code)
|
||||||
|
}
|
||||||
|
if body := statusRecorder.Body.String(); !strings.Contains(body, `"enabled":false`) {
|
||||||
|
t.Fatalf("未返回 enabled=false: %s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyRecorder := httptest.NewRecorder()
|
||||||
|
applyReq := httptest.NewRequest(http.MethodPost, "/api/system/update/apply", nil)
|
||||||
|
handler.Apply(applyRecorder, applyReq)
|
||||||
|
if applyRecorder.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("升级接口期望 503,实际 %d", applyRecorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
-15
@@ -17,12 +17,15 @@ import (
|
|||||||
"github.com/fonu/fonu/internal/backup"
|
"github.com/fonu/fonu/internal/backup"
|
||||||
"github.com/fonu/fonu/internal/certificate"
|
"github.com/fonu/fonu/internal/certificate"
|
||||||
"github.com/fonu/fonu/internal/chinacidr"
|
"github.com/fonu/fonu/internal/chinacidr"
|
||||||
|
"github.com/fonu/fonu/internal/cloudflared"
|
||||||
"github.com/fonu/fonu/internal/config"
|
"github.com/fonu/fonu/internal/config"
|
||||||
"github.com/fonu/fonu/internal/db"
|
"github.com/fonu/fonu/internal/db"
|
||||||
"github.com/fonu/fonu/internal/ddns"
|
"github.com/fonu/fonu/internal/ddns"
|
||||||
"github.com/fonu/fonu/internal/discovery"
|
"github.com/fonu/fonu/internal/discovery"
|
||||||
"github.com/fonu/fonu/internal/frp"
|
"github.com/fonu/fonu/internal/frp"
|
||||||
"github.com/fonu/fonu/internal/logstore"
|
"github.com/fonu/fonu/internal/logstore"
|
||||||
|
"github.com/fonu/fonu/internal/metrics"
|
||||||
|
"github.com/fonu/fonu/internal/monitor"
|
||||||
"github.com/fonu/fonu/internal/nginx"
|
"github.com/fonu/fonu/internal/nginx"
|
||||||
"github.com/fonu/fonu/internal/notify"
|
"github.com/fonu/fonu/internal/notify"
|
||||||
"github.com/fonu/fonu/internal/proxy"
|
"github.com/fonu/fonu/internal/proxy"
|
||||||
@@ -31,6 +34,7 @@ import (
|
|||||||
"github.com/fonu/fonu/internal/service"
|
"github.com/fonu/fonu/internal/service"
|
||||||
"github.com/fonu/fonu/internal/settings"
|
"github.com/fonu/fonu/internal/settings"
|
||||||
"github.com/fonu/fonu/internal/traffic"
|
"github.com/fonu/fonu/internal/traffic"
|
||||||
|
"github.com/fonu/fonu/internal/updater"
|
||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
@@ -39,6 +43,7 @@ type App struct {
|
|||||||
server *http.Server
|
server *http.Server
|
||||||
nginx *nginx.Manager
|
nginx *nginx.Manager
|
||||||
frpMulti *frp.Service
|
frpMulti *frp.Service
|
||||||
|
cloudflare *cloudflared.Service
|
||||||
scheduler *scheduler.Scheduler
|
scheduler *scheduler.Scheduler
|
||||||
shutdownFn context.CancelFunc
|
shutdownFn context.CancelFunc
|
||||||
db interface{ Close() error }
|
db interface{ Close() error }
|
||||||
@@ -55,6 +60,14 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 会话密钥:显式配置优先;未配置则在数据目录里读/生成并持久化
|
||||||
|
// (它同时是 secret box 的加密密钥,所以只在未配置时才生成,不替换已配置的值)
|
||||||
|
secretSource, err := cfg.ResolveSessionSecret()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
logger.Info("会话密钥已就绪", "module", "SYSTEM", "source", secretSource)
|
||||||
|
|
||||||
conn, err := db.Open(cfg.DBPath())
|
conn, err := db.Open(cfg.DBPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -92,6 +105,8 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
|||||||
ddnsSvc := ddns.NewService(ddnsStore, settingsStore, secretBox, logger, notifySvc)
|
ddnsSvc := ddns.NewService(ddnsStore, settingsStore, secretBox, logger, notifySvc)
|
||||||
acmeSvc := acme.NewService(cfg, certStore, ddnsSvc, settingsStore, proxySvc, logger, notifySvc)
|
acmeSvc := acme.NewService(cfg, certStore, ddnsSvc, settingsStore, proxySvc, logger, notifySvc)
|
||||||
backupSvc := backup.New(cfg.DataDir)
|
backupSvc := backup.New(cfg.DataDir)
|
||||||
|
// 指标历史:与巡检同生命周期,采样跟随巡检轮次
|
||||||
|
metricsStore := metrics.NewStore(conn)
|
||||||
discoverySvc := discovery.New()
|
discoverySvc := discovery.New()
|
||||||
// 新增:多服务端 FRP 模块(接管原网关模式)
|
// 新增:多服务端 FRP 模块(接管原网关模式)
|
||||||
frpMultiSvc := frp.NewService(cfg, frp.NewStore(conn), secretBox, logger, certStore)
|
frpMultiSvc := frp.NewService(cfg, frp.NewStore(conn), secretBox, logger, certStore)
|
||||||
@@ -99,33 +114,50 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
|||||||
conn.Close()
|
conn.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
cloudflareSvc := cloudflared.NewService(cfg, cloudflared.NewStore(conn), secretBox, logger, proxySvc)
|
||||||
|
if err := cloudflareSvc.EnsureDirs(); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
proxySvc.SetExitSync(cloudflareSvc)
|
||||||
startedAt := time.Now().UTC().Format(time.RFC3339)
|
startedAt := time.Now().UTC().Format(time.RFC3339)
|
||||||
trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
|
trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
|
||||||
|
updaterClient := updater.NewClient(cfg.UpdaterSocket)
|
||||||
|
|
||||||
handler := api.NewRouter(api.Deps{
|
handler := api.NewRouter(api.Deps{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
Auth: authSvc,
|
Auth: authSvc,
|
||||||
Proxy: proxySvc,
|
Proxy: proxySvc,
|
||||||
DDNS: ddnsSvc,
|
DDNS: ddnsSvc,
|
||||||
ACME: acmeSvc,
|
ACME: acmeSvc,
|
||||||
Settings: settingsStore,
|
Settings: settingsStore,
|
||||||
Notify: notifySvc,
|
Notify: notifySvc,
|
||||||
ChinaCIDR: chinaCIDRSvc,
|
ChinaCIDR: chinaCIDRSvc,
|
||||||
Backup: backupSvc,
|
Backup: backupSvc,
|
||||||
Discovery: discoverySvc,
|
Metrics: metricsStore,
|
||||||
FRPMulti: frpMultiSvc,
|
Discovery: discoverySvc,
|
||||||
Traffic: trafficCollector,
|
FRPMulti: frpMultiSvc,
|
||||||
StaticFS: staticFS,
|
Cloudflare: cloudflareSvc,
|
||||||
StartedAt: startedAt,
|
Traffic: trafficCollector,
|
||||||
|
Updater: updaterClient,
|
||||||
|
StaticFS: staticFS,
|
||||||
|
StartedAt: startedAt,
|
||||||
})
|
})
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: cfg.ListenAddr,
|
Addr: cfg.ListenAddr,
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
// 读超时放到 5 分钟:备份恢复要上传最大 64MB;不设 WriteTimeout,否则日志 SSE 会被中途切断
|
||||||
|
ReadTimeout: 5 * time.Minute,
|
||||||
|
IdleTimeout: 2 * time.Minute,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
frpMultiSvc.SetBaseContext(ctx)
|
||||||
|
go frpMultiSvc.StartAgentTunnels(ctx)
|
||||||
|
go frpMultiSvc.StartAgentCertificateRenewal(ctx)
|
||||||
|
go cloudflareSvc.StartAll(ctx)
|
||||||
intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
|
intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
|
||||||
if intervalMinutes <= 0 {
|
if intervalMinutes <= 0 {
|
||||||
intervalMinutes = 5
|
intervalMinutes = 5
|
||||||
@@ -134,6 +166,10 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
|||||||
if chinaHours <= 0 {
|
if chinaHours <= 0 {
|
||||||
chinaHours = 24
|
chinaHours = 24
|
||||||
}
|
}
|
||||||
|
// 巡检间隔比其它任务短:「连续 N 次不通」需要足够采样次数才能判定
|
||||||
|
monitorChecker := monitor.New(settingsStore, notifySvc, logger, monitorSource{frp: frpMultiSvc, cloudflare: cloudflareSvc, logger: logger, traffic: trafficCollector}, healthRecorder{frp: frpMultiSvc}, metricsStore)
|
||||||
|
// 自动备份:启动时会先跑一轮,靠「最新备份是否在 24h 内」避免频繁重启重复备份
|
||||||
|
backupRunner := backup.NewAutoRunner(backupSvc, settingsStore, notifySvc, logger)
|
||||||
sched := scheduler.New(
|
sched := scheduler.New(
|
||||||
scheduler.Job{
|
scheduler.Job{
|
||||||
Name: "ddns",
|
Name: "ddns",
|
||||||
@@ -150,6 +186,16 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
|||||||
Interval: time.Duration(chinaHours) * time.Hour,
|
Interval: time.Duration(chinaHours) * time.Hour,
|
||||||
Run: chinaCIDRSvc.Tick,
|
Run: chinaCIDRSvc.Tick,
|
||||||
},
|
},
|
||||||
|
scheduler.Job{
|
||||||
|
Name: "monitor",
|
||||||
|
Interval: 2 * time.Minute,
|
||||||
|
Run: monitorChecker.Run,
|
||||||
|
},
|
||||||
|
scheduler.Job{
|
||||||
|
Name: "backup",
|
||||||
|
Interval: backup.AutoInterval,
|
||||||
|
Run: backupRunner.Run,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
sched.Start(ctx)
|
sched.Start(ctx)
|
||||||
trafficCollector.Start(ctx)
|
trafficCollector.Start(ctx)
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/cloudflared"
|
||||||
|
"github.com/fonu/fonu/internal/frp"
|
||||||
|
"github.com/fonu/fonu/internal/monitor"
|
||||||
|
"github.com/fonu/fonu/internal/traffic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// monitorSource 把 frp 服务适配成 monitor.Source:monitor 只关心「状态快照」,
|
||||||
|
// 怎么从 frp / agent 取是这里的事,两边因此互不依赖。
|
||||||
|
type monitorSource struct {
|
||||||
|
frp *frp.Service
|
||||||
|
cloudflare *cloudflared.Service
|
||||||
|
logger *slog.Logger
|
||||||
|
traffic *traffic.Collector
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s monitorSource) CloudflareRoutes(ctx context.Context) ([]monitor.CloudflareRouteStatus, error) {
|
||||||
|
if s.cloudflare == nil {
|
||||||
|
return []monitor.CloudflareRouteStatus{}, nil
|
||||||
|
}
|
||||||
|
statuses, err := s.cloudflare.HealthStatuses(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]monitor.CloudflareRouteStatus, 0, len(statuses))
|
||||||
|
for _, item := range statuses {
|
||||||
|
out = append(out, monitor.CloudflareRouteStatus{
|
||||||
|
TunnelID: item.TunnelID,
|
||||||
|
Name: item.Name,
|
||||||
|
Domain: item.Domain,
|
||||||
|
State: item.State,
|
||||||
|
Reason: item.Reason,
|
||||||
|
Detail: item.Detail,
|
||||||
|
Known: item.Known,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s monitorSource) RecordCloudflareHealth(ctx context.Context, samples []monitor.CloudflareHealthSample) error {
|
||||||
|
if s.cloudflare == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
converted := make([]cloudflared.HealthSample, 0, len(samples))
|
||||||
|
for _, sample := range samples {
|
||||||
|
converted = append(converted, cloudflared.HealthSample{
|
||||||
|
TunnelID: sample.TunnelID,
|
||||||
|
Name: sample.Name,
|
||||||
|
Domain: sample.Domain,
|
||||||
|
State: sample.State,
|
||||||
|
Reason: sample.Reason,
|
||||||
|
Detail: sample.Detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return s.cloudflare.RecordHealth(ctx, converted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s monitorSource) PruneCloudflareHealth(ctx context.Context) error {
|
||||||
|
if s.cloudflare == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := s.cloudflare.PruneHealth(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s monitorSource) Routes(ctx context.Context) ([]monitor.RouteStatus, error) {
|
||||||
|
servers, err := s.enabledServers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
routes := make([]monitor.RouteStatus, 0, len(servers))
|
||||||
|
for _, server := range servers {
|
||||||
|
// 单台服务端探测失败不影响其它服务端:它本轮跳过,等下一轮
|
||||||
|
health, err := s.frp.RouteHealth(ctx, server.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("巡检跳过服务端隧道", "module", "MONITOR", "server_id", server.ID, "error", err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, item := range health {
|
||||||
|
routes = append(routes, monitor.RouteStatus{
|
||||||
|
ServerID: server.ID,
|
||||||
|
Domain: item.Domain,
|
||||||
|
TunnelOK: item.TunnelOK,
|
||||||
|
DNSOK: item.DNSOK,
|
||||||
|
ServiceOK: item.ServiceOK,
|
||||||
|
Known: item.TunnelKnown,
|
||||||
|
Detail: item.TunnelDetail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return routes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s monitorSource) Servers(ctx context.Context) ([]monitor.ServerStatus, error) {
|
||||||
|
servers, err := s.enabledServers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]monitor.ServerStatus, 0, len(servers))
|
||||||
|
for _, server := range servers {
|
||||||
|
out = append(out, monitor.ServerStatus{
|
||||||
|
ID: server.ID,
|
||||||
|
Name: server.Name,
|
||||||
|
DesiredState: server.DesiredState,
|
||||||
|
ProcessState: server.ProcessState,
|
||||||
|
ConnectionState: server.ConnectionState,
|
||||||
|
Detail: server.LastError,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s monitorSource) Agents(ctx context.Context) ([]monitor.AgentStatus, error) {
|
||||||
|
servers, err := s.enabledServers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]monitor.AgentStatus, 0, len(servers))
|
||||||
|
for _, server := range servers {
|
||||||
|
// AgentClient 只在「地址与令牌都可用」时才返回 ok:没配 agent 的服务端不参与可达性判定
|
||||||
|
client, ok := s.frp.AgentClient(ctx, server.ID)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
status := monitor.AgentStatus{ID: server.ID, Name: server.Name, Reachable: true}
|
||||||
|
if err := client.Ping(ctx); err != nil {
|
||||||
|
status.Reachable = false
|
||||||
|
status.Detail = err.Error()
|
||||||
|
}
|
||||||
|
out = append(out, status)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s monitorSource) Drift(ctx context.Context) ([]monitor.DriftStatus, error) {
|
||||||
|
servers, err := s.enabledServers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]monitor.DriftStatus, 0, len(servers))
|
||||||
|
for _, server := range servers {
|
||||||
|
report, err := s.frp.RouteDrift(ctx, server.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("巡检跳过漂移检测", "module", "MONITOR", "server_id", server.ID, "error", err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
status := monitor.DriftStatus{ID: server.ID, Name: server.Name, Count: len(report.Items)}
|
||||||
|
for _, item := range report.Items {
|
||||||
|
status.Items = append(status.Items, fmt.Sprintf("%s %s", item.Domain, driftKindText(item.Kind)))
|
||||||
|
}
|
||||||
|
out = append(out, status)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traffic 返回若干域名的当前上下行速率:流量采集器内部按 host:port 累计,这里按域名合并。
|
||||||
|
func (s monitorSource) Traffic(ctx context.Context, domains []string) (map[string]monitor.TrafficRate, error) {
|
||||||
|
if s.traffic == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
snapshot := s.traffic.SnapshotByHost(domains)
|
||||||
|
rates := make(map[string]monitor.TrafficRate, len(snapshot))
|
||||||
|
for host, item := range snapshot {
|
||||||
|
rates[host] = monitor.TrafficRate{Upload: item.UploadRate, Download: item.DownloadRate}
|
||||||
|
}
|
||||||
|
return rates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s monitorSource) enabledServers(ctx context.Context) ([]frp.Server, error) {
|
||||||
|
servers, err := s.frp.ListServers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
enabled := make([]frp.Server, 0, len(servers))
|
||||||
|
for _, server := range servers {
|
||||||
|
// 停用中的服务端是用户主动关掉的,不该参与任何告警判定
|
||||||
|
if server.Enabled {
|
||||||
|
enabled = append(enabled, server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return enabled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// driftKindText 与公网反代页漂移清单的文案保持一致,通知里才不用回页面去对照。
|
||||||
|
func driftKindText(kind string) string {
|
||||||
|
switch kind {
|
||||||
|
case frp.RouteDriftMissing:
|
||||||
|
return "未部署"
|
||||||
|
case frp.RouteDriftOrphan:
|
||||||
|
return "孤儿配置"
|
||||||
|
case frp.RouteDriftNotLoaded:
|
||||||
|
return "未生效"
|
||||||
|
case frp.RouteDriftContentMismatch:
|
||||||
|
return "内容不一致"
|
||||||
|
default:
|
||||||
|
return kind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// healthRecorder 把 monitor 的健康结论翻成 frp 侧样本并落库(
|
||||||
|
// 两侧的状态字面量由 healthStateValue 显式翻译,改动一侧会在这里编译期报错)。
|
||||||
|
type healthRecorder struct {
|
||||||
|
frp *frp.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r healthRecorder) RecordRouteHealth(ctx context.Context, samples []monitor.HealthSample) error {
|
||||||
|
converted := make([]frp.RouteHealthSample, 0, len(samples))
|
||||||
|
for _, sample := range samples {
|
||||||
|
converted = append(converted, frp.RouteHealthSample{
|
||||||
|
ServerID: sample.ServerID,
|
||||||
|
Domain: sample.Domain,
|
||||||
|
State: healthStateValue(sample.State),
|
||||||
|
Reason: sample.Reason,
|
||||||
|
Detail: sample.Detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_, err := r.frp.RecordRouteHealth(ctx, converted)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r healthRecorder) PruneRouteHealth(ctx context.Context) error {
|
||||||
|
_, err := r.frp.PruneRouteHealth(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func healthStateValue(state string) string {
|
||||||
|
switch state {
|
||||||
|
case monitor.HealthUp:
|
||||||
|
return frp.RouteHealthUp
|
||||||
|
case monitor.HealthDown:
|
||||||
|
return frp.RouteHealthDown
|
||||||
|
default:
|
||||||
|
return frp.RouteHealthUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/frp"
|
||||||
|
"github.com/fonu/fonu/internal/monitor"
|
||||||
|
)
|
||||||
|
|
||||||
|
// monitor 与 frp 两侧的状态字面量必须一致,落库前由 healthStateValue 翻译一次;
|
||||||
|
// 这个测试把翻译钉住,避免以后改一侧的常量而另一侧悄悄写错值。
|
||||||
|
func TestHealthStateValue(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
monitor.HealthUp: frp.RouteHealthUp,
|
||||||
|
monitor.HealthDown: frp.RouteHealthDown,
|
||||||
|
monitor.HealthUnknown: frp.RouteHealthUnknown,
|
||||||
|
"其他": frp.RouteHealthUnknown,
|
||||||
|
}
|
||||||
|
for input, want := range cases {
|
||||||
|
if got := healthStateValue(input); got != want {
|
||||||
|
t.Fatalf("%q 应翻译为 %q,实际 %q", input, want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,7 +118,7 @@ func (s *Service) AdminIDForSession(ctx context.Context, sessionID string) (int,
|
|||||||
return adminID, nil
|
return adminID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ChangePassword(ctx context.Context, adminID int, oldPassword, newPassword string) error {
|
func (s *Service) ChangePassword(ctx context.Context, adminID int, oldPassword, newPassword, keepSessionID string) error {
|
||||||
if err := validate.Password(newPassword); err != nil {
|
if err := validate.Password(newPassword); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,11 @@ func (s *Service) ChangePassword(ctx context.Context, adminID int, oldPassword,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = s.db.ExecContext(ctx, `UPDATE admins SET password_hash = ? WHERE id = ?`, string(newHash), adminID)
|
if _, err := s.db.ExecContext(ctx, `UPDATE admins SET password_hash = ? WHERE id = ?`, string(newHash), adminID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 改密后失效该账号的其他会话(保留当前这次,否则用户会被自己踢下线)
|
||||||
|
_, err = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE admin_id = ? AND id <> ?`, adminID, keepSessionID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// API Token 给无法保存会话 cookie 的调用方(脚本、HomeAssistant、手机快捷指令)用。
|
||||||
|
//
|
||||||
|
// 明文只在创建响应里返回一次,库里只存 SHA-256:令牌由 crypto/rand 生成、熵足够高,
|
||||||
|
// 不像人类密码那样需要 bcrypt 这类慢哈希(慢哈希在这里只会拖慢每个 API 请求)。
|
||||||
|
const (
|
||||||
|
TokenScopeRead = "read"
|
||||||
|
TokenScopeWrite = "write"
|
||||||
|
|
||||||
|
// lastUsedWriteInterval 限制回写频率:单连接 SQLite 上每个请求都写一次不划算,
|
||||||
|
// 同一分钟内重复使用不再写。
|
||||||
|
lastUsedWriteInterval = time.Minute
|
||||||
|
|
||||||
|
maxTokenNameLength = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
type APIToken struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
LastUsedAt string `json:"last_used_at,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatedAPIToken 只在创建响应里出现:Token 是明文,之后无法再取回。
|
||||||
|
type CreatedAPIToken struct {
|
||||||
|
APIToken
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListAPITokens(ctx context.Context) ([]APIToken, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id, name, scope, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), created_at
|
||||||
|
FROM api_tokens ORDER BY id DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
tokens := make([]APIToken, 0, 8)
|
||||||
|
for rows.Next() {
|
||||||
|
var token APIToken
|
||||||
|
if err := rows.Scan(&token.ID, &token.Name, &token.Scope, &token.ExpiresAt, &token.LastUsedAt, &token.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tokens = append(tokens, token)
|
||||||
|
}
|
||||||
|
return tokens, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAPIToken 新建令牌并返回一次明文;expiresAt 为零值表示永不过期。
|
||||||
|
func (s *Service) CreateAPIToken(ctx context.Context, name, scope string, expiresAt time.Time) (CreatedAPIToken, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return CreatedAPIToken{}, fmt.Errorf("请填写令牌名称")
|
||||||
|
}
|
||||||
|
if len([]rune(name)) > maxTokenNameLength {
|
||||||
|
return CreatedAPIToken{}, fmt.Errorf("令牌名称最长 %d 个字", maxTokenNameLength)
|
||||||
|
}
|
||||||
|
if scope != TokenScopeRead && scope != TokenScopeWrite {
|
||||||
|
return CreatedAPIToken{}, fmt.Errorf("无效的权限范围")
|
||||||
|
}
|
||||||
|
|
||||||
|
plain, err := randomToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return CreatedAPIToken{}, err
|
||||||
|
}
|
||||||
|
expires := ""
|
||||||
|
if !expiresAt.IsZero() {
|
||||||
|
expires = expiresAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
res, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO api_tokens(name, token_hash, scope, expires_at) VALUES (?, ?, ?, ?)`,
|
||||||
|
name, hashToken(plain), scope, expires)
|
||||||
|
if err != nil {
|
||||||
|
return CreatedAPIToken{}, err
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return CreatedAPIToken{}, err
|
||||||
|
}
|
||||||
|
token, err := s.apiTokenByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return CreatedAPIToken{}, err
|
||||||
|
}
|
||||||
|
return CreatedAPIToken{APIToken: token, Token: plain}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DeleteAPIToken(ctx context.Context, id int64) error {
|
||||||
|
res, err := s.db.ExecContext(ctx, `DELETE FROM api_tokens WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return fmt.Errorf("令牌不存在")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAPIToken 校验明文令牌并返回权限范围;校验通过后限频回写最后使用时间。
|
||||||
|
func (s *Service) ValidateAPIToken(ctx context.Context, plain string) (string, error) {
|
||||||
|
plain = strings.TrimSpace(plain)
|
||||||
|
if plain == "" {
|
||||||
|
return "", ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
id int64
|
||||||
|
scope string
|
||||||
|
expiresAt string
|
||||||
|
)
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, scope, COALESCE(expires_at, '') FROM api_tokens WHERE token_hash = ?`, hashToken(plain)).
|
||||||
|
Scan(&id, &scope, &expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", ErrUnauthorized
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if expiresAt != "" {
|
||||||
|
expires, err := time.Parse(time.RFC3339, expiresAt)
|
||||||
|
if err != nil || time.Now().After(expires) {
|
||||||
|
return "", ErrUnauthorized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
cutoff := now.Add(-lastUsedWriteInterval).Format(time.RFC3339)
|
||||||
|
// 回写失败不影响本次调用:last_used_at 只是审计信息
|
||||||
|
_, _ = s.db.ExecContext(ctx,
|
||||||
|
`UPDATE api_tokens SET last_used_at = ? WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`,
|
||||||
|
now.Format(time.RFC3339), id, cutoff)
|
||||||
|
return scope, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) apiTokenByID(ctx context.Context, id int64) (APIToken, error) {
|
||||||
|
var token APIToken
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, name, scope, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), created_at
|
||||||
|
FROM api_tokens WHERE id = ?`, id).
|
||||||
|
Scan(&token.ID, &token.Name, &token.Scope, &token.ExpiresAt, &token.LastUsedAt, &token.CreatedAt)
|
||||||
|
return token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashToken(plain string) string {
|
||||||
|
sum := sha256.Sum256([]byte(plain))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestService(t *testing.T) *Service {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
return New(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPITokenLifecycle(t *testing.T) {
|
||||||
|
svc := newTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
created, err := svc.CreateAPIToken(ctx, "homeassistant", TokenScopeRead, time.Time{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if created.Token == "" || created.Scope != TokenScopeRead || created.ID == 0 {
|
||||||
|
t.Fatalf("unexpected created token: %+v", created)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 库里只存 SHA-256:明文不落盘
|
||||||
|
var stored string
|
||||||
|
if err := svc.db.QueryRowContext(ctx, `SELECT token_hash FROM api_tokens WHERE id = ?`, created.ID).Scan(&stored); err != nil {
|
||||||
|
t.Fatalf("read hash: %v", err)
|
||||||
|
}
|
||||||
|
if stored == created.Token || len(stored) != 64 {
|
||||||
|
t.Fatalf("令牌应以 SHA-256 存储,实际 %q", stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
scope, err := svc.ValidateAPIToken(ctx, created.Token)
|
||||||
|
if err != nil || scope != TokenScopeRead {
|
||||||
|
t.Fatalf("validate: scope=%q err=%v", scope, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastUsed string
|
||||||
|
if err := svc.db.QueryRowContext(ctx, `SELECT COALESCE(last_used_at, '') FROM api_tokens WHERE id = ?`, created.ID).Scan(&lastUsed); err != nil {
|
||||||
|
t.Fatalf("read last_used_at: %v", err)
|
||||||
|
}
|
||||||
|
if lastUsed == "" {
|
||||||
|
t.Fatal("校验通过后应回写 last_used_at")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.ValidateAPIToken(ctx, "not-a-real-token"); err != ErrUnauthorized {
|
||||||
|
t.Fatalf("未知令牌应拒绝,实际 %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.DeleteAPIToken(ctx, created.ID); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ValidateAPIToken(ctx, created.Token); err != ErrUnauthorized {
|
||||||
|
t.Fatalf("删除后令牌应失效,实际 %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.DeleteAPIToken(ctx, created.ID); err == nil {
|
||||||
|
t.Fatal("重复删除应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPITokenExpiry(t *testing.T) {
|
||||||
|
svc := newTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
expired, err := svc.CreateAPIToken(ctx, "old-script", TokenScopeWrite, time.Now().Add(-time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ValidateAPIToken(ctx, expired.Token); err != ErrUnauthorized {
|
||||||
|
t.Fatalf("过期令牌应拒绝,实际 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPITokenRejectsBadInput(t *testing.T) {
|
||||||
|
svc := newTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := svc.CreateAPIToken(ctx, " ", TokenScopeRead, time.Time{}); err == nil {
|
||||||
|
t.Fatal("空名称应报错")
|
||||||
|
}
|
||||||
|
if _, err := svc.CreateAPIToken(ctx, "bad-scope", "admin", time.Time{}); err == nil {
|
||||||
|
t.Fatal("未知权限范围应报错")
|
||||||
|
}
|
||||||
|
if _, err := svc.ValidateAPIToken(ctx, " "); err != ErrUnauthorized {
|
||||||
|
t.Fatalf("空令牌应拒绝,实际 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAPITokensReturnsNewestFirst(t *testing.T) {
|
||||||
|
svc := newTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := svc.CreateAPIToken(ctx, "first", TokenScopeRead, time.Time{}); err != nil {
|
||||||
|
t.Fatalf("create first: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.CreateAPIToken(ctx, "second", TokenScopeWrite, time.Time{}); err != nil {
|
||||||
|
t.Fatalf("create second: %v", err)
|
||||||
|
}
|
||||||
|
tokens, err := svc.ListAPITokens(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(tokens) != 2 || tokens[0].Name != "second" {
|
||||||
|
t.Fatalf("应按 id 倒序返回:%+v", tokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 保留份数的边界:0/负数按默认值处理,上限防止把磁盘塞满
|
||||||
|
const (
|
||||||
|
DefaultKeepCount = 7
|
||||||
|
MaxKeepCount = 60
|
||||||
|
)
|
||||||
|
|
||||||
|
// archiveNamePattern 备份命名格式(Export 用 CreateTemp 生成,后缀是随机数字)。
|
||||||
|
// List / Path 都按它过滤,避免把 backups 目录里任意文件当成备份读写或删除。
|
||||||
|
var archiveNamePattern = regexp.MustCompile(`^fonu-backup-\d{8}-\d{6}-\d+\.tar\.gz$`)
|
||||||
|
|
||||||
|
// Archive 留在磁盘上的一份备份
|
||||||
|
type Archive struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
ModTime time.Time `json:"mod_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) backupsDir() string {
|
||||||
|
return filepath.Join(s.dataDir, "backups")
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 按新的在前列出备份;目录不存在返回空列表(还没备份过是正常状态)。
|
||||||
|
func (s *Service) List() ([]Archive, error) {
|
||||||
|
entries, err := os.ReadDir(s.backupsDir())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
archives := make([]Archive, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !archiveNamePattern.MatchString(entry.Name()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
archives = append(archives, Archive{Name: entry.Name(), Size: info.Size(), ModTime: info.ModTime().UTC()})
|
||||||
|
}
|
||||||
|
// 文件名前缀就是时间戳,按名倒序即按时间倒序
|
||||||
|
sort.Slice(archives, func(i, j int) bool { return archives[i].Name > archives[j].Name })
|
||||||
|
return archives, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune 只保留最新的 keep 份,返回删除数量;keep <= 0 表示不剪枝。
|
||||||
|
func (s *Service) Prune(keep int) (int, error) {
|
||||||
|
if keep <= 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
archives, err := s.List()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
removed := 0
|
||||||
|
for _, archive := range archives[min(keep, len(archives)):] {
|
||||||
|
if err := s.Remove(archive.Name); err != nil {
|
||||||
|
return removed, err
|
||||||
|
}
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
return removed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path 把备份名解析成绝对路径;名字必须匹配备份命名格式,避免被用来读写目录外的文件。
|
||||||
|
func (s *Service) Path(name string) (string, error) {
|
||||||
|
if !archiveNamePattern.MatchString(name) {
|
||||||
|
return "", fmt.Errorf("备份名无效")
|
||||||
|
}
|
||||||
|
return filepath.Join(s.backupsDir(), name), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove 删除指定备份
|
||||||
|
func (s *Service) Remove(name string) error {
|
||||||
|
path, err := s.Path(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("备份不存在")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify 回读归档并返回条目数:确认这份备份不是写了一半的残件,
|
||||||
|
// 同时要求包内必须有 fonu.db(否则多半不是 Fonu 的备份)。
|
||||||
|
func (s *Service) Verify(ctx context.Context, path string) (int, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
gr, err := gzip.NewReader(file)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("备份文件无法解压:%w", err)
|
||||||
|
}
|
||||||
|
defer gr.Close()
|
||||||
|
|
||||||
|
tr := tar.NewReader(gr)
|
||||||
|
count := 0
|
||||||
|
hasDatabase := false
|
||||||
|
for {
|
||||||
|
header, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return count, fmt.Errorf("备份文件已损坏:%w", err)
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
if header.Name == "fonu.db" {
|
||||||
|
hasDatabase = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasDatabase {
|
||||||
|
return count, fmt.Errorf("备份里没有 fonu.db,可能不是 Fonu 的备份")
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeepCount 读取保留份数(未配置或越界时回落到默认值)。
|
||||||
|
// 自动备份任务与「立即备份」共用它,避免两处各写一套默认值。
|
||||||
|
func KeepCount(ctx context.Context, store *settings.Store) int {
|
||||||
|
keep, err := store.GetInt(ctx, settings.KeyBackupKeepCount)
|
||||||
|
if err != nil || keep <= 0 {
|
||||||
|
return DefaultKeepCount
|
||||||
|
}
|
||||||
|
if keep > MaxKeepCount {
|
||||||
|
return MaxKeepCount
|
||||||
|
}
|
||||||
|
return keep
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/notify"
|
||||||
|
"github.com/fonu/fonu/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AutoInterval 自动备份间隔。scheduler 启动时会先跑一轮,靠「最新备份是否还在间隔内」
|
||||||
|
// 判断该不该备份,因此频繁重启(NAS 上很常见)不会重复备份。
|
||||||
|
const AutoInterval = 24 * time.Hour
|
||||||
|
|
||||||
|
// AutoRunner 定期导出一份归档、回读校验、再按保留份数剪枝。
|
||||||
|
type AutoRunner struct {
|
||||||
|
svc *Service
|
||||||
|
settings *settings.Store
|
||||||
|
notify *notify.Service
|
||||||
|
logger *slog.Logger
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAutoRunner(svc *Service, settingsStore *settings.Store, notifySvc *notify.Service, logger *slog.Logger) *AutoRunner {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &AutoRunner{svc: svc, settings: settingsStore, notify: notifySvc, logger: logger, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run 跑一轮。失败只通知、不返回错误:scheduler 的任务没有重试机制,通知才是可见性来源。
|
||||||
|
func (r *AutoRunner) Run(ctx context.Context) {
|
||||||
|
if r == nil || r.svc == nil || r.settings == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enabled, err := r.settings.GetBool(ctx, settings.KeyBackupAutoEnabled)
|
||||||
|
if err != nil || !enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
archives, err := r.svc.List()
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Warn("读取备份列表失败", "module", "BACKUP", "error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(archives) > 0 && !dueForBackup(archives[0].ModTime, r.now(), AutoInterval) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := r.svc.Export(ctx)
|
||||||
|
if err != nil {
|
||||||
|
r.fail(ctx, fmt.Sprintf("导出备份失败:%s", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := r.svc.Verify(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
// 校验不过的归档不算一份可用备份:删掉并如实告警,别让用户以为已经有备份
|
||||||
|
if removeErr := r.svc.Remove(filepath.Base(path)); removeErr != nil {
|
||||||
|
r.logger.Warn("删除校验失败的备份失败", "module", "BACKUP", "error", removeErr.Error())
|
||||||
|
}
|
||||||
|
r.fail(ctx, fmt.Sprintf("备份校验失败:%s", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
removed, err := r.svc.Prune(KeepCount(ctx, r.settings))
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Warn("剪枝旧备份失败", "module", "BACKUP", "error", err.Error())
|
||||||
|
}
|
||||||
|
r.logger.Info("自动备份完成", "module", "BACKUP",
|
||||||
|
"file", filepath.Base(path), "entries", entries, "removed", removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dueForBackup 判断该不该做一次备份:没有备份,或最新一份已超过间隔。
|
||||||
|
func dueForBackup(newest, now time.Time, interval time.Duration) bool {
|
||||||
|
if newest.IsZero() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return now.Sub(newest) >= interval
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AutoRunner) fail(ctx context.Context, message string) {
|
||||||
|
r.logger.Error("自动备份失败", "module", "BACKUP", "error", message)
|
||||||
|
if r.notify != nil {
|
||||||
|
r.notify.Alert(ctx, notify.EventBackupFailure, "自动备份失败", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDueForBackup(t *testing.T) {
|
||||||
|
now := time.Date(2026, 9, 25, 10, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// 还没有备份 → 立刻做一份
|
||||||
|
if !dueForBackup(time.Time{}, now, AutoInterval) {
|
||||||
|
t.Fatal("没有备份时应立即备份")
|
||||||
|
}
|
||||||
|
// 刚备份过 → 跳过:这正是重启不会重复备份的原因
|
||||||
|
if dueForBackup(now.Add(-time.Hour), now, AutoInterval) {
|
||||||
|
t.Fatal("间隔内的备份不应重复执行")
|
||||||
|
}
|
||||||
|
// 正好到间隔 → 该做了
|
||||||
|
if !dueForBackup(now.Add(-AutoInterval), now, AutoInterval) {
|
||||||
|
t.Fatal("到达间隔应再备份一次")
|
||||||
|
}
|
||||||
|
// 超过间隔 → 该做
|
||||||
|
if !dueForBackup(now.Add(-25*time.Hour), now, AutoInterval) {
|
||||||
|
t.Fatal("超过间隔应再备份一次")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportVerifyRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "fonu.db"), []byte("db"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc := New(dir)
|
||||||
|
|
||||||
|
path, err := svc.Export(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("export: %v", err)
|
||||||
|
}
|
||||||
|
entries, err := svc.Verify(context.Background(), path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
if entries < 1 {
|
||||||
|
t.Fatalf("条目数应至少为 1,实际 %d", entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
archives, err := svc.List()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(archives) != 1 {
|
||||||
|
t.Fatalf("应列出 1 份备份,实际 %d", len(archives))
|
||||||
|
}
|
||||||
|
if archives[0].Size == 0 || !archiveNamePattern.MatchString(archives[0].Name) {
|
||||||
|
t.Fatalf("备份条目异常:%+v", archives[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyRejectsGarbage(t *testing.T) {
|
||||||
|
svc := New(t.TempDir())
|
||||||
|
path := filepath.Join(svc.backupsDir(), "fonu-backup-20260925-000000-1.tar.gz")
|
||||||
|
if err := os.MkdirAll(svc.backupsDir(), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte("not a gzip stream"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.Verify(context.Background(), path); err == nil {
|
||||||
|
t.Fatal("非 gzip 内容应校验失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPruneAndPath(t *testing.T) {
|
||||||
|
svc := New(t.TempDir())
|
||||||
|
if err := os.MkdirAll(svc.backupsDir(), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{
|
||||||
|
"fonu-backup-20260923-100000-1.tar.gz",
|
||||||
|
"fonu-backup-20260924-100000-2.tar.gz",
|
||||||
|
"fonu-backup-20260925-100000-3.tar.gz",
|
||||||
|
} {
|
||||||
|
if err := os.WriteFile(filepath.Join(svc.backupsDir(), name), []byte("x"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 不符合命名格式的文件不参与:备份目录里可能混着手工放进来的东西
|
||||||
|
if err := os.WriteFile(filepath.Join(svc.backupsDir(), "notes.txt"), []byte("x"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
archives, err := svc.List()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(archives) != 3 {
|
||||||
|
t.Fatalf("应只列出 3 份备份,实际 %d", len(archives))
|
||||||
|
}
|
||||||
|
if archives[0].Name != "fonu-backup-20260925-100000-3.tar.gz" {
|
||||||
|
t.Fatalf("应按时间倒序,实际首条 %s", archives[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
removed, err := svc.Prune(2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prune: %v", err)
|
||||||
|
}
|
||||||
|
if removed != 1 {
|
||||||
|
t.Fatalf("应删除 1 份,实际 %d", removed)
|
||||||
|
}
|
||||||
|
if archives, _ = svc.List(); len(archives) != 2 {
|
||||||
|
t.Fatalf("剪枝后应剩 2 份,实际 %d", len(archives))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 名称校验:不能让备份名把读写带出备份目录
|
||||||
|
for _, name := range []string{"../fonu.db", "notes.txt", "", "fonu-backup-x.tar.gz"} {
|
||||||
|
if _, err := svc.Path(name); err == nil {
|
||||||
|
t.Fatalf("非法备份名应拒绝:%q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := svc.Remove("fonu-backup-20260920-100000-9.tar.gz"); err == nil {
|
||||||
|
t.Fatal("删除不存在的备份应报错")
|
||||||
|
}
|
||||||
|
if _, err := svc.Prune(0); err != nil {
|
||||||
|
t.Fatalf("keep<=0 应直接返回:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
-33
@@ -16,49 +16,70 @@ type Service struct {
|
|||||||
dataDir string
|
dataDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 还原时的固定权限:备份内容只有数据库、证书与 nginx 配置,不接受归档自带的权限位
|
||||||
|
const (
|
||||||
|
restoreFileMode = 0o640
|
||||||
|
restoreDirMode = 0o750
|
||||||
|
)
|
||||||
|
|
||||||
func New(dataDir string) *Service {
|
func New(dataDir string) *Service {
|
||||||
return &Service{dataDir: dataDir}
|
return &Service{dataDir: dataDir}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// backupTimeFmt 归档文件名里的时间戳;List / Path 都按同一套格式校验
|
||||||
|
const backupTimeFmt = "20060102-150405"
|
||||||
|
|
||||||
|
// Export 导出一份归档并返回路径;文件留在 <DataDir>/backups 下,由调用方决定下载后删除还是长期保留。
|
||||||
func (s *Service) Export(ctx context.Context) (string, error) {
|
func (s *Service) Export(ctx context.Context) (string, error) {
|
||||||
tmpDir := filepath.Join(s.dataDir, "backups")
|
if err := os.MkdirAll(s.backupsDir(), 0o755); err != nil {
|
||||||
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
name := fmt.Sprintf("fonu-backup-%s.tar.gz", time.Now().Format("20060102-His"))
|
// CreateTemp 保证同一秒内多次导出不会覆盖已有备份(文件名带随机后缀)
|
||||||
outPath := filepath.Join(tmpDir, name)
|
file, err := os.CreateTemp(s.backupsDir(), fmt.Sprintf("fonu-backup-%s-*.tar.gz", time.Now().Format(backupTimeFmt)))
|
||||||
file, err := os.Create(outPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer file.Close()
|
outPath := file.Name()
|
||||||
|
if err := s.writeArchive(ctx, file, outPath); err != nil {
|
||||||
gw := gzip.NewWriter(file)
|
|
||||||
tw := tar.NewWriter(gw)
|
|
||||||
|
|
||||||
entries := []string{
|
|
||||||
"fonu.db",
|
|
||||||
"certs",
|
|
||||||
"nginx",
|
|
||||||
}
|
|
||||||
for _, entry := range entries {
|
|
||||||
if err := addToArchive(ctx, tw, s.dataDir, entry); err != nil {
|
|
||||||
_ = tw.Close()
|
|
||||||
_ = gw.Close()
|
|
||||||
_ = os.Remove(outPath)
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tw.Close(); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := gw.Close(); err != nil {
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return outPath, nil
|
return outPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeArchive 写归档,逐层检查关闭错误。
|
||||||
|
//
|
||||||
|
// Close 的错误必须往上抛:tar/gzip 的尾部与文件系统的 flush 都发生在 Close,
|
||||||
|
// 忽略它就会出现「备份文件不完整、函数却返回成功」——这是最坏的一种假成功。
|
||||||
|
// 任一步失败都删掉半成品,不留一个看起来能用的备份。
|
||||||
|
func (s *Service) writeArchive(ctx context.Context, file *os.File, outPath string) error {
|
||||||
|
discard := func() {
|
||||||
|
_ = file.Close()
|
||||||
|
_ = os.Remove(outPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
gw := gzip.NewWriter(file)
|
||||||
|
tw := tar.NewWriter(gw)
|
||||||
|
for _, entry := range []string{"fonu.db", "certs", "nginx", "cloudflared/tunnels"} {
|
||||||
|
if err := addToArchive(ctx, tw, s.dataDir, entry); err != nil {
|
||||||
|
discard()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
discard()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := gw.Close(); err != nil {
|
||||||
|
discard()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
_ = os.Remove(outPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Restore(ctx context.Context, archivePath string) error {
|
func (s *Service) Restore(ctx context.Context, archivePath string) error {
|
||||||
file, err := os.Open(archivePath)
|
file, err := os.Open(archivePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -85,19 +106,22 @@ func (s *Service) Restore(ctx context.Context, archivePath string) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
target := filepath.Join(s.dataDir, header.Name)
|
target := filepath.Join(s.dataDir, header.Name)
|
||||||
if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(s.dataDir)) {
|
// 归档条目必须落在 DataDir 内:前缀比较会被 ../data-other 这类同前缀路径绕过,用 Rel 判定
|
||||||
return fmt.Errorf("备份条目非法")
|
rel, err := filepath.Rel(s.dataDir, target)
|
||||||
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||||
|
return fmt.Errorf("备份条目非法: %s", header.Name)
|
||||||
}
|
}
|
||||||
switch header.Typeflag {
|
switch header.Typeflag {
|
||||||
case tar.TypeDir:
|
case tar.TypeDir:
|
||||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
if err := os.MkdirAll(target, restoreDirMode); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
case tar.TypeReg:
|
case tar.TypeReg:
|
||||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(target), restoreDirMode); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
|
// 归档里的权限位不照搬(构造的归档可带 0777 / 执行位):备份内容只有数据库、证书与 nginx 配置
|
||||||
|
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, restoreFileMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeArchive 造一个含指定条目的 tar.gz,用于验证还原侧的路径校验与权限处理。
|
||||||
|
func writeArchive(t *testing.T, entries map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "backup.tar.gz")
|
||||||
|
file, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
gz := gzip.NewWriter(file)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
for name, content := range entries {
|
||||||
|
header := &tar.Header{Name: name, Mode: 0o777, Size: int64(len(content)), Typeflag: tar.TypeReg}
|
||||||
|
if err := tw.WriteHeader(header); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := tw.Write([]byte(content)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := gz.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRestoreRejectsEscapingEntries 覆盖「同前缀绕过」:旧实现用 strings.HasPrefix 判断,
|
||||||
|
// DataDir=/data 时 ../data-other/x 会通过前缀匹配写到 DataDir 之外。
|
||||||
|
func TestRestoreRejectsEscapingEntries(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dataDir := filepath.Join(root, "data")
|
||||||
|
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
archive := writeArchive(t, map[string]string{"../data-other/evil.txt": "x"})
|
||||||
|
if err := New(dataDir).Restore(context.Background(), archive); err == nil {
|
||||||
|
t.Fatal("应当拒绝逃出 DataDir 的条目")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "data-other", "evil.txt")); err == nil {
|
||||||
|
t.Fatal("逃逸条目被写到了 DataDir 之外")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreRejectsParentEscape(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dataDir := filepath.Join(root, "data")
|
||||||
|
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
archive := writeArchive(t, map[string]string{"../../evil.txt": "x"})
|
||||||
|
if err := New(dataDir).Restore(context.Background(), archive); err == nil {
|
||||||
|
t.Fatal("应当拒绝带 .. 的条目")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRestoreAppliesFixedMode:归档里的权限位不照搬(构造的归档可带 0777 / 执行位)。
|
||||||
|
func TestRestoreAppliesFixedMode(t *testing.T) {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
archive := writeArchive(t, map[string]string{"nginx/nginx.conf": "worker_processes 1;"})
|
||||||
|
if err := New(dataDir).Restore(context.Background(), archive); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(filepath.Join(dataDir, "nginx", "nginx.conf"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := info.Mode().Perm(); runtime.GOOS == "windows" {
|
||||||
|
// Windows 不保留 Unix 权限位(可写文件统一报 0666):只断言没带上执行位
|
||||||
|
if got&0o111 != 0 {
|
||||||
|
t.Fatalf("还原后的文件不应带执行位,实际 %o", got)
|
||||||
|
}
|
||||||
|
} else if got != restoreFileMode {
|
||||||
|
t.Fatalf("还原后的权限应为 %o,实际 %o", restoreFileMode, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportIncludesCloudflareTunnelConfig(t *testing.T) {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
tunnelDir := filepath.Join(dataDir, "cloudflared", "tunnels", "home")
|
||||||
|
if err := os.MkdirAll(tunnelDir, 0o750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tunnelDir, "config.yml"), []byte("tunnel: test\n"), 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path, err := New(dataDir).Export(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
gr, err := gzip.NewReader(file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer gr.Close()
|
||||||
|
tr := tar.NewReader(gr)
|
||||||
|
found := false
|
||||||
|
for {
|
||||||
|
header, err := tr.Next()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if header.Name == "cloudflared/tunnels/home/config.yml" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("备份应包含 Cloudflare 隧道配置")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,14 +14,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fonu/fonu/internal/config"
|
"github.com/fonu/fonu/internal/config"
|
||||||
|
"github.com/fonu/fonu/internal/fsutil"
|
||||||
"github.com/fonu/fonu/internal/nginx"
|
"github.com/fonu/fonu/internal/nginx"
|
||||||
"github.com/fonu/fonu/internal/proxy"
|
"github.com/fonu/fonu/internal/proxy"
|
||||||
"github.com/fonu/fonu/internal/settings"
|
"github.com/fonu/fonu/internal/settings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultV4URL = "https://raw.githubusercontent.com/17mon/china_ip_list/master/china_ip_list.txt"
|
defaultV4URL = "https://raw.githubusercontent.com/17mon/china_ip_list/master/china_ip_list.txt"
|
||||||
ghfastProxyPrefix = "https://ghfast.top/"
|
ghfastProxyPrefix = "https://ghfast.top/"
|
||||||
)
|
)
|
||||||
|
|
||||||
// metowolf/iplist 已下线 CN/ipv6.txt;按优先级尝试可用源。
|
// metowolf/iplist 已下线 CN/ipv6.txt;按优先级尝试可用源。
|
||||||
@@ -96,11 +97,11 @@ func (s *Service) Update(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tmpPath := s.cfg.ChinaCIDRTempPath()
|
tmpPath := s.cfg.ChinaCIDRTempPath()
|
||||||
if err := os.WriteFile(tmpPath, []byte(body), 0o644); err != nil {
|
if err := fsutil.WriteFileAtomic(tmpPath, []byte(body), 0o644); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
rules, err := s.proxyStore.ListEnabled(ctx)
|
rules, err := s.proxyStore.ListEnabledLocal(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const minBinarySize = 10 << 20
|
||||||
|
|
||||||
|
// Mirror 是 cloudflared 二进制下载镜像。
|
||||||
|
type Mirror struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Base string `json:"base"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultMirrors 返回内置镜像表;Base 为空表示 GitHub 官方源。
|
||||||
|
func DefaultMirrors() []Mirror {
|
||||||
|
return []Mirror{
|
||||||
|
{ID: "official", Name: "GitHub 官方源"},
|
||||||
|
{ID: "gh-proxy-org", Name: "gh-proxy.org", Base: "https://gh-proxy.org/"},
|
||||||
|
{ID: "gh-proxy-hk", Name: "gh-proxy 香港", Base: "https://hk.gh-proxy.org/"},
|
||||||
|
{ID: "gh-proxy-cdn", Name: "gh-proxy CDN", Base: "https://cdn.gh-proxy.org/"},
|
||||||
|
{ID: "gh-proxy-edgeone", Name: "gh-proxy EdgeOne", Base: "https://edgeone.gh-proxy.org/"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findMirror(id string) Mirror {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
for _, mirror := range DefaultMirrors() {
|
||||||
|
if mirror.ID == id {
|
||||||
|
return mirror
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DefaultMirrors()[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func binaryFileName() string {
|
||||||
|
name := "cloudflared-" + runtime.GOOS + "-" + runtime.GOARCH
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
name += ".exe"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadURL 生成指定镜像的下载地址;version 为空表示 latest。
|
||||||
|
func DownloadURL(mirror Mirror, version string) string {
|
||||||
|
official := "https://github.com/cloudflare/cloudflared/releases/latest/download/" + binaryFileName()
|
||||||
|
if strings.TrimSpace(version) != "" {
|
||||||
|
official = "https://github.com/cloudflare/cloudflared/releases/download/" + strings.TrimSpace(version) + "/" + binaryFileName()
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(mirror.Base) == "" {
|
||||||
|
return official
|
||||||
|
}
|
||||||
|
return mirror.Base + official
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) binaryPath() string {
|
||||||
|
name := "cloudflared"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
name += ".exe"
|
||||||
|
}
|
||||||
|
return filepath.Join(s.cfg.DataDir, "cloudflared", "bin", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BinaryVersion 返回本机 cloudflared 版本;未安装返回空串。
|
||||||
|
func (s *Service) BinaryVersion() string {
|
||||||
|
path := s.binaryPath()
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
out, err := exec.Command(path, "--version").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadBinary 下载并替换本机 cloudflared 二进制。
|
||||||
|
func (s *Service) DownloadBinary(ctx context.Context, mirrorID string) (string, error) {
|
||||||
|
return s.DownloadBinaryVersion(ctx, mirrorID, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadBinaryVersion 下载指定版本;version 为空表示最新版。
|
||||||
|
func (s *Service) DownloadBinaryVersion(ctx context.Context, mirrorID, version string) (string, error) {
|
||||||
|
mirror := findMirror(mirrorID)
|
||||||
|
target := s.binaryPath()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
tmp := target + ".download"
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
if err := fetchFile(ctx, DownloadURL(mirror, version), tmp); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
info, err := os.Stat(tmp)
|
||||||
|
if err != nil || info.Size() < minBinarySize {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", fmt.Errorf("下载文件过小,可能被镜像源截断")
|
||||||
|
}
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
_ = os.Chmod(tmp, 0o755)
|
||||||
|
}
|
||||||
|
if out, err := exec.Command(tmp, "--version").CombinedOutput(); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", fmt.Errorf("cloudflared 二进制不可执行: %s", strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
previous := target + ".previous"
|
||||||
|
if _, err := os.Stat(target); err == nil {
|
||||||
|
_ = os.Remove(previous)
|
||||||
|
if err := os.Rename(target, previous); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, target); err != nil {
|
||||||
|
_ = os.Rename(previous, target)
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return s.BinaryVersion(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestBinaryVersion 查询 Cloudflare 官方 GitHub Release 的最新版本。
|
||||||
|
func (s *Service) LatestBinaryVersion(ctx context.Context) (string, error) {
|
||||||
|
client := &http.Client{Timeout: 20 * time.Second}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/repos/cloudflare/cloudflared/releases/latest", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("读取最新版本失败:HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
version := strings.TrimSpace(payload.TagName)
|
||||||
|
if version == "" {
|
||||||
|
return "", fmt.Errorf("GitHub 未返回 cloudflared 版本")
|
||||||
|
}
|
||||||
|
return version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RollbackBinary 恢复上一版 cloudflared 二进制。
|
||||||
|
func (s *Service) RollbackBinary() (string, error) {
|
||||||
|
target := s.binaryPath()
|
||||||
|
previous := target + ".previous"
|
||||||
|
if _, err := os.Stat(previous); err != nil {
|
||||||
|
return "", fmt.Errorf("没有可回滚的 cloudflared 版本")
|
||||||
|
}
|
||||||
|
failed := target + ".failed"
|
||||||
|
_ = os.Remove(failed)
|
||||||
|
if _, err := os.Stat(target); err == nil {
|
||||||
|
if err := os.Rename(target, failed); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Rename(previous, target); err != nil {
|
||||||
|
_ = os.Rename(failed, target)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_ = os.Remove(failed)
|
||||||
|
return s.BinaryVersion(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchFile(ctx context.Context, url, path string) error {
|
||||||
|
client := &http.Client{Timeout: 5 * time.Minute}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("下载 cloudflared 失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("下载 cloudflared 失败: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
file, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
if _, err := io.Copy(file, resp.Body); err != nil {
|
||||||
|
return fmt.Errorf("写入 cloudflared 失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDownloadURL(t *testing.T) {
|
||||||
|
official := DownloadURL(Mirror{ID: "official"}, "")
|
||||||
|
if !strings.Contains(official, "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-") {
|
||||||
|
t.Fatalf("官方下载地址不正确:%s", official)
|
||||||
|
}
|
||||||
|
proxied := DownloadURL(Mirror{ID: "gh-proxy-org", Base: "https://gh-proxy.org/"}, "")
|
||||||
|
if !strings.HasPrefix(proxied, "https://gh-proxy.org/https://github.com/cloudflare/cloudflared/releases/latest/download/") {
|
||||||
|
t.Fatalf("镜像前缀拼接不正确:%s", proxied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindMirrorFallback(t *testing.T) {
|
||||||
|
if got := findMirror("not-exist"); got.ID != "official" {
|
||||||
|
t.Fatalf("未知镜像应回退官方源:%#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const cloudflareAPIBase = "https://api.cloudflare.com/client/v4"
|
||||||
|
|
||||||
|
// CloudflareAPI 用 Account ID + API Token 调用 Cloudflare API。
|
||||||
|
type CloudflareAPI struct {
|
||||||
|
AccountID string
|
||||||
|
APIToken string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCloudflareAPI(accountID, apiToken string) *CloudflareAPI {
|
||||||
|
return &CloudflareAPI{
|
||||||
|
AccountID: strings.TrimSpace(accountID),
|
||||||
|
APIToken: strings.TrimSpace(apiToken),
|
||||||
|
client: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type cfAPIError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cfEnvelope struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
Errors []cfAPIError `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *CloudflareAPI) do(ctx context.Context, method, path string, payload any, out any) error {
|
||||||
|
var body io.Reader
|
||||||
|
if payload != nil {
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, cloudflareAPIBase+path, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+a.APIToken)
|
||||||
|
if payload != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("请求 Cloudflare API 失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var envelope cfEnvelope
|
||||||
|
if err := json.Unmarshal(data, &envelope); err != nil {
|
||||||
|
return fmt.Errorf("Cloudflare API 响应格式无效(HTTP %d)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if !envelope.Success {
|
||||||
|
message := "Cloudflare API 调用失败"
|
||||||
|
if len(envelope.Errors) > 0 && strings.TrimSpace(envelope.Errors[0].Message) != "" {
|
||||||
|
message = envelope.Errors[0].Message
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s(HTTP %d)", message, resp.StatusCode)
|
||||||
|
}
|
||||||
|
if out != nil && len(envelope.Result) > 0 {
|
||||||
|
if err := json.Unmarshal(envelope.Result, out); err != nil {
|
||||||
|
return fmt.Errorf("Cloudflare API 结果解析失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyToken 调用账号级 tokens/verify 接口验证 API Token。
|
||||||
|
func (a *CloudflareAPI) VerifyToken(ctx context.Context) (string, error) {
|
||||||
|
if a.AccountID == "" {
|
||||||
|
return "", fmt.Errorf("请先填写 Account ID")
|
||||||
|
}
|
||||||
|
if a.APIToken == "" {
|
||||||
|
return "", fmt.Errorf("请先填写 API Token(cfat_...)")
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/tokens/verify", nil, &result); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if result.Status != "active" {
|
||||||
|
return "", fmt.Errorf("API Token 状态为 %s,不是 active", result.Status)
|
||||||
|
}
|
||||||
|
return "API Token 有效(状态:active)", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTunnel 创建一条 config_src=local 的命名隧道,并返回隧道 ID 与凭据 token。
|
||||||
|
func (a *CloudflareAPI) CreateTunnel(ctx context.Context, name string) (tunnelID, token string, err error) {
|
||||||
|
var result struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
payload := map[string]any{"name": name, "config_src": "local"}
|
||||||
|
if err := a.do(ctx, http.MethodPost, "/accounts/"+a.AccountID+"/cfd_tunnel", payload, &result); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if result.ID == "" {
|
||||||
|
return "", "", fmt.Errorf("Cloudflare 未返回隧道 ID")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(result.Token) != "" {
|
||||||
|
return result.ID, result.Token, nil
|
||||||
|
}
|
||||||
|
token, err = a.TunnelToken(ctx, result.ID)
|
||||||
|
return result.ID, token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TunnelToken 读取指定隧道的凭据 token。
|
||||||
|
func (a *CloudflareAPI) TunnelToken(ctx context.Context, tunnelID string) (string, error) {
|
||||||
|
var token string
|
||||||
|
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"/token", nil, &token); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(token) == "" {
|
||||||
|
return "", fmt.Errorf("Cloudflare 未返回隧道凭据")
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutIngress 把本地 ingress 推送到 Cloudflare 云端配置。
|
||||||
|
func (a *CloudflareAPI) PutIngress(ctx context.Context, tunnelID string, ingress []IngressRule) error {
|
||||||
|
return a.do(ctx, http.MethodPut, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"/configurations", map[string]any{
|
||||||
|
"config": map[string]any{"ingress": ingress},
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIngress 读取云端隧道的 ingress 配置。
|
||||||
|
func (a *CloudflareAPI) GetIngress(ctx context.Context, tunnelID string) ([]IngressRule, error) {
|
||||||
|
var result struct {
|
||||||
|
Config struct {
|
||||||
|
Ingress []IngressRule `json:"ingress"`
|
||||||
|
} `json:"config"`
|
||||||
|
}
|
||||||
|
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"/configurations", nil, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.Config.Ingress, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTunnel 删除云端隧道。
|
||||||
|
func (a *CloudflareAPI) DeleteTunnel(ctx context.Context, tunnelID string) error {
|
||||||
|
return a.do(ctx, http.MethodDelete, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"?cascade=true", nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zone 是 Cloudflare 的域名区域。
|
||||||
|
type Zone struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListZones 返回当前 API Token 可访问的 Zone。
|
||||||
|
func (a *CloudflareAPI) ListZones(ctx context.Context) ([]Zone, error) {
|
||||||
|
var zones []Zone
|
||||||
|
if err := a.do(ctx, http.MethodGet, "/zones?per_page=50", nil, &zones); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return zones, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindZone 从 hostname 逐级向上查找所属 Zone。
|
||||||
|
func (a *CloudflareAPI) FindZone(ctx context.Context, hostname string) (string, string, error) {
|
||||||
|
labels := strings.Split(strings.Trim(hostname, "."), ".")
|
||||||
|
for i := 0; i < len(labels)-1; i++ {
|
||||||
|
candidate := strings.Join(labels[i:], ".")
|
||||||
|
var zones []Zone
|
||||||
|
if err := a.do(ctx, http.MethodGet, "/zones?name="+url.QueryEscape(candidate), nil, &zones); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if len(zones) > 0 {
|
||||||
|
return zones[0].ID, zones[0].Name, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", fmt.Errorf("未找到 %s 对应的 Cloudflare Zone;请确认该域名的根域名已加入当前 Account ID,且 API Token 的 Zone Resources 包含它,并具备 Zone:Read 权限", hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureCNAME 幂等创建指向隧道的 CNAME 记录,并开启代理(橙云)。
|
||||||
|
// 已存在且目标一致的记录会被接管;指向其他目标的记录不会被覆盖。
|
||||||
|
func (a *CloudflareAPI) EnsureCNAME(ctx context.Context, zoneID, hostname, target string) (string, error) {
|
||||||
|
var records []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
path := "/zones/" + zoneID + "/dns_records?type=CNAME&name=" + url.QueryEscape(hostname)
|
||||||
|
if err := a.do(ctx, http.MethodGet, path, nil, &records); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"type": "CNAME", "name": hostname, "content": target, "proxied": true, "ttl": 1,
|
||||||
|
}
|
||||||
|
if len(records) > 0 {
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(records[0].Content), strings.TrimSpace(target)) {
|
||||||
|
return "", fmt.Errorf("DNS 记录 %s 已指向其他目标,未自动覆盖", hostname)
|
||||||
|
}
|
||||||
|
if err := a.do(ctx, http.MethodPut, "/zones/"+zoneID+"/dns_records/"+records[0].ID, payload, nil); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return records[0].ID, nil
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := a.do(ctx, http.MethodPost, "/zones/"+zoneID+"/dns_records", payload, &created); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(created.ID) == "" {
|
||||||
|
return "", fmt.Errorf("Cloudflare 未返回 DNS 记录 ID")
|
||||||
|
}
|
||||||
|
return created.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDNSRecord 删除一条明确由 Fonu 接管的 DNS 记录。
|
||||||
|
func (a *CloudflareAPI) DeleteDNSRecord(ctx context.Context, zoneID, recordID string) error {
|
||||||
|
return a.do(ctx, http.MethodDelete, "/zones/"+zoneID+"/dns_records/"+recordID, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
type accessApp struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasAccessForHostname 只读检测域名是否已有 Access 应用;Token 无权限时返回错误,不修改任何配置。
|
||||||
|
func (a *CloudflareAPI) HasAccessForHostname(ctx context.Context, hostname string) (bool, string, error) {
|
||||||
|
var apps []accessApp
|
||||||
|
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/access/apps?per_page=100", nil, &apps); err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
wanted := strings.ToLower(strings.TrimSpace(hostname))
|
||||||
|
for _, app := range apps {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(app.Domain), wanted) {
|
||||||
|
return true, app.Name, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, "", nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tokenPayload struct {
|
||||||
|
AccountTag string `json:"a"`
|
||||||
|
TunnelID string `json:"t"`
|
||||||
|
TunnelSecret string `json:"s"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeToken 解析 Cloudflare Tunnel token;兼容 a/t/s 与 AccountTag/TunnelID/TunnelSecret 两种字段名。
|
||||||
|
func DecodeToken(token string) (accountTag, tunnelID, tunnelSecret string, err error) {
|
||||||
|
clean := strings.NewReplacer("\n", "", "\r", "", " ", "", "\t", "").Replace(strings.TrimSpace(token))
|
||||||
|
if clean == "" {
|
||||||
|
return "", "", "", fmt.Errorf("token 为空")
|
||||||
|
}
|
||||||
|
raw, err := decodeBase64(clean)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
var payload tokenPayload
|
||||||
|
if err := json.Unmarshal(raw, &payload); err != nil || payload.TunnelID == "" {
|
||||||
|
var alt struct {
|
||||||
|
AccountTag string `json:"AccountTag"`
|
||||||
|
TunnelID string `json:"TunnelID"`
|
||||||
|
TunnelSecret string `json:"TunnelSecret"`
|
||||||
|
}
|
||||||
|
if err2 := json.Unmarshal(raw, &alt); err2 != nil {
|
||||||
|
return "", "", "", fmt.Errorf("token JSON 解析失败: %w", err)
|
||||||
|
}
|
||||||
|
payload.AccountTag, payload.TunnelID, payload.TunnelSecret = alt.AccountTag, alt.TunnelID, alt.TunnelSecret
|
||||||
|
}
|
||||||
|
if payload.AccountTag == "" || payload.TunnelID == "" || payload.TunnelSecret == "" {
|
||||||
|
return "", "", "", fmt.Errorf("token 缺少 AccountTag / TunnelID / TunnelSecret")
|
||||||
|
}
|
||||||
|
return payload.AccountTag, payload.TunnelID, payload.TunnelSecret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeBase64 同时兼容标准 base64 与 URL-safe base64,并兼容有无填充两种形式。
|
||||||
|
// Cloudflare 面板生成的 Tunnel Token 使用 URL-safe 编码,直接用标准 base64 会在 '-' / '_' 处失败。
|
||||||
|
func decodeBase64(input string) ([]byte, error) {
|
||||||
|
encodings := []*base64.Encoding{
|
||||||
|
base64.StdEncoding, base64.RawStdEncoding,
|
||||||
|
base64.URLEncoding, base64.RawURLEncoding,
|
||||||
|
}
|
||||||
|
for _, encoding := range encodings {
|
||||||
|
if data, err := encoding.DecodeString(input); err == nil {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
padded := input
|
||||||
|
if remainder := len(padded) % 4; remainder != 0 {
|
||||||
|
padded += strings.Repeat("=", 4-remainder)
|
||||||
|
}
|
||||||
|
for _, encoding := range encodings {
|
||||||
|
if data, err := encoding.DecodeString(padded); err == nil {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("token base64 解码失败:请确认粘贴的是完整的 Cloudflare 隧道 token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CredentialsJSON 生成 cloudflared 需要的 creds.json 内容。
|
||||||
|
func CredentialsJSON(accountTag, tunnelID, tunnelSecret string) string {
|
||||||
|
data, _ := json.MarshalIndent(map[string]string{
|
||||||
|
"AccountTag": accountTag,
|
||||||
|
"TunnelID": tunnelID,
|
||||||
|
"TunnelSecret": tunnelSecret,
|
||||||
|
}, "", " ")
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildConfig 生成 locally-managed 的 config.yml;ingress 末条由调用方保证是 catch-all。
|
||||||
|
func BuildConfig(tunnelID, credsPath string, network NetworkSettings, ingress []IngressRule) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "tunnel: %s\n", tunnelID)
|
||||||
|
fmt.Fprintf(&b, "credentials-file: %s\n\n", credsPath)
|
||||||
|
writeNetworkSection(&b, network)
|
||||||
|
b.WriteString("ingress:\n")
|
||||||
|
for _, rule := range ingress {
|
||||||
|
if rule.Hostname != "" {
|
||||||
|
fmt.Fprintf(&b, " - hostname: %s\n", rule.Hostname)
|
||||||
|
if path := strings.TrimSpace(rule.Path); path != "" && path != "*" {
|
||||||
|
fmt.Fprintf(&b, " path: %s\n", path)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " service: %s\n", rule.Service)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " - service: %s\n", rule.Service)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeNetworkSection(b *strings.Builder, network NetworkSettings) {
|
||||||
|
protocol := strings.TrimSpace(network.TransportProtocol)
|
||||||
|
edge := strings.TrimSpace(network.EdgeIPVersion)
|
||||||
|
hasOrigin := strings.TrimSpace(network.OriginCAPool) != "" || network.NoTLSVerify || strings.TrimSpace(network.HTTPHostHeader) != ""
|
||||||
|
if protocol != "" && protocol != "auto" {
|
||||||
|
fmt.Fprintf(b, "protocol: %s\n", protocol)
|
||||||
|
}
|
||||||
|
if edge != "" && edge != "auto" {
|
||||||
|
fmt.Fprintf(b, "edge-ip-version: \"%s\"\n", edge)
|
||||||
|
}
|
||||||
|
if network.HAConnections > 0 {
|
||||||
|
fmt.Fprintf(b, "ha-connections: %d\n", network.HAConnections)
|
||||||
|
}
|
||||||
|
if hasOrigin {
|
||||||
|
b.WriteString("originRequest:\n")
|
||||||
|
if pool := strings.TrimSpace(network.OriginCAPool); pool != "" {
|
||||||
|
fmt.Fprintf(b, " caPool: %s\n", pool)
|
||||||
|
}
|
||||||
|
if network.NoTLSVerify {
|
||||||
|
b.WriteString(" noTLSVerify: true\n")
|
||||||
|
}
|
||||||
|
if header := strings.TrimSpace(network.HTTPHostHeader); header != "" {
|
||||||
|
fmt.Fprintf(b, " httpHostHeader: %s\n", header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if protocol != "" || edge != "" || network.HAConnections > 0 || hasOrigin {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildIngress 从域名列表生成 ingress,锁定三条不变式:末条 catch-all、去重、稳定排序。
|
||||||
|
func BuildIngress(hostnames []string, service string) []IngressRule {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
cleaned := make([]string, 0, len(hostnames))
|
||||||
|
for _, host := range hostnames {
|
||||||
|
host = strings.ToLower(strings.TrimSpace(host))
|
||||||
|
if host == "" || seen[host] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[host] = true
|
||||||
|
cleaned = append(cleaned, host)
|
||||||
|
}
|
||||||
|
sort.Strings(cleaned)
|
||||||
|
out := make([]IngressRule, 0, len(cleaned)+1)
|
||||||
|
for _, host := range cleaned {
|
||||||
|
out = append(out, IngressRule{Hostname: host, Service: service})
|
||||||
|
}
|
||||||
|
return append(out, IngressRule{Service: "http_status:404"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildIngressForRules 从反向代理规则派生 ingress,每条域名使用自己的回源地址。
|
||||||
|
func BuildIngressForRules(rules []proxy.Rule) []IngressRule {
|
||||||
|
serviceByHost := map[string]string{}
|
||||||
|
for _, rule := range rules {
|
||||||
|
if !rule.CloudflareEnabled() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
service := strings.TrimSpace(rule.Upstream)
|
||||||
|
if service == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, host := range rule.Hostnames() {
|
||||||
|
serviceByHost[host] = service
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hosts := make([]string, 0, len(serviceByHost))
|
||||||
|
for host := range serviceByHost {
|
||||||
|
hosts = append(hosts, host)
|
||||||
|
}
|
||||||
|
sort.Strings(hosts)
|
||||||
|
out := make([]IngressRule, 0, len(hosts)+1)
|
||||||
|
for _, host := range hosts {
|
||||||
|
out = append(out, IngressRule{Hostname: host, Service: serviceByHost[host]})
|
||||||
|
}
|
||||||
|
return append(out, IngressRule{Service: "http_status:404"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeService 生成回源 service;localhost 归一化为 127.0.0.1。
|
||||||
|
func NormalizeService(protocol, host string, port int) string {
|
||||||
|
host = strings.TrimSpace(host)
|
||||||
|
if host == "localhost" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s:%d", protocol, host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskSecret 只保留首尾少量字符,用于 API 回显与日志。
|
||||||
|
func MaskSecret(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(value) <= 8 {
|
||||||
|
return "********"
|
||||||
|
}
|
||||||
|
return value[:4] + "********" + value[len(value)-4:]
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDecodeToken(t *testing.T) {
|
||||||
|
payload := `{"a":"account-1","t":"tunnel-2","s":"secret-3"}`
|
||||||
|
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
|
||||||
|
account, tunnel, secret, err := DecodeToken(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if account != "account-1" || tunnel != "tunnel-2" || secret != "secret-3" {
|
||||||
|
t.Fatalf("解码结果不符:%s %s %s", account, tunnel, secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeTokenAlternativeFields(t *testing.T) {
|
||||||
|
payload := `{"AccountTag":"a","TunnelID":"t","TunnelSecret":"s"}`
|
||||||
|
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
|
||||||
|
if _, _, _, err := DecodeToken(encoded); err != nil {
|
||||||
|
t.Fatalf("兼容字段名解析失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeTokenRejectsInvalid(t *testing.T) {
|
||||||
|
if _, _, _, err := DecodeToken("not-base64"); err == nil {
|
||||||
|
t.Fatal("非法 token 应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeTokenURLSafe(t *testing.T) {
|
||||||
|
payload := `{"a":"account-1","t":"tunnel-2","s":"secret-3"}`
|
||||||
|
encoded := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
||||||
|
if _, _, _, err := DecodeToken(encoded); err != nil {
|
||||||
|
t.Fatalf("URL-safe token 应可解码: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeBase64Flexible(t *testing.T) {
|
||||||
|
raw := []byte{0xfb, 0xef, 0xbe, 0xad}
|
||||||
|
for _, encoded := range []string{
|
||||||
|
base64.RawURLEncoding.EncodeToString(raw),
|
||||||
|
base64.URLEncoding.EncodeToString(raw),
|
||||||
|
base64.RawStdEncoding.EncodeToString(raw),
|
||||||
|
} {
|
||||||
|
got, err := decodeBase64(encoded)
|
||||||
|
if err != nil || !bytes.Equal(got, raw) {
|
||||||
|
t.Fatalf("编码 %q 解码失败:%v", encoded, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildIngressInvariants(t *testing.T) {
|
||||||
|
got := BuildIngress([]string{"B.example.com", "a.example.com", "a.example.com", " "}, "http://127.0.0.1:80")
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("应生成 2 条域名 + 1 条 catch-all:%#v", got)
|
||||||
|
}
|
||||||
|
if got[0].Hostname != "a.example.com" || got[1].Hostname != "b.example.com" {
|
||||||
|
t.Fatalf("域名应去重并稳定排序:%#v", got)
|
||||||
|
}
|
||||||
|
if got[2].Hostname != "" || got[2].Service != "http_status:404" {
|
||||||
|
t.Fatalf("末条必须是 catch-all:%#v", got[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildIngressForRulesUsesRuleUpstream(t *testing.T) {
|
||||||
|
rules := []proxy.Rule{
|
||||||
|
{
|
||||||
|
Enabled: true,
|
||||||
|
Upstream: "http://192.168.1.10:8080",
|
||||||
|
Hosts: []proxy.Host{{Hostname: "b.example.com"}},
|
||||||
|
Exits: []string{proxy.ExitCloudflare},
|
||||||
|
CFTunnelID: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Enabled: true,
|
||||||
|
Upstream: "http://192.168.1.11:9090",
|
||||||
|
Hosts: []proxy.Host{{Hostname: "a.example.com"}},
|
||||||
|
Exits: []string{proxy.ExitLocal, proxy.ExitCloudflare},
|
||||||
|
CFTunnelID: 9,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := BuildIngressForRules(rules)
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("应生成 2 条域名 + catch-all:%#v", got)
|
||||||
|
}
|
||||||
|
if got[0].Hostname != "a.example.com" || got[0].Service != "http://192.168.1.11:9090" {
|
||||||
|
t.Fatalf("第一条 ingress 不符:%#v", got[0])
|
||||||
|
}
|
||||||
|
if got[1].Hostname != "b.example.com" || got[1].Service != "http://192.168.1.10:8080" {
|
||||||
|
t.Fatalf("第二条 ingress 不符:%#v", got[1])
|
||||||
|
}
|
||||||
|
if got[2].Hostname != "" || got[2].Service != "http_status:404" {
|
||||||
|
t.Fatalf("末条必须是 catch-all:%#v", got[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildIngressForRulesSkipsDisabledAndManualRules(t *testing.T) {
|
||||||
|
rules := []proxy.Rule{
|
||||||
|
{Enabled: false, Upstream: "http://127.0.0.1:8080", Hosts: []proxy.Host{{Hostname: "off.example.com"}}, Exits: []string{proxy.ExitCloudflare}, CFTunnelID: 1},
|
||||||
|
{Enabled: true, Upstream: "http://127.0.0.1:8081", Hosts: []proxy.Host{{Hostname: "local.example.com"}}, Exits: []string{proxy.ExitLocal}},
|
||||||
|
}
|
||||||
|
got := BuildIngressForRules(rules)
|
||||||
|
if len(got) != 1 || got[0].Service != "http_status:404" {
|
||||||
|
t.Fatalf("停用规则和仅本机规则不应进入 Cloudflare ingress:%#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildConfig(t *testing.T) {
|
||||||
|
config := BuildConfig("tunnel-1", "/data/creds.json", DefaultNetworkSettings(), BuildIngress([]string{"a.example.com"}, "http://127.0.0.1:80"))
|
||||||
|
for _, want := range []string{
|
||||||
|
"tunnel: tunnel-1",
|
||||||
|
"credentials-file: /data/creds.json",
|
||||||
|
"protocol: http2",
|
||||||
|
`edge-ip-version: "4"`,
|
||||||
|
"ha-connections: 2",
|
||||||
|
"- hostname: a.example.com",
|
||||||
|
"service: http://127.0.0.1:80",
|
||||||
|
"- service: http_status:404",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(config, want) {
|
||||||
|
t.Fatalf("config.yml 缺少 %q:\n%s", want, config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeService(t *testing.T) {
|
||||||
|
if got := NormalizeService("http", "localhost", 80); got != "http://127.0.0.1:80" {
|
||||||
|
t.Fatalf("NormalizeService() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
HealthUp = "up"
|
||||||
|
HealthDown = "down"
|
||||||
|
HealthUnknown = "unknown"
|
||||||
|
|
||||||
|
HealthRetention = 30 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// HealthStatus 一条域名在当前巡检轮次中的健康快照。
|
||||||
|
type HealthStatus struct {
|
||||||
|
TunnelID int64
|
||||||
|
Name string
|
||||||
|
Domain string
|
||||||
|
State string
|
||||||
|
Reason string
|
||||||
|
Detail string
|
||||||
|
Known bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthSample 交给落库层的状态样本。
|
||||||
|
type HealthSample struct {
|
||||||
|
TunnelID int64
|
||||||
|
Name string
|
||||||
|
Domain string
|
||||||
|
State string
|
||||||
|
Reason string
|
||||||
|
Detail string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthEvent 一条状态变更记录。
|
||||||
|
type HealthEvent struct {
|
||||||
|
TunnelID int64 `json:"tunnel_id"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
At string `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthIncident 一段 Cloudflare 隧道不可用区间。
|
||||||
|
type HealthIncident struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UptimeStat 一条 Cloudflare 域名的可用率与不可用区间。
|
||||||
|
type UptimeStat struct {
|
||||||
|
TunnelID int64 `json:"tunnel_id"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Uptime float64 `json:"uptime_pct"`
|
||||||
|
DownMinutes int `json:"down_minutes"`
|
||||||
|
UnknownMinutes int `json:"unknown_minutes"`
|
||||||
|
CoveredMinutes int `json:"covered_minutes"`
|
||||||
|
Incidents []HealthIncident `json:"incidents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TunnelSummary 仪表盘使用的 Cloudflare 隧道概览。
|
||||||
|
type TunnelSummary struct {
|
||||||
|
Count int
|
||||||
|
Online int
|
||||||
|
Status string
|
||||||
|
Message string
|
||||||
|
LastError string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthStatuses 返回所有 Cloudflare 隧道域名的当前健康状态。
|
||||||
|
func (s *Service) HealthStatuses(ctx context.Context) ([]HealthStatus, error) {
|
||||||
|
settings, err := s.store.GetSettingsRaw(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tunnels, err := s.store.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []HealthStatus{}
|
||||||
|
for i := range tunnels {
|
||||||
|
tunnel := tunnels[i]
|
||||||
|
s.attachConfig(&tunnel)
|
||||||
|
status := s.manager.Status(tunnel.ID)
|
||||||
|
for _, domain := range tunnel.Hostnames {
|
||||||
|
out = append(out, healthStatus(settings.Enabled, tunnel, status, domain))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func healthStatus(globalEnabled bool, tunnel Tunnel, status RuntimeStatus, domain string) HealthStatus {
|
||||||
|
item := HealthStatus{TunnelID: tunnel.ID, Name: tunnel.Name, Domain: domain, State: HealthUnknown, Known: false}
|
||||||
|
if !globalEnabled {
|
||||||
|
item.Reason = "disabled"
|
||||||
|
item.Detail = "Cloudflare 隧道全局托管已停用"
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
if status.Running {
|
||||||
|
item.Known = true
|
||||||
|
if status.Status == "connected" {
|
||||||
|
item.State = HealthUp
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
item.State = HealthDown
|
||||||
|
item.Reason = "connection"
|
||||||
|
item.Detail = strings.TrimSpace(status.LastError)
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
if tunnel.AutoStart {
|
||||||
|
item.Known = true
|
||||||
|
item.State = HealthDown
|
||||||
|
item.Reason = "process"
|
||||||
|
item.Detail = strings.TrimSpace(status.LastError)
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
item.Reason = "stopped"
|
||||||
|
item.Detail = "隧道未运行,且未启用自动启动"
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordHealth 只写状态变化,避免每轮巡检都插入重复记录。
|
||||||
|
func (s *Service) RecordHealth(ctx context.Context, samples []HealthSample) error {
|
||||||
|
if len(samples) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
current, err := s.latestHealthStates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
for _, sample := range samples {
|
||||||
|
domain := strings.ToLower(strings.TrimSpace(sample.Domain))
|
||||||
|
if sample.TunnelID <= 0 || domain == "" || sample.State == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := healthKey(sample.TunnelID, domain)
|
||||||
|
if state, ok := current[key]; ok && state == sample.State {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := s.store.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO cf_health_events(tunnel_id, domain, state, reason, detail, at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`, sample.TunnelID, domain, sample.State, sample.Reason, sample.Detail, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) latestHealthStates(ctx context.Context) (map[string]string, error) {
|
||||||
|
rows, err := s.store.db.QueryContext(ctx, `
|
||||||
|
SELECT e.tunnel_id, e.domain, e.state
|
||||||
|
FROM cf_health_events e
|
||||||
|
JOIN (
|
||||||
|
SELECT tunnel_id, domain, MAX(id) AS id
|
||||||
|
FROM cf_health_events
|
||||||
|
GROUP BY tunnel_id, domain
|
||||||
|
) m ON m.id = e.id
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
states := map[string]string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var tunnelID int64
|
||||||
|
var domain, state string
|
||||||
|
if err := rows.Scan(&tunnelID, &domain, &state); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
states[healthKey(tunnelID, domain)] = state
|
||||||
|
}
|
||||||
|
return states, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthUptime 返回窗口内每条 Cloudflare 域名的可用率。
|
||||||
|
func (s *Service) HealthUptime(ctx context.Context, window time.Duration) ([]UptimeStat, error) {
|
||||||
|
if window <= 0 {
|
||||||
|
window = 24 * time.Hour
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
since := now.Add(-window)
|
||||||
|
events, err := s.healthEventsSince(ctx, since)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ComputeHealthUptime(events, since, now), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) healthEventsSince(ctx context.Context, since time.Time) ([]HealthEvent, error) {
|
||||||
|
stamp := since.UTC().Format(time.RFC3339)
|
||||||
|
rows, err := s.store.db.QueryContext(ctx, `
|
||||||
|
SELECT tunnel_id, domain, state, COALESCE(reason, ''), COALESCE(detail, ''), at
|
||||||
|
FROM cf_health_events WHERE at >= ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT e.tunnel_id, e.domain, e.state, COALESCE(e.reason, ''), COALESCE(e.detail, ''), e.at
|
||||||
|
FROM cf_health_events e
|
||||||
|
JOIN (
|
||||||
|
SELECT tunnel_id, domain, MAX(id) AS id
|
||||||
|
FROM cf_health_events WHERE at < ?
|
||||||
|
GROUP BY tunnel_id, domain
|
||||||
|
) m ON m.id = e.id
|
||||||
|
`, stamp, stamp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
events := []HealthEvent{}
|
||||||
|
for rows.Next() {
|
||||||
|
var event HealthEvent
|
||||||
|
if err := rows.Scan(&event.TunnelID, &event.Domain, &event.State, &event.Reason, &event.Detail, &event.At); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
events = append(events, event)
|
||||||
|
}
|
||||||
|
return events, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PruneHealth 清理超过保留期的状态变更。
|
||||||
|
func (s *Service) PruneHealth(ctx context.Context) (int64, error) {
|
||||||
|
res, err := s.store.db.ExecContext(ctx, `DELETE FROM cf_health_events WHERE at < ?`,
|
||||||
|
time.Now().UTC().Add(-HealthRetention).Format(time.RFC3339))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary 返回隧道数量、在线数与最近错误。
|
||||||
|
func (s *Service) Summary(ctx context.Context) TunnelSummary {
|
||||||
|
tunnels, err := s.store.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return TunnelSummary{Status: "error", Message: err.Error()}
|
||||||
|
}
|
||||||
|
summary := TunnelSummary{Count: len(tunnels), Status: "ok"}
|
||||||
|
if len(tunnels) == 0 {
|
||||||
|
summary.Status = "none"
|
||||||
|
summary.Message = "未创建隧道"
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
for _, tunnel := range tunnels {
|
||||||
|
status := s.manager.Status(tunnel.ID)
|
||||||
|
if status.Running && status.Status == "connected" {
|
||||||
|
summary.Online++
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(tunnel.LastError) != "" {
|
||||||
|
summary.LastError = tunnel.LastError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if summary.Online == 0 {
|
||||||
|
summary.Status = "error"
|
||||||
|
summary.Message = "没有在线隧道"
|
||||||
|
} else if summary.Online < summary.Count {
|
||||||
|
summary.Status = "warning"
|
||||||
|
summary.Message = fmt.Sprintf("%d/%d 条在线", summary.Online, summary.Count)
|
||||||
|
} else {
|
||||||
|
summary.Message = fmt.Sprintf("%d 条在线", summary.Online)
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func healthKey(tunnelID int64, domain string) string {
|
||||||
|
return fmt.Sprintf("%d|%s", tunnelID, strings.ToLower(strings.TrimSpace(domain)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeHealthUptime 与 FRP 侧同样的口径:unknown 不计入可用率分母。
|
||||||
|
type healthPoint struct {
|
||||||
|
at time.Time
|
||||||
|
tunnelID int64
|
||||||
|
domain string
|
||||||
|
state string
|
||||||
|
reason, detail string
|
||||||
|
}
|
||||||
|
|
||||||
|
func ComputeHealthUptime(events []HealthEvent, from, to time.Time) []UptimeStat {
|
||||||
|
byRule := map[string][]healthPoint{}
|
||||||
|
order := make([]string, 0, len(events))
|
||||||
|
for _, event := range events {
|
||||||
|
at, err := time.Parse(time.RFC3339, event.At)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := healthKey(event.TunnelID, event.Domain)
|
||||||
|
if _, ok := byRule[key]; !ok {
|
||||||
|
order = append(order, key)
|
||||||
|
}
|
||||||
|
byRule[key] = append(byRule[key], healthPoint{
|
||||||
|
at: at, tunnelID: event.TunnelID, domain: event.Domain,
|
||||||
|
state: event.State, reason: event.Reason, detail: event.Detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Strings(order)
|
||||||
|
stats := make([]UptimeStat, 0, len(order))
|
||||||
|
for _, key := range order {
|
||||||
|
points := byRule[key]
|
||||||
|
sort.Slice(points, func(i, j int) bool { return points[i].at.Before(points[j].at) })
|
||||||
|
stat := computeHealthPoints(points, from, to)
|
||||||
|
if stat.Domain != "" {
|
||||||
|
stats = append(stats, stat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeHealthPoints(points []healthPoint, from, to time.Time) UptimeStat {
|
||||||
|
start := from
|
||||||
|
index := 0
|
||||||
|
state, reason, detail := "", "", ""
|
||||||
|
for index < len(points) && !points[index].at.After(from) {
|
||||||
|
state, reason, detail = points[index].state, points[index].reason, points[index].detail
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
if state == "" {
|
||||||
|
if index >= len(points) {
|
||||||
|
return UptimeStat{}
|
||||||
|
}
|
||||||
|
start = points[index].at
|
||||||
|
state, reason, detail = points[index].state, points[index].reason, points[index].detail
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
stat := UptimeStat{TunnelID: points[0].tunnelID, Domain: points[0].domain, State: state, Incidents: []HealthIncident{}}
|
||||||
|
upMinutes := 0
|
||||||
|
for {
|
||||||
|
end := to
|
||||||
|
ongoing := true
|
||||||
|
if index < len(points) {
|
||||||
|
end = points[index].at
|
||||||
|
ongoing = false
|
||||||
|
}
|
||||||
|
minutes := int(end.Sub(start).Minutes())
|
||||||
|
if minutes < 0 {
|
||||||
|
minutes = 0
|
||||||
|
}
|
||||||
|
switch state {
|
||||||
|
case HealthUp:
|
||||||
|
upMinutes += minutes
|
||||||
|
case HealthDown:
|
||||||
|
stat.DownMinutes += minutes
|
||||||
|
incident := HealthIncident{From: start.UTC().Format(time.RFC3339), Reason: reason, Detail: detail, Minutes: minutes}
|
||||||
|
if !ongoing {
|
||||||
|
incident.To = end.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
stat.Incidents = append(stat.Incidents, incident)
|
||||||
|
default:
|
||||||
|
stat.UnknownMinutes += minutes
|
||||||
|
}
|
||||||
|
stat.CoveredMinutes += minutes
|
||||||
|
stat.State = state
|
||||||
|
if index >= len(points) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
start = points[index].at
|
||||||
|
state, reason, detail = points[index].state, points[index].reason, points[index].detail
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
observed := upMinutes + stat.DownMinutes
|
||||||
|
if observed > 0 {
|
||||||
|
stat.Uptime = float64(upMinutes) / float64(observed) * 100
|
||||||
|
} else {
|
||||||
|
stat.Uptime = 100
|
||||||
|
if stat.UnknownMinutes > 0 {
|
||||||
|
stat.Uptime = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stat
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/config"
|
||||||
|
"github.com/fonu/fonu/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComputeHealthUptimeExcludesUnknown(t *testing.T) {
|
||||||
|
from := time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC)
|
||||||
|
to := from.Add(2 * time.Hour)
|
||||||
|
events := []HealthEvent{
|
||||||
|
{TunnelID: 1, Domain: "a.example.com", State: HealthUnknown, At: from.Format(time.RFC3339)},
|
||||||
|
{TunnelID: 1, Domain: "a.example.com", State: HealthUp, At: from.Add(30 * time.Minute).Format(time.RFC3339)},
|
||||||
|
{TunnelID: 1, Domain: "a.example.com", State: HealthDown, At: from.Add(90 * time.Minute).Format(time.RFC3339)},
|
||||||
|
}
|
||||||
|
stats := ComputeHealthUptime(events, from, to)
|
||||||
|
if len(stats) != 1 {
|
||||||
|
t.Fatalf("应返回一条统计:%#v", stats)
|
||||||
|
}
|
||||||
|
if got := stats[0].Uptime; got < 66.6 || got > 66.7 || stats[0].UnknownMinutes != 30 || stats[0].DownMinutes != 30 {
|
||||||
|
t.Fatalf("可用率计算不符:%#v", stats[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordHealthSkipsUnchangedState(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
conn, err := db.Open(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc := NewService(config.Config{DataDir: dir}, NewStore(conn), nil, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
||||||
|
sample := []HealthSample{{TunnelID: 1, Domain: "a.example.com", State: HealthUp}}
|
||||||
|
if err := svc.RecordHealth(context.Background(), sample); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.RecordHealth(context.Background(), sample); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := conn.QueryRow(`SELECT COUNT(*) FROM cf_health_events`).Scan(&count); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("同状态不应重复写入,实际 %d 条", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import "syscall"
|
||||||
|
|
||||||
|
func hideWindowAttr() *syscall.SysProcAttr {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import "syscall"
|
||||||
|
|
||||||
|
func hideWindowAttr() *syscall.SysProcAttr {
|
||||||
|
return &syscall.SysProcAttr{HideWindow: true}
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var metricsPortPattern = regexp.MustCompile(`(?i)starting metrics server on 127\.0\.0\.1:(\d+)/metrics`)
|
||||||
|
|
||||||
|
type processState struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
status string
|
||||||
|
lastError string
|
||||||
|
metricsPort int
|
||||||
|
metrics RuntimeStatus
|
||||||
|
logMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager 托管 cloudflared 子进程、日志与 metrics。
|
||||||
|
type Manager struct {
|
||||||
|
cfg config.Config
|
||||||
|
logger *slog.Logger
|
||||||
|
mu sync.Mutex
|
||||||
|
processes map[int64]*processState
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(cfg config.Config, logger *slog.Logger) *Manager {
|
||||||
|
return &Manager{cfg: cfg, logger: logger, processes: map[int64]*processState{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRunArgs(tunnel Tunnel, configPath, token string) []string {
|
||||||
|
args := []string{"tunnel"}
|
||||||
|
if tunnel.Mode == ModeTokenRemote {
|
||||||
|
args = append(args, networkArgs(tunnel.Network)...)
|
||||||
|
return append(args, "run", "--token", token)
|
||||||
|
}
|
||||||
|
args = append(args, "--config", configPath)
|
||||||
|
args = append(args, networkArgs(tunnel.Network)...)
|
||||||
|
return append(args, "run")
|
||||||
|
}
|
||||||
|
|
||||||
|
func networkArgs(network NetworkSettings) []string {
|
||||||
|
args := []string{}
|
||||||
|
if network.HAConnections > 0 {
|
||||||
|
args = append(args, "--ha-connections", strconv.Itoa(network.HAConnections))
|
||||||
|
}
|
||||||
|
if pool := strings.TrimSpace(network.OriginCAPool); pool != "" {
|
||||||
|
args = append(args, "--origin-ca-pool", pool)
|
||||||
|
}
|
||||||
|
if network.NoTLSVerify {
|
||||||
|
args = append(args, "--no-tls-verify")
|
||||||
|
}
|
||||||
|
if header := strings.TrimSpace(network.HTTPHostHeader); header != "" {
|
||||||
|
args = append(args, "--http-host-header", header)
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildEnv(network NetworkSettings) []string {
|
||||||
|
env := os.Environ()
|
||||||
|
if protocol := strings.TrimSpace(network.TransportProtocol); protocol != "" {
|
||||||
|
env = append(env, "TUNNEL_TRANSPORT_PROTOCOL="+protocol)
|
||||||
|
}
|
||||||
|
if edge := strings.TrimSpace(network.EdgeIPVersion); edge != "" {
|
||||||
|
env = append(env, "TUNNEL_EDGE_IP_VERSION="+edge)
|
||||||
|
}
|
||||||
|
if network.ProxyMode == "disabled" {
|
||||||
|
env = removeEnv(env, []string{"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"})
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeEnv(env []string, names []string) []string {
|
||||||
|
out := make([]string, 0, len(env))
|
||||||
|
for _, item := range env {
|
||||||
|
remove := false
|
||||||
|
for _, name := range names {
|
||||||
|
if strings.HasPrefix(item, name+"=") {
|
||||||
|
remove = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !remove {
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start 启动一条隧道的 cloudflared 子进程。
|
||||||
|
func (m *Manager) Start(tunnel Tunnel, binaryPath, configPath, token string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
if _, exists := m.processes[tunnel.ID]; exists {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return fmt.Errorf("隧道已在运行中")
|
||||||
|
}
|
||||||
|
state := &processState{status: "starting"}
|
||||||
|
m.processes[tunnel.ID] = state
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
logFile, err := os.OpenFile(tunnel.LogPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o640)
|
||||||
|
if err != nil {
|
||||||
|
m.remove(tunnel.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cmd := exec.Command(binaryPath, buildRunArgs(tunnel, configPath, token)...)
|
||||||
|
cmd.Env = buildEnv(tunnel.Network)
|
||||||
|
cmd.SysProcAttr = hideWindowAttr()
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
_ = logFile.Close()
|
||||||
|
m.remove(tunnel.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
_ = logFile.Close()
|
||||||
|
m.remove(tunnel.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
_ = logFile.Close()
|
||||||
|
m.remove(tunnel.ID)
|
||||||
|
return fmt.Errorf("启动 cloudflared 失败: %w", err)
|
||||||
|
}
|
||||||
|
state.cmd = cmd
|
||||||
|
go m.pipeLogs(tunnel.ID, stdout, logFile)
|
||||||
|
go m.pipeLogs(tunnel.ID, stderr, logFile)
|
||||||
|
go m.wait(tunnel.ID, cmd, logFile)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) pipeLogs(id int64, reader io.Reader, logFile *os.File) {
|
||||||
|
scanner := bufio.NewScanner(reader)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
m.writeLog(id, logFile, line)
|
||||||
|
m.detectMetrics(id, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) writeLog(id int64, logFile *os.File, line string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
state := m.processes[id]
|
||||||
|
m.mu.Unlock()
|
||||||
|
if state == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.logMu.Lock()
|
||||||
|
defer state.logMu.Unlock()
|
||||||
|
_, _ = fmt.Fprintf(logFile, "%s %s\n", time.Now().Format(time.RFC3339), line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) detectMetrics(id int64, line string) {
|
||||||
|
match := metricsPortPattern.FindStringSubmatch(line)
|
||||||
|
if len(match) != 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
port, err := strconv.Atoi(match[1])
|
||||||
|
if err != nil || port <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
state := m.processes[id]
|
||||||
|
if state == nil || state.metricsPort == port {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.metricsPort = port
|
||||||
|
m.mu.Unlock()
|
||||||
|
go m.scrapeMetrics(id, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) scrapeMetrics(id int64, port int) {
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
metricsURL := fmt.Sprintf("http://127.0.0.1:%d/metrics", port)
|
||||||
|
readyURL := fmt.Sprintf("http://127.0.0.1:%d/ready", port)
|
||||||
|
lastIn, lastOut := 0.0, 0.0
|
||||||
|
for {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
if !m.running(id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text, err := fetchText(client, metricsURL)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
snapshot := parseMetrics(text)
|
||||||
|
status := RuntimeStatus{
|
||||||
|
Running: true, Status: "connecting", MetricsPort: port,
|
||||||
|
Connections: snapshot.connections, LatencyMS: snapshot.latencyMS,
|
||||||
|
Transport: snapshot.transport,
|
||||||
|
}
|
||||||
|
if snapshot.hasQUIC {
|
||||||
|
status.BandwidthIn = math.Max(0, snapshot.bytesIn-lastIn) / 2
|
||||||
|
status.BandwidthOut = math.Max(0, snapshot.bytesOut-lastOut) / 2
|
||||||
|
lastIn, lastOut = snapshot.bytesIn, snapshot.bytesOut
|
||||||
|
} else {
|
||||||
|
status.BandwidthNotice = "HTTP/2 官方 metrics 不含字节指标"
|
||||||
|
}
|
||||||
|
if snapshot.connections > 0 {
|
||||||
|
status.Status = "connected"
|
||||||
|
} else if isReady(client, readyURL) {
|
||||||
|
status.Status = "reconnecting"
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
if state := m.processes[id]; state != nil {
|
||||||
|
state.metrics = status
|
||||||
|
state.status = status.Status
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) running(id int64) bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.processes[id] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) wait(id int64, cmd *exec.Cmd, logFile *os.File) {
|
||||||
|
err := cmd.Wait()
|
||||||
|
_ = logFile.Close()
|
||||||
|
m.mu.Lock()
|
||||||
|
state := m.processes[id]
|
||||||
|
if state != nil {
|
||||||
|
if err != nil {
|
||||||
|
state.status = "failed"
|
||||||
|
state.lastError = err.Error()
|
||||||
|
} else {
|
||||||
|
state.status = "stopped"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) remove(id int64) {
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.processes, id)
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop 停止隧道子进程。
|
||||||
|
func (m *Manager) Stop(id int64) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
state := m.processes[id]
|
||||||
|
delete(m.processes, id)
|
||||||
|
m.mu.Unlock()
|
||||||
|
if state == nil || state.cmd == nil || state.cmd.Process == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_ = state.cmd.Process.Kill()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status 返回运行状态与最近一次 metrics 快照。
|
||||||
|
func (m *Manager) Status(id int64) RuntimeStatus {
|
||||||
|
m.mu.Lock()
|
||||||
|
state := m.processes[id]
|
||||||
|
m.mu.Unlock()
|
||||||
|
if state == nil {
|
||||||
|
return RuntimeStatus{Status: "stopped"}
|
||||||
|
}
|
||||||
|
out := state.metrics
|
||||||
|
out.Status = state.status
|
||||||
|
out.LastError = state.lastError
|
||||||
|
out.MetricsPort = state.metricsPort
|
||||||
|
out.Running = state.status != "stopped" && state.status != "failed"
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logs 读取日志文件末尾若干行。
|
||||||
|
func (m *Manager) Logs(logPath string, lines int) (string, error) {
|
||||||
|
if lines <= 0 {
|
||||||
|
lines = 200
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(logPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
all := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||||
|
if len(all) > lines {
|
||||||
|
all = all[len(all)-lines:]
|
||||||
|
}
|
||||||
|
return strings.Join(all, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type metricsSnapshot struct {
|
||||||
|
connections int
|
||||||
|
latencyMS float64
|
||||||
|
bytesIn float64
|
||||||
|
bytesOut float64
|
||||||
|
transport string
|
||||||
|
hasQUIC bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMetrics(text string) metricsSnapshot {
|
||||||
|
snapshot := metricsSnapshot{transport: "http2"}
|
||||||
|
var rttSum, rttCount float64
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "cloudflared_tunnel_ha_connections"):
|
||||||
|
snapshot.connections = int(parseMetricValue(line))
|
||||||
|
case strings.HasPrefix(line, "quic_client_receive_bytes"):
|
||||||
|
snapshot.bytesIn += parseMetricValue(line)
|
||||||
|
snapshot.hasQUIC = true
|
||||||
|
case strings.HasPrefix(line, "quic_client_sent_bytes"):
|
||||||
|
snapshot.bytesOut += parseMetricValue(line)
|
||||||
|
snapshot.hasQUIC = true
|
||||||
|
case strings.HasPrefix(line, "quic_client_smoothed_rtt"):
|
||||||
|
rttSum += parseMetricValue(line)
|
||||||
|
rttCount++
|
||||||
|
snapshot.hasQUIC = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if snapshot.hasQUIC {
|
||||||
|
snapshot.transport = "quic"
|
||||||
|
if rttCount > 0 {
|
||||||
|
snapshot.latencyMS = rttSum / rttCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMetricValue(line string) float64 {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
value, _ := strconv.ParseFloat(fields[len(fields)-1], 64)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchText(client *http.Client, url string) (string, error) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isReady(client *http.Client, url string) bool {
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
return resp.StatusCode == http.StatusOK
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildRunArgs(t *testing.T) {
|
||||||
|
local := strings.Join(buildRunArgs(Tunnel{Mode: ModeTokenLocal, Network: DefaultNetworkSettings()}, "/data/config.yml", ""), " ")
|
||||||
|
for _, want := range []string{"tunnel", "--config /data/config.yml", "--ha-connections 2", "run"} {
|
||||||
|
if !strings.Contains(local, want) {
|
||||||
|
t.Fatalf("本地规则启动参数缺少 %q:%s", want, local)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remote := strings.Join(buildRunArgs(Tunnel{Mode: ModeTokenRemote, Network: DefaultNetworkSettings()}, "", "token-123"), " ")
|
||||||
|
if strings.Contains(remote, "--config") || !strings.Contains(remote, "run --token token-123") {
|
||||||
|
t.Fatalf("云端规则模式不应使用 --config:%s", remote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMetrics(t *testing.T) {
|
||||||
|
text := strings.Join([]string{
|
||||||
|
"cloudflared_tunnel_ha_connections 2",
|
||||||
|
"quic_client_receive_bytes 1000",
|
||||||
|
"quic_client_sent_bytes 500",
|
||||||
|
"quic_client_smoothed_rtt 0.02",
|
||||||
|
}, "\n")
|
||||||
|
snapshot := parseMetrics(text)
|
||||||
|
if snapshot.connections != 2 || !snapshot.hasQUIC || snapshot.transport != "quic" {
|
||||||
|
t.Fatalf("QUIC metrics 解析不符:%#v", snapshot)
|
||||||
|
}
|
||||||
|
if snapshot.latencyMS <= 0 {
|
||||||
|
t.Fatalf("RTT 解析失败:%#v", snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMetricsHTTP2(t *testing.T) {
|
||||||
|
snapshot := parseMetrics("cloudflared_tunnel_ha_connections 1")
|
||||||
|
if snapshot.hasQUIC || snapshot.transport != "http2" || snapshot.connections != 1 {
|
||||||
|
t.Fatalf("HTTP/2 metrics 解析不符:%#v", snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetricsPortPatternCaseInsensitive(t *testing.T) {
|
||||||
|
match := metricsPortPattern.FindStringSubmatch("2026-09-30T18:00:00Z INF Starting metrics server on 127.0.0.1:20241/metrics")
|
||||||
|
if len(match) != 2 || match[1] != "20241" {
|
||||||
|
t.Fatalf("应识别大写 Starting 的 metrics 端口:%#v", match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildEnvDisablesProxy(t *testing.T) {
|
||||||
|
t.Setenv("HTTP_PROXY", "http://127.0.0.1:8080")
|
||||||
|
env := buildEnv(NetworkSettings{ProxyMode: "disabled"})
|
||||||
|
for _, item := range env {
|
||||||
|
if strings.HasPrefix(item, "HTTP_PROXY=") {
|
||||||
|
t.Fatalf("禁用代理时不应保留 HTTP_PROXY:%v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Mode 是隧道的托管模式;不同模式决定启动参数与规则生效侧。
|
||||||
|
const (
|
||||||
|
ModeTokenLocal = "token-local" // token 解码成本地凭据,规则由 Fonu 生成
|
||||||
|
ModeTokenRemote = "token-remote" // 直接用 --token 启动,规则由 Cloudflare 面板管理
|
||||||
|
ModeAccountLocal = "account-local" // cert.pem + credentials,规则本地生成,可云端同步
|
||||||
|
)
|
||||||
|
|
||||||
|
// NetworkSettings 是 cloudflared 的网络调优参数。
|
||||||
|
type NetworkSettings struct {
|
||||||
|
TransportProtocol string `json:"transport_protocol"` // http2 / quic / auto
|
||||||
|
EdgeIPVersion string `json:"edge_ip_version"` // 4 / 6 / auto
|
||||||
|
HAConnections int `json:"ha_connections"`
|
||||||
|
ProxyMode string `json:"proxy_mode"` // system / disabled
|
||||||
|
OriginCAPool string `json:"origin_ca_pool"`
|
||||||
|
NoTLSVerify bool `json:"no_tls_verify"`
|
||||||
|
HTTPHostHeader string `json:"http_host_header"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultNetworkSettings 返回国内环境更稳的默认值。
|
||||||
|
func DefaultNetworkSettings() NetworkSettings {
|
||||||
|
return NetworkSettings{
|
||||||
|
TransportProtocol: "http2",
|
||||||
|
EdgeIPVersion: "4",
|
||||||
|
HAConnections: 2,
|
||||||
|
ProxyMode: "system",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngressRule 对应 config.yml 的 ingress 条目。
|
||||||
|
type IngressRule struct {
|
||||||
|
Hostname string `json:"hostname,omitempty" yaml:"hostname,omitempty"`
|
||||||
|
Path string `json:"path,omitempty" yaml:"path,omitempty"`
|
||||||
|
Service string `json:"service" yaml:"service"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tunnel 是一条 Cloudflare 隧道。
|
||||||
|
type Tunnel struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
TunnelID string `json:"tunnel_id"`
|
||||||
|
AccountTag string `json:"account_tag"`
|
||||||
|
CredentialsEnc string `json:"-"`
|
||||||
|
CertPEMEnc string `json:"-"`
|
||||||
|
ConfigPath string `json:"config_path"`
|
||||||
|
CredsPath string `json:"creds_path"`
|
||||||
|
LogPath string `json:"log_path"`
|
||||||
|
Network NetworkSettings `json:"network"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
MetricsPort int `json:"metrics_port"`
|
||||||
|
AutoStart bool `json:"auto_start"`
|
||||||
|
Managed bool `json:"managed"`
|
||||||
|
Hostnames []string `json:"hostnames"`
|
||||||
|
OriginService string `json:"origin_service"`
|
||||||
|
DNSWarning string `json:"dns_warning,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInput 是创建 / 更新隧道时的入参。
|
||||||
|
type CreateInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Hostnames []string `json:"hostnames"`
|
||||||
|
OriginService string `json:"origin_service"`
|
||||||
|
Network NetworkSettings `json:"network"`
|
||||||
|
AutoStart bool `json:"auto_start"`
|
||||||
|
Managed bool `json:"managed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppSettings 是模块级配置;默认 token 只回显掩码,不返回明文。
|
||||||
|
type AppSettings struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
DefaultTokenConfigured bool `json:"default_token_configured"`
|
||||||
|
DefaultTokenMasked string `json:"default_token_masked"`
|
||||||
|
APITokenConfigured bool `json:"api_token_configured"`
|
||||||
|
APITokenMasked string `json:"api_token_masked"`
|
||||||
|
Mirror string `json:"mirror"`
|
||||||
|
Network NetworkSettings `json:"network"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppSettingsInput 是保存应用配置的入参;Token 留空表示保留原值。
|
||||||
|
type AppSettingsInput struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
APIToken string `json:"api_token"`
|
||||||
|
Mirror string `json:"mirror"`
|
||||||
|
Network NetworkSettings `json:"network"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeStatus 是隧道运行状态与指标快照。
|
||||||
|
type RuntimeStatus struct {
|
||||||
|
Running bool `json:"running"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
MetricsPort int `json:"metrics_port"`
|
||||||
|
Connections int `json:"connections"`
|
||||||
|
LatencyMS float64 `json:"latency_ms"`
|
||||||
|
BandwidthIn float64 `json:"bandwidth_in"`
|
||||||
|
BandwidthOut float64 `json:"bandwidth_out"`
|
||||||
|
Transport string `json:"transport"`
|
||||||
|
BandwidthNotice string `json:"bandwidth_notice,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DriftResult 是本地规则派生 ingress 与云端配置的差异。
|
||||||
|
type DriftResult struct {
|
||||||
|
TunnelID int64 `json:"tunnel_id"`
|
||||||
|
Managed bool `json:"managed"`
|
||||||
|
HasDrift bool `json:"has_drift"`
|
||||||
|
Items []string `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiagnosticCheck 一项诊断结果。
|
||||||
|
type DiagnosticCheck struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiagnosticsResult 是 Cloudflare 隧道的一键诊断结果。
|
||||||
|
type DiagnosticsResult struct {
|
||||||
|
TunnelID int64 `json:"tunnel_id"`
|
||||||
|
GeneratedAt string `json:"generated_at"`
|
||||||
|
Checks []DiagnosticCheck `json:"checks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreflightCheck 是接入向导的一项预检结果。
|
||||||
|
type PreflightCheck struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Action string `json:"action,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreflightResult 是 Cloudflare 接入前的整体检查结果。
|
||||||
|
type PreflightResult struct {
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
Checks []PreflightCheck `json:"checks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preflight 检查新增 Cloudflare 出口前最容易出错的配置。
|
||||||
|
func (s *Service) Preflight(ctx context.Context) PreflightResult {
|
||||||
|
binary := s.binaryCheck()
|
||||||
|
checks := []PreflightCheck{{Name: binary.Name, Status: binary.Status, Message: binary.Message}}
|
||||||
|
row, err := s.store.GetSettingsRaw(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "应用配置", Status: "error", Message: err.Error()})}
|
||||||
|
}
|
||||||
|
checks = append(checks, PreflightCheck{
|
||||||
|
Name: "Account ID", Status: statusBool(strings.TrimSpace(row.AccountID) != ""), Message: firstNonEmpty(row.AccountID, "未填写 Account ID"), Action: "settings",
|
||||||
|
})
|
||||||
|
checks = append(checks, PreflightCheck{
|
||||||
|
Name: "API Token", Status: statusBool(row.APITokenEnc != ""), Message: boolText(row.APITokenEnc != "", "已保存 API Token", "未保存 API Token"), Action: "settings",
|
||||||
|
})
|
||||||
|
if row.APITokenEnc == "" {
|
||||||
|
return PreflightResult{Ready: false, Checks: checks}
|
||||||
|
}
|
||||||
|
api, err := s.cloudflareAPI(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "Cloudflare API", Status: "error", Message: err.Error(), Action: "settings"})}
|
||||||
|
}
|
||||||
|
zones, err := api.ListZones(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "Zone 权限", Status: "error", Message: err.Error(), Action: "settings"})}
|
||||||
|
}
|
||||||
|
checks = append(checks, PreflightCheck{
|
||||||
|
Name: "Zone 权限", Status: statusBool(len(zones) > 0), Message: fmt.Sprintf("可访问 %d 个 Cloudflare Zone", len(zones)), Action: "settings",
|
||||||
|
})
|
||||||
|
return PreflightResult{Ready: !hasPreflightError(checks), Checks: checks}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusBool(ok bool) string {
|
||||||
|
if ok {
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolText(ok bool, yes, no string) string {
|
||||||
|
if ok {
|
||||||
|
return yes
|
||||||
|
}
|
||||||
|
return no
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(value, fallback string) string {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasPreflightError(checks []PreflightCheck) bool {
|
||||||
|
for _, check := range checks {
|
||||||
|
if check.Status == "error" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drift 比较本地规则派生 ingress 与 Cloudflare 云端配置。
|
||||||
|
func (s *Service) Drift(ctx context.Context, id int64) (DriftResult, error) {
|
||||||
|
tunnel, err := s.store.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return DriftResult{}, err
|
||||||
|
}
|
||||||
|
result := DriftResult{TunnelID: id, Managed: tunnel.Managed, Items: []string{}}
|
||||||
|
if !tunnel.Managed {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
rules, err := s.rules.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return DriftResult{}, err
|
||||||
|
}
|
||||||
|
expected := BuildIngressForRules(rulesForTunnel(rules, tunnel.ID))
|
||||||
|
api, err := s.cloudflareAPI(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return DriftResult{}, err
|
||||||
|
}
|
||||||
|
actual, err := api.GetIngress(ctx, tunnel.TunnelID)
|
||||||
|
if err != nil {
|
||||||
|
return DriftResult{}, err
|
||||||
|
}
|
||||||
|
result.Items = compareIngress(expected, actual)
|
||||||
|
result.HasDrift = len(result.Items) > 0
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushTunnel 以本地规则为准重新覆盖云端 ingress。
|
||||||
|
func (s *Service) PushTunnel(ctx context.Context, id int64) error {
|
||||||
|
tunnel, err := s.store.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !tunnel.Managed {
|
||||||
|
return fmt.Errorf("手工隧道不支持从规则覆盖,请先在编辑中开启自动托管")
|
||||||
|
}
|
||||||
|
return s.syncManagedTunnel(ctx, tunnel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareIngress(expected, actual []IngressRule) []string {
|
||||||
|
expectedMap := ingressMap(expected)
|
||||||
|
actualMap := ingressMap(actual)
|
||||||
|
items := []string{}
|
||||||
|
keys := unionKeys(expectedMap, actualMap)
|
||||||
|
for _, key := range keys {
|
||||||
|
want, wantOK := expectedMap[key]
|
||||||
|
got, gotOK := actualMap[key]
|
||||||
|
switch {
|
||||||
|
case wantOK && !gotOK:
|
||||||
|
items = append(items, fmt.Sprintf("%s 本地存在,云端缺失", key))
|
||||||
|
case !wantOK && gotOK:
|
||||||
|
items = append(items, fmt.Sprintf("%s 云端存在,本地已移除", key))
|
||||||
|
case want != got:
|
||||||
|
items = append(items, fmt.Sprintf("%s 回源不一致:本地 %s,云端 %s", key, want, got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func ingressMap(ingress []IngressRule) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, item := range ingress {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(item.Hostname))
|
||||||
|
if key == "" {
|
||||||
|
key = "(catch-all)"
|
||||||
|
}
|
||||||
|
out[key] = strings.TrimSpace(item.Service)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func unionKeys(a, b map[string]string) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for key := range a {
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
}
|
||||||
|
for key := range b {
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(seen))
|
||||||
|
for key := range seen {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnostics 返回一组面向用户的 Cloudflare 隧道检查项。
|
||||||
|
func (s *Service) Diagnostics(ctx context.Context, id int64) (DiagnosticsResult, error) {
|
||||||
|
tunnel, err := s.store.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return DiagnosticsResult{}, err
|
||||||
|
}
|
||||||
|
result := DiagnosticsResult{TunnelID: id, GeneratedAt: time.Now().UTC().Format(time.RFC3339), Checks: []DiagnosticCheck{}}
|
||||||
|
result.Checks = append(result.Checks, s.binaryCheck())
|
||||||
|
result.Checks = append(result.Checks, configCheck(tunnel.ConfigPath))
|
||||||
|
result.Checks = append(result.Checks, processCheck(s.manager.Status(id), tunnel))
|
||||||
|
api := s.apiDiagnostic(ctx, tunnel)
|
||||||
|
result.Checks = append(result.Checks, api)
|
||||||
|
result.Checks = append(result.Checks, s.dnsCheck(ctx, tunnel))
|
||||||
|
result.Checks = append(result.Checks, networkCheck("Cloudflare edge region1", "region1.v2.argotunnel.com:7844"))
|
||||||
|
result.Checks = append(result.Checks, networkCheck("Cloudflare edge region2", "region2.v2.argotunnel.com:7844"))
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) binaryCheck() DiagnosticCheck {
|
||||||
|
version := s.BinaryVersion()
|
||||||
|
if strings.TrimSpace(version) == "" {
|
||||||
|
return DiagnosticCheck{Name: "cloudflared 二进制", Status: "error", Message: "未安装或不可执行"}
|
||||||
|
}
|
||||||
|
return DiagnosticCheck{Name: "cloudflared 二进制", Status: "ok", Message: version}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configCheck(path string) DiagnosticCheck {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return DiagnosticCheck{Name: "本地配置", Status: "error", Message: "config.yml 不存在或不可读"}
|
||||||
|
}
|
||||||
|
return DiagnosticCheck{Name: "本地配置", Status: "ok", Message: "config.yml 可读"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processCheck(status RuntimeStatus, tunnel Tunnel) DiagnosticCheck {
|
||||||
|
if status.Running && status.Status == "connected" {
|
||||||
|
return DiagnosticCheck{Name: "隧道进程", Status: "ok", Message: fmt.Sprintf("已连接,HA 连接数 %d", status.Connections)}
|
||||||
|
}
|
||||||
|
if tunnel.AutoStart {
|
||||||
|
return DiagnosticCheck{Name: "隧道进程", Status: "error", Message: "未运行或未连接:" + status.LastError}
|
||||||
|
}
|
||||||
|
return DiagnosticCheck{Name: "隧道进程", Status: "warning", Message: "当前未运行,且未启用自动启动"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) apiDiagnostic(ctx context.Context, tunnel Tunnel) DiagnosticCheck {
|
||||||
|
api, err := s.cloudflareAPI(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: err.Error()}
|
||||||
|
}
|
||||||
|
if tunnel.TunnelID == "" {
|
||||||
|
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: "隧道 ID 为空"}
|
||||||
|
}
|
||||||
|
if _, err := api.GetIngress(ctx, tunnel.TunnelID); err != nil {
|
||||||
|
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: err.Error()}
|
||||||
|
}
|
||||||
|
return DiagnosticCheck{Name: "Cloudflare API", Status: "ok", Message: "API Token 与云端配置可访问"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) dnsCheck(ctx context.Context, tunnel Tunnel) DiagnosticCheck {
|
||||||
|
records, err := s.store.ListDNSRecords(ctx, tunnel.ID)
|
||||||
|
if err != nil {
|
||||||
|
return DiagnosticCheck{Name: "DNS 记录", Status: "error", Message: err.Error()}
|
||||||
|
}
|
||||||
|
if len(records) == 0 {
|
||||||
|
return DiagnosticCheck{Name: "DNS 记录", Status: "warning", Message: "没有 Fonu 接管的 DNS 记录"}
|
||||||
|
}
|
||||||
|
return DiagnosticCheck{Name: "DNS 记录", Status: "ok", Message: fmt.Sprintf("已接管 %d 条记录", len(records))}
|
||||||
|
}
|
||||||
|
|
||||||
|
func networkCheck(name, address string) DiagnosticCheck {
|
||||||
|
conn, err := net.DialTimeout("tcp", address, 3*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return DiagnosticCheck{Name: name, Status: "warning", Message: "连接失败:" + err.Error()}
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
return DiagnosticCheck{Name: name, Status: "ok", Message: "TCP 7844 可连接"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteTestResult 是单条路由源服务的连通性测试结果。
|
||||||
|
type RouteTestResult struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
LatencyMS float64 `json:"latency_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService 测试一条路由 service 的 TCP 连通性。
|
||||||
|
func (s *Service) TestService(ctx context.Context, service string) (RouteTestResult, error) {
|
||||||
|
address, err := serviceAddress(service)
|
||||||
|
if err != nil {
|
||||||
|
return RouteTestResult{}, err
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
dialer := net.Dialer{Timeout: 5 * time.Second}
|
||||||
|
conn, err := dialer.DialContext(ctx, "tcp", address)
|
||||||
|
if err != nil {
|
||||||
|
return RouteTestResult{OK: false, Message: err.Error()}, nil
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
return RouteTestResult{OK: true, Message: "源服务 TCP 可连接", LatencyMS: float64(time.Since(start).Milliseconds())}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceAddress(raw string) (string, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err != nil || parsed.Host == "" {
|
||||||
|
if !strings.Contains(raw, ":") {
|
||||||
|
return "", fmt.Errorf("service 格式无效:%s", raw)
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
host := parsed.Hostname()
|
||||||
|
port := parsed.Port()
|
||||||
|
if port == "" {
|
||||||
|
switch parsed.Scheme {
|
||||||
|
case "http":
|
||||||
|
port = "80"
|
||||||
|
case "https":
|
||||||
|
port = "443"
|
||||||
|
case "ssh":
|
||||||
|
port = "22"
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("service 缺少端口:%s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(host, port), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCompareIngressDetectsMissingAndMismatch(t *testing.T) {
|
||||||
|
expected := BuildIngress([]string{"a.example.com", "b.example.com"}, "http://127.0.0.1:8080")
|
||||||
|
actual := []IngressRule{
|
||||||
|
{Hostname: "a.example.com", Service: "http://127.0.0.1:9090"},
|
||||||
|
{Hostname: "c.example.com", Service: "http://127.0.0.1:8080"},
|
||||||
|
{Service: "http_status:404"},
|
||||||
|
}
|
||||||
|
items := compareIngress(expected, actual)
|
||||||
|
if len(items) != 3 {
|
||||||
|
t.Fatalf("应检测出 3 项漂移,实际 %#v", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareIngressIgnoresOrder(t *testing.T) {
|
||||||
|
expected := BuildIngress([]string{"b.example.com", "a.example.com"}, "http://127.0.0.1:8080")
|
||||||
|
actual := []IngressRule{
|
||||||
|
{Service: "http_status:404"},
|
||||||
|
{Hostname: "a.example.com", Service: "http://127.0.0.1:8080"},
|
||||||
|
{Hostname: "b.example.com", Service: "http://127.0.0.1:8080"},
|
||||||
|
}
|
||||||
|
if items := compareIngress(expected, actual); len(items) != 0 {
|
||||||
|
t.Fatalf("顺序不同不应算漂移:%#v", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/fsutil"
|
||||||
|
"github.com/fonu/fonu/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RouteView 是配置页展示的一条 ingress 路由。
|
||||||
|
type RouteView struct {
|
||||||
|
ID int64 `json:"id,omitempty"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TunnelRoutes 是配置页需要的路由列表。
|
||||||
|
type TunnelRoutes struct {
|
||||||
|
Managed bool `json:"managed"`
|
||||||
|
Routes []RouteView `json:"routes"`
|
||||||
|
CatchAll string `json:"catch_all"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteInput 是手工隧道路由保存入参。
|
||||||
|
type RouteInput struct {
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes 返回托管规则的派生路由,或手工 config.yml 中的 ingress 路由。
|
||||||
|
func (s *Service) Routes(ctx context.Context, id int64) (TunnelRoutes, error) {
|
||||||
|
tunnel, err := s.store.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return TunnelRoutes{}, err
|
||||||
|
}
|
||||||
|
if tunnel.Managed {
|
||||||
|
return s.managedRoutes(ctx, tunnel)
|
||||||
|
}
|
||||||
|
return manualRoutes(tunnel.ConfigPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) managedRoutes(ctx context.Context, tunnel Tunnel) (TunnelRoutes, error) {
|
||||||
|
if s.rules == nil {
|
||||||
|
return TunnelRoutes{Managed: true, Routes: []RouteView{}, CatchAll: "http_status:404"}, nil
|
||||||
|
}
|
||||||
|
rules, err := s.rules.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return TunnelRoutes{}, err
|
||||||
|
}
|
||||||
|
out := TunnelRoutes{Managed: true, Routes: []RouteView{}, CatchAll: "http_status:404"}
|
||||||
|
for _, rule := range rulesForTunnel(rules, tunnel.ID) {
|
||||||
|
for _, host := range rule.Hostnames() {
|
||||||
|
out.Routes = append(out.Routes, RouteView{
|
||||||
|
ID: rule.ID, Hostname: host, Path: "*", Service: rule.Upstream, Source: "rule",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func manualRoutes(configPath string) (TunnelRoutes, error) {
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return TunnelRoutes{Routes: []RouteView{}, CatchAll: "http_status:404"}, nil
|
||||||
|
}
|
||||||
|
return TunnelRoutes{}, err
|
||||||
|
}
|
||||||
|
out := TunnelRoutes{Routes: []RouteView{}, CatchAll: "http_status:404"}
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
for i, raw := range lines {
|
||||||
|
line := strings.TrimSpace(raw)
|
||||||
|
host, found := strings.CutPrefix(line, "- hostname:")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hostname := strings.TrimSpace(host)
|
||||||
|
if hostname == "" || i+1 >= len(lines) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := "*"
|
||||||
|
next := i + 1
|
||||||
|
if value, ok := strings.CutPrefix(strings.TrimSpace(lines[next]), "path:"); ok {
|
||||||
|
path = strings.TrimSpace(value)
|
||||||
|
next++
|
||||||
|
}
|
||||||
|
if next >= len(lines) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
service, ok := strings.CutPrefix(strings.TrimSpace(lines[next]), "service:")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Routes = append(out.Routes, RouteView{
|
||||||
|
Hostname: hostname, Path: path, Service: strings.TrimSpace(service), Source: "config",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(out.Routes) > 0 {
|
||||||
|
catchAll, err := catchAllService(data)
|
||||||
|
if err == nil && catchAll != "" {
|
||||||
|
out.CatchAll = catchAll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func catchAllService(data []byte) (string, error) {
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
for i := len(lines) - 1; i >= 0; i-- {
|
||||||
|
line := strings.TrimSpace(lines[i])
|
||||||
|
if strings.HasPrefix(line, "- service:") {
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(line, "- service:")), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("未找到 catch-all")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceRoutes 保存手工隧道的路由列表,并重新生成本地与云端 ingress。
|
||||||
|
func (s *Service) ReplaceRoutes(ctx context.Context, id int64, routes []RouteInput) (TunnelRoutes, error) {
|
||||||
|
tunnel, err := s.store.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return TunnelRoutes{}, err
|
||||||
|
}
|
||||||
|
if tunnel.Managed {
|
||||||
|
return TunnelRoutes{}, fmt.Errorf("托管隧道的路由由反向代理规则管理")
|
||||||
|
}
|
||||||
|
ingress, hostnames, err := buildManualIngress(routes)
|
||||||
|
if err != nil {
|
||||||
|
return TunnelRoutes{}, err
|
||||||
|
}
|
||||||
|
if err := fsutil.WriteFileAtomic(tunnel.ConfigPath, []byte(BuildConfig(tunnel.TunnelID, tunnel.CredsPath, tunnel.Network, ingress)), 0o640); err != nil {
|
||||||
|
return TunnelRoutes{}, err
|
||||||
|
}
|
||||||
|
if tunnel.Mode == ModeAccountLocal && tunnel.TunnelID != "" {
|
||||||
|
api, err := s.cloudflareAPI(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return TunnelRoutes{}, err
|
||||||
|
}
|
||||||
|
if err := api.PutIngress(ctx, tunnel.TunnelID, ingress); err != nil {
|
||||||
|
return TunnelRoutes{}, err
|
||||||
|
}
|
||||||
|
if err := s.syncTunnelDNS(ctx, api, tunnel.ID, tunnel.TunnelID, hostnames); err != nil {
|
||||||
|
return TunnelRoutes{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.restartIfRunning(ctx, tunnel.ID); err != nil {
|
||||||
|
return TunnelRoutes{}, err
|
||||||
|
}
|
||||||
|
return s.Routes(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildManualIngress(routes []RouteInput) ([]IngressRule, []string, error) {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
ingress := make([]IngressRule, 0, len(routes)+1)
|
||||||
|
hostnames := make([]string, 0, len(routes))
|
||||||
|
for _, route := range routes {
|
||||||
|
hostname := strings.ToLower(strings.TrimSpace(route.Hostname))
|
||||||
|
if err := validate.Domain(hostname); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("域名 %s 无效:%w", hostname, err)
|
||||||
|
}
|
||||||
|
if _, ok := seen[hostname]; ok {
|
||||||
|
return nil, nil, fmt.Errorf("域名 %s 重复", hostname)
|
||||||
|
}
|
||||||
|
seen[hostname] = struct{}{}
|
||||||
|
service := strings.TrimSpace(route.Service)
|
||||||
|
if service == "" || !strings.Contains(service, "://") {
|
||||||
|
return nil, nil, fmt.Errorf("域名 %s 的源服务格式无效", hostname)
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(route.Path)
|
||||||
|
// Cloudflare ingress 的 path 是正则;通配路由必须省略 path,发送 "*" 会被判为非法正则。
|
||||||
|
if path == "" || path == "*" {
|
||||||
|
path = ""
|
||||||
|
}
|
||||||
|
ingress = append(ingress, IngressRule{Hostname: hostname, Path: path, Service: service})
|
||||||
|
hostnames = append(hostnames, hostname)
|
||||||
|
}
|
||||||
|
ingress = append(ingress, IngressRule{Service: "http_status:404"})
|
||||||
|
return ingress, hostnames, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManualRoutesFromConfig(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
content := `tunnel: test
|
||||||
|
ingress:
|
||||||
|
- hostname: a.example.com
|
||||||
|
service: http://127.0.0.1:8080
|
||||||
|
- hostname: b.example.com
|
||||||
|
service: http://127.0.0.1:9090
|
||||||
|
- service: http_status:404
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := manualRoutes(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(result.Routes) != 2 || result.Routes[0].Hostname != "a.example.com" || result.Routes[1].Service != "http://127.0.0.1:9090" {
|
||||||
|
t.Fatalf("手工路由解析不符:%#v", result)
|
||||||
|
}
|
||||||
|
if result.CatchAll != "http_status:404" {
|
||||||
|
t.Fatalf("catch-all 不符:%q", result.CatchAll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildManualIngress(t *testing.T) {
|
||||||
|
ingress, hostnames, err := buildManualIngress([]RouteInput{
|
||||||
|
{Hostname: "A.Example.com", Path: "*", Service: "http://127.0.0.1:8080"},
|
||||||
|
{Hostname: "b.example.com", Service: "https://127.0.0.1:8443"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(ingress) != 3 || ingress[0].Hostname != "a.example.com" || ingress[1].Hostname != "b.example.com" {
|
||||||
|
t.Fatalf("ingress 结果不符:%#v", ingress)
|
||||||
|
}
|
||||||
|
if ingress[0].Path != "" || ingress[1].Path != "" {
|
||||||
|
t.Fatalf("通配路径不应写入 Cloudflare ingress:%#v", ingress)
|
||||||
|
}
|
||||||
|
if len(hostnames) != 2 || hostnames[1] != "b.example.com" {
|
||||||
|
t.Fatalf("hostnames 结果不符:%#v", hostnames)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user