Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a0740a60f | ||
|
|
a7d71752e3 | ||
|
|
9aaefa3b77 | ||
|
|
7d8a327533 | ||
|
|
5a36ae243c | ||
|
|
a7e47e950a | ||
|
|
419c205103 | ||
|
|
c5502b0f95 | ||
|
|
a14d92ccbb | ||
|
|
9ca5da4523 | ||
|
|
244297f96c | ||
|
|
ea5fd1b934 | ||
|
|
ac56394dce | ||
|
|
1f33c7d9a7 | ||
|
|
90b7a9af6e | ||
|
|
31a5abe539 | ||
|
|
fafde0f709 | ||
|
|
c6f96ffc0f |
@@ -22,6 +22,7 @@ scripts/*
|
||||
!scripts/havline-install.sh
|
||||
!scripts/upgrade.sh
|
||||
!scripts/upgrade-hub.sh
|
||||
!scripts/push-oci-manual.ps1
|
||||
# 本地压缩包与导出产物不入库(如 web/src.rar)
|
||||
*.rar
|
||||
*.zip
|
||||
|
||||
@@ -2,7 +2,28 @@
|
||||
|
||||
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` 通过
|
||||
|
||||
### 变更
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="web/src/assets/brand/logo.png" alt="Havline Logo" width="96" />
|
||||
<img src="web/src/assets/brand/havline-logo.svg" alt="Havline Logo" width="96" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">Havline</h1>
|
||||
@@ -8,10 +8,11 @@
|
||||
|
||||
<p align="center">
|
||||
面向 <strong>飞牛 fnOS</strong> 的轻量公网访问管理工具<br />
|
||||
反向代理 · DDNS · HTTPS 证书 · 内网穿透 · 一站式 Web 管理
|
||||
反向代理 · DDNS · HTTPS 证书 · 内网穿透 · Cloudflare Tunnel · 一站式 Web 管理
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/mrlee233/havline/releases"><img src="https://img.shields.io/badge/version-1.9.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/其他环境-未测试-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" />
|
||||
@@ -28,50 +29,148 @@
|
||||
|
||||
无需手写 Nginx 配置,也无需在多个工具之间切换。当前开发与测试环境以飞牛 fnOS 为主,**群晖、威联通、自建 Linux 等其他环境尚未充分测试**,部署前请自行验证。
|
||||
|
||||
Havline 基于开源项目 [O96u/Fonu](https://github.com/O96u/Fonu) **1.0.2** 继续开发,保留上游在反向代理、DDNS、证书和内网穿透上的基础能力,并继续扩展公网 Agent、Cloudflare Tunnel、一键升级和飞牛应用交付。
|
||||
|
||||
## 功能特性
|
||||
|
||||
| 模块 | 说明 |
|
||||
| -------------- | ---------------------------------------------------------------------------- |
|
||||
| **反向代理** | 多域名、多端口、HTTP/HTTPS、自动重定向;实时访问日志、流量统计、当前连接 IP |
|
||||
| **DDNS** | 支持 Cloudflare、DNSPod、阿里云、腾讯云、火山引擎 DNS;单任务多根域名,自动同步公网 IP |
|
||||
| **HTTPS 证书** | ACME 自动申请与续期;证书/私钥/ZIP 下载;申请进度实时日志 |
|
||||
| **仪表盘** | 公网 IP、域名、证书、服务状态一览;请求趋势与运行健康度 |
|
||||
| **日志中心** | 系统日志、Nginx 访问日志、Nginx 错误日志;分页筛选与自动刷新 |
|
||||
| **内网穿透** | FRP 客户端(Nginx 网关穿透):无公网 IP 时经 VPS 中转,反代与安全策略不变 |
|
||||
| **系统设置** | 常规 / 通知 / 安全 / 高级分栏;邮件/Webhook/Telegram 告警、备份与恢复 |
|
||||
| **其他** | 深色模式、配置导出/导入、GitHub Release 更新提示 |
|
||||
| 模块 | 说明 |
|
||||
| -------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| **反向代理** | 多域名、多端口、HTTP/HTTPS、自动重定向;实时访问日志、流量统计、当前连接 IP |
|
||||
| **DDNS** | 支持 Cloudflare、DNSPod、阿里云、腾讯云、火山引擎 DNS;单任务多根域名,自动同步公网 IP |
|
||||
| **HTTPS 证书** | ACME 自动申请与续期;证书/私钥/ZIP 下载;申请进度实时日志 |
|
||||
| **仪表盘** | 公网 IP、域名、证书、服务状态一览;请求趋势与运行健康度 |
|
||||
| **日志中心** | 系统日志、Nginx 访问日志、Nginx 错误日志;分页筛选与自动刷新 |
|
||||
| **内网穿透** | FRP 多服务端管理,支持 TCP/UDP/HTTP/HTTPS 等代理类型;客户端按需下载、配置与运行诊断 |
|
||||
| **公网 Agent** | 通过 SSH 安装和维护 VPS Agent,远程管理 frps、Nginx、证书和路由 |
|
||||
| **Cloudflare Tunnel** | cloudflared 按需下载、隧道与路由管理、DNS 同步、健康检查和漂移修复 |
|
||||
| **一键升级** | 含 updater 的 Compose 部署支持镜像构建、主服务重建和版本健康检查 |
|
||||
| **系统设置** | 常规 / 通知 / 安全 / 高级分栏;邮件/Webhook/Telegram 告警、备份与恢复 |
|
||||
| **其他** | 深色模式、配置导出/导入、版本更新提示 |
|
||||
|
||||
## 相比上游 Fonu 的主要增量
|
||||
|
||||
| 方向 | Havline 增量 |
|
||||
| --- | --- |
|
||||
| **公网服务端** | 通过 SSH 安装、升级和维护 VPS 上的 `havline-agent`,远程管理 frps、Nginx、证书和公网反代 |
|
||||
| **FRP 管理** | 多服务端管理、FRP 客户端按需下载、代理规则校验、运行诊断、路由健康与漂移检测 |
|
||||
| **Cloudflare Tunnel** | cloudflared 按需下载、Account/API Token 预检、隧道与路由管理、DNS 同步、健康巡检和漂移修复 |
|
||||
| **一键升级** | Compose + updater sidecar,支持构建/重建/健康检查,并在部分失败场景下尝试回滚 |
|
||||
| **飞牛交付** | 提供 Docker 离线镜像包与 Native 应用两种形态,支持统一网关 `/app/havline` 接入 |
|
||||
| **文档体系** | 增加图文操作手册、部署/升级/迁移文档、飞牛打包方案和 Cloudflare/FRP 专题方案 |
|
||||
|
||||
## 界面展示
|
||||
|
||||
<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(Let's Encrypt) |
|
||||
| **DDNS** | Cloudflare / DNSPod / 阿里云 / 腾讯云 / 火山引擎 DNS API |
|
||||
| **内网穿透** | FRP 客户端(多服务端):无公网 IP 时经 VPS 中转,8 种穿透类型与插件,frps.toml 同源生成 |
|
||||
| **前端** | Vue 3、TypeScript、Vite、Naive UI、ECharts |
|
||||
| **部署** | Docker 镜像(amd64 / arm64),本地源码构建 |
|
||||
| 层级 | 技术 |
|
||||
| ------------ | ---------------------------------------------------------------------- |
|
||||
| **后端** | Go 1.23、标准库 HTTP、SQLite(modernc.org/sqlite) |
|
||||
| **反向代理** | 内置 Nginx(动态生成配置、热重载) |
|
||||
| **证书** | go-acme/lego(ACME DNS-01) |
|
||||
| **DDNS** | Cloudflare / DNSPod / 阿里云 / 腾讯云 / 火山引擎 DNS API |
|
||||
| **内网穿透** | FRP 客户端按需下载;多服务端和代理规则管理,支持公网 Agent |
|
||||
| **隧道** | Cloudflare Tunnel / cloudflared |
|
||||
| **前端** | Vue 3、TypeScript、Vite、Naive UI、ECharts |
|
||||
| **部署** | Docker 多架构镜像(amd64 / arm64)、GitHub Actions CI |
|
||||
|
||||
## 项目结构
|
||||
|
||||
| 路径 | 说明 |
|
||||
| --- | --- |
|
||||
| `cmd/havline` | 主程序入口,提供 Web 管理后台、API、调度和内置 Nginx/FRP 管理 |
|
||||
| `cmd/havline-agent` | 公网 VPS 上的 Agent,接收主程序下发并管理 frps、Nginx、证书和路由 |
|
||||
| `cmd/havline-updater` | 一键升级 sidecar,仅它挂载 Docker Socket,并通过 Unix Socket 与主程序通信 |
|
||||
| `internal/api` | HTTP 路由、鉴权中间件、请求处理器和 API 响应封装 |
|
||||
| `internal/app` | 应用启动、服务装配、统一网关和运行生命周期 |
|
||||
| `internal/nginx`、`internal/nginxtmpl` | Nginx 配置生成、共享模板、安全/限流/geo/TLS 片段和热重载 |
|
||||
| `internal/proxy`、`internal/service` | 反向代理规则、证书绑定、访问策略和 Nginx 配置编排 |
|
||||
| `internal/frp` | FRP 多服务端、规则、运行进程、Agent 安装、诊断、健康和漂移检测 |
|
||||
| `internal/cloudflared` | Cloudflare Tunnel 设置、隧道、路由、DNS、健康事件和漂移修复 |
|
||||
| `internal/agent` | Agent 服务端实现,包括鉴权、监听策略、防火墙提示、Nginx/frps 操作 |
|
||||
| `internal/updater` | updater 服务端、客户端、执行器和远端版本读取 |
|
||||
| `internal/ddns`、`internal/certificate`、`internal/acme` | DDNS 服务商、证书存储、导入和 ACME 申请续期 |
|
||||
| `internal/backup`、`internal/notify`、`internal/monitor`、`internal/metrics` | 备份恢复、通知渠道、监控采集和指标导出 |
|
||||
| `web/src` | Vue 3 前端,包含 API 客户端、组合式函数、组件、路由、样式和页面视图 |
|
||||
| `migrations` | SQLite 数据库迁移 |
|
||||
| `docs` | 项目方案、部署、升级、操作手册和飞牛应用文档 |
|
||||
| `docker-compose*.yml` | Host、Bridge 和单文件 Git 构建部署编排 |
|
||||
|
||||
## 部署
|
||||
|
||||
本地项目:三个 compose 文件均从源码构建镜像(`build: .`),不拉取任何仓库镜像,构建产物标签为 `havline:local`。
|
||||
源码仓库:[https://github.com/mrlee233/havline](https://github.com/mrlee233/havline) · [发布版本](https://github.com/mrlee233/havline/releases)
|
||||
|
||||
> **详细部署安装文档**:[`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) —— 环境要求、镜像构建、三种部署方式、首次配置、FRP/DDNS/证书联动、升级回滚与故障排查。
|
||||
> **FRPS 服务端管理方案**:[`docs/frps服务端管理方案.md`](frps服务端管理方案.md) —— 公网 VPS 部署 `havline-agent`,SSH 从本地 Havline 安装/升级/卸载,日常通过本地管理面板调用 API 自动编排 Nginx、frps 与证书。
|
||||
以下方式均从源码构建,不使用上游 Docker 镜像。
|
||||
|
||||
| 场景 | 推荐方式 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 飞牛 fnOS 常规安装 | Host 模式 Compose | 避开系统已占用的 `80/443`,默认使用 `6893/18080/9443` |
|
||||
| 非 host 网络或自定义端口映射 | Bridge 模式 Compose | 主服务和 updater 通过端口映射访问,适合测试或自建 Linux |
|
||||
| 只拿一个 Compose 文件部署 | 单文件 Git 构建 | updater 从远端仓库读取版本并构建,本地不需要完整源码 |
|
||||
| 飞牛应用商店安装 | Docker 离线包或 Native FPK | 见下方「飞牛 fnOS 应用」章节 |
|
||||
|
||||
### 快速部署(推荐 · 飞牛 fnOS · Host 模式)
|
||||
|
||||
飞牛系统已占用 **80/443**,使用 **host 网络** + **高位反代端口**,避免与系统 Nginx 冲突。
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name havline \
|
||||
--net=host \
|
||||
-v ./data:/data \
|
||||
--restart unless-stopped \
|
||||
havline:local
|
||||
git clone --branch main https://github.com/mrlee233/havline.git
|
||||
cd havline
|
||||
docker compose -f docker-compose.host.yml up -d --build
|
||||
```
|
||||
|
||||
| 入口 | 地址 |
|
||||
@@ -80,44 +179,49 @@ docker run -d \
|
||||
| HTTP 反代(默认) | `http://<NAS-IP>:18080` |
|
||||
| HTTPS 反代(默认) | `https://<NAS-IP>:9443` |
|
||||
|
||||
- `./data` 挂载后自动作为数据目录,存放数据库、Nginx 配置、证书与日志
|
||||
- 飞牛系统已占用 80/443,容器默认反代端口为 `18080`、`9443`,可通过环境变量修改
|
||||
- 默认命名卷挂载到 `/data`,存放数据库、Nginx 配置、证书与日志;可通过 `HAVLINE_DATA_PATH` 选择绑定目录,已有数据须先迁移
|
||||
- Host 编排设置反代默认端口为 `18080`、`9443`,可通过环境变量修改;该编排不包含 updater
|
||||
|
||||
### 其他部署方式
|
||||
|
||||
<details>
|
||||
<summary>Docker Compose</summary>
|
||||
<summary>本地源码 + Bridge 网络</summary>
|
||||
|
||||
在已克隆的源码目录中执行:
|
||||
|
||||
```bash
|
||||
HAVLINE_SESSION_SECRET=your-secret docker compose -f docker-compose.host.yml up -d
|
||||
docker compose -f docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
默认映射 `6893:6893`、`18080:80`、`9443:443`。主程序首次启动时会在共享卷中自动生成 updater Token,updater 等待并读取同一文件,不会创建额外的初始化容器;只有 updater 挂载 Docker Socket。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Bridge 网络(端口映射,非飞牛环境可自行尝试)</summary>
|
||||
<summary>单文件 Git 构建(不需要本地源码)</summary>
|
||||
|
||||
```bash
|
||||
HAVLINE_SESSION_SECRET=your-secret docker compose -f docker-compose.hub.yml up -d --build
|
||||
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>
|
||||
<summary>手动构建镜像</summary>
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
docker build -t havline:local .
|
||||
```
|
||||
|
||||
在源码根目录执行;构建镜像不会自动启动容器,部署时仍需选择合适的 Compose 编排。
|
||||
|
||||
</details>
|
||||
|
||||
### Web 一键升级
|
||||
|
||||
`docker-compose.yml` 与 `docker-compose.hub.yml` 会同时启动受限的 `havline-updater` sidecar。检测到新版本后,侧栏会出现「一键升级」入口,可在管理页面完成构建、重建与健康检查。
|
||||
|
||||
> 从旧版本升级时,需要先手动更新 Compose 文件并执行一次 `docker compose up -d --build` 启动 sidecar。详见 [`docs/system-upgrade.md`](docs/system-upgrade.md)。
|
||||
|
||||
### 首次登录
|
||||
|
||||
1. 启动后查看容器/运行日志,获取自动生成的 **admin 初始密码**
|
||||
@@ -126,28 +230,69 @@ docker compose up -d --build
|
||||
|
||||
### FRP 内网穿透(无公网 IP)
|
||||
|
||||
适用于无法端口映射、无公网 IPv4 的场景。Havline 内置 **frpc**,支持**多台公网服务端**与全部 8 种穿透类型(tcp / udp / http / https / tcpmux / stcp / sudp / xtcp)及插件(static_file / socks5 / https2http 等)。
|
||||
适用于无法端口映射、无公网 IPv4 的场景。Havline 在 **内网穿透** 页面统一管理多个服务端与代理规则,**frpc 按需下载**:
|
||||
|
||||
**典型流量路径**(tcp 规则 + 本地反代):用户 → DNS(解析到 VPS)→ frps → frpc → Havline Nginx → 内网服务
|
||||
- **Web 网关**:外网 HTTP/HTTPS 经 VPS 上的 frps 中转至本地 Havline Nginx(默认 `18080` / `9443`),域名分流、证书、访问控制、日志仍由 Nginx 处理
|
||||
- **端口转发(TCP/UDP)**:将 VPS 远程端口映射到内网服务(如 SSH、数据库、DNS/游戏等 UDP);可与 Web 网关同时启用,纯端口穿透可不配置 Web 域名
|
||||
- **服务端设置**:填写 frps 地址、端口、认证和 TLS 选项;保存后添加代理规则,校验配置再启动
|
||||
|
||||
**Web 流量路径**:用户 → DNS(解析到 VPS)→ frps → frpc → Havline Nginx → 内网服务
|
||||
|
||||
**端口转发流量路径**:用户 → VPS 公网 IP:远程端口 → frps → frpc → 内网 `IP:端口`(TCP 或 UDP)
|
||||
|
||||
#### 1. VPS 部署 frps
|
||||
|
||||
在服务端「详情」弹窗点 **「frps.toml」** 一键生成与 frpc 侧同源的服务端配置(bindPort / 认证 / webServer / 传输参数 / vhost 端口与防火墙清单全部对齐),复制到 VPS 保存后 `frps -c frps.toml` 启动,按文件末尾清单放行防火墙端口。
|
||||
在具有公网 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
|
||||
```
|
||||
|
||||
启动:`frps -c frps.toml`。安全组/防火墙需放行 **7000**(控制连接)、**80/443**(Web 虚拟主机),以及 **隧道远程端口**(在 Havline 添加规则后按提示放行;UDP 隧道需在防火墙同时放行对应端口的 UDP)。
|
||||
|
||||
#### 2. Havline 配置 frpc
|
||||
|
||||
在侧栏 **FRP 穿透** 页面:
|
||||
在侧栏 **内网穿透** 页面:
|
||||
|
||||
- **添加服务端**:主机地址 / 端口(frps bindPort,默认 7000)/ 认证方式与 Token,与 frps 一致;可选填 frps 管理接口(webServer)用于拉取穿透流量;
|
||||
- **添加穿透规则**:按需选择类型——tcp / udp 填远程端口,http / https / tcpmux 填自定义域名,stcp / sudp / xtcp 凭 secretKey 访问;
|
||||
- 保存后「应用配置」弹窗内校验并应用;服务端「详情」含五项诊断与独立日志。
|
||||
1. 添加服务端,填写服务器地址、端口、认证信息和 TLS 选项,与 VPS 上 frps 保持一致。
|
||||
2. 添加 HTTP / HTTPS 代理时填写域名和本机 Nginx 目标端口;Host 编排默认使用 `18080` / `9443`,Bridge 容器内通常为 `80` / `443`,以实际规则为准。
|
||||
3. 添加 TCP / UDP 代理时填写 VPS 远程端口及内网目标地址;开放对应安全组和防火墙端口。
|
||||
4. 在应用配置中下载或激活 FRP 客户端,校验配置后启动,通过服务端诊断和日志检查运行状态。
|
||||
|
||||
控制连接成功不等于业务已连通,还需检查代理端口、内网目标、DNS 和 Nginx 路由。
|
||||
|
||||
#### 3. DNS 与证书
|
||||
|
||||
- **DDNS**:IP 来源**留空**=检测本机(域名指家庭宽带);填**自定义 IP**=VPS 的 IP(域名直接指 VPS,Web 服务经 VPS 对外时使用);VPS 动态 IP 场景把服务端地址填 DDNS 域名即可自动跟随;
|
||||
- **HTTPS 证书**:ACME DNS-01 验证(复用 DDNS 服务商凭证,含火山引擎);「统一入口」方案证书在本地 Nginx 终结,「直通」方案在 VPS 侧终止。
|
||||
- **DDNS**:FRP 访问域名应解析到 **VPS 公网 IP**,而非 NAS IP;按需使用自定义 IP 或在 DNS 服务商处配置
|
||||
- **HTTPS 证书**:Web 网关推荐继续使用 **DNS-01** 验证(Cloudflare、阿里云、腾讯云、DNSPod、火山引擎等已支持);证书仍在 NAS 侧 Nginx 终结
|
||||
- **纯端口穿透**:无需为 TCP/UDP 隧道单独配置域名证书
|
||||
|
||||
### 公网 Agent 与 Cloudflare Tunnel
|
||||
|
||||
- **公网 Agent**:在公网 Agent 页面配置 VPS 信息,按需通过 SSH 安装并测试连接,再管理 frps、Nginx 和证书;需要公网反代时部署对应路由。限制 Agent 管理通道访问,不要仅凭业务端口可用判断管理通道安全。
|
||||
- **Cloudflare Tunnel**:配置 Account ID 和 API Token,预检授权并按需下载 cloudflared;创建隧道后,在反向代理规则中选择 Cloudflare 出口和目标隧道,检查路由、DNS 与连接状态。托管模式从本地规则派生 ingress,修复漂移前先确认本地配置。
|
||||
|
||||
## 飞牛 fnOS 应用
|
||||
|
||||
Havline 提供两种飞牛应用形态,均支持统一网关入口 `/app/havline`。
|
||||
|
||||
### Docker 应用(离线镜像包)
|
||||
|
||||
- 独立仓库:<https://github.com/mrlee233/havline>
|
||||
- 安装包:`havline-1.9.0.fpk`
|
||||
- 包内包含 amd64 / arm64 离线镜像,安装时执行 `docker load`
|
||||
- Compose 使用本地镜像并设置 `pull_policy: never`,不依赖外部 Registry
|
||||
- 管理界面通过统一网关 `/app/havline` 访问
|
||||
|
||||
> 组合场景详解已内置页面:FRP 页右上「使用说明」→「组合场景详解」标签,覆盖十个典型部署场景与故障排查。
|
||||
|
||||
## 环境变量
|
||||
|
||||
@@ -155,41 +300,98 @@ docker compose up -d --build
|
||||
| ----------------------- | ------------------------- | ------------------------------------------ |
|
||||
| `HAVLINE_LISTEN` | `:6893` | 管理后台监听地址 |
|
||||
| `HAVLINE_DATA_DIR` | `/data` | 数据目录(数据库、Nginx 配置、证书、日志) |
|
||||
| `HAVLINE_SESSION_SECRET` | 空(自动生成) | 会话与凭据加密密钥;有数据后不要更换 |
|
||||
| `HAVLINE_SESSION_SECRET` | 空,自动生成 | 会话与凭据加密密钥,保存在数据目录;已有数据后不要更换 |
|
||||
| `HAVLINE_INITIAL_ADMIN_PASSWORD` | 空,自动生成 | 首次初始化管理员时使用的密码;不设置时会生成随机密码并输出到首次启动日志 |
|
||||
| `HAVLINE_UPDATER_TOKEN` | 空,自动生成 | 可选覆盖值;未设置时读取共享 Token 文件 |
|
||||
| `HAVLINE_UPDATER_TOKEN_FILE` | `/run/havline-updater/updater.token` | 主程序与 updater sidecar 共享的 Token 文件 |
|
||||
| `HAVLINE_UPDATER_SOCKET` | `/run/havline-updater/updater.sock` | 主程序与 updater sidecar 通信的 Unix Socket |
|
||||
| `HAVLINE_DATA_PATH` | `havline-data` | Compose 挂载来源,可配置宿主机目录 |
|
||||
| `HAVLINE_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代规则的默认监听端口 |
|
||||
| `HAVLINE_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代规则的默认监听端口 |
|
||||
| `HAVLINE_NGINX_BIN` | `nginx` | Nginx 可执行文件路径(本地开发可指定) |
|
||||
| `HAVLINE_FRPC_BIN` | `frpc` | frpc 可执行文件路径(镜像不预装,用「应用配置」按需下载或自行放置) |
|
||||
| `HAVLINE_FRPC_BIN` | `frpc` | frpc 可执行文件路径(运行时可按需下载) |
|
||||
| `HAVLINE_FRP_PID` | `{DATA_DIR}/frp/frpc.pid` | frpc 进程 PID 文件路径 |
|
||||
| `HAVLINE_UPDATER_SOCKET` | `/run/havline-updater/updater.sock` | 一键升级 sidecar 的 Unix Socket;未挂载时自动禁用 |
|
||||
| `HAVLINE_GATEWAY_SOCKET` | 空 | 飞牛统一网关接入时使用的 Unix Socket |
|
||||
| `HAVLINE_GATEWAY_PREFIX` | 空 | 统一网关访问前缀,例如 `/app/havline` |
|
||||
| `HAVLINE_AGENT_LISTEN` | `127.0.0.1:7700` | Agent 默认监听地址 |
|
||||
| `HAVLINE_AGENT_ALLOW_REMOTE` | `0` | 是否允许 Agent 监听非回环地址;启用前应确认防火墙和 HTTPS/隧道策略 |
|
||||
| `HAVLINE_AGENT_TOKEN` | 安装时生成 | Agent Bearer Token |
|
||||
| `HAVLINE_UPDATE_PROJECT_DIR` | `/workspace` | updater 容器内 Compose 项目目录 |
|
||||
| `HAVLINE_UPDATE_COMPOSE_FILE` | `docker-compose.yml` | updater 允许操作的 Compose 文件 |
|
||||
| `HAVLINE_UPDATE_MODE` | `local` | 升级模式,支持 `local` 或 `git` |
|
||||
| `HAVLINE_UPDATE_REPO` | 无 | `git` 模式下的远端仓库地址 |
|
||||
| `HAVLINE_UPDATE_BRANCH` | `main` | `git` 模式下的分支 |
|
||||
| `HAVLINE_UPDATE_HOST_DIR` | 自动发现 | 宿主机 Compose 文件目录;自动发现失败时必须显式设置 |
|
||||
|
||||
### 数据目录速查
|
||||
|
||||
| 路径 | 内容 |
|
||||
| --- | --- |
|
||||
| `/data/havline.db` | 管理员、会话、代理、DDNS、FRP、Cloudflare、健康事件等 SQLite 数据 |
|
||||
| `/data/session_secret` | 会话签名与敏感凭据加密主密钥;已有数据后不可随意更换 |
|
||||
| `/data/nginx/` | 生成的 Nginx 配置、日志和相关运行文件 |
|
||||
| `/data/certs/` | ACME 证书与私钥 |
|
||||
| `/data/frp/` | frpc 配置、二进制、PID 和运行状态 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **生产环境务必设置** `HAVLINE_SESSION_SECRET`,不要使用默认值。
|
||||
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**:HTTPS 申请前请确保域名 DNS 已解析到本机,且 DDNS 凭证能管理对应根域。
|
||||
5. **证书与 DNS**:DNS-01 申请不要求域名指向 NAS,但需确保 DNS 凭证能写入对应根域的验证记录,并等待传播。业务访问仍需正确解析、端口和路由。
|
||||
6. **数据持久化**:Docker 部署请挂载 volume 到 `HAVLINE_DATA_DIR`,避免容器重建后配置丢失。
|
||||
7. **备份**:可通过设置页导出配置;重要证书与数据库建议定期备份 `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
|
||||
# 前端热更新
|
||||
cd web && npm ci && npm run dev
|
||||
```powershell
|
||||
# 构建前端并准备嵌入目录
|
||||
cd web
|
||||
npm ci
|
||||
npm run build
|
||||
cd ..
|
||||
New-Item -ItemType Directory -Force cmd/havline/web/dist | Out-Null
|
||||
Copy-Item web/dist/* cmd/havline/web/dist -Recurse -Force
|
||||
|
||||
# 后端(需先把 web/dist 复制到 cmd/havline/web/dist)
|
||||
cd web && npm run build
|
||||
# 运行后端(需要可用 Nginx)
|
||||
go run ./cmd/havline
|
||||
|
||||
# 测试
|
||||
go test ./...
|
||||
cd web && npm run typecheck
|
||||
```
|
||||
|
||||
发版版本号以根目录 `VERSION` 文件为单一来源,`docker compose build` 自动读取并注入前后端;本地 `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`。
|
||||
|
||||
## 开源协议
|
||||
|
||||
本项目采用 [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.html) 开源。
|
||||
|
||||
Havline 基于 [O96u/Fonu](https://github.com/O96u/Fonu) **1.0.2** 版本继续开发,感谢上游作者及贡献者。保留原作者版权、来源与 GPL-3.0 下适用的源代码提供义务。
|
||||
|
||||
问题反馈:[Havline Issues](https://github.com/mrlee233/havline/issues)。请提供版本、部署方式、复现步骤和脱敏日志,不上传密码、Token、私钥或完整备份。
|
||||
|
||||
@@ -6,11 +6,25 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/havline/havline/internal/updater"
|
||||
)
|
||||
|
||||
func main() {
|
||||
token, tokenSource, err := updater.WaitForToken(
|
||||
envOr("HAVLINE_UPDATER_TOKEN", ""),
|
||||
envOr("HAVLINE_UPDATER_TOKEN_FILE", updater.DefaultTokenPath),
|
||||
2*time.Minute,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if token == "" {
|
||||
log.Fatal("HAVLINE_UPDATER_TOKEN 或 HAVLINE_UPDATER_TOKEN_FILE 未配置")
|
||||
}
|
||||
log.Printf("updater Token 已就绪:%s", tokenSource)
|
||||
|
||||
server, err := updater.NewServer(updater.Config{
|
||||
SocketPath: envOr("HAVLINE_UPDATER_SOCKET", "/run/havline-updater/updater.sock"),
|
||||
ProjectDir: envOr("HAVLINE_UPDATE_PROJECT_DIR", "/workspace"),
|
||||
@@ -24,6 +38,7 @@ func main() {
|
||||
ProjectName: envOr("HAVLINE_UPDATE_PROJECT_NAME", ""),
|
||||
SelfImage: envOr("HAVLINE_UPDATE_SELF_IMAGE", ""),
|
||||
DockerSocket: envOr("HAVLINE_UPDATE_DOCKER_SOCK", "/var/run/docker.sock"),
|
||||
Token: token,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# 源码仓库:https://github.com/mrlee233/havline.git(main 分支)
|
||||
# 飞牛 NAS 等已占用 80/443 的环境:使用 host 网络 + 高位默认反代端口
|
||||
# 本地项目:直接用源码构建镜像,不从 Docker Hub 拉取
|
||||
name: havline
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# 用法:
|
||||
# mkdir -p /opt/havline && cd /opt/havline
|
||||
# # 取本文件(按你仓库页面上的「原始文件」链接下载,或 git clone 后复制过来):
|
||||
# # curl -fsSLo docker-compose.hub.yml <仓库里这个文件的原始地址>
|
||||
# # curl -fsSLo docker-compose.hub.yml https://raw.githubusercontent.com/mrlee233/havline/main/docker-compose.hub.yml
|
||||
# docker compose up -d --build # 会话密钥由应用首次启动自动生成并持久化在数据卷里
|
||||
#
|
||||
# 想自己指定会话密钥(可选项):在 compose 文件同层建 .env 写入
|
||||
@@ -20,7 +20,7 @@ name: havline
|
||||
services:
|
||||
havline:
|
||||
build:
|
||||
context: http://45.207.223.190:10082/mrlee/Havline.git#main
|
||||
context: https://github.com/mrlee233/havline.git#main
|
||||
args:
|
||||
NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine}
|
||||
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
||||
@@ -49,6 +49,9 @@ services:
|
||||
HAVLINE_DATA_DIR: /data
|
||||
HAVLINE_LISTEN: :6893
|
||||
HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
|
||||
# 主程序首次启动时生成共享 Token;旧部署仍可用环境变量覆盖
|
||||
HAVLINE_UPDATER_TOKEN: ${HAVLINE_UPDATER_TOKEN:-}
|
||||
HAVLINE_UPDATER_TOKEN_FILE: /run/havline-updater/updater.token
|
||||
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
|
||||
# 想自己指定就在同层 .env 里设这个变量;注意它同时是凭据加密密钥,已有数据后不要更换
|
||||
HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-}
|
||||
@@ -58,7 +61,7 @@ services:
|
||||
# 受限升级 sidecar:只有它持有 Docker Socket,主程序通过共享 Unix Socket 触发
|
||||
updater:
|
||||
build:
|
||||
context: http://45.207.223.190:10082/mrlee/Havline.git#main
|
||||
context: https://github.com/mrlee233/havline.git#main
|
||||
target: updater
|
||||
args:
|
||||
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
||||
@@ -74,11 +77,13 @@ services:
|
||||
- havline-updater-sock:/run/havline-updater
|
||||
environment:
|
||||
HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
|
||||
HAVLINE_UPDATER_TOKEN: ${HAVLINE_UPDATER_TOKEN:-}
|
||||
HAVLINE_UPDATER_TOKEN_FILE: /run/havline-updater/updater.token
|
||||
HAVLINE_UPDATE_PROJECT_DIR: /workspace
|
||||
HAVLINE_UPDATE_COMPOSE_FILE: docker-compose.hub.yml
|
||||
HAVLINE_UPDATE_SERVICE: havline
|
||||
HAVLINE_UPDATE_MODE: git
|
||||
HAVLINE_UPDATE_REPO: http://45.207.223.190:10082/mrlee/Havline.git
|
||||
HAVLINE_UPDATE_REPO: https://github.com/mrlee233/havline.git
|
||||
HAVLINE_UPDATE_BRANCH: main
|
||||
HAVLINE_UPDATE_APP_URL: http://havline:6893
|
||||
HAVLINE_UPDATE_HOST_DIR: ${HAVLINE_UPDATE_HOST_DIR:-}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# 源码仓库:https://github.com/mrlee233/havline.git(main 分支)
|
||||
# 固定项目名:不改的话,改了目录名/项目名后 compose 会把它当成新项目,
|
||||
# havline-data 会变成新空卷(看起来像「数据丢了」)。
|
||||
name: havline
|
||||
@@ -34,6 +35,9 @@ services:
|
||||
HAVLINE_DATA_DIR: /data
|
||||
HAVLINE_LISTEN: :6893
|
||||
HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
|
||||
# 主程序首次启动时生成共享 Token;旧部署仍可用环境变量覆盖
|
||||
HAVLINE_UPDATER_TOKEN: ${HAVLINE_UPDATER_TOKEN:-}
|
||||
HAVLINE_UPDATER_TOKEN_FILE: /run/havline-updater/updater.token
|
||||
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
|
||||
# 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换
|
||||
HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-}
|
||||
@@ -58,6 +62,8 @@ services:
|
||||
- havline-updater-sock:/run/havline-updater
|
||||
environment:
|
||||
HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
|
||||
HAVLINE_UPDATER_TOKEN: ${HAVLINE_UPDATER_TOKEN:-}
|
||||
HAVLINE_UPDATER_TOKEN_FILE: /run/havline-updater/updater.token
|
||||
HAVLINE_UPDATE_PROJECT_DIR: /workspace
|
||||
HAVLINE_UPDATE_COMPOSE_FILE: docker-compose.yml
|
||||
HAVLINE_UPDATE_SERVICE: havline
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
# 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 页面右上「使用说明」(内置十个组合场景详解)。
|
||||
|
||||
## 0. 交付物清单与安装路径速查
|
||||
|
||||
| 交付物 | 内容 | 适用 | 安装方式 |
|
||||
| --- | --- | --- | --- |
|
||||
| `havline-1.1.0-linux-amd64.tar.gz`(14MB) | 编译好的 Linux 二进制(前端已内嵌)+ migrations + systemd + 一键脚本 | **裸机 Linux 服务器**,目标机只需系统 nginx | 解压 → `sudo bash install.sh`(见 3.4) |
|
||||
| `havline-1.1.0-image.tar.gz`(约 100-150MB) | 构建好的 Docker 镜像 `havline:local`(含 nginx + frpc) | 目标机有 Docker、要离线安装 | `docker load` → `docker run`(见 3.3 方式 C) |
|
||||
| `havline-1.1.0-src.zip`(11MB) | 完整源码 + Dockerfile/compose + 部署文档 | 要自行构建 / 二次开发 / 交叉编译 | `docker compose build` 后按方式 A/B 部署(见第 2 节) |
|
||||
| `havline-1.9.0-linux-amd64.tar.gz`(大小以实际发布包为准) | 编译好的 Linux 二进制(前端已内嵌)+ migrations + systemd + 一键脚本 | **裸机 Linux 服务器**,目标机只需系统 nginx | 解压 → `sudo bash install.sh` |
|
||||
| `havline-1.9.0-image.tar.gz`(大小以实际发布包为准) | 构建好的 Docker 镜像 `havline:local`(含 Nginx,不预装 frpc) | 目标机有 Docker、要离线安装 | `docker load` → `docker run` |
|
||||
| `havline-1.9.0-src.zip`(大小以实际发布包为准) | 完整源码 + Dockerfile/compose + 部署文档 | 要自行构建 / 二次开发 / 交叉编译 | `docker compose build` 后按方式 A/B 部署(见第 2 节) |
|
||||
|
||||
**怎么选**:飞牛 fnOS 首选镜像包导入(图形界面,见第 3 节末「飞牛 fnOS 部署详解」),要在 NAS 上构建则用源码包 + host 模式 compose;普通 Linux 服务器要省事用 Linux 运行包(免 Docker);目标机不方便联网构建用镜像包离线导入。三者的功能完全一致——容器里跑的也是同一份二进制 + 系统 nginx。
|
||||
|
||||
@@ -17,15 +19,15 @@
|
||||
|
||||
### 1.1 源码包内容
|
||||
|
||||
`havline-1.1.0-src.zip`(约 11MB,304 项)为完整可构建源码:
|
||||
`havline-1.9.0-src.zip`(文件大小与条目数以实际发布包为准)为完整可构建源码:
|
||||
|
||||
| 目录/文件 | 说明 |
|
||||
| --- | --- |
|
||||
| `cmd/` `internal/` | Go 后端源码(含已迁移的多服务端 FRP 模块) |
|
||||
| `web/` | Vue 3 + TypeScript 前端源码 |
|
||||
| `migrations/` | SQLite 数据库迁移 001–022(启动时自动按序应用) |
|
||||
| `migrations/` | SQLite 数据库迁移 001–036(启动时自动按序应用) |
|
||||
| `Dockerfile` / `docker-compose*.yml` | 本地构建镜像与三种部署编排 |
|
||||
| `VERSION` / `CHANGELOG.md` | 版本号单一来源(当前 `1.1.0`)与版本记录 |
|
||||
| `VERSION` / `CHANGELOG.md` | 版本号单一来源(当前 `1.9.0`)与版本记录 |
|
||||
| `deploy/nginx/mime.types` | 容器内 Nginx 依赖的 MIME 类型文件 |
|
||||
| `docs/screenshots/` | README 引用的界面截图 |
|
||||
|
||||
@@ -52,7 +54,7 @@ docker compose build
|
||||
|
||||
一次构建同时产出:前端 `dist`、Go 后端二进制与运行镜像(frpc 不预装,运行期用「应用配置」按需下载),标签为 `havline:local`。
|
||||
|
||||
**版本号**:读取根目录 `VERSION` 文件(当前 `1.1.0`)自动注入前后端显示;要改版本号只需编辑该文件后重新 build。
|
||||
**版本号**:读取根目录 `VERSION` 文件(当前 `1.9.0`)自动注入前后端显示;要改版本号只需编辑该文件后重新 build。
|
||||
|
||||
**网络受限环境**可覆盖基础镜像与 frpc 下载源(默认值见 `Dockerfile` 顶部注释):
|
||||
|
||||
@@ -71,7 +73,9 @@ docker build \
|
||||
飞牛系统自带 Nginx 占用 80/443,Havline 使用 host 网络 + 高位反代端口避免冲突。
|
||||
|
||||
```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 端口映射
|
||||
|
||||
```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);
|
||||
- 数据同样在命名卷 `havline-data`。
|
||||
- 数据同样在命名卷 `havline-data`;
|
||||
- 主程序首次启动时会在共享卷中自动生成 updater Token,sidecar 等待并读取同一文件。旧部署如显式设置 `HAVLINE_UPDATER_TOKEN`,仍会优先使用该值。
|
||||
|
||||
### 方式 C:docker run(不想用 compose)
|
||||
|
||||
@@ -94,16 +99,18 @@ HAVLINE_SESSION_SECRET=请改成一串随机字符 docker compose -f docker-comp
|
||||
docker run -d --name havline \
|
||||
--net=host \
|
||||
-v ./data:/data \
|
||||
-e HAVLINE_SESSION_SECRET=请改成一串随机字符 \
|
||||
-e HAVLINE_SESSION_SECRET=可选的固定密钥 \
|
||||
--restart unless-stopped \
|
||||
havline:local
|
||||
```
|
||||
|
||||
host 模式下反代端口用环境变量控制:`-e HAVLINE_NGINX_HTTP_PORT=18080 -e HAVLINE_NGINX_HTTPS_PORT=9443`。Bridge 环境改为 `-p 6893:6893 -p 18080:80 -p 9443:443`(去掉 `--net=host`)。
|
||||
|
||||
**生成随机 SESSION_SECRET**:`openssl rand -hex 32`(Linux)或 PowerShell:`-join ((48..57)+(97..122) | Get-Random -Count 32 | % {[char]$_})`。
|
||||
**生成随机密钥**: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)
|
||||
|
||||
@@ -113,12 +120,12 @@ curl -fsSL https://get.docker.com | sh
|
||||
sudo systemctl enable --now docker
|
||||
|
||||
# 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 分钟,取决于网络与机器性能)
|
||||
docker compose build
|
||||
|
||||
# 3. 生成密钥并启动(Bridge 模式,映射 6893/18080/9443)
|
||||
# 3. 可选:生成会话密钥并启动(Bridge 模式,映射 6893/18080/9443)
|
||||
echo "HAVLINE_SESSION_SECRET=$(openssl rand -hex 32)" > .env
|
||||
docker compose -f docker-compose.hub.yml up -d
|
||||
|
||||
@@ -143,7 +150,7 @@ curl -I http://127.0.0.1:6893/login # 应返回 200
|
||||
|
||||
```bash
|
||||
# 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 开机自启)
|
||||
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`。
|
||||
|
||||
> 镜像包(`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 部署详解(图形界面,不敲命令)
|
||||
|
||||
@@ -177,7 +184,7 @@ journalctl -u havline | grep -i password # 取 admin 初始密码
|
||||
|
||||
**方式一:导入现成镜像(推荐,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 再导入);
|
||||
3. **创建容器**:容器 → 创建:
|
||||
|
||||
@@ -187,7 +194,7 @@ journalctl -u havline | grep -i password # 取 admin 初始密码
|
||||
| 镜像 | `havline:local` |
|
||||
| 网络 | **host**(飞牛 80/443 被系统占用,host + 高位端口最省事;UI 不支持 host 时选 Bridge 并映射 6893/18080/9443) |
|
||||
| 存储挂载 | NAS 建目录(如 `/vol1/…/docker/havline/data`)→ 映射到容器 `/data`(读写) |
|
||||
| 环境变量 | `HAVLINE_SESSION_SECRET=随机串`、`HAVLINE_DATA_DIR=/data`、`HAVLINE_LISTEN=:6893`、`HAVLINE_NGINX_HTTP_PORT=18080`、`HAVLINE_NGINX_HTTPS_PORT=9443` |
|
||||
| 环境变量 | `HAVLINE_DATA_DIR=/data`、`HAVLINE_LISTEN=:6893`、`HAVLINE_NGINX_HTTP_PORT=18080`、`HAVLINE_NGINX_HTTPS_PORT=9443`;`HAVLINE_SESSION_SECRET` 可留空自动生成 |
|
||||
| 重启策略 | 自动重启 / unless-stopped(开机自启) |
|
||||
|
||||
4. **启动并取初始密码**:容器详情 → 日志,搜 `password`;浏览器打开 `http://<NAS-IP>:6893` 登录。
|
||||
@@ -196,13 +203,13 @@ journalctl -u havline | grep -i password # 取 admin 初始密码
|
||||
|
||||
1. 把源码包 zip 上传到 NAS 并解压(文件管理支持解压,或电脑解压后整目录上传);
|
||||
2. Docker 应用 → 项目(Compose)→ 新建:指定项目目录为解压后的源码目录、使用 `docker-compose.host.yml`(或直接把该文件内容粘贴到 Compose 编辑器);
|
||||
3. 在源码目录同层放一个 `.env` 文件,内容一行:`HAVLINE_SESSION_SECRET=随机串`;
|
||||
3. 在源码目录同层放一个 `.env` 文件;host 编排不需要 updater Token,`HAVLINE_SESSION_SECRET` 可留空,应用会在数据目录生成并持久化;
|
||||
4. 构建并启动。⚠️ NAS 上构建耗时较长(前端编译),低配机器建议改用方式一;
|
||||
5. 后续升级:上传新源码覆盖 → 项目重新构建即可,数据在卷里不受影响。
|
||||
|
||||
**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 就追加在下面):
|
||||
```
|
||||
NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine
|
||||
@@ -224,10 +231,13 @@ journalctl -u havline | grep -i password # 取 admin 初始密码
|
||||
|
||||
## 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 初始密码会打印在日志里);
|
||||
2. 浏览器打开 `http://<NAS-IP>:6893/login`,用 `admin` + 初始密码登录;
|
||||
3. **立即**在「设置 → 安全」修改管理员密码;
|
||||
4. 确认「设置 → 常规」中 `HAVLINE_SESSION_SECRET` 已改为非默认值(compose 文件中已传入则跳过);
|
||||
4. 确认数据目录中存在 `session_secret`,并将其与数据库、证书和配置一起纳入备份;不要在已有数据后更换它;
|
||||
5. 建议在「设置 → 通知」配置一种告警方式(邮件 / Webhook / Telegram),DDNS 失败、证书续签失败、登录异常等事件会推送。
|
||||
|
||||
## 5. 反向代理与 HTTPS 证书
|
||||
@@ -259,7 +269,102 @@ DDNS 页 → 添加任务:选服务商 → 填 API 凭证(可「测试连接
|
||||
3. **添加穿透规则**:8 种类型按需选——tcp/udp 填远程端口;http/https/tcpmux 填自定义域名(走 frps vhost 端口);stcp/sudp/xtcp 凭 secretKey(无公网监听);
|
||||
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 模式)确认映射 |
|
||||
| 提示「会话加密密钥」警告 | 未设置 `HAVLINE_SESSION_SECRET`,compose 传入即可 |
|
||||
| updater 启动失败或一直等待 Token | 检查主程序日志,以及共享卷中的 `/run/havline-updater/updater.token` 是否存在;旧部署可用 `HAVLINE_UPDATER_TOKEN` 覆盖 |
|
||||
| 反代 502 | 目标内网应用未启动或地址填错;日志中心看 Nginx error 日志 |
|
||||
| 证书申请失败 | DDNS 凭证是否有效、域名 DNS 是否已生效、ACME 邮箱是否已设置 |
|
||||
| frpc 连不上 frps | 两端 Token/端口不一致;用服务端「详情 → frps.toml」重新生成对齐 |
|
||||
| 穿透流量显示 0 | frps 未配置 webServer 或 Havline 服务端表单未填管理地址(非必须功能) |
|
||||
| 页面改动未生效 | 确认访问的是容器端口而非开发服务器;`docker compose up -d --build` 重建 |
|
||||
| 升级失败后服务未恢复 | 先查看 `docker logs havline-updater`;只有重建成功但新版本健康检查失败时才会自动回滚,构建失败或 updater 自身异常需要人工按镜像 tag 回滚 |
|
||||
|
||||
## 10. 卸载
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# 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 已完成,其余待实施 |
|
||||
| `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 兼容清理待后续 |
|
||||
@@ -19,6 +20,8 @@
|
||||
| `fnos-app-packaging-plan.md` | 飞牛 fnOS 应用打包方案:官方文档依据、Docker 模板、向导、权限、数据迁移、升级与发布流程 | 方案待实施 |
|
||||
| `project-audit-2026-09-24.md` | 全项目审查:P0/P1/P2 分级,含精确行号与逐条修法 | **进行中**——P0 已随 v1.2.7 完成,P1 实施中 |
|
||||
| `frp-server-list-merge-plan.md` | FRP 服务端列表并入「公网服务端」页(方案 A,四步) | 步 1/2 已完成(v1.2.6);步 3 文案收尾、步 4 死样式清理未做 |
|
||||
| `agent-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-view-design.md` | 公网 Agent 页设计方案(Pass 1/2 流程) | 部分过期:顶部下拉已被左列表 + 右详情取代 |
|
||||
| `agent-view-design-v2.md` | 公网 Agent 页设计方案 v2 | 部分过期:同上 |
|
||||
|
||||
@@ -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。跑通后再谈多节点与生态。
|
||||
- 最大的长期风险不是技术,而是**信任**:一个能改服务器状态的工具,只要有一次「说能回滚结果没退回去」,就很难再被装到生产机上。回滚能力必须比新功能优先级更高。
|
||||
@@ -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` 源码为准;建议评审确认后再实施改动。
|
||||
@@ -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、轮换证书、查看日志
|
||||
@@ -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、打开日志
|
||||
@@ -830,3 +830,249 @@ Havline 适合做飞牛 Docker 应用,不适合首发做成 CGI 或原生应
|
||||
3. 增加 multi-arch 镜像发布。
|
||||
4. 增加 `fnos/` 打包模板和构建脚本。
|
||||
5. 验证 `TRIM_PKGVAR/data` 数据迁移。
|
||||
|
||||
---
|
||||
|
||||
## 十九、官方文档复核与实施修正(2026-10-01)
|
||||
|
||||
本轮已逐页复核飞牛应用开放平台文档中的以下内容:
|
||||
|
||||
- 快速开始:准备工作、创建应用、测试应用、上架应用
|
||||
- 核心概念:`manifest`、权限、资源、运行时、环境变量、应用入口、统一网关、用户向导
|
||||
- 开发工具:`fnpack`、`appcenter-cli`
|
||||
- Docker 应用案例:`fnpack create --template docker`、Compose、资源声明、状态检查、桌面入口
|
||||
|
||||
### 19.1 已确认正确的方案
|
||||
|
||||
现有方案的大方向与官方 Docker 应用案例一致:
|
||||
|
||||
1. 使用 Docker 应用模板,而不是首发做 CGI 或原生应用。
|
||||
2. 包结构使用 `manifest`、`config/privilege`、`config/resource`、`app/docker/docker-compose.yaml`、`app/ui/config`、`cmd/main` 和 `wizard/`。
|
||||
3. `config/resource` 声明 Docker 项目资源的格式正确:
|
||||
|
||||
```json
|
||||
{
|
||||
"docker-project": {
|
||||
"projects": [
|
||||
{
|
||||
"name": "havline",
|
||||
"path": "docker"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. `cmd/main` 的 `start` 和 `stop` 可以空操作,Docker 项目启停由飞牛应用中心处理;`status` 检查容器状态,运行中返回 `0`,未运行返回 `3`。
|
||||
5. 首发关闭 Havline 自带 updater,避免 Docker Socket、宿主机路径映射和飞牛应用中心升级冲突。
|
||||
6. 默认使用 `run-as=package`,不使用 Root,不挂载 Docker Socket。
|
||||
7. 使用 `TRIM_SERVICE_PORT`、`TRIM_APPDEST`、`TRIM_PKGVAR` 等变量,而不是硬编码安装路径。
|
||||
|
||||
### 19.2 需要修正或补充的实现细节
|
||||
|
||||
1. **以 `fnpack` 模板为起点**
|
||||
|
||||
官方 Docker 案例推荐先执行:
|
||||
|
||||
```bash
|
||||
fnpack create havline --template docker
|
||||
cd havline
|
||||
```
|
||||
|
||||
因此 `fnos/` 不应完全手写。正确做法是:
|
||||
|
||||
- 先用模板生成标准骨架;
|
||||
- 再将 Havline 的 Compose、入口和生命周期脚本替换进去;
|
||||
- 让构建脚本只维护差异部分,避免未来官方模板升级时难以同步。
|
||||
|
||||
2. **`cmd/main` 的状态检查必须与 Compose 一致**
|
||||
|
||||
Compose 中有 `container_name: havline` 时,`cmd/main` 应检查该名称:
|
||||
|
||||
```bash
|
||||
CONTAINER_NAME="havline"
|
||||
```
|
||||
|
||||
如果以后移除 `container_name`,`cmd/main` 需要按 Compose 实际生成的容器名检查,否则应用中心会一直显示未运行。
|
||||
|
||||
3. **向导密钥字段必须使用 `password` 类型**
|
||||
|
||||
官方向导支持 `password` 类型,不能把管理员初始密码或 Token 用普通 `text` 收集:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "password",
|
||||
"field": "wizard_initial_admin_password",
|
||||
"label": "初始管理员密码",
|
||||
"rules": [
|
||||
{ "required": true, "message": "请输入初始管理员密码" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
向导值会变成同名环境变量,生命周期脚本使用前必须再次校验,并禁止写入日志。
|
||||
|
||||
4. **自定义向导字段统一使用 `wizard_` 前缀**
|
||||
|
||||
官方建议:
|
||||
|
||||
- 自定义字段使用 `wizard_` 前缀;
|
||||
- 字段名一旦发布应保持稳定;
|
||||
- 不要使用 `TRIM_` 前缀,因为该前缀保留给系统变量。
|
||||
|
||||
Havline 建议字段:
|
||||
|
||||
```text
|
||||
wizard_service_port
|
||||
wizard_nginx_http_port
|
||||
wizard_nginx_https_port
|
||||
wizard_timezone
|
||||
wizard_initial_admin_password
|
||||
```
|
||||
|
||||
5. **生命周期脚本错误必须写入 `TRIM_TEMP_LOGFILE`**
|
||||
|
||||
官方要求生命周期脚本以非零状态退出前,将用户可见错误写入:
|
||||
|
||||
```text
|
||||
TRIM_TEMP_LOGFILE
|
||||
```
|
||||
|
||||
安装、升级、配置和卸载脚本都应遵循该约定,不能只输出到 stdout。
|
||||
|
||||
6. **`app/ui/config` 的 `port` 必须三处一致**
|
||||
|
||||
以下三个值必须保持一致:
|
||||
|
||||
```text
|
||||
manifest.service_port
|
||||
app/ui/config 的 port
|
||||
docker-compose.yaml 中 ${TRIM_SERVICE_PORT}:6893
|
||||
```
|
||||
|
||||
否则应用卡片可能打开失败,或应用中心显示运行但入口不可访问。
|
||||
|
||||
7. **Docker Compose 支持官方路径变量**
|
||||
|
||||
官方明确指出 Docker Compose 可以使用:
|
||||
|
||||
```text
|
||||
TRIM_SERVICE_PORT
|
||||
TRIM_APPDEST
|
||||
TRIM_PKGVAR
|
||||
TRIM_DATA_SHARE_PATHS
|
||||
```
|
||||
|
||||
Havline 推荐继续使用:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- "${TRIM_PKGVAR}:/data"
|
||||
```
|
||||
|
||||
不要额外拼出无意义的 `${TRIM_PKGVAR}/data`,除非确实要在数据根下再做子目录。
|
||||
|
||||
8. **共享目录只暴露明确需要的路径**
|
||||
|
||||
`config/resource` 的 `data-share` 会让用户在文件管理器中看到路径。首发只能暴露备份目录,绝不能暴露:
|
||||
|
||||
```text
|
||||
session_secret
|
||||
havline.db
|
||||
certs/
|
||||
nginx/
|
||||
frp/
|
||||
cloudflared/
|
||||
```
|
||||
|
||||
9. **镜像必须真正支持 amd64 与 arm64**
|
||||
|
||||
`platform=all` 只代表应用包不含架构相关二进制,容器镜像本身仍必须支持目标架构。CI 必须构建并推送:
|
||||
|
||||
```text
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
```
|
||||
|
||||
否则 x86 测试通过但 ARM 设备仍会启动失败。
|
||||
|
||||
10. **版本和升级说明由 fnOS 管理**
|
||||
|
||||
首发使用飞牛应用中心升级。`manifest.changelog` 应面向用户简洁表达本次变更;
|
||||
Havline 内部升级页面、`havline-updater` sidecar 和 Docker Socket 默认不进入 fnOS 包。
|
||||
|
||||
11. **端口入口和容器端口必须区分**
|
||||
|
||||
`app/ui/config` 的 `port` 支持使用向导变量,因此管理入口可以写成:
|
||||
|
||||
```json
|
||||
"port": "${wizard_service_port}"
|
||||
```
|
||||
|
||||
Compose 中应把宿主机端口映射到 Havline 容器内的固定端口:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "${wizard_service_port}:6893"
|
||||
- "${wizard_nginx_http_port}:80"
|
||||
- "${wizard_nginx_https_port}:443"
|
||||
```
|
||||
|
||||
不要把 `18080` 或 `9443` 当成容器内部监听端口。它们是宿主机端口,容器内仍然是 `80/443`。
|
||||
|
||||
### 19.3 建议的首发实施顺序
|
||||
|
||||
阶段 1 只做“能安装、能打开、能保留数据”:
|
||||
|
||||
1. 用 `fnpack create havline --template docker` 生成标准骨架。
|
||||
2. 改写 `manifest`,确定 `appname=havline`、`service_port` 和桌面入口。
|
||||
3. 编写 Havline 专用 `app/docker/docker-compose.yaml`。
|
||||
4. 编写 `cmd/main` 容器状态检查。
|
||||
5. 编写 `wizard/install`,只收集端口、时区和初始管理员密码。
|
||||
6. 编写 `cmd/install_callback`,创建数据目录、修正权限并完成首次初始化。
|
||||
7. 构建 amd64 / arm64 镜像。
|
||||
8. 用 `fnpack build` 打包并在飞牛测试设备安装验证。
|
||||
|
||||
当前打包工程位于主仓库同级的飞牛工作目录中,不进入主仓库:
|
||||
|
||||
```text
|
||||
F:\Fonu-main\fnos\
|
||||
├── fnpack.exe
|
||||
├── build.ps1
|
||||
├── README.md
|
||||
└── havline/
|
||||
```
|
||||
|
||||
主程序仍位于 `F:\Fonu-main\havline`,打包工程通过主仓库的 `VERSION` 和
|
||||
`fnos/build.ps1` 保持版本同步。
|
||||
|
||||
阶段 1 暂不实现:
|
||||
|
||||
- Havline 自带 updater;
|
||||
- Docker Socket;
|
||||
- 统一网关 Unix Socket;
|
||||
- 高级 host 网络模式;
|
||||
- 自动迁移旧 Docker Compose 数据。
|
||||
|
||||
### 19.4 首发验收清单
|
||||
|
||||
```text
|
||||
[ ] fnpack 模板生成的项目结构与官方一致
|
||||
[ ] manifest 必填字段完整
|
||||
[ ] service_port、入口 port、Compose 映射三处一致
|
||||
[ ] cmd/main status 与 Compose 容器名一致
|
||||
[ ] wizard 使用 password 类型收集初始密码
|
||||
[ ] 安装回调创建并修正 TRIM_PKGVAR 权限
|
||||
[ ] 数据目录持久化,升级后 session_secret 不变
|
||||
[ ] 默认不挂载 Docker Socket
|
||||
[ ] 默认关闭 Havline updater
|
||||
[ ] amd64 与 arm64 镜像均可拉取并启动
|
||||
[ ] 卸载默认保留数据,明确选择后才删除
|
||||
[ ] TRIM_TEMP_LOGFILE 能输出可读错误
|
||||
```
|
||||
|
||||
### 19.5 结论
|
||||
|
||||
现有方案不需要推倒重来,主要修正点是把手工设计的包结构收敛到官方
|
||||
`fnpack create --template docker` 模板,并补齐向导密码、状态检查、
|
||||
路径变量、生命周期错误输出和双架构镜像验证。
|
||||
|
||||
@@ -0,0 +1,736 @@
|
||||
# Havline 飞牛 Native 应用改造方案
|
||||
|
||||
> 日期:2026-10-02
|
||||
> 依据:飞牛应用开放平台 Native 应用案例、统一网关、应用权限、环境变量、应用资源
|
||||
> 目标:把 Havline 做成完全不依赖 Docker 的飞牛原生应用
|
||||
|
||||
## 一、结论
|
||||
|
||||
技术上可行,但不建议直接把当前离线 Docker 包替换掉。
|
||||
|
||||
推荐路线:
|
||||
|
||||
```text
|
||||
Go 主程序原生运行
|
||||
+ 统一网关 Unix Socket
|
||||
+ 打包 Nginx 双架构二进制
|
||||
+ frpc / cloudflared 按需打包或运行时下载
|
||||
+ cmd/main 管理进程生命周期
|
||||
```
|
||||
|
||||
最终形态:
|
||||
|
||||
```text
|
||||
不依赖 Docker Engine
|
||||
不依赖 Docker Compose
|
||||
不依赖外部镜像仓库
|
||||
管理界面通过 /app/havline 统一网关访问
|
||||
公网反代功能通过高位端口运行
|
||||
```
|
||||
|
||||
主要风险:
|
||||
|
||||
```text
|
||||
Nginx 双架构打包与动态库依赖
|
||||
非 root 用户无法绑定 80/443
|
||||
进程启动、停止、状态和崩溃恢复
|
||||
frpc / cloudflared 架构与版本管理
|
||||
真机升级和回滚验证
|
||||
```
|
||||
|
||||
## 二、官方 Native 应用模型
|
||||
|
||||
官方 Native 应用案例说明:
|
||||
|
||||
```text
|
||||
Native 应用是在飞牛 fnOS 上运行的进程
|
||||
可以提供 Web UI、后台服务、CLI 工具
|
||||
推荐使用统一网关 /app/{appname}
|
||||
服务监听 ${TRIM_APPDEST}/app.sock
|
||||
cmd/main 负责 start、stop、status
|
||||
应用文件放在 ${TRIM_APPDEST}
|
||||
数据放在 ${TRIM_PKGVAR}
|
||||
```
|
||||
|
||||
官方 Notepad 示例的关键结构:
|
||||
|
||||
```text
|
||||
notepad/
|
||||
├── app/
|
||||
│ ├── server/
|
||||
│ └── ui/config
|
||||
├── cmd/
|
||||
│ └── main
|
||||
├── config/
|
||||
│ ├── privilege
|
||||
│ └── resource
|
||||
├── manifest
|
||||
├── ICON.PNG
|
||||
└── ICON_256.PNG
|
||||
```
|
||||
|
||||
启动脚本核心逻辑:
|
||||
|
||||
```bash
|
||||
APP_DIR="${TRIM_APPDEST}/server"
|
||||
SOCKET_PATH="${TRIM_APPDEST}/app.sock"
|
||||
|
||||
start)
|
||||
cd "$APP_DIR"
|
||||
SOCKET_PATH="$SOCKET_PATH" ./havline &
|
||||
echo "$!" > "$PID_FILE"
|
||||
;;
|
||||
|
||||
stop)
|
||||
kill -TERM "$(cat "$PID_FILE")"
|
||||
rm -f "$SOCKET_PATH" "$PID_FILE"
|
||||
;;
|
||||
|
||||
status)
|
||||
kill -0 "$(cat "$PID_FILE")"
|
||||
;;
|
||||
```
|
||||
|
||||
## 三、Havline 依赖清单
|
||||
|
||||
### 3.1 Go 主程序
|
||||
|
||||
可以直接编译为 Linux 静态二进制:
|
||||
|
||||
```text
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build
|
||||
```
|
||||
|
||||
优点:
|
||||
|
||||
```text
|
||||
SQLite 使用 modernc.org/sqlite,纯 Go,不依赖 CGO
|
||||
前端已经通过 go:embed 内嵌
|
||||
ACME、DDNS、通知、备份均为 Go 实现
|
||||
不需要 Node.js、Python 或 Java 运行时
|
||||
```
|
||||
|
||||
### 3.2 必需外部依赖
|
||||
|
||||
| 依赖 | 用途 | 原生方案 |
|
||||
| --- | --- | --- |
|
||||
| Nginx | 反向代理、HTTPS、证书加载 | 必须打包双架构二进制 |
|
||||
| mime.types | Nginx MIME 配置 | 随包分发 |
|
||||
| ca-certificates | HTTPS 客户端信任 | 使用系统证书或随包分发 |
|
||||
| tzdata | 时区 | 使用系统时区或随包分发 |
|
||||
|
||||
### 3.3 可选外部依赖
|
||||
|
||||
| 依赖 | 用途 | 原生方案 |
|
||||
| --- | --- | --- |
|
||||
| frpc | FRP 内网穿透 | 打包双架构或运行时下载 |
|
||||
| cloudflared | Cloudflare Tunnel | 打包双架构或运行时下载 |
|
||||
| ssh | Agent 远程安装 | 使用系统 SSH |
|
||||
| systemctl | 公网 Agent 管理 frps | 仅 Agent 场景需要 |
|
||||
| ss | 端口诊断 | 使用系统工具 |
|
||||
|
||||
### 3.4 可以移除的依赖
|
||||
|
||||
原生应用不需要:
|
||||
|
||||
```text
|
||||
Docker Engine
|
||||
Docker Compose
|
||||
Docker Socket
|
||||
havline-updater sidecar
|
||||
宿主机 Compose 文件
|
||||
```
|
||||
|
||||
## 四、目标包结构
|
||||
|
||||
建议新增独立工程:
|
||||
|
||||
```text
|
||||
fnos-native/
|
||||
└── havline/
|
||||
├── manifest
|
||||
├── ICON.PNG
|
||||
├── ICON_256.PNG
|
||||
├── app/
|
||||
│ ├── server/
|
||||
│ │ ├── amd64/
|
||||
│ │ │ ├── havline
|
||||
│ │ │ └── nginx
|
||||
│ │ ├── arm64/
|
||||
│ │ │ ├── havline
|
||||
│ │ │ └── nginx
|
||||
│ │ ├── mime.types
|
||||
│ │ └── VERSION
|
||||
│ └── ui/
|
||||
│ ├── config
|
||||
│ └── images/
|
||||
├── cmd/
|
||||
│ ├── main
|
||||
│ ├── install_callback
|
||||
│ ├── upgrade_callback
|
||||
│ ├── config_callback
|
||||
│ └── uninstall_callback
|
||||
├── config/
|
||||
│ ├── privilege
|
||||
│ └── resource
|
||||
└── wizard/
|
||||
├── install
|
||||
├── config
|
||||
└── uninstall
|
||||
```
|
||||
|
||||
安装后:
|
||||
|
||||
```text
|
||||
/var/apps/havline/
|
||||
├── target/
|
||||
│ ├── server/
|
||||
│ │ ├── amd64/
|
||||
│ │ ├── arm64/
|
||||
│ │ └── mime.types
|
||||
│ └── app.sock
|
||||
├── var/
|
||||
│ ├── havline.db
|
||||
│ ├── session_secret
|
||||
│ ├── nginx/
|
||||
│ ├── certs/
|
||||
│ ├── frp/
|
||||
│ └── cloudflared/
|
||||
└── cmd/
|
||||
```
|
||||
|
||||
## 五、运行架构
|
||||
|
||||
```text
|
||||
cmd/main start
|
||||
-> 选择当前架构目录
|
||||
-> 设置 HAVLINE_DATA_DIR=${TRIM_PKGVAR}
|
||||
-> 设置 HAVLINE_NGINX_BIN=${TRIM_APPDEST}/server/<arch>/nginx
|
||||
-> 设置 HAVLINE_NGINX_MIME_TYPES=${TRIM_APPDEST}/server/mime.types
|
||||
-> 设置 HAVLINE_GATEWAY_SOCKET=${TRIM_APPDEST}/app.sock
|
||||
-> 启动 havline 主程序
|
||||
-> 写入 PID 文件
|
||||
|
||||
havline 主程序
|
||||
-> 监听 Unix Socket 供统一网关访问
|
||||
-> 启动 Nginx 子进程
|
||||
-> 按需启动 frpc / cloudflared
|
||||
-> 管理数据库、证书、DDNS、反代规则
|
||||
|
||||
cmd/main stop
|
||||
-> 发送 SIGTERM
|
||||
-> 等待主程序退出
|
||||
-> 主程序负责停止 Nginx 和可选子进程
|
||||
-> 清理 app.sock 和 PID 文件
|
||||
|
||||
cmd/main status
|
||||
-> 检查 PID 是否存活
|
||||
-> 存活返回 0,否则返回 3
|
||||
```
|
||||
|
||||
## 六、统一网关接入
|
||||
|
||||
`app/ui/config`:
|
||||
|
||||
```json
|
||||
{
|
||||
".url": {
|
||||
"havline.main": {
|
||||
"title": "Havline",
|
||||
"icon": "images/icon_{0}.png",
|
||||
"type": "iframe",
|
||||
"protocol": "",
|
||||
"gatewayPrefix": "/app/havline",
|
||||
"gatewaySocket": "app.sock",
|
||||
"url": "/app/havline",
|
||||
"allUsers": false,
|
||||
"control": {
|
||||
"accessPerm": "readonly"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
主程序需要新增:
|
||||
|
||||
```text
|
||||
HAVLINE_GATEWAY_SOCKET=${TRIM_APPDEST}/app.sock
|
||||
HAVLINE_GATEWAY_PREFIX=/app/havline
|
||||
```
|
||||
|
||||
监听方式:
|
||||
|
||||
```go
|
||||
listener, err := net.Listen("unix", cfg.GatewaySocket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
os.Chmod(cfg.GatewaySocket, 0o660)
|
||||
server.Serve(listener)
|
||||
```
|
||||
|
||||
前端需要支持:
|
||||
|
||||
```text
|
||||
Vite base = /app/havline/
|
||||
Vue Router base = /app/havline/
|
||||
API base = /app/havline/api
|
||||
SSE base = /app/havline/api/...
|
||||
WebSocket base = /app/havline/ws
|
||||
Cookie Path = /app/havline 或 /
|
||||
```
|
||||
|
||||
## 七、Nginx 原生打包
|
||||
|
||||
这是整个改造最大的技术点。
|
||||
|
||||
### 7.1 可选方案
|
||||
|
||||
| 方案 | 优点 | 缺点 |
|
||||
| --- | --- | --- |
|
||||
| 打包静态 Nginx | 不依赖系统 Nginx | 需要自行构建双架构 |
|
||||
| 打包动态 Nginx + 依赖库 | 接近发行版行为 | 需要处理 .so 和 LD_LIBRARY_PATH |
|
||||
| 使用飞牛系统 Nginx | 包体小 | 版本、路径、配置目录不可控 |
|
||||
| 改用 Go 内置反向代理 | 完全纯 Go | Havline 现有 Nginx 功能需要重写 |
|
||||
|
||||
推荐:
|
||||
|
||||
```text
|
||||
打包静态 Nginx 或半静态 Nginx
|
||||
amd64 与 arm64 各一份
|
||||
随包提供 mime.types
|
||||
```
|
||||
|
||||
### 7.2 Nginx 配置调整
|
||||
|
||||
原生应用以 package 用户运行,不能直接绑定 80/443。
|
||||
|
||||
建议:
|
||||
|
||||
```text
|
||||
管理界面:统一网关 Unix Socket
|
||||
Nginx HTTP:18080
|
||||
Nginx HTTPS:9443
|
||||
```
|
||||
|
||||
如果确实需要 80/443:
|
||||
|
||||
```text
|
||||
使用 root 生命周期脚本启动 Nginx master
|
||||
Nginx worker 降权到 package 用户
|
||||
或者给二进制授予 CAP_NET_BIND_SERVICE
|
||||
```
|
||||
|
||||
不推荐让整个 Havline 长期以 root 运行。
|
||||
|
||||
### 7.3 Nginx 目录
|
||||
|
||||
建议:
|
||||
|
||||
```text
|
||||
${TRIM_APPDEST}/server/<arch>/nginx
|
||||
${TRIM_APPDEST}/server/mime.types
|
||||
${TRIM_PKGVAR}/nginx/
|
||||
${TRIM_PKGVAR}/certs/
|
||||
${TRIM_PKGVAR}/logs/
|
||||
```
|
||||
|
||||
环境变量:
|
||||
|
||||
```text
|
||||
HAVLINE_NGINX_BIN=${TRIM_APPDEST}/server/<arch>/nginx
|
||||
HAVLINE_NGINX_MIME_TYPES=${TRIM_APPDEST}/server/mime.types
|
||||
HAVLINE_NGINX_PID=${TRIM_PKGVAR}/nginx/nginx.pid
|
||||
HAVLINE_NGINX_HTTP_PORT=18080
|
||||
HAVLINE_NGINX_HTTPS_PORT=9443
|
||||
```
|
||||
|
||||
## 八、frpc 与 cloudflared
|
||||
|
||||
这两个是可选功能依赖。
|
||||
|
||||
### 方案 A:随包分发
|
||||
|
||||
```text
|
||||
app/server/amd64/frpc
|
||||
app/server/arm64/frpc
|
||||
app/server/amd64/cloudflared
|
||||
app/server/arm64/cloudflared
|
||||
```
|
||||
|
||||
优点:
|
||||
|
||||
```text
|
||||
完全离线
|
||||
版本可控
|
||||
```
|
||||
|
||||
缺点:
|
||||
|
||||
```text
|
||||
包体变大
|
||||
每次升级 Havline 都要同步升级二进制
|
||||
```
|
||||
|
||||
### 方案 B:运行时按需下载
|
||||
|
||||
保持 Havline 现有下载逻辑,但把下载目录指向:
|
||||
|
||||
```text
|
||||
${TRIM_PKGVAR}/frp/
|
||||
${TRIM_PKGVAR}/cloudflared/
|
||||
```
|
||||
|
||||
优点:
|
||||
|
||||
```text
|
||||
FPK 更小
|
||||
```
|
||||
|
||||
缺点:
|
||||
|
||||
```text
|
||||
首次使用需要网络
|
||||
下载源可能被墙
|
||||
需要校验 SHA256
|
||||
```
|
||||
|
||||
推荐首版:
|
||||
|
||||
```text
|
||||
frpc:随包分发
|
||||
cloudflared:随包分发或首次使用时下载
|
||||
```
|
||||
|
||||
## 九、数据目录与迁移
|
||||
|
||||
原生应用数据目录:
|
||||
|
||||
```text
|
||||
${TRIM_PKGVAR}
|
||||
```
|
||||
|
||||
需要保留:
|
||||
|
||||
```text
|
||||
havline.db
|
||||
session_secret
|
||||
nginx/
|
||||
certs/
|
||||
frp/
|
||||
cloudflared/
|
||||
logs/
|
||||
ssh_known_hosts
|
||||
```
|
||||
|
||||
从当前离线 Docker 包迁移:
|
||||
|
||||
```text
|
||||
Docker 包数据目录:${TRIM_PKGVAR}
|
||||
原生包数据目录:${TRIM_PKGVAR}
|
||||
```
|
||||
|
||||
如果两个应用使用同一个 appname,路径可能相同。
|
||||
|
||||
迁移步骤:
|
||||
|
||||
```text
|
||||
1. 停止 Docker 版 Havline
|
||||
2. 备份数据目录
|
||||
3. 安装原生版
|
||||
4. 确认 session_secret 不变
|
||||
5. 启动原生版
|
||||
6. 验证数据库、证书、规则、隧道
|
||||
```
|
||||
|
||||
## 十、生命周期脚本
|
||||
|
||||
### 10.1 cmd/main
|
||||
|
||||
职责:
|
||||
|
||||
```text
|
||||
start:选择架构、设置环境变量、启动 havline、写 PID
|
||||
stop:发送 SIGTERM、等待退出、清理 PID 和 Socket
|
||||
status:检查 PID 是否存活
|
||||
```
|
||||
|
||||
关键环境变量:
|
||||
|
||||
```bash
|
||||
APP_DIR="${TRIM_APPDEST}/server"
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64|amd64) ARCH_DIR="amd64" ;;
|
||||
aarch64|arm64) ARCH_DIR="arm64" ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
|
||||
export HAVLINE_DATA_DIR="$TRIM_PKGVAR"
|
||||
export HAVLINE_NGINX_BIN="$APP_DIR/$ARCH_DIR/nginx"
|
||||
export HAVLINE_NGINX_MIME_TYPES="$APP_DIR/mime.types"
|
||||
export HAVLINE_NGINX_HTTP_PORT=18080
|
||||
export HAVLINE_NGINX_HTTPS_PORT=9443
|
||||
export HAVLINE_GATEWAY_SOCKET="$TRIM_APPDEST/app.sock"
|
||||
export HAVLINE_GATEWAY_PREFIX="/app/havline"
|
||||
export HAVLINE_UPDATER_SOCKET=""
|
||||
```
|
||||
|
||||
### 10.2 install_callback
|
||||
|
||||
职责:
|
||||
|
||||
```text
|
||||
创建 TRIM_PKGVAR
|
||||
设置目录权限
|
||||
复制默认配置
|
||||
检查二进制架构
|
||||
检查 Nginx 可执行
|
||||
```
|
||||
|
||||
### 10.3 upgrade_callback
|
||||
|
||||
职责:
|
||||
|
||||
```text
|
||||
检查数据库和 session_secret
|
||||
保留数据目录
|
||||
检查新二进制架构
|
||||
必要时迁移 Nginx 配置
|
||||
```
|
||||
|
||||
### 10.4 uninstall_callback
|
||||
|
||||
职责:
|
||||
|
||||
```text
|
||||
默认保留 TRIM_PKGVAR
|
||||
用户选择 purge 时删除数据
|
||||
清理 app.sock 和 PID 文件
|
||||
```
|
||||
|
||||
## 十一、多架构构建
|
||||
|
||||
构建脚本需要输出:
|
||||
|
||||
```text
|
||||
amd64/havline
|
||||
arm64/havline
|
||||
amd64/nginx
|
||||
arm64/nginx
|
||||
```
|
||||
|
||||
Go 二进制:
|
||||
|
||||
```bash
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o amd64/havline ./cmd/havline
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -o arm64/havline ./cmd/havline
|
||||
```
|
||||
|
||||
Nginx:
|
||||
|
||||
```text
|
||||
从静态构建产物复制
|
||||
或从容器镜像中提取
|
||||
并检查动态库依赖
|
||||
```
|
||||
|
||||
检查:
|
||||
|
||||
```bash
|
||||
file amd64/havline
|
||||
file arm64/havline
|
||||
ldd amd64/nginx
|
||||
ldd arm64/nginx
|
||||
```
|
||||
|
||||
## 十二、分阶段实施
|
||||
|
||||
### 阶段 1:Native PoC
|
||||
|
||||
目标:不打包 Nginx,先验证 Go 主程序原生运行。
|
||||
|
||||
内容:
|
||||
|
||||
```text
|
||||
编译 amd64/arm64 Go 二进制
|
||||
统一网关 Unix Socket
|
||||
cmd/main start/stop/status
|
||||
TRIM_PKGVAR 数据目录
|
||||
```
|
||||
|
||||
验收:
|
||||
|
||||
```text
|
||||
[ ] 应用可安装
|
||||
[ ] /app/havline 可打开
|
||||
[ ] 进程可启停
|
||||
[ ] 数据可持久化
|
||||
```
|
||||
|
||||
### 阶段 2:Nginx 打包
|
||||
|
||||
目标:让反向代理功能原生可用。
|
||||
|
||||
内容:
|
||||
|
||||
```text
|
||||
静态 Nginx 双架构
|
||||
mime.types
|
||||
18080/9443 端口
|
||||
证书目录
|
||||
nginx -t 校验
|
||||
```
|
||||
|
||||
验收:
|
||||
|
||||
```text
|
||||
[ ] Nginx 可启动
|
||||
[ ] 配置可校验
|
||||
[ ] HTTPS 可加载
|
||||
[ ] 反向代理规则生效
|
||||
```
|
||||
|
||||
### 阶段 3:frpc / cloudflared
|
||||
|
||||
目标:恢复 FRP 和 Cloudflare Tunnel 功能。
|
||||
|
||||
内容:
|
||||
|
||||
```text
|
||||
双架构二进制
|
||||
或按需下载
|
||||
SHA256 校验
|
||||
进程管理
|
||||
日志
|
||||
```
|
||||
|
||||
### 阶段 4:升级与迁移
|
||||
|
||||
目标:从 Docker 包迁移到原生包。
|
||||
|
||||
内容:
|
||||
|
||||
```text
|
||||
数据迁移
|
||||
session_secret 保留
|
||||
版本兼容
|
||||
回滚方案
|
||||
```
|
||||
|
||||
### 阶段 5:真机验证
|
||||
|
||||
目标:x86_64 与 ARM 设备安装验证。
|
||||
|
||||
内容:
|
||||
|
||||
```text
|
||||
安装、启动、停止
|
||||
统一网关入口
|
||||
反向代理
|
||||
证书
|
||||
FRP
|
||||
Cloudflare
|
||||
升级
|
||||
卸载
|
||||
```
|
||||
|
||||
## 十三、验收清单
|
||||
|
||||
```text
|
||||
[ ] 不依赖 Docker Engine
|
||||
[ ] 不依赖 Docker Compose
|
||||
[ ] 不依赖外部镜像仓库
|
||||
[ ] Go 二进制支持 amd64 与 arm64
|
||||
[ ] Nginx 支持 amd64 与 arm64
|
||||
[ ] 管理界面通过 /app/havline 打开
|
||||
[ ] Unix Socket 权限正确
|
||||
[ ] 主程序可启动、停止、检查状态
|
||||
[ ] 数据写入 TRIM_PKGVAR
|
||||
[ ] session_secret 升级后不变
|
||||
[ ] Nginx 反向代理正常
|
||||
[ ] HTTPS 证书正常
|
||||
[ ] FRP 功能正常
|
||||
[ ] Cloudflare Tunnel 功能正常
|
||||
[ ] 升级不丢数据
|
||||
[ ] 卸载默认保留数据
|
||||
```
|
||||
|
||||
## 十四、风险与工作量
|
||||
|
||||
| 风险 | 影响 | 处理 |
|
||||
| --- | --- | --- |
|
||||
| Nginx 双架构打包 | 原生版无法启动 | 阶段 2 专项验证 |
|
||||
| Nginx 动态库依赖 | 目标设备缺库 | 静态编译或随包分发 .so |
|
||||
| 非 root 绑定 80/443 | 端口权限失败 | 使用 18080/9443 |
|
||||
| 进程崩溃 | 服务中断 | PID 检查、重启策略、日志 |
|
||||
| Socket 权限 | 网关无法访问 | 启动时 chmod |
|
||||
| 前端绝对路径 | 网关下 404 | 统一 base path |
|
||||
| frpc/cloudflared 版本 | 功能不可用 | 随包分发并校验 SHA256 |
|
||||
| 数据迁移 | 凭据无法解密 | 保留 session_secret |
|
||||
| 升级替换二进制 | 运行中进程被覆盖 | stop 后再替换 |
|
||||
| ARM 真机差异 | 测试不充分 | 真机验证 |
|
||||
|
||||
工作量估算:
|
||||
|
||||
```text
|
||||
阶段 1:2 到 3 天
|
||||
阶段 2:3 到 5 天
|
||||
阶段 3:2 到 4 天
|
||||
阶段 4:2 到 3 天
|
||||
阶段 5:3 到 5 天
|
||||
合计:2 到 4 周
|
||||
```
|
||||
|
||||
## 十五、与离线 Docker 包对比
|
||||
|
||||
| 项目 | 离线 Docker 包 | 原生应用 |
|
||||
| --- | --- | --- |
|
||||
| 依赖 Docker | 是 | 否 |
|
||||
| 依赖仓库 | 否 | 否 |
|
||||
| 包体 | 约 150 MB | 预计 40 到 80 MB |
|
||||
| 改造量 | 已完成 | 2 到 4 周 |
|
||||
| Nginx | 容器内已有 | 需要自行打包 |
|
||||
| 升级 | Docker load | 替换二进制 |
|
||||
| 进程管理 | Docker Compose | cmd/main |
|
||||
| 风险 | 低 | 高 |
|
||||
| 推荐度 | 当前优先 | 长期演进 |
|
||||
|
||||
## 十六、建议
|
||||
|
||||
当前阶段建议:
|
||||
|
||||
```text
|
||||
先发布并验证离线 Docker 包
|
||||
保留统一网关改造方案
|
||||
原生应用作为独立分支推进
|
||||
```
|
||||
|
||||
原生应用的推荐顺序:
|
||||
|
||||
```text
|
||||
1. 先做 Go 主程序 + 统一网关 PoC
|
||||
2. 再做 Nginx 双架构打包
|
||||
3. 再恢复 FRP 和 Cloudflare
|
||||
4. 最后做数据迁移和真机升级
|
||||
```
|
||||
|
||||
只有在以下条件满足时,才建议把原生应用作为正式发布形态:
|
||||
|
||||
```text
|
||||
Nginx 双架构在真实设备稳定运行
|
||||
统一网关 Socket 权限稳定
|
||||
升级和回滚验证通过
|
||||
frpc / cloudflared 架构验证通过
|
||||
```
|
||||
|
||||
## 十七、官方参考
|
||||
|
||||
- Native 应用案例:<https://developer.fnnas.com/docs/examples/native/>
|
||||
- 统一网关:<https://developer.fnnas.com/docs/core-concepts/gateway-registration/>
|
||||
- 应用权限:<https://developer.fnnas.com/docs/core-concepts/privilege/>
|
||||
- 环境变量:<https://developer.fnnas.com/docs/core-concepts/environment-variables/>
|
||||
- 应用资源:<https://developer.fnnas.com/docs/core-concepts/resource/>
|
||||
- 应用入口:<https://developer.fnnas.com/docs/core-concepts/app-entry/>
|
||||
@@ -0,0 +1,890 @@
|
||||
# Havline 飞牛统一网关应用改造方案
|
||||
|
||||
> 日期:2026-10-02
|
||||
> 适用范围:`fnos/havline` 飞牛应用包与 Havline 主程序 1.9.x
|
||||
> 官方依据:飞牛应用开放平台「统一网关」「应用入口」「应用资源」「环境变量」「Docker 应用案例」
|
||||
|
||||
## 一、结论先行
|
||||
|
||||
统一网关不是 Docker 应用模板的替代品,而是飞牛应用的一种**访问模型**。
|
||||
|
||||
官方文档明确说明:Docker 应用也可以使用统一网关。做法是把
|
||||
`${TRIM_APPDEST}` 挂载到容器内,让容器中的服务在安装目录下创建
|
||||
Unix Socket,再由 fnOS 网关转发请求。
|
||||
|
||||
因此 Havline 的正确改造方向是:
|
||||
|
||||
```text
|
||||
Docker 运行形态(保留)
|
||||
+ 统一网关访问模型(新增)
|
||||
+ 端口服务回退(保留)
|
||||
```
|
||||
|
||||
而不是把现有 Docker 应用整体推翻重做。
|
||||
|
||||
推荐首版边界:
|
||||
|
||||
```text
|
||||
管理界面:
|
||||
通过 /app/havline 和 Unix Socket 暴露
|
||||
|
||||
公网反代、DDNS、证书、FRP、Cloudflare Tunnel:
|
||||
仍由 Havline 自己管理
|
||||
|
||||
端口 18080/9443:
|
||||
作为公网反代功能端口和回退入口保留
|
||||
```
|
||||
|
||||
## 二、统一网关是什么
|
||||
|
||||
飞牛统一网关为应用提供飞牛 fnOS 访问域名下的稳定系统 URL。
|
||||
|
||||
请求路径:
|
||||
|
||||
```text
|
||||
/app/{appname}
|
||||
/app/{appname}/{customPath}
|
||||
```
|
||||
|
||||
工作流程:
|
||||
|
||||
1. 用户在飞牛桌面打开应用。
|
||||
2. fnOS 先校验用户会话。
|
||||
3. fnOS 将请求转发到应用声明的 Unix Socket。
|
||||
4. 应用读取网关注入的用户 Header。
|
||||
5. 应用继续执行自己的业务权限校验。
|
||||
|
||||
统一网关与端口服务的对比:
|
||||
|
||||
| 能力 | 端口服务 | 统一网关 |
|
||||
| --- | --- | --- |
|
||||
| 访问地址 | 独立端口 | `/app/{appname}` |
|
||||
| NAS 登录态 | 不接入 | 转发前校验 |
|
||||
| 用户上下文 | 无 | `X-Trim-*` Header |
|
||||
| WebSocket | 应用自行处理 | 支持 |
|
||||
| 常驻服务 | 支持 | 支持 |
|
||||
| 部署形态 | 任意 | 任意,Docker 也支持 |
|
||||
| 核心适配 | 监听端口 | 监听 Unix Socket |
|
||||
|
||||
## 三、官方字段与约束
|
||||
|
||||
### 3.1 应用入口
|
||||
|
||||
统一网关入口写在 `app/ui/config`:
|
||||
|
||||
```json
|
||||
{
|
||||
".url": {
|
||||
"havline.main": {
|
||||
"title": "Havline",
|
||||
"icon": "images/icon_{0}.png",
|
||||
"type": "iframe",
|
||||
"protocol": "",
|
||||
"gatewayPrefix": "/app/havline",
|
||||
"gatewaySocket": "app.sock",
|
||||
"url": "/app/havline",
|
||||
"allUsers": false,
|
||||
"control": {
|
||||
"accessPerm": "readonly"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段规则:
|
||||
|
||||
```text
|
||||
gatewayPrefix
|
||||
必须使用 /app/{appname} 或 /app/{appname}/{customPath}
|
||||
发布后应保持稳定
|
||||
|
||||
gatewaySocket
|
||||
只填写 Socket 文件名,例如 app.sock
|
||||
实际路径为 ${TRIM_APPDEST}/app.sock
|
||||
|
||||
protocol / port
|
||||
在统一网关入口中被忽略,不参与路由
|
||||
```
|
||||
|
||||
### 3.2 Docker 应用接入
|
||||
|
||||
官方 Docker 案例给出的结构:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
web:
|
||||
image: my-app:latest
|
||||
container_name: hello-docker-web
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- "${TRIM_APPDEST}:/app/target:rw"
|
||||
environment:
|
||||
GATEWAY_SOCKET: /app/target/app.sock
|
||||
```
|
||||
|
||||
容器内服务监听:
|
||||
|
||||
```text
|
||||
/app/target/app.sock
|
||||
```
|
||||
|
||||
宿主机对应路径:
|
||||
|
||||
```text
|
||||
/var/apps/{appname}/target/app.sock
|
||||
```
|
||||
|
||||
### 3.3 用户 Header
|
||||
|
||||
网关校验会话后注入:
|
||||
|
||||
| Header | 含义 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| `X-Trim-Userid` | 当前用户 UID | `1000` |
|
||||
| `X-Trim-Isadmin` | 是否管理员 | `true` / `false` |
|
||||
| `X-Trim-Username` | 当前用户名 | `admin` |
|
||||
|
||||
这些 Header 只能作为网关可信上下文使用。应用仍然需要校验:
|
||||
|
||||
```text
|
||||
接口权限
|
||||
资源归属
|
||||
文件路径
|
||||
高风险操作
|
||||
```
|
||||
|
||||
不能信任客户端自己传入的用户 ID。
|
||||
|
||||
## 四、当前 Havline 的差距
|
||||
|
||||
### 4.1 当前监听方式
|
||||
|
||||
```text
|
||||
HAVLINE_LISTEN=:6893
|
||||
```
|
||||
|
||||
主程序使用 TCP 端口监听:
|
||||
|
||||
```text
|
||||
internal/config/config.go
|
||||
ListenAddr = HAVLINE_LISTEN,默认 :6893
|
||||
|
||||
internal/app/app.go
|
||||
http.Server{Addr: cfg.ListenAddr}
|
||||
server.ListenAndServe()
|
||||
```
|
||||
|
||||
当前没有 Unix Socket 监听能力。
|
||||
|
||||
### 4.2 当前飞牛入口
|
||||
|
||||
```json
|
||||
{
|
||||
".url": {
|
||||
"havline.main": {
|
||||
"title": "Havline",
|
||||
"icon": "images/icon_{0}.png",
|
||||
"type": "iframe",
|
||||
"protocol": "http",
|
||||
"port": "${wizard_service_port}",
|
||||
"url": "/",
|
||||
"allUsers": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这是端口服务入口,不是统一网关入口。
|
||||
|
||||
### 4.3 当前前端路径假设
|
||||
|
||||
```text
|
||||
Vue Router:
|
||||
createWebHistory(),无 base
|
||||
|
||||
API 客户端:
|
||||
直接使用 /api/... 绝对路径
|
||||
|
||||
SSE / EventSource:
|
||||
/api/proxies/{id}/logs/stream
|
||||
|
||||
静态资源:
|
||||
由 Vite 默认根路径构建
|
||||
```
|
||||
|
||||
统一网关路径是 `/app/havline`,这些绝对路径不能直接复用。
|
||||
|
||||
### 4.4 当前容器权限
|
||||
|
||||
Dockerfile 没有显式 `USER`,容器默认以 root 运行。
|
||||
|
||||
这对创建 Socket 文件是有利的:
|
||||
|
||||
```text
|
||||
/app/target/app.sock
|
||||
```
|
||||
|
||||
但如果后续改为非 root 用户运行,需要额外处理 Socket 的属主和权限。
|
||||
|
||||
### 4.5 当前端口映射
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "${wizard_service_port}:6893"
|
||||
- "${wizard_nginx_http_port}:80"
|
||||
- "${wizard_nginx_https_port}:443"
|
||||
```
|
||||
|
||||
统一网关只替代管理界面的入口,不会替代 Havline 的反向代理端口。
|
||||
|
||||
## 五、目标架构
|
||||
|
||||
### 5.1 访问分层
|
||||
|
||||
```text
|
||||
飞牛桌面
|
||||
-> https://fnOS域名/app/havline
|
||||
-> 飞牛统一网关校验登录态
|
||||
-> /var/apps/havline/target/app.sock
|
||||
-> Havline 网关监听器
|
||||
-> Havline API / Web UI
|
||||
|
||||
Havline 公网反代功能
|
||||
-> 18080/9443 端口
|
||||
-> Havline 内置 Nginx
|
||||
-> 用户配置的 NAS 服务
|
||||
```
|
||||
|
||||
### 5.2 包结构目标
|
||||
|
||||
```text
|
||||
fnos/havline/
|
||||
├── manifest
|
||||
├── app/
|
||||
│ ├── docker/
|
||||
│ │ └── docker-compose.yaml
|
||||
│ └── ui/
|
||||
│ └── config
|
||||
├── config/
|
||||
│ ├── privilege
|
||||
│ └── resource
|
||||
├── cmd/
|
||||
│ ├── main
|
||||
│ ├── install_callback
|
||||
│ ├── upgrade_callback
|
||||
│ ├── config_callback
|
||||
│ └── uninstall_callback
|
||||
└── wizard/
|
||||
├── install
|
||||
├── config
|
||||
└── uninstall
|
||||
```
|
||||
|
||||
### 5.3 Docker Compose 目标
|
||||
|
||||
```yaml
|
||||
services:
|
||||
havline:
|
||||
image: jayhub.top/mrlee/havline:${TRIM_APPVER}-amd64-docker
|
||||
container_name: havline
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${wizard_nginx_http_port}:80"
|
||||
- "${wizard_nginx_https_port}:443"
|
||||
volumes:
|
||||
- "${TRIM_PKGVAR}:/data"
|
||||
- "${TRIM_APPDEST}:/app/target:rw"
|
||||
environment:
|
||||
HAVLINE_DATA_DIR: /data
|
||||
HAVLINE_LISTEN: ":6893"
|
||||
HAVLINE_GATEWAY_SOCKET: "/app/target/app.sock"
|
||||
HAVLINE_GATEWAY_PREFIX: "/app/havline"
|
||||
HAVLINE_TRUST_GATEWAY_HEADERS: "false"
|
||||
HAVLINE_NGINX_HTTP_PORT: "80"
|
||||
HAVLINE_NGINX_HTTPS_PORT: "443"
|
||||
HAVLINE_UPDATER_SOCKET: ""
|
||||
HAVLINE_INITIAL_ADMIN_PASSWORD: "${wizard_initial_admin_password}"
|
||||
TZ: "${wizard_timezone}"
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
```text
|
||||
HAVLINE_LISTEN 保留,用于端口回退和内部健康检查
|
||||
HAVLINE_GATEWAY_SOCKET 指向统一网关 Socket
|
||||
HAVLINE_GATEWAY_PREFIX 用于前端 base path 和路由
|
||||
HAVLINE_TRUST_GATEWAY_HEADERS 第一阶段关闭
|
||||
wizard_service_port 可以从端口映射中移除
|
||||
```
|
||||
|
||||
### 5.4 manifest 目标
|
||||
|
||||
首版建议:
|
||||
|
||||
```ini
|
||||
appname = havline
|
||||
version = <版本>
|
||||
display_name = Havline
|
||||
desc = NAS 公网访问管理:反向代理、DDNS、HTTPS 证书、FRP、Cloudflare Tunnel 与一键升级
|
||||
source = thirdparty
|
||||
platform = all
|
||||
maintainer = Havline
|
||||
maintainer_url = https://jayhub.top/mrlee/Havline
|
||||
distributor = Havline
|
||||
desktop_uidir = ui
|
||||
desktop_applaunchname = havline.main
|
||||
checkport = false
|
||||
ctl_stop = true
|
||||
service_port = 6893
|
||||
```
|
||||
|
||||
`service_port` 是否保留需要实测:
|
||||
|
||||
```text
|
||||
如果只注册统一网关入口:
|
||||
可以不依赖 service_port
|
||||
|
||||
如果同时保留端口回退入口:
|
||||
保留 service_port 便于调试
|
||||
```
|
||||
|
||||
## 六、三种实现路线
|
||||
|
||||
### 方案 A:Go 主程序原生监听 Unix Socket
|
||||
|
||||
实现:
|
||||
|
||||
```text
|
||||
internal/config
|
||||
增加 GatewaySocket、GatewayPrefix 配置
|
||||
|
||||
internal/app
|
||||
同时创建 TCP listener 和 Unix listener
|
||||
TCP 用于端口回退
|
||||
Unix 用于统一网关
|
||||
```
|
||||
|
||||
伪代码:
|
||||
|
||||
```go
|
||||
tcpServer := &http.Server{Addr: cfg.ListenAddr, Handler: handler}
|
||||
unixServer := &http.Server{Handler: handler}
|
||||
|
||||
if cfg.GatewaySocket != "" {
|
||||
_ = os.Remove(cfg.GatewaySocket)
|
||||
listener, err := net.Listen("unix", cfg.GatewaySocket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(cfg.GatewaySocket, 0o660); err != nil {
|
||||
return err
|
||||
}
|
||||
go unixServer.Serve(listener)
|
||||
}
|
||||
|
||||
go tcpServer.ListenAndServe()
|
||||
```
|
||||
|
||||
优点:
|
||||
|
||||
```text
|
||||
无额外进程
|
||||
可以精确处理 base path
|
||||
可以按来源区分 TCP 与网关请求
|
||||
可以安全启用 X-Trim-* SSO
|
||||
网络性能更好
|
||||
```
|
||||
|
||||
缺点:
|
||||
|
||||
```text
|
||||
需要修改 Go 主程序
|
||||
需要补充 Unix Socket 生命周期测试
|
||||
```
|
||||
|
||||
推荐度:高。
|
||||
|
||||
### 方案 B:容器内 Nginx 监听 Unix Socket
|
||||
|
||||
实现:
|
||||
|
||||
```text
|
||||
容器内 Nginx 监听 /app/target/app.sock
|
||||
反向代理到 127.0.0.1:6893
|
||||
```
|
||||
|
||||
优点:
|
||||
|
||||
```text
|
||||
不改 Go 主程序
|
||||
可以快速做统一网关 PoC
|
||||
```
|
||||
|
||||
缺点:
|
||||
|
||||
```text
|
||||
多一层代理
|
||||
base path 和 Header 处理复杂
|
||||
错误排查链路更长
|
||||
SSO 头部的可信边界不清晰
|
||||
```
|
||||
|
||||
推荐度:中,适合过渡验证,不建议作为长期方案。
|
||||
|
||||
### 方案 C:单独 Sidecar 容器
|
||||
|
||||
实现:
|
||||
|
||||
```text
|
||||
新增 sidecar 容器监听 Unix Socket
|
||||
代理到 havline 容器
|
||||
```
|
||||
|
||||
优点:
|
||||
|
||||
```text
|
||||
主容器不增加进程
|
||||
```
|
||||
|
||||
缺点:
|
||||
|
||||
```text
|
||||
Compose 复杂度高
|
||||
生命周期和升级耦合
|
||||
收益不明显
|
||||
```
|
||||
|
||||
推荐度:低。
|
||||
|
||||
## 七、前端 base path 改造
|
||||
|
||||
统一网关路径固定为:
|
||||
|
||||
```text
|
||||
/app/havline
|
||||
```
|
||||
|
||||
前端需要支持可配置 base path。
|
||||
|
||||
### 7.1 Vite
|
||||
|
||||
目标:
|
||||
|
||||
```ts
|
||||
export default defineConfig({
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
构建飞牛统一网关版本时:
|
||||
|
||||
```text
|
||||
VITE_BASE_PATH=/app/havline/
|
||||
```
|
||||
|
||||
### 7.2 Vue Router
|
||||
|
||||
当前:
|
||||
|
||||
```ts
|
||||
createWebHistory()
|
||||
```
|
||||
|
||||
目标:
|
||||
|
||||
```ts
|
||||
createWebHistory(import.meta.env.BASE_URL)
|
||||
```
|
||||
|
||||
或者由运行时 `HAVLINE_GATEWAY_PREFIX` 注入。
|
||||
|
||||
### 7.3 API 客户端
|
||||
|
||||
当前:
|
||||
|
||||
```ts
|
||||
fetch('/api/...')
|
||||
```
|
||||
|
||||
目标:
|
||||
|
||||
```ts
|
||||
const API_BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
|
||||
fetch(`${API_BASE}/api/...`)
|
||||
```
|
||||
|
||||
或者由前端运行时配置读取:
|
||||
|
||||
```text
|
||||
window.__HAVLINE_BASE_PATH__
|
||||
```
|
||||
|
||||
### 7.4 SSE / EventSource
|
||||
|
||||
当前:
|
||||
|
||||
```ts
|
||||
new EventSource('/api/proxies/{id}/logs/stream')
|
||||
```
|
||||
|
||||
目标:
|
||||
|
||||
```ts
|
||||
new EventSource(`${API_BASE}/api/proxies/{id}/logs/stream`)
|
||||
```
|
||||
|
||||
### 7.5 WebSocket
|
||||
|
||||
目标路径:
|
||||
|
||||
```text
|
||||
/app/havline/ws
|
||||
```
|
||||
|
||||
前端必须使用当前页面主机和协议:
|
||||
|
||||
```ts
|
||||
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = `${wsProtocol}//${location.host}/app/havline/ws`
|
||||
```
|
||||
|
||||
### 7.6 Cookie
|
||||
|
||||
需要确认 Havline 的会话 Cookie 是否设置了 Path。
|
||||
|
||||
建议:
|
||||
|
||||
```text
|
||||
端口模式:Path=/
|
||||
网关模式:Path=/app/havline
|
||||
```
|
||||
|
||||
或者统一使用 `Path=/`,减少改造量。
|
||||
|
||||
### 7.7 CSRF / Origin
|
||||
|
||||
如果 Havline 有 Origin 或 CSRF 校验,需要把以下来源加入白名单:
|
||||
|
||||
```text
|
||||
https://<fnOS域名>
|
||||
https://<fnOS域名>/app/havline
|
||||
```
|
||||
|
||||
## 八、认证与权限设计
|
||||
|
||||
### 8.1 第一阶段:双登录,低风险
|
||||
|
||||
```text
|
||||
飞牛网关校验 fnOS 登录态
|
||||
Havline 仍然显示自己的登录页
|
||||
用户登录 Havline 后正常使用
|
||||
```
|
||||
|
||||
优点:
|
||||
|
||||
```text
|
||||
改造最小
|
||||
不影响端口模式
|
||||
不引入 SSO 安全问题
|
||||
```
|
||||
|
||||
缺点:
|
||||
|
||||
```text
|
||||
用户需要登录两次
|
||||
```
|
||||
|
||||
建议首版采用。
|
||||
|
||||
### 8.2 第二阶段:网关 SSO
|
||||
|
||||
启用条件:
|
||||
|
||||
```text
|
||||
HAVLINE_TRUST_GATEWAY_HEADERS=true
|
||||
只有 Unix Socket 请求才信任 X-Trim-* Header
|
||||
```
|
||||
|
||||
映射规则建议:
|
||||
|
||||
```text
|
||||
X-Trim-Isadmin=true
|
||||
-> 映射到 Havline admin
|
||||
|
||||
X-Trim-Isadmin=false
|
||||
-> 默认拒绝管理界面
|
||||
-> 或后续映射为只读用户
|
||||
|
||||
缺少 X-Trim-Userid
|
||||
-> 视为非网关请求,回退到本地登录
|
||||
```
|
||||
|
||||
安全要求:
|
||||
|
||||
```text
|
||||
Unix Socket 文件权限限制为网关和 Havline 进程可访问
|
||||
TCP 端口请求绝对不能信任 X-Trim-* Header
|
||||
网关 Header 只用于身份上下文,不替代 Havline 业务权限
|
||||
```
|
||||
|
||||
## 九、Havline 功能边界
|
||||
|
||||
统一网关只解决飞牛系统域名下的应用入口,不替代 Havline 的核心功能。
|
||||
|
||||
| Havline 功能 | 统一网关是否替代 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 管理界面入口 | 是 | 改为 `/app/havline` |
|
||||
| fnOS 登录态 | 部分 | 网关校验登录,Havline 仍需业务鉴权 |
|
||||
| 内置 Nginx 反向代理 | 否 | 仍由 Havline 管理 |
|
||||
| DDNS | 否 | 仍由 Havline 管理 |
|
||||
| HTTPS 证书申请 | 否 | 仍由 Havline 管理 |
|
||||
| FRP 内网穿透 | 否 | 仍由 Havline 管理 |
|
||||
| Cloudflare Tunnel | 否 | 仍由 Havline 管理 |
|
||||
| 公网域名反代 | 否 | 仍由 Havline 管理 |
|
||||
| 端口 18080/9443 | 否 | 作为 Havline 功能端口保留 |
|
||||
|
||||
不要把 Havline 的公网反代、证书和隧道功能迁入飞牛统一网关,否则会丢失
|
||||
Havline 作为独立公网访问管理工具的能力。
|
||||
|
||||
## 十、生命周期与打包变更
|
||||
|
||||
### 10.1 安装回调
|
||||
|
||||
需要确保:
|
||||
|
||||
```text
|
||||
${TRIM_APPDEST} 存在
|
||||
${TRIM_APPDEST} 权限允许容器写入 app.sock
|
||||
${TRIM_PKGVAR} 数据目录权限正确
|
||||
```
|
||||
|
||||
### 10.2 启动
|
||||
|
||||
容器启动时:
|
||||
|
||||
```text
|
||||
删除 ${TRIM_APPDEST}/app.sock 残留文件
|
||||
创建新的 Unix Socket
|
||||
设置 Socket 权限
|
||||
```
|
||||
|
||||
### 10.3 升级
|
||||
|
||||
升级时:
|
||||
|
||||
```text
|
||||
gatewayPrefix 保持不变
|
||||
gatewaySocket 文件名保持不变
|
||||
数据目录保持不变
|
||||
Socket 文件重新创建
|
||||
```
|
||||
|
||||
### 10.4 卸载
|
||||
|
||||
卸载时:
|
||||
|
||||
```text
|
||||
删除 ${TRIM_APPDEST}/app.sock
|
||||
默认保留 ${TRIM_PKGVAR}
|
||||
```
|
||||
|
||||
## 十一、分阶段实施计划
|
||||
|
||||
### 阶段 0:官方行为 PoC
|
||||
|
||||
目标:用最小 Docker 示例验证统一网关。
|
||||
|
||||
必须验证:
|
||||
|
||||
```text
|
||||
[ ] ${TRIM_APPDEST} 挂载路径
|
||||
[ ] Socket 文件权限
|
||||
[ ] /app/havline 请求是否保留前缀
|
||||
[ ] Header 是否原样注入
|
||||
[ ] WebSocket 是否可用
|
||||
[ ] 应用重启后 Socket 是否自动重建
|
||||
```
|
||||
|
||||
产出:
|
||||
|
||||
```text
|
||||
docs/fnos-unified-gateway-poc.md
|
||||
```
|
||||
|
||||
### 阶段 1:Havline 支持 Unix Socket
|
||||
|
||||
目标:主程序可以同时监听 TCP 和 Unix Socket。
|
||||
|
||||
改造:
|
||||
|
||||
```text
|
||||
internal/config
|
||||
internal/app
|
||||
启动日志
|
||||
停止清理
|
||||
```
|
||||
|
||||
验收:
|
||||
|
||||
```text
|
||||
[ ] TCP 6893 正常
|
||||
[ ] Unix Socket 正常
|
||||
[ ] Socket 权限正确
|
||||
[ ] 容器重启后 Socket 重建
|
||||
```
|
||||
|
||||
### 阶段 2:前端 base path
|
||||
|
||||
目标:Havline 可以在 `/app/havline` 下完整运行。
|
||||
|
||||
改造:
|
||||
|
||||
```text
|
||||
Vite base
|
||||
Vue Router base
|
||||
API client base
|
||||
EventSource base
|
||||
WebSocket base
|
||||
Cookie Path
|
||||
CSRF / Origin
|
||||
```
|
||||
|
||||
验收:
|
||||
|
||||
```text
|
||||
[ ] 页面打开
|
||||
[ ] 静态资源加载
|
||||
[ ] 登录
|
||||
[ ] API
|
||||
[ ] SSE
|
||||
[ ] WebSocket
|
||||
[ ] 刷新深层路由
|
||||
```
|
||||
|
||||
### 阶段 3:飞牛包切换统一网关入口
|
||||
|
||||
目标:桌面入口走 `/app/havline`。
|
||||
|
||||
改造:
|
||||
|
||||
```text
|
||||
app/ui/config
|
||||
docker-compose.yaml
|
||||
```
|
||||
|
||||
保留:
|
||||
|
||||
```text
|
||||
18080/9443 端口
|
||||
端口回退入口
|
||||
```
|
||||
|
||||
验收:
|
||||
|
||||
```text
|
||||
[ ] 飞牛桌面入口打开正确
|
||||
[ ] 不需要端口入口即可访问管理界面
|
||||
[ ] 18080/9443 功能不受影响
|
||||
```
|
||||
|
||||
### 阶段 4:网关 SSO
|
||||
|
||||
目标:使用 `X-Trim-*` 自动识别飞牛管理员。
|
||||
|
||||
改造:
|
||||
|
||||
```text
|
||||
HAVLINE_TRUST_GATEWAY_HEADERS=true
|
||||
只对 Unix Socket 请求生效
|
||||
管理员映射
|
||||
非管理员处理策略
|
||||
```
|
||||
|
||||
验收:
|
||||
|
||||
```text
|
||||
[ ] 管理员免登录
|
||||
[ ] 非管理员被正确拒绝或只读
|
||||
[ ] TCP 请求伪造 Header 无效
|
||||
```
|
||||
|
||||
### 阶段 5:体验收敛
|
||||
|
||||
目标:统一网关成为默认入口,端口仅保留给 Havline 自身功能。
|
||||
|
||||
验收:
|
||||
|
||||
```text
|
||||
[ ] 飞牛桌面入口只显示统一网关入口
|
||||
[ ] 端口入口不再出现在应用卡片
|
||||
[ ] 升级、卸载、重装正常
|
||||
```
|
||||
|
||||
## 十二、验收清单
|
||||
|
||||
```text
|
||||
[ ] gatewayPrefix 稳定为 /app/havline
|
||||
[ ] gatewaySocket 稳定为 app.sock
|
||||
[ ] ${TRIM_APPDEST} 正确挂载
|
||||
[ ] Havline 监听 Unix Socket
|
||||
[ ] Socket 启动前清理残留文件
|
||||
[ ] Socket 权限满足网关访问
|
||||
[ ] 前端使用 /app/havline base
|
||||
[ ] API、SSE、WebSocket 路径正确
|
||||
[ ] Cookie 和 CSRF 正常
|
||||
[ ] 端口回退仍可用
|
||||
[ ] 飞牛管理员可通过网关访问
|
||||
[ ] 非管理员访问策略明确
|
||||
[ ] 升级后数据不丢
|
||||
[ ] 卸载默认保留数据
|
||||
[ ] amd64 与 arm64 镜像可启动
|
||||
```
|
||||
|
||||
## 十三、风险与未决问题
|
||||
|
||||
| 风险 | 影响 | 处理 |
|
||||
| --- | --- | --- |
|
||||
| 网关是否剥离 `/app/havline` 前缀未知 | 前端 base path 可能不一致 | 阶段 0 PoC 实测 |
|
||||
| Socket 属主和权限未知 | 网关无法访问 Socket | PoC 验证并在启动时 chmod |
|
||||
| 前端绝对路径较多 | 网关下页面或 API 404 | 统一引入 base path |
|
||||
| SSE / WebSocket 路径 | 实时日志和状态页不可用 | 阶段 2 专项测试 |
|
||||
| 双登录体验 | 用户需要登录两次 | 阶段 4 再做 SSO |
|
||||
| 伪造 `X-Trim-*` Header | 越权风险 | 只信任 Unix Socket 请求 |
|
||||
| 端口与网关并存 | 入口混乱 | 桌面只注册网关入口 |
|
||||
| Havline 公网反代功能边界 | 用户误解为被替代 | 文档明确说明 |
|
||||
| 飞牛审核要求 | 上架被拒 | 提交前复核官方审核项 |
|
||||
| 镜像多架构兼容 | 飞牛 Docker 28.5.2 对 OCI index 兼容性有限 | 使用 Docker schema2 兼容格式 |
|
||||
|
||||
## 十四、推荐落地顺序
|
||||
|
||||
```text
|
||||
1. 做最小统一网关 Docker PoC
|
||||
2. 确认前缀、Socket 权限、Header、WebSocket
|
||||
3. 主程序增加 Unix Socket 监听
|
||||
4. 前端增加可配置 base path
|
||||
5. 飞牛包注册统一网关入口
|
||||
6. 保留端口回退
|
||||
7. 实测通过后再做网关 SSO
|
||||
8. 最后收敛桌面入口
|
||||
```
|
||||
|
||||
## 十五、结论
|
||||
|
||||
Havline 不需要从 Docker 应用模板重写成另一种应用,而应在现有 Docker
|
||||
应用上叠加统一网关访问模型。
|
||||
|
||||
推荐的最终形态:
|
||||
|
||||
```text
|
||||
运行形态:飞牛 Docker 应用
|
||||
访问模型:统一网关 /app/havline
|
||||
服务监听:TCP 6893 + Unix Socket app.sock
|
||||
用户体系:首版保留 Havline 登录,后续再做网关 SSO
|
||||
功能边界:Havline 继续管理公网反代、DDNS、证书、FRP 和 Cloudflare Tunnel
|
||||
回退能力:保留 18080/9443 端口
|
||||
```
|
||||
|
||||
这样既能获得飞牛系统统一的 HTTPS 域名、登录态和桌面入口体验,又不会
|
||||
破坏 Havline 作为独立 NAS 公网访问管理工具的核心能力。
|
||||
@@ -0,0 +1,185 @@
|
||||
# Gitea Registry 镜像拉取问题与修复方案
|
||||
|
||||
> 记录时间:2026-10-02
|
||||
> 适用范围:`jayhub.top/mrlee/havline` 与飞牛应用 `Fnos-Havline`
|
||||
|
||||
## 一、当前状态
|
||||
|
||||
Havline `1.9.0` 的多架构镜像已经通过 Registry HTTP API 上传成功:
|
||||
|
||||
| 平台 | 子 manifest |
|
||||
| --- | --- |
|
||||
| linux/amd64 | `sha256:83b31003aaa84435bdfc02e5e436d7e23f71e184326002ff8cdc6bba84e4c77c` |
|
||||
| linux/arm64 | `sha256:5f30e660bd8ec4563bfe225c69bc5a1cb80b1766655494c2d7bd863f43aa4b06` |
|
||||
| 多架构索引 | `sha256:6eed17af17349bea312b5e1fd8d6fbbde074d157a9e8687a734d9371c59a8db2` |
|
||||
|
||||
Registry 中当前 tag:
|
||||
|
||||
```text
|
||||
mrlee/havline:1.9.0
|
||||
```
|
||||
|
||||
使用带认证的 Registry API 可以正常读取 OCI index。Docker CLI 的
|
||||
`push` 与 `pull` 仍然失败:
|
||||
|
||||
```text
|
||||
failed to authorize: failed to fetch oauth token:
|
||||
authorization server did not include a token in the response
|
||||
```
|
||||
|
||||
## 二、根因
|
||||
|
||||
Gitea 当前生成的 Registry 认证地址是 HTTP:
|
||||
|
||||
```text
|
||||
WWW-Authenticate: Bearer realm="http://jayhub.top/v2/token",
|
||||
service="container_registry",scope="*"
|
||||
```
|
||||
|
||||
实际 token 接口已经通过 HTTPS 提供服务。Docker 遇到 HTTP 到 HTTPS 的
|
||||
跨协议跳转时,会丢掉用于换取 token 的 Basic 认证信息,最终拿不到带权限
|
||||
的 token。
|
||||
|
||||
同时,匿名请求得到的 token 没有 `pull` scope:
|
||||
|
||||
```text
|
||||
scope=[]
|
||||
user=-1
|
||||
```
|
||||
|
||||
这意味着飞牛设备在没有 Registry 登录凭据时,即使服务器修好 realm,也
|
||||
仍然会被公开包的认证策略拦截。仓库和包本身虽然是 public,但当前 Gitea
|
||||
Registry 仍要求客户端登录。
|
||||
|
||||
## 三、服务器端修复
|
||||
|
||||
### 1. 修正 Gitea 的 ROOT_URL
|
||||
|
||||
编辑 Gitea 的 `app.ini`,把 `ROOT_URL` 从 HTTP 改为 HTTPS:
|
||||
|
||||
```ini
|
||||
[server]
|
||||
ROOT_URL = https://jayhub.top/
|
||||
```
|
||||
|
||||
### 2. 确保 Nginx 传递正确协议
|
||||
|
||||
在 Gitea 的反向代理配置中加入:
|
||||
|
||||
```nginx
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
```
|
||||
|
||||
如果你使用的是宝塔、1Panel 或飞牛自带的反代,请在对应站点配置中确认
|
||||
`X-Forwarded-Proto` 没有被覆盖为 `http`。
|
||||
|
||||
### 3. 重启 Gitea
|
||||
|
||||
```bash
|
||||
docker restart gitea
|
||||
```
|
||||
|
||||
如果容器名不同,先执行:
|
||||
|
||||
```bash
|
||||
docker ps --format '{{.Names}} {{.Image}}'
|
||||
```
|
||||
|
||||
### 4. 验证 realm
|
||||
|
||||
```bash
|
||||
curl -sSI https://jayhub.top/v2/ | grep -i www-authenticate
|
||||
```
|
||||
|
||||
期望结果:
|
||||
|
||||
```text
|
||||
WWW-Authenticate: Bearer realm="https://jayhub.top/v2/token",
|
||||
service="container_registry",scope="*"
|
||||
```
|
||||
|
||||
### 5. 验证 Docker 拉取
|
||||
|
||||
```bash
|
||||
docker pull jayhub.top/mrlee/havline:1.9.0
|
||||
```
|
||||
|
||||
如果本机已经登录过 `jayhub.top`,先执行一次:
|
||||
|
||||
```bash
|
||||
docker logout jayhub.top
|
||||
docker login jayhub.top
|
||||
```
|
||||
|
||||
## 四、飞牛端仍被 401 拦截时的处理
|
||||
|
||||
### 方案 A:在飞牛设备登录 Registry(推荐用于继续使用 Gitea)
|
||||
|
||||
在飞牛终端中执行:
|
||||
|
||||
```bash
|
||||
docker login jayhub.top -u mrlee
|
||||
```
|
||||
|
||||
密码建议使用 Gitea Personal Access Token,而不是账号密码。Token 权限
|
||||
至少需要:
|
||||
|
||||
```text
|
||||
read:package
|
||||
```
|
||||
|
||||
登录后再安装 `havline-1.9.0.fpk`。这种方式需要每台安装设备执行一次登录,
|
||||
不适合完全没有终端操作经验的使用者。
|
||||
|
||||
### 方案 B:改为离线镜像包(推荐给最终用户)
|
||||
|
||||
如果希望完全绕开 Gitea Registry 的认证和 realm 问题,可以把 `docker save`
|
||||
生成的镜像归档放进飞牛 `.fpk`,并在安装回调中执行 `docker load`。这样
|
||||
安装过程不依赖外网 Registry,也不需要在飞牛设备上保存 Registry 凭据。
|
||||
|
||||
这个方案的代价是 `.fpk` 体积会显著增加,需要同时评估飞牛应用中心的上传
|
||||
限制和升级包体积。
|
||||
|
||||
### 方案 C:改用允许匿名拉取的公共 Registry
|
||||
|
||||
如果继续发布给普通用户使用,建议把镜像推送到 Docker Hub、GHCR 或其它
|
||||
允许匿名拉取多架构镜像的 Registry,再把飞牛 Compose 中的镜像地址改成
|
||||
对应的公共地址。这个方案的用户体验最好,但需要额外的 Registry 账号和
|
||||
发布流程。
|
||||
|
||||
## 五、应急手动推送工具
|
||||
|
||||
在 Gitea realm 修好之前,可以使用下面的脚本绕过 Docker 客户端直接推送:
|
||||
|
||||
```powershell
|
||||
docker buildx build `
|
||||
--platform linux/amd64 `
|
||||
--build-arg VERSION_FILE=VERSION `
|
||||
--output type=oci,dest=build\havline-amd64.oci.tar .
|
||||
|
||||
tar -xf build\havline-amd64.oci.tar -C build\havline-amd64
|
||||
|
||||
pwsh scripts/push-oci-manual.ps1 `
|
||||
-LayoutDir build\havline-amd64 `
|
||||
-Platform linux/amd64 `
|
||||
-Tag 1.9.0
|
||||
```
|
||||
|
||||
脚本会从 Git 凭据存储中读取 `jayhub.top` 的登录信息,通过 HTTPS 请求
|
||||
Bearer token,再直接调用 Registry HTTP API 上传缺失 blob 和 manifest。
|
||||
|
||||
这个脚本只是应急工具,不能替代服务器端的 `ROOT_URL` 修复。服务器修好
|
||||
之后,应恢复使用标准的 `docker buildx build --push`。
|
||||
|
||||
## 六、发布前检查清单
|
||||
|
||||
```text
|
||||
[ ] Gitea ROOT_URL 已改为 https://jayhub.top/
|
||||
[ ] Nginx 已传递 X-Forwarded-Proto https
|
||||
[ ] curl /v2/ 返回的 realm 是 https
|
||||
[ ] docker pull 在干净客户端上成功
|
||||
[ ] 飞牛设备无需额外操作即可安装
|
||||
[ ] amd64 与 arm64 镜像都能被设备拉取
|
||||
```
|
||||
@@ -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。
|
||||
@@ -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 集成的故障恢复成本,并把历史方案与当前实现明确分层。
|
||||
@@ -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 是单点**——它挂了所有应用一起不可用。这是该架构的固有取舍,不是配置错误。
|
||||
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -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)。其余问题集中在校验口径不统一、敏感数据出口缺少二次确认与审计这两条主线上。
|
||||
@@ -59,6 +59,9 @@
|
||||
|
||||
## 4. 首次部署
|
||||
|
||||
`docker-compose.yml` 和 `docker-compose.hub.yml` 会由主程序在共享卷中自动生成 updater Token 文件,sidecar 等待并读取该文件。`HAVLINE_SESSION_SECRET` 可以留空,由应用生成并写入数据目录;已有数据时必须沿用原值。
|
||||
|
||||
|
||||
一键升级依赖 `havline-updater` 已经运行。
|
||||
|
||||
旧版本升级到 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_UPDATE_PROJECT_DIR` | Compose 项目目录,容器内为 `/workspace` |
|
||||
| `HAVLINE_UPDATE_COMPOSE_FILE` | 只允许 `docker-compose.yml`、`docker-compose.hub.yml`、`docker-compose.host.yml` |
|
||||
@@ -194,10 +202,12 @@ docker compose -f /实际/的/Compose/目录/docker-compose.hub.yml up -d --buil
|
||||
|
||||
### `docker-compose.hub.yml` 模式无法读取远端版本
|
||||
|
||||
当前 hub 发布源固定为 `https://jayhub.top/mrlee/Havline.git` 的 `main` 分支;检查 Compose 文件中的 `build.context`、`HAVLINE_UPDATE_REPO` 和 `HAVLINE_UPDATE_BRANCH` 三者是否一致。
|
||||
|
||||
检查 sidecar 容器内是否可以访问 Git 仓库:
|
||||
|
||||
```bash
|
||||
docker exec -it havline-updater sh -lc 'git ls-remote http://45.207.223.190:10082/mrlee/Havline.git main'
|
||||
docker exec -it havline-updater sh -lc 'git ls-remote https://jayhub.top/mrlee/Havline.git main'
|
||||
```
|
||||
|
||||
### 升级卡在 `running`
|
||||
@@ -238,17 +248,16 @@ HAVLINE_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate havl
|
||||
|
||||
## 10. 在线升级验证
|
||||
|
||||
示例页面用于验证升级结果。v1.9.0 的验证流程:
|
||||
「关于项目」页面可以用于确认升级后的版本信息。v1.9.0 的验证流程:
|
||||
|
||||
1. 确认当前线上实例为 v1.3.0,且 `havline-updater` sidecar 已运行。
|
||||
2. 确认远端 `main` 分支的 `VERSION` 为 `1.9.0`。
|
||||
3. 在侧栏看到「一键升级」后点击并确认。
|
||||
4. 等待升级弹窗显示成功,页面自动刷新。
|
||||
5. 打开侧栏「示例页面」,确认:
|
||||
- 页面路由为 `/example`;
|
||||
5. 打开侧栏「关于项目」,确认:
|
||||
- 页面路由为 `/about`;
|
||||
- 页面显示的版本为 `1.9.0`;
|
||||
- 页面显示验证标识 `v1.9.0-example-page`;
|
||||
- 页面显示独立的「本次升级验证」区块;
|
||||
- 「升级验证」显示为「通过」。
|
||||
- 「源项目」显示为 `O96u/Fonu`;
|
||||
- 开源协议显示为 `GPL-3.0`。
|
||||
|
||||
如果示例页面可见且版本正确,说明镜像构建、容器重建、后端版本注入和前端静态资源更新均已生效。
|
||||
如果「关于项目」页面可见且版本正确,说明镜像构建、容器重建、后端版本注入和前端静态资源更新均已生效。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
> 本文逐版本列出上游 1.0.3 / 1.0.4 / 1.0.5 的全部变更,并逐条核对**本项目代码现状**,
|
||||
> 给出「同步 / 已具备 / 不适用 / 待核对」的判定与落地建议。
|
||||
>
|
||||
> 上游来源:`https://github.com/O96u/Havline`(CHANGELOG.md + 提交记录)
|
||||
> 上游来源:`https://github.com/O96u/Fonu`(CHANGELOG.md + 提交记录)
|
||||
> 核对方式:对本仓库 `f:\Havline-main\Havline-1.0.2` 做 grep / 文件树核对(证据写在每条的「本项目现状」里)
|
||||
> 更新日期:2026-09-23
|
||||
|
||||
@@ -343,4 +343,3 @@ Nginx 的冲突规则是「**同端口 + 同 `server_name`**」:此时只在 r
|
||||
3. 解析器对手写配置里的 `include`、变量、`listen unix:` 一律忽略——宁可漏报也不误报(拦不住保存比误报更可接受)。
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -309,6 +310,83 @@ func (s *Server) nginxReload(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// nginxAction 启动 / 停止 / 重启 VPS 上的 Nginx。
|
||||
//
|
||||
// 不走 systemctl:宝塔、源码编译等安装方式没有 systemd unit,
|
||||
// 统一用 nginx 自身信号控制,与 reloadNginx 走同一条路径,行为一致可预期。
|
||||
func (s *Server) nginxAction(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if req.Action != "start" && req.Action != "stop" && req.Action != "restart" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "action 需为 start/stop/restart"})
|
||||
return
|
||||
}
|
||||
nginx := s.nginxPath()
|
||||
if nginx == "" {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "未找到 nginx 可执行文件:请先安装 Nginx,或用 HAVLINE_AGENT_NGINX_BIN 指定路径"})
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := runNginxAction(nginx, req.Action); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 启停异步生效,留出短暂窗口再重新探测,避免页面读到旧状态
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
s.invalidateNginxProbe()
|
||||
probe := s.probeNginx()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"action": req.Action,
|
||||
"nginx_running": probe.Running,
|
||||
"nginx_ok": probe.OK,
|
||||
"nginx_error": probe.Error,
|
||||
})
|
||||
}
|
||||
|
||||
// runNginxAction 执行一次启停;已经是目标状态时按幂等处理,不报错。
|
||||
func runNginxAction(nginx, action string) error {
|
||||
running := nginxRunning()
|
||||
switch action {
|
||||
case "start":
|
||||
if running {
|
||||
return nil
|
||||
}
|
||||
return startNginx(nginx)
|
||||
case "stop":
|
||||
if !running {
|
||||
return nil
|
||||
}
|
||||
return stopNginx(nginx)
|
||||
default: // restart
|
||||
if running {
|
||||
_ = stopNginx(nginx)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
return startNginx(nginx)
|
||||
}
|
||||
}
|
||||
|
||||
func startNginx(nginx string) error {
|
||||
if out, err := exec.Command(nginx).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("启动 Nginx 失败:%s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stopNginx(nginx string) error {
|
||||
if out, err := exec.Command(nginx, "-s", "stop").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("停止 Nginx 失败:%s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// frpsConfig 回读 VPS 上的 frps.toml,供应用侧与「即将下发的内容」比对,检出被手工修改的情况
|
||||
func (s *Server) frpsConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
path := strings.TrimSpace(s.cfg.FrpsConfigPath)
|
||||
|
||||
@@ -31,37 +31,7 @@ import (
|
||||
"github.com/havline/havline/internal/sysinfo"
|
||||
)
|
||||
|
||||
// AgentVersion 随每次 agent 行为变更手动递增;status/ping 与安装脚本都会回报,用于确认 VPS 上跑的是哪个版本。
|
||||
// 0.5.0:目录判定改用 nginx -T 自省(dump 完整生效配置,按 "# configuration file" 标记
|
||||
// 找出真正被加载的文件所在目录,零猜测);include 链递归与 HAVLINE_AGENT_NGINX_CONF_DIR 保留为回退。
|
||||
// 0.9.0:Nginx 可用性显式探测(status 暴露 nginx_ok/nginx_missing)、部署前预检、一键安装 Nginx 端点。
|
||||
// 0.9.1:status 补充 nginx_running / nginx_version,供页面展示 Nginx 状态卡片。
|
||||
// 0.9.2:Basic Auth 哈希改为纯 Go apr1(去掉对 VPS openssl 的依赖);安装脚本 token 改用 /dev/urandom。
|
||||
// 0.10.0:新增 /api/v1/metrics 主机资源采集、/api/v1/logs/nginx 日志读取、/api/v1/frps/config 配置回读。
|
||||
// 0.11.0:新增 /api/v1/certs 证书清单与 /api/v1/nginx/reload 显式重载(回传校验/重载失败原因)。
|
||||
// 0.12.0:frps 安装包的 proxy 参数改白名单 + 下载后校验 SHA-256;Basic Auth 自动生成的密码不再写进
|
||||
// nginx 配置注释(改为只在 PUT 响应里回一次)。
|
||||
// 0.12.1:frps 启停失败如实回报(不再无条件写「已启动」);HTTP 服务补 ReadTimeout/IdleTimeout。
|
||||
// 0.12.2:新增域名 vhost 的版本列表 / 对比 / 回滚端点;写配置前留版本,校验失败时恢复上一个可用版本。
|
||||
// 0.12.3:frps 日志尾巴改为最近 20 行、最新在前,并用 short-iso 时间戳(默认格式不带年份、分不清哪一天)。
|
||||
// 0.12.4:日志行重写成「2026-09-22 18:43:34 进程: 消息」,去掉 T 分隔、时区偏移与每行重复的主机名。
|
||||
// 0.12.5:修正 frps 官方校验文件名(frp 一直叫 frp_sha256_checksums.txt,原代码拼的是 frp_<版本>_sha256sums.txt,导致首次安装报 HTTP 404)。
|
||||
// 0.12.6:nginxPath 探测不到时返回空串(原来回落到配置里的裸名字 "nginx"),
|
||||
// 修掉「VPS 未安装 Nginx」告警与一键安装按钮永远不出现、以及把它误报成「配置校验未通过」的问题。
|
||||
// 0.12.7:vhost 目录探测不再要求被加载文件带 .conf 后缀(Debian 的 sites-enabled/default 不带),
|
||||
// 并在失败信息里带上最后一次的具体原因与常见的显式指定目录。
|
||||
// 0.12.8:Agent Bearer Token 改为常量时间比较,避免认证路径上的长度/内容时序泄漏。
|
||||
// 0.12.9:限流状态文件损坏时拒绝覆盖,避免保存单条规则时清空其他域名的限流配置。
|
||||
// 0.13.0:默认只监听回环地址,非回环监听必须显式允许;状态接口暴露监听范围与传输安全。
|
||||
// 0.13.1:nginx 版本备份目录改为隐藏目录(.<name>.versions)并自动迁移旧目录,
|
||||
// 修掉旧 <name>.versions 目录被 include * 当配置文件读取、nginx -t 报 pread() "Is a directory" 的问题。
|
||||
// 0.13.2:nginx -T 探测失败时先从报错文本解析旧 <name>.versions 目录并迁移后重试,
|
||||
// 修掉「配置目录还没探测出来、迁移逻辑没机会执行」导致 HTTPS 直连持续失败的问题。
|
||||
// 0.13.3:HTTPS 反代改用 default_server 并在 reload 后做本机证书指纹自检,
|
||||
// 证书不匹配时回滚并回报实际指纹与端口占用进程,区分「nginx 未生效」与「公网链路被接管」。
|
||||
// 0.14.0:HTTPS 端口自动选择(7443/8443/9443)、当前证书信息查询、主机防火墙检测与 ufw/firewalld 显式放行。
|
||||
// 0.14.1:vhost 模板迁出到 internal/nginxtmpl,统一 TLS、安全头、allow/deny、WebSocket 与限流输出。
|
||||
const AgentVersion = "0.15.0"
|
||||
const AgentVersion = "0.15.2"
|
||||
|
||||
// Config 是 havline-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
||||
type Config struct {
|
||||
@@ -254,6 +224,7 @@ func agentRoutes(s *Server) []agentRoute {
|
||||
{http.MethodGet, "/api/v1/frps/config", s.frpsConfig},
|
||||
{http.MethodGet, "/api/v1/certs", s.certs},
|
||||
{http.MethodPost, "/api/v1/nginx/reload", s.nginxReload},
|
||||
{http.MethodPost, "/api/v1/nginx/action", s.nginxAction},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,7 +307,7 @@ func (s *Server) status(w http.ResponseWriter, _ *http.Request) {
|
||||
"frps_unit_exec": strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")),
|
||||
"frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil,
|
||||
"frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)),
|
||||
"frps_log_tail": strings.TrimSpace(journalTailDesc("frps", frpsLogTailLines)),
|
||||
"frps_log_tail": frpsLogTailDesc(),
|
||||
"dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)),
|
||||
"routes": s.routeNames(),
|
||||
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
||||
@@ -460,7 +431,7 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if writeUnit {
|
||||
unit := fmt.Sprintf("[Unit]\nDescription=frps service (managed by havline-agent)\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=%s -c %s\nRestart=on-failure\nRestartSec=3\n\n[Install]\nWantedBy=multi-user.target\n", installPath, s.cfg.FrpsConfigPath)
|
||||
unit := fmt.Sprintf("[Unit]\nDescription=frps service (managed by havline-agent)\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=%s -c %s\nRestart=on-failure\nRestartSec=3\nStandardOutput=append:%s\nStandardError=append:%s\n\n[Install]\nWantedBy=multi-user.target\n", installPath, s.cfg.FrpsConfigPath, frpsSystemdLogPath, frpsSystemdLogPath)
|
||||
if err := atomicWrite(unitPath, []byte(unit), 0o644); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "写入 frps.service 失败: " + err.Error()})
|
||||
return
|
||||
@@ -515,22 +486,111 @@ func (s *Server) frpsAction(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "action 需为 start/stop/restart"})
|
||||
return
|
||||
}
|
||||
notes := make([]string, 0, 2)
|
||||
if req.Action == "start" || req.Action == "restart" {
|
||||
if note := cleanupOrphanFRPS(); note != "" {
|
||||
notes = append(notes, note)
|
||||
}
|
||||
}
|
||||
if output, err := command.CombinedOutput(); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": fmt.Sprintf("systemctl %s frps 失败: %s", req.Action, strings.TrimSpace(string(output)))})
|
||||
return
|
||||
}
|
||||
time.Sleep(800 * time.Millisecond) // 给 frps 启动留出窗口,失败时才能在 is-active/journal 里看到
|
||||
active := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil
|
||||
result := map[string]any{"ok": true, "action": req.Action, "frps_active": active}
|
||||
result := map[string]any{"ok": true, "action": req.Action, "frps_active": active, "notes": notes}
|
||||
if !active {
|
||||
// 启动失败时把 frps 的失败原因带回去,而不是只说“启动成功”
|
||||
journal, _ := exec.Command("journalctl", "-u", "frps", "-n", "8", "--no-pager").CombinedOutput()
|
||||
result["ok"] = false
|
||||
result["error"] = fmt.Sprintf("systemctl %s 已执行但 frps 未进入运行状态,最近日志:\n%s", req.Action, strings.TrimSpace(string(journal)))
|
||||
result["error"] = s.frpsFailureDetail(req.Action)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
const frpsSystemdLogPath = "/var/log/frps-systemd.log"
|
||||
|
||||
// cleanupOrphanFRPS 在 systemd 服务未运行时清理手工启动的 frps,
|
||||
// 避免旧进程占用 7000/vhost 端口后导致 systemd 启动即退出。
|
||||
func cleanupOrphanFRPS() string {
|
||||
if exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil {
|
||||
return ""
|
||||
}
|
||||
if exec.Command("pgrep", "-x", "frps").Run() != nil {
|
||||
return ""
|
||||
}
|
||||
if err := exec.Command("pkill", "-x", "frps").Run(); err != nil {
|
||||
return "检测到 systemd 之外运行的 frps 进程,但停止失败"
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
return "检测到 systemd 之外运行的 frps 进程,已先停止旧进程"
|
||||
}
|
||||
|
||||
// frpsFailureDetail 汇总 systemd 状态、输出日志与直接启动结果,
|
||||
// 避免 journald 不可用时只剩 “No journal files were found”。
|
||||
func (s *Server) frpsFailureDetail(action string) string {
|
||||
lines := []string{fmt.Sprintf("systemctl %s 已执行但 frps 未进入运行状态", action)}
|
||||
if out := strings.TrimSpace(execCommandOutput("systemctl", "show", "frps",
|
||||
"-p", "ActiveState", "-p", "SubState", "-p", "Result", "-p", "ExecMainCode", "-p", "ExecMainStatus")); out != "" {
|
||||
lines = append(lines, "systemd 状态:\n"+out)
|
||||
}
|
||||
if out := strings.TrimSpace(execCommandOutput("systemctl", "status", "frps", "--no-pager", "-l")); out != "" {
|
||||
lines = append(lines, "systemctl status:\n"+tailText(out, 2000))
|
||||
}
|
||||
if out := strings.TrimSpace(journalTail("frps", frpsLogTailLines)); out != "" && !strings.Contains(out, "No journal files were found") {
|
||||
lines = append(lines, "journal 日志:\n"+out)
|
||||
}
|
||||
if out := strings.TrimSpace(readFRPSSystemdLog()); out != "" {
|
||||
lines = append(lines, "frps 输出:\n"+out)
|
||||
}
|
||||
if out := s.probeFRPSStartup(); out != "" {
|
||||
lines = append(lines, "直接启动诊断:\n"+out)
|
||||
}
|
||||
return strings.Join(lines, "\n\n")
|
||||
}
|
||||
|
||||
// probeFRPSStartup 在无 systemd 日志时直接运行 frps 2 秒:
|
||||
// 配置错误会立即返回;配置正常时会打印启动成功后被 timeout 结束。
|
||||
func (s *Server) probeFRPSStartup() string {
|
||||
if exec.Command("pgrep", "-x", "frps").Run() == nil {
|
||||
return "已有 frps 进程在运行,跳过直接启动诊断"
|
||||
}
|
||||
out, err := exec.Command("timeout", "2s", s.cfg.FrpsBinary, "-c", s.cfg.FrpsConfigPath).CombinedOutput()
|
||||
text := strings.TrimSpace(string(out))
|
||||
hint := frpsStartupHint(text)
|
||||
if err != nil {
|
||||
if text == "" {
|
||||
text = err.Error()
|
||||
}
|
||||
return tailText(text+hint, 2400)
|
||||
}
|
||||
if text == "" {
|
||||
return "frps 直接运行 2 秒未报错,可能已正常启动后被超时结束;请检查 systemd 单元和端口占用"
|
||||
}
|
||||
return tailText(text+hint, 2400)
|
||||
}
|
||||
|
||||
func frpsStartupHint(output string) string {
|
||||
if !strings.Contains(output, "address already in use") {
|
||||
return ""
|
||||
}
|
||||
return "\n\n建议:80/443 通常由 VPS 上的 Nginx 占用;请把 frps 的 vhostHTTPPort/vhostHTTPSPort 改为 8080/8443,保存后重新下发 frps 配置并重启 frps。"
|
||||
}
|
||||
|
||||
func frpsLogTailDesc() string {
|
||||
if out := strings.TrimSpace(journalTailDesc("frps", frpsLogTailLines)); out != "" && !strings.Contains(out, "No journal files were found") {
|
||||
return out
|
||||
}
|
||||
return readFRPSSystemdLog()
|
||||
}
|
||||
|
||||
func readFRPSSystemdLog() string {
|
||||
data, err := os.ReadFile(frpsSystemdLogPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return tailText(string(data), 8000)
|
||||
}
|
||||
|
||||
func downloadToFile(url, path string) error {
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Get(url)
|
||||
|
||||
@@ -277,7 +277,12 @@ func (h *FRPMultiHandler) AgentInstall(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
server, err := h.svc.AgentInstall(r.Context(), id)
|
||||
var server frp.Server
|
||||
if r.URL.Query().Get("trust_new_host_key") == "true" {
|
||||
server, err = h.svc.AgentInstallTrustHostKey(r.Context(), id)
|
||||
} else {
|
||||
server, err = h.svc.AgentInstall(r.Context(), id)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
@@ -796,6 +801,28 @@ func (h *FRPMultiHandler) AgentNginxReload(w http.ResponseWriter, r *http.Reques
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// AgentNginxAction 启动 / 停止 / 重启 VPS 上的 Nginx(需 agent ≥ 0.15.2)
|
||||
func (h *FRPMultiHandler) AgentNginxAction(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
result, err := h.svc.AgentNginxAction(r.Context(), id, body.Action)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
@@ -809,11 +836,12 @@ func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
if err := h.svc.AgentFRPSAction(r.Context(), id, body.Action); err != nil {
|
||||
result, err := h.svc.AgentFRPSAction(r.Context(), id, body.Action)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "action": body.Action})
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) InstallFRPSOnAgent(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -168,6 +168,7 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/certs", frpHandler.AgentCerts)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/nginx/reload", frpHandler.AgentNginxReload)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/nginx/action", frpHandler.AgentNginxAction)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions", frpHandler.AgentRouteVersions)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions/{name}", frpHandler.AgentRouteVersion)
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/havline/havline/internal/acme"
|
||||
@@ -68,6 +70,14 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
||||
}
|
||||
logger.Info("会话密钥已就绪", "module", "SYSTEM", "source", secretSource)
|
||||
|
||||
updaterToken, updaterTokenSource, err := updater.EnsureToken(cfg.UpdaterToken, cfg.UpdaterTokenFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if updaterToken != "" {
|
||||
logger.Info("updater Token 已就绪", "module", "SYSTEM", "source", updaterTokenSource)
|
||||
}
|
||||
|
||||
conn, err := db.Open(cfg.DBPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -122,7 +132,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
||||
proxySvc.SetExitSync(cloudflareSvc)
|
||||
startedAt := time.Now().UTC().Format(time.RFC3339)
|
||||
trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
|
||||
updaterClient := updater.NewClient(cfg.UpdaterSocket)
|
||||
updaterClient := updater.NewClient(cfg.UpdaterSocket, updaterToken)
|
||||
|
||||
handler := api.NewRouter(api.Deps{
|
||||
Config: cfg,
|
||||
@@ -144,6 +154,9 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
||||
StaticFS: staticFS,
|
||||
StartedAt: startedAt,
|
||||
})
|
||||
if prefix := strings.TrimSpace(cfg.GatewayPrefix); prefix != "" {
|
||||
handler = stripPrefixIfPresent(prefix, handler)
|
||||
}
|
||||
server := &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: handler,
|
||||
@@ -235,7 +248,32 @@ func (a *App) bootstrapNginx(ctx context.Context, proxySvc *service.ProxyService
|
||||
}
|
||||
|
||||
func (a *App) Run() error {
|
||||
return a.server.ListenAndServe()
|
||||
if strings.TrimSpace(a.cfg.GatewaySocket) == "" {
|
||||
return a.server.ListenAndServe()
|
||||
}
|
||||
// 先准备两端监听,任何一端失败都不留下半启动的服务。
|
||||
tcp, err := net.Listen("tcp", a.server.Addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tcp.Close()
|
||||
socket := strings.TrimSpace(a.cfg.GatewaySocket)
|
||||
if err := os.Remove(socket); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
listener, err := net.Listen("unix", socket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer listener.Close()
|
||||
defer os.Remove(socket)
|
||||
if err := os.Chmod(socket, 0o660); err != nil {
|
||||
return err
|
||||
}
|
||||
errCh := make(chan error, 2)
|
||||
go func() { errCh <- a.server.Serve(tcp) }()
|
||||
go func() { errCh <- a.server.Serve(listener) }()
|
||||
return <-errCh
|
||||
}
|
||||
|
||||
func (a *App) Shutdown(ctx context.Context) error {
|
||||
@@ -246,6 +284,9 @@ func (a *App) Shutdown(ctx context.Context) error {
|
||||
if err := a.server.Shutdown(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if socket := strings.TrimSpace(a.cfg.GatewaySocket); socket != "" {
|
||||
_ = os.Remove(socket)
|
||||
}
|
||||
if err := a.nginx.Stop(ctx); err != nil {
|
||||
a.logger.Error("nginx stop failed", "module", "NGINX", "error", err.Error())
|
||||
}
|
||||
@@ -260,6 +301,19 @@ func (a *App) Shutdown(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func stripPrefixIfPresent(prefix string, next http.Handler) http.Handler {
|
||||
prefix = strings.TrimRight(prefix, "/")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == prefix || strings.HasPrefix(r.URL.Path, prefix+"/") {
|
||||
r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
|
||||
if r.URL.Path == "" {
|
||||
r.URL.Path = "/"
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveMigrationsDir() (string, error) {
|
||||
candidates := []string{
|
||||
"migrations",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGatewayPrefix(t *testing.T) {
|
||||
for _, tc := range []struct{ input, want string }{
|
||||
{"/app/havline/api/version", "/api/version"},
|
||||
{"/app/havline", "/"},
|
||||
{"/api/version", "/api/version"},
|
||||
{"/app/havline-other/api/version", "/app/havline-other/api/version"},
|
||||
} {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
handler := stripPrefixIfPresent("/app/havline/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != tc.want {
|
||||
t.Errorf("路径为 %q,期望 %q", r.URL.Path, tc.want)
|
||||
}
|
||||
}))
|
||||
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, tc.input, nil))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -45,20 +47,35 @@ func (s *Service) EnsureDefaultAdmin(ctx context.Context, log *slog.Logger) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
password, err := generatePassword(16)
|
||||
if err != nil {
|
||||
return err
|
||||
password := strings.TrimSpace(os.Getenv("HAVLINE_INITIAL_ADMIN_PASSWORD"))
|
||||
generated := false
|
||||
if password == "" {
|
||||
var err error
|
||||
password, err = generatePassword(16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
generated = true
|
||||
}
|
||||
if err := s.Setup(ctx, defaultAdminUsername, password); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info(
|
||||
"首次启动已创建管理员账户,请使用以下凭据登录,并在「设置」中尽快修改密码",
|
||||
"module", "SYSTEM",
|
||||
"username", defaultAdminUsername,
|
||||
"password", password,
|
||||
)
|
||||
if generated {
|
||||
log.Info(
|
||||
"首次启动已创建管理员账户,请使用以下凭据登录,并在「设置」中尽快修改密码",
|
||||
"module", "SYSTEM",
|
||||
"username", defaultAdminUsername,
|
||||
"password", password,
|
||||
)
|
||||
} else {
|
||||
log.Info(
|
||||
"首次启动已使用 HAVLINE_INITIAL_ADMIN_PASSWORD 创建管理员账户,请在登录后尽快修改密码",
|
||||
"module", "SYSTEM",
|
||||
"username", defaultAdminUsername,
|
||||
"password_source", "environment",
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -39,3 +39,26 @@ func TestEnsureDefaultAdminCreatesOnce(t *testing.T) {
|
||||
t.Fatalf("expected unauthorized for wrong password, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDefaultAdminUsesEnvPassword(t *testing.T) {
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("HAVLINE_INITIAL_ADMIN_PASSWORD", "env-password-123")
|
||||
svc := New(conn)
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
|
||||
if err := svc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
|
||||
t.Fatalf("bootstrap with env password: %v", err)
|
||||
}
|
||||
if _, err := svc.Login(context.Background(), "admin", "env-password-123"); err != nil {
|
||||
t.Fatalf("expected env password login to succeed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
|
||||
type Config struct {
|
||||
ListenAddr string
|
||||
GatewaySocket string
|
||||
GatewayPrefix string
|
||||
DataDir string
|
||||
SessionSecret string
|
||||
NginxBin string
|
||||
@@ -22,6 +24,8 @@ type Config struct {
|
||||
FrpcBin string
|
||||
FrpPIDFile string
|
||||
UpdaterSocket string
|
||||
UpdaterToken string
|
||||
UpdaterTokenFile string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
@@ -31,6 +35,8 @@ func Load() Config {
|
||||
}
|
||||
return Config{
|
||||
ListenAddr: envOr("HAVLINE_LISTEN", ":6893"),
|
||||
GatewaySocket: envOr("HAVLINE_GATEWAY_SOCKET", ""),
|
||||
GatewayPrefix: envOr("HAVLINE_GATEWAY_PREFIX", ""),
|
||||
DataDir: dataDir,
|
||||
SessionSecret: envOr("HAVLINE_SESSION_SECRET", ""),
|
||||
NginxBin: envOr("HAVLINE_NGINX_BIN", "nginx"),
|
||||
@@ -40,7 +46,9 @@ func Load() Config {
|
||||
NginxDefaultHTTPSPort: envIntOr("HAVLINE_NGINX_HTTPS_PORT", 443),
|
||||
FrpcBin: envOr("HAVLINE_FRPC_BIN", "frpc"),
|
||||
FrpPIDFile: envOr("HAVLINE_FRP_PID", dataDir+"/frp/frpc.pid"),
|
||||
UpdaterSocket: envOr("HAVLINE_UPDATER_SOCKET", "/run/havline-updater/updater.sock"),
|
||||
UpdaterSocket: updaterSocket(),
|
||||
UpdaterToken: envOr("HAVLINE_UPDATER_TOKEN", ""),
|
||||
UpdaterTokenFile: envOr("HAVLINE_UPDATER_TOKEN_FILE", defaultUpdaterTokenFile()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +112,12 @@ func (c Config) SSHKnownHostsPath() string {
|
||||
return filepath.Join(c.DataDir, "ssh_known_hosts")
|
||||
}
|
||||
|
||||
// SSHKnownHostsPathForServer 为每台 FRP 服务端提供独立的 known_hosts,
|
||||
// 避免一台 VPS 的主机密钥记录影响另一台服务器的 SSH 连接。
|
||||
func (c Config) SSHKnownHostsPathForServer(serverID int64) string {
|
||||
return filepath.Join(c.DataDir, "ssh_known_hosts", fmt.Sprintf("server-%d", serverID))
|
||||
}
|
||||
|
||||
func (c Config) ErrorsDir() string {
|
||||
return c.NginxDir() + "/errors"
|
||||
}
|
||||
@@ -232,3 +246,15 @@ func envIntOr(key string, fallback int) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// 原生应用显式传空值以禁用 Docker 升级;未配置时保留容器默认值。
|
||||
func updaterSocket() string {
|
||||
if value, ok := os.LookupEnv("HAVLINE_UPDATER_SOCKET"); ok {
|
||||
return value
|
||||
}
|
||||
return "/run/havline-updater/updater.sock"
|
||||
}
|
||||
|
||||
func defaultUpdaterTokenFile() string {
|
||||
return "/run/havline-updater/updater.token"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -33,3 +34,20 @@ func TestLoadNginxDefaultPortsFallback(t *testing.T) {
|
||||
t.Fatalf("ports %d %d", cfg.NginxDefaultHTTPPort, cfg.NginxDefaultHTTPSPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUpdaterTokenFile(t *testing.T) {
|
||||
t.Setenv("HAVLINE_UPDATER_TOKEN_FILE", "/tmp/havline-updater.token")
|
||||
|
||||
cfg := Load()
|
||||
if cfg.UpdaterTokenFile != "/tmp/havline-updater.token" {
|
||||
t.Fatalf("updater token file %q", cfg.UpdaterTokenFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHKnownHostsPathForServer(t *testing.T) {
|
||||
cfg := Config{DataDir: "/data"}
|
||||
want := filepath.Join("/data", "ssh_known_hosts", "server-3")
|
||||
if got := cfg.SSHKnownHostsPathForServer(3); got != want {
|
||||
t.Fatalf("per-server known_hosts path %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNativeGatewayConfig(t *testing.T) {
|
||||
t.Setenv("HAVLINE_GATEWAY_SOCKET", "/tmp/havline-test.sock")
|
||||
t.Setenv("HAVLINE_GATEWAY_PREFIX", "/app/havline")
|
||||
t.Setenv("HAVLINE_UPDATER_SOCKET", "")
|
||||
cfg := Load()
|
||||
if cfg.GatewaySocket != "/tmp/havline-test.sock" || cfg.GatewayPrefix != "/app/havline" {
|
||||
t.Fatal("原生网关配置未生效")
|
||||
}
|
||||
if cfg.UpdaterSocket != "" {
|
||||
t.Fatal("原生应用显式空值应禁用 Docker 升级")
|
||||
}
|
||||
}
|
||||
@@ -219,9 +219,15 @@ const agentMinVersionInsights = "0.11.0"
|
||||
// agentMinVersionRouteVersions 是域名 vhost 版本列表 / 对比 / 回滚端点要求的最低 agent 版本
|
||||
const agentMinVersionRouteVersions = "0.12.2"
|
||||
|
||||
// agentMinVersionNginxAction 是 Nginx 启停(start / stop / restart)端点要求的最低 agent 版本
|
||||
const agentMinVersionNginxAction = "0.15.2"
|
||||
|
||||
// ErrAgentEndpointMissing 表示 VPS 上的 agent 版本过旧、没有该端点(HTTP 404/405)
|
||||
var ErrAgentEndpointMissing = errors.New("VPS 上的 agent 版本过低,不支持该接口")
|
||||
|
||||
// ErrAgentTokenMismatch 表示 Havline 保存的 Token 与 VPS 上 Agent 配置不一致。
|
||||
var ErrAgentTokenMismatch = errors.New("Agent Token 不匹配,请点击「升级 Agent」重新同步")
|
||||
|
||||
// statusError 保留 HTTP 状态码,便于上层区分「端点不存在」与真实错误
|
||||
type statusError struct {
|
||||
code int
|
||||
@@ -232,6 +238,14 @@ func (e *statusError) Error() string {
|
||||
return fmt.Sprintf("agent 返回 HTTP %d: %s", e.code, e.body)
|
||||
}
|
||||
|
||||
func wrapAgentAuth(err error) error {
|
||||
var statusErr *statusError
|
||||
if errors.As(err, &statusErr) && statusErr.code == http.StatusUnauthorized {
|
||||
return fmt.Errorf("%w: %s", ErrAgentTokenMismatch, statusErr.body)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// wrapEndpointMissing 把「端点不存在」统一成带升级提示的错误(按端点给出最低版本),其它错误原样返回
|
||||
func wrapEndpointMissing(err error, minVersion string) error {
|
||||
var statusErr *statusError
|
||||
@@ -280,6 +294,16 @@ func (c *AgentClient) NginxReload(ctx context.Context) (map[string]any, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// NginxAction 启动 / 停止 / 重启 VPS 上的 Nginx(需 agent ≥ 0.15.2)
|
||||
func (c *AgentClient) NginxAction(ctx context.Context, action string) (map[string]any, error) {
|
||||
var result map[string]any
|
||||
payload := map[string]string{"action": action}
|
||||
if err := c.request(ctx, http.MethodPost, "/api/v1/nginx/action", payload, &result); err != nil {
|
||||
return nil, wrapEndpointMissing(err, agentMinVersionNginxAction)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FRPSConfig 回读 VPS 上的 frps.toml(用于与下发内容比对)
|
||||
func (c *AgentClient) FRPSConfig(ctx context.Context) (map[string]any, error) {
|
||||
var result map[string]any
|
||||
@@ -378,8 +402,31 @@ func (c *AgentClient) DeleteRoute(ctx context.Context, domain string) error {
|
||||
return c.request(ctx, http.MethodDelete, "/api/v1/routes/"+domain, nil, nil)
|
||||
}
|
||||
|
||||
func (c *AgentClient) FRPSAction(ctx context.Context, action string) error {
|
||||
return c.request(ctx, http.MethodPost, "/api/v1/frps/action", map[string]string{"action": action}, nil)
|
||||
func (c *AgentClient) FRPSAction(ctx context.Context, action string) (map[string]any, error) {
|
||||
var result map[string]any
|
||||
if err := c.request(ctx, http.MethodPost, "/api/v1/frps/action", map[string]string{"action": action}, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizeFRPSActionResult(result)
|
||||
}
|
||||
|
||||
// normalizeFRPSActionResult 保留 agent 的 ok=false 与日志,避免后端把它误判为成功。
|
||||
func normalizeFRPSActionResult(result map[string]any) (map[string]any, error) {
|
||||
if result == nil {
|
||||
return nil, errors.New("agent 未返回 frps 操作结果")
|
||||
}
|
||||
ok, exists := result["ok"].(bool)
|
||||
if !exists {
|
||||
return nil, errors.New("agent 未返回 frps 操作结果")
|
||||
}
|
||||
if ok {
|
||||
return result, nil
|
||||
}
|
||||
if msg, _ := result["error"].(string); strings.TrimSpace(msg) != "" {
|
||||
return result, nil
|
||||
}
|
||||
result["error"] = "frps 未进入运行状态,请查看 frps 日志"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *AgentClient) InstallFRPS(ctx context.Context, version, proxy string) (map[string]any, error) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package frp
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeFRPSActionResult(t *testing.T) {
|
||||
if _, err := normalizeFRPSActionResult(nil); err == nil {
|
||||
t.Fatal("空响应应返回错误")
|
||||
}
|
||||
if _, err := normalizeFRPSActionResult(map[string]any{}); err == nil {
|
||||
t.Fatal("缺少 ok 字段应返回错误")
|
||||
}
|
||||
|
||||
failed, err := normalizeFRPSActionResult(map[string]any{
|
||||
"ok": false,
|
||||
"error": "systemctl start 已执行但 frps 未进入运行状态",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ok=false 应保留给前端展示,got %v", err)
|
||||
}
|
||||
if failed["ok"] != false || failed["error"] == "" {
|
||||
t.Fatalf("失败结果未保留:%+v", failed)
|
||||
}
|
||||
|
||||
failedWithoutMessage, err := normalizeFRPSActionResult(map[string]any{"ok": false})
|
||||
if err != nil {
|
||||
t.Fatalf("ok=false 应补齐默认错误,got %v", err)
|
||||
}
|
||||
if failedWithoutMessage["error"] != "frps 未进入运行状态,请查看 frps 日志" {
|
||||
t.Fatalf("默认错误不正确:%+v", failedWithoutMessage)
|
||||
}
|
||||
|
||||
success, err := normalizeFRPSActionResult(map[string]any{"ok": true, "action": "start"})
|
||||
if err != nil || success["ok"] != true {
|
||||
t.Fatalf("成功结果解析失败:result=%+v err=%v", success, err)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,8 @@ func (s *Service) AgentMetrics(ctx context.Context, serverID int64) (map[string]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
|
||||
}
|
||||
return client.Metrics(ctx)
|
||||
result, err := client.Metrics(ctx)
|
||||
return result, wrapAgentAuth(err)
|
||||
}
|
||||
|
||||
// AgentNginxLogs 读取 VPS 上 Nginx 的 access / error 日志尾部
|
||||
@@ -29,7 +30,8 @@ func (s *Service) AgentNginxLogs(ctx context.Context, serverID int64, kind strin
|
||||
if lines <= 0 || lines > 2000 {
|
||||
lines = 200
|
||||
}
|
||||
return client.NginxLogs(ctx, kind, lines)
|
||||
result, err := client.NginxLogs(ctx, kind, lines)
|
||||
return result, wrapAgentAuth(err)
|
||||
}
|
||||
|
||||
// AgentCerts 列出 VPS 证书目录下的域名与到期时间,供与 Havline 证书库交叉核对
|
||||
@@ -38,7 +40,8 @@ func (s *Service) AgentCerts(ctx context.Context, serverID int64) (map[string]an
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
|
||||
}
|
||||
return client.Certs(ctx)
|
||||
result, err := client.Certs(ctx)
|
||||
return result, wrapAgentAuth(err)
|
||||
}
|
||||
|
||||
// AgentNginxReload 显式校验并重载 VPS 上的 Nginx,返回失败原因
|
||||
@@ -47,7 +50,18 @@ func (s *Service) AgentNginxReload(ctx context.Context, serverID int64) (map[str
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
|
||||
}
|
||||
return client.NginxReload(ctx)
|
||||
result, err := client.NginxReload(ctx)
|
||||
return result, wrapAgentAuth(err)
|
||||
}
|
||||
|
||||
// AgentNginxAction 启动 / 停止 / 重启 VPS 上的 Nginx(需 agent ≥ 0.15.2)
|
||||
func (s *Service) AgentNginxAction(ctx context.Context, serverID int64, action string) (map[string]any, error) {
|
||||
client, ok := s.agentClient(ctx, serverID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
|
||||
}
|
||||
result, err := client.NginxAction(ctx, action)
|
||||
return result, wrapAgentAuth(err)
|
||||
}
|
||||
|
||||
// AgentFRPSConfig 回读 VPS 上的 frps.toml,供与「即将下发的内容」比对
|
||||
@@ -56,5 +70,6 @@ func (s *Service) AgentFRPSConfig(ctx context.Context, serverID int64) (map[stri
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
|
||||
}
|
||||
return client.FRPSConfig(ctx)
|
||||
result, err := client.FRPSConfig(ctx)
|
||||
return result, wrapAgentAuth(err)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentruntime "github.com/havline/havline/internal/agent"
|
||||
"github.com/havline/havline/internal/frp/agentbin"
|
||||
)
|
||||
|
||||
@@ -71,6 +72,13 @@ func (i AgentInstaller) Install(ctx context.Context) (string, error) {
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("安装完成但未取回 Token: %s", strings.TrimSpace(output))
|
||||
}
|
||||
version := extractAgentVersion(output)
|
||||
if version == "" {
|
||||
return "", fmt.Errorf("安装完成但未识别 Agent 版本,无法确认升级结果: %s", strings.TrimSpace(output))
|
||||
}
|
||||
if version != agentruntime.AgentVersion {
|
||||
return "", fmt.Errorf("VPS 上安装的 Agent 版本 %s 与主程序内嵌版本 %s 不一致;请重新构建主程序镜像后再执行「升级 Agent」", version, agentruntime.AgentVersion)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
@@ -105,6 +113,19 @@ func extractToken(output string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractAgentVersion(output string) string {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
if version, found := strings.CutPrefix(strings.TrimSpace(line), "agent version: "); found {
|
||||
fields := strings.Fields(version)
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(fields[len(fields)-1], "v")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func agentInstallScript() ([]byte, error) {
|
||||
// 与 scripts/agent-install.sh 同一份逻辑内嵌:支持传入二进制路径参数,卸载子命令。
|
||||
return []byte(`#!/usr/bin/env bash
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package frp
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractAgentVersion(t *testing.T) {
|
||||
output := "agent version: 0.15.1\nhavline-agent installed\nToken: abc123"
|
||||
if got := extractAgentVersion(output); got != "0.15.1" {
|
||||
t.Fatalf("版本解析失败,got %q", got)
|
||||
}
|
||||
if got := extractAgentVersion("agent version: havline-agent 0.15.1\nhavline-agent installed"); got != "0.15.1" {
|
||||
t.Fatalf("带程序名前缀的版本解析失败,got %q", got)
|
||||
}
|
||||
if got := extractAgentVersion("agent version: v0.15.1"); got != "0.15.1" {
|
||||
t.Fatalf("带 v 前缀的版本解析失败,got %q", got)
|
||||
}
|
||||
if got := extractAgentVersion("havline-agent installed\nToken: abc123"); got != "" {
|
||||
t.Fatalf("无版本行时应返回空串,got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -14,25 +15,31 @@ import (
|
||||
const agentRemoteAddr = "127.0.0.1:7700"
|
||||
|
||||
const (
|
||||
agentTunnelKeepalive = 30 * time.Second
|
||||
agentTunnelBackoffMin = time.Second
|
||||
agentTunnelBackoffMax = 60 * time.Second
|
||||
agentTunnelKeepalive = 30 * time.Second
|
||||
agentTunnelKeepaliveTimeout = 5 * time.Second
|
||||
agentTunnelBackoffMin = time.Second
|
||||
agentTunnelBackoffMax = 60 * time.Second
|
||||
)
|
||||
|
||||
type agentTunnel struct {
|
||||
serverID int64
|
||||
localPort int
|
||||
ssh SSHInstaller
|
||||
serverID int64
|
||||
localPort int
|
||||
remoteAddr string
|
||||
ssh SSHInstaller
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
connectMu sync.Mutex
|
||||
mu sync.Mutex
|
||||
listener net.Listener
|
||||
client *ssh.Client
|
||||
state string
|
||||
lastError string
|
||||
wg sync.WaitGroup
|
||||
|
||||
connsMu sync.Mutex
|
||||
conns map[net.Conn]struct{}
|
||||
}
|
||||
|
||||
func newAgentTunnel(parent context.Context, serverID int64, localPort int, installer SSHInstaller) *agentTunnel {
|
||||
@@ -42,13 +49,15 @@ func newAgentTunnel(parent context.Context, serverID int64, localPort int, insta
|
||||
func newAgentTunnelWithListener(parent context.Context, serverID int64, localPort int, installer SSHInstaller, listener net.Listener) *agentTunnel {
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
return &agentTunnel{
|
||||
serverID: serverID,
|
||||
localPort: localPort,
|
||||
ssh: installer,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
listener: listener,
|
||||
state: "connecting",
|
||||
serverID: serverID,
|
||||
localPort: localPort,
|
||||
remoteAddr: agentRemoteAddr,
|
||||
ssh: installer,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
listener: listener,
|
||||
state: "connecting",
|
||||
conns: map[net.Conn]struct{}{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +70,13 @@ func (t *agentTunnel) Start() error {
|
||||
}
|
||||
t.listener = listener
|
||||
}
|
||||
if t.localPort == 0 {
|
||||
if addr, ok := t.listener.Addr().(*net.TCPAddr); ok {
|
||||
t.localPort = addr.Port
|
||||
}
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.state = "connected"
|
||||
t.state = "connecting"
|
||||
t.lastError = ""
|
||||
t.mu.Unlock()
|
||||
t.wg.Add(2)
|
||||
@@ -76,7 +90,7 @@ func (t *agentTunnel) monitorLoop() {
|
||||
defer t.wg.Done()
|
||||
backoff := agentTunnelBackoffMin
|
||||
for {
|
||||
_, err := t.ensureClient()
|
||||
err := t.check()
|
||||
wait := agentTunnelKeepalive
|
||||
if err != nil {
|
||||
t.setState("reconnecting", err)
|
||||
@@ -92,6 +106,24 @@ func (t *agentTunnel) monitorLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// check 同时验证 SSH 连接和远端 Agent 端口,避免只连上 SSH 就误判隧道可用。
|
||||
func (t *agentTunnel) check() error {
|
||||
client, err := t.ensureClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := t.keepalive(client); err != nil {
|
||||
t.invalidateClient(client)
|
||||
return fmt.Errorf("SSH keepalive 失败: %w", err)
|
||||
}
|
||||
remote, err := client.Dial("tcp", t.remoteAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接远端 Agent 端口失败: %w", err)
|
||||
}
|
||||
_ = remote.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *agentTunnel) sleep(d time.Duration) bool {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
@@ -126,8 +158,13 @@ func (t *agentTunnel) acceptLoop() {
|
||||
return
|
||||
}
|
||||
t.wg.Add(1)
|
||||
if !t.trackConn(conn) {
|
||||
t.wg.Done()
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer t.wg.Done()
|
||||
defer t.untrackConn(conn)
|
||||
t.handle(conn)
|
||||
}()
|
||||
}
|
||||
@@ -138,31 +175,53 @@ func (t *agentTunnel) handle(local net.Conn) {
|
||||
client, err := t.ensureClient()
|
||||
if err != nil {
|
||||
t.setState("reconnecting", err)
|
||||
writeTunnelError(local, err)
|
||||
return
|
||||
}
|
||||
remote, err := client.Dial("tcp", agentRemoteAddr)
|
||||
remote, err := client.Dial("tcp", t.remoteAddr)
|
||||
if err != nil {
|
||||
t.invalidateClient(client)
|
||||
t.setState("reconnecting", err)
|
||||
return
|
||||
remote, err = t.retryRemoteDial(client, err)
|
||||
if err != nil {
|
||||
t.setState("reconnecting", err)
|
||||
writeTunnelError(local, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
defer remote.Close()
|
||||
t.setState("connected", nil)
|
||||
proxyTunnel(local, remote)
|
||||
}
|
||||
|
||||
// retryRemoteDial 仅在 SSH 连接本身失效时重连,远端 Agent 端口暂时不可用不会关闭共享连接。
|
||||
func (t *agentTunnel) retryRemoteDial(client *ssh.Client, firstErr error) (net.Conn, error) {
|
||||
if err := t.keepalive(client); err == nil {
|
||||
return nil, fmt.Errorf("连接远端 Agent 端口失败: %w", firstErr)
|
||||
}
|
||||
t.invalidateClient(client)
|
||||
reconnected, err := t.ensureClient()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH 连接失效,重连失败: %w", err)
|
||||
}
|
||||
remote, err := reconnected.Dial("tcp", t.remoteAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH 已重连,但远端 Agent 端口仍不可用: %w", err)
|
||||
}
|
||||
return remote, nil
|
||||
}
|
||||
|
||||
func (t *agentTunnel) ensureClient() (*ssh.Client, error) {
|
||||
// 单飞:监控循环和请求处理可能同时触发建连,串行化后只允许一条 SSH 握手。
|
||||
// 正常请求只复用连接,不在热路径做 keepalive,避免多个接口互相排队。
|
||||
t.connectMu.Lock()
|
||||
defer t.connectMu.Unlock()
|
||||
|
||||
t.mu.Lock()
|
||||
if t.client != nil {
|
||||
client := t.client
|
||||
t.mu.Unlock()
|
||||
if _, _, err := client.SendRequest("keepalive@openssh.com", true, nil); err == nil {
|
||||
return client, nil
|
||||
}
|
||||
t.invalidateClient(client)
|
||||
} else {
|
||||
t.mu.Unlock()
|
||||
return client, nil
|
||||
}
|
||||
t.mu.Unlock()
|
||||
ctx, cancel := context.WithTimeout(t.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
client, err := t.ssh.client(ctx)
|
||||
@@ -181,6 +240,30 @@ func (t *agentTunnel) ensureClient() (*ssh.Client, error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (t *agentTunnel) keepalive(client *ssh.Client) error {
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, err := client.SendRequest("keepalive@openssh.com", true, nil)
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-time.After(agentTunnelKeepaliveTimeout):
|
||||
return fmt.Errorf("SSH keepalive 超时")
|
||||
case <-t.ctx.Done():
|
||||
return t.ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// writeTunnelError 让本地 HTTP 客户端立即得到明确错误,而不是一直等到 context deadline。
|
||||
func writeTunnelError(conn net.Conn, err error) {
|
||||
body, _ := json.Marshal(map[string]string{"error": "SSH 隧道不可用: " + err.Error()})
|
||||
_, _ = fmt.Fprintf(conn,
|
||||
"HTTP/1.1 502 Bad Gateway\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s",
|
||||
len(body), body)
|
||||
}
|
||||
|
||||
func (t *agentTunnel) invalidateClient(client *ssh.Client) {
|
||||
t.mu.Lock()
|
||||
if t.client == client {
|
||||
@@ -217,9 +300,39 @@ func (t *agentTunnel) Stop() {
|
||||
_ = t.client.Close()
|
||||
}
|
||||
t.mu.Unlock()
|
||||
t.closeConns()
|
||||
t.wg.Wait()
|
||||
}
|
||||
|
||||
func (t *agentTunnel) trackConn(conn net.Conn) bool {
|
||||
t.connsMu.Lock()
|
||||
defer t.connsMu.Unlock()
|
||||
if t.ctx.Err() != nil {
|
||||
_ = conn.Close()
|
||||
return false
|
||||
}
|
||||
t.conns[conn] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *agentTunnel) untrackConn(conn net.Conn) {
|
||||
t.connsMu.Lock()
|
||||
delete(t.conns, conn)
|
||||
t.connsMu.Unlock()
|
||||
}
|
||||
|
||||
func (t *agentTunnel) closeConns() {
|
||||
t.connsMu.Lock()
|
||||
conns := make([]net.Conn, 0, len(t.conns))
|
||||
for conn := range t.conns {
|
||||
conns = append(conns, conn)
|
||||
}
|
||||
t.connsMu.Unlock()
|
||||
for _, conn := range conns {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func proxyTunnel(a, b net.Conn) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type directTCPIPPayload struct {
|
||||
DestAddr string
|
||||
DestPort uint32
|
||||
OriginAddr string
|
||||
OriginPort uint32
|
||||
}
|
||||
|
||||
func TestAgentTunnelReturnsBadGatewayWhenRemoteAgentIsDown(t *testing.T) {
|
||||
sshAddr := startFakeSSHServer(t)
|
||||
tunnel := startTestAgentTunnel(t, sshAddr, "127.0.0.1:1")
|
||||
|
||||
response, err := (&http.Client{Timeout: 5 * time.Second}).Get(
|
||||
fmt.Sprintf("http://127.0.0.1:%d/api/v1/status", tunnel.localPort),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("应返回 HTTP 502,而不是 EOF 或超时: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("状态码 = %d,期望 502", response.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
if !strings.Contains(string(body), "SSH 隧道不可用") {
|
||||
t.Fatalf("响应应包含隧道失败原因,实际为 %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTunnelForwardsHTTPToRemoteAgent(t *testing.T) {
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer remote.Close()
|
||||
|
||||
sshAddr := startFakeSSHServer(t)
|
||||
tunnel := startTestAgentTunnel(t, sshAddr, strings.TrimPrefix(remote.URL, "http://"))
|
||||
response, err := (&http.Client{Timeout: 5 * time.Second}).Get(
|
||||
fmt.Sprintf("http://127.0.0.1:%d/api/v1/status", tunnel.localPort),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
if response.StatusCode != http.StatusOK || string(body) != `{"ok":true}` {
|
||||
t.Fatalf("隧道转发失败: status=%d body=%q", response.StatusCode, string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTunnelSerializesConcurrentSSHConnections(t *testing.T) {
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer remote.Close()
|
||||
|
||||
sshAddr, connectionCount := startFakeSSHServerWithCounter(t, 300*time.Millisecond)
|
||||
tunnel := startTestAgentTunnel(t, sshAddr, strings.TrimPrefix(remote.URL, "http://"))
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
var wait sync.WaitGroup
|
||||
for index := 0; index < 4; index++ {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
response, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/api/v1/status", tunnel.localPort))
|
||||
if err == nil {
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
if got := connectionCount.Load(); got != 1 {
|
||||
t.Fatalf("并发请求建立了 %d 条 SSH 连接,期望单飞复用 1 条", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTunnelDoesNotBlockRequestsOnSlowKeepalive(t *testing.T) {
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer remote.Close()
|
||||
|
||||
sshAddr, _ := startFakeSSHServerWithDelays(t, 0, 3*time.Second)
|
||||
tunnel := startTestAgentTunnel(t, sshAddr, strings.TrimPrefix(remote.URL, "http://"))
|
||||
started := time.Now()
|
||||
response, err := (&http.Client{Timeout: 5 * time.Second}).Get(
|
||||
fmt.Sprintf("http://127.0.0.1:%d/api/v1/status", tunnel.localPort),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
if elapsed := time.Since(started); elapsed > time.Second {
|
||||
t.Fatalf("正常请求被慢 keepalive 阻塞了 %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func startTestAgentTunnel(t *testing.T, sshAddr, remoteAddr string) *agentTunnel {
|
||||
t.Helper()
|
||||
host, portText, err := net.SplitHostPort(sshAddr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tunnel := newAgentTunnel(context.Background(), 1, 0, SSHInstaller{
|
||||
Host: host, Port: port, User: "test", Password: "test",
|
||||
Timeout: 2 * time.Second, KnownHostsPath: filepath.Join(t.TempDir(), "known_hosts"),
|
||||
})
|
||||
tunnel.remoteAddr = remoteAddr
|
||||
if err := tunnel.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(tunnel.Stop)
|
||||
return tunnel
|
||||
}
|
||||
|
||||
func startFakeSSHServer(t *testing.T) string {
|
||||
t.Helper()
|
||||
addr, _ := startFakeSSHServerWithCounter(t, 0)
|
||||
return addr
|
||||
}
|
||||
|
||||
func startFakeSSHServerWithCounter(t *testing.T, handshakeDelay time.Duration) (string, *atomic.Int32) {
|
||||
t.Helper()
|
||||
return startFakeSSHServerWithDelays(t, handshakeDelay, 0)
|
||||
}
|
||||
|
||||
func startFakeSSHServerWithDelays(t *testing.T, handshakeDelay, keepaliveDelay time.Duration) (string, *atomic.Int32) {
|
||||
t.Helper()
|
||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signer, err := ssh.NewSignerFromKey(privateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := &ssh.ServerConfig{
|
||||
PasswordCallback: func(ssh.ConnMetadata, []byte) (*ssh.Permissions, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
config.AddHostKey(signer)
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = listener.Close() })
|
||||
var connectionCount atomic.Int32
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go serveFakeSSHConn(conn, config, &connectionCount, handshakeDelay, keepaliveDelay)
|
||||
}
|
||||
}()
|
||||
return listener.Addr().String(), &connectionCount
|
||||
}
|
||||
|
||||
func serveFakeSSHConn(
|
||||
conn net.Conn,
|
||||
config *ssh.ServerConfig,
|
||||
connectionCount *atomic.Int32,
|
||||
handshakeDelay, keepaliveDelay time.Duration,
|
||||
) {
|
||||
if handshakeDelay > 0 {
|
||||
time.Sleep(handshakeDelay)
|
||||
}
|
||||
connectionCount.Add(1)
|
||||
serverConn, channels, requests, err := ssh.NewServerConn(conn, config)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
defer serverConn.Close()
|
||||
go func() {
|
||||
for request := range requests {
|
||||
if request.Type == "keepalive@openssh.com" && keepaliveDelay > 0 {
|
||||
time.Sleep(keepaliveDelay)
|
||||
}
|
||||
if request.WantReply {
|
||||
_ = request.Reply(true, nil)
|
||||
}
|
||||
}
|
||||
}()
|
||||
for newChannel := range channels {
|
||||
if newChannel.ChannelType() != "direct-tcpip" {
|
||||
_ = newChannel.Reject(ssh.UnknownChannelType, "unsupported")
|
||||
continue
|
||||
}
|
||||
var payload directTCPIPPayload
|
||||
if err := ssh.Unmarshal(newChannel.ExtraData(), &payload); err != nil {
|
||||
_ = newChannel.Reject(ssh.ConnectionFailed, "invalid direct-tcpip payload")
|
||||
continue
|
||||
}
|
||||
target := net.JoinHostPort(payload.DestAddr, strconv.Itoa(int(payload.DestPort)))
|
||||
remote, err := net.DialTimeout("tcp", target, 2*time.Second)
|
||||
if err != nil {
|
||||
_ = newChannel.Reject(ssh.ConnectionFailed, err.Error())
|
||||
continue
|
||||
}
|
||||
channel, channelRequests, err := newChannel.Accept()
|
||||
if err != nil {
|
||||
_ = remote.Close()
|
||||
continue
|
||||
}
|
||||
go ssh.DiscardRequests(channelRequests)
|
||||
go func() {
|
||||
_, _ = io.Copy(remote, channel)
|
||||
_ = remote.Close()
|
||||
}()
|
||||
go func() {
|
||||
_, _ = io.Copy(channel, remote)
|
||||
_ = channel.Close()
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,8 @@ func TestAgentTunnelBindsLoopbackAndReleasesPort(t *testing.T) {
|
||||
t.Fatal("隧道启动后本地端口应已被占用")
|
||||
}
|
||||
state, _ := tunnel.State()
|
||||
if state != "connected" && state != "reconnecting" {
|
||||
t.Fatalf("state=%q, want connected 或 reconnecting", state)
|
||||
if state != "connecting" && state != "connected" && state != "reconnecting" {
|
||||
t.Fatalf("state=%q, want connecting、connected 或 reconnecting", state)
|
||||
}
|
||||
tunnel.Stop()
|
||||
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("隧道停止后活动连接应被关闭")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"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 }
|
||||
|
||||
@@ -65,6 +65,9 @@ func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (str
|
||||
if httpsPort <= 0 {
|
||||
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("vhostHTTPSPort = %d\n", httpsPort))
|
||||
}
|
||||
|
||||
@@ -102,3 +102,11 @@ func (s *Service) waitForManagers(ctx context.Context, action func(*Manager) err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -33,3 +33,17 @@ func TestSummarizeRouteHealthEmpty(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -416,6 +417,17 @@ func (s *Service) StartServer(ctx context.Context, id int64) (Server, error) {
|
||||
if err != nil {
|
||||
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.enrichTLSStatus(&server)
|
||||
s.enrichServerRuntime(ctx, &server)
|
||||
@@ -638,6 +650,14 @@ func (s *Service) UpdateServer(ctx context.Context, id int64, in ServerInput) (S
|
||||
if !server.Enabled {
|
||||
_ = 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" {
|
||||
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.serverLogPath(id))
|
||||
_ = os.Remove(s.cfg.SSHKnownHostsPathForServer(id))
|
||||
s.removeManager(id)
|
||||
return s.persistConfig(ctx)
|
||||
}
|
||||
@@ -715,7 +736,7 @@ func (s *Service) sshInstaller(ctx context.Context, serverID int64) (Server, SSH
|
||||
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" {
|
||||
installer.Password = secret
|
||||
} 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) {
|
||||
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)
|
||||
if err != nil {
|
||||
return Server{}, err
|
||||
@@ -745,9 +780,17 @@ func (s *Service) AgentInstall(ctx context.Context, serverID int64) (Server, err
|
||||
if err != nil {
|
||||
return Server{}, err
|
||||
}
|
||||
// 已是 HTTPS 直连时,升级 Agent 不应把用户的传输方式改回隧道。
|
||||
// 已是 HTTPS 直连时优先保持原传输方式;服务器重置或证书丢失时自动回退隧道,
|
||||
// 避免 SSH 安装已经成功,却因为旧证书指纹失效而把整个升级判定为失败。
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -796,6 +839,23 @@ func (s *Service) AgentInstall(ctx context.Context, serverID int64) (Server, err
|
||||
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 升级:
|
||||
// 保持 agent_url、pin 与 transport 不变,只更新 Token 密文并重新验证连通。
|
||||
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
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return Server{}, err
|
||||
}
|
||||
client := NewAgentClientWithPin(agentHTTPSBaseURL(server), token, server.AgentTLSPin)
|
||||
if err := client.DisableHTTPS(ctx); err != nil {
|
||||
return Server{}, fmt.Errorf("关闭 HTTPS 直连失败: %w", err)
|
||||
// 切换时不再依赖旧 HTTPS 直连;先通过隧道验证 Agent,再清理旧 HTTPS 配置。
|
||||
client := NewAgentClient(fmt.Sprintf("http://127.0.0.1:%d", tunnel.localPort), token)
|
||||
if err := client.Ping(ctx); err != nil {
|
||||
return Server{}, fmt.Errorf("SSH 隧道已建立,但 Agent 验证失败: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := s.ensureAgentTunnel(ctx, serverID); err != nil {
|
||||
if err := client.DisableHTTPS(ctx); 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
|
||||
}
|
||||
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) {
|
||||
client, ok := s.agentClient(ctx, serverID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
|
||||
statusCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
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) {
|
||||
@@ -1354,10 +1477,10 @@ func (s *Service) InstallFRPSOnAgent(ctx context.Context, serverID int64, versio
|
||||
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)
|
||||
if !ok {
|
||||
return fmt.Errorf("公网 agent 未配置或 Token 未保存")
|
||||
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
|
||||
}
|
||||
return client.FRPSAction(ctx, action)
|
||||
}
|
||||
@@ -1598,7 +1721,7 @@ func (s *Service) RouteHealth(ctx context.Context, serverID int64) ([]RouteHealt
|
||||
}
|
||||
item.DNSOK = 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 实际返回的代理名列表(供前端悬停排查)
|
||||
names := make([]string, 0, len(tunnelNames))
|
||||
for name := range tunnelNames {
|
||||
if !strings.HasPrefix(name, "domain:") {
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" && !strings.HasPrefix(name, "domain:") {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
item.TunnelDetail = fmt.Sprintf("frps 已注册代理: %s;但均未绑定域名 %s", strings.Join(names, ", "), domain)
|
||||
item.TunnelDetail = tunnelMismatchDetail(names, domain)
|
||||
}
|
||||
} else {
|
||||
item.TunnelDetail = dashboardDiag
|
||||
@@ -1667,6 +1791,26 @@ func (s *Service) RouteHealth(ctx context.Context, serverID int64) ([]RouteHealt
|
||||
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 校验规则归属与域名)。
|
||||
func (s *Service) GetProxy(ctx context.Context, id int64) (Proxy, error) {
|
||||
return s.store.GetProxy(ctx, id)
|
||||
@@ -1907,7 +2051,7 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient
|
||||
}
|
||||
baseURL := server.AgentURL
|
||||
var pins []string
|
||||
if server.AgentTransport == "tunnel" {
|
||||
if server.AgentTransport == "tunnel" || (server.AgentTransport == "http" && isLoopbackAgentURL(server.AgentURL)) {
|
||||
tunnel, err := s.ensureAgentTunnel(ctx, serverID)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
@@ -1952,6 +2096,22 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient
|
||||
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) {
|
||||
if !proxy.Enabled || (proxy.Type != "http" && proxy.Type != "https") || len(proxy.CustomDomains) == 0 {
|
||||
return
|
||||
@@ -2003,6 +2163,16 @@ func (s *Service) Start(ctx context.Context) error {
|
||||
var failures []string
|
||||
for _, server := range servers {
|
||||
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 {
|
||||
s.recordProcessState(ctx, server.ID, manager, err.Error())
|
||||
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
|
||||
for _, server := range servers {
|
||||
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 {
|
||||
s.recordProcessState(ctx, server.ID, manager, err.Error())
|
||||
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)
|
||||
if proxies, proxyErr := s.store.ListEnabledProxies(ctx, server.ID); proxyErr == nil {
|
||||
current.EnabledProxies = len(proxies)
|
||||
if len(proxies) == 0 {
|
||||
// 没有启用规则的服务端不应启动 frpc,也不应把它的历史退出错误聚合到全局状态。
|
||||
current.Status = "stopped"
|
||||
current.PID = 0
|
||||
current.LastError = ""
|
||||
}
|
||||
}
|
||||
statuses = append(statuses, current)
|
||||
}
|
||||
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 取服务端运行状态,并在版本探测失败时按二进制目录名推导版本:
|
||||
// frpc 进程未运行或 --version 探测失败时,RuntimeStatus.Version 因带 omitempty 会被省略,
|
||||
// 前端(FRP 穿透页状态卡、仪表盘)就只能显示「未检测 / 未知」。
|
||||
|
||||
@@ -62,3 +62,45 @@ func TestSummarizeServerSamplesUsesLatestStatus(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,8 +57,8 @@ func (i SSHInstaller) hostKeyCallback() (ssh.HostKeyCallback, error) {
|
||||
if len(keyErr.Want) == 0 {
|
||||
return appendKnownHost(path, remote, key)
|
||||
}
|
||||
return fmt.Errorf("SSH 主机密钥与首次记录不一致(%s 期望 %s,实际 %s);若确认 VPS 重装过系统,请删除 %s 后重连",
|
||||
remote.String(), ssh.FingerprintSHA256(keyErr.Want[0].Key), ssh.FingerprintSHA256(key), path)
|
||||
return fmt.Errorf("SSH 主机密钥与首次记录不一致(%s 期望 %s,实际 %s);若确认 VPS 重装过系统,请重新点击「安装 Agent」并选择「信任并重装」",
|
||||
remote.String(), ssh.FingerprintSHA256(keyErr.Want[0].Key), ssh.FingerprintSHA256(key))
|
||||
}
|
||||
return fmt.Errorf("SSH 主机密钥校验失败: %w", err)
|
||||
}, nil
|
||||
|
||||
@@ -7,9 +7,15 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
legacyProxyQueryTimeout = 8 * time.Second
|
||||
legacyProxyConcurrency = 4
|
||||
)
|
||||
|
||||
// frps 管理接口(webServer)返回的代理状态结构,仅取本模块用到的字段。
|
||||
type frpsProxyStatus struct {
|
||||
Name string `json:"name"`
|
||||
@@ -118,15 +124,43 @@ func (c *frpsTrafficClient) fetchLegacyProxyByName(ctx context.Context, server S
|
||||
// fetchLegacyRouteProxies 按候选规则逐条查询旧版 frps,并保留域名到真实代理名的映射。
|
||||
func (c *frpsTrafficClient) fetchLegacyRouteProxies(ctx context.Context, server Server, password string, names []string) ServerTraffic {
|
||||
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)
|
||||
proxyNamesByDomain := make(map[string]string)
|
||||
var lastErr error
|
||||
for _, name := range names {
|
||||
proxyName := name
|
||||
if user := strings.TrimSpace(server.Options.User); user != "" && !strings.HasPrefix(proxyName, user+".") {
|
||||
proxyName = user + "." + proxyName
|
||||
}
|
||||
info, _, err := c.fetchLegacyProxyByName(ctx, server, password, proxyName)
|
||||
for result := range results {
|
||||
info, err := result.info, result.err
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
@@ -23,6 +24,15 @@ func validateServerInput(in ServerInput) error {
|
||||
if in.Options.DashboardPort != 0 && !validPort(in.Options.DashboardPort) {
|
||||
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) {
|
||||
return fmt.Errorf("frps 管理接口地址无效")
|
||||
}
|
||||
@@ -271,6 +281,31 @@ func normalizeServerOptions(in ServerOptions) ServerOptions {
|
||||
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 {
|
||||
return value == "none" || value == "token" || value == "oidc"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,15 @@ import (
|
||||
)
|
||||
|
||||
// Client 通过共享 Unix Socket 调用 havline-updater sidecar。
|
||||
const tokenHeader = "X-Havline-Updater-Token"
|
||||
|
||||
type Client struct {
|
||||
socketPath string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewClient(socketPath string) *Client {
|
||||
func NewClient(socketPath, token string) *Client {
|
||||
socketPath = strings.TrimSpace(socketPath)
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
@@ -29,6 +32,7 @@ func NewClient(socketPath string) *Client {
|
||||
}
|
||||
return &Client{
|
||||
socketPath: socketPath,
|
||||
token: strings.TrimSpace(token),
|
||||
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 {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set(tokenHeader, c.token)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -2,6 +2,7 @@ package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -34,6 +35,7 @@ type Config struct {
|
||||
ProjectName string
|
||||
SelfImage string
|
||||
DockerSocket string
|
||||
Token string
|
||||
BuildTimeout 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.ProjectName = strings.TrimSpace(cfg.ProjectName)
|
||||
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 {
|
||||
cfg.BuildTimeout = 30 * time.Minute
|
||||
}
|
||||
@@ -126,7 +132,14 @@ func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /status", s.handleStatus)
|
||||
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 上提供服务。
|
||||
|
||||
@@ -2,6 +2,8 @@ package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -29,6 +31,7 @@ func TestNewServerDefaultsAndValidation(t *testing.T) {
|
||||
server, err := NewServer(Config{
|
||||
ProjectDir: t.TempDir(),
|
||||
ComposeFile: "docker-compose.yml",
|
||||
Token: "test-token",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -36,17 +39,17 @@ func TestNewServerDefaultsAndValidation(t *testing.T) {
|
||||
if !server.state.Enabled || server.cfg.Mode != "local" {
|
||||
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 应被拒绝")
|
||||
}
|
||||
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 应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureComposeFile(t *testing.T) {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -70,6 +73,7 @@ func TestResolveComposePlanUsesHostProjectDir(t *testing.T) {
|
||||
ComposeFile: "docker-compose.hub.yml",
|
||||
HostProjectDir: hostDir,
|
||||
ProjectName: "havline",
|
||||
Token: "test-token",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
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) {
|
||||
cases := []struct {
|
||||
path string
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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("文件不存在时应等待超时")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<#
|
||||
Manual OCI layout pusher for registries whose token realm is misconfigured.
|
||||
|
||||
This script is an emergency fallback. It reads Docker's OCI layout output,
|
||||
obtains a Bearer token over HTTPS from the Git credential store, and uploads
|
||||
the blobs and image manifest directly through the Registry HTTP API.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$LayoutDir,
|
||||
[Parameter(Mandatory = $true)][string]$Platform,
|
||||
[Parameter(Mandatory = $true)][string]$Tag,
|
||||
[string]$Registry = "jayhub.top",
|
||||
[string]$Repository = "mrlee/havline",
|
||||
[string]$CredentialHost = "jayhub.top"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$script:Registry = $Registry
|
||||
$script:Repository = $Repository
|
||||
|
||||
function Get-RegistryBasicAuth {
|
||||
$cred = "protocol=https`nhost=$CredentialHost`n`n" | git credential fill 2>$null
|
||||
$values = @{}
|
||||
$cred -split "`n" | ForEach-Object {
|
||||
if ($_ -match '^(.*?)=(.*)$') { $values[$matches[1]] = $matches[2] }
|
||||
}
|
||||
if (-not $values.username -or -not $values.password) {
|
||||
throw "No Git credential found for $CredentialHost"
|
||||
}
|
||||
$raw = $values.username + ":" + $values.password
|
||||
return "Basic " + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($raw))
|
||||
}
|
||||
|
||||
function Get-BearerToken([string]$Scope) {
|
||||
$basic = Get-RegistryBasicAuth
|
||||
$url = "https://$Registry/v2/token?service=container_registry&scope=$Scope"
|
||||
$response = Invoke-RestMethod -Uri $url -Headers @{ Authorization = $basic }
|
||||
if (-not $response.token) { throw "Registry did not return a token" }
|
||||
return $response.token
|
||||
}
|
||||
|
||||
function Get-BlobPath([string]$LayoutRoot, [string]$Digest) {
|
||||
return Join-Path $LayoutRoot ("blobs\sha256\" + ($Digest -replace '^sha256:', ''))
|
||||
}
|
||||
|
||||
function Read-JsonBlob([string]$LayoutRoot, [string]$Digest) {
|
||||
$path = Get-BlobPath $LayoutRoot $Digest
|
||||
return Get-Content $path -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Find-PlatformManifest($Index, [string]$Arch, [string]$Os) {
|
||||
foreach ($entry in $Index.manifests) {
|
||||
if ($entry.platform.architecture -eq $Arch -and $entry.platform.os -eq $Os) {
|
||||
return $entry
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Resolve-ImageManifest([string]$LayoutRoot, [string]$Arch, [string]$Os) {
|
||||
$index = Get-Content (Join-Path $LayoutRoot "index.json") -Raw | ConvertFrom-Json
|
||||
foreach ($entry in $index.manifests) {
|
||||
$document = Read-JsonBlob $LayoutRoot $entry.digest
|
||||
if ($document.mediaType -eq "application/vnd.oci.image.manifest.v1+json") {
|
||||
return @{ Digest = $entry.digest; Document = $document }
|
||||
}
|
||||
$match = Find-PlatformManifest $document $Arch $Os
|
||||
if ($match) {
|
||||
return @{ Digest = $match.digest; Document = (Read-JsonBlob $LayoutRoot $match.digest) }
|
||||
}
|
||||
}
|
||||
throw "No image manifest found for $Os/$Arch"
|
||||
}
|
||||
|
||||
function Test-BlobExists([string]$Digest, [string]$Token) {
|
||||
$url = "https://$Registry/v2/$Repository/blobs/$Digest"
|
||||
$response = Invoke-WebRequest -Uri $url -Method Head -Headers @{ Authorization = "Bearer $Token" } -SkipHttpErrorCheck
|
||||
return $response.StatusCode -eq 200
|
||||
}
|
||||
|
||||
function Push-Blob([string]$LayoutRoot, [string]$Digest, [string]$Token) {
|
||||
if (Test-BlobExists $Digest $Token) {
|
||||
Write-Output "Blob already exists: $Digest"
|
||||
return
|
||||
}
|
||||
$postUrl = "https://$Registry/v2/$Repository/blobs/uploads/"
|
||||
$post = Invoke-WebRequest -Uri $postUrl -Method Post -Headers @{ Authorization = "Bearer $Token" } -SkipHttpErrorCheck
|
||||
if ($post.StatusCode -ne 202) { throw "Create upload session failed: HTTP $($post.StatusCode)" }
|
||||
$location = $post.Headers["Location"]
|
||||
if ($location -is [array]) { $location = $location[0] }
|
||||
if ($location -notmatch '^https?://') { $location = "https://$Registry$location" }
|
||||
$separator = "?"
|
||||
if ($location -match '\?') { $separator = "&" }
|
||||
$putUrl = $location + $separator + "digest=" + $Digest
|
||||
$path = Get-BlobPath $LayoutRoot $Digest
|
||||
$curlArgs = @(
|
||||
"-sS", "-o", "NUL", "-w", "%{http_code}", "-X", "PUT",
|
||||
"-H", "Authorization: Bearer $Token",
|
||||
"-H", "Content-Type: application/octet-stream",
|
||||
"--data-binary", ("@" + $path), $putUrl
|
||||
)
|
||||
$status = (& curl.exe @curlArgs).Trim()
|
||||
if ($status -ne "201") { throw "Upload blob failed: $Digest -> HTTP $status" }
|
||||
Write-Output "Uploaded blob: $Digest"
|
||||
}
|
||||
|
||||
function Push-Manifest([string]$TagValue, [string]$MediaType, [string]$Json, [string]$Token) {
|
||||
$url = "https://$Registry/v2/$Repository/manifests/$TagValue"
|
||||
$response = Invoke-WebRequest -Uri $url -Method Put -Headers @{ Authorization = "Bearer $Token" } -ContentType $MediaType -Body ([Text.Encoding]::UTF8.GetBytes($Json)) -SkipHttpErrorCheck
|
||||
if ($response.StatusCode -ne 201) {
|
||||
throw "Push manifest failed: HTTP $($response.StatusCode) $($response.Content)"
|
||||
}
|
||||
return $response.Headers["Docker-Content-Digest"]
|
||||
}
|
||||
|
||||
$parts = $Platform -split "/"
|
||||
if ($parts.Count -ne 2) { throw "Platform must look like linux/amd64" }
|
||||
$layoutRoot = (Resolve-Path $LayoutDir).Path
|
||||
$manifest = Resolve-ImageManifest $layoutRoot $parts[1] $parts[0]
|
||||
$allBlobs = @($manifest.Document.config.digest) + @($manifest.Document.layers | ForEach-Object { $_.digest })
|
||||
$token = Get-BearerToken "repository:$Repository`:push,pull"
|
||||
foreach ($blob in $allBlobs) { Push-Blob $layoutRoot $blob $token }
|
||||
$manifestJson = Get-Content (Get-BlobPath $layoutRoot $manifest.Digest) -Raw
|
||||
$digest = Push-Manifest $Tag $manifest.Document.mediaType $manifestJson $token
|
||||
Write-Output "Manifest: $digest"
|
||||
Write-Output ("Tag: {0}/{1}:{2}" -f $Registry, $Repository, $Tag)
|
||||
@@ -16,7 +16,7 @@
|
||||
# 4) 打印「回滚到上一个 tag」的命令
|
||||
set -euo pipefail
|
||||
|
||||
REPO="${REPO:-http://45.207.223.190:10082/mrlee/Havline.git}"
|
||||
REPO="${REPO:-https://jayhub.top/mrlee/Havline.git}"
|
||||
BRANCH="${BRANCH:-main}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.hub.yml}"
|
||||
APP_CONTAINER="${APP_CONTAINER:-havline}"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "havline-web",
|
||||
"private": true,
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.2",
|
||||
"packageManager": "npm@10.9.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -9,10 +9,6 @@ import type {
|
||||
DDNSConfig,
|
||||
DDNSSavePayload,
|
||||
DDNSTestPayload,
|
||||
FRPResponse,
|
||||
FRPSavePayload,
|
||||
FRPSaveResponse,
|
||||
FRPStatus,
|
||||
FrpBinary,
|
||||
FrpDownloadTask,
|
||||
FrpProxy,
|
||||
@@ -51,6 +47,7 @@ import type {
|
||||
CfPreflightResult,
|
||||
CfRouteTestResult,
|
||||
} from './types'
|
||||
import { withBase } from './base'
|
||||
|
||||
export function asList<T>(value: T[] | null | undefined): T[] {
|
||||
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 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> {
|
||||
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> {
|
||||
const response = await fetch(path, {
|
||||
const response = await fetch(withBase(path), {
|
||||
credentials: 'include',
|
||||
signal,
|
||||
headers: {
|
||||
@@ -179,7 +177,7 @@ export const api = {
|
||||
email: payload.email ?? '',
|
||||
}),
|
||||
}),
|
||||
certificateApplyStreamURL: (jobId: string) => `/api/certificates/jobs/${encodeURIComponent(jobId)}/stream`,
|
||||
certificateApplyStreamURL: (jobId: string) => withBase(`/api/certificates/jobs/${encodeURIComponent(jobId)}/stream`),
|
||||
importCertificate: (payload: {
|
||||
certificate?: string
|
||||
private_key?: string
|
||||
@@ -192,7 +190,7 @@ export const api = {
|
||||
}),
|
||||
async downloadCertificate(domain: string, part: 'zip' | 'cert' | 'key' = 'zip') {
|
||||
const response = await fetch(
|
||||
`/api/certificates/${encodeURIComponent(domain)}/download?part=${part}`,
|
||||
withBase(`/api/certificates/${encodeURIComponent(domain)}/download?part=${part}`),
|
||||
{ credentials: 'include' },
|
||||
)
|
||||
if (!response.ok) {
|
||||
@@ -265,19 +263,6 @@ export const api = {
|
||||
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)=====
|
||||
getFrpStatus: () => request<FrpStatus>('/api/frpmulti/status'),
|
||||
getFrpRuntime: () => request<FrpRuntimeInfo>('/api/frpmulti/runtime'),
|
||||
@@ -324,11 +309,11 @@ export const api = {
|
||||
request<FrpServer>(`/api/frpmulti/servers/${id}/agent/transport`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ transport, port }),
|
||||
}),
|
||||
}, AGENT_OPERATION_TIMEOUT_MS),
|
||||
getFrpAgentTransport: (id: number) =>
|
||||
request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/transport`),
|
||||
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) =>
|
||||
request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/firewall?port=${port}`),
|
||||
allowFrpAgentFirewallPort: (id: number, port: number) =>
|
||||
@@ -336,17 +321,27 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ port }),
|
||||
}),
|
||||
probeFrpAgent: (id: number) => request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/probe`, { method: 'POST' }),
|
||||
installFrpAgent: (id: number) => request<FrpServer>(`/api/frpmulti/servers/${id}/agent/install`, { method: 'POST' }),
|
||||
uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }),
|
||||
sshDiagnoseFrpAgent: (id: number) => request<{ output: string }>(`/api/frpmulti/servers/${id}/agent/ssh-diagnose`, { method: 'POST' }),
|
||||
probeFrpAgent: (id: number) => request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/probe`, { method: 'POST' }, AGENT_OPERATION_TIMEOUT_MS),
|
||||
installFrpAgent: (id: number, trustNewHostKey = false) =>
|
||||
request<FrpServer>(
|
||||
`/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`),
|
||||
getFrpAgentCerts: (id: number) =>
|
||||
request<import('./types').FrpAgentCerts>(`/api/frpmulti/servers/${id}/agent/certs`),
|
||||
reloadFrpAgentNginx: (id: number) =>
|
||||
request<import('./types').FrpAgentNginxReload>(`/api/frpmulti/servers/${id}/agent/nginx/reload`, {
|
||||
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) =>
|
||||
request<import('./types').FrpAgentMetrics>(`/api/frpmulti/servers/${id}/agent/metrics`),
|
||||
getFrpAgentNginxLogs: (id: number, type: 'access' | 'error', lines = 200) =>
|
||||
@@ -378,7 +373,7 @@ export const api = {
|
||||
{ method: 'POST' },
|
||||
),
|
||||
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) =>
|
||||
request<import('./types').FrpAgentRouteHealth[]>(`/api/frpmulti/servers/${serverId}/agent/routes/health`),
|
||||
getFrpRouteHealthSummary: () =>
|
||||
@@ -396,13 +391,13 @@ export const api = {
|
||||
`/api/status-page${code ? `?code=${encodeURIComponent(code)}` : ''}`,
|
||||
),
|
||||
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') =>
|
||||
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') =>
|
||||
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) =>
|
||||
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 }) => {
|
||||
const q = new URLSearchParams()
|
||||
@@ -432,7 +427,7 @@ export const api = {
|
||||
},
|
||||
|
||||
async exportBackup() {
|
||||
const response = await fetch('/api/backup/export', { credentials: 'include' })
|
||||
const response = await fetch(withBase('/api/backup/export'), { credentials: 'include' })
|
||||
if (!response.ok) {
|
||||
throw new Error('导出备份失败')
|
||||
}
|
||||
@@ -454,7 +449,7 @@ export const api = {
|
||||
deleteBackupArchive: (name: string) =>
|
||||
request<{ ok: boolean }>(`/api/backup/archives/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||
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) {
|
||||
throw new Error('下载备份失败')
|
||||
}
|
||||
@@ -470,7 +465,7 @@ export const api = {
|
||||
async restoreBackup(file: File) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const response = await fetch('/api/backup/restore', {
|
||||
const response = await fetch(withBase('/api/backup/restore'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: form,
|
||||
|
||||
@@ -345,62 +345,6 @@ export interface DDNSTestPayload {
|
||||
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 核心迁移)=====
|
||||
|
||||
export interface FrpServer {
|
||||
@@ -796,6 +740,14 @@ export interface FrpAgentNginxReload {
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
export interface FrpAgentNginxAction {
|
||||
ok: boolean
|
||||
action: string
|
||||
nginx_running?: boolean
|
||||
nginx_ok?: boolean
|
||||
nginx_error?: string
|
||||
}
|
||||
|
||||
export interface FrpAgentMetrics {
|
||||
cpu_percent: number
|
||||
mem_used_bytes: number
|
||||
@@ -866,6 +818,11 @@ export interface FrpAgentRouteDetail {
|
||||
}
|
||||
|
||||
export interface FrpAgentStatus {
|
||||
configured?: boolean
|
||||
mgmt_enabled?: boolean
|
||||
token_mismatch?: boolean
|
||||
available?: boolean
|
||||
message?: string
|
||||
agent_version?: string
|
||||
listen_addr?: string
|
||||
listen_scope?: string
|
||||
|
||||
@@ -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>
|
||||
@@ -1,4 +1,6 @@
|
||||
export const GITHUB_REPO = 'O96u/Havline'
|
||||
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_DISPLAY = `github.com/${GITHUB_REPO}`
|
||||
export const GITHUB_RELEASES_URL = `${GITHUB_REPO_URL}/releases`
|
||||
|
||||
@@ -123,9 +123,9 @@ import {
|
||||
CogOutline,
|
||||
DocumentTextOutline,
|
||||
Ellipse,
|
||||
FlashOutline,
|
||||
GitNetworkOutline,
|
||||
GridOutline,
|
||||
InformationCircleOutline,
|
||||
LockClosedOutline,
|
||||
LogoGithub,
|
||||
MoonOutline,
|
||||
@@ -205,7 +205,7 @@ const menuDefs = [
|
||||
{ label: 'Cloudflare 隧道', key: 'cloudflare', icon: CloudOutline },
|
||||
{ label: '日志中心', key: 'logs', icon: DocumentTextOutline },
|
||||
{ label: '系统设置', key: 'settings', icon: CogOutline },
|
||||
{ label: '示例页面', key: 'example', icon: FlashOutline },
|
||||
{ label: '关于项目', key: 'about', icon: InformationCircleOutline },
|
||||
]
|
||||
|
||||
const menuOptions = computed<MenuOption[]>(() =>
|
||||
|
||||
@@ -14,10 +14,10 @@ import CloudflareTunnelConfigView from '../views/CloudflareTunnelConfigView.vue'
|
||||
import AgentView from '../views/AgentView.vue'
|
||||
import ProxyRemoteView from '../views/ProxyRemoteView.vue'
|
||||
import StatusPageView from '../views/StatusPageView.vue'
|
||||
import ExampleView from '../views/ExampleView.vue'
|
||||
import AboutView from '../views/AboutView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{ path: '/setup', redirect: '/login' },
|
||||
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } },
|
||||
@@ -96,10 +96,10 @@ const router = createRouter({
|
||||
meta: { title: '系统设置', description: '配置系统参数与运行策略' },
|
||||
},
|
||||
{
|
||||
path: 'example',
|
||||
name: 'example',
|
||||
component: ExampleView,
|
||||
meta: { title: '示例页面', description: '用于验证在线升级是否成功生效' },
|
||||
path: 'about',
|
||||
name: 'about',
|
||||
component: AboutView,
|
||||
meta: { title: '关于项目', description: '查看 Havline 版本、源开源项目和技术信息' },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||