Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2856040883 | ||
|
|
0f68101503 | ||
|
|
50b8cc1351 | ||
|
|
d1c498a286 | ||
|
|
9c77d23078 | ||
|
|
fb57b2aef8 | ||
|
|
2a0740a60f | ||
|
|
a7d71752e3 | ||
|
|
9aaefa3b77 |
@@ -13,6 +13,11 @@ dist/
|
||||
.cursor/
|
||||
.playwright-cli/
|
||||
output/
|
||||
# Windows 本地构建产物不入库(历史上误提交过 havline-agent.exe,10MB 级二进制)
|
||||
*.exe
|
||||
# 项目文档(方案 / 手册 / 截图)只保留在本地,不入版本库、不推送
|
||||
# 例外:随仓库分发的示例可在取消忽略后单独跟踪
|
||||
/docs/
|
||||
web/public/111favicon.png
|
||||
web/src/assets/brand/logo00.png
|
||||
# scripts/ 下的本地辅助脚本不入库,但随仓库分发的 agent 安装脚本需要跟踪
|
||||
|
||||
@@ -1,232 +1,338 @@
|
||||
<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>
|
||||
|
||||
<h1 align="center">Havline</h1>
|
||||
|
||||
<p align="center"><strong>让每一台 NAS,都能安全地连接世界。</strong></p>
|
||||
<p align="center"><strong>让 NAS 访问更简单</strong></p>
|
||||
|
||||
<p align="center">
|
||||
面向 <strong>飞牛 fnOS</strong> 等 NAS 环境的轻量公网访问管理控制台<br />
|
||||
反向代理 · DDNS · HTTPS 证书 · 内网穿透 · Cloudflare Tunnel · 一键升级
|
||||
面向 <strong>飞牛 fnOS</strong> 的轻量公网访问管理工具<br />
|
||||
反向代理 · DDNS · HTTPS 证书 · 内网穿透 · Cloudflare Tunnel · 一站式 Web 管理
|
||||
</p>
|
||||
|
||||
<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/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/Docker-amd64_%7C_arm64-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" />
|
||||
<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/Nginx-内置-009639?style=flat-square&logo=nginx&logoColor=white" alt="Nginx" />
|
||||
<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>
|
||||
</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 反向代理,不必手工维护复杂配置;
|
||||
- 自动同步 DDNS,保持域名始终指向当前公网 IP;
|
||||
- 使用 ACME DNS-01 申请与续期 HTTPS 证书;
|
||||
- 通过 FRP 在无公网 IP 时经 VPS 中转服务;
|
||||
- 通过公网 Agent 远程管理 VPS 上的 frps 与 Nginx;
|
||||
- 使用 Cloudflare Tunnel 发布服务,不必开放公网端口;
|
||||
- 在一个仪表盘中查看状态、日志、流量和运行健康度。
|
||||
Havline 基于开源项目 [O96u/Fonu](https://github.com/O96u/Fonu) **1.0.2** 继续开发,保留上游在反向代理、DDNS、证书和内网穿透上的基础能力,并继续扩展公网 Agent、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 增量 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
| **公网服务端** | 通过 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 |
|
||||
|
||||
## 项目结构
|
||||
|
||||
| 路径 | 说明 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
| `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 构建部署编排 |
|
||||
|
||||
## 功能总览
|
||||
## 部署
|
||||
|
||||
| 模块 | 能力 |
|
||||
| --- | --- |
|
||||
| **反向代理** | 多域名、多端口、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 构建、重建、健康检查与失败回滚 |
|
||||
源码仓库:[https://github.com/mrlee233/havline](https://github.com/mrlee233/havline) · [发布版本](https://github.com/mrlee233/havline/releases)
|
||||
|
||||
## 快速开始
|
||||
以下方式均从源码构建,不使用上游 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
|
||||
docker run -d \
|
||||
--name havline \
|
||||
--net=host \
|
||||
-v ./data:/data \
|
||||
--restart unless-stopped \
|
||||
havline:local
|
||||
git clone --branch main https://github.com/mrlee233/havline.git
|
||||
cd havline
|
||||
docker compose -f docker-compose.host.yml up -d --build
|
||||
```
|
||||
|
||||
| 入口 | 默认地址 |
|
||||
| --- | --- |
|
||||
| 管理后台 | `http://<NAS-IP>:6893` |
|
||||
| HTTP 反向代理 | `http://<NAS-IP>:18080` |
|
||||
| HTTPS 反向代理 | `https://<NAS-IP>:9443` |
|
||||
| 入口 | 地址 |
|
||||
| ------------------ | ----------------------- |
|
||||
| 管理后台 | `http://<NAS-IP>:6893` |
|
||||
| HTTP 反代(默认) | `http://<NAS-IP>:18080` |
|
||||
| HTTPS 反代(默认) | `https://<NAS-IP>:9443` |
|
||||
|
||||
### Docker Compose
|
||||
- 默认命名卷挂载到 `/data`,存放数据库、Nginx 配置、证书与日志;可通过 `HAVLINE_DATA_PATH` 选择绑定目录,已有数据须先迁移
|
||||
- Host 编排设置反代默认端口为 `18080`、`9443`,可通过环境变量修改;该编排不包含 updater
|
||||
|
||||
### 其他部署方式
|
||||
|
||||
<details>
|
||||
<summary>本地源码 + Bridge 网络</summary>
|
||||
|
||||
在已克隆的源码目录中执行:
|
||||
|
||||
```bash
|
||||
# 飞牛 fnOS / host 网络
|
||||
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
|
||||
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` 初始密码;
|
||||
2. 打开 `http://<NAS-IP>:6893/login`;
|
||||
3. 登录后立即在 **系统设置 → 管理员账户** 中修改密码。
|
||||
1. 启动后查看容器/运行日志,获取自动生成的 **admin 初始密码**
|
||||
2. 访问 `http://<NAS-IP>:6893/login` 登录
|
||||
3. 在 **设置** 中立即修改管理员密码
|
||||
|
||||
> 完整部署、权限、端口、数据目录、迁移和故障排查请阅读
|
||||
> [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) 与
|
||||
> [`docs/container-deploy-and-data.md`](docs/container-deploy-and-data.md)。
|
||||
### FRP 内网穿透(无公网 IP)
|
||||
|
||||
## 内网穿透与公网服务端
|
||||
适用于无法端口映射、无公网 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
|
||||
用户 → DNS(VPS) → frps → frpc → Havline Nginx → 内网服务
|
||||
**Web 流量路径**:用户 → 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)。
|
||||
|
||||
| 主题 | 文档 |
|
||||
| --- | --- |
|
||||
| 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) |
|
||||
#### 2. Havline 配置 frpc
|
||||
|
||||
## Cloudflare Tunnel
|
||||
在侧栏 **内网穿透** 页面:
|
||||
|
||||
Havline 支持将 Cloudflare Tunnel 作为服务发布出口。反向代理规则可选择本机 Nginx 或
|
||||
Cloudflare 出口,并自动派生隧道 ingress、同步 DNS、检查健康状态和修复云端漂移。
|
||||
1. 添加服务端,填写服务器地址、端口、认证信息和 TLS 选项,与 VPS 上 frps 保持一致。
|
||||
2. 添加 HTTP / HTTPS 代理时填写域名和本机 Nginx 目标端口;Host 编排默认使用 `18080` / `9443`,Bridge 容器内通常为 `80` / `443`,以实际规则为准。
|
||||
3. 添加 TCP / UDP 代理时填写 VPS 远程端口及内网目标地址;开放对应安全组和防火墙端口。
|
||||
4. 在应用配置中下载或激活 FRP 客户端,校验配置后启动,通过服务端诊断和日志检查运行状态。
|
||||
|
||||
| 主题 | 文档 |
|
||||
| --- | --- |
|
||||
| 当前功能 | [`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) |
|
||||
控制连接成功不等于业务已连通,还需检查代理端口、内网目标、DNS 和 Nginx 路由。
|
||||
|
||||
## 一键升级
|
||||
#### 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. 拉取或构建目标版本镜像;
|
||||
2. 重建 `havline` 服务;
|
||||
3. 执行健康检查;
|
||||
4. 失败时尝试回滚到升级前镜像。
|
||||
### 公网 Agent 与 Cloudflare Tunnel
|
||||
|
||||
- **公网 Agent**:在公网 Agent 页面配置 VPS 信息,按需通过 SSH 安装并测试连接,再管理 frps、Nginx 和证书;需要公网反代时部署对应路由。限制 Agent 管理通道访问,不要仅凭业务端口可用判断管理通道安全。
|
||||
- **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_DATA_DIR` | `/data` | 数据目录,包含数据库、Nginx、证书与日志 |
|
||||
| `HAVLINE_SESSION_SECRET` | 空,自动生成 | 会话签名与凭据加密密钥;有数据后不要更换 |
|
||||
| `HAVLINE_NGINX_BIN` | `nginx` | Nginx 可执行文件路径 |
|
||||
| `HAVLINE_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代规则默认监听端口 |
|
||||
| `HAVLINE_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代规则默认监听端口 |
|
||||
| `HAVLINE_FRPC_BIN` | `frpc` | frpc 可执行文件路径 |
|
||||
| `HAVLINE_FRP_PID` | `{DATA_DIR}/frp/frpc.pid` | frpc PID 文件 |
|
||||
| `HAVLINE_UPDATER_SOCKET` | `/run/havline-updater/updater.sock` | 一键升级 sidecar Socket |
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| ----------------------- | ------------------------- | ------------------------------------------ |
|
||||
| `HAVLINE_LISTEN` | `:6893` | 管理后台监听地址 |
|
||||
| `HAVLINE_DATA_DIR` | `/data` | 数据目录(数据库、Nginx 配置、证书、日志) |
|
||||
| `HAVLINE_SESSION_SECRET` | 空,自动生成 | 会话与凭据加密密钥,保存在数据目录;已有数据后不要更换 |
|
||||
| `HAVLINE_INITIAL_ADMIN_PASSWORD` | 空,自动生成 | 首次初始化管理员时使用的密码;不设置时会生成随机密码并输出到首次启动日志 |
|
||||
| `HAVLINE_UPDATER_TOKEN` | 空,自动生成 | 可选覆盖值;未设置时读取共享 Token 文件 |
|
||||
| `HAVLINE_UPDATER_TOKEN_FILE` | `/run/havline-updater/updater.token` | 主程序与 updater sidecar 共享的 Token 文件 |
|
||||
| `HAVLINE_UPDATER_SOCKET` | `/run/havline-updater/updater.sock` | 主程序与 updater sidecar 通信的 Unix Socket |
|
||||
| `HAVLINE_DATA_PATH` | `havline-data` | Compose 挂载来源,可配置宿主机目录 |
|
||||
| `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_ALLOW_REMOTE` | `0` | 是否允许 Agent 监听非回环地址 |
|
||||
| `HAVLINE_AGENT_ALLOW_REMOTE` | `0` | 是否允许 Agent 监听非回环地址;启用前应确认防火墙和 HTTPS/隧道策略 |
|
||||
| `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 |
|
||||
| 数据存储 | `modernc.org/sqlite`,单文件数据库 |
|
||||
| 反向代理 | Nginx,配置动态生成与热重载 |
|
||||
| 证书 | go-acme/lego,ACME DNS-01 |
|
||||
| 内网穿透 | FRP 客户端 / 服务端与自研管理 Agent |
|
||||
| 隧道 | Cloudflare Tunnel / cloudflared |
|
||||
| 前端 | Vue 3、TypeScript、Vite、Naive UI、ECharts |
|
||||
| 部署 | Docker、Docker Compose、Linux systemd |
|
||||
| 架构 | amd64 / arm64 |
|
||||
| `/data/havline.db` | 管理员、会话、代理、DDNS、FRP、Cloudflare、健康事件等 SQLite 数据 |
|
||||
| `/data/session_secret` | 会话签名与敏感凭据加密主密钥;已有数据后不可随意更换 |
|
||||
| `/data/nginx/` | 生成的 Nginx 配置、日志和相关运行文件 |
|
||||
| `/data/certs/` | ACME 证书与私钥 |
|
||||
| `/data/frp/` | frpc 配置、二进制、PID 和运行状态 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
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
|
||||
npm ci
|
||||
npm run dev
|
||||
|
||||
# 构建前端并同步到 Go embed 目录
|
||||
npm run build
|
||||
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 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
|
||||
```
|
||||
|
||||
| 文档 | 说明 |
|
||||
| --- | --- |
|
||||
| [`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) | 全部文档索引 |
|
||||
版本来源为根目录 `VERSION`;Docker 构建注入前后端,本地 `go run` 未注入时显示 `dev`。
|
||||
|
||||
## 开源许可与致谢
|
||||
## 开源协议
|
||||
|
||||
> 开源不是重新发明轮子,而是站在巨人的肩膀上,把一件事继续做得更好。
|
||||
本项目采用 [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) 开源。品牌名称调整不会改变
|
||||
原项目及其衍生版本在 GPL-3.0 下的版权、署名和源代码提供义务。
|
||||
|
||||
软件按“原样”提供,不附带任何担保。请在部署前做好数据备份、访问控制和网络安全配置。
|
||||
问题反馈:[Havline Issues](https://github.com/mrlee233/havline/issues)。请提供版本、部署方式、复现步骤和脱敏日志,不上传密码、Token、私钥或完整备份。
|
||||
|
||||
@@ -6,11 +6,25 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/havline/havline/internal/updater"
|
||||
)
|
||||
|
||||
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{
|
||||
SocketPath: envOr("HAVLINE_UPDATER_SOCKET", "/run/havline-updater/updater.sock"),
|
||||
ProjectDir: envOr("HAVLINE_UPDATE_PROJECT_DIR", "/workspace"),
|
||||
@@ -24,6 +38,7 @@ func main() {
|
||||
ProjectName: envOr("HAVLINE_UPDATE_PROJECT_NAME", ""),
|
||||
SelfImage: envOr("HAVLINE_UPDATE_SELF_IMAGE", ""),
|
||||
DockerSocket: envOr("HAVLINE_UPDATE_DOCKER_SOCK", "/var/run/docker.sock"),
|
||||
Token: token,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# 源码仓库:https://github.com/mrlee233/havline.git(main 分支)
|
||||
# 飞牛 NAS 等已占用 80/443 的环境:使用 host 网络 + 高位默认反代端口
|
||||
# 本地项目:直接用源码构建镜像,不从 Docker Hub 拉取
|
||||
name: havline
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# 用法:
|
||||
# mkdir -p /opt/havline && cd /opt/havline
|
||||
# # 取本文件(按你仓库页面上的「原始文件」链接下载,或 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 # 会话密钥由应用首次启动自动生成并持久化在数据卷里
|
||||
#
|
||||
# 想自己指定会话密钥(可选项):在 compose 文件同层建 .env 写入
|
||||
@@ -20,7 +20,7 @@ name: havline
|
||||
services:
|
||||
havline:
|
||||
build:
|
||||
context: https://jayhub.top/mrlee/Havline.git#main
|
||||
context: https://github.com/mrlee233/havline.git#main
|
||||
args:
|
||||
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}
|
||||
@@ -49,6 +49,9 @@ services:
|
||||
HAVLINE_DATA_DIR: /data
|
||||
HAVLINE_LISTEN: :6893
|
||||
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)
|
||||
# 想自己指定就在同层 .env 里设这个变量;注意它同时是凭据加密密钥,已有数据后不要更换
|
||||
HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-}
|
||||
@@ -58,7 +61,7 @@ services:
|
||||
# 受限升级 sidecar:只有它持有 Docker Socket,主程序通过共享 Unix Socket 触发
|
||||
updater:
|
||||
build:
|
||||
context: https://jayhub.top/mrlee/Havline.git#main
|
||||
context: https://github.com/mrlee233/havline.git#main
|
||||
target: updater
|
||||
args:
|
||||
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
||||
@@ -74,11 +77,13 @@ services:
|
||||
- havline-updater-sock:/run/havline-updater
|
||||
environment:
|
||||
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_COMPOSE_FILE: docker-compose.hub.yml
|
||||
HAVLINE_UPDATE_SERVICE: havline
|
||||
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_APP_URL: http://havline:6893
|
||||
HAVLINE_UPDATE_HOST_DIR: ${HAVLINE_UPDATE_HOST_DIR:-}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# 源码仓库:https://github.com/mrlee233/havline.git(main 分支)
|
||||
# 固定项目名:不改的话,改了目录名/项目名后 compose 会把它当成新项目,
|
||||
# havline-data 会变成新空卷(看起来像「数据丢了」)。
|
||||
name: havline
|
||||
@@ -34,6 +35,9 @@ services:
|
||||
HAVLINE_DATA_DIR: /data
|
||||
HAVLINE_LISTEN: :6893
|
||||
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)
|
||||
# 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换
|
||||
HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-}
|
||||
@@ -58,6 +62,8 @@ services:
|
||||
- havline-updater-sock:/run/havline-updater
|
||||
environment:
|
||||
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_COMPOSE_FILE: docker-compose.yml
|
||||
HAVLINE_UPDATE_SERVICE: havline
|
||||
|
||||
@@ -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 # 删除镜像
|
||||
```
|
||||
@@ -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 侧终止。
|
||||
@@ -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 引用。
|
||||
@@ -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 主动上报。
|
||||
@@ -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 的安全目标。
|
||||
@@ -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`、验证逻辑复用部署链路的探测段。
|
||||
@@ -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 层面互补)。
|
||||
@@ -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 ./...`。
|
||||
@@ -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 ./...` 使本地生效。
|
||||
@@ -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。
|
||||
@@ -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 不支持改名)的结论来自当日检索,实施前建议再核实一次平台当前行为。
|
||||
@@ -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 隧道配置。
|
||||
- 漂移比较。
|
||||
@@ -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 才能在官方能力、参考项目功能与自身产品体系之间取得平衡,而不是停留在「进程开关 + 配置预览」。
|
||||
@@ -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 管理“我的服务如何安全、稳定地访问”。
|
||||
@@ -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 定位筛选后的实施计划,**实施以本文为准**。
|
||||
@@ -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`、密钥相关内容必须过一遍「预览脱敏 / 下发原文」两头检查。
|
||||
@@ -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`。
|
||||
@@ -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`)。
|
||||
@@ -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/>
|
||||
@@ -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 公网访问管理工具的核心能力。
|
||||
@@ -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. **不动后端接口**:本次是纯前端信息架构调整。
|
||||
@@ -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 下发配置保持一致。
|
||||
@@ -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 上没有任何管理界面。
|
||||
@@ -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 与文件系统)。
|
||||
@@ -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 镜像都能被设备拉取
|
||||
```
|
||||
@@ -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 回滚四条路径全部通过。
|
||||
@@ -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>
|
||||
@@ -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 等能力时,才不会把复杂度继续堆进两个超大服务文件。
|
||||
@@ -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` 需重建并重启才能看到本轮前端改动。
|
||||
@@ -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 ./...`。
|
||||
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 201 KiB |
@@ -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`。
|
||||
|
||||
如果「关于项目」页面可见且版本正确,说明镜像构建、容器重建、后端版本注入和前端静态资源更新均已生效。
|
||||
@@ -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:` 一律忽略——宁可漏报也不误报(拦不住保存比误报更可接受)。
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -309,6 +310,83 @@ func (s *Server) nginxReload(w http.ResponseWriter, _ *http.Request) {
|
||||
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,供应用侧与「即将下发的内容」比对,检出被手工修改的情况
|
||||
func (s *Server) frpsConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
path := strings.TrimSpace(s.cfg.FrpsConfigPath)
|
||||
|
||||
@@ -31,37 +31,7 @@ import (
|
||||
"github.com/havline/havline/internal/sysinfo"
|
||||
)
|
||||
|
||||
// AgentVersion 随每次 agent 行为变更手动递增;status/ping 与安装脚本都会回报,用于确认 VPS 上跑的是哪个版本。
|
||||
// 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"
|
||||
const AgentVersion = "0.15.2"
|
||||
|
||||
// Config 是 havline-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
||||
type Config struct {
|
||||
@@ -254,6 +224,7 @@ func agentRoutes(s *Server) []agentRoute {
|
||||
{http.MethodGet, "/api/v1/frps/config", s.frpsConfig},
|
||||
{http.MethodGet, "/api/v1/certs", s.certs},
|
||||
{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_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil,
|
||||
"frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)),
|
||||
"frps_log_tail": strings.TrimSpace(journalTailDesc("frps", frpsLogTailLines)),
|
||||
"frps_log_tail": frpsLogTailDesc(),
|
||||
"dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)),
|
||||
"routes": s.routeNames(),
|
||||
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
||||
@@ -460,7 +431,7 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "写入 frps.service 失败: " + err.Error()})
|
||||
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"})
|
||||
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 {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": fmt.Sprintf("systemctl %s frps 失败: %s", req.Action, strings.TrimSpace(string(output)))})
|
||||
return
|
||||
}
|
||||
time.Sleep(800 * time.Millisecond) // 给 frps 启动留出窗口,失败时才能在 is-active/journal 里看到
|
||||
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 {
|
||||
// 启动失败时把 frps 的失败原因带回去,而不是只说“启动成功”
|
||||
journal, _ := exec.Command("journalctl", "-u", "frps", "-n", "8", "--no-pager").CombinedOutput()
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Get(url)
|
||||
|
||||
@@ -50,7 +50,9 @@ type frpServerRequest struct {
|
||||
TLSTrustedCA string `json:"tls_trusted_ca"`
|
||||
ClearTLSCertificate bool `json:"clear_tls_certificate"`
|
||||
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 {
|
||||
@@ -277,7 +279,12 @@ func (h *FRPMultiHandler) AgentInstall(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
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 {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
@@ -796,6 +803,28 @@ func (h *FRPMultiHandler) AgentNginxReload(w http.ResponseWriter, r *http.Reques
|
||||
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) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
@@ -809,11 +838,12 @@ func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
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())
|
||||
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) {
|
||||
@@ -1033,6 +1063,7 @@ func (request frpServerRequest) serverInput() frp.ServerInput {
|
||||
DashboardPassword: request.DashboardPassword, ClearDashboardPassword: request.ClearDashboardPassword,
|
||||
TLSCertificate: request.TLSCertificate, TLSKey: request.TLSKey, TLSTrustedCA: request.TLSTrustedCA,
|
||||
ClearTLSCertificate: request.ClearTLSCertificate, ClearTLSKey: request.ClearTLSKey, ClearTLSTrustedCA: request.ClearTLSTrustedCA,
|
||||
AllowPrivilegedVhostPorts: request.AllowPrivilegedVhostPorts,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -168,6 +168,7 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/certs", frpHandler.AgentCerts)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/nginx/reload", frpHandler.AgentNginxReload)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/nginx/action", frpHandler.AgentNginxAction)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions", frpHandler.AgentRouteVersions)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions/{name}", frpHandler.AgentRouteVersion)
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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)
|
||||
|
||||
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())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -122,7 +132,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
||||
proxySvc.SetExitSync(cloudflareSvc)
|
||||
startedAt := time.Now().UTC().Format(time.RFC3339)
|
||||
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{
|
||||
Config: cfg,
|
||||
@@ -144,6 +154,9 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
||||
StaticFS: staticFS,
|
||||
StartedAt: startedAt,
|
||||
})
|
||||
if prefix := strings.TrimSpace(cfg.GatewayPrefix); prefix != "" {
|
||||
handler = stripPrefixIfPresent(prefix, handler)
|
||||
}
|
||||
server := &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: handler,
|
||||
@@ -235,7 +248,32 @@ func (a *App) bootstrapNginx(ctx context.Context, proxySvc *service.ProxyService
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -246,6 +284,9 @@ func (a *App) Shutdown(ctx context.Context) error {
|
||||
if err := a.server.Shutdown(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if socket := strings.TrimSpace(a.cfg.GatewaySocket); socket != "" {
|
||||
_ = os.Remove(socket)
|
||||
}
|
||||
if err := a.nginx.Stop(ctx); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
candidates := []string{
|
||||
"migrations",
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
|
||||
type Config struct {
|
||||
ListenAddr string
|
||||
GatewaySocket string
|
||||
GatewayPrefix string
|
||||
DataDir string
|
||||
SessionSecret string
|
||||
NginxBin string
|
||||
@@ -22,6 +24,8 @@ type Config struct {
|
||||
FrpcBin string
|
||||
FrpPIDFile string
|
||||
UpdaterSocket string
|
||||
UpdaterToken string
|
||||
UpdaterTokenFile string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
@@ -31,6 +35,8 @@ func Load() Config {
|
||||
}
|
||||
return Config{
|
||||
ListenAddr: envOr("HAVLINE_LISTEN", ":6893"),
|
||||
GatewaySocket: envOr("HAVLINE_GATEWAY_SOCKET", ""),
|
||||
GatewayPrefix: envOr("HAVLINE_GATEWAY_PREFIX", ""),
|
||||
DataDir: dataDir,
|
||||
SessionSecret: envOr("HAVLINE_SESSION_SECRET", ""),
|
||||
NginxBin: envOr("HAVLINE_NGINX_BIN", "nginx"),
|
||||
@@ -40,7 +46,9 @@ func Load() Config {
|
||||
NginxDefaultHTTPSPort: envIntOr("HAVLINE_NGINX_HTTPS_PORT", 443),
|
||||
FrpcBin: envOr("HAVLINE_FRPC_BIN", "frpc"),
|
||||
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")
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return c.NginxDir() + "/errors"
|
||||
}
|
||||
@@ -232,3 +246,15 @@ func envIntOr(key string, fallback int) int {
|
||||
}
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -33,3 +34,20 @@ func TestLoadNginxDefaultPortsFallback(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 升级")
|
||||
}
|
||||
}
|
||||
@@ -219,9 +219,15 @@ const agentMinVersionInsights = "0.11.0"
|
||||
// agentMinVersionRouteVersions 是域名 vhost 版本列表 / 对比 / 回滚端点要求的最低 agent 版本
|
||||
const agentMinVersionRouteVersions = "0.12.2"
|
||||
|
||||
// agentMinVersionNginxAction 是 Nginx 启停(start / stop / restart)端点要求的最低 agent 版本
|
||||
const agentMinVersionNginxAction = "0.15.2"
|
||||
|
||||
// ErrAgentEndpointMissing 表示 VPS 上的 agent 版本过旧、没有该端点(HTTP 404/405)
|
||||
var ErrAgentEndpointMissing = errors.New("VPS 上的 agent 版本过低,不支持该接口")
|
||||
|
||||
// ErrAgentTokenMismatch 表示 Havline 保存的 Token 与 VPS 上 Agent 配置不一致。
|
||||
var ErrAgentTokenMismatch = errors.New("Agent Token 不匹配,请点击「升级 Agent」重新同步")
|
||||
|
||||
// statusError 保留 HTTP 状态码,便于上层区分「端点不存在」与真实错误
|
||||
type statusError struct {
|
||||
code int
|
||||
@@ -232,6 +238,14 @@ func (e *statusError) Error() string {
|
||||
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 把「端点不存在」统一成带升级提示的错误(按端点给出最低版本),其它错误原样返回
|
||||
func wrapEndpointMissing(err error, minVersion string) error {
|
||||
var statusErr *statusError
|
||||
@@ -280,6 +294,16 @@ func (c *AgentClient) NginxReload(ctx context.Context) (map[string]any, error) {
|
||||
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(用于与下发内容比对)
|
||||
func (c *AgentClient) FRPSConfig(ctx context.Context) (map[string]any, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
func (c *AgentClient) FRPSAction(ctx context.Context, action string) error {
|
||||
return c.request(ctx, http.MethodPost, "/api/v1/frps/action", map[string]string{"action": action}, nil)
|
||||
func (c *AgentClient) FRPSAction(ctx context.Context, action string) (map[string]any, error) {
|
||||
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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,8 @@ func (s *Service) AgentMetrics(ctx context.Context, serverID int64) (map[string]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
|
||||
}
|
||||
return client.Metrics(ctx)
|
||||
result, err := client.Metrics(ctx)
|
||||
return result, wrapAgentAuth(err)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
lines = 200
|
||||
}
|
||||
return client.NginxLogs(ctx, kind, lines)
|
||||
result, err := client.NginxLogs(ctx, kind, lines)
|
||||
return result, wrapAgentAuth(err)
|
||||
}
|
||||
|
||||
// AgentCerts 列出 VPS 证书目录下的域名与到期时间,供与 Havline 证书库交叉核对
|
||||
@@ -38,7 +40,8 @@ func (s *Service) AgentCerts(ctx context.Context, serverID int64) (map[string]an
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
|
||||
}
|
||||
return client.Certs(ctx)
|
||||
result, err := client.Certs(ctx)
|
||||
return result, wrapAgentAuth(err)
|
||||
}
|
||||
|
||||
// AgentNginxReload 显式校验并重载 VPS 上的 Nginx,返回失败原因
|
||||
@@ -47,7 +50,18 @@ func (s *Service) AgentNginxReload(ctx context.Context, serverID int64) (map[str
|
||||
if !ok {
|
||||
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,供与「即将下发的内容」比对
|
||||
@@ -56,5 +70,6 @@ func (s *Service) AgentFRPSConfig(ctx context.Context, serverID int64) (map[stri
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
|
||||
}
|
||||
return client.FRPSConfig(ctx)
|
||||
result, err := client.FRPSConfig(ctx)
|
||||
return result, wrapAgentAuth(err)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentruntime "github.com/havline/havline/internal/agent"
|
||||
"github.com/havline/havline/internal/frp/agentbin"
|
||||
)
|
||||
|
||||
@@ -71,6 +72,13 @@ func (i AgentInstaller) Install(ctx context.Context) (string, error) {
|
||||
if token == "" {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -105,6 +113,19 @@ func extractToken(output string) string {
|
||||
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) {
|
||||
// 与 scripts/agent-install.sh 同一份逻辑内嵌:支持传入二进制路径参数,卸载子命令。
|
||||
return []byte(`#!/usr/bin/env bash
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -14,25 +15,31 @@ import (
|
||||
const agentRemoteAddr = "127.0.0.1:7700"
|
||||
|
||||
const (
|
||||
agentTunnelKeepalive = 30 * time.Second
|
||||
agentTunnelBackoffMin = time.Second
|
||||
agentTunnelBackoffMax = 60 * time.Second
|
||||
agentTunnelKeepalive = 30 * time.Second
|
||||
agentTunnelKeepaliveTimeout = 5 * time.Second
|
||||
agentTunnelBackoffMin = time.Second
|
||||
agentTunnelBackoffMax = 60 * time.Second
|
||||
)
|
||||
|
||||
type agentTunnel struct {
|
||||
serverID int64
|
||||
localPort int
|
||||
ssh SSHInstaller
|
||||
serverID int64
|
||||
localPort int
|
||||
remoteAddr string
|
||||
ssh SSHInstaller
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
connectMu sync.Mutex
|
||||
mu sync.Mutex
|
||||
listener net.Listener
|
||||
client *ssh.Client
|
||||
state string
|
||||
lastError string
|
||||
wg sync.WaitGroup
|
||||
|
||||
connsMu sync.Mutex
|
||||
conns map[net.Conn]struct{}
|
||||
}
|
||||
|
||||
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 {
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
return &agentTunnel{
|
||||
serverID: serverID,
|
||||
localPort: localPort,
|
||||
ssh: installer,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
listener: listener,
|
||||
state: "connecting",
|
||||
serverID: serverID,
|
||||
localPort: localPort,
|
||||
remoteAddr: agentRemoteAddr,
|
||||
ssh: installer,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
listener: listener,
|
||||
state: "connecting",
|
||||
conns: map[net.Conn]struct{}{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +70,13 @@ func (t *agentTunnel) Start() error {
|
||||
}
|
||||
t.listener = listener
|
||||
}
|
||||
if t.localPort == 0 {
|
||||
if addr, ok := t.listener.Addr().(*net.TCPAddr); ok {
|
||||
t.localPort = addr.Port
|
||||
}
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.state = "connected"
|
||||
t.state = "connecting"
|
||||
t.lastError = ""
|
||||
t.mu.Unlock()
|
||||
t.wg.Add(2)
|
||||
@@ -76,7 +90,7 @@ func (t *agentTunnel) monitorLoop() {
|
||||
defer t.wg.Done()
|
||||
backoff := agentTunnelBackoffMin
|
||||
for {
|
||||
_, err := t.ensureClient()
|
||||
err := t.check()
|
||||
wait := agentTunnelKeepalive
|
||||
if err != nil {
|
||||
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 {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
@@ -126,8 +158,13 @@ func (t *agentTunnel) acceptLoop() {
|
||||
return
|
||||
}
|
||||
t.wg.Add(1)
|
||||
if !t.trackConn(conn) {
|
||||
t.wg.Done()
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer t.wg.Done()
|
||||
defer t.untrackConn(conn)
|
||||
t.handle(conn)
|
||||
}()
|
||||
}
|
||||
@@ -138,31 +175,53 @@ func (t *agentTunnel) handle(local net.Conn) {
|
||||
client, err := t.ensureClient()
|
||||
if err != nil {
|
||||
t.setState("reconnecting", err)
|
||||
writeTunnelError(local, err)
|
||||
return
|
||||
}
|
||||
remote, err := client.Dial("tcp", agentRemoteAddr)
|
||||
remote, err := client.Dial("tcp", t.remoteAddr)
|
||||
if err != nil {
|
||||
t.invalidateClient(client)
|
||||
t.setState("reconnecting", err)
|
||||
return
|
||||
remote, err = t.retryRemoteDial(client, err)
|
||||
if err != nil {
|
||||
t.setState("reconnecting", err)
|
||||
writeTunnelError(local, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
defer remote.Close()
|
||||
t.setState("connected", nil)
|
||||
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) {
|
||||
// 单飞:监控循环和请求处理可能同时触发建连,串行化后只允许一条 SSH 握手。
|
||||
// 正常请求只复用连接,不在热路径做 keepalive,避免多个接口互相排队。
|
||||
t.connectMu.Lock()
|
||||
defer t.connectMu.Unlock()
|
||||
|
||||
t.mu.Lock()
|
||||
if t.client != nil {
|
||||
client := t.client
|
||||
t.mu.Unlock()
|
||||
if _, _, err := client.SendRequest("keepalive@openssh.com", true, nil); err == nil {
|
||||
return client, nil
|
||||
}
|
||||
t.invalidateClient(client)
|
||||
} else {
|
||||
t.mu.Unlock()
|
||||
return client, nil
|
||||
}
|
||||
t.mu.Unlock()
|
||||
ctx, cancel := context.WithTimeout(t.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
client, err := t.ssh.client(ctx)
|
||||
@@ -181,6 +240,30 @@ func (t *agentTunnel) ensureClient() (*ssh.Client, error) {
|
||||
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) {
|
||||
t.mu.Lock()
|
||||
if t.client == client {
|
||||
@@ -217,9 +300,39 @@ func (t *agentTunnel) Stop() {
|
||||
_ = t.client.Close()
|
||||
}
|
||||
t.mu.Unlock()
|
||||
t.closeConns()
|
||||
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) {
|
||||
var wg sync.WaitGroup
|
||||
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()
|
||||
}()
|
||||
}
|
||||
}
|
||||