feat: 公网反代部署漂移与全量重部署,仪表盘补公网穿透/系统资源/中国 IP 段/通知通道(v1.2.3)
仪表盘:新增「公网穿透」卡与运行状态「公网反代」行(新增 GET /api/frpmulti/routes/summary 逐台聚合);「性能」面板(今日异常/平均响应)与「流量 Top 5」;新增一行三卡「系统资源 / 中国 IP 段 / 通知通道」,系统资源来自新增跨平台采集 internal/sysinfo 仪表盘:「服务」卡不再写死「正常」,按 Fonu/Nginx/DDNS/证书四项实际状态聚合;/api/status 每 15 秒刷新并按页面可见性暂停轮询;删除死样式 公网反代:部署漂移清单(未部署/孤儿配置/未生效/内容不一致)+ 重新部署全部(逐条下发、单条失败不中断) 公网 Agent:端口矩阵补端口含义;frps 启动一致性核对;证书徽标补证书来源 FRP:frpc 版本探测失败时回落按二进制目录名推导,并逐台服务端探测 文档:新增 docs/agent-capability-plan.md(agent 能力扩展方案);VERSION 提升到 1.2.3
This commit is contained in:
@@ -2,6 +2,32 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [v1.2.3] - 2026-09-24
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 仪表盘:新增「公网穿透」统计卡,并在「运行状态」面板增加「公网反代」行——数据来自新增的 `GET /api/frpmulti/routes/summary`(逐台服务端采集四段健康后聚合,单台失败只记名不中断)
|
||||||
|
- 仪表盘:新增「性能」面板(今日异常 / 平均响应,`/api/status` 早已返回但页面从未展示),「实时流量」面板新增「流量 Top 5」(直接复用已有的逐规则流量轮询)
|
||||||
|
- 仪表盘:新增一行三卡「系统资源 / 中国 IP 段 / 通知通道」;系统资源来自新增的跨平台采集包 `internal/sysinfo`(Linux `/proc`、Windows kernel32,无新增依赖;容器内为宿主机视角)
|
||||||
|
- 公网反代:新增部署漂移清单(未部署 / 孤儿配置 / 未生效 / 内容不一致)与「重新部署全部」——逐条下发、单条失败不中断;新增 `GET /api/frpmulti/servers/{id}/agent/routes/drift` 与 `POST .../agent/routes/redeploy`
|
||||||
|
- 公网 Agent:端口矩阵补端口含义提示;「frps 运行时」新增「启动一致性」核对(systemd 实际启动命令 vs 配置的二进制与配置文件,检出「Fonu 写入的 frps.toml 不是 frps 实际加载的那份」)
|
||||||
|
- 新增 `docs/agent-capability-plan.md`:fonu-agent 能力扩展方案(12 个端点现状盘点、P0–P2 分批设计、agent 侧与应用侧改动流程、验收与风险回滚)
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
- 仪表盘「服务」卡:原先写死「正常 / 4 个」,现按 Fonu / Nginx / DDNS / 证书四项实际状态聚合,异常时点名并把徽标降级为 warning/error(此前 Nginx 停止、DDNS 异常时该卡仍显示「正常」,与右侧「运行状态」面板自相矛盾)
|
||||||
|
- 仪表盘轮询:`/api/status` 每 15 秒刷新一次(CPU 占用率需要两次采样才有意义),并在页面隐藏时暂停「实时流量」与「状态」两个轮询
|
||||||
|
- 公网反代健康列:证书徽标补「证书来源」——`Fonu 证书库:有(剩余 N 天)` 或 `无(需先在证书管理申请)`,用于区分「没有证书可推送」与「VPS 上证书过期或未覆盖」
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- FRP:frpc 版本读不出来——`RuntimeStatus.Version` 带 `omitempty`,`frpc --version` 探测失败时字段被整体省略,前端只能显示「未检测 / 未知」;现回落为按 `<FrpDir>/bin/<版本>/` 目录名推导(与「FRP 应用配置」弹窗的 `active_version` 同源),并改为逐台服务端探测版本(原先只探测默认那台)
|
||||||
|
- 仪表盘:删除死样式(`.dashboard` / `.stat-highlight` / `.proxy-metric*`)
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- 版本号提升到 1.2.3(版本单一来源仍是根目录 `VERSION`)
|
||||||
|
|
||||||
## [v1.2.2] - 2026-09-23
|
## [v1.2.2] - 2026-09-23
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
# fonu-agent 能力扩展方案
|
||||||
|
|
||||||
|
> 本文盘点 VPS 侧 `fonu-agent` 现有的接口面与数据面,给出「能直接做」「需要新增 agent 端点」「不做」三类功能的详细设计。
|
||||||
|
>
|
||||||
|
> 依据:本仓库 `internal/agent/`、`internal/frp/agent_client.go`、`internal/frp/agent_installer.go`、`internal/frp/ssh_installer.go`、`internal/api/frpmulti_handler.go`、`web/src/views/AgentView.vue`、`web/src/views/ProxyRemoteView.vue`(均为读代码确认,非推测)。
|
||||||
|
> 当前 `AgentVersion = "0.9.2"`(`internal/agent/server.go:34`)。
|
||||||
|
> 更新日期:2026-09-23
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现状盘点
|
||||||
|
|
||||||
|
### 1.1 agent 的接口面(12 个端点)
|
||||||
|
|
||||||
|
`internal/agent/server.go:151-162`:
|
||||||
|
|
||||||
|
| # | 方法与路径 | 作用 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `GET /api/v1/ping` | 存活探测(安装/升级后确认 token 与网络) |
|
||||||
|
| 2 | `GET /api/v1/status` | 一次性返回 Nginx / frps / 端口 / 路由名的快照 |
|
||||||
|
| 3 | `GET /api/v1/routes` | 每条 `fonu-*.conf` 的真实状态:`file_exists` / `syntax_ok` / `loaded` |
|
||||||
|
| 4 | `PUT /api/v1/routes` | 写入(或覆盖)一条域名的 Nginx 片段 |
|
||||||
|
| 5 | `GET /api/v1/routes/{domain}/conf` | 回读某域名实际生效的配置文件内容 |
|
||||||
|
| 6 | `DELETE /api/v1/routes/{domain}` | 删除某域名的片段 |
|
||||||
|
| 7 | `POST /api/v1/nginx/install` | 一键安装 Nginx |
|
||||||
|
| 8 | `PUT /api/v1/china-cidr` | 下发国内地址库(供 `china_only` 规则引用) |
|
||||||
|
| 9 | `PUT /api/v1/frps/config` | 写入 frps.toml |
|
||||||
|
| 10 | `POST /api/v1/frps/install` | 下载并安装指定版本的 frps(幂等,重复调用=升级) |
|
||||||
|
| 11 | `POST /api/v1/frps/action` | frps 的 start / stop / restart |
|
||||||
|
| 12 | `POST /api/v1/certs` | 推送证书(fullchain + privatekey) |
|
||||||
|
|
||||||
|
鉴权:全部走 `s.auth(...)` 包装的 token 校验;agent **只监听 VPS 本地**,由 Fonu 主动发起请求,**不做任何主动上报**。
|
||||||
|
|
||||||
|
### 1.2 agent 的数据面(`status` 已返回、但应用侧只用了一部分)
|
||||||
|
|
||||||
|
`internal/agent/server.go:191` 的 `status` 返回字段:
|
||||||
|
|
||||||
|
| 字段 | 含义 | 应用侧现状 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `agent_version` | agent 自身版本 | 已用(AgentView 版本核对) |
|
||||||
|
| `nginx_ok` / `nginx_missing` / `nginx_running` / `nginx_path` / `nginx_version` / `nginx_error` | Nginx 可用性探测(带缓存) | 已用 |
|
||||||
|
| `nginx_binary` / `nginx_conf_dir` | 配置的二进制与 vhost 目录 | 已用 |
|
||||||
|
| `ports` | 80 / 443 / 7000 / 8080 等端口是否监听 | **只用到了 8080 一项**(`AgentView.vue:359`) |
|
||||||
|
| `frps_active` / `frps_enabled` | systemd 单元是否运行 / 是否开机自启 | 已用 |
|
||||||
|
| `frps_binary` / `frps_config_path` | Fonu 侧配置的路径 | 已用 |
|
||||||
|
| `frps_unit_exec` | systemd 实际执行的启动命令 | 已展示,**未做一致性核对** |
|
||||||
|
| `frps_version` | 探测到的 frps 版本 | 已展示 |
|
||||||
|
| `frps_log_tail` | frps 日志尾部 6 行 | 已展示 |
|
||||||
|
| `dashboard_addr` | 从 frps.toml 解析出的 dashboard 地址 | 已用 |
|
||||||
|
| `routes` | 已部署的域名列表 | 已用(部署台账) |
|
||||||
|
|
||||||
|
### 1.3 应用侧的调用面
|
||||||
|
|
||||||
|
`internal/frp/agent_client.go` 暴露 15 个方法,与上面 12 个端点一一对应(`Ping` / `Status` / `SyncRoute` / `SyncRouteSpec` / `SyncRouteEx` / `RouteConf` / `RoutesDetail` / `DeleteRoute` / `InstallNginx` / `SyncChinaCIDR` / `FRPSAction` / `InstallFRPS` / `PushFRPSConfig` / `PushCertificate`)。
|
||||||
|
|
||||||
|
其中 `RouteSpec`(`agent_client.go:83`)承载了完整部署语义:`Domain / Upstream / CertDir / TLS / WebSocket / RedirectHTTPS / HTTPSDisabled / SecurityHeaders / TLS13Only / VhostPort / AllowIPs / DenyIPs / BasicAuth* / ClientMaxBodySize / ProxyReadTimeout / RateLimit*`。
|
||||||
|
|
||||||
|
### 1.4 安装与升级链路(已闭环)
|
||||||
|
|
||||||
|
- agent 二进制**内嵌在 Fonu 里**(`internal/frp/agentbin`,按 arch 取),通过 **SSH** 上传到 VPS 并注册/重启 systemd 单元(`agent_installer.go` + `ssh_installer.go`)。SSH 连接信息就是迁移 023 加的 `ssh_host / ssh_port / ssh_user / ssh_auth / ssh_secret_enc`。
|
||||||
|
- 因此「升级 Agent」不需要 agent 自己有下载能力——**自升级不是必须项**。
|
||||||
|
- 版本约定:`AgentVersion` 上方按「每项行为变更一行」的注释累积,改动后手动递增(当前 0.9.2)。
|
||||||
|
|
||||||
|
### 1.5 能力边界(缺口所在)
|
||||||
|
|
||||||
|
1. **没有资源采集**:VPS 的 CPU / 内存 / 磁盘 / 负载完全不可见。
|
||||||
|
2. **没有日志读取**:只有 frps 的 6 行 tail,Nginx 的 access / error 日志读不到(排障必须 SSH)。
|
||||||
|
3. **frps 配置只写不读**:`PUT /api/v1/frps/config` 存在,没有对应的 GET;用户手改 VPS 上的 frps.toml 后 Fonu 无从知晓,下次下发直接覆盖。
|
||||||
|
4. **没有证书清单**:只能推送,无法列出 VPS 上现有的证书与到期时间(宝塔自己申请的证书看不到)。
|
||||||
|
5. **部署是逐条的**:`SyncRouteSpec` 按单条规则调用,没有「全量重部署」入口——agent 重装/换机后只能一条条点。
|
||||||
|
6. **写入结果不含 reload 细节**:片段写入成功但 Nginx reload 失败时,用户拿不到失败原因。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、方案总览
|
||||||
|
|
||||||
|
| 批次 | 项 | 改动面 | 前置依赖 | 预估 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| **P0-1** | 端口矩阵 | 前端(AgentView) | 无,`status.ports` 已有 | 0.5h |
|
||||||
|
| **P0-2** | 部署漂移清单 + 一键全量重部署 | 后端 service + 前端 | 无,`GET /routes` 与 `RouteConf` 已有 | 2h |
|
||||||
|
| **P0-3** | frps 运行一致性核对 | 前端 | 无,`status` 已有 | 0.5h |
|
||||||
|
| **P0-4** | 证书推送核对 | 前端 | 无,`RouteHealth` 已有 | 0.5h |
|
||||||
|
| **P1-1** | VPS 资源采集 `GET /api/v1/metrics` | agent + 应用 + 前端 | 需递增 AgentVersion、重编二进制、VPS 上升级 | 2h |
|
||||||
|
| **P1-2** | 公网 Nginx 日志 `GET /api/v1/logs/nginx` | agent + 应用 + 前端 | 同上 | 2.5h |
|
||||||
|
| **P1-3** | frps.toml 回读 `GET /api/v1/frps/config` | agent + 应用 + 前端 | 同上 | 1.5h |
|
||||||
|
| **P2-1** | VPS 证书清单 `GET /api/v1/certs` | agent + 应用 + 前端 | 同上 | 1.5h |
|
||||||
|
| **P2-2** | reload 失败原因回传 | agent + 应用 | 同上 | 1h |
|
||||||
|
| **P3** | 不做:防火墙读写、任意命令执行、agent 自升级 | — | — | — |
|
||||||
|
|
||||||
|
**贯穿约束(所有新增 agent 端点都适用)**:老版本 agent 没有新端点,应用侧调用必须**按「端点不存在」优雅降级**(收到 404/405 时显示「VPS 上的 agent 版本过低,请升级 Agent」而不是报错),否则用户升级了 Fonu 但没升级 agent 时页面会直接报错。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、P0 详细设计(不动 agent,纯应用侧)
|
||||||
|
|
||||||
|
### 3.1 端口矩阵
|
||||||
|
|
||||||
|
- **数据**:`status.ports`(布尔映射)。实现前先以 agent 代码为准列出全部键(当前已知 `80` / `443` / `7000` / `8080`)。
|
||||||
|
- **呈现**:AgentView「运行状态」段内加一张两列小表(端口 / 状态徽标),或把现有「8080 端口」那一项扩成完整列表。
|
||||||
|
- **降级**:agent 版本过旧、`ports` 缺失时整块显示「当前 agent 版本不返回端口信息」。
|
||||||
|
- **验收**:停掉监听 443 的服务后该行转异常色;旧 agent 上不报错。
|
||||||
|
|
||||||
|
### 3.2 部署漂移清单 + 一键全量重部署(价值最高)
|
||||||
|
|
||||||
|
三个集合:`A` = 数据库里启用中的反代规则域名;`B` = `GET /api/v1/routes` 返回的域名;`C` = 其中 `loaded = true` 的域名。
|
||||||
|
|
||||||
|
| 类别 | 定义 | 含义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 未部署 | `A - B` | 库里要部署但 VPS 上没有(agent 重装、换机、手工删过) |
|
||||||
|
| 孤儿配置 | `B - A` | VPS 有但库里没有(手工加的 `fonu-*.conf`、已删规则的残留) |
|
||||||
|
| 未生效 | `A ∩ (B - C)` | 文件在但未被 Nginx 加载(`syntax_ok=false`,或 include 未覆盖) |
|
||||||
|
| 内容不一致 | `RouteConf(domain)` 与「按当前部署设置生成的内容」规范化比对 | 手工改过上游 / 证书路径 / 安全项 |
|
||||||
|
|
||||||
|
**一键全量重部署**:对 `A` 逐条 `SyncRouteSpec`(串行、逐条收集结果、失败不中断,沿用 `Service.Reload` 的隔离报错风格),结束后回读一次 `GET /routes` 复核,结果弹窗汇总「成功 N / 失败 M + 每条失败原因」。
|
||||||
|
|
||||||
|
**前端落点**:公网反代页顶部提示条(有差异时才出现)+「重新部署全部」按钮(二次确认,因为会覆盖手工改动)。
|
||||||
|
|
||||||
|
**验收**:手工删一个 `fonu-*.conf` → 清单出现「未部署」,重部署后消失;手工改一条 conf 的上游 → 出现「内容不一致」;语法错误时归入「未生效」并给出原因。
|
||||||
|
|
||||||
|
### 3.3 frps 运行一致性核对
|
||||||
|
|
||||||
|
- **比对三项**:`frps_unit_exec`(systemd 实际执行)vs Fonu 配置的 `frps_binary`;`frps_version` vs 期望版本;`frps_config_path` vs Fonu 生成路径。
|
||||||
|
- **呈现**:AgentView「frps 控制」段加一行「启动路径一致性」,不一致时并列显示两个路径。
|
||||||
|
- **验收**:手工改过 systemd unit 的 VPS 上显示不一致;一致的机器显示绿色。
|
||||||
|
|
||||||
|
### 3.4 证书推送核对
|
||||||
|
|
||||||
|
- **数据**:已有 `RouteHealth` 的证书段(`cert_ok` / `cert_detail`)+ 推送结果。
|
||||||
|
- **呈现**:公网反代页健康列已展示证书段;补一行「证书来源」(Fonu 推送 / 待推送 / VPS 本地已有)。
|
||||||
|
- **验收**:证书过期时健康列给出证书段失败与到期天数。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、P1 详细设计(新增 agent 端点)
|
||||||
|
|
||||||
|
> 改动流程见 §7.1;以下端点响应都带 `checked_at`,与现有 `status` 保持一致。
|
||||||
|
|
||||||
|
### 4.1 `GET /api/v1/metrics` —— VPS 资源采集
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cpu_percent": 12.5,
|
||||||
|
"mem_used_bytes": 1288490188,
|
||||||
|
"mem_total_bytes": 4294967296,
|
||||||
|
"disk_used_bytes": 21474836480,
|
||||||
|
"disk_total_bytes": 53687091200,
|
||||||
|
"disk_path": "/",
|
||||||
|
"load_avg": [0.42, 0.51, 0.63],
|
||||||
|
"uptime_seconds": 864000,
|
||||||
|
"checked_at": "2026-09-23T19:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **实现要点**:**直接复用仓库里已有的 `internal/sysinfo`**(同一 module,`cmd/fonu-agent` 可直接引用)——`/proc/stat`、`/proc/meminfo`、`Statfs` 以及 Windows 的 kernel32 路径都已写好;agent 跑在 VPS 上,采到的天然是 VPS 自身数值。
|
||||||
|
- **CPU 差值**:`sysinfo.Read(prev)` 需要上次采样;agent 进程内保存上次 `CPUSample`,并对间隔 < 1 秒的重复调用直接返回上次结果(避免连续刷新时数值抖动)。
|
||||||
|
- **`load_avg` / `uptime_seconds`**:Linux 读 `/proc/loadavg`、`/proc/uptime`;非 Linux 省略这两个字段(前端按缺失处理)。
|
||||||
|
- **应用侧链路**:`AgentClient.Metrics(ctx)` → `GET /api/frpmulti/servers/{id}/agent/metrics` → AgentView 新增「VPS 资源」卡(复用本地仪表盘「系统资源」的行样式:名称 / 用量明细 / 4px 进度条 / 百分比,≥75% 转橙、≥90% 转红)。
|
||||||
|
- **降级**:404/405 → 卡片显示「VPS 上的 agent 版本过低(需 ≥ 0.10.0),请升级 Agent」。
|
||||||
|
- **验收**:VPS 上跑 `dd` 或压测时 CPU / 磁盘数值随之上升;磁盘接近写满时进度条转红;旧 agent 上显示升级提示而不是报错。
|
||||||
|
|
||||||
|
### 4.2 `GET /api/v1/logs/nginx` —— 公网 Nginx 日志
|
||||||
|
|
||||||
|
请求:`GET /api/v1/logs/nginx?type=access|error&lines=200`(`lines` 上限 2000,默认 200)
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "access",
|
||||||
|
"path": "/var/log/nginx/access.log",
|
||||||
|
"lines": ["127.0.0.1 - - [23/Sep/2026:19:00:00 +0800] \"GET / HTTP/1.1\" 200 1234"],
|
||||||
|
"truncated": false,
|
||||||
|
"checked_at": "2026-09-23T19:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **路径来源(白名单,不接受任意 path 参数)**:
|
||||||
|
1. 优先从 `nginx -T` 的 dump 里解析 `access_log` / `error_log` 指令(agent 已有 `nginx -T` 解析与缓存经验,见 `resolveNginxConfDir`);
|
||||||
|
2. 解析不到时回落默认路径 `/var/log/nginx/{access,error}.log`;
|
||||||
|
3. 支持环境变量 `FONU_AGENT_NGINX_LOG_DIR` 覆盖(与现有 `FONU_AGENT_NGINX_CONF_DIR` 的回退风格一致)。
|
||||||
|
- **只读尾部**:实现 `tailFile(path, n)`(与现有 `journalTail` 同思路),从文件末尾按块倒读,避免把整个日志读进内存;单行超长(如 4KB)截断。
|
||||||
|
- **错误处理**:文件不存在 → 200 + `lines: []` + 原样返回 `path`(前端显示「未找到日志文件」);无权限 → 403 并给出明确原因。
|
||||||
|
- **应用侧链路**:`AgentClient.NginxLogs(ctx, serverID, type, lines)` → `GET /api/frpmulti/servers/{id}/agent/logs/nginx?...`;日志中心新增「公网 Nginx」页签,复用现有 `LogToolbar` / `LogPagination` / `EmptyState` 与 `logTimeCell`,与「FRP 日志」页签同一套呈现。
|
||||||
|
- **验收**:VPS 上产生一次访问后页签能看到该条 access 记录;agent 无读权限时显示明确错误;`lines` 超过 2000 被夹到上限。
|
||||||
|
|
||||||
|
### 4.3 `GET /api/v1/frps/config` —— frps.toml 回读
|
||||||
|
|
||||||
|
响应:`{ "path": "/etc/frp/frps.toml", "exists": true, "content": "...", "mod_time": "2026-09-23T18:00:00Z", "checked_at": "..." }`
|
||||||
|
|
||||||
|
- **实现要点**:读 `s.cfg.FrpsConfigPath`,限制文件大小(如 256KB),超限返回 `truncated: true`;文件不存在返回 `exists: false` 而不是错误。
|
||||||
|
- **应用侧**:在「公网服务端」的 frps 同源配置弹窗里加「与 VPS 实际文件比对」——把「Fonu 即将下发的内容」与回读内容做规范化 diff(去注释与空白),不一致时提示「VPS 上的 frps.toml 已被手工修改」,并给「重新下发」按钮(复用已有 `PushFRPSConfig`)。
|
||||||
|
- **验收**:在 VPS 上手工改一行 frps.toml → 弹窗提示不一致并高亮差异行;重新下发后恢复一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、P2 详细设计
|
||||||
|
|
||||||
|
### 5.1 `GET /api/v1/certs` —— VPS 证书清单
|
||||||
|
|
||||||
|
响应:`{ "certs": [{ "domain": "7023.36800.cc", "cert_path": "...", "key_path": "...", "not_after": "2026-12-01T00:00:00Z", "days_left": 69 }], "warnings": [], "checked_at": "..." }`
|
||||||
|
|
||||||
|
- **实现要点**:遍历 `s.cfg.CertsDir` 下的域名目录,用标准库 `crypto/x509` 解析 `fullchain.pem` 的 `NotAfter`;解析失败的单条跳过并记入 `warnings`。
|
||||||
|
- **应用侧**:与 Fonu 的证书列表交叉核对,输出「Fonu 有 / VPS 有 / 两边都有但到期时间不同」三类;宝塔自行申请的证书也能被看到。
|
||||||
|
- **验收**:过期证书 `days_left < 0` 并标红;Fonu 未推送过的域名出现在「VPS 本地已有」分组。
|
||||||
|
|
||||||
|
### 5.2 reload 失败原因回传
|
||||||
|
|
||||||
|
- **做法(择一,倾向 A)**:
|
||||||
|
- **A**:`PUT /api/v1/routes` 与 `DELETE /api/v1/routes/{domain}` 的响应体增加 `reload_ok` / `reload_error` 字段——写入成功但 reload 失败时不再只回 200。
|
||||||
|
- **B**:新增 `POST /api/v1/nginx/reload`,返回 `nginx -t` 与 reload 的 stderr。
|
||||||
|
- **应用侧**:部署结果提示里带上 `reload_error`;漂移清单的「未生效」类别直接引用它。
|
||||||
|
- **验收**:故意写一条语法错误片段 → 部署结果明确显示 reload 失败原因,而不是「部署成功」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、明确不做的
|
||||||
|
|
||||||
|
1. **VPS 防火墙规则读写**(ufw / firewalld / 宝塔面板差异大、需要 root,误操作会直接断网)——最多做只读展示。
|
||||||
|
2. **任意命令执行端点**——agent 目前只有 token 鉴权,开放 shell 等于把 VPS 交出去。
|
||||||
|
3. **agent 自升级**——现有链路(Fonu 内嵌二进制 + SSH 上传 + 重启 systemd)已经闭环,自升级只省掉存 SSH 凭据,收益有限,还要额外处理下载源与签名校验。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、改动流程与验收
|
||||||
|
|
||||||
|
### 7.1 agent 侧(每新增一个端点都要走完)
|
||||||
|
|
||||||
|
1. `internal/agent/server.go`:加 handler + `mux.HandleFunc(...)`,并在 `AgentVersion` 上方按现有约定补一行版本说明(例如 `// 0.10.0:新增 /api/v1/metrics 资源采集与 /api/v1/logs/nginx 日志读取。`),把常量递增到 **`0.10.0`**。
|
||||||
|
2. 重编两个架构的内嵌二进制(Fonu 编译期嵌入,必须是真实二进制而不是占位文件):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:GOOS='linux'; $env:CGO_ENABLED='0'; $env:GOARCH='amd64'
|
||||||
|
go build -trimpath -o internal/frp/agentbin/fonu-agent-linux-amd64.new ./cmd/fonu-agent
|
||||||
|
$env:GOARCH='arm64'
|
||||||
|
go build -trimpath -o internal/frp/agentbin/fonu-agent-linux-arm64.new ./cmd/fonu-agent
|
||||||
|
Move-Item -Force internal/frp/agentbin/fonu-agent-linux-amd64.new internal/frp/agentbin/fonu-agent-linux-amd64
|
||||||
|
Move-Item -Force internal/frp/agentbin/fonu-agent-linux-arm64.new internal/frp/agentbin/fonu-agent-linux-arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 重启本机 Fonu(加载新内嵌二进制)→ VPS 上「升级 Agent」→ 用 `GET /api/v1/status` 的 `agent_version` 确认已是 `0.10.0`。
|
||||||
|
|
||||||
|
### 7.2 应用侧(每个端点对应一条链路)
|
||||||
|
|
||||||
|
`internal/frp/agent_client.go` 加方法 → `internal/frp/service.go` 加业务方法(含**端点不存在时的降级**:区分 404/405 与真实错误)→ `internal/api/frpmulti_handler.go` 加 handler → `internal/api/router.go` 注册(沿用现有 `agent/*` 路径风格)→ `web/src/api/{client,types}.ts` → 前端页面。
|
||||||
|
|
||||||
|
### 7.3 验收(每批的检查项)
|
||||||
|
|
||||||
|
- **P0**:手工制造四类漂移各一次,清单分类正确;全量重部署在单条失败时其余仍继续并汇总报错;旧 agent 上端口矩阵降级不报错。
|
||||||
|
- **P1**:`metrics` 数值随 VPS 负载变化且 CPU 不抖动;`logs/nginx` 尾部读取正确且超限被夹到 2000;`frps/config` 回读能检出手工改动。
|
||||||
|
- **P2**:证书清单含宝塔自签证书与到期天数;语法错误片段部署时回传 reload 失败原因。
|
||||||
|
- **通用**:新端点全部走 `s.auth`;`go vet` / `go test ./internal/...` / `vue-tsc` 全绿;agent 版本不匹配时前端一律显示升级提示而不是报错。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、风险与回滚
|
||||||
|
|
||||||
|
| 风险 | 说明 | 应对 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **新老版本错配** | Fonu 已升级、VPS 上的 agent 仍是旧版 | 应用侧对新端点统一做 404/405 降级,前端显示「请升级 Agent」;不改动已有端点的行为 |
|
||||||
|
| **只读端点的权限** | `/var/log/nginx` 与证书目录可能不允许 agent 运行用户读取 | 端点返回明确的 403 + 路径,前端提示「agent 无读取权限」;安装脚本里补充读权限说明 |
|
||||||
|
| **`metrics` 采样抖动** | 高频调用时 CPU 差值失真 | 间隔 < 1 秒直接返回上次结果;前端按 15 秒轮询(与本地仪表盘一致) |
|
||||||
|
| **全量重部署覆盖手工改动** | 会覆盖 VPS 上手工编辑过的 `fonu-*.conf` | 按钮带二次确认 + 差异预览;`RouteConf` 回读内容先展示再覆盖 |
|
||||||
|
| **回滚** | agent 行为变更后需要退回旧版本 | agent 二进制随 Fonu 版本走:用旧版 Fonu 的「升级 Agent」即可把旧二进制重新上传;新增端点均为**纯增量**,不改变已有端点语义,应用侧回滚也无需数据迁移 |
|
||||||
|
|
||||||
|
**总原则**:新增端点一律只读或纯增量、不改变已有端点语义;所有 agent 交互都保持「Fonu 主动拉取、agent 被动应答」的现有模型,不引入 agent 主动上报。
|
||||||
@@ -468,6 +468,36 @@ func (h *FRPMultiHandler) AgentRouteHealthSummary(w http.ResponseWriter, r *http
|
|||||||
writeJSON(w, http.StatusOK, summary)
|
writeJSON(w, http.StatusOK, summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AgentRouteDrift 比对库里的期望与 VPS 上的实际配置,返回部署漂移清单
|
||||||
|
func (h *FRPMultiHandler) AgentRouteDrift(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
report, err := h.svc.RouteDrift(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, report)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentRedeployRoutes 把该服务端上所有启用中的公网反代规则重新下发一遍
|
||||||
|
func (h *FRPMultiHandler) AgentRedeployRoutes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.svc.RedeployAllRoutes(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request) {
|
func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := parseID(r.PathValue("id"))
|
id, err := parseID(r.PathValue("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ func NewRouter(deps Deps) http.Handler {
|
|||||||
protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes)
|
protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes)
|
||||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/health", frpHandler.AgentRouteHealth)
|
protect("GET /api/frpmulti/servers/{id}/agent/routes/health", frpHandler.AgentRouteHealth)
|
||||||
protect("GET /api/frpmulti/routes/summary", frpHandler.AgentRouteHealthSummary)
|
protect("GET /api/frpmulti/routes/summary", frpHandler.AgentRouteHealthSummary)
|
||||||
|
protect("GET /api/frpmulti/servers/{id}/agent/routes/drift", frpHandler.AgentRouteDrift)
|
||||||
|
protect("POST /api/frpmulti/servers/{id}/agent/routes/redeploy", frpHandler.AgentRedeployRoutes)
|
||||||
protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx)
|
protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx)
|
||||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf)
|
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf)
|
||||||
protect("GET /api/frpmulti/servers/{id}/agent/route-candidates", frpHandler.AgentRouteCandidates)
|
protect("GET /api/frpmulti/servers/{id}/agent/route-candidates", frpHandler.AgentRouteCandidates)
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package frp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 漂移类型:库里的期望(A)与 VPS 上的实际(B/C)之间的差异分类
|
||||||
|
const (
|
||||||
|
RouteDriftMissing = "missing" // 库里要部署但 VPS 上没有
|
||||||
|
RouteDriftOrphan = "orphan" // VPS 有但库里没有(手工添加或已删规则的残留)
|
||||||
|
RouteDriftNotLoaded = "not_loaded" // 文件在但未被 Nginx 加载
|
||||||
|
RouteDriftContentMismatch = "content_mismatch" // 上流地址与库里的期望不一致
|
||||||
|
)
|
||||||
|
|
||||||
|
// RouteDriftItem 一条差异
|
||||||
|
type RouteDriftItem struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Expected string `json:"expected,omitempty"`
|
||||||
|
Actual string `json:"actual,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteDriftReport 部署漂移报告:Expected = 库里启用中的规则域名数,Deployed = VPS 上 fonu-*.conf 数,Loaded = 其中被加载的数
|
||||||
|
type RouteDriftReport struct {
|
||||||
|
ServerID int64 `json:"server_id"`
|
||||||
|
Expected int `json:"expected"`
|
||||||
|
Deployed int `json:"deployed"`
|
||||||
|
Loaded int `json:"loaded"`
|
||||||
|
Items []RouteDriftItem `json:"items"`
|
||||||
|
CheckedAt string `json:"checked_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteDrift 比对「数据库里启用中的公网反代规则」与「VPS 上 fonu-*.conf 的实际状态」。
|
||||||
|
// 内容比对只做上流地址(proxy_pass):配置文件由 agent 渲染,应用侧不重复实现一套渲染器,
|
||||||
|
// 因此不做全文 diff,避免两套渲染逻辑漂移带来的误报。
|
||||||
|
func (s *Service) RouteDrift(ctx context.Context, serverID int64) (RouteDriftReport, error) {
|
||||||
|
client, ok := s.agentClient(ctx, serverID)
|
||||||
|
if !ok {
|
||||||
|
return RouteDriftReport{}, fmt.Errorf("公网 agent 未配置或 Token 未保存")
|
||||||
|
}
|
||||||
|
proxies, err := s.store.ListEnabledProxies(ctx, serverID)
|
||||||
|
if err != nil {
|
||||||
|
return RouteDriftReport{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A:库里的期望域名 → 期望上流
|
||||||
|
expected := map[string]string{}
|
||||||
|
for _, proxy := range proxies {
|
||||||
|
if !deployableProxy(proxy) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
upstream, uerr := s.agentUpstreamFor(ctx, proxy)
|
||||||
|
if uerr != nil {
|
||||||
|
upstream = ""
|
||||||
|
}
|
||||||
|
for _, domain := range proxy.CustomDomains {
|
||||||
|
expected[domain] = upstream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
details, err := client.RoutesDetail(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return RouteDriftReport{}, err
|
||||||
|
}
|
||||||
|
// B / C:VPS 上的实际
|
||||||
|
actual := make(map[string]RouteDetail, len(details))
|
||||||
|
loaded := 0
|
||||||
|
for _, detail := range details {
|
||||||
|
actual[detail.Domain] = detail
|
||||||
|
if detail.Loaded {
|
||||||
|
loaded++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
report := RouteDriftReport{
|
||||||
|
ServerID: serverID,
|
||||||
|
Expected: len(expected),
|
||||||
|
Deployed: len(details),
|
||||||
|
Loaded: loaded,
|
||||||
|
CheckedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
for domain, want := range expected {
|
||||||
|
detail, ok := actual[domain]
|
||||||
|
if !ok {
|
||||||
|
report.Items = append(report.Items, RouteDriftItem{
|
||||||
|
Domain: domain, Kind: RouteDriftMissing,
|
||||||
|
Detail: "VPS 上没有该域名的配置,重新部署即可写入",
|
||||||
|
Expected: want,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !detail.Loaded {
|
||||||
|
report.Items = append(report.Items, RouteDriftItem{
|
||||||
|
Domain: domain, Kind: RouteDriftNotLoaded,
|
||||||
|
Detail: "配置文件存在但未被 Nginx 加载(语法未通过,或 include 未覆盖该文件)",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if want == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content, cerr := client.RouteConf(ctx, domain)
|
||||||
|
if cerr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
got := normalizeUpstream(proxyPassTarget(content))
|
||||||
|
if got != "" && got != normalizeUpstream(want) {
|
||||||
|
report.Items = append(report.Items, RouteDriftItem{
|
||||||
|
Domain: domain, Kind: RouteDriftContentMismatch,
|
||||||
|
Detail: "VPS 上的上流地址与 Fonu 记录不一致(可能被手工修改过)",
|
||||||
|
Expected: want, Actual: got,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for domain := range actual {
|
||||||
|
if _, ok := expected[domain]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
report.Items = append(report.Items, RouteDriftItem{
|
||||||
|
Domain: domain, Kind: RouteDriftOrphan,
|
||||||
|
Detail: "VPS 上存在该域名配置,但 Fonu 里没有对应规则(手工添加,或已删除规则的残留)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(report.Items, func(i, j int) bool {
|
||||||
|
if report.Items[i].Kind != report.Items[j].Kind {
|
||||||
|
return report.Items[i].Kind < report.Items[j].Kind
|
||||||
|
}
|
||||||
|
return report.Items[i].Domain < report.Items[j].Domain
|
||||||
|
})
|
||||||
|
return report, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedeployRouteResult 单条规则的重新部署结果
|
||||||
|
type RedeployRouteResult struct {
|
||||||
|
ProxyID int64 `json:"proxy_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Domains string `json:"domains"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedeployAllResult 全量重部署汇总
|
||||||
|
type RedeployAllResult struct {
|
||||||
|
ServerID int64 `json:"server_id"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Succeeded int `json:"succeeded"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Results []RedeployRouteResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedeployAllRoutes 把该服务端上所有启用中的公网反代规则重新下发一遍。
|
||||||
|
// 逐条执行、单条失败不中断(沿用 Reload 的隔离报错风格),返回逐条结果供前端汇总展示。
|
||||||
|
func (s *Service) RedeployAllRoutes(ctx context.Context, serverID int64) (RedeployAllResult, error) {
|
||||||
|
proxies, err := s.store.ListEnabledProxies(ctx, serverID)
|
||||||
|
if err != nil {
|
||||||
|
return RedeployAllResult{}, err
|
||||||
|
}
|
||||||
|
result := RedeployAllResult{ServerID: serverID, Results: []RedeployRouteResult{}}
|
||||||
|
for _, proxy := range proxies {
|
||||||
|
if !deployableProxy(proxy) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Total++
|
||||||
|
item := RedeployRouteResult{
|
||||||
|
ProxyID: proxy.ID,
|
||||||
|
Name: proxy.Name,
|
||||||
|
Domains: strings.Join(proxy.CustomDomains, "、"),
|
||||||
|
}
|
||||||
|
if err := s.DeployAgentRoute(ctx, proxy.ID); err != nil {
|
||||||
|
item.Error = err.Error()
|
||||||
|
result.Failed++
|
||||||
|
} else {
|
||||||
|
item.OK = true
|
||||||
|
result.Succeeded++
|
||||||
|
}
|
||||||
|
result.Results = append(result.Results, item)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deployableProxy 与 DeployAgentRoute 的准入条件保持一致:启用的 http/https 规则且填了自定义域名
|
||||||
|
func deployableProxy(proxy Proxy) bool {
|
||||||
|
if !proxy.Enabled || len(proxy.CustomDomains) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return proxy.Type == "http" || proxy.Type == "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyPassTarget 从 Nginx 配置文本里取出第一条 proxy_pass 的目标地址
|
||||||
|
func proxyPassTarget(content string) string {
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if !strings.HasPrefix(trimmed, "proxy_pass ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value := strings.TrimPrefix(trimmed, "proxy_pass ")
|
||||||
|
return strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(value), ";"))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeUpstream 去掉协议前缀与结尾斜杠,便于比较(agent 渲染的是 http://127.0.0.1:8080,应用侧期望的是 127.0.0.1:8080)
|
||||||
|
func normalizeUpstream(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
value = strings.TrimPrefix(value, "http://")
|
||||||
|
value = strings.TrimPrefix(value, "https://")
|
||||||
|
return strings.TrimSuffix(value, "/")
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package frp
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestProxyPassTarget(t *testing.T) {
|
||||||
|
content := "server {\n listen 443 ssl;\n location / {\n proxy_pass http://127.0.0.1:8080;\n }\n}\n"
|
||||||
|
if got := proxyPassTarget(content); got != "http://127.0.0.1:8080" {
|
||||||
|
t.Fatalf("unexpected target: %q", got)
|
||||||
|
}
|
||||||
|
if got := proxyPassTarget("server {\n listen 80;\n}\n"); got != "" {
|
||||||
|
t.Fatalf("expected empty target, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeUpstream(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"http://127.0.0.1:8080": "127.0.0.1:8080",
|
||||||
|
"https://127.0.0.1:8443/": "127.0.0.1:8443",
|
||||||
|
"127.0.0.1:8080": "127.0.0.1:8080",
|
||||||
|
}
|
||||||
|
for input, want := range cases {
|
||||||
|
if got := normalizeUpstream(input); got != want {
|
||||||
|
t.Fatalf("normalizeUpstream(%q) = %q,期望 %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployableProxy(t *testing.T) {
|
||||||
|
deployable := Proxy{Enabled: true, Type: "http", CustomDomains: []string{"a.com"}}
|
||||||
|
if !deployableProxy(deployable) {
|
||||||
|
t.Fatal("启用的 http 规则且有自定义域名,应可部署")
|
||||||
|
}
|
||||||
|
for _, proxy := range []Proxy{
|
||||||
|
{Enabled: false, Type: "http", CustomDomains: []string{"a.com"}},
|
||||||
|
{Enabled: true, Type: "tcp", CustomDomains: []string{"a.com"}},
|
||||||
|
{Enabled: true, Type: "http"},
|
||||||
|
} {
|
||||||
|
if deployableProxy(proxy) {
|
||||||
|
t.Fatalf("不应判定为可部署:%+v", proxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -290,6 +290,12 @@ export const api = {
|
|||||||
uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { 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' }),
|
sshDiagnoseFrpAgent: (id: number) => request<{ output: string }>(`/api/frpmulti/servers/${id}/agent/ssh-diagnose`, { method: 'POST' }),
|
||||||
getFrpAgentStatus: (id: number) => request<import('./types').FrpAgentStatus>(`/api/frpmulti/servers/${id}/agent/status`),
|
getFrpAgentStatus: (id: number) => request<import('./types').FrpAgentStatus>(`/api/frpmulti/servers/${id}/agent/status`),
|
||||||
|
getFrpAgentRouteDrift: (id: number) =>
|
||||||
|
request<import('./types').FrpRouteDriftReport>(`/api/frpmulti/servers/${id}/agent/routes/drift`),
|
||||||
|
redeployFrpAgentRoutes: (id: number) =>
|
||||||
|
request<import('./types').FrpRedeployAllResult>(`/api/frpmulti/servers/${id}/agent/routes/redeploy`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
getFrpAgentRoutes: (id: number) => request<import('./types').FrpAgentRouteDetail[]>(`/api/frpmulti/servers/${id}/agent/routes`),
|
getFrpAgentRoutes: (id: number) => request<import('./types').FrpAgentRouteDetail[]>(`/api/frpmulti/servers/${id}/agent/routes`),
|
||||||
getFrpAgentRouteCandidates: (id: number) => request<import('./types').FrpProxy[]>(`/api/frpmulti/servers/${id}/agent/route-candidates`),
|
getFrpAgentRouteCandidates: (id: number) => request<import('./types').FrpProxy[]>(`/api/frpmulti/servers/${id}/agent/route-candidates`),
|
||||||
deployFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'POST' }),
|
deployFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'POST' }),
|
||||||
|
|||||||
@@ -736,6 +736,39 @@ export interface FrpRouteHealthSummary {
|
|||||||
failed_servers?: string[]
|
failed_servers?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FrpRouteDriftItem {
|
||||||
|
domain: string
|
||||||
|
kind: 'missing' | 'orphan' | 'not_loaded' | 'content_mismatch'
|
||||||
|
detail: string
|
||||||
|
expected?: string
|
||||||
|
actual?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrpRouteDriftReport {
|
||||||
|
server_id: number
|
||||||
|
expected: number
|
||||||
|
deployed: number
|
||||||
|
loaded: number
|
||||||
|
items: FrpRouteDriftItem[]
|
||||||
|
checked_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrpRedeployRouteResult {
|
||||||
|
proxy_id: number
|
||||||
|
name: string
|
||||||
|
domains: string
|
||||||
|
ok: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrpRedeployAllResult {
|
||||||
|
server_id: number
|
||||||
|
total: number
|
||||||
|
succeeded: number
|
||||||
|
failed: number
|
||||||
|
results: FrpRedeployRouteResult[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface FrpAgentRouteDetail {
|
export interface FrpAgentRouteDetail {
|
||||||
domain: string
|
domain: string
|
||||||
file_exists: boolean
|
file_exists: boolean
|
||||||
|
|||||||
@@ -70,7 +70,13 @@
|
|||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<n-icon :component="SwapHorizontalOutline" class="stat-card__icon" :class="portIconClass" aria-hidden="true" />
|
<n-icon :component="SwapHorizontalOutline" class="stat-card__icon" :class="portIconClass" aria-hidden="true" />
|
||||||
<div class="port-list">
|
<div class="port-list">
|
||||||
<span v-for="port in watchedPorts" :key="port" class="port-item" :class="{ 'port-item--on': portOn(port) }">
|
<span
|
||||||
|
v-for="port in watchedPorts"
|
||||||
|
:key="port"
|
||||||
|
class="port-item"
|
||||||
|
:class="{ 'port-item--on': portOn(port) }"
|
||||||
|
:title="portMeaning(port)"
|
||||||
|
>
|
||||||
{{ port }} {{ portOn(port) ? '✓' : '✗' }}
|
{{ port }} {{ portOn(port) ? '✓' : '✗' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,7 +129,9 @@
|
|||||||
<div><label>二进制路径</label><span class="mono mono-wrap">{{ agentStatus?.frps_binary || '—' }}</span></div>
|
<div><label>二进制路径</label><span class="mono mono-wrap">{{ agentStatus?.frps_binary || '—' }}</span></div>
|
||||||
<div><label>配置文件</label><span class="mono mono-wrap">{{ agentStatus?.frps_config_path || '—' }}</span></div>
|
<div><label>配置文件</label><span class="mono mono-wrap">{{ agentStatus?.frps_config_path || '—' }}</span></div>
|
||||||
<div class="field-grid__wide"><label>启动命令</label><span class="mono mono-wrap">{{ agentStatus?.frps_unit_exec || '—' }}</span></div>
|
<div class="field-grid__wide"><label>启动命令</label><span class="mono mono-wrap">{{ agentStatus?.frps_unit_exec || '—' }}</span></div>
|
||||||
|
<div><label>启动一致性</label><span class="field-tags"><StatusBadge :kind="frpsConsistency.kind" :text="frpsConsistency.text" /></span></div>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="!frpsConsistency.ok && frpsConsistency.detail" class="form-hint">{{ frpsConsistency.detail }}</p>
|
||||||
</div>
|
</div>
|
||||||
</FonuCard>
|
</FonuCard>
|
||||||
</section>
|
</section>
|
||||||
@@ -282,6 +290,34 @@ let timer: number | undefined
|
|||||||
|
|
||||||
const watchedPorts = ['80', '443', '7000', '8080'] as const
|
const watchedPorts = ['80', '443', '7000', '8080'] as const
|
||||||
|
|
||||||
|
// 端口含义(P0-1):让端口矩阵直接可读,不用再去查哪个端口是谁的
|
||||||
|
const portMeanings: Record<string, string> = {
|
||||||
|
'80': '公网 HTTP 入口(Nginx)',
|
||||||
|
'443': '公网 HTTPS 入口(Nginx)',
|
||||||
|
'7000': 'frps 控制端口(frpc 连接用)',
|
||||||
|
'8080': 'frps vhost HTTP 端口(Nginx 回源到 frps)',
|
||||||
|
}
|
||||||
|
|
||||||
|
function portMeaning(port: string) {
|
||||||
|
return `${portMeanings[port] ?? '端口'}:${portOn(port) ? '正在监听' : '未监听'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// frps 运行一致性(P0-3):systemd 实际执行的启动命令,是否用的是 Fonu 下发的二进制与配置文件。
|
||||||
|
// 不一致意味着:手工改过 unit,或者 Fonu 写入的 frps.toml 并不是 frps 实际加载的那一份。
|
||||||
|
const frpsConsistency = computed(() => {
|
||||||
|
const exec = agentStatus.value?.frps_unit_exec ?? ''
|
||||||
|
if (!exec) return { ok: false, kind: 'warning' as const, text: '未获取', detail: '' }
|
||||||
|
const binary = agentStatus.value?.frps_binary ?? ''
|
||||||
|
const config = agentStatus.value?.frps_config_path ?? ''
|
||||||
|
const problems: string[] = []
|
||||||
|
if (binary && !exec.includes(binary)) problems.push(`启动命令未使用配置的二进制(${binary})`)
|
||||||
|
if (config && !exec.includes(config)) problems.push(`启动命令未使用 Fonu 下发的配置文件(${config})`)
|
||||||
|
if (problems.length > 0) {
|
||||||
|
return { ok: false, kind: 'warning' as const, text: '不一致', detail: problems.join(';') }
|
||||||
|
}
|
||||||
|
return { ok: true, kind: 'success' as const, text: '一致', detail: '启动命令与配置的二进制、配置文件一致' }
|
||||||
|
})
|
||||||
|
|
||||||
const server = computed(() => servers.value.find((item) => item.id === selectedId.value) ?? null)
|
const server = computed(() => servers.value.find((item) => item.id === selectedId.value) ?? null)
|
||||||
const serverOptions = computed(() => servers.value
|
const serverOptions = computed(() => servers.value
|
||||||
.filter((item) => item.agent_configured || item.ssh_configured)
|
.filter((item) => item.agent_configured || item.ssh_configured)
|
||||||
|
|||||||
@@ -35,6 +35,25 @@
|
|||||||
<!-- 数据读取失败:必须可见(原先 loadErrorMsg 写了但从未渲染) -->
|
<!-- 数据读取失败:必须可见(原先 loadErrorMsg 写了但从未渲染) -->
|
||||||
<n-alert v-if="loadErrorMsg" type="error" :bordered="false" class="page-alert">{{ loadErrorMsg }}</n-alert>
|
<n-alert v-if="loadErrorMsg" type="error" :bordered="false" class="page-alert">{{ loadErrorMsg }}</n-alert>
|
||||||
|
|
||||||
|
<!-- 部署漂移:库里的期望与 VPS 上的实际不一致时才出现(P0-2) -->
|
||||||
|
<n-alert v-if="driftItems.length > 0" type="warning" :bordered="false" class="page-alert">
|
||||||
|
<div class="alert-body">
|
||||||
|
<div class="alert-body__text">
|
||||||
|
<strong>检测到 {{ driftItems.length }} 项部署漂移({{ driftSummary }})</strong>
|
||||||
|
<span>VPS 上的实际配置与 Fonu 记录不一致:可能被手工修改过,或 agent 重装后没有重新部署。</span>
|
||||||
|
<span class="mono alert-body__detail">{{ driftPreview }}</span>
|
||||||
|
</div>
|
||||||
|
<n-popconfirm @positive-click="redeployAll">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button size="small" type="primary" :loading="redeploying" :disabled="busy !== null">
|
||||||
|
重新部署全部
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
将按 Fonu 里的记录重新下发该服务端的全部启用规则,会覆盖 VPS 上的手工修改。确定继续?
|
||||||
|
</n-popconfirm>
|
||||||
|
</div>
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
<!-- Nginx 前置条件:缺失 / 校验未通过 -->
|
<!-- Nginx 前置条件:缺失 / 校验未通过 -->
|
||||||
<n-alert v-if="nginxBlocking" :type="nginxBlocking.type" :bordered="false" class="page-alert">
|
<n-alert v-if="nginxBlocking" :type="nginxBlocking.type" :bordered="false" class="page-alert">
|
||||||
<div class="alert-body">
|
<div class="alert-body">
|
||||||
@@ -331,7 +350,8 @@ import PageHeader from '../components/PageHeader.vue'
|
|||||||
import StatusBadge from '../components/StatusBadge.vue'
|
import StatusBadge from '../components/StatusBadge.vue'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import type {
|
import type {
|
||||||
FrpAgentRouteDetail, FrpAgentRouteHealth, FrpAgentStatus, FrpProxy, FrpProxyTraffic, FrpServer,
|
CertificateRecord, FrpAgentRouteDetail, FrpAgentRouteHealth, FrpAgentStatus, FrpProxy, FrpProxyTraffic,
|
||||||
|
FrpRouteDriftReport, FrpServer,
|
||||||
} from '../api/types'
|
} from '../api/types'
|
||||||
import { formatBytes, formatDate, formatRate, formatRelativeTime } from '../utils/format'
|
import { formatBytes, formatDate, formatRate, formatRelativeTime } from '../utils/format'
|
||||||
import { copyText } from '../utils/clipboard'
|
import { copyText } from '../utils/clipboard'
|
||||||
@@ -355,6 +375,9 @@ const proxyTraffic = ref<Record<string, FrpProxyTraffic>>({})
|
|||||||
const trafficErrors = ref<Record<string, string>>({})
|
const trafficErrors = ref<Record<string, string>>({})
|
||||||
const agentStatus = ref<FrpAgentStatus | null>(null)
|
const agentStatus = ref<FrpAgentStatus | null>(null)
|
||||||
const statusLoaded = ref(false)
|
const statusLoaded = ref(false)
|
||||||
|
const routeDrift = ref<FrpRouteDriftReport | null>(null)
|
||||||
|
const certificates = ref<CertificateRecord[]>([])
|
||||||
|
const redeploying = ref(false)
|
||||||
const settingsModal = ref(false)
|
const settingsModal = ref(false)
|
||||||
const settingsTarget = ref<FrpProxy | null>(null)
|
const settingsTarget = ref<FrpProxy | null>(null)
|
||||||
const settingsTab = ref<'basic' | 'security' | 'advanced'>('basic')
|
const settingsTab = ref<'basic' | 'security' | 'advanced'>('basic')
|
||||||
@@ -452,6 +475,8 @@ async function refreshAll(options: { silent?: boolean } = { silent: true }) {
|
|||||||
await track('规则列表', () => api.getFrpAgentRouteCandidates(current.id), (value) => { candidates.value = value })
|
await track('规则列表', () => api.getFrpAgentRouteCandidates(current.id), (value) => { candidates.value = value })
|
||||||
await track('路由文件', () => api.getFrpAgentRoutes(current.id), (value) => { routeDetails.value = value })
|
await track('路由文件', () => api.getFrpAgentRoutes(current.id), (value) => { routeDetails.value = value })
|
||||||
await track('健康检查', () => api.getFrpAgentRouteHealth(current.id), (value) => { routeHealth.value = value })
|
await track('健康检查', () => api.getFrpAgentRouteHealth(current.id), (value) => { routeHealth.value = value })
|
||||||
|
await track('配置漂移', () => api.getFrpAgentRouteDrift(current.id), (value) => { routeDrift.value = value })
|
||||||
|
await track('证书列表', () => api.listCertificates(), (value) => { certificates.value = value })
|
||||||
await track('流量采集', () => api.listFrpProxiesTraffic(), (value) => {
|
await track('流量采集', () => api.listFrpProxiesTraffic(), (value) => {
|
||||||
proxyTraffic.value = value.items ?? {}
|
proxyTraffic.value = value.items ?? {}
|
||||||
trafficErrors.value = value.errors ?? {}
|
trafficErrors.value = value.errors ?? {}
|
||||||
@@ -502,17 +527,72 @@ function stateOf(candidate: FrpProxy): { state: RouteState; label: string; kind:
|
|||||||
return { state: 'none', label: '未部署', kind: 'disabled' }
|
return { state: 'none', label: '未部署', kind: 'disabled' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 证书来源(P0-4):Fonu 证书库里是否有该域名的证书(决定重新部署时能否推送证书、公网 HTTPS 能否终结)
|
||||||
|
function certSummary(domain: string) {
|
||||||
|
const record = certificates.value.find((item) => item.domain === domain)
|
||||||
|
if (!record) return 'Fonu 证书库:无(需先在「证书管理」申请)'
|
||||||
|
return `Fonu 证书库:有(剩余 ${record.days_left} 天)`
|
||||||
|
}
|
||||||
|
|
||||||
// 健康四段(按域名):DNS / 证书 / 隧道 / 服务
|
// 健康四段(按域名):DNS / 证书 / 隧道 / 服务
|
||||||
function healthOf(domain: string) {
|
function healthOf(domain: string) {
|
||||||
const health = routeHealth.value.find((item) => item.domain === domain)
|
const health = routeHealth.value.find((item) => item.domain === domain)
|
||||||
return [
|
return [
|
||||||
{ label: 'DNS', ok: health?.dns_ok ?? false, detail: health?.dns_detail || (health?.dns_ok ? '已指向服务端' : '未指向服务端或未检测') },
|
{ label: 'DNS', ok: health?.dns_ok ?? false, detail: health?.dns_detail || (health?.dns_ok ? '已指向服务端' : '未指向服务端或未检测') },
|
||||||
{ label: '证书', ok: health?.cert_ok ?? false, detail: health?.cert_detail || (health?.cert_ok ? '证书已覆盖该域名' : '未发现该域名的证书') },
|
{
|
||||||
|
label: '证书',
|
||||||
|
ok: health?.cert_ok ?? false,
|
||||||
|
detail: `${health?.cert_detail || (health?.cert_ok ? '证书已覆盖该域名' : '未发现该域名的证书')}|${certSummary(domain)}`,
|
||||||
|
},
|
||||||
{ label: '隧道', ok: health?.tunnel_ok ?? false, detail: health?.tunnel_detail || (health?.tunnel_ok ? '已注册到 frps' : 'frps 未发现该规则的隧道') },
|
{ label: '隧道', ok: health?.tunnel_ok ?? false, detail: health?.tunnel_detail || (health?.tunnel_ok ? '已注册到 frps' : 'frps 未发现该规则的隧道') },
|
||||||
{ label: '服务', ok: health?.service_ok ?? false, detail: health?.service_ok ? '内网服务可达' : '内网服务不可达' },
|
{ label: '服务', ok: health?.service_ok ?? false, detail: health?.service_ok ? '内网服务可达' : '内网服务不可达' },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 部署漂移:库里的期望与 VPS 上的实际之间的差异(未部署 / 孤儿配置 / 未生效 / 内容不一致)
|
||||||
|
const driftKindText: Record<string, string> = {
|
||||||
|
missing: '未部署',
|
||||||
|
orphan: '孤儿配置',
|
||||||
|
not_loaded: '未生效',
|
||||||
|
content_mismatch: '内容不一致',
|
||||||
|
}
|
||||||
|
|
||||||
|
const driftItems = computed(() => routeDrift.value?.items ?? [])
|
||||||
|
|
||||||
|
const driftSummary = computed(() => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const item of driftItems.value) {
|
||||||
|
counts.set(item.kind, (counts.get(item.kind) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
return [...counts.entries()].map(([kind, count]) => `${driftKindText[kind] ?? kind} ${count}`).join(' · ')
|
||||||
|
})
|
||||||
|
|
||||||
|
const driftPreview = computed(() => {
|
||||||
|
const preview = driftItems.value.slice(0, 3).map((item) => `${item.domain}(${driftKindText[item.kind] ?? item.kind})`)
|
||||||
|
return driftItems.value.length > 3 ? `${preview.join(';')} …` : preview.join(';')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 全量重部署:逐条下发,失败不中断(后端已逐条汇总)
|
||||||
|
async function redeployAll() {
|
||||||
|
const current = server.value
|
||||||
|
if (!current) return
|
||||||
|
redeploying.value = true
|
||||||
|
try {
|
||||||
|
const result = await api.redeployFrpAgentRoutes(current.id)
|
||||||
|
await refreshAll()
|
||||||
|
if (result.failed > 0) {
|
||||||
|
const firstError = result.results.find((item) => !item.ok)?.error ?? ''
|
||||||
|
message.warning(`重新部署:成功 ${result.succeeded} 条,失败 ${result.failed} 条${firstError ? `;首个失败:${firstError}` : ''}`)
|
||||||
|
} else {
|
||||||
|
message.success(`重新部署完成:${result.succeeded} 条规则全部成功`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '重新部署失败')
|
||||||
|
} finally {
|
||||||
|
redeploying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isHealthy(candidate: FrpProxy) {
|
function isHealthy(candidate: FrpProxy) {
|
||||||
const domains = domainsOf(candidate)
|
const domains = domainsOf(candidate)
|
||||||
if (!domains.length) return false
|
if (!domains.length) return false
|
||||||
|
|||||||
Reference in New Issue
Block a user