Compare commits

3 Commits
Author SHA1 Message Date
mrlee 2a0740a60f chore(release): 发布 v1.9.2
CI / verify (push) Canceled after 0s
Docker Release / build-and-push (amd64, linux/amd64) (push) Canceled after 0s
Docker Release / build-and-push (arm64, linux/arm64) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Docker Release / create-manifest (push) Canceled after 0s
- 「公网服务端」新增 Nginx 启停控制(Agent 0.15.2,停止带二次确认)
- frps 启动失败不再误报成功,并增强启动诊断
- frps vhost 端口禁止 80/443,避免与 VPS 上 Nginx 冲突
- 旧服务端配置的 tcp_mux / login_fail_exit 默认值补齐
- 新增安全审查、页面布局方案与场景操作文档
2026-10-07 19:26:02 +08:00
mrlee a7d71752e3 feat(updater): generate shared token without init container 2026-10-05 19:11:05 +08:00
mrlee 9aaefa3b77 fix: surface frps startup failures and avoid nginx port conflicts 2026-10-05 13:07:56 +08:00
68 changed files with 5699 additions and 521 deletions
+22 -1
View File
@@ -2,7 +2,28 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [Unreleased] ## [v1.9.2] - 2026-10-07
### 新增
- **updater Token 零配置且无额外容器**:主程序首次启动时在共享卷中自动生成 `/run/havline-updater/updater.token`,sidecar 等待并读取同一文件;主服务与 sidecar 支持 `HAVLINE_UPDATER_TOKEN_FILE`,旧部署仍可用 `HAVLINE_UPDATER_TOKEN` 覆盖
- **Nginx 启停控制**:「公网服务端」页的 Nginx 卡片新增启动 / 停止按钮,停止前有二次确认并提示影响范围;Agent 提升到 `0.15.2`,新增 `POST /api/v1/nginx/action` 端点(start / stop / restart),走 nginx 自身信号而非 systemctl,兼容宝塔、源码编译等没有 systemd unit 的安装方式;两个内嵌 Agent 二进制已重编
### 修复
- **frps 启动失败不再误报成功**:主程序现在保留 Agent 返回的 `ok=false` 与错误内容,`systemctl start` 后 frps 未进入运行状态时会如实返回失败,不再固定提示「启动成功」
- **frps 启动诊断增强**:Agent 版本提升到 `0.15.1`,启动失败时汇总 systemd 状态、`systemctl status`、journal、`/var/log/frps-systemd.log`,并在 journald 不可用时直接运行 `frps -c /etc/frp/frps.toml` 2 秒捕获真实错误;两个内嵌 Agent 二进制已重编
- **frps 与 Nginx 端口冲突防呆**:frps `vhostHTTPPort` / `vhostHTTPSPort` 禁止使用 `80/443`,服务端保存和 `frps.toml` 生成阶段都会提示改用 `8080/8443`,避免 VPS 上 Nginx 已占用 80/443 时 frps 启动即退出
- **frps 启动前清理旧进程**:systemd 服务未运行但检测到 systemd 之外的手工 `frps` 进程时,启动/重启前先停止旧进程,避免端口占用导致 systemd 反复 auto-restart
- **公网反代隧道提示修正**:frps 管理接口可访问但没有任何注册代理时,不再显示 `frps 已注册代理: ;`,改为提示 frpc 未登录或尚未注册该域名
- **旧服务端配置的布尔默认值补齐**:`config_json` 缺少 `tcp_mux` / `login_fail_exit` 键时(016 迁移给已有服务端补的默认值是 `{}`)按表单默认值补成 `true`,不再静默生成 `transport.tcpMux = false`(每条新连接都要重建 TCP+TLS,首次建连明显变慢)与 `loginFailExit = false`;显式配置为 `false` 的仍然保留
### 测试
- 新增 Agent 操作结果解析、Agent 版本解析、frps vhost 端口冲突校验和隧道提示文案测试
- 新增 updater Token 环境变量优先级、共享文件生成与解析、等待超时、缺失文件和空文件测试
- 新增服务端选项缺省 `tcp_mux` / `login_fail_exit` 键时的默认值兜底测试
- `go test ./...`、`npm run typecheck` 通过
### 变更 ### 变更
+317 -152
View File
@@ -1,232 +1,397 @@
<p align="center"> <p align="center">
<img src="web/src/assets/brand/havline-logo.svg" alt="Havline Logo" width="112" /> <img src="web/src/assets/brand/havline-logo.svg" alt="Havline Logo" width="96" />
</p> </p>
<h1 align="center">Havline</h1> <h1 align="center">Havline</h1>
<p align="center"><strong>让每一台 NAS,都能安全地连接世界。</strong></p> <p align="center"><strong>让 NAS 访问更简单</strong></p>
<p align="center"> <p align="center">
面向 <strong>飞牛 fnOS</strong> 等 NAS 环境的轻量公网访问管理控制台<br /> 面向 <strong>飞牛 fnOS</strong> 的轻量公网访问管理工具<br />
反向代理 · DDNS · HTTPS 证书 · 内网穿透 · Cloudflare Tunnel · 一键升级 反向代理 · DDNS · HTTPS 证书 · 内网穿透 · Cloudflare Tunnel · 一站式 Web 管理
</p> </p>
<p align="center"> <p align="center">
<a href="https://github.com/mrlee233/havline/releases"><img src="https://img.shields.io/badge/version-1.9.0-22c55e?style=flat-square" alt="发布版本" /></a>
<img src="https://img.shields.io/badge/目标平台-飞牛_fnOS-22c55e?style=flat-square" alt="飞牛 fnOS" /> <img src="https://img.shields.io/badge/目标平台-飞牛_fnOS-22c55e?style=flat-square" alt="飞牛 fnOS" />
<img src="https://img.shields.io/badge/Go-1.23-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go 1.23" /> <img src="https://img.shields.io/badge/其他环境-未测试-94a3b8?style=flat-square" alt="其他环境未测试" />
<img src="https://img.shields.io/badge/Go-1.23-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go" />
<img src="https://img.shields.io/badge/Vue-3-4FC08D?style=flat-square&logo=vuedotjs&logoColor=white" alt="Vue 3" /> <img src="https://img.shields.io/badge/Vue-3-4FC08D?style=flat-square&logo=vuedotjs&logoColor=white" alt="Vue 3" />
<img src="https://img.shields.io/badge/Docker-amd64_%7C_arm64-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /> <img src="https://img.shields.io/badge/Nginx-内置-009639?style=flat-square&logo=nginx&logoColor=white" alt="Nginx" />
<a href="https://www.gnu.org/licenses/gpl-3.0.html"> <a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://img.shields.io/badge/license-GPL--3.0-blue?style=flat-square" alt="GPL-3.0" /></a>
<img src="https://img.shields.io/badge/license-GPL--3.0-blue?style=flat-square" alt="GPL-3.0" />
</a>
</p> </p>
> **Havline 是基于开源项目 [O96u/Fonu](https://github.com/O96u/Fonu) 1.0.2 版本进行二次开发的项目。**
> 原项目已经完成了反向代理、DDNS、证书和内网穿透等核心能力。Havline 在此基础上进行品牌重塑、
> 功能迭代、界面调整、部署流程整理和文档重构,并继续遵循 GPL-3.0 开源协议。
--- ---
## 项目简介 ## 简介
NAS 的价值不只在存储,更在于把文件、媒体、自动化工具和家庭服务安全地带到需要它们的地方。 **Havline** 是一款主要为 **飞牛 fnOS** 编写的反代与管理工具,帮助你在 NAS 上快速搭建稳定的公网访问能力:通过 Web 界面管理 Nginx 反向代理、自动同步 DDNS、申请与续期 Let's Encrypt 证书,并在一个仪表盘里查看服务状态、访问日志与流量统计。
**Havline** 把 NAS 用户最常用、也最容易出错的公网访问能力收进一个 Web 控制台: 无需手写 Nginx 配置,也无需在多个工具之间切换。当前开发与测试环境以飞牛 fnOS 为主,**群晖、威联通、自建 Linux 等其他环境尚未充分测试**,部署前请自行验证。
- 管理 Nginx 反向代理,不必手工维护复杂配置; Havline 基于开源项目 [O96u/Fonu](https://github.com/O96u/Fonu) **1.0.2** 继续开发,保留上游在反向代理、DDNS、证书和内网穿透上的基础能力,并继续扩展公网 Agent、Cloudflare Tunnel、一键升级和飞牛应用交付。
- 自动同步 DDNS,保持域名始终指向当前公网 IP;
- 使用 ACME DNS-01 申请与续期 HTTPS 证书;
- 通过 FRP 在无公网 IP 时经 VPS 中转服务;
- 通过公网 Agent 远程管理 VPS 上的 frps 与 Nginx;
- 使用 Cloudflare Tunnel 发布服务,不必开放公网端口;
- 在一个仪表盘中查看状态、日志、流量和运行健康度。
当前开发与测试环境以 **飞牛 fnOS** 为主。群晖、威联通、自建 Linux 等其他环境尚未充分测试, ## 功能特性
部署前请自行验证端口、权限和数据目录。
## 界面预览 | 模块 | 说明 |
| -------------- | ---------------------------------------------------------------------------------------------- |
| **反向代理** | 多域名、多端口、HTTP/HTTPS、自动重定向;实时访问日志、流量统计、当前连接 IP |
| **DDNS** | 支持 Cloudflare、DNSPod、阿里云、腾讯云、火山引擎 DNS;单任务多根域名,自动同步公网 IP |
| **HTTPS 证书** | ACME 自动申请与续期;证书/私钥/ZIP 下载;申请进度实时日志 |
| **仪表盘** | 公网 IP、域名、证书、服务状态一览;请求趋势与运行健康度 |
| **日志中心** | 系统日志、Nginx 访问日志、Nginx 错误日志;分页筛选与自动刷新 |
| **内网穿透** | FRP 多服务端管理,支持 TCP/UDP/HTTP/HTTPS 等代理类型;客户端按需下载、配置与运行诊断 |
| **公网 Agent** | 通过 SSH 安装和维护 VPS Agent,远程管理 frps、Nginx、证书和路由 |
| **Cloudflare Tunnel** | cloudflared 按需下载、隧道与路由管理、DNS 同步、健康检查和漂移修复 |
| **一键升级** | 含 updater 的 Compose 部署支持镜像构建、主服务重建和版本健康检查 |
| **系统设置** | 常规 / 通知 / 安全 / 高级分栏;邮件/Webhook/Telegram 告警、备份与恢复 |
| **其他** | 深色模式、配置导出/导入、版本更新提示 |
| 仪表盘 | 反向代理 | ## 相比上游 Fonu 的主要增量
| 方向 | Havline 增量 |
| --- | --- | | --- | --- |
| ![仪表盘](docs/screenshots/dashboard.png) | ![反向代理](docs/screenshots/proxy.png) | | **公网服务端** | 通过 SSH 安装、升级和维护 VPS 上的 `havline-agent`,远程管理 frps、Nginx、证书和公网反代 |
| **FRP 管理** | 多服务端管理、FRP 客户端按需下载、代理规则校验、运行诊断、路由健康与漂移检测 |
| **Cloudflare Tunnel** | cloudflared 按需下载、Account/API Token 预检、隧道与路由管理、DNS 同步、健康巡检和漂移修复 |
| **一键升级** | Compose + updater sidecar,支持构建/重建/健康检查,并在部分失败场景下尝试回滚 |
| **飞牛交付** | 提供 Docker 离线镜像包与 Native 应用两种形态,支持统一网关 `/app/havline` 接入 |
| **文档体系** | 增加图文操作手册、部署/升级/迁移文档、飞牛打包方案和 Cloudflare/FRP 专题方案 |
| DDNS | 证书管理 | ## 界面展示
<table>
<tr>
<td align="center" width="50%">
<b>仪表盘</b><br>
<a href="docs/screenshots/guide-dashboard.png"><img src="docs/screenshots/guide-dashboard.png" alt="仪表盘" width="100%" /></a>
</td>
<td align="center" width="50%">
<b>反向代理</b><br>
<a href="docs/screenshots/guide-proxy.png"><img src="docs/screenshots/guide-proxy.png" alt="反向代理" width="100%" /></a>
</td>
</tr>
<tr>
<td align="center">
<b>新建反向代理规则</b><br>
<a href="docs/screenshots/guide-dialog-proxy-create.png"><img src="docs/screenshots/guide-dialog-proxy-create.png" alt="新建反向代理规则" width="100%" /></a>
</td>
<td align="center">
<b>DDNS 添加任务</b><br>
<a href="docs/screenshots/guide-dialog-ddns-add.png"><img src="docs/screenshots/guide-dialog-ddns-add.png" alt="DDNS 添加任务" width="100%" /></a>
</td>
</tr>
<tr>
<td align="center">
<b>证书申请</b><br>
<a href="docs/screenshots/guide-dialog-cert-apply.png"><img src="docs/screenshots/guide-dialog-cert-apply.png" alt="证书申请" width="100%" /></a>
</td>
<td align="center">
<b>内网穿透</b><br>
<a href="docs/screenshots/guide-frp.png"><img src="docs/screenshots/guide-frp.png" alt="内网穿透" width="100%" /></a>
</td>
</tr>
<tr>
<td align="center">
<b>公网服务端</b><br>
<a href="docs/screenshots/guide-agent.png"><img src="docs/screenshots/guide-agent.png" alt="公网服务端" width="100%" /></a>
</td>
<td align="center">
<b>Cloudflare 隧道</b><br>
<a href="docs/screenshots/guide-cloudflare.png"><img src="docs/screenshots/guide-cloudflare.png" alt="Cloudflare 隧道" width="100%" /></a>
</td>
</tr>
<tr>
<td align="center">
<b>Cloudflare 新建隧道</b><br>
<a href="docs/screenshots/guide-dialog-cf-create.png"><img src="docs/screenshots/guide-dialog-cf-create.png" alt="Cloudflare 新建隧道" width="100%" /></a>
</td>
<td align="center">
<b>系统设置</b><br>
<a href="docs/screenshots/guide-settings.png"><img src="docs/screenshots/guide-settings.png" alt="系统设置" width="100%" /></a>
</td>
</tr>
</table>
更多页面、弹窗和逐步操作说明见 [`docs/user-guide.md`](docs/user-guide.md)。
## 技术栈
| 层级 | 技术 |
| ------------ | ---------------------------------------------------------------------- |
| **后端** | Go 1.23、标准库 HTTP、SQLite(modernc.org/sqlite) |
| **反向代理** | 内置 Nginx(动态生成配置、热重载) |
| **证书** | go-acme/lego(ACME DNS-01) |
| **DDNS** | Cloudflare / DNSPod / 阿里云 / 腾讯云 / 火山引擎 DNS API |
| **内网穿透** | FRP 客户端按需下载;多服务端和代理规则管理,支持公网 Agent |
| **隧道** | Cloudflare Tunnel / cloudflared |
| **前端** | Vue 3、TypeScript、Vite、Naive UI、ECharts |
| **部署** | Docker 多架构镜像(amd64 / arm64)、GitHub Actions CI |
## 项目结构
| 路径 | 说明 |
| --- | --- | | --- | --- |
| ![DDNS](docs/screenshots/ddns.png) | ![证书管理](docs/screenshots/certificates.png) | | `cmd/havline` | 主程序入口,提供 Web 管理后台、API、调度和内置 Nginx/FRP 管理 |
| `cmd/havline-agent` | 公网 VPS 上的 Agent,接收主程序下发并管理 frps、Nginx、证书和路由 |
| `cmd/havline-updater` | 一键升级 sidecar,仅它挂载 Docker Socket,并通过 Unix Socket 与主程序通信 |
| `internal/api` | HTTP 路由、鉴权中间件、请求处理器和 API 响应封装 |
| `internal/app` | 应用启动、服务装配、统一网关和运行生命周期 |
| `internal/nginx`、`internal/nginxtmpl` | Nginx 配置生成、共享模板、安全/限流/geo/TLS 片段和热重载 |
| `internal/proxy`、`internal/service` | 反向代理规则、证书绑定、访问策略和 Nginx 配置编排 |
| `internal/frp` | FRP 多服务端、规则、运行进程、Agent 安装、诊断、健康和漂移检测 |
| `internal/cloudflared` | Cloudflare Tunnel 设置、隧道、路由、DNS、健康事件和漂移修复 |
| `internal/agent` | Agent 服务端实现,包括鉴权、监听策略、防火墙提示、Nginx/frps 操作 |
| `internal/updater` | updater 服务端、客户端、执行器和远端版本读取 |
| `internal/ddns`、`internal/certificate`、`internal/acme` | DDNS 服务商、证书存储、导入和 ACME 申请续期 |
| `internal/backup`、`internal/notify`、`internal/monitor`、`internal/metrics` | 备份恢复、通知渠道、监控采集和指标导出 |
| `web/src` | Vue 3 前端,包含 API 客户端、组合式函数、组件、路由、样式和页面视图 |
| `migrations` | SQLite 数据库迁移 |
| `docs` | 项目方案、部署、升级、操作手册和飞牛应用文档 |
| `docker-compose*.yml` | Host、Bridge 和单文件 Git 构建部署编排 |
## 功能总览 ## 部署
| 模块 | 能力 | 源码仓库:[https://github.com/mrlee233/havline](https://github.com/mrlee233/havline) · [发布版本](https://github.com/mrlee233/havline/releases)
| --- | --- |
| **反向代理** | 多域名、多端口、HTTP/HTTPS、自动跳转、Nginx 配置生成与热重载 |
| **DDNS** | Cloudflare、DNSPod、阿里云、腾讯云、火山引擎;单任务多域名与自动同步 |
| **HTTPS 证书** | ACME 自动申请与续期、通配符证书、手动导入、证书与私钥下载 |
| **内网穿透** | FRP 多服务端、8 种代理类型、插件、规则下发、流量与连接数监控 |
| **公网服务端** | SSH 安装与升级 `havline-agent`,远程管理 VPS 上的 frps 与 Nginx |
| **Cloudflare Tunnel** | cloudflared 安装、隧道、路由、DNS、健康检查、漂移修复 |
| **仪表盘** | 公网 IP、域名、证书、代理状态、请求趋势、流量和运行健康度 |
| **日志中心** | 系统日志、Nginx 访问日志、错误日志;分页、筛选与自动刷新 |
| **通知与备份** | 邮件、Webhook、Telegram、钉钉、飞书、企业微信;配置导出与恢复 |
| **一键升级** | Docker Compose sidecar 构建、重建、健康检查与失败回滚 |
## 快速开始 以下方式均从源码构建,不使用上游 Docker 镜像。
### 推荐:飞牛 fnOS Host 模式 | 场景 | 推荐方式 | 说明 |
| --- | --- | --- |
| 飞牛 fnOS 常规安装 | Host 模式 Compose | 避开系统已占用的 `80/443`,默认使用 `6893/18080/9443` |
| 非 host 网络或自定义端口映射 | Bridge 模式 Compose | 主服务和 updater 通过端口映射访问,适合测试或自建 Linux |
| 只拿一个 Compose 文件部署 | 单文件 Git 构建 | updater 从远端仓库读取版本并构建,本地不需要完整源码 |
| 飞牛应用商店安装 | Docker 离线包或 Native FPK | 见下方「飞牛 fnOS 应用」章节 |
飞牛系统通常已经占用 `80/443`,推荐使用 **host 网络 + 高位反代端口**: ### 快速部署(推荐 · 飞牛 fnOS · Host 模式)
飞牛系统已占用 **80/443**,使用 **host 网络** + **高位反代端口**,避免与系统 Nginx 冲突。
```bash ```bash
docker run -d \ git clone --branch main https://github.com/mrlee233/havline.git
--name havline \ cd havline
--net=host \ docker compose -f docker-compose.host.yml up -d --build
-v ./data:/data \
--restart unless-stopped \
havline:local
``` ```
| 入口 | 默认地址 | | 入口 | 地址 |
| --- | --- | | ------------------ | ----------------------- |
| 管理后台 | `http://<NAS-IP>:6893` | | 管理后台 | `http://<NAS-IP>:6893` |
| HTTP 反向代理 | `http://<NAS-IP>:18080` | | HTTP 反代(默认) | `http://<NAS-IP>:18080` |
| HTTPS 反向代理 | `https://<NAS-IP>:9443` | | HTTPS 反代(默认) | `https://<NAS-IP>:9443` |
### Docker Compose - 默认命名卷挂载到 `/data`,存放数据库、Nginx 配置、证书与日志;可通过 `HAVLINE_DATA_PATH` 选择绑定目录,已有数据须先迁移
- Host 编排设置反代默认端口为 `18080`、`9443`,可通过环境变量修改;该编排不包含 updater
### 其他部署方式
<details>
<summary>本地源码 + Bridge 网络</summary>
在已克隆的源码目录中执行:
```bash ```bash
# 飞牛 fnOS / host 网络 docker compose -f docker-compose.yml up -d --build
HAVLINE_SESSION_SECRET=your-secret \
docker compose -f docker-compose.host.yml up -d
# Bridge 网络
HAVLINE_SESSION_SECRET=your-secret \
docker compose -f docker-compose.yml up -d --build
``` ```
默认映射 `6893:6893`、`18080:80`、`9443:443`。主程序首次启动时会在共享卷中自动生成 updater Token,updater 等待并读取同一文件,不会创建额外的初始化容器;只有 updater 挂载 Docker Socket。
</details>
<details>
<summary>单文件 Git 构建(不需要本地源码)</summary>
```bash
mkdir -p /opt/havline && cd /opt/havline
curl -fsSLo docker-compose.hub.yml \
https://github.com/mrlee233/havline/raw/branch/main/docker-compose.hub.yml
docker compose -f docker-compose.hub.yml up -d --build
```
BuildKit 从当前仓库 `main` 分支构建,默认使用 Bridge 端口映射。Token 由主程序自动生成,`HAVLINE_VERSION` 只控制镜像标签,不锁定源码提交。
</details>
<details>
<summary>手动构建镜像</summary>
```bash
docker build -t havline:local .
```
在源码根目录执行;构建镜像不会自动启动容器,部署时仍需选择合适的 Compose 编排。
</details>
### 首次登录 ### 首次登录
1. 查看容器或系统日志,获取自动生成的 `admin` 初始密码; 1. 启动后查看容器/运行日志,获取自动生成的 **admin 初始密码**
2. 打开 `http://<NAS-IP>:6893/login`; 2. 访问 `http://<NAS-IP>:6893/login` 登录
3. 登录后立即在 **系统设置 → 管理员账户** 中修改密码。 3. 在 **设置** 中立即修改管理员密码
> 完整部署、权限、端口、数据目录、迁移和故障排查请阅读 ### FRP 内网穿透(无公网 IP)
> [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) 与
> [`docs/container-deploy-and-data.md`](docs/container-deploy-and-data.md)。
## 内网穿透与公网服务端 适用于无法端口映射、无公网 IPv4 的场景。Havline 在 **内网穿透** 页面统一管理多个服务端与代理规则,**frpc 按需下载**:
没有公网 IPv4 或无法端口映射时,可以使用 FRP 经 VPS 中转: - **Web 网关**:外网 HTTP/HTTPS 经 VPS 上的 frps 中转至本地 Havline Nginx(默认 `18080` / `9443`),域名分流、证书、访问控制、日志仍由 Nginx 处理
- **端口转发(TCP/UDP)**:将 VPS 远程端口映射到内网服务(如 SSH、数据库、DNS/游戏等 UDP);可与 Web 网关同时启用,纯端口穿透可不配置 Web 域名
- **服务端设置**:填写 frps 地址、端口、认证和 TLS 选项;保存后添加代理规则,校验配置再启动
```text **Web 流量路径**:用户 → DNS(解析到 VPS)→ frps → frpc → Havline Nginx → 内网服务
用户 → DNS(VPS) → frps → frpc → Havline Nginx → 内网服务
**端口转发流量路径**:用户 → VPS 公网 IP:远程端口 → frps → frpc → 内网 `IP:端口`(TCP 或 UDP)
#### 1. VPS 部署 frps
在具有公网 IP 的 VPS 上安装 [frp](https://github.com/fatedier/frp),选择与本地 frpc 兼容的版本。在服务端详情中查看生成的 `frps.toml` 参考,也可通过公网 Agent 管理 frps。
典型 Web 网关示例:
```toml
bindAddr = "0.0.0.0"
bindPort = 7000
auth.method = "token"
auth.token = "your-secret-token"
vhostHTTPPort = 80
vhostHTTPSPort = 443
``` ```
公网 VPS 上可以安装 `havline-agent`,由本地 Havline 通过 SSH 管理 agent、frps、Nginx 和证书。 启动:`frps -c frps.toml`。安全组/防火墙需放行 **7000**(控制连接)、**80/443**(Web 虚拟主机),以及 **隧道远程端口**(在 Havline 添加规则后按提示放行;UDP 隧道需在防火墙同时放行对应端口的 UDP)。
| 主题 | 文档 | #### 2. Havline 配置 frpc
| --- | --- |
| FRP 使用与场景 | [`docs/FRP公网暴露操作手册.md`](docs/FRP公网暴露操作手册.md) |
| 公网服务端管理 | [`docs/frps服务端管理方案.md`](docs/frps服务端管理方案.md) |
| Agent 监听与安全 | [`docs/agent-listen-strategy-plan.md`](docs/agent-listen-strategy-plan.md) |
## Cloudflare Tunnel 在侧栏 **内网穿透** 页面:
Havline 支持将 Cloudflare Tunnel 作为服务发布出口。反向代理规则可选择本机 Nginx 或 1. 添加服务端,填写服务器地址、端口、认证信息和 TLS 选项,与 VPS 上 frps 保持一致。
Cloudflare 出口,并自动派生隧道 ingress、同步 DNS、检查健康状态和修复云端漂移。 2. 添加 HTTP / HTTPS 代理时填写域名和本机 Nginx 目标端口;Host 编排默认使用 `18080` / `9443`,Bridge 容器内通常为 `80` / `443`,以实际规则为准。
3. 添加 TCP / UDP 代理时填写 VPS 远程端口及内网目标地址;开放对应安全组和防火墙端口。
4. 在应用配置中下载或激活 FRP 客户端,校验配置后启动,通过服务端诊断和日志检查运行状态。
| 主题 | 文档 | 控制连接成功不等于业务已连通,还需检查代理端口、内网目标、DNS 和 Nginx 路由。
| --- | --- |
| 当前功能 | [`docs/cloudflared-current-features.md`](docs/cloudflared-current-features.md) |
| 实施方案 | [`docs/cloudflared-havline-fit-plan.md`](docs/cloudflared-havline-fit-plan.md) |
| UX 调研 | [`docs/cloudflared-github-ux-research.md`](docs/cloudflared-github-ux-research.md) |
## 一键升级 #### 3. DNS 与证书
`docker-compose.yml` 与 `docker-compose.hub.yml` 会启动受限的 `havline-updater` sidecar。 - **DDNS**:FRP 访问域名应解析到 **VPS 公网 IP**,而非 NAS IP;按需使用自定义 IP 或在 DNS 服务商处配置
检测到新版本后,侧栏会出现「一键升级」入口,可在页面中完成: - **HTTPS 证书**:Web 网关推荐继续使用 **DNS-01** 验证(Cloudflare、阿里云、腾讯云、DNSPod、火山引擎等已支持);证书仍在 NAS 侧 Nginx 终结
- **纯端口穿透**:无需为 TCP/UDP 隧道单独配置域名证书
1. 拉取或构建目标版本镜像; ### 公网 Agent 与 Cloudflare Tunnel
2. 重建 `havline` 服务;
3. 执行健康检查; - **公网 Agent**:在公网 Agent 页面配置 VPS 信息,按需通过 SSH 安装并测试连接,再管理 frps、Nginx 和证书;需要公网反代时部署对应路由。限制 Agent 管理通道访问,不要仅凭业务端口可用判断管理通道安全。
4. 失败时尝试回滚到升级前镜像。 - **Cloudflare Tunnel**:配置 Account ID 和 API Token,预检授权并按需下载 cloudflared;创建隧道后,在反向代理规则中选择 Cloudflare 出口和目标隧道,检查路由、DNS 与连接状态。托管模式从本地规则派生 ingress,修复漂移前先确认本地配置。
## 飞牛 fnOS 应用
Havline 提供两种飞牛应用形态,均支持统一网关入口 `/app/havline`。
### Docker 应用(离线镜像包)
- 独立仓库:<https://github.com/mrlee233/havline>
- 安装包:`havline-1.9.0.fpk`
- 包内包含 amd64 / arm64 离线镜像,安装时执行 `docker load`
- Compose 使用本地镜像并设置 `pull_policy: never`,不依赖外部 Registry
- 管理界面通过统一网关 `/app/havline` 访问
详细设计、安全边界、故障排查和验证步骤见 [`docs/system-upgrade.md`](docs/system-upgrade.md)。
## 环境变量 ## 环境变量
| 变量 | 默认值 | 说明 | | 变量 | 默认值 | 说明 |
| --- | --- | --- | | ----------------------- | ------------------------- | ------------------------------------------ |
| `HAVLINE_LISTEN` | `:6893` | 管理后台监听地址 | | `HAVLINE_LISTEN` | `:6893` | 管理后台监听地址 |
| `HAVLINE_DATA_DIR` | `/data` | 数据目录,包含数据库、Nginx、证书与日志 | | `HAVLINE_DATA_DIR` | `/data` | 数据目录(数据库、Nginx 配置、证书、日志) |
| `HAVLINE_SESSION_SECRET` | 空,自动生成 | 会话签名与凭据加密密钥;有数据后不要更换 | | `HAVLINE_SESSION_SECRET` | 空,自动生成 | 会话与凭据加密密钥,保存在数据目录;已有数据后不要更换 |
| `HAVLINE_NGINX_BIN` | `nginx` | Nginx 可执行文件路径 | | `HAVLINE_INITIAL_ADMIN_PASSWORD` | 空,自动生成 | 首次初始化管理员时使用的密码;不设置时会生成随机密码并输出到首次启动日志 |
| `HAVLINE_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代规则默认监听端口 | | `HAVLINE_UPDATER_TOKEN` | 空,自动生成 | 可选覆盖值;未设置时读取共享 Token 文件 |
| `HAVLINE_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代规则默认监听端口 | | `HAVLINE_UPDATER_TOKEN_FILE` | `/run/havline-updater/updater.token` | 主程序与 updater sidecar 共享的 Token 文件 |
| `HAVLINE_FRPC_BIN` | `frpc` | frpc 可执行文件路径 | | `HAVLINE_UPDATER_SOCKET` | `/run/havline-updater/updater.sock` | 主程序与 updater sidecar 通信的 Unix Socket |
| `HAVLINE_FRP_PID` | `{DATA_DIR}/frp/frpc.pid` | frpc PID 文件 | | `HAVLINE_DATA_PATH` | `havline-data` | Compose 挂载来源,可配置宿主机目录 |
| `HAVLINE_UPDATER_SOCKET` | `/run/havline-updater/updater.sock` | 一键升级 sidecar Socket | | `HAVLINE_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代规则的默认监听端口 |
| `HAVLINE_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代规则的默认监听端口 |
| `HAVLINE_NGINX_BIN` | `nginx` | Nginx 可执行文件路径(本地开发可指定) |
| `HAVLINE_FRPC_BIN` | `frpc` | frpc 可执行文件路径(运行时可按需下载) |
| `HAVLINE_FRP_PID` | `{DATA_DIR}/frp/frpc.pid` | frpc 进程 PID 文件路径 |
| `HAVLINE_GATEWAY_SOCKET` | 空 | 飞牛统一网关接入时使用的 Unix Socket |
| `HAVLINE_GATEWAY_PREFIX` | 空 | 统一网关访问前缀,例如 `/app/havline` |
| `HAVLINE_AGENT_LISTEN` | `127.0.0.1:7700` | Agent 默认监听地址 | | `HAVLINE_AGENT_LISTEN` | `127.0.0.1:7700` | Agent 默认监听地址 |
| `HAVLINE_AGENT_ALLOW_REMOTE` | `0` | 是否允许 Agent 监听非回环地址 | | `HAVLINE_AGENT_ALLOW_REMOTE` | `0` | 是否允许 Agent 监听非回环地址;启用前应确认防火墙和 HTTPS/隧道策略 |
| `HAVLINE_AGENT_TOKEN` | 安装时生成 | Agent Bearer Token | | `HAVLINE_AGENT_TOKEN` | 安装时生成 | Agent Bearer Token |
| `HAVLINE_UPDATE_PROJECT_DIR` | `/workspace` | updater 容器内 Compose 项目目录 |
| `HAVLINE_UPDATE_COMPOSE_FILE` | `docker-compose.yml` | updater 允许操作的 Compose 文件 |
| `HAVLINE_UPDATE_MODE` | `local` | 升级模式,支持 `local` 或 `git` |
| `HAVLINE_UPDATE_REPO` | 无 | `git` 模式下的远端仓库地址 |
| `HAVLINE_UPDATE_BRANCH` | `main` | `git` 模式下的分支 |
| `HAVLINE_UPDATE_HOST_DIR` | 自动发现 | 宿主机 Compose 文件目录;自动发现失败时必须显式设置 |
## 技术架构 ### 数据目录速查
| 层级 | 技术 | | 路径 | 内容 |
| --- | --- | | --- | --- |
| 后端 | Go 1.23、标准库 HTTP、SQLite | | `/data/havline.db` | 管理员、会话、代理、DDNS、FRP、Cloudflare、健康事件等 SQLite 数据 |
| 数据存储 | `modernc.org/sqlite`,单文件数据库 | | `/data/session_secret` | 会话签名与敏感凭据加密主密钥;已有数据后不可随意更换 |
| 反向代理 | Nginx,配置动态生成与热重载 | | `/data/nginx/` | 生成的 Nginx 配置、日志和相关运行文件 |
| 证书 | go-acme/lego,ACME DNS-01 | | `/data/certs/` | ACME 证书与私钥 |
| 内网穿透 | FRP 客户端 / 服务端与自研管理 Agent | | `/data/frp/` | frpc 配置、二进制、PID 和运行状态 |
| 隧道 | Cloudflare Tunnel / cloudflared |
| 前端 | Vue 3、TypeScript、Vite、Naive UI、ECharts | ## 注意事项
| 部署 | Docker、Docker Compose、Linux systemd |
| 架构 | amd64 / arm64 | 1. **密钥持久化**:会话密钥未显式配置时自动生成并写入数据目录;已有数据不要更换密钥。含 updater 的编排会由主程序在共享卷中自动生成 Token,旧部署仍可用 `HAVLINE_UPDATER_TOKEN` 覆盖。
2. **端口冲突**:宿主机已有 Nginx(如飞牛系统)占用 80/443 时,不要让 Havline 反代规则监听 80/443;使用 `18080/9443` 或 host 模式 + 环境变量。
3. **已有反代规则**:修改 `HAVLINE_NGINX_HTTP_PORT` / `HAVLINE_NGINX_HTTPS_PORT` 后,**不会自动更新**数据库中已有规则的端口,需在反代页手动修改并保存。
4. **访问日志**:仅统计经 Havline Nginx 反代的域名流量,不包含管理后台 `:6893` 的请求。
5. **证书与 DNS**:DNS-01 申请不要求域名指向 NAS,但需确保 DNS 凭证能写入对应根域的验证记录,并等待传播。业务访问仍需正确解析、端口和路由。
6. **数据持久化**:Docker 部署请挂载 volume 到 `HAVLINE_DATA_DIR`,避免容器重建后配置丢失。
7. **备份**:可通过设置页导出数据;文件级备份前停止服务,保留整个数据目录(数据库、密钥、证书私钥和运行配置);显式环境密钥需另行安全保留。
8. **升级**:含 updater 的 Compose 实例可使用一键升级;仅重建后版本健康检查失败时尝试镜像回滚,构建失败和数据库迁移回退需人工处理。升级前保留备份和旧镜像。
## 常见问题
| 现象 | 优先检查 |
| --- | --- |
| 无法登录或忘记初始密码 | 首次启动日志中的 `admin` 密码;若日志已丢失,停止服务后从备份恢复,或按部署文档处理数据库 |
| 启动后提示端口被占用 | 飞牛系统通常占用 `80/443`;Host 模式请使用 `6893/18080/9443`,Bridge 模式检查端口映射 |
| 修改反代端口后旧规则不生效 | `HAVLINE_NGINX_HTTP_PORT` / `HAVLINE_NGINX_HTTPS_PORT` 只影响新规则,已有规则需在反代页手动修改 |
| 证书申请失败 | 检查 DNS 服务商凭证、根域权限、ACME 邮箱、DNS 传播和 CA 限制;泛域名必须使用 DNS-01 |
| DDNS 没有更新 | 检查 DNS API 权限、记录是否存在、公网 IP 探测结果;FRP 场景域名应解析到 VPS 公网 IP |
| FRP 客户端启动失败 | 确认 frps 地址/端口/Token/TLS 一致,VPS 安全组已放行控制端口和远程端口 |
| Cloudflare DNS 未自动创建 | 确认根域名在同一个 Account ID 下,API Token 至少具备 Zone:Read 和 DNS:Edit |
| Cloudflare 隧道 502 或 Token 错误 | 使用 API Token 预检;Tunnel Token 与 Account API Token 不是同一种凭据 |
| 升级时找不到 Compose 文件 | 设置 `HAVLINE_UPDATE_HOST_DIR` 指向宿主机 Compose 所在目录,并确认容器名和 Compose 项目标签来自同一部署 |
| 升级后数据异常 | 恢复升级前备份,确认 `HAVLINE_DATA_DIR`、`session_secret` 和数据库迁移状态未变化 |
| Agent 直连失败 | 确认 Agent 监听地址、端口、防火墙和 Token;默认策略应优先使用 SSH 隧道或 HTTPS,不建议直接暴露管理端口 |
| 公网访问失败 | 按 DNS → 端口/隧道 → Nginx 路由 → 内网服务逐段排查,不要只看 FRP 或 Tunnel 是否在线 |
## 本地开发 ## 本地开发
```bash ```powershell
# 前端依赖与热更新 # 构建前端并准备嵌入目录
cd web cd web
npm ci npm ci
npm run dev
# 构建前端并同步到 Go embed 目录
npm run build npm run build
cd .. cd ..
Copy-Item -Path web/dist/* -Destination cmd/havline/web/dist -Recurse -Force New-Item -ItemType Directory -Force cmd/havline/web/dist | Out-Null
Copy-Item web/dist/* cmd/havline/web/dist -Recurse -Force
# 运行后端 # 运行后端(需要可用 Nginx)
go run ./cmd/havline go run ./cmd/havline
# 测试
go test ./...
go vet ./...
cd web && npm test
``` ```
`VERSION` 是版本号单一来源。Docker 构建会读取并注入前后端;直接 `go run` 时未注入版本会显示 `dev`。 前端热更新在另一终端执行 `cd web`、`npm run dev`。测试命令:
## 文档导航 ```powershell
go test ./...
go vet ./...
cd web
npm run typecheck
npm test
```
| 文档 | 说明 | 版本来源为根目录 `VERSION`;Docker 构建注入前后端,本地 `go run` 未注入时显示 `dev`。
| --- | --- |
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | 裸机、Docker、飞牛 host 模式部署 |
| [`docs/container-deploy-and-data.md`](docs/container-deploy-and-data.md) | 容器部署与数据目录 |
| [`docs/system-upgrade.md`](docs/system-upgrade.md) | 一键升级与回滚 |
| [`docs/FRP公网暴露操作手册.md`](docs/FRP公网暴露操作手册.md) | FRP 使用手册 |
| [`docs/frps服务端管理方案.md`](docs/frps服务端管理方案.md) | 公网服务端与 Agent |
| [`docs/cloudflared-current-features.md`](docs/cloudflared-current-features.md) | Cloudflare Tunnel 当前能力 |
| [`docs/fnos-app-packaging-plan.md`](docs/fnos-app-packaging-plan.md) | 飞牛应用打包方案 |
| [`docs/README.md`](docs/README.md) | 全部文档索引 |
## 开源许可与致谢 ## 开源协议
> 开源不是重新发明轮子,而是站在巨人的肩膀上,把一件事继续做得更好。 本项目采用 [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.html) 开源。
Havline 基于开源项目 [**O96u/Fonu**](https://github.com/O96u/Fonu) **1.0.2** 版本进行二次开发。 Havline 基于 [O96u/Fonu](https://github.com/O96u/Fonu) **1.0.2** 版本继续开发,感谢上游作者及贡献者。保留原作者版权、来源与 GPL-3.0 下适用的源代码提供义务。
感谢原项目及其贡献者提供的基础实现和工程积累。
本项目继续采用 [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.html) 开源。品牌名称调整不会改变 问题反馈:[Havline Issues](https://github.com/mrlee233/havline/issues)。请提供版本、部署方式、复现步骤和脱敏日志,不上传密码、Token、私钥或完整备份。
原项目及其衍生版本在 GPL-3.0 下的版权、署名和源代码提供义务。
软件按“原样”提供,不附带任何担保。请在部署前做好数据备份、访问控制和网络安全配置。
+1 -1
View File
@@ -1 +1 @@
1.9.0 1.9.2
+15
View File
@@ -6,11 +6,25 @@ import (
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time"
"github.com/havline/havline/internal/updater" "github.com/havline/havline/internal/updater"
) )
func main() { func main() {
token, tokenSource, err := updater.WaitForToken(
envOr("HAVLINE_UPDATER_TOKEN", ""),
envOr("HAVLINE_UPDATER_TOKEN_FILE", updater.DefaultTokenPath),
2*time.Minute,
)
if err != nil {
log.Fatal(err)
}
if token == "" {
log.Fatal("HAVLINE_UPDATER_TOKEN 或 HAVLINE_UPDATER_TOKEN_FILE 未配置")
}
log.Printf("updater Token 已就绪:%s", tokenSource)
server, err := updater.NewServer(updater.Config{ server, err := updater.NewServer(updater.Config{
SocketPath: envOr("HAVLINE_UPDATER_SOCKET", "/run/havline-updater/updater.sock"), SocketPath: envOr("HAVLINE_UPDATER_SOCKET", "/run/havline-updater/updater.sock"),
ProjectDir: envOr("HAVLINE_UPDATE_PROJECT_DIR", "/workspace"), ProjectDir: envOr("HAVLINE_UPDATE_PROJECT_DIR", "/workspace"),
@@ -24,6 +38,7 @@ func main() {
ProjectName: envOr("HAVLINE_UPDATE_PROJECT_NAME", ""), ProjectName: envOr("HAVLINE_UPDATE_PROJECT_NAME", ""),
SelfImage: envOr("HAVLINE_UPDATE_SELF_IMAGE", ""), SelfImage: envOr("HAVLINE_UPDATE_SELF_IMAGE", ""),
DockerSocket: envOr("HAVLINE_UPDATE_DOCKER_SOCK", "/var/run/docker.sock"), DockerSocket: envOr("HAVLINE_UPDATE_DOCKER_SOCK", "/var/run/docker.sock"),
Token: token,
}, nil) }, nil)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
+1
View File
@@ -1,3 +1,4 @@
# 源码仓库:https://github.com/mrlee233/havline.git(main 分支)
# 飞牛 NAS 等已占用 80/443 的环境:使用 host 网络 + 高位默认反代端口 # 飞牛 NAS 等已占用 80/443 的环境:使用 host 网络 + 高位默认反代端口
# 本地项目:直接用源码构建镜像,不从 Docker Hub 拉取 # 本地项目:直接用源码构建镜像,不从 Docker Hub 拉取
name: havline name: havline
+9 -4
View File
@@ -3,7 +3,7 @@
# 用法: # 用法:
# mkdir -p /opt/havline && cd /opt/havline # mkdir -p /opt/havline && cd /opt/havline
# # 取本文件(按你仓库页面上的「原始文件」链接下载,或 git clone 后复制过来): # # 取本文件(按你仓库页面上的「原始文件」链接下载,或 git clone 后复制过来):
# # curl -fsSLo docker-compose.hub.yml <仓库里这个文件的原始地址> # # curl -fsSLo docker-compose.hub.yml https://raw.githubusercontent.com/mrlee233/havline/main/docker-compose.hub.yml
# docker compose up -d --build # 会话密钥由应用首次启动自动生成并持久化在数据卷里 # docker compose up -d --build # 会话密钥由应用首次启动自动生成并持久化在数据卷里
# #
# 想自己指定会话密钥(可选项):在 compose 文件同层建 .env 写入 # 想自己指定会话密钥(可选项):在 compose 文件同层建 .env 写入
@@ -20,7 +20,7 @@ name: havline
services: services:
havline: havline:
build: build:
context: https://jayhub.top/mrlee/Havline.git#main context: https://github.com/mrlee233/havline.git#main
args: args:
NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine} NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine}
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm} GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
@@ -49,6 +49,9 @@ services:
HAVLINE_DATA_DIR: /data HAVLINE_DATA_DIR: /data
HAVLINE_LISTEN: :6893 HAVLINE_LISTEN: :6893
HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
# 主程序首次启动时生成共享 Token;旧部署仍可用环境变量覆盖
HAVLINE_UPDATER_TOKEN: ${HAVLINE_UPDATER_TOKEN:-}
HAVLINE_UPDATER_TOKEN_FILE: /run/havline-updater/updater.token
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600) # 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
# 想自己指定就在同层 .env 里设这个变量;注意它同时是凭据加密密钥,已有数据后不要更换 # 想自己指定就在同层 .env 里设这个变量;注意它同时是凭据加密密钥,已有数据后不要更换
HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-} HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-}
@@ -58,7 +61,7 @@ services:
# 受限升级 sidecar:只有它持有 Docker Socket,主程序通过共享 Unix Socket 触发 # 受限升级 sidecar:只有它持有 Docker Socket,主程序通过共享 Unix Socket 触发
updater: updater:
build: build:
context: https://jayhub.top/mrlee/Havline.git#main context: https://github.com/mrlee233/havline.git#main
target: updater target: updater
args: args:
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm} GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
@@ -74,11 +77,13 @@ services:
- havline-updater-sock:/run/havline-updater - havline-updater-sock:/run/havline-updater
environment: environment:
HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
HAVLINE_UPDATER_TOKEN: ${HAVLINE_UPDATER_TOKEN:-}
HAVLINE_UPDATER_TOKEN_FILE: /run/havline-updater/updater.token
HAVLINE_UPDATE_PROJECT_DIR: /workspace HAVLINE_UPDATE_PROJECT_DIR: /workspace
HAVLINE_UPDATE_COMPOSE_FILE: docker-compose.hub.yml HAVLINE_UPDATE_COMPOSE_FILE: docker-compose.hub.yml
HAVLINE_UPDATE_SERVICE: havline HAVLINE_UPDATE_SERVICE: havline
HAVLINE_UPDATE_MODE: git HAVLINE_UPDATE_MODE: git
HAVLINE_UPDATE_REPO: https://jayhub.top/mrlee/Havline.git HAVLINE_UPDATE_REPO: https://github.com/mrlee233/havline.git
HAVLINE_UPDATE_BRANCH: main HAVLINE_UPDATE_BRANCH: main
HAVLINE_UPDATE_APP_URL: http://havline:6893 HAVLINE_UPDATE_APP_URL: http://havline:6893
HAVLINE_UPDATE_HOST_DIR: ${HAVLINE_UPDATE_HOST_DIR:-} HAVLINE_UPDATE_HOST_DIR: ${HAVLINE_UPDATE_HOST_DIR:-}
+6
View File
@@ -1,3 +1,4 @@
# 源码仓库:https://github.com/mrlee233/havline.git(main 分支)
# 固定项目名:不改的话,改了目录名/项目名后 compose 会把它当成新项目, # 固定项目名:不改的话,改了目录名/项目名后 compose 会把它当成新项目,
# havline-data 会变成新空卷(看起来像「数据丢了」)。 # havline-data 会变成新空卷(看起来像「数据丢了」)。
name: havline name: havline
@@ -34,6 +35,9 @@ services:
HAVLINE_DATA_DIR: /data HAVLINE_DATA_DIR: /data
HAVLINE_LISTEN: :6893 HAVLINE_LISTEN: :6893
HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
# 主程序首次启动时生成共享 Token;旧部署仍可用环境变量覆盖
HAVLINE_UPDATER_TOKEN: ${HAVLINE_UPDATER_TOKEN:-}
HAVLINE_UPDATER_TOKEN_FILE: /run/havline-updater/updater.token
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600) # 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
# 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换 # 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换
HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-} HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-}
@@ -58,6 +62,8 @@ services:
- havline-updater-sock:/run/havline-updater - havline-updater-sock:/run/havline-updater
environment: environment:
HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
HAVLINE_UPDATER_TOKEN: ${HAVLINE_UPDATER_TOKEN:-}
HAVLINE_UPDATER_TOKEN_FILE: /run/havline-updater/updater.token
HAVLINE_UPDATE_PROJECT_DIR: /workspace HAVLINE_UPDATE_PROJECT_DIR: /workspace
HAVLINE_UPDATE_COMPOSE_FILE: docker-compose.yml HAVLINE_UPDATE_COMPOSE_FILE: docker-compose.yml
HAVLINE_UPDATE_SERVICE: havline HAVLINE_UPDATE_SERVICE: havline
+131 -25
View File
@@ -1,15 +1,17 @@
# Havline 部署安装文档 # Havline 部署安装文档
> 适用版本:v1.1.0(源码包 `havline-1.1.0-src.zip`)。本文覆盖从拿到安装包到服务上线的完整流程:环境要求 → 打包 → 三种部署方式 → 首次配置 → FRP/DDNS/证书联动 → 升级与回滚 → 故障排查。 > 适用版本:v1.9.0。本文覆盖 Docker / 飞牛 fnOS / Linux 原生部署、数据持久化、敏感数据保护、updater sidecar、一键升级、回滚和故障排查。文中旧版本包名仅用于说明历史交付物,不代表当前发布包名。
>
> 当前发布源:`https://jayhub.top/mrlee/Havline.git`,分支:`main`。`docker-compose.hub.yml` 和 updater sidecar 都固定使用这组仓库地址与分支;如果实际发布仓库发生变化,必须同步修改 Compose 文件、updater 环境变量和本节说明后再部署。
> 功能细节与组合场景请配合项目内两份文档:`README.md`(功能总览)与 FRP 页面右上「使用说明」(内置十个组合场景详解)。 > 功能细节与组合场景请配合项目内两份文档:`README.md`(功能总览)与 FRP 页面右上「使用说明」(内置十个组合场景详解)。
## 0. 交付物清单与安装路径速查 ## 0. 交付物清单与安装路径速查
| 交付物 | 内容 | 适用 | 安装方式 | | 交付物 | 内容 | 适用 | 安装方式 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `havline-1.1.0-linux-amd64.tar.gz`(14MB) | 编译好的 Linux 二进制(前端已内嵌)+ migrations + systemd + 一键脚本 | **裸机 Linux 服务器**,目标机只需系统 nginx | 解压 → `sudo bash install.sh`(见 3.4) | | `havline-1.9.0-linux-amd64.tar.gz`(大小以实际发布包为准) | 编译好的 Linux 二进制(前端已内嵌)+ migrations + systemd + 一键脚本 | **裸机 Linux 服务器**,目标机只需系统 nginx | 解压 → `sudo bash install.sh` |
| `havline-1.1.0-image.tar.gz`(约 100-150MB) | 构建好的 Docker 镜像 `havline:local`(含 nginx + frpc) | 目标机有 Docker、要离线安装 | `docker load` → `docker run`(见 3.3 方式 C) | | `havline-1.9.0-image.tar.gz`(大小以实际发布包为准) | 构建好的 Docker 镜像 `havline:local`(含 Nginx,不预装 frpc) | 目标机有 Docker、要离线安装 | `docker load` → `docker run` |
| `havline-1.1.0-src.zip`(11MB) | 完整源码 + Dockerfile/compose + 部署文档 | 要自行构建 / 二次开发 / 交叉编译 | `docker compose build` 后按方式 A/B 部署(见第 2 节) | | `havline-1.9.0-src.zip`(大小以实际发布包为准) | 完整源码 + Dockerfile/compose + 部署文档 | 要自行构建 / 二次开发 / 交叉编译 | `docker compose build` 后按方式 A/B 部署(见第 2 节) |
**怎么选**:飞牛 fnOS 首选镜像包导入(图形界面,见第 3 节末「飞牛 fnOS 部署详解」),要在 NAS 上构建则用源码包 + host 模式 compose;普通 Linux 服务器要省事用 Linux 运行包(免 Docker);目标机不方便联网构建用镜像包离线导入。三者的功能完全一致——容器里跑的也是同一份二进制 + 系统 nginx。 **怎么选**:飞牛 fnOS 首选镜像包导入(图形界面,见第 3 节末「飞牛 fnOS 部署详解」),要在 NAS 上构建则用源码包 + host 模式 compose;普通 Linux 服务器要省事用 Linux 运行包(免 Docker);目标机不方便联网构建用镜像包离线导入。三者的功能完全一致——容器里跑的也是同一份二进制 + 系统 nginx。
@@ -17,15 +19,15 @@
### 1.1 源码包内容 ### 1.1 源码包内容
`havline-1.1.0-src.zip`(约 11MB,304 项)为完整可构建源码: `havline-1.9.0-src.zip`(文件大小与条目数以实际发布包为准)为完整可构建源码:
| 目录/文件 | 说明 | | 目录/文件 | 说明 |
| --- | --- | | --- | --- |
| `cmd/` `internal/` | Go 后端源码(含已迁移的多服务端 FRP 模块) | | `cmd/` `internal/` | Go 后端源码(含已迁移的多服务端 FRP 模块) |
| `web/` | Vue 3 + TypeScript 前端源码 | | `web/` | Vue 3 + TypeScript 前端源码 |
| `migrations/` | SQLite 数据库迁移 001–022(启动时自动按序应用) | | `migrations/` | SQLite 数据库迁移 001–036(启动时自动按序应用) |
| `Dockerfile` / `docker-compose*.yml` | 本地构建镜像与三种部署编排 | | `Dockerfile` / `docker-compose*.yml` | 本地构建镜像与三种部署编排 |
| `VERSION` / `CHANGELOG.md` | 版本号单一来源(当前 `1.1.0`)与版本记录 | | `VERSION` / `CHANGELOG.md` | 版本号单一来源(当前 `1.9.0`)与版本记录 |
| `deploy/nginx/mime.types` | 容器内 Nginx 依赖的 MIME 类型文件 | | `deploy/nginx/mime.types` | 容器内 Nginx 依赖的 MIME 类型文件 |
| `docs/screenshots/` | README 引用的界面截图 | | `docs/screenshots/` | README 引用的界面截图 |
@@ -52,7 +54,7 @@ docker compose build
一次构建同时产出:前端 `dist`、Go 后端二进制与运行镜像(frpc 不预装,运行期用「应用配置」按需下载),标签为 `havline:local`。 一次构建同时产出:前端 `dist`、Go 后端二进制与运行镜像(frpc 不预装,运行期用「应用配置」按需下载),标签为 `havline:local`。
**版本号**:读取根目录 `VERSION` 文件(当前 `1.1.0`)自动注入前后端显示;要改版本号只需编辑该文件后重新 build。 **版本号**:读取根目录 `VERSION` 文件(当前 `1.9.0`)自动注入前后端显示;要改版本号只需编辑该文件后重新 build。
**网络受限环境**可覆盖基础镜像与 frpc 下载源(默认值见 `Dockerfile` 顶部注释): **网络受限环境**可覆盖基础镜像与 frpc 下载源(默认值见 `Dockerfile` 顶部注释):
@@ -71,7 +73,9 @@ docker build \
飞牛系统自带 Nginx 占用 80/443,Havline 使用 host 网络 + 高位反代端口避免冲突。 飞牛系统自带 Nginx 占用 80/443,Havline 使用 host 网络 + 高位反代端口避免冲突。
```bash ```bash
HAVLINE_SESSION_SECRET=请改成一串随机字符 docker compose -f docker-compose.host.yml up -d --build # host 编排不启用 updater sidecar;HAVLINE_SESSION_SECRET 留空时由应用生成并持久化
# 如需显式密钥,请提前写入同层 .env
docker compose -f docker-compose.host.yml up -d --build
``` ```
- 无端口映射,容器直接使用宿主机网络栈; - 无端口映射,容器直接使用宿主机网络栈;
@@ -82,11 +86,12 @@ HAVLINE_SESSION_SECRET=请改成一串随机字符 docker compose -f docker-comp
### 方式 B:其他环境 / 空闲 80/443 —— Bridge 端口映射 ### 方式 B:其他环境 / 空闲 80/443 —— Bridge 端口映射
```bash ```bash
HAVLINE_SESSION_SECRET=请改成一串随机字符 docker compose -f docker-compose.hub.yml up -d --build docker compose -f docker-compose.hub.yml up -d --build
``` ```
- 端口映射 `6893:6893`、`18080:80`、`9443:443`(宿主机 18080/9443 → 容器 80/443); - 端口映射 `6893:6893`、`18080:80`、`9443:443`(宿主机 18080/9443 → 容器 80/443);
- 数据同样在命名卷 `havline-data`。 - 数据同样在命名卷 `havline-data`;
- 主程序首次启动时会在共享卷中自动生成 updater Token,sidecar 等待并读取同一文件。旧部署如显式设置 `HAVLINE_UPDATER_TOKEN`,仍会优先使用该值。
### 方式 C:docker run(不想用 compose) ### 方式 C:docker run(不想用 compose)
@@ -94,16 +99,18 @@ HAVLINE_SESSION_SECRET=请改成一串随机字符 docker compose -f docker-comp
docker run -d --name havline \ docker run -d --name havline \
--net=host \ --net=host \
-v ./data:/data \ -v ./data:/data \
-e HAVLINE_SESSION_SECRET=请改成一串随机字符 \ -e HAVLINE_SESSION_SECRET=可选的固定密钥 \
--restart unless-stopped \ --restart unless-stopped \
havline:local 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`)。 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]$_})`。 **生成随机密钥**:Linux 使用 `openssl rand -hex 32`;PowerShell 可使用 `-join ((48..57)+(97..102) | Get-Random -Count 64 | % {[char]$_})`。
> **`HAVLINE_SESSION_SECRET` 是什么**:Havline 的主密钥,两个用途——① 签名/加密登录会话 Cookie(决定登录态是否可被伪造);② 作为 `secret.Box` 加密存储中敏感凭据(DDNS API Token、FRP Token、SMTP 密码等)。默认值 `change-me-in-production` 是公开的:拿到它即可伪造管理员会话、解密数据库里的全部凭据,**生产环境必须替换**。⚠️ 换密钥的时机:首次启动前设置;若之后更换,旧密钥加密的凭据将无法解密(应用会提示重新保存),需到对应页面重录 Token。 > **`HAVLINE_SESSION_SECRET`** 是会话签名和敏感凭据加密密钥。当前代码在它为空时,会在 `/data/session_secret` 生成 32 字节随机值并以 `0600` 权限持久化。已有数据后不要更换,否则数据库中已加密的 DDNS、FRP、通知、Cloudflare 等凭据无法解密。
>
> **`HAVLINE_UPDATER_TOKEN`** 是主程序与 updater sidecar 的共享鉴权密钥。包含 updater 的 Compose 会由主程序在共享卷中自动生成 Token 文件,默认不需要手动配置。旧部署仍可用环境变量覆盖;Token 文件不得提交到仓库、截图、工单或公开备份说明。
### Linux 服务器完整部署步骤(Ubuntu / Debian / CentOS) ### Linux 服务器完整部署步骤(Ubuntu / Debian / CentOS)
@@ -113,12 +120,12 @@ curl -fsSL https://get.docker.com | sh
sudo systemctl enable --now docker sudo systemctl enable --now docker
# 1. 解压源码包并进入目录 # 1. 解压源码包并进入目录
unzip havline-1.1.0-src.zip -d havline && cd havline unzip havline-1.9.0-src.zip -d havline && cd havline
# 2. 构建镜像(约 3-10 分钟,取决于网络与机器性能) # 2. 构建镜像(约 3-10 分钟,取决于网络与机器性能)
docker compose build docker compose build
# 3. 生成密钥并启动(Bridge 模式,映射 6893/18080/9443) # 3. 可选:生成会话密钥并启动(Bridge 模式,映射 6893/18080/9443)
echo "HAVLINE_SESSION_SECRET=$(openssl rand -hex 32)" > .env echo "HAVLINE_SESSION_SECRET=$(openssl rand -hex 32)" > .env
docker compose -f docker-compose.hub.yml up -d docker compose -f docker-compose.hub.yml up -d
@@ -143,7 +150,7 @@ curl -I http://127.0.0.1:6893/login # 应返回 200
```bash ```bash
# 1. 解压 # 1. 解压
tar xzf havline-1.1.0-linux-amd64.tar.gz -C havline && cd havline tar xzf havline-1.9.0-linux-amd64.tar.gz -C havline && cd havline
# 2. 一键安装(自动建 havline 系统用户、装到 /opt/havline、生成随机密钥、注册 systemd 开机自启) # 2. 一键安装(自动建 havline 系统用户、装到 /opt/havline、生成随机密钥、注册 systemd 开机自启)
sudo bash install.sh sudo bash install.sh
@@ -169,7 +176,7 @@ journalctl -u havline | grep -i password # 取 admin 初始密码
**手动安装**(不用脚本):按包内 `README.txt` 要点——建 `havline` 系统用户、复制二进制与 migrations 到 `/opt/havline`、`EnvironmentFile` 写入随机密钥、`systemctl enable --now 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 启动。 > 镜像包(`havline-1.9.0-image.tar.gz`)来源:在任意装了 Docker 的机器上对源码包执行 `docker compose build` 后 `docker save havline:local | gzip > havline-1.9.0-image.tar.gz`;目标机 `docker load < havline-1.9.0-image.tar.gz` 后按方式 C 启动。
### 飞牛 fnOS 部署详解(图形界面,不敲命令) ### 飞牛 fnOS 部署详解(图形界面,不敲命令)
@@ -177,7 +184,7 @@ journalctl -u havline | grep -i password # 取 admin 初始密码
**方式一:导入现成镜像(推荐,5 分钟)** **方式一:导入现成镜像(推荐,5 分钟)**
1. **上传文件**:把 `havline-1.1.0-image.tar.gz` 通过文件管理 / SMB 上传到 NAS,如共享目录 `docker/havline/`; 1. **上传文件**:把 `havline-1.9.0-image.tar.gz` 通过文件管理 / SMB 上传到 NAS,如共享目录 `docker/havline/`;
2. **导入镜像**:打开 Docker 应用 → 镜像 → 导入(从文件加载)→ 选择该 tar.gz → 等待镜像列表出现 `havline:local`(若界面只接受 .tar,先在电脑上解压出内层的 .tar 再导入); 2. **导入镜像**:打开 Docker 应用 → 镜像 → 导入(从文件加载)→ 选择该 tar.gz → 等待镜像列表出现 `havline:local`(若界面只接受 .tar,先在电脑上解压出内层的 .tar 再导入);
3. **创建容器**:容器 → 创建: 3. **创建容器**:容器 → 创建:
@@ -187,7 +194,7 @@ journalctl -u havline | grep -i password # 取 admin 初始密码
| 镜像 | `havline:local` | | 镜像 | `havline:local` |
| 网络 | **host**(飞牛 80/443 被系统占用,host + 高位端口最省事;UI 不支持 host 时选 Bridge 并映射 6893/18080/9443) | | 网络 | **host**(飞牛 80/443 被系统占用,host + 高位端口最省事;UI 不支持 host 时选 Bridge 并映射 6893/18080/9443) |
| 存储挂载 | NAS 建目录(如 `/vol1/…/docker/havline/data`)→ 映射到容器 `/data`(读写) | | 存储挂载 | 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` | | 环境变量 | `HAVLINE_DATA_DIR=/data`、`HAVLINE_LISTEN=:6893`、`HAVLINE_NGINX_HTTP_PORT=18080`、`HAVLINE_NGINX_HTTPS_PORT=9443`;`HAVLINE_SESSION_SECRET` 可留空自动生成 |
| 重启策略 | 自动重启 / unless-stopped(开机自启) | | 重启策略 | 自动重启 / unless-stopped(开机自启) |
4. **启动并取初始密码**:容器详情 → 日志,搜 `password`;浏览器打开 `http://<NAS-IP>:6893` 登录。 4. **启动并取初始密码**:容器详情 → 日志,搜 `password`;浏览器打开 `http://<NAS-IP>:6893` 登录。
@@ -196,13 +203,13 @@ journalctl -u havline | grep -i password # 取 admin 初始密码
1. 把源码包 zip 上传到 NAS 并解压(文件管理支持解压,或电脑解压后整目录上传); 1. 把源码包 zip 上传到 NAS 并解压(文件管理支持解压,或电脑解压后整目录上传);
2. Docker 应用 → 项目(Compose)→ 新建:指定项目目录为解压后的源码目录、使用 `docker-compose.host.yml`(或直接把该文件内容粘贴到 Compose 编辑器); 2. Docker 应用 → 项目(Compose)→ 新建:指定项目目录为解压后的源码目录、使用 `docker-compose.host.yml`(或直接把该文件内容粘贴到 Compose 编辑器);
3. 在源码目录同层放一个 `.env` 文件,内容一行:`HAVLINE_SESSION_SECRET=随机串`; 3. 在源码目录同层放一个 `.env` 文件;host 编排不需要 updater Token,`HAVLINE_SESSION_SECRET` 可留空,应用会在数据目录生成并持久化;
4. 构建并启动。⚠️ NAS 上构建耗时较长(前端编译),低配机器建议改用方式一; 4. 构建并启动。⚠️ NAS 上构建耗时较长(前端编译),低配机器建议改用方式一;
5. 后续升级:上传新源码覆盖 → 项目重新构建即可,数据在卷里不受影响。 5. 后续升级:上传新源码覆盖 → 项目重新构建即可,数据在卷里不受影响。
**Compose 构建报 401(docker.fnnas.com)**:fnOS 的 Docker 设置里默认配了自家加速地址,但它对 `library/` 官方镜像拒绝匿名拉取。三种解法任选: **Compose 构建报 401(docker.fnnas.com)**:fnOS 的 Docker 设置里默认配了自家加速地址,但它对 `library/` 官方镜像拒绝匿名拉取。三种解法任选:
1. **改用镜像包(最省事)**:跳过构建,按方式一导入 `havline-1.1.0-image.tar.gz`; 1. **改用镜像包(最省事)**:跳过构建,按方式一导入 `havline-1.9.0-image.tar.gz`;
2. **覆盖基础镜像源**:在源码目录的 `.env` 里加三行(已有 HAVLINE_SESSION_SECRET 就追加在下面): 2. **覆盖基础镜像源**:在源码目录的 `.env` 里加三行(已有 HAVLINE_SESSION_SECRET 就追加在下面):
``` ```
NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine
@@ -224,10 +231,13 @@ journalctl -u havline | grep -i password # 取 admin 初始密码
## 4. 首次登录与安全配置 ## 4. 首次登录与安全配置
> `docker-compose.host.yml` 不包含 updater sidecar;`docker-compose.yml` 和 `docker-compose.hub.yml` 会由主程序自动生成共享 Token。不要把 host 模式的“自动生成会话密钥”和 updater 的“自动生成共享 Token”混为一谈。
1. 启动后查看初始密码:`docker logs havline | grep -i password`(自动生成的 admin 初始密码会打印在日志里); 1. 启动后查看初始密码:`docker logs havline | grep -i password`(自动生成的 admin 初始密码会打印在日志里);
2. 浏览器打开 `http://<NAS-IP>:6893/login`,用 `admin` + 初始密码登录; 2. 浏览器打开 `http://<NAS-IP>:6893/login`,用 `admin` + 初始密码登录;
3. **立即**在「设置 → 安全」修改管理员密码; 3. **立即**在「设置 → 安全」修改管理员密码;
4. 确认「设置 → 常规」中 `HAVLINE_SESSION_SECRET` 已改为非默认值(compose 文件中已传入则跳过); 4. 确认数据目录中存在 `session_secret`,并将其与数据库、证书和配置一起纳入备份;不要在已有数据后更换它;
5. 建议在「设置 → 通知」配置一种告警方式(邮件 / Webhook / Telegram),DDNS 失败、证书续签失败、登录异常等事件会推送。 5. 建议在「设置 → 通知」配置一种告警方式(邮件 / Webhook / Telegram),DDNS 失败、证书续签失败、登录异常等事件会推送。
## 5. 反向代理与 HTTPS 证书 ## 5. 反向代理与 HTTPS 证书
@@ -259,7 +269,102 @@ DDNS 页 → 添加任务:选服务商 → 填 API 凭证(可「测试连接
3. **添加穿透规则**:8 种类型按需选——tcp/udp 填远程端口;http/https/tcpmux 填自定义域名(走 frps vhost 端口);stcp/sudp/xtcp 凭 secretKey(无公网监听); 3. **添加穿透规则**:8 种类型按需选——tcp/udp 填远程端口;http/https/tcpmux 填自定义域名(走 frps vhost 端口);stcp/sudp/xtcp 凭 secretKey(无公网监听);
4. **应用并验证**:「应用配置」→「校验并应用配置」;服务端「详情」看五项诊断(TCP 可达 / frpc 登录 / 代理可达 / DNS 解析 / 归属地)与独立日志。 4. **应用并验证**:「应用配置」→「校验并应用配置」;服务端「详情」看五项诊断(TCP 可达 / frpc 登录 / 代理可达 / DNS 解析 / 归属地)与独立日志。
## 8. 升级与回滚 ## 8. 数据保护、升级与回滚
### 8.1 敏感数据保护边界
Havline 的持久化数据不是只有 SQLite。生产环境应把整个 `/data` 数据目录视为高敏感资产,并限制宿主机目录或 Docker volume 的读权限。以下内容不得提交到 Git、写入公开日志或上传到公开工单:
| 数据 | 位置或来源 | 保护要求 |
| --- | --- | --- |
| 管理员、会话、代理、DDNS、FRP、Cloudflare 和健康记录 | `/data/havline.db` | 限制文件权限;不要在服务运行时直接编辑;备份需加密并限制访问 |
| 会话签名与凭据加密主密钥 | `/data/session_secret` 或 `HAVLINE_SESSION_SECRET` | 与数据库一起备份;已有数据后不可随意更换;文件权限应为 `0600` |
| ACME 证书和私钥 | `/data/certs/` | 私钥按密钥材料保护;备份和迁移必须保密 |
| Nginx、FRP、Cloudflare 配置 | `/data/nginx/`、`/data/frp/` 及 Cloudflare 数据目录 | 配置可能包含域名、Token 引用和内部地址,不应公开 |
| API Token、FRP Token、DNS/SMTP/Webhook 凭据 | 数据库加密字段及备份归档 | 依赖会话密钥解密;不要只恢复数据库而遗漏 `session_secret` |
| updater 共享鉴权 Token | 共享卷中的 `/run/havline-updater/updater.token` | 只给主程序和 sidecar;旧部署可用 `HAVLINE_UPDATER_TOKEN` 覆盖 |
| Docker Socket | updater sidecar 的宿主机挂载 | 等同高权限 Docker 控制权;仅允许受信任管理员操作宿主机 |
推荐备份整个数据目录,并在服务停止或应用备份流程完成后复制,避免 SQLite 和配置文件处于不一致状态:
```bash
# 先确认当前 Compose 项目和数据卷名称;不要盲目使用旧卷名
docker compose -f docker-compose.hub.yml stop havline
docker volume ls | grep havline
docker run --rm \
-v havline_havline-data:/data:ro \
-v "$PWD":/backup \
alpine tar czf /backup/havline-data-$(date +%F).tgz -C /data .
docker compose -f docker-compose.hub.yml start havline
```
如果使用 `HAVLINE_DATA_PATH` 绑定目录,应备份该绑定目录而不是假设命名卷仍存在。备份文件本身应存放在受限目录,最好再进行离线加密;恢复前先停止服务,恢复后检查 `session_secret`、数据库迁移、证书权限和 Nginx/FRP 配置。
### 8.2 updater sidecar 的安全边界与验证
`docker-compose.yml` 和 `docker-compose.hub.yml` 中,主程序只挂载共享 Unix Socket,不挂载 Docker Socket;只有 updater sidecar 挂载 `${DOCKER_SOCK:-/var/run/docker.sock}`。Socket 目录通过 `havline-updater-sock` 共享,文件权限为 `0660`,HTTP 接口还必须携带 `X-Havline-Updater-Token`,Token 由 `.env` 注入两侧。sidecar 缺少 Token 时拒绝启动。
sidecar 的 Compose 项目目录边界如下:
- `/workspace` 只映射 `${HAVLINE_UPDATE_HOST_DIR:-.}`;
- `HAVLINE_UPDATE_PROJECT_DIR` 固定为容器内 `/workspace`;
- Compose 文件名由编排固定为 `docker-compose.yml` 或 `docker-compose.hub.yml`;
- 服务名固定为 `havline`;
- git 模式仓库和分支固定为 `https://jayhub.top/mrlee/Havline.git` / `main`;
- updater 会读取现有容器的 Compose 项目标签,无法确认原项目时拒绝升级;
- 本地模式会拒绝带未提交改动的 Git 工作区;
- helper 模式只接受位于宿主机项目目录内的 Compose 文件,不能通过 `..` 越界。
部署后应执行以下检查:
```bash
# 1. 确认两个容器都在运行,且 updater 挂载了 Socket、主程序没有 Docker Socket
docker compose -f docker-compose.hub.yml ps
docker inspect havline-updater --format '{{json .Mounts}}'
docker inspect havline --format '{{json .Mounts}}'
# 2. 确认 Compose 项目、工作目录和配置文件标签来自同一个部署项目
docker inspect havline --format '{{ index .Config.Labels "com.docker.compose.project" }}'
docker inspect havline --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}'
docker inspect havline --format '{{ index .Config.Labels "com.docker.compose.project.config_files" }}'
# 3. 不要把 Token 打印到终端;只确认 Token 文件已生成
docker compose -f docker-compose.hub.yml exec havline test -s /run/havline-updater/updater.token && echo updater-token-ready
# 4. 从主程序页面检查 updater status;手工请求 Unix Socket 无 Token 应返回 401
docker compose -f docker-compose.hub.yml logs --tail 100 havline-updater
```
### 8.3 升级、健康检查与回滚事实
升级流程是:读取远端 `VERSION` → 记录当前镜像 → 构建目标镜像 → 只重建 `havline` → 在 `HAVLINE_UPDATE_APP_URL` 上轮询 `/api/version` → 成功才标记完成。当前实现的自动回滚触发条件是“容器重建成功,但新版本在健康检查超时内未返回目标版本”;回滚使用升级前记录的镜像 tag。
以下情况**不会**由当前 updater 自动恢复:
- 构建失败;
- Compose 重建失败;
- updater sidecar 自己退出、Token 错误或 Docker Socket 不可用;
- 升级前已经没有可识别的旧镜像 tag;
- 数据库迁移已经向前执行后,旧版本代码不兼容新结构。
因此升级前必须保留整目录备份和旧镜像 tag:
```bash
# 记录可回滚镜像,不要只记录 VERSION 文本
docker image inspect "havline:${HAVLINE_VERSION:-local}" --format '{{.RepoTags}}'
docker images havline
# hub 模式从固定 main 分支构建;先备份,再执行升级
docker compose -f docker-compose.hub.yml up -d --build
docker compose -f docker-compose.hub.yml logs --tail 200 havline-updater
curl -fsS http://127.0.0.1:6893/api/version
# 自动回滚未成功时,使用实际存在的旧 tag 手动重建
HAVLINE_VERSION=<旧版本> docker compose -f docker-compose.hub.yml up -d --no-deps --force-recreate havline
```
异常重启由两个服务的 `restart: unless-stopped` 覆盖:主程序或 updater 进程退出后 Docker 会尝试重启。但这不等于升级成功;必须同时检查容器状态、日志、`/api/version`、数据库迁移和数据目录内容。
### 升级 ### 升级
@@ -289,12 +394,13 @@ docker compose -f docker-compose.host.yml up -d --build
| 现象 | 处理 | | 现象 | 处理 |
| --- | --- | | --- | --- |
| 容器起来但 6893 打不开 | `docker logs havline` 看启动报错;`docker port havline`(Bridge 模式)确认映射 | | 容器起来但 6893 打不开 | `docker logs havline` 看启动报错;`docker port havline`(Bridge 模式)确认映射 |
| 提示「会话加密密钥」警告 | 未设置 `HAVLINE_SESSION_SECRET`,compose 传入即可 | | updater 启动失败或一直等待 Token | 检查主程序日志,以及共享卷中的 `/run/havline-updater/updater.token` 是否存在;旧部署可用 `HAVLINE_UPDATER_TOKEN` 覆盖 |
| 反代 502 | 目标内网应用未启动或地址填错;日志中心看 Nginx error 日志 | | 反代 502 | 目标内网应用未启动或地址填错;日志中心看 Nginx error 日志 |
| 证书申请失败 | DDNS 凭证是否有效、域名 DNS 是否已生效、ACME 邮箱是否已设置 | | 证书申请失败 | DDNS 凭证是否有效、域名 DNS 是否已生效、ACME 邮箱是否已设置 |
| frpc 连不上 frps | 两端 Token/端口不一致;用服务端「详情 → frps.toml」重新生成对齐 | | frpc 连不上 frps | 两端 Token/端口不一致;用服务端「详情 → frps.toml」重新生成对齐 |
| 穿透流量显示 0 | frps 未配置 webServer 或 Havline 服务端表单未填管理地址(非必须功能) | | 穿透流量显示 0 | frps 未配置 webServer 或 Havline 服务端表单未填管理地址(非必须功能) |
| 页面改动未生效 | 确认访问的是容器端口而非开发服务器;`docker compose up -d --build` 重建 | | 页面改动未生效 | 确认访问的是容器端口而非开发服务器;`docker compose up -d --build` 重建 |
| 升级失败后服务未恢复 | 先查看 `docker logs havline-updater`;只有重建成功但新版本健康检查失败时才会自动回滚,构建失败或 updater 自身异常需要人工按镜像 tag 回滚 |
## 10. 卸载 ## 10. 卸载
+5 -2
View File
@@ -1,13 +1,14 @@
# Havline 文档索引 # Havline 文档索引
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
> 最近更新:2026-10-01(v1.9.0) > 最近更新:2026-10-03(v1.9.0)
## 方案与设计(本目录) ## 方案与设计(本目录)
| 文件 | 主题 | 状态 | | 文件 | 主题 | 状态 |
| --- | --- | --- | | --- | --- | --- |
| `project-analysis-2026-09-27.md` | 全项目详细分析(v1.2.19):架构、模块、数据、API、前端、链路、风险与改进路线 | 当前版本快照 | | `project-analysis-2026-10-03.md` | 当前目录结构与实现分析(v1.9.0):架构、模块、数据、API、前端、部署、文档偏差与风险 | 当前版本快照 |
| `project-analysis-2026-09-27.md` | 全项目详细分析(v1.2.19):架构、模块、数据、API、前端、链路、风险与改进路线 | 历史版本快照 |
| `improvement-roadmap-2026-09-27.md` | P0-P3 改进路线实施方案:逐项改法、测试、验收、依赖、发布与回滚 | P0-1、P0-3、P0-4 已完成,其余待实施 | | `improvement-roadmap-2026-09-27.md` | P0-P3 改进路线实施方案:逐项改法、测试、验收、依赖、发布与回滚 | P0-1、P0-3、P0-4 已完成,其余待实施 |
| `system-upgrade.md` | Docker Compose 一键升级:sidecar 架构、首次部署、安全边界、回滚、故障排查与验证页 | 已实施(v1.3.8) | | `system-upgrade.md` | Docker Compose 一键升级:sidecar 架构、首次部署、安全边界、回滚、故障排查与验证页 | 已实施(v1.3.8) |
| `agent-listen-strategy-plan.md` | Agent 默认监听策略:回环监听、SSH 隧道优先、HTTPS pin 可选与自动迁移方案 | 阶段 1-4 已实施(v1.4.0);2.0.0 兼容清理待后续 | | `agent-listen-strategy-plan.md` | Agent 默认监听策略:回环监听、SSH 隧道优先、HTTPS pin 可选与自动迁移方案 | 阶段 1-4 已实施(v1.4.0);2.0.0 兼容清理待后续 |
@@ -19,6 +20,8 @@
| `fnos-app-packaging-plan.md` | 飞牛 fnOS 应用打包方案:官方文档依据、Docker 模板、向导、权限、数据迁移、升级与发布流程 | 方案待实施 | | `fnos-app-packaging-plan.md` | 飞牛 fnOS 应用打包方案:官方文档依据、Docker 模板、向导、权限、数据迁移、升级与发布流程 | 方案待实施 |
| `project-audit-2026-09-24.md` | 全项目审查:P0/P1/P2 分级,含精确行号与逐条修法 | **进行中**——P0 已随 v1.2.7 完成,P1 实施中 | | `project-audit-2026-09-24.md` | 全项目审查:P0/P1/P2 分级,含精确行号与逐条修法 | **进行中**——P0 已随 v1.2.7 完成,P1 实施中 |
| `frp-server-list-merge-plan.md` | FRP 服务端列表并入「公网服务端」页(方案 A,四步) | 步 1/2 已完成(v1.2.6);步 3 文案收尾、步 4 死样式清理未做 | | `frp-server-list-merge-plan.md` | FRP 服务端列表并入「公网服务端」页(方案 A,四步) | 步 1/2 已完成(v1.2.6);步 3 文案收尾、步 4 死样式清理未做 |
| `agent-standalone-project-analysis.md` | havline-agent 独立项目化分析:现状盘点、耦合面、同类项目对标、三种定位方案、能力扩展与安全模型 | 方向讨论稿,待决策 |
| `agent-server-automation-vision.md` | havline-agent 场景化蓝图:网站部署、软件安装、网络运维等场景地图,Resource/Action/Recipe 三层能力模型、Scope Guard 与落地路线 | 方向讨论稿,待决策 |
| `agent-capability-plan.md` | havline-agent 能力扩展(P0/P1/P2/P3) | 已实施(agent 0.11.0);P3 三项不做 | | `agent-capability-plan.md` | havline-agent 能力扩展(P0/P1/P2/P3) | 已实施(agent 0.11.0);P3 三项不做 |
| `agent-view-design.md` | 公网 Agent 页设计方案(Pass 1/2 流程) | 部分过期:顶部下拉已被左列表 + 右详情取代 | | `agent-view-design.md` | 公网 Agent 页设计方案(Pass 1/2 流程) | 部分过期:顶部下拉已被左列表 + 右详情取代 |
| `agent-view-design-v2.md` | 公网 Agent 页设计方案 v2 | 部分过期:同上 | | `agent-view-design-v2.md` | 公网 Agent 页设计方案 v2 | 部分过期:同上 |
+424
View File
@@ -0,0 +1,424 @@
# havline-agent 场景化蓝图:从入口治理到服务器执行层
> 分析日期:2026-10-03
> 前置文档:`agent-standalone-project-analysis.md`(现状盘点与独立化方案)
> 本文主题:把 agent 的控制能力扩展到网站部署、软件安装、网络运维等更广场景时的产品定位、能力模型与落地路径
> 文档性质:方向讨论稿,不构成实施承诺
---
## 一、目标重述
要做的不是「再做一个服务器面板」,而是:
> **客户端(本地 CLI / Web / IDE 插件 / AI Agent)描述需求,服务器上的 agent 在受控边界内把它变成现实,并保证可校验、可回滚、可审计。**
三个关键词决定了这个方向能不能成立:
| 关键词 | 含义 | 为什么关键 |
| --- | --- | --- |
| **受控边界** | agent 只做被显式允许的事,其余一律拒绝 | 否则等价于开放远程 root shell,任何安全评审都过不了 |
| **可校验** | 变更前后都有事实依据(语法检查、健康探测、diff) | 决定用户敢不敢让它碰生产机器 |
| **可回滚** | 每次变更留下恢复点,失败能退回去 | 这是与 Ansible 式「一次性执行脚本」最本质的区别 |
现有 agent 已经在**入口配置**这一个领域做到了这三点(vhost 版本化回滚、`nginx -t` 校验、写前备份)。本文讨论的是把同一套方法推广到更多领域。
---
## 二、场景地图
按领域列出可覆盖的场景,并标注每个场景需要的「能力原语」、风险等级和现有 agent 的覆盖情况。
### 2.1 网站部署
| 场景 | 需要的原语 | 风险 | 可回滚 | 现状 |
| --- | --- | --- | --- | --- |
| 静态站 / SPA 部署 | 发布目录创建、文件同步、Nginx 站点 | 低 | 是(切目录) | 部分:有 Nginx vhost,无文件同步 |
| Node / PHP / Python 应用 | 运行时安装、进程托管、反代 | 中 | 部分(代码可回滚,运行时可多版本共存) | 无 |
| WordPress 等成品站 | 软件包、数据库、站点配置的组合配方 | 中 | 部分(数据库需单独备份) | 无 |
| Docker Compose 应用 | Compose 文件下发、镜像拉取、启停 | 中高 | 是(保留旧 compose + 镜像) | 无 |
| 多站点共存与端口/资源隔离 | 站点注册表、冲突检测 | 中 | 是 | 部分:单域名 vhost 已隔离 |
> 关键判断:**「网站部署」不应被实现为「执行一段部署脚本」**,而应实现为「站点资源 + 运行时资源 + 证书资源 + 反代资源」的声明式组合。这样每个部分都能单独 diff 和回滚。
### 2.2 环境初始化与软件安装
| 场景 | 需要的原语 | 风险 | 可回滚 | 现状 |
| --- | --- | --- | --- | --- |
| Web 服务器 | `package.install(nginx)` + 启动 | 中 | 可卸载,需谨慎 | 已具备(`POST /api/v1/nginx/install`) |
| 运行时(Node / Python / Go / Java) | 版本化安装、PATH 管理、多版本共存 | 中 | 是(多版本目录) | 无 |
| 数据库 / 缓存 | 安装、初始化、账号、基础加固 | 高 | 部分(数据不可回滚) | 无 |
| Docker / Compose | 安装、仓库配置、daemon 参数 | 高 | 部分 | 无 |
| 系统调优(文件数、内核参数、swap) | 配置文件资源 + 生效动作 | 中高 | 是(配置可回滚,重启才完全生效) | 无 |
| 时区 / 语言 / 主机名等基础设置 | 系统资源 | 低 | 是 | 无 |
> 关键判断:**「安装软件」是供应链动作,必须有来源白名单与校验**。现有 agent 对 frps 已做到(官方 checksum + 下载源白名单),这个模式应作为所有安装类动作的标准:**固定来源、固定校验、固定安装路径**,而不是「执行用户给的安装命令」。
### 2.3 反向代理与证书(现有强项)
| 场景 | 现状 |
| --- | --- |
| 域名 vhost 部署 / 删除 / 版本回滚 | 已具备 |
| 反代部署设置(白名单/黑名单、Basic Auth、限流、连接数、体积、超时、安全头、TLS1.3) | 已具备 |
| 证书推送与托管 | 已具备 |
| HTTPS 直连(证书 pin + 自检) | 已具备 |
| 中国 IP 段下发 | 已具备 |
| 证书到期巡检与自动续期编排 | 部分(仅回传 `not_after`,靠中央驱动) |
| 多 backend(Caddy / Traefik / HAProxy) | 无 |
### 2.4 网络与运维
| 场景 | 需要的原语 | 风险 | 现状 |
| --- | --- | --- | --- |
| 防火墙端口放行 | `firewall.allow(port)` | 中 | 已具备(仅 ufw / firewalld 自动,nftables/iptables 明确拒绝自动修改) |
| 端口占用与监听排查 | 只读探测 | 低 | 已具备(80/443/7000/8080) |
| DNS 解析诊断 | 只读查询 | 低 | 部分(frps 诊断里有 DNS 检查) |
| frps / frpc 编排 | 安装、配置、启停 | 中 | 已具备 |
| 内网互联 / VPN(WireGuard、Tailscale) | 配置资源 + 启停 | 中高 | 无 |
| 抓包 / 连通性诊断 | 只读命令 + 输出脱敏 | 低中 | 无 |
| 系统时间同步 / NTP | 配置资源 | 低 | 无 |
> 关键判断:**只读诊断能力应该优先做**——它风险最低、对运维的实际价值最高,而且能显著提升「敢把 agent 装到生产机」的意愿。
### 2.5 数据、备份与迁移
| 场景 | 需要的原语 | 风险 | 现状 |
| --- | --- | --- | --- |
| 站点目录 / 配置打包备份 | 打包动作 + 外送 | 低 | 无 |
| 数据库备份与校验 | 备份动作 + 完整性校验 | 中 | 无 |
| 定时备份策略 | 计划任务资源 | 中 | 无 |
| 跨机迁移(导出 → 导入) | 备份 + 恢复配方 | 高 | 无 |
| 配置快照与差异比对 | 已有的版本机制可复用 | 低 | 部分(Nginx / 证书) |
> 关键判断:**备份是「回滚」能力的前提**。对一个声称可回滚的产品,备份应该是最早补齐的能力之一,而且它本身风险低、收益高。
### 2.6 服务生命周期与观测
| 场景 | 需要的原语 | 风险 | 现状 |
| --- | --- | --- | --- |
| systemd 服务启停 / 状态(白名单 unit) | `service.action(unit)` | 中高 | 部分(仅 frps 硬编码) |
| 服务日志读取 | 只读日志 | 低 | 部分(Nginx、frps) |
| 主机指标 | 只读采集 | 低 | 已具备 |
| 结构化日志外送 / 事件流 | 推送 | 低中 | 无 |
| Prometheus `/metrics` | 只读 | 低 | 无 |
| 自定义健康探针(HTTP / TCP) | 只读探测 | 低 | 部分(vhost 回源自检) |
> 关键判断:**systemd unit 白名单是危险与价值的临界点**。开放任意 unit 启停 ≈ 半只脚踩进 root shell;只开放配置文件里声明的 unit 则可以接受。
### 2.7 安全加固
| 场景 | 需要的原语 | 风险 | 现状 |
| --- | --- | --- | --- |
| SSH 配置加固(禁密码、改端口、限用户) | 配置资源 + 校验 + 重载 | **高风险** | 无 |
| fail2ban / 登录防护 | 安装 + 配置资源 | 中 | 无 |
| 用户与密钥管理 | 用户资源、authorized_keys 资源 | 高 | 无 |
| 端口暴露面巡检 | 只读 | 低 | 部分 |
| 证书 / 密钥过期巡检 | 只读 | 低 | 部分 |
> 关键判断:**SSH 加固必须在协议层强制「先校验再生效 + 保底会话」**,因为配置写错会直接把运维人员锁在门外。这是典型的「可回滚但代价极高」操作,需要单独设计安全的执行窗口。
### 2.8 开发工作流
| 场景 | 需要的原语 | 风险 | 现状 |
| --- | --- | --- | --- |
| 拉取代码 / 构建 / 发布 | Git 动作、构建配方 | 中 | 无 |
| 环境变量与密钥注入 | 文件资源(0600) | 中高 | 无 |
| 发布与回滚(保留 N 个版本) | 发布资源 + 目录切换 | 中 | 无 |
| CI 触发(webhook 入站) | 入站端点 + 审批 | 中 | 无 |
| 运行日志聚合查看 | 只读 | 低 | 部分 |
---
## 三、核心设计:三层能力模型
这是整个方案里最重要的一节。要把「更多场景」做出来又不变成远程 shell,必须把能力分成三层,**层与层之间有明确的信任与校验规则**。
### 第一层:Resource(声明式资源)—— 主干,优先做
**特征**:描述「期望状态」,而不是「要执行的命令」。
```yaml
# 概念示例
kind: nginx.route
metadata: { domain: app.example.com }
spec:
upstream: http://192.168.1.20:8080
tls: { certificate: app.example.com, min_version: "1.2" }
access: { allow_ips: ["1.2.3.0/24"], basic_auth: { user: ops } }
limits: { rate: 20, burst: 40, max_body: 50m }
```
- 必须**幂等**:重复应用同一份声明不产生变化;
- 必须能 **plan**(输出将要发生的差异);
- 必须能 **apply**(应用差异)与 **rollback**(退回上一个已知良好状态);
- 实现上就是把现有 `putRoute` 的事务化逻辑(备份 → 写入 → 校验 → 回滚)抽象成通用流程。
**适用**:vhost、证书、站点、systemd unit 文件、防火墙规则、配置文件片段、用户、计划任务。
### 第二层:Action(受控动作)—— 补充,参数化白名单
**特征**:单一、明确、可审计的动词,参数有 schema 校验,不是自由命令。
```text
nginx.reload
nginx.install { distro }
package.install { name, version? } # name 必须来自允许清单
service.restart { unit } # unit 必须来自声明清单
backup.run { paths, dest }
cert.renew { domain }
firewall.allow { port, protocol }
```
- 参数必须是**结构化字段**,不能是命令字符串;
- 每个动作声明:风险等级、是否可回滚、是否需要审批、需要的 capability;
- 现有 agent 的动作(`nginx/install`、`frps/install`、`frps/action`、`firewall/allow`、`nginx/reload`)就是这个层级的雏形,只需补上 registry 与 schema。
### 第三层:Recipe(配方)—— 覆盖泛化需求的关键
**特征**:由 Resource 与 Action 组成的步骤化编排,声明式 YAML,可参数化、可 dry-run、可审批。
```yaml
# 概念示例:部署一个 Node 应用
name: deploy-node-app
params:
domain: { type: string, required: true }
git_repo: { type: string, required: true }
node_version: { type: string, default: "20" }
steps:
- resource: runtime.node { version: "{{ node_version }}" }
- resource: site.release { name: app, source: git, repo: "{{ git_repo }}" , keep: 3 }
- action: service.restart { unit: app.service }
- resource: nginx.route { domain: "{{ domain }}", upstream: http://127.0.0.1:3000 }
- resource: certificate { domain: "{{ domain }}" }
verify:
- probe: http.get { url: "https://{{ domain }}/healthz", expect: 200 }
rollback:
- resource: site.release { name: app, action: previous }
- resource: nginx.route { domain: "{{ domain }}", action: previous }
```
为什么这一层是关键:
- **它把「任意需求」收敛成「可枚举的配方」**。用户要的是「部署网站」「装软件」,不是「执行我的脚本」;
- 配方可被**审核、签名、版本化、分享**,形成可积累的资产(配方库 / 市场),这正是 Ansible role、Terraform module 的价值所在;
- 每个步骤落到前两层,因此安全边界、幂等、回滚能力天然继承。
**明确不做**:不提供「自定义 shell 步骤」入口,或只提供需要独立开关 + 高危审批 + 明确标注「不可回滚」的受限通道。
### 三层的信任规则
| 层 | 是否需要审批 | 可回滚性 | 需要的 capability |
| --- | --- | --- | --- |
| Resource | 默认不需要(幂等、可回滚) | 高 | `resource:<type>:write` |
| Action | 中高风险动作需要 | 视动作 | `action:<name>:execute`;高危单独授予 |
| Recipe | 首次运行需要(可记住授权) | 取决于步骤 | `recipe:<name>:run` + 各步骤能力 |
---
## 四、配套机制(决定这个产品能否被信任)
### 4.1 Scope Guard(作用域闸门)
在 agent 侧硬性限制可触及的范围,配置化、默认最小:
| 维度 | 限制方式 |
| --- | --- |
| 文件系统 | 只允许声明目录(`/etc/nginx/...`、`/var/www/...`),路径规范化后校验前缀,拒绝 `..` 与符号链接逃逸 |
| systemd | 只允许声明 unit 列表(而不是任意 unit) |
| 软件包 | 只允许声明包名清单 + 固定来源仓库 |
| 端口 | 只允许放行声明端口,且拒绝危险端口范围(如 22 变更需显式开关) |
| 执行 | 只允许 registry 中的动作;无 shell 透传 |
| 网络出站 | 只允许白名单目标(软件源、Git 主机、ACME) |
### 4.2 能力分级 Token
把当前的单一全权 Bearer Token 拆成能力集:
```text
route:read route:write
cert:read cert:write
service:read service:write
package:install
firewall:write
host:read # metrics / logs
recipe:run
```
- 一个客户端只拿到它需要的能力;
- 高危能力(`package:install`、`ssh:write`)单独签发并支持撤销;
- 能力清单在 `GET /api/v1/capabilities` 中暴露,控制面据此决定 UI 显示什么。
### 4.3 变更事务
把现有 vhost 的逻辑抽象成通用事务:
```text
Plan → 计算差异(不落盘)
Snapshot→ 备份受影响文件与状态,记录回滚点
Validate→ 语法/语义校验(配置文件可 `-t`,其他类型需各自校验器)
Apply → 原子写入 + 重载/生效
Verify → 健康探测(HTTP / 端口 / 进程)
Commit → 记录审计事件
↓ 任一步失败
Rollback→ 恢复回滚点并重新校验
```
**关键**:每一步都要显式声明「是否可回滚」。不可回滚的步骤(安装软件包、数据库迁移、DNS 变更、SSH 配置)必须在 plan 阶段就向用户明确标注。
### 4.4 审计与事件流
每次变更产生结构化事件:操作者标识、能力、配方/资源、参数摘要(脱敏)、前后 diff、结果、回滚点、耗时。
- 本地保留最近 N 条,支持导出;
- 可推送 webhook / syslog / 对象存储;
- 这是企业交付的硬前提,也是「AI 运维」场景里唯一的责任追溯手段。
### 4.5 审批闸门
| 场景 | 闸门 |
| --- | --- |
| 高危动作(SSH 加固、用户变更、包卸载) | 需要一次性确认令牌(由客户端生成、用户输入) |
| 首次运行配方 | 需要显示完整 plan 并确认 |
| AI 发起的变更 | 协议层强制「提议 → 人确认 → 执行」,不允许 AI 直接持有高危能力 |
| 批量节点 | 需要分批 + 健康门槛 + 自动暂停 |
---
## 五、架构蓝图
```mermaid
flowchart TB
subgraph Client["客户端(本地)"]
CLI[CLI / plan-apply]
WEB[Web 控制台]
IDE[IDE 插件]
AI[AI 助手 / MCP]
end
subgraph Core["控制面(可选自托管)"]
PLAN[Plan 与 Diff]
INV[节点与能力清单]
AUDIT[(审计与状态)]
RECIPE[(配方库)]
end
subgraph Link["连接层"]
SSH[SSH 隧道]
TLS[HTTPS + 证书 pin]
REV[反向隧道(待做)]
end
subgraph Agent["havline-agent 执行引擎"]
API[API /api/v1 + 能力协商]
SCOPE[Scope Guard]
TX[事务引擎]
RES[Resource Providers\nnginx·cert·systemd·file·firewall·site·package]
ACT[Action Registry\n参数 schema + 风险等级]
RCP[Recipe Engine\n步骤·依赖·dry-run·rollback]
PROBE[Probe & Metrics]
LOG[(本地审计日志)]
end
CLI --> PLAN
WEB --> PLAN
IDE --> PLAN
AI --> PLAN
PLAN --> Link
RECIPE --> Link
Link --> API --> SCOPE --> TX
TX --> RES
TX --> ACT
RCP --> RES
RCP --> ACT
PROBE --> AUDIT
TX --> LOG
LOG --> AUDIT
```
设计要点:
1. **控制面可选**:agent 必须能在「只有一个 CLI,没有中央服务」的情况下被完整驱动。这样既降低了部署门槛,也让 agent 本身可被第三方集成。
2. **执行引擎在 agent 侧**:控制面只负责描述与编排,不直接触碰服务器。这是与「面板和服务器同机」的传统方案(宝塔、1Panel)最本质的结构差异——面板被攻破不直接等于服务器被攻破。
3. **Provider 可扩展**:新增领域(比如 Caddy、Docker、PostgreSQL)应该是新增 Provider,而不是改核心。
---
## 六、对标与差异化
| 项目 | 模型 | 与本方向的差异 |
| --- | --- | --- |
| Ansible | 无 agent,SSH 推 YAML playbook | 我们不要求目标机有 Python、不依赖 SSH 免密;提供**常驻 agent + 能力分级 + 事务回滚 + 状态查询**。Ansible 是「批量自动化」,我们更偏「交互式、少量节点、高风险操作的精确控制」 |
| SaltStack / Puppet / Chef | agent + 状态收敛 | 我们更轻(单二进制、无运行时依赖),且把「回滚点」作为一等公民,而不是只做收敛 |
| Terraform | 声明式 + plan/apply | 我们不做云资源,但**术语与体验可以对齐**(plan 先看差异),这对开发者是熟悉的肌肉记忆 |
| 宝塔 / 1Panel | 面板与服务器同机,全权 | 我们是**分离式**:本地客户端 + 服务器只装 agent;面板暴露面不落在生产机上;不绑定容器 |
| Coolify / Dokploy / Komodo | 中央 + 节点,聚焦应用部署 | 我们覆盖更宽(网络运维、系统配置、安全加固),且不要求 Docker |
| Portainer | 容器管理 | 不重叠,可并存 |
| frp-panel | 多节点 frp 控制台 | 我们在 frp 之上还有系统层与站点层 |
| Rundeck / AWX / Semaphore | 作业编排、审批、RBAC | 它们解决「编排与审批」,需要一个执行端;**agent 正好可以作为它们的执行端**(互补而非竞争) |
| Tailscale / Cloudflare Tunnel | 连接层 | 解决「怎么到达」,我们解决「到达之后做什么」;可作为连接层被复用 |
| MCP / AI Agent 生态 | AI 调用工具 | 我们提供带审批与回滚的执行端,正好补上 AI 运维最缺的那一环 |
**一句话差异化**:
> 别人给你「批量执行」或「全权面板」;我们给你**「在受控边界内、可先看计划、可回滚、可审计地改变服务器状态」**。
---
## 七、MVP 建议
如果只挑一条最短路径来验证这个方向,我建议这样切:
**验证目标**:证明「用配方描述一次网站部署,比写脚本更安全、更可回滚」,且用户愿意用。
**MVP 范围(建议 6–10 周)**
1. **协议层**:`/api/v1/capabilities`、能力分级 Token、审计事件、`plan/apply/rollback` 三段式;
2. **Resource Providers(3 个)**:`nginx.route`(已有,抽象化)、`certificate`(已有,抽象化)、`site.release`(新增:发布目录 + 保留 N 版 + 切换);
3. **Action Registry(4 个)**:`nginx.reload`、`nginx.install`、`service.restart`(白名单 unit)、`package.install`(白名单包名);
4. **Recipe Engine**:YAML 步骤、参数校验、dry-run、逐步骤回滚;
5. **内置配方(3 个)**:
- `deploy-static-site`:目录发布 + 证书 + 反代;
- `deploy-node-app`:运行时安装 + 发布 + systemd unit + 反代 + 证书;
- `harden-nginx`:安全头 + 限流 + 日志轮转;
6. **Scope Guard**:文件前缀、unit 白名单、包名白名单;
7. **客户端**:CLI(`plan` / `apply` / `rollback` / `audit`),Web 后续再接。
**不放进 MVP**:SSH 加固、用户管理、数据库、Docker 编排、多节点批量、SaaS 控制面。这些等 MVP 验证后被真实需求拉出来再做。
---
## 八、风险与反面意见
必须写在前面,避免自我说服:
1. **赛道重叠最严重**:Ansible 生态已经极其成熟,1Panel / 宝塔占据中文服务器的存量用户。如果不能清晰回答「为什么不用 Ansible」,这个方向会变成自娱自乐。我认为唯一站得住的答案是**「事务化 + 能力分级 + 交互式精确控制」**,而不是「更全的功能」。
2. **「更多场景」等于「更大攻击面」**:每加一个 Provider / Action,就多一处可能被滥用或被注入的表面。必须坚持「Scope Guard 先于 Provider」的开发顺序。
3. **回滚不是万能的**:装包、数据库、DNS、SSH 配置都可能不可逆。产品必须在 UI 和协议上都诚实地标注,否则一次失败就会失去用户信任。
4. **跨发行版适配成本被低估**:apt / dnf / yum / apk、systemd / openrc、不同 Nginx 布局(`sites-enabled` vs `conf.d`,宝塔 / 1Panel 自带 Nginx)——现有 `detectNginxConfDir` 已经踩过一轮坑,推广到更多软件时这个成本会成倍上升。
5. **长任务与交互**:编译、下载、数据库迁移都是分钟级甚至更久,需要任务队列、日志流、断线重连与幂等重入。现有 API 是同步的,这是必须解决的架构问题。
6. **版本矩阵**:agent 独立发版后,能力 × 发行版 × 后端软件的组合会迅速膨胀,必须靠 capability 协商而不是版本号判断。
---
## 九、落地路线
| 阶段 | 目标 | 关键产出 | 判断继续的条件 |
| --- | --- | --- | --- |
| S0 基础 | 把现有能力事务化、抽象化 | 三层模型落地、能力 Token、审计、plan/apply | 现有 Havline 场景不回归 |
| S1 单机 MVP | 证明配方比脚本好 | 3 Provider + 4 Action + Recipe Engine + 3 内置配方 + CLI | 真实用户用它部署过至少一个站点 |
| S2 扩展 | 覆盖更多开发者需求 | site.release 增强、运行时安装、环境变量注入、发布回滚、`/metrics` | 出现重复使用的配方(不是一次性尝鲜) |
| S3 运维面 | 吸引网络运维用户 | 只读诊断集、防火墙与端口、备份与恢复、systemd 白名单 | 有用户把它装在非 Havline 的机器上 |
| S4 编排 | 多节点与协作 | 配方库、审批流、批量与灰度、Web 控制台 | 出现真实多节点诉求 |
| S5 生态 | 被集成 | Terraform provider、MCP server、CI webhook | 外部调用方出现 |
**贯穿始终的一条纪律**:每个新能力都必须先回答三个问题——**Scope 是什么、能不能回滚、需要什么能力位**。答不上来就不做。
---
## 十、结论
- 这个方向的**技术可行性已经由现有 agent 证明**:vhost 已实现「备份 → 校验 → 应用 → 失败回滚 → 版本回滚」,把它抽象成通用事务,就能覆盖更多领域。
- 覆盖「网站部署、软件安装」的正确姿势不是**开放命令执行**,而是**Resource + Action + Recipe 三层模型**:声明式资源保证幂等与回滚,受控动作保证可审计,配方把泛化需求收敛成可审核、可复用的资产。
- 与 Ansible、1Panel、宝塔的差异化必须建立在**「事务化 + 能力分级 + 交互式精确控制」**上,而不是功能数量。如果只能说出一句卖点,应该是:**「先看计划,再改服务器,出错能退回去」**。
- 最短验证路径是 **S1 单机 MVP**:3 个 Provider、4 个 Action、1 个 Recipe Engine、3 个内置配方、1 个 CLI。跑通后再谈多节点与生态。
- 最大的长期风险不是技术,而是**信任**:一个能改服务器状态的工具,只要有一次「说能回滚结果没退回去」,就很难再被装到生产机上。回滚能力必须比新功能优先级更高。
+170
View File
@@ -0,0 +1,170 @@
# 公网服务端「添加 / 编辑」弹窗表单合理性分析
> 分析对象:`F:\Fonu-main\havline\web\src\views\AgentView.vue` 中的服务端弹窗(`showServerModal`)
> 重点:**「远程管理」标签**(`serverFormTab === 'remote'`,模板 464–540 行)
> 性质:静态代码审查(未运行、未修改),用于后续评审与改进。
> 结论先行:整体结构清晰、密文回显与插件提示较完善,但**「远程管理」标签的帮助栏与标签页不匹配、表单缺少任何校验、`enabled` 字段无 UI 入口**,属于需要优先修复的合理性问题。
---
## 一、分析对象与背景
- 该弹窗同时用于「添加服务端」和「编辑服务端」(`openServerModal`,1407 行),标题随 `editingServer` 切换。
- 表单分为三个标签页(tabbar,404–406 行):
1. `basic` 基础配置(410 行起):名称/地址/端口/认证(含 OIDC)/描述。
2. `advanced` 高级配置(542 行起):frpc 日志级别、传输协议、TLS、连接调优。
3. `remote` 远程管理(464 行起):frps 服务端参数、管理接口、公网 Agent 接入。
- 三块面板通过 `v-show` 切换,共用一个 `<n-form>`(409 行)和底部单个「保存」按钮(`saveServer`,1603 行)。
- 本次重点分析的「远程管理」标签包含 4 个区块:
- `frps vhost 与域名反代`(466–491)
- `TCP/UDP 端口分配`(493–500)
- `frps 管理接口`(502–521)
- `公网 Agent 管理`(523–540)
---
## 二、表单整体结构观察
| 观察点 | 现状 | 合理性判断 |
| --- | --- | --- |
| 标签顺序 | tabbar 顺序为 基础 / 高级 / 远程;DOM 面板顺序为 basic / remote / advanced(`v-show` 不影响显示,但维护易错位) | 基本可用,但 DOM 与 tab 顺序不一致,改动时易踩坑 |
| 表单校验 | `<n-form label-placement="top" class="proxy-modal__pane" @submit.prevent="saveServer">`,**无 `:model`、无 `:rules`** | ⚠️ 偏弱:`required` 仅显示星号,不产生校验 |
| 提交按钮 | 三页共用底部「保存」,只写数据库 | ⚠️ 与"远端需下发/重启才生效"的语义割裂 |
| 密文字段 | Token / OIDC Secret / Dashboard 密码 / SSH 密码密钥 / Agent Token 统一用 `ConfiguredSecretField` | ✅ 较好,编辑留空保持不变 |
| 危险操作 | 「清除管理密码」「清除证书/密钥/CA」均有独立按钮 | ✅ 较好 |
---
## 三、「远程管理」标签逐区块分析
### 3.1 frps vhost 与域名反代(466–491 行)
字段:HTTP 回源端口 `vhost_http_port`、HTTPS 回源端口 `vhost_https_port`、子域名根 `sub_domain_host`、tcpmux 监听端口 `tcpmux_http_connect_port`、自定义 404 页面 `custom_404_page`、强制 TLS `tls_force`、frps 写文件日志 `frps_log_to_file`、日志保留天数 `frps_log_max_days`。
合理之处:
- 对 80/443 冲突有专门告警(469 行),并解释"应由 Nginx 占用"。
- 提示了"修改后需下发并重启、重新部署规则"的必要性。
问题:
1. **本质是 frps 服务端配置,却归入"远程管理"**:`vhostHTTPPort/vhostHTTPSPort/subDomainHost/tcpmux` 是 frps.toml 的服务端参数,与"是否远程管理"无关;`tls_force`、`frps_log_*` 同样是 frps 服务端行为。标签命名与内容职责不匹配。
2. **端口冲突无校验**:只对 `==80/443` 告警,未校验 `vhost_http_port` 与 `vhost_https_port`、`server_port`(默认 7000)、`dashboard_port`、`tcpmux_http_connect_port` 之间的重复。
3. **`tls_force` 与高级标签的 `tls_enabled` 无联动**:类型注释已写明"frpc 必须开 transport.tls.enable"(types.ts 405 行),但两字段分属不同标签、互不校验,配置成 `tls_force=true` + `tls_enabled=false` 时连接会失败。
4. **默认值呈现不一致**:placeholder 写"默认 8080/8443",但 `defaultServerOptions()`(1291 行)**不包含** `vhost_http_port/vhost_https_port/sub_domain_host/tcpmux_http_connect_port/custom_404_page/tls_force/frps_log_to_file/frps_log_max_days/allow_ports/max_ports_per_client`,新增服务端时这些字段为 `undefined`。
5. **`custom_404_page` 无格式/存在性校验**(VPS 本地路径只能靠后端报错)。
6. **`sub_domain_host` 的依赖关系未提示校验**(规则用 subdomain 时才生效,属跨模块依赖)。
### 3.2 TCP/UDP 端口分配(493–500 行)
字段:`allow_ports`(端口白名单)、`max_ports_per_client`。
- 说明文字较准确(区分 vhost 回源端口不在此限制、需云安全组与主机防火墙放行)。
- 问题:`allow_ports` 为纯文本,格式(`6000,6005-6010`)无前端校验,错误只会在后端/下发阶段暴露;`max_ports_per_client` 允许 0(不限)与 `n-input-number` 默认空值语义需要靠文案理解。
### 3.3 frps 管理接口(502–521 行)
字段:`dashboard_addr`、`dashboard_port`、`dashboard_user`、`dashboard_password`(密文)+「清除管理密码」。
- 说明清楚地区分了"仅用于 Havline 拉取流量,与连接认证无关"。
- 问题:
1. **成对字段无关联校验**:只填 `dashboard_port` 不填 `dashboard_addr`(或反之)时无提示,仅静默不采集。
2. 该区块与「远程管理」标签主题(Agent/远程)关联度弱,它其实是 frps 的可观测性配置。
### 3.4 公网 Agent 管理(523–540 行)
字段:`agent_url`、`agent_token`、`ssh_host`、`ssh_port`、`ssh_user`、`ssh_auth`(key/password)、`ssh_secret`、「启用 Agent 日常管理」`mgmt_enabled`;编辑且已配置时额外显示「测试连接 / 下发 frps 配置 / 查看服务端详情」。
合理之处:
- 明确"首次安装/升级需 SSH,日常走 Agent API";SSH 仅用于生命周期与隧道。
- 三个快捷操作按钮对编辑态友好。
问题:
1. **`agent_url` placeholder 与实际安全模型冲突**:示例 `http://vps.example.com:7700`(525 行)鼓励公网明文 HTTP,而页面自身文案强调"不会默认暴露公网 7700"(244 行),且默认推荐 SSH 隧道 / HTTPS 固定。示例具有误导性。
2. **SSH 字段无完整性校验**:填了 `ssh_host` 但缺 `ssh_user`/`ssh_secret`、或 `ssh_auth=key` 却粘贴密码时,无前端提示,失败延后到安装阶段。
3. **`ssh_secret` 文案不随 `ssh_auth` 切换**:始终为"SSH 密码/私钥",用户需自行判断格式。
4. **`mgmt_enabled=true` 但 `agent_url` 为空**的冲突组合无提示。
5. **弹窗内「下发 frps 配置」(539 行)用的是已保存数据**:`pushAgentConfig(editingServer)` 以服务端 id 触发,后端按已存库配置生成;若用户在同一弹窗内刚改了 vhost/端口(尚未保存),点它下发的是**旧配置**,与"修改后需下发"的预期相悖。
---
## 四、合理性问题清单(按优先级)
### P0(建议优先修复)
1. **「远程管理」帮助栏串台(明确缺陷)**
- `serverHelpItems`(1307 行)只对 `advanced` 分支;`remote` 落入默认分支,展示的是**基础配置**的说明(基本信息/认证方式/frps 管理接口/描述)。
- 帮助栏标题 `serverFormTab === 'basic' ? '配置说明' : '高级配置说明'`(601 行):`remote` 标签显示"高级配置说明"。
- `serverHelpTip`(1323 行)对 `remote` 返回通用兜底文案。
- 结果:用户在「远程管理」标签看到的右侧说明与其所在标签完全不符。
2. **表单无任何校验**(409、1603 行)
- 无 `:model`/`:rules`;`saveServer` 直接提交。缺:名称/主机地址必填、端口有效、**创建时 token 认证令牌为空**、dashboard 成对、SSH 完整性、端口冲突等。
3. **`enabled` 字段无 UI 入口**
- `serverForm.enabled` 在 reactive(1300 行)与 payload(1611 行)中存在,但**三个标签页均无绑定输入**,添加/编辑恒为 `true`。用户无法在表单中停用服务端。
### P1(影响体验/正确性)
4. 跨字段、跨标签冲突无校验(端口重复;`tls_force` ↔ `tls_enabled`;`sub_domain_host` 依赖)。
5. 保存与"下发/重启"语义混用;弹窗内「下发 frps 配置」可能下发未保存前的旧配置。
6. **过时文案**:多处提示"在「内网穿透」页服务端表单…填写"(244、310 行),但服务端表单已于 2026-09-24 迁入本页(AgentView),指引指向错误。
7. `agent_url` 示例鼓励公网明文 HTTP,与页面安全模型矛盾。
8. `dashboard_port`/`tcpmux` 的 0/空语义依赖文案,无强制。
### P2(可优化)
9. 「远程管理」混合了 frps 服务端参数、管理接口、Agent 接入三类关注点,标签命名与内容不符;建议拆分或重命名为"服务端与 Agent"。
10. `defaultServerOptions()` 未覆盖远程标签字段,新增时 `undefined`,`n-switch`(`tls_force`/`frps_log_to_file`)可能收到 `undefined`。
11. 高级标签用 collapse 收纳密集字段,远程标签 4 区块 ~18 字段平铺,密度管理不一致。
12. `frps_log_max_days`(远程)与 `log_max_days`(高级/frpc)命名相近、分属不同标签,易混。
13. `custom_404_page`、`allow_ports` 无格式校验。
---
## 五、「远程管理」字段级对照表
| 字段(绑定) | 标签 | 控件 | 主要问题 | 建议 |
| --- | --- | --- | --- | --- |
| `options.vhost_http_port` | HTTP 回源端口 | number | 端口冲突未校验;无默认值 | 与相关端口做唯一性校验;补充默认值 |
| `options.vhost_https_port` | HTTPS 回源端口 | number | 同上;`tls_force` 联动缺失 | 同上;联动 TLS 校验 |
| `options.sub_domain_host` | 子域名根 | text | 依赖关系未提示 | 增加格式校验与说明 |
| `options.tcpmux_http_connect_port` | tcpmux 监听端口 | number | "存在 tcpmux 规则时必填"无强制 | 按规则类型动态必填 |
| `options.custom_404_page` | 自定义 404 页面 | text | 无路径校验 | 格式提示/后端预校验 |
| `options.tls_force` | 强制 TLS | switch | 与 frpc `tls_enabled` 无联动 | 跨标签联动校验/提示 |
| `options.frps_log_to_file` | frps 写文件日志 | switch | 默认 undefined | 纳入 defaultServerOptions |
| `options.frps_log_max_days` | 日志保留天数 | number | 与 frpc `log_max_days` 易混 | 明确命名/分组 |
| `options.allow_ports` | 端口白名单 | text | 无格式校验 | 正则校验 `\d+(-\d+)?(,\d+(-\d+)?)*` |
| `options.max_ports_per_client` | 每客户端端口上限 | number | 0=不限需文案 | 保留,补 hint |
| `options.dashboard_addr` | 管理地址 | text | 与 port 不成对校验 | 成对校验 |
| `options.dashboard_port` | 管理端口 | number | 同上 | 成对校验 |
| `options.dashboard_user` | 管理用户 | text | — | — |
| `serverForm.dashboard_password` | 管理密码 | 密文 | — | — |
| `serverForm.agent_url` | Agent API 地址 | text | 示例误导;无格式校验 | 改为隧道/HTTPS 示例;URL 校验 |
| `serverForm.agent_token` | Agent Token | 密文 | — | — |
| `serverForm.ssh_host` | SSH 地址 | text | 无完整性依赖校验 | 完整性校验 |
| `serverForm.ssh_port` | SSH 端口 | number | — | — |
| `serverForm.ssh_user` | SSH 用户 | text | 同上 | 完整性校验 |
| `serverForm.ssh_auth` | SSH 认证 | select | 未联动 `ssh_secret` 文案 | 随选择切换文案/校验 |
| `serverForm.ssh_secret` | SSH 密码/私钥 | 密文 | 同上 | 按认证方式校验格式 |
| `serverForm.mgmt_enabled` | 启用 Agent 日常管理 | switch | 与 `agent_url` 空值冲突 | 联动提示 |
| `serverForm.enabled` | (无标签) | 无 | **无 UI 入口** | 暴露或移除 |
---
## 六、改进建议
1. **修正帮助栏(低成本、高收益)**:为 `remote` 增加独立说明项集合;帮助栏标题改为按标签映射(如 `basic→配置说明 / advanced→高级配置说明 / remote→远程管理说明`)。
2. **引入表单校验**:给 `<n-form>` 加 `:model` + `:rules`(或提交前手工校验),至少覆盖:名称/主机地址必填、端口范围、创建时 Token 认证必填、dashboard 成对、SSH 三件套完整、端口冲突、`allow_ports` 格式。
3. **处理 `enabled`**:在基础配置补充"启用服务端"开关,或从表单与 payload 中移除。
4. **跨字段联动**:`tls_force` ↔ `tls_enabled`、`ssh_auth` ↔ `ssh_secret`、`mgmt_enabled` ↔ `agent_url`、端口唯一性。
5. **明确保存/生效语义**:保存后如涉及 frps 参数变更,给出"需下发并重启"的显式提示;或将「下发 frps 配置」改为先保存再下发,避免下发旧配置。
6. **修正过时文案**:将"在「内网穿透」页服务端表单…"改为"本页表单…"(244、310 行)。
7. **重排/重命名远程标签**:按"frps 服务端参数 / 可观测性(管理接口) / Agent 接入"折叠分组;标签可命名为"服务端与 Agent"。补齐 `defaultServerOptions()` 的远程字段默认值。
8. **示例文案**:`agent_url` placeholder 改为 SSH 隧道本地地址或 HTTPS 固定示例,并注明不推荐公网明文。
---
## 七、附:类型定义对照
`FrpServerOptions`(`web/src/api/types.ts` 395–450 行)中与「远程管理」相关的字段均已定义,并有中文注释(如 `tls_force` 注明"frpc 必须开 transport.tls.enable"、`dashboard_*` 注明"仅用于拉取只读信息")。前端表单字段与类型基本一致,主要缺口在**校验、默认值、帮助文案与字段归置**,而非类型缺失。
> 本文档为静态分析结论,具体以 `AgentView.vue` 源码为准;建议评审确认后再实施改动。
+360
View File
@@ -0,0 +1,360 @@
# havline-agent 独立项目化分析
> 分析日期:2026-10-03
> 分析对象:`internal/agent`(`havline-agent`,AgentVersion `0.15.0`)
> 分析方式:源码通读(25 条 API 路由、安全模型、依赖关系)、安装脚本核对、同类开源项目调研
> 文档性质:产品与技术方向讨论稿,不构成实施承诺
>
> 延伸阅读:如果把 agent 的控制能力扩展到网站部署、软件安装等更广场景,见 `agent-server-automation-vision.md`(场景地图与 Resource/Action/Recipe 三层能力模型)。
---
## 一、结论摘要
`havline-agent` 不是一个「监控探针」,而是一个**运行在公网服务器上、以 root 身份代中央面板执行受控变更的节点执行器**。它当前的价值集中在四件事上:
1. **Nginx 站点托管**:写入 vhost、`nginx -t` 校验、重载,失败自动回滚,并保留最多 10 个历史版本;
2. **frps 生命周期**:从 GitHub Release 下载安装包(SHA-256 校验 + 加速代理白名单)、写配置、启停、读回配置与日志;
3. **证书与传输**:接收证书推送、可自行配置 HTTPS 直连并做自检;
4. **节点事实采集**:主机指标、Nginx 日志尾部、端口占用、防火墙状态、路由漂移所需的存在性/加载状态。
它已经具备独立产品的**技术骨架**,但**产品定义、协议演进和安全模型**都还绑定在 Havline 的具体用法上。如果要做独立项目,我的判断是:
- **值得做**,但价值不在「再写一个 frp 面板」,而在于「**把服务器变更做成可校验、可回滚、可审计的事务**」这一层抽象;
- **第一步应该是「解耦发行」,不是「另起产品」**:先把 agent 拆成可独立版本、独立升级、协议向后兼容的组件(方案 A);
- **第二步是「通用节点执行器」**:抽象出 Nginx / Caddy / frps / systemd 等 backend,让其他面板或自建平台也能接入(方案 B);
- **第三步才是「多节点编排平台」**:声明式配置、节点组、灰度与漂移治理(方案 C),这一步的竞争对手是 Coolify / Dokploy / Portainer 这类成型项目,需要明确差异化才值得投入。
最大的**未解风险**是权限模型:agent 以 `User=root` 运行,等价于把宿主机高权限暴露给一个 HTTP 接口。当前靠「回环监听 + Bearer Token + SSH 隧道 + 证书 pin」把攻击面收窄,这在「面板自己调用」的模型下是合理的,但**在独立产品、多租户、或接入第三方调用方时不够**。
---
## 二、现状盘点
### 2.1 定位与部署形态
| 项 | 现状 |
| --- | --- |
| 交付物 | 单个 Linux 二进制 `havline-agent`(amd64 / arm64),无 CGO |
| 安装方式 | `scripts/agent-install.sh`:安装到 `/usr/local/bin`,写 `/etc/havline-agent/agent.env`,注册 systemd |
| 运行身份 | systemd `User=root`、`Restart=on-failure`,`After=network-online.target nginx.service` |
| 默认监听 | `127.0.0.1:7700`,非回环监听必须显式 `HAVLINE_AGENT_ALLOW_REMOTE=1` |
| 鉴权 | `Authorization: Bearer <token>`,常量时间比较;Token 由安装脚本用 `/dev/urandom` 生成(32 字节 hex) |
| 调用方式 | 中央 Havline 经 SSH 隧道访问回环端口;或配置 HTTPS 直连 + 证书 pin |
| 数据目录 | `/var/lib/havline-agent`(探测缓存、限流状态、HTTPS 代理状态) |
| 证书目录 | `/var/lib/havline-agent/certs/<domain>/`(`fullchain.pem` / `privkey.pem`,权限 0600) |
### 2.2 API 表面(25 条路由)
| 分组 | 端点 | 说明 |
| --- | --- | --- |
| 探活 | `GET /api/v1/ping`、`GET /api/v1/status` | 服务标识、监听范围、传输安全、Nginx 探测、frps 状态、端口占用、路由清单 |
| 路由 | `GET/PUT /api/v1/routes`、`GET /api/v1/routes/{domain}/conf`、`DELETE /api/v1/routes/{domain}` | vhost 部署、读取、删除;写入时做 Nginx 预检与回源自检 |
| 版本 | `GET /api/v1/routes/{domain}/versions`、`.../versions/{name}`、`POST .../versions/{name}/rollback` | 每个域名最多 10 个历史版本,支持查看与回滚 |
| Nginx | `POST /api/v1/nginx/install`、`POST /api/v1/nginx/reload` | 一键安装(apt/dnf/yum/apk)、显式重载 |
| 传输 | `PUT/GET/DELETE /api/v1/transport/https` | 配置 HTTPS 直连(候选端口自动选择)、查询证书到期时间、移除 |
| 防火墙 | `GET /api/v1/firewall`、`POST /api/v1/firewall/allow` | 只读探测 ufw/firewalld/nftables/iptables;仅 ufw/firewalld 可自动放行 |
| frps | `POST /api/v1/frps/install`、`POST /api/v1/frps/action`、`PUT/GET /api/v1/frps/config` | 下载校验安装、启停、配置写入与回读 |
| 证书 | `POST /api/v1/certs`、`GET /api/v1/certs` | 接收证书推送、列出已托管证书 |
| 观测 | `GET /api/v1/metrics`、`GET /api/v1/logs/nginx` | 主机 CPU/内存/磁盘/负载/运行时长;Nginx access / error 日志尾部 |
| 其他 | `PUT /api/v1/china-cidr` | 下发中国 IP 段用于 geo 规则 |
### 2.3 已经做对的工程决策
这些是独立化时可以**直接继承**的资产:
- **写前备份、校验失败回滚**:`applyRouteContent` 先落版本、再写入、`nginx -t` 失败则恢复上一版;HTTPS 配置同样处理。避免了「校验失败就把在跑的站点从磁盘抹掉」这类事故。
- **幂等安装**:`POST /api/v1/frps/install` 重复调用等价于升级;Nginx 安装先探测再跳过。
- **供应链校验**:frps 安装包先取官方 checksum 再下载,做 SHA-256 比对;下载源限定官方与白名单加速前缀。
- **输入面收敛**:域名正则校验、请求体上限(4KB–16MB 分级)、日志路径**不接受请求参数**(从 `nginx -T` 实际生效配置推导),避免变成任意文件读取。
- **探测缓存与节流**:Nginx 探测、CPU 采样(1 秒窗口)都有缓存,避免状态轮询把 `nginx -t` 变成 DoS。
- **敏感值不落配置**:Basic Auth 自动生成的密码只在响应里回一次,不写入配置注释(避免「查看配置」永久泄露)。
- **纯 Go apr1**:不依赖系统 `openssl`,跨发行版稳定。
### 2.4 与主项目的耦合面
| 耦合点 | 内容 | 拆分影响 |
| --- | --- | --- |
| 内部包依赖 | `internal/fsutil`(版本目录、原子写)、`internal/nginxtmpl`(模板)、`internal/sysinfo`(指标) | 需要把这些抽成独立模块或复制为 vendor 子包 |
| 调用方 | `internal/frp/agent_client.go`(429 行):Bearer + 证书 pin + 超时策略 | 需定义公开协议契约,而非 Go 客户端内部约定 |
| 安装链路 | `internal/frp/ssh_installer.go`、`scripts/agent-install.sh`、内嵌二进制 `internal/frp/agentbin` | 独立项目需要自己的发行与升级通道 |
| 版本矩阵 | `AgentVersion` 与主程序能力探测(`wrapEndpointMissing`、兼容性测试) | 独立发版会放大版本兼容成本 |
| 命名与标识 | 服务名 `havline-agent`、路径 `/etc/havline-agent`、`/var/lib/havline-agent` | 改名涉及迁移脚本 |
**量级评估**:`internal/agent` 非测试实现约 2.8k 行(`server.go` 单文件 1766 行),另有 9 个测试文件。拆分本身工作量不大,真正的成本在**协议设计、版本兼容和多调用方支持**。
---
## 三、同类项目对标
| 项目 | 类型 | 与 havline-agent 的关系 | 可借鉴点 |
| --- | --- | --- | --- |
| [frp-panel](https://github.com/VaalaCat/frp-panel)(VaalaCat) | 多节点 frp Web 控制台(Master + Worker) | 功能重叠最多的项目:同样管 frps/frpc、多节点、实时日志 | Master/Worker 分离、统一凭据分发、Worker 边缘化 |
| [Nginx Proxy Manager](https://github.com/NginxProxyManager/nginx-proxy-manager) | 反代 GUI(单体,含 Nginx) | 用户群重叠;它把 Nginx 和 UI 放一起 | 多用户角色、审计日志、上手体验 |
| [Traefik](https://github.com/traefik/traefik) / [Caddy](https://github.com/caddyserver/caddy) | 现代反向代理 | 是「agent 要驱动的 backend」的替代实现 | 声明式配置、自动 HTTPS、动态 provider 抽象 |
| [Coolify](https://github.com/coollabsio/coolify) / [Dokploy](https://github.com/Dokploy/dokploy) | 自托管 PaaS | 同属「中央面板 + 节点执行」范式,但面向应用部署而非入口治理 | 节点注册流程、资源占用控制(Dokploy 闲置约 350MB / 1% CPU) |
| [Portainer](https://docs.portainer.io/start/architecture)(Server + Agent) | 容器管理 | Agent 架构最成熟的参考:**Agent 无状态、数据回传 Server**、一个 Server 接多个 Agent | 无状态化、故障域隔离、air-gap 支持 |
| [Beszel](https://github.com/henrygd/beszel) | 轻量监控(Hub + Agent) | 证明「Go 单二进制 + 一键脚本 + 低占用」的 agent 分发模型可行 | 安装体验(一条命令)、空闲 10–15MB 内存 |
| node_exporter / Netdata | 指标采集 | 只做观测,不做变更 | `/metrics` 暴露、采集与动作分离 |
| [Cockpit](https://cockpit-project.org/) / Webmin | 服务器管理面板 | 直接对标「agent 的权限边界」争论:Cockpit 限定服务管理,Webmin 全 root | 能力分级、限定作用域 |
| Tailscale / Cloudflare Tunnel | 连接层 | 与 agent 竞争「如何安全到达内网」 | 出站连接优先、无需开放入站端口 |
### 3.1 差异定位
把上表压缩成一句话,havline-agent 的差异点是:
> 别人管**容器**(Portainer/Coolify/Dokploy)、管**指标**(Beszel/netdata)、管**连接**(Tailscale/Cloudflare),或管 **frp 控制台**(frp-panel);
> havline-agent 管的是**「服务器上的入口配置」这件事本身**——vhost、证书、vhost 与 frps 的协同,并且**把每次变更做成带回滚的事务**。
这个差异点是真的,但目前**没有被产品化表达**:没有独立的配置模型、没有 audit 语义、没有 backend 抽象,所以外部看不到「事务」这个卖点。
---
## 四、独立化的三种定位方案
### 方案 A:Havline Node —— 作为主项目的独立发行组件
**定位**:仍是 Havline 的一部分,但独立版本、独立升级、独立仓库或独立目录。
- 收益:版本矩阵可控、agent 可独立热修(不必发主程序)、第三方可只部署 agent。
- 代价:最小,主要是协议冻结与迁移脚本。
- 适用:当前阶段最务实的一步。
**关键动作**:抽出 `agent` 模块依赖 → 定义 `agent-protocol.md`(v1 契约)→ agent 自带 `-version` 与升级端点 → 主程序做能力协商(不靠版本号硬判)。
### 方案 B:通用节点执行器(Node Actuator)
**定位**:一个面向「任何控制面」的节点变更 API。控制面可以是 Havline,也可以是脚本、Ansible、Terraform provider、甚至 AI Agent。
- 核心抽象:`backend` 接口(nginx / caddy / traefik / haproxy / frps / systemd)+ `changeset`(事务)+ `capabilities`(能力协商)。
- 收益:从「Havline 的内部件」变成「可被集成的组件」,获得外部使用者的可能性。
- 代价:中。需要 backend 抽象、配置模型、稳定协议、审计与权限分级。
- 关键风险:如果做成「能执行任意命令的远程 root API」,会被安全社区直接劝退;**必须限定在声明式、可校验、可回滚的资源操作**上。
### 方案 C:多节点编排平台(Fleet)
**定位**:中央控制面 + 多节点,做声明式入口治理:节点组、漂移报告、灰度下发、证书编排、批量滚动。
- 收益:产品上限最高,可做 SaaS 或企业版(审计、SSO、RBAC)。
- 代价:高。直接进入 Coolify / Dokploy / frp-panel / Portainer 的竞争区间,且需要长期维护。
- 前提:方案 A/B 已经在真实环境里跑稳,并积累到「多节点」的真实需求(不是想象的需求)。
**推荐路径**:A(1–2 周)→ B(1–2 个月,看是否真有外部调用方)→ C(只有出现明确的多节点用户诉求才启动)。
---
## 五、能力扩展联想
以下按主题列「可以做」的方向,标注价值与代价,供筛选。
### 5.1 连接与传输
| 想法 | 价值 | 代价 |
| --- | --- | --- |
| **反向隧道(agent 出站连中央)** | 高。彻底去掉「中央要能 SSH 到节点」和「开放入站端口」两个前提,才是 Tailscale 级别的体验 | 中高:长连接、重连、多路复用、NAT 穿透 |
| mTLS 双向认证替代裸 Bearer | 高。把「共享密钥」升级为「节点身份」 | 低中:证书签发与轮换 |
| 节点注册令牌(一次性) | 中。安装即注册,省去手工复制 Token | 低 |
| WireGuard / Tailscale 适配器 | 中。复用成熟连接层而不是自研 | 中 |
| 多路径回退(SSH → HTTPS pin → 隧道) | 中。已有部分实现,可显式化策略 | 低 |
### 5.2 配置模型与 GitOps
| 想法 | 价值 | 代价 |
| --- | --- | --- |
| **声明式资源模型**(`Route` / `Certificate` / `FrpsServer`) | 高。当前 API 是「动作导向」,声明式才能做 diff、dry-run、幂等 | 中 |
| dry-run / plan 输出 | 高。与「事务」卖点天然契合:先给 diff 再执行 | 低(已有校验能力) |
| 从 Git 拉取期望状态 | 中高。agent 主动拉,控制面只存声明 | 中 |
| Caddy / Traefik backend 适配器 | 中高。Nginx 之外的用户群很可观 | 中高(模板语义差异大) |
| 配置规范化与冲突检测(跨域名/端口) | 中。目前端口冲突主要靠上层判断 | 中 |
### 5.3 证书与密钥
| 想法 | 价值 | 代价 |
| --- | --- | --- |
| 边缘 ACME(agent 自己申请,中央只下发 DNS 凭据策略) | 中高。减少私钥长途传输 | 中高 |
| 证书轮换编排 + 到期预警 + 提前换发 | 高。当前只有 `not_after` 只读,靠中央驱动 | 低中 |
| 私钥不出节点(只回传指纹与到期) | 高。安全叙事更硬 | 中 |
| 证书与 vhost 的一致性巡检 | 中。已具备部分素材(certs 列表 + route conf) | 低 |
### 5.4 可观测性
| 想法 | 价值 | 代价 |
| --- | --- | --- |
| `GET /metrics`(Prometheus 文本格式) | 高。把 agent 接入现成监控栈,成本极低 | 低 |
| 结构化日志推送(而非拉取尾部) | 中高。现在只能中央轮询 tail | 中 |
| 多视角探测(从节点测中央、测外部端点) | 中。健康判定更可信 | 低中 |
| 变更事件流(webhook / SSE) | 中高。为审计与自动化提供基础 | 中 |
### 5.5 运维动作(需谨慎扩权)
| 想法 | 价值 | 代价 / 风险 |
| --- | --- | --- |
| systemd 服务托管(启停/状态,白名单 unit) | 中高 | 中。必须白名单,否则等于远程 root shell |
| 软件包安装(Nginx/frps 之外的组件) | 中 | 高。供应链与破坏性风险 |
| 备份执行(配置与证书打包外送) | 中 | 低中 |
| 只读免鉴权的 `/healthz` | 低 | 低(注意别泄露版本细节) |
| Docker 精简支持(容器重建) | 中 | 高。与 Portainer 正面竞争,不建议早期做 |
### 5.6 安全与治理
| 想法 | 价值 | 代价 |
| --- | --- | --- |
| **能力分级**(`route:write`、`cert:write`、`frps:admin`、`metrics:read`) | 高。Token 可只授予一类能力 | 中 |
| **非 root 模式**(sudoers 白名单 + 文件属主约束) | 高。回答「为什么要 root」这个必然被问的问题 | 中高 |
| 审计日志(谁、何时、改了什么、结果、回滚点) | 高。企业交付的硬需求 | 中 |
| 变更审批门槛(危险操作需二次确认令牌) | 中 | 低 |
| 配置模板注入面清理(IP 列表、体积、超时的结构化校验) | 高。当前用户可控字段拼进 Nginx 配置,是长期风险 | 中 |
### 5.7 节点群与编排
| 想法 | 价值 | 代价 |
| --- | --- | --- |
| 节点组 + 批量下发(滚动/灰度) | 高(方案 C 核心) | 高 |
| 漂移报告(期望 vs 实际) | 高。已具备单节点素材 | 中 |
| 一键批量升级节点(带健康检查与回滚) | 高 | 中高(节点失联会很难收场) |
| 节点标签与调度(按区域/机房分配入口) | 中 | 中 |
### 5.8 生态集成
| 想法 | 价值 | 代价 |
| --- | --- | --- |
| Terraform provider / Ansible collection | 中高。进入基础设施即代码工作流 | 中 |
| **MCP server**(把节点能力暴露为工具,供 AI 运维调用) | 中高且时机好;必须带「危险操作需人工确认」闸门 | 低中 |
| Webhook / 事件总线(变更、证书到期、节点离线) | 中高 | 低 |
| 官方 Helm / Compose 一键部署 | 中 | 低 |
---
## 六、架构建议
### 6.1 三个平面
```mermaid
flowchart LR
subgraph Control["控制面(可选:中央面板 / 脚本 / IaC / AI Agent)"]
C1[声明式资源定义]
C2[Diff 与审批]
C3[审计与状态汇总]
end
subgraph Link["连接平面"]
L1[SSH 隧道]
L2[HTTPS + 证书 pin]
L3[反向隧道(待做)]
end
subgraph Data["数据面:havline-agent"]
D1[Backend: nginx / caddy / traefik]
D2[CertStore]
D3[frps / systemd]
D4[Probe + Metrics]
D5[事务引擎:备份 → 校验 → 应用 → 回滚]
end
C1 --> C2 --> Link --> D5
D5 --> D1
D5 --> D2
D5 --> D3
D4 --> C3
Link --> C3
```
要点:**控制面可替换**。把 agent 做成「无控制面也能被驱动」的组件,才谈得上独立项目。
### 6.2 协议
- 保持 JSON over HTTP、`/api/v1` 前缀,新增 `GET /api/v1/capabilities` 声明支持的能力与 backend,替代按版本号猜能力。
- 变更接口引入声明式语义:`PUT /api/v1/routes/{domain}` 保持现状(幂等),新增 `POST /api/v1/changesets`(多资源事务 + plan/apply 两段式)。
- 错误响应统一结构化(`code` / `message` / `details` / `rollback`),便于控制面做重试与提示。
### 6.3 状态与幂等
- 明确区分**缓存**(`nginx_conf_dir`、探测结果、metrics)与**状态**(限流状态、HTTPS 代理状态、证书目录),独立项目里应把缓存设计为可丢弃。
- 现在「删除路由会连同版本历史一起删」是合理的产品决策,但应在协议层显式表达(例如 `?keep_versions=true`)。
### 6.4 Backend 抽象(方案 B 的核心)
建议抽取的最小接口:
```text
Backend interface {
Name() string
Capabilities() []string
Validate(desired) error
Plan(current, desired) (Diff, error)
Apply(diff) (rollbackToken, error)
Rollback(rollbackToken) error
Probe() (Health, error)
}
```
Nginx 已有实现可直接包装。**不建议**一开始就同时支持多 backend:先把 Nginx 事务化打磨好,再以「社区插件」形式引入 Caddy。
---
## 七、安全模型(最重要的待办)
### 7.1 现状的风险点
| 风险 | 现状 | 建议 |
| --- | --- | --- |
| 进程权限 | systemd `User=root`,因为要写 Nginx 配置、装包、操作 systemd | 提供非 root 模式:文件属主 + sudoers 白名单(`nginx -t`、`systemctl reload nginx`、指定 install 脚本) |
| 令牌 | 单一 Bearer Token,全能力 | 能力分级 Token + 轮换 + 撤销 |
| 控制面身份 | 证书 pin(客户端侧校验服务端),服务端不校验客户端证书 | mTLS 或节点身份证书 |
| 用户可控字段 | 域名/上游/IP 列表/体积/超时进入 Nginx 配置模板 | 结构化校验 + 模板白名单 + 生成后 `nginx -t`(已有最后一道) |
| 审计 | 有操作结果,无独立审计流 | 变更审计事件(含回滚点、发起方标识) |
| 供应链 | frps 已做 SHA-256 + 源白名单 | 扩展到 cloudflared、自升级、脚本下载 |
| 暴露面 | 回环 + SSH 隧道(默认安全) | 保留默认;直连模式强制 HTTPS + pin |
### 7.2 一句话原则
> agent 的能力边界应该由**「可声明、可校验、可回滚的资源操作」**定义,而不是由「能执行哪些命令」定义。任何越界的操作(任意命令、任意文件路径、任意 unit)都应该是显式配置项 + 默认关闭。
---
## 八、风险与「不做」清单
**主要风险**
1. **定位漂移**:做成「小号 Portainer/Coolify」会失去差异点,且竞争不过成熟项目。
2. **权限叙事过不去**:root + HTTP 接口的组合,在任何安全评审里都会被质疑;没有能力分级与非 root 模式,企业场景基本无解。
3. **版本兼容爆炸**:独立发版后,agent 版本 × 主程序版本 × backend 组合会迅速增加;必须靠能力协商缓解。
4. **多节点运维复杂度**:节点失联、部分成功、状态收敛,是方案 C 最难的部分,容易低估。
5. **上游变化**:frp 的校验文件名、下载结构、配置格式都变过(代码里已有 `fetchChecksumForCandidates` 兜底),依赖上游的模块需要持续跟进。
**建议不做(至少早期不做)**
- 不做任意命令执行 / Shell 透传;
- 不做容器编排(Portainer/Coolify 的地盘);
- 不做通用指标时序库(Beszel/Netdata 的地盘);
- 不做多租户 SaaS 控制面(在 A/B 验证前);
- 不做 Windows 支持(当前实现强依赖 Linux 语义:`nginx -T`、systemd、ufw/firewalld)。
---
## 九、落地路线建议
| 里程碑 | 目标 | 产出 | 预估 |
| --- | --- | --- | --- |
| M0 解耦 | agent 可独立版本、独立升级;依赖自洽 | 独立目录/仓库、`agent-protocol.md` v1、`/api/v1/capabilities`、能力探测替代版本判断 | 1–2 周 |
| M1 安全 | 回答「为什么 root」 | 能力分级 Token、非 root 模式(sudoers 白名单)、审计事件、`/metrics` | 3–5 周 |
| M2 协议 | 从动作导向到声明式 | `changesets`(plan/apply/rollback)、结构化错误、dry-run | 4–6 周 |
| M3 开放 | 让外部能接入 | backend 抽象(Nginx 先行)、文档 + SDK(Go/HTTP)、Terraform provider 试用 | 2–3 个月 |
| M4 编排 | 多节点(视需求) | 节点组、漂移报告、批量滚动与回滚 | 视反馈 |
**判断是否进入 M3/M4 的信号**:是否出现**非 Havline 的调用方**(外部面板、IaC、脚本);是否出现**真实的 3 个以上节点**的管理诉求。没有这两个信号,做 M0/M1 的收益(可维护性、安全性)就足够了。
---
## 十、需要注意的一个现成机会
当前 agent 的能力组合(受控变更 + 校验回滚 + 事实采集)**天然适合作为 AI 运维的执行端**:把 API 暴露为 MCP 工具,让 AI 提出变更、由 agent 校验并执行、失败回滚,人工只做审批。这条路径的门槛不在技术,而在**危险操作的确认闸门**——必须把「AI 提议 → 人确认 → agent 执行」做成协议级约束,否则会变成「给 AI 一把 root 钥匙」。
---
## 十一、最终判断
- 独立化**技术上可行**,`internal/agent` 的工程质量(备份回滚、幂等安装、输入收敛、供应链校验)在同类项目中属于中上水平,这是可以拿出去的基础。
- 真正缺的不是功能,而是三样东西:**明确的定位叙事**(服务器入口配置的事务层)、**协议契约**(声明式 + 能力协商)、**权限模型**(能力分级 + 非 root)。
- 建议顺序:先做「解耦发行 + 安全模型」(M0/M1),因为这两步即使不独立成项目,对 Havline 本身也是纯收益;等出现外部调用方或真实多节点需求,再决定是否推进 M3/M4。
- 如果只允许做一件事:**把「变更事务」抽象出来并写进协议**——这是 havline-agent 区别于 frp-panel、Nginx Proxy Manager、Portainer 的唯一护城河。
@@ -0,0 +1,160 @@
# 「公网服务端」页面布局对标与优化方案(GitHub 开源项目版)
> 日期:2026-10-07
> 对象:[AgentView.vue](F:/Fonu-main/havline/web/src/views/AgentView.vue)(2244 行 / 136 KB)
> 性质:只出方案,不含代码改动
> 前置文档:[agent-view-layout-plan-2026-10-07.md](F:/Fonu-main/havline/docs/agent-view-layout-plan-2026-10-07.md)(站内同类页面对比)
> 本文补充的是**站外开源项目的对标注解**,两者可合并使用
## 一、检索方法与候选项目
通过 GitHub Search API 按 `frp web panel`、`homelab dashboard`、`server management panel`、`admin dashboard list detail` 四组关键词检索,按 star 排序取候选,再拉取各仓库的默认分支文件树定位前端页面目录。数据抓取时间:2026-10-07。
| 项目 | Star | 技术栈 | 定位 | 参考价值 |
| --- | --- | --- | --- | --- |
| [louislam/uptime-kuma](https://github.com/louislam/uptime-kuma) | 92,180 | Vue 3 | 自托管监控面板 | **最高**,同为「列表 + 详情」形态 |
| [djylb/nps](https://github.com/djylb/nps) | 1,079 | Go + Web UI | 内网穿透 / 反向代理面板 | 高,业务同源 |
| [MCSManager/MCSManager](https://github.com/MCSManager/MCSManager) | 4,982 | TypeScript + Vue | 多实例服务器管理面板 | 中,多节点组织方式 |
| [27260102/frp-admin](https://github.com/27260102/frp-admin) | 9 | React + Ant Design | FRP 管理面板 | **最高**,业务完全同源 |
| [Gogowwww/frp-manager](https://github.com/Gogowwww/frp-manager) | 4 | Python | frps/frpc 管理面板 | 低,仅参考功能面 |
| [BloretCrew/BloraPanel](https://github.com/BloretCrew/BloraPanel) | 1 | Go + Vue 3 | 多节点服务器面板 | 低 |
说明:`nps` 与 `BloraPanel` 的文件树未成功取到前端页面目录,未纳入结构对比;其余项目只取了页面文件与目录结构,未做完整代码审计。
## 二、对标项目的布局拆解
### 2.1 uptime-kuma:列表与详情拆到路由级
这是本次最有价值的发现。它把「列表 + 详情」拆成了**三个独立路由页面**:
```
src/pages/
├─ List.vue 36 行 ← 入口页极薄,只做一件事:
│ <MonitorList :scrollbar="true" />
├─ Details.vue 1053 行 ← 单个监控项的详情页
├─ EditMonitor.vue ← 编辑表单独立成页
└─ Dashboard.vue ← 概览
```
关键点:
1. `List.vue` 只有 36 行,列表的全部逻辑(分组、状态点、实时心跳条、排序)都在 `MonitorList` 组件里。入口页面不承载业务。
2. 详情是**独立路由**(`/details/:id`),不是同页右栏,也不是弹窗。用户可以前进后退、可以直接分享链接。
3. 详情页内部继续拆子组件(心跳条、图表、历史记录),单文件控制在千行量级且职责单一。
4. 编辑用独立页面,与「查看」彻底分离。
### 2.2 frp-admin:业务同源,页面按职责切分
```
frontend/src/
├─ components/MainLayout.tsx
└─ pages/
├─ Dashboard.tsx 概览
├─ ClientList.tsx 客户端 / 代理列表
├─ ProxyStatus.tsx 代理运行状态
├─ ServerConfig.tsx 服务端配置
├─ Settings.tsx 设置
└─ Login.tsx
```
关键点:
1. **配置(ServerConfig)与运行状态(ProxyStatus)分开**,不塞进同一个页面。
2. 从 `ClientList.tsx` 的导入可以看到它用 `Tabs` + `Collapse` + `Divider` 组织信息密度:
```ts
import {
Card, Table, Button, Space, Modal, Form, Input, Select, message, Popconfirm, Tag,
InputNumber, Empty, Alert, Tabs, Collapse, Switch, Divider,
} from 'antd';
```
3. 危险操作统一用 `Popconfirm` 包裹,不与普通按钮并列平铺。
4. 代理类型、健康检查类型等用**数据表驱动**渲染(`proxyTypes`、`healthCheckTypes`、`commonPorts` 常量表),而不是每个表单项手写一遍。这一点 Havline 的 FrpView 已经做到了,AgentView 的表单还没有。
### 2.3 MCSManager:容器 + 组件化
`frontend/src/views/` 下只有 `LayoutContainer.vue`、`Login.vue`、`Install.vue`、`SsoBindLogin.vue`——业务页面全部落在 `components/` 下的可复用组件里,视图层只负责布局壳。
## 三、对标结论:六条可借鉴的模式
把上面的做法逐条对到 Havline「公网服务端」页:
| # | 开源做法 | Havline 现状 | 差距 |
| --- | --- | --- | --- |
| 1 | 列表与详情是独立路由(uptime-kuma) | 同页左右分栏 | 详情无法直链,返回键行为不符合直觉 |
| 2 | 列表逻辑抽成组件,入口页 36 行(uptime-kuma) | 全部内联在 2244 行单文件 | 差距最大的一条 |
| 3 | 编辑表单独立成页 / 独立组件(uptime-kuma `EditMonitor.vue`) | 225 行弹窗内联在 view 里 | 表单无法单独测试与复用 |
| 4 | 配置与运行状态分页(frp-admin) | 同页堆叠 6 个区块 | 见前置文档 A3 |
| 5 | `Tabs` + `Collapse` 收敛信息密度(frp-admin) | 主区全部展开,需滚 4-5 屏 | 见前置文档 A3 |
| 6 | 数据表驱动表单选项 + `Popconfirm` 隔离危险操作(frp-admin) | 表单字段手写;删除与测速同级 | 见前置文档 A5 |
另外有一处**反向印证**:uptime-kuma 的详情页靠子组件拆分控制在千行量级,而不是靠 `max-height` 加内部滚动。Havline 侧栏 `max-height: 720px` 的写死高度(前置文档 D1)属于相反的思路。
## 四、优化方案(对标版)
在原方案 A/B/C 基础上,按对标结果重新编排。**方案 A 不变**(纯格式与样式,风险最低),下面只讲 B、C 的对标调整。
### 方案 B′:详情内聚 + 主区 tab(对应差距 4、5)
1. 主区改 `n-tabs`,四个页签:`概览` / `环境与依赖` / `运行控制` / `日志与输出`。
- 对齐 frp-admin 的「配置与状态分开」思路,把「运行控制」独立成页签
2. 消除重复详情(前置文档 A1):删除「查看」弹窗,或将其降级为「深度诊断」并只保留主区没有的内容
3. 侧栏去掉写死的 `max-height`,改为页面统一滚动
### 方案 C′:按 uptime-kuma 的粒度拆分(对应差距 1、2、3)
**C′-1 组件拆分**(先做,收益直接)
| 新组件 | 承担的现有代码 | 预估行数 |
| --- | --- | --- |
| `ServerListSidebar.vue` | 侧栏列表 + 列表项 | ~120 |
| `ServerOverviewPanel.vue` | stats-row + VPS 资源 + VPS 证书 | ~150 |
| `ServerDependencyPanel.vue` | dep-grid 三张卡 | ~120 |
| `ServerControlPanel.vue` | 生命周期 + frps 控制 | ~180 |
| `ServerFormModal.vue` | 表单弹窗(390-615 行) | ~230 |
| `ServerLogsPanel.vue` | 输出与日志 | ~90 |
完成后 `AgentView.vue` 预计降到 400 行以内(壳 + 状态编排 + 弹窗挂载),与 uptime-kuma 的 `List.vue` 定位一致。
**C′-2 路由级拆分**(可选,改动较大)
如果希望进一步对齐 uptime-kuma,可把详情拆成独立路由:
```
/servers 列表(可保留当前左栏)
/servers/:id 详情(页签:概览 / 环境 / 控制 / 日志)
```
好处是详情可直链、可前进后退、可分享;代价是列表与详情间的切换从"点一下"变成"路由跳转",对高频切换场景反而略慢。**建议先做 C′-1,观察实际使用是否真的需要路由级拆分,再决定是否做 C′-2。**
**C′-3 表单数据表驱动**(对齐差距 6)
参考 frp-admin 的 `proxyTypes` / `commonPorts` 常量表,把 AgentView 服务端表单里重复的字段声明抽成配置数组,减少手写重复项,也顺带缩短 `ServerFormModal.vue`。
### 不做的事(补充)
在原文档「不做的事」基础上,再补两条:
1. **不引入 antd / Element Plus 等第二套组件库**:对标的是布局思路,不是 UI 框架。Havline 已统一使用 Naive UI。
2. **不照搬 uptime-kuma 的 `v-html` 描述区**:那里有 `eslint-disable vue/no-v-html` 注释,属于其自身的历史包袱,不适合作为参考。
## 五、落地顺序
| 阶段 | 内容 | 对标来源 | 风险 |
| --- | --- | --- | --- |
| 1 | 原方案 A 全部 6 项 | — | 低 |
| 2 | 方案 B′(页签 + 去重复详情 + 侧栏滚动) | frp-admin | 中 |
| 3 | C′-1 组件拆分(按表逐个剥离) | uptime-kuma `List.vue` | 中 |
| 4 | C′-3 表单数据表驱动 | frp-admin | 低 |
| 5 | C′-2 路由级拆分(视需要) | uptime-kuma 路由 | 中高 |
前三步做完,页面结构与这两个成熟项目的差距基本拉平;第 4、5 步属于进一步优化。
## 六、验证方式
沿用前置文档第七节的验证清单(`typecheck` / `build`、三档宽度目视、深浅主题、关键交互回归)。组件拆分阶段额外需要:
1. 每剥离一个组件就单独提交,保证可回滚
2. 剥离过程中不改任何 props 之外的行为,提交信息里注明「仅搬移结构」
3. 全部拆完后回归:切换服务端、安装/升级 Agent、切换传输模式、下发 frps 配置、启停 frps、轮换证书、查看日志
+159
View File
@@ -0,0 +1,159 @@
# 「公网服务端」页面排版与结构调整方案
> 日期:2026-10-07
> 对象:[AgentView.vue](F:/Fonu-main/havline/web/src/views/AgentView.vue)(2244 行 / 136 KB)
> 性质:只出方案,不含代码改动
> 参考页面:DdnsView.vue、ProxyRemoteView.vue、FrpView.vue
## 一、现状测绘
页面当前的 DOM 骨架:
```
PageHeader(公网服务端 + 刷新)
├─ LoadError / EmptyState
└─ .agent-layout(grid: 260-320px | 1fr)
├─ aside.server-sidebar 服务端列表
└─ div.agent-detail
├─ .server-toolbar 7 个操作按钮平铺
├─ n-alert 状态提示(互斥)
├─ .entrance-row 公网反代入口引导
├─ ① section.stats-row 4 张指标卡 + VPS 资源卡 + VPS 证书卡
├─ ② section.dep-grid 3 张卡(Agent / Nginx / frps 运行时)
├─ ④③ .control-grid 2 张卡(生命周期 / frps 控制)
└─ ⑤ HavlineCard 输出与日志(内部两个 tab)
另外 5 个 n-modal 集中在 template 尾部:
frps.toml 预览、服务端表单(390-615,共 225 行)、日志、服务端详情、frps 配置
```
页面要做的事:服务端增删改查、Agent 生命周期、frps 控制、frps.toml 预览下发、VPS 资源与证书巡检、操作输出与日志。
## 二、同类页面参考
| 页面 | 布局 | 详情区组织 | 操作按钮 |
| --- | --- | --- | --- |
| [DdnsView.vue](F:/Fonu-main/havline/web/src/views/DdnsView.vue:97) | `.ddns-layout` + `aside.task-sidebar` + `section.task-detail` | 头部 `.task-detail__head` + 若干 `HavlineCard.detail-section` | 集中在头部,分三级:主操作(primary ghost)、次级(默认)、危险(error) |
| [ProxyRemoteView.vue](F:/Fonu-main/havline/web/src/views/ProxyRemoteView.vue:27) | `.proxy-layout` + `.proxy-detail` | `stats-row` + 2 张 `section-card` | 数量少,未出现平铺 |
| FrpView.vue | 规则列表 + 弹窗 | 弹窗内分 tab | — |
全站通行模式:**指标用 `stats-row`,内容用 `HavlineCard.section-card`**,两者语义不同、不混用。这一点上「公网服务端」页是偏离的。
## 三、发现的问题
### A. 信息架构
**A1 同一份详情做了两套。** 主区已经完整展示了状态、依赖、生命周期、日志,工具栏上的「查看」又打开一个 `n-modal`,里面是基本信息 / 服务器状态 / 延迟曲线 / 代理 / 连接日志([第 633-667 行](F:/Fonu-main/havline/web/src/views/AgentView.vue:633))。两边字段重叠,用户不知道该点哪个,维护时也容易只改一边。
**A2 区块编号与渲染顺序不符。** 注释写着 `<!-- ④ Agent 生命周期(SSH) + ③ frps 控制:两列并排 -->`([第 239 行](F:/Fonu-main/havline/web/src/views/AgentView.vue:239)),但 ① ② 之后直接是 ④,编号失去导航作用,反而让人以为漏了 ③。
**A3 主区没有内部导航。** `.agent-detail` 里纵向堆了 6 个区块,要看到「输出与日志」至少滚动 4-5 屏。左侧切服务端、右侧滚很久,是这个页面最明显的使用负担。
**A4 `stats-row` 混装了两种语义的卡片。** 4 张 `.stat-card`(指标)与 2 张 `HavlineCard.section-card.vps-resource-card`(内容)共用一个 4 列网格。样式里已经要靠特判来收拾:
```css
/* 前四张卡每行三张(各占 2/6),VPS 资源与 VPS 证书各占 3/6 并排 */
/* VPS 资源:占满「环境与依赖」整行,避免单独占一格显得突兀 */
.dep-grid > .vps-resource-card { grid-column: span 3; }
```
注释本身就说明了"不协调"。
**A5 工具栏 7 个操作完全平权。** [第 76-83 行](F:/Fonu-main/havline/web/src/views/AgentView.vue:76):启动/停止、查看、日志、测速、校验、编辑、删除并列,全部 `size="small"`,其中「删除」是 `quaternary type="error"`,与「测速」视觉权重接近。DdnsView 的做法是主操作 / 次级 / 危险三级分组,值得对齐。
### B. 排版与代码可读性
**B1 缩进错乱(DOM 正确)。** [第 86 行](F:/Fonu-main/havline/web/src/views/AgentView.vue:86)起,「状态提示」到「输出与日志」的整段缩进比父级 `.server-toolbar` 浅两格,直到 [第 360 行](F:/Fonu-main/havline/web/src/views/AgentView.vue:360)附近才恢复。实际层级是对的(`</div>` 在 361-362 行才闭合 `.agent-detail` / `.agent-layout`),但读代码时无法判断区块归属,后续改结构极易改错层级。
**B2 单行过长。** Nginx / frps 运行时两张卡的字段全部写成单行,最长的超过 200 字符(例如 [第 233 行](F:/Fonu-main/havline/web/src/views/AgentView.vue:233)的"启动命令")。diff、review 和合并冲突都不友好。
**B3 图标体系不一致。** 端口监听卡用文本符号 `{{ port }} {{ portOn(port) ? '✓' : '✗' }}`([第 134 行](F:/Fonu-main/havline/web/src/views/AgentView.vue:134)),页面其余位置统一用 `n-icon`。
**B4 冗余字号规则。** `.stat-card__value` 定义了 30px([第 1841 行](F:/Fonu-main/havline/web/src/views/AgentView.vue:1841)),但本页所有 stat-card 都加了 `--sm`(18px),30px 规则形同虚设,多半是从 FrpView 迁入时留下的。
### C. 文件结构
**C1 单文件职责过载。** 2244 行承担 7 类功能,`n-modal` 表单就占 225 行。任何一处调整都要在 136 KB 的文件里定位。
**C2 与 DDNS 页"同构"却各写一套。** [第 25 行](F:/Fonu-main/havline/web/src/views/AgentView.vue:25)注释自己写明「与 DDNS 页(`.ddns-layout` / `.task-sidebar`)同构」,但类名与样式完全重复实现(`.agent-layout`/`.server-sidebar` vs `.ddns-layout`/`.task-sidebar`)。
**C3 样式分两段来源。** [第 2064 行](F:/Fonu-main/havline/web/src/views/AgentView.vue:2064)有注释「以下样式随「公网服务端」列表与弹窗从内网穿透页迁入(2026-09-24)」,与前半段并存,存在历史迁移残留。
### D. 交互细节
**D1 侧栏高度写死。** `.server-sidebar__list { max-height: 720px; overflow: auto; }`([第 2221 行](F:/Fonu-main/havline/web/src/views/AgentView.vue:2221)),服务端多时会出现"侧栏内部滚动 + 页面滚动"双重滚动。
**D2 选中态偏弱。** `.server-item--active` 只有边框色与一圈 1px 阴影,在深色主题下辨识度有限。
**D3 值得保留的部分。** `EmptyState` + `LoadError` + `statusAlert`(互斥最多一条)的设计是好的,不要在这次调整中丢掉。
## 四、优化方案
按"改动量 / 收益"排成三档,可分批做。
### 方案 A:低风险整理(建议先做)
不改信息架构,只修可读性与一致性。
1. 修正 86-361 行的缩进,使层级与 DOM 一致(纯格式)
2. 修正 ①②③④⑤ 注释编号,与渲染顺序对齐;或直接删掉编号,避免误导
3. 长单行字段拆行(Nginx / frps 运行时两张卡)
4. 端口监听卡的 `✓`/`✗` 换成 `n-icon`(如 `CheckmarkOutline` / `CloseOutline`)
5. 把 VPS 资源 / VPS 证书从 `stats-row` 移到独立的 `section-card` 容器,去掉 `grid-column: span 3` 特判
6. 工具栏按三级重组:`启动/停止`(主)→ `查看 / 日志 / 测速 / 校验 / 编辑`(次级)→ `删除`(危险,右侧分离)
预期:不改变用户可见的信息,但代码可读性与操作层级明显改善。
### 方案 B:信息架构调整(推荐)
1. **消除重复详情**:主区保留完整详情,把「查看」弹窗改为「深度诊断」并只保留主区没有的内容(延迟曲线、代理列表、连接日志),或直接删除该入口。二选一,不要并存。
2. **主区改 tab**:把 ①~⑤ 收进 `n-tabs`,例如
- `概览`:stats-row + VPS 资源 / 证书
- `环境与依赖`:dep-grid 三张卡
- `运行控制`:控制网格两张卡
- `日志与输出`:现有输出与日志卡
这样主区高度从"5 屏"收敛到"1 屏 + 按需切换",与本页"左侧选服务端、右侧看状态"的用法一致。
3. **侧栏高度自适应**:`.server-sidebar__list` 去掉固定 `max-height`,改由父级 `align-items: start` 与页面滚动统一处理,避免双滚动。
4. **选中态加强**:在现有边框基础上加左侧 2px 强调条或背景色差,提升深色主题下的辨识度。
### 方案 C:结构重构(收益最大,风险也最高)
1. 抽公共组件 `ListDetailLayout`(sidebar + detail 网格),由本页与 DdnsView 共用,消除 C2 的重复实现。
2. 拆分本页为多个子组件,建议边界:
- `ServerListSidebar.vue`
- `ServerOverviewPanel.vue`(stats + VPS 资源/证书)
- `ServerDependencyPanel.vue`
- `ServerControlPanel.vue`(生命周期 + frps 控制)
- `ServerFormModal.vue`(225 行表单)
- `ServerLogsPanel.vue`
3. 清理 C3 的历史迁移样式段,确认无引用后合并去重。
这一步做完,单文件预计从 2244 行降到 400 行以内,但需要回归测试本页全部交互(安装 Agent、切换传输模式、下发 frps 配置、启停 frps、轮换证书)。
## 五、建议的推进顺序
| 阶段 | 内容 | 风险 | 可独立上线 |
| --- | --- | --- | --- |
| 第一步 | 方案 A 全部 6 项 | 低(纯格式与样式) | 是 |
| 第二步 | 方案 B 的 1、2 项(去重复详情 + 主区 tab) | 中(改交互路径) | 是 |
| 第三步 | 方案 B 的 3、4 项 | 低 | 是 |
| 第四步 | 方案 C 拆分 | 中高(涉及多组件) | 是,建议按组件逐个剥离 |
## 六、不做的事
为避免过度重构,以下不在本次范围内:
1. **不改后端接口**:本次全部为前端结构与样式调整。
2. **不合并「公网服务端」与「公网反代」两个页面**:两者职责不同(一个管 Agent/frps 主机,一个管域名路由),页面之间已有 `.entrance-row` 引导,保持独立。
3. **不改动 `statusAlert` 互斥提示机制**与 `EmptyState` / `LoadError` 的既有约定。
4. **不动 `n-modal` 的字段与提交逻辑**:方案 C 拆分时只搬移结构,不重写表单校验。
## 七、验证方式
每阶段完成后需要确认:
1. `npm run typecheck` 与 `npm run build` 通过
2. 在 3 档宽度下目视核对布局:≥1280px(双列)、1100px(切换单列断点)、≤760px(移动端)
3. 深色 / 浅色两套主题下检查选中态、告警、徽标对比度
4. 关键交互回归:切换服务端、安装/升级 Agent、切换传输模式(tunnel / https-pin / http)、下发 frps 配置、启停 frps、打开日志
+951
View File
@@ -0,0 +1,951 @@
# Agent、Nginx、frps 安装日志与进度方案
## 1. 背景
当前在界面执行以下操作时,按钮只显示加载圈,用户无法判断任务处于哪个阶段:
- 安装或升级 Agent
- 安装 Nginx
- 安装或升级 frps
这些操作耗时较长,尤其是:
- SSH 上传 Agent 二进制
- `apt-get update` 与 `apt-get install nginx`
- 下载 frps 安装包
- 校验 SHA-256
- 解压 tar.gz
- 写入 systemd unit
- 启动或重启服务
用户目前只能在操作结束后看到最终输出,失败时也无法判断是网络、SSH、包管理器、下载还是 systemd 阶段的问题。
## 2. 当前实现分析
### 2.1 Agent 安装
调用链:
```text
AgentView.vue
-> POST /api/frpmulti/servers/{id}/agent/install
-> internal/api/frpmulti_handler.go
-> Service.agentInstall
-> AgentInstaller.Install
-> SSHInstaller.Install
-> uploadBytes + runSSHSession
```
特点:
- 安装确实通过 SSH 执行,不依赖 Agent 的 HTTPS 或隧道。
- `uploadBytes` 当前一次性写入二进制,没有上传进度。
- `runSSHSession` 使用 `session.Stdout` 和 `session.Stderr` 收集输出,但只在结束后返回。
- 前端 `run()` 只负责按钮 loading 和最终输出,没有实时事件通道。
- 安装输出里包含 `Token: ...`,如果直接展示日志,必须先脱敏。
### 2.2 Nginx 安装
调用链:
```text
ProxyRemoteView.vue
-> POST /api/frpmulti/servers/{id}/agent/nginx/install
-> Service.InstallAgentNginx
-> AgentClient.InstallNginx
-> Agent POST /api/v1/nginx/install
-> Server.installNginx
```
特点:
- 真正的安装在 VPS Agent 进程内执行。
- 当前使用 `exec.CommandContext(...).CombinedOutput()`,命令结束后才能拿到输出。
- `apt-get update`、`dnf install`、`yum install`、`apk add` 都可能持续数分钟。
- Agent 的 HTTP 响应是一次性的,中间过程没有 job、SSE 或状态查询接口。
### 2.3 frps 安装
调用链:
```text
AgentView.vue
-> POST /api/frpmulti/servers/{id}/agent/frps-install
-> Service.InstallFRPSOnAgent
-> AgentClient.InstallFRPS
-> Agent POST /api/v1/frps/install
-> Server.installFRPS
```
特点:
- `downloadToFile` 当前没有下载进度回调。
- SHA-256 校验、解压、写 unit、systemctl 重启都只在最终响应里返回 notes。
- 安装完成后,中央端还会继续下发 `frps.toml`,这一步也应该纳入任务日志。
### 2.4 现有可复用能力
项目已有两类进度模式:
1. 证书申请任务
- `internal/acme/job.go`
- `POST /api/certificates/apply` 返回 `job_id`
- `GET /api/certificates/jobs/{id}/stream` 使用 SSE
- 前端 `CertificatesView.vue` 使用 `EventSource`
2. FRP 客户端下载任务
- `internal/frp/service.go` 的 `DownloadTask`
- `GET /api/frpmulti/download/status`
- 前端 `FrpView.vue` 每 500ms 轮询
- 支持 `downloaded`、`total`、`message`、`status`
建议以 ACME 的 SSE 任务模型为主,以 FRP 下载的进度字段为补充,不新造第三套机制。
## 3. 方案目标
### 3.1 用户目标
- 点击安装后立即看到任务已经开始。
- 能看到当前阶段,例如“连接 SSH”“上传 Agent”“下载 frps”“校验安装包”“重启服务”。
- 能看到实时日志,而不是只看圈圈。
- 能区分“确定进度”和“不确定进度”。
- 失败时能定位到具体阶段和最后几行日志。
- 刷新页面或切换页面后,任务不丢,回来还能继续看。
### 3.2 工程目标
- 统一 Agent 安装、Nginx 安装、frps 安装的任务模型。
- 复用现有 SSE、认证、前端 EventSource 经验。
- 保持旧 Agent 兼容,不要求所有 VPS 同时升级。
- 日志必须脱敏,不能把 Agent Token 或 SSH 凭据输出到浏览器。
- 多服务器之间任务完全隔离。
- 同一服务器同一安装类型不允许并发执行。
## 4. 总体架构
### 4.1 两类执行域
需要明确区分:
```text
中央 Havline 服务
-> 通过 SSH 执行 Agent 安装
VPS Agent
-> 在 VPS 本机执行 Nginx 安装
-> 在 VPS 本机执行 frps 安装
```
因此不能只做一套任务管理器:
- Agent 安装任务由中央服务管理。
- Nginx 和 frps 安装任务由 Agent 管理,中央服务负责桥接和转发。
### 4.2 数据流
```mermaid
sequenceDiagram
participant Browser as 浏览器
participant Central as Havline 中央服务
participant SSH as VPS SSH
participant Agent as VPS Agent
Browser->>Central: POST 创建安装任务
Central-->>Browser: 202 { job_id }
Browser->>Central: GET /jobs/{id}/stream
alt Agent 安装
Central->>SSH: SSH 上传并安装
SSH-->>Central: 实时 stdout / stderr
Central-->>Browser: SSE log / progress / done
else Nginx 或 frps 安装
Central->>Agent: POST 创建 Agent 任务
Agent-->>Central: { job_id }
Central->>Agent: GET /jobs/{id}/stream
Agent-->>Central: 实时日志和进度
Central-->>Browser: 转发为中央 SSE
end
```
### 4.3 为什么选择 SSE
推荐使用 SSE,而不是 WebSocket:
- 项目已有 SSE 实现和前端 EventSource 使用经验。
- 安装日志是单向流,不需要双向通信。
- 更容易通过 Nginx、隧道和现有代理。
- 断线后可以重新连接并回放快照。
轮询作为兼容兜底:
- 某些代理环境可能缓冲 SSE。
- 旧 Agent 不支持 SSE 时,中央服务可以轮询 Agent 任务状态。
- 前端 SSE 失败时,可以退化为轮询中央任务状态。
## 5. 统一任务模型
### 5.1 任务字段
建议新增共享任务结构:
```go
type OperationJob struct {
ID string
ServerID int64
Kind string
Status string
Phase string
Message string
Current int64
Total int64
Percent float64
Error string
StartedAt time.Time
UpdatedAt time.Time
FinishedAt *time.Time
}
```
`Kind` 建议值:
```text
agent-install
nginx-install
frps-install
```
`Status` 建议值:
```text
queued
running
completed
failed
cancelled
```
`Phase` 建议值:
Agent 安装:
```text
prepare
ssh-connect
upload-agent
upload-script
install-agent
verify-agent
configure-transport
done
```
Nginx 安装:
```text
detect-package-manager
update-package-index
install-package
enable-service
probe-nginx
done
```
frps 安装:
```text
fetch-checksum
download-package
verify-checksum
extract-binary
write-systemd-unit
restart-service
push-config
probe-frps
done
```
### 5.2 SSE 事件
统一事件格式:
```json
{
"type": "log",
"job_id": "20261006-001",
"kind": "nginx-install",
"status": "running",
"phase": "install-package",
"level": "info",
"message": "正在执行 apt-get install -y nginx",
"current": 0,
"total": 0,
"percent": 0,
"timestamp": "2026-10-06T10:00:00Z"
}
```
事件类型:
```text
snapshot 连接后先发送当前完整状态
phase 阶段变化
log stdout / stderr 日志
progress 确定进度,例如下载字节数
done 任务完成
error 任务失败
heartbeat 心跳,防止代理断开
```
进度事件示例:
```json
{
"type": "progress",
"phase": "download-package",
"message": "正在下载 frps 安装包",
"current": 18350080,
"total": 45613056,
"percent": 40.2
}
```
不确定进度示例:
```json
{
"type": "phase",
"phase": "install-package",
"message": "正在安装 Nginx,包管理器未提供可解析进度"
}
```
## 6. 中央服务设计
### 6.1 新增共享任务包
建议新增:
```text
internal/opjob/job.go
internal/opjob/manager.go
internal/opjob/sse.go
```
职责:
- 创建任务
- 保存事件快照
- 管理订阅者
- 发送 SSE
- 限制日志缓冲区
- 任务完成和过期清理
`internal/acme/job.go` 后续可以迁移到该包,避免重复维护。
### 6.2 中央任务管理器
任务管理器需要支持:
- `Create(serverID, kind)`
- `Get(jobID)`
- `Publish(jobID, event)`
- `Finish(jobID, result)`
- `Stream(ctx, jobID, writer)`
- `Latest(serverID, kind)`
- `CleanupExpired`
内存策略:
- 每个任务最多保留 500 到 1000 条日志事件。
- 超出后丢弃最旧日志,并插入“日志已截断”提示。
- 已完成任务保留 30 分钟。
- 服务重启后任务丢失,前端提示“任务记录已丢失,请重新执行”。
### 6.3 中央 API
推荐统一操作入口:
```http
POST /api/frpmulti/servers/{id}/operations
Content-Type: application/json
{
"kind": "agent-install",
"params": {
"trust_new_host_key": false
}
}
```
返回:
```json
{
"job_id": "20261006-abc123",
"status": "queued"
}
```
其他接口:
```http
GET /api/frpmulti/jobs/{jobID}
GET /api/frpmulti/jobs/{jobID}/stream
GET /api/frpmulti/servers/{id}/jobs/latest?kind=nginx-install
```
兼容策略:
- 保留现有同步安装接口一个版本周期。
- 新前端优先使用 `/operations` 和 SSE。
- 旧 Agent 不支持任务流时,中央任务使用兼容模式。
### 6.4 中央任务隔离
必须按 `server_id` 隔离:
- 任务查询校验 `server_id` 与当前登录会话权限。
- 任务流校验任务属于目标服务器。
- 同一服务器同一 `kind` 只允许一个运行中任务。
- 不同服务器可以并行安装,互不阻塞。
- 前端切换服务器时关闭旧 EventSource,重新连接新服务器任务。
## 7. Agent 侧任务设计
### 7.1 Agent 任务管理器
建议新增:
```text
internal/agent/jobs.go
internal/agent/job_handlers.go
```
Agent 任务接口:
```http
POST /api/v1/jobs/nginx/install
POST /api/v1/jobs/frps/install
GET /api/v1/jobs/{id}
GET /api/v1/jobs/{id}/stream
```
鉴权:
- 继续使用 Agent Token。
- 浏览器不直接连接 Agent SSE。
- 中央服务使用 `Authorization: Bearer <token>` 读取 Agent SSE。
### 7.2 Nginx 安装改造
当前:
```go
out, err := install.CombinedOutput()
```
改造为:
```go
stdout, _ := cmd.StdoutPipe()
stderr, _ := cmd.StderrPipe()
startStreamScanner(stdout, job.Log)
startStreamScanner(stderr, job.Log)
err := cmd.Run()
```
阶段:
1. `detect-package-manager`
2. `update-package-index`
3. `install-package`
4. `enable-service`
5. `probe-nginx`
进度策略:
- apt、dnf、yum、apk 的安装进度无法统一解析。
- 使用阶段进度,例如 0、15、45、75、90、100。
- UI 使用 indeterminate 进度条,同时显示实时日志。
### 7.3 frps 安装改造
当前:
```go
downloadToFile(downloadURL, tarball)
```
改造为:
```go
downloadToFileWithProgress(downloadURL, tarball, func(current, total int64) {
job.Progress("download-package", current, total)
})
```
阶段和进度:
| 阶段 | 进度 | 说明 |
| --- | ---: | --- |
| fetch-checksum | 5% | 获取官方校验文件 |
| download-package | 5% 到 60% | 按字节数计算真实进度 |
| verify-checksum | 65% | 校验 SHA-256 |
| extract-binary | 75% | 解压 frps |
| write-systemd-unit | 85% | 写入或接管 unit |
| restart-service | 95% | systemctl enable/restart |
| push-config | 98% | 中央下发 frps.toml |
| probe-frps | 100% | 探测最终状态 |
### 7.4 Agent 并发控制
同一 Agent 上:
- Nginx 安装不能并发执行。
- frps 安装不能并发执行。
- 包管理器安装建议全局串行,避免 apt/dpkg 锁冲突。
- 下载任务可以独立,但最终写 unit 和重启服务要串行。
### 7.5 Agent 任务取消
建议 P1 实现:
```http
POST /api/v1/jobs/{id}/cancel
```
取消行为:
- 取消 context。
- 对 apt/dnf/yum/apk 使用进程组 kill。
- 保留已产生日志。
- 标记任务为 `cancelled`。
## 8. 中央桥接 Agent 任务
### 8.1 AgentClient 新方法
在 `internal/frp/agent_client.go` 增加:
```go
StartAgentJob(ctx, kind, payload) (string, error)
AgentJobSnapshot(ctx, jobID) (OperationJob, error)
StreamAgentJob(ctx, jobID, onEvent func(Event) error) error
```
注意:
- 现有 `AgentClient.http` 有 60 秒总超时,不适合 SSE。
- 新增无总超时的 `streamHTTP` client。
- SSE 读取使用 `bufio.Scanner`,设置最大行长度。
- 连接中断时自动重试快照或重新订阅。
### 8.2 中央任务执行器
```text
创建中央 job
-> 启动 Agent job
-> 订阅 Agent SSE
-> 将 Agent 事件发布到中央 job
-> Agent 完成
-> 中央执行后续步骤
-> 中央 job 完成
```
frps 的后续步骤:
```text
agent frps install done
-> 生成 frps.toml
-> 下发 frps.toml
-> 探测 frps 状态
-> 完成
```
### 8.3 旧 Agent 兼容
新增最低版本常量,例如:
```go
const agentMinVersionJobs = "0.16.0"
```
旧 Agent 处理:
- 中央检测到 Agent 不支持 `/api/v1/jobs/*`。
- 中央仍创建任务,但只发布阶段事件。
- 调用旧同步接口。
- 完成后发布最终日志和结果。
- UI 提示“当前 Agent 版本不支持实时日志,已使用兼容模式”。
## 9. 前端设计
### 9.1 新 composable
建议新增:
```text
web/src/composables/useOperationJob.ts
```
职责:
- 创建任务
- 建立 EventSource
- 维护任务状态
- 维护日志数组
- 自动重连
- 页面隐藏时暂停 UI 更新
- 组件卸载时关闭 EventSource
状态示例:
```ts
interface OperationJobState {
jobId: string
kind: string
status: 'idle' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
phase: string
message: string
percent: number
current: number
total: number
logs: OperationLogLine[]
error: string
result?: Record<string, unknown>
}
```
### 9.2 新组件
建议新增:
```text
web/src/components/OperationProgressDrawer.vue
```
布局:
```text
标题:安装 Agent / 安装 Nginx / 安装 frps
状态:运行中 / 已完成 / 失败
阶段:准备 -> SSH -> 上传 -> 安装 -> 校验 -> 完成
进度条:确定进度或 indeterminate
日志框:实时 stdout / stderr
底部:复制日志、下载日志、取消、关闭
```
交互:
- 安装开始后自动打开抽屉。
- 日志自动滚动,用户手动上滚后暂停自动滚动。
- 失败时显示错误摘要和“查看完整日志”。
- 完成后显示最终结果,例如 Agent 版本、Nginx 路径、frps 版本。
- 关闭抽屉不取消任务。
### 9.3 页面接入
Agent 页面:
- “安装 Agent / 升级 Agent”改为创建 `agent-install` 任务。
- “安装 / 升级 frps”改为创建 `frps-install` 任务。
公网反代页面:
- “一键安装 Nginx”改为创建 `nginx-install` 任务。
现有 `opOutput`:
- 保留为历史摘要。
- 实时日志迁移到新抽屉。
- 完成后仍然写入历史摘要,方便回看。
### 9.4 页面刷新和切换
建议在浏览器保存最近一次任务:
```text
localStorage["havline:last-job:{serverID}:{kind}"] = jobID
```
页面加载时:
1. 读取最近 jobID。
2. 请求 `GET /api/frpmulti/jobs/{jobID}`。
3. 如果任务仍在运行,恢复日志面板。
4. 如果任务已完成或过期,清理记录。
服务端同时提供:
```http
GET /api/frpmulti/servers/{id}/jobs/latest?kind=agent-install
```
用于跨浏览器恢复。
## 10. 日志脱敏
这是本方案的安全硬要求。
### 10.1 必须脱敏的内容
- `Token: ...`
- `HAVLINE_AGENT_TOKEN=...`
- `Authorization: Bearer ...`
- SSH 密码
- 私钥内容
- 证书私钥
- 可能包含密码的命令行
### 10.2 脱敏位置
必须在以下位置之前脱敏:
- Agent 任务事件发送前
- 中央任务事件发送前
- SSE 写入前
- 历史日志保存前
不能只在前端隐藏,因为浏览器和日志接口仍可能看到明文。
### 10.3 示例
原始:
```text
Token: 8f3c2e...
```
输出:
```text
Token: ********
```
## 11. 性能与稳定性
### 11.1 日志节流
apt、dnf、systemctl 输出可能非常密集。
策略:
- 每 100ms 批量发送一次日志。
- 单条日志超过 4KB 时截断。
- 单任务最多保留 1000 条事件。
- 超出后丢弃旧日志并发送“日志已截断”。
### 11.2 SSE 心跳
- 每 15 秒发送一次 heartbeat。
- 避免 Nginx、隧道、浏览器代理因空闲断开。
### 11.3 背压
- 订阅通道使用有缓冲 channel。
- 慢订阅者不能阻塞任务执行。
- 状态、phase、done、error 事件必须可靠送达。
- 普通日志在缓冲满时可以丢弃并标记。
### 11.4 超时
建议:
| 操作 | 超时 |
| --- | ---: |
| Agent 安装 | 10 分钟 |
| Nginx 安装 | 10 分钟 |
| frps 安装 | 15 分钟 |
| SSE 空闲 | 不超时,使用 heartbeat |
| Agent SSE 重连 | 指数退避,最多 3 次 |
## 12. 错误处理
### 12.1 SSH 断开
- 任务标记失败。
- 保留已经收到的日志。
- 提示“SSH 连接中断,Agent 是否安装成功未知,请先点击探测环境”。
### 12.2 Agent 重启
- 中央连接 Agent SSE 时返回 404。
- 任务标记失败。
- 提示“Agent 已重启,任务状态丢失,请重新执行或探测当前状态”。
### 12.3 浏览器断开
- 任务继续执行。
- 浏览器重新连接后从 snapshot 开始回放。
### 12.4 中央服务重启
- 内存任务丢失。
- 前端收到任务不存在。
- 提示“任务记录已丢失,请重新执行或查看实际状态”。
## 13. 分阶段实施
### 阶段 1:统一任务基础设施
目标:
- 新增 `internal/opjob`。
- 实现 Job、Manager、SSE。
- 支持 snapshot、log、phase、progress、done、error、heartbeat。
- 增加单元测试。
不改用户可见行为。
### 阶段 2:Agent 安装实时日志
目标:
- 新增中央 `agent-install` 任务。
- SSH 上传、安装脚本输出实时推送。
- 前端新增 `OperationProgressDrawer`。
- Agent 页面接入。
这是收益最高、依赖最少的一步。
### 阶段 3:Nginx 安装实时日志
目标:
- Agent 增加 job 接口。
- `installNginx` 改造为流式执行。
- 中央桥接 Agent SSE。
- 公网反代页面接入。
- 旧 Agent 使用兼容模式。
### 阶段 4:frps 安装进度
目标:
- Agent frps 下载增加字节进度。
- 校验、解压、systemd、配置下发纳入任务阶段。
- Agent 页面接入。
### 阶段 5:任务恢复和取消
目标:
- 浏览器刷新恢复任务。
- 任务历史查询。
- 取消任务。
- 任务中心或统一历史面板。
### 阶段 6:持久化任务历史
可选:
- 将已完成任务摘要写入 SQLite。
- 支持按服务器、类型、时间筛选。
- 支持导出诊断包。
## 14. 文件影响清单
### 14.1 新增
```text
internal/opjob/job.go
internal/opjob/manager.go
internal/opjob/sse.go
internal/opjob/job_test.go
internal/opjob/sse_test.go
internal/agent/jobs.go
internal/agent/job_handlers.go
web/src/composables/useOperationJob.ts
web/src/composables/useOperationJob.spec.ts
web/src/components/OperationProgressDrawer.vue
```
### 14.2 修改
```text
internal/frp/ssh_installer.go
internal/frp/agent_installer.go
internal/frp/agent_client.go
internal/frp/service.go
internal/api/frpmulti_handler.go
internal/api/router.go
internal/agent/server.go
internal/agent/transport.go
web/src/api/client.ts
web/src/api/types.ts
web/src/views/AgentView.vue
web/src/views/ProxyRemoteView.vue
web/src/views/FrpView.vue
web/vite.config.ts
```
## 15. 测试计划
### 15.1 后端单元测试
- Job 快照回放。
- 多订阅者同时接收。
- done 后订阅自动结束。
- 日志缓冲区上限。
- SSE 心跳。
- 脱敏规则。
- 同一服务器同一任务并发拒绝。
- 不同服务器任务隔离。
- 旧 Agent 兼容回退。
### 15.2 后端集成测试
- 假 SSH 服务器返回多行输出,验证 Agent 安装事件顺序。
- 假 Agent 返回 SSE,验证中央桥接。
- frps 下载模拟慢速 reader,验证进度百分比。
- SSE 中途断开,验证重连和快照恢复。
### 15.3 前端测试
- `useOperationJob` 创建任务并连接 EventSource。
- snapshot、log、progress、done、error 状态更新。
- EventSource 重连。
- 组件卸载时关闭连接。
- 日志自动滚动与暂停。
- 安装按钮在任务运行中禁用。
### 15.4 手工验收
- 慢速 VPS 安装 Agent,能看到上传和安装日志。
- Ubuntu 安装 Nginx,能看到 apt 输出。
- CentOS 安装 Nginx,能看到 yum/dnf 输出。
- frps 下载慢时能看到百分比。
- 安装中途刷新页面,回来后仍能看到任务。
- 切换到另一台服务器,不会串台。
- 日志中没有 Token 和密码。
## 16. 验收标准
满足以下条件才算完成:
1. Agent 安装不再只显示圈圈,至少显示阶段和实时日志。
2. Nginx 安装能显示包管理器实时输出。
3. frps 安装能显示下载百分比、校验、解压和 systemctl 结果。
4. 安装失败时能指出失败阶段和最后错误。
5. 浏览器刷新或切换页面后,任务不丢。
6. 多服务器任务完全隔离。
7. 同一服务器同一安装类型不能重复并发。
8. Agent Token、SSH 凭据、私钥不会出现在日志中。
9. 旧 Agent 仍然可以安装 Nginx 和 frps,只是没有实时日志。
10. 后端、前端测试全部通过。
## 17. 推荐实施顺序
优先级从高到低:
1. 中央 `opjob` 任务基础设施。
2. Agent 安装实时日志。
3. 前端通用进度抽屉。
4. Agent 侧 Nginx job。
5. 中央桥接 Agent SSE。
6. frps 下载进度。
7. 任务恢复。
8. 任务取消。
9. 任务历史持久化。
先做第 1 到第 3 步,可以最快解决“只有圈圈”的核心体验问题,而且不需要先升级所有 VPS Agent。
+397
View File
@@ -0,0 +1,397 @@
# Havline 项目结构与实现分析(v1.9.0)
> 分析日期:2026-10-03
> 分析对象:`F:\Fonu-main\havline`
> 版本来源:根目录 `VERSION`,当前为 `1.9.0`
> 分析方式:重新扫描工作区目录,阅读启动入口、应用装配、路由、配置、Docker 构建、Compose 编排、前端路由、数据库迁移和现有文档。本文是当前目录的结构快照,不替代带日期的历史方案文档。
## 一、结论摘要
Havline 是面向飞牛 fnOS 和家庭 NAS 的公网访问管理控制台。项目以 Go 单体应用作为本地控制面,以 Vue 3 管理后台作为操作界面,以 SQLite 和 `/data` 数据目录保存状态,并通过 Nginx、FRP、Cloudflare Tunnel、DDNS 和 ACME 证书把内网服务发布到公网。
当前版本已经从单一的本机反向代理工具扩展为多角色运维系统:
1. 本机 Nginx 反向代理与配置版本管理;
2. 多 DNS 服务商 DDNS 与记录同步;
3. ACME 证书申请、续期、导入和下载;
4. FRP 多服务端、代理规则、运行状态、流量和诊断;
5. 公网 `havline-agent` 对 VPS 上的 frps、Nginx、证书和路由进行远程管理;
6. Cloudflare Tunnel 的本地托管、路由同步、DNS、健康和漂移管理;
7. 日志、指标、通知、备份恢复、服务发现和一键升级。
从工程形态看,项目的核心不是简单 CRUD,而是「控制面 + 外部进程编排 + 远程节点管理 + 配置生成与回滚 + 周期任务」的组合系统。复杂度最高的区域仍然是 FRP/Agent;Cloudflare Tunnel 已成为第二个需要持续关注的外部系统集成边界。
## 二、目录边界与工作区现状
外层 `F:\Fonu-main` 是工作区容器,包含多个交付形态和临时产物;当前主源码目录是 `havline`,而不是主动文件路径中显示的历史 `Fonu-1.0.2` 目录。
```text
F:\Fonu-main
├─ havline/ 当前主源码与 Git 项目内容
│ ├─ cmd/ Go 程序入口
│ ├─ internal/ 后端领域模块
│ ├─ migrations/ SQLite 数据库迁移 001–036
│ ├─ web/ Vue 3 + TypeScript 前端
│ ├─ docs/ 当前项目文档、方案和审查记录
│ ├─ scripts/ 构建、安装、升级脚本
│ ├─ Dockerfile 多阶段 Docker 构建
│ ├─ docker-compose*.yml 三种 Compose 部署形态
│ ├─ VERSION 版本单一来源,当前 1.9.0
│ └─ CHANGELOG.md 版本变更记录
├─ fnos/ fnOS 打包交付物
├─ fnos-native/ fnOS 原生应用工程及其 app-src 镜像
├─ havline-docs/ 独立 Nuxt 文档站点
├─ docs/ 外层临时资料和 FRP 迁移资料
└─ .tmp-* / 日志 / 数据库 本地构建、测试和运行产物
```
`havline` 目录内当前可见的结构规模:
| 区域 | 当前观察 |
| --- | --- |
| Go 后端 | `internal` 下约 281 个 Go 文件,包含业务代码和测试代码 |
| 数据库迁移 | 36 个 SQL 迁移,从 `001_init.sql` 到 `036_cf_health.sql` |
| 前端页面 | 14 个 Vue 页面,覆盖登录、仪表盘、代理、FRP、Cloudflare、日志和设置 |
| 前端技术 | Vue 3.5、TypeScript、Vite、Naive UI、ECharts、CodeMirror、Vitest |
| 后端技术 | Go 1.23、标准库 HTTP、SQLite、ACME、SSH、多个 DNS/Cloudflare SDK |
| 交付架构 | Docker bridge、Docker host、Git URL 构建、Linux 原生部署、fnOS 应用打包 |
外层还存在 `.tmp-guide-data`、`.tmp-native`、`.tmp-push`、`.data` 和日志等运行或验证产物。它们适合排查本地环境,不应被当成源码、正式数据迁移或发布文档的来源。
## 三、总体架构
```mermaid
flowchart LR
Browser[浏览器] -->|HTTP :6893| App[Havline 控制面]
App --> DB[(SQLite /data/havline.db)]
App --> FS[/data 数据目录]
App --> Nginx[Nginx 配置与进程]
Nginx --> NAS[NAS 内网服务]
App --> DNS[DNS 服务商]
App --> ACME[ACME CA]
App --> Scheduler[后台调度器]
App --> FRPC[本机 frpc]
FRPC --> FRPS[公网 VPS frps]
App --> Agent[公网 havline-agent]
Agent --> VPSNginx[VPS Nginx]
App --> Cloudflared[本机 cloudflared]
Cloudflared --> Cloudflare[Cloudflare Edge]
App --> Updater[受限 updater sidecar]
```
### 3.1 运行角色
| 角色 | 位置 | 主要职责 |
| --- | --- | --- |
| `havline` | NAS、Docker 或 Linux 主机 | 管理后台、本机 Nginx、DDNS、证书、FRP、Cloudflare、调度器和数据 |
| `havline-agent` | 公网 VPS | 远程管理 VPS Nginx、frps、证书、路由和诊断 |
| Nginx | NAS 与 VPS 两端 | 提供本机反代、HTTPS 终止和公网入口 |
| frpc / frps | NAS / VPS | 在没有公网入站条件时建立 FRP 隧道 |
| cloudflared | NAS | 将选定的服务通过 Cloudflare Tunnel 发布 |
| updater | Docker sidecar | 持有 Docker Socket,通过 Unix Socket 接收受限升级请求 |
| SQLite | `/data/havline.db` | 保存配置、账号、运行状态、健康事件和历史数据 |
### 3.2 典型访问链路
本机反代链路:
```text
公网客户端 → NAS Nginx → NAS 内网服务
```
FRP 链路:
```text
公网客户端 → VPS DNS/Nginx → frps → frpc → NAS Nginx → 内网服务
```
Cloudflare 链路:
```text
公网客户端 → Cloudflare Edge → cloudflared → NAS 内网服务
```
管理链路:
```text
浏览器 → Havline HTTP API → 领域 Service → SQLite / 文件系统 / 外部进程 / 远程 Agent
```
## 四、启动与依赖装配
主入口是 `cmd/havline/main.go`,启动过程较集中:
1. `config.Load()` 读取监听地址、数据目录、Nginx、frpc 和 updater 配置;
2. `app.ResolveMigrationsDir()` 从当前目录及上级候选目录寻找迁移文件;
3. 通过 `go:embed` 将 `web/dist` 嵌入主程序;
4. `app.New()` 创建数据目录和日志目录;
5. 解析会话密钥;
6. 打开 SQLite,按序执行迁移;
7. 创建加密盒、管理员服务、各领域 Store/Service;
8. 注册 API 路由和静态 SPA;
9. 启动 FRP Agent 隧道、Cloudflare 隧道、定时调度、流量采集;
10. 初始化 Nginx 配置,恢复 FRP 运行状态并执行自动启动;
11. 监听 `SIGINT` / `SIGTERM`,按超时上下文关闭 HTTP、Nginx、FRP 和数据库。
`internal/app/app.go` 是整个后端的组合根。它的优点是依赖关系可直接追踪;风险是任何新模块都需要修改装配代码,长期可能继续增大启动函数的耦合度。
## 五、后端模块职责
### 5.1 API 层
`internal/api/router.go` 集中注册 HTTP API,并通过 `routeSpec` 标记公开路由和受保护路由。当前公开接口主要包括版本、登录状态、登录、登出和可选的公开状态页;业务接口默认经过 Session Middleware 和 `RequireAuth`。
API 领域覆盖:
- `/api/proxies`:本机反向代理、日志流、客户端和 Nginx 规则;
- `/api/ddns`:DDNS 任务、测试和立即同步;
- `/api/certificates`:证书列表、申请、导入、续期和下载;
- `/api/frpmulti`:FRP 服务端、代理、运行时、Agent、诊断、指标和路由;
- `/api/cloudflare`:隧道、路由、DNS、健康、漂移、二进制和设置;
- `/api/backup`:导出、归档、下载、恢复和删除;
- `/api/logs`、`/api/metrics`:日志流、分页日志和指标历史;
- `/api/system/update`:升级状态和执行升级;
- `/api/settings`:系统设置、通知、Token 和中国 IP 段。
路由注册集中有利于鉴权覆盖测试,但文件本身已经较长。新增 API 时应同时补充处理器测试、鉴权覆盖和前端 API 类型,避免只注册后端路径而遗漏客户端契约。
### 5.2 领域模块
| 包 | 主要职责 |
| --- | --- |
| `config` | 环境变量、数据目录、默认端口、会话密钥解析 |
| `db` | SQLite 打开、迁移和数据库基础操作 |
| `auth` | 管理员、会话、密码和 API Token |
| `secret` | 敏感配置的加密存储 |
| `proxy` / `service` | 代理规则、出口、端口校验和业务编排 |
| `nginx` / `nginxtmpl` | Nginx 配置生成、语法检查、启动、重载、版本和回滚 |
| `ddns` | Cloudflare、DNSPod、阿里云、腾讯云、火山引擎等 DNS 服务商 |
| `acme` / `certificate` | ACME DNS-01、证书任务、导入、下载和元数据 |
| `frp` | 多服务端 FRP、frpc 进程、frps 配置、Agent、健康、漂移和流量 |
| `agent` | 公网 Agent HTTP 服务、路由、版本、传输和防火墙 |
| `cloudflared` | Cloudflare API、隧道进程、ingress、DNS、健康和漂移 |
| `monitor` / `metrics` | 周期巡检、健康事件和指标历史 |
| `scheduler` | DDNS、ACME、中国 CIDR、巡检和自动备份等周期任务 |
| `backup` | 数据导出、归档、恢复和自动备份保留策略 |
| `notify` | 邮件、Webhook、Telegram、钉钉、飞书和企业微信 |
| `logstore` / `traffic` | 应用日志、Nginx 日志、访问解析、流量和连接统计 |
| `discovery` | NAS 常见服务发现与候选配置 |
| `updater` | 主程序与升级 sidecar 之间的 Unix Socket 协议 |
### 5.3 外部进程与文件系统边界
Havline 不只读写数据库,还会生成和维护以下运行资源:
- `/data/nginx/nginx.conf`、规则目录、自定义配置和配置备份;
- `/data/certs` 证书和私钥;
- `/data/frp/frpc.toml`、PID 文件和运行日志;
- Cloudflare Tunnel 的二进制、配置和凭据;
- `/data/logs` 应用、Nginx 和 FRP 日志;
- `/data/ssh_known_hosts`,用于 SSH 主机密钥 TOFU 校验;
- `/data/session_secret`,用于未显式配置时持久化会话密钥。
这意味着备份、升级和恢复不能只关注 SQLite。完整实例恢复至少需要数据库、证书、Nginx 自定义配置、FRP/Cloudflare 运行文件和会话密钥保持一致。
## 六、数据与安全模型
### 6.1 数据持久化
Compose 默认把命名卷 `havline-data` 挂载到容器 `/data`,也可通过 `HAVLINE_DATA_PATH` 改为绑定目录。主数据库为 `/data/havline.db`,迁移已覆盖:
- 管理员、会话和系统设置;
- 本机代理及多 Host、端口、安全、流量和出口;
- DDNS 任务、域名记录和自定义公网 IP;
- 证书、ACME CA 和证书域名;
- FRP 服务端、选项、代理、运行状态、诊断、Agent 和健康;
- API Token、路由健康、指标采样;
- Cloudflare Tunnel、全局设置、API Token、Account ID 和健康事件。
截至当前目录,迁移编号到 `036_cf_health.sql`,说明 Cloudflare 健康事件已经进入数据库持久化边界。
### 6.2 会话密钥
`Config.ResolveSessionSecret()` 的实际优先级是:
1. 显式环境变量 `HAVLINE_SESSION_SECRET`;
2. 数据目录中的 `session_secret`;
3. 不存在或明显损坏时生成 32 字节随机值,以 64 个十六进制字符写入并设置为 `0600`。
该密钥同时用于会话签名和敏感凭据加密。已有数据后更换密钥会导致已保存的 DDNS、FRP、通知和 Cloudflare 等凭据无法解密,因此部署文档必须把密钥持久化和迁移作为数据恢复的一部分。
### 6.3 鉴权边界
管理 API 默认通过会话中间件和认证检查;API Token 用于自动化访问。公开状态页是唯一明确设计为匿名读取的业务页面,代码中还保留按 IP 限流和访问码控制的语义。远程 Agent 使用独立 Bearer Token、SSH 安装/诊断和可选的传输层能力。
主要安全关注点:
- updater sidecar 挂载 Docker Socket,必须限制其 Unix Socket 调用面和宿主机项目目录;
- Agent 若监听非回环地址,必须配合 Token、TLS/隧道和防火墙策略;
- 证书私钥、DNS 凭据、FRP Token 和 Cloudflare Token 需要同时保护数据库、数据卷和备份归档;
- 状态页和日志接口不能意外泄露内部域名、凭据或运行细节;
- Compose 文件中的 Git 构建地址、更新仓库和项目目录必须与实际发布源保持一致。
## 七、前端结构
前端入口通过 Vue Router 将页面分为公开区域和登录后的 AppLayout 区域:
```text
/login 登录
/status 公开状态页
/dashboard 仪表盘
/ddns DDNS
/certificates 证书管理
/proxies 本机反向代理
/frp 内网穿透
/frp-agent 公网 Agent
/cloudflare Cloudflare 隧道列表
/cloudflare/:id/config 隧道独立配置页
/public-proxy 公网反代
/logs 日志中心
/settings 系统设置
/about 关于项目
```
前端职责主要集中在页面组件、API Client、类型定义、状态/格式化工具和布局组件。技术栈包含:
- Vue 3.5 与 Vue Router 4;
- TypeScript 5.7 与 `vue-tsc`;
- Vite 6;
- Naive UI;
- ECharts 与 `vue-echarts`;
- CodeMirror 6,用于配置编辑;
- SortableJS,用于规则排序;
- Vitest,用于单元测试。
需要持续控制的维护成本是页面组件体量。代理、设置、Agent 和 Cloudflare 页面承载了较多状态、弹窗、表单、配置编辑和运行操作,后续新增功能应优先抽取可复用的表单、状态卡片、日志流和配置编辑子组件,避免继续扩大单文件复杂度。
## 八、部署与构建
### 8.1 Dockerfile
当前 Dockerfile 是多阶段构建:
1. 从 `VERSION` 读取版本;
2. 使用 Node 镜像构建前端,并将版本注入 `VITE_APP_VERSION`;
3. 使用 Go 镜像下载依赖并交叉编译 amd64/arm64 的 `havline-agent`;
4. 将前端 `dist` 复制到 `cmd/havline/web/dist`,由 Go `embed` 打入主程序;
5. 编译主程序并通过 `-ldflags` 注入后端版本;
6. 单独构建 updater sidecar;
7. 运行时镜像安装 Nginx、CA 证书和 tzdata,默认暴露 6893、80、443。
当前运行镜像不预装 `frpc`,内网穿透需要在应用内按需下载并保存到数据目录。文档若仍写成“镜像内置 frpc”,应按当前 Dockerfile 修正。
### 8.2 Compose 形态
| 文件 | 场景 | 特征 |
| --- | --- | --- |
| `docker-compose.yml` | 本地源码构建 | bridge 网络、主服务和 updater sidecar |
| `docker-compose.host.yml` | fnOS 等已占用 80/443 | host 网络,默认反代端口 18080/9443 |
| `docker-compose.hub.yml` | 只保留编排文件部署 | BuildKit 从 Git URL 拉源码构建,适合干净机器 |
三个 Compose 文件都使用固定项目名 `havline`,并支持 `HAVLINE_DATA_PATH` 将默认命名卷替换为绑定目录。`docker-compose.hub.yml` 当前从 `https://jayhub.top/mrlee/Havline.git#main` 构建和升级,部署环境需要确认该地址、分支和实际发布仓库的关系。
### 8.3 一键升级
Docker 模式由主服务和 updater sidecar 组成:主服务只通过共享 Unix Socket 请求升级,sidecar 才挂载 Docker Socket。升级过程涉及拉取/构建目标代码、重建服务、健康检查和失败回滚。
该设计降低了主业务进程直接控制 Docker 的权限,但 updater 仍然拥有高权限宿主机能力。升级协议、项目目录挂载、Compose 文件名、服务名、镜像标签和回滚条件必须作为一个整体测试。
## 九、关键业务流程
### 9.1 新建本机反向代理
```text
前端表单
→ POST /api/proxies
→ Proxy Handler / Proxy Service
→ 写入 SQLite
→ 生成 Nginx 规则与证书引用
→ 语法检查
→ 原子写入和备份
→ Nginx reload
→ 可选同步 Cloudflare ingress/DNS
```
### 9.2 DDNS 与证书
DDNS 任务由 scheduler 周期触发,也可通过 API 手动执行。证书申请使用 ACME DNS-01,凭据从加密存储读取,申请过程通过 Job 和日志流反馈给前端,成功后更新证书元数据并供 Nginx 或远程 Agent 使用。
### 9.3 FRP 与 Agent
本地 Havline 保存服务端和代理定义,负责生成 `frpc.toml`、下载/激活 FRP 二进制、启动和停止本地进程。公网 Agent 通过 SSH 安装或管理,随后由 Havline 调用 Agent API 完成 frps、Nginx、证书、路由和诊断操作。路由漂移、健康、版本和配置回滚使该链路具备运维闭环,但也使错误恢复路径较长。
### 9.4 Cloudflare Tunnel
反向代理规则可以选择本机出口或 Cloudflare 出口。托管隧道从规则派生 ingress,并负责本地配置、Cloudflare 云端路由、DNS 记录、隧道进程、健康事件和漂移修复。隧道配置页单独呈现运行指标、拓扑、路由和控制操作,说明该功能已经从实验性集成进入主要业务边界。
## 十、现有文档与实现的偏差
本次读取发现,正式代码已经进入 v1.9.0,但部分历史文档仍停留在早期版本,使用前应区分“历史快照”和“当前操作说明”:
1. `docs/DEPLOYMENT.md` 顶部仍标注适用版本 v1.1.0,迁移数量、包名和若干功能描述明显早于当前实现;
2. 该部署文档仍强调手工设置会话密钥,并按历史固定默认值描述风险,而当前代码在未配置时会生成并持久化随机密钥;
3. `docs/README.md` 已将 `project-analysis-2026-09-27.md` 标记为 v1.2.19 快照,不能代表当前 v1.9.0 的完整 Cloudflare 和品牌状态;
4. `CHANGELOG.md` 的 `[Unreleased]` 仍包含品牌全量切换说明,而 v1.9.0 已记录 Cloudflare 功能,版本状态需要在发布流程中明确;
5. 当前代码、Compose 和环境变量已统一使用 `HAVLINE_*`,旧 `FONU_*` 只应在历史文档中保留,并需要避免被新部署步骤继续复制;
6. `fnos-native/app-src` 是另一个包含源码副本的交付边界,修改主 `havline` 后若需要发布 fnOS 原生包,必须明确同步策略,不能默认认为两个目录自动一致。
建议把文档分成三类:当前部署手册、当前功能基线、历史分析/方案。历史文件保留原内容,但标题和索引应明确版本和时效,避免用户把 v1.1.0 的命令用于 v1.9.0。
## 十一、风险与改进优先级
### P0:发布与数据安全
- 更新部署文档,使会话密钥、数据卷、备份和当前 Compose 变量与代码一致;
- 验证 updater sidecar 的 Socket 鉴权、项目目录边界、升级失败回滚和异常重启;
- 对数据库、证书、Token、私钥和备份归档建立统一的敏感数据保护说明;
- 明确 `docker-compose.hub.yml` 的 Git 地址、分支和发布仓库,避免升级拉到非预期源。
### P1:复杂集成稳定性
- 为 FRP/Agent/Cloudflare 的“配置写入 → 进程重载 → 健康检查 → 漂移修复”建立端到端测试;
- 把远程 Agent 调用的超时、重试、版本兼容和错误恢复状态统一建模;
- 验证 Cloudflare DNS 清理只影响 Havline 接管的记录;
- 检查迁移从已有实例升级到 `036` 的完整性,并验证备份恢复后凭据和运行文件可用。
### P2:可维护性
- 拆分大型 Vue 页面和大型 FRP/Agent 服务文件;
- 统一 API 类型、错误码、异步 Job、日志流和状态映射;
- 继续保持路由鉴权覆盖测试,并为每个外部进程管理器补充失败注入测试;
- 将重复的配置版本、原子写入和回滚逻辑沉淀到已有 `fsutil` 或明确的领域接口中。
### P3:交付一致性
- 明确 `havline`、`fnos-native/app-src`、fnOS `.fpk` 和独立文档站点之间的发布关系;
- 在 CI 中增加版本源、Compose 变量、文档版本和嵌入前端资源的一致性检查;
- 清理或隔离外层临时数据库、日志和构建产物,减少分析时误把运行状态当源码事实的风险。
## 十二、建议的后续验证清单
在把本分析作为发布依据前,建议在 `havline` 目录执行:
```bash
go test ./... -count=1
go vet ./...
cd web
npm run typecheck
npm test
npm run build
```
部署验证至少覆盖:
- 空数据目录首次启动并确认随机 `session_secret` 权限;
- 旧数据卷升级并确认数据库迁移全部完成;
- Compose bridge、host、hub 三种形态分别启动;
- Nginx 配置写入、语法错误、回滚和重载失败;
- FRP 本地进程、VPS Agent 和路由漂移;
- Cloudflare 隧道、DNS、ingress 同步、健康事件和恢复;
- 备份导出、全量恢复、凭据解密和证书/配置文件恢复;
- updater sidecar 升级成功、健康检查失败和回滚。
## 十三、最终判断
当前 `havline` 已经是一个功能较完整的 NAS 公网访问控制面,代码、部署和文档均已完成从 Fonu 到 Havline 的大部分迁移。架构主线清晰:Go 负责控制面和外部系统编排,Vue 负责交互,SQLite 负责状态,文件系统负责运行配置和密钥,Nginx/FRP/Cloudflare 负责不同公网出口。
项目下一阶段的主要工作不在于继续堆叠入口,而在于收敛交付事实:同步当前部署文档、强化高权限升级边界、保证数据库与文件数据的一致备份恢复、降低 FRP/Agent/Cloudflare 集成的故障恢复成本,并把历史方案与当前实现明确分层。
+211
View File
@@ -0,0 +1,211 @@
# 场景 A 可行性分析与操作步骤:多 Web 应用统一入口
> 日期:2026-10-07
> 场景:家庭宽带 NAS,多个 Web 应用统一 443 对外,VPS 做公网入口中转,TLS 在 NAS 本地终止
> 性质:只做分析与步骤说明,不含代码改动
> 结论:**整体可行**,但场景描述里有 3 处前提需要修正,否则其中一步会走不通
## 一、目标链路
```
用户 → https://app.example.com:443
→ DNS 解析到 VPS IP
→ VPS:443(frps 监听,由 tcp 规则提供)
→ FRP TCP 隧道(remotePort 443)
→ NAS frpc → 127.0.0.1:443
→ Havline 本地 Nginx(TLS 在此终止,按 server_name 分流)
→ 内网应用
```
这条链路的关键特征:**TLS 全程到 NAS 才解开**,VPS 只做 TCP 转发,看不到明文。相比「VPS 上终止 TLS」的方案,这是更安全的一种,值得保留。
## 二、先修正场景描述里的三处前提
### 前提 1:`http://` 跳转 `https://` 这一步走不通
原文第 6 步写「按域名建规则(HTTPS + 强制跳转 + 选证书)」。问题在于:浏览器访问 `http://app.example.com` 走的是 **80 端口**,而本场景只把 **443** 通过隧道引到 NAS,80 的流量根本到不了本地 Nginx。
再看 Havline 的实现,`http_redirect` 的语义是「在**同一个监听端口**上额外生成一个 301 server 块」:
```go
// internal/nginx/generate.go
if rule.HTTPRedirect && useHTTPS {
b.WriteString(" return 301 https://$host:$server_port$request_uri;\n")
}
```
它服务于「同一端口既能收明文又能收 TLS」的场合,无法覆盖 80 端口。如果为了跳转把规则的监听端口设成 80 并开启 HTTPS,生成的会是 `listen 80 ssl;`,属于错误配置。
**处理方式(三选一)**:
1. **推荐**:在 VPS 层做跳转——VPS 上已有 Nginx 时,加一个 `listen 80; server_name app.example.com; return 301 https://$host$request_uri;`。这与隧道无关,最简单。
2. 额外开一条 tcp 隧道(remotePort 80 → 本地 80),再用自定义全局配置在本地 Nginx 手工加跳转。
3. 接受「只有 https 可访问」,不做跳转。
因此在第 6 步里,`HTTP 跳转 HTTPS` 开关不要打开。
### 前提 2:443 只能作为 TCP 规则的远程端口,不能填进 frps 的 vhost 端口
场景第 1 步写「放行 bindPort 与 443」,这是对的;但要避免一个常见混淆:Havline 在生成 frps.toml 时**明确禁止** `vhostHTTPPort` / `vhostHTTPSPort` 使用 80/443,会直接报错:
> frps vhost 回源端口不能使用 80/443(通常由 VPS 上的 Nginx 占用);请在服务端表单改为 8080/8443 后重新生成配置
原因:vhost 端口是给「http/https 类型规则 + VPS Nginx 反代」用的,属于另一条技术路线。**本场景用的是 `tcp` 类型规则**,443 是规则的 remotePort,两者互不冲突。
### 前提 3:DDNS 解析到家庭宽带 IP 并不总是可行
原文最后一段写「域名也可解析到家庭宽带 IP(DDNS 留空自动检测),VPS 仅做隧道;两种都可行」。这条要加前提:
1. 家宽必须是**真正的公网 IPv4**(非 CGNAT 大内网),否则外部无法直连到家庭 IP;
2. 国内运营商普遍**封锁家宽的 80/443 入站**,即使有公网 IP,443 也可能不通。
只要这两条有一条不满足,「解析到家庭 IP」就等于解析到一个不可用的地址,链路会静默失败。相比之下,**解析到 VPS IP 是更稳的选择**,也是本场景的推荐形态。
## 三、项目里的功能支撑(已逐项核对)
| 场景需要 | 项目实现 | 位置 |
| --- | --- | --- |
| 生成/下发 frps.toml | `FRPSServerConfig`(脱敏预览)+ `PushFRPSConfigToAgent`(下发) | [frps_template.go](F:/Fonu-main/havline/internal/frp/frps_template.go)、服务端「详情 → frps.toml」 |
| DDNS 自定义 IP(指向 VPS) | 自定义 IPv4/IPv6,留空则自动检测本机公网 IP | [DdnsView.vue](F:/Fonu-main/havline/web/src/views/DdnsView.vue:451) |
| 通配符证书 DNS-01 | lego `dns01`,支持 Cloudflare / DNSPod / 阿里云 / 腾讯云 / 火山 | [dns_provider.go](F:/Fonu-main/havline/internal/acme/dns_provider.go:16) |
| DNS-01 凭证复用 DDNS | **自动复用**,无需重填 | [service.go](F:/Fonu-main/havline/internal/acme/service.go:126) `ddnsSvc.ConfigForDNSZone` |
| FRP TCP 规则 | 类型 tcp + 远程端口 + 内网目标 | FrpView 规则表单 |
| 反代规则监听 443 + HTTPS + 出口 | 监听端口 / 出口多选 / 启用 HTTPS | [ProxyView.vue](F:/Fonu-main/havline/web/src/views/ProxyView.vue:459) |
| 证书自动绑定规则 | 按域名自动匹配,**通配符优先于精确域名** | [generate.go](F:/Fonu-main/havline/internal/nginx/generate.go:249) `findCertificate` |
| 校验并应用 | frpc `verify` 通过才落盘 | [manager.go](F:/Fonu-main/havline/internal/frp/manager.go:41) |
两点值得注意的细节:
1. **证书不需要在反代规则里逐个选**。生成器按域名找证书,顺序是先试 `*.example.com` 再试 `example.com`。所以只要申请了通配符证书,之后新增子域名规则会自动套用。
2. **DNS-01 凭证确实复用 DDNS**:`prepareApplyByZone` 先取 DDNS 配置,再由 `dnsCredentialsForDomain` 拿到 `ddns.Credentials` 交给 `newDNS01Provider`。前提是申请的域名必须落在 DDNS 已托管的 zone 内,否则会触发 `DomainsUnderZones` 校验失败。
## 四、操作步骤
### 步骤 1:VPS 侧准备
1. 确认 443 空闲:`ss -lntp | grep ':443 '`。若 VPS 上已有 Nginx 占用 443,需要先停掉或改端口,否则 frps 的 tcp 规则绑定失败。
2. 放行端口:`bindPort`(默认 7000)与 **443**。
3. 如果 frps.toml 配了 `allowPorts` 白名单,务必把 443 加进去。
4. 生成 frps 配置:Havline →「公网服务端」→ 选中该 VPS →「frps.toml」查看同源配置,用「下发 frps 配置」写入 VPS,或在 VPS 上手工放置。
5. 启动 frps,确认控制端口可连。
### 步骤 2:DDNS 指向 VPS
1. 「域名解析」页 → 新建任务 → 服务商选你的 DNS 服务商,填入凭证。
2. 记录填写完整域名:`example.com`、`*.example.com`(通配符便于后续新增子域)。
3. 点该任务的「自定义 IP」→ IPv4 填 **VPS 的 IP**(这一步很关键,不填的话 Havline 会去检测 NAS 自己的公网 IP)。
4. 立即同步一次,确认解析生效:`nslookup app.example.com` 返回 VPS IP。
### 步骤 3:申请通配符证书
1. 「证书」页 → 申请 → CA 选 Let's Encrypt → 校验方式为 **DNS-01**。
2. 域名填 `example.com` 与 `*.example.com`(一起提交,避免重复申请)。
3. 凭证无需填写,会自动复用步骤 2 里 DDNS 的凭证。
4. 申请成功后确认通配符证书落在 `wildcard.example.com` 目录下。
### 步骤 4:添加 FRP 服务端
1. 「公网服务端」→ 添加服务端。
2. 主机地址填 VPS IP,端口 7000,Token 与 frps 一致。
3. 若要用本页的「安装 Agent / 下发 frps 配置」能力,同时填好 Agent 地址与 SSH 信息。
### 步骤 5:添加 FRP TCP 规则
1. 「公网服务端」→ 选中该服务端 → 新建规则。
2. 类型 `tcp`;内网地址 `127.0.0.1`;内网端口 `443`;公网远程端口 `443`。
3. 命名建议用真实用途(例如 `nas-https-entry`),不要沿用 `ssh-tcp` 这类与实际不符的名字。
4. 启动 frpc,日志应出现该规则的 `start proxy success`。
### 步骤 6:建立反向代理规则
**入口更正**:这一步在「**反向代理**」页(路由 `/proxies`),不是「公网反代」页。「公网反代」(`/public-proxy`)管理的是部署到 **VPS 上的 Nginx 反代**,属于「VPS 出口」路线(TLS 在 VPS 终止);本场景要的是 NAS 本地 Nginx,必须用「反向代理」页。
页面右上角「新建规则」→ 弹窗有三个页签:**基础配置 / 安全设置 / Nginx**。
#### 基础配置页签逐字段填写
| 字段 | 填写值 | 说明 |
| --- | --- | --- |
| 名称 | `NAS 统一入口` | 选填,建议填,便于列表识别 |
| 前端域名 | `app.example.com` | 必填;每行一个;**不要带端口** |
| 监听端口 | `443` | 必填;**默认值是 80,必须改成 443** |
| 监听协议 | 只勾 `IPv4` | 默认即 IPv4;除非 VPS 侧走 IPv6 否则不必勾 IPv6 |
| 目标地址 | `http://127.0.0.1:8080` | 换成实际应用地址 |
| 出口 | 只勾「本机 Nginx」 | **不要勾 Cloudflare 隧道** |
| 启用 HTTPS | 打开 | 默认已开 |
| HTTP 跳转 HTTPS | **关闭** | **默认是打开的,必须手动关**,原因见前提 1 |
| 启用规则 | 打开 | 默认已开 |
证书不需要选,生成器按域名自动匹配步骤 3 的通配符证书(优先 `*.example.com`)。
#### 两个默认值陷阱
1. `监听端口` 默认是 **80**,不改的话会以 root 权限在 NAS 上监听 80,与预期不符。
2. `HTTP 跳转 HTTPS` 默认是 **开**。打开后会在 443 端口上额外生成一个明文 301 server 块——对公网访问没有实际作用,反而让配置语义混乱,因此要关掉。
#### 安全设置页签(可选)
1. 「一键推荐」会填入:HTTPS + 请求限流(10 r/s,burst 20)+ 安全响应头。**注意它会把 `HTTP 跳转 HTTPS` 重新打开**,点完要回基础配置再关掉。
2. **不要开启「仅中国大陆 IP」**:TCP 隧道下客户端真实 IP 已丢失(见 5.1),该策略会失效或误判。
3. 需要额外保护时可开 Basic Auth;「高级 → 仅 TLS 1.3」按需。
#### Nginx 页签
保持默认的生成模式即可,不需要手工编辑。
#### 保存与生效
1. 点保存。保存前会做「域名 + 端口」冲突检查,若提示 `域名 x:443 已被规则「y」使用`,说明该端口已被占用。
2. 保存成功后后端会自动同步 Nginx 配置并重载;若返回 `sync_warning`,表示规则已写入但 Nginx 同步失败,需要到日志页排查。
3. 若 NAS 本地 443 已被其他服务占用,重载会失败——先用 `ss -lntp | grep ':443 '` 确认。
### 步骤 7:验证
1. frpc 日志:`start proxy success`,无 `start error`。
2. VPS 上确认 443 已被 frps 监听:`ss -lntp | grep ':443 '`。
3. 外网探测 TCP:
```powershell
1..5 | % { (Measure-Command { $c = New-Object Net.Sockets.TcpClient; $c.Connect('VPS_IP', 443); $c.Close() }).TotalMilliseconds }
```
4. 端到端验证(这一步才说明链路通):
```powershell
curl.exe -v --resolve app.example.com:443:VPS_IP https://app.example.com/
```
5. 检查返回的证书是否为通配符证书(`curl -v` 输出中 subject 应为 `*.example.com`)。
6. Havline「公网服务端 → 详情」看诊断与可用率;日志页看该服务端运行日志。
## 五、已知限制
### 5.1 客户端真实 IP 会丢失
TCP 隧道下,NAS 本地 Nginx 看到的 `$remote_addr` 是 frpc 来源地址(回环),不是访客真实 IP。影响两处:
1. Nginx 访问日志里的 IP 全部失真;
2. Havline 的「中国 IP 段」访问控制(按来源 IP 放行/拦截)会失效——所有请求看起来都来自内网。
若要保留真实 IP,需要 FRP 规则开启 `transport.proxyProtocolVersion = "v2"`,同时本地 Nginx 的监听加 `proxy_protocol` 并配合 realip 模块。这属于可选增强,需要两端同时改。
### 5.2 NAS 本地 443 的占用
Havline 要在 NAS 上监听 443。如果 fnOS 系统面板或其他容器已经占用 443,需要先让出端口。可用 `ss -lntp | grep ':443 '` 确认。
### 5.3 「本机出口」与「公网反代(VPS 出口)」是两条不同路线
1. 本场景用的是**本机出口**:配置生成在 NAS 本地 Nginx,TLS 在 NAS 终止。
2. 「公网反代」里另一种形态是 **VPS 出口**:frps 开 vhost 端口(8080/8443),VPS 上 Nginx 反代回源,TLS 在 VPS 终止。
两者不要混用同一条规则,否则会出现「证书在 NAS、TLS 却在 VPS 终止」的错配。判断方法:看规则表单里「出口」勾了哪些。
## 六、扩展性
这套结构的好处正如场景描述所说:**新增应用只改反代,不动 FRP**。
1. 新建反代规则,监听端口仍填 443,域名换 `app2.example.com`,上游指向新应用;
2. 生成器自动套用同一张通配符证书;
3. 不需要新增 FRP 规则、不需要改 frps、不需要动 DNS(通配符记录已覆盖)。
唯一需要留意的是:所有子域名共用同一条 443 隧道,因此 **NAS 本机 Nginx 是单点**——它挂了所有应用一起不可用。这是该架构的固有取舍,不是配置错误。
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+271
View File
@@ -0,0 +1,271 @@
# Havline 安全审查与代码审查报告
> 审查日期:2026-10-07
> 审查对象:Havline 主仓(`F:\Fonu-main\havline`),版本 `1.9.2`
> 审查方式:定向安全面扫描 + 关键路径人工复核 + 自动化检查
## 一、审查范围与方法
代码规模:
| 部分 | 文件数 | 约行数 |
| --- | --- | --- |
| Go 后端(`internal/`、`cmd/`) | 292 | 40,173 |
| 前端 Vue/TS(`web/src`) | 61 | 23,538 |
覆盖的安全面:
1. 命令执行(`os/exec` 全量枚举,回溯参数来源)
2. SQL 构造(拼接式查询扫描)
3. 路径处理(`filepath.Join` 与用户输入的交汇点)
4. 认证与授权(路由注册、中间件、会话、令牌)
5. 加密与密钥(TLS 配置、证书、令牌生成)
6. 出站请求(SSRF 面)
7. 文件解压与导入(tar/zip slip)
8. 前端注入与凭据存储
9. 日志与备份的信息泄漏链
自动化检查结果:
```
go vet ./... 通过(无输出)
npm run typecheck 通过(havline-web@1.9.2)
gosec / govulncheck / staticcheck 本机未安装,未执行
```
`gosec`、`govulncheck` 未安装,依赖漏洞与规则化静态扫描属于本次未覆盖范围,建议在 CI 中补上。
## 二、总体结论
整体安全基线扎实,不是"到处漏风"的状态。认证、路径安全、解压安全和 agent 暴露面这几处明显经过专门设计,多处还能看到针对具体攻击手法的注释(例如备份解压用 `filepath.Rel` 而非前缀比较,理由写在代码里)。
本次共确认 1 个高风险、4 个中风险、6 个低风险问题,均不属于"未授权访问"级别的直接可利用漏洞。高风险项是公网部署下的认证入口缺口,建议优先处理。
> 修订说明(2026-10-07):初版把「Agent 远程监听为明文 HTTP」列为 P1-2。复核后确认该结论有误——项目已实现 SSH 隧道与 HTTPS 证书指纹固定两种安全传输模式,`transport_security` 字段只描述 Agent 本机监听地址的安全等级,与 Havline→Agent 的连接方式无关。该条已改写为 P3-6。
## 三、漏洞与风险清单
### P1 高风险
#### P1-1 登录接口无速率限制与失败锁定
位置:[auth_handler.go](F:/Fonu-main/havline/internal/api/auth_handler.go) 的 `Login`、[auth.go](F:/Fonu-main/havline/internal/auth/auth.go:120) 的 `Service.Login`
`Login` 对密码错误直接返回 `ErrUnauthorized`,没有失败计数、没有按 IP 或用户名限流、没有临时锁定,也没有写入安全审计日志。扫描 `internal/api/auth_handler.go` 与 `internal/auth/auth.go` 全文件,未发现任何 `rate` / `limit` / `attempt` / `lockout` / `backoff` 逻辑。
影响:Havline 面板常被端口映射到公网。攻击者可以对 `admin` 账号持续爆破。`bcrypt`(DefaultCost)把单次尝试成本抬到约几十毫秒,显著提高了爆破代价,但这只是延缓,不构成阻断;分布式来源的尝试更不受影响。
建议:按「用户名 + 客户端 IP」做令牌桶限流,失败累积到阈值后指数退避并短期锁定;成功登录后清零计数;失败事件写入系统日志便于发现。
#### P1-2(已降级为 P3-6)
初版结论「Agent 远程监听为明文 HTTP」经复核不成立,已改写为 P3-6,原因见该条。此处保留占位,便于与初版对照。
### P2 中风险
#### P2-1 证书域名校验过弱,与其它模块不一致
位置:[import.go](F:/Fonu-main/havline/internal/certificate/import.go:104) `validateDomainLabel`
```go
func validateDomainLabel(domain string) error {
if domain == "" {
return fmt.Errorf("域名格式无效")
}
parts := strings.Split(domain, ".")
for _, part := range parts {
if part == "" || len(part) > 63 {
return fmt.Errorf("域名格式无效")
}
}
return nil
}
```
只检查「按 `.` 切分后每段非空且不超过 63 字符」,没有字符集白名单。对比另外两处的严格程度:
- Agent:[server.go](F:/Fonu-main/havline/internal/agent/server.go:112) 用正则 `domainPattern` 限定 `[a-z0-9-]`
- FRP:[validate.go](F:/Fonu-main/havline/internal/frp/validate.go) 的 `validDomain` 做了完整标签校验
具体后果:导入证书时域名取自证书的 `DNSNames` 与 `CommonName`(攻击者上传自签证书即可完全控制),`a/b` 这类含斜杠的值能通过校验,随后进入 `certStorageNames` → `filepath.Join(certsDir, name)` → [service.go](F:/Fonu-main/havline/internal/acme/service.go:624) 创建嵌套目录并写入 `fullchain.pem` / `privatekey.pem`。
好消息是 `..` 恰好会被「空段」检查挡住(`../..` 切分后产生空段),所以目前无法逃出 `certsDir`,实际危害限于目录污染。但这是靠巧合防护而非显式设计——`..` 与 `/` 的拦截不应该依赖同一条规则。而且这条宽松规则与另外两个模块的严格规则并存,属于典型的校验口径分裂。
建议:抽出统一的域名校验函数,三处共用;`certStorageNames` 侧再加一道 `filepath.Base` 与字符白名单兜底。
#### P2-2 TLS 私钥可经 API 明文导出
位置:[cert_handler.go](F:/Fonu-main/havline/internal/api/cert_handler.go:112) `Download`、[download.go](F:/Fonu-main/havline/internal/acme/download.go:23)
`GET /api/certificates/{domain}/download?part=key` 会直接返回 `privatekey.pem` 内容。
该接口有正确的鉴权(走 `RequireAuth`),也做了路径校验(`validateCertFilePath` 用 `filepath.Rel` 确认在 `CertsDir` 内,写得很好),且域名必须存在于数据库记录中,不能任意读文件。
风险点在于权限粒度:任何有效会话或具备 `write` scope 的 API Token 都能一次性导出全部站点私钥,没有二次确认、没有审计记录。结合 P1-1,一旦口令被爆破成功,攻击者可直接拖走所有证书私钥。
建议:私钥导出要求重新输入口令或二次确认,并记录审计日志;或对 `part=key` 单独要求更高权限。
#### P2-3 备份文件包含应用日志,日志里含初始管理员口令
位置:[auth.go](F:/Fonu-main/havline/internal/auth/auth.go:64) 首次启动分支、[service.go](F:/Fonu-main/havline/internal/backup/service.go:139) `addDir`、[config.go](F:/Fonu-main/havline/internal/config/config.go:102)
三处叠加形成一条泄漏链:
1. 首次启动自动生成管理员密码后,以明文写入系统日志(`"首次启动已创建管理员账户…"` 携带 `password` 字段)
2. `LogsDir()` 返回 `DataDir + "/logs"`,位于数据目录内
3. 备份打包 `addDir` 时只排除了 `backups` 子目录,其余目录整体入包
结果:导出的备份归档里带走了含明文初始口令的 `app.log`。
影响取决于用户是否已改密码——初始口令是一次性的,若未及时修改则等价于口令泄漏;即便已修改,日志中残留的其他敏感信息(DDNS 凭据、Agent Token 片段等)也可能随备份外传。
建议:首次口令只输出到标准输出或一次性文件,不落常规日志;备份打包时排除 `logs/`(或至少排除 `app.log`)。
#### P2-4 自更新按可变 tag 拉取镜像,不固定 digest
位置:[runner.go](F:/Fonu-main/havline/internal/updater/runner.go) `compose` / `currentImage` 相关逻辑
更新流程通过 `docker compose` 拉取 `havline:<version>` 镜像并以 tag 为主键,全文件搜索未见 `sha256` / `checksum` / 签名校验。
`docker pull` 自身会校验 manifest 与层 digest,所以这不是"完全不校验"。真正的风险是 tag 可变:同一个 `1.9.2` 在不同时间可能指向不同内容,镜像仓库一旦被入侵或凭据泄漏,可以覆盖 tag 下发任意镜像,且本地无法察觉版本内容被替换。
建议:发布时记录镜像 digest,更新流程固定 digest 或校验发布清单;至少在状态里展示 digest 便于比对。
#### P2-5 会话 Cookie 的 Secure 判定信任 `X-Forwarded-Proto`
位置:[auth_handler.go](F:/Fonu-main/havline/internal/api/auth_handler.go:181) `requestIsHTTPS`
```go
func requestIsHTTPS(r *http.Request) bool {
if r.TLS != nil {
return true
}
return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https")
}
```
任何来源都可以设置该请求头,没有可信代理地址白名单。代码注释解释了为什么这样取舍(明文部署下若强制加 `Secure` 会导致无法登录),属于有意为之,但它把"是否启用 Secure"的控制权交给了客户端。
实际影响有限:伪造该头只会让服务器给 Cookie 加上 `Secure`,在明文场景下反而导致自己的登录态失效(自伤)。真正的隐患是它掩盖了部署形态——反代场景下若请求直达后端,判定的可信度无从保证。
建议:改为按配置项显式声明「对外协议」(部署时设定),或限定只信任来自已知反代地址的 `X-Forwarded-Proto`。
### P3 低风险
#### P3-1 备份恢复未防符号链接
位置:[service.go](F:/Fonu-main/havline/internal/backup/service.go:108) `Restore`
解压逻辑已经做对了最关键的部分:跳过非 `TypeReg` / `TypeDir` 条目,并用 `filepath.Rel` 拒绝逃出 `dataDir` 的路径(注释还专门说明了为什么不能用前缀比较)。剩余缺口是写入前没有 `Lstat` 检查目标是否已是指向目录外的符号链接——若 `dataDir` 内预先存在恶意符号链接,写入会跟随它。
触发前提较苛刻(需要先能在数据目录内造符号链接),定级 P3。建议写入前用 `os.Lstat` 检查并拒绝符号链接目标。
#### P3-2 随机口令生成存在取模偏置
位置:[auth.go](F:/Fonu-main/havline/internal/auth/auth.go:211) `generatePassword`
```go
out[i] = letters[int(randBytes[i])%len(letters)]
```
`len(letters)` 为 58,不能整除 256,因此字符分布轻微偏向字母表前部。熵损失极小(约 0.01 bit/字符),实际不构成风险,但属于应当消除的实现瑕疵。建议改用 `crypto/rand.Int` 或拒绝采样。
#### P3-3 会话生命周期固定,无滑动续期与绝对上限
位置:[auth.go](F:/Fonu-main/havline/internal/auth/auth.go:22) `sessionTTL`
会话固定 7 天过期,既不在活跃使用时续期,也没有"最长生命期"概念。两个方向都不理想:长期不用的会话仍保留 7 天有效窗口;活跃会话也不会因超期而强制重新认证。
#### P3-4 FRP 服务端布尔选项缺少默认值兜底(已修复)
位置:[validate.go](F:/Fonu-main/havline/internal/frp/validate.go:289)
本次审查中已修复:迁移 `016` 给已有服务端补的 `config_json` 默认值是 `{}`,而 `normalizeServerOptions` 只兜底整型字段,导致 `tcp_mux` / `login_fail_exit` 保持 Go 零值 `false`,生成 `transport.tcpMux = false`(每条新连接都要重建 TCP+TLS)与 `loginFailExit = false`。现已按「键缺省取默认、显式 false 保留」反序列化,并补了单元测试。
#### P3-5 Agent 路由内容可写入任意 Nginx 配置(设计如此)
位置:[server.go](F:/Fonu-main/havline/internal/agent/server.go:1370) `routePath` / `applyRouteContent`
`PUT /api/v1/routes/{domain}` 的内容由管理端提供,Agent 落盘后执行 `nginx -t` 并 reload。域名已严格校验,且改前留版本、校验失败自动回滚,工程上做得很细。
需要记录的是权限含义:持有 Agent Token 即可写入任意 Nginx 指令。若 Nginx 以 root 运行且存在可用的危险指令组合,等于把文件写入能力提升为更高权限。这属于"Agent 本就拥有主机管理权"的设计前提,建议在文档中明确这一信任边界,而不是在代码层修补。
#### P3-6 Agent 传输模式的默认值是明文 http
位置:[028_agent_transport.sql](F:/Fonu-main/havline/migrations/028_agent_transport.sql)、[AgentView.vue](F:/Fonu-main/havline/web/src/views/AgentView.vue:255)
初版把这里误判为 P1。复核后的准确情况是:Agent 本机监听 `127.0.0.1:7700` 明文 HTTP 属于设计(回环流量不出主机,`validateListenAddr` 也会拦截非回环地址),而 Havline→Agent 的连接有三种模式,其中两种已经是安全的。
| 模式 | 路径 | 安全性 |
| --- | --- | --- |
| `tunnel` | Havline 侧建立 SSH 隧道,转发到 VPS 的 `127.0.0.1:7700` | 安全,Agent 无需对外开放 |
| `https-pin` | Agent 侧 Nginx 监听 TLS 端口(默认 7443),证书由 Havline 生成下发,客户端做 SHA-256 指纹固定 | 安全,证书 365 天且到期前自动轮换 |
| `http` | 明文直连 Agent 的 7700 | 明文,Token 可被嗅探 |
实现依据:[service.go](F:/Fonu-main/havline/internal/frp/service.go:831) 的 tunnel 分支、[service.go](F:/Fonu-main/havline/internal/frp/service.go:885) 的 https-pin 分支、[agent_https.go](F:/Fonu-main/havline/internal/frp/agent_https.go) 的证书生成、[agent_client.go](F:/Fonu-main/havline/internal/frp/agent_client.go:79) 的指纹固定。
前端也已经处理到位:`agent_transport === 'http'` 时显示告警「当前仍是公网明文 HTTP。点击「升级 Agent」可自动迁移到 SSH 隧道安全模式。」,并提供一键切换。
残留风险只在默认值:迁移 `028` 把 `agent_transport` 默认设为 `'http'`,已有记录与新建记录都落在明文模式,需要用户主动迁移。若部署时开了 `HAVLINE_AGENT_ALLOW_REMOTE=1` 且未切换,就会形成长期明文暴露。
建议:把默认值改为 `tunnel`(或在新装流程中默认选择安全模式),并在「Agent 处于远程监听且 transport 仍为 `http`」时于服务端列表持续标红,而不是只弹一次告警。
## 四、代码质量审查
### 结构与规模
- 分层清晰:`cmd`(入口)/ `internal`(领域服务)/ `web`(前端)/ `migrations`(增量迁移),跨模块调用基本走接口。
- 单文件过大是主要结构问题:[agent/server.go](F:/Fonu-main/havline/internal/agent/server.go) 达 71 KB,把路由、Nginx 管理、证书、frps 控制、命令执行混在一处。按领域拆分(route / nginx / frps / certs)会显著降低审查难度。
- `internal/frp/service.go` 同样偏大,本次修 bug 时也要在多处来回定位。
### 规范一致性
- 中文注释质量高,多处注释解释"为什么这么写"而非"做了什么"(例如备份解压用 `filepath.Rel` 的原因、`natTraversal` 只在显式禁用时输出),这类注释对后续维护价值很大。
- 主要不一致是同一语义三种实现:域名校验存在 Agent 正则、FRP `validDomain`、证书 `validateDomainLabel` 三套口径,宽松程度不同。建议统一。
- 文档与实现存在偏差:[FrpView.vue](F:/Fonu-main/havline/web/src/views/FrpView.vue:385) 描述诊断有「TCP 可达 / frpc 登录 / 代理可达 / DNS 解析 / 归属地」五项,而 [diagnostics.go](F:/Fonu-main/havline/internal/frp/diagnostics.go:88) 实际只产出 `tcp_reachable`、`frpc_login`、`dns_resolution`、`location` 四项,「代理可达」并不存在;且诊断只探测控制端口,不覆盖规则的公网远程端口。
### 测试
测试密度在同类项目中属于良好水平:认证、令牌、备份、Agent 授权、路由版本、frp 配置渲染等关键路径均有单元测试,`agent` 包甚至有遍历全部路由、断言无 Token 必须返回 401 的测试(`router_auth_test.go`),这是很有效的防回归手段。
本次验证命令均通过:
```
go test ./internal/frp/ 通过
go vet ./... 通过
npm run typecheck 通过
```
未覆盖:`-race` 竞态检测、依赖漏洞扫描(`govulncheck`)、SAST(`gosec`)本次均未执行。
## 五、修复优先级建议
按投入产出排序:
1. 登录限流与失败锁定(P1-1)——改动集中在认证层,收益最大
2. 统一域名校验函数(P2-1)——顺手消除三处口径分裂
3. 私钥导出加二次确认与审计(P2-2)
4. 备份排除 `logs/`,初始口令不落常规日志(P2-3)
5. Agent 传输默认值改为 `tunnel`,并对明文模式持续标红(P3-6)——迁移里改一个默认值即可改善默认安全姿态
## 六、已确认的安全设计
以下设计经代码复核确认正确,无需修改:
1. 口令使用 `bcrypt`(DefaultCost),会话 ID 由 `crypto/rand` 生成 32 字节
2. API Token 仅存 SHA-256、区分读写 scope,且 `password` / `logout` / `tokens` / `update` 路径禁止 Token 访问,避免令牌自提权([middleware.go](F:/Fonu-main/havline/internal/api/middleware.go))
3. Cookie 设置 `HttpOnly` + `SameSite=Lax`,条件启用 `Secure`
4. 路由默认全部经过 `SessionMiddleware` + `RequireAuth`,`public()` 仅用于登录与登出
5. 备份 tar 解压拒绝目录逃逸,归档名走正则白名单,解压不照搬权限位
6. 版本文件读取拒绝 `.` / `..` / 含路径分隔符([fsutil.go](F:/Fonu-main/havline/internal/fsutil/fsutil.go:185))
7. Agent 全部路由统一鉴权、常量时间比较 Token、默认仅监听回环、`upstream` 与域名过滤防 Nginx 配置注入
8. TLS 指纹固定实现正确:pin 为空时 fail-closed([agent_client.go](F:/Fonu-main/havline/internal/frp/agent_client.go:79));Agent 自检的 `InsecureSkipVerify` 只用于回环地址并随后比对指纹
9. 未放宽 CORS;前端不把 Token 存入 `localStorage`(仅主题偏好)
10. SQL 全量使用参数占位符,未发现拼接式查询
11. Agent 远程传输已提供 SSH 隧道与「HTTPS + 证书指纹固定」两种安全模式,证书自动轮换,前端对明文模式给出告警和一键迁移入口
## 七、结论
本次未发现未授权访问、远程代码执行或 SQL 注入级别的直接可利用漏洞。代码在这几类问题上防守到位,甚至优于多数同类自托管项目的平均水平。
真正需要处理的是公网暴露面下的认证入口缺口:登录没有限流(P1-1)。Agent 的远程传输已经具备 SSH 隧道与 HTTPS 指纹固定两种安全模式,遗留问题只是默认值仍落在明文 `http`(P3-6)。其余问题集中在校验口径不统一、敏感数据出口缺少二次确认与审计这两条主线上。
+10
View File
@@ -59,6 +59,9 @@
## 4. 首次部署 ## 4. 首次部署
`docker-compose.yml` 和 `docker-compose.hub.yml` 会由主程序在共享卷中自动生成 updater Token 文件,sidecar 等待并读取该文件。`HAVLINE_SESSION_SECRET` 可以留空,由应用生成并写入数据目录;已有数据时必须沿用原值。
一键升级依赖 `havline-updater` 已经运行。 一键升级依赖 `havline-updater` 已经运行。
旧版本升级到 v1.3.0 时,需要先手动执行一次: 旧版本升级到 v1.3.0 时,需要先手动执行一次:
@@ -85,6 +88,11 @@ docker compose -f docker-compose.hub.yml up -d --build
| 变量 | 说明 | | 变量 | 说明 |
| --- | --- | | --- | --- |
| `HAVLINE_UPDATER_TOKEN` | 可选覆盖值;显式设置时优先于 Token 文件 |
| `HAVLINE_UPDATER_TOKEN_FILE` | 主程序与 sidecar 共享的 Token 文件,默认 `/run/havline-updater/updater.token` |
| `HAVLINE_UPDATER_SOCKET` | sidecar 监听的 Unix Socket |
| 变量 | 说明 |
| `HAVLINE_UPDATER_SOCKET` | sidecar 监听的 Unix Socket | | `HAVLINE_UPDATER_SOCKET` | sidecar 监听的 Unix Socket |
| `HAVLINE_UPDATE_PROJECT_DIR` | Compose 项目目录,容器内为 `/workspace` | | `HAVLINE_UPDATE_PROJECT_DIR` | Compose 项目目录,容器内为 `/workspace` |
| `HAVLINE_UPDATE_COMPOSE_FILE` | 只允许 `docker-compose.yml`、`docker-compose.hub.yml`、`docker-compose.host.yml` | | `HAVLINE_UPDATE_COMPOSE_FILE` | 只允许 `docker-compose.yml`、`docker-compose.hub.yml`、`docker-compose.host.yml` |
@@ -194,6 +202,8 @@ docker compose -f /实际/的/Compose/目录/docker-compose.hub.yml up -d --buil
### `docker-compose.hub.yml` 模式无法读取远端版本 ### `docker-compose.hub.yml` 模式无法读取远端版本
当前 hub 发布源固定为 `https://jayhub.top/mrlee/Havline.git` 的 `main` 分支;检查 Compose 文件中的 `build.context`、`HAVLINE_UPDATE_REPO` 和 `HAVLINE_UPDATE_BRANCH` 三者是否一致。
检查 sidecar 容器内是否可以访问 Git 仓库: 检查 sidecar 容器内是否可以访问 Git 仓库:
```bash ```bash
+78
View File
@@ -3,6 +3,7 @@ package agent
import ( import (
"bytes" "bytes"
"crypto/x509" "crypto/x509"
"encoding/json"
"encoding/pem" "encoding/pem"
"fmt" "fmt"
"io" "io"
@@ -309,6 +310,83 @@ func (s *Server) nginxReload(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, payload) writeJSON(w, http.StatusOK, payload)
} }
// nginxAction 启动 / 停止 / 重启 VPS 上的 Nginx。
//
// 不走 systemctl:宝塔、源码编译等安装方式没有 systemd unit,
// 统一用 nginx 自身信号控制,与 reloadNginx 走同一条路径,行为一致可预期。
func (s *Server) nginxAction(w http.ResponseWriter, r *http.Request) {
var req struct {
Action string `json:"action"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if req.Action != "start" && req.Action != "stop" && req.Action != "restart" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "action 需为 start/stop/restart"})
return
}
nginx := s.nginxPath()
if nginx == "" {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "未找到 nginx 可执行文件:请先安装 Nginx,或用 HAVLINE_AGENT_NGINX_BIN 指定路径"})
return
}
s.mu.Lock()
defer s.mu.Unlock()
if err := runNginxAction(nginx, req.Action); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
// 启停异步生效,留出短暂窗口再重新探测,避免页面读到旧状态
time.Sleep(600 * time.Millisecond)
s.invalidateNginxProbe()
probe := s.probeNginx()
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"action": req.Action,
"nginx_running": probe.Running,
"nginx_ok": probe.OK,
"nginx_error": probe.Error,
})
}
// runNginxAction 执行一次启停;已经是目标状态时按幂等处理,不报错。
func runNginxAction(nginx, action string) error {
running := nginxRunning()
switch action {
case "start":
if running {
return nil
}
return startNginx(nginx)
case "stop":
if !running {
return nil
}
return stopNginx(nginx)
default: // restart
if running {
_ = stopNginx(nginx)
time.Sleep(500 * time.Millisecond)
}
return startNginx(nginx)
}
}
func startNginx(nginx string) error {
if out, err := exec.Command(nginx).CombinedOutput(); err != nil {
return fmt.Errorf("启动 Nginx 失败:%s", strings.TrimSpace(string(out)))
}
return nil
}
func stopNginx(nginx string) error {
if out, err := exec.Command(nginx, "-s", "stop").CombinedOutput(); err != nil {
return fmt.Errorf("停止 Nginx 失败:%s", strings.TrimSpace(string(out)))
}
return nil
}
// frpsConfig 回读 VPS 上的 frps.toml,供应用侧与「即将下发的内容」比对,检出被手工修改的情况 // frpsConfig 回读 VPS 上的 frps.toml,供应用侧与「即将下发的内容」比对,检出被手工修改的情况
func (s *Server) frpsConfig(w http.ResponseWriter, _ *http.Request) { func (s *Server) frpsConfig(w http.ResponseWriter, _ *http.Request) {
path := strings.TrimSpace(s.cfg.FrpsConfigPath) path := strings.TrimSpace(s.cfg.FrpsConfigPath)
+96 -36
View File
@@ -31,37 +31,7 @@ import (
"github.com/havline/havline/internal/sysinfo" "github.com/havline/havline/internal/sysinfo"
) )
// AgentVersion 随每次 agent 行为变更手动递增;status/ping 与安装脚本都会回报,用于确认 VPS 上跑的是哪个版本。 const AgentVersion = "0.15.2"
// 0.5.0:目录判定改用 nginx -T 自省(dump 完整生效配置,按 "# configuration file" 标记
// 找出真正被加载的文件所在目录,零猜测);include 链递归与 HAVLINE_AGENT_NGINX_CONF_DIR 保留为回退。
// 0.9.0:Nginx 可用性显式探测(status 暴露 nginx_ok/nginx_missing)、部署前预检、一键安装 Nginx 端点。
// 0.9.1:status 补充 nginx_running / nginx_version,供页面展示 Nginx 状态卡片。
// 0.9.2:Basic Auth 哈希改为纯 Go apr1(去掉对 VPS openssl 的依赖);安装脚本 token 改用 /dev/urandom。
// 0.10.0:新增 /api/v1/metrics 主机资源采集、/api/v1/logs/nginx 日志读取、/api/v1/frps/config 配置回读。
// 0.11.0:新增 /api/v1/certs 证书清单与 /api/v1/nginx/reload 显式重载(回传校验/重载失败原因)。
// 0.12.0:frps 安装包的 proxy 参数改白名单 + 下载后校验 SHA-256;Basic Auth 自动生成的密码不再写进
// nginx 配置注释(改为只在 PUT 响应里回一次)。
// 0.12.1:frps 启停失败如实回报(不再无条件写「已启动」);HTTP 服务补 ReadTimeout/IdleTimeout。
// 0.12.2:新增域名 vhost 的版本列表 / 对比 / 回滚端点;写配置前留版本,校验失败时恢复上一个可用版本。
// 0.12.3:frps 日志尾巴改为最近 20 行、最新在前,并用 short-iso 时间戳(默认格式不带年份、分不清哪一天)。
// 0.12.4:日志行重写成「2026-09-22 18:43:34 进程: 消息」,去掉 T 分隔、时区偏移与每行重复的主机名。
// 0.12.5:修正 frps 官方校验文件名(frp 一直叫 frp_sha256_checksums.txt,原代码拼的是 frp_<版本>_sha256sums.txt,导致首次安装报 HTTP 404)。
// 0.12.6:nginxPath 探测不到时返回空串(原来回落到配置里的裸名字 "nginx"),
// 修掉「VPS 未安装 Nginx」告警与一键安装按钮永远不出现、以及把它误报成「配置校验未通过」的问题。
// 0.12.7:vhost 目录探测不再要求被加载文件带 .conf 后缀(Debian 的 sites-enabled/default 不带),
// 并在失败信息里带上最后一次的具体原因与常见的显式指定目录。
// 0.12.8:Agent Bearer Token 改为常量时间比较,避免认证路径上的长度/内容时序泄漏。
// 0.12.9:限流状态文件损坏时拒绝覆盖,避免保存单条规则时清空其他域名的限流配置。
// 0.13.0:默认只监听回环地址,非回环监听必须显式允许;状态接口暴露监听范围与传输安全。
// 0.13.1:nginx 版本备份目录改为隐藏目录(.<name>.versions)并自动迁移旧目录,
// 修掉旧 <name>.versions 目录被 include * 当配置文件读取、nginx -t 报 pread() "Is a directory" 的问题。
// 0.13.2:nginx -T 探测失败时先从报错文本解析旧 <name>.versions 目录并迁移后重试,
// 修掉「配置目录还没探测出来、迁移逻辑没机会执行」导致 HTTPS 直连持续失败的问题。
// 0.13.3:HTTPS 反代改用 default_server 并在 reload 后做本机证书指纹自检,
// 证书不匹配时回滚并回报实际指纹与端口占用进程,区分「nginx 未生效」与「公网链路被接管」。
// 0.14.0:HTTPS 端口自动选择(7443/8443/9443)、当前证书信息查询、主机防火墙检测与 ufw/firewalld 显式放行。
// 0.14.1:vhost 模板迁出到 internal/nginxtmpl,统一 TLS、安全头、allow/deny、WebSocket 与限流输出。
const AgentVersion = "0.15.0"
// Config 是 havline-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。 // Config 是 havline-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
type Config struct { type Config struct {
@@ -254,6 +224,7 @@ func agentRoutes(s *Server) []agentRoute {
{http.MethodGet, "/api/v1/frps/config", s.frpsConfig}, {http.MethodGet, "/api/v1/frps/config", s.frpsConfig},
{http.MethodGet, "/api/v1/certs", s.certs}, {http.MethodGet, "/api/v1/certs", s.certs},
{http.MethodPost, "/api/v1/nginx/reload", s.nginxReload}, {http.MethodPost, "/api/v1/nginx/reload", s.nginxReload},
{http.MethodPost, "/api/v1/nginx/action", s.nginxAction},
} }
} }
@@ -336,7 +307,7 @@ func (s *Server) status(w http.ResponseWriter, _ *http.Request) {
"frps_unit_exec": strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")), "frps_unit_exec": strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")),
"frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil, "frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil,
"frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)), "frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)),
"frps_log_tail": strings.TrimSpace(journalTailDesc("frps", frpsLogTailLines)), "frps_log_tail": frpsLogTailDesc(),
"dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)), "dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)),
"routes": s.routeNames(), "routes": s.routeNames(),
"checked_at": time.Now().UTC().Format(time.RFC3339), "checked_at": time.Now().UTC().Format(time.RFC3339),
@@ -460,7 +431,7 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
} }
} }
if writeUnit { if writeUnit {
unit := fmt.Sprintf("[Unit]\nDescription=frps service (managed by havline-agent)\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=%s -c %s\nRestart=on-failure\nRestartSec=3\n\n[Install]\nWantedBy=multi-user.target\n", installPath, s.cfg.FrpsConfigPath) unit := fmt.Sprintf("[Unit]\nDescription=frps service (managed by havline-agent)\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=%s -c %s\nRestart=on-failure\nRestartSec=3\nStandardOutput=append:%s\nStandardError=append:%s\n\n[Install]\nWantedBy=multi-user.target\n", installPath, s.cfg.FrpsConfigPath, frpsSystemdLogPath, frpsSystemdLogPath)
if err := atomicWrite(unitPath, []byte(unit), 0o644); err != nil { if err := atomicWrite(unitPath, []byte(unit), 0o644); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "写入 frps.service 失败: " + err.Error()}) writeJSON(w, http.StatusBadGateway, map[string]string{"error": "写入 frps.service 失败: " + err.Error()})
return return
@@ -515,22 +486,111 @@ func (s *Server) frpsAction(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "action 需为 start/stop/restart"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "action 需为 start/stop/restart"})
return return
} }
notes := make([]string, 0, 2)
if req.Action == "start" || req.Action == "restart" {
if note := cleanupOrphanFRPS(); note != "" {
notes = append(notes, note)
}
}
if output, err := command.CombinedOutput(); err != nil { if output, err := command.CombinedOutput(); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": fmt.Sprintf("systemctl %s frps 失败: %s", req.Action, strings.TrimSpace(string(output)))}) writeJSON(w, http.StatusBadGateway, map[string]string{"error": fmt.Sprintf("systemctl %s frps 失败: %s", req.Action, strings.TrimSpace(string(output)))})
return return
} }
time.Sleep(800 * time.Millisecond) // 给 frps 启动留出窗口,失败时才能在 is-active/journal 里看到 time.Sleep(800 * time.Millisecond) // 给 frps 启动留出窗口,失败时才能在 is-active/journal 里看到
active := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil active := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil
result := map[string]any{"ok": true, "action": req.Action, "frps_active": active} result := map[string]any{"ok": true, "action": req.Action, "frps_active": active, "notes": notes}
if !active { if !active {
// 启动失败时把 frps 的失败原因带回去,而不是只说“启动成功” // 启动失败时把 frps 的失败原因带回去,而不是只说“启动成功”
journal, _ := exec.Command("journalctl", "-u", "frps", "-n", "8", "--no-pager").CombinedOutput()
result["ok"] = false result["ok"] = false
result["error"] = fmt.Sprintf("systemctl %s 已执行但 frps 未进入运行状态,最近日志:\n%s", req.Action, strings.TrimSpace(string(journal))) result["error"] = s.frpsFailureDetail(req.Action)
} }
writeJSON(w, http.StatusOK, result) writeJSON(w, http.StatusOK, result)
} }
const frpsSystemdLogPath = "/var/log/frps-systemd.log"
// cleanupOrphanFRPS 在 systemd 服务未运行时清理手工启动的 frps,
// 避免旧进程占用 7000/vhost 端口后导致 systemd 启动即退出。
func cleanupOrphanFRPS() string {
if exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil {
return ""
}
if exec.Command("pgrep", "-x", "frps").Run() != nil {
return ""
}
if err := exec.Command("pkill", "-x", "frps").Run(); err != nil {
return "检测到 systemd 之外运行的 frps 进程,但停止失败"
}
time.Sleep(300 * time.Millisecond)
return "检测到 systemd 之外运行的 frps 进程,已先停止旧进程"
}
// frpsFailureDetail 汇总 systemd 状态、输出日志与直接启动结果,
// 避免 journald 不可用时只剩 “No journal files were found”。
func (s *Server) frpsFailureDetail(action string) string {
lines := []string{fmt.Sprintf("systemctl %s 已执行但 frps 未进入运行状态", action)}
if out := strings.TrimSpace(execCommandOutput("systemctl", "show", "frps",
"-p", "ActiveState", "-p", "SubState", "-p", "Result", "-p", "ExecMainCode", "-p", "ExecMainStatus")); out != "" {
lines = append(lines, "systemd 状态:\n"+out)
}
if out := strings.TrimSpace(execCommandOutput("systemctl", "status", "frps", "--no-pager", "-l")); out != "" {
lines = append(lines, "systemctl status:\n"+tailText(out, 2000))
}
if out := strings.TrimSpace(journalTail("frps", frpsLogTailLines)); out != "" && !strings.Contains(out, "No journal files were found") {
lines = append(lines, "journal 日志:\n"+out)
}
if out := strings.TrimSpace(readFRPSSystemdLog()); out != "" {
lines = append(lines, "frps 输出:\n"+out)
}
if out := s.probeFRPSStartup(); out != "" {
lines = append(lines, "直接启动诊断:\n"+out)
}
return strings.Join(lines, "\n\n")
}
// probeFRPSStartup 在无 systemd 日志时直接运行 frps 2 秒:
// 配置错误会立即返回;配置正常时会打印启动成功后被 timeout 结束。
func (s *Server) probeFRPSStartup() string {
if exec.Command("pgrep", "-x", "frps").Run() == nil {
return "已有 frps 进程在运行,跳过直接启动诊断"
}
out, err := exec.Command("timeout", "2s", s.cfg.FrpsBinary, "-c", s.cfg.FrpsConfigPath).CombinedOutput()
text := strings.TrimSpace(string(out))
hint := frpsStartupHint(text)
if err != nil {
if text == "" {
text = err.Error()
}
return tailText(text+hint, 2400)
}
if text == "" {
return "frps 直接运行 2 秒未报错,可能已正常启动后被超时结束;请检查 systemd 单元和端口占用"
}
return tailText(text+hint, 2400)
}
func frpsStartupHint(output string) string {
if !strings.Contains(output, "address already in use") {
return ""
}
return "\n\n建议:80/443 通常由 VPS 上的 Nginx 占用;请把 frps 的 vhostHTTPPort/vhostHTTPSPort 改为 8080/8443,保存后重新下发 frps 配置并重启 frps。"
}
func frpsLogTailDesc() string {
if out := strings.TrimSpace(journalTailDesc("frps", frpsLogTailLines)); out != "" && !strings.Contains(out, "No journal files were found") {
return out
}
return readFRPSSystemdLog()
}
func readFRPSSystemdLog() string {
data, err := os.ReadFile(frpsSystemdLogPath)
if err != nil {
return ""
}
return tailText(string(data), 8000)
}
func downloadToFile(url, path string) error { func downloadToFile(url, path string) error {
client := &http.Client{Timeout: 10 * time.Minute} client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Get(url) resp, err := client.Get(url)
+31 -3
View File
@@ -277,7 +277,12 @@ func (h *FRPMultiHandler) AgentInstall(w http.ResponseWriter, r *http.Request) {
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效") writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
return return
} }
server, err := h.svc.AgentInstall(r.Context(), id) var server frp.Server
if r.URL.Query().Get("trust_new_host_key") == "true" {
server, err = h.svc.AgentInstallTrustHostKey(r.Context(), id)
} else {
server, err = h.svc.AgentInstall(r.Context(), id)
}
if err != nil { if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error()) writeError(r, w, http.StatusBadGateway, err.Error())
return return
@@ -796,6 +801,28 @@ func (h *FRPMultiHandler) AgentNginxReload(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusOK, result) writeJSON(w, http.StatusOK, result)
} }
// AgentNginxAction 启动 / 停止 / 重启 VPS 上的 Nginx(需 agent ≥ 0.15.2)
func (h *FRPMultiHandler) AgentNginxAction(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
return
}
var body struct {
Action string `json:"action"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
result, err := h.svc.AgentNginxAction(r.Context(), id, body.Action)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request) { func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id")) id, err := parseID(r.PathValue("id"))
if err != nil { if err != nil {
@@ -809,11 +836,12 @@ func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request
writeError(r, w, http.StatusBadRequest, "请求格式无效") writeError(r, w, http.StatusBadRequest, "请求格式无效")
return return
} }
if err := h.svc.AgentFRPSAction(r.Context(), id, body.Action); err != nil { result, err := h.svc.AgentFRPSAction(r.Context(), id, body.Action)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error()) writeError(r, w, http.StatusBadGateway, err.Error())
return return
} }
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "action": body.Action}) writeJSON(w, http.StatusOK, result)
} }
func (h *FRPMultiHandler) InstallFRPSOnAgent(w http.ResponseWriter, r *http.Request) { func (h *FRPMultiHandler) InstallFRPSOnAgent(w http.ResponseWriter, r *http.Request) {
+1
View File
@@ -168,6 +168,7 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
protect("GET /api/frpmulti/servers/{id}/agent/certs", frpHandler.AgentCerts) protect("GET /api/frpmulti/servers/{id}/agent/certs", frpHandler.AgentCerts)
protect("POST /api/frpmulti/servers/{id}/agent/nginx/reload", frpHandler.AgentNginxReload) protect("POST /api/frpmulti/servers/{id}/agent/nginx/reload", frpHandler.AgentNginxReload)
protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx) protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx)
protect("POST /api/frpmulti/servers/{id}/agent/nginx/action", frpHandler.AgentNginxAction)
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf) protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf)
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions", frpHandler.AgentRouteVersions) protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions", frpHandler.AgentRouteVersions)
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions/{name}", frpHandler.AgentRouteVersion) protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions/{name}", frpHandler.AgentRouteVersion)
+56 -2
View File
@@ -6,9 +6,11 @@ import (
"io" "io"
"io/fs" "io/fs"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/havline/havline/internal/acme" "github.com/havline/havline/internal/acme"
@@ -68,6 +70,14 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
} }
logger.Info("会话密钥已就绪", "module", "SYSTEM", "source", secretSource) logger.Info("会话密钥已就绪", "module", "SYSTEM", "source", secretSource)
updaterToken, updaterTokenSource, err := updater.EnsureToken(cfg.UpdaterToken, cfg.UpdaterTokenFile)
if err != nil {
return nil, err
}
if updaterToken != "" {
logger.Info("updater Token 已就绪", "module", "SYSTEM", "source", updaterTokenSource)
}
conn, err := db.Open(cfg.DBPath()) conn, err := db.Open(cfg.DBPath())
if err != nil { if err != nil {
return nil, err return nil, err
@@ -122,7 +132,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
proxySvc.SetExitSync(cloudflareSvc) proxySvc.SetExitSync(cloudflareSvc)
startedAt := time.Now().UTC().Format(time.RFC3339) startedAt := time.Now().UTC().Format(time.RFC3339)
trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc) trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
updaterClient := updater.NewClient(cfg.UpdaterSocket) updaterClient := updater.NewClient(cfg.UpdaterSocket, updaterToken)
handler := api.NewRouter(api.Deps{ handler := api.NewRouter(api.Deps{
Config: cfg, Config: cfg,
@@ -144,6 +154,9 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
StaticFS: staticFS, StaticFS: staticFS,
StartedAt: startedAt, StartedAt: startedAt,
}) })
if prefix := strings.TrimSpace(cfg.GatewayPrefix); prefix != "" {
handler = stripPrefixIfPresent(prefix, handler)
}
server := &http.Server{ server := &http.Server{
Addr: cfg.ListenAddr, Addr: cfg.ListenAddr,
Handler: handler, Handler: handler,
@@ -235,7 +248,32 @@ func (a *App) bootstrapNginx(ctx context.Context, proxySvc *service.ProxyService
} }
func (a *App) Run() error { func (a *App) Run() error {
return a.server.ListenAndServe() if strings.TrimSpace(a.cfg.GatewaySocket) == "" {
return a.server.ListenAndServe()
}
// 先准备两端监听,任何一端失败都不留下半启动的服务。
tcp, err := net.Listen("tcp", a.server.Addr)
if err != nil {
return err
}
defer tcp.Close()
socket := strings.TrimSpace(a.cfg.GatewaySocket)
if err := os.Remove(socket); err != nil && !os.IsNotExist(err) {
return err
}
listener, err := net.Listen("unix", socket)
if err != nil {
return err
}
defer listener.Close()
defer os.Remove(socket)
if err := os.Chmod(socket, 0o660); err != nil {
return err
}
errCh := make(chan error, 2)
go func() { errCh <- a.server.Serve(tcp) }()
go func() { errCh <- a.server.Serve(listener) }()
return <-errCh
} }
func (a *App) Shutdown(ctx context.Context) error { func (a *App) Shutdown(ctx context.Context) error {
@@ -246,6 +284,9 @@ func (a *App) Shutdown(ctx context.Context) error {
if err := a.server.Shutdown(ctx); err != nil { if err := a.server.Shutdown(ctx); err != nil {
return err return err
} }
if socket := strings.TrimSpace(a.cfg.GatewaySocket); socket != "" {
_ = os.Remove(socket)
}
if err := a.nginx.Stop(ctx); err != nil { if err := a.nginx.Stop(ctx); err != nil {
a.logger.Error("nginx stop failed", "module", "NGINX", "error", err.Error()) a.logger.Error("nginx stop failed", "module", "NGINX", "error", err.Error())
} }
@@ -260,6 +301,19 @@ func (a *App) Shutdown(ctx context.Context) error {
return nil return nil
} }
func stripPrefixIfPresent(prefix string, next http.Handler) http.Handler {
prefix = strings.TrimRight(prefix, "/")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == prefix || strings.HasPrefix(r.URL.Path, prefix+"/") {
r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
if r.URL.Path == "" {
r.URL.Path = "/"
}
}
next.ServeHTTP(w, r)
})
}
func ResolveMigrationsDir() (string, error) { func ResolveMigrationsDir() (string, error) {
candidates := []string{ candidates := []string{
"migrations", "migrations",
+25
View File
@@ -0,0 +1,25 @@
package app
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestGatewayPrefix(t *testing.T) {
for _, tc := range []struct{ input, want string }{
{"/app/havline/api/version", "/api/version"},
{"/app/havline", "/"},
{"/api/version", "/api/version"},
{"/app/havline-other/api/version", "/app/havline-other/api/version"},
} {
t.Run(tc.input, func(t *testing.T) {
handler := stripPrefixIfPresent("/app/havline/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != tc.want {
t.Errorf("路径为 %q,期望 %q", r.URL.Path, tc.want)
}
}))
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, tc.input, nil))
})
}
}
+27 -1
View File
@@ -12,6 +12,8 @@ import (
type Config struct { type Config struct {
ListenAddr string ListenAddr string
GatewaySocket string
GatewayPrefix string
DataDir string DataDir string
SessionSecret string SessionSecret string
NginxBin string NginxBin string
@@ -22,6 +24,8 @@ type Config struct {
FrpcBin string FrpcBin string
FrpPIDFile string FrpPIDFile string
UpdaterSocket string UpdaterSocket string
UpdaterToken string
UpdaterTokenFile string
} }
func Load() Config { func Load() Config {
@@ -31,6 +35,8 @@ func Load() Config {
} }
return Config{ return Config{
ListenAddr: envOr("HAVLINE_LISTEN", ":6893"), ListenAddr: envOr("HAVLINE_LISTEN", ":6893"),
GatewaySocket: envOr("HAVLINE_GATEWAY_SOCKET", ""),
GatewayPrefix: envOr("HAVLINE_GATEWAY_PREFIX", ""),
DataDir: dataDir, DataDir: dataDir,
SessionSecret: envOr("HAVLINE_SESSION_SECRET", ""), SessionSecret: envOr("HAVLINE_SESSION_SECRET", ""),
NginxBin: envOr("HAVLINE_NGINX_BIN", "nginx"), NginxBin: envOr("HAVLINE_NGINX_BIN", "nginx"),
@@ -40,7 +46,9 @@ func Load() Config {
NginxDefaultHTTPSPort: envIntOr("HAVLINE_NGINX_HTTPS_PORT", 443), NginxDefaultHTTPSPort: envIntOr("HAVLINE_NGINX_HTTPS_PORT", 443),
FrpcBin: envOr("HAVLINE_FRPC_BIN", "frpc"), FrpcBin: envOr("HAVLINE_FRPC_BIN", "frpc"),
FrpPIDFile: envOr("HAVLINE_FRP_PID", dataDir+"/frp/frpc.pid"), FrpPIDFile: envOr("HAVLINE_FRP_PID", dataDir+"/frp/frpc.pid"),
UpdaterSocket: envOr("HAVLINE_UPDATER_SOCKET", "/run/havline-updater/updater.sock"), UpdaterSocket: updaterSocket(),
UpdaterToken: envOr("HAVLINE_UPDATER_TOKEN", ""),
UpdaterTokenFile: envOr("HAVLINE_UPDATER_TOKEN_FILE", defaultUpdaterTokenFile()),
} }
} }
@@ -104,6 +112,12 @@ func (c Config) SSHKnownHostsPath() string {
return filepath.Join(c.DataDir, "ssh_known_hosts") return filepath.Join(c.DataDir, "ssh_known_hosts")
} }
// SSHKnownHostsPathForServer 为每台 FRP 服务端提供独立的 known_hosts,
// 避免一台 VPS 的主机密钥记录影响另一台服务器的 SSH 连接。
func (c Config) SSHKnownHostsPathForServer(serverID int64) string {
return filepath.Join(c.DataDir, "ssh_known_hosts", fmt.Sprintf("server-%d", serverID))
}
func (c Config) ErrorsDir() string { func (c Config) ErrorsDir() string {
return c.NginxDir() + "/errors" return c.NginxDir() + "/errors"
} }
@@ -232,3 +246,15 @@ func envIntOr(key string, fallback int) int {
} }
return n return n
} }
// 原生应用显式传空值以禁用 Docker 升级;未配置时保留容器默认值。
func updaterSocket() string {
if value, ok := os.LookupEnv("HAVLINE_UPDATER_SOCKET"); ok {
return value
}
return "/run/havline-updater/updater.sock"
}
func defaultUpdaterTokenFile() string {
return "/run/havline-updater/updater.token"
}
+18
View File
@@ -2,6 +2,7 @@ package config
import ( import (
"os" "os"
"path/filepath"
"testing" "testing"
) )
@@ -33,3 +34,20 @@ func TestLoadNginxDefaultPortsFallback(t *testing.T) {
t.Fatalf("ports %d %d", cfg.NginxDefaultHTTPPort, cfg.NginxDefaultHTTPSPort) t.Fatalf("ports %d %d", cfg.NginxDefaultHTTPPort, cfg.NginxDefaultHTTPSPort)
} }
} }
func TestLoadUpdaterTokenFile(t *testing.T) {
t.Setenv("HAVLINE_UPDATER_TOKEN_FILE", "/tmp/havline-updater.token")
cfg := Load()
if cfg.UpdaterTokenFile != "/tmp/havline-updater.token" {
t.Fatalf("updater token file %q", cfg.UpdaterTokenFile)
}
}
func TestSSHKnownHostsPathForServer(t *testing.T) {
cfg := Config{DataDir: "/data"}
want := filepath.Join("/data", "ssh_known_hosts", "server-3")
if got := cfg.SSHKnownHostsPathForServer(3); got != want {
t.Fatalf("per-server known_hosts path %q", got)
}
}
+16
View File
@@ -0,0 +1,16 @@
package config
import "testing"
func TestNativeGatewayConfig(t *testing.T) {
t.Setenv("HAVLINE_GATEWAY_SOCKET", "/tmp/havline-test.sock")
t.Setenv("HAVLINE_GATEWAY_PREFIX", "/app/havline")
t.Setenv("HAVLINE_UPDATER_SOCKET", "")
cfg := Load()
if cfg.GatewaySocket != "/tmp/havline-test.sock" || cfg.GatewayPrefix != "/app/havline" {
t.Fatal("原生网关配置未生效")
}
if cfg.UpdaterSocket != "" {
t.Fatal("原生应用显式空值应禁用 Docker 升级")
}
}
+49 -2
View File
@@ -219,9 +219,15 @@ const agentMinVersionInsights = "0.11.0"
// agentMinVersionRouteVersions 是域名 vhost 版本列表 / 对比 / 回滚端点要求的最低 agent 版本 // agentMinVersionRouteVersions 是域名 vhost 版本列表 / 对比 / 回滚端点要求的最低 agent 版本
const agentMinVersionRouteVersions = "0.12.2" const agentMinVersionRouteVersions = "0.12.2"
// agentMinVersionNginxAction 是 Nginx 启停(start / stop / restart)端点要求的最低 agent 版本
const agentMinVersionNginxAction = "0.15.2"
// ErrAgentEndpointMissing 表示 VPS 上的 agent 版本过旧、没有该端点(HTTP 404/405) // ErrAgentEndpointMissing 表示 VPS 上的 agent 版本过旧、没有该端点(HTTP 404/405)
var ErrAgentEndpointMissing = errors.New("VPS 上的 agent 版本过低,不支持该接口") var ErrAgentEndpointMissing = errors.New("VPS 上的 agent 版本过低,不支持该接口")
// ErrAgentTokenMismatch 表示 Havline 保存的 Token 与 VPS 上 Agent 配置不一致。
var ErrAgentTokenMismatch = errors.New("Agent Token 不匹配,请点击「升级 Agent」重新同步")
// statusError 保留 HTTP 状态码,便于上层区分「端点不存在」与真实错误 // statusError 保留 HTTP 状态码,便于上层区分「端点不存在」与真实错误
type statusError struct { type statusError struct {
code int code int
@@ -232,6 +238,14 @@ func (e *statusError) Error() string {
return fmt.Sprintf("agent 返回 HTTP %d: %s", e.code, e.body) return fmt.Sprintf("agent 返回 HTTP %d: %s", e.code, e.body)
} }
func wrapAgentAuth(err error) error {
var statusErr *statusError
if errors.As(err, &statusErr) && statusErr.code == http.StatusUnauthorized {
return fmt.Errorf("%w: %s", ErrAgentTokenMismatch, statusErr.body)
}
return err
}
// wrapEndpointMissing 把「端点不存在」统一成带升级提示的错误(按端点给出最低版本),其它错误原样返回 // wrapEndpointMissing 把「端点不存在」统一成带升级提示的错误(按端点给出最低版本),其它错误原样返回
func wrapEndpointMissing(err error, minVersion string) error { func wrapEndpointMissing(err error, minVersion string) error {
var statusErr *statusError var statusErr *statusError
@@ -280,6 +294,16 @@ func (c *AgentClient) NginxReload(ctx context.Context) (map[string]any, error) {
return result, nil return result, nil
} }
// NginxAction 启动 / 停止 / 重启 VPS 上的 Nginx(需 agent ≥ 0.15.2)
func (c *AgentClient) NginxAction(ctx context.Context, action string) (map[string]any, error) {
var result map[string]any
payload := map[string]string{"action": action}
if err := c.request(ctx, http.MethodPost, "/api/v1/nginx/action", payload, &result); err != nil {
return nil, wrapEndpointMissing(err, agentMinVersionNginxAction)
}
return result, nil
}
// FRPSConfig 回读 VPS 上的 frps.toml(用于与下发内容比对) // FRPSConfig 回读 VPS 上的 frps.toml(用于与下发内容比对)
func (c *AgentClient) FRPSConfig(ctx context.Context) (map[string]any, error) { func (c *AgentClient) FRPSConfig(ctx context.Context) (map[string]any, error) {
var result map[string]any var result map[string]any
@@ -378,8 +402,31 @@ func (c *AgentClient) DeleteRoute(ctx context.Context, domain string) error {
return c.request(ctx, http.MethodDelete, "/api/v1/routes/"+domain, nil, nil) return c.request(ctx, http.MethodDelete, "/api/v1/routes/"+domain, nil, nil)
} }
func (c *AgentClient) FRPSAction(ctx context.Context, action string) error { func (c *AgentClient) FRPSAction(ctx context.Context, action string) (map[string]any, error) {
return c.request(ctx, http.MethodPost, "/api/v1/frps/action", map[string]string{"action": action}, nil) var result map[string]any
if err := c.request(ctx, http.MethodPost, "/api/v1/frps/action", map[string]string{"action": action}, &result); err != nil {
return nil, err
}
return normalizeFRPSActionResult(result)
}
// normalizeFRPSActionResult 保留 agent 的 ok=false 与日志,避免后端把它误判为成功。
func normalizeFRPSActionResult(result map[string]any) (map[string]any, error) {
if result == nil {
return nil, errors.New("agent 未返回 frps 操作结果")
}
ok, exists := result["ok"].(bool)
if !exists {
return nil, errors.New("agent 未返回 frps 操作结果")
}
if ok {
return result, nil
}
if msg, _ := result["error"].(string); strings.TrimSpace(msg) != "" {
return result, nil
}
result["error"] = "frps 未进入运行状态,请查看 frps 日志"
return result, nil
} }
func (c *AgentClient) InstallFRPS(ctx context.Context, version, proxy string) (map[string]any, error) { func (c *AgentClient) InstallFRPS(ctx context.Context, version, proxy string) (map[string]any, error) {
+36
View File
@@ -0,0 +1,36 @@
package frp
import "testing"
func TestNormalizeFRPSActionResult(t *testing.T) {
if _, err := normalizeFRPSActionResult(nil); err == nil {
t.Fatal("空响应应返回错误")
}
if _, err := normalizeFRPSActionResult(map[string]any{}); err == nil {
t.Fatal("缺少 ok 字段应返回错误")
}
failed, err := normalizeFRPSActionResult(map[string]any{
"ok": false,
"error": "systemctl start 已执行但 frps 未进入运行状态",
})
if err != nil {
t.Fatalf("ok=false 应保留给前端展示,got %v", err)
}
if failed["ok"] != false || failed["error"] == "" {
t.Fatalf("失败结果未保留:%+v", failed)
}
failedWithoutMessage, err := normalizeFRPSActionResult(map[string]any{"ok": false})
if err != nil {
t.Fatalf("ok=false 应补齐默认错误,got %v", err)
}
if failedWithoutMessage["error"] != "frps 未进入运行状态,请查看 frps 日志" {
t.Fatalf("默认错误不正确:%+v", failedWithoutMessage)
}
success, err := normalizeFRPSActionResult(map[string]any{"ok": true, "action": "start"})
if err != nil || success["ok"] != true {
t.Fatalf("成功结果解析失败:result=%+v err=%v", success, err)
}
}
+20 -5
View File
@@ -14,7 +14,8 @@ func (s *Service) AgentMetrics(ctx context.Context, serverID int64) (map[string]
if !ok { if !ok {
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存") return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
} }
return client.Metrics(ctx) result, err := client.Metrics(ctx)
return result, wrapAgentAuth(err)
} }
// AgentNginxLogs 读取 VPS 上 Nginx 的 access / error 日志尾部 // AgentNginxLogs 读取 VPS 上 Nginx 的 access / error 日志尾部
@@ -29,7 +30,8 @@ func (s *Service) AgentNginxLogs(ctx context.Context, serverID int64, kind strin
if lines <= 0 || lines > 2000 { if lines <= 0 || lines > 2000 {
lines = 200 lines = 200
} }
return client.NginxLogs(ctx, kind, lines) result, err := client.NginxLogs(ctx, kind, lines)
return result, wrapAgentAuth(err)
} }
// AgentCerts 列出 VPS 证书目录下的域名与到期时间,供与 Havline 证书库交叉核对 // AgentCerts 列出 VPS 证书目录下的域名与到期时间,供与 Havline 证书库交叉核对
@@ -38,7 +40,8 @@ func (s *Service) AgentCerts(ctx context.Context, serverID int64) (map[string]an
if !ok { if !ok {
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存") return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
} }
return client.Certs(ctx) result, err := client.Certs(ctx)
return result, wrapAgentAuth(err)
} }
// AgentNginxReload 显式校验并重载 VPS 上的 Nginx,返回失败原因 // AgentNginxReload 显式校验并重载 VPS 上的 Nginx,返回失败原因
@@ -47,7 +50,18 @@ func (s *Service) AgentNginxReload(ctx context.Context, serverID int64) (map[str
if !ok { if !ok {
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存") return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
} }
return client.NginxReload(ctx) result, err := client.NginxReload(ctx)
return result, wrapAgentAuth(err)
}
// AgentNginxAction 启动 / 停止 / 重启 VPS 上的 Nginx(需 agent ≥ 0.15.2)
func (s *Service) AgentNginxAction(ctx context.Context, serverID int64, action string) (map[string]any, error) {
client, ok := s.agentClient(ctx, serverID)
if !ok {
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
}
result, err := client.NginxAction(ctx, action)
return result, wrapAgentAuth(err)
} }
// AgentFRPSConfig 回读 VPS 上的 frps.toml,供与「即将下发的内容」比对 // AgentFRPSConfig 回读 VPS 上的 frps.toml,供与「即将下发的内容」比对
@@ -56,5 +70,6 @@ func (s *Service) AgentFRPSConfig(ctx context.Context, serverID int64) (map[stri
if !ok { if !ok {
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存") return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
} }
return client.FRPSConfig(ctx) result, err := client.FRPSConfig(ctx)
return result, wrapAgentAuth(err)
} }
+21
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"strings" "strings"
agentruntime "github.com/havline/havline/internal/agent"
"github.com/havline/havline/internal/frp/agentbin" "github.com/havline/havline/internal/frp/agentbin"
) )
@@ -71,6 +72,13 @@ func (i AgentInstaller) Install(ctx context.Context) (string, error) {
if token == "" { if token == "" {
return "", fmt.Errorf("安装完成但未取回 Token: %s", strings.TrimSpace(output)) return "", fmt.Errorf("安装完成但未取回 Token: %s", strings.TrimSpace(output))
} }
version := extractAgentVersion(output)
if version == "" {
return "", fmt.Errorf("安装完成但未识别 Agent 版本,无法确认升级结果: %s", strings.TrimSpace(output))
}
if version != agentruntime.AgentVersion {
return "", fmt.Errorf("VPS 上安装的 Agent 版本 %s 与主程序内嵌版本 %s 不一致;请重新构建主程序镜像后再执行「升级 Agent」", version, agentruntime.AgentVersion)
}
return token, nil return token, nil
} }
@@ -105,6 +113,19 @@ func extractToken(output string) string {
return "" return ""
} }
func extractAgentVersion(output string) string {
for _, line := range strings.Split(output, "\n") {
if version, found := strings.CutPrefix(strings.TrimSpace(line), "agent version: "); found {
fields := strings.Fields(version)
if len(fields) == 0 {
return ""
}
return strings.TrimPrefix(fields[len(fields)-1], "v")
}
}
return ""
}
func agentInstallScript() ([]byte, error) { func agentInstallScript() ([]byte, error) {
// 与 scripts/agent-install.sh 同一份逻辑内嵌:支持传入二进制路径参数,卸载子命令。 // 与 scripts/agent-install.sh 同一份逻辑内嵌:支持传入二进制路径参数,卸载子命令。
return []byte(`#!/usr/bin/env bash return []byte(`#!/usr/bin/env bash
+19
View File
@@ -0,0 +1,19 @@
package frp
import "testing"
func TestExtractAgentVersion(t *testing.T) {
output := "agent version: 0.15.1\nhavline-agent installed\nToken: abc123"
if got := extractAgentVersion(output); got != "0.15.1" {
t.Fatalf("版本解析失败,got %q", got)
}
if got := extractAgentVersion("agent version: havline-agent 0.15.1\nhavline-agent installed"); got != "0.15.1" {
t.Fatalf("带程序名前缀的版本解析失败,got %q", got)
}
if got := extractAgentVersion("agent version: v0.15.1"); got != "0.15.1" {
t.Fatalf("带 v 前缀的版本解析失败,got %q", got)
}
if got := extractAgentVersion("havline-agent installed\nToken: abc123"); got != "" {
t.Fatalf("无版本行时应返回空串,got %q", got)
}
}
+138 -25
View File
@@ -2,6 +2,7 @@ package frp
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net" "net"
@@ -14,25 +15,31 @@ import (
const agentRemoteAddr = "127.0.0.1:7700" const agentRemoteAddr = "127.0.0.1:7700"
const ( const (
agentTunnelKeepalive = 30 * time.Second agentTunnelKeepalive = 30 * time.Second
agentTunnelBackoffMin = time.Second agentTunnelKeepaliveTimeout = 5 * time.Second
agentTunnelBackoffMax = 60 * time.Second agentTunnelBackoffMin = time.Second
agentTunnelBackoffMax = 60 * time.Second
) )
type agentTunnel struct { type agentTunnel struct {
serverID int64 serverID int64
localPort int localPort int
ssh SSHInstaller remoteAddr string
ssh SSHInstaller
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
connectMu sync.Mutex
mu sync.Mutex mu sync.Mutex
listener net.Listener listener net.Listener
client *ssh.Client client *ssh.Client
state string state string
lastError string lastError string
wg sync.WaitGroup wg sync.WaitGroup
connsMu sync.Mutex
conns map[net.Conn]struct{}
} }
func newAgentTunnel(parent context.Context, serverID int64, localPort int, installer SSHInstaller) *agentTunnel { func newAgentTunnel(parent context.Context, serverID int64, localPort int, installer SSHInstaller) *agentTunnel {
@@ -42,13 +49,15 @@ func newAgentTunnel(parent context.Context, serverID int64, localPort int, insta
func newAgentTunnelWithListener(parent context.Context, serverID int64, localPort int, installer SSHInstaller, listener net.Listener) *agentTunnel { func newAgentTunnelWithListener(parent context.Context, serverID int64, localPort int, installer SSHInstaller, listener net.Listener) *agentTunnel {
ctx, cancel := context.WithCancel(parent) ctx, cancel := context.WithCancel(parent)
return &agentTunnel{ return &agentTunnel{
serverID: serverID, serverID: serverID,
localPort: localPort, localPort: localPort,
ssh: installer, remoteAddr: agentRemoteAddr,
ctx: ctx, ssh: installer,
cancel: cancel, ctx: ctx,
listener: listener, cancel: cancel,
state: "connecting", listener: listener,
state: "connecting",
conns: map[net.Conn]struct{}{},
} }
} }
@@ -61,8 +70,13 @@ func (t *agentTunnel) Start() error {
} }
t.listener = listener t.listener = listener
} }
if t.localPort == 0 {
if addr, ok := t.listener.Addr().(*net.TCPAddr); ok {
t.localPort = addr.Port
}
}
t.mu.Lock() t.mu.Lock()
t.state = "connected" t.state = "connecting"
t.lastError = "" t.lastError = ""
t.mu.Unlock() t.mu.Unlock()
t.wg.Add(2) t.wg.Add(2)
@@ -76,7 +90,7 @@ func (t *agentTunnel) monitorLoop() {
defer t.wg.Done() defer t.wg.Done()
backoff := agentTunnelBackoffMin backoff := agentTunnelBackoffMin
for { for {
_, err := t.ensureClient() err := t.check()
wait := agentTunnelKeepalive wait := agentTunnelKeepalive
if err != nil { if err != nil {
t.setState("reconnecting", err) t.setState("reconnecting", err)
@@ -92,6 +106,24 @@ func (t *agentTunnel) monitorLoop() {
} }
} }
// check 同时验证 SSH 连接和远端 Agent 端口,避免只连上 SSH 就误判隧道可用。
func (t *agentTunnel) check() error {
client, err := t.ensureClient()
if err != nil {
return err
}
if err := t.keepalive(client); err != nil {
t.invalidateClient(client)
return fmt.Errorf("SSH keepalive 失败: %w", err)
}
remote, err := client.Dial("tcp", t.remoteAddr)
if err != nil {
return fmt.Errorf("连接远端 Agent 端口失败: %w", err)
}
_ = remote.Close()
return nil
}
func (t *agentTunnel) sleep(d time.Duration) bool { func (t *agentTunnel) sleep(d time.Duration) bool {
timer := time.NewTimer(d) timer := time.NewTimer(d)
defer timer.Stop() defer timer.Stop()
@@ -126,8 +158,13 @@ func (t *agentTunnel) acceptLoop() {
return return
} }
t.wg.Add(1) t.wg.Add(1)
if !t.trackConn(conn) {
t.wg.Done()
return
}
go func() { go func() {
defer t.wg.Done() defer t.wg.Done()
defer t.untrackConn(conn)
t.handle(conn) t.handle(conn)
}() }()
} }
@@ -138,31 +175,53 @@ func (t *agentTunnel) handle(local net.Conn) {
client, err := t.ensureClient() client, err := t.ensureClient()
if err != nil { if err != nil {
t.setState("reconnecting", err) t.setState("reconnecting", err)
writeTunnelError(local, err)
return return
} }
remote, err := client.Dial("tcp", agentRemoteAddr) remote, err := client.Dial("tcp", t.remoteAddr)
if err != nil { if err != nil {
t.invalidateClient(client) remote, err = t.retryRemoteDial(client, err)
t.setState("reconnecting", err) if err != nil {
return t.setState("reconnecting", err)
writeTunnelError(local, err)
return
}
} }
defer remote.Close() defer remote.Close()
t.setState("connected", nil) t.setState("connected", nil)
proxyTunnel(local, remote) proxyTunnel(local, remote)
} }
// retryRemoteDial 仅在 SSH 连接本身失效时重连,远端 Agent 端口暂时不可用不会关闭共享连接。
func (t *agentTunnel) retryRemoteDial(client *ssh.Client, firstErr error) (net.Conn, error) {
if err := t.keepalive(client); err == nil {
return nil, fmt.Errorf("连接远端 Agent 端口失败: %w", firstErr)
}
t.invalidateClient(client)
reconnected, err := t.ensureClient()
if err != nil {
return nil, fmt.Errorf("SSH 连接失效,重连失败: %w", err)
}
remote, err := reconnected.Dial("tcp", t.remoteAddr)
if err != nil {
return nil, fmt.Errorf("SSH 已重连,但远端 Agent 端口仍不可用: %w", err)
}
return remote, nil
}
func (t *agentTunnel) ensureClient() (*ssh.Client, error) { func (t *agentTunnel) ensureClient() (*ssh.Client, error) {
// 单飞:监控循环和请求处理可能同时触发建连,串行化后只允许一条 SSH 握手。
// 正常请求只复用连接,不在热路径做 keepalive,避免多个接口互相排队。
t.connectMu.Lock()
defer t.connectMu.Unlock()
t.mu.Lock() t.mu.Lock()
if t.client != nil { if t.client != nil {
client := t.client client := t.client
t.mu.Unlock() t.mu.Unlock()
if _, _, err := client.SendRequest("keepalive@openssh.com", true, nil); err == nil { return client, nil
return client, nil
}
t.invalidateClient(client)
} else {
t.mu.Unlock()
} }
t.mu.Unlock()
ctx, cancel := context.WithTimeout(t.ctx, 15*time.Second) ctx, cancel := context.WithTimeout(t.ctx, 15*time.Second)
defer cancel() defer cancel()
client, err := t.ssh.client(ctx) client, err := t.ssh.client(ctx)
@@ -181,6 +240,30 @@ func (t *agentTunnel) ensureClient() (*ssh.Client, error) {
return client, nil return client, nil
} }
func (t *agentTunnel) keepalive(client *ssh.Client) error {
done := make(chan error, 1)
go func() {
_, _, err := client.SendRequest("keepalive@openssh.com", true, nil)
done <- err
}()
select {
case err := <-done:
return err
case <-time.After(agentTunnelKeepaliveTimeout):
return fmt.Errorf("SSH keepalive 超时")
case <-t.ctx.Done():
return t.ctx.Err()
}
}
// writeTunnelError 让本地 HTTP 客户端立即得到明确错误,而不是一直等到 context deadline。
func writeTunnelError(conn net.Conn, err error) {
body, _ := json.Marshal(map[string]string{"error": "SSH 隧道不可用: " + err.Error()})
_, _ = fmt.Fprintf(conn,
"HTTP/1.1 502 Bad Gateway\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s",
len(body), body)
}
func (t *agentTunnel) invalidateClient(client *ssh.Client) { func (t *agentTunnel) invalidateClient(client *ssh.Client) {
t.mu.Lock() t.mu.Lock()
if t.client == client { if t.client == client {
@@ -217,9 +300,39 @@ func (t *agentTunnel) Stop() {
_ = t.client.Close() _ = t.client.Close()
} }
t.mu.Unlock() t.mu.Unlock()
t.closeConns()
t.wg.Wait() t.wg.Wait()
} }
func (t *agentTunnel) trackConn(conn net.Conn) bool {
t.connsMu.Lock()
defer t.connsMu.Unlock()
if t.ctx.Err() != nil {
_ = conn.Close()
return false
}
t.conns[conn] = struct{}{}
return true
}
func (t *agentTunnel) untrackConn(conn net.Conn) {
t.connsMu.Lock()
delete(t.conns, conn)
t.connsMu.Unlock()
}
func (t *agentTunnel) closeConns() {
t.connsMu.Lock()
conns := make([]net.Conn, 0, len(t.conns))
for conn := range t.conns {
conns = append(conns, conn)
}
t.connsMu.Unlock()
for _, conn := range conns {
_ = conn.Close()
}
}
func proxyTunnel(a, b net.Conn) { func proxyTunnel(a, b net.Conn) {
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(2) wg.Add(2)
@@ -0,0 +1,246 @@
package frp
import (
"context"
"crypto/ed25519"
"crypto/rand"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"golang.org/x/crypto/ssh"
)
type directTCPIPPayload struct {
DestAddr string
DestPort uint32
OriginAddr string
OriginPort uint32
}
func TestAgentTunnelReturnsBadGatewayWhenRemoteAgentIsDown(t *testing.T) {
sshAddr := startFakeSSHServer(t)
tunnel := startTestAgentTunnel(t, sshAddr, "127.0.0.1:1")
response, err := (&http.Client{Timeout: 5 * time.Second}).Get(
fmt.Sprintf("http://127.0.0.1:%d/api/v1/status", tunnel.localPort),
)
if err != nil {
t.Fatalf("应返回 HTTP 502,而不是 EOF 或超时: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusBadGateway {
t.Fatalf("状态码 = %d,期望 502", response.StatusCode)
}
body, _ := io.ReadAll(response.Body)
if !strings.Contains(string(body), "SSH 隧道不可用") {
t.Fatalf("响应应包含隧道失败原因,实际为 %q", string(body))
}
}
func TestAgentTunnelForwardsHTTPToRemoteAgent(t *testing.T) {
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer remote.Close()
sshAddr := startFakeSSHServer(t)
tunnel := startTestAgentTunnel(t, sshAddr, strings.TrimPrefix(remote.URL, "http://"))
response, err := (&http.Client{Timeout: 5 * time.Second}).Get(
fmt.Sprintf("http://127.0.0.1:%d/api/v1/status", tunnel.localPort),
)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
body, _ := io.ReadAll(response.Body)
if response.StatusCode != http.StatusOK || string(body) != `{"ok":true}` {
t.Fatalf("隧道转发失败: status=%d body=%q", response.StatusCode, string(body))
}
}
func TestAgentTunnelSerializesConcurrentSSHConnections(t *testing.T) {
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer remote.Close()
sshAddr, connectionCount := startFakeSSHServerWithCounter(t, 300*time.Millisecond)
tunnel := startTestAgentTunnel(t, sshAddr, strings.TrimPrefix(remote.URL, "http://"))
client := &http.Client{Timeout: 5 * time.Second}
var wait sync.WaitGroup
for index := 0; index < 4; index++ {
wait.Add(1)
go func() {
defer wait.Done()
response, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/api/v1/status", tunnel.localPort))
if err == nil {
_, _ = io.Copy(io.Discard, response.Body)
_ = response.Body.Close()
}
}()
}
wait.Wait()
if got := connectionCount.Load(); got != 1 {
t.Fatalf("并发请求建立了 %d 条 SSH 连接,期望单飞复用 1 条", got)
}
}
func TestAgentTunnelDoesNotBlockRequestsOnSlowKeepalive(t *testing.T) {
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer remote.Close()
sshAddr, _ := startFakeSSHServerWithDelays(t, 0, 3*time.Second)
tunnel := startTestAgentTunnel(t, sshAddr, strings.TrimPrefix(remote.URL, "http://"))
started := time.Now()
response, err := (&http.Client{Timeout: 5 * time.Second}).Get(
fmt.Sprintf("http://127.0.0.1:%d/api/v1/status", tunnel.localPort),
)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, response.Body)
if elapsed := time.Since(started); elapsed > time.Second {
t.Fatalf("正常请求被慢 keepalive 阻塞了 %v", elapsed)
}
}
func startTestAgentTunnel(t *testing.T, sshAddr, remoteAddr string) *agentTunnel {
t.Helper()
host, portText, err := net.SplitHostPort(sshAddr)
if err != nil {
t.Fatal(err)
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatal(err)
}
tunnel := newAgentTunnel(context.Background(), 1, 0, SSHInstaller{
Host: host, Port: port, User: "test", Password: "test",
Timeout: 2 * time.Second, KnownHostsPath: filepath.Join(t.TempDir(), "known_hosts"),
})
tunnel.remoteAddr = remoteAddr
if err := tunnel.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(tunnel.Stop)
return tunnel
}
func startFakeSSHServer(t *testing.T) string {
t.Helper()
addr, _ := startFakeSSHServerWithCounter(t, 0)
return addr
}
func startFakeSSHServerWithCounter(t *testing.T, handshakeDelay time.Duration) (string, *atomic.Int32) {
t.Helper()
return startFakeSSHServerWithDelays(t, handshakeDelay, 0)
}
func startFakeSSHServerWithDelays(t *testing.T, handshakeDelay, keepaliveDelay time.Duration) (string, *atomic.Int32) {
t.Helper()
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
signer, err := ssh.NewSignerFromKey(privateKey)
if err != nil {
t.Fatal(err)
}
config := &ssh.ServerConfig{
PasswordCallback: func(ssh.ConnMetadata, []byte) (*ssh.Permissions, error) {
return nil, nil
},
}
config.AddHostKey(signer)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = listener.Close() })
var connectionCount atomic.Int32
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go serveFakeSSHConn(conn, config, &connectionCount, handshakeDelay, keepaliveDelay)
}
}()
return listener.Addr().String(), &connectionCount
}
func serveFakeSSHConn(
conn net.Conn,
config *ssh.ServerConfig,
connectionCount *atomic.Int32,
handshakeDelay, keepaliveDelay time.Duration,
) {
if handshakeDelay > 0 {
time.Sleep(handshakeDelay)
}
connectionCount.Add(1)
serverConn, channels, requests, err := ssh.NewServerConn(conn, config)
if err != nil {
_ = conn.Close()
return
}
defer serverConn.Close()
go func() {
for request := range requests {
if request.Type == "keepalive@openssh.com" && keepaliveDelay > 0 {
time.Sleep(keepaliveDelay)
}
if request.WantReply {
_ = request.Reply(true, nil)
}
}
}()
for newChannel := range channels {
if newChannel.ChannelType() != "direct-tcpip" {
_ = newChannel.Reject(ssh.UnknownChannelType, "unsupported")
continue
}
var payload directTCPIPPayload
if err := ssh.Unmarshal(newChannel.ExtraData(), &payload); err != nil {
_ = newChannel.Reject(ssh.ConnectionFailed, "invalid direct-tcpip payload")
continue
}
target := net.JoinHostPort(payload.DestAddr, strconv.Itoa(int(payload.DestPort)))
remote, err := net.DialTimeout("tcp", target, 2*time.Second)
if err != nil {
_ = newChannel.Reject(ssh.ConnectionFailed, err.Error())
continue
}
channel, channelRequests, err := newChannel.Accept()
if err != nil {
_ = remote.Close()
continue
}
go ssh.DiscardRequests(channelRequests)
go func() {
_, _ = io.Copy(remote, channel)
_ = remote.Close()
}()
go func() {
_, _ = io.Copy(channel, remote)
_ = channel.Close()
}()
}
}
+16 -2
View File
@@ -24,8 +24,8 @@ func TestAgentTunnelBindsLoopbackAndReleasesPort(t *testing.T) {
t.Fatal("隧道启动后本地端口应已被占用") t.Fatal("隧道启动后本地端口应已被占用")
} }
state, _ := tunnel.State() state, _ := tunnel.State()
if state != "connected" && state != "reconnecting" { if state != "connecting" && state != "connected" && state != "reconnecting" {
t.Fatalf("state=%q, want connected 或 reconnecting", state) t.Fatalf("state=%q, want connecting、connected 或 reconnecting", state)
} }
tunnel.Stop() tunnel.Stop()
if listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)); err != nil { if listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)); err != nil {
@@ -51,3 +51,17 @@ func TestNextTunnelBackoff(t *testing.T) {
} }
} }
} }
func TestAgentTunnelStopClosesActiveConnections(t *testing.T) {
tunnel := newAgentTunnel(context.Background(), 1, 0, SSHInstaller{})
server, client := net.Pipe()
defer client.Close()
if !tunnel.trackConn(server) {
t.Fatal("隧道运行时应能登记活动连接")
}
tunnel.Stop()
_ = client.SetReadDeadline(time.Now().Add(time.Second))
if _, err := client.Read(make([]byte, 1)); err == nil {
t.Fatal("隧道停止后活动连接应被关闭")
}
}
Binary file not shown.
Binary file not shown.
+26
View File
@@ -1,6 +1,7 @@
package frp package frp
import ( import (
"encoding/json"
"strings" "strings"
"testing" "testing"
) )
@@ -62,4 +63,29 @@ func TestRenderNATTraversalOnlyWhenDisabledAssistedAddrs(t *testing.T) {
} }
} }
// 旧数据(config_json = "{}")缺省布尔键时必须补回表单默认值,
// 否则会生成 transport.tcpMux = false,让每条新连接都重建 TCP+TLS。
func TestServerOptionsBooleanDefaultsWhenKeysMissing(t *testing.T) {
var legacy ServerOptions
if err := json.Unmarshal([]byte("{}"), &legacy); err != nil {
t.Fatalf("解析旧版空配置失败:%v", err)
}
legacy = normalizeServerOptions(legacy)
if !legacy.TCPMux || !legacy.LoginFailExit {
t.Fatalf("缺省键应补默认值 true,实际 tcpMux=%v loginFailExit=%v", legacy.TCPMux, legacy.LoginFailExit)
}
content := renderTOML(renderedConfig{Server: Server{ServerAddr: "vps.example.com", ServerPort: 7000, Options: legacy}})
if !strings.Contains(content, "transport.tcpMux = true") {
t.Fatalf("旧版配置应渲染 transport.tcpMux = true:\n%s", content)
}
var explicit ServerOptions
if err := json.Unmarshal([]byte(`{"tcp_mux":false,"login_fail_exit":false}`), &explicit); err != nil {
t.Fatalf("解析显式配置失败:%v", err)
}
if explicit.TCPMux || explicit.LoginFailExit {
t.Fatalf("显式 false 不应被默认值覆盖,实际 tcpMux=%v loginFailExit=%v", explicit.TCPMux, explicit.LoginFailExit)
}
}
func intPtr(value int) *int { return &value } func intPtr(value int) *int { return &value }
+3
View File
@@ -65,6 +65,9 @@ func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (str
if httpsPort <= 0 { if httpsPort <= 0 {
httpsPort = 8443 httpsPort = 8443
} }
if httpPort == 80 || httpsPort == 443 {
return "", fmt.Errorf("frps vhost 回源端口不能使用 80/443(通常由 VPS 上的 Nginx 占用);请在服务端表单改为 8080/8443 后重新生成配置")
}
b.WriteString(fmt.Sprintf("vhostHTTPPort = %d\n", httpPort)) b.WriteString(fmt.Sprintf("vhostHTTPPort = %d\n", httpPort))
b.WriteString(fmt.Sprintf("vhostHTTPSPort = %d\n", httpsPort)) b.WriteString(fmt.Sprintf("vhostHTTPSPort = %d\n", httpsPort))
} }
+8
View File
@@ -102,3 +102,11 @@ func (s *Service) waitForManagers(ctx context.Context, action func(*Manager) err
} }
return nil return nil
} }
func sshSettingsChanged(existing, updated Server, secretUpdated bool) bool {
return existing.SSHHost != updated.SSHHost ||
existing.SSHPort != updated.SSHPort ||
existing.SSHUser != updated.SSHUser ||
existing.SSHAuth != updated.SSHAuth ||
secretUpdated
}
+14
View File
@@ -33,3 +33,17 @@ func TestSummarizeRouteHealthEmpty(t *testing.T) {
t.Fatalf("expected no failed servers, got %+v", summary.FailedServers) t.Fatalf("expected no failed servers, got %+v", summary.FailedServers)
} }
} }
func TestTunnelMismatchDetail(t *testing.T) {
empty := tunnelMismatchDetail(nil, "5211.36800.cc")
wantEmpty := "frps 当前没有注册任何代理;请确认 frpc 已启动并成功登录,域名 5211.36800.cc 尚未绑定"
if empty != wantEmpty {
t.Fatalf("空代理列表提示不正确,got %q", empty)
}
bound := tunnelMismatchDetail([]string{"nas-web"}, "5211.36800.cc")
wantBound := "frps 已注册代理: nas-web;但均未绑定域名 5211.36800.cc"
if bound != wantBound {
t.Fatalf("代理未绑定域名提示不正确,got %q", bound)
}
}
+212 -18
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -416,6 +417,17 @@ func (s *Service) StartServer(ctx context.Context, id int64) (Server, error) {
if err != nil { if err != nil {
return Server{}, err return Server{}, err
} }
hasProxies, err := s.hasEnabledProxies(ctx, id)
if err != nil {
return Server{}, err
}
if !hasProxies {
// 空服务端没有 frpc 需要运行,保持 stopped,避免后续监控把它误判为异常退出。
s.setServerRuntime(ctx, id, "stopped", "")
s.enrichTLSStatus(&server)
s.enrichServerRuntime(ctx, &server)
return server, nil
}
s.setServerRuntime(ctx, id, "running", "") s.setServerRuntime(ctx, id, "running", "")
s.enrichTLSStatus(&server) s.enrichTLSStatus(&server)
s.enrichServerRuntime(ctx, &server) s.enrichServerRuntime(ctx, &server)
@@ -638,6 +650,14 @@ func (s *Service) UpdateServer(ctx context.Context, id int64, in ServerInput) (S
if !server.Enabled { if !server.Enabled {
_ = s.managerForServer(id).Stop(ctx) _ = s.managerForServer(id).Stop(ctx)
} }
sshChanged := sshSettingsChanged(existing, server, sshSecretUpdate)
if sshChanged {
// SSH 目标或凭据变化后必须废弃旧隧道,避免新服务端继续复用旧 VPS 的连接。
s.stopAgentTunnel(id)
}
if sshChanged || agentTokenUpdate || existing.AgentURL != server.AgentURL {
s.invalidateAgentClient(id)
}
if !server.MgmtEnabled || server.AgentTransport != "tunnel" { if !server.MgmtEnabled || server.AgentTransport != "tunnel" {
s.stopAgentTunnel(id) s.stopAgentTunnel(id)
} }
@@ -654,6 +674,7 @@ func (s *Service) DeleteServer(ctx context.Context, id int64) error {
} }
_ = os.Remove(s.serverConfigPath(id)) _ = os.Remove(s.serverConfigPath(id))
_ = os.Remove(s.serverLogPath(id)) _ = os.Remove(s.serverLogPath(id))
_ = os.Remove(s.cfg.SSHKnownHostsPathForServer(id))
s.removeManager(id) s.removeManager(id)
return s.persistConfig(ctx) return s.persistConfig(ctx)
} }
@@ -715,7 +736,7 @@ func (s *Service) sshInstaller(ctx context.Context, serverID int64) (Server, SSH
return Server{}, SSHInstaller{}, err return Server{}, SSHInstaller{}, err
} }
} }
installer := SSHInstaller{Host: server.SSHHost, Port: server.SSHPort, User: server.SSHUser, KnownHostsPath: s.cfg.SSHKnownHostsPath()} installer := SSHInstaller{Host: server.SSHHost, Port: server.SSHPort, User: server.SSHUser, KnownHostsPath: s.cfg.SSHKnownHostsPathForServer(serverID)}
if server.SSHAuth == "password" { if server.SSHAuth == "password" {
installer.Password = secret installer.Password = secret
} else { } else {
@@ -733,6 +754,20 @@ func (s *Service) AgentProbe(ctx context.Context, serverID int64) (map[string]an
} }
func (s *Service) AgentInstall(ctx context.Context, serverID int64) (Server, error) { func (s *Service) AgentInstall(ctx context.Context, serverID int64) (Server, error) {
return s.agentInstall(ctx, serverID, false)
}
// AgentInstallTrustHostKey 在用户确认 VPS 已重装系统后,先更新该服务端的 SSH 主机密钥记录,再安装 Agent。
func (s *Service) AgentInstallTrustHostKey(ctx context.Context, serverID int64) (Server, error) {
return s.agentInstall(ctx, serverID, true)
}
func (s *Service) agentInstall(ctx context.Context, serverID int64, trustHostKey bool) (Server, error) {
if trustHostKey {
if err := s.resetAgentSSHKnownHost(ctx, serverID); err != nil {
return Server{}, err
}
}
server, installer, err := s.sshInstaller(ctx, serverID) server, installer, err := s.sshInstaller(ctx, serverID)
if err != nil { if err != nil {
return Server{}, err return Server{}, err
@@ -745,9 +780,17 @@ func (s *Service) AgentInstall(ctx context.Context, serverID int64) (Server, err
if err != nil { if err != nil {
return Server{}, err return Server{}, err
} }
// 已是 HTTPS 直连时,升级 Agent 不应把用户的传输方式改回隧道。 // 已是 HTTPS 直连时优先保持原传输方式;服务器重置或证书丢失时自动回退隧道,
// 避免 SSH 安装已经成功,却因为旧证书指纹失效而把整个升级判定为失败。
if server.AgentTransport == "https-pin" { if server.AgentTransport == "https-pin" {
return s.keepAgentHTTPSAfterInstall(ctx, server, token, agentToken) updated, keepErr := s.keepAgentHTTPSAfterInstall(ctx, server, token, agentToken)
if keepErr == nil {
return updated, nil
}
s.logger.Warn("HTTPS 直连验证失败,自动回退 SSH 隧道",
"server_id", serverID,
"error", keepErr.Error(),
)
} }
localPort, listener, err := s.reserveAgentTunnelPort(ctx, serverID, server.AgentLocalPort) localPort, listener, err := s.reserveAgentTunnelPort(ctx, serverID, server.AgentLocalPort)
if err != nil { if err != nil {
@@ -796,6 +839,23 @@ func (s *Service) AgentInstall(ctx context.Context, serverID int64) (Server, err
return updated, nil return updated, nil
} }
func (s *Service) resetAgentSSHKnownHost(ctx context.Context, serverID int64) error {
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return err
}
if strings.TrimSpace(server.SSHHost) == "" || strings.TrimSpace(server.SSHUser) == "" {
return fmt.Errorf("请先填写 SSH 地址与用户")
}
s.stopAgentTunnel(serverID)
s.invalidateAgentClient(serverID)
path := s.cfg.SSHKnownHostsPathForServer(serverID)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("重置 SSH 主机密钥记录失败: %w", err)
}
return nil
}
// keepAgentHTTPSAfterInstall 在 HTTPS 直连模式下完成 Agent 升级: // keepAgentHTTPSAfterInstall 在 HTTPS 直连模式下完成 Agent 升级:
// 保持 agent_url、pin 与 transport 不变,只更新 Token 密文并重新验证连通。 // 保持 agent_url、pin 与 transport 不变,只更新 Token 密文并重新验证连通。
func (s *Service) keepAgentHTTPSAfterInstall(ctx context.Context, server Server, token, agentToken string) (Server, error) { func (s *Service) keepAgentHTTPSAfterInstall(ctx context.Context, server Server, token, agentToken string) (Server, error) {
@@ -1264,16 +1324,26 @@ func (s *Service) SwitchAgentToTunnel(ctx context.Context, serverID int64) (Serv
return Server{}, err return Server{}, err
} }
if server.AgentTransport == "https-pin" { if server.AgentTransport == "https-pin" {
tunnel, err := s.ensureAgentTunnel(ctx, serverID)
if err != nil {
return Server{}, fmt.Errorf("建立 SSH 隧道失败: %w", err)
}
token, err := s.agentToken(ctx, serverID) token, err := s.agentToken(ctx, serverID)
if err != nil { if err != nil {
return Server{}, err return Server{}, err
} }
client := NewAgentClientWithPin(agentHTTPSBaseURL(server), token, server.AgentTLSPin) // 切换时不再依赖旧 HTTPS 直连;先通过隧道验证 Agent,再清理旧 HTTPS 配置。
if err := client.DisableHTTPS(ctx); err != nil { client := NewAgentClient(fmt.Sprintf("http://127.0.0.1:%d", tunnel.localPort), token)
return Server{}, fmt.Errorf("关闭 HTTPS 直连失败: %w", err) if err := client.Ping(ctx); err != nil {
return Server{}, fmt.Errorf("SSH 隧道已建立,但 Agent 验证失败: %w", err)
} }
} if err := client.DisableHTTPS(ctx); err != nil {
if _, err := s.ensureAgentTunnel(ctx, serverID); err != nil { s.logger.Warn("关闭旧 HTTPS 直连失败,已保留 SSH 隧道",
"server_id", serverID,
"error", err.Error(),
)
}
} else if _, err := s.ensureAgentTunnel(ctx, serverID); err != nil {
return Server{}, err return Server{}, err
} }
updated, err := s.store.GetServer(ctx, serverID) updated, err := s.store.GetServer(ctx, serverID)
@@ -1319,11 +1389,64 @@ func (s *Service) AgentClient(ctx context.Context, serverID int64) (*AgentClient
} }
func (s *Service) AgentStatus(ctx context.Context, serverID int64) (map[string]any, error) { func (s *Service) AgentStatus(ctx context.Context, serverID int64) (map[string]any, error) {
client, ok := s.agentClient(ctx, serverID) statusCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
if !ok { defer cancel()
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存") server, err := s.store.GetServer(statusCtx, serverID)
if err != nil {
return nil, err
} }
return client.Status(ctx) if !server.AgentConfigured {
return map[string]any{
"configured": false,
"mgmt_enabled": false,
"available": false,
"message": "公网 agent 未配置或 Token 未保存",
}, nil
}
if !server.MgmtEnabled {
return map[string]any{
"configured": true,
"mgmt_enabled": false,
"available": false,
"message": "Agent 已配置,但未启用日常管理",
}, nil
}
if server.AgentTransport == "tunnel" {
if state, lastError, ok := s.tunnelState(serverID); ok && (state == "failed" || state == "reconnecting") {
if strings.TrimSpace(lastError) == "" {
lastError = state
}
return nil, fmt.Errorf("SSH 隧道未连接:%s", lastError)
}
}
client, ok := s.agentClient(statusCtx, serverID)
if !ok {
return map[string]any{
"configured": false,
"mgmt_enabled": false,
"available": false,
"message": "公网 agent 未配置或 Token 未保存",
}, nil
}
status, err := client.Status(statusCtx)
if err != nil {
if statusErr, ok := err.(*statusError); ok && statusErr.code == 401 {
return map[string]any{
"configured": true,
"mgmt_enabled": true,
"available": false,
"token_mismatch": true,
"message": "Agent Token 不匹配,请点击「升级 Agent」重新同步",
}, nil
}
return nil, err
}
if status == nil {
status = map[string]any{}
}
status["configured"] = true
status["mgmt_enabled"] = true
return status, nil
} }
func (s *Service) InstallFRPSOnAgent(ctx context.Context, serverID int64, version, proxy string) (map[string]any, error) { func (s *Service) InstallFRPSOnAgent(ctx context.Context, serverID int64, version, proxy string) (map[string]any, error) {
@@ -1354,10 +1477,10 @@ func (s *Service) InstallFRPSOnAgent(ctx context.Context, serverID int64, versio
return result, nil return result, nil
} }
func (s *Service) AgentFRPSAction(ctx context.Context, serverID int64, action string) error { func (s *Service) AgentFRPSAction(ctx context.Context, serverID int64, action string) (map[string]any, error) {
client, ok := s.agentClient(ctx, serverID) client, ok := s.agentClient(ctx, serverID)
if !ok { if !ok {
return fmt.Errorf("公网 agent 未配置或 Token 未保存") return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
} }
return client.FRPSAction(ctx, action) return client.FRPSAction(ctx, action)
} }
@@ -1598,7 +1721,7 @@ func (s *Service) RouteHealth(ctx context.Context, serverID int64) ([]RouteHealt
} }
item.DNSOK = matched item.DNSOK = matched
if !matched { if !matched {
item.DNSDetail = "未指向服务端地址" item.DNSDetail = fmt.Sprintf("当前解析 %s;服务端地址 %s", formatIPAddrs(ips), formatIPAddrs(serverIPs))
} }
} }
} }
@@ -1645,12 +1768,13 @@ func (s *Service) RouteHealth(ctx context.Context, serverID int64) ([]RouteHealt
} else { // 诊断透出:dashboard 实际返回的代理名列表(供前端悬停排查) } else { // 诊断透出:dashboard 实际返回的代理名列表(供前端悬停排查)
names := make([]string, 0, len(tunnelNames)) names := make([]string, 0, len(tunnelNames))
for name := range tunnelNames { for name := range tunnelNames {
if !strings.HasPrefix(name, "domain:") { name = strings.TrimSpace(name)
if name != "" && !strings.HasPrefix(name, "domain:") {
names = append(names, name) names = append(names, name)
} }
} }
sort.Strings(names) sort.Strings(names)
item.TunnelDetail = fmt.Sprintf("frps 已注册代理: %s;但均未绑定域名 %s", strings.Join(names, ", "), domain) item.TunnelDetail = tunnelMismatchDetail(names, domain)
} }
} else { } else {
item.TunnelDetail = dashboardDiag item.TunnelDetail = dashboardDiag
@@ -1667,6 +1791,26 @@ func (s *Service) RouteHealth(ctx context.Context, serverID int64) ([]RouteHealt
return out, nil return out, nil
} }
func formatIPAddrs(addrs []net.IPAddr) string {
if len(addrs) == 0 {
return "无"
}
values := make([]string, 0, len(addrs))
for _, addr := range addrs {
values = append(values, addr.IP.String())
}
return strings.Join(values, ", ")
}
// tunnelMismatchDetail 区分「frps 一个代理都没有」和「有代理但没绑定当前域名」,
// 避免空列表被拼成 “frps 已注册代理: ;” 这种误导性提示。
func tunnelMismatchDetail(names []string, domain string) string {
if len(names) == 0 {
return fmt.Sprintf("frps 当前没有注册任何代理;请确认 frpc 已启动并成功登录,域名 %s 尚未绑定", domain)
}
return fmt.Sprintf("frps 已注册代理: %s;但均未绑定域名 %s", strings.Join(names, ", "), domain)
}
// GetProxy 导出单条穿透规则读取(供 handler 校验规则归属与域名)。 // GetProxy 导出单条穿透规则读取(供 handler 校验规则归属与域名)。
func (s *Service) GetProxy(ctx context.Context, id int64) (Proxy, error) { func (s *Service) GetProxy(ctx context.Context, id int64) (Proxy, error) {
return s.store.GetProxy(ctx, id) return s.store.GetProxy(ctx, id)
@@ -1907,7 +2051,7 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient
} }
baseURL := server.AgentURL baseURL := server.AgentURL
var pins []string var pins []string
if server.AgentTransport == "tunnel" { if server.AgentTransport == "tunnel" || (server.AgentTransport == "http" && isLoopbackAgentURL(server.AgentURL)) {
tunnel, err := s.ensureAgentTunnel(ctx, serverID) tunnel, err := s.ensureAgentTunnel(ctx, serverID)
if err != nil { if err != nil {
return nil, false return nil, false
@@ -1952,6 +2096,22 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient
return client, true return client, true
} }
func isLoopbackAgentURL(raw string) bool {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return false
}
host := strings.TrimSpace(parsed.Hostname())
if host == "" {
return false
}
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func (s *Service) syncAgentRoute(ctx context.Context, proxy Proxy) { func (s *Service) syncAgentRoute(ctx context.Context, proxy Proxy) {
if !proxy.Enabled || (proxy.Type != "http" && proxy.Type != "https") || len(proxy.CustomDomains) == 0 { if !proxy.Enabled || (proxy.Type != "http" && proxy.Type != "https") || len(proxy.CustomDomains) == 0 {
return return
@@ -2003,6 +2163,16 @@ func (s *Service) Start(ctx context.Context) error {
var failures []string var failures []string
for _, server := range servers { for _, server := range servers {
manager := s.managerForServer(server.ID) manager := s.managerForServer(server.ID)
hasProxies, proxyErr := s.hasEnabledProxies(ctx, server.ID)
if proxyErr != nil {
failures = append(failures, fmt.Sprintf("服务端 %s 读取规则失败:%v", server.Name, proxyErr))
continue
}
if !hasProxies {
_ = manager.Stop(ctx)
s.recordProcessState(ctx, server.ID, manager, "")
continue
}
if err := manager.Verify(ctx, s.serverConfigPath(server.ID)); err != nil { if err := manager.Verify(ctx, s.serverConfigPath(server.ID)); err != nil {
s.recordProcessState(ctx, server.ID, manager, err.Error()) s.recordProcessState(ctx, server.ID, manager, err.Error())
failures = append(failures, fmt.Sprintf("服务端 %s 校验失败:%v", server.Name, err)) failures = append(failures, fmt.Sprintf("服务端 %s 校验失败:%v", server.Name, err))
@@ -2075,6 +2245,16 @@ func (s *Service) Reload(ctx context.Context) error {
var failures []string var failures []string
for _, server := range servers { for _, server := range servers {
manager := s.managerForServer(server.ID) manager := s.managerForServer(server.ID)
hasProxies, proxyErr := s.hasEnabledProxies(ctx, server.ID)
if proxyErr != nil {
failures = append(failures, fmt.Sprintf("服务端 %s 读取规则失败:%v", server.Name, proxyErr))
continue
}
if !hasProxies {
_ = manager.Stop(ctx)
s.recordProcessState(ctx, server.ID, manager, "")
continue
}
if err := manager.Verify(ctx, s.serverConfigPath(server.ID)); err != nil { if err := manager.Verify(ctx, s.serverConfigPath(server.ID)); err != nil {
s.recordProcessState(ctx, server.ID, manager, err.Error()) s.recordProcessState(ctx, server.ID, manager, err.Error())
failures = append(failures, fmt.Sprintf("服务端 %s 校验失败:%v", server.Name, err)) failures = append(failures, fmt.Sprintf("服务端 %s 校验失败:%v", server.Name, err))
@@ -2213,12 +2393,26 @@ func (s *Service) Status(ctx context.Context) RuntimeStatus {
current.Configured = s.serverConfigured(server) current.Configured = s.serverConfigured(server)
if proxies, proxyErr := s.store.ListEnabledProxies(ctx, server.ID); proxyErr == nil { if proxies, proxyErr := s.store.ListEnabledProxies(ctx, server.ID); proxyErr == nil {
current.EnabledProxies = len(proxies) current.EnabledProxies = len(proxies)
if len(proxies) == 0 {
// 没有启用规则的服务端不应启动 frpc,也不应把它的历史退出错误聚合到全局状态。
current.Status = "stopped"
current.PID = 0
current.LastError = ""
}
} }
statuses = append(statuses, current) statuses = append(statuses, current)
} }
return latestRuntimeStatus(statuses) return latestRuntimeStatus(statuses)
} }
func (s *Service) hasEnabledProxies(ctx context.Context, serverID int64) (bool, error) {
proxies, err := s.store.ListEnabledProxies(ctx, serverID)
if err != nil {
return false, err
}
return len(proxies) > 0, nil
}
// statusWithVersion 取服务端运行状态,并在版本探测失败时按二进制目录名推导版本: // statusWithVersion 取服务端运行状态,并在版本探测失败时按二进制目录名推导版本:
// frpc 进程未运行或 --version 探测失败时,RuntimeStatus.Version 因带 omitempty 会被省略, // frpc 进程未运行或 --version 探测失败时,RuntimeStatus.Version 因带 omitempty 会被省略,
// 前端(FRP 穿透页状态卡、仪表盘)就只能显示「未检测 / 未知」。 // 前端(FRP 穿透页状态卡、仪表盘)就只能显示「未检测 / 未知」。
+42
View File
@@ -62,3 +62,45 @@ func TestSummarizeServerSamplesUsesLatestStatus(t *testing.T) {
t.Fatalf("unexpected last error: %s", detail.LastError) t.Fatalf("unexpected last error: %s", detail.LastError)
} }
} }
func TestSSHSettingsChanged(t *testing.T) {
base := Server{SSHHost: "vps-a.example.com", SSHPort: 22, SSHUser: "root", SSHAuth: "key"}
if sshSettingsChanged(base, base, false) {
t.Fatal("相同 SSH 配置不应判定为变化")
}
cases := []Server{
{SSHHost: "vps-b.example.com", SSHPort: 22, SSHUser: "root", SSHAuth: "key"},
{SSHHost: "vps-a.example.com", SSHPort: 2222, SSHUser: "root", SSHAuth: "key"},
{SSHHost: "vps-a.example.com", SSHPort: 22, SSHUser: "admin", SSHAuth: "key"},
{SSHHost: "vps-a.example.com", SSHPort: 22, SSHUser: "root", SSHAuth: "password"},
}
for _, updated := range cases {
if !sshSettingsChanged(base, updated, false) {
t.Fatalf("SSH 配置变化未识别: %+v", updated)
}
}
if !sshSettingsChanged(base, base, true) {
t.Fatal("SSH 凭据更新应判定为变化")
}
}
func TestIsLoopbackAgentURL(t *testing.T) {
for _, raw := range []string{
"http://127.0.0.1:17700",
"http://localhost:17700",
"https://[::1]:17700",
} {
if !isLoopbackAgentURL(raw) {
t.Fatalf("%q 应识别为回环地址", raw)
}
}
for _, raw := range []string{
"http://38.95.74.87:7700",
"https://agent.example.com",
"",
} {
if isLoopbackAgentURL(raw) {
t.Fatalf("%q 不应识别为回环地址", raw)
}
}
}
+2 -2
View File
@@ -57,8 +57,8 @@ func (i SSHInstaller) hostKeyCallback() (ssh.HostKeyCallback, error) {
if len(keyErr.Want) == 0 { if len(keyErr.Want) == 0 {
return appendKnownHost(path, remote, key) return appendKnownHost(path, remote, key)
} }
return fmt.Errorf("SSH 主机密钥与首次记录不一致(%s 期望 %s,实际 %s);若确认 VPS 重装过系统,请删除 %s 后重连", return fmt.Errorf("SSH 主机密钥与首次记录不一致(%s 期望 %s,实际 %s);若确认 VPS 重装过系统,请重新点击「安装 Agent」并选择「信任并重装」",
remote.String(), ssh.FingerprintSHA256(keyErr.Want[0].Key), ssh.FingerprintSHA256(key), path) remote.String(), ssh.FingerprintSHA256(keyErr.Want[0].Key), ssh.FingerprintSHA256(key))
} }
return fmt.Errorf("SSH 主机密钥校验失败: %w", err) return fmt.Errorf("SSH 主机密钥校验失败: %w", err)
}, nil }, nil
+40 -6
View File
@@ -7,9 +7,15 @@ import (
"io" "io"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
) )
const (
legacyProxyQueryTimeout = 8 * time.Second
legacyProxyConcurrency = 4
)
// frps 管理接口(webServer)返回的代理状态结构,仅取本模块用到的字段。 // frps 管理接口(webServer)返回的代理状态结构,仅取本模块用到的字段。
type frpsProxyStatus struct { type frpsProxyStatus struct {
Name string `json:"name"` Name string `json:"name"`
@@ -118,15 +124,43 @@ func (c *frpsTrafficClient) fetchLegacyProxyByName(ctx context.Context, server S
// fetchLegacyRouteProxies 按候选规则逐条查询旧版 frps,并保留域名到真实代理名的映射。 // fetchLegacyRouteProxies 按候选规则逐条查询旧版 frps,并保留域名到真实代理名的映射。
func (c *frpsTrafficClient) fetchLegacyRouteProxies(ctx context.Context, server Server, password string, names []string) ServerTraffic { func (c *frpsTrafficClient) fetchLegacyRouteProxies(ctx context.Context, server Server, password string, names []string) ServerTraffic {
empty := map[string]ProxyTraffic{} empty := map[string]ProxyTraffic{}
if len(names) == 0 {
return ServerTraffic{ServerID: server.ID, OK: true, Proxies: empty}
}
queryCtx, cancel := context.WithTimeout(ctx, legacyProxyQueryTimeout)
defer cancel()
type queryResult struct {
info legacyProxyInfo
err error
}
results := make(chan queryResult, len(names))
semaphore := make(chan struct{}, legacyProxyConcurrency)
var wait sync.WaitGroup
for _, name := range names {
wait.Add(1)
go func(name string) {
defer wait.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
proxyName := name
if user := strings.TrimSpace(server.Options.User); user != "" && !strings.HasPrefix(proxyName, user+".") {
proxyName = user + "." + proxyName
}
info, _, err := c.fetchLegacyProxyByName(queryCtx, server, password, proxyName)
results <- queryResult{info: info, err: err}
}(name)
}
go func() {
wait.Wait()
close(results)
}()
proxies := make(map[string]ProxyTraffic) proxies := make(map[string]ProxyTraffic)
proxyNamesByDomain := make(map[string]string) proxyNamesByDomain := make(map[string]string)
var lastErr error var lastErr error
for _, name := range names { for result := range results {
proxyName := name info, err := result.info, result.err
if user := strings.TrimSpace(server.Options.User); user != "" && !strings.HasPrefix(proxyName, user+".") {
proxyName = user + "." + proxyName
}
info, _, err := c.fetchLegacyProxyByName(ctx, server, password, proxyName)
if err != nil { if err != nil {
lastErr = err lastErr = err
continue continue
+50
View File
@@ -0,0 +1,50 @@
package frp
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
func TestFetchLegacyRouteProxiesUsesBoundedConcurrency(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
name := strings.TrimPrefix(r.URL.Path, "/api/proxies/")
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"name":%q,"todayTrafficIn":1,"todayTrafficOut":2,"curConns":3}`, name)
}))
defer server.Close()
host, portText, err := net.SplitHostPort(strings.TrimPrefix(server.URL, "http://"))
if err != nil {
t.Fatal(err)
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatal(err)
}
client := newFRPSTrafficClient()
names := []string{"a", "b", "c", "d", "e", "f", "g", "h"}
started := time.Now()
result := client.fetchLegacyRouteProxies(context.Background(), Server{
ID: 1,
Options: ServerOptions{
DashboardAddr: host,
DashboardPort: port,
},
}, "", names)
elapsed := time.Since(started)
if !result.OK || len(result.Proxies) != len(names) {
t.Fatalf("逐条查询结果不完整: ok=%v proxies=%d", result.OK, len(result.Proxies))
}
if elapsed >= time.Second {
t.Fatalf("并发查询耗时 %v,仍接近串行等待", elapsed)
}
}
+35
View File
@@ -1,6 +1,7 @@
package frp package frp
import ( import (
"encoding/json"
"fmt" "fmt"
"net" "net"
"net/url" "net/url"
@@ -23,6 +24,15 @@ func validateServerInput(in ServerInput) error {
if in.Options.DashboardPort != 0 && !validPort(in.Options.DashboardPort) { if in.Options.DashboardPort != 0 && !validPort(in.Options.DashboardPort) {
return fmt.Errorf("frps 管理接口端口无效") return fmt.Errorf("frps 管理接口端口无效")
} }
if in.Options.VhostHTTPPort != 0 && !validPort(in.Options.VhostHTTPPort) {
return fmt.Errorf("frps HTTP 回源端口无效")
}
if in.Options.VhostHTTPSPort != 0 && !validPort(in.Options.VhostHTTPSPort) {
return fmt.Errorf("frps HTTPS 回源端口无效")
}
if in.Options.VhostHTTPPort == 80 || in.Options.VhostHTTPSPort == 443 {
return fmt.Errorf("frps vhost 回源端口不能使用 80/443(通常由 VPS 上的 Nginx 占用);请使用 8080/8443")
}
if !validDashboardAddr(in.Options.DashboardAddr) { if !validDashboardAddr(in.Options.DashboardAddr) {
return fmt.Errorf("frps 管理接口地址无效") return fmt.Errorf("frps 管理接口地址无效")
} }
@@ -271,6 +281,31 @@ func normalizeServerOptions(in ServerOptions) ServerOptions {
return in return in
} }
// ServerOptions 的布尔字段在表单默认值里是 true,但 JSON 缺省时零值是 false。
// 016 迁移给已有服务端补的 config_json 默认值为 "{}",非 UI 路径创建的服务端也可能不带这些键;
// 缺省时若不兜底,生成的 frpc 配置会变成 transport.tcpMux = false(每条新连接都要重新建
// TCP+TLS,首连明显变慢)与 loginFailExit = false(登录失败不再退出,排障信息被吞掉)。
// 因此按「键缺省即取默认值」反序列化;显式写入 false 的配置仍然保留 false。
func (in *ServerOptions) UnmarshalJSON(data []byte) error {
type plain ServerOptions
var decoded plain
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
var keys map[string]json.RawMessage
if err := json.Unmarshal(data, &keys); err != nil {
return err
}
*in = ServerOptions(decoded)
if _, ok := keys["tcp_mux"]; !ok {
in.TCPMux = true
}
if _, ok := keys["login_fail_exit"]; !ok {
in.LoginFailExit = true
}
return nil
}
func validAuthMethod(value string) bool { func validAuthMethod(value string) bool {
return value == "none" || value == "token" || value == "oidc" return value == "none" || value == "token" || value == "oidc"
} }
+34
View File
@@ -41,3 +41,37 @@ func TestValidateProxyInput(t *testing.T) {
}) })
} }
} }
func TestValidateServerInputVhostPorts(t *testing.T) {
base := ServerInput{
Name: "web",
ServerAddr: "203.0.113.10",
ServerPort: 7000,
Options: ServerOptions{
AuthMethod: "token",
LogLevel: "info",
Protocol: "tcp",
},
}
cases := []struct {
name string
http int
https int
wantErr bool
}{
{name: "默认端口", http: 8080, https: 8443},
{name: "HTTP 80 与 Nginx 冲突", http: 80, https: 8443, wantErr: true},
{name: "HTTPS 443 与 Nginx 冲突", http: 8080, https: 443, wantErr: true},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
input := normalizeServerInput(base)
input.Options.VhostHTTPPort = testCase.http
input.Options.VhostHTTPSPort = testCase.https
err := validateServerInput(input)
if (err != nil) != testCase.wantErr {
t.Fatalf("validateServerInput() error = %v, wantErr %v", err, testCase.wantErr)
}
})
}
}
+8 -1
View File
@@ -14,12 +14,15 @@ import (
) )
// Client 通过共享 Unix Socket 调用 havline-updater sidecar。 // Client 通过共享 Unix Socket 调用 havline-updater sidecar。
const tokenHeader = "X-Havline-Updater-Token"
type Client struct { type Client struct {
socketPath string socketPath string
token string
http *http.Client http *http.Client
} }
func NewClient(socketPath string) *Client { func NewClient(socketPath, token string) *Client {
socketPath = strings.TrimSpace(socketPath) socketPath = strings.TrimSpace(socketPath)
transport := &http.Transport{ transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
@@ -29,6 +32,7 @@ func NewClient(socketPath string) *Client {
} }
return &Client{ return &Client{
socketPath: socketPath, socketPath: socketPath,
token: strings.TrimSpace(token),
http: &http.Client{Transport: transport, Timeout: 15 * time.Second}, http: &http.Client{Transport: transport, Timeout: 15 * time.Second},
} }
} }
@@ -76,6 +80,9 @@ func (c *Client) request(ctx context.Context, method, path string, payload any,
if payload != nil { if payload != nil {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
} }
if c.token != "" {
req.Header.Set(tokenHeader, c.token)
}
resp, err := c.http.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
return err return err
+14 -1
View File
@@ -2,6 +2,7 @@ package updater
import ( import (
"context" "context"
"crypto/subtle"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -34,6 +35,7 @@ type Config struct {
ProjectName string ProjectName string
SelfImage string SelfImage string
DockerSocket string DockerSocket string
Token string
BuildTimeout time.Duration BuildTimeout time.Duration
HealthTimeout time.Duration HealthTimeout time.Duration
} }
@@ -87,6 +89,10 @@ func NewServer(cfg Config, logger *slog.Logger) (*Server, error) {
cfg.HostProjectDir = strings.TrimSpace(cfg.HostProjectDir) cfg.HostProjectDir = strings.TrimSpace(cfg.HostProjectDir)
cfg.ProjectName = strings.TrimSpace(cfg.ProjectName) cfg.ProjectName = strings.TrimSpace(cfg.ProjectName)
cfg.SelfImage = strings.TrimSpace(cfg.SelfImage) cfg.SelfImage = strings.TrimSpace(cfg.SelfImage)
cfg.Token = strings.TrimSpace(cfg.Token)
if cfg.Token == "" {
return nil, fmt.Errorf("HAVLINE_UPDATER_TOKEN 未设置,拒绝启动未鉴权的升级服务")
}
if cfg.BuildTimeout <= 0 { if cfg.BuildTimeout <= 0 {
cfg.BuildTimeout = 30 * time.Minute cfg.BuildTimeout = 30 * time.Minute
} }
@@ -126,7 +132,14 @@ func (s *Server) Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /status", s.handleStatus) mux.HandleFunc("GET /status", s.handleStatus)
mux.HandleFunc("POST /apply", s.handleApply) mux.HandleFunc("POST /apply", s.handleApply)
return mux return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
provided := strings.TrimSpace(r.Header.Get(tokenHeader))
if len(provided) != len(s.cfg.Token) || subtle.ConstantTimeCompare([]byte(provided), []byte(s.cfg.Token)) != 1 {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "updater 鉴权失败"})
return
}
mux.ServeHTTP(w, r)
})
} }
// Serve 在共享 Unix Socket 上提供服务。 // Serve 在共享 Unix Socket 上提供服务。
+40 -3
View File
@@ -2,6 +2,8 @@ package updater
import ( import (
"context" "context"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -29,6 +31,7 @@ func TestNewServerDefaultsAndValidation(t *testing.T) {
server, err := NewServer(Config{ server, err := NewServer(Config{
ProjectDir: t.TempDir(), ProjectDir: t.TempDir(),
ComposeFile: "docker-compose.yml", ComposeFile: "docker-compose.yml",
Token: "test-token",
}, nil) }, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -36,17 +39,17 @@ func TestNewServerDefaultsAndValidation(t *testing.T) {
if !server.state.Enabled || server.cfg.Mode != "local" { if !server.state.Enabled || server.cfg.Mode != "local" {
t.Fatalf("默认状态不正确: %#v", server.state) t.Fatalf("默认状态不正确: %#v", server.state)
} }
if _, err := NewServer(Config{ProjectDir: t.TempDir(), Mode: "invalid"}, nil); err == nil { if _, err := NewServer(Config{ProjectDir: t.TempDir(), Mode: "invalid", Token: "test-token"}, nil); err == nil {
t.Fatal("非法 mode 应被拒绝") t.Fatal("非法 mode 应被拒绝")
} }
if _, err := NewServer(Config{ProjectDir: t.TempDir(), Mode: "git"}, nil); err == nil { if _, err := NewServer(Config{ProjectDir: t.TempDir(), Mode: "git", Token: "test-token"}, nil); err == nil {
t.Fatal("git 模式缺少 RepoURL 应被拒绝") t.Fatal("git 模式缺少 RepoURL 应被拒绝")
} }
} }
func TestEnsureComposeFile(t *testing.T) { func TestEnsureComposeFile(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
server, err := NewServer(Config{ProjectDir: dir, ComposeFile: "docker-compose.yml", ProjectName: "havline"}, nil) server, err := NewServer(Config{ProjectDir: dir, ComposeFile: "docker-compose.yml", ProjectName: "havline", Token: "test-token"}, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -70,6 +73,7 @@ func TestResolveComposePlanUsesHostProjectDir(t *testing.T) {
ComposeFile: "docker-compose.hub.yml", ComposeFile: "docker-compose.hub.yml",
HostProjectDir: hostDir, HostProjectDir: hostDir,
ProjectName: "havline", ProjectName: "havline",
Token: "test-token",
}, nil) }, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -86,6 +90,39 @@ func TestResolveComposePlanUsesHostProjectDir(t *testing.T) {
} }
} }
func TestNewServerRequiresToken(t *testing.T) {
if _, err := NewServer(Config{ProjectDir: t.TempDir()}, nil); err == nil {
t.Fatal("缺少 Token 时应拒绝启动")
}
}
func TestHandlerRequiresToken(t *testing.T) {
server, err := NewServer(Config{ProjectDir: t.TempDir(), Token: "test-token"}, nil)
if err != nil {
t.Fatal(err)
}
for _, token := range []string{"", "wrong-token"} {
req := httptest.NewRequest(http.MethodGet, "http://unix/status", nil)
if token != "" {
req.Header.Set(tokenHeader, token)
}
res := httptest.NewRecorder()
server.Handler().ServeHTTP(res, req)
if res.Code != http.StatusUnauthorized {
t.Fatalf("Token %q 应返回 401,实际为 %d", token, res.Code)
}
}
req := httptest.NewRequest(http.MethodGet, "http://unix/status", nil)
req.Header.Set(tokenHeader, "test-token")
res := httptest.NewRecorder()
server.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("正确 Token 应返回 200,实际为 %d", res.Code)
}
}
func TestIsContainerPath(t *testing.T) { func TestIsContainerPath(t *testing.T) {
cases := []struct { cases := []struct {
path string path string
+112
View File
@@ -0,0 +1,112 @@
package updater
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// DefaultTokenPath 是主服务与 updater sidecar 共享 Token 的默认位置。
const DefaultTokenPath = "/run/havline-updater/updater.token"
const tokenWaitInterval = 200 * time.Millisecond
// ResolveToken 解析 updater Token:显式环境变量优先,其次读取共享文件。
// 文件不存在时返回空 Token,不视为错误,便于不含 updater 的部署直接启动。
func ResolveToken(token, tokenFile string) (string, string, error) {
if value := strings.TrimSpace(token); value != "" {
return value, "环境变量", nil
}
path := strings.TrimSpace(tokenFile)
if path == "" {
return "", "", nil
}
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", "", nil
}
return "", "", fmt.Errorf("读取 updater Token 文件失败: %w", err)
}
value := strings.TrimSpace(string(data))
if value == "" {
return "", "", fmt.Errorf("updater Token 文件为空: %s", path)
}
return value, path, nil
}
// EnsureToken 确保共享 Token 文件存在。环境变量优先;未配置时由主程序生成
// 并原子写入共享卷,updater 随后读取同一文件。
func EnsureToken(token, tokenFile string) (string, string, error) {
if value := strings.TrimSpace(token); value != "" {
return value, "环境变量", nil
}
path := strings.TrimSpace(tokenFile)
if path == "" {
return "", "", nil
}
if data, err := os.ReadFile(path); err == nil {
if value := strings.TrimSpace(string(data)); value != "" {
return value, path, nil
}
} else if !errors.Is(err, os.ErrNotExist) {
return "", "", fmt.Errorf("读取 updater Token 文件失败: %w", err)
}
value, err := randomToken()
if err != nil {
return "", "", err
}
if err := writeTokenFile(path, value); err != nil {
return "", "", err
}
return value, path, nil
}
// WaitForToken 等待主程序在共享卷中创建 Token 文件;环境变量已设置时立即返回。
func WaitForToken(token, tokenFile string, timeout time.Duration) (string, string, error) {
if timeout <= 0 {
timeout = 60 * time.Second
}
deadline := time.Now().Add(timeout)
for {
value, source, err := ResolveToken(token, tokenFile)
if err != nil {
return "", "", err
}
if value != "" {
return value, source, nil
}
if time.Now().After(deadline) {
return "", "", fmt.Errorf("等待 updater Token 文件超时: %s", tokenFile)
}
time.Sleep(tokenWaitInterval)
}
}
func randomToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("生成 updater Token 失败: %w", err)
}
return hex.EncodeToString(buf), nil
}
func writeTokenFile(path, token string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("创建 updater Token 目录失败: %w", err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, []byte(token+"\n"), 0o600); err != nil {
return fmt.Errorf("写入 updater Token 文件失败: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("保存 updater Token 文件失败: %w", err)
}
return os.Chmod(path, 0o600)
}
+107
View File
@@ -0,0 +1,107 @@
package updater
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestResolveTokenPrefersEnvironment(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "updater.token")
if err := os.WriteFile(path, []byte("file-token\n"), 0o600); err != nil {
t.Fatal(err)
}
token, source, err := ResolveToken("env-token", path)
if err != nil {
t.Fatal(err)
}
if token != "env-token" || source != "环境变量" {
t.Fatalf("环境变量应优先,实际 token=%q source=%q", token, source)
}
}
func TestResolveTokenFromFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "updater.token")
if err := os.WriteFile(path, []byte(" file-token \n"), 0o600); err != nil {
t.Fatal(err)
}
token, source, err := ResolveToken("", path)
if err != nil {
t.Fatal(err)
}
if token != "file-token" || source != path {
t.Fatalf("文件 Token 解析不正确,实际 token=%q source=%q", token, source)
}
}
func TestResolveTokenMissingFile(t *testing.T) {
token, source, err := ResolveToken("", filepath.Join(t.TempDir(), "missing.token"))
if err != nil {
t.Fatal(err)
}
if token != "" || source != "" {
t.Fatalf("文件不存在时应返回空 Token,实际 token=%q source=%q", token, source)
}
}
func TestResolveTokenEmptyFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "updater.token")
if err := os.WriteFile(path, []byte("\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := ResolveToken("", path); err == nil {
t.Fatal("空 Token 文件应返回错误")
}
}
func TestEnsureTokenCreatesAndReusesFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "updater.token")
token, source, err := EnsureToken("", path)
if err != nil {
t.Fatal(err)
}
if len(token) != 64 || source != path {
t.Fatalf("生成的 Token 不正确,实际 token=%q source=%q", token, source)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(data) != token+"\n" {
t.Fatalf("Token 文件内容不正确: %q", string(data))
}
again, _, err := EnsureToken("", path)
if err != nil {
t.Fatal(err)
}
if again != token {
t.Fatalf("重复调用应复用文件 Token,实际 %q", again)
}
}
func TestWaitForTokenReadsExistingFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "updater.token")
if err := os.WriteFile(path, []byte("file-token\n"), 0o600); err != nil {
t.Fatal(err)
}
token, _, err := WaitForToken("", path, time.Second)
if err != nil {
t.Fatal(err)
}
if token != "file-token" {
t.Fatalf("等待 Token 结果不正确: %q", token)
}
}
func TestWaitForTokenTimesOut(t *testing.T) {
path := filepath.Join(t.TempDir(), "missing.token")
if _, _, err := WaitForToken("", path, 50*time.Millisecond); err == nil {
t.Fatal("文件不存在时应等待超时")
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "havline-web", "name": "havline-web",
"private": true, "private": true,
"version": "1.9.0", "version": "1.9.2",
"packageManager": "npm@10.9.4", "packageManager": "npm@10.9.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
+6
View File
@@ -0,0 +1,6 @@
export const APP_BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
export function withBase(path: string): string {
if (!path.startsWith('/')) return path
return `${APP_BASE}${path}`
}
+30 -35
View File
@@ -9,10 +9,6 @@ import type {
DDNSConfig, DDNSConfig,
DDNSSavePayload, DDNSSavePayload,
DDNSTestPayload, DDNSTestPayload,
FRPResponse,
FRPSavePayload,
FRPSaveResponse,
FRPStatus,
FrpBinary, FrpBinary,
FrpDownloadTask, FrpDownloadTask,
FrpProxy, FrpProxy,
@@ -51,6 +47,7 @@ import type {
CfPreflightResult, CfPreflightResult,
CfRouteTestResult, CfRouteTestResult,
} from './types' } from './types'
import { withBase } from './base'
export function asList<T>(value: T[] | null | undefined): T[] { export function asList<T>(value: T[] | null | undefined): T[] {
return Array.isArray(value) ? value : [] return Array.isArray(value) ? value : []
@@ -58,6 +55,7 @@ export function asList<T>(value: T[] | null | undefined): T[] {
const REQUEST_TIMEOUT_MS = 30_000 const REQUEST_TIMEOUT_MS = 30_000
const CERT_REQUEST_TIMEOUT_MS = 10 * 60_000 const CERT_REQUEST_TIMEOUT_MS = 10 * 60_000
const AGENT_OPERATION_TIMEOUT_MS = 5 * 60_000
async function request<T>(path: string, init?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> { async function request<T>(path: string, init?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
const controller = new AbortController() const controller = new AbortController()
@@ -79,7 +77,7 @@ async function request<T>(path: string, init?: RequestInit, timeoutMs = REQUEST_
} }
async function requestWithSignal<T>(path: string, init?: RequestInit, signal?: AbortSignal): Promise<T> { async function requestWithSignal<T>(path: string, init?: RequestInit, signal?: AbortSignal): Promise<T> {
const response = await fetch(path, { const response = await fetch(withBase(path), {
credentials: 'include', credentials: 'include',
signal, signal,
headers: { headers: {
@@ -179,7 +177,7 @@ export const api = {
email: payload.email ?? '', email: payload.email ?? '',
}), }),
}), }),
certificateApplyStreamURL: (jobId: string) => `/api/certificates/jobs/${encodeURIComponent(jobId)}/stream`, certificateApplyStreamURL: (jobId: string) => withBase(`/api/certificates/jobs/${encodeURIComponent(jobId)}/stream`),
importCertificate: (payload: { importCertificate: (payload: {
certificate?: string certificate?: string
private_key?: string private_key?: string
@@ -192,7 +190,7 @@ export const api = {
}), }),
async downloadCertificate(domain: string, part: 'zip' | 'cert' | 'key' = 'zip') { async downloadCertificate(domain: string, part: 'zip' | 'cert' | 'key' = 'zip') {
const response = await fetch( const response = await fetch(
`/api/certificates/${encodeURIComponent(domain)}/download?part=${part}`, withBase(`/api/certificates/${encodeURIComponent(domain)}/download?part=${part}`),
{ credentials: 'include' }, { credentials: 'include' },
) )
if (!response.ok) { if (!response.ok) {
@@ -265,19 +263,6 @@ export const api = {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}), }),
getFRP: () => request<FRPResponse>('/api/frp'),
saveFRP: (payload: FRPSavePayload) =>
request<FRPSaveResponse>('/api/frp', {
method: 'PUT',
body: JSON.stringify(payload),
}),
getFRPStatus: () => request<FRPStatus>('/api/frp/status'),
syncFRPDomains: () =>
request<FRPSaveResponse & { domains: string[] }>('/api/frp/sync-domains', {
method: 'POST',
}),
getFRPLogs: (limit = 200) => request<string[]>(`/api/frp/logs?limit=${limit}`),
// ===== FRP 多服务端模块(/api/frpmulti)===== // ===== FRP 多服务端模块(/api/frpmulti)=====
getFrpStatus: () => request<FrpStatus>('/api/frpmulti/status'), getFrpStatus: () => request<FrpStatus>('/api/frpmulti/status'),
getFrpRuntime: () => request<FrpRuntimeInfo>('/api/frpmulti/runtime'), getFrpRuntime: () => request<FrpRuntimeInfo>('/api/frpmulti/runtime'),
@@ -324,11 +309,11 @@ export const api = {
request<FrpServer>(`/api/frpmulti/servers/${id}/agent/transport`, { request<FrpServer>(`/api/frpmulti/servers/${id}/agent/transport`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ transport, port }), body: JSON.stringify({ transport, port }),
}), }, AGENT_OPERATION_TIMEOUT_MS),
getFrpAgentTransport: (id: number) => getFrpAgentTransport: (id: number) =>
request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/transport`), request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/transport`),
rotateFrpAgentTLSCert: (id: number) => rotateFrpAgentTLSCert: (id: number) =>
request<FrpServer>(`/api/frpmulti/servers/${id}/agent/transport/rotate`, { method: 'POST' }), request<FrpServer>(`/api/frpmulti/servers/${id}/agent/transport/rotate`, { method: 'POST' }, AGENT_OPERATION_TIMEOUT_MS),
getFrpAgentFirewall: (id: number, port: number) => getFrpAgentFirewall: (id: number, port: number) =>
request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/firewall?port=${port}`), request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/firewall?port=${port}`),
allowFrpAgentFirewallPort: (id: number, port: number) => allowFrpAgentFirewallPort: (id: number, port: number) =>
@@ -336,17 +321,27 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ port }), body: JSON.stringify({ port }),
}), }),
probeFrpAgent: (id: number) => request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/probe`, { method: 'POST' }), probeFrpAgent: (id: number) => request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/probe`, { method: 'POST' }, AGENT_OPERATION_TIMEOUT_MS),
installFrpAgent: (id: number) => request<FrpServer>(`/api/frpmulti/servers/${id}/agent/install`, { method: 'POST' }), installFrpAgent: (id: number, trustNewHostKey = false) =>
uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }), request<FrpServer>(
sshDiagnoseFrpAgent: (id: number) => request<{ output: string }>(`/api/frpmulti/servers/${id}/agent/ssh-diagnose`, { method: 'POST' }), `/api/frpmulti/servers/${id}/agent/install${trustNewHostKey ? '?trust_new_host_key=true' : ''}`,
{ method: 'POST' },
AGENT_OPERATION_TIMEOUT_MS,
),
uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }, AGENT_OPERATION_TIMEOUT_MS),
sshDiagnoseFrpAgent: (id: number) => request<{ output: string }>(`/api/frpmulti/servers/${id}/agent/ssh-diagnose`, { method: 'POST' }, AGENT_OPERATION_TIMEOUT_MS),
getFrpAgentStatus: (id: number) => request<import('./types').FrpAgentStatus>(`/api/frpmulti/servers/${id}/agent/status`), getFrpAgentStatus: (id: number) => request<import('./types').FrpAgentStatus>(`/api/frpmulti/servers/${id}/agent/status`),
getFrpAgentCerts: (id: number) => getFrpAgentCerts: (id: number) =>
request<import('./types').FrpAgentCerts>(`/api/frpmulti/servers/${id}/agent/certs`), request<import('./types').FrpAgentCerts>(`/api/frpmulti/servers/${id}/agent/certs`),
reloadFrpAgentNginx: (id: number) => reloadFrpAgentNginx: (id: number) =>
request<import('./types').FrpAgentNginxReload>(`/api/frpmulti/servers/${id}/agent/nginx/reload`, { request<import('./types').FrpAgentNginxReload>(`/api/frpmulti/servers/${id}/agent/nginx/reload`, {
method: 'POST', method: 'POST',
}), }, AGENT_OPERATION_TIMEOUT_MS),
actionFrpAgentNginx: (id: number, action: 'start' | 'stop' | 'restart') =>
request<import('./types').FrpAgentNginxAction>(`/api/frpmulti/servers/${id}/agent/nginx/action`, {
method: 'POST',
body: JSON.stringify({ action }),
}, AGENT_OPERATION_TIMEOUT_MS),
getFrpAgentMetrics: (id: number) => getFrpAgentMetrics: (id: number) =>
request<import('./types').FrpAgentMetrics>(`/api/frpmulti/servers/${id}/agent/metrics`), request<import('./types').FrpAgentMetrics>(`/api/frpmulti/servers/${id}/agent/metrics`),
getFrpAgentNginxLogs: (id: number, type: 'access' | 'error', lines = 200) => getFrpAgentNginxLogs: (id: number, type: 'access' | 'error', lines = 200) =>
@@ -378,7 +373,7 @@ export const api = {
{ method: 'POST' }, { method: 'POST' },
), ),
installFrpAgentNginx: (serverId: number) => installFrpAgentNginx: (serverId: number) =>
request<{ ok: boolean; manager?: string; output?: string; path?: string; error?: string }>(`/api/frpmulti/servers/${serverId}/agent/nginx/install`, { method: 'POST' }), request<{ ok: boolean; manager?: string; output?: string; path?: string; error?: string }>(`/api/frpmulti/servers/${serverId}/agent/nginx/install`, { method: 'POST' }, AGENT_OPERATION_TIMEOUT_MS),
getFrpAgentRouteHealth: (serverId: number) => getFrpAgentRouteHealth: (serverId: number) =>
request<import('./types').FrpAgentRouteHealth[]>(`/api/frpmulti/servers/${serverId}/agent/routes/health`), request<import('./types').FrpAgentRouteHealth[]>(`/api/frpmulti/servers/${serverId}/agent/routes/health`),
getFrpRouteHealthSummary: () => getFrpRouteHealthSummary: () =>
@@ -396,13 +391,13 @@ export const api = {
`/api/status-page${code ? `?code=${encodeURIComponent(code)}` : ''}`, `/api/status-page${code ? `?code=${encodeURIComponent(code)}` : ''}`,
), ),
removeFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'DELETE' }), removeFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'DELETE' }),
pushFrpConfigToAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-config`, { method: 'POST' }), pushFrpConfigToAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-config`, { method: 'POST' }, AGENT_OPERATION_TIMEOUT_MS),
frpsServerAction: (id: number, action: 'start' | 'stop' | 'restart') => frpsServerAction: (id: number, action: 'start' | 'stop' | 'restart') =>
request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-action`, { method: 'POST', body: JSON.stringify({ action }) }), request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-action`, { method: 'POST', body: JSON.stringify({ action }) }, AGENT_OPERATION_TIMEOUT_MS),
frpsServerActionRaw: (id: number, action: 'start' | 'stop' | 'restart') => frpsServerActionRaw: (id: number, action: 'start' | 'stop' | 'restart') =>
request<{ ok: boolean; error?: string }>(`/api/frpmulti/servers/${id}/agent/frps-action`, { method: 'POST', body: JSON.stringify({ action }) }), request<{ ok: boolean; error?: string }>(`/api/frpmulti/servers/${id}/agent/frps-action`, { method: 'POST', body: JSON.stringify({ action }) }, AGENT_OPERATION_TIMEOUT_MS),
installFrpServer: (id: number, version: string, proxy: string) => installFrpServer: (id: number, version: string, proxy: string) =>
request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/frps-install`, { method: 'POST', body: JSON.stringify({ version, proxy }) }), request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/frps-install`, { method: 'POST', body: JSON.stringify({ version, proxy }) }, AGENT_OPERATION_TIMEOUT_MS),
getAccessLogs: (params?: { limit?: number; keyword?: string; status?: number }) => { getAccessLogs: (params?: { limit?: number; keyword?: string; status?: number }) => {
const q = new URLSearchParams() const q = new URLSearchParams()
@@ -432,7 +427,7 @@ export const api = {
}, },
async exportBackup() { async exportBackup() {
const response = await fetch('/api/backup/export', { credentials: 'include' }) const response = await fetch(withBase('/api/backup/export'), { credentials: 'include' })
if (!response.ok) { if (!response.ok) {
throw new Error('导出备份失败') throw new Error('导出备份失败')
} }
@@ -454,7 +449,7 @@ export const api = {
deleteBackupArchive: (name: string) => deleteBackupArchive: (name: string) =>
request<{ ok: boolean }>(`/api/backup/archives/${encodeURIComponent(name)}`, { method: 'DELETE' }), request<{ ok: boolean }>(`/api/backup/archives/${encodeURIComponent(name)}`, { method: 'DELETE' }),
async downloadBackupArchive(name: string) { async downloadBackupArchive(name: string) {
const response = await fetch(`/api/backup/archives/${encodeURIComponent(name)}`, { credentials: 'include' }) const response = await fetch(withBase(`/api/backup/archives/${encodeURIComponent(name)}`), { credentials: 'include' })
if (!response.ok) { if (!response.ok) {
throw new Error('下载备份失败') throw new Error('下载备份失败')
} }
@@ -470,7 +465,7 @@ export const api = {
async restoreBackup(file: File) { async restoreBackup(file: File) {
const form = new FormData() const form = new FormData()
form.append('file', file) form.append('file', file)
const response = await fetch('/api/backup/restore', { const response = await fetch(withBase('/api/backup/restore'), {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
body: form, body: form,
+13 -56
View File
@@ -345,62 +345,6 @@ export interface DDNSTestPayload {
api_secret?: string api_secret?: string
} }
export interface FRPStatus {
enabled: boolean
connected: boolean
message: string
last_error?: string
server_endpoint?: string
connected_at?: string
uptime_seconds?: number
client_version?: string
http_gateway_enabled?: boolean
https_gateway_enabled?: boolean
synced_domain_count?: number
}
export interface FRPConfig {
enabled: boolean
server_addr: string
server_port: number
auth_token: string
has_auth_token: boolean
tls_enabled: boolean
custom_domains: string[]
}
export interface FRPResponse {
enabled: boolean
server_addr: string
server_port: number
auth_token: string
has_auth_token: boolean
tls_enabled: boolean
custom_domains: string[]
status: FRPStatus
frps_config: string
nginx_http_port: number
nginx_https_port: number
}
export interface FRPSaveResponse {
message: string
config: FRPConfig
status: FRPStatus
frps_config: string
nginx_http_port: number
nginx_https_port: number
}
export interface FRPSavePayload {
enabled: boolean
server_addr: string
server_port: number
auth_token?: string
tls_enabled: boolean
custom_domains: string[]
}
// ===== FRP 多服务端模块(/api/frpmulti,0.1.25 核心迁移)===== // ===== FRP 多服务端模块(/api/frpmulti,0.1.25 核心迁移)=====
export interface FrpServer { export interface FrpServer {
@@ -796,6 +740,14 @@ export interface FrpAgentNginxReload {
checked_at: string checked_at: string
} }
export interface FrpAgentNginxAction {
ok: boolean
action: string
nginx_running?: boolean
nginx_ok?: boolean
nginx_error?: string
}
export interface FrpAgentMetrics { export interface FrpAgentMetrics {
cpu_percent: number cpu_percent: number
mem_used_bytes: number mem_used_bytes: number
@@ -866,6 +818,11 @@ export interface FrpAgentRouteDetail {
} }
export interface FrpAgentStatus { export interface FrpAgentStatus {
configured?: boolean
mgmt_enabled?: boolean
token_mismatch?: boolean
available?: boolean
message?: string
agent_version?: string agent_version?: string
listen_addr?: string listen_addr?: string
listen_scope?: string listen_scope?: string
-93
View File
@@ -1,93 +0,0 @@
<template>
<VChart class="chart" :option="option" autoresize />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { use } from 'echarts/core'
import { PieChart } from 'echarts/charts'
import { LegendComponent, TitleComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import VChart from 'vue-echarts'
import { useTheme } from '../composables/useTheme'
use([CanvasRenderer, PieChart, LegendComponent, TitleComponent, TooltipComponent])
const props = defineProps<{
segments: { label: string; value: number; color: string }[]
}>()
const { isDark } = useTheme()
const option = computed(() => {
const textColor = isDark.value ? '#94a3b8' : '#6b7280'
const titleColor = isDark.value ? '#f1f5f9' : '#111827'
const total = props.segments.reduce((sum, s) => sum + s.value, 0)
return {
title: [
{
text: String(total),
left: '31%',
top: '38%',
textAlign: 'center',
textStyle: { fontSize: 22, fontWeight: 700, color: titleColor },
},
{
text: '总请求',
left: '31%',
top: '52%',
textAlign: 'center',
textStyle: { fontSize: 12, color: textColor },
},
],
tooltip: {
trigger: 'item',
backgroundColor: isDark.value ? '#1e293b' : '#fff',
borderColor: isDark.value ? '#334155' : '#e5e7eb',
textStyle: { color: isDark.value ? '#f1f5f9' : '#111827', fontSize: 12 },
formatter: '{b}: {c} ({d}%)',
},
legend: {
orient: 'vertical',
right: 0,
top: 'center',
itemWidth: 8,
itemHeight: 8,
itemGap: 12,
textStyle: { color: textColor, fontSize: 13 },
formatter: (name: string) => {
const seg = props.segments.find((s) => s.label === name)
const total = props.segments.reduce((sum, s) => sum + s.value, 0) || 1
const pct = seg ? Math.round((seg.value / total) * 100) : 0
return `${name} ${pct}% (${seg?.value ?? 0})`
},
},
series: [
{
type: 'pie',
radius: ['52%', '72%'],
center: ['32%', '50%'],
avoidLabelOverlap: false,
label: { show: false },
emphasis: {
scale: true,
scaleSize: 4,
},
data: props.segments.map((s) => ({
name: s.label,
value: s.value,
itemStyle: { color: s.color },
})),
},
],
}
})
</script>
<style scoped>
.chart {
width: 100%;
height: 200px;
}
</style>
+3 -1
View File
@@ -1,4 +1,6 @@
export const GITHUB_REPO = 'O96u/Fonu' export const GITHUB_REPO = 'mrlee233/havline'
export const UPSTREAM_REPO = 'O96u/Fonu'
export const UPSTREAM_REPO_URL = `https://github.com/${UPSTREAM_REPO}`
export const GITHUB_REPO_URL = `https://github.com/${GITHUB_REPO}` export const GITHUB_REPO_URL = `https://github.com/${GITHUB_REPO}`
export const GITHUB_REPO_DISPLAY = `github.com/${GITHUB_REPO}` export const GITHUB_REPO_DISPLAY = `github.com/${GITHUB_REPO}`
export const GITHUB_RELEASES_URL = `${GITHUB_REPO_URL}/releases` export const GITHUB_RELEASES_URL = `${GITHUB_REPO_URL}/releases`
+1 -1
View File
@@ -17,7 +17,7 @@ import StatusPageView from '../views/StatusPageView.vue'
import AboutView from '../views/AboutView.vue' import AboutView from '../views/AboutView.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: [
{ path: '/setup', redirect: '/login' }, { path: '/setup', redirect: '/login' },
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } }, { path: '/login', name: 'login', component: LoginView, meta: { public: true } },
+162 -14
View File
@@ -1,5 +1,5 @@
<template> <template>
<PageHeader title="关于项目" description="了解 Havline 的项目定位、来源、能力与开源信息"> <PageHeader title="关于项目" description="查看版本、项目仓库、发布记录与开源信息">
<template #actions> <template #actions>
<n-button :loading="refreshing" @click="refreshVersion"> <n-button :loading="refreshing" @click="refreshVersion">
<template #icon><n-icon :component="RefreshOutline" /></template> <template #icon><n-icon :component="RefreshOutline" /></template>
@@ -15,18 +15,18 @@
<p class="about-kicker">ABOUT HAVLINE</p> <p class="about-kicker">ABOUT HAVLINE</p>
<h2>让每一台 NAS,都能安全地连接世界。</h2> <h2>让每一台 NAS,都能安全地连接世界。</h2>
<p> <p>
Havline 是一款面向飞牛 fnOS 等 NAS 环境的轻量公网访问管理工具。 Havline 面向家庭 NAS 与自托管环境,集中管理服务的公网访问入口。
它把反向代理、DDNS、HTTPS 证书、内网穿透、公网 Agent 与 Cloudflare Tunnel 从域名解析、HTTPS 证书到反向代理、FRP 和 Cloudflare 隧道,
收进一个 Web 控制台,让家庭与小型办公环境中的服务访问更简单、更可控。 将分散的配置与日常运维集中到一处,支持容器化与本地化部署。
</p> </p>
<div class="about-actions"> <div class="about-actions">
<a :href="GITHUB_REPO_URL" target="_blank" rel="noopener noreferrer"> <a :href="GITHUB_REPO_URL" target="_blank" rel="noopener noreferrer">
<n-icon :component="LogoGithub" /> <n-icon :component="LogoGithub" />
查看源项目 查看项目源码
</a> </a>
<a :href="GITHUB_RELEASES_URL" target="_blank" rel="noopener noreferrer"> <a :href="GITHUB_RELEASES_URL" target="_blank" rel="noopener noreferrer">
<n-icon :component="CloudDownloadOutline" /> <n-icon :component="CloudDownloadOutline" />
Release 发布版本
</a> </a>
<a :href="GPL_URL" target="_blank" rel="noopener noreferrer"> <a :href="GPL_URL" target="_blank" rel="noopener noreferrer">
<n-icon :component="DocumentTextOutline" /> <n-icon :component="DocumentTextOutline" />
@@ -42,7 +42,7 @@
<strong class="mono">v{{ version }}</strong> <strong class="mono">v{{ version }}</strong>
</div> </div>
<div class="about-meta-item"> <div class="about-meta-item">
<span>源项目</span> <span>项目仓库</span>
<strong>{{ GITHUB_REPO }}</strong> <strong>{{ GITHUB_REPO }}</strong>
</div> </div>
<div class="about-meta-item"> <div class="about-meta-item">
@@ -56,6 +56,7 @@
</div> </div>
</section> </section>
<div class="about-sections">
<HavlineCard title="项目定位" subtitle="Havline 解决什么问题"> <HavlineCard title="项目定位" subtitle="Havline 解决什么问题">
<div class="about-lead"> <div class="about-lead">
<p> <p>
@@ -75,7 +76,7 @@
</article> </article>
<article class="about-point"> <article class="about-point">
<h3>保留可控性</h3> <h3>保留可控性</h3>
<p>所有配置、凭据和运行数据都保存在自己的数据目录中,不依赖外部托管平台。</p> <p>本地配置与运行数据由自己管理,支持配置回滚和备份恢复;DNS、证书与隧道按所选服务接入。</p>
</article> </article>
</div> </div>
</HavlineCard> </HavlineCard>
@@ -85,13 +86,13 @@
<blockquote>开源不是重新发明轮子,而是站在巨人的肩膀上,把一件事继续做得更好。</blockquote> <blockquote>开源不是重新发明轮子,而是站在巨人的肩膀上,把一件事继续做得更好。</blockquote>
<p> <p>
Havline 是基于开源项目 Havline 是基于开源项目
<a :href="GITHUB_REPO_URL" target="_blank" rel="noopener noreferrer">{{ GITHUB_REPO }}</a> <a :href="UPSTREAM_REPO_URL" target="_blank" rel="noopener noreferrer">{{ UPSTREAM_REPO }}</a>
<strong>1.0.2 版本</strong>进行二次开发的项目。原始项目已经完成了反向代理、DDNS、证书、 <strong>1.0.2 版本</strong>进行二次开发的项目。原始项目已经完成了反向代理、DDNS、证书、
内网穿透等核心能力,为 Havline 提供了坚实的工程基础。 内网穿透等核心能力,为 Havline 提供了坚实的工程基础。
</p> </p>
<p> <p>
我们在原项目的基础上进行了品牌重塑、界面调整、功能迭代、部署流程整理和文档重构。 Havline 在此基础上持续完善多服务端管理、公网 Agent、Cloudflare 隧道和部署兼容性。
无论项目名称如何变化,Havline 都保留对源项目和其贡献者的明确致谢,并继续遵循 当前源码与版本发布由 {{ GITHUB_REPO }} 维护,保留上游来源与贡献者致谢,并继续遵循
<a :href="GPL_URL" target="_blank" rel="noopener noreferrer">GPL-3.0</a> <a :href="GPL_URL" target="_blank" rel="noopener noreferrer">GPL-3.0</a>
开源协议。 开源协议。
</p> </p>
@@ -112,6 +113,85 @@
</div> </div>
</HavlineCard> </HavlineCard>
<HavlineCard title="适用场景" subtitle="根据网络条件选择服务入口">
<div class="about-points">
<article class="about-point">
<h3>有公网访问条件的 NAS</h3>
<p>通过 DDNS 跟随公网 IP,使用 Nginx 反向代理和 HTTPS 证书发布服务。实际访问仍取决于路由器转发、防火墙、运营商限制与 IPv4 / IPv6 连通性。</p>
</article>
<article class="about-point">
<h3>通过公网服务器接入</h3>
<p>使用 FRP 连接自有 VPS,并通过公网 Agent 管理服务器上的 Nginx、frps 和服务路由。VPS、域名与服务器权限由使用者自行准备和管理。</p>
</article>
<article class="about-point">
<h3>不开放本机入站端口</h3>
<p>通过 Cloudflare Tunnel 发布内网 Web 服务,管理隧道、域名路由与 DNS。此方式依赖 Cloudflare 账户、授权和网络连接,适用范围受其服务规则约束。</p>
</article>
</div>
</HavlineCard>
<HavlineCard title="部署与运行" subtitle="一份业务源码,多种交付方式">
<div class="about-detail-grid">
<article class="about-detail-item">
<span class="about-detail-item__eyebrow">Docker</span>
<h3>Compose 管理容器与数据</h3>
<p>适合通用 Linux、NAS 和已有 Docker 环境。数据通过命名卷或绑定目录持久化,配有 updater 的 Compose 部署支持受控升级。</p>
</article>
<article class="about-detail-item">
<span class="about-detail-item__eyebrow">自托管部署</span>
<h3>按环境选择运行方式</h3>
<p>可根据设备与运维习惯选择容器化或本地化部署,数据结构保持一致,便于迁移、备份和恢复。</p>
</article>
<article class="about-detail-item">
<span class="about-detail-item__eyebrow">按需组件</span>
<h3>首次使用时下载</h3>
<p>FRP 客户端和 cloudflared 不预置在基础运行环境中,启用对应功能时需要联网下载并保存到数据目录。</p>
</article>
<article class="about-detail-item">
<span class="about-detail-item__eyebrow">外部依赖</span>
<h3>域名、证书与服务器由用户准备</h3>
<p>DNS、ACME 证书、公网 VPS 和 Cloudflare Tunnel 需要相应账户、凭据或服务器,应用不会自动获得公网 IP 或第三方授权。</p>
</article>
</div>
</HavlineCard>
<HavlineCard title="数据与安全" subtitle="本地管理不等于无需防护">
<div class="about-points about-points--security">
<article class="about-point">
<h3>完整备份,而非只备份数据库</h3>
<p>数据库、会话与凭据加密密钥、证书私钥和运行配置共同组成实例数据。迁移或升级前应备份完整数据目录;使用环境变量提供密钥时,还需安全保留原值。</p>
</article>
<article class="about-point">
<h3>管理入口与服务入口分离</h3>
<p>对外发布业务服务时,不应顺带开放管理后台、Docker Socket 或 Agent 管理端口。按需配置认证、HTTPS、防火墙和访问限制,并定期检查授权范围。</p>
</article>
<article class="about-point">
<h3>保护凭据与备份归档</h3>
<p>DNS Token、服务器凭据和证书私钥属于敏感数据。凭据加密不能替代文件权限与备份保护;已有数据后不要随意更换加密主密钥,以免失去解密能力。</p>
</article>
</div>
</HavlineCard>
<HavlineCard title="项目维护与反馈" subtitle="以当前源码和发布记录为准">
<div class="about-detail-grid about-detail-grid--feedback">
<article class="about-detail-item">
<span class="about-detail-item__eyebrow">源码与版本</span>
<h3>跟随项目仓库发布</h3>
<p>当前源码由 <a :href="GITHUB_REPO_URL" target="_blank" rel="noopener noreferrer">{{ GITHUB_REPO }}</a> 维护,已发布版本与更新说明见 <a :href="GITHUB_RELEASES_URL" target="_blank" rel="noopener noreferrer">发布记录</a>。生产环境请固定版本并保留备份。</p>
</article>
<article class="about-detail-item">
<span class="about-detail-item__eyebrow">问题反馈</span>
<h3>提供可复现的信息</h3>
<p>通过 <a :href="`${GITHUB_REPO_URL}/issues`" target="_blank" rel="noopener noreferrer">GitHub Issues</a> 反馈时,请附版本、部署方式、设备架构、复现步骤和脱敏日志。</p>
</article>
<article class="about-detail-item about-detail-item--wide">
<span class="about-detail-item__eyebrow">开源协作</span>
<h3>保留来源,持续改进</h3>
<p>欢迎提交问题复现、兼容性反馈和代码改进。Havline 保留上游贡献者的版权与来源说明,持续按 GPL-3.0 发布;请勿上传密码、Token、私钥、完整数据库或包含敏感配置的备份归档。</p>
</article>
</div>
</HavlineCard>
<HavlineCard title="技术栈" subtitle="保持简单、透明、易部署"> <HavlineCard title="技术栈" subtitle="保持简单、透明、易部署">
<div class="about-tech"> <div class="about-tech">
<section v-for="group in techGroups" :key="group.title" class="about-tech__group"> <section v-for="group in techGroups" :key="group.title" class="about-tech__group">
@@ -135,8 +215,8 @@
<div class="about-license__item"> <div class="about-license__item">
<n-icon :component="LogoGithub" /> <n-icon :component="LogoGithub" />
<div> <div>
<h3>源项目保留</h3> <h3>上游来源</h3>
<p>源项目地址固定为 {{ GITHUB_REPO }},用于追溯上游来源和许可证义务。</p> <p>基于 {{ UPSTREAM_REPO }} 继续开发,保留原作者版权、来源说明与许可证。</p>
</div> </div>
</div> </div>
<div class="about-license__item"> <div class="about-license__item">
@@ -148,6 +228,7 @@
</div> </div>
</div> </div>
</HavlineCard> </HavlineCard>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -174,6 +255,8 @@ import {
GITHUB_RELEASES_URL, GITHUB_RELEASES_URL,
GITHUB_REPO, GITHUB_REPO,
GITHUB_REPO_URL, GITHUB_REPO_URL,
UPSTREAM_REPO,
UPSTREAM_REPO_URL,
} from '../constants/app' } from '../constants/app'
import { formatDate } from '../utils/format' import { formatDate } from '../utils/format'
@@ -376,6 +459,16 @@ onMounted(() => {
white-space: nowrap; white-space: nowrap;
} }
.about-sections {
display: grid;
gap: var(--havline-space-5);
min-width: 0;
}
.about-sections > :deep(.havline-card) {
min-width: 0;
}
.about-lead { .about-lead {
max-width: 860px; max-width: 860px;
color: var(--havline-text-secondary); color: var(--havline-text-secondary);
@@ -394,6 +487,56 @@ onMounted(() => {
margin-top: var(--havline-space-5); margin-top: var(--havline-space-5);
} }
.about-points--security {
margin-top: 0;
}
.about-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--havline-space-4);
}
.about-detail-item {
min-width: 0;
padding: var(--havline-space-4);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background: var(--havline-bg);
}
.about-detail-item--wide {
grid-column: 1 / -1;
}
.about-detail-item__eyebrow {
display: block;
margin-bottom: var(--havline-space-2);
color: var(--havline-brand-text);
font-family: var(--havline-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.about-detail-item h3 {
margin: 0 0 var(--havline-space-2);
color: var(--havline-text);
font-size: 15px;
}
.about-detail-item p {
margin: 0;
color: var(--havline-text-secondary);
font-size: 13px;
line-height: 1.75;
}
.about-detail-item a {
color: var(--havline-brand-text);
text-decoration: none;
}
.about-point { .about-point {
padding-left: var(--havline-space-4); padding-left: var(--havline-space-4);
border-left: 2px solid var(--havline-brand); border-left: 2px solid var(--havline-brand);
@@ -556,10 +699,15 @@ onMounted(() => {
.about-points, .about-points,
.about-features, .about-features,
.about-license { .about-license,
.about-detail-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.about-detail-item--wide {
grid-column: auto;
}
.about-tech__group { .about-tech__group {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: var(--havline-space-2); gap: var(--havline-space-2);
+214 -25
View File
@@ -215,6 +215,25 @@
<p v-if="agentStatus?.nginx_error" class="form-hint nginx-error mono">{{ agentStatus.nginx_error }}</p> <p v-if="agentStatus?.nginx_error" class="form-hint nginx-error mono">{{ agentStatus.nginx_error }}</p>
<p v-if="reloadHint" class="form-hint">{{ reloadHint }}</p> <p v-if="reloadHint" class="form-hint">{{ reloadHint }}</p>
<div class="agent-actions"> <div class="agent-actions">
<n-popconfirm
v-if="agentStatus?.nginx_running"
positive-text="停止"
negative-text="取消"
@positive-click="nginxAction('stop')"
>
<template #trigger>
<n-button size="small" :loading="actingNginx" :disabled="busy !== null">停止 Nginx</n-button>
</template>
停止后 VPS 上所有经 Nginx 反代的域名会立即无法访问,需要重新启动才能恢复。确定停止?
</n-popconfirm>
<n-button
v-else
size="small"
type="primary"
:loading="actingNginx"
:disabled="busy !== null || !agentStatus?.nginx_path"
@click="nginxAction('start')"
>启动 Nginx</n-button>
<n-button size="small" :loading="reloadingNginx" :disabled="busy !== null" @click="reloadNginx">重载 Nginx</n-button> <n-button size="small" :loading="reloadingNginx" :disabled="busy !== null" @click="reloadNginx">重载 Nginx</n-button>
<router-link to="/public-proxy"><n-button size="small" quaternary>去「公网反代」处理</n-button></router-link> <router-link to="/public-proxy"><n-button size="small" quaternary>去「公网反代」处理</n-button></router-link>
</div> </div>
@@ -241,7 +260,7 @@
<HavlineCard class="section-card" title="Agent 生命周期(SSH)"> <HavlineCard class="section-card" title="Agent 生命周期(SSH)">
<div class="section-body"> <div class="section-body">
<p class="form-hint"> <p class="form-hint">
首次安装、升级、卸载需要 SSH(在「内网穿透」页服务端表单的「公网 Agent 管理」区填写);安装成功后自动建立 SSH 隧道,不会默认暴露公网 7700。 首次安装、升级、卸载需要 SSH(在本页「编辑服务端 → 远程管理 → 公网 Agent 管理」区填写);安装成功后自动建立 SSH 隧道,不会默认暴露公网 7700。
SSH 信息仅用于生命周期和隧道,凭据加密存储、不回显。 SSH 信息仅用于生命周期和隧道,凭据加密存储、不回显。
</p> </p>
<div class="field-grid agent-transport-grid"> <div class="field-grid agent-transport-grid">
@@ -307,7 +326,7 @@
<n-button size="small" quaternary :disabled="!dashboardURL" @click="openDashboard">frps 管理面板</n-button> <n-button size="small" quaternary :disabled="!dashboardURL" @click="openDashboard">frps 管理面板</n-button>
</span> </span>
</template> </template>
未配置 frps 管理接口:在「内网穿透」页服务端表单的「frps 管理接口」填写地址与端口后保存 未配置 frps 管理接口:在本页「编辑服务端 → 服务端配置 → frps 管理接口」填写地址与端口后保存
</n-tooltip> </n-tooltip>
<n-button size="small" :loading="busy === 'push-config'" :disabled="busy !== null && busy !== 'push-config'" @click="pushAgentConfig(server)">下发 frps 配置</n-button> <n-button size="small" :loading="busy === 'push-config'" :disabled="busy !== null && busy !== 'push-config'" @click="pushAgentConfig(server)">下发 frps 配置</n-button>
<n-button size="small" quaternary :loading="configLoading" :disabled="busy !== null" @click="viewConfig(server)">配置查看</n-button> <n-button size="small" quaternary :loading="configLoading" :disabled="busy !== null" @click="viewConfig(server)">配置查看</n-button>
@@ -403,6 +422,7 @@
<div class="proxy-modal__tabbar"> <div class="proxy-modal__tabbar">
<button type="button" class="proxy-modal__tab" :class="{ 'proxy-modal__tab--active': serverFormTab === 'basic' }" @click="serverFormTab = 'basic'">基础配置</button> <button type="button" class="proxy-modal__tab" :class="{ 'proxy-modal__tab--active': serverFormTab === 'basic' }" @click="serverFormTab = 'basic'">基础配置</button>
<button type="button" class="proxy-modal__tab" :class="{ 'proxy-modal__tab--active': serverFormTab === 'advanced' }" @click="serverFormTab = 'advanced'">高级配置</button> <button type="button" class="proxy-modal__tab" :class="{ 'proxy-modal__tab--active': serverFormTab === 'advanced' }" @click="serverFormTab = 'advanced'">高级配置</button>
<button type="button" class="proxy-modal__tab" :class="{ 'proxy-modal__tab--active': serverFormTab === 'server' }" @click="serverFormTab = 'server'">服务端配置</button>
<button type="button" class="proxy-modal__tab" :class="{ 'proxy-modal__tab--active': serverFormTab === 'remote' }" @click="serverFormTab = 'remote'">远程管理</button> <button type="button" class="proxy-modal__tab" :class="{ 'proxy-modal__tab--active': serverFormTab === 'remote' }" @click="serverFormTab = 'remote'">远程管理</button>
</div> </div>
<div class="proxy-modal__scroll"> <div class="proxy-modal__scroll">
@@ -415,6 +435,7 @@
<n-form-item label="主机地址" required><n-input v-model:value="serverForm.server_addr" placeholder="例如:frps.example.com" /></n-form-item> <n-form-item label="主机地址" required><n-input v-model:value="serverForm.server_addr" placeholder="例如:frps.example.com" /></n-form-item>
<n-form-item label="端口" required><n-input-number v-model:value="serverForm.server_port" :min="1" :max="65535" /></n-form-item> <n-form-item label="端口" required><n-input-number v-model:value="serverForm.server_port" :min="1" :max="65535" /></n-form-item>
</div> </div>
<div class="switch-row"><span>启用服务端</span><n-switch v-model:value="serverForm.enabled" /></div>
</section> </section>
<section class="form-section"> <section class="form-section">
@@ -461,10 +482,18 @@
<n-form-item label="描述"><n-input v-model:value="serverForm.options.remark" type="textarea" :rows="2" placeholder="添加一些关于此服务器的备注" /></n-form-item> <n-form-item label="描述"><n-input v-model:value="serverForm.options.remark" type="textarea" :rows="2" placeholder="添加一些关于此服务器的备注" /></n-form-item>
</section> </section>
</div> </div>
<div v-show="serverFormTab === 'remote'" class="frp-server-modal__remote"> <div v-show="serverFormTab === 'server'" class="frp-server-modal__server">
<section class="form-section"> <section class="form-section">
<h3><n-icon :component="SwapHorizontalOutline" /> frps vhost 反代端口</h3> <h3><n-icon :component="SwapHorizontalOutline" /> frps vhost 与域名反代</h3>
<p class="form-hint">frps 的 vhostHTTPPort / vhostHTTPSPort,即 Nginx 反代的回源端口(proxy_pass http://127.0.0.1:端口)。修改后需「下发 frps 配置」并重启 frps,且重新部署各规则的反代。留空用默认 8080 / 8443。</p> <p class="form-hint">frps 的 vhostHTTPPort / vhostHTTPSPort 是 Nginx 反代的回源端口(proxy_pass http://127.0.0.1:端口),同时包含 subDomainHost、tcpmux 等域名入口配置。修改后需「下发 frps 配置」并重启 frps,且重新部署各规则的反代。留空用默认 8080 / 8443。</p>
<n-alert
v-if="serverForm.options.vhost_http_port === 80 || serverForm.options.vhost_https_port === 443"
type="warning"
:show-icon="false"
style="margin-bottom: 12px"
>
80/443 通常由 VPS 上的 Nginx 占用,frps 不能再次监听;请改为 8080/8443,否则 frps 会因端口冲突启动失败。
</n-alert>
<div class="form-grid"> <div class="form-grid">
<n-form-item label="HTTP 回源端口"><n-input-number v-model:value="serverForm.options.vhost_http_port" :min="1" :max="65535" placeholder="默认 8080" /></n-form-item> <n-form-item label="HTTP 回源端口"><n-input-number v-model:value="serverForm.options.vhost_http_port" :min="1" :max="65535" placeholder="默认 8080" /></n-form-item>
<n-form-item label="HTTPS 回源端口"><n-input-number v-model:value="serverForm.options.vhost_https_port" :min="1" :max="65535" placeholder="默认 8443" /></n-form-item> <n-form-item label="HTTPS 回源端口"><n-input-number v-model:value="serverForm.options.vhost_https_port" :min="1" :max="65535" placeholder="默认 8443" /></n-form-item>
@@ -473,10 +502,6 @@
<n-form-item label="子域名根(subDomainHost)"><n-input v-model:value="serverForm.options.sub_domain_host" placeholder="例如:frps.example.com,规则填 subdomain 后生成为 sub.frps.example.com" /></n-form-item> <n-form-item label="子域名根(subDomainHost)"><n-input v-model:value="serverForm.options.sub_domain_host" placeholder="例如:frps.example.com,规则填 subdomain 后生成为 sub.frps.example.com" /></n-form-item>
<n-form-item label="tcpmux 监听端口"><n-input-number v-model:value="serverForm.options.tcpmux_http_connect_port" :min="1" :max="65535" placeholder="存在 tcpmux 规则时必填" /></n-form-item> <n-form-item label="tcpmux 监听端口"><n-input-number v-model:value="serverForm.options.tcpmux_http_connect_port" :min="1" :max="65535" placeholder="存在 tcpmux 规则时必填" /></n-form-item>
</div> </div>
<div class="form-grid">
<n-form-item label="端口白名单(allowPorts)"><n-input v-model:value="serverForm.options.allow_ports" placeholder="如 6000,6005-6010,7000-8000;留空不限制" /></n-form-item>
<n-form-item label="每客户端端口上限"><n-input-number v-model:value="serverForm.options.max_ports_per_client" :min="0" :max="65535" placeholder="0 表示不限" /></n-form-item>
</div>
<div class="form-grid"> <div class="form-grid">
<n-form-item label="自定义 404 页面(VPS 路径)"><n-input v-model:value="serverForm.options.custom_404_page" placeholder="例如 /var/www/404.html;留空用默认" /></n-form-item> <n-form-item label="自定义 404 页面(VPS 路径)"><n-input v-model:value="serverForm.options.custom_404_page" placeholder="例如 /var/www/404.html;留空用默认" /></n-form-item>
<div class="switch-row"><span>强制 TLS(frps 只接受 TLS 连接)</span><n-switch v-model:value="serverForm.options.tls_force" /></div> <div class="switch-row"><span>强制 TLS(frps 只接受 TLS 连接)</span><n-switch v-model:value="serverForm.options.tls_force" /></div>
@@ -485,6 +510,15 @@
<n-form-item v-if="serverForm.options.frps_log_to_file" label="日志保留天数"><n-input-number v-model:value="serverForm.options.frps_log_max_days" :min="1" :max="365" placeholder="默认 3" class="narrow-input" /></n-form-item> <n-form-item v-if="serverForm.options.frps_log_to_file" label="日志保留天数"><n-input-number v-model:value="serverForm.options.frps_log_max_days" :min="1" :max="365" placeholder="默认 3" class="narrow-input" /></n-form-item>
</section> </section>
<section class="form-section">
<h3><n-icon :component="GitNetworkOutline" /> TCP/UDP 端口分配</h3>
<p class="form-hint">allowPorts 只限制 tcp / udp 规则使用的 remotePort,不控制 HTTP/HTTPS 的 vhost 回源端口。留空表示不限制,但实际远程端口仍需在云安全组和 VPS 主机防火墙放行。</p>
<div class="form-grid">
<n-form-item label="端口白名单(allowPorts)"><n-input v-model:value="serverForm.options.allow_ports" placeholder="如 6000,6005-6010,7000-8000;留空不限制" /></n-form-item>
<n-form-item label="每客户端端口上限"><n-input-number v-model:value="serverForm.options.max_ports_per_client" :min="0" :max="65535" placeholder="0 表示不限" /></n-form-item>
</div>
</section>
<section class="form-section"> <section class="form-section">
<h3><n-icon :component="ServerOutline" /> frps 管理接口</h3> <h3><n-icon :component="ServerOutline" /> frps 管理接口</h3>
<p class="form-hint">用于 Havline 拉取穿透规则的实时连接与流量信息;需在 frps 的 frps.toml 中开启 webServer(dashboard),与 frpc 连接用的令牌无关。</p> <p class="form-hint">用于 Havline 拉取穿透规则的实时连接与流量信息;需在 frps 的 frps.toml 中开启 webServer(dashboard),与 frpc 连接用的令牌无关。</p>
@@ -505,18 +539,19 @@
</div> </div>
<n-button v-if="editingServer?.dashboard_has_pwd" size="small" quaternary type="error" @click="serverForm.clear_dashboard_password = true">清除管理密码</n-button> <n-button v-if="editingServer?.dashboard_has_pwd" size="small" quaternary type="error" @click="serverForm.clear_dashboard_password = true">清除管理密码</n-button>
</section> </section>
</div>
<div v-show="serverFormTab === 'remote'" class="frp-server-modal__remote">
<section class="form-section"> <section class="form-section">
<h3><n-icon :component="CloudOutline" /> 公网 Agent 管理</h3> <h3><n-icon :component="CloudOutline" /> 公网 Agent 管理</h3>
<p class="form-hint">Agent 部署在公网 VPS 上,保存 HTTP/HTTPS 规则后可由 Agent 自动生成反代;「公网 Agent」页提供完整管理面板。首次安装/升级需要 SSH,日常管理走 Agent API。</p> <p class="form-hint">Agent 部署在公网 VPS 上,保存 HTTP/HTTPS 规则后可由 Agent 自动生成反代;「公网 Agent」页提供完整管理面板。首次安装/升级需要 SSH,日常管理走 Agent API。Agent 地址通常由 SSH 隧道或 HTTPS 固定自动回填,不建议将 7700 明文暴露到公网。</p>
<div class="form-grid"> <div class="form-grid">
<n-form-item label="Agent API 地址"><n-input v-model:value="serverForm.agent_url" placeholder="例如:http://vps.example.com:7700" /></n-form-item> <n-form-item label="Agent API 地址"><n-input v-model:value="serverForm.agent_url" placeholder="安装后自动回填;也可填 http://127.0.0.1:7700(SSH 隧道)" /></n-form-item>
<n-form-item :show-label="false" :show-feedback="false"><ConfiguredSecretField v-model="serverForm.agent_token" label="Agent Token" :configured="!!editingServer?.agent_configured" placeholder="留空保持不变" /></n-form-item> <n-form-item :show-label="false" :show-feedback="false"><ConfiguredSecretField v-model="serverForm.agent_token" label="Agent Token" :configured="!!editingServer?.agent_configured" placeholder="留空保持不变" /></n-form-item>
<n-form-item label="SSH 地址"><n-input v-model:value="serverForm.ssh_host" placeholder="用于安装/升级/卸载 agent" /></n-form-item> <n-form-item label="SSH 地址"><n-input v-model:value="serverForm.ssh_host" placeholder="用于安装/升级/卸载 agent" /></n-form-item>
<n-form-item label="SSH 端口"><n-input-number v-model:value="serverForm.ssh_port" :min="1" :max="65535" /></n-form-item> <n-form-item label="SSH 端口"><n-input-number v-model:value="serverForm.ssh_port" :min="1" :max="65535" /></n-form-item>
<n-form-item label="SSH 用户"><n-input v-model:value="serverForm.ssh_user" placeholder="root 或具备 sudo 权限的用户" /></n-form-item> <n-form-item label="SSH 用户"><n-input v-model:value="serverForm.ssh_user" placeholder="root 或具备 sudo 权限的用户" /></n-form-item>
<n-form-item label="SSH 认证"><n-select v-model:value="serverForm.ssh_auth" :options="[{ label: '私钥', value: 'key' }, { label: '密码', value: 'password' }]" /></n-form-item> <n-form-item label="SSH 认证"><n-select v-model:value="serverForm.ssh_auth" :options="[{ label: '私钥', value: 'key' }, { label: '密码', value: 'password' }]" /></n-form-item>
<n-form-item :show-label="false" :show-feedback="false"><ConfiguredSecretField v-model="serverForm.ssh_secret" label="SSH 密码/私钥" :configured="!!editingServer?.ssh_configured" placeholder="用于 Agent 安装与升级" /></n-form-item> <n-form-item :show-label="false" :show-feedback="false"><ConfiguredSecretField v-model="serverForm.ssh_secret" :label="serverForm.ssh_auth === 'key' ? 'SSH 私钥' : 'SSH 密码'" :configured="!!editingServer?.ssh_configured" :placeholder="serverForm.ssh_auth === 'key' ? '粘贴私钥内容,用于 Agent 安装与升级' : '输入 SSH 密码,用于 Agent 安装与升级'" /></n-form-item>
</div> </div>
<div class="switch-row"><span>启用 Agent 日常管理</span><n-switch v-model:value="serverForm.mgmt_enabled" /></div> <div class="switch-row"><span>启用 Agent 日常管理</span><n-switch v-model:value="serverForm.mgmt_enabled" /></div>
<div v-if="editingServer?.agent_configured" class="agent-actions"> <div v-if="editingServer?.agent_configured" class="agent-actions">
@@ -524,6 +559,7 @@
<n-button size="small" :loading="busy === 'push-config'" :disabled="busy !== null && busy !== 'push-config'" @click="pushAgentConfig(editingServer)">下发 frps 配置</n-button> <n-button size="small" :loading="busy === 'push-config'" :disabled="busy !== null && busy !== 'push-config'" @click="pushAgentConfig(editingServer)">下发 frps 配置</n-button>
<n-button size="small" quaternary @click="showServerModal = false; selectedId = editingServer.id">查看服务端详情</n-button> <n-button size="small" quaternary @click="showServerModal = false; selectedId = editingServer.id">查看服务端详情</n-button>
</div> </div>
<p v-if="editingServer?.agent_configured" class="form-hint">「下发 frps 配置」使用已保存的配置;若刚修改了上方参数,请先保存再下发。</p>
</section> </section>
</div> </div>
<div v-show="serverFormTab === 'advanced'" class="frp-server-modal__advanced"> <div v-show="serverFormTab === 'advanced'" class="frp-server-modal__advanced">
@@ -585,7 +621,7 @@
</div> </div>
</div> </div>
<div class="proxy-modal__help"> <div class="proxy-modal__help">
<h4>{{ serverFormTab === 'basic' ? '配置说明' : '高级配置说明' }}</h4> <h4>{{ serverHelpTitle }}</h4>
<ol> <ol>
<li v-for="item in serverHelpItems" :key="item.title"><strong>{{ item.title }}</strong>:{{ item.text }}</li> <li v-for="item in serverHelpItems" :key="item.title"><strong>{{ item.title }}</strong>:{{ item.text }}</li>
</ol> </ol>
@@ -791,6 +827,8 @@ const headerDescription = computed(() => {
// 三态:未加载 / 加载中 / 失败——避免把"没拉到"显示成"未连接" // 三态:未加载 / 加载中 / 失败——避免把"没拉到"显示成"未连接"
const agentConnectionText = computed(() => { const agentConnectionText = computed(() => {
if (!statusLoaded.value) return statusLoading.value ? '读取中…' : '—' if (!statusLoaded.value) return statusLoading.value ? '读取中…' : '—'
if (agentStatus.value?.configured === false) return '未配置'
if (agentStatus.value?.token_mismatch) return 'Token 不匹配'
return agentStatusOk.value ? '在线' : '未连接' return agentStatusOk.value ? '在线' : '未连接'
}) })
const frpsStateText = computed(() => { const frpsStateText = computed(() => {
@@ -819,6 +857,27 @@ const statusAlert = computed<{ type: 'error' | 'warning'; text: string; action?:
const current = server.value const current = server.value
if (!current || !statusLoaded.value) return null if (!current || !statusLoaded.value) return null
if (!agentStatusOk.value) { if (!agentStatusOk.value) {
if (agentStatus.value?.configured === false) {
return {
type: 'warning',
text: '尚未安装 Agent:请先填写 SSH 信息,再点击下方「安装 Agent」。',
action: { label: '安装 Agent', run: () => installAgent(current) },
}
}
if (agentStatus.value?.mgmt_enabled === false) {
return {
type: 'warning',
text: 'Agent 已配置,但「启用 Agent 日常管理」未开启。',
action: { label: '编辑服务端', run: () => openServerModal(current) },
}
}
if (agentStatus.value?.token_mismatch) {
return {
type: 'error',
text: 'Agent Token 不匹配:Havline 保存的 Token 与 VPS 上的 Agent 配置不一致。',
action: { label: '升级 Agent', run: () => installAgent(current) },
}
}
return { return {
type: 'error', type: 'error',
text: 'Agent 未连接:请确认 VPS 上 havline-agent 正在运行,且 Agent 地址与 Token 与页面一致。', text: 'Agent 未连接:请确认 VPS 上 havline-agent 正在运行,且 Agent 地址与 Token 与页面一致。',
@@ -990,6 +1049,8 @@ async function loadHavlineCerts() {
// Nginx 显式重载(需 agent ≥ 0.11.0):把校验 / 重载失败原因直接显示出来 // Nginx 显式重载(需 agent ≥ 0.11.0):把校验 / 重载失败原因直接显示出来
const reloadingNginx = ref(false) const reloadingNginx = ref(false)
const reloadHint = ref('') const reloadHint = ref('')
// Nginx 启停(需 agent ≥ 0.15.2):按钮文案随进程状态切换
const actingNginx = ref(false)
async function reloadNginx() { async function reloadNginx() {
const target = server.value const target = server.value
@@ -1012,18 +1073,42 @@ async function reloadNginx() {
} }
} }
// 启停后刷新状态,让卡片上的「进程」与按钮文案同步
async function nginxAction(action: 'start' | 'stop' | 'restart') {
const target = server.value
if (!target) return
actingNginx.value = true
reloadHint.value = ''
try {
const result = await api.actionFrpAgentNginx(target.id, action)
await refreshAll({ silent: true })
reloadHint.value = result.nginx_running ? 'Nginx 已启动' : 'Nginx 已停止'
} catch (error) {
reloadHint.value = error instanceof Error ? error.message : 'Nginx 操作失败'
} finally {
actingNginx.value = false
}
}
// 轮询走静默(不触碰 busy);首次加载显示 statusLoading // 轮询走静默(不触碰 busy);首次加载显示 statusLoading
async function refreshAll(options: { silent?: boolean } = { silent: true }) { async function refreshAll(options: { silent?: boolean } = { silent: true }) {
const current = server.value const current = server.value
if (!current) return if (!current) return
if (options.silent === false) statusLoading.value = true if (options.silent === false) statusLoading.value = true
try { try {
agentStatus.value = await api.getFrpAgentStatus(current.id) const status = await api.getFrpAgentStatus(current.id)
agentStatusOk.value = true agentStatus.value = status
agentStatusOk.value = status.configured !== false && status.mgmt_enabled !== false && status.token_mismatch !== true
statusLoaded.value = true statusLoaded.value = true
// 资源采集单独走,不阻断状态加载 if (agentStatusOk.value) {
void loadMetrics(current.id) // 资源采集单独走,不阻断状态加载
void loadAgentCerts(current.id) void loadMetrics(current.id)
void loadAgentCerts(current.id)
} else {
agentMetrics.value = null
metricsError.value = ''
havlineCertDomains.value = new Set()
}
} catch (error) { } catch (error) {
agentStatusOk.value = false agentStatusOk.value = false
statusLoaded.value = true statusLoaded.value = true
@@ -1040,9 +1125,39 @@ function probe(target: FrpServer) {
})) }))
} }
function isSSHHostKeyChangedError(error: unknown) {
return error instanceof Error && error.message.includes('SSH 主机密钥与首次记录不一致')
}
function confirmTrustSSHHostKey(): Promise<boolean> {
return new Promise((resolve) => {
dialog.warning({
title: 'SSH 主机密钥已变化',
content: '该服务器可能已重装系统。确认新主机密钥可信后,Havline 将更新记录并重新安装 Agent。',
positiveText: '信任并重装',
negativeText: '取消',
onPositiveClick: () => resolve(true),
onNegativeClick: () => resolve(false),
onClose: () => resolve(false),
})
})
}
async function installAgentWithTrust(target: FrpServer) {
try {
return await api.installFrpAgent(target.id)
} catch (error) {
if (!isSSHHostKeyChangedError(error)) throw error
if (!(await confirmTrustSSHHostKey())) {
throw new Error('已取消信任新的 SSH 主机密钥')
}
return api.installFrpAgent(target.id, true)
}
}
function installAgent(target: FrpServer) { function installAgent(target: FrpServer) {
return run('agent-install', target.agent_configured ? '升级 Agent' : '安装 Agent', return run('agent-install', target.agent_configured ? '升级 Agent' : '安装 Agent',
() => api.installFrpAgent(target.id), () => installAgentWithTrust(target),
() => ({ text: 'Agent 安装/升级完成,已自动回填 Agent 地址与 Token', ok: true })) () => ({ text: 'Agent 安装/升级完成,已自动回填 Agent 地址与 Token', ok: true }))
.then(async (result) => { if (result !== undefined) await load() }) .then(async (result) => { if (result !== undefined) await load() })
} }
@@ -1201,7 +1316,7 @@ async function compareFrpsConfig() {
const frpsConfigTarget = ref<FrpServer | null>(null) const frpsConfigTarget = ref<FrpServer | null>(null)
const frpsConfigLoading = ref(false) const frpsConfigLoading = ref(false)
const serverFormTab = ref<'basic' | 'advanced' | 'remote'>('basic') const serverFormTab = ref<'basic' | 'advanced' | 'server' | 'remote'>('basic')
const editingServer = ref<FrpServer | null>(null) const editingServer = ref<FrpServer | null>(null)
const viewingServer = ref<FrpServer | null>(null) const viewingServer = ref<FrpServer | null>(null)
@@ -1221,12 +1336,14 @@ function defaultServerOptions(): FrpServerOptions {
dial_server_keepalive: 7200, pool_count: 0, tcp_mux: true, tcp_mux_keepalive_interval: 60, dial_server_keepalive: 7200, pool_count: 0, tcp_mux: true, tcp_mux_keepalive_interval: 60,
heartbeat_interval: 30, heartbeat_timeout: 90, login_fail_exit: true, tls_disable_custom_first_byte: false, auto_start: false, heartbeat_interval: 30, heartbeat_timeout: 90, login_fail_exit: true, tls_disable_custom_first_byte: false, auto_start: false,
dashboard_addr: '', dashboard_port: 0, dashboard_user: '', dashboard_addr: '', dashboard_port: 0, dashboard_user: '',
sub_domain_host: '', custom_404_page: '', tls_force: false, allow_ports: '', max_ports_per_client: 0,
frps_log_to_file: false, frps_log_max_days: 3,
} }
} }
const serverForm = reactive({ const serverForm = reactive({
name: '', server_addr: '', server_port: 7000, tls_enabled: true, tls_server_name: '', enabled: true, token: '', name: '', server_addr: '', server_port: 7000, tls_enabled: true, tls_server_name: '', enabled: true, token: '',
agent_url: '', agent_token: '', ssh_host: '', ssh_port: 22, ssh_user: 'root', ssh_auth: 'key', ssh_secret: '', mgmt_enabled: false, agent_url: '', agent_token: '', ssh_host: '', ssh_port: 22, ssh_user: 'root', ssh_auth: 'key', ssh_secret: '', mgmt_enabled: true,
oidc_client_secret: '', dashboard_password: '', clear_dashboard_password: false, tls_certificate: '', tls_key: '', tls_trusted_ca: '', clear_tls_certificate: false, oidc_client_secret: '', dashboard_password: '', clear_dashboard_password: false, tls_certificate: '', tls_key: '', tls_trusted_ca: '', clear_tls_certificate: false,
clear_tls_key: false, clear_tls_trusted_ca: false, options: defaultServerOptions(), clear_tls_key: false, clear_tls_trusted_ca: false, options: defaultServerOptions(),
}) })
@@ -1238,17 +1355,27 @@ const serverHelpItems = computed(() => {
{ title: 'TLS', text: '默认启用;证书与密钥用于双向认证,受信任的 CA 用于校验 frps 证书,SNI 与 frps 侧证书域名一致。' }, { title: 'TLS', text: '默认启用;证书与密钥用于双向认证,受信任的 CA 用于校验 frps 证书,SNI 与 frps 侧证书域名一致。' },
{ title: '连接调优', text: '心跳与 TCP 多路复用保持默认即可;网络不稳定时可加大超时,连接池可降低高并发时的建连延迟。' }, { title: '连接调优', text: '心跳与 TCP 多路复用保持默认即可;网络不稳定时可加大超时,连接池可降低高并发时的建连延迟。' },
] ]
if (serverFormTab.value === 'server') return [
{ title: 'frps vhost 与域名反代', text: 'vhostHTTPPort/vhostHTTPSPort 是 Nginx 反代的回源端口,同时决定 subDomainHost、tcpmux 等域名入口;留空用默认 8080/8443,80/443 会与 VPS 上的 Nginx 冲突。修改后需「下发 frps 配置」并重启 frps,再重新部署各规则。' },
{ title: 'TCP/UDP 端口分配', text: 'allowPorts 只限制 tcp/udp 规则使用的 remotePort,不控制 vhost 回源端口;实际端口仍需在云安全组与 VPS 主机防火墙放行。' },
{ title: 'frps 管理接口', text: 'frps 开启 webServer(dashboard)后填写,Havline 借此拉取穿透规则的实时连接与流量;地址与端口需成对填写,与连接认证无关。' },
]
if (serverFormTab.value === 'remote') return [
{ title: '公网 Agent 管理', text: 'Agent 部署在 VPS 上,保存 HTTP/HTTPS 规则后自动生成反代;首次安装/升级需 SSH,日常管理走 Agent API,Token 与 SSH 凭据加密存储、不回显。' },
]
const common = [ const common = [
{ title: '基本信息', text: '主机地址和端口对应 frps 的 bindAddr / bindPort,多个服务端可并行配置,规则保存时选择归属。' }, { title: '基本信息', text: '主机地址和端口对应 frps 的 bindAddr / bindPort,多个服务端可并行配置,规则保存时选择归属。' },
{ title: '认证方式', text: '与 frps 的 auth 配置保持一致:无认证、令牌(auth.token)或 OIDC;令牌与密钥加密存储,编辑时留空保持不变。' }, { title: '认证方式', text: '与 frps 的 auth 配置保持一致:无认证、令牌(auth.token)或 OIDC;令牌与密钥加密存储,编辑时留空保持不变。' },
{ title: 'frps 管理接口', text: 'frps 开启 webServer(dashboard)后填写,Havline 借此拉取穿透规则的实时连接与流量;不写入 frpc.toml,与连接认证无关。' },
{ title: '描述', text: '仅用于在 Havline 中区分多台服务端的备注信息。' }, { title: '描述', text: '仅用于在 Havline 中区分多台服务端的备注信息。' },
] ]
if (serverForm.options.auth_method === 'oidc') return [...common, { title: 'OIDC', text: '客户端 ID、密钥和令牌端点由身份提供商提供;受众与权限范围需与 frps 的 OIDC 校验配置匹配。' }] if (serverForm.options.auth_method === 'oidc') return [...common, { title: 'OIDC', text: '客户端 ID、密钥和令牌端点由身份提供商提供;受众与权限范围需与 frps 的 OIDC 校验配置匹配。' }]
return common return common
}) })
const serverHelpTitle = computed(() => ({ basic: '配置说明', advanced: '高级配置说明', server: '服务端配置说明', remote: '远程管理说明' }[serverFormTab.value]))
const serverHelpTip = computed(() => { const serverHelpTip = computed(() => {
if (serverFormTab.value === 'advanced') return '传输参数需与 frps 服务端配置匹配;修改后请点击“校验并应用配置”。' if (serverFormTab.value === 'advanced') return '传输参数需与 frps 服务端配置匹配;修改后保存即可生效。'
if (serverFormTab.value === 'server') return 'frps 参数修改后需保存并「下发 frps 配置」,再重启 frps 才会生效。'
if (serverFormTab.value === 'remote') return '首次安装/升级需 SSH;安装成功后建议使用 SSH 隧道或 HTTPS 固定接入,不要长期暴露公网 7700。'
if (serverForm.options.auth_method === 'oidc') return 'OIDC 需要身份提供商支持客户端凭证模式,且 frps 侧已启用相同的 OIDC 校验。' if (serverForm.options.auth_method === 'oidc') return 'OIDC 需要身份提供商支持客户端凭证模式,且 frps 侧已启用相同的 OIDC 校验。'
return '请先在 VPS 上部署 frps 并放行对应端口,再保存服务端配置。' return '请先在 VPS 上部署 frps 并放行对应端口,再保存服务端配置。'
}) })
@@ -1325,10 +1452,12 @@ function statusBarTooltip(sample: FrpServerDetail['status']['samples'][number])
function resetServerForm() { function resetServerForm() {
Object.assign(serverForm, { Object.assign(serverForm, {
name: '', server_addr: '', server_port: 7000, tls_enabled: true, tls_server_name: '', enabled: true, token: '', name: '', server_addr: '', server_port: 7000, tls_enabled: true, tls_server_name: '', enabled: true, token: '',
agent_url: '', agent_token: '', ssh_host: '', ssh_port: 22, ssh_user: 'root', ssh_auth: 'key', ssh_secret: '', mgmt_enabled: true,
oidc_client_secret: '', dashboard_password: '', clear_dashboard_password: false, tls_certificate: '', tls_key: '', tls_trusted_ca: '', clear_tls_certificate: false, oidc_client_secret: '', dashboard_password: '', clear_dashboard_password: false, tls_certificate: '', tls_key: '', tls_trusted_ca: '', clear_tls_certificate: false,
clear_tls_key: false, clear_tls_trusted_ca: false, clear_tls_key: false, clear_tls_trusted_ca: false,
}) })
Object.assign(serverForm.options, defaultServerOptions()) // 整体替换 options,避免上一次编辑的字段(如 vhost/日志/端口)残留到新增表单
serverForm.options = defaultServerOptions()
} }
function openServerModal(server?: FrpServer) { function openServerModal(server?: FrpServer) {
@@ -1527,7 +1656,67 @@ function serverStateTooltip(server: FrpServer) {
return `期望:${serverStartupText(server)};frpc:${processStateText(server)};FRP:${connectionStateText(server)}${server.last_error ? `;${server.last_error}` : ''}` return `期望:${serverStartupText(server)};frpc:${processStateText(server)};FRP:${connectionStateText(server)}${server.last_error ? `;${server.last_error}` : ''}`
} }
function validateServerForm(): string {
const form = serverForm
if (!form.name.trim()) return '请填写服务端名称'
if (!form.server_addr.trim()) return '请填写主机地址'
if (!form.server_port || form.server_port < 1 || form.server_port > 65535) return '端口需在 1-65535 之间'
if (form.options.auth_method === 'token' && !editingServer.value && !form.token.trim()) {
return '认证方式为令牌时,请填写与 frps 一致的令牌'
}
if (form.options.auth_method === 'oidc') {
if (!form.options.oidc_client_id?.trim()) return 'OIDC 需填写客户端 ID'
if (!form.options.oidc_token_endpoint_url?.trim()) return 'OIDC 需填写令牌端点'
if (!editingServer.value && !form.oidc_client_secret.trim()) return 'OIDC 需填写客户端密钥'
}
if (form.options.vhost_http_port === 80 || form.options.vhost_https_port === 443) {
return 'frps 回源端口不能使用 80/443(通常由 VPS 上的 Nginx 占用),请改为 8080/8443'
}
const ports: Array<[string, number]> = [
['控制端口', form.server_port],
['HTTP 回源端口', form.options.vhost_http_port || 8080],
['HTTPS 回源端口', form.options.vhost_https_port || 8443],
]
if (form.options.dashboard_port) ports.push(['管理端口', form.options.dashboard_port])
if (form.options.tcpmux_http_connect_port) ports.push(['tcpmux 端口', form.options.tcpmux_http_connect_port])
const seen = new Map<number, string>()
for (const [label, port] of ports) {
const prev = seen.get(port)
if (prev) return `${prev}与${label}不能使用同一端口(${port})`
seen.set(port, label)
}
const hasDashboardAddr = !!form.options.dashboard_addr?.trim()
const hasDashboardPort = !!form.options.dashboard_port
if (hasDashboardAddr !== hasDashboardPort) return 'frps 管理接口的地址与端口需同时填写或同时留空'
if (form.ssh_host.trim()) {
if (!form.ssh_user.trim()) return '填写了 SSH 地址后,需同时填写 SSH 用户'
if (!editingServer.value?.ssh_configured && !form.ssh_secret.trim()) return '填写了 SSH 地址后,需填写 SSH 密码或私钥'
}
if (form.options.tls_force && !form.tls_enabled) return '已勾选「强制 TLS」,请在高级配置中启用 TLS'
const allowPorts = form.options.allow_ports?.trim()
if (allowPorts && !/^\d+(-\d+)?(\s*,\s*\d+(-\d+)?)*$/.test(allowPorts)) {
return '端口白名单格式应为 6000,6005-6010 这样的单端口或范围'
}
return ''
}
async function saveServer() { async function saveServer() {
const invalid = validateServerForm()
if (invalid) {
message.error(invalid)
return
}
if (serverForm.mgmt_enabled && !serverForm.agent_url.trim()) {
message.warning('未填写 Agent 地址:保存后可在本页通过 SSH 安装 Agent 自动回填')
}
savingServer.value = true savingServer.value = true
try { try {
const payload: FrpServerPayload = { const payload: FrpServerPayload = {
+3 -1
View File
@@ -11,7 +11,7 @@
<LoadError v-if="loadError" :message="loadError" @retry="init" /> <LoadError v-if="loadError" :message="loadError" @retry="init" />
<n-alert <n-alert
v-if="!loadError && frpEnabled" v-if="!loadError && frpEnabled && !bypassFrpHint"
class="frp-ddns-alert" class="frp-ddns-alert"
type="info" type="info"
:bordered="false" :bordered="false"
@@ -19,6 +19,7 @@
> >
请将域名 DNS 解析指向 <strong>FRP 服务器(VPS)的公网 IP</strong>,而非 NAS 本机 IP。可在 请将域名 DNS 解析指向 <strong>FRP 服务器(VPS)的公网 IP</strong>,而非 NAS 本机 IP。可在
<router-link :to="{ name: 'frp' }">内网穿透</router-link> 页管理 FRP 配置。 <router-link :to="{ name: 'frp' }">内网穿透</router-link> 页管理 FRP 配置。
<n-button size="small" type="primary" @click="bypassFrpHint = true">仍然进入 DDNS 设置</n-button>
</n-alert> </n-alert>
<template v-else-if="!loadError"> <template v-else-if="!loadError">
@@ -528,6 +529,7 @@ const dialog = useDialog()
const configs = ref<DDNSConfig[]>([]) const configs = ref<DDNSConfig[]>([])
const loading = ref(false) const loading = ref(false)
const frpEnabled = ref(false) const frpEnabled = ref(false)
const bypassFrpHint = ref(false)
const refreshingIP = ref(false) const refreshingIP = ref(false)
const loadError = ref('') const loadError = ref('')
const savingTask = ref(false) const savingTask = ref(false)
+1 -1
View File
@@ -469,7 +469,7 @@
→ frps(vhostHTTPPort,按 Host 分流) → frps(vhostHTTPPort,按 Host 分流)
→ FRP HTTP 隧道 → NAS 内网应用</pre> → FRP HTTP 隧道 → NAS 内网应用</pre>
<ol class="guide-steps"> <ol class="guide-steps">
<li>存在 http/https 规则时,本页生成的 frps.toml 已自动带 vhostHTTPPort / vhostHTTPSPort(80/443);用非标准端口请自行调整并放行防火墙;</li> <li>存在 http/https 规则时,frps.toml 会自动带 vhostHTTPPort / vhostHTTPSPort;VPS 上运行 Nginx 时请使用 8080/8443,80/443 留给 Nginx 对外监听;</li>
<li>DDNS「自定义 IP」把 app.example.com 解析到 VPS IP;</li> <li>DDNS「自定义 IP」把 app.example.com 解析到 VPS IP;</li>
<li>FRP 规则:类型 http(或 https),自定义域名 app.example.com,内网目标为应用地址;</li> <li>FRP 规则:类型 http(或 https),自定义域名 app.example.com,内网目标为应用地址;</li>
<li>应用配置后验证。</li> <li>应用配置后验证。</li>
+2 -1
View File
@@ -912,6 +912,7 @@ import {
WarningOutline, WarningOutline,
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
import { api, asList } from '../api/client' import { api, asList } from '../api/client'
import { withBase } from '../api/base'
import type { CfTunnel, DiscoveredService, ProxyClientConn, ProxyRule, ProxySavePayload, ProxyTraffic } from '../api/types' import type { CfTunnel, DiscoveredService, ProxyClientConn, ProxyRule, ProxySavePayload, ProxyTraffic } from '../api/types'
import { useVisibilityPolling } from '../composables/useVisibilityPolling' import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import EmptyState from '../components/EmptyState.vue' import EmptyState from '../components/EmptyState.vue'
@@ -1835,7 +1836,7 @@ function stopLogStream() {
function startLogStream() { function startLogStream() {
const rule = selectedRule.value const rule = selectedRule.value
if (!rule || logEventSource) return if (!rule || logEventSource) return
logEventSource = new EventSource(`/api/proxies/${rule.id}/logs/stream?tail=100`, { logEventSource = new EventSource(withBase(`/api/proxies/${rule.id}/logs/stream?tail=100`), {
withCredentials: true, withCredentials: true,
}) })
logEventSource.addEventListener('log', (event) => { logEventSource.addEventListener('log', (event) => {
+3
View File
@@ -5,6 +5,7 @@ import vue from '@vitejs/plugin-vue'
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')) as { version: string } const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')) as { version: string }
export default defineConfig({ export default defineConfig({
base: process.env.VITE_BASE_PATH || '/',
plugins: [vue()], plugins: [vue()],
define: { define: {
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || pkg.version), __APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || pkg.version),
@@ -15,6 +16,8 @@ export default defineConfig({
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:6893', target: 'http://localhost:6893',
timeout: 300_000,
proxyTimeout: 300_000,
configure(proxy) { configure(proxy) {
proxy.on('proxyRes', (proxyRes, req) => { proxy.on('proxyRes', (proxyRes, req) => {
if (req.url?.includes('/stream')) { if (req.url?.includes('/stream')) {