feat: rename project from Fonu to Havline
CI / verify (push) Canceled after 0s

This commit is contained in:
2026-10-01 16:54:35 +08:00
parent c377405696
commit 8fa5d922ac
250 changed files with 6724 additions and 5070 deletions
+3 -3
View File
@@ -16,7 +16,7 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
# 顺序不能颠倒:cmd/fonu/main.go 用 //go:embed all:web/dist 嵌入前端, # 顺序不能颠倒:cmd/havline/main.go 用 //go:embed all:web/dist 嵌入前端,
# 干净检出时该目录不存在,go vet / go build 会直接失败。 # 干净检出时该目录不存在,go vet / go build 会直接失败。
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -39,8 +39,8 @@ jobs:
- name: Sync web dist into embed directory - name: Sync web dist into embed directory
run: | run: |
rm -rf cmd/fonu/web/dist rm -rf cmd/havline/web/dist
cp -r web/dist cmd/fonu/web/dist cp -r web/dist cmd/havline/web/dist
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
+2 -2
View File
@@ -60,7 +60,7 @@ jobs:
push: true push: true
build-args: | build-args: |
VERSION=${{ steps.version.outputs.version }} VERSION=${{ steps.version.outputs.version }}
tags: ${{ secrets.DOCKERHUB_USERNAME }}/fonu:${{ steps.version.outputs.version }}-${{ matrix.arch }} tags: ${{ secrets.DOCKERHUB_USERNAME }}/havline:${{ steps.version.outputs.version }}-${{ matrix.arch }}
labels: | labels: |
org.opencontainers.image.source=${{ github.repositoryUrl }} org.opencontainers.image.source=${{ github.repositoryUrl }}
org.opencontainers.image.version=${{ steps.version.outputs.version }} org.opencontainers.image.version=${{ steps.version.outputs.version }}
@@ -91,7 +91,7 @@ jobs:
- name: Create multi-arch manifest - name: Create multi-arch manifest
env: env:
IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/fonu IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/havline
VERSION: ${{ steps.version.outputs.version }} VERSION: ${{ steps.version.outputs.version }}
run: | run: |
if [ -z "${IMAGE}" ]; then if [ -z "${IMAGE}" ]; then
+6 -2
View File
@@ -2,7 +2,7 @@ bin/
.data/ .data/
web/node_modules/ web/node_modules/
web/dist/ web/dist/
cmd/fonu/web/dist/ cmd/havline/web/dist/
dist/ dist/
*.db *.db
/data/ /data/
@@ -11,11 +11,15 @@ dist/
.env.local .env.local
.DS_Store .DS_Store
.cursor/ .cursor/
.playwright-cli/
output/
web/public/111favicon.png
web/src/assets/brand/logo00.png
# scripts/ 下的本地辅助脚本不入库,但随仓库分发的 agent 安装脚本需要跟踪 # scripts/ 下的本地辅助脚本不入库,但随仓库分发的 agent 安装脚本需要跟踪
scripts/* scripts/*
!scripts/agent-install.sh !scripts/agent-install.sh
!scripts/build-linux.ps1 !scripts/build-linux.ps1
!scripts/fonu-install.sh !scripts/havline-install.sh
!scripts/upgrade.sh !scripts/upgrade.sh
!scripts/upgrade-hub.sh !scripts/upgrade-hub.sh
# 本地压缩包与导出产物不入库(如 web/src.rar) # 本地压缩包与导出产物不入库(如 web/src.rar)
+8
View File
@@ -2,6 +2,14 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [Unreleased]
### 变更
- **品牌全量切换为 Havline**:项目显示名、Go module、命令目录、二进制、环境变量、数据库、Cookie、Docker/Compose、Agent、Updater、脚本和当前文档统一切换到 `Havline / havline / HAVLINE_`
- **AgentVersion 提升到 0.15.0**:Agent 安装路径、配置目录、数据目录、systemd unit、Nginx 托管标记和 API 服务标识全部切换到 `havline-agent`
- **历史文档保留原品牌名**:`CHANGELOG.md` 历史版本、旧品牌评估文档和带日期的历史分析报告保留原始名称,作为项目沿革和 GPL 来源记录
## [v1.9.0] - 2026-10-01 ## [v1.9.0] - 2026-10-01
### 新增 ### 新增
+14 -14
View File
@@ -37,13 +37,13 @@ COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod 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 目录(替换仓库中的占位文件),主程序构建时自动内嵌 # 先交叉编译 havline-agent 到 embed 目录(替换仓库中的占位文件),主程序构建时自动内嵌
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 \ 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/havline-agent-linux-amd64 ./cmd/havline-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/havline-agent-linux-arm64 ./cmd/havline-agent
COPY --from=web-builder /src/web/dist ./cmd/fonu/web/dist COPY --from=web-builder /src/web/dist ./cmd/havline/web/dist
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 \ 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/havline/havline/internal/version.Version=$(cat /src/VERSION | tr -d ' \t\n\r')" \
-o /fonu ./cmd/fonu -o /havline ./cmd/havline
# 一键升级 sidecar:独立容器持有 Docker Socket,主程序只通过共享 Unix Socket 调用它 # 一键升级 sidecar:独立容器持有 Docker Socket,主程序只通过共享 Unix Socket 调用它
FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS updater-builder FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS updater-builder
@@ -55,13 +55,13 @@ COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY cmd ./cmd COPY cmd ./cmd
COPY internal ./internal 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 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 /havline-updater ./cmd/havline-updater
# updater 运行时:Docker CLI / Compose 与 Git 只在 sidecar 内可用 # updater 运行时:Docker CLI / Compose 与 Git 只在 sidecar 内可用
FROM ${UPDATER_IMAGE} AS updater FROM ${UPDATER_IMAGE} AS updater
RUN apk add --no-cache git docker-cli-compose docker-cli-buildx ca-certificates 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 COPY --from=updater-builder /havline-updater /usr/local/bin/havline-updater
ENTRYPOINT ["/usr/local/bin/fonu-updater"] ENTRYPOINT ["/usr/local/bin/havline-updater"]
# 运行时 stage # 运行时 stage
FROM ${RUNTIME_IMAGE} FROM ${RUNTIME_IMAGE}
@@ -72,18 +72,18 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY --from=go-builder /fonu /app/fonu COPY --from=go-builder /havline /app/havline
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
# 时区:Go 的 time.Local 取决于 TZ 环境变量,且需要镜像内存在 zoneinfo(故装上 tzdata) # 时区:Go 的 time.Local 取决于 TZ 环境变量,且需要镜像内存在 zoneinfo(故装上 tzdata)
ENV TZ=Asia/Shanghai \ ENV TZ=Asia/Shanghai \
FONU_DATA_DIR=/data \ HAVLINE_DATA_DIR=/data \
FONU_LISTEN=:6893 \ HAVLINE_LISTEN=:6893 \
FONU_NGINX_MIME_TYPES=/etc/nginx/mime.types HAVLINE_NGINX_MIME_TYPES=/etc/nginx/mime.types
EXPOSE 6893 80 443 EXPOSE 6893 80 443
VOLUME ["/data"] VOLUME ["/data"]
CMD ["/app/fonu"] CMD ["/app/havline"]
+30 -30
View File
@@ -1,8 +1,8 @@
<p align="center"> <p align="center">
<img src="web/src/assets/brand/logo.png" alt="Fonu Logo" width="96" /> <img src="web/src/assets/brand/logo.png" alt="Havline Logo" width="96" />
</p> </p>
<h1 align="center">Fonu</h1> <h1 align="center">Havline</h1>
<p align="center"><strong>让 NAS 访问更简单</strong></p> <p align="center"><strong>让 NAS 访问更简单</strong></p>
@@ -24,7 +24,7 @@
## 简介 ## 简介
**Fonu** 是一款主要为 **飞牛 fnOS** 编写的反代与管理工具,帮助你在 NAS 上快速搭建稳定的公网访问能力:通过 Web 界面管理 Nginx 反向代理、自动同步 DDNS、申请与续期 Let's Encrypt 证书,并在一个仪表盘里查看服务状态、访问日志与流量统计。 **Havline** 是一款主要为 **飞牛 fnOS** 编写的反代与管理工具,帮助你在 NAS 上快速搭建稳定的公网访问能力:通过 Web 界面管理 Nginx 反向代理、自动同步 DDNS、申请与续期 Let's Encrypt 证书,并在一个仪表盘里查看服务状态、访问日志与流量统计。
无需手写 Nginx 配置,也无需在多个工具之间切换。当前开发与测试环境以飞牛 fnOS 为主,**群晖、威联通、自建 Linux 等其他环境尚未充分测试**,部署前请自行验证。 无需手写 Nginx 配置,也无需在多个工具之间切换。当前开发与测试环境以飞牛 fnOS 为主,**群晖、威联通、自建 Linux 等其他环境尚未充分测试**,部署前请自行验证。
@@ -56,10 +56,10 @@
## 部署 ## 部署
本地项目:三个 compose 文件均从源码构建镜像(`build: .`),不拉取任何仓库镜像,构建产物标签为 `fonu:local`。 本地项目:三个 compose 文件均从源码构建镜像(`build: .`),不拉取任何仓库镜像,构建产物标签为 `havline:local`。
> **详细部署安装文档**:[`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) —— 环境要求、镜像构建、三种部署方式、首次配置、FRP/DDNS/证书联动、升级回滚与故障排查。 > **详细部署安装文档**:[`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) —— 环境要求、镜像构建、三种部署方式、首次配置、FRP/DDNS/证书联动、升级回滚与故障排查。
> **FRPS 服务端管理方案**:[`docs/frps服务端管理方案.md`](frps服务端管理方案.md) —— 公网 VPS 部署 `fonu-agent`,SSH 从本地 Fonu 安装/升级/卸载,日常通过本地管理面板调用 API 自动编排 Nginx、frps 与证书。 > **FRPS 服务端管理方案**:[`docs/frps服务端管理方案.md`](frps服务端管理方案.md) —— 公网 VPS 部署 `havline-agent`,SSH 从本地 Havline 安装/升级/卸载,日常通过本地管理面板调用 API 自动编排 Nginx、frps 与证书。
### 快速部署(推荐 · 飞牛 fnOS · Host 模式) ### 快速部署(推荐 · 飞牛 fnOS · Host 模式)
@@ -67,11 +67,11 @@
```bash ```bash
docker run -d \ docker run -d \
--name fonu \ --name havline \
--net=host \ --net=host \
-v ./data:/data \ -v ./data:/data \
--restart unless-stopped \ --restart unless-stopped \
fonu:local havline:local
``` ```
| 入口 | 地址 | | 入口 | 地址 |
@@ -89,7 +89,7 @@ docker run -d \
<summary>Docker Compose</summary> <summary>Docker Compose</summary>
```bash ```bash
FONU_SESSION_SECRET=your-secret docker compose -f docker-compose.host.yml up -d HAVLINE_SESSION_SECRET=your-secret docker compose -f docker-compose.host.yml up -d
``` ```
</details> </details>
@@ -98,7 +98,7 @@ FONU_SESSION_SECRET=your-secret docker compose -f docker-compose.host.yml up -d
<summary>Bridge 网络(端口映射,非飞牛环境可自行尝试)</summary> <summary>Bridge 网络(端口映射,非飞牛环境可自行尝试)</summary>
```bash ```bash
FONU_SESSION_SECRET=your-secret docker compose -f docker-compose.hub.yml up -d --build HAVLINE_SESSION_SECRET=your-secret docker compose -f docker-compose.hub.yml up -d --build
``` ```
</details> </details>
@@ -114,7 +114,7 @@ docker compose up -d --build
### Web 一键升级 ### Web 一键升级
`docker-compose.yml` 与 `docker-compose.hub.yml` 会同时启动受限的 `fonu-updater` sidecar。检测到新版本后,侧栏会出现「一键升级」入口,可在管理页面完成构建、重建与健康检查。 `docker-compose.yml` 与 `docker-compose.hub.yml` 会同时启动受限的 `havline-updater` sidecar。检测到新版本后,侧栏会出现「一键升级」入口,可在管理页面完成构建、重建与健康检查。
> 从旧版本升级时,需要先手动更新 Compose 文件并执行一次 `docker compose up -d --build` 启动 sidecar。详见 [`docs/system-upgrade.md`](docs/system-upgrade.md)。 > 从旧版本升级时,需要先手动更新 Compose 文件并执行一次 `docker compose up -d --build` 启动 sidecar。详见 [`docs/system-upgrade.md`](docs/system-upgrade.md)。
@@ -126,15 +126,15 @@ docker compose up -d --build
### FRP 内网穿透(无公网 IP) ### FRP 内网穿透(无公网 IP)
适用于无法端口映射、无公网 IPv4 的场景。Fonu 内置 **frpc**,支持**多台公网服务端**与全部 8 种穿透类型(tcp / udp / http / https / tcpmux / stcp / sudp / xtcp)及插件(static_file / socks5 / https2http 等)。 适用于无法端口映射、无公网 IPv4 的场景。Havline 内置 **frpc**,支持**多台公网服务端**与全部 8 种穿透类型(tcp / udp / http / https / tcpmux / stcp / sudp / xtcp)及插件(static_file / socks5 / https2http 等)。
**典型流量路径**(tcp 规则 + 本地反代):用户 → DNS(解析到 VPS)→ frps → frpc → Fonu Nginx → 内网服务 **典型流量路径**(tcp 规则 + 本地反代):用户 → DNS(解析到 VPS)→ frps → frpc → Havline Nginx → 内网服务
#### 1. VPS 部署 frps #### 1. VPS 部署 frps
在服务端「详情」弹窗点 **「frps.toml」** 一键生成与 frpc 侧同源的服务端配置(bindPort / 认证 / webServer / 传输参数 / vhost 端口与防火墙清单全部对齐),复制到 VPS 保存后 `frps -c frps.toml` 启动,按文件末尾清单放行防火墙端口。 在服务端「详情」弹窗点 **「frps.toml」** 一键生成与 frpc 侧同源的服务端配置(bindPort / 认证 / webServer / 传输参数 / vhost 端口与防火墙清单全部对齐),复制到 VPS 保存后 `frps -c frps.toml` 启动,按文件末尾清单放行防火墙端口。
#### 2. Fonu 配置 frpc #### 2. Havline 配置 frpc
在侧栏 **FRP 穿透** 页面: 在侧栏 **FRP 穿透** 页面:
@@ -153,25 +153,25 @@ docker compose up -d --build
| 变量 | 默认值 | 说明 | | 变量 | 默认值 | 说明 |
| ----------------------- | ------------------------- | ------------------------------------------ | | ----------------------- | ------------------------- | ------------------------------------------ |
| `FONU_LISTEN` | `:6893` | 管理后台监听地址 | | `HAVLINE_LISTEN` | `:6893` | 管理后台监听地址 |
| `FONU_DATA_DIR` | `/data` | 数据目录(数据库、Nginx 配置、证书、日志) | | `HAVLINE_DATA_DIR` | `/data` | 数据目录(数据库、Nginx 配置、证书、日志) |
| `FONU_SESSION_SECRET` | 空(自动生成) | 会话与凭据加密密钥;有数据后不要更换 | | `HAVLINE_SESSION_SECRET` | 空(自动生成) | 会话与凭据加密密钥;有数据后不要更换 |
| `FONU_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代规则的默认监听端口 | | `HAVLINE_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代规则的默认监听端口 |
| `FONU_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代规则的默认监听端口 | | `HAVLINE_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代规则的默认监听端口 |
| `FONU_NGINX_BIN` | `nginx` | Nginx 可执行文件路径(本地开发可指定) | | `HAVLINE_NGINX_BIN` | `nginx` | Nginx 可执行文件路径(本地开发可指定) |
| `FONU_FRPC_BIN` | `frpc` | frpc 可执行文件路径(镜像不预装,用「应用配置」按需下载或自行放置) | | `HAVLINE_FRPC_BIN` | `frpc` | frpc 可执行文件路径(镜像不预装,用「应用配置」按需下载或自行放置) |
| `FONU_FRP_PID` | `{DATA_DIR}/frp/frpc.pid` | frpc 进程 PID 文件路径 | | `HAVLINE_FRP_PID` | `{DATA_DIR}/frp/frpc.pid` | frpc 进程 PID 文件路径 |
| `FONU_UPDATER_SOCKET` | `/run/fonu-updater/updater.sock` | 一键升级 sidecar 的 Unix Socket;未挂载时自动禁用 | | `HAVLINE_UPDATER_SOCKET` | `/run/havline-updater/updater.sock` | 一键升级 sidecar 的 Unix Socket;未挂载时自动禁用 |
## 注意事项 ## 注意事项
1. **生产环境务必设置** `FONU_SESSION_SECRET`,不要使用默认值。 1. **生产环境务必设置** `HAVLINE_SESSION_SECRET`,不要使用默认值。
2. **端口冲突**:宿主机已有 Nginx(如飞牛系统)占用 80/443 时,不要让 Fonu 反代规则监听 80/443;使用 `18080/9443` 或 host 模式 + 环境变量。 2. **端口冲突**:宿主机已有 Nginx(如飞牛系统)占用 80/443 时,不要让 Havline 反代规则监听 80/443;使用 `18080/9443` 或 host 模式 + 环境变量。
3. **已有反代规则**:修改 `FONU_NGINX_HTTP_PORT` / `FONU_NGINX_HTTPS_PORT` 后,**不会自动更新**数据库中已有规则的端口,需在反代页手动修改并保存。 3. **已有反代规则**:修改 `HAVLINE_NGINX_HTTP_PORT` / `HAVLINE_NGINX_HTTPS_PORT` 后,**不会自动更新**数据库中已有规则的端口,需在反代页手动修改并保存。
4. **访问日志**:仅统计经 Fonu Nginx 反代的域名流量,不包含管理后台 `:6893` 的请求。 4. **访问日志**:仅统计经 Havline Nginx 反代的域名流量,不包含管理后台 `:6893` 的请求。
5. **证书与 DNS**:HTTPS 申请前请确保域名 DNS 已解析到本机,且 DDNS 凭证能管理对应根域。 5. **证书与 DNS**:HTTPS 申请前请确保域名 DNS 已解析到本机,且 DDNS 凭证能管理对应根域。
6. **数据持久化**:Docker 部署请挂载 volume 到 `FONU_DATA_DIR`,避免容器重建后配置丢失。 6. **数据持久化**:Docker 部署请挂载 volume 到 `HAVLINE_DATA_DIR`,避免容器重建后配置丢失。
7. **备份**:可通过设置页导出配置;重要证书与数据库建议定期备份 `FONU_DATA_DIR` 目录。 7. **备份**:可通过设置页导出配置;重要证书与数据库建议定期备份 `HAVLINE_DATA_DIR` 目录。
## 本地开发 ## 本地开发
@@ -179,9 +179,9 @@ docker compose up -d --build
# 前端热更新 # 前端热更新
cd web && npm ci && npm run dev cd web && npm ci && npm run dev
# 后端(需先把 web/dist 复制到 cmd/fonu/web/dist) # 后端(需先把 web/dist 复制到 cmd/havline/web/dist)
cd web && npm run build cd web && npm run build
go run ./cmd/fonu go run ./cmd/havline
# 测试 # 测试
go test ./... go test ./...
-62
View File
@@ -1,62 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
"github.com/fonu/fonu/internal/agent"
)
func main() {
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")
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")
frpsConfig := flag.String("frps-config", envOr("FONU_AGENT_FRPS_CONFIG", "/etc/frp/frps.toml"), "frps config path")
frpsBin := flag.String("frps-bin", envOr("FONU_AGENT_FRPS_BIN", "frps"), "frps binary")
dataDir := flag.String("data-dir", envOr("FONU_AGENT_DATA_DIR", "/var/lib/fonu-agent"), "agent data directory")
showVersion := flag.Bool("version", false, "print agent version and exit")
flag.Parse()
if *showVersion {
fmt.Println("fonu-agent", agent.AgentVersion)
return
}
if *token == "" {
log.Fatal("FONU_AGENT_TOKEN or -token is required")
}
server, err := agent.NewServer(agent.Config{
ListenAddr: *listen, AllowRemote: *allowRemote, Token: *token, NginxConfDir: *nginxDir,
CertsDir: *certsDir, FrpsConfigPath: *frpsConfig, FrpsBinary: *frpsBin, DataDir: *dataDir,
})
if err != nil {
log.Fatal(err)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := server.ListenAndServe(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
}
func envBool(key string) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
-44
View File
@@ -1,44 +0,0 @@
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
}
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
"github.com/havline/havline/internal/agent"
)
func main() {
listen := flag.String("listen", envOr("HAVLINE_AGENT_LISTEN", "127.0.0.1:7700"), "agent listen address")
allowRemote := flag.Bool("allow-remote", envBool("HAVLINE_AGENT_ALLOW_REMOTE"), "allow non-loopback listen address")
token := flag.String("token", os.Getenv("HAVLINE_AGENT_TOKEN"), "agent bearer token")
nginxDir := flag.String("nginx-conf-dir", envOr("HAVLINE_AGENT_NGINX_CONF_DIR", ""), "managed nginx config directory(留空自动探测 Nginx 实际加载的 vhost 目录)")
certsDir := flag.String("certs-dir", envOr("HAVLINE_AGENT_CERTS_DIR", "/var/lib/havline-agent/certs"), "certificate directory")
frpsConfig := flag.String("frps-config", envOr("HAVLINE_AGENT_FRPS_CONFIG", "/etc/frp/frps.toml"), "frps config path")
frpsBin := flag.String("frps-bin", envOr("HAVLINE_AGENT_FRPS_BIN", "frps"), "frps binary")
dataDir := flag.String("data-dir", envOr("HAVLINE_AGENT_DATA_DIR", "/var/lib/havline-agent"), "agent data directory")
showVersion := flag.Bool("version", false, "print agent version and exit")
flag.Parse()
if *showVersion {
fmt.Println("havline-agent", agent.AgentVersion)
return
}
if *token == "" {
log.Fatal("HAVLINE_AGENT_TOKEN or -token is required")
}
server, err := agent.NewServer(agent.Config{
ListenAddr: *listen, AllowRemote: *allowRemote, Token: *token, NginxConfDir: *nginxDir,
CertsDir: *certsDir, FrpsConfigPath: *frpsConfig, FrpsBinary: *frpsBin, DataDir: *dataDir,
})
if err != nil {
log.Fatal(err)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := server.ListenAndServe(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
}
func envBool(key string) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"github.com/havline/havline/internal/updater"
)
func main() {
server, err := updater.NewServer(updater.Config{
SocketPath: envOr("HAVLINE_UPDATER_SOCKET", "/run/havline-updater/updater.sock"),
ProjectDir: envOr("HAVLINE_UPDATE_PROJECT_DIR", "/workspace"),
ComposeFile: envOr("HAVLINE_UPDATE_COMPOSE_FILE", "docker-compose.yml"),
Service: envOr("HAVLINE_UPDATE_SERVICE", "havline"),
Mode: envOr("HAVLINE_UPDATE_MODE", "local"),
RepoURL: envOr("HAVLINE_UPDATE_REPO", ""),
Branch: envOr("HAVLINE_UPDATE_BRANCH", "main"),
AppURL: envOr("HAVLINE_UPDATE_APP_URL", "http://havline:6893"),
HostProjectDir: envOr("HAVLINE_UPDATE_HOST_DIR", ""),
ProjectName: envOr("HAVLINE_UPDATE_PROJECT_NAME", ""),
SelfImage: envOr("HAVLINE_UPDATE_SELF_IMAGE", ""),
DockerSocket: envOr("HAVLINE_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
}
+2 -2
View File
@@ -12,8 +12,8 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/fonu/fonu/internal/app" "github.com/havline/havline/internal/app"
"github.com/fonu/fonu/internal/config" "github.com/havline/havline/internal/config"
) )
//go:embed all:web/dist //go:embed all:web/dist
+13 -13
View File
@@ -1,8 +1,8 @@
# 飞牛 NAS 等已占用 80/443 的环境:使用 host 网络 + 高位默认反代端口 # 飞牛 NAS 等已占用 80/443 的环境:使用 host 网络 + 高位默认反代端口
# 本地项目:直接用源码构建镜像,不从 Docker Hub 拉取 # 本地项目:直接用源码构建镜像,不从 Docker Hub 拉取
name: fonu name: havline
services: services:
fonu: havline:
build: build:
context: . context: .
args: args:
@@ -10,25 +10,25 @@ 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} image: havline:${HAVLINE_VERSION:-local}
container_name: fonu container_name: havline
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
volumes: volumes:
# 数据目录:默认用命名卷 fonu-data(由 Docker 管理,位置随 Docker 数据根)。 # 数据目录:默认用命名卷 havline-data(由 Docker 管理,位置随 Docker 数据根)。
# 想落到指定目录就在同层 .env 里设 FONU_DATA_PATH=/vol1/docker/fonu-data —— # 想落到指定目录就在同层 .env 里设 HAVLINE_DATA_PATH=/vol1/docker/havline-data ——
# 以 / 或 . 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷。 # 以 / 或 . 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷。
- ${FONU_DATA_PATH:-fonu-data}:/data - ${HAVLINE_DATA_PATH:-havline-data}:/data
environment: environment:
FONU_DATA_DIR: /data HAVLINE_DATA_DIR: /data
FONU_LISTEN: :6893 HAVLINE_LISTEN: :6893
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600) # 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
# 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换 # 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换
FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-} HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-}
# 时区:通知消息时间按此显示(默认东八区) # 时区:通知消息时间按此显示(默认东八区)
TZ: ${TZ:-Asia/Shanghai} TZ: ${TZ:-Asia/Shanghai}
FONU_NGINX_HTTP_PORT: ${FONU_NGINX_HTTP_PORT:-18080} HAVLINE_NGINX_HTTP_PORT: ${HAVLINE_NGINX_HTTP_PORT:-18080}
FONU_NGINX_HTTPS_PORT: ${FONU_NGINX_HTTPS_PORT:-9443} HAVLINE_NGINX_HTTPS_PORT: ${HAVLINE_NGINX_HTTPS_PORT:-9443}
volumes: volumes:
fonu-data: havline-data:
+35 -35
View File
@@ -1,13 +1,13 @@
# 从仓库拉源码构建并启动(适合干净机器:只要这一个文件) # 从仓库拉源码构建并启动(适合干净机器:只要这一个文件)
# #
# 用法: # 用法:
# mkdir -p /opt/fonu && cd /opt/fonu # mkdir -p /opt/havline && cd /opt/havline
# # 取本文件(按你仓库页面上的「原始文件」链接下载,或 git clone 后复制过来): # # 取本文件(按你仓库页面上的「原始文件」链接下载,或 git clone 后复制过来):
# # curl -fsSLo docker-compose.hub.yml <仓库里这个文件的原始地址> # # curl -fsSLo docker-compose.hub.yml <仓库里这个文件的原始地址>
# docker compose up -d --build # 会话密钥由应用首次启动自动生成并持久化在数据卷里 # docker compose up -d --build # 会话密钥由应用首次启动自动生成并持久化在数据卷里
# #
# 想自己指定会话密钥(可选项):在 compose 文件同层建 .env 写入 # 想自己指定会话密钥(可选项):在 compose 文件同层建 .env 写入
# FONU_SESSION_SECRET=<一串随机字符,openssl rand -hex 32 可生成> # HAVLINE_SESSION_SECRET=<一串随机字符,openssl rand -hex 32 可生成>
# 注意:它同时是凭据加密密钥,数据卷里已经有数据后不要随意更换,否则已存的凭据无法解密。 # 注意:它同时是凭据加密密钥,数据卷里已经有数据后不要随意更换,否则已存的凭据无法解密。
# #
# 构建上下文用的是 Git 地址:BuildKit 会自己把仓库克隆下来,不需要先 git clone 到本地; # 构建上下文用的是 Git 地址:BuildKit 会自己把仓库克隆下来,不需要先 git clone 到本地;
@@ -16,22 +16,22 @@
# #
# 首次构建要拉前端与 Go 依赖,比较慢;之后靠 Dockerfile 里的 cache mount 复用缓存。 # 首次构建要拉前端与 Go 依赖,比较慢;之后靠 Dockerfile 里的 cache mount 复用缓存。
# 80/443 已被占用(如飞牛 NAS)请改用 docker-compose.host.yml。 # 80/443 已被占用(如飞牛 NAS)请改用 docker-compose.host.yml。
name: fonu name: havline
services: services:
fonu: havline:
build: build:
context: http://45.207.223.190:10082/mrlee/-Fonu.git#main context: http://45.207.223.190:10082/mrlee/Havline.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} 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:${FONU_VERSION:-local} image: havline:${HAVLINE_VERSION:-local}
# 有 build 段时 compose 默认会先尝试拉同名镜像(日志里那条 "fonu Pulling" 就是), # 有 build 段时 compose 默认会先尝试拉同名镜像(日志里那条 "havline Pulling" 就是),
# 国内直连 Docker Hub 会超时报警;强制走构建,跳过这一步。 # 国内直连 Docker Hub 会超时报警;强制走构建,跳过这一步。
pull_policy: build pull_policy: build
container_name: fonu container_name: havline
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- updater - updater
@@ -40,53 +40,53 @@ services:
- "18080:80" - "18080:80"
- "9443:443" - "9443:443"
volumes: volumes:
# 数据目录:默认用命名卷 fonu-data(由 Docker 管理,位置随 Docker 数据根)。 # 数据目录:默认用命名卷 havline-data(由 Docker 管理,位置随 Docker 数据根)。
# 想落到指定目录就在同层 .env 里设 FONU_DATA_PATH=/vol1/docker/fonu-data —— # 想落到指定目录就在同层 .env 里设 HAVLINE_DATA_PATH=/vol1/docker/havline-data ——
# 以 / 或 . 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷。 # 以 / 或 . 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷。
- ${FONU_DATA_PATH:-fonu-data}:/data - ${HAVLINE_DATA_PATH:-havline-data}:/data
- fonu-updater-sock:/run/fonu-updater - havline-updater-sock:/run/havline-updater
environment: environment:
FONU_DATA_DIR: /data HAVLINE_DATA_DIR: /data
FONU_LISTEN: :6893 HAVLINE_LISTEN: :6893
FONU_UPDATER_SOCKET: /run/fonu-updater/updater.sock HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600) # 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
# 想自己指定就在同层 .env 里设这个变量;注意它同时是凭据加密密钥,已有数据后不要更换 # 想自己指定就在同层 .env 里设这个变量;注意它同时是凭据加密密钥,已有数据后不要更换
FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-} HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-}
# 时区:通知消息时间按此显示(默认东八区) # 时区:通知消息时间按此显示(默认东八区)
TZ: ${TZ:-Asia/Shanghai} TZ: ${TZ:-Asia/Shanghai}
# 受限升级 sidecar:只有它持有 Docker Socket,主程序通过共享 Unix Socket 触发 # 受限升级 sidecar:只有它持有 Docker Socket,主程序通过共享 Unix Socket 触发
updater: updater:
build: build:
context: http://45.207.223.190:10082/mrlee/-Fonu.git#main context: http://45.207.223.190:10082/mrlee/Havline.git#main
target: updater target: updater
args: args:
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}
UPDATER_IMAGE: ${UPDATER_IMAGE:-docker.m.daocloud.io/library/docker:27-cli} 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-updater:${FONU_VERSION:-local} image: havline-updater:${HAVLINE_VERSION:-local}
pull_policy: build pull_policy: build
container_name: fonu-updater container_name: havline-updater
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ${DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock - ${DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock
- ${FONU_UPDATE_HOST_DIR:-.}:/workspace - ${HAVLINE_UPDATE_HOST_DIR:-.}:/workspace
- fonu-updater-sock:/run/fonu-updater - havline-updater-sock:/run/havline-updater
environment: environment:
FONU_UPDATER_SOCKET: /run/fonu-updater/updater.sock HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
FONU_UPDATE_PROJECT_DIR: /workspace HAVLINE_UPDATE_PROJECT_DIR: /workspace
FONU_UPDATE_COMPOSE_FILE: docker-compose.hub.yml HAVLINE_UPDATE_COMPOSE_FILE: docker-compose.hub.yml
FONU_UPDATE_SERVICE: fonu HAVLINE_UPDATE_SERVICE: havline
FONU_UPDATE_MODE: git HAVLINE_UPDATE_MODE: git
FONU_UPDATE_REPO: http://45.207.223.190:10082/mrlee/-Fonu.git HAVLINE_UPDATE_REPO: http://45.207.223.190:10082/mrlee/Havline.git
FONU_UPDATE_BRANCH: main HAVLINE_UPDATE_BRANCH: main
FONU_UPDATE_APP_URL: http://fonu:6893 HAVLINE_UPDATE_APP_URL: http://havline:6893
FONU_UPDATE_HOST_DIR: ${FONU_UPDATE_HOST_DIR:-} HAVLINE_UPDATE_HOST_DIR: ${HAVLINE_UPDATE_HOST_DIR:-}
FONU_UPDATE_PROJECT_NAME: ${FONU_UPDATE_PROJECT_NAME:-fonu} HAVLINE_UPDATE_PROJECT_NAME: ${HAVLINE_UPDATE_PROJECT_NAME:-havline}
FONU_UPDATE_SELF_IMAGE: fonu-updater:${FONU_VERSION:-local} HAVLINE_UPDATE_SELF_IMAGE: havline-updater:${HAVLINE_VERSION:-local}
FONU_UPDATE_DOCKER_SOCK: ${DOCKER_SOCK:-/var/run/docker.sock} HAVLINE_UPDATE_DOCKER_SOCK: ${DOCKER_SOCK:-/var/run/docker.sock}
TZ: ${TZ:-Asia/Shanghai} TZ: ${TZ:-Asia/Shanghai}
volumes: volumes:
fonu-data: havline-data:
fonu-updater-sock: havline-updater-sock:
+30 -30
View File
@@ -1,12 +1,12 @@
# 固定项目名:不改的话,改了目录名/项目名后 compose 会把它当成新项目, # 固定项目名:不改的话,改了目录名/项目名后 compose 会把它当成新项目,
# fonu-data 会变成新空卷(看起来像「数据丢了」)。 # havline-data 会变成新空卷(看起来像「数据丢了」)。
name: fonu name: havline
# 本地项目:用源码构建镜像(镜像名随 VERSION 变,便于回滚;也便于导出 / 备份) # 本地项目:用源码构建镜像(镜像名随 VERSION 变,便于回滚;也便于导出 / 备份)
# 升级:docker compose up -d --build(或在部署机跑 scripts/upgrade.sh) # 升级:docker compose up -d --build(或在部署机跑 scripts/upgrade.sh)
# 回滚:FONU_VERSION=上一个版本号 docker compose up -d(镜像 tag 还在本地) # 回滚:HAVLINE_VERSION=上一个版本号 docker compose up -d(镜像 tag 还在本地)
services: services:
fonu: havline:
build: build:
context: . context: .
args: args:
@@ -15,8 +15,8 @@ services:
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} 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:${FONU_VERSION:-local} image: havline:${HAVLINE_VERSION:-local}
container_name: fonu container_name: havline
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- updater - updater
@@ -25,18 +25,18 @@ services:
- "18080:80" - "18080:80"
- "9443:443" - "9443:443"
volumes: volumes:
# 数据目录:默认用命名卷 fonu-data(由 Docker 管理,位置随 Docker 数据根)。 # 数据目录:默认用命名卷 havline-data(由 Docker 管理,位置随 Docker 数据根)。
# 想落到指定目录就在同层 .env 里设 FONU_DATA_PATH=/vol1/docker/fonu-data —— # 想落到指定目录就在同层 .env 里设 HAVLINE_DATA_PATH=/vol1/docker/havline-data ——
# 以 / 或 . 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷。 # 以 / 或 . 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷。
- ${FONU_DATA_PATH:-fonu-data}:/data - ${HAVLINE_DATA_PATH:-havline-data}:/data
- fonu-updater-sock:/run/fonu-updater - havline-updater-sock:/run/havline-updater
environment: environment:
FONU_DATA_DIR: /data HAVLINE_DATA_DIR: /data
FONU_LISTEN: :6893 HAVLINE_LISTEN: :6893
FONU_UPDATER_SOCKET: /run/fonu-updater/updater.sock HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600) # 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
# 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换 # 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换
FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-} HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-}
# 时区:通知消息时间按此显示(默认东八区) # 时区:通知消息时间按此显示(默认东八区)
TZ: ${TZ:-Asia/Shanghai} TZ: ${TZ:-Asia/Shanghai}
@@ -49,26 +49,26 @@ 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}
UPDATER_IMAGE: ${UPDATER_IMAGE:-docker.m.daocloud.io/library/docker:27-cli} 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-updater:${FONU_VERSION:-local} image: havline-updater:${HAVLINE_VERSION:-local}
container_name: fonu-updater container_name: havline-updater
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ${DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock - ${DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock
- ${FONU_UPDATE_HOST_DIR:-.}:/workspace - ${HAVLINE_UPDATE_HOST_DIR:-.}:/workspace
- fonu-updater-sock:/run/fonu-updater - havline-updater-sock:/run/havline-updater
environment: environment:
FONU_UPDATER_SOCKET: /run/fonu-updater/updater.sock HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
FONU_UPDATE_PROJECT_DIR: /workspace HAVLINE_UPDATE_PROJECT_DIR: /workspace
FONU_UPDATE_COMPOSE_FILE: docker-compose.yml HAVLINE_UPDATE_COMPOSE_FILE: docker-compose.yml
FONU_UPDATE_SERVICE: fonu HAVLINE_UPDATE_SERVICE: havline
FONU_UPDATE_MODE: local HAVLINE_UPDATE_MODE: local
FONU_UPDATE_APP_URL: http://fonu:6893 HAVLINE_UPDATE_APP_URL: http://havline:6893
FONU_UPDATE_HOST_DIR: ${FONU_UPDATE_HOST_DIR:-} HAVLINE_UPDATE_HOST_DIR: ${HAVLINE_UPDATE_HOST_DIR:-}
FONU_UPDATE_PROJECT_NAME: ${FONU_UPDATE_PROJECT_NAME:-fonu} HAVLINE_UPDATE_PROJECT_NAME: ${HAVLINE_UPDATE_PROJECT_NAME:-havline}
FONU_UPDATE_SELF_IMAGE: fonu-updater:${FONU_VERSION:-local} HAVLINE_UPDATE_SELF_IMAGE: havline-updater:${HAVLINE_VERSION:-local}
FONU_UPDATE_DOCKER_SOCK: ${DOCKER_SOCK:-/var/run/docker.sock} HAVLINE_UPDATE_DOCKER_SOCK: ${DOCKER_SOCK:-/var/run/docker.sock}
TZ: ${TZ:-Asia/Shanghai} TZ: ${TZ:-Asia/Shanghai}
volumes: volumes:
fonu-data: havline-data:
fonu-updater-sock: havline-updater-sock:
+56 -56
View File
@@ -1,15 +1,15 @@
# Fonu 部署安装文档 # Havline 部署安装文档
> 适用版本:v1.1.0(源码包 `fonu-1.1.0-src.zip`)。本文覆盖从拿到安装包到服务上线的完整流程:环境要求 → 打包 → 三种部署方式 → 首次配置 → FRP/DDNS/证书联动 → 升级与回滚 → 故障排查。 > 适用版本:v1.1.0(源码包 `havline-1.1.0-src.zip`)。本文覆盖从拿到安装包到服务上线的完整流程:环境要求 → 打包 → 三种部署方式 → 首次配置 → FRP/DDNS/证书联动 → 升级与回滚 → 故障排查。
> 功能细节与组合场景请配合项目内两份文档:`README.md`(功能总览)与 FRP 页面右上「使用说明」(内置十个组合场景详解)。 > 功能细节与组合场景请配合项目内两份文档:`README.md`(功能总览)与 FRP 页面右上「使用说明」(内置十个组合场景详解)。
## 0. 交付物清单与安装路径速查 ## 0. 交付物清单与安装路径速查
| 交付物 | 内容 | 适用 | 安装方式 | | 交付物 | 内容 | 适用 | 安装方式 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `fonu-1.1.0-linux-amd64.tar.gz`(14MB) | 编译好的 Linux 二进制(前端已内嵌)+ migrations + systemd + 一键脚本 | **裸机 Linux 服务器**,目标机只需系统 nginx | 解压 → `sudo bash install.sh`(见 3.4) | | `havline-1.1.0-linux-amd64.tar.gz`(14MB) | 编译好的 Linux 二进制(前端已内嵌)+ migrations + systemd + 一键脚本 | **裸机 Linux 服务器**,目标机只需系统 nginx | 解压 → `sudo bash install.sh`(见 3.4) |
| `fonu-1.1.0-image.tar.gz`(约 100-150MB) | 构建好的 Docker 镜像 `fonu:local`(含 nginx + frpc) | 目标机有 Docker、要离线安装 | `docker load` → `docker run`(见 3.3 方式 C) | | `havline-1.1.0-image.tar.gz`(约 100-150MB) | 构建好的 Docker 镜像 `havline:local`(含 nginx + frpc) | 目标机有 Docker、要离线安装 | `docker load` → `docker run`(见 3.3 方式 C) |
| `fonu-1.1.0-src.zip`(11MB) | 完整源码 + Dockerfile/compose + 部署文档 | 要自行构建 / 二次开发 / 交叉编译 | `docker compose build` 后按方式 A/B 部署(见第 2 节) | | `havline-1.1.0-src.zip`(11MB) | 完整源码 + Dockerfile/compose + 部署文档 | 要自行构建 / 二次开发 / 交叉编译 | `docker compose build` 后按方式 A/B 部署(见第 2 节) |
**怎么选**:飞牛 fnOS 首选镜像包导入(图形界面,见第 3 节末「飞牛 fnOS 部署详解」),要在 NAS 上构建则用源码包 + host 模式 compose;普通 Linux 服务器要省事用 Linux 运行包(免 Docker);目标机不方便联网构建用镜像包离线导入。三者的功能完全一致——容器里跑的也是同一份二进制 + 系统 nginx。 **怎么选**:飞牛 fnOS 首选镜像包导入(图形界面,见第 3 节末「飞牛 fnOS 部署详解」),要在 NAS 上构建则用源码包 + host 模式 compose;普通 Linux 服务器要省事用 Linux 运行包(免 Docker);目标机不方便联网构建用镜像包离线导入。三者的功能完全一致——容器里跑的也是同一份二进制 + 系统 nginx。
@@ -17,7 +17,7 @@
### 1.1 源码包内容 ### 1.1 源码包内容
`fonu-1.1.0-src.zip`(约 11MB,304 项)为完整可构建源码: `havline-1.1.0-src.zip`(约 11MB,304 项)为完整可构建源码:
| 目录/文件 | 说明 | | 目录/文件 | 说明 |
| --- | --- | | --- | --- |
@@ -50,7 +50,7 @@
docker compose build docker compose build
``` ```
一次构建同时产出:前端 `dist`、Go 后端二进制与运行镜像(frpc 不预装,运行期用「应用配置」按需下载),标签为 `fonu:local`。 一次构建同时产出:前端 `dist`、Go 后端二进制与运行镜像(frpc 不预装,运行期用「应用配置」按需下载),标签为 `havline:local`。
**版本号**:读取根目录 `VERSION` 文件(当前 `1.1.0`)自动注入前后端显示;要改版本号只需编辑该文件后重新 build。 **版本号**:读取根目录 `VERSION` 文件(当前 `1.1.0`)自动注入前后端显示;要改版本号只需编辑该文件后重新 build。
@@ -61,49 +61,49 @@ docker build \
--build-arg NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine \ --build-arg NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine \
--build-arg GO_IMAGE=docker.m.daocloud.io/library/golang:1.23-bookworm \ --build-arg GO_IMAGE=docker.m.daocloud.io/library/golang:1.23-bookworm \
--build-arg RUNTIME_IMAGE=docker.m.daocloud.io/library/debian:bookworm-slim \ --build-arg RUNTIME_IMAGE=docker.m.daocloud.io/library/debian:bookworm-slim \
-t fonu:local . -t havline:local .
``` ```
## 3. 三种部署方式(按环境二选一) ## 3. 三种部署方式(按环境二选一)
### 方式 A:飞牛 fnOS / 已占用 80/443 的环境 —— host 模式(推荐) ### 方式 A:飞牛 fnOS / 已占用 80/443 的环境 —— host 模式(推荐)
飞牛系统自带 Nginx 占用 80/443,Fonu 使用 host 网络 + 高位反代端口避免冲突。 飞牛系统自带 Nginx 占用 80/443,Havline 使用 host 网络 + 高位反代端口避免冲突。
```bash ```bash
FONU_SESSION_SECRET=请改成一串随机字符 docker compose -f docker-compose.host.yml up -d --build HAVLINE_SESSION_SECRET=请改成一串随机字符 docker compose -f docker-compose.host.yml up -d --build
``` ```
- 无端口映射,容器直接使用宿主机网络栈; - 无端口映射,容器直接使用宿主机网络栈;
- 反代端口由环境变量 `FONU_NGINX_HTTP_PORT=18080`、`FONU_NGINX_HTTPS_PORT=9443` 决定(文件内已内置); - 反代端口由环境变量 `HAVLINE_NGINX_HTTP_PORT=18080`、`HAVLINE_NGINX_HTTPS_PORT=9443` 决定(文件内已内置);
- 入口:管理后台 `http://<NAS-IP>:6893`,HTTP 反代 `http://<NAS-IP>:18080`,HTTPS 反代 `https://<NAS-IP>:9443`; - 入口:管理后台 `http://<NAS-IP>:6893`,HTTP 反代 `http://<NAS-IP>:18080`,HTTPS 反代 `https://<NAS-IP>:9443`;
- 数据持久化在命名卷 `fonu-data`(挂载到容器 `/data`)。 - 数据持久化在命名卷 `havline-data`(挂载到容器 `/data`)。
### 方式 B:其他环境 / 空闲 80/443 —— Bridge 端口映射 ### 方式 B:其他环境 / 空闲 80/443 —— Bridge 端口映射
```bash ```bash
FONU_SESSION_SECRET=请改成一串随机字符 docker compose -f docker-compose.hub.yml up -d --build HAVLINE_SESSION_SECRET=请改成一串随机字符 docker compose -f docker-compose.hub.yml up -d --build
``` ```
- 端口映射 `6893:6893`、`18080:80`、`9443:443`(宿主机 18080/9443 → 容器 80/443); - 端口映射 `6893:6893`、`18080:80`、`9443:443`(宿主机 18080/9443 → 容器 80/443);
- 数据同样在命名卷 `fonu-data`。 - 数据同样在命名卷 `havline-data`。
### 方式 C:docker run(不想用 compose) ### 方式 C:docker run(不想用 compose)
```bash ```bash
docker run -d --name fonu \ docker run -d --name havline \
--net=host \ --net=host \
-v ./data:/data \ -v ./data:/data \
-e FONU_SESSION_SECRET=请改成一串随机字符 \ -e HAVLINE_SESSION_SECRET=请改成一串随机字符 \
--restart unless-stopped \ --restart unless-stopped \
fonu:local havline:local
``` ```
host 模式下反代端口用环境变量控制:`-e FONU_NGINX_HTTP_PORT=18080 -e FONU_NGINX_HTTPS_PORT=9443`。Bridge 环境改为 `-p 6893:6893 -p 18080:80 -p 9443:443`(去掉 `--net=host`)。 host 模式下反代端口用环境变量控制:`-e HAVLINE_NGINX_HTTP_PORT=18080 -e HAVLINE_NGINX_HTTPS_PORT=9443`。Bridge 环境改为 `-p 6893:6893 -p 18080:80 -p 9443:443`(去掉 `--net=host`)。
**生成随机 SESSION_SECRET**:`openssl rand -hex 32`(Linux)或 PowerShell:`-join ((48..57)+(97..122) | Get-Random -Count 32 | % {[char]$_})`。 **生成随机 SESSION_SECRET**:`openssl rand -hex 32`(Linux)或 PowerShell:`-join ((48..57)+(97..122) | Get-Random -Count 32 | % {[char]$_})`。
> **`FONU_SESSION_SECRET` 是什么**:Fonu 的主密钥,两个用途——① 签名/加密登录会话 Cookie(决定登录态是否可被伪造);② 作为 `secret.Box` 加密存储中敏感凭据(DDNS API Token、FRP Token、SMTP 密码等)。默认值 `change-me-in-production` 是公开的:拿到它即可伪造管理员会话、解密数据库里的全部凭据,**生产环境必须替换**。⚠️ 换密钥的时机:首次启动前设置;若之后更换,旧密钥加密的凭据将无法解密(应用会提示重新保存),需到对应页面重录 Token。 > **`HAVLINE_SESSION_SECRET` 是什么**:Havline 的主密钥,两个用途——① 签名/加密登录会话 Cookie(决定登录态是否可被伪造);② 作为 `secret.Box` 加密存储中敏感凭据(DDNS API Token、FRP Token、SMTP 密码等)。默认值 `change-me-in-production` 是公开的:拿到它即可伪造管理员会话、解密数据库里的全部凭据,**生产环境必须替换**。⚠️ 换密钥的时机:首次启动前设置;若之后更换,旧密钥加密的凭据将无法解密(应用会提示重新保存),需到对应页面重录 Token。
### Linux 服务器完整部署步骤(Ubuntu / Debian / CentOS) ### Linux 服务器完整部署步骤(Ubuntu / Debian / CentOS)
@@ -113,13 +113,13 @@ curl -fsSL https://get.docker.com | sh
sudo systemctl enable --now docker sudo systemctl enable --now docker
# 1. 解压源码包并进入目录 # 1. 解压源码包并进入目录
unzip fonu-1.1.0-src.zip -d fonu && cd fonu unzip havline-1.1.0-src.zip -d havline && cd havline
# 2. 构建镜像(约 3-10 分钟,取决于网络与机器性能) # 2. 构建镜像(约 3-10 分钟,取决于网络与机器性能)
docker compose build docker compose build
# 3. 生成密钥并启动(Bridge 模式,映射 6893/18080/9443) # 3. 生成密钥并启动(Bridge 模式,映射 6893/18080/9443)
echo "FONU_SESSION_SECRET=$(openssl rand -hex 32)" > .env echo "HAVLINE_SESSION_SECRET=$(openssl rand -hex 32)" > .env
docker compose -f docker-compose.hub.yml up -d docker compose -f docker-compose.hub.yml up -d
# 4. 防火墙放行(按需) # 4. 防火墙放行(按需)
@@ -128,48 +128,48 @@ sudo ufw allow 80/tcp 443/tcp # 反代(Bridge 模式容器内监听 80/443
# firewalld 用: firewall-cmd --permanent --add-port=6893/tcp && firewall-cmd --reload # firewalld 用: firewall-cmd --permanent --add-port=6893/tcp && firewall-cmd --reload
# 5. 验证 # 5. 验证
docker logs fonu | grep -i password # 取 admin 初始密码 docker logs havline | grep -i password # 取 admin 初始密码
curl -I http://127.0.0.1:6893/login # 应返回 200 curl -I http://127.0.0.1:6893/login # 应返回 200
``` ```
- **开机自启**:compose 内已含 `restart: unless-stopped`,且第 0 步已 `enable docker`,重启服务器自动拉起; - **开机自启**:compose 内已含 `restart: unless-stopped`,且第 0 步已 `enable docker`,重启服务器自动拉起;
- **80/443 被系统 Nginx 占用**:改用 `docker-compose.host.yml`(host 网络 + 18080/9443 高位端口),或改 `docker-compose.hub.yml` 里的宿主端口映射; - **80/443 被系统 Nginx 占用**:改用 `docker-compose.host.yml`(host 网络 + 18080/9443 高位端口),或改 `docker-compose.hub.yml` 里的宿主端口映射;
- **离线部署**(目标机不方便联网构建):在有网的机器上 `docker compose build` 后 `docker save fonu:local | gzip > fonu.tar.gz`,拷贝到目标机 `docker load < fonu.tar.gz`,再用方式 C 的 `docker run` 启动(无需再构建); - **离线部署**(目标机不方便联网构建):在有网的机器上 `docker compose build` 后 `docker save havline:local | gzip > havline.tar.gz`,拷贝到目标机 `docker load < havline.tar.gz`,再用方式 C 的 `docker run` 启动(无需再构建);
- **数据位置**:命名卷 `fonu-data`(`docker volume inspect fonu-data` 查看宿主机实际路径),备份该卷即备份全部配置与证书。 - **数据位置**:命名卷 `havline-data`(`docker volume inspect havline-data` 查看宿主机实际路径),备份该卷即备份全部配置与证书。
### Linux 运行包安装(免 Docker,裸机部署) ### Linux 运行包安装(免 Docker,裸机部署)
适用:不想装 Docker 的 Linux 服务器。前提:系统已装 nginx(Fonu 复用它做反代),root 权限。 适用:不想装 Docker 的 Linux 服务器。前提:系统已装 nginx(Havline 复用它做反代),root 权限。
```bash ```bash
# 1. 解压 # 1. 解压
tar xzf fonu-1.1.0-linux-amd64.tar.gz -C fonu && cd fonu tar xzf havline-1.1.0-linux-amd64.tar.gz -C havline && cd havline
# 2. 一键安装(自动建 fonu 系统用户、装到 /opt/fonu、生成随机密钥、注册 systemd 开机自启) # 2. 一键安装(自动建 havline 系统用户、装到 /opt/havline、生成随机密钥、注册 systemd 开机自启)
sudo bash install.sh sudo bash install.sh
# 3. 验证 # 3. 验证
systemctl status fonu systemctl status havline
curl -I http://127.0.0.1:6893/login curl -I http://127.0.0.1:6893/login
journalctl -u fonu | grep -i password # 取 admin 初始密码 journalctl -u havline | grep -i password # 取 admin 初始密码
``` ```
包内容:`fonu` 二进制(前端已 `go:embed` 内嵌,无需单独部署 dist)、`migrations/`(从二进制同级目录自动查找)、`deploy/nginx/mime.types`、`fonu.service`(systemd 模板)、`install.sh`、`DEPLOYMENT.md`。 包内容:`havline` 二进制(前端已 `go:embed` 内嵌,无需单独部署 dist)、`migrations/`(从二进制同级目录自动查找)、`deploy/nginx/mime.types`、`havline.service`(systemd 模板)、`install.sh`、`DEPLOYMENT.md`。
关键路径(install.sh 自动配置): 关键路径(install.sh 自动配置):
| 项 | 值 | | 项 | 值 |
| --- | --- | | --- | --- |
| 二进制 | `/opt/fonu/fonu` | | 二进制 | `/opt/havline/havline` |
| 数据目录 | `/var/lib/fonu`(数据库、证书、日志全在这) | | 数据目录 | `/var/lib/havline`(数据库、证书、日志全在这) |
| 会话密钥 | `/etc/default/fonu`(重复安装保留原值) | | 会话密钥 | `/etc/default/havline`(重复安装保留原值) |
| 服务 | `systemctl restart fonu` 生效配置改动 | | 服务 | `systemctl restart havline` 生效配置改动 |
**备份/迁移**:停服务后打包 `/var/lib/fonu` 即可(`tar czf fonu-data.tar.gz /var/lib/fonu`),新机器解压到同路径 + 装同版本二进制即完成迁移。 **备份/迁移**:停服务后打包 `/var/lib/havline` 即可(`tar czf havline-data.tar.gz /var/lib/havline`),新机器解压到同路径 + 装同版本二进制即完成迁移。
**手动安装**(不用脚本):按包内 `README.txt` 要点——建 `fonu` 系统用户、复制二进制与 migrations 到 `/opt/fonu`、`EnvironmentFile` 写入随机密钥、`systemctl enable --now fonu`。 **手动安装**(不用脚本):按包内 `README.txt` 要点——建 `havline` 系统用户、复制二进制与 migrations 到 `/opt/havline`、`EnvironmentFile` 写入随机密钥、`systemctl enable --now havline`。
> 镜像包(`fonu-1.1.0-image.tar.gz`)来源:在任意装了 Docker 的机器上对源码包执行 `docker compose build` 后 `docker save fonu:local | gzip > fonu-1.1.0-image.tar.gz`;目标机 `docker load < fonu-1.1.0-image.tar.gz` 后按方式 C 启动。 > 镜像包(`havline-1.1.0-image.tar.gz`)来源:在任意装了 Docker 的机器上对源码包执行 `docker compose build` 后 `docker save havline:local | gzip > havline-1.1.0-image.tar.gz`;目标机 `docker load < havline-1.1.0-image.tar.gz` 后按方式 C 启动。
### 飞牛 fnOS 部署详解(图形界面,不敲命令) ### 飞牛 fnOS 部署详解(图形界面,不敲命令)
@@ -177,17 +177,17 @@ journalctl -u fonu | grep -i password # 取 admin 初始密码
**方式一:导入现成镜像(推荐,5 分钟)** **方式一:导入现成镜像(推荐,5 分钟)**
1. **上传文件**:把 `fonu-1.1.0-image.tar.gz` 通过文件管理 / SMB 上传到 NAS,如共享目录 `docker/fonu/`; 1. **上传文件**:把 `havline-1.1.0-image.tar.gz` 通过文件管理 / SMB 上传到 NAS,如共享目录 `docker/havline/`;
2. **导入镜像**:打开 Docker 应用 → 镜像 → 导入(从文件加载)→ 选择该 tar.gz → 等待镜像列表出现 `fonu:local`(若界面只接受 .tar,先在电脑上解压出内层的 .tar 再导入); 2. **导入镜像**:打开 Docker 应用 → 镜像 → 导入(从文件加载)→ 选择该 tar.gz → 等待镜像列表出现 `havline:local`(若界面只接受 .tar,先在电脑上解压出内层的 .tar 再导入);
3. **创建容器**:容器 → 创建: 3. **创建容器**:容器 → 创建:
| 配置项 | 填写 | | 配置项 | 填写 |
| --- | --- | | --- | --- |
| 名称 | `fonu` | | 名称 | `havline` |
| 镜像 | `fonu:local` | | 镜像 | `havline:local` |
| 网络 | **host**(飞牛 80/443 被系统占用,host + 高位端口最省事;UI 不支持 host 时选 Bridge 并映射 6893/18080/9443) | | 网络 | **host**(飞牛 80/443 被系统占用,host + 高位端口最省事;UI 不支持 host 时选 Bridge 并映射 6893/18080/9443) |
| 存储挂载 | NAS 建目录(如 `/vol1/…/docker/fonu/data`)→ 映射到容器 `/data`(读写) | | 存储挂载 | NAS 建目录(如 `/vol1/…/docker/havline/data`)→ 映射到容器 `/data`(读写) |
| 环境变量 | `FONU_SESSION_SECRET=随机串`、`FONU_DATA_DIR=/data`、`FONU_LISTEN=:6893`、`FONU_NGINX_HTTP_PORT=18080`、`FONU_NGINX_HTTPS_PORT=9443` | | 环境变量 | `HAVLINE_SESSION_SECRET=随机串`、`HAVLINE_DATA_DIR=/data`、`HAVLINE_LISTEN=:6893`、`HAVLINE_NGINX_HTTP_PORT=18080`、`HAVLINE_NGINX_HTTPS_PORT=9443` |
| 重启策略 | 自动重启 / unless-stopped(开机自启) | | 重启策略 | 自动重启 / unless-stopped(开机自启) |
4. **启动并取初始密码**:容器详情 → 日志,搜 `password`;浏览器打开 `http://<NAS-IP>:6893` 登录。 4. **启动并取初始密码**:容器详情 → 日志,搜 `password`;浏览器打开 `http://<NAS-IP>:6893` 登录。
@@ -196,14 +196,14 @@ journalctl -u fonu | grep -i password # 取 admin 初始密码
1. 把源码包 zip 上传到 NAS 并解压(文件管理支持解压,或电脑解压后整目录上传); 1. 把源码包 zip 上传到 NAS 并解压(文件管理支持解压,或电脑解压后整目录上传);
2. Docker 应用 → 项目(Compose)→ 新建:指定项目目录为解压后的源码目录、使用 `docker-compose.host.yml`(或直接把该文件内容粘贴到 Compose 编辑器); 2. Docker 应用 → 项目(Compose)→ 新建:指定项目目录为解压后的源码目录、使用 `docker-compose.host.yml`(或直接把该文件内容粘贴到 Compose 编辑器);
3. 在源码目录同层放一个 `.env` 文件,内容一行:`FONU_SESSION_SECRET=随机串`; 3. 在源码目录同层放一个 `.env` 文件,内容一行:`HAVLINE_SESSION_SECRET=随机串`;
4. 构建并启动。⚠️ NAS 上构建耗时较长(前端编译),低配机器建议改用方式一; 4. 构建并启动。⚠️ NAS 上构建耗时较长(前端编译),低配机器建议改用方式一;
5. 后续升级:上传新源码覆盖 → 项目重新构建即可,数据在卷里不受影响。 5. 后续升级:上传新源码覆盖 → 项目重新构建即可,数据在卷里不受影响。
**Compose 构建报 401(docker.fnnas.com)**:fnOS 的 Docker 设置里默认配了自家加速地址,但它对 `library/` 官方镜像拒绝匿名拉取。三种解法任选: **Compose 构建报 401(docker.fnnas.com)**:fnOS 的 Docker 设置里默认配了自家加速地址,但它对 `library/` 官方镜像拒绝匿名拉取。三种解法任选:
1. **改用镜像包(最省事)**:跳过构建,按方式一导入 `fonu-1.1.0-image.tar.gz`; 1. **改用镜像包(最省事)**:跳过构建,按方式一导入 `havline-1.1.0-image.tar.gz`;
2. **覆盖基础镜像源**:在源码目录的 `.env` 里加三行(已有 FONU_SESSION_SECRET 就追加在下面): 2. **覆盖基础镜像源**:在源码目录的 `.env` 里加三行(已有 HAVLINE_SESSION_SECRET 就追加在下面):
``` ```
NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine
GO_IMAGE=docker.m.daocloud.io/library/golang:1.23-bookworm GO_IMAGE=docker.m.daocloud.io/library/golang:1.23-bookworm
@@ -224,10 +224,10 @@ journalctl -u fonu | grep -i password # 取 admin 初始密码
## 4. 首次登录与安全配置 ## 4. 首次登录与安全配置
1. 启动后查看初始密码:`docker logs fonu | grep -i password`(自动生成的 admin 初始密码会打印在日志里); 1. 启动后查看初始密码:`docker logs havline | grep -i password`(自动生成的 admin 初始密码会打印在日志里);
2. 浏览器打开 `http://<NAS-IP>:6893/login`,用 `admin` + 初始密码登录; 2. 浏览器打开 `http://<NAS-IP>:6893/login`,用 `admin` + 初始密码登录;
3. **立即**在「设置 → 安全」修改管理员密码; 3. **立即**在「设置 → 安全」修改管理员密码;
4. 确认「设置 → 常规」中 `FONU_SESSION_SECRET` 已改为非默认值(compose 文件中已传入则跳过); 4. 确认「设置 → 常规」中 `HAVLINE_SESSION_SECRET` 已改为非默认值(compose 文件中已传入则跳过);
5. 建议在「设置 → 通知」配置一种告警方式(邮件 / Webhook / Telegram),DDNS 失败、证书续签失败、登录异常等事件会推送。 5. 建议在「设置 → 通知」配置一种告警方式(邮件 / Webhook / Telegram),DDNS 失败、证书续签失败、登录异常等事件会推送。
## 5. 反向代理与 HTTPS 证书 ## 5. 反向代理与 HTTPS 证书
@@ -254,7 +254,7 @@ DDNS 页 → 添加任务:选服务商 → 填 API 凭证(可「测试连接
前置:一台有公网 IP 的 VPS。 前置:一台有公网 IP 的 VPS。
1. **Fonu 添加服务端**:FRP 穿透页 → 添加服务端 → 填 VPS 地址、bindPort(默认 7000)、认证方式与 Token(与 frps 一致); 1. **Havline 添加服务端**:FRP 穿透页 → 添加服务端 → 填 VPS 地址、bindPort(默认 7000)、认证方式与 Token(与 frps 一致);
2. **生成 frps 配置**:服务端「详情」→「frps.toml」→ 复制生成内容到 VPS 保存为 `frps.toml`,执行 `frps -c frps.toml`;按文件末尾的防火墙清单在 VPS 安全组放行端口; 2. **生成 frps 配置**:服务端「详情」→「frps.toml」→ 复制生成内容到 VPS 保存为 `frps.toml`,执行 `frps -c frps.toml`;按文件末尾的防火墙清单在 VPS 安全组放行端口;
3. **添加穿透规则**:8 种类型按需选——tcp/udp 填远程端口;http/https/tcpmux 填自定义域名(走 frps vhost 端口);stcp/sudp/xtcp 凭 secretKey(无公网监听); 3. **添加穿透规则**:8 种类型按需选——tcp/udp 填远程端口;http/https/tcpmux 填自定义域名(走 frps vhost 端口);stcp/sudp/xtcp 凭 secretKey(无公网监听);
4. **应用并验证**:「应用配置」→「校验并应用配置」;服务端「详情」看五项诊断(TCP 可达 / frpc 登录 / 代理可达 / DNS 解析 / 归属地)与独立日志。 4. **应用并验证**:「应用配置」→「校验并应用配置」;服务端「详情」看五项诊断(TCP 可达 / frpc 登录 / 代理可达 / DNS 解析 / 归属地)与独立日志。
@@ -265,12 +265,12 @@ DDNS 页 → 添加任务:选服务商 → 填 API 凭证(可「测试连接
```bash ```bash
# 1. 备份数据(最重要) # 1. 备份数据(最重要)
docker run --rm -v fonu-data:/data -v ${PWD}:/backup alpine tar czf /backup/fonu-data-backup.tar.gz /data docker run --rm -v havline-data:/data -v ${PWD}:/backup alpine tar czf /backup/havline-data-backup.tar.gz /data
# 2. 解压新版源码包覆盖项目目录(VERSION 与 migrations 会更新) # 2. 解压新版源码包覆盖项目目录(VERSION 与 migrations 会更新)
# 3. 重建并启动(迁移 015–022 会在启动时自动应用) # 3. 重建并启动(迁移 015–022 会在启动时自动应用)
docker compose -f docker-compose.host.yml up -d --build docker compose -f docker-compose.host.yml up -d --build
# 4. 看启动日志确认迁移与版本 # 4. 看启动日志确认迁移与版本
docker logs -f fonu docker logs -f havline
``` ```
### 回滚 ### 回滚
@@ -288,18 +288,18 @@ docker compose -f docker-compose.host.yml up -d --build
| 现象 | 处理 | | 现象 | 处理 |
| --- | --- | | --- | --- |
| 容器起来但 6893 打不开 | `docker logs fonu` 看启动报错;`docker port fonu`(Bridge 模式)确认映射 | | 容器起来但 6893 打不开 | `docker logs havline` 看启动报错;`docker port havline`(Bridge 模式)确认映射 |
| 提示「会话加密密钥」警告 | 未设置 `FONU_SESSION_SECRET`,compose 传入即可 | | 提示「会话加密密钥」警告 | 未设置 `HAVLINE_SESSION_SECRET`,compose 传入即可 |
| 反代 502 | 目标内网应用未启动或地址填错;日志中心看 Nginx error 日志 | | 反代 502 | 目标内网应用未启动或地址填错;日志中心看 Nginx error 日志 |
| 证书申请失败 | DDNS 凭证是否有效、域名 DNS 是否已生效、ACME 邮箱是否已设置 | | 证书申请失败 | DDNS 凭证是否有效、域名 DNS 是否已生效、ACME 邮箱是否已设置 |
| frpc 连不上 frps | 两端 Token/端口不一致;用服务端「详情 → frps.toml」重新生成对齐 | | frpc 连不上 frps | 两端 Token/端口不一致;用服务端「详情 → frps.toml」重新生成对齐 |
| 穿透流量显示 0 | frps 未配置 webServer 或 Fonu 服务端表单未填管理地址(非必须功能) | | 穿透流量显示 0 | frps 未配置 webServer 或 Havline 服务端表单未填管理地址(非必须功能) |
| 页面改动未生效 | 确认访问的是容器端口而非开发服务器;`docker compose up -d --build` 重建 | | 页面改动未生效 | 确认访问的是容器端口而非开发服务器;`docker compose up -d --build` 重建 |
## 10. 卸载 ## 10. 卸载
```bash ```bash
docker compose down # 停止并删除容器(数据卷保留) docker compose down # 停止并删除容器(数据卷保留)
docker volume rm fonu-data # 彻底删除数据(不可逆,先备份) docker volume rm havline-data # 彻底删除数据(不可逆,先备份)
docker rmi fonu:local # 删除镜像 docker rmi havline:local # 删除镜像
``` ```
+24 -24
View File
@@ -1,7 +1,7 @@
# 内网应用经 FRP 暴露公网:NAS 侧完整操作手册 # 内网应用经 FRP 暴露公网:NAS 侧完整操作手册
> 场景:把 NAS 内网应用(本例 `192.168.31.152:7023`)通过一台公网 VPS(`38.95.74.87`)以域名(`tv.36800.cc`)暴露出去,URL 不带端口号,且后续域名 / HTTPS / 访问控制全部在 NAS 的 Fonu 网页里管理,VPS 只做纯流量转发。 > 场景:把 NAS 内网应用(本例 `192.168.31.152:7023`)通过一台公网 VPS(`38.95.74.87`)以域名(`tv.36800.cc`)暴露出去,URL 不带端口号,且后续域名 / HTTPS / 访问控制全部在 NAS 的 Havline 网页里管理,VPS 只做纯流量转发。
> 适用版本:Fonu v1.1.0+(FRP 多服务端模块)。VPS 上 **不需要也不应该** 装 Nginx 反代。 > 适用版本:Havline v1.1.0+(FRP 多服务端模块)。VPS 上 **不需要也不应该** 装 Nginx 反代。
## 0. 最终架构与访问地址 ## 0. 最终架构与访问地址
@@ -12,7 +12,7 @@ VPS frps(监听 80 —— 由 frpc 动态注册的 tcp 隧道,VPS 上无需
↓ FRP 隧道(bindPort 7000,TLS) ↓ FRP 隧道(bindPort 7000,TLS)
NAS frpc NAS frpc
↓ 127.0.0.1:18080 ↓ 127.0.0.1:18080
Fonu Nginx(按域名分流,后续可加 HTTPS/黑白名单) Havline Nginx(按域名分流,后续可加 HTTPS/黑白名单)
↓ http://192.168.31.152:7023 ↓ http://192.168.31.152:7023
内网应用 内网应用
``` ```
@@ -23,17 +23,17 @@ Fonu Nginx(按域名分流,后续可加 HTTPS/黑白名单)
| --- | --- | --- | | --- | --- | --- |
| 公网 VPS | `38.95.74.87`(tv.36800.cc) | 只跑 frps,Nginx 停用 | | 公网 VPS | `38.95.74.87`(tv.36800.cc) | 只跑 frps,Nginx 停用 |
| NAS 内网应用 | `192.168.31.152:7023` | 真正的 Web 服务 | | NAS 内网应用 | `192.168.31.152:7023` | 真正的 Web 服务 |
| Fonu 管理后台 | `http://<NAS-IP>:6893` | 全部配置入口 | | Havline 管理后台 | `http://<NAS-IP>:6893` | 全部配置入口 |
## 1. 前置条件 ## 1. 前置条件
- VPS:已部署 frps(bindPort 7000、token 认证;参考 Fonu 服务端详情「frps.toml」一键生成); - VPS:已部署 frps(bindPort 7000、token 认证;参考 Havline 服务端详情「frps.toml」一键生成);
- VPS:frps 的 Nginx **未**占用 80(占用见第 5 节排障); - VPS:frps 的 Nginx **未**占用 80(占用见第 5 节排障);
- 云厂商安全组已放行:7000/tcp(frps 控制)、80/tcp(对外)、7500/tcp(frps 管理接口,可选,用于流量统计); - 云厂商安全组已放行:7000/tcp(frps 控制)、80/tcp(对外)、7500/tcp(frps 管理接口,可选,用于流量统计);
- NAS:Fonu 已部署并能打开管理后台; - NAS:Havline 已部署并能打开管理后台;
- 域名:`tv.36800.cc` 的 DNS A 记录指向 VPS IP(可用 Fonu DDNS 页「自定义 IP」固定,或由 DNS 服务商直接管理)。 - 域名:`tv.36800.cc` 的 DNS A 记录指向 VPS IP(可用 Havline DDNS 页「自定义 IP」固定,或由 DNS 服务商直接管理)。
## 2. Fonu 网页配置(三处) ## 2. Havline 网页配置(三处)
### 2.1 FRP 服务端 ### 2.1 FRP 服务端
@@ -57,9 +57,9 @@ Fonu Nginx(按域名分流,后续可加 HTTPS/黑白名单)
| --- | --- | --- | | --- | --- | --- |
| 所属服务端 | 上一步的服务端 | | | 所属服务端 | 上一步的服务端 | |
| 名称 | `tv`(frps 全局唯一,出现在日志里) | | | 名称 | `tv`(frps 全局唯一,出现在日志里) | |
| 类型 | **tcp** | ⚠️ 不选 http——80 直接由 frps 转发,域名分流交给 Fonu 反代 | | 类型 | **tcp** | ⚠️ 不选 http——80 直接由 frps 转发,域名分流交给 Havline 反代 |
| 远程端口 | **80** | 选 tcp 类型后出现此字段;frpc 登录时向 frps 动态注册 | | 远程端口 | **80** | 选 tcp 类型后出现此字段;frpc 登录时向 frps 动态注册 |
| 内网目标 | **127.0.0.1:18080** | 指向 Fonu 自己的 Nginx HTTP 监听(fnOS host 模式默认 18080) | | 内网目标 | **127.0.0.1:18080** | 指向 Havline 自己的 Nginx HTTP 监听(fnOS host 模式默认 18080) |
保存 → 右上 **「应用配置」→「校验并应用配置」**,或对该服务端「停止→启动」。 保存 → 右上 **「应用配置」→「校验并应用配置」**,或对该服务端「停止→启动」。
@@ -72,25 +72,25 @@ Fonu Nginx(按域名分流,后续可加 HTTPS/黑白名单)
| 前端域名 | `tv.36800.cc` | | 前端域名 | `tv.36800.cc` |
| 监听端口 | **18080**(与 2.2 的内网目标端口一致;fnOS 下不要用 80,系统 Nginx 已占用) | | 监听端口 | **18080**(与 2.2 的内网目标端口一致;fnOS 下不要用 80,系统 Nginx 已占用) |
| 目标地址 | `http://192.168.31.152:7023`(⚠️ 指向真正的内网应用,完整 URL 带协议与端口) | | 目标地址 | `http://192.168.31.152:7023`(⚠️ 指向真正的内网应用,完整 URL 带协议与端口) |
| HTTPS | 按需;fnOS 下 Fonu Nginx 的 HTTPS 监听默认 9443 | | HTTPS | 按需;fnOS 下 Havline Nginx 的 HTTPS 监听默认 9443 |
> ⚠️ **回环警告**:目标地址千万不要填 `http://tv.36800.cc:xxxx`——那会让请求从 NAS 出公网再回 NAS(NAS→VPS→NAS 绕圈)。反代目标是**内网应用本身**。 > ⚠️ **回环警告**:目标地址千万不要填 `http://tv.36800.cc:xxxx`——那会让请求从 NAS 出公网再回 NAS(NAS→VPS→NAS 绕圈)。反代目标是**内网应用本身**。
> ⚠️ **「目标地址缺少主机名」报错**:目标地址格式必须完整 `http://IP:端口`(协议+主机+端口缺一不可,单斜杠 `http:/` 或中文冒号都会报 400)。 > ⚠️ **「目标地址缺少主机名」报错**:目标地址格式必须完整 `http://IP:端口`(协议+主机+端口缺一不可,单斜杠 `http:/` 或中文冒号都会报 400)。
## 3. VPS 侧配置(一次性的两步) ## 3. VPS 侧配置(一次性的两步)
### 3.1 frps.toml(Fonu 一键生成) ### 3.1 frps.toml(Havline 一键生成)
Fonu 服务端「详情」→「frps.toml」→ 复制内容到 VPS 保存为 `frps.toml`: Havline 服务端「详情」→「frps.toml」→ 复制内容到 VPS 保存为 `frps.toml`:
```toml ```toml
bindAddr = "0.0.0.0" bindAddr = "0.0.0.0"
bindPort = 7000 bindPort = 7000
auth.method = "token" auth.method = "token"
auth.token = "<与 Fonu 一致>" auth.token = "<与 Havline 一致>"
webServer.addr = "0.0.0.0" # 可选:Fonu 拉取穿透流量 webServer.addr = "0.0.0.0" # 可选:Havline 拉取穿透流量
webServer.port = 7500 webServer.port = 7500
webServer.user = "admin" webServer.user = "admin"
webServer.password = "<自定义>" webServer.password = "<自定义>"
@@ -130,8 +130,8 @@ frpc 约每 33 秒自动重试注册,释放后无需任何手动操作,注
| 步骤 | 命令 / 操作 | 预期 | | 步骤 | 命令 / 操作 | 预期 |
| --- | --- | --- | | --- | --- | --- |
| VPS 80 已监听 | `ss -tlnp \| grep ':80 '` | 进程为 frps | | VPS 80 已监听 | `ss -tlnp \| grep ':80 '` | 进程为 frps |
| frpc 注册成功 | Fonu 服务端「日志」 | 无 `port unavailable`,有 proxy 启动记录 | | frpc 注册成功 | Havline 服务端「日志」 | 无 `port unavailable`,有 proxy 启动记录 |
| 隧道通 | Fonu 服务端「测速」/「详情」诊断 | TCP 可达、frpc 登录 ok、延迟正常 | | 隧道通 | Havline 服务端「测速」/「详情」诊断 | TCP 可达、frpc 登录 ok、延迟正常 |
| 分流通 | 服务端「详情」→ 诊断 `proxy_reachable` | ok | | 分流通 | 服务端「详情」→ 诊断 `proxy_reachable` | ok |
| 端到端 | 外网浏览器 `http://tv.36800.cc` | 直达内网应用页面 | | 端到端 | 外网浏览器 `http://tv.36800.cc` | 直达内网应用页面 |
@@ -142,10 +142,10 @@ frpc 约每 33 秒自动重试注册,释放后无需任何手动操作,注
| `Connection refused`(外部 curl 80) | **VPS 侧**:frps 没绑上 80 | ① `ss -tlnp \| grep ':80 '` 看 80 被谁占(多为 Nginx 没停)→ 停用;② `systemctl status frps` 确认在跑且用的新配置(nohup 手动跑的必须 `pkill frps` 重启);③ 重启后等 frpc 自动重试 | | `Connection refused`(外部 curl 80) | **VPS 侧**:frps 没绑上 80 | ① `ss -tlnp \| grep ':80 '` 看 80 被谁占(多为 Nginx 没停)→ 停用;② `systemctl status frps` 确认在跑且用的新配置(nohup 手动跑的必须 `pkill frps` 重启);③ 重启后等 frpc 自动重试 |
| frpc 日志 `start error: port unavailable` | 同上 | VPS 80 被占;见 3.2 | | frpc 日志 `start error: port unavailable` | 同上 | VPS 80 被占;见 3.2 |
| 诊断「frpc 登录」失败 | Token/端口不一致 | 两端 token 对齐;端口=frps bindPort | | 诊断「frpc 登录」失败 | Token/端口不一致 | 两端 token 对齐;端口=frps bindPort |
| 登录成功但 `proxy_reachable` 失败 | Fonu 反代 | 反代规则监听端口与 FRP 内网目标端口一致(18080);反代已启用 | | 登录成功但 `proxy_reachable` 失败 | Havline 反代 | 反代规则监听端口与 FRP 内网目标端口一致(18080);反代已启用 |
| 打开域名 404 | 域名分流失败 | 反代规则的前端域名与访问域名完全一致;frps 无需 vhost(tcp 方案) | | 打开域名 404 | 域名分流失败 | 反代规则的前端域名与访问域名完全一致;frps 无需 vhost(tcp 方案) |
| 域名解析不对 | DNS | `nslookup tv.36800.cc` 应返回 VPS IP;用 DDNS 自定义 IP 固定 | | 域名解析不对 | DNS | `nslookup tv.36800.cc` 应返回 VPS IP;用 DDNS 自定义 IP 固定 |
| 流量统计全 0 | frps webServer 未配 | 非必须;要在 Fonu 看流量就配 webServer 并在服务端表单填管理接口 | | 流量统计全 0 | frps webServer 未配 | 非必须;要在 Havline 看流量就配 webServer 并在服务端表单填管理接口 |
## 6. 两种方案对比(VPS 80 的归属) ## 6. 两种方案对比(VPS 80 的归属)
@@ -154,9 +154,9 @@ frpc 约每 33 秒自动重试注册,释放后无需任何手动操作,注
| FRP 规则类型 | tcp,远程端口 80 → 127.0.0.1:18080 | http 类型 + frps `vhostHTTPPort=8080` | | FRP 规则类型 | tcp,远程端口 80 → 127.0.0.1:18080 | http 类型 + frps `vhostHTTPPort=8080` |
| http 类型去端口 | —(tcp 方案直接占 80) | `vhostHTTPPort` 从 8080 改 **80**:URL 变 `http://域名`(无端口);前提同样是 VPS 停 Nginx 释放 80,改后 `pkill frps` 重启生效;NAS 侧规则无需动 | | http 类型去端口 | —(tcp 方案直接占 80) | `vhostHTTPPort` 从 8080 改 **80**:URL 变 `http://域名`(无端口);前提同样是 VPS 停 Nginx 释放 80,改后 `pkill frps` 重启生效;NAS 侧规则无需动 |
| VPS Nginx | 不需要(停用) | 必须:listen 80 → proxy_pass 127.0.0.1:8080(**必须带** `proxy_set_header Host $host`) | | VPS Nginx | 不需要(停用) | 必须:listen 80 → proxy_pass 127.0.0.1:8080(**必须带** `proxy_set_header Host $host`) |
| 加新域名 | Fonu 反代加一条 + FRP 加一条 tcp 规则(远程端口仍 80,内网目标同 18080) | VPS 上加 server 块 | | 加新域名 | Havline 反代加一条 + FRP 加一条 tcp 规则(远程端口仍 80,内网目标同 18080) | VPS 上加 server 块 |
| 上 HTTPS | Fonu 证书页(DNS-01)+ 反代开 HTTPS + FRP 加 tcp 443→127.0.0.1:9443 | VPS 上装证书 | | 上 HTTPS | Havline 证书页(DNS-01)+ 反代开 HTTPS + FRP 加 tcp 443→127.0.0.1:9443 | VPS 上装证书 |
| 访问控制(IP/BasicAuth) | Fonu 反代页直接配 | VPS 上手写 | | 访问控制(IP/BasicAuth) | Havline 反代页直接配 | VPS 上手写 |
| 限制 | VPS 80/443 不能再跑别的站点 | VPS 可共存多站点 | | 限制 | VPS 80/443 不能再跑别的站点 | VPS 可共存多站点 |
| URL | `http://tv.36800.cc`(无端口) | `http://tv.36800.cc`(80)或带 vhost 端口 | | URL | `http://tv.36800.cc`(无端口) | `http://tv.36800.cc`(80)或带 vhost 端口 |
@@ -164,7 +164,7 @@ frpc 约每 33 秒自动重试注册,释放后无需任何手动操作,注
## 7. HTTPS 升级路径(方案 A 延伸) ## 7. HTTPS 升级路径(方案 A 延伸)
1. **Fonu 证书页**:申请 `tv.36800.cc` 或 `*.36800.cc` 证书(ACME DNS-01,复用 DDNS 页服务商凭证); 1. **Havline 证书页**:申请 `tv.36800.cc` 或 `*.36800.cc` 证书(ACME DNS-01,复用 DDNS 页服务商凭证);
2. **反代规则**:开 HTTPS、绑定证书、(可选)HTTP 强制跳转; 2. **反代规则**:开 HTTPS、绑定证书、(可选)HTTP 强制跳转;
3. **FRP 加一条 tcp 规则**:远程端口 443 → 内网目标 `127.0.0.1:9443`(Fonu Nginx HTTPS 监听); 3. **FRP 加一条 tcp 规则**:远程端口 443 → 内网目标 `127.0.0.1:9443`(Havline Nginx HTTPS 监听);
4. 安全组放行 443;之后 `https://tv.36800.cc` 可用,TLS 全程在 NAS 侧终止。 4. 安全组放行 443;之后 `https://tv.36800.cc` 可用,TLS 全程在 NAS 侧终止。
+8 -7
View File
@@ -1,4 +1,4 @@
# Fonu 文档索引 # Havline 文档索引
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
> 最近更新:2026-10-01(v1.9.0) > 最近更新:2026-10-01(v1.9.0)
@@ -11,14 +11,15 @@
| `improvement-roadmap-2026-09-27.md` | P0-P3 改进路线实施方案:逐项改法、测试、验收、依赖、发布与回滚 | P0-1、P0-3、P0-4 已完成,其余待实施 | | `improvement-roadmap-2026-09-27.md` | P0-P3 改进路线实施方案:逐项改法、测试、验收、依赖、发布与回滚 | P0-1、P0-3、P0-4 已完成,其余待实施 |
| `system-upgrade.md` | Docker Compose 一键升级:sidecar 架构、首次部署、安全边界、回滚、故障排查与验证页 | 已实施(v1.3.8) | | `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 兼容清理待后续 | | `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-integration-plan.md` | Cloudflare Tunnel(cloudflared)与 Havline 的融合方案:托管模型、复用面、分阶段落地与 v2 优化 | 阶段 1-2 已实施(v1.6.0);后续按 `cloudflared-havline-fit-plan.md` 推进 |
| `cloudflared-feature-plan.md` | Cloudflare 隧道功能全集:官方最新文档、参考项目功能、Fonu 现状差距与 P0-P3 路线 | 查漏用;实施以 `cloudflared-fonu-fit-plan.md` 为准 | | `cloudflared-feature-plan.md` | Cloudflare 隧道功能全集:官方最新文档、参考项目功能、Havline 现状差距与 P0-P3 路线 | 查漏用;实施以 `cloudflared-havline-fit-plan.md` 为准 |
| `cloudflared-fonu-fit-plan.md` | Cloudflare 隧道 Fonu 适配版计划:出口抽象、规则同源、体系整合、明确不做项与阶段实施 | 阶段 1-3 主体已实施;剩余项见 `cloudflared-current-features.md` | | `cloudflared-havline-fit-plan.md` | Cloudflare 隧道 Havline 适配版计划:出口抽象、规则同源、体系整合、明确不做项与阶段实施 | 阶段 1-3 主体已实施;剩余项见 `cloudflared-current-features.md` |
| `cloudflared-current-features.md` | Cloudflare 隧道当前已实现功能说明:页面、出口、路由、DNS、监控、备份、诊断、API 与限制 | 当前版本功能基线 | | `cloudflared-current-features.md` | Cloudflare 隧道当前已实现功能说明:页面、出口、路由、DNS、监控、备份、诊断、API 与限制 | 当前版本功能基线 |
| `cloudflared-github-ux-research.md` | GitHub 同类项目调研:Cloudflare 管理面板、反向代理、访问平台、监控与流量检查器的可借鉴体验 | 调研与优化路线 | | `cloudflared-github-ux-research.md` | GitHub 同类项目调研:Cloudflare 管理面板、反向代理、访问平台、监控与流量检查器的可借鉴体验 | 调研与优化路线 |
| `fnos-app-packaging-plan.md` | 飞牛 fnOS 应用打包方案:官方文档依据、Docker 模板、向导、权限、数据迁移、升级与发布流程 | 方案待实施 |
| `project-audit-2026-09-24.md` | 全项目审查:P0/P1/P2 分级,含精确行号与逐条修法 | **进行中**——P0 已随 v1.2.7 完成,P1 实施中 | | `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 死样式清理未做 | | `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-capability-plan.md` | havline-agent 能力扩展(P0/P1/P2/P3) | 已实施(agent 0.11.0);P3 三项不做 |
| `agent-view-design.md` | 公网 Agent 页设计方案(Pass 1/2 流程) | 部分过期:顶部下拉已被左列表 + 右详情取代 | | `agent-view-design.md` | 公网 Agent 页设计方案(Pass 1/2 流程) | 部分过期:顶部下拉已被左列表 + 右详情取代 |
| `agent-view-design-v2.md` | 公网 Agent 页设计方案 v2 | 部分过期:同上 | | `agent-view-design-v2.md` | 公网 Agent 页设计方案 v2 | 部分过期:同上 |
| `agent-view-refactor-plan.md` | 公网 Agent 页四段式(状态 → 控制 → 生命周期 → 输出)整改 | 已实施 | | `agent-view-refactor-plan.md` | 公网 Agent 页四段式(状态 → 控制 → 生命周期 → 输出)整改 | 已实施 |
@@ -26,11 +27,11 @@
| `agent-proxy-settings-plan.md` | 公网反代「部署设置」对齐本地反代的语义迁移 | 已实施 | | `agent-proxy-settings-plan.md` | 公网反代「部署设置」对齐本地反代的语义迁移 | 已实施 |
| `agent-proxy-roadmap.md` | 公网反代功能增强规划(含部署踩坑史) | 部分已实施(漂移检测、全量重部署),其余待排期 | | `agent-proxy-roadmap.md` | 公网反代功能增强规划(含部署踩坑史) | 部分已实施(漂移检测、全量重部署),其余待排期 |
| `frps-features.md` | frps 服务端功能实现清单(对照官方 frps_full_example.toml) | 参考 | | `frps-features.md` | frps 服务端功能实现清单(对照官方 frps_full_example.toml) | 参考 |
| `frps服务端管理方案.md` | FRPS 服务端管理方案(fonu-agent 部署在公网服务器) | 已实施(Agent 管理面板) | | `frps服务端管理方案.md` | FRPS 服务端管理方案(havline-agent 部署在公网服务器) | 已实施(Agent 管理面板) |
| `frps管理方案.md` | FRPS 管理方案(早期版本) | 疑似已被 `frps服务端管理方案.md` 取代,保留供追溯(待确认) | | `frps管理方案.md` | FRPS 管理方案(早期版本) | 疑似已被 `frps服务端管理方案.md` 取代,保留供追溯(待确认) |
| `upstream-sync-plan.md` | 上游 1.0.2 → 1.0.5 对照与可借鉴项 | 部分过期:对 `/frp` 现状的描述基于 2026-09-23 | | `upstream-sync-plan.md` | 上游 1.0.2 → 1.0.5 对照与可借鉴项 | 部分过期:对 `/frp` 现状的描述基于 2026-09-23 |
| `FRP公网暴露操作手册.md` | FRP 公网暴露操作手册(面向使用) | 参考 | | `FRP公网暴露操作手册.md` | FRP 公网暴露操作手册(面向使用) | 参考 |
| `DEPLOYMENT.md` | 部署安装:裸机 / Docker / 飞牛 host 模式 | 参考;适用 `fonu-1.1.0` 安装包 | | `DEPLOYMENT.md` | 部署安装:裸机 / Docker / 飞牛 host 模式 | 参考;适用 `havline-1.1.0` 安装包 |
## 其它位置 ## 其它位置
+28 -28
View File
@@ -1,8 +1,8 @@
# fonu-agent 能力扩展方案 # havline-agent 能力扩展方案
> **实施状态(2026-09-24)**:P0/P1/P2 已全部落地(agent 0.11.0);P3 三项按本文约定不做。剩余为真机验收项,见 `docs/project-audit-2026-09-24.md`。 > **实施状态(2026-09-24)**:P0/P1/P2 已全部落地(agent 0.11.0);P3 三项按本文约定不做。剩余为真机验收项,见 `docs/project-audit-2026-09-24.md`。
> 本文盘点 VPS 侧 `fonu-agent` 现有的接口面与数据面,给出「能直接做」「需要新增 agent 端点」「不做」三类功能的详细设计。 > 本文盘点 VPS 侧 `havline-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`(均为读代码确认,非推测)。 > 依据:本仓库 `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`)。 > 当前 `AgentVersion = "0.9.2"`(`internal/agent/server.go:34`)。
@@ -20,7 +20,7 @@
| --- | --- | --- | | --- | --- | --- |
| 1 | `GET /api/v1/ping` | 存活探测(安装/升级后确认 token 与网络) | | 1 | `GET /api/v1/ping` | 存活探测(安装/升级后确认 token 与网络) |
| 2 | `GET /api/v1/status` | 一次性返回 Nginx / frps / 端口 / 路由名的快照 | | 2 | `GET /api/v1/status` | 一次性返回 Nginx / frps / 端口 / 路由名的快照 |
| 3 | `GET /api/v1/routes` | 每条 `fonu-*.conf` 的真实状态:`file_exists` / `syntax_ok` / `loaded` | | 3 | `GET /api/v1/routes` | 每条 `havline-*.conf` 的真实状态:`file_exists` / `syntax_ok` / `loaded` |
| 4 | `PUT /api/v1/routes` | 写入(或覆盖)一条域名的 Nginx 片段 | | 4 | `PUT /api/v1/routes` | 写入(或覆盖)一条域名的 Nginx 片段 |
| 5 | `GET /api/v1/routes/{domain}/conf` | 回读某域名实际生效的配置文件内容 | | 5 | `GET /api/v1/routes/{domain}/conf` | 回读某域名实际生效的配置文件内容 |
| 6 | `DELETE /api/v1/routes/{domain}` | 删除某域名的片段 | | 6 | `DELETE /api/v1/routes/{domain}` | 删除某域名的片段 |
@@ -31,7 +31,7 @@
| 11 | `POST /api/v1/frps/action` | frps 的 start / stop / restart | | 11 | `POST /api/v1/frps/action` | frps 的 start / stop / restart |
| 12 | `POST /api/v1/certs` | 推送证书(fullchain + privatekey) | | 12 | `POST /api/v1/certs` | 推送证书(fullchain + privatekey) |
鉴权:全部走 `s.auth(...)` 包装的 token 校验;agent **只监听 VPS 本地**,由 Fonu 主动发起请求,**不做任何主动上报**。 鉴权:全部走 `s.auth(...)` 包装的 token 校验;agent **只监听 VPS 本地**,由 Havline 主动发起请求,**不做任何主动上报**。
### 1.2 agent 的数据面(`status` 已返回、但应用侧只用了一部分) ### 1.2 agent 的数据面(`status` 已返回、但应用侧只用了一部分)
@@ -44,7 +44,7 @@
| `nginx_binary` / `nginx_conf_dir` | 配置的二进制与 vhost 目录 | 已用 | | `nginx_binary` / `nginx_conf_dir` | 配置的二进制与 vhost 目录 | 已用 |
| `ports` | 80 / 443 / 7000 / 8080 等端口是否监听 | **只用到了 8080 一项**(`AgentView.vue:359`) | | `ports` | 80 / 443 / 7000 / 8080 等端口是否监听 | **只用到了 8080 一项**(`AgentView.vue:359`) |
| `frps_active` / `frps_enabled` | systemd 单元是否运行 / 是否开机自启 | 已用 | | `frps_active` / `frps_enabled` | systemd 单元是否运行 / 是否开机自启 | 已用 |
| `frps_binary` / `frps_config_path` | Fonu 侧配置的路径 | 已用 | | `frps_binary` / `frps_config_path` | Havline 侧配置的路径 | 已用 |
| `frps_unit_exec` | systemd 实际执行的启动命令 | 已展示,**未做一致性核对** | | `frps_unit_exec` | systemd 实际执行的启动命令 | 已展示,**未做一致性核对** |
| `frps_version` | 探测到的 frps 版本 | 已展示 | | `frps_version` | 探测到的 frps 版本 | 已展示 |
| `frps_log_tail` | frps 日志尾部 6 行 | 已展示 | | `frps_log_tail` | frps 日志尾部 6 行 | 已展示 |
@@ -59,7 +59,7 @@
### 1.4 安装与升级链路(已闭环) ### 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 二进制**内嵌在 Havline 里**(`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 自己有下载能力——**自升级不是必须项**。 - 因此「升级 Agent」不需要 agent 自己有下载能力——**自升级不是必须项**。
- 版本约定:`AgentVersion` 上方按「每项行为变更一行」的注释累积,改动后手动递增(当前 0.9.2)。 - 版本约定:`AgentVersion` 上方按「每项行为变更一行」的注释累积,改动后手动递增(当前 0.9.2)。
@@ -67,7 +67,7 @@
1. **没有资源采集**:VPS 的 CPU / 内存 / 磁盘 / 负载完全不可见。 1. **没有资源采集**:VPS 的 CPU / 内存 / 磁盘 / 负载完全不可见。
2. **没有日志读取**:只有 frps 的 6 行 tail,Nginx 的 access / error 日志读不到(排障必须 SSH)。 2. **没有日志读取**:只有 frps 的 6 行 tail,Nginx 的 access / error 日志读不到(排障必须 SSH)。
3. **frps 配置只写不读**:`PUT /api/v1/frps/config` 存在,没有对应的 GET;用户手改 VPS 上的 frps.toml 后 Fonu 无从知晓,下次下发直接覆盖。 3. **frps 配置只写不读**:`PUT /api/v1/frps/config` 存在,没有对应的 GET;用户手改 VPS 上的 frps.toml 后 Havline 无从知晓,下次下发直接覆盖。
4. **没有证书清单**:只能推送,无法列出 VPS 上现有的证书与到期时间(宝塔自己申请的证书看不到)。 4. **没有证书清单**:只能推送,无法列出 VPS 上现有的证书与到期时间(宝塔自己申请的证书看不到)。
5. **部署是逐条的**:`SyncRouteSpec` 按单条规则调用,没有「全量重部署」入口——agent 重装/换机后只能一条条点。 5. **部署是逐条的**:`SyncRouteSpec` 按单条规则调用,没有「全量重部署」入口——agent 重装/换机后只能一条条点。
6. **写入结果不含 reload 细节**:片段写入成功但 Nginx reload 失败时,用户拿不到失败原因。 6. **写入结果不含 reload 细节**:片段写入成功但 Nginx reload 失败时,用户拿不到失败原因。
@@ -89,7 +89,7 @@
| **P2-2** | reload 失败原因回传 | agent + 应用 | 同上 | 1h | | **P2-2** | reload 失败原因回传 | agent + 应用 | 同上 | 1h |
| **P3** | 不做:防火墙读写、任意命令执行、agent 自升级 | — | — | — | | **P3** | 不做:防火墙读写、任意命令执行、agent 自升级 | — | — | — |
**贯穿约束(所有新增 agent 端点都适用)**:老版本 agent 没有新端点,应用侧调用必须**按「端点不存在」优雅降级**(收到 404/405 时显示「VPS 上的 agent 版本过低,请升级 Agent」而不是报错),否则用户升级了 Fonu 但没升级 agent 时页面会直接报错。 **贯穿约束(所有新增 agent 端点都适用)**:老版本 agent 没有新端点,应用侧调用必须**按「端点不存在」优雅降级**(收到 404/405 时显示「VPS 上的 agent 版本过低,请升级 Agent」而不是报错),否则用户升级了 Havline 但没升级 agent 时页面会直接报错。
--- ---
@@ -109,7 +109,7 @@
| 类别 | 定义 | 含义 | | 类别 | 定义 | 含义 |
| --- | --- | --- | | --- | --- | --- |
| 未部署 | `A - B` | 库里要部署但 VPS 上没有(agent 重装、换机、手工删过) | | 未部署 | `A - B` | 库里要部署但 VPS 上没有(agent 重装、换机、手工删过) |
| 孤儿配置 | `B - A` | VPS 有但库里没有(手工加的 `fonu-*.conf`、已删规则的残留) | | 孤儿配置 | `B - A` | VPS 有但库里没有(手工加的 `havline-*.conf`、已删规则的残留) |
| 未生效 | `A ∩ (B - C)` | 文件在但未被 Nginx 加载(`syntax_ok=false`,或 include 未覆盖) | | 未生效 | `A ∩ (B - C)` | 文件在但未被 Nginx 加载(`syntax_ok=false`,或 include 未覆盖) |
| 内容不一致 | `RouteConf(domain)` 与「按当前部署设置生成的内容」规范化比对 | 手工改过上游 / 证书路径 / 安全项 | | 内容不一致 | `RouteConf(domain)` 与「按当前部署设置生成的内容」规范化比对 | 手工改过上游 / 证书路径 / 安全项 |
@@ -117,18 +117,18 @@
**前端落点**:公网反代页顶部提示条(有差异时才出现)+「重新部署全部」按钮(二次确认,因为会覆盖手工改动)。 **前端落点**:公网反代页顶部提示条(有差异时才出现)+「重新部署全部」按钮(二次确认,因为会覆盖手工改动)。
**验收**:手工删一个 `fonu-*.conf` → 清单出现「未部署」,重部署后消失;手工改一条 conf 的上游 → 出现「内容不一致」;语法错误时归入「未生效」并给出原因。 **验收**:手工删一个 `havline-*.conf` → 清单出现「未部署」,重部署后消失;手工改一条 conf 的上游 → 出现「内容不一致」;语法错误时归入「未生效」并给出原因。
### 3.3 frps 运行一致性核对 ### 3.3 frps 运行一致性核对
- **比对三项**:`frps_unit_exec`(systemd 实际执行)vs Fonu 配置的 `frps_binary`;`frps_version` vs 期望版本;`frps_config_path` vs Fonu 生成路径。 - **比对三项**:`frps_unit_exec`(systemd 实际执行)vs Havline 配置的 `frps_binary`;`frps_version` vs 期望版本;`frps_config_path` vs Havline 生成路径。
- **呈现**:AgentView「frps 控制」段加一行「启动路径一致性」,不一致时并列显示两个路径。 - **呈现**:AgentView「frps 控制」段加一行「启动路径一致性」,不一致时并列显示两个路径。
- **验收**:手工改过 systemd unit 的 VPS 上显示不一致;一致的机器显示绿色。 - **验收**:手工改过 systemd unit 的 VPS 上显示不一致;一致的机器显示绿色。
### 3.4 证书推送核对 ### 3.4 证书推送核对
- **数据**:已有 `RouteHealth` 的证书段(`cert_ok` / `cert_detail`)+ 推送结果。 - **数据**:已有 `RouteHealth` 的证书段(`cert_ok` / `cert_detail`)+ 推送结果。
- **呈现**:公网反代页健康列已展示证书段;补一行「证书来源」(Fonu 推送 / 待推送 / VPS 本地已有)。 - **呈现**:公网反代页健康列已展示证书段;补一行「证书来源」(Havline 推送 / 待推送 / VPS 本地已有)。
- **验收**:证书过期时健康列给出证书段失败与到期天数。 - **验收**:证书过期时健康列给出证书段失败与到期天数。
--- ---
@@ -155,7 +155,7 @@
} }
``` ```
- **实现要点**:**直接复用仓库里已有的 `internal/sysinfo`**(同一 module,`cmd/fonu-agent` 可直接引用)——`/proc/stat`、`/proc/meminfo`、`Statfs` 以及 Windows 的 kernel32 路径都已写好;agent 跑在 VPS 上,采到的天然是 VPS 自身数值。 - **实现要点**:**直接复用仓库里已有的 `internal/sysinfo`**(同一 module,`cmd/havline-agent` 可直接引用)——`/proc/stat`、`/proc/meminfo`、`Statfs` 以及 Windows 的 kernel32 路径都已写好;agent 跑在 VPS 上,采到的天然是 VPS 自身数值。
- **CPU 差值**:`sysinfo.Read(prev)` 需要上次采样;agent 进程内保存上次 `CPUSample`,并对间隔 < 1 秒的重复调用直接返回上次结果(避免连续刷新时数值抖动)。 - **CPU 差值**:`sysinfo.Read(prev)` 需要上次采样;agent 进程内保存上次 `CPUSample`,并对间隔 < 1 秒的重复调用直接返回上次结果(避免连续刷新时数值抖动)。
- **`load_avg` / `uptime_seconds`**:Linux 读 `/proc/loadavg`、`/proc/uptime`;非 Linux 省略这两个字段(前端按缺失处理)。 - **`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% 转红)。 - **应用侧链路**:`AgentClient.Metrics(ctx)` → `GET /api/frpmulti/servers/{id}/agent/metrics` → AgentView 新增「VPS 资源」卡(复用本地仪表盘「系统资源」的行样式:名称 / 用量明细 / 4px 进度条 / 百分比,≥75% 转橙、≥90% 转红)。
@@ -181,7 +181,7 @@
- **路径来源(白名单,不接受任意 path 参数)**: - **路径来源(白名单,不接受任意 path 参数)**:
1. 优先从 `nginx -T` 的 dump 里解析 `access_log` / `error_log` 指令(agent 已有 `nginx -T` 解析与缓存经验,见 `resolveNginxConfDir`); 1. 优先从 `nginx -T` 的 dump 里解析 `access_log` / `error_log` 指令(agent 已有 `nginx -T` 解析与缓存经验,见 `resolveNginxConfDir`);
2. 解析不到时回落默认路径 `/var/log/nginx/{access,error}.log`; 2. 解析不到时回落默认路径 `/var/log/nginx/{access,error}.log`;
3. 支持环境变量 `FONU_AGENT_NGINX_LOG_DIR` 覆盖(与现有 `FONU_AGENT_NGINX_CONF_DIR` 的回退风格一致)。 3. 支持环境变量 `HAVLINE_AGENT_NGINX_LOG_DIR` 覆盖(与现有 `HAVLINE_AGENT_NGINX_CONF_DIR` 的回退风格一致)。
- **只读尾部**:实现 `tailFile(path, n)`(与现有 `journalTail` 同思路),从文件末尾按块倒读,避免把整个日志读进内存;单行超长(如 4KB)截断。 - **只读尾部**:实现 `tailFile(path, n)`(与现有 `journalTail` 同思路),从文件末尾按块倒读,避免把整个日志读进内存;单行超长(如 4KB)截断。
- **错误处理**:文件不存在 → 200 + `lines: []` + 原样返回 `path`(前端显示「未找到日志文件」);无权限 → 403 并给出明确原因。 - **错误处理**:文件不存在 → 200 + `lines: []` + 原样返回 `path`(前端显示「未找到日志文件」);无权限 → 403 并给出明确原因。
- **应用侧链路**:`AgentClient.NginxLogs(ctx, serverID, type, lines)` → `GET /api/frpmulti/servers/{id}/agent/logs/nginx?...`;日志中心新增「公网 Nginx」页签,复用现有 `LogToolbar` / `LogPagination` / `EmptyState` 与 `logTimeCell`,与「FRP 日志」页签同一套呈现。 - **应用侧链路**:`AgentClient.NginxLogs(ctx, serverID, type, lines)` → `GET /api/frpmulti/servers/{id}/agent/logs/nginx?...`;日志中心新增「公网 Nginx」页签,复用现有 `LogToolbar` / `LogPagination` / `EmptyState` 与 `logTimeCell`,与「FRP 日志」页签同一套呈现。
@@ -192,7 +192,7 @@
响应:`{ "path": "/etc/frp/frps.toml", "exists": true, "content": "...", "mod_time": "2026-09-23T18:00:00Z", "checked_at": "..." }` 响应:`{ "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` 而不是错误。 - **实现要点**:读 `s.cfg.FrpsConfigPath`,限制文件大小(如 256KB),超限返回 `truncated: true`;文件不存在返回 `exists: false` 而不是错误。
- **应用侧**:在「公网服务端」的 frps 同源配置弹窗里加「与 VPS 实际文件比对」——把「Fonu 即将下发的内容」与回读内容做规范化 diff(去注释与空白),不一致时提示「VPS 上的 frps.toml 已被手工修改」,并给「重新下发」按钮(复用已有 `PushFRPSConfig`)。 - **应用侧**:在「公网服务端」的 frps 同源配置弹窗里加「与 VPS 实际文件比对」——把「Havline 即将下发的内容」与回读内容做规范化 diff(去注释与空白),不一致时提示「VPS 上的 frps.toml 已被手工修改」,并给「重新下发」按钮(复用已有 `PushFRPSConfig`)。
- **验收**:在 VPS 上手工改一行 frps.toml → 弹窗提示不一致并高亮差异行;重新下发后恢复一致。 - **验收**:在 VPS 上手工改一行 frps.toml → 弹窗提示不一致并高亮差异行;重新下发后恢复一致。
--- ---
@@ -204,8 +204,8 @@
响应:`{ "certs": [{ "domain": "7023.36800.cc", "cert_path": "...", "key_path": "...", "not_after": "2026-12-01T00:00:00Z", "days_left": 69 }], "warnings": [], "checked_at": "..." }` 响应:`{ "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`。 - **实现要点**:遍历 `s.cfg.CertsDir` 下的域名目录,用标准库 `crypto/x509` 解析 `fullchain.pem` 的 `NotAfter`;解析失败的单条跳过并记入 `warnings`。
- **应用侧**:与 Fonu 的证书列表交叉核对,输出「Fonu 有 / VPS 有 / 两边都有但到期时间不同」三类;宝塔自行申请的证书也能被看到。 - **应用侧**:与 Havline 的证书列表交叉核对,输出「Havline 有 / VPS 有 / 两边都有但到期时间不同」三类;宝塔自行申请的证书也能被看到。
- **验收**:过期证书 `days_left < 0` 并标红;Fonu 未推送过的域名出现在「VPS 本地已有」分组。 - **验收**:过期证书 `days_left < 0` 并标红;Havline 未推送过的域名出现在「VPS 本地已有」分组。
### 5.2 reload 失败原因回传 ### 5.2 reload 失败原因回传
@@ -221,7 +221,7 @@
1. **VPS 防火墙规则读写**(ufw / firewalld / 宝塔面板差异大、需要 root,误操作会直接断网)——最多做只读展示。 1. **VPS 防火墙规则读写**(ufw / firewalld / 宝塔面板差异大、需要 root,误操作会直接断网)——最多做只读展示。
2. **任意命令执行端点**——agent 目前只有 token 鉴权,开放 shell 等于把 VPS 交出去。 2. **任意命令执行端点**——agent 目前只有 token 鉴权,开放 shell 等于把 VPS 交出去。
3. **agent 自升级**——现有链路(Fonu 内嵌二进制 + SSH 上传 + 重启 systemd)已经闭环,自升级只省掉存 SSH 凭据,收益有限,还要额外处理下载源与签名校验。 3. **agent 自升级**——现有链路(Havline 内嵌二进制 + SSH 上传 + 重启 systemd)已经闭环,自升级只省掉存 SSH 凭据,收益有限,还要额外处理下载源与签名校验。
--- ---
@@ -230,18 +230,18 @@
### 7.1 agent 侧(每新增一个端点都要走完) ### 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`**。 1. `internal/agent/server.go`:加 handler + `mux.HandleFunc(...)`,并在 `AgentVersion` 上方按现有约定补一行版本说明(例如 `// 0.10.0:新增 /api/v1/metrics 资源采集与 /api/v1/logs/nginx 日志读取。`),把常量递增到 **`0.10.0`**。
2. 重编两个架构的内嵌二进制(Fonu 编译期嵌入,必须是真实二进制而不是占位文件): 2. 重编两个架构的内嵌二进制(Havline 编译期嵌入,必须是真实二进制而不是占位文件):
```powershell ```powershell
$env:GOOS='linux'; $env:CGO_ENABLED='0'; $env:GOARCH='amd64' $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 go build -trimpath -o internal/frp/agentbin/havline-agent-linux-amd64.new ./cmd/havline-agent
$env:GOARCH='arm64' $env:GOARCH='arm64'
go build -trimpath -o internal/frp/agentbin/fonu-agent-linux-arm64.new ./cmd/fonu-agent go build -trimpath -o internal/frp/agentbin/havline-agent-linux-arm64.new ./cmd/havline-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/havline-agent-linux-amd64.new internal/frp/agentbin/havline-agent-linux-amd64
Move-Item -Force internal/frp/agentbin/fonu-agent-linux-arm64.new internal/frp/agentbin/fonu-agent-linux-arm64 Move-Item -Force internal/frp/agentbin/havline-agent-linux-arm64.new internal/frp/agentbin/havline-agent-linux-arm64
``` ```
3. 重启本机 Fonu(加载新内嵌二进制)→ VPS 上「升级 Agent」→ 用 `GET /api/v1/status` 的 `agent_version` 确认已是 `0.10.0`。 3. 重启本机 Havline(加载新内嵌二进制)→ VPS 上「升级 Agent」→ 用 `GET /api/v1/status` 的 `agent_version` 确认已是 `0.10.0`。
### 7.2 应用侧(每个端点对应一条链路) ### 7.2 应用侧(每个端点对应一条链路)
@@ -260,10 +260,10 @@ Move-Item -Force internal/frp/agentbin/fonu-agent-linux-arm64.new internal/frp/a
| 风险 | 说明 | 应对 | | 风险 | 说明 | 应对 |
| --- | --- | --- | | --- | --- | --- |
| **新老版本错配** | Fonu 已升级、VPS 上的 agent 仍是旧版 | 应用侧对新端点统一做 404/405 降级,前端显示「请升级 Agent」;不改动已有端点的行为 | | **新老版本错配** | Havline 已升级、VPS 上的 agent 仍是旧版 | 应用侧对新端点统一做 404/405 降级,前端显示「请升级 Agent」;不改动已有端点的行为 |
| **只读端点的权限** | `/var/log/nginx` 与证书目录可能不允许 agent 运行用户读取 | 端点返回明确的 403 + 路径,前端提示「agent 无读取权限」;安装脚本里补充读权限说明 | | **只读端点的权限** | `/var/log/nginx` 与证书目录可能不允许 agent 运行用户读取 | 端点返回明确的 403 + 路径,前端提示「agent 无读取权限」;安装脚本里补充读权限说明 |
| **`metrics` 采样抖动** | 高频调用时 CPU 差值失真 | 间隔 < 1 秒直接返回上次结果;前端按 15 秒轮询(与本地仪表盘一致) | | **`metrics` 采样抖动** | 高频调用时 CPU 差值失真 | 间隔 < 1 秒直接返回上次结果;前端按 15 秒轮询(与本地仪表盘一致) |
| **全量重部署覆盖手工改动** | 会覆盖 VPS 上手工编辑过的 `fonu-*.conf` | 按钮带二次确认 + 差异预览;`RouteConf` 回读内容先展示再覆盖 | | **全量重部署覆盖手工改动** | 会覆盖 VPS 上手工编辑过的 `havline-*.conf` | 按钮带二次确认 + 差异预览;`RouteConf` 回读内容先展示再覆盖 |
| **回滚** | agent 行为变更后需要退回旧版本 | agent 二进制随 Fonu 版本走:用旧版 Fonu 的「升级 Agent」即可把旧二进制重新上传;新增端点均为**纯增量**,不改变已有端点语义,应用侧回滚也无需数据迁移 | | **回滚** | agent 行为变更后需要退回旧版本 | agent 二进制随 Havline 版本走:用旧版 Havline 的「升级 Agent」即可把旧二进制重新上传;新增端点均为**纯增量**,不改变已有端点语义,应用侧回滚也无需数据迁移 |
**总原则**:新增端点一律只读或纯增量、不改变已有端点语义;所有 agent 交互都保持「Fonu 主动拉取、agent 被动应答」的现有模型,不引入 agent 主动上报。 **总原则**:新增端点一律只读或纯增量、不改变已有端点语义;所有 agent 交互都保持「Havline 主动拉取、agent 被动应答」的现有模型,不引入 agent 主动上报。
+43 -43
View File
@@ -14,14 +14,14 @@
推荐采用**自动为主、安全优先、旧模式显式兼容**的三层策略: 推荐采用**自动为主、安全优先、旧模式显式兼容**的三层策略:
1. **默认模式:SSH 隧道 + 回环监听** 1. **默认模式:SSH 隧道 + 回环监听**
- `fonu-agent` 默认只监听 `127.0.0.1:7700`。 - `havline-agent` 默认只监听 `127.0.0.1:7700`。
- 中央 Fonu 使用现有 SSH 凭据建立本地转发: - 中央 Havline 使用现有 SSH 凭据建立本地转发:
```text ```text
中央 Fonu 127.0.0.1:<随机本地端口> 中央 Havline 127.0.0.1:<随机本地端口>
→ SSH 加密连接 → SSH 加密连接
→ VPS 127.0.0.1:7700 → VPS 127.0.0.1:7700
→ fonu-agent → havline-agent
``` ```
- 不开放公网 Agent 端口,不需要域名、证书、云安全组或防火墙修改。 - 不开放公网 Agent 端口,不需要域名、证书、云安全组或防火墙修改。
@@ -30,7 +30,7 @@
- Agent 仍只监听回环地址。 - Agent 仍只监听回环地址。
- HTTPS 不作为首次安装选项;Agent 安全安装完成后,用户可在「公网 Agent」页切换到该高级模式。 - HTTPS 不作为首次安装选项;Agent 安全安装完成后,用户可在「公网 Agent」页切换到该高级模式。
- 切换时自动配置 Nginx HTTPS 反代,并使用自签名证书。 - 切换时自动配置 Nginx HTTPS 反代,并使用自签名证书。
- 中央 Fonu 首次安装时记录证书指纹,后续按指纹校验。 - 中央 Havline 首次安装时记录证书指纹,后续按指纹校验。
- 适用于希望免维护常驻 SSH 隧道、且愿意开放一个 HTTPS 端口的用户。 - 适用于希望免维护常驻 SSH 隧道、且愿意开放一个 HTTPS 端口的用户。
3. **兼容模式:显式公网明文 HTTP** 3. **兼容模式:显式公网明文 HTTP**
@@ -38,7 +38,7 @@
- UI 持续显示高危警告。 - UI 持续显示高危警告。
- 不允许自动降级到该模式。 - 不允许自动降级到该模式。
默认安装与升级过程不需要用户设置 `FONU_AGENT_LISTEN`、TLS 证书或防火墙规则。 默认安装与升级过程不需要用户设置 `HAVLINE_AGENT_LISTEN`、TLS 证书或防火墙规则。
--- ---
@@ -49,7 +49,7 @@
当前入口: 当前入口:
```go ```go
listenv := flag.String("listen", envOr("FONU_AGENT_LISTEN", ":7700"), ...) listenv := flag.String("listen", envOr("HAVLINE_AGENT_LISTEN", ":7700"), ...)
``` ```
`internal/agent.NewServer` 空配置时同样回落到: `internal/agent.NewServer` 空配置时同样回落到:
@@ -73,7 +73,7 @@ cfg.ListenAddr = ":7700"
API: http://<VPS-IP>:7700 API: http://<VPS-IP>:7700
``` ```
### 2.3 中央 Fonu 连接 ### 2.3 中央 Havline 连接
安装完成后,`AgentInstall` 写入: 安装完成后,`AgentInstall` 写入:
@@ -81,7 +81,7 @@ cfg.ListenAddr = ":7700"
AgentURL = fmt.Sprintf("http://%s:%d", server.SSHHost, 7700) AgentURL = fmt.Sprintf("http://%s:%d", server.SSHHost, 7700)
``` ```
中央 Fonu 后续直接访问 VPS 公网 IP 的 7700 端口。 中央 Havline 后续直接访问 VPS 公网 IP 的 7700 端口。
### 2.4 当前问题 ### 2.4 当前问题
@@ -99,13 +99,13 @@ AgentURL = fmt.Sprintf("http://%s:%d", server.SSHHost, 7700)
### 3.1 必须实现 ### 3.1 必须实现
1. 新安装默认不监听公网网卡。 1. 新安装默认不监听公网网卡。
2. 安装过程自动建立中央 Fonu 到 Agent 的安全连接。 2. 安装过程自动建立中央 Havline 到 Agent 的安全连接。
3. 不需要用户手工配置网络或证书。 3. 不需要用户手工配置网络或证书。
4. Agent 升级时可自动迁移旧监听配置。 4. Agent 升级时可自动迁移旧监听配置。
5. 旧部署仍可显式使用公网 HTTP。 5. 旧部署仍可显式使用公网 HTTP。
6. 传输失败时给出可执行的诊断信息。 6. 传输失败时给出可执行的诊断信息。
7. 中央 Fonu 重启后可自动恢复连接。 7. 中央 Havline 重启后可自动恢复连接。
8. Agent 与中央 Fonu 的版本兼容策略保持清晰。 8. Agent 与中央 Havline 的版本兼容策略保持清晰。
### 3.2 非目标 ### 3.2 非目标
@@ -155,7 +155,7 @@ AgentURL = fmt.Sprintf("http://%s:%d", server.SSHHost, 7700)
| 防火墙 | 需要放行一个 HTTPS 端口 | | 防火墙 | 需要放行一个 HTTPS 端口 |
| 证书 | 自动生成和固定,不需要公网 CA | | 证书 | 自动生成和固定,不需要公网 CA |
| 复杂度 | 中 | | 复杂度 | 中 |
| 主要优势 | 中央 Fonu 可直接连接,不依赖常驻 SSH | | 主要优势 | 中央 Havline 可直接连接,不依赖常驻 SSH |
| 主要限制 | 云安全组仍需用户放行端口 | | 主要限制 | 云安全组仍需用户放行端口 |
结论:作为可选直连模式。 结论:作为可选直连模式。
@@ -181,26 +181,26 @@ AgentURL = fmt.Sprintf("http://%s:%d", server.SSHHost, 7700)
```mermaid ```mermaid
flowchart LR flowchart LR
Web[Fonu Web] --> API[Fonu 后端] Web[Havline Web] --> API[Havline 后端]
API --> Tunnel[SSH 隧道管理器] API --> Tunnel[SSH 隧道管理器]
Tunnel -->|127.0.0.1:自动分配端口| SSH[SSH 加密连接] Tunnel -->|127.0.0.1:自动分配端口| SSH[SSH 加密连接]
SSH --> VPSLoopback[VPS 127.0.0.1:7700] SSH --> VPSLoopback[VPS 127.0.0.1:7700]
VPSLoopback --> Agent[fonu-agent] VPSLoopback --> Agent[havline-agent]
``` ```
### 5.2 角色职责 ### 5.2 角色职责
| 组件 | 职责 | | 组件 | 职责 |
| --- | --- | | --- | --- |
| Fonu 后端 | 决定 transport、启动隧道、调用 AgentClient | | Havline 后端 | 决定 transport、启动隧道、调用 AgentClient |
| 隧道管理器 | 建立 SSH 本地转发、保持连接、重连、健康检查 | | 隧道管理器 | 建立 SSH 本地转发、保持连接、重连、健康检查 |
| fonu-agent | 默认只监听回环,不再负责公网暴露 | | havline-agent | 默认只监听回环,不再负责公网暴露 |
| 安装脚本 | 写入 `FONU_AGENT_LISTEN=127.0.0.1:7700`,禁止默认可公开 | | 安装脚本 | 写入 `HAVLINE_AGENT_LISTEN=127.0.0.1:7700`,禁止默认可公开 |
| UI | 显示安全模式、连接状态、可选迁移和显式旧模式警告 | | UI | 显示安全模式、连接状态、可选迁移和显式旧模式警告 |
### 5.3 连接抽象 ### 5.3 连接抽象
中央 Fonu 不再把“公网 IP + 7700”当作 Agent 的唯一定义。新增 transport 概念: 中央 Havline 不再把“公网 IP + 7700”当作 Agent 的唯一定义。新增 transport 概念:
```text ```text
tunnel SSH 本地转发,默认 tunnel SSH 本地转发,默认
@@ -310,7 +310,7 @@ const defaultListenAddr = "127.0.0.1:7700"
以下位置同步: 以下位置同步:
- `cmd/fonu-agent/main.go` - `cmd/havline-agent/main.go`
- `internal/agent.NewServer` - `internal/agent.NewServer`
- `scripts/agent-install.sh` - `scripts/agent-install.sh`
- `internal/frp/agent_installer.go` 内嵌安装脚本 - `internal/frp/agent_installer.go` 内嵌安装脚本
@@ -329,7 +329,7 @@ VPS公网IP:7700
则必须显式设置: 则必须显式设置:
```dotenv ```dotenv
FONU_AGENT_ALLOW_REMOTE=1 HAVLINE_AGENT_ALLOW_REMOTE=1
``` ```
否则 Agent 拒绝启动并输出: 否则 Agent 拒绝启动并输出:
@@ -337,21 +337,21 @@ FONU_AGENT_ALLOW_REMOTE=1
```text ```text
检测到 Agent 将监听非回环地址,但未显式允许远程访问。 检测到 Agent 将监听非回环地址,但未显式允许远程访问。
默认安全模式只监听 127.0.0.1:7700。 默认安全模式只监听 127.0.0.1:7700。
如确认需要公网明文访问,请设置 FONU_AGENT_ALLOW_REMOTE=1。 如确认需要公网明文访问,请设置 HAVLINE_AGENT_ALLOW_REMOTE=1。
``` ```
### 7.3 旧配置迁移 ### 7.3 旧配置迁移
旧版本 `agent.env` 可能没有 `FONU_AGENT_LISTEN`,此时: 旧版本 `agent.env` 可能没有 `HAVLINE_AGENT_LISTEN`,此时:
1. 新 Agent 默认监听回环; 1. 新 Agent 默认监听回环;
2. 安装脚本显式写入: 2. 安装脚本显式写入:
```dotenv ```dotenv
FONU_AGENT_LISTEN=127.0.0.1:7700 HAVLINE_AGENT_LISTEN=127.0.0.1:7700
``` ```
3. 中央 Fonu 在执行「升级 Agent」时同步建立隧道; 3. 中央 Havline 在执行「升级 Agent」时同步建立隧道;
4. 升级完成后切换 transport; 4. 升级完成后切换 transport;
5. 旧公网地址从 `agent_url` 中移除; 5. 旧公网地址从 `agent_url` 中移除;
6. 不停留手工维护窗口。 6. 不停留手工维护窗口。
@@ -376,12 +376,12 @@ Agent `/api/v1/status` 新增:
### 8.1 新安装 ### 8.1 新安装
1. 用户填写 VPS SSH 信息。 1. 用户填写 VPS SSH 信息。
2. 中央 Fonu 通过 SSH 探测系统与 Nginx。 2. 中央 Havline 通过 SSH 探测系统与 Nginx。
3. 分配 `agent_local_port`。 3. 分配 `agent_local_port`。
4. 上传 Agent 二进制。 4. 上传 Agent 二进制。
5. 安装脚本写入: 5. 安装脚本写入:
- Token; - Token;
- `FONU_AGENT_LISTEN=127.0.0.1:7700`; - `HAVLINE_AGENT_LISTEN=127.0.0.1:7700`;
- systemd 服务。 - systemd 服务。
6. 启动 Agent。 6. 启动 Agent。
7. 通过 SSH 本地转发建立隧道。 7. 通过 SSH 本地转发建立隧道。
@@ -413,7 +413,7 @@ Agent `/api/v1/status` 新增:
1. 读取启用管理且 transport=tunnel 的服务端。 1. 读取启用管理且 transport=tunnel 的服务端。
2. 逐个调用 `Tunnel.Ensure`。 2. 逐个调用 `Tunnel.Ensure`。
3. 启动失败不阻塞整个 Fonu 启动。 3. 启动失败不阻塞整个 Havline 启动。
4. UI 显示重连状态。 4. UI 显示重连状态。
5. 后台按指数退避继续重试。 5. 后台按指数退避继续重试。
@@ -426,7 +426,7 @@ Agent `/api/v1/status` 新增:
- 不希望长期维护 SSH 隧道; - 不希望长期维护 SSH 隧道;
- 可以接受开放一个 HTTPS 端口; - 可以接受开放一个 HTTPS 端口;
- 没有域名或不想申请公网证书; - 没有域名或不想申请公网证书;
- 希望 Agent API 可被中央 Fonu直接访问。 - 希望 Agent API 可被中央 Havline直接访问。
### 9.2 安装后切换流程 ### 9.2 安装后切换流程
@@ -436,8 +436,8 @@ Agent `/api/v1/status` 新增:
2. 切换流程生成自签名证书: 2. 切换流程生成自签名证书:
```text ```text
/etc/fonu-agent/tls/agent.crt /etc/havline-agent/tls/agent.crt
/etc/fonu-agent/tls/agent.key /etc/havline-agent/tls/agent.key
``` ```
3. 自动寻找可用端口: 3. 自动寻找可用端口:
@@ -449,8 +449,8 @@ Agent `/api/v1/status` 新增:
```nginx ```nginx
server { server {
listen 7443 ssl; listen 7443 ssl;
ssl_certificate /etc/fonu-agent/tls/agent.crt; ssl_certificate /etc/havline-agent/tls/agent.crt;
ssl_certificate_key /etc/fonu-agent/tls/agent.key; ssl_certificate_key /etc/havline-agent/tls/agent.key;
location /api/v1/ { location /api/v1/ {
proxy_pass http://127.0.0.1:7700; proxy_pass http://127.0.0.1:7700;
@@ -459,7 +459,7 @@ Agent `/api/v1/status` 新增:
``` ```
5. 返回证书 SHA-256 指纹。 5. 返回证书 SHA-256 指纹。
6. 中央 Fonu 保存 pin,并使用自定义校验。 6. 中央 Havline 保存 pin,并使用自定义校验。
### 9.3 TLS 固定 ### 9.3 TLS 固定
@@ -494,7 +494,7 @@ VerifyPeerCertificate(rawCerts [][]byte, _ [][]*x509.Certificate) error {
云厂商安全组始终不能自动修改。UI 必须提示: 云厂商安全组始终不能自动修改。UI 必须提示:
```text ```text
请在云控制台放行 TCP 7443。主机防火墙需要你确认后由 Fonu 执行,Fonu 不会修改云安全组。 请在云控制台放行 TCP 7443。主机防火墙需要你确认后由 Havline 执行,Havline 不会修改云安全组。
``` ```
--- ---
@@ -504,11 +504,11 @@ VerifyPeerCertificate(rawCerts [][]byte, _ [][]*x509.Certificate) error {
仅用于旧部署迁移或用户明确要求: 仅用于旧部署迁移或用户明确要求:
```dotenv ```dotenv
FONU_AGENT_LISTEN=:7700 HAVLINE_AGENT_LISTEN=:7700
FONU_AGENT_ALLOW_REMOTE=1 HAVLINE_AGENT_ALLOW_REMOTE=1
``` ```
中央 Fonu UI 必须: 中央 Havline UI 必须:
- 显示“公网明文传输”红色状态; - 显示“公网明文传输”红色状态;
- 不在新安装中默认选择; - 不在新安装中默认选择;
@@ -638,7 +638,7 @@ Agent 监听:127.0.0.1:7700
### 14.1 单元测试 ### 14.1 单元测试
- 默认监听地址是回环; - 默认监听地址是回环;
- 非回环监听缺少 `FONU_AGENT_ALLOW_REMOTE` 时拒绝启动; - 非回环监听缺少 `HAVLINE_AGENT_ALLOW_REMOTE` 时拒绝启动;
- 回环监听无需额外变量; - 回环监听无需额外变量;
- `agent_transport` 序列化与旧数据兼容; - `agent_transport` 序列化与旧数据兼容;
- 隧道本地端口分配、冲突与释放; - 隧道本地端口分配、冲突与释放;
@@ -661,7 +661,7 @@ Agent 监听:127.0.0.1:7700
至少覆盖: 至少覆盖:
- Debian / Ubuntu / CentOS; - Debian / Ubuntu / CentOS;
- fnOS 中的 Fonu; - fnOS 中的 Havline;
- 宝塔 Nginx; - 宝塔 Nginx;
- 已存在 80 / 443 / 7443 占用的 VPS; - 已存在 80 / 443 / 7443 占用的 VPS;
- 云安全组只开放 SSH 的 VPS; - 云安全组只开放 SSH 的 VPS;
@@ -673,7 +673,7 @@ Agent 监听:127.0.0.1:7700
- 公网扫描不到 7700; - 公网扫描不到 7700;
- 无需新增云安全组规则; - 无需新增云安全组规则;
- UI 显示隧道已连接; - UI 显示隧道已连接;
- Fonu 重启后自动恢复; - Havline 重启后自动恢复;
- 「升级 Agent」可以自动迁移旧配置; - 「升级 Agent」可以自动迁移旧配置;
- 失败时不会丢失旧连接。 - 失败时不会丢失旧连接。
@@ -684,7 +684,7 @@ Agent 监听:127.0.0.1:7700
### 阶段 1:默认回环与防误开 ### 阶段 1:默认回环与防误开
- Agent 默认监听改为 `127.0.0.1:7700`; - Agent 默认监听改为 `127.0.0.1:7700`;
- 非回环监听要求 `FONU_AGENT_ALLOW_REMOTE=1`; - 非回环监听要求 `HAVLINE_AGENT_ALLOW_REMOTE=1`;
- 安装脚本显式写入监听地址; - 安装脚本显式写入监听地址;
- UI 显示监听范围。 - UI 显示监听范围。
@@ -729,7 +729,7 @@ Agent 监听:127.0.0.1:7700
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 1 | SSH 隧道作为唯一默认模式 | 首次安装不提供模式选择,统一安装为回环监听 + SSH 隧道;HTTPS 直连只作为安装后的高级切换项 | 现有安装流程已经依赖 SSH;默认路径必须最少输入、最少网络要求 | | 1 | SSH 隧道作为唯一默认模式 | 首次安装不提供模式选择,统一安装为回环监听 + SSH 隧道;HTTPS 直连只作为安装后的高级切换项 | 现有安装流程已经依赖 SSH;默认路径必须最少输入、最少网络要求 |
| 2 | 隧道本地端口使用固定范围 | 固定为 `17700-18700`,绑定 `127.0.0.1`;分配结果持久化;启动时先复用持久化端口,再在范围内顺序查找空闲端口;范围耗尽时明确报错,不使用临时端口 | 固定范围便于排查和审计;持久化避免每次重启变化;不回退临时端口避免地址漂移 | | 2 | 隧道本地端口使用固定范围 | 固定为 `17700-18700`,绑定 `127.0.0.1`;分配结果持久化;启动时先复用持久化端口,再在范围内顺序查找空闲端口;范围耗尽时明确报错,不使用临时端口 | 固定范围便于排查和审计;持久化避免每次重启变化;不回退临时端口避免地址漂移 |
| 3 | 隧道跟随主应用生命周期 | 使用应用内 `agent_tunnel` 管理器,在 Fonu 启动后按服务端恢复;应用退出时统一关闭;断线后指数退避重连;首版不新增独立 systemd 服务 | 当前项目是单进程控制面,调度器、FRP、监控均在同一生命周期内,新增守护进程会扩大部署复杂度 | | 3 | 隧道跟随主应用生命周期 | 使用应用内 `agent_tunnel` 管理器,在 Havline 启动后按服务端恢复;应用退出时统一关闭;断线后指数退避重连;首版不新增独立 systemd 服务 | 当前项目是单进程控制面,调度器、FRP、监控均在同一生命周期内,新增守护进程会扩大部署复杂度 |
| 4 | 公网 HTTP 保留整个 1.x 兼容期 | 自 `1.4.0` 起不再允许新安装选择 HTTP;存量连接继续可用但显示高危警告并提供迁移;计划在 `2.0.0` 移除 | 需要给旧 Agent 和环境留出迁移窗口,同时从版本边界上明确技术债的截止点 | | 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 防止轮换瞬间把所有中央连接锁死 | | 5 | HTTPS 自签名证书使用短期自动轮换 | ECDSA P-256,有效期 365 天;剩余 30 天时自动轮换;轮换期间同时接受旧 pin 和新 pin,双 pin 窗口 7 天;优先通过现有 SSH 通道获取新指纹后再切换 | 避免长期证书失管;双 pin 防止轮换瞬间把所有中央连接锁死 |
| 6 | 首版不自动改主机防火墙 | 首先只检测、展示和提示;SSH 隧道模式本身不需要放行新端口;HTTPS 高级模式后续再提供显式的“一键放行主机防火墙”操作,并始终不修改云厂商安全组 | 防火墙和云安全组属于高风险外部状态;首版先把安全默认值落地,避免自动变更导致 SSH 或现有服务失联 | | 6 | 首版不自动改主机防火墙 | 首先只检测、展示和提示;SSH 隧道模式本身不需要放行新端口;HTTPS 高级模式后续再提供显式的“一键放行主机防火墙”操作,并始终不修改云厂商安全组 | 防火墙和云安全组属于高风险外部状态;首版先把安全默认值落地,避免自动变更导致 SSH 或现有服务失联 |
+4 -4
View File
@@ -33,7 +33,7 @@
- 手动部署 / 移除单条规则的反代(部署时自动推证书、自动读最新 vhost 端口); - 手动部署 / 移除单条规则的反代(部署时自动推证书、自动读最新 vhost 端口);
- VPS 当前已加载路由展示(`agentStatus.routes`); - VPS 当前已加载路由展示(`agentStatus.routes`);
- 反代文件按 `fonu-<域名>.conf` 写入 Nginx 实际加载目录(`-T` 试探-验证)。 - 反代文件按 `havline-<域名>.conf` 写入 Nginx 实际加载目录(`-T` 试探-验证)。
--- ---
@@ -103,7 +103,7 @@ curl -s -o /dev/null -w "%{http_code}" -H "Host: <域名>" http://127.0.0.1:<vho
### 8. 部署历史与回滚 ### 8. 部署历史与回滚
每次部署在 agent 侧留 `fonu-<域名>.conf.bak-<时间戳>`(保留最近 N 份), 每次部署在 agent 侧留 `havline-<域名>.conf.bak-<时间戳>`(保留最近 N 份),
页面提供「查看历史 / 回滚到此版本」。 页面提供「查看历史 / 回滚到此版本」。
- 误操作(比如改坏了 upstream)30 秒恢复; - 误操作(比如改坏了 upstream)30 秒恢复;
@@ -112,7 +112,7 @@ curl -s -o /dev/null -w "%{http_code}" -H "Host: <域名>" http://127.0.0.1:<vho
### 9. 域名健康检查面板 ### 9. 域名健康检查面板
页面顶部一行:每个已部署域名的公网可达性(HTTPS 状态码 + 证书剩余天数), 页面顶部一行:每个已部署域名的公网可达性(HTTPS 状态码 + 证书剩余天数),
由 Fonu 侧定时探测(复用服务端诊断的 TTL 缓存模式)。 由 Havline 侧定时探测(复用服务端诊断的 TTL 缓存模式)。
- 证书临期/隧道断链主动可见; - 证书临期/隧道断链主动可见;
- 成本:中。 - 成本:中。
@@ -128,7 +128,7 @@ curl -s -o /dev/null -w "%{http_code}" -H "Host: <域名>" http://127.0.0.1:<vho
### 11. 流量统计接入 ### 11. 流量统计接入
frps dashboard 已有连接数/流量数据(Fonu 正在拉取),按域名聚合展示到反代列表, frps dashboard 已有连接数/流量数据(Havline 正在拉取),按域名聚合展示到反代列表,
可见每条反代的实际使用量。 可见每条反代的实际使用量。
- 成本:中。 - 成本:中。
+15 -15
View File
@@ -1,7 +1,7 @@
# 公网反代「部署设置」对齐本地反代 —— 方案设计 # 公网反代「部署设置」对齐本地反代 —— 方案设计
> 目标:把本地反向代理(`ProxyView.vue` 规则弹窗)中真正有价值的安全与运维设置,按公网反代的语义迁移到 > 目标:把本地反向代理(`ProxyView.vue` 规则弹窗)中真正有价值的安全与运维设置,按公网反代的语义迁移到
> 「公网反代」页(`/public-proxy`,`ProxyRemoteView.vue`)的「部署设置」中,由 fonu-agent 生成对应的 Nginx vhost 配置。 > 「公网反代」页(`/public-proxy`,`ProxyRemoteView.vue`)的「部署设置」中,由 havline-agent 生成对应的 Nginx vhost 配置。
> >
> 原则:**公网反代只管「公网入口」这一层**。凡是会破坏 frps vhost 路由、或本质属于「本机内网分流」的选项, > 原则:**公网反代只管「公网入口」这一层**。凡是会破坏 frps vhost 路由、或本质属于「本机内网分流」的选项,
> 一律不迁移(见「明确不做」一节)。 > 一律不迁移(见「明确不做」一节)。
@@ -22,7 +22,7 @@
- -
**高级**:仅 TLS 1.3、安全响应头、限流、连接数限制 **高级**:仅 TLS 1.3、安全响应头、限流、连接数限制
第一梯队 7 项都是 agent 模板内改动 + Fonu 参数透传,风险低、见效快;第二梯队 3 项需要 agent 多写一个 http 上下文文件。说一声我从第一梯队开始做,或者指定要哪几项。 第一梯队 7 项都是 agent 模板内改动 + Havline 参数透传,风险低、见效快;第二梯队 3 项需要 agent 多写一个 http 上下文文件。说一声我从第一梯队开始做,或者指定要哪几项。
@@ -50,7 +50,7 @@
- `basic_auth_user`(密码由 agent 自动生成并落盘 htpasswd); - `basic_auth_user`(密码由 agent 自动生成并落盘 htpasswd);
- 默认头:Host / X-Real-IP / X-Forwarded-For。 - 默认头:Host / X-Real-IP / X-Forwarded-For。
Fonu 侧对应链路:`ProxyOptions`(`internal/frp/model.go`)→ `DeployAgentRoute` → `AgentClient.SyncRouteEx` Havline 侧对应链路:`ProxyOptions`(`internal/frp/model.go`)→ `DeployAgentRoute` → `AgentClient.SyncRouteEx`
→ `PUT /api/v1/routes`。 → `PUT /api/v1/routes`。
--- ---
@@ -73,9 +73,9 @@ Fonu 侧对应链路:`ProxyOptions`(`internal/frp/model.go`)→ `DeployAge
| # | 功能 | 关键约束 | | # | 功能 | 关键约束 |
| --- | --- | --- | | --- | --- | --- |
| 8 | **请求限流** | `limit_req_zone` 只能出现在 `http` 块。agent 需额外生成 `fonu-limits.conf`(宝塔将 vhost 目录 include 在 `http{}` 内,因此该文件顶层指令即 http 上下文),各域名 server 内 `limit_req zone=fonu_<域名>_req burst=N nodelay;` | | 8 | **请求限流** | `limit_req_zone` 只能出现在 `http` 块。agent 需额外生成 `havline-limits.conf`(宝塔将 vhost 目录 include 在 `http{}` 内,因此该文件顶层指令即 http 上下文),各域名 server 内 `limit_req zone=havline_<域名>_req burst=N nodelay;` |
| 9 | **连接数限制** | 同上,`limit_conn_zone` + `limit_conn fonu_<域名>_conn N;` | | 9 | **连接数限制** | 同上,`limit_conn_zone` + `limit_conn havline_<域名>_conn N;` |
| 10 | **仅中国大陆 IP** | 需 `geo` 模块 + 中国 IP 段下发到 VPS(Fonu 已有 china cidr 模块,7456 条 v4),下发体积最大、成本最高,建议最后做 | | 10 | **仅中国大陆 IP** | 需 `geo` 模块 + 中国 IP 段下发到 VPS(Havline 已有 china cidr 模块,7456 条 v4),下发体积最大、成本最高,建议最后做 |
### 明确不做(字段 / 语义不匹配) ### 明确不做(字段 / 语义不匹配)
@@ -109,13 +109,13 @@ Fonu 侧对应链路:`ProxyOptions`(`internal/frp/model.go`)→ `DeployAge
- 这些字段**只作用于公网 Nginx 模板,绝不写入 frpc.toml**(与现有 `websocket` / `redirect_https` 等一致,在 `ProxyOptions` 上以注释标明); - 这些字段**只作用于公网 Nginx 模板,绝不写入 frpc.toml**(与现有 `websocket` / `redirect_https` 等一致,在 `ProxyOptions` 上以注释标明);
- 「启用 HTTPS」采用**反向字段 `https_disabled`**:字段缺省时保持现有行为(证书推送成功即启用 TLS), - 「启用 HTTPS」采用**反向字段 `https_disabled`**:字段缺省时保持现有行为(证书推送成功即启用 TLS),
保证新 Fonu + 旧 agent、旧数据不出现行为突变。 保证新 Havline + 旧 agent、旧数据不出现行为突变。
--- ---
## 四、实施步骤 ## 四、实施步骤
### 阶段 1:第一梯队后端(agent + Fonu) ### 阶段 1:第一梯队后端(agent + Havline)
1. `internal/agent/server.go` 1. `internal/agent/server.go`
- `routeRequest` 增加第一梯队字段; - `routeRequest` 增加第一梯队字段;
@@ -149,8 +149,8 @@ Fonu 侧对应链路:`ProxyOptions`(`internal/frp/model.go`)→ `DeployAge
### 阶段 3:第二梯队(按需) ### 阶段 3:第二梯队(按需)
7. agent 增加 `fonu-limits.conf` 生成器(http 上下文 zone 定义)+ 每域名 server 引用; 7. agent 增加 `havline-limits.conf` 生成器(http 上下文 zone 定义)+ 每域名 server 引用;
8. 中国 IP:Fonu 将 china cidr 列表推送到 agent(`/var/lib/fonu-agent/geo/china.conf`),agent 生成 `geo` 块。 8. 中国 IP:Havline 将 china cidr 列表推送到 agent(`/var/lib/havline-agent/geo/china.conf`),agent 生成 `geo` 块。
--- ---
@@ -159,7 +159,7 @@ Fonu 侧对应链路:`ProxyOptions`(`internal/frp/model.go`)→ `DeployAge
每次改动后全量跑(项目既有闭环): 每次改动后全量跑(项目既有闭环):
```powershell ```powershell
cd f:\Fonu-main\Fonu-1.0.2 cd f:\Havline-main\Havline-1.0.2
gofmt -w internal/agent/server.go internal/frp/model.go internal/frp/agent_client.go internal/frp/service.go gofmt -w internal/agent/server.go internal/frp/model.go internal/frp/agent_client.go internal/frp/service.go
go build ./... ; go vet ./internal/frp ./internal/agent ./internal/api go build ./... ; go vet ./internal/frp ./internal/agent ./internal/api
go test ./internal/frp ./internal/api go test ./internal/frp ./internal/api
@@ -170,7 +170,7 @@ agent 二进制需按既有流程交叉编译(amd64 + arm64)并升级:
```powershell ```powershell
$env:GOOS='linux'; $env:GOARCH='amd64'; $env:CGO_ENABLED='0' $env:GOOS='linux'; $env:GOARCH='amd64'; $env:CGO_ENABLED='0'
go build -trimpath -o internal/frp/agentbin/fonu-agent-linux-amd64.new ./cmd/fonu-agent go build -trimpath -o internal/frp/agentbin/havline-agent-linux-amd64.new ./cmd/havline-agent
# arm64 同理;Move-Item 覆盖后,在「公网 Agent」页执行「升级 Agent」 # arm64 同理;Move-Item 覆盖后,在「公网 Agent」页执行「升级 Agent」
``` ```
@@ -178,7 +178,7 @@ VPS 侧实测(部署 7023 规则后):
```bash ```bash
nginx -t # 语法 nginx -t # 语法
cat /www/server/panel/vhost/nginx/fonu-7023.36800.cc.conf # 配置内容(或页面「查看配置」) cat /www/server/panel/vhost/nginx/havline-7023.36800.cc.conf # 配置内容(或页面「查看配置」)
curl -I http://7023.36800.cc # 强制跳转 → 301 curl -I http://7023.36800.cc # 强制跳转 → 301
curl -sI -H 'Host: 7023.36800.cc' http://127.0.0.1:9080 # 回源自检 curl -sI -H 'Host: 7023.36800.cc' http://127.0.0.1:9080 # 回源自检
curl -k -sI https://7023.36800.cc # 响应头(HSTS / X-Frame-Options) curl -k -sI https://7023.36800.cc # 响应头(HSTS / X-Frame-Options)
@@ -196,8 +196,8 @@ for i in $(seq 1 50); do curl -s -o /dev/null -w '%{http_code} ' http://7023.368
| 场景 | 期望行为 | | 场景 | 期望行为 |
| --- | --- | | --- | --- |
| 新 Fonu + 旧 agent | 旧 agent 忽略未知 JSON 字段(Go 默认行为),新选项静默不生效 → **部署前必须先「升级 Agent」**,页面提示 | | 新 Havline + 旧 agent | 旧 agent 忽略未知 JSON 字段(Go 默认行为),新选项静默不生效 → **部署前必须先「升级 Agent」**,页面提示 |
| 旧 Fonu + 新 agent | 新字段缺省,行为与现状一致(`https_disabled` 缺省 = 启用 TLS) | | 旧 Havline + 新 agent | 新字段缺省,行为与现状一致(`https_disabled` 缺省 = 启用 TLS) |
| frps v0.68.0 / v0.71.0 | 与本方案无关(作用于 Nginx vhost,不依赖 dashboard API) | | frps v0.68.0 / v0.71.0 | 与本方案无关(作用于 Nginx vhost,不依赖 dashboard API) |
| 回滚 | 关闭对应开关后重新部署即可恢复;agent 侧每域名配置为整文件覆盖写,无残留 | | 回滚 | 关闭对应开关后重新部署即可恢复;agent 侧每域名配置为整文件覆盖写,无残留 |
+17 -17
View File
@@ -17,11 +17,11 @@
| 框架 | Vue 3 + `<script setup lang="ts">` + Vite 6 | | 框架 | Vue 3 + `<script setup lang="ts">` + Vite 6 |
| 组件库 | **naive-ui**(`n-button` `n-select` `n-tabs` `n-modal` `n-popconfirm` `n-alert` `n-spin` `n-icon` `n-tag` `n-tooltip` `n-form`) | | 组件库 | **naive-ui**(`n-button` `n-select` `n-tabs` `n-modal` `n-popconfirm` `n-alert` `n-spin` `n-icon` `n-tag` `n-tooltip` `n-form`) |
| 图标 | `@vicons/ionicons5` | | 图标 | `@vicons/ionicons5` |
| 令牌系统 | `web/src/styles/variables.css`:`:root` + `html.dark` 双主题,`--fonu-*` 前缀 | | 令牌系统 | `web/src/styles/variables.css`:`:root` + `html.dark` 双主题,`--havline-*` 前缀 |
| 共享组件 | `PageHeader` `FonuCard` `StatCard` `StatusBadge` `EmptyState` `LoadError` | | 共享组件 | `PageHeader` `HavlineCard` `StatCard` `StatusBadge` `EmptyState` `LoadError` |
| 工具 | `utils/format.ts`(`formatBytes/formatRate/formatDate/formatRelativeTime/…`)、`utils/status.ts`(`StatusKind`) | | 工具 | `utils/format.ts`(`formatBytes/formatRate/formatDate/formatRelativeTime/…`)、`utils/status.ts`(`StatusKind`) |
**结论**:沿用既有体系(naive-ui + `--fonu-*` + 共享组件),**不引入**第二套框架/令牌。 **结论**:沿用既有体系(naive-ui + `--havline-*` + 共享组件),**不引入**第二套框架/令牌。
--- ---
@@ -84,20 +84,20 @@
| 语义 | 令牌 | 用于 | 判定 | | 语义 | 令牌 | 用于 | 判定 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 正常 | `--fonu-brand`(`-soft`/`-text`) | 在线、运行中、监听中、成功输出 | 无需干预 | | 正常 | `--havline-brand`(`-soft`/`-text`) | 在线、运行中、监听中、成功输出 | 无需干预 |
| 待处理 | `--fonu-warning` | frps 未运行、端口未监听、frps 未安装、开机自启未启用 | 可一键恢复 | | 待处理 | `--havline-warning` | frps 未运行、端口未监听、frps 未安装、开机自启未启用 | 可一键恢复 |
| 需人工干预 | `--fonu-error` | Agent 未连接、Nginx 缺失、操作失败、卸载确认 | 需排查/不可逆 | | 需人工干预 | `--havline-error` | Agent 未连接、Nginx 缺失、操作失败、卸载确认 | 需排查/不可逆 |
| 中性 | `--fonu-text-secondary` / `--fonu-text-muted` | 字段名、说明、时间、空态 | — | | 中性 | `--havline-text-secondary` / `--havline-text-muted` | 字段名、说明、时间、空态 | — |
| 引导 | `--fonu-info`(`-soft`) | 页面级引导(反代入口等) | 非故障 | | 引导 | `--havline-info`(`-soft`) | 页面级引导(反代入口等) | 非故障 |
规则:一屏 error ≤ 1 处;warning 只用于"有目标状态但未达成",不用于"没数据"。亮/暗由 `variables.css` 覆盖,**页面零硬编码色值**。 规则:一屏 error ≤ 1 处;warning 只用于"有目标状态但未达成",不用于"没数据"。亮/暗由 `variables.css` 覆盖,**页面零硬编码色值**。
**Type(沿用项目字体,显式声明)** **Type(沿用项目字体,显式声明)**
- 正文 `--fonu-font`,等宽 `--fonu-mono`。skill 禁用 system-ui 系默认栈,但本项目已有设计系统,按 **"MATCH it, never add a second"** 继续使用;换字体属全局决策(改 `variables.css` + 全站回归),不在本页范围。 - 正文 `--havline-font`,等宽 `--havline-mono`。skill 禁用 system-ui 系默认栈,但本项目已有设计系统,按 **"MATCH it, never add a second"** 继续使用;换字体属全局决策(改 `variables.css` + 全站回归),不在本页范围。
- 页内 4 档字号 / 3 档字重:数值词条 18px/700(`.stat-card__value--sm`)、数值数字 30px/700/`-0.03em`、字段名与卡标签 12–13px/600、说明与时间 12–12.5px/400;路径/命令/版本/日志走 `--fonu-mono` 12.5–13px。 - 页内 4 档字号 / 3 档字重:数值词条 18px/700(`.stat-card__value--sm`)、数值数字 30px/700/`-0.03em`、字段名与卡标签 12–13px/600、说明与时间 12–12.5px/400;路径/命令/版本/日志走 `--havline-mono` 12.5–13px。
**Spacing & Radius**:只用 `--fonu-space-1…6` 与 `--fonu-radius(-sm)`、`--fonu-shadow`;禁止魔数;内联尺寸一律改类(`.agent-select` / `.frps-version-select`)。 **Spacing & Radius**:只用 `--havline-space-1…6` 与 `--havline-radius(-sm)`、`--havline-shadow`;禁止魔数;内联尺寸一律改类(`.agent-select` / `.frps-version-select`)。
--- ---
@@ -111,8 +111,8 @@
│ ● Agent 连接 ● frps 服务 ● 反代路由 ● 端口监听 │ ● Agent 连接 ● frps 服务 ● 反代路由 ● 端口监听
│ 在线/未连接 运行中/未运行 3 条 80✓ 443✓ │ 在线/未连接 运行中/未运行 3 条 80✓ 443✓
│ <agent 地址> <frps 版本> 健康 2/3 已生效 7000✓ 8080✗ │ <agent 地址> <frps 版本> 健康 2/3 已生效 7000✓ 8080✗
├ ② 环境与依赖(Fonu Agent | Nginx | frps 运行时,三列并排) ├ ② 环境与依赖(Havline Agent | Nginx | frps 运行时,三列并排)
│ Fonu Agent Nginx(本页新增) frps 运行时 │ Havline Agent Nginx(本页新增) frps 运行时
│ 版本 / 地址 可用性徽标 / 版本 二进制 / 版本 │ 版本 / 地址 可用性徽标 / 版本 二进制 / 版本
│ 凭据:已配置 ✓ 可执行路径 / 配置目录 配置路径 │ 凭据:已配置 ✓ 可执行路径 / 配置目录 配置路径
│ [探测环境] 错误原文(红) 启动命令 / 开机自启 │ [探测环境] 错误原文(红) 启动命令 / 开机自启
@@ -160,7 +160,7 @@
**动效**:首屏一次性 `fade-up`(`translateY(4px)→0`,160ms),仅 `onMounted` 触发一次;`prefers-reduced-motion: reduce` 全禁用;除此之外不做散点微动效(不加卡片 hover 位移/脉冲)。 **动效**:首屏一次性 `fade-up`(`translateY(4px)→0`,160ms),仅 `onMounted` 触发一次;`prefers-reduced-motion: reduce` 全禁用;除此之外不做散点微动效(不加卡片 hover 位移/脉冲)。
**无障碍**:状态区 `<section aria-label="运行状态">`;装饰图标 `aria-hidden`;按钮均有可读文本;输出区 `aria-live="polite"`;Tab 顺序 = 选择器 → 刷新 → 状态区(跳过只读)→ 各分区按钮带 → tabs;对比度实测 AA(`--fonu-brand/warning/error/text-muted` 在 `--fonu-surface` 上,亮+暗各一遍);≤760px 按钮 `min-height: 44px`;响应式 `1200+ / 900–1199 / ≤900 / ≤760`(运行状态 4→2→1 列;环境与依赖三列 →≤1199 降 2 列 →≤900 单列,且三列时卡片内短字段(版本/徽标)双列并排、长路径与命令整行,控制卡片高度),**320px 无横向溢出**。 **无障碍**:状态区 `<section aria-label="运行状态">`;装饰图标 `aria-hidden`;按钮均有可读文本;输出区 `aria-live="polite"`;Tab 顺序 = 选择器 → 刷新 → 状态区(跳过只读)→ 各分区按钮带 → tabs;对比度实测 AA(`--havline-brand/warning/error/text-muted` 在 `--havline-surface` 上,亮+暗各一遍);≤760px 按钮 `min-height: 44px`;响应式 `1200+ / 900–1199 / ≤900 / ≤760`(运行状态 4→2→1 列;环境与依赖三列 →≤1199 降 2 列 →≤900 单列,且三列时卡片内短字段(版本/徽标)双列并排、长路径与命令整行,控制卡片高度),**320px 无横向溢出**。
--- ---
@@ -217,7 +217,7 @@ function pushOutput(source: string, text: string, ok = true) {
## 九、验收清单 ## 九、验收清单
- [ ] 复用项目真实栈与令牌(naive-ui + `--fonu-*` + 共享组件),未新增第二套 - [ ] 复用项目真实栈与令牌(naive-ui + `--havline-*` + 共享组件),未新增第二套
- [ ] 单一设计方向(机架面板)落地,颜色/间距/圆角全走令牌,无 banned 字体引入 - [ ] 单一设计方向(机架面板)落地,颜色/间距/圆角全走令牌,无 banned 字体引入
- [ ] 交互状态齐全:hover / pressed / **focused(键盘实测)** / disabled / loading / empty / error - [ ] 交互状态齐全:hover / pressed / **focused(键盘实测)** / disabled / loading / empty / error
- [ ] 无障碍:对比度 AA(亮+暗)、可见焦点环、键盘可完成全部操作、`aria-live` 播报、图标 `aria-hidden` - [ ] 无障碍:对比度 AA(亮+暗)、可见焦点环、键盘可完成全部操作、`aria-live` 播报、图标 `aria-hidden`
@@ -233,7 +233,7 @@ function pushOutput(source: string, text: string, ok = true) {
| --- | --- | | --- | --- |
| `docs/agent-view-design.md`(v1) | 被本文取代(v1 只是结构搬家,未解决"功能不可见");确认后可删除 | | `docs/agent-view-design.md`(v1) | 被本文取代(v1 只是结构搬家,未解决"功能不可见");确认后可删除 |
| `docs/agent-view-refactor-plan.md` | 被本文取代 | | `docs/agent-view-refactor-plan.md` | 被本文取代 |
| 工作区 skill `fonu-page-ui` | 页面 UI 通用标准;本文是它在「公网 Agent」页的具体化 | | 工作区 skill `havline-page-ui` | 页面 UI 通用标准;本文是它在「公网 Agent」页的具体化 |
工作量:**单文件改动(`web/src/views/AgentView.vue`)**,约 5–6 小时(含 5 处能力补齐、状态机重构、无障碍与响应式)。 工作量:**单文件改动(`web/src/views/AgentView.vue`)**,约 5–6 小时(含 5 处能力补齐、状态机重构、无障碍与响应式)。
验证:`npx vue-tsc --noEmit` + `npx vite build`,再同步 `web/dist → cmd/fonu/web/dist` 后 `go build ./...`。 验证:`npx vue-tsc --noEmit` + `npx vite build`,再同步 `web/dist → cmd/havline/web/dist` 后 `go build ./...`。
+22 -22
View File
@@ -3,7 +3,7 @@
> **时效说明(2026-09-24)**:文中「顶部下拉选服务端」的结构已被 v1.2.6 的**左列表 + 右详情**取代(页面标题也改为「公网服务端」),现状见 `docs/frp-server-list-merge-plan.md`。 > **时效说明(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`(结构整改)被本文吸收。 > 并结合本项目(Havline)既有页面与设计系统。落地时以本文为准,`docs/agent-view-refactor-plan.md`(结构整改)被本文吸收。
> >
> 更新日期:2026-09-23 > 更新日期:2026-09-23
@@ -16,12 +16,12 @@
| 框架 | Vue 3 + `<script setup lang="ts">` + Vite 6(`web/package.json`) | | 框架 | Vue 3 + `<script setup lang="ts">` + Vite 6(`web/package.json`) |
| 组件库 | **naive-ui**(`n-button` `n-select` `n-tabs` `n-modal` `n-popconfirm` `n-alert` `n-spin` `n-icon` `n-tag` `n-form`) | | 组件库 | **naive-ui**(`n-button` `n-select` `n-tabs` `n-modal` `n-popconfirm` `n-alert` `n-spin` `n-icon` `n-tag` `n-form`) |
| 图标 | `@vicons/ionicons5` | | 图标 | `@vicons/ionicons5` |
| 令牌系统 | `web/src/styles/variables.css`:`:root` + `html.dark` / `[data-theme='dark']` 双主题,`--fonu-*` 前缀 | | 令牌系统 | `web/src/styles/variables.css`:`:root` + `html.dark` / `[data-theme='dark']` 双主题,`--havline-*` 前缀 |
| 共享组件 | `PageHeader` `FonuCard` `StatCard` `StatusBadge` `EmptyState` `LoadError`(另有 `MiniBarChart` 等图表件) | | 共享组件 | `PageHeader` `HavlineCard` `StatCard` `StatusBadge` `EmptyState` `LoadError`(另有 `MiniBarChart` 等图表件) |
| 工具 | `utils/format.ts`(`formatBytes/formatRate/formatDate/formatRelativeTime/…`)、`utils/status.ts`(`StatusKind` 语义) | | 工具 | `utils/format.ts`(`formatBytes/formatRate/formatDate/formatRelativeTime/…`)、`utils/status.ts`(`StatusKind` 语义) |
| 同族页面 | `DashboardView`(统计卡 + 插槽)、`DdnsView`(提示 + 状态卡)、`CertificatesView`(统计卡规格基准)、`ProxyView`(分组/弹窗)、`SettingsView`(分区) | | 同族页面 | `DashboardView`(统计卡 + 插槽)、`DdnsView`(提示 + 状态卡)、`CertificatesView`(统计卡规格基准)、`ProxyView`(分组/弹窗)、`SettingsView`(分区) |
**结论(skill 的 "Already has a design system → MATCH it" 规则)**:本页**沿用** naive-ui + `--fonu-*` 令牌 + 既有共享组件, **结论(skill 的 "Already has a design system → MATCH it" 规则)**:本页**沿用** naive-ui + `--havline-*` 令牌 + 既有共享组件,
**不引入** Tailwind / shadcn-vue 等第二套体系,**不新增**第二套令牌或全局样式。 **不引入** Tailwind / shadcn-vue 等第二套体系,**不新增**第二套令牌或全局样式。
--- ---
@@ -39,17 +39,17 @@
| 语义 | 令牌 | 用在哪 | 判定规则 | | 语义 | 令牌 | 用在哪 | 判定规则 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 正常 / 运行 | `--fonu-brand`(`--fonu-brand-soft`、`--fonu-brand-text`) | 在线、运行中、监听中、成功输出 | 无需人工干预 | | 正常 / 运行 | `--havline-brand`(`--havline-brand-soft`、`--havline-brand-text`) | 在线、运行中、监听中、成功输出 | 无需人工干预 |
| 待处理 | `--fonu-warning` | frps 未运行、端口未监听、frps 未安装、开机自启未启用 | 可一键恢复(引导操作) | | 待处理 | `--havline-warning` | frps 未运行、端口未监听、frps 未安装、开机自启未启用 | 可一键恢复(引导操作) |
| 需人工干预 | `--fonu-error` | Agent 未连接、操作失败输出、卸载确认 | 需用户排查或不可逆 | | 需人工干预 | `--havline-error` | Agent 未连接、操作失败输出、卸载确认 | 需用户排查或不可逆 |
| 中性信息 | `--fonu-text-secondary` / `--fonu-text-muted` | 字段名、说明、时间戳、空态 | — | | 中性信息 | `--havline-text-secondary` / `--havline-text-muted` | 字段名、说明、时间戳、空态 | — |
| 引导 | `--fonu-info`(`--fonu-info-soft`) | 页面级引导提示(反代入口等) | 非故障类提示 | | 引导 | `--havline-info`(`--havline-info-soft`) | 页面级引导提示(反代入口等) | 非故障类提示 |
规则:**一屏内 error 不超过一处**;warning 不用于"没数据",只用于"有目标状态但未达成"。亮/暗两套值由 `variables.css` 的 `html.dark` 覆盖,**页面内零硬编码色值**。 规则:**一屏内 error 不超过一处**;warning 不用于"没数据",只用于"有目标状态但未达成"。亮/暗两套值由 `variables.css` 的 `html.dark` 覆盖,**页面内零硬编码色值**。
### Type(沿用项目字体,显式声明) ### Type(沿用项目字体,显式声明)
- 正文字体 `--fonu-font`(`-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei'`),等宽 `--fonu-mono`。 - 正文字体 `--havline-font`(`-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei'`),等宽 `--havline-mono`。
- **关于 skill 的"禁用 system-ui 系默认栈"**:本项目已存在设计系统,按 "MATCH it, never add a second" 规则,**本页继续使用项目字体**;替换字体属**全局决策**(改 `variables.css`,影响全站并需全站回归),不在本页范围。 - **关于 skill 的"禁用 system-ui 系默认栈"**:本项目已存在设计系统,按 "MATCH it, never add a second" 规则,**本页继续使用项目字体**;替换字体属**全局决策**(改 `variables.css`,影响全站并需全站回归),不在本页范围。
- 页内只用 4 档字号 + 3 档字重(与 `CertificatesView` 统计卡规格一致): - 页内只用 4 档字号 + 3 档字重(与 `CertificatesView` 统计卡规格一致):
@@ -58,13 +58,13 @@
| 统计卡数值(词条类) | 18px / 700 | `.stat-card__value--sm` | | 统计卡数值(词条类) | 18px / 700 | `.stat-card__value--sm` |
| 统计卡数值(数字类) | 30px / 700,`letter-spacing:-0.03em` | `.stat-card__value` | | 统计卡数值(数字类) | 30px / 700,`letter-spacing:-0.03em` | `.stat-card__value` |
| 字段名 / 卡片标签 | 12–13px / 600 | `.stat-card__label`、`.field-grid label` | | 字段名 / 卡片标签 | 12–13px / 600 | `.stat-card__label`、`.field-grid label` |
| 说明 / 时间戳 / 空态 | 12–12.5px / 400 | `--fonu-text-muted`、`.form-hint` | | 说明 / 时间戳 / 空态 | 12–12.5px / 400 | `--havline-text-muted`、`.form-hint` |
| 路径 / 命令 / 版本 / 日志 | `--fonu-mono` 12.5–13px | `.mono` / `.mono-wrap` / `.code-block` | | 路径 / 命令 / 版本 / 日志 | `--havline-mono` 12.5–13px | `.mono` / `.mono-wrap` / `.code-block` |
### Spacing & Radius ### Spacing & Radius
- 间距只用 `--fonu-space-1…6`(4/8/12/16/24/32),**禁止魔数**;卡片内边距统一 `var(--fonu-space-4) var(--fonu-space-5)`。 - 间距只用 `--havline-space-1…6`(4/8/12/16/24/32),**禁止魔数**;卡片内边距统一 `var(--havline-space-4) var(--havline-space-5)`。
- 圆角只用 `--fonu-radius`(12px) / `--fonu-radius-sm`(8px);阴影只用 `--fonu-shadow`。 - 圆角只用 `--havline-radius`(12px) / `--havline-radius-sm`(8px);阴影只用 `--havline-shadow`。
- 页面里已有的 `style="width: 220px"` 这类内联尺寸,一律改为类(`.agent-select` / `.frps-version-select`)。 - 页面里已有的 `style="width: 220px"` 这类内联尺寸,一律改为类(`.agent-select` / `.frps-version-select`)。
--- ---
@@ -80,13 +80,13 @@
│ 在线/未连接 运行中/未运行 监听中/未监听 监听中/未监听 │ 在线/未连接 运行中/未运行 监听中/未监听 监听中/未监听
│ <agent 地址> <frps 版本> HTTP 入口 HTTPS 入口 │ <agent 地址> <frps 版本> HTTP 入口 HTTPS 入口
├ 两列网格 ├ 两列网格
│ FonuCard 服务状态详情 FonuCard frps 服务控制 │ HavlineCard 服务状态详情 HavlineCard frps 服务控制
│ field-grid 2 列: 按钮带:启动|停止 · 重启 · │ field-grid 2 列: 按钮带:启动|停止 · 重启 ·
│ Agent 版本 / frps 版本 frps 管理面板 · 下发配置 │ Agent 版本 / frps 版本 frps 管理面板 · 下发配置
│ 启动命令 / 配置文件 [版本选择 ▾] 安装/升级 │ 启动命令 / 配置文件 [版本选择 ▾] 安装/升级
│ 安装位置 / 开机自启 │ 安装位置 / 开机自启
│ 7000 端口 │ 7000 端口
│ FonuCard Agent 生命周期(SSH) FonuCard 输出与日志 │ HavlineCard Agent 生命周期(SSH) HavlineCard 输出与日志
│ 探测环境 · 安装/升级 Agent n-tabs:操作输出 | frps 日志 │ 探测环境 · 安装/升级 Agent n-tabs:操作输出 | frps 日志
│ SSH 诊断 · 卸载(保留) 操作输出 = 时间线(来源+时间+ok) │ SSH 诊断 · 卸载(保留) 操作输出 = 时间线(来源+时间+ok)
│ 卸载(含数据) ← 二次确认 空态 EmptyState · [清空] │ 卸载(含数据) ← 二次确认 空态 EmptyState · [清空]
@@ -125,7 +125,7 @@
**动效(high-impact, restrained)** **动效(high-impact, restrained)**
- 首屏**一次性**入场:状态区与卡片 `fade-up`(`translateY(4px)→0`,160ms,`--fonu-shadow` 不变),仅 `onMounted` 后触发一次; - 首屏**一次性**入场:状态区与卡片 `fade-up`(`translateY(4px)→0`,160ms,`--havline-shadow` 不变),仅 `onMounted` 后触发一次;
- `@media (prefers-reduced-motion: reduce)` 下全部禁用; - `@media (prefers-reduced-motion: reduce)` 下全部禁用;
- 除入场与按钮库内置反馈外,**不做**散点微动效(不加卡片 hover 位移、不加脉冲动画)。 - 除入场与按钮库内置反馈外,**不做**散点微动效(不加卡片 hover 位移、不加脉冲动画)。
@@ -134,7 +134,7 @@
- 语义:状态区用 `<section aria-label="服务状态">`;装饰性图标 `aria-hidden="true"`;按钮均有可读文本(不出现只有图标的裸按钮); - 语义:状态区用 `<section aria-label="服务状态">`;装饰性图标 `aria-hidden="true"`;按钮均有可读文本(不出现只有图标的裸按钮);
- 动态内容:操作输出容器 `aria-live="polite"`(异步追加需播报);`n-popconfirm` 已带键盘与焦点管理; - 动态内容:操作输出容器 `aria-live="polite"`(异步追加需播报);`n-popconfirm` 已带键盘与焦点管理;
- 键盘:Tab 顺序 = 选择器 → 刷新 → 状态区(跳过只读)→ 各卡片按钮带 → tabs;`n-tabs` 支持方向键; - 键盘:Tab 顺序 = 选择器 → 刷新 → 状态区(跳过只读)→ 各卡片按钮带 → tabs;`n-tabs` 支持方向键;
- 对比度:实测下列组合达 WCAG AA(正文 4.5:1、大字号 3:1)——`--fonu-brand`/`--fonu-warning`/`--fonu-error`/`--fonu-text-muted` 在 `--fonu-surface` 上,**亮色与暗色各测一遍**; - 对比度:实测下列组合达 WCAG AA(正文 4.5:1、大字号 3:1)——`--havline-brand`/`--havline-warning`/`--havline-error`/`--havline-text-muted` 在 `--havline-surface` 上,**亮色与暗色各测一遍**;
- 触控:≤760px 时按钮 `min-height: 44px`(或升为 `size="medium"`),满足最小触控目标; - 触控:≤760px 时按钮 `min-height: 44px`(或升为 `size="medium"`),满足最小触控目标;
- 响应式:`1200px+` 4 列状态 / `900–1199px` 2 列 / `≤900px` 单列 + 两列网格并一列 / `≤760px` 按钮铺满 / **`320px` 无横向溢出**。 - 响应式:`1200px+` 4 列状态 / `900–1199px` 2 列 / `≤900px` 单列 + 两列网格并一列 / `≤760px` 按钮铺满 / **`320px` 无横向溢出**。
@@ -184,7 +184,7 @@ function pushOutput(source: string, text: string, ok = true) {
## 七、本页不做(Never) ## 七、本页不做(Never)
- 不引入第二套 UI 框架 / 令牌 / 全局样式(naive-ui + `--fonu-*` 已存在); - 不引入第二套 UI 框架 / 令牌 / 全局样式(naive-ui + `--havline-*` 已存在);
- 不改全局字体与令牌(属全站决策); - 不改全局字体与令牌(属全站决策);
- 不用硬编码色值与魔数间距(一律令牌); - 不用硬编码色值与魔数间距(一律令牌);
- 不做渐变、居中大标题、统一灰卡片这类 slop 模式; - 不做渐变、居中大标题、统一灰卡片这类 slop 模式;
@@ -196,7 +196,7 @@ function pushOutput(source: string, text: string, ok = true) {
## 八、验收清单(对照 skill self-check) ## 八、验收清单(对照 skill self-check)
- [ ] 复用了项目真实栈与令牌(naive-ui + `--fonu-*` + 共享组件),未新增第二套 - [ ] 复用了项目真实栈与令牌(naive-ui + `--havline-*` + 共享组件),未新增第二套
- [ ] 单一设计方向(机架面板)落地:状态条 + 输出时间线为 signature,无 banned 字体引入,颜色/间距/圆角全部走令牌 - [ ] 单一设计方向(机架面板)落地:状态条 + 输出时间线为 signature,无 banned 字体引入,颜色/间距/圆角全部走令牌
- [ ] 交互状态齐全:hover / pressed / **focused(键盘实测)** / disabled / loading / empty / error - [ ] 交互状态齐全:hover / pressed / **focused(键盘实测)** / disabled / loading / empty / error
- [ ] 无障碍:对比度 AA(亮+暗)、可见焦点环、键盘可完成全部操作、`aria-live` 播报输出、图标 `aria-hidden` - [ ] 无障碍:对比度 AA(亮+暗)、可见焦点环、键盘可完成全部操作、`aria-live` 播报输出、图标 `aria-hidden`
@@ -210,7 +210,7 @@ function pushOutput(source: string, text: string, ok = true) {
| 文档 | 关系 | | 文档 | 关系 |
| --- | --- | | --- | --- |
| `docs/agent-view-refactor-plan.md` | 结构整改清单(12 项问题 + 分步改造),**本文吸收其全部内容**并补上设计方向、状态矩阵、无障碍与动效规格;实施时以本文为准 | | `docs/agent-view-refactor-plan.md` | 结构整改清单(12 项问题 + 分步改造),**本文吸收其全部内容**并补上设计方向、状态矩阵、无障碍与动效规格;实施时以本文为准 |
| `web/.breezell/skills/fonu-page-ui`(工作区 skill) | 页面 UI 通用标准;本文是它在「公网 Agent」页的具体化 | | `web/.breezell/skills/havline-page-ui`(工作区 skill) | 页面 UI 通用标准;本文是它在「公网 Agent」页的具体化 |
工作量:**单文件改动(`web/src/views/AgentView.vue`)**,约 4–5 小时(比原结构整改多出的部分:设计方向落地、状态矩阵、无障碍与动效、320px 适配)。 工作量:**单文件改动(`web/src/views/AgentView.vue`)**,约 4–5 小时(比原结构整改多出的部分:设计方向落地、状态矩阵、无障碍与动效、320px 适配)。
验证:`npx vue-tsc --noEmit` + `npx vite build`,再同步 `web/dist → cmd/fonu/web/dist` 后 `go build ./...` 使本地生效。 验证:`npx vue-tsc --noEmit` + `npx vite build`,再同步 `web/dist → cmd/havline/web/dist` 后 `go build ./...` 使本地生效。
+12 -12
View File
@@ -1,7 +1,7 @@
# 公网 Agent 页(`AgentView.vue`)整合整改方案 # 公网 Agent 页(`AgentView.vue`)整合整改方案
> 目标:把「公网 Agent」页从"功能堆叠"整理成**状态 → 控制 → 生命周期 → 输出**四段式结构, > 目标:把「公网 Agent」页从"功能堆叠"整理成**状态 → 控制 → 生命周期 → 输出**四段式结构,
> 信息层级清晰、操作归属明确、危险操作可控,并完全符合 `fonu-page-ui` 页面 UI 标准。 > 信息层级清晰、操作归属明确、危险操作可控,并完全符合 `havline-page-ui` 页面 UI 标准。
> >
> 参照页:`DdnsView.vue`(状态卡 + 提示)、`CertificatesView.vue`(统计卡规格)、`ProxyView.vue`(分组与按钮区)、`SettingsView.vue`(分区)。 > 参照页:`DdnsView.vue`(状态卡 + 提示)、`CertificatesView.vue`(统计卡规格)、`ProxyView.vue`(分组与按钮区)、`SettingsView.vue`(分区)。
> 更新日期:2026-09-23 > 更新日期:2026-09-23
@@ -17,7 +17,7 @@
| 3 | **loading 粒度太粗** | 所有按钮都用 `:loading="agentActionId === server.id"`——任何操作进行中,页面**全部**按钮一起转圈 | 无法分辨在跑哪个操作,也无法阻止并发操作 | | 3 | **loading 粒度太粗** | 所有按钮都用 `:loading="agentActionId === server.id"`——任何操作进行中,页面**全部**按钮一起转圈 | 无法分辨在跑哪个操作,也无法阻止并发操作 |
| 4 | **输出全部塞进一个 `agentOutput`** | 探测 / 安装 / 卸载 / SSH 诊断 / frps 启停 / 下发配置 / 安装 frps 七类输出共用一个 ref,显示在「Agent 生命周期」卡底部 | 输出归属不明;执行「重启 frps」后输出出现在 SSH 卡里,语义错位 | | 4 | **输出全部塞进一个 `agentOutput`** | 探测 / 安装 / 卸载 / SSH 诊断 / frps 启停 / 下发配置 / 安装 frps 七类输出共用一个 ref,显示在「Agent 生命周期」卡底部 | 输出归属不明;执行「重启 frps」后输出出现在 SSH 卡里,语义错位 |
| 5 | **日志与输出分居两卡** | 「frps 最近日志」独立成卡,`agentOutput` 在另一张卡 | 排障时要在两处来回看 | | 5 | **日志与输出分居两卡** | 「frps 最近日志」独立成卡,`agentOutput` 在另一张卡 | 排障时要在两处来回看 |
| 6 | **危险操作无二次确认** | 「卸载(含数据)」按钮点击即执行,`rm -rf /var/lib/fonu-agent` 不可逆 | 误点即丢路由与证书 | | 6 | **危险操作无二次确认** | 「卸载(含数据)」按钮点击即执行,`rm -rf /var/lib/havline-agent` 不可逆 | 误点即丢路由与证书 |
| 7 | **页面级提示缺失** | agent 未连接、frps 未安装等状态只在卡片里体现,没有 `n-alert` 引导(技能要求:页面级提示放 PageHeader 之后) | 新用户不知道下一步该去哪一页 | | 7 | **页面级提示缺失** | agent 未连接、frps 未安装等状态只在卡片里体现,没有 `n-alert` 引导(技能要求:页面级提示放 PageHeader 之后) | 新用户不知道下一步该去哪一页 |
| 8 | **跳转卡占位浪费** | 「公网 Nginx 反代」整卡只放一句说明 + 一个跳转按钮 | 占掉半行网格,信息密度低 | | 8 | **跳转卡占位浪费** | 「公网 Nginx 反代」整卡只放一句说明 + 一个跳转按钮 | 占掉半行网格,信息密度低 |
| 9 | **轮询与用户操作耦合** | 10 秒 `refreshAll()` 里设置 `agentActionId`,与用户点击共用同一状态 | 轮询时按钮闪烁 loading | | 9 | **轮询与用户操作耦合** | 10 秒 `refreshAll()` 里设置 `agentActionId`,与用户点击共用同一状态 | 轮询时按钮闪烁 loading |
@@ -38,13 +38,13 @@ stats-row(统一 4 列稳定网格)
├─ frps 服务(运行中/未运行 + 版本) ├─ frps 服务(运行中/未运行 + 版本)
├─ 80 端口 ├─ 80 端口
└─ 443 端口 └─ 443 端口
FonuCard「服务状态详情」 HavlineCard「服务状态详情」
└─ field-grid:Agent 版本 / 已安装 frps 版本 / frps 启动命令 / 配置文件 / 安装位置 / 开机自启 / 7000 端口(StatusBadge) └─ field-grid:Agent 版本 / 已安装 frps 版本 / frps 启动命令 / 配置文件 / 安装位置 / 开机自启 / 7000 端口(StatusBadge)
FonuCard「frps 服务控制」 HavlineCard「frps 服务控制」
└─ 按钮区:启动|停止 · 重启 · frps 管理面板 · 下发配置 · [版本选择] 安装/升级 └─ 按钮区:启动|停止 · 重启 · frps 管理面板 · 下发配置 · [版本选择] 安装/升级
FonuCard「Agent 生命周期(SSH)」 HavlineCard「Agent 生命周期(SSH)」
└─ 按钮区:探测环境 · 安装/升级 Agent · SSH 诊断 · 卸载(保留数据)· 卸载(含数据,二次确认) └─ 按钮区:探测环境 · 安装/升级 Agent · SSH 诊断 · 卸载(保留数据)· 卸载(含数据,二次确认)
FonuCard「输出与日志」(n-tabs) HavlineCard「输出与日志」(n-tabs)
├─ Tab 操作输出:带来源标签 + 时间;空态 EmptyState;「清空」按钮 ├─ Tab 操作输出:带来源标签 + 时间;空态 EmptyState;「清空」按钮
└─ Tab frps 日志:frps_log_tail;空态 EmptyState └─ Tab frps 日志:frps_log_tail;空态 EmptyState
``` ```
@@ -64,8 +64,8 @@ FonuCard「输出与日志」(n-tabs)
### 2. 统一网格(解决 #2、#12) ### 2. 统一网格(解决 #2、#12)
```css ```css
.stats-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); } .stats-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--havline-space-4); margin-bottom: var(--havline-space-4); }
.agent-page-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); } .agent-page-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--havline-space-4); margin-bottom: var(--havline-space-4); }
@media (max-width: 900px) { .stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } .agent-page-grid { grid-template-columns: 1fr; } } @media (max-width: 900px) { .stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } .agent-page-grid { grid-template-columns: 1fr; } }
@media (max-width: 760px) { .stats-row { grid-template-columns: 1fr; } .agent-actions { width: 100%; } .agent-actions .n-button { flex: 1 1 auto; } } @media (max-width: 760px) { .stats-row { grid-template-columns: 1fr; } .agent-actions { width: 100%; } .agent-actions .n-button { flex: 1 1 auto; } }
``` ```
@@ -111,7 +111,7 @@ function pushOutput(source: string, text: string, ok = true) {
```vue ```vue
<n-popconfirm @positive-click="uninstallAgent(server, false)"> <n-popconfirm @positive-click="uninstallAgent(server, false)">
<template #trigger><n-button size="small" type="warning">卸载(含数据)</n-button></template> <template #trigger><n-button size="small" type="warning">卸载(含数据)</n-button></template>
将删除 VPS 上的 agent、frps 单元与 /var/lib/fonu-agent(路由与证书一并丢失),确认继续? 将删除 VPS 上的 agent、frps 单元与 /var/lib/havline-agent(路由与证书一并丢失),确认继续?
</n-popconfirm> </n-popconfirm>
``` ```
@@ -123,7 +123,7 @@ function pushOutput(source: string, text: string, ok = true) {
| 条件 | 类型 | 文案要点 | 动作 | | 条件 | 类型 | 文案要点 | 动作 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `!agentStatusOk` | `error` | Agent 未连接:检查 VPS 上 fonu-agent 是否运行、Agent 地址与 Token 是否正确 | 「探测环境」 | | `!agentStatusOk` | `error` | Agent 未连接:检查 VPS 上 havline-agent 是否运行、Agent 地址与 Token 是否正确 | 「探测环境」 |
| `!agentStatus?.frps_active` | `warning` | frps 未运行:可点「启动 frps」或在下方安装 | 「启动 frps」 | | `!agentStatus?.frps_active` | `warning` | frps 未运行:可点「启动 frps」或在下方安装 | 「启动 frps」 |
| 反代入口引导(常驻) | `info` | 反代的逐条部署/配置查看/访问控制在「公网反代」页 | 跳转 `/public-proxy` | | 反代入口引导(常驻) | `info` | 反代的逐条部署/配置查看/访问控制在「公网反代」页 | 跳转 `/public-proxy` |
@@ -152,7 +152,7 @@ watch(releases, (list) => { if (!frpsInstallVersion.value && list.length) frpsIn
| --- | --- | | --- | --- |
| `web/src/views/AgentView.vue` | 模板结构重排、脚本状态重构、样式网格与断点补齐(单文件改动) | | `web/src/views/AgentView.vue` | 模板结构重排、脚本状态重构、样式网格与断点补齐(单文件改动) |
| `web/src/api/client.ts` / `types.ts` | 预期**无需**改动(现有 `getFrpAgentStatus` / `probeFrpAgent` / `installFrpAgent` / `uninstallFrpAgent` / `sshDiagnoseFrpAgent` / `frpsServerActionRaw` / `pushFrpConfigToAgent` / `installFrpServer` / `listFrpReleases` 已够用) | | `web/src/api/client.ts` / `types.ts` | 预期**无需**改动(现有 `getFrpAgentStatus` / `probeFrpAgent` / `installFrpAgent` / `uninstallFrpAgent` / `sshDiagnoseFrpAgent` / `frpsServerActionRaw` / `pushFrpConfigToAgent` / `installFrpServer` / `listFrpReleases` 已够用) |
| `cmd/fonu/web/dist` | 构建产物同步(gitignore,不入库) | | `cmd/havline/web/dist` | 构建产物同步(gitignore,不入库) |
验证闭环(技能第 8 节): 验证闭环(技能第 8 节):
@@ -160,7 +160,7 @@ watch(releases, (list) => { if (!frpsInstallVersion.value && list.length) frpsIn
cd web cd web
npx vue-tsc --noEmit # 0 错误 npx vue-tsc --noEmit # 0 错误
npx vite build # 成功 npx vite build # 成功
# 本地生效:同步 dist → cmd/fonu/web/dist 后 go build ./... # 本地生效:同步 dist → cmd/havline/web/dist 后 go build ./...
``` ```
回归检查点(真机,VPS 38.95.74.87): 回归检查点(真机,VPS 38.95.74.87):
+11 -11
View File
@@ -5,19 +5,19 @@
> 相关文档: > 相关文档:
> - `cloudflared-integration-plan.md`:早期融合方案 > - `cloudflared-integration-plan.md`:早期融合方案
> - `cloudflared-feature-plan.md`:官方与参考项目功能全集,用于查漏 > - `cloudflared-feature-plan.md`:官方与参考项目功能全集,用于查漏
> - `cloudflared-fonu-fit-plan.md`:Fonu 适配版阶段计划 > - `cloudflared-havline-fit-plan.md`:Havline 适配版阶段计划
--- ---
## 一、模块定位 ## 一、模块定位
Cloudflare 隧道在 Fonu 中不是独立的 cloudflared 管理面板,而是公网访问的第二条出口: Cloudflare 隧道在 Havline 中不是独立的 cloudflared 管理面板,而是公网访问的第二条出口:
- 本机 Nginx:适合有公网 IP 或已有端口映射的场景。 - 本机 Nginx:适合有公网 IP 或已有端口映射的场景。
- FRP:适合需要 TCP/UDP、自控 VPS 或大流量的场景。 - FRP:适合需要 TCP/UDP、自控 VPS 或大流量的场景。
- Cloudflare 隧道:适合没有公网 IP、主要发布 HTTP/HTTPS Web 服务的场景。 - Cloudflare 隧道:适合没有公网 IP、主要发布 HTTP/HTTPS Web 服务的场景。
当前模块已经融入 Fonu 的规则、监控、通知、状态页、日志、备份、诊断和升级体系。 当前模块已经融入 Havline 的规则、监控、通知、状态页、日志、备份、诊断和升级体系。
--- ---
@@ -183,7 +183,7 @@ Cloudflare 隧道在 Fonu 中不是独立的 cloudflared 管理面板,而是
- 崩溃后读取日志尾部便于诊断。 - 崩溃后读取日志尾部便于诊断。
- 开机启动: - 开机启动:
- 全局托管开启时,应用启动会恢复全部隧道。 - 全局托管开启时,应用启动会恢复全部隧道。
- “随 Fonu 自动启动”开关保留为隧道级选项。 - “随 Havline 自动启动”开关保留为隧道级选项。
- 全局总开关: - 全局总开关:
- 开启时启动全部隧道。 - 开启时启动全部隧道。
- 关闭时停止全部隧道。 - 关闭时停止全部隧道。
@@ -300,10 +300,10 @@ Cloudflare 隧道在 Fonu 中不是独立的 cloudflared 管理面板,而是
<hostname> -> <tunnel-id>.cfargotunnel.com <hostname> -> <tunnel-id>.cfargotunnel.com
``` ```
- 开启 Cloudflare 代理(橙云)。 - 开启 Cloudflare 代理(橙云)。
- 记录 Fonu 创建或接管的 DNS 记录: - 记录 Havline 创建或接管的 DNS 记录:
- `cf_dns_records` - `cf_dns_records`
- 删除域名或移除出口时: - 删除域名或移除出口时:
- 只删除 Fonu 自己接管的记录。 - 只删除 Havline 自己接管的记录。
- 不覆盖指向其他目标的用户 DNS 记录。 - 不覆盖指向其他目标的用户 DNS 记录。
- 域名从一条隧道迁移到另一条隧道时支持接管迁移。 - 域名从一条隧道迁移到另一条隧道时支持接管迁移。
@@ -395,15 +395,15 @@ Cloudflare 隧道在 Fonu 中不是独立的 cloudflared 管理面板,而是
已实现: 已实现:
- 备份内容包含: - 备份内容包含:
- `fonu.db` - `havline.db`
- `certs` - `certs`
- `nginx` - `nginx`
- `cloudflared/tunnels` - `cloudflared/tunnels`
- `cloudflared/tunnels` 包含: - `cloudflared/tunnels` 包含:
- `config.yml` - `config.yml`
- `creds.json` - `creds.json`
- 数据库中的 `cf_tunnels`、`cf_settings`、`cf_dns_records` 随 `fonu.db` 恢复。 - 数据库中的 `cf_tunnels`、`cf_settings`、`cf_dns_records` 随 `havline.db` 恢复。
- 恢复后仍建议重启 Fonu,由启动流程恢复全部隧道。 - 恢复后仍建议重启 Havline,由启动流程恢复全部隧道。
--- ---
@@ -436,7 +436,7 @@ Cloudflare 隧道在 Fonu 中不是独立的 cloudflared 管理面板,而是
- 本地 `config.yml`。 - 本地 `config.yml`。
- 隧道进程状态。 - 隧道进程状态。
- Cloudflare API 与云端配置可访问性。 - Cloudflare API 与云端配置可访问性。
- Fonu 接管的 DNS 记录。 - Havline 接管的 DNS 记录。
- Cloudflare edge region1。 - Cloudflare edge region1。
- Cloudflare edge region2。 - Cloudflare edge region2。
@@ -572,7 +572,7 @@ GET /api/cloudflare/access-status
### `cf_dns_records` ### `cf_dns_records`
保存 Fonu 接管的 DNS 记录: 保存 Havline 接管的 DNS 记录:
- 本地隧道 ID。 - 本地隧道 ID。
- Zone ID。 - Zone ID。
+20 -20
View File
@@ -1,14 +1,14 @@
# Cloudflare 隧道模块全功能规划 # Cloudflare 隧道模块全功能规划
> 调研日期:2026-09-30 > 调研日期: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 现有实现 > 调研对象:Cloudflare 官方文档(developers.cloudflare.com,2026-08/09 更新)+ `F:\WWW\cf\Tunnel-Cloudflared-linux`(Tunnel-Cloudflared-Manager 1.0.1)+ Havline 1.8.0 现有实现
> 目标:把 Fonu 的「Cloudflare 隧道」从「可用的创建与托管」推进到「覆盖官方主要能力、可长期运维」的完整模块。 > 目标:把 Havline 的「Cloudflare 隧道」从「可用的创建与托管」推进到「覆盖官方主要能力、可长期运维」的完整模块。
--- ---
## 一、官方文档调研结论 ## 一、官方文档调研结论
Cloudflare 把 Tunnel 分成两条产品线,Fonu 目前主要落在第一条,规划时需要同时兼顾两条: Cloudflare 把 Tunnel 分成两条产品线,Havline 目前主要落在第一条,规划时需要同时兼顾两条:
| 产品线 | 文档入口 | 典型用途 | | 产品线 | 文档入口 | 典型用途 |
| --- | --- | --- | | --- | --- | --- |
@@ -76,14 +76,14 @@ Cloudflare 把 Tunnel 分成两条产品线,Fonu 目前主要落在第一条
### 1.3 官方版本要点(2026-09) ### 1.3 官方版本要点(2026-09)
- 运行参数文档最近更新 2026-09;`post-quantum`、`token-file`、`region`、`tag`、`dns-resolver-addrs`、`edge-bind-address` 等参数已进入官方参考。 - 运行参数文档最近更新 2026-09;`post-quantum`、`token-file`、`region`、`tag`、`dns-resolver-addrs`、`edge-bind-address` 等参数已进入官方参考。
- `edge-ip-version` 官方默认值曾由 `4` 调整为 `auto`;Fonu 已显式下发 `4`,与国内链路建议一致。 - `edge-ip-version` 官方默认值曾由 `4` 调整为 `auto`;Havline 已显式下发 `4`,与国内链路建议一致。
- 官方把「公开发布应用」与「Zero Trust 私有网络」拆成两条文档线,Fonu 需要明确自己的默认定位是公开发布应用。 - 官方把「公开发布应用」与「Zero Trust 私有网络」拆成两条文档线,Havline 需要明确自己的默认定位是公开发布应用。
--- ---
## 二、参考项目功能清单(Tunnel-Cloudflared-Manager 1.0.1) ## 二、参考项目功能清单(Tunnel-Cloudflared-Manager 1.0.1)
参考项目是一个独立面板,功能面比 Fonu 当前实现更宽,但缺少与 Fonu 的规则、健康、通知、状态页体系融合。 参考项目是一个独立面板,功能面比 Havline 当前实现更宽,但缺少与 Havline 的规则、健康、通知、状态页体系融合。
### 2.1 隧道管理 ### 2.1 隧道管理
@@ -143,7 +143,7 @@ Cloudflare 把 Tunnel 分成两条产品线,Fonu 目前主要落在第一条
--- ---
## 三、Fonu 当前实现(v1.8.0) ## 三、Havline 当前实现(v1.8.0)
| 能力 | 状态 | | 能力 | 状态 |
| --- | --- | | --- | --- |
@@ -167,7 +167,7 @@ Cloudflare 把 Tunnel 分成两条产品线,Fonu 目前主要落在第一条
| 账号模式(cert.pem) | ❌ 未实现 | | 账号模式(cert.pem) | ❌ 未实现 |
| 实时日志流(WebSocket/SSE) | ❌ 未实现 | | 实时日志流(WebSocket/SSE) | ❌ 未实现 |
| 诊断日志、网络预检、隧道 info | ❌ 未实现 | | 诊断日志、网络预检、隧道 info | ❌ 未实现 |
| 接入 Fonu monitor / 通知 / 状态页 | ❌ 未实现 | | 接入 Havline monitor / 通知 / 状态页 | ❌ 未实现 |
| cloudflared 自动更新 | ❌ 未实现 | | cloudflared 自动更新 | ❌ 未实现 |
| 自定义镜像管理、下载进度 | ❌ 未实现 | | 自定义镜像管理、下载进度 | ❌ 未实现 |
| Origin 高级参数 UI | ❌ 未实现 | | Origin 高级参数 UI | ❌ 未实现 |
@@ -181,10 +181,10 @@ Cloudflare 把 Tunnel 分成两条产品线,Fonu 目前主要落在第一条
### 4.1 必须补齐(否则模块不完整) ### 4.1 必须补齐(否则模块不完整)
1. **云端隧道列表与导入**:目前 Fonu 只能管理自己创建的隧道,已有隧道无法纳管。 1. **云端隧道列表与导入**:目前 Havline 只能管理自己创建的隧道,已有隧道无法纳管。
2. **端点级 CRUD**:现在是整体覆盖 ingress,无法单独增删改一条 hostname/path。 2. **端点级 CRUD**:现在是整体覆盖 ingress,无法单独增删改一条 hostname/path。
3. **云端配置拉取与漂移检测**:云端被手动修改后,Fonu 无法发现。 3. **云端配置拉取与漂移检测**:云端被手动修改后,Havline 无法发现。
4. **健康接入 Fonu 体系**:隧道状态没有进入 monitor、通知、状态页、可用率。 4. **健康接入 Havline 体系**:隧道状态没有进入 monitor、通知、状态页、可用率。
5. **实时日志**:轮询文件在故障排查时体验差。 5. **实时日志**:轮询文件在故障排查时体验差。
6. **诊断能力**:缺 `tunnel info`、`tunnel diag`、网络预检、端口要求检查。 6. **诊断能力**:缺 `tunnel info`、`tunnel diag`、网络预检、端口要求检查。
@@ -215,7 +215,7 @@ Cloudflare 把 Tunnel 分成两条产品线,Fonu 目前主要落在第一条
**P0-1 云端隧道同步与导入** **P0-1 云端隧道同步与导入**
- 目标:列出 Cloudflare 账号下的全部隧道,把未纳管的隧道导入 Fonu。 - 目标:列出 Cloudflare 账号下的全部隧道,把未纳管的隧道导入 Havline。
- 实现:`GET /accounts/{id}/cfd_tunnel` 列表;导入时读取 token、写 `creds.json`/`config.yml`、落库。 - 实现:`GET /accounts/{id}/cfd_tunnel` 列表;导入时读取 token、写 `creds.json`/`config.yml`、落库。
- 验收:账号下已有隧道能在页面看到并导入;重复导入不产生重复记录。 - 验收:账号下已有隧道能在页面看到并导入;重复导入不产生重复记录。
@@ -227,11 +227,11 @@ Cloudflare 把 Tunnel 分成两条产品线,Fonu 目前主要落在第一条
**P0-3 云端配置拉取与漂移检测** **P0-3 云端配置拉取与漂移检测**
- 目标:云端被手动修改后,Fonu 能发现差异并选择拉取或覆盖。 - 目标:云端被手动修改后,Havline 能发现差异并选择拉取或覆盖。
- 实现:`GET /accounts/{id}/cfd_tunnel/{tunnel}/configurations`;与本地 config 做 diff;提供「拉取到本地」「推送到云端」。 - 实现:`GET /accounts/{id}/cfd_tunnel/{tunnel}/configurations`;与本地 config 做 diff;提供「拉取到本地」「推送到云端」。
- 验收:diff 可见;两个方向都能执行并留痕。 - 验收:diff 可见;两个方向都能执行并留痕。
**P0-4 健康接入 Fonu 体系** **P0-4 健康接入 Havline 体系**
- 目标:隧道断连、恢复进入现有 monitor、通知、状态页与可用率。 - 目标:隧道断连、恢复进入现有 monitor、通知、状态页与可用率。
- 实现:新增 cloudflared 的 `monitor.Source`;`/ready` + `ha_connections` 判活;断连/恢复事件复用 notify;状态页增加隧道条目。 - 实现:新增 cloudflared 的 `monitor.Source`;`/ready` + `ha_connections` 判活;断连/恢复事件复用 notify;状态页增加隧道条目。
@@ -240,7 +240,7 @@ Cloudflare 把 Tunnel 分成两条产品线,Fonu 目前主要落在第一条
**P0-5 实时日志(SSE)** **P0-5 实时日志(SSE)**
- 目标:日志页实时滚动,替代轮询。 - 目标:日志页实时滚动,替代轮询。
- 实现:Fonu 现有日志 SSE 模式复用;cloudflared 日志文件 tail + 增量推送;保留轮询回退。 - 实现:Havline 现有日志 SSE 模式复用;cloudflared 日志文件 tail + 增量推送;保留轮询回退。
- 验收:页面实时看到新日志;切换页面/重连不丢关键行。 - 验收:页面实时看到新日志;切换页面/重连不丢关键行。
**P0-6 诊断能力** **P0-6 诊断能力**
@@ -254,7 +254,7 @@ Cloudflare 把 Tunnel 分成两条产品线,Fonu 目前主要落在第一条
**P1-1 快速隧道** **P1-1 快速隧道**
- 目标:临时把本地端口发布到 `trycloudflare.com`,用于演示与开发。 - 目标:临时把本地端口发布到 `trycloudflare.com`,用于演示与开发。
- 实现:`--url` 启动、日志解析公共 URL、进程随 Fonu 生命周期、默认关闭。 - 实现:`--url` 启动、日志解析公共 URL、进程随 Havline 生命周期、默认关闭。
- 边界:明确标注不保证 SLA、URL 每次变化。 - 边界:明确标注不保证 SLA、URL 每次变化。
**P1-2 多副本与零停机更新** **P1-2 多副本与零停机更新**
@@ -284,7 +284,7 @@ Cloudflare 把 Tunnel 分成两条产品线,Fonu 目前主要落在第一条
**P1-6 审计日志** **P1-6 审计日志**
- 目标:记录谁在何时修改了隧道、端点、DNS、令牌与全局开关。 - 目标:记录谁在何时修改了隧道、端点、DNS、令牌与全局开关。
- 实现:复用 Fonu 日志/审计基础设施;操作前记录旧值、操作后记录新值。 - 实现:复用 Havline 日志/审计基础设施;操作前记录旧值、操作后记录新值。
- 验收:关键变更可查询、可导出。 - 验收:关键变更可查询、可导出。
### P2:体验与增强 ### P2:体验与增强
@@ -425,18 +425,18 @@ Cloudflare 把 Tunnel 分成两条产品线,Fonu 目前主要落在第一条
- **网络边界**:只做本机到 CF 边缘的调优;访客侧不可控。 - **网络边界**:只做本机到 CF 边缘的调优;访客侧不可控。
- **合规边界**:大文件/视频分发不在推荐用法内。 - **合规边界**:大文件/视频分发不在推荐用法内。
- **安全边界**:不代配 Access 高风险策略;不写 hosts;不改系统 ulimit/防火墙。 - **安全边界**:不代配 Access 高风险策略;不写 hosts;不改系统 ulimit/防火墙。
- **运行边界**:Fonu 只托管自己启动的 cloudflared 进程;不接管外部 systemd 服务。 - **运行边界**:Havline 只托管自己启动的 cloudflared 进程;不接管外部 systemd 服务。
- **数据边界**:凭据加密存储;预览脱敏;审计日志不记录明文 token。 - **数据边界**:凭据加密存储;预览脱敏;审计日志不记录明文 token。
--- ---
## 十、结论 ## 十、结论
Fonu 的 Cloudflare 隧道模块已经具备「创建、运行、托管、DNS」的闭环。下一阶段的目标是把它从「能用」推进到「可长期运维」: Havline 的 Cloudflare 隧道模块已经具备「创建、运行、托管、DNS」的闭环。下一阶段的目标是把它从「能用」推进到「可长期运维」:
1. 先补齐云端同步、端点 CRUD、漂移检测、健康接入、实时日志、诊断(阶段 1)。 1. 先补齐云端同步、端点 CRUD、漂移检测、健康接入、实时日志、诊断(阶段 1)。
2. 再补快速隧道、多副本、自动更新、令牌轮换、Origin 高级参数、审计(阶段 2)。 2. 再补快速隧道、多副本、自动更新、令牌轮换、Origin 高级参数、审计(阶段 2)。
3. 最后做镜像、图表、版本回滚、日志治理、系统检查与网络调优扩展(阶段 3)。 3. 最后做镜像、图表、版本回滚、日志治理、系统检查与网络调优扩展(阶段 3)。
4. 私有网络、Access、负载均衡、边缘优选等平台化能力按需推进(阶段 4)。 4. 私有网络、Access、负载均衡、边缘优选等平台化能力按需推进(阶段 4)。
这样 Fonu 才能在官方能力、参考项目功能与自身产品体系之间取得平衡,而不是停留在「进程开关 + 配置预览」。 这样 Havline 才能在官方能力、参考项目功能与自身产品体系之间取得平衡,而不是停留在「进程开关 + 配置预览」。
+33 -33
View File
@@ -1,8 +1,8 @@
# Cloudflare 隧道同类项目调研与 Fonu 体验优化建议 # Cloudflare 隧道同类项目调研与 Havline 体验优化建议
> 日期:2026-09-30 > 日期:2026-09-30
> 调研范围:GitHub 上 Cloudflare Tunnel / cloudflared 管理面板、反向代理管理器、自托管访问平台、状态监测与流量检查器。 > 调研范围:GitHub 上 Cloudflare Tunnel / cloudflared 管理面板、反向代理管理器、自托管访问平台、状态监测与流量检查器。
> 目标:不是照搬功能,而是提取适合 Fonu 的自动化方式、页面结构和长期运维体验。 > 目标:不是照搬功能,而是提取适合 Havline 的自动化方式、页面结构和长期运维体验。
--- ---
@@ -23,7 +23,7 @@
- 代表:Uptime Kuma、Tunnel Inspector - 代表:Uptime Kuma、Tunnel Inspector
- 重点:可用率、状态页、事故记录、请求级观测、重放和抓包体验。 - 重点:可用率、状态页、事故记录、请求级观测、重放和抓包体验。
对 Fonu 最重要的启发不是“再加多少按钮”,而是: 对 Havline 最重要的启发不是“再加多少按钮”,而是:
- 用期望状态和自动调和代替手工点同步。 - 用期望状态和自动调和代替手工点同步。
- 让规则、隧道、DNS、证书、监控在同一套模型中管理。 - 让规则、隧道、DNS、证书、监控在同一套模型中管理。
@@ -69,16 +69,16 @@
- 支持环境变量配置协议、边缘 IP、metrics、重试等。 - 支持环境变量配置协议、边缘 IP、metrics、重试等。
- 数据持久化到 `/config`。 - 数据持久化到 `/config`。
对 Fonu 的启示: 对 Havline 的启示:
- 初次配置应该更短:Account ID、API Token、Zone、Tunnel 四步内完成。 - 初次配置应该更短:Account ID、API Token、Zone、Tunnel 四步内完成。
- 所有高级网络参数应该折叠到“高级选项”,默认值直接可用。 - 所有高级网络参数应该折叠到“高级选项”,默认值直接可用。
- 当前 Fonu 已覆盖它的主要能力,并且多了 API 托管、DNS、规则同步和监控。 - 当前 Havline 已覆盖它的主要能力,并且多了 API 托管、DNS、规则同步和监控。
不建议照搬: 不建议照搬:
- 只有 Token 和启停,模型太薄。 - 只有 Token 和启停,模型太薄。
- 不适合 Fonu 的“服务发布规则”产品定位。 - 不适合 Havline 的“服务发布规则”产品定位。
### 3.2 DockFlare ### 3.2 DockFlare
@@ -97,9 +97,9 @@
- 加密配置、运行时状态和数据的备份恢复。 - 加密配置、运行时状态和数据的备份恢复。
- 多语言 UI。 - 多语言 UI。
对 Fonu 的启示: 对 Havline 的启示:
- 当前 Fonu 已经有“反向代理规则派生 Cloudflare ingress”,方向正确。 - 当前 Havline 已经有“反向代理规则派生 Cloudflare ingress”,方向正确。
- 下一步应补“期望状态”和“调和结果”: - 下一步应补“期望状态”和“调和结果”:
- 本地期望配置。 - 本地期望配置。
- Cloudflare 实际配置。 - Cloudflare 实际配置。
@@ -108,7 +108,7 @@
- 一键修复。 - 一键修复。
- Docker 标签自动发布非常适合 NAS。 - Docker 标签自动发布非常适合 NAS。
- 多主机 Agent 适合进阶用户,但不应优先于单机体验。 - 多主机 Agent 适合进阶用户,但不应优先于单机体验。
- Access 自动化风险较高,Fonu 暂时保持只读更稳。 - Access 自动化风险较高,Havline 暂时保持只读更稳。
### 3.3 Tunnel Ingress Controller ### 3.3 Tunnel Ingress Controller
@@ -122,9 +122,9 @@
- Controller 持续把声明同步到 Cloudflare。 - Controller 持续把声明同步到 Cloudflare。
- 不要求用户手工编辑 cloudflared ingress。 - 不要求用户手工编辑 cloudflared ingress。
对 Fonu 的启示: 对 Havline 的启示:
- Fonu 的“反向代理规则就是服务入口声明”是同一思路的非 K8s 版本。 - Havline 的“反向代理规则就是服务入口声明”是同一思路的非 K8s 版本。
- 无需引入 K8s,但应借鉴: - 无需引入 K8s,但应借鉴:
- 声明与运行状态分离。 - 声明与运行状态分离。
- 调和结果可见。 - 调和结果可见。
@@ -146,13 +146,13 @@
- 实时日志。 - 实时日志。
- 自动下载 cloudflared。 - 自动下载 cloudflared。
对 Fonu 的启示: 对 Havline 的启示:
- Quick Tunnel 适合“临时演示”,不建议作为正式发布方式。 - Quick Tunnel 适合“临时演示”,不建议作为正式发布方式。
- 双向同步是 Fonu 当前明显缺口: - 双向同步是 Havline 当前明显缺口:
- 当前支持检测漂移并“本地覆盖云端”。 - 当前支持检测漂移并“本地覆盖云端”。
- 缺少“云端覆盖本地”。 - 缺少“云端覆盖本地”。
- 多账号模式对 Fonu 属于进阶能力,可放到远期。 - 多账号模式对 Havline 属于进阶能力,可放到远期。
- 实时日志和指标已具备,继续加强即可。 - 实时日志和指标已具备,继续加强即可。
### 3.5 cftunnel ### 3.5 cftunnel
@@ -172,7 +172,7 @@
- 一键分享二维码、地址、Telegram。 - 一键分享二维码、地址、Telegram。
- 自动更新。 - 自动更新。
对 Fonu 的启示: 对 Havline 的启示:
- “快速隧道”可以作为临时验证入口,但必须标明不保证 SLA。 - “快速隧道”可以作为临时验证入口,但必须标明不保证 SLA。
- 场景模板非常值得借鉴: - 场景模板非常值得借鉴:
@@ -182,7 +182,7 @@
- 相册 - 相册
- Jellyfin 管理页 - Jellyfin 管理页
- “复制访问地址 / 二维码分享”适合手机用户。 - “复制访问地址 / 二维码分享”适合手机用户。
- Cloud + FRP 双引擎的概念与 Fonu 的出口抽象一致。 - Cloud + FRP 双引擎的概念与 Havline 的出口抽象一致。
### 3.6 Nginx Proxy Manager ### 3.6 Nginx Proxy Manager
@@ -197,12 +197,12 @@
- 证书申请和续签直接在规则旁完成。 - 证书申请和续签直接在规则旁完成。
- 管理界面简单,新手可按表单完成部署。 - 管理界面简单,新手可按表单完成部署。
对 Fonu 的启示: 对 Havline 的启示:
- 反向代理规则的“编辑页”应成为服务发布中心。 - 反向代理规则的“编辑页”应成为服务发布中心。
- Cloudflare 出口应该是一个选项,而不是另一套孤立页面。 - Cloudflare 出口应该是一个选项,而不是另一套孤立页面。
- SSL、Access、健康检查、日志应围绕同一条规则组织。 - SSL、Access、健康检查、日志应围绕同一条规则组织。
- Fonu 当前已有这一方向,但页面还可以更像“资源详情页”。 - Havline 当前已有这一方向,但页面还可以更像“资源详情页”。
### 3.7 Pangolin ### 3.7 Pangolin
@@ -218,14 +218,14 @@
- 对每个资源做审计和会话记录。 - 对每个资源做审计和会话记录。
- 多 connector 和站点冗余。 - 多 connector 和站点冗余。
对 Fonu 的启示: 对 Havline 的启示:
- 普通 NAS 用户更需要“访问我的服务”,不是“管理网络隧道”。 - 普通 NAS 用户更需要“访问我的服务”,不是“管理网络隧道”。
- 可以借鉴资源入口页: - 可以借鉴资源入口页:
- 按应用、域名、出口分组。 - 按应用、域名、出口分组。
- 一键打开服务。 - 一键打开服务。
- 显示维护状态和最近访问。 - 显示维护状态和最近访问。
- 不建议 Fonu 当前做企业 RBAC、SSO、PAM;成本过高。 - 不建议 Havline 当前做企业 RBAC、SSO、PAM;成本过高。
### 3.8 Uptime Kuma ### 3.8 Uptime Kuma
@@ -241,7 +241,7 @@
- 通知渠道丰富。 - 通知渠道丰富。
- 2FA。 - 2FA。
对 Fonu 的启示: 对 Havline 的启示:
- 当前公开状态页已有域名和可用率。 - 当前公开状态页已有域名和可用率。
- 下一步最值得补: - 下一步最值得补:
@@ -268,7 +268,7 @@
- 请求重放和 Copy as cURL。 - 请求重放和 Copy as cURL。
- SSE 实时推送。 - SSE 实时推送。
对 Fonu 的启示: 对 Havline 的启示:
- 适合做成“Cloudflare 出口调试模式”,不要默认常驻。 - 适合做成“Cloudflare 出口调试模式”,不要默认常驻。
- 可提供: - 可提供:
@@ -291,7 +291,7 @@
优秀项目不会让用户直接面对复杂配置。 优秀项目不会让用户直接面对复杂配置。
建议 Fonu 增加: 建议 Havline 增加:
1. 检查 cloudflared 是否安装。 1. 检查 cloudflared 是否安装。
2. 检查 Account ID。 2. 检查 Account ID。
@@ -346,7 +346,7 @@ nas.example.com
### 4.4 路由表体验 ### 4.4 路由表体验
当前 Fonu 已有路由表。建议继续补: 当前 Havline 已有路由表。建议继续补:
- 拖拽排序。 - 拖拽排序。
- 启用/停用。 - 启用/停用。
@@ -410,7 +410,7 @@ nas.example.com
桌面管理器和 DockFlare 都把多账号作为进阶能力。 桌面管理器和 DockFlare 都把多账号作为进阶能力。
建议 Fonu: 建议 Havline:
- 当前继续以 Account ID + API Token 为默认。 - 当前继续以 Account ID + API Token 为默认。
- 远期可支持多账号。 - 远期可支持多账号。
@@ -431,12 +431,12 @@ nas.example.com
--- ---
## 五、Fonu 不该照搬的功能 ## 五、Havline 不该照搬的功能
| 功能 | 结论 | 原因 | | 功能 | 结论 | 原因 |
| --- | --- | --- | | --- | --- | --- |
| Kubernetes Controller | 不做 | 不是 NAS 用户场景 | | Kubernetes Controller | 不做 | 不是 NAS 用户场景 |
| 企业 SSO / PAM / RBAC 平台 | 暂不做 | 成本高,偏离 Fonu 定位 | | 企业 SSO / PAM / RBAC 平台 | 暂不做 | 成本高,偏离 Havline 定位 |
| Cloudflare 邮件套件 | 不做 | 与访问管理无关 | | Cloudflare 邮件套件 | 不做 | 与访问管理无关 |
| 多语言 13 种起步 | 暂不做 | 先保持中文产品体验 | | 多语言 13 种起步 | 暂不做 | 先保持中文产品体验 |
| 多主机 Agent 优先 | 后置 | 先做好单机 NAS | | 多主机 Agent 优先 | 后置 | 先做好单机 NAS |
@@ -446,7 +446,7 @@ nas.example.com
--- ---
## 六、Fonu 建议路线 ## 六、Havline 建议路线
> 实施状态:截至 2026-10-01,本节的 P0 已完成第一轮落地,详见 `cloudflared-current-features.md`。 > 实施状态:截至 2026-10-01,本节的 P0 已完成第一轮落地,详见 `cloudflared-current-features.md`。
@@ -468,7 +468,7 @@ nas.example.com
6. **拓扑图** 6. **拓扑图**
- 域名 → 出口 → 隧道 → 服务。 - 域名 → 出口 → 隧道 → 服务。
7. **Docker 标签发布** 7. **Docker 标签发布**
- 容器标签自动生成 Fonu 规则和 Cloudflare 出口。 - 容器标签自动生成 Havline 规则和 Cloudflare 出口。
8. **场景模板** 8. **场景模板**
- Home Assistant、Immich、Jellyfin、qBittorrent、AdGuard。 - Home Assistant、Immich、Jellyfin、qBittorrent、AdGuard。
9. **访问二维码与分享** 9. **访问二维码与分享**
@@ -494,7 +494,7 @@ nas.example.com
--- ---
## 七、针对当前 Fonu 页面的具体改版建议 ## 七、针对当前 Havline 页面的具体改版建议
### 7.1 Cloudflare 总览页 ### 7.1 Cloudflare 总览页
@@ -562,7 +562,7 @@ DNS:已创建
## 八、最终建议 ## 八、最终建议
Fonu 不应继续横向堆叠 cloudflared 参数。更值得投入的是: Havline 不应继续横向堆叠 cloudflared 参数。更值得投入的是:
1. 把 Cloudflare 接入做成向导。 1. 把 Cloudflare 接入做成向导。
2. 把“规则 → 出口 → 隧道 → DNS → 监控”做成同一条链路。 2. 把“规则 → 出口 → 隧道 → DNS → 监控”做成同一条链路。
@@ -571,6 +571,6 @@ Fonu 不应继续横向堆叠 cloudflared 参数。更值得投入的是:
5. 把 Docker / NAS 服务发布做到自动发现。 5. 把 Docker / NAS 服务发布做到自动发现。
6. 把高级能力放到高级页,不干扰默认路径。 6. 把高级能力放到高级页,不干扰默认路径。
这会让 Fonu 与普通 cloudflared 面板产生明确差异: 这会让 Havline 与普通 cloudflared 面板产生明确差异:
> 普通面板管理 cloudflared;Fonu 管理“我的服务如何安全、稳定地访问”。 > 普通面板管理 cloudflared;Havline 管理“我的服务如何安全、稳定地访问”。
@@ -1,22 +1,22 @@
# Cloudflare 隧道:Fonu 适配版功能计划 # Cloudflare 隧道:Havline 适配版功能计划
> 日期:2026-09-30 > 日期:2026-09-30
> 定位:`cloudflared-feature-plan.md` 是「官方 + 参考项目的功能全集」;本文是**按 Fonu 产品定位筛选后的适配版计划**,实施以本文为准。 > 定位:`cloudflared-feature-plan.md` 是「官方 + 参考项目的功能全集」;本文是**按 Havline 产品定位筛选后的适配版计划**,实施以本文为准。
> 核心原则:Fonu 不是 cloudflared 管理面板,而是「NAS / 家庭服务器的公网访问管理面板」。Cloudflare 隧道只是它的第二条出口。 > 核心原则:Havline 不是 cloudflared 管理面板,而是「NAS / 家庭服务器的公网访问管理面板」。Cloudflare 隧道只是它的第二条出口。
--- ---
## 一、先明确 Fonu 是什么 ## 一、先明确 Havline 是什么
### 1.1 产品定位 ### 1.1 产品定位
Fonu 面对的是家庭 NAS、小主机、家用服务器的用户,核心价值是: Havline 面对的是家庭 NAS、小主机、家用服务器的用户,核心价值是:
- 用域名访问内网服务,而不是记 IP 和端口; - 用域名访问内网服务,而不是记 IP 和端口;
- 自动处理证书、反代、穿透、DDNS; - 自动处理证书、反代、穿透、DDNS;
- 尽量少配置、少暴露、少维护。 - 尽量少配置、少暴露、少维护。
Fonu 已经有三条主线: Havline 已经有三条主线:
1. **反向代理**:域名 → 本机 Nginx → 内网服务; 1. **反向代理**:域名 → 本机 Nginx → 内网服务;
2. **FRP 穿透**:本机 frpc → VPS frps → 域名; 2. **FRP 穿透**:本机 frpc → VPS frps → 域名;
@@ -30,7 +30,7 @@ Cloudflare 隧道的加入,是为了解决一类现有链路解决不了的人
1. **CF 隧道是第二条出口,不是替代 FRP**:TCP/UDP、自主可控、大流量仍走 FRP。 1. **CF 隧道是第二条出口,不是替代 FRP**:TCP/UDP、自主可控、大流量仍走 FRP。
2. **规则同源**:用户维护的是「服务发布规则」,不是「隧道 ingress」。CF 只是一个出口选项。 2. **规则同源**:用户维护的是「服务发布规则」,不是「隧道 ingress」。CF 只是一个出口选项。
3. **融入 Fonu 体系**:监控、通知、状态页、日志、备份、升级、审计都复用现有模块,不另造一套面板。 3. **融入 Havline 体系**:监控、通知、状态页、日志、备份、升级、审计都复用现有模块,不另造一套面板。
--- ---
@@ -54,45 +54,45 @@ Cloudflare 隧道的加入,是为了解决一类现有链路解决不了的人
## 三、借鉴与不借鉴 ## 三、借鉴与不借鉴
参考项目 `Tunnel-Cloudflared-Manager 1.0.1` 是一个独立的 cloudflared 面板。以下判断按「是否符合 Fonu 产品形态」给出: 参考项目 `Tunnel-Cloudflared-Manager 1.0.1` 是一个独立的 cloudflared 面板。以下判断按「是否符合 Havline 产品形态」给出:
### 3.1 值得借鉴 ### 3.1 值得借鉴
| 参考项目做法 | Fonu 该怎么用 | | 参考项目做法 | Havline 该怎么用 |
| --- | --- | | --- | --- |
| token 解码成本地凭据 | 已用 Account ID + API Token 替代;保留解码能力用于导入 | | token 解码成本地凭据 | 已用 Account ID + API Token 替代;保留解码能力用于导入 |
| metrics 端口从日志发现 | 已实现,继续沿用 | | metrics 端口从日志发现 | 已实现,继续沿用 |
| ingress 三条不变式 | 已实现,继续沿用 | | ingress 三条不变式 | 已实现,继续沿用 |
| 云端 / 本地配置互相同步 | 值得做,但要简化为「diff → 拉取或覆盖」 | | 云端 / 本地配置互相同步 | 值得做,但要简化为「diff → 拉取或覆盖」 |
| 端点级增删改 | 值得做,但必须挂在 Fonu 的「服务发布规则」下 | | 端点级增删改 | 值得做,但必须挂在 Havline 的「服务发布规则」下 |
| 快速隧道 | 只做「临时演示」,不做正式功能 | | 快速隧道 | 只做「临时演示」,不做正式功能 |
| 网络诊断 | 值得做,做成 Fonu 的一键诊断 | | 网络诊断 | 值得做,做成 Havline 的一键诊断 |
| 镜像与下载进度 | 值得做,但复用 Fonu 现有镜像表,不做用户自定义镜像管理 | | 镜像与下载进度 | 值得做,但复用 Havline 现有镜像表,不做用户自定义镜像管理 |
| Windows 隐藏控制台 | 已实现,继续沿用 | | Windows 隐藏控制台 | 已实现,继续沿用 |
### 3.2 不借鉴 ### 3.2 不借鉴
| 参考项目做法 | 不借鉴的理由 | | 参考项目做法 | 不借鉴的理由 |
| --- | --- | | --- | --- |
| 自带 JWT + 账号体系 | Fonu 已有会话 + API Token,重复建设 | | 自带 JWT + 账号体系 | Havline 已有会话 + API Token,重复建设 |
| 独立主题、多语言 | 与 Fonu 全局设置重复 | | 独立主题、多语言 | 与 Havline 全局设置重复 |
| 多账号 cert.pem 登录 | 与 Fonu 的 Account ID + API Token 模型冲突;远期若有需求再评估 | | 多账号 cert.pem 登录 | 与 Havline 的 Account ID + API Token 模型冲突;远期若有需求再评估 |
| 私有网络 / WARP routing / virtual network | 面向企业 Zero Trust,NAS 用户不需要 | | 私有网络 / WARP routing / virtual network | 面向企业 Zero Trust,NAS 用户不需要 |
| Kubernetes / Ansible / Terraform / 云厂商部署指南 | 不是 Fonu 的交付形态 | | Kubernetes / Ansible / Terraform / 云厂商部署指南 | 不是 Havline 的交付形态 |
| 负载均衡 | 家用单机场景不需要 | | 负载均衡 | 家用单机场景不需要 |
| Workers VPC / Hyperdrive / Spectrum / Keyless SSL | 超出 Fonu 定位 | | Workers VPC / Hyperdrive / Spectrum / Keyless SSL | 超出 Havline 定位 |
| WebSocket 实时日志 | Fonu 已有 SSE 日志模式,不为 CF 单独引入 WSS | | WebSocket 实时日志 | Havline 已有 SSE 日志模式,不为 CF 单独引入 WSS |
| 自定义镜像管理(增删改测) | Fonu 只需内置镜像表 + 自定义 URL,不需要用户维护镜像源 | | 自定义镜像管理(增删改测) | Havline 只需内置镜像表 + 自定义 URL,不需要用户维护镜像源 |
| 独立 systemd / Windows service 安装 | Fonu 自己托管进程,不接管外部服务 | | 独立 systemd / Windows service 安装 | Havline 自己托管进程,不接管外部服务 |
| 快速隧道作为正式功能 | 官方明确不保证 SLA,只做演示入口 | | 快速隧道作为正式功能 | 官方明确不保证 SLA,只做演示入口 |
--- ---
## 四、符合 Fonu 的功能全景 ## 四、符合 Havline 的功能全景
按「用户价值」而不是「参考项目有什么」重新分组。 按「用户价值」而不是「参考项目有什么」重新分组。
### 4.1 出口抽象(Fonu 的核心差异化) ### 4.1 出口抽象(Havline 的核心差异化)
**F-1 服务发布规则增加「出口」字段** **F-1 服务发布规则增加「出口」字段**
@@ -111,7 +111,7 @@ Cloudflare 隧道的加入,是为了解决一类现有链路解决不了的人
- 出口支持多选(FRP + Cloudflare),用于灰度或双保险。 - 出口支持多选(FRP + Cloudflare),用于灰度或双保险。
- 验收:同一域名可同时存在 FRP 与 CF 入口,互不影响。 - 验收:同一域名可同时存在 FRP 与 CF 入口,互不影响。
### 4.2 自动化(延续 Fonu「自动为主」) ### 4.2 自动化(延续 Havline「自动为主」)
**F-4 一次配置,后续全自动**(大部分已完成) **F-4 一次配置,后续全自动**(大部分已完成)
@@ -134,14 +134,14 @@ Cloudflare 隧道的加入,是为了解决一类现有链路解决不了的人
- 进程退出自动重启; - 进程退出自动重启;
- `/ready` 与 `ha_connections` 判活; - `/ready` 与 `ha_connections` 判活;
- 连续失败进入 Fonu 告警;恢复后发通知。 - 连续失败进入 Havline 告警;恢复后发通知。
### 4.3 融入 Fonu 现有体系 ### 4.3 融入 Havline 现有体系
**F-8 监控与可用率** **F-8 监控与可用率**
- 新增 cloudflared 的 `monitor.Source`; - 新增 cloudflared 的 `monitor.Source`;
- 隧道健康进入 Fonu 现有可用率与历史指标。 - 隧道健康进入 Havline 现有可用率与历史指标。
**F-9 通知** **F-9 通知**
@@ -155,7 +155,7 @@ Cloudflare 隧道的加入,是为了解决一类现有链路解决不了的人
**F-11 日志中心** **F-11 日志中心**
- CF 隧道日志进入 Fonu 日志页; - CF 隧道日志进入 Havline 日志页;
- 复用 SSE 增量推送,不单独做 WebSocket。 - 复用 SSE 增量推送,不单独做 WebSocket。
**F-12 仪表盘** **F-12 仪表盘**
@@ -164,19 +164,19 @@ Cloudflare 隧道的加入,是为了解决一类现有链路解决不了的人
**F-13 备份与迁移** **F-13 备份与迁移**
- `cf_tunnels` 与凭据纳入 Fonu 备份/恢复流程; - `cf_tunnels` 与凭据纳入 Havline 备份/恢复流程;
- 恢复后自动重启隧道。 - 恢复后自动重启隧道。
**F-14 一键升级与版本策略** **F-14 一键升级与版本策略**
- cloudflared 版本跟随 Fonu 升级流程; - cloudflared 版本跟随 Havline 升级流程;
- 提供「检查更新 / 更新 cloudflared / 回滚」; - 提供「检查更新 / 更新 cloudflared / 回滚」;
- 不要求用户手工替换二进制。 - 不要求用户手工替换二进制。
**F-15 审计** **F-15 审计**
- 记录谁在何时修改了服务发布规则、出口、CF 凭据、全局开关; - 记录谁在何时修改了服务发布规则、出口、CF 凭据、全局开关;
- 复用 Fonu 的日志/审计基础设施。 - 复用 Havline 的日志/审计基础设施。
### 4.4 NAS 场景体验 ### 4.4 NAS 场景体验
@@ -198,13 +198,13 @@ Cloudflare 隧道的加入,是为了解决一类现有链路解决不了的人
**F-19 证书提示** **F-19 证书提示**
- 使用 CF 出口时,CF 边缘自动提供 HTTPS; - 使用 CF 出口时,CF 边缘自动提供 HTTPS;
- 页面提示「该域名无需在 Fonu 申请证书」,避免用户重复操作。 - 页面提示「该域名无需在 Havline 申请证书」,避免用户重复操作。
### 4.5 运维与安全 ### 4.5 运维与安全
**F-20 端点级编辑** **F-20 端点级编辑**
- 在 Fonu 规则详情里编辑 hostname、path、service、协议; - 在 Havline 规则详情里编辑 hostname、path、service、协议;
- 提供 `ingress validate` 与 `ingress rule <url>` 校验。 - 提供 `ingress validate` 与 `ingress rule <url>` 校验。
**F-21 云端漂移检测** **F-21 云端漂移检测**
@@ -215,7 +215,7 @@ Cloudflare 隧道的加入,是为了解决一类现有链路解决不了的人
**F-22 一键诊断** **F-22 一键诊断**
- `tunnel info`、`tunnel diag`、网络预检(region1/region2、7844、443)、系统要求检查; - `tunnel info`、`tunnel diag`、网络预检(region1/region2、7844、443)、系统要求检查;
- 结果转成 Fonu 风格的可读结论与建议。 - 结果转成 Havline 风格的可读结论与建议。
**F-23 令牌轮换** **F-23 令牌轮换**
@@ -266,24 +266,24 @@ Cloudflare 隧道的加入,是为了解决一类现有链路解决不了的人
| 功能 | 结论 | 理由 | | 功能 | 结论 | 理由 |
| --- | --- | --- | | --- | --- | --- |
| 独立登录、JWT、多用户体系 | 不做 | Fonu 已有认证与 API Token | | 独立登录、JWT、多用户体系 | 不做 | Havline 已有认证与 API Token |
| 独立主题、多语言 | 不做 | 属于 Fonu 全局设置 | | 独立主题、多语言 | 不做 | 属于 Havline 全局设置 |
| 私有网络 / WARP routing | 不做(远期评估) | 面向 Zero Trust 企业,NAS 用户不需要 | | 私有网络 / WARP routing | 不做(远期评估) | 面向 Zero Trust 企业,NAS 用户不需要 |
| 负载均衡 | 不做 | 家用单机场景不需要 | | 负载均衡 | 不做 | 家用单机场景不需要 |
| Kubernetes / Ansible / Terraform 指南 | 不做 | 不是 Fonu 交付形态 | | Kubernetes / Ansible / Terraform 指南 | 不做 | 不是 Havline 交付形态 |
| Workers VPC / Hyperdrive / Spectrum | 不做 | 超范围 | | Workers VPC / Hyperdrive / Spectrum | 不做 | 超范围 |
| WebSocket 实时日志 | 不做 | 复用 Fonu SSE | | WebSocket 实时日志 | 不做 | 复用 Havline SSE |
| 自定义镜像管理 | 不做 | 内置镜像表 + 自定义 URL 足够 | | 自定义镜像管理 | 不做 | 内置镜像表 + 自定义 URL 足够 |
| 接管外部 systemd / Windows service | 不做 | Fonu 只托管自己启动的进程 | | 接管外部 systemd / Windows service | 不做 | Havline 只托管自己启动的进程 |
| 快速隧道作为正式功能 | 不做 | 官方不保证 SLA,只做演示 | | 快速隧道作为正式功能 | 不做 | 官方不保证 SLA,只做演示 |
| 代配 Access 策略 | 不做 | 高风险,只做只读检测 | | 代配 Access 策略 | 不做 | 高风险,只做只读检测 |
| 自动扩权 / 自动改系统防火墙 | 不做 | 保持 Fonu 现有安全边界 | | 自动扩权 / 自动改系统防火墙 | 不做 | 保持 Havline 现有安全边界 |
--- ---
## 六、与现有模块的整合点 ## 六、与现有模块的整合点
| Fonu 模块 | 整合方式 | | Havline 模块 | 整合方式 |
| --- | --- | | --- | --- |
| 反向代理规则 | 增加「出口」字段;CF 隧道从规则派生 | | 反向代理规则 | 增加「出口」字段;CF 隧道从规则派生 |
| FRP 多服务端 | 与 CF 出口并列,互不影响 | | FRP 多服务端 | 与 CF 出口并列,互不影响 |
@@ -335,7 +335,7 @@ Cloudflare 隧道的加入,是为了解决一类现有链路解决不了的人
| 阶段 | 内容 | 目标 | | 阶段 | 内容 | 目标 |
| --- | --- | --- | | --- | --- | --- |
| 阶段 1 | F-1 ~ F-3 出口抽象;F-5 规则同步;F-6 DNS 管理完善 | 让 CF 成为反代规则的一个出口,而不是独立模块 | | 阶段 1 | F-1 ~ F-3 出口抽象;F-5 规则同步;F-6 DNS 管理完善 | 让 CF 成为反代规则的一个出口,而不是独立模块 |
| 阶段 2 | F-8 ~ F-15 体系整合;F-20 ~ F-22 编辑与诊断 | 让 CF 隧道进入 Fonu 监控、通知、状态页、日志、备份、审计 | | 阶段 2 | F-8 ~ F-15 体系整合;F-20 ~ F-22 编辑与诊断 | 让 CF 隧道进入 Havline 监控、通知、状态页、日志、备份、审计 |
| 阶段 3 | F-16 ~ F-19 NAS 体验;F-23 ~ F-26 运维安全 | 一键发布、域名选择、令牌轮换、条款提示、Access 检测 | | 阶段 3 | F-16 ~ F-19 NAS 体验;F-23 ~ F-26 运维安全 | 一键发布、域名选择、令牌轮换、条款提示、Access 检测 |
| 阶段 4 | F-27 ~ F-30 可选增强 | 快速隧道、多副本、边缘优选、账号模式按需 | | 阶段 4 | F-27 ~ F-30 可选增强 | 快速隧道、多副本、边缘优选、账号模式按需 |
@@ -347,7 +347,7 @@ Cloudflare 隧道的加入,是为了解决一类现有链路解决不了的人
- 用户只维护「域名 → 服务」的规则,出口切换不需要改域名或端口; - 用户只维护「域名 → 服务」的规则,出口切换不需要改域名或端口;
- 反代规则变化后,CF 隧道与 DNS 自动跟随; - 反代规则变化后,CF 隧道与 DNS 自动跟随;
- 隧道断连能进 Fonu 告警与状态页; - 隧道断连能进 Havline 告警与状态页;
- 一条规则可以同时走 FRP 与 CF,互不影响; - 一条规则可以同时走 FRP 与 CF,互不影响;
- CF 出口域名不需要本地证书; - CF 出口域名不需要本地证书;
- 备份恢复后隧道能自动恢复; - 备份恢复后隧道能自动恢复;
@@ -358,13 +358,13 @@ Cloudflare 隧道的加入,是为了解决一类现有链路解决不了的人
## 十、结论 ## 十、结论
Fonu 需要的不是「另一个 cloudflared 面板」,而是: Havline 需要的不是「另一个 cloudflared 面板」,而是:
> 把 Cloudflare 隧道做成反向代理规则的一个出口选项,并让监控、通知、状态页、日志、备份、升级、审计自动覆盖它。 > 把 Cloudflare 隧道做成反向代理规则的一个出口选项,并让监控、通知、状态页、日志、备份、升级、审计自动覆盖它。
按本文阶段 1-3 实施,Fonu 的 CF 模块会形成与参考项目不同的产品形态:参考项目面向「管理 cloudflared」,Fonu 面向「管理我的服务怎么上公网」。 按本文阶段 1-3 实施,Havline 的 CF 模块会形成与参考项目不同的产品形态:参考项目面向「管理 cloudflared」,Havline 面向「管理我的服务怎么上公网」。
本文与 `cloudflared-feature-plan.md` 的关系: 本文与 `cloudflared-feature-plan.md` 的关系:
- `cloudflared-feature-plan.md`:官方 + 参考项目的功能全集,用于查漏; - `cloudflared-feature-plan.md`:官方 + 参考项目的功能全集,用于查漏;
- 本文:按 Fonu 定位筛选后的实施计划,**实施以本文为准**。 - 本文:按 Havline 定位筛选后的实施计划,**实施以本文为准**。
+62 -62
View File
@@ -1,18 +1,18 @@
# Cloudflare Tunnel(cloudflared)与 Fonu 的融合方案 # Cloudflare Tunnel(cloudflared)与 Havline 的融合方案
> 日期:2026-09-25,v2 修订 2026-09-28 对象:Fonu 1.5.4(VERSION 1.5.4) > 日期:2026-09-25,v2 修订 2026-09-28 对象:Havline 1.5.4(VERSION 1.5.4)
> 方法:先查 Cloudflare 官方文档与同类开源项目,再逐个核对本仓库现有可复用面(下文带 `文件:行` 的都是读过代码得出的);v2 逐文件核对了 `F:\WWW\cf\Tunnel-Cloudflared-linux`(Tunnel-Cloudflared-Manager 1.0.1)源码。 > 方法:先查 Cloudflare 官方文档与同类开源项目,再逐个核对本仓库现有可复用面(下文带 `文件:行` 的都是读过代码得出的);v2 逐文件核对了 `F:\WWW\cf\Tunnel-Cloudflared-linux`(Tunnel-Cloudflared-Manager 1.0.1)源码。
> 结论速览:**值得做,但建议以「第二条出口」的形态并存,先做 locally-managed + 进程托管,不接 Cloudflare API**。v2 修订了部署位置、模式生效侧、metrics / 健康与二进制校验四处关键细节,详见 §五、§六、§十二;实施以 §十二 为准。 > 结论速览:**值得做,但建议以「第二条出口」的形态并存,先做 locally-managed + 进程托管,不接 Cloudflare API**。v2 修订了部署位置、模式生效侧、metrics / 健康与二进制校验四处关键细节,详见 §五、§六、§十二;实施以 §十二 为准。
--- ---
## 一、为什么值得做(对 Fonu 的战略意义) ## 一、为什么值得做(对 Havline 的战略意义)
Fonu 现在的穿透链路**必须有公网服务端**:本机 `frpc` → VPS 上的 `frps` → 域名。用户得有一台带公网 IP 的云主机,还得自己放行端口、签证书、配 DNS。 Havline 现在的穿透链路**必须有公网服务端**:本机 `frpc` → VPS 上的 `frps` → 域名。用户得有一台带公网 IP 的云主机,还得自己放行端口、签证书、配 DNS。
Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `cloudflared` **主动向外**建立到边缘的长连接(出站 only),公网侧不需要任何监听端口,也不需要公网 IP——顺带白拿 CF 的 WAF / DDoS 防护与自动 HTTPS。 Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `cloudflared` **主动向外**建立到边缘的长连接(出站 only),公网侧不需要任何监听端口,也不需要公网 IP——顺带白拿 CF 的 WAF / DDoS 防护与自动 HTTPS。
对 Fonu 的用户画像(家庭/个人 NAS)来说,这正好补上一类人:**没有实体公网 IP、或不想为穿透长期租 VPS**的人。 对 Havline 的用户画像(家庭/个人 NAS)来说,这正好补上一类人:**没有实体公网 IP、或不想为穿透长期租 VPS**的人。
但要说清楚它**替代不了什么**(这决定了它是「并存」而不是「替换」): 但要说清楚它**替代不了什么**(这决定了它是「并存」而不是「替换」):
@@ -30,16 +30,16 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
## 二、同类项目(检索结果) ## 二、同类项目(检索结果)
| 项目 | 做什么 | 对 Fonu 的借鉴 | | 项目 | 做什么 | 对 Havline 的借鉴 |
| --- | --- | --- | | --- | --- | --- |
| [WisdomSky/Cloudflared-web](https://github.com/WisdomSky/Cloudflared-web)(≈451★,Vue) | Docker 镜像里打包 `cloudflared` + 简单 Web UI,用来 start/stop **remotely-managed** 隧道 | **最接近的同位产品**:证明「面板管 cloudflared 进程」这个形态成立;但它的能力止步于开关,规则仍在 CF 面板里——这正是 Fonu 可以做得更好的地方(规则同源生成) | | [WisdomSky/Cloudflared-web](https://github.com/WisdomSky/Cloudflared-web)(≈451★,Vue) | Docker 镜像里打包 `cloudflared` + 简单 Web UI,用来 start/stop **remotely-managed** 隧道 | **最接近的同位产品**:证明「面板管 cloudflared 进程」这个形态成立;但它的能力止步于开关,规则仍在 CF 面板里——这正是 Havline 可以做得更好的地方(规则同源生成) |
| [adyanth/cloudflare-operator](https://github.com/adyanth/cloudflare-operator)(≈691★) | Kubernetes Operator,用 **CF API** 创建/管理 Tunnel 与 DNS | 演示了「API 驱动」的完整闭环;但需要 API Token + Account/Zone ID,是第二个凭据面(Fonu 第一轮不建议走,见 §六) | | [adyanth/cloudflare-operator](https://github.com/adyanth/cloudflare-operator)(≈691★) | Kubernetes Operator,用 **CF API** 创建/管理 Tunnel 与 DNS | 演示了「API 驱动」的完整闭环;但需要 API Token + Account/Zone ID,是第二个凭据面(Havline 第一轮不建议走,见 §六) |
| [qingchencloud/cftunnel](https://github.com/qingchencloud/cftunnel)(≈338★) | 轻量隧道控制 | 佐证需求存在 | | [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 社区的做法可参考:**两种模式并存**,文档把差异讲透 | | [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 提供多平台二进制 | | [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 免费档最宽松,这也是它值得优先支持的原因 | | 对照项:[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 只需管好本机客户端**。 `docs/feature-benchmark-2026-09-24.md` 里已经把 Pangolin 列为架构最近的同类项目;Cloudflare Tunnel 的差别在于:**它把「服务端」外包给了 Cloudflare,Havline 只需管好本机客户端**。
--- ---
@@ -51,23 +51,23 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
| 规则来源 | 本机 `config.yml` 的 `ingress:` | **CF 面板 / API**(本地 `config.yml` 会被**忽略**) | | 规则来源 | 本机 `config.yml` 的 `ingress:` | **CF 面板 / API**(本地 `config.yml` 会被**忽略**) |
| 建隧道 | `cloudflared tunnel create <name>` | CF 面板,或 `POST /accounts/{id}/cfd_tunnel` | | 建隧道 | `cloudflared tunnel create <name>` | CF 面板,或 `POST /accounts/{id}/cfd_tunnel` |
| DNS | `cloudflared tunnel route dns <tunnel> <hostname>` | CF 面板自动建 CNAME | | DNS | `cloudflared tunnel route dns <tunnel> <hostname>` | CF 面板自动建 CNAME |
| 与 Fonu 的价值取向 | ✅ **契合**:反代规则由 Fonu 同源生成(和现在生成 `frps.toml` / nginx vhost 一个路子) | ⚠️ 冲突:规则跑到 CF 面板去了,Fonu 沦为「进程开关」 | | 与 Havline 的价值取向 | ✅ **契合**:反代规则由 Havline 同源生成(和现在生成 `frps.toml` / nginx vhost 一个路子) | ⚠️ 冲突:规则跑到 CF 面板去了,Havline 沦为「进程开关」 |
官方文档确认了这个分界([配置参考](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))。 官方文档确认了这个分界([配置参考](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 生成的规则生效了。 **结论:第一轮只支持 locally-managed。** 若用户只想粘贴 token,提供「仅托管进程」的降级模式并**在界面上明确写清「规则由 Cloudflare 面板管理」**,不要让用户以为 Havline 生成的规则生效了。
> 补充(参考 §九 后修正):这个二分法不够细。参考实现证明**token 可以解码**(base64 的 JSON,含 `AccountTag` / `TunnelID` / `TunnelSecret`),用它自己生成 `creds.json` 就能按 locally-managed 跑本地 `config.yml`;再配合「云端 ↔ 本地 ingress 互相同步」,两种模式实际上是可切换的。所以 Fonu 的正确做法不是「二选一」,而是**显式区分并同步**:当前生效的是本地 `config.yml` 还是云端 ingress,以及「推送/拉取」两个动作。**v2 已把模式拆成三种并明确生效侧,见 §12.3。** > 补充(参考 §九 后修正):这个二分法不够细。参考实现证明**token 可以解码**(base64 的 JSON,含 `AccountTag` / `TunnelID` / `TunnelSecret`),用它自己生成 `creds.json` 就能按 locally-managed 跑本地 `config.yml`;再配合「云端 ↔ 本地 ingress 互相同步」,两种模式实际上是可切换的。所以 Havline 的正确做法不是「二选一」,而是**显式区分并同步**:当前生效的是本地 `config.yml` 还是云端 ingress,以及「推送/拉取」两个动作。**v2 已把模式拆成三种并明确生效侧,见 §12.3。**
### 健康探测(这点比 frp 更省事) ### 健康探测(这点比 frp 更省事)
`cloudflared tunnel --metrics 127.0.0.1:<port> run --config ...` 会暴露 HTTP 端点,其中 **`/ready` 在有到边缘的活跃连接时返回 200**(CF 官方在 k8s livenessProbe 里就是这么用的)。Fonu 现在为了判断「隧道通不通」要绕 frps dashboard,还专门加过 `tunnel_known` 来区分「判不出来」和「不通」;CF Tunnel 则有明确的就绪端点,可以直接喂给现有健康链路。 `cloudflared tunnel --metrics 127.0.0.1:<port> run --config ...` 会暴露 HTTP 端点,其中 **`/ready` 在有到边缘的活跃连接时返回 200**(CF 官方在 k8s livenessProbe 里就是这么用的)。Havline 现在为了判断「隧道通不通」要绕 frps dashboard,还专门加过 `tunnel_known` 来区分「判不出来」和「不通」;CF Tunnel 则有明确的就绪端点,可以直接喂给现有健康链路。
--- ---
## 四、本仓库可复用的面(已核对代码) ## 四、本仓库可复用的面(已核对代码)
| 能力 | Fonu 现有实现 | 复用方式 | | 能力 | Havline 现有实现 | 复用方式 |
| --- | --- | --- | | --- | --- | --- |
| 二进制下载 / 启用 | `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 页) | | 二进制下载 / 启用 | `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` 完全同构,可直接复用(日志追加、不截断的语义也一致) | | 客户端进程托管 | `internal/frp/manager.go`:`os.OpenFile(logPath, O_CREATE\|O_APPEND\|O_WRONLY, 0600)` + `cmd.Stdout/Stderr` 重定向 + 状态/退出原因 + `tailFile` 读日志尾 | 与 `cloudflared tunnel run` 完全同构,可直接复用(日志追加、不截断的语义也一致) |
@@ -87,7 +87,7 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
- 管理一份 cloudflared 二进制(复用 §四 的下载/启用框架)。 - 管理一份 cloudflared 二进制(复用 §四 的下载/启用框架)。
- 管理 N 个隧道实例,每个实例 = 一份 `config.yml` + credentials + 日志文件 + 子进程。 - 管理 N 个隧道实例,每个实例 = 一份 `config.yml` + credentials + 日志文件 + 子进程。
- `ingress` 规则**由 Fonu 生成**:从本机已有的反代规则里挑「要暴露的域名」,映射到 `http://127.0.0.1:<本地端口>`,模板与 nginx vhost 同源风格;末尾固定给 catch-all `service: http_status:404`(CF 要求最后一条兜底)。 - `ingress` 规则**由 Havline 生成**:从本机已有的反代规则里挑「要暴露的域名」,映射到 `http://127.0.0.1:<本地端口>`,模板与 nginx vhost 同源风格;末尾固定给 catch-all `service: http_status:404`(CF 要求最后一条兜底)。
- 健康:`--metrics` + `/ready`,写入现有健康与可用率链路;通知复用(隧道断开/恢复)。 - 健康:`--metrics` + `/ready`,写入现有健康与可用率链路;通知复用(隧道断开/恢复)。
- 前端:与「公网服务端」同构的列表 + 详情(状态 / 日志 / 配置预览 / 下发)。 - 前端:与「公网服务端」同构的列表 + 详情(状态 / 日志 / 配置预览 / 下发)。
@@ -106,7 +106,7 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
只生成 `config.yml` 与 DNS 指引,让用户自己跑 `cloudflared`。 只生成 `config.yml` 与 DNS 指引,让用户自己跑 `cloudflared`。
**不建议**:不接管进程就没有状态、日志、健康、可用率,等于放弃 Fonu 的全部价值。 **不建议**:不接管进程就没有状态、日志、健康、可用率,等于放弃 Havline 的全部价值。
--- ---
@@ -119,7 +119,7 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
1. 新增「Cloudflare 隧道」入口:粘贴 **token**(remotely-managed)或上传 **credentials JSON + tunnel UUID**(locally-managed),凭据加密存储。 1. 新增「Cloudflare 隧道」入口:粘贴 **token**(remotely-managed)或上传 **credentials JSON + tunnel UUID**(locally-managed),凭据加密存储。
2. 生成 `config.yml` 预览(**脱敏**,沿用 v1.2.12 的预览/下发两分支约定)并把文件写到 `<DataDir>/cloudflared/<name>/config.yml`。 2. 生成 `config.yml` 预览(**脱敏**,沿用 v1.2.12 的预览/下发两分支约定)并把文件写到 `<DataDir>/cloudflared/<name>/config.yml`。
3. 界面给出 CF 侧操作指引(建隧道、CNAME、`cloudflared tunnel route dns`),**不接管进程**。 3. 界面给出 CF 侧操作指引(建隧道、CNAME、`cloudflared tunnel route dns`),**不接管进程**。
4. 明确提示模式差异:locally-managed 才由 Fonu 生成规则。 4. 明确提示模式差异:locally-managed 才由 Havline 生成规则。
### 阶段 2:托管进程与健康(核心价值) ### 阶段 2:托管进程与健康(核心价值)
@@ -146,7 +146,7 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
- TryCloudflare(快速隧道:`cloudflared tunnel --url http://localhost:PORT`)官方明确「不保证 SLA 与可用性,仅供测试与开发」,域名随机、每次重启变化——**不作为正式功能**,最多做成「临时演示」按钮。 - TryCloudflare(快速隧道:`cloudflared tunnel --url http://localhost:PORT`)官方明确「不保证 SLA 与可用性,仅供测试与开发」,域名随机、每次重启变化——**不作为正式功能**,最多做成「临时演示」按钮。
**网络可用性** **网络可用性**
- 中国大陆访问 CF 边缘的延迟与稳定性不稳定,这对 Fonu 的用户群是**现实问题**:建议在文档与界面上对比说明「CF 适合海外访问优先的场景,国内链路以 frp + 自持 VPS 更可控」。 - 中国大陆访问 CF 边缘的延迟与稳定性不稳定,这对 Havline 的用户群是**现实问题**:建议在文档与界面上对比说明「CF 适合海外访问优先的场景,国内链路以 frp + 自持 VPS 更可控」。
- 出站 only 意味着 VPS 上不需要任何放行端口,但也就**读不到**「CF 侧有哪些域名指向了这条隧道」——相关校验只能靠 `cloudflared` 自身命令,不能靠 agent。 - 出站 only 意味着 VPS 上不需要任何放行端口,但也就**读不到**「CF 侧有哪些域名指向了这条隧道」——相关校验只能靠 `cloudflared` 自身命令,不能靠 agent。
**实现边界** **实现边界**
@@ -182,29 +182,29 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
### 9.2 值得直接借鉴的六个做法 ### 9.2 值得直接借鉴的六个做法
**① token 可以解码成凭据,于是 token 模式也能本地管规则** **① token 可以解码成凭据,于是 token 模式也能本地管规则**
`initialize_token_tunnel` 把 CF 隧道 token 解成 `{AccountTag, TunnelID, TunnelSecret}` 并写出 `creds.json` + `config.yml`——即「用户只给一个 token,但规则仍由面板本地生成」。这直接提升了 Fonu 方案的上限(§三 的补充已按此修正)。 `initialize_token_tunnel` 把 CF 隧道 token 解成 `{AccountTag, TunnelID, TunnelSecret}` 并写出 `creds.json` + `config.yml`——即「用户只给一个 token,但规则仍由面板本地生成」。这直接提升了 Havline 方案的上限(§三 的补充已按此修正)。
**② metrics 端口不去猜,从日志里读出来** **② metrics 端口不去猜,从日志里读出来**
命名隧道启动时**故意不传 `--metrics`**,让 cloudflared 用默认端口段(20241-20245),然后从 stderr 解析 `INF starting metrics server on 127.0.0.1:65432/metrics` 拿到真实端口——天然避开端口冲突(Fonu 现在给 frps dashboard 固定端口,多实例时会撞)。 命名隧道启动时**故意不传 `--metrics`**,让 cloudflared 用默认端口段(20241-20245),然后从 stderr 解析 `INF starting metrics server on 127.0.0.1:65432/metrics` 拿到真实端口——天然避开端口冲突(Havline 现在给 frps dashboard 固定端口,多实例时会撞)。
**③ `localhost` 必须归一化成 `127.0.0.1`** **③ `localhost` 必须归一化成 `127.0.0.1`**
`add_tunnel_endpoint` 里把 `localhost` 换成 `127.0.0.1`,注释写明「避免 Cloudflare 将其转换为本地网络 IP」。这是 CF 侧的真实坑,Fonu 生成 `ingress` 时要照做。 `add_tunnel_endpoint` 里把 `localhost` 换成 `127.0.0.1`,注释写明「避免 Cloudflare 将其转换为本地网络 IP」。这是 CF 侧的真实坑,Havline 生成 `ingress` 时要照做。
**④ 始终保留最后一条 catch-all** **④ 始终保留最后一条 catch-all**
新增端点插在「数组倒数第一位之前」(`insert_pos = len - 1`),末条永远是 `service: http_status:404`。Fonu 改 `ingress` 时必须沿用这个不变式,否则配置校验不过。 新增端点插在「数组倒数第一位之前」(`insert_pos = len - 1`),末条永远是 `service: http_status:404`。Havline 改 `ingress` 时必须沿用这个不变式,否则配置校验不过。
**⑤ 带宽指标:官方 metrics 没有 HTTP/2 字节数,它做了系统级兜底** **⑤ 带宽指标:官方 metrics 没有 HTTP/2 字节数,它做了系统级兜底**
- QUIC 下直接读 `quic_client_receive_bytes` / `quic_client_sent_bytes` / `quic_client_smoothed_rtt`。 - 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` 降级原因)。 - 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)。 - 并且**只统计公网远端 IP**(过滤私有/回环/链路本地/`100.64/10` CGNAT),延迟兜底用**主动 HEAD 探测** ingress 里的公网 hostname(带 `?__cf_probe=<nonce>` + no-cache)。
> 对 Fonu 的意义:这一块可以**先不做**(我们已有规则级流量与可用率),但「降级要给出明确原因而不是空白」这个原则要学。 > 对 Havline 的意义:这一块可以**先不做**(我们已有规则级流量与可用率),但「降级要给出明确原因而不是空白」这个原则要学。
**⑥ Windows 下起子进程要隐藏控制台窗口** **⑥ Windows 下起子进程要隐藏控制台窗口**
`creation_flags(CREATE_NO_WINDOW)`(`0x08000000`)。Fonu 本机在 Windows 上跑,托管 `cloudflared.exe` 时必须带上,否则会闪黑窗。 `creation_flags(CREATE_NO_WINDOW)`(`0x08000000`)。Havline 本机在 Windows 上跑,托管 `cloudflared.exe` 时必须带上,否则会闪黑窗。
### 9.3 与 Fonu 的差异(决定「借鉴什么、不借鉴什么」) ### 9.3 与 Havline 的差异(决定「借鉴什么、不借鉴什么」)
| 维度 | cf-tunnel-manager | Fonu 该怎么做 | | 维度 | cf-tunnel-manager | Havline 该怎么做 |
| --- | --- | --- | | --- | --- | --- |
| 职责 | **专做** Cloudflare Tunnel 的一个独立面板 | 把 CF 作为**第二条出口**并入现有规则/健康/通知/状态页体系 | | 职责 | **专做** Cloudflare Tunnel 的一个独立面板 | 把 CF 作为**第二条出口**并入现有规则/健康/通知/状态页体系 |
| 存储 | JSON 文件(`data/*.json`)+ `data/tunnels/<名>/` | 继续用 SQLite + 迁移;凭据进 `secret.Box` 加密字段 | | 存储 | JSON 文件(`data/*.json`)+ `data/tunnels/<名>/` | 继续用 SQLite + 迁移;凭据进 `secret.Box` 加密字段 |
@@ -212,11 +212,11 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
| 健康 | metrics 端点 scraping + 主动探测(仅前端展示) | 接入 `internal/monitor` 的 `Source`/`HealthRecorder`,落库可用率 + 复用告警与通知 | | 健康 | metrics 端点 scraping + 主动探测(仅前端展示) | 接入 `internal/monitor` 的 `Source`/`HealthRecorder`,落库可用率 + 复用告警与通知 |
| 实时日志 | WebSocket 广播(专为「看着它滚」设计) | 沿用现有「读日志文件 + 日志页」模型(与 frpc 一致,无需引入 WS) | | 实时日志 | WebSocket 广播(专为「看着它滚」设计) | 沿用现有「读日志文件 + 日志页」模型(与 frpc 一致,无需引入 WS) |
| 多实例 | 多隧道并行(每隧道一个子进程) | 同模;但用 SQLite 建 `tunnels` 表而不是内存 HashMap | | 多实例 | 多隧道并行(每隧道一个子进程) | 同模;但用 SQLite 建 `tunnels` 表而不是内存 HashMap |
| 认证 | 自带 JWT + 账号体系 | 复用 Fonu 现有会话 + API Token | | 认证 | 自带 JWT + 账号体系 | 复用 Havline 现有会话 + API Token |
### 9.4 对方案本体的修订建议 ### 9.4 对方案本体的修订建议
1. **方案 A 的第一阶段就加「凭据导入」两条路**:`token`(解码出 creds.json)与 `cert.pem + credentials JSON`(账号模式),二者最终都落到「本地 `config.yml` 由 Fonu 生成」。 1. **方案 A 的第一阶段就加「凭据导入」两条路**:`token`(解码出 creds.json)与 `cert.pem + credentials JSON`(账号模式),二者最终都落到「本地 `config.yml` 由 Havline 生成」。
2. **阶段 2 把「metrics 端口从日志发现」写成实现约定**,不要固定端口。 2. **阶段 2 把「metrics 端口从日志发现」写成实现约定**,不要固定端口。
3. **阶段 3 增加「云端 ↔ 本地同步」两个动作**(拉取 / 推送),并在界面上标明当前生效侧——这是参考项目比原方案更完整的地方。 3. **阶段 3 增加「云端 ↔ 本地同步」两个动作**(拉取 / 推送),并在界面上标明当前生效侧——这是参考项目比原方案更完整的地方。
4. **`ingress` 生成器锁定三条不变式**:末条 catch-all、`localhost` → `127.0.0.1`、插入位置在 catch-all 之前;并补一个「生成后自检」的纯函数测试(参照本仓 `maskFRPSSecrets` 的测试写法)。 4. **`ingress` 生成器锁定三条不变式**:末条 catch-all、`localhost` → `127.0.0.1`、插入位置在 catch-all 之前;并补一个「生成后自检」的纯函数测试(参照本仓 `maskFRPSSecrets` 的测试写法)。
@@ -234,7 +234,7 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
| 手段 | 具体做法 | 适用与代价 | | 手段 | 具体做法 | 适用与代价 |
| --- | --- | --- | | --- | --- | --- |
| **选协议** | `--protocol quic` / `http2` | 国内 QUIC(UDP 7844)常被 QoS 掉,`http2`(TCP 7844)反而稳;这是**成本最低、收益最直接**的一项 | | **选协议** | `--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 好线路) | | **选地址族** | `--edge-ip-version 4` / `6` / `auto`,或环境变量 `TUNNEL_EDGE_IP_VERSION` | ⚠️ **2026.4.0 起默认值由 `4` 改成 `auto`**(官方 CHANGES 里的 Breaking Change)——`auto` 会跟着系统解析器走,国内环境若 IPv6 路由差就可能反而变慢。**建议 Havline 显式下发 `--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」的通行做法 | | **优选边缘 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」的通行做法 |
**优选时两条不能错的约束**: **优选时两条不能错的约束**:
@@ -250,19 +250,19 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
也就是说:换域名解析 / 让访客改 hosts 都不现实,**访问侧这一段在 Tunnel 架构下就是交给 CF 的**。 也就是说:换域名解析 / 让访客改 hosts 都不现实,**访问侧这一段在 Tunnel 架构下就是交给 CF 的**。
### 对 Fonu 方案的落点 ### 对 Havline 方案的落点
| 阶段 | 做什么 | 依据 | | 阶段 | 做什么 | 依据 |
| --- | --- | --- | | --- | --- | --- |
| 阶段 2(建议直接做) | 「网络调优」三项做成设置项:`protocol`(默认 http2)、`edge-ip-version`(**默认 4**)、`ha-connections`;以命令行参数下发 | 参考实现已验证可行,且改一项就能治国内 QUIC 被打压的场景 | | 阶段 2(建议直接做) | 「网络调优」三项做成设置项:`protocol`(默认 http2)、`edge-ip-version`(**默认 4**)、`ha-connections`;以命令行参数下发 | 参考实现已验证可行,且改一项就能治国内 QUIC 被打压的场景 |
| 阶段 3(可选) | 「边缘优选」工具:并发探测官方 IPv4 段的 **TCP 7844 握手延迟** → 生成「两个 region 各 1~2 个优选 IP」的 hosts 建议;**页面展示而不是代写 hosts**(Windows 改 hosts 需管理员,Fonu 不应越权),并提供「复制这段内容」+ 生效检测 | 沿用本仓「不越权、降级要说明原因」的口径 | | 阶段 3(可选) | 「边缘优选」工具:并发探测官方 IPv4 段的 **TCP 7844 握手延迟** → 生成「两个 region 各 1~2 个优选 IP」的 hosts 建议;**页面展示而不是代写 hosts**(Windows 改 hosts 需管理员,Havline 不应越权),并提供「复制这段内容」+ 生效检测 | 沿用本仓「不越权、降级要说明原因」的口径 |
| 阶段 3(可选) | 优选前后对比:用**已有的** `metrics_samples` 延迟/可用率序列展示效果,而不是另造一套测速图 | 复用 §四 的健康与指标链路 | | 阶段 3(可选) | 优选前后对比:用**已有的** `metrics_samples` 延迟/可用率序列展示效果,而不是另造一套测速图 | 复用 §四 的健康与指标链路 |
> metrics 端口发现、健康判定与网络调优默认值见 §12.5 / §12.7;本节只讨论「本机 → CF 边缘」的优选。 > metrics 端口发现、健康判定与网络调优默认值见 §12.5 / §12.7;本节只讨论「本机 → CF 边缘」的优选。
### 必须写进功能说明的三句实话 ### 必须写进功能说明的三句实话
1. 优选只影响「本机 → CF 边缘」这一段;访客到自己那侧 CF 边缘的那段,Fonu 与用户都控制不了。 1. 优选只影响「本机 → CF 边缘」这一段;访客到自己那侧 CF 边缘的那段,Havline 与用户都控制不了。
2. 所以它**不是**「翻墙提速」类工具:国内访问 CF 的稳定性受链路与运营商影响,优选只能把「本机出口到边缘」这一段挑好。 2. 所以它**不是**「翻墙提速」类工具:国内访问 CF 的稳定性受链路与运营商影响,优选只能把「本机出口到边缘」这一段挑好。
3. 官方端点会变(见上),因此把所有 IP/域名做成**可配置 + 有默认值 + 可一键恢复默认**。 3. 官方端点会变(见上),因此把所有 IP/域名做成**可配置 + 有默认值 + 可一键恢复默认**。
@@ -270,13 +270,13 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
## 十一、给用户的一句话结论(可直接对外) ## 十一、给用户的一句话结论(可直接对外)
> Fonu 现在支持两类公网出口:**frp**(自持 VPS,任意 TCP/UDP,完全自主)与 **Cloudflare 隧道**(无需公网 IP 与 VPS,HTTP 类最省事,自带 CF 防护)。 > Havline 现在支持两类公网出口:**frp**(自持 VPS,任意 TCP/UDP,完全自主)与 **Cloudflare 隧道**(无需公网 IP 与 VPS,HTTP 类最省事,自带 CF 防护)。
> 要裸 TCP 端口、要完全自主,用 frp;要让内网 Web 服务零端口暴露地上公网,用 Cloudflare 隧道。 > 要裸 TCP 端口、要完全自主,用 frp;要让内网 Web 服务零端口暴露地上公网,用 Cloudflare 隧道。
> Cloudflare 隧道还提供网络调优(协议 / 地址族 / 连接数)与**边缘 IP 优选**(仅作用于本机到 CF 边缘这一段,访客侧无法自选)。 > Cloudflare 隧道还提供网络调优(协议 / 地址族 / 连接数)与**边缘 IP 优选**(仅作用于本机到 CF 边缘这一段,访客侧无法自选)。
--- ---
## 十二、方案优化 v2(结合 Tunnel-Cloudflared-Manager 1.0.1 与 Fonu 1.5.4) ## 十二、方案优化 v2(结合 Tunnel-Cloudflared-Manager 1.0.1 与 Havline 1.5.4)
> 本节是 2026-09-28 的优化版落地口径。§一~§十一 保留为背景与论证;出现冲突时以本节为准。 > 本节是 2026-09-28 的优化版落地口径。§一~§十一 保留为背景与论证;出现冲突时以本节为准。
@@ -290,43 +290,43 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
| --- | --- | --- | | --- | --- | --- |
| token 解码后的 JSON 字段是 `a` / `t` / `s`,兼容 `AccountTag` / `TunnelID` / `TunnelSecret`;使用标准 base64 并自动补 `=` | `src/services/cloudflare_api.rs` 的 `decode_tunnel_token` / `base64_decode` | token 模式也能本地生成 `creds.json`,从而按 locally-managed 跑本地规则 | | 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 | | `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 必须显式选择「本地生效」还是「云端生效」 | | `start_tunnel` 在 token 非空时优先用 `--token` 启动,即使已经生成了本地 `config.yml` | `src/services/tunnel.rs` 的 `start_tunnel` | **本地规则会被云端覆盖**;Havline 必须显式选择「本地生效」还是「云端生效」 |
| 命名隧道**不传** `--metrics`,从 stderr 解析 `starting metrics server on 127.0.0.1:<port>/metrics`;快速隧道传 `--metrics 127.0.0.1:0` | `start_tunnel` / `start_quick_tunnel` | metrics 端口必须从日志发现,不能固定,否则多实例会撞端口 | | 命名隧道**不传** `--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 带宽要明确降级 | | 健康用 `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 必须做哈希 / 版本校验,并统一镜像表 | | 二进制下载只校验「文件 >= 10MB」,没有 SHA-256;`get_available_mirrors` 返回的镜像 ID 与实际支持的镜像不一致 | `src/services/binary.rs` | Havline 必须做哈希 / 版本校验,并统一镜像表 |
| ingress 三条不变式:末条固定 `http_status:404`、`localhost` 归一化为 `127.0.0.1`、新规则插在 catch-all 之前 | `add_tunnel_endpoint` / `add_tunnel_endpoint_local_only` | 生成器必须锁定这三条并加纯函数测试 | | 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` 时必须带,否则闪黑窗 | | Windows 子进程设置 `CREATE_NO_WINDOW` | `start_tunnel` | Havline 在 Windows 托管 `cloudflared.exe` 时必须带,否则闪黑窗 |
| 快速隧道从 stderr 解析 `*.trycloudflare.com` URL 并缓存 | `start_quick_tunnel` | 只能做「临时演示」;URL 每次重启都会变 | | 快速隧道从 stderr 解析 `*.trycloudflare.com` URL 并缓存 | `start_quick_tunnel` | 只能做「临时演示」;URL 每次重启都会变 |
| 存储是 JSON 文件 + 内存 `HashMap<String, Child>` | `src/storage/config.rs`、`TunnelProcessManager` | Fonu 用 SQLite + 迁移,多实例与并发状态更稳 | | 存储是 JSON 文件 + 内存 `HashMap<String, Child>` | `src/storage/config.rs`、`TunnelProcessManager` | Havline 用 SQLite + 迁移,多实例与并发状态更稳 |
### 12.2 部署位置:原方案最大的空白 ### 12.2 部署位置:原方案最大的空白
原方案默认「本机跑 cloudflared」,但没有回答一个关键问题:**Fonu 是容器化部署,容器里的 `127.0.0.1` 不是宿主机。** 原方案默认「本机跑 cloudflared」,但没有回答一个关键问题:**Havline 是容器化部署,容器里的 `127.0.0.1` 不是宿主机。**
Fonu 的运行时(`Dockerfile`)在同一个容器里装了主程序与 nginx,`docker-compose.yml` 只映射 `6893` / `80` / `443`。因此 v2 的默认拓扑是: Havline 的运行时(`Dockerfile`)在同一个容器里装了主程序与 nginx,`docker-compose.yml` 只映射 `6893` / `80` / `443`。因此 v2 的默认拓扑是:
> **cloudflared 跑在 Fonu 容器内,与 nginx 同命名空间;CF ingress 统一指向 `http://127.0.0.1:<nginx_http_port>`(默认 80),由 nginx 再转发到上游。** > **cloudflared 跑在 Havline 容器内,与 nginx 同命名空间;CF ingress 统一指向 `http://127.0.0.1:<nginx_http_port>`(默认 80),由 nginx 再转发到上游。**
这样做有四个直接收益: 这样做有四个直接收益:
1. 绕开容器网络命名空间的坑:`127.0.0.1` 在容器内确定指向 Fonu nginx,而不是宿主机或上游。 1. 绕开容器网络命名空间的坑:`127.0.0.1` 在容器内确定指向 Havline nginx,而不是宿主机或上游。
2. 复用现有 nginx 的安全、限流、geo、TLS、错误页与 Basic Auth,不在 CF 侧重复一套策略。 2. 复用现有 nginx 的安全、限流、geo、TLS、错误页与 Basic Auth,不在 CF 侧重复一套策略。
3. 规则同源:CF 只负责「域名 → Fonu nginx」,nginx 负责「域名 → 上游」;本机反代规则仍是唯一事实来源。 3. 规则同源:CF 只负责「域名 → Havline nginx」,nginx 负责「域名 → 上游」;本机反代规则仍是唯一事实来源。
4. 只有 HTTP / HTTPS 规则能进 CF;TCP / UDP 规则明确排除,避免用户误解。 4. 只有 HTTP / HTTPS 规则能进 CF;TCP / UDP 规则明确排除,避免用户误解。
三种拓扑的取舍: 三种拓扑的取舍:
| 拓扑 | cloudflared 位置 | ingress 目标 | 适用 | | 拓扑 | cloudflared 位置 | ingress 目标 | 适用 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| **容器内(默认)** | Fonu 容器 | `http://127.0.0.1:80`(容器内 nginx) | 绝大多数部署;规则先接入本机 nginx | | **容器内(默认)** | Havline 容器 | `http://127.0.0.1:80`(容器内 nginx) | 绝大多数部署;规则先接入本机 nginx |
| host 网络 | Fonu 容器 `network_mode: host` | `http://127.0.0.1:80`(宿主机 nginx) | 需要直接访问宿主机端口、或已有宿主机 nginx | | host 网络 | Havline 容器 `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 的高级用户 | | VPS Agent(可选) | VPS 上的 `havline-agent` 托管 | `http://127.0.0.1:<frps vhost 端口>` | 已有 VPS、只想让部分域名走 CF 的高级用户 |
部署注意事项: 部署注意事项:
- cloudflared 是**出站 only**,不要为它额外映射任何入站端口。 - cloudflared 是**出站 only**,不要为它额外映射任何入站端口。
- 二进制、`creds.json`、`cert.pem`、`config.yml`、日志都必须落在 `/data` 持久卷下,否则容器重建即丢失。 - 二进制、`creds.json`、`cert.pem`、`config.yml`、日志都必须落在 `/data` 持久卷下,否则容器重建即丢失。
- 如果要暴露宿主机或内网服务,推荐先把该服务接入 Fonu 的本机 nginx 反代,再让 CF 指向 nginx;不要在 CF ingress 里直接写宿主机 IP。 - 如果要暴露宿主机或内网服务,推荐先把该服务接入 Havline 的本机 nginx 反代,再让 CF 指向 nginx;不要在 CF ingress 里直接写宿主机 IP。
- host 网络模式只作为显式选项,不默认开启;开启后 `127.0.0.1` 的语义会变化,UI 必须提示。 - host 网络模式只作为显式选项,不默认开启;开启后 `127.0.0.1` 的语义会变化,UI 必须提示。
### 12.3 凭据与模式:显式区分「谁在生效」 ### 12.3 凭据与模式:显式区分「谁在生效」
@@ -335,21 +335,21 @@ Fonu 的运行时(`Dockerfile`)在同一个容器里装了主程序与 nginx
| 模式 | 凭据 | 启动方式 | 规则来源 | 云端 fetch / push | | 模式 | 凭据 | 启动方式 | 规则来源 | 云端 fetch / push |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| **A. token + 本地规则** | token | 解码 token → 写 `creds.json` → `tunnel --config config.yml run` | Fonu 生成的 `config.yml` | ❌(token 不含 API 凭据) | | **A. token + 本地规则** | token | 解码 token → 写 `creds.json` → `tunnel --config config.yml run` | Havline 生成的 `config.yml` | ❌(token 不含 API 凭据) |
| **B. token + 云端规则** | token | `tunnel run --token <token>` | Cloudflare Dashboard / 云端 | ❌ | | **B. token + 云端规则** | token | `tunnel run --token <token>` | Cloudflare Dashboard / 云端 | ❌ |
| **C. 账号 + 本地规则 + 云端同步** | `cert.pem` + credentials JSON | `tunnel --config config.yml run` | 本地,可与云端互相同步 | ✅ | | **C. 账号 + 本地规则 + 云端同步** | `cert.pem` + credentials JSON | `tunnel --config config.yml run` | 本地,可与云端互相同步 | ✅ |
实施口径: 实施口径:
- 模式 A 绝不传 `--token`,只用 `--config`,否则本地规则不生效。 - 模式 A 绝不传 `--token`,只用 `--config`,否则本地规则不生效。
- 模式 B 必须在 UI 明确写「规则由 Cloudflare 面板管理,Fonu 的本地预览不生效」,不要让用户以为 Fonu 生成的规则已经下发。 - 模式 B 必须在 UI 明确写「规则由 Cloudflare 面板管理,Havline 的本地预览不生效」,不要让用户以为 Havline 生成的规则已经下发。
- 模式 C 才提供「从云端拉取」「推送到云端」两个动作;token 模式不提供,只给 Dashboard 指引。 - 模式 C 才提供「从云端拉取」「推送到云端」两个动作;token 模式不提供,只给 Dashboard 指引。
- 所有凭据走 `secret.Box` 加密字段;预览一律掩码,下发时用原文。`creds.json` 权限 `0600`,`config.yml` 权限 `0640`,目录 `0750`。 - 所有凭据走 `secret.Box` 加密字段;预览一律掩码,下发时用原文。`creds.json` 权限 `0600`,`config.yml` 权限 `0640`,目录 `0750`。
- 状态接口返回 `effective_side: local | cloud`,前端常驻显示,切换模式时要求二次确认。 - 状态接口返回 `effective_side: local | cloud`,前端常驻显示,切换模式时要求二次确认。
### 12.4 数据模型与目录布局(Fonu 版) ### 12.4 数据模型与目录布局(Havline 版)
参考实现用 JSON 文件 + 内存 HashMap,适合单进程小工具;Fonu 已有 SQLite 与迁移体系,v2 建议落成表: 参考实现用 JSON 文件 + 内存 HashMap,适合单进程小工具;Havline 已有 SQLite 与迁移体系,v2 建议落成表:
`cf_tunnels`: `cf_tunnels`:
@@ -366,7 +366,7 @@ Fonu 的运行时(`Dockerfile`)在同一个容器里装了主程序与 nginx
| `network_json` | protocol / edge-ip-version / ha-connections / proxy_mode / originRequest | | `network_json` | protocol / edge-ip-version / ha-connections / proxy_mode / originRequest |
| `status` / `last_error` | `stopped` / `starting` / `connected` / `reconnecting` / `failed` | | `status` / `last_error` | `stopped` / `starting` / `connected` / `reconnecting` / `failed` |
| `metrics_port` | 从日志发现的端口,仅运行时缓存 | | `metrics_port` | 从日志发现的端口,仅运行时缓存 |
| `auto_start` | Fonu 启动时是否自动拉起 | | `auto_start` | Havline 启动时是否自动拉起 |
目录布局: 目录布局:
@@ -391,7 +391,7 @@ Fonu 的运行时(`Dockerfile`)在同一个容器里装了主程序与 nginx
- 统一镜像表:`official` / `gh-proxy-org` / `gh-proxy-hk` / `gh-proxy-cdn` / `gh-proxy-edgeone` + 自定义 URL 模板;修正参考项目「镜像列表与实现不一致」的问题。 - 统一镜像表:`official` / `gh-proxy-org` / `gh-proxy-hk` / `gh-proxy-cdn` / `gh-proxy-edgeone` + 自定义 URL 模板;修正参考项目「镜像列表与实现不一致」的问题。
- 下载校验:优先官方 checksums(若提供);否则至少校验文件大小、可执行性、`cloudflared --version`;镜像源与官方源使用同一份哈希。 - 下载校验:优先官方 checksums(若提供);否则至少校验文件大小、可执行性、`cloudflared --version`;镜像源与官方源使用同一份哈希。
- 版本目录 + 原子替换 + 激活指针,复用 `internal/frp/runtime.go` 的 `readActiveBinary` / `writeActiveBinary` 思路。 - 版本目录 + 原子替换 + 激活指针,复用 `internal/frp/runtime.go` 的 `readActiveBinary` / `writeActiveBinary` 思路。
- 支持 linux amd64 / arm64、windows amd64、darwin amd64 / arm64;Fonu 侧用 `runtime.GOOS/GOARCH` 选择。 - 支持 linux amd64 / arm64、windows amd64、darwin amd64 / arm64;Havline 侧用 `runtime.GOOS/GOARCH` 选择。
**进程** **进程**
@@ -414,7 +414,7 @@ Fonu 的运行时(`Dockerfile`)在同一个容器里装了主程序与 nginx
- `/ready` 返回 200:cloudflared 已就绪。 - `/ready` 返回 200:cloudflared 已就绪。
- `cloudflared_tunnel_ha_connections > 0`:已连上边缘。 - `cloudflared_tunnel_ha_connections > 0`:已连上边缘。
- 延迟:QUIC 用 `quic_client_smoothed_rtt`;HTTP/2 用 `cloudflared_rpc_client_latency_secs` 或主动 HEAD 探测。 - 延迟: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 统计。 - 带宽:QUIC 直接用 `quic_client_receive_bytes` / `quic_client_sent_bytes` 差值;HTTP/2 官方没有字节指标,Havline 第一轮明确降级为「不展示带宽」并给出原因,不做参考项目那套进程 socket 统计。
--- ---
@@ -423,7 +423,7 @@ Fonu 的运行时(`Dockerfile`)在同一个容器里装了主程序与 nginx
输入: 输入:
- 本机反代规则中启用且属于 HTTP / HTTPS 的域名; - 本机反代规则中启用且属于 HTTP / HTTPS 的域名;
- Fonu nginx 的容器内入口端口(默认 `80`,可用配置覆盖); - Havline nginx 的容器内入口端口(默认 `80`,可用配置覆盖);
- 用户勾选要暴露的域名集合。 - 用户勾选要暴露的域名集合。
输出:`config.yml` 的 `ingress` 数组。三条不变式必须写进生成器并用纯函数测试锁定: 输出:`config.yml` 的 `ingress` 数组。三条不变式必须写进生成器并用纯函数测试锁定:
@@ -435,7 +435,7 @@ Fonu 的运行时(`Dockerfile`)在同一个容器里装了主程序与 nginx
与 nginx 的关系: 与 nginx 的关系:
```text ```text
访客 → Cloudflare 边缘 → cloudflared(Fonu 容器)→ 127.0.0.1:80(Fonu nginx)→ 上游 访客 → Cloudflare 边缘 → cloudflared(Havline 容器)→ 127.0.0.1:80(Havline nginx)→ 上游
``` ```
- CF 侧不重复配置限流、geo、TLS、Basic Auth;这些仍由本机 nginx 负责。 - CF 侧不重复配置限流、geo、TLS、Basic Auth;这些仍由本机 nginx 负责。
@@ -444,7 +444,7 @@ Fonu 的运行时(`Dockerfile`)在同一个容器里装了主程序与 nginx
### 12.7 网络调优(默认值与下发方式) ### 12.7 网络调优(默认值与下发方式)
| 设置 | Fonu 默认 | 下发方式 | 说明 | | 设置 | Havline 默认 | 下发方式 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `transport_protocol` | `http2` | 环境变量 `TUNNEL_TRANSPORT_PROTOCOL` + `config.yml` 的 `protocol` | 国内 QUIC(UDP 7844)常被 QoS,TCP 更稳;提供 `auto` / `quic` | | `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` | | `edge_ip_version` | `4` | 环境变量 `TUNNEL_EDGE_IP_VERSION` + `config.yml` 的 `edge-ip-version` | 2026.4.0 起官方默认改为 `auto`,国内 IPv6 路由差时反而变慢;提供 `auto` / `6` |
@@ -477,9 +477,9 @@ Fonu 的运行时(`Dockerfile`)在同一个容器里装了主程序与 nginx
token 模式不提供 push / fetch,只给 Dashboard 指引;这是凭据能力的硬边界,不要用「同步失败」这类模糊提示掩盖。 token 模式不提供 push / fetch,只给 Dashboard 指引;这是凭据能力的硬边界,不要用「同步失败」这类模糊提示掩盖。
### 12.9 与 Fonu 现有模块的集成点 ### 12.9 与 Havline 现有模块的集成点
| Fonu 模块 | 复用还是新增 | 具体落点 | | Havline 模块 | 复用还是新增 | 具体落点 |
| --- | --- | --- | | --- | --- | --- |
| 二进制下载 / 启用 | 新增 `internal/cloudflared`,参考 `internal/frp/runtime.go` | 镜像表、哈希校验、版本目录、激活指针 | | 二进制下载 / 启用 | 新增 `internal/cloudflared`,参考 `internal/frp/runtime.go` | 镜像表、哈希校验、版本目录、激活指针 |
| 进程托管 | 新增 `internal/cloudflared`,参考 `internal/frp/manager.go` | 启停、日志追加、退出原因、`tailFile` | | 进程托管 | 新增 `internal/cloudflared`,参考 `internal/frp/manager.go` | 启停、日志追加、退出原因、`tailFile` |
@@ -532,7 +532,7 @@ token 模式不提供 push / fetch,只给 Dashboard 指引;这是凭据能
- **容器网络**:默认拓扑是容器内 cloudflared → 容器内 nginx;不要假设 `127.0.0.1` 是宿主机。 - **容器网络**:默认拓扑是容器内 cloudflared → 容器内 nginx;不要假设 `127.0.0.1` 是宿主机。
- **token 覆盖**:模式 B 的本地 `config.yml` 不生效;UI 必须明确,模式 A 才能本地管规则。 - **token 覆盖**:模式 B 的本地 `config.yml` 不生效;UI 必须明确,模式 A 才能本地管规则。
- **HTTP/2 带宽**:官方 metrics 没有字节指标;第一轮明确降级并说明原因,不做进程 socket 统计。 - **HTTP/2 带宽**:官方 metrics 没有字节指标;第一轮明确降级并说明原因,不做进程 socket 统计。
- **Windows**:`CREATE_NO_WINDOW` 必带;若做进程级字节统计还需要管理员权限,Fonu 第一轮不做。 - **Windows**:`CREATE_NO_WINDOW` 必带;若做进程级字节统计还需要管理员权限,Havline 第一轮不做。
- **CF 条款**:视频 / 大文件分发不在推荐用法内;TryCloudflare 不保证 SLA。 - **CF 条款**:视频 / 大文件分发不在推荐用法内;TryCloudflare 不保证 SLA。
- **国内链路**:CF 边缘延迟受运营商影响;优选只影响本机到边缘一段。 - **国内链路**:CF 边缘延迟受运营商影响;优选只影响本机到边缘一段。
- **API 凭据**:账号模式的 `cert.pem` 与可选 API Token 都是高权限凭据,必须最小化权限并加密存储。 - **API 凭据**:账号模式的 `cert.pem` 与可选 API Token 都是高权限凭据,必须最小化权限并加密存储。
@@ -542,9 +542,9 @@ token 模式不提供 push / fetch,只给 Dashboard 指引;这是凭据能
### 12.13 结论 ### 12.13 结论
Cloudflare Tunnel 对 Fonu 是**第二条出口**,不是 frp 的替代:它补上「没有公网 IP、也不想长期租 VPS」的 HTTP 类场景。v2 的核心修订是四件事: Cloudflare Tunnel 对 Havline 是**第二条出口**,不是 frp 的替代:它补上「没有公网 IP、也不想长期租 VPS」的 HTTP 类场景。v2 的核心修订是四件事:
1. cloudflared 跑在 Fonu 容器内,ingress 统一指向容器内 nginx,绕开容器网络坑; 1. cloudflared 跑在 Havline 容器内,ingress 统一指向容器内 nginx,绕开容器网络坑;
2. 显式区分「本地规则生效」与「云端规则生效」,不让 token 模式悄悄覆盖本地配置; 2. 显式区分「本地规则生效」与「云端规则生效」,不让 token 模式悄悄覆盖本地配置;
3. metrics 端口从日志发现,健康用 `/ready` + `ha_connections`,HTTP/2 带宽明确降级; 3. metrics 端口从日志发现,健康用 `/ready` + `ha_connections`,HTTP/2 带宽明确降级;
4. 二进制下载补哈希 / 版本校验,ingress 生成器锁定三条不变式。 4. 二进制下载补哈希 / 版本校验,ingress 生成器锁定三条不变式。
+41 -41
View File
@@ -1,4 +1,4 @@
# Fonu 容器部署与数据指南 # Havline 容器部署与数据指南
> 适用版本:v1.2.19 及以后(Docker / 飞牛 fnOS 等 NAS 环境) > 适用版本:v1.2.19 及以后(Docker / 飞牛 fnOS 等 NAS 环境)
> 相关文件:`docker-compose.yml`、`docker-compose.hub.yml`、`docker-compose.host.yml`、`Dockerfile`、`scripts/upgrade.sh` > 相关文件:`docker-compose.yml`、`docker-compose.hub.yml`、`docker-compose.host.yml`、`Dockerfile`、`scripts/upgrade.sh`
@@ -13,79 +13,79 @@
| `docker-compose.yml` | 用**本地源码**构建(`context: .`) | 已经有源码目录(改代码后直接重建最方便) | | `docker-compose.yml` | 用**本地源码**构建(`context: .`) | 已经有源码目录(改代码后直接重建最方便) |
| `docker-compose.host.yml` | 本地源码构建 + `network_mode: host` | 80/443 已被别的服务占用(如飞牛、群晖自带反代) | | `docker-compose.host.yml` | 本地源码构建 + `network_mode: host` | 80/443 已被别的服务占用(如飞牛、群晖自带反代) |
三份都写了 `name: fonu`:**不要删掉这行**。不写它,改了目录名/项目名后 compose 会把它当成新项目,`fonu-data` 变成新的空卷——现象就是「数据全没了」。 三份都写了 `name: havline`:**不要删掉这行**。不写它,改了目录名/项目名后 compose 会把它当成新项目,`havline-data` 变成新的空卷——现象就是「数据全没了」。
--- ---
## 二、干净机器部署(推荐路径) ## 二、干净机器部署(推荐路径)
```bash ```bash
mkdir -p /opt/fonu && cd /opt/fonu mkdir -p /opt/havline && cd /opt/havline
# 从仓库取这一份编排文件(用你仓库页面上的「原始文件」链接,或 git clone 后复制) # 从仓库取这一份编排文件(用你仓库页面上的「原始文件」链接,或 git clone 后复制)
# curl -fsSLo docker-compose.hub.yml <仓库里该文件的原始地址> # curl -fsSLo docker-compose.hub.yml <仓库里该文件的原始地址>
docker compose up -d --build docker compose up -d --build
docker compose logs -f fonu # 首次启动会初始化数据库、生成会话密钥 docker compose logs -f havline # 首次启动会初始化数据库、生成会话密钥
``` ```
首次访问 `http://<机器IP>:6893`,用日志里的初始口令登录。初始口令只在**首次启动**生成一次: 首次访问 `http://<机器IP>:6893`,用日志里的初始口令登录。初始口令只在**首次启动**生成一次:
```bash ```bash
docker compose logs fonu 2>&1 | grep -i password | tail -n 3 docker compose logs havline 2>&1 | grep -i password | tail -n 3
``` ```
80/443 被占用的环境改用 host 那份: 80/443 被占用的环境改用 host 那份:
```bash ```bash
COMPOSE_FILE=docker-compose.host.yml docker compose up -d --build COMPOSE_FILE=docker-compose.host.yml docker compose up -d --build
# 反代端口默认 18080/9443(可用 FONU_NGINX_HTTP_PORT / FONU_NGINX_HTTPS_PORT 覆盖) # 反代端口默认 18080/9443(可用 HAVLINE_NGINX_HTTP_PORT / HAVLINE_NGINX_HTTPS_PORT 覆盖)
``` ```
--- ---
## 三、数据存在哪 ## 三、数据存在哪
数据**不在镜像里**,全部在数据目录中(容器内固定为 `/data`,由 `FONU_DATA_DIR` 指定): 数据**不在镜像里**,全部在数据目录中(容器内固定为 `/data`,由 `HAVLINE_DATA_DIR` 指定):
| 路径 | 内容 | | 路径 | 内容 |
| --- | --- | | --- | --- |
| `fonu.db` | SQLite 主库:反代规则、穿透规则与服务端、DDNS、证书记录、设置、API Token、健康事件、指标历史 | | `havline.db` | SQLite 主库:反代规则、穿透规则与服务端、DDNS、证书记录、设置、API Token、健康事件、指标历史 |
| `session_secret` | 会话与**凭据加密密钥**(v1.2.17 起未配置时自动生成,0600) | | `session_secret` | 会话与**凭据加密密钥**(v1.2.17 起未配置时自动生成,0600) |
| `logs/` | `app.log`(应用日志)、`access.log` / `error.log`(本机 Nginx) | | `logs/` | `app.log`(应用日志)、`access.log` / `error.log`(本机 Nginx) |
| `certs/` | 证书与私钥 | | `certs/` | 证书与私钥 |
| `nginx/` | Fonu 生成的 Nginx 配置与 pid | | `nginx/` | Havline 生成的 Nginx 配置与 pid |
| `frp/` | frpc 二进制、各服务端的 `frpc-server-N.toml` 与日志 | | `frp/` | frpc 二进制、各服务端的 `frpc-server-N.toml` 与日志 |
| `ssh_known_hosts` | SSH 主机密钥 TOFU 记录 | | `ssh_known_hosts` | SSH 主机密钥 TOFU 记录 |
| `backups/` | 应用内「数据备份」导出的归档 | | `backups/` | 应用内「数据备份」导出的归档 |
### 3.1 默认:命名卷 ### 3.1 默认:命名卷
默认挂在命名卷 `fonu-data`(compose 项目名是 `fonu`,所以完整卷名是 `fonu_fonu-data`),由 Docker 管理,位置随 Docker 数据根。 默认挂在命名卷 `havline-data`(compose 项目名是 `havline`,所以完整卷名是 `havline_havline-data`),由 Docker 管理,位置随 Docker 数据根。
```bash ```bash
docker volume ls | grep fonu docker volume ls | grep havline
docker volume inspect fonu_fonu-data --format '{{.Mountpoint}}' docker volume inspect havline_havline-data --format '{{.Mountpoint}}'
docker compose exec fonu ls -l /data # 容器内视角,最直观 docker compose exec havline ls -l /data # 容器内视角,最直观
``` ```
飞牛这类 NAS 的 Docker 数据根常在 `/vol1/docker`,于是实际落在 飞牛这类 NAS 的 Docker 数据根常在 `/vol1/docker`,于是实际落在
`/vol1/docker/volumes/fonu_fonu-data/_data`。**以 `docker volume inspect` 为准**,各家不同。 `/vol1/docker/volumes/havline_havline-data/_data`。**以 `docker volume inspect` 为准**,各家不同。
### 3.2 指定宿主机目录(bind mount) ### 3.2 指定宿主机目录(bind mount)
在同层 `.env` 里写一行即可,以 `/` 或 `.` 开头的路径会被 compose 当成 bind mount: 在同层 `.env` 里写一行即可,以 `/` 或 `.` 开头的路径会被 compose 当成 bind mount:
```bash ```bash
echo 'FONU_DATA_PATH=/vol1/docker/fonu-data' >> .env echo 'HAVLINE_DATA_PATH=/vol1/docker/havline-data' >> .env
``` ```
从**已有命名卷**迁过去(迁移前先停服务): 从**已有命名卷**迁过去(迁移前先停服务):
```bash ```bash
docker compose down docker compose down
sudo mkdir -p /vol1/docker/fonu-data sudo mkdir -p /vol1/docker/havline-data
docker run --rm -v fonu_fonu-data:/from -v /vol1/docker/fonu-data:/to alpine sh -c 'cp -av /from/. /to/' docker run --rm -v havline_havline-data:/from -v /vol1/docker/havline-data:/to alpine sh -c 'cp -av /from/. /to/'
docker compose up -d docker compose up -d
docker compose exec fonu ls -l /data # 确认数据还在 docker compose exec havline ls -l /data # 确认数据还在
``` ```
反过来(bind → 卷)同理,把两边路径对调即可。**不要**只做一边的拷贝就改配置,否则会看到「空实例」。 反过来(bind → 卷)同理,把两边路径对调即可。**不要**只做一边的拷贝就改配置,否则会看到「空实例」。
@@ -97,14 +97,14 @@ docker compose exec fonu ls -l /data # 确认数据还在
**整目录备份**(推荐,含密钥与证书): **整目录备份**(推荐,含密钥与证书):
```bash ```bash
docker run --rm -v fonu_fonu-data:/data -v "$PWD":/backup alpine \ docker run --rm -v havline_havline-data:/data -v "$PWD":/backup alpine \
tar czf /backup/fonu-data-$(date +%F).tgz -C /data . tar czf /backup/havline-data-$(date +%F).tgz -C /data .
``` ```
三个必须知道的点: 三个必须知道的点:
1. 千万别只拷 `fonu.db`:`session_secret` 是凭据的加密密钥,缺了它库里存的 frp token、通知密码都解不开; 1. 千万别只拷 `havline.db`:`session_secret` 是凭据的加密密钥,缺了它库里存的 frp token、通知密码都解不开;
2. 不要在服务运行时手改 `fonu.db`(SQLite 单连接,容易被写坏)——要改先 `docker compose stop fonu`; 2. 不要在服务运行时手改 `havline.db`(SQLite 单连接,容易被写坏)——要改先 `docker compose stop havline`;
3. 应用内「设置 → 数据」也有备份/恢复按钮,归档落在 `backups/`,恢复后需要重启容器。 3. 应用内「设置 → 数据」也有备份/恢复按钮,归档落在 `backups/`,恢复后需要重启容器。
--- ---
@@ -127,11 +127,11 @@ COMPOSE_FILE=docker-compose.host.yml sudo bash scripts/upgrade.sh # host 网
### 5.2 手动 ### 5.2 手动
```bash ```bash
cd /opt/fonu && docker compose pull 2>/dev/null; docker compose up -d --build cd /opt/havline && docker compose pull 2>/dev/null; docker compose up -d --build
docker images fonu # 看历史上留下哪些 tag,回滚时用 docker images havline # 看历史上留下哪些 tag,回滚时用
``` ```
镜像 tag 由 `FONU_VERSION` 决定(`scripts/upgrade.sh` 会从仓库 `VERSION` 自动取),不设时是 `fonu:local`。 镜像 tag 由 `HAVLINE_VERSION` 决定(`scripts/upgrade.sh` 会从仓库 `VERSION` 自动取),不设时是 `havline:local`。
镜像是本地构建的,所以升级不需要 `docker pull`;上面那条 `up` 会带上 `--build` 重新构建。 镜像是本地构建的,所以升级不需要 `docker pull`;上面那条 `up` 会带上 `--build` 重新构建。
@@ -140,16 +140,16 @@ docker images fonu # 看历史上留下哪些 tag,回滚时
构建上下文是 Git 地址,所以**每次 `--build` 都会重新解析仓库分支**(日志里那句 `load git source ... refs/heads/main` 就是它),不需要 `git pull`: 构建上下文是 Git 地址,所以**每次 `--build` 都会重新解析仓库分支**(日志里那句 `load git source ... refs/heads/main` 就是它),不需要 `git pull`:
```bash ```bash
cd /opt/fonu cd /opt/havline
docker compose up -d --build # 重新克隆 main → 构建 → 按新镜像重建容器 docker compose up -d --build # 重新克隆 main → 构建 → 按新镜像重建容器
``` ```
建议**带上版本号**,给镜像留一个固定 tag(否则一直是 `fonu:local`,回滚时找不到旧版): 建议**带上版本号**,给镜像留一个固定 tag(否则一直是 `havline:local`,回滚时找不到旧版):
```bash ```bash
FONU_VERSION=1.2.19 docker compose up -d --build HAVLINE_VERSION=1.2.19 docker compose up -d --build
# 回滚:镜像还在本地,不用重新构建 # 回滚:镜像还在本地,不用重新构建
FONU_VERSION=1.2.18 docker compose up -d HAVLINE_VERSION=1.2.18 docker compose up -d
``` ```
版本号从仓库的 `CHANGELOG.md` 或 `VERSION` 文件取;面板左侧栏底部也会显示当前运行的版本。 版本号从仓库的 `CHANGELOG.md` 或 `VERSION` 文件取;面板左侧栏底部也会显示当前运行的版本。
@@ -159,7 +159,7 @@ FONU_VERSION=1.2.18 docker compose up -d
### 5.4 升级前后的两件小事 ### 5.4 升级前后的两件小事
- **升级前备份**(整目录,含密钥):见 §四那条 `tar` 命令;遇到异常可整个目录回滚。 - **升级前备份**(整目录,含密钥):见 §四那条 `tar` 命令;遇到异常可整个目录回滚。
- **升级后确认**:`docker compose logs -f fonu` 看启动是否正常(会打印「会话密钥已就绪」),再刷新面板看版本与功能。 - **升级后确认**:`docker compose logs -f havline` 看启动是否正常(会打印「会话密钥已就绪」),再刷新面板看版本与功能。
--- ---
@@ -167,12 +167,12 @@ FONU_VERSION=1.2.18 docker compose up -d
| 变量 | 默认 | 说明 | | 变量 | 默认 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `FONU_DATA_DIR` | `/data` | 容器内数据目录,一般不用改;要换位置改**挂载** | | `HAVLINE_DATA_DIR` | `/data` | 容器内数据目录,一般不用改;要换位置改**挂载** |
| `FONU_DATA_PATH` | `fonu-data` | **宿主机侧**数据位置:命名卷名,或绝对路径(bind mount) | | `HAVLINE_DATA_PATH` | `havline-data` | **宿主机侧**数据位置:命名卷名,或绝对路径(bind mount) |
| `FONU_LISTEN` | `:6893` | 面板监听地址 | | `HAVLINE_LISTEN` | `:6893` | 面板监听地址 |
| `FONU_SESSION_SECRET` | 空 | 留空即自动生成并持久化;**它是凭据加密密钥,有数据后不要更换** | | `HAVLINE_SESSION_SECRET` | 空 | 留空即自动生成并持久化;**它是凭据加密密钥,有数据后不要更换** |
| `FONU_NGINX_HTTP_PORT` / `FONU_NGINX_HTTPS_PORT` | `80` / `443` | 反代监听端口(host 网络那份默认 18080/9443) | | `HAVLINE_NGINX_HTTP_PORT` / `HAVLINE_NGINX_HTTPS_PORT` | `80` / `443` | 反代监听端口(host 网络那份默认 18080/9443) |
| `FONU_UPDATER_SOCKET` | `/run/fonu-updater/updater.sock` | 一键升级 sidecar 的 Unix Socket | | `HAVLINE_UPDATER_SOCKET` | `/run/havline-updater/updater.sock` | 一键升级 sidecar 的 Unix Socket |
| `DOCKER_SOCK` | `/var/run/docker.sock` | 宿主机 Docker Socket;rootless Docker 或自定义路径时覆盖 | | `DOCKER_SOCK` | `/var/run/docker.sock` | 宿主机 Docker Socket;rootless Docker 或自定义路径时覆盖 |
| `TZ` | `Asia/Shanghai` | 通知与日志显示用的时区 | | `TZ` | `Asia/Shanghai` | 通知与日志显示用的时区 |
| `NODE_IMAGE` / `GO_IMAGE` / `RUNTIME_IMAGE` / `UPDATER_IMAGE` | 国内镜像源 | 构建用的基础镜像,被加速器拦截时用来覆盖 | | `NODE_IMAGE` / `GO_IMAGE` / `RUNTIME_IMAGE` / `UPDATER_IMAGE` | 国内镜像源 | 构建用的基础镜像,被加速器拦截时用来覆盖 |
@@ -184,20 +184,20 @@ FONU_VERSION=1.2.18 docker compose up -d
**构建报 `401 Unauthorized`(`resolve image config for docker-image://docker/dockerfile:1`)** **构建报 `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`。 国内加速器(如飞牛的 `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`** **`error while interpolating ... HAVLINE_SESSION_SECRET is missing a value`**
这是旧版 compose(把该变量写成必填)留下的行为。新版本是 `${FONU_SESSION_SECRET:-}`:留空即由应用自动生成。重新取一份编排文件即可。 这是旧版 compose(把该变量写成必填)留下的行为。新版本是 `${HAVLINE_SESSION_SECRET:-}`:留空即由应用自动生成。重新取一份编排文件即可。
**构建时 `fonu Pulling` 之后超时告警** **构建时 `havline Pulling` 之后超时告警**
有 `build:` 段时 compose 仍会先尝试拉同名镜像;`.hub.yml` 已加 `pull_policy: build` 跳过。其余两份可忽略这条警告。 有 `build:` 段时 compose 仍会先尝试拉同名镜像;`.hub.yml` 已加 `pull_policy: build` 跳过。其余两份可忽略这条警告。
**换目录后「数据没了」** **换目录后「数据没了」**
几乎没有真的丢:要么新目录是空的(迁移没做),要么项目名变了导致卷名变了。先用 `docker volume ls` 找到旧卷,按 §3.2 拷过去。 几乎没有真的丢:要么新目录是空的(迁移没做),要么项目名变了导致卷名变了。先用 `docker volume ls` 找到旧卷,按 §3.2 拷过去。
**面板能开但「今日请求/实时流量/性能」没数据** **面板能开但「今日请求/实时流量/性能」没数据**
这三张卡读的是**本机 Nginx 访问日志**(`<数据目录>/logs/access.log`),只有在 Fonu 里配了本机反代规则并产生访问后才有数据;VPS 侧的流量在「公网反代」页与「日志中心 → 公网 Nginx」。 这三张卡读的是**本机 Nginx 访问日志**(`<数据目录>/logs/access.log`),只有在 Havline 里配了本机反代规则并产生访问后才有数据;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`。 不想用容器时可以用 `scripts/build-linux.ps1` 打出 `havline-<版本>-linux-<arch>.tar.gz`,在目标机用 `sudo bash install.sh` 安装(装到 `/opt/havline`,数据在 `/var/lib/havline`,systemd 单元 `havline.service`)。两者的数据目录结构完全一致,因此**可以互相迁移**:把一方的数据目录内容整个拷到另一方即可,注意带上 `session_secret`。
+832
View File
@@ -0,0 +1,832 @@
# Havline 飞牛 fnOS 应用打包方案
> 日期:2026-10-01
> 依据:飞牛应用开放平台文档:<https://developer.fnnas.com/docs/guide/>
> 目标:将 Havline 从 Docker Compose / 裸机部署,改造为可安装、可升级、可卸载的飞牛 fnOS `.fpk` 应用。
---
## 一、官方文档结论
飞牛 fnOS 应用包的核心组成:
```text
manifest
config/privilege
config/resource
app/
cmd/
wizard/
ICON.PNG
ICON_256.PNG
```
官方提供三种常见形态:
1. **CGI 应用**
- 适合静态页面和轻量入口。
- 不支持 WebSocket,不适合 Havline 这种常驻服务。
2. **统一网关应用**
- 复用 fnOS 访问域名和登录态。
- 服务监听 Unix Socket,由 fnOS 网关转发。
- 支持 WebSocket,但 Havline 需要新增 Unix Socket 监听能力。
3. **Docker 应用**
- 使用 `app/docker/docker-compose.yaml`。
- 在 `config/resource` 中声明 `docker-project`。
- 最适合 Havline 当前形态。
**首选方案:飞牛 Docker 应用模板。**
原因:
- Havline 已经是完整 Docker 应用。
- 镜像内已包含 Nginx、Go 主程序和数据库运行环境。
- Cloudflare Tunnel、FRP、Agent、证书管理都依赖常驻进程。
- fnOS Docker 项目资源可以托管启动、停止和运行状态。
- 避免把 Havline 的 Go、Nginx、SQLite 和 shell 依赖重新拆进原生应用生命周期。
---
## 二、当前 Havline 的部署形态
### 2.1 当前主要组成
```text
havline
├── Go 主程序
├── 内嵌 Web 前端
├── 内置 Nginx
├── SQLite
├── FRP 客户端 / Agent 管理
├── Cloudflare Tunnel / cloudflared
├── ACME / DDNS / 通知
└── 可选 havline-updater sidecar
```
### 2.2 当前关键目录
```text
/data
├── havline.db
├── session_secret
├── nginx/
├── certs/
├── logs/
├── frp/
└── cloudflared/
```
### 2.3 当前端口
| 端口 | 用途 |
| --- | --- |
| `6893` | Havline 管理界面/API |
| `80` | 本机 Nginx HTTP |
| `443` | 本机 Nginx HTTPS |
飞牛 fnOS 通常已占用 `80/443`,因此 fnOS 包应默认使用:
```text
管理端口:6893
Nginx HTTP:18080
Nginx HTTPS:9443
```
端口应允许安装向导修改。
### 2.4 当前 updater 的限制
Havline 当前的一键升级依赖:
- Docker Socket
- updater sidecar
- 宿主机 Compose 项目目录
- 对宿主 Docker 执行 `compose build/up` 的权限
在 fnOS 应用包内继续保留这条链路会带来:
- 容器内 path mapping 复杂;
- fnOS 应用目录路径和宿主路径不一致;
- 应用中心升级与 Havline 内部升级冲突;
- 需要给应用额外 Docker Socket 权限。
**方案:fnOS 首发版本默认关闭 Havline 内置 updater,统一由飞牛应用中心升级。**
后续如果需要,可做“高级模式”单独启用。
---
## 三、目标包结构
建议在仓库新增:
```text
fnos/
├── manifest
├── ICON.PNG
├── ICON_256.PNG
├── config/
│ ├── privilege
│ └── resource
├── app/
│ ├── docker/
│ │ └── docker-compose.yaml
│ └── ui/
│ ├── config
│ └── images/
│ ├── icon_64.png
│ └── icon_256.png
├── cmd/
│ ├── main
│ ├── install_init
│ ├── install_callback
│ ├── upgrade_init
│ ├── upgrade_callback
│ ├── uninstall_init
│ ├── uninstall_callback
│ ├── config_init
│ └── config_callback
└── wizard/
├── install
├── upgrade
├── config
└── uninstall
```
构建脚本负责:
1. 复制 `fnos/` 模板到临时目录。
2. 用 `VERSION` 替换 `manifest.version`。
3. 生成对应的 `docker-compose.yaml`。
4. 调用 `fnpack build`。
5. 输出 `havline-<version>.fpk`。
---
## 四、Manifest 草案
```ini
appname=havline
version=1.9.0
display_name=Havline
desc=NAS 公网访问管理:反向代理、FRP、Cloudflare Tunnel、DDNS 与证书
source=thirdparty
platform=all
maintainer=Havline
maintainer_url=https://example.com
os_min_version=1.0.0
desktop_uidir=ui
desktop_applaunchname=havline.main
service_port=6893
checkport=true
ctl_stop=true
changelog=本次更新
```
字段说明:
- `appname=havline`:应用唯一标识。
- `platform=all`:包内不含架构二进制,实际架构由 multi-arch 镜像决定。
- `service_port=6893`:桌面入口默认访问端口。
- `ctl_stop=true`:应用中心显示启动、停止和运行状态。
- `desktop_applaunchname=havline.main`:应用卡片默认入口。
### 多架构要求
发布时必须提供:
```text
linux/amd64
linux/arm64
```
否则 `platform=all` 只在镜像本身支持目标架构时成立。
---
## 五、权限与资源
### 5.1 `config/privilege`
```json
{
"defaults": {
"run-as": "package"
},
"username": "havline",
"groupname": "havline"
}
```
原则:
- 长期运行的主程序使用包用户。
- 不默认给 Root。
- 不默认挂载 Docker Socket。
- 不默认加入 `docker` 用户组。
如果未来需要启用内部 updater,应单独评估 Docker Socket 的权限边界。
### 5.2 `config/resource`
```json
{
"docker-project": {
"projects": [
{
"name": "havline",
"path": "docker"
}
]
}
}
```
可选共享目录:
```json
{
"data-share": {
"shares": [
{
"name": "havline/backups"
}
]
}
}
```
建议首发版本只暴露备份目录。
不要暴露:
- `havline.db`
- `session_secret`
- `certs/`
- `nginx/`
- `cloudflared/`
- `frp/`
这些内容包含访问控制、证书和加密凭据。
---
## 六、Docker Compose 草案
建议新建:
```text
fnos/app/docker/docker-compose.yaml
```
内容:
```yaml
name: havline
services:
havline:
image: ghcr.io/example/havline:${TRIM_APPVER}
container_name: havline
restart: unless-stopped
ports:
- "${TRIM_SERVICE_PORT}:6893"
- "${wizard_nginx_http_port}:18080"
- "${wizard_nginx_https_port}:9443"
volumes:
- "${TRIM_PKGVAR}/data:/data"
environment:
HAVLINE_DATA_DIR: /data
HAVLINE_LISTEN: ":6893"
HAVLINE_NGINX_HTTP_PORT: "${wizard_nginx_http_port}"
HAVLINE_NGINX_HTTPS_PORT: "${wizard_nginx_https_port}"
HAVLINE_SESSION_SECRET: ""
TZ: "${wizard_timezone}"
HAVLINE_UPDATER_SOCKET: ""
```
说明:
- `TRIM_SERVICE_PORT` 默认映射管理界面。
- 使用 bridge 网络,避免与 fnOS 现有 host 网络服务直接冲突。
- Nginx 使用 18080/9443,避免占用 fnOS 的 80/443。
- 数据全部落在 `TRIM_PKGVAR/data`。
- 不挂载 Docker Socket。
- 不启动 updater sidecar。
### 为什么首发不直接用 host 网络
host 网络确实更贴近 Havline 裸机模式,但:
- fnOS 本身和其他应用可能占用大量端口;
- 应用中心无法明确管理端口冲突;
- bridge + 端口映射更容易解释和回滚。
首发建议 bridge。
后续可提供“高级安装模式:host 网络”。
---
## 七、桌面入口
`app/ui/config`:
```json
{
".url": {
"havline.main": {
"title": "Havline",
"icon": "images/icon_{0}.png",
"type": "iframe",
"protocol": "http",
"port": "${TRIM_SERVICE_PORT}",
"url": "/",
"allUsers": false,
"control": {
"accessPerm": "readonly"
}
}
}
}
```
考虑:
- Havline 当前是单管理员产品,默认只让管理员可见。
- 使用端口服务,不依赖 fnOS 网关。
- 后续若新增统一网关,可把入口改成 `gatewayPrefix/gatewaySocket`,无需端口。
---
## 八、生命周期脚本
### `cmd/main`
Docker 项目的启停由 fnOS Docker 资源处理,`main` 主要提供状态检查。
```bash
#!/bin/bash
CONTAINER_NAME="havline"
is_running() {
docker inspect "$CONTAINER_NAME" 2>/dev/null | grep -q '"Status": "running"'
}
case "$1" in
start|stop)
exit 0
;;
status)
if is_running; then
exit 0
fi
exit 3
;;
*)
echo "Unknown command: $1" > "$TRIM_TEMP_LOGFILE"
exit 1
;;
esac
```
### `cmd/install_callback`
职责:
- 创建 `TRIM_PKGVAR/data`
- 修正目录权限
- 如果用户选择了迁移,执行数据复制
- 输出安装摘要
### `cmd/upgrade_callback`
职责:
- 检查数据库可读写
- 保留已有的 `session_secret`
- 记录升级前后版本
- 可选:导入旧版本迁移文件
### `cmd/config_callback`
职责:
- 配置端口或时区变化时,验证新端口
- 如果只是环境变量变化,尽量交给 Docker Compose 重建
- 输出清晰的失败原因到 `TRIM_TEMP_LOGFILE`
### `cmd/uninstall_callback`
默认行为:
- 删除容器和程序
- 保留应用数据
只有用户在卸载向导中明确选择“删除全部数据”时,才删除:
```text
${TRIM_PKGVAR}/data
```
---
## 九、安装向导
### `wizard/install`
建议字段:
```json
[
{
"stepTitle": "Havline 访问端口",
"items": [
{
"type": "text",
"field": "wizard_nginx_http_port",
"label": "Nginx HTTP 端口",
"initValue": "18080",
"rules": [
{
"required": true,
"message": "请输入端口"
},
{
"pattern": "^[0-9]+$",
"message": "端口必须是数字"
}
]
},
{
"type": "text",
"field": "wizard_nginx_https_port",
"label": "Nginx HTTPS 端口",
"initValue": "9443",
"rules": [
{
"required": true,
"message": "请输入端口"
},
{
"pattern": "^[0-9]+$",
"message": "端口必须是数字"
}
]
}
]
},
{
"stepTitle": "基础设置",
"items": [
{
"type": "select",
"field": "wizard_timezone",
"label": "时区",
"initValue": "Asia/Shanghai",
"options": [
{
"label": "Asia/Shanghai",
"value": "Asia/Shanghai"
},
{
"label": "UTC",
"value": "UTC"
}
]
}
]
}
]
```
### 管理员初始化
当前 Havline 首次启动会自动生成随机管理员密码并写入日志。
对于 fnOS 应用,建议改造为:
1. 安装向导收集初始管理员密码;或
2. 首次打开 Havline 时进入创建管理员页面;或
3. 读取 `HAVLINE_INITIAL_ADMIN_PASSWORD` 进行一次性初始化。
**不建议继续把管理员密码写入日志。**
---
## 十、环境变量映射
| fnOS 变量 | 映射到 Havline | 说明 |
| --- | --- | --- |
| `TRIM_APPVER` | 镜像 tag | 应用版本 |
| `TRIM_SERVICE_PORT` | 6893 映射 | 桌面入口 |
| `TRIM_PKGVAR` | `/data` | 持久化数据 |
| `TRIM_APPDEST` | 应用 target | 只读程序目录 |
| `wizard_nginx_http_port` | `HAVLINE_NGINX_HTTP_PORT` | Nginx HTTP |
| `wizard_nginx_https_port` | `HAVLINE_NGINX_HTTPS_PORT` | Nginx HTTPS |
| `wizard_timezone` | `TZ` | 时区 |
Havline 数据目录内应继续保留:
```text
${TRIM_PKGVAR}/data/session_secret
```
不要每次安装或升级重新生成。
---
## 十一、更新策略
### 首发方案
统一使用飞牛应用中心升级:
- 更新 `.fpk`
- 拉取新 multi-arch 镜像
- 保留 `${TRIM_PKGVAR}/data`
- 重启 Docker Compose 项目
### 禁用 Havline 自带 updater
fnOS 包内:
```text
HAVLINE_UPDATER_SOCKET=""
```
并且 compose 不启动:
```text
havline-updater
```
原因:
- App Center 已经是唯一更新入口。
- 避免 Docker Socket 权限。
- 避免应用目录映射错误。
- 避免升级 Recursive / 自更新冲突。
### 后续可选
如果确实需要应用内更新,再增加“高级更新模式”:
- 显式让用户开启;
- 显式挂载 Docker Socket;
- 显式配置宿主项目路径;
- 失败时回滚到上一版镜像。
---
## 十二、数据迁移方案
### 从 Docker Compose 迁移
旧环境可能有:
```text
/opt/havline/data
```
或 Docker 命名卷:
```text
havline-data
```
迁移步骤:
1. 停止旧 Havline。
2. 备份旧数据目录。
3. 将旧目录复制到:
```text
${TRIM_PKGVAR}/data
```
4. 确认 `session_secret` 和 `havline.db` 都存在。
5. 启动 fnOS 应用。
6. 登录并验证:
- 用户与设置
- 反向代理规则
- FRP 服务端
- Cloudflare 隧道
- 证书
- 备份列表
### 从裸机迁移
裸机默认数据目录通常是:
```text
./.data
```
迁移内容一致,重点是保持 `session_secret` 不变。
否则:
- 已加密的 API Token
- DDNS 凭据
- 通知凭据
- Cloudflare 凭据
可能无法解密。
---
## 十三、安全设计
### 默认安全基线
- `run-as=package`
- 不启用 Root
- 不挂载 Docker Socket
- 不暴露证书、数据库和加密密钥目录
- 数据目录仅应用用户可读写
- 仅暴露管理端口、Nginx 高位的 HTTP/HTTPS 端口
- 管理员入口默认只对 fnOS 管理员可见
### 首次管理员密码
必须改造当前“随机密码写日志”行为。
推荐优先级:
1. 首次进入创建管理员页面
2. 安装向导收集密码
3. 环境变量一次性初始化
### Cloudflare Token
仍由 Havline 独立加密保存:
```text
${TRIM_PKGVAR}/data/session_secret
```
不放入 fnOS 共享目录。
---
## 十四、构建与发布流程
### 1. 构建 multi-arch 镜像
```bash
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg VERSION_FILE=VERSION \
-t registry.example.com/havline:${VERSION} \
--push .
```
### 2. 生成 fnOS 项目
从以下模板生成:
```text
fnos/
```
替换:
- `manifest.version`
- Compose 镜像 tag
- 应用说明
- `changelog`
### 3. 打包
```bash
fnpack build --directory dist/fnos/havline
```
### 4. 测试设备安装
```bash
appcenter-cli install-fpk havline-${VERSION}.fpk --env config.env
```
### 5. 发布前验证
在至少一台 x86 和一台 ARM 测试设备验证:
- 安装
- 启动
- 桌面入口
- 端口映射
- 数据持久化
- 应用中心升级
- 卸载保留数据
- 卸载删除数据
---
## 十五、分阶段实施
### 阶段 1:可用包
- 新增 `fnos/` 目录
- 编写 manifest
- Docker Compose 模板
- 安装/卸载/状态生命周期
- 管理入口
- 端口向导
- 使用固定镜像 tag
目标:可以在飞牛测试设备安装并打开 Havline。
### 阶段 2:数据与升级
- `TRIM_PKGVAR` 持久化
- 旧 Docker Compose 数据迁移脚本
- 应用中心升级验证
- 保留/删除数据选项
- `session_secret` 兼容检查
目标:数据不丢,可稳定升级。
### 阶段 3:fnOS 体验
- 首次管理员初始化页面
- 端口冲突检测
- 应用入口图标完善
- 备份目录共享
- 时区和系统信息适配
目标:符合飞牛应用中心体验。
### 阶段 4:高级集成
- 可选统一网关
- 可选 Docker Socket updater
- fnOS 开放 API 集成
- 应用中心审核材料
- ARM 实机完整验证
---
## 十六、风险与处理
| 风险 | 影响 | 处理 |
| --- | --- | --- |
| `80/443` 被 fnOS 占用 | Havline Nginx 无法启动 | 默认改用 18080/9443,向导可改 |
| 镜像只有 amd64 | ARM 设备无法运行 | CI 构建 amd64 + arm64 |
| fnOS 应用升级与内置 updater 冲突 | 版本回退或容器失控 | 首发禁用内置 updater |
| `session_secret` 丢失 | 加密凭据无法解密 | 持久化到 `TRIM_PKGVAR`,迁移时必须保留 |
| Docker Socket 权限过大 | 安全风险 | 默认不挂载 |
| 数据目录权限错误 | 数据库无法写入 | `install_callback` 修正权限 |
| 管理员密码写日志 | 凭据泄露 | 安装向导或首次设置页 |
| 端口冲突 | 应用无法启动 | 向导配置,安装时检测 |
| ARM 镜像无 cloudflared | Cloudflare 隧道不可用 | 镜像内校验架构,或运行时下载对应 binary |
---
## 十七、官方文档参考
- 欢迎加入:<https://developer.fnnas.com/docs/guide/>
- 快速开始:
- <https://developer.fnnas.com/docs/quick-started/prerequisites/>
- <https://developer.fnnas.com/docs/quick-started/create-application/>
- <https://developer.fnnas.com/docs/quick-started/test-application/>
- <https://developer.fnnas.com/docs/quick-started/publish-application/>
- 核心概念:
- <https://developer.fnnas.com/docs/core-concepts/manifest/>
- <https://developer.fnnas.com/docs/core-concepts/privilege/>
- <https://developer.fnnas.com/docs/core-concepts/resource/>
- <https://developer.fnnas.com/docs/core-concepts/runtime/>
- <https://developer.fnnas.com/docs/core-concepts/environment-variables/>
- <https://developer.fnnas.com/docs/core-concepts/app-entry/>
- <https://developer.fnnas.com/docs/core-concepts/gateway-registration/>
- <https://developer.fnnas.com/docs/core-concepts/wizard/>
- 工具:
- <https://developer.fnnas.com/docs/cli/fnpack/>
- <https://developer.fnnas.com/docs/cli/appcentercli/>
- Docker 案例:
- <https://developer.fnnas.com/docs/examples/docker/>
---
## 十八、结论
Havline 适合做飞牛 Docker 应用,不适合首发做成 CGI 或原生应用。
首发目标:
```text
飞牛应用中心安装
→ Docker Compose 启动 Havline
→ 桌面入口打开管理界面
→ 数据持久化到 TRIM_PKGVAR
→ 应用中心升级
```
最需要先解决的代码问题:
1. 管理员初始化不再写日志。
2. 默认 updater 改为可关闭,fnOS 包中关闭。
3. 增加 multi-arch 镜像发布。
4. 增加 `fnos/` 打包模板和构建脚本。
5. 验证 `TRIM_PKGVAR/data` 数据迁移。
+2 -2
View File
@@ -83,7 +83,7 @@ PageHeader「公网服务端」(原「公网 Agent」)
│ 操作列:启动·停止 · 查看 · 日志 · 测速 · 校验 · 编辑 · 删除 │ 操作列:启动·停止 · 查看 · 日志 · 测速 · 校验 · 编辑 · 删除
│ (去掉「Agent 管理」——它就在本页;行点击即选中) │ (去掉「Agent 管理」——它就在本页;行点击即选中)
├─ 选中服务端的详情(现有 AgentView 分区原样搬过来) ├─ 选中服务端的详情(现有 AgentView 分区原样搬过来)
│ 环境与依赖(Fonu Agent / Nginx / frps 运行时 / VPS 资源 / VPS 证书) │ 环境与依赖(Havline Agent / Nginx / frps 运行时 / VPS 资源 / VPS 证书)
│ frps 控制 + Agent 生命周期(SSH) │ frps 控制 + Agent 生命周期(SSH)
│ 输出与日志 │ 输出与日志
└─ 弹窗:服务端表单(新增/编辑)、服务端日志、服务端详情、frps 同源配置、导出、FRP 应用配置 └─ 弹窗:服务端表单(新增/编辑)、服务端日志、服务端详情、frps 同源配置、导出、FRP 应用配置
@@ -147,7 +147,7 @@ PageHeader「公网服务端」(原「公网 Agent」)
- 服务端表单里的「打开管理面板」改为「查看服务端详情」(关弹窗 + 页内选中);详情弹窗「客户端」一行所需的 `runtime` 由新增的 `loadRuntimeVersion()` 拉取。 - 服务端表单里的「打开管理面板」改为「查看服务端详情」(关弹窗 + 页内选中);详情弹窗「客户端」一行所需的 `runtime` 由新增的 `loadRuntimeVersion()` 拉取。
- 详情弹窗「代理」表依赖的 `proxyPublicEndpoint` 在 `AgentView` 本地补了一份(原属规则侧);表单里的「下发 frps 配置」复用 `AgentView` 既有的 `run('push-config', …)` 版本,输出进「输出与日志」。 - 详情弹窗「代理」表依赖的 `proxyPublicEndpoint` 在 `AgentView` 本地补了一份(原属规则侧);表单里的「下发 frps 配置」复用 `AgentView` 既有的 `run('push-config', …)` 版本,输出进「输出与日志」。
- **未迁移**:`/frp` 的「查看脱敏配置」弹窗(`showExport`)留在原页——触发按钮在「穿透规则」卡片头部,导出物是脱敏 frpc.toml,属客户端 / 规则视角。这是按 §4.3 自己的判据(弹窗依赖服务端还是规则)做的取舍,与 §4.3 表格里「导出脱敏配置 → 迁走」一行不同。 - **未迁移**:`/frp` 的「查看脱敏配置」弹窗(`showExport`)留在原页——触发按钮在「穿透规则」卡片头部,导出物是脱敏 frpc.toml,属客户端 / 规则视角。这是按 §4.3 自己的判据(弹窗依赖服务端还是规则)做的取舍,与 §4.3 表格里「导出脱敏配置 → 迁走」一行不同。
- 验证:`vue-tsc --noEmit` 0 错误、`vite build` 通过、`FrpView.vue` 内旧标识符 grep 为 0、全仓无 0 字节源文件、`bin\fonu.exe` 已重建。 - 验证:`vue-tsc --noEmit` 0 错误、`vite build` 通过、`FrpView.vue` 内旧标识符 grep 为 0、全仓无 0 字节源文件、`bin\havline.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` 仍被规则表单弹窗使用,不能删。 - **踩坑记录(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*`)。 - **布局调整(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` 高亮)。 - 原 8 列 `n-data-table` 换成紧凑列表项:名称 + 启动状态标签 + `主机:端口` + 延迟 / 位置 + TLS / Token / Agent / SSH 标签;行点击即选中(`--active` 高亮)。
+6 -6
View File
@@ -3,7 +3,7 @@
> 基于 frp v0.71.0 官方 `frps_full_example.toml` 全量对照。 > 基于 frp v0.71.0 官方 `frps_full_example.toml` 全量对照。
> 状态图例:✅ 已实现 🔶 部分实现(有缺口或需手动步骤) ⬜ 未实现 🚫 有意不实现(有说明理由) > 状态图例:✅ 已实现 🔶 部分实现(有缺口或需手动步骤) ⬜ 未实现 🚫 有意不实现(有说明理由)
> >
> 「Fonu 入口」= 用户在 Fonu 界面操作的位置;「生成器」= `internal/frp/frps_template.go` 的 frps.toml 同源生成器。 > 「Havline 入口」= 用户在 Havline 界面操作的位置;「生成器」= `internal/frp/frps_template.go` 的 frps.toml 同源生成器。
> 更新日期:2026-09-21 > 更新日期:2026-09-21
--- ---
@@ -44,7 +44,7 @@
| 配置项 | 官方默认 | 状态 | 说明 | | 配置项 | 官方默认 | 状态 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `webServer.addr / port` | `127.0.0.1:0` | ✅ | 表单「frps 管理接口」地址/端口;Fonu 借此拉流量;「frps 管理」按钮直达面板 | | `webServer.addr / port` | `127.0.0.1:0` | ✅ | 表单「frps 管理接口」地址/端口;Havline 借此拉流量;「frps 管理」按钮直达面板 |
| `webServer.user / password` | — | ✅ | 表单同源;密码加密存储(ConfiguredSecretField 掩码闭环) | | `webServer.user / password` | — | ✅ | 表单同源;密码加密存储(ConfiguredSecretField 掩码闭环) |
| `webServer.tls.certFile / keyFile` | — | ⬜ | dashboard 自身 HTTPS 未提供 | | `webServer.tls.certFile / keyFile` | — | ⬜ | dashboard 自身 HTTPS 未提供 |
@@ -87,10 +87,10 @@
| 配置项 | 官方默认 | 状态 | 说明 | | 配置项 | 官方默认 | 状态 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 多用户 `user` 前缀隔离 | — | ⬜ | 未提供(frps 端 `user` 是 frpc 登录名前缀隔离,与 Fonu 的服务端 user 概念不同) | | 多用户 `user` 前缀隔离 | — | ⬜ | 未提供(frps 端 `user` 是 frpc 登录名前缀隔离,与 Havline 的服务端 user 概念不同) |
| `udpPacketSize` | 1500 | ⬜ | 未提供(需与 frpc 同源,frpc 侧也无此表单项) | | `udpPacketSize` | 1500 | ⬜ | 未提供(需与 frpc 同源,frpc 侧也无此表单项) |
## 十、Fonu Agent 侧的 frps 运维能力(与配置生成配套) ## 十、Havline Agent 侧的 frps 运维能力(与配置生成配套)
| 功能 | 状态 | 说明 | | 功能 | 状态 | 说明 |
| --- | --- | --- | | --- | --- | --- |
@@ -116,6 +116,6 @@
## 附:架构口径 ## 附:架构口径
- **frps.toml 是生成器产物,不是用户手写文件**:所有已实现项均以 Fonu 服务端表单为唯一事实源,VPS 侧禁止手改(改了会被下次「下发 frps 配置」覆盖); - **frps.toml 是生成器产物,不是用户手写文件**:所有已实现项均以 Havline 服务端表单为唯一事实源,VPS 侧禁止手改(改了会被下次「下发 frps 配置」覆盖);
- **端口同源链**:表单 `vhost_http_port/vhost_https_port` → frps.toml `vhostHTTPPort/vhostHTTPSPort` → Nginx `proxy_pass 127.0.0.1:<port>`,三方由 `agentUpstreamFor`/生成器共用同一值; - **端口同源链**:表单 `vhost_http_port/vhost_https_port` → frps.toml `vhostHTTPPort/vhostHTTPSPort` → Nginx `proxy_pass 127.0.0.1:<port>`,三方由 `agentUpstreamFor`/生成器共用同一值;
- **agent 与 frps 解耦**:agent 只管 Nginx 片段/证书/frps 生命周期,frps 进程由 systemd 托管;二者通过 Fonu 下发配置保持一致。 - **agent 与 frps 解耦**:agent 只管 Nginx 片段/证书/frps 生命周期,frps 进程由 systemd 托管;二者通过 Havline 下发配置保持一致。
+52 -52
View File
@@ -1,19 +1,19 @@
# FRPS 服务端管理方案(fonu-agent,部署于公网服务器) # FRPS 服务端管理方案(havline-agent,部署于公网服务器)
> 需求:frpc 端(Fonu/NAS)提交穿透规则后,**公网服务器端自动完成反向代理设置**,实现域名无端口访问(`http://tv.36800.cc`),并自动管理服务器侧证书与 frps。 > 需求:frpc 端(Havline/NAS)提交穿透规则后,**公网服务器端自动完成反向代理设置**,实现域名无端口访问(`http://tv.36800.cc`),并自动管理服务器侧证书与 frps。
> 实现形态:在公网 VPS 上部署一个常驻管理组件 **fonu-agent**(单个 Go 二进制 + systemd),对外提供管理 API;Fonu 在规则变化时调用该 API,agent 在 VPS 本地完成 Nginx 反代配置、frps 管理与证书部署。 > 实现形态:在公网 VPS 上部署一个常驻管理组件 **havline-agent**(单个 Go 二进制 + systemd),对外提供管理 API;Havline 在规则变化时调用该 API,agent 在 VPS 本地完成 Nginx 反代配置、frps 管理与证书部署。
> **安装与管理通道**:agent 的**首次安装、升级、卸载通过 SSH 由 Fonu 远程完成**(用户在 Fonu 界面填 SSH 信息点按钮即可,无需手工登 VPS);装好后 agent 以 **API** 接受日常管理。管理面板全部在本地 Fonu,VPS 上没有任何面板。 > **安装与管理通道**:agent 的**首次安装、升级、卸载通过 SSH 由 Havline 远程完成**(用户在 Havline 界面填 SSH 信息点按钮即可,无需手工登 VPS);装好后 agent 以 **API** 接受日常管理。管理面板全部在本地 Havline,VPS 上没有任何面板。
> 修订记录:v2 混合形态(SSH 装/卸 + API 管理);v1 为纯 SSH 推送方案,v2 初稿为「agent 纯 API」方案,两版历史见文末对照。 > 修订记录:v2 混合形态(SSH 装/卸 + API 管理);v1 为纯 SSH 推送方案,v2 初稿为「agent 纯 API」方案,两版历史见文末对照。
## 1. 目标与效果 ## 1. 目标与效果
| 诉求 | 达成方式 | | 诉求 | 达成方式 |
| --- | --- | | --- | --- |
| frpc 提交规则后 frps 端自动反代 | Fonu 保存规则 → 调 agent API 注册域名路由 → agent 写 Nginx vhost 并 reload | | frpc 提交规则后 frps 端自动反代 | Havline 保存规则 → 调 agent API 注册域名路由 → agent 写 Nginx vhost 并 reload |
| 无端口访问 | agent 的 Nginx 监听 80(443),`proxy_pass` 到 frps vhost 端口,URL 不带端口号 | | 无端口访问 | agent 的 Nginx 监听 80(443),`proxy_pass` 到 frps vhost 端口,URL 不带端口号 |
| 管理证书 | Fonu 证书页(DNS-01)签发后一键推送到 agent,自动挂 443 并续期重推 | | 管理证书 | Havline 证书页(DNS-01)签发后一键推送到 agent,自动挂 443 并续期重推 |
| frps 管理 | 配置下发(vhost 端口等)、进程重启/状态/版本、端口占用诊断,全部 API 化 | | frps 管理 | 配置下发(vhost 端口等)、进程重启/状态/版本、端口占用诊断,全部 API 化 |
| VPS 上已有 Nginx/站点 | 共存:agent 只写自己的 vhost 文件(`conf.d/fonu-*.conf`),不动其他配置 | | VPS 上已有 Nginx/站点 | 共存:agent 只写自己的 vhost 文件(`conf.d/havline-*.conf`),不动其他配置 |
最终链路(http 规则为例): 最终链路(http 规则为例):
@@ -26,46 +26,46 @@
## 2. 总体架构 ## 2. 总体架构
```text ```text
Fonu (NAS,管理面板所在地) 公网服务器 (VPS) Havline (NAS,管理面板所在地) 公网服务器 (VPS)
┌──────────────────────────────┐ ┌────────────────────────────────┐ ┌──────────────────────────────┐ ┌────────────────────────────────┐
│ FRP 模块(现有) │ │ frps :7000 控制 / 8080 vhost │ │ FRP 模块(现有) │ │ frps :7000 控制 / 8080 vhost │
│ 规则 CRUD / frpc / frps.toml │ │ nginx :80/443 │ │ 规则 CRUD / frpc / frps.toml │ │ nginx :80/443 │
│ 证书页(lego DNS-01,已有) │ │ └─ fonu-*.conf(agent 产物) │ │ 证书页(lego DNS-01,已有) │ │ └─ havline-*.conf(agent 产物) │
│ ┌────────────────────┐ │ │ fonu-agent :7700(常驻) │ │ ┌────────────────────┐ │ │ havline-agent :7700(常驻) │
│ │ SSH 安装器(新增) │──SSH──┼→ 首次安装/升级/卸载 agent │ │ │ SSH 安装器(新增) │──SSH──┼→ 首次安装/升级/卸载 agent │
│ │ agent 客户端(新增) │──API──┼→ 日常管理:路由/配置/证书/状态 │ │ │ agent 客户端(新增) │──API──┼→ 日常管理:路由/配置/证书/状态 │
│ └────────────────────┘ │ │ ├─ 路由管理(nginx vhost 编排) │ │ └────────────────────┘ │ │ ├─ 路由管理(nginx vhost 编排) │
│ 管理面板 = Fonu 网页 │ │ ├─ frps 管理(配置/进程/诊断) │ │ 管理面板 = Havline 网页 │ │ ├─ frps 管理(配置/进程/诊断) │
│ (规则页/证书页/管理弹窗) │ │ └─ 证书接收与挂载 │ │ (规则页/证书页/管理弹窗) │ │ └─ 证书接收与挂载 │
└──────────────────────────────┘ └────────────────────────────────┘ └──────────────────────────────┘ └────────────────────────────────┘
``` ```
两条通道各司其职: 两条通道各司其职:
- **SSH(低频)**:仅用于 agent 生命周期——安装、升级、卸载、agent 失联自救。用户在 Fonu 填一次 SSH 信息(可选保存备用); - **SSH(低频)**:仅用于 agent 生命周期——安装、升级、卸载、agent 失联自救。用户在 Havline 填一次 SSH 信息(可选保存备用);
- **API(高频)**:日常全部管理——规则同步、frps 配置下发、证书推送、状态查询。面板 100% 在 Fonu 本地,VPS 上没有任何管理界面。 - **API(高频)**:日常全部管理——规则同步、frps 配置下发、证书推送、状态查询。面板 100% 在 Havline 本地,VPS 上没有任何管理界面。
职责划分:Fonu 是**控制面**(用户在哪配置就存哪),agent 是**执行面**(只接收指令并落实),frps/Nginx 是**数据面**。agent 无状态(配置即文件),可随时重装。 职责划分:Havline 是**控制面**(用户在哪配置就存哪),agent 是**执行面**(只接收指令并落实),frps/Nginx 是**数据面**。agent 无状态(配置即文件),可随时重装。
## 3. fonu-agent 设计(VPS 侧) ## 3. havline-agent 设计(VPS 侧)
单个 Go 二进制(复用主项目代码编译,`cmd/fonu-agent`),systemd 常驻,管理 API 监听 `:7700`(可配置)。 单个 Go 二进制(复用主项目代码编译,`cmd/havline-agent`),systemd 常驻,管理 API 监听 `:7700`(可配置)。
### 3.1 管理 API(Bearer Token 鉴权) ### 3.1 管理 API(Bearer Token 鉴权)
| 方法/路径 | 作用 | | 方法/路径 | 作用 |
| --- | --- | | --- | --- |
| `GET /api/v1/ping` | 连通性/鉴权校验(Fonu「测试连接」按钮) | | `GET /api/v1/ping` | 连通性/鉴权校验(Havline「测试连接」按钮) |
| `GET /api/v1/status` | frps 版本/运行方式/配置路径、80/443/7000/8080 端口占用者、已注册路由、证书有效期 | | `GET /api/v1/status` | frps 版本/运行方式/配置路径、80/443/7000/8080 端口占用者、已注册路由、证书有效期 |
| `PUT /api/v1/routes` | 注册/更新域名路由:`{domain, upstream(=frps vhost 端口), tls, cert_id}` → 写 Nginx vhost → `nginx -t` → reload(失败自动回滚文件) | | `PUT /api/v1/routes` | 注册/更新域名路由:`{domain, upstream(=frps vhost 端口), tls, cert_id}` → 写 Nginx vhost → `nginx -t` → reload(失败自动回滚文件) |
| `DELETE /api/v1/routes/{domain}` | 注销路由(规则删除/停用时调用) | | `DELETE /api/v1/routes/{domain}` | 注销路由(规则删除/停用时调用) |
| `GET /api/v1/routes` | 当前路由清单(对账用) | | `GET /api/v1/routes` | 当前路由清单(对账用) |
| `PUT /api/v1/frps/config` | 下发完整 frps.toml(Fonu 同源生成)→ 备份旧文件 → 按运行方式重启/重载 → 回读验证 | | `PUT /api/v1/frps/config` | 下发完整 frps.toml(Havline 同源生成)→ 备份旧文件 → 按运行方式重启/重载 → 回读验证 |
| `POST /api/v1/certs` | 部署证书:`{domain, fullchain_pem, key_pem}` → 写 `/var/lib/fonu-agent/certs/<domain>/`(0600)→ 更新 443 vhost → reload | | `POST /api/v1/certs` | 部署证书:`{domain, fullchain_pem, key_pem}` → 写 `/var/lib/havline-agent/certs/<domain>/`(0600)→ 更新 443 vhost → reload |
### 3.2 Nginx 编排(反代核心) ### 3.2 Nginx 编排(反代核心)
- 每个域名一个文件:`/etc/nginx/conf.d/fonu-tv.36800.cc.conf`,模板: - 每个域名一个文件:`/etc/nginx/conf.d/havline-tv.36800.cc.conf`,模板:
```nginx ```nginx
server { server {
@@ -82,34 +82,34 @@ server {
# 启用证书后追加 443 server 块(ssl_certificate 指向 agent 证书目录)+ 80 跳转 # 启用证书后追加 443 server 块(ssl_certificate 指向 agent 证书目录)+ 80 跳转
``` ```
- 写入流程:写 `.tmp` → `nginx -t` 校验 → 通过则替换并 `nginx -s reload`,失败保留原文件并把 `nginx -t` 输出回传给 Fonu 显示; - 写入流程:写 `.tmp` → `nginx -t` 校验 → 通过则替换并 `nginx -s reload`,失败保留原文件并把 `nginx -t` 输出回传给 Havline 显示;
- **只增删自己的 `fonu-*.conf`**,绝不触碰其他配置 → 与宝塔/手工站点共存; - **只增删自己的 `havline-*.conf`**,绝不触碰其他配置 → 与宝塔/手工站点共存;
- 首次运行自检:nginx 是否安装、`conf.d`/`sites-enabled` 目录探测、80/443 监听者识别(nginx? frps? 其他?)。 - 首次运行自检:nginx 是否安装、`conf.d`/`sites-enabled` 目录探测、80/443 监听者识别(nginx? frps? 其他?)。
### 3.3 frps 管理 ### 3.3 frps 管理
- **配置下发**:Fonu「frps.toml 同源生成」已有(含 vhost 端口、auth、webServer),改为推送给 agent 落盘:备份旧文件 → 原子替换 → 按运行方式生效(systemd 单元 `systemctl restart frps`;nohup 场景 `pkill frps` 后按原命令拉起,agent 从状态接口学习原启动命令); - **配置下发**:Havline「frps.toml 同源生成」已有(含 vhost 端口、auth、webServer),改为推送给 agent 落盘:备份旧文件 → 原子替换 → 按运行方式生效(systemd 单元 `systemctl restart frps`;nohup 场景 `pkill frps` 后按原命令拉起,agent 从状态接口学习原启动命令);
- **诊断**:`status` 聚合 frps 版本/存活/配置路径 + `ss -tlnp` 端口占用判定——把之前手工排查(`port unavailable`、80 被占)变成 API 一次调用; - **诊断**:`status` 聚合 frps 版本/存活/配置路径 + `ss -tlnp` 端口占用判定——把之前手工排查(`port unavailable`、80 被占)变成 API 一次调用;
- **边界**:不安装/升级 frps 二进制(检测报告提示版本即可)。 - **边界**:不安装/升级 frps 二进制(检测报告提示版本即可)。
### 3.4 证书 ### 3.4 证书
- 签发仍在 **Fonu 证书页**(lego DNS-01,复用 DDNS 凭证,NAS 侧完成,VPS 无需参与); - 签发仍在 **Havline 证书页**(lego DNS-01,复用 DDNS 凭证,NAS 侧完成,VPS 无需参与);
- 部署:证书页「部署到服务器」→ `POST /certs` 推送 PEM → agent 落盘并生成 443 vhost → reload; - 部署:证书页「部署到服务器」→ `POST /certs` 推送 PEM → agent 落盘并生成 443 vhost → reload;
- 续期:Fonu 定时任务续期成功后自动重推,VPS 侧无感; - 续期:Havline 定时任务续期成功后自动重推,VPS 侧无感;
- (可选演进)agent 本地 ACME HTTP-01:agent 已在 80 上,可自助签发,作为无 DNS 凭证场景的备选,本期不做。 - (可选演进)agent 本地 ACME HTTP-01:agent 已在 80 上,可自助签发,作为无 DNS 凭证场景的备选,本期不做。
## 4. 通信协议与安全 ## 4. 通信协议与安全
| 项 | 设计 | | 项 | 设计 |
| --- | --- | | --- | --- |
| 鉴权 | Bearer Token(Fonu 服务端表单生成/粘贴,agent 配置文件持有;两端一致即可用),`7700` 建议用防火墙限制仅 NAS IP 访问 | | 鉴权 | Bearer Token(Havline 服务端表单生成/粘贴,agent 配置文件持有;两端一致即可用),`7700` 建议用防火墙限制仅 NAS IP 访问 |
| 传输 | 明文 HTTP 起步可接受(Token + 内网限制);增强路径:agent 用 frps 证书或自签证书提供 HTTPS 管理口 | | 传输 | 明文 HTTP 起步可接受(Token + 内网限制);增强路径:agent 用 frps 证书或自签证书提供 HTTPS 管理口 |
| 命令安全 | agent 只执行固定动作(写文件/nginx -t/reload/重启 frps),**不执行 Fonu 下发的任意命令**;域名/路径参数做白名单校验(域名格式、路径锁定 `conf.d/fonu-*` 与 `/var/lib/fonu-agent/`) | | 命令安全 | agent 只执行固定动作(写文件/nginx -t/reload/重启 frps),**不执行 Havline 下发的任意命令**;域名/路径参数做白名单校验(域名格式、路径锁定 `conf.d/havline-*` 与 `/var/lib/havline-agent/`) |
| 幂等 | 路由/配置/证书接口全部幂等,重复调用无副作用;`routes` 对账接口支持 Fonu 定时校准 | | 幂等 | 路由/配置/证书接口全部幂等,重复调用无副作用;`routes` 对账接口支持 Havline 定时校准 |
| 审计 | agent 每个写操作记本地日志(who/when/what),Fonu 侧同步状态入系统日志 | | 审计 | agent 每个写操作记本地日志(who/when/what),Havline 侧同步状态入系统日志 |
## 5. Fonu 侧改动(frpc 端) ## 5. Havline 侧改动(frpc 端)
| 模块 | 内容 | | 模块 | 内容 |
| --- | --- | | --- | --- |
@@ -120,15 +120,15 @@ server {
| 管理弹窗 | 服务端「管理」按钮:status 报告(frps 状态/端口占用者/路由清单/证书有效期)+ 一键操作(下发配置/全量对账) | | 管理弹窗 | 服务端「管理」按钮:status 报告(frps 状态/端口占用者/路由清单/证书有效期)+ 一键操作(下发配置/全量对账) |
| API 客户端 | `internal/frp/agent_client.go`:Go HTTP 客户端封装上述 7 个接口(超时/重试/错误透传) | | API 客户端 | `internal/frp/agent_client.go`:Go HTTP 客户端封装上述 7 个接口(超时/重试/错误透传) |
## 6. agent 部署与生命周期管理(SSH,全程在 Fonu 界面完成) ## 6. agent 部署与生命周期管理(SSH,全程在 Havline 界面完成)
### 6.1 首次安装(用户不登 VPS) ### 6.1 首次安装(用户不登 VPS)
1. Fonu 服务端表单「远程管理」分组填 SSH 信息(地址/端口/用户/密码或私钥,可选保存)+ 点「安装 agent」; 1. Havline 服务端表单「远程管理」分组填 SSH 信息(地址/端口/用户/密码或私钥,可选保存)+ 点「安装 agent」;
2. Fonu 经单次 SSH 会话自动完成: 2. Havline 经单次 SSH 会话自动完成:
- 探测环境:`uname -m`(选二进制)、nginx 是否安装、`systemd` 可用性、端口占用快照; - 探测环境:`uname -m`(选二进制)、nginx 是否安装、`systemd` 可用性、端口占用快照;
- SFTP 上传 `fonu-agent` 二进制与 `install.sh`(从 Fonu 自带资源解出对应架构); - SFTP 上传 `havline-agent` 二进制与 `install.sh`(从 Havline 自带资源解出对应架构);
- 远程执行 `install.sh`:落位 `/usr/local/bin/fonu-agent`、写 `/etc/fonu-agent/agent.toml`(**生成随机 Token 并回传给 Fonu**,无需用户抄写)、注册 `systemd` 单元并启动; - 远程执行 `install.sh`:落位 `/usr/local/bin/havline-agent`、写 `/etc/havline-agent/agent.toml`(**生成随机 Token 并回传给 Havline**,无需用户抄写)、注册 `systemd` 单元并启动;
- 安装后立刻走 API `ping` 验证 + 防火墙提示(如 7700 未放行给出命令); - 安装后立刻走 API `ping` 验证 + 防火墙提示(如 7700 未放行给出命令);
3. UI 展示每步输出,失败可重试(幂等,重复安装=覆盖升级)。 3. UI 展示每步输出,失败可重试(幂等,重复安装=覆盖升级)。
@@ -136,21 +136,21 @@ server {
| 动作 | 方式 | 说明 | | 动作 | 方式 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| 升级 agent | SSH:上传新二进制 + `systemctl restart fonu-agent` | 路由/证书/Token 均在磁盘,不丢 | | 升级 agent | SSH:上传新二进制 + `systemctl restart havline-agent` | 路由/证书/Token 均在磁盘,不丢 |
| 卸载 agent | SSH:`install.sh --uninstall`(停服务、可选保留数据) | nginx vhost 文件一并清理(可选项) | | 卸载 agent | SSH:`install.sh --uninstall`(停服务、可选保留数据) | nginx vhost 文件一并清理(可选项) |
| agent 失联 | API 不可达时,Fonu 管理弹窗提示「走 SSH 通道诊断」:进程状态/端口/日志尾部 | SSH 是 API 故障时的备用通道 | | agent 失联 | API 不可达时,Havline 管理弹窗提示「走 SSH 通道诊断」:进程状态/端口/日志尾部 | SSH 是 API 故障时的备用通道 |
### 6.3 日常管理(API,面板在 Fonu) ### 6.3 日常管理(API,面板在 Havline)
安装完成后的一切操作不再用 SSH:规则联动同步、frps.toml 下发、证书部署、status 查询、全量对账——全部是 Fonu 界面按钮/自动触发,走 agent API。VPS 上没有任何管理面板,用户日常甚至不需要记住 VPS 密码。 安装完成后的一切操作不再用 SSH:规则联动同步、frps.toml 下发、证书部署、status 查询、全量对账——全部是 Havline 界面按钮/自动触发,走 agent API。VPS 上没有任何管理面板,用户日常甚至不需要记住 VPS 密码。
## 6.5 Fonu 侧新增「SSH 安装器」(与 agent 客户端并列) ## 6.5 Havline 侧新增「SSH 安装器」(与 agent 客户端并列)
| 模块 | 内容 | | 模块 | 内容 |
| --- | --- | | --- | --- |
| `internal/frp/ssh_installer.go`(新) | SSH 连接(x/crypto/ssh 已在 go.mod)、SFTP 上传二进制/install.sh、远程幂等安装流程、Token 回传 | | `internal/frp/ssh_installer.go`(新) | SSH 连接(x/crypto/ssh 已在 go.mod)、SFTP 上传二进制/install.sh、远程幂等安装流程、Token 回传 |
| agent 二进制内嵌 | 构建期把 `fonu-agent` 各架构二进制 embed 进 Fonu(构建矩阵:linux-amd64/arm64),安装时按 `uname -m` 选择,无需用户单独下载 | | agent 二进制内嵌 | 构建期把 `havline-agent` 各架构二进制 embed 进 Havline(构建矩阵:linux-amd64/arm64),安装时按 `uname -m` 选择,无需用户单独下载 |
| 数据模型 | 复用 023 迁移:`ssh_host/port/user/auth/secret_enc` 可选保存(仅启用「安装/升级/卸载」功能时需要),agent API Token 由安装器自动生成并写入 Fonu | | 数据模型 | 复用 023 迁移:`ssh_host/port/user/auth/secret_enc` 可选保存(仅启用「安装/升级/卸载」功能时需要),agent API Token 由安装器自动生成并写入 Havline |
| UI | 服务端表单「远程管理」:SSH 信息(可选)+「安装 agent / 升级 / 卸载 / 走 SSH 诊断」按钮,安装过程实时输出展示 | | UI | 服务端表单「远程管理」:SSH 信息(可选)+「安装 agent / 升级 / 卸载 / 走 SSH 诊断」按钮,安装过程实时输出展示 |
> SSH 信息**可选保存**:仅安装/升级时需要;日常管理走 API Token。安全上 SSH 凭据同 token 机制(secret.Box 加密、不回显),远程命令固定白名单模板(上传/安装/卸载三种),无注入面。 > SSH 信息**可选保存**:仅安装/升级时需要;日常管理走 API Token。安全上 SSH 凭据同 token 机制(secret.Box 加密、不回显),远程命令固定白名单模板(上传/安装/卸载三种),无注入面。
@@ -159,22 +159,22 @@ server {
| 模块 | 位置 | 内容 | 量级 | | 模块 | 位置 | 内容 | 量级 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| agent 主体 | `cmd/fonu-agent/` + `internal/agent/`(新) | HTTP 服务、鉴权、路由/frps/证书三个执行器、nginx 模板与 reload、状态聚合 | 大(约 1200 行) | | agent 主体 | `cmd/havline-agent/` + `internal/agent/`(新) | HTTP 服务、鉴权、路由/frps/证书三个执行器、nginx 模板与 reload、状态聚合 | 大(约 1200 行) |
| Fonu SSH 安装器 | `internal/frp/ssh_installer.go`(新) | SSH/SFTP 上传、远程幂等安装/升级/卸载、agent 二进制 embed、Token 自动回传 | 中 | | Havline SSH 安装器 | `internal/frp/ssh_installer.go`(新) | SSH/SFTP 上传、远程幂等安装/升级/卸载、agent 二进制 embed、Token 自动回传 | 中 |
| Fonu agent 客户端 | `internal/frp/agent_client.go`(新) | 7 个接口封装 | 中 | | Havline agent 客户端 | `internal/frp/agent_client.go`(新) | 7 个接口封装 | 中 |
| 数据模型/迁移 | `023_frp_server_agent.sql` | SSH + agent 字段 + 规则同步状态 | 小 | | 数据模型/迁移 | `023_frp_server_agent.sql` | SSH + agent 字段 + 规则同步状态 | 小 |
| 规则联动 | `internal/frp/service.go` | 保存/删除/启停后同步调用 + 状态落库 | 中 | | 规则联动 | `internal/frp/service.go` | 保存/删除/启停后同步调用 + 状态落库 | 中 |
| API/UI | `frpmulti_handler.go`、`router.go`、`FrpView.vue`、`client.ts` | 5 个端点 + 表单分组 + 管理弹窗 + 同步状态列 | 中 | | API/UI | `frpmulti_handler.go`、`router.go`、`FrpView.vue`、`client.ts` | 5 个端点 + 表单分组 + 管理弹窗 + 同步状态列 | 中 |
| 测试 | `agent_test.go` 等 | nginx 模板渲染/回滚、接口幂等、鉴权 | 中 | | 测试 | `agent_test.go` 等 | nginx 模板渲染/回滚、接口幂等、鉴权 | 中 |
总计约 2500–3000 行(含测试)。`mgmt_enabled=0` 时 Fonu 行为与 v1.1.0 完全一致;agent 未部署的服务端不受任何影响。 总计约 2500–3000 行(含测试)。`mgmt_enabled=0` 时 Havline 行为与 v1.1.0 完全一致;agent 未部署的服务端不受任何影响。
## 8. 里程碑 ## 8. 里程碑
| 阶段 | 内容 | 验收 | | 阶段 | 内容 | 验收 |
| --- | --- | --- | | --- | --- | --- |
| M1 | agent 主体(ping/status/routes)+ Fonu SSH 安装器(一键安装) | Fonu 填 SSH 信息点「安装」后自动装好 agent 并回连成功,status 能报端口占用者 | | M1 | agent 主体(ping/status/routes)+ Havline SSH 安装器(一键安装) | Havline 填 SSH 信息点「安装」后自动装好 agent 并回连成功,status 能报端口占用者 |
| M2 | Fonu 规则联动 + 路由自动注册 + frps 配置下发 | 保存 http 规则后 `http://域名` 无端口直达;frps.toml 一键下发生效 | | M2 | Havline 规则联动 + 路由自动注册 + frps 配置下发 | 保存 http 规则后 `http://域名` 无端口直达;frps.toml 一键下发生效 |
| M3 | 证书推送 + 续期联动 + 443 路由 | `https://域名` 可用,续期自动重推 | | M3 | 证书推送 + 续期联动 + 443 路由 | `https://域名` 可用,续期自动重推 |
| M4 | 管理弹窗 UI + 全量对账 + 升级/卸载/SSH 诊断 + 手册更新 | 手册第 6 节新增方案 C(agent 模式:SSH 装、API 管) | | M4 | 管理弹窗 UI + 全量对账 + 升级/卸载/SSH 诊断 + 手册更新 | 手册第 6 节新增方案 C(agent 模式:SSH 装、API 管) |
@@ -189,11 +189,11 @@ server {
| | v2 最终(agent + SSH 安装器) | v1(纯 SSH 推送) | | | v2 最终(agent + SSH 安装器) | v1(纯 SSH 推送) |
| --- | --- | --- | | --- | --- | --- |
| VPS 侧 | 常驻 agent,但**安装/升级由 Fonu SSH 一键完成** | 零安装 | | VPS 侧 | 常驻 agent,但**安装/升级由 Havline SSH 一键完成** | 零安装 |
| 日常管理 | API(面板全在 Fonu,日常无需 SSH) | 每次操作都走 SSH 命令 | | 日常管理 | API(面板全在 Havline,日常无需 SSH) | 每次操作都走 SSH 命令 |
| 能力上限 | 高:本地编排 nginx/frps/证书,可自愈可扩展 | 受限于 SSH 命令白名单 | | 能力上限 | 高:本地编排 nginx/frps/证书,可自愈可扩展 | 受限于 SSH 命令白名单 |
| 与已有站点共存 | 只写 `fonu-*.conf`,共存 | 可实现但每步都要远程拼配置 | | 与已有站点共存 | 只写 `havline-*.conf`,共存 | 可实现但每步都要远程拼配置 |
| 故障定位 | status API 一次拿全;agent 失联时 SSH 急救 | 每次探测拼 SSH 命令 | | 故障定位 | status API 一次拿全;agent 失联时 SSH 急救 | 每次探测拼 SSH 命令 |
| 适用 | VPS 长期做穿透入口、多域名/HTTPS 全自动 | 不想在 VPS 装常驻组件 | | 适用 | VPS 长期做穿透入口、多域名/HTTPS 全自动 | 不想在 VPS 装常驻组件 |
结论:**SSH 与 API 不是二选一**——SSH 只承担 agent 生命周期(装/升/卸/急救),API 承担日常管理;管理面板 100% 在 Fonu 本地,VPS 上没有任何管理界面。 结论:**SSH 与 API 不是二选一**——SSH 只承担 agent 生命周期(装/升/卸/急救),API 承担日常管理;管理面板 100% 在 Havline 本地,VPS 上没有任何管理界面。
+17 -17
View File
@@ -1,6 +1,6 @@
# FRPS 服务端自动管理方案(frps-manager) # FRPS 服务端自动管理方案(frps-manager)
> 目标:frpc 端在 Fonu 提交穿透规则后,frps 端**自动完成反向代理配置与生效**,实现域名无端口访问(`http://tv.36800.cc`),并自动管理 VPS 侧证书。本文是基于现有项目(Fonu v1.1.0 FRP 多服务端模块)的实施方案,不引入新服务、不改 frp 二进制。 > 目标:frpc 端在 Havline 提交穿透规则后,frps 端**自动完成反向代理配置与生效**,实现域名无端口访问(`http://tv.36800.cc`),并自动管理 VPS 侧证书。本文是基于现有项目(Havline v1.1.0 FRP 多服务端模块)的实施方案,不引入新服务、不改 frp 二进制。
## 1. 现状与问题 ## 1. 现状与问题
@@ -10,28 +10,28 @@
| --- | --- | --- | | --- | --- | --- |
| tcp 规则注册(远程端口) | ✅ 自动(frpc 登录时向 frps 动态注册) | 无 | | tcp 规则注册(远程端口) | ✅ 自动(frpc 登录时向 frps 动态注册) | 无 |
| http 规则域名路由 | ⚠️ 需 frps.toml 预先配置 `vhostHTTPPort` | 首次使用 http 类型时 VPS 要手工改配置+重启 | | http 规则域名路由 | ⚠️ 需 frps.toml 预先配置 `vhostHTTPPort` | 首次使用 http 类型时 VPS 要手工改配置+重启 |
| frps.toml 生成 | ✅ Fonu 一键生成,用户手动复制到 VPS | 每次变更都要 SSH 手工同步 | | frps.toml 生成 | ✅ Havline 一键生成,用户手动复制到 VPS | 每次变更都要 SSH 手工同步 |
| VPS 端口释放(80/443 被占) | ❌ 手动 SSH 停 Nginx | `port unavailable` 最常见根因 | | VPS 端口释放(80/443 被占) | ❌ 手动 SSH 停 Nginx | `port unavailable` 最常见根因 |
| 改配置后生效 | ❌ 手动 `pkill frps` 重启(frp 无热加载) | 容易忘、容易误 | | 改配置后生效 | ❌ 手动 `pkill frps` 重启(frp 无热加载) | 容易忘、容易误 |
| VPS 侧证书 | ❌ 无 | HTTPS 要在 VPS 手工装证书 | | VPS 侧证书 | ❌ 无 | HTTPS 要在 VPS 手工装证书 |
| 配置漂移检测 | ❌ 无 | VPS 上的文件与 Fonu 数据库可能不一致 | | 配置漂移检测 | ❌ 无 | VPS 上的文件与 Havline 数据库可能不一致 |
## 2. 总体设计 ## 2. 总体设计
在 Fonu(frpc 所在机器)新增 **frps-manager** 子系统:Fonu 通过 **SSH** 连接 VPS,执行「检测 → 生成 → 推送 → 生效 → 验证」闭环。VPS 侧**零安装**(只要求 frps + SSH)。 在 Havline(frpc 所在机器)新增 **frps-manager** 子系统:Havline 通过 **SSH** 连接 VPS,执行「检测 → 生成 → 推送 → 生效 → 验证」闭环。VPS 侧**零安装**(只要求 frps + SSH)。
```text ```text
Fonu (NAS) VPS (38.95.74.87) Havline (NAS) VPS (38.95.74.87)
┌─────────────────────────────┐ ┌──────────────────────────┐ ┌─────────────────────────────┐ ┌──────────────────────────┐
│ FRP 模块(现有) │ │ frps (7000/80/443/7500) │ │ FRP 模块(现有) │ │ frps (7000/80/443/7500) │
│ 规则/服务端 CRUD │ │ ↑ frpc 动态注册端口 │ │ 规则/服务端 CRUD │ │ ↑ frpc 动态注册端口 │
│ frps-manager(新增) │ SSH │ frps.toml (推送产物) │ │ frps-manager(新增) │ SSH │ frps.toml (推送产物) │
│ 检测→生成→推送→reload→验证 │ ────────→ │ /etc/fonu/frps/ │ │ 检测→生成→推送→reload→验证 │ ────────→ │ /etc/havline/frps/ │
│ 证书申请→推送→挂载 │ │ /etc/fonu/frps/certs/ │ │ 证书申请→推送→挂载 │ │ /etc/havline/frps/certs/ │
└─────────────────────────────┘ └──────────────────────────┘ └─────────────────────────────┘ └──────────────────────────┘
``` ```
核心思路:**frps 不认识"反代",它只认 frps.toml**。因此"自动反代"= ① 规则变化时重算出应然的 frps.toml(含 vhost 端口、subDomainHost、TLS 配置)→ ② 与 VPS 现状 diff → ③ 无变化则跳过,有变化则原子推送 → ④ 通过 frps **admin API 热重载**(无需重启进程)→ ⑤ 回读验证。证书同理:Fonu 证书页现有 ACME 能力签好后把证书文件推到 VPS 并写入 frps.toml 的 `transport.tls`/`webServer.tls` 段。 核心思路:**frps 不认识"反代",它只认 frps.toml**。因此"自动反代"= ① 规则变化时重算出应然的 frps.toml(含 vhost 端口、subDomainHost、TLS 配置)→ ② 与 VPS 现状 diff → ③ 无变化则跳过,有变化则原子推送 → ④ 通过 frps **admin API 热重载**(无需重启进程)→ ⑤ 回读验证。证书同理:Havline 证书页现有 ACME 能力签好后把证书文件推到 VPS 并写入 frps.toml 的 `transport.tls`/`webServer.tls` 段。
## 3. 服务端模型扩展(frp_servers 表) ## 3. 服务端模型扩展(frp_servers 表)
@@ -44,7 +44,7 @@ Fonu (NAS) VPS (38.95.74.87)
| `ssh_user` | TEXT | 建议 root 或具备 sudo 的用户 | | `ssh_user` | TEXT | 建议 root 或具备 sudo 的用户 |
| `ssh_auth` | TEXT | `password` / `key` | | `ssh_auth` | TEXT | `password` / `key` |
| `ssh_secret_enc` | TEXT | 密码或私钥(加密) | | `ssh_secret_enc` | TEXT | 密码或私钥(加密) |
| `frps_dir` | TEXT | VPS 上 frps 目录,默认 `/etc/fonu/frps` | | `frps_dir` | TEXT | VPS 上 frps 目录,默认 `/etc/havline/frps` |
| `frps_bin` | TEXT | frps 二进制路径,默认 `/usr/local/bin/frps` | | `frps_bin` | TEXT | frps 二进制路径,默认 `/usr/local/bin/frps` |
| `mgmt_enabled` | INTEGER | 是否启用自动管理(0/1,默认 0=纯手动,向后兼容) | | `mgmt_enabled` | INTEGER | 是否启用自动管理(0/1,默认 0=纯手动,向后兼容) |
@@ -64,7 +64,7 @@ cat /proc/$(pidof frps)/cmdline # 实际使用的配置路径
cat <配置路径> # 现有 frps.toml 内容(做 diff) cat <配置路径> # 现有 frps.toml 内容(做 diff)
``` ```
返回结构化报告:frps 版本/运行状态/配置路径、80/443 占用者判定(frps/nginx/其他/空闲)、现有 frps.toml 与 Fonu 期望配置的 **diff 摘要**、待办建议(如「80 被 nginx 占用,需释放」)。这就是用户之前反复手工排查的自动化。 返回结构化报告:frps 版本/运行状态/配置路径、80/443 占用者判定(frps/nginx/其他/空闲)、现有 frps.toml 与 Havline 期望配置的 **diff 摘要**、待办建议(如「80 被 nginx 占用,需释放」)。这就是用户之前反复手工排查的自动化。
### 4.2 下发(Apply)——「自动反代」的主流程 ### 4.2 下发(Apply)——「自动反代」的主流程
@@ -74,7 +74,7 @@ cat <配置路径> # 现有 frps.toml 内容(做 dif
- 存在 http/https/tcpmux 规则 → 输出 `vhostHTTPPort = 80`、`vhostHTTPSPort = 443`(去端口访问的关键); - 存在 http/https/tcpmux 规则 → 输出 `vhostHTTPPort = 80`、`vhostHTTPSPort = 443`(去端口访问的关键);
- 存在 subdomain 规则 → 输出 `subDomainHost = <用户在服务端表单新填的主域>`; - 存在 subdomain 规则 → 输出 `subDomainHost = <用户在服务端表单新填的主域>`;
- 证书启用时输出 `transport.tls.force` / `webServer.tls` 段; - 证书启用时输出 `transport.tls.force` / `webServer.tls` 段;
2. **SSH 推送(原子)**:新内容写 `/etc/fonu/frps/frps.toml.new` → `mv` 原子替换 → 旧文件留 `frps.toml.bak`(回滚点); 2. **SSH 推送(原子)**:新内容写 `/etc/havline/frps/frps.toml.new` → `mv` 原子替换 → 旧文件留 `frps.toml.bak`(回滚点);
3. **热重载**:调 frps admin API `PUT /api/reload`(frps webServer 7500 已有 Basic Auth 凭据)——**不杀进程**,连接不断;API 不可用时降级 `systemctl restart frps`(nohup 场景提示用户); 3. **热重载**:调 frps admin API `PUT /api/reload`(frps webServer 7500 已有 Basic Auth 凭据)——**不杀进程**,连接不断;API 不可用时降级 `systemctl restart frps`(nohup 场景提示用户);
4. **验证**:重读 VPS 上的 frps.toml 确认与期望一致 + `ss` 确认 80/443 已由 frps 监听 + 通过公网侧发起一次探测请求; 4. **验证**:重读 VPS 上的 frps.toml 确认与期望一致 + `ss` 确认 80/443 已由 frps 监听 + 通过公网侧发起一次探测请求;
5. 全程动作与输出写入系统日志(复用 logstore),UI 弹窗展示每步结果。 5. 全程动作与输出写入系统日志(复用 logstore),UI 弹窗展示每步结果。
@@ -88,14 +88,14 @@ cat <配置路径> # 现有 frps.toml 内容(做 dif
### 4.4 证书管理(VPS 侧 HTTPS) ### 4.4 证书管理(VPS 侧 HTTPS)
复用 Fonu 证书页现有 ACME(lego)能力,证书签发在 NAS 侧完成(DNS-01,无需 VPS 参与),然后: 复用 Havline 证书页现有 ACME(lego)能力,证书签发在 NAS 侧完成(DNS-01,无需 VPS 参与),然后:
1. 证书页选「部署到 VPS」→ 选目标服务端; 1. 证书页选「部署到 VPS」→ 选目标服务端;
2. SSH 推送 `fullchain.pem` / `privkey.pem` 到 `/etc/fonu/frps/certs/<域名>/`(0600); 2. SSH 推送 `fullchain.pem` / `privkey.pem` 到 `/etc/havline/frps/certs/<域名>/`(0600);
3. frps.toml 写入 `webServer.tls.certFile/keyFile`(管理面板 HTTPS);对外流量 TLS 若需要也在 frps 侧终止时,走 `transport.tls` 相关段(v0.71 支持 `transport.tls.force` + 证书); 3. frps.toml 写入 `webServer.tls.certFile/keyFile`(管理面板 HTTPS);对外流量 TLS 若需要也在 frps 侧终止时,走 `transport.tls` 相关段(v0.71 支持 `transport.tls.force` + 证书);
4. 续期:Fonu 证书定时任务续期成功后自动重推 + reload(证书 60 天滚动,VPS 侧无感)。 4. 续期:Havline 证书定时任务续期成功后自动重推 + reload(证书 60 天滚动,VPS 侧无感)。
> 设计取舍:对外业务的 HTTPS 终止仍推荐在 **Fonu Nginx(NAS 侧)**(本手册方案 A 架构),frps 侧证书用于 webServer 管理面板与 `tls.force` 场景。若用户想让 VPS 终止 TLS,同一套推送机制也覆盖。 > 设计取舍:对外业务的 HTTPS 终止仍推荐在 **Havline Nginx(NAS 侧)**(本手册方案 A 架构),frps 侧证书用于 webServer 管理面板与 `tls.force` 场景。若用户想让 VPS 终止 TLS,同一套推送机制也覆盖。
## 5. API 与 UI ## 5. API 与 UI
@@ -133,8 +133,8 @@ cat <配置路径> # 现有 frps.toml 内容(做 dif
## 7. 安全设计 ## 7. 安全设计
1. SSH 凭据 secret.Box 加密落库,API 永不回显(同 token 掩码机制); 1. SSH 凭据 secret.Box 加密落库,API 永不回显(同 token 掩码机制);
2. 推荐文档引导用户用**密钥登录**(Fonu 可一键生成 ed25519 密钥对、展示公钥让用户贴到 VPS `authorized_keys`); 2. 推荐文档引导用户用**密钥登录**(Havline 可一键生成 ed25519 密钥对、展示公钥让用户贴到 VPS `authorized_keys`);
3. 所有远程命令为**固定白名单模板**(版本/端口/状态/替换文件/reload),不拼接用户输入,杜绝命令注入;SFTP 只写 `/etc/fonu/frps/` 前缀内路径; 3. 所有远程命令为**固定白名单模板**(版本/端口/状态/替换文件/reload),不拼接用户输入,杜绝命令注入;SFTP 只写 `/etc/havline/frps/` 前缀内路径;
4. 危险动作(释放端口、重启 frps)二次确认 + 动作日志; 4. 危险动作(释放端口、重启 frps)二次确认 + 动作日志;
5. Apply 幂等:重复执行无副作用(diff 无变化即跳过); 5. Apply 幂等:重复执行无副作用(diff 无变化即跳过);
6. frps admin API 调用走本机 127.0.0.1:7500(SSH 远端执行 curl),不暴露公网。 6. frps admin API 调用走本机 127.0.0.1:7500(SSH 远端执行 curl),不暴露公网。
+962
View File
@@ -0,0 +1,962 @@
# Fonu → Havline 品牌改名实施与影响评估方案
> 日期:2026-10-01
> 当前版本:`1.9.0`
> 当前 Agent 版本:`0.14.1`
> 扫描范围:仓库源码、部署文件、脚本、前端、文档、GitHub Actions、迁移与生成配置
> 扫描结果:**227 个包含 `Fonu / fonu / FONU` 的文件,共 4032 处匹配**
> 扫描排除:`.git`、`web/node_modules`、`web/dist`、`cmd/fonu/web/dist`、`output`、`.playwright-cli`、Havline Logo 预览页
> 说明:本方案文档本身不计入上述扫描统计。
> 执行记录:2026-10-01 已按“无兼容迁移”模式执行全量切换。本文中的兼容迁移设计作为历史设计记录保留,不再代表当前实施状态。
---
## 0. 结论速览
品牌改名可以做,但**不能做一次无脑全局替换**。
真正危险的不是页面文字,而是下面这些会直接影响老用户升级的契约:
| 风险项 | 当前值 | 改名后的风险 |
| --- | --- | --- |
| 数据文件名 | `fonu.db` | 裸改名后应用会新建空库,用户会认为数据丢失 |
| 环境变量前缀 | `FONU_*` | 裸改名后老 `.env`、compose、systemd 配置全部失效 |
| Docker Volume | `fonu-data` | 裸改名后会创建新空卷 |
| Compose 项目名 | `name: fonu` | 项目名变化会让容器、网络、卷全部重新创建 |
| Agent 路径 | `/etc/fonu-agent`、`/var/lib/fonu-agent` | 服务端与 VPS Agent 版本错配时探测和升级失败 |
| Agent systemd | `fonu-agent.service` | 新老 Agent 可能同时存在或互相抢端口 |
| 会话 Cookie | `fonu_session` | 裸改名会让所有已登录用户掉线 |
| 一键升级 | `fonu`、`fonu-updater` 服务名与镜像 | 改名后 updater 找不到目标服务或镜像 |
| 前端版本检查 | `O96u/Fonu` | 仓库改名或常量未同步后,在线版本检查失效 |
推荐采用:
**分层渐进改名 + 一个稳定版本周期的兼容层。**
推荐目标命名:
| 形态 | 目标值 |
| --- | --- |
| 显示名 | `Havline` |
| 小写标识 | `havline` |
| 环境变量前缀 | `HAVLINE_` |
| Go module | `github.com/havline/havline` |
| 主程序 | `havline` |
| Agent | `havline-agent` |
| Updater | `havline-updater` |
| Docker 镜像 | `havline:<version>` |
| Updater 镜像 | `havline-updater:<version>` |
| Compose 项目 | `name: havline` |
| Compose 服务 | `havline`、`updater` |
| 数据卷 | `havline-data` |
| 数据库 | `havline.db` |
---
## 1. 当前工作区前提
当前工作区不是干净状态,执行正式改名之前必须先处理基线问题。
已存在的未提交或未跟踪内容:
| 类型 | 路径 | 与本次改名关系 |
| --- | --- | --- |
| 已修改 | `web/src/layouts/AppLayout.vue` | 侧栏已切到 `havline-logo.svg`,属于品牌展示层改动 |
| 已修改 | `web/src/assets/brand/logo.png` | 旧品牌资源,改名时必须统一处理 |
| 已修改 | `web/public/favicon.png` | 旧 favicon,改名时必须统一处理 |
| 已新增 | `web/src/assets/brand/havline-logo.svg` | 当前侧栏使用的第一版 SVG |
| 已新增 | `web/src/assets/brand/havline-logo-v1.svg` | 备选或历史 SVG |
| 已新增 | `web/src/assets/brand/havline-logo-v2.svg` | 当前推荐的第二版 SVG,尚未接入侧栏 |
| 已新增 | `web/assets/image/havline-logo.svg` | 镜像内资源目录的 SVG |
| 已新增 | `docs/havline-logo-preview.html` | 第一版 Logo 预览 |
| 已新增 | `docs/havline-logo-v2-preview.html` | 第二版 Logo 预览 |
| 未跟踪 | `docs/fnos-app-packaging-plan.md` | 飞牛应用打包方案,与品牌名有关 |
| 已删除 | `internal/frp-1.0.2/**` | 非本次改名逻辑的一部分,执行前需确认是否保留该删除 |
| 已删除 | `web/src/views/FrpView-1.0.2.vue` | 非本次改名逻辑的一部分,执行前需确认是否保留该删除 |
建议:
1. 先把 Logo V2 和侧栏资源作为独立提交合入。
2. 再确认 `internal/frp-1.0.2` 与 `FrpView-1.0.2.vue` 的删除是否属于当前基线。
3. 然后从干净、可回滚的基线开始兼容层和全仓改名。
---
## 2. 全仓扫描结果
### 2.1 数量统计
| 分类 | 文件数 |
| --- | ---: |
| Go、module、迁移 | 144 |
| 前端 Web | 40 |
| 部署、Docker、脚本 | 9 |
| 文档、README、CHANGELOG | 31 |
| GitHub Actions、`.gitignore` | 3 |
| 合计 | 227 |
匹配总量:**4032 处**。
### 2.2 重复率最高的文件
| 文件 | 匹配数 | 说明 |
| --- | ---: | --- |
| `web/src/views/AgentView.vue` | 214 | Agent 管理页,包含大量文案、变量和 `FonuCard` 引用 |
| `web/src/views/FrpView.vue` | 159 | 内网穿透页,包含品牌文案、变量和组件 |
| `web/src/views/ProxyView.vue` | 133 | 反向代理页,主要是 CSS 变量与组件名 |
| `web/src/views/ProxyRemoteView.vue` | 97 | 公网反代页 |
| `web/src/views/DashboardView.vue` | 97 | 仪表盘 |
| `web/src/views/SettingsView.vue` | 87 | 设置页 |
| `web/src/views/CertificatesView.vue` | 80 | 证书页 |
| `web/src/views/DdnsView.vue` | 77 | DDNS 页 |
| `docs/fnos-app-packaging-plan.md` | 65 | 飞牛应用方案 |
| `docs/improvement-roadmap-2026-09-27.md` | 65 | 改进路线图 |
| `docs/cloudflared-integration-plan.md` | 62 | Cloudflare 集成方案 |
| `CHANGELOG.md` | 57 | 历史版本记录 |
| `docs/brand-rename-plan.md` | 57 | 上一版改名评估文档 |
| `docs/DEPLOYMENT.md` | 56 | 部署文档 |
| `web/src/styles/variables.css` | 51 | CSS 品牌变量定义 |
| `internal/agent/server.go` | 48 | Agent 行为与远端路径 |
---
## 3. 命名映射表
### 3.1 用户可见名称
| 当前 | 目标 | 处理方式 |
| --- | --- | --- |
| `Fonu` | `Havline` | 页面标题、侧栏、登录页、通知、状态页 |
| `github.com/O96u/Fonu` | 待确认的新 GitHub 地址 | 版本检查、Release 链接 |
| Gitea `mrlee/-Fonu.git` | 待确认的新仓库地址 | Compose Git 构建、Updater |
| `Fonu Agent` | `Havline Agent` | UI、日志、systemd Description |
| `Fonu 自托管网关管理面板` | `Havline 自托管网关管理面板` | systemd unit 描述 |
### 3.2 代码与构建标识
| 类型 | 当前 | 目标 | 风险 |
| --- | --- | --- | --- |
| Go module | `github.com/fonu/fonu` | `github.com/havline/havline` | 中 |
| 主程序目录 | `cmd/fonu` | `cmd/havline` | 中 |
| Agent 目录 | `cmd/fonu-agent` | `cmd/havline-agent` | 高 |
| Updater 目录 | `cmd/fonu-updater` | `cmd/havline-updater` | 高 |
| 前端包名 | `fonu-web` | `havline-web` | 低 |
| 嵌入目录 | `cmd/fonu/web/dist` | `cmd/havline/web/dist` | 中 |
| Agent 嵌入文件 | `fonu-agent-linux-amd64` | `havline-agent-linux-amd64` | 高 |
| 构建版本注入 | `github.com/fonu/fonu/internal/version.Version` | `github.com/havline/havline/internal/version.Version` | 高 |
| Docker 镜像 | `fonu:<version>` | `havline:<version>` | 高 |
| Updater 镜像 | `fonu-updater:<version>` | `havline-updater:<version>` | 高 |
### 3.3 运行期契约
| 类型 | 当前 | 目标 | 兼容要求 |
| --- | --- | --- | --- |
| 环境变量 | `FONU_*` | `HAVLINE_*` | 新前缀优先,旧前缀兜底 |
| 数据库 | `fonu.db` | `havline.db` | 自动探测并接管旧库 |
| Cookie | `fonu_session` | `havline_session` | 新 Cookie 优先,旧 Cookie 兜底 |
| 本地主题 | `fonu-theme` | `havline-theme` | 读旧值,写新值 |
| Compose 项目 | `name: fonu` | `name: havline` | 需要卷迁移说明 |
| Compose 服务 | `fonu` | `havline` | Updater 变量必须同步 |
| Updater Socket | `/run/fonu-updater/updater.sock` | `/run/havline-updater/updater.sock` | Compose 两侧同步 |
| Updater Socket 卷 | `fonu-updater-sock` | `havline-updater-sock` | 需要卷迁移 |
| 数据卷 | `fonu-data` | `havline-data` | 不能直接改名,需要复制或保留旧卷名 |
| Agent 响应字段 | `"service": "fonu-agent"` | `"service": "havline-agent"` | 新服务端同时识别新旧值 |
### 3.4 Agent 远端契约
| 类型 | 当前 | 目标 | 风险 |
| --- | --- | --- | --- |
| 二进制 | `/usr/local/bin/fonu-agent` | `/usr/local/bin/havline-agent` | 高 |
| 配置目录 | `/etc/fonu-agent` | `/etc/havline-agent` | 高 |
| 数据目录 | `/var/lib/fonu-agent` | `/var/lib/havline-agent` | 高 |
| systemd unit | `fonu-agent.service` | `havline-agent.service` | 高 |
| 环境变量 | `FONU_AGENT_*` | `HAVLINE_AGENT_*` | 高 |
| Nginx 标记 | `# Managed by fonu-agent.` | `# Managed by havline-agent.` | 高 |
| Nginx 探针 | `fonu-probe.conf` | `havline-probe.conf` | 高 |
| Nginx 残留 | `fonu-*.conf` | `havline-*.conf` | 高 |
| 证书目录 | `/etc/fonu-agent/tls` | `/etc/havline-agent/tls` | 高 |
---
## 4. 分层影响分析
### 4.1 展示层
主要范围:
| 区域 | 文件 |
| --- | --- |
| 浏览器标题 | `web/index.html` |
| 侧栏品牌 | `web/src/layouts/AppLayout.vue` |
| 登录页 | `web/src/views/LoginView.vue` |
| 公开状态页 | `internal/api/status_page_handler.go`、`internal/api/status_handler.go` |
| 通知 | `internal/notify/format.go`、`internal/notify/service.go`、`internal/notify/push.go` |
| Agent 页面 | `web/src/views/AgentView.vue` |
| FRP 页面 | `web/src/views/FrpView.vue` |
| 设置页 | `web/src/views/SettingsView.vue` |
| 提示文案 | `web/src/constants/notifyEvents.ts`、`web/src/router/index.ts` |
高风险文案:
| 路径 | 当前内容 |
| --- | --- |
| `web/src/layouts/AppLayout.vue` | `Fonu`、`让 NAS 访问更简单` |
| `web/src/views/LoginView.vue` | `Fonu` 标题、旧 Logo、`alt="Fonu"` |
| `web/index.html` | `<title>Fonu</title>` |
| `web/src/views/AgentView.vue` | `Fonu Agent`、`fonu-agent`、删除确认文案 |
| `web/src/constants/notifyEvents.ts` | `VPS 上的 fonu-agent 连续无响应` |
| `internal/notify/*` | 邮件、推送中的品牌前缀 |
| `internal/api/status_*` | 公开状态页标题、页脚 |
### 4.2 前端资源层
当前品牌资源:
| 路径 | 用途 | 建议 |
| --- | --- | --- |
| `web/src/assets/brand/logo.png` | 侧栏/登录页旧 Logo | 保留至替换完成,随后删除 |
| `web/src/assets/brand/logo00.png` | 历史/备用 Logo | 确认无引用后处理 |
| `web/src/assets/brand/havline-logo.svg` | 当前第一版 SVG | 如决定 V2,替换引用 |
| `web/src/assets/brand/havline-logo-v1.svg` | 第一版 SVG 备份 | 归档或删除 |
| `web/src/assets/brand/havline-logo-v2.svg` | 推荐第二版 SVG | 作为正式 Logo 候选 |
| `web/assets/image/logo.png` | 镜像内旧 Logo | 替换为 SVG 或导出 PNG |
| `web/assets/image/havline-logo.svg` | 镜像内 SVG | 确认与 `src/assets` 保持一致 |
| `web/assets/image/banner.png` | 品牌 Banner | 需要重绘 |
| `web/src/assets/brand/banner.png` | 前端 Banner | 需要重绘 |
| `web/public/favicon.png` | 浏览器 favicon | 换成 Havline SVG/PNG |
| `web/public/111favicon.png` | 本地备用文件 | 确认是否应该入库 |
### 4.3 CSS 与组件层
CSS 变量数量:`web/src/styles/variables.css` 中 51 处 `--fonu-*`,且被大量 `.vue` 文件引用。
建议:
| 当前 | 目标 |
| --- | --- |
| `--fonu-brand` | `--havline-brand` |
| `--fonu-surface` | `--havline-surface` |
| `--fonu-text` | `--havline-text` |
| `--fonu-space-*` | `--havline-space-*` |
| `--fonu-radius*` | `--havline-radius*` |
组件重命名:
| 当前 | 目标 |
| --- | --- |
| `web/src/components/FonuCard.vue` | `web/src/components/HavlineCard.vue` |
| `.fonu-card` | `.havline-card` |
| `FonuCard` import | `HavlineCard` import |
影响文件:
`web/src/views/AgentView.vue`、`CertificatesView.vue`、`CloudflareTunnelConfigView.vue`、`CloudflareView.vue`、`DashboardView.vue`、`DdnsView.vue`、`ExampleView.vue`、`FrpView.vue`、`LogsView.vue`、`ProxyRemoteView.vue`、`ProxyView.vue`、`SettingsView.vue`。
### 4.4 构建与发布层
| 文件 | 影响 |
| --- | --- |
| `Dockerfile` | Agent 交叉编译、嵌入目录、主程序路径、Updater 二进制、环境变量 |
| `docker-compose.yml` | 项目名、服务名、镜像、容器名、卷、环境变量 |
| `docker-compose.hub.yml` | Git 构建地址、镜像、Updater、Socket、卷、服务名 |
| `docker-compose.host.yml` | 项目名、镜像、容器名、卷、host 网络环境变量 |
| `scripts/build-linux.ps1` | 发布物命名、`cmd/fonu`、agent 输出路径、ldflags |
| `scripts/fonu-install.sh` | `/opt/fonu`、`/var/lib/fonu`、`/etc/fonu`、`fonu.service` |
| `scripts/agent-install.sh` | Agent 二进制、目录、systemd unit、环境变量 |
| `scripts/upgrade.sh` | 容器名、镜像名、`FONU_VERSION` |
| `scripts/upgrade-hub.sh` | Gitea 地址、容器名、镜像名、回滚命令 |
| `.github/workflows/ci.yml` | `cmd/fonu/web/dist` 同步路径 |
| `.github/workflows/docker-release.yml` | Docker Hub 镜像 `DOCKERHUB_USERNAME}/fonu` |
| `.github/workflows/github-release.yml` | 虽无直接旧名,但 Release 流程需要回归 |
### 4.5 Go 代码标识层
`go.mod`:
```text
github.com/fonu/fonu
```
一旦修改 module path,所有 Go 文件中的 import 都需要更新。
当前受影响 Go 文件包括:
| 目录 | 文件数量 |
| --- | ---: |
| `cmd` | 3 |
| `internal/acme` | 7 |
| `internal/agent` | 5 |
| `internal/api` | 27 |
| `internal/app` | 3 |
| `internal/auth` | 3 |
| `internal/backup` | 4 |
| `internal/cloudflared` | 10 |
| `internal/config` | 2 |
| `internal/frp` | 13 |
| `internal/nginx` | 23 |
| `internal/notify` | 7 |
| `internal/updater` | 6 |
| 其他 internal | 17 |
| `migrations` | 2 |
### 4.6 运行契约层
| 文件 | 当前契约 | 改名风险 |
| --- | --- | --- |
| `internal/config/config.go` | `FONU_*`、`fonu.db` | 环境变量和数据库路径 |
| `internal/api/middleware.go` | `fonu_session` | 登录态 |
| `internal/api/auth_handler.go` | 设置/清除 `fonu_session` | 登录态 |
| `web/src/composables/useTheme.ts` | `fonu-theme` | 主题偏好 |
| `internal/backup/service.go` | 备份名、`fonu.db` 检查 | 备份兼容 |
| `internal/backup/archive.go` | `fonu-backup-*`、`fonu.db` | 导入旧备份 |
| `internal/updater/runner.go` | `FONU_VERSION`、`fonu:<version>` | 一键升级 |
| `cmd/fonu-updater/main.go` | Updater 环境变量与默认服务名 | 一键升级 |
| `internal/frp/agent_installer.go` | `/etc/fonu-agent`、`/usr/local/bin/fonu-agent` | Agent 安装与探测 |
| `internal/frp/ssh_installer.go` | 上传 `/tmp/fonu-agent` | Agent 安装 |
| `internal/agent/server.go` | Agent 版本、远端路径、Nginx 标记 | Agent 兼容 |
| `internal/agent/transport.go` | `/etc/fonu-agent/tls` | HTTPS 直连 |
| `internal/monitor/monitor.go` | `fonu-agent` 状态文案与判定 | 监控与通知 |
### 4.7 数据与备份层
| 数据 | 当前值 | 处理策略 |
| --- | --- | --- |
| SQLite 主库 | `<DataDir>/fonu.db` | 检测旧库,优先原子重命名,失败则回退旧路径 |
| Session Secret | `<DataDir>/session_secret` | 文件名不含品牌,不需要改名 |
| 备份文件名 | `fonu-backup-YYYYMMDD-HHMMSS-N.tar.gz` | 新名生成,旧备份仍允许导入 |
| 备份识别 | 必须存在 `fonu.db` | 同时兼容 `havline.db` 与 `fonu.db` |
| Docker 卷 | `fonu-data` | 不自动删旧卷,提供复制/挂载迁移 |
| Updater 卷 | `fonu-updater-sock` | 与 Compose 同步迁移 |
| Agent 数据 | `/var/lib/fonu-agent` | 安装脚本迁移到新目录,旧目录保留一个版本 |
| Agent 配置 | `/etc/fonu-agent/agent.env` | 新目录优先,旧目录兼容读取 |
---
## 5. 兼容迁移设计
### 5.1 环境变量
当前读取逻辑:
```go
envOr("FONU_DATA_DIR", defaultDataDir())
```
目标逻辑:
```go
envOrHavline("HAVLINE_DATA_DIR", "FONU_DATA_DIR", defaultDataDir())
```
规则:
1. `HAVLINE_*` 优先。
2. `FONU_*` 作为 fallback。
3. 命中旧变量时启动日志给出一条迁移提示。
4. 至少保留一个 stable 版本后再删除旧前缀。
必须兼容的环境变量:
```text
FONU_DATA_DIR
FONU_LISTEN
FONU_SESSION_SECRET
FONU_NGINX_BIN
FONU_NGINX_PID
FONU_NGINX_MIME_TYPES
FONU_NGINX_HTTP_PORT
FONU_NGINX_HTTPS_PORT
FONU_FRPC_BIN
FONU_FRP_PID
FONU_UPDATER_SOCKET
FONU_AGENT_LISTEN
FONU_AGENT_ALLOW_REMOTE
FONU_AGENT_TOKEN
FONU_AGENT_NGINX_CONF_DIR
FONU_AGENT_NGINX_LOG_DIR
FONU_AGENT_NGINX_BIN
FONU_AGENT_CERTS_DIR
FONU_AGENT_DATA_DIR
FONU_AGENT_FRPS_CONFIG
FONU_AGENT_FRPS_BIN
FONU_UPDATE_PROJECT_DIR
FONU_UPDATE_COMPOSE_FILE
FONU_UPDATE_SERVICE
FONU_UPDATE_MODE
FONU_UPDATE_REPO
FONU_UPDATE_BRANCH
FONU_UPDATE_APP_URL
FONU_UPDATE_HOST_DIR
FONU_UPDATE_PROJECT_NAME
FONU_UPDATE_SELF_IMAGE
FONU_UPDATE_DOCKER_SOCK
FONU_VERSION
FONU_DATA_PATH
```
### 5.2 数据库接管
判断顺序:
1. `havline.db` 存在:直接使用。
2. `havline.db` 不存在且 `fonu.db` 存在:尝试原子重命名。
3. 重命名失败:继续使用 `fonu.db`,日志提示迁移。
4. 两者都不存在:创建 `havline.db`。
禁止行为:
1. 不能在旧库存在时直接创建新空库。
2. 不能删除旧库。
3. 不能修改 `session_secret`,否则已有加密凭据无法解密。
### 5.3 Cookie 与本地偏好
| 项目 | 读策略 | 写策略 |
| --- | --- | --- |
| Session Cookie | `havline_session` 优先,回退 `fonu_session` | 只写 `havline_session` |
| 主题偏好 | `havline-theme` 优先,回退 `fonu-theme` | 读到旧值后回写新键 |
### 5.4 Docker Compose 与 Volume
不能直接把 `fonu-data` 改成 `havline-data` 就结束。
推荐策略:
1. 新 compose 使用 `name: havline`。
2. 新卷名为 `havline-data`。
3. 文档提供旧卷到新卷的复制命令。
4. 升级前先备份旧卷。
5. 首版兼容可以直接把 `havline-data` 显式指向旧卷进行阶段性过渡。
示例:
```yaml
volumes:
havline-data:
name: fonu-data
```
上述做法只用于过渡版本。完全切换完成后再恢复为 `name: havline-data`。
### 5.5 Updater 迁移
一键升级涉及四组变量:
```text
FONU_UPDATE_SERVICE=fonu
FONU_UPDATE_APP_URL=http://fonu:6893
FONU_UPDATE_SELF_IMAGE=fonu-updater:<version>
FONU_UPDATER_SOCKET=/run/fonu-updater/updater.sock
```
改名时必须一起处理:
| 当前 | 目标 |
| --- | --- |
| Service `fonu` | Service `havline` |
| App URL `http://fonu:6893` | `http://havline:6893` |
| Updater 镜像 `fonu-updater` | `havline-updater` |
| Updater socket 目录 | `/run/havline-updater/updater.sock` |
| Socket volume | `havline-updater-sock` |
### 5.6 Agent 迁移
推荐 Agent 安装脚本流程:
1. 检测旧 `/etc/fonu-agent/agent.env`。
2. 停止并禁用 `fonu-agent.service`。
3. 安装新 `havline-agent` 二进制。
4. 创建 `/etc/havline-agent`、`/var/lib/havline-agent`。
5. 迁移或复制旧 token、证书和 frps 配置。
6. 安装 `havline-agent.service`。
7. 保留旧目录一个版本,便于回滚。
8. 服务端探测同时检查新旧二进制、目录和 systemd unit。
Agent 版本必须递增。当前 `AgentVersion = 0.14.1`,品牌与路径变更建议递增到 `0.15.0`。
---
## 6. 实施阶段
### 阶段 0:冻结名称与外部身份
需要确认:
1. GitHub 仓库新地址。
2. Gitea 仓库新地址。
3. Docker Hub 命名空间与仓库名。
4. 是否保留旧仓库重定向。
5. 是否发布旧镜像名兼容标签。
6. 域名与商标状态。
### 阶段 1:兼容层先行
先不替换全仓品牌,只增加兼容能力:
1. `internal/config/config.go` 支持 `HAVLINE_*` 与 `FONU_*` 双读。
2. 数据库支持 `havline.db` 与 `fonu.db`。
3. Session Cookie 支持新旧双读。
4. `useTheme.ts` 支持新旧 key。
5. Agent 探测支持新旧路径。
6. Backup 导入支持新旧数据库名。
7. Updater 支持新旧服务名与镜像名过渡。
### 阶段 2:展示层改名
1. `web/index.html` 标题。
2. `AppLayout.vue` 品牌名、标语、Logo。
3. `LoginView.vue` 标题、Logo、说明文案。
4. 公开状态页和通知品牌名。
5. `web/src/constants/notifyEvents.ts` 和路由 meta。
6. 前端资源与 favicon。
7. `FonuCard.vue` -> `HavlineCard.vue`。
8. `--fonu-*` -> `--havline-*`。
### 阶段 3:代码标识改名
1. `go.mod` module path。
2. 全仓 Go import。
3. `cmd/fonu` -> `cmd/havline`。
4. `cmd/fonu-agent` -> `cmd/havline-agent`。
5. `cmd/fonu-updater` -> `cmd/havline-updater`。
6. Dockerfile ldflags。
7. Agent 嵌入文件。
8. CI `cmd/fonu/web/dist` 路径。
### 阶段 4:交付与升级链改名
1. Docker 镜像名。
2. Updater 镜像名。
3. Compose service、container、volume。
4. `scripts/upgrade.sh`、`scripts/upgrade-hub.sh`。
5. 一键升级配置。
6. Docker Release workflow。
### 阶段 5:Agent 全量切换
1. 新 Agent 二进制。
2. 新 systemd unit。
3. 新目录和环境变量。
4. Nginx 标记兼容。
5. 旧 Agent 探测、升级、回滚。
6. Agent 版本递增与双架构重编。
### 阶段 6:文档与历史清理
1. README、DEPLOYMENT、容器文档、飞牛打包文档。
2. CHANGELOG 只追加,不改历史发布记录。
3. 删除旧兼容代码。
4. 移除旧 Logo 和旧资源。
5. 全仓 grep 验收。
---
## 7. 验证矩阵
### 7.1 构建验证
```powershell
go build ./...
go vet ./...
go test ./... -count=1
cd web
npx vue-tsc --noEmit
npx vite build
npm test
```
### 7.2 Docker 验证
1. `docker-compose.yml` 新建部署。
2. `docker-compose.hub.yml` 单文件部署。
3. `docker-compose.host.yml` host 网络部署。
4. 旧 `fonu-data` 卷启动新版本。
5. 旧 `.env` 中 `FONU_*` 继续生效。
6. 新 `.env` 中 `HAVLINE_*` 优先。
7. 一键升级成功。
8. 回滚到旧镜像成功。
### 7.3 数据验证
1. 旧 `fonu.db` 自动接管。
2. 旧备份导入。
3. 新备份导出与恢复。
4. `session_secret` 不变,凭据仍能解密。
5. 旧 Cookie 能继续登录一次并迁移到新 Cookie。
6. 旧主题偏好迁移。
### 7.4 Agent 验证
1. 旧 Agent -> 新服务端。
2. 新 Agent -> 旧服务端。
3. 新 Agent -> 新服务端。
4. 旧 `/etc/fonu-agent` 升级到新目录。
5. 旧 `fonu-agent.service` 被正确停止和替换。
6. 旧 `fonu-probe.conf` 和 `fonu-*.conf` 能被识别和清理。
---
## 8. 回滚策略
| 阶段 | 回滚方式 |
| --- | --- |
| 展示层 | Git revert 文案与图片 |
| CSS/组件 | Git revert 组件重命名 |
| 环境变量双读 | 保留旧逻辑,无需回滚 |
| 数据库 | 优先使用旧库,反向重命名需单独文档 |
| Docker Volume | 保留旧卷,不自动删除 |
| Updater | 恢复旧 compose 与旧镜像 |
| Agent | 保留旧目录与旧 unit,重新启用旧服务 |
| Module path | 整体 revert,避免混合 import |
---
## 9. 建议提交拆分
| 提交 | 内容 |
| --- | --- |
| 1 | 增加 Havline Logo 资产与预览 |
| 2 | 品牌改名方案文档 |
| 3 | 环境变量、DB、Cookie、主题兼容层 |
| 4 | 前端展示层与资源替换 |
| 5 | CSS 变量与组件重命名 |
| 6 | Go module、cmd 目录、Agent 嵌入路径 |
| 7 | Docker、Compose、Updater、脚本 |
| 8 | Agent 远端安装与迁移 |
| 9 | 文档全面更新 |
| 10 | 兼容层清理 |
每个提交都必须能独立构建和回滚,不要把 10 个阶段压成一个提交。
---
## 10. 完整文件清单
以下文件为当前扫描到包含 `Fonu / fonu / FONU` 的全部文件。生成目录、`node_modules`、`.git` 不在此列表中。
### 10.1 GitHub Actions 与忽略规则
```text
.github/workflows/ci.yml
.github/workflows/docker-release.yml
.gitignore
```
### 10.2 Go、module 与迁移
```text
go.mod
cmd/fonu/main.go
cmd/fonu-agent/main.go
cmd/fonu-updater/main.go
internal/acme/dns_provider.go
internal/acme/dns_provider_test.go
internal/acme/domains.go
internal/acme/download.go
internal/acme/job.go
internal/acme/service.go
internal/acme/volcengine_dns.go
internal/agent/insights.go
internal/agent/route_test.go
internal/agent/server.go
internal/agent/transport.go
internal/agent/versions.go
internal/agent/versions_test.go
internal/api/auth_handler.go
internal/api/auth_handler_test.go
internal/api/backup_handler.go
internal/api/cert_handler.go
internal/api/chinacidr_handler.go
internal/api/cloudflare_handler.go
internal/api/ddns_handler.go
internal/api/deps.go
internal/api/discovery_handler.go
internal/api/frpmulti_handler.go
internal/api/logs_handler.go
internal/api/logs_handler_test.go
internal/api/metrics_handler.go
internal/api/middleware.go
internal/api/middleware_test.go
internal/api/nginx_handler.go
internal/api/notify_handler.go
internal/api/proxy_handler.go
internal/api/proxy_security.go
internal/api/router.go
internal/api/router_auth_test.go
internal/api/settings_handler.go
internal/api/status_handler.go
internal/api/status_page_handler.go
internal/api/token_handler.go
internal/api/update_handler.go
internal/api/version_handler.go
internal/api/version_handler_test.go
internal/app/app.go
internal/app/monitor_source.go
internal/app/monitor_source_test.go
internal/auth/auth.go
internal/auth/auth_test.go
internal/auth/token_test.go
internal/backup/archive.go
internal/backup/auto.go
internal/backup/auto_test.go
internal/backup/service.go
internal/chinacidr/service.go
internal/cloudflared/cloudflare_api.go
internal/cloudflared/config.go
internal/cloudflared/config_test.go
internal/cloudflared/health_test.go
internal/cloudflared/manager.go
internal/cloudflared/model.go
internal/cloudflared/operations.go
internal/cloudflared/routes.go
internal/cloudflared/service.go
internal/cloudflared/settings_test.go
internal/cloudflared/store.go
internal/config/config.go
internal/config/config_test.go
internal/db/db_test.go
internal/ddns/service.go
internal/frp/agent_client.go
internal/frp/agent_insights.go
internal/frp/agent_installer.go
internal/frp/agentbin/embed.go
internal/frp/frps_template.go
internal/frp/manager.go
internal/frp/model.go
internal/frp/route_drift.go
internal/frp/runtime.go
internal/frp/service.go
internal/frp/ssh_installer.go
internal/frp/ssh_installer_test.go
internal/fsutil/fsutil_test.go
internal/logstore/dashboard_access_test.go
internal/logstore/reader.go
internal/monitor/monitor.go
internal/nginx/certs.go
internal/nginx/comments.go
internal/nginx/custom.go
internal/nginx/custom_test.go
internal/nginx/endpoints.go
internal/nginx/endpoints_test.go
internal/nginx/errors.go
internal/nginx/errors_test.go
internal/nginx/generate.go
internal/nginx/generate_china_bypass_test.go
internal/nginx/generate_china_cidr_path_test.go
internal/nginx/generate_comments_test.go
internal/nginx/generate_https_no_cert_test.go
internal/nginx/generate_https_with_cert_test.go
internal/nginx/generate_multi_test.go
internal/nginx/generate_order_test.go
internal/nginx/generate_security_test.go
internal/nginx/generate_seed_mix_test.go
internal/nginx/generate_test.go
internal/nginx/global_framework_test.go
internal/nginx/htpasswd.go
internal/nginx/htpasswd_test.go
internal/nginx/nginx.go
internal/nginx/nginx_integration_test.go
internal/nginx/nginx_unavailable_test.go
internal/nginx/pid_test.go
internal/nginx/realip.go
internal/nginx/security.go
internal/nginx/versions.go
internal/nginx/versions_test.go
internal/nginxtmpl/nginxtmpl_test.go
internal/notify/format.go
internal/notify/format_test.go
internal/notify/push.go
internal/notify/push_test.go
internal/notify/service.go
internal/notify/smtp_test.go
internal/notify/store.go
internal/notify/store_test.go
internal/proxy/custom_endpoints_test.go
internal/proxy/proxy.go
internal/proxy/security.go
internal/proxy/security_test.go
internal/service/nginx_config.go
internal/service/proxy.go
internal/service/proxy_opts.go
internal/sysinfo/sysinfo_windows.go
internal/traffic/collector.go
internal/traffic/collector_test.go
internal/updater/client.go
internal/updater/model.go
internal/updater/remote_version_test.go
internal/updater/runner.go
internal/updater/server.go
internal/updater/server_test.go
migrations/023_frp_server_agent.sql
migrations/035_proxy_exits.sql
```
### 10.3 前端 Web
```text
web/index.html
web/package.json
web/package-lock.json
web/src/App.vue
web/src/main.ts
web/src/api/client.ts
web/src/api/types.ts
web/src/components/ConfigDiffView.vue
web/src/components/EmptyState.vue
web/src/components/FonuCard.vue
web/src/components/LoadError.vue
web/src/components/MetricsTrendPanel.vue
web/src/components/NginxCodeEditor.vue
web/src/components/PageHeader.vue
web/src/components/ProxyAccessLogBox.vue
web/src/components/StatCard.vue
web/src/components/StatusBadge.vue
web/src/components/TaskProviderForm.vue
web/src/components/UpdateModal.vue
web/src/composables/useTheme.ts
web/src/constants/app.ts
web/src/constants/notifyEvents.ts
web/src/layouts/AppLayout.vue
web/src/router/index.ts
web/src/styles/variables.css
web/src/utils/clipboard.ts
web/src/views/AgentView.vue
web/src/views/CertificatesView.vue
web/src/views/CloudflareTunnelConfigView.vue
web/src/views/CloudflareView.vue
web/src/views/DashboardView.vue
web/src/views/DdnsView.vue
web/src/views/ExampleView.vue
web/src/views/FrpView.vue
web/src/views/LoginView.vue
web/src/views/LogsView.vue
web/src/views/ProxyRemoteView.vue
web/src/views/ProxyView.vue
web/src/views/SettingsView.vue
web/src/views/StatusPageView.vue
```
### 10.4 部署、Docker 与脚本
```text
Dockerfile
docker-compose.yml
docker-compose.hub.yml
docker-compose.host.yml
scripts/agent-install.sh
scripts/build-linux.ps1
scripts/fonu-install.sh
scripts/upgrade.sh
scripts/upgrade-hub.sh
```
### 10.5 文档与历史
```text
CHANGELOG.md
README.md
docs/DEPLOYMENT.md
docs/FRP公网暴露操作手册.md
docs/README.md
docs/agent-capability-plan.md
docs/agent-listen-strategy-plan.md
docs/agent-proxy-roadmap.md
docs/agent-proxy-settings-plan.md
docs/agent-view-design-v2.md
docs/agent-view-design.md
docs/agent-view-refactor-plan.md
docs/brand-rename-plan.md
docs/cloudflared-current-features.md
docs/cloudflared-feature-plan.md
docs/cloudflared-fonu-fit-plan.md
docs/cloudflared-github-ux-research.md
docs/cloudflared-integration-plan.md
docs/container-deploy-and-data.md
docs/feature-benchmark-2026-09-24.md
docs/fnos-app-packaging-plan.md
docs/frp-server-list-merge-plan.md
docs/frps-features.md
docs/frps服务端管理方案.md
docs/frps管理方案.md
docs/improvement-roadmap-2026-09-27.md
docs/project-analysis-2026-09-27.md
docs/project-audit-2026-09-24.md
docs/proxy-remote-design.md
docs/system-upgrade.md
docs/upstream-sync-plan.md
```
### 10.6 不在 grep 结果中但需要同步处理
| 路径 | 原因 |
| --- | --- |
| `web/src/assets/brand/havline-logo-v2.svg` | 新 Logo 资源,需要决定最终接入口径 |
| `web/src/assets/brand/havline-logo-v1.svg` | Logo 历史版本 |
| `web/src/assets/brand/havline-logo.svg` | 当前已接入侧栏 |
| `web/src/assets/brand/logo00.png` | 历史/备用 Logo |
| `web/src/assets/brand/banner.png` | 旧品牌 Banner |
| `web/src/assets/brand/banner-art.png` | 旧品牌 Banner 素材 |
| `web/assets/image/havline-logo.svg` | 镜像资源目录中的 SVG |
| `web/assets/image/logo.png` | 旧品牌图片,需要替换 |
| `web/assets/image/banner.png` | 旧品牌 Banner,需要重绘 |
| `web/public/favicon.png` | 旧 favicon,需要替换 |
| `web/public/111favicon.png` | 未确认用途 |
| `docs/havline-brand-rename-plan.md` | 本方案文档本身 |
| `docs/havline-logo-preview.html` | 第一版 Logo 预览 |
| `docs/havline-logo-v2-preview.html` | 第二版 Logo 预览 |
| `cmd/fonu/web/dist/**` | 生成目录,改名后由构建重新生成 |
| `internal/frp/agentbin/fonu-agent-linux-amd64` | 生成/嵌入二进制,改名后重新编译 |
| `internal/frp/agentbin/fonu-agent-linux-arm64` | 生成/嵌入二进制,改名后重新编译 |
| `fonu-agent.exe` | 本地构建产物 |
---
## 11. 最终执行顺序
推荐顺序:
1. 确认最终 Logo、GitHub、Gitea、Docker Hub 身份。
2. 增加兼容层。
3. 替换展示层。
4. 替换 CSS 与组件。
5. 替换 Go module 与 cmd 目录。
6. 替换 Docker、Compose、Updater。
7. 替换 Agent 远端安装与探测逻辑。
8. 全量迁移文档。
9. 跑完整回归。
10. 保留兼容层一个版本。
11. 删除旧兼容代码与旧资源。
---
## 12. 验收 grep
改名过程中可重复执行:
```powershell
rg --hidden -in "fonu" `
--glob '!web/node_modules/**' `
--glob '!web/dist/**' `
--glob '!cmd/havline/web/dist/**' `
--glob '!.git/**' `
--glob '!output/**' `
--glob '!.playwright-cli/**'
```
分类验收:
```powershell
rg -n "FONU_"
rg -n "fonu\.db|fonu_session|fonu-theme"
rg -n "fonu-agent|fonu-probe|/etc/fonu|/var/lib/fonu"
rg -n "github\.com/fonu/fonu|O96u/Fonu|/fonu:|fonu-data"
rg -n -- "--fonu-"
```
最终目标:
1. 源码中不再出现旧品牌展示文案。
2. 运行时代码不再依赖旧环境变量、旧 Cookie、旧数据库文件名。
3. 兼容层只保留明确记录的旧值 fallback。
4. 文档历史记录不改写,只新增迁移说明。
5. 新安装、旧数据升级、旧 Agent 升级、旧 Compose 回滚四条路径全部通过。
File diff suppressed because it is too large Load Diff
+905
View File
@@ -0,0 +1,905 @@
<!doctype html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3C/svg%3E">
<title>Havline Logo V2 概念预览</title>
<style>
:root {
--page: #06121e;
--panel: #0b1c2b;
--panel-soft: #10283a;
--text: #f2fbfa;
--muted: #8daab6;
--line: rgba(235, 255, 252, 0.13);
--line-strong: rgba(235, 255, 252, 0.24);
--blue: #2fa9ff;
--mint: #63f0d0;
--gold: #ffb15a;
--radius: 8px;
--shadow: 0 22px 70px rgba(0, 0, 0, 0.28);
--font: "Segoe UI", "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
html[data-theme="light"] {
--page: #eef5f6;
--panel: #ffffff;
--panel-soft: #e4f0f1;
--text: #0a2535;
--muted: #58717b;
--line: rgba(10, 37, 53, 0.12);
--line-strong: rgba(10, 37, 53, 0.21);
--shadow: 0 22px 70px rgba(20, 55, 70, 0.13);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--page);
color: var(--text);
font-family: var(--font);
line-height: 1.6;
}
button {
cursor: pointer;
font: inherit;
}
.sprite {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
}
.topbar {
position: sticky;
top: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
min-height: 70px;
padding: 14px clamp(18px, 4vw, 56px);
background: color-mix(in srgb, var(--page) 92%, transparent);
border-bottom: 1px solid var(--line);
backdrop-filter: blur(18px);
}
.topbar__brand,
.topbar__actions,
.inline-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.topbar__mark {
width: 34px;
height: 34px;
}
.topbar__title {
display: grid;
line-height: 1.15;
}
.topbar__title strong {
font-size: 17px;
}
.topbar__title span {
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.control {
min-height: 38px;
padding: 0 14px;
color: var(--text);
background: transparent;
border: 1px solid var(--line-strong);
border-radius: var(--radius);
}
.control:hover,
.control[aria-pressed="true"] {
background: var(--panel-soft);
}
.control--primary {
color: #04131d;
background: linear-gradient(135deg, var(--blue), var(--mint));
border-color: transparent;
font-weight: 700;
}
.wrap {
width: min(1160px, calc(100% - 36px));
margin: 0 auto;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 0.92fr) minmax(380px, 1.08fr);
gap: clamp(28px, 5vw, 72px);
align-items: center;
min-height: calc(100vh - 70px);
padding: 70px 0;
}
.eyebrow {
margin: 0 0 14px;
color: var(--mint);
font-family: var(--mono);
font-size: 12px;
letter-spacing: 0.15em;
text-transform: uppercase;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
margin-bottom: 12px;
font-size: clamp(52px, 8vw, 92px);
line-height: 0.94;
letter-spacing: -0.02em;
}
.lead {
margin-bottom: 18px;
font-size: clamp(19px, 2.3vw, 29px);
font-weight: 600;
line-height: 1.28;
}
.copy {
max-width: 620px;
margin-bottom: 22px;
color: var(--muted);
font-size: 15px;
}
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
color: var(--mint);
background: color-mix(in srgb, var(--mint) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--mint) 28%, transparent);
border-radius: 999px;
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.08em;
}
.stage {
position: relative;
display: grid;
place-items: center;
min-height: 560px;
padding: 48px;
overflow: hidden;
background:
linear-gradient(to right, color-mix(in srgb, var(--line) 62%, transparent) 1px, transparent 1px) 0 0 / 36px 36px,
linear-gradient(to bottom, color-mix(in srgb, var(--line) 62%, transparent) 1px, transparent 1px) 0 0 / 36px 36px,
color-mix(in srgb, var(--panel) 95%, #071a29);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.stage::after {
content: "";
position: absolute;
inset: 18px;
border: 1px solid color-mix(in srgb, var(--mint) 17%, transparent);
border-radius: var(--radius);
pointer-events: none;
}
.hero-lockup {
position: relative;
z-index: 1;
width: min(100%, 570px);
color: var(--text);
}
.section {
padding: 82px 0;
border-top: 1px solid var(--line);
}
.section__head {
display: grid;
grid-template-columns: minmax(0, 0.7fr) minmax(0, 1.3fr);
gap: 28px;
margin-bottom: 30px;
}
.section__head h2 {
margin-bottom: 0;
font-size: clamp(27px, 4vw, 44px);
line-height: 1.08;
}
.section__head p {
max-width: 680px;
margin-bottom: 0;
color: var(--muted);
}
.fix-grid,
.concept-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.fix-card,
.concept-card,
.preview-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.fix-card {
min-height: 200px;
padding: 22px;
}
.fix-card__index {
display: block;
margin-bottom: 42px;
color: var(--mint);
font-family: var(--mono);
font-size: 12px;
letter-spacing: 0.12em;
}
.fix-card h3 {
margin-bottom: 8px;
font-size: 17px;
}
.fix-card p {
margin-bottom: 0;
color: var(--muted);
font-size: 14px;
}
.compare {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
}
.compare-card {
display: grid;
place-items: center;
min-height: 360px;
padding: 34px;
background: #f4fbfb;
border: 1px solid rgba(7, 34, 50, 0.12);
border-radius: var(--radius);
color: #092638;
}
.compare-card--dark {
color: var(--text);
background: #071a29;
border-color: var(--line);
}
.compare-card__label {
align-self: end;
margin-top: 22px;
color: inherit;
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.1em;
opacity: 0.62;
text-transform: uppercase;
}
.mark-large {
width: min(100%, 190px);
height: auto;
}
.concept-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.concept-card {
display: grid;
place-items: center;
min-height: 340px;
padding: 30px;
}
.concept-card__mark {
width: 140px;
height: 140px;
}
.concept-card__copy {
align-self: end;
margin-top: 22px;
text-align: center;
}
.concept-card__copy h3 {
margin-bottom: 5px;
font-size: 17px;
}
.concept-card__copy p {
margin-bottom: 0;
color: var(--muted);
font-size: 14px;
}
.concept-card--recommended {
border-color: color-mix(in srgb, var(--mint) 42%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mint) 12%, transparent);
}
.sidebar-preview {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 16px;
}
.sidebar-card {
width: 300px;
padding: 18px 0;
color: #0b2735;
background: #ffffff;
border: 1px solid rgba(12, 43, 58, 0.13);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 12px;
padding: 0 18px 18px;
border-bottom: 1px solid rgba(12, 43, 58, 0.1);
}
.sidebar-brand__mark {
width: 40px;
height: 40px;
flex: 0 0 auto;
}
.sidebar-brand__name {
font-size: 18px;
font-weight: 700;
line-height: 1.2;
}
.sidebar-brand__tagline {
color: #66808b;
font-size: 11px;
line-height: 1.35;
}
.sidebar-item {
display: flex;
align-items: center;
gap: 12px;
margin: 10px 12px 0;
padding: 10px 14px;
color: #2a4a57;
font-size: 14px;
border-radius: 8px;
}
.sidebar-item--active {
color: #168f75;
background: #e5f6f1;
font-weight: 600;
}
.sidebar-dot {
width: 17px;
height: 17px;
border: 2px solid currentColor;
border-radius: 5px;
opacity: 0.82;
}
.sidebar-note {
display: grid;
align-content: center;
padding: 34px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.sidebar-note h3 {
margin-bottom: 8px;
font-size: 21px;
}
.sidebar-note p {
margin-bottom: 0;
color: var(--muted);
}
.size-row {
display: flex;
align-items: end;
gap: 28px;
flex-wrap: wrap;
padding: 34px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.size-sample {
display: grid;
gap: 10px;
justify-items: center;
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
}
.size-sample svg {
width: var(--size);
height: var(--size);
}
.source {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 14px;
align-items: start;
padding: 18px;
background: #04101a;
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
pre {
max-height: 430px;
margin: 0;
overflow: auto;
color: #b9dbe7;
font-family: var(--mono);
font-size: 12px;
line-height: 1.6;
white-space: pre;
}
.footnote {
padding: 34px 0 56px;
color: var(--muted);
font-size: 13px;
}
@media (max-width: 980px) {
.hero,
.section__head,
.sidebar-preview {
grid-template-columns: 1fr;
}
.hero {
min-height: auto;
}
.fix-grid,
.concept-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 650px) {
.topbar {
align-items: flex-start;
flex-direction: column;
}
.topbar__actions {
width: 100%;
}
.control {
flex: 1 1 auto;
}
.stage {
min-height: 390px;
padding: 28px;
}
.fix-grid,
.concept-grid,
.compare {
grid-template-columns: 1fr;
}
.sidebar-card {
width: 100%;
}
.source {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<svg class="sprite" aria-hidden="true" focusable="false">
<defs>
<linearGradient id="v2-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0f506d"></stop>
<stop offset="1" stop-color="#061827"></stop>
</linearGradient>
<linearGradient id="v2-ink" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f6fffd"></stop>
<stop offset="1" stop-color="#c9f5ed"></stop>
</linearGradient>
<linearGradient id="v2-line" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#299cff"></stop>
<stop offset="1" stop-color="#68f3d2"></stop>
</linearGradient>
<symbol id="mark-v2" viewBox="0 0 96 96">
<rect x="4" y="4" width="88" height="88" rx="22" fill="url(#v2-bg)"></rect>
<rect x="4.8" y="4.8" width="86.4" height="86.4" rx="21.2" fill="none" stroke="rgba(242,255,252,.14)" stroke-width="1.6"></rect>
<path d="M17 62C29 52 37 68 47 58C57 48 66 52 79 43" stroke="url(#v2-line)" stroke-width="6.5" fill="none" stroke-linecap="round"></path>
<rect x="28" y="24" width="11" height="48" rx="5.5" fill="url(#v2-ink)"></rect>
<rect x="57" y="24" width="11" height="48" rx="5.5" fill="url(#v2-ink)"></rect>
</symbol>
<symbol id="mark-gate" viewBox="0 0 96 96">
<rect x="4" y="4" width="88" height="88" rx="22" fill="url(#v2-bg)"></rect>
<rect x="4.8" y="4.8" width="86.4" height="86.4" rx="21.2" fill="none" stroke="rgba(242,255,252,.14)" stroke-width="1.6"></rect>
<path d="M40 24C32 24 28 31 28 48C28 65 32 72 40 72" stroke="url(#v2-ink)" stroke-width="10" fill="none" stroke-linecap="round"></path>
<path d="M56 24C64 24 68 31 68 48C68 65 64 72 56 72" stroke="url(#v2-ink)" stroke-width="10" fill="none" stroke-linecap="round"></path>
<path d="M34 59C43 51 54 45 63 37" stroke="url(#v2-line)" stroke-width="7" fill="none" stroke-linecap="round"></path>
</symbol>
<symbol id="mark-lower" viewBox="0 0 96 96">
<rect x="4" y="4" width="88" height="88" rx="22" fill="url(#v2-bg)"></rect>
<rect x="4.8" y="4.8" width="86.4" height="86.4" rx="21.2" fill="none" stroke="rgba(242,255,252,.14)" stroke-width="1.6"></rect>
<path d="M31 23V73" stroke="url(#v2-ink)" stroke-width="11" stroke-linecap="round"></path>
<path d="M31 51C31 36 40 29 50 29C60 29 66 37 66 51V73" stroke="url(#v2-ink)" stroke-width="11" fill="none" stroke-linecap="round"></path>
<circle cx="49" cy="51" r="5" fill="#68f3d2"></circle>
<path d="M39 62C44 55 54 55 58 62" stroke="url(#v2-line)" stroke-width="5" fill="none" stroke-linecap="round"></path>
</symbol>
</defs>
</svg>
<header class="topbar">
<div class="topbar__brand">
<svg class="topbar__mark" viewBox="0 0 96 96" aria-label="Havline V2 标志">
<use href="#mark-v2"></use>
</svg>
<div class="topbar__title">
<strong>Havline V2</strong>
<span>Refined mark / harbor gate</span>
</div>
</div>
<div class="topbar__actions">
<button class="control" type="button" data-theme-set="dark" aria-pressed="true">深海</button>
<button class="control" type="button" data-theme-set="light" aria-pressed="false">白昼</button>
<button class="control control--primary" type="button" id="download-v2">下载 SVG</button>
</div>
</header>
<main>
<section class="wrap hero">
<div>
<p class="eyebrow">Logo evolution / 02</p>
<h1>Havline</h1>
<p class="lead">从“一个 H”变成“一个港湾入口”。</p>
<p class="copy">V2 保留 H 的识别骨架,但不再用两个端点圆点做连接。线路改为从 H 后方穿过的连续波浪,视觉上像一条服务路径穿过港湾入口,也更适合侧栏的小尺寸使用。</p>
<div class="inline-row">
<span class="pill">NO EYES / CONTINUOUS ROUTE</span>
<span class="pill">40PX SIDEBAR SAFE</span>
</div>
</div>
<div class="stage">
<div class="hero-lockup">
<svg viewBox="0 0 560 140" role="img" aria-labelledby="hero-v2-title hero-v2-desc">
<title id="hero-v2-title">Havline V2 横向标志</title>
<desc id="hero-v2-desc">左侧为 Havline V2 标志,右侧为 havline 字体组合。</desc>
<use href="#mark-v2" x="0" y="10" width="120" height="120"></use>
<text x="146" y="67" fill="currentColor" font-family="Segoe UI, Microsoft YaHei, sans-serif" font-size="49" font-weight="700" letter-spacing="0.4">havline</text>
<text x="148" y="94" fill="currentColor" font-family="SFMono-Regular, Consolas, monospace" font-size="12" letter-spacing="1.5" opacity="0.62">SAFE LINE FOR EVERY SERVICE</text>
<line x1="148" y1="110" x2="525" y2="110" stroke="currentColor" stroke-width="1" opacity="0.16"></line>
</svg>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<div class="section__head">
<h2>这版改了什么</h2>
<p>问题不在 H,而在连接方式。旧版的端点圆点在 40px 侧栏里会形成类似“眼睛”的视觉焦点,抢掉了 H 和线路的主次关系。V2 让线路后退,保留一个干净的入口。</p>
</div>
<div class="fix-grid">
<article class="fix-card">
<span class="fix-card__index">01 / REMOVE</span>
<h3>去掉端点圆点</h3>
<p>不再在左右立柱上放节点,避免小尺寸下像双眼或天线,让 H 的主体轮廓更干净。</p>
</article>
<article class="fix-card">
<span class="fix-card__index">02 / LAYER</span>
<h3>线路退到 H 后方</h3>
<p>波浪线路从双柱背后穿过,只在中间形成连接,保留“服务穿过港湾入口”的叙事。</p>
</article>
<article class="fix-card">
<span class="fix-card__index">03 / FLOW</span>
<h3>改用连续波浪</h3>
<p>斜线改成更平滑的水波路径,既有链路感,也接近港湾与水面意象。</p>
</article>
<article class="fix-card">
<span class="fix-card__index">04 / SIDEBAR</span>
<h3>为 40px 优化</h3>
<p>主结构只保留两根立柱和一条线路,缩放后仍能读出 H,不依赖额外细节。</p>
</article>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<div class="section__head">
<h2>前后对比</h2>
<p>左侧是上一版,右侧是 V2。对比重点不是“更复杂”,而是让标志在侧栏尺寸下更稳定、更耐看。</p>
</div>
<div class="compare">
<article class="compare-card">
<svg class="mark-large" viewBox="0 0 96 96" aria-label="旧版 Havline 标志">
<rect x="4" y="4" width="88" height="88" rx="22" fill="url(#v2-bg)"></rect>
<rect x="4.8" y="4.8" width="86.4" height="86.4" rx="21.2" fill="none" stroke="rgba(242,255,252,.13)" stroke-width="1.6"></rect>
<path d="M31 25.5V70.5" stroke="#f2fffc" stroke-width="11" stroke-linecap="round"></path>
<path d="M65 25.5V70.5" stroke="#f2fffc" stroke-width="11" stroke-linecap="round"></path>
<path d="M31 59C39 59 42.5 49 49.5 49C56.5 49 57.5 37 65 37" stroke="url(#v2-line)" stroke-width="7" fill="none" stroke-linecap="round" stroke-linejoin="round"></path>
<circle cx="31" cy="59" r="5" fill="#2fa9ff" stroke="#061827" stroke-width="2.2"></circle>
<circle cx="65" cy="37" r="5" fill="#63f0d0" stroke="#061827" stroke-width="2.2"></circle>
</svg>
<span class="compare-card__label">V1 / endpoint nodes</span>
</article>
<article class="compare-card compare-card--dark">
<svg class="mark-large" viewBox="0 0 96 96" aria-label="新版 Havline V2 标志">
<use href="#mark-v2"></use>
</svg>
<span class="compare-card__label">V2 / continuous route</span>
</article>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<div class="section__head">
<h2>三个方向</h2>
<p>推荐主案是左侧的“连续路线 H”。另外两个方向可以用于周边、活动或后续品牌迭代,不适合直接替代主标志。</p>
</div>
<div class="concept-grid">
<article class="concept-card concept-card--recommended">
<svg class="concept-card__mark" viewBox="0 0 96 96" aria-label="连续路线 H">
<use href="#mark-v2"></use>
</svg>
<div class="concept-card__copy">
<h3>连续路线 H</h3>
<p>推荐主案</p>
</div>
</article>
<article class="concept-card">
<svg class="concept-card__mark" viewBox="0 0 96 96" aria-label="港湾拱门">
<use href="#mark-gate"></use>
</svg>
<div class="concept-card__copy">
<h3>港湾拱门</h3>
<p>更抽象、画面感更强</p>
</div>
</article>
<article class="concept-card">
<svg class="concept-card__mark" viewBox="0 0 96 96" aria-label="小写 h 路线">
<use href="#mark-lower"></use>
</svg>
<div class="concept-card__copy">
<h3>小写 h 路线</h3>
<p>更亲和、更偏产品化</p>
</div>
</article>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<div class="section__head">
<h2>侧栏模拟</h2>
<p>在当前的 40px 品牌区里,V2 不再出现两个高亮圆点,视觉重心重新回到 H 和品牌名称上。</p>
</div>
<div class="sidebar-preview">
<div class="sidebar-card">
<div class="sidebar-brand">
<svg class="sidebar-brand__mark" viewBox="0 0 96 96" aria-label="Havline V2 侧栏标志">
<use href="#mark-v2"></use>
</svg>
<div>
<div class="sidebar-brand__name">Havline</div>
<div class="sidebar-brand__tagline">让 NAS 访问更简单</div>
</div>
</div>
<div class="sidebar-item sidebar-item--active">
<span class="sidebar-dot"></span>
仪表盘
</div>
<div class="sidebar-item">
<span class="sidebar-dot"></span>
DDNS
</div>
<div class="sidebar-item">
<span class="sidebar-dot"></span>
证书管理
</div>
<div class="sidebar-item">
<span class="sidebar-dot"></span>
反向代理
</div>
</div>
<div class="sidebar-note">
<h3>更像“连接”,不像“表情”</h3>
<p>旧版的两个端点圆点在侧栏压缩后过于醒目。V2 取消了端点节点,把线路收进 H 的结构里,因此远看是一个稳定的入口符号,近看才读到服务路径。</p>
</div>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<div class="section__head">
<h2>尺寸检查</h2>
<p>主标志建议最小使用 16px。侧栏品牌区 40px,应用图标 64px,登录页主标志 120px 以上。</p>
</div>
<div class="size-row">
<span class="size-sample" style="--size:16px">
<svg viewBox="0 0 96 96"><use href="#mark-v2"></use></svg>
16
</span>
<span class="size-sample" style="--size:24px">
<svg viewBox="0 0 96 96"><use href="#mark-v2"></use></svg>
24
</span>
<span class="size-sample" style="--size:40px">
<svg viewBox="0 0 96 96"><use href="#mark-v2"></use></svg>
40
</span>
<span class="size-sample" style="--size:64px">
<svg viewBox="0 0 96 96"><use href="#mark-v2"></use></svg>
64
</span>
<span class="size-sample" style="--size:96px">
<svg viewBox="0 0 96 96"><use href="#mark-v2"></use></svg>
96
</span>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<div class="section__head">
<h2>SVG 源文件</h2>
<p>这是 V2 主案的可下载 SVG,后续可以直接替换侧栏中的现有标志,不需要再调整图片尺寸。</p>
</div>
<div class="source">
<pre id="v2-code"></pre>
<div class="inline-row">
<button class="control control--primary" type="button" id="copy-v2">复制 SVG</button>
<button class="control" type="button" id="download-v2-2">下载 SVG</button>
</div>
</div>
</div>
</section>
</main>
<footer class="wrap footnote">
Havline V2 · 主案:连续路线 H · 设计目标:更干净、更有港湾入口感、在 40px 侧栏里不产生“表情”错觉。
</footer>
<script>
const root = document.documentElement;
const themeButtons = document.querySelectorAll("[data-theme-set]");
function setTheme(theme) {
root.dataset.theme = theme;
themeButtons.forEach((button) => {
button.setAttribute("aria-pressed", String(button.dataset.themeSet === theme));
});
}
themeButtons.forEach((button) => {
button.addEventListener("click", () => setTheme(button.dataset.themeSet));
});
const exportSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" role="img" aria-label="Havline logo v2">
<defs>
<linearGradient id="havline-v2-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0f506d"/>
<stop offset="1" stop-color="#061827"/>
</linearGradient>
<linearGradient id="havline-v2-ink" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f6fffd"/>
<stop offset="1" stop-color="#c9f5ed"/>
</linearGradient>
<linearGradient id="havline-v2-line" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#299cff"/>
<stop offset="1" stop-color="#68f3d2"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="88" height="88" rx="22" fill="url(#havline-v2-bg)"/>
<rect x="4.8" y="4.8" width="86.4" height="86.4" rx="21.2" fill="none" stroke="rgba(242,255,252,.14)" stroke-width="1.6"/>
<path d="M17 62C29 52 37 68 47 58C57 48 66 52 79 43" stroke="url(#havline-v2-line)" stroke-width="6.5" fill="none" stroke-linecap="round"/>
<rect x="28" y="24" width="11" height="48" rx="5.5" fill="url(#havline-v2-ink)"/>
<rect x="57" y="24" width="11" height="48" rx="5.5" fill="url(#havline-v2-ink)"/>
</svg>`;
document.getElementById("v2-code").textContent = exportSvg;
function downloadV2() {
const blob = new Blob([exportSvg], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "havline-logo-v2.svg";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
document.getElementById("download-v2").addEventListener("click", downloadV2);
document.getElementById("download-v2-2").addEventListener("click", downloadV2);
document.getElementById("copy-v2").addEventListener("click", async (event) => {
try {
await navigator.clipboard.writeText(exportSvg);
event.currentTarget.textContent = "已复制";
setTimeout(() => {
event.currentTarget.textContent = "复制 SVG";
}, 1400);
} catch {
event.currentTarget.textContent = "复制失败";
setTimeout(() => {
event.currentTarget.textContent = "复制 SVG";
}, 1400);
}
});
</script>
</body>
</html>
+10 -10
View File
@@ -16,12 +16,12 @@
| 框架 | Vue 3 + `<script setup lang="ts">` + Vite 6 | | 框架 | Vue 3 + `<script setup lang="ts">` + Vite 6 |
| 组件库 | **naive-ui**(`n-select` `n-button` `n-modal` `n-form` `n-switch` `n-input` `n-input-number` `n-tag`) | | 组件库 | **naive-ui**(`n-select` `n-button` `n-modal` `n-form` `n-switch` `n-input` `n-input-number` `n-tag`) |
| 图标 | `@vicons/ionicons5`(本页目前未用图标) | | 图标 | `@vicons/ionicons5`(本页目前未用图标) |
| 令牌系统 | `web/src/styles/variables.css`:`:root` + `html.dark` 双主题,`--fonu-*` 前缀 | | 令牌系统 | `web/src/styles/variables.css`:`:root` + `html.dark` 双主题,`--havline-*` 前缀 |
| 共享组件 | `PageHeader` `FonuCard` `LoadError` `EmptyState` `StatusBadge`(本页**未用** `StatusBadge`) | | 共享组件 | `PageHeader` `HavlineCard` `LoadError` `EmptyState` `StatusBadge`(本页**未用** `StatusBadge`) |
| 工具 | `utils/format.ts`(已用 `formatBytes` / `formatRate`) | | 工具 | `utils/format.ts`(已用 `formatBytes` / `formatRate`) |
| 同族页面 | `ProxyView`(本地反代,交互参照)、`AgentView`(已按 v2 重构,同为公网侧) | | 同族页面 | `ProxyView`(本地反代,交互参照)、`AgentView`(已按 v2 重构,同为公网侧) |
**结论**:沿用既有体系(naive-ui + `--fonu-*` + 共享组件),**不引入**第二套框架/令牌。 **结论**:沿用既有体系(naive-ui + `--havline-*` + 共享组件),**不引入**第二套框架/令牌。
--- ---
@@ -35,7 +35,7 @@
| 2 | **没有统计区** | 全页无 `.stats-row` / `.stat-card`,而数据齐全(规则数、已部署数、健康通过数、流量、连接数) | 与所有标准页(Dashboard/Ddns/Certificates/Proxy)不一致,一眼看不到全局 | | 2 | **没有统计区** | 全页无 `.stats-row` / `.stat-card`,而数据齐全(规则数、已部署数、健康通过数、流量、连接数) | 与所有标准页(Dashboard/Ddns/Certificates/Proxy)不一致,一眼看不到全局 |
| 3 | **硬编码颜色 6 处** | `#f0c36d`、`#e79b9b`、`#dc2626`、`#16a34a`、`rgba(240,195,109,.12)`、`rgba(34,197,94,.12)` | 违反令牌规则;暗色主题下对比度未验证 | | 3 | **硬编码颜色 6 处** | `#f0c36d`、`#e79b9b`、`#dc2626`、`#16a34a`、`rgba(240,195,109,.12)`、`rgba(34,197,94,.12)` | 违反令牌规则;暗色主题下对比度未验证 |
| 4 | **自造状态组件** | `nginxState()` 返回自造 `n-tag`;`healthBadges()` 自造 `.health-badge/.health-ok/.health-bad` | 与 `StatusBadge` + `utils/status.ts` 语义分叉,同类状态在不同页面长得不一样 | | 4 | **自造状态组件** | `nginxState()` 返回自造 `n-tag`;`healthBadges()` 自造 `.health-badge/.health-ok/.health-bad` | 与 `StatusBadge` + `utils/status.ts` 语义分叉,同类状态在不同页面长得不一样 |
| 5 | **「使用说明」整卡占位** | 一整张 `FonuCard` 只有一段 `form-hint` | 占掉整行网格,信息密度低(`AgentView` 已把这类内容改成 `n-alert` + 入口行) | | 5 | **「使用说明」整卡占位** | 一整张 `HavlineCard` 只有一段 `form-hint` | 占掉整行网格,信息密度低(`AgentView` 已把这类内容改成 `n-alert` + 入口行) |
| 6 | **空态与折叠区未令牌化** | 自造 `.empty-route` div;`<details class="raw-routes">` 原生折叠 | 空态应为 `EmptyState`;原生折叠样式不随主题 | | 6 | **空态与折叠区未令牌化** | 自造 `.empty-route` div;`<details class="raw-routes">` 原生折叠 | 空态应为 `EmptyState`;原生折叠样式不随主题 |
| 7 | **loading 粒度粗** | 单个 `refreshing` 同时驱动「刷新」「重新检测」与轮询 | 轮询时按钮闪 loading;无法区分"在部署"与"在刷新" | | 7 | **loading 粒度粗** | 单个 `refreshing` 同时驱动「刷新」「重新检测」与轮询 | 轮询时按钮闪 loading;无法区分"在部署"与"在刷新" |
| 8 | **切换服务端不重置** | `watch(selectedId)` 不存在 | 上一台的健康/流量/agent 状态残留在新服务端视图里(串台) | | 8 | **切换服务端不重置** | `watch(selectedId)` 不存在 | 上一台的健康/流量/agent 状态残留在新服务端视图里(串台) |
@@ -95,11 +95,11 @@
- **Layout**:状态提示 → 统计区(4 列)→ 环境与 Nginx(2 列)→ 规则台账(整宽)→ 入口行;左对齐,无居中、无渐变。 - **Layout**:状态提示 → 统计区(4 列)→ 环境与 Nginx(2 列)→ 规则台账(整宽)→ 入口行;左对齐,无居中、无渐变。
- **Signature**:**规则行左侧的「四段健康徽标串」**(DNS · 证书 · 隧道 · 服务),一眼扫完所有规则的健康。 - **Signature**:**规则行左侧的「四段健康徽标串」**(DNS · 证书 · 隧道 · 服务),一眼扫完所有规则的健康。
**Color**:沿用 5 个语义令牌(同 `AgentView` v2):`--fonu-brand` 正常 / `--fonu-warning` 待处理 / `--fonu-error` 需人工干预 / 中性 `--fonu-text-*` / `--fonu-info` 引导。**页面零硬编码色值**(现状 6 处全部替换:`#16a34a`→`--fonu-brand`、`#dc2626`→`--fonu-error`、`#f0c36d`→`--fonu-warning`、`#e79b9b`→`--fonu-error`、两处 `rgba(...)`→`--fonu-brand-soft` / `--fonu-warning` 的 soft 变体或 `--fonu-bg-muted`)。 **Color**:沿用 5 个语义令牌(同 `AgentView` v2):`--havline-brand` 正常 / `--havline-warning` 待处理 / `--havline-error` 需人工干预 / 中性 `--havline-text-*` / `--havline-info` 引导。**页面零硬编码色值**(现状 6 处全部替换:`#16a34a`→`--havline-brand`、`#dc2626`→`--havline-error`、`#f0c36d`→`--havline-warning`、`#e79b9b`→`--havline-error`、两处 `rgba(...)`→`--havline-brand-soft` / `--havline-warning` 的 soft 变体或 `--havline-bg-muted`)。
**Type**:沿用 `--fonu-font` / `--fonu-mono`;页内 4 档字号(12 / 12.5 / 13 / 18-30)+ 3 档字重,与 `CertificatesView` 统计卡规格一致。 **Type**:沿用 `--havline-font` / `--havline-mono`;页内 4 档字号(12 / 12.5 / 13 / 18-30)+ 3 档字重,与 `CertificatesView` 统计卡规格一致。
**Spacing & Radius**:只用 `--fonu-space-1…6` 与 `--fonu-radius(-sm)`、`--fonu-shadow`;`style="width: 220px"` → `.proxy-remote-select`。 **Spacing & Radius**:只用 `--havline-space-1…6` 与 `--havline-radius(-sm)`、`--havline-shadow`;`style="width: 220px"` → `.proxy-remote-select`。
--- ---
@@ -187,10 +187,10 @@ timer = window.setInterval(() => {
## 十、验收清单 ## 十、验收清单
- [ ] 复用项目真实栈与令牌(naive-ui + `--fonu-*` + `PageHeader/FonuCard/LoadError/EmptyState/StatusBadge`),未新增第二套 - [ ] 复用项目真实栈与令牌(naive-ui + `--havline-*` + `PageHeader/HavlineCard/LoadError/EmptyState/StatusBadge`),未新增第二套
- [ ] 单一设计方向(部署台账)落地;零硬编码色值;`style="width: …"` 清零 - [ ] 单一设计方向(部署台账)落地;零硬编码色值;`style="width: …"` 清零
- [ ] 交互状态齐全:hover / pressed / focused(键盘实测)/ disabled / loading / empty / error - [ ] 交互状态齐全:hover / pressed / focused(键盘实测)/ disabled / loading / empty / error
- [ ] 无障碍:对比度 AA(亮+暗,重点验 `--fonu-brand` / `--fonu-warning` / `--fonu-error` 在 `--fonu-surface` 上)、键盘可达全部动作、装饰图标 `aria-hidden`、健康徽标有可读文本 - [ ] 无障碍:对比度 AA(亮+暗,重点验 `--havline-brand` / `--havline-warning` / `--havline-error` 在 `--havline-surface` 上)、键盘可达全部动作、装饰图标 `aria-hidden`、健康徽标有可读文本
- [ ] 最窄 320px 无横向溢出;≤760px 规则行纵向堆叠、动作按钮铺满且 ≥44px - [ ] 最窄 320px 无横向溢出;≤760px 规则行纵向堆叠、动作按钮铺满且 ≥44px
- [ ] **功能可见性:对照第三节 A–Q,每项有明确位置;#1 错误可见、#8 切换不串台、#7 loading 分粒度 三项缺陷已修** - [ ] **功能可见性:对照第三节 A–Q,每项有明确位置;#1 错误可见、#8 切换不串台、#7 loading 分粒度 三项缺陷已修**
- [ ] 回归:部署/移除/保存部署设置三条路径真机验证;`vue-tsc` 0 错误 + `vite build` 成功 - [ ] 回归:部署/移除/保存部署设置三条路径真机验证;`vue-tsc` 0 错误 + `vite build` 成功
@@ -210,4 +210,4 @@ timer = window.setInterval(() => {
合计约 **5.5–6 小时**,单文件改动(`ProxyRemoteView.vue`),**不涉及后端与 agent**。 合计约 **5.5–6 小时**,单文件改动(`ProxyRemoteView.vue`),**不涉及后端与 agent**。
能力缺口 R(批量部署)与 S(配置回滚)不在本轮:R 可后续单独做(纯前端循环 + 进度),S 需 agent 侧新增历史接口。 能力缺口 R(批量部署)与 S(配置回滚)不在本轮:R 可后续单独做(纯前端循环 + 进度),S 需 agent 侧新增历史接口。
验证:`npx vue-tsc --noEmit` + `npx vite build`,再同步 `web/dist → cmd/fonu/web/dist` 后 `go build ./...`。 验证:`npx vue-tsc --noEmit` + `npx vite build`,再同步 `web/dist → cmd/havline/web/dist` 后 `go build ./...`。
+38 -38
View File
@@ -1,4 +1,4 @@
# Fonu Web 一键升级方案 # Havline Web 一键升级方案
> 实施版本:v1.3.0 > 实施版本:v1.3.0
> 适用范围:`docker-compose.yml`、`docker-compose.hub.yml` > 适用范围:`docker-compose.yml`、`docker-compose.hub.yml`
@@ -9,8 +9,8 @@
在管理后台检测到新版本后,用户点击「一键升级」即可完成: 在管理后台检测到新版本后,用户点击「一键升级」即可完成:
1. 读取远端最新版本; 1. 读取远端最新版本;
2. 构建新的 `fonu` 镜像; 2. 构建新的 `havline` 镜像;
3. 只重建 `fonu` 容器; 3. 只重建 `havline` 容器;
4. 等待 `/api/version` 返回目标版本; 4. 等待 `/api/version` 返回目标版本;
5. 失败时尝试回滚到升级前的镜像 tag; 5. 失败时尝试回滚到升级前的镜像 tag;
6. 把升级过程和结果返回给前端。 6. 把升级过程和结果返回给前端。
@@ -19,7 +19,7 @@
主程序容器**不挂载** `/var/run/docker.sock`。 主程序容器**不挂载** `/var/run/docker.sock`。
只有 `fonu-updater` sidecar 持有: 只有 `havline-updater` sidecar 持有:
- Docker Socket; - Docker Socket;
- Docker CLI 与 Compose; - Docker CLI 与 Compose;
@@ -30,16 +30,16 @@
```text ```text
浏览器 浏览器
→ fonu 后端 /api/system/update/* → havline 后端 /api/system/update/*
→ /run/fonu-updater/updater.sock → /run/havline-updater/updater.sock
→ fonu-updater → havline-updater
→ docker compose build/up → docker compose build/up
``` ```
这表示: 这表示:
- 管理后台被攻破时,攻击者仍需通过 sidecar 的固定接口才能触发升级; - 管理后台被攻破时,攻击者仍需通过 sidecar 的固定接口才能触发升级;
- sidecar 只接受固定的 Compose 文件名和固定的 `fonu` 服务名; - sidecar 只接受固定的 Compose 文件名和固定的 `havline` 服务名;
- 不提供任意命令执行接口; - 不提供任意命令执行接口;
- 不支持自定义 shell 命令。 - 不支持自定义 shell 命令。
@@ -53,13 +53,13 @@
两种模式都会: 两种模式都会:
- 读取目标版本; - 读取目标版本;
- 构建 `fonu` 镜像; - 构建 `havline` 镜像;
- 执行 `docker compose up -d --no-deps --force-recreate fonu`; - 执行 `docker compose up -d --no-deps --force-recreate havline`;
- 轮询 `/api/version` 验证升级结果。 - 轮询 `/api/version` 验证升级结果。
## 4. 首次部署 ## 4. 首次部署
一键升级依赖 `fonu-updater` 已经运行。 一键升级依赖 `havline-updater` 已经运行。
旧版本升级到 v1.3.0 时,需要先手动执行一次: 旧版本升级到 v1.3.0 时,需要先手动执行一次:
@@ -79,22 +79,22 @@ docker compose -f docker-compose.hub.yml up -d --build
| 变量 | 默认值 | 说明 | | 变量 | 默认值 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `FONU_UPDATER_SOCKET` | `/run/fonu-updater/updater.sock` | 与 sidecar 通信的 Unix Socket | | `HAVLINE_UPDATER_SOCKET` | `/run/havline-updater/updater.sock` | 与 sidecar 通信的 Unix Socket |
### updater sidecar ### updater sidecar
| 变量 | 说明 | | 变量 | 说明 |
| --- | --- | | --- | --- |
| `FONU_UPDATER_SOCKET` | sidecar 监听的 Unix Socket | | `HAVLINE_UPDATER_SOCKET` | sidecar 监听的 Unix Socket |
| `FONU_UPDATE_PROJECT_DIR` | Compose 项目目录,容器内为 `/workspace` | | `HAVLINE_UPDATE_PROJECT_DIR` | Compose 项目目录,容器内为 `/workspace` |
| `FONU_UPDATE_COMPOSE_FILE` | 只允许 `docker-compose.yml`、`docker-compose.hub.yml`、`docker-compose.host.yml` | | `HAVLINE_UPDATE_COMPOSE_FILE` | 只允许 `docker-compose.yml`、`docker-compose.hub.yml`、`docker-compose.host.yml` |
| `FONU_UPDATE_SERVICE` | 要重建的服务,默认 `fonu` | | `HAVLINE_UPDATE_SERVICE` | 要重建的服务,默认 `havline` |
| `FONU_UPDATE_MODE` | `local` 或 `git` | | `HAVLINE_UPDATE_MODE` | `local` 或 `git` |
| `FONU_UPDATE_REPO` | `git` 模式下的仓库地址 | | `HAVLINE_UPDATE_REPO` | `git` 模式下的仓库地址 |
| `FONU_UPDATE_BRANCH` | `git` 模式下的分支,默认 `main` | | `HAVLINE_UPDATE_BRANCH` | `git` 模式下的分支,默认 `main` |
| `FONU_UPDATE_APP_URL` | 健康检查地址,默认 `http://fonu:6893` | | `HAVLINE_UPDATE_APP_URL` | 健康检查地址,默认 `http://havline:6893` |
| `DOCKER_SOCK` | 宿主机 Docker Socket,默认 `/var/run/docker.sock` | | `DOCKER_SOCK` | 宿主机 Docker Socket,默认 `/var/run/docker.sock` |
| `FONU_UPDATE_HOST_DIR` | 宿主机上 Compose 文件所在目录;留空时自动从 Compose 容器标签发现,设置后作为显式覆盖 | | `HAVLINE_UPDATE_HOST_DIR` | 宿主机上 Compose 文件所在目录;留空时自动从 Compose 容器标签发现,设置后作为显式覆盖 |
## 6. 前端行为 ## 6. 前端行为
@@ -119,7 +119,7 @@ docker compose -f docker-compose.hub.yml up -d --build
升级前会记录当前容器镜像。升级后健康检查超时,sidecar 会尝试: 升级前会记录当前容器镜像。升级后健康检查超时,sidecar 会尝试:
```bash ```bash
FONU_VERSION=<上一个版本> docker compose up -d --no-deps --force-recreate fonu HAVLINE_VERSION=<上一个版本> docker compose up -d --no-deps --force-recreate havline
``` ```
回滚是否成功会写入升级日志。 回滚是否成功会写入升级日志。
@@ -131,9 +131,9 @@ FONU_VERSION=<上一个版本> docker compose up -d --no-deps --force-recreate f
检查: 检查:
1. 当前是否使用 `docker-compose.yml` 或 `docker-compose.hub.yml`; 1. 当前是否使用 `docker-compose.yml` 或 `docker-compose.hub.yml`;
2. `fonu-updater` 容器是否在运行; 2. `havline-updater` 容器是否在运行;
3. `FONU_UPDATER_SOCKET` 是否在两侧指向同一路径; 3. `HAVLINE_UPDATER_SOCKET` 是否在两侧指向同一路径;
4. 共享卷 `fonu-updater-sock` 是否存在。 4. 共享卷 `havline-updater-sock` 是否存在。
如果状态接口返回: 如果状态接口返回:
@@ -160,18 +160,18 @@ docker compose up -d --build
### 升级时报 `/workspace/docker-compose.hub.yml: no such file or directory` ### 升级时报 `/workspace/docker-compose.hub.yml: no such file or directory`
v1.3.6 起 updater 会自动从 `fonu` 容器的 Compose 标签读取宿主机目录,并通过 helper 容器执行 Compose。如果自动发现仍失败,可以显式设置目录: v1.3.6 起 updater 会自动从 `havline` 容器的 Compose 标签读取宿主机目录,并通过 helper 容器执行 Compose。如果自动发现仍失败,可以显式设置目录:
先查询运行中 `fonu` 容器的 Compose 工作目录: 先查询运行中 `havline` 容器的 Compose 工作目录:
```bash ```bash
docker inspect fonu --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' docker inspect havline --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}'
``` ```
把输出目录写入 Compose 同层 `.env` 作为覆盖: 把输出目录写入 Compose 同层 `.env` 作为覆盖:
```dotenv ```dotenv
FONU_UPDATE_HOST_DIR=/实际/的/Compose/目录 HAVLINE_UPDATE_HOST_DIR=/实际/的/Compose/目录
``` ```
然后只重建 updater: 然后只重建 updater:
@@ -182,11 +182,11 @@ docker compose -f /实际/的/Compose/目录/docker-compose.hub.yml up -d --buil
状态接口会明确提示 Compose 文件缺失,修复挂载或设置覆盖后按钮才会重新显示。 状态接口会明确提示 Compose 文件缺失,修复挂载或设置覆盖后按钮才会重新显示。
### 报 `container name "/fonu" is already in use` ### 报 `container name "/havline" is already in use`
这表示 Compose 没有识别到当前容器所属的原项目,可能把升级当成新项目并尝试新建网络与数据卷。v1.3.7 的更新版 updater 会读取现有容器的 `com.docker.compose.project` 标签,并显式复用原项目名。 这表示 Compose 没有识别到当前容器所属的原项目,可能把升级当成新项目并尝试新建网络与数据卷。v1.3.7 的更新版 updater 会读取现有容器的 `com.docker.compose.project` 标签,并显式复用原项目名。
如果现有 `fonu` 容器不是由 Compose 管理,updater 会拒绝升级,避免新建 `fonu_fonu-data` 等空数据卷。应先按 Compose 方式重新部署,再使用一键升级。 如果现有 `havline` 容器不是由 Compose 管理,updater 会拒绝升级,避免新建 `havline_havline-data` 等空数据卷。应先按 Compose 方式重新部署,再使用一键升级。
### helper 命令里出现 `-v /workspace:/workspace` ### helper 命令里出现 `-v /workspace:/workspace`
@@ -197,7 +197,7 @@ docker compose -f /实际/的/Compose/目录/docker-compose.hub.yml up -d --buil
检查 sidecar 容器内是否可以访问 Git 仓库: 检查 sidecar 容器内是否可以访问 Git 仓库:
```bash ```bash
docker exec -it fonu-updater sh -lc 'git ls-remote http://45.207.223.190:10082/mrlee/-Fonu.git main' docker exec -it havline-updater sh -lc 'git ls-remote http://45.207.223.190:10082/mrlee/Havline.git main'
``` ```
### 升级卡在 `running` ### 升级卡在 `running`
@@ -205,14 +205,14 @@ docker exec -it fonu-updater sh -lc 'git ls-remote http://45.207.223.190:10082/m
查看 sidecar 日志: 查看 sidecar 日志:
```bash ```bash
docker logs -f fonu-updater docker logs -f havline-updater
``` ```
以及 Compose 输出: 以及 Compose 输出:
```bash ```bash
docker compose ps docker compose ps
docker compose logs --tail 100 fonu docker compose logs --tail 100 havline
``` ```
### 回滚失败 ### 回滚失败
@@ -220,13 +220,13 @@ docker compose logs --tail 100 fonu
确认升级前的镜像 tag 仍然存在: 确认升级前的镜像 tag 仍然存在:
```bash ```bash
docker images fonu docker images havline
``` ```
然后用明确的版本 tag 手动切回: 然后用明确的版本 tag 手动切回:
```bash ```bash
FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu HAVLINE_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate havline
``` ```
## 9. 暂不支持 ## 9. 暂不支持
@@ -234,13 +234,13 @@ FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu
- `docker-compose.host.yml`:需要确认 host 网络与端口占用后再设计; - `docker-compose.host.yml`:需要确认 host 网络与端口占用后再设计;
- 原生 systemd 安装:需要单独的受控升级 helper; - 原生 systemd 安装:需要单独的受控升级 helper;
- 回滚到远端不存在的镜像:需要额外发布流程; - 回滚到远端不存在的镜像:需要额外发布流程;
- 升级 `fonu-updater` 自身:当前只重建 `fonu` 服务,sidecar 更新仍需一次手动 Compose 操作。 - 升级 `havline-updater` 自身:当前只重建 `havline` 服务,sidecar 更新仍需一次手动 Compose 操作。
## 10. 在线升级验证 ## 10. 在线升级验证
示例页面用于验证升级结果。v1.9.0 的验证流程: 示例页面用于验证升级结果。v1.9.0 的验证流程:
1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。 1. 确认当前线上实例为 v1.3.0,且 `havline-updater` sidecar 已运行。
2. 确认远端 `main` 分支的 `VERSION` 为 `1.9.0`。 2. 确认远端 `main` 分支的 `VERSION` 为 `1.9.0`。
3. 在侧栏看到「一键升级」后点击并确认。 3. 在侧栏看到「一键升级」后点击并确认。
4. 等待升级弹窗显示成功,页面自动刷新。 4. 等待升级弹窗显示成功,页面自动刷新。
+5 -5
View File
@@ -1,4 +1,4 @@
# 上游同步方案:Fonu 1.0.2 → 1.0.5 对照与可借鉴项 # 上游同步方案:Havline 1.0.2 → 1.0.5 对照与可借鉴项
> **时效说明(2026-09-24)**:文中对 `/frp` 页现状的描述基于 2026-09-23,已被 v1.2.6 的重构推翻(现为**左服务端列表 + 右规则表格**,服务端增删改已并入 `/frp-agent`)。 > **时效说明(2026-09-24)**:文中对 `/frp` 页现状的描述基于 2026-09-23,已被 v1.2.6 的重构推翻(现为**左服务端列表 + 右规则表格**,服务端增删改已并入 `/frp-agent`)。
@@ -6,8 +6,8 @@
> 本文逐版本列出上游 1.0.3 / 1.0.4 / 1.0.5 的全部变更,并逐条核对**本项目代码现状**, > 本文逐版本列出上游 1.0.3 / 1.0.4 / 1.0.5 的全部变更,并逐条核对**本项目代码现状**,
> 给出「同步 / 已具备 / 不适用 / 待核对」的判定与落地建议。 > 给出「同步 / 已具备 / 不适用 / 待核对」的判定与落地建议。
> >
> 上游来源:`https://github.com/O96u/Fonu`(CHANGELOG.md + 提交记录) > 上游来源:`https://github.com/O96u/Havline`(CHANGELOG.md + 提交记录)
> 核对方式:对本仓库 `f:\Fonu-main\Fonu-1.0.2` 做 grep / 文件树核对(证据写在每条的「本项目现状」里) > 核对方式:对本仓库 `f:\Havline-main\Havline-1.0.2` 做 grep / 文件树核对(证据写在每条的「本项目现状」里)
> 更新日期:2026-09-23 > 更新日期:2026-09-23
--- ---
@@ -91,7 +91,7 @@
`AgentView.vue`(1 处,「配置查看」弹窗的复制)、 `AgentView.vue`(1 处,「配置查看」弹窗的复制)、
`DdnsView.vue`(1 处)、`ProxyView.vue`(1 处)、 `DdnsView.vue`(1 处)、`ProxyView.vue`(1 处)、
归档文件 `FrpView-1.0.2.vue`(1 处,已弃用代码,可不动)。 归档文件 `FrpView-1.0.2.vue`(1 处,已弃用代码,可不动)。
- 影响:Fonu 常用 `http://内网IP:6893` 访问,属**非安全上下文**,此时 `navigator.clipboard` 为 `undefined` → 复制功能直接失败(本项目现在只弹「复制失败」提示,用户无从下手)。 - 影响:Havline 常用 `http://内网IP:6893` 访问,属**非安全上下文**,此时 `navigator.clipboard` 为 `undefined` → 复制功能直接失败(本项目现在只弹「复制失败」提示,用户无从下手)。
- 建议:**P0 同步**。做法:新增 `web/src/utils/clipboard.ts`: - 建议:**P0 同步**。做法:新增 `web/src/utils/clipboard.ts`:
```ts ```ts
export async function copyText(text: string): Promise<boolean> { export async function copyText(text: string): Promise<boolean> {
@@ -309,7 +309,7 @@ Nginx 的冲突规则是「**同端口 + 同 `server_name`**」:此时只在 r
1. **不按「同端口」判冲突**:同端口不同域名是合法虚拟主机,按端口报错会让「一个端口挂多个站点」 1. **不按「同端口」判冲突**:同端口不同域名是合法虚拟主机,按端口报错会让「一个端口挂多个站点」
这种最常见的用法无法保存。 这种最常见的用法无法保存。
2. **不探测系统层面的端口占用**(`net.Listen` 试占、`ss -ltn`):Fonu 自己就是占用方(Nginx 监听 80/443), 2. **不探测系统层面的端口占用**(`net.Listen` 试占、`ss -ltn`):Havline 自己就是占用方(Nginx 监听 80/443),
且容器与宿主机视角不同,误报率极高。上游所指也是 Nginx 配置内的冲突,而非系统端口。 且容器与宿主机视角不同,误报率极高。上游所指也是 Nginx 配置内的冲突,而非系统端口。
### 8.6 验收标准 ### 8.6 验收标准
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/fonu/fonu module github.com/havline/havline
go 1.23.0 go 1.23.0
Binary file not shown.
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/go-acme/lego/v4/providers/dns/dnspod" "github.com/go-acme/lego/v4/providers/dns/dnspod"
"github.com/go-acme/lego/v4/providers/dns/tencentcloud" "github.com/go-acme/lego/v4/providers/dns/tencentcloud"
"github.com/fonu/fonu/internal/ddns" "github.com/havline/havline/internal/ddns"
) )
func newDNS01Provider(provider string, cred ddns.Credentials) (challenge.Provider, error) { func newDNS01Provider(provider string, cred ddns.Credentials) (challenge.Provider, error) {
+1 -1
View File
@@ -3,7 +3,7 @@ package acme
import ( import (
"testing" "testing"
"github.com/fonu/fonu/internal/ddns" "github.com/havline/havline/internal/ddns"
) )
func TestValidateDNSCredentialsVolcengine(t *testing.T) { func TestValidateDNSCredentialsVolcengine(t *testing.T) {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/fonu/fonu/internal/validate" "github.com/havline/havline/internal/validate"
) )
func NormalizeCertDomains(raw []string) ([]string, error) { func NormalizeCertDomains(raw []string) ([]string, error) {
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
certstore "github.com/fonu/fonu/internal/certificate" certstore "github.com/havline/havline/internal/certificate"
) )
type DownloadPart string type DownloadPart string
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"sync" "sync"
"time" "time"
certstore "github.com/fonu/fonu/internal/certificate" certstore "github.com/havline/havline/internal/certificate"
) )
type JobEvent struct { type JobEvent struct {
+6 -6
View File
@@ -24,12 +24,12 @@ import (
"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"
certstore "github.com/fonu/fonu/internal/certificate" certstore "github.com/havline/havline/internal/certificate"
"github.com/fonu/fonu/internal/config" "github.com/havline/havline/internal/config"
"github.com/fonu/fonu/internal/ddns" "github.com/havline/havline/internal/ddns"
"github.com/fonu/fonu/internal/notify" "github.com/havline/havline/internal/notify"
"github.com/fonu/fonu/internal/service" "github.com/havline/havline/internal/service"
"github.com/fonu/fonu/internal/settings" "github.com/havline/havline/internal/settings"
) )
type Service struct { type Service struct {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/challenge/dns01"
"github.com/fonu/fonu/internal/ddns" "github.com/havline/havline/internal/ddns"
) )
type volcengineDNS struct { type volcengineDNS struct {
+3 -3
View File
@@ -17,7 +17,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/fonu/fonu/internal/sysinfo" "github.com/havline/havline/internal/sysinfo"
) )
const ( const (
@@ -130,7 +130,7 @@ func (s *Server) nginxLogPath(kind string) string {
} }
} }
} }
dir := strings.TrimSpace(os.Getenv("FONU_AGENT_NGINX_LOG_DIR")) dir := strings.TrimSpace(os.Getenv("HAVLINE_AGENT_NGINX_LOG_DIR"))
if dir == "" { if dir == "" {
dir = "/var/log/nginx" dir = "/var/log/nginx"
} }
@@ -210,7 +210,7 @@ type vpsCertificate struct {
DaysLeft int `json:"days_left"` DaysLeft int `json:"days_left"`
} }
// certs 列出 VPS 证书目录下的域名与到期时间:用于与 Fonu 证书库交叉核对,也能看到宝塔自行申请的证书 // certs 列出 VPS 证书目录下的域名与到期时间:用于与 Havline 证书库交叉核对,也能看到宝塔自行申请的证书
func (s *Server) certs(w http.ResponseWriter, _ *http.Request) { func (s *Server) certs(w http.ResponseWriter, _ *http.Request) {
dir := strings.TrimSpace(s.cfg.CertsDir) dir := strings.TrimSpace(s.cfg.CertsDir)
payload := map[string]any{ payload := map[string]any{
+6 -6
View File
@@ -31,10 +31,10 @@ func TestValidateRoute(t *testing.T) {
} }
// TestNginxRouteRendersUpstream:vhost 模板的关键行(标记、server_name、proxy_pass)。 // TestNginxRouteRendersUpstream:vhost 模板的关键行(标记、server_name、proxy_pass)。
// 不构造 Basic Auth 场景——那条路径会往 /var/lib/fonu-agent 写 htpasswd,不适合在单测里触发。 // 不构造 Basic Auth 场景——那条路径会往 /var/lib/havline-agent 写 htpasswd,不适合在单测里触发。
func TestNginxRouteRendersUpstream(t *testing.T) { func TestNginxRouteRendersUpstream(t *testing.T) {
out := nginxRoute(routeRequest{Domain: "a.example.com", Upstream: "127.0.0.1:8080"}, "") out := nginxRoute(routeRequest{Domain: "a.example.com", Upstream: "127.0.0.1:8080"}, "")
if !strings.HasPrefix(out, "# Managed by fonu-agent.") { if !strings.HasPrefix(out, "# Managed by havline-agent.") {
t.Errorf("缺少托管标记,其它工具无法识别该文件归属:\n%s", out) t.Errorf("缺少托管标记,其它工具无法识别该文件归属:\n%s", out)
} }
if !strings.Contains(out, "server_name a.example.com;") { if !strings.Contains(out, "server_name a.example.com;") {
@@ -63,16 +63,16 @@ func TestNginxRouteSharedTemplateSemantics(t *testing.T) {
RateLimitBurst: 20, RateLimitBurst: 20,
ConnLimitMax: 5, ConnLimitMax: 5,
ChinaOnly: true, ChinaOnly: true,
}, "/etc/fonu-agent/certs/a.example.com") }, "/etc/havline-agent/certs/a.example.com")
for _, want := range []string{ for _, want := range []string{
"ssl_protocols TLSv1.2 TLSv1.3;", "ssl_protocols TLSv1.2 TLSv1.3;",
`add_header Referrer-Policy "strict-origin-when-cross-origin" always;`, `add_header Referrer-Policy "strict-origin-when-cross-origin" always;`,
"deny 8.8.8.8;", "deny 8.8.8.8;",
"if ($fonu_china_client = 0) { return 403; }", "if ($havline_china_client = 0) { return 403; }",
"allow 192.168.1.0/24;", "allow 192.168.1.0/24;",
"deny all;", "deny all;",
"limit_conn fonu_a_example_com_conn 5;", "limit_conn havline_a_example_com_conn 5;",
"limit_req zone=fonu_a_example_com_req burst=20 nodelay;", "limit_req zone=havline_a_example_com_req burst=20 nodelay;",
"proxy_set_header Upgrade $http_upgrade;", "proxy_set_header Upgrade $http_upgrade;",
`proxy_set_header Connection "upgrade";`, `proxy_set_header Connection "upgrade";`,
} { } {
+49 -49
View File
@@ -26,14 +26,14 @@ import (
"sync" "sync"
"time" "time"
"github.com/fonu/fonu/internal/fsutil" "github.com/havline/havline/internal/fsutil"
"github.com/fonu/fonu/internal/nginxtmpl" "github.com/havline/havline/internal/nginxtmpl"
"github.com/fonu/fonu/internal/sysinfo" "github.com/havline/havline/internal/sysinfo"
) )
// AgentVersion 随每次 agent 行为变更手动递增;status/ping 与安装脚本都会回报,用于确认 VPS 上跑的是哪个版本。 // AgentVersion 随每次 agent 行为变更手动递增;status/ping 与安装脚本都会回报,用于确认 VPS 上跑的是哪个版本。
// 0.5.0:目录判定改用 nginx -T 自省(dump 完整生效配置,按 "# configuration file" 标记 // 0.5.0:目录判定改用 nginx -T 自省(dump 完整生效配置,按 "# configuration file" 标记
// 找出真正被加载的文件所在目录,零猜测);include 链递归与 FONU_AGENT_NGINX_CONF_DIR 保留为回退。 // 找出真正被加载的文件所在目录,零猜测);include 链递归与 HAVLINE_AGENT_NGINX_CONF_DIR 保留为回退。
// 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。
@@ -61,9 +61,9 @@ import (
// 证书不匹配时回滚并回报实际指纹与端口占用进程,区分「nginx 未生效」与「公网链路被接管」。 // 证书不匹配时回滚并回报实际指纹与端口占用进程,区分「nginx 未生效」与「公网链路被接管」。
// 0.14.0:HTTPS 端口自动选择(7443/8443/9443)、当前证书信息查询、主机防火墙检测与 ufw/firewalld 显式放行。 // 0.14.0:HTTPS 端口自动选择(7443/8443/9443)、当前证书信息查询、主机防火墙检测与 ufw/firewalld 显式放行。
// 0.14.1:vhost 模板迁出到 internal/nginxtmpl,统一 TLS、安全头、allow/deny、WebSocket 与限流输出。 // 0.14.1:vhost 模板迁出到 internal/nginxtmpl,统一 TLS、安全头、allow/deny、WebSocket 与限流输出。
const AgentVersion = "0.14.1" const AgentVersion = "0.15.0"
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。 // Config 是 havline-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
type Config struct { type Config struct {
ListenAddr string ListenAddr string
AllowRemote bool AllowRemote bool
@@ -157,7 +157,7 @@ func NewServer(cfg Config) (*Server, error) {
cfg.NginxBinary = "nginx" cfg.NginxBinary = "nginx"
} }
if strings.TrimSpace(cfg.CertsDir) == "" { if strings.TrimSpace(cfg.CertsDir) == "" {
cfg.CertsDir = "/var/lib/fonu-agent/certs" cfg.CertsDir = "/var/lib/havline-agent/certs"
} }
if strings.TrimSpace(cfg.FrpsConfigPath) == "" { if strings.TrimSpace(cfg.FrpsConfigPath) == "" {
cfg.FrpsConfigPath = "/etc/frp/frps.toml" cfg.FrpsConfigPath = "/etc/frp/frps.toml"
@@ -169,7 +169,7 @@ func NewServer(cfg Config) (*Server, error) {
cfg.FrpsBinary = "/usr/local/bin/" + filepath.Base(cfg.FrpsBinary) cfg.FrpsBinary = "/usr/local/bin/" + filepath.Base(cfg.FrpsBinary)
} }
if strings.TrimSpace(cfg.DataDir) == "" { if strings.TrimSpace(cfg.DataDir) == "" {
cfg.DataDir = "/var/lib/fonu-agent" cfg.DataDir = "/var/lib/havline-agent"
} }
if strings.TrimSpace(cfg.Token) == "" { if strings.TrimSpace(cfg.Token) == "" {
return nil, errors.New("agent token is required") return nil, errors.New("agent token is required")
@@ -201,7 +201,7 @@ func validateListenAddr(addr string, allowRemote bool) (bool, error) {
loopback = ip.IsLoopback() loopback = ip.IsLoopback()
} }
if !loopback && !allowRemote { if !loopback && !allowRemote {
return false, fmt.Errorf("检测到 Agent 将监听非回环地址 %s;默认安全模式只监听 127.0.0.1:7700。如确认需要远程监听,请设置 FONU_AGENT_ALLOW_REMOTE=1", addr) return false, fmt.Errorf("检测到 Agent 将监听非回环地址 %s;默认安全模式只监听 127.0.0.1:7700。如确认需要远程监听,请设置 HAVLINE_AGENT_ALLOW_REMOTE=1", addr)
} }
return loopback, nil return loopback, nil
} }
@@ -303,7 +303,7 @@ func secureTokenEqual(got, want string) bool {
} }
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": "havline-agent"})
} }
func (s *Server) status(w http.ResponseWriter, _ *http.Request) { func (s *Server) status(w http.ResponseWriter, _ *http.Request) {
@@ -312,7 +312,7 @@ func (s *Server) status(w http.ResponseWriter, _ *http.Request) {
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) httpsState, httpsConfigured := readHTTPSProxyState(s.cfg.DataDir)
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"service": "fonu-agent", "service": "havline-agent",
"agent_version": AgentVersion, "agent_version": AgentVersion,
"listen_addr": s.cfg.ListenAddr, "listen_addr": s.cfg.ListenAddr,
"listen_scope": listenScope(s.listenLoopback), "listen_scope": listenScope(s.listenLoopback),
@@ -460,7 +460,7 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
} }
} }
if writeUnit { if writeUnit {
unit := fmt.Sprintf("[Unit]\nDescription=frps service (managed by fonu-agent)\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=%s -c %s\nRestart=on-failure\nRestartSec=3\n\n[Install]\nWantedBy=multi-user.target\n", installPath, s.cfg.FrpsConfigPath) unit := fmt.Sprintf("[Unit]\nDescription=frps service (managed by havline-agent)\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=%s -c %s\nRestart=on-failure\nRestartSec=3\n\n[Install]\nWantedBy=multi-user.target\n", installPath, s.cfg.FrpsConfigPath)
if err := atomicWrite(unitPath, []byte(unit), 0o644); err != nil { if err := atomicWrite(unitPath, []byte(unit), 0o644); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "写入 frps.service 失败: " + err.Error()}) writeJSON(w, http.StatusBadGateway, map[string]string{"error": "写入 frps.service 失败: " + err.Error()})
return return
@@ -624,7 +624,7 @@ func execCommandOutput(name string, args ...string) string {
return string(output) return string(output)
} }
// dashboardAddr 已废弃:面板地址改由 Fonu 前端从服务端表单的「frps 管理接口」字段拼出,agent 不再解析 frps.toml。 // dashboardAddr 已废弃:面板地址改由 Havline 前端从服务端表单的「frps 管理接口」字段拼出,agent 不再解析 frps.toml。
func dashboardAddr(configPath string) string { func dashboardAddr(configPath string) string {
data, err := os.ReadFile(configPath) data, err := os.ReadFile(configPath)
if err != nil { if err != nil {
@@ -754,7 +754,7 @@ func nginxBinaryPath(configured string) string {
func detectNginxConfDir(binary string) (string, error) { func detectNginxConfDir(binary string) (string, error) {
nginx := nginxBinaryPath(binary) nginx := nginxBinaryPath(binary)
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),或用 HAVLINE_AGENT_NGINX_BIN 显式指定路径")
} }
// 旧版 Agent 可能把 <配置>.versions 目录留在 include 范围内,先按常见目录清理,避免 nginx -T 直接失败。 // 旧版 Agent 可能把 <配置>.versions 目录留在 include 范围内,先按常见目录清理,避免 nginx -T 直接失败。
for _, dir := range []string{"/etc/nginx/sites-enabled", "/etc/nginx/conf.d", "/etc/nginx"} { for _, dir := range []string{"/etc/nginx/sites-enabled", "/etc/nginx/conf.d", "/etc/nginx"} {
@@ -802,8 +802,8 @@ func detectNginxConfDir(binary string) (string, error) {
lastReason = fmt.Sprintf("%s: %v", dir, err) lastReason = fmt.Sprintf("%s: %v", dir, err)
continue continue
} }
probe := filepath.Join(dir, "fonu-probe.conf") probe := filepath.Join(dir, "havline-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 havline-agent. Do not edit.\nserver {\n listen 127.0.0.1:1;\n server_name havline-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) lastReason = fmt.Sprintf("%s: %v", dir, err)
continue continue
@@ -822,11 +822,11 @@ func detectNginxConfDir(binary string) (string, error) {
continue continue
} }
// 该目录不接受完整 server 块(片段目录):删除 probe 文件。 // 该目录不接受完整 server 块(片段目录):删除 probe 文件。
// 若 -t 失败信息指向历史残留的 fonu-*.conf,也一并清理(仅限 Agent 生成的文件)。 // 若 -t 失败信息指向历史残留的 havline-*.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)) lastReason = fmt.Sprintf("%s: nginx -t 失败:%s", dir, tailText(string(tOut), 300))
cleanupLegacyFonuConf(string(tOut)) cleanupLegacyHavlineConf(string(tOut))
if err := runCommand(nginx, "-t"); err == nil { if err := runCommand(nginx, "-t"); err == nil {
continue // 清理残留后配置恢复可用,换下一个目录 continue // 清理残留后配置恢复可用,换下一个目录
} }
@@ -837,7 +837,7 @@ func detectNginxConfDir(binary string) (string, error) {
if lastReason == "" { if lastReason == "" {
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) return "", fmt.Errorf("在 %d 个被加载目录中均无法放置完整 server 配置(已尝试: %s);最后一次失败原因:%s;也可用 HAVLINE_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 语义)。
@@ -857,10 +857,10 @@ func dirHasVhostConf(dir string) bool {
return false return false
} }
// cleanupLegacyFonuConf 根据 nginx -t 的报错文本清理 Agent 历史生成的坏文件(仅限 fonu- 前缀)。 // cleanupLegacyHavlineConf 根据 nginx -t 的报错文本清理 Agent 历史生成的坏文件(仅限 havline- 前缀)。
func cleanupLegacyFonuConf(nginxTestOutput string) { func cleanupLegacyHavlineConf(nginxTestOutput string) {
for _, line := range strings.Split(nginxTestOutput, "\n") { for _, line := range strings.Split(nginxTestOutput, "\n") {
idx := strings.Index(line, "fonu-") idx := strings.Index(line, "havline-")
if idx < 0 { if idx < 0 {
continue continue
} }
@@ -878,7 +878,7 @@ func cleanupLegacyFonuConf(nginxTestOutput string) {
if !strings.HasSuffix(path, ".conf") { if !strings.HasSuffix(path, ".conf") {
continue continue
} }
if data, err := os.ReadFile(path); err == nil && strings.HasPrefix(string(data), "# Managed by fonu-agent.") { if data, err := os.ReadFile(path); err == nil && strings.HasPrefix(string(data), "# Managed by havline-agent.") {
_ = os.Remove(path) _ = os.Remove(path)
} }
} }
@@ -905,10 +905,10 @@ func (s *Server) routeDetails() []map[string]any {
} }
details := []map[string]any{} details := []map[string]any{}
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "fonu-") || !strings.HasSuffix(entry.Name(), ".conf") { if entry.IsDir() || !strings.HasPrefix(entry.Name(), "havline-") || !strings.HasSuffix(entry.Name(), ".conf") {
continue continue
} }
domain := strings.TrimSuffix(strings.TrimPrefix(entry.Name(), "fonu-"), ".conf") domain := strings.TrimSuffix(strings.TrimPrefix(entry.Name(), "havline-"), ".conf")
path := filepath.Join(s.cfg.NginxConfDir, entry.Name()) path := filepath.Join(s.cfg.NginxConfDir, entry.Name())
item := map[string]any{ item := map[string]any{
"domain": domain, "domain": domain,
@@ -935,7 +935,7 @@ type routeRequest struct {
TLS bool `json:"tls"` TLS bool `json:"tls"`
CertDir string `json:"cert_dir,omitempty"` CertDir string `json:"cert_dir,omitempty"`
WebSocket bool `json:"websocket,omitempty"` WebSocket bool `json:"websocket,omitempty"`
VhostPort int `json:"vhost_port,omitempty"` // 回源自检用:Fonu 传入本次部署的 frps vhost 端口 VhostPort int `json:"vhost_port,omitempty"` // 回源自检用:Havline 传入本次部署的 frps vhost 端口
RedirectTLS bool `json:"redirect_https,omitempty"` // HTTP 80 强制跳转 HTTPS RedirectTLS bool `json:"redirect_https,omitempty"` // HTTP 80 强制跳转 HTTPS
AllowIPs []string `json:"allow_ips,omitempty"` // IP 白名单;空则不限制 AllowIPs []string `json:"allow_ips,omitempty"` // IP 白名单;空则不限制
BasicAuthUser string `json:"basic_auth_user,omitempty"` // Basic Auth 用户名;空则不启用(密码自动生成) BasicAuthUser string `json:"basic_auth_user,omitempty"` // Basic Auth 用户名;空则不启用(密码自动生成)
@@ -1143,7 +1143,7 @@ type nginxInstallResult struct {
// 需要 agent 以 root 运行(systemd 单元默认如此)。 // 需要 agent 以 root 运行(systemd 单元默认如此)。
func (s *Server) installNginx(w http.ResponseWriter, r *http.Request) { func (s *Server) installNginx(w http.ResponseWriter, r *http.Request) {
if os.Geteuid() != 0 { if os.Geteuid() != 0 {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "安装 Nginx 需要 root 权限:请以 root 运行 fonu-agent"}) writeJSON(w, http.StatusForbidden, map[string]string{"error": "安装 Nginx 需要 root 权限:请以 root 运行 havline-agent"})
return return
} }
if probe := s.probeNginx(); probe.OK { if probe := s.probeNginx(); probe.OK {
@@ -1196,7 +1196,7 @@ type chinaCIDRRequest struct {
Content string `json:"content"` Content string `json:"content"`
} }
// putChinaCIDR 接收 Fonu 下发的中国 IP 段(geo include 格式),写入 geo 变量文件并重载。 // putChinaCIDR 接收 Havline 下发的中国 IP 段(geo include 格式),写入 geo 变量文件并重载。
func (s *Server) putChinaCIDR(w http.ResponseWriter, r *http.Request) { func (s *Server) putChinaCIDR(w http.ResponseWriter, r *http.Request) {
var req chinaCIDRRequest var req chinaCIDRRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<20)).Decode(&req); err != nil { if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<20)).Decode(&req); err != nil {
@@ -1226,7 +1226,7 @@ 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
} }
geo := "# Managed by fonu-agent. Do not edit.\ngeo $fonu_china_client {\n default 0;\n include " + chinaListPath() + ";\n}\n" geo := "# Managed by havline-agent. Do not edit.\ngeo $havline_china_client {\n default 0;\n include " + chinaListPath() + ";\n}\n"
if err := atomicWrite(s.chinaGeoFilePath(), []byte(geo), 0o640); err != nil { if err := atomicWrite(s.chinaGeoFilePath(), []byte(geo), 0o640); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return return
@@ -1256,7 +1256,7 @@ func (s *Server) writeRoute(req routeRequest) error {
if certDir == "" { if certDir == "" {
certDir = filepath.Join(s.cfg.CertsDir, req.Domain) certDir = filepath.Join(s.cfg.CertsDir, req.Domain)
} }
// 仅中国大陆 IP 依赖已下发的 IP 段(geo 变量在 fonuagent-china.conf 中定义) // 仅中国大陆 IP 依赖已下发的 IP 段(geo 变量在 havlineagent-china.conf 中定义)
if req.ChinaOnly { if req.ChinaOnly {
if _, err := os.Stat(chinaListPath()); err != nil { if _, err := os.Stat(chinaListPath()); err != nil {
return fmt.Errorf("启用「仅中国大陆 IP」前需先同步中国 IP 段(请在公网反代页触发同步后重试)") return fmt.Errorf("启用「仅中国大陆 IP」前需先同步中国 IP 段(请在公网反代页触发同步后重试)")
@@ -1265,7 +1265,7 @@ func (s *Server) writeRoute(req routeRequest) error {
content := nginxRoute(req, certDir) content := nginxRoute(req, certDir)
path := s.routePath(req.Domain) path := s.routePath(req.Domain)
// 清理历史版本写到其他目录的同域名 Agent 文件:从 nginx -T 的生效清单反向比对, // 清理历史版本写到其他目录的同域名 Agent 文件:从 nginx -T 的生效清单反向比对,
// 非当前目录但内容带 Agent 标记的 fonu-<domain>.conf 一律删除(不碰宝塔自己的配置)。 // 非当前目录但内容带 Agent 标记的 havline-<domain>.conf 一律删除(不碰宝塔自己的配置)。
if nginx := s.nginxPath(); nginx != "" { if nginx := s.nginxPath(); nginx != "" {
if tOut, tErr := exec.Command(nginx, "-T").CombinedOutput(); tErr == nil { if tOut, tErr := exec.Command(nginx, "-T").CombinedOutput(); tErr == nil {
for _, line := range strings.Split(string(tOut), "\n") { for _, line := range strings.Split(string(tOut), "\n") {
@@ -1273,8 +1273,8 @@ func (s *Server) writeRoute(req routeRequest) error {
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), ":")
base := filepath.Base(p) base := filepath.Base(p)
if p != path && base == "fonu-"+req.Domain+".conf" { if p != path && base == "havline-"+req.Domain+".conf" {
if data, readErr := os.ReadFile(p); readErr == nil && strings.HasPrefix(string(data), "# Managed by fonu-agent.") { if data, readErr := os.ReadFile(p); readErr == nil && strings.HasPrefix(string(data), "# Managed by havline-agent.") {
_ = os.Remove(p) _ = os.Remove(p)
} }
} }
@@ -1299,17 +1299,17 @@ func (s *Server) routeNames() []string {
} }
var result []string var result []string
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "fonu-") || !strings.HasSuffix(entry.Name(), ".conf") { if entry.IsDir() || !strings.HasPrefix(entry.Name(), "havline-") || !strings.HasSuffix(entry.Name(), ".conf") {
continue continue
} }
result = append(result, strings.TrimSuffix(strings.TrimPrefix(entry.Name(), "fonu-"), ".conf")) result = append(result, strings.TrimSuffix(strings.TrimPrefix(entry.Name(), "havline-"), ".conf"))
} }
sort.Strings(result) sort.Strings(result)
return result return result
} }
func (s *Server) routePath(domain string) string { func (s *Server) routePath(domain string) string {
return filepath.Join(s.cfg.NginxConfDir, "fonu-"+domain+".conf") return filepath.Join(s.cfg.NginxConfDir, "havline-"+domain+".conf")
} }
func (s *Server) validateNginx() error { func (s *Server) validateNginx() error {
@@ -1380,7 +1380,7 @@ func migrateVersionDirsFromNginxOutput(output string) bool {
func (s *Server) reloadNginx() error { func (s *Server) reloadNginx() error {
nginx := s.nginxPath() nginx := s.nginxPath()
if nginx == "" { if nginx == "" {
return errors.New("未找到 nginx 可执行文件:请先在「公网反代」页安装 Nginx,或用 FONU_AGENT_NGINX_BIN 指定路径") return errors.New("未找到 nginx 可执行文件:请先在「公网反代」页安装 Nginx,或用 HAVLINE_AGENT_NGINX_BIN 指定路径")
} }
return runCommand(nginx, "-s", "reload") return runCommand(nginx, "-s", "reload")
} }
@@ -1530,7 +1530,7 @@ func validDomain(value string) bool {
func nginxRoute(req routeRequest, certDir string) string { func nginxRoute(req routeRequest, certDir string) string {
var b strings.Builder var b strings.Builder
b.WriteString("# Managed by fonu-agent. Do not edit.\n") b.WriteString("# Managed by havline-agent. Do not edit.\n")
// HTTPSDisabled 显式关闭 HTTPS:即使证书已推送也不监听 443(字段缺省 = 沿用现有行为) // HTTPSDisabled 显式关闭 HTTPS:即使证书已推送也不监听 443(字段缺省 = 沿用现有行为)
tlsEnabled := req.TLS && !req.HTTPSDisabled tlsEnabled := req.TLS && !req.HTTPSDisabled
if req.RedirectTLS && tlsEnabled { if req.RedirectTLS && tlsEnabled {
@@ -1557,7 +1557,7 @@ func nginxRoute(req routeRequest, certDir string) string {
Enabled: req.SecurityHeaders, Enabled: req.SecurityHeaders,
HTTPS: tlsEnabled, HTTPS: tlsEnabled,
})) }))
// 限流/连接数的 zone 定义在 fonuagent-limits.conf(http 上下文),此处只声明状态码 // 限流/连接数的 zone 定义在 havlineagent-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")
} }
@@ -1572,7 +1572,7 @@ func nginxRoute(req routeRequest, certDir string) string {
// 顺序由共享模板统一:deny → 仅大陆规则 → allow → deny all。 // 顺序由共享模板统一:deny → 仅大陆规则 → allow → deny all。
chinaDeny := "" chinaDeny := ""
if req.ChinaOnly { if req.ChinaOnly {
chinaDeny = " if ($fonu_china_client = 0) { return 403; }\n" chinaDeny = " if ($havline_china_client = 0) { return 403; }\n"
} }
aclText := nginxtmpl.AccessDirectives(nginxtmpl.AccessControl{ aclText := nginxtmpl.AccessDirectives(nginxtmpl.AccessControl{
Allow: req.AllowIPs, Allow: req.AllowIPs,
@@ -1585,15 +1585,15 @@ func nginxRoute(req routeRequest, certDir string) 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 \"Havline Protected\";\n auth_basic_user_file %s;\n", basicAuthFilePath(req.Domain))
} }
} else if _, 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 \"Havline Protected\";\n auth_basic_user_file %s;\n", basicAuthFilePath(req.Domain))
} }
} }
// 限流 / 连接数(zone 在 http 上下文定义) // 限流 / 连接数(zone 在 http 上下文定义)
limitText := nginxtmpl.LimitDirectives(nginxtmpl.LimitConfig{ limitText := nginxtmpl.LimitDirectives(nginxtmpl.LimitConfig{
Zone: nginxtmpl.ZoneName("fonu", req.Domain), Zone: nginxtmpl.ZoneName("havline", req.Domain),
Rate: req.RateLimitRate, Rate: req.RateLimitRate,
Burst: req.RateLimitBurst, Burst: req.RateLimitBurst,
Conn: req.ConnLimitMax, Conn: req.ConnLimitMax,
@@ -1603,21 +1603,21 @@ func nginxRoute(req routeRequest, certDir string) string {
} }
// agentStateDir 存放 Agent 的持久状态(htpasswd、geo IP 段、限流参数)。 // agentStateDir 存放 Agent 的持久状态(htpasswd、geo IP 段、限流参数)。
const agentStateDir = "/var/lib/fonu-agent" const agentStateDir = "/var/lib/havline-agent"
// zoneKey 把域名转成合法的 nginx zone 名(仅保留小写字母与数字,其余换下划线)。 // zoneKey 把域名转成合法的 nginx zone 名(仅保留小写字母与数字,其余换下划线)。
func zoneKey(domain string) string { func zoneKey(domain string) string {
return nginxtmpl.ZoneName("fonu", domain) return nginxtmpl.ZoneName("havline", domain)
} }
// limitsFilePath 返回 zone 定义文件(放在 vhost 目录,顶层指令即 http 上下文)。 // limitsFilePath 返回 zone 定义文件(放在 vhost 目录,顶层指令即 http 上下文)。
func (s *Server) limitsFilePath() string { func (s *Server) limitsFilePath() string {
return filepath.Join(s.cfg.NginxConfDir, "fonuagent-limits.conf") return filepath.Join(s.cfg.NginxConfDir, "havlineagent-limits.conf")
} }
// chinaGeoFilePath 返回 geo 变量定义文件(同样位于 http 上下文)。 // chinaGeoFilePath 返回 geo 变量定义文件(同样位于 http 上下文)。
func (s *Server) chinaGeoFilePath() string { func (s *Server) chinaGeoFilePath() string {
return filepath.Join(s.cfg.NginxConfDir, "fonuagent-china.conf") return filepath.Join(s.cfg.NginxConfDir, "havlineagent-china.conf")
} }
// chinaListPath 返回中国 IP 段文件(geo include 格式:<cidr> 1;)。 // chinaListPath 返回中国 IP 段文件(geo include 格式:<cidr> 1;)。
@@ -1678,7 +1678,7 @@ func (s *Server) writeLimitState(state map[string]routeLimit) error {
return nil return nil
} }
var b strings.Builder var b strings.Builder
b.WriteString("# Managed by fonu-agent. Do not edit.\n") b.WriteString("# Managed by havline-agent. Do not edit.\n")
domains := make([]string, 0, len(state)) domains := make([]string, 0, len(state))
for domain := range state { for domain := range state {
domains = append(domains, domain) domains = append(domains, domain)
@@ -1744,7 +1744,7 @@ func writeBasicAuthSecret(domain, user, password string) error {
// basicAuthFilePath 返回指定域名的 htpasswd 文件路径(与反代配置同目录管理)。 // basicAuthFilePath 返回指定域名的 htpasswd 文件路径(与反代配置同目录管理)。
func basicAuthFilePath(domain string) string { func basicAuthFilePath(domain string) string {
return filepath.Join("/var/lib/fonu-agent", "basicauth", domain+".htpasswd") return filepath.Join("/var/lib/havline-agent", "basicauth", domain+".htpasswd")
} }
// ensureBasicAuthSecret 读取已有 Basic Auth 密码;不存在则生成随机密码并写入 htpasswd 文件。 // ensureBasicAuthSecret 读取已有 Basic Auth 密码;不存在则生成随机密码并写入 htpasswd 文件。
+7 -7
View File
@@ -17,14 +17,14 @@ import (
"strings" "strings"
"time" "time"
"github.com/fonu/fonu/internal/fsutil" "github.com/havline/havline/internal/fsutil"
"github.com/fonu/fonu/internal/nginxtmpl" "github.com/havline/havline/internal/nginxtmpl"
) )
const ( const (
httpsProxyConfigName = "fonuagent-https.conf" httpsProxyConfigName = "havlineagent-https.conf"
httpsProxyCertPath = "/etc/fonu-agent/tls/agent.crt" httpsProxyCertPath = "/etc/havline-agent/tls/agent.crt"
httpsProxyKeyPath = "/etc/fonu-agent/tls/agent.key" httpsProxyKeyPath = "/etc/havline-agent/tls/agent.key"
) )
type httpsProxyRequest struct { type httpsProxyRequest struct {
@@ -119,7 +119,7 @@ func (s *Server) applyHTTPSProxy(req httpsProxyRequest, port int) error {
if err != nil { if err != nil {
return err return err
} }
tlsDir := "/etc/fonu-agent/tls" tlsDir := "/etc/havline-agent/tls"
if err := os.MkdirAll(tlsDir, 0o750); err != nil { if err := os.MkdirAll(tlsDir, 0o750); err != nil {
return err return err
} }
@@ -206,7 +206,7 @@ func (s *Server) ensureNginxDir() (string, error) {
} }
func renderHTTPSProxy(port int, certPath, keyPath string) string { func renderHTTPSProxy(port int, certPath, keyPath string) string {
return fmt.Sprintf(`# Managed by fonu-agent. Do not edit. return fmt.Sprintf(`# Managed by havline-agent. Do not edit.
server { server {
listen %d ssl default_server; listen %d ssl default_server;
server_name _; server_name _;
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/fonu/fonu/internal/fsutil" "github.com/havline/havline/internal/fsutil"
) )
// maxRouteVersions 每个域名 vhost 保留的版本份数。内容相同的写入会被 fsutil 去重, // maxRouteVersions 每个域名 vhost 保留的版本份数。内容相同的写入会被 fsutil 去重,
+7 -7
View File
@@ -17,12 +17,12 @@ import (
"testing" "testing"
"time" "time"
"github.com/fonu/fonu/internal/fsutil" "github.com/havline/havline/internal/fsutil"
) )
func TestMigrateLegacyVersionDirs(t *testing.T) { func TestMigrateLegacyVersionDirs(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "fonuagent-https.conf") path := filepath.Join(dir, "havlineagent-https.conf")
legacy := path + ".versions" legacy := path + ".versions"
if err := os.MkdirAll(legacy, 0o700); err != nil { if err := os.MkdirAll(legacy, 0o700); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -37,7 +37,7 @@ func TestMigrateLegacyVersionDirs(t *testing.T) {
if _, err := os.Stat(legacy); !os.IsNotExist(err) { if _, err := os.Stat(legacy); !os.IsNotExist(err) {
t.Fatalf("旧版本目录应被移出 nginx include 范围:%v", err) t.Fatalf("旧版本目录应被移出 nginx include 范围:%v", err)
} }
entries, err := os.ReadDir(filepath.Join(dir, ".fonuagent-https.conf.versions")) entries, err := os.ReadDir(filepath.Join(dir, ".havlineagent-https.conf.versions"))
if err != nil || len(entries) != 1 { if err != nil || len(entries) != 1 {
t.Fatalf("隐藏版本目录应保留 1 个版本:%v %v", entries, err) t.Fatalf("隐藏版本目录应保留 1 个版本:%v %v", entries, err)
} }
@@ -45,7 +45,7 @@ func TestMigrateLegacyVersionDirs(t *testing.T) {
func TestMigrateVersionDirsFromNginxOutput(t *testing.T) { func TestMigrateVersionDirsFromNginxOutput(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
legacy := filepath.Join(dir, "fonuagent-https.conf.versions") legacy := filepath.Join(dir, "havlineagent-https.conf.versions")
if err := os.MkdirAll(legacy, 0o700); err != nil { if err := os.MkdirAll(legacy, 0o700); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -85,7 +85,7 @@ func TestCertificatePin(t *testing.T) {
func TestApplyRouteContentSavesPreviousVersion(t *testing.T) { func TestApplyRouteContentSavesPreviousVersion(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "fonu-a.example.com.conf") path := filepath.Join(dir, "havline-a.example.com.conf")
if err := os.WriteFile(path, []byte("old config"), 0o640); err != nil { if err := os.WriteFile(path, []byte("old config"), 0o640); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -109,7 +109,7 @@ func TestApplyRouteContentSavesPreviousVersion(t *testing.T) {
func TestApplyRouteContentRestoresOnValidationFailure(t *testing.T) { func TestApplyRouteContentRestoresOnValidationFailure(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "fonu-a.example.com.conf") path := filepath.Join(dir, "havline-a.example.com.conf")
if err := os.WriteFile(path, []byte("old config"), 0o640); err != nil { if err := os.WriteFile(path, []byte("old config"), 0o640); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -128,7 +128,7 @@ func TestApplyRouteContentRestoresOnValidationFailure(t *testing.T) {
func TestApplyRouteContentRemovesNewFileOnValidationFailure(t *testing.T) { func TestApplyRouteContentRemovesNewFileOnValidationFailure(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "fonu-new.example.com.conf") path := filepath.Join(dir, "havline-new.example.com.conf")
server := &Server{ server := &Server{
cfg: Config{NginxConfDir: dir}, cfg: Config{NginxConfDir: dir},
validateNginxFn: func() error { return errors.New("nginx rejected") }, validateNginxFn: func() error { return errors.New("nginx rejected") },
+4 -4
View File
@@ -9,8 +9,8 @@ import (
"sync" "sync"
"time" "time"
"github.com/fonu/fonu/internal/auth" "github.com/havline/havline/internal/auth"
"github.com/fonu/fonu/internal/notify" "github.com/havline/havline/internal/notify"
) )
type AuthHandler struct { type AuthHandler struct {
@@ -168,7 +168,7 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
func setSessionCookie(w http.ResponseWriter, r *http.Request, 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: "havline_session",
Value: sessionID, Value: sessionID,
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
@@ -197,7 +197,7 @@ func clientIP(r *http.Request) string {
func clearSessionCookie(w http.ResponseWriter, r *http.Request) { func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "fonu_session", Name: "havline_session",
Value: "", Value: "",
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
+11 -11
View File
@@ -10,19 +10,19 @@ import (
"testing" "testing"
"time" "time"
"github.com/fonu/fonu/internal/auth" "github.com/havline/havline/internal/auth"
"github.com/fonu/fonu/internal/backup" "github.com/havline/havline/internal/backup"
"github.com/fonu/fonu/internal/config" "github.com/havline/havline/internal/certificate"
"github.com/fonu/fonu/internal/db" "github.com/havline/havline/internal/config"
"github.com/fonu/fonu/internal/discovery" "github.com/havline/havline/internal/db"
"github.com/fonu/fonu/internal/certificate" "github.com/havline/havline/internal/discovery"
"github.com/fonu/fonu/internal/proxy" "github.com/havline/havline/internal/proxy"
"github.com/fonu/fonu/internal/service" "github.com/havline/havline/internal/service"
"github.com/fonu/fonu/internal/settings" "github.com/havline/havline/internal/settings"
) )
func TestAuthSetupAndLoginFlow(t *testing.T) { func TestAuthSetupAndLoginFlow(t *testing.T) {
conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db")) conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
if err != nil { if err != nil {
t.Fatalf("open db: %v", err) t.Fatalf("open db: %v", err)
} }
@@ -67,7 +67,7 @@ func TestAuthSetupAndLoginFlow(t *testing.T) {
} }
cookie := loginRec.Result().Cookies()[0] cookie := loginRec.Result().Cookies()[0]
if cookie.Name != "fonu_session" || cookie.HttpOnly == false { if cookie.Name != "havline_session" || cookie.HttpOnly == false {
t.Fatal("expected httponly session cookie") t.Fatal("expected httponly session cookie")
} }
+5 -5
View File
@@ -6,8 +6,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/fonu/fonu/internal/backup" "github.com/havline/havline/internal/backup"
"github.com/fonu/fonu/internal/settings" "github.com/havline/havline/internal/settings"
) )
type BackupHandler struct { type BackupHandler struct {
@@ -52,7 +52,7 @@ func (h *BackupHandler) Restore(w http.ResponseWriter, r *http.Request) {
defer file.Close() defer file.Close()
// 上传文件名完全由客户端控制:不能拼进临时路径(../ 可逃出临时目录),交给 CreateTemp 生成 // 上传文件名完全由客户端控制:不能拼进临时路径(../ 可逃出临时目录),交给 CreateTemp 生成
out, err := os.CreateTemp("", "fonu-restore-*.tar.gz") out, err := os.CreateTemp("", "havline-restore-*.tar.gz")
if err != nil { if err != nil {
writeError(r, w, http.StatusInternalServerError, "保存上传文件失败") writeError(r, w, http.StatusInternalServerError, "保存上传文件失败")
return return
@@ -72,7 +72,7 @@ func (h *BackupHandler) Restore(w http.ResponseWriter, r *http.Request) {
return return
} }
_ = os.Remove(tmpPath) _ = os.Remove(tmpPath)
writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Fonu"}) writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Havline"})
} }
// ListArchives 列出留在磁盘上的备份(自动备份 + 手动「立即备份」)。 // ListArchives 列出留在磁盘上的备份(自动备份 + 手动「立即备份」)。
@@ -150,7 +150,7 @@ func (h *BackupHandler) RestoreArchive(w http.ResponseWriter, r *http.Request) {
writeError(r, w, http.StatusBadRequest, err.Error(), err) writeError(r, w, http.StatusBadRequest, err.Error(), err)
return return
} }
writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Fonu"}) writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Havline"})
} }
// DeleteArchive 删除指定备份 // DeleteArchive 删除指定备份
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/fonu/fonu/internal/acme" "github.com/havline/havline/internal/acme"
) )
type CertHandler struct { type CertHandler struct {
+1 -1
View File
@@ -3,7 +3,7 @@ package api
import ( import (
"net/http" "net/http"
"github.com/fonu/fonu/internal/chinacidr" "github.com/havline/havline/internal/chinacidr"
) )
type ChinaCIDRHandler struct { type ChinaCIDRHandler struct {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/fonu/fonu/internal/cloudflared" "github.com/havline/havline/internal/cloudflared"
) )
type CloudflareHandler struct { type CloudflareHandler struct {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/fonu/fonu/internal/ddns" "github.com/havline/havline/internal/ddns"
) )
type DDNSHandler struct { type DDNSHandler struct {
+15 -15
View File
@@ -4,21 +4,21 @@ import (
"io/fs" "io/fs"
"log/slog" "log/slog"
"github.com/fonu/fonu/internal/acme" "github.com/havline/havline/internal/acme"
"github.com/fonu/fonu/internal/auth" "github.com/havline/havline/internal/auth"
"github.com/fonu/fonu/internal/backup" "github.com/havline/havline/internal/backup"
"github.com/fonu/fonu/internal/chinacidr" "github.com/havline/havline/internal/chinacidr"
"github.com/fonu/fonu/internal/cloudflared" "github.com/havline/havline/internal/cloudflared"
"github.com/fonu/fonu/internal/config" "github.com/havline/havline/internal/config"
"github.com/fonu/fonu/internal/ddns" "github.com/havline/havline/internal/ddns"
"github.com/fonu/fonu/internal/discovery" "github.com/havline/havline/internal/discovery"
"github.com/fonu/fonu/internal/frp" "github.com/havline/havline/internal/frp"
"github.com/fonu/fonu/internal/metrics" "github.com/havline/havline/internal/metrics"
"github.com/fonu/fonu/internal/notify" "github.com/havline/havline/internal/notify"
"github.com/fonu/fonu/internal/service" "github.com/havline/havline/internal/service"
"github.com/fonu/fonu/internal/settings" "github.com/havline/havline/internal/settings"
"github.com/fonu/fonu/internal/traffic" "github.com/havline/havline/internal/traffic"
"github.com/fonu/fonu/internal/updater" "github.com/havline/havline/internal/updater"
) )
type Deps struct { type Deps struct {
+1 -1
View File
@@ -3,7 +3,7 @@ package api
import ( import (
"net/http" "net/http"
"github.com/fonu/fonu/internal/discovery" "github.com/havline/havline/internal/discovery"
) )
type DiscoveryHandler struct { type DiscoveryHandler struct {
+2 -2
View File
@@ -10,8 +10,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/fonu/fonu/internal/auth" "github.com/havline/havline/internal/auth"
"github.com/fonu/fonu/internal/frp" "github.com/havline/havline/internal/frp"
) )
// FRPMultiHandler 承载多服务端 FRP 模块的 HTTP 接口(/api/frpmulti/*), // FRPMultiHandler 承载多服务端 FRP 模块的 HTTP 接口(/api/frpmulti/*),
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"github.com/fonu/fonu/internal/config" "github.com/havline/havline/internal/config"
"github.com/fonu/fonu/internal/logstore" "github.com/havline/havline/internal/logstore"
) )
type LogsHandler struct { type LogsHandler struct {
+11 -11
View File
@@ -9,20 +9,20 @@ import (
"testing" "testing"
"time" "time"
"github.com/fonu/fonu/internal/auth" "github.com/havline/havline/internal/auth"
"github.com/fonu/fonu/internal/backup" "github.com/havline/havline/internal/backup"
"github.com/fonu/fonu/internal/config" "github.com/havline/havline/internal/certificate"
"github.com/fonu/fonu/internal/db" "github.com/havline/havline/internal/config"
"github.com/fonu/fonu/internal/discovery" "github.com/havline/havline/internal/db"
"github.com/fonu/fonu/internal/certificate" "github.com/havline/havline/internal/discovery"
"github.com/fonu/fonu/internal/proxy" "github.com/havline/havline/internal/proxy"
"github.com/fonu/fonu/internal/service" "github.com/havline/havline/internal/service"
"github.com/fonu/fonu/internal/settings" "github.com/havline/havline/internal/settings"
) )
func TestLogsAccessRoute(t *testing.T) { func TestLogsAccessRoute(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "fonu.db")) conn, err := db.Open(filepath.Join(dir, "havline.db"))
if err != nil { if err != nil {
t.Fatalf("open db: %v", err) t.Fatalf("open db: %v", err)
} }
@@ -50,7 +50,7 @@ func TestLogsAccessRoute(t *testing.T) {
}) })
req := httptest.NewRequest(http.MethodGet, "/api/logs/access?limit=100", nil) req := httptest.NewRequest(http.MethodGet, "/api/logs/access?limit=100", nil)
req.AddCookie(&http.Cookie{Name: "fonu_session", Value: sessionID}) req.AddCookie(&http.Cookie{Name: "havline_session", Value: sessionID})
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/fonu/fonu/internal/metrics" "github.com/havline/havline/internal/metrics"
) )
type MetricsHandler struct { type MetricsHandler struct {
+2 -2
View File
@@ -5,7 +5,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/fonu/fonu/internal/auth" "github.com/havline/havline/internal/auth"
) )
type contextKey string type contextKey string
@@ -15,7 +15,7 @@ const sessionContextKey contextKey = "session_id"
func SessionMiddleware(authSvc *auth.Service) func(http.Handler) http.Handler { func SessionMiddleware(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) {
cookie, err := r.Cookie("fonu_session") cookie, err := r.Cookie("havline_session")
if err == nil && cookie.Value != "" { if err == nil && cookie.Value != "" {
ctx := context.WithValue(r.Context(), sessionContextKey, cookie.Value) ctx := context.WithValue(r.Context(), sessionContextKey, cookie.Value)
r = r.WithContext(ctx) r = r.WithContext(ctx)
+3 -3
View File
@@ -8,13 +8,13 @@ import (
"testing" "testing"
"time" "time"
"github.com/fonu/fonu/internal/auth" "github.com/havline/havline/internal/auth"
"github.com/fonu/fonu/internal/db" "github.com/havline/havline/internal/db"
) )
func newTokenTestAuth(t *testing.T, scope string) (*auth.Service, string) { func newTokenTestAuth(t *testing.T, scope string) (*auth.Service, string) {
t.Helper() t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db")) conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
if err != nil { if err != nil {
t.Fatalf("open db: %v", err) t.Fatalf("open db: %v", err)
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"github.com/fonu/fonu/internal/service" "github.com/havline/havline/internal/service"
) )
type NginxHandler struct { type NginxHandler struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"github.com/fonu/fonu/internal/notify" "github.com/havline/havline/internal/notify"
) )
type NotifyHandler struct { type NotifyHandler struct {
+5 -5
View File
@@ -5,11 +5,11 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/fonu/fonu/internal/config" "github.com/havline/havline/internal/config"
"github.com/fonu/fonu/internal/logstore" "github.com/havline/havline/internal/logstore"
"github.com/fonu/fonu/internal/proxy" "github.com/havline/havline/internal/proxy"
"github.com/fonu/fonu/internal/service" "github.com/havline/havline/internal/service"
"github.com/fonu/fonu/internal/traffic" "github.com/havline/havline/internal/traffic"
) )
type ProxyHandler struct { type ProxyHandler struct {
+1 -1
View File
@@ -1,7 +1,7 @@
package api package api
import ( import (
"github.com/fonu/fonu/internal/proxy" "github.com/havline/havline/internal/proxy"
) )
type securityRequest struct { type securityRequest struct {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/fonu/fonu/internal/auth" "github.com/havline/havline/internal/auth"
) )
// routeSpec 是一条 API 路由的注册信息;测试据此遍历全部路由验证鉴权覆盖。 // routeSpec 是一条 API 路由的注册信息;测试据此遍历全部路由验证鉴权覆盖。
+9 -9
View File
@@ -11,14 +11,14 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/fonu/fonu/internal/auth" "github.com/havline/havline/internal/auth"
"github.com/fonu/fonu/internal/certificate" "github.com/havline/havline/internal/certificate"
"github.com/fonu/fonu/internal/cloudflared" "github.com/havline/havline/internal/cloudflared"
"github.com/fonu/fonu/internal/config" "github.com/havline/havline/internal/config"
"github.com/fonu/fonu/internal/db" "github.com/havline/havline/internal/db"
"github.com/fonu/fonu/internal/frp" "github.com/havline/havline/internal/frp"
"github.com/fonu/fonu/internal/secret" "github.com/havline/havline/internal/secret"
"github.com/fonu/fonu/internal/settings" "github.com/havline/havline/internal/settings"
) )
var routeParamPattern = regexp.MustCompile(`\{[^}]+\}`) var routeParamPattern = regexp.MustCompile(`\{[^}]+\}`)
@@ -26,7 +26,7 @@ var routeParamPattern = regexp.MustCompile(`\{[^}]+\}`)
// TestProtectedRoutesRequireAuth 遍历路由表:受保护路由匿名必须 401,公开路由不能被鉴权误拦。 // TestProtectedRoutesRequireAuth 遍历路由表:受保护路由匿名必须 401,公开路由不能被鉴权误拦。
func TestProtectedRoutesRequireAuth(t *testing.T) { func TestProtectedRoutesRequireAuth(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "fonu.db")) conn, err := db.Open(filepath.Join(dir, "havline.db"))
if err != nil { if err != nil {
t.Fatalf("open db: %v", err) t.Fatalf("open db: %v", err)
} }
+4 -4
View File
@@ -7,10 +7,10 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/fonu/fonu/internal/notify" "github.com/havline/havline/internal/notify"
"github.com/fonu/fonu/internal/service" "github.com/havline/havline/internal/service"
"github.com/fonu/fonu/internal/settings" "github.com/havline/havline/internal/settings"
"github.com/fonu/fonu/internal/validate" "github.com/havline/havline/internal/validate"
) )
type SettingsHandler struct { type SettingsHandler struct {
+9 -9
View File
@@ -11,15 +11,15 @@ import (
"sync" "sync"
"time" "time"
"github.com/fonu/fonu/internal/acme" "github.com/havline/havline/internal/acme"
certstore "github.com/fonu/fonu/internal/certificate" certstore "github.com/havline/havline/internal/certificate"
"github.com/fonu/fonu/internal/cloudflared" "github.com/havline/havline/internal/cloudflared"
"github.com/fonu/fonu/internal/config" "github.com/havline/havline/internal/config"
"github.com/fonu/fonu/internal/ddns" "github.com/havline/havline/internal/ddns"
"github.com/fonu/fonu/internal/logstore" "github.com/havline/havline/internal/logstore"
"github.com/fonu/fonu/internal/publicip" "github.com/havline/havline/internal/publicip"
"github.com/fonu/fonu/internal/service" "github.com/havline/havline/internal/service"
"github.com/fonu/fonu/internal/sysinfo" "github.com/havline/havline/internal/sysinfo"
) )
type StatusHandler struct { type StatusHandler struct {
+5 -5
View File
@@ -10,10 +10,10 @@ import (
"sync" "sync"
"time" "time"
"github.com/fonu/fonu/internal/cloudflared" "github.com/havline/havline/internal/cloudflared"
"github.com/fonu/fonu/internal/frp" "github.com/havline/havline/internal/frp"
"github.com/fonu/fonu/internal/notify" "github.com/havline/havline/internal/notify"
"github.com/fonu/fonu/internal/settings" "github.com/havline/havline/internal/settings"
) )
// statusPageWindow / statusPageLimit 匿名访问的限流窗口与配额: // statusPageWindow / statusPageLimit 匿名访问的限流窗口与配额:
@@ -99,7 +99,7 @@ func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
} }
payload := statusPagePayload{ payload := statusPagePayload{
Title: "Fonu 服务状态", Title: "Havline 服务状态",
CodeRequired: codeRequired, CodeRequired: codeRequired,
CheckedAt: time.Now().UTC().Format(time.RFC3339), CheckedAt: time.Now().UTC().Format(time.RFC3339),
Routes: make([]statusPageRoute, 0), Routes: make([]statusPageRoute, 0),
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/fonu/fonu/internal/auth" "github.com/havline/havline/internal/auth"
) )
// 令牌管理挂在 AuthHandler 上:它已经持有 auth.Service,不必再加一层 handler。 // 令牌管理挂在 AuthHandler 上:它已经持有 auth.Service,不必再加一层 handler。
+1 -1
View File
@@ -3,7 +3,7 @@ package api
import ( import (
"net/http" "net/http"
"github.com/fonu/fonu/internal/updater" "github.com/havline/havline/internal/updater"
) )
type UpdateHandler struct { type UpdateHandler struct {
+2 -2
View File
@@ -3,8 +3,8 @@ package api
import ( import (
"net/http" "net/http"
"github.com/fonu/fonu/internal/config" "github.com/havline/havline/internal/config"
"github.com/fonu/fonu/internal/version" "github.com/havline/havline/internal/version"
) )
type versionResponse struct { type versionResponse struct {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/fonu/fonu/internal/config" "github.com/havline/havline/internal/config"
"github.com/fonu/fonu/internal/version" "github.com/havline/havline/internal/version"
) )
func TestVersionEndpoint(t *testing.T) { func TestVersionEndpoint(t *testing.T) {
+24 -24
View File
@@ -11,30 +11,30 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/fonu/fonu/internal/acme" "github.com/havline/havline/internal/acme"
"github.com/fonu/fonu/internal/api" "github.com/havline/havline/internal/api"
"github.com/fonu/fonu/internal/auth" "github.com/havline/havline/internal/auth"
"github.com/fonu/fonu/internal/backup" "github.com/havline/havline/internal/backup"
"github.com/fonu/fonu/internal/certificate" "github.com/havline/havline/internal/certificate"
"github.com/fonu/fonu/internal/chinacidr" "github.com/havline/havline/internal/chinacidr"
"github.com/fonu/fonu/internal/cloudflared" "github.com/havline/havline/internal/cloudflared"
"github.com/fonu/fonu/internal/config" "github.com/havline/havline/internal/config"
"github.com/fonu/fonu/internal/db" "github.com/havline/havline/internal/db"
"github.com/fonu/fonu/internal/ddns" "github.com/havline/havline/internal/ddns"
"github.com/fonu/fonu/internal/discovery" "github.com/havline/havline/internal/discovery"
"github.com/fonu/fonu/internal/frp" "github.com/havline/havline/internal/frp"
"github.com/fonu/fonu/internal/logstore" "github.com/havline/havline/internal/logstore"
"github.com/fonu/fonu/internal/metrics" "github.com/havline/havline/internal/metrics"
"github.com/fonu/fonu/internal/monitor" "github.com/havline/havline/internal/monitor"
"github.com/fonu/fonu/internal/nginx" "github.com/havline/havline/internal/nginx"
"github.com/fonu/fonu/internal/notify" "github.com/havline/havline/internal/notify"
"github.com/fonu/fonu/internal/proxy" "github.com/havline/havline/internal/proxy"
"github.com/fonu/fonu/internal/scheduler" "github.com/havline/havline/internal/scheduler"
"github.com/fonu/fonu/internal/secret" "github.com/havline/havline/internal/secret"
"github.com/fonu/fonu/internal/service" "github.com/havline/havline/internal/service"
"github.com/fonu/fonu/internal/settings" "github.com/havline/havline/internal/settings"
"github.com/fonu/fonu/internal/traffic" "github.com/havline/havline/internal/traffic"
"github.com/fonu/fonu/internal/updater" "github.com/havline/havline/internal/updater"
) )
type App struct { type App struct {
+4 -4
View File
@@ -5,10 +5,10 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"github.com/fonu/fonu/internal/cloudflared" "github.com/havline/havline/internal/cloudflared"
"github.com/fonu/fonu/internal/frp" "github.com/havline/havline/internal/frp"
"github.com/fonu/fonu/internal/monitor" "github.com/havline/havline/internal/monitor"
"github.com/fonu/fonu/internal/traffic" "github.com/havline/havline/internal/traffic"
) )
// monitorSource 把 frp 服务适配成 monitor.Source:monitor 只关心「状态快照」, // monitorSource 把 frp 服务适配成 monitor.Source:monitor 只关心「状态快照」,
+2 -2
View File
@@ -3,8 +3,8 @@ package app
import ( import (
"testing" "testing"
"github.com/fonu/fonu/internal/frp" "github.com/havline/havline/internal/frp"
"github.com/fonu/fonu/internal/monitor" "github.com/havline/havline/internal/monitor"
) )
// monitor 与 frp 两侧的状态字面量必须一致,落库前由 healthStateValue 翻译一次; // monitor 与 frp 两侧的状态字面量必须一致,落库前由 healthStateValue 翻译一次;
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"github.com/fonu/fonu/internal/validate" "github.com/havline/havline/internal/validate"
) )
const sessionTTL = 7 * 24 * time.Hour const sessionTTL = 7 * 24 * time.Hour
+2 -2
View File
@@ -7,11 +7,11 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/fonu/fonu/internal/db" "github.com/havline/havline/internal/db"
) )
func TestEnsureDefaultAdminCreatesOnce(t *testing.T) { func TestEnsureDefaultAdminCreatesOnce(t *testing.T) {
conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db")) conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
if err != nil { if err != nil {
t.Fatalf("open db: %v", err) t.Fatalf("open db: %v", err)
} }
+2 -2
View File
@@ -6,12 +6,12 @@ import (
"testing" "testing"
"time" "time"
"github.com/fonu/fonu/internal/db" "github.com/havline/havline/internal/db"
) )
func newTestService(t *testing.T) *Service { func newTestService(t *testing.T) *Service {
t.Helper() t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db")) conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
if err != nil { if err != nil {
t.Fatalf("open db: %v", err) t.Fatalf("open db: %v", err)
} }
+5 -5
View File
@@ -12,7 +12,7 @@ import (
"sort" "sort"
"time" "time"
"github.com/fonu/fonu/internal/settings" "github.com/havline/havline/internal/settings"
) )
// 保留份数的边界:0/负数按默认值处理,上限防止把磁盘塞满 // 保留份数的边界:0/负数按默认值处理,上限防止把磁盘塞满
@@ -23,7 +23,7 @@ const (
// archiveNamePattern 备份命名格式(Export 用 CreateTemp 生成,后缀是随机数字)。 // archiveNamePattern 备份命名格式(Export 用 CreateTemp 生成,后缀是随机数字)。
// List / Path 都按它过滤,避免把 backups 目录里任意文件当成备份读写或删除。 // List / Path 都按它过滤,避免把 backups 目录里任意文件当成备份读写或删除。
var archiveNamePattern = regexp.MustCompile(`^fonu-backup-\d{8}-\d{6}-\d+\.tar\.gz$`) var archiveNamePattern = regexp.MustCompile(`^havline-backup-\d{8}-\d{6}-\d+\.tar\.gz$`)
// Archive 留在磁盘上的一份备份 // Archive 留在磁盘上的一份备份
type Archive struct { type Archive struct {
@@ -104,7 +104,7 @@ func (s *Service) Remove(name string) error {
} }
// Verify 回读归档并返回条目数:确认这份备份不是写了一半的残件, // Verify 回读归档并返回条目数:确认这份备份不是写了一半的残件,
// 同时要求包内必须有 fonu.db(否则多半不是 Fonu 的备份)。 // 同时要求包内必须有 havline.db(否则多半不是 Havline 的备份)。
func (s *Service) Verify(ctx context.Context, path string) (int, error) { func (s *Service) Verify(ctx context.Context, path string) (int, error) {
file, err := os.Open(path) file, err := os.Open(path)
if err != nil { if err != nil {
@@ -133,12 +133,12 @@ func (s *Service) Verify(ctx context.Context, path string) (int, error) {
return count, err return count, err
} }
count++ count++
if header.Name == "fonu.db" { if header.Name == "havline.db" {
hasDatabase = true hasDatabase = true
} }
} }
if !hasDatabase { if !hasDatabase {
return count, fmt.Errorf("备份里没有 fonu.db,可能不是 Fonu 的备份") return count, fmt.Errorf("备份里没有 havline.db,可能不是 Havline 的备份")
} }
return count, nil return count, nil
} }
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/fonu/fonu/internal/notify" "github.com/havline/havline/internal/notify"
"github.com/fonu/fonu/internal/settings" "github.com/havline/havline/internal/settings"
) )
// AutoInterval 自动备份间隔。scheduler 启动时会先跑一轮,靠「最新备份是否还在间隔内」 // AutoInterval 自动备份间隔。scheduler 启动时会先跑一轮,靠「最新备份是否还在间隔内」
+8 -8
View File
@@ -31,7 +31,7 @@ func TestDueForBackup(t *testing.T) {
func TestExportVerifyRoundTrip(t *testing.T) { func TestExportVerifyRoundTrip(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "fonu.db"), []byte("db"), 0o600); err != nil { if err := os.WriteFile(filepath.Join(dir, "havline.db"), []byte("db"), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
svc := New(dir) svc := New(dir)
@@ -62,7 +62,7 @@ func TestExportVerifyRoundTrip(t *testing.T) {
func TestVerifyRejectsGarbage(t *testing.T) { func TestVerifyRejectsGarbage(t *testing.T) {
svc := New(t.TempDir()) svc := New(t.TempDir())
path := filepath.Join(svc.backupsDir(), "fonu-backup-20260925-000000-1.tar.gz") path := filepath.Join(svc.backupsDir(), "havline-backup-20260925-000000-1.tar.gz")
if err := os.MkdirAll(svc.backupsDir(), 0o755); err != nil { if err := os.MkdirAll(svc.backupsDir(), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -80,9 +80,9 @@ func TestListPruneAndPath(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
for _, name := range []string{ for _, name := range []string{
"fonu-backup-20260923-100000-1.tar.gz", "havline-backup-20260923-100000-1.tar.gz",
"fonu-backup-20260924-100000-2.tar.gz", "havline-backup-20260924-100000-2.tar.gz",
"fonu-backup-20260925-100000-3.tar.gz", "havline-backup-20260925-100000-3.tar.gz",
} { } {
if err := os.WriteFile(filepath.Join(svc.backupsDir(), name), []byte("x"), 0o600); err != nil { if err := os.WriteFile(filepath.Join(svc.backupsDir(), name), []byte("x"), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -100,7 +100,7 @@ func TestListPruneAndPath(t *testing.T) {
if len(archives) != 3 { if len(archives) != 3 {
t.Fatalf("应只列出 3 份备份,实际 %d", len(archives)) t.Fatalf("应只列出 3 份备份,实际 %d", len(archives))
} }
if archives[0].Name != "fonu-backup-20260925-100000-3.tar.gz" { if archives[0].Name != "havline-backup-20260925-100000-3.tar.gz" {
t.Fatalf("应按时间倒序,实际首条 %s", archives[0].Name) t.Fatalf("应按时间倒序,实际首条 %s", archives[0].Name)
} }
@@ -116,12 +116,12 @@ func TestListPruneAndPath(t *testing.T) {
} }
// 名称校验:不能让备份名把读写带出备份目录 // 名称校验:不能让备份名把读写带出备份目录
for _, name := range []string{"../fonu.db", "notes.txt", "", "fonu-backup-x.tar.gz"} { for _, name := range []string{"../havline.db", "notes.txt", "", "havline-backup-x.tar.gz"} {
if _, err := svc.Path(name); err == nil { if _, err := svc.Path(name); err == nil {
t.Fatalf("非法备份名应拒绝:%q", name) t.Fatalf("非法备份名应拒绝:%q", name)
} }
} }
if err := svc.Remove("fonu-backup-20260920-100000-9.tar.gz"); err == nil { if err := svc.Remove("havline-backup-20260920-100000-9.tar.gz"); err == nil {
t.Fatal("删除不存在的备份应报错") t.Fatal("删除不存在的备份应报错")
} }
if _, err := svc.Prune(0); err != nil { if _, err := svc.Prune(0); err != nil {
+2 -2
View File
@@ -35,7 +35,7 @@ func (s *Service) Export(ctx context.Context) (string, error) {
return "", err return "", err
} }
// CreateTemp 保证同一秒内多次导出不会覆盖已有备份(文件名带随机后缀) // CreateTemp 保证同一秒内多次导出不会覆盖已有备份(文件名带随机后缀)
file, err := os.CreateTemp(s.backupsDir(), fmt.Sprintf("fonu-backup-%s-*.tar.gz", time.Now().Format(backupTimeFmt))) file, err := os.CreateTemp(s.backupsDir(), fmt.Sprintf("havline-backup-%s-*.tar.gz", time.Now().Format(backupTimeFmt)))
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -59,7 +59,7 @@ func (s *Service) writeArchive(ctx context.Context, file *os.File, outPath strin
gw := gzip.NewWriter(file) gw := gzip.NewWriter(file)
tw := tar.NewWriter(gw) tw := tar.NewWriter(gw)
for _, entry := range []string{"fonu.db", "certs", "nginx", "cloudflared/tunnels"} { for _, entry := range []string{"havline.db", "certs", "nginx", "cloudflared/tunnels"} {
if err := addToArchive(ctx, tw, s.dataDir, entry); err != nil { if err := addToArchive(ctx, tw, s.dataDir, entry); err != nil {
discard() discard()
return err return err
+5 -5
View File
@@ -13,11 +13,11 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/fonu/fonu/internal/config" "github.com/havline/havline/internal/config"
"github.com/fonu/fonu/internal/fsutil" "github.com/havline/havline/internal/fsutil"
"github.com/fonu/fonu/internal/nginx" "github.com/havline/havline/internal/nginx"
"github.com/fonu/fonu/internal/proxy" "github.com/havline/havline/internal/proxy"
"github.com/fonu/fonu/internal/settings" "github.com/havline/havline/internal/settings"
) )
const ( const (
+1 -1
View File
@@ -228,7 +228,7 @@ func (a *CloudflareAPI) EnsureCNAME(ctx context.Context, zoneID, hostname, targe
return created.ID, nil return created.ID, nil
} }
// DeleteDNSRecord 删除一条明确由 Fonu 接管的 DNS 记录。 // DeleteDNSRecord 删除一条明确由 Havline 接管的 DNS 记录。
func (a *CloudflareAPI) DeleteDNSRecord(ctx context.Context, zoneID, recordID string) error { func (a *CloudflareAPI) DeleteDNSRecord(ctx context.Context, zoneID, recordID string) error {
return a.do(ctx, http.MethodDelete, "/zones/"+zoneID+"/dns_records/"+recordID, nil, nil) return a.do(ctx, http.MethodDelete, "/zones/"+zoneID+"/dns_records/"+recordID, nil, nil)
} }
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/fonu/fonu/internal/proxy" "github.com/havline/havline/internal/proxy"
) )
type tokenPayload struct { type tokenPayload struct {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/fonu/fonu/internal/proxy" "github.com/havline/havline/internal/proxy"
) )
func TestDecodeToken(t *testing.T) { func TestDecodeToken(t *testing.T) {
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/fonu/fonu/internal/config" "github.com/havline/havline/internal/config"
"github.com/fonu/fonu/internal/db" "github.com/havline/havline/internal/db"
) )
func TestComputeHealthUptimeExcludesUnknown(t *testing.T) { func TestComputeHealthUptimeExcludesUnknown(t *testing.T) {
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/fonu/fonu/internal/config" "github.com/havline/havline/internal/config"
) )
var metricsPortPattern = regexp.MustCompile(`(?i)starting metrics server on 127\.0\.0\.1:(\d+)/metrics`) var metricsPortPattern = regexp.MustCompile(`(?i)starting metrics server on 127\.0\.0\.1:(\d+)/metrics`)

Some files were not shown because too many files have changed in this diff Show More