Compare commits

9 Commits
Author SHA1 Message Date
mrlee 2856040883 chore(release): 发布 v1.9.3
CI / verify (push) Canceled after 0s
Docker Release / build-and-push (amd64, linux/amd64) (push) Canceled after 0s
Docker Release / build-and-push (arm64, linux/arm64) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Docker Release / create-manifest (push) Canceled after 0s
- VERSION 与 web/package.json、package-lock 提升到 1.9.3
- README 版本徽章与飞牛安装包名同步到 1.9.3
- CHANGELOG 的 [Unreleased] 段落转为 [v1.9.3] - 2026-10-11
2026-10-11 15:18:32 +08:00
mrlee 0f68101503 feat(frp): frps vhost 回源端口支持 80/443,公网反代在该配置下不再适用
- 服务端表单不再硬性拒绝 vhostHTTPPort=80 / vhostHTTPSPort=443,改为保存时弹窗二次确认;
  确认后请求携带 allow_privileged_vhost_ports 提交并由后端校验放行,未携带标记的请求(含直接调 API)仍被拒绝
- frps.toml 生成改为写入风险注释、不再报错;各规则的公网反代回源地址随 80/443 自动跟随
- 「公网反代」页检测到 80/443 后显示说明卡片并隐藏统计/Nginx/规则台账/漂移区块,
  同时跳过 agent 的路由、漂移与健康请求
- 后端 DeployAgentRoute 与 RedeployAllRoutes(含漂移一键修复)拒绝下发,自动下发路径 syncAgentRoute 跳过
- 新增 internal/frp/vhost_port_test.go:校验、二次确认、frps.toml 生成、反代回源跟随与守卫对照
2026-10-11 15:18:32 +08:00
mrlee 50b8cc1351 Update README.md 2026-10-11 13:58:57 +08:00
mrlee d1c498a286 chore: 停止跟踪 docs/ 并移除 README 中的文档链接
CI / verify (push) Canceled after 0s
- docs/ 只保留在本地,加入 .gitignore(85 个文件移出版本库,工作区文件不受影响)
- README 删除截图画廊与 docs/user-guide.md 引用,并从项目结构表中移除 docs 行
2026-10-11 13:51:35 +08:00
mrlee 9c77d23078 Delete docs directory 2026-10-11 13:32:37 +08:00
mrlee fb57b2aef8 chore: 同步版本号到 1.9.2、整理 CHANGELOG 并移除误提交的 exe
- README 版本徽章与飞牛安装包名对齐到 1.9.2
- web/package-lock.json 根版本与 web/package.json 保持一致(原为 1.9.0)
- CHANGELOG 合并整理 1.1.0 起的相近补丁版本并重新编排版本号
- 取消跟踪 Windows 构建产物 havline-agent.exe,并加入 .gitignore
2026-10-11 13:23:56 +08:00
mrlee 2a0740a60f chore(release): 发布 v1.9.2
CI / verify (push) Canceled after 0s
Docker Release / build-and-push (amd64, linux/amd64) (push) Canceled after 0s
Docker Release / build-and-push (arm64, linux/arm64) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Docker Release / create-manifest (push) Canceled after 0s
- 「公网服务端」新增 Nginx 启停控制(Agent 0.15.2,停止带二次确认)
- frps 启动失败不再误报成功,并增强启动诊断
- frps vhost 端口禁止 80/443,避免与 VPS 上 Nginx 冲突
- 旧服务端配置的 tcp_mux / login_fail_exit 默认值补齐
- 新增安全审查、页面布局方案与场景操作文档
2026-10-07 19:26:02 +08:00
mrlee a7d71752e3 feat(updater): generate shared token without init container 2026-10-05 19:11:05 +08:00
mrlee 9aaefa3b77 fix: surface frps startup failures and avoid nginx port conflicts 2026-10-05 13:07:56 +08:00
137 changed files with 2940 additions and 19555 deletions
+5
View File
@@ -13,6 +13,11 @@ dist/
.cursor/ .cursor/
.playwright-cli/ .playwright-cli/
output/ output/
# Windows 本地构建产物不入库(历史上误提交过 havline-agent.exe,10MB 级二进制)
*.exe
# 项目文档(方案 / 手册 / 截图)只保留在本地,不入版本库、不推送
# 例外:随仓库分发的示例可在取消忽略后单独跟踪
/docs/
web/public/111favicon.png web/public/111favicon.png
web/src/assets/brand/logo00.png web/src/assets/brand/logo00.png
# scripts/ 下的本地辅助脚本不入库,但随仓库分发的 agent 安装脚本需要跟踪 # scripts/ 下的本地辅助脚本不入库,但随仓库分发的 agent 安装脚本需要跟踪
+205 -701
View File
File diff suppressed because it is too large Load Diff
+258 -152
View File
@@ -1,232 +1,338 @@
<p align="center"> <p align="center">
<img src="web/src/assets/brand/havline-logo.svg" alt="Havline Logo" width="112" /> <img src="web/src/assets/brand/havline-logo.svg" alt="Havline Logo" width="96" />
</p> </p>
<h1 align="center">Havline</h1> <h1 align="center">Havline</h1>
<p align="center"><strong>让每一台 NAS,都能安全地连接世界。</strong></p> <p align="center"><strong>让 NAS 访问更简单</strong></p>
<p align="center"> <p align="center">
面向 <strong>飞牛 fnOS</strong> 等 NAS 环境的轻量公网访问管理控制台<br /> 面向 <strong>飞牛 fnOS</strong> 的轻量公网访问管理工具<br />
反向代理 · DDNS · HTTPS 证书 · 内网穿透 · Cloudflare Tunnel · 一键升级 反向代理 · DDNS · HTTPS 证书 · 内网穿透 · Cloudflare Tunnel · 一站式 Web 管理
</p> </p>
<p align="center"> <p align="center">
<a href="https://github.com/mrlee233/havline/releases"><img src="https://img.shields.io/badge/version-1.9.3-22c55e?style=flat-square" alt="发布版本" /></a>
<img src="https://img.shields.io/badge/目标平台-飞牛_fnOS-22c55e?style=flat-square" alt="飞牛 fnOS" /> <img src="https://img.shields.io/badge/目标平台-飞牛_fnOS-22c55e?style=flat-square" alt="飞牛 fnOS" />
<img src="https://img.shields.io/badge/Go-1.23-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go 1.23" /> <img src="https://img.shields.io/badge/其他环境-未测试-94a3b8?style=flat-square" alt="其他环境未测试" />
<img src="https://img.shields.io/badge/Go-1.23-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go" />
<img src="https://img.shields.io/badge/Vue-3-4FC08D?style=flat-square&logo=vuedotjs&logoColor=white" alt="Vue 3" /> <img src="https://img.shields.io/badge/Vue-3-4FC08D?style=flat-square&logo=vuedotjs&logoColor=white" alt="Vue 3" />
<img src="https://img.shields.io/badge/Docker-amd64_%7C_arm64-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /> <img src="https://img.shields.io/badge/Nginx-内置-009639?style=flat-square&logo=nginx&logoColor=white" alt="Nginx" />
<a href="https://www.gnu.org/licenses/gpl-3.0.html"> <a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://img.shields.io/badge/license-GPL--3.0-blue?style=flat-square" alt="GPL-3.0" /></a>
<img src="https://img.shields.io/badge/license-GPL--3.0-blue?style=flat-square" alt="GPL-3.0" />
</a>
</p> </p>
> **Havline 是基于开源项目 [O96u/Fonu](https://github.com/O96u/Fonu) 1.0.2 版本进行二次开发的项目。**
> 原项目已经完成了反向代理、DDNS、证书和内网穿透等核心能力。Havline 在此基础上进行品牌重塑、
> 功能迭代、界面调整、部署流程整理和文档重构,并继续遵循 GPL-3.0 开源协议。
--- ---
## 项目简介 ## 简介
NAS 的价值不只在存储,更在于把文件、媒体、自动化工具和家庭服务安全地带到需要它们的地方。 **Havline** 是一款主要为 **飞牛 fnOS** 编写的反代与管理工具,帮助你在 NAS 上快速搭建稳定的公网访问能力:通过 Web 界面管理 Nginx 反向代理、自动同步 DDNS、申请与续期 Let's Encrypt 证书,并在一个仪表盘里查看服务状态、访问日志与流量统计。
**Havline** 把 NAS 用户最常用、也最容易出错的公网访问能力收进一个 Web 控制台: 无需手写 Nginx 配置,也无需在多个工具之间切换。当前开发与测试环境以飞牛 fnOS 为主,**群晖、威联通、自建 Linux 等其他环境尚未充分测试**,部署前请自行验证。
- 管理 Nginx 反向代理,不必手工维护复杂配置; Havline 基于开源项目 [O96u/Fonu](https://github.com/O96u/Fonu) **1.0.2** 继续开发,保留上游在反向代理、DDNS、证书和内网穿透上的基础能力,并继续扩展公网 Agent、Cloudflare Tunnel、一键升级和飞牛应用交付。
- 自动同步 DDNS,保持域名始终指向当前公网 IP;
- 使用 ACME DNS-01 申请与续期 HTTPS 证书;
- 通过 FRP 在无公网 IP 时经 VPS 中转服务;
- 通过公网 Agent 远程管理 VPS 上的 frps 与 Nginx;
- 使用 Cloudflare Tunnel 发布服务,不必开放公网端口;
- 在一个仪表盘中查看状态、日志、流量和运行健康度。
当前开发与测试环境以 **飞牛 fnOS** 为主。群晖、威联通、自建 Linux 等其他环境尚未充分测试, ## 功能特性
部署前请自行验证端口、权限和数据目录。
## 界面预览 | 模块 | 说明 |
| -------------- | ---------------------------------------------------------------------------------------------- |
| **反向代理** | 多域名、多端口、HTTP/HTTPS、自动重定向;实时访问日志、流量统计、当前连接 IP |
| **DDNS** | 支持 Cloudflare、DNSPod、阿里云、腾讯云、火山引擎 DNS;单任务多根域名,自动同步公网 IP |
| **HTTPS 证书** | ACME 自动申请与续期;证书/私钥/ZIP 下载;申请进度实时日志 |
| **仪表盘** | 公网 IP、域名、证书、服务状态一览;请求趋势与运行健康度 |
| **日志中心** | 系统日志、Nginx 访问日志、Nginx 错误日志;分页筛选与自动刷新 |
| **内网穿透** | FRP 多服务端管理,支持 TCP/UDP/HTTP/HTTPS 等代理类型;客户端按需下载、配置与运行诊断 |
| **公网 Agent** | 通过 SSH 安装和维护 VPS Agent,远程管理 frps、Nginx、证书和路由 |
| **Cloudflare Tunnel** | cloudflared 按需下载、隧道与路由管理、DNS 同步、健康检查和漂移修复 |
| **一键升级** | 含 updater 的 Compose 部署支持镜像构建、主服务重建和版本健康检查 |
| **系统设置** | 常规 / 通知 / 安全 / 高级分栏;邮件/Webhook/Telegram 告警、备份与恢复 |
| **其他** | 深色模式、配置导出/导入、版本更新提示 |
| 仪表盘 | 反向代理 | ## 相比上游 Fonu 的主要增量
| 方向 | Havline 增量 |
| --- | --- | | --- | --- |
| ![仪表盘](docs/screenshots/dashboard.png) | ![反向代理](docs/screenshots/proxy.png) | | **公网服务端** | 通过 SSH 安装、升级和维护 VPS 上的 `havline-agent`,远程管理 frps、Nginx、证书和公网反代 |
| **FRP 管理** | 多服务端管理、FRP 客户端按需下载、代理规则校验、运行诊断、路由健康与漂移检测 |
| **Cloudflare Tunnel** | cloudflared 按需下载、Account/API Token 预检、隧道与路由管理、DNS 同步、健康巡检和漂移修复 |
| **一键升级** | Compose + updater sidecar,支持构建/重建/健康检查,并在部分失败场景下尝试回滚 |
| **飞牛交付** | 提供 Docker 离线镜像包与 Native 应用两种形态,支持统一网关 `/app/havline` 接入 |
| DDNS | 证书管理 | ## 技术栈
| 层级 | 技术 |
| ------------ | ---------------------------------------------------------------------- |
| **后端** | Go 1.23、标准库 HTTP、SQLite(modernc.org/sqlite) |
| **反向代理** | 内置 Nginx(动态生成配置、热重载) |
| **证书** | go-acme/lego(ACME DNS-01) |
| **DDNS** | Cloudflare / DNSPod / 阿里云 / 腾讯云 / 火山引擎 DNS API |
| **内网穿透** | FRP 客户端按需下载;多服务端和代理规则管理,支持公网 Agent |
| **隧道** | Cloudflare Tunnel / cloudflared |
| **前端** | Vue 3、TypeScript、Vite、Naive UI、ECharts |
| **部署** | Docker 多架构镜像(amd64 / arm64)、GitHub Actions CI |
## 项目结构
| 路径 | 说明 |
| --- | --- | | --- | --- |
| ![DDNS](docs/screenshots/ddns.png) | ![证书管理](docs/screenshots/certificates.png) | | `cmd/havline` | 主程序入口,提供 Web 管理后台、API、调度和内置 Nginx/FRP 管理 |
| `cmd/havline-agent` | 公网 VPS 上的 Agent,接收主程序下发并管理 frps、Nginx、证书和路由 |
| `cmd/havline-updater` | 一键升级 sidecar,仅它挂载 Docker Socket,并通过 Unix Socket 与主程序通信 |
| `internal/api` | HTTP 路由、鉴权中间件、请求处理器和 API 响应封装 |
| `internal/app` | 应用启动、服务装配、统一网关和运行生命周期 |
| `internal/nginx`、`internal/nginxtmpl` | Nginx 配置生成、共享模板、安全/限流/geo/TLS 片段和热重载 |
| `internal/proxy`、`internal/service` | 反向代理规则、证书绑定、访问策略和 Nginx 配置编排 |
| `internal/frp` | FRP 多服务端、规则、运行进程、Agent 安装、诊断、健康和漂移检测 |
| `internal/cloudflared` | Cloudflare Tunnel 设置、隧道、路由、DNS、健康事件和漂移修复 |
| `internal/agent` | Agent 服务端实现,包括鉴权、监听策略、防火墙提示、Nginx/frps 操作 |
| `internal/updater` | updater 服务端、客户端、执行器和远端版本读取 |
| `internal/ddns`、`internal/certificate`、`internal/acme` | DDNS 服务商、证书存储、导入和 ACME 申请续期 |
| `internal/backup`、`internal/notify`、`internal/monitor`、`internal/metrics` | 备份恢复、通知渠道、监控采集和指标导出 |
| `web/src` | Vue 3 前端,包含 API 客户端、组合式函数、组件、路由、样式和页面视图 |
| `migrations` | SQLite 数据库迁移 |
| `docker-compose*.yml` | Host、Bridge 和单文件 Git 构建部署编排 |
## 功能总览 ## 部署
| 模块 | 能力 | 源码仓库:[https://github.com/mrlee233/havline](https://github.com/mrlee233/havline) · [发布版本](https://github.com/mrlee233/havline/releases)
| --- | --- |
| **反向代理** | 多域名、多端口、HTTP/HTTPS、自动跳转、Nginx 配置生成与热重载 |
| **DDNS** | Cloudflare、DNSPod、阿里云、腾讯云、火山引擎;单任务多域名与自动同步 |
| **HTTPS 证书** | ACME 自动申请与续期、通配符证书、手动导入、证书与私钥下载 |
| **内网穿透** | FRP 多服务端、8 种代理类型、插件、规则下发、流量与连接数监控 |
| **公网服务端** | SSH 安装与升级 `havline-agent`,远程管理 VPS 上的 frps 与 Nginx |
| **Cloudflare Tunnel** | cloudflared 安装、隧道、路由、DNS、健康检查、漂移修复 |
| **仪表盘** | 公网 IP、域名、证书、代理状态、请求趋势、流量和运行健康度 |
| **日志中心** | 系统日志、Nginx 访问日志、错误日志;分页、筛选与自动刷新 |
| **通知与备份** | 邮件、Webhook、Telegram、钉钉、飞书、企业微信;配置导出与恢复 |
| **一键升级** | Docker Compose sidecar 构建、重建、健康检查与失败回滚 |
## 快速开始 以下方式均从源码构建,不使用上游 Docker 镜像。
### 推荐:飞牛 fnOS Host 模式 | 场景 | 推荐方式 | 说明 |
| --- | --- | --- |
| 飞牛 fnOS 常规安装 | Host 模式 Compose | 避开系统已占用的 `80/443`,默认使用 `6893/18080/9443` |
| 非 host 网络或自定义端口映射 | Bridge 模式 Compose | 主服务和 updater 通过端口映射访问,适合测试或自建 Linux |
| 只拿一个 Compose 文件部署 | 单文件 Git 构建 | updater 从远端仓库读取版本并构建,本地不需要完整源码 |
| 飞牛应用商店安装 | Docker 离线包或 Native FPK | 见下方「飞牛 fnOS 应用」章节 |
飞牛系统通常已经占用 `80/443`,推荐使用 **host 网络 + 高位反代端口**: ### 快速部署(推荐 · 飞牛 fnOS · Host 模式)
飞牛系统已占用 **80/443**,使用 **host 网络** + **高位反代端口**,避免与系统 Nginx 冲突。
```bash ```bash
docker run -d \ git clone --branch main https://github.com/mrlee233/havline.git
--name havline \ cd havline
--net=host \ docker compose -f docker-compose.host.yml up -d --build
-v ./data:/data \
--restart unless-stopped \
havline:local
``` ```
| 入口 | 默认地址 | | 入口 | 地址 |
| --- | --- | | ------------------ | ----------------------- |
| 管理后台 | `http://<NAS-IP>:6893` | | 管理后台 | `http://<NAS-IP>:6893` |
| HTTP 反向代理 | `http://<NAS-IP>:18080` | | HTTP 反代(默认) | `http://<NAS-IP>:18080` |
| HTTPS 反向代理 | `https://<NAS-IP>:9443` | | HTTPS 反代(默认) | `https://<NAS-IP>:9443` |
### Docker Compose - 默认命名卷挂载到 `/data`,存放数据库、Nginx 配置、证书与日志;可通过 `HAVLINE_DATA_PATH` 选择绑定目录,已有数据须先迁移
- Host 编排设置反代默认端口为 `18080`、`9443`,可通过环境变量修改;该编排不包含 updater
### 其他部署方式
<details>
<summary>本地源码 + Bridge 网络</summary>
在已克隆的源码目录中执行:
```bash ```bash
# 飞牛 fnOS / host 网络 docker compose -f docker-compose.yml up -d --build
HAVLINE_SESSION_SECRET=your-secret \
docker compose -f docker-compose.host.yml up -d
# Bridge 网络
HAVLINE_SESSION_SECRET=your-secret \
docker compose -f docker-compose.yml up -d --build
``` ```
默认映射 `6893:6893`、`18080:80`、`9443:443`。主程序首次启动时会在共享卷中自动生成 updater Token,updater 等待并读取同一文件,不会创建额外的初始化容器;只有 updater 挂载 Docker Socket。
</details>
<details>
<summary>单文件 Git 构建(不需要本地源码)</summary>
```bash
mkdir -p /opt/havline && cd /opt/havline
curl -fsSLo docker-compose.hub.yml \
https://github.com/mrlee233/havline/raw/branch/main/docker-compose.hub.yml
docker compose -f docker-compose.hub.yml up -d --build
```
BuildKit 从当前仓库 `main` 分支构建,默认使用 Bridge 端口映射。Token 由主程序自动生成,`HAVLINE_VERSION` 只控制镜像标签,不锁定源码提交。
</details>
<details>
<summary>手动构建镜像</summary>
```bash
docker build -t havline:local .
```
在源码根目录执行;构建镜像不会自动启动容器,部署时仍需选择合适的 Compose 编排。
</details>
### 首次登录 ### 首次登录
1. 查看容器或系统日志,获取自动生成的 `admin` 初始密码; 1. 启动后查看容器/运行日志,获取自动生成的 **admin 初始密码**
2. 打开 `http://<NAS-IP>:6893/login`; 2. 访问 `http://<NAS-IP>:6893/login` 登录
3. 登录后立即在 **系统设置 → 管理员账户** 中修改密码。 3. 在 **设置** 中立即修改管理员密码
> 完整部署、权限、端口、数据目录、迁移和故障排查请阅读 ### FRP 内网穿透(无公网 IP)
> [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) 与
> [`docs/container-deploy-and-data.md`](docs/container-deploy-and-data.md)。
## 内网穿透与公网服务端 适用于无法端口映射、无公网 IPv4 的场景。Havline 在 **内网穿透** 页面统一管理多个服务端与代理规则,**frpc 按需下载**:
没有公网 IPv4 或无法端口映射时,可以使用 FRP 经 VPS 中转: - **Web 网关**:外网 HTTP/HTTPS 经 VPS 上的 frps 中转至本地 Havline Nginx(默认 `18080` / `9443`),域名分流、证书、访问控制、日志仍由 Nginx 处理
- **端口转发(TCP/UDP)**:将 VPS 远程端口映射到内网服务(如 SSH、数据库、DNS/游戏等 UDP);可与 Web 网关同时启用,纯端口穿透可不配置 Web 域名
- **服务端设置**:填写 frps 地址、端口、认证和 TLS 选项;保存后添加代理规则,校验配置再启动
```text **Web 流量路径**:用户 → DNS(解析到 VPS)→ frps → frpc → Havline Nginx → 内网服务
用户 → DNS(VPS) → frps → frpc → Havline Nginx → 内网服务
**端口转发流量路径**:用户 → VPS 公网 IP:远程端口 → frps → frpc → 内网 `IP:端口`(TCP 或 UDP)
#### 1. VPS 部署 frps
在具有公网 IP 的 VPS 上安装 [frp](https://github.com/fatedier/frp),选择与本地 frpc 兼容的版本。在服务端详情中查看生成的 `frps.toml` 参考,也可通过公网 Agent 管理 frps。
典型 Web 网关示例:
```toml
bindAddr = "0.0.0.0"
bindPort = 7000
auth.method = "token"
auth.token = "your-secret-token"
vhostHTTPPort = 80
vhostHTTPSPort = 443
``` ```
公网 VPS 上可以安装 `havline-agent`,由本地 Havline 通过 SSH 管理 agent、frps、Nginx 和证书。 启动:`frps -c frps.toml`。安全组/防火墙需放行 **7000**(控制连接)、**80/443**(Web 虚拟主机),以及 **隧道远程端口**(在 Havline 添加规则后按提示放行;UDP 隧道需在防火墙同时放行对应端口的 UDP)。
| 主题 | 文档 | #### 2. Havline 配置 frpc
| --- | --- |
| FRP 使用与场景 | [`docs/FRP公网暴露操作手册.md`](docs/FRP公网暴露操作手册.md) |
| 公网服务端管理 | [`docs/frps服务端管理方案.md`](docs/frps服务端管理方案.md) |
| Agent 监听与安全 | [`docs/agent-listen-strategy-plan.md`](docs/agent-listen-strategy-plan.md) |
## Cloudflare Tunnel 在侧栏 **内网穿透** 页面:
Havline 支持将 Cloudflare Tunnel 作为服务发布出口。反向代理规则可选择本机 Nginx 或 1. 添加服务端,填写服务器地址、端口、认证信息和 TLS 选项,与 VPS 上 frps 保持一致。
Cloudflare 出口,并自动派生隧道 ingress、同步 DNS、检查健康状态和修复云端漂移。 2. 添加 HTTP / HTTPS 代理时填写域名和本机 Nginx 目标端口;Host 编排默认使用 `18080` / `9443`,Bridge 容器内通常为 `80` / `443`,以实际规则为准。
3. 添加 TCP / UDP 代理时填写 VPS 远程端口及内网目标地址;开放对应安全组和防火墙端口。
4. 在应用配置中下载或激活 FRP 客户端,校验配置后启动,通过服务端诊断和日志检查运行状态。
| 主题 | 文档 | 控制连接成功不等于业务已连通,还需检查代理端口、内网目标、DNS 和 Nginx 路由。
| --- | --- |
| 当前功能 | [`docs/cloudflared-current-features.md`](docs/cloudflared-current-features.md) |
| 实施方案 | [`docs/cloudflared-havline-fit-plan.md`](docs/cloudflared-havline-fit-plan.md) |
| UX 调研 | [`docs/cloudflared-github-ux-research.md`](docs/cloudflared-github-ux-research.md) |
## 一键升级 #### 3. DNS 与证书
`docker-compose.yml` 与 `docker-compose.hub.yml` 会启动受限的 `havline-updater` sidecar。 - **DDNS**:FRP 访问域名应解析到 **VPS 公网 IP**,而非 NAS IP;按需使用自定义 IP 或在 DNS 服务商处配置
检测到新版本后,侧栏会出现「一键升级」入口,可在页面中完成: - **HTTPS 证书**:Web 网关推荐继续使用 **DNS-01** 验证(Cloudflare、阿里云、腾讯云、DNSPod、火山引擎等已支持);证书仍在 NAS 侧 Nginx 终结
- **纯端口穿透**:无需为 TCP/UDP 隧道单独配置域名证书
1. 拉取或构建目标版本镜像; ### 公网 Agent 与 Cloudflare Tunnel
2. 重建 `havline` 服务;
3. 执行健康检查; - **公网 Agent**:在公网 Agent 页面配置 VPS 信息,按需通过 SSH 安装并测试连接,再管理 frps、Nginx 和证书;需要公网反代时部署对应路由。限制 Agent 管理通道访问,不要仅凭业务端口可用判断管理通道安全。
4. 失败时尝试回滚到升级前镜像。 - **Cloudflare Tunnel**:配置 Account ID 和 API Token,预检授权并按需下载 cloudflared;创建隧道后,在反向代理规则中选择 Cloudflare 出口和目标隧道,检查路由、DNS 与连接状态。托管模式从本地规则派生 ingress,修复漂移前先确认本地配置。
## 飞牛 fnOS 应用
Havline 提供两种飞牛应用形态,均支持统一网关入口 `/app/havline`。
### Docker 应用(离线镜像包)
- 独立仓库:<https://github.com/mrlee233/havline>
- 安装包:`havline-1.9.3.fpk`
- 包内包含 amd64 / arm64 离线镜像,安装时执行 `docker load`
- Compose 使用本地镜像并设置 `pull_policy: never`,不依赖外部 Registry
- 管理界面通过统一网关 `/app/havline` 访问
详细设计、安全边界、故障排查和验证步骤见 [`docs/system-upgrade.md`](docs/system-upgrade.md)。
## 环境变量 ## 环境变量
| 变量 | 默认值 | 说明 | | 变量 | 默认值 | 说明 |
| --- | --- | --- | | ----------------------- | ------------------------- | ------------------------------------------ |
| `HAVLINE_LISTEN` | `:6893` | 管理后台监听地址 | | `HAVLINE_LISTEN` | `:6893` | 管理后台监听地址 |
| `HAVLINE_DATA_DIR` | `/data` | 数据目录,包含数据库、Nginx、证书与日志 | | `HAVLINE_DATA_DIR` | `/data` | 数据目录(数据库、Nginx 配置、证书、日志) |
| `HAVLINE_SESSION_SECRET` | 空,自动生成 | 会话签名与凭据加密密钥;有数据后不要更换 | | `HAVLINE_SESSION_SECRET` | 空,自动生成 | 会话与凭据加密密钥,保存在数据目录;已有数据后不要更换 |
| `HAVLINE_NGINX_BIN` | `nginx` | Nginx 可执行文件路径 | | `HAVLINE_INITIAL_ADMIN_PASSWORD` | 空,自动生成 | 首次初始化管理员时使用的密码;不设置时会生成随机密码并输出到首次启动日志 |
| `HAVLINE_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代规则默认监听端口 | | `HAVLINE_UPDATER_TOKEN` | 空,自动生成 | 可选覆盖值;未设置时读取共享 Token 文件 |
| `HAVLINE_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代规则默认监听端口 | | `HAVLINE_UPDATER_TOKEN_FILE` | `/run/havline-updater/updater.token` | 主程序与 updater sidecar 共享的 Token 文件 |
| `HAVLINE_FRPC_BIN` | `frpc` | frpc 可执行文件路径 | | `HAVLINE_UPDATER_SOCKET` | `/run/havline-updater/updater.sock` | 主程序与 updater sidecar 通信的 Unix Socket |
| `HAVLINE_FRP_PID` | `{DATA_DIR}/frp/frpc.pid` | frpc PID 文件 | | `HAVLINE_DATA_PATH` | `havline-data` | Compose 挂载来源,可配置宿主机目录 |
| `HAVLINE_UPDATER_SOCKET` | `/run/havline-updater/updater.sock` | 一键升级 sidecar Socket | | `HAVLINE_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代规则的默认监听端口 |
| `HAVLINE_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代规则的默认监听端口 |
| `HAVLINE_NGINX_BIN` | `nginx` | Nginx 可执行文件路径(本地开发可指定) |
| `HAVLINE_FRPC_BIN` | `frpc` | frpc 可执行文件路径(运行时可按需下载) |
| `HAVLINE_FRP_PID` | `{DATA_DIR}/frp/frpc.pid` | frpc 进程 PID 文件路径 |
| `HAVLINE_GATEWAY_SOCKET` | 空 | 飞牛统一网关接入时使用的 Unix Socket |
| `HAVLINE_GATEWAY_PREFIX` | 空 | 统一网关访问前缀,例如 `/app/havline` |
| `HAVLINE_AGENT_LISTEN` | `127.0.0.1:7700` | Agent 默认监听地址 | | `HAVLINE_AGENT_LISTEN` | `127.0.0.1:7700` | Agent 默认监听地址 |
| `HAVLINE_AGENT_ALLOW_REMOTE` | `0` | 是否允许 Agent 监听非回环地址 | | `HAVLINE_AGENT_ALLOW_REMOTE` | `0` | 是否允许 Agent 监听非回环地址;启用前应确认防火墙和 HTTPS/隧道策略 |
| `HAVLINE_AGENT_TOKEN` | 安装时生成 | Agent Bearer Token | | `HAVLINE_AGENT_TOKEN` | 安装时生成 | Agent Bearer Token |
| `HAVLINE_UPDATE_PROJECT_DIR` | `/workspace` | updater 容器内 Compose 项目目录 |
| `HAVLINE_UPDATE_COMPOSE_FILE` | `docker-compose.yml` | updater 允许操作的 Compose 文件 |
| `HAVLINE_UPDATE_MODE` | `local` | 升级模式,支持 `local` 或 `git` |
| `HAVLINE_UPDATE_REPO` | 无 | `git` 模式下的远端仓库地址 |
| `HAVLINE_UPDATE_BRANCH` | `main` | `git` 模式下的分支 |
| `HAVLINE_UPDATE_HOST_DIR` | 自动发现 | 宿主机 Compose 文件目录;自动发现失败时必须显式设置 |
## 技术架构 ### 数据目录速查
| 层级 | 技术 | | 路径 | 内容 |
| --- | --- | | --- | --- |
| 后端 | Go 1.23、标准库 HTTP、SQLite | | `/data/havline.db` | 管理员、会话、代理、DDNS、FRP、Cloudflare、健康事件等 SQLite 数据 |
| 数据存储 | `modernc.org/sqlite`,单文件数据库 | | `/data/session_secret` | 会话签名与敏感凭据加密主密钥;已有数据后不可随意更换 |
| 反向代理 | Nginx,配置动态生成与热重载 | | `/data/nginx/` | 生成的 Nginx 配置、日志和相关运行文件 |
| 证书 | go-acme/lego,ACME DNS-01 | | `/data/certs/` | ACME 证书与私钥 |
| 内网穿透 | FRP 客户端 / 服务端与自研管理 Agent | | `/data/frp/` | frpc 配置、二进制、PID 和运行状态 |
| 隧道 | Cloudflare Tunnel / cloudflared |
| 前端 | Vue 3、TypeScript、Vite、Naive UI、ECharts | ## 注意事项
| 部署 | Docker、Docker Compose、Linux systemd |
| 架构 | amd64 / arm64 | 1. **密钥持久化**:会话密钥未显式配置时自动生成并写入数据目录;已有数据不要更换密钥。含 updater 的编排会由主程序在共享卷中自动生成 Token,旧部署仍可用 `HAVLINE_UPDATER_TOKEN` 覆盖。
2. **端口冲突**:宿主机已有 Nginx(如飞牛系统)占用 80/443 时,不要让 Havline 反代规则监听 80/443;使用 `18080/9443` 或 host 模式 + 环境变量。
3. **已有反代规则**:修改 `HAVLINE_NGINX_HTTP_PORT` / `HAVLINE_NGINX_HTTPS_PORT` 后,**不会自动更新**数据库中已有规则的端口,需在反代页手动修改并保存。
4. **访问日志**:仅统计经 Havline Nginx 反代的域名流量,不包含管理后台 `:6893` 的请求。
5. **证书与 DNS**:DNS-01 申请不要求域名指向 NAS,但需确保 DNS 凭证能写入对应根域的验证记录,并等待传播。业务访问仍需正确解析、端口和路由。
6. **数据持久化**:Docker 部署请挂载 volume 到 `HAVLINE_DATA_DIR`,避免容器重建后配置丢失。
7. **备份**:可通过设置页导出数据;文件级备份前停止服务,保留整个数据目录(数据库、密钥、证书私钥和运行配置);显式环境密钥需另行安全保留。
8. **升级**:含 updater 的 Compose 实例可使用一键升级;仅重建后版本健康检查失败时尝试镜像回滚,构建失败和数据库迁移回退需人工处理。升级前保留备份和旧镜像。
## 常见问题
| 现象 | 优先检查 |
| --- | --- |
| 无法登录或忘记初始密码 | 首次启动日志中的 `admin` 密码;若日志已丢失,停止服务后从备份恢复,或按部署文档处理数据库 |
| 启动后提示端口被占用 | 飞牛系统通常占用 `80/443`;Host 模式请使用 `6893/18080/9443`,Bridge 模式检查端口映射 |
| 修改反代端口后旧规则不生效 | `HAVLINE_NGINX_HTTP_PORT` / `HAVLINE_NGINX_HTTPS_PORT` 只影响新规则,已有规则需在反代页手动修改 |
| 证书申请失败 | 检查 DNS 服务商凭证、根域权限、ACME 邮箱、DNS 传播和 CA 限制;泛域名必须使用 DNS-01 |
| DDNS 没有更新 | 检查 DNS API 权限、记录是否存在、公网 IP 探测结果;FRP 场景域名应解析到 VPS 公网 IP |
| FRP 客户端启动失败 | 确认 frps 地址/端口/Token/TLS 一致,VPS 安全组已放行控制端口和远程端口 |
| Cloudflare DNS 未自动创建 | 确认根域名在同一个 Account ID 下,API Token 至少具备 Zone:Read 和 DNS:Edit |
| Cloudflare 隧道 502 或 Token 错误 | 使用 API Token 预检;Tunnel Token 与 Account API Token 不是同一种凭据 |
| 升级时找不到 Compose 文件 | 设置 `HAVLINE_UPDATE_HOST_DIR` 指向宿主机 Compose 所在目录,并确认容器名和 Compose 项目标签来自同一部署 |
| 升级后数据异常 | 恢复升级前备份,确认 `HAVLINE_DATA_DIR`、`session_secret` 和数据库迁移状态未变化 |
| Agent 直连失败 | 确认 Agent 监听地址、端口、防火墙和 Token;默认策略应优先使用 SSH 隧道或 HTTPS,不建议直接暴露管理端口 |
| 公网访问失败 | 按 DNS → 端口/隧道 → Nginx 路由 → 内网服务逐段排查,不要只看 FRP 或 Tunnel 是否在线 |
## 本地开发 ## 本地开发
```bash ```powershell
# 前端依赖与热更新 # 构建前端并准备嵌入目录
cd web cd web
npm ci npm ci
npm run dev
# 构建前端并同步到 Go embed 目录
npm run build npm run build
cd .. cd ..
Copy-Item -Path web/dist/* -Destination cmd/havline/web/dist -Recurse -Force New-Item -ItemType Directory -Force cmd/havline/web/dist | Out-Null
Copy-Item web/dist/* cmd/havline/web/dist -Recurse -Force
# 运行后端 # 运行后端(需要可用 Nginx)
go run ./cmd/havline go run ./cmd/havline
# 测试
go test ./...
go vet ./...
cd web && npm test
``` ```
`VERSION` 是版本号单一来源。Docker 构建会读取并注入前后端;直接 `go run` 时未注入版本会显示 `dev`。 前端热更新在另一终端执行 `cd web`、`npm run dev`。测试命令:
## 文档导航 ```powershell
go test ./...
go vet ./...
cd web
npm run typecheck
npm test
```
| 文档 | 说明 | 版本来源为根目录 `VERSION`;Docker 构建注入前后端,本地 `go run` 未注入时显示 `dev`。
| --- | --- |
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | 裸机、Docker、飞牛 host 模式部署 |
| [`docs/container-deploy-and-data.md`](docs/container-deploy-and-data.md) | 容器部署与数据目录 |
| [`docs/system-upgrade.md`](docs/system-upgrade.md) | 一键升级与回滚 |
| [`docs/FRP公网暴露操作手册.md`](docs/FRP公网暴露操作手册.md) | FRP 使用手册 |
| [`docs/frps服务端管理方案.md`](docs/frps服务端管理方案.md) | 公网服务端与 Agent |
| [`docs/cloudflared-current-features.md`](docs/cloudflared-current-features.md) | Cloudflare Tunnel 当前能力 |
| [`docs/fnos-app-packaging-plan.md`](docs/fnos-app-packaging-plan.md) | 飞牛应用打包方案 |
| [`docs/README.md`](docs/README.md) | 全部文档索引 |
## 开源许可与致谢 ## 开源协议
> 开源不是重新发明轮子,而是站在巨人的肩膀上,把一件事继续做得更好。 本项目采用 [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.html) 开源。
Havline 基于开源项目 [**O96u/Fonu**](https://github.com/O96u/Fonu) **1.0.2** 版本进行二次开发。 Havline 基于 [O96u/Fonu](https://github.com/O96u/Fonu) **1.0.2** 版本继续开发,感谢上游作者及贡献者。保留原作者版权、来源与 GPL-3.0 下适用的源代码提供义务。
感谢原项目及其贡献者提供的基础实现和工程积累。
本项目继续采用 [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.html) 开源。品牌名称调整不会改变 问题反馈:[Havline Issues](https://github.com/mrlee233/havline/issues)。请提供版本、部署方式、复现步骤和脱敏日志,不上传密码、Token、私钥或完整备份。
原项目及其衍生版本在 GPL-3.0 下的版权、署名和源代码提供义务。
软件按“原样”提供,不附带任何担保。请在部署前做好数据备份、访问控制和网络安全配置。
+1 -1
View File
@@ -1 +1 @@
1.9.0 1.9.3
+15
View File
@@ -6,11 +6,25 @@ import (
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time"
"github.com/havline/havline/internal/updater" "github.com/havline/havline/internal/updater"
) )
func main() { func main() {
token, tokenSource, err := updater.WaitForToken(
envOr("HAVLINE_UPDATER_TOKEN", ""),
envOr("HAVLINE_UPDATER_TOKEN_FILE", updater.DefaultTokenPath),
2*time.Minute,
)
if err != nil {
log.Fatal(err)
}
if token == "" {
log.Fatal("HAVLINE_UPDATER_TOKEN 或 HAVLINE_UPDATER_TOKEN_FILE 未配置")
}
log.Printf("updater Token 已就绪:%s", tokenSource)
server, err := updater.NewServer(updater.Config{ server, err := updater.NewServer(updater.Config{
SocketPath: envOr("HAVLINE_UPDATER_SOCKET", "/run/havline-updater/updater.sock"), SocketPath: envOr("HAVLINE_UPDATER_SOCKET", "/run/havline-updater/updater.sock"),
ProjectDir: envOr("HAVLINE_UPDATE_PROJECT_DIR", "/workspace"), ProjectDir: envOr("HAVLINE_UPDATE_PROJECT_DIR", "/workspace"),
@@ -24,6 +38,7 @@ func main() {
ProjectName: envOr("HAVLINE_UPDATE_PROJECT_NAME", ""), ProjectName: envOr("HAVLINE_UPDATE_PROJECT_NAME", ""),
SelfImage: envOr("HAVLINE_UPDATE_SELF_IMAGE", ""), SelfImage: envOr("HAVLINE_UPDATE_SELF_IMAGE", ""),
DockerSocket: envOr("HAVLINE_UPDATE_DOCKER_SOCK", "/var/run/docker.sock"), DockerSocket: envOr("HAVLINE_UPDATE_DOCKER_SOCK", "/var/run/docker.sock"),
Token: token,
}, nil) }, nil)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
+1
View File
@@ -1,3 +1,4 @@
# 源码仓库:https://github.com/mrlee233/havline.git(main 分支)
# 飞牛 NAS 等已占用 80/443 的环境:使用 host 网络 + 高位默认反代端口 # 飞牛 NAS 等已占用 80/443 的环境:使用 host 网络 + 高位默认反代端口
# 本地项目:直接用源码构建镜像,不从 Docker Hub 拉取 # 本地项目:直接用源码构建镜像,不从 Docker Hub 拉取
name: havline name: havline
+9 -4
View File
@@ -3,7 +3,7 @@
# 用法: # 用法:
# mkdir -p /opt/havline && cd /opt/havline # mkdir -p /opt/havline && cd /opt/havline
# # 取本文件(按你仓库页面上的「原始文件」链接下载,或 git clone 后复制过来): # # 取本文件(按你仓库页面上的「原始文件」链接下载,或 git clone 后复制过来):
# # curl -fsSLo docker-compose.hub.yml <仓库里这个文件的原始地址> # # curl -fsSLo docker-compose.hub.yml https://raw.githubusercontent.com/mrlee233/havline/main/docker-compose.hub.yml
# docker compose up -d --build # 会话密钥由应用首次启动自动生成并持久化在数据卷里 # docker compose up -d --build # 会话密钥由应用首次启动自动生成并持久化在数据卷里
# #
# 想自己指定会话密钥(可选项):在 compose 文件同层建 .env 写入 # 想自己指定会话密钥(可选项):在 compose 文件同层建 .env 写入
@@ -20,7 +20,7 @@ name: havline
services: services:
havline: havline:
build: build:
context: https://jayhub.top/mrlee/Havline.git#main context: https://github.com/mrlee233/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}
@@ -49,6 +49,9 @@ services:
HAVLINE_DATA_DIR: /data HAVLINE_DATA_DIR: /data
HAVLINE_LISTEN: :6893 HAVLINE_LISTEN: :6893
HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
# 主程序首次启动时生成共享 Token;旧部署仍可用环境变量覆盖
HAVLINE_UPDATER_TOKEN: ${HAVLINE_UPDATER_TOKEN:-}
HAVLINE_UPDATER_TOKEN_FILE: /run/havline-updater/updater.token
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600) # 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
# 想自己指定就在同层 .env 里设这个变量;注意它同时是凭据加密密钥,已有数据后不要更换 # 想自己指定就在同层 .env 里设这个变量;注意它同时是凭据加密密钥,已有数据后不要更换
HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-} HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-}
@@ -58,7 +61,7 @@ services:
# 受限升级 sidecar:只有它持有 Docker Socket,主程序通过共享 Unix Socket 触发 # 受限升级 sidecar:只有它持有 Docker Socket,主程序通过共享 Unix Socket 触发
updater: updater:
build: build:
context: https://jayhub.top/mrlee/Havline.git#main context: https://github.com/mrlee233/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}
@@ -74,11 +77,13 @@ services:
- havline-updater-sock:/run/havline-updater - havline-updater-sock:/run/havline-updater
environment: environment:
HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
HAVLINE_UPDATER_TOKEN: ${HAVLINE_UPDATER_TOKEN:-}
HAVLINE_UPDATER_TOKEN_FILE: /run/havline-updater/updater.token
HAVLINE_UPDATE_PROJECT_DIR: /workspace HAVLINE_UPDATE_PROJECT_DIR: /workspace
HAVLINE_UPDATE_COMPOSE_FILE: docker-compose.hub.yml HAVLINE_UPDATE_COMPOSE_FILE: docker-compose.hub.yml
HAVLINE_UPDATE_SERVICE: havline HAVLINE_UPDATE_SERVICE: havline
HAVLINE_UPDATE_MODE: git HAVLINE_UPDATE_MODE: git
HAVLINE_UPDATE_REPO: https://jayhub.top/mrlee/Havline.git HAVLINE_UPDATE_REPO: https://github.com/mrlee233/havline.git
HAVLINE_UPDATE_BRANCH: main HAVLINE_UPDATE_BRANCH: main
HAVLINE_UPDATE_APP_URL: http://havline:6893 HAVLINE_UPDATE_APP_URL: http://havline:6893
HAVLINE_UPDATE_HOST_DIR: ${HAVLINE_UPDATE_HOST_DIR:-} HAVLINE_UPDATE_HOST_DIR: ${HAVLINE_UPDATE_HOST_DIR:-}
+6
View File
@@ -1,3 +1,4 @@
# 源码仓库:https://github.com/mrlee233/havline.git(main 分支)
# 固定项目名:不改的话,改了目录名/项目名后 compose 会把它当成新项目, # 固定项目名:不改的话,改了目录名/项目名后 compose 会把它当成新项目,
# havline-data 会变成新空卷(看起来像「数据丢了」)。 # havline-data 会变成新空卷(看起来像「数据丢了」)。
name: havline name: havline
@@ -34,6 +35,9 @@ services:
HAVLINE_DATA_DIR: /data HAVLINE_DATA_DIR: /data
HAVLINE_LISTEN: :6893 HAVLINE_LISTEN: :6893
HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
# 主程序首次启动时生成共享 Token;旧部署仍可用环境变量覆盖
HAVLINE_UPDATER_TOKEN: ${HAVLINE_UPDATER_TOKEN:-}
HAVLINE_UPDATER_TOKEN_FILE: /run/havline-updater/updater.token
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600) # 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
# 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换 # 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换
HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-} HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-}
@@ -58,6 +62,8 @@ services:
- havline-updater-sock:/run/havline-updater - havline-updater-sock:/run/havline-updater
environment: environment:
HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
HAVLINE_UPDATER_TOKEN: ${HAVLINE_UPDATER_TOKEN:-}
HAVLINE_UPDATER_TOKEN_FILE: /run/havline-updater/updater.token
HAVLINE_UPDATE_PROJECT_DIR: /workspace HAVLINE_UPDATE_PROJECT_DIR: /workspace
HAVLINE_UPDATE_COMPOSE_FILE: docker-compose.yml HAVLINE_UPDATE_COMPOSE_FILE: docker-compose.yml
HAVLINE_UPDATE_SERVICE: havline HAVLINE_UPDATE_SERVICE: havline
-305
View File
@@ -1,305 +0,0 @@
# Havline 部署安装文档
> 适用版本:v1.1.0(源码包 `havline-1.1.0-src.zip`)。本文覆盖从拿到安装包到服务上线的完整流程:环境要求 → 打包 → 三种部署方式 → 首次配置 → FRP/DDNS/证书联动 → 升级与回滚 → 故障排查。
> 功能细节与组合场景请配合项目内两份文档:`README.md`(功能总览)与 FRP 页面右上「使用说明」(内置十个组合场景详解)。
## 0. 交付物清单与安装路径速查
| 交付物 | 内容 | 适用 | 安装方式 |
| --- | --- | --- | --- |
| `havline-1.1.0-linux-amd64.tar.gz`(14MB) | 编译好的 Linux 二进制(前端已内嵌)+ migrations + systemd + 一键脚本 | **裸机 Linux 服务器**,目标机只需系统 nginx | 解压 → `sudo bash install.sh`(见 3.4) |
| `havline-1.1.0-image.tar.gz`(约 100-150MB) | 构建好的 Docker 镜像 `havline:local`(含 nginx + frpc) | 目标机有 Docker、要离线安装 | `docker load` → `docker run`(见 3.3 方式 C) |
| `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。
## 1. 包内容与环境要求
### 1.1 源码包内容
`havline-1.1.0-src.zip`(约 11MB,304 项)为完整可构建源码:
| 目录/文件 | 说明 |
| --- | --- |
| `cmd/` `internal/` | Go 后端源码(含已迁移的多服务端 FRP 模块) |
| `web/` | Vue 3 + TypeScript 前端源码 |
| `migrations/` | SQLite 数据库迁移 001–022(启动时自动按序应用) |
| `Dockerfile` / `docker-compose*.yml` | 本地构建镜像与三种部署编排 |
| `VERSION` / `CHANGELOG.md` | 版本号单一来源(当前 `1.1.0`)与版本记录 |
| `deploy/nginx/mime.types` | 容器内 Nginx 依赖的 MIME 类型文件 |
| `docs/screenshots/` | README 引用的界面截图 |
**已排除**(打包时即剔除,无需自行处理):`.data/`(本地运行数据)、`web/node_modules`(构建时自动安装)、`internal/frp-1.0.2/` 与 `FrpView-1.0.2.vue`(弃用归档)、`*.db`。
### 1.2 部署机要求
| 项 | 要求 |
| --- | --- |
| 系统 | 飞牛 fnOS(主要目标平台);其他 Linux x86_64 / arm64 理论可用,未充分测试 |
| Docker | 20.10+(含 compose v2) |
| 内存 | ≥ 512MB 空闲(构建期建议 ≥ 2GB,前端编译吃内存) |
| 磁盘 | ≥ 2GB(镜像约 300MB + 数据目录按日志量增长) |
| 网络 | 构建期需访问 npm registry 与 Go module proxy;运行期需访问 GitHub(按需下载 frpc)与你的 DNS 服务商 API(DDNS/证书) |
| 端口 | 管理端口 6893;反代端口默认 80/443(Bridge)或 18080/9443(飞牛 host 模式) |
## 2. 构建镜像(所有部署方式的前提)
解压源码包后进入项目根目录执行:
```bash
docker compose build
```
一次构建同时产出:前端 `dist`、Go 后端二进制与运行镜像(frpc 不预装,运行期用「应用配置」按需下载),标签为 `havline:local`。
**版本号**:读取根目录 `VERSION` 文件(当前 `1.1.0`)自动注入前后端显示;要改版本号只需编辑该文件后重新 build。
**网络受限环境**可覆盖基础镜像与 frpc 下载源(默认值见 `Dockerfile` 顶部注释):
```bash
docker build \
--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 RUNTIME_IMAGE=docker.m.daocloud.io/library/debian:bookworm-slim \
-t havline:local .
```
## 3. 三种部署方式(按环境二选一)
### 方式 A:飞牛 fnOS / 已占用 80/443 的环境 —— host 模式(推荐)
飞牛系统自带 Nginx 占用 80/443,Havline 使用 host 网络 + 高位反代端口避免冲突。
```bash
HAVLINE_SESSION_SECRET=请改成一串随机字符 docker compose -f docker-compose.host.yml up -d --build
```
- 无端口映射,容器直接使用宿主机网络栈;
- 反代端口由环境变量 `HAVLINE_NGINX_HTTP_PORT=18080`、`HAVLINE_NGINX_HTTPS_PORT=9443` 决定(文件内已内置);
- 入口:管理后台 `http://<NAS-IP>:6893`,HTTP 反代 `http://<NAS-IP>:18080`,HTTPS 反代 `https://<NAS-IP>:9443`;
- 数据持久化在命名卷 `havline-data`(挂载到容器 `/data`)。
### 方式 B:其他环境 / 空闲 80/443 —— Bridge 端口映射
```bash
HAVLINE_SESSION_SECRET=请改成一串随机字符 docker compose -f docker-compose.hub.yml up -d --build
```
- 端口映射 `6893:6893`、`18080:80`、`9443:443`(宿主机 18080/9443 → 容器 80/443);
- 数据同样在命名卷 `havline-data`。
### 方式 C:docker run(不想用 compose)
```bash
docker run -d --name havline \
--net=host \
-v ./data:/data \
-e HAVLINE_SESSION_SECRET=请改成一串随机字符 \
--restart unless-stopped \
havline:local
```
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]$_})`。
> **`HAVLINE_SESSION_SECRET` 是什么**:Havline 的主密钥,两个用途——① 签名/加密登录会话 Cookie(决定登录态是否可被伪造);② 作为 `secret.Box` 加密存储中敏感凭据(DDNS API Token、FRP Token、SMTP 密码等)。默认值 `change-me-in-production` 是公开的:拿到它即可伪造管理员会话、解密数据库里的全部凭据,**生产环境必须替换**。⚠️ 换密钥的时机:首次启动前设置;若之后更换,旧密钥加密的凭据将无法解密(应用会提示重新保存),需到对应页面重录 Token。
### Linux 服务器完整部署步骤(Ubuntu / Debian / CentOS)
```bash
# 0. 安装 Docker(已装跳过)
curl -fsSL https://get.docker.com | sh
sudo systemctl enable --now docker
# 1. 解压源码包并进入目录
unzip havline-1.1.0-src.zip -d havline && cd havline
# 2. 构建镜像(约 3-10 分钟,取决于网络与机器性能)
docker compose build
# 3. 生成密钥并启动(Bridge 模式,映射 6893/18080/9443)
echo "HAVLINE_SESSION_SECRET=$(openssl rand -hex 32)" > .env
docker compose -f docker-compose.hub.yml up -d
# 4. 防火墙放行(按需)
sudo ufw allow 6893/tcp # 管理后台
sudo ufw allow 80/tcp 443/tcp # 反代(Bridge 模式容器内监听 80/443)
# firewalld 用: firewall-cmd --permanent --add-port=6893/tcp && firewall-cmd --reload
# 5. 验证
docker logs havline | grep -i password # 取 admin 初始密码
curl -I http://127.0.0.1:6893/login # 应返回 200
```
- **开机自启**:compose 内已含 `restart: unless-stopped`,且第 0 步已 `enable docker`,重启服务器自动拉起;
- **80/443 被系统 Nginx 占用**:改用 `docker-compose.host.yml`(host 网络 + 18080/9443 高位端口),或改 `docker-compose.hub.yml` 里的宿主端口映射;
- **离线部署**(目标机不方便联网构建):在有网的机器上 `docker compose build` 后 `docker save havline:local | gzip > havline.tar.gz`,拷贝到目标机 `docker load < havline.tar.gz`,再用方式 C 的 `docker run` 启动(无需再构建);
- **数据位置**:命名卷 `havline-data`(`docker volume inspect havline-data` 查看宿主机实际路径),备份该卷即备份全部配置与证书。
### Linux 运行包安装(免 Docker,裸机部署)
适用:不想装 Docker 的 Linux 服务器。前提:系统已装 nginx(Havline 复用它做反代),root 权限。
```bash
# 1. 解压
tar xzf havline-1.1.0-linux-amd64.tar.gz -C havline && cd havline
# 2. 一键安装(自动建 havline 系统用户、装到 /opt/havline、生成随机密钥、注册 systemd 开机自启)
sudo bash install.sh
# 3. 验证
systemctl status havline
curl -I http://127.0.0.1:6893/login
journalctl -u havline | grep -i password # 取 admin 初始密码
```
包内容:`havline` 二进制(前端已 `go:embed` 内嵌,无需单独部署 dist)、`migrations/`(从二进制同级目录自动查找)、`deploy/nginx/mime.types`、`havline.service`(systemd 模板)、`install.sh`、`DEPLOYMENT.md`。
关键路径(install.sh 自动配置):
| 项 | 值 |
| --- | --- |
| 二进制 | `/opt/havline/havline` |
| 数据目录 | `/var/lib/havline`(数据库、证书、日志全在这) |
| 会话密钥 | `/etc/default/havline`(重复安装保留原值) |
| 服务 | `systemctl restart havline` 生效配置改动 |
**备份/迁移**:停服务后打包 `/var/lib/havline` 即可(`tar czf havline-data.tar.gz /var/lib/havline`),新机器解压到同路径 + 装同版本二进制即完成迁移。
**手动安装**(不用脚本):按包内 `README.txt` 要点——建 `havline` 系统用户、复制二进制与 migrations 到 `/opt/havline`、`EnvironmentFile` 写入随机密钥、`systemctl enable --now havline`。
> 镜像包(`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 应用中心安装 Docker 应用(较新版本自带)。以下菜单名以 fnOS 当前版本实际界面为准,大致路径一致。
**方式一:导入现成镜像(推荐,5 分钟)**
1. **上传文件**:把 `havline-1.1.0-image.tar.gz` 通过文件管理 / SMB 上传到 NAS,如共享目录 `docker/havline/`;
2. **导入镜像**:打开 Docker 应用 → 镜像 → 导入(从文件加载)→ 选择该 tar.gz → 等待镜像列表出现 `havline:local`(若界面只接受 .tar,先在电脑上解压出内层的 .tar 再导入);
3. **创建容器**:容器 → 创建:
| 配置项 | 填写 |
| --- | --- |
| 名称 | `havline` |
| 镜像 | `havline:local` |
| 网络 | **host**(飞牛 80/443 被系统占用,host + 高位端口最省事;UI 不支持 host 时选 Bridge 并映射 6893/18080/9443) |
| 存储挂载 | NAS 建目录(如 `/vol1/…/docker/havline/data`)→ 映射到容器 `/data`(读写) |
| 环境变量 | `HAVLINE_SESSION_SECRET=随机串`、`HAVLINE_DATA_DIR=/data`、`HAVLINE_LISTEN=:6893`、`HAVLINE_NGINX_HTTP_PORT=18080`、`HAVLINE_NGINX_HTTPS_PORT=9443` |
| 重启策略 | 自动重启 / unless-stopped(开机自启) |
4. **启动并取初始密码**:容器详情 → 日志,搜 `password`;浏览器打开 `http://<NAS-IP>:6893` 登录。
**方式二:Compose 项目(用源码包,在 NAS 上构建)**
1. 把源码包 zip 上传到 NAS 并解压(文件管理支持解压,或电脑解压后整目录上传);
2. Docker 应用 → 项目(Compose)→ 新建:指定项目目录为解压后的源码目录、使用 `docker-compose.host.yml`(或直接把该文件内容粘贴到 Compose 编辑器);
3. 在源码目录同层放一个 `.env` 文件,内容一行:`HAVLINE_SESSION_SECRET=随机串`;
4. 构建并启动。⚠️ NAS 上构建耗时较长(前端编译),低配机器建议改用方式一;
5. 后续升级:上传新源码覆盖 → 项目重新构建即可,数据在卷里不受影响。
**Compose 构建报 401(docker.fnnas.com)**:fnOS 的 Docker 设置里默认配了自家加速地址,但它对 `library/` 官方镜像拒绝匿名拉取。三种解法任选:
1. **改用镜像包(最省事)**:跳过构建,按方式一导入 `havline-1.1.0-image.tar.gz`;
2. **覆盖基础镜像源**:在源码目录的 `.env` 里加三行(已有 HAVLINE_SESSION_SECRET 就追加在下面):
```
NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine
GO_IMAGE=docker.m.daocloud.io/library/golang:1.23-bookworm
RUNTIME_IMAGE=docker.m.daocloud.io/library/debian:bookworm-slim
GOPROXY=https://goproxy.cn,direct
```
(Dockerfile/compose 现已内置这些默认值,无需 .env;此处列出供手动 docker build 参考或覆盖。`GOPROXY` 解决 go mod download 访问 proxy.golang.org 超时——国内默认 goproxy.cn,海外可覆盖回 direct。)
然后 Compose 重新构建——compose 文件已支持这三个变量透传;
3. **换/关掉 fnOS 加速地址**:Docker 应用 → 设置 → 镜像源,把 `docker.fnnas.com` 换成可用加速源(如 `docker.m.daocloud.io`)或清空,再重新构建。
**fnOS 注意事项**:
- 80/443 已被 fnOS 系统 Nginx 占用——**不要**把反代规则监听 80/443,用默认 18080/9443;
- host 模式下容器端口即宿主机端口,fnOS 防火墙(如有启用)放行 6893 / 18080 / 9443;
- 数据目录建议放在存储池挂载路径下(`/vol1/…`,以文件管理器显示的实际路径为准),系统重装时数据不随系统分区丢失;
- 升级:方式一重新导入新镜像 → 停止并删除旧容器 → 用相同挂载/环境变量重建(数据在映射目录,不丢);方式二直接重新构建。
## 4. 首次登录与安全配置
1. 启动后查看初始密码:`docker logs havline | grep -i password`(自动生成的 admin 初始密码会打印在日志里);
2. 浏览器打开 `http://<NAS-IP>:6893/login`,用 `admin` + 初始密码登录;
3. **立即**在「设置 → 安全」修改管理员密码;
4. 确认「设置 → 常规」中 `HAVLINE_SESSION_SECRET` 已改为非默认值(compose 文件中已传入则跳过);
5. 建议在「设置 → 通知」配置一种告警方式(邮件 / Webhook / Telegram),DDNS 失败、证书续签失败、登录异常等事件会推送。
## 5. 反向代理与 HTTPS 证书
1. **反向代理页 → 添加规则**:填前端域名(如 `app.example.com`)、后端目标(内网应用地址)、HTTPS 开关与证书;
2. **证书页 → 申请证书**:ACME DNS-01 验证,需先在 DDNS 页配置对应服务商凭证(Cloudflare / DNSPod / 阿里云 / 腾讯云 / 火山引擎),通配符证书 `*.example.com` 可覆盖整组域名;
3. DNS 记录指向本机 IP:家庭宽带场景由 DDNS 自动跟随(见第 6 节);
4. 验证:浏览器访问域名,证书页看剩余有效期,日志中心看访问/错误日志。
## 6. DDNS(域名解析自动跟随)
DDNS 页 → 添加任务:选服务商 → 填 API 凭证(可「测试连接」)→ 添加解析记录(支持 `@`、子域、完整域名)→ 开 IPv4/IPv6 → 保存。
**IP 来源两种模式**(任务详情 → 「自定义 IP」按钮设置):
| 模式 | 填法 | 适用 |
| --- | --- | --- |
| 自动检测(默认) | 自定义 IP 留空 | 域名指向家庭宽带出口 IP |
| 自定义 IP | 填固定公网 IP(如 VPS 的 IP) | 域名直接指向公网服务器 |
自定义 IP 必须为公网地址,填内网 IP 会被拒绝更新。VPS 动态 IP 场景:自定义 IP 填 VPS 当前 IP,FRP 服务端地址填 DDNS 域名,VPS 换 IP 后自动跟随。
## 7. FRP 内网穿透(无公网 IP 时)
前置:一台有公网 IP 的 VPS。
1. **Havline 添加服务端**:FRP 穿透页 → 添加服务端 → 填 VPS 地址、bindPort(默认 7000)、认证方式与 Token(与 frps 一致);
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(无公网监听);
4. **应用并验证**:「应用配置」→「校验并应用配置」;服务端「详情」看五项诊断(TCP 可达 / frpc 登录 / 代理可达 / DNS 解析 / 归属地)与独立日志。
## 8. 升级与回滚
### 升级
```bash
# 1. 备份数据(最重要)
docker run --rm -v havline-data:/data -v ${PWD}:/backup alpine tar czf /backup/havline-data-backup.tar.gz /data
# 2. 解压新版源码包覆盖项目目录(VERSION 与 migrations 会更新)
# 3. 重建并启动(迁移 015–022 会在启动时自动应用)
docker compose -f docker-compose.host.yml up -d --build
# 4. 看启动日志确认迁移与版本
docker logs -f havline
```
### 回滚
```bash
docker compose down
# 还原上一版源码目录后:
docker compose -f docker-compose.host.yml up -d --build
# 数据目录如需还原:解压第 1 步的备份 tar 到挂载卷
```
> 注意:数据库迁移只向前应用(`schema_migrations` 记录版本号)。跨大版本回滚前必须先做第 1 步的数据备份,否则旧版代码遇到新表结构可能启动异常。
## 9. 故障排查速查
| 现象 | 处理 |
| --- | --- |
| 容器起来但 6893 打不开 | `docker logs havline` 看启动报错;`docker port havline`(Bridge 模式)确认映射 |
| 提示「会话加密密钥」警告 | 未设置 `HAVLINE_SESSION_SECRET`,compose 传入即可 |
| 反代 502 | 目标内网应用未启动或地址填错;日志中心看 Nginx error 日志 |
| 证书申请失败 | DDNS 凭证是否有效、域名 DNS 是否已生效、ACME 邮箱是否已设置 |
| frpc 连不上 frps | 两端 Token/端口不一致;用服务端「详情 → frps.toml」重新生成对齐 |
| 穿透流量显示 0 | frps 未配置 webServer 或 Havline 服务端表单未填管理地址(非必须功能) |
| 页面改动未生效 | 确认访问的是容器端口而非开发服务器;`docker compose up -d --build` 重建 |
## 10. 卸载
```bash
docker compose down # 停止并删除容器(数据卷保留)
docker volume rm havline-data # 彻底删除数据(不可逆,先备份)
docker rmi havline:local # 删除镜像
```
-170
View File
@@ -1,170 +0,0 @@
# 内网应用经 FRP 暴露公网:NAS 侧完整操作手册
> 场景:把 NAS 内网应用(本例 `192.168.31.152:7023`)通过一台公网 VPS(`38.95.74.87`)以域名(`tv.36800.cc`)暴露出去,URL 不带端口号,且后续域名 / HTTPS / 访问控制全部在 NAS 的 Havline 网页里管理,VPS 只做纯流量转发。
> 适用版本:Havline v1.1.0+(FRP 多服务端模块)。VPS 上 **不需要也不应该** 装 Nginx 反代。
## 0. 最终架构与访问地址
```text
用户浏览器
↓ http://tv.36800.cc(80 端口,URL 不带端口)
VPS frps(监听 80 —— 由 frpc 动态注册的 tcp 隧道,VPS 上无需 Nginx)
↓ FRP 隧道(bindPort 7000,TLS)
NAS frpc
↓ 127.0.0.1:18080
Havline Nginx(按域名分流,后续可加 HTTPS/黑白名单)
↓ http://192.168.31.152:7023
内网应用
```
**访问地址:`http://tv.36800.cc`**
| 角色 | 地址 | 说明 |
| --- | --- | --- |
| 公网 VPS | `38.95.74.87`(tv.36800.cc) | 只跑 frps,Nginx 停用 |
| NAS 内网应用 | `192.168.31.152:7023` | 真正的 Web 服务 |
| Havline 管理后台 | `http://<NAS-IP>:6893` | 全部配置入口 |
## 1. 前置条件
- VPS:已部署 frps(bindPort 7000、token 认证;参考 Havline 服务端详情「frps.toml」一键生成);
- VPS:frps 的 Nginx **未**占用 80(占用见第 5 节排障);
- 云厂商安全组已放行:7000/tcp(frps 控制)、80/tcp(对外)、7500/tcp(frps 管理接口,可选,用于流量统计);
- NAS:Havline 已部署并能打开管理后台;
- 域名:`tv.36800.cc` 的 DNS A 记录指向 VPS IP(可用 Havline DDNS 页「自定义 IP」固定,或由 DNS 服务商直接管理)。
## 2. Havline 网页配置(三处)
### 2.1 FRP 服务端
路径:**FRP 穿透 → 添加服务端**
| 字段 | 值 |
| --- | --- |
| 主机地址 | VPS 的 IP 或域名 |
| 端口 | 7000(= frps bindPort) |
| 认证方式 | Token,与 VPS frps.toml 的 `auth.token` 一致 |
| TLS | 开启(frps 默认接受) |
| frps 管理接口(可选) | 填 frps `webServer` 的地址/端口/用户/密码,用于显示穿透流量 |
保存后状态应显示「运行中 · 已连接」。
### 2.2 穿透规则(核心)
路径:**FRP 穿透 → 添加规则**
| 字段 | 值 | 说明 |
| --- | --- | --- |
| 所属服务端 | 上一步的服务端 | |
| 名称 | `tv`(frps 全局唯一,出现在日志里) | |
| 类型 | **tcp** | ⚠️ 不选 http——80 直接由 frps 转发,域名分流交给 Havline 反代 |
| 远程端口 | **80** | 选 tcp 类型后出现此字段;frpc 登录时向 frps 动态注册 |
| 内网目标 | **127.0.0.1:18080** | 指向 Havline 自己的 Nginx HTTP 监听(fnOS host 模式默认 18080) |
保存 → 右上 **「应用配置」→「校验并应用配置」**,或对该服务端「停止→启动」。
### 2.3 反向代理(域名分流)
路径:**反向代理 → 添加规则**
| 字段 | 值 |
| --- | --- |
| 前端域名 | `tv.36800.cc` |
| 监听端口 | **18080**(与 2.2 的内网目标端口一致;fnOS 下不要用 80,系统 Nginx 已占用) |
| 目标地址 | `http://192.168.31.152:7023`(⚠️ 指向真正的内网应用,完整 URL 带协议与端口) |
| HTTPS | 按需;fnOS 下 Havline Nginx 的 HTTPS 监听默认 9443 |
> ⚠️ **回环警告**:目标地址千万不要填 `http://tv.36800.cc:xxxx`——那会让请求从 NAS 出公网再回 NAS(NAS→VPS→NAS 绕圈)。反代目标是**内网应用本身**。
> ⚠️ **「目标地址缺少主机名」报错**:目标地址格式必须完整 `http://IP:端口`(协议+主机+端口缺一不可,单斜杠 `http:/` 或中文冒号都会报 400)。
## 3. VPS 侧配置(一次性的两步)
### 3.1 frps.toml(Havline 一键生成)
Havline 服务端「详情」→「frps.toml」→ 复制内容到 VPS 保存为 `frps.toml`:
```toml
bindAddr = "0.0.0.0"
bindPort = 7000
auth.method = "token"
auth.token = "<与 Havline 一致>"
webServer.addr = "0.0.0.0" # 可选:Havline 拉取穿透流量
webServer.port = 7500
webServer.user = "admin"
webServer.password = "<自定义>"
log.to = "console"
log.level = "info"
log.maxDays = 3
log.disablePrintColor = false
```
> 注意:**这个文件里没有任何远程端口**。tcp 类型规则的远程端口(80)由 frpc 登录时动态注册,frps 只管 bindPort 和认证——规则改了也不用改这个文件。
启动:`./frps -c frps.toml`(建议配 systemd)。
### 3.2 释放 80 端口(关键,`port unavailable` 的唯一解法)
80 只能给一个进程。frps 要绑 80,VPS 上原有的 Nginx 必须停掉:
```bash
# 确认 80 被谁占用
ss -tlnp | grep ':80 '
# 停止并禁用 Nginx(宝塔面板安装的在宝塔「软件商店」里停)
systemctl stop nginx
systemctl disable nginx
# 确认已释放(应无输出)
ss -tlnp | grep ':80 '
```
frpc 约每 33 秒自动重试注册,释放后无需任何手动操作,注册成功后 frps 日志出现 proxy 启动记录、frpc 日志的 `start error: port unavailable` 消失。
> 如果 VPS 的 80/443 还要跑其他站点(Nginx 不能停),本方案不适用——改回「VPS Nginx 监听 80 → proxy_pass 127.0.0.1:8080(frps vhost)」的方案,FRP 规则用 http 类型,见第 6 节方案对比。
## 4. 验证清单
| 步骤 | 命令 / 操作 | 预期 |
| --- | --- | --- |
| VPS 80 已监听 | `ss -tlnp \| grep ':80 '` | 进程为 frps |
| frpc 注册成功 | Havline 服务端「日志」 | 无 `port unavailable`,有 proxy 启动记录 |
| 隧道通 | Havline 服务端「测速」/「详情」诊断 | TCP 可达、frpc 登录 ok、延迟正常 |
| 分流通 | 服务端「详情」→ 诊断 `proxy_reachable` | ok |
| 端到端 | 外网浏览器 `http://tv.36800.cc` | 直达内网应用页面 |
## 5. 故障排查(按链路顺序)
| 现象 | 断点 | 处理 |
| --- | --- | --- |
| `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 登录」失败 | Token/端口不一致 | 两端 token 对齐;端口=frps bindPort |
| 登录成功但 `proxy_reachable` 失败 | Havline 反代 | 反代规则监听端口与 FRP 内网目标端口一致(18080);反代已启用 |
| 打开域名 404 | 域名分流失败 | 反代规则的前端域名与访问域名完全一致;frps 无需 vhost(tcp 方案) |
| 域名解析不对 | DNS | `nslookup tv.36800.cc` 应返回 VPS IP;用 DDNS 自定义 IP 固定 |
| 流量统计全 0 | frps webServer 未配 | 非必须;要在 Havline 看流量就配 webServer 并在服务端表单填管理接口 |
## 6. 两种方案对比(VPS 80 的归属)
| | 方案 A:NAS 侧全管(本文) | 方案 B:VPS Nginx 反代 |
| --- | --- | --- |
| 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 侧规则无需动 |
| VPS Nginx | 不需要(停用) | 必须:listen 80 → proxy_pass 127.0.0.1:8080(**必须带** `proxy_set_header Host $host`) |
| 加新域名 | Havline 反代加一条 + FRP 加一条 tcp 规则(远程端口仍 80,内网目标同 18080) | VPS 上加 server 块 |
| 上 HTTPS | Havline 证书页(DNS-01)+ 反代开 HTTPS + FRP 加 tcp 443→127.0.0.1:9443 | VPS 上装证书 |
| 访问控制(IP/BasicAuth) | Havline 反代页直接配 | VPS 上手写 |
| 限制 | VPS 80/443 不能再跑别的站点 | VPS 可共存多站点 |
| URL | `http://tv.36800.cc`(无端口) | `http://tv.36800.cc`(80)或带 vhost 端口 |
选型:VPS 专用做穿透入口 → 方案 A;VPS 80/443 另有他用 → 方案 B。两方案不混用(80 归属唯一)。
## 7. HTTPS 升级路径(方案 A 延伸)
1. **Havline 证书页**:申请 `tv.36800.cc` 或 `*.36800.cc` 证书(ACME DNS-01,复用 DDNS 页服务商凭证);
2. **反代规则**:开 HTTPS、绑定证书、(可选)HTTP 强制跳转;
3. **FRP 加一条 tcp 规则**:远程端口 443 → 内网目标 `127.0.0.1:9443`(Havline Nginx HTTPS 监听);
4. 安全组放行 443;之后 `https://tv.36800.cc` 可用,TLS 全程在 NAS 侧终止。
-39
View File
@@ -1,39 +0,0 @@
# Havline 文档索引
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
> 最近更新:2026-10-01(v1.9.0)
## 方案与设计(本目录)
| 文件 | 主题 | 状态 |
| --- | --- | --- |
| `project-analysis-2026-09-27.md` | 全项目详细分析(v1.2.19):架构、模块、数据、API、前端、链路、风险与改进路线 | 当前版本快照 |
| `improvement-roadmap-2026-09-27.md` | P0-P3 改进路线实施方案:逐项改法、测试、验收、依赖、发布与回滚 | P0-1、P0-3、P0-4 已完成,其余待实施 |
| `system-upgrade.md` | Docker Compose 一键升级:sidecar 架构、首次部署、安全边界、回滚、故障排查与验证页 | 已实施(v1.3.8) |
| `agent-listen-strategy-plan.md` | Agent 默认监听策略:回环监听、SSH 隧道优先、HTTPS pin 可选与自动迁移方案 | 阶段 1-4 已实施(v1.4.0);2.0.0 兼容清理待后续 |
| `cloudflared-integration-plan.md` | Cloudflare Tunnel(cloudflared)与 Havline 的融合方案:托管模型、复用面、分阶段落地与 v2 优化 | 阶段 1-2 已实施(v1.6.0);后续按 `cloudflared-havline-fit-plan.md` 推进 |
| `cloudflared-feature-plan.md` | Cloudflare 隧道功能全集:官方最新文档、参考项目功能、Havline 现状差距与 P0-P3 路线 | 查漏用;实施以 `cloudflared-havline-fit-plan.md` 为准 |
| `cloudflared-havline-fit-plan.md` | Cloudflare 隧道 Havline 适配版计划:出口抽象、规则同源、体系整合、明确不做项与阶段实施 | 阶段 1-3 主体已实施;剩余项见 `cloudflared-current-features.md` |
| `cloudflared-current-features.md` | Cloudflare 隧道当前已实现功能说明:页面、出口、路由、DNS、监控、备份、诊断、API 与限制 | 当前版本功能基线 |
| `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 实施中 |
| `frp-server-list-merge-plan.md` | FRP 服务端列表并入「公网服务端」页(方案 A,四步) | 步 1/2 已完成(v1.2.6);步 3 文案收尾、步 4 死样式清理未做 |
| `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-v2.md` | 公网 Agent 页设计方案 v2 | 部分过期:同上 |
| `agent-view-refactor-plan.md` | 公网 Agent 页四段式(状态 → 控制 → 生命周期 → 输出)整改 | 已实施 |
| `proxy-remote-design.md` | 公网反代页设计方案(以本地反代页为参照) | 已实施;列表已改为左服务端 + 右台账 |
| `agent-proxy-settings-plan.md` | 公网反代「部署设置」对齐本地反代的语义迁移 | 已实施 |
| `agent-proxy-roadmap.md` | 公网反代功能增强规划(含部署踩坑史) | 部分已实施(漂移检测、全量重部署),其余待排期 |
| `frps-features.md` | frps 服务端功能实现清单(对照官方 frps_full_example.toml) | 参考 |
| `frps服务端管理方案.md` | FRPS 服务端管理方案(havline-agent 部署在公网服务器) | 已实施(Agent 管理面板) |
| `frps管理方案.md` | FRPS 管理方案(早期版本) | 疑似已被 `frps服务端管理方案.md` 取代,保留供追溯(待确认) |
| `upstream-sync-plan.md` | 上游 1.0.2 → 1.0.5 对照与可借鉴项 | 部分过期:对 `/frp` 现状的描述基于 2026-09-23 |
| `FRP公网暴露操作手册.md` | FRP 公网暴露操作手册(面向使用) | 参考 |
| `DEPLOYMENT.md` | 部署安装:裸机 / Docker / 飞牛 host 模式 | 参考;适用 `havline-1.1.0` 安装包 |
## 其它位置
- 仓库根 `../docs/`(`111.md`、`FRP迁移方案-0.1.25到1.0.2.md`、`FRP版本对比-1.0.2-vs-0.1.25.md`)为**临时区**,不随本项目维护;正式文档一律放本目录。
- `screenshots/` 供 README 引用。
-269
View File
@@ -1,269 +0,0 @@
# havline-agent 能力扩展方案
> **实施状态(2026-09-24)**:P0/P1/P2 已全部落地(agent 0.11.0);P3 三项按本文约定不做。剩余为真机验收项,见 `docs/project-audit-2026-09-24.md`。
> 本文盘点 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`(均为读代码确认,非推测)。
> 当前 `AgentVersion = "0.9.2"`(`internal/agent/server.go:34`)。
> 更新日期:2026-09-23
---
## 一、现状盘点
### 1.1 agent 的接口面(12 个端点)
`internal/agent/server.go:151-162`:
| # | 方法与路径 | 作用 |
| --- | --- | --- |
| 1 | `GET /api/v1/ping` | 存活探测(安装/升级后确认 token 与网络) |
| 2 | `GET /api/v1/status` | 一次性返回 Nginx / frps / 端口 / 路由名的快照 |
| 3 | `GET /api/v1/routes` | 每条 `havline-*.conf` 的真实状态:`file_exists` / `syntax_ok` / `loaded` |
| 4 | `PUT /api/v1/routes` | 写入(或覆盖)一条域名的 Nginx 片段 |
| 5 | `GET /api/v1/routes/{domain}/conf` | 回读某域名实际生效的配置文件内容 |
| 6 | `DELETE /api/v1/routes/{domain}` | 删除某域名的片段 |
| 7 | `POST /api/v1/nginx/install` | 一键安装 Nginx |
| 8 | `PUT /api/v1/china-cidr` | 下发国内地址库(供 `china_only` 规则引用) |
| 9 | `PUT /api/v1/frps/config` | 写入 frps.toml |
| 10 | `POST /api/v1/frps/install` | 下载并安装指定版本的 frps(幂等,重复调用=升级) |
| 11 | `POST /api/v1/frps/action` | frps 的 start / stop / restart |
| 12 | `POST /api/v1/certs` | 推送证书(fullchain + privatekey) |
鉴权:全部走 `s.auth(...)` 包装的 token 校验;agent **只监听 VPS 本地**,由 Havline 主动发起请求,**不做任何主动上报**。
### 1.2 agent 的数据面(`status` 已返回、但应用侧只用了一部分)
`internal/agent/server.go:191` 的 `status` 返回字段:
| 字段 | 含义 | 应用侧现状 |
| --- | --- | --- |
| `agent_version` | agent 自身版本 | 已用(AgentView 版本核对) |
| `nginx_ok` / `nginx_missing` / `nginx_running` / `nginx_path` / `nginx_version` / `nginx_error` | Nginx 可用性探测(带缓存) | 已用 |
| `nginx_binary` / `nginx_conf_dir` | 配置的二进制与 vhost 目录 | 已用 |
| `ports` | 80 / 443 / 7000 / 8080 等端口是否监听 | **只用到了 8080 一项**(`AgentView.vue:359`) |
| `frps_active` / `frps_enabled` | systemd 单元是否运行 / 是否开机自启 | 已用 |
| `frps_binary` / `frps_config_path` | Havline 侧配置的路径 | 已用 |
| `frps_unit_exec` | systemd 实际执行的启动命令 | 已展示,**未做一致性核对** |
| `frps_version` | 探测到的 frps 版本 | 已展示 |
| `frps_log_tail` | frps 日志尾部 6 行 | 已展示 |
| `dashboard_addr` | 从 frps.toml 解析出的 dashboard 地址 | 已用 |
| `routes` | 已部署的域名列表 | 已用(部署台账) |
### 1.3 应用侧的调用面
`internal/frp/agent_client.go` 暴露 15 个方法,与上面 12 个端点一一对应(`Ping` / `Status` / `SyncRoute` / `SyncRouteSpec` / `SyncRouteEx` / `RouteConf` / `RoutesDetail` / `DeleteRoute` / `InstallNginx` / `SyncChinaCIDR` / `FRPSAction` / `InstallFRPS` / `PushFRPSConfig` / `PushCertificate`)。
其中 `RouteSpec`(`agent_client.go:83`)承载了完整部署语义:`Domain / Upstream / CertDir / TLS / WebSocket / RedirectHTTPS / HTTPSDisabled / SecurityHeaders / TLS13Only / VhostPort / AllowIPs / DenyIPs / BasicAuth* / ClientMaxBodySize / ProxyReadTimeout / RateLimit*`。
### 1.4 安装与升级链路(已闭环)
- agent 二进制**内嵌在 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 自己有下载能力——**自升级不是必须项**。
- 版本约定:`AgentVersion` 上方按「每项行为变更一行」的注释累积,改动后手动递增(当前 0.9.2)。
### 1.5 能力边界(缺口所在)
1. **没有资源采集**:VPS 的 CPU / 内存 / 磁盘 / 负载完全不可见。
2. **没有日志读取**:只有 frps 的 6 行 tail,Nginx 的 access / error 日志读不到(排障必须 SSH)。
3. **frps 配置只写不读**:`PUT /api/v1/frps/config` 存在,没有对应的 GET;用户手改 VPS 上的 frps.toml 后 Havline 无从知晓,下次下发直接覆盖。
4. **没有证书清单**:只能推送,无法列出 VPS 上现有的证书与到期时间(宝塔自己申请的证书看不到)。
5. **部署是逐条的**:`SyncRouteSpec` 按单条规则调用,没有「全量重部署」入口——agent 重装/换机后只能一条条点。
6. **写入结果不含 reload 细节**:片段写入成功但 Nginx reload 失败时,用户拿不到失败原因。
---
## 二、方案总览
| 批次 | 项 | 改动面 | 前置依赖 | 预估 |
| --- | --- | --- | --- | --- |
| **P0-1** | 端口矩阵 | 前端(AgentView) | 无,`status.ports` 已有 | 0.5h |
| **P0-2** | 部署漂移清单 + 一键全量重部署 | 后端 service + 前端 | 无,`GET /routes` 与 `RouteConf` 已有 | 2h |
| **P0-3** | frps 运行一致性核对 | 前端 | 无,`status` 已有 | 0.5h |
| **P0-4** | 证书推送核对 | 前端 | 无,`RouteHealth` 已有 | 0.5h |
| **P1-1** | VPS 资源采集 `GET /api/v1/metrics` | agent + 应用 + 前端 | 需递增 AgentVersion、重编二进制、VPS 上升级 | 2h |
| **P1-2** | 公网 Nginx 日志 `GET /api/v1/logs/nginx` | agent + 应用 + 前端 | 同上 | 2.5h |
| **P1-3** | frps.toml 回读 `GET /api/v1/frps/config` | agent + 应用 + 前端 | 同上 | 1.5h |
| **P2-1** | VPS 证书清单 `GET /api/v1/certs` | agent + 应用 + 前端 | 同上 | 1.5h |
| **P2-2** | reload 失败原因回传 | agent + 应用 | 同上 | 1h |
| **P3** | 不做:防火墙读写、任意命令执行、agent 自升级 | — | — | — |
**贯穿约束(所有新增 agent 端点都适用)**:老版本 agent 没有新端点,应用侧调用必须**按「端点不存在」优雅降级**(收到 404/405 时显示「VPS 上的 agent 版本过低,请升级 Agent」而不是报错),否则用户升级了 Havline 但没升级 agent 时页面会直接报错。
---
## 三、P0 详细设计(不动 agent,纯应用侧)
### 3.1 端口矩阵
- **数据**:`status.ports`(布尔映射)。实现前先以 agent 代码为准列出全部键(当前已知 `80` / `443` / `7000` / `8080`)。
- **呈现**:AgentView「运行状态」段内加一张两列小表(端口 / 状态徽标),或把现有「8080 端口」那一项扩成完整列表。
- **降级**:agent 版本过旧、`ports` 缺失时整块显示「当前 agent 版本不返回端口信息」。
- **验收**:停掉监听 443 的服务后该行转异常色;旧 agent 上不报错。
### 3.2 部署漂移清单 + 一键全量重部署(价值最高)
三个集合:`A` = 数据库里启用中的反代规则域名;`B` = `GET /api/v1/routes` 返回的域名;`C` = 其中 `loaded = true` 的域名。
| 类别 | 定义 | 含义 |
| --- | --- | --- |
| 未部署 | `A - B` | 库里要部署但 VPS 上没有(agent 重装、换机、手工删过) |
| 孤儿配置 | `B - A` | VPS 有但库里没有(手工加的 `havline-*.conf`、已删规则的残留) |
| 未生效 | `A ∩ (B - C)` | 文件在但未被 Nginx 加载(`syntax_ok=false`,或 include 未覆盖) |
| 内容不一致 | `RouteConf(domain)` 与「按当前部署设置生成的内容」规范化比对 | 手工改过上游 / 证书路径 / 安全项 |
**一键全量重部署**:对 `A` 逐条 `SyncRouteSpec`(串行、逐条收集结果、失败不中断,沿用 `Service.Reload` 的隔离报错风格),结束后回读一次 `GET /routes` 复核,结果弹窗汇总「成功 N / 失败 M + 每条失败原因」。
**前端落点**:公网反代页顶部提示条(有差异时才出现)+「重新部署全部」按钮(二次确认,因为会覆盖手工改动)。
**验收**:手工删一个 `havline-*.conf` → 清单出现「未部署」,重部署后消失;手工改一条 conf 的上游 → 出现「内容不一致」;语法错误时归入「未生效」并给出原因。
### 3.3 frps 运行一致性核对
- **比对三项**:`frps_unit_exec`(systemd 实际执行)vs Havline 配置的 `frps_binary`;`frps_version` vs 期望版本;`frps_config_path` vs Havline 生成路径。
- **呈现**:AgentView「frps 控制」段加一行「启动路径一致性」,不一致时并列显示两个路径。
- **验收**:手工改过 systemd unit 的 VPS 上显示不一致;一致的机器显示绿色。
### 3.4 证书推送核对
- **数据**:已有 `RouteHealth` 的证书段(`cert_ok` / `cert_detail`)+ 推送结果。
- **呈现**:公网反代页健康列已展示证书段;补一行「证书来源」(Havline 推送 / 待推送 / VPS 本地已有)。
- **验收**:证书过期时健康列给出证书段失败与到期天数。
---
## 四、P1 详细设计(新增 agent 端点)
> 改动流程见 §7.1;以下端点响应都带 `checked_at`,与现有 `status` 保持一致。
### 4.1 `GET /api/v1/metrics` —— VPS 资源采集
响应:
```json
{
"cpu_percent": 12.5,
"mem_used_bytes": 1288490188,
"mem_total_bytes": 4294967296,
"disk_used_bytes": 21474836480,
"disk_total_bytes": 53687091200,
"disk_path": "/",
"load_avg": [0.42, 0.51, 0.63],
"uptime_seconds": 864000,
"checked_at": "2026-09-23T19:00:00Z"
}
```
- **实现要点**:**直接复用仓库里已有的 `internal/sysinfo`**(同一 module,`cmd/havline-agent` 可直接引用)——`/proc/stat`、`/proc/meminfo`、`Statfs` 以及 Windows 的 kernel32 路径都已写好;agent 跑在 VPS 上,采到的天然是 VPS 自身数值。
- **CPU 差值**:`sysinfo.Read(prev)` 需要上次采样;agent 进程内保存上次 `CPUSample`,并对间隔 < 1 秒的重复调用直接返回上次结果(避免连续刷新时数值抖动)。
- **`load_avg` / `uptime_seconds`**:Linux 读 `/proc/loadavg`、`/proc/uptime`;非 Linux 省略这两个字段(前端按缺失处理)。
- **应用侧链路**:`AgentClient.Metrics(ctx)` → `GET /api/frpmulti/servers/{id}/agent/metrics` → AgentView 新增「VPS 资源」卡(复用本地仪表盘「系统资源」的行样式:名称 / 用量明细 / 4px 进度条 / 百分比,≥75% 转橙、≥90% 转红)。
- **降级**:404/405 → 卡片显示「VPS 上的 agent 版本过低(需 ≥ 0.10.0),请升级 Agent」。
- **验收**:VPS 上跑 `dd` 或压测时 CPU / 磁盘数值随之上升;磁盘接近写满时进度条转红;旧 agent 上显示升级提示而不是报错。
### 4.2 `GET /api/v1/logs/nginx` —— 公网 Nginx 日志
请求:`GET /api/v1/logs/nginx?type=access|error&lines=200`(`lines` 上限 2000,默认 200)
响应:
```json
{
"type": "access",
"path": "/var/log/nginx/access.log",
"lines": ["127.0.0.1 - - [23/Sep/2026:19:00:00 +0800] \"GET / HTTP/1.1\" 200 1234"],
"truncated": false,
"checked_at": "2026-09-23T19:00:00Z"
}
```
- **路径来源(白名单,不接受任意 path 参数)**:
1. 优先从 `nginx -T` 的 dump 里解析 `access_log` / `error_log` 指令(agent 已有 `nginx -T` 解析与缓存经验,见 `resolveNginxConfDir`);
2. 解析不到时回落默认路径 `/var/log/nginx/{access,error}.log`;
3. 支持环境变量 `HAVLINE_AGENT_NGINX_LOG_DIR` 覆盖(与现有 `HAVLINE_AGENT_NGINX_CONF_DIR` 的回退风格一致)。
- **只读尾部**:实现 `tailFile(path, n)`(与现有 `journalTail` 同思路),从文件末尾按块倒读,避免把整个日志读进内存;单行超长(如 4KB)截断。
- **错误处理**:文件不存在 → 200 + `lines: []` + 原样返回 `path`(前端显示「未找到日志文件」);无权限 → 403 并给出明确原因。
- **应用侧链路**:`AgentClient.NginxLogs(ctx, serverID, type, lines)` → `GET /api/frpmulti/servers/{id}/agent/logs/nginx?...`;日志中心新增「公网 Nginx」页签,复用现有 `LogToolbar` / `LogPagination` / `EmptyState` 与 `logTimeCell`,与「FRP 日志」页签同一套呈现。
- **验收**:VPS 上产生一次访问后页签能看到该条 access 记录;agent 无读权限时显示明确错误;`lines` 超过 2000 被夹到上限。
### 4.3 `GET /api/v1/frps/config` —— frps.toml 回读
响应:`{ "path": "/etc/frp/frps.toml", "exists": true, "content": "...", "mod_time": "2026-09-23T18:00:00Z", "checked_at": "..." }`
- **实现要点**:读 `s.cfg.FrpsConfigPath`,限制文件大小(如 256KB),超限返回 `truncated: true`;文件不存在返回 `exists: false` 而不是错误。
- **应用侧**:在「公网服务端」的 frps 同源配置弹窗里加「与 VPS 实际文件比对」——把「Havline 即将下发的内容」与回读内容做规范化 diff(去注释与空白),不一致时提示「VPS 上的 frps.toml 已被手工修改」,并给「重新下发」按钮(复用已有 `PushFRPSConfig`)。
- **验收**:在 VPS 上手工改一行 frps.toml → 弹窗提示不一致并高亮差异行;重新下发后恢复一致。
---
## 五、P2 详细设计
### 5.1 `GET /api/v1/certs` —— VPS 证书清单
响应:`{ "certs": [{ "domain": "7023.36800.cc", "cert_path": "...", "key_path": "...", "not_after": "2026-12-01T00:00:00Z", "days_left": 69 }], "warnings": [], "checked_at": "..." }`
- **实现要点**:遍历 `s.cfg.CertsDir` 下的域名目录,用标准库 `crypto/x509` 解析 `fullchain.pem` 的 `NotAfter`;解析失败的单条跳过并记入 `warnings`。
- **应用侧**:与 Havline 的证书列表交叉核对,输出「Havline 有 / VPS 有 / 两边都有但到期时间不同」三类;宝塔自行申请的证书也能被看到。
- **验收**:过期证书 `days_left < 0` 并标红;Havline 未推送过的域名出现在「VPS 本地已有」分组。
### 5.2 reload 失败原因回传
- **做法(择一,倾向 A)**:
- **A**:`PUT /api/v1/routes` 与 `DELETE /api/v1/routes/{domain}` 的响应体增加 `reload_ok` / `reload_error` 字段——写入成功但 reload 失败时不再只回 200。
- **B**:新增 `POST /api/v1/nginx/reload`,返回 `nginx -t` 与 reload 的 stderr。
- **应用侧**:部署结果提示里带上 `reload_error`;漂移清单的「未生效」类别直接引用它。
- **验收**:故意写一条语法错误片段 → 部署结果明确显示 reload 失败原因,而不是「部署成功」。
---
## 六、明确不做的
1. **VPS 防火墙规则读写**(ufw / firewalld / 宝塔面板差异大、需要 root,误操作会直接断网)——最多做只读展示。
2. **任意命令执行端点**——agent 目前只有 token 鉴权,开放 shell 等于把 VPS 交出去。
3. **agent 自升级**——现有链路(Havline 内嵌二进制 + SSH 上传 + 重启 systemd)已经闭环,自升级只省掉存 SSH 凭据,收益有限,还要额外处理下载源与签名校验。
---
## 七、改动流程与验收
### 7.1 agent 侧(每新增一个端点都要走完)
1. `internal/agent/server.go`:加 handler + `mux.HandleFunc(...)`,并在 `AgentVersion` 上方按现有约定补一行版本说明(例如 `// 0.10.0:新增 /api/v1/metrics 资源采集与 /api/v1/logs/nginx 日志读取。`),把常量递增到 **`0.10.0`**。
2. 重编两个架构的内嵌二进制(Havline 编译期嵌入,必须是真实二进制而不是占位文件):
```powershell
$env:GOOS='linux'; $env:CGO_ENABLED='0'; $env:GOARCH='amd64'
go build -trimpath -o internal/frp/agentbin/havline-agent-linux-amd64.new ./cmd/havline-agent
$env:GOARCH='arm64'
go build -trimpath -o internal/frp/agentbin/havline-agent-linux-arm64.new ./cmd/havline-agent
Move-Item -Force internal/frp/agentbin/havline-agent-linux-amd64.new internal/frp/agentbin/havline-agent-linux-amd64
Move-Item -Force internal/frp/agentbin/havline-agent-linux-arm64.new internal/frp/agentbin/havline-agent-linux-arm64
```
3. 重启本机 Havline(加载新内嵌二进制)→ VPS 上「升级 Agent」→ 用 `GET /api/v1/status` 的 `agent_version` 确认已是 `0.10.0`。
### 7.2 应用侧(每个端点对应一条链路)
`internal/frp/agent_client.go` 加方法 → `internal/frp/service.go` 加业务方法(含**端点不存在时的降级**:区分 404/405 与真实错误)→ `internal/api/frpmulti_handler.go` 加 handler → `internal/api/router.go` 注册(沿用现有 `agent/*` 路径风格)→ `web/src/api/{client,types}.ts` → 前端页面。
### 7.3 验收(每批的检查项)
- **P0**:手工制造四类漂移各一次,清单分类正确;全量重部署在单条失败时其余仍继续并汇总报错;旧 agent 上端口矩阵降级不报错。
- **P1**:`metrics` 数值随 VPS 负载变化且 CPU 不抖动;`logs/nginx` 尾部读取正确且超限被夹到 2000;`frps/config` 回读能检出手工改动。
- **P2**:证书清单含宝塔自签证书与到期天数;语法错误片段部署时回传 reload 失败原因。
- **通用**:新端点全部走 `s.auth`;`go vet` / `go test ./internal/...` / `vue-tsc` 全绿;agent 版本不匹配时前端一律显示升级提示而不是报错。
---
## 八、风险与回滚
| 风险 | 说明 | 应对 |
| --- | --- | --- |
| **新老版本错配** | Havline 已升级、VPS 上的 agent 仍是旧版 | 应用侧对新端点统一做 404/405 降级,前端显示「请升级 Agent」;不改动已有端点的行为 |
| **只读端点的权限** | `/var/log/nginx` 与证书目录可能不允许 agent 运行用户读取 | 端点返回明确的 403 + 路径,前端提示「agent 无读取权限」;安装脚本里补充读权限说明 |
| **`metrics` 采样抖动** | 高频调用时 CPU 差值失真 | 间隔 < 1 秒直接返回上次结果;前端按 15 秒轮询(与本地仪表盘一致) |
| **全量重部署覆盖手工改动** | 会覆盖 VPS 上手工编辑过的 `havline-*.conf` | 按钮带二次确认 + 差异预览;`RouteConf` 回读内容先展示再覆盖 |
| **回滚** | agent 行为变更后需要退回旧版本 | agent 二进制随 Havline 版本走:用旧版 Havline 的「升级 Agent」即可把旧二进制重新上传;新增端点均为**纯增量**,不改变已有端点语义,应用侧回滚也无需数据迁移 |
**总原则**:新增端点一律只读或纯增量、不改变已有端点语义;所有 agent 交互都保持「Havline 主动拉取、agent 被动应答」的现有模型,不引入 agent 主动上报。
-757
View File
@@ -1,757 +0,0 @@
# Agent 默认监听策略与自动安全接入方案
> 方案编号:P0-5
> 制定日期:2026-09-28
> 当前 AgentVersion:0.14.0
> 目标:默认不暴露 Agent 管理接口,安装和升级过程自动建立安全连接,不要求用户手工配置端口、隧道或证书
> 实施状态(2026-09-28):阶段 1-5 主体已在 v1.5.0 完成,包括回环监听、SSH 隧道与后台重连、旧 HTTP 迁移、HTTPS 端口自动选择、证书自动轮换与双 pin 过渡、主机防火墙辅助、传输状态接口与审计记录。剩余:2.0.0 移除旧 HTTP 兼容分支,以及 Debian/Ubuntu/CentOS、宝塔、端口占用、云安全组受限等真机矩阵验证。
---
## 一、结论
推荐采用**自动为主、安全优先、旧模式显式兼容**的三层策略:
1. **默认模式:SSH 隧道 + 回环监听**
- `havline-agent` 默认只监听 `127.0.0.1:7700`。
- 中央 Havline 使用现有 SSH 凭据建立本地转发:
```text
中央 Havline 127.0.0.1:<随机本地端口>
→ SSH 加密连接
→ VPS 127.0.0.1:7700
→ havline-agent
```
- 不开放公网 Agent 端口,不需要域名、证书、云安全组或防火墙修改。
2. **可选模式:自动 HTTPS 反代 + 证书固定**
- Agent 仍只监听回环地址。
- HTTPS 不作为首次安装选项;Agent 安全安装完成后,用户可在「公网 Agent」页切换到该高级模式。
- 切换时自动配置 Nginx HTTPS 反代,并使用自签名证书。
- 中央 Havline 首次安装时记录证书指纹,后续按指纹校验。
- 适用于希望免维护常驻 SSH 隧道、且愿意开放一个 HTTPS 端口的用户。
3. **兼容模式:显式公网明文 HTTP**
- 保留旧部署能力,但必须显式开启。
- UI 持续显示高危警告。
- 不允许自动降级到该模式。
默认安装与升级过程不需要用户设置 `HAVLINE_AGENT_LISTEN`、TLS 证书或防火墙规则。
---
## 二、现状
### 2.1 Agent 监听
当前入口:
```go
listenv := flag.String("listen", envOr("HAVLINE_AGENT_LISTEN", ":7700"), ...)
```
`internal/agent.NewServer` 空配置时同样回落到:
```go
cfg.ListenAddr = ":7700"
```
`:7700` 等价于监听所有 IPv4 / IPv6 网卡。
### 2.2 传输安全
当前 Agent 使用明文 HTTP:
- Bearer Token 在网络上直接传输;
- Token 已采用常量时间比较;
- Agent 默认没有 TLS;
- 安装脚本打印:
```text
API: http://<VPS-IP>:7700
```
### 2.3 中央 Havline 连接
安装完成后,`AgentInstall` 写入:
```go
AgentURL = fmt.Sprintf("http://%s:%d", server.SSHHost, 7700)
```
中央 Havline 后续直接访问 VPS 公网 IP 的 7700 端口。
### 2.4 当前问题
- 用户需要额外放行或确认 7700;
- 如果防火墙忘记放行,Agent 显示不可达;
- 如果放行,Token 暴露在明文网络中;
- 部署工具和容器环境可能改变监听行为;
- “默认安全”没有形成产品行为;
- 安全策略主要依赖用户理解和手工操作。
---
## 三、设计目标
### 3.1 必须实现
1. 新安装默认不监听公网网卡。
2. 安装过程自动建立中央 Havline 到 Agent 的安全连接。
3. 不需要用户手工配置网络或证书。
4. Agent 升级时可自动迁移旧监听配置。
5. 旧部署仍可显式使用公网 HTTP。
6. 传输失败时给出可执行的诊断信息。
7. 中央 Havline 重启后可自动恢复连接。
8. Agent 与中央 Havline 的版本兼容策略保持清晰。
### 3.2 非目标
- 不尝试自动修改云厂商安全组;
- 不要求用户申请域名或公网 CA 证书;
- 不引入多租户权限模型;
- 不把 Agent 管理 API 暴露给家庭用户的普通浏览器;
- 不提供一个“自动回退到明文 HTTP”的危险路径。
---
## 四、方案对比
### 4.1 方案 A:公网 HTTP + Bearer Token(现状)
| 项目 | 评价 |
| --- | --- |
| 自动化 | 高 |
| 安全性 | 低 |
| 防火墙 | 需要放行 7700 |
| 证书 | 不需要 |
| 复杂度 | 低 |
| 主要风险 | Token 明文、端口暴露、被动扫描 |
结论:只保留为显式兼容模式,不再作为默认。
### 4.2 方案 B:SSH 隧道 + Agent 回环监听(推荐默认)
| 项目 | 评价 |
| --- | --- |
| 自动化 | 很高 |
| 安全性 | 高 |
| 防火墙 | 不需要新增端口 |
| 证书 | 不需要 |
| 复杂度 | 中高,需要隧道生命周期管理 |
| 依赖 | SSH 可达;本功能安装 Agent 时已经要求 SSH |
| 主要优势 | 不暴露端口、不依赖域名和证书 |
结论:作为默认模式。
### 4.3 方案 C:Nginx HTTPS + 自签名证书固定
| 项目 | 评价 |
| --- | --- |
| 自动化 | 高 |
| 安全性 | 高 |
| 防火墙 | 需要放行一个 HTTPS 端口 |
| 证书 | 自动生成和固定,不需要公网 CA |
| 复杂度 | 中 |
| 主要优势 | 中央 Havline 可直接连接,不依赖常驻 SSH |
| 主要限制 | 云安全组仍需用户放行端口 |
结论:作为可选直连模式。
### 4.4 方案 D:公网域名 + ACME 证书
| 项目 | 评价 |
| --- | --- |
| 自动化 | 中 |
| 安全性 | 高 |
| 防火墙 | 需要 443 或 DNS-01 |
| 证书 | 需要域名、DNS 与 CA |
| 复杂度 | 高 |
| 用户成本 | 高 |
结论:不作为默认。可作为未来面向高级用户的能力。
---
## 五、推荐架构
### 5.1 默认数据流
```mermaid
flowchart LR
Web[Havline Web] --> API[Havline 后端]
API --> Tunnel[SSH 隧道管理器]
Tunnel -->|127.0.0.1:自动分配端口| SSH[SSH 加密连接]
SSH --> VPSLoopback[VPS 127.0.0.1:7700]
VPSLoopback --> Agent[havline-agent]
```
### 5.2 角色职责
| 组件 | 职责 |
| --- | --- |
| Havline 后端 | 决定 transport、启动隧道、调用 AgentClient |
| 隧道管理器 | 建立 SSH 本地转发、保持连接、重连、健康检查 |
| havline-agent | 默认只监听回环,不再负责公网暴露 |
| 安装脚本 | 写入 `HAVLINE_AGENT_LISTEN=127.0.0.1:7700`,禁止默认可公开 |
| UI | 显示安全模式、连接状态、可选迁移和显式旧模式警告 |
### 5.3 连接抽象
中央 Havline 不再把“公网 IP + 7700”当作 Agent 的唯一定义。新增 transport 概念:
```text
tunnel SSH 本地转发,默认
https-pin Nginx HTTPS + 证书指纹固定,可选
http 公网明文,仅显式兼容
```
`AgentClient` 仍使用普通 HTTP 客户端接口,但 baseURL 由 transport 管理:
- tunnel:`http://127.0.0.1:<localPort>`
- https-pin:`https://<host>:<port>`
- http:`http://<host>:7700`
---
## 六、默认模式:SSH 隧道
### 6.1 为什么默认走 SSH
安装 Agent 已经必须使用 SSH,因此:
- 不新增用户操作;
- 不需要公网开放 7700;
- 不需要证书和域名;
- 不依赖云安全组修改;
- SSH 主机密钥已经使用 TOFU 校验;
- Token 继续保留,形成双层认证。
### 6.2 本地端口分配
每个 VPS 服务端分配一个稳定的本地回环端口:
```text
127.0.0.1:<agent_local_port>
```
分配方式建议:
1. 首次安装时在 `frp_servers.agent_local_port` 中保存;
2. 可在 `17700-18700` 范围内分配;
3. 分配时检查:
- 未被其他服务端占用;
- 未被本机其他 SSH 隧道占用;
- 可以正常绑定。
4. 保存后不再变化,便于重启恢复。
### 6.3 隧道管理器
新增:
```text
internal/frp/agent_tunnel.go
```
核心能力:
- `Ensure(serverID)`:确保隧道已启动;
- `Stop(serverID)`:停止隧道;
- `Restart(serverID)`:重建;
- `State(serverID)`:返回 connected / reconnecting / failed;
- 应用退出时统一关闭。
实现建议:
- 使用现有 `golang.org/x/crypto/ssh`;
- 复用 `SSHInstaller` 的 known_hosts 校验;
- SSH 连接使用 keepalive;
- 本地监听使用 `127.0.0.1`,不绑定 `0.0.0.0`;
- 连接断开后指数退避重连;
- 每个服务端最多一个隧道实例;
- 同一 `AgentClient` 复用 HTTP 连接。
### 6.4 健康检查
隧道建立后执行:
1. 本地端口可以连接;
2. `GET /api/v1/ping` 返回 200;
3. Bearer Token 验证通过;
4. `GET /api/v1/status` 返回 Agent 版本。
只有四项都通过,才把 transport 标记为 connected。
### 6.5 连接状态
前端需要显示:
```text
传输方式:SSH 隧道
本地端口:127.0.0.1:177xx
Agent 地址:127.0.0.1:7700(VPS 回环)
隧道状态:已连接 / 重连中 / 失败
最后错误
```
---
## 七、Agent 默认监听与防误开
### 7.1 默认值
修改:
```go
const defaultListenAddr = "127.0.0.1:7700"
```
以下位置同步:
- `cmd/havline-agent/main.go`
- `internal/agent.NewServer`
- `scripts/agent-install.sh`
- `internal/frp/agent_installer.go` 内嵌安装脚本
### 7.2 远程监听保护
如果监听地址不是回环:
```text
0.0.0.0:7700
:7700
[::]:7700
VPS公网IP:7700
```
则必须显式设置:
```dotenv
HAVLINE_AGENT_ALLOW_REMOTE=1
```
否则 Agent 拒绝启动并输出:
```text
检测到 Agent 将监听非回环地址,但未显式允许远程访问。
默认安全模式只监听 127.0.0.1:7700。
如确认需要公网明文访问,请设置 HAVLINE_AGENT_ALLOW_REMOTE=1。
```
### 7.3 旧配置迁移
旧版本 `agent.env` 可能没有 `HAVLINE_AGENT_LISTEN`,此时:
1. 新 Agent 默认监听回环;
2. 安装脚本显式写入:
```dotenv
HAVLINE_AGENT_LISTEN=127.0.0.1:7700
```
3. 中央 Havline 在执行「升级 Agent」时同步建立隧道;
4. 升级完成后切换 transport;
5. 旧公网地址从 `agent_url` 中移除;
6. 不停留手工维护窗口。
### 7.4 状态接口
Agent `/api/v1/status` 新增:
```json
{
"listen_addr": "127.0.0.1:7700",
"listen_scope": "loopback",
"remote_access_enabled": false,
"transport_security": "loopback"
}
```
---
## 八、自动安装流程
### 8.1 新安装
1. 用户填写 VPS SSH 信息。
2. 中央 Havline 通过 SSH 探测系统与 Nginx。
3. 分配 `agent_local_port`。
4. 上传 Agent 二进制。
5. 安装脚本写入:
- Token;
- `HAVLINE_AGENT_LISTEN=127.0.0.1:7700`;
- systemd 服务。
6. 启动 Agent。
7. 通过 SSH 本地转发建立隧道。
8. 调用:
- `GET /api/v1/ping`
- `GET /api/v1/status`
9. 保存:
- token 密文;
- transport=`tunnel`;
- local port;
- AgentVersion。
10. UI 显示“安全模式已启用”。
### 8.2 自动迁移旧安装
1. 检测现有 `agent_url` 是否为 `http://公网IP:7700`。
2. 显示“可升级到安全模式”,但不强制立即迁移。
3. 用户点击「升级 Agent」后:
- 安装新二进制;
- 重写 `agent.env` 为回环监听;
- 重启服务;
- 建立隧道;
- 验证连通;
- 切换 central transport。
4. 只有隧道验证成功后,才关闭旧公网连接。
5. 失败时保留旧 Agent 配置和旧连接,自动回滚。
### 8.3 应用重启恢复
1. 读取启用管理且 transport=tunnel 的服务端。
2. 逐个调用 `Tunnel.Ensure`。
3. 启动失败不阻塞整个 Havline 启动。
4. UI 显示重连状态。
5. 后台按指数退避继续重试。
---
## 九、可选模式:HTTPS + 证书固定
### 9.1 适用场景
- 不希望长期维护 SSH 隧道;
- 可以接受开放一个 HTTPS 端口;
- 没有域名或不想申请公网证书;
- 希望 Agent API 可被中央 Havline直接访问。
### 9.2 安装后切换流程
首次安装默认仍使用 SSH 隧道。只有用户在「公网 Agent」页明确切换到 HTTPS 直连时,才执行以下步骤:
1. Agent 仍监听 `127.0.0.1:7700`。
2. 切换流程生成自签名证书:
```text
/etc/havline-agent/tls/agent.crt
/etc/havline-agent/tls/agent.key
```
3. 自动寻找可用端口:
- 优先 7443;
- 其次 8443、9443;
- 若 443 空闲且未占用可提供选项,但不默认抢占。
4. 生成独立 Nginx server block:
```nginx
server {
listen 7443 ssl;
ssl_certificate /etc/havline-agent/tls/agent.crt;
ssl_certificate_key /etc/havline-agent/tls/agent.key;
location /api/v1/ {
proxy_pass http://127.0.0.1:7700;
}
}
```
5. 返回证书 SHA-256 指纹。
6. 中央 Havline 保存 pin,并使用自定义校验。
### 9.3 TLS 固定
不能使用:
```go
InsecureSkipVerify: true
```
应实现:
```go
VerifyPeerCertificate(rawCerts [][]byte, _ [][]*x509.Certificate) error {
// 计算证书 DER 的 SHA-256
// 与数据库中保存的 pin 做常量时间比较
// 同时校验有效期与 SAN(如可校验)
}
```
首次安装必须通过 SSH 获取指纹,避免首次连接被中间人截获。
### 9.4 防火墙
> 实施更新(v1.5.0 / Agent 0.14.0):已支持检测 ufw / firewalld / nftables / iptables;对 ufw 与 firewalld 可在用户明确确认后自动放行,nftables / iptables 仅检测和提示,不会自动改规则。
自动放行仅在用户明确确认后执行,支持的工具:
- ufw;
- firewalld;
- nftables / iptables(只提示,不自动改规则)。
云厂商安全组始终不能自动修改。UI 必须提示:
```text
请在云控制台放行 TCP 7443。主机防火墙需要你确认后由 Havline 执行,Havline 不会修改云安全组。
```
---
## 十、兼容模式:显式公网 HTTP
仅用于旧部署迁移或用户明确要求:
```dotenv
HAVLINE_AGENT_LISTEN=:7700
HAVLINE_AGENT_ALLOW_REMOTE=1
```
中央 Havline UI 必须:
- 显示“公网明文传输”红色状态;
- 不在新安装中默认选择;
- 不支持由自动模式静默回退;
- 显示迁移到 SSH 隧道或 HTTPS 的按钮;
- 在变更时记录审计事件。
旧 transport 允许读取和继续连接,但所有新增安装默认使用 tunnel。
---
## 十一、数据模型变更
建议新增迁移:
```sql
ALTER TABLE frp_servers ADD COLUMN agent_transport TEXT NOT NULL DEFAULT 'http';
ALTER TABLE frp_servers ADD COLUMN agent_local_port INTEGER NOT NULL DEFAULT 0;
ALTER TABLE frp_servers ADD COLUMN agent_tls_pin TEXT NOT NULL DEFAULT '';
ALTER TABLE frp_servers ADD COLUMN agent_listen_addr TEXT NOT NULL DEFAULT '';
ALTER TABLE frp_servers ADD COLUMN agent_transport_state TEXT NOT NULL DEFAULT 'unknown';
ALTER TABLE frp_servers ADD COLUMN agent_transport_error TEXT NOT NULL DEFAULT '';
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `agent_transport` | `tunnel` / `https-pin` / `http` |
| `agent_local_port` | 隧道本地回环端口 |
| `agent_tls_pin` | HTTPS 证书 DER SHA-256 |
| `agent_listen_addr` | Agent 实际监听地址缓存 |
| `agent_transport_state` | `connected` / `reconnecting` / `failed` / `unknown` |
| `agent_transport_error` | 最近一次连接错误 |
### 11.1 兼容迁移
旧数据:
```text
agent_transport = http
```
新安装:
```text
agent_transport = tunnel
```
---
## 十二、API 与前端变更
### 12.1 API
`FrpServer` JSON 增加:
```json
{
"agent_transport": "tunnel",
"agent_transport_state": "connected",
"agent_local_port": 17701,
"agent_listen_addr": "127.0.0.1:7700",
"agent_transport_error": ""
}
```
新增管理端点:
| 方法 | 路径 | 作用 |
| --- | --- | --- |
| POST | `/api/frpmulti/servers/{id}/agent/transport` | 切换 tunnel / https-pin / http |
| POST | `/api/frpmulti/servers/{id}/agent/tunnel/restart` | 重启隧道 |
| GET | `/api/frpmulti/servers/{id}/agent/transport` | 读取详细状态 |
### 12.2 前端
「公网 Agent」页新增传输模式卡:
- SSH 隧道,默认;
- HTTPS + 证书固定,可选;
- 公网 HTTP 明文,兼容。
显示:
```text
传输方式:SSH 隧道
隧道状态:已连接
本地入口:127.0.0.1:17701
Agent 监听:127.0.0.1:7700
安全等级:安全
```
旧模式显示:
```text
公网明文 HTTP
风险:Token 会通过网络明文传输
建议迁移到 SSH 隧道
```
---
## 十三、自动回滚
迁移或切换 transport 失败时:
1. 保留旧 transport 与旧 Agent 配置;
2. 不覆盖旧 Token;
3. 不删除旧公网端口配置;
4. 如果已经重写 `agent.env`,恢复旧文件;
5. 如果 Agent 已重启但新连接失败,重新启动旧服务;
6. 向 UI 返回完整错误和回滚结果。
任何自动迁移都必须满足:
```text
新连接验证成功
→ 才提交数据库 transport
→ 才停止旧监听
```
---
## 十四、测试计划
### 14.1 单元测试
- 默认监听地址是回环;
- 非回环监听缺少 `HAVLINE_AGENT_ALLOW_REMOTE` 时拒绝启动;
- 回环监听无需额外变量;
- `agent_transport` 序列化与旧数据兼容;
- 隧道本地端口分配、冲突与释放;
- 隧道重连、退避和关闭;
- 证书 pin 正确、证书变化、pin 不匹配时拒绝;
- 旧 HTTP transport 继续可用并产生警告。
### 14.2 集成测试
- 在测试 SSH 服务上建立本地转发;
- Agent 只监听 127.0.0.1;
- 应用重启后隧道自动恢复;
- SSH 断开后重连;
- 多服务端并发隧道;
- 迁移前失败回滚;
- 迁移成功后旧公网端口关闭。
### 14.3 真机验证
至少覆盖:
- Debian / Ubuntu / CentOS;
- fnOS 中的 Havline;
- 宝塔 Nginx;
- 已存在 80 / 443 / 7443 占用的 VPS;
- 云安全组只开放 SSH 的 VPS;
- 云安全组开放 7443 的可选 HTTPS 模式。
### 14.4 验收条件
- 新安装后 `ss -tlnp` 只能看到 Agent 监听 `127.0.0.1:7700`;
- 公网扫描不到 7700;
- 无需新增云安全组规则;
- UI 显示隧道已连接;
- Havline 重启后自动恢复;
- 「升级 Agent」可以自动迁移旧配置;
- 失败时不会丢失旧连接。
---
## 十五、实施阶段
### 阶段 1:默认回环与防误开
- Agent 默认监听改为 `127.0.0.1:7700`;
- 非回环监听要求 `HAVLINE_AGENT_ALLOW_REMOTE=1`;
- 安装脚本显式写入监听地址;
- UI 显示监听范围。
该阶段暂不自动切换中央连接,避免破坏旧部署。
### 阶段 2:SSH 隧道管理器
- 新增数据库字段;
- 新增隧道管理器和 AgentClient transport;
- 安装后自动建立隧道;
- 应用重启恢复;
- UI 显示 transport 状态。
### 阶段 3:自动迁移旧 Agent
- 检测旧公网 HTTP;
- 「升级 Agent」时执行安全迁移;
- 成功切换后关闭旧入口;
- 失败自动回滚。
### 阶段 4:可选 HTTPS + pin
- 自动生成证书;
- Nginx server block;
- TLS pin;
- 防火墙辅助;
- UI 可选切换。
### 阶段 5:收紧默认
- 新安装不再接受公网 HTTP;
- 兼容模式需要明确的高风险确认;
- 文档和通知引导迁移。
---
## 十六、决策结论
以下决策已于 2026-09-28 确定,后续实施按此执行。
| 编号 | 决策 | 执行设定 | 理由 |
| --- | --- | --- | --- |
| 1 | SSH 隧道作为唯一默认模式 | 首次安装不提供模式选择,统一安装为回环监听 + SSH 隧道;HTTPS 直连只作为安装后的高级切换项 | 现有安装流程已经依赖 SSH;默认路径必须最少输入、最少网络要求 |
| 2 | 隧道本地端口使用固定范围 | 固定为 `17700-18700`,绑定 `127.0.0.1`;分配结果持久化;启动时先复用持久化端口,再在范围内顺序查找空闲端口;范围耗尽时明确报错,不使用临时端口 | 固定范围便于排查和审计;持久化避免每次重启变化;不回退临时端口避免地址漂移 |
| 3 | 隧道跟随主应用生命周期 | 使用应用内 `agent_tunnel` 管理器,在 Havline 启动后按服务端恢复;应用退出时统一关闭;断线后指数退避重连;首版不新增独立 systemd 服务 | 当前项目是单进程控制面,调度器、FRP、监控均在同一生命周期内,新增守护进程会扩大部署复杂度 |
| 4 | 公网 HTTP 保留整个 1.x 兼容期 | 自 `1.4.0` 起不再允许新安装选择 HTTP;存量连接继续可用但显示高危警告并提供迁移;计划在 `2.0.0` 移除 | 需要给旧 Agent 和环境留出迁移窗口,同时从版本边界上明确技术债的截止点 |
| 5 | HTTPS 自签名证书使用短期自动轮换 | ECDSA P-256,有效期 365 天;剩余 30 天时自动轮换;轮换期间同时接受旧 pin 和新 pin,双 pin 窗口 7 天;优先通过现有 SSH 通道获取新指纹后再切换 | 避免长期证书失管;双 pin 防止轮换瞬间把所有中央连接锁死 |
| 6 | 首版不自动改主机防火墙 | 首先只检测、展示和提示;SSH 隧道模式本身不需要放行新端口;HTTPS 高级模式后续再提供显式的“一键放行主机防火墙”操作,并始终不修改云厂商安全组 | 防火墙和云安全组属于高风险外部状态;首版先把安全默认值落地,避免自动变更导致 SSH 或现有服务失联 |
### 16.1 决策对实施阶段的影响
- 阶段 1 只做 Agent 默认回环和防误开。
- 阶段 2 实现 `17700-18700` 的持久化端口分配与应用内隧道管理器。
- 阶段 3 完成旧 Agent 自动迁移;迁移失败必须回到旧 transport。
- 阶段 4 增加 HTTPS + pin 的高级模式、证书轮换和双 pin 窗口。
- `1.4.0` 起前端不再允许新建 HTTP transport。
- `2.0.0` 删除 `http` transport 和旧 Agent 兼容分支。
- 防火墙变更仅在阶段 4 之后按用户明确操作执行,首版不实现自动写规则。
---
## 十七、最终建议
优先实现前三个阶段:
1. Agent 默认回环;
2. SSH 隧道自动建立与恢复;
3. 旧 Agent 自动迁移并支持回滚。
这三步完成后,默认部署不再暴露公网 7700,也不需要用户修改 Composer 或防火墙。HTTPS + pin 与防火墙辅助放在后续阶段,作为需要直连用户的可选增强。这样可以在不扩大用户操作成本的前提下,完成 P0-5 的安全目标。
-163
View File
@@ -1,163 +0,0 @@
# 公网反代(Agent 页「公网 Nginx 反代」区)功能增强规划
> 基于实际部署踩坑史(目录探测、证书缺失、端口漂移、类型错配均发生过)梳理,
> 每项标注:价值 / 实现成本 / 依赖。供后续逐项开发选用。
> 更新日期:2026-09-21
---
第一梯队(最推荐先做,1-2 天)
功能 解决什么
① 部署后在线验证 部署成功后 agent 在 VPS 上 curl -H "Host: 域名" 127.0.0.1:9080,页面直接显示「回源自检 200 ✓」或「回源 502 ✗(隧道未通)」——你昨天遇到的“部署成功但打开 502”当时就能看见断在哪层
③ 状态徽章实时化 「已部署」升级为「已生效 / 已写入未加载 / 未部署」三态(nginx -T 真实验证),tcp/ 目录那种“写了但不加载”的残留一眼可见
⑥ WebSocket 开关 家庭影院等实时服务的标配,一个开关 + 模板片段,成本极低
第二梯队(反代能力追平本地反代)
② 配置查看/自定义:每条规则可看生成的 nginx 配置、可改(WebSocket 头、特殊 location 不再需要 SSH)——本地反代已有 auto/custom 模式可直接复用;
④ HTTP 强制跳转 HTTPS、⑤ IP 白名单 / Basic Auth——公网暴露面的安全标配。
第三/四梯队(多规则后有价值)
批量同步、部署历史回滚、域名健康检查面板(含证书剩余天数)、自定义 upstream、流量统计、错误日志快捷查看——文档里都有具体设计。
文档末尾附了配套 API 草案(4 个端点 + agent 侧新增项),实现时按这个走即可。说一声做哪几个,我按第一批开始。
## 现状回顾(已具备)
- 手动部署 / 移除单条规则的反代(部署时自动推证书、自动读最新 vhost 端口);
- VPS 当前已加载路由展示(`agentStatus.routes`);
- 反代文件按 `havline-<域名>.conf` 写入 Nginx 实际加载目录(`-T` 试探-验证)。
---
## 第一梯队:立刻有用(解决「部署了≠生效」的可见性)
### 1. 部署后在线验证(强烈推荐,第一个做)
部署成功后 agent 自动做一次真实探测并把结果返回给页面:
```text
在 VPS 上执行:
curl -s -o /dev/null -w "%{http_code}" -H "Host: <域名>" http://127.0.0.1:<vhost端口>
```
- 页面显示「部署成功 · 回源自检 200 ✓」或「部署成功 · 但回源 502 ✗(隧道未通)」;
- 直接区分「反代层 OK / 隧道层断」,消灭"部署成功但打开 502"这类排障;
- 成本:低(agent 加一个验证步骤 + 前端展示字段)。
### 2. Nginx 配置内容查看 / 自定义
每条规则提供「查看生成的 nginx 配置」弹窗(对齐本地反代已有的 auto/custom 模式):
- auto:显示 agent 生成的配置(只读);
- custom:用户编辑后下发,agent 写入文件并 reload;
- 排障时可确认「文件里到底写了什么」;特殊需求(WebSocket 头、自定义 location、缓存策略)不再需要 SSH;
- 成本:中(agent 加 GET/PUT conf 接口;本地反代的 nginx_config.go 模式可直接复用)。
### 3. 反代状态徽章的实时化
「已部署 / 未部署」目前基于 `routes` 文件名推断,升级为真实状态:
- 检查文件存在 ✓ + `nginx -t` 通过 ✓ + 域名出现在 `nginx -T` ✓ → 「已生效」;
- 文件在但 `nginx -T` 没有它 → 「已写入未加载」(目录不对的残留场景);
- 成本:低。
## 第二梯队:安全与可控性(公网暴露面的标配)
### 4. 一键 HTTPS 强制跳转
生成的配置加一段 80 → 443 的 301 redirect(现在 http 和 443 是两个平行的 server)。
- 表单按规则勾选「HTTP 跳转 HTTPS」;
- 成本:低。
### 5. 访问控制(IP 白名单 / Basic Auth)
公网服务(尤其路由器/摄像头等敏感入口)的标配:
- IP 白名单:`allow x.x.x.x; deny all;` 列表来自表单;
- Basic Auth:agent 生成 htpasswd 并在 location 中引用;
- 成本:中。
### 6. WebSocket 支持开关
生成配置追加 `proxy_set_header Upgrade $http_upgrade; Connection "upgrade";`(群晖/家庭影院等实时服务需要)。
- 成本:低(一个开关 + 模板片段)。
## 第三梯队:运维效率(多规则后才有价值)
### 7. 批量部署 / 全部同步
「一键同步全部规则」:对当前服务端所有候选规则循环部署,展示每条的成败。
配合规则增删后的批量重建场景。
- 成本:低-中。
### 8. 部署历史与回滚
每次部署在 agent 侧留 `havline-<域名>.conf.bak-<时间戳>`(保留最近 N 份),
页面提供「查看历史 / 回滚到此版本」。
- 误操作(比如改坏了 upstream)30 秒恢复;
- 成本:中。
### 9. 域名健康检查面板
页面顶部一行:每个已部署域名的公网可达性(HTTPS 状态码 + 证书剩余天数),
由 Havline 侧定时探测(复用服务端诊断的 TTL 缓存模式)。
- 证书临期/隧道断链主动可见;
- 成本:中。
## 第四梯队:锦上添花
### 10. 自定义 upstream 覆盖
允许某条规则的反代目标不跟随穿透规则(例如指向本机 Nginx:80 做二次分流),
默认仍自动取穿透规则的 vhost 回源。
- 成本:低。
### 11. 流量统计接入
frps dashboard 已有连接数/流量数据(Havline 正在拉取),按域名聚合展示到反代列表,
可见每条反代的实际使用量。
- 成本:中。
### 12. Nginx 错误日志快捷查看
agent 提供 `tail /www/wwwlogs/<域名>.error.log`,规则行内展开最近 20 行。
- 成本:低。
---
## 建议路线
```text
第一批(1-2 天):① 部署后在线验证 + ③ 状态徽章实时化 + ⑥ WebSocket 开关
→ 直接消灭「部署成功但不通」的灰区
第二批(2-3 天):② 配置查看/自定义 + ④ 强制跳转 + ⑤ 访问控制
→ 反代层能力追平本地反代
第三批(按需):⑦⑧⑨⑩⑪⑫
```
## 附:接口草案(第一/二批共用)
```text
GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf # 查看生成配置
PUT /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf # 自定义配置下发
POST /api/frpmulti/servers/{id}/agent/routes/{proxyID}/verify # 部署后在线验证
POST /api/frpmulti/servers/{id}/agent/routes/batch # 批量同步
```
agent 侧新增:`GET/PUT /api/v1/routes/{domain}/conf`、验证逻辑复用部署链路的探测段。
-209
View File
@@ -1,209 +0,0 @@
# 公网反代「部署设置」对齐本地反代 —— 方案设计
> 目标:把本地反向代理(`ProxyView.vue` 规则弹窗)中真正有价值的安全与运维设置,按公网反代的语义迁移到
> 「公网反代」页(`/public-proxy`,`ProxyRemoteView.vue`)的「部署设置」中,由 havline-agent 生成对应的 Nginx vhost 配置。
>
> 原则:**公网反代只管「公网入口」这一层**。凡是会破坏 frps vhost 路由、或本质属于「本机内网分流」的选项,
> 一律不迁移(见「明确不做」一节)。
>
> 更新日期:2026-09-22
---
## 建议
「部署设置」弹窗从现在的单页改为**三个 Tab(基础 / 安全 / 高级)**,与本地反代弹窗结构对齐:
-
**基础**:启用 HTTPS、强制跳转、WebSocket、上传大小、代理超时
-
**安全**:IP 白名单 / 黑名单、Basic Auth(用户名 + 可选密码)
-
**高级**:仅 TLS 1.3、安全响应头、限流、连接数限制
第一梯队 7 项都是 agent 模板内改动 + Havline 参数透传,风险低、见效快;第二梯队 3 项需要 agent 多写一个 http 上下文文件。说一声我从第一梯队开始做,或者指定要哪几项。
## 一、现状对照
### 本地反代(ProxyView.vue)规则弹窗现有设置项
| 分组 | 设置项 |
| --- | --- |
| 基础 | 名称、前端域名、目标地址、**启用 HTTPS**、**HTTP 跳转 HTTPS**、启用规则 |
| 安全 · IP 访问控制 | 仅中国大陆 IP、黑名单模式 + 列表、白名单模式 + 列表 |
| 安全 · 认证 | Basic Auth(用户名 + **自定义密码**) |
| 安全 · 流量控制 | 请求限流(速率 / 突发)、连接数限制(每 IP 并发) |
| 安全 · 高级 | 忽略后端 TLS 证书、使用目标 Host 头、仅 TLS 1.3、安全响应头 |
| Nginx | auto / custom 自定义配置 |
### 公网反代(agent `nginxRoute` 模板)现有能力
`internal/agent/server.go` 的 `routeRequest` 与 `nginxRoute` 已支持:
- `domain` / `upstream`(`127.0.0.1:<frps vhost 端口>`)/ `tls` + `cert_dir`;
- `websocket`(Upgrade / Connection 头);
- `redirect_https`(80 → 443 的 301 + 443 TLS server 两块);
- `allow_ips`(location 内 allow + deny all);
- `basic_auth_user`(密码由 agent 自动生成并落盘 htpasswd);
- 默认头:Host / X-Real-IP / X-Forwarded-For。
Havline 侧对应链路:`ProxyOptions`(`internal/frp/model.go`)→ `DeployAgentRoute` → `AgentClient.SyncRouteEx`
→ `PUT /api/v1/routes`。
---
## 二、迁移清单
### 第一梯队:纯模板级改动,无 Nginx 上下文限制(建议先做)
| # | 功能 | 对应本地项 | 生成的 Nginx 指令 |
| --- | --- | --- | --- |
| 1 | **启用 HTTPS 开关** | 启用 HTTPS | 关闭时仅 `listen 80`;开启且证书已推送时 `listen 443 ssl` |
| 2 | **安全响应头** | 安全响应头 | `add_header Strict-Transport-Security / X-Frame-Options / X-Content-Type-Options / Referrer-Policy` |
| 3 | **仅 TLS 1.3** | 仅 TLS 1.3 | `ssl_protocols TLSv1.3;` |
| 4 | **客户端最大上传大小** | (本地为全局默认) | `client_max_body_size 50m;`(server 级,按规则可覆盖) |
| 5 | **代理超时** | (本地固定 3600s) | `proxy_read_timeout 3600s;`(长轮询 / 流媒体 / 大文件必需) |
| 6 | **IP 黑名单** | 黑名单模式 | location 内 `deny x.x.x.x;`(与现有白名单对称) |
| 7 | **Basic Auth 自定义密码** | Basic Auth 密码 | 用表单密码写 htpasswd;留空则保持「首次自动生成」 |
### 第二梯队:需要 Nginx `http` 上下文或额外数据下发(成本中等)
| # | 功能 | 关键约束 |
| --- | --- | --- |
| 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 havline_<域名>_conn N;` |
| 10 | **仅中国大陆 IP** | 需 `geo` 模块 + 中国 IP 段下发到 VPS(Havline 已有 china cidr 模块,7456 条 v4),下发体积最大、成本最高,建议最后做 |
### 明确不做(字段 / 语义不匹配)
| 功能 | 原因 |
| --- | --- |
| 使用目标 Host 头 | 公网回源目标是 frps 的 vhost 端口,**frps 靠 Host 头匹配隧道**。改成上游 Host 会让 vhost 无法路由,直接 404。必须保持 `$host` |
| 忽略后端 TLS 证书 | 公网 upstream 是 `http://127.0.0.1:<vhost端口>`,不涉及上游 TLS 校验,无作用 |
| 自定义 upstream 覆盖 | 技术上可行,但会让流量绕过 frps 隧道(健康检查「隧道」必然 ✗),语义属于「本机内网分流」,只在本地反代页做 |
---
## 三、字段契约
两侧 JSON key 必须严格一致(历史教训:字段未逐项对照导致多轮返工)。
| 显示名 | `ProxyOptions`(Go,`internal/frp/model.go`) | JSON key | TS(`web/src/api/types.ts`) | `routeRequest`(agent) | Nginx 指令 |
| --- | --- | --- | --- | --- | --- |
| 启用 HTTPS | `HTTPSDisabled bool` | `https_disabled` | `https_disabled?: boolean` | `HTTPSDisabled bool` | 关闭时不出 `listen 443` |
| 上传大小 | `ClientMaxBodySize string` | `client_max_body_size` | `client_max_body_size?: string` | `ClientMaxBodySize string` | `client_max_body_size 50m;` |
| 代理超时 | `ProxyReadTimeout string` | `proxy_read_timeout` | `proxy_read_timeout?: string` | `ProxyReadTimeout string` | `proxy_read_timeout 3600s;` |
| 安全响应头 | `SecurityHeaders bool` | `security_headers` | `security_headers?: boolean` | `SecurityHeaders bool` | `add_header ...` |
| 仅 TLS 1.3 | `TLS13Only bool` | `tls13_only` | `tls13_only?: boolean` | `TLS13Only bool` | `ssl_protocols TLSv1.3;` |
| IP 黑名单 | `DenyIPs []string` | `deny_ips` | `deny_ips?: string[]` | `DenyIPs []string` | `deny x;` |
| Basic Auth 密码 | `BasicAuthPassword string` | `basic_auth_password` | `basic_auth_password?: string` | `BasicAuthPassword string` | 写入 htpasswd |
| 请求限流(速率) | `RateLimitRate int` | `rate_limit_rate` | `rate_limit_rate?: number` | `RateLimitRate int` | `limit_req_zone ... rate=Nr/s;` |
| 请求限流(突发) | `RateLimitBurst int` | `rate_limit_burst` | `rate_limit_burst?: number` | `RateLimitBurst int` | `limit_req ... burst=N` |
| 连接数限制 | `ConnLimitMax int` | `conn_limit_max` | `conn_limit_max?: number` | `ConnLimitMax int` | `limit_conn_zone` / `limit_conn` |
| 仅中国大陆 | `ChinaOnly bool` | `china_only` | `china_only?: boolean` | `ChinaOnly bool` | `geo` + `allow`/`deny` |
约束:
- 这些字段**只作用于公网 Nginx 模板,绝不写入 frpc.toml**(与现有 `websocket` / `redirect_https` 等一致,在 `ProxyOptions` 上以注释标明);
- 「启用 HTTPS」采用**反向字段 `https_disabled`**:字段缺省时保持现有行为(证书推送成功即启用 TLS),
保证新 Havline + 旧 agent、旧数据不出现行为突变。
---
## 四、实施步骤
### 阶段 1:第一梯队后端(agent + Havline)
1. `internal/agent/server.go`
- `routeRequest` 增加第一梯队字段;
- `nginxRoute` 模板按字段输出指令:`listen 443` 受 `HTTPSDisabled` 控制;`redirect_https` 仅在 TLS 实际启用时生成;
- `client_max_body_size` / `proxy_read_timeout` / 安全响应头 / `ssl_protocols` / `deny` 列表;
- Basic Auth:传入密码时 `htpasswd -nbB` 写入,未传则沿用现有「首次自动生成 + 复用」。
2. `internal/frp/model.go`:`ProxyOptions` 增加同名 Go 字段(含注释)。
3. `internal/frp/agent_client.go`:`SyncRouteEx` 参数改为**传结构体**(当前 10 个位置参数已到上限,继续追加易错):
```go
type RouteSpec struct {
Domain, Upstream, CertDir string
TLS, WebSocket, RedirectHTTPS, HTTPSDisabled bool
VhostPort int
AllowIPs, DenyIPs []string
BasicAuthUser, BasicAuthPassword string
ClientMaxBodySize, ProxyReadTimeout string
SecurityHeaders, TLS13Only bool
}
func (c *AgentClient) SyncRouteSpec(ctx, spec RouteSpec) (map[string]any, error)
```
保留 `SyncRoute` / `SyncRouteEx` 作为兼容包装。
4. `internal/frp/service.go`:`DeployAgentRoute` 从 `proxy.Options` 组装 `RouteSpec` 透传。
### 阶段 2:第一梯队前端
5. `web/src/api/types.ts` 补 `FrpProxyOptions` 字段;
6. `ProxyRemoteView.vue`「部署设置」弹窗改为三个 Tab(与本地反代结构对齐):
- **基础**:启用 HTTPS、HTTP 强制跳转、WebSocket、上传大小、代理超时;
- **安全**:IP 白名单、IP 黑名单、Basic Auth(用户名 + 可选密码);
- **高级**:仅 TLS 1.3、安全响应头(第二梯队上线后再加限流/连接数/中国 IP)。
### 阶段 3:第二梯队(按需)
7. agent 增加 `havline-limits.conf` 生成器(http 上下文 zone 定义)+ 每域名 server 引用;
8. 中国 IP:Havline 将 china cidr 列表推送到 agent(`/var/lib/havline-agent/geo/china.conf`),agent 生成 `geo` 块。
---
## 五、验证方案
每次改动后全量跑(项目既有闭环):
```powershell
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
go build ./... ; go vet ./internal/frp ./internal/agent ./internal/api
go test ./internal/frp ./internal/api
cd web ; npx vue-tsc --noEmit ; npx vite build
```
agent 二进制需按既有流程交叉编译(amd64 + arm64)并升级:
```powershell
$env:GOOS='linux'; $env:GOARCH='amd64'; $env:CGO_ENABLED='0'
go build -trimpath -o internal/frp/agentbin/havline-agent-linux-amd64.new ./cmd/havline-agent
# arm64 同理;Move-Item 覆盖后,在「公网 Agent」页执行「升级 Agent」
```
VPS 侧实测(部署 7023 规则后):
```bash
nginx -t # 语法
cat /www/server/panel/vhost/nginx/havline-7023.36800.cc.conf # 配置内容(或页面「查看配置」)
curl -I http://7023.36800.cc # 强制跳转 → 301
curl -sI -H 'Host: 7023.36800.cc' http://127.0.0.1:9080 # 回源自检
curl -k -sI https://7023.36800.cc # 响应头(HSTS / X-Frame-Options)
openssl s_client -connect 7023.36800.cc:443 -tls1_2 </dev/null | head -3 # 仅 TLS1.3 时应握手失败
curl -sI -H 'Host: 7023.36800.cc' -d @bigfile http://127.0.0.1:9080 # 上传大小限制
for i in $(seq 1 50); do curl -s -o /dev/null -w '%{http_code} ' http://7023.36800.cc/; done # 限流 → 429(第二梯队)
```
排查口径沿用现有约定:健康检查五段徽标(DNS / 证书 / 隧道 / 服务 / 反代生效态)任一 ✗,
悬停看详情定位断点,不再靠猜。
---
## 六、兼容性与回滚
| 场景 | 期望行为 |
| --- | --- |
| 新 Havline + 旧 agent | 旧 agent 忽略未知 JSON 字段(Go 默认行为),新选项静默不生效 → **部署前必须先「升级 Agent」**,页面提示 |
| 旧 Havline + 新 agent | 新字段缺省,行为与现状一致(`https_disabled` 缺省 = 启用 TLS) |
| frps v0.68.0 / v0.71.0 | 与本方案无关(作用于 Nginx vhost,不依赖 dashboard API) |
| 回滚 | 关闭对应开关后重新部署即可恢复;agent 侧每域名配置为整文件覆盖写,无残留 |
---
## 七、附:与既有文档的关系
- `docs/agent-proxy-roadmap.md`:功能梯队总览(本方案是其「第二批」在部署设置维度的细化);
- `docs/frps-features.md`:frps 服务端配置能力对照(frps.toml 层面,与本文的 Nginx 层面互补)。
-239
View File
@@ -1,239 +0,0 @@
# 公网 Agent 页(`AgentView.vue`)设计方案 v2
> **时效说明(2026-09-24)**:文中「顶部下拉选服务端」的结构已被 v1.2.6 的**左列表 + 右详情**取代(页面标题也改为「公网服务端」),现状见 `docs/frp-server-list-merge-plan.md`。
> 本文按 `breezell-design` 两遍流程产出,核心是**先列清功能、再谈设计**:
> 第二节把本页应有的 **27 项能力**逐条落位(含 5 处当前页面看不到的能力),第四节按功能分区。
>
> 取代 `docs/agent-view-design.md`(v1 只做了结构搬家,未解决"功能不可见"问题);
> 也取代 `docs/agent-view-refactor-plan.md`。更新日期:2026-09-23
---
## 一、Pass 1a:栈与设计系统检测
| 检测项 | 实际值 |
| --- | --- |
| 框架 | 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`) |
| 图标 | `@vicons/ionicons5` |
| 令牌系统 | `web/src/styles/variables.css`:`:root` + `html.dark` 双主题,`--havline-*` 前缀 |
| 共享组件 | `PageHeader` `HavlineCard` `StatCard` `StatusBadge` `EmptyState` `LoadError` |
| 工具 | `utils/format.ts`(`formatBytes/formatRate/formatDate/formatRelativeTime/…`)、`utils/status.ts`(`StatusKind`) |
**结论**:沿用既有体系(naive-ui + `--havline-*` + 共享组件),**不引入**第二套框架/令牌。
---
## 二、功能总清单(Capability Inventory)
> 设计原则:**没有隐式功能**。每项能力都要有固定位置、可见状态、明确反馈;
> 能做的显示按钮,不能做的显示**原因**(禁用 + 提示),而不是隐藏。
| # | 能力 | 数据来源 | 当前状态 | 新设计位置 | 可见性形态 |
| --- | --- | --- | --- | --- | --- |
| A | 选择公网服务端 | `listFrpServers`(agent/ssh 已配置) | 已有 | `PageHeader#actions` | 下拉选择 |
| B | Agent 连通性 | `getFrpAgentStatus` 成败 | 已有 | ① 运行状态 | 三态:在线 / 未连接 / 未加载 |
| C | Agent 地址 | `server.agent_url` | 已有 | ① 运行状态卡副标题 | 等宽文本 |
| D | Agent 版本 | `agent_version` | 已有 | ② 环境与依赖 | 等宽文本 |
| E | **Agent 凭据是否已配置** | `server.agent_configured` | **未展示** | ② 环境与依赖 | StatusBadge:已配置 / 未配置 |
| F | frps 运行状态 | `frps_active` | 已有 | ① 运行状态 | 在线 / 未运行 |
| G | frps 已安装版本 | `frps_version` | 已有 | ① + ② | 文本 / 徽标 |
| H | frps 二进制路径 | `frps_binary` | 已有 | ② 环境与依赖 | 等宽 + 换行 |
| I | frps 配置文件路径 | `frps_config_path` | 已有 | ② 环境与依赖 | 等宽 + 换行 |
| J | frps 启动命令 | `frps_unit_exec` | 已有 | ② 环境与依赖 | 等宽 + 换行 |
| K | frps 开机自启 | `frps_enabled` | 已有 | ② 环境与依赖 | StatusBadge |
| L | frps 管理面板入口 | `dashboard_addr/port` → `dashboardURL` | 有(未配置时**按钮隐藏**) | ③ frps 控制 | **常显**;未配置时禁用 + tooltip 指向配置位置 |
| M | 端口监听 80/443/7000/8080 | `ports` | 仅 80/443 卡 + 7000 字段(**8080 未展示**) | ① 运行状态「端口」卡 | 四端口全列:`80✓ 443✓ 7000✗ 8080✗` |
| N | **已注册反代路由数** | `routes[]` | **未展示** | ① 运行状态 | 「反代路由 N 条」+ 折叠列表 |
| O | **最后一次检查时间** | `checked_at` | **未展示** | `PageHeader` 副标题 | 「检查于 x 前」(`formatRelativeTime`) |
| P | **Nginx 可用性(本页完全未展示)** | `nginx_ok/missing/running/version/path/conf_dir/error` | **未展示**(在公网反代页) | ② 环境与依赖「Nginx」组 | 徽标 + 版本 + 路径 + 配置目录 + 错误原文 + 跳转 |
| Q | 启动 / 停止 frps | `frpsServerActionRaw(start/stop)` | 已有 | ③ frps 控制 | 按状态互斥的按钮 |
| R | 重启 frps | `frpsServerActionRaw(restart)` | 已有 | ③ frps 控制 | 按钮 |
| S | 下发 frps 配置 | `pushFrpConfigToAgent` | 已有(散在另一卡) | ③ frps 控制 | 按钮 |
| S2 | 查看即将下发的 frps 配置 | `getFrpFRPSConfig` | 本页未提供 | ③ frps 控制(紧跟下发按钮) | 按钮 + 只读弹窗(等宽代码块 + 复制) |
| T | 安装 / 升级 frps | `installFrpServer` + `listFrpReleases` | 已有 | ③ frps 控制 | 版本下拉 + 按钮(列表空时禁用) |
| U | 探测环境 | `probeFrpAgent` | 已有 | ④ Agent 生命周期 | 按钮 |
| V | 安装 / 升级 Agent | `installFrpAgent` | 已有 | ④ Agent 生命周期 | 按钮 |
| W | 卸载(保留数据 / 含数据) | `uninstallFrpAgent(keepData)` | 已有(危险无确认) | ④ Agent 生命周期 | 两按钮,含数据**带二次确认** |
| X | SSH 诊断 | `sshDiagnoseFrpAgent` | 已有 | ④ Agent 生命周期 | 按钮 |
| Y | frps 最近日志 | `frps_log_tail` | 已有(独立卡) | ⑤ 输出与日志 Tab | 日志块 + 空态 |
| Z | 操作输出(7 类) | 各接口返回 | 已有(单一 `agentOutput`) | ⑤ 输出与日志 Tab | 时间线:来源 + 时间 + 成败 |
| AA | 公网反代管理入口 | `getFrpAgentRouteCandidates` / `RouteHealth` | 有(整卡只放跳转) | 入口行 + ① 反代路由卡 | 「N 条规则 · 健康 X/Y」+ 跳转 `/public-proxy` |
### 本次必须补齐的 5 处(数据已返回,页面没显示)
| 项 | 数据字段 | 为什么重要 |
| --- | --- | --- |
| E | `agent_configured` | 决定"能不能连上 agent",是排障第一问 |
| M(8080) | `ports["8080"]` | frps vhost 端口是否在听,直接对应反代回源 |
| N | `routes[]` | 一眼看出 VPS 上已注册几条反代 |
| O | `checked_at` | 判断状态是不是陈旧的(页面 10 秒轮询,用户需要知道数据新鲜度) |
| P | `nginx_*` 七字段 | 反代的前置依赖;本页不显示就只能跳到另一页才能发现"没装 Nginx" |
---
## 三、设计方向与令牌(Pass 1b)
**方向:机架面板(controlled density)**——像看一排机架指示灯:状态永远第一眼可见,操作收进分区按钮带,输出以时间线呈现;克制、无装饰、密度优先。
- **Layout**:状态区(4 列稳定网格)→ 环境与依赖(两列)→ 控制/生命周期(两列)→ 输出与日志(整宽);左对齐,无居中大标题、无渐变、无装饰插图。
- **Signature**:**四色状态点行 + 操作输出时间线**。
**Color(5 个语义令牌,全部复用现有)**
| 语义 | 令牌 | 用于 | 判定 |
| --- | --- | --- | --- |
| 正常 | `--havline-brand`(`-soft`/`-text`) | 在线、运行中、监听中、成功输出 | 无需干预 |
| 待处理 | `--havline-warning` | frps 未运行、端口未监听、frps 未安装、开机自启未启用 | 可一键恢复 |
| 需人工干预 | `--havline-error` | Agent 未连接、Nginx 缺失、操作失败、卸载确认 | 需排查/不可逆 |
| 中性 | `--havline-text-secondary` / `--havline-text-muted` | 字段名、说明、时间、空态 | — |
| 引导 | `--havline-info`(`-soft`) | 页面级引导(反代入口等) | 非故障 |
规则:一屏 error ≤ 1 处;warning 只用于"有目标状态但未达成",不用于"没数据"。亮/暗由 `variables.css` 覆盖,**页面零硬编码色值**。
**Type(沿用项目字体,显式声明)**
- 正文 `--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;路径/命令/版本/日志走 `--havline-mono` 12.5–13px。
**Spacing & Radius**:只用 `--havline-space-1…6` 与 `--havline-radius(-sm)`、`--havline-shadow`;禁止魔数;内联尺寸一律改类(`.agent-select` / `.frps-version-select`)。
---
## 四、页面分区与线框(按功能分区)
```text
┌ PageHeader 公网 Agent · 检查于 12 秒前 [服务端 ▾] [刷新] ┐
├ n-alert 状态提示(互斥一条:error 未连接 / warning frps 未运行)
├ 入口行 公网反代:3 条规则 · 健康 2/3 → /public-proxy
├ ① 运行状态 section[aria-label=运行状态] 4 列稳定网格
│ ● Agent 连接 ● frps 服务 ● 反代路由 ● 端口监听
│ 在线/未连接 运行中/未运行 3 条 80✓ 443✓
│ <agent 地址> <frps 版本> 健康 2/3 已生效 7000✓ 8080✗
├ ② 环境与依赖(Havline Agent | Nginx | frps 运行时,三列并排)
│ Havline Agent Nginx(本页新增) frps 运行时
│ 版本 / 地址 可用性徽标 / 版本 二进制 / 版本
│ 凭据:已配置 ✓ 可执行路径 / 配置目录 配置路径
│ [探测环境] 错误原文(红) 启动命令 / 开机自启
│ → 去「公网反代」处理
├ ③+④ 两列并排(左:Agent 生命周期(SSH) / 右:frps 控制)
│ 左 [探测环境] [安装/升级 Agent] [SSH 诊断] 右 [启动|停止] [重启] [管理面板]
│ [卸载(保留数据)] [卸载(含数据) ← 二次确认] [下发配置] [版本 ▾] [安装/升级]
└ ⑤ 输出与日志 n-tabs:操作时间线(来源+时间+成败,aria-live) | frps 日志
空态 EmptyState · [清空]
```
分区原则:
1. **每项能力都有固定位置**(对照第二节清单);按钮常显,不可用时**禁用 + 说明原因**,绝不隐藏;
2. 状态区只读 / 控制区只写 / 输出区只展示,三段物理分离;
3. 新增的「环境与依赖」把三件前置条件(Agent 凭据、Nginx、frps 运行时)集中可见——排障时最先要看的就是这三项。
---
## 五、组件与状态矩阵(Pass 2 规格)
| 元素 | default | hover | pressed | focused | disabled | loading |
| --- | --- | --- | --- | --- | --- | --- |
| `n-button`(主/次/危险) | 库默认 | 库默认 | 库默认 | 库默认焦点环(键盘实测) | `busy !== null && busy !== 本动作` | `busy === '动作名'` |
| 服务端选择器 | 库默认 | 库默认 | — | 库默认 | 无服务端时禁用 | — |
| `n-tabs`(输出/日志) | 库默认 | 库默认 | 库默认 | 库默认(←/→ 可切换) | — | — |
| `n-popconfirm`(卸载含数据) | warning 触发器 | 库默认 | 库默认 | 库默认 | busy 时禁用 | 确认后走按钮 loading |
| `.stat-card`(只读) | 令牌底色 | **有意不加 hover** | — | 非交互不可聚焦 | — | 骨架 / `—` |
| 「管理面板」(未配管理接口) | **常显但禁用** | — | — | — | 是(tooltip 指向配置位置) | — |
| 「安装/升级 frps」(版本列表未加载) | **常显但禁用** | — | — | — | 是(提示「正在获取官方版本列表…」) | 列表加载中 |
**内容状态(区分"没数据"与"拿不到数据")**
| 场景 | 呈现 |
| --- | --- |
| 尚未拉取 | 数值位 `—`,副标题「读取中…」 |
| 首次拉取中 | `n-spin` 包裹状态区(仅首次,轮询不显示) |
| 拉取失败 | 数值位「未连接」+ 顶部 `error` alert(含「探测环境」动作) |
| 无已注册路由 / 无日志 | `EmptyState`(不再渲染「暂无日志」字符串) |
| 操作失败 | 时间线中该条 `ok=false` 标红 + `message.error` |
---
## 六、动效与无障碍
**动效**:首屏一次性 `fade-up`(`translateY(4px)→0`,160ms),仅 `onMounted` 触发一次;`prefers-reduced-motion: reduce` 全禁用;除此之外不做散点微动效(不加卡片 hover 位移/脉冲)。
**无障碍**:状态区 `<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 无横向溢出**。
---
## 七、状态与数据流(含前一轮复核发现的 3 处缺陷修复)
```ts
// 1) 切换服务端必须重置,避免串台
watch(selectedId, () => {
opOutput.value = []
agentStatus.value = null
statusLoaded.value = false
agentStatusOk.value = false
void refreshAll()
})
// 2) 三态区分:未加载 / 加载中 / 失败
const statusLoaded = ref(false) // 是否成功拉取过
const statusLoading = ref(false) // 首次加载中
// 3) 按动作粒度 loading,轮询静默
type Action = 'probe' | 'agent-install' | 'agent-uninstall' | 'diagnose'
| 'frps-start' | 'frps-stop' | 'frps-restart' | 'frps-install' | 'push-config'
const busy = ref<Action | null>(null)
async function run<T>(action: Action, fn: () => Promise<T>) {
if (busy.value) return // 安全网:按钮已 disabled
busy.value = action
try { return await fn() } finally { busy.value = null }
}
async function refreshAll(options: { silent?: boolean } = { silent: true }) { /* 轮询不触碰 busy */ }
// 4) 输出时间线:来源 + 时间 + 成败,上限 20 条
interface OpOutput { at: string; source: string; text: string; ok: boolean }
const opOutput = ref<OpOutput[]>([])
function pushOutput(source: string, text: string, ok = true) {
opOutput.value = [{ at: new Date().toISOString(), source, text, ok }, ...opOutput.value].slice(0, 20)
}
```
**提示规则**:状态提示互斥且最多一条(`error` 未连接 > `warning` frps 未运行);「公网反代入口」不占 alert 位,独立成入口行。
**轮询**:10 秒;`document.visibilityState === 'hidden'` 时跳过;`onBeforeUnmount` 清理定时器。
---
## 八、本页不做(Never)
- 不引入第二套 UI 框架 / 令牌 / 全局样式;不改全局字体与令牌;
- 不用硬编码色值与魔数间距;不做渐变、居中大标题、统一灰卡片等 slop 模式;
- 统计卡不加装饰性 hover / 位移 / 脉冲;
- 不把页面级操作塞进正文卡片(统一进 `PageHeader#actions`);
- **不隐藏功能**:任何能力要么可用,要么可见地禁用并给出原因;
- 不改后端与 agent(纯前端单文件)。
---
## 九、验收清单
- [ ] 复用项目真实栈与令牌(naive-ui + `--havline-*` + 共享组件),未新增第二套
- [ ] 单一设计方向(机架面板)落地,颜色/间距/圆角全走令牌,无 banned 字体引入
- [ ] 交互状态齐全:hover / pressed / **focused(键盘实测)** / disabled / loading / empty / error
- [ ] 无障碍:对比度 AA(亮+暗)、可见焦点环、键盘可完成全部操作、`aria-live` 播报、图标 `aria-hidden`
- [ ] 最窄 320px 无横向溢出;≤760px 按钮 ≥44px
- [ ] **功能可见性:逐条对照第二节 A–AA,每项都有明确位置;E / M(8080) / N / O / P 五项新增展示已落地;不存在隐藏功能**
- [ ] 回归:切换服务端不串台;仅被点按钮 loading;轮询不闪;危险操作有二次确认;`vue-tsc` 0 错误 + `vite build` 成功
---
## 十、与既有文档关系与工作量
| 文档 | 关系 |
| --- | --- |
| `docs/agent-view-design.md`(v1) | 被本文取代(v1 只是结构搬家,未解决"功能不可见");确认后可删除 |
| `docs/agent-view-refactor-plan.md` | 被本文取代 |
| 工作区 skill `havline-page-ui` | 页面 UI 通用标准;本文是它在「公网 Agent」页的具体化 |
工作量:**单文件改动(`web/src/views/AgentView.vue`)**,约 5–6 小时(含 5 处能力补齐、状态机重构、无障碍与响应式)。
验证:`npx vue-tsc --noEmit` + `npx vite build`,再同步 `web/dist → cmd/havline/web/dist` 后 `go build ./...`。
-216
View File
@@ -1,216 +0,0 @@
# 公网 Agent 页(`AgentView.vue`)设计方案
> **时效说明(2026-09-24)**:文中「顶部下拉选服务端」的结构已被 v1.2.6 的**左列表 + 右详情**取代(页面标题也改为「公网服务端」),现状见 `docs/frp-server-list-merge-plan.md`。
> 本文按 `breezell-design` 两遍流程产出:**Pass 1a 检测栈 → Pass 1b 确定设计方向与令牌 → Pass 2 实施规格**,
> 并结合本项目(Havline)既有页面与设计系统。落地时以本文为准,`docs/agent-view-refactor-plan.md`(结构整改)被本文吸收。
>
> 更新日期:2026-09-23
---
## 一、Pass 1a:栈与设计系统检测(结论先行)
| 检测项 | 实际值 |
| --- | --- |
| 框架 | 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`) |
| 图标 | `@vicons/ionicons5` |
| 令牌系统 | `web/src/styles/variables.css`:`:root` + `html.dark` / `[data-theme='dark']` 双主题,`--havline-*` 前缀 |
| 共享组件 | `PageHeader` `HavlineCard` `StatCard` `StatusBadge` `EmptyState` `LoadError`(另有 `MiniBarChart` 等图表件) |
| 工具 | `utils/format.ts`(`formatBytes/formatRate/formatDate/formatRelativeTime/…`)、`utils/status.ts`(`StatusKind` 语义) |
| 同族页面 | `DashboardView`(统计卡 + 插槽)、`DdnsView`(提示 + 状态卡)、`CertificatesView`(统计卡规格基准)、`ProxyView`(分组/弹窗)、`SettingsView`(分区) |
**结论(skill 的 "Already has a design system → MATCH it" 规则)**:本页**沿用** naive-ui + `--havline-*` 令牌 + 既有共享组件,
**不引入** Tailwind / shadcn-vue 等第二套体系,**不新增**第二套令牌或全局样式。
---
## 二、Pass 1b:设计方向
### 方向:机架面板(controlled density)
一句话概念:**像看一排机架指示灯** —— 状态永远是第一眼可见的、紧凑排列的四色状态条,操作被收进分区按钮带,输出以时间线呈现。克制、无装饰,密度优先。
- **Layout**:`状态区(4 列稳定网格) → 状态详情(2 列) → 控制/生命周期(2 列) → 输出与日志(整宽)`;左对齐、无居中大标题、无渐变、无装饰插图。
- **Signature(记忆点)**:**四色状态点行 + 操作输出时间线**(每条输出带来源徽标与相对时间)。
### Color(5 个语义令牌,全部复用现有)
| 语义 | 令牌 | 用在哪 | 判定规则 |
| --- | --- | --- | --- |
| 正常 / 运行 | `--havline-brand`(`--havline-brand-soft`、`--havline-brand-text`) | 在线、运行中、监听中、成功输出 | 无需人工干预 |
| 待处理 | `--havline-warning` | frps 未运行、端口未监听、frps 未安装、开机自启未启用 | 可一键恢复(引导操作) |
| 需人工干预 | `--havline-error` | Agent 未连接、操作失败输出、卸载确认 | 需用户排查或不可逆 |
| 中性信息 | `--havline-text-secondary` / `--havline-text-muted` | 字段名、说明、时间戳、空态 | — |
| 引导 | `--havline-info`(`--havline-info-soft`) | 页面级引导提示(反代入口等) | 非故障类提示 |
规则:**一屏内 error 不超过一处**;warning 不用于"没数据",只用于"有目标状态但未达成"。亮/暗两套值由 `variables.css` 的 `html.dark` 覆盖,**页面内零硬编码色值**。
### Type(沿用项目字体,显式声明)
- 正文字体 `--havline-font`(`-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei'`),等宽 `--havline-mono`。
- **关于 skill 的"禁用 system-ui 系默认栈"**:本项目已存在设计系统,按 "MATCH it, never add a second" 规则,**本页继续使用项目字体**;替换字体属**全局决策**(改 `variables.css`,影响全站并需全站回归),不在本页范围。
- 页内只用 4 档字号 + 3 档字重(与 `CertificatesView` 统计卡规格一致):
| 用途 | 字号 / 字重 | 令牌 |
| --- | --- | --- |
| 统计卡数值(词条类) | 18px / 700 | `.stat-card__value--sm` |
| 统计卡数值(数字类) | 30px / 700,`letter-spacing:-0.03em` | `.stat-card__value` |
| 字段名 / 卡片标签 | 12–13px / 600 | `.stat-card__label`、`.field-grid label` |
| 说明 / 时间戳 / 空态 | 12–12.5px / 400 | `--havline-text-muted`、`.form-hint` |
| 路径 / 命令 / 版本 / 日志 | `--havline-mono` 12.5–13px | `.mono` / `.mono-wrap` / `.code-block` |
### Spacing & Radius
- 间距只用 `--havline-space-1…6`(4/8/12/16/24/32),**禁止魔数**;卡片内边距统一 `var(--havline-space-4) var(--havline-space-5)`。
- 圆角只用 `--havline-radius`(12px) / `--havline-radius-sm`(8px);阴影只用 `--havline-shadow`。
- 页面里已有的 `style="width: 220px"` 这类内联尺寸,一律改为类(`.agent-select` / `.frps-version-select`)。
---
## 三、信息架构与线框
```text
┌ PageHeader 公网 Agent · 一句话说明 [服务端选择 ▾] [刷新] ┐
├ n-alert(最多一条状态提示:error 未连接 / warning frps 未运行)
├ 入口引导行(次级链接,不占 alert 位):反代的部署与访问控制在「公网反代」→
├ 状态区 section[aria-label=服务状态] 4 列稳定网格
│ ● Agent 连接 ● frps 服务 ● 80 端口 ● 443 端口
│ 在线/未连接 运行中/未运行 监听中/未监听 监听中/未监听
│ <agent 地址> <frps 版本> HTTP 入口 HTTPS 入口
├ 两列网格
│ HavlineCard 服务状态详情 HavlineCard frps 服务控制
│ field-grid 2 列: 按钮带:启动|停止 · 重启 ·
│ Agent 版本 / frps 版本 frps 管理面板 · 下发配置
│ 启动命令 / 配置文件 [版本选择 ▾] 安装/升级
│ 安装位置 / 开机自启
│ 7000 端口
│ HavlineCard Agent 生命周期(SSH) HavlineCard 输出与日志
│ 探测环境 · 安装/升级 Agent n-tabs:操作输出 | frps 日志
│ SSH 诊断 · 卸载(保留) 操作输出 = 时间线(来源+时间+ok)
│ 卸载(含数据) ← 二次确认 空态 EmptyState · [清空]
└ ────────────────────────────────────────────────────────────────
```
要点:**状态只读 / 操作只写 / 输出只展示** 三段物理分离;「公网 Nginx 反代」原整卡删除,改为入口引导行。
---
## 四、组件与状态矩阵(Pass 2 规格)
### 交互状态(skill:每个可交互元素都要有全状态)
| 元素 | default | hover | pressed | focused | disabled | loading |
| --- | --- | --- | --- | --- | --- | --- |
| `n-button`(主/次/危险) | 库默认 | 库默认 | 库默认 | 库默认焦点环(回归实测 Tab 可达) | `:disabled="busy !== null && busy !== 本动作"` | `:loading="busy === '动作名'"` |
| 服务端选择器 `n-select` | 库默认 | 库默认 | — | 库默认 | 无服务端时禁用 | 无 |
| `n-tabs`(输出/日志) | 库默认 | 库默认 | 库默认 | 库默认(键盘 ←/→ 可切换) | 无 | 无 |
| `n-popconfirm`(卸载含数据) | 触发器为 warning 按钮 | 库默认 | 库默认 | 库默认 | busy 时禁用 | 确认后走按钮 loading |
| `.stat-card`(只读) | 令牌底色 | **有意不加 hover** | — | 不可聚焦(非交互元素) | — | 骨架/`—` |
### 内容状态(区分"没数据"与"拿不到数据")
| 场景 | 呈现 |
| --- | --- |
| 尚未拉取(`statusLoaded=false`) | 数值位显示 `—`,`__sub` 显示「读取中…」 |
| 拉取中(首次) | `n-spin` 包裹状态区(仅首次,轮询不显示) |
| 拉取失败 | 数值位「未连接」+ 顶部 `error` alert(含「探测环境」动作) |
| 列表/日志为空 | `EmptyState`(不再渲染「暂无日志」字符串) |
| 操作失败 | 输出时间线中该条 `ok=false` 标红 + `message.error` |
---
## 五、动效与无障碍
**动效(high-impact, restrained)**
- 首屏**一次性**入场:状态区与卡片 `fade-up`(`translateY(4px)→0`,160ms,`--havline-shadow` 不变),仅 `onMounted` 后触发一次;
- `@media (prefers-reduced-motion: reduce)` 下全部禁用;
- 除入场与按钮库内置反馈外,**不做**散点微动效(不加卡片 hover 位移、不加脉冲动画)。
**无障碍(Web 必做)**
- 语义:状态区用 `<section aria-label="服务状态">`;装饰性图标 `aria-hidden="true"`;按钮均有可读文本(不出现只有图标的裸按钮);
- 动态内容:操作输出容器 `aria-live="polite"`(异步追加需播报);`n-popconfirm` 已带键盘与焦点管理;
- 键盘:Tab 顺序 = 选择器 → 刷新 → 状态区(跳过只读)→ 各卡片按钮带 → tabs;`n-tabs` 支持方向键;
- 对比度:实测下列组合达 WCAG AA(正文 4.5:1、大字号 3:1)——`--havline-brand`/`--havline-warning`/`--havline-error`/`--havline-text-muted` 在 `--havline-surface` 上,**亮色与暗色各测一遍**;
- 触控:≤760px 时按钮 `min-height: 44px`(或升为 `size="medium"`),满足最小触控目标;
- 响应式:`1200px+` 4 列状态 / `900–1199px` 2 列 / `≤900px` 单列 + 两列网格并一列 / `≤760px` 按钮铺满 / **`320px` 无横向溢出**。
---
## 六、状态与数据流(含前一轮复核发现的 3 处缺陷修复)
```ts
// 1) 切换服务端必须重置,避免串台(缺陷修复)
watch(selectedId, () => {
opOutput.value = []
agentStatus.value = null
statusLoaded.value = false
agentStatusOk.value = false
void refreshAll()
})
// 2) 三态区分:未加载 / 加载中 / 失败(缺陷修复)
const statusLoaded = ref(false) // 是否已成功拉取过
const statusLoading = ref(false) // 首次加载中
// 3) 操作状态机:按动作粒度 loading,轮询静默(缺陷修复)
type Action = 'probe' | 'agent-install' | 'agent-uninstall' | 'diagnose'
| 'frps-start' | 'frps-stop' | 'frps-restart' | 'frps-install' | 'push-config'
const busy = ref<Action | null>(null)
async function run<T>(action: Action, fn: () => Promise<T>) {
if (busy.value) return // 安全网:按钮已 disabled,这里只是兜底
busy.value = action
try { return await fn() } finally { busy.value = null }
}
async function refreshAll(options: { silent?: boolean } = { silent: true }) { /* 轮询不触碰 busy */ }
// 4) 输出时间线:来源 + 时间 + 成功标记,上限 20 条
interface OpOutput { at: string; source: string; text: string; ok: boolean }
const opOutput = ref<OpOutput[]>([])
function pushOutput(source: string, text: string, ok = true) {
opOutput.value = [{ at: new Date().toISOString(), source, text, ok }, ...opOutput.value].slice(0, 20)
}
```
**提示规则(修掉原方案"互斥"与"常驻"的矛盾)**:状态提示**互斥且最多一条**(优先级 `error` 未连接 > `warning` frps 未运行);
「公网反代入口」**不占 alert 位**,改为 PageHeader 下方一行次级链接。
**轮询**:10 秒一次;`document.visibilityState === 'hidden'` 时跳过;`onBeforeUnmount` 清理定时器。
---
## 七、本页不做(Never)
- 不引入第二套 UI 框架 / 令牌 / 全局样式(naive-ui + `--havline-*` 已存在);
- 不改全局字体与令牌(属全站决策);
- 不用硬编码色值与魔数间距(一律令牌);
- 不做渐变、居中大标题、统一灰卡片这类 slop 模式;
- 统计卡不加装饰性 hover / 位移 / 脉冲;
- 不把页面级操作塞进正文卡片(统一进 `PageHeader#actions`);
- 不改后端与 agent(本次纯前端单文件)。
---
## 八、验收清单(对照 skill self-check)
- [ ] 复用了项目真实栈与令牌(naive-ui + `--havline-*` + 共享组件),未新增第二套
- [ ] 单一设计方向(机架面板)落地:状态条 + 输出时间线为 signature,无 banned 字体引入,颜色/间距/圆角全部走令牌
- [ ] 交互状态齐全:hover / pressed / **focused(键盘实测)** / disabled / loading / empty / error
- [ ] 无障碍:对比度 AA(亮+暗)、可见焦点环、键盘可完成全部操作、`aria-live` 播报输出、图标 `aria-hidden`
- [ ] 最窄 320px 无横向溢出;≤760px 按钮 ≥44px 触控目标
- [ ] 回归:切换服务端不串台;仅被点按钮 loading;轮询不闪;危险操作有二次确认;`vue-tsc` 0 错误 + `vite build` 成功
---
## 九、与既有文档关系与工作量
| 文档 | 关系 |
| --- | --- |
| `docs/agent-view-refactor-plan.md` | 结构整改清单(12 项问题 + 分步改造),**本文吸收其全部内容**并补上设计方向、状态矩阵、无障碍与动效规格;实施时以本文为准 |
| `web/.breezell/skills/havline-page-ui`(工作区 skill) | 页面 UI 通用标准;本文是它在「公网 Agent」页的具体化 |
工作量:**单文件改动(`web/src/views/AgentView.vue`)**,约 4–5 小时(比原结构整改多出的部分:设计方向落地、状态矩阵、无障碍与动效、320px 适配)。
验证:`npx vue-tsc --noEmit` + `npx vite build`,再同步 `web/dist → cmd/havline/web/dist` 后 `go build ./...` 使本地生效。
-200
View File
@@ -1,200 +0,0 @@
# 公网 Agent 页(`AgentView.vue`)整合整改方案
> 目标:把「公网 Agent」页从"功能堆叠"整理成**状态 → 控制 → 生命周期 → 输出**四段式结构,
> 信息层级清晰、操作归属明确、危险操作可控,并完全符合 `havline-page-ui` 页面 UI 标准。
>
> 参照页:`DdnsView.vue`(状态卡 + 提示)、`CertificatesView.vue`(统计卡规格)、`ProxyView.vue`(分组与按钮区)、`SettingsView.vue`(分区)。
> 更新日期:2026-09-23
---
## 一、现状问题清单
| # | 问题 | 具体表现 | 影响 |
| --- | --- | --- | --- |
| 1 | **状态与操作混在一起** | 4 张状态卡只覆盖「Agent 连接 / frps 服务 / 80 / 443」;而 Agent 版本、frps 启动命令、配置路径、开机自启、7000 端口散落在「frps 服务控制」卡的 `field-grid` 里,与启停按钮同处一卡 | 看状态要跨两张卡,操作区被字段挤占 |
| 2 | **两个 `agent-page-grid` 列宽漂移** | 均用 `repeat(auto-fit, minmax(360px, 1fr))`,窗口宽度变化时卡片宽度与列数跳动,与标准页 `repeat(4, minmax(0,1fr))` 的稳定网格不一致 | 视觉不整齐,窄屏断裂点不可预期 |
| 3 | **loading 粒度太粗** | 所有按钮都用 `:loading="agentActionId === server.id"`——任何操作进行中,页面**全部**按钮一起转圈 | 无法分辨在跑哪个操作,也无法阻止并发操作 |
| 4 | **输出全部塞进一个 `agentOutput`** | 探测 / 安装 / 卸载 / SSH 诊断 / frps 启停 / 下发配置 / 安装 frps 七类输出共用一个 ref,显示在「Agent 生命周期」卡底部 | 输出归属不明;执行「重启 frps」后输出出现在 SSH 卡里,语义错位 |
| 5 | **日志与输出分居两卡** | 「frps 最近日志」独立成卡,`agentOutput` 在另一张卡 | 排障时要在两处来回看 |
| 6 | **危险操作无二次确认** | 「卸载(含数据)」按钮点击即执行,`rm -rf /var/lib/havline-agent` 不可逆 | 误点即丢路由与证书 |
| 7 | **页面级提示缺失** | agent 未连接、frps 未安装等状态只在卡片里体现,没有 `n-alert` 引导(技能要求:页面级提示放 PageHeader 之后) | 新用户不知道下一步该去哪一页 |
| 8 | **跳转卡占位浪费** | 「公网 Nginx 反代」整卡只放一句说明 + 一个跳转按钮 | 占掉半行网格,信息密度低 |
| 9 | **轮询与用户操作耦合** | 10 秒 `refreshAll()` 里设置 `agentActionId`,与用户点击共用同一状态 | 轮询时按钮闪烁 loading |
| 10 | **默认版本硬编码** | `frpsInstallVersion.value?.trim() \|\| '0.71.0'`,与「官方版本列表」加载结果脱节 | 列表加载成功后默认值仍可能是写死的旧版本 |
| 11 | **空态用字符串** | 日志空时渲染 `'暂无日志'` 文本 | 与标准页的 `EmptyState` 不一致 |
| 12 | **窄屏断点不全** | 只有 `@media (max-width: 900px)`,缺 760px 档(技能第 7 节) | 手机宽度下卡片内按钮换行拥挤 |
---
## 二、目标结构
```text
PageHeader(公网 Agent)
└─ #actions:服务端选择器 · 刷新
n-alert(条件渲染:Agent 未连接 / frps 未安装 / Nginx 缺失 → 一句引导 + 跳转)
stats-row(统一 4 列稳定网格)
├─ Agent 连接(在线/未连接 + agent 地址)
├─ frps 服务(运行中/未运行 + 版本)
├─ 80 端口
└─ 443 端口
HavlineCard「服务状态详情」
└─ field-grid:Agent 版本 / 已安装 frps 版本 / frps 启动命令 / 配置文件 / 安装位置 / 开机自启 / 7000 端口(StatusBadge)
HavlineCard「frps 服务控制」
└─ 按钮区:启动|停止 · 重启 · frps 管理面板 · 下发配置 · [版本选择] 安装/升级
HavlineCard「Agent 生命周期(SSH)」
└─ 按钮区:探测环境 · 安装/升级 Agent · SSH 诊断 · 卸载(保留数据)· 卸载(含数据,二次确认)
HavlineCard「输出与日志」(n-tabs)
├─ Tab 操作输出:带来源标签 + 时间;空态 EmptyState;「清空」按钮
└─ Tab frps 日志:frps_log_tail;空态 EmptyState
```
要点:**状态只读 → 操作只写 → 输出只展示**,三段物理分离;「公网反代」跳转改为 `n-alert` 或 PageHeader 次级入口。
---
## 三、逐项改造清单
### 1. 状态详情独立成卡(解决 #1)
- 把「frps 服务控制」里的 `field-grid` 整体搬到新的「服务状态详情」卡;
- 状态类字段用 `StatusBadge`(开机自启、7000 端口、frps 版本),值类字段用 `.mono` / `.mono-wrap`;
- 「frps 服务控制」只留按钮区与安装版本选择。
### 2. 统一网格(解决 #2、#12)
```css
.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(--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: 760px) { .stats-row { grid-template-columns: 1fr; } .agent-actions { width: 100%; } .agent-actions .n-button { flex: 1 1 auto; } }
```
### 3. 操作状态精细化(解决 #3、#9)
```ts
// 当前进行中的动作:'status' | 'probe' | 'agent-install' | 'agent-uninstall' | 'diagnose'
// | 'frps-start' | 'frps-stop' | 'frps-restart' | 'frps-install' | 'push-config'
const busy = ref<string | null>(null)
const statusLoading = ref(false)
async function run<T>(action: string, fn: () => Promise<T>): Promise<T | undefined> {
if (busy.value) return // 串行化,避免并发操作
busy.value = action
try { return await fn() } finally { busy.value = null }
}
// 轮询走静默:不设 busy,只设 statusLoading
async function refreshAll(options: { silent?: boolean } = { silent: true }) { /* ... */ }
```
按钮写法:`:loading="busy === 'frps-restart'"`、`:disabled="busy !== null && busy !== 'frps-restart'"`。
### 4. 输出与日志合并 + 来源标注(解决 #4、#5、#11)
```ts
interface OpOutput { at: string; source: string; text: string; ok: boolean }
const opOutput = ref<OpOutput[]>([]) // 最多保留 20 条
function pushOutput(source: string, text: string, ok = true) {
opOutput.value = [{ at: new Date().toISOString(), source, text, ok }, ...opOutput.value].slice(0, 20)
}
```
- 每类操作写入自己的 `source`(如「重启 frps」「SSH 诊断」「安装 Agent」),失败用 `ok=false` 标红;
- 展示:`n-tabs`(操作输出 / frps 日志),操作输出项显示 `formatDate(at)` 或 `formatRelativeTime(at)` + 来源徽标 + `.code-block`;
- 空态用 `EmptyState`,不再渲染「暂无日志」字符串;
- 提供「清空」按钮(`opOutput.value = []`)。
### 5. 危险操作二次确认(解决 #6)
```vue
<n-popconfirm @positive-click="uninstallAgent(server, false)">
<template #trigger><n-button size="small" type="warning">卸载(含数据)</n-button></template>
将删除 VPS 上的 agent、frps 单元与 /var/lib/havline-agent(路由与证书一并丢失),确认继续?
</n-popconfirm>
```
「卸载(保留路由/证书)」保持一步执行(`--keep-data`),仅加 `quaternary` 视觉区分。
### 6. 页面级提示(解决 #7、#8)
在 `PageHeader` 之后按条件渲染 `n-alert`(互斥,取最高优先级一条):
| 条件 | 类型 | 文案要点 | 动作 |
| --- | --- | --- | --- |
| `!agentStatusOk` | `error` | Agent 未连接:检查 VPS 上 havline-agent 是否运行、Agent 地址与 Token 是否正确 | 「探测环境」 |
| `!agentStatus?.frps_active` | `warning` | frps 未运行:可点「启动 frps」或在下方安装 | 「启动 frps」 |
| 反代入口引导(常驻) | `info` | 反代的逐条部署/配置查看/访问控制在「公网反代」页 | 跳转 `/public-proxy` |
原「公网 Nginx 反代」整卡随之删除。
### 7. 版本默认值取列表(解决 #10)
```ts
const frpsVersionOptions = computed(() => releases.value.map((r) => ({ label: r.version, value: r.version })))
watch(releases, (list) => { if (!frpsInstallVersion.value && list.length) frpsInstallVersion.value = list[0].version })
```
列表为空时禁用「安装/升级 frps」并提示「正在获取官方版本列表…」,不再回退硬编码版本。
### 8. 轮询策略
- 保持 10 秒刷新,但 `refreshAll({ silent: true })` 不触碰 `busy`;
- 页面不可见时暂停:`document.visibilityState === 'hidden'` 时跳过(减少无谓请求);
- `onBeforeUnmount` 清理定时器(现有行为保留)。
---
## 四、涉及文件与验证
| 文件 | 改动 |
| --- | --- |
| `web/src/views/AgentView.vue` | 模板结构重排、脚本状态重构、样式网格与断点补齐(单文件改动) |
| `web/src/api/client.ts` / `types.ts` | 预期**无需**改动(现有 `getFrpAgentStatus` / `probeFrpAgent` / `installFrpAgent` / `uninstallFrpAgent` / `sshDiagnoseFrpAgent` / `frpsServerActionRaw` / `pushFrpConfigToAgent` / `installFrpServer` / `listFrpReleases` 已够用) |
| `cmd/havline/web/dist` | 构建产物同步(gitignore,不入库) |
验证闭环(技能第 8 节):
```powershell
cd web
npx vue-tsc --noEmit # 0 错误
npx vite build # 成功
# 本地生效:同步 dist → cmd/havline/web/dist 后 go build ./...
```
回归检查点(真机,VPS 38.95.74.87):
1. 页面加载:状态卡 4 张、无 loading 闪烁(轮询静默);
2. 点「重启 frps」:**仅该按钮**转圈,其余按钮 disabled;输出出现在「操作输出」且来源标为「重启 frps」;
3. 点「SSH 诊断」:输出进入同一区域但来源不同,时间正确;
4. 点「卸载(含数据)」:出现二次确认;取消后不执行;
5. agent 未连接场景:顶部出现 `error` 级 `n-alert`,点「探测环境」有反馈;
6. 窄屏(≤760px):统计卡单列、按钮区铺满不溢出;
7. 暗色主题:所有颜色随令牌切换,无残留写死色值。
---
## 五、风险与回滚
| 风险 | 处理 |
| --- | --- |
| 结构调整导致既有操作失效 | 只重排模板与状态,**不改 api 调用**;逐项对照本方案第三节,改完按第四节回归 |
| 危险操作加确认后影响老用户肌肉记忆 | 仅「卸载(含数据)」加确认,其余保持一步 |
| 输出改为列表后信息过多 | 上限 20 条 + 清空按钮 |
| 回滚 | 单文件改动,`git checkout -- web/src/views/AgentView.vue` 即可回到当前版本(当前版本已按 skill 对齐,可安全回退) |
---
## 六、工作量
| 步骤 | 内容 | 预计 |
| --- | --- | --- |
| 1 | 状态详情卡拆分 + 网格统一 + 断点 | 0.5h |
| 2 | `busy` 状态机 + 按钮 loading 精细化 | 0.5h |
| 3 | 输出与日志合并(tabs + 来源 + 空态) | 1h |
| 4 | 页面级 `n-alert` + 删跳转卡 | 0.5h |
| 5 | 危险操作确认 + 版本默认值 + 轮询静默 | 0.5h |
| 6 | 验证(tsc/build/回归清单) | 0.5h |
合计约 **3–3.5 小时**,单文件改动,不涉及后端与 agent。
-429
View File
@@ -1,429 +0,0 @@
# Fonu 品牌升级 / 改名方案
> 日期:2026-09-25 对象:Fonu 1.0.2(VERSION 1.2.12)
> 方法:把 "Fonu / fonu / FONU_" 在仓库里**实际出现的位置逐个查出来**(下文带 `文件:行` 的都是读代码得到的),再按「改动代价 × 破坏性」分层给方案。
> 结论速览:**能做,而且没有"改不动"的地方;但"全面改名"绝不等价于全局替换——有 3 处一动就会伤到存量用户(数据库文件名、环境变量前缀、远端 agent 凭据路径)。推荐策略 B:分层渐进 + 兼容层,一个版本后清理兼容代码。**
---
## 〇、一句话回答
| 问题 | 回答 |
| --- | --- |
| 能不能换名? | 能。所有品牌落点都是可枚举的文本/路径/常量,没有第三方注册死锁(GitHub 仓库名可改、镜像名可切、CLI 名随二进制)。 |
| 能不能"全面"? | 能,但要分 5 层做。**展示层(用户看见的字)改起来是半天的事,存储层与契约层(数据文件名、环境变量、远端 agent 路径)必须带兼容层**,否则老用户升级后会出现「数据像丢了」「容器起来了但库是空的」「VPS 上的 agent 失联」。 |
| 改名量级 | 实测 `Fonu|fonu|FONU` 至少 1912 处、120+ 个文件(分页上限,实际更多);其中约 2/3 是 CSS 变量(`--fonu-*`)、Go import 前缀、文档正文这类**机械替换**,真正的决策点只有 5 个(见 §二)。 |
---
## 一、品牌落点盘点(按层,带实测证据)
### 第 1 层:展示层(用户能看见的字与图)
| 落点 | 位置 | 说明 |
| --- | --- | --- |
| 浏览器标题 | `web/index.html` `<title>Fonu</title>` | 建议与侧栏名统一 |
| 侧栏品牌区 | `web/src/layouts/AppLayout.vue:15,17,34` | logo `alt="Fonu"`、`brand__name` 文字、页脚品牌名;`:132` 引 `../assets/brand/logo.png` |
| 品牌标语 | `AppLayout.vue:18` 「让 NAS 访问更简单」 | 换名时建议一并重写(新名 + 新一句话定位) |
| 视觉资产 | `web/src/assets/brand/logo.png`、`web/public/favicon.png`、`web/assets/image/logo.png`、`web/assets/image/banner.png` | 图片需重做;`web/assets/image/*` 由 Dockerfile `COPY web/assets/image /app/web/assets/image` 进镜像 |
| 登录页 | `web/src/views/LoginView.vue` | 品牌露出最集中的页面之一 |
| 公共组件 | `web/src/components/{PageHeader,EmptyState,LoadError,StatusBadge,StatCard,FonuCard}.vue` | 前 5 个是文案/样式里的品牌残留,`FonuCard.vue` **文件名本身含品牌名**(含 import 该组件的多处 view) |
| 前端常量 | `web/src/constants/app.ts:1` `GITHUB_REPO = 'O96u/Fonu'` | 对外展示的仓库地址,改名必须同步(见 §六) |
| 通知消息 | `internal/notify/format.go`、`internal/notify/{service,store,push}.go` | 邮件/推送正文里的品牌前缀,用户直接在手机上看到 |
| 公开状态页 | `internal/api/status_page_handler.go`、`status_handler.go`(各 4~8 处) | `/status` 公开页标题、页脚品牌 |
| CSS 变量 | `web/src/styles/variables.css:2` 起 `--fonu-brand`、`--fonu-brand-soft`…(全仓 51 处) | **纯机械替换**,但波及所有 `.vue` 样式;建议一次性做且用 `replace_all` |
| 本地偏好 key | `web/src/composables/useTheme.ts:4` `themeKey = 'fonu-theme'` | 改名后老用户主题偏好重置(可一行兼容,见 §五.4) |
### 第 2 层:交付 / 部署层(用户拿到的东西叫什么)
| 落点 | 位置 | 风险 |
| --- | --- | --- |
| 主程序二进制名 | `Dockerfile`:构建产物 `/fonu` → `COPY --from=go-builder /fonu /app/fonu`、`CMD ["/app/fonu"]`;发布物 `fonu-1.1.0-linux-amd64` | 中:换名即换文件名,用户脚本里的路径要跟着改 |
| agent 交叉编译名 | `Dockerfile`:`go build -o internal/frp/agentbin/fonu-agent-linux-{amd64,arm64}` | **高**:与 `internal/frp/agentbin/embed.go` 的 `//go:embed fonu-agent-linux-amd64` 强耦合,改一处必须同批改另一处,否则编译期直接失败 |
| Docker 编排 | `docker-compose.yml`、`.host.yml`、`.hub.yml`:service `fonu`、`container_name: fonu`、`image: fonu:local`、volume `fonu-data` | **高**:volume 名一变,`docker compose up` 会建**新空 volume**,用户会认为「数据丢了」;必须写清迁移命令或显式 pin `name:` |
| 镜像仓库名 | `.github/workflows/docker-release.yml:63,94` `${DOCKERHUB_USERNAME}/fonu:<ver>-<arch>` | Docker Hub **不支持仓库改名**(只能新建),见 §六 |
| 前端 embed 路径 | 根 `.gitignore` 的 `cmd/fonu/web/dist/`、`.github/workflows/ci.yml:19,42,43`、`//go:embed all:web/dist` 所在 `cmd/fonu/main.go` | 中:目录改名会牵动 CI、构建脚本、embed 指令、Dockerfile |
| agent 安装布局 | `scripts/agent-install.sh`:`PREFIX=/usr/local/bin/fonu-agent`、`CONF_DIR=/etc/fonu-agent`、`UNIT=/etc/systemd/system/fonu-agent.service`、`/var/lib/fonu-agent/certs` | **高**:涉及已在用户 VPS 上运行的服务,升级路径必须兼容(见 §五.5) |
| 环境变量 | `internal/config/config.go:24,29,31~38`(`FONU_DATA_DIR`/`FONU_LISTEN`/`FONU_SESSION_SECRET`/`FONU_NGINX_*`/`FONU_FRPC_BIN`/`FONU_FRP_PID`)+ `Dockerfile` 的 `ENV` 块 + `docker-compose*.yml` + `docs/DEPLOYMENT.md`(大量) | **高**:前缀一改,老用户的 `.env` / compose / `-e` 全部静默失效 → 容器用默认值起来(例如数据目录跑回 `/data` 之外、会话密钥回到 `change-me-in-production`) |
### 第 3 层:代码标识层(改了不影响运行语义,但波及面最大)
| 落点 | 位置 | 说明 |
| --- | --- | --- |
| Go module path | `go.mod:1` `module github.com/fonu/fonu` | **每个 .go 文件的 import 块**(`internal/api` 20 个文件、`internal/nginx`、`internal/frp`…);同时 `Dockerfile` 的 `-ldflags "-X github.com/fonu/fonu/internal/version.Version=..."` 必须同步改,否则版本号注入静默失效(二进制显示 `dev`) |
| 前端包名 | `web/package.json:2` `"name": "fonu-web"` | 机械替换 |
| 内嵌 agent 资产名 | `internal/frp/agentbin/embed.go` | 见上,与 Dockerfile 成对出现 |
| 文档 | `README.md`、`CHANGELOG.md`、`docs/*.md`(20+ 篇,其中 `DEPLOYMENT.md` 56 处、`frps服务端管理方案.md` 52 处) | 量大但不危险;建议随代码同批改,避免文档与实现不一致 |
### 第 4 层:运行期契约层(跨进程/跨版本,最容易漏)
| 落点 | 位置 | 说明 |
| --- | --- | --- |
| 会话 Cookie 名 | `internal/api/middleware.go:18` `r.Cookie("fonu_session")` | 改名 = **所有已登录用户掉线**(可接受,但要在发布说明写明) |
| agent 凭据文件 | `internal/frp/agent_installer.go:35,36,168` + `:145`:服务端通过 SSH 在 VPS 上 `sed` 读 `/etc/fonu-agent/agent.env` 的 `FONU_AGENT_TOKEN` | **服务端与 agent 必须同批发布**:服务端换了路径/变量名,而用户 VPS 上还是旧 agent(或反之),「Agent 探测」会直接失败 |
| agent 自述标识 | `internal/agent/server.go:213,221` `{"ok":true,"service":"fonu-agent"}` | 属于对外可见的 API 响应体;若有任何前端/脚本按 `service` 字段判断,改名前要一起查 |
| agent 落盘目录 | `internal/agent/server.go:133,145`:`/var/lib/fonu-agent/certs`、`/var/lib/fonu-agent` | 与安装脚本一致 |
| 远端 systemd unit 描述 | `internal/agent/server.go:344` `Description=frps service (managed by fonu-agent)` | 远端残留可见文本 |
| 远端 nginx 探针/标记 | `internal/agent/server.go:674-675`:写 `fonu-probe.conf`、文件头 `# Managed by fonu-agent. Do not edit.`;`:692` 已有「清理历史残留 `fonu-*.conf`」逻辑 | **改名最容易踩的隐性坑**:老 VPS 上留着旧品牌标记的片段,新版本若只认新标记,会把它们判成「未知文件」→ 漂移检测误报或残留永不清理 |
| 远端 frp 安装与漂移 | `internal/frp/ssh_installer.go`、`internal/frp/route_drift.go`(各 4 / 2 处) | 同上:判定「这是我管理的文件」的依据里含品牌标记 |
### 第 5 层:数据层(最危险,改错就是"丢数据")
| 落点 | 位置 | 说明 |
| --- | --- | --- |
| 数据库文件名 | `internal/config/config.go:49-51` `DBPath() = <DataDir>/fonu.db` | **一改,程序找不到库 → 建一个空库 → 用户以为数据没了**。必须带「探测旧文件 + 原子重命名」 |
| 数据目录默认值 | `config.go:126` `defaultDataDir()`:`/data` 或 `<cwd>/.data` | ✅ **好消息:不含品牌名,无需迁移** |
| 各子目录 | `config.go` 的 `NginxDir/LogsDir/CertsDir/FrpDir`…(`nginx`/`logs`/`certs`/`frp`/`ssh_known_hosts`) | ✅ 也都不含品牌名,只有 DB 文件名带品牌 |
| 迁移脚本 | `migrations/023_frp_server_agent.sql`(1 处品牌) | SQL 迁移一般不需要改(库文件名才是关键路径) |
> 盘点的意义:**品牌名"长"在 5 个不同性质的地方**——字、路径、常量、协议、文件名。前两类可以随便改,后三类改之前要先想好兼容。
---
## 二、三个必须先解决的暗礁
1. **数据库文件名 `fonu.db`(`internal/config/config.go:50`)**
裸改 = 静默新建空库。做法:启动时若「新名库不存在」且「`fonu.db` 存在」→ 同目录 `os.Rename` 到新名(原子、可回滚),失败则**继续用旧路径并在日志/界面提示**,绝不新建第二份库。Windows 上文件被占用时 rename 会失败,所以「失败即回退到旧路径」这条兜底不是可选项。
2. **环境变量前缀 `FONU_*`(`internal/config/config.go:24,29,31-38`)**
裸改 = 老部署的 `FONU_SESSION_SECRET` 失效 → 会话密钥回到公开默认值 `change-me-in-production`(**这是安全问题,不只是体验问题**:拿到默认密钥可伪造管理员会话、解密库里凭据,见 `docs/DEPLOYMENT.md` 对该密钥的说明)。
做法:`envOr` 升级为「新前缀 → 旧前缀 → 默认值」三档读取,命中旧前缀时启动日志给一条明确的迁移提示(含新旧变量名对照),至少保留一个 stable 版本后再删旧前缀。**尤其 `FONU_SESSION_SECRET` 与 `FONU_DATA_DIR` 必须双读。**
3. **远端 agent 的凭据路径与品牌标记(`agent_installer.go:35,36,145,168`;`agent/server.go:674,692`)**
服务端是**通过 SSH 去读用户 VPS 上的文件**来判断 agent 状态的。服务端与 agent 不同批发布时:
- 探测读旧路径、agent 写新路径 → 「Agent 探测」永远显示未配置;
- nginx 探针/片段标记改名 → 旧文件既不被清理也不被识别,漂移检测出现假阳性。
做法:探测端**两个路径都试**(新路径优先,旧路径兜底);`agent` 二进制**同时接受新旧 env 变量名**;生成器把新旧品牌标记**都视为"自己生成的文件"**(清理/漂移判定同时匹配),并在 agent 版本号里体现本次变更(本仓既有约定:agent 行为变更需递增 `AgentVersion` 并重编内嵌双架构二进制)。
---
## 三、三种策略与推荐
| | 策略 A:只换皮 | **策略 B:分层渐进 + 兼容层(推荐)** | 策略 C:一次性硬切 |
| --- | --- | --- | --- |
| 范围 | 只做第 1 层(文案/图/CSS 变量/通知) | 第 1→2→4→5→3 层按阶段推进,关键处留兼容 | 5 层全改,不留兼容 |
| 代码净度 | 低(`fonu.db`、`fonu-agent`、`FONU_*` 仍是旧名) | 中:过渡期有双读分支,1~2 个版本后清理 | 高 |
| 存量用户 | 无感 | 无感(或仅掉一次登录态) | **有损**:需手工迁移数据目录/重建 volume/重装 agent |
| 适合 | 只想换个门面、用户量很小 | 已有真实用户、有 Docker 与 VPS 两种部署形态(本仓现状) | 尚未发布 / 可强制清退老版本 |
| 代价 | 半天量级,零风险 | 机械替换 + 5 个兼容点 + 一轮双形态回归 | 改动最少但迁移成本转嫁给用户 |
**推荐 B**,理由:本仓已有 Docker(`docker-compose*.yml`)、host 直装、VPS agent 三种落地面,且 `docs/DEPLOYMENT.md` 里明确把 `FONU_SESSION_SECRET` 当作安全关键项——这些决定了「裸改」的代价远高于「留兼容层」。
---
## 四、推荐执行路线(五阶段)
> 每阶段的验证沿用本仓既有约定:`go build ./... && go vet ./... && go test ./...` + `cd web && npm run typecheck && npm run build`;涉及 agent 的改动递增 `AgentVersion` 并重编内嵌二进制;用户明确要求提交时,才同批更新 `CHANGELOG.md` 与 `VERSION`。
### 阶段 0:定名与冻结(不写代码)
- 定下新品牌名与英文小写标识(CLI/包名/目录用)、显示名(首字母大写形态)。
- 检查占用:域名、GitHub org/repo、Docker Hub 命名空间、唯一包名(如 npm/PyPI 若以后要用)、以及**中文语境下的商标检索**。
- 决定 GitHub 与 Docker Hub 的走向(§六),因为 `web/src/constants/app.ts:1` 与版本检查 `web/src/utils/version.ts:37` 直接依赖它。
- 产出:`旧名 → 新名` 对照表(含:显示名、小写标识、二进制名、镜像名、容器名、env 前缀、cookie 名、DB 文件名、agent 目录名)。
### 阶段 1:展示层(零风险,先拿到"看着像新品牌"的反馈)
- 改:`web/index.html` 标题、`AppLayout.vue:15,17,18,34`、`LoginView.vue`、公共组件(含 `FonuCard.vue` 重命名)、`web/src/constants/app.ts:1`、`internal/notify/format.go`、`internal/api/status_page_handler.go`/`status_handler.go` 的品牌文案。
- 换图:`web/src/assets/brand/logo.png`、`web/public/favicon.png`、`web/assets/image/{logo,banner}.png`。
- CSS 变量 `--fonu-*` → `--<new>-*`(`variables.css` 定义 + 全部 `.vue` 引用,用 `replace_all` 一次完成)。
- 验证:`npm run build` 通过 + 前端手点一遍侧栏/登录页/设置页/公开状态页。
- 回滚:纯文案与样式,`git revert` 即可。
### 阶段 2:交付层(二进制、镜像、compose、发布物)
- 二进制 `fonu` → 新名;`docker-compose*.yml` 的 service/容器名/image/volume;`.github/workflows/docker-release.yml:63,94` 的镜像路径。
- **volume 必须处理**:在 compose 里显式声明 `volumes: { <new>-data: { name: <new>-data } }` 并在 `docs/DEPLOYMENT.md` 写「老用户迁移三行命令」(旧 volume 挂到新名,或 `docker volume create` + 复制)。
- 老二进制名建议保留一个版本(同镜像内留符号链接,或发布说明里给 rename 命令),避免用户脚本直接报错。
- 验证:本地 `docker compose up`,用**旧 volume** 起新容器,确认库被正确打开(这是本阶段的验收核心,不是"能起")。
- 回滚:compose 文件与镜像名回退即可;volume 命名要事先确定,避免中途再改一次。
### 阶段 3:存储层与契约层(暗礁集中区,必须带兼容)
- `config.go`:`DBPath()` 换新名 + **旧库自动接管**(§二.1);`envOr` 支持旧前缀兜底 + 启动迁移提示(§二.2)。
- `middleware.go:18` cookie 名:可选做「新名 cookie 优先、旧名兜底」读一次,或直接换名并在发布说明写「需重新登录」。
- `useTheme.ts:4`:读旧 key 回退 + 写回新 key(三行代码,避免用户主题偏好被重置)。
- agent 侧:`agent-install.sh`(`/usr/local/bin`、`/etc`、`/var/lib`、systemd unit 名)、`internal/agent/server.go:133,145,213,221,344,674,692`、`internal/frp/agent_installer.go:35,36,145,168`、`ssh_installer.go`、`route_drift.go`。
- 安装脚本要能**识别并接管已安装的旧 agent**(停旧 unit、装新 unit、保留/迁移 `/etc/fonu-agent` 的 env 文件),而不是留下两套服务互相抢。
- 探测与漂移判定:新旧路径/标记**都算自己人**,至少一个版本。
- 验证(关键):造一份**旧版数据目录**,用新二进制启动 → 确认打开的是旧库且数据完整;再用**旧版 agent** 对**新版服务端**跑一次「Agent 探测 / 安装 / 日志」;`go test ./...` 通过(新增的 DB 接管与 env 双读建议各补一个纯函数单测,参照本仓 `maskFRPSSecrets` 那类测试写法)。
- 回滚:DB rename 是单向的(必要时提供反向重命名说明);env 双读让回滚无感。
### 阶段 4:代码标识层(机械但波及全仓)
- `go.mod:1` module path → 新路径;全仓 import 替换(`gofmt`/`goimports` 后 `go build ./...` 兜底)。
- **同步点**:`Dockerfile` 的 `-ldflags "-X github.com/fonu/fonu/internal/version.Version=..."`(漏改则版本号变 `dev`,且不报错);`internal/frp/agentbin/embed.go` 与 Dockerfile 的 agent 输出名;`web/package.json:2`;`cmd/fonu/` → `cmd/<new>/`(连带 `.gitignore`、`ci.yml:19,42,43`、embed 指令)。
- 建议与阶段 2/3 分开发布(单独一次提交),便于「编译失败」时快速定位。
- 验证:`go build ./... && go vet ./... && go test ./...`;构建后确认 `GET /api/version`(或设置页)显示真实版本号而非 `dev`。
### 阶段 5:清理(1~2 个版本后)
- 删除旧 env 前缀兜底、旧 DB 文件名探测、旧 cookie/localStorage key、旧 agent 路径与标记、旧预先/镜像别名。
- 用 §九 的自查清单跑一次全仓 grep,确认只剩新名。
---
## 五、兼容层设计要点(可照做)
1. **环境变量**:`envOr` → `envOrLayered(newKey, oldKey, default)`;命中旧 key 时启动日志输出一次「检测到旧变量 `FONU_DATA_DIR`,已生效;请迁移到 `<NEW>_DATA_DIR`」。优先级:新 > 旧 > 默认,**不要**用「旧覆盖新」。
2. **数据库接管**:判断顺序 = 新库存在? → 用新库;否则旧库存在? → rename 后使用(失败则直接用旧路径 + 明确提示);否则建新库。全程不改动用户其它文件。
3. **Docker volume**:`name:` 显式固定新名;给出一条从旧 volume 复制的命令;**不要**靠 compose 自动改名。
4. **前端本地状态**:`localStorage.getItem('fonu-theme') ?? localStorage.getItem('<new>-theme')`,读到旧值时写回新 key。
5. **agent 平滑升级**:
- agent 二进制双读新旧 env 名(`FONU_AGENT_TOKEN` 与 `<NEW>_AGENT_TOKEN`);
- 安装脚本 `systemctl disable --now fonu-agent` → 装新 unit → 迁移 `/etc/fonu-agent/agent.env` → 启动新 unit;
- 服务端探测命令**两个路径并联**(`test -s A || test -s B`),避免版本错配期间误报;
- 远端文件识别同时匹配新旧标记;`server.go:692` 的「历史残留清理」逻辑扩成「新旧品牌前缀都清」。
6. **凭据与密钥**:品牌改名**不应触碰**加密数据本身(`secret.Box` 加密的 DDNS/FRP/SMTP 凭据与品牌无关),所以只要 `FONU_SESSION_SECRET` 双读做好,老用户的凭据继续可用——这也是「不要顺手改密钥派生」的理由。
---
## 六、平台与外部身份
| 项 | 现状 | 建议做法 |
| --- | --- | --- |
| GitHub 仓库 | `web/src/constants/app.ts:1` `O96u/Fonu`;`web/src/utils/version.ts:37` 用 `api.github.com/repos/${GITHUB_REPO}/releases/latest` 做版本检查 | GitHub **支持仓库改名并保留旧地址重定向(含 API 与 release 资产下载)**,但重定向会被「别人用旧名新建仓库」永久打断;对自用/小众项目风险低,仍建议:改名后**保留旧仓库名不被占用**(或在旧名新建一个空仓库做说明页——注意这恰好会打断重定向,二者只能选一)。改名后必须回归一次版本检查(`version.ts` 是前端直连 GitHub API)。 |
| Docker Hub | `.github/workflows/docker-release.yml:63,94` `${DOCKERHUB_USERNAME}/fonu` | Docker Hub **不支持仓库改名**,只能新建仓库并重推标签;老用户 `docker pull` 旧名会继续拿到旧标签(不会自动指向新镜像,也不会报错)→ 发布说明里给出新镜像地址 + 迁移命令。 |
| 域名 / 文档站 | 仓库内无硬编码站点域名 | 若已注册 `fonu.*` 域名,改名后建议保留旧域名 301 到新域名(同 GitHub 重定向的思路)。 |
| 文档 | 20+ 篇 md(`DEPLOYMENT.md` 56 处、`frps服务端管理方案.md` 52 处…) | 与代码同批改;`docs/brand-rename-plan.md`(本文)建议保留,作为改名决策记录。 |
| 发布物命名 | 根目录 `fonu-1.1.0-linux-amd64`、`fonu-1.1.0-src.zip`、`fonu-image-build4.log` 等 | 历史产物不必改名;新产物按新名。 |
---
## 七、新名候选与自查
**命名约束(硬性)**:
- 小写标识:仅 `[a-z0-9-]`,2~8 字符,无连字符歧义(`<new>-agent`、`<new>.db`、`--<new>-brand`、`<NEW>_DATA_DIR` 都要好看);
- 不与现有工具重名(`frp`、`nginx`、`caddy`、`tunnel` 等已占用词避免单独使用);
- 域名 / GitHub org / Docker Hub / npm 包名四处可注册;
- 中文可读(本项目用户群中文语境,"念出来不别扭"很重要);
- 商标自查(至少本国 + 主要市场)。
**候选清单**(`标识` 一列就是命令行、包名、`<标识>-agent`、`<标识>.db`、`<IDENT>_DATA_DIR` 要用的形态;每一项在落地前都要走 §七.3 的四处自查,下表「备注」只标注**已知**的同名存在,不代表未被占用)
#### A. 通路 / 门 / 桥(英文语义直白,技术用户第一眼就懂)
| 显示名 | 标识 | 中文名建议 | 寓意与适配 |
| --- | --- | --- | --- |
| Portway | `portway` | 通途 | port(端口)+ way(通路)——产品做的就是"把端口接出去",语义与技术完全对齐;`portway-agent`、`portway.db` 读感都好 |
| Viaduct | `viaduct` | 虹桥 | 高架桥:在两个互不相通的网络之间架一条通路;画面感强,适合做几何型 logo |
| Causeway | `causeway` | 长堤 | 跨水而过的路——跨过 NAT/"内网之海";8 字符是上限,仍可接受 |
| Underpass | `underpass` | 潜通 | 字面"从下面穿过去",暗合 NAT 打洞的本意;比 Underpass 更贴的同类还有 Overpass,但后者已被 OSM 的查询 API 广泛占用 |
| Thruway | `thruway` | 直通 | thru 带"穿透"的俚语味,拼写短促;注意美式拼写,正式英文里少见 |
| Ponto | `ponto` | 蓬托 | 葡萄牙语/世界语"桥",5 字符、短促好念;同名存在较多(银行、设计工具),必须自查 |
#### B. 渡 / 舟 / 引渡(中文意象最贴产品,拼音做标识)
| 显示名 | 标识 | 中文名 | 寓意与适配 |
| --- | --- | --- | --- |
| Dukou | `dukou` | 渡口 | **语义完全命中**:把内网的船渡到公网对岸;中文用户一看就懂、一说就记住;`dukou` 5 字符,命令行友好 |
| Zhandao | `zhandao` | 栈道 | 在无路可走的险处架出一条路——正是 NAT 之后的处境;名字冷门,检索不打架 |
| Yongdao | `yongdao` | 甬道 | 连接两端的地下通道;比"隧道"更雅,避开与 tunnel 这类通用词的碰撞 |
| Changdu | `changdu` | 长渡 | "长期摆渡"的意味,适合强调常驻服务;缺点是"长渡"作为词不常见 |
| Yinqiao | `yinqiao` | 引桥 | 从匝道引上主桥的那一段——很像"从内网引到出口";亲切、口语化 |
> 说明:中文意象类在中文用户群里记忆点最强,代价是国际展示偏窄;如果以后要面向海外,建议「英文标识 + 中文名」成对使用(例如 `dukou` / 渡口),而不是只取其一。
#### C. 延续 Fonu 语音(保留 Fo 音,让改名读起来像"升级"而不是"换了个软件")
| 显示名 | 标识 | 说明 |
| --- | --- | --- |
| Fonara | `fonara` | 保留 `Fo` 音 + 自造后缀,老用户不会觉得陌生;自造词在域名/商标上的可得性通常好于现成词 |
| Fonix | `fonix` | 与 Phoenix(凤凰)谐音,正好呼应"品牌涅槃升级";注意存在 Fönix 等同名公司,需自查 |
| Fonia | `fonia` | 希腊语系词根常与"声音/传输"相关(telefonia),5 字符干净;要确认不与电信类品牌混淆 |
| Fonli | `fonli` | 最短的延续方案,`fonli-agent` / `FONLI_DATA_DIR` 都短;缺点是语义联想弱,需要靠视觉与标语补 |
#### D. 自造短词(域名与商标可得性最好,语义靠后续塑造)
| 显示名 | 标识 | 中文名建议 | 备注 |
| --- | --- | --- | --- |
| Nuvio | `nuvio` | 努维 | 发音圆润、无强语义包袱,适合做纯品牌名 |
| Tando | `tando` | 坦途 | 与"坦途"天然谐音——自造词的语义可由中文名反向赋义,这个配对意外地贴 |
| Kaimu | `kaimu` | 开目 | 意为"把门打开",发音柔和 |
| Runa | `runa` | 鲁纳 | 短、好拼、国际化;注意与 Runa 相关的既有品牌 |
| Sairo | `sairo` | 赛罗 | 读音有速度感,适合强调链路性能 |
| Airlock | `airlock` | 气闸 | 不算自造词,但画面感极强:内网是舱内、公网是舱外,穿透就是"过气闸";独特、好画 logo,且不与 frp/nginx/tunnel 语义重叠 |
#### D 组补充:再给 10 个自造短词
**D-1 音节谐音型** —— 读音贴合一个中文词,中文用户不需要解释就记住,中文名与标识互相印证:
| 显示名 | 标识 | 中文名 | 说明 |
| --- | --- | --- | --- |
| Kaitu | `kaitu` | 开通 | 读音近似「开通」,含义直接落在"把内网服务开通到公网";5 字符,`kaitu-agent`、`KAITU_DATA_DIR` 都短 |
| Kailu | `kailu` | 开路 | 与「开路」完全同音——在 NAT 后面开出一条路,痛点隐喻最强;代价是内蒙古开鲁县同名,检索会混入地名 |
| Toulu | `toulu` | 透路 | 「透」(穿透)+「路」(通路),两个核心动作各取一字;`toulu` 生僻,检索最干净 |
| Chuantu | `chuantu` | 穿途 | 「穿」+「途」,气质偏文学;8 字符正好卡在硬上限,`chuantu-agent` 偏长,适合只在品牌层使用、标识再截短 |
**D-2 纯自造型** —— 无既有语义包袱,靠 logo 与标语赋义,域名与商标的可得性通常更好:
| 显示名 | 标识 | 中文名建议 | 说明 |
| --- | --- | --- | --- |
| Kuvo | `kuvo` | 库沃 | 4 字符、K/V 音干脆,读起来就像产品名;注意先锋 DJ 有同名社区平台 KUVO,必须自查 |
| Kelo | `kelo` | 松洛 | 芬兰语 kelo 指北极圈的站桩枯松(在恶劣环境里站住的意思),冷僻、科技圈少见 |
| Veko | `veko` | 维克 | 纯自造,音形短促,适合做"快"的联想 |
| Yomo | `yomo` | 悠陌 | 发音柔和,中文名可往"悠然可达"方向塑造 |
| Tovo | `tovo` | 途沃 | 首音节与「途」谐形,自带一点通路感,便于往中文语义靠 |
| Kivo | `kivo` | 启沃 | 首音节与「启」(开启)谐形,在图标上可以做"开门"的暗示 |
> 两条路线的取舍:**D-1 在中文本地传播最省力**(不用解释就懂),**D-2 在域名与商标上更干净**(生僻意味着少撞)。用户群以中文 NAS 玩家为主时 D-1 沟通成本最低;若之后要同时面向海外,D-2 + 一个中文名是更稳的组合。
#### E. 「本地 / 自家」语义(名字自带"服务跑在本地"的意思)
**E-1 拉丁语与概念型**("就地、内部、家宅"的直译,气质克制,适合技术向调性)
| 显示名 | 标识 | 中文名建议 | 说明 |
| --- | --- | --- | --- |
| Insitu | `insitu` | 就地 | 拉丁语 in situ = "在原位、就地"——服务不往外搬,就在它原本那台机器上运行。6 字符,冷僻,检索最干净 |
| Intra | `intra` | 内域 | 拉丁语"内部",直接指向内网/局域网;注意 Google Jigsaw 有同名的反审查 DNS 应用,需自查 |
| Domus | `domus` | 宅邸 | 拉丁语"家宅",数据与服务都收在自己的房子里;Domus 是欧美建筑媒体的常用名 |
| Localis | `localis` | 本地 | local 的拉丁语源,语义最直白;缺点是偏长(7 字符),且与英国一家同名智库撞车 |
**E-2 跨语言的"家"**(一个字就说清"自己的地方",且大多冷僻)
| 显示名 | 标识 | 中文名建议 | 说明 |
| --- | --- | --- | --- |
| Kodu | `kodu` | 科杜 | 爱沙尼亚语"家";4 字符、极冷僻,念起来干净利落 |
| Sumika | `sumika` | 住屋 | 日语「住処」(すみか)= 栖息之所;6 字符,画面温和,科技圈少见 |
| Jitaku | `jitaku` | 自宅 | 日语「自宅」,含义精确到"自己的房子",与 self-hosted 一一对应 |
| Uchi | `uchi` | 屋内 | 日语「内 / うち」同时是"内部"和"家";缺点是与美国连锁日料品牌 Uchi 同名 |
| Hearth | `hearth` | 炉边 | 英语"炉边",是"家"最温暖的意象;缺点是撞《炉石传说》的简称 Hearth |
**E-3 中文拼音直觉型**(中文用户零解释成本,代价是通用词难注册)
| 显示名 | 标识 | 中文名 | 说明 |
| --- | --- | --- | --- |
| Benji | `benji` | 本机 | 拼音「本机」完全对应,语义最准;缺点是与英文人名 Benji 撞车,检索会被淹没 |
| Bendi | `bendi` | 本地 | 最直白的一个;正因为通用,作为商品名与商标的注册难度最大 |
| Jiudi | `jiudi` | 就地 | 拼音「就地」,是 E-1 里 `insitu` 的中文同义形态,两者可以互为备选 |
| Wojia | `wojia` | 我家 | 亲切、有归属感,一句"服务就在我家跑着"把定位说完;注意与房产类品牌谐音 |
| Yuannei | `yuannei` | 院内 | "自家院子里那台机器"——NAS 用户共情很强;7 字符偏长 |
| Anwo | `anwo` | 安窝 | "给服务安个窝",读音短、意象活泼,适合偏年轻的产品调性 |
> 两点提醒:
> 1. **`homelab` / `self-hosted` / `on-prem` 这几个词恰好描述你的产品,但它们是行业通用术语,不能当商品名**——建议放进标语(例如「为 homelab 而生」),品牌名另取。
> 2. "本地"语义与定位最贴,也最容易撞现成词:`bendi`、`benji`、`localis` 这类直白形态被占的概率,明显高于 `insitu`、`kodu`、`sumika` 这类冷僻形态。走这条语义线时,优先在冷僻形态里挑。
**这条语义线上的首选三个**
1. **`insitu` / 就地** —— 语义精确(就地运行、不外迁)、拉丁出处让名字有质感、且检索干净,是"本地服务"这层意思里最不像拼凑的一个。
2. **`jitaku` / 自宅** —— self-hosted 的日文对应词,含义零歧义,短而冷门,写进 `<标识>-agent`、`<标识>.db` 也好看。
3. **`kodu` / 家** —— 4 字符、跨语言都能念,留白最多,便于日后把它讲成"你自己的服务空间"。
#### F. 「NAS 存储 + 穿透」双关(一个名字同时指向"存着"和"能到")
**F-1 英文双关型**(同一个词里同时有"盘/存储"和"通路")
| 显示名 | 标识 | 中文名建议 | 说明 |
| --- | --- | --- | --- |
| Driveway | `driveway` | 盘路 | drive(盘/硬盘)+ way(通路)——字面是"车库前的车道",放在存储语境里刚好是"盘往外走的路",双关最巧;缺点是常见英文词,域名与商标被占概率高,必须先查 |
| Baylink | `baylink` | 盘链 | bay(NAS 的"盘位")+ link(连接);7 字符、自造度高,检索干净 |
| Bayway | `bayway` | 盘途 | bay + way,5 字符,短而奇;英文里有同名小地名,软件领域少见 |
| Portage | `portage` | 搬运 | 指在两条不通的水道之间把船与货搬过去,与"把 NAS 里的东西搬到公网"完全同构;**但 Gentoo 的包管理器就叫 Portage,技术圈认知已被占满,不建议** |
**F-2 存储器物型**("存"义明确,通路那层交给标语)
| 显示名 | 标识 | 中文名建议 | 说明 |
| --- | --- | --- | --- |
| Tansu | `tansu` | 箪笥 | 日语「箪笥」=分层抽屉的日式储物柜;冷僻、有质感,画面也很适合做图标 |
| Coffer | `coffer` | 藏柜 | 英语"保险箱",5 字符,画面是"锁着的箱子";冷僻、少撞 |
| Attic | `attic` | 阁楼 | "东西放阁楼,要用时上去取"——正是家庭 NAS 的真实使用方式,亲切易懂 |
| Larder | `larder` | 储藏室 | 家里存食物的小间,冷僻且带生活气 |
| Granary | `granary` | 粮仓 | 囤粮之所,画面感最强;7 字符偏长 |
| Hako | `hako` | 箱 | 日语「箱」,4 字符、极简,适合走冷淡克制的产品调性 |
**F-3 中文拼音双关型**(仓/库 + 门/路/口)
| 显示名 | 标识 | 中文名 | 说明 |
| --- | --- | --- | --- |
| Canglu | `canglu` | 仓路 | cāng lù 完全对应:「仓」是 NAS 里存的东西、「路」是穿透出去的通路——一个名字两层意思,5 字符,`canglu-agent`、`canglu.db` 都顺 |
| Cangmen | `cangmen` | 仓门 | 「把自家仓门打开」,直指"在外面访问家里的东西"这个诉求;6 字符 |
| Chuku | `chuku` | 出库 | 「出库」=把仓库里的东西送出去,动作感最强,中文用户零解释成本;4 字符 |
| Kumen | `kumen` | 库门 | 与 cangmen 同义,更短、更有古意 |
| Cangdao | `cangdao` | 仓道 | 「仓中之道」,把存储与通路并列写出来;6 字符 |
| Kufang | `kufang` | 库房 | 最直白好懂;正因为通用,商标注册难度最大 |
**F-4 直白组合型**(NAS + 词:`nasway` / `nasport` / `nasbay`)
含义最准确、最不用解释,但都含通用缩写 `NAS`,商标几乎不可能注册,看起来也像内部工具名——**建议只用作副标题或仓库简介(例如「nasway — 为 NAS 而生的穿透面板」),不要当品牌名**。
#### 这条线上的首选三个
1. **`driveway` / 盘路** —— 双关最巧:drive 是硬盘、way 是通路,英文用户会心一笑,中文名「盘路」也说得通;代价是常见词,必须先核域名与商标。
2. **`canglu` / 仓路** —— 中文双关,拼音与标识完全对应,`canglu-agent`、`canglu.db`、`CANGLU_DATA_DIR` 都好念,且不与前几组任何候选重叠。
3. **`tansu` / 箪笥** —— 想要"存储器物"的气质又要冷僻好检索时的选择;分层抽屉的柜子本身就是个不错的图标母题。
> ⚠️ 一个诚实的提醒:让**一个名字同时说清"NAS 里存着"和"外面能到"**,很容易变成拼装词(nasway、cangdao 这类就在这个边缘)。如果只能保住一层意思,建议保"通路",把"NAS/存储"交给标语去说——例如「让家里的 NAS 走出内网」,名字本身留出生长空间。
#### 建议优先考虑的组合(按这个项目的实际处境排序)
1. **`portway` / 通途** —— 语义、标识、双语读感三项都最均衡;技术与中文用户都不需要解释。
2. **`dukou` / 渡口** —— 中文语境里最容易被记住的一个,产品定位讲一遍就懂;国际化时用英文标识兜住。
3. **`zhandao` / 栈道** —— 冷门、好检索、意象有情绪("在无路处开出一条路"),适合做社区故事。
4. **`fonara`** —— 想保住 Fo 音、把这次改动讲成"品牌升级"而非"另起炉灶"时的首选。
5. **`airlock` / 气闸** —— 想要强识别度的视觉符号(圆环阀门 + 状态灯,天然适合做状态图标)时的首选。
#### 已知撞名、不建议使用的名字
| 名字 | 为什么别用 |
| --- | --- |
| Pangolin / 穿山甲 | 是**同类开源项目本身**(`fosrl/pangolin`,见 `docs/feature-benchmark-2026-09-24.md` 的同类项目盘点),会被直接误认成它 |
| Harbor | CNCF 的镜像仓库项目,技术圈认知已被占满 |
| Warp / Warpgate / Skyway / Overpass / Lumen | 分别撞 Cloudflare WARP 与 Warp.dev、知名开源堡垒机、NTT 的 P2P 服务、OSM 查询 API、电信运营商与多个产品线 |
| Nexo / Zeta / Orbi | 本文初稿曾把它们当作"简洁短词"举例,实际分别撞加密借贷平台、多家同名公司、Netgear 的 Mesh 产品线;写进 `web/src/constants/app.ts` 之前先排除 |
| Portalis / Wayfare | 游戏/出行类同名产品较多,检索会被淹没 |
> ⚠️ 现名 `Fonu` 与罗曼语系词 `fono`(电话)高度接近,换名正好可以顺手绕开这类"念起来像另一个词"的问题——建议把「读起来/拼起来是否与既有词重合」列入上面的硬性约束。
**改名后自查清单(供阶段 5 使用)**:
```
# 全仓(排除 node_modules / dist / .data)
rg -in "fonu" --glob '!web/node_modules/**' --glob '!**/dist/**' --glob '!.data/**'
# 分类核查
rg -n "FONU_" # 环境变量:是否只剩新前缀
rg -n "fonu_session|fonu-theme" # 契约/本地状态
rg -n "fonu\\.db|/fonu-agent|fonu-probe" # 存储与远端残留
rg -n "O96u/Fonu|/fonu:" # 外部身份与镜像
rg -n "github.com/fonu/fonu" # module path(含 Dockerfile ldflags)
```
**正式定名前的四处占用自查(逐个到站内查,别指望搜索引擎)**
| 查什么 | 怎么查 | 判定标准 |
| --- | --- | --- |
| GitHub 组织 / 仓库 | `https://github.com/search?q=<标识>&type=repositories` 与组织页直连 | 组织名被占 → 无法建同名 org;同名仓库若是活跃的同类工具 → 直接换 |
| Docker Hub | 站内搜 `<标识>`,并确认 `<你的用户名>/<标识>` 可创建 | 镜像名一旦发布,改名只能新建仓库(见 §六) |
| 域名 | `.com` / `.net` / `.io` / `.cn` / `.dev` 的 whois | 核心后缀被竞品或商标方持有 → 减分;仅冷门后缀被占可接受 |
| 商标 | 中国商标网 + 目标市场(EUIPO / USPTO),查第 9 类(软件)、第 42 类(技术服务) | 存在同类同名的在先注册 → 直接排除,不改 |
> 本次实测:泛化的网页检索对"这个名字有没有被占用"几乎无效(只会返回无关内容),所以这四处必须站内查。另外把派生形态也念一遍——`<标识>-agent`、`<标识>.db`、`--<标识>-brand`、`<IDENT>_DATA_DIR`——它们是每天都要看见的东西。
---
## 八、风险与回滚
| 风险 | 触发条件 | 缓解 |
| --- | --- | --- |
| 老用户"数据丢失" | 只改 `DBPath()` 不做接管 | 阶段 3 的 rename + 失败回退;发布说明写明「首次启动会接管旧库」 |
| 会话密钥静默回落默认值 | 只改 env 前缀 | `FONU_SESSION_SECRET` 双读 + 启动告警;保留一个版本的旧前缀 |
| VPS agent 失联/漂移误报 | 服务端与 agent 版本错配 | 新旧路径/变量/标记双支持;`AgentVersion` 递增;安装脚本接管旧 unit |
| Docker 起来是空库 | volume 名变更 | compose 显式 `name:` + 迁移命令 + 用旧 volume 起新容器做验收 |
| 版本号显示 `dev` | 漏改 Dockerfile 的 `-ldflags` module path | 构建后必查版本接口;CI 加一条断言 |
| 客户端全部掉登录态 | cookie 改名 | 双读或接受一次重登(发布说明写明) |
| 前端版本检查失效 | GitHub 仓库改名后旧名被占/新常量未同步 | 改名后回归一次 `version.ts` 路径 |
**回滚策略**:阶段 1~3 的回滚以「保留旧名兼容分支」为主(一个 `git revert` 即可,因为兼容层是新增而非替换);数据库重命名是不可逆动作,但方向单一(旧→新),回退版本时提供一条反向重命名说明即可。
---
## 九、结论
- **可以做全面品牌升级,落点是有限且已全部找清的**(见 §一,含 5 层证据)。
- **不要全局替换**:`internal/config/config.go:50`(`fonu.db`)、`config.go:24,29,31-38`(`FONU_*`)、`internal/frp/agent_installer.go:35,36`(VPS 上 `/etc/fonu-agent/agent.env`)这三处是"改了会伤用户"的位置,必须先设计兼容再动手。
- **建议按 §四 的五阶段推进**,阶段 1 就能拿到"看着焕然一新"的结果,阶段 3 才是真正需要谨慎的工程部分;阶段 5 用 §七 的清单收尾。
- 本方案只做分析,未改动任何代码。
---
> 说明:本文基于 2026-09-25 的仓库状态(VERSION 1.2.12)实测盘点;外部平台(GitHub 重定向、Docker Hub 不支持改名)的结论来自当日检索,实施前建议再核实一次平台当前行为。
-654
View File
@@ -1,654 +0,0 @@
# Cloudflare 隧道当前功能说明
> 日期:2026-10-01
> 状态:v1.9.0 实测功能基线。本文描述**当前代码实际实现的能力**,不是早期规划稿。
> 相关文档:
> - `cloudflared-integration-plan.md`:早期融合方案
> - `cloudflared-feature-plan.md`:官方与参考项目功能全集,用于查漏
> - `cloudflared-havline-fit-plan.md`:Havline 适配版阶段计划
---
## 一、模块定位
Cloudflare 隧道在 Havline 中不是独立的 cloudflared 管理面板,而是公网访问的第二条出口:
- 本机 Nginx:适合有公网 IP 或已有端口映射的场景。
- FRP:适合需要 TCP/UDP、自控 VPS 或大流量的场景。
- Cloudflare 隧道:适合没有公网 IP、主要发布 HTTP/HTTPS Web 服务的场景。
当前模块已经融入 Havline 的规则、监控、通知、状态页、日志、备份、诊断和升级体系。
---
## 二、已实现的入口与页面
### 2.1 Cloudflare 隧道列表页
页面:`web/src/views/CloudflareView.vue`
已实现:
- 隧道列表显示:
- 隧道名称
- 状态
- 副本 / cloudflared HA 连接数
- cloudflared 版本
- 路由数量
- 指标端口
- 全局托管开关:
- 关闭:停止全部隧道。
- 开启:立即启动全部隧道。
- 应用重启:全局托管开启时自动恢复全部隧道。
- 新建隧道弹窗:
- 自动托管模式。
- 手工隧道模式。
- 手工隧道暴露规则:
- 子域名输入。
- 域从 Cloudflare Zone 列表选择。
- HTTP / HTTPS / TCP / SSH 服务类型。
- `://` 与 URL 分离显示。
- 实时显示完整主机名。
- 隧道操作:
- 详情。
- 配置页。
- 启动 / 停止 / 重启。
- 删除。
- 应用配置:
- Account ID。
- API Token。
- Token 验证。
- cloudflared 下载镜像。
- 默认网络参数。
### 2.2 隧道配置页
页面:`web/src/views/CloudflareTunnelConfigView.vue`
已实现:
- 独立路由页面,不再使用编辑弹窗:
- `/cloudflare/:id/config`
- 指标卡:
- 活动副本
- 路由数
- 状态
- 运行时间
- 基础配置:
- 页面只显示“编辑基础配置”按钮。
- 点击后弹窗编辑名称、子域名、Zone、服务类型、URL、自动启动。
- 路由列表:
- 顺序。
- 目标域名。
- 路径。
- 服务。
- 来源。
- 操作。
- 拓扑图:
- 域名节点。
- Cloudflare 出口节点。
- 隧道节点。
- 服务节点。
- 点阵画布和曲线连接。
- 路由操作:
- 添加路由。
- 编辑路由。
- 删除路由。
- 上移 / 下移。
- 复制路由。
- 测试源服务 TCP 连通性。
- 路由弹窗:
- 子域名 + Zone 下拉。
- HTTP / HTTPS / TCP / SSH + URL。
- 完整主机名实时预览。
- 全部捕获规则:
- 默认显示 `http_status:404`。
- 运行控制:
- 启动、停止、重启。
自动托管隧道的路由只读,来源显示为“反向代理规则”,并提示到“反向代理”页维护。
---
## 三、Cloudflare 账号与二进制
### 3.1 账号配置
实现文件:
- `internal/cloudflared/cloudflare_api.go`
- `internal/cloudflared/service.go`
已实现:
- 保存 Account ID + API Token。
- API Token 加密存储。
- Token 掩码回显。
- 调用账号级 `tokens/verify` 验证 Token。
- 查询当前 Token 可访问的 Zone。
- 根据域名向上查找所属 Zone。
- 创建、读取、删除 Cloudflare 隧道。
- 获取隧道 Token。
- 读取和覆盖远端 ingress。
- 创建、更新、删除 DNS CNAME。
- 只读检测 Cloudflare Access。
### 3.2 cloudflared 二进制
实现文件:
- `internal/cloudflared/binary.go`
已实现:
- 内置多种下载镜像。
- 下载并原子替换本机 cloudflared。
- 校验文件大小和可执行性。
- 显示当前版本。
- 查询 GitHub 最新版本。
- 下载指定版本。
- 保留上一版并支持回滚。
版本显示统一为语义版本,例如:
```text
2026.9.3
```
---
## 四、隧道生命周期
实现文件:
- `internal/cloudflared/service.go`
- `internal/cloudflared/manager.go`
- `internal/cloudflared/store.go`
已实现:
- 通过 Cloudflare API 创建命名隧道。
- 生成本地凭据:
- `creds.json`
- `config.yml`
- `cloudflared.log`
- 支持隧道模式模型:
- `account-local`
- `token-local`
- `token-remote`
- 进程托管:
- 启动。
- 停止。
- 重启。
- 崩溃后读取日志尾部便于诊断。
- 开机启动:
- 全局托管开启时,应用启动会恢复全部隧道。
- “随 Havline 自动启动”开关保留为隧道级选项。
- 全局总开关:
- 开启时启动全部隧道。
- 关闭时停止全部隧道。
- 凭据刷新:
- 重新获取隧道 Token。
- 更新本地 `creds.json`。
- 运行中时自动重启。
---
## 五、出口抽象与反向代理规则
实现文件:
- `internal/proxy/proxy.go`
- `internal/service/proxy.go`
- `internal/cloudflared/service.go`
迁移:
- `migrations/035_proxy_exits.sql`
已实现:
- 反向代理规则增加出口字段:
- `local`
- `cloudflare`
- 支持同时选择:
- 本机 Nginx + Cloudflare。
- Cloudflare 出口增加目标隧道:
- `cf_tunnel_id`
- 规则变化自动同步:
- 新增规则。
- 修改规则。
- 删除规则。
- 启停规则。
- 托管隧道 ingress 从规则派生:
- 每条域名使用自己的回源地址。
- 仅同步启用的 Cloudflare 出口规则。
- 仅 Cloudflare 出口的规则不生成无用的本机 Nginx server。
- 规则保存成功但 Cloudflare 同步失败时:
- 本地规则仍保存。
- 返回 `sync_warning`。
- 前端显示警告而不是把整次保存判为失败。
---
## 六、路由与 ingress
实现文件:
- `internal/cloudflared/routes.go`
- `internal/cloudflared/config.go`
已实现:
- 手工隧道路由来自本地 `config.yml`。
- 托管隧道路由来自反向代理规则。
- 路由列表支持:
- 添加。
- 编辑。
- 删除。
- 上移 / 下移。
- 复制。
- 源服务连通性测试。
- 保存路由时:
- 重新生成本地 `config.yml`。
- 覆盖 Cloudflare 云端 ingress。
- 同步 DNS。
- 运行中时重启 cloudflared。
- 末条 catch-all 固定为:
```text
http_status:404
```
- 通配路径处理:
- UI 显示 `*`。
- 发送到 Cloudflare 时省略 `path`。
- 避免 `*` 被当作非法正则。
- 支持正则 path 的后端字段,但当前页面主要使用通配路径。
### P0:接入与调和
- Cloudflare 页面新增“接入向导”。
- 接入预检检查:
- cloudflared 二进制。
- Account ID。
- API Token。
- Zone 权限。
- 隧道列表新增“调和”列:
- 已同步。
- 有漂移。
- 手工隧道。
- 有漂移时可一键修复:
- 以本地规则覆盖 Cloudflare 云端配置。
---
## 七、DNS 管理
实现文件:
- `internal/cloudflared/cloudflare_api.go`
- `internal/cloudflared/service.go`
- `internal/cloudflared/store.go`
迁移:
- `migrations/035_proxy_exits.sql`
已实现:
- 自动创建 CNAME:
```text
<hostname> -> <tunnel-id>.cfargotunnel.com
```
- 开启 Cloudflare 代理(橙云)。
- 记录 Havline 创建或接管的 DNS 记录:
- `cf_dns_records`
- 删除域名或移除出口时:
- 只删除 Havline 自己接管的记录。
- 不覆盖指向其他目标的用户 DNS 记录。
- 域名从一条隧道迁移到另一条隧道时支持接管迁移。
---
## 八、状态、指标与日志
实现文件:
- `internal/cloudflared/manager.go`
- `internal/cloudflared/health.go`
已实现:
- 从 cloudflared metrics 端口读取:
- HA 连接数。
- 延迟。
- 上下行速率。
- 传输协议。
- 隧道列表实时状态。
- 配置页指标卡。
- 日志中心新增“Cloudflare 隧道”页签:
- 选择隧道。
- 查看 200 / 500 / 2000 行日志。
- 关键词筛选。
---
## 九、健康巡检、通知与状态页
迁移:
- `migrations/036_cf_health.sql`
实现文件:
- `internal/cloudflared/health.go`
- `internal/monitor/monitor.go`
- `internal/app/monitor_source.go`
已实现:
- 记录 Cloudflare 域名健康状态变化:
- `up`
- `down`
- `unknown`
- 24 小时可用率。
- 不可用区间。
- 巡检复用现有:
- 路由健康开关。
- 隧道断开通知开关。
- 连续失败阈值。
- 连续不通和恢复复用通知事件 `tunnel_down`。
- 公开状态页合并:
- FRP 路由。
- Cloudflare 路由。
- 状态页展示每条路由的不可用区间:
- 开始时间。
- 持续分钟数。
- 原因。
- 状态页只显示域名、来源、状态和可用率,不泄露隧道 ID 与内网地址。
---
## 十、仪表盘
实现文件:
- `internal/api/status_handler.go`
- `web/src/views/DashboardView.vue`
已实现:
- `/api/status` 增加:
- Cloudflare 隧道总数。
- 在线数。
- 状态。
- 摘要消息。
- 仪表盘健康列表增加“Cloudflare 隧道”条目。
---
## 十一、备份与恢复
实现文件:
- `internal/backup/service.go`
已实现:
- 备份内容包含:
- `havline.db`
- `certs`
- `nginx`
- `cloudflared/tunnels`
- `cloudflared/tunnels` 包含:
- `config.yml`
- `creds.json`
- 数据库中的 `cf_tunnels`、`cf_settings`、`cf_dns_records` 随 `havline.db` 恢复。
- 恢复后仍建议重启 Havline,由启动流程恢复全部隧道。
---
## 十二、漂移检测与诊断
实现文件:
- `internal/cloudflared/operations.go`
已实现:
### 12.1 漂移检测
- 比较本地规则派生 ingress 与 Cloudflare 云端 ingress。
- 检出:
- 本地有、云端缺失。
- 云端有、本地已移除。
- 回源地址不一致。
- 支持“以本地覆盖”:
```text
POST /api/cloudflare/tunnels/{id}/sync
```
- 当前未实现“以云端为准拉取”。
### 12.2 一键诊断
检查项:
- cloudflared 二进制。
- 本地 `config.yml`。
- 隧道进程状态。
- Cloudflare API 与云端配置可访问性。
- Havline 接管的 DNS 记录。
- Cloudflare edge region1。
- Cloudflare edge region2。
---
## 十三、NAS 场景能力
实现文件:
- `internal/discovery/service.go`
- `web/src/views/ProxyView.vue`
已实现:
- 服务发现目录包含:
- 飞牛 fnOS
- AList
- Jellyfin
- PhotoPrism
- qBittorrent
- Portainer
- Home Assistant
- Immich
- AdGuard Home
- Synology DSM
- 反向代理页“扫描服务”打开发现列表。
- 点击“发布”会:
- 预填服务名称。
- 预填内网 upstream。
- 若已有托管隧道,自动选择 Cloudflare 出口。
- 打开规则编辑表单等待填写域名。
当前不是完全一键创建,仍需要用户填写域名并确认保存。
---
## 十四、安全与保护提示
已实现:
- Cloudflare Tunnel 条款提示:
- 不适合持续大文件/视频分发。
- 仅 Cloudflare 出口时:
- 提示无需申请本机 HTTPS 证书。
- Access 只读检测:
- 检测域名是否已有 Access 应用。
- 不代配 Access 策略。
- API Token 权限提示:
- `Account: Cloudflare Tunnel: Edit`
- `Zone: Zone: Read`
- `Zone: DNS: Edit`
- Zone Resources 必须包含待发布根域名。
---
## 十五、当前 API
### 隧道
```text
GET /api/cloudflare/tunnels
POST /api/cloudflare/tunnels
GET /api/cloudflare/tunnels/{id}
PUT /api/cloudflare/tunnels/{id}
DELETE /api/cloudflare/tunnels/{id}
POST /api/cloudflare/tunnels/{id}/start
POST /api/cloudflare/tunnels/{id}/stop
POST /api/cloudflare/tunnels/{id}/restart
POST /api/cloudflare/tunnels/{id}/credentials/refresh
GET /api/cloudflare/tunnels/{id}/status
GET /api/cloudflare/tunnels/{id}/logs
GET /api/cloudflare/tunnels/{id}/config
GET /api/cloudflare/tunnels/{id}/routes
PUT /api/cloudflare/tunnels/{id}/routes
POST /api/cloudflare/routes/test
GET /api/cloudflare/tunnels/{id}/drift
POST /api/cloudflare/tunnels/{id}/sync
GET /api/cloudflare/tunnels/{id}/diagnostics
```
### 二进制与配置
```text
GET /api/cloudflare/binary
POST /api/cloudflare/binary/download
GET /api/cloudflare/binary/latest
POST /api/cloudflare/binary/rollback
GET /api/cloudflare/settings
PUT /api/cloudflare/settings
POST /api/cloudflare/settings/test
POST /api/cloudflare/enabled
GET /api/cloudflare/preflight
```
### Zone、候选与 Access
```text
GET /api/cloudflare/ingress-candidates
GET /api/cloudflare/zones
GET /api/cloudflare/access-status
```
---
## 十六、数据表
### `cf_tunnels`
保存隧道基础信息:
- 名称。
- 模式。
- Tunnel ID。
- Account Tag。
- 加密后的凭据。
- 配置、凭据、日志路径。
- 网络设置。
- 运行状态。
- metrics 端口。
- 自动启动选项。
- 是否自动托管。
### `cf_settings`
保存应用级配置:
- Account ID。
- 加密后的默认 Token。
- 加密后的 API Token。
- 下载镜像。
- 默认网络设置。
- 全局托管开关。
### `cf_dns_records`
保存 Havline 接管的 DNS 记录:
- 本地隧道 ID。
- Zone ID。
- DNS Record ID。
- Hostname。
### `cf_health_events`
保存 Cloudflare 域名健康状态变化:
- 隧道 ID。
- 域名。
- 状态。
- 原因。
- 详情。
- 时间。
### `proxy_rules` 扩展
- `exits_json`
- `cf_tunnel_id`
---
## 十七、当前未完成或仅部分实现
以下能力尚未完成,不能按“已实现”宣传:
1. 完整审计日志
- 尚未记录“谁在何时修改了规则、出口、凭据和设置”。
2. 云端配置拉取
- 漂移检测支持“本地覆盖云端”。
- 尚不支持“云端覆盖本地”。
3. 完整无感令牌轮换
- 当前是“刷新/重新获取隧道凭据”。
- 尚未实现创建新隧道后无缝迁移全部规则。
4. Access 策略管理
- 只做只读检测。
- 不创建、不修改 Access 应用。
5. 完全一键服务发布
- 当前是扫描、预填、打开表单。
- 仍需要用户填写域名并确认保存。
6. 正则路径 UI
- 后端路由模型支持 `path`。
- 当前页面主要使用通配路径,尚未提供独立的 path 高级编辑控件。
7. 多 Cloudflare 账号
- 当前应用配置只支持一个 Account ID + API Token。
- `cert.pem` / 多账号模式尚未实现。
---
## 十八、验证基线
当前实现覆盖以下验证:
```text
go test ./... -count=1
go vet ./...
cd web && npm run typecheck
cd web && npm test -- --run
cd web && npm run build
```
新增或重点测试:
- 出口默认值、持久化与过滤。
- Cloudflare 按规则生成 ingress。
- Cloudflare 健康状态与可用率计算。
- 手工 config 路由解析。
- 通配 path 不发送非法正则。
- DNS 记录归属。
- 备份包含 cloudflared 隧道配置。
- 漂移比较。
-442
View File
@@ -1,442 +0,0 @@
# Cloudflare 隧道模块全功能规划
> 调研日期:2026-09-30
> 调研对象:Cloudflare 官方文档(developers.cloudflare.com,2026-08/09 更新)+ `F:\WWW\cf\Tunnel-Cloudflared-linux`(Tunnel-Cloudflared-Manager 1.0.1)+ Havline 1.8.0 现有实现
> 目标:把 Havline 的「Cloudflare 隧道」从「可用的创建与托管」推进到「覆盖官方主要能力、可长期运维」的完整模块。
---
## 一、官方文档调研结论
Cloudflare 把 Tunnel 分成两条产品线,Havline 目前主要落在第一条,规划时需要同时兼顾两条:
| 产品线 | 文档入口 | 典型用途 |
| --- | --- | --- |
| Zero Trust / 私有网络 | [cloudflare-one Tunnel](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/) | 私有网络、SSH/RDP、VPN 替代、Access 保护 |
| 公开发布应用 | [tunnel](https://developers.cloudflare.com/tunnel/) | 把公网域名指向自建服务,替代 VPS 与端口暴露 |
### 1.1 核心概念(官方)
- **Tunnel**:一条以 UUID 标识的持久对象,是源站与 Cloudflare 之间的逻辑链路。
- **Connector**:一个 `cloudflared` 进程;同一条隧道可以运行多个 connector。
- **Replica**:同一隧道的多个 `cloudflared` 实例,用于高可用与零停机更新。
- **Remotely-managed**:隧道由 Cloudflare 面板 / API 管理,规则在云端,`cloudflared` 用 token 启动。
- **Locally-managed**:规则在本机 `config.yml`,用 `credentials-file` 启动。
- **Quick tunnel**:`trycloudflare.com` 临时隧道,无需账号,官方明确仅用于测试与开发。
- **Virtual network**:私有网络中用于隔离不同网络的路由域。
### 1.2 官方关键能力清单
**隧道与连接器**
- 创建、删除、列出、按名称或 UUID 查询隧道
- 获取隧道 token;token 轮换
- 同一隧道部署多个 replica
- `cloudflared tunnel info` 查看连接器与连接状态
- 每个 connector 默认建立 4 条出站连接,分布在至少两个数据中心
- 支持 post-quantum 加密(`--post-quantum`)
**路由**
- Published application:`hostname`(可带 `path`)→ `service`
- 支持服务协议:`http`、`https`、`ssh`、`rdp`、`smb`、`tcp`、`unix`、`http_status`、`http_host`、TCP over WebSocket 等
- DNS:创建 CNAME 指向 `<tunnel-id>.cfargotunnel.com`,需开启代理
- 私有网络:CIDR route、WARP routing、virtual network
- Load Balancing:公开负载均衡与私有负载均衡
**配置**
- `config.yml` 顶层:`tunnel`、`credentials-file`、`ingress`、`warp-routing`、`originRequest`、`protocol`、`edge-ip-version`、`ha-connections`、`retries`、`grace-period`、`post-quantum`、`region`、`tag`、`logfile`、`loglevel`、`metrics`、`pidfile`、`token`、`token-file`
- `ingress` 必须末条 catch-all(如 `http_status:404`),按顺序匹配 hostname / path
- Origin 参数:TLS(`originServerName`、`matchSNItoHost`、`caPool`、`noTLSVerify`、`tlsTimeout`、`http2Origin`)、HTTP(`httpHostHeader`、`disableChunkedEncoding`)、连接(`connectTimeout`、`noHappyEyeballs`、`proxyType`、`proxyAddress`、`proxyPort`、`keepAliveTimeout`、`keepAliveConnections`、`tcpKeepAlive`)、Access(`access`)
- 校验工具:`cloudflared tunnel ingress validate`、`cloudflared tunnel ingress rule <url>`
**观测**
- Tunnel health:面板隧道健康、`cloudflared tunnel info`
- Cloudflare Notifications:隧道健康事件通知
- Logs:Cloudflare 侧日志、remote log streaming、network session logs、本机 `cloudflared` 日志
- Metrics:`/metrics`(Prometheus 格式)、`/ready`、`/healthcheck`
- Diagnostic logs:`cloudflared tunnel diag` 生成诊断文件
**运维与部署**
- Linux systemd、macOS launch agent、Windows service
- Docker、Kubernetes、Ansible、Terraform、AWS/Azure/GCP 指南
- 更新 cloudflared;多实例零停机更新
- 系统要求:出站 7844(TCP/UDP)与 443;ulimit;容量计算
- 文件权限要求:`cert.pem`、`<UUID>.json`、`config.yml`
**权限与安全**
- API Token 权限:`Account > Cloudflare Tunnel > Edit`、`Zone > DNS > Edit`(查询 Zone 还需 `Zone > Zone > Read`)
- Access、Service Auth、mTLS、Keyless SSL、Spectrum、Workers VPC、Hyperdrive 等集成
- Tunnel ownership、account-scoped roles、撤销账号证书
### 1.3 官方版本要点(2026-09)
- 运行参数文档最近更新 2026-09;`post-quantum`、`token-file`、`region`、`tag`、`dns-resolver-addrs`、`edge-bind-address` 等参数已进入官方参考。
- `edge-ip-version` 官方默认值曾由 `4` 调整为 `auto`;Havline 已显式下发 `4`,与国内链路建议一致。
- 官方把「公开发布应用」与「Zero Trust 私有网络」拆成两条文档线,Havline 需要明确自己的默认定位是公开发布应用。
---
## 二、参考项目功能清单(Tunnel-Cloudflared-Manager 1.0.1)
参考项目是一个独立面板,功能面比 Havline 当前实现更宽,但缺少与 Havline 的规则、健康、通知、状态页体系融合。
### 2.1 隧道管理
- 命名隧道:token 启动、config-file 启动
- 快速隧道:`--url` 启动、从日志解析 `*.trycloudflare.com` URL
- 启动 / 停止 / 停止全部 / 运行列表 / URL 查询
- 创建隧道(账号模式,`tunnel create`)
- 初始化 token 隧道:解码 token → 写 `creds.json` + `config.yml`
- 删除云端隧道
### 2.2 路由与云端同步
- 端点列表:读取本地 `config.yml` 的 ingress
- 新增 / 更新 / 删除端点(本地)
- 新增端点时自动调用 `tunnel route dns` 创建 DNS
- 从云端拉取 ingress 到本地
- 把本地 ingress 推送到云端
- ingress 三条不变式:catch-all、`localhost` 归一化、插入在 catch-all 之前
### 2.3 账号模式
- `cert.pem` 登录与 finalize
- 列出账号下的隧道
- 同步云端隧道
- 删除账号
### 2.4 二进制与镜像
- 检查 / 下载 / 替换 cloudflared
- 下载进度
- 镜像管理:列出、新增、更新、删除、测试
- 内置官方与多个 gh-proxy 镜像
### 2.5 指标与日志
- 每 2 秒抓取 `/metrics`
- QUIC:`quic_client_receive_bytes`、`quic_client_sent_bytes`、`quic_client_smoothed_rtt`
- HTTP/2:RPC latency、主动 HEAD 探测;带宽用进程 socket 统计(Windows 需要管理员)
- 连接数:`cloudflared_tunnel_ha_connections`
- 实时日志:WebSocket 推送
- 清空日志
### 2.6 网络与调优
- `protocol`(auto/http2/quic)、代理环境探测与回落
- `edge-ip-version`、`ha-connections`
- `originRequest`:`caPool`、`noTLSVerify`、`httpHostHeader`
- 网络诊断接口
- Windows 子进程隐藏控制台
### 2.7 运维
- 版本检查与应用自更新
- JWT + bcrypt 登录、失败锁定
- 多语言、主题
- Docker 与 Linux 安装脚本
---
## 三、Havline 当前实现(v1.8.0)
| 能力 | 状态 |
| --- | --- |
| Account ID + API Token 凭据 | ✅ 已实现 |
| API Token 验证(`/accounts/{id}/tokens/verify`) | ✅ 已实现 |
| 创建 / 删除云端隧道 | ✅ 已实现 |
| 获取隧道 token、写 `creds.json` / `config.yml` | ✅ 已实现 |
| 推送 ingress 到云端 | ✅ 已实现 |
| 自动创建 CNAME DNS | ✅ 已实现 |
| 二进制下载与版本检查 | ✅ 已实现(多镜像、大小与可执行校验) |
| 进程托管、启停、重启、日志文件 | ✅ 已实现 |
| metrics 端口发现、`/ready`、`ha_connections`、QUIC 带宽/RTT | ✅ 已实现(基础) |
| 全局托管开关 | ✅ 已实现 |
| auto_start | ✅ 已实现 |
| 前端页面与弹窗 | ✅ 已实现 |
| 云端隧道列表同步 / 导入已有隧道 | ❌ 未实现 |
| 端点级 CRUD(path / 协议 / 多服务) | ❌ 未实现 |
| 从云端拉取 ingress(pull) | ❌ 未实现 |
| 多副本 / 零停机更新 | ❌ 未实现 |
| 快速隧道 | ❌ 未实现 |
| 账号模式(cert.pem) | ❌ 未实现 |
| 实时日志流(WebSocket/SSE) | ❌ 未实现 |
| 诊断日志、网络预检、隧道 info | ❌ 未实现 |
| 接入 Havline monitor / 通知 / 状态页 | ❌ 未实现 |
| cloudflared 自动更新 | ❌ 未实现 |
| 自定义镜像管理、下载进度 | ❌ 未实现 |
| Origin 高级参数 UI | ❌ 未实现 |
| 令牌轮换、权限审计 | ❌ 未实现 |
| 私有网络 / WARP routing / Access / 负载均衡 | ❌ 未实现 |
| 边缘 IP 优选、post-quantum、region/tag | ❌ 未实现 |
---
## 四、差距与机会
### 4.1 必须补齐(否则模块不完整)
1. **云端隧道列表与导入**:目前 Havline 只能管理自己创建的隧道,已有隧道无法纳管。
2. **端点级 CRUD**:现在是整体覆盖 ingress,无法单独增删改一条 hostname/path。
3. **云端配置拉取与漂移检测**:云端被手动修改后,Havline 无法发现。
4. **健康接入 Havline 体系**:隧道状态没有进入 monitor、通知、状态页、可用率。
5. **实时日志**:轮询文件在故障排查时体验差。
6. **诊断能力**:缺 `tunnel info`、`tunnel diag`、网络预检、端口要求检查。
### 4.2 高价值增强
1. 快速隧道(临时演示、开发调试)
2. 多副本与零停机更新
3. cloudflared 自动更新与版本策略
4. 令牌轮换
5. Origin 高级参数(HTTPS 回源、自签证书、Host 头)
6. 自定义镜像与下载进度
7. 审计日志(谁在何时修改了隧道、端点、DNS)
### 4.3 远期能力
1. 账号模式(cert.pem)与多账号管理
2. 私有网络 / WARP routing / virtual network
3. Cloudflare Access / Service Auth 集成
4. 负载均衡集成
5. 边缘 IP 优选
6. Workers VPC / Hyperdrive / Spectrum 等生态集成
---
## 五、全量功能规划
### P0:模块完整性(先补齐)
**P0-1 云端隧道同步与导入**
- 目标:列出 Cloudflare 账号下的全部隧道,把未纳管的隧道导入 Havline。
- 实现:`GET /accounts/{id}/cfd_tunnel` 列表;导入时读取 token、写 `creds.json`/`config.yml`、落库。
- 验收:账号下已有隧道能在页面看到并导入;重复导入不产生重复记录。
**P0-2 端点级 CRUD 与 ingress 校验**
- 目标:不再整体覆盖 ingress,支持单条 hostname/path/service 的增删改。
- 实现:本地 `config.yml` 解析与增量修改;云端 `PUT configurations` 同步;`cloudflared tunnel ingress validate` 与 `ingress rule <url>` 自检。
- 验收:新增、修改、删除单个端点后本地与云端一致;非法规则被拒绝。
**P0-3 云端配置拉取与漂移检测**
- 目标:云端被手动修改后,Havline 能发现差异并选择拉取或覆盖。
- 实现:`GET /accounts/{id}/cfd_tunnel/{tunnel}/configurations`;与本地 config 做 diff;提供「拉取到本地」「推送到云端」。
- 验收:diff 可见;两个方向都能执行并留痕。
**P0-4 健康接入 Havline 体系**
- 目标:隧道断连、恢复进入现有 monitor、通知、状态页与可用率。
- 实现:新增 cloudflared 的 `monitor.Source`;`/ready` + `ha_connections` 判活;断连/恢复事件复用 notify;状态页增加隧道条目。
- 验收:停掉隧道后产生告警;恢复后产生恢复通知;状态页可见。
**P0-5 实时日志(SSE)**
- 目标:日志页实时滚动,替代轮询。
- 实现:Havline 现有日志 SSE 模式复用;cloudflared 日志文件 tail + 增量推送;保留轮询回退。
- 验收:页面实时看到新日志;切换页面/重连不丢关键行。
**P0-6 诊断能力**
- 目标:一键完成常见故障排查。
- 实现:`cloudflared tunnel info <name>`、`cloudflared tunnel diag`、`cloudflared tunnel ingress validate`、网络预检(region1/region2、7844、443)、端口与系统要求检查。
- 验收:诊断页给出结构化结果与建议,不只是原始输出。
### P1:运维与安全
**P1-1 快速隧道**
- 目标:临时把本地端口发布到 `trycloudflare.com`,用于演示与开发。
- 实现:`--url` 启动、日志解析公共 URL、进程随 Havline 生命周期、默认关闭。
- 边界:明确标注不保证 SLA、URL 每次变化。
**P1-2 多副本与零停机更新**
- 目标:同一隧道支持多个 connector,更新时不中断。
- 实现:replica 配置与进程管理;逐个重启;`tunnel info` 确认新连接注册后再停旧实例。
- 验收:更新过程中隧道可用率不下降。
**P1-3 cloudflared 自动更新与版本策略**
- 目标:可检查新版本、按策略升级、支持回滚。
- 实现:官方 Release 检查;保留旧版本目录;升级前校验;失败回滚。
- 验收:升级成功可回滚;版本信息可追溯。
**P1-4 令牌轮换**
- 目标:按官方 `tunnel token` 支持重新签发 token。
- 实现:`GET /accounts/{id}/cfd_tunnel/{tunnel}/token`;轮换后更新 `creds.json` 并重启连接器;双凭据过渡窗口。
- 验收:轮换过程不中断或只短暂闪断。
**P1-5 Origin 高级参数**
- 目标:支持 HTTPS 回源、自签证书、Host 头等官方 origin 参数。
- 实现:`originRequest` 的 `originServerName`、`matchSNItoHost`、`caPool`、`noTLSVerify`、`httpHostHeader`、`connectTimeout`、`keepAliveTimeout` 等。
- 验收:HTTPS 回源与自签证书场景可用;参数写入本地与云端配置。
**P1-6 审计日志**
- 目标:记录谁在何时修改了隧道、端点、DNS、令牌与全局开关。
- 实现:复用 Havline 日志/审计基础设施;操作前记录旧值、操作后记录新值。
- 验收:关键变更可查询、可导出。
### P2:体验与增强
**P2-1 自定义镜像与下载进度**
- 目标:镜像可增删改测,下载有进度。
- 实现:镜像表(URL 模板)+ 连通性测试;SSE 推送下载进度。
**P2-2 指标图表与历史**
- 目标:展示隧道延迟、带宽、连接数、可用率历史。
- 实现:复用 `metrics_samples`;前端图表;区分 QUIC/HTTP2 可用指标。
**P2-3 配置版本、diff 与回滚**
- 目标:隧道 `config.yml` 与云端 ingress 的版本化。
- 实现:复用 `fsutil.BackupVersioned`;变更前留版本;支持 diff 与回滚。
**P2-4 日志导出与清理**
- 目标:导出、清理单隧道日志;限制日志体积。
- 实现:日志文件轮转、下载、清空;保留期设置。
**P2-5 系统要求与容量检查**
- 目标:按官方要求检查 ulimit、出站 7844/443、系统资源。
- 实现:启动前检查与页面提示;不做自动修改系统参数。
**P2-6 网络调优扩展**
- 目标:暴露官方 run parameters。
- 实现:`post-quantum`、`region`、`tag`、`dns-resolver-addrs`、`edge-bind-address`、`retries`、`grace-period`、`loglevel`、`logfile`、`pidfile`。
### P3:平台化
**P3-1 账号模式(cert.pem)与多账号管理**
- 支持 `cloudflared tunnel login`、cert.pem 导入、多账号切换、账号下隧道同步。
**P3-2 私有网络(WARP routing / CIDR / virtual network)**
- 支持 `warp-routing: enabled: true`、CIDR route、virtual network、私有主机名路由。
**P3-3 Access / Service Auth**
- 只做诊断与提示,或在用户显式配置后生成 Access 应用;不代配高风险策略。
**P3-4 负载均衡集成**
- 公开/私有负载均衡池与隧道关联;只做只读展示与跳转。
**P3-5 边缘 IP 优选**
- 探测官方 IPv4 段的 TCP 7844 延迟,生成两个 region 的优选建议;页面展示,不代写 hosts。
**P3-6 生态集成**
- Workers VPC、Hyperdrive、Spectrum、Keyless SSL、Data Localization 等按需评估。
---
## 六、推荐实施顺序
| 阶段 | 内容 | 预计 | 完成标志 |
| --- | --- | --- | --- |
| 阶段 1 | P0-1 ~ P0-6 | 1-2 周 | 云端同步、端点 CRUD、漂移检测、健康接入、实时日志、诊断全部可用 |
| 阶段 2 | P1-1 ~ P1-6 | 2-3 周 | 快速隧道、多副本、自动更新、令牌轮换、Origin 高级参数、审计 |
| 阶段 3 | P2-1 ~ P2-6 | 2 周 | 镜像、图表、版本回滚、日志治理、系统检查、网络调优扩展 |
| 阶段 4 | P3-1 ~ P3-6 | 按需 | 账号模式、私有网络、Access、负载均衡、边缘优选、生态集成 |
建议先做阶段 1,因为它直接补齐「已经有隧道但无法纳管」「云端被改无法发现」「故障无法定位」这三类现实问题。
---
## 七、数据模型与 API 变更
### 7.1 数据模型
- `cf_tunnels`:增加 `source`(created/imported)、`cloud_status`、`connector_count`、`last_sync_at`。
- 新增 `cf_tunnel_endpoints`:`tunnel_id`、`hostname`、`path`、`service`、`protocol`、`origin_json`。
- 新增 `cf_tunnel_events`:健康事件、审计事件、同步事件。
- `cf_settings`:扩展 `enabled`、`auto_update`、`update_channel`、`mirror`。
### 7.2 API
- `GET /api/cloudflare/cloud-tunnels`:列出云端隧道
- `POST /api/cloudflare/cloud-tunnels/{id}/import`:导入
- `GET/POST/PUT/DELETE /api/cloudflare/tunnels/{id}/endpoints`:端点 CRUD
- `POST /api/cloudflare/tunnels/{id}/pull`:从云端拉取
- `GET /api/cloudflare/tunnels/{id}/drift`:漂移检测
- `GET /api/cloudflare/tunnels/{id}/logs/stream`:实时日志
- `GET /api/cloudflare/tunnels/{id}/diagnostics`:诊断
- `POST /api/cloudflare/tunnels/{id}/token/rotate`:令牌轮换
- `GET/POST /api/cloudflare/mirrors`:镜像管理
### 7.3 前端
- 隧道详情增加端点列表、diff、诊断、实时日志、指标图表。
- 新增「云端隧道」页签或导入入口。
- 应用配置增加自动更新策略、镜像管理、系统要求检查结果。
---
## 八、测试与验收
**单元测试**
- ingress 增删改与三条不变式
- 云端配置 diff
- token 解码、轮换
- metrics 解析与降级
- 诊断结果解析
**集成测试**
- 创建 → 启动 → 健康 → 停止 → 删除全流程
- 云端修改后用 diff 发现并拉取
- 多副本零停机更新
- 自动 DNS 创建/更新
**前端**
- 端点编辑、diff 视图、诊断面板、实时日志
**真机矩阵**
- Windows、Linux、Docker 容器内、host 网络
- 代理环境、QUIC 被拦、7443/7844 被占用、无 IPv6
- Cloudflare Access 与无 Access
---
## 九、风险与边界
- **官方接口演进**:Cloudflare API 与 cloudflared 参数会变;所有参数需要可配置 + 默认值 + 可恢复默认。
- **权限边界**:API Token 权限不足时只提示,不自动扩权。
- **网络边界**:只做本机到 CF 边缘的调优;访客侧不可控。
- **合规边界**:大文件/视频分发不在推荐用法内。
- **安全边界**:不代配 Access 高风险策略;不写 hosts;不改系统 ulimit/防火墙。
- **运行边界**:Havline 只托管自己启动的 cloudflared 进程;不接管外部 systemd 服务。
- **数据边界**:凭据加密存储;预览脱敏;审计日志不记录明文 token。
---
## 十、结论
Havline 的 Cloudflare 隧道模块已经具备「创建、运行、托管、DNS」的闭环。下一阶段的目标是把它从「能用」推进到「可长期运维」:
1. 先补齐云端同步、端点 CRUD、漂移检测、健康接入、实时日志、诊断(阶段 1)。
2. 再补快速隧道、多副本、自动更新、令牌轮换、Origin 高级参数、审计(阶段 2)。
3. 最后做镜像、图表、版本回滚、日志治理、系统检查与网络调优扩展(阶段 3)。
4. 私有网络、Access、负载均衡、边缘优选等平台化能力按需推进(阶段 4)。
这样 Havline 才能在官方能力、参考项目功能与自身产品体系之间取得平衡,而不是停留在「进程开关 + 配置预览」。
-576
View File
@@ -1,576 +0,0 @@
# Cloudflare 隧道同类项目调研与 Havline 体验优化建议
> 日期:2026-09-30
> 调研范围:GitHub 上 Cloudflare Tunnel / cloudflared 管理面板、反向代理管理器、自托管访问平台、状态监测与流量检查器。
> 目标:不是照搬功能,而是提取适合 Havline 的自动化方式、页面结构和长期运维体验。
---
## 一、调研结论摘要
同类项目大致分成四类:
1. **cloudflared 单容器 Web UI**
- 代表:Cloudflared-web
- 重点:快速设置 Token、启停隧道、查看运行状态。
2. **Cloudflare 路由与 Access 自动化平台**
- 代表:DockFlare
- 重点:Docker 标签、期望状态调和、DNS、ingress、Access 策略统一管理。
3. **Homelab 反向代理 / 私有访问平台**
- 代表:Nginx Proxy Manager、Pangolin、Cosmos Server
- 重点:可视化路由、证书、访问控制、资源入口页、审计。
4. **监控、状态页与流量调试**
- 代表:Uptime Kuma、Tunnel Inspector
- 重点:可用率、状态页、事故记录、请求级观测、重放和抓包体验。
对 Havline 最重要的启发不是“再加多少按钮”,而是:
- 用期望状态和自动调和代替手工点同步。
- 让规则、隧道、DNS、证书、监控在同一套模型中管理。
- 配置页要面向“服务发布”,而不是面向 cloudflared 参数。
- 把诊断、修复、日志和状态页做成闭环。
- 高级能力可以保留,但默认路径必须非常短。
---
## 二、代表性项目
以下 Star 数据为 2026-09-30 GitHub 查询结果,仅作为热度参考。
| 项目 | 类型 | 技术栈 | Star | 最值得借鉴 |
| --- | --- | --- | ---: | --- |
| [cloudflare/cloudflared](https://github.com/cloudflare/cloudflared) | 官方客户端 | Go | 15970 | 官方能力边界、TryCloudflare、版本支持策略 |
| [DockFlare](https://github.com/ChrispyBacon-dev/DockFlare) | Tunnel + Access 自动化 | Python | 2471 | Docker 标签、期望状态调和、多主 Agent、备份恢复 |
| [Nginx Proxy Manager](https://github.com/NginxProxyManager/nginx-proxy-manager) | 反向代理面板 | JavaScript | 高热度 | Host / Streams / Access Lists / SSL / 用户与审计 |
| [cloudflare-tunnel-ingress-controller](https://github.com/STRRL/cloudflare-tunnel-ingress-controller) | Tunnel Ingress Controller | Go | 1197 | 声明式路由、Ingress 到 Tunnel/DNS 自动生成 |
| [cftunnel](https://github.com/qingchencloud/cftunnel) | 全协议穿透 CLI | Go | 345 | 快速隧道、Cloud + FRP 双引擎、模板、分享、自动更新 |
| [Cloudflared-web](https://github.com/WisdomSky/Cloudflared-web) | 极简 Web UI | Vue | 465 | 单容器安装、Token 管理、启停、低学习成本 |
| [Tunnel Cloudflared Desktop](https://github.com/maskodingku/Tunnel-Cloudflared-Desktop) | 桌面管理器 | Tauri + Rust | 55 | 多账号、`cert.pem`、双向同步、实时指标、Quick Tunnel |
| [Tunnel Inspector](https://github.com/BlockbusterAndy/cloudflare-tunnel-inspector) | 流量检查器 | TypeScript | 6 | 请求/响应捕获、SSE 实时流、搜索筛选、重放、敏感头脱敏 |
| [Pangolin](https://github.com/fosrl/pangolin) | 自托管访问平台 | Go / Web | 高热度 | 资源入口页、统一身份、按资源授权、浏览器访问 SSH/RDP/VNC |
| [Uptime Kuma](https://github.com/louislam/uptime-kuma) | 监控与状态页 | Vue / Node | 高热度 | 多状态页、事故记录、证书信息、通知渠道、2FA |
---
## 三、项目逐个分析
### 3.1 Cloudflared-web
项目定位:
- 一个 Docker 镜像同时打包 cloudflared 和简单 Web UI。
- 主要面向“只想管理 Token 和启停隧道”的用户。
核心体验:
- Docker 启动后直接打开固定端口网页。
- 填写 Token,一键启停。
- 支持 BASIC AUTH。
- 支持环境变量配置协议、边缘 IP、metrics、重试等。
- 数据持久化到 `/config`。
对 Havline 的启示:
- 初次配置应该更短:Account ID、API Token、Zone、Tunnel 四步内完成。
- 所有高级网络参数应该折叠到“高级选项”,默认值直接可用。
- 当前 Havline 已覆盖它的主要能力,并且多了 API 托管、DNS、规则同步和监控。
不建议照搬:
- 只有 Token 和启停,模型太薄。
- 不适合 Havline 的“服务发布规则”产品定位。
### 3.2 DockFlare
项目定位:
- 面向多变自托管环境的 Cloudflare Tunnel / DNS / Access 控制平面。
- 强调“定义一次意图,然后持续调和”。
核心能力:
- Docker 标签自动发现服务。
- Web UI 手工规则。
- 远程 Agent 多主机管理。
- Tunnel ingress、DNS、Access 统一调和。
- Access group 和可复用策略。
- 加密配置、运行时状态和数据的备份恢复。
- 多语言 UI。
对 Havline 的启示:
- 当前 Havline 已经有“反向代理规则派生 Cloudflare ingress”,方向正确。
- 下一步应补“期望状态”和“调和结果”:
- 本地期望配置。
- Cloudflare 实际配置。
- 最后同步时间。
- 漂移原因。
- 一键修复。
- Docker 标签自动发布非常适合 NAS。
- 多主机 Agent 适合进阶用户,但不应优先于单机体验。
- Access 自动化风险较高,Havline 暂时保持只读更稳。
### 3.3 Tunnel Ingress Controller
项目定位:
- 把 Kubernetes Ingress 转成 Cloudflare Tunnel 和 DNS 配置。
核心思想:
- 用户只维护 Kubernetes 原生声明。
- Controller 持续把声明同步到 Cloudflare。
- 不要求用户手工编辑 cloudflared ingress。
对 Havline 的启示:
- Havline 的“反向代理规则就是服务入口声明”是同一思路的非 K8s 版本。
- 无需引入 K8s,但应借鉴:
- 声明与运行状态分离。
- 调和结果可见。
- 失败原因可追踪。
- 不应让用户理解底层 ingress 文件。
### 3.4 Tunnel Cloudflared Desktop
项目定位:
- 面向桌面用户的 Cloudflare Tunnel 管理器。
核心能力:
- Quick Tunnel。
- 多账号 `cert.pem`。
- 本地与 Cloudflare Dashboard 双向同步。
- 实时指标。
- 实时日志。
- 自动下载 cloudflared。
对 Havline 的启示:
- Quick Tunnel 适合“临时演示”,不建议作为正式发布方式。
- 双向同步是 Havline 当前明显缺口:
- 当前支持检测漂移并“本地覆盖云端”。
- 缺少“云端覆盖本地”。
- 多账号模式对 Havline 属于进阶能力,可放到远期。
- 实时日志和指标已具备,继续加强即可。
### 3.5 cftunnel
项目定位:
- 全协议内网穿透 CLI。
- Cloud 模式使用 Cloudflare Tunnel。
- Relay 模式使用自建 frps。
核心体验:
- `quick <port>` 生成临时公网地址。
- 可加 Basic Auth。
- 自动 DNS。
- 预设模板。
- 一键分享二维码、地址、Telegram。
- 自动更新。
对 Havline 的启示:
- “快速隧道”可以作为临时验证入口,但必须标明不保证 SLA。
- 场景模板非常值得借鉴:
- Webhook
- Home Assistant
- 面板
- 相册
- Jellyfin 管理页
- “复制访问地址 / 二维码分享”适合手机用户。
- Cloud + FRP 双引擎的概念与 Havline 的出口抽象一致。
### 3.6 Nginx Proxy Manager
项目定位:
- 最经典的自托管反向代理 Web 面板。
核心体验:
- Hosts、Streams、Access Lists、SSL Certificates、Users 分栏。
- 每条代理规则有独立状态、SSL、访问控制、高级配置。
- 证书申请和续签直接在规则旁完成。
- 管理界面简单,新手可按表单完成部署。
对 Havline 的启示:
- 反向代理规则的“编辑页”应成为服务发布中心。
- Cloudflare 出口应该是一个选项,而不是另一套孤立页面。
- SSL、Access、健康检查、日志应围绕同一条规则组织。
- Havline 当前已有这一方向,但页面还可以更像“资源详情页”。
### 3.7 Pangolin
项目定位:
- 开源自托管 SASE / Zero Trust 访问平台。
核心体验:
- 资源入口页。
- 用户、角色、资源级授权。
- 浏览器直接访问 SSH、RDP、VNC。
- 对每个资源做审计和会话记录。
- 多 connector 和站点冗余。
对 Havline 的启示:
- 普通 NAS 用户更需要“访问我的服务”,不是“管理网络隧道”。
- 可以借鉴资源入口页:
- 按应用、域名、出口分组。
- 一键打开服务。
- 显示维护状态和最近访问。
- 不建议 Havline 当前做企业 RBAC、SSO、PAM;成本过高。
### 3.8 Uptime Kuma
项目定位:
- 自托管监控和状态页。
核心体验:
- 多状态页。
- 不同域名绑定不同状态页。
- Ping 图表、证书信息。
- 通知渠道丰富。
- 2FA。
对 Havline 的启示:
- 当前公开状态页已有域名和可用率。
- 下一步最值得补:
- 事故记录。
- 维护窗口。
- 最近 24h / 7d / 30d 状态条。
- 多个状态页。
- 自定义标题、Logo、说明。
- 这些比继续增加隧道参数更直接影响用户体验。
### 3.9 Tunnel Inspector
项目定位:
- Cloudflare Tunnel 的本地 HTTP 流量检查器。
核心体验:
- 实时请求列表。
- 方法和状态码筛选。
- 请求/响应头、Body、耗时、TTFB。
- 响应流式透传。
- 敏感头脱敏。
- 请求重放和 Copy as cURL。
- SSE 实时推送。
对 Havline 的启示:
- 适合做成“Cloudflare 出口调试模式”,不要默认常驻。
- 可提供:
- 最近请求。
- 上游状态码。
- 回源耗时。
- 错误请求。
- 一键复制 curl。
- 需要严格限制:
- 仅本机访问。
- 默认脱敏。
- 默认关闭。
- 明确数据不离开本机。
---
## 四、跨项目共同的最佳体验
### 4.1 安装向导与预检
优秀项目不会让用户直接面对复杂配置。
建议 Havline 增加:
1. 检查 cloudflared 是否安装。
2. 检查 Account ID。
3. 检查 API Token 权限。
4. 列出可用 Zone。
5. 检查目标根域名是否在 Zone 内。
6. 检查 DNS 是否已被其他目标占用。
7. 检查 7844 / 443 连通性。
8. 生成一条测试隧道并验证访问。
### 4.2 期望状态与调和中心
用户看到的不应只是“同步按钮”,而是:
```text
期望状态:已发布
Cloudflare 实际:缺失
最后同步:2 分钟前
原因:DNS CNAME 目标不一致
操作:修复 / 查看差异
```
这比单纯提示“保存失败”更有价值。
### 4.3 可视化拓扑
很多用户不理解 Nginx、frps、cloudflared、DNS、Access 的关系。
建议:
```text
域名 → 出口 → 隧道 → 内网服务
```
例如:
```text
nas.example.com
→ Cloudflare
→ tunnel: home-nas
→ http://192.168.1.10:8001
```
拓扑图可以直接显示:
- 域名。
- 出口。
- DNS 状态。
- 隧道状态。
- 回源状态。
- 证书来源。
### 4.4 路由表体验
当前 Havline 已有路由表。建议继续补:
- 拖拽排序。
- 启用/停用。
- 复制路由。
- 正则路径提示。
- “测试此路由”按钮。
- 最后匹配结果。
- 路由来源标签:
- 手工。
- 反向代理规则。
- 导入。
- 修改历史和回滚。
### 4.5 状态页与事故
建议把公开状态页从“当前状态列表”升级为:
- 当前状态。
- 最近事故。
- 事故持续时间。
- 维护窗口。
- 最近 24h / 7d / 30d 状态条。
- 服务分组。
### 4.6 流量检查模式
建议作为高级诊断功能:
- 默认关闭。
- 开启后显示最近请求。
- 自动过滤静态资源。
- 显示 4xx/5xx。
- 回源错误和边缘错误分开。
- 可复制为 curl。
- 可查看请求/响应头。
- 默认隐藏 Authorization、Cookie、Token。
### 4.7 快速隧道
适合做“临时演示”:
- 只暴露单个本地端口。
- 生成 `trycloudflare.com` 地址。
- 明确标注:
- 地址会变化。
- 不保证 SLA。
- 不用于正式服务。
- 可复制地址或生成二维码。
### 4.8 一键分享与移动体验
对 NAS / 手机用户很有价值:
- 复制访问链接。
- 生成二维码。
- 分享到 Telegram / 微信。
- 移动端查看状态页。
- PWA 支持可作为远期项。
### 4.9 多账号与 `cert.pem`
桌面管理器和 DockFlare 都把多账号作为进阶能力。
建议 Havline:
- 当前继续以 Account ID + API Token 为默认。
- 远期可支持多账号。
- `cert.pem` 不应替代 API Token,只作为高级兼容模式。
- 多账号必须有明确的账号隔离,避免路由误绑。
### 4.10 审计与回滚
自托管平台越复杂,回滚越重要。
建议:
- 记录规则和出口变更前后值。
- 记录操作主体。
- 记录 Cloudflare 同步结果。
- 支持手动回滚配置。
- 敏感字段只记录“已修改”,不记录明文。
---
## 五、Havline 不该照搬的功能
| 功能 | 结论 | 原因 |
| --- | --- | --- |
| Kubernetes Controller | 不做 | 不是 NAS 用户场景 |
| 企业 SSO / PAM / RBAC 平台 | 暂不做 | 成本高,偏离 Havline 定位 |
| Cloudflare 邮件套件 | 不做 | 与访问管理无关 |
| 多语言 13 种起步 | 暂不做 | 先保持中文产品体验 |
| 多主机 Agent 优先 | 后置 | 先做好单机 NAS |
| 自动配置 Access 策略 | 后置 | 权限风险高 |
| 默认常驻流量抓包 | 不做 | 隐私和性能风险 |
| 用户自定义 cloudflared 镜像管理 | 不做 | 增加维护复杂度 |
---
## 六、Havline 建议路线
> 实施状态:截至 2026-10-01,本节的 P0 已完成第一轮落地,详见 `cloudflared-current-features.md`。
### P0:最值得先做
1. **Cloudflare 接入向导**
- Account、Token、Zone、Tunnel 一步检查。
2. **调和中心**
- 展示期望状态、云端状态、漂移原因、最后同步时间。
3. **路由表增强**
- 排序、启停、复制、测试、来源标签。
4. **一键修复**
- DNS、ingress、进程、二进制四类修复动作。
5. **状态页事故记录**
- 事故时间线、持续时长、维护窗口。
### P1:用户体验提升
6. **拓扑图**
- 域名 → 出口 → 隧道 → 服务。
7. **Docker 标签发布**
- 容器标签自动生成 Havline 规则和 Cloudflare 出口。
8. **场景模板**
- Home Assistant、Immich、Jellyfin、qBittorrent、AdGuard。
9. **访问二维码与分享**
- 手机用户高频需求。
10. **云端覆盖本地**
- 完成双向同步,不只支持本地覆盖云端。
### P2:高级能力
11. **流量检查模式**
12. **多 Cloudflare 账号**
13. **Access 策略只读增强**
14. **审计日志**
15. **Quick Tunnel 临时演示**
16. **外部 Agent 多主机发布**
### P3:远期
17. PWA / 移动端体验。
18. 团队用户与资源级授权。
19. 浏览器 SSH / RDP / VNC 网关。
20. 多 connector 主备。
---
## 七、针对当前 Havline 页面的具体改版建议
### 7.1 Cloudflare 总览页
建议结构:
```text
PageHeader
接入状态卡
隧道总览卡
待处理问题
最近变更
快速操作
```
待处理问题可以包括:
- API Token 权限不足。
- 根域名不在 Zone。
- DNS 被其他记录占用。
- 隧道离线。
- 本地与云端漂移。
- cloudflared 有新版本。
### 7.2 隧道配置页
建议分为:
```text
概览
路由
副本 / 指标
DNS
诊断
日志
高级
```
不要把所有内容放在一个长页面里。
### 7.3 反向代理规则页
在规则详情中增加:
```text
出口:本机 Nginx + Cloudflare
Cloudflare 状态:已同步
DNS:已创建
访问保护:未启用
最近错误:无
```
用户不需要离开规则详情就能知道整条链路是否健康。
### 7.4 仪表盘
增加:
- Cloudflare 在线隧道。
- 异常路由。
- DNS 漂移。
- 最近恢复 / 最近故障。
- cloudflared 可更新提示。
---
## 八、最终建议
Havline 不应继续横向堆叠 cloudflared 参数。更值得投入的是:
1. 把 Cloudflare 接入做成向导。
2. 把“规则 → 出口 → 隧道 → DNS → 监控”做成同一条链路。
3. 把漂移、诊断和修复做成可操作闭环。
4. 把状态页做成真正的可分享服务状态页。
5. 把 Docker / NAS 服务发布做到自动发现。
6. 把高级能力放到高级页,不干扰默认路径。
这会让 Havline 与普通 cloudflared 面板产生明确差异:
> 普通面板管理 cloudflared;Havline 管理“我的服务如何安全、稳定地访问”。
-370
View File
@@ -1,370 +0,0 @@
# Cloudflare 隧道:Havline 适配版功能计划
> 日期:2026-09-30
> 定位:`cloudflared-feature-plan.md` 是「官方 + 参考项目的功能全集」;本文是**按 Havline 产品定位筛选后的适配版计划**,实施以本文为准。
> 核心原则:Havline 不是 cloudflared 管理面板,而是「NAS / 家庭服务器的公网访问管理面板」。Cloudflare 隧道只是它的第二条出口。
---
## 一、先明确 Havline 是什么
### 1.1 产品定位
Havline 面对的是家庭 NAS、小主机、家用服务器的用户,核心价值是:
- 用域名访问内网服务,而不是记 IP 和端口;
- 自动处理证书、反代、穿透、DDNS;
- 尽量少配置、少暴露、少维护。
Havline 已经有三条主线:
1. **反向代理**:域名 → 本机 Nginx → 内网服务;
2. **FRP 穿透**:本机 frpc → VPS frps → 域名;
3. **公网 Agent**:管理 VPS 上的 frps 与 Nginx。
Cloudflare 隧道的加入,是为了解决一类现有链路解决不了的人:
> 没有公网 IP、也不想长期租 VPS,但想让内网 Web 服务通过域名安全访问。
### 1.2 因此有三条不可动摇的原则
1. **CF 隧道是第二条出口,不是替代 FRP**:TCP/UDP、自主可控、大流量仍走 FRP。
2. **规则同源**:用户维护的是「服务发布规则」,不是「隧道 ingress」。CF 只是一个出口选项。
3. **融入 Havline 体系**:监控、通知、状态页、日志、备份、升级、审计都复用现有模块,不另造一套面板。
---
## 二、目标用户与场景
### 2.1 适合
- 家宽没有公网 IPv4,或运营商封 80/443;
- 有域名托管在 Cloudflare,或愿意把域名 NS 切到 Cloudflare;
- 主要暴露 HTTP/HTTPS 服务:NAS 面板、PhotoPrism、Jellyfin 管理页、Home Assistant、qBittorrent WebUI、OpenWrt 面板等;
- 希望「一次配置好,之后自动跟随反代规则」。
### 2.2 不适合
- 需要裸 TCP/UDP 端口(游戏服、非 HTTP 协议);
- 需要完全自主可控、流量不经过第三方;
- 大文件/视频持续分发(Cloudflare 非 Enterprise 条款限制);
- 企业级 Zero Trust 私有网络、VPN 替代。
---
## 三、借鉴与不借鉴
参考项目 `Tunnel-Cloudflared-Manager 1.0.1` 是一个独立的 cloudflared 面板。以下判断按「是否符合 Havline 产品形态」给出:
### 3.1 值得借鉴
| 参考项目做法 | Havline 该怎么用 |
| --- | --- |
| token 解码成本地凭据 | 已用 Account ID + API Token 替代;保留解码能力用于导入 |
| metrics 端口从日志发现 | 已实现,继续沿用 |
| ingress 三条不变式 | 已实现,继续沿用 |
| 云端 / 本地配置互相同步 | 值得做,但要简化为「diff → 拉取或覆盖」 |
| 端点级增删改 | 值得做,但必须挂在 Havline 的「服务发布规则」下 |
| 快速隧道 | 只做「临时演示」,不做正式功能 |
| 网络诊断 | 值得做,做成 Havline 的一键诊断 |
| 镜像与下载进度 | 值得做,但复用 Havline 现有镜像表,不做用户自定义镜像管理 |
| Windows 隐藏控制台 | 已实现,继续沿用 |
### 3.2 不借鉴
| 参考项目做法 | 不借鉴的理由 |
| --- | --- |
| 自带 JWT + 账号体系 | Havline 已有会话 + API Token,重复建设 |
| 独立主题、多语言 | 与 Havline 全局设置重复 |
| 多账号 cert.pem 登录 | 与 Havline 的 Account ID + API Token 模型冲突;远期若有需求再评估 |
| 私有网络 / WARP routing / virtual network | 面向企业 Zero Trust,NAS 用户不需要 |
| Kubernetes / Ansible / Terraform / 云厂商部署指南 | 不是 Havline 的交付形态 |
| 负载均衡 | 家用单机场景不需要 |
| Workers VPC / Hyperdrive / Spectrum / Keyless SSL | 超出 Havline 定位 |
| WebSocket 实时日志 | Havline 已有 SSE 日志模式,不为 CF 单独引入 WSS |
| 自定义镜像管理(增删改测) | Havline 只需内置镜像表 + 自定义 URL,不需要用户维护镜像源 |
| 独立 systemd / Windows service 安装 | Havline 自己托管进程,不接管外部服务 |
| 快速隧道作为正式功能 | 官方明确不保证 SLA,只做演示入口 |
---
## 四、符合 Havline 的功能全景
按「用户价值」而不是「参考项目有什么」重新分组。
### 4.1 出口抽象(Havline 的核心差异化)
**F-1 服务发布规则增加「出口」字段**
- 一条规则描述「域名 → 内网服务」,出口可选:本机 Nginx / FRP / Cloudflare 隧道。
- 现有反向代理规则作为基础,新增出口选择,不新建第二套规则模型。
- 验收:同一条规则可以切换出口,切换后域名与服务保持不变。
**F-2 CF 隧道 ingress 自动跟随反代规则**
- 反代规则新增/修改/删除域名时,自动同步到该隧道的 ingress。
- 只同步 HTTP/HTTPS 规则;TCP/UDP 规则在出口选择里标为「Cloudflare 不支持」。
- 验收:改一条反代规则,CF 隧道配置与云端 ingress 自动更新,无需手工编辑。
**F-3 一条规则可以同时走 FRP 与 CF**
- 出口支持多选(FRP + Cloudflare),用于灰度或双保险。
- 验收:同一域名可同时存在 FRP 与 CF 入口,互不影响。
### 4.2 自动化(延续 Havline「自动为主」)
**F-4 一次配置,后续全自动**(大部分已完成)
- Account ID + API Token 一次保存;
- 自动创建隧道、写 `creds.json` / `config.yml`、推送 ingress、创建 CNAME;
- 自动启动、自动重连、崩溃重启。
**F-5 反代规则变化自动同步**
- 规则增删改 → 生成新 ingress → 本地与云端同步 → 需要时重载配置。
- 验收:用户不需要理解 ingress,也不需要手工点同步。
**F-6 自动 DNS 管理**
- 创建域名时自动 CNAME 到 `<tunnel-id>.cfargotunnel.com`;
- 删除域名或隧道时,删除「指向该隧道」的 DNS 记录(只删自己创建的)。
- 验收:域名增删与 DNS 一致,不误删用户其他记录。
**F-7 自动健康与自愈**
- 进程退出自动重启;
- `/ready` 与 `ha_connections` 判活;
- 连续失败进入 Havline 告警;恢复后发通知。
### 4.3 融入 Havline 现有体系
**F-8 监控与可用率**
- 新增 cloudflared 的 `monitor.Source`;
- 隧道健康进入 Havline 现有可用率与历史指标。
**F-9 通知**
- 隧道断连、恢复、证书/DNS 失败复用 `notify`;
- 通知文案面向 NAS 用户,而不是 cloudflared 术语。
**F-10 状态页**
- 公开状态页增加 CF 隧道域名与可用率;
- 只展示域名与状态,不泄露隧道 ID、本机地址。
**F-11 日志中心**
- CF 隧道日志进入 Havline 日志页;
- 复用 SSE 增量推送,不单独做 WebSocket。
**F-12 仪表盘**
- 仪表盘增加「Cloudflare 隧道」卡片:隧道数、在线数、最近错误。
**F-13 备份与迁移**
- `cf_tunnels` 与凭据纳入 Havline 备份/恢复流程;
- 恢复后自动重启隧道。
**F-14 一键升级与版本策略**
- cloudflared 版本跟随 Havline 升级流程;
- 提供「检查更新 / 更新 cloudflared / 回滚」;
- 不要求用户手工替换二进制。
**F-15 审计**
- 记录谁在何时修改了服务发布规则、出口、CF 凭据、全局开关;
- 复用 Havline 的日志/审计基础设施。
### 4.4 NAS 场景体验
**F-16 Docker 服务发现一键发布**
- 复用现有 `discovery`,发现本机 Docker 服务;
- 选择服务 → 填域名 → 自动创建反代规则 + CF 出口。
**F-17 常用 NAS 应用预设**
- NAS 面板、PhotoPrism、Jellyfin、Home Assistant、qBittorrent 等端口预设;
- 用户只需选应用与域名。
**F-18 域名与 Zone 选择器**
- 通过 API Token 列出账号下的 Zone 与可用域名;
- 用户从列表选择,而不是手输。
**F-19 证书提示**
- 使用 CF 出口时,CF 边缘自动提供 HTTPS;
- 页面提示「该域名无需在 Havline 申请证书」,避免用户重复操作。
### 4.5 运维与安全
**F-20 端点级编辑**
- 在 Havline 规则详情里编辑 hostname、path、service、协议;
- 提供 `ingress validate` 与 `ingress rule <url>` 校验。
**F-21 云端漂移检测**
- 定期比较本地 ingress 与云端配置;
- 发现漂移后提示「以本地为准覆盖」或「以云端为准拉取」。
**F-22 一键诊断**
- `tunnel info`、`tunnel diag`、网络预检(region1/region2、7844、443)、系统要求检查;
- 结果转成 Havline 风格的可读结论与建议。
**F-23 令牌轮换**
- 提供「重新签发隧道凭据」入口;
- 轮换过程尽量不中断,失败自动回滚。
**F-24 全局托管开关**(已完成)
- 一键停止全部隧道,重启不自动拉起;
- 开启时只恢复 auto_start 的隧道。
**F-25 条款与用量提示**
- 页面常驻说明:不适合大文件/视频分发;
- 不采集、不上报用户流量到外部。
**F-26 Access 保护状态检测(只读)**
- 检测域名是否启用 Cloudflare Access;
- 只提示,不代配策略。
### 4.6 可选增强
**F-27 快速隧道(临时演示)**
- 临时把某个本地端口发到 `trycloudflare.com`;
- 明确标注不保证 SLA、URL 每次变化、仅用于测试。
**F-28 多副本**
- 高级选项,用于「主备两台设备跑同一条隧道」;
- 默认关闭,不打扰普通用户。
**F-29 边缘 IP 优选**
- 只影响「本机 → CF 边缘」一段;
- 页面展示优选建议,不代写 hosts;
- 面向国内链路质量差的用户。
**F-30 账号模式(cert.pem)**
- 仅当用户明确需要「多账号 / 团队权限」时提供;
- 默认仍推荐 Account ID + API Token。
---
## 五、明确不做的功能
| 功能 | 结论 | 理由 |
| --- | --- | --- |
| 独立登录、JWT、多用户体系 | 不做 | Havline 已有认证与 API Token |
| 独立主题、多语言 | 不做 | 属于 Havline 全局设置 |
| 私有网络 / WARP routing | 不做(远期评估) | 面向 Zero Trust 企业,NAS 用户不需要 |
| 负载均衡 | 不做 | 家用单机场景不需要 |
| Kubernetes / Ansible / Terraform 指南 | 不做 | 不是 Havline 交付形态 |
| Workers VPC / Hyperdrive / Spectrum | 不做 | 超范围 |
| WebSocket 实时日志 | 不做 | 复用 Havline SSE |
| 自定义镜像管理 | 不做 | 内置镜像表 + 自定义 URL 足够 |
| 接管外部 systemd / Windows service | 不做 | Havline 只托管自己启动的进程 |
| 快速隧道作为正式功能 | 不做 | 官方不保证 SLA,只做演示 |
| 代配 Access 策略 | 不做 | 高风险,只做只读检测 |
| 自动扩权 / 自动改系统防火墙 | 不做 | 保持 Havline 现有安全边界 |
---
## 六、与现有模块的整合点
| Havline 模块 | 整合方式 |
| --- | --- |
| 反向代理规则 | 增加「出口」字段;CF 隧道从规则派生 |
| FRP 多服务端 | 与 CF 出口并列,互不影响 |
| 证书管理 | CF 出口域名提示无需本地证书 |
| DDNS | CF 橙色云代理后本地 IP 变化不再影响访问,可提示用户 |
| 监控 / 可用率 | 新增 cloudflared Source |
| 通知 | 断连、恢复、DNS 失败复用 |
| 公开状态页 | 增加 CF 隧道条目 |
| 日志中心 | CF 日志进入现有日志页 |
| 仪表盘 | 新增 CF 隧道卡片 |
| Docker 服务发现 | 一键发布到 CF |
| 备份 / 恢复 | cf_tunnels 与凭据纳入 |
| 一键升级 | cloudflared 版本更新纳入 |
| 审计 | 规则、出口、凭据变更记录 |
---
## 七、数据模型与 API 变更
### 7.1 数据模型
- `frp_proxies`(或服务发布规则表)增加 `exit` 字段:`local` / `frp` / `cloudflare` / 多选;
- `cf_tunnels` 增加 `rule_id`(可选,关联服务发布规则)、`source`、`last_sync_at`;
- 新增 `cf_endpoint_drift` 或复用现有漂移检测表;
- `cf_settings` 增加 `auto_update`、`update_channel`。
### 7.2 API
- `PUT /api/proxies/{id}/exit`:切换出口;
- `GET /api/cloudflare/tunnels/{id}/drift`:漂移检测;
- `POST /api/cloudflare/tunnels/{id}/pull`:从云端拉取;
- `POST /api/cloudflare/tunnels/{id}/diagnostics`:一键诊断;
- `GET /api/cloudflare/zones`:Zone / 域名列表;
- `POST /api/cloudflare/tunnels/{id}/token/rotate`:令牌轮换;
- `GET /api/cloudflare/tunnels/{id}/logs/stream`:SSE 日志;
- `GET /api/discovery/docker`:Docker 服务发现(若尚未提供)。
### 7.3 前端
- 反向代理规则表单增加「出口」选择器;
- 规则详情增加「Cloudflare 隧道」状态与操作;
- CF 页面聚焦「隧道状态 / 诊断 / 日志 / 全局开关」,弱化「手工建隧道」;
- 仪表盘、状态页、日志中心增加 CF 条目。
---
## 八、阶段计划
| 阶段 | 内容 | 目标 |
| --- | --- | --- |
| 阶段 1 | F-1 ~ F-3 出口抽象;F-5 规则同步;F-6 DNS 管理完善 | 让 CF 成为反代规则的一个出口,而不是独立模块 |
| 阶段 2 | F-8 ~ F-15 体系整合;F-20 ~ F-22 编辑与诊断 | 让 CF 隧道进入 Havline 监控、通知、状态页、日志、备份、审计 |
| 阶段 3 | F-16 ~ F-19 NAS 体验;F-23 ~ F-26 运维安全 | 一键发布、域名选择、令牌轮换、条款提示、Access 检测 |
| 阶段 4 | F-27 ~ F-30 可选增强 | 快速隧道、多副本、边缘优选、账号模式按需 |
**优先级建议**:阶段 1 是产品形态的关键,先做;阶段 2 决定「能不能长期运维」;阶段 3 决定「好不好用」;阶段 4 是加分项。
---
## 九、验收标准
- 用户只维护「域名 → 服务」的规则,出口切换不需要改域名或端口;
- 反代规则变化后,CF 隧道与 DNS 自动跟随;
- 隧道断连能进 Havline 告警与状态页;
- 一条规则可以同时走 FRP 与 CF,互不影响;
- CF 出口域名不需要本地证书;
- 备份恢复后隧道能自动恢复;
- 所有 CF 凭据加密存储、预览脱敏;
- 明确不做的功能不会出现在界面里,避免用户误解。
---
## 十、结论
Havline 需要的不是「另一个 cloudflared 面板」,而是:
> 把 Cloudflare 隧道做成反向代理规则的一个出口选项,并让监控、通知、状态页、日志、备份、升级、审计自动覆盖它。
按本文阶段 1-3 实施,Havline 的 CF 模块会形成与参考项目不同的产品形态:参考项目面向「管理 cloudflared」,Havline 面向「管理我的服务怎么上公网」。
本文与 `cloudflared-feature-plan.md` 的关系:
- `cloudflared-feature-plan.md`:官方 + 参考项目的功能全集,用于查漏;
- 本文:按 Havline 定位筛选后的实施计划,**实施以本文为准**。
-556
View File
@@ -1,556 +0,0 @@
# Cloudflare Tunnel(cloudflared)与 Havline 的融合方案
> 日期: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)源码。
> 结论速览:**值得做,但建议以「第二条出口」的形态并存,先做 locally-managed + 进程托管,不接 Cloudflare API**。v2 修订了部署位置、模式生效侧、metrics / 健康与二进制校验四处关键细节,详见 §五、§六、§十二;实施以 §十二 为准。
---
## 一、为什么值得做(对 Havline 的战略意义)
Havline 现在的穿透链路**必须有公网服务端**:本机 `frpc` → VPS 上的 `frps` → 域名。用户得有一台带公网 IP 的云主机,还得自己放行端口、签证书、配 DNS。
Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `cloudflared` **主动向外**建立到边缘的长连接(出站 only),公网侧不需要任何监听端口,也不需要公网 IP——顺带白拿 CF 的 WAF / DDoS 防护与自动 HTTPS。
对 Havline 的用户画像(家庭/个人 NAS)来说,这正好补上一类人:**没有实体公网 IP、或不想为穿透长期租 VPS**的人。
但要说清楚它**替代不了什么**(这决定了它是「并存」而不是「替换」):
| 场景 | frp | Cloudflare Tunnel |
| --- | --- | --- |
| 内网 HTTP(S) 网站/面板(域名访问) | ✅ | ✅ **更省事**(无需 VPS、无需放行端口) |
| 任意 TCP/UDP(游戏服、非 HTTP 协议) | ✅ | ⚠️ 需要访问端也装 `cloudflared`(`cloudflared access tcp`),不是通用 TCP 出口 |
| 客户端不需要额外软件的裸 TCP 端口 | ✅ | ❌ |
| 自持服务端 / 完全自主可控 | ✅ | ❌(流量必经 CF 边缘) |
| 大文件/视频流量 | ✅(自己带宽说话) | ⚠️ CF 条款限制非 Enterprise 的 CDN 视频/大文件用途,见 §七 |
所以定位是:**HTTP 类公网访问走 Cloudflare 更划算,TCP 类与自主性要求高的仍走 frp**。
---
## 二、同类项目(检索结果)
| 项目 | 做什么 | 对 Havline 的借鉴 |
| --- | --- | --- |
| [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,是第二个凭据面(Havline 第一轮不建议走,见 §六) |
| [qingchencloud/cftunnel](https://github.com/qingchencloud/cftunnel)(≈338★) | 轻量隧道控制 | 佐证需求存在 |
| [brenner-tobias/addon-cloudflared](https://github.com/brenner-tobias/addon-cloudflared) | Home Assistant 插件,locally-managed 与 remotely-managed 两种模式都支持 | HA 社区的做法可参考:**两种模式并存**,文档把差异讲透 |
| [cloudflare/cloudflared](https://github.com/cloudflare/cloudflared) | 官方客户端(Apache-2.0) | 我们的被托管对象;GitHub Release 提供多平台二进制 |
| 对照项:[Pangolin](https://github.com/fosrl/pangolin) / Tailscale Funnel / ngrok | 同类「免公网 IP」方案 | Tailscale Funnel 只开 HTTPS 且端口数量有限、ngrok 免费档收紧;CF Tunnel 免费档最宽松,这也是它值得优先支持的原因 |
`docs/feature-benchmark-2026-09-24.md` 里已经把 Pangolin 列为架构最近的同类项目;Cloudflare Tunnel 的差别在于:**它把「服务端」外包给了 Cloudflare,Havline 只需管好本机客户端**。
---
## 三、cloudflared 的两种托管模型(选型的关键)
| | locally-managed(本地管理) | remotely-managed(远程管理) |
| --- | --- | --- |
| 凭据 | `cert.pem`(`cloudflared tunnel login` 浏览器授权)+ `<UUID>.json` credentials | 一个 **token**(`eyJ...`,CF 面板生成) |
| 规则来源 | 本机 `config.yml` 的 `ingress:` | **CF 面板 / API**(本地 `config.yml` 会被**忽略**) |
| 建隧道 | `cloudflared tunnel create <name>` | CF 面板,或 `POST /accounts/{id}/cfd_tunnel` |
| DNS | `cloudflared tunnel route dns <tunnel> <hostname>` | CF 面板自动建 CNAME |
| 与 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))。
**结论:第一轮只支持 locally-managed。** 若用户只想粘贴 token,提供「仅托管进程」的降级模式并**在界面上明确写清「规则由 Cloudflare 面板管理」**,不要让用户以为 Havline 生成的规则生效了。
> 补充(参考 §九 后修正):这个二分法不够细。参考实现证明**token 可以解码**(base64 的 JSON,含 `AccountTag` / `TunnelID` / `TunnelSecret`),用它自己生成 `creds.json` 就能按 locally-managed 跑本地 `config.yml`;再配合「云端 ↔ 本地 ingress 互相同步」,两种模式实际上是可切换的。所以 Havline 的正确做法不是「二选一」,而是**显式区分并同步**:当前生效的是本地 `config.yml` 还是云端 ingress,以及「推送/拉取」两个动作。**v2 已把模式拆成三种并明确生效侧,见 §12.3。**
### 健康探测(这点比 frp 更省事)
`cloudflared tunnel --metrics 127.0.0.1:<port> run --config ...` 会暴露 HTTP 端点,其中 **`/ready` 在有到边缘的活跃连接时返回 200**(CF 官方在 k8s livenessProbe 里就是这么用的)。Havline 现在为了判断「隧道通不通」要绕 frps dashboard,还专门加过 `tunnel_known` 来区分「判不出来」和「不通」;CF Tunnel 则有明确的就绪端点,可以直接喂给现有健康链路。
---
## 四、本仓库可复用的面(已核对代码)
| 能力 | 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 页) |
| 客户端进程托管 | `internal/frp/manager.go`:`os.OpenFile(logPath, O_CREATE\|O_APPEND\|O_WRONLY, 0600)` + `cmd.Stdout/Stderr` 重定向 + 状态/退出原因 + `tailFile` 读日志尾 | 与 `cloudflared tunnel run` 完全同构,可直接复用(日志追加、不截断的语义也一致) |
| 凭据加密与回显 | `secret.Box` + `maskedToken = "********"`(`internal/frp/service.go:23`)+ 「预览脱敏 / 下发原文」两分支约定(v1.2.12 刚修) | token / credentials JSON 存加密字段;**预览一律掩码** |
| 配置生成 | `internal/frp/frps_template.go`、`internal/proxy` 的 nginx vhost 模板 | `config.yml` 的 `ingress:` 用同样思路生成,注释与安全提示的风格照搬 |
| 健康与可用率 | `internal/monitor`(`Source` 接口 + `HealthRecorder`)、`migrations/026` 的健康事件表、`metrics_samples` | 给 `Source` 加一个 provider(frp / cloudflared),`/ready` 作为判活依据 |
| 日志界面 / 公开状态页 | 日志页各页签、`/status` 公开状态页(默认关闭 + 访问码 + 限流) | 新隧道类型直接进现有页面,不用另造一套 |
| 规则模型 | `frp.Proxy{ServerID, Type, LocalIP, LocalPort, CustomDomains, Options}`(`internal/frp/model.go:186`) | 不再需要 `ServerID` 时用一个 provider 字段代替(见方案 B) |
---
## 五、三个方案
### 方案 A:独立模块「Cloudflare 隧道」(推荐第一轮)
新增一个与「公网服务端(FRP)」并列的模块,**不碰现有 frp 规则模型**:
- 管理一份 cloudflared 二进制(复用 §四 的下载/启用框架)。
- 管理 N 个隧道实例,每个实例 = 一份 `config.yml` + credentials + 日志文件 + 子进程。
- `ingress` 规则**由 Havline 生成**:从本机已有的反代规则里挑「要暴露的域名」,映射到 `http://127.0.0.1:<本地端口>`,模板与 nginx vhost 同源风格;末尾固定给 catch-all `service: http_status:404`(CF 要求最后一条兜底)。
- 健康:`--metrics` + `/ready`,写入现有健康与可用率链路;通知复用(隧道断开/恢复)。
- 前端:与「公网服务端」同构的列表 + 详情(状态 / 日志 / 配置预览 / 下发)。
**优点**:改动封闭、不动现有模型、可独立上线、风险低。
**代价**:规则要在两处维护(frp 规则 vs CF 隧道规则)——这是方案 B 要解决的。
### 方案 B:把「隧道出口」抽象成 provider(v2 方向)
在规则上加 `provider: frp | cloudflare`(`frp.Proxy` 增字段,配套迁移),规则本身只说「哪个域名 → 内网哪个端口」,出口由 provider 决定:
- 好处:一条规则可在 FRP / CF 之间切换,健康、可用率、状态页、漂移检测天然统一。
- 成本:动模型 + 迁移 + 前端多处(规则表单、健康四段、公网反代页),且 `ServerID` 的语义要重新定义(CF 侧没有服务端)。
- 建议:**等方案 A 稳定、用户真的需要在两种出口间迁移规则时再做**。
### 方案 C:只做引导(不接管进程)
只生成 `config.yml` 与 DNS 指引,让用户自己跑 `cloudflared`。
**不建议**:不接管进程就没有状态、日志、健康、可用率,等于放弃 Havline 的全部价值。
---
## 六、推荐落地路径(分阶段)
> 本节阶段划分是 v1 口径;v2 修订见 §12.10,实施以 §12.10 为准。
### 阶段 1:只读与引导(最低风险,先验证需求)
1. 新增「Cloudflare 隧道」入口:粘贴 **token**(remotely-managed)或上传 **credentials JSON + tunnel UUID**(locally-managed),凭据加密存储。
2. 生成 `config.yml` 预览(**脱敏**,沿用 v1.2.12 的预览/下发两分支约定)并把文件写到 `<DataDir>/cloudflared/<name>/config.yml`。
3. 界面给出 CF 侧操作指引(建隧道、CNAME、`cloudflared tunnel route dns`),**不接管进程**。
4. 明确提示模式差异:locally-managed 才由 Havline 生成规则。
### 阶段 2:托管进程与健康(核心价值)
5. 把 §四 的 release/binaries 框架泛化,支持下载 `cloudflared` 并启用指定版本;进程托管复用 `Manager`(日志、状态、退出原因、`tailFile`)。
6. `--metrics 127.0.0.1:<port>` + `/ready` 接入 `internal/monitor`:新增 provider 判活,接入现有通知与 `metrics_samples` 可用率。
7. 子命令支持:启动 / 停止 / 重启 / 重载配置 / 查看日志(与「公网服务端」的操作面板一致)。
### 阶段 3:规则同源与界面完善
8. 从本机反代规则推导 `ingress`(勾选要暴露的域名),生成后一键下发并重启;末尾自动补 catch-all。
9. 前端补齐:状态卡、日志页签、配置预览(脱敏)、与 VPS 实际文件的漂移检测(沿用「配置漂移」的交互)。
10. Access(Zero Trust)相关只做**诊断提示**(例如「此域名未启用 Access 保护」),不代配置策略。
### 阶段 4(可选,需单独评估)
11. 接 Cloudflare API 自动建隧道与 DNS:需要 API Token(权限 `Account: Cloudflare Tunnel: Edit` + `Zone: DNS: Edit`)+ Account ID + Zone ID。**这是第二个凭据面**,收益是省掉几步手工操作,代价是多一份高权限凭据要保管——建议等前面稳定后再评估,并且只在用户显式开启时启用。
---
## 七、风险与边界(务必写进功能说明)
**条款与合规**
- Tunnel 本身免费、不限条数/带宽,但 Cloudflare 的 CDN 条款限制「用 CDN 提供视频与其他大文件」(非 Enterprise 需购买 Stream/Images 等付费服务)。社区里这条措辞有过调整,但仍应视为风险:**用 CF Tunnel 做影音/大文件分发不在我们的推荐用法内**,界面上要提示。
- TryCloudflare(快速隧道:`cloudflared tunnel --url http://localhost:PORT`)官方明确「不保证 SLA 与可用性,仅供测试与开发」,域名随机、每次重启变化——**不作为正式功能**,最多做成「临时演示」按钮。
**网络可用性**
- 中国大陆访问 CF 边缘的延迟与稳定性不稳定,这对 Havline 的用户群是**现实问题**:建议在文档与界面上对比说明「CF 适合海外访问优先的场景,国内链路以 frp + 自持 VPS 更可控」。
- 出站 only 意味着 VPS 上不需要任何放行端口,但也就**读不到**「CF 侧有哪些域名指向了这条隧道」——相关校验只能靠 `cloudflared` 自身命令,不能靠 agent。
**实现边界**
- **不做 remotely-managed 的规则生成**(会被云端覆盖,见 §三)。
- **不代配 Access 策略**(只提示状态)。
- **不改现有 frp 链路**:CF Tunnel 是并存出口;`internal/frp` 的规则、健康、状态页语义保持不变。
- **不做通用 TCP 出口**:需要访问端装 `cloudflared` 的场景超出「NAS 面板」的定位,只做说明。
- 凭据全部走 `secret.Box` 加密 + 掩码回显;日志与 API 响应绝不出现明文 token(沿用 `maskedToken` 与「预览脱敏」约定)。
---
## 九、参考实现分析:`cf-tunnel-manager`(用户自有项目,Gitee `li-hua-88/cf-tunnel-manager`)
> 本节是 2026-09-25 的 v1 分析。2026-09-28 已对照 1.0.1 Linux 源码逐文件复核,补充与修正见 §十二。
> 项目:**Tunnel-Cloudflared-Manager** v1.0.1,Rust + Actix-web + Tokio + Serde,前端原生 JS + Tailwind(`static/`),JSON 文件存储,MIT;Linux systemd(`tcm`/`cftm` 运维脚本)+ Docker 两套交付。
> 本节结论来自逐个读到源码的模块:`src/services/{tunnel,binary,cloudflare_api}.rs`、`src/models/tunnel.rs`、`src/utils/{config_helper,paths}.rs`、`Cargo.toml`。
### 9.1 它的定位与能力
| 维度 | 它做了什么 |
| --- | --- |
| 隧道类型 | **命名隧道**(token 或 config-file 启动)+ **快速隧道**(`--url`) |
| 进程管理 | `TunnelProcessManager`:`HashMap<名称, Child>` + 独立的 `login_process` + 快速隧道 URL 缓存 |
| 日志 | `tokio::process` 逐行读 stdout/stderr → **WebSocket 广播**(`tunnel-log` 事件),前端实时滚动 |
| 指标 | 每 2 秒抓 `cloudflared` 的 Prometheus 端点 → 同样用 WebSocket 广播(`tunnel-metrics`) |
| 二进制 | `src/services/binary.rs` 自管下载/替换/检测,不要求用户预装 |
| 账号模式 | `--origincert` + `tunnel create` + `tunnel route dns`,把生成的 `creds.json` 搬进自己的 `data/tunnels/<名>/` |
| 云端同步 | `src/services/cloudflare_api.rs` 能按 tunnel **拉取/推送** ingress 配置(`fetch_tunnel_config` / `push_tunnel_config`) |
| 登录与安全 | JWT + bcrypt 管理员账号,默认 `admin`/`admin666`(首次登录强制改密) |
| 网络调优 | `protocol`(auto/http2/quic,Windows 或检测到代理时回落 http2)、`ha-connections`、`edge-ip-version`、`originRequest`(`caPool`/`noTLSVerify`/`httpHostHeader`)、代理环境变量开关 |
### 9.2 值得直接借鉴的六个做法
**① token 可以解码成凭据,于是 token 模式也能本地管规则**
`initialize_token_tunnel` 把 CF 隧道 token 解成 `{AccountTag, TunnelID, TunnelSecret}` 并写出 `creds.json` + `config.yml`——即「用户只给一个 token,但规则仍由面板本地生成」。这直接提升了 Havline 方案的上限(§三 的补充已按此修正)。
**② metrics 端口不去猜,从日志里读出来**
命名隧道启动时**故意不传 `--metrics`**,让 cloudflared 用默认端口段(20241-20245),然后从 stderr 解析 `INF starting metrics server on 127.0.0.1:65432/metrics` 拿到真实端口——天然避开端口冲突(Havline 现在给 frps dashboard 固定端口,多实例时会撞)。
**③ `localhost` 必须归一化成 `127.0.0.1`**
`add_tunnel_endpoint` 里把 `localhost` 换成 `127.0.0.1`,注释写明「避免 Cloudflare 将其转换为本地网络 IP」。这是 CF 侧的真实坑,Havline 生成 `ingress` 时要照做。
**④ 始终保留最后一条 catch-all**
新增端点插在「数组倒数第一位之前」(`insert_pos = len - 1`),末条永远是 `service: http_status:404`。Havline 改 `ingress` 时必须沿用这个不变式,否则配置校验不过。
**⑤ 带宽指标:官方 metrics 没有 HTTP/2 字节数,它做了系统级兜底**
- QUIC 下直接读 `quic_client_receive_bytes` / `quic_client_sent_bytes` / `quic_client_smoothed_rtt`。
- HTTP/2 下官方无字节数,改用**按进程采 TCP 连接字节**:Linux 走 netlink `SOCK_DIAG_BY_FAMILY` + `/proc/<pid>/fd` 取 socket inode;Windows 走 `GetExtendedTcpTable` + `GetPerTcpConnectionEStats`(需要管理员,失败时给 `windows_estats_requires_admin` 降级原因)。
- 并且**只统计公网远端 IP**(过滤私有/回环/链路本地/`100.64/10` CGNAT),延迟兜底用**主动 HEAD 探测** ingress 里的公网 hostname(带 `?__cf_probe=<nonce>` + no-cache)。
> 对 Havline 的意义:这一块可以**先不做**(我们已有规则级流量与可用率),但「降级要给出明确原因而不是空白」这个原则要学。
**⑥ Windows 下起子进程要隐藏控制台窗口**
`creation_flags(CREATE_NO_WINDOW)`(`0x08000000`)。Havline 本机在 Windows 上跑,托管 `cloudflared.exe` 时必须带上,否则会闪黑窗。
### 9.3 与 Havline 的差异(决定「借鉴什么、不借鉴什么」)
| 维度 | cf-tunnel-manager | Havline 该怎么做 |
| --- | --- | --- |
| 职责 | **专做** Cloudflare Tunnel 的一个独立面板 | 把 CF 作为**第二条出口**并入现有规则/健康/通知/状态页体系 |
| 存储 | JSON 文件(`data/*.json`)+ `data/tunnels/<名>/` | 继续用 SQLite + 迁移;凭据进 `secret.Box` 加密字段 |
| 规则来源 | 面板自己的 ingress 列表(+ 云端同步) | 从**本机已有反代规则**推导(同源生成),避免第二份手工维护 |
| 健康 | metrics 端点 scraping + 主动探测(仅前端展示) | 接入 `internal/monitor` 的 `Source`/`HealthRecorder`,落库可用率 + 复用告警与通知 |
| 实时日志 | WebSocket 广播(专为「看着它滚」设计) | 沿用现有「读日志文件 + 日志页」模型(与 frpc 一致,无需引入 WS) |
| 多实例 | 多隧道并行(每隧道一个子进程) | 同模;但用 SQLite 建 `tunnels` 表而不是内存 HashMap |
| 认证 | 自带 JWT + 账号体系 | 复用 Havline 现有会话 + API Token |
### 9.4 对方案本体的修订建议
1. **方案 A 的第一阶段就加「凭据导入」两条路**:`token`(解码出 creds.json)与 `cert.pem + credentials JSON`(账号模式),二者最终都落到「本地 `config.yml` 由 Havline 生成」。
2. **阶段 2 把「metrics 端口从日志发现」写成实现约定**,不要固定端口。
3. **阶段 3 增加「云端 ↔ 本地同步」两个动作**(拉取 / 推送),并在界面上标明当前生效侧——这是参考项目比原方案更完整的地方。
4. **`ingress` 生成器锁定三条不变式**:末条 catch-all、`localhost` → `127.0.0.1`、插入位置在 catch-all 之前;并补一个「生成后自检」的纯函数测试(参照本仓 `maskFRPSSecrets` 的测试写法)。
---
## 十、IP 优选:能不能做、能做到哪一步
### 结论一句话
**隧道侧(本机 `cloudflared` → CF 边缘)可以优选,访问侧(浏览器 → CF 边缘)不行。** 而国内用户最在意的那一段延迟往往在访问侧——所以不要把它宣传成「提速神器」,它的收益是**可控但有限**的。
### 隧道侧:三条可用手段(已核对官方文档与变更日志)
| 手段 | 具体做法 | 适用与代价 |
| --- | --- | --- |
| **选协议** | `--protocol quic` / `http2` | 国内 QUIC(UDP 7844)常被 QoS 掉,`http2`(TCP 7844)反而稳;这是**成本最低、收益最直接**的一项 |
| **选地址族** | `--edge-ip-version 4` / `6` / `auto`,或环境变量 `TUNNEL_EDGE_IP_VERSION` | ⚠️ **2026.4.0 起默认值由 `4` 改成 `auto`**(官方 CHANGES 里的 Breaking Change)——`auto` 会跟着系统解析器走,国内环境若 IPv6 路由差就可能反而变慢。**建议 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」的通行做法 |
**优选时两条不能错的约束**:
1. **两个 region 都要覆盖**。官方预检文档要求健康部署有**四个活跃 replica、同时连上 `region1` 与 `region2`**(或对应地区域名)——只改其中一个域名,另一半连接会跑到未优选(乃至不通)的路径上,净效果可能是变差。
2. **IP 列表不能硬编码**。CF 改过端点与 hostname(例:`proxy-dns` 整条功能已在 2026.2.0 被移除),优选表要么给默认值 + 可配置,要么每次从官方 DNS 拉候选集再测。
关于实例数:`--ha-connections` 决定每个 region 开几条连接,也是「多路择优」的天然手段(参考实现 §9.1 已把它做成设置项)。
### 访问侧:Tunnel 模式下做不到
用 CF Tunnel 时,公域域名必须 CNAME 到 `<tunnel-uuid>.cfargotunnel.com` 并且**开启代理(橙云)**——解析结果由 CF 决定,**用户无法自选边缘 IP**。社区那套「测速 + 自选 IP」之所以常见,是因为他们用的是「CNAME 到优选 IP + 关闭橙云 + 自己回源」的方案;那**恰好是 Tunnel 不能走的路**(关掉代理就没有 Tunnel 了)。
也就是说:换域名解析 / 让访客改 hosts 都不现实,**访问侧这一段在 Tunnel 架构下就是交给 CF 的**。
### 对 Havline 方案的落点
| 阶段 | 做什么 | 依据 |
| --- | --- | --- |
| 阶段 2(建议直接做) | 「网络调优」三项做成设置项:`protocol`(默认 http2)、`edge-ip-version`(**默认 4**)、`ha-connections`;以命令行参数下发 | 参考实现已验证可行,且改一项就能治国内 QUIC 被打压的场景 |
| 阶段 3(可选) | 「边缘优选」工具:并发探测官方 IPv4 段的 **TCP 7844 握手延迟** → 生成「两个 region 各 1~2 个优选 IP」的 hosts 建议;**页面展示而不是代写 hosts**(Windows 改 hosts 需管理员,Havline 不应越权),并提供「复制这段内容」+ 生效检测 | 沿用本仓「不越权、降级要说明原因」的口径 |
| 阶段 3(可选) | 优选前后对比:用**已有的** `metrics_samples` 延迟/可用率序列展示效果,而不是另造一套测速图 | 复用 §四 的健康与指标链路 |
> metrics 端口发现、健康判定与网络调优默认值见 §12.5 / §12.7;本节只讨论「本机 → CF 边缘」的优选。
### 必须写进功能说明的三句实话
1. 优选只影响「本机 → CF 边缘」这一段;访客到自己那侧 CF 边缘的那段,Havline 与用户都控制不了。
2. 所以它**不是**「翻墙提速」类工具:国内访问 CF 的稳定性受链路与运营商影响,优选只能把「本机出口到边缘」这一段挑好。
3. 官方端点会变(见上),因此把所有 IP/域名做成**可配置 + 有默认值 + 可一键恢复默认**。
---
## 十一、给用户的一句话结论(可直接对外)
> Havline 现在支持两类公网出口:**frp**(自持 VPS,任意 TCP/UDP,完全自主)与 **Cloudflare 隧道**(无需公网 IP 与 VPS,HTTP 类最省事,自带 CF 防护)。
> 要裸 TCP 端口、要完全自主,用 frp;要让内网 Web 服务零端口暴露地上公网,用 Cloudflare 隧道。
> Cloudflare 隧道还提供网络调优(协议 / 地址族 / 连接数)与**边缘 IP 优选**(仅作用于本机到 CF 边缘这一段,访客侧无法自选)。
---
## 十二、方案优化 v2(结合 Tunnel-Cloudflared-Manager 1.0.1 与 Havline 1.5.4)
> 本节是 2026-09-28 的优化版落地口径。§一~§十一 保留为背景与论证;出现冲突时以本节为准。
> 实施状态(2026-09-28,v1.6.0):阶段 1–2 已实施,包含 token-local / token-remote 两种模式、token 解码与 creds.json / config.yml 生成、ingress 生成器、SQLite 存储、cloudflared 二进制下载、进程托管、日志、metrics 端口发现与健康抓取、`/api/cloudflare/*` 接口与前端页面。阶段 3 的账号模式(cert.pem)与云端 fetch / push、阶段 4 的边缘优选与快速隧道尚未实施。
### 12.1 源码核对新增的关键事实
逐文件核对 `F:\WWW\cf\Tunnel-Cloudflared-linux` 后,以下事实会直接改变实施细节:
| 事实 | 源码位置 | 对方案的影响 |
| --- | --- | --- |
| token 解码后的 JSON 字段是 `a` / `t` / `s`,兼容 `AccountTag` / `TunnelID` / `TunnelSecret`;使用标准 base64 并自动补 `=` | `src/services/cloudflare_api.rs` 的 `decode_tunnel_token` / `base64_decode` | token 模式也能本地生成 `creds.json`,从而按 locally-managed 跑本地规则 |
| `cert.pem` 内含 `-----BEGIN ARGO TUNNEL TOKEN-----` 块,解出 `accountID` + `apiToken` / `serviceKey` | 同文件 `parse_cert_pem` | 只有账号模式持有云端 API 凭据,才能 push / fetch 云端 ingress |
| `start_tunnel` 在 token 非空时优先用 `--token` 启动,即使已经生成了本地 `config.yml` | `src/services/tunnel.rs` 的 `start_tunnel` | **本地规则会被云端覆盖**;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 端口必须从日志发现,不能固定,否则多实例会撞端口 |
| 健康用 `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` | Havline 必须做哈希 / 版本校验,并统一镜像表 |
| 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` | Havline 在 Windows 托管 `cloudflared.exe` 时必须带,否则闪黑窗 |
| 快速隧道从 stderr 解析 `*.trycloudflare.com` URL 并缓存 | `start_quick_tunnel` | 只能做「临时演示」;URL 每次重启都会变 |
| 存储是 JSON 文件 + 内存 `HashMap<String, Child>` | `src/storage/config.rs`、`TunnelProcessManager` | Havline 用 SQLite + 迁移,多实例与并发状态更稳 |
### 12.2 部署位置:原方案最大的空白
原方案默认「本机跑 cloudflared」,但没有回答一个关键问题:**Havline 是容器化部署,容器里的 `127.0.0.1` 不是宿主机。**
Havline 的运行时(`Dockerfile`)在同一个容器里装了主程序与 nginx,`docker-compose.yml` 只映射 `6893` / `80` / `443`。因此 v2 的默认拓扑是:
> **cloudflared 跑在 Havline 容器内,与 nginx 同命名空间;CF ingress 统一指向 `http://127.0.0.1:<nginx_http_port>`(默认 80),由 nginx 再转发到上游。**
这样做有四个直接收益:
1. 绕开容器网络命名空间的坑:`127.0.0.1` 在容器内确定指向 Havline nginx,而不是宿主机或上游。
2. 复用现有 nginx 的安全、限流、geo、TLS、错误页与 Basic Auth,不在 CF 侧重复一套策略。
3. 规则同源:CF 只负责「域名 → Havline nginx」,nginx 负责「域名 → 上游」;本机反代规则仍是唯一事实来源。
4. 只有 HTTP / HTTPS 规则能进 CF;TCP / UDP 规则明确排除,避免用户误解。
三种拓扑的取舍:
| 拓扑 | cloudflared 位置 | ingress 目标 | 适用 |
| --- | --- | --- | --- |
| **容器内(默认)** | Havline 容器 | `http://127.0.0.1:80`(容器内 nginx) | 绝大多数部署;规则先接入本机 nginx |
| host 网络 | Havline 容器 `network_mode: host` | `http://127.0.0.1:80`(宿主机 nginx) | 需要直接访问宿主机端口、或已有宿主机 nginx |
| VPS Agent(可选) | VPS 上的 `havline-agent` 托管 | `http://127.0.0.1:<frps vhost 端口>` | 已有 VPS、只想让部分域名走 CF 的高级用户 |
部署注意事项:
- cloudflared 是**出站 only**,不要为它额外映射任何入站端口。
- 二进制、`creds.json`、`cert.pem`、`config.yml`、日志都必须落在 `/data` 持久卷下,否则容器重建即丢失。
- 如果要暴露宿主机或内网服务,推荐先把该服务接入 Havline 的本机 nginx 反代,再让 CF 指向 nginx;不要在 CF ingress 里直接写宿主机 IP。
- host 网络模式只作为显式选项,不默认开启;开启后 `127.0.0.1` 的语义会变化,UI 必须提示。
### 12.3 凭据与模式:显式区分「谁在生效」
参考实现证明了 token 可以解码,但也暴露了一个陷阱:**token 启动与本地 `config.yml` 是互斥的**。v2 把模式拆成三种,UI 必须让用户明确选择并显示当前生效侧:
| 模式 | 凭据 | 启动方式 | 规则来源 | 云端 fetch / push |
| --- | --- | --- | --- | --- |
| **A. token + 本地规则** | token | 解码 token → 写 `creds.json` → `tunnel --config config.yml run` | Havline 生成的 `config.yml` | ❌(token 不含 API 凭据) |
| **B. token + 云端规则** | token | `tunnel run --token <token>` | Cloudflare Dashboard / 云端 | ❌ |
| **C. 账号 + 本地规则 + 云端同步** | `cert.pem` + credentials JSON | `tunnel --config config.yml run` | 本地,可与云端互相同步 | ✅ |
实施口径:
- 模式 A 绝不传 `--token`,只用 `--config`,否则本地规则不生效。
- 模式 B 必须在 UI 明确写「规则由 Cloudflare 面板管理,Havline 的本地预览不生效」,不要让用户以为 Havline 生成的规则已经下发。
- 模式 C 才提供「从云端拉取」「推送到云端」两个动作;token 模式不提供,只给 Dashboard 指引。
- 所有凭据走 `secret.Box` 加密字段;预览一律掩码,下发时用原文。`creds.json` 权限 `0600`,`config.yml` 权限 `0640`,目录 `0750`。
- 状态接口返回 `effective_side: local | cloud`,前端常驻显示,切换模式时要求二次确认。
### 12.4 数据模型与目录布局(Havline 版)
参考实现用 JSON 文件 + 内存 HashMap,适合单进程小工具;Havline 已有 SQLite 与迁移体系,v2 建议落成表:
`cf_tunnels`:
| 字段 | 说明 |
| --- | --- |
| `id` / `name` | 主键与用户可见名称 |
| `mode` | `token-local` / `token-remote` / `account-local` |
| `tunnel_id` / `account_tag` | 从 token 或 credentials 解析 |
| `credentials_enc` | token 或 credentials JSON 密文 |
| `cert_pem_enc` | 账号模式 `cert.pem` 密文 |
| `api_token_enc` | 可选的云端同步 API Token 密文 |
| `config_path` / `creds_path` / `log_path` | 运行文件路径 |
| `binary_version` | 当前使用的 cloudflared 版本 |
| `network_json` | protocol / edge-ip-version / ha-connections / proxy_mode / originRequest |
| `status` / `last_error` | `stopped` / `starting` / `connected` / `reconnecting` / `failed` |
| `metrics_port` | 从日志发现的端口,仅运行时缓存 |
| `auto_start` | Havline 启动时是否自动拉起 |
目录布局:
```text
<DataDir>/cloudflared/
bin/<version>/cloudflared
tunnels/<id>/
config.yml
creds.json
cert.pem
cloudflared.log
```
多实例 = 多目录 + 多子进程 + 多日志文件;快速隧道单独用 `mode=quick` 记录,`public_url` 只在运行期有效。
### 12.5 进程与二进制管理
建议新建 `internal/cloudflared` 包,沿用 `internal/frp` 的 runtime / manager 模式,但不直接耦合 frp 模型:
**二进制**
- 统一镜像表:`official` / `gh-proxy-org` / `gh-proxy-hk` / `gh-proxy-cdn` / `gh-proxy-edgeone` + 自定义 URL 模板;修正参考项目「镜像列表与实现不一致」的问题。
- 下载校验:优先官方 checksums(若提供);否则至少校验文件大小、可执行性、`cloudflared --version`;镜像源与官方源使用同一份哈希。
- 版本目录 + 原子替换 + 激活指针,复用 `internal/frp/runtime.go` 的 `readActiveBinary` / `writeActiveBinary` 思路。
- 支持 linux amd64 / arm64、windows amd64、darwin amd64 / arm64;Havline 侧用 `runtime.GOOS/GOARCH` 选择。
**进程**
- `exec.Command` + stdout / stderr 逐行读取,同时追加写入 `<tunnel>/cloudflared.log`。
- Windows 设置 `CREATE_NO_WINDOW`(`0x08000000`)。
- 启动参数:
- 模式 A / C:`tunnel --config <config.yml> run`
- 模式 B:`tunnel run --token <token>`
- 快速隧道:`tunnel --metrics 127.0.0.1:0 --url <target>`
- 网络参数:`--ha-connections`、`--origin-ca-pool`、`--no-tls-verify`、`--http-host-header`;环境变量 `TUNNEL_TRANSPORT_PROTOCOL`、`TUNNEL_EDGE_IP_VERSION`。
**metrics 端口发现**
- 命名隧道不传 `--metrics`;快速隧道传 `--metrics 127.0.0.1:0`。
- 从 stderr 用正则解析:`starting metrics server on 127\.0\.0\.1:(\d+)/metrics`。
- 解析到端口后轮询 `http://127.0.0.1:<port>/metrics`,最多等待 60 秒;超时给出明确原因,不留下空白状态。
**健康判定**
- `/ready` 返回 200:cloudflared 已就绪。
- `cloudflared_tunnel_ha_connections > 0`:已连上边缘。
- 延迟:QUIC 用 `quic_client_smoothed_rtt`;HTTP/2 用 `cloudflared_rpc_client_latency_secs` 或主动 HEAD 探测。
- 带宽:QUIC 直接用 `quic_client_receive_bytes` / `quic_client_sent_bytes` 差值;HTTP/2 官方没有字节指标,Havline 第一轮明确降级为「不展示带宽」并给出原因,不做参考项目那套进程 socket 统计。
---
### 12.6 ingress 生成器(与 nginx 规则同源)
输入:
- 本机反代规则中启用且属于 HTTP / HTTPS 的域名;
- Havline nginx 的容器内入口端口(默认 `80`,可用配置覆盖);
- 用户勾选要暴露的域名集合。
输出:`config.yml` 的 `ingress` 数组。三条不变式必须写进生成器并用纯函数测试锁定:
1. 末条永远是 `- service: http_status:404`;
2. `localhost` 一律归一化为 `127.0.0.1`;
3. 新规则插在 catch-all 之前,且同域名去重、排序稳定。
与 nginx 的关系:
```text
访客 → Cloudflare 边缘 → cloudflared(Havline 容器)→ 127.0.0.1:80(Havline nginx)→ 上游
```
- CF 侧不重复配置限流、geo、TLS、Basic Auth;这些仍由本机 nginx 负责。
- 只允许 HTTP / HTTPS 规则进入 CF ingress;TCP / UDP 规则在界面上明确标为「Cloudflare 隧道不支持」。
- 生成后提供「配置预览(脱敏)」与「生成后自检」,自检至少覆盖三条不变式与空 ingress 场景。
### 12.7 网络调优(默认值与下发方式)
| 设置 | Havline 默认 | 下发方式 | 说明 |
| --- | --- | --- | --- |
| `transport_protocol` | `http2` | 环境变量 `TUNNEL_TRANSPORT_PROTOCOL` + `config.yml` 的 `protocol` | 国内 QUIC(UDP 7844)常被 QoS,TCP 更稳;提供 `auto` / `quic` |
| `edge_ip_version` | `4` | 环境变量 `TUNNEL_EDGE_IP_VERSION` + `config.yml` 的 `edge-ip-version` | 2026.4.0 起官方默认改为 `auto`,国内 IPv6 路由差时反而变慢;提供 `auto` / `6` |
| `ha_connections` | `2` | `--ha-connections` + `config.yml` 的 `ha-connections` | 每个 region 的连接数,1–4 可调 |
| `proxy_mode` | `system` | 启动时按需移除 / 保留 `HTTP_PROXY` 等环境变量 | `disabled` 时清空代理变量;检测到代理或 Windows 时协议回落 `http2` |
| `originRequest` | 空 | `--origin-ca-pool` / `--no-tls-verify` / `--http-host-header` | 仅高级用户;默认不回源 HTTPS,避免自签证书问题 |
诊断接口返回:平台、代理环境探测结果、实际协议、实际代理模式、建议。降级必须给出原因,例如 `windows_estats_requires_admin`、`http2_official_metrics_unavailable`。
边缘优选(阶段 3 可选):
- 只做「本机 → CF 边缘」的 TCP 7844 延迟探测与展示,不代写 hosts;
- 两个 region 都要覆盖,IP 列表可配置、有默认值、可一键恢复默认;
- 访客到自己那侧 CF 边缘的一段无法优选,功能说明必须写清楚。
### 12.8 云端同步(仅账号模式)
账号模式(`cert.pem`)才提供云端同步,API 细节:
- 拉取:`GET https://api.cloudflare.com/client/v4/accounts/{account_id}/cfd_tunnel/{tunnel_id}/configurations`
- 推送:`PUT` 同一路径,body 为 `{"config":{"ingress":[...]}}`
- 推送的 ingress 必须包含末条 catch-all,否则 Cloudflare 可能拒绝或行为不确定。
冲突处理:
- UI 常驻显示 `effective_side: local | cloud`;
- 「拉取到本地」会覆盖本地 `config.yml`,操作前备份当前版本(复用本仓 `fsutil.BackupVersioned`);
- 「推送到云端」会覆盖云端 ingress,操作前展示 diff 并二次确认;
- 两个动作都写审计日志(操作者、隧道、方向、结果)。
token 模式不提供 push / fetch,只给 Dashboard 指引;这是凭据能力的硬边界,不要用「同步失败」这类模糊提示掩盖。
### 12.9 与 Havline 现有模块的集成点
| Havline 模块 | 复用还是新增 | 具体落点 |
| --- | --- | --- |
| 二进制下载 / 启用 | 新增 `internal/cloudflared`,参考 `internal/frp/runtime.go` | 镜像表、哈希校验、版本目录、激活指针 |
| 进程托管 | 新增 `internal/cloudflared`,参考 `internal/frp/manager.go` | 启停、日志追加、退出原因、`tailFile` |
| 健康与可用率 | 复用 `internal/monitor` 的 `Source` / `HealthRecorder` | 新增 CF Tunnel Source;`/ready` + `ha_connections` |
| 通知 | 复用 `internal/notify` | 隧道断开 / 恢复事件 |
| 状态页 | 复用现有公开状态页 | 新增隧道条目与可用率 |
| 规则来源 | 复用 `internal/proxy` 的本机反代规则 | 只取 HTTP / HTTPS 域名,生成 ingress |
| 日志界面 | 复用现有日志页模型 | 读 `<tunnel>/cloudflared.log`,不引入 WebSocket |
| 凭据加密 | 复用 `internal/secret` | token / cert.pem / credentials JSON 全部加密 |
| frp 链路 | **不改** | CF Tunnel 是并存出口,不碰现有 frp 规则、健康与状态语义 |
### 12.10 修订后的阶段计划
| 阶段 | 交付物 | 验收标准 |
| --- | --- | --- |
| 阶段 1:凭据与配置 | 凭据导入(token / cert.pem)、模式选择、`creds.json` + `config.yml` 生成、脱敏预览 | 三种模式都能生成文件;模式 B 明确提示本地规则不生效;凭据不明文回显 |
| 阶段 2:进程与健康 | 二进制下载 / 启用、进程启停、日志、metrics 端口发现、`/ready` + 连接数健康 | 启动后能连上边缘;停止后进程与端口释放;健康状态落库并进通知 |
| 阶段 3:规则同源与云端同步 | 从本机反代规则生成 ingress、漂移检测、账号模式 fetch / push、网络调优设置 | ingress 三条不变式测试通过;云端同步有 diff 与审计;网络参数可配置 |
| 阶段 4(可选) | 边缘优选、快速隧道、高级 originRequest | 优选只展示不越权;快速隧道明确标注「临时演示」 |
### 12.11 测试与验收
单元测试:
- token 解码:标准 base64、缺填充、非法输入、`a/t/s` 与 `AccountTag/TunnelID/TunnelSecret` 两种字段名;
- ingress 生成:catch-all、`localhost` 归一化、插入位置、去重、空规则;
- metrics 解析:端口行、QUIC / HTTP/2 指标、连接数、降级原因;
- 网络参数构建:protocol / edge-ip-version / ha-connections / proxy_mode;
- 镜像 URL 与自定义模板;
- 凭据脱敏:token、cert.pem、credentials JSON 不出现在预览与日志。
集成测试:
- 启动 / 停止 / 重启 / 自动启动;
- metrics 端口发现与健康状态流转;
- 日志追加与 tail;
- 云端 fetch / push 用 mock API 验证请求体、catch-all 与错误处理;
- 容器内 `127.0.0.1:80` 回源链路。
前端:
- 隧道列表 / 详情 / 状态 / 日志 / 配置预览;
- 模式选择与「当前生效侧」提示;
- 脱敏预览与二次确认。
真机矩阵:Linux amd64 / arm64、Windows、Docker 容器内、host 网络、代理环境、国内与海外链路。
### 12.12 风险与边界(补充)
- **容器网络**:默认拓扑是容器内 cloudflared → 容器内 nginx;不要假设 `127.0.0.1` 是宿主机。
- **token 覆盖**:模式 B 的本地 `config.yml` 不生效;UI 必须明确,模式 A 才能本地管规则。
- **HTTP/2 带宽**:官方 metrics 没有字节指标;第一轮明确降级并说明原因,不做进程 socket 统计。
- **Windows**:`CREATE_NO_WINDOW` 必带;若做进程级字节统计还需要管理员权限,Havline 第一轮不做。
- **CF 条款**:视频 / 大文件分发不在推荐用法内;TryCloudflare 不保证 SLA。
- **国内链路**:CF 边缘延迟受运营商影响;优选只影响本机到边缘一段。
- **API 凭据**:账号模式的 `cert.pem` 与可选 API Token 都是高权限凭据,必须最小化权限并加密存储。
- **Access**:只做诊断提示,不代配策略。
- **TCP / UDP**:CF Tunnel 不是通用 TCP 出口;需要访问端装 cloudflared 的场景不在首轮范围。
- **不改 frp**:现有 frp 链路、健康与状态页语义保持不变。
### 12.13 结论
Cloudflare Tunnel 对 Havline 是**第二条出口**,不是 frp 的替代:它补上「没有公网 IP、也不想长期租 VPS」的 HTTP 类场景。v2 的核心修订是四件事:
1. cloudflared 跑在 Havline 容器内,ingress 统一指向容器内 nginx,绕开容器网络坑;
2. 显式区分「本地规则生效」与「云端规则生效」,不让 token 模式悄悄覆盖本地配置;
3. metrics 端口从日志发现,健康用 `/ready` + `ha_connections`,HTTP/2 带宽明确降级;
4. 二进制下载补哈希 / 版本校验,ingress 生成器锁定三条不变式。
按 §12.10 的阶段 1–3 实施即可形成可用闭环;阶段 4 的边缘优选与快速隧道作为可选增强,不阻塞主线。
---
> 说明:本文只做方案分析,未改动任何代码。实施时沿用本仓库既有约定:每步验证 `go build/vet/test` + `vue-tsc/vite build`、agent 行为变更递增 `AgentVersion`、提交同批更新 `CHANGELOG` 与 `VERSION`、密钥相关内容必须过一遍「预览脱敏 / 下发原文」两头检查。
-203
View File
@@ -1,203 +0,0 @@
# Havline 容器部署与数据指南
> 适用版本:v1.2.19 及以后(Docker / 飞牛 fnOS 等 NAS 环境)
> 相关文件:`docker-compose.yml`、`docker-compose.hub.yml`、`docker-compose.host.yml`、`Dockerfile`、`scripts/upgrade.sh`
---
## 一、三个编排文件怎么选
| 文件 | 构建方式 | 适用场景 |
| --- | --- | --- |
| `docker-compose.hub.yml` | **从 Git 仓库拉取源码**构建(`build.context` 是仓库地址) | **干净机器首选**:只要这一个文件,不需要先 `git clone` |
| `docker-compose.yml` | 用**本地源码**构建(`context: .`) | 已经有源码目录(改代码后直接重建最方便) |
| `docker-compose.host.yml` | 本地源码构建 + `network_mode: host` | 80/443 已被别的服务占用(如飞牛、群晖自带反代) |
三份都写了 `name: havline`:**不要删掉这行**。不写它,改了目录名/项目名后 compose 会把它当成新项目,`havline-data` 变成新的空卷——现象就是「数据全没了」。
---
## 二、干净机器部署(推荐路径)
```bash
mkdir -p /opt/havline && cd /opt/havline
# 从仓库取这一份编排文件(用你仓库页面上的「原始文件」链接,或 git clone 后复制)
# curl -fsSLo docker-compose.hub.yml <仓库里该文件的原始地址>
docker compose up -d --build
docker compose logs -f havline # 首次启动会初始化数据库、生成会话密钥
```
首次访问 `http://<机器IP>:6893`,用日志里的初始口令登录。初始口令只在**首次启动**生成一次:
```bash
docker compose logs havline 2>&1 | grep -i password | tail -n 3
```
80/443 被占用的环境改用 host 那份:
```bash
COMPOSE_FILE=docker-compose.host.yml docker compose up -d --build
# 反代端口默认 18080/9443(可用 HAVLINE_NGINX_HTTP_PORT / HAVLINE_NGINX_HTTPS_PORT 覆盖)
```
---
## 三、数据存在哪
数据**不在镜像里**,全部在数据目录中(容器内固定为 `/data`,由 `HAVLINE_DATA_DIR` 指定):
| 路径 | 内容 |
| --- | --- |
| `havline.db` | SQLite 主库:反代规则、穿透规则与服务端、DDNS、证书记录、设置、API Token、健康事件、指标历史 |
| `session_secret` | 会话与**凭据加密密钥**(v1.2.17 起未配置时自动生成,0600) |
| `logs/` | `app.log`(应用日志)、`access.log` / `error.log`(本机 Nginx) |
| `certs/` | 证书与私钥 |
| `nginx/` | Havline 生成的 Nginx 配置与 pid |
| `frp/` | frpc 二进制、各服务端的 `frpc-server-N.toml` 与日志 |
| `ssh_known_hosts` | SSH 主机密钥 TOFU 记录 |
| `backups/` | 应用内「数据备份」导出的归档 |
### 3.1 默认:命名卷
默认挂在命名卷 `havline-data`(compose 项目名是 `havline`,所以完整卷名是 `havline_havline-data`),由 Docker 管理,位置随 Docker 数据根。
```bash
docker volume ls | grep havline
docker volume inspect havline_havline-data --format '{{.Mountpoint}}'
docker compose exec havline ls -l /data # 容器内视角,最直观
```
飞牛这类 NAS 的 Docker 数据根常在 `/vol1/docker`,于是实际落在
`/vol1/docker/volumes/havline_havline-data/_data`。**以 `docker volume inspect` 为准**,各家不同。
### 3.2 指定宿主机目录(bind mount)
在同层 `.env` 里写一行即可,以 `/` 或 `.` 开头的路径会被 compose 当成 bind mount:
```bash
echo 'HAVLINE_DATA_PATH=/vol1/docker/havline-data' >> .env
```
从**已有命名卷**迁过去(迁移前先停服务):
```bash
docker compose down
sudo mkdir -p /vol1/docker/havline-data
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 exec havline ls -l /data # 确认数据还在
```
反过来(bind → 卷)同理,把两边路径对调即可。**不要**只做一边的拷贝就改配置,否则会看到「空实例」。
---
## 四、备份与恢复
**整目录备份**(推荐,含密钥与证书):
```bash
docker run --rm -v havline_havline-data:/data -v "$PWD":/backup alpine \
tar czf /backup/havline-data-$(date +%F).tgz -C /data .
```
三个必须知道的点:
1. 千万别只拷 `havline.db`:`session_secret` 是凭据的加密密钥,缺了它库里存的 frp token、通知密码都解不开;
2. 不要在服务运行时手改 `havline.db`(SQLite 单连接,容易被写坏)——要改先 `docker compose stop havline`;
3. 应用内「设置 → 数据」也有备份/恢复按钮,归档落在 `backups/`,恢复后需要重启容器。
---
## 五、升级与回滚
### 5.1 一条命令(推荐)
仓库自带 `scripts/upgrade.sh`(在部署机上执行):
```bash
sudo bash scripts/upgrade.sh # 拉代码 → 构建 → 重建 → 健康检查
sudo bash scripts/upgrade.sh --no-pull # 用当前源码重建
sudo bash scripts/upgrade.sh --rollback 1.2.18 # 回滚到本地已有的镜像 tag(不重建)
COMPOSE_FILE=docker-compose.host.yml sudo bash scripts/upgrade.sh # host 网络那份
```
脚本会打印升级前后的镜像 tag,并轮询 `http://127.0.0.1:6893/api/version` 确认起来。
### 5.2 手动
```bash
cd /opt/havline && docker compose pull 2>/dev/null; docker compose up -d --build
docker images havline # 看历史上留下哪些 tag,回滚时用
```
镜像 tag 由 `HAVLINE_VERSION` 决定(`scripts/upgrade.sh` 会从仓库 `VERSION` 自动取),不设时是 `havline:local`。
镜像是本地构建的,所以升级不需要 `docker pull`;上面那条 `up` 会带上 `--build` 重新构建。
### 5.3 只有编排文件时(`docker-compose.hub.yml` 部署,推荐路径)
构建上下文是 Git 地址,所以**每次 `--build` 都会重新解析仓库分支**(日志里那句 `load git source ... refs/heads/main` 就是它),不需要 `git pull`:
```bash
cd /opt/havline
docker compose up -d --build # 重新克隆 main → 构建 → 按新镜像重建容器
```
建议**带上版本号**,给镜像留一个固定 tag(否则一直是 `havline:local`,回滚时找不到旧版):
```bash
HAVLINE_VERSION=1.2.19 docker compose up -d --build
# 回滚:镜像还在本地,不用重新构建
HAVLINE_VERSION=1.2.18 docker compose up -d
```
版本号从仓库的 `CHANGELOG.md` 或 `VERSION` 文件取;面板左侧栏底部也会显示当前运行的版本。
这套部署方式下,`scripts/upgrade.sh` 不太适用(它需要本地源码目录来 `git pull` 与读 `VERSION`)——如果你希望也能一条命令搞定,可以把这两个文件(`scripts/upgrade.sh` + `VERSION`)也放到部署目录,或告诉我,我给 hub 变体加一个专用脚本。
### 5.4 升级前后的两件小事
- **升级前备份**(整目录,含密钥):见 §四那条 `tar` 命令;遇到异常可整个目录回滚。
- **升级后确认**:`docker compose logs -f havline` 看启动是否正常(会打印「会话密钥已就绪」),再刷新面板看版本与功能。
---
## 六、环境变量速查
| 变量 | 默认 | 说明 |
| --- | --- | --- |
| `HAVLINE_DATA_DIR` | `/data` | 容器内数据目录,一般不用改;要换位置改**挂载** |
| `HAVLINE_DATA_PATH` | `havline-data` | **宿主机侧**数据位置:命名卷名,或绝对路径(bind mount) |
| `HAVLINE_LISTEN` | `:6893` | 面板监听地址 |
| `HAVLINE_SESSION_SECRET` | 空 | 留空即自动生成并持久化;**它是凭据加密密钥,有数据后不要更换** |
| `HAVLINE_NGINX_HTTP_PORT` / `HAVLINE_NGINX_HTTPS_PORT` | `80` / `443` | 反代监听端口(host 网络那份默认 18080/9443) |
| `HAVLINE_UPDATER_SOCKET` | `/run/havline-updater/updater.sock` | 一键升级 sidecar 的 Unix Socket |
| `DOCKER_SOCK` | `/var/run/docker.sock` | 宿主机 Docker Socket;rootless Docker 或自定义路径时覆盖 |
| `TZ` | `Asia/Shanghai` | 通知与日志显示用的时区 |
| `NODE_IMAGE` / `GO_IMAGE` / `RUNTIME_IMAGE` / `UPDATER_IMAGE` | 国内镜像源 | 构建用的基础镜像,被加速器拦截时用来覆盖 |
---
## 七、常见问题
**构建报 `401 Unauthorized`(`resolve image config for docker-image://docker/dockerfile:1`)**
国内加速器(如飞牛的 `docker.fnnas.com`)会拦下 BuildKit 的 frontend 镜像。Dockerfile 已不再显式指定 `# syntax=`,正常不会再遇到;若仍报错,改用加速器能拉的地址:`# syntax=docker.m.daocloud.io/docker/dockerfile:1`,或去掉四处 `--mount=type=cache`。
**`error while interpolating ... HAVLINE_SESSION_SECRET is missing a value`**
这是旧版 compose(把该变量写成必填)留下的行为。新版本是 `${HAVLINE_SESSION_SECRET:-}`:留空即由应用自动生成。重新取一份编排文件即可。
**构建时 `havline Pulling` 之后超时告警**
有 `build:` 段时 compose 仍会先尝试拉同名镜像;`.hub.yml` 已加 `pull_policy: build` 跳过。其余两份可忽略这条警告。
**换目录后「数据没了」**
几乎没有真的丢:要么新目录是空的(迁移没做),要么项目名变了导致卷名变了。先用 `docker volume ls` 找到旧卷,按 §3.2 拷过去。
**面板能开但「今日请求/实时流量/性能」没数据**
这三张卡读的是**本机 Nginx 访问日志**(`<数据目录>/logs/access.log`),只有在 Havline 里配了本机反代规则并产生访问后才有数据;VPS 侧的流量在「公网反代」页与「日志中心 → 公网 Nginx」。
---
## 八、与原生安装的分工
不想用容器时可以用 `scripts/build-linux.ps1` 打出 `havline-<版本>-linux-<arch>.tar.gz`,在目标机用 `sudo bash install.sh` 安装(装到 `/opt/havline`,数据在 `/var/lib/havline`,systemd 单元 `havline.service`)。两者的数据目录结构完全一致,因此**可以互相迁移**:把一方的数据目录内容整个拷到另一方即可,注意带上 `session_secret`。
-297
View File
@@ -1,297 +0,0 @@
# 同类项目对标与增补建议
> 日期:2026-09-24 对象:Fonu 1.0.2(VERSION 1.2.10)
> 方法:检索 GitHub 上与本项目同域的开源项目(隧道/反代管理面板、自托管监控、Nginx 面板、DDNS 面板),逐项对照 Fonu 现状给出「可优化 / 可增补」。
> 可信度:对标项目的能力来自其首页 / README 摘要与检索片段,**未逐个深读源码**;Fonu 现状来自本仓库代码与 2026-09-24 的全项目审查(`docs/project-audit-2026-09-24.md`)。
---
## 一、对标项目
| 项目 | 定位 | 与 Fonu 的关系 |
| --- | --- | --- |
| [fosrl/pangolin](https://github.com/fosrl/pangolin) | 自托管**隧道式反代管理服务**,内置身份与访问控制 + 仪表盘 UI | 架构最接近:VPS 侧服务 + 内网侧隧道客户端 + 统一面板;差别在它主打**身份/访问控制**(SSO、资源级策略) |
| [vaala/frp-panel](https://github.com/vaala/frp-panel) | frp 的**中心化控制面板**(Go + gRPC + protobuf,React/Next + shadcn) | 同为「面板 + 多客户端」模型;它管的是多台内网 frpc,Fonu 管的是本机 frpc + 多台公网 frps |
| [henrygd/beszel](https://github.com/henrygd/beszel) | 轻量服务器监控,**hub + agent**,含历史数据、Docker 统计、告警、多用户、OAuth、API | 与 Fonu 的 app + fonu-agent 架构同型;它在「历史/告警/账号」上明显更成熟 |
| [0xJacky/nginx-ui](https://github.com/0xJacky/nginx-ui) | Nginx 的 Web 管理界面:配置**版本备份/对比/回滚**、集群、证书一键签发与续期、在线编辑、加密配置导出 | Fonu 的反代层与它重叠;它的配置版本管理值得直接借鉴 |
| [jeessy2/ddns-go](https://github.com/jeessy2/ddns-go) / [qdm12/ddns-updater](https://github.com/qdm12/ddns-updater) | DDNS 客户端:多服务商、Web UI、多记录类型、通知(ddns-updater 走 Shoutrrr,支持几十种渠道) | Fonu 的 DDNS 模块与其重叠;差距在**通知渠道数量**与记录类型覆盖 |
---
## 二、能力对照(✔ 已有 / ◐ 部分 / ✘ 无)
| 能力 | pangolin | frp-panel | beszel | nginx-ui | ddns-go | Fonu 现状 |
| --- | --- | --- | --- | --- | --- | --- |
| 面板 + 内网 agent(SSH 引导安装) | ✔ | ✔ | ✔ | ◐ | ✘ | **✔**(SSH 装 agent、HTTP 管理、TOFU 主机密钥) |
| 多台公网服务端 + 多规则类型 | ✘ | ◐ | ✘ | ✘ | ✘ | **✔**(frps 多台、8 种穿透类型、规则按服务端归属) |
| 公网反代下发 + 证书推送 | ✔ | ◐ | ✘ | ✔ | ✘ | **✔**(vhost 模板生成、证书推送、部署漂移检测、四段健康检查) |
| 配置**版本历史 / 对比 / 回滚** | ◐ | ◐ | ✘ | **✔** | ✘ | **✘**(只有单份 `.bak`) |
| 指标**历史趋势** | ◐ | ◐ | **✔** | ◐ | ✘ | **✘**(仅当前值 + 最近 5 分钟采样) |
| **阈值型告警**(CPU/磁盘/连续失败) | ◐ | ✘ | **✔** | ✘ | ◐ | **✘**(只有事件型通知) |
| 通知渠道数量 | ◐ | ◐ | ✔ | ✘ | ✔ | ◐(邮件 / Webhook / Telegram) |
| **API Token**(脚本、HomeAssistant 调用) | ✔ | ✔ | ✔ | ✔ | ✔ | **✘**(仅会话 cookie) |
| 多用户 / OIDC / 只读账号 | **✔** | ◐ | ✔ | ◐ | ✘ | **✘**(单管理员) |
| 定时自动备份 + 保留策略 | ◐ | ◐ | **✔** | ✔ | ✘ | ◐(手动导出 / 恢复) |
| Docker 容器统计 | ✘ | ✘ | **✔** | ✘ | ✘ | **✘** |
| 加密配置导出 / 迁移包 | ◐ | ✘ | ✘ | **✔** | ✘ | ◐(脱敏导出,无导入) |
---
## 三、建议增补(按价值 / 成本排序)
### P0 · 成本低、直接补齐明显短板
1. **配置版本历史 + 对比 + 回滚**(学 nginx-ui)
- 现状:`internal/fsutil.Backup` 只保留一份 `<path>.bak`;公网反代页已有「漂移检测」能看出「VPS 上的与记录不一致」,但没有历史可比。
- 做法:把 `.bak` 升级为 `<path>.versions/<时间戳>.conf`(保留 N 份,沿用现有 `pruneBackups` 思路),复用公网反代页「漂移清单」的 UI 模式做「版本列表 + 差异高亮 + 一键回滚」。
- 落点:`internal/fsutil`(新增 `BackupVersioned`)、`internal/nginx/custom.go`(已有 `backupFile`/`pruneBackups` 可复用)、`web/src/views/ProxyRemoteView.vue`。
- 价值:误改配置后可一键回到上一个可用版本,比现在的「删掉重部署」可靠得多。
2. **API Token**(学 beszel / nginx-ui)
- 现状:97 条 API 路由只有会话 cookie 一种认证;脚本、HomeAssistant、手机快捷指令都调不动。
- 做法:`api_tokens` 表(名称 + 哈希 + 权限范围 + 最后使用时间),`RequireAuth` 中间件同时接受 `Authorization: Bearer`,设置页做管理(新建时只显示一次明文,沿用现有「掩码往返」约定)。
- 落点:`internal/api/middleware.go`、`internal/auth`、`web/src/views/SettingsView.vue`。
- 注意:这是新增认证面,必须走一次安全自查(令牌哈希存储、撤销、只读范围)。
3. **通知渠道补齐国内常用通道**(学 ddns-go / ddns-updater)
- 现状:邮件 / Webhook / Telegram。`web/src/constants/notifyProviders.ts` 里已有 `DEFAULT_BARK_SERVER`、`DEFAULT_NTFY_SERVER` 常量(说明预留了位置)。
- 做法:补 Bark、ntfy、企业微信、钉钉、飞书(都是「POST 一个 URL + JSON」模式,可复用现有 Webhook 通道的发送路径)。
- 落点:`internal/notify`、`web/src/constants/notifyProviders.ts`、`SettingsView.vue`。
- 价值:NAS 用户最常用 Bark / 企业微信,成本极低。
4. **阈值型告警**(学 beszel)
- 现状:通知全是事件型(DDNS 失败、证书到期、IP 频繁访问、登录失败、Nginx 重载失败),没有「CPU 持续 90%」「磁盘 85%」「隧道连续 3 次不通」这类。
- 做法:复用 `internal/scheduler`(已有 `recover()` 与间隔校验)加一个指标巡检任务 + 冷却时间,阈值与开关走设置项(现有 `notify_*` 事件开关的模式可直接照搬)。
- 落点:`internal/notify`、`internal/scheduler`、`internal/agent`(metrics 端点已存在,v1.2.4 加的)。
### P1 · 价值高、工作量中等
5. **指标历史与趋势图**(学 beszel)
- 现状:VPS 资源、隧道流量都只有「当前值 + 最近 5 分钟内存采样」,重启即丢。
- 做法:新增 `metrics_samples` 表(按 1 分钟落点 + 保留期设置),前端加 1h / 24h / 7d 切换(ECharts 与 `MiniTrafficChart` 已在用)。
- 价值:NAS 场景最关心磁盘与带宽趋势,这是当前仪表盘最大的能力缺口。
6. **健康检查持续化 + 只读状态页**(学 uptime-kuma / beszel)
- 现状:四段健康检查是「打开页面才算一次」,页面关掉就没有数据;`internal/agent/insights.go` 已有采样能力。
- 做法:后台按间隔对每条反代规则做一轮检查并落库,连续失败 N 次才告警、恢复时再通知一次;可加一个可公开的只读状态页(按规则展示最近 24 小时可用率)。
- 注意:状态页是**新增的匿名访问面**,必须单独设计(默认关闭、可设访问码)。
7. **定时自动备份 + 保留策略**(学 beszel / nginx-ui)
- 现状:手动「导出 / 恢复」;恢复路径的校验与权限刚在 v1.2.7 收紧。
- 做法:`scheduler` 加每日备份任务(写 `<DataDir>/backups`,保留 N 份,可选 WebDAV/S3 上传),设置页给保留份数。
8. **一键迁移包(加密导出 + 导入)**(学 nginx-ui 的加密配置导出)
- 现状:只有「脱敏配置导出」(给用户看,不含密钥),换机器得手动重填服务端与证书。
- 做法:导出含服务端 / 规则 / 证书 / 设置 / 密钥的加密包(口令派生密钥,用现有 `internal/secret`),导入时校验版本并落库。
- 价值:换 NAS、重装系统、备份恢复三类场景都会用到。
9. **Docker 容器统计**(学 beszel)
- 做法:agent 加只读 `GET /api/v1/docker`(容器列表 + CPU/内存/网络),公网服务端页加一张卡(`internal/sysinfo` 已有采集框架)。
- 注意:agent 侧要显式限制只读、并处理「VPS 上没装 Docker」的降级。
### P2 · 可选
10. **只读账号 / OIDC**(学 pangolin):给家人看仪表盘、或接入已有的 SSO。属权限模型改动,建议在 API Token 落地、认证面稳定后再做。
11. **DDNS 记录类型扩展**(学 ddns-go):显式支持 AAAA / CNAME / 多记录批量,并在 UI 上区分。
12. **日志增强**:按域名 / 规则过滤、导出 CSV(现在有级别筛选、关键词搜索、分页与倒序)。
13. **规则模板与克隆**:常用规则(HTTP 反代 + Basic Auth + 限流)存为模板,新建时一键套用。
---
## 四、现有功能的优化点(对标后回看自己)
| 位置 | 可优化 | 依据 |
| --- | --- | --- |
| 健康检查 | 现在每轮 7 个接口并行(v1.2.8 已改并行)+ 15 秒轮询;可把「健康检查」单独拉长到 60 秒、其余保持高频 | beszel 对采集频率按指标分层 |
| 通知 | 事件型通知缺「冷却时间」,连续事件会连发(登录失败已有窗口统计,可推广到全部事件) | uptime-kuma / beszel 都有冷却与静默期 |
| 证书 | 续期失败后的重试与提醒策略值得复核(到期提醒已有) | nginx-ui 强调「自动续期 + 失败可见」 |
| 备份 | `internal/backup` 的写入方 `defer Close()` 不检查错误(审查报告 §二已列,P1 未做) | 与 nginx-ui 的「备份可用性」要求一致 |
| 配置生成 | 反代配置由模板生成是**优点**(保证同源),不要为了对齐 nginx-ui 而开放全文在线编辑 | 本项目刚在 v1.2.8 收敛模板重复,开放全文编辑会破坏该约束 |
| 前端 | 颜色令牌收敛(231 处)与 i18n 起步仍在待办 | 与对标项目无关,属自身债务 |
---
## 五、明确不建议跟进
1. **多租户 / 组织模型**(pangolin 面向团队):Fonu 是个人 / 家庭 NAS 工具,引入组织、成员、资源级策略会让设置页与数据模型复杂度翻倍。
2. **Nginx 配置全文在线编辑**(nginx-ui 的路子):会破坏「反代配置由模板同源生成」这一核心保证;保留现有「自定义片段」入口即可。
3. **集群 / 多节点镜像**(nginx-ui 的集群管理):Fonu 的 app + agent 已是多节点模型,再叠一层集群属于重复抽象。
4. **MCP / AI 助手接入**(nginx-ui 新功能):与项目定位不符,且每个新接口都是新的攻击面,收益不明确。
5. **把 frps 面板做成 frp-panel 那样的「多 frpc 集中管理」**:Fonu 的 frpc 就跑在本机 NAS 上(单一客户端 + 多服务端),不需要再管别人的 frpc。
---
## 六、建议实施顺序
1. 配置版本历史 + 回滚(P0-1)——与刚做完的 fsutil 收敛直接衔接,风险最低、收益最直观。
2. 通知渠道补齐 + 阈值告警(P0-3、P0-4)——复用现有通知与调度框架,改动集中在 `internal/notify`。
3. API Token(P0-2)——单独一轮,配一次安全自查。
4. 指标历史与趋势图(P1-5)+ 健康检查持续化(P1-6)——需要新表与后台任务,建议同一轮做(共用落点与保留期设置)。
5. 其余按需。
## 七、第二批方向(第二轮检索补充)
上一节偏「同域直接对标」;这一批来自相邻领域(零信任访问、状态页、加密备份、NAS 硬件监控、SSO/MFA、服务面板),都是 Fonu 已有模块的**能力延伸**,不需要新架构。
| 借鉴点 | 出处 | 落到 Fonu 哪里 | 价值 |
| --- | --- | --- | --- |
| **API Token 的到期 / 刷新 / 新旧密钥宽限期** | Cloudflare Zero Trust 的 Service Token | 补强 §三-P0-2:令牌加 `expires_at`、可刷新延长、轮换时旧密钥保留一段宽限期 | 让脚本 / HomeAssistant 的接入既可撤销又不会一改就断 |
| **状态页 + 事故(incident)记录** | Gatus、Uptime Kuma | 与 §三-P1-6 合并:持续健康检查落库后,除「可用率」再输出「不可用区间」列表(什么时间、哪条规则、多久) | 「昨晚为什么打不开」这类问题有处可查 |
| **Prometheus `/metrics` 导出** | Gatus | 新端点,把已有指标(VPS 资源、隧道流量、健康状态、证书剩余天数)按 Prometheus 文本格式暴露 | 一行配置就能接入 Grafana / HomeAssistant,比自建图表性价比高 |
| **事件订阅(webhook 推事件)** | Cloudflare 的 Event subscriptions | 现有通知是「发给人」;再加「推给系统」(把规则部署 / 证书续期 / 漂移检测事件 POST 到外部 URL) | 对接自动化(如部署后触发 CDN 刷新) |
| **2FA / TOTP + 登录设备与会话列表** | Authelia、Authentik | 补强 §三-P2-10:TOTP 绑定(二维码 + 一次性恢复码)、已登录设备列表与「踢下线」、登录历史 | Fonu 面板暴露在公网时,单密码是最大的残留风险;且与 v1.2.8 的「改密失效其他会话」天然衔接 |
| **审计日志**(谁、何时、改了什么) | Authelia、Authentik | 新增 `audit_logs` 表,在服务端 / 规则 / 证书 / 设置的写入口记一条(含来源 IP) | 多设备 / 多人使用时能回溯误操作 |
| **备份保留策略(GFS)+ 完整性校验** | restic / kopia | 补强 §三-P1-7:保留策略改成 `keep-daily/weekly/monthly`;恢复前先校验包内条目(数量 / 大小 / 时间)再落盘 | 备份不会因「保留太多份」撑爆磁盘,也不会拿半个包去覆盖现有数据 |
| **本机 SMART 磁盘健康 + UPS 状态** | NAS Doctor、Scrutiny、PeaNUT | 扩展 `internal/sysinfo`(本机侧,不经 agent):磁盘 SMART(温度 / 重分配扇区 / 待处理扇区 / 通电小时)、UPS 电量与剩余时间 | NAS 用户的真痛点:硬盘将坏、市电断了;目前仪表盘只有 CPU / 内存 / 磁盘占用 |
| **服务入口墙** | Homepage、Homarr、Glance | 仪表盘新增一区:把启用中的反代 / 穿透规则按域名聚合成可点击卡片(域名、类型、健康点),复用现有 `openDomain()` | NAS 用户装面板就是为了「一处点开所有服务」 |
| **移动端 PWA + 命令面板** | Homepage、Dashy(kiosk 模式) | `manifest.webmanifest` + 图标,让手机「加到主屏」;再加 Cmd/Ctrl+K 跳页面 / 搜规则 | 手机上用得多,成本很低 |
**这一批里我建议先做的两项**:
1. **本机 SMART + UPS**(上表第 8 行)——Fonu 面向 NAS,而当前仪表盘完全没覆盖硬件健康;`internal/sysinfo` 已有平台分文件(`_linux.go` / `_windows.go`)的框架,加采集与一张卡即可,不碰 agent。
2. **Prometheus 导出**(第 3 行)——把已有指标换个输出格式就能接 Grafana / HomeAssistant,比自建历史图表(§三-P1-5)成本低一个量级,两者可先做这个。
**仍然不建议跟的**(补充):把 Fonu 变成通用监控平台(Gatus / Uptime Kuma 那种「任意 URL 探活」)——它的健康检查应该围绕自己下发的反代规则,而不是变成一个通用探活器;引入 Redis 会话存储(Authelia / Authentik 的选择)也没必要,单机 SQLite 足够。
---
## 八、待评估:读取 VPS 实际放行端口(只读)
> 背景:现在 Fonu 只能**告诉用户该放行哪些端口**(frps 同源配置弹窗按已启用的 TCP/UDP 规则推导端口列表,`internal/frp/frps_template.go` 也生成 `allowPorts`),但读不到服务器上**实际放行**了什么。隧道连不上时最常见的原因正是端口未放行,而日志不会点明。
### 结论
**主机侧防火墙可以读,云厂商安全组读不到。**
| 来源 | 可行性 | 做法 |
| --- | --- | --- |
| `ufw`(Debian / Ubuntu) | ✅ | `ufw status numbered`(agent 以 root 运行,无需额外提权) |
| `firewalld`(RHEL / CentOS) | ✅ | `firewall-cmd --list-ports` / `--list-services` / `--list-rich-rules` |
| `nftables` / `iptables` 兜底 | ◐ | `nft list ruleset` / `iptables-save`——是 ufw/firewalld 的底层,直接读会看到大量实现细节,只作为「没有前两者时」的兑底 |
| **云厂商安全组**(本机是 RainYun) | ❌ | 安全组在厂商控制台 / API,**主机侧不可见**;要读就得引入 AccessKey(新凭据面,不建议) |
| 上游 NAT / 网络设备 | ❌ | 不在主机视野内 |
### 设计(只读,不提供任何写操作)
1. agent 新增 `GET /api/v1/firewall`(沿用 `s.auth`):返回 `{backend, rules: [{port, proto, source, action}], missing_tool, checked_at}`;先探测哪个前端存在,命令全部**固定 argv**(与现有 `systemctl` / `ss -tlnp` 的做法一致,不拼用户输入)。
2. 应用侧 `AgentClient.Firewall()` + `GET /api/frpmulti/servers/{id}/agent/firewall`,与现有 agent 路由同构(`internal/api/router.go`)。
3. 前端把「需要放行的端口」(现有规则推导逻辑)与「实际已放行」做**交叉对照**:直接标出「已放行 / 未放行」,并固定一行说明「云安全组需在厂商控制台确认(主机侧读不到)」。落点:`web/src/views/AgentView.vue`(frps.toml 弹窗与详情区)与 `FrpView.vue` 的规则区提示。
4. 与已有数据交叉验证:agent 现在已在采集 `ss -tlnp` 的监听端口,可额外区分「已放行但无服务监听」与「有服务监听但未放行」。
### 边界与风险
- **只读**:不提供任何防火墙写操作,与 agent 能力方案 §六「防火墙规则读写不做(最多只读)」一致。
- **降级**:命令不存在 / 无权限 / 无规则三种情况都要能降级成「未检测到防火墙管理工具」而不是报错。
- **不引入云 API**:接厂商 API 意味着用户要交出 AccessKey,收益(省一次控制台确认)不值这个代价。
- **agent 行为变更**:需递增 `AgentVersion`(0.12.2)并重编双架构内嵌二进制,VPS 上点「升级 Agent」才生效。
---
## 九、P0 实施状态(2026-09-24)
### 已完成:配置版本的后端基础
| 改动 | 位置 | 说明 |
| --- | --- | --- |
| `VersionsDir` / `BackupVersioned` / `pruneVersions` | `internal/fsutil/fsutil.go` | 版本存为 `<path>.versions/<UTC 时间戳.毫秒>-<随机 4 位>.conf`,按文件名倒序即新旧顺序;`keep <= 0` 不剪枝;文件不存在视为无需备份 |
| 接入 Nginx 配置写入 | `internal/nginx/nginx.go` 的 `Apply` | 覆盖前先 `.bak`(保留最近一次,兼容既有读取方),再 `.versions/` 保留最近 10 份 |
| 测试 | `internal/fsutil/fsutil_test.go` | 新增 3 例:内容正确、剪枝到 keep 份、文件不存在返回空且不报错 |
验证:`gofmt` 干净、`go build ./...` / `go vet` / `go test ./internal/fsutil/ ./internal/nginx/` 全绿。
### 已完成:版本列表 / 差异 / 回滚(本机侧与 agent 侧都做了)
| 改动 | 位置 |
| --- | --- |
| `ListVersions` / `ReadVersion`(名称校验拒绍上跳)、`BackupVersioned` 对相同内容去重 | `internal/fsutil/fsutil.go` + 4 个测试 |
| `ReadConfig` / `ListConfigVersions` / `ReadConfigVersion` / `RollbackConfigVersion`(先语法校验 + `nginx -t`,不过关不覆盖);把 Apply 的「让它生效」抽成 `activate` 供回滚复用 | `internal/nginx/versions.go`、`nginx.go` |
| `GET /api/settings/nginx/config/versions`、`GET …/{name}`、`POST …/{name}/rollback` | `internal/api/nginx_handler.go`、`router.go`、`internal/service/nginx_config.go` |
| 设置页「Nginx 全局配置」卡内新增「主配置版本与回滚」:版本列表 + 逐行差异 + 二次确认回滚 | `web/src/views/SettingsView.vue` |
| agent 侧:写 vhost 前留版本;**校验失败改为恢复上一个可用版本**(此前直接删文件,等于把站点从磁盘上抹掉);新增 `GET /routes/{domain}/versions`、`GET …/versions/{name}`、`POST …/versions/{name}/rollback`;删路由时一并清版本目录 | `internal/agent/versions.go`、`server.go`(`AgentVersion` → **0.12.2**,双架构二进制已重编) |
| 应用侧:`AgentClient.RouteVersions/RouteVersion/RollbackRouteVersion` + 三个 `/api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions…` 路由(端点不存在时提示「需 ≥ 0.12.2」) | `internal/frp/agent_client.go`、`internal/api/frpmulti_handler.go`、`router.go` |
| 前端:公网反代页「查看配置」弹窗内加版本面板 + 差异高亮 + 回滚二次确认 | `web/src/views/ProxyRemoteView.vue`、`components/ConfigDiffView.vue`、`utils/diff.ts` |
#### 原计划(已按此实现,保留作对照)
1. `internal/fsutil`:加 `ListVersions(path) ([]VersionEntry, error)`(名称 + 大小 + 修改时间,新的在前)与 `ReadVersion(path, name) ([]byte, error)`(拒绝带 `/` 或 `..` 的名称)。
2. `internal/nginx`:`ListConfigVersions()` / `ReadConfigVersion(name)` / `RollbackConfigVersion(name)`——回滚必须先 `ValidateConfigSyntax` + `m.validate(ctx, tmp)` 通过再写回并 reload,失败不覆盖当前配置(与 Apply 同构)。
3. `internal/api`:`GET /api/nginx/versions`、`GET /api/nginx/versions/{name}`、`POST /api/nginx/versions/{name}/rollback`(受保护路由,写入口径同现有 nginx 接口)。
4. 前端:本地反代页 Nginx 配置区 + 公网反代页——版本列表(时间 / 大小)+ 逐行差异高亮(复用漂移清单的样式)+ 回滚二次确认。
5. 边界:本轮只做**本机侧**;VPS 上的 `fonu-<域名>.conf` 版本历史属于 agent 侧,需单独一轮(agent 写路由时也调 `BackupVersioned`,再配一个只读列表端点)。
### 已完成:其余三项 P0(明细见文末「本轮完成明细」)
#### 原计划(已按此实现,保留作对照)
- **API Token**:表 `api_tokens(id, name, token_hash, scopes, last_used_at, expires_at, created_at)`;`RequireAuth` 同时接受 `Authorization: Bearer`;新建时只显示一次明文(沿用现有「掩码往返」约定);设置页管理;落地前过一遍安全自查(哈希存储、可撤销、只读范围、`last_used_at` 审计)。建议单独一轮。
- **通知渠道**:现有 email / webhook / telegram 三通道的抽象(`SaveInput` / `ParseSaveInputFromMap` / `validateSaveInput`)→ 新增 Bark / ntfy / 企业微信 / 钉钉 / 飞书(都是 POST URL + JSON);`web/src/constants/notifyProviders.ts` 已有 `DEFAULT_BARK_SERVER` / `DEFAULT_NTFY_SERVER` 常量可用。
- **阈值告警**:`internal/scheduler` 加指标巡检任务(数据源:agent `GET /api/v1/metrics` + 本机 `internal/sysinfo`)→ 阈值 / 开关 / 冷却时间走设置项(照搬 `notify_*` 事件开关模式);冷却状态可先放内存(重启丢可接受)。
建议顺序:版本回滚 → 通知渠道 → 阈值告警 → API Token(本轮已全部完成)。
### 本轮完成明细
**通知渠道补齐**
- Bark / ntfy / Gotify **本来就有**(上文「只有邮件 / Webhook / Telegram」不准确);本轮补的是**企业微信机器人、钉钉机器人、飞书机器人**:固定路径 + JSON,`Key` 存 `Webhook.Key`、`Server` 可覆盖默认域名。
- 钉钉正文固定以「Fonu · 」开头,机器人用「自定义关键词 = Fonu」即可命中(加签方式暂不支持,UI 已注明)。
- `providerUsesKey()` 收敛了「凭据放 Key 还是 WebhookSecret」的判定,保存与落库共用一处。
- 前端:预设下拉 + 三家各自的字段与提示;**切换预设时服务地址自动换成该预设默认值**(否则会把 `api.day.app` 带到企业微信上)。
- 测试:`push_test.go` 锁定三家的端点与 payload 形状。
**阈值型告警**
- 新包 `internal/monitor`:`Checker.Run` 每 2 分钟跑一轮(`internal/app/app.go` 注册为 scheduler 任务)。
- 资源:CPU / 内存 / 磁盘超阈值告警,同一项 30 分钟静默期,回落到阈值以下即清冷却(再次超标可立即再报)。
- 隧道:连续 N 次巡检未在 frps 上注册才告警(默认 3 次),**只在刚好达到阈值与恢复时各发一次**,不刷屏。
- 事件与设置项:`on_resource_threshold` / `on_tunnel_down`(默认**关闭**,升级后不改变现有用户收到的通知)+ `notify_cpu_threshold`(90) / `notify_memory_threshold`(90) / `notify_disk_threshold`(85) / `notify_tunnel_fail_threshold`(3);开关关闭时连采集与探测都跳过。
- 前端:告警事件新增「资源占用过高」「隧道连续不通」两项与对应阈值输入。
- 测试:`monitor_test.go` 覆盖「只报一次 / 恢复报一次 / 未达阈值静默」。
**API Token**
- 迁移 `025_api_tokens.sql`:`api_tokens(id, name, token_hash, scope, expires_at, last_used_at, created_at)`,`token_hash` 是明文令牌的 SHA-256(令牌是 `crypto/rand` 的 32 字节随机串,熵足够,不需要 bcrypt);明文只在创建响应里回一次。
- `RequireAuth` 同时接受会话 cookie 与 `Authorization: Bearer <token>`;`read` 范围只允许 GET/HEAD,`write` 与会话同权。
- **安全自查**:① 令牌管理(`/api/settings/tokens`)与改密 / 退出**只允许登录会话**——否则一个 write 令牌能给自己提权或无限续期;② 撤销即删行、立即失效;③ 可选有效期(最长 3650 天,空为永不过期);④ `last_used_at` 限频回写(1 分钟一次,单连接 SQLite 上不做每请求写)。
- 接口:`GET/POST /api/settings/tokens`、`DELETE /api/settings/tokens/{id}`;设置页「高级」新增「API Token」卡:列表(名称 / 权限 / 创建 / 最近使用 / 有效期)+ 创建表单 + 明文一次性展示与复制 + 撤销确认。
- 测试:`internal/auth/token_test.go`(生命周期 / 过期 / 非法输入 / 明文不落盘)、`internal/api/middleware_test.go`(只读令牌只读、写令牌可写、令牌不能管理令牌、错误凭据 401)。
**本轮验证**
`gofmt`(改动文件干净)、`go build ./...`、`go vet ./...`、`go test ./... -count=1` 全绿;`npx vue-tsc --noEmit` 0 错误、`npx vite build` 成功;`internal/frp/agentbin/` 双架构二进制已重编(含 `0.12.2`)。
**待用户操作**
1. **重启本机 `bin\fonu.exe`** 才能看到新接口与新 UI(新迁移在启动时自动执行)。
2. **VPS 上点「升级 Agent」** 才会装上 0.12.2,公网反代页的版本历史才会出现(否则提示「需 ≥ 0.12.2」)。
3. 已随本轮提交:`CHANGELOG.md` 新增 v1.2.9 段,`VERSION` 与 `web/package.json` 同步到 1.2.9。
## 十、A 档告警落地(2026-09-25)
> 承接 §三-P0-4 的阈值告警:把「隧道不通」这一族故障的三个常见成因补齐。**纯应用侧改动,agent 无改动**(`AgentVersion` 保持 0.12.2,无需在 VPS 上重新升级)。
| 新事件 | 判定 | 默认 | 限流 |
| --- | --- | --- | --- |
| 公网服务端掉线 `frps_server_down` | `DesiredState` 为 running/starting 时,进程 stopped/error 或连接 disconnected/error | 关闭 · 连续 2 次 | 达标报一次 + 恢复报一次 |
| 公网 agent 不可达 `agent_unreachable` | agent 地址与令牌都可用,但 `GET /api/v1/ping` 连续失败 | 关闭 · 连续 3 次 | 同上 |
| 配置漂移 `route_drift` | `RouteDrift` 报告有 items(未部署 / 孤儿配置 / 未生效 / 内容不一致) | 关闭 · 24 小时 | 首次即报 + 每 24h 重复 + 清空报恢复 |
**避免误报的三条约定**:① 服务端只在「用户要它运行」时判定,手动停掉的不报;② `ConnectionState=unknown` 按健康处理(状态源 `manager+log` 偶尔读不到不代表掉线);③ 没配 agent 的服务端不参与可达性判定。
**结构变化**:`internal/monitor` 的注入方式从「一个 routes 闭包」改成 `Source` 接口(Routes / Servers / Agents / Drift),frp 侧适配放在新文件 `internal/app/monitor_source.go`——monitor 依旧不依赖 frp 包,新增数据源只需扩接口。三条新检查与原有隧道巡检共用 `failureTransition`(原 `tunnelTransition` 泛化),漂移因不是「连续失败」型而单独用冷却;`notify.Alert` 一律在锁外调用。
## 十一、P1 实施状态(2026-09-25)
| 项 | 状态 | 落点与关键决定 |
| --- | --- | --- |
| 7 定时自动备份 + 保留策略 | ✅ | `internal/backup`:导出改 `writeArchive` 逐个检查 tar→gzip→文件的 `Close()`(修审查报告 §二);`AutoRunner` 按「最新备份是否在 24h 内」判定,重启不重复备份;导出后回读校验 |
| 6 健康检查持续化 + 只读状态页 | ✅ | 先修 `tunnel_known`(判不了 ≠ 不通,修掉了新告警的误报根因);`route_health_events` 只存状态变更(up/down/unknown),可用率与不可用区间由它推导;公开状态页默认关闭、访问码存 SHA-256、按 IP 限流、只暴露域名与可用率 |
| 5 指标历史与趋势图 | ✅ | 纵向点表 `(scope, metric, value, at)`,**不上降采样表**(每 2 分钟采样行量极小,查询时按桶取平均);本机资源 + 每规则上下行速率(`SnapshotByHost` 与端点聚合共用 `aggregateHostKeys`);仪表盘趋势卡 1h/24h/7d |
| 8 一键迁移包 | ⬜ 未做 | 建议缩范围:先只做「导出加密包 + 只允许导入到空实例」 |
| 9 Docker 容器统计 | ⬜ 未做 | 要动 agent(递增 `AgentVersion` + 重编 + 用户升级),建议排在「本机 SMART + UPS」之后 |
**本轮教训(记一下免得重犯)**:`vue-tsc` 不校验模板标签闭合,模板结构错误只有 `npx vite build` 会拦(本轮搬卡片时标签失衡就是它报的 `Element is missing end tag`)——前端改动两个都要跑。
---
> 说明:本文件是**建议清单**,未改动任何代码;实施时按本仓库既有约定(每步验证 `go build/vet/test` + `vue-tsc/vite build`、agent 行为变更递增 `AgentVersion`、提交同批更新 `CHANGELOG` 与 `VERSION`)。
File diff suppressed because it is too large Load Diff
-736
View File
@@ -1,736 +0,0 @@
# Havline 飞牛 Native 应用改造方案
> 日期:2026-10-02
> 依据:飞牛应用开放平台 Native 应用案例、统一网关、应用权限、环境变量、应用资源
> 目标:把 Havline 做成完全不依赖 Docker 的飞牛原生应用
## 一、结论
技术上可行,但不建议直接把当前离线 Docker 包替换掉。
推荐路线:
```text
Go 主程序原生运行
+ 统一网关 Unix Socket
+ 打包 Nginx 双架构二进制
+ frpc / cloudflared 按需打包或运行时下载
+ cmd/main 管理进程生命周期
```
最终形态:
```text
不依赖 Docker Engine
不依赖 Docker Compose
不依赖外部镜像仓库
管理界面通过 /app/havline 统一网关访问
公网反代功能通过高位端口运行
```
主要风险:
```text
Nginx 双架构打包与动态库依赖
非 root 用户无法绑定 80/443
进程启动、停止、状态和崩溃恢复
frpc / cloudflared 架构与版本管理
真机升级和回滚验证
```
## 二、官方 Native 应用模型
官方 Native 应用案例说明:
```text
Native 应用是在飞牛 fnOS 上运行的进程
可以提供 Web UI、后台服务、CLI 工具
推荐使用统一网关 /app/{appname}
服务监听 ${TRIM_APPDEST}/app.sock
cmd/main 负责 start、stop、status
应用文件放在 ${TRIM_APPDEST}
数据放在 ${TRIM_PKGVAR}
```
官方 Notepad 示例的关键结构:
```text
notepad/
├── app/
│ ├── server/
│ └── ui/config
├── cmd/
│ └── main
├── config/
│ ├── privilege
│ └── resource
├── manifest
├── ICON.PNG
└── ICON_256.PNG
```
启动脚本核心逻辑:
```bash
APP_DIR="${TRIM_APPDEST}/server"
SOCKET_PATH="${TRIM_APPDEST}/app.sock"
start)
cd "$APP_DIR"
SOCKET_PATH="$SOCKET_PATH" ./havline &
echo "$!" > "$PID_FILE"
;;
stop)
kill -TERM "$(cat "$PID_FILE")"
rm -f "$SOCKET_PATH" "$PID_FILE"
;;
status)
kill -0 "$(cat "$PID_FILE")"
;;
```
## 三、Havline 依赖清单
### 3.1 Go 主程序
可以直接编译为 Linux 静态二进制:
```text
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build
```
优点:
```text
SQLite 使用 modernc.org/sqlite,纯 Go,不依赖 CGO
前端已经通过 go:embed 内嵌
ACME、DDNS、通知、备份均为 Go 实现
不需要 Node.js、Python 或 Java 运行时
```
### 3.2 必需外部依赖
| 依赖 | 用途 | 原生方案 |
| --- | --- | --- |
| Nginx | 反向代理、HTTPS、证书加载 | 必须打包双架构二进制 |
| mime.types | Nginx MIME 配置 | 随包分发 |
| ca-certificates | HTTPS 客户端信任 | 使用系统证书或随包分发 |
| tzdata | 时区 | 使用系统时区或随包分发 |
### 3.3 可选外部依赖
| 依赖 | 用途 | 原生方案 |
| --- | --- | --- |
| frpc | FRP 内网穿透 | 打包双架构或运行时下载 |
| cloudflared | Cloudflare Tunnel | 打包双架构或运行时下载 |
| ssh | Agent 远程安装 | 使用系统 SSH |
| systemctl | 公网 Agent 管理 frps | 仅 Agent 场景需要 |
| ss | 端口诊断 | 使用系统工具 |
### 3.4 可以移除的依赖
原生应用不需要:
```text
Docker Engine
Docker Compose
Docker Socket
havline-updater sidecar
宿主机 Compose 文件
```
## 四、目标包结构
建议新增独立工程:
```text
fnos-native/
└── havline/
├── manifest
├── ICON.PNG
├── ICON_256.PNG
├── app/
│ ├── server/
│ │ ├── amd64/
│ │ │ ├── havline
│ │ │ └── nginx
│ │ ├── arm64/
│ │ │ ├── havline
│ │ │ └── nginx
│ │ ├── mime.types
│ │ └── VERSION
│ └── ui/
│ ├── config
│ └── images/
├── cmd/
│ ├── main
│ ├── install_callback
│ ├── upgrade_callback
│ ├── config_callback
│ └── uninstall_callback
├── config/
│ ├── privilege
│ └── resource
└── wizard/
├── install
├── config
└── uninstall
```
安装后:
```text
/var/apps/havline/
├── target/
│ ├── server/
│ │ ├── amd64/
│ │ ├── arm64/
│ │ └── mime.types
│ └── app.sock
├── var/
│ ├── havline.db
│ ├── session_secret
│ ├── nginx/
│ ├── certs/
│ ├── frp/
│ └── cloudflared/
└── cmd/
```
## 五、运行架构
```text
cmd/main start
-> 选择当前架构目录
-> 设置 HAVLINE_DATA_DIR=${TRIM_PKGVAR}
-> 设置 HAVLINE_NGINX_BIN=${TRIM_APPDEST}/server/<arch>/nginx
-> 设置 HAVLINE_NGINX_MIME_TYPES=${TRIM_APPDEST}/server/mime.types
-> 设置 HAVLINE_GATEWAY_SOCKET=${TRIM_APPDEST}/app.sock
-> 启动 havline 主程序
-> 写入 PID 文件
havline 主程序
-> 监听 Unix Socket 供统一网关访问
-> 启动 Nginx 子进程
-> 按需启动 frpc / cloudflared
-> 管理数据库、证书、DDNS、反代规则
cmd/main stop
-> 发送 SIGTERM
-> 等待主程序退出
-> 主程序负责停止 Nginx 和可选子进程
-> 清理 app.sock 和 PID 文件
cmd/main status
-> 检查 PID 是否存活
-> 存活返回 0,否则返回 3
```
## 六、统一网关接入
`app/ui/config`:
```json
{
".url": {
"havline.main": {
"title": "Havline",
"icon": "images/icon_{0}.png",
"type": "iframe",
"protocol": "",
"gatewayPrefix": "/app/havline",
"gatewaySocket": "app.sock",
"url": "/app/havline",
"allUsers": false,
"control": {
"accessPerm": "readonly"
}
}
}
}
```
主程序需要新增:
```text
HAVLINE_GATEWAY_SOCKET=${TRIM_APPDEST}/app.sock
HAVLINE_GATEWAY_PREFIX=/app/havline
```
监听方式:
```go
listener, err := net.Listen("unix", cfg.GatewaySocket)
if err != nil {
return err
}
os.Chmod(cfg.GatewaySocket, 0o660)
server.Serve(listener)
```
前端需要支持:
```text
Vite base = /app/havline/
Vue Router base = /app/havline/
API base = /app/havline/api
SSE base = /app/havline/api/...
WebSocket base = /app/havline/ws
Cookie Path = /app/havline 或 /
```
## 七、Nginx 原生打包
这是整个改造最大的技术点。
### 7.1 可选方案
| 方案 | 优点 | 缺点 |
| --- | --- | --- |
| 打包静态 Nginx | 不依赖系统 Nginx | 需要自行构建双架构 |
| 打包动态 Nginx + 依赖库 | 接近发行版行为 | 需要处理 .so 和 LD_LIBRARY_PATH |
| 使用飞牛系统 Nginx | 包体小 | 版本、路径、配置目录不可控 |
| 改用 Go 内置反向代理 | 完全纯 Go | Havline 现有 Nginx 功能需要重写 |
推荐:
```text
打包静态 Nginx 或半静态 Nginx
amd64 与 arm64 各一份
随包提供 mime.types
```
### 7.2 Nginx 配置调整
原生应用以 package 用户运行,不能直接绑定 80/443。
建议:
```text
管理界面:统一网关 Unix Socket
Nginx HTTP:18080
Nginx HTTPS:9443
```
如果确实需要 80/443:
```text
使用 root 生命周期脚本启动 Nginx master
Nginx worker 降权到 package 用户
或者给二进制授予 CAP_NET_BIND_SERVICE
```
不推荐让整个 Havline 长期以 root 运行。
### 7.3 Nginx 目录
建议:
```text
${TRIM_APPDEST}/server/<arch>/nginx
${TRIM_APPDEST}/server/mime.types
${TRIM_PKGVAR}/nginx/
${TRIM_PKGVAR}/certs/
${TRIM_PKGVAR}/logs/
```
环境变量:
```text
HAVLINE_NGINX_BIN=${TRIM_APPDEST}/server/<arch>/nginx
HAVLINE_NGINX_MIME_TYPES=${TRIM_APPDEST}/server/mime.types
HAVLINE_NGINX_PID=${TRIM_PKGVAR}/nginx/nginx.pid
HAVLINE_NGINX_HTTP_PORT=18080
HAVLINE_NGINX_HTTPS_PORT=9443
```
## 八、frpc 与 cloudflared
这两个是可选功能依赖。
### 方案 A:随包分发
```text
app/server/amd64/frpc
app/server/arm64/frpc
app/server/amd64/cloudflared
app/server/arm64/cloudflared
```
优点:
```text
完全离线
版本可控
```
缺点:
```text
包体变大
每次升级 Havline 都要同步升级二进制
```
### 方案 B:运行时按需下载
保持 Havline 现有下载逻辑,但把下载目录指向:
```text
${TRIM_PKGVAR}/frp/
${TRIM_PKGVAR}/cloudflared/
```
优点:
```text
FPK 更小
```
缺点:
```text
首次使用需要网络
下载源可能被墙
需要校验 SHA256
```
推荐首版:
```text
frpc:随包分发
cloudflared:随包分发或首次使用时下载
```
## 九、数据目录与迁移
原生应用数据目录:
```text
${TRIM_PKGVAR}
```
需要保留:
```text
havline.db
session_secret
nginx/
certs/
frp/
cloudflared/
logs/
ssh_known_hosts
```
从当前离线 Docker 包迁移:
```text
Docker 包数据目录:${TRIM_PKGVAR}
原生包数据目录:${TRIM_PKGVAR}
```
如果两个应用使用同一个 appname,路径可能相同。
迁移步骤:
```text
1. 停止 Docker 版 Havline
2. 备份数据目录
3. 安装原生版
4. 确认 session_secret 不变
5. 启动原生版
6. 验证数据库、证书、规则、隧道
```
## 十、生命周期脚本
### 10.1 cmd/main
职责:
```text
start:选择架构、设置环境变量、启动 havline、写 PID
stop:发送 SIGTERM、等待退出、清理 PID 和 Socket
status:检查 PID 是否存活
```
关键环境变量:
```bash
APP_DIR="${TRIM_APPDEST}/server"
ARCH="$(uname -m)"
case "$ARCH" in
x86_64|amd64) ARCH_DIR="amd64" ;;
aarch64|arm64) ARCH_DIR="arm64" ;;
*) exit 1 ;;
esac
export HAVLINE_DATA_DIR="$TRIM_PKGVAR"
export HAVLINE_NGINX_BIN="$APP_DIR/$ARCH_DIR/nginx"
export HAVLINE_NGINX_MIME_TYPES="$APP_DIR/mime.types"
export HAVLINE_NGINX_HTTP_PORT=18080
export HAVLINE_NGINX_HTTPS_PORT=9443
export HAVLINE_GATEWAY_SOCKET="$TRIM_APPDEST/app.sock"
export HAVLINE_GATEWAY_PREFIX="/app/havline"
export HAVLINE_UPDATER_SOCKET=""
```
### 10.2 install_callback
职责:
```text
创建 TRIM_PKGVAR
设置目录权限
复制默认配置
检查二进制架构
检查 Nginx 可执行
```
### 10.3 upgrade_callback
职责:
```text
检查数据库和 session_secret
保留数据目录
检查新二进制架构
必要时迁移 Nginx 配置
```
### 10.4 uninstall_callback
职责:
```text
默认保留 TRIM_PKGVAR
用户选择 purge 时删除数据
清理 app.sock 和 PID 文件
```
## 十一、多架构构建
构建脚本需要输出:
```text
amd64/havline
arm64/havline
amd64/nginx
arm64/nginx
```
Go 二进制:
```bash
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o amd64/havline ./cmd/havline
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -o arm64/havline ./cmd/havline
```
Nginx:
```text
从静态构建产物复制
或从容器镜像中提取
并检查动态库依赖
```
检查:
```bash
file amd64/havline
file arm64/havline
ldd amd64/nginx
ldd arm64/nginx
```
## 十二、分阶段实施
### 阶段 1:Native PoC
目标:不打包 Nginx,先验证 Go 主程序原生运行。
内容:
```text
编译 amd64/arm64 Go 二进制
统一网关 Unix Socket
cmd/main start/stop/status
TRIM_PKGVAR 数据目录
```
验收:
```text
[ ] 应用可安装
[ ] /app/havline 可打开
[ ] 进程可启停
[ ] 数据可持久化
```
### 阶段 2:Nginx 打包
目标:让反向代理功能原生可用。
内容:
```text
静态 Nginx 双架构
mime.types
18080/9443 端口
证书目录
nginx -t 校验
```
验收:
```text
[ ] Nginx 可启动
[ ] 配置可校验
[ ] HTTPS 可加载
[ ] 反向代理规则生效
```
### 阶段 3:frpc / cloudflared
目标:恢复 FRP 和 Cloudflare Tunnel 功能。
内容:
```text
双架构二进制
或按需下载
SHA256 校验
进程管理
日志
```
### 阶段 4:升级与迁移
目标:从 Docker 包迁移到原生包。
内容:
```text
数据迁移
session_secret 保留
版本兼容
回滚方案
```
### 阶段 5:真机验证
目标:x86_64 与 ARM 设备安装验证。
内容:
```text
安装、启动、停止
统一网关入口
反向代理
证书
FRP
Cloudflare
升级
卸载
```
## 十三、验收清单
```text
[ ] 不依赖 Docker Engine
[ ] 不依赖 Docker Compose
[ ] 不依赖外部镜像仓库
[ ] Go 二进制支持 amd64 与 arm64
[ ] Nginx 支持 amd64 与 arm64
[ ] 管理界面通过 /app/havline 打开
[ ] Unix Socket 权限正确
[ ] 主程序可启动、停止、检查状态
[ ] 数据写入 TRIM_PKGVAR
[ ] session_secret 升级后不变
[ ] Nginx 反向代理正常
[ ] HTTPS 证书正常
[ ] FRP 功能正常
[ ] Cloudflare Tunnel 功能正常
[ ] 升级不丢数据
[ ] 卸载默认保留数据
```
## 十四、风险与工作量
| 风险 | 影响 | 处理 |
| --- | --- | --- |
| Nginx 双架构打包 | 原生版无法启动 | 阶段 2 专项验证 |
| Nginx 动态库依赖 | 目标设备缺库 | 静态编译或随包分发 .so |
| 非 root 绑定 80/443 | 端口权限失败 | 使用 18080/9443 |
| 进程崩溃 | 服务中断 | PID 检查、重启策略、日志 |
| Socket 权限 | 网关无法访问 | 启动时 chmod |
| 前端绝对路径 | 网关下 404 | 统一 base path |
| frpc/cloudflared 版本 | 功能不可用 | 随包分发并校验 SHA256 |
| 数据迁移 | 凭据无法解密 | 保留 session_secret |
| 升级替换二进制 | 运行中进程被覆盖 | stop 后再替换 |
| ARM 真机差异 | 测试不充分 | 真机验证 |
工作量估算:
```text
阶段 1:2 到 3 天
阶段 2:3 到 5 天
阶段 3:2 到 4 天
阶段 4:2 到 3 天
阶段 5:3 到 5 天
合计:2 到 4 周
```
## 十五、与离线 Docker 包对比
| 项目 | 离线 Docker 包 | 原生应用 |
| --- | --- | --- |
| 依赖 Docker | 是 | 否 |
| 依赖仓库 | 否 | 否 |
| 包体 | 约 150 MB | 预计 40 到 80 MB |
| 改造量 | 已完成 | 2 到 4 周 |
| Nginx | 容器内已有 | 需要自行打包 |
| 升级 | Docker load | 替换二进制 |
| 进程管理 | Docker Compose | cmd/main |
| 风险 | 低 | 高 |
| 推荐度 | 当前优先 | 长期演进 |
## 十六、建议
当前阶段建议:
```text
先发布并验证离线 Docker 包
保留统一网关改造方案
原生应用作为独立分支推进
```
原生应用的推荐顺序:
```text
1. 先做 Go 主程序 + 统一网关 PoC
2. 再做 Nginx 双架构打包
3. 再恢复 FRP 和 Cloudflare
4. 最后做数据迁移和真机升级
```
只有在以下条件满足时,才建议把原生应用作为正式发布形态:
```text
Nginx 双架构在真实设备稳定运行
统一网关 Socket 权限稳定
升级和回滚验证通过
frpc / cloudflared 架构验证通过
```
## 十七、官方参考
- Native 应用案例:<https://developer.fnnas.com/docs/examples/native/>
- 统一网关:<https://developer.fnnas.com/docs/core-concepts/gateway-registration/>
- 应用权限:<https://developer.fnnas.com/docs/core-concepts/privilege/>
- 环境变量:<https://developer.fnnas.com/docs/core-concepts/environment-variables/>
- 应用资源:<https://developer.fnnas.com/docs/core-concepts/resource/>
- 应用入口:<https://developer.fnnas.com/docs/core-concepts/app-entry/>
-890
View File
@@ -1,890 +0,0 @@
# Havline 飞牛统一网关应用改造方案
> 日期:2026-10-02
> 适用范围:`fnos/havline` 飞牛应用包与 Havline 主程序 1.9.x
> 官方依据:飞牛应用开放平台「统一网关」「应用入口」「应用资源」「环境变量」「Docker 应用案例」
## 一、结论先行
统一网关不是 Docker 应用模板的替代品,而是飞牛应用的一种**访问模型**。
官方文档明确说明:Docker 应用也可以使用统一网关。做法是把
`${TRIM_APPDEST}` 挂载到容器内,让容器中的服务在安装目录下创建
Unix Socket,再由 fnOS 网关转发请求。
因此 Havline 的正确改造方向是:
```text
Docker 运行形态(保留)
+ 统一网关访问模型(新增)
+ 端口服务回退(保留)
```
而不是把现有 Docker 应用整体推翻重做。
推荐首版边界:
```text
管理界面:
通过 /app/havline 和 Unix Socket 暴露
公网反代、DDNS、证书、FRP、Cloudflare Tunnel:
仍由 Havline 自己管理
端口 18080/9443:
作为公网反代功能端口和回退入口保留
```
## 二、统一网关是什么
飞牛统一网关为应用提供飞牛 fnOS 访问域名下的稳定系统 URL。
请求路径:
```text
/app/{appname}
/app/{appname}/{customPath}
```
工作流程:
1. 用户在飞牛桌面打开应用。
2. fnOS 先校验用户会话。
3. fnOS 将请求转发到应用声明的 Unix Socket。
4. 应用读取网关注入的用户 Header。
5. 应用继续执行自己的业务权限校验。
统一网关与端口服务的对比:
| 能力 | 端口服务 | 统一网关 |
| --- | --- | --- |
| 访问地址 | 独立端口 | `/app/{appname}` |
| NAS 登录态 | 不接入 | 转发前校验 |
| 用户上下文 | 无 | `X-Trim-*` Header |
| WebSocket | 应用自行处理 | 支持 |
| 常驻服务 | 支持 | 支持 |
| 部署形态 | 任意 | 任意,Docker 也支持 |
| 核心适配 | 监听端口 | 监听 Unix Socket |
## 三、官方字段与约束
### 3.1 应用入口
统一网关入口写在 `app/ui/config`:
```json
{
".url": {
"havline.main": {
"title": "Havline",
"icon": "images/icon_{0}.png",
"type": "iframe",
"protocol": "",
"gatewayPrefix": "/app/havline",
"gatewaySocket": "app.sock",
"url": "/app/havline",
"allUsers": false,
"control": {
"accessPerm": "readonly"
}
}
}
}
```
字段规则:
```text
gatewayPrefix
必须使用 /app/{appname} 或 /app/{appname}/{customPath}
发布后应保持稳定
gatewaySocket
只填写 Socket 文件名,例如 app.sock
实际路径为 ${TRIM_APPDEST}/app.sock
protocol / port
在统一网关入口中被忽略,不参与路由
```
### 3.2 Docker 应用接入
官方 Docker 案例给出的结构:
```yaml
services:
web:
image: my-app:latest
container_name: hello-docker-web
restart: unless-stopped
volumes:
- "${TRIM_APPDEST}:/app/target:rw"
environment:
GATEWAY_SOCKET: /app/target/app.sock
```
容器内服务监听:
```text
/app/target/app.sock
```
宿主机对应路径:
```text
/var/apps/{appname}/target/app.sock
```
### 3.3 用户 Header
网关校验会话后注入:
| Header | 含义 | 示例 |
| --- | --- | --- |
| `X-Trim-Userid` | 当前用户 UID | `1000` |
| `X-Trim-Isadmin` | 是否管理员 | `true` / `false` |
| `X-Trim-Username` | 当前用户名 | `admin` |
这些 Header 只能作为网关可信上下文使用。应用仍然需要校验:
```text
接口权限
资源归属
文件路径
高风险操作
```
不能信任客户端自己传入的用户 ID。
## 四、当前 Havline 的差距
### 4.1 当前监听方式
```text
HAVLINE_LISTEN=:6893
```
主程序使用 TCP 端口监听:
```text
internal/config/config.go
ListenAddr = HAVLINE_LISTEN,默认 :6893
internal/app/app.go
http.Server{Addr: cfg.ListenAddr}
server.ListenAndServe()
```
当前没有 Unix Socket 监听能力。
### 4.2 当前飞牛入口
```json
{
".url": {
"havline.main": {
"title": "Havline",
"icon": "images/icon_{0}.png",
"type": "iframe",
"protocol": "http",
"port": "${wizard_service_port}",
"url": "/",
"allUsers": false
}
}
}
```
这是端口服务入口,不是统一网关入口。
### 4.3 当前前端路径假设
```text
Vue Router:
createWebHistory(),无 base
API 客户端:
直接使用 /api/... 绝对路径
SSE / EventSource:
/api/proxies/{id}/logs/stream
静态资源:
由 Vite 默认根路径构建
```
统一网关路径是 `/app/havline`,这些绝对路径不能直接复用。
### 4.4 当前容器权限
Dockerfile 没有显式 `USER`,容器默认以 root 运行。
这对创建 Socket 文件是有利的:
```text
/app/target/app.sock
```
但如果后续改为非 root 用户运行,需要额外处理 Socket 的属主和权限。
### 4.5 当前端口映射
```yaml
ports:
- "${wizard_service_port}:6893"
- "${wizard_nginx_http_port}:80"
- "${wizard_nginx_https_port}:443"
```
统一网关只替代管理界面的入口,不会替代 Havline 的反向代理端口。
## 五、目标架构
### 5.1 访问分层
```text
飞牛桌面
-> https://fnOS域名/app/havline
-> 飞牛统一网关校验登录态
-> /var/apps/havline/target/app.sock
-> Havline 网关监听器
-> Havline API / Web UI
Havline 公网反代功能
-> 18080/9443 端口
-> Havline 内置 Nginx
-> 用户配置的 NAS 服务
```
### 5.2 包结构目标
```text
fnos/havline/
├── manifest
├── app/
│ ├── docker/
│ │ └── docker-compose.yaml
│ └── ui/
│ └── config
├── config/
│ ├── privilege
│ └── resource
├── cmd/
│ ├── main
│ ├── install_callback
│ ├── upgrade_callback
│ ├── config_callback
│ └── uninstall_callback
└── wizard/
├── install
├── config
└── uninstall
```
### 5.3 Docker Compose 目标
```yaml
services:
havline:
image: jayhub.top/mrlee/havline:${TRIM_APPVER}-amd64-docker
container_name: havline
restart: unless-stopped
ports:
- "${wizard_nginx_http_port}:80"
- "${wizard_nginx_https_port}:443"
volumes:
- "${TRIM_PKGVAR}:/data"
- "${TRIM_APPDEST}:/app/target:rw"
environment:
HAVLINE_DATA_DIR: /data
HAVLINE_LISTEN: ":6893"
HAVLINE_GATEWAY_SOCKET: "/app/target/app.sock"
HAVLINE_GATEWAY_PREFIX: "/app/havline"
HAVLINE_TRUST_GATEWAY_HEADERS: "false"
HAVLINE_NGINX_HTTP_PORT: "80"
HAVLINE_NGINX_HTTPS_PORT: "443"
HAVLINE_UPDATER_SOCKET: ""
HAVLINE_INITIAL_ADMIN_PASSWORD: "${wizard_initial_admin_password}"
TZ: "${wizard_timezone}"
```
说明:
```text
HAVLINE_LISTEN 保留,用于端口回退和内部健康检查
HAVLINE_GATEWAY_SOCKET 指向统一网关 Socket
HAVLINE_GATEWAY_PREFIX 用于前端 base path 和路由
HAVLINE_TRUST_GATEWAY_HEADERS 第一阶段关闭
wizard_service_port 可以从端口映射中移除
```
### 5.4 manifest 目标
首版建议:
```ini
appname = havline
version = <版本>
display_name = Havline
desc = NAS 公网访问管理:反向代理、DDNS、HTTPS 证书、FRP、Cloudflare Tunnel 与一键升级
source = thirdparty
platform = all
maintainer = Havline
maintainer_url = https://jayhub.top/mrlee/Havline
distributor = Havline
desktop_uidir = ui
desktop_applaunchname = havline.main
checkport = false
ctl_stop = true
service_port = 6893
```
`service_port` 是否保留需要实测:
```text
如果只注册统一网关入口:
可以不依赖 service_port
如果同时保留端口回退入口:
保留 service_port 便于调试
```
## 六、三种实现路线
### 方案 A:Go 主程序原生监听 Unix Socket
实现:
```text
internal/config
增加 GatewaySocket、GatewayPrefix 配置
internal/app
同时创建 TCP listener 和 Unix listener
TCP 用于端口回退
Unix 用于统一网关
```
伪代码:
```go
tcpServer := &http.Server{Addr: cfg.ListenAddr, Handler: handler}
unixServer := &http.Server{Handler: handler}
if cfg.GatewaySocket != "" {
_ = os.Remove(cfg.GatewaySocket)
listener, err := net.Listen("unix", cfg.GatewaySocket)
if err != nil {
return err
}
if err := os.Chmod(cfg.GatewaySocket, 0o660); err != nil {
return err
}
go unixServer.Serve(listener)
}
go tcpServer.ListenAndServe()
```
优点:
```text
无额外进程
可以精确处理 base path
可以按来源区分 TCP 与网关请求
可以安全启用 X-Trim-* SSO
网络性能更好
```
缺点:
```text
需要修改 Go 主程序
需要补充 Unix Socket 生命周期测试
```
推荐度:高。
### 方案 B:容器内 Nginx 监听 Unix Socket
实现:
```text
容器内 Nginx 监听 /app/target/app.sock
反向代理到 127.0.0.1:6893
```
优点:
```text
不改 Go 主程序
可以快速做统一网关 PoC
```
缺点:
```text
多一层代理
base path 和 Header 处理复杂
错误排查链路更长
SSO 头部的可信边界不清晰
```
推荐度:中,适合过渡验证,不建议作为长期方案。
### 方案 C:单独 Sidecar 容器
实现:
```text
新增 sidecar 容器监听 Unix Socket
代理到 havline 容器
```
优点:
```text
主容器不增加进程
```
缺点:
```text
Compose 复杂度高
生命周期和升级耦合
收益不明显
```
推荐度:低。
## 七、前端 base path 改造
统一网关路径固定为:
```text
/app/havline
```
前端需要支持可配置 base path。
### 7.1 Vite
目标:
```ts
export default defineConfig({
base: process.env.VITE_BASE_PATH || '/',
// ...
})
```
构建飞牛统一网关版本时:
```text
VITE_BASE_PATH=/app/havline/
```
### 7.2 Vue Router
当前:
```ts
createWebHistory()
```
目标:
```ts
createWebHistory(import.meta.env.BASE_URL)
```
或者由运行时 `HAVLINE_GATEWAY_PREFIX` 注入。
### 7.3 API 客户端
当前:
```ts
fetch('/api/...')
```
目标:
```ts
const API_BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
fetch(`${API_BASE}/api/...`)
```
或者由前端运行时配置读取:
```text
window.__HAVLINE_BASE_PATH__
```
### 7.4 SSE / EventSource
当前:
```ts
new EventSource('/api/proxies/{id}/logs/stream')
```
目标:
```ts
new EventSource(`${API_BASE}/api/proxies/{id}/logs/stream`)
```
### 7.5 WebSocket
目标路径:
```text
/app/havline/ws
```
前端必须使用当前页面主机和协议:
```ts
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${wsProtocol}//${location.host}/app/havline/ws`
```
### 7.6 Cookie
需要确认 Havline 的会话 Cookie 是否设置了 Path。
建议:
```text
端口模式:Path=/
网关模式:Path=/app/havline
```
或者统一使用 `Path=/`,减少改造量。
### 7.7 CSRF / Origin
如果 Havline 有 Origin 或 CSRF 校验,需要把以下来源加入白名单:
```text
https://<fnOS域名>
https://<fnOS域名>/app/havline
```
## 八、认证与权限设计
### 8.1 第一阶段:双登录,低风险
```text
飞牛网关校验 fnOS 登录态
Havline 仍然显示自己的登录页
用户登录 Havline 后正常使用
```
优点:
```text
改造最小
不影响端口模式
不引入 SSO 安全问题
```
缺点:
```text
用户需要登录两次
```
建议首版采用。
### 8.2 第二阶段:网关 SSO
启用条件:
```text
HAVLINE_TRUST_GATEWAY_HEADERS=true
只有 Unix Socket 请求才信任 X-Trim-* Header
```
映射规则建议:
```text
X-Trim-Isadmin=true
-> 映射到 Havline admin
X-Trim-Isadmin=false
-> 默认拒绝管理界面
-> 或后续映射为只读用户
缺少 X-Trim-Userid
-> 视为非网关请求,回退到本地登录
```
安全要求:
```text
Unix Socket 文件权限限制为网关和 Havline 进程可访问
TCP 端口请求绝对不能信任 X-Trim-* Header
网关 Header 只用于身份上下文,不替代 Havline 业务权限
```
## 九、Havline 功能边界
统一网关只解决飞牛系统域名下的应用入口,不替代 Havline 的核心功能。
| Havline 功能 | 统一网关是否替代 | 说明 |
| --- | --- | --- |
| 管理界面入口 | 是 | 改为 `/app/havline` |
| fnOS 登录态 | 部分 | 网关校验登录,Havline 仍需业务鉴权 |
| 内置 Nginx 反向代理 | 否 | 仍由 Havline 管理 |
| DDNS | 否 | 仍由 Havline 管理 |
| HTTPS 证书申请 | 否 | 仍由 Havline 管理 |
| FRP 内网穿透 | 否 | 仍由 Havline 管理 |
| Cloudflare Tunnel | 否 | 仍由 Havline 管理 |
| 公网域名反代 | 否 | 仍由 Havline 管理 |
| 端口 18080/9443 | 否 | 作为 Havline 功能端口保留 |
不要把 Havline 的公网反代、证书和隧道功能迁入飞牛统一网关,否则会丢失
Havline 作为独立公网访问管理工具的能力。
## 十、生命周期与打包变更
### 10.1 安装回调
需要确保:
```text
${TRIM_APPDEST} 存在
${TRIM_APPDEST} 权限允许容器写入 app.sock
${TRIM_PKGVAR} 数据目录权限正确
```
### 10.2 启动
容器启动时:
```text
删除 ${TRIM_APPDEST}/app.sock 残留文件
创建新的 Unix Socket
设置 Socket 权限
```
### 10.3 升级
升级时:
```text
gatewayPrefix 保持不变
gatewaySocket 文件名保持不变
数据目录保持不变
Socket 文件重新创建
```
### 10.4 卸载
卸载时:
```text
删除 ${TRIM_APPDEST}/app.sock
默认保留 ${TRIM_PKGVAR}
```
## 十一、分阶段实施计划
### 阶段 0:官方行为 PoC
目标:用最小 Docker 示例验证统一网关。
必须验证:
```text
[ ] ${TRIM_APPDEST} 挂载路径
[ ] Socket 文件权限
[ ] /app/havline 请求是否保留前缀
[ ] Header 是否原样注入
[ ] WebSocket 是否可用
[ ] 应用重启后 Socket 是否自动重建
```
产出:
```text
docs/fnos-unified-gateway-poc.md
```
### 阶段 1:Havline 支持 Unix Socket
目标:主程序可以同时监听 TCP 和 Unix Socket。
改造:
```text
internal/config
internal/app
启动日志
停止清理
```
验收:
```text
[ ] TCP 6893 正常
[ ] Unix Socket 正常
[ ] Socket 权限正确
[ ] 容器重启后 Socket 重建
```
### 阶段 2:前端 base path
目标:Havline 可以在 `/app/havline` 下完整运行。
改造:
```text
Vite base
Vue Router base
API client base
EventSource base
WebSocket base
Cookie Path
CSRF / Origin
```
验收:
```text
[ ] 页面打开
[ ] 静态资源加载
[ ] 登录
[ ] API
[ ] SSE
[ ] WebSocket
[ ] 刷新深层路由
```
### 阶段 3:飞牛包切换统一网关入口
目标:桌面入口走 `/app/havline`。
改造:
```text
app/ui/config
docker-compose.yaml
```
保留:
```text
18080/9443 端口
端口回退入口
```
验收:
```text
[ ] 飞牛桌面入口打开正确
[ ] 不需要端口入口即可访问管理界面
[ ] 18080/9443 功能不受影响
```
### 阶段 4:网关 SSO
目标:使用 `X-Trim-*` 自动识别飞牛管理员。
改造:
```text
HAVLINE_TRUST_GATEWAY_HEADERS=true
只对 Unix Socket 请求生效
管理员映射
非管理员处理策略
```
验收:
```text
[ ] 管理员免登录
[ ] 非管理员被正确拒绝或只读
[ ] TCP 请求伪造 Header 无效
```
### 阶段 5:体验收敛
目标:统一网关成为默认入口,端口仅保留给 Havline 自身功能。
验收:
```text
[ ] 飞牛桌面入口只显示统一网关入口
[ ] 端口入口不再出现在应用卡片
[ ] 升级、卸载、重装正常
```
## 十二、验收清单
```text
[ ] gatewayPrefix 稳定为 /app/havline
[ ] gatewaySocket 稳定为 app.sock
[ ] ${TRIM_APPDEST} 正确挂载
[ ] Havline 监听 Unix Socket
[ ] Socket 启动前清理残留文件
[ ] Socket 权限满足网关访问
[ ] 前端使用 /app/havline base
[ ] API、SSE、WebSocket 路径正确
[ ] Cookie 和 CSRF 正常
[ ] 端口回退仍可用
[ ] 飞牛管理员可通过网关访问
[ ] 非管理员访问策略明确
[ ] 升级后数据不丢
[ ] 卸载默认保留数据
[ ] amd64 与 arm64 镜像可启动
```
## 十三、风险与未决问题
| 风险 | 影响 | 处理 |
| --- | --- | --- |
| 网关是否剥离 `/app/havline` 前缀未知 | 前端 base path 可能不一致 | 阶段 0 PoC 实测 |
| Socket 属主和权限未知 | 网关无法访问 Socket | PoC 验证并在启动时 chmod |
| 前端绝对路径较多 | 网关下页面或 API 404 | 统一引入 base path |
| SSE / WebSocket 路径 | 实时日志和状态页不可用 | 阶段 2 专项测试 |
| 双登录体验 | 用户需要登录两次 | 阶段 4 再做 SSO |
| 伪造 `X-Trim-*` Header | 越权风险 | 只信任 Unix Socket 请求 |
| 端口与网关并存 | 入口混乱 | 桌面只注册网关入口 |
| Havline 公网反代功能边界 | 用户误解为被替代 | 文档明确说明 |
| 飞牛审核要求 | 上架被拒 | 提交前复核官方审核项 |
| 镜像多架构兼容 | 飞牛 Docker 28.5.2 对 OCI index 兼容性有限 | 使用 Docker schema2 兼容格式 |
## 十四、推荐落地顺序
```text
1. 做最小统一网关 Docker PoC
2. 确认前缀、Socket 权限、Header、WebSocket
3. 主程序增加 Unix Socket 监听
4. 前端增加可配置 base path
5. 飞牛包注册统一网关入口
6. 保留端口回退
7. 实测通过后再做网关 SSO
8. 最后收敛桌面入口
```
## 十五、结论
Havline 不需要从 Docker 应用模板重写成另一种应用,而应在现有 Docker
应用上叠加统一网关访问模型。
推荐的最终形态:
```text
运行形态:飞牛 Docker 应用
访问模型:统一网关 /app/havline
服务监听:TCP 6893 + Unix Socket app.sock
用户体系:首版保留 Havline 登录,后续再做网关 SSO
功能边界:Havline 继续管理公网反代、DDNS、证书、FRP 和 Cloudflare Tunnel
回退能力:保留 18080/9443 端口
```
这样既能获得飞牛系统统一的 HTTPS 域名、登录态和桌面入口体验,又不会
破坏 Havline 作为独立 NAS 公网访问管理工具的核心能力。
-228
View File
@@ -1,228 +0,0 @@
# FRP 服务端列表与公网 Agent 页合并方案
> 问题:`/frp`(内网穿透)页里的「公网服务端」列表,是否应该并入 `/frp-agent`(公网 Agent)页——像 DDNS 页那样「列表 + 详情同页」?
>
> 依据:本仓库 `web/src/layouts/AppLayout.vue`、`web/src/router/index.ts`、`web/src/views/{FrpView,AgentView,DdnsView,ProxyRemoteView}.vue`(读代码确认,非推测)。
> 更新日期:2026-09-24
---
## 一、现状盘点
### 1.1 侧边栏与三个 FRP 相关页
`web/src/layouts/AppLayout.vue:171-179` 的菜单顺序:
| 菜单 | 路由名 | 视图 | 页面职责 |
| --- | --- | --- | --- |
| 内网穿透 | `frp` | `FrpView.vue` | 客户端状态 + **公网服务端列表(增删改查 + 启停/测速/校验/日志)** + 穿透规则列表 + 8 个弹窗 |
| 公网 Agent | `frp-agent` | `AgentView.vue` | **单个服务端**的 agent 详情:运行状态 / 环境与依赖(含 VPS 资源、VPS 证书)/ frps 控制 / Agent 生命周期(SSH)/ 输出与日志 |
| 公网反代 | `public-proxy` | `ProxyRemoteView.vue` | 单个服务端的公网反代台账(部署 / 移除 / 健康 / 漂移) |
### 1.2 重叠矩阵(同一件事现在有几处入口)
| 能力 | 内网穿透页 | 公网 Agent 页 | 公网反代页 | 说明 |
| --- | --- | --- | --- | --- |
| 服务端增删改 | ✅ 列表 + 弹窗表单 | ❌ | ❌ | 唯一入口在 `/frp` |
| 服务端列表(名称/主机/端口/延迟/位置/配置/启动状态) | ✅ `n-data-table` 8 列 | ❌(只有顶部下拉选服务端) | ❌(顶部下拉) | **同一份数据的两种呈现** |
| 启动 / 停止 frpc | ✅ 行内按钮 | ❌ | ❌ | |
| 查看服务端详情 | ✅ 弹窗(代理/服务器状态/诊断) | ✅ 整页 | ❌ | **内容重叠** |
| frps 安装 / 启停 / 版本 | ❌ | ✅ | ❌ | |
| Agent 安装 / 升级 / 卸载 / SSH 诊断 | ❌(只有「Agent 管理」跳转按钮) | ✅ | ❌ | |
| Agent 环境探测 / Nginx 安装 | ❌ | ✅ | ❌ | |
| 穿透规则 | ✅ | ❌ | ❌ | 内网穿透页的核心 |
| 公网反代规则 | ❌ | ❌ | ✅ | |
**结论**:服务端本身(连接配置 + 启停)归 `/frp`,而「这台服务端上的 agent 与 frps 状态」归 `/frp-agent`。两页通过 `/frp` 列表行内的「Agent 管理」按钮跳转,跳转后 AgentView 用**顶部下拉**重新选一遍服务端——**同一个对象被选两次**,这是用户感到割裂的根源。
### 1.3 现有跳转关系(合并时要一并处理)
| 来源 | 目标 | 位置 |
| --- | --- | --- |
| `/frp` 服务端行「Agent 管理」 | `/frp-agent` | `FrpView.vue` `serverColumns` 操作列 |
| `/frp` 穿透规则区(未配置服务端时的空态) | 本页「添加服务端」 | `FrpView.vue` |
| `/public-proxy` 空态「尚未配置 FRP 服务端」 | `/frp` | `ProxyRemoteView.vue` |
| `/frp-agent` Nginx 卡「去『公网反代』处理」 | `/public-proxy` | `AgentView.vue` |
| `/ddns` 顶部提示「FRP 内网穿透已启用」 | `/frp` | `DdnsView.vue` |
---
## 二、DDNS 页作为参照:它是什么模式
`DdnsView.vue` 的结构(`PageHeader` → `stats-row` → 详情卡 → 弹窗):
- **一页承载「列表 + 选中项详情」**:页面顶部是全局动作(「全部刷新」)与 5 张统计卡(外网 IP 检测 / 任务总数 / 正常 / 异常 / 最后同步),下面直接是**当前任务的详情分区**(`detail-section` 卡片),新增/编辑走弹窗。
- 也就是说:**用户不需要在「列表页」和「详情页」之间跳**,选中的对象在同一页内切换。
对照到 FRP:`/frp-agent` 现在已经完全是「单对象详情页」的形态(顶部下拉选服务端 + 分区详情),**缺的正是 DDNS 那样的列表**——把 `/frp` 的服务端列表搬进来,形态立刻与 DDNS 一致,而且顶部下拉可以退化为「列表选中行」的结果(或保留下拉作为快捷切换)。
---
## 三、三个方案对比
| 方案 | 做法 | 优点 | 缺点 | 工作量 |
| --- | --- | --- | --- | --- |
| **A(推荐)** | 把服务端列表并入 `/frp-agent`:页面改为「统计区 + 服务端列表 + 选中服务端的 agent 详情」;`/frp` 只保留客户端状态与穿透规则 | 与 DDNS 形态一致;服务端管理只在一处;列表与详情同屏,不再「选两次」;`/frp` 回归「穿透规则」本职 | 服务端表单弹窗(含 frps 选项 / 远程 / Agent / SSH 四个分栏)要搬到 AgentView;两页文案与跳转需同步 | 中,约 1–1.5 天 |
| **B** | 反向合并:把 agent 详情塞进 `/frp` 行内的「查看」弹窗 | 不动列表位置 | AgentView 现有 5 个分区 + 2 张并排卡 + 日志区,弹窗塞不下;等于推倒已成型页面重做 | 大且不划算 |
| **C** | 不动结构,只补深链:「Agent 管理」跳转带 `?server=<id>`,AgentView 读 query 自动选中 | 改动最小(约 0.5h);消除「再选一次」的割裂感 | 仍是两个页面,服务端管理仍分散 | 小 |
**推荐 A,并把 C 作为 A 的第一步**:即使 A 中途暂停,深链带来的收益已经落地,且不会与 A 的后续步骤冲突。
---
## 四、方案 A 详细设计
### 4.1 目标页面结构(`/frp-agent`,参照 DDNS)
```
PageHeader「公网服务端」(原「公网 Agent」)
actions: [选择服务端 ▾](快捷切换,选项与列表一致) [刷新]
├─ stats-row:沿用现有 5 张卡(Agent 状态 / Nginx / frps 服务 / 反代路由 / 端口监听)
├─ 服务端列表(从 /frp 迁入)
│ n-data-table 8 列:名称 / 主机 / 端口 / 延迟 / 位置 / 配置 / 启动状态 / 操作
│ 操作列:启动·停止 · 查看 · 日志 · 测速 · 校验 · 编辑 · 删除
│ (去掉「Agent 管理」——它就在本页;行点击即选中)
├─ 选中服务端的详情(现有 AgentView 分区原样搬过来)
│ 环境与依赖(Havline Agent / Nginx / frps 运行时 / VPS 资源 / VPS 证书)
│ frps 控制 + Agent 生命周期(SSH)
│ 输出与日志
└─ 弹窗:服务端表单(新增/编辑)、服务端日志、服务端详情、frps 同源配置、导出、FRP 应用配置
```
`/frp`(内网穿透)保留并收敛为:
- 4 张状态卡(客户端状态 / 启用规则 / frpc 版本 / 最近应用)
- 穿透规则列表 + 规则相关弹窗(规则表单、规则详情、FRP 应用配置)
- 空态:「尚未配置服务端」→ 改为链接到 `/frp-agent`(原来是本页直接开「添加服务端」弹窗)
### 4.2 数据流与选中态
- 列表数据 `api.listFrpServers()`:两页现在各自拉一次,迁入后只拉一次(AgentView 本来就在拉)。
- 选中态:沿用 AgentView 的 `selectedId`;新增**行点击选中 + 选中行高亮**。
- 深链:`/frp-agent?server=<id>` → 初始化时读 query 决定 `selectedId`(即方案 C,作为第一步落地)。
- `/frp` 的「Agent 管理」跳转改为 `router.push({ name: 'frp-agent', query: { server: id } })`。
- 列表为空:整页 EmptyState(AgentView 已有)+「添加服务端」按钮直接开弹窗(不再跳去 `/frp`)。
### 4.3 弹窗归属(关键决定)
| 弹窗 | 现在位置 | 迁移后 | 判据 |
| --- | --- | --- | --- |
| 服务端表单(新增 / 编辑) | `/frp` | `/frp-agent` | 服务端的「家」在这里;`/frp` 不再需要 |
| 服务端日志 / 服务端详情 | `/frp` | `/frp-agent` | 都是单服务端视角,与详情区同屏更好 |
| frps 同源配置 / 导出脱敏配置 | `/frp` | `/frp-agent` | 依赖服务端的 frps 选项 → 属服务端视角 |
| 规则表单 / 规则详情 / FRP 应用配置 | `/frp` | 不动 | 与穿透规则绑定 |
实现时以「弹窗依赖的是**服务端**还是**规则**」为判据,不要按文件位置机械搬运。
### 4.4 命名与文案
- 页面标题与菜单:`公网 Agent` → **`公网服务端`**(或「公网服务端(Agent)」,二者都行,推荐前者,因为它现在同时管服务端与 agent)。
- **路由名 `frp-agent` 与路径 `/frp-agent` 保持不变**,避免破坏深链、侧边栏高亮与已有跳转;只改显示文案。
- `/frp` 的「公网服务端」卡片整体移除,其 `subtitle`("可添加并管理多个 frps 服务端连接")文案迁到新页描述。
### 4.5 空态与降级
- 没有服务端:`/frp-agent` 显示 EmptyState + 「添加服务端」;`/frp` 规则区空态改为「先去『公网服务端』添加服务端」。
- 服务端未配 agent/SSH:详情区沿用现有降级(`agentStatusOk`、状态告警),列表「配置」列沿用现有 TLS/Token 标签。
- agent 版本过低:沿用 404/405 降级文案,无需新逻辑。
---
## 五、迁移步骤(四步,每步独立可交付 / 可回滚)
| 步骤 | 内容 | 交付标准 | 预估 |
| --- | --- | --- | --- |
| **步 1 · 深链** | `/frp` 的「Agent 管理」改为 `router.push({ name: 'frp-agent', query: { server: id } })`;`AgentView` 初始化读 query 决定 `selectedId` | 从任意一行点进 Agent 页,直接落在该服务端(不再需要再选一次) | 0.5h |
| **步 2 · 列表迁入** | 把 `serverColumns`、列表卡片、行操作、服务端表单 / 服务端日志 / 服务端详情弹窗整体搬到 `AgentView`;行点击即选中并高亮;`FrpView` 删除该卡片与相关弹窗、`serverColumns`、`openServerModal` 等 | 新增 / 编辑 / 启停 / 删除 / 测速 / 校验 / 日志全部在新页可用;`/frp` 只剩状态卡 + 规则 | 0.5 天 |
| **步 3 · 收尾** | 菜单与页面标题改「公网服务端」;`/frp` 规则区空态改指向新页;`ProxyRemoteView` / `DdnsView` 里「去内网穿透页添加服务端」这类文案改指向 `/frp-agent`(指向规则页的链接不动);核对侧边栏顺序(内网穿透 → 公网服务端 → 公网反代) | 所有入口文案自洽 | 0.25 天 |
| **步 4 · 清理** | 删除迁移后残留的死样式与死变量(如 `/frp` 里只服务端用到的 `.server-detail*`、`.frp-detail-card` 等);`grep` 校验无残留引用 | `vue-tsc` + `vite build` 通过,grep 旧类名/旧函数名为 0 | 0.25 天 |
**实施状态(2026-09-24)**:**步 1、步 2 已完成**。
- **步 1 · 深链**:`FrpView.vue` 的「Agent 管理」改为 `{ name: 'frp-agent', query: { server: String(row.id) } }`;`AgentView.vue` 新增 `useRoute` 并在 `selectedId` 初始化时读 `?server=<id>`(非法 id 由 `load()` 回落为第一台)。
- **步 2 · 列表迁入**:服务端列表卡片、服务端表单 / 日志 / 详情 / frps 同源配置四个弹窗、`serverColumns` 与全部服务端脚本、相关样式整体迁入 `AgentView.vue`(971 → 1935 行),`FrpView.vue` 删除同批代码(2189 → 1425 行)。实施要点:
- 行点击即选中(`serverRowProps` + `.frp-server-row--active` 高亮),与顶部下拉共用 `selectedId`;操作列去掉「Agent 管理」,单元格加 `stopPropagation` 避免点按钮时误选。
- 列表改为展示**全部**服务端(原先按 `agent_configured || ssh_configured` 过滤),顶部下拉同步放开过滤——否则未配 Agent 的服务端无法在本页编辑 / 删除。
- 列表为空时整页 EmptyState + 「添加服务端」直接开表单(不再跳去 `/frp`);`/frp` 规则区在「无服务端」时给出指向 `/frp-agent` 的空态,避免按钮被禁用后无处可去。
- 服务端表单里的「打开管理面板」改为「查看服务端详情」(关弹窗 + 页内选中);详情弹窗「客户端」一行所需的 `runtime` 由新增的 `loadRuntimeVersion()` 拉取。
- 详情弹窗「代理」表依赖的 `proxyPublicEndpoint` 在 `AgentView` 本地补了一份(原属规则侧);表单里的「下发 frps 配置」复用 `AgentView` 既有的 `run('push-config', …)` 版本,输出进「输出与日志」。
- **未迁移**:`/frp` 的「查看脱敏配置」弹窗(`showExport`)留在原页——触发按钮在「穿透规则」卡片头部,导出物是脱敏 frpc.toml,属客户端 / 规则视角。这是按 §4.3 自己的判据(弹窗依赖服务端还是规则)做的取舍,与 §4.3 表格里「导出脱敏配置 → 迁走」一行不同。
- 验证:`vue-tsc --noEmit` 0 错误、`vite build` 通过、`FrpView.vue` 内旧标识符 grep 为 0、全仓无 0 字节源文件、`bin\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` 仍被规则表单弹窗使用,不能删。
- **布局调整(2026-09-24,用户要求对齐 DDNS 页)**:`/frp-agent` 改为 DDNS 那样的**左列表 + 右详情**——`.agent-layout { grid-template-columns: minmax(260px, 320px) minmax(0, 1fr) }`,≤1100px 收成一列;侧栏结构照抄 DDNS 的 `.ddns-layout` / `.task-sidebar`(类名换成本页语义的 `.server-sidebar*` / `.server-item*`)。
- 原 8 列 `n-data-table` 换成紧凑列表项:名称 + 启动状态标签 + `主机:端口` + 延迟 / 位置 + TLS / Token / Agent / SSH 标签;行点击即选中(`--active` 高亮)。
- 行内操作移到右侧工具条 `.server-toolbar`(启动·停止 · 查看 · 日志 · 测速 · 校验 · 编辑 · 删除),标题旁显示当前服务端名与端点。
- `serverColumns` / `serverRowProps` / 顶部「选择服务端」下拉及其 `serverOptions` 一并删除(左侧列表即选择器);页面标题改「公网服务端」。
- 与 §4.1 里「统计区 → 列表 → 详情」的**堆叠**结构不同,这是用户确认后的取舍;右侧内容顺序不变(状态提示 → 公网反代入口 → ① 运行状态 → ② 环境与依赖 → ③④ → ⑤ 输出与日志)。
- **`/frp` 布局调整(2026-09-24,用户两次纠正后定稿)**:`/frp` 改成**左:服务端列表 / 右:该服务端的穿透规则**——左侧 `.server-sidebar`(名称 + `boot_status` 状态标签 + `主机:端口` + 规则数 / 启用数 + TLS / Token / Agent / SSH 标签,点行即切换服务端);右侧 `.rule-pane`(头部显示当前服务端与规则条数,含「查看脱敏配置 / 管理该服务端(深链到 `/frp-agent?server=<id>`)/ 添加规则」)+ **原来的穿透规则表格**(恢复 `columns`,`:data="serverProxies"` 按选中服务端过滤;按用户要求删掉冗余的「服务端」列,`:scroll-x` 2020 → 1890);规则详情回到弹窗(表格「详情」按钮 → `openProxyDetail`,速率采样随弹窗开关)。顶部 4 张客户端状态卡保持整宽。左侧列表的状态标签只用列表接口就带的 `boot_status`,不为此重新引入已迁走的 `serverDiagnostics` / `serverStartupText`。
- **步 3(文案与入口收尾)、步 4(死样式清理)待做**。
### 5.1 步 2 执行清单(含 `FrpView.vue` 代码锚点)
> 行号为 2026-09-24 快照;执行时以标识符为准(行号仅用于快速定位)。
**模板(迁出 → `AgentView.vue`)**
| 片段 | 行 | 备注 |
| --- | --- | --- |
| 「公网服务端」卡片(含 `serverColumns` 驱动的 `n-data-table`) | 53–77 | 卡片本身整块搬走 |
| 服务端表单弹窗 | 约 350–500 | 四个分栏;内含「公网 Agent 管理」段(219–236),其中 `<router-link to="/frp-agent">` 迁入后应改为**页面内选中** |
| 服务端日志弹窗 | 512–528 | |
| 服务端详情弹窗 | 529–564 | `preset="card"` + `.frp-detail-card` |
| 导出脱敏配置弹窗 | 565–576 | 触发点在 81 行按钮 |
| frps 同源配置弹窗 | 580–600 | 含 P1-3 的「比对 VPS 实际文件」 |
**脚本(迁出)**
- 状态:`servers`(924)、`serverDiagnostics`(932)、`testingId`(935)、`agentActionId`(937)、`savingServer`(939)、`showServerModal`(941)、`showExport`(943)、`showServerDetail`(946)、`showFRPSConfig`(949)、`frpsConfigContent`(950)、`frpsConfigTarget`(992)、`serverFormTab`(996)、`exportContent`(997)、`editingServer`(1015)、`viewingServer`(1016)、`serverForm`(1037)、`showServerLogs`(1408)
- 函数:`openServerModal`(1370)、`openServerDetail`(1381)、`openServerLogs`(1413)、`saveServer`(1510)、`testAgent`(1534)、`pushAgentConfig`(1541)、`startServer`(1599)、`stopServer`(1610)、`testServer`(1772)、`openFRPSConfig`(1797)
- `serverColumns`(约 1160–1200,其中 1180 行的「Agent 管理」`RouterLink` 迁入后删除)
- 一并迁出的辅助:`serverStartupText` / `latencyOf` / `buildServerPayload`(内联在 `saveServer`)/ `ConfiguredSecretField` 组件与相关图标导入
**留在 `/frp`**:4 张状态卡、穿透规则列表、规则表单 / 规则详情 / FRP 应用配置等规则侧弹窗与逻辑。
**执行注意**
1. 迁出后 grep `FrpView.vue` 里旧标识符(`serverForm` / `showServerModal` / `serverColumns` / `openServerLogs` / `frpsConfigTarget` / `showExport` / `editingServer` / `viewingServer`)应为 **0**;
2. `busy` / `run()` / `message` 若被规则侧共用,**不要整体迁走**——保留在 `FrpView` 或按需抽成 composable;
3. 迁入 `AgentView` 后:行点击 = `selectedId = row.id`,选中行加高亮类;操作列去掉「Agent 管理」;
4. 迁完后 `/frp` 的规则区空态改指向 `/frp-agent`(步 3)。
---
## 六、影响面清单
- **前端**:`FrpView.vue`(删列表与 4 个服务端弹窗 + 死样式)、`AgentView.vue`(加列表与弹窗 + 行选中)、`AppLayout.vue`(菜单文案)、`DdnsView.vue` / `ProxyRemoteView.vue`(指向服务端配置的文案与链接)、`router/index.ts`(**不改**,仅确认)。
- **后端**:**零改动**——所有接口都已存在且两页共用(`listFrpServers` / `createFrpServer` / `updateFrpServer` / `deleteFrpServer` / `startFrpServer` / `stopFrpServer` / 诊断与日志等)。
- **文档**:`docs/agent-view-design-v2.md` 若把「公网 Agent」定义为「单服务端详情页」,需补一句「现已并入服务端列表」;本文在实施完成后标注状态。
---
## 七、风险与回滚
| 风险 | 说明 | 应对 |
| --- | --- | --- |
| 弹窗搬迁漏掉入口 | 某个弹窗可能不止一个触发点 | 迁移前对每个弹窗 `grep` 触发点逐个确认归属;搬迁后 grep 旧文件确认无残留引用 |
| `selectedId` 语义冲突 | 两页各有一份选中态 | 迁入后 `/frp` 不再持有 `selectedId`,并确认没有别的逻辑依赖它 |
| 首屏变慢 | 列表 + 状态 + 资源 + 证书并发请求 | 列表与状态走同一轮 `Promise.all`,资源与证书仍单独异步(沿用现状) |
| 深链指向不存在的 id | 手工改 URL 或服务端已删除 | 回落为列表第一个(AgentView 现有逻辑已是「不在列表就取第一个」) |
| 回滚 | 步 2 是主要风险点 | 四步各自独立提交:步 1/2/3 可分别 revert,不会互相牵连 |
---
## 八、验收清单
- `/frp-agent` 一页内完成:选服务端(列表行或顶部下拉)→ 看 Agent / Nginx / frps / VPS 资源 / VPS 证书 → 启停 frpc → 编辑 / 删除服务端 → 看服务端日志、详情与 frps 同源配置。
- `/frp` 只剩客户端状态卡 + 穿透规则;规则的新增 / 编辑 / 应用 / 导出不受影响。
- 深链:从任意页用 `?server=<id>` 直达该服务端;id 不存在时不报错。
- 文案自洽:菜单、页面标题、空态、跨页链接都指向 `/frp-agent`。
- 构建:`vue-tsc --noEmit` 0 错误、`vite build` 通过、无残留死代码(grep 旧类名与旧函数名)。
---
## 九、明确不做
1. **不把「穿透规则」也搬进 AgentView**:规则属于内网穿透页,且 `/frp` 需要保留「状态卡 + 规则」以维持「配置 → 应用 → 查看」的闭环。
2. **不把 `/public-proxy` 的反代台账并进来**:它虽按服务端维度,但属另一业务,且已有统计区 + 台账 + 漂移清单的成熟形态。
3. **不改路由路径与路由名**(只改显示文案),避免破坏深链、侧边栏高亮与已有跳转。
4. **不动后端接口**:本次是纯前端信息架构调整。
-121
View File
@@ -1,121 +0,0 @@
# frps(服务端)功能实现清单
> 基于 frp v0.71.0 官方 `frps_full_example.toml` 全量对照。
> 状态图例:✅ 已实现 🔶 部分实现(有缺口或需手动步骤) ⬜ 未实现 🚫 有意不实现(有说明理由)
>
> 「Havline 入口」= 用户在 Havline 界面操作的位置;「生成器」= `internal/frp/frps_template.go` 的 frps.toml 同源生成器。
> 更新日期:2026-09-21
---
## 一、网络与监听([common] 顶层键)
| 配置项 | 官方默认 | 状态 | 说明 |
| --- | --- | --- | --- |
| `bindAddr` | `0.0.0.0` | ✅ | 固定生成 `0.0.0.0` |
| `bindPort` | `7000` | ✅ | 取服务端表单「端口」,与 frpc 同源 |
| `kcpBindPort` | 0(禁用) | ✅ | 表单「协议」选 kcp 时生成 |
| `quicBindPort` | 0(禁用) | ✅ | 表单「协议」选 quic 时生成 |
| `proxyBindAddr` | 同 bindAddr | ⬜ | 未提供(UDP/HTTP 代理单独绑定地址的场景才需要) |
| `vhostHTTPPort` | 0(禁用) | ✅ | 有 http/https 规则时生成;**表单「远程管理 → frps vhost 反代端口」可自定义**,默认 8080 |
| `vhostHTTPSPort` | 0(禁用) | ✅ | 同上,默认 8443 |
| `vhostHTTPTimeout` | 60 | ⬜ | 未提供(vhost 响应超时,一般无需改) |
| `tcpmuxHTTPConnectPort` | 0(禁用) | ⬜ | tcpmux 需要它(存在 tcpmux 规则时生成器只提示 vhost,未生成此键) |
| `tcpmuxPassThrough` | false | ⬜ | 未提供 |
## 二、认证(auth.*)
| 配置项 | 官方默认 | 状态 | 说明 |
| --- | --- | --- | --- |
| `auth.method`(token) | `token` | ✅ | 与 frpc 同源;Token 加密存储,生成时解密写入 |
| `auth.token` | — | ✅ | 同上 |
| `auth.method`(none) | — | ✅ | 生成安全提示注释 |
| `auth.oidc.*`(issuer/clientID/clientSecret/audience/scope/tokenEndpointURL 等) | — | 🔶 | frpc 侧 OIDC 表单已支持;frps 侧校验参数依赖 IdP,生成器只输出注释指引手动配置 |
## 三、TLS(transport.tls.*)
| 配置项 | 官方默认 | 状态 | 说明 |
| --- | --- | --- | --- |
| `transport.tls.force` | false | 🔶 | frpc 侧未启用 TLS 时生成**注释**建议开启;无表单开关直接生成 |
| `transport.tls.certFile / keyFile` | — | ⬜ | frps 侧 TLS 证书未提供(frpc 侧证书表单已有,frps 反向 TLS 需另加) |
| `transport.tls.trustedCaFile` | — | ⬜ | 未提供 |
## 四、管理面板(webServer.*)
| 配置项 | 官方默认 | 状态 | 说明 |
| --- | --- | --- | --- |
| `webServer.addr / port` | `127.0.0.1:0` | ✅ | 表单「frps 管理接口」地址/端口;Havline 借此拉流量;「frps 管理」按钮直达面板 |
| `webServer.user / password` | — | ✅ | 表单同源;密码加密存储(ConfiguredSecretField 掩码闭环) |
| `webServer.tls.certFile / keyFile` | — | ⬜ | dashboard 自身 HTTPS 未提供 |
## 五、HTTP / 子域名(vhost 语义)
| 配置项 | 官方默认 | 状态 | 说明 |
| --- | --- | --- | --- |
| `subDomainHost` | "" | 🔶 | **缺口检测已实现**:规则填了 subdomain 时生成器输出注释提醒必须配置;但表单无字段、不能直接生成 |
| `custom404Page` | — | ⬜ | 未提供 |
| `httpPlugins`(server 端) | — | ⬜ | frps 端 HTTP 插件未提供(frpc 端 8 种插件表单已支持,两者是不同层的功能) |
## 六、限流与配额
| 配置项 | 官方默认 | 状态 | 说明 |
| --- | --- | --- | --- |
| `allowPorts`(端口白名单/范围) | 不限 | ⬜ | 未提供 |
| `maxPortsPerClient` | 0(不限) | ⬜ | 未提供 |
| `userConnTimeout` | 10 | ⬜ | 未提供 |
## 七、传输调优(transport.* 其余)
| 配置项 | 官方默认 | 状态 | 说明 |
| --- | --- | --- | --- |
| `transport.tcpMux` | true | ✅ | 与 frpc 同源;仅关闭时输出 |
| `transport.tcpMuxKeepaliveInterval` | 30 | ✅ | 表单「高级配置」同源(frpc 侧);frps 生成器未输出该键,两端不一致时 TCPMux keepalive 各用默认值——**低风险缺口** |
| `transport.maxPoolCount` | 0(按 frpc) | ✅ | 有 pool_count 时输出 |
| `transport.heartbeatInterval / timeout` | 30/90 | ✅ | frpc 侧表单同源;frps 生成器不输出(服务端跟随客户端心跳,官方无需配置) |
| `transport.tls.force` | — | 🔶 | 见第三节 |
## 八、运行与日志
| 配置项 | 官方默认 | 状态 | 说明 |
| --- | --- | --- | --- |
| `log.to / level / maxDays` | console | ⬜ | frps 日志走 journalctl(agent 采集尾部显示),未提供文件日志配置生成 |
| `log.disablePrintColor` | false | ⬜ | 未提供 |
| `detailedErrorsToClient` | true | ⬜ | 未提供 |
| `printStats`(每 30s) | — | ⬜ | 未提供 |
## 九、用户隔离与资产
| 配置项 | 官方默认 | 状态 | 说明 |
| --- | --- | --- | --- |
| 多用户 `user` 前缀隔离 | — | ⬜ | 未提供(frps 端 `user` 是 frpc 登录名前缀隔离,与 Havline 的服务端 user 概念不同) |
| `udpPacketSize` | 1500 | ⬜ | 未提供(需与 frpc 同源,frpc 侧也无此表单项) |
## 十、Havline Agent 侧的 frps 运维能力(与配置生成配套)
| 功能 | 状态 | 说明 |
| --- | --- | --- |
| 一键安装 / 升级(SSH) | ✅ | 原子替换 + `-version` 校验,失败不伪报成功 |
| systemd 托管(frps.service) | ✅ | 坏 unit 自动接管重写 |
| 状态可视(进程/端口/版本/日志尾/dashboard 地址) | ✅ | 「公网 Agent」页 |
| 启动 / 停止 / 重启 | ✅ | 含 `ok:false` 失败契约与 journal 透出 |
| 配置下发(同源 frps.toml) | ✅ | 「下发 frps 配置」按钮 |
| 官方版本安装 / 升级(下拉选择) | ✅ | 含下载代理 |
| 证书下发(HTTPS 规则) | ✅ | `/api/v1/certs` |
| **公网 Nginx 反代手动部署 / 移除** | ✅ | 试探-验证式目录自动发现(`-T` 加载验证),规则保存与反代解耦 |
| **vhost 端口自定义** | ✅ | 表单 → frps.toml → Nginx upstream 三方同源 |
---
## 优先级建议(下一步最值得补的)
1. **`subDomainHost` 表单字段**(🔶):subdomain 功能目前只能靠手动改 frps.toml,是 vhost 体系里最后一块手动拼图;配合 `custom404Page` 一起做收益最高;
2. **`transport.tls.force` 开关**(🔶):一条注释提示不如一个开关——公网 frps 的标准安全实践;
3. **`allowPorts` / `maxPortsPerClient`**(⬜):多用户/防滥用场景需要;单用户自用优先级低;
4. **`tcpmuxHTTPConnectPort`**(⬜):存在 tcpmux 规则时的硬依赖,用到 tcpmux 的话必须补;
5. **frps 日志配置**(⬜):journalctl 尾部只能看 6 行,排障时不够;生成 `log.to = "/var/log/frps.log"` 即可接入现有日志查看。
## 附:架构口径
- **frps.toml 是生成器产物,不是用户手写文件**:所有已实现项均以 Havline 服务端表单为唯一事实源,VPS 侧禁止手改(改了会被下次「下发 frps 配置」覆盖);
- **端口同源链**:表单 `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 托管;二者通过 Havline 下发配置保持一致。
-199
View File
@@ -1,199 +0,0 @@
# FRPS 服务端管理方案(havline-agent,部署于公网服务器)
> 需求:frpc 端(Havline/NAS)提交穿透规则后,**公网服务器端自动完成反向代理设置**,实现域名无端口访问(`http://tv.36800.cc`),并自动管理服务器侧证书与 frps。
> 实现形态:在公网 VPS 上部署一个常驻管理组件 **havline-agent**(单个 Go 二进制 + systemd),对外提供管理 API;Havline 在规则变化时调用该 API,agent 在 VPS 本地完成 Nginx 反代配置、frps 管理与证书部署。
> **安装与管理通道**:agent 的**首次安装、升级、卸载通过 SSH 由 Havline 远程完成**(用户在 Havline 界面填 SSH 信息点按钮即可,无需手工登 VPS);装好后 agent 以 **API** 接受日常管理。管理面板全部在本地 Havline,VPS 上没有任何面板。
> 修订记录:v2 混合形态(SSH 装/卸 + API 管理);v1 为纯 SSH 推送方案,v2 初稿为「agent 纯 API」方案,两版历史见文末对照。
## 1. 目标与效果
| 诉求 | 达成方式 |
| --- | --- |
| frpc 提交规则后 frps 端自动反代 | Havline 保存规则 → 调 agent API 注册域名路由 → agent 写 Nginx vhost 并 reload |
| 无端口访问 | agent 的 Nginx 监听 80(443),`proxy_pass` 到 frps vhost 端口,URL 不带端口号 |
| 管理证书 | Havline 证书页(DNS-01)签发后一键推送到 agent,自动挂 443 并续期重推 |
| frps 管理 | 配置下发(vhost 端口等)、进程重启/状态/版本、端口占用诊断,全部 API 化 |
| VPS 上已有 Nginx/站点 | 共存:agent 只写自己的 vhost 文件(`conf.d/havline-*.conf`),不动其他配置 |
最终链路(http 规则为例):
```text
用户 → http://tv.36800.cc(80,无端口)
→ VPS Nginx(agent 生成的 vhost)→ 127.0.0.1:8080(frps vhostHTTPPort)
→ frps 按域名路由 → FRP 隧道 → NAS frpc → 192.168.31.152:7023
```
## 2. 总体架构
```text
Havline (NAS,管理面板所在地) 公网服务器 (VPS)
┌──────────────────────────────┐ ┌────────────────────────────────┐
│ FRP 模块(现有) │ │ frps :7000 控制 / 8080 vhost │
│ 规则 CRUD / frpc / frps.toml │ │ nginx :80/443 │
│ 证书页(lego DNS-01,已有) │ │ └─ havline-*.conf(agent 产物) │
│ ┌────────────────────┐ │ │ havline-agent :7700(常驻) │
│ │ SSH 安装器(新增) │──SSH──┼→ 首次安装/升级/卸载 agent │
│ │ agent 客户端(新增) │──API──┼→ 日常管理:路由/配置/证书/状态 │
│ └────────────────────┘ │ │ ├─ 路由管理(nginx vhost 编排) │
│ 管理面板 = Havline 网页 │ │ ├─ frps 管理(配置/进程/诊断) │
│ (规则页/证书页/管理弹窗) │ │ └─ 证书接收与挂载 │
└──────────────────────────────┘ └────────────────────────────────┘
```
两条通道各司其职:
- **SSH(低频)**:仅用于 agent 生命周期——安装、升级、卸载、agent 失联自救。用户在 Havline 填一次 SSH 信息(可选保存备用);
- **API(高频)**:日常全部管理——规则同步、frps 配置下发、证书推送、状态查询。面板 100% 在 Havline 本地,VPS 上没有任何管理界面。
职责划分:Havline 是**控制面**(用户在哪配置就存哪),agent 是**执行面**(只接收指令并落实),frps/Nginx 是**数据面**。agent 无状态(配置即文件),可随时重装。
## 3. havline-agent 设计(VPS 侧)
单个 Go 二进制(复用主项目代码编译,`cmd/havline-agent`),systemd 常驻,管理 API 监听 `:7700`(可配置)。
### 3.1 管理 API(Bearer Token 鉴权)
| 方法/路径 | 作用 |
| --- | --- |
| `GET /api/v1/ping` | 连通性/鉴权校验(Havline「测试连接」按钮) |
| `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(失败自动回滚文件) |
| `DELETE /api/v1/routes/{domain}` | 注销路由(规则删除/停用时调用) |
| `GET /api/v1/routes` | 当前路由清单(对账用) |
| `PUT /api/v1/frps/config` | 下发完整 frps.toml(Havline 同源生成)→ 备份旧文件 → 按运行方式重启/重载 → 回读验证 |
| `POST /api/v1/certs` | 部署证书:`{domain, fullchain_pem, key_pem}` → 写 `/var/lib/havline-agent/certs/<domain>/`(0600)→ 更新 443 vhost → reload |
### 3.2 Nginx 编排(反代核心)
- 每个域名一个文件:`/etc/nginx/conf.d/havline-tv.36800.cc.conf`,模板:
```nginx
server {
listen 80;
server_name tv.36800.cc;
location / {
proxy_pass http://127.0.0.1:8080; # frps vhostHTTPPort
proxy_set_header Host $host; # frps 按域名分流,必须保留
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
}
}
# 启用证书后追加 443 server 块(ssl_certificate 指向 agent 证书目录)+ 80 跳转
```
- 写入流程:写 `.tmp` → `nginx -t` 校验 → 通过则替换并 `nginx -s reload`,失败保留原文件并把 `nginx -t` 输出回传给 Havline 显示;
- **只增删自己的 `havline-*.conf`**,绝不触碰其他配置 → 与宝塔/手工站点共存;
- 首次运行自检:nginx 是否安装、`conf.d`/`sites-enabled` 目录探测、80/443 监听者识别(nginx? frps? 其他?)。
### 3.3 frps 管理
- **配置下发**:Havline「frps.toml 同源生成」已有(含 vhost 端口、auth、webServer),改为推送给 agent 落盘:备份旧文件 → 原子替换 → 按运行方式生效(systemd 单元 `systemctl restart frps`;nohup 场景 `pkill frps` 后按原命令拉起,agent 从状态接口学习原启动命令);
- **诊断**:`status` 聚合 frps 版本/存活/配置路径 + `ss -tlnp` 端口占用判定——把之前手工排查(`port unavailable`、80 被占)变成 API 一次调用;
- **边界**:不安装/升级 frps 二进制(检测报告提示版本即可)。
### 3.4 证书
- 签发仍在 **Havline 证书页**(lego DNS-01,复用 DDNS 凭证,NAS 侧完成,VPS 无需参与);
- 部署:证书页「部署到服务器」→ `POST /certs` 推送 PEM → agent 落盘并生成 443 vhost → reload;
- 续期:Havline 定时任务续期成功后自动重推,VPS 侧无感;
- (可选演进)agent 本地 ACME HTTP-01:agent 已在 80 上,可自助签发,作为无 DNS 凭证场景的备选,本期不做。
## 4. 通信协议与安全
| 项 | 设计 |
| --- | --- |
| 鉴权 | Bearer Token(Havline 服务端表单生成/粘贴,agent 配置文件持有;两端一致即可用),`7700` 建议用防火墙限制仅 NAS IP 访问 |
| 传输 | 明文 HTTP 起步可接受(Token + 内网限制);增强路径:agent 用 frps 证书或自签证书提供 HTTPS 管理口 |
| 命令安全 | agent 只执行固定动作(写文件/nginx -t/reload/重启 frps),**不执行 Havline 下发的任意命令**;域名/路径参数做白名单校验(域名格式、路径锁定 `conf.d/havline-*` 与 `/var/lib/havline-agent/`) |
| 幂等 | 路由/配置/证书接口全部幂等,重复调用无副作用;`routes` 对账接口支持 Havline 定时校准 |
| 审计 | agent 每个写操作记本地日志(who/when/what),Havline 侧同步状态入系统日志 |
## 5. Havline 侧改动(frpc 端)
| 模块 | 内容 |
| --- | --- |
| 数据模型 | `frp_servers` 加 `agent_url`、`agent_token_enc`(secret.Box 加密不回显)、`mgmt_enabled`(默认 0,行为与 v1.1.0 完全一致);迁移 `023_frp_server_agent.sql` |
| 规则联动 | 规则保存/删除/启停且 `mgmt_enabled` 时,自动调 agent 路由接口(http/https 类型域名规则);失败不阻塞保存,规则行显示同步状态(已同步/待同步/失败+原因) |
| frps.toml | 「下发到服务器」按钮替代「复制到 VPS」;一键含重启与验证 |
| 证书页 | 「部署到服务器」:选目标服务端 → 推送 PEM → 回显 agent 挂载结果;续期任务成功后自动重推 |
| 管理弹窗 | 服务端「管理」按钮:status 报告(frps 状态/端口占用者/路由清单/证书有效期)+ 一键操作(下发配置/全量对账) |
| API 客户端 | `internal/frp/agent_client.go`:Go HTTP 客户端封装上述 7 个接口(超时/重试/错误透传) |
## 6. agent 部署与生命周期管理(SSH,全程在 Havline 界面完成)
### 6.1 首次安装(用户不登 VPS)
1. Havline 服务端表单「远程管理」分组填 SSH 信息(地址/端口/用户/密码或私钥,可选保存)+ 点「安装 agent」;
2. Havline 经单次 SSH 会话自动完成:
- 探测环境:`uname -m`(选二进制)、nginx 是否安装、`systemd` 可用性、端口占用快照;
- SFTP 上传 `havline-agent` 二进制与 `install.sh`(从 Havline 自带资源解出对应架构);
- 远程执行 `install.sh`:落位 `/usr/local/bin/havline-agent`、写 `/etc/havline-agent/agent.toml`(**生成随机 Token 并回传给 Havline**,无需用户抄写)、注册 `systemd` 单元并启动;
- 安装后立刻走 API `ping` 验证 + 防火墙提示(如 7700 未放行给出命令);
3. UI 展示每步输出,失败可重试(幂等,重复安装=覆盖升级)。
### 6.2 升级 / 卸载 / 自救
| 动作 | 方式 | 说明 |
| --- | --- | --- |
| 升级 agent | SSH:上传新二进制 + `systemctl restart havline-agent` | 路由/证书/Token 均在磁盘,不丢 |
| 卸载 agent | SSH:`install.sh --uninstall`(停服务、可选保留数据) | nginx vhost 文件一并清理(可选项) |
| agent 失联 | API 不可达时,Havline 管理弹窗提示「走 SSH 通道诊断」:进程状态/端口/日志尾部 | SSH 是 API 故障时的备用通道 |
### 6.3 日常管理(API,面板在 Havline)
安装完成后的一切操作不再用 SSH:规则联动同步、frps.toml 下发、证书部署、status 查询、全量对账——全部是 Havline 界面按钮/自动触发,走 agent API。VPS 上没有任何管理面板,用户日常甚至不需要记住 VPS 密码。
## 6.5 Havline 侧新增「SSH 安装器」(与 agent 客户端并列)
| 模块 | 内容 |
| --- | --- |
| `internal/frp/ssh_installer.go`(新) | SSH 连接(x/crypto/ssh 已在 go.mod)、SFTP 上传二进制/install.sh、远程幂等安装流程、Token 回传 |
| agent 二进制内嵌 | 构建期把 `havline-agent` 各架构二进制 embed 进 Havline(构建矩阵:linux-amd64/arm64),安装时按 `uname -m` 选择,无需用户单独下载 |
| 数据模型 | 复用 023 迁移:`ssh_host/port/user/auth/secret_enc` 可选保存(仅启用「安装/升级/卸载」功能时需要),agent API Token 由安装器自动生成并写入 Havline |
| UI | 服务端表单「远程管理」:SSH 信息(可选)+「安装 agent / 升级 / 卸载 / 走 SSH 诊断」按钮,安装过程实时输出展示 |
> SSH 信息**可选保存**:仅安装/升级时需要;日常管理走 API Token。安全上 SSH 凭据同 token 机制(secret.Box 加密、不回显),远程命令固定白名单模板(上传/安装/卸载三种),无注入面。
## 7. 实现拆解与工作量
| 模块 | 位置 | 内容 | 量级 |
| --- | --- | --- | --- |
| agent 主体 | `cmd/havline-agent/` + `internal/agent/`(新) | HTTP 服务、鉴权、路由/frps/证书三个执行器、nginx 模板与 reload、状态聚合 | 大(约 1200 行) |
| Havline SSH 安装器 | `internal/frp/ssh_installer.go`(新) | SSH/SFTP 上传、远程幂等安装/升级/卸载、agent 二进制 embed、Token 自动回传 | 中 |
| Havline agent 客户端 | `internal/frp/agent_client.go`(新) | 7 个接口封装 | 中 |
| 数据模型/迁移 | `023_frp_server_agent.sql` | SSH + agent 字段 + 规则同步状态 | 小 |
| 规则联动 | `internal/frp/service.go` | 保存/删除/启停后同步调用 + 状态落库 | 中 |
| API/UI | `frpmulti_handler.go`、`router.go`、`FrpView.vue`、`client.ts` | 5 个端点 + 表单分组 + 管理弹窗 + 同步状态列 | 中 |
| 测试 | `agent_test.go` 等 | nginx 模板渲染/回滚、接口幂等、鉴权 | 中 |
总计约 2500–3000 行(含测试)。`mgmt_enabled=0` 时 Havline 行为与 v1.1.0 完全一致;agent 未部署的服务端不受任何影响。
## 8. 里程碑
| 阶段 | 内容 | 验收 |
| --- | --- | --- |
| M1 | agent 主体(ping/status/routes)+ Havline SSH 安装器(一键安装) | Havline 填 SSH 信息点「安装」后自动装好 agent 并回连成功,status 能报端口占用者 |
| M2 | Havline 规则联动 + 路由自动注册 + frps 配置下发 | 保存 http 规则后 `http://域名` 无端口直达;frps.toml 一键下发生效 |
| M3 | 证书推送 + 续期联动 + 443 路由 | `https://域名` 可用,续期自动重推 |
| M4 | 管理弹窗 UI + 全量对账 + 升级/卸载/SSH 诊断 + 手册更新 | 手册第 6 节新增方案 C(agent 模式:SSH 装、API 管) |
## 9. 明确不做(本期)
- 不做 agent 侧自主 ACME(DNS-01 在 NAS 侧签发已覆盖;HTTP-01 作为后续可选);
- 不做 frps 二进制远程安装/升级(status 报告版本,安装沿用现有手册);
- 不做 agent 集群/多机编排(一个服务端一个 agent,模型对齐现有 frp_servers);
- 不动 frp 二进制与协议(agent 只操作 frps 的配置文件、进程与 vhost 端口)。
## 10. 方案形态对照
| | v2 最终(agent + SSH 安装器) | v1(纯 SSH 推送) |
| --- | --- | --- |
| VPS 侧 | 常驻 agent,但**安装/升级由 Havline SSH 一键完成** | 零安装 |
| 日常管理 | API(面板全在 Havline,日常无需 SSH) | 每次操作都走 SSH 命令 |
| 能力上限 | 高:本地编排 nginx/frps/证书,可自愈可扩展 | 受限于 SSH 命令白名单 |
| 与已有站点共存 | 只写 `havline-*.conf`,共存 | 可实现但每步都要远程拼配置 |
| 故障定位 | status API 一次拿全;agent 失联时 SSH 急救 | 每次探测拼 SSH 命令 |
| 适用 | VPS 长期做穿透入口、多域名/HTTPS 全自动 | 不想在 VPS 装常驻组件 |
结论:**SSH 与 API 不是二选一**——SSH 只承担 agent 生命周期(装/升/卸/急救),API 承担日常管理;管理面板 100% 在 Havline 本地,VPS 上没有任何管理界面。
-156
View File
@@ -1,156 +0,0 @@
# FRPS 服务端自动管理方案(frps-manager)
> 目标:frpc 端在 Havline 提交穿透规则后,frps 端**自动完成反向代理配置与生效**,实现域名无端口访问(`http://tv.36800.cc`),并自动管理 VPS 侧证书。本文是基于现有项目(Havline v1.1.0 FRP 多服务端模块)的实施方案,不引入新服务、不改 frp 二进制。
## 1. 现状与问题
当前(v1.1.0)frpc 侧规则提交后:
| 环节 | 现状 | 问题 |
| --- | --- | --- |
| tcp 规则注册(远程端口) | ✅ 自动(frpc 登录时向 frps 动态注册) | 无 |
| http 规则域名路由 | ⚠️ 需 frps.toml 预先配置 `vhostHTTPPort` | 首次使用 http 类型时 VPS 要手工改配置+重启 |
| frps.toml 生成 | ✅ Havline 一键生成,用户手动复制到 VPS | 每次变更都要 SSH 手工同步 |
| VPS 端口释放(80/443 被占) | ❌ 手动 SSH 停 Nginx | `port unavailable` 最常见根因 |
| 改配置后生效 | ❌ 手动 `pkill frps` 重启(frp 无热加载) | 容易忘、容易误 |
| VPS 侧证书 | ❌ 无 | HTTPS 要在 VPS 手工装证书 |
| 配置漂移检测 | ❌ 无 | VPS 上的文件与 Havline 数据库可能不一致 |
## 2. 总体设计
在 Havline(frpc 所在机器)新增 **frps-manager** 子系统:Havline 通过 **SSH** 连接 VPS,执行「检测 → 生成 → 推送 → 生效 → 验证」闭环。VPS 侧**零安装**(只要求 frps + SSH)。
```text
Havline (NAS) VPS (38.95.74.87)
┌─────────────────────────────┐ ┌──────────────────────────┐
│ FRP 模块(现有) │ │ frps (7000/80/443/7500) │
│ 规则/服务端 CRUD │ │ ↑ frpc 动态注册端口 │
│ frps-manager(新增) │ SSH │ frps.toml (推送产物) │
│ 检测→生成→推送→reload→验证 │ ────────→ │ /etc/havline/frps/ │
│ 证书申请→推送→挂载 │ │ /etc/havline/frps/certs/ │
└─────────────────────────────┘ └──────────────────────────┘
```
核心思路:**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 表)
新增「服务端连接信息」(加密存储,复用 secret.Box):
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `ssh_host` | TEXT | VPS SSH 地址(可与 server_addr 不同) |
| `ssh_port` | INTEGER | 默认 22 |
| `ssh_user` | TEXT | 建议 root 或具备 sudo 的用户 |
| `ssh_auth` | TEXT | `password` / `key` |
| `ssh_secret_enc` | TEXT | 密码或私钥(加密) |
| `frps_dir` | TEXT | VPS 上 frps 目录,默认 `/etc/havline/frps` |
| `frps_bin` | TEXT | frps 二进制路径,默认 `/usr/local/bin/frps` |
| `mgmt_enabled` | INTEGER | 是否启用自动管理(0/1,默认 0=纯手动,向后兼容) |
迁移 `023_frp_server_ssh.sql`。SSH 凭据用 secret.Box 加密,接口不回显(同 Token 处理)。
## 4. 核心流程
### 4.1 检测(Probe)——服务端详情新增「检测服务端」按钮
一次 SSH 会话内批量执行(只读,不改任何东西):
```bash
frps --version # 版本
ss -tlnp | grep -E ':(80|443|7000) ' # 端口占用者(frps? nginx? 其他?)
systemctl is-active nginx frps # 服务状态
cat /proc/$(pidof frps)/cmdline # 实际使用的配置路径
cat <配置路径> # 现有 frps.toml 内容(做 diff)
```
返回结构化报告:frps 版本/运行状态/配置路径、80/443 占用者判定(frps/nginx/其他/空闲)、现有 frps.toml 与 Havline 期望配置的 **diff 摘要**、待办建议(如「80 被 nginx 占用,需释放」)。这就是用户之前反复手工排查的自动化。
### 4.2 下发(Apply)——「自动反代」的主流程
触发时机:用户点「下发配置」,或规则保存后若 `mgmt_enabled` 且检测到配置将变化(规则含 http 类型 / 证书变更)自动触发。
1. **生成期望配置**:扩展现有 `buildFRPSServerConfig`:
- 存在 http/https/tcpmux 规则 → 输出 `vhostHTTPPort = 80`、`vhostHTTPSPort = 443`(去端口访问的关键);
- 存在 subdomain 规则 → 输出 `subDomainHost = <用户在服务端表单新填的主域>`;
- 证书启用时输出 `transport.tls.force` / `webServer.tls` 段;
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 场景提示用户);
4. **验证**:重读 VPS 上的 frps.toml 确认与期望一致 + `ss` 确认 80/443 已由 frps 监听 + 通过公网侧发起一次探测请求;
5. 全程动作与输出写入系统日志(复用 logstore),UI 弹窗展示每步结果。
### 4.3 释放端口(安全加固提示,不偷偷杀人)
检测发现 80/443 被 nginx 等占用时,**不自动杀进程**(危险动作),而是:
- 检测报告中标红 + 给出一键命令;
- UI 提供「释放端口」按钮:二次确认后执行 `systemctl stop nginx && systemctl disable nginx`(仅当占用者确认为 nginx 时可点,其他占用者只提示不动手);
- 释放后自动重跑 Apply 验证闭环。
### 4.4 证书管理(VPS 侧 HTTPS)
复用 Havline 证书页现有 ACME(lego)能力,证书签发在 NAS 侧完成(DNS-01,无需 VPS 参与),然后:
1. 证书页选「部署到 VPS」→ 选目标服务端;
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` + 证书);
4. 续期:Havline 证书定时任务续期成功后自动重推 + reload(证书 60 天滚动,VPS 侧无感)。
> 设计取舍:对外业务的 HTTPS 终止仍推荐在 **Havline Nginx(NAS 侧)**(本手册方案 A 架构),frps 侧证书用于 webServer 管理面板与 `tls.force` 场景。若用户想让 VPS 终止 TLS,同一套推送机制也覆盖。
## 5. API 与 UI
### 5.1 新增 API(挂在 `/api/frpmulti` 下,鉴权与现有中间件一致)
| 方法/路径 | 作用 |
| --- | --- |
| `PUT /servers/{id}/ssh` | 保存 SSH 连接信息(加密存储) |
| `GET /servers/{id}/probe` | 检测:frps 状态/端口占用/配置 diff |
| `POST /servers/{id}/apply` | 下发期望配置 + reload + 验证 |
| `POST /servers/{id}/release-port` | 释放被占端口(body: `{port, confirm: true}`) |
| `POST /servers/{id}/push-cert` | 推送证书到 VPS 并挂载 |
### 5.2 前端(FrpView.vue)
- 服务端表单「高级配置」折叠区新增「远程管理」分组:SSH 地址/端口/用户/认证方式/密钥(ConfiguredSecretField,v-model)/frps 目录;
- 服务端列表操作列新增「管理」按钮 → 弹窗(复用 `proxy-modal` 自绘骨架)三块:检测报告(分色状态行)、diff 视图(`<pre>` 左右对照期望/现状)、操作按钮(下发配置 / 释放端口 / 推送证书);
- 规则保存时的自动下发提示:非阻塞 toast「检测到 vhost 配置变化,点击下发」。
## 6. 实现拆解与工作量
| 模块 | 文件 | 内容 | 量级 |
| --- | --- | --- | --- |
| 迁移 | `migrations/023_frp_server_ssh.sql` | SSH 字段 | 小 |
| SSH 客户端 | `internal/frp/ssh.go`(新) | 连接池/单会话命令执行/SFTP 推送/超时 | 中(x/crypto/ssh 已在 go.mod) |
| 检测 | `internal/frp/probe.go`(新) | 探测命令编排 + 结果结构体 + 80 占用者判定 | 中 |
| 配置 diff/应用 | `internal/frp/frps_apply.go`(新) | 期望配置生成(扩展现有 frps_template)、diff、原子推送、admin API reload、验证 | 中 |
| 证书推送 | `internal/frp/cert_push.go`(新) | 证书文件推送 + frps.toml 证书段写入 | 小 |
| Service/API | `frpmulti_handler.go` + `router.go` | 5 个端点 | 小 |
| 前端 | `FrpView.vue` + `client.ts` | 管理弹窗 + 表单分组 + 按钮 | 中 |
| 测试 | `probe_test.go` `frps_apply_test.go` | 命令输出解析、diff、原子写逻辑 | 中 |
总计约 1500–2000 行(含测试),纯增量、不动现有链路(`mgmt_enabled=0` 时行为与 v1.1.0 完全一致)。
## 7. 安全设计
1. SSH 凭据 secret.Box 加密落库,API 永不回显(同 token 掩码机制);
2. 推荐文档引导用户用**密钥登录**(Havline 可一键生成 ed25519 密钥对、展示公钥让用户贴到 VPS `authorized_keys`);
3. 所有远程命令为**固定白名单模板**(版本/端口/状态/替换文件/reload),不拼接用户输入,杜绝命令注入;SFTP 只写 `/etc/havline/frps/` 前缀内路径;
4. 危险动作(释放端口、重启 frps)二次确认 + 动作日志;
5. Apply 幂等:重复执行无副作用(diff 无变化即跳过);
6. frps admin API 调用走本机 127.0.0.1:7500(SSH 远端执行 curl),不暴露公网。
## 8. 里程碑
| 阶段 | 内容 | 验收 |
| --- | --- | --- |
| M1 | 迁移 + SSH 客户端 + 检测(Probe) | 服务端「检测」按钮能报告 frps 状态与 80 占用者 |
| M2 | 期望配置生成 + diff + 原子推送 + reload + 验证 | 改规则后一键下发,`http://域名` 直达,`port unavailable` 类问题由系统自愈 |
| M3 | 证书推送 + 续期联动 | 证书页一键部署到 VPS,续期自动重推 |
| M4 | UI 完善 + 文档(手册第 6 节追加方案 A') | 手册更新,`port unavailable` 类问题由系统自愈 |
## 9. 明确不做(本期)
- 不实现 VPS 伴生 agent(能力更强但需安装,作为后续可选演进);
- 不做 frps 二进制的远程安装/升级(用户自装,检测报告给出版本即可);
- 不做多 VPS 并行下发编排(现有逐服务端模型已覆盖);
- 不改 frp 二进制、不依赖 frp 私有接口(只用 admin API 与文件系统)。
-185
View File
@@ -1,185 +0,0 @@
# Gitea Registry 镜像拉取问题与修复方案
> 记录时间:2026-10-02
> 适用范围:`jayhub.top/mrlee/havline` 与飞牛应用 `Fnos-Havline`
## 一、当前状态
Havline `1.9.0` 的多架构镜像已经通过 Registry HTTP API 上传成功:
| 平台 | 子 manifest |
| --- | --- |
| linux/amd64 | `sha256:83b31003aaa84435bdfc02e5e436d7e23f71e184326002ff8cdc6bba84e4c77c` |
| linux/arm64 | `sha256:5f30e660bd8ec4563bfe225c69bc5a1cb80b1766655494c2d7bd863f43aa4b06` |
| 多架构索引 | `sha256:6eed17af17349bea312b5e1fd8d6fbbde074d157a9e8687a734d9371c59a8db2` |
Registry 中当前 tag:
```text
mrlee/havline:1.9.0
```
使用带认证的 Registry API 可以正常读取 OCI index。Docker CLI 的
`push` 与 `pull` 仍然失败:
```text
failed to authorize: failed to fetch oauth token:
authorization server did not include a token in the response
```
## 二、根因
Gitea 当前生成的 Registry 认证地址是 HTTP:
```text
WWW-Authenticate: Bearer realm="http://jayhub.top/v2/token",
service="container_registry",scope="*"
```
实际 token 接口已经通过 HTTPS 提供服务。Docker 遇到 HTTP 到 HTTPS 的
跨协议跳转时,会丢掉用于换取 token 的 Basic 认证信息,最终拿不到带权限
的 token。
同时,匿名请求得到的 token 没有 `pull` scope:
```text
scope=[]
user=-1
```
这意味着飞牛设备在没有 Registry 登录凭据时,即使服务器修好 realm,也
仍然会被公开包的认证策略拦截。仓库和包本身虽然是 public,但当前 Gitea
Registry 仍要求客户端登录。
## 三、服务器端修复
### 1. 修正 Gitea 的 ROOT_URL
编辑 Gitea 的 `app.ini`,把 `ROOT_URL` 从 HTTP 改为 HTTPS:
```ini
[server]
ROOT_URL = https://jayhub.top/
```
### 2. 确保 Nginx 传递正确协议
在 Gitea 的反向代理配置中加入:
```nginx
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
```
如果你使用的是宝塔、1Panel 或飞牛自带的反代,请在对应站点配置中确认
`X-Forwarded-Proto` 没有被覆盖为 `http`。
### 3. 重启 Gitea
```bash
docker restart gitea
```
如果容器名不同,先执行:
```bash
docker ps --format '{{.Names}} {{.Image}}'
```
### 4. 验证 realm
```bash
curl -sSI https://jayhub.top/v2/ | grep -i www-authenticate
```
期望结果:
```text
WWW-Authenticate: Bearer realm="https://jayhub.top/v2/token",
service="container_registry",scope="*"
```
### 5. 验证 Docker 拉取
```bash
docker pull jayhub.top/mrlee/havline:1.9.0
```
如果本机已经登录过 `jayhub.top`,先执行一次:
```bash
docker logout jayhub.top
docker login jayhub.top
```
## 四、飞牛端仍被 401 拦截时的处理
### 方案 A:在飞牛设备登录 Registry(推荐用于继续使用 Gitea)
在飞牛终端中执行:
```bash
docker login jayhub.top -u mrlee
```
密码建议使用 Gitea Personal Access Token,而不是账号密码。Token 权限
至少需要:
```text
read:package
```
登录后再安装 `havline-1.9.0.fpk`。这种方式需要每台安装设备执行一次登录,
不适合完全没有终端操作经验的使用者。
### 方案 B:改为离线镜像包(推荐给最终用户)
如果希望完全绕开 Gitea Registry 的认证和 realm 问题,可以把 `docker save`
生成的镜像归档放进飞牛 `.fpk`,并在安装回调中执行 `docker load`。这样
安装过程不依赖外网 Registry,也不需要在飞牛设备上保存 Registry 凭据。
这个方案的代价是 `.fpk` 体积会显著增加,需要同时评估飞牛应用中心的上传
限制和升级包体积。
### 方案 C:改用允许匿名拉取的公共 Registry
如果继续发布给普通用户使用,建议把镜像推送到 Docker Hub、GHCR 或其它
允许匿名拉取多架构镜像的 Registry,再把飞牛 Compose 中的镜像地址改成
对应的公共地址。这个方案的用户体验最好,但需要额外的 Registry 账号和
发布流程。
## 五、应急手动推送工具
在 Gitea realm 修好之前,可以使用下面的脚本绕过 Docker 客户端直接推送:
```powershell
docker buildx build `
--platform linux/amd64 `
--build-arg VERSION_FILE=VERSION `
--output type=oci,dest=build\havline-amd64.oci.tar .
tar -xf build\havline-amd64.oci.tar -C build\havline-amd64
pwsh scripts/push-oci-manual.ps1 `
-LayoutDir build\havline-amd64 `
-Platform linux/amd64 `
-Tag 1.9.0
```
脚本会从 Git 凭据存储中读取 `jayhub.top` 的登录信息,通过 HTTPS 请求
Bearer token,再直接调用 Registry HTTP API 上传缺失 blob 和 manifest。
这个脚本只是应急工具,不能替代服务器端的 `ROOT_URL` 修复。服务器修好
之后,应恢复使用标准的 `docker buildx build --push`。
## 六、发布前检查清单
```text
[ ] Gitea ROOT_URL 已改为 https://jayhub.top/
[ ] Nginx 已传递 X-Forwarded-Proto https
[ ] curl /v2/ 返回的 realm 是 https
[ ] docker pull 在干净客户端上成功
[ ] 飞牛设备无需额外操作即可安装
[ ] amd64 与 arm64 镜像都能被设备拉取
```
-962
View File
@@ -1,962 +0,0 @@
# 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
@@ -1,905 +0,0 @@
<!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>
File diff suppressed because it is too large Load Diff
-983
View File
@@ -1,983 +0,0 @@
# Fonu 项目详细分析(v1.2.19)
> 分析日期:2026-09-27
> 分析对象:`F:\Fonu-main\Fonu-1.0.2`
> 代码版本:`VERSION = 1.2.19`,前端 `package.json = 1.2.19`,内置 Agent `0.12.7`
> 分析方式:源码静态阅读、目录与依赖统计、迁移与路由核对、构建级验证
> 验证结果:`go test ./... -count=1`、`go vet ./...`、`npm run typecheck`、`npm test` 均通过
---
## 一、结论摘要
Fonu 是面向飞牛 fnOS 和家庭 NAS 场景的「公网访问管理面板」。它把四类原本分散的运维能力收敛到一个 Go 单体和 Vue 管理后台中:
1. 本机 Nginx 反向代理与配置版本管理;
2. 多家 DNS 服务商的 DDNS 自动同步;
3. ACME 证书申请、续期、导入与下载;
4. 基于 FRP 的内网穿透,以及部署在公网 VPS 上的 `fonu-agent` 远程管理。
项目当前形态不是单纯 CRUD 面板,而是已经形成「本地控制面 + 公网 Agent + 定时任务 + SQLite 状态库 + Nginx 配置生成器」的完整运维系统。
主要优点:
- **模块边界较清楚**:`internal` 下按 `proxy`、`nginx`、`ddns`、`acme`、`frp`、`agent`、`monitor`、`backup` 等领域拆分,应用装配集中在 `internal/app/app.go`。
- **交付链路完整**:同一套源码可构建 Docker 镜像、Linux amd64/arm64 原生安装包,并内嵌双架构 `fonu-agent`。
- **关键配置具备回滚能力**:本机 Nginx 主配置、规则自定义配置,以及公网 Agent 上的 vhost 都有版本列表、差异和回滚入口。
- **自动化程度较高**:DDNS、证书续期、中国 IP 段更新、健康巡检、指标采样、自动备份均由调度器统一驱动。
- **测试基础设施已建立**:后端 90 个 Go 测试文件;前端已有 Vitest、TypeScript 严格模式与 CI 验证流程。
需要重点关注:
- **FRP 是系统复杂度中心**:`internal/frp` 约 6049 行,`internal/agent` 约 2079 行,二者共同承担 VPS 配置、进程、Nginx、证书、frps、诊断、版本与漂移检测,修改风险高于其他模块。
- **前端巨型组件明显**:`ProxyView.vue` 约 3150 行,`SettingsView.vue` 约 2144 行,`AgentView.vue` 约 1807 行,维护和样式隔离成本较高。
- **仍存在历史遗留接口引用**:前端保留 `/api/frp` 旧方法,`DdnsView.vue` 仍调用它,但后端当前只注册 `/api/frpmulti/*`;该调用落入 `catch`,会造成 DDNS 页的 FRP 提示状态始终为关闭。
- **初始管理员密码会进入系统日志**:`auth.EnsureDefaultAdmin` 将初始密码写入应用日志,日志中心的系统日志又会读取该文件,安全边界需要收紧。
- **仓库工作区有未提交改动**:当前有归档目录删除、内置 Agent 二进制更新、品牌图片更新等本地改动;分析时没有修改或回退这些内容。
---
## 二、仓库定位与目录边界
外层目录 `F:\Fonu-main` 不是项目源码仓库,而是工作区容器:
```text
F:\Fonu-main
├─ .breezell/ 工作区辅助目录
├─ .gocache-agent/ Agent 构建缓存
├─ docs/ 外层临时文档
├─ Fonu-1.0.2/ 真正的 Git 仓库与源码根目录
├─ agent-tsc.log
├─ fonu-image-build4.log
└─ frps-m1-tsc.log
```
真正的 Git 仓库位于 `F:\Fonu-main\Fonu-1.0.2`:
- 当前分支:`main`
- 远端:`origin/main`
- 最近提交:`70b891c feat(scripts): 新增 upgrade-hub.sh,仅编排文件的部署可一条命令升级`
- 当前未提交内容:
- 删除 `internal/frp-1.0.2/*`;
- 删除 `web/src/views/FrpView-1.0.2.vue`;
- 修改 `internal/frp/agentbin/fonu-agent-linux-amd64`;
- 修改 `internal/frp/agentbin/fonu-agent-linux-arm64`;
- 修改 `web/public/favicon.png`、`web/src/assets/brand/logo.png`;
- 新增 `web/public/111favicon.png`、`web/src/assets/brand/logo00.png`。
项目主体目录:
| 目录 | 作用 |
| --- | --- |
| `cmd/fonu` | 本地控制面主程序入口,嵌入前端静态资源 |
| `cmd/fonu-agent` | 公网 VPS Agent 入口 |
| `internal` | 全部业务实现,共 29 个领域包 |
| `migrations` | SQLite 迁移,共 27 个 SQL 文件 |
| `web` | Vue 3 + Vite 管理后台 |
| `deploy` | Nginx MIME 类型等部署资源 |
| `scripts` | 原生安装、升级、Agent 安装和 Linux 打包脚本 |
| `docs` | 设计、审查、部署与 FRP 文档 |
| `.github/workflows` | CI、Docker 发布、GitHub Release |
---
## 三、总体架构
```mermaid
flowchart LR
Browser[浏览器管理后台] -->|HTTP :6893 / Cookie / Bearer Token| App[fonu 控制面]
Public[公网访客] -->|HTTP/HTTPS| Nginx[本机 Nginx]
Nginx -->|反向代理| NAS[NAS 内网服务]
App --> DB[(SQLite /data/fonu.db)]
App --> FS[数据目录 /data]
App --> NginxCtl[Nginx 配置生成与重载]
App --> DDNS[Cloudflare / DNSPod / 阿里云 / 腾讯云 / 火山引擎]
App --> ACME[ACME CA]
App --> Scheduler[后台调度器]
App -->|frpc.toml / 进程管理| FRPC[本机 frpc]
FRPC -->|隧道| FRPS[公网 VPS frps]
FRPS -->|vhost| AgentNginx[VPS Nginx]
AgentNginx -->|隧道回源| Nginx
App -->|Bearer Token API :7700| Agent[fonu-agent]
Agent --> AgentNginx
Agent --> FRPS
Agent --> CertStore[VPS 证书目录]
```
运行角色:
| 角色 | 运行位置 | 职责 |
| --- | --- | --- |
| `fonu` | NAS / Docker / 原生 Linux | 管理后台、本机 Nginx、DDNS、证书、frpc、调度器、数据持久化 |
| `fonu-agent` | 公网 VPS | 管理 VPS 上的 Nginx vhost、证书副本、frps 进程与配置,并提供诊断只读接口 |
| Nginx | NAS 与 VPS 两端 | NAS 侧负责反代与流量入口;VPS 侧负责公网反代和 frps vhost 回源 |
| frpc / frps | NAS / VPS | 在无公网 IP 时建立 TCP、HTTP、HTTPS 等隧道 |
| SQLite | NAS 数据目录 | 保存账号、会话、代理规则、DDNS、证书、FRP、指标、健康、备份元数据等 |
---
## 四、技术栈与构建交付
### 4.1 后端
`go.mod` 声明 Go 1.23,模块路径为 `github.com/fonu/fonu`。本次验证环境使用 Go 1.26.2,可正常构建与测试。
关键依赖:
| 能力 | 依赖 |
| --- | --- |
| HTTP 服务 | Go 标准库 `net/http` 与 `http.ServeMux` |
| SQLite | `modernc.org/sqlite`,纯 Go,不依赖 CGO |
| ACME | `github.com/go-acme/lego/v4` |
| DNSPod | `github.com/nrdcg/dnspod-go` |
| 腾讯云 | `tencentcloud-sdk-go` |
| 火山引擎 | `volcengine-go-sdk` |
| SSH | `golang.org/x/crypto/ssh` |
| 密码哈希 | `golang.org/x/crypto/bcrypt` |
| 日志轮转 | `gopkg.in/natefinch/lumberjack.v2` |
后端有两个入口:
- `cmd/fonu/main.go`:加载配置、解析迁移目录、嵌入 `web/dist`、启动 `app.New`、监听退出信号。
- `cmd/fonu-agent/main.go`:解析监听地址、Token、Nginx 目录、证书目录、frps 路径,启动 Agent HTTP 服务。
### 4.2 前端
前端使用 Vue 3.5、TypeScript 5.7、Vite 6:
| 能力 | 依赖 |
| --- | --- |
| UI 组件 | Naive UI |
| 路由 | Vue Router |
| 图表 | ECharts + vue-echarts |
| 配置编辑器 | CodeMirror 6 |
| 拖拽排序 | SortableJS |
| 图标 | `@vicons/ionicons5` |
| 测试 | Vitest |
`web/src` 共 52 个 TypeScript / Vue 源文件。前端测试目前有 2 个 spec、13 条测试,主要集中在格式化与状态映射纯函数。
### 4.3 Docker 与原生安装
项目提供三种 Compose:
| 文件 | 场景 | 网络 |
| --- | --- | --- |
| `docker-compose.yml` | 普通 Docker、本地源码构建 | bridge,映射 6893/18080/9443 |
| `docker-compose.host.yml` | 飞牛 fnOS 等已占用 80/443 的环境 | host 网络,默认反代端口 18080/9443 |
| `docker-compose.hub.yml` | 干净机器只保留一个编排文件 | BuildKit 直接从 Git 仓库拉源码构建 |
Dockerfile 的主要流程:
1. 从根目录 `VERSION` 读取版本;
2. 构建前端;
3. 交叉编译 Linux amd64 / arm64 `fonu-agent`;
4. 将前端 `dist` 复制到 `cmd/fonu/web/dist` 供 `go:embed`;
5. 编译 `fonu` 主程序;
6. 运行时镜像安装 Nginx、CA 证书、tzdata;
7. 默认监听 6893,暴露 80/443,数据目录为 `/data`。
注意:当前 Dockerfile 不预装 `frpc`,需要用户在「应用配置」中按需下载。旧版 README 或部署文档中若仍表述为“镜像内置 frpc”,应按当前实现校正。
### 4.4 版本一致性
当前版本事实:
| 位置 | 值 |
| --- | --- |
| `VERSION` | `1.2.19` |
| `web/package.json` | `1.2.19` |
| `AgentVersion` | `0.12.7` |
| `internal/version.Version` | 构建时通过 `-ldflags` 注入,默认 `dev` |
前端构建时优先读取 `VITE_APP_VERSION`,否则回落到 `package.json`。Dockerfile 将 `VERSION` 注入前端和后端。
---
## 五、后端模块分析
### 5.1 规模概览
后端共有 235 个 `.go` 文件,其中 90 个是 `_test.go`。按非测试源码粗略统计,主要模块规模如下:
| 包 | 文件数 | 约行数 | 职责 |
| --- | ---: | ---: | --- |
| `frp` | 23 | 6049 | FRP 多服务端、frpc 运行、Agent 调用、诊断、健康、漂移、版本 |
| `api` | 22 | 3241 | HTTP 路由、处理器、中间件、响应封装 |
| `ddns` | 14 | 2379 | 多服务商 DDNS、记录同步、凭证、Zone 解析 |
| `nginx` | 23 | 2186 | Nginx 配置生成、校验、启动、重载、版本与回滚 |
| `agent` | 4 | 2079 | 公网 VPS Agent 的全部接口与配置写入 |
| `notify` | 7 | 1418 | 通知配置、加密存储、邮件、Webhook、Telegram 与国内 IM |
| `acme` | 8 | 1370 | ACME 申请、续期、导入、导出、任务日志 |
| `proxy` | 3 | 1015 | 反代规则模型、SQLite Store 与端口冲突校验 |
| `service` | 3 | 595 | 反代与 Nginx 配置的业务服务层 |
| `monitor` | 1 | 559 | 资源、隧道、服务端、Agent、配置漂移巡检 |
| `logstore` | 4 | 685 | 系统日志、访问日志、错误日志读取与显示转换 |
| `traffic` | 3 | 421 | Nginx access log 跟踪、流量聚合、在线连接 |
| `backup` | 3 | 401 | 导出、恢复、校验、自动备份与保留策略 |
| `certificate` | 2 | 353 | 证书元数据 Store 与导入 |
| `auth` | 2 | 334 | 管理员、会话、API Token 验证 |
| `chinacidr` | 1 | 301 | 中国 IP 段下载、生成与 Nginx 联动 |
| `metrics` | 1 | 231 | 指标历史写入、查询与分桶 |
| `config` | 1 | 201 | 环境变量、数据目录、会话密钥 |
| `settings` | 1 | 193 | 设置项读写、默认值与类型转换 |
| `fsutil` | 1 | 164 | 原子写、备份、版本列表与回滚基础 |
### 5.2 应用装配:`internal/app/app.go`
`app.New` 是理解整个系统的入口,按顺序完成:
1. 创建日志目录与 JSON 日志;
2. 创建数据、Nginx、日志、证书、FRP 目录;
3. 解析或自动生成会话密钥;
4. 打开 SQLite 并执行迁移;
5. 创建 AES-GCM 凭据加密盒;
6. 初始化管理员账号;
7. 创建 proxy、settings、ddns、certificate、nginx 等 Store / Service;
8. 创建 FRP 多服务端服务;
9. 创建 HTTP 路由;
10. 创建调度器与流量采集器;
11. 启动 DDNS、ACME、中国 IP、巡检、自动备份任务;
12. 初始化 Nginx 配置并恢复 frpc 运行状态;
13. 启动 HTTP 服务,退出时关闭 Nginx、frpc 和数据库。
这种集中装配方式让依赖关系清晰,但也使 `app.go` 成为高耦合入口。新增模块时应继续通过构造参数或小型接口注入,避免在 `app.New` 中堆积具体职责。
### 5.3 配置与数据目录:`internal/config`
关键环境变量:
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `FONU_LISTEN` | `:6893` | 管理后台监听地址 |
| `FONU_DATA_DIR` | `/data` 或当前目录 `.data` | 数据根目录 |
| `FONU_SESSION_SECRET` | 空 | 会话签名与凭据加密密钥;空时自动生成 |
| `FONU_NGINX_BIN` | `nginx` | Nginx 可执行文件 |
| `FONU_NGINX_PID` | `<DataDir>/nginx/nginx.pid` | Nginx PID |
| `FONU_NGINX_MIME_TYPES` | `/etc/nginx/mime.types` 或仓库资源 | MIME 类型文件 |
| `FONU_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代默认端口 |
| `FONU_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代默认端口 |
| `FONU_FRPC_BIN` | `frpc` | frpc 可执行文件 |
| `FONU_FRPC_PID` | `<DataDir>/frp/frpc.pid` | frpc PID 文件 |
会话密钥策略值得肯定:
- 显式配置时优先使用;
- 未配置时生成 32 字节随机值;
- 持久化为 `<DataDir>/session_secret`,权限 0600;
- 使用临时文件加改名写入,避免半文件;
- 已经使用历史默认值时只提示,不自动替换,避免历史密文全部无法解密。
数据目录典型结构:
```text
<DataDir>/
├─ fonu.db
├─ session_secret
├─ ssh_known_hosts
├─ nginx/
│ ├─ nginx.conf
│ ├─ rules/
│ ├─ custom/
│ ├─ backups/
│ ├─ errors/
│ └─ china_cidr.conf
├─ certs/
├─ frp/
│ ├─ frpc.toml
│ └─ frpc.pid
├─ logs/
│ ├─ app.log
│ ├─ access.log
│ ├─ error.log
│ └─ frpc.log
└─ backups/
```
### 5.4 数据库:`internal/db`
SQLite 初始化参数:
- `foreign_keys(1)`:启用外键;
- `busy_timeout(5000)`:锁等待 5 秒;
- `SetMaxOpenConns(1)`:单连接串行访问。
单连接是保守且适合 NAS 单机场景的选择,能避免多连接写冲突,但所有 API、调度任务和日志查询都共享一个数据库连接。当历史指标、健康事件和流量查询增大时,响应延迟可能互相影响。
迁移系统特点:
- 按文件名前缀数字排序;
- 每个迁移在独立事务中执行;
- 成功后写入 `schema_migrations`;
- 已应用版本跳过;
- 迁移文件不嵌入二进制,运行时通过 `ResolveMigrationsDir` 在多个候选路径查找。
### 5.5 认证与凭据
认证能力分三类:
1. 管理员账号:bcrypt 哈希;
2. 浏览器会话:32 字节随机 Token,数据库保存,默认 7 天;
3. API Token:随机明文只返回一次,数据库保存 SHA-256,支持 `read` / `write` 范围。
登录保护:
- 同一 IP 在 5 分钟内失败 5 次后返回 429;
- 登录失败会进入通知统计;
- 修改密码后删除该账号的其他会话,保留当前会话;
- Cookie 使用 `HttpOnly`、`SameSite=Lax`,并根据直连 TLS 或 `X-Forwarded-Proto` 设置 `Secure`。
凭据加密:
- 使用会话密钥经 SHA-256 派生 AES-256-GCM;
- 加密 DDNS、FRP、通知、SSH、Dashboard 和 OIDC 等敏感字段;
- 部分凭据 API 只返回 `has_token` / `has_password` 等状态,不回传明文。
---
## 六、数据库模型
### 6.1 表清单
| 表 | 迁移 | 作用 |
| --- | --- | --- |
| `schema_migrations` | 001 | 迁移版本 |
| `admins` | 001 | 管理员账号 |
| `sessions` | 001 | 浏览器会话 |
| `api_tokens` | 025 | API 脚本令牌 |
| `proxy_rules` | 001 / 006 / 010-014 | 本机反向代理规则 |
| `proxy_hosts` | 006 / 010 | 规则域名与监听端口 |
| `proxy_custom_endpoints` | 024 | 自定义 Nginx 规则解析出的域名端口占用 |
| `proxy_traffic` | 009 | 规则累计流量 |
| `ddns_configs` | 002-008 / 022 | DDNS 任务、记录、凭证、自定义 IP |
| `certificates` | 002 / 004 / 005 | 证书元数据、CA、多域名 |
| `settings` | 001 | 键值设置 |
| `frp_servers` | 015-016 / 020 / 023 | FRP 服务端、认证、Dashboard、Agent、SSH |
| `frp_proxies` | 015 / 018 / 021 | FRP 穿透规则与扩展选项 |
| `frp_runtime_status` | 015 / 019 | frpc 期望状态、进程状态、连接状态 |
| `frp_server_diagnostics` | 017 | 服务端延迟与可用性采样 |
| `route_health_events` | 026 | 公网规则状态变更事件 |
| `metrics_samples` | 027 | 本机与规则指标历史 |
### 6.2 数据设计亮点
- `proxy_hosts(hostname, listen_port)` 唯一,避免同一域名与端口被多条规则占用。
- `proxy_custom_endpoints` 单独记录自定义 Nginx 配置的监听端点,保证自定义模式也能参与冲突校验。
- `frp_proxies.type` 在 021 迁移中扩展到 8 种类型,并为 TCP / UDP 远程端口分别建立唯一索引。
- `frp_runtime_status` 将“用户期望状态”和“实际进程/连接状态”分开,有助于区分手动停止和异常掉线。
- `route_health_events` 只记录状态变化,不保存每轮原始采样,降低长期数据量。
- `metrics_samples` 采用 `(scope, metric, value, at)` 纵向结构,增加指标不需要改表。
### 6.3 数据生命周期
| 数据 | 清理方式 |
| --- | --- |
| 会话 | 校验过期时删除;修改密码时删除其他会话 |
| 健康事件 | 巡检按保留期清理 |
| 指标采样 | 巡检按保留期清理 |
| 备份归档 | 按设置中的保留份数剪枝 |
| Nginx 配置版本 | 默认保留最近 10 个有效变化 |
| 日志 | 由 `logstore` 与轮转策略处理 |
---
## 七、HTTP API 与认证面
### 7.1 路由规模
当前 `internal/api/router.go` 中:
- 受保护路由约 113 条:使用 `SessionMiddleware + RequireAuth`;
- 匿名路由 5 条:版本、登录状态、登录、退出、公开状态页;
- 静态资源与 SPA fallback:非 `/api/` 路由全部交给前端。
匿名接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/version` | 版本与默认反代端口 |
| GET | `/api/auth/status` | 是否初始化、是否已登录 |
| POST | `/api/auth/login` | 登录 |
| POST | `/api/auth/logout` | 退出 |
| GET | `/api/status-page` | 默认关闭的只读状态页 |
### 7.2 API 分组
| 分组 | 代表路径 | 能力 |
| --- | --- | --- |
| 认证 | `/api/auth/*` | 登录、退出、改密 |
| API Token | `/api/settings/tokens` | 创建、列出、撤销 |
| 仪表盘 | `/api/status` | 公网 IP、DDNS、证书、反代、资源和请求趋势 |
| 本机反代 | `/api/proxies*` | 规则 CRUD、排序、流量、连接、实时日志 |
| Nginx 配置 | `/api/proxies/{id}/nginx`、`/api/settings/nginx/*` | 自动/自定义配置、校验、回滚、版本 |
| DDNS | `/api/ddns*` | 任务、测试、全部更新、单任务更新 |
| 证书 | `/api/certificates*` | CA 选项、申请、SSE 日志、导入、续期、下载、删除 |
| 设置 | `/api/settings` | 常规、通知、安全、高级设置 |
| 日志 | `/api/logs/*` | 访问、错误、系统、SSE |
| 指标 | `/api/metrics/history` | 分桶历史趋势 |
| 备份 | `/api/backup*` | 导出、恢复、归档、下载、删除 |
| 服务发现 | `/api/discovery/scan` | 扫描常见 NAS 服务端口 |
| FRP 多服务端 | `/api/frpmulti/*` | 服务端、frpc、规则、诊断、版本下载 |
| 公网 Agent | `/api/frpmulti/servers/{id}/agent/*` | 状态、Nginx、frps、证书、路由、漂移、指标 |
### 7.3 Agent API
`fonu-agent` 当前提供 20 个 `/api/v1/*` 接口,全部使用 Bearer Token:
| 类别 | 接口 |
| --- | --- |
| 基础 | `ping`、`status` |
| 路由 | 列表、写入、详情、删除、版本、回滚 |
| Nginx | 安装、重载、日志 |
| frps | 配置读取与写入、安装、启停重启 |
| 证书 | 写入与列表 |
| 中国 IP | `PUT /china-cidr` |
| 指标 | `GET /metrics` |
Agent 侧的重要安全措施:
- SSH 安装使用 TOFU 主机密钥校验,首次记录,后续变更拒绝;
- frps 下载只允许官方地址和固定加速前缀;
- frps 安装包下载后校验 SHA-256;
- Basic Auth 使用纯 Go apr1,不依赖 VPS 上的 `openssl`;
- 写 vhost 前保留版本,`nginx -t` 失败时恢复上一个可用配置。
---
## 八、前端结构
### 8.1 路由与页面
前端共 11 条路由定义:
| 路径 | 名称 | 是否公开 | 页面 |
| --- | --- | --- | --- |
| `/setup` | - | 重定向 | 跳转登录 |
| `/login` | `login` | 是 | 登录 |
| `/status` | `status` | 是 | 公开状态页 |
| `/dashboard` | `dashboard` | 否 | 仪表盘 |
| `/ddns` | `ddns` | 否 | DDNS |
| `/certificates` | `certificates` | 否 | 证书管理 |
| `/proxies` | `proxies` | 否 | 本机反向代理 |
| `/frp` | `frp` | 否 | 内网穿透 |
| `/frp-agent` | `frp-agent` | 否 | 公网服务端 |
| `/public-proxy` | `public-proxy` | 否 | 公网反代 |
| `/logs` | `logs` | 否 | 日志中心 |
| `/settings` | `settings` | 否 | 系统设置 |
路由守卫在每次受保护页面跳转前请求 `/api/auth/status`。公开状态页不做登录检查,避免匿名访问时产生无意义请求。
### 8.2 页面规模
| 文件 | 约行数 | 主要职责 |
| --- | ---: | --- |
| `ProxyView.vue` | 3150 | 本机反代规则、详情、日志、Nginx 配置 |
| `SettingsView.vue` | 2144 | 常规、通知、安全、高级、备份、Token |
| `AgentView.vue` | 1807 | 公网服务端、Agent、frps 生命周期、诊断 |
| `CertificatesView.vue` | 1685 | 证书列表、申请、续期、下载、导入 |
| `DashboardView.vue` | 1679 | 状态卡、趋势、健康、流量 |
| `DdnsView.vue` | 1568 | DDNS 任务、记录、服务商配置 |
| `FrpView.vue` | 1406 | frpc 服务端和穿透规则 |
| `ProxyRemoteView.vue` | 1150 | 公网反代部署、漂移、健康、版本 |
| `LogsView.vue` | 860 | 系统、访问、错误、FRP 日志 |
这些页面都由 Composition API 直接承载大量状态和逻辑。当前结构适合快速迭代,但需要继续拆分:
- `ProxyView.vue`:拆规则表单、Nginx 面板、客户端面板、日志面板;
- `AgentView.vue`:拆服务端表单、日志弹窗、详情弹窗、延迟图;
- `SettingsView.vue`:拆通知、安全、备份、Token、状态页卡片。
### 8.3 API 客户端
`web/src/api/client.ts` 统一处理:
- `credentials: include`;
- JSON 请求体;
- 30 秒普通请求超时;
- 10 分钟证书操作超时;
- 错误信息归一化;
- Blob 下载与文件名解析。
`web/src/api/types.ts` 约 923 行,集中定义后端响应结构。类型覆盖较全,但仍保留旧 FRP 单体接口类型,形成历史冗余。
### 8.4 前端测试
已有:
- `web/src/utils/status.spec.ts`:状态值到 UI 状态的映射回归;
- `web/src/utils/format.spec.ts`:格式化函数。
缺口:
- API 客户端无测试;
- 页面组件无测试;
- 轮询与可见性行为无测试;
- 配置差异、回滚、表单校验等高风险交互仍只能依赖类型检查与人工验证。
---
## 九、核心业务链路
### 9.1 本机反向代理
数据流:
```text
前端保存规则
→ proxy.Store 校验域名与端口唯一性
→ 写入 proxy_rules / proxy_hosts
→ 生成 Nginx 配置
→ nginx -t 校验
→ 热重载或重启
→ 写入流量采集与日志
```
要点:
- 规则支持多域名、多端口、IPv4/IPv6 监听、HTTPS 与 HTTP 跳转;
- 安全设置保存在 `security_json`,包括 IP 黑白名单、仅中国大陆、Basic Auth、限流、连接数限制、安全响应头等;
- 自动模式由模板生成,自定义模式允许编辑但会解析监听端点参与冲突校验;
- 配置写入前保留 `.bak` 与 `.versions`,失败不覆盖当前可用配置;
- Nginx 配置版本支持列表、读取、差异对比和回滚。
### 9.2 DDNS
支持的服务商:
- Cloudflare;
- DNSPod;
- 阿里云 DNS;
- 腾讯云 DNS;
- 火山引擎 DNS。
能力:
- 单任务多根域名 / 多记录;
- 最长后缀 Zone 匹配;
- IPv4 / IPv6;
- 自定义固定 IP;
- 手动测试与全部更新;
- 定时任务按设置间隔运行,默认 5 分钟;
- 凭证加密保存;
- IP 变化、失败等情况进入通知系统。
### 9.3 证书
证书链路:
```text
选择 CA 与域名
→ 复用 DDNS 凭证
→ DNS-01 挑战
→ lego 申请
→ 写入证书目录
→ 更新 certificates 表
→ 重载 Nginx
→ 前端通过 SSE 获取任务日志
```
当前 CA 相关能力:
- Let's Encrypt;
- ZeroSSL;
- LiteSSL;
- LiteSSL 强制 EAB;
- 可配置 DNS 递归解析器,避免容器内 `127.0.0.11` 无法解析权威 NS;
- 支持导入 PEM、下载 ZIP / 证书 / 私钥;
- 每天执行续期巡检,按到期阈值提醒。
### 9.4 FRP 与公网 Agent
FRP 模块当前支持:
- 多台公网 frps;
- 8 种穿透类型:tcp、udp、http、https、tcpmux、stcp、sudp、xtcp;
- frpc 多版本下载、启用与删除;
- frpc 配置生成与进程管理;
- frps.toml 同源生成;
- 服务端认证方式:token、none、OIDC;
- frpc 流量、连接数与速率;
- 服务端 TCP / 登录 / 代理 / DNS / 归属地诊断;
- SSH 一键安装、升级、卸载 `fonu-agent`;
- Agent 状态、Nginx、frps 配置、证书、日志、指标;
- 公网反代 vhost 部署、漂移检测、版本与回滚;
- 四段健康检查:DNS、证书、隧道、回源服务。
主要风险点:
- `internal/frp/service.go` 和 `internal/agent/server.go` 都是超大文件;
- 宿主机 Nginx 模板与 Agent Nginx 模板存在两份实现,需要人工保持同步;
- Agent 与主程序之间存在版本兼容契约,旧 Agent 通过 404/405 映射升级提示,但接口演进仍需谨慎;
- VPS 上的系统环境差异大,尤其是 Nginx 安装位置、systemd、宝塔环境与 PATH。
### 9.5 监控、健康与通知
调度器每 2 分钟执行 `monitor`:
- 本机 CPU、内存、磁盘;
- 资源阈值;
- 本机规则流量;
- 公网规则 DNS / 证书 / 隧道 / 服务四段健康;
- frps 服务端掉线;
- Agent 不可达;
- 公网 vhost 配置漂移。
通知渠道:
- SMTP 邮件;
- Webhook;
- Telegram;
- Bark;
- ntfy;
- Gotify;
- 企业微信;
- 钉钉;
- 飞书。
告警策略已具备:
- 连续失败阈值;
- 恢复通知;
- 资源告警冷却;
- 配置漂移冷却;
- 登录失败窗口统计。
### 9.6 指标、日志与流量
指标:
- 本机资源与每条公网规则流量写入 `metrics_samples`;
- 查询时按桶取平均,前端支持 1 小时、24 小时、7 天趋势;
- 避免每加一个指标就改表结构。
日志:
- `app.log`:应用系统日志;
- `access.log`:本机 Nginx 访问日志;
- `error.log`:本机 Nginx 错误日志;
- `frpc.log`:FRP 客户端日志;
- 日志中心支持分页、筛选和 SSE 实时访问/错误日志。
流量:
- `traffic.Collector` 跟踪 access log 增量;
- 汇总到规则和域名;
- 计算累计上下行、速率与当前连接数;
- 与监控巡检共用主机维度聚合算法,减少口径漂移。
### 9.7 备份与恢复
能力:
- 手动导出;
- 上传恢复;
- 定时自动备份;
- 服务端归档列表;
- 下载、恢复、删除;
- 恢复前校验归档;
- 按保留份数剪枝;
- 自动备份失败通知。
恢复会覆盖数据库与证书等关键数据,前端需要二次确认。由于 SQLite 正在运行,恢复后推荐重启 Fonu。
---
## 十、后台调度
`internal/app/app.go` 注册的任务:
| 任务 | 间隔 | 作用 |
| --- | --- | --- |
| DDNS | 默认 5 分钟,可在设置中调整 | 检测公网 IP 并同步 DNS |
| ACME | 24 小时 | 证书续期与到期提醒 |
| 中国 IP 段 | 默认 24 小时,可在设置中调整 | 下载并生成 Nginx geo 配置 |
| monitor | 2 分钟 | 资源、隧道、服务端、Agent、漂移巡检与指标采样 |
| backup | 24 小时 | 自动备份并按策略保留 |
调度器还负责:
- 任务 panic 恢复;
- 非正间隔跳过;
- 应用退出时通过 Context 取消。
---
## 十一、部署与运维
### 11.1 推荐部署形态
飞牛 fnOS 推荐:
```bash
docker run -d \
--name fonu \
--net=host \
-v /vol1/docker/fonu-data:/data \
--restart unless-stopped \
fonu:local
```
管理入口 `http://<NAS-IP>:6893`,默认反代端口 18080/9443,避免和系统 Nginx 抢占 80/443。
### 11.2 升级方式
三种方式:
1. 本地源码:`docker compose up -d --build`;
2. 单文件编排:保留 `docker-compose.hub.yml`,重新执行 `docker compose up -d --build`;
3. 原生安装:使用 `scripts/build-linux.ps1` 构建包,再由 `scripts/fonu-install.sh` 安装。
升级脚本:
- `scripts/upgrade.sh`:拉代码、构建、重建、健康检查,支持 `--no-pull` 和回滚;
- `scripts/upgrade-hub.sh`:仅使用编排文件的部署升级;
- `scripts/agent-install.sh`:安装 `fonu-agent`。
### 11.3 数据持久化
默认使用命名卷 `fonu-data`。如需绑定宿主机目录,在 `.env` 中设置:
```dotenv
FONU_DATA_PATH=/vol1/docker/fonu-data
```
Compose 约定以 `/` 或 `.` 开头的值解释为 bind mount,其余字符串仍是命名卷。
`FONU_SESSION_SECRET` 同时承担凭据解密,已有数据后不能随意替换,否则 DDNS、FRP、通知和 SSH 等密文可能无法解密。
---
## 十二、工程化、测试与质量门禁
### 12.1 CI
`.github/workflows/ci.yml` 已实现完整验证顺序:
1. Checkout;
2. 安装 Node 22;
3. `npm ci`;
4. `npm run build`;
5. `npm test`;
6. 将 `web/dist` 同步到 `cmd/fonu/web/dist`;
7. 安装 Go;
8. `go vet ./...`;
9. `go test ./... -count=1`;
10. `go build ./...`。
顺序不可颠倒,因为 `cmd/fonu/main.go` 使用 `//go:embed all:web/dist`,干净检出时前端产物必须先生成。
当前 CI 尚未开启 `gofmt -l` 卡口,原因是仓库仍有历史未格式化文件。建议在集中执行一次 `gofmt -w` 后开启。
### 12.2 发布工作流
- `docker-release.yml`:tag 或手动触发,构建 amd64/arm64 镜像并生成多架构 manifest;
- `github-release.yml`:根据 `CHANGELOG.md` 生成 GitHub Release。
### 12.3 测试覆盖
Go 测试分布较广,`nginx` 包测试最多,其次为 `frp`、`acme`、`api`。
当前没有测试文件的包:
- `internal/discovery`;
- `internal/secret`;
- `internal/service`;
- `internal/settings`;
- `internal/version`;
- `cmd/fonu`;
- `cmd/fonu-agent`。
优先级较高的补测:
1. `internal/service` 的规则保存、Nginx 自定义配置与回滚;
2. `internal/settings` 的类型转换、默认值和迁移;
3. `internal/secret` 的加解密与密钥错误提示;
4. 路由与中间件测试,确保新增受保护接口不会漏鉴权;
5. Agent `readLimitState`、`writeLimitState` 与配置回滚;
6. 前端 API 客户端和轮询组合式函数。
### 12.4 本次验证
本次分析实际执行:
```bash
go test ./... -count=1
go vet ./...
cd web && npm run typecheck
cd web && npm test
```
结果:
- Go:所有含测试包通过;
- Go vet:无输出,退出码 0;
- 前端类型检查:通过;
- 前端测试:2 个测试文件、13 条测试全部通过。
### 12.5 格式化
仓库目前仍存在历史未格式化文件。本次只做过读取和测试,没有批量执行 `gofmt -w`,以免把格式化噪音混入用户当前未提交的改动。
---
## 十三、当前风险与技术债
### 13.1 功能与兼容性
1. **旧 `/api/frp` 客户端残留**
- `web/src/api/client.ts` 仍定义 `getFRP`、`saveFRP`、`getFRPStatus`、`syncFRPDomains`、`getFRPLogs`;
- `web/src/views/DdnsView.vue` 在 `init()` 中调用 `api.getFRP()`;
- 后端当前只注册 `/api/frpmulti/*`,因此该请求失败后进入 `catch`,`frpEnabled` 被设为 `false`;
- 影响:DDNS 页可能无法显示与 FRP 相关的提示;
- 建议:删除旧类型与旧方法,或改为调用 `/api/frpmulti/status`。
2. **Agent 版本兼容需要长期维护**
- 主程序通过 404/405 识别旧 Agent,并提示升级;
- 新接口应继续沿用最低版本提示,避免前端只看到网络错误。
3. **宿主机与 Agent 的 Nginx 模板双份实现**
- 一边是 `internal/nginx`,一边是 `internal/agent`;
- 修改一处忘记另一处会造成本机与公网行为不一致;
- 建议抽共享模板包,至少共享安全指令、限流、geo、WebSocket、TLS 片段。
### 13.2 安全
1. **初始管理员密码写入应用日志**
- `auth.EnsureDefaultAdmin` 记录用户名和密码;
- `logstore.appendSystemAttrs` 会把密码原样拼进系统日志;
- `GET /api/logs/system` 可读取该系统日志;
- 建议只在进程首次启动时输出到终端一次,不写入持久日志;或生成一次性初始密码文件并设置 0600。
2. **Agent 默认明文 HTTP 监听**
- 默认 `:7700`,Bearer Token 直接在网络中传输;
- 如果直接暴露公网,需要 HTTPS、内网限制或前置反向代理;
- 建议默认绑定 `127.0.0.1`,由 Nginx 或隧道暴露,并把公网绑定作为显式选项。
3. **Agent Token 比较未使用常量时间**
- Agent 使用字符串比较;
- 建议使用 `crypto/subtle.ConstantTimeCompare`。
4. **Agent 限流状态反序列化失败会静默清空**
- `readLimitState` 忽略 JSON 错误;
- 后续写入可能删除 zone 文件;
- 建议解析失败返回错误并保留原文件。
5. **备份上传仍允许 64MB**
- 已有 `CreateTemp` 修复路径穿越;
- 仍建议降低上限并增加频率限制。
### 13.3 性能与并发
1. **SQLite 单连接**
- 当前实现简单可靠,但监控查询、日志、指标与 API 会竞争同一连接;
- 可通过缩短事务、减少大查询、为高频读增加轻量缓存来缓解。
2. **前端大页面与高频轮询**
- 多个页面存在 15 秒轮询,仪表盘流量轮询更快;
- 轮询请求应加入 in-flight 互斥,并在页面隐藏时暂停;
- 可以统一抽 `useVisibilityPolling`。
3. **巨型后端文件**
- `internal/frp/service.go` 约 1805 行;
- `internal/agent/server.go` 约 1551 行;
- 建议按能力拆分文件,例如 rate limit、nginx template、frps install、route versions、diagnostics。
### 13.4 维护性
1. **前端样式与弹窗外壳重复**
- 多个页面重复实现左列表、弹窗外壳和状态色;
- 建议抽 `SidebarList.vue`、`ModalShell.vue` 和统一状态令牌。
2. **硬编码颜色仍需收敛**
- `variables.css` 已有品牌令牌;
- 页面中仍有大量直接色值与重复色板。
3. **旧文档时效**
- `project-audit-2026-09-24.md` 基于 v1.2.6,其中很多 P0/P1 已实施;
- 阅读旧审查时应以 CHANGELOG、当前源码和本文档为准。
4. **归档目录删除尚未提交**
- `internal/frp-1.0.2` 与 `FrpView-1.0.2.vue` 在工作树中已删除但未提交;
- 分析时保持原样,没有回退或提交。
---
## 十四、建议改进路线
### P0:先处理明确问题
1. 修正 `DdnsView` 对旧 `/api/frp` 的调用,切到 `/api/frpmulti/status`。
2. 初始管理员密码不再写入持久应用日志。
3. Agent Token 比较改为常量时间。
4. `readLimitState` 解析失败时拒绝覆盖原状态。
5. 明确 Agent 默认监听策略,最好默认仅本机或要求 HTTPS。
### P1:降低核心模块风险
1. 抽共享 Nginx 模板包,统一本机与 Agent 的安全、限流、geo、TLS 逻辑。
2. 拆分 `internal/frp/service.go` 与 `internal/agent/server.go`。
3. 拆分 `ProxyView.vue`、`SettingsView.vue`、`AgentView.vue`。
4. 为路由鉴权、配置回滚、Agent 版本兼容补测试。
5. 前端轮询统一抽 composable,加入互斥与页面可见性控制。
### P2:工程质量提升
1. 全仓执行一次 `gofmt -w`,随后在 CI 中开启 `gofmt -l` 卡口。
2. 清理旧 FRP 类型、旧 API 方法和未使用样式。
3. 收敛颜色令牌与重复弹窗外壳。
4. 增加前端 API 客户端测试与关键页面组件测试。
5. 为 SQLite 高频查询建立基准,确认单连接是否满足长期指标数据。
### P3:能力增强
1. 加密迁移包:导出并导入含敏感凭据的完整实例。
2. 审计日志:记录谁在何时修改了规则、服务端、证书和设置。
3. 2FA / TOTP 与登录设备管理。
4. SMART 磁盘健康与 UPS 状态。
5. Prometheus `/metrics` 导出。
6. 状态页事故记录与服务入口聚合页。
---
## 十五、关键源码索引
| 目标 | 文件 |
| --- | --- |
| 应用装配 | `internal/app/app.go` |
| 配置 | `internal/config/config.go` |
| 数据库迁移 | `internal/db/db.go`、`migrations/*.sql` |
| HTTP 路由 | `internal/api/router.go` |
| 认证中间件 | `internal/api/middleware.go` |
| 登录与会话 | `internal/auth/auth.go`、`internal/api/auth_handler.go` |
| 本机反代 | `internal/proxy/proxy.go`、`internal/service/nginx_config.go` |
| Nginx 生成与重载 | `internal/nginx/*.go` |
| DDNS | `internal/ddns/*.go` |
| 证书 | `internal/acme/*.go`、`internal/certificate/*.go` |
| FRP 主服务 | `internal/frp/service.go` |
| FRP 模型 | `internal/frp/model.go` |
| Agent 客户端 | `internal/frp/agent_client.go` |
| 公网 Agent | `internal/agent/server.go`、`internal/agent/versions.go` |
| 监控巡检 | `internal/monitor/monitor.go` |
| 通知 | `internal/notify/*.go` |
| 日志 | `internal/logstore/*.go` |
| 流量 | `internal/traffic/collector.go` |
| 指标 | `internal/metrics/*.go` |
| 备份 | `internal/backup/*.go` |
| 前端路由 | `web/src/router/index.ts` |
| 前端 API | `web/src/api/client.ts`、`web/src/api/types.ts` |
| 前端布局 | `web/src/layouts/AppLayout.vue` |
| CI | `.github/workflows/ci.yml` |
| 容器构建 | `Dockerfile`、`docker-compose*.yml` |
---
## 十六、总结
Fonu 已经从一个 Nginx 反代面板发展为覆盖「本地入口、DNS、证书、内网穿透、公网 Agent、监控告警、备份恢复」的 NAS 公网访问控制面。
它的优势在于:功能完整、模块边界总体清楚、迁移与部署链路成熟、关键配置有版本和回滚、自动化任务覆盖主要运维场景。
当前最需要做的不是继续横向增加功能,而是先把 FRP / Agent 这条最复杂的链路拆小,把旧接口残留和日志泄密等明确问题清掉,再补上路由鉴权、配置回滚和 Agent 兼容测试。这样后续增加迁移包、审计、2FA、Prometheus 等能力时,才不会把复杂度继续堆进两个超大服务文件。
-290
View File
@@ -1,290 +0,0 @@
# Fonu 全项目审查报告
> 审查日期:2026-09-24 对象:`f:\Fonu-main\Fonu-1.0.2`(VERSION 1.2.6;Go 后端 `cmd/` + `internal/` 202 个 `.go`,前端 `web/src` 44 个源文件)
> 方法:三路并行静态审查(Go 后端 / 前端 / 工程与部署)+ 高信号 grep 统计。
> 可信度说明:§1.1、§5.1 的高危项已逐条读取原文核对;其余结论来自 grep 与源码阅读,未做运行时验证;行号为 2026-09-24 快照,实施时以标识符为准。
> 用途:把「哪里要改、哪里要补」按优先级摊开,供逐项实施与勾选。
---
## 〇、结论速览
| 级别 | 条数 | 内容 |
| --- | --- | --- |
| **P0 · 安全** | 4 | 备份恢复的任意文件写;agent 安装 frps 的 SSRF + 二进制无校验;SSH 不校验主机密钥;三处凭据明文回传/落盘 |
| **P0 · 用户可见缺陷** | 2 | `/frp-agent` 服务端详情弹窗状态点恒灰;仪表盘 DDNS/证书卡「红灰点 + 正常」文案矛盾 |
| **P1 · 正确性** | 6 | 安装失败仍报「已启动」;备份失败仍覆盖唯一副本;限流状态损坏静默清空;请求体解析错误被忽略等 |
| **P1 · 工程** | 5 | CI 只发版不校验;三套锁文件并存;版本号双源(本地构建显示 1.0.2);`fonu-agent.exe` 入库;前端零测试设施 |
| **P1 · 前端质量** | 4 | 死样式与重复样式块;231 处硬编码色值;轮询无 in-flight 互斥且只有仪表盘处理页面隐藏 |
| **P2 · 重构/增补** | 若干 | 5 套「安全写文件」、4 套 `.bak`、agent 与宿主机各写一份 nginx 模板;8 个包零测试;i18n 缺失 |
**已确认无问题、不必重复投入**:无命令注入(`exec.Command` 全为固定 argv);无路由漏挂鉴权(API 97 条受保护 + 4 条公开,agent 17 条全包在 `s.auth`);无无界 `io.ReadAll`(全配 `io.LimitReader`);无 ticker 泄漏(两处 `NewTicker` 都有 `defer Stop`);无 `console.log`/`debugger`;无 `v-html`;密钥落盘权限普遍 0600;前端定时器清理齐全。
---
## 一、安全
### 1.1 P0 高危(已逐条复核原文)
| 位置 | 问题 | 修法 |
| --- | --- | --- |
| `internal/api/backup_handler.go:52-53` | `tmpPath := filepath.Join(os.TempDir(), header.Filename)` 后 `os.Create` —— multipart 文件名完全可控,`../../root/.ssh/authorized_keys` 之类会逃出 `/tmp` 并被截断写入(`filepath.Join` 会 Clean 掉 `..`,不构成防护) | 改 `os.CreateTemp("", "fonu-restore-*.zip")`,或用 `filepath.Base(header.Filename)` |
| `internal/agent/server.go:257-260` | `req.Proxy` 未做任何校验就被拼成 `proxy + "/" + downloadURL` 交给 VPS 下载,随后解压安装为 `frps` 可执行文件 → SSRF + 任意二进制安装;且整条路径**不做 SHA-256 校验**(对比 `internal/frp/runtime.go:203` 主程序下载是校验的) | `req.Proxy` 走 `internal/frp/runtime.go:320 downloadProxyOptions` 白名单;下载后校验 `sha256sums` |
| `internal/frp/ssh_installer.go:42` | `HostKeyCallback: ssh.InsecureIgnoreHostKey()` —— 接受任意主机密钥,MITM 可截获该 VPS 的 root SSH 凭据 | 用 `knownhosts` 校验;或首次连接回显指纹让用户确认后落库 |
| `internal/notify/store.go:400-404` + `internal/api/settings_handler.go:31,50` | `SanitizeSettings` 只删 SMTP 密码 / Webhook Secret / Telegram Token,而 `KeyZeroSSLAPIKey` 在可写白名单里且随 `GET /api/settings` 原样返回 → **ZeroSSL API Key 明文回传浏览器** | 把 `KeyZeroSSLAPIKey` 加入 `SanitizeSettings`,改用掩码(2026-09-24 实施:只回 `MaskedSecret`,未删键——`SettingsView` 与 `CertificatesView` 都用「值非空」判断已配置,删键会让「已配置」状态与 ZeroSSL 申请门槛失效) |
### 1.2 密钥与凭据(中危)
| 位置 | 问题 | 修法 |
| --- | --- | --- |
| `internal/frp/agent_installer.go:34` → `internal/frp/service.go:694-700` → `POST /api/frpmulti/servers/{id}/agent/probe` | Probe 用 `sed` 从 `/etc/fonu-agent/agent.env` 读出 `FONU_AGENT_TOKEN` 放进返回体,**明文回传前端**(库里同一字段走 `has_token` 掩码,`internal/frp/model.go:102`) | 只返回 `has_token` + 后 4 位 |
| `internal/agent/server.go:1243` | 自动生成的 Basic Auth 密码被写成 nginx 注释 `# Basic Auth: user=… password=…`,文件 0640 落在 VPS vhost 目录,且被 `GET /api/v1/routes/{domain}/conf` 原样返回 | 只在 `PUT /api/v1/routes` 响应里返回一次密码,不写注释 |
| `internal/auth/auth.go:57-62` | 首启生成的 admin 密码明文写入应用日志(`internal/app/app.go:54-55` 同时进 stdout 与 `app.log`),而 `GET /api/logs/system` 会把该文件内容返回 UI | 仅写 stderr,或改为环境变量 / 一次性文件下发初始密码 |
| `internal/agent/server.go:97,892` | 目录缓存文件用 `0o644` | 统一 `0o600` |
### 1.3 鉴权强度与会话
| 位置 | 级别 | 问题 | 修法 |
| --- | --- | --- | --- |
| `internal/api/auth_handler.go:52-70` | 中 | 登录失败只记录,**无限速/锁定**,可无限爆破 | 按 IP + 用户名做失败计数与退避(`notify` 里已有窗口统计可复用) |
| `internal/api/auth_handler.go:90-99` | 中 | 会话 cookie 缺 `Secure`,而应用默认明文 HTTP(`internal/app/app.go:122`) | 依 `X-Forwarded-Proto` 或配置项决定是否加 `Secure` |
| `internal/api/auth_handler.go:114` | 低 | `clearSessionCookie` 未设 `SameSite`(其余 cookie 是 Lax) | 补齐 `SameSite=Lax` |
| 全局 | 中 | 无 CSRF 令牌,状态变更仅靠 cookie | 校验 `Origin`/`Referer`,或加双提交令牌 |
| `internal/auth/auth.go:22,120-138` | 中 | 会话固定 7 天 TTL,改密码**不失效其他会话** | `ChangePassword` 时 `DELETE FROM sessions WHERE admin_id = ?` |
| `internal/agent/server.go:191` | 低 | token 比较用 `!=`(非常量时间) | `crypto/subtle.ConstantTimeCompare` |
| `internal/agent/server.go:114`、`cmd/fonu-agent/main.go:17` | 中 | agent 默认监听 `:7700` 且为明文 HTTP,Bearer token 在公网裸奔;`-token` 还会进 `ps` 输出 | 默认绑 `127.0.0.1`(Nginx 前置)或加 TLS |
| `internal/api/router.go:180` + `:42` | 低 | `SessionMiddleware` 被套两层,重复解析 cookie | 只在外层套一次 |
### 1.4 路径穿越(除 1.1 外)
| 位置 | 级别 | 问题 | 修法 |
| --- | --- | --- | --- |
| `internal/backup/service.go:88` | 中 | `strings.HasPrefix(cleaned, cleaned(dataDir))` 是**不带分隔符的前缀判断**:`DataDir=/data` 时条目 `../data-other/x` 清理后为 `/data-other/x`,前缀匹配通过 → 写到 DataDir 之外 | 用 `filepath.Rel` + 拒绝 `..`(`internal/acme/download.go:75-89 validateCertFilePath` 已是正确写法,直接复用) |
| `internal/backup/service.go:100` | 中 | `os.FileMode(header.Mode)` 未掩码,构造的归档可造出 0777 / 带执行位文件 | `& 0o777`,目录/文件强制 `0o750/0o640` |
| `internal/api/backup_handler.go:41` | 低 | 单请求 64MB 且该端点无频率限制 | 降到 16MB 并加限速 |
---
## 二、错误处理与正确性
| 位置 | 级别 | 问题 | 修法 |
| --- | --- | --- | --- |
| `internal/agent/server.go:318` | 高 | `_ = exec.Command("systemctl","enable","--now","frps").Run()` 后无条件 `notes = append(notes, "frps.service 已启动")` —— 启动失败仍报成功 | 判 `err`,失败写 stderr 到 `notes` 并置 `ok:false` |
| `internal/frp/service.go:1498` | 中 | `_ = os.WriteFile(path+".bak", current, 0o600)` 失败被忽略,紧接着 `os.Rename` 覆盖唯一副本,可能丢配置 | 备份失败即 return,不覆盖 |
| `internal/agent/server.go:1188` 附近 `readLimitState` | 中 | `_ = json.Unmarshal` 失败静默变空 map,`writeLimitState` 随后删掉 zone 文件 → 全部路由限流静默消失 | 反序列化失败返回 error 并保留原文件 |
| `internal/frp/service.go:775-779` | 中 | `InstallFRPSOnAgent` 里 `_ = client.PushFRPSConfig(...)` 忽略下发失败,前端只看到「安装成功」 | 把该错误并入返回结果 |
| `internal/api/cert_handler.go:47,144`、`ddns_handler.go:122`、`frpmulti_handler.go:609` | 中 | `_ = json.NewDecoder(r.Body).Decode(&req)` 忽略解析错误,继续用零值请求体 | `if err := ...; err != nil { 400 }` |
| `internal/backup/service.go:30`、`internal/api/backup_handler.go:53-71` | 中 | 写入方 `defer Close()` 不检查错误:ENOSPC 时半个备份被当有效文件下载;`out.Close()` 失败后仍去 Restore | 写入方改显式 `if err := f.Close(); err != nil` |
| `internal/api/logs_handler.go:85,112` | 低 | `_ = logstore.StreamFile(...)` 丢弃 SSE 写出错误 | `slog.Warn` 记录 |
| `internal/api/auth_handler.go:98` | 低 | `_ = h.auth.Logout(...)` 忽略删会话失败,客户端仍清 cookie | 失败返回 500 |
| `internal/traffic/collector.go:148,151` | 低 | `_ = c.persist(ctx)` 落库失败无痕迹 | 记 warn 并保留脏标记重试 |
| `internal/frp/agent_client.go:58` | 低 | `data, _ := io.ReadAll(...)` 忽略读错误,非 2xx 时错误体可能为空 | 判 err |
| `internal/frp/agent_installer.go:24-25` | 低 | `Probe` 把所有 SSH 失败吞成空串,与「VPS 上确实没有」无法区分 | 收集失败原因,结果里带 `warnings` |
| `internal/frp/ssh_installer.go:125-126` | 低 | `var _ io.Reader = (*os.File)(nil)` / `var _ = filepath.Separator` 是为保留导入的哑声明 | 删两行及两个导入 |
| `internal/agent/server.go:229,422` | 低 | `dashboardAddr` 注释自称已废弃,仍被 `status` 调用,每次读一遍 frps.toml | 删函数与调用 |
**已核查无问题**:`err` 遮蔽(`service.go:1494` 用 `readErr` 是有意为之);无命令注入(22 处 `exec.Command` 全固定 argv;SSH 侧唯一插值点 `uploadBytes:95` 经 `shellPath:123` 单引号转义,传入常量 `/tmp/fonu-agent`)。
---
## 三、并发与生命周期
| 位置 | 级别 | 问题 | 修法 |
| --- | --- | --- | --- |
| `internal/frp/service.go:166,170-172` | 中 | `go s.runDownload(version)` 用 `context.Background()` + 10 分钟超时,不随 App 关闭取消,`internal/app/app.go:196-215 Shutdown` 也不等待;任务状态只在内存,重启即丢 | 传 App ctx,`sync.WaitGroup` 在 Shutdown 等待 |
| `internal/scheduler/scheduler.go:31,37` | 中 | `job.Run(ctx)` 无 `recover()`,任一 tick panic 会崩掉整个进程;`time.NewTicker(job.Interval)` 在 Interval ≤ 0 时 panic,目前只靠 `internal/app/app.go:135-143` 兜住 | 每个 job 包 `defer recover()`;`New`/`Start` 跳过非正间隔 |
| `internal/traffic/collector.go:176-198` | 中 | `ingestLine` 持 `c.mu` 写锁时调 `notify.RecordAccessHit`(DB 写 + 通知判断),**锁内做 I/O**,阻塞 `/api/proxies/traffic`、`/clients` 读 | 锁内只累积 IP,锁外统一 notify |
| `internal/frp/service.go:1258` | 中 | `agentClient` 每次调用都 `NewAgentClient` → 新建 `http.Client`/`Transport`,批量同步 N 条路由就建 N 个连接池 | 按 serverID 缓存 client |
| `internal/agent/server.go:107 preloadNginxConfDir` | 低 | 启动 `go func()` 一次性任务无 ctx,退出时可能正在跑 `nginx -T` | 加 ctx 或 WaitGroup |
| `internal/logstore/stream.go:65-70` | 低 | 每个 SSE 客户端一个 goroutine + 500ms 轮询(ctx 检查正确、无泄漏) | 可接受;量级上去后换 fsnotify |
**已核查无问题**:全部 `http.Client` 构造点都带 `Timeout`;两处 `time.NewTicker` 都有 `defer Stop()`。
---
## 四、重复逻辑(可收敛点)
| 重复项 | 证据 | 建议 |
| --- | --- | --- |
| **「安全写文件」5 套实现** | `internal/agent/server.go:1428 atomicWrite`、`internal/frp/service.go:1816 writeConfigAtomically`、`internal/nginx/nginx.go:114-141`(固定 `.tmp` 名,0644,且同一段在 `:194`、`:394` 再抄两遍)、`internal/chinacidr/service.go:99-117`、`internal/frp/service.go:1488-1501` | 抽 `internal/fsutil.WriteFileAtomic(path, data, mode)`;`nginx.go` 三处合并为 `applyConfigFile` |
| **非原子直写** | `internal/nginx/custom.go:98,144,151,158`、`internal/nginx/errors.go:32,78` | 同上 |
| **`.bak` 备份 4 套** | `internal/frp/service.go:1496-1498`、`:1821-1825`、`internal/nginx/nginx.go:132-136`、`internal/nginx/custom.go:190-197` | 统一 `fsutil.Backup(path)` |
| **nginx 模板双份** | `internal/agent/server.go`(`nginxRoute:1155`、`sslDirectives:1420`、`zoneKey:1390`、限流/geo 生成)与 `internal/nginx/generate.go`(有 30 个测试文件覆盖)各写一遍 vhost/TLS/限流模板 | 抽到共享包,agent 与宿主机同源;否则改一处必须记得改两处 |
| **agent HTTP 客户端双份** | `internal/frp/agent_client.go` 与 `internal/frp/agent_insights.go`、`internal/agent/insights.go` 对同一批端点各自解包 | insights 也走 `AgentClient.request` |
| **规则自定义配置两处入口** | `internal/service/nginx_config.go` 与 `internal/nginx/custom.go` | 只保留 `internal/nginx/custom.go` |
---
## 五、前端
### 5.1 用户可见缺陷(已复核)
| 位置 | 级别 | 问题 | 修法 |
| --- | --- | --- | --- |
| `web/src/views/AgentView.vue:582` | **P0** | `<StatusBadge :value="serverStartupType(viewingServer)" :text="serverStartupText(viewingServer)" />` —— `serverStartupType()`(`:1382-1387`)返回的是 `'success'\|'warning'\|'error'\|'default'`(`n-tag` 的 type 值),却被当状态值传给 `:value`;`statusKind('success')` 与 `statusKind('default')` 都落到 unknown → **服务端详情弹窗「运行中」和未知态都是灰点**(`FrpView.vue:777` 注释记录过同一个坑,那边修了、这边没修) | 改 `:kind="serverStartupType(viewingServer)"`,函数标注返回 `StatusKind` 并把 `'default'` 换成 `'unknown'`;`:49` 的 `n-tag :type` 另拆 `serverStartupTagType()` |
| `web/src/views/DashboardView.vue:35,46` | **P1** | `:value="status?.ddns_status"` / `:value="status?.certificate_status"` 配静态 `text="正常"` —— 后端返回 `error`/`none` 时出现「红/灰点 + 正常」自相矛盾 | `text` 改由 `statusLabel(value)` 推导 |
| `DdnsView.vue:170,213`、`ProxyView.vue:139`、`DashboardView.vue:22,59,70,162,238,263` | P2 | 目前值恰好是 `statusKind()` 的合法输入,但语义错,后端一改值就静默变灰 | 统一改 `:kind` |
| `web/src/views/ProxyRemoteView.vue:489-514` | P1 | `refreshAll` 里 7 个独立接口用 `await track(...)` 串行发,每次刷新耗时是 7 个往返相加;15 秒轮询时同样串行 | 改 `Promise.all`(`track` 已把异常收进 `failures`,并行不影响「单点失败不中断整轮」);健康检查可拉长周期或只在手动刷新时跑 |
### 5.2 死代码与重复样式(重构遗留)
| 位置 | 级别 | 问题 |
| --- | --- | --- |
| `web/src/views/FrpView.vue:1229-1236`、`:1396-1398`、`:1468-1474`、`:1487`、`:1524` | P1 | `frp-server-table`、`frp-detail-card`、`server-detail*`、`detail-stats`、`server-status-summary`、`status-bars` 等类名在模板与脚本 **0 引用**(活体已迁到 `AgentView.vue:578-610` / `1818-1844`),整块可删 |
| `web/src/views/AgentView.vue:1853-1855` | P1 | `.detail-subgroup`/`__title`/`.detail-muted` 与 `:1850-1852` **逐字重复两遍**,删后一组 |
| 弹窗外壳 `.proxy-modal__*` | P1 | 在 `FrpView.vue`(`.frp-proxy-modal` 与 `.frp-guide-modal` 各一份)、`ProxyRemoteView.vue:942-958`、`AgentView.vue`、`ProxyView.vue` **四份**,差异仅 1–2 个属性 | 抽 `ProxyModalShell.vue`(header/tabbar/scroll/help 四插槽)或非 scoped 的 `styles/proxy-modal.css` |
| 左列表骨架 | P1 | `DdnsView`(`.ddns-layout`/`.task-sidebar`/`.task-item`)与 `AgentView`/`FrpView`/`ProxyRemoteView`(`.server-sidebar`/`.server-item`)两套命名同构重复;`.server-item__name`/`__endpoint` 三条规则在三个文件逐字相同 | 抽 `SidebarList.vue`(items + activeKey + 行插槽) |
> 说明:`FrpView.vue` 里 `.server-sidebar` / `.server-item`、`.frp-proxy-modal`、`.frp-guide-modal` **仍在模板中使用,不是死代码**,不要一起删。
### 5.3 硬编码颜色(违反「零硬编码色值」约定)
grep 计 `#hex`/`rgb()`/`rgba()` 共 267 处 / 19 文件;扣除 `styles/variables.css`(36 处,令牌定义本体)后 231 处。按文件:`useTheme.ts` 45、`CertificatesView.vue` 44、`ProxyView.vue` 34、`ProxyAccessLogBox.vue` 22、`DdnsView.vue` 18、`DashboardView.vue` 15、`MiniTrafficChart.vue` 11、`MiniBarChart.vue` 9、`SettingsView.vue` 8、`StatCard.vue` 6、`MiniDonutChart.vue` 5、`LogsView.vue` 4、`AgentView.vue` 3、`StatusBadge.vue` 3,其余各 1。
| 优先级 | 位置 | 问题 |
| --- | --- | --- |
| P1 | `DashboardView.vue:1124-1131` | 横幅写死 `#f4fffc/#f1fbff/#eef9ff/#0f172a/#134e4a` 与 `rgba(16,185,129,…)`,而 `variables.css:17,58` 已有 `--fonu-banner-bg`/`--fonu-banner-border` |
| P1 | `CertificatesView.vue:1256-1272`、`DdnsView.vue:1263-1279`、`ProxyView.vue:2225-2228` | 同一组状态色 `#3b82f6/#10b981/#f59e0b/#ef4444` 复制三处 | 收敛到 `StatCard` 的 tone 类或 `--fonu-tone-*` 令牌 |
| P1 | `CertificatesView.vue:1182-1211`、`ProxyAccessLogBox.vue:149-150` | 同一暗色终端色板写两遍 | 抽 `--fonu-log-*` |
| P1 | `StatusBadge.vue:43,48,53`、`StatCard.vue:84-95` | 组件内 `rgba(16,185,129,.12)` 绕过 `variables.css:4` 已有的 `--fonu-brand-soft` | 改用令牌并补 warning/error 的 soft 令牌 |
| P2 | `ProxyView.vue`、`DdnsView.vue`、`CertificatesView.vue`、`SettingsView.vue:1251-1282,1601-1613`、`LogsView.vue:854-898` 等 | 零散违规,含 `color-mix(in srgb, #10b981 40%, …)` 与 `var(--n-error-color, #e11d48)` 直接引用 Naive UI 变量 | 逐条换语义令牌 |
| P2 | `MiniBarChart`/`MiniDonutChart`/`MiniTrafficChart`/`useTheme.ts` | 图表色板三份重复拷贝,且 `AgentView.vue:1257` 用 `#18a058` 与品牌绿不一致 | `useTheme.ts` 导出 `CHART_COLORS` 供各图表复用 |
**有意保留**:`FrpView.vue:1385`、`AgentView.vue:1808` 的 `color: #fff`(品牌底色上的文字);ECharts 系列色(按既有约定保留,但建议收敛为常量)。
### 5.4 类型
- `api/client.ts:288,331` 的 `Record<string, any>` 是全仓仅有的 2 处 `any` → 定义 `FrpAgentProbeResult`。
- `FrpView.vue:778` 用 `as 'success' | 'disabled' | 'warning'` 硬转 → 改 `: StatusKind`。
- `DdnsView.vue:669-673` 的 `taskStatusKind()` 名为 Kind 实为状态值,与 `StatusBadge` 的 `kind` 语义相反 → 改名 `taskStatusValue`。
- `AgentView.vue:1382-1387` 的 `serverStartupType()` 无返回类型注解,是 §5.1 那条 P0 的根因。
### 5.5 轮询与页面可见性
- 清理齐全(9 处 `clearInterval` 都在 `onUnmounted`/`onBeforeUnmount`)。
- **只有仪表盘处理了页面隐藏**:`DashboardView.vue:846-856` 只停 traffic(2s)/status(15s),同文件的 `cidrPollTimer`(`:946-974`)与 `loadRouteHealth` 不在暂停范围;`ProxyView.vue:1799,1825`、`FrpView.vue:1207,914`、`LogsView.vue:782`、`ProxyRemoteView.vue:865` 完全没有隐藏页处理 → 抽 `useVisibilityPolling()`。
- **轮询无 in-flight 互斥**:`DashboardView.vue:808-812` 每 2s 调 `refreshTraffic()`,体内只有 try/catch,慢链路会堆积并发请求 → 加 `if (loading.value) return` 或改递归 `setTimeout`。
- 频率偏高:2s 类(Dashboard/ProxyView)建议降到 5s 或按激活状态降频。
### 5.6 超大组件
`ProxyView.vue` 3480、`AgentView.vue` 1966、`CertificatesView.vue` 1875、`DashboardView.vue` 1758、`DdnsView.vue` 1745、`SettingsView.vue` 1672、`FrpView.vue` 1557。
- `ProxyView.vue` 优先拆 `RuleFormModal.vue`、`NginxSettingsPanel.vue`、`RuleClientsPanel.vue`。
- `AgentView.vue`:模板 `440-559` → `ServerFormModal.vue`;`561-577` → `ServerLogsModal.vue`;`578-640` → `ServerDetailModal.vue` + `LatencyChart.vue`。
- 搬迁硬约束:**scoped 样式不随模板走,必须整份复制**(`AgentView.vue:1850-1855` 的重复块就是漏搬/重搬的产物)。
### 5.7 空态 / 可访问性 / i18n
- `v-html` 0 处;空态覆盖良好(`EmptyState`/`LoadError` 各页 2–11 处);未发现纯图标按钮缺 `aria-label`。
- 缺口:`AgentView.vue:609`(代理表)与 `:610`(连接日志)用裸文本兜底,未走 `EmptyState`;`DashboardView.vue:244` 的 `health-item__icon--${tone}` 仅靠颜色传达状态,建议补文字或 `aria-label`。
- i18n:无 i18n 设施,用户可见文案全部中文硬编码(含中文的行 1882 行 / 17 文件,含注释故为上限;最多 `AgentView` 388、`FrpView` 352、`ProxyView` 227)。建议先建 `src/locales/zh-CN.ts` 按 views 命名空间搬迁,或至少把高频重复串提为常量(样板:`utils/status.ts:3-11` 的 `LABELS`)。
---
## 六、测试缺口
**Go:71 个 `*_test.go`,但 8 个包零测试** —— `internal/app`、`internal/service`、`internal/scheduler`、`internal/settings`、`internal/backup`、`internal/secret`、`internal/discovery`、`internal/version`,以及 `cmd/fonu`、`cmd/fonu-agent`。
关键文件零覆盖(按风险排序):
1. `internal/agent/server.go`(1465 行,VPS 侧所有文件写入/nginx 操作/exec)—— 包内仅 `apr1_test.go`、`insights_test.go`;`validateRoute`、`nginxRoute`、`atomicWrite`、`detectNginxConfDir`、`installFRPS` 全无测试。
2. `internal/api/router.go` + `middleware.go` —— 没有任何测试断言「每条路由都在 `RequireAuth` 之后」,新增路由漏 `protect()` 不会有信号。
3. `internal/backup/service.go` —— §1.4 的路径校验 medium 无测试;`backup_handler.go` 的临时文件命名也无测试。
4. `internal/frp/service.go`(1847 行)—— 仅 `service_diagnostics_test.go` 间接触及。
5. `internal/frp/ssh_installer.go`、`agent_installer.go` —— `extractToken`、`isELFBinary`、`shellPath`(含单引号转义)都是纯函数,测试成本极低。
建议补的最小集:① 路由鉴权覆盖测试(遍历 `NewRouter`,非公开路径断言 401);② `backup.Restore` 的 `../` 与 `../data-other/` 两组用例;③ `agent.nginxRoute` + `validateRoute` 表驱动测试;④ `shellPath`/`extractToken`/`isELFBinary` 纯函数测试;⑤ `scheduler` panic 恢复测试;⑥ `internal/notify` 补「SMTP 密文损坏时 Telegram 仍可发送」。
**前端:完全没有测试设施** —— `web/package.json` 无 `test` 脚本,devDependencies 无 vitest/jest/@vue/test-utils,`web/src` 无 `*.spec.ts`/`*.test.ts`;唯一质量门是 `npm run build` 内部的 `vue-tsc --noEmit`。建议加 vitest 骨架,从 `src/api` 与格式化纯函数起步。
---
## 七、工程化 / CI / Docker / 版本
| 位置 | 级别 | 问题 | 修法 |
| --- | --- | --- | --- |
| `.github/workflows/`(`docker-release.yml` 106 行、`github-release.yml` 52 行) | **高** | CI 存在但**只发版不校验**:仅 tag / `workflow_dispatch` 触发,零 `go build`/`vet`/`test`、零 `vue-tsc`/`vite build` | 新增 `.github/workflows/ci.yml`,`on: [push, pull_request]` |
| 同上 + `cmd/fonu/main.go:19` | **高** | 步骤顺序不可颠倒:`//go:embed all:web/dist`,而 `web/dist/` 被 `.gitignore` 忽略 → 干净检出下 `go build ./...` 直接失败。CI 必须「前端构建 → 复制 dist 到 `cmd/fonu/web/dist` → `go vet` → `go test ./... -count=1` → `go build`」 | 按此顺序写 `ci.yml` |
| `web/package-lock.json` + `pnpm-lock.yaml` + `pnpm-workspace.yaml` | **高** | 三套依赖描述并存,`package.json` 无 `packageManager`,全仓无 pnpm 调用,Dockerfile 用 `npm ci` | 删 `pnpm-lock.yaml` 与 `pnpm-workspace.yaml`,加 `"packageManager": "npm@10"`(2026-09-24 实施:仓库里实际只有 `web/pnpm-workspace.yaml`,无 `pnpm-lock.yaml`;已删该文件并声明 `npm@10.9.4`) |
| `VERSION`(1.2.6) vs `web/package.json:4`(1.0.2) vs `web/vite.config.ts:10` | **高** | 版本号双源:Docker 链正确注入 `VERSION`,但本地 `npm run dev/build` 回落 `pkg.version` → UI(`AppLayout.vue:140`)显示 1.0.2 | 同步 `package.json` 版本,或让 `vite.config.ts` 直接读 `../VERSION`;CI 加一致性校验 |
| `web/package-lock.json`(如 `:1360`) | 中 | `resolved` 指向 `registry.npmmirror.com`,海外 runner 会从镜像拉包 | 加 `web/.npmrc` 或 CI 用 `npm ci --registry=https://registry.npmjs.org` |
| `fonu-agent.exe`(项目根) | 高 | 未被 `.gitignore` 覆盖(规则里没有 `*.exe`) | 加 `*.exe` 或精确到 `/fonu-agent.exe` |
| `internal/frp/agentbin/*` | 中 | 是 `//go:embed` 编译必需资源(须入库),但无二进制标记 | `.gitattributes` 加 `internal/frp/agentbin/* binary` |
| `docker-release.yml` 的 `build-args: VERSION=…` | 中 | 死配置:Dockerfile 只有 `ARG VERSION_FILE`,从不读 `ARG VERSION`,tag 与文件不一致时静默 | workflow 加一步校验 tag == `VERSION` 文件,或 Dockerfile 支持 `ARG VERSION` 优先 |
| `.dockerignore:15` | 中 | ` docs` 带前导空格;`:12-13` 仍排除已不存在的 `internal/frp-1.0.2`、`FrpView-1.0.2.vue` | 去掉空格与两行过期排除项 |
| `Dockerfile:21` | 低 | `COPY web/package.json web/package-lock.json* ./` 通配符会在锁文件缺失时静默退化 | 去掉 `*` |
| `Dockerfile:53` | 低 | `COPY web/assets/image` 与 `internal/nginx/error_assets_embed.go` 的 embed 可能重复 | 确认运行时用途,若已被 embed 覆盖则移除 |
| `README.md:156`、`docs/DEPLOYMENT.md:42,53`、`internal/frp/frps_template.go:45` | 中 | 文档称「镜像内置 frpc 0.71.0」,而 `Dockerfile:46` 明确「frpc 不预装」 | 三处改为「按需下载」 |
**已确认正确**:镜像链路自洽(`npm ci` → `vite build` → dist 拷入 `cmd/fonu/web/dist` 正好匹配 embed;`web/assets/image` 与 `cmd/fonu/web/dist` 均真实存在,COPY 不会失败);`.data/`、`*.db` 已被忽略。
---
## 八、文档与仓库卫生
| 位置 | 级别 | 问题 | 修法 |
| --- | --- | --- | --- |
| git 根 `f:\Fonu-main` | **高** | 仓库根在此且 `No commits yet on main`,`Fonu-1.0.2/` 整目录未跟踪,根无 `.gitignore`;源码与文档全在版本控制之外 | 明确仓库边界:在 `Fonu-1.0.2/` 内独立 `git init`,或根补 `.gitignore` 并首次提交 |
| `f:\Fonu-main` 根目录 | **高** | 散落 `fonu-1.1.0-image.tar.gz`、`fonu-1.1.0-linux-amd64.tar.gz`、`fonu-1.1.0-linux-amd64`、`fonu-1.1.0-src.zip`、`agent-tsc.log`、`frps-m1-tsc.log`、`fonu-image-build4.log` | 移出仓库或删除;根 `.gitignore` 加 `*.tar.gz`/`*.zip`/`*.log`/`fonu-*` |
| `.breezell/`、`.gocache-agent/` | 中 | 未被 gitignore(`.dockerignore:20` 只挡了前者进镜像) | 根 `.gitignore` 加两行 |
| `internal/frp-1.0.2/`、`web/src/views/FrpView-1.0.2.vue` | 中 | 工作树已删除但**尚未提交**(`git status` 显示为未暂存删除),误 `git checkout` 即回滚清理 | 随下次提交一并提交,并在 CHANGELOG 记一条「移除 1.0.2 归档副本」 |
| `Fonu-1.0.2/docs/`(14 份 md) | 中 | 无索引;`upstream-sync-plan.md` 对 `/frp` 现状的描述基于 2026-09-23(已被 v1.2.6 重构推翻);`agent-view-design.md` / `-v2.md` 的「顶部下拉」结构已过期;`agent-capability-plan.md` 的 P0/P1/P2 已实施但仍以「方案」语气;`frps管理方案.md` 疑似被 `frps服务端管理方案.md` 取代 | 加 `docs/README.md` 索引表(文件 / 主题 / 状态 / 更新日期);给三份文档加时效或实施状态标注 |
| `f:\Fonu-main\docs\` 与 `Fonu-1.0.2\docs\` | 中 | 两套文档根并存(根下 `111.md`、`FRP迁移方案-0.1.25到1.0.2.md`、`FRP版本对比-1.0.2-vs-0.1.25.md`) | 合并,或声明根 docs 为临时区 |
| 仓库根 | 低 | 无 README(`Fonu-1.0.2/README.md` 本身 189 行、质量良好) | 上移或放最短指路 README |
| `gofmt` | 低 | `internal/api/auth_handler.go:29-32`、`internal/api/settings_handler.go:44-79`、`internal/traffic/collector.go:18-24,31-38` 从列对齐看确定未格式化(**待 `gofmt -l internal cmd` 确认全量**) | 跑 `gofmt -w` 并在 CI 加卡口 |
---
## 九、增补清单(按优先级,可勾选)
**P0(先做)**
> **实施状态(2026-09-24)**:以下 8 项已全部完成并随 v1.2.7 提交;agent 侧同时把 `AgentVersion` 递增到 0.12.0 并重编了内嵌双架构二进制(VPS 上需点「升级 Agent」才生效)。两处与本文写法不同的取舍见 §1.1 末行与 §七 pnpm 行。
- [ ] `internal/api/backup_handler.go:52` 改 `os.CreateTemp`;`internal/backup/service.go:88` 改 `filepath.Rel` 校验;`:100` 掩码 mode
- [ ] `internal/agent/server.go:258` 的 `req.Proxy` 走白名单 + 下载后校验 SHA-256
- [ ] `internal/frp/ssh_installer.go:42` 换 `knownhosts`(或指纹确认)
- [ ] `internal/notify/store.go:400` 增删 `KeyZeroSSLAPIKey`;`agent_installer.go:34` 只回 `has_token`;`agent/server.go:1243` 不写密码注释
- [ ] `web/src/views/AgentView.vue:582` 改 `:kind` + 返回类型标注;`DashboardView.vue:35,46` 文案随状态推导
- [ ] `.github/workflows/ci.yml`(前端构建 → dist 同步 → vet → test → build)
- [ ] 删 `web/pnpm-lock.yaml` + `pnpm-workspace.yaml`;`web/package.json` 加 `packageManager` 并与 `VERSION` 对齐
- [ ] 根目录 `.gitignore`(含 `*.exe`、`.breezell/`、`.gocache-agent/`)并清理散落产物
**P1**
- [ ] `internal/agent/server.go:318` 假成功、`internal/frp/service.go:1498` 备份失败仍覆盖、`:775-779` 忽略下发失败
- [ ] 4 处请求体解析忽略错误改 400(`cert_handler.go:47,144`、`ddns_handler.go:122`、`frpmulti_handler.go:609`)
- [ ] 两个 `http.Server` 补 `ReadTimeout`/`IdleTimeout`;`runDownload` 挂 App ctx;`scheduler` 加 `recover()`
- [ ] 登录失败退避 + 改密失效旧会话 + cookie `Secure`/`SameSite`
- [ ] `ProxyRemoteView.vue:489-514` 改 `Promise.all`
- [ ] 清 `FrpView.vue` 四组死样式 + `AgentView.vue:1853-1855` 重复块(即 merge 方案的步 4)
- [ ] 抽 `ProxyModalShell.vue` / `SidebarList.vue`;抽 `useVisibilityPolling()`;轮询加 in-flight 互斥
- [ ] 修 README/DEPLOYMENT/`frps_template.go:45` 的「内置 frpc」说法;删 `.dockerignore:12-13,15` 问题行
- [ ] `docs/README.md` 索引 + 三份文档时效标注
**P2**
- [ ] 抽 `internal/fsutil`(原子写 + 备份),合并 5 套写文件 / 4 套 `.bak`
- [ ] nginx 模板抽共享包,agent 与宿主机同源
- [ ] `traffic/collector.go:176` 锁外 notify;`frp/service.go:1258` 缓存 agent client
- [ ] 补测试:路由鉴权覆盖、`backup.Restore` 路径、`agent.nginxRoute`、纯函数组、`scheduler` panic、notify 隔离
- [ ] 前端加 vitest;颜色令牌收敛(231 处);i18n 起步
- [ ] 拆 `ProxyView.vue`(3480 行)等超大组件
---
## 十、需用户决策
1. **仓库边界**:`f:\Fonu-main`(根,未跟踪全部源码)还是在 `Fonu-1.0.2/` 内独立 `git init`?这决定 `.gitignore` 与 README 放哪。
2. **包管理器**:确定 npm(删 pnpm 两个文件)还是保留 pnpm(需同步改 Dockerfile/README)。
3. **SSH 主机密钥策略**:`knownhosts` 落库(严格、需用户首次确认指纹)还是仅做指纹变更告警(宽松)。
4. **agent 监听地址**:改绑 `127.0.0.1` 需确认 VPS 上 Nginx/SSH 隧道的前置方式,属于会改变部署方式的改动。
5. **版本号粒度**:v1.2.4/1.2.5/1.2.6 同日三段,是否改为「同日合并一段」。
6. **前端 i18n**:是否要真正引入(工作量大),还是只做文案常量集中化。
## 十一、需真机验证(静态审查无法覆盖)
- agent 升到 0.11.0 后:VPS 资源数值随负载变化、公网 Nginx 日志、frps.toml 手工改动比对、VPS 证书清单、故意写错配置后点「重载 Nginx」看失败原因。
- `/frp-agent`、`/frp`、`/public-proxy` 三页新分栏结构的实际渲染(本地登录墙无凭据,未做视觉验证)。
- `bin\fonu.exe` 需重建并重启才能看到本轮前端改动。
-213
View File
@@ -1,213 +0,0 @@
# 公网反代页(`ProxyRemoteView.vue`)设计方案
> 目标:以**本地反向代理页 `ProxyView.vue` 为交互参照**,结合公网反代特有语境(部署到 VPS、Nginx 由 agent 管理、证书由 agent 推送、frps 提供隧道),
> 把公网反代页从"能用的列表页"整理成**功能齐全、状态可见、与本地页同族**的页面。
>
> 按 `breezell-design` 两遍流程产出:Pass 1a 栈检测 → Pass 1b 设计方向与令牌 → Pass 2 实施规格。
> 参考页:`ProxyView.vue`(3485 行,本地反代)、`AgentView.vue`(刚按 v2 重构,本页与其保持同族)。
> 更新日期:2026-09-23
---
## 一、Pass 1a:栈与设计系统检测
| 检测项 | 实际值 |
| --- | --- |
| 框架 | 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`) |
| 图标 | `@vicons/ionicons5`(本页目前未用图标) |
| 令牌系统 | `web/src/styles/variables.css`:`:root` + `html.dark` 双主题,`--havline-*` 前缀 |
| 共享组件 | `PageHeader` `HavlineCard` `LoadError` `EmptyState` `StatusBadge`(本页**未用** `StatusBadge`) |
| 工具 | `utils/format.ts`(已用 `formatBytes` / `formatRate`) |
| 同族页面 | `ProxyView`(本地反代,交互参照)、`AgentView`(已按 v2 重构,同为公网侧) |
**结论**:沿用既有体系(naive-ui + `--havline-*` + 共享组件),**不引入**第二套框架/令牌。
---
## 二、现状盘点(含实测缺陷)
`ProxyRemoteView.vue` 现为 584 行,结构是:`PageHeader` → Nginx 提示条 → 三张卡片(Nginx 状态 / 穿透规则与公网反代状态 / 使用说明)→ 两个弹窗(部署设置 / 反代配置)。
| # | 问题 | 证据 | 影响 |
| --- | --- | --- | --- |
| 1 | **刷新失败静默** | `refreshAll()` 的 catch 写入 `loadErrorMsg`,但该变量**从未在模板中渲染** | 拉取规则/健康/流量失败时页面毫无反馈,用户以为"就是没数据" |
| 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)` | 违反令牌规则;暗色主题下对比度未验证 |
| 4 | **自造状态组件** | `nginxState()` 返回自造 `n-tag`;`healthBadges()` 自造 `.health-badge/.health-ok/.health-bad` | 与 `StatusBadge` + `utils/status.ts` 语义分叉,同类状态在不同页面长得不一样 |
| 5 | **「使用说明」整卡占位** | 一整张 `HavlineCard` 只有一段 `form-hint` | 占掉整行网格,信息密度低(`AgentView` 已把这类内容改成 `n-alert` + 入口行) |
| 6 | **空态与折叠区未令牌化** | 自造 `.empty-route` div;`<details class="raw-routes">` 原生折叠 | 空态应为 `EmptyState`;原生折叠样式不随主题 |
| 7 | **loading 粒度粗** | 单个 `refreshing` 同时驱动「刷新」「重新检测」与轮询 | 轮询时按钮闪 loading;无法区分"在部署"与"在刷新" |
| 8 | **切换服务端不重置** | `watch(selectedId)` 不存在 | 上一台的健康/流量/agent 状态残留在新服务端视图里(串台) |
| 9 | **轮询未做可见性/冲突保护** | 15 秒 `setInterval` 里只判断 `!refreshing` | 页面隐藏时仍打 6 个接口;`refreshAll` 串行 6 请求,慢时可能叠压 |
| 10 | **内联尺寸** | `style="width: 220px"` | 应为类名(如 `AgentView` 的 `.agent-select`) |
| 11 | **能力缺口(本地页有、本页无)** | 本地页有:批量排序(`reorderProxies`)、扫描发现(`scanDiscovery`)、Nginx 配置编辑与**回滚**(`saveProxyNginx`/`rollbackProxyNginx`)、客户端连接列表(`getProxyClients`)、实时流量曲线 | 公网侧对应位置缺失:无批量部署、无历史回滚、无已部署路由的客户端/连接明细视图 |
---
## 三、功能总清单(Capability Inventory)
> 原则同 `AgentView` v2:**没有隐式功能**——每个能力有固定位置、可见状态、明确反馈;能做的显示按钮,不能做的显示原因(禁用 + 提示)。
| # | 能力 | 数据来源 | 现状 | 新设计位置 | 可见性形态 |
| --- | --- | --- | --- | --- | --- |
| A | 选择公网服务端 | `listFrpServers`(agent/ssh 已配置) | 已有 | `PageHeader#actions` | 下拉 + 类名宽度 |
| B | 刷新全部数据 | `refreshAll()` | 已有 | `PageHeader#actions` | 主按钮 + `:loading`(独立于轮询) |
| C | **页面级状态提示** | `agentStatus.nginx_missing / nginx_ok / nginx_error` | 自造 alert(硬编码色) | `PageHeader` 之后 | `n-alert`(error/warning),含动作按钮 |
| D | Nginx 可用性概览 | `nginx_missing/ok/running/version/path/conf_dir/error` | 8 项 `nginx-status-grid` | ① 环境区(与 AgentView 同族) | 徽标 + 值字段(令牌化) |
| E | 一键安装 Nginx | `installFrpAgentNginx` | 已有(提示条 + 卡片各一处,重复) | ① 环境区(**只保留一处**) | 按钮 + `:loading` |
| F | **规则汇总统计** | 由 `routeCandidates` + `routeDetails` + `routeHealth` 计算 | **无** | ② 统计区(4 列) | 统计卡:规则总数 / 已生效 / 健康通过 / 连接数 |
| G | 规则列表(每行) | `getFrpAgentRouteCandidates` | 已有 | ③ 规则区 | 行:名称 + 域名 + 上游 + 回源端口 |
| H | 部署态徽标(四态) | `getFrpAgentRoutes` → `stateOf()` | 已有(自造 tag) | ③ 规则行 | `StatusBadge`:已生效 / 已写入未加载 / 语法错误 / 未部署 |
| I | 健康检查(4 段) | `getFrpAgentRouteHealth` → DNS/证书/隧道/服务 | 已有(自造 badge) | ③ 规则行 | `StatusBadge` × 4 + 悬停详情 |
| J | 流量指标(6 项) | `listFrpProxiesTraffic` | 已有(自造 `.traffic-metric`) | ③ 规则行 | 等宽小字 + `formatBytes/formatRate`;采集失败标红 |
| K | 部署反代 | `deployFrpAgentRoute` | 已有 | ③ 规则行 | 主按钮(仅该行 loading) |
| L | 移除反代 | `removeFrpAgentRoute` | 已有 | ③ 规则行 | warning 按钮 + **二次确认**(现无确认) |
| M | 部署设置(3 Tab) | `updateFrpProxy` + 重新部署 | 已有(字段齐全) | 弹窗(沿用 `proxy-modal` 结构) | 基础 / 安全 / 高级 |
| N | 查看生成的 nginx 配置 | `getFrpAgentRouteConf` | 已有 | ③ 规则行 → 弹窗 | 只读代码块 + 复制 |
| O | **VPS 上 agent 管理的路由文件** | `getFrpAgentRoutes`(含 syntax_ok/loaded) | `<details>` 折叠 | ③ 规则区底部 | 与部署态合并展示,令牌化折叠(或独立 Tab) |
| P | 空态引导 | — | 自造 `.empty-route` | ③ 规则区 | `EmptyState` + 「去内网穿透页」动作 |
| Q | 前往公网 Agent 页 | — | 无入口 | `PageHeader` 下方入口行 | 次级链接(Nginx/agent 异常时指向排查页) |
| R | **批量部署**(能力缺口) | 循环 `deployFrpAgentRoute` | **无** | ③ 规则区头部 | 「部署全部未部署」+ 进度反馈(**分批实施,见第十一节**) |
| S | **配置回滚**(能力缺口) | agent 侧需新增历史版本接口 | **无** | 弹窗内(部署设置 → 高级)或规则行 | 暂列"不做",需后端支持(见第九节) |
---
## 四、与本地反代页(`ProxyView.vue`)的对照
| 本地页能力 | 公网反代对应做法 | 结论 |
| --- | --- | --- |
| 6 张统计卡(服务总数/总上传/总下载/当前上传/当前下载/当前连接) | 公网侧统计对象是**规则与部署态**,不是单机流量 | **对齐形式**(`.stats-row` + `.stat-card`),**换指标**:规则总数 / 已生效 / 健康通过 / 连接数 |
| 概览区:域名 · 监听 · 目标 · 协议(`overview-strip`) | 公网侧对应"这台 VPS 的入口":域名 / 回源 / vhost 端口 / 协议 | **对齐**:放进规则行的上游行,不单开概览卡 |
| 实时流量卡(最近 5 分钟 + `live-badge` 实时) | frps 侧只有**当日累计 + 相邻两次采样速率**,无时间序列接口 | **不对齐**:保留"当前速率"文本,不做曲线;若要做需后端提供采样序列 |
| 客户端连接列表(`getProxyClients`) | 公网侧无对应接口(frps dashboard 只给连接数) | **不对齐**:显示连接数;明细需后端扩展 |
| Nginx 配置编辑 + 保存 + **回滚** | 公网侧配置由 agent 生成(模板化),手工编辑会与模板冲突 | **不对齐(有意)**:只提供"查看配置";回滚需 agent 侧版本化,列入不做 |
| 扫描发现(`scanDiscovery`)、排序(`reorderProxies`) | 公网侧规则来源是穿透规则(内网侧),排序/发现属内网语义 | **不对齐**:排序按"名称 + 域名"稳定排序;发现入口指向「内网穿透」页 |
| 规则弹窗 3 Tab(基础配置/安全设置/Nginx) | 公网侧弹窗已同构(基础/安全/高级) | **已对齐**(本轮不改字段,只做校验与提示) |
| 表单里「使用目标 Host 头」「忽略后端 TLS 校验」 | 公网侧明确不做(破坏 frps vhost 路由 / 回源为明文) | **不照搬**(已写入 `docs/agent-proxy-settings-plan.md` 排除清单) |
---
## 五、设计方向与令牌(Pass 1b)
**方向:部署台账(deployment ledger)**——与 `AgentView` 的"机架面板"同族:一行一条规则,左边是"这条反代是什么",右边是"它现在什么状态、能做什么";颜色只用来表达状态,不用来装饰。
- **Layout**:状态提示 → 统计区(4 列)→ 环境与 Nginx(2 列)→ 规则台账(整宽)→ 入口行;左对齐,无居中、无渐变。
- **Signature**:**规则行左侧的「四段健康徽标串」**(DNS · 证书 · 隧道 · 服务),一眼扫完所有规则的健康。
**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**:沿用 `--havline-font` / `--havline-mono`;页内 4 档字号(12 / 12.5 / 13 / 18-30)+ 3 档字重,与 `CertificatesView` 统计卡规格一致。
**Spacing & Radius**:只用 `--havline-space-1…6` 与 `--havline-radius(-sm)`、`--havline-shadow`;`style="width: 220px"` → `.proxy-remote-select`。
---
## 六、页面分区与线框
```text
┌ PageHeader 公网反代 · 一句话说明 [服务端 ▾] [刷新] ┐
├ n-alert(Nginx 缺失 / 校验失败,含「一键安装 Nginx」动作)
├ 入口行:Agent 与 frps 的生命周期、配置下发在「公网 Agent」页 →
├ ① 统计区(4 列) 规则总数 · 已生效 · 健康通过 · 当前连接
├ ② 环境与 Nginx(2 列)
│ 可用性 / 进程 / 版本 / 可执行文件 / vhost 目录 / 配置校验 / 端口监听
├ ③ 规则台账(整宽)
│ [名称] 域名(mono) [已生效] [查看配置] [部署设置] [部署反代] [移除反代]
│ 上游 192.168.31.152:7023(回源 127.0.0.1:8080)
│ DNS ✓ 证书 ✓ 隧道 ✓ 服务 ✓ 连接 3 · 当前 ↑12KB/s ↓2MB/s · 总 ↑1.2GB ↓8.4GB
│ …(每行一条;空态 EmptyState + 「去内网穿透页」)
│ 折叠:VPS 上 agent 管理的路由文件(3)—— 7023.36800.cc 已加载 / …
└ 弹窗:部署设置(基础 / 安全 / 高级)· 反代配置(只读 + 复制)
```
分区原则:**统计区只读汇总 / 环境区只读诊断 + 安装动作 / 规则区一行一规则(状态在左、动作在右)**;「使用说明」整卡删除,内容并入 `PageHeader.description` 与 `n-alert`。
---
## 七、组件与状态矩阵(Pass 2 规格)
| 元素 | default | hover | pressed | focused | disabled | loading |
| --- | --- | --- | --- | --- | --- | --- |
| 刷新按钮 | 库默认 | 库默认 | 库默认 | 库默认焦点环 | 无服务端时禁用 | `:loading="manualRefreshing"`(轮询不驱动) |
| 服务端选择器 | 库默认 | 库默认 | — | 库默认 | 无服务端时禁用 | — |
| 「部署反代」 | primary | 库默认 | 库默认 | 库默认 | 该行操作进行中 | `busy === 'deploy:<id>'` |
| 「移除反代」 | warning quaternary | 库默认 | 库默认 | 库默认 | 同上 | `busy === 'remove:<id>'` |
| 「一键安装 Nginx」 | primary | 库默认 | 库默认 | 库默认 | agent 未连接 | `:loading="installingNginx"` |
| 健康徽标 / 部署态 | `StatusBadge` | —(只读) | — | 不可聚焦 | — | 数据未加载时显示 `—` |
| 统计卡 | 令牌底色 | **有意不加 hover** | — | 不可聚焦 | — | 未加载显示 `—`(不是 0) |
| 折叠「路由文件」 | 原生 summary + 令牌样式 | 文字色变 | — | 库默认 | — | — |
**内容状态**:未加载 `—` / 首次加载 `n-spin` / 拉取失败 `n-alert error`(修 #1,`loadErrorMsg` 必须渲染)/ 无规则 `EmptyState` / 无路由文件 折叠区隐藏 / 流量采集失败该行标红并给 tooltip。
---
## 八、状态与数据流(含缺陷修复)
```ts
// 1) 切换服务端必须重置(修 #8)
watch(selectedId, () => {
routeCandidates.value = []; routeDetails.value = []; routeHealth.value = []
proxyTraffic.value = {}; trafficErrors.value = {}; agentStatus.value = null
loadErrorMsg.value = ''
void refreshAll({ silent: false })
})
// 2) 错误必须可见(修 #1)
const loadErrorMsg = ref('') // 模板:<n-alert v-if="loadErrorMsg" type="error" :bordered="false">{{ loadErrorMsg }}</n-alert>
// 3) loading 分粒度(修 #7)
const manualRefreshing = ref(false) // 仅"刷新/重新检测"按钮
type Action = `deploy:${number}` | `remove:${number}` | 'install-nginx' | 'save-settings'
const busy = ref<Action | null>(null)
async function run<T>(action: Action, fn: () => Promise<T>) { /* 同 AgentView:串行 + 失败写入 loadErrorMsg */ }
// 4) 轮询:静默 + 可见性保护(修 #9)
timer = window.setInterval(() => {
if (!server.value || document.visibilityState === 'hidden') return
void refreshAll({ silent: true }) // 不触碰 manualRefreshing,不覆盖 loadErrorMsg(失败仅记 console + 顶部提示去抖)
}, 15000)
```
`refreshAll` 的 6 个接口调用保持串行(`routeCandidates → routeDetails → routeHealth → traffic → status`),但**单个接口失败不再中断整轮**:分别 try/catch,把失败项汇总成一句提示(现状是一个失败就整轮丢失后续数据)。
---
## 九、本页不做(Never)
- 不引入第二套 UI 框架 / 令牌;不改全局字体与令牌;
- 不用硬编码色值(现状 6 处全部清除)与魔数间距;
- **不做** Nginx 配置手工编辑与回滚(公网配置由 agent 模板生成,手工编辑会与模板冲突;回滚需 agent 侧版本化,属后端能力);
- **不做**流量时间序列曲线(frps 只提供当日累计 + 采样速率,无历史接口);
- **不做**目标 Host 头改写、忽略后端 TLS 校验(会破坏 frps vhost 路由 / 回源是明文);
- 不把「使用说明」这类静态文案单独占一张卡;
- 不在本页重复 Agent 生命周期操作(安装/升级/卸载 agent 归「公网 Agent」页,本页只放入口链接)。
---
## 十、验收清单
- [ ] 复用项目真实栈与令牌(naive-ui + `--havline-*` + `PageHeader/HavlineCard/LoadError/EmptyState/StatusBadge`),未新增第二套
- [ ] 单一设计方向(部署台账)落地;零硬编码色值;`style="width: …"` 清零
- [ ] 交互状态齐全:hover / pressed / focused(键盘实测)/ disabled / loading / empty / error
- [ ] 无障碍:对比度 AA(亮+暗,重点验 `--havline-brand` / `--havline-warning` / `--havline-error` 在 `--havline-surface` 上)、键盘可达全部动作、装饰图标 `aria-hidden`、健康徽标有可读文本
- [ ] 最窄 320px 无横向溢出;≤760px 规则行纵向堆叠、动作按钮铺满且 ≥44px
- [ ] **功能可见性:对照第三节 A–Q,每项有明确位置;#1 错误可见、#8 切换不串台、#7 loading 分粒度 三项缺陷已修**
- [ ] 回归:部署/移除/保存部署设置三条路径真机验证;`vue-tsc` 0 错误 + `vite build` 成功
---
## 十一、实施顺序与工作量
| 阶段 | 内容 | 预计 |
| --- | --- | --- |
| P1 缺陷修复 | #1 错误可见、#8 切换重置、#7 loading 分粒度、#9 轮询保护、#10 内联尺寸、#3 硬编码色值 | 1h |
| P2 结构对齐 | 统计区(4 卡)、环境与 Nginx 卡令牌化、删「使用说明」卡改 `n-alert` + 入口行、空态换 `EmptyState` | 1.5h |
| P3 规则行重排 | `StatusBadge` 化部署态与健康徽标、流量指标排版、移除二次确认、折叠区令牌化 | 1.5h |
| P4 弹窗收尾 | 部署设置字段校验与提示统一、反代配置弹窗加「复制」 | 0.5h |
| P5 验证 | tsc / build / 真机回归(部署·移除·保存设置) | 1h |
合计约 **5.5–6 小时**,单文件改动(`ProxyRemoteView.vue`),**不涉及后端与 agent**。
能力缺口 R(批量部署)与 S(配置回滚)不在本轮:R 可后续单独做(纯前端循环 + 进度),S 需 agent 侧新增历史接口。
验证:`npx vue-tsc --noEmit` + `npx vite build`,再同步 `web/dist → cmd/havline/web/dist` 后 `go build ./...`。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

-253
View File
@@ -1,253 +0,0 @@
# Havline Web 一键升级方案
> 实施版本:v1.3.0
> 适用范围:`docker-compose.yml`、`docker-compose.hub.yml`
> 不适用范围:`docker-compose.host.yml`、原生 systemd 安装、Windows 直接运行
## 1. 设计目标
在管理后台检测到新版本后,用户点击「一键升级」即可完成:
1. 读取远端最新版本;
2. 构建新的 `havline` 镜像;
3. 只重建 `havline` 容器;
4. 等待 `/api/version` 返回目标版本;
5. 失败时尝试回滚到升级前的镜像 tag;
6. 把升级过程和结果返回给前端。
## 2. 安全边界
主程序容器**不挂载** `/var/run/docker.sock`。
只有 `havline-updater` sidecar 持有:
- Docker Socket;
- Docker CLI 与 Compose;
- 项目目录;
- Git。
主程序与 sidecar 通过一个共享 Unix Socket 通信:
```text
浏览器
→ havline 后端 /api/system/update/*
→ /run/havline-updater/updater.sock
→ havline-updater
→ docker compose build/up
```
这表示:
- 管理后台被攻破时,攻击者仍需通过 sidecar 的固定接口才能触发升级;
- sidecar 只接受固定的 Compose 文件名和固定的 `havline` 服务名;
- 不提供任意命令执行接口;
- 不支持自定义 shell 命令。
## 3. 两种升级模式
| Compose 文件 | 模式 | 行为 |
| --- | --- | --- |
| `docker-compose.yml` | `local` | 在项目目录检查 Git 工作区,干净时执行 `git pull --ff-only`,再构建 |
| `docker-compose.hub.yml` | `git` | Compose 的 build context 直接从 Git 仓库拉取,updater 只负责构建与重建 |
两种模式都会:
- 读取目标版本;
- 构建 `havline` 镜像;
- 执行 `docker compose up -d --no-deps --force-recreate havline`;
- 轮询 `/api/version` 验证升级结果。
## 4. 首次部署
一键升级依赖 `havline-updater` 已经运行。
旧版本升级到 v1.3.0 时,需要先手动执行一次:
```bash
# 本地源码部署
docker compose up -d --build
# 单文件 Git 构建部署
docker compose -f docker-compose.hub.yml up -d --build
```
完成这一轮后,后续版本即可在 Web 页面一键升级。
## 5. 环境变量
### 主程序
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `HAVLINE_UPDATER_SOCKET` | `/run/havline-updater/updater.sock` | 与 sidecar 通信的 Unix Socket |
### updater sidecar
| 变量 | 说明 |
| --- | --- |
| `HAVLINE_UPDATER_SOCKET` | sidecar 监听的 Unix Socket |
| `HAVLINE_UPDATE_PROJECT_DIR` | Compose 项目目录,容器内为 `/workspace` |
| `HAVLINE_UPDATE_COMPOSE_FILE` | 只允许 `docker-compose.yml`、`docker-compose.hub.yml`、`docker-compose.host.yml` |
| `HAVLINE_UPDATE_SERVICE` | 要重建的服务,默认 `havline` |
| `HAVLINE_UPDATE_MODE` | `local` 或 `git` |
| `HAVLINE_UPDATE_REPO` | `git` 模式下的仓库地址 |
| `HAVLINE_UPDATE_BRANCH` | `git` 模式下的分支,默认 `main` |
| `HAVLINE_UPDATE_APP_URL` | 健康检查地址,默认 `http://havline:6893` |
| `DOCKER_SOCK` | 宿主机 Docker Socket,默认 `/var/run/docker.sock` |
| `HAVLINE_UPDATE_HOST_DIR` | 宿主机上 Compose 文件所在目录;留空时自动从 Compose 容器标签发现,设置后作为显式覆盖 |
## 6. 前端行为
侧栏版本区在满足以下条件时显示「一键升级」:
- sidecar 可用;
- 远端版本高于当前版本;
- 当前没有正在执行的升级任务。
点击后弹窗显示:
- 当前版本;
- 目标版本;
- 升级方式;
- 当前阶段;
- 最近日志。
升级开始后,页面会持续轮询升级状态。容器重建期间请求会短暂失败,前端会显示「服务正在重启,等待恢复」,恢复成功后自动刷新页面。
## 7. 回滚
升级前会记录当前容器镜像。升级后健康检查超时,sidecar 会尝试:
```bash
HAVLINE_VERSION=<上一个版本> docker compose up -d --no-deps --force-recreate havline
```
回滚是否成功会写入升级日志。
## 8. 常见问题
### 侧栏没有「一键升级」
检查:
1. 当前是否使用 `docker-compose.yml` 或 `docker-compose.hub.yml`;
2. `havline-updater` 容器是否在运行;
3. `HAVLINE_UPDATER_SOCKET` 是否在两侧指向同一路径;
4. 共享卷 `havline-updater-sock` 是否存在。
如果状态接口返回:
```text
读取远端版本失败:git clone ... signal: killed ... fatal: early EOF
```
说明运行中的 updater 仍是旧版本,仍在用完整克隆读取 `VERSION`。升级到 v1.3.2 后,updater 会改用 Gitea API / raw 地址读取版本文件。由于 sidecar 不能升级自身,需要先手动重建一次:
```bash
docker compose -f docker-compose.hub.yml up -d --build updater
# 本地源码部署
docker compose up -d --build updater
```
### `docker-compose.yml` 模式提示存在未提交改动
升级前的 Git 工作区检查要求干净,避免覆盖用户修改。先处理工作区,或手动执行:
```bash
docker compose up -d --build
```
### 升级时报 `/workspace/docker-compose.hub.yml: no such file or directory`
v1.3.6 起 updater 会自动从 `havline` 容器的 Compose 标签读取宿主机目录,并通过 helper 容器执行 Compose。如果自动发现仍失败,可以显式设置目录:
先查询运行中 `havline` 容器的 Compose 工作目录:
```bash
docker inspect havline --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}'
```
把输出目录写入 Compose 同层 `.env` 作为覆盖:
```dotenv
HAVLINE_UPDATE_HOST_DIR=/实际/的/Compose/目录
```
然后只重建 updater:
```bash
docker compose -f /实际/的/Compose/目录/docker-compose.hub.yml up -d --build updater
```
状态接口会明确提示 Compose 文件缺失,修复挂载或设置覆盖后按钮才会重新显示。
### 报 `container name "/havline" is already in use`
这表示 Compose 没有识别到当前容器所属的原项目,可能把升级当成新项目并尝试新建网络与数据卷。v1.3.7 的更新版 updater 会读取现有容器的 `com.docker.compose.project` 标签,并显式复用原项目名。
如果现有 `havline` 容器不是由 Compose 管理,updater 会拒绝升级,避免新建 `havline_havline-data` 等空数据卷。应先按 Compose 方式重新部署,再使用一键升级。
### helper 命令里出现 `-v /workspace:/workspace`
说明 Compose 标签只返回了容器内路径。更新后的 updater 会优先从 `project.config_files` 标签推导宿主机目录,并忽略 `/workspace` 这类容器路径。若标签仍无法提供真实宿主机路径,应检查部署工具的 Compose 标签是否完整;正常情况下无需手工设置目录。
### `docker-compose.hub.yml` 模式无法读取远端版本
检查 sidecar 容器内是否可以访问 Git 仓库:
```bash
docker exec -it havline-updater sh -lc 'git ls-remote https://jayhub.top/mrlee/Havline.git main'
```
### 升级卡在 `running`
查看 sidecar 日志:
```bash
docker logs -f havline-updater
```
以及 Compose 输出:
```bash
docker compose ps
docker compose logs --tail 100 havline
```
### 回滚失败
确认升级前的镜像 tag 仍然存在:
```bash
docker images havline
```
然后用明确的版本 tag 手动切回:
```bash
HAVLINE_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate havline
```
## 9. 暂不支持
- `docker-compose.host.yml`:需要确认 host 网络与端口占用后再设计;
- 原生 systemd 安装:需要单独的受控升级 helper;
- 回滚到远端不存在的镜像:需要额外发布流程;
- 升级 `havline-updater` 自身:当前只重建 `havline` 服务,sidecar 更新仍需一次手动 Compose 操作。
## 10. 在线升级验证
「关于项目」页面可以用于确认升级后的版本信息。v1.9.0 的验证流程:
1. 确认当前线上实例为 v1.3.0,且 `havline-updater` sidecar 已运行。
2. 确认远端 `main` 分支的 `VERSION` 为 `1.9.0`。
3. 在侧栏看到「一键升级」后点击并确认。
4. 等待升级弹窗显示成功,页面自动刷新。
5. 打开侧栏「关于项目」,确认:
- 页面路由为 `/about`;
- 页面显示的版本为 `1.9.0`;
- 「源项目」显示为 `O96u/Fonu`;
- 开源协议显示为 `GPL-3.0`。
如果「关于项目」页面可见且版本正确,说明镜像构建、容器重建、后端版本注入和前端静态资源更新均已生效。
-345
View File
@@ -1,345 +0,0 @@
# 上游同步方案:Havline 1.0.2 → 1.0.5 对照与可借鉴项
> **时效说明(2026-09-24)**:文中对 `/frp` 页现状的描述基于 2026-09-23,已被 v1.2.6 的重构推翻(现为**左服务端列表 + 右规则表格**,服务端增删改已并入 `/frp-agent`)。
> 本项目基于**上游 v1.0.2**(2026-09-18)开发,上游已迭代到 **v1.0.5**(2026-09-23)。
> 本文逐版本列出上游 1.0.3 / 1.0.4 / 1.0.5 的全部变更,并逐条核对**本项目代码现状**,
> 给出「同步 / 已具备 / 不适用 / 待核对」的判定与落地建议。
>
> 上游来源:`https://github.com/O96u/Fonu`(CHANGELOG.md + 提交记录)
> 核对方式:对本仓库 `f:\Havline-main\Havline-1.0.2` 做 grep / 文件树核对(证据写在每条的「本项目现状」里)
> 更新日期:2026-09-23
---
## 一、结论速览
| # | 上游项 | 版本 | 本项目现状 | 建议 |
| --- | --- | --- | --- | --- |
| 1 | 非 HTTPS 环境复制到剪贴板失败 | 1.0.4 | **同样存在**:6 处 `navigator.clipboard`(5 个文件) | **P0 同步**:抽 `utils/clipboard.ts` 加降级 |
| 2 | 非 HTTPS 添加 TCP 隧道失败(`crypto.randomUUID`) | 1.0.4 | **不适用**:全仓 `randomUUID` = 0 处 | 不适用(附一条 `crypto.subtle` 复核项) |
| 3 | 日志中心新增「FRP 日志」Tab(解析 frpc 日志) | 1.0.3 | **缺口**:`LogsView.vue` 只有 系统 / 访问 / Nginx 三个 Tab | **P1 同步**(后端接口已具备,纯前端) |
| 4 | 通知:单通道密钥解密失败不拖垮其它通道 | 1.0.5 | **确凿缺口**:`store.go:105-108` 解密失败即 return,整份配置加载失败 | **P1 同步修** |
| 5 | 通知时间按系统时区显示 | 1.0.4 | **条件缺口**:`format.go` 用 `time.Now()`,但容器未设 `TZ` → Docker 下显示 UTC | P1 补 `TZ` |
| 6 | frps 参考配置自动生成 `allowPorts` + 防火墙放行提示 | 1.0.3 | **已具备**:`internal/frp/frps_template.go` 已生成 `allowPorts` | 已具备;仅可补「VPS 防火墙放行」UI 提示 |
| 7 | TCP 远程端口手动指定支持 1–65535 | 1.0.5 | **已具备**:`FrpView.vue:336` 已是 `:min="1" :max="65535"` | 已具备;后端随机范围(1024–65535)待核 |
| 8 | FRP 页重构为「状态卡 + Web/TCP/高级/运行日志分栏」 | 1.0.3 | **方向已对齐**:本轮刚改为标准统计区 + 卡片分区 | 不照搬(形态差异大,见 §三.1) |
| 9 | 独立 TCP 隧道列表 / 纯 TCP 模式无需域名 | 1.0.3 | **功能已具备**:规则列表支持 tcp/udp/http/https 等类型 | 可选做「按类型筛选」,不照搬分栏 |
| 10 | 反代:新建入口时校验监听端口占用 | 1.0.5 | **已具备且语义更准**:`ensureHostAvailable` 校验「同域名 + 同端口」;仅缺冲突规则名与 custom 模式覆盖 | P3 收尾(详见 §八) |
| 11 | 反代:复制改为「预填表单、不改域名后缀」 | 1.0.5 | **部分对齐**:已是 `openDuplicate` 打开表单,但 `duplicateName()` 仍加「-复制」 | P3 去后缀 |
| 12 | 反代:子表撑满卡片 / 域名后紧跟复制·打开 / 状态列仅保留开关 | 1.0.5 | 本地布局不同(本轮未涉及该页) | P3 视觉可选对齐 |
| 13 | Telegram 代理支持 socks5 + 自动补 `http://` | 1.0.3 | **代理已具备**(`telegram.go:43-51`,Go 原生支持 socks5);缺保存校验与自动补前缀 | P3 补校验 |
| 14 | 通知事件开关保存与前端状态同步更可靠 | 1.0.5 | **未定位到缺陷**:`service.go:210` `send()` 为单一出口,未复现上游描述的问题 | 保留观察 |
| 15 | 连接设置:地址与端口合并一行 / 卡片内重启按钮 | 1.0.3 | 本地 FrpView 已有服务端启停按钮 | P3 视觉可选 |
**优先级**:P0 = 剪贴板降级(真实功能缺陷,且本轮新增的复制按钮也在其中);P1 = FRP 日志 Tab、通知健壮性两条;P2/P3 = 核对项与视觉对齐。
---
## 二、逐版本明细
### v1.0.3(2026-09-21)
**新增**
1. **FRP:支持独立 TCP 隧道列表(如 SSH、数据库),可与 Web 网关同时使用;纯 TCP 模式无需配置穿透域名**
- 本项目现状:`FrpView.vue` 的规则表单已支持 `tcp / udp / http / https / tcpmux / stcp / sudp / xtcp`(`proxyForm.type`),且 `remote_port` 仅对 tcp/udp 显示(`FrpView.vue:336`)→ **能力已具备**。
- 差异:上游是「Web / TCP 分栏」的信息架构;本项目是「统一规则列表 + 类型字段」。本项目的公网反代链路(`ProxyRemoteView` → agent → Nginx)依赖 http 规则 + 自定义域名,改成上游式分栏会牵动两页交互。
- 建议:**不照搬分栏**;可选做「规则列表按类型筛选」的小改进(纯前端,低风险)。
2. **FRP:允许先保存连接设置(地址/端口/Token),待配置域名或 TCP 隧道后再启动 frpc**
- 本项目现状:服务端保存与 frpc 启动是分离的(`createFrpServer` 与 `startFrpServer` 独立),但未核对「无规则时是否允许启动」。
- 建议:列入 §四 待核对;若本地已有拦截,则对齐为「允许保存、启动前给提示」。
3. **FRP:frps 参考配置自动生成 `allowPorts`,并提示在 VPS 防火墙放行 TCP 远程端口**
- 本项目现状:`internal/frp/frps_template.go` **已生成 `allowPorts`**(grep 命中)。
- 缺口:缺少「请在 VPS 防火墙放行 TCP 远程端口」的 UI 提示。
- 建议:**P2**,在 frps 配置弹窗(`showFRPSConfig`)与规则保存后各加一句提示(纯文案 + 1 个列表推导)。
4. **日志中心:新增 FRP 日志 Tab,解析 frpc 运行日志(时间、级别、来源、消息)**
- 本项目现状:`LogsView.vue` 现有 Tab = `系统日志 / 访问日志 / Nginx 日志`(第 11 / 40 / 72 行)→ **无 FRP Tab**。
- 后端:已有 `getFrpLogs` / `getFrpProxyLogs`(`api` 命中),日志内容可得。
- 建议:**P1 同步**。落地方式:`LogsView.vue` 增加第 4 个 Tab + 解析器(时间/级别/来源/消息四列,复用 `formatLogTime` / `formatLogLine` 与现有筛选控件),后端若已有 `/api/frp/logs` 则纯前端。
**改进**
5. **FRP:内网穿透页重构为「状态卡 + 连接设置 + Web/TCP/高级/运行日志分栏」**
- 本项目现状:本轮(见提交 `70796e5`)刚把 4 张自造状态卡改为标准 `.stats-row/.stat-card`,并把 6 个弹窗统一到 `.frp-card-modal` 外壳 → **视觉方向已对齐**。
- 建议:不照搬分栏(理由同 §2.1),但可借鉴其「运行日志就近展示」的思路——本地运行日志已在弹窗与日志中心可查。
6. **FRP:状态区展示已配置 Web 域名与 TCP 隧道,标签单行展示,超出可跳转对应 Tab**
- 本项目现状:`stats-row` 第三张卡是「frpc 版本」,未展示「已配置域名/隧道」。上游把这两类信息提到状态区。
- 建议:**P2 可选**。若要做,建议把第 4 张卡「最近应用」换成「已配置入口(N 个 Web / M 个 TCP)」,点击跳规则列表并筛选。
7. **FRP:连接设置中服务器地址与端口合并为一行;左侧卡片内提供重启 frp 服务按钮**
- 本项目现状:服务端行操作列已含「启动/停止」(`serverColumns` 操作列),但无「重启」。
- 建议:**P3**。加一个「重启」按钮(`stopServer` → `startServer` 串联或后端 action),与上游一致。
8. **通知:Telegram 代理地址支持 socks5,保存时校验格式并自动补全 `http://` 前缀**
- 本项目现状:`internal/notify/telegram.go` 存在;是否支持 socks5 未核对。
- 建议:列入 §四 待核对。
### v1.0.4(2026-09-21)
9. **修复:非 HTTPS 访问时添加 TCP 隧道失败(`crypto.randomUUID` 不可用)**
- 本项目现状:**全仓 `randomUUID` 命中 0 处**(`*.vue` 与全仓两次核对均为 0)→ **不适用**。
- 附注:仍需确认无 `crypto.subtle`(同样要求安全上下文)的使用;`crypto.getRandomValues` 不受限。
10. **修复:非 HTTPS 环境下复制到剪贴板失败(内网穿透、DDNS、反向代理)** ← **本项目同样存在**
- 本项目现状:`navigator.clipboard` 共 **6 处 / 5 个文件**:
`ProxyRemoteView.vue`(2 处,本轮新增的「复制域名」「复制反代配置」)、
`AgentView.vue`(1 处,「配置查看」弹窗的复制)、
`DdnsView.vue`(1 处)、`ProxyView.vue`(1 处)、
归档文件 `FrpView-1.0.2.vue`(1 处,已弃用代码,可不动)。
- 影响:Havline 常用 `http://内网IP:6893` 访问,属**非安全上下文**,此时 `navigator.clipboard` 为 `undefined` → 复制功能直接失败(本项目现在只弹「复制失败」提示,用户无从下手)。
- 建议:**P0 同步**。做法:新增 `web/src/utils/clipboard.ts`:
```ts
export async function copyText(text: string): Promise<boolean> {
// 非安全上下文(http:// 内网 IP)下 navigator.clipboard 不存在,回退到 execCommand
if (navigator.clipboard?.writeText) {
try { await navigator.clipboard.writeText(text); return true } catch { /* 落到回退 */ }
}
try {
const el = document.createElement('textarea')
el.value = text; el.setAttribute('readonly', ''); el.style.position = 'fixed'; el.style.opacity = '0'
document.body.appendChild(el); el.select(); document.execCommand('copy'); document.body.removeChild(el)
return true
} catch { return false }
}
```
然后 5 个调用点统一改为 `copyText(...)`,失败提示保留。
11. **修复:通知消息时间未按系统设置时区显示(如 Telegram 测试通知)**
- 本项目现状:`internal/notify/format.go` 存在;是否用 `time.Local` / 配置时区未核对。
- 建议:列入 §四 待核对;修法一般是 `time.Now()` → 按系统时区格式化(或统一走一个 `nowInSystemZone()` 帮助函数)。
### v1.0.5(2026-09-23)
**新增**
12. **反代:新建入口时校验监听端口占用,避免重复端口静默冲突**
- 本项目现状(已核实):**已具备**。`internal/proxy/proxy.go:499 ensureHostAvailable` 在保存前查 `proxy_hosts` 的「同域名 + 同端口」,`proxy_hosts` 表另有 UNIQUE 约束兜底。
- 语义差异:上游按「端口」判冲突,本项目按「端口 + 域名」判冲突——后者才是 Nginx 的真实冲突(同端口不同域名是合法的虚拟主机,按端口判会误报)。
- 残留缺口:错误提示不含冲突规则名;`nginx_mode = custom` 的规则不参与校验。详见 §八。
**改进**
13. **反代:入口/规则「复制」改为打开表单预填原内容,由用户确认后再保存(不再自动创建、不改域名后缀)**
- 本项目现状:`ProxyView.vue` 已有复制相关实现(grep 命中 9 处),行为是否「直接创建」未核对。
- 建议:**P2 核对**;若本地是「点击即创建副本」,按上游改为「预填表单」,避免误建规则。
14. **反代:入口分组子表布局撑满卡片宽度;域名后紧跟复制/打开链接;状态列仅保留开关**
- 本项目现状:`ProxyView.vue` 无「入口分组」概念(grep `分组` = 0)→ **子表撑满**项不适用;「域名后紧跟复制/打开链接」与「状态列仅保留开关」是纯视觉项。
- 建议:**P3 可选**。注意:本轮刚在 `ProxyRemoteView` 的域名列做了「域名 + 复制 + 打开」的同类交互(见提交 `ca309db`),若要对齐,可复用同一套 `copyText` + 图标按钮样式。
15. **反代:同端口监听配置冲突提示仅在配置确实不一致时显示**
- 本项目现状:未核对本地是否有该提示(`ProxyView.vue` 的冲突提示逻辑)。
- 建议:**P2 核对**(误报会干扰用户)。
16. **FRP:TCP 隧道远程端口手动指定支持 1–65535(0 仍为自动分配,随机范围 1024–65535)**
- 本项目现状:**前端已具备**(`FrpView.vue:336` `:min="1" :max="65535"`)。
- 待核:后端「0 = 自动分配」的随机范围是否为 1024–65535(`internal/frp` 内)。
- 建议:核对后如有差异,改一行常量。
17. **通知:各通道密钥解密失败不再拖垮其它通道;事件开关保存与前端状态同步更可靠**
- 本项目现状:`internal/notify/store.go` 有 10 处解密相关逻辑 → 需要看「单通道解密失败是否被上层吞掉/中断整轮发送」。
- 建议:**P1 核对后修**。典型修法:每个通道独立 `try/catch`(Go 里是错误收集而非 return),把失败通道记入日志与结果,继续发其它通道。
**修复**
18. **通知:旧 SMTP 等密文无法解密时,Telegram/Webhook 等已配置通道仍无法告警的问题**
- 与第 17 条同源,**同一处修改可一并解决**。
---
## 三、不照搬项及原因
1. **FRP 页「Web / TCP / 高级 / 运行日志」分栏**:本项目的公网反代链路(`ProxyRemoteView` → agent → VPS Nginx)依赖「http 规则 + 自定义域名 + 部署态」这一套语义;照搬分栏会让「规则 → 部署 → 健康」的入口被打散。当前「统计区 + 服务端表 + 规则表」的形态更贴合本项目已有能力。
2. **`crypto.randomUUID` 修复**:本项目未使用该 API(全仓 0 处),无需移植。
3. **入口分组子表撑满**:本项目 `ProxyView` 没有「入口分组」结构,不适用。
---
## 四、逐项核实结果(A–H,全部已读代码确认,无推测)
| # | 上游项 | 核实结论 | 证据 |
| --- | --- | --- | --- |
| A | 允许先保存连接设置、待有域名/TCP 隧道再启动 frpc | **已具备**:本项目没有任何「必须有规则」的拦截 | `internal/frp/service.go:385` `StartServer` 仅做 GetServer → setServerRuntime("running") → enrich 返回;`FrpView.vue:1118` 启动按钮无 disabled 条件 |
| B | Telegram 代理支持 socks5 + 保存时校验格式、自动补 `http://` | **代理已具备(含 socks5)**;**缺保存校验与自动补全** | `internal/notify/telegram.go:43-51` 已有 `cfg.Telegram.ProxyURL` + `http.Transport{Proxy: http.ProxyURL(parsed)}`(Go 原生支持 `socks5://`) |
| C | 通知时间按系统时区显示 | **条件缺口**:代码用 `time.Now()` 格式化(跟随进程 TZ),但**容器未设 TZ** → Docker 部署下显示 UTC | `internal/notify/format.go:9/30`;`Dockerfile` 与 compose 无 `TZ` 环境变量 |
| D | 单通道密钥解密失败不拖垮其它通道 | **确凿缺口,与上游同一 bug** | `internal/notify/store.go:105-108`:SMTP 密文解密失败即 `return Config{}, runtimeSecrets{}, secret.DecryptHint(err)` → **整份配置加载失败**,Telegram/Webhook 也拿不到配置 → 无法告警;`store.go:117` 的 webhook 同理 |
| E | TCP 远程端口 0=自动分配(随机 1024–65535) | **本项目无此功能**:前端强制手填 1–65535,后端只做范围校验 | `FrpView.vue:336` `:min="1"`;`internal/frp/validate.go:331` `port > 0 && port <= 65535`;全仓未找到随机分配逻辑 |
| F | 反代:新建入口时校验监听端口占用 | **已具备(原判为「缺口」是关键词 grep 误判)**:校验存在但文案为「已被其他规则使用」,故搜 `占用`/`in use` 无命中 | `internal/proxy/proxy.go:499-524 ensureHostAvailable/ensureHostsAvailable`(`Create` 调用于 :225、`Update` 调用于 :345/:367);表约束见 `replaceHosts:526` 的 UNIQUE 分支;错误经 `api/proxy_handler.go:87 writeError(400, err.Error())` 直达前端 |
| G | 反代:复制改为「预填表单、不改域名后缀」 | **部分对齐**:已是打开表单预填,但仍自动加「-复制」后缀 | `ProxyView.vue:148` `@click="openDuplicate(selectedRule)"`(打开而非直接创建);`ProxyView.vue:1364-1366` `duplicateName()` 追加 `'-复制'` |
| H | 是否存在 `crypto.subtle`(同样要求安全上下文) | **不存在**(0 处) | 全仓扫描 0 命中 |
### 核实后的关键结论
1. **真正要修的只有两处**:剪贴板降级(P0,6 处隐患)、通知单通道解密失败(P1,`store.go` 直接 return 导致整份配置不可用);
2. **两处是「条件性」的**:通知时区(仅容器未设 TZ 时出现)、反代复制后缀(行为差异,非缺陷);
3. **四处本项目不缺**:无规则启动、Telegram 代理(含 socks5)、远程端口范围校验、`crypto.subtle`;**一处无对应功能**:远程端口 0=自动分配。
### 原待核对清单(索引保留)
## 五、建议的分批实施
| 批次 | 内容 | 影响面 | 预计 |
| --- | --- | --- | --- |
| **P0** | 剪贴板降级:新增 `utils/clipboard.ts` + 改 5 处调用点 | 前端 6 文件(含本轮新增的复制按钮) | 0.5h |
| **P1-a** | 日志中心「FRP 日志」Tab(四列解析 + 复用筛选) | `LogsView.vue`(+ 可能 1 个后端接口确认) | 1.5h |
| **P1-b** | 通知健壮性:单通道解密失败隔离 + 时区(第 17/18/11 条) | `internal/notify/*`(后端,需 `go test`) | 1.5h |
| **P2** | 待核对 A–H 逐条核实并按结论修(含 frps 防火墙提示、端口占用校验、复制行为、socks5) | 前后端零散 | 2h |
| **P3** | 视觉可选对齐(状态卡补「已配置入口」、规则列表类型筛选、反代视觉三项、重启按钮) | 前端 2 页 | 2h |
**验收**:每批以 `npx vue-tsc --noEmit` + `npx vite build`(前端)与 `go build ./...` + `go test ./internal/...`(后端)为准;涉及通知的后端改动需按现有测试补一条「单通道解密失败仍能发送其它通道」的用例。
---
## 六、附:上游 1.0.2 及更早(本项目基线之前)
本项目基线是上游 **v1.0.2(2026-09-18,新增「证书:火山引擎 DNS 的 ACME DNS-01 验证」)**,其上游历史(v1.0.0 通知体系、v1.0.1 DDNS 火山引擎与多层子域)在本项目 `CHANGELOG.md` 中已有对应条目(v1.0.0 / v1.0.1 / v1.0.2 段),本轮无需回溯。
---
## 七、可增加项清单(本项目确实没有的,按价值排序)
| 序 | 可增加项 | 依据(上游版本) | 落地要点 | 工作量 |
| --- | --- | --- | --- | --- |
| 1 | **剪贴板降级**:`utils/clipboard.ts` + 5 处调用点 | 1.0.4 修复 | `navigator.clipboard` 优先、`execCommand` 回退;非 HTTPS 内网访问下才真正生效 | 0.5h |
| 2 | **通知通道解密失败隔离**:单通道失败不拖垮整轮 | 1.0.5 修复 | `internal/notify/store.go` 的 `load()` 改为逐通道收集错误(失败通道标不可用 + 记原因),继续解析其它通道;补一条「SMTP 密文损坏时 Telegram 仍可发送」的测试 | 1.5h |
| 3 | **日志中心「FRP 日志」Tab**(解析时间/级别/来源/消息) | 1.0.3 新增 | `LogsView.vue` 增第 4 个 Tab;后端 `getFrpLogs` 已可用 | 1.5h |
| 4 | **通知时区**:容器设 `TZ`(并让通知时间走系统时区) | 1.0.4 修复 | `docker-compose*.yml` 加 `TZ: Asia/Shanghai`;`Dockerfile` 可加 `ENV TZ` | 0.5h |
| 5 | ~~反代监听端口占用校验~~ → 已具备,降级为「补冲突规则名 + custom 模式覆盖」 | 1.0.5 新增 | 见 §八:错误提示带冲突规则名;custom 规则解析其 `listen`/`server_name` 后纳入校验 | 0.5h + 2h |
| 6 | **frps 配置弹窗补「VPS 防火墙放行 TCP 远程端口」提示** | 1.0.3 改进 | 纯文案 + 由规则列表推导端口集合(`allowPorts` 已生成) | 0.5h |
| 7 | **Telegram 代理保存校验 + 自动补 `http://`** | 1.0.3 改进 | 保存时校验 URL 格式(允许 `http/https/socks5`),缺 scheme 时补 `http://` | 0.5h |
| 8 | **反代复制去掉「-复制」后缀** | 1.0.5 改进 | `duplicateName()` 改为保留原名 + 提示用户改域名 | 0.5h |
| 9 | **FRP 状态卡补「已配置入口」**(N 个 Web / M 个 TCP,点击筛选) | 1.0.3 改进 | 替换第 4 张卡「最近应用」或新增一张 | 1h |
| 10 | **服务端行加「重启」按钮** | 1.0.3 改进 | 复用 stop/start 串联或后端 action | 0.5h |
| 11 | **远程端口 0=自动分配(随机 1024–65535)** | 1.0.5 改进 | 后端随机分配 + 前端允许 0;本项目当前强制手填,属新增能力 | 1h |
> 前 4 项是「本项目确实缺少且有实际影响」的;第 5–8 项是上游同期新增/改进的缺口;第 9–11 项属可选增强。
---
## 八、第 5 项详细整理:反代监听端口占用校验
### 8.1 结论:本项目已有等价实现,且判定语义比上游更准
上游 1.0.5 的「新建入口时校验监听端口占用,避免重复端口静默冲突」,在本项目**已经具备**。
§四.F 当时判为「缺口」,是因为用关键词 `占用` / `in use` 做 grep,而本项目代码里的措辞是
「已被其他规则使用」,所以没搜到——这是**关键词误判**,已在上文更正。
更关键的是**判定语义**:上游按「端口」判冲突,本项目按「端口 + 域名」判冲突。后者才是 Nginx 的真实冲突,
前者会把「同一端口挂多个站点」这种最常见的用法误报成冲突。
### 8.2 Nginx 语义:一条规则到底占用哪些「端口 + 域名」
端口占用的判定必须与 `internal/nginx/generate.go` 的生成逻辑对齐,否则会误报或漏报。
`writeRuleBlocks`(`generate.go:120`)按 `rule.PortGroups()`(`proxy.go:101`)逐组生成 server 块:
| 规则字段 | 生成的 server 块 | 监听端口来源 |
| --- | --- | --- |
| 每个 PortGroup | 至少 1 个 | `host.ListenPort`(host 级覆盖)否则 `rule.ListenPort` |
| `https_enabled = true` 且有可用证书 | +1 个(`listen <port> ssl`) | **同组端口**,不额外占端口 |
| `http_redirect = true` 且 `useHTTPS` | +1 个(同端口、无 ssl、`return 301`) | **同组端口**,不额外占端口 |
**关键推论**:一条规则占用的端口集合就是它的 `PortGroups()` 端口集合;
`https_enabled` / `http_redirect` **不会**额外占用 80 / 443,因此不必按这两个开关推算端口。
Nginx 的冲突规则是「**同端口 + 同 `server_name`**」:此时只在 reload 时打一条
`conflicting server name ... ignored` 警告,**第一个 server 块生效、后面的静默失效**——
这正是上游所说的「重复端口静默冲突」。反过来,「同端口 + 不同域名」是**合法的虚拟主机**,不能报错。
### 8.3 本项目已有的校验链(逐跳)
| 跳 | 位置 | 行为 |
| --- | --- | --- |
| 1 | `web/src/views/ProxyView.vue` 保存表单 | `POST/PUT /api/proxy/rules` |
| 2 | `internal/api/proxy_handler.go:54` / `:93` | `svc.Create` / `svc.Update`;出错走 `writeError(400, err.Error())` |
| 3 | `internal/proxy/proxy.go:225` / `:345` / `:367` | `ensureHostsAvailable(hosts, listenPort, excludeRuleID)` |
| 4 | `internal/proxy/proxy.go:499` | `SELECT rule_id FROM proxy_hosts WHERE hostname = ? COLLATE NOCASE AND listen_port = ?` |
| 5 | `internal/proxy/proxy.go:511` | 返回 `前端域名 %s:%d 已被其他规则使用` |
| 6 | `internal/proxy/proxy.go:526` `replaceHosts` | 撞 `UNIQUE(hostname, listen_port)` 时给出同一文案(兜底) |
**三条路径都已覆盖**:新建(`:225`)、改端口(`:345`,仅校验跟随规则端口的 host,host 级独立端口不受影响)、
改域名(`:367`)。`excludeRuleID` 保证编辑自身时不误判。前端 `message.error(error.message)` 直接展示后端文案,
因此**只改后端文案即可,前端无需改动**。
### 8.4 三处真实缺口(按价值排序)
> **实施状态(2026-09-23):缺口 1、2(方案 C)、3 已完成**,落地位置与测试见 §8.7。
**缺口 1 · 冲突提示不含冲突规则名**(上游明确要求「错误提示写明冲突规则名」)
- 现状:只说「前端域名 a.com:80 已被其他规则使用」,用户还得自己去列表里找是哪一条。
- 改法:`ensureHostAvailable` 的查询 JOIN `proxy_rules` 取 `name`:
```sql
SELECT h.rule_id, COALESCE(r.name, '') FROM proxy_hosts h
LEFT JOIN proxy_rules r ON r.id = h.rule_id
WHERE h.hostname = ? COLLATE NOCASE AND h.listen_port = ? LIMIT 1
```
错误串改为 `前端域名 a.com:80 已被规则「名称」占用`;`name` 为空时回落为 `规则 #id`。
- 影响面:`proxy.go` 单函数 + `proxy_hosts_test.go` 中断言该文案的用例需同步。
- 风险低,0.5h。
**缺口 2 · `nginx_mode = custom` 的规则完全不参与校验**(真正的盲区)
- 现状:`Generate`(`generate.go:52`)对 custom 规则走 `writeCustomRuleBlocks`(`generate.go:108`),
把 `ruleCustomContent` 原样写入;其自定义配置里的 `listen` / `server_name` **不写入 `proxy_hosts`**,
因此与已有规则的「同端口 + 同域名」冲突**保存时无任何提示**,只在 Nginx reload 时打一条 warning。
- 改法(先轻后重,二选一):
1. **保存 custom 配置时解析**其 `listen <port>` 与 `server_name`,与 `ListEnabled` 的其它规则做
「同端口 + 同域名」比对,冲突即报错并写明冲突规则名。可复用 `internal/nginx/custom.go` 中
已有的 `listen ` 前缀处理与 `syntax.go` 的行解析思路。
2. **把解析结果落库到 `proxy_hosts`**(`hostname` 取 `server_name`、`listen_port` 取端口),
这样 8.3 的校验链天然覆盖 custom 规则,不必维护两套逻辑(更推荐,但要处理多域名/通配写法)。
- 需处理的写法:`listen 80 default_server;`、`server_name a.com b.com;`、通配 `*.a.com`、
`include` 片段、注释行。
- 风险中,2h。
**缺口 3 · 并发下端口变更的 TOCTOU**(低危)
- 现状:`Update` 的 `:359` 分支裸执行 `UPDATE proxy_hosts SET listen_port = ? ...`,未捕获 UNIQUE;
校验(`:345`)与写入之间存在窗口。单用户场景几乎不会触发。
- 改法:把该 `UPDATE` 的 UNIQUE 错误映射成与 `replaceHosts` 相同的中文文案,或把校验与写入并入同一事务。
- 风险低,0.5h(可随缺口 1 一起做)。
### 8.5 明确**不做**的两件事
1. **不按「同端口」判冲突**:同端口不同域名是合法虚拟主机,按端口报错会让「一个端口挂多个站点」
这种最常见的用法无法保存。
2. **不探测系统层面的端口占用**(`net.Listen` 试占、`ss -ltn`):Havline 自己就是占用方(Nginx 监听 80/443),
且容器与宿主机视角不同,误报率极高。上游所指也是 Nginx 配置内的冲突,而非系统端口。
### 8.6 验收标准
- 「同端口 + 同域名」与其它规则冲突 → 400,且提示中**含冲突规则名**;
- 「同端口 + 不同域名」→ 正常保存,不误报;
- host 级独立端口(`a.com:8081` 与 `b.com:8080`)互不干扰;
- 编辑规则自身(不改域名)→ 不误判为与自身冲突(`excludeRuleID` 已保证,需用例固化);
- custom 模式规则与其 `listen`/`server_name` 冲突 → 保存时报错(缺口 2 完成后)。
### 8.7 实施记录(2026-09-23)
缺口 1、2(方案 C)、3 已落地:
| 缺口 | 落地位置 |
| --- | --- |
| 1 · 冲突提示带规则名 | `internal/proxy/proxy.go` 的 `ensureHostAvailable` + 新增 `endpointOwner`(描述优先「备注名 → 该规则首个域名 → `规则 #id`」);文案统一为「域名 a.com:80 已被规则「官网」使用,请编辑现有规则或更换域名/端口」 |
| 2 · 方案 C 独立占用表 | 迁移 `migrations/024_proxy_custom_endpoints.sql`(`UNIQUE(rule_id, hostname, listen_port)` + `ON DELETE CASCADE`);解析器 `internal/nginx/endpoints.go` 的 `ParseServerEndpoints`;读写 `internal/proxy/custom_endpoints.go`(`ReplaceCustomEndpoints` / `ClearCustomEndpoints` / `EnsureEndpointsAvailable`);`ensureHostAvailable` 现在合并查两张表 |
| 2 · 落库时机 | `internal/service/nginx_config.go`:`SaveRuleNginx` 手动分支(校验占用 → 写文件 → 落库)、切回 `auto` 分支(清理占用)、`RollbackRuleNginx`(回滚后同步占用);删除规则由外键级联清理 |
| 3 · TOCTOU | `Update` 的 `UPDATE proxy_hosts SET listen_port` 捕获 UNIQUE 后按域名复检并给出带规则名的原因;`replaceHosts` 同样改为复检;新增 `isUniqueViolation` 供两处复用 |
**测试**:`internal/nginx/endpoints_test.go`(多 server 块、`[::]` 写法、通配与正则、无 `listen` 默认 80、`include`/变量忽略);
`internal/proxy/custom_endpoints_test.go`(走真实迁移建库:冲突含规则名、排除自身、同域名不同端口放行、custom 占用对表单规则可见、级联清理、清理后释放)。
### 8.8 未覆盖的边界(有意保留)
1. **全局手动 Nginx 片段**(`SaveGlobalNginx` → `writeGlobalCustomInclude`)里的 `server` 块不参与校验:
它没有 rule_id,纳入校验需要额外的归属语义,本轮未做。
2. **前端预检**(`ProxyView.vue` 的 `findHostConflict`)只覆盖 `proxy_hosts`(表单规则的 `rule.hosts`),看不到 custom 占用;
这类冲突由后端保存时报错兜底,错误消息直接展示给用户。
3. 解析器对手写配置里的 `include`、变量、`listen unix:` 一律忽略——宁可漏报也不误报(拦不住保存比误报更可接受)。
-1551
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+78
View File
@@ -3,6 +3,7 @@ package agent
import ( import (
"bytes" "bytes"
"crypto/x509" "crypto/x509"
"encoding/json"
"encoding/pem" "encoding/pem"
"fmt" "fmt"
"io" "io"
@@ -309,6 +310,83 @@ func (s *Server) nginxReload(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, payload) writeJSON(w, http.StatusOK, payload)
} }
// nginxAction 启动 / 停止 / 重启 VPS 上的 Nginx。
//
// 不走 systemctl:宝塔、源码编译等安装方式没有 systemd unit,
// 统一用 nginx 自身信号控制,与 reloadNginx 走同一条路径,行为一致可预期。
func (s *Server) nginxAction(w http.ResponseWriter, r *http.Request) {
var req struct {
Action string `json:"action"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if req.Action != "start" && req.Action != "stop" && req.Action != "restart" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "action 需为 start/stop/restart"})
return
}
nginx := s.nginxPath()
if nginx == "" {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "未找到 nginx 可执行文件:请先安装 Nginx,或用 HAVLINE_AGENT_NGINX_BIN 指定路径"})
return
}
s.mu.Lock()
defer s.mu.Unlock()
if err := runNginxAction(nginx, req.Action); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
// 启停异步生效,留出短暂窗口再重新探测,避免页面读到旧状态
time.Sleep(600 * time.Millisecond)
s.invalidateNginxProbe()
probe := s.probeNginx()
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"action": req.Action,
"nginx_running": probe.Running,
"nginx_ok": probe.OK,
"nginx_error": probe.Error,
})
}
// runNginxAction 执行一次启停;已经是目标状态时按幂等处理,不报错。
func runNginxAction(nginx, action string) error {
running := nginxRunning()
switch action {
case "start":
if running {
return nil
}
return startNginx(nginx)
case "stop":
if !running {
return nil
}
return stopNginx(nginx)
default: // restart
if running {
_ = stopNginx(nginx)
time.Sleep(500 * time.Millisecond)
}
return startNginx(nginx)
}
}
func startNginx(nginx string) error {
if out, err := exec.Command(nginx).CombinedOutput(); err != nil {
return fmt.Errorf("启动 Nginx 失败:%s", strings.TrimSpace(string(out)))
}
return nil
}
func stopNginx(nginx string) error {
if out, err := exec.Command(nginx, "-s", "stop").CombinedOutput(); err != nil {
return fmt.Errorf("停止 Nginx 失败:%s", strings.TrimSpace(string(out)))
}
return nil
}
// frpsConfig 回读 VPS 上的 frps.toml,供应用侧与「即将下发的内容」比对,检出被手工修改的情况 // frpsConfig 回读 VPS 上的 frps.toml,供应用侧与「即将下发的内容」比对,检出被手工修改的情况
func (s *Server) frpsConfig(w http.ResponseWriter, _ *http.Request) { func (s *Server) frpsConfig(w http.ResponseWriter, _ *http.Request) {
path := strings.TrimSpace(s.cfg.FrpsConfigPath) path := strings.TrimSpace(s.cfg.FrpsConfigPath)
+96 -36
View File
@@ -31,37 +31,7 @@ import (
"github.com/havline/havline/internal/sysinfo" "github.com/havline/havline/internal/sysinfo"
) )
// AgentVersion 随每次 agent 行为变更手动递增;status/ping 与安装脚本都会回报,用于确认 VPS 上跑的是哪个版本。 const AgentVersion = "0.15.2"
// 0.5.0:目录判定改用 nginx -T 自省(dump 完整生效配置,按 "# configuration file" 标记
// 找出真正被加载的文件所在目录,零猜测);include 链递归与 HAVLINE_AGENT_NGINX_CONF_DIR 保留为回退。
// 0.9.0:Nginx 可用性显式探测(status 暴露 nginx_ok/nginx_missing)、部署前预检、一键安装 Nginx 端点。
// 0.9.1:status 补充 nginx_running / nginx_version,供页面展示 Nginx 状态卡片。
// 0.9.2:Basic Auth 哈希改为纯 Go apr1(去掉对 VPS openssl 的依赖);安装脚本 token 改用 /dev/urandom。
// 0.10.0:新增 /api/v1/metrics 主机资源采集、/api/v1/logs/nginx 日志读取、/api/v1/frps/config 配置回读。
// 0.11.0:新增 /api/v1/certs 证书清单与 /api/v1/nginx/reload 显式重载(回传校验/重载失败原因)。
// 0.12.0:frps 安装包的 proxy 参数改白名单 + 下载后校验 SHA-256;Basic Auth 自动生成的密码不再写进
// nginx 配置注释(改为只在 PUT 响应里回一次)。
// 0.12.1:frps 启停失败如实回报(不再无条件写「已启动」);HTTP 服务补 ReadTimeout/IdleTimeout。
// 0.12.2:新增域名 vhost 的版本列表 / 对比 / 回滚端点;写配置前留版本,校验失败时恢复上一个可用版本。
// 0.12.3:frps 日志尾巴改为最近 20 行、最新在前,并用 short-iso 时间戳(默认格式不带年份、分不清哪一天)。
// 0.12.4:日志行重写成「2026-09-22 18:43:34 进程: 消息」,去掉 T 分隔、时区偏移与每行重复的主机名。
// 0.12.5:修正 frps 官方校验文件名(frp 一直叫 frp_sha256_checksums.txt,原代码拼的是 frp_<版本>_sha256sums.txt,导致首次安装报 HTTP 404)。
// 0.12.6:nginxPath 探测不到时返回空串(原来回落到配置里的裸名字 "nginx"),
// 修掉「VPS 未安装 Nginx」告警与一键安装按钮永远不出现、以及把它误报成「配置校验未通过」的问题。
// 0.12.7:vhost 目录探测不再要求被加载文件带 .conf 后缀(Debian 的 sites-enabled/default 不带),
// 并在失败信息里带上最后一次的具体原因与常见的显式指定目录。
// 0.12.8:Agent Bearer Token 改为常量时间比较,避免认证路径上的长度/内容时序泄漏。
// 0.12.9:限流状态文件损坏时拒绝覆盖,避免保存单条规则时清空其他域名的限流配置。
// 0.13.0:默认只监听回环地址,非回环监听必须显式允许;状态接口暴露监听范围与传输安全。
// 0.13.1:nginx 版本备份目录改为隐藏目录(.<name>.versions)并自动迁移旧目录,
// 修掉旧 <name>.versions 目录被 include * 当配置文件读取、nginx -t 报 pread() "Is a directory" 的问题。
// 0.13.2:nginx -T 探测失败时先从报错文本解析旧 <name>.versions 目录并迁移后重试,
// 修掉「配置目录还没探测出来、迁移逻辑没机会执行」导致 HTTPS 直连持续失败的问题。
// 0.13.3:HTTPS 反代改用 default_server 并在 reload 后做本机证书指纹自检,
// 证书不匹配时回滚并回报实际指纹与端口占用进程,区分「nginx 未生效」与「公网链路被接管」。
// 0.14.0:HTTPS 端口自动选择(7443/8443/9443)、当前证书信息查询、主机防火墙检测与 ufw/firewalld 显式放行。
// 0.14.1:vhost 模板迁出到 internal/nginxtmpl,统一 TLS、安全头、allow/deny、WebSocket 与限流输出。
const AgentVersion = "0.15.0"
// Config 是 havline-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。 // Config 是 havline-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
type Config struct { type Config struct {
@@ -254,6 +224,7 @@ func agentRoutes(s *Server) []agentRoute {
{http.MethodGet, "/api/v1/frps/config", s.frpsConfig}, {http.MethodGet, "/api/v1/frps/config", s.frpsConfig},
{http.MethodGet, "/api/v1/certs", s.certs}, {http.MethodGet, "/api/v1/certs", s.certs},
{http.MethodPost, "/api/v1/nginx/reload", s.nginxReload}, {http.MethodPost, "/api/v1/nginx/reload", s.nginxReload},
{http.MethodPost, "/api/v1/nginx/action", s.nginxAction},
} }
} }
@@ -336,7 +307,7 @@ func (s *Server) status(w http.ResponseWriter, _ *http.Request) {
"frps_unit_exec": strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")), "frps_unit_exec": strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")),
"frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil, "frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil,
"frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)), "frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)),
"frps_log_tail": strings.TrimSpace(journalTailDesc("frps", frpsLogTailLines)), "frps_log_tail": frpsLogTailDesc(),
"dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)), "dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)),
"routes": s.routeNames(), "routes": s.routeNames(),
"checked_at": time.Now().UTC().Format(time.RFC3339), "checked_at": time.Now().UTC().Format(time.RFC3339),
@@ -460,7 +431,7 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
} }
} }
if writeUnit { if writeUnit {
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) 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\nStandardOutput=append:%s\nStandardError=append:%s\n\n[Install]\nWantedBy=multi-user.target\n", installPath, s.cfg.FrpsConfigPath, frpsSystemdLogPath, frpsSystemdLogPath)
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
@@ -515,22 +486,111 @@ func (s *Server) frpsAction(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "action 需为 start/stop/restart"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "action 需为 start/stop/restart"})
return return
} }
notes := make([]string, 0, 2)
if req.Action == "start" || req.Action == "restart" {
if note := cleanupOrphanFRPS(); note != "" {
notes = append(notes, note)
}
}
if output, err := command.CombinedOutput(); err != nil { if output, err := command.CombinedOutput(); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": fmt.Sprintf("systemctl %s frps 失败: %s", req.Action, strings.TrimSpace(string(output)))}) writeJSON(w, http.StatusBadGateway, map[string]string{"error": fmt.Sprintf("systemctl %s frps 失败: %s", req.Action, strings.TrimSpace(string(output)))})
return return
} }
time.Sleep(800 * time.Millisecond) // 给 frps 启动留出窗口,失败时才能在 is-active/journal 里看到 time.Sleep(800 * time.Millisecond) // 给 frps 启动留出窗口,失败时才能在 is-active/journal 里看到
active := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil active := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil
result := map[string]any{"ok": true, "action": req.Action, "frps_active": active} result := map[string]any{"ok": true, "action": req.Action, "frps_active": active, "notes": notes}
if !active { if !active {
// 启动失败时把 frps 的失败原因带回去,而不是只说“启动成功” // 启动失败时把 frps 的失败原因带回去,而不是只说“启动成功”
journal, _ := exec.Command("journalctl", "-u", "frps", "-n", "8", "--no-pager").CombinedOutput()
result["ok"] = false result["ok"] = false
result["error"] = fmt.Sprintf("systemctl %s 已执行但 frps 未进入运行状态,最近日志:\n%s", req.Action, strings.TrimSpace(string(journal))) result["error"] = s.frpsFailureDetail(req.Action)
} }
writeJSON(w, http.StatusOK, result) writeJSON(w, http.StatusOK, result)
} }
const frpsSystemdLogPath = "/var/log/frps-systemd.log"
// cleanupOrphanFRPS 在 systemd 服务未运行时清理手工启动的 frps,
// 避免旧进程占用 7000/vhost 端口后导致 systemd 启动即退出。
func cleanupOrphanFRPS() string {
if exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil {
return ""
}
if exec.Command("pgrep", "-x", "frps").Run() != nil {
return ""
}
if err := exec.Command("pkill", "-x", "frps").Run(); err != nil {
return "检测到 systemd 之外运行的 frps 进程,但停止失败"
}
time.Sleep(300 * time.Millisecond)
return "检测到 systemd 之外运行的 frps 进程,已先停止旧进程"
}
// frpsFailureDetail 汇总 systemd 状态、输出日志与直接启动结果,
// 避免 journald 不可用时只剩 “No journal files were found”。
func (s *Server) frpsFailureDetail(action string) string {
lines := []string{fmt.Sprintf("systemctl %s 已执行但 frps 未进入运行状态", action)}
if out := strings.TrimSpace(execCommandOutput("systemctl", "show", "frps",
"-p", "ActiveState", "-p", "SubState", "-p", "Result", "-p", "ExecMainCode", "-p", "ExecMainStatus")); out != "" {
lines = append(lines, "systemd 状态:\n"+out)
}
if out := strings.TrimSpace(execCommandOutput("systemctl", "status", "frps", "--no-pager", "-l")); out != "" {
lines = append(lines, "systemctl status:\n"+tailText(out, 2000))
}
if out := strings.TrimSpace(journalTail("frps", frpsLogTailLines)); out != "" && !strings.Contains(out, "No journal files were found") {
lines = append(lines, "journal 日志:\n"+out)
}
if out := strings.TrimSpace(readFRPSSystemdLog()); out != "" {
lines = append(lines, "frps 输出:\n"+out)
}
if out := s.probeFRPSStartup(); out != "" {
lines = append(lines, "直接启动诊断:\n"+out)
}
return strings.Join(lines, "\n\n")
}
// probeFRPSStartup 在无 systemd 日志时直接运行 frps 2 秒:
// 配置错误会立即返回;配置正常时会打印启动成功后被 timeout 结束。
func (s *Server) probeFRPSStartup() string {
if exec.Command("pgrep", "-x", "frps").Run() == nil {
return "已有 frps 进程在运行,跳过直接启动诊断"
}
out, err := exec.Command("timeout", "2s", s.cfg.FrpsBinary, "-c", s.cfg.FrpsConfigPath).CombinedOutput()
text := strings.TrimSpace(string(out))
hint := frpsStartupHint(text)
if err != nil {
if text == "" {
text = err.Error()
}
return tailText(text+hint, 2400)
}
if text == "" {
return "frps 直接运行 2 秒未报错,可能已正常启动后被超时结束;请检查 systemd 单元和端口占用"
}
return tailText(text+hint, 2400)
}
func frpsStartupHint(output string) string {
if !strings.Contains(output, "address already in use") {
return ""
}
return "\n\n建议:80/443 通常由 VPS 上的 Nginx 占用;请把 frps 的 vhostHTTPPort/vhostHTTPSPort 改为 8080/8443,保存后重新下发 frps 配置并重启 frps。"
}
func frpsLogTailDesc() string {
if out := strings.TrimSpace(journalTailDesc("frps", frpsLogTailLines)); out != "" && !strings.Contains(out, "No journal files were found") {
return out
}
return readFRPSSystemdLog()
}
func readFRPSSystemdLog() string {
data, err := os.ReadFile(frpsSystemdLogPath)
if err != nil {
return ""
}
return tailText(string(data), 8000)
}
func downloadToFile(url, path string) error { func downloadToFile(url, path string) error {
client := &http.Client{Timeout: 10 * time.Minute} client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Get(url) resp, err := client.Get(url)
+35 -4
View File
@@ -50,7 +50,9 @@ type frpServerRequest struct {
TLSTrustedCA string `json:"tls_trusted_ca"` TLSTrustedCA string `json:"tls_trusted_ca"`
ClearTLSCertificate bool `json:"clear_tls_certificate"` ClearTLSCertificate bool `json:"clear_tls_certificate"`
ClearTLSKey bool `json:"clear_tls_key"` ClearTLSKey bool `json:"clear_tls_key"`
ClearTLSTrustedCA bool `json:"clear_tls_trusted_ca"` // 二次确认标记:vhost 回源端口填 80/443 时前端必须带上
AllowPrivilegedVhostPorts bool `json:"allow_privileged_vhost_ports"`
ClearTLSTrustedCA bool `json:"clear_tls_trusted_ca"`
} }
type frpProxyRequest struct { type frpProxyRequest struct {
@@ -277,7 +279,12 @@ func (h *FRPMultiHandler) AgentInstall(w http.ResponseWriter, r *http.Request) {
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效") writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
return return
} }
server, err := h.svc.AgentInstall(r.Context(), id) var server frp.Server
if r.URL.Query().Get("trust_new_host_key") == "true" {
server, err = h.svc.AgentInstallTrustHostKey(r.Context(), id)
} else {
server, err = h.svc.AgentInstall(r.Context(), id)
}
if err != nil { if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error()) writeError(r, w, http.StatusBadGateway, err.Error())
return return
@@ -796,6 +803,28 @@ func (h *FRPMultiHandler) AgentNginxReload(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusOK, result) writeJSON(w, http.StatusOK, result)
} }
// AgentNginxAction 启动 / 停止 / 重启 VPS 上的 Nginx(需 agent ≥ 0.15.2)
func (h *FRPMultiHandler) AgentNginxAction(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
return
}
var body struct {
Action string `json:"action"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
result, err := h.svc.AgentNginxAction(r.Context(), id, body.Action)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request) { func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id")) id, err := parseID(r.PathValue("id"))
if err != nil { if err != nil {
@@ -809,11 +838,12 @@ func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request
writeError(r, w, http.StatusBadRequest, "请求格式无效") writeError(r, w, http.StatusBadRequest, "请求格式无效")
return return
} }
if err := h.svc.AgentFRPSAction(r.Context(), id, body.Action); err != nil { result, err := h.svc.AgentFRPSAction(r.Context(), id, body.Action)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error()) writeError(r, w, http.StatusBadGateway, err.Error())
return return
} }
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "action": body.Action}) writeJSON(w, http.StatusOK, result)
} }
func (h *FRPMultiHandler) InstallFRPSOnAgent(w http.ResponseWriter, r *http.Request) { func (h *FRPMultiHandler) InstallFRPSOnAgent(w http.ResponseWriter, r *http.Request) {
@@ -1033,6 +1063,7 @@ func (request frpServerRequest) serverInput() frp.ServerInput {
DashboardPassword: request.DashboardPassword, ClearDashboardPassword: request.ClearDashboardPassword, DashboardPassword: request.DashboardPassword, ClearDashboardPassword: request.ClearDashboardPassword,
TLSCertificate: request.TLSCertificate, TLSKey: request.TLSKey, TLSTrustedCA: request.TLSTrustedCA, TLSCertificate: request.TLSCertificate, TLSKey: request.TLSKey, TLSTrustedCA: request.TLSTrustedCA,
ClearTLSCertificate: request.ClearTLSCertificate, ClearTLSKey: request.ClearTLSKey, ClearTLSTrustedCA: request.ClearTLSTrustedCA, ClearTLSCertificate: request.ClearTLSCertificate, ClearTLSKey: request.ClearTLSKey, ClearTLSTrustedCA: request.ClearTLSTrustedCA,
AllowPrivilegedVhostPorts: request.AllowPrivilegedVhostPorts,
} }
} }
+1
View File
@@ -168,6 +168,7 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
protect("GET /api/frpmulti/servers/{id}/agent/certs", frpHandler.AgentCerts) protect("GET /api/frpmulti/servers/{id}/agent/certs", frpHandler.AgentCerts)
protect("POST /api/frpmulti/servers/{id}/agent/nginx/reload", frpHandler.AgentNginxReload) protect("POST /api/frpmulti/servers/{id}/agent/nginx/reload", frpHandler.AgentNginxReload)
protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx) protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx)
protect("POST /api/frpmulti/servers/{id}/agent/nginx/action", frpHandler.AgentNginxAction)
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf) protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf)
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions", frpHandler.AgentRouteVersions) protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions", frpHandler.AgentRouteVersions)
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions/{name}", frpHandler.AgentRouteVersion) protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions/{name}", frpHandler.AgentRouteVersion)
+56 -2
View File
@@ -6,9 +6,11 @@ import (
"io" "io"
"io/fs" "io/fs"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/havline/havline/internal/acme" "github.com/havline/havline/internal/acme"
@@ -68,6 +70,14 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
} }
logger.Info("会话密钥已就绪", "module", "SYSTEM", "source", secretSource) logger.Info("会话密钥已就绪", "module", "SYSTEM", "source", secretSource)
updaterToken, updaterTokenSource, err := updater.EnsureToken(cfg.UpdaterToken, cfg.UpdaterTokenFile)
if err != nil {
return nil, err
}
if updaterToken != "" {
logger.Info("updater Token 已就绪", "module", "SYSTEM", "source", updaterTokenSource)
}
conn, err := db.Open(cfg.DBPath()) conn, err := db.Open(cfg.DBPath())
if err != nil { if err != nil {
return nil, err return nil, err
@@ -122,7 +132,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
proxySvc.SetExitSync(cloudflareSvc) proxySvc.SetExitSync(cloudflareSvc)
startedAt := time.Now().UTC().Format(time.RFC3339) startedAt := time.Now().UTC().Format(time.RFC3339)
trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc) trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
updaterClient := updater.NewClient(cfg.UpdaterSocket) updaterClient := updater.NewClient(cfg.UpdaterSocket, updaterToken)
handler := api.NewRouter(api.Deps{ handler := api.NewRouter(api.Deps{
Config: cfg, Config: cfg,
@@ -144,6 +154,9 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
StaticFS: staticFS, StaticFS: staticFS,
StartedAt: startedAt, StartedAt: startedAt,
}) })
if prefix := strings.TrimSpace(cfg.GatewayPrefix); prefix != "" {
handler = stripPrefixIfPresent(prefix, handler)
}
server := &http.Server{ server := &http.Server{
Addr: cfg.ListenAddr, Addr: cfg.ListenAddr,
Handler: handler, Handler: handler,
@@ -235,7 +248,32 @@ func (a *App) bootstrapNginx(ctx context.Context, proxySvc *service.ProxyService
} }
func (a *App) Run() error { func (a *App) Run() error {
return a.server.ListenAndServe() if strings.TrimSpace(a.cfg.GatewaySocket) == "" {
return a.server.ListenAndServe()
}
// 先准备两端监听,任何一端失败都不留下半启动的服务。
tcp, err := net.Listen("tcp", a.server.Addr)
if err != nil {
return err
}
defer tcp.Close()
socket := strings.TrimSpace(a.cfg.GatewaySocket)
if err := os.Remove(socket); err != nil && !os.IsNotExist(err) {
return err
}
listener, err := net.Listen("unix", socket)
if err != nil {
return err
}
defer listener.Close()
defer os.Remove(socket)
if err := os.Chmod(socket, 0o660); err != nil {
return err
}
errCh := make(chan error, 2)
go func() { errCh <- a.server.Serve(tcp) }()
go func() { errCh <- a.server.Serve(listener) }()
return <-errCh
} }
func (a *App) Shutdown(ctx context.Context) error { func (a *App) Shutdown(ctx context.Context) error {
@@ -246,6 +284,9 @@ func (a *App) Shutdown(ctx context.Context) error {
if err := a.server.Shutdown(ctx); err != nil { if err := a.server.Shutdown(ctx); err != nil {
return err return err
} }
if socket := strings.TrimSpace(a.cfg.GatewaySocket); socket != "" {
_ = os.Remove(socket)
}
if err := a.nginx.Stop(ctx); err != nil { if err := a.nginx.Stop(ctx); err != nil {
a.logger.Error("nginx stop failed", "module", "NGINX", "error", err.Error()) a.logger.Error("nginx stop failed", "module", "NGINX", "error", err.Error())
} }
@@ -260,6 +301,19 @@ func (a *App) Shutdown(ctx context.Context) error {
return nil return nil
} }
func stripPrefixIfPresent(prefix string, next http.Handler) http.Handler {
prefix = strings.TrimRight(prefix, "/")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == prefix || strings.HasPrefix(r.URL.Path, prefix+"/") {
r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
if r.URL.Path == "" {
r.URL.Path = "/"
}
}
next.ServeHTTP(w, r)
})
}
func ResolveMigrationsDir() (string, error) { func ResolveMigrationsDir() (string, error) {
candidates := []string{ candidates := []string{
"migrations", "migrations",
+25
View File
@@ -0,0 +1,25 @@
package app
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestGatewayPrefix(t *testing.T) {
for _, tc := range []struct{ input, want string }{
{"/app/havline/api/version", "/api/version"},
{"/app/havline", "/"},
{"/api/version", "/api/version"},
{"/app/havline-other/api/version", "/app/havline-other/api/version"},
} {
t.Run(tc.input, func(t *testing.T) {
handler := stripPrefixIfPresent("/app/havline/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != tc.want {
t.Errorf("路径为 %q,期望 %q", r.URL.Path, tc.want)
}
}))
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, tc.input, nil))
})
}
}
+27 -1
View File
@@ -12,6 +12,8 @@ import (
type Config struct { type Config struct {
ListenAddr string ListenAddr string
GatewaySocket string
GatewayPrefix string
DataDir string DataDir string
SessionSecret string SessionSecret string
NginxBin string NginxBin string
@@ -22,6 +24,8 @@ type Config struct {
FrpcBin string FrpcBin string
FrpPIDFile string FrpPIDFile string
UpdaterSocket string UpdaterSocket string
UpdaterToken string
UpdaterTokenFile string
} }
func Load() Config { func Load() Config {
@@ -31,6 +35,8 @@ func Load() Config {
} }
return Config{ return Config{
ListenAddr: envOr("HAVLINE_LISTEN", ":6893"), ListenAddr: envOr("HAVLINE_LISTEN", ":6893"),
GatewaySocket: envOr("HAVLINE_GATEWAY_SOCKET", ""),
GatewayPrefix: envOr("HAVLINE_GATEWAY_PREFIX", ""),
DataDir: dataDir, DataDir: dataDir,
SessionSecret: envOr("HAVLINE_SESSION_SECRET", ""), SessionSecret: envOr("HAVLINE_SESSION_SECRET", ""),
NginxBin: envOr("HAVLINE_NGINX_BIN", "nginx"), NginxBin: envOr("HAVLINE_NGINX_BIN", "nginx"),
@@ -40,7 +46,9 @@ func Load() Config {
NginxDefaultHTTPSPort: envIntOr("HAVLINE_NGINX_HTTPS_PORT", 443), NginxDefaultHTTPSPort: envIntOr("HAVLINE_NGINX_HTTPS_PORT", 443),
FrpcBin: envOr("HAVLINE_FRPC_BIN", "frpc"), FrpcBin: envOr("HAVLINE_FRPC_BIN", "frpc"),
FrpPIDFile: envOr("HAVLINE_FRP_PID", dataDir+"/frp/frpc.pid"), FrpPIDFile: envOr("HAVLINE_FRP_PID", dataDir+"/frp/frpc.pid"),
UpdaterSocket: envOr("HAVLINE_UPDATER_SOCKET", "/run/havline-updater/updater.sock"), UpdaterSocket: updaterSocket(),
UpdaterToken: envOr("HAVLINE_UPDATER_TOKEN", ""),
UpdaterTokenFile: envOr("HAVLINE_UPDATER_TOKEN_FILE", defaultUpdaterTokenFile()),
} }
} }
@@ -104,6 +112,12 @@ func (c Config) SSHKnownHostsPath() string {
return filepath.Join(c.DataDir, "ssh_known_hosts") return filepath.Join(c.DataDir, "ssh_known_hosts")
} }
// SSHKnownHostsPathForServer 为每台 FRP 服务端提供独立的 known_hosts,
// 避免一台 VPS 的主机密钥记录影响另一台服务器的 SSH 连接。
func (c Config) SSHKnownHostsPathForServer(serverID int64) string {
return filepath.Join(c.DataDir, "ssh_known_hosts", fmt.Sprintf("server-%d", serverID))
}
func (c Config) ErrorsDir() string { func (c Config) ErrorsDir() string {
return c.NginxDir() + "/errors" return c.NginxDir() + "/errors"
} }
@@ -232,3 +246,15 @@ func envIntOr(key string, fallback int) int {
} }
return n return n
} }
// 原生应用显式传空值以禁用 Docker 升级;未配置时保留容器默认值。
func updaterSocket() string {
if value, ok := os.LookupEnv("HAVLINE_UPDATER_SOCKET"); ok {
return value
}
return "/run/havline-updater/updater.sock"
}
func defaultUpdaterTokenFile() string {
return "/run/havline-updater/updater.token"
}
+18
View File
@@ -2,6 +2,7 @@ package config
import ( import (
"os" "os"
"path/filepath"
"testing" "testing"
) )
@@ -33,3 +34,20 @@ func TestLoadNginxDefaultPortsFallback(t *testing.T) {
t.Fatalf("ports %d %d", cfg.NginxDefaultHTTPPort, cfg.NginxDefaultHTTPSPort) t.Fatalf("ports %d %d", cfg.NginxDefaultHTTPPort, cfg.NginxDefaultHTTPSPort)
} }
} }
func TestLoadUpdaterTokenFile(t *testing.T) {
t.Setenv("HAVLINE_UPDATER_TOKEN_FILE", "/tmp/havline-updater.token")
cfg := Load()
if cfg.UpdaterTokenFile != "/tmp/havline-updater.token" {
t.Fatalf("updater token file %q", cfg.UpdaterTokenFile)
}
}
func TestSSHKnownHostsPathForServer(t *testing.T) {
cfg := Config{DataDir: "/data"}
want := filepath.Join("/data", "ssh_known_hosts", "server-3")
if got := cfg.SSHKnownHostsPathForServer(3); got != want {
t.Fatalf("per-server known_hosts path %q", got)
}
}
+16
View File
@@ -0,0 +1,16 @@
package config
import "testing"
func TestNativeGatewayConfig(t *testing.T) {
t.Setenv("HAVLINE_GATEWAY_SOCKET", "/tmp/havline-test.sock")
t.Setenv("HAVLINE_GATEWAY_PREFIX", "/app/havline")
t.Setenv("HAVLINE_UPDATER_SOCKET", "")
cfg := Load()
if cfg.GatewaySocket != "/tmp/havline-test.sock" || cfg.GatewayPrefix != "/app/havline" {
t.Fatal("原生网关配置未生效")
}
if cfg.UpdaterSocket != "" {
t.Fatal("原生应用显式空值应禁用 Docker 升级")
}
}
+49 -2
View File
@@ -219,9 +219,15 @@ const agentMinVersionInsights = "0.11.0"
// agentMinVersionRouteVersions 是域名 vhost 版本列表 / 对比 / 回滚端点要求的最低 agent 版本 // agentMinVersionRouteVersions 是域名 vhost 版本列表 / 对比 / 回滚端点要求的最低 agent 版本
const agentMinVersionRouteVersions = "0.12.2" const agentMinVersionRouteVersions = "0.12.2"
// agentMinVersionNginxAction 是 Nginx 启停(start / stop / restart)端点要求的最低 agent 版本
const agentMinVersionNginxAction = "0.15.2"
// ErrAgentEndpointMissing 表示 VPS 上的 agent 版本过旧、没有该端点(HTTP 404/405) // ErrAgentEndpointMissing 表示 VPS 上的 agent 版本过旧、没有该端点(HTTP 404/405)
var ErrAgentEndpointMissing = errors.New("VPS 上的 agent 版本过低,不支持该接口") var ErrAgentEndpointMissing = errors.New("VPS 上的 agent 版本过低,不支持该接口")
// ErrAgentTokenMismatch 表示 Havline 保存的 Token 与 VPS 上 Agent 配置不一致。
var ErrAgentTokenMismatch = errors.New("Agent Token 不匹配,请点击「升级 Agent」重新同步")
// statusError 保留 HTTP 状态码,便于上层区分「端点不存在」与真实错误 // statusError 保留 HTTP 状态码,便于上层区分「端点不存在」与真实错误
type statusError struct { type statusError struct {
code int code int
@@ -232,6 +238,14 @@ func (e *statusError) Error() string {
return fmt.Sprintf("agent 返回 HTTP %d: %s", e.code, e.body) return fmt.Sprintf("agent 返回 HTTP %d: %s", e.code, e.body)
} }
func wrapAgentAuth(err error) error {
var statusErr *statusError
if errors.As(err, &statusErr) && statusErr.code == http.StatusUnauthorized {
return fmt.Errorf("%w: %s", ErrAgentTokenMismatch, statusErr.body)
}
return err
}
// wrapEndpointMissing 把「端点不存在」统一成带升级提示的错误(按端点给出最低版本),其它错误原样返回 // wrapEndpointMissing 把「端点不存在」统一成带升级提示的错误(按端点给出最低版本),其它错误原样返回
func wrapEndpointMissing(err error, minVersion string) error { func wrapEndpointMissing(err error, minVersion string) error {
var statusErr *statusError var statusErr *statusError
@@ -280,6 +294,16 @@ func (c *AgentClient) NginxReload(ctx context.Context) (map[string]any, error) {
return result, nil return result, nil
} }
// NginxAction 启动 / 停止 / 重启 VPS 上的 Nginx(需 agent ≥ 0.15.2)
func (c *AgentClient) NginxAction(ctx context.Context, action string) (map[string]any, error) {
var result map[string]any
payload := map[string]string{"action": action}
if err := c.request(ctx, http.MethodPost, "/api/v1/nginx/action", payload, &result); err != nil {
return nil, wrapEndpointMissing(err, agentMinVersionNginxAction)
}
return result, nil
}
// FRPSConfig 回读 VPS 上的 frps.toml(用于与下发内容比对) // FRPSConfig 回读 VPS 上的 frps.toml(用于与下发内容比对)
func (c *AgentClient) FRPSConfig(ctx context.Context) (map[string]any, error) { func (c *AgentClient) FRPSConfig(ctx context.Context) (map[string]any, error) {
var result map[string]any var result map[string]any
@@ -378,8 +402,31 @@ func (c *AgentClient) DeleteRoute(ctx context.Context, domain string) error {
return c.request(ctx, http.MethodDelete, "/api/v1/routes/"+domain, nil, nil) return c.request(ctx, http.MethodDelete, "/api/v1/routes/"+domain, nil, nil)
} }
func (c *AgentClient) FRPSAction(ctx context.Context, action string) error { func (c *AgentClient) FRPSAction(ctx context.Context, action string) (map[string]any, error) {
return c.request(ctx, http.MethodPost, "/api/v1/frps/action", map[string]string{"action": action}, nil) var result map[string]any
if err := c.request(ctx, http.MethodPost, "/api/v1/frps/action", map[string]string{"action": action}, &result); err != nil {
return nil, err
}
return normalizeFRPSActionResult(result)
}
// normalizeFRPSActionResult 保留 agent 的 ok=false 与日志,避免后端把它误判为成功。
func normalizeFRPSActionResult(result map[string]any) (map[string]any, error) {
if result == nil {
return nil, errors.New("agent 未返回 frps 操作结果")
}
ok, exists := result["ok"].(bool)
if !exists {
return nil, errors.New("agent 未返回 frps 操作结果")
}
if ok {
return result, nil
}
if msg, _ := result["error"].(string); strings.TrimSpace(msg) != "" {
return result, nil
}
result["error"] = "frps 未进入运行状态,请查看 frps 日志"
return result, nil
} }
func (c *AgentClient) InstallFRPS(ctx context.Context, version, proxy string) (map[string]any, error) { func (c *AgentClient) InstallFRPS(ctx context.Context, version, proxy string) (map[string]any, error) {
+36
View File
@@ -0,0 +1,36 @@
package frp
import "testing"
func TestNormalizeFRPSActionResult(t *testing.T) {
if _, err := normalizeFRPSActionResult(nil); err == nil {
t.Fatal("空响应应返回错误")
}
if _, err := normalizeFRPSActionResult(map[string]any{}); err == nil {
t.Fatal("缺少 ok 字段应返回错误")
}
failed, err := normalizeFRPSActionResult(map[string]any{
"ok": false,
"error": "systemctl start 已执行但 frps 未进入运行状态",
})
if err != nil {
t.Fatalf("ok=false 应保留给前端展示,got %v", err)
}
if failed["ok"] != false || failed["error"] == "" {
t.Fatalf("失败结果未保留:%+v", failed)
}
failedWithoutMessage, err := normalizeFRPSActionResult(map[string]any{"ok": false})
if err != nil {
t.Fatalf("ok=false 应补齐默认错误,got %v", err)
}
if failedWithoutMessage["error"] != "frps 未进入运行状态,请查看 frps 日志" {
t.Fatalf("默认错误不正确:%+v", failedWithoutMessage)
}
success, err := normalizeFRPSActionResult(map[string]any{"ok": true, "action": "start"})
if err != nil || success["ok"] != true {
t.Fatalf("成功结果解析失败:result=%+v err=%v", success, err)
}
}
+20 -5
View File
@@ -14,7 +14,8 @@ func (s *Service) AgentMetrics(ctx context.Context, serverID int64) (map[string]
if !ok { if !ok {
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存") return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
} }
return client.Metrics(ctx) result, err := client.Metrics(ctx)
return result, wrapAgentAuth(err)
} }
// AgentNginxLogs 读取 VPS 上 Nginx 的 access / error 日志尾部 // AgentNginxLogs 读取 VPS 上 Nginx 的 access / error 日志尾部
@@ -29,7 +30,8 @@ func (s *Service) AgentNginxLogs(ctx context.Context, serverID int64, kind strin
if lines <= 0 || lines > 2000 { if lines <= 0 || lines > 2000 {
lines = 200 lines = 200
} }
return client.NginxLogs(ctx, kind, lines) result, err := client.NginxLogs(ctx, kind, lines)
return result, wrapAgentAuth(err)
} }
// AgentCerts 列出 VPS 证书目录下的域名与到期时间,供与 Havline 证书库交叉核对 // AgentCerts 列出 VPS 证书目录下的域名与到期时间,供与 Havline 证书库交叉核对
@@ -38,7 +40,8 @@ func (s *Service) AgentCerts(ctx context.Context, serverID int64) (map[string]an
if !ok { if !ok {
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存") return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
} }
return client.Certs(ctx) result, err := client.Certs(ctx)
return result, wrapAgentAuth(err)
} }
// AgentNginxReload 显式校验并重载 VPS 上的 Nginx,返回失败原因 // AgentNginxReload 显式校验并重载 VPS 上的 Nginx,返回失败原因
@@ -47,7 +50,18 @@ func (s *Service) AgentNginxReload(ctx context.Context, serverID int64) (map[str
if !ok { if !ok {
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存") return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
} }
return client.NginxReload(ctx) result, err := client.NginxReload(ctx)
return result, wrapAgentAuth(err)
}
// AgentNginxAction 启动 / 停止 / 重启 VPS 上的 Nginx(需 agent ≥ 0.15.2)
func (s *Service) AgentNginxAction(ctx context.Context, serverID int64, action string) (map[string]any, error) {
client, ok := s.agentClient(ctx, serverID)
if !ok {
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
}
result, err := client.NginxAction(ctx, action)
return result, wrapAgentAuth(err)
} }
// AgentFRPSConfig 回读 VPS 上的 frps.toml,供与「即将下发的内容」比对 // AgentFRPSConfig 回读 VPS 上的 frps.toml,供与「即将下发的内容」比对
@@ -56,5 +70,6 @@ func (s *Service) AgentFRPSConfig(ctx context.Context, serverID int64) (map[stri
if !ok { if !ok {
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存") return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
} }
return client.FRPSConfig(ctx) result, err := client.FRPSConfig(ctx)
return result, wrapAgentAuth(err)
} }
+21
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"strings" "strings"
agentruntime "github.com/havline/havline/internal/agent"
"github.com/havline/havline/internal/frp/agentbin" "github.com/havline/havline/internal/frp/agentbin"
) )
@@ -71,6 +72,13 @@ func (i AgentInstaller) Install(ctx context.Context) (string, error) {
if token == "" { if token == "" {
return "", fmt.Errorf("安装完成但未取回 Token: %s", strings.TrimSpace(output)) return "", fmt.Errorf("安装完成但未取回 Token: %s", strings.TrimSpace(output))
} }
version := extractAgentVersion(output)
if version == "" {
return "", fmt.Errorf("安装完成但未识别 Agent 版本,无法确认升级结果: %s", strings.TrimSpace(output))
}
if version != agentruntime.AgentVersion {
return "", fmt.Errorf("VPS 上安装的 Agent 版本 %s 与主程序内嵌版本 %s 不一致;请重新构建主程序镜像后再执行「升级 Agent」", version, agentruntime.AgentVersion)
}
return token, nil return token, nil
} }
@@ -105,6 +113,19 @@ func extractToken(output string) string {
return "" return ""
} }
func extractAgentVersion(output string) string {
for _, line := range strings.Split(output, "\n") {
if version, found := strings.CutPrefix(strings.TrimSpace(line), "agent version: "); found {
fields := strings.Fields(version)
if len(fields) == 0 {
return ""
}
return strings.TrimPrefix(fields[len(fields)-1], "v")
}
}
return ""
}
func agentInstallScript() ([]byte, error) { func agentInstallScript() ([]byte, error) {
// 与 scripts/agent-install.sh 同一份逻辑内嵌:支持传入二进制路径参数,卸载子命令。 // 与 scripts/agent-install.sh 同一份逻辑内嵌:支持传入二进制路径参数,卸载子命令。
return []byte(`#!/usr/bin/env bash return []byte(`#!/usr/bin/env bash
+19
View File
@@ -0,0 +1,19 @@
package frp
import "testing"
func TestExtractAgentVersion(t *testing.T) {
output := "agent version: 0.15.1\nhavline-agent installed\nToken: abc123"
if got := extractAgentVersion(output); got != "0.15.1" {
t.Fatalf("版本解析失败,got %q", got)
}
if got := extractAgentVersion("agent version: havline-agent 0.15.1\nhavline-agent installed"); got != "0.15.1" {
t.Fatalf("带程序名前缀的版本解析失败,got %q", got)
}
if got := extractAgentVersion("agent version: v0.15.1"); got != "0.15.1" {
t.Fatalf("带 v 前缀的版本解析失败,got %q", got)
}
if got := extractAgentVersion("havline-agent installed\nToken: abc123"); got != "" {
t.Fatalf("无版本行时应返回空串,got %q", got)
}
}
+138 -25
View File
@@ -2,6 +2,7 @@ package frp
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net" "net"
@@ -14,25 +15,31 @@ import (
const agentRemoteAddr = "127.0.0.1:7700" const agentRemoteAddr = "127.0.0.1:7700"
const ( const (
agentTunnelKeepalive = 30 * time.Second agentTunnelKeepalive = 30 * time.Second
agentTunnelBackoffMin = time.Second agentTunnelKeepaliveTimeout = 5 * time.Second
agentTunnelBackoffMax = 60 * time.Second agentTunnelBackoffMin = time.Second
agentTunnelBackoffMax = 60 * time.Second
) )
type agentTunnel struct { type agentTunnel struct {
serverID int64 serverID int64
localPort int localPort int
ssh SSHInstaller remoteAddr string
ssh SSHInstaller
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
connectMu sync.Mutex
mu sync.Mutex mu sync.Mutex
listener net.Listener listener net.Listener
client *ssh.Client client *ssh.Client
state string state string
lastError string lastError string
wg sync.WaitGroup wg sync.WaitGroup
connsMu sync.Mutex
conns map[net.Conn]struct{}
} }
func newAgentTunnel(parent context.Context, serverID int64, localPort int, installer SSHInstaller) *agentTunnel { func newAgentTunnel(parent context.Context, serverID int64, localPort int, installer SSHInstaller) *agentTunnel {
@@ -42,13 +49,15 @@ func newAgentTunnel(parent context.Context, serverID int64, localPort int, insta
func newAgentTunnelWithListener(parent context.Context, serverID int64, localPort int, installer SSHInstaller, listener net.Listener) *agentTunnel { func newAgentTunnelWithListener(parent context.Context, serverID int64, localPort int, installer SSHInstaller, listener net.Listener) *agentTunnel {
ctx, cancel := context.WithCancel(parent) ctx, cancel := context.WithCancel(parent)
return &agentTunnel{ return &agentTunnel{
serverID: serverID, serverID: serverID,
localPort: localPort, localPort: localPort,
ssh: installer, remoteAddr: agentRemoteAddr,
ctx: ctx, ssh: installer,
cancel: cancel, ctx: ctx,
listener: listener, cancel: cancel,
state: "connecting", listener: listener,
state: "connecting",
conns: map[net.Conn]struct{}{},
} }
} }
@@ -61,8 +70,13 @@ func (t *agentTunnel) Start() error {
} }
t.listener = listener t.listener = listener
} }
if t.localPort == 0 {
if addr, ok := t.listener.Addr().(*net.TCPAddr); ok {
t.localPort = addr.Port
}
}
t.mu.Lock() t.mu.Lock()
t.state = "connected" t.state = "connecting"
t.lastError = "" t.lastError = ""
t.mu.Unlock() t.mu.Unlock()
t.wg.Add(2) t.wg.Add(2)
@@ -76,7 +90,7 @@ func (t *agentTunnel) monitorLoop() {
defer t.wg.Done() defer t.wg.Done()
backoff := agentTunnelBackoffMin backoff := agentTunnelBackoffMin
for { for {
_, err := t.ensureClient() err := t.check()
wait := agentTunnelKeepalive wait := agentTunnelKeepalive
if err != nil { if err != nil {
t.setState("reconnecting", err) t.setState("reconnecting", err)
@@ -92,6 +106,24 @@ func (t *agentTunnel) monitorLoop() {
} }
} }
// check 同时验证 SSH 连接和远端 Agent 端口,避免只连上 SSH 就误判隧道可用。
func (t *agentTunnel) check() error {
client, err := t.ensureClient()
if err != nil {
return err
}
if err := t.keepalive(client); err != nil {
t.invalidateClient(client)
return fmt.Errorf("SSH keepalive 失败: %w", err)
}
remote, err := client.Dial("tcp", t.remoteAddr)
if err != nil {
return fmt.Errorf("连接远端 Agent 端口失败: %w", err)
}
_ = remote.Close()
return nil
}
func (t *agentTunnel) sleep(d time.Duration) bool { func (t *agentTunnel) sleep(d time.Duration) bool {
timer := time.NewTimer(d) timer := time.NewTimer(d)
defer timer.Stop() defer timer.Stop()
@@ -126,8 +158,13 @@ func (t *agentTunnel) acceptLoop() {
return return
} }
t.wg.Add(1) t.wg.Add(1)
if !t.trackConn(conn) {
t.wg.Done()
return
}
go func() { go func() {
defer t.wg.Done() defer t.wg.Done()
defer t.untrackConn(conn)
t.handle(conn) t.handle(conn)
}() }()
} }
@@ -138,31 +175,53 @@ func (t *agentTunnel) handle(local net.Conn) {
client, err := t.ensureClient() client, err := t.ensureClient()
if err != nil { if err != nil {
t.setState("reconnecting", err) t.setState("reconnecting", err)
writeTunnelError(local, err)
return return
} }
remote, err := client.Dial("tcp", agentRemoteAddr) remote, err := client.Dial("tcp", t.remoteAddr)
if err != nil { if err != nil {
t.invalidateClient(client) remote, err = t.retryRemoteDial(client, err)
t.setState("reconnecting", err) if err != nil {
return t.setState("reconnecting", err)
writeTunnelError(local, err)
return
}
} }
defer remote.Close() defer remote.Close()
t.setState("connected", nil) t.setState("connected", nil)
proxyTunnel(local, remote) proxyTunnel(local, remote)
} }
// retryRemoteDial 仅在 SSH 连接本身失效时重连,远端 Agent 端口暂时不可用不会关闭共享连接。
func (t *agentTunnel) retryRemoteDial(client *ssh.Client, firstErr error) (net.Conn, error) {
if err := t.keepalive(client); err == nil {
return nil, fmt.Errorf("连接远端 Agent 端口失败: %w", firstErr)
}
t.invalidateClient(client)
reconnected, err := t.ensureClient()
if err != nil {
return nil, fmt.Errorf("SSH 连接失效,重连失败: %w", err)
}
remote, err := reconnected.Dial("tcp", t.remoteAddr)
if err != nil {
return nil, fmt.Errorf("SSH 已重连,但远端 Agent 端口仍不可用: %w", err)
}
return remote, nil
}
func (t *agentTunnel) ensureClient() (*ssh.Client, error) { func (t *agentTunnel) ensureClient() (*ssh.Client, error) {
// 单飞:监控循环和请求处理可能同时触发建连,串行化后只允许一条 SSH 握手。
// 正常请求只复用连接,不在热路径做 keepalive,避免多个接口互相排队。
t.connectMu.Lock()
defer t.connectMu.Unlock()
t.mu.Lock() t.mu.Lock()
if t.client != nil { if t.client != nil {
client := t.client client := t.client
t.mu.Unlock() t.mu.Unlock()
if _, _, err := client.SendRequest("keepalive@openssh.com", true, nil); err == nil { return client, nil
return client, nil
}
t.invalidateClient(client)
} else {
t.mu.Unlock()
} }
t.mu.Unlock()
ctx, cancel := context.WithTimeout(t.ctx, 15*time.Second) ctx, cancel := context.WithTimeout(t.ctx, 15*time.Second)
defer cancel() defer cancel()
client, err := t.ssh.client(ctx) client, err := t.ssh.client(ctx)
@@ -181,6 +240,30 @@ func (t *agentTunnel) ensureClient() (*ssh.Client, error) {
return client, nil return client, nil
} }
func (t *agentTunnel) keepalive(client *ssh.Client) error {
done := make(chan error, 1)
go func() {
_, _, err := client.SendRequest("keepalive@openssh.com", true, nil)
done <- err
}()
select {
case err := <-done:
return err
case <-time.After(agentTunnelKeepaliveTimeout):
return fmt.Errorf("SSH keepalive 超时")
case <-t.ctx.Done():
return t.ctx.Err()
}
}
// writeTunnelError 让本地 HTTP 客户端立即得到明确错误,而不是一直等到 context deadline。
func writeTunnelError(conn net.Conn, err error) {
body, _ := json.Marshal(map[string]string{"error": "SSH 隧道不可用: " + err.Error()})
_, _ = fmt.Fprintf(conn,
"HTTP/1.1 502 Bad Gateway\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s",
len(body), body)
}
func (t *agentTunnel) invalidateClient(client *ssh.Client) { func (t *agentTunnel) invalidateClient(client *ssh.Client) {
t.mu.Lock() t.mu.Lock()
if t.client == client { if t.client == client {
@@ -217,9 +300,39 @@ func (t *agentTunnel) Stop() {
_ = t.client.Close() _ = t.client.Close()
} }
t.mu.Unlock() t.mu.Unlock()
t.closeConns()
t.wg.Wait() t.wg.Wait()
} }
func (t *agentTunnel) trackConn(conn net.Conn) bool {
t.connsMu.Lock()
defer t.connsMu.Unlock()
if t.ctx.Err() != nil {
_ = conn.Close()
return false
}
t.conns[conn] = struct{}{}
return true
}
func (t *agentTunnel) untrackConn(conn net.Conn) {
t.connsMu.Lock()
delete(t.conns, conn)
t.connsMu.Unlock()
}
func (t *agentTunnel) closeConns() {
t.connsMu.Lock()
conns := make([]net.Conn, 0, len(t.conns))
for conn := range t.conns {
conns = append(conns, conn)
}
t.connsMu.Unlock()
for _, conn := range conns {
_ = conn.Close()
}
}
func proxyTunnel(a, b net.Conn) { func proxyTunnel(a, b net.Conn) {
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(2) wg.Add(2)
@@ -0,0 +1,246 @@
package frp
import (
"context"
"crypto/ed25519"
"crypto/rand"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"golang.org/x/crypto/ssh"
)
type directTCPIPPayload struct {
DestAddr string
DestPort uint32
OriginAddr string
OriginPort uint32
}
func TestAgentTunnelReturnsBadGatewayWhenRemoteAgentIsDown(t *testing.T) {
sshAddr := startFakeSSHServer(t)
tunnel := startTestAgentTunnel(t, sshAddr, "127.0.0.1:1")
response, err := (&http.Client{Timeout: 5 * time.Second}).Get(
fmt.Sprintf("http://127.0.0.1:%d/api/v1/status", tunnel.localPort),
)
if err != nil {
t.Fatalf("应返回 HTTP 502,而不是 EOF 或超时: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusBadGateway {
t.Fatalf("状态码 = %d,期望 502", response.StatusCode)
}
body, _ := io.ReadAll(response.Body)
if !strings.Contains(string(body), "SSH 隧道不可用") {
t.Fatalf("响应应包含隧道失败原因,实际为 %q", string(body))
}
}
func TestAgentTunnelForwardsHTTPToRemoteAgent(t *testing.T) {
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer remote.Close()
sshAddr := startFakeSSHServer(t)
tunnel := startTestAgentTunnel(t, sshAddr, strings.TrimPrefix(remote.URL, "http://"))
response, err := (&http.Client{Timeout: 5 * time.Second}).Get(
fmt.Sprintf("http://127.0.0.1:%d/api/v1/status", tunnel.localPort),
)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
body, _ := io.ReadAll(response.Body)
if response.StatusCode != http.StatusOK || string(body) != `{"ok":true}` {
t.Fatalf("隧道转发失败: status=%d body=%q", response.StatusCode, string(body))
}
}
func TestAgentTunnelSerializesConcurrentSSHConnections(t *testing.T) {
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer remote.Close()
sshAddr, connectionCount := startFakeSSHServerWithCounter(t, 300*time.Millisecond)
tunnel := startTestAgentTunnel(t, sshAddr, strings.TrimPrefix(remote.URL, "http://"))
client := &http.Client{Timeout: 5 * time.Second}
var wait sync.WaitGroup
for index := 0; index < 4; index++ {
wait.Add(1)
go func() {
defer wait.Done()
response, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/api/v1/status", tunnel.localPort))
if err == nil {
_, _ = io.Copy(io.Discard, response.Body)
_ = response.Body.Close()
}
}()
}
wait.Wait()
if got := connectionCount.Load(); got != 1 {
t.Fatalf("并发请求建立了 %d 条 SSH 连接,期望单飞复用 1 条", got)
}
}
func TestAgentTunnelDoesNotBlockRequestsOnSlowKeepalive(t *testing.T) {
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer remote.Close()
sshAddr, _ := startFakeSSHServerWithDelays(t, 0, 3*time.Second)
tunnel := startTestAgentTunnel(t, sshAddr, strings.TrimPrefix(remote.URL, "http://"))
started := time.Now()
response, err := (&http.Client{Timeout: 5 * time.Second}).Get(
fmt.Sprintf("http://127.0.0.1:%d/api/v1/status", tunnel.localPort),
)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, response.Body)
if elapsed := time.Since(started); elapsed > time.Second {
t.Fatalf("正常请求被慢 keepalive 阻塞了 %v", elapsed)
}
}
func startTestAgentTunnel(t *testing.T, sshAddr, remoteAddr string) *agentTunnel {
t.Helper()
host, portText, err := net.SplitHostPort(sshAddr)
if err != nil {
t.Fatal(err)
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatal(err)
}
tunnel := newAgentTunnel(context.Background(), 1, 0, SSHInstaller{
Host: host, Port: port, User: "test", Password: "test",
Timeout: 2 * time.Second, KnownHostsPath: filepath.Join(t.TempDir(), "known_hosts"),
})
tunnel.remoteAddr = remoteAddr
if err := tunnel.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(tunnel.Stop)
return tunnel
}
func startFakeSSHServer(t *testing.T) string {
t.Helper()
addr, _ := startFakeSSHServerWithCounter(t, 0)
return addr
}
func startFakeSSHServerWithCounter(t *testing.T, handshakeDelay time.Duration) (string, *atomic.Int32) {
t.Helper()
return startFakeSSHServerWithDelays(t, handshakeDelay, 0)
}
func startFakeSSHServerWithDelays(t *testing.T, handshakeDelay, keepaliveDelay time.Duration) (string, *atomic.Int32) {
t.Helper()
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
signer, err := ssh.NewSignerFromKey(privateKey)
if err != nil {
t.Fatal(err)
}
config := &ssh.ServerConfig{
PasswordCallback: func(ssh.ConnMetadata, []byte) (*ssh.Permissions, error) {
return nil, nil
},
}
config.AddHostKey(signer)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = listener.Close() })
var connectionCount atomic.Int32
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go serveFakeSSHConn(conn, config, &connectionCount, handshakeDelay, keepaliveDelay)
}
}()
return listener.Addr().String(), &connectionCount
}
func serveFakeSSHConn(
conn net.Conn,
config *ssh.ServerConfig,
connectionCount *atomic.Int32,
handshakeDelay, keepaliveDelay time.Duration,
) {
if handshakeDelay > 0 {
time.Sleep(handshakeDelay)
}
connectionCount.Add(1)
serverConn, channels, requests, err := ssh.NewServerConn(conn, config)
if err != nil {
_ = conn.Close()
return
}
defer serverConn.Close()
go func() {
for request := range requests {
if request.Type == "keepalive@openssh.com" && keepaliveDelay > 0 {
time.Sleep(keepaliveDelay)
}
if request.WantReply {
_ = request.Reply(true, nil)
}
}
}()
for newChannel := range channels {
if newChannel.ChannelType() != "direct-tcpip" {
_ = newChannel.Reject(ssh.UnknownChannelType, "unsupported")
continue
}
var payload directTCPIPPayload
if err := ssh.Unmarshal(newChannel.ExtraData(), &payload); err != nil {
_ = newChannel.Reject(ssh.ConnectionFailed, "invalid direct-tcpip payload")
continue
}
target := net.JoinHostPort(payload.DestAddr, strconv.Itoa(int(payload.DestPort)))
remote, err := net.DialTimeout("tcp", target, 2*time.Second)
if err != nil {
_ = newChannel.Reject(ssh.ConnectionFailed, err.Error())
continue
}
channel, channelRequests, err := newChannel.Accept()
if err != nil {
_ = remote.Close()
continue
}
go ssh.DiscardRequests(channelRequests)
go func() {
_, _ = io.Copy(remote, channel)
_ = remote.Close()
}()
go func() {
_, _ = io.Copy(channel, remote)
_ = channel.Close()
}()
}
}

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