Compare commits

...
43 Commits
Author SHA1 Message Date
mrlee c377405696 chore(release): 发布 v1.9.0 并更新 Cloudflare 文档
CI / verify (push) Canceled after 0s
2026-10-01 13:12:37 +08:00
mrlee 83cc9d504b feat(cloudflare): 完成隧道接入、运维与 P0 体验优化
CI / verify (push) Canceled after 0s
2026-10-01 11:32:54 +08:00
mrlee 36d8b1e464 docs: 按 Fonu 定位输出 Cloudflare 隧道适配版功能计划
CI / verify (push) Canceled after 0s
2026-09-30 13:16:35 +08:00
mrlee dd40008be8 docs: 基于官方文档与参考项目输出 Cloudflare 隧道全功能规划
CI / verify (push) Canceled after 0s
2026-09-30 12:04:07 +08:00
mrlee 2d0f63517b feat(cloudflare): 新增全局托管开关并发布 v1.8.0
CI / verify (push) Canceled after 0s
2026-09-30 11:56:56 +08:00
mrlee 1434cfd72b feat(cloudflare): 自动创建隧道 DNS 记录并发布 v1.7.0
CI / verify (push) Canceled after 0s
2026-09-28 18:47:37 +08:00
mrlee af07ebd462 fix(cloudflare): 测试连接使用当前输入的 Account ID 并发布 v1.6.9
CI / verify (push) Canceled after 0s
2026-09-28 18:30:34 +08:00
mrlee 8f78aad19b feat(cloudflare): 改用 Account ID + API Token 创建管理隧道并发布 v1.6.8
CI / verify (push) Canceled after 0s
2026-09-28 18:23:38 +08:00
mrlee 7acf13583b feat(cloudflare): 支持 API Token 连接验证并发布 v1.6.7
CI / verify (push) Canceled after 0s
2026-09-28 17:53:45 +08:00
mrlee 26dd84abaa fix(cloudflare): Token 解码兼容 URL-safe base64 并发布 v1.6.6
CI / verify (push) Canceled after 0s
2026-09-28 17:48:09 +08:00
mrlee 3caff9fd8e feat(cloudflare): Token 配置新增测试连接与获取入口并发布 v1.6.5
CI / verify (push) Canceled after 0s
2026-09-28 17:32:06 +08:00
mrlee be35fbf546 fix(cloudflare): 隧道弹窗去除与应用配置重复字段并发布 v1.6.4
CI / verify (push) Canceled after 0s
2026-09-28 17:27:15 +08:00
mrlee 5e995dee28 feat(cloudflare): 应用配置支持程序安装与默认 Token 并发布 v1.6.3
CI / verify (push) Canceled after 0s
2026-09-28 16:59:30 +08:00
mrlee 0d7af99a78 fix(cloudflare): 统一隧道弹窗宽度并发布 v1.6.2
CI / verify (push) Canceled after 0s
2026-09-28 16:44:39 +08:00
mrlee aefa2a5b20 feat(cloudflare): 完善隧道页面弹窗并发布 v1.6.1
CI / verify (push) Canceled after 0s
2026-09-28 16:41:15 +08:00
mrlee a8a33e87d2 feat(cloudflare): 新增 Cloudflare 隧道独立模块并发布 v1.6.0
CI / verify (push) Canceled after 0s
2026-09-28 16:36:05 +08:00
mrlee 66081e93d5 docs: 结合 Tunnel-Cloudflared-Manager 1.0.1 优化 Cloudflare Tunnel 方案
CI / verify (push) Canceled after 0s
2026-09-28 16:16:10 +08:00
mrlee 8481bc3ac8 refactor(web): 统一前端轮询组合式函数并发布 v1.5.4
CI / verify (push) Canceled after 0s
2026-09-28 16:06:52 +08:00
mrlee 21efc10536 test: 补路由鉴权、配置回滚与 Agent 版本兼容测试并发布 v1.5.3
CI / verify (push) Canceled after 0s
2026-09-28 15:53:36 +08:00
mrlee 0feb6132d6 fix(agent): 升级 Agent 保持 HTTPS 直连并清理残留反代并发布 v1.5.2
CI / verify (push) Canceled after 0s
2026-09-28 15:42:16 +08:00
mrlee 1ada58af02 refactor(nginx): 抽取共享 Nginx 模板包并发布 v1.5.1
CI / verify (push) Canceled after 0s
2026-09-28 15:37:14 +08:00
mrlee faf43293e5 feat(agent): 完成 HTTPS 自动端口、证书轮换与防火墙辅助并发布 v1.5.0
CI / verify (push) Canceled after 0s
2026-09-28 15:19:51 +08:00
mrlee dfce843b50 fix(agent): HTTPS 自检证书指纹并定位端口占用并发布 v1.4.6
CI / verify (push) Canceled after 0s
2026-09-28 14:57:11 +08:00
mrlee 87c072f5d4 fix(agent): 探测阶段自愈 nginx 版本目录并发布 v1.4.5
CI / verify (push) Canceled after 0s
2026-09-28 14:43:59 +08:00
mrlee c92d06642f fix(agent): 修复 nginx 版本目录被误加载并发布 v1.4.4
CI / verify (push) Canceled after 0s
2026-09-28 14:37:15 +08:00
mrlee 8c07e64e87 fix(agent): 自愈 HTTPS 直连地址并发布 v1.4.3
CI / verify (push) Canceled after 0s
2026-09-28 14:30:35 +08:00
mrlee 4a29bcb249 fix(agent): 同步更新 transport URL 并发布 v1.4.2
CI / verify (push) Canceled after 0s
2026-09-28 14:24:37 +08:00
mrlee fac9234e4d feat(agent): 增加 HTTPS pin 模式并修复隧道端口竞态
CI / verify (push) Canceled after 0s
2026-09-28 14:13:57 +08:00
mrlee 1bec91cc5e feat(agent): 默认回环监听并自动建立 SSH 隧道并发布 v1.4.0
CI / verify (push) Canceled after 0s
2026-09-28 13:42:21 +08:00
mrlee 49d4af27ca fix(updater): 忽略容器路径并优先使用 Compose 配置目录
CI / verify (push) Canceled after 0s
2026-09-28 12:01:39 +08:00
mrlee 63538ec5b7 fix(agent): 限流状态损坏时拒绝覆盖并发布 v1.3.9
CI / verify (push) Canceled after 0s
2026-09-28 11:53:58 +08:00
mrlee 454371368c feat(web): 更新示例页验证内容并发布 v1.3.8
CI / verify (push) Canceled after 0s
2026-09-28 11:32:33 +08:00
mrlee 4e948f65d1 fix(updater): 复用原 Compose 项目名并保护非 Compose 部署
CI / verify (push) Canceled after 0s
2026-09-28 11:24:51 +08:00
mrlee f63c28e10e feat(web): 添加示例页升级验证区块并发布 v1.3.7
CI / verify (push) Canceled after 0s
2026-09-28 11:15:16 +08:00
mrlee d154aef807 fix(updater): 自动定位宿主机 Compose 目录并发布 v1.3.6
CI / verify (push) Canceled after 0s
2026-09-28 11:10:14 +08:00
mrlee 3f23610e59 feat(web): 更新示例页验证内容并发布 v1.3.5
CI / verify (push) Canceled after 0s
2026-09-28 10:58:06 +08:00
mrlee bed1e223cc fix(updater): 修复 Compose 文件挂载并发布 v1.3.4
CI / verify (push) Canceled after 0s
2026-09-28 10:30:50 +08:00
mrlee da054517c0 feat(web): 更新示例页升级验证标识并发布 v1.3.3
CI / verify (push) Canceled after 0s
2026-09-27 19:25:49 +08:00
mrlee fa9cdf913f fix(updater): 通过 raw 接口读取远端版本并发布 v1.3.2
CI / verify (push) Canceled after 0s
2026-09-27 19:18:54 +08:00
mrlee 6074cfceca feat(web): 添加在线升级验证示例页并发布 v1.3.1
CI / verify (push) Canceled after 0s
2026-09-27 18:58:26 +08:00
mrlee 163df0c5b0 feat(update): 支持 Docker Compose 页面一键升级并发布 v1.3.0
CI / verify (push) Canceled after 0s
2026-09-27 18:43:34 +08:00
mrlee 32dfe3b6de fix(agent): Bearer Token 改为常量时间比较并发布 v1.2.21
CI / verify (push) Canceled after 0s
2026-09-27 18:27:23 +08:00
mrlee d39f8a7201 chore(release): 发布 v1.2.20
CI / verify (push) Canceled after 0s
2026-09-27 18:16:40 +08:00
130 changed files with 16962 additions and 560 deletions
+634 -1
View File
@@ -2,12 +2,645 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [Unreleased] ## [v1.9.0] - 2026-10-01
### 新增
- **Cloudflare 隧道出口抽象**:反向代理规则新增 `local` / `cloudflare` 出口选择,支持同时使用本机 Nginx 与 Cloudflare 隧道;托管隧道从服务发布规则自动派生 ingress,规则新增、修改、删除、启停时自动同步 Cloudflare 云端与 DNS
- **Cloudflare 独立配置页**:隧道「编辑」改为独立普通页面 `/cloudflare/:id/config`,包含指标、基础配置、拓扑图、路由列表和运行控制;基础配置通过按钮打开弹窗,不再直接堆叠在页面中
- **Cloudflare 路由管理增强**:新增路由添加、编辑、删除、上移、下移、复制和源服务 TCP 连通性测试;通配路径 `*` 只在界面显示,不再作为非法正则发送给 Cloudflare
- **Cloudflare 接入向导与预检**:新增 cloudflared、Account ID、API Token、Zone 权限检查,并在 Cloudflare 页面提供接入向导入口
- **Cloudflare 调和中心**:隧道列表显示“调和”状态;检测本地规则与云端 ingress 漂移,支持以本地规则一键覆盖修复
- **Cloudflare 运维能力**:新增隧道健康巡检、24 小时可用率、断连/恢复通知、状态页事故记录、仪表盘摘要、日志中心分页、漂移检测、一键诊断、Access 只读检测、Zone 列表和 DNS 记录归属管理
- **cloudflared 二进制管理**:支持查询最新版本、指定版本下载、保留上一版回滚;Cloudflare 页面显示当前版本并支持检查更新
- **NAS 服务发现增强**:服务发现目录新增 Home Assistant、Immich、AdGuard Home、Synology DSM 等常见 NAS 应用;扫描后可按 Cloudflare 出口预填服务发布规则
- **备份恢复增强**:备份归档加入 `cloudflared/tunnels`,保留隧道 `config.yml`、`creds.json` 与相关数据库记录
- **Cloudflare 拓扑图**:按“域名 → 出口 → 隧道 → 服务”展示服务发布链路,支持点阵画布、节点和曲线连接
### 修复
- **Cloudflare 全局启动策略**:全局托管开启或 Fonu 启动时,默认启动全部隧道;关闭时停止全部隧道并阻止应用重启自动拉起
- **Cloudflare 路由保存提示**:规则本体保存成功但 Cloudflare 同步失败时返回 `sync_warning` 并在前端提示,不再把已保存的规则判为完全失败
- **Cloudflare metrics 端口识别**:兼容 `Starting metrics server...` 大小写日志格式,修复 metrics 端口、连接数、传输协议无法采集的问题
- **Cloudflare DNS 安全清理**:只删除 Fonu 接管或创建的 DNS 记录,不覆盖用户已有的其他目标 CNAME
- **Cloudflare 隧道删除保护**:仍被反向代理规则引用的托管隧道不允许删除或切换为手工隧道
### 测试
- 新增 Cloudflare 出口默认值与持久化、托管规则生成 ingress、健康状态与可用率、路由解析、通配 path、DNS 记录归属、漂移比较、备份包含隧道配置等测试
- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test -- --run`、`npm run build` 通过
### 变更
- 版本号提升到 1.9.0;`AgentVersion` 保持 **0.14.1**,本次无 Agent 行为变化
## [v1.8.0] - 2026-09-30
### 新增
- **Cloudflare 隧道全局托管开关**:页面顶部新增「全局托管」开关:
- 关闭时立即停止所有隧道进程,应用重启后不再自动拉起;单条隧道的「启动」按钮同时禁用
- 开启时只恢复标记了 `auto_start` 的隧道,其余保持停止
- 只影响本机 cloudflared 进程托管,不删除本地配置、凭据、云端隧道或 DNS 记录
- 新增 `cf_settings.enabled` 字段(迁移 `034`)与 `POST /api/cloudflare/enabled` 接口
### 变更
- 版本号提升到 1.8.0;`AgentVersion` 保持 **0.14.1**
### 测试
- 新增「全局停用时拒绝启动」单元测试
- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过
## [v1.7.0] - 2026-09-28
### 新增
- **自动创建 Cloudflare DNS 记录**:创建隧道时,Fonu 会为每个暴露域名查找所属 Cloudflare Zone(从 hostname 逐级向上)并幂等创建 / 更新 CNAME:
- 目标为 `<tunnel-id>.cfargotunnel.com`
- 自动开启代理(橙云)
- 同名 CNAME 已存在时更新而不是重复创建
- 编辑隧道时同步 DNS;创建失败只在页面提示警告,不影响隧道本身
- API Token 权限说明更新为:`Account: Cloudflare Tunnel: Edit`、`Zone: Zone: Read`、`Zone: DNS: Edit`
### 变更
- 版本号提升到 1.7.0;`AgentVersion` 保持 **0.14.1**
### 测试
- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过
## [v1.6.9] - 2026-09-28
### 修复
- **测试连接未使用当前输入的 Account ID**:测试连接原先只读取数据库中已保存的 Account ID,未保存直接点测试会误报「请先填写 Account ID」。现在前端会把输入框里的 Account ID 与 API Token 一起提交,后端优先使用输入值,留空时才回退到已保存配置
### 变更
- 版本号提升到 1.6.9;`AgentVersion` 保持 **0.14.1**
### 测试
- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过
## [v1.6.8] - 2026-09-28
### 变更
- **Cloudflare 凭据模型改为 Account ID + API Token**:应用配置不再要求 Tunnel Token;Fonu 用 Account ID + API Token 调用 Cloudflare API:
- 「测试连接」调用 `GET /accounts/{account_id}/tokens/verify`
- 新建隧道时自动创建 `config_src=local` 的命名隧道、获取凭据、生成本地 `config.yml` 与 `creds.json`
- 删除隧道时同步删除云端隧道;编辑时同步推送 ingress
- 隧道弹窗去掉 Token 与模式选择,只保留名称、暴露域名、回源 service 与自动启动
- 新增 `cf_settings.account_id` 字段(迁移 `033`)
### 变更
- 版本号提升到 1.6.8;`AgentVersion` 保持 **0.14.1**
### 测试
- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过
## [v1.6.7] - 2026-09-28
### 新增
- **API Token 连接验证**:应用配置的 Token 区域拆分为两个输入:
- 隧道 Token(Tunnel Token):用于 cloudflared 运行
- API Token(`cfat_...`):用于连接验证,调用 Cloudflare 官方 `GET /user/tokens/verify` 接口
- 「测试连接」优先使用 API Token;未配置 API Token 时回退到隧道 Token 的临时连接测试;输入以 `cfat_` 开头时也会自动走 API 验证
- 新增 `cf_settings.api_token_enc` 字段(迁移 `032`),API Token 独立加密存储与掩码回显
### 变更
- 版本号提升到 1.6.7;`AgentVersion` 保持 **0.14.1**
### 测试
- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过
## [v1.6.6] - 2026-09-28
### 修复
- **Token 解码兼容 URL-safe base64**:Cloudflare 面板生成的 Tunnel Token 使用 URL-safe base64(含 `-` / `_`),原先只按标准 base64 解码,会报 `illegal base64 data at input byte N` 并返回 502。现在同时兼容标准 / URL-safe 与有无填充四种形式,失败时给出更明确的提示
### 变更
- 版本号提升到 1.6.6;`AgentVersion` 保持 **0.14.1**
### 测试
- 新增 URL-safe 与无填充 token 解码测试
## [v1.6.5] - 2026-09-28
### 新增
- **Token 配置连接入口**:应用配置的 Token 区域新增两个入口:
- 「测试连接」:后端用 cloudflared 临时启动一次连接,最多等待 25 秒,确认 token 能注册到 Cloudflare 边缘;成功后显示隧道 ID,失败给出明确原因
- 「获取 Token」:直达 Cloudflare Zero Trust 面板
- 新增 `POST /api/cloudflare/settings/test` 接口;请求体 token 留空时使用已保存的默认 token
### 变更
- 版本号提升到 1.6.5;`AgentVersion` 保持 **0.14.1**
### 测试
- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过
## [v1.6.4] - 2026-09-28
### 变更
- **新建 / 编辑隧道弹窗去重**:移除与应用配置重复的 Token 输入与网络调优字段;Token、传输协议、边缘地址族、HA 连接数、代理模式统一在「应用配置」中维护,保存时自动应用到新建或编辑的隧道
- 新建隧道前若未配置默认 Token,弹窗内直接提示并阻止提交;编辑已有隧道不受影响
- 隧道弹窗现在只保留名称、模式、暴露域名、回源 service 与自动启动
### 变更
- 版本号提升到 1.6.4;`AgentVersion` 保持 **0.14.1**
### 测试
- `npm run typecheck`、`npm test`、`npm run build` 通过
## [v1.6.3] - 2026-09-28
### 新增
- **Cloudflare 应用配置**:「cloudflared 二进制」弹窗升级为「应用配置」,包含三块:
- 程序安装:当前版本、下载镜像、下载并安装
- Token 配置:保存模块级默认 token,掩码回显;新建隧道时可以留空,直接使用默认 token
- 默认网络设置:传输协议、边缘地址族、HA 连接数、代理模式,作为新建隧道的默认值
- 新增 `cf_settings` 表(迁移 `031`)与 `GET / PUT /api/cloudflare/settings` 接口
### 变更
- 版本号提升到 1.6.3;`AgentVersion` 保持 **0.14.1**
### 测试
- 新增应用配置与默认 token 的端到端单元测试
- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过
## [v1.6.2] - 2026-09-28
### 修复
- **Cloudflare 隧道弹窗宽度**:`n-modal` 的宽度原先写在 scoped class 上,没有传到 naive-ui 的 modal 根节点,部分视口下弹窗被拉成 100%。现在改为与其他页面一致的 `:style` 写法:编辑弹窗 `min(720px, 96vw)`、详情弹窗 `min(760px, 96vw)`、二进制弹窗 `min(560px, 94vw)`
### 变更
- 版本号提升到 1.6.2;`AgentVersion` 保持 **0.14.1**
### 测试
- `npm run typecheck`、`npm run build` 通过
## [v1.6.1] - 2026-09-28
### 修复
- **Cloudflare 隧道弹窗完善**:编辑弹窗回填已配置的暴露域名与回源 service(后端从 `config.yml` 解析);按「基本信息 / 暴露规则 / 网络调优」分组,增加模式说明;云端规则模式隐藏本地域名配置并提示规则由 Cloudflare 面板管理
- **详情弹窗**:改为状态卡 + `config.yml` / 日志分页,日志每 3 秒自动刷新,支持一键复制配置,刷新按钮带加载状态
- **二进制弹窗**:显示当前版本与目标文件;镜像列表为空时回退官方源
- **保存校验**:提交前校验名称、token 与本地规则模式的暴露域名
### 变更
- 版本号提升到 1.6.1;`AgentVersion` 保持 **0.14.1**,本次无 Agent 行为变化
### 测试
- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过
## [v1.6.0] - 2026-09-28
### 新增
- **Cloudflare 隧道独立模块(阶段 1–2)**:新增 `internal/cloudflared` 包,覆盖 token 解码、`creds.json` / `config.yml` 生成、ingress 生成器、SQLite 存储、cloudflared 二进制下载(镜像表 + 大小与可执行校验)、子进程托管、日志追加、metrics 端口发现与健康抓取
- 新增 `cf_tunnels` 表与 `/api/cloudflare/*` 接口:列表、创建、更新、删除、启动、停止、重启、状态、日志、配置预览、二进制信息与下载、ingress 候选域名
- 前端新增「Cloudflare 隧道」页面:隧道列表、创建 / 编辑、启停、配置预览、日志、二进制下载与网络调优设置
- 支持 `token-local`(Fonu 生成本地 ingress)与 `token-remote`(Cloudflare 面板管理规则)两种模式;界面显示模式差异
- 网络调优默认值:`http2` / IPv4 / HA 2 / 跟随系统代理;ingress 锁定 catch-all、`127.0.0.1` 归一化与稳定排序三条不变式
### 变更
- 版本号提升到 1.6.0;`AgentVersion` 保持 **0.14.1**,本次无 Agent 行为变化
### 测试
- `internal/cloudflared`:token 解码、ingress 不变式、config.yml、metrics 解析、启动参数、镜像 URL、代理禁用单元测试
- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过
### 未实施
- 账号模式(`cert.pem`)与云端 ingress 的 fetch / push
- 边缘 IP 优选、快速隧道、自动 DNS
## [v1.5.4] - 2026-09-28
### 变更
- **P1-5 统一前端轮询组合式函数**:`useVisibilityPolling` 重写为支持 `enabled` / `immediate` / `onError` 选项,加入 in-flight 互斥,返回 `start` / `stop` / `busy` / `lastError`;核心逻辑抽成 `createVisibilityPoller`,可在无 DOM 环境下测试
- Dashboard、Proxy、Logs、Settings、Frp、UpdateModal、Agent 的轮询全部迁移到组合式函数;仅 AppLayout 时钟保留原生计时器
- 慢接口不再堆积:上一轮未结束时跳过下一轮;页面隐藏时暂停,回到前台立即补一次
### 测试
- 新增 6 项组合式函数单元测试:隐藏不执行、回到前台补一次、in-flight 互斥、`enabled=false`、卸载清理、`onError` 后继续轮询
- `npm run typecheck`、`npm test`(25 项)、`npm run build` 通过
### 变更
- 版本号提升到 1.5.4;`AgentVersion` 保持 **0.14.1**,本次无 Agent 行为变化
## [v1.5.3] - 2026-09-28
### 新增
- **P1-4 路由鉴权覆盖测试**:API 路由收敛为可遍历路由表,新增 `internal/api/router_auth_test.go` 遍历 100+ 受保护路由,匿名请求必须返回 401,公开路由不被鉴权误拦;Agent 路由收敛为 `agentRoutes` 声明,新增 `internal/agent/router_auth_test.go` 遍历全部 `/api/v1/*` 验证无 Token 必须 401
- **配置回滚测试**:本机新增 `internal/nginx/versions_test.go`,覆盖版本列表 / 读取、非法版本名拒绝路径上跳、语法校验失败不覆盖当前配置、成功回滚保留当前版本;Agent 新增写 vhost 前保存版本、校验失败恢复上一可用版本、首次部署失败删除新文件、回滚后执行 reload 的测试
- **Agent 版本兼容测试**:新增 `internal/frp/agent_versions_compat_test.go`,覆盖 404 / 405 判定端点缺失并提示最低版本与「升级 Agent」、400 / 500 不误判、状态接口 `agent_version` 解析
- **前端版本字段降级测试**:新增 `agentVersionText` 工具与测试,旧 Agent 缺少 `agent_version` 时显示占位符而不是空白
### 变更
- 版本号提升到 1.5.3;`AgentVersion` 保持 **0.14.1**,本次无 Agent 行为变化
- Agent 的 nginx 校验与 reload 增加测试注入钩子;生产路径仍调用真实 nginx,行为不变
### 测试
- `go test ./...`、`go vet ./...` 通过
- `npm run typecheck`、`npm test`(19 项)、`npm run build` 通过
## [v1.5.2] - 2026-09-28
### 修复
- **升级 Agent 不再强制切回 SSH 隧道**:HTTPS 直连模式下点击「升级 Agent」原先会无条件建立隧道并把 transport 改回 `tunnel`,用户升级后需要重新切换 HTTPS。现在升级前是 `https-pin` 时会保持 HTTPS 直连,只更新 Token 密文并重新验证连通
- **清理隧道模式下残留的 HTTPS 反代**:旧升级流程切回隧道时没有关闭 Agent 上的 `fonuagent-https.conf`,导致 7443 等端口继续暴露。现在从 HTTPS 迁移到隧道、应用启动恢复隧道、手动「重启隧道」时都会通过隧道调用 `DisableHTTPS` 清理残留
### 变更
- 版本号提升到 1.5.2;`AgentVersion` 保持 **0.14.1**,本次是中央端修复,不需要重新升级 Agent
### 测试
- `internal/frp` 现有单元测试与编译校验通过;升级分支依赖真实 SSH/Agent 环境,未新增本地集成测试
## [v1.5.1] - 2026-09-28
### 变更
- **P1-1 共享 Nginx 模板包**:新增纯生成包 `internal/nginxtmpl`,统一 TLS 指令、安全响应头、allow / deny、WebSocket 头、限流指令与 zone 命名;本机 `internal/nginx` 与公网 Agent 均改为调用共享函数,重复模板实现已删除
- **安全响应头对齐**:两端统一输出 HSTS、`X-Content-Type-Options`、`X-Frame-Options`、`Referrer-Policy`,本机此前缺少 `Referrer-Policy`
- **访问控制顺序统一**:统一为黑名单 `deny` → 仅大陆规则 → 白名单 `allow` → `deny all`,黑名单优先匹配
- **TLS 指令显式化**:两端统一显式输出 `ssl_protocols TLSv1.2 TLSv1.3` 或 `TLSv1.3`
- 版本号提升到 1.5.1;`AgentVersion` 0.14.0 → **0.14.1**,内嵌 amd64 / arm64 二进制已重编;VPS 上需点一次「升级 Agent」才会生效
### 测试
- `internal/nginxtmpl`:新增共享函数的 TLS、安全头、访问控制、限流、zone 与 WebSocket 特征化测试
- `internal/nginx`:本机生成测试继续通过;`internal/agent`:新增 vhost 顺序与共享语义测试
## [v1.5.0] - 2026-09-28
### 新增
- **HTTPS 直连端口自动选择**:Agent 在 `7443` / `8443` / `9443` 中逐个尝试可用端口,每轮都会写配置、reload 并做本机证书自检;全部失败时返回每个端口的具体原因。显式传入端口时仍按指定值执行,`443` 可通过接口显式指定
- **证书自动轮换**:证书有效期 365 天,剩余 30 天时后台自动生成新证书并通过当前连接安装;过渡期 7 天内客户端同时接受新旧 pin,避免轮换瞬间连接失败。旧记录缺少到期信息时,先从 Agent 读取当前证书到期时间再决定是否轮换
- **主机防火墙辅助**:Agent 新增防火墙检测与显式放行接口,支持 `ufw` / `firewalld` 自动放行;`nftables` / `iptables` 只检测和提示,不自动修改规则。Agent 页增加「检测主机防火墙 / 放行主机防火墙」
- **隧道后台保活与重连**:隧道启动后每 30 秒保活,断线按 1 秒到 60 秒指数退避重建,不再只在有流量时才尝试重连
- **传输详细状态接口**:`GET /api/frpmulti/servers/{id}/agent/transport` 返回 transport、状态、端口、pin、证书到期时间与防火墙状态
- **审计记录**:transport 切换、证书轮换、防火墙放行写入 AUDIT 日志,包含 admin_id、server_id 与来源地址
- **证书 SAN 校验**:pin 校验同时校验证书 SAN 与连接主机一致
### 变更
- 版本号提升到 1.5.0;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 0.13.3 → **0.14.0**,内嵌 amd64 / arm64 二进制已重编;VPS 上需点一次「升级 Agent」才会生效
- Agent 页轮询改用 `useVisibilityPolling`,页面隐藏时暂停
### 测试
- `internal/frp`:新增多 pin 过渡、SAN 拒绝、端口解析与轮换兼容测试
- `internal/agent`:新增 ufw 解析与候选端口测试
- `internal/frp`:隧道退避策略单元测试
## [v1.4.6] - 2026-09-28
### 修复
- **HTTPS 直连证书指纹不匹配可定位**:nginx 配置校验通过、但公网 `7443` 返回的证书与刚写入的不一致时,原来只提示“证书指纹不匹配”,无法判断是 nginx 未生效还是端口被其他服务接管。现在 HTTPS server 使用 `default_server` 绑定,Agent 会在 reload 后从本机 `127.0.0.1:<端口>` 握手自检并重试 5 秒;指纹不一致时回滚,并回报实际指纹与 `ss -tlnp` 端口占用进程
- **公网链路错误提示**:主程序在 Agent 本机自检通过、但公网地址校验失败时,会明确提示检查 `7443` 的端口转发、防火墙或其他服务接管
### 变更
- 版本号提升到 1.4.6;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 0.13.2 → **0.13.3**,内嵌 amd64 / arm64 二进制已重编;VPS 上需重新点一次「升级 Agent」才会生效
### 测试
- `internal/agent`:新增证书 PEM 指纹计算测试
## [v1.4.5] - 2026-09-28
### 修复
- **Agent 探测阶段无法自愈 nginx 残留目录**:0.13.1 的迁移只在配置目录已知或 `nginx -t` 校验前执行;当 `nginx -T` 在探测目录阶段就因 `.versions` 目录失败时,Agent 仍返回 `nginx -T 执行失败: exit status 1`。现在会先从 `nginx -T` 报错文本解析出 `<配置>.versions` 路径并迁移,再重试一次;安装 / 升级 Agent 的脚本也会先把 `sites-enabled`、`conf.d` 下的旧 `.versions` 目录移出 include 范围
### 变更
- 版本号提升到 1.4.5;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 0.13.1 → **0.13.2**,内嵌 amd64 / arm64 二进制已重编;VPS 上需重新点一次「升级 Agent」才会生效
### 测试
- `internal/agent`:新增从 nginx 报错文本解析并迁移旧版本目录的测试
- `scripts/agent-install.sh`:`bash -n` 语法校验通过
## [v1.4.4] - 2026-09-28
### 修复
- **Agent nginx 版本目录被误加载**:切换 HTTPS 直连时,配置备份会写到 `<配置文件>.versions` 目录;该目录位于 `sites-enabled` 时会被 nginx 的 `include .../sites-enabled/*` 当成配置文件读取,导致 `nginx -t` 报 `pread() ".../fonuagent-https.conf.versions" failed (21: Is a directory)`。现在版本目录改为隐藏目录 `.<文件名>.versions`,`include *` 不再匹配;旧目录会在读取、备份或 `nginx -t` 前自动迁移,历史版本内容保留
### 变更
- 版本号提升到 1.4.4;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 0.13.0 → **0.13.1**,内嵌 amd64 / arm64 二进制已重编;VPS 上需点一次「升级 Agent」才会生效
### 测试
- `internal/fsutil`:新增隐藏版本目录与旧目录迁移测试
- `internal/agent`:新增 `migrateLegacyVersionDirs` 迁移测试
## [v1.4.3] - 2026-09-28
### 修复
- **HTTPS 直连旧地址自愈**:从 SSH 隧道切换到「HTTPS + 证书固定」后,历史实例的 `agent_url` 可能仍为已失效的 `http://127.0.0.1:17701`,隧道关闭后直接报 `connection refused`。现在读取 HTTPS transport 时会依据 `ssh_host` 与 `agent_listen_addr` 自动重建 `https://<host>:<port>`,并在地址变化时回写数据库;切回 SSH 隧道也使用同一重建逻辑,无需手工修改地址
### 变更
- 版本号提升到 1.4.3;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 保持 0.13.0
### 测试
- `internal/frp`:新增 `TestAgentHTTPSBaseURL`,覆盖旧隧道地址重建、已是 HTTPS 地址保持、默认端口回退与缺少 SSH 主机的情况
## [v1.4.2] - 2026-09-28
### 修复
- **切换 HTTPS 后 Agent URL 未更新**:启用 HTTPS 直连时只更新了 transport 和证书 pin,数据库里的 `agent_url` 仍保留旧的 `http://127.0.0.1:17701`。隧道关闭后仍访问旧本地地址,导致 `connect: connection refused`。现在 transport、URL、端口和 pin 同批更新,并在切换后清理旧 AgentClient 缓存
- **切回 SSH 隧道时同步恢复本地 URL**:恢复隧道会重新写入 `http://127.0.0.1:<localPort>`,避免残留 HTTPS 地址影响后续状态读取
### 变更
- 版本号提升到 1.4.2;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 保持 0.13.0
### 测试
- `internal/frp`:transport 切换与缓存失效编译验证
## [v1.4.1] - 2026-09-28
### 修复
- **SSH 隧道端口分配竞态**:原来先探测端口再关闭 listener,随后才真正启动隧道,两个安装动作或残留占用可能在两步之间抢到同一端口,报 `bind: Only one usage of each socket address`。现在改为分配端口时直接持有 listener 并交给隧道使用;17700 被占用时自动尝试后续端口
### 变更
- 版本号提升到 1.4.1;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 保持 0.13.0
### 测试
- `internal/frp`:隧道 listener 复用、端口占用与释放回归测试
## [v1.4.0] - 2026-09-28
### 新增
- **Agent 默认监听回环**:`fonu-agent` 默认改为 `127.0.0.1:7700`;监听非回环地址必须显式设置 `FONU_AGENT_ALLOW_REMOTE=1`,状态接口新增监听地址、监听范围和传输安全字段
- **SSH 隧道自动接入**:中央 Fonu 安装或升级 Agent 后自动分配 `17700-18700` 范围内的本地回环端口,通过现有 SSH 凭据建立隧道访问 VPS 的 `127.0.0.1:7700`,无需新增公网端口、证书或防火墙规则
- **隧道生命周期管理**:应用启动后自动恢复 tunnel transport;应用退出时统一关闭;每个服务端支持单独的「重启隧道」操作
- **旧公网 HTTP 自动迁移**:旧 Agent 点击「升级 Agent」后安装脚本会备份旧 `agent.env`、改写为回环监听并自动建立隧道;隧道验证失败时恢复旧监听配置
- **可选 HTTPS 直连 + 证书指纹固定**:安装后可从 SSH 隧道切换到 HTTPS 直连。Fonu 自动生成 ECDSA 自签名证书、通过现有安全连接下发,VPS Agent 自动写入独立 Nginx TLS 反代;后续连接按证书 SHA-256 pin 校验,不使用 `InsecureSkipVerify` 裸跳过
- **防火墙提示边界**:HTTPS 模式明确提示云安全组需放行 TCP 7443;Fonu 不自动修改云安全组,主机防火墙自动处理仍留在后续阶段
### 变更
- 数据库新增 Agent transport / 本地端口 / TLS pin / 监听地址 / 连接状态 / 连接错误字段(迁移 028)
- 前端「公网 Agent」页显示传输方式、隧道状态、本地入口和 Agent 监听地址;旧公网 HTTP 显示迁移警告
- 版本号提升到 1.4.0;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 0.12.9 → **0.13.0**,内嵌 amd64 / arm64 二进制已重编;VPS 上需升级 Agent
### 测试
- `internal/agent`:回环默认值与远程监听保护
- `internal/frp` / `internal/app`:隧道生命周期、transport 存储、证书生成与 pin 校验测试
## [v1.3.9] - 2026-09-28
### 修复
- **限流状态损坏时拒绝覆盖**:`readLimitState` 原来忽略 JSON 解析错误并返回空 map,保存任一规则时可能把其他域名的限流配置清空。现在文件不存在仍视为空状态,读取失败、空文件或 JSON 损坏则返回错误,`applyLimitState` / `clearLimitState` 会保留原文件与现有 Nginx zone,不再覆盖
- **一键升级宿主机目录自动发现**:当 Compose 标签里的 `working_dir` 是 `/workspace` 这类容器路径时,不再直接把它当宿主机目录;优先使用 `project.config_files` 推导真实目录,无法得到宿主机路径时明确报错,避免 helper 挂载 `/workspace:/workspace` 后找不到 Compose 文件
### 变更
- 版本号提升到 1.3.9;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 0.12.8 → **0.12.9**,内嵌 amd64 / arm64 二进制已重编;VPS 上需点一次「升级 Agent」才会生效
### 测试
- `internal/agent`:新增限流状态文件缺失、正常解析、空文件、截断 JSON、null 内容与失败后原文件不变的测试
## [v1.3.8] - 2026-09-28
### 新增
- **示例页面 v1.3.8 验证标识**:页面版本提示、「本次升级验证」区块、页面状态和验证标识同步更新为 `v1.3.8-example-page`,用于再次验证线上升级后的前端资源切换
### 变更
- 版本号提升到 1.3.8;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 保持 0.12.8
### 测试
- 前端类型检查、单元测试与生产构建
## [v1.3.7] - 2026-09-28
### 新增
- **示例页面新增独立升级验证区块**:页面增加「本次升级验证」高亮区块,显示 `v1.3.7`,验证标识更新为 `v1.3.7-example-page`,用于直观确认升级后的前端资源已切换
### 修复
- **复用原 Compose 项目名重建容器**:updater 在通过 helper 容器执行 `docker compose` 时,先从现有 `fonu` 容器读取 `com.docker.compose.project` 标签,并显式传入同一个项目名,避免 Compose 将现有容器视为另一个项目后重新创建网络、数据卷并撞上 `/fonu` 容器名冲突。现有容器不是 Compose 管理时,updater 会拒绝升级并提示按 Compose 方式重新部署,避免误建空数据卷
### 变更
- 版本号提升到 1.3.7;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 保持 0.12.8
### 测试
- 前端类型检查、单元测试与生产构建
## [v1.3.6] - 2026-09-28
### 修复
- **自动定位宿主机 Compose 目录**:当 updater 容器内的 `/workspace` 没有挂载到真实 Compose 目录时,不再直接失败。updater 会从 `fonu` 容器的 `com.docker.compose.project.working_dir` / `project.config_files` 标签读取宿主机路径,并通过临时 helper 容器挂载真实目录执行 `docker compose`。`FONU_UPDATE_HOST_DIR` 仍可作为显式覆盖
- 示例页验证标识更新为 `v1.3.6-example-page`
### 变更
- 版本号提升到 1.3.6;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 保持 0.12.8
### 测试
- `internal/updater`:覆盖 Compose 文件直挂载与宿主机目录 helper 模式
## [v1.3.5] - 2026-09-28
### 新增
- **示例页面升级验证内容**:页面版本提示与验证标识更新为 `v1.3.5-example-page`,新增「页面状态」统计卡和「本次示例页变化」说明,便于确认升级后的前端资源已经切换
### 变更
- 版本号提升到 1.3.5;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 保持 0.12.8
### 测试
- 前端类型检查、单元测试与生产构建
## [v1.3.4] - 2026-09-28
### 修复
- **一键升级找不到 Compose 文件**:fnOS 或 Docker UI 启动 Compose 时,updater 的 `.:/workspace` 可能解析到错误目录,构建时报 `open /workspace/docker-compose.hub.yml: no such file or directory`。Compose 文件现支持用 `FONU_UPDATE_HOST_DIR` 显式指定宿主机 Compose 目录,并在状态接口中提前报告缺少 Compose 文件,避免排到升级后才失败
- **示例页验证标识更新为 v1.3.4-example-page**,用于确认升级后的前端资源确实生效
### 变更
- 版本号提升到 1.3.4;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 保持 0.12.8
### 测试
- `internal/updater`:补充 Compose 文件缺失检查
## [v1.3.3] - 2026-09-27
### 新增
- **示例页面升级验证标识**:示例页面新增固定的 `v1.3.3-example-page` 验证标识与展示项,升级后可直接通过标识区分新旧前端资源,确认页面、路由和静态资源均已更新
### 变更
- 版本号提升到 1.3.3;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 保持 0.12.8,本次无 Agent 行为变化
### 测试
- 前端类型检查、单元测试与生产构建
## [v1.3.2] - 2026-09-27
### 修复
- **一键升级无法读取远端版本**:`fonu-updater` 检查更新时原来会 `git clone --depth 1` 整个仓库,而状态接口的父级超时只有 20 秒,克隆未完成即被 `signal: killed`,表现为 `fatal: early EOF`,前端因此一直不显示「一键升级」。现改为优先通过 Gitea API / raw 地址或 GitHub raw 读取 `VERSION`,显著降低检查耗时,不再依赖完整克隆
### 变更
- 版本号提升到 1.3.2;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 保持 0.12.8,本次无 Agent 行为变化
- `fonu-updater` 位于 sidecar,不能通过自身升级自身;已有部署需要先手动重建一次 `updater` 服务,之后该修复才生效
### 测试
- `internal/updater`:新增 Gitea API / raw 地址解析、文本与 base64 JSON 版本内容解析测试
## [v1.3.1] - 2026-09-27
### 新增
- **示例页面(在线升级验证页)**:新增 `/example` 页面与侧栏「示例页面」入口,页面实时读取 `/api/version`,展示当前版本、验证步骤和验证结果,用于确认一键升级后前端路由、菜单与后端版本均已更新
### 变更
- 版本号提升到 1.3.1;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 保持 0.12.8,本次无 Agent 行为变化
### 测试
- 前端类型检查、单元测试与生产构建
## [v1.3.0] - 2026-09-27
### 新增
- **Web 一键升级(Docker Compose)**:新增受限的 `fonu-updater` 升级 sidecar,支持 `docker-compose.yml`(本机源码构建)与 `docker-compose.hub.yml`(Git 仓库构建)两种部署。主程序只通过共享 Unix Socket 调用 sidecar,不直接挂载 Docker Socket;升级时只重建 `fonu` 服务,构建或健康检查失败会自动尝试回滚到升级前的镜像 tag
- 前端侧栏在检测到可升级版本时显示「一键升级」入口,升级弹窗展示当前版本、目标版本、升级方式、阶段消息与最近日志;服务重启期间浏览器会自动等待恢复
- 新增 `docs/system-upgrade.md`,说明首次部署 sidecar、安全边界、升级流程、回滚和常见问题
### 变更
- 版本号提升到 1.3.0;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 保持 0.12.8,本次无 Agent 行为变化
### 注意
- 已经运行旧版本的实例,需要先手动更新 Compose 文件并执行一次 `docker compose up -d --build`,让 `fonu-updater` sidecar 启动。此后才可以在 Web 页面一键升级
### 测试
- `internal/updater`:版本比较、compose 路径白名单、配置校验
## [v1.2.21] - 2026-09-27
### 修复
- **Agent Bearer Token 改为常量时间比较**:`internal/agent` 原来用普通字符串比较校验 Token,现将 Authorization 解析与 Token 比较拆分,改用 `crypto/subtle.ConstantTimeCompare`,避免认证路径上的长度与内容时序泄漏
### 变更
- 版本号提升到 1.2.21;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 0.12.7 → **0.12.8**,内嵌 amd64 / arm64 二进制已重编;VPS 上需点一次「升级 Agent」才会生效
### 测试
- `internal/agent`:新增认证测试,覆盖正确 Token、大小写 Bearer、多余空白、错误 Token、长度不同、缺失 Authorization、错误认证方案
## [v1.2.20] - 2026-09-27
### 修复 ### 修复
- **DDNS 页 FRP 提示恢复**:`DdnsView.vue` 原来调用已退役的 `/api/frp`,请求失败后会把 `frpEnabled` 固定为 `false`,导致“FRP 内网穿透已启用”提示不再出现。现改用 `/api/frpmulti/status`,仅在“已配置服务端且存在启用规则”时显示,并补充判定函数单元测试 - **DDNS 页 FRP 提示恢复**:`DdnsView.vue` 原来调用已退役的 `/api/frp`,请求失败后会把 `frpEnabled` 固定为 `false`,导致“FRP 内网穿透已启用”提示不再出现。现改用 `/api/frpmulti/status`,仅在“已配置服务端且存在启用规则”时显示,并补充判定函数单元测试
### 变更
- 版本号提升到 1.2.20;`web/package.json` 与 `web/package-lock.json` 同步对齐
- 本轮不涉及 Go 与 agent 行为,`AgentVersion` 仍为 0.12.7
## [v1.2.19] - 2026-09-27 ## [v1.2.19] - 2026-09-27
### 新增 ### 新增
+19
View File
@@ -11,6 +11,7 @@ ARG VERSION_FILE=VERSION
ARG NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine ARG NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine
ARG GO_IMAGE=docker.m.daocloud.io/library/golang:1.23-bookworm ARG GO_IMAGE=docker.m.daocloud.io/library/golang:1.23-bookworm
ARG RUNTIME_IMAGE=docker.m.daocloud.io/library/debian:bookworm-slim ARG RUNTIME_IMAGE=docker.m.daocloud.io/library/debian:bookworm-slim
ARG UPDATER_IMAGE=docker.m.daocloud.io/library/docker:27-cli
# Go 依赖代理:国内默认 goproxy.cn(官方 proxy.golang.org 国内不可达),海外可覆盖回 direct # Go 依赖代理:国内默认 goproxy.cn(官方 proxy.golang.org 国内不可达),海外可覆盖回 direct
ARG GOPROXY=https://goproxy.cn,direct ARG GOPROXY=https://goproxy.cn,direct
@@ -44,6 +45,24 @@ RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache
-ldflags "-X github.com/fonu/fonu/internal/version.Version=$(cat /src/VERSION | tr -d ' \t\n\r')" \ -ldflags "-X github.com/fonu/fonu/internal/version.Version=$(cat /src/VERSION | tr -d ' \t\n\r')" \
-o /fonu ./cmd/fonu -o /fonu ./cmd/fonu
# 一键升级 sidecar:独立容器持有 Docker Socket,主程序只通过共享 Unix Socket 调用它
FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS updater-builder
ARG TARGETARCH
ARG GOPROXY
ENV GOPROXY=${GOPROXY}
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY cmd ./cmd
COPY internal ./internal
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath -o /fonu-updater ./cmd/fonu-updater
# updater 运行时:Docker CLI / Compose 与 Git 只在 sidecar 内可用
FROM ${UPDATER_IMAGE} AS updater
RUN apk add --no-cache git docker-cli-compose docker-cli-buildx ca-certificates
COPY --from=updater-builder /fonu-updater /usr/local/bin/fonu-updater
ENTRYPOINT ["/usr/local/bin/fonu-updater"]
# 运行时 stage # 运行时 stage
FROM ${RUNTIME_IMAGE} FROM ${RUNTIME_IMAGE}
ARG TARGETARCH ARG TARGETARCH
+8 -1
View File
@@ -112,6 +112,12 @@ docker compose up -d --build
</details> </details>
### Web 一键升级
`docker-compose.yml` 与 `docker-compose.hub.yml` 会同时启动受限的 `fonu-updater` sidecar。检测到新版本后,侧栏会出现「一键升级」入口,可在管理页面完成构建、重建与健康检查。
> 从旧版本升级时,需要先手动更新 Compose 文件并执行一次 `docker compose up -d --build` 启动 sidecar。详见 [`docs/system-upgrade.md`](docs/system-upgrade.md)。
### 首次登录 ### 首次登录
1. 启动后查看容器/运行日志,获取自动生成的 **admin 初始密码** 1. 启动后查看容器/运行日志,获取自动生成的 **admin 初始密码**
@@ -149,12 +155,13 @@ docker compose up -d --build
| ----------------------- | ------------------------- | ------------------------------------------ | | ----------------------- | ------------------------- | ------------------------------------------ |
| `FONU_LISTEN` | `:6893` | 管理后台监听地址 | | `FONU_LISTEN` | `:6893` | 管理后台监听地址 |
| `FONU_DATA_DIR` | `/data` | 数据目录(数据库、Nginx 配置、证书、日志) | | `FONU_DATA_DIR` | `/data` | 数据目录(数据库、Nginx 配置、证书、日志) |
| `FONU_SESSION_SECRET` | `change-me-in-production` | 会话加密密钥,生产环境请自行修改 | | `FONU_SESSION_SECRET` | 空(自动生成) | 会话与凭据加密密钥;有数据后不要更换 |
| `FONU_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代规则的默认监听端口 | | `FONU_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代规则的默认监听端口 |
| `FONU_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代规则的默认监听端口 | | `FONU_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代规则的默认监听端口 |
| `FONU_NGINX_BIN` | `nginx` | Nginx 可执行文件路径(本地开发可指定) | | `FONU_NGINX_BIN` | `nginx` | Nginx 可执行文件路径(本地开发可指定) |
| `FONU_FRPC_BIN` | `frpc` | frpc 可执行文件路径(镜像不预装,用「应用配置」按需下载或自行放置) | | `FONU_FRPC_BIN` | `frpc` | frpc 可执行文件路径(镜像不预装,用「应用配置」按需下载或自行放置) |
| `FONU_FRP_PID` | `{DATA_DIR}/frp/frpc.pid` | frpc 进程 PID 文件路径 | | `FONU_FRP_PID` | `{DATA_DIR}/frp/frpc.pid` | frpc 进程 PID 文件路径 |
| `FONU_UPDATER_SOCKET` | `/run/fonu-updater/updater.sock` | 一键升级 sidecar 的 Unix Socket;未挂载时自动禁用 |
## 注意事项 ## 注意事项
+1 -1
View File
@@ -1 +1 @@
1.2.19 1.9.0
+13 -2
View File
@@ -7,13 +7,15 @@ import (
"log" "log"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"github.com/fonu/fonu/internal/agent" "github.com/fonu/fonu/internal/agent"
) )
func main() { func main() {
listen := flag.String("listen", envOr("FONU_AGENT_LISTEN", ":7700"), "agent listen address") listen := flag.String("listen", envOr("FONU_AGENT_LISTEN", "127.0.0.1:7700"), "agent listen address")
allowRemote := flag.Bool("allow-remote", envBool("FONU_AGENT_ALLOW_REMOTE"), "allow non-loopback listen address")
token := flag.String("token", os.Getenv("FONU_AGENT_TOKEN"), "agent bearer token") token := flag.String("token", os.Getenv("FONU_AGENT_TOKEN"), "agent bearer token")
nginxDir := flag.String("nginx-conf-dir", envOr("FONU_AGENT_NGINX_CONF_DIR", ""), "managed nginx config directory(留空自动探测 Nginx 实际加载的 vhost 目录)") nginxDir := flag.String("nginx-conf-dir", envOr("FONU_AGENT_NGINX_CONF_DIR", ""), "managed nginx config directory(留空自动探测 Nginx 实际加载的 vhost 目录)")
certsDir := flag.String("certs-dir", envOr("FONU_AGENT_CERTS_DIR", "/var/lib/fonu-agent/certs"), "certificate directory") certsDir := flag.String("certs-dir", envOr("FONU_AGENT_CERTS_DIR", "/var/lib/fonu-agent/certs"), "certificate directory")
@@ -30,7 +32,7 @@ func main() {
log.Fatal("FONU_AGENT_TOKEN or -token is required") log.Fatal("FONU_AGENT_TOKEN or -token is required")
} }
server, err := agent.NewServer(agent.Config{ server, err := agent.NewServer(agent.Config{
ListenAddr: *listen, Token: *token, NginxConfDir: *nginxDir, ListenAddr: *listen, AllowRemote: *allowRemote, Token: *token, NginxConfDir: *nginxDir,
CertsDir: *certsDir, FrpsConfigPath: *frpsConfig, FrpsBinary: *frpsBin, DataDir: *dataDir, CertsDir: *certsDir, FrpsConfigPath: *frpsConfig, FrpsBinary: *frpsBin, DataDir: *dataDir,
}) })
if err != nil { if err != nil {
@@ -49,3 +51,12 @@ func envOr(key, fallback string) string {
} }
return fallback return fallback
} }
func envBool(key string) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"github.com/fonu/fonu/internal/updater"
)
func main() {
server, err := updater.NewServer(updater.Config{
SocketPath: envOr("FONU_UPDATER_SOCKET", "/run/fonu-updater/updater.sock"),
ProjectDir: envOr("FONU_UPDATE_PROJECT_DIR", "/workspace"),
ComposeFile: envOr("FONU_UPDATE_COMPOSE_FILE", "docker-compose.yml"),
Service: envOr("FONU_UPDATE_SERVICE", "fonu"),
Mode: envOr("FONU_UPDATE_MODE", "local"),
RepoURL: envOr("FONU_UPDATE_REPO", ""),
Branch: envOr("FONU_UPDATE_BRANCH", "main"),
AppURL: envOr("FONU_UPDATE_APP_URL", "http://fonu:6893"),
HostProjectDir: envOr("FONU_UPDATE_HOST_DIR", ""),
ProjectName: envOr("FONU_UPDATE_PROJECT_NAME", ""),
SelfImage: envOr("FONU_UPDATE_SELF_IMAGE", ""),
DockerSocket: envOr("FONU_UPDATE_DOCKER_SOCK", "/var/run/docker.sock"),
}, nil)
if err != nil {
log.Fatal(err)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := server.Serve(ctx); err != nil && ctx.Err() == nil {
log.Fatal(err)
}
}
func envOr(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
+38
View File
@@ -25,6 +25,7 @@ services:
NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine} NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine}
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm} GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim} RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim}
UPDATER_IMAGE: ${UPDATER_IMAGE:-docker.m.daocloud.io/library/docker:27-cli}
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct} GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
image: fonu:${FONU_VERSION:-local} image: fonu:${FONU_VERSION:-local}
# 有 build 段时 compose 默认会先尝试拉同名镜像(日志里那条 "fonu Pulling" 就是), # 有 build 段时 compose 默认会先尝试拉同名镜像(日志里那条 "fonu Pulling" 就是),
@@ -32,6 +33,8 @@ services:
pull_policy: build pull_policy: build
container_name: fonu container_name: fonu
restart: unless-stopped restart: unless-stopped
depends_on:
- updater
ports: ports:
- "6893:6893" - "6893:6893"
- "18080:80" - "18080:80"
@@ -41,14 +44,49 @@ services:
# 想落到指定目录就在同层 .env 里设 FONU_DATA_PATH=/vol1/docker/fonu-data —— # 想落到指定目录就在同层 .env 里设 FONU_DATA_PATH=/vol1/docker/fonu-data ——
# 以 / 或 . 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷。 # 以 / 或 . 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷。
- ${FONU_DATA_PATH:-fonu-data}:/data - ${FONU_DATA_PATH:-fonu-data}:/data
- fonu-updater-sock:/run/fonu-updater
environment: environment:
FONU_DATA_DIR: /data FONU_DATA_DIR: /data
FONU_LISTEN: :6893 FONU_LISTEN: :6893
FONU_UPDATER_SOCKET: /run/fonu-updater/updater.sock
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600) # 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
# 想自己指定就在同层 .env 里设这个变量;注意它同时是凭据加密密钥,已有数据后不要更换 # 想自己指定就在同层 .env 里设这个变量;注意它同时是凭据加密密钥,已有数据后不要更换
FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-} FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-}
# 时区:通知消息时间按此显示(默认东八区) # 时区:通知消息时间按此显示(默认东八区)
TZ: ${TZ:-Asia/Shanghai} TZ: ${TZ:-Asia/Shanghai}
# 受限升级 sidecar:只有它持有 Docker Socket,主程序通过共享 Unix Socket 触发
updater:
build:
context: http://45.207.223.190:10082/mrlee/-Fonu.git#main
target: updater
args:
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
UPDATER_IMAGE: ${UPDATER_IMAGE:-docker.m.daocloud.io/library/docker:27-cli}
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
image: fonu-updater:${FONU_VERSION:-local}
pull_policy: build
container_name: fonu-updater
restart: unless-stopped
volumes:
- ${DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock
- ${FONU_UPDATE_HOST_DIR:-.}:/workspace
- fonu-updater-sock:/run/fonu-updater
environment:
FONU_UPDATER_SOCKET: /run/fonu-updater/updater.sock
FONU_UPDATE_PROJECT_DIR: /workspace
FONU_UPDATE_COMPOSE_FILE: docker-compose.hub.yml
FONU_UPDATE_SERVICE: fonu
FONU_UPDATE_MODE: git
FONU_UPDATE_REPO: http://45.207.223.190:10082/mrlee/-Fonu.git
FONU_UPDATE_BRANCH: main
FONU_UPDATE_APP_URL: http://fonu:6893
FONU_UPDATE_HOST_DIR: ${FONU_UPDATE_HOST_DIR:-}
FONU_UPDATE_PROJECT_NAME: ${FONU_UPDATE_PROJECT_NAME:-fonu}
FONU_UPDATE_SELF_IMAGE: fonu-updater:${FONU_VERSION:-local}
FONU_UPDATE_DOCKER_SOCK: ${DOCKER_SOCK:-/var/run/docker.sock}
TZ: ${TZ:-Asia/Shanghai}
volumes: volumes:
fonu-data: fonu-data:
fonu-updater-sock:
+35
View File
@@ -13,10 +13,13 @@ services:
NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine} NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine}
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm} GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim} RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim}
UPDATER_IMAGE: ${UPDATER_IMAGE:-docker.m.daocloud.io/library/docker:27-cli}
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct} GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
image: fonu:${FONU_VERSION:-local} image: fonu:${FONU_VERSION:-local}
container_name: fonu container_name: fonu
restart: unless-stopped restart: unless-stopped
depends_on:
- updater
ports: ports:
- "6893:6893" - "6893:6893"
- "18080:80" - "18080:80"
@@ -26,14 +29,46 @@ services:
# 想落到指定目录就在同层 .env 里设 FONU_DATA_PATH=/vol1/docker/fonu-data —— # 想落到指定目录就在同层 .env 里设 FONU_DATA_PATH=/vol1/docker/fonu-data ——
# 以 / 或 . 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷。 # 以 / 或 . 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷。
- ${FONU_DATA_PATH:-fonu-data}:/data - ${FONU_DATA_PATH:-fonu-data}:/data
- fonu-updater-sock:/run/fonu-updater
environment: environment:
FONU_DATA_DIR: /data FONU_DATA_DIR: /data
FONU_LISTEN: :6893 FONU_LISTEN: :6893
FONU_UPDATER_SOCKET: /run/fonu-updater/updater.sock
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600) # 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
# 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换 # 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换
FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-} FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-}
# 时区:通知消息时间按此显示(默认东八区) # 时区:通知消息时间按此显示(默认东八区)
TZ: ${TZ:-Asia/Shanghai} TZ: ${TZ:-Asia/Shanghai}
# 受限升级 sidecar:只有它持有 Docker Socket,主程序通过共享 Unix Socket 触发
updater:
build:
context: .
target: updater
args:
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
UPDATER_IMAGE: ${UPDATER_IMAGE:-docker.m.daocloud.io/library/docker:27-cli}
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
image: fonu-updater:${FONU_VERSION:-local}
container_name: fonu-updater
restart: unless-stopped
volumes:
- ${DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock
- ${FONU_UPDATE_HOST_DIR:-.}:/workspace
- fonu-updater-sock:/run/fonu-updater
environment:
FONU_UPDATER_SOCKET: /run/fonu-updater/updater.sock
FONU_UPDATE_PROJECT_DIR: /workspace
FONU_UPDATE_COMPOSE_FILE: docker-compose.yml
FONU_UPDATE_SERVICE: fonu
FONU_UPDATE_MODE: local
FONU_UPDATE_APP_URL: http://fonu:6893
FONU_UPDATE_HOST_DIR: ${FONU_UPDATE_HOST_DIR:-}
FONU_UPDATE_PROJECT_NAME: ${FONU_UPDATE_PROJECT_NAME:-fonu}
FONU_UPDATE_SELF_IMAGE: fonu-updater:${FONU_VERSION:-local}
FONU_UPDATE_DOCKER_SOCK: ${DOCKER_SOCK:-/var/run/docker.sock}
TZ: ${TZ:-Asia/Shanghai}
volumes: volumes:
fonu-data: fonu-data:
fonu-updater-sock:
+9 -2
View File
@@ -1,14 +1,21 @@
# Fonu 文档索引 # Fonu 文档索引
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
> 最近更新:2026-09-27(v1.2.19) > 最近更新:2026-10-01(v1.9.0)
## 方案与设计(本目录) ## 方案与设计(本目录)
| 文件 | 主题 | 状态 | | 文件 | 主题 | 状态 |
| --- | --- | --- | | --- | --- | --- |
| `project-analysis-2026-09-27.md` | 全项目详细分析(v1.2.19):架构、模块、数据、API、前端、链路、风险与改进路线 | 当前版本快照 | | `project-analysis-2026-09-27.md` | 全项目详细分析(v1.2.19):架构、模块、数据、API、前端、链路、风险与改进路线 | 当前版本快照 |
| `improvement-roadmap-2026-09-27.md` | P0-P3 改进路线实施方案:逐项改法、测试、验收、依赖、发布与回滚 | P0-1 已完成,其余待实施 | | `improvement-roadmap-2026-09-27.md` | P0-P3 改进路线实施方案:逐项改法、测试、验收、依赖、发布与回滚 | P0-1、P0-3、P0-4 已完成,其余待实施 |
| `system-upgrade.md` | Docker Compose 一键升级:sidecar 架构、首次部署、安全边界、回滚、故障排查与验证页 | 已实施(v1.3.8) |
| `agent-listen-strategy-plan.md` | Agent 默认监听策略:回环监听、SSH 隧道优先、HTTPS pin 可选与自动迁移方案 | 阶段 1-4 已实施(v1.4.0);2.0.0 兼容清理待后续 |
| `cloudflared-integration-plan.md` | Cloudflare Tunnel(cloudflared)与 Fonu 的融合方案:托管模型、复用面、分阶段落地与 v2 优化 | 阶段 1-2 已实施(v1.6.0);后续按 `cloudflared-fonu-fit-plan.md` 推进 |
| `cloudflared-feature-plan.md` | Cloudflare 隧道功能全集:官方最新文档、参考项目功能、Fonu 现状差距与 P0-P3 路线 | 查漏用;实施以 `cloudflared-fonu-fit-plan.md` 为准 |
| `cloudflared-fonu-fit-plan.md` | Cloudflare 隧道 Fonu 适配版计划:出口抽象、规则同源、体系整合、明确不做项与阶段实施 | 阶段 1-3 主体已实施;剩余项见 `cloudflared-current-features.md` |
| `cloudflared-current-features.md` | Cloudflare 隧道当前已实现功能说明:页面、出口、路由、DNS、监控、备份、诊断、API 与限制 | 当前版本功能基线 |
| `cloudflared-github-ux-research.md` | GitHub 同类项目调研:Cloudflare 管理面板、反向代理、访问平台、监控与流量检查器的可借鉴体验 | 调研与优化路线 |
| `project-audit-2026-09-24.md` | 全项目审查:P0/P1/P2 分级,含精确行号与逐条修法 | **进行中**——P0 已随 v1.2.7 完成,P1 实施中 | | `project-audit-2026-09-24.md` | 全项目审查:P0/P1/P2 分级,含精确行号与逐条修法 | **进行中**——P0 已随 v1.2.7 完成,P1 实施中 |
| `frp-server-list-merge-plan.md` | FRP 服务端列表并入「公网服务端」页(方案 A,四步) | 步 1/2 已完成(v1.2.6);步 3 文案收尾、步 4 死样式清理未做 | | `frp-server-list-merge-plan.md` | FRP 服务端列表并入「公网服务端」页(方案 A,四步) | 步 1/2 已完成(v1.2.6);步 3 文案收尾、步 4 死样式清理未做 |
| `agent-capability-plan.md` | fonu-agent 能力扩展(P0/P1/P2/P3) | 已实施(agent 0.11.0);P3 三项不做 | | `agent-capability-plan.md` | fonu-agent 能力扩展(P0/P1/P2/P3) | 已实施(agent 0.11.0);P3 三项不做 |
+757
View File
@@ -0,0 +1,757 @@
# Agent 默认监听策略与自动安全接入方案
> 方案编号:P0-5
> 制定日期:2026-09-28
> 当前 AgentVersion:0.14.0
> 目标:默认不暴露 Agent 管理接口,安装和升级过程自动建立安全连接,不要求用户手工配置端口、隧道或证书
> 实施状态(2026-09-28):阶段 1-5 主体已在 v1.5.0 完成,包括回环监听、SSH 隧道与后台重连、旧 HTTP 迁移、HTTPS 端口自动选择、证书自动轮换与双 pin 过渡、主机防火墙辅助、传输状态接口与审计记录。剩余:2.0.0 移除旧 HTTP 兼容分支,以及 Debian/Ubuntu/CentOS、宝塔、端口占用、云安全组受限等真机矩阵验证。
---
## 一、结论
推荐采用**自动为主、安全优先、旧模式显式兼容**的三层策略:
1. **默认模式:SSH 隧道 + 回环监听**
- `fonu-agent` 默认只监听 `127.0.0.1:7700`。
- 中央 Fonu 使用现有 SSH 凭据建立本地转发:
```text
中央 Fonu 127.0.0.1:<随机本地端口>
→ SSH 加密连接
→ VPS 127.0.0.1:7700
→ fonu-agent
```
- 不开放公网 Agent 端口,不需要域名、证书、云安全组或防火墙修改。
2. **可选模式:自动 HTTPS 反代 + 证书固定**
- Agent 仍只监听回环地址。
- HTTPS 不作为首次安装选项;Agent 安全安装完成后,用户可在「公网 Agent」页切换到该高级模式。
- 切换时自动配置 Nginx HTTPS 反代,并使用自签名证书。
- 中央 Fonu 首次安装时记录证书指纹,后续按指纹校验。
- 适用于希望免维护常驻 SSH 隧道、且愿意开放一个 HTTPS 端口的用户。
3. **兼容模式:显式公网明文 HTTP**
- 保留旧部署能力,但必须显式开启。
- UI 持续显示高危警告。
- 不允许自动降级到该模式。
默认安装与升级过程不需要用户设置 `FONU_AGENT_LISTEN`、TLS 证书或防火墙规则。
---
## 二、现状
### 2.1 Agent 监听
当前入口:
```go
listenv := flag.String("listen", envOr("FONU_AGENT_LISTEN", ":7700"), ...)
```
`internal/agent.NewServer` 空配置时同样回落到:
```go
cfg.ListenAddr = ":7700"
```
`:7700` 等价于监听所有 IPv4 / IPv6 网卡。
### 2.2 传输安全
当前 Agent 使用明文 HTTP:
- Bearer Token 在网络上直接传输;
- Token 已采用常量时间比较;
- Agent 默认没有 TLS;
- 安装脚本打印:
```text
API: http://<VPS-IP>:7700
```
### 2.3 中央 Fonu 连接
安装完成后,`AgentInstall` 写入:
```go
AgentURL = fmt.Sprintf("http://%s:%d", server.SSHHost, 7700)
```
中央 Fonu 后续直接访问 VPS 公网 IP 的 7700 端口。
### 2.4 当前问题
- 用户需要额外放行或确认 7700;
- 如果防火墙忘记放行,Agent 显示不可达;
- 如果放行,Token 暴露在明文网络中;
- 部署工具和容器环境可能改变监听行为;
- “默认安全”没有形成产品行为;
- 安全策略主要依赖用户理解和手工操作。
---
## 三、设计目标
### 3.1 必须实现
1. 新安装默认不监听公网网卡。
2. 安装过程自动建立中央 Fonu 到 Agent 的安全连接。
3. 不需要用户手工配置网络或证书。
4. Agent 升级时可自动迁移旧监听配置。
5. 旧部署仍可显式使用公网 HTTP。
6. 传输失败时给出可执行的诊断信息。
7. 中央 Fonu 重启后可自动恢复连接。
8. Agent 与中央 Fonu 的版本兼容策略保持清晰。
### 3.2 非目标
- 不尝试自动修改云厂商安全组;
- 不要求用户申请域名或公网 CA 证书;
- 不引入多租户权限模型;
- 不把 Agent 管理 API 暴露给家庭用户的普通浏览器;
- 不提供一个“自动回退到明文 HTTP”的危险路径。
---
## 四、方案对比
### 4.1 方案 A:公网 HTTP + Bearer Token(现状)
| 项目 | 评价 |
| --- | --- |
| 自动化 | 高 |
| 安全性 | 低 |
| 防火墙 | 需要放行 7700 |
| 证书 | 不需要 |
| 复杂度 | 低 |
| 主要风险 | Token 明文、端口暴露、被动扫描 |
结论:只保留为显式兼容模式,不再作为默认。
### 4.2 方案 B:SSH 隧道 + Agent 回环监听(推荐默认)
| 项目 | 评价 |
| --- | --- |
| 自动化 | 很高 |
| 安全性 | 高 |
| 防火墙 | 不需要新增端口 |
| 证书 | 不需要 |
| 复杂度 | 中高,需要隧道生命周期管理 |
| 依赖 | SSH 可达;本功能安装 Agent 时已经要求 SSH |
| 主要优势 | 不暴露端口、不依赖域名和证书 |
结论:作为默认模式。
### 4.3 方案 C:Nginx HTTPS + 自签名证书固定
| 项目 | 评价 |
| --- | --- |
| 自动化 | 高 |
| 安全性 | 高 |
| 防火墙 | 需要放行一个 HTTPS 端口 |
| 证书 | 自动生成和固定,不需要公网 CA |
| 复杂度 | 中 |
| 主要优势 | 中央 Fonu 可直接连接,不依赖常驻 SSH |
| 主要限制 | 云安全组仍需用户放行端口 |
结论:作为可选直连模式。
### 4.4 方案 D:公网域名 + ACME 证书
| 项目 | 评价 |
| --- | --- |
| 自动化 | 中 |
| 安全性 | 高 |
| 防火墙 | 需要 443 或 DNS-01 |
| 证书 | 需要域名、DNS 与 CA |
| 复杂度 | 高 |
| 用户成本 | 高 |
结论:不作为默认。可作为未来面向高级用户的能力。
---
## 五、推荐架构
### 5.1 默认数据流
```mermaid
flowchart LR
Web[Fonu Web] --> API[Fonu 后端]
API --> Tunnel[SSH 隧道管理器]
Tunnel -->|127.0.0.1:自动分配端口| SSH[SSH 加密连接]
SSH --> VPSLoopback[VPS 127.0.0.1:7700]
VPSLoopback --> Agent[fonu-agent]
```
### 5.2 角色职责
| 组件 | 职责 |
| --- | --- |
| Fonu 后端 | 决定 transport、启动隧道、调用 AgentClient |
| 隧道管理器 | 建立 SSH 本地转发、保持连接、重连、健康检查 |
| fonu-agent | 默认只监听回环,不再负责公网暴露 |
| 安装脚本 | 写入 `FONU_AGENT_LISTEN=127.0.0.1:7700`,禁止默认可公开 |
| UI | 显示安全模式、连接状态、可选迁移和显式旧模式警告 |
### 5.3 连接抽象
中央 Fonu 不再把“公网 IP + 7700”当作 Agent 的唯一定义。新增 transport 概念:
```text
tunnel SSH 本地转发,默认
https-pin Nginx HTTPS + 证书指纹固定,可选
http 公网明文,仅显式兼容
```
`AgentClient` 仍使用普通 HTTP 客户端接口,但 baseURL 由 transport 管理:
- tunnel:`http://127.0.0.1:<localPort>`
- https-pin:`https://<host>:<port>`
- http:`http://<host>:7700`
---
## 六、默认模式:SSH 隧道
### 6.1 为什么默认走 SSH
安装 Agent 已经必须使用 SSH,因此:
- 不新增用户操作;
- 不需要公网开放 7700;
- 不需要证书和域名;
- 不依赖云安全组修改;
- SSH 主机密钥已经使用 TOFU 校验;
- Token 继续保留,形成双层认证。
### 6.2 本地端口分配
每个 VPS 服务端分配一个稳定的本地回环端口:
```text
127.0.0.1:<agent_local_port>
```
分配方式建议:
1. 首次安装时在 `frp_servers.agent_local_port` 中保存;
2. 可在 `17700-18700` 范围内分配;
3. 分配时检查:
- 未被其他服务端占用;
- 未被本机其他 SSH 隧道占用;
- 可以正常绑定。
4. 保存后不再变化,便于重启恢复。
### 6.3 隧道管理器
新增:
```text
internal/frp/agent_tunnel.go
```
核心能力:
- `Ensure(serverID)`:确保隧道已启动;
- `Stop(serverID)`:停止隧道;
- `Restart(serverID)`:重建;
- `State(serverID)`:返回 connected / reconnecting / failed;
- 应用退出时统一关闭。
实现建议:
- 使用现有 `golang.org/x/crypto/ssh`;
- 复用 `SSHInstaller` 的 known_hosts 校验;
- SSH 连接使用 keepalive;
- 本地监听使用 `127.0.0.1`,不绑定 `0.0.0.0`;
- 连接断开后指数退避重连;
- 每个服务端最多一个隧道实例;
- 同一 `AgentClient` 复用 HTTP 连接。
### 6.4 健康检查
隧道建立后执行:
1. 本地端口可以连接;
2. `GET /api/v1/ping` 返回 200;
3. Bearer Token 验证通过;
4. `GET /api/v1/status` 返回 Agent 版本。
只有四项都通过,才把 transport 标记为 connected。
### 6.5 连接状态
前端需要显示:
```text
传输方式:SSH 隧道
本地端口:127.0.0.1:177xx
Agent 地址:127.0.0.1:7700(VPS 回环)
隧道状态:已连接 / 重连中 / 失败
最后错误
```
---
## 七、Agent 默认监听与防误开
### 7.1 默认值
修改:
```go
const defaultListenAddr = "127.0.0.1:7700"
```
以下位置同步:
- `cmd/fonu-agent/main.go`
- `internal/agent.NewServer`
- `scripts/agent-install.sh`
- `internal/frp/agent_installer.go` 内嵌安装脚本
### 7.2 远程监听保护
如果监听地址不是回环:
```text
0.0.0.0:7700
:7700
[::]:7700
VPS公网IP:7700
```
则必须显式设置:
```dotenv
FONU_AGENT_ALLOW_REMOTE=1
```
否则 Agent 拒绝启动并输出:
```text
检测到 Agent 将监听非回环地址,但未显式允许远程访问。
默认安全模式只监听 127.0.0.1:7700。
如确认需要公网明文访问,请设置 FONU_AGENT_ALLOW_REMOTE=1。
```
### 7.3 旧配置迁移
旧版本 `agent.env` 可能没有 `FONU_AGENT_LISTEN`,此时:
1. 新 Agent 默认监听回环;
2. 安装脚本显式写入:
```dotenv
FONU_AGENT_LISTEN=127.0.0.1:7700
```
3. 中央 Fonu 在执行「升级 Agent」时同步建立隧道;
4. 升级完成后切换 transport;
5. 旧公网地址从 `agent_url` 中移除;
6. 不停留手工维护窗口。
### 7.4 状态接口
Agent `/api/v1/status` 新增:
```json
{
"listen_addr": "127.0.0.1:7700",
"listen_scope": "loopback",
"remote_access_enabled": false,
"transport_security": "loopback"
}
```
---
## 八、自动安装流程
### 8.1 新安装
1. 用户填写 VPS SSH 信息。
2. 中央 Fonu 通过 SSH 探测系统与 Nginx。
3. 分配 `agent_local_port`。
4. 上传 Agent 二进制。
5. 安装脚本写入:
- Token;
- `FONU_AGENT_LISTEN=127.0.0.1:7700`;
- systemd 服务。
6. 启动 Agent。
7. 通过 SSH 本地转发建立隧道。
8. 调用:
- `GET /api/v1/ping`
- `GET /api/v1/status`
9. 保存:
- token 密文;
- transport=`tunnel`;
- local port;
- AgentVersion。
10. UI 显示“安全模式已启用”。
### 8.2 自动迁移旧安装
1. 检测现有 `agent_url` 是否为 `http://公网IP:7700`。
2. 显示“可升级到安全模式”,但不强制立即迁移。
3. 用户点击「升级 Agent」后:
- 安装新二进制;
- 重写 `agent.env` 为回环监听;
- 重启服务;
- 建立隧道;
- 验证连通;
- 切换 central transport。
4. 只有隧道验证成功后,才关闭旧公网连接。
5. 失败时保留旧 Agent 配置和旧连接,自动回滚。
### 8.3 应用重启恢复
1. 读取启用管理且 transport=tunnel 的服务端。
2. 逐个调用 `Tunnel.Ensure`。
3. 启动失败不阻塞整个 Fonu 启动。
4. UI 显示重连状态。
5. 后台按指数退避继续重试。
---
## 九、可选模式:HTTPS + 证书固定
### 9.1 适用场景
- 不希望长期维护 SSH 隧道;
- 可以接受开放一个 HTTPS 端口;
- 没有域名或不想申请公网证书;
- 希望 Agent API 可被中央 Fonu直接访问。
### 9.2 安装后切换流程
首次安装默认仍使用 SSH 隧道。只有用户在「公网 Agent」页明确切换到 HTTPS 直连时,才执行以下步骤:
1. Agent 仍监听 `127.0.0.1:7700`。
2. 切换流程生成自签名证书:
```text
/etc/fonu-agent/tls/agent.crt
/etc/fonu-agent/tls/agent.key
```
3. 自动寻找可用端口:
- 优先 7443;
- 其次 8443、9443;
- 若 443 空闲且未占用可提供选项,但不默认抢占。
4. 生成独立 Nginx server block:
```nginx
server {
listen 7443 ssl;
ssl_certificate /etc/fonu-agent/tls/agent.crt;
ssl_certificate_key /etc/fonu-agent/tls/agent.key;
location /api/v1/ {
proxy_pass http://127.0.0.1:7700;
}
}
```
5. 返回证书 SHA-256 指纹。
6. 中央 Fonu 保存 pin,并使用自定义校验。
### 9.3 TLS 固定
不能使用:
```go
InsecureSkipVerify: true
```
应实现:
```go
VerifyPeerCertificate(rawCerts [][]byte, _ [][]*x509.Certificate) error {
// 计算证书 DER 的 SHA-256
// 与数据库中保存的 pin 做常量时间比较
// 同时校验有效期与 SAN(如可校验)
}
```
首次安装必须通过 SSH 获取指纹,避免首次连接被中间人截获。
### 9.4 防火墙
> 实施更新(v1.5.0 / Agent 0.14.0):已支持检测 ufw / firewalld / nftables / iptables;对 ufw 与 firewalld 可在用户明确确认后自动放行,nftables / iptables 仅检测和提示,不会自动改规则。
自动放行仅在用户明确确认后执行,支持的工具:
- ufw;
- firewalld;
- nftables / iptables(只提示,不自动改规则)。
云厂商安全组始终不能自动修改。UI 必须提示:
```text
请在云控制台放行 TCP 7443。主机防火墙需要你确认后由 Fonu 执行,Fonu 不会修改云安全组。
```
---
## 十、兼容模式:显式公网 HTTP
仅用于旧部署迁移或用户明确要求:
```dotenv
FONU_AGENT_LISTEN=:7700
FONU_AGENT_ALLOW_REMOTE=1
```
中央 Fonu UI 必须:
- 显示“公网明文传输”红色状态;
- 不在新安装中默认选择;
- 不支持由自动模式静默回退;
- 显示迁移到 SSH 隧道或 HTTPS 的按钮;
- 在变更时记录审计事件。
旧 transport 允许读取和继续连接,但所有新增安装默认使用 tunnel。
---
## 十一、数据模型变更
建议新增迁移:
```sql
ALTER TABLE frp_servers ADD COLUMN agent_transport TEXT NOT NULL DEFAULT 'http';
ALTER TABLE frp_servers ADD COLUMN agent_local_port INTEGER NOT NULL DEFAULT 0;
ALTER TABLE frp_servers ADD COLUMN agent_tls_pin TEXT NOT NULL DEFAULT '';
ALTER TABLE frp_servers ADD COLUMN agent_listen_addr TEXT NOT NULL DEFAULT '';
ALTER TABLE frp_servers ADD COLUMN agent_transport_state TEXT NOT NULL DEFAULT 'unknown';
ALTER TABLE frp_servers ADD COLUMN agent_transport_error TEXT NOT NULL DEFAULT '';
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `agent_transport` | `tunnel` / `https-pin` / `http` |
| `agent_local_port` | 隧道本地回环端口 |
| `agent_tls_pin` | HTTPS 证书 DER SHA-256 |
| `agent_listen_addr` | Agent 实际监听地址缓存 |
| `agent_transport_state` | `connected` / `reconnecting` / `failed` / `unknown` |
| `agent_transport_error` | 最近一次连接错误 |
### 11.1 兼容迁移
旧数据:
```text
agent_transport = http
```
新安装:
```text
agent_transport = tunnel
```
---
## 十二、API 与前端变更
### 12.1 API
`FrpServer` JSON 增加:
```json
{
"agent_transport": "tunnel",
"agent_transport_state": "connected",
"agent_local_port": 17701,
"agent_listen_addr": "127.0.0.1:7700",
"agent_transport_error": ""
}
```
新增管理端点:
| 方法 | 路径 | 作用 |
| --- | --- | --- |
| POST | `/api/frpmulti/servers/{id}/agent/transport` | 切换 tunnel / https-pin / http |
| POST | `/api/frpmulti/servers/{id}/agent/tunnel/restart` | 重启隧道 |
| GET | `/api/frpmulti/servers/{id}/agent/transport` | 读取详细状态 |
### 12.2 前端
「公网 Agent」页新增传输模式卡:
- SSH 隧道,默认;
- HTTPS + 证书固定,可选;
- 公网 HTTP 明文,兼容。
显示:
```text
传输方式:SSH 隧道
隧道状态:已连接
本地入口:127.0.0.1:17701
Agent 监听:127.0.0.1:7700
安全等级:安全
```
旧模式显示:
```text
公网明文 HTTP
风险:Token 会通过网络明文传输
建议迁移到 SSH 隧道
```
---
## 十三、自动回滚
迁移或切换 transport 失败时:
1. 保留旧 transport 与旧 Agent 配置;
2. 不覆盖旧 Token;
3. 不删除旧公网端口配置;
4. 如果已经重写 `agent.env`,恢复旧文件;
5. 如果 Agent 已重启但新连接失败,重新启动旧服务;
6. 向 UI 返回完整错误和回滚结果。
任何自动迁移都必须满足:
```text
新连接验证成功
→ 才提交数据库 transport
→ 才停止旧监听
```
---
## 十四、测试计划
### 14.1 单元测试
- 默认监听地址是回环;
- 非回环监听缺少 `FONU_AGENT_ALLOW_REMOTE` 时拒绝启动;
- 回环监听无需额外变量;
- `agent_transport` 序列化与旧数据兼容;
- 隧道本地端口分配、冲突与释放;
- 隧道重连、退避和关闭;
- 证书 pin 正确、证书变化、pin 不匹配时拒绝;
- 旧 HTTP transport 继续可用并产生警告。
### 14.2 集成测试
- 在测试 SSH 服务上建立本地转发;
- Agent 只监听 127.0.0.1;
- 应用重启后隧道自动恢复;
- SSH 断开后重连;
- 多服务端并发隧道;
- 迁移前失败回滚;
- 迁移成功后旧公网端口关闭。
### 14.3 真机验证
至少覆盖:
- Debian / Ubuntu / CentOS;
- fnOS 中的 Fonu;
- 宝塔 Nginx;
- 已存在 80 / 443 / 7443 占用的 VPS;
- 云安全组只开放 SSH 的 VPS;
- 云安全组开放 7443 的可选 HTTPS 模式。
### 14.4 验收条件
- 新安装后 `ss -tlnp` 只能看到 Agent 监听 `127.0.0.1:7700`;
- 公网扫描不到 7700;
- 无需新增云安全组规则;
- UI 显示隧道已连接;
- Fonu 重启后自动恢复;
- 「升级 Agent」可以自动迁移旧配置;
- 失败时不会丢失旧连接。
---
## 十五、实施阶段
### 阶段 1:默认回环与防误开
- Agent 默认监听改为 `127.0.0.1:7700`;
- 非回环监听要求 `FONU_AGENT_ALLOW_REMOTE=1`;
- 安装脚本显式写入监听地址;
- UI 显示监听范围。
该阶段暂不自动切换中央连接,避免破坏旧部署。
### 阶段 2:SSH 隧道管理器
- 新增数据库字段;
- 新增隧道管理器和 AgentClient transport;
- 安装后自动建立隧道;
- 应用重启恢复;
- UI 显示 transport 状态。
### 阶段 3:自动迁移旧 Agent
- 检测旧公网 HTTP;
- 「升级 Agent」时执行安全迁移;
- 成功切换后关闭旧入口;
- 失败自动回滚。
### 阶段 4:可选 HTTPS + pin
- 自动生成证书;
- Nginx server block;
- TLS pin;
- 防火墙辅助;
- UI 可选切换。
### 阶段 5:收紧默认
- 新安装不再接受公网 HTTP;
- 兼容模式需要明确的高风险确认;
- 文档和通知引导迁移。
---
## 十六、决策结论
以下决策已于 2026-09-28 确定,后续实施按此执行。
| 编号 | 决策 | 执行设定 | 理由 |
| --- | --- | --- | --- |
| 1 | SSH 隧道作为唯一默认模式 | 首次安装不提供模式选择,统一安装为回环监听 + SSH 隧道;HTTPS 直连只作为安装后的高级切换项 | 现有安装流程已经依赖 SSH;默认路径必须最少输入、最少网络要求 |
| 2 | 隧道本地端口使用固定范围 | 固定为 `17700-18700`,绑定 `127.0.0.1`;分配结果持久化;启动时先复用持久化端口,再在范围内顺序查找空闲端口;范围耗尽时明确报错,不使用临时端口 | 固定范围便于排查和审计;持久化避免每次重启变化;不回退临时端口避免地址漂移 |
| 3 | 隧道跟随主应用生命周期 | 使用应用内 `agent_tunnel` 管理器,在 Fonu 启动后按服务端恢复;应用退出时统一关闭;断线后指数退避重连;首版不新增独立 systemd 服务 | 当前项目是单进程控制面,调度器、FRP、监控均在同一生命周期内,新增守护进程会扩大部署复杂度 |
| 4 | 公网 HTTP 保留整个 1.x 兼容期 | 自 `1.4.0` 起不再允许新安装选择 HTTP;存量连接继续可用但显示高危警告并提供迁移;计划在 `2.0.0` 移除 | 需要给旧 Agent 和环境留出迁移窗口,同时从版本边界上明确技术债的截止点 |
| 5 | HTTPS 自签名证书使用短期自动轮换 | ECDSA P-256,有效期 365 天;剩余 30 天时自动轮换;轮换期间同时接受旧 pin 和新 pin,双 pin 窗口 7 天;优先通过现有 SSH 通道获取新指纹后再切换 | 避免长期证书失管;双 pin 防止轮换瞬间把所有中央连接锁死 |
| 6 | 首版不自动改主机防火墙 | 首先只检测、展示和提示;SSH 隧道模式本身不需要放行新端口;HTTPS 高级模式后续再提供显式的“一键放行主机防火墙”操作,并始终不修改云厂商安全组 | 防火墙和云安全组属于高风险外部状态;首版先把安全默认值落地,避免自动变更导致 SSH 或现有服务失联 |
### 16.1 决策对实施阶段的影响
- 阶段 1 只做 Agent 默认回环和防误开。
- 阶段 2 实现 `17700-18700` 的持久化端口分配与应用内隧道管理器。
- 阶段 3 完成旧 Agent 自动迁移;迁移失败必须回到旧 transport。
- 阶段 4 增加 HTTPS + pin 的高级模式、证书轮换和双 pin 窗口。
- `1.4.0` 起前端不再允许新建 HTTP transport。
- `2.0.0` 删除 `http` transport 和旧 Agent 兼容分支。
- 防火墙变更仅在阶段 4 之后按用户明确操作执行,首版不实现自动写规则。
---
## 十七、最终建议
优先实现前三个阶段:
1. Agent 默认回环;
2. SSH 隧道自动建立与恢复;
3. 旧 Agent 自动迁移并支持回滚。
这三步完成后,默认部署不再暴露公网 7700,也不需要用户修改 Composer 或防火墙。HTTPS + pin 与防火墙辅助放在后续阶段,作为需要直连用户的可选增强。这样可以在不扩大用户操作成本的前提下,完成 P0-5 的安全目标。
+654
View File
@@ -0,0 +1,654 @@
# Cloudflare 隧道当前功能说明
> 日期:2026-10-01
> 状态:v1.9.0 实测功能基线。本文描述**当前代码实际实现的能力**,不是早期规划稿。
> 相关文档:
> - `cloudflared-integration-plan.md`:早期融合方案
> - `cloudflared-feature-plan.md`:官方与参考项目功能全集,用于查漏
> - `cloudflared-fonu-fit-plan.md`:Fonu 适配版阶段计划
---
## 一、模块定位
Cloudflare 隧道在 Fonu 中不是独立的 cloudflared 管理面板,而是公网访问的第二条出口:
- 本机 Nginx:适合有公网 IP 或已有端口映射的场景。
- FRP:适合需要 TCP/UDP、自控 VPS 或大流量的场景。
- Cloudflare 隧道:适合没有公网 IP、主要发布 HTTP/HTTPS Web 服务的场景。
当前模块已经融入 Fonu 的规则、监控、通知、状态页、日志、备份、诊断和升级体系。
---
## 二、已实现的入口与页面
### 2.1 Cloudflare 隧道列表页
页面:`web/src/views/CloudflareView.vue`
已实现:
- 隧道列表显示:
- 隧道名称
- 状态
- 副本 / cloudflared HA 连接数
- cloudflared 版本
- 路由数量
- 指标端口
- 全局托管开关:
- 关闭:停止全部隧道。
- 开启:立即启动全部隧道。
- 应用重启:全局托管开启时自动恢复全部隧道。
- 新建隧道弹窗:
- 自动托管模式。
- 手工隧道模式。
- 手工隧道暴露规则:
- 子域名输入。
- 域从 Cloudflare Zone 列表选择。
- HTTP / HTTPS / TCP / SSH 服务类型。
- `://` 与 URL 分离显示。
- 实时显示完整主机名。
- 隧道操作:
- 详情。
- 配置页。
- 启动 / 停止 / 重启。
- 删除。
- 应用配置:
- Account ID。
- API Token。
- Token 验证。
- cloudflared 下载镜像。
- 默认网络参数。
### 2.2 隧道配置页
页面:`web/src/views/CloudflareTunnelConfigView.vue`
已实现:
- 独立路由页面,不再使用编辑弹窗:
- `/cloudflare/:id/config`
- 指标卡:
- 活动副本
- 路由数
- 状态
- 运行时间
- 基础配置:
- 页面只显示“编辑基础配置”按钮。
- 点击后弹窗编辑名称、子域名、Zone、服务类型、URL、自动启动。
- 路由列表:
- 顺序。
- 目标域名。
- 路径。
- 服务。
- 来源。
- 操作。
- 拓扑图:
- 域名节点。
- Cloudflare 出口节点。
- 隧道节点。
- 服务节点。
- 点阵画布和曲线连接。
- 路由操作:
- 添加路由。
- 编辑路由。
- 删除路由。
- 上移 / 下移。
- 复制路由。
- 测试源服务 TCP 连通性。
- 路由弹窗:
- 子域名 + Zone 下拉。
- HTTP / HTTPS / TCP / SSH + URL。
- 完整主机名实时预览。
- 全部捕获规则:
- 默认显示 `http_status:404`。
- 运行控制:
- 启动、停止、重启。
自动托管隧道的路由只读,来源显示为“反向代理规则”,并提示到“反向代理”页维护。
---
## 三、Cloudflare 账号与二进制
### 3.1 账号配置
实现文件:
- `internal/cloudflared/cloudflare_api.go`
- `internal/cloudflared/service.go`
已实现:
- 保存 Account ID + API Token。
- API Token 加密存储。
- Token 掩码回显。
- 调用账号级 `tokens/verify` 验证 Token。
- 查询当前 Token 可访问的 Zone。
- 根据域名向上查找所属 Zone。
- 创建、读取、删除 Cloudflare 隧道。
- 获取隧道 Token。
- 读取和覆盖远端 ingress。
- 创建、更新、删除 DNS CNAME。
- 只读检测 Cloudflare Access。
### 3.2 cloudflared 二进制
实现文件:
- `internal/cloudflared/binary.go`
已实现:
- 内置多种下载镜像。
- 下载并原子替换本机 cloudflared。
- 校验文件大小和可执行性。
- 显示当前版本。
- 查询 GitHub 最新版本。
- 下载指定版本。
- 保留上一版并支持回滚。
版本显示统一为语义版本,例如:
```text
2026.9.3
```
---
## 四、隧道生命周期
实现文件:
- `internal/cloudflared/service.go`
- `internal/cloudflared/manager.go`
- `internal/cloudflared/store.go`
已实现:
- 通过 Cloudflare API 创建命名隧道。
- 生成本地凭据:
- `creds.json`
- `config.yml`
- `cloudflared.log`
- 支持隧道模式模型:
- `account-local`
- `token-local`
- `token-remote`
- 进程托管:
- 启动。
- 停止。
- 重启。
- 崩溃后读取日志尾部便于诊断。
- 开机启动:
- 全局托管开启时,应用启动会恢复全部隧道。
- “随 Fonu 自动启动”开关保留为隧道级选项。
- 全局总开关:
- 开启时启动全部隧道。
- 关闭时停止全部隧道。
- 凭据刷新:
- 重新获取隧道 Token。
- 更新本地 `creds.json`。
- 运行中时自动重启。
---
## 五、出口抽象与反向代理规则
实现文件:
- `internal/proxy/proxy.go`
- `internal/service/proxy.go`
- `internal/cloudflared/service.go`
迁移:
- `migrations/035_proxy_exits.sql`
已实现:
- 反向代理规则增加出口字段:
- `local`
- `cloudflare`
- 支持同时选择:
- 本机 Nginx + Cloudflare。
- Cloudflare 出口增加目标隧道:
- `cf_tunnel_id`
- 规则变化自动同步:
- 新增规则。
- 修改规则。
- 删除规则。
- 启停规则。
- 托管隧道 ingress 从规则派生:
- 每条域名使用自己的回源地址。
- 仅同步启用的 Cloudflare 出口规则。
- 仅 Cloudflare 出口的规则不生成无用的本机 Nginx server。
- 规则保存成功但 Cloudflare 同步失败时:
- 本地规则仍保存。
- 返回 `sync_warning`。
- 前端显示警告而不是把整次保存判为失败。
---
## 六、路由与 ingress
实现文件:
- `internal/cloudflared/routes.go`
- `internal/cloudflared/config.go`
已实现:
- 手工隧道路由来自本地 `config.yml`。
- 托管隧道路由来自反向代理规则。
- 路由列表支持:
- 添加。
- 编辑。
- 删除。
- 上移 / 下移。
- 复制。
- 源服务连通性测试。
- 保存路由时:
- 重新生成本地 `config.yml`。
- 覆盖 Cloudflare 云端 ingress。
- 同步 DNS。
- 运行中时重启 cloudflared。
- 末条 catch-all 固定为:
```text
http_status:404
```
- 通配路径处理:
- UI 显示 `*`。
- 发送到 Cloudflare 时省略 `path`。
- 避免 `*` 被当作非法正则。
- 支持正则 path 的后端字段,但当前页面主要使用通配路径。
### P0:接入与调和
- Cloudflare 页面新增“接入向导”。
- 接入预检检查:
- cloudflared 二进制。
- Account ID。
- API Token。
- Zone 权限。
- 隧道列表新增“调和”列:
- 已同步。
- 有漂移。
- 手工隧道。
- 有漂移时可一键修复:
- 以本地规则覆盖 Cloudflare 云端配置。
---
## 七、DNS 管理
实现文件:
- `internal/cloudflared/cloudflare_api.go`
- `internal/cloudflared/service.go`
- `internal/cloudflared/store.go`
迁移:
- `migrations/035_proxy_exits.sql`
已实现:
- 自动创建 CNAME:
```text
<hostname> -> <tunnel-id>.cfargotunnel.com
```
- 开启 Cloudflare 代理(橙云)。
- 记录 Fonu 创建或接管的 DNS 记录:
- `cf_dns_records`
- 删除域名或移除出口时:
- 只删除 Fonu 自己接管的记录。
- 不覆盖指向其他目标的用户 DNS 记录。
- 域名从一条隧道迁移到另一条隧道时支持接管迁移。
---
## 八、状态、指标与日志
实现文件:
- `internal/cloudflared/manager.go`
- `internal/cloudflared/health.go`
已实现:
- 从 cloudflared metrics 端口读取:
- HA 连接数。
- 延迟。
- 上下行速率。
- 传输协议。
- 隧道列表实时状态。
- 配置页指标卡。
- 日志中心新增“Cloudflare 隧道”页签:
- 选择隧道。
- 查看 200 / 500 / 2000 行日志。
- 关键词筛选。
---
## 九、健康巡检、通知与状态页
迁移:
- `migrations/036_cf_health.sql`
实现文件:
- `internal/cloudflared/health.go`
- `internal/monitor/monitor.go`
- `internal/app/monitor_source.go`
已实现:
- 记录 Cloudflare 域名健康状态变化:
- `up`
- `down`
- `unknown`
- 24 小时可用率。
- 不可用区间。
- 巡检复用现有:
- 路由健康开关。
- 隧道断开通知开关。
- 连续失败阈值。
- 连续不通和恢复复用通知事件 `tunnel_down`。
- 公开状态页合并:
- FRP 路由。
- Cloudflare 路由。
- 状态页展示每条路由的不可用区间:
- 开始时间。
- 持续分钟数。
- 原因。
- 状态页只显示域名、来源、状态和可用率,不泄露隧道 ID 与内网地址。
---
## 十、仪表盘
实现文件:
- `internal/api/status_handler.go`
- `web/src/views/DashboardView.vue`
已实现:
- `/api/status` 增加:
- Cloudflare 隧道总数。
- 在线数。
- 状态。
- 摘要消息。
- 仪表盘健康列表增加“Cloudflare 隧道”条目。
---
## 十一、备份与恢复
实现文件:
- `internal/backup/service.go`
已实现:
- 备份内容包含:
- `fonu.db`
- `certs`
- `nginx`
- `cloudflared/tunnels`
- `cloudflared/tunnels` 包含:
- `config.yml`
- `creds.json`
- 数据库中的 `cf_tunnels`、`cf_settings`、`cf_dns_records` 随 `fonu.db` 恢复。
- 恢复后仍建议重启 Fonu,由启动流程恢复全部隧道。
---
## 十二、漂移检测与诊断
实现文件:
- `internal/cloudflared/operations.go`
已实现:
### 12.1 漂移检测
- 比较本地规则派生 ingress 与 Cloudflare 云端 ingress。
- 检出:
- 本地有、云端缺失。
- 云端有、本地已移除。
- 回源地址不一致。
- 支持“以本地覆盖”:
```text
POST /api/cloudflare/tunnels/{id}/sync
```
- 当前未实现“以云端为准拉取”。
### 12.2 一键诊断
检查项:
- cloudflared 二进制。
- 本地 `config.yml`。
- 隧道进程状态。
- Cloudflare API 与云端配置可访问性。
- Fonu 接管的 DNS 记录。
- Cloudflare edge region1。
- Cloudflare edge region2。
---
## 十三、NAS 场景能力
实现文件:
- `internal/discovery/service.go`
- `web/src/views/ProxyView.vue`
已实现:
- 服务发现目录包含:
- 飞牛 fnOS
- AList
- Jellyfin
- PhotoPrism
- qBittorrent
- Portainer
- Home Assistant
- Immich
- AdGuard Home
- Synology DSM
- 反向代理页“扫描服务”打开发现列表。
- 点击“发布”会:
- 预填服务名称。
- 预填内网 upstream。
- 若已有托管隧道,自动选择 Cloudflare 出口。
- 打开规则编辑表单等待填写域名。
当前不是完全一键创建,仍需要用户填写域名并确认保存。
---
## 十四、安全与保护提示
已实现:
- Cloudflare Tunnel 条款提示:
- 不适合持续大文件/视频分发。
- 仅 Cloudflare 出口时:
- 提示无需申请本机 HTTPS 证书。
- Access 只读检测:
- 检测域名是否已有 Access 应用。
- 不代配 Access 策略。
- API Token 权限提示:
- `Account: Cloudflare Tunnel: Edit`
- `Zone: Zone: Read`
- `Zone: DNS: Edit`
- Zone Resources 必须包含待发布根域名。
---
## 十五、当前 API
### 隧道
```text
GET /api/cloudflare/tunnels
POST /api/cloudflare/tunnels
GET /api/cloudflare/tunnels/{id}
PUT /api/cloudflare/tunnels/{id}
DELETE /api/cloudflare/tunnels/{id}
POST /api/cloudflare/tunnels/{id}/start
POST /api/cloudflare/tunnels/{id}/stop
POST /api/cloudflare/tunnels/{id}/restart
POST /api/cloudflare/tunnels/{id}/credentials/refresh
GET /api/cloudflare/tunnels/{id}/status
GET /api/cloudflare/tunnels/{id}/logs
GET /api/cloudflare/tunnels/{id}/config
GET /api/cloudflare/tunnels/{id}/routes
PUT /api/cloudflare/tunnels/{id}/routes
POST /api/cloudflare/routes/test
GET /api/cloudflare/tunnels/{id}/drift
POST /api/cloudflare/tunnels/{id}/sync
GET /api/cloudflare/tunnels/{id}/diagnostics
```
### 二进制与配置
```text
GET /api/cloudflare/binary
POST /api/cloudflare/binary/download
GET /api/cloudflare/binary/latest
POST /api/cloudflare/binary/rollback
GET /api/cloudflare/settings
PUT /api/cloudflare/settings
POST /api/cloudflare/settings/test
POST /api/cloudflare/enabled
GET /api/cloudflare/preflight
```
### Zone、候选与 Access
```text
GET /api/cloudflare/ingress-candidates
GET /api/cloudflare/zones
GET /api/cloudflare/access-status
```
---
## 十六、数据表
### `cf_tunnels`
保存隧道基础信息:
- 名称。
- 模式。
- Tunnel ID。
- Account Tag。
- 加密后的凭据。
- 配置、凭据、日志路径。
- 网络设置。
- 运行状态。
- metrics 端口。
- 自动启动选项。
- 是否自动托管。
### `cf_settings`
保存应用级配置:
- Account ID。
- 加密后的默认 Token。
- 加密后的 API Token。
- 下载镜像。
- 默认网络设置。
- 全局托管开关。
### `cf_dns_records`
保存 Fonu 接管的 DNS 记录:
- 本地隧道 ID。
- Zone ID。
- DNS Record ID。
- Hostname。
### `cf_health_events`
保存 Cloudflare 域名健康状态变化:
- 隧道 ID。
- 域名。
- 状态。
- 原因。
- 详情。
- 时间。
### `proxy_rules` 扩展
- `exits_json`
- `cf_tunnel_id`
---
## 十七、当前未完成或仅部分实现
以下能力尚未完成,不能按“已实现”宣传:
1. 完整审计日志
- 尚未记录“谁在何时修改了规则、出口、凭据和设置”。
2. 云端配置拉取
- 漂移检测支持“本地覆盖云端”。
- 尚不支持“云端覆盖本地”。
3. 完整无感令牌轮换
- 当前是“刷新/重新获取隧道凭据”。
- 尚未实现创建新隧道后无缝迁移全部规则。
4. Access 策略管理
- 只做只读检测。
- 不创建、不修改 Access 应用。
5. 完全一键服务发布
- 当前是扫描、预填、打开表单。
- 仍需要用户填写域名并确认保存。
6. 正则路径 UI
- 后端路由模型支持 `path`。
- 当前页面主要使用通配路径,尚未提供独立的 path 高级编辑控件。
7. 多 Cloudflare 账号
- 当前应用配置只支持一个 Account ID + API Token。
- `cert.pem` / 多账号模式尚未实现。
---
## 十八、验证基线
当前实现覆盖以下验证:
```text
go test ./... -count=1
go vet ./...
cd web && npm run typecheck
cd web && npm test -- --run
cd web && npm run build
```
新增或重点测试:
- 出口默认值、持久化与过滤。
- Cloudflare 按规则生成 ingress。
- Cloudflare 健康状态与可用率计算。
- 手工 config 路由解析。
- 通配 path 不发送非法正则。
- DNS 记录归属。
- 备份包含 cloudflared 隧道配置。
- 漂移比较。
+442
View File
@@ -0,0 +1,442 @@
# Cloudflare 隧道模块全功能规划
> 调研日期:2026-09-30
> 调研对象:Cloudflare 官方文档(developers.cloudflare.com,2026-08/09 更新)+ `F:\WWW\cf\Tunnel-Cloudflared-linux`(Tunnel-Cloudflared-Manager 1.0.1)+ Fonu 1.8.0 现有实现
> 目标:把 Fonu 的「Cloudflare 隧道」从「可用的创建与托管」推进到「覆盖官方主要能力、可长期运维」的完整模块。
---
## 一、官方文档调研结论
Cloudflare 把 Tunnel 分成两条产品线,Fonu 目前主要落在第一条,规划时需要同时兼顾两条:
| 产品线 | 文档入口 | 典型用途 |
| --- | --- | --- |
| Zero Trust / 私有网络 | [cloudflare-one Tunnel](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/) | 私有网络、SSH/RDP、VPN 替代、Access 保护 |
| 公开发布应用 | [tunnel](https://developers.cloudflare.com/tunnel/) | 把公网域名指向自建服务,替代 VPS 与端口暴露 |
### 1.1 核心概念(官方)
- **Tunnel**:一条以 UUID 标识的持久对象,是源站与 Cloudflare 之间的逻辑链路。
- **Connector**:一个 `cloudflared` 进程;同一条隧道可以运行多个 connector。
- **Replica**:同一隧道的多个 `cloudflared` 实例,用于高可用与零停机更新。
- **Remotely-managed**:隧道由 Cloudflare 面板 / API 管理,规则在云端,`cloudflared` 用 token 启动。
- **Locally-managed**:规则在本机 `config.yml`,用 `credentials-file` 启动。
- **Quick tunnel**:`trycloudflare.com` 临时隧道,无需账号,官方明确仅用于测试与开发。
- **Virtual network**:私有网络中用于隔离不同网络的路由域。
### 1.2 官方关键能力清单
**隧道与连接器**
- 创建、删除、列出、按名称或 UUID 查询隧道
- 获取隧道 token;token 轮换
- 同一隧道部署多个 replica
- `cloudflared tunnel info` 查看连接器与连接状态
- 每个 connector 默认建立 4 条出站连接,分布在至少两个数据中心
- 支持 post-quantum 加密(`--post-quantum`)
**路由**
- Published application:`hostname`(可带 `path`)→ `service`
- 支持服务协议:`http`、`https`、`ssh`、`rdp`、`smb`、`tcp`、`unix`、`http_status`、`http_host`、TCP over WebSocket 等
- DNS:创建 CNAME 指向 `<tunnel-id>.cfargotunnel.com`,需开启代理
- 私有网络:CIDR route、WARP routing、virtual network
- Load Balancing:公开负载均衡与私有负载均衡
**配置**
- `config.yml` 顶层:`tunnel`、`credentials-file`、`ingress`、`warp-routing`、`originRequest`、`protocol`、`edge-ip-version`、`ha-connections`、`retries`、`grace-period`、`post-quantum`、`region`、`tag`、`logfile`、`loglevel`、`metrics`、`pidfile`、`token`、`token-file`
- `ingress` 必须末条 catch-all(如 `http_status:404`),按顺序匹配 hostname / path
- Origin 参数:TLS(`originServerName`、`matchSNItoHost`、`caPool`、`noTLSVerify`、`tlsTimeout`、`http2Origin`)、HTTP(`httpHostHeader`、`disableChunkedEncoding`)、连接(`connectTimeout`、`noHappyEyeballs`、`proxyType`、`proxyAddress`、`proxyPort`、`keepAliveTimeout`、`keepAliveConnections`、`tcpKeepAlive`)、Access(`access`)
- 校验工具:`cloudflared tunnel ingress validate`、`cloudflared tunnel ingress rule <url>`
**观测**
- Tunnel health:面板隧道健康、`cloudflared tunnel info`
- Cloudflare Notifications:隧道健康事件通知
- Logs:Cloudflare 侧日志、remote log streaming、network session logs、本机 `cloudflared` 日志
- Metrics:`/metrics`(Prometheus 格式)、`/ready`、`/healthcheck`
- Diagnostic logs:`cloudflared tunnel diag` 生成诊断文件
**运维与部署**
- Linux systemd、macOS launch agent、Windows service
- Docker、Kubernetes、Ansible、Terraform、AWS/Azure/GCP 指南
- 更新 cloudflared;多实例零停机更新
- 系统要求:出站 7844(TCP/UDP)与 443;ulimit;容量计算
- 文件权限要求:`cert.pem`、`<UUID>.json`、`config.yml`
**权限与安全**
- API Token 权限:`Account > Cloudflare Tunnel > Edit`、`Zone > DNS > Edit`(查询 Zone 还需 `Zone > Zone > Read`)
- Access、Service Auth、mTLS、Keyless SSL、Spectrum、Workers VPC、Hyperdrive 等集成
- Tunnel ownership、account-scoped roles、撤销账号证书
### 1.3 官方版本要点(2026-09)
- 运行参数文档最近更新 2026-09;`post-quantum`、`token-file`、`region`、`tag`、`dns-resolver-addrs`、`edge-bind-address` 等参数已进入官方参考。
- `edge-ip-version` 官方默认值曾由 `4` 调整为 `auto`;Fonu 已显式下发 `4`,与国内链路建议一致。
- 官方把「公开发布应用」与「Zero Trust 私有网络」拆成两条文档线,Fonu 需要明确自己的默认定位是公开发布应用。
---
## 二、参考项目功能清单(Tunnel-Cloudflared-Manager 1.0.1)
参考项目是一个独立面板,功能面比 Fonu 当前实现更宽,但缺少与 Fonu 的规则、健康、通知、状态页体系融合。
### 2.1 隧道管理
- 命名隧道:token 启动、config-file 启动
- 快速隧道:`--url` 启动、从日志解析 `*.trycloudflare.com` URL
- 启动 / 停止 / 停止全部 / 运行列表 / URL 查询
- 创建隧道(账号模式,`tunnel create`)
- 初始化 token 隧道:解码 token → 写 `creds.json` + `config.yml`
- 删除云端隧道
### 2.2 路由与云端同步
- 端点列表:读取本地 `config.yml` 的 ingress
- 新增 / 更新 / 删除端点(本地)
- 新增端点时自动调用 `tunnel route dns` 创建 DNS
- 从云端拉取 ingress 到本地
- 把本地 ingress 推送到云端
- ingress 三条不变式:catch-all、`localhost` 归一化、插入在 catch-all 之前
### 2.3 账号模式
- `cert.pem` 登录与 finalize
- 列出账号下的隧道
- 同步云端隧道
- 删除账号
### 2.4 二进制与镜像
- 检查 / 下载 / 替换 cloudflared
- 下载进度
- 镜像管理:列出、新增、更新、删除、测试
- 内置官方与多个 gh-proxy 镜像
### 2.5 指标与日志
- 每 2 秒抓取 `/metrics`
- QUIC:`quic_client_receive_bytes`、`quic_client_sent_bytes`、`quic_client_smoothed_rtt`
- HTTP/2:RPC latency、主动 HEAD 探测;带宽用进程 socket 统计(Windows 需要管理员)
- 连接数:`cloudflared_tunnel_ha_connections`
- 实时日志:WebSocket 推送
- 清空日志
### 2.6 网络与调优
- `protocol`(auto/http2/quic)、代理环境探测与回落
- `edge-ip-version`、`ha-connections`
- `originRequest`:`caPool`、`noTLSVerify`、`httpHostHeader`
- 网络诊断接口
- Windows 子进程隐藏控制台
### 2.7 运维
- 版本检查与应用自更新
- JWT + bcrypt 登录、失败锁定
- 多语言、主题
- Docker 与 Linux 安装脚本
---
## 三、Fonu 当前实现(v1.8.0)
| 能力 | 状态 |
| --- | --- |
| Account ID + API Token 凭据 | ✅ 已实现 |
| API Token 验证(`/accounts/{id}/tokens/verify`) | ✅ 已实现 |
| 创建 / 删除云端隧道 | ✅ 已实现 |
| 获取隧道 token、写 `creds.json` / `config.yml` | ✅ 已实现 |
| 推送 ingress 到云端 | ✅ 已实现 |
| 自动创建 CNAME DNS | ✅ 已实现 |
| 二进制下载与版本检查 | ✅ 已实现(多镜像、大小与可执行校验) |
| 进程托管、启停、重启、日志文件 | ✅ 已实现 |
| metrics 端口发现、`/ready`、`ha_connections`、QUIC 带宽/RTT | ✅ 已实现(基础) |
| 全局托管开关 | ✅ 已实现 |
| auto_start | ✅ 已实现 |
| 前端页面与弹窗 | ✅ 已实现 |
| 云端隧道列表同步 / 导入已有隧道 | ❌ 未实现 |
| 端点级 CRUD(path / 协议 / 多服务) | ❌ 未实现 |
| 从云端拉取 ingress(pull) | ❌ 未实现 |
| 多副本 / 零停机更新 | ❌ 未实现 |
| 快速隧道 | ❌ 未实现 |
| 账号模式(cert.pem) | ❌ 未实现 |
| 实时日志流(WebSocket/SSE) | ❌ 未实现 |
| 诊断日志、网络预检、隧道 info | ❌ 未实现 |
| 接入 Fonu monitor / 通知 / 状态页 | ❌ 未实现 |
| cloudflared 自动更新 | ❌ 未实现 |
| 自定义镜像管理、下载进度 | ❌ 未实现 |
| Origin 高级参数 UI | ❌ 未实现 |
| 令牌轮换、权限审计 | ❌ 未实现 |
| 私有网络 / WARP routing / Access / 负载均衡 | ❌ 未实现 |
| 边缘 IP 优选、post-quantum、region/tag | ❌ 未实现 |
---
## 四、差距与机会
### 4.1 必须补齐(否则模块不完整)
1. **云端隧道列表与导入**:目前 Fonu 只能管理自己创建的隧道,已有隧道无法纳管。
2. **端点级 CRUD**:现在是整体覆盖 ingress,无法单独增删改一条 hostname/path。
3. **云端配置拉取与漂移检测**:云端被手动修改后,Fonu 无法发现。
4. **健康接入 Fonu 体系**:隧道状态没有进入 monitor、通知、状态页、可用率。
5. **实时日志**:轮询文件在故障排查时体验差。
6. **诊断能力**:缺 `tunnel info`、`tunnel diag`、网络预检、端口要求检查。
### 4.2 高价值增强
1. 快速隧道(临时演示、开发调试)
2. 多副本与零停机更新
3. cloudflared 自动更新与版本策略
4. 令牌轮换
5. Origin 高级参数(HTTPS 回源、自签证书、Host 头)
6. 自定义镜像与下载进度
7. 审计日志(谁在何时修改了隧道、端点、DNS)
### 4.3 远期能力
1. 账号模式(cert.pem)与多账号管理
2. 私有网络 / WARP routing / virtual network
3. Cloudflare Access / Service Auth 集成
4. 负载均衡集成
5. 边缘 IP 优选
6. Workers VPC / Hyperdrive / Spectrum 等生态集成
---
## 五、全量功能规划
### P0:模块完整性(先补齐)
**P0-1 云端隧道同步与导入**
- 目标:列出 Cloudflare 账号下的全部隧道,把未纳管的隧道导入 Fonu。
- 实现:`GET /accounts/{id}/cfd_tunnel` 列表;导入时读取 token、写 `creds.json`/`config.yml`、落库。
- 验收:账号下已有隧道能在页面看到并导入;重复导入不产生重复记录。
**P0-2 端点级 CRUD 与 ingress 校验**
- 目标:不再整体覆盖 ingress,支持单条 hostname/path/service 的增删改。
- 实现:本地 `config.yml` 解析与增量修改;云端 `PUT configurations` 同步;`cloudflared tunnel ingress validate` 与 `ingress rule <url>` 自检。
- 验收:新增、修改、删除单个端点后本地与云端一致;非法规则被拒绝。
**P0-3 云端配置拉取与漂移检测**
- 目标:云端被手动修改后,Fonu 能发现差异并选择拉取或覆盖。
- 实现:`GET /accounts/{id}/cfd_tunnel/{tunnel}/configurations`;与本地 config 做 diff;提供「拉取到本地」「推送到云端」。
- 验收:diff 可见;两个方向都能执行并留痕。
**P0-4 健康接入 Fonu 体系**
- 目标:隧道断连、恢复进入现有 monitor、通知、状态页与可用率。
- 实现:新增 cloudflared 的 `monitor.Source`;`/ready` + `ha_connections` 判活;断连/恢复事件复用 notify;状态页增加隧道条目。
- 验收:停掉隧道后产生告警;恢复后产生恢复通知;状态页可见。
**P0-5 实时日志(SSE)**
- 目标:日志页实时滚动,替代轮询。
- 实现:Fonu 现有日志 SSE 模式复用;cloudflared 日志文件 tail + 增量推送;保留轮询回退。
- 验收:页面实时看到新日志;切换页面/重连不丢关键行。
**P0-6 诊断能力**
- 目标:一键完成常见故障排查。
- 实现:`cloudflared tunnel info <name>`、`cloudflared tunnel diag`、`cloudflared tunnel ingress validate`、网络预检(region1/region2、7844、443)、端口与系统要求检查。
- 验收:诊断页给出结构化结果与建议,不只是原始输出。
### P1:运维与安全
**P1-1 快速隧道**
- 目标:临时把本地端口发布到 `trycloudflare.com`,用于演示与开发。
- 实现:`--url` 启动、日志解析公共 URL、进程随 Fonu 生命周期、默认关闭。
- 边界:明确标注不保证 SLA、URL 每次变化。
**P1-2 多副本与零停机更新**
- 目标:同一隧道支持多个 connector,更新时不中断。
- 实现:replica 配置与进程管理;逐个重启;`tunnel info` 确认新连接注册后再停旧实例。
- 验收:更新过程中隧道可用率不下降。
**P1-3 cloudflared 自动更新与版本策略**
- 目标:可检查新版本、按策略升级、支持回滚。
- 实现:官方 Release 检查;保留旧版本目录;升级前校验;失败回滚。
- 验收:升级成功可回滚;版本信息可追溯。
**P1-4 令牌轮换**
- 目标:按官方 `tunnel token` 支持重新签发 token。
- 实现:`GET /accounts/{id}/cfd_tunnel/{tunnel}/token`;轮换后更新 `creds.json` 并重启连接器;双凭据过渡窗口。
- 验收:轮换过程不中断或只短暂闪断。
**P1-5 Origin 高级参数**
- 目标:支持 HTTPS 回源、自签证书、Host 头等官方 origin 参数。
- 实现:`originRequest` 的 `originServerName`、`matchSNItoHost`、`caPool`、`noTLSVerify`、`httpHostHeader`、`connectTimeout`、`keepAliveTimeout` 等。
- 验收:HTTPS 回源与自签证书场景可用;参数写入本地与云端配置。
**P1-6 审计日志**
- 目标:记录谁在何时修改了隧道、端点、DNS、令牌与全局开关。
- 实现:复用 Fonu 日志/审计基础设施;操作前记录旧值、操作后记录新值。
- 验收:关键变更可查询、可导出。
### P2:体验与增强
**P2-1 自定义镜像与下载进度**
- 目标:镜像可增删改测,下载有进度。
- 实现:镜像表(URL 模板)+ 连通性测试;SSE 推送下载进度。
**P2-2 指标图表与历史**
- 目标:展示隧道延迟、带宽、连接数、可用率历史。
- 实现:复用 `metrics_samples`;前端图表;区分 QUIC/HTTP2 可用指标。
**P2-3 配置版本、diff 与回滚**
- 目标:隧道 `config.yml` 与云端 ingress 的版本化。
- 实现:复用 `fsutil.BackupVersioned`;变更前留版本;支持 diff 与回滚。
**P2-4 日志导出与清理**
- 目标:导出、清理单隧道日志;限制日志体积。
- 实现:日志文件轮转、下载、清空;保留期设置。
**P2-5 系统要求与容量检查**
- 目标:按官方要求检查 ulimit、出站 7844/443、系统资源。
- 实现:启动前检查与页面提示;不做自动修改系统参数。
**P2-6 网络调优扩展**
- 目标:暴露官方 run parameters。
- 实现:`post-quantum`、`region`、`tag`、`dns-resolver-addrs`、`edge-bind-address`、`retries`、`grace-period`、`loglevel`、`logfile`、`pidfile`。
### P3:平台化
**P3-1 账号模式(cert.pem)与多账号管理**
- 支持 `cloudflared tunnel login`、cert.pem 导入、多账号切换、账号下隧道同步。
**P3-2 私有网络(WARP routing / CIDR / virtual network)**
- 支持 `warp-routing: enabled: true`、CIDR route、virtual network、私有主机名路由。
**P3-3 Access / Service Auth**
- 只做诊断与提示,或在用户显式配置后生成 Access 应用;不代配高风险策略。
**P3-4 负载均衡集成**
- 公开/私有负载均衡池与隧道关联;只做只读展示与跳转。
**P3-5 边缘 IP 优选**
- 探测官方 IPv4 段的 TCP 7844 延迟,生成两个 region 的优选建议;页面展示,不代写 hosts。
**P3-6 生态集成**
- Workers VPC、Hyperdrive、Spectrum、Keyless SSL、Data Localization 等按需评估。
---
## 六、推荐实施顺序
| 阶段 | 内容 | 预计 | 完成标志 |
| --- | --- | --- | --- |
| 阶段 1 | P0-1 ~ P0-6 | 1-2 周 | 云端同步、端点 CRUD、漂移检测、健康接入、实时日志、诊断全部可用 |
| 阶段 2 | P1-1 ~ P1-6 | 2-3 周 | 快速隧道、多副本、自动更新、令牌轮换、Origin 高级参数、审计 |
| 阶段 3 | P2-1 ~ P2-6 | 2 周 | 镜像、图表、版本回滚、日志治理、系统检查、网络调优扩展 |
| 阶段 4 | P3-1 ~ P3-6 | 按需 | 账号模式、私有网络、Access、负载均衡、边缘优选、生态集成 |
建议先做阶段 1,因为它直接补齐「已经有隧道但无法纳管」「云端被改无法发现」「故障无法定位」这三类现实问题。
---
## 七、数据模型与 API 变更
### 7.1 数据模型
- `cf_tunnels`:增加 `source`(created/imported)、`cloud_status`、`connector_count`、`last_sync_at`。
- 新增 `cf_tunnel_endpoints`:`tunnel_id`、`hostname`、`path`、`service`、`protocol`、`origin_json`。
- 新增 `cf_tunnel_events`:健康事件、审计事件、同步事件。
- `cf_settings`:扩展 `enabled`、`auto_update`、`update_channel`、`mirror`。
### 7.2 API
- `GET /api/cloudflare/cloud-tunnels`:列出云端隧道
- `POST /api/cloudflare/cloud-tunnels/{id}/import`:导入
- `GET/POST/PUT/DELETE /api/cloudflare/tunnels/{id}/endpoints`:端点 CRUD
- `POST /api/cloudflare/tunnels/{id}/pull`:从云端拉取
- `GET /api/cloudflare/tunnels/{id}/drift`:漂移检测
- `GET /api/cloudflare/tunnels/{id}/logs/stream`:实时日志
- `GET /api/cloudflare/tunnels/{id}/diagnostics`:诊断
- `POST /api/cloudflare/tunnels/{id}/token/rotate`:令牌轮换
- `GET/POST /api/cloudflare/mirrors`:镜像管理
### 7.3 前端
- 隧道详情增加端点列表、diff、诊断、实时日志、指标图表。
- 新增「云端隧道」页签或导入入口。
- 应用配置增加自动更新策略、镜像管理、系统要求检查结果。
---
## 八、测试与验收
**单元测试**
- ingress 增删改与三条不变式
- 云端配置 diff
- token 解码、轮换
- metrics 解析与降级
- 诊断结果解析
**集成测试**
- 创建 → 启动 → 健康 → 停止 → 删除全流程
- 云端修改后用 diff 发现并拉取
- 多副本零停机更新
- 自动 DNS 创建/更新
**前端**
- 端点编辑、diff 视图、诊断面板、实时日志
**真机矩阵**
- Windows、Linux、Docker 容器内、host 网络
- 代理环境、QUIC 被拦、7443/7844 被占用、无 IPv6
- Cloudflare Access 与无 Access
---
## 九、风险与边界
- **官方接口演进**:Cloudflare API 与 cloudflared 参数会变;所有参数需要可配置 + 默认值 + 可恢复默认。
- **权限边界**:API Token 权限不足时只提示,不自动扩权。
- **网络边界**:只做本机到 CF 边缘的调优;访客侧不可控。
- **合规边界**:大文件/视频分发不在推荐用法内。
- **安全边界**:不代配 Access 高风险策略;不写 hosts;不改系统 ulimit/防火墙。
- **运行边界**:Fonu 只托管自己启动的 cloudflared 进程;不接管外部 systemd 服务。
- **数据边界**:凭据加密存储;预览脱敏;审计日志不记录明文 token。
---
## 十、结论
Fonu 的 Cloudflare 隧道模块已经具备「创建、运行、托管、DNS」的闭环。下一阶段的目标是把它从「能用」推进到「可长期运维」:
1. 先补齐云端同步、端点 CRUD、漂移检测、健康接入、实时日志、诊断(阶段 1)。
2. 再补快速隧道、多副本、自动更新、令牌轮换、Origin 高级参数、审计(阶段 2)。
3. 最后做镜像、图表、版本回滚、日志治理、系统检查与网络调优扩展(阶段 3)。
4. 私有网络、Access、负载均衡、边缘优选等平台化能力按需推进(阶段 4)。
这样 Fonu 才能在官方能力、参考项目功能与自身产品体系之间取得平衡,而不是停留在「进程开关 + 配置预览」。
+370
View File
@@ -0,0 +1,370 @@
# Cloudflare 隧道:Fonu 适配版功能计划
> 日期:2026-09-30
> 定位:`cloudflared-feature-plan.md` 是「官方 + 参考项目的功能全集」;本文是**按 Fonu 产品定位筛选后的适配版计划**,实施以本文为准。
> 核心原则:Fonu 不是 cloudflared 管理面板,而是「NAS / 家庭服务器的公网访问管理面板」。Cloudflare 隧道只是它的第二条出口。
---
## 一、先明确 Fonu 是什么
### 1.1 产品定位
Fonu 面对的是家庭 NAS、小主机、家用服务器的用户,核心价值是:
- 用域名访问内网服务,而不是记 IP 和端口;
- 自动处理证书、反代、穿透、DDNS;
- 尽量少配置、少暴露、少维护。
Fonu 已经有三条主线:
1. **反向代理**:域名 → 本机 Nginx → 内网服务;
2. **FRP 穿透**:本机 frpc → VPS frps → 域名;
3. **公网 Agent**:管理 VPS 上的 frps 与 Nginx。
Cloudflare 隧道的加入,是为了解决一类现有链路解决不了的人:
> 没有公网 IP、也不想长期租 VPS,但想让内网 Web 服务通过域名安全访问。
### 1.2 因此有三条不可动摇的原则
1. **CF 隧道是第二条出口,不是替代 FRP**:TCP/UDP、自主可控、大流量仍走 FRP。
2. **规则同源**:用户维护的是「服务发布规则」,不是「隧道 ingress」。CF 只是一个出口选项。
3. **融入 Fonu 体系**:监控、通知、状态页、日志、备份、升级、审计都复用现有模块,不另造一套面板。
---
## 二、目标用户与场景
### 2.1 适合
- 家宽没有公网 IPv4,或运营商封 80/443;
- 有域名托管在 Cloudflare,或愿意把域名 NS 切到 Cloudflare;
- 主要暴露 HTTP/HTTPS 服务:NAS 面板、PhotoPrism、Jellyfin 管理页、Home Assistant、qBittorrent WebUI、OpenWrt 面板等;
- 希望「一次配置好,之后自动跟随反代规则」。
### 2.2 不适合
- 需要裸 TCP/UDP 端口(游戏服、非 HTTP 协议);
- 需要完全自主可控、流量不经过第三方;
- 大文件/视频持续分发(Cloudflare 非 Enterprise 条款限制);
- 企业级 Zero Trust 私有网络、VPN 替代。
---
## 三、借鉴与不借鉴
参考项目 `Tunnel-Cloudflared-Manager 1.0.1` 是一个独立的 cloudflared 面板。以下判断按「是否符合 Fonu 产品形态」给出:
### 3.1 值得借鉴
| 参考项目做法 | Fonu 该怎么用 |
| --- | --- |
| token 解码成本地凭据 | 已用 Account ID + API Token 替代;保留解码能力用于导入 |
| metrics 端口从日志发现 | 已实现,继续沿用 |
| ingress 三条不变式 | 已实现,继续沿用 |
| 云端 / 本地配置互相同步 | 值得做,但要简化为「diff → 拉取或覆盖」 |
| 端点级增删改 | 值得做,但必须挂在 Fonu 的「服务发布规则」下 |
| 快速隧道 | 只做「临时演示」,不做正式功能 |
| 网络诊断 | 值得做,做成 Fonu 的一键诊断 |
| 镜像与下载进度 | 值得做,但复用 Fonu 现有镜像表,不做用户自定义镜像管理 |
| Windows 隐藏控制台 | 已实现,继续沿用 |
### 3.2 不借鉴
| 参考项目做法 | 不借鉴的理由 |
| --- | --- |
| 自带 JWT + 账号体系 | Fonu 已有会话 + API Token,重复建设 |
| 独立主题、多语言 | 与 Fonu 全局设置重复 |
| 多账号 cert.pem 登录 | 与 Fonu 的 Account ID + API Token 模型冲突;远期若有需求再评估 |
| 私有网络 / WARP routing / virtual network | 面向企业 Zero Trust,NAS 用户不需要 |
| Kubernetes / Ansible / Terraform / 云厂商部署指南 | 不是 Fonu 的交付形态 |
| 负载均衡 | 家用单机场景不需要 |
| Workers VPC / Hyperdrive / Spectrum / Keyless SSL | 超出 Fonu 定位 |
| WebSocket 实时日志 | Fonu 已有 SSE 日志模式,不为 CF 单独引入 WSS |
| 自定义镜像管理(增删改测) | Fonu 只需内置镜像表 + 自定义 URL,不需要用户维护镜像源 |
| 独立 systemd / Windows service 安装 | Fonu 自己托管进程,不接管外部服务 |
| 快速隧道作为正式功能 | 官方明确不保证 SLA,只做演示入口 |
---
## 四、符合 Fonu 的功能全景
按「用户价值」而不是「参考项目有什么」重新分组。
### 4.1 出口抽象(Fonu 的核心差异化)
**F-1 服务发布规则增加「出口」字段**
- 一条规则描述「域名 → 内网服务」,出口可选:本机 Nginx / FRP / Cloudflare 隧道。
- 现有反向代理规则作为基础,新增出口选择,不新建第二套规则模型。
- 验收:同一条规则可以切换出口,切换后域名与服务保持不变。
**F-2 CF 隧道 ingress 自动跟随反代规则**
- 反代规则新增/修改/删除域名时,自动同步到该隧道的 ingress。
- 只同步 HTTP/HTTPS 规则;TCP/UDP 规则在出口选择里标为「Cloudflare 不支持」。
- 验收:改一条反代规则,CF 隧道配置与云端 ingress 自动更新,无需手工编辑。
**F-3 一条规则可以同时走 FRP 与 CF**
- 出口支持多选(FRP + Cloudflare),用于灰度或双保险。
- 验收:同一域名可同时存在 FRP 与 CF 入口,互不影响。
### 4.2 自动化(延续 Fonu「自动为主」)
**F-4 一次配置,后续全自动**(大部分已完成)
- Account ID + API Token 一次保存;
- 自动创建隧道、写 `creds.json` / `config.yml`、推送 ingress、创建 CNAME;
- 自动启动、自动重连、崩溃重启。
**F-5 反代规则变化自动同步**
- 规则增删改 → 生成新 ingress → 本地与云端同步 → 需要时重载配置。
- 验收:用户不需要理解 ingress,也不需要手工点同步。
**F-6 自动 DNS 管理**
- 创建域名时自动 CNAME 到 `<tunnel-id>.cfargotunnel.com`;
- 删除域名或隧道时,删除「指向该隧道」的 DNS 记录(只删自己创建的)。
- 验收:域名增删与 DNS 一致,不误删用户其他记录。
**F-7 自动健康与自愈**
- 进程退出自动重启;
- `/ready` 与 `ha_connections` 判活;
- 连续失败进入 Fonu 告警;恢复后发通知。
### 4.3 融入 Fonu 现有体系
**F-8 监控与可用率**
- 新增 cloudflared 的 `monitor.Source`;
- 隧道健康进入 Fonu 现有可用率与历史指标。
**F-9 通知**
- 隧道断连、恢复、证书/DNS 失败复用 `notify`;
- 通知文案面向 NAS 用户,而不是 cloudflared 术语。
**F-10 状态页**
- 公开状态页增加 CF 隧道域名与可用率;
- 只展示域名与状态,不泄露隧道 ID、本机地址。
**F-11 日志中心**
- CF 隧道日志进入 Fonu 日志页;
- 复用 SSE 增量推送,不单独做 WebSocket。
**F-12 仪表盘**
- 仪表盘增加「Cloudflare 隧道」卡片:隧道数、在线数、最近错误。
**F-13 备份与迁移**
- `cf_tunnels` 与凭据纳入 Fonu 备份/恢复流程;
- 恢复后自动重启隧道。
**F-14 一键升级与版本策略**
- cloudflared 版本跟随 Fonu 升级流程;
- 提供「检查更新 / 更新 cloudflared / 回滚」;
- 不要求用户手工替换二进制。
**F-15 审计**
- 记录谁在何时修改了服务发布规则、出口、CF 凭据、全局开关;
- 复用 Fonu 的日志/审计基础设施。
### 4.4 NAS 场景体验
**F-16 Docker 服务发现一键发布**
- 复用现有 `discovery`,发现本机 Docker 服务;
- 选择服务 → 填域名 → 自动创建反代规则 + CF 出口。
**F-17 常用 NAS 应用预设**
- NAS 面板、PhotoPrism、Jellyfin、Home Assistant、qBittorrent 等端口预设;
- 用户只需选应用与域名。
**F-18 域名与 Zone 选择器**
- 通过 API Token 列出账号下的 Zone 与可用域名;
- 用户从列表选择,而不是手输。
**F-19 证书提示**
- 使用 CF 出口时,CF 边缘自动提供 HTTPS;
- 页面提示「该域名无需在 Fonu 申请证书」,避免用户重复操作。
### 4.5 运维与安全
**F-20 端点级编辑**
- 在 Fonu 规则详情里编辑 hostname、path、service、协议;
- 提供 `ingress validate` 与 `ingress rule <url>` 校验。
**F-21 云端漂移检测**
- 定期比较本地 ingress 与云端配置;
- 发现漂移后提示「以本地为准覆盖」或「以云端为准拉取」。
**F-22 一键诊断**
- `tunnel info`、`tunnel diag`、网络预检(region1/region2、7844、443)、系统要求检查;
- 结果转成 Fonu 风格的可读结论与建议。
**F-23 令牌轮换**
- 提供「重新签发隧道凭据」入口;
- 轮换过程尽量不中断,失败自动回滚。
**F-24 全局托管开关**(已完成)
- 一键停止全部隧道,重启不自动拉起;
- 开启时只恢复 auto_start 的隧道。
**F-25 条款与用量提示**
- 页面常驻说明:不适合大文件/视频分发;
- 不采集、不上报用户流量到外部。
**F-26 Access 保护状态检测(只读)**
- 检测域名是否启用 Cloudflare Access;
- 只提示,不代配策略。
### 4.6 可选增强
**F-27 快速隧道(临时演示)**
- 临时把某个本地端口发到 `trycloudflare.com`;
- 明确标注不保证 SLA、URL 每次变化、仅用于测试。
**F-28 多副本**
- 高级选项,用于「主备两台设备跑同一条隧道」;
- 默认关闭,不打扰普通用户。
**F-29 边缘 IP 优选**
- 只影响「本机 → CF 边缘」一段;
- 页面展示优选建议,不代写 hosts;
- 面向国内链路质量差的用户。
**F-30 账号模式(cert.pem)**
- 仅当用户明确需要「多账号 / 团队权限」时提供;
- 默认仍推荐 Account ID + API Token。
---
## 五、明确不做的功能
| 功能 | 结论 | 理由 |
| --- | --- | --- |
| 独立登录、JWT、多用户体系 | 不做 | Fonu 已有认证与 API Token |
| 独立主题、多语言 | 不做 | 属于 Fonu 全局设置 |
| 私有网络 / WARP routing | 不做(远期评估) | 面向 Zero Trust 企业,NAS 用户不需要 |
| 负载均衡 | 不做 | 家用单机场景不需要 |
| Kubernetes / Ansible / Terraform 指南 | 不做 | 不是 Fonu 交付形态 |
| Workers VPC / Hyperdrive / Spectrum | 不做 | 超范围 |
| WebSocket 实时日志 | 不做 | 复用 Fonu SSE |
| 自定义镜像管理 | 不做 | 内置镜像表 + 自定义 URL 足够 |
| 接管外部 systemd / Windows service | 不做 | Fonu 只托管自己启动的进程 |
| 快速隧道作为正式功能 | 不做 | 官方不保证 SLA,只做演示 |
| 代配 Access 策略 | 不做 | 高风险,只做只读检测 |
| 自动扩权 / 自动改系统防火墙 | 不做 | 保持 Fonu 现有安全边界 |
---
## 六、与现有模块的整合点
| Fonu 模块 | 整合方式 |
| --- | --- |
| 反向代理规则 | 增加「出口」字段;CF 隧道从规则派生 |
| FRP 多服务端 | 与 CF 出口并列,互不影响 |
| 证书管理 | CF 出口域名提示无需本地证书 |
| DDNS | CF 橙色云代理后本地 IP 变化不再影响访问,可提示用户 |
| 监控 / 可用率 | 新增 cloudflared Source |
| 通知 | 断连、恢复、DNS 失败复用 |
| 公开状态页 | 增加 CF 隧道条目 |
| 日志中心 | CF 日志进入现有日志页 |
| 仪表盘 | 新增 CF 隧道卡片 |
| Docker 服务发现 | 一键发布到 CF |
| 备份 / 恢复 | cf_tunnels 与凭据纳入 |
| 一键升级 | cloudflared 版本更新纳入 |
| 审计 | 规则、出口、凭据变更记录 |
---
## 七、数据模型与 API 变更
### 7.1 数据模型
- `frp_proxies`(或服务发布规则表)增加 `exit` 字段:`local` / `frp` / `cloudflare` / 多选;
- `cf_tunnels` 增加 `rule_id`(可选,关联服务发布规则)、`source`、`last_sync_at`;
- 新增 `cf_endpoint_drift` 或复用现有漂移检测表;
- `cf_settings` 增加 `auto_update`、`update_channel`。
### 7.2 API
- `PUT /api/proxies/{id}/exit`:切换出口;
- `GET /api/cloudflare/tunnels/{id}/drift`:漂移检测;
- `POST /api/cloudflare/tunnels/{id}/pull`:从云端拉取;
- `POST /api/cloudflare/tunnels/{id}/diagnostics`:一键诊断;
- `GET /api/cloudflare/zones`:Zone / 域名列表;
- `POST /api/cloudflare/tunnels/{id}/token/rotate`:令牌轮换;
- `GET /api/cloudflare/tunnels/{id}/logs/stream`:SSE 日志;
- `GET /api/discovery/docker`:Docker 服务发现(若尚未提供)。
### 7.3 前端
- 反向代理规则表单增加「出口」选择器;
- 规则详情增加「Cloudflare 隧道」状态与操作;
- CF 页面聚焦「隧道状态 / 诊断 / 日志 / 全局开关」,弱化「手工建隧道」;
- 仪表盘、状态页、日志中心增加 CF 条目。
---
## 八、阶段计划
| 阶段 | 内容 | 目标 |
| --- | --- | --- |
| 阶段 1 | F-1 ~ F-3 出口抽象;F-5 规则同步;F-6 DNS 管理完善 | 让 CF 成为反代规则的一个出口,而不是独立模块 |
| 阶段 2 | F-8 ~ F-15 体系整合;F-20 ~ F-22 编辑与诊断 | 让 CF 隧道进入 Fonu 监控、通知、状态页、日志、备份、审计 |
| 阶段 3 | F-16 ~ F-19 NAS 体验;F-23 ~ F-26 运维安全 | 一键发布、域名选择、令牌轮换、条款提示、Access 检测 |
| 阶段 4 | F-27 ~ F-30 可选增强 | 快速隧道、多副本、边缘优选、账号模式按需 |
**优先级建议**:阶段 1 是产品形态的关键,先做;阶段 2 决定「能不能长期运维」;阶段 3 决定「好不好用」;阶段 4 是加分项。
---
## 九、验收标准
- 用户只维护「域名 → 服务」的规则,出口切换不需要改域名或端口;
- 反代规则变化后,CF 隧道与 DNS 自动跟随;
- 隧道断连能进 Fonu 告警与状态页;
- 一条规则可以同时走 FRP 与 CF,互不影响;
- CF 出口域名不需要本地证书;
- 备份恢复后隧道能自动恢复;
- 所有 CF 凭据加密存储、预览脱敏;
- 明确不做的功能不会出现在界面里,避免用户误解。
---
## 十、结论
Fonu 需要的不是「另一个 cloudflared 面板」,而是:
> 把 Cloudflare 隧道做成反向代理规则的一个出口选项,并让监控、通知、状态页、日志、备份、升级、审计自动覆盖它。
按本文阶段 1-3 实施,Fonu 的 CF 模块会形成与参考项目不同的产品形态:参考项目面向「管理 cloudflared」,Fonu 面向「管理我的服务怎么上公网」。
本文与 `cloudflared-feature-plan.md` 的关系:
- `cloudflared-feature-plan.md`:官方 + 参考项目的功能全集,用于查漏;
- 本文:按 Fonu 定位筛选后的实施计划,**实施以本文为准**。
+576
View File
@@ -0,0 +1,576 @@
# Cloudflare 隧道同类项目调研与 Fonu 体验优化建议
> 日期:2026-09-30
> 调研范围:GitHub 上 Cloudflare Tunnel / cloudflared 管理面板、反向代理管理器、自托管访问平台、状态监测与流量检查器。
> 目标:不是照搬功能,而是提取适合 Fonu 的自动化方式、页面结构和长期运维体验。
---
## 一、调研结论摘要
同类项目大致分成四类:
1. **cloudflared 单容器 Web UI**
- 代表:Cloudflared-web
- 重点:快速设置 Token、启停隧道、查看运行状态。
2. **Cloudflare 路由与 Access 自动化平台**
- 代表:DockFlare
- 重点:Docker 标签、期望状态调和、DNS、ingress、Access 策略统一管理。
3. **Homelab 反向代理 / 私有访问平台**
- 代表:Nginx Proxy Manager、Pangolin、Cosmos Server
- 重点:可视化路由、证书、访问控制、资源入口页、审计。
4. **监控、状态页与流量调试**
- 代表:Uptime Kuma、Tunnel Inspector
- 重点:可用率、状态页、事故记录、请求级观测、重放和抓包体验。
对 Fonu 最重要的启发不是“再加多少按钮”,而是:
- 用期望状态和自动调和代替手工点同步。
- 让规则、隧道、DNS、证书、监控在同一套模型中管理。
- 配置页要面向“服务发布”,而不是面向 cloudflared 参数。
- 把诊断、修复、日志和状态页做成闭环。
- 高级能力可以保留,但默认路径必须非常短。
---
## 二、代表性项目
以下 Star 数据为 2026-09-30 GitHub 查询结果,仅作为热度参考。
| 项目 | 类型 | 技术栈 | Star | 最值得借鉴 |
| --- | --- | --- | ---: | --- |
| [cloudflare/cloudflared](https://github.com/cloudflare/cloudflared) | 官方客户端 | Go | 15970 | 官方能力边界、TryCloudflare、版本支持策略 |
| [DockFlare](https://github.com/ChrispyBacon-dev/DockFlare) | Tunnel + Access 自动化 | Python | 2471 | Docker 标签、期望状态调和、多主 Agent、备份恢复 |
| [Nginx Proxy Manager](https://github.com/NginxProxyManager/nginx-proxy-manager) | 反向代理面板 | JavaScript | 高热度 | Host / Streams / Access Lists / SSL / 用户与审计 |
| [cloudflare-tunnel-ingress-controller](https://github.com/STRRL/cloudflare-tunnel-ingress-controller) | Tunnel Ingress Controller | Go | 1197 | 声明式路由、Ingress 到 Tunnel/DNS 自动生成 |
| [cftunnel](https://github.com/qingchencloud/cftunnel) | 全协议穿透 CLI | Go | 345 | 快速隧道、Cloud + FRP 双引擎、模板、分享、自动更新 |
| [Cloudflared-web](https://github.com/WisdomSky/Cloudflared-web) | 极简 Web UI | Vue | 465 | 单容器安装、Token 管理、启停、低学习成本 |
| [Tunnel Cloudflared Desktop](https://github.com/maskodingku/Tunnel-Cloudflared-Desktop) | 桌面管理器 | Tauri + Rust | 55 | 多账号、`cert.pem`、双向同步、实时指标、Quick Tunnel |
| [Tunnel Inspector](https://github.com/BlockbusterAndy/cloudflare-tunnel-inspector) | 流量检查器 | TypeScript | 6 | 请求/响应捕获、SSE 实时流、搜索筛选、重放、敏感头脱敏 |
| [Pangolin](https://github.com/fosrl/pangolin) | 自托管访问平台 | Go / Web | 高热度 | 资源入口页、统一身份、按资源授权、浏览器访问 SSH/RDP/VNC |
| [Uptime Kuma](https://github.com/louislam/uptime-kuma) | 监控与状态页 | Vue / Node | 高热度 | 多状态页、事故记录、证书信息、通知渠道、2FA |
---
## 三、项目逐个分析
### 3.1 Cloudflared-web
项目定位:
- 一个 Docker 镜像同时打包 cloudflared 和简单 Web UI。
- 主要面向“只想管理 Token 和启停隧道”的用户。
核心体验:
- Docker 启动后直接打开固定端口网页。
- 填写 Token,一键启停。
- 支持 BASIC AUTH。
- 支持环境变量配置协议、边缘 IP、metrics、重试等。
- 数据持久化到 `/config`。
对 Fonu 的启示:
- 初次配置应该更短:Account ID、API Token、Zone、Tunnel 四步内完成。
- 所有高级网络参数应该折叠到“高级选项”,默认值直接可用。
- 当前 Fonu 已覆盖它的主要能力,并且多了 API 托管、DNS、规则同步和监控。
不建议照搬:
- 只有 Token 和启停,模型太薄。
- 不适合 Fonu 的“服务发布规则”产品定位。
### 3.2 DockFlare
项目定位:
- 面向多变自托管环境的 Cloudflare Tunnel / DNS / Access 控制平面。
- 强调“定义一次意图,然后持续调和”。
核心能力:
- Docker 标签自动发现服务。
- Web UI 手工规则。
- 远程 Agent 多主机管理。
- Tunnel ingress、DNS、Access 统一调和。
- Access group 和可复用策略。
- 加密配置、运行时状态和数据的备份恢复。
- 多语言 UI。
对 Fonu 的启示:
- 当前 Fonu 已经有“反向代理规则派生 Cloudflare ingress”,方向正确。
- 下一步应补“期望状态”和“调和结果”:
- 本地期望配置。
- Cloudflare 实际配置。
- 最后同步时间。
- 漂移原因。
- 一键修复。
- Docker 标签自动发布非常适合 NAS。
- 多主机 Agent 适合进阶用户,但不应优先于单机体验。
- Access 自动化风险较高,Fonu 暂时保持只读更稳。
### 3.3 Tunnel Ingress Controller
项目定位:
- 把 Kubernetes Ingress 转成 Cloudflare Tunnel 和 DNS 配置。
核心思想:
- 用户只维护 Kubernetes 原生声明。
- Controller 持续把声明同步到 Cloudflare。
- 不要求用户手工编辑 cloudflared ingress。
对 Fonu 的启示:
- Fonu 的“反向代理规则就是服务入口声明”是同一思路的非 K8s 版本。
- 无需引入 K8s,但应借鉴:
- 声明与运行状态分离。
- 调和结果可见。
- 失败原因可追踪。
- 不应让用户理解底层 ingress 文件。
### 3.4 Tunnel Cloudflared Desktop
项目定位:
- 面向桌面用户的 Cloudflare Tunnel 管理器。
核心能力:
- Quick Tunnel。
- 多账号 `cert.pem`。
- 本地与 Cloudflare Dashboard 双向同步。
- 实时指标。
- 实时日志。
- 自动下载 cloudflared。
对 Fonu 的启示:
- Quick Tunnel 适合“临时演示”,不建议作为正式发布方式。
- 双向同步是 Fonu 当前明显缺口:
- 当前支持检测漂移并“本地覆盖云端”。
- 缺少“云端覆盖本地”。
- 多账号模式对 Fonu 属于进阶能力,可放到远期。
- 实时日志和指标已具备,继续加强即可。
### 3.5 cftunnel
项目定位:
- 全协议内网穿透 CLI。
- Cloud 模式使用 Cloudflare Tunnel。
- Relay 模式使用自建 frps。
核心体验:
- `quick <port>` 生成临时公网地址。
- 可加 Basic Auth。
- 自动 DNS。
- 预设模板。
- 一键分享二维码、地址、Telegram。
- 自动更新。
对 Fonu 的启示:
- “快速隧道”可以作为临时验证入口,但必须标明不保证 SLA。
- 场景模板非常值得借鉴:
- Webhook
- Home Assistant
- 面板
- 相册
- Jellyfin 管理页
- “复制访问地址 / 二维码分享”适合手机用户。
- Cloud + FRP 双引擎的概念与 Fonu 的出口抽象一致。
### 3.6 Nginx Proxy Manager
项目定位:
- 最经典的自托管反向代理 Web 面板。
核心体验:
- Hosts、Streams、Access Lists、SSL Certificates、Users 分栏。
- 每条代理规则有独立状态、SSL、访问控制、高级配置。
- 证书申请和续签直接在规则旁完成。
- 管理界面简单,新手可按表单完成部署。
对 Fonu 的启示:
- 反向代理规则的“编辑页”应成为服务发布中心。
- Cloudflare 出口应该是一个选项,而不是另一套孤立页面。
- SSL、Access、健康检查、日志应围绕同一条规则组织。
- Fonu 当前已有这一方向,但页面还可以更像“资源详情页”。
### 3.7 Pangolin
项目定位:
- 开源自托管 SASE / Zero Trust 访问平台。
核心体验:
- 资源入口页。
- 用户、角色、资源级授权。
- 浏览器直接访问 SSH、RDP、VNC。
- 对每个资源做审计和会话记录。
- 多 connector 和站点冗余。
对 Fonu 的启示:
- 普通 NAS 用户更需要“访问我的服务”,不是“管理网络隧道”。
- 可以借鉴资源入口页:
- 按应用、域名、出口分组。
- 一键打开服务。
- 显示维护状态和最近访问。
- 不建议 Fonu 当前做企业 RBAC、SSO、PAM;成本过高。
### 3.8 Uptime Kuma
项目定位:
- 自托管监控和状态页。
核心体验:
- 多状态页。
- 不同域名绑定不同状态页。
- Ping 图表、证书信息。
- 通知渠道丰富。
- 2FA。
对 Fonu 的启示:
- 当前公开状态页已有域名和可用率。
- 下一步最值得补:
- 事故记录。
- 维护窗口。
- 最近 24h / 7d / 30d 状态条。
- 多个状态页。
- 自定义标题、Logo、说明。
- 这些比继续增加隧道参数更直接影响用户体验。
### 3.9 Tunnel Inspector
项目定位:
- Cloudflare Tunnel 的本地 HTTP 流量检查器。
核心体验:
- 实时请求列表。
- 方法和状态码筛选。
- 请求/响应头、Body、耗时、TTFB。
- 响应流式透传。
- 敏感头脱敏。
- 请求重放和 Copy as cURL。
- SSE 实时推送。
对 Fonu 的启示:
- 适合做成“Cloudflare 出口调试模式”,不要默认常驻。
- 可提供:
- 最近请求。
- 上游状态码。
- 回源耗时。
- 错误请求。
- 一键复制 curl。
- 需要严格限制:
- 仅本机访问。
- 默认脱敏。
- 默认关闭。
- 明确数据不离开本机。
---
## 四、跨项目共同的最佳体验
### 4.1 安装向导与预检
优秀项目不会让用户直接面对复杂配置。
建议 Fonu 增加:
1. 检查 cloudflared 是否安装。
2. 检查 Account ID。
3. 检查 API Token 权限。
4. 列出可用 Zone。
5. 检查目标根域名是否在 Zone 内。
6. 检查 DNS 是否已被其他目标占用。
7. 检查 7844 / 443 连通性。
8. 生成一条测试隧道并验证访问。
### 4.2 期望状态与调和中心
用户看到的不应只是“同步按钮”,而是:
```text
期望状态:已发布
Cloudflare 实际:缺失
最后同步:2 分钟前
原因:DNS CNAME 目标不一致
操作:修复 / 查看差异
```
这比单纯提示“保存失败”更有价值。
### 4.3 可视化拓扑
很多用户不理解 Nginx、frps、cloudflared、DNS、Access 的关系。
建议:
```text
域名 → 出口 → 隧道 → 内网服务
```
例如:
```text
nas.example.com
→ Cloudflare
→ tunnel: home-nas
→ http://192.168.1.10:8001
```
拓扑图可以直接显示:
- 域名。
- 出口。
- DNS 状态。
- 隧道状态。
- 回源状态。
- 证书来源。
### 4.4 路由表体验
当前 Fonu 已有路由表。建议继续补:
- 拖拽排序。
- 启用/停用。
- 复制路由。
- 正则路径提示。
- “测试此路由”按钮。
- 最后匹配结果。
- 路由来源标签:
- 手工。
- 反向代理规则。
- 导入。
- 修改历史和回滚。
### 4.5 状态页与事故
建议把公开状态页从“当前状态列表”升级为:
- 当前状态。
- 最近事故。
- 事故持续时间。
- 维护窗口。
- 最近 24h / 7d / 30d 状态条。
- 服务分组。
### 4.6 流量检查模式
建议作为高级诊断功能:
- 默认关闭。
- 开启后显示最近请求。
- 自动过滤静态资源。
- 显示 4xx/5xx。
- 回源错误和边缘错误分开。
- 可复制为 curl。
- 可查看请求/响应头。
- 默认隐藏 Authorization、Cookie、Token。
### 4.7 快速隧道
适合做“临时演示”:
- 只暴露单个本地端口。
- 生成 `trycloudflare.com` 地址。
- 明确标注:
- 地址会变化。
- 不保证 SLA。
- 不用于正式服务。
- 可复制地址或生成二维码。
### 4.8 一键分享与移动体验
对 NAS / 手机用户很有价值:
- 复制访问链接。
- 生成二维码。
- 分享到 Telegram / 微信。
- 移动端查看状态页。
- PWA 支持可作为远期项。
### 4.9 多账号与 `cert.pem`
桌面管理器和 DockFlare 都把多账号作为进阶能力。
建议 Fonu:
- 当前继续以 Account ID + API Token 为默认。
- 远期可支持多账号。
- `cert.pem` 不应替代 API Token,只作为高级兼容模式。
- 多账号必须有明确的账号隔离,避免路由误绑。
### 4.10 审计与回滚
自托管平台越复杂,回滚越重要。
建议:
- 记录规则和出口变更前后值。
- 记录操作主体。
- 记录 Cloudflare 同步结果。
- 支持手动回滚配置。
- 敏感字段只记录“已修改”,不记录明文。
---
## 五、Fonu 不该照搬的功能
| 功能 | 结论 | 原因 |
| --- | --- | --- |
| Kubernetes Controller | 不做 | 不是 NAS 用户场景 |
| 企业 SSO / PAM / RBAC 平台 | 暂不做 | 成本高,偏离 Fonu 定位 |
| Cloudflare 邮件套件 | 不做 | 与访问管理无关 |
| 多语言 13 种起步 | 暂不做 | 先保持中文产品体验 |
| 多主机 Agent 优先 | 后置 | 先做好单机 NAS |
| 自动配置 Access 策略 | 后置 | 权限风险高 |
| 默认常驻流量抓包 | 不做 | 隐私和性能风险 |
| 用户自定义 cloudflared 镜像管理 | 不做 | 增加维护复杂度 |
---
## 六、Fonu 建议路线
> 实施状态:截至 2026-10-01,本节的 P0 已完成第一轮落地,详见 `cloudflared-current-features.md`。
### P0:最值得先做
1. **Cloudflare 接入向导**
- Account、Token、Zone、Tunnel 一步检查。
2. **调和中心**
- 展示期望状态、云端状态、漂移原因、最后同步时间。
3. **路由表增强**
- 排序、启停、复制、测试、来源标签。
4. **一键修复**
- DNS、ingress、进程、二进制四类修复动作。
5. **状态页事故记录**
- 事故时间线、持续时长、维护窗口。
### P1:用户体验提升
6. **拓扑图**
- 域名 → 出口 → 隧道 → 服务。
7. **Docker 标签发布**
- 容器标签自动生成 Fonu 规则和 Cloudflare 出口。
8. **场景模板**
- Home Assistant、Immich、Jellyfin、qBittorrent、AdGuard。
9. **访问二维码与分享**
- 手机用户高频需求。
10. **云端覆盖本地**
- 完成双向同步,不只支持本地覆盖云端。
### P2:高级能力
11. **流量检查模式**
12. **多 Cloudflare 账号**
13. **Access 策略只读增强**
14. **审计日志**
15. **Quick Tunnel 临时演示**
16. **外部 Agent 多主机发布**
### P3:远期
17. PWA / 移动端体验。
18. 团队用户与资源级授权。
19. 浏览器 SSH / RDP / VNC 网关。
20. 多 connector 主备。
---
## 七、针对当前 Fonu 页面的具体改版建议
### 7.1 Cloudflare 总览页
建议结构:
```text
PageHeader
接入状态卡
隧道总览卡
待处理问题
最近变更
快速操作
```
待处理问题可以包括:
- API Token 权限不足。
- 根域名不在 Zone。
- DNS 被其他记录占用。
- 隧道离线。
- 本地与云端漂移。
- cloudflared 有新版本。
### 7.2 隧道配置页
建议分为:
```text
概览
路由
副本 / 指标
DNS
诊断
日志
高级
```
不要把所有内容放在一个长页面里。
### 7.3 反向代理规则页
在规则详情中增加:
```text
出口:本机 Nginx + Cloudflare
Cloudflare 状态:已同步
DNS:已创建
访问保护:未启用
最近错误:无
```
用户不需要离开规则详情就能知道整条链路是否健康。
### 7.4 仪表盘
增加:
- Cloudflare 在线隧道。
- 异常路由。
- DNS 漂移。
- 最近恢复 / 最近故障。
- cloudflared 可更新提示。
---
## 八、最终建议
Fonu 不应继续横向堆叠 cloudflared 参数。更值得投入的是:
1. 把 Cloudflare 接入做成向导。
2. 把“规则 → 出口 → 隧道 → DNS → 监控”做成同一条链路。
3. 把漂移、诊断和修复做成可操作闭环。
4. 把状态页做成真正的可分享服务状态页。
5. 把 Docker / NAS 服务发布做到自动发现。
6. 把高级能力放到高级页,不干扰默认路径。
这会让 Fonu 与普通 cloudflared 面板产生明确差异:
> 普通面板管理 cloudflared;Fonu 管理“我的服务如何安全、稳定地访问”。
+287 -4
View File
@@ -1,8 +1,8 @@
# Cloudflare Tunnel(cloudflared)与 Fonu 的融合方案 # Cloudflare Tunnel(cloudflared)与 Fonu 的融合方案
> 日期:2026-09-25 对象:Fonu 1.0.2(VERSION 1.2.12) > 日期:2026-09-25,v2 修订 2026-09-28 对象:Fonu 1.5.4(VERSION 1.5.4)
> 方法:先查 Cloudflare 官方文档与同类开源项目,再逐个核对本仓库现有可复用面(下文带 `文件:行` 的都是读过代码得出的)。 > 方法:先查 Cloudflare 官方文档与同类开源项目,再逐个核对本仓库现有可复用面(下文带 `文件:行` 的都是读过代码得出的);v2 逐文件核对了 `F:\WWW\cf\Tunnel-Cloudflared-linux`(Tunnel-Cloudflared-Manager 1.0.1)源码。
> 结论速览:**值得做,但建议以「第二条出口」的形态并存,先做 locally-managed + 进程托管,不接 Cloudflare API**。详见 §五、§六。 > 结论速览:**值得做,但建议以「第二条出口」的形态并存,先做 locally-managed + 进程托管,不接 Cloudflare API**。v2 修订了部署位置、模式生效侧、metrics / 健康与二进制校验四处关键细节,详见 §五、§六、§十二;实施以 §十二 为准。
--- ---
@@ -57,7 +57,7 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
**结论:第一轮只支持 locally-managed。** 若用户只想粘贴 token,提供「仅托管进程」的降级模式并**在界面上明确写清「规则由 Cloudflare 面板管理」**,不要让用户以为 Fonu 生成的规则生效了。 **结论:第一轮只支持 locally-managed。** 若用户只想粘贴 token,提供「仅托管进程」的降级模式并**在界面上明确写清「规则由 Cloudflare 面板管理」**,不要让用户以为 Fonu 生成的规则生效了。
> 补充(参考 §九 后修正):这个二分法不够细。参考实现证明**token 可以解码**(base64 的 JSON,含 `AccountTag` / `TunnelID` / `TunnelSecret`),用它自己生成 `creds.json` 就能按 locally-managed 跑本地 `config.yml`;再配合「云端 ↔ 本地 ingress 互相同步」,两种模式实际上是可切换的。所以 Fonu 的正确做法不是「二选一」,而是**显式区分并同步**:当前生效的是本地 `config.yml` 还是云端 ingress,以及「推送/拉取」两个动作。 > 补充(参考 §九 后修正):这个二分法不够细。参考实现证明**token 可以解码**(base64 的 JSON,含 `AccountTag` / `TunnelID` / `TunnelSecret`),用它自己生成 `creds.json` 就能按 locally-managed 跑本地 `config.yml`;再配合「云端 ↔ 本地 ingress 互相同步」,两种模式实际上是可切换的。所以 Fonu 的正确做法不是「二选一」,而是**显式区分并同步**:当前生效的是本地 `config.yml` 还是云端 ingress,以及「推送/拉取」两个动作。**v2 已把模式拆成三种并明确生效侧,见 §12.3。**
### 健康探测(这点比 frp 更省事) ### 健康探测(这点比 frp 更省事)
@@ -112,6 +112,8 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
## 六、推荐落地路径(分阶段) ## 六、推荐落地路径(分阶段)
> 本节阶段划分是 v1 口径;v2 修订见 §12.10,实施以 §12.10 为准。
### 阶段 1:只读与引导(最低风险,先验证需求) ### 阶段 1:只读与引导(最低风险,先验证需求)
1. 新增「Cloudflare 隧道」入口:粘贴 **token**(remotely-managed)或上传 **credentials JSON + tunnel UUID**(locally-managed),凭据加密存储。 1. 新增「Cloudflare 隧道」入口:粘贴 **token**(remotely-managed)或上传 **credentials JSON + tunnel UUID**(locally-managed),凭据加密存储。
@@ -158,6 +160,8 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
## 九、参考实现分析:`cf-tunnel-manager`(用户自有项目,Gitee `li-hua-88/cf-tunnel-manager`) ## 九、参考实现分析:`cf-tunnel-manager`(用户自有项目,Gitee `li-hua-88/cf-tunnel-manager`)
> 本节是 2026-09-25 的 v1 分析。2026-09-28 已对照 1.0.1 Linux 源码逐文件复核,补充与修正见 §十二。
> 项目:**Tunnel-Cloudflared-Manager** v1.0.1,Rust + Actix-web + Tokio + Serde,前端原生 JS + Tailwind(`static/`),JSON 文件存储,MIT;Linux systemd(`tcm`/`cftm` 运维脚本)+ Docker 两套交付。 > 项目:**Tunnel-Cloudflared-Manager** v1.0.1,Rust + Actix-web + Tokio + Serde,前端原生 JS + Tailwind(`static/`),JSON 文件存储,MIT;Linux systemd(`tcm`/`cftm` 运维脚本)+ Docker 两套交付。
> 本节结论来自逐个读到源码的模块:`src/services/{tunnel,binary,cloudflare_api}.rs`、`src/models/tunnel.rs`、`src/utils/{config_helper,paths}.rs`、`Cargo.toml`。 > 本节结论来自逐个读到源码的模块:`src/services/{tunnel,binary,cloudflare_api}.rs`、`src/models/tunnel.rs`、`src/utils/{config_helper,paths}.rs`、`Cargo.toml`。
@@ -254,6 +258,8 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
| 阶段 3(可选) | 「边缘优选」工具:并发探测官方 IPv4 段的 **TCP 7844 握手延迟** → 生成「两个 region 各 1~2 个优选 IP」的 hosts 建议;**页面展示而不是代写 hosts**(Windows 改 hosts 需管理员,Fonu 不应越权),并提供「复制这段内容」+ 生效检测 | 沿用本仓「不越权、降级要说明原因」的口径 | | 阶段 3(可选) | 「边缘优选」工具:并发探测官方 IPv4 段的 **TCP 7844 握手延迟** → 生成「两个 region 各 1~2 个优选 IP」的 hosts 建议;**页面展示而不是代写 hosts**(Windows 改 hosts 需管理员,Fonu 不应越权),并提供「复制这段内容」+ 生效检测 | 沿用本仓「不越权、降级要说明原因」的口径 |
| 阶段 3(可选) | 优选前后对比:用**已有的** `metrics_samples` 延迟/可用率序列展示效果,而不是另造一套测速图 | 复用 §四 的健康与指标链路 | | 阶段 3(可选) | 优选前后对比:用**已有的** `metrics_samples` 延迟/可用率序列展示效果,而不是另造一套测速图 | 复用 §四 的健康与指标链路 |
> metrics 端口发现、健康判定与网络调优默认值见 §12.5 / §12.7;本节只讨论「本机 → CF 边缘」的优选。
### 必须写进功能说明的三句实话 ### 必须写进功能说明的三句实话
1. 优选只影响「本机 → CF 边缘」这一段;访客到自己那侧 CF 边缘的那段,Fonu 与用户都控制不了。 1. 优选只影响「本机 → CF 边缘」这一段;访客到自己那侧 CF 边缘的那段,Fonu 与用户都控制不了。
@@ -270,4 +276,281 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
--- ---
## 十二、方案优化 v2(结合 Tunnel-Cloudflared-Manager 1.0.1 与 Fonu 1.5.4)
> 本节是 2026-09-28 的优化版落地口径。§一~§十一 保留为背景与论证;出现冲突时以本节为准。
> 实施状态(2026-09-28,v1.6.0):阶段 1–2 已实施,包含 token-local / token-remote 两种模式、token 解码与 creds.json / config.yml 生成、ingress 生成器、SQLite 存储、cloudflared 二进制下载、进程托管、日志、metrics 端口发现与健康抓取、`/api/cloudflare/*` 接口与前端页面。阶段 3 的账号模式(cert.pem)与云端 fetch / push、阶段 4 的边缘优选与快速隧道尚未实施。
### 12.1 源码核对新增的关键事实
逐文件核对 `F:\WWW\cf\Tunnel-Cloudflared-linux` 后,以下事实会直接改变实施细节:
| 事实 | 源码位置 | 对方案的影响 |
| --- | --- | --- |
| token 解码后的 JSON 字段是 `a` / `t` / `s`,兼容 `AccountTag` / `TunnelID` / `TunnelSecret`;使用标准 base64 并自动补 `=` | `src/services/cloudflare_api.rs` 的 `decode_tunnel_token` / `base64_decode` | token 模式也能本地生成 `creds.json`,从而按 locally-managed 跑本地规则 |
| `cert.pem` 内含 `-----BEGIN ARGO TUNNEL TOKEN-----` 块,解出 `accountID` + `apiToken` / `serviceKey` | 同文件 `parse_cert_pem` | 只有账号模式持有云端 API 凭据,才能 push / fetch 云端 ingress |
| `start_tunnel` 在 token 非空时优先用 `--token` 启动,即使已经生成了本地 `config.yml` | `src/services/tunnel.rs` 的 `start_tunnel` | **本地规则会被云端覆盖**;Fonu 必须显式选择「本地生效」还是「云端生效」 |
| 命名隧道**不传** `--metrics`,从 stderr 解析 `starting metrics server on 127.0.0.1:<port>/metrics`;快速隧道传 `--metrics 127.0.0.1:0` | `start_tunnel` / `start_quick_tunnel` | metrics 端口必须从日志发现,不能固定,否则多实例会撞端口 |
| 健康用 `cloudflared_tunnel_ha_connections > 0` 判定已连接;QUIC 有字节指标,HTTP/2 没有 | `start_metrics_scraper` | `/ready` 只适合做就绪判断,连接数与带宽要走 metrics;HTTP/2 带宽要明确降级 |
| 二进制下载只校验「文件 >= 10MB」,没有 SHA-256;`get_available_mirrors` 返回的镜像 ID 与实际支持的镜像不一致 | `src/services/binary.rs` | Fonu 必须做哈希 / 版本校验,并统一镜像表 |
| ingress 三条不变式:末条固定 `http_status:404`、`localhost` 归一化为 `127.0.0.1`、新规则插在 catch-all 之前 | `add_tunnel_endpoint` / `add_tunnel_endpoint_local_only` | 生成器必须锁定这三条并加纯函数测试 |
| Windows 子进程设置 `CREATE_NO_WINDOW` | `start_tunnel` | Fonu 在 Windows 托管 `cloudflared.exe` 时必须带,否则闪黑窗 |
| 快速隧道从 stderr 解析 `*.trycloudflare.com` URL 并缓存 | `start_quick_tunnel` | 只能做「临时演示」;URL 每次重启都会变 |
| 存储是 JSON 文件 + 内存 `HashMap<String, Child>` | `src/storage/config.rs`、`TunnelProcessManager` | Fonu 用 SQLite + 迁移,多实例与并发状态更稳 |
### 12.2 部署位置:原方案最大的空白
原方案默认「本机跑 cloudflared」,但没有回答一个关键问题:**Fonu 是容器化部署,容器里的 `127.0.0.1` 不是宿主机。**
Fonu 的运行时(`Dockerfile`)在同一个容器里装了主程序与 nginx,`docker-compose.yml` 只映射 `6893` / `80` / `443`。因此 v2 的默认拓扑是:
> **cloudflared 跑在 Fonu 容器内,与 nginx 同命名空间;CF ingress 统一指向 `http://127.0.0.1:<nginx_http_port>`(默认 80),由 nginx 再转发到上游。**
这样做有四个直接收益:
1. 绕开容器网络命名空间的坑:`127.0.0.1` 在容器内确定指向 Fonu nginx,而不是宿主机或上游。
2. 复用现有 nginx 的安全、限流、geo、TLS、错误页与 Basic Auth,不在 CF 侧重复一套策略。
3. 规则同源:CF 只负责「域名 → Fonu nginx」,nginx 负责「域名 → 上游」;本机反代规则仍是唯一事实来源。
4. 只有 HTTP / HTTPS 规则能进 CF;TCP / UDP 规则明确排除,避免用户误解。
三种拓扑的取舍:
| 拓扑 | cloudflared 位置 | ingress 目标 | 适用 |
| --- | --- | --- | --- |
| **容器内(默认)** | Fonu 容器 | `http://127.0.0.1:80`(容器内 nginx) | 绝大多数部署;规则先接入本机 nginx |
| host 网络 | Fonu 容器 `network_mode: host` | `http://127.0.0.1:80`(宿主机 nginx) | 需要直接访问宿主机端口、或已有宿主机 nginx |
| VPS Agent(可选) | VPS 上的 `fonu-agent` 托管 | `http://127.0.0.1:<frps vhost 端口>` | 已有 VPS、只想让部分域名走 CF 的高级用户 |
部署注意事项:
- cloudflared 是**出站 only**,不要为它额外映射任何入站端口。
- 二进制、`creds.json`、`cert.pem`、`config.yml`、日志都必须落在 `/data` 持久卷下,否则容器重建即丢失。
- 如果要暴露宿主机或内网服务,推荐先把该服务接入 Fonu 的本机 nginx 反代,再让 CF 指向 nginx;不要在 CF ingress 里直接写宿主机 IP。
- host 网络模式只作为显式选项,不默认开启;开启后 `127.0.0.1` 的语义会变化,UI 必须提示。
### 12.3 凭据与模式:显式区分「谁在生效」
参考实现证明了 token 可以解码,但也暴露了一个陷阱:**token 启动与本地 `config.yml` 是互斥的**。v2 把模式拆成三种,UI 必须让用户明确选择并显示当前生效侧:
| 模式 | 凭据 | 启动方式 | 规则来源 | 云端 fetch / push |
| --- | --- | --- | --- | --- |
| **A. token + 本地规则** | token | 解码 token → 写 `creds.json` → `tunnel --config config.yml run` | Fonu 生成的 `config.yml` | ❌(token 不含 API 凭据) |
| **B. token + 云端规则** | token | `tunnel run --token <token>` | Cloudflare Dashboard / 云端 | ❌ |
| **C. 账号 + 本地规则 + 云端同步** | `cert.pem` + credentials JSON | `tunnel --config config.yml run` | 本地,可与云端互相同步 | ✅ |
实施口径:
- 模式 A 绝不传 `--token`,只用 `--config`,否则本地规则不生效。
- 模式 B 必须在 UI 明确写「规则由 Cloudflare 面板管理,Fonu 的本地预览不生效」,不要让用户以为 Fonu 生成的规则已经下发。
- 模式 C 才提供「从云端拉取」「推送到云端」两个动作;token 模式不提供,只给 Dashboard 指引。
- 所有凭据走 `secret.Box` 加密字段;预览一律掩码,下发时用原文。`creds.json` 权限 `0600`,`config.yml` 权限 `0640`,目录 `0750`。
- 状态接口返回 `effective_side: local | cloud`,前端常驻显示,切换模式时要求二次确认。
### 12.4 数据模型与目录布局(Fonu 版)
参考实现用 JSON 文件 + 内存 HashMap,适合单进程小工具;Fonu 已有 SQLite 与迁移体系,v2 建议落成表:
`cf_tunnels`:
| 字段 | 说明 |
| --- | --- |
| `id` / `name` | 主键与用户可见名称 |
| `mode` | `token-local` / `token-remote` / `account-local` |
| `tunnel_id` / `account_tag` | 从 token 或 credentials 解析 |
| `credentials_enc` | token 或 credentials JSON 密文 |
| `cert_pem_enc` | 账号模式 `cert.pem` 密文 |
| `api_token_enc` | 可选的云端同步 API Token 密文 |
| `config_path` / `creds_path` / `log_path` | 运行文件路径 |
| `binary_version` | 当前使用的 cloudflared 版本 |
| `network_json` | protocol / edge-ip-version / ha-connections / proxy_mode / originRequest |
| `status` / `last_error` | `stopped` / `starting` / `connected` / `reconnecting` / `failed` |
| `metrics_port` | 从日志发现的端口,仅运行时缓存 |
| `auto_start` | Fonu 启动时是否自动拉起 |
目录布局:
```text
<DataDir>/cloudflared/
bin/<version>/cloudflared
tunnels/<id>/
config.yml
creds.json
cert.pem
cloudflared.log
```
多实例 = 多目录 + 多子进程 + 多日志文件;快速隧道单独用 `mode=quick` 记录,`public_url` 只在运行期有效。
### 12.5 进程与二进制管理
建议新建 `internal/cloudflared` 包,沿用 `internal/frp` 的 runtime / manager 模式,但不直接耦合 frp 模型:
**二进制**
- 统一镜像表:`official` / `gh-proxy-org` / `gh-proxy-hk` / `gh-proxy-cdn` / `gh-proxy-edgeone` + 自定义 URL 模板;修正参考项目「镜像列表与实现不一致」的问题。
- 下载校验:优先官方 checksums(若提供);否则至少校验文件大小、可执行性、`cloudflared --version`;镜像源与官方源使用同一份哈希。
- 版本目录 + 原子替换 + 激活指针,复用 `internal/frp/runtime.go` 的 `readActiveBinary` / `writeActiveBinary` 思路。
- 支持 linux amd64 / arm64、windows amd64、darwin amd64 / arm64;Fonu 侧用 `runtime.GOOS/GOARCH` 选择。
**进程**
- `exec.Command` + stdout / stderr 逐行读取,同时追加写入 `<tunnel>/cloudflared.log`。
- Windows 设置 `CREATE_NO_WINDOW`(`0x08000000`)。
- 启动参数:
- 模式 A / C:`tunnel --config <config.yml> run`
- 模式 B:`tunnel run --token <token>`
- 快速隧道:`tunnel --metrics 127.0.0.1:0 --url <target>`
- 网络参数:`--ha-connections`、`--origin-ca-pool`、`--no-tls-verify`、`--http-host-header`;环境变量 `TUNNEL_TRANSPORT_PROTOCOL`、`TUNNEL_EDGE_IP_VERSION`。
**metrics 端口发现**
- 命名隧道不传 `--metrics`;快速隧道传 `--metrics 127.0.0.1:0`。
- 从 stderr 用正则解析:`starting metrics server on 127\.0\.0\.1:(\d+)/metrics`。
- 解析到端口后轮询 `http://127.0.0.1:<port>/metrics`,最多等待 60 秒;超时给出明确原因,不留下空白状态。
**健康判定**
- `/ready` 返回 200:cloudflared 已就绪。
- `cloudflared_tunnel_ha_connections > 0`:已连上边缘。
- 延迟:QUIC 用 `quic_client_smoothed_rtt`;HTTP/2 用 `cloudflared_rpc_client_latency_secs` 或主动 HEAD 探测。
- 带宽:QUIC 直接用 `quic_client_receive_bytes` / `quic_client_sent_bytes` 差值;HTTP/2 官方没有字节指标,Fonu 第一轮明确降级为「不展示带宽」并给出原因,不做参考项目那套进程 socket 统计。
---
### 12.6 ingress 生成器(与 nginx 规则同源)
输入:
- 本机反代规则中启用且属于 HTTP / HTTPS 的域名;
- Fonu nginx 的容器内入口端口(默认 `80`,可用配置覆盖);
- 用户勾选要暴露的域名集合。
输出:`config.yml` 的 `ingress` 数组。三条不变式必须写进生成器并用纯函数测试锁定:
1. 末条永远是 `- service: http_status:404`;
2. `localhost` 一律归一化为 `127.0.0.1`;
3. 新规则插在 catch-all 之前,且同域名去重、排序稳定。
与 nginx 的关系:
```text
访客 → Cloudflare 边缘 → cloudflared(Fonu 容器)→ 127.0.0.1:80(Fonu nginx)→ 上游
```
- CF 侧不重复配置限流、geo、TLS、Basic Auth;这些仍由本机 nginx 负责。
- 只允许 HTTP / HTTPS 规则进入 CF ingress;TCP / UDP 规则在界面上明确标为「Cloudflare 隧道不支持」。
- 生成后提供「配置预览(脱敏)」与「生成后自检」,自检至少覆盖三条不变式与空 ingress 场景。
### 12.7 网络调优(默认值与下发方式)
| 设置 | Fonu 默认 | 下发方式 | 说明 |
| --- | --- | --- | --- |
| `transport_protocol` | `http2` | 环境变量 `TUNNEL_TRANSPORT_PROTOCOL` + `config.yml` 的 `protocol` | 国内 QUIC(UDP 7844)常被 QoS,TCP 更稳;提供 `auto` / `quic` |
| `edge_ip_version` | `4` | 环境变量 `TUNNEL_EDGE_IP_VERSION` + `config.yml` 的 `edge-ip-version` | 2026.4.0 起官方默认改为 `auto`,国内 IPv6 路由差时反而变慢;提供 `auto` / `6` |
| `ha_connections` | `2` | `--ha-connections` + `config.yml` 的 `ha-connections` | 每个 region 的连接数,1–4 可调 |
| `proxy_mode` | `system` | 启动时按需移除 / 保留 `HTTP_PROXY` 等环境变量 | `disabled` 时清空代理变量;检测到代理或 Windows 时协议回落 `http2` |
| `originRequest` | 空 | `--origin-ca-pool` / `--no-tls-verify` / `--http-host-header` | 仅高级用户;默认不回源 HTTPS,避免自签证书问题 |
诊断接口返回:平台、代理环境探测结果、实际协议、实际代理模式、建议。降级必须给出原因,例如 `windows_estats_requires_admin`、`http2_official_metrics_unavailable`。
边缘优选(阶段 3 可选):
- 只做「本机 → CF 边缘」的 TCP 7844 延迟探测与展示,不代写 hosts;
- 两个 region 都要覆盖,IP 列表可配置、有默认值、可一键恢复默认;
- 访客到自己那侧 CF 边缘的一段无法优选,功能说明必须写清楚。
### 12.8 云端同步(仅账号模式)
账号模式(`cert.pem`)才提供云端同步,API 细节:
- 拉取:`GET https://api.cloudflare.com/client/v4/accounts/{account_id}/cfd_tunnel/{tunnel_id}/configurations`
- 推送:`PUT` 同一路径,body 为 `{"config":{"ingress":[...]}}`
- 推送的 ingress 必须包含末条 catch-all,否则 Cloudflare 可能拒绝或行为不确定。
冲突处理:
- UI 常驻显示 `effective_side: local | cloud`;
- 「拉取到本地」会覆盖本地 `config.yml`,操作前备份当前版本(复用本仓 `fsutil.BackupVersioned`);
- 「推送到云端」会覆盖云端 ingress,操作前展示 diff 并二次确认;
- 两个动作都写审计日志(操作者、隧道、方向、结果)。
token 模式不提供 push / fetch,只给 Dashboard 指引;这是凭据能力的硬边界,不要用「同步失败」这类模糊提示掩盖。
### 12.9 与 Fonu 现有模块的集成点
| Fonu 模块 | 复用还是新增 | 具体落点 |
| --- | --- | --- |
| 二进制下载 / 启用 | 新增 `internal/cloudflared`,参考 `internal/frp/runtime.go` | 镜像表、哈希校验、版本目录、激活指针 |
| 进程托管 | 新增 `internal/cloudflared`,参考 `internal/frp/manager.go` | 启停、日志追加、退出原因、`tailFile` |
| 健康与可用率 | 复用 `internal/monitor` 的 `Source` / `HealthRecorder` | 新增 CF Tunnel Source;`/ready` + `ha_connections` |
| 通知 | 复用 `internal/notify` | 隧道断开 / 恢复事件 |
| 状态页 | 复用现有公开状态页 | 新增隧道条目与可用率 |
| 规则来源 | 复用 `internal/proxy` 的本机反代规则 | 只取 HTTP / HTTPS 域名,生成 ingress |
| 日志界面 | 复用现有日志页模型 | 读 `<tunnel>/cloudflared.log`,不引入 WebSocket |
| 凭据加密 | 复用 `internal/secret` | token / cert.pem / credentials JSON 全部加密 |
| frp 链路 | **不改** | CF Tunnel 是并存出口,不碰现有 frp 规则、健康与状态语义 |
### 12.10 修订后的阶段计划
| 阶段 | 交付物 | 验收标准 |
| --- | --- | --- |
| 阶段 1:凭据与配置 | 凭据导入(token / cert.pem)、模式选择、`creds.json` + `config.yml` 生成、脱敏预览 | 三种模式都能生成文件;模式 B 明确提示本地规则不生效;凭据不明文回显 |
| 阶段 2:进程与健康 | 二进制下载 / 启用、进程启停、日志、metrics 端口发现、`/ready` + 连接数健康 | 启动后能连上边缘;停止后进程与端口释放;健康状态落库并进通知 |
| 阶段 3:规则同源与云端同步 | 从本机反代规则生成 ingress、漂移检测、账号模式 fetch / push、网络调优设置 | ingress 三条不变式测试通过;云端同步有 diff 与审计;网络参数可配置 |
| 阶段 4(可选) | 边缘优选、快速隧道、高级 originRequest | 优选只展示不越权;快速隧道明确标注「临时演示」 |
### 12.11 测试与验收
单元测试:
- token 解码:标准 base64、缺填充、非法输入、`a/t/s` 与 `AccountTag/TunnelID/TunnelSecret` 两种字段名;
- ingress 生成:catch-all、`localhost` 归一化、插入位置、去重、空规则;
- metrics 解析:端口行、QUIC / HTTP/2 指标、连接数、降级原因;
- 网络参数构建:protocol / edge-ip-version / ha-connections / proxy_mode;
- 镜像 URL 与自定义模板;
- 凭据脱敏:token、cert.pem、credentials JSON 不出现在预览与日志。
集成测试:
- 启动 / 停止 / 重启 / 自动启动;
- metrics 端口发现与健康状态流转;
- 日志追加与 tail;
- 云端 fetch / push 用 mock API 验证请求体、catch-all 与错误处理;
- 容器内 `127.0.0.1:80` 回源链路。
前端:
- 隧道列表 / 详情 / 状态 / 日志 / 配置预览;
- 模式选择与「当前生效侧」提示;
- 脱敏预览与二次确认。
真机矩阵:Linux amd64 / arm64、Windows、Docker 容器内、host 网络、代理环境、国内与海外链路。
### 12.12 风险与边界(补充)
- **容器网络**:默认拓扑是容器内 cloudflared → 容器内 nginx;不要假设 `127.0.0.1` 是宿主机。
- **token 覆盖**:模式 B 的本地 `config.yml` 不生效;UI 必须明确,模式 A 才能本地管规则。
- **HTTP/2 带宽**:官方 metrics 没有字节指标;第一轮明确降级并说明原因,不做进程 socket 统计。
- **Windows**:`CREATE_NO_WINDOW` 必带;若做进程级字节统计还需要管理员权限,Fonu 第一轮不做。
- **CF 条款**:视频 / 大文件分发不在推荐用法内;TryCloudflare 不保证 SLA。
- **国内链路**:CF 边缘延迟受运营商影响;优选只影响本机到边缘一段。
- **API 凭据**:账号模式的 `cert.pem` 与可选 API Token 都是高权限凭据,必须最小化权限并加密存储。
- **Access**:只做诊断提示,不代配策略。
- **TCP / UDP**:CF Tunnel 不是通用 TCP 出口;需要访问端装 cloudflared 的场景不在首轮范围。
- **不改 frp**:现有 frp 链路、健康与状态页语义保持不变。
### 12.13 结论
Cloudflare Tunnel 对 Fonu 是**第二条出口**,不是 frp 的替代:它补上「没有公网 IP、也不想长期租 VPS」的 HTTP 类场景。v2 的核心修订是四件事:
1. cloudflared 跑在 Fonu 容器内,ingress 统一指向容器内 nginx,绕开容器网络坑;
2. 显式区分「本地规则生效」与「云端规则生效」,不让 token 模式悄悄覆盖本地配置;
3. metrics 端口从日志发现,健康用 `/ready` + `ha_connections`,HTTP/2 带宽明确降级;
4. 二进制下载补哈希 / 版本校验,ingress 生成器锁定三条不变式。
按 §12.10 的阶段 1–3 实施即可形成可用闭环;阶段 4 的边缘优选与快速隧道作为可选增强,不阻塞主线。
---
> 说明:本文只做方案分析,未改动任何代码。实施时沿用本仓库既有约定:每步验证 `go build/vet/test` + `vue-tsc/vite build`、agent 行为变更递增 `AgentVersion`、提交同批更新 `CHANGELOG` 与 `VERSION`、密钥相关内容必须过一遍「预览脱敏 / 下发原文」两头检查。 > 说明:本文只做方案分析,未改动任何代码。实施时沿用本仓库既有约定:每步验证 `go build/vet/test` + `vue-tsc/vite build`、agent 行为变更递增 `AgentVersion`、提交同批更新 `CHANGELOG` 与 `VERSION`、密钥相关内容必须过一遍「预览脱敏 / 下发原文」两头检查。
+3 -1
View File
@@ -172,8 +172,10 @@ FONU_VERSION=1.2.18 docker compose up -d
| `FONU_LISTEN` | `:6893` | 面板监听地址 | | `FONU_LISTEN` | `:6893` | 面板监听地址 |
| `FONU_SESSION_SECRET` | 空 | 留空即自动生成并持久化;**它是凭据加密密钥,有数据后不要更换** | | `FONU_SESSION_SECRET` | 空 | 留空即自动生成并持久化;**它是凭据加密密钥,有数据后不要更换** |
| `FONU_NGINX_HTTP_PORT` / `FONU_NGINX_HTTPS_PORT` | `80` / `443` | 反代监听端口(host 网络那份默认 18080/9443) | | `FONU_NGINX_HTTP_PORT` / `FONU_NGINX_HTTPS_PORT` | `80` / `443` | 反代监听端口(host 网络那份默认 18080/9443) |
| `FONU_UPDATER_SOCKET` | `/run/fonu-updater/updater.sock` | 一键升级 sidecar 的 Unix Socket |
| `DOCKER_SOCK` | `/var/run/docker.sock` | 宿主机 Docker Socket;rootless Docker 或自定义路径时覆盖 |
| `TZ` | `Asia/Shanghai` | 通知与日志显示用的时区 | | `TZ` | `Asia/Shanghai` | 通知与日志显示用的时区 |
| `NODE_IMAGE` / `GO_IMAGE` / `RUNTIME_IMAGE` | 国内镜像源 | 构建用的基础镜像,被加速器拦截时用来覆盖 | | `NODE_IMAGE` / `GO_IMAGE` / `RUNTIME_IMAGE` / `UPDATER_IMAGE` | 国内镜像源 | 构建用的基础镜像,被加速器拦截时用来覆盖 |
--- ---
+31
View File
@@ -312,6 +312,8 @@ Fonu 首次启动已创建管理员账户
## 3.3 P0-3:Agent Token 使用常量时间比较 ## 3.3 P0-3:Agent Token 使用常量时间比较
> 实施状态:2026-09-27 已完成。
### 3.3.1 现状 ### 3.3.1 现状
`internal/agent/server.go` 的 `auth` 方法使用普通字符串比较: `internal/agent/server.go` 的 `auth` 方法使用普通字符串比较:
@@ -370,10 +372,20 @@ if token == "" || !secureEqual(token, s.cfg.Token) {
- 正确 Token 通过,错误 Token 拒绝。 - 正确 Token 通过,错误 Token 拒绝。
- Agent 不依赖应用侧 API 中间件代码。 - Agent 不依赖应用侧 API 中间件代码。
### 3.3.6 实施记录
- `internal/agent/server.go` 新增 `bearerToken` 与 `secureTokenEqual`,认证路径改用 `crypto/subtle.ConstantTimeCompare`。
- `AgentVersion` 已由 `0.12.7` 提升到 `0.12.8`。
- 新增 `internal/agent/auth_test.go`,覆盖正确 Token、大小写 Bearer、多余空白、错误 Token、长度不同、缺失 Authorization 和错误认证方案。
- 内嵌 amd64 / arm64 二进制已重编;VPS 需要执行一次「升级 Agent」。
- 验证通过:`go test ./internal/agent -count=1`、`go test ./... -count=1`、`go vet ./...`、`npm test`(17 条)。
--- ---
## 3.4 P0-4:限流状态解析失败时拒绝覆盖原状态 ## 3.4 P0-4:限流状态解析失败时拒绝覆盖原状态
> 实施状态:2026-09-28 已完成。
### 3.4.1 现状 ### 3.4.1 现状
`internal/agent/server.go` 的 `readLimitState`: `internal/agent/server.go` 的 `readLimitState`:
@@ -433,10 +445,23 @@ func (s *Server) clearLimitState(domain string) error
- 错误能显示在“应用配置”或路由保存响应中。 - 错误能显示在“应用配置”或路由保存响应中。
- `nginx -t` 仍以真实生成结果为准。 - `nginx -t` 仍以真实生成结果为准。
### 3.4.6 实施记录
- `readLimitState` 改为返回 `(map[string]routeLimit, error)`,新增可测试的 `readLimitStateFile`。
- 文件不存在返回空状态;读取失败、空文件、`null` 或 JSON 损坏返回错误。
- `applyLimitState` / `clearLimitState` 传播错误并保留原文件,不再用空状态覆盖。
- 新增 `internal/agent/limits_test.go`,覆盖缺失、正常、空文件、截断 JSON、null 和原文件不变。
- `AgentVersion` 提升到 `0.12.9`,双架构内嵌二进制已重编。
- 验证通过:`go test ./internal/agent -count=1`、`go test ./... -count=1`、`go vet ./...`、`npm test`。
--- ---
## 3.5 P0-5:明确 Agent 默认监听策略 ## 3.5 P0-5:明确 Agent 默认监听策略
> 方案文档:`agent-listen-strategy-plan.md`(2026-09-28,待实施)
> 实施状态:阶段 1-4 已随 v1.4.0 实施(默认回环、SSH 隧道、旧 HTTP 自动迁移、HTTPS + pin);仅剩 2.0.0 兼容清理。
### 3.5.1 现状 ### 3.5.1 现状
- `cmd/fonu-agent/main.go` 默认 `:7700`。 - `cmd/fonu-agent/main.go` 默认 `:7700`。
@@ -560,6 +585,8 @@ P1 的核心是控制复杂度,不改变可见功能。
## 4.1 P1-1:抽共享 Nginx 模板包 ## 4.1 P1-1:抽共享 Nginx 模板包
> 实施状态:2026-09-28 已完成(v1.5.1)。共享包 `internal/nginxtmpl` 已落地,本机与 Agent 的 TLS、安全响应头、allow/deny、WebSocket、限流指令与 zone 命名均已迁出;仅保留中国 IP geo 变量名与 Agent 限流文件路径的适配层。
### 4.1.1 现状 ### 4.1.1 现状
本机与公网 Agent 各有一套 Nginx 生成逻辑: 本机与公网 Agent 各有一套 Nginx 生成逻辑:
@@ -816,6 +843,8 @@ func ZoneName(prefix string, parts ...string) string
## 4.4 P1-4:补路由鉴权、配置回滚、Agent 版本兼容测试 ## 4.4 P1-4:补路由鉴权、配置回滚、Agent 版本兼容测试
> 实施状态:2026-09-28 已完成(v1.5.3)。API 与 Agent 路由均收敛为可遍历路由表;新增 `router_auth_test.go` 遍历全部受保护路由验证匿名 401;补齐本机与 Agent 的配置回滚测试;新增 `agent_versions_compat_test.go` 与前端版本字段降级测试。
### 4.4.1 路由鉴权覆盖测试 ### 4.4.1 路由鉴权覆盖测试
目标:防止新增 API 时漏掉 `protect()`。 目标:防止新增 API 时漏掉 `protect()`。
@@ -907,6 +936,8 @@ internal/frp/agent_versions_compat_test.go
## 4.5 P1-5:统一前端轮询组合式函数 ## 4.5 P1-5:统一前端轮询组合式函数
> 实施状态:2026-09-28 已完成(v1.5.4)。`useVisibilityPolling` 增加 in-flight 互斥、`enabled` / `immediate` / `onError` 选项与 `busy` / `lastError` 返回值;Dashboard、Proxy、Logs、Settings、Frp、UpdateModal、Agent 的轮询已全部迁移,仅 AppLayout 时钟保留原生计时器。
### 4.5.1 现状 ### 4.5.1 现状
已有 `web/src/composables/useVisibilityPolling.ts`,并被: 已有 `web/src/composables/useVisibilityPolling.ts`,并被:
+254
View File
@@ -0,0 +1,254 @@
# Fonu Web 一键升级方案
> 实施版本:v1.3.0
> 适用范围:`docker-compose.yml`、`docker-compose.hub.yml`
> 不适用范围:`docker-compose.host.yml`、原生 systemd 安装、Windows 直接运行
## 1. 设计目标
在管理后台检测到新版本后,用户点击「一键升级」即可完成:
1. 读取远端最新版本;
2. 构建新的 `fonu` 镜像;
3. 只重建 `fonu` 容器;
4. 等待 `/api/version` 返回目标版本;
5. 失败时尝试回滚到升级前的镜像 tag;
6. 把升级过程和结果返回给前端。
## 2. 安全边界
主程序容器**不挂载** `/var/run/docker.sock`。
只有 `fonu-updater` sidecar 持有:
- Docker Socket;
- Docker CLI 与 Compose;
- 项目目录;
- Git。
主程序与 sidecar 通过一个共享 Unix Socket 通信:
```text
浏览器
→ fonu 后端 /api/system/update/*
→ /run/fonu-updater/updater.sock
→ fonu-updater
→ docker compose build/up
```
这表示:
- 管理后台被攻破时,攻击者仍需通过 sidecar 的固定接口才能触发升级;
- sidecar 只接受固定的 Compose 文件名和固定的 `fonu` 服务名;
- 不提供任意命令执行接口;
- 不支持自定义 shell 命令。
## 3. 两种升级模式
| Compose 文件 | 模式 | 行为 |
| --- | --- | --- |
| `docker-compose.yml` | `local` | 在项目目录检查 Git 工作区,干净时执行 `git pull --ff-only`,再构建 |
| `docker-compose.hub.yml` | `git` | Compose 的 build context 直接从 Git 仓库拉取,updater 只负责构建与重建 |
两种模式都会:
- 读取目标版本;
- 构建 `fonu` 镜像;
- 执行 `docker compose up -d --no-deps --force-recreate fonu`;
- 轮询 `/api/version` 验证升级结果。
## 4. 首次部署
一键升级依赖 `fonu-updater` 已经运行。
旧版本升级到 v1.3.0 时,需要先手动执行一次:
```bash
# 本地源码部署
docker compose up -d --build
# 单文件 Git 构建部署
docker compose -f docker-compose.hub.yml up -d --build
```
完成这一轮后,后续版本即可在 Web 页面一键升级。
## 5. 环境变量
### 主程序
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `FONU_UPDATER_SOCKET` | `/run/fonu-updater/updater.sock` | 与 sidecar 通信的 Unix Socket |
### updater sidecar
| 变量 | 说明 |
| --- | --- |
| `FONU_UPDATER_SOCKET` | sidecar 监听的 Unix Socket |
| `FONU_UPDATE_PROJECT_DIR` | Compose 项目目录,容器内为 `/workspace` |
| `FONU_UPDATE_COMPOSE_FILE` | 只允许 `docker-compose.yml`、`docker-compose.hub.yml`、`docker-compose.host.yml` |
| `FONU_UPDATE_SERVICE` | 要重建的服务,默认 `fonu` |
| `FONU_UPDATE_MODE` | `local` 或 `git` |
| `FONU_UPDATE_REPO` | `git` 模式下的仓库地址 |
| `FONU_UPDATE_BRANCH` | `git` 模式下的分支,默认 `main` |
| `FONU_UPDATE_APP_URL` | 健康检查地址,默认 `http://fonu:6893` |
| `DOCKER_SOCK` | 宿主机 Docker Socket,默认 `/var/run/docker.sock` |
| `FONU_UPDATE_HOST_DIR` | 宿主机上 Compose 文件所在目录;留空时自动从 Compose 容器标签发现,设置后作为显式覆盖 |
## 6. 前端行为
侧栏版本区在满足以下条件时显示「一键升级」:
- sidecar 可用;
- 远端版本高于当前版本;
- 当前没有正在执行的升级任务。
点击后弹窗显示:
- 当前版本;
- 目标版本;
- 升级方式;
- 当前阶段;
- 最近日志。
升级开始后,页面会持续轮询升级状态。容器重建期间请求会短暂失败,前端会显示「服务正在重启,等待恢复」,恢复成功后自动刷新页面。
## 7. 回滚
升级前会记录当前容器镜像。升级后健康检查超时,sidecar 会尝试:
```bash
FONU_VERSION=<上一个版本> docker compose up -d --no-deps --force-recreate fonu
```
回滚是否成功会写入升级日志。
## 8. 常见问题
### 侧栏没有「一键升级」
检查:
1. 当前是否使用 `docker-compose.yml` 或 `docker-compose.hub.yml`;
2. `fonu-updater` 容器是否在运行;
3. `FONU_UPDATER_SOCKET` 是否在两侧指向同一路径;
4. 共享卷 `fonu-updater-sock` 是否存在。
如果状态接口返回:
```text
读取远端版本失败:git clone ... signal: killed ... fatal: early EOF
```
说明运行中的 updater 仍是旧版本,仍在用完整克隆读取 `VERSION`。升级到 v1.3.2 后,updater 会改用 Gitea API / raw 地址读取版本文件。由于 sidecar 不能升级自身,需要先手动重建一次:
```bash
docker compose -f docker-compose.hub.yml up -d --build updater
# 本地源码部署
docker compose up -d --build updater
```
### `docker-compose.yml` 模式提示存在未提交改动
升级前的 Git 工作区检查要求干净,避免覆盖用户修改。先处理工作区,或手动执行:
```bash
docker compose up -d --build
```
### 升级时报 `/workspace/docker-compose.hub.yml: no such file or directory`
v1.3.6 起 updater 会自动从 `fonu` 容器的 Compose 标签读取宿主机目录,并通过 helper 容器执行 Compose。如果自动发现仍失败,可以显式设置目录:
先查询运行中 `fonu` 容器的 Compose 工作目录:
```bash
docker inspect fonu --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}'
```
把输出目录写入 Compose 同层 `.env` 作为覆盖:
```dotenv
FONU_UPDATE_HOST_DIR=/实际/的/Compose/目录
```
然后只重建 updater:
```bash
docker compose -f /实际/的/Compose/目录/docker-compose.hub.yml up -d --build updater
```
状态接口会明确提示 Compose 文件缺失,修复挂载或设置覆盖后按钮才会重新显示。
### 报 `container name "/fonu" is already in use`
这表示 Compose 没有识别到当前容器所属的原项目,可能把升级当成新项目并尝试新建网络与数据卷。v1.3.7 的更新版 updater 会读取现有容器的 `com.docker.compose.project` 标签,并显式复用原项目名。
如果现有 `fonu` 容器不是由 Compose 管理,updater 会拒绝升级,避免新建 `fonu_fonu-data` 等空数据卷。应先按 Compose 方式重新部署,再使用一键升级。
### helper 命令里出现 `-v /workspace:/workspace`
说明 Compose 标签只返回了容器内路径。更新后的 updater 会优先从 `project.config_files` 标签推导宿主机目录,并忽略 `/workspace` 这类容器路径。若标签仍无法提供真实宿主机路径,应检查部署工具的 Compose 标签是否完整;正常情况下无需手工设置目录。
### `docker-compose.hub.yml` 模式无法读取远端版本
检查 sidecar 容器内是否可以访问 Git 仓库:
```bash
docker exec -it fonu-updater sh -lc 'git ls-remote http://45.207.223.190:10082/mrlee/-Fonu.git main'
```
### 升级卡在 `running`
查看 sidecar 日志:
```bash
docker logs -f fonu-updater
```
以及 Compose 输出:
```bash
docker compose ps
docker compose logs --tail 100 fonu
```
### 回滚失败
确认升级前的镜像 tag 仍然存在:
```bash
docker images fonu
```
然后用明确的版本 tag 手动切回:
```bash
FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu
```
## 9. 暂不支持
- `docker-compose.host.yml`:需要确认 host 网络与端口占用后再设计;
- 原生 systemd 安装:需要单独的受控升级 helper;
- 回滚到远端不存在的镜像:需要额外发布流程;
- 升级 `fonu-updater` 自身:当前只重建 `fonu` 服务,sidecar 更新仍需一次手动 Compose 操作。
## 10. 在线升级验证
示例页面用于验证升级结果。v1.9.0 的验证流程:
1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。
2. 确认远端 `main` 分支的 `VERSION` 为 `1.9.0`。
3. 在侧栏看到「一键升级」后点击并确认。
4. 等待升级弹窗显示成功,页面自动刷新。
5. 打开侧栏「示例页面」,确认:
- 页面路由为 `/example`;
- 页面显示的版本为 `1.9.0`;
- 页面显示验证标识 `v1.9.0-example-page`;
- 页面显示独立的「本次升级验证」区块;
- 「升级验证」显示为「通过」。
如果示例页面可见且版本正确,说明镜像构建、容器重建、后端版本注入和前端静态资源更新均已生效。
+54
View File
@@ -0,0 +1,54 @@
package agent
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestServerAuthToken(t *testing.T) {
server := &Server{cfg: Config{Token: "secret-token"}}
handler := server.auth(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
cases := []struct {
name string
header string
status int
}{
{name: "正确 Token", header: "Bearer secret-token", status: http.StatusNoContent},
{name: "Bearer 大小写不敏感", header: "bearer secret-token", status: http.StatusNoContent},
{name: "多余空白可容忍", header: " Bearer secret-token ", status: http.StatusNoContent},
{name: "错误 Token", header: "Bearer wrong-token", status: http.StatusUnauthorized},
{name: "长度不同的 Token", header: "Bearer secret-token-longer", status: http.StatusUnauthorized},
{name: "缺少 Authorization", status: http.StatusUnauthorized},
{name: "认证方案错误", header: "Token secret-token", status: http.StatusUnauthorized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/ping", nil)
if tc.header != "" {
req.Header.Set("Authorization", tc.header)
}
recorder := httptest.NewRecorder()
handler(recorder, req)
if recorder.Code != tc.status {
t.Fatalf("期望状态 %d,实际 %d", tc.status, recorder.Code)
}
})
}
}
func TestSecureTokenEqual(t *testing.T) {
if !secureTokenEqual("same-token", "same-token") {
t.Fatal("相同 Token 应通过")
}
if secureTokenEqual("short", "longer-token") {
t.Fatal("不同 Token 不应通过")
}
if secureTokenEqual("", "non-empty") {
t.Fatal("空 Token 不应通过")
}
}
+127
View File
@@ -0,0 +1,127 @@
package agent
import (
"encoding/json"
"errors"
"net/http"
"os/exec"
"strconv"
"strings"
)
type firewallStatus struct {
Tool string `json:"tool"`
Active bool `json:"active"`
PortOpen bool `json:"port_open"`
Message string `json:"message,omitempty"`
}
// firewallStatus 只检测主机防火墙,不修改规则;云安全组无法从 VPS 内部感知。
func (s *Server) firewallStatus(w http.ResponseWriter, r *http.Request) {
port := 7443
if raw := strings.TrimSpace(r.URL.Query().Get("port")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 65535 {
port = parsed
}
}
writeJSON(w, http.StatusOK, detectFirewall(port))
}
// firewallAllow 仅在用户显式确认后调用;只自动处理 ufw 与 firewalld。
func (s *Server) firewallAllow(w http.ResponseWriter, r *http.Request) {
var req struct {
Port int `json:"port"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if req.Port <= 0 || req.Port > 65535 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "端口无效"})
return
}
output, err := allowFirewallPort(req.Port)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "output": output, "status": detectFirewall(req.Port)})
}
func detectFirewall(port int) firewallStatus {
if path, err := exec.LookPath("ufw"); err == nil {
if out, err := exec.Command(path, "status").Output(); err == nil {
active, open := parseUFWStatus(string(out), port)
if active {
return firewallStatus{Tool: "ufw", Active: true, PortOpen: open}
}
}
}
if path, err := exec.LookPath("firewall-cmd"); err == nil {
if out, err := exec.Command(path, "--state").Output(); err == nil && strings.TrimSpace(string(out)) == "running" {
return firewallStatus{Tool: "firewalld", Active: true, PortOpen: firewalldPortOpen(path, port)}
}
}
if _, err := exec.LookPath("nft"); err == nil {
return firewallStatus{Tool: "nftables", Active: true, Message: "nftables 需要手工放行,Agent 不会自动修改规则"}
}
if _, err := exec.LookPath("iptables"); err == nil {
return firewallStatus{Tool: "iptables", Active: true, Message: "iptables 需要手工放行,Agent 不会自动修改规则"}
}
return firewallStatus{Tool: "none", Message: "未检测到主机防火墙"}
}
// parseUFWStatus 纯解析 ufw status 输出:是否启用、目标端口是否已放行。
func parseUFWStatus(output string, port int) (bool, bool) {
active := false
open := false
needle := strconv.Itoa(port)
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Status:") {
active = strings.EqualFold(strings.TrimSpace(strings.TrimPrefix(line, "Status:")), "active")
continue
}
if !strings.Contains(line, needle) || !strings.Contains(line, "ALLOW") {
continue
}
fields := strings.Fields(line)
if len(fields) > 0 && (fields[0] == needle || strings.HasPrefix(fields[0], needle+"/")) {
open = true
}
}
return active, open
}
func firewalldPortOpen(path string, port int) bool {
out, err := exec.Command(path, "--list-ports").Output()
if err != nil {
return false
}
for _, item := range strings.Fields(string(out)) {
if strings.HasPrefix(item, strconv.Itoa(port)+"/") {
return true
}
}
return false
}
func allowFirewallPort(port int) (string, error) {
status := detectFirewall(port)
switch status.Tool {
case "ufw":
out, err := exec.Command("ufw", "allow", strconv.Itoa(port)+"/tcp").CombinedOutput()
return strings.TrimSpace(string(out)), err
case "firewalld":
first, err := exec.Command("firewall-cmd", "--permanent", "--add-port="+strconv.Itoa(port)+"/tcp").CombinedOutput()
if err != nil {
return strings.TrimSpace(string(first)), err
}
second, err := exec.Command("firewall-cmd", "--reload").CombinedOutput()
return strings.TrimSpace(string(first) + "\n" + string(second)), err
case "nftables", "iptables":
return "", errors.New("当前防火墙需要手工放行,Agent 不会自动修改 nftables / iptables 规则")
default:
return "", errors.New("未检测到可自动放行的主机防火墙(仅支持 ufw / firewalld)")
}
}
+38
View File
@@ -0,0 +1,38 @@
package agent
import "testing"
func TestParseUFWStatus(t *testing.T) {
output := `Status: active
To Action From
-- ------ ----
22/tcp ALLOW Anywhere
7443/tcp ALLOW Anywhere
`
active, open := parseUFWStatus(output, 7443)
if !active || !open {
t.Fatalf("应识别出 ufw 已启用且 7443 已放行:active=%v open=%v", active, open)
}
_, open = parseUFWStatus(output, 9443)
if open {
t.Fatal("未放行的端口不应判定为已放行")
}
}
func TestParseUFWStatusInactive(t *testing.T) {
active, open := parseUFWStatus("Status: inactive\n", 7443)
if active || open {
t.Fatalf("inactive 时不应判定为启用:active=%v open=%v", active, open)
}
}
func TestCandidateHTTPSPorts(t *testing.T) {
if got := candidateHTTPSPorts(9443); len(got) != 1 || got[0] != 9443 {
t.Fatalf("显式端口应只返回该端口:%v", got)
}
got := candidateHTTPSPorts(0)
if len(got) != 3 || got[0] != 7443 || got[1] != 8443 || got[2] != 9443 {
t.Fatalf("自动模式应返回 7443/8443/9443:%v", got)
}
}
+65
View File
@@ -0,0 +1,65 @@
package agent
import (
"os"
"path/filepath"
"testing"
)
func TestReadLimitStateFile(t *testing.T) {
dir := t.TempDir()
missing, err := readLimitStateFile(filepath.Join(dir, "missing.json"))
if err != nil {
t.Fatalf("文件不存在应返回空状态: %v", err)
}
if len(missing) != 0 {
t.Fatalf("文件不存在应返回空 map,实际 %#v", missing)
}
validPath := filepath.Join(dir, "valid.json")
valid := []byte(`{"a.example.com":{"rate":10,"burst":20,"conn":5}}`)
if err := os.WriteFile(validPath, valid, 0o600); err != nil {
t.Fatal(err)
}
state, err := readLimitStateFile(validPath)
if err != nil {
t.Fatal(err)
}
got := state["a.example.com"]
if got.Rate != 10 || got.Burst != 20 || got.Conn != 5 {
t.Fatalf("解析结果不正确: %#v", got)
}
brokenPath := filepath.Join(dir, "broken.json")
broken := []byte(`{"a.example.com":`)
if err := os.WriteFile(brokenPath, broken, 0o600); err != nil {
t.Fatal(err)
}
if _, err := readLimitStateFile(brokenPath); err == nil {
t.Fatal("损坏 JSON 应返回错误")
}
after, err := os.ReadFile(brokenPath)
if err != nil {
t.Fatal(err)
}
if string(after) != string(broken) {
t.Fatalf("读取失败不应修改原文件: %q", after)
}
emptyPath := filepath.Join(dir, "empty.json")
if err := os.WriteFile(emptyPath, nil, 0o600); err != nil {
t.Fatal(err)
}
if _, err := readLimitStateFile(emptyPath); err == nil {
t.Fatal("空文件应返回错误")
}
nullPath := filepath.Join(dir, "null.json")
if err := os.WriteFile(nullPath, []byte("null\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := readLimitStateFile(nullPath); err == nil {
t.Fatal("null 内容应返回错误")
}
}
+35
View File
@@ -0,0 +1,35 @@
package agent
import "testing"
func TestValidateListenAddr(t *testing.T) {
cases := []struct {
addr string
allowRemote bool
loopback bool
wantErr bool
}{
{"127.0.0.1:7700", false, true, false},
{"localhost:7700", false, true, false},
{"[::1]:7700", false, true, false},
{":7700", false, false, true},
{"0.0.0.0:7700", false, false, true},
{"0.0.0.0:7700", true, false, false},
{"bad-address", false, false, true},
}
for _, tc := range cases {
loopback, err := validateListenAddr(tc.addr, tc.allowRemote)
if tc.wantErr {
if err == nil {
t.Fatalf("%s 应拒绝", tc.addr)
}
continue
}
if err != nil {
t.Fatalf("%s 不应报错: %v", tc.addr, err)
}
if loopback != tc.loopback {
t.Fatalf("%s loopback=%v, want %v", tc.addr, loopback, tc.loopback)
}
}
}
+40
View File
@@ -48,6 +48,46 @@ func TestNginxRouteRendersUpstream(t *testing.T) {
} }
} }
// TestNginxRouteSharedTemplateSemantics 锁定共享模板迁出后的关键语义与顺序。
func TestNginxRouteSharedTemplateSemantics(t *testing.T) {
out := nginxRoute(routeRequest{
Domain: "a.example.com",
Upstream: "127.0.0.1:8080",
TLS: true,
RedirectTLS: true,
SecurityHeaders: true,
WebSocket: true,
AllowIPs: []string{"192.168.1.0/24"},
DenyIPs: []string{"8.8.8.8"},
RateLimitRate: 10,
RateLimitBurst: 20,
ConnLimitMax: 5,
ChinaOnly: true,
}, "/etc/fonu-agent/certs/a.example.com")
for _, want := range []string{
"ssl_protocols TLSv1.2 TLSv1.3;",
`add_header Referrer-Policy "strict-origin-when-cross-origin" always;`,
"deny 8.8.8.8;",
"if ($fonu_china_client = 0) { return 403; }",
"allow 192.168.1.0/24;",
"deny all;",
"limit_conn fonu_a_example_com_conn 5;",
"limit_req zone=fonu_a_example_com_req burst=20 nodelay;",
"proxy_set_header Upgrade $http_upgrade;",
`proxy_set_header Connection "upgrade";`,
} {
if !strings.Contains(out, want) {
t.Fatalf("缺少 %q:\n%s", want, out)
}
}
denyIdx := strings.Index(out, "deny 8.8.8.8;")
allowIdx := strings.Index(out, "allow 192.168.1.0/24;")
denyAllIdx := strings.Index(out, "deny all;")
if denyIdx < 0 || allowIdx < 0 || denyAllIdx < 0 || !(denyIdx < allowIdx && allowIdx < denyAllIdx) {
t.Fatalf("allow / deny 顺序不正确:\n%s", out)
}
}
// TestFileSHA256 覆盖 frps 安装包的校验路径。 // TestFileSHA256 覆盖 frps 安装包的校验路径。
func TestFileSHA256(t *testing.T) { func TestFileSHA256(t *testing.T) {
path := filepath.Join(t.TempDir(), "payload.txt") path := filepath.Join(t.TempDir(), "payload.txt")
+29
View File
@@ -0,0 +1,29 @@
package agent
import (
"net/http"
"net/http/httptest"
"regexp"
"testing"
)
var agentRouteParamPattern = regexp.MustCompile(`\{[^}]+\}`)
// TestAgentRoutesRequireToken 遍历全部 Agent API:匿名请求必须 401,防止新增路由漏挂 auth。
func TestAgentRoutesRequireToken(t *testing.T) {
server := &Server{cfg: Config{Token: "secret-token"}}
routes := agentRoutes(server)
if len(routes) < 20 {
t.Fatalf("Agent 路由覆盖不足:%d 条", len(routes))
}
handler := server.Handler()
for _, route := range routes {
path := agentRouteParamPattern.ReplaceAllString(route.Pattern, "1")
req := httptest.NewRequest(route.Method, path, nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("路由 %s %s 匿名请求应返回 401,实际 %d", route.Method, route.Pattern, rec.Code)
}
}
}
+250 -96
View File
@@ -6,6 +6,7 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"crypto/subtle"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -26,6 +27,7 @@ import (
"time" "time"
"github.com/fonu/fonu/internal/fsutil" "github.com/fonu/fonu/internal/fsutil"
"github.com/fonu/fonu/internal/nginxtmpl"
"github.com/fonu/fonu/internal/sysinfo" "github.com/fonu/fonu/internal/sysinfo"
) )
@@ -48,11 +50,23 @@ import (
// 修掉「VPS 未安装 Nginx」告警与一键安装按钮永远不出现、以及把它误报成「配置校验未通过」的问题。 // 修掉「VPS 未安装 Nginx」告警与一键安装按钮永远不出现、以及把它误报成「配置校验未通过」的问题。
// 0.12.7:vhost 目录探测不再要求被加载文件带 .conf 后缀(Debian 的 sites-enabled/default 不带), // 0.12.7:vhost 目录探测不再要求被加载文件带 .conf 后缀(Debian 的 sites-enabled/default 不带),
// 并在失败信息里带上最后一次的具体原因与常见的显式指定目录。 // 并在失败信息里带上最后一次的具体原因与常见的显式指定目录。
const AgentVersion = "0.12.7" // 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.14.1"
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。 // Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
type Config struct { type Config struct {
ListenAddr string ListenAddr string
AllowRemote bool
Token string Token string
NginxConfDir string NginxConfDir string
NginxBinary string NginxBinary string
@@ -65,6 +79,7 @@ type Config struct {
// Server 暴露 agent HTTP 服务。 // Server 暴露 agent HTTP 服务。
type Server struct { type Server struct {
cfg Config cfg Config
listenLoopback bool
mu sync.Mutex mu sync.Mutex
// metrics 采样缓存:CPU 占用率需要两次采样,间隔内的重复请求直接复用上次结果 // metrics 采样缓存:CPU 占用率需要两次采样,间隔内的重复请求直接复用上次结果
metricsMu sync.Mutex metricsMu sync.Mutex
@@ -75,6 +90,9 @@ type Server struct {
nginxStatusMu sync.Mutex nginxStatusMu sync.Mutex
nginxStatusAt time.Time nginxStatusAt time.Time
nginxStatus nginxProbe nginxStatus nginxProbe
// 测试注入钩子;生产环境为 nil,走真实 nginx 校验与重载
validateNginxFn func() error
reloadNginxFn func() error
} }
// nginxProbe Nginx 可用性探测结果。 // nginxProbe Nginx 可用性探测结果。
@@ -125,7 +143,11 @@ var domainPattern = regexp.MustCompile(`^(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])
func NewServer(cfg Config) (*Server, error) { func NewServer(cfg Config) (*Server, error) {
if strings.TrimSpace(cfg.ListenAddr) == "" { if strings.TrimSpace(cfg.ListenAddr) == "" {
cfg.ListenAddr = ":7700" cfg.ListenAddr = "127.0.0.1:7700"
}
loopback, err := validateListenAddr(cfg.ListenAddr, cfg.AllowRemote)
if err != nil {
return nil, err
} }
if strings.TrimSpace(cfg.NginxConfDir) == "" { if strings.TrimSpace(cfg.NginxConfDir) == "" {
// 目录探测失败不阻断启动:agent 照常提供状态接口,路由写入时重试探测(systemd PATH 下可能找不到 nginx,写入前会补全路径) // 目录探测失败不阻断启动:agent 照常提供状态接口,路由写入时重试探测(systemd PATH 下可能找不到 nginx,写入前会补全路径)
@@ -161,35 +183,85 @@ func NewServer(cfg Config) (*Server, error) {
return nil, fmt.Errorf("create agent directory: %w", err) return nil, fmt.Errorf("create agent directory: %w", err)
} }
} }
server := &Server{cfg: cfg} server := &Server{cfg: cfg, listenLoopback: loopback}
// 启动后台预解析目录(不阻断监听);每次启动都会用最新探测逻辑重新解析并覆盖落盘缓存, // 启动后台预解析目录(不阻断监听);每次启动都会用最新探测逻辑重新解析并覆盖落盘缓存,
// 历史错误目录的缓存会被自动修正。 // 历史错误目录的缓存会被自动修正。
server.preloadNginxConfDir() server.preloadNginxConfDir()
return server, nil return server, nil
} }
func validateListenAddr(addr string, allowRemote bool) (bool, error) {
host, _, err := net.SplitHostPort(strings.TrimSpace(addr))
if err != nil {
return false, fmt.Errorf("Agent 监听地址无效: %w", err)
}
host = strings.Trim(host, "[]")
loopback := strings.EqualFold(host, "localhost")
if ip := net.ParseIP(host); ip != nil {
loopback = ip.IsLoopback()
}
if !loopback && !allowRemote {
return false, fmt.Errorf("检测到 Agent 将监听非回环地址 %s;默认安全模式只监听 127.0.0.1:7700。如确认需要远程监听,请设置 FONU_AGENT_ALLOW_REMOTE=1", addr)
}
return loopback, nil
}
func listenScope(loopback bool) string {
if loopback {
return "loopback"
}
return "public"
}
func transportSecurity(loopback bool) string {
if loopback {
return "loopback"
}
return "plaintext-http"
}
type agentRoute struct {
Method string
Pattern string
Handler http.HandlerFunc
}
// agentRoutes 集中声明 Agent API;Handler 统一包 auth,测试据此遍历验证无 Token 必须 401。
func agentRoutes(s *Server) []agentRoute {
return []agentRoute{
{http.MethodGet, "/api/v1/ping", s.ping},
{http.MethodGet, "/api/v1/status", s.status},
{http.MethodGet, "/api/v1/routes", s.routes},
{http.MethodPut, "/api/v1/routes", s.putRoute},
{http.MethodGet, "/api/v1/routes/{domain}/conf", s.routeConf},
{http.MethodGet, "/api/v1/routes/{domain}/versions", s.routeVersions},
{http.MethodGet, "/api/v1/routes/{domain}/versions/{name}", s.routeVersion},
{http.MethodPost, "/api/v1/routes/{domain}/versions/{name}/rollback", s.rollbackRoute},
{http.MethodPut, "/api/v1/china-cidr", s.putChinaCIDR},
{http.MethodPost, "/api/v1/nginx/install", s.installNginx},
{http.MethodPut, "/api/v1/transport/https", s.putHTTPSProxy},
{http.MethodGet, "/api/v1/transport/https", s.getHTTPSProxy},
{http.MethodDelete, "/api/v1/transport/https", s.deleteHTTPSProxy},
{http.MethodGet, "/api/v1/firewall", s.firewallStatus},
{http.MethodPost, "/api/v1/firewall/allow", s.firewallAllow},
{http.MethodDelete, "/api/v1/routes/{domain}", s.deleteRoute},
{http.MethodPut, "/api/v1/frps/config", s.putFRPSConfig},
{http.MethodPost, "/api/v1/frps/install", s.installFRPS},
{http.MethodPost, "/api/v1/frps/action", s.frpsAction},
{http.MethodPost, "/api/v1/certs", s.putCertificate},
{http.MethodGet, "/api/v1/metrics", s.metrics},
{http.MethodGet, "/api/v1/logs/nginx", s.nginxLogs},
{http.MethodGet, "/api/v1/frps/config", s.frpsConfig},
{http.MethodGet, "/api/v1/certs", s.certs},
{http.MethodPost, "/api/v1/nginx/reload", s.nginxReload},
}
}
func (s *Server) Handler() http.Handler { func (s *Server) Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/ping", s.auth(s.ping)) for _, route := range agentRoutes(s) {
mux.HandleFunc("GET /api/v1/status", s.auth(s.status)) mux.HandleFunc(route.Method+" "+route.Pattern, s.auth(route.Handler))
mux.HandleFunc("GET /api/v1/routes", s.auth(s.routes)) }
mux.HandleFunc("PUT /api/v1/routes", s.auth(s.putRoute))
mux.HandleFunc("GET /api/v1/routes/{domain}/conf", s.auth(s.routeConf))
mux.HandleFunc("GET /api/v1/routes/{domain}/versions", s.auth(s.routeVersions))
mux.HandleFunc("GET /api/v1/routes/{domain}/versions/{name}", s.auth(s.routeVersion))
mux.HandleFunc("POST /api/v1/routes/{domain}/versions/{name}/rollback", s.auth(s.rollbackRoute))
mux.HandleFunc("PUT /api/v1/china-cidr", s.auth(s.putChinaCIDR))
mux.HandleFunc("POST /api/v1/nginx/install", s.auth(s.installNginx))
mux.HandleFunc("DELETE /api/v1/routes/{domain}", s.auth(s.deleteRoute))
mux.HandleFunc("PUT /api/v1/frps/config", s.auth(s.putFRPSConfig))
mux.HandleFunc("POST /api/v1/frps/install", s.auth(s.installFRPS))
mux.HandleFunc("POST /api/v1/frps/action", s.auth(s.frpsAction))
mux.HandleFunc("POST /api/v1/certs", s.auth(s.putCertificate))
mux.HandleFunc("GET /api/v1/metrics", s.auth(s.metrics))
mux.HandleFunc("GET /api/v1/logs/nginx", s.auth(s.nginxLogs))
mux.HandleFunc("GET /api/v1/frps/config", s.auth(s.frpsConfig))
mux.HandleFunc("GET /api/v1/certs", s.auth(s.certs))
mux.HandleFunc("POST /api/v1/nginx/reload", s.auth(s.nginxReload))
return mux return mux
} }
@@ -206,7 +278,8 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer "+s.cfg.Token { token := bearerToken(r)
if token == "" || !secureTokenEqual(token, s.cfg.Token) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return return
} }
@@ -214,6 +287,21 @@ func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
} }
} }
// bearerToken 解析 Authorization: Bearer <token>;格式不符时返回空串。
func bearerToken(r *http.Request) string {
header := strings.TrimSpace(r.Header.Get("Authorization"))
const prefix = "Bearer "
if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) {
return ""
}
return strings.TrimSpace(header[len(prefix):])
}
// secureTokenEqual 使用常量时间比较,避免通过响应时间推测 Token 内容。
func secureTokenEqual(got, want string) bool {
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
}
func (s *Server) ping(w http.ResponseWriter, _ *http.Request) { func (s *Server) ping(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "service": "fonu-agent"}) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "service": "fonu-agent"})
} }
@@ -222,9 +310,16 @@ func (s *Server) status(w http.ResponseWriter, _ *http.Request) {
ports := map[string]bool{"80": portListening(80), "443": portListening(443), "7000": portListening(7000), "8080": portListening(8080)} ports := map[string]bool{"80": portListening(80), "443": portListening(443), "7000": portListening(7000), "8080": portListening(8080)}
nginxProbe := s.probeNginx() nginxProbe := s.probeNginx()
frpsActive := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil frpsActive := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil
httpsState, httpsConfigured := readHTTPSProxyState(s.cfg.DataDir)
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"service": "fonu-agent", "service": "fonu-agent",
"agent_version": AgentVersion, "agent_version": AgentVersion,
"listen_addr": s.cfg.ListenAddr,
"listen_scope": listenScope(s.listenLoopback),
"remote_access_enabled": s.cfg.AllowRemote,
"transport_security": transportSecurity(s.listenLoopback),
"https_proxy_configured": httpsConfigured,
"https_proxy_port": httpsState.Port,
"nginx_binary": s.cfg.NginxBinary, "nginx_binary": s.cfg.NginxBinary,
"nginx_conf_dir": s.cfg.NginxConfDir, "nginx_conf_dir": s.cfg.NginxConfDir,
"nginx_ok": nginxProbe.OK, "nginx_ok": nginxProbe.OK,
@@ -661,7 +756,14 @@ func detectNginxConfDir(binary string) (string, error) {
if nginx == "" { if nginx == "" {
return "", fmt.Errorf("未找到可用的 nginx 可执行文件;请先在 VPS 安装 Nginx(apt install nginx / yum install nginx),或用 FONU_AGENT_NGINX_BIN 显式指定路径") return "", fmt.Errorf("未找到可用的 nginx 可执行文件;请先在 VPS 安装 Nginx(apt install nginx / yum install nginx),或用 FONU_AGENT_NGINX_BIN 显式指定路径")
} }
// 旧版 Agent 可能把 <配置>.versions 目录留在 include 范围内,先按常见目录清理,避免 nginx -T 直接失败。
for _, dir := range []string{"/etc/nginx/sites-enabled", "/etc/nginx/conf.d", "/etc/nginx"} {
migrateLegacyVersionDirs(dir)
}
out, err := exec.Command(nginx, "-T").CombinedOutput() out, err := exec.Command(nginx, "-T").CombinedOutput()
if err != nil && migrateVersionDirsFromNginxOutput(string(out)) {
out, err = exec.Command(nginx, "-T").CombinedOutput()
}
if err != nil { if err != nil {
return "", fmt.Errorf("nginx -T 执行失败: %w", err) return "", fmt.Errorf("nginx -T 执行失败: %w", err)
} }
@@ -695,6 +797,7 @@ func detectNginxConfDir(binary string) (string, error) {
}) })
var lastReason string var lastReason string
for _, dir := range dirs { for _, dir := range dirs {
migrateLegacyVersionDirs(dir)
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
lastReason = fmt.Sprintf("%s: %v", dir, err) lastReason = fmt.Sprintf("%s: %v", dir, err)
continue continue
@@ -1128,11 +1231,11 @@ func (s *Server) putChinaCIDR(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return return
} }
if err := s.validateNginx(); err != nil { if err := s.validateRouteConfig(); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "nginx config rejected: " + err.Error()}) writeJSON(w, http.StatusBadGateway, map[string]string{"error": "nginx config rejected: " + err.Error()})
return return
} }
if err := s.reloadNginx(); err != nil { if err := s.reloadRouteConfig(); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return return
} }
@@ -1210,9 +1313,70 @@ func (s *Server) routePath(domain string) string {
} }
func (s *Server) validateNginx() error { func (s *Server) validateNginx() error {
migrateLegacyVersionDirs(s.cfg.NginxConfDir)
return runCommand(s.nginxPath(), "-t") return runCommand(s.nginxPath(), "-t")
} }
func (s *Server) validateRouteConfig() error {
if s.validateNginxFn != nil {
return s.validateNginxFn()
}
return s.validateNginx()
}
func (s *Server) reloadRouteConfig() error {
if s.reloadNginxFn != nil {
return s.reloadNginxFn()
}
return s.reloadNginx()
}
// migrateLegacyVersionDirs 把 conf 目录下的 <name>.versions 目录迁移为隐藏目录。
// 旧布局会被 nginx 的 include * 当成配置文件读取,报 pread() "Is a directory" 导致 nginx -t 失败。
func migrateLegacyVersionDirs(dir string) {
dir = strings.TrimSpace(dir)
if dir == "" {
return
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, entry := range entries {
if !entry.IsDir() || entry.Name() == ".versions" || !strings.HasSuffix(entry.Name(), ".versions") {
continue
}
base := strings.TrimSuffix(filepath.Join(dir, entry.Name()), ".versions")
_ = fsutil.MigrateLegacyVersions(base)
}
}
// migrateVersionDirsFromNginxOutput 从 nginx 报错文本里解析被误当配置文件读取的 .versions 目录并迁移。
// nginx -T 在探测目录前就失败时,这是拿到具体路径的唯一线索。
func migrateVersionDirsFromNginxOutput(output string) bool {
changed := false
for _, line := range strings.Split(output, "\n") {
idx := strings.Index(line, ".versions")
if idx < 0 {
continue
}
start := strings.LastIndex(line[:idx], "\"")
if start < 0 {
start = strings.LastIndexAny(line[:idx], " '")
}
path := line[start+1 : idx+len(".versions")]
if !filepath.IsAbs(path) {
continue
}
base := strings.TrimSuffix(path, ".versions")
if err := fsutil.MigrateLegacyVersions(base); err != nil {
continue
}
changed = true
}
return changed
}
func (s *Server) reloadNginx() error { func (s *Server) reloadNginx() error {
nginx := s.nginxPath() nginx := s.nginxPath()
if nginx == "" { if nginx == "" {
@@ -1389,14 +1553,10 @@ func nginxRoute(req routeRequest, certDir string) string {
fmt.Fprintf(&b, " proxy_read_timeout %s;\n", timeout) fmt.Fprintf(&b, " proxy_read_timeout %s;\n", timeout)
} }
// 安全响应头:HSTS 仅在 HTTPS 实际启用时输出 // 安全响应头:HSTS 仅在 HTTPS 实际启用时输出
if req.SecurityHeaders { b.WriteString(nginxtmpl.ServerSecurityHeaders(nginxtmpl.SecurityHeaders{
b.WriteString(" add_header X-Content-Type-Options \"nosniff\" always;\n") Enabled: req.SecurityHeaders,
b.WriteString(" add_header X-Frame-Options \"SAMEORIGIN\" always;\n") HTTPS: tlsEnabled,
b.WriteString(" add_header Referrer-Policy \"strict-origin-when-cross-origin\" always;\n") }))
if tlsEnabled {
b.WriteString(" add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\n")
}
}
// 限流/连接数的 zone 定义在 fonuagent-limits.conf(http 上下文),此处只声明状态码 // 限流/连接数的 zone 定义在 fonuagent-limits.conf(http 上下文),此处只声明状态码
if req.RateLimitRate > 0 { if req.RateLimitRate > 0 {
b.WriteString(" limit_req_status 429;\n") b.WriteString(" limit_req_status 429;\n")
@@ -1406,26 +1566,19 @@ func nginxRoute(req routeRequest, certDir string) string {
} }
if req.WebSocket { if req.WebSocket {
b.WriteString(" proxy_http_version 1.1;\n") b.WriteString(" proxy_http_version 1.1;\n")
b.WriteString(" proxy_set_header Upgrade $http_upgrade;\n") b.WriteString(nginxtmpl.UpgradeHeaders(" ", "\"upgrade\""))
b.WriteString(" proxy_set_header Connection \"upgrade\";\n")
} }
// IP 访问控制:白名单(allow + deny all)与黑名单(deny)可同时生效; // IP 访问控制:白名单(allow + deny all)与黑名单(deny)可同时生效;
// Nginx 按出现顺序匹配,因此 allow 在前、deny 在后。 // 顺序由共享模板统一:deny → 仅大陆规则 → allow → deny all。
var acl strings.Builder chinaDeny := ""
for _, ip := range req.AllowIPs { if req.ChinaOnly {
ip = strings.TrimSpace(ip) chinaDeny = " if ($fonu_china_client = 0) { return 403; }\n"
if ip != "" {
fmt.Fprintf(&acl, " allow %s;\n", ip)
}
}
for _, ip := range req.DenyIPs {
if ip = strings.TrimSpace(ip); ip != "" {
fmt.Fprintf(&acl, " deny %s;\n", ip)
}
}
if len(req.AllowIPs) > 0 {
acl.WriteString(" deny all;\n")
} }
aclText := nginxtmpl.AccessDirectives(nginxtmpl.AccessControl{
Allow: req.AllowIPs,
Deny: req.DenyIPs,
ChinaDeny: chinaDeny,
}, " ")
// Basic Auth:密码优先用规则表单值(自定义),留空则首次自动生成并复用(htpasswd 格式); // Basic Auth:密码优先用规则表单值(自定义),留空则首次自动生成并复用(htpasswd 格式);
// 自动生成的明文由 putRoute 在响应里回一次,这里只写 auth_basic 指令,不写密码注释 // 自动生成的明文由 putRoute 在响应里回一次,这里只写 auth_basic 指令,不写密码注释
authLine := "" authLine := ""
@@ -1438,24 +1591,14 @@ func nginxRoute(req routeRequest, certDir string) string {
authLine = fmt.Sprintf(" auth_basic \"Fonu Protected\";\n auth_basic_user_file %s;\n", basicAuthFilePath(req.Domain)) authLine = fmt.Sprintf(" auth_basic \"Fonu Protected\";\n auth_basic_user_file %s;\n", basicAuthFilePath(req.Domain))
} }
} }
// 限流 / 连接数 / 仅中国大陆 IP 的 location 级指令(zone 与 geo 变量在 http 上下文定义) // 限流 / 连接数(zone 在 http 上下文定义)
var limitText strings.Builder limitText := nginxtmpl.LimitDirectives(nginxtmpl.LimitConfig{
if req.RateLimitRate > 0 { Zone: nginxtmpl.ZoneName("fonu", req.Domain),
zone := zoneKey(req.Domain) Rate: req.RateLimitRate,
if req.RateLimitBurst > 0 { Burst: req.RateLimitBurst,
fmt.Fprintf(&limitText, " limit_req zone=%s_req burst=%d nodelay;\n", zone, req.RateLimitBurst) Conn: req.ConnLimitMax,
} else { }, " ")
fmt.Fprintf(&limitText, " limit_req zone=%s_req nodelay;\n", zone) fmt.Fprintf(&b, " location / {\n%s%s%s proxy_pass http://%s;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_http_version 1.1;\n }\n}\n", aclText, authLine, limitText, req.Upstream)
}
}
if req.ConnLimitMax > 0 {
fmt.Fprintf(&limitText, " limit_conn %s_conn %d;\n", zoneKey(req.Domain), req.ConnLimitMax)
}
if req.ChinaOnly {
limitText.WriteString(" if ($fonu_china_client = 0) { return 403; }\n")
}
aclText := acl.String()
fmt.Fprintf(&b, " location / {\n%s%s%s proxy_pass http://%s;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_http_version 1.1;\n }\n}\n", aclText, authLine, limitText.String(), req.Upstream)
return b.String() return b.String()
} }
@@ -1464,16 +1607,7 @@ const agentStateDir = "/var/lib/fonu-agent"
// zoneKey 把域名转成合法的 nginx zone 名(仅保留小写字母与数字,其余换下划线)。 // zoneKey 把域名转成合法的 nginx zone 名(仅保留小写字母与数字,其余换下划线)。
func zoneKey(domain string) string { func zoneKey(domain string) string {
var b strings.Builder return nginxtmpl.ZoneName("fonu", domain)
b.WriteString("fonu_")
for _, r := range strings.ToLower(domain) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
return b.String()
} }
// limitsFilePath 返回 zone 定义文件(放在 vhost 目录,顶层指令即 http 上下文)。 // limitsFilePath 返回 zone 定义文件(放在 vhost 目录,顶层指令即 http 上下文)。
@@ -1502,14 +1636,29 @@ type routeLimit struct {
Conn int `json:"conn,omitempty"` Conn int `json:"conn,omitempty"`
} }
func readLimitState() map[string]routeLimit { func readLimitState() (map[string]routeLimit, error) {
state := map[string]routeLimit{} return readLimitStateFile(limitsStatePath())
data, err := os.ReadFile(limitsStatePath())
if err != nil {
return state
} }
_ = json.Unmarshal(data, &state)
return state func readLimitStateFile(path string) (map[string]routeLimit, error) {
state := map[string]routeLimit{}
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return state, nil
}
if err != nil {
return nil, err
}
if strings.TrimSpace(string(data)) == "" {
return nil, fmt.Errorf("限流状态文件为空: %s", path)
}
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("解析限流状态失败: %w", err)
}
if state == nil {
return nil, fmt.Errorf("限流状态文件内容为空对象: %s", path)
}
return state, nil
} }
// writeLimitState 落盘限流参数并重生成 zone 定义文件;无限流时删除该文件。 // writeLimitState 落盘限流参数并重生成 zone 定义文件;无限流时删除该文件。
@@ -1539,10 +1688,10 @@ func (s *Server) writeLimitState(state map[string]routeLimit) error {
item := state[domain] item := state[domain]
key := zoneKey(domain) key := zoneKey(domain)
if item.Rate > 0 { if item.Rate > 0 {
fmt.Fprintf(&b, "limit_req_zone $binary_remote_addr zone=%s_req:10m rate=%dr/s;\n", key, item.Rate) b.WriteString(nginxtmpl.RateLimitZone(key+"_req", item.Rate))
} }
if item.Conn > 0 { if item.Conn > 0 {
fmt.Fprintf(&b, "limit_conn_zone $binary_remote_addr zone=%s_conn:10m;\n", key) b.WriteString(nginxtmpl.ConnLimitZone(key + "_conn"))
} }
} }
return atomicWrite(path, []byte(b.String()), 0o640) return atomicWrite(path, []byte(b.String()), 0o640)
@@ -1550,7 +1699,10 @@ func (s *Server) writeLimitState(state map[string]routeLimit) error {
// applyLimitState 记录本次路由的限流参数并刷新 zone 文件(必须在写 vhost 之前完成)。 // applyLimitState 记录本次路由的限流参数并刷新 zone 文件(必须在写 vhost 之前完成)。
func (s *Server) applyLimitState(req routeRequest) error { func (s *Server) applyLimitState(req routeRequest) error {
state := readLimitState() state, err := readLimitState()
if err != nil {
return fmt.Errorf("读取限流状态失败,已保留原文件: %w", err)
}
if req.RateLimitRate > 0 || req.ConnLimitMax > 0 { if req.RateLimitRate > 0 || req.ConnLimitMax > 0 {
state[req.Domain] = routeLimit{Rate: req.RateLimitRate, Burst: req.RateLimitBurst, Conn: req.ConnLimitMax} state[req.Domain] = routeLimit{Rate: req.RateLimitRate, Burst: req.RateLimitBurst, Conn: req.ConnLimitMax}
} else { } else {
@@ -1561,7 +1713,10 @@ func (s *Server) applyLimitState(req routeRequest) error {
// clearLimitState 删除该域名的限流参数并刷新 zone 文件。 // clearLimitState 删除该域名的限流参数并刷新 zone 文件。
func (s *Server) clearLimitState(domain string) error { func (s *Server) clearLimitState(domain string) error {
state := readLimitState() state, err := readLimitState()
if err != nil {
return fmt.Errorf("读取限流状态失败,已保留原文件: %w", err)
}
if _, ok := state[domain]; !ok { if _, ok := state[domain]; !ok {
return nil return nil
} }
@@ -1571,12 +1726,11 @@ func (s *Server) clearLimitState(domain string) error {
// sslDirectives 生成证书与 TLS 协议指令;tls13Only 时只允许 TLS 1.3。 // sslDirectives 生成证书与 TLS 协议指令;tls13Only 时只允许 TLS 1.3。
func sslDirectives(certDir string, tls13Only bool) string { func sslDirectives(certDir string, tls13Only bool) string {
var b strings.Builder return nginxtmpl.SSLDirectives(nginxtmpl.TLSConfig{
fmt.Fprintf(&b, " ssl_certificate %s;\n ssl_certificate_key %s;\n", filepath.Join(certDir, "fullchain.pem"), filepath.Join(certDir, "privkey.pem")) CertPath: filepath.Join(certDir, "fullchain.pem"),
if tls13Only { KeyPath: filepath.Join(certDir, "privkey.pem"),
b.WriteString(" ssl_protocols TLSv1.3;\n") TLS13Only: tls13Only,
} })
return b.String()
} }
// writeBasicAuthSecret 用规则表单提供的密码写入 htpasswd(自定义密码场景)。 // writeBasicAuthSecret 用规则表单提供的密码写入 htpasswd(自定义密码场景)。
+317
View File
@@ -0,0 +1,317 @@
package agent
import (
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/fonu/fonu/internal/fsutil"
"github.com/fonu/fonu/internal/nginxtmpl"
)
const (
httpsProxyConfigName = "fonuagent-https.conf"
httpsProxyCertPath = "/etc/fonu-agent/tls/agent.crt"
httpsProxyKeyPath = "/etc/fonu-agent/tls/agent.key"
)
type httpsProxyRequest struct {
CertPEM string `json:"cert_pem"`
KeyPEM string `json:"key_pem"`
Port int `json:"port"`
}
type httpsProxyState struct {
Port int `json:"port"`
}
func (s *Server) putHTTPSProxy(w http.ResponseWriter, r *http.Request) {
var req httpsProxyRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if req.Port < 0 || req.Port > 65535 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "端口需在 0-65535 之间,0 表示自动选择"})
return
}
if _, err := tls.X509KeyPair([]byte(req.CertPEM), []byte(req.KeyPEM)); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "证书或私钥格式无效"})
return
}
if probe := s.probeNginx(); probe.Missing {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "VPS 未安装 Nginx,无法启用 HTTPS 直连"})
return
}
s.mu.Lock()
defer s.mu.Unlock()
port, err := s.configureHTTPSProxy(req)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "port": port, "self_check": "ok"})
}
func (s *Server) deleteHTTPSProxy(w http.ResponseWriter, _ *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.removeHTTPSProxy(); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// getHTTPSProxy 返回当前 HTTPS 反代状态与证书到期时间,供中央端同步轮换计划。
func (s *Server) getHTTPSProxy(w http.ResponseWriter, _ *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
state, enabled := readHTTPSProxyState(s.cfg.DataDir)
notAfter := ""
if t, err := readCertificateNotAfter(httpsProxyCertPath); err == nil {
notAfter = t.UTC().Format(time.RFC3339)
}
writeJSON(w, http.StatusOK, map[string]any{
"enabled": enabled,
"port": state.Port,
"not_after": notAfter,
})
}
// configureHTTPSProxy 按候选端口逐个尝试;请求端口为 0 时自动选择 7443 / 8443 / 9443。
func (s *Server) configureHTTPSProxy(req httpsProxyRequest) (int, error) {
var lastErr error
for _, port := range candidateHTTPSPorts(req.Port) {
if err := s.applyHTTPSProxy(req, port); err != nil {
lastErr = fmt.Errorf("端口 %d 不可用: %w", port, err)
continue
}
return port, nil
}
if lastErr == nil {
lastErr = errors.New("没有可用的 HTTPS 端口")
}
return 0, lastErr
}
func candidateHTTPSPorts(requested int) []int {
if requested > 0 {
return []int{requested}
}
return []int{7443, 8443, 9443}
}
func (s *Server) applyHTTPSProxy(req httpsProxyRequest, port int) error {
dir, err := s.ensureNginxDir()
if err != nil {
return err
}
tlsDir := "/etc/fonu-agent/tls"
if err := os.MkdirAll(tlsDir, 0o750); err != nil {
return err
}
if err := atomicWrite(httpsProxyCertPath, []byte(req.CertPEM), 0o640); err != nil {
return err
}
if err := atomicWrite(httpsProxyKeyPath, []byte(req.KeyPEM), 0o600); err != nil {
return err
}
path := filepath.Join(dir, httpsProxyConfigName)
previous, readErr := os.ReadFile(path)
if _, err := fsutil.BackupVersioned(path, 10); err != nil {
return err
}
content := renderHTTPSProxy(port, httpsProxyCertPath, httpsProxyKeyPath)
if err := atomicWrite(path, []byte(content), 0o640); err != nil {
return err
}
if err := s.validateNginx(); err != nil {
s.restoreHTTPSConfig(path, previous, readErr)
return fmt.Errorf("nginx config rejected: %w", err)
}
if err := s.reloadNginx(); err != nil {
s.restoreHTTPSConfig(path, previous, readErr)
return err
}
pin, err := certificatePin(req.CertPEM)
if err != nil {
return err
}
if err := s.verifyHTTPSProxy(port, pin); err != nil {
s.restoreHTTPSConfig(path, previous, readErr)
return err
}
return writeHTTPSProxyState(s.cfg.DataDir, httpsProxyState{Port: port})
}
func (s *Server) removeHTTPSProxy() error {
dir, err := s.ensureNginxDir()
if err != nil {
return err
}
path := filepath.Join(dir, httpsProxyConfigName)
previous, readErr := os.ReadFile(path)
if errors.Is(readErr, os.ErrNotExist) {
_ = os.Remove(httpsProxyStatePath(s.cfg.DataDir))
return nil
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if err := s.validateNginx(); err != nil {
s.restoreHTTPSConfig(path, previous, readErr)
return err
}
if err := s.reloadNginx(); err != nil {
s.restoreHTTPSConfig(path, previous, readErr)
return err
}
_ = os.Remove(httpsProxyStatePath(s.cfg.DataDir))
return nil
}
func (s *Server) restoreHTTPSConfig(path string, previous []byte, readErr error) {
if readErr == nil {
_ = atomicWrite(path, previous, 0o640)
} else {
_ = os.Remove(path)
}
_ = s.reloadNginx()
}
func (s *Server) ensureNginxDir() (string, error) {
dir := strings.TrimSpace(s.cfg.NginxConfDir)
if dir != "" {
return dir, nil
}
dir, err := detectNginxConfDir(s.nginxPath())
if err != nil {
return "", err
}
s.cfg.NginxConfDir = dir
return dir, nil
}
func renderHTTPSProxy(port int, certPath, keyPath string) string {
return fmt.Sprintf(`# Managed by fonu-agent. Do not edit.
server {
listen %d ssl default_server;
server_name _;
%s
location /api/v1/ {
proxy_pass http://127.0.0.1:7700;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Authorization $http_authorization;
}
}
`, port, nginxtmpl.SSLDirectives(nginxtmpl.TLSConfig{CertPath: certPath, KeyPath: keyPath}))
}
func certificatePin(certPEM string) (string, error) {
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
return "", errors.New("证书 PEM 解码失败")
}
sum := sha256.Sum256(block.Bytes)
return hex.EncodeToString(sum[:]), nil
}
// verifyHTTPSProxy 在 reload 后从本机回环地址握手,确认 7443 上真正返回的就是刚写入的证书。
// nginx reload 是异步的,这里留出短暂重试窗口,避免旧 worker 造成的瞬时误判。
func (s *Server) verifyHTTPSProxy(port int, want string) error {
deadline := time.Now().Add(5 * time.Second)
var lastErr error
for {
lastErr = probeHTTPSPin(port, want)
if lastErr == nil {
return nil
}
if time.Now().After(deadline) {
return lastErr
}
time.Sleep(500 * time.Millisecond)
}
}
func probeHTTPSPin(port int, want string) error {
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
dialer := &net.Dialer{Timeout: 3 * time.Second}
conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12})
if err != nil {
return fmt.Errorf("本机 https://%s 自检失败:%w;%s", addr, err, portListenerHint(port))
}
defer conn.Close()
certs := conn.ConnectionState().PeerCertificates
if len(certs) == 0 {
return fmt.Errorf("本机 https://%s 未返回证书;%s", addr, portListenerHint(port))
}
sum := sha256.Sum256(certs[0].Raw)
got := hex.EncodeToString(sum[:])
if !strings.EqualFold(got, want) {
return fmt.Errorf("本机 https://%s 证书指纹不匹配:期望 %s,实际 %s;%s", addr, want, got, portListenerHint(port))
}
return nil
}
// portListenerHint 把监听该端口的进程信息带进错误里,便于区分 nginx 未生效与端口被其他服务占用。
func portListenerHint(port int) string {
out, err := exec.Command("ss", "-tlnp").CombinedOutput()
if err != nil {
return "无法读取端口监听信息"
}
suffix := ":" + strconv.Itoa(port)
lines := make([]string, 0, 2)
for _, line := range strings.Split(string(out), "\n") {
if strings.Contains(line, suffix+" ") || strings.HasSuffix(strings.TrimSpace(line), suffix) {
lines = append(lines, strings.TrimSpace(line))
}
}
if len(lines) == 0 {
return "未发现监听 " + suffix + " 的进程"
}
return "监听 " + suffix + ":" + strings.Join(lines, " | ")
}
func httpsProxyStatePath(dataDir string) string {
return filepath.Join(dataDir, "https_transport.json")
}
func writeHTTPSProxyState(dataDir string, state httpsProxyState) error {
if strings.TrimSpace(dataDir) == "" {
return nil
}
data, err := json.Marshal(state)
if err != nil {
return err
}
return atomicWrite(httpsProxyStatePath(dataDir), data, 0o640)
}
func readHTTPSProxyState(dataDir string) (httpsProxyState, bool) {
data, err := os.ReadFile(httpsProxyStatePath(dataDir))
if err != nil {
return httpsProxyState{}, false
}
var state httpsProxyState
if err := json.Unmarshal(data, &state); err != nil || state.Port <= 0 {
return httpsProxyState{}, false
}
return state, true
}
+2 -2
View File
@@ -88,7 +88,7 @@ func (s *Server) rollbackRoute(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return return
} }
if err := s.reloadNginx(); err != nil { if err := s.reloadRouteConfig(); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return return
} }
@@ -107,7 +107,7 @@ func (s *Server) applyRouteContent(path string, content []byte) error {
if err := atomicWrite(path, content, 0o640); err != nil { if err := atomicWrite(path, content, 0o640); err != nil {
return err return err
} }
if err := s.validateNginx(); err != nil { if err := s.validateRouteConfig(); err != nil {
if readErr == nil { if readErr == nil {
_ = atomicWrite(path, previous, 0o640) _ = atomicWrite(path, previous, 0o640)
} else { } else {
+182
View File
@@ -0,0 +1,182 @@
package agent
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"errors"
"math/big"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/fonu/fonu/internal/fsutil"
)
func TestMigrateLegacyVersionDirs(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "fonuagent-https.conf")
legacy := path + ".versions"
if err := os.MkdirAll(legacy, 0o700); err != nil {
t.Fatal(err)
}
name := "20260101T000000.000-abcd.conf"
if err := os.WriteFile(filepath.Join(legacy, name), []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
migrateLegacyVersionDirs(dir)
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
t.Fatalf("旧版本目录应被移出 nginx include 范围:%v", err)
}
entries, err := os.ReadDir(filepath.Join(dir, ".fonuagent-https.conf.versions"))
if err != nil || len(entries) != 1 {
t.Fatalf("隐藏版本目录应保留 1 个版本:%v %v", entries, err)
}
}
func TestMigrateVersionDirsFromNginxOutput(t *testing.T) {
dir := t.TempDir()
legacy := filepath.Join(dir, "fonuagent-https.conf.versions")
if err := os.MkdirAll(legacy, 0o700); err != nil {
t.Fatal(err)
}
output := "2026/09/28 14:38:16 [crit] 6053#6053: pread() \"" + legacy + "\" failed (21: Is a directory)"
if !migrateVersionDirsFromNginxOutput(output) {
t.Fatal("应从 nginx 报错文本中识别并迁移旧版本目录")
}
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
t.Fatalf("旧版本目录应被移出 include 范围:%v", err)
}
}
func TestCertificatePin(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
block := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
got, err := certificatePin(string(block))
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(der)
if want := hex.EncodeToString(sum[:]); got != want {
t.Fatalf("certificatePin() = %s, 期望 %s", got, want)
}
}
func TestApplyRouteContentSavesPreviousVersion(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "fonu-a.example.com.conf")
if err := os.WriteFile(path, []byte("old config"), 0o640); err != nil {
t.Fatal(err)
}
server := &Server{cfg: Config{NginxConfDir: dir}, validateNginxFn: func() error { return nil }}
if err := server.applyRouteContent(path, []byte("new config")); err != nil {
t.Fatal(err)
}
current, err := os.ReadFile(path)
if err != nil || string(current) != "new config" {
t.Fatalf("当前配置未更新:%q %v", current, err)
}
versions, err := fsutil.ListVersions(path)
if err != nil || len(versions) != 1 {
t.Fatalf("写 vhost 前应保存上一版本:%v %v", versions, err)
}
previous, err := fsutil.ReadVersion(path, versions[0].Name)
if err != nil || string(previous) != "old config" {
t.Fatalf("历史版本内容不符:%q %v", previous, err)
}
}
func TestApplyRouteContentRestoresOnValidationFailure(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "fonu-a.example.com.conf")
if err := os.WriteFile(path, []byte("old config"), 0o640); err != nil {
t.Fatal(err)
}
server := &Server{
cfg: Config{NginxConfDir: dir},
validateNginxFn: func() error { return errors.New("nginx rejected") },
}
if err := server.applyRouteContent(path, []byte("new config")); err == nil {
t.Fatal("校验失败应返回错误")
}
current, err := os.ReadFile(path)
if err != nil || string(current) != "old config" {
t.Fatalf("校验失败后应恢复上一可用版本:%q %v", current, err)
}
}
func TestApplyRouteContentRemovesNewFileOnValidationFailure(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "fonu-new.example.com.conf")
server := &Server{
cfg: Config{NginxConfDir: dir},
validateNginxFn: func() error { return errors.New("nginx rejected") },
}
if err := server.applyRouteContent(path, []byte("new config")); err == nil {
t.Fatal("校验失败应返回错误")
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("首次部署失败应删除新文件,避免留下坏配置:%v", err)
}
}
func TestRollbackRouteReloadsAfterApply(t *testing.T) {
dir := t.TempDir()
reloaded := false
server := &Server{
cfg: Config{NginxConfDir: dir},
validateNginxFn: func() error { return nil },
reloadNginxFn: func() error { reloaded = true; return nil },
}
path := server.routePath("a.example.com")
if err := os.WriteFile(path, []byte("healthy"), 0o640); err != nil {
t.Fatal(err)
}
if _, err := fsutil.BackupVersioned(path, maxRouteVersions); err != nil {
t.Fatal(err)
}
versions, err := fsutil.ListVersions(path)
if err != nil || len(versions) != 1 {
t.Fatalf("准备版本失败:%v %v", versions, err)
}
if err := os.WriteFile(path, []byte("broken"), 0o640); err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/routes/{domain}/versions/{name}/rollback", server.rollbackRoute)
req := httptest.NewRequest(http.MethodPost, "/api/v1/routes/a.example.com/versions/"+versions[0].Name+"/rollback", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("回滚应成功:%d %s", rec.Code, rec.Body.String())
}
if !reloaded {
t.Fatal("回滚后应执行 nginx reload")
}
current, err := os.ReadFile(path)
if err != nil || string(current) != "healthy" {
t.Fatalf("回滚后内容不符:%q %v", current, err)
}
}
+404
View File
@@ -0,0 +1,404 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/fonu/fonu/internal/cloudflared"
)
type CloudflareHandler struct {
svc *cloudflared.Service
}
func NewCloudflareHandler(svc *cloudflared.Service) *CloudflareHandler {
return &CloudflareHandler{svc: svc}
}
func (h *CloudflareHandler) List(w http.ResponseWriter, r *http.Request) {
items, err := h.svc.List(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, items)
}
func (h *CloudflareHandler) Get(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
item, err := h.svc.Get(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *CloudflareHandler) Create(w http.ResponseWriter, r *http.Request) {
var input cloudflared.CreateInput
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&input); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
input.Network = normalizeCloudflareNetwork(input.Network)
item, err := h.svc.Create(r.Context(), input)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *CloudflareHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
var input cloudflared.CreateInput
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&input); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
input.Network = normalizeCloudflareNetwork(input.Network)
item, err := h.svc.Update(r.Context(), id, input)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *CloudflareHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
if err := h.svc.Delete(r.Context(), id); err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *CloudflareHandler) Start(w http.ResponseWriter, r *http.Request) {
h.tunnelAction(w, r, h.svc.Start)
}
func (h *CloudflareHandler) Stop(w http.ResponseWriter, r *http.Request) {
h.tunnelAction(w, r, h.svc.Stop)
}
func (h *CloudflareHandler) Restart(w http.ResponseWriter, r *http.Request) {
h.tunnelAction(w, r, h.svc.Restart)
}
func (h *CloudflareHandler) RefreshCredentials(w http.ResponseWriter, r *http.Request) {
h.tunnelAction(w, r, h.svc.RefreshCredentials)
}
func (h *CloudflareHandler) tunnelAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64) (cloudflared.Tunnel, error)) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
item, err := fn(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *CloudflareHandler) Status(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
status, err := h.svc.RuntimeStatus(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, status)
}
func (h *CloudflareHandler) Logs(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
lines, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("lines")))
output, err := h.svc.Logs(r.Context(), id, lines)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"output": output})
}
func (h *CloudflareHandler) Config(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
content, err := h.svc.PreviewConfig(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"content": content})
}
func (h *CloudflareHandler) Routes(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
routes, err := h.svc.Routes(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, routes)
}
func (h *CloudflareHandler) ReplaceRoutes(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
var request struct {
Routes []cloudflared.RouteInput `json:"routes"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
routes, err := h.svc.ReplaceRoutes(r.Context(), id, request.Routes)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, routes)
}
func (h *CloudflareHandler) Drift(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
result, err := h.svc.Drift(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *CloudflareHandler) SyncRoutes(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
if err := h.svc.PushTunnel(r.Context(), id); err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *CloudflareHandler) Diagnostics(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
result, err := h.svc.Diagnostics(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *CloudflareHandler) Preflight(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.svc.Preflight(r.Context()))
}
func (h *CloudflareHandler) TestRoute(w http.ResponseWriter, r *http.Request) {
var request struct {
Service string `json:"service"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
result, err := h.svc.TestService(r.Context(), request.Service)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *CloudflareHandler) BinaryInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"version": h.svc.BinaryVersion(),
"mirrors": cloudflared.DefaultMirrors(),
})
}
func (h *CloudflareHandler) AppSettings(w http.ResponseWriter, r *http.Request) {
settings, err := h.svc.AppSettings(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, settings)
}
func (h *CloudflareHandler) SaveAppSettings(w http.ResponseWriter, r *http.Request) {
var input cloudflared.AppSettingsInput
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&input); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
input.Network = normalizeCloudflareNetwork(input.Network)
settings, err := h.svc.SaveAppSettings(r.Context(), input)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, settings)
}
func (h *CloudflareHandler) TestToken(w http.ResponseWriter, r *http.Request) {
var request struct {
AccountID string `json:"account_id"`
Token string `json:"token"`
Kind string `json:"kind"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
message, err := h.svc.TestToken(r.Context(), request.AccountID, request.Token, request.Kind)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": message})
}
func (h *CloudflareHandler) SetEnabled(w http.ResponseWriter, r *http.Request) {
var request struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
settings, err := h.svc.SetEnabled(r.Context(), request.Enabled)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, settings)
}
func (h *CloudflareHandler) DownloadBinary(w http.ResponseWriter, r *http.Request) {
var request struct {
Mirror string `json:"mirror"`
Version string `json:"version"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
version, err := h.svc.DownloadBinaryVersion(r.Context(), request.Mirror, request.Version)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version})
}
func (h *CloudflareHandler) LatestBinary(w http.ResponseWriter, r *http.Request) {
version, err := h.svc.LatestBinaryVersion(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"version": version})
}
func (h *CloudflareHandler) RollbackBinary(w http.ResponseWriter, r *http.Request) {
version, err := h.svc.RollbackBinary()
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version})
}
func (h *CloudflareHandler) IngressCandidates(w http.ResponseWriter, r *http.Request) {
hosts, err := h.svc.IngressCandidates(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"hostnames": hosts})
}
func (h *CloudflareHandler) Zones(w http.ResponseWriter, r *http.Request) {
zones, err := h.svc.Zones(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"zones": zones})
}
func (h *CloudflareHandler) AccessStatus(w http.ResponseWriter, r *http.Request) {
hostname := strings.TrimSpace(r.URL.Query().Get("hostname"))
enabled, name, err := h.svc.AccessStatus(r.Context(), hostname)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"enabled": enabled, "name": name})
}
func normalizeCloudflareNetwork(network cloudflared.NetworkSettings) cloudflared.NetworkSettings {
defaults := cloudflared.DefaultNetworkSettings()
if strings.TrimSpace(network.TransportProtocol) == "" {
network.TransportProtocol = defaults.TransportProtocol
}
if strings.TrimSpace(network.EdgeIPVersion) == "" {
network.EdgeIPVersion = defaults.EdgeIPVersion
}
if network.HAConnections <= 0 {
network.HAConnections = defaults.HAConnections
}
if strings.TrimSpace(network.ProxyMode) == "" {
network.ProxyMode = defaults.ProxyMode
}
return network
}
+4
View File
@@ -8,6 +8,7 @@ import (
"github.com/fonu/fonu/internal/auth" "github.com/fonu/fonu/internal/auth"
"github.com/fonu/fonu/internal/backup" "github.com/fonu/fonu/internal/backup"
"github.com/fonu/fonu/internal/chinacidr" "github.com/fonu/fonu/internal/chinacidr"
"github.com/fonu/fonu/internal/cloudflared"
"github.com/fonu/fonu/internal/config" "github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/ddns" "github.com/fonu/fonu/internal/ddns"
"github.com/fonu/fonu/internal/discovery" "github.com/fonu/fonu/internal/discovery"
@@ -17,6 +18,7 @@ import (
"github.com/fonu/fonu/internal/service" "github.com/fonu/fonu/internal/service"
"github.com/fonu/fonu/internal/settings" "github.com/fonu/fonu/internal/settings"
"github.com/fonu/fonu/internal/traffic" "github.com/fonu/fonu/internal/traffic"
"github.com/fonu/fonu/internal/updater"
) )
type Deps struct { type Deps struct {
@@ -32,8 +34,10 @@ type Deps struct {
Metrics *metrics.Store Metrics *metrics.Store
Discovery *discovery.Service Discovery *discovery.Service
FRPMulti *frp.Service FRPMulti *frp.Service
Cloudflare *cloudflared.Service
Traffic *traffic.Collector Traffic *traffic.Collector
Notify *notify.Service Notify *notify.Service
Updater *updater.Client
StaticFS fs.FS StaticFS fs.FS
StartedAt string StartedAt string
} }
+135 -2
View File
@@ -4,11 +4,13 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"io" "io"
"log/slog"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/fonu/fonu/internal/auth"
"github.com/fonu/fonu/internal/frp" "github.com/fonu/fonu/internal/frp"
) )
@@ -16,6 +18,7 @@ import (
// 包装 internal/frp 的 Service(0.1.25 核心迁移版)。 // 包装 internal/frp 的 Service(0.1.25 核心迁移版)。
type FRPMultiHandler struct { type FRPMultiHandler struct {
svc *frp.Service svc *frp.Service
auth *auth.Service
} }
type frpServerRequest struct { type frpServerRequest struct {
@@ -68,8 +71,8 @@ type frpDownloadProxyRequest struct {
Proxy string `json:"proxy"` Proxy string `json:"proxy"`
} }
func NewFRPMultiHandler(svc *frp.Service) *FRPMultiHandler { func NewFRPMultiHandler(svc *frp.Service, authSvc *auth.Service) *FRPMultiHandler {
return &FRPMultiHandler{svc: svc} return &FRPMultiHandler{svc: svc, auth: authSvc}
} }
func (h *FRPMultiHandler) Status(w http.ResponseWriter, r *http.Request) { func (h *FRPMultiHandler) Status(w http.ResponseWriter, r *http.Request) {
@@ -324,6 +327,136 @@ func (h *FRPMultiHandler) AgentTest(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true}) writeJSON(w, http.StatusOK, map[string]any{"ok": true})
} }
func (h *FRPMultiHandler) AgentTunnelRestart(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
return
}
server, err := h.svc.RestartAgentTunnel(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, server)
}
func (h *FRPMultiHandler) SetAgentTransport(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
return
}
var request struct {
Transport string `json:"transport"`
Port int `json:"port"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
var server frp.Server
switch strings.TrimSpace(request.Transport) {
case "tunnel":
server, err = h.svc.SwitchAgentToTunnel(r.Context(), id)
case "https-pin":
server, err = h.svc.EnableAgentHTTPS(r.Context(), id, request.Port)
default:
writeError(r, w, http.StatusBadRequest, "不支持的传输方式")
return
}
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
h.audit(r, id, "transport", "切换为 "+server.AgentTransport)
writeJSON(w, http.StatusOK, server)
}
func (h *FRPMultiHandler) AgentTransportStatus(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
return
}
status, err := h.svc.AgentTransportStatus(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, status)
}
func (h *FRPMultiHandler) AgentTransportRotateCert(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
return
}
server, err := h.svc.RotateAgentCertificate(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
h.audit(r, id, "rotate-tls-cert", server.AgentTLSPin)
writeJSON(w, http.StatusOK, server)
}
func (h *FRPMultiHandler) AgentFirewallStatus(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
return
}
port, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("port")))
status, err := h.svc.AgentFirewallStatus(r.Context(), id, port)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, status)
}
func (h *FRPMultiHandler) AgentFirewallAllow(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
return
}
var request struct {
Port int `json:"port"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
result, err := h.svc.AllowAgentFirewallPort(r.Context(), id, request.Port)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
h.audit(r, id, "firewall-allow", strconv.Itoa(request.Port))
writeJSON(w, http.StatusOK, result)
}
// audit 记录 Agent 传输配置变更;admin_id 取自登录会话,API Token 调用没有会话时记为 0。
func (h *FRPMultiHandler) audit(r *http.Request, serverID int64, action, detail string) {
adminID := 0
if sessionID, ok := r.Context().Value(sessionContextKey).(string); ok && sessionID != "" && h.auth != nil {
if id, err := h.auth.AdminIDForSession(r.Context(), sessionID); err == nil {
adminID = id
}
}
slog.Default().Info("审计:Agent 配置变更",
"module", "AUDIT",
"action", action,
"server_id", serverID,
"admin_id", adminID,
"detail", detail,
"remote_addr", r.RemoteAddr,
)
}
func (h *FRPMultiHandler) AgentStatus(w http.ResponseWriter, r *http.Request) { func (h *FRPMultiHandler) AgentStatus(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id")) id, err := parseID(r.PathValue("id"))
if err != nil { if err != nil {
+2 -1
View File
@@ -74,7 +74,8 @@ func tokenDeniedPath(path string) bool {
if path == "/api/auth/password" || path == "/api/auth/logout" { if path == "/api/auth/password" || path == "/api/auth/logout" {
return true return true
} }
return strings.HasPrefix(path, "/api/settings/tokens") return strings.HasPrefix(path, "/api/settings/tokens") ||
strings.HasPrefix(path, "/api/system/update")
} }
func sessionIDFromContext(ctx context.Context) string { func sessionIDFromContext(ctx context.Context) string {
+17
View File
@@ -36,6 +36,8 @@ type proxyRequest struct {
Name *string `json:"name"` Name *string `json:"name"`
Remark *string `json:"remark"` // deprecated alias for name Remark *string `json:"remark"` // deprecated alias for name
Security *securityRequest `json:"security,omitempty"` Security *securityRequest `json:"security,omitempty"`
Exits []string `json:"exits"`
CFTunnelID *int64 `json:"cf_tunnel_id"`
} }
type proxyReorderRequest struct { type proxyReorderRequest struct {
@@ -80,6 +82,8 @@ func (h *ProxyHandler) Create(w http.ResponseWriter, r *http.Request) {
Enabled: boolDefault(req.Enabled, true), Enabled: boolDefault(req.Enabled, true),
Name: name, Name: name,
Security: req.securityInput(), Security: req.securityInput(),
Exits: req.Exits,
CFTunnelID: int64Default(req.CFTunnelID, 0),
} }
rule, err := h.svc.Create(r.Context(), in) rule, err := h.svc.Create(r.Context(), in)
@@ -135,6 +139,12 @@ func (h *ProxyHandler) Update(w http.ResponseWriter, r *http.Request) {
sec := req.securityInput() sec := req.securityInput()
in.Security = &sec in.Security = &sec
} }
if req.Exits != nil {
in.Exits = &req.Exits
}
if req.CFTunnelID != nil {
in.CFTunnelID = req.CFTunnelID
}
rule, err := h.svc.Update(r.Context(), id, in) rule, err := h.svc.Update(r.Context(), id, in)
if err != nil { if err != nil {
@@ -258,3 +268,10 @@ func boolDefault(v *bool, fallback bool) bool {
} }
return *v return *v
} }
func int64Default(v *int64, fallback int64) int64 {
if v == nil {
return fallback
}
return *v
}
+85 -14
View File
@@ -10,39 +10,58 @@ import (
"github.com/fonu/fonu/internal/auth" "github.com/fonu/fonu/internal/auth"
) )
// routeSpec 是一条 API 路由的注册信息;测试据此遍历全部路由验证鉴权覆盖。
type routeSpec struct {
Pattern string
Handler http.HandlerFunc
Public bool
}
func NewRouter(deps Deps) http.Handler { func NewRouter(deps Deps) http.Handler {
handler, _ := newRouterWithSpecs(deps)
return handler
}
func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
logsHandler := NewLogsHandler(deps.Config) logsHandler := NewLogsHandler(deps.Config)
r := &Router{ r := &Router{
authHandler: NewAuthHandler(deps.Auth, deps.Notify), authHandler: NewAuthHandler(deps.Auth, deps.Notify),
proxyHandler: NewProxyHandler(deps.Config, deps.Proxy, logsHandler, deps.Traffic), proxyHandler: NewProxyHandler(deps.Config, deps.Proxy, logsHandler, deps.Traffic),
nginxHandler: NewNginxHandler(deps.Proxy), nginxHandler: NewNginxHandler(deps.Proxy),
statusHandler: NewStatusHandler(deps.Config, deps.Proxy, deps.DDNS, deps.ACME, deps.StartedAt, deps.Config.NginxPIDFile), statusHandler: NewStatusHandler(deps.Config, deps.Proxy, deps.DDNS, deps.ACME, deps.Cloudflare, deps.StartedAt, deps.Config.NginxPIDFile),
ddnsHandler: NewDDNSHandler(deps.DDNS), ddnsHandler: NewDDNSHandler(deps.DDNS),
certHandler: NewCertHandler(deps.ACME), certHandler: NewCertHandler(deps.ACME),
settingsHandler: NewSettingsHandler(deps.Settings, deps.Proxy, deps.Notify), settingsHandler: NewSettingsHandler(deps.Settings, deps.Proxy, deps.Notify),
chinaCIDRHandler: NewChinaCIDRHandler(deps.ChinaCIDR), chinaCIDRHandler: NewChinaCIDRHandler(deps.ChinaCIDR),
logsHandler: logsHandler, logsHandler: logsHandler,
backupHandler: NewBackupHandler(deps.Backup, deps.Settings), backupHandler: NewBackupHandler(deps.Backup, deps.Settings),
statusPageHandler: NewStatusPageHandler(deps.Settings, deps.FRPMulti), statusPageHandler: NewStatusPageHandler(deps.Settings, deps.FRPMulti, deps.Cloudflare),
metricsHandler: NewMetricsHandler(deps.Metrics), metricsHandler: NewMetricsHandler(deps.Metrics),
discoveryHandler: NewDiscoveryHandler(deps.Discovery), discoveryHandler: NewDiscoveryHandler(deps.Discovery),
frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti), frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti, deps.Auth),
cloudflareHandler: NewCloudflareHandler(deps.Cloudflare),
updateHandler: NewUpdateHandler(deps.Updater),
authSvc: deps.Auth, authSvc: deps.Auth,
staticFS: deps.StaticFS, staticFS: deps.StaticFS,
} }
mux := http.NewServeMux() mux := http.NewServeMux()
var specs []routeSpec
mux.HandleFunc("GET /api/version", Version(deps.Config)) public := func(pattern string, handler http.HandlerFunc) {
// 唯一一个匿名只读接口:默认关闭、可设访问码、按 IP 限流 specs = append(specs, routeSpec{Pattern: pattern, Handler: handler, Public: true})
mux.HandleFunc("GET /api/status-page", r.statusPageHandler.Get)
mux.HandleFunc("GET /api/auth/status", r.authHandler.Status)
mux.HandleFunc("POST /api/auth/login", r.authHandler.Login)
mux.HandleFunc("POST /api/auth/logout", r.authHandler.Logout)
protect := func(pattern string, handler http.HandlerFunc) {
mux.Handle(pattern, SessionMiddleware(deps.Auth)(RequireAuth(deps.Auth)(http.HandlerFunc(handler))))
} }
protected := func(pattern string, handler http.HandlerFunc) {
specs = append(specs, routeSpec{Pattern: pattern, Handler: handler})
}
public("GET /api/version", Version(deps.Config))
// 唯一一个匿名只读接口:默认关闭、可设访问码、按 IP 限流
public("GET /api/status-page", r.statusPageHandler.Get)
public("GET /api/auth/status", r.authHandler.Status)
public("POST /api/auth/login", r.authHandler.Login)
public("POST /api/auth/logout", r.authHandler.Logout)
protect := protected
protect("POST /api/auth/password", r.authHandler.ChangePassword) protect("POST /api/auth/password", r.authHandler.ChangePassword)
protect("GET /api/settings/tokens", r.authHandler.ListTokens) protect("GET /api/settings/tokens", r.authHandler.ListTokens)
@@ -94,6 +113,8 @@ func NewRouter(deps Deps) http.Handler {
protect("GET /api/logs/system", r.logsHandler.System) protect("GET /api/logs/system", r.logsHandler.System)
protect("GET /api/metrics/history", r.metricsHandler.History) protect("GET /api/metrics/history", r.metricsHandler.History)
protect("GET /api/logs/stream", r.logsHandler.Stream) protect("GET /api/logs/stream", r.logsHandler.Stream)
protect("GET /api/system/update/status", r.updateHandler.Status)
protect("POST /api/system/update/apply", r.updateHandler.Apply)
protect("GET /api/backup/export", r.backupHandler.Export) protect("GET /api/backup/export", r.backupHandler.Export)
protect("POST /api/backup/restore", r.backupHandler.Restore) protect("POST /api/backup/restore", r.backupHandler.Restore)
protect("GET /api/backup/archives", r.backupHandler.ListArchives) protect("GET /api/backup/archives", r.backupHandler.ListArchives)
@@ -128,6 +149,12 @@ func NewRouter(deps Deps) http.Handler {
protect("POST /api/frpmulti/servers/{id}/agent/uninstall", frpHandler.AgentUninstall) protect("POST /api/frpmulti/servers/{id}/agent/uninstall", frpHandler.AgentUninstall)
protect("POST /api/frpmulti/servers/{id}/agent/ssh-diagnose", frpHandler.AgentSSHDiagnose) protect("POST /api/frpmulti/servers/{id}/agent/ssh-diagnose", frpHandler.AgentSSHDiagnose)
protect("POST /api/frpmulti/servers/{id}/agent/test", frpHandler.AgentTest) protect("POST /api/frpmulti/servers/{id}/agent/test", frpHandler.AgentTest)
protect("POST /api/frpmulti/servers/{id}/agent/tunnel/restart", frpHandler.AgentTunnelRestart)
protect("POST /api/frpmulti/servers/{id}/agent/transport", frpHandler.SetAgentTransport)
protect("GET /api/frpmulti/servers/{id}/agent/transport", frpHandler.AgentTransportStatus)
protect("POST /api/frpmulti/servers/{id}/agent/transport/rotate", frpHandler.AgentTransportRotateCert)
protect("GET /api/frpmulti/servers/{id}/agent/firewall", frpHandler.AgentFirewallStatus)
protect("POST /api/frpmulti/servers/{id}/agent/firewall/allow", frpHandler.AgentFirewallAllow)
protect("GET /api/frpmulti/servers/{id}/agent/status", frpHandler.AgentStatus) protect("GET /api/frpmulti/servers/{id}/agent/status", frpHandler.AgentStatus)
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)
@@ -164,15 +191,57 @@ func NewRouter(deps Deps) http.Handler {
protect("GET /api/frpmulti/config/export", frpHandler.ExportConfig) protect("GET /api/frpmulti/config/export", frpHandler.ExportConfig)
} }
if deps.Cloudflare != nil {
cf := r.cloudflareHandler
protect("GET /api/cloudflare/tunnels", cf.List)
protect("POST /api/cloudflare/tunnels", cf.Create)
protect("GET /api/cloudflare/tunnels/{id}", cf.Get)
protect("PUT /api/cloudflare/tunnels/{id}", cf.Update)
protect("DELETE /api/cloudflare/tunnels/{id}", cf.Delete)
protect("POST /api/cloudflare/tunnels/{id}/start", cf.Start)
protect("POST /api/cloudflare/tunnels/{id}/stop", cf.Stop)
protect("POST /api/cloudflare/tunnels/{id}/restart", cf.Restart)
protect("POST /api/cloudflare/tunnels/{id}/credentials/refresh", cf.RefreshCredentials)
protect("GET /api/cloudflare/tunnels/{id}/status", cf.Status)
protect("GET /api/cloudflare/tunnels/{id}/logs", cf.Logs)
protect("GET /api/cloudflare/tunnels/{id}/config", cf.Config)
protect("GET /api/cloudflare/tunnels/{id}/routes", cf.Routes)
protect("PUT /api/cloudflare/tunnels/{id}/routes", cf.ReplaceRoutes)
protect("GET /api/cloudflare/tunnels/{id}/drift", cf.Drift)
protect("POST /api/cloudflare/tunnels/{id}/sync", cf.SyncRoutes)
protect("GET /api/cloudflare/tunnels/{id}/diagnostics", cf.Diagnostics)
protect("GET /api/cloudflare/binary", cf.BinaryInfo)
protect("POST /api/cloudflare/binary/download", cf.DownloadBinary)
protect("GET /api/cloudflare/binary/latest", cf.LatestBinary)
protect("POST /api/cloudflare/binary/rollback", cf.RollbackBinary)
protect("GET /api/cloudflare/settings", cf.AppSettings)
protect("PUT /api/cloudflare/settings", cf.SaveAppSettings)
protect("POST /api/cloudflare/settings/test", cf.TestToken)
protect("POST /api/cloudflare/enabled", cf.SetEnabled)
protect("GET /api/cloudflare/ingress-candidates", cf.IngressCandidates)
protect("GET /api/cloudflare/zones", cf.Zones)
protect("GET /api/cloudflare/access-status", cf.AccessStatus)
protect("GET /api/cloudflare/preflight", cf.Preflight)
protect("POST /api/cloudflare/routes/test", cf.TestRoute)
}
if deps.StaticFS != nil { if deps.StaticFS != nil {
mux.Handle("/", r.spaHandler()) mux.Handle("/", r.spaHandler())
} }
for _, spec := range specs {
if spec.Public {
mux.HandleFunc(spec.Pattern, spec.Handler)
continue
}
mux.Handle(spec.Pattern, SessionMiddleware(deps.Auth)(RequireAuth(deps.Auth)(spec.Handler)))
}
logger := deps.Logger logger := deps.Logger
if logger == nil { if logger == nil {
logger = slog.Default() logger = slog.Default()
} }
return loggingMiddleware(logger, SessionMiddleware(deps.Auth)(mux)) return loggingMiddleware(logger, SessionMiddleware(deps.Auth)(mux)), specs
} }
type Router struct { type Router struct {
@@ -190,6 +259,8 @@ type Router struct {
metricsHandler *MetricsHandler metricsHandler *MetricsHandler
discoveryHandler *DiscoveryHandler discoveryHandler *DiscoveryHandler
frpmultiHandler *FRPMultiHandler frpmultiHandler *FRPMultiHandler
cloudflareHandler *CloudflareHandler
updateHandler *UpdateHandler
authSvc *auth.Service authSvc *auth.Service
staticFS fs.FS staticFS fs.FS
} }
+101
View File
@@ -0,0 +1,101 @@
package api
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/fonu/fonu/internal/auth"
"github.com/fonu/fonu/internal/certificate"
"github.com/fonu/fonu/internal/cloudflared"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/db"
"github.com/fonu/fonu/internal/frp"
"github.com/fonu/fonu/internal/secret"
"github.com/fonu/fonu/internal/settings"
)
var routeParamPattern = regexp.MustCompile(`\{[^}]+\}`)
// TestProtectedRoutesRequireAuth 遍历路由表:受保护路由匿名必须 401,公开路由不能被鉴权误拦。
func TestProtectedRoutesRequireAuth(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "fonu.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)
}
authSvc := auth.New(conn)
box, err := secret.NewBox("router-auth-test-key")
if err != nil {
t.Fatal(err)
}
cfg := config.Config{DataDir: dir, NginxPIDFile: filepath.Join(dir, "nginx.pid")}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
frpSvc := frp.NewService(cfg, frp.NewStore(conn), box, logger, certificate.NewStore(conn))
cfSvc := cloudflared.NewService(cfg, cloudflared.NewStore(conn), box, logger, nil)
handler, specs := newRouterWithSpecs(Deps{
Config: cfg,
Auth: authSvc,
FRPMulti: frpSvc,
Cloudflare: cfSvc,
Settings: settings.NewStore(conn),
Logger: logger,
})
protected, public := 0, 0
for _, spec := range specs {
req := httptest.NewRequest(routeMethod(spec.Pattern), sampleRoutePath(spec.Pattern), nil)
code := callRouteSafely(handler, req)
if spec.Public {
public++
if code == http.StatusUnauthorized {
t.Fatalf("公开路由 %s 不应被鉴权拦截", spec.Pattern)
}
continue
}
protected++
if code != http.StatusUnauthorized {
t.Fatalf("受保护路由 %s 匿名请求应返回 401,实际 %d", spec.Pattern, code)
}
}
if protected < 100 {
t.Fatalf("受保护路由覆盖不足:%d 条", protected)
}
if public < 4 {
t.Fatalf("公开路由覆盖不足:%d 条", public)
}
}
func routeMethod(pattern string) string {
method, _, _ := strings.Cut(pattern, " ")
return method
}
func sampleRoutePath(pattern string) string {
_, path, _ := strings.Cut(pattern, " ")
return routeParamPattern.ReplaceAllString(path, "1")
}
// callRouteSafely 在依赖缺失导致 handler panic 时返回 0,避免公开路由的构造依赖影响鉴权断言。
func callRouteSafely(handler http.Handler, req *http.Request) (code int) {
defer func() {
if recover() != nil {
code = 0
}
}()
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec.Code
}
+16 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/fonu/fonu/internal/acme" "github.com/fonu/fonu/internal/acme"
certstore "github.com/fonu/fonu/internal/certificate" certstore "github.com/fonu/fonu/internal/certificate"
"github.com/fonu/fonu/internal/cloudflared"
"github.com/fonu/fonu/internal/config" "github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/ddns" "github.com/fonu/fonu/internal/ddns"
"github.com/fonu/fonu/internal/logstore" "github.com/fonu/fonu/internal/logstore"
@@ -26,13 +27,14 @@ type StatusHandler struct {
proxySvc *service.ProxyService proxySvc *service.ProxyService
ddnsSvc *ddns.Service ddnsSvc *ddns.Service
acmeSvc *acme.Service acmeSvc *acme.Service
cloudflareSvc *cloudflared.Service
startedAt string startedAt string
nginxPIDFile string nginxPIDFile string
cpuMu sync.Mutex cpuMu sync.Mutex
cpuSample sysinfo.CPUSample cpuSample sysinfo.CPUSample
} }
func NewStatusHandler(cfg config.Config, proxySvc *service.ProxyService, ddnsSvc *ddns.Service, acmeSvc *acme.Service, startedAt, nginxPIDFile string) *StatusHandler { func NewStatusHandler(cfg config.Config, proxySvc *service.ProxyService, ddnsSvc *ddns.Service, acmeSvc *acme.Service, cloudflareSvc *cloudflared.Service, startedAt, nginxPIDFile string) *StatusHandler {
// 先采一次 CPU 计数器,使首次请求就能算出占用率(否则只能返回 0) // 先采一次 CPU 计数器,使首次请求就能算出占用率(否则只能返回 0)
_, sample := sysinfo.Read(sysinfo.CPUSample{}) _, sample := sysinfo.Read(sysinfo.CPUSample{})
return &StatusHandler{ return &StatusHandler{
@@ -40,6 +42,7 @@ func NewStatusHandler(cfg config.Config, proxySvc *service.ProxyService, ddnsSvc
proxySvc: proxySvc, proxySvc: proxySvc,
ddnsSvc: ddnsSvc, ddnsSvc: ddnsSvc,
acmeSvc: acmeSvc, acmeSvc: acmeSvc,
cloudflareSvc: cloudflareSvc,
startedAt: startedAt, startedAt: startedAt,
nginxPIDFile: nginxPIDFile, nginxPIDFile: nginxPIDFile,
cpuSample: sample, cpuSample: sample,
@@ -71,6 +74,10 @@ type statusResponse struct {
MemTotalBytes uint64 `json:"mem_total_bytes"` MemTotalBytes uint64 `json:"mem_total_bytes"`
DiskUsedBytes uint64 `json:"disk_used_bytes"` DiskUsedBytes uint64 `json:"disk_used_bytes"`
DiskTotalBytes uint64 `json:"disk_total_bytes"` DiskTotalBytes uint64 `json:"disk_total_bytes"`
CloudflareCount int `json:"cloudflare_count"`
CloudflareOnline int `json:"cloudflare_online"`
CloudflareStatus string `json:"cloudflare_status"`
CloudflareMessage string `json:"cloudflare_message,omitempty"`
StartedAt string `json:"started_at"` StartedAt string `json:"started_at"`
UptimeSeconds int64 `json:"uptime_seconds"` UptimeSeconds int64 `json:"uptime_seconds"`
} }
@@ -97,6 +104,10 @@ func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
resources, cpuSample := sysinfo.Read(h.currentCPUSample()) resources, cpuSample := sysinfo.Read(h.currentCPUSample())
h.setCPUSample(cpuSample) h.setCPUSample(cpuSample)
cfSummary := cloudflared.TunnelSummary{Status: "none", Message: "未创建隧道"}
if h.cloudflareSvc != nil {
cfSummary = h.cloudflareSvc.Summary(r.Context())
}
accessLog := filepath.Join(h.cfg.LogsDir(), "access.log") accessLog := filepath.Join(h.cfg.LogsDir(), "access.log")
total, errors, avgMs := logstore.CountTodayAccess(accessLog) total, errors, avgMs := logstore.CountTodayAccess(accessLog)
@@ -131,6 +142,10 @@ func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
MemTotalBytes: resources.MemTotalBytes, MemTotalBytes: resources.MemTotalBytes,
DiskUsedBytes: resources.DiskUsedBytes, DiskUsedBytes: resources.DiskUsedBytes,
DiskTotalBytes: resources.DiskTotalBytes, DiskTotalBytes: resources.DiskTotalBytes,
CloudflareCount: cfSummary.Count,
CloudflareOnline: cfSummary.Online,
CloudflareStatus: cfSummary.Status,
CloudflareMessage: cfSummary.Message,
StartedAt: h.startedAt, StartedAt: h.startedAt,
UptimeSeconds: int64(uptime), UptimeSeconds: int64(uptime),
}) })
+89 -10
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"net" "net"
@@ -9,6 +10,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/fonu/fonu/internal/cloudflared"
"github.com/fonu/fonu/internal/frp" "github.com/fonu/fonu/internal/frp"
"github.com/fonu/fonu/internal/notify" "github.com/fonu/fonu/internal/notify"
"github.com/fonu/fonu/internal/settings" "github.com/fonu/fonu/internal/settings"
@@ -28,13 +30,15 @@ const (
type StatusPageHandler struct { type StatusPageHandler struct {
settings *settings.Store settings *settings.Store
frp *frp.Service frp *frp.Service
cloudflare *cloudflared.Service
limiter *statusPageLimiter limiter *statusPageLimiter
} }
func NewStatusPageHandler(settingsStore *settings.Store, frpSvc *frp.Service) *StatusPageHandler { func NewStatusPageHandler(settingsStore *settings.Store, frpSvc *frp.Service, cloudflareSvc *cloudflared.Service) *StatusPageHandler {
return &StatusPageHandler{ return &StatusPageHandler{
settings: settingsStore, settings: settingsStore,
frp: frpSvc, frp: frpSvc,
cloudflare: cloudflareSvc,
limiter: newStatusPageLimiter(), limiter: newStatusPageLimiter(),
} }
} }
@@ -42,9 +46,19 @@ func NewStatusPageHandler(settingsStore *settings.Store, frpSvc *frp.Service) *S
type statusPageRoute struct { type statusPageRoute struct {
Domain string `json:"domain"` Domain string `json:"domain"`
State string `json:"state"` State string `json:"state"`
Source string `json:"source,omitempty"`
Uptime float64 `json:"uptime_pct"` Uptime float64 `json:"uptime_pct"`
DownMinutes int `json:"down_minutes"` DownMinutes int `json:"down_minutes"`
UnknownMinutes int `json:"unknown_minutes"` UnknownMinutes int `json:"unknown_minutes"`
Incidents []statusPageIncident `json:"incidents"`
}
type statusPageIncident struct {
From string `json:"from"`
To string `json:"to,omitempty"`
Reason string `json:"reason,omitempty"`
Detail string `json:"detail,omitempty"`
Minutes int `json:"minutes"`
} }
type statusPagePayload struct { type statusPagePayload struct {
@@ -70,7 +84,7 @@ func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
} }
enabled, _ := h.settings.GetBool(r.Context(), settings.KeyStatusPageEnabled) enabled, _ := h.settings.GetBool(r.Context(), settings.KeyStatusPageEnabled)
if !enabled || h.frp == nil { if !enabled || (h.frp == nil && h.cloudflare == nil) {
writeError(r, w, http.StatusNotFound, "状态页未开启") writeError(r, w, http.StatusNotFound, "状态页未开启")
return return
} }
@@ -84,17 +98,30 @@ func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
return return
} }
stats, err := h.frp.RouteUptime(r.Context(), 24*time.Hour)
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
return
}
payload := statusPagePayload{ payload := statusPagePayload{
Title: "Fonu 服务状态", Title: "Fonu 服务状态",
CodeRequired: codeRequired, CodeRequired: codeRequired,
CheckedAt: time.Now().UTC().Format(time.RFC3339), CheckedAt: time.Now().UTC().Format(time.RFC3339),
Routes: make([]statusPageRoute, 0, len(stats)), Routes: make([]statusPageRoute, 0),
}
if err := appendFRPStatus(&payload, h.frp, r.Context()); err != nil {
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
return
}
if err := appendCloudflareStatus(&payload, h.cloudflare, r.Context()); err != nil {
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
return
}
writeJSON(w, http.StatusOK, payload)
}
func appendFRPStatus(payload *statusPagePayload, svc *frp.Service, ctx context.Context) error {
if svc == nil {
return nil
}
stats, err := svc.RouteUptime(ctx, 24*time.Hour)
if err != nil {
return err
} }
for _, stat := range stats { for _, stat := range stats {
switch stat.State { switch stat.State {
@@ -108,12 +135,64 @@ func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
payload.Routes = append(payload.Routes, statusPageRoute{ payload.Routes = append(payload.Routes, statusPageRoute{
Domain: stat.Domain, Domain: stat.Domain,
State: stat.State, State: stat.State,
Source: "frp",
Uptime: stat.Uptime, Uptime: stat.Uptime,
DownMinutes: stat.DownMinutes, DownMinutes: stat.DownMinutes,
UnknownMinutes: stat.UnknownMinutes, UnknownMinutes: stat.UnknownMinutes,
Incidents: mapFRPIncidents(stat.Incidents),
}) })
} }
writeJSON(w, http.StatusOK, payload) return nil
}
func appendCloudflareStatus(payload *statusPagePayload, svc *cloudflared.Service, ctx context.Context) error {
if svc == nil {
return nil
}
stats, err := svc.HealthUptime(ctx, 24*time.Hour)
if err != nil {
return err
}
for _, stat := range stats {
switch stat.State {
case cloudflared.HealthUp:
payload.Up++
case cloudflared.HealthDown:
payload.Down++
default:
payload.Unknown++
}
payload.Routes = append(payload.Routes, statusPageRoute{
Domain: stat.Domain,
State: stat.State,
Source: "cloudflare",
Uptime: stat.Uptime,
DownMinutes: stat.DownMinutes,
UnknownMinutes: stat.UnknownMinutes,
Incidents: mapCloudflareIncidents(stat.Incidents),
})
}
return nil
}
func mapFRPIncidents(items []frp.RouteHealthIncident) []statusPageIncident {
out := make([]statusPageIncident, 0, len(items))
for _, item := range items {
out = append(out, statusPageIncident{
From: item.From, To: item.To, Reason: item.Reason, Detail: item.Detail, Minutes: item.Minutes,
})
}
return out
}
func mapCloudflareIncidents(items []cloudflared.HealthIncident) []statusPageIncident {
out := make([]statusPageIncident, 0, len(items))
for _, item := range items {
out = append(out, statusPageIncident{
From: item.From, To: item.To, Reason: item.Reason, Detail: item.Detail, Minutes: item.Minutes,
})
}
return out
} }
func hashStatusPageCode(code string) string { func hashStatusPageCode(code string) string {
+45
View File
@@ -0,0 +1,45 @@
package api
import (
"net/http"
"github.com/fonu/fonu/internal/updater"
)
type UpdateHandler struct {
client *updater.Client
}
func NewUpdateHandler(client *updater.Client) *UpdateHandler {
return &UpdateHandler{client: client}
}
func (h *UpdateHandler) Status(w http.ResponseWriter, r *http.Request) {
if h.client == nil || !h.client.Enabled() {
writeJSON(w, http.StatusOK, updater.Status{
Enabled: false,
Phase: updater.PhaseIdle,
Message: "当前部署未启用一键升级",
})
return
}
status, err := h.client.Status(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, "读取升级状态失败", err)
return
}
writeJSON(w, http.StatusOK, status)
}
func (h *UpdateHandler) Apply(w http.ResponseWriter, r *http.Request) {
if h.client == nil || !h.client.Enabled() {
writeError(r, w, http.StatusServiceUnavailable, "当前部署未启用一键升级")
return
}
status, err := h.client.Apply(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, "启动升级失败", err)
return
}
writeJSON(w, http.StatusAccepted, status)
}
+29
View File
@@ -0,0 +1,29 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestUpdateHandlerDisabled(t *testing.T) {
handler := NewUpdateHandler(nil)
statusRecorder := httptest.NewRecorder()
statusReq := httptest.NewRequest(http.MethodGet, "/api/system/update/status", nil)
handler.Status(statusRecorder, statusReq)
if statusRecorder.Code != http.StatusOK {
t.Fatalf("状态接口期望 200,实际 %d", statusRecorder.Code)
}
if body := statusRecorder.Body.String(); !strings.Contains(body, `"enabled":false`) {
t.Fatalf("未返回 enabled=false: %s", body)
}
applyRecorder := httptest.NewRecorder()
applyReq := httptest.NewRequest(http.MethodPost, "/api/system/update/apply", nil)
handler.Apply(applyRecorder, applyReq)
if applyRecorder.Code != http.StatusServiceUnavailable {
t.Fatalf("升级接口期望 503,实际 %d", applyRecorder.Code)
}
}
+16 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/fonu/fonu/internal/backup" "github.com/fonu/fonu/internal/backup"
"github.com/fonu/fonu/internal/certificate" "github.com/fonu/fonu/internal/certificate"
"github.com/fonu/fonu/internal/chinacidr" "github.com/fonu/fonu/internal/chinacidr"
"github.com/fonu/fonu/internal/cloudflared"
"github.com/fonu/fonu/internal/config" "github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/db" "github.com/fonu/fonu/internal/db"
"github.com/fonu/fonu/internal/ddns" "github.com/fonu/fonu/internal/ddns"
@@ -33,6 +34,7 @@ import (
"github.com/fonu/fonu/internal/service" "github.com/fonu/fonu/internal/service"
"github.com/fonu/fonu/internal/settings" "github.com/fonu/fonu/internal/settings"
"github.com/fonu/fonu/internal/traffic" "github.com/fonu/fonu/internal/traffic"
"github.com/fonu/fonu/internal/updater"
) )
type App struct { type App struct {
@@ -41,6 +43,7 @@ type App struct {
server *http.Server server *http.Server
nginx *nginx.Manager nginx *nginx.Manager
frpMulti *frp.Service frpMulti *frp.Service
cloudflare *cloudflared.Service
scheduler *scheduler.Scheduler scheduler *scheduler.Scheduler
shutdownFn context.CancelFunc shutdownFn context.CancelFunc
db interface{ Close() error } db interface{ Close() error }
@@ -111,8 +114,15 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
conn.Close() conn.Close()
return nil, err return nil, err
} }
cloudflareSvc := cloudflared.NewService(cfg, cloudflared.NewStore(conn), secretBox, logger, proxySvc)
if err := cloudflareSvc.EnsureDirs(); err != nil {
conn.Close()
return nil, err
}
proxySvc.SetExitSync(cloudflareSvc)
startedAt := time.Now().UTC().Format(time.RFC3339) startedAt := time.Now().UTC().Format(time.RFC3339)
trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc) trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
updaterClient := updater.NewClient(cfg.UpdaterSocket)
handler := api.NewRouter(api.Deps{ handler := api.NewRouter(api.Deps{
Config: cfg, Config: cfg,
@@ -128,7 +138,9 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
Metrics: metricsStore, Metrics: metricsStore,
Discovery: discoverySvc, Discovery: discoverySvc,
FRPMulti: frpMultiSvc, FRPMulti: frpMultiSvc,
Cloudflare: cloudflareSvc,
Traffic: trafficCollector, Traffic: trafficCollector,
Updater: updaterClient,
StaticFS: staticFS, StaticFS: staticFS,
StartedAt: startedAt, StartedAt: startedAt,
}) })
@@ -143,6 +155,9 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
frpMultiSvc.SetBaseContext(ctx) frpMultiSvc.SetBaseContext(ctx)
go frpMultiSvc.StartAgentTunnels(ctx)
go frpMultiSvc.StartAgentCertificateRenewal(ctx)
go cloudflareSvc.StartAll(ctx)
intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval) intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
if intervalMinutes <= 0 { if intervalMinutes <= 0 {
intervalMinutes = 5 intervalMinutes = 5
@@ -152,7 +167,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
chinaHours = 24 chinaHours = 24
} }
// 巡检间隔比其它任务短:「连续 N 次不通」需要足够采样次数才能判定 // 巡检间隔比其它任务短:「连续 N 次不通」需要足够采样次数才能判定
monitorChecker := monitor.New(settingsStore, notifySvc, logger, monitorSource{frp: frpMultiSvc, logger: logger, traffic: trafficCollector}, healthRecorder{frp: frpMultiSvc}, metricsStore) monitorChecker := monitor.New(settingsStore, notifySvc, logger, monitorSource{frp: frpMultiSvc, cloudflare: cloudflareSvc, logger: logger, traffic: trafficCollector}, healthRecorder{frp: frpMultiSvc}, metricsStore)
// 自动备份:启动时会先跑一轮,靠「最新备份是否在 24h 内」避免频繁重启重复备份 // 自动备份:启动时会先跑一轮,靠「最新备份是否在 24h 内」避免频繁重启重复备份
backupRunner := backup.NewAutoRunner(backupSvc, settingsStore, notifySvc, logger) backupRunner := backup.NewAutoRunner(backupSvc, settingsStore, notifySvc, logger)
sched := scheduler.New( sched := scheduler.New(
+51
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"github.com/fonu/fonu/internal/cloudflared"
"github.com/fonu/fonu/internal/frp" "github.com/fonu/fonu/internal/frp"
"github.com/fonu/fonu/internal/monitor" "github.com/fonu/fonu/internal/monitor"
"github.com/fonu/fonu/internal/traffic" "github.com/fonu/fonu/internal/traffic"
@@ -14,10 +15,60 @@ import (
// 怎么从 frp / agent 取是这里的事,两边因此互不依赖。 // 怎么从 frp / agent 取是这里的事,两边因此互不依赖。
type monitorSource struct { type monitorSource struct {
frp *frp.Service frp *frp.Service
cloudflare *cloudflared.Service
logger *slog.Logger logger *slog.Logger
traffic *traffic.Collector traffic *traffic.Collector
} }
func (s monitorSource) CloudflareRoutes(ctx context.Context) ([]monitor.CloudflareRouteStatus, error) {
if s.cloudflare == nil {
return []monitor.CloudflareRouteStatus{}, nil
}
statuses, err := s.cloudflare.HealthStatuses(ctx)
if err != nil {
return nil, err
}
out := make([]monitor.CloudflareRouteStatus, 0, len(statuses))
for _, item := range statuses {
out = append(out, monitor.CloudflareRouteStatus{
TunnelID: item.TunnelID,
Name: item.Name,
Domain: item.Domain,
State: item.State,
Reason: item.Reason,
Detail: item.Detail,
Known: item.Known,
})
}
return out, nil
}
func (s monitorSource) RecordCloudflareHealth(ctx context.Context, samples []monitor.CloudflareHealthSample) error {
if s.cloudflare == nil {
return nil
}
converted := make([]cloudflared.HealthSample, 0, len(samples))
for _, sample := range samples {
converted = append(converted, cloudflared.HealthSample{
TunnelID: sample.TunnelID,
Name: sample.Name,
Domain: sample.Domain,
State: sample.State,
Reason: sample.Reason,
Detail: sample.Detail,
})
}
return s.cloudflare.RecordHealth(ctx, converted)
}
func (s monitorSource) PruneCloudflareHealth(ctx context.Context) error {
if s.cloudflare == nil {
return nil
}
_, err := s.cloudflare.PruneHealth(ctx)
return err
}
func (s monitorSource) Routes(ctx context.Context) ([]monitor.RouteStatus, error) { func (s monitorSource) Routes(ctx context.Context) ([]monitor.RouteStatus, error) {
servers, err := s.enabledServers(ctx) servers, err := s.enabledServers(ctx)
if err != nil { if err != nil {
+1 -1
View File
@@ -59,7 +59,7 @@ func (s *Service) writeArchive(ctx context.Context, file *os.File, outPath strin
gw := gzip.NewWriter(file) gw := gzip.NewWriter(file)
tw := tar.NewWriter(gw) tw := tar.NewWriter(gw)
for _, entry := range []string{"fonu.db", "certs", "nginx"} { for _, entry := range []string{"fonu.db", "certs", "nginx", "cloudflared/tunnels"} {
if err := addToArchive(ctx, tw, s.dataDir, entry); err != nil { if err := addToArchive(ctx, tw, s.dataDir, entry); err != nil {
discard() discard()
return err return err
+40
View File
@@ -88,3 +88,43 @@ func TestRestoreAppliesFixedMode(t *testing.T) {
t.Fatalf("还原后的权限应为 %o,实际 %o", restoreFileMode, got) t.Fatalf("还原后的权限应为 %o,实际 %o", restoreFileMode, got)
} }
} }
func TestExportIncludesCloudflareTunnelConfig(t *testing.T) {
dataDir := t.TempDir()
tunnelDir := filepath.Join(dataDir, "cloudflared", "tunnels", "home")
if err := os.MkdirAll(tunnelDir, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tunnelDir, "config.yml"), []byte("tunnel: test\n"), 0o640); err != nil {
t.Fatal(err)
}
path, err := New(dataDir).Export(context.Background())
if err != nil {
t.Fatal(err)
}
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
gr, err := gzip.NewReader(file)
if err != nil {
t.Fatal(err)
}
defer gr.Close()
tr := tar.NewReader(gr)
found := false
for {
header, err := tr.Next()
if err != nil {
break
}
if header.Name == "cloudflared/tunnels/home/config.yml" {
found = true
break
}
}
if !found {
t.Fatal("备份应包含 Cloudflare 隧道配置")
}
}
+1 -1
View File
@@ -101,7 +101,7 @@ func (s *Service) Update(ctx context.Context) error {
return err return err
} }
rules, err := s.proxyStore.ListEnabled(ctx) rules, err := s.proxyStore.ListEnabledLocal(ctx)
if err != nil { if err != nil {
return err return err
} }
+208
View File
@@ -0,0 +1,208 @@
package cloudflared
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
const minBinarySize = 10 << 20
// Mirror 是 cloudflared 二进制下载镜像。
type Mirror struct {
ID string `json:"id"`
Name string `json:"name"`
Base string `json:"base"`
}
// DefaultMirrors 返回内置镜像表;Base 为空表示 GitHub 官方源。
func DefaultMirrors() []Mirror {
return []Mirror{
{ID: "official", Name: "GitHub 官方源"},
{ID: "gh-proxy-org", Name: "gh-proxy.org", Base: "https://gh-proxy.org/"},
{ID: "gh-proxy-hk", Name: "gh-proxy 香港", Base: "https://hk.gh-proxy.org/"},
{ID: "gh-proxy-cdn", Name: "gh-proxy CDN", Base: "https://cdn.gh-proxy.org/"},
{ID: "gh-proxy-edgeone", Name: "gh-proxy EdgeOne", Base: "https://edgeone.gh-proxy.org/"},
}
}
func findMirror(id string) Mirror {
id = strings.TrimSpace(id)
for _, mirror := range DefaultMirrors() {
if mirror.ID == id {
return mirror
}
}
return DefaultMirrors()[0]
}
func binaryFileName() string {
name := "cloudflared-" + runtime.GOOS + "-" + runtime.GOARCH
if runtime.GOOS == "windows" {
name += ".exe"
}
return name
}
// DownloadURL 生成指定镜像的下载地址;version 为空表示 latest。
func DownloadURL(mirror Mirror, version string) string {
official := "https://github.com/cloudflare/cloudflared/releases/latest/download/" + binaryFileName()
if strings.TrimSpace(version) != "" {
official = "https://github.com/cloudflare/cloudflared/releases/download/" + strings.TrimSpace(version) + "/" + binaryFileName()
}
if strings.TrimSpace(mirror.Base) == "" {
return official
}
return mirror.Base + official
}
func (s *Service) binaryPath() string {
name := "cloudflared"
if runtime.GOOS == "windows" {
name += ".exe"
}
return filepath.Join(s.cfg.DataDir, "cloudflared", "bin", name)
}
// BinaryVersion 返回本机 cloudflared 版本;未安装返回空串。
func (s *Service) BinaryVersion() string {
path := s.binaryPath()
if _, err := os.Stat(path); err != nil {
return ""
}
out, err := exec.Command(path, "--version").CombinedOutput()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// DownloadBinary 下载并替换本机 cloudflared 二进制。
func (s *Service) DownloadBinary(ctx context.Context, mirrorID string) (string, error) {
return s.DownloadBinaryVersion(ctx, mirrorID, "")
}
// DownloadBinaryVersion 下载指定版本;version 为空表示最新版。
func (s *Service) DownloadBinaryVersion(ctx context.Context, mirrorID, version string) (string, error) {
mirror := findMirror(mirrorID)
target := s.binaryPath()
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return "", err
}
tmp := target + ".download"
_ = os.Remove(tmp)
if err := fetchFile(ctx, DownloadURL(mirror, version), tmp); err != nil {
_ = os.Remove(tmp)
return "", err
}
info, err := os.Stat(tmp)
if err != nil || info.Size() < minBinarySize {
_ = os.Remove(tmp)
return "", fmt.Errorf("下载文件过小,可能被镜像源截断")
}
if runtime.GOOS != "windows" {
_ = os.Chmod(tmp, 0o755)
}
if out, err := exec.Command(tmp, "--version").CombinedOutput(); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("cloudflared 二进制不可执行: %s", strings.TrimSpace(string(out)))
}
previous := target + ".previous"
if _, err := os.Stat(target); err == nil {
_ = os.Remove(previous)
if err := os.Rename(target, previous); err != nil {
_ = os.Remove(tmp)
return "", err
}
}
if err := os.Rename(tmp, target); err != nil {
_ = os.Rename(previous, target)
_ = os.Remove(tmp)
return "", err
}
return s.BinaryVersion(), nil
}
// LatestBinaryVersion 查询 Cloudflare 官方 GitHub Release 的最新版本。
func (s *Service) LatestBinaryVersion(ctx context.Context) (string, error) {
client := &http.Client{Timeout: 20 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/repos/cloudflare/cloudflared/releases/latest", nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("读取最新版本失败:HTTP %d", resp.StatusCode)
}
var payload struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
return "", err
}
version := strings.TrimSpace(payload.TagName)
if version == "" {
return "", fmt.Errorf("GitHub 未返回 cloudflared 版本")
}
return version, nil
}
// RollbackBinary 恢复上一版 cloudflared 二进制。
func (s *Service) RollbackBinary() (string, error) {
target := s.binaryPath()
previous := target + ".previous"
if _, err := os.Stat(previous); err != nil {
return "", fmt.Errorf("没有可回滚的 cloudflared 版本")
}
failed := target + ".failed"
_ = os.Remove(failed)
if _, err := os.Stat(target); err == nil {
if err := os.Rename(target, failed); err != nil {
return "", err
}
}
if err := os.Rename(previous, target); err != nil {
_ = os.Rename(failed, target)
return "", err
}
_ = os.Remove(failed)
return s.BinaryVersion(), nil
}
func fetchFile(ctx context.Context, url, path string) error {
client := &http.Client{Timeout: 5 * time.Minute}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("下载 cloudflared 失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("下载 cloudflared 失败: HTTP %d", resp.StatusCode)
}
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
if _, err := io.Copy(file, resp.Body); err != nil {
return fmt.Errorf("写入 cloudflared 失败: %w", err)
}
return nil
}
+23
View File
@@ -0,0 +1,23 @@
package cloudflared
import (
"strings"
"testing"
)
func TestDownloadURL(t *testing.T) {
official := DownloadURL(Mirror{ID: "official"}, "")
if !strings.Contains(official, "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-") {
t.Fatalf("官方下载地址不正确:%s", official)
}
proxied := DownloadURL(Mirror{ID: "gh-proxy-org", Base: "https://gh-proxy.org/"}, "")
if !strings.HasPrefix(proxied, "https://gh-proxy.org/https://github.com/cloudflare/cloudflared/releases/latest/download/") {
t.Fatalf("镜像前缀拼接不正确:%s", proxied)
}
}
func TestFindMirrorFallback(t *testing.T) {
if got := findMirror("not-exist"); got.ID != "official" {
t.Fatalf("未知镜像应回退官方源:%#v", got)
}
}
+255
View File
@@ -0,0 +1,255 @@
package cloudflared
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const cloudflareAPIBase = "https://api.cloudflare.com/client/v4"
// CloudflareAPI 用 Account ID + API Token 调用 Cloudflare API。
type CloudflareAPI struct {
AccountID string
APIToken string
client *http.Client
}
func NewCloudflareAPI(accountID, apiToken string) *CloudflareAPI {
return &CloudflareAPI{
AccountID: strings.TrimSpace(accountID),
APIToken: strings.TrimSpace(apiToken),
client: &http.Client{Timeout: 30 * time.Second},
}
}
type cfAPIError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type cfEnvelope struct {
Success bool `json:"success"`
Result json.RawMessage `json:"result"`
Errors []cfAPIError `json:"errors"`
}
func (a *CloudflareAPI) do(ctx context.Context, method, path string, payload any, out any) error {
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, cloudflareAPIBase+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+a.APIToken)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := a.client.Do(req)
if err != nil {
return fmt.Errorf("请求 Cloudflare API 失败: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return err
}
var envelope cfEnvelope
if err := json.Unmarshal(data, &envelope); err != nil {
return fmt.Errorf("Cloudflare API 响应格式无效(HTTP %d)", resp.StatusCode)
}
if !envelope.Success {
message := "Cloudflare API 调用失败"
if len(envelope.Errors) > 0 && strings.TrimSpace(envelope.Errors[0].Message) != "" {
message = envelope.Errors[0].Message
}
return fmt.Errorf("%s(HTTP %d)", message, resp.StatusCode)
}
if out != nil && len(envelope.Result) > 0 {
if err := json.Unmarshal(envelope.Result, out); err != nil {
return fmt.Errorf("Cloudflare API 结果解析失败: %w", err)
}
}
return nil
}
// VerifyToken 调用账号级 tokens/verify 接口验证 API Token。
func (a *CloudflareAPI) VerifyToken(ctx context.Context) (string, error) {
if a.AccountID == "" {
return "", fmt.Errorf("请先填写 Account ID")
}
if a.APIToken == "" {
return "", fmt.Errorf("请先填写 API Token(cfat_...)")
}
var result struct {
Status string `json:"status"`
}
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/tokens/verify", nil, &result); err != nil {
return "", err
}
if result.Status != "active" {
return "", fmt.Errorf("API Token 状态为 %s,不是 active", result.Status)
}
return "API Token 有效(状态:active)", nil
}
// CreateTunnel 创建一条 config_src=local 的命名隧道,并返回隧道 ID 与凭据 token。
func (a *CloudflareAPI) CreateTunnel(ctx context.Context, name string) (tunnelID, token string, err error) {
var result struct {
ID string `json:"id"`
Token string `json:"token"`
}
payload := map[string]any{"name": name, "config_src": "local"}
if err := a.do(ctx, http.MethodPost, "/accounts/"+a.AccountID+"/cfd_tunnel", payload, &result); err != nil {
return "", "", err
}
if result.ID == "" {
return "", "", fmt.Errorf("Cloudflare 未返回隧道 ID")
}
if strings.TrimSpace(result.Token) != "" {
return result.ID, result.Token, nil
}
token, err = a.TunnelToken(ctx, result.ID)
return result.ID, token, err
}
// TunnelToken 读取指定隧道的凭据 token。
func (a *CloudflareAPI) TunnelToken(ctx context.Context, tunnelID string) (string, error) {
var token string
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"/token", nil, &token); err != nil {
return "", err
}
if strings.TrimSpace(token) == "" {
return "", fmt.Errorf("Cloudflare 未返回隧道凭据")
}
return token, nil
}
// PutIngress 把本地 ingress 推送到 Cloudflare 云端配置。
func (a *CloudflareAPI) PutIngress(ctx context.Context, tunnelID string, ingress []IngressRule) error {
return a.do(ctx, http.MethodPut, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"/configurations", map[string]any{
"config": map[string]any{"ingress": ingress},
}, nil)
}
// GetIngress 读取云端隧道的 ingress 配置。
func (a *CloudflareAPI) GetIngress(ctx context.Context, tunnelID string) ([]IngressRule, error) {
var result struct {
Config struct {
Ingress []IngressRule `json:"ingress"`
} `json:"config"`
}
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"/configurations", nil, &result); err != nil {
return nil, err
}
return result.Config.Ingress, nil
}
// DeleteTunnel 删除云端隧道。
func (a *CloudflareAPI) DeleteTunnel(ctx context.Context, tunnelID string) error {
return a.do(ctx, http.MethodDelete, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"?cascade=true", nil, nil)
}
// Zone 是 Cloudflare 的域名区域。
type Zone struct {
ID string `json:"id"`
Name string `json:"name"`
}
// ListZones 返回当前 API Token 可访问的 Zone。
func (a *CloudflareAPI) ListZones(ctx context.Context) ([]Zone, error) {
var zones []Zone
if err := a.do(ctx, http.MethodGet, "/zones?per_page=50", nil, &zones); err != nil {
return nil, err
}
return zones, nil
}
// FindZone 从 hostname 逐级向上查找所属 Zone。
func (a *CloudflareAPI) FindZone(ctx context.Context, hostname string) (string, string, error) {
labels := strings.Split(strings.Trim(hostname, "."), ".")
for i := 0; i < len(labels)-1; i++ {
candidate := strings.Join(labels[i:], ".")
var zones []Zone
if err := a.do(ctx, http.MethodGet, "/zones?name="+url.QueryEscape(candidate), nil, &zones); err != nil {
return "", "", err
}
if len(zones) > 0 {
return zones[0].ID, zones[0].Name, nil
}
}
return "", "", fmt.Errorf("未找到 %s 对应的 Cloudflare Zone;请确认该域名的根域名已加入当前 Account ID,且 API Token 的 Zone Resources 包含它,并具备 Zone:Read 权限", hostname)
}
// EnsureCNAME 幂等创建指向隧道的 CNAME 记录,并开启代理(橙云)。
// 已存在且目标一致的记录会被接管;指向其他目标的记录不会被覆盖。
func (a *CloudflareAPI) EnsureCNAME(ctx context.Context, zoneID, hostname, target string) (string, error) {
var records []struct {
ID string `json:"id"`
Content string `json:"content"`
}
path := "/zones/" + zoneID + "/dns_records?type=CNAME&name=" + url.QueryEscape(hostname)
if err := a.do(ctx, http.MethodGet, path, nil, &records); err != nil {
return "", err
}
payload := map[string]any{
"type": "CNAME", "name": hostname, "content": target, "proxied": true, "ttl": 1,
}
if len(records) > 0 {
if !strings.EqualFold(strings.TrimSpace(records[0].Content), strings.TrimSpace(target)) {
return "", fmt.Errorf("DNS 记录 %s 已指向其他目标,未自动覆盖", hostname)
}
if err := a.do(ctx, http.MethodPut, "/zones/"+zoneID+"/dns_records/"+records[0].ID, payload, nil); err != nil {
return "", err
}
return records[0].ID, nil
}
var created struct {
ID string `json:"id"`
}
if err := a.do(ctx, http.MethodPost, "/zones/"+zoneID+"/dns_records", payload, &created); err != nil {
return "", err
}
if strings.TrimSpace(created.ID) == "" {
return "", fmt.Errorf("Cloudflare 未返回 DNS 记录 ID")
}
return created.ID, nil
}
// DeleteDNSRecord 删除一条明确由 Fonu 接管的 DNS 记录。
func (a *CloudflareAPI) DeleteDNSRecord(ctx context.Context, zoneID, recordID string) error {
return a.do(ctx, http.MethodDelete, "/zones/"+zoneID+"/dns_records/"+recordID, nil, nil)
}
type accessApp struct {
ID string `json:"id"`
Name string `json:"name"`
Domain string `json:"domain"`
}
// HasAccessForHostname 只读检测域名是否已有 Access 应用;Token 无权限时返回错误,不修改任何配置。
func (a *CloudflareAPI) HasAccessForHostname(ctx context.Context, hostname string) (bool, string, error) {
var apps []accessApp
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/access/apps?per_page=100", nil, &apps); err != nil {
return false, "", err
}
wanted := strings.ToLower(strings.TrimSpace(hostname))
for _, app := range apps {
if strings.EqualFold(strings.TrimSpace(app.Domain), wanted) {
return true, app.Name, nil
}
}
return false, "", nil
}
+198
View File
@@ -0,0 +1,198 @@
package cloudflared
import (
"encoding/base64"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/fonu/fonu/internal/proxy"
)
type tokenPayload struct {
AccountTag string `json:"a"`
TunnelID string `json:"t"`
TunnelSecret string `json:"s"`
}
// DecodeToken 解析 Cloudflare Tunnel token;兼容 a/t/s 与 AccountTag/TunnelID/TunnelSecret 两种字段名。
func DecodeToken(token string) (accountTag, tunnelID, tunnelSecret string, err error) {
clean := strings.NewReplacer("\n", "", "\r", "", " ", "", "\t", "").Replace(strings.TrimSpace(token))
if clean == "" {
return "", "", "", fmt.Errorf("token 为空")
}
raw, err := decodeBase64(clean)
if err != nil {
return "", "", "", err
}
var payload tokenPayload
if err := json.Unmarshal(raw, &payload); err != nil || payload.TunnelID == "" {
var alt struct {
AccountTag string `json:"AccountTag"`
TunnelID string `json:"TunnelID"`
TunnelSecret string `json:"TunnelSecret"`
}
if err2 := json.Unmarshal(raw, &alt); err2 != nil {
return "", "", "", fmt.Errorf("token JSON 解析失败: %w", err)
}
payload.AccountTag, payload.TunnelID, payload.TunnelSecret = alt.AccountTag, alt.TunnelID, alt.TunnelSecret
}
if payload.AccountTag == "" || payload.TunnelID == "" || payload.TunnelSecret == "" {
return "", "", "", fmt.Errorf("token 缺少 AccountTag / TunnelID / TunnelSecret")
}
return payload.AccountTag, payload.TunnelID, payload.TunnelSecret, nil
}
// decodeBase64 同时兼容标准 base64 与 URL-safe base64,并兼容有无填充两种形式。
// Cloudflare 面板生成的 Tunnel Token 使用 URL-safe 编码,直接用标准 base64 会在 '-' / '_' 处失败。
func decodeBase64(input string) ([]byte, error) {
encodings := []*base64.Encoding{
base64.StdEncoding, base64.RawStdEncoding,
base64.URLEncoding, base64.RawURLEncoding,
}
for _, encoding := range encodings {
if data, err := encoding.DecodeString(input); err == nil {
return data, nil
}
}
padded := input
if remainder := len(padded) % 4; remainder != 0 {
padded += strings.Repeat("=", 4-remainder)
}
for _, encoding := range encodings {
if data, err := encoding.DecodeString(padded); err == nil {
return data, nil
}
}
return nil, fmt.Errorf("token base64 解码失败:请确认粘贴的是完整的 Cloudflare 隧道 token")
}
// CredentialsJSON 生成 cloudflared 需要的 creds.json 内容。
func CredentialsJSON(accountTag, tunnelID, tunnelSecret string) string {
data, _ := json.MarshalIndent(map[string]string{
"AccountTag": accountTag,
"TunnelID": tunnelID,
"TunnelSecret": tunnelSecret,
}, "", " ")
return string(data)
}
// BuildConfig 生成 locally-managed 的 config.yml;ingress 末条由调用方保证是 catch-all。
func BuildConfig(tunnelID, credsPath string, network NetworkSettings, ingress []IngressRule) string {
var b strings.Builder
fmt.Fprintf(&b, "tunnel: %s\n", tunnelID)
fmt.Fprintf(&b, "credentials-file: %s\n\n", credsPath)
writeNetworkSection(&b, network)
b.WriteString("ingress:\n")
for _, rule := range ingress {
if rule.Hostname != "" {
fmt.Fprintf(&b, " - hostname: %s\n", rule.Hostname)
if path := strings.TrimSpace(rule.Path); path != "" && path != "*" {
fmt.Fprintf(&b, " path: %s\n", path)
}
fmt.Fprintf(&b, " service: %s\n", rule.Service)
continue
}
fmt.Fprintf(&b, " - service: %s\n", rule.Service)
}
return b.String()
}
func writeNetworkSection(b *strings.Builder, network NetworkSettings) {
protocol := strings.TrimSpace(network.TransportProtocol)
edge := strings.TrimSpace(network.EdgeIPVersion)
hasOrigin := strings.TrimSpace(network.OriginCAPool) != "" || network.NoTLSVerify || strings.TrimSpace(network.HTTPHostHeader) != ""
if protocol != "" && protocol != "auto" {
fmt.Fprintf(b, "protocol: %s\n", protocol)
}
if edge != "" && edge != "auto" {
fmt.Fprintf(b, "edge-ip-version: \"%s\"\n", edge)
}
if network.HAConnections > 0 {
fmt.Fprintf(b, "ha-connections: %d\n", network.HAConnections)
}
if hasOrigin {
b.WriteString("originRequest:\n")
if pool := strings.TrimSpace(network.OriginCAPool); pool != "" {
fmt.Fprintf(b, " caPool: %s\n", pool)
}
if network.NoTLSVerify {
b.WriteString(" noTLSVerify: true\n")
}
if header := strings.TrimSpace(network.HTTPHostHeader); header != "" {
fmt.Fprintf(b, " httpHostHeader: %s\n", header)
}
}
if protocol != "" || edge != "" || network.HAConnections > 0 || hasOrigin {
b.WriteString("\n")
}
}
// BuildIngress 从域名列表生成 ingress,锁定三条不变式:末条 catch-all、去重、稳定排序。
func BuildIngress(hostnames []string, service string) []IngressRule {
seen := map[string]bool{}
cleaned := make([]string, 0, len(hostnames))
for _, host := range hostnames {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" || seen[host] {
continue
}
seen[host] = true
cleaned = append(cleaned, host)
}
sort.Strings(cleaned)
out := make([]IngressRule, 0, len(cleaned)+1)
for _, host := range cleaned {
out = append(out, IngressRule{Hostname: host, Service: service})
}
return append(out, IngressRule{Service: "http_status:404"})
}
// BuildIngressForRules 从反向代理规则派生 ingress,每条域名使用自己的回源地址。
func BuildIngressForRules(rules []proxy.Rule) []IngressRule {
serviceByHost := map[string]string{}
for _, rule := range rules {
if !rule.CloudflareEnabled() {
continue
}
service := strings.TrimSpace(rule.Upstream)
if service == "" {
continue
}
for _, host := range rule.Hostnames() {
serviceByHost[host] = service
}
}
hosts := make([]string, 0, len(serviceByHost))
for host := range serviceByHost {
hosts = append(hosts, host)
}
sort.Strings(hosts)
out := make([]IngressRule, 0, len(hosts)+1)
for _, host := range hosts {
out = append(out, IngressRule{Hostname: host, Service: serviceByHost[host]})
}
return append(out, IngressRule{Service: "http_status:404"})
}
// NormalizeService 生成回源 service;localhost 归一化为 127.0.0.1。
func NormalizeService(protocol, host string, port int) string {
host = strings.TrimSpace(host)
if host == "localhost" {
host = "127.0.0.1"
}
return fmt.Sprintf("%s://%s:%d", protocol, host, port)
}
// MaskSecret 只保留首尾少量字符,用于 API 回显与日志。
func MaskSecret(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if len(value) <= 8 {
return "********"
}
return value[:4] + "********" + value[len(value)-4:]
}
+138
View File
@@ -0,0 +1,138 @@
package cloudflared
import (
"bytes"
"encoding/base64"
"strings"
"testing"
"github.com/fonu/fonu/internal/proxy"
)
func TestDecodeToken(t *testing.T) {
payload := `{"a":"account-1","t":"tunnel-2","s":"secret-3"}`
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
account, tunnel, secret, err := DecodeToken(encoded)
if err != nil {
t.Fatal(err)
}
if account != "account-1" || tunnel != "tunnel-2" || secret != "secret-3" {
t.Fatalf("解码结果不符:%s %s %s", account, tunnel, secret)
}
}
func TestDecodeTokenAlternativeFields(t *testing.T) {
payload := `{"AccountTag":"a","TunnelID":"t","TunnelSecret":"s"}`
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
if _, _, _, err := DecodeToken(encoded); err != nil {
t.Fatalf("兼容字段名解析失败: %v", err)
}
}
func TestDecodeTokenRejectsInvalid(t *testing.T) {
if _, _, _, err := DecodeToken("not-base64"); err == nil {
t.Fatal("非法 token 应报错")
}
}
func TestDecodeTokenURLSafe(t *testing.T) {
payload := `{"a":"account-1","t":"tunnel-2","s":"secret-3"}`
encoded := base64.RawURLEncoding.EncodeToString([]byte(payload))
if _, _, _, err := DecodeToken(encoded); err != nil {
t.Fatalf("URL-safe token 应可解码: %v", err)
}
}
func TestDecodeBase64Flexible(t *testing.T) {
raw := []byte{0xfb, 0xef, 0xbe, 0xad}
for _, encoded := range []string{
base64.RawURLEncoding.EncodeToString(raw),
base64.URLEncoding.EncodeToString(raw),
base64.RawStdEncoding.EncodeToString(raw),
} {
got, err := decodeBase64(encoded)
if err != nil || !bytes.Equal(got, raw) {
t.Fatalf("编码 %q 解码失败:%v", encoded, err)
}
}
}
func TestBuildIngressInvariants(t *testing.T) {
got := BuildIngress([]string{"B.example.com", "a.example.com", "a.example.com", " "}, "http://127.0.0.1:80")
if len(got) != 3 {
t.Fatalf("应生成 2 条域名 + 1 条 catch-all:%#v", got)
}
if got[0].Hostname != "a.example.com" || got[1].Hostname != "b.example.com" {
t.Fatalf("域名应去重并稳定排序:%#v", got)
}
if got[2].Hostname != "" || got[2].Service != "http_status:404" {
t.Fatalf("末条必须是 catch-all:%#v", got[2])
}
}
func TestBuildIngressForRulesUsesRuleUpstream(t *testing.T) {
rules := []proxy.Rule{
{
Enabled: true,
Upstream: "http://192.168.1.10:8080",
Hosts: []proxy.Host{{Hostname: "b.example.com"}},
Exits: []string{proxy.ExitCloudflare},
CFTunnelID: 9,
},
{
Enabled: true,
Upstream: "http://192.168.1.11:9090",
Hosts: []proxy.Host{{Hostname: "a.example.com"}},
Exits: []string{proxy.ExitLocal, proxy.ExitCloudflare},
CFTunnelID: 9,
},
}
got := BuildIngressForRules(rules)
if len(got) != 3 {
t.Fatalf("应生成 2 条域名 + catch-all:%#v", got)
}
if got[0].Hostname != "a.example.com" || got[0].Service != "http://192.168.1.11:9090" {
t.Fatalf("第一条 ingress 不符:%#v", got[0])
}
if got[1].Hostname != "b.example.com" || got[1].Service != "http://192.168.1.10:8080" {
t.Fatalf("第二条 ingress 不符:%#v", got[1])
}
if got[2].Hostname != "" || got[2].Service != "http_status:404" {
t.Fatalf("末条必须是 catch-all:%#v", got[2])
}
}
func TestBuildIngressForRulesSkipsDisabledAndManualRules(t *testing.T) {
rules := []proxy.Rule{
{Enabled: false, Upstream: "http://127.0.0.1:8080", Hosts: []proxy.Host{{Hostname: "off.example.com"}}, Exits: []string{proxy.ExitCloudflare}, CFTunnelID: 1},
{Enabled: true, Upstream: "http://127.0.0.1:8081", Hosts: []proxy.Host{{Hostname: "local.example.com"}}, Exits: []string{proxy.ExitLocal}},
}
got := BuildIngressForRules(rules)
if len(got) != 1 || got[0].Service != "http_status:404" {
t.Fatalf("停用规则和仅本机规则不应进入 Cloudflare ingress:%#v", got)
}
}
func TestBuildConfig(t *testing.T) {
config := BuildConfig("tunnel-1", "/data/creds.json", DefaultNetworkSettings(), BuildIngress([]string{"a.example.com"}, "http://127.0.0.1:80"))
for _, want := range []string{
"tunnel: tunnel-1",
"credentials-file: /data/creds.json",
"protocol: http2",
`edge-ip-version: "4"`,
"ha-connections: 2",
"- hostname: a.example.com",
"service: http://127.0.0.1:80",
"- service: http_status:404",
} {
if !strings.Contains(config, want) {
t.Fatalf("config.yml 缺少 %q:\n%s", want, config)
}
}
}
func TestNormalizeService(t *testing.T) {
if got := NormalizeService("http", "localhost", 80); got != "http://127.0.0.1:80" {
t.Fatalf("NormalizeService() = %q", got)
}
}
+377
View File
@@ -0,0 +1,377 @@
package cloudflared
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
const (
HealthUp = "up"
HealthDown = "down"
HealthUnknown = "unknown"
HealthRetention = 30 * 24 * time.Hour
)
// HealthStatus 一条域名在当前巡检轮次中的健康快照。
type HealthStatus struct {
TunnelID int64
Name string
Domain string
State string
Reason string
Detail string
Known bool
}
// HealthSample 交给落库层的状态样本。
type HealthSample struct {
TunnelID int64
Name string
Domain string
State string
Reason string
Detail string
}
// HealthEvent 一条状态变更记录。
type HealthEvent struct {
TunnelID int64 `json:"tunnel_id"`
Domain string `json:"domain"`
State string `json:"state"`
Reason string `json:"reason,omitempty"`
Detail string `json:"detail,omitempty"`
At string `json:"at"`
}
// HealthIncident 一段 Cloudflare 隧道不可用区间。
type HealthIncident struct {
From string `json:"from"`
To string `json:"to,omitempty"`
Reason string `json:"reason,omitempty"`
Detail string `json:"detail,omitempty"`
Minutes int `json:"minutes"`
}
// UptimeStat 一条 Cloudflare 域名的可用率与不可用区间。
type UptimeStat struct {
TunnelID int64 `json:"tunnel_id"`
Domain string `json:"domain"`
State string `json:"state"`
Uptime float64 `json:"uptime_pct"`
DownMinutes int `json:"down_minutes"`
UnknownMinutes int `json:"unknown_minutes"`
CoveredMinutes int `json:"covered_minutes"`
Incidents []HealthIncident `json:"incidents"`
}
// TunnelSummary 仪表盘使用的 Cloudflare 隧道概览。
type TunnelSummary struct {
Count int
Online int
Status string
Message string
LastError string
}
// HealthStatuses 返回所有 Cloudflare 隧道域名的当前健康状态。
func (s *Service) HealthStatuses(ctx context.Context) ([]HealthStatus, error) {
settings, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return nil, err
}
tunnels, err := s.store.List(ctx)
if err != nil {
return nil, err
}
out := []HealthStatus{}
for i := range tunnels {
tunnel := tunnels[i]
s.attachConfig(&tunnel)
status := s.manager.Status(tunnel.ID)
for _, domain := range tunnel.Hostnames {
out = append(out, healthStatus(settings.Enabled, tunnel, status, domain))
}
}
return out, nil
}
func healthStatus(globalEnabled bool, tunnel Tunnel, status RuntimeStatus, domain string) HealthStatus {
item := HealthStatus{TunnelID: tunnel.ID, Name: tunnel.Name, Domain: domain, State: HealthUnknown, Known: false}
if !globalEnabled {
item.Reason = "disabled"
item.Detail = "Cloudflare 隧道全局托管已停用"
return item
}
if status.Running {
item.Known = true
if status.Status == "connected" {
item.State = HealthUp
return item
}
item.State = HealthDown
item.Reason = "connection"
item.Detail = strings.TrimSpace(status.LastError)
return item
}
if tunnel.AutoStart {
item.Known = true
item.State = HealthDown
item.Reason = "process"
item.Detail = strings.TrimSpace(status.LastError)
return item
}
item.Reason = "stopped"
item.Detail = "隧道未运行,且未启用自动启动"
return item
}
// RecordHealth 只写状态变化,避免每轮巡检都插入重复记录。
func (s *Service) RecordHealth(ctx context.Context, samples []HealthSample) error {
if len(samples) == 0 {
return nil
}
current, err := s.latestHealthStates(ctx)
if err != nil {
return err
}
now := time.Now().UTC().Format(time.RFC3339)
for _, sample := range samples {
domain := strings.ToLower(strings.TrimSpace(sample.Domain))
if sample.TunnelID <= 0 || domain == "" || sample.State == "" {
continue
}
key := healthKey(sample.TunnelID, domain)
if state, ok := current[key]; ok && state == sample.State {
continue
}
if _, err := s.store.db.ExecContext(ctx, `
INSERT INTO cf_health_events(tunnel_id, domain, state, reason, detail, at)
VALUES (?, ?, ?, ?, ?, ?)
`, sample.TunnelID, domain, sample.State, sample.Reason, sample.Detail, now); err != nil {
return err
}
}
return nil
}
func (s *Service) latestHealthStates(ctx context.Context) (map[string]string, error) {
rows, err := s.store.db.QueryContext(ctx, `
SELECT e.tunnel_id, e.domain, e.state
FROM cf_health_events e
JOIN (
SELECT tunnel_id, domain, MAX(id) AS id
FROM cf_health_events
GROUP BY tunnel_id, domain
) m ON m.id = e.id
`)
if err != nil {
return nil, err
}
defer rows.Close()
states := map[string]string{}
for rows.Next() {
var tunnelID int64
var domain, state string
if err := rows.Scan(&tunnelID, &domain, &state); err != nil {
return nil, err
}
states[healthKey(tunnelID, domain)] = state
}
return states, rows.Err()
}
// HealthUptime 返回窗口内每条 Cloudflare 域名的可用率。
func (s *Service) HealthUptime(ctx context.Context, window time.Duration) ([]UptimeStat, error) {
if window <= 0 {
window = 24 * time.Hour
}
now := time.Now().UTC()
since := now.Add(-window)
events, err := s.healthEventsSince(ctx, since)
if err != nil {
return nil, err
}
return ComputeHealthUptime(events, since, now), nil
}
func (s *Service) healthEventsSince(ctx context.Context, since time.Time) ([]HealthEvent, error) {
stamp := since.UTC().Format(time.RFC3339)
rows, err := s.store.db.QueryContext(ctx, `
SELECT tunnel_id, domain, state, COALESCE(reason, ''), COALESCE(detail, ''), at
FROM cf_health_events WHERE at >= ?
UNION ALL
SELECT e.tunnel_id, e.domain, e.state, COALESCE(e.reason, ''), COALESCE(e.detail, ''), e.at
FROM cf_health_events e
JOIN (
SELECT tunnel_id, domain, MAX(id) AS id
FROM cf_health_events WHERE at < ?
GROUP BY tunnel_id, domain
) m ON m.id = e.id
`, stamp, stamp)
if err != nil {
return nil, err
}
defer rows.Close()
events := []HealthEvent{}
for rows.Next() {
var event HealthEvent
if err := rows.Scan(&event.TunnelID, &event.Domain, &event.State, &event.Reason, &event.Detail, &event.At); err != nil {
return nil, err
}
events = append(events, event)
}
return events, rows.Err()
}
// PruneHealth 清理超过保留期的状态变更。
func (s *Service) PruneHealth(ctx context.Context) (int64, error) {
res, err := s.store.db.ExecContext(ctx, `DELETE FROM cf_health_events WHERE at < ?`,
time.Now().UTC().Add(-HealthRetention).Format(time.RFC3339))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// Summary 返回隧道数量、在线数与最近错误。
func (s *Service) Summary(ctx context.Context) TunnelSummary {
tunnels, err := s.store.List(ctx)
if err != nil {
return TunnelSummary{Status: "error", Message: err.Error()}
}
summary := TunnelSummary{Count: len(tunnels), Status: "ok"}
if len(tunnels) == 0 {
summary.Status = "none"
summary.Message = "未创建隧道"
return summary
}
for _, tunnel := range tunnels {
status := s.manager.Status(tunnel.ID)
if status.Running && status.Status == "connected" {
summary.Online++
}
if strings.TrimSpace(tunnel.LastError) != "" {
summary.LastError = tunnel.LastError
}
}
if summary.Online == 0 {
summary.Status = "error"
summary.Message = "没有在线隧道"
} else if summary.Online < summary.Count {
summary.Status = "warning"
summary.Message = fmt.Sprintf("%d/%d 条在线", summary.Online, summary.Count)
} else {
summary.Message = fmt.Sprintf("%d 条在线", summary.Online)
}
return summary
}
func healthKey(tunnelID int64, domain string) string {
return fmt.Sprintf("%d|%s", tunnelID, strings.ToLower(strings.TrimSpace(domain)))
}
// ComputeHealthUptime 与 FRP 侧同样的口径:unknown 不计入可用率分母。
type healthPoint struct {
at time.Time
tunnelID int64
domain string
state string
reason, detail string
}
func ComputeHealthUptime(events []HealthEvent, from, to time.Time) []UptimeStat {
byRule := map[string][]healthPoint{}
order := make([]string, 0, len(events))
for _, event := range events {
at, err := time.Parse(time.RFC3339, event.At)
if err != nil {
continue
}
key := healthKey(event.TunnelID, event.Domain)
if _, ok := byRule[key]; !ok {
order = append(order, key)
}
byRule[key] = append(byRule[key], healthPoint{
at: at, tunnelID: event.TunnelID, domain: event.Domain,
state: event.State, reason: event.Reason, detail: event.Detail,
})
}
sort.Strings(order)
stats := make([]UptimeStat, 0, len(order))
for _, key := range order {
points := byRule[key]
sort.Slice(points, func(i, j int) bool { return points[i].at.Before(points[j].at) })
stat := computeHealthPoints(points, from, to)
if stat.Domain != "" {
stats = append(stats, stat)
}
}
return stats
}
func computeHealthPoints(points []healthPoint, from, to time.Time) UptimeStat {
start := from
index := 0
state, reason, detail := "", "", ""
for index < len(points) && !points[index].at.After(from) {
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
if state == "" {
if index >= len(points) {
return UptimeStat{}
}
start = points[index].at
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
stat := UptimeStat{TunnelID: points[0].tunnelID, Domain: points[0].domain, State: state, Incidents: []HealthIncident{}}
upMinutes := 0
for {
end := to
ongoing := true
if index < len(points) {
end = points[index].at
ongoing = false
}
minutes := int(end.Sub(start).Minutes())
if minutes < 0 {
minutes = 0
}
switch state {
case HealthUp:
upMinutes += minutes
case HealthDown:
stat.DownMinutes += minutes
incident := HealthIncident{From: start.UTC().Format(time.RFC3339), Reason: reason, Detail: detail, Minutes: minutes}
if !ongoing {
incident.To = end.UTC().Format(time.RFC3339)
}
stat.Incidents = append(stat.Incidents, incident)
default:
stat.UnknownMinutes += minutes
}
stat.CoveredMinutes += minutes
stat.State = state
if index >= len(points) {
break
}
start = points[index].at
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
observed := upMinutes + stat.DownMinutes
if observed > 0 {
stat.Uptime = float64(upMinutes) / float64(observed) * 100
} else {
stat.Uptime = 100
if stat.UnknownMinutes > 0 {
stat.Uptime = 0
}
}
return stat
}
+57
View File
@@ -0,0 +1,57 @@
package cloudflared
import (
"context"
"io"
"log/slog"
"path/filepath"
"testing"
"time"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/db"
)
func TestComputeHealthUptimeExcludesUnknown(t *testing.T) {
from := time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC)
to := from.Add(2 * time.Hour)
events := []HealthEvent{
{TunnelID: 1, Domain: "a.example.com", State: HealthUnknown, At: from.Format(time.RFC3339)},
{TunnelID: 1, Domain: "a.example.com", State: HealthUp, At: from.Add(30 * time.Minute).Format(time.RFC3339)},
{TunnelID: 1, Domain: "a.example.com", State: HealthDown, At: from.Add(90 * time.Minute).Format(time.RFC3339)},
}
stats := ComputeHealthUptime(events, from, to)
if len(stats) != 1 {
t.Fatalf("应返回一条统计:%#v", stats)
}
if got := stats[0].Uptime; got < 66.6 || got > 66.7 || stats[0].UnknownMinutes != 30 || stats[0].DownMinutes != 30 {
t.Fatalf("可用率计算不符:%#v", stats[0])
}
}
func TestRecordHealthSkipsUnchangedState(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatal(err)
}
svc := NewService(config.Config{DataDir: dir}, NewStore(conn), nil, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
sample := []HealthSample{{TunnelID: 1, Domain: "a.example.com", State: HealthUp}}
if err := svc.RecordHealth(context.Background(), sample); err != nil {
t.Fatal(err)
}
if err := svc.RecordHealth(context.Background(), sample); err != nil {
t.Fatal(err)
}
var count int
if err := conn.QueryRow(`SELECT COUNT(*) FROM cf_health_events`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("同状态不应重复写入,实际 %d 条", count)
}
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
package cloudflared
import "syscall"
func hideWindowAttr() *syscall.SysProcAttr {
return nil
}
@@ -0,0 +1,9 @@
//go:build windows
package cloudflared
import "syscall"
func hideWindowAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{HideWindow: true}
}
+375
View File
@@ -0,0 +1,375 @@
package cloudflared
import (
"bufio"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/fonu/fonu/internal/config"
)
var metricsPortPattern = regexp.MustCompile(`(?i)starting metrics server on 127\.0\.0\.1:(\d+)/metrics`)
type processState struct {
cmd *exec.Cmd
status string
lastError string
metricsPort int
metrics RuntimeStatus
logMu sync.Mutex
}
// Manager 托管 cloudflared 子进程、日志与 metrics。
type Manager struct {
cfg config.Config
logger *slog.Logger
mu sync.Mutex
processes map[int64]*processState
}
func NewManager(cfg config.Config, logger *slog.Logger) *Manager {
return &Manager{cfg: cfg, logger: logger, processes: map[int64]*processState{}}
}
func buildRunArgs(tunnel Tunnel, configPath, token string) []string {
args := []string{"tunnel"}
if tunnel.Mode == ModeTokenRemote {
args = append(args, networkArgs(tunnel.Network)...)
return append(args, "run", "--token", token)
}
args = append(args, "--config", configPath)
args = append(args, networkArgs(tunnel.Network)...)
return append(args, "run")
}
func networkArgs(network NetworkSettings) []string {
args := []string{}
if network.HAConnections > 0 {
args = append(args, "--ha-connections", strconv.Itoa(network.HAConnections))
}
if pool := strings.TrimSpace(network.OriginCAPool); pool != "" {
args = append(args, "--origin-ca-pool", pool)
}
if network.NoTLSVerify {
args = append(args, "--no-tls-verify")
}
if header := strings.TrimSpace(network.HTTPHostHeader); header != "" {
args = append(args, "--http-host-header", header)
}
return args
}
func buildEnv(network NetworkSettings) []string {
env := os.Environ()
if protocol := strings.TrimSpace(network.TransportProtocol); protocol != "" {
env = append(env, "TUNNEL_TRANSPORT_PROTOCOL="+protocol)
}
if edge := strings.TrimSpace(network.EdgeIPVersion); edge != "" {
env = append(env, "TUNNEL_EDGE_IP_VERSION="+edge)
}
if network.ProxyMode == "disabled" {
env = removeEnv(env, []string{"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"})
}
return env
}
func removeEnv(env []string, names []string) []string {
out := make([]string, 0, len(env))
for _, item := range env {
remove := false
for _, name := range names {
if strings.HasPrefix(item, name+"=") {
remove = true
break
}
}
if !remove {
out = append(out, item)
}
}
return out
}
// Start 启动一条隧道的 cloudflared 子进程。
func (m *Manager) Start(tunnel Tunnel, binaryPath, configPath, token string) error {
m.mu.Lock()
if _, exists := m.processes[tunnel.ID]; exists {
m.mu.Unlock()
return fmt.Errorf("隧道已在运行中")
}
state := &processState{status: "starting"}
m.processes[tunnel.ID] = state
m.mu.Unlock()
logFile, err := os.OpenFile(tunnel.LogPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o640)
if err != nil {
m.remove(tunnel.ID)
return err
}
cmd := exec.Command(binaryPath, buildRunArgs(tunnel, configPath, token)...)
cmd.Env = buildEnv(tunnel.Network)
cmd.SysProcAttr = hideWindowAttr()
stdout, err := cmd.StdoutPipe()
if err != nil {
_ = logFile.Close()
m.remove(tunnel.ID)
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
_ = logFile.Close()
m.remove(tunnel.ID)
return err
}
if err := cmd.Start(); err != nil {
_ = logFile.Close()
m.remove(tunnel.ID)
return fmt.Errorf("启动 cloudflared 失败: %w", err)
}
state.cmd = cmd
go m.pipeLogs(tunnel.ID, stdout, logFile)
go m.pipeLogs(tunnel.ID, stderr, logFile)
go m.wait(tunnel.ID, cmd, logFile)
return nil
}
func (m *Manager) pipeLogs(id int64, reader io.Reader, logFile *os.File) {
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
m.writeLog(id, logFile, line)
m.detectMetrics(id, line)
}
}
func (m *Manager) writeLog(id int64, logFile *os.File, line string) {
m.mu.Lock()
state := m.processes[id]
m.mu.Unlock()
if state == nil {
return
}
state.logMu.Lock()
defer state.logMu.Unlock()
_, _ = fmt.Fprintf(logFile, "%s %s\n", time.Now().Format(time.RFC3339), line)
}
func (m *Manager) detectMetrics(id int64, line string) {
match := metricsPortPattern.FindStringSubmatch(line)
if len(match) != 2 {
return
}
port, err := strconv.Atoi(match[1])
if err != nil || port <= 0 {
return
}
m.mu.Lock()
state := m.processes[id]
if state == nil || state.metricsPort == port {
m.mu.Unlock()
return
}
state.metricsPort = port
m.mu.Unlock()
go m.scrapeMetrics(id, port)
}
func (m *Manager) scrapeMetrics(id int64, port int) {
client := &http.Client{Timeout: 5 * time.Second}
metricsURL := fmt.Sprintf("http://127.0.0.1:%d/metrics", port)
readyURL := fmt.Sprintf("http://127.0.0.1:%d/ready", port)
lastIn, lastOut := 0.0, 0.0
for {
time.Sleep(2 * time.Second)
if !m.running(id) {
return
}
text, err := fetchText(client, metricsURL)
if err != nil {
continue
}
snapshot := parseMetrics(text)
status := RuntimeStatus{
Running: true, Status: "connecting", MetricsPort: port,
Connections: snapshot.connections, LatencyMS: snapshot.latencyMS,
Transport: snapshot.transport,
}
if snapshot.hasQUIC {
status.BandwidthIn = math.Max(0, snapshot.bytesIn-lastIn) / 2
status.BandwidthOut = math.Max(0, snapshot.bytesOut-lastOut) / 2
lastIn, lastOut = snapshot.bytesIn, snapshot.bytesOut
} else {
status.BandwidthNotice = "HTTP/2 官方 metrics 不含字节指标"
}
if snapshot.connections > 0 {
status.Status = "connected"
} else if isReady(client, readyURL) {
status.Status = "reconnecting"
}
m.mu.Lock()
if state := m.processes[id]; state != nil {
state.metrics = status
state.status = status.Status
}
m.mu.Unlock()
}
}
func (m *Manager) running(id int64) bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.processes[id] != nil
}
func (m *Manager) wait(id int64, cmd *exec.Cmd, logFile *os.File) {
err := cmd.Wait()
_ = logFile.Close()
m.mu.Lock()
state := m.processes[id]
if state != nil {
if err != nil {
state.status = "failed"
state.lastError = err.Error()
} else {
state.status = "stopped"
}
}
m.mu.Unlock()
}
func (m *Manager) remove(id int64) {
m.mu.Lock()
delete(m.processes, id)
m.mu.Unlock()
}
// Stop 停止隧道子进程。
func (m *Manager) Stop(id int64) error {
m.mu.Lock()
state := m.processes[id]
delete(m.processes, id)
m.mu.Unlock()
if state == nil || state.cmd == nil || state.cmd.Process == nil {
return nil
}
_ = state.cmd.Process.Kill()
return nil
}
// Status 返回运行状态与最近一次 metrics 快照。
func (m *Manager) Status(id int64) RuntimeStatus {
m.mu.Lock()
state := m.processes[id]
m.mu.Unlock()
if state == nil {
return RuntimeStatus{Status: "stopped"}
}
out := state.metrics
out.Status = state.status
out.LastError = state.lastError
out.MetricsPort = state.metricsPort
out.Running = state.status != "stopped" && state.status != "failed"
return out
}
// Logs 读取日志文件末尾若干行。
func (m *Manager) Logs(logPath string, lines int) (string, error) {
if lines <= 0 {
lines = 200
}
data, err := os.ReadFile(logPath)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
all := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
if len(all) > lines {
all = all[len(all)-lines:]
}
return strings.Join(all, "\n"), nil
}
type metricsSnapshot struct {
connections int
latencyMS float64
bytesIn float64
bytesOut float64
transport string
hasQUIC bool
}
func parseMetrics(text string) metricsSnapshot {
snapshot := metricsSnapshot{transport: "http2"}
var rttSum, rttCount float64
for _, line := range strings.Split(text, "\n") {
switch {
case strings.HasPrefix(line, "cloudflared_tunnel_ha_connections"):
snapshot.connections = int(parseMetricValue(line))
case strings.HasPrefix(line, "quic_client_receive_bytes"):
snapshot.bytesIn += parseMetricValue(line)
snapshot.hasQUIC = true
case strings.HasPrefix(line, "quic_client_sent_bytes"):
snapshot.bytesOut += parseMetricValue(line)
snapshot.hasQUIC = true
case strings.HasPrefix(line, "quic_client_smoothed_rtt"):
rttSum += parseMetricValue(line)
rttCount++
snapshot.hasQUIC = true
}
}
if snapshot.hasQUIC {
snapshot.transport = "quic"
if rttCount > 0 {
snapshot.latencyMS = rttSum / rttCount
}
}
return snapshot
}
func parseMetricValue(line string) float64 {
fields := strings.Fields(line)
if len(fields) == 0 {
return 0
}
value, _ := strconv.ParseFloat(fields[len(fields)-1], 64)
return value
}
func fetchText(client *http.Client, url string) (string, error) {
resp, err := client.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return "", err
}
return string(data), nil
}
func isReady(client *http.Client, url string) bool {
resp, err := client.Get(url)
if err != nil {
return false
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
return resp.StatusCode == http.StatusOK
}
+59
View File
@@ -0,0 +1,59 @@
package cloudflared
import (
"strings"
"testing"
)
func TestBuildRunArgs(t *testing.T) {
local := strings.Join(buildRunArgs(Tunnel{Mode: ModeTokenLocal, Network: DefaultNetworkSettings()}, "/data/config.yml", ""), " ")
for _, want := range []string{"tunnel", "--config /data/config.yml", "--ha-connections 2", "run"} {
if !strings.Contains(local, want) {
t.Fatalf("本地规则启动参数缺少 %q:%s", want, local)
}
}
remote := strings.Join(buildRunArgs(Tunnel{Mode: ModeTokenRemote, Network: DefaultNetworkSettings()}, "", "token-123"), " ")
if strings.Contains(remote, "--config") || !strings.Contains(remote, "run --token token-123") {
t.Fatalf("云端规则模式不应使用 --config:%s", remote)
}
}
func TestParseMetrics(t *testing.T) {
text := strings.Join([]string{
"cloudflared_tunnel_ha_connections 2",
"quic_client_receive_bytes 1000",
"quic_client_sent_bytes 500",
"quic_client_smoothed_rtt 0.02",
}, "\n")
snapshot := parseMetrics(text)
if snapshot.connections != 2 || !snapshot.hasQUIC || snapshot.transport != "quic" {
t.Fatalf("QUIC metrics 解析不符:%#v", snapshot)
}
if snapshot.latencyMS <= 0 {
t.Fatalf("RTT 解析失败:%#v", snapshot)
}
}
func TestParseMetricsHTTP2(t *testing.T) {
snapshot := parseMetrics("cloudflared_tunnel_ha_connections 1")
if snapshot.hasQUIC || snapshot.transport != "http2" || snapshot.connections != 1 {
t.Fatalf("HTTP/2 metrics 解析不符:%#v", snapshot)
}
}
func TestMetricsPortPatternCaseInsensitive(t *testing.T) {
match := metricsPortPattern.FindStringSubmatch("2026-09-30T18:00:00Z INF Starting metrics server on 127.0.0.1:20241/metrics")
if len(match) != 2 || match[1] != "20241" {
t.Fatalf("应识别大写 Starting 的 metrics 端口:%#v", match)
}
}
func TestBuildEnvDisablesProxy(t *testing.T) {
t.Setenv("HTTP_PROXY", "http://127.0.0.1:8080")
env := buildEnv(NetworkSettings{ProxyMode: "disabled"})
for _, item := range env {
if strings.HasPrefix(item, "HTTP_PROXY=") {
t.Fatalf("禁用代理时不应保留 HTTP_PROXY:%v", item)
}
}
}
+111
View File
@@ -0,0 +1,111 @@
package cloudflared
import "time"
// Mode 是隧道的托管模式;不同模式决定启动参数与规则生效侧。
const (
ModeTokenLocal = "token-local" // token 解码成本地凭据,规则由 Fonu 生成
ModeTokenRemote = "token-remote" // 直接用 --token 启动,规则由 Cloudflare 面板管理
ModeAccountLocal = "account-local" // cert.pem + credentials,规则本地生成,可云端同步
)
// NetworkSettings 是 cloudflared 的网络调优参数。
type NetworkSettings struct {
TransportProtocol string `json:"transport_protocol"` // http2 / quic / auto
EdgeIPVersion string `json:"edge_ip_version"` // 4 / 6 / auto
HAConnections int `json:"ha_connections"`
ProxyMode string `json:"proxy_mode"` // system / disabled
OriginCAPool string `json:"origin_ca_pool"`
NoTLSVerify bool `json:"no_tls_verify"`
HTTPHostHeader string `json:"http_host_header"`
}
// DefaultNetworkSettings 返回国内环境更稳的默认值。
func DefaultNetworkSettings() NetworkSettings {
return NetworkSettings{
TransportProtocol: "http2",
EdgeIPVersion: "4",
HAConnections: 2,
ProxyMode: "system",
}
}
// IngressRule 对应 config.yml 的 ingress 条目。
type IngressRule struct {
Hostname string `json:"hostname,omitempty" yaml:"hostname,omitempty"`
Path string `json:"path,omitempty" yaml:"path,omitempty"`
Service string `json:"service" yaml:"service"`
}
// Tunnel 是一条 Cloudflare 隧道。
type Tunnel struct {
ID int64 `json:"id"`
Name string `json:"name"`
Mode string `json:"mode"`
TunnelID string `json:"tunnel_id"`
AccountTag string `json:"account_tag"`
CredentialsEnc string `json:"-"`
CertPEMEnc string `json:"-"`
ConfigPath string `json:"config_path"`
CredsPath string `json:"creds_path"`
LogPath string `json:"log_path"`
Network NetworkSettings `json:"network"`
Status string `json:"status"`
LastError string `json:"last_error"`
MetricsPort int `json:"metrics_port"`
AutoStart bool `json:"auto_start"`
Managed bool `json:"managed"`
Hostnames []string `json:"hostnames"`
OriginService string `json:"origin_service"`
DNSWarning string `json:"dns_warning,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreateInput 是创建 / 更新隧道时的入参。
type CreateInput struct {
Name string `json:"name"`
Mode string `json:"mode"`
Token string `json:"token"`
Hostnames []string `json:"hostnames"`
OriginService string `json:"origin_service"`
Network NetworkSettings `json:"network"`
AutoStart bool `json:"auto_start"`
Managed bool `json:"managed"`
}
// AppSettings 是模块级配置;默认 token 只回显掩码,不返回明文。
type AppSettings struct {
AccountID string `json:"account_id"`
Enabled bool `json:"enabled"`
DefaultTokenConfigured bool `json:"default_token_configured"`
DefaultTokenMasked string `json:"default_token_masked"`
APITokenConfigured bool `json:"api_token_configured"`
APITokenMasked string `json:"api_token_masked"`
Mirror string `json:"mirror"`
Network NetworkSettings `json:"network"`
}
// AppSettingsInput 是保存应用配置的入参;Token 留空表示保留原值。
type AppSettingsInput struct {
AccountID string `json:"account_id"`
Enabled *bool `json:"enabled,omitempty"`
Token string `json:"token"`
APIToken string `json:"api_token"`
Mirror string `json:"mirror"`
Network NetworkSettings `json:"network"`
}
// RuntimeStatus 是隧道运行状态与指标快照。
type RuntimeStatus struct {
Running bool `json:"running"`
Status string `json:"status"`
LastError string `json:"last_error"`
MetricsPort int `json:"metrics_port"`
Connections int `json:"connections"`
LatencyMS float64 `json:"latency_ms"`
BandwidthIn float64 `json:"bandwidth_in"`
BandwidthOut float64 `json:"bandwidth_out"`
Transport string `json:"transport"`
BandwidthNotice string `json:"bandwidth_notice,omitempty"`
}
+323
View File
@@ -0,0 +1,323 @@
package cloudflared
import (
"context"
"fmt"
"net"
"net/url"
"os"
"sort"
"strings"
"time"
)
// DriftResult 是本地规则派生 ingress 与云端配置的差异。
type DriftResult struct {
TunnelID int64 `json:"tunnel_id"`
Managed bool `json:"managed"`
HasDrift bool `json:"has_drift"`
Items []string `json:"items"`
}
// DiagnosticCheck 一项诊断结果。
type DiagnosticCheck struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
}
// DiagnosticsResult 是 Cloudflare 隧道的一键诊断结果。
type DiagnosticsResult struct {
TunnelID int64 `json:"tunnel_id"`
GeneratedAt string `json:"generated_at"`
Checks []DiagnosticCheck `json:"checks"`
}
// PreflightCheck 是接入向导的一项预检结果。
type PreflightCheck struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
Action string `json:"action,omitempty"`
}
// PreflightResult 是 Cloudflare 接入前的整体检查结果。
type PreflightResult struct {
Ready bool `json:"ready"`
Checks []PreflightCheck `json:"checks"`
}
// Preflight 检查新增 Cloudflare 出口前最容易出错的配置。
func (s *Service) Preflight(ctx context.Context) PreflightResult {
binary := s.binaryCheck()
checks := []PreflightCheck{{Name: binary.Name, Status: binary.Status, Message: binary.Message}}
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "应用配置", Status: "error", Message: err.Error()})}
}
checks = append(checks, PreflightCheck{
Name: "Account ID", Status: statusBool(strings.TrimSpace(row.AccountID) != ""), Message: firstNonEmpty(row.AccountID, "未填写 Account ID"), Action: "settings",
})
checks = append(checks, PreflightCheck{
Name: "API Token", Status: statusBool(row.APITokenEnc != ""), Message: boolText(row.APITokenEnc != "", "已保存 API Token", "未保存 API Token"), Action: "settings",
})
if row.APITokenEnc == "" {
return PreflightResult{Ready: false, Checks: checks}
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "Cloudflare API", Status: "error", Message: err.Error(), Action: "settings"})}
}
zones, err := api.ListZones(ctx)
if err != nil {
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "Zone 权限", Status: "error", Message: err.Error(), Action: "settings"})}
}
checks = append(checks, PreflightCheck{
Name: "Zone 权限", Status: statusBool(len(zones) > 0), Message: fmt.Sprintf("可访问 %d 个 Cloudflare Zone", len(zones)), Action: "settings",
})
return PreflightResult{Ready: !hasPreflightError(checks), Checks: checks}
}
func statusBool(ok bool) string {
if ok {
return "ok"
}
return "error"
}
func boolText(ok bool, yes, no string) string {
if ok {
return yes
}
return no
}
func firstNonEmpty(value, fallback string) string {
if strings.TrimSpace(value) != "" {
return value
}
return fallback
}
func hasPreflightError(checks []PreflightCheck) bool {
for _, check := range checks {
if check.Status == "error" {
return true
}
}
return false
}
// Drift 比较本地规则派生 ingress 与 Cloudflare 云端配置。
func (s *Service) Drift(ctx context.Context, id int64) (DriftResult, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return DriftResult{}, err
}
result := DriftResult{TunnelID: id, Managed: tunnel.Managed, Items: []string{}}
if !tunnel.Managed {
return result, nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return DriftResult{}, err
}
expected := BuildIngressForRules(rulesForTunnel(rules, tunnel.ID))
api, err := s.cloudflareAPI(ctx)
if err != nil {
return DriftResult{}, err
}
actual, err := api.GetIngress(ctx, tunnel.TunnelID)
if err != nil {
return DriftResult{}, err
}
result.Items = compareIngress(expected, actual)
result.HasDrift = len(result.Items) > 0
return result, nil
}
// PushTunnel 以本地规则为准重新覆盖云端 ingress。
func (s *Service) PushTunnel(ctx context.Context, id int64) error {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return err
}
if !tunnel.Managed {
return fmt.Errorf("手工隧道不支持从规则覆盖,请先在编辑中开启自动托管")
}
return s.syncManagedTunnel(ctx, tunnel)
}
func compareIngress(expected, actual []IngressRule) []string {
expectedMap := ingressMap(expected)
actualMap := ingressMap(actual)
items := []string{}
keys := unionKeys(expectedMap, actualMap)
for _, key := range keys {
want, wantOK := expectedMap[key]
got, gotOK := actualMap[key]
switch {
case wantOK && !gotOK:
items = append(items, fmt.Sprintf("%s 本地存在,云端缺失", key))
case !wantOK && gotOK:
items = append(items, fmt.Sprintf("%s 云端存在,本地已移除", key))
case want != got:
items = append(items, fmt.Sprintf("%s 回源不一致:本地 %s,云端 %s", key, want, got))
}
}
return items
}
func ingressMap(ingress []IngressRule) map[string]string {
out := map[string]string{}
for _, item := range ingress {
key := strings.ToLower(strings.TrimSpace(item.Hostname))
if key == "" {
key = "(catch-all)"
}
out[key] = strings.TrimSpace(item.Service)
}
return out
}
func unionKeys(a, b map[string]string) []string {
seen := map[string]struct{}{}
for key := range a {
seen[key] = struct{}{}
}
for key := range b {
seen[key] = struct{}{}
}
keys := make([]string, 0, len(seen))
for key := range seen {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
// Diagnostics 返回一组面向用户的 Cloudflare 隧道检查项。
func (s *Service) Diagnostics(ctx context.Context, id int64) (DiagnosticsResult, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return DiagnosticsResult{}, err
}
result := DiagnosticsResult{TunnelID: id, GeneratedAt: time.Now().UTC().Format(time.RFC3339), Checks: []DiagnosticCheck{}}
result.Checks = append(result.Checks, s.binaryCheck())
result.Checks = append(result.Checks, configCheck(tunnel.ConfigPath))
result.Checks = append(result.Checks, processCheck(s.manager.Status(id), tunnel))
api := s.apiDiagnostic(ctx, tunnel)
result.Checks = append(result.Checks, api)
result.Checks = append(result.Checks, s.dnsCheck(ctx, tunnel))
result.Checks = append(result.Checks, networkCheck("Cloudflare edge region1", "region1.v2.argotunnel.com:7844"))
result.Checks = append(result.Checks, networkCheck("Cloudflare edge region2", "region2.v2.argotunnel.com:7844"))
return result, nil
}
func (s *Service) binaryCheck() DiagnosticCheck {
version := s.BinaryVersion()
if strings.TrimSpace(version) == "" {
return DiagnosticCheck{Name: "cloudflared 二进制", Status: "error", Message: "未安装或不可执行"}
}
return DiagnosticCheck{Name: "cloudflared 二进制", Status: "ok", Message: version}
}
func configCheck(path string) DiagnosticCheck {
if _, err := os.Stat(path); err != nil {
return DiagnosticCheck{Name: "本地配置", Status: "error", Message: "config.yml 不存在或不可读"}
}
return DiagnosticCheck{Name: "本地配置", Status: "ok", Message: "config.yml 可读"}
}
func processCheck(status RuntimeStatus, tunnel Tunnel) DiagnosticCheck {
if status.Running && status.Status == "connected" {
return DiagnosticCheck{Name: "隧道进程", Status: "ok", Message: fmt.Sprintf("已连接,HA 连接数 %d", status.Connections)}
}
if tunnel.AutoStart {
return DiagnosticCheck{Name: "隧道进程", Status: "error", Message: "未运行或未连接:" + status.LastError}
}
return DiagnosticCheck{Name: "隧道进程", Status: "warning", Message: "当前未运行,且未启用自动启动"}
}
func (s *Service) apiDiagnostic(ctx context.Context, tunnel Tunnel) DiagnosticCheck {
api, err := s.cloudflareAPI(ctx)
if err != nil {
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: err.Error()}
}
if tunnel.TunnelID == "" {
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: "隧道 ID 为空"}
}
if _, err := api.GetIngress(ctx, tunnel.TunnelID); err != nil {
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: err.Error()}
}
return DiagnosticCheck{Name: "Cloudflare API", Status: "ok", Message: "API Token 与云端配置可访问"}
}
func (s *Service) dnsCheck(ctx context.Context, tunnel Tunnel) DiagnosticCheck {
records, err := s.store.ListDNSRecords(ctx, tunnel.ID)
if err != nil {
return DiagnosticCheck{Name: "DNS 记录", Status: "error", Message: err.Error()}
}
if len(records) == 0 {
return DiagnosticCheck{Name: "DNS 记录", Status: "warning", Message: "没有 Fonu 接管的 DNS 记录"}
}
return DiagnosticCheck{Name: "DNS 记录", Status: "ok", Message: fmt.Sprintf("已接管 %d 条记录", len(records))}
}
func networkCheck(name, address string) DiagnosticCheck {
conn, err := net.DialTimeout("tcp", address, 3*time.Second)
if err != nil {
return DiagnosticCheck{Name: name, Status: "warning", Message: "连接失败:" + err.Error()}
}
_ = conn.Close()
return DiagnosticCheck{Name: name, Status: "ok", Message: "TCP 7844 可连接"}
}
// RouteTestResult 是单条路由源服务的连通性测试结果。
type RouteTestResult struct {
OK bool `json:"ok"`
Message string `json:"message"`
LatencyMS float64 `json:"latency_ms"`
}
// TestService 测试一条路由 service 的 TCP 连通性。
func (s *Service) TestService(ctx context.Context, service string) (RouteTestResult, error) {
address, err := serviceAddress(service)
if err != nil {
return RouteTestResult{}, err
}
start := time.Now()
dialer := net.Dialer{Timeout: 5 * time.Second}
conn, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return RouteTestResult{OK: false, Message: err.Error()}, nil
}
_ = conn.Close()
return RouteTestResult{OK: true, Message: "源服务 TCP 可连接", LatencyMS: float64(time.Since(start).Milliseconds())}, nil
}
func serviceAddress(raw string) (string, error) {
raw = strings.TrimSpace(raw)
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
if !strings.Contains(raw, ":") {
return "", fmt.Errorf("service 格式无效:%s", raw)
}
return raw, nil
}
host := parsed.Hostname()
port := parsed.Port()
if port == "" {
switch parsed.Scheme {
case "http":
port = "80"
case "https":
port = "443"
case "ssh":
port = "22"
default:
return "", fmt.Errorf("service 缺少端口:%s", raw)
}
}
return net.JoinHostPort(host, port), nil
}
+28
View File
@@ -0,0 +1,28 @@
package cloudflared
import "testing"
func TestCompareIngressDetectsMissingAndMismatch(t *testing.T) {
expected := BuildIngress([]string{"a.example.com", "b.example.com"}, "http://127.0.0.1:8080")
actual := []IngressRule{
{Hostname: "a.example.com", Service: "http://127.0.0.1:9090"},
{Hostname: "c.example.com", Service: "http://127.0.0.1:8080"},
{Service: "http_status:404"},
}
items := compareIngress(expected, actual)
if len(items) != 3 {
t.Fatalf("应检测出 3 项漂移,实际 %#v", items)
}
}
func TestCompareIngressIgnoresOrder(t *testing.T) {
expected := BuildIngress([]string{"b.example.com", "a.example.com"}, "http://127.0.0.1:8080")
actual := []IngressRule{
{Service: "http_status:404"},
{Hostname: "a.example.com", Service: "http://127.0.0.1:8080"},
{Hostname: "b.example.com", Service: "http://127.0.0.1:8080"},
}
if items := compareIngress(expected, actual); len(items) != 0 {
t.Fatalf("顺序不同不应算漂移:%#v", items)
}
}
+185
View File
@@ -0,0 +1,185 @@
package cloudflared
import (
"context"
"fmt"
"os"
"strings"
"github.com/fonu/fonu/internal/fsutil"
"github.com/fonu/fonu/internal/validate"
)
// RouteView 是配置页展示的一条 ingress 路由。
type RouteView struct {
ID int64 `json:"id,omitempty"`
Hostname string `json:"hostname"`
Path string `json:"path"`
Service string `json:"service"`
Source string `json:"source"`
}
// TunnelRoutes 是配置页需要的路由列表。
type TunnelRoutes struct {
Managed bool `json:"managed"`
Routes []RouteView `json:"routes"`
CatchAll string `json:"catch_all"`
}
// RouteInput 是手工隧道路由保存入参。
type RouteInput struct {
Hostname string `json:"hostname"`
Path string `json:"path"`
Service string `json:"service"`
}
// Routes 返回托管规则的派生路由,或手工 config.yml 中的 ingress 路由。
func (s *Service) Routes(ctx context.Context, id int64) (TunnelRoutes, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return TunnelRoutes{}, err
}
if tunnel.Managed {
return s.managedRoutes(ctx, tunnel)
}
return manualRoutes(tunnel.ConfigPath)
}
func (s *Service) managedRoutes(ctx context.Context, tunnel Tunnel) (TunnelRoutes, error) {
if s.rules == nil {
return TunnelRoutes{Managed: true, Routes: []RouteView{}, CatchAll: "http_status:404"}, nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return TunnelRoutes{}, err
}
out := TunnelRoutes{Managed: true, Routes: []RouteView{}, CatchAll: "http_status:404"}
for _, rule := range rulesForTunnel(rules, tunnel.ID) {
for _, host := range rule.Hostnames() {
out.Routes = append(out.Routes, RouteView{
ID: rule.ID, Hostname: host, Path: "*", Service: rule.Upstream, Source: "rule",
})
}
}
return out, nil
}
func manualRoutes(configPath string) (TunnelRoutes, error) {
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return TunnelRoutes{Routes: []RouteView{}, CatchAll: "http_status:404"}, nil
}
return TunnelRoutes{}, err
}
out := TunnelRoutes{Routes: []RouteView{}, CatchAll: "http_status:404"}
lines := strings.Split(string(data), "\n")
for i, raw := range lines {
line := strings.TrimSpace(raw)
host, found := strings.CutPrefix(line, "- hostname:")
if !found {
continue
}
hostname := strings.TrimSpace(host)
if hostname == "" || i+1 >= len(lines) {
continue
}
path := "*"
next := i + 1
if value, ok := strings.CutPrefix(strings.TrimSpace(lines[next]), "path:"); ok {
path = strings.TrimSpace(value)
next++
}
if next >= len(lines) {
continue
}
service, ok := strings.CutPrefix(strings.TrimSpace(lines[next]), "service:")
if !ok {
continue
}
out.Routes = append(out.Routes, RouteView{
Hostname: hostname, Path: path, Service: strings.TrimSpace(service), Source: "config",
})
}
if len(out.Routes) > 0 {
catchAll, err := catchAllService(data)
if err == nil && catchAll != "" {
out.CatchAll = catchAll
}
}
return out, nil
}
func catchAllService(data []byte) (string, error) {
lines := strings.Split(string(data), "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if strings.HasPrefix(line, "- service:") {
return strings.TrimSpace(strings.TrimPrefix(line, "- service:")), nil
}
}
return "", fmt.Errorf("未找到 catch-all")
}
// ReplaceRoutes 保存手工隧道的路由列表,并重新生成本地与云端 ingress。
func (s *Service) ReplaceRoutes(ctx context.Context, id int64, routes []RouteInput) (TunnelRoutes, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return TunnelRoutes{}, err
}
if tunnel.Managed {
return TunnelRoutes{}, fmt.Errorf("托管隧道的路由由反向代理规则管理")
}
ingress, hostnames, err := buildManualIngress(routes)
if err != nil {
return TunnelRoutes{}, err
}
if err := fsutil.WriteFileAtomic(tunnel.ConfigPath, []byte(BuildConfig(tunnel.TunnelID, tunnel.CredsPath, tunnel.Network, ingress)), 0o640); err != nil {
return TunnelRoutes{}, err
}
if tunnel.Mode == ModeAccountLocal && tunnel.TunnelID != "" {
api, err := s.cloudflareAPI(ctx)
if err != nil {
return TunnelRoutes{}, err
}
if err := api.PutIngress(ctx, tunnel.TunnelID, ingress); err != nil {
return TunnelRoutes{}, err
}
if err := s.syncTunnelDNS(ctx, api, tunnel.ID, tunnel.TunnelID, hostnames); err != nil {
return TunnelRoutes{}, err
}
}
if err := s.restartIfRunning(ctx, tunnel.ID); err != nil {
return TunnelRoutes{}, err
}
return s.Routes(ctx, id)
}
func buildManualIngress(routes []RouteInput) ([]IngressRule, []string, error) {
seen := map[string]struct{}{}
ingress := make([]IngressRule, 0, len(routes)+1)
hostnames := make([]string, 0, len(routes))
for _, route := range routes {
hostname := strings.ToLower(strings.TrimSpace(route.Hostname))
if err := validate.Domain(hostname); err != nil {
return nil, nil, fmt.Errorf("域名 %s 无效:%w", hostname, err)
}
if _, ok := seen[hostname]; ok {
return nil, nil, fmt.Errorf("域名 %s 重复", hostname)
}
seen[hostname] = struct{}{}
service := strings.TrimSpace(route.Service)
if service == "" || !strings.Contains(service, "://") {
return nil, nil, fmt.Errorf("域名 %s 的源服务格式无效", hostname)
}
path := strings.TrimSpace(route.Path)
// Cloudflare ingress 的 path 是正则;通配路由必须省略 path,发送 "*" 会被判为非法正则。
if path == "" || path == "*" {
path = ""
}
ingress = append(ingress, IngressRule{Hostname: hostname, Path: path, Service: service})
hostnames = append(hostnames, hostname)
}
ingress = append(ingress, IngressRule{Service: "http_status:404"})
return ingress, hostnames, nil
}
+51
View File
@@ -0,0 +1,51 @@
package cloudflared
import (
"os"
"path/filepath"
"testing"
)
func TestManualRoutesFromConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yml")
content := `tunnel: test
ingress:
- hostname: a.example.com
service: http://127.0.0.1:8080
- hostname: b.example.com
service: http://127.0.0.1:9090
- service: http_status:404
`
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
t.Fatal(err)
}
result, err := manualRoutes(path)
if err != nil {
t.Fatal(err)
}
if len(result.Routes) != 2 || result.Routes[0].Hostname != "a.example.com" || result.Routes[1].Service != "http://127.0.0.1:9090" {
t.Fatalf("手工路由解析不符:%#v", result)
}
if result.CatchAll != "http_status:404" {
t.Fatalf("catch-all 不符:%q", result.CatchAll)
}
}
func TestBuildManualIngress(t *testing.T) {
ingress, hostnames, err := buildManualIngress([]RouteInput{
{Hostname: "A.Example.com", Path: "*", Service: "http://127.0.0.1:8080"},
{Hostname: "b.example.com", Service: "https://127.0.0.1:8443"},
})
if err != nil {
t.Fatal(err)
}
if len(ingress) != 3 || ingress[0].Hostname != "a.example.com" || ingress[1].Hostname != "b.example.com" {
t.Fatalf("ingress 结果不符:%#v", ingress)
}
if ingress[0].Path != "" || ingress[1].Path != "" {
t.Fatalf("通配路径不应写入 Cloudflare ingress:%#v", ingress)
}
if len(hostnames) != 2 || hostnames[1] != "b.example.com" {
t.Fatalf("hostnames 结果不符:%#v", hostnames)
}
}
+828
View File
@@ -0,0 +1,828 @@
package cloudflared
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/fsutil"
"github.com/fonu/fonu/internal/proxy"
"github.com/fonu/fonu/internal/secret"
)
const defaultOriginService = "http://127.0.0.1:80"
// RuleSource 提供本机反代规则,用于推导 ingress 候选域名。
type RuleSource interface {
List(ctx context.Context) ([]proxy.Rule, error)
}
// Service 是 Cloudflare 隧道模块的门面。
type Service struct {
cfg config.Config
store *Store
secretBox *secret.Box
logger *slog.Logger
manager *Manager
rules RuleSource
}
func NewService(cfg config.Config, store *Store, secretBox *secret.Box, logger *slog.Logger, rules RuleSource) *Service {
moduleLogger := logger.With("module", "CLOUDFLARED")
return &Service{
cfg: cfg,
store: store,
secretBox: secretBox,
logger: moduleLogger,
manager: NewManager(cfg, moduleLogger),
rules: rules,
}
}
func (s *Service) EnsureDirs() error {
return os.MkdirAll(filepath.Join(s.cfg.DataDir, "cloudflared", "bin"), 0o755)
}
func (s *Service) List(ctx context.Context) ([]Tunnel, error) {
items, err := s.store.List(ctx)
if err != nil {
return nil, err
}
for i := range items {
s.attachConfig(&items[i])
}
return items, nil
}
func (s *Service) Get(ctx context.Context, id int64) (Tunnel, error) {
item, err := s.store.Get(ctx, id)
if err != nil {
return Tunnel{}, err
}
s.attachConfig(&item)
return item, nil
}
// Create 解析 token、生成 creds.json 与 config.yml,并写入数据库。
func (s *Service) Create(ctx context.Context, in CreateInput) (Tunnel, error) {
if strings.TrimSpace(in.Mode) == "" {
in.Mode = ModeAccountLocal
}
if err := validateInput(in); err != nil {
return Tunnel{}, err
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return Tunnel{}, err
}
in.Network = s.applyDefaultNetwork(ctx, in.Network)
tunnelID, tunnelToken, err := api.CreateTunnel(ctx, in.Name)
if err != nil {
return Tunnel{}, err
}
accountTag, decodedID, tunnelSecret, err := DecodeToken(tunnelToken)
if err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, fmt.Errorf("Cloudflare 返回的隧道凭据无法解析: %w", err)
}
dir := s.tunnelDir(in.Name)
if err := os.MkdirAll(dir, 0o750); err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
configPath := filepath.Join(dir, "config.yml")
credsPath := filepath.Join(dir, "creds.json")
logPath := filepath.Join(dir, "cloudflared.log")
origin := strings.TrimSpace(in.OriginService)
if origin == "" {
origin = defaultOriginService
}
ingress := BuildIngress(in.Hostnames, origin)
if in.Managed {
ingress = BuildIngress(nil, origin)
}
if err := fsutil.WriteFileAtomic(credsPath, []byte(CredentialsJSON(accountTag, decodedID, tunnelSecret)), 0o600); err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
if err := fsutil.WriteFileAtomic(configPath, []byte(BuildConfig(decodedID, credsPath, in.Network, ingress)), 0o640); err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
encrypted, err := s.secretBox.Encrypt(tunnelToken)
if err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
tunnel, err := s.store.Create(ctx, in, decodedID, accountTag, encrypted, "", configPath, credsPath, logPath)
if err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
if tunnel.Managed {
if err := s.syncManagedTunnel(ctx, tunnel); err != nil {
s.logger.Warn("创建托管隧道后同步规则失败", "tunnel_id", decodedID, "error", err.Error())
tunnel.DNSWarning = "隧道已创建,但规则或 DNS 同步失败:" + err.Error()
}
return tunnel, nil
}
_ = api.PutIngress(ctx, decodedID, ingress)
if err := s.syncTunnelDNS(ctx, api, tunnel.ID, decodedID, in.Hostnames); err != nil {
s.logger.Warn("自动创建 Cloudflare DNS 失败", "tunnel_id", decodedID, "error", err.Error())
tunnel.DNSWarning = "隧道已创建,但 DNS 记录未自动创建:" + err.Error()
}
return tunnel, nil
}
// Update 更新隧道配置;凭据由 Cloudflare API 创建时生成,编辑不修改。
func (s *Service) Update(ctx context.Context, id int64, in CreateInput) (Tunnel, error) {
current, err := s.store.Get(ctx, id)
if err != nil {
return Tunnel{}, err
}
if current.Managed && !in.Managed {
if count := s.tunnelUsageCount(ctx, id); count > 0 {
return Tunnel{}, fmt.Errorf("仍有 %d 条反向代理规则使用该托管隧道,请先移除 Cloudflare 出口", count)
}
}
_ = s.manager.Stop(id)
in.Network = s.applyDefaultNetwork(ctx, in.Network)
origin := strings.TrimSpace(in.OriginService)
if origin == "" {
origin = defaultOriginService
}
ingress := BuildIngress(in.Hostnames, origin)
if in.Managed {
ingress = BuildIngress(nil, origin)
}
if err := fsutil.WriteFileAtomic(current.ConfigPath, []byte(BuildConfig(current.TunnelID, current.CredsPath, in.Network, ingress)), 0o640); err != nil {
return Tunnel{}, err
}
updated, err := s.store.Update(ctx, id, in, current.TunnelID, current.AccountTag, current.CredentialsEnc, current.CertPEMEnc,
current.ConfigPath, current.CredsPath, current.LogPath)
if err != nil {
return Tunnel{}, err
}
dnsWarning := ""
if updated.Managed {
if err := s.syncManagedTunnel(ctx, updated); err != nil {
dnsWarning = "托管隧道已保存,但规则或 DNS 同步失败:" + err.Error()
}
} else if updated.Mode == ModeAccountLocal && updated.TunnelID != "" {
if api, err := s.cloudflareAPI(ctx); err == nil {
_ = api.PutIngress(ctx, updated.TunnelID, ingress)
if err := s.syncTunnelDNS(ctx, api, updated.ID, updated.TunnelID, in.Hostnames); err != nil {
s.logger.Warn("同步 Cloudflare DNS 失败", "tunnel_id", updated.TunnelID, "error", err.Error())
dnsWarning = "DNS 记录同步失败:" + err.Error()
}
}
}
updated.DNSWarning = dnsWarning
return updated, nil
}
func (s *Service) Delete(ctx context.Context, id int64) error {
current, err := s.store.Get(ctx, id)
if err != nil {
return err
}
if count := s.tunnelUsageCount(ctx, id); count > 0 {
return fmt.Errorf("仍有 %d 条反向代理规则使用该隧道,请先移除这些规则的 Cloudflare 出口", count)
}
_ = s.manager.Stop(id)
if current.TunnelID != "" {
if api, err := s.cloudflareAPI(ctx); err == nil {
s.deleteTunnelDNS(ctx, api, current.ID)
_ = api.DeleteTunnel(ctx, current.TunnelID)
}
}
if dir := filepath.Dir(current.ConfigPath); dir != "" && strings.HasPrefix(dir, s.cfg.DataDir) {
_ = os.RemoveAll(dir)
}
return s.store.Delete(ctx, id)
}
func (s *Service) tunnelUsageCount(ctx context.Context, id int64) int {
if s.rules == nil {
return 0
}
rules, err := s.rules.List(ctx)
if err != nil {
return 0
}
count := 0
for _, rule := range rules {
if rule.UsesExit(proxy.ExitCloudflare) && rule.CFTunnelID == id {
count++
}
}
return count
}
func (s *Service) Start(ctx context.Context, id int64) (Tunnel, error) {
settings, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return Tunnel{}, err
}
if !settings.Enabled {
return Tunnel{}, fmt.Errorf("Cloudflare 隧道全局已停用,请先在页面顶部重新启用")
}
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return Tunnel{}, err
}
binaryPath := s.binaryPath()
if _, err := os.Stat(binaryPath); err != nil {
return Tunnel{}, fmt.Errorf("cloudflared 未安装,请先下载二进制")
}
token := ""
if tunnel.Mode == ModeTokenRemote {
plain, err := s.secretBox.Decrypt(tunnel.CredentialsEnc)
if err != nil {
return Tunnel{}, fmt.Errorf("凭据解密失败: %w", err)
}
token = plain
}
if err := s.manager.Start(tunnel, binaryPath, tunnel.ConfigPath, token); err != nil {
_ = s.store.SetRuntime(ctx, id, "failed", err.Error(), 0)
return Tunnel{}, err
}
_ = s.store.SetRuntime(ctx, id, "starting", "", 0)
return s.Get(ctx, id)
}
func (s *Service) Stop(ctx context.Context, id int64) (Tunnel, error) {
_ = s.manager.Stop(id)
_ = s.store.SetRuntime(ctx, id, "stopped", "", 0)
return s.Get(ctx, id)
}
func (s *Service) Restart(ctx context.Context, id int64) (Tunnel, error) {
_, _ = s.Stop(ctx, id)
return s.Start(ctx, id)
}
// RefreshCredentials 重新从 Cloudflare 获取隧道凭据并更新本地 creds.json。
func (s *Service) RefreshCredentials(ctx context.Context, id int64) (Tunnel, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return Tunnel{}, err
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return Tunnel{}, err
}
token, err := api.TunnelToken(ctx, tunnel.TunnelID)
if err != nil {
return Tunnel{}, err
}
accountTag, decodedID, tunnelSecret, err := DecodeToken(token)
if err != nil {
return Tunnel{}, err
}
if err := fsutil.WriteFileAtomic(tunnel.CredsPath, []byte(CredentialsJSON(accountTag, decodedID, tunnelSecret)), 0o600); err != nil {
return Tunnel{}, err
}
encrypted, err := s.secretBox.Encrypt(token)
if err != nil {
return Tunnel{}, err
}
if err := s.store.UpdateCredentials(ctx, id, encrypted); err != nil {
return Tunnel{}, err
}
if s.manager.Status(id).Running {
return s.Restart(ctx, id)
}
return s.Get(ctx, id)
}
func (s *Service) RuntimeStatus(ctx context.Context, id int64) (RuntimeStatus, error) {
if _, err := s.store.Get(ctx, id); err != nil {
return RuntimeStatus{}, err
}
return s.manager.Status(id), nil
}
// AppSettings 返回模块级配置;默认 token 只回显掩码。
func (s *Service) AppSettings(ctx context.Context) (AppSettings, error) {
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return AppSettings{}, err
}
out := AppSettings{AccountID: row.AccountID, Enabled: row.Enabled, Mirror: row.Mirror, Network: row.Network}
if row.TokenEnc != "" {
out.DefaultTokenConfigured = true
if plain, err := s.secretBox.Decrypt(row.TokenEnc); err == nil {
out.DefaultTokenMasked = MaskSecret(plain)
}
}
if row.APITokenEnc != "" {
out.APITokenConfigured = true
if plain, err := s.secretBox.Decrypt(row.APITokenEnc); err == nil {
out.APITokenMasked = MaskSecret(plain)
}
}
return out, nil
}
// SaveAppSettings 保存默认 token、下载镜像与默认网络设置;Token 留空表示保留原值。
func (s *Service) SaveAppSettings(ctx context.Context, in AppSettingsInput) (AppSettings, error) {
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return AppSettings{}, err
}
accountID := strings.TrimSpace(in.AccountID)
if accountID == "" {
accountID = row.AccountID
}
enabled := row.Enabled
if in.Enabled != nil {
enabled = *in.Enabled
}
tokenEnc := row.TokenEnc
apiTokenEnc := row.APITokenEnc
if apiToken := strings.TrimSpace(in.APIToken); apiToken != "" {
apiTokenEnc, err = s.secretBox.Encrypt(apiToken)
if err != nil {
return AppSettings{}, err
}
}
mirror := strings.TrimSpace(in.Mirror)
if mirror == "" {
mirror = row.Mirror
}
if mirror == "" {
mirror = "official"
}
network := in.Network
if strings.TrimSpace(network.TransportProtocol) == "" {
network = row.Network
}
if err := s.store.SaveSettings(ctx, accountID, enabled, tokenEnc, apiTokenEnc, mirror, network); err != nil {
return AppSettings{}, err
}
return s.AppSettings(ctx)
}
// SetEnabled 切换全局托管开关;关闭时停止所有隧道,开启时恢复 auto_start 的隧道。
func (s *Service) SetEnabled(ctx context.Context, enabled bool) (AppSettings, error) {
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return AppSettings{}, err
}
if err := s.store.SaveSettings(ctx, row.AccountID, enabled, row.TokenEnc, row.APITokenEnc, row.Mirror, row.Network); err != nil {
return AppSettings{}, err
}
if enabled {
s.StartAll(ctx)
} else {
s.StopAll(ctx)
}
return s.AppSettings(ctx)
}
// StopAll 停止所有隧道进程,但保留配置、凭据与云端隧道。
func (s *Service) StopAll(ctx context.Context) {
tunnels, err := s.store.List(ctx)
if err != nil {
s.logger.Warn("读取 Cloudflare 隧道失败", "error", err.Error())
return
}
for _, tunnel := range tunnels {
if err := s.manager.Stop(tunnel.ID); err != nil {
s.logger.Warn("停止 Cloudflare 隧道失败", "tunnel_id", tunnel.ID, "error", err.Error())
}
_ = s.store.SetRuntime(ctx, tunnel.ID, "stopped", "", 0)
}
}
func (s *Service) applyDefaultNetwork(ctx context.Context, network NetworkSettings) NetworkSettings {
if strings.TrimSpace(network.TransportProtocol) != "" {
return network
}
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return DefaultNetworkSettings()
}
return row.Network
}
func (s *Service) cloudflareAPI(ctx context.Context) (*CloudflareAPI, error) {
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return nil, err
}
accountID := strings.TrimSpace(row.AccountID)
if accountID == "" {
return nil, fmt.Errorf("请先在「应用配置」中填写 Account ID")
}
if row.APITokenEnc == "" {
return nil, fmt.Errorf("请先在「应用配置」中保存 API Token")
}
token, err := s.secretBox.Decrypt(row.APITokenEnc)
if err != nil {
return nil, fmt.Errorf("API Token 解密失败: %w", err)
}
return NewCloudflareAPI(accountID, token), nil
}
// SyncRules 让所有托管隧道从反向代理规则重新派生 ingress 与 DNS。
func (s *Service) SyncRules(ctx context.Context) error {
if s.rules == nil {
return nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return err
}
tunnels, err := s.store.List(ctx)
if err != nil {
return err
}
var firstErr error
for _, tunnel := range tunnels {
if !tunnel.Managed {
continue
}
assigned := rulesForTunnel(rules, tunnel.ID)
dnsRecords, err := s.store.ListDNSRecords(ctx, tunnel.ID)
if err != nil && firstErr == nil {
firstErr = err
continue
}
if len(assigned) == 0 && len(dnsRecords) == 0 {
continue
}
if err := s.syncManagedTunnel(ctx, tunnel); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func rulesForTunnel(rules []proxy.Rule, tunnelID int64) []proxy.Rule {
out := make([]proxy.Rule, 0)
for _, rule := range rules {
if !rule.CloudflareEnabled() || rule.CFTunnelID != tunnelID {
continue
}
out = append(out, rule)
}
return out
}
func (s *Service) syncManagedTunnel(ctx context.Context, tunnel Tunnel) error {
if !tunnel.Managed {
return nil
}
if s.rules == nil {
return nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return err
}
assigned := rulesForTunnel(rules, tunnel.ID)
ingress := BuildIngressForRules(assigned)
content := BuildConfig(tunnel.TunnelID, tunnel.CredsPath, tunnel.Network, ingress)
if err := fsutil.WriteFileAtomic(tunnel.ConfigPath, []byte(content), 0o640); err != nil {
return err
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return err
}
if err := api.PutIngress(ctx, tunnel.TunnelID, ingress); err != nil {
return err
}
if err := s.syncTunnelDNS(ctx, api, tunnel.ID, tunnel.TunnelID, hostnamesFromIngress(ingress)); err != nil {
return err
}
return s.restartIfRunning(ctx, tunnel.ID)
}
func hostnamesFromIngress(ingress []IngressRule) []string {
out := make([]string, 0, len(ingress))
for _, item := range ingress {
if strings.TrimSpace(item.Hostname) != "" {
out = append(out, item.Hostname)
}
}
return out
}
func (s *Service) restartIfRunning(ctx context.Context, id int64) error {
if !s.manager.Status(id).Running {
return nil
}
if _, err := s.Restart(ctx, id); err != nil {
return fmt.Errorf("配置已同步,但隧道重启失败:%w", err)
}
return nil
}
// syncTunnelDNS 让 Cloudflare DNS 与目标 hostname 保持一致,并只清理 Fonu 自己接管的记录。
func (s *Service) syncTunnelDNS(ctx context.Context, api *CloudflareAPI, localTunnelID int64, remoteTunnelID string, hostnames []string) error {
desired := make(map[string]struct{}, len(hostnames))
for _, raw := range hostnames {
hostname := strings.ToLower(strings.TrimSpace(raw))
if hostname != "" {
desired[hostname] = struct{}{}
}
}
records, err := s.store.ListDNSRecords(ctx, localTunnelID)
if err != nil {
return err
}
var firstErr error
for _, record := range records {
if _, ok := desired[record.Hostname]; ok {
continue
}
if err := api.DeleteDNSRecord(ctx, record.ZoneID, record.RecordID); err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
if err := s.store.DeleteDNSRecord(ctx, record.ID); err != nil && firstErr == nil {
firstErr = err
}
}
target := remoteTunnelID + ".cfargotunnel.com"
for hostname := range desired {
zoneID, _, err := api.FindZone(ctx, hostname)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
recordID, err := api.EnsureCNAME(ctx, zoneID, hostname, target)
if err != nil {
if transferredID, transferErr := s.transferTrackedDNS(ctx, api, localTunnelID, zoneID, hostname, target); transferErr == nil {
recordID = transferredID
err = nil
}
}
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
if strings.TrimSpace(recordID) == "" {
if firstErr == nil {
firstErr = fmt.Errorf("Cloudflare 未返回 %s 的 DNS 记录 ID", hostname)
}
continue
}
if err := s.store.UpsertDNSRecord(ctx, localTunnelID, zoneID, recordID, hostname); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (s *Service) transferTrackedDNS(ctx context.Context, api *CloudflareAPI, localTunnelID int64, zoneID, hostname, target string) (string, error) {
record, err := s.store.FindDNSRecord(ctx, hostname)
if err != nil {
return "", err
}
if record.TunnelID == localTunnelID {
if err := api.DeleteDNSRecord(ctx, record.ZoneID, record.RecordID); err != nil {
return "", err
}
if err := s.store.DeleteDNSRecord(ctx, record.ID); err != nil {
return "", err
}
return api.EnsureCNAME(ctx, zoneID, hostname, target)
}
if err := api.DeleteDNSRecord(ctx, record.ZoneID, record.RecordID); err != nil {
return "", err
}
if err := s.store.DeleteDNSRecord(ctx, record.ID); err != nil {
return "", err
}
return api.EnsureCNAME(ctx, zoneID, hostname, target)
}
func (s *Service) deleteTunnelDNS(ctx context.Context, api *CloudflareAPI, localTunnelID int64) {
records, err := s.store.ListDNSRecords(ctx, localTunnelID)
if err != nil {
s.logger.Warn("读取 Cloudflare DNS 记录失败", "tunnel_id", localTunnelID, "error", err.Error())
return
}
for _, record := range records {
if err := api.DeleteDNSRecord(ctx, record.ZoneID, record.RecordID); err != nil {
s.logger.Warn("删除 Cloudflare DNS 记录失败", "hostname", record.Hostname, "error", err.Error())
continue
}
_ = s.store.DeleteDNSRecord(ctx, record.ID)
}
_ = s.store.DeleteDNSRecordsForTunnel(ctx, localTunnelID)
}
// VerifyAPIToken 用 Account ID + API Token 调用 Cloudflare 账号级 tokens/verify 接口。
// accountID / input 为空时回退到应用配置中已保存的值。
func (s *Service) VerifyAPIToken(ctx context.Context, accountID, input string) (string, error) {
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return "", err
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
accountID = strings.TrimSpace(row.AccountID)
}
if accountID == "" {
return "", fmt.Errorf("请先填写 Account ID")
}
token := strings.TrimSpace(input)
if token == "" {
if row.APITokenEnc == "" {
return "", fmt.Errorf("请填写 API Token(cfat_...),或先在应用配置中保存")
}
token, err = s.secretBox.Decrypt(row.APITokenEnc)
if err != nil {
return "", fmt.Errorf("API Token 解密失败: %w", err)
}
}
return NewCloudflareAPI(accountID, token).VerifyToken(ctx)
}
// TestToken 验证 Account ID + API Token 是否可用。
func (s *Service) TestToken(ctx context.Context, accountID, input, _ string) (string, error) {
return s.VerifyAPIToken(ctx, accountID, input)
}
// Zones 返回当前 API Token 可访问的 Cloudflare Zone。
func (s *Service) Zones(ctx context.Context) ([]Zone, error) {
api, err := s.cloudflareAPI(ctx)
if err != nil {
return nil, err
}
return api.ListZones(ctx)
}
// AccessStatus 只读检测域名是否启用了 Cloudflare Access。
func (s *Service) AccessStatus(ctx context.Context, hostname string) (bool, string, error) {
hostname = strings.ToLower(strings.TrimSpace(hostname))
if hostname == "" {
return false, "", fmt.Errorf("域名为空")
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return false, "", err
}
return api.HasAccessForHostname(ctx, hostname)
}
// attachConfig 从 config.yml 回填域名与回源 service,供编辑弹窗使用。
func (s *Service) attachConfig(item *Tunnel) {
data, err := os.ReadFile(item.ConfigPath)
if err != nil {
return
}
lines := strings.Split(string(data), "\n")
for i, raw := range lines {
line := strings.TrimSpace(raw)
host, found := strings.CutPrefix(line, "- hostname:")
if !found {
continue
}
host = strings.TrimSpace(host)
if host == "" {
continue
}
item.Hostnames = append(item.Hostnames, host)
if i+1 >= len(lines) {
continue
}
if service, ok := strings.CutPrefix(strings.TrimSpace(lines[i+1]), "service:"); ok && item.OriginService == "" {
item.OriginService = strings.TrimSpace(service)
}
}
}
func (s *Service) Logs(ctx context.Context, id int64, lines int) (string, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return "", err
}
return s.manager.Logs(tunnel.LogPath, lines)
}
func (s *Service) PreviewConfig(ctx context.Context, id int64) (string, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return "", err
}
data, err := os.ReadFile(tunnel.ConfigPath)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
return string(data), nil
}
// IngressCandidates 从本机反代规则推导可暴露的域名候选。
func (s *Service) IngressCandidates(ctx context.Context) ([]string, error) {
if s.rules == nil {
return []string{}, nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return nil, err
}
hosts := []string{}
for _, rule := range rules {
if !rule.Enabled {
continue
}
for _, group := range rule.PortGroups() {
hosts = append(hosts, group.Hostnames...)
}
}
return hosts, nil
}
// StartAuto 在应用启动时拉起标记了 auto_start 的隧道。
func (s *Service) StartAuto(ctx context.Context) {
settings, err := s.store.GetSettingsRaw(ctx)
if err != nil {
s.logger.Warn("读取 Cloudflare 配置失败", "error", err.Error())
return
}
if !settings.Enabled {
return
}
tunnels, err := s.store.List(ctx)
if err != nil {
s.logger.Warn("读取 Cloudflare 隧道失败", "error", err.Error())
return
}
for _, tunnel := range tunnels {
if !tunnel.AutoStart {
continue
}
if _, err := s.Start(ctx, tunnel.ID); err != nil {
s.logger.Warn("自动启动 Cloudflare 隧道失败", "tunnel_id", tunnel.ID, "error", err.Error())
}
}
}
// StartAll 启动全部隧道;用于全局总开关重新开启时立即恢复所有出口。
func (s *Service) StartAll(ctx context.Context) {
settings, err := s.store.GetSettingsRaw(ctx)
if err != nil {
s.logger.Warn("读取 Cloudflare 配置失败", "error", err.Error())
return
}
if !settings.Enabled {
return
}
tunnels, err := s.store.List(ctx)
if err != nil {
s.logger.Warn("读取 Cloudflare 隧道失败", "error", err.Error())
return
}
for _, tunnel := range tunnels {
if _, err := s.Start(ctx, tunnel.ID); err != nil {
s.logger.Warn("启动 Cloudflare 隧道失败", "tunnel_id", tunnel.ID, "error", err.Error())
}
}
}
func (s *Service) tunnelDir(name string) string {
safe := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
return r
}
return '_'
}, strings.TrimSpace(name))
return filepath.Join(s.cfg.DataDir, "cloudflared", "tunnels", safe)
}
func validateInput(in CreateInput) error {
if strings.TrimSpace(in.Name) == "" {
return fmt.Errorf("隧道名称不能为空")
}
switch in.Mode {
case ModeAccountLocal, ModeTokenLocal, ModeTokenRemote:
default:
return fmt.Errorf("不支持的隧道模式: %s", in.Mode)
}
if !in.Managed && len(in.Hostnames) == 0 {
return fmt.Errorf("手动隧道至少需要一个暴露域名")
}
if in.Managed && in.Mode != ModeAccountLocal {
return fmt.Errorf("仅 API 管理隧道支持自动托管")
}
return nil
}
+102
View File
@@ -0,0 +1,102 @@
package cloudflared
import (
"context"
"io"
"log/slog"
"path/filepath"
"strings"
"testing"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/db"
"github.com/fonu/fonu/internal/secret"
)
func TestAppSettingsAccountAndAPIToken(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatal(err)
}
box, err := secret.NewBox("cloudflared-settings-test")
if err != nil {
t.Fatal(err)
}
svc := NewService(config.Config{DataDir: dir}, NewStore(conn), box, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
settings, err := svc.SaveAppSettings(context.Background(), AppSettingsInput{
AccountID: "acc-1", APIToken: "cfat_test_token", Mirror: "gh-proxy-org", Network: DefaultNetworkSettings(),
})
if err != nil {
t.Fatal(err)
}
if settings.AccountID != "acc-1" || !settings.APITokenConfigured || settings.APITokenMasked == "" {
t.Fatalf("Account ID 与 API Token 应保存并回显掩码:%#v", settings)
}
api, err := svc.cloudflareAPI(context.Background())
if err != nil || api.AccountID != "acc-1" || api.APIToken != "cfat_test_token" {
t.Fatalf("cloudflareAPI 构造失败:%v %#v", err, api)
}
}
func TestSetEnabledBlocksStart(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatal(err)
}
box, err := secret.NewBox("cloudflared-enabled-test")
if err != nil {
t.Fatal(err)
}
svc := NewService(config.Config{DataDir: dir}, NewStore(conn), box, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
settings, err := svc.SetEnabled(context.Background(), false)
if err != nil {
t.Fatal(err)
}
if settings.Enabled {
t.Fatal("全局托管应为停用")
}
if _, err := svc.Start(context.Background(), 1); err == nil || !strings.Contains(err.Error(), "全局已停用") {
t.Fatalf("全局停用时启动应被拒绝:%v", err)
}
settings, err = svc.SetEnabled(context.Background(), true)
if err != nil || !settings.Enabled {
t.Fatalf("重新启用失败:%v %#v", err, settings)
}
}
func TestManagedTunnelPersists(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatal(err)
}
store := NewStore(conn)
item, err := store.Create(context.Background(), CreateInput{
Name: "managed",
Mode: ModeAccountLocal,
Network: DefaultNetworkSettings(),
Managed: true,
AutoStart: true,
}, "remote-tunnel", "account", "encrypted", "", "/tmp/config.yml", "/tmp/creds.json", "/tmp/cloudflared.log")
if err != nil {
t.Fatal(err)
}
if !item.Managed || !item.AutoStart || item.TunnelID != "remote-tunnel" {
t.Fatalf("托管隧道字段未持久化:%#v", item)
}
}
+252
View File
@@ -0,0 +1,252 @@
package cloudflared
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
const tunnelSelect = `
SELECT id, name, mode, COALESCE(tunnel_id, ''), COALESCE(account_tag, ''),
COALESCE(credentials_enc, ''), COALESCE(cert_pem_enc, ''),
COALESCE(config_path, ''), COALESCE(creds_path, ''), COALESCE(log_path, ''),
COALESCE(network_json, '{}'), COALESCE(status, 'stopped'), COALESCE(last_error, ''),
COALESCE(metrics_port, 0), COALESCE(auto_start, 0), COALESCE(managed, 0), created_at, updated_at
FROM cf_tunnels`
func (s *Store) List(ctx context.Context) ([]Tunnel, error) {
rows, err := s.db.QueryContext(ctx, tunnelSelect+` ORDER BY id ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Tunnel{}
for rows.Next() {
item, err := scanTunnel(rows)
if err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (s *Store) Get(ctx context.Context, id int64) (Tunnel, error) {
item, err := scanTunnel(s.db.QueryRowContext(ctx, tunnelSelect+` WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return Tunnel{}, fmt.Errorf("Cloudflare 隧道不存在")
}
return item, err
}
func (s *Store) Create(ctx context.Context, in CreateInput, tunnelID, accountTag, credentialsEnc, certPEMEnc, configPath, credsPath, logPath string) (Tunnel, error) {
network, _ := json.Marshal(in.Network)
result, err := s.db.ExecContext(ctx, `
INSERT INTO cf_tunnels(name, mode, tunnel_id, account_tag, credentials_enc, cert_pem_enc,
config_path, creds_path, log_path, network_json, status, auto_start, managed, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'stopped', ?, ?, datetime('now'))
`, in.Name, in.Mode, tunnelID, accountTag, credentialsEnc, certPEMEnc,
configPath, credsPath, logPath, string(network), boolInt(in.AutoStart), boolInt(in.Managed))
if err != nil {
return Tunnel{}, mapUniqueName(err)
}
id, _ := result.LastInsertId()
return s.Get(ctx, id)
}
func (s *Store) Update(ctx context.Context, id int64, in CreateInput, tunnelID, accountTag, credentialsEnc, certPEMEnc, configPath, credsPath, logPath string) (Tunnel, error) {
network, _ := json.Marshal(in.Network)
_, err := s.db.ExecContext(ctx, `
UPDATE cf_tunnels
SET name = ?, mode = ?, tunnel_id = ?, account_tag = ?, credentials_enc = ?,
cert_pem_enc = ?, config_path = ?, creds_path = ?, log_path = ?,
network_json = ?, auto_start = ?, managed = ?, updated_at = datetime('now')
WHERE id = ?
`, in.Name, in.Mode, tunnelID, accountTag, credentialsEnc, certPEMEnc,
configPath, credsPath, logPath, string(network), boolInt(in.AutoStart), boolInt(in.Managed), id)
if err != nil {
return Tunnel{}, mapUniqueName(err)
}
return s.Get(ctx, id)
}
func (s *Store) Delete(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM cf_tunnels WHERE id = ?`, id)
return err
}
func (s *Store) SetRuntime(ctx context.Context, id int64, status, lastError string, metricsPort int) error {
_, err := s.db.ExecContext(ctx, `
UPDATE cf_tunnels SET status = ?, last_error = ?, metrics_port = ?, updated_at = datetime('now')
WHERE id = ?
`, status, lastError, metricsPort, id)
return err
}
func (s *Store) UpdateCredentials(ctx context.Context, id int64, credentialsEnc string) error {
_, err := s.db.ExecContext(ctx, `
UPDATE cf_tunnels SET credentials_enc = ?, updated_at = datetime('now') WHERE id = ?
`, credentialsEnc, id)
return err
}
// settingsRow 是应用配置的原始行;TokenEnc 只在 Service 内部解密,不直接回给前端。
type settingsRow struct {
AccountID string
Enabled bool
TokenEnc string
APITokenEnc string
Mirror string
Network NetworkSettings
}
// DNSRecord 是 Fonu 自动创建或接管的 Cloudflare DNS 记录。
type DNSRecord struct {
ID int64 `json:"id"`
TunnelID int64 `json:"tunnel_id"`
ZoneID string `json:"zone_id"`
RecordID string `json:"record_id"`
Hostname string `json:"hostname"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (s *Store) ListDNSRecords(ctx context.Context, tunnelID int64) ([]DNSRecord, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, tunnel_id, zone_id, record_id, hostname, created_at, updated_at
FROM cf_dns_records WHERE tunnel_id = ? ORDER BY hostname ASC
`, tunnelID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []DNSRecord{}
for rows.Next() {
var item DNSRecord
if err := rows.Scan(&item.ID, &item.TunnelID, &item.ZoneID, &item.RecordID, &item.Hostname, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (s *Store) FindDNSRecord(ctx context.Context, hostname string) (DNSRecord, error) {
var item DNSRecord
err := s.db.QueryRowContext(ctx, `
SELECT id, tunnel_id, zone_id, record_id, hostname, created_at, updated_at
FROM cf_dns_records WHERE hostname = ?
`, strings.ToLower(strings.TrimSpace(hostname))).Scan(
&item.ID, &item.TunnelID, &item.ZoneID, &item.RecordID, &item.Hostname, &item.CreatedAt, &item.UpdatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return DNSRecord{}, fmt.Errorf("Cloudflare DNS 记录不存在")
}
return item, err
}
func (s *Store) UpsertDNSRecord(ctx context.Context, tunnelID int64, zoneID, recordID, hostname string) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO cf_dns_records(tunnel_id, zone_id, record_id, hostname, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(hostname) DO UPDATE SET
tunnel_id = excluded.tunnel_id,
zone_id = excluded.zone_id,
record_id = excluded.record_id,
updated_at = datetime('now')
`, tunnelID, zoneID, recordID, strings.ToLower(strings.TrimSpace(hostname)))
return err
}
func (s *Store) DeleteDNSRecord(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM cf_dns_records WHERE id = ?`, id)
return err
}
func (s *Store) DeleteDNSRecordsForTunnel(ctx context.Context, tunnelID int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM cf_dns_records WHERE tunnel_id = ?`, tunnelID)
return err
}
func (s *Store) GetSettingsRaw(ctx context.Context) (settingsRow, error) {
var accountID, tokenEnc, apiTokenEnc, mirror, networkJSON string
var enabled int
err := s.db.QueryRowContext(ctx, `
SELECT COALESCE(account_id, ''), COALESCE(enabled, 1), COALESCE(default_token_enc, ''), COALESCE(api_token_enc, ''),
COALESCE(mirror, 'official'), COALESCE(network_json, '{}')
FROM cf_settings WHERE id = 1
`).Scan(&accountID, &enabled, &tokenEnc, &apiTokenEnc, &mirror, &networkJSON)
if errors.Is(err, sql.ErrNoRows) {
return settingsRow{Enabled: true, Mirror: "official", Network: DefaultNetworkSettings()}, nil
}
if err != nil {
return settingsRow{}, err
}
row := settingsRow{AccountID: accountID, Enabled: enabled == 1, TokenEnc: tokenEnc, APITokenEnc: apiTokenEnc, Mirror: mirror, Network: DefaultNetworkSettings()}
_ = json.Unmarshal([]byte(networkJSON), &row.Network)
return row, nil
}
func (s *Store) SaveSettings(ctx context.Context, accountID string, enabled bool, tokenEnc, apiTokenEnc, mirror string, network NetworkSettings) error {
networkJSON, _ := json.Marshal(network)
_, err := s.db.ExecContext(ctx, `
UPDATE cf_settings
SET account_id = ?, enabled = ?, default_token_enc = ?, api_token_enc = ?, mirror = ?, network_json = ?, updated_at = datetime('now')
WHERE id = 1
`, accountID, boolInt(enabled), tokenEnc, apiTokenEnc, mirror, string(networkJSON))
return err
}
func scanTunnel(row interface{ Scan(dest ...any) error }) (Tunnel, error) {
var item Tunnel
var networkJSON, created, updated string
var autoStart, managed int
err := row.Scan(&item.ID, &item.Name, &item.Mode, &item.TunnelID, &item.AccountTag,
&item.CredentialsEnc, &item.CertPEMEnc, &item.ConfigPath, &item.CredsPath, &item.LogPath,
&networkJSON, &item.Status, &item.LastError, &item.MetricsPort, &autoStart, &managed, &created, &updated)
if err != nil {
return Tunnel{}, err
}
item.AutoStart = autoStart == 1
item.Managed = managed == 1
item.Network = DefaultNetworkSettings()
_ = json.Unmarshal([]byte(networkJSON), &item.Network)
item.CreatedAt = parseTime(created)
item.UpdatedAt = parseTime(updated)
return item, nil
}
func parseTime(raw string) time.Time {
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05"} {
if parsed, err := time.Parse(layout, raw); err == nil {
return parsed
}
}
return time.Time{}
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
func mapUniqueName(err error) error {
if err != nil && strings.Contains(err.Error(), "UNIQUE") {
return fmt.Errorf("Cloudflare 隧道名称已存在")
}
return err
}
+2
View File
@@ -21,6 +21,7 @@ type Config struct {
NginxDefaultHTTPSPort int NginxDefaultHTTPSPort int
FrpcBin string FrpcBin string
FrpPIDFile string FrpPIDFile string
UpdaterSocket string
} }
func Load() Config { func Load() Config {
@@ -39,6 +40,7 @@ func Load() Config {
NginxDefaultHTTPSPort: envIntOr("FONU_NGINX_HTTPS_PORT", 443), NginxDefaultHTTPSPort: envIntOr("FONU_NGINX_HTTPS_PORT", 443),
FrpcBin: envOr("FONU_FRPC_BIN", "frpc"), FrpcBin: envOr("FONU_FRPC_BIN", "frpc"),
FrpPIDFile: envOr("FONU_FRP_PID", dataDir+"/frp/frpc.pid"), FrpPIDFile: envOr("FONU_FRP_PID", dataDir+"/frp/frpc.pid"),
UpdaterSocket: envOr("FONU_UPDATER_SOCKET", "/run/fonu-updater/updater.sock"),
} }
} }
+4
View File
@@ -38,6 +38,10 @@ var catalog = []catalogEntry{
{Name: "PhotoPrism", Port: 2342, Platform: "photoprism", Keywords: []string{"photoprism"}}, {Name: "PhotoPrism", Port: 2342, Platform: "photoprism", Keywords: []string{"photoprism"}},
{Name: "qBittorrent", Port: 8080, Platform: "qbittorrent", Keywords: []string{"qbittorrent"}}, {Name: "qBittorrent", Port: 8080, Platform: "qbittorrent", Keywords: []string{"qbittorrent"}},
{Name: "Portainer", Port: 9000, Platform: "portainer", Keywords: []string{"portainer"}}, {Name: "Portainer", Port: 9000, Platform: "portainer", Keywords: []string{"portainer"}},
{Name: "Home Assistant", Port: 8123, Platform: "home-assistant", Keywords: []string{"home assistant", "homeassistant"}},
{Name: "Immich", Port: 2283, Platform: "immich", Keywords: []string{"immich"}},
{Name: "AdGuard Home", Port: 3000, Platform: "adguard-home", Keywords: []string{"adguard home", "adguardhome"}},
{Name: "Synology DSM", Port: 5000, Platform: "synology", Keywords: []string{"synology", "diskstation"}},
} }
func New() *Service { func New() *Service {
+138 -2
View File
@@ -3,10 +3,16 @@ package frp
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
@@ -18,16 +24,107 @@ import (
type AgentClient struct { type AgentClient struct {
baseURL string baseURL string
token string token string
pin string
http *http.Client http *http.Client
} }
func NewAgentClient(baseURL, token string) *AgentClient { func NewAgentClient(baseURL, token string) *AgentClient {
return &AgentClient{ return newAgentClient(baseURL, token, "")
}
func NewAgentClientWithPin(baseURL, token, pin string) *AgentClient {
return newAgentClient(baseURL, token, pin)
}
// NewAgentClientWithPins 支持证书轮换过渡期同时接受旧 pin 与新 pin。
func NewAgentClientWithPins(baseURL, token string, pins ...string) *AgentClient {
return newAgentClient(baseURL, token, pins...)
}
func newAgentClient(baseURL, token string, pins ...string) *AgentClient {
normalized := normalizePins(pins)
client := &AgentClient{
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
token: token, token: token,
pin: strings.Join(normalized, ","),
// 路由写入链路含 nginx -T 自省 + -t 校验 + reload(宝塔带 lua/waf 模块时各步可达数秒),15s 会超时 // 路由写入链路含 nginx -T 自省 + -t 校验 + reload(宝塔带 lua/waf 模块时各步可达数秒),15s 会超时
http: &http.Client{Timeout: 60 * time.Second}, http: &http.Client{Timeout: 60 * time.Second},
} }
if len(normalized) > 0 {
client.http.Transport = &http.Transport{TLSClientConfig: pinTLSConfig(hostFromBaseURL(client.baseURL), normalized...)}
}
return client
}
func normalizePins(pins []string) []string {
out := make([]string, 0, len(pins))
for _, pin := range pins {
pin = strings.ToLower(strings.TrimSpace(pin))
if pin == "" {
continue
}
out = append(out, pin)
}
return out
}
func hostFromBaseURL(baseURL string) string {
parsed, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil {
return ""
}
return parsed.Hostname()
}
func pinTLSConfig(host string, pins ...string) *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: true,
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return fmt.Errorf("服务器未返回证书")
}
cert, err := x509.ParseCertificate(rawCerts[0])
if err != nil {
return err
}
now := time.Now()
if now.Before(cert.NotBefore) || now.After(cert.NotAfter) {
return fmt.Errorf("Agent HTTPS 证书不在有效期内")
}
if err := verifyCertificateHost(cert, host); err != nil {
return err
}
sum := sha256.Sum256(rawCerts[0])
got := hex.EncodeToString(sum[:])
for _, pin := range pins {
if subtle.ConstantTimeCompare([]byte(got), []byte(pin)) == 1 {
return nil
}
}
return fmt.Errorf("Agent HTTPS 证书指纹不匹配")
},
}
}
// verifyCertificateHost 校验证书 SAN 是否匹配连接主机,避免 pin 正确但证书属于其他主机。
func verifyCertificateHost(cert *x509.Certificate, host string) error {
host = strings.TrimSpace(host)
if host == "" {
return nil
}
if ip := net.ParseIP(host); ip != nil {
for _, candidate := range cert.IPAddresses {
if candidate.Equal(ip) {
return nil
}
}
return fmt.Errorf("Agent HTTPS 证书不包含 IP %s", host)
}
if err := cert.VerifyHostname(host); err != nil {
return fmt.Errorf("Agent HTTPS 证书 SAN 不匹配 %s", host)
}
return nil
} }
func (c *AgentClient) request(ctx context.Context, method, path string, payload any, result any) error { func (c *AgentClient) request(ctx context.Context, method, path string, payload any, result any) error {
@@ -55,7 +152,10 @@ func (c *AgentClient) request(ctx context.Context, method, path string, payload
return err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &statusError{code: resp.StatusCode, body: strings.TrimSpace(string(data))} return &statusError{code: resp.StatusCode, body: strings.TrimSpace(string(data))}
} }
@@ -71,12 +171,48 @@ func (c *AgentClient) Ping(ctx context.Context) error {
return c.request(ctx, http.MethodGet, "/api/v1/ping", nil, nil) return c.request(ctx, http.MethodGet, "/api/v1/ping", nil, nil)
} }
func (c *AgentClient) ConfigureHTTPS(ctx context.Context, certPEM, keyPEM string, port int) (map[string]any, error) {
var result map[string]any
err := c.request(ctx, http.MethodPut, "/api/v1/transport/https", map[string]any{
"cert_pem": certPEM,
"key_pem": keyPEM,
"port": port,
}, &result)
return result, err
}
func (c *AgentClient) DisableHTTPS(ctx context.Context) error {
var result map[string]any
return c.request(ctx, http.MethodDelete, "/api/v1/transport/https", nil, &result)
}
// HTTPSState 读取 Agent 当前 HTTPS 反代状态与证书到期时间。
func (c *AgentClient) HTTPSState(ctx context.Context) (map[string]any, error) {
var result map[string]any
err := c.request(ctx, http.MethodGet, "/api/v1/transport/https", nil, &result)
return result, err
}
func (c *AgentClient) Status(ctx context.Context) (map[string]any, error) { func (c *AgentClient) Status(ctx context.Context) (map[string]any, error) {
var result map[string]any var result map[string]any
err := c.request(ctx, http.MethodGet, "/api/v1/status", nil, &result) err := c.request(ctx, http.MethodGet, "/api/v1/status", nil, &result)
return result, err return result, err
} }
// FirewallStatus 读取 VPS 主机防火墙类型与目标端口放行状态。
func (c *AgentClient) FirewallStatus(ctx context.Context, port int) (map[string]any, error) {
var result map[string]any
err := c.request(ctx, http.MethodGet, "/api/v1/firewall?port="+strconv.Itoa(port), nil, &result)
return result, err
}
// AllowFirewallPort 请求 Agent 显式放行主机防火墙端口(仅 ufw / firewalld)。
func (c *AgentClient) AllowFirewallPort(ctx context.Context, port int) (map[string]any, error) {
var result map[string]any
err := c.request(ctx, http.MethodPost, "/api/v1/firewall/allow", map[string]any{"port": port}, &result)
return result, err
}
// agentMinVersionInsights 是 P1 三个只读端点(metrics / logs / frps-config)要求的最低 agent 版本 // agentMinVersionInsights 是 P1 三个只读端点(metrics / logs / frps-config)要求的最低 agent 版本
const agentMinVersionInsights = "0.11.0" const agentMinVersionInsights = "0.11.0"
+72
View File
@@ -0,0 +1,72 @@
package frp
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"fmt"
"math/big"
"net"
"time"
)
type agentCertificate struct {
CertPEM string
KeyPEM string
Pin string
NotAfter time.Time
}
func generateAgentCertificate(host string) (agentCertificate, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return agentCertificate{}, err
}
serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serial, err := rand.Int(rand.Reader, serialLimit)
if err != nil {
return agentCertificate{}, err
}
now := time.Now()
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: host},
NotBefore: now.Add(-5 * time.Minute),
NotAfter: now.Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
if ip := net.ParseIP(host); ip != nil {
template.IPAddresses = []net.IP{ip}
} else {
template.DNSNames = []string{host}
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return agentCertificate{}, err
}
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return agentCertificate{}, err
}
sum := sha256.Sum256(der)
return agentCertificate{
CertPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})),
KeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})),
Pin: hex.EncodeToString(sum[:]),
NotAfter: template.NotAfter,
}, nil
}
func validatePin(pin string) error {
decoded, err := hex.DecodeString(pin)
if err != nil || len(decoded) != sha256.Size {
return fmt.Errorf("证书指纹格式无效")
}
return nil
}
+148
View File
@@ -0,0 +1,148 @@
package frp
import (
"crypto/x509"
"encoding/pem"
"net"
"strings"
"testing"
"time"
)
func TestGenerateAgentCertificate(t *testing.T) {
cert, err := generateAgentCertificate("127.0.0.1")
if err != nil {
t.Fatal(err)
}
if err := validatePin(cert.Pin); err != nil {
t.Fatalf("pin 无效: %v", err)
}
block, _ := pem.Decode([]byte(cert.CertPEM))
if block == nil {
t.Fatal("证书 PEM 解码失败")
}
parsed, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatal(err)
}
if len(parsed.IPAddresses) != 1 || !parsed.IPAddresses[0].Equal(net.ParseIP("127.0.0.1")) {
t.Fatalf("证书 IP SAN 不正确: %#v", parsed.IPAddresses)
}
}
func TestPinTLSConfigVerification(t *testing.T) {
cert, err := generateAgentCertificate("agent.example.com")
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode([]byte(cert.CertPEM))
if block == nil {
t.Fatal("证书 PEM 解码失败")
}
verify := pinTLSConfig("agent.example.com", cert.Pin).VerifyPeerCertificate
if err := verify([][]byte{block.Bytes}, nil); err != nil {
t.Fatalf("正确 pin 校验失败: %v", err)
}
wrong := pinTLSConfig("agent.example.com", strings.Repeat("0", 64)).VerifyPeerCertificate
if err := wrong([][]byte{block.Bytes}, nil); err == nil {
t.Fatal("错误 pin 应校验失败")
}
}
func TestPinTLSConfigAcceptsGracePins(t *testing.T) {
cert, err := generateAgentCertificate("agent.example.com")
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode([]byte(cert.CertPEM))
if block == nil {
t.Fatal("证书 PEM 解码失败")
}
verify := pinTLSConfig("agent.example.com", strings.Repeat("1", 64), cert.Pin).VerifyPeerCertificate
if err := verify([][]byte{block.Bytes}, nil); err != nil {
t.Fatalf("轮换过渡期应同时接受旧 pin 与新 pin: %v", err)
}
}
func TestPinTLSConfigRejectsHostMismatch(t *testing.T) {
cert, err := generateAgentCertificate("agent.example.com")
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode([]byte(cert.CertPEM))
if block == nil {
t.Fatal("证书 PEM 解码失败")
}
verify := pinTLSConfig("other.example.com", cert.Pin).VerifyPeerCertificate
if err := verify([][]byte{block.Bytes}, nil); err == nil {
t.Fatal("证书 SAN 与连接主机不一致时应拒绝")
}
}
func TestAgentHTTPSBaseURL(t *testing.T) {
cases := []struct {
name string
server Server
want string
}{
{
name: "旧隧道地址按监听端口重建",
server: Server{AgentURL: "http://127.0.0.1:17701", SSHHost: "agent.example.com", AgentListenAddr: "0.0.0.0:7443"},
want: "https://agent.example.com:7443",
},
{
name: "已是 HTTPS 地址保持不变",
server: Server{AgentURL: "https://agent.example.com:8443", SSHHost: "agent.example.com", AgentListenAddr: "0.0.0.0:7443"},
want: "https://agent.example.com:8443",
},
{
name: "监听地址缺少端口时使用默认端口",
server: Server{AgentURL: "http://127.0.0.1:17701", SSHHost: "agent.example.com"},
want: "https://agent.example.com:7443",
},
{
name: "缺少 SSH 主机时保留原地址",
server: Server{AgentURL: "http://127.0.0.1:17701"},
want: "http://127.0.0.1:17701",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := agentHTTPSBaseURL(tc.server); got != tc.want {
t.Fatalf("agentHTTPSBaseURL() = %q, 期望 %q", got, tc.want)
}
})
}
}
func TestAgentTransportPins(t *testing.T) {
active := Server{
AgentTLSPin: "new",
AgentTLSPinPrev: "old",
AgentTLSPinPrevUntil: time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
}
pins := agentTransportPins(active)
if len(pins) != 2 || pins[0] != "new" || pins[1] != "old" {
t.Fatalf("过渡期应同时返回新旧 pin:%v", pins)
}
expired := active
expired.AgentTLSPinPrevUntil = time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
if pins := agentTransportPins(expired); len(pins) != 1 || pins[0] != "new" {
t.Fatalf("过渡期结束后只应保留新 pin:%v", pins)
}
}
func TestResultPortAndListenPort(t *testing.T) {
if got := resultPort(map[string]any{"port": float64(8443)}, 0); got != 8443 {
t.Fatalf("resultPort() = %d, 期望 8443", got)
}
if got := resultPort(nil, 7443); got != 7443 {
t.Fatalf("resultPort() 回退值 = %d, 期望 7443", got)
}
if got := listenPort("0.0.0.0:9443", 7443); got != 9443 {
t.Fatalf("listenPort() = %d, 期望 9443", got)
}
if got := listenPort("", 7443); got != 7443 {
t.Fatalf("listenPort() 回退值 = %d, 期望 7443", got)
}
}
+20
View File
@@ -84,6 +84,13 @@ func (i AgentInstaller) Uninstall(ctx context.Context, keepData bool) (string, e
return strings.TrimSpace(out), err return strings.TrimSpace(out), err
} }
// RestorePreviousConfig 在安全迁移验证失败时恢复安装前的 agent.env,并重启旧监听配置。
func (i AgentInstaller) RestorePreviousConfig(ctx context.Context) error {
command := `if [ -f /etc/fonu-agent/agent.env.previous ]; then cp /etc/fonu-agent/agent.env.previous /etc/fonu-agent/agent.env && chmod 600 /etc/fonu-agent/agent.env && systemctl restart fonu-agent; fi`
_, err := i.SSH.Run(ctx, command)
return err
}
// Diagnose agent 失联时走 SSH 急救:进程、端口、服务日志尾部。 // Diagnose agent 失联时走 SSH 急救:进程、端口、服务日志尾部。
func (i AgentInstaller) Diagnose(ctx context.Context) (string, error) { func (i AgentInstaller) Diagnose(ctx context.Context) (string, error) {
return i.SSH.Run(ctx, "systemctl status fonu-agent --no-pager -l 2>/dev/null | head -20; echo '---'; journalctl -u fonu-agent -n 15 --no-pager 2>/dev/null; echo '---'; ss -tlnp 2>/dev/null | grep ':7700 ' || echo '7700 not listening'") return i.SSH.Run(ctx, "systemctl status fonu-agent --no-pager -l 2>/dev/null | head -20; echo '---'; journalctl -u fonu-agent -n 15 --no-pager 2>/dev/null; echo '---'; ss -tlnp 2>/dev/null | grep ':7700 ' || echo '7700 not listening'")
@@ -123,6 +130,15 @@ mkdir -p "$CONF_DIR" /var/lib/fonu-agent/certs
# 先停服务,再原子替换;安装后必须确认目标存在、可执行且能输出版本 # 先停服务,再原子替换;安装后必须确认目标存在、可执行且能输出版本
systemctl stop fonu-agent 2>/dev/null || true systemctl stop fonu-agent 2>/dev/null || true
TMP_PREFIX="${PREFIX}.new" TMP_PREFIX="${PREFIX}.new"
# 迁移旧版 Agent 留在 nginx include 范围内的 <配置>.versions 目录(保留历史版本内容)
for NGINX_DIR in /etc/nginx/sites-enabled /etc/nginx/conf.d; do
[ -d "$NGINX_DIR" ] || continue
for LEGACY in "$NGINX_DIR"/*.versions; do
[ -d "$LEGACY" ] || continue
TARGET="$NGINX_DIR/.$(basename "$LEGACY")"
[ -e "$TARGET" ] || mv "$LEGACY" "$TARGET" || true
done
done
install -m 0755 "$AGENT_BINARY" "$TMP_PREFIX" install -m 0755 "$AGENT_BINARY" "$TMP_PREFIX"
if [ ! -x "$TMP_PREFIX" ]; then if [ ! -x "$TMP_PREFIX" ]; then
echo "agent 二进制安装失败:$TMP_PREFIX 不存在或不可执行" >&2 echo "agent 二进制安装失败:$TMP_PREFIX 不存在或不可执行" >&2
@@ -144,6 +160,10 @@ if [ ! -f "$CONF_FILE" ]; then
# 用 /dev/urandom 生成 64 位 hex token,不依赖 openssl(缺失时会静默写入空 token,导致 Fonu 无法认证 agent) # 用 /dev/urandom 生成 64 位 hex token,不依赖 openssl(缺失时会静默写入空 token,导致 Fonu 无法认证 agent)
printf 'FONU_AGENT_TOKEN=%s\n' "$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" > "$CONF_FILE" printf 'FONU_AGENT_TOKEN=%s\n' "$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" > "$CONF_FILE"
fi fi
cp "$CONF_FILE" "$CONF_FILE.previous" 2>/dev/null || true
touch "$CONF_FILE"
sed -i '/^FONU_AGENT_LISTEN=/d; /^FONU_AGENT_ALLOW_REMOTE=/d' "$CONF_FILE"
printf 'FONU_AGENT_LISTEN=127.0.0.1:7700\nFONU_AGENT_ALLOW_REMOTE=0\n' >> "$CONF_FILE"
cat > "$UNIT" <<'UNIT' cat > "$UNIT" <<'UNIT'
[Unit] [Unit]
Description=Fonu FRPS management agent Description=Fonu FRPS management agent
+239
View File
@@ -0,0 +1,239 @@
package frp
import (
"context"
"fmt"
"io"
"net"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
const agentRemoteAddr = "127.0.0.1:7700"
const (
agentTunnelKeepalive = 30 * time.Second
agentTunnelBackoffMin = time.Second
agentTunnelBackoffMax = 60 * time.Second
)
type agentTunnel struct {
serverID int64
localPort int
ssh SSHInstaller
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
listener net.Listener
client *ssh.Client
state string
lastError string
wg sync.WaitGroup
}
func newAgentTunnel(parent context.Context, serverID int64, localPort int, installer SSHInstaller) *agentTunnel {
return newAgentTunnelWithListener(parent, serverID, localPort, installer, nil)
}
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",
}
}
func (t *agentTunnel) Start() error {
if t.listener == nil {
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", t.localPort))
if err != nil {
t.setState("failed", err)
return fmt.Errorf("监听隧道本地端口失败: %w", err)
}
t.listener = listener
}
t.mu.Lock()
t.state = "connected"
t.lastError = ""
t.mu.Unlock()
t.wg.Add(2)
go t.acceptLoop()
go t.monitorLoop()
return nil
}
// monitorLoop 主动保活并在断开后按指数退避重建 SSH 连接,避免只在有流量时才尝试重连。
func (t *agentTunnel) monitorLoop() {
defer t.wg.Done()
backoff := agentTunnelBackoffMin
for {
_, err := t.ensureClient()
wait := agentTunnelKeepalive
if err != nil {
t.setState("reconnecting", err)
backoff = nextTunnelBackoff(backoff)
wait = backoff
} else {
t.setState("connected", nil)
backoff = agentTunnelBackoffMin
}
if !t.sleep(wait) {
return
}
}
}
func (t *agentTunnel) sleep(d time.Duration) bool {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-t.ctx.Done():
return false
case <-timer.C:
return true
}
}
func nextTunnelBackoff(current time.Duration) time.Duration {
if current < agentTunnelBackoffMin {
return agentTunnelBackoffMin
}
next := current * 2
if next > agentTunnelBackoffMax {
return agentTunnelBackoffMax
}
return next
}
func (t *agentTunnel) acceptLoop() {
defer t.wg.Done()
for {
conn, err := t.listener.Accept()
if err != nil {
if t.ctx.Err() != nil {
return
}
t.setState("failed", err)
return
}
t.wg.Add(1)
go func() {
defer t.wg.Done()
t.handle(conn)
}()
}
}
func (t *agentTunnel) handle(local net.Conn) {
defer local.Close()
client, err := t.ensureClient()
if err != nil {
t.setState("reconnecting", err)
return
}
remote, err := client.Dial("tcp", agentRemoteAddr)
if err != nil {
t.invalidateClient(client)
t.setState("reconnecting", err)
return
}
defer remote.Close()
t.setState("connected", nil)
proxyTunnel(local, remote)
}
func (t *agentTunnel) ensureClient() (*ssh.Client, error) {
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()
}
ctx, cancel := context.WithTimeout(t.ctx, 15*time.Second)
defer cancel()
client, err := t.ssh.client(ctx)
if err != nil {
return nil, err
}
t.mu.Lock()
if t.client != nil {
existing := t.client
t.mu.Unlock()
_ = client.Close()
return existing, nil
}
t.client = client
t.mu.Unlock()
return client, nil
}
func (t *agentTunnel) invalidateClient(client *ssh.Client) {
t.mu.Lock()
if t.client == client {
t.client = nil
}
t.mu.Unlock()
_ = client.Close()
}
func (t *agentTunnel) State() (string, string) {
t.mu.Lock()
defer t.mu.Unlock()
return t.state, t.lastError
}
func (t *agentTunnel) setState(state string, err error) {
t.mu.Lock()
defer t.mu.Unlock()
t.state = state
if err == nil {
t.lastError = ""
return
}
t.lastError = err.Error()
}
func (t *agentTunnel) Stop() {
t.cancel()
t.mu.Lock()
if t.listener != nil {
_ = t.listener.Close()
}
if t.client != nil {
_ = t.client.Close()
}
t.mu.Unlock()
t.wg.Wait()
}
func proxyTunnel(a, b net.Conn) {
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
_, _ = io.Copy(a, b)
_ = a.Close()
_ = b.Close()
}()
go func() {
defer wg.Done()
_, _ = io.Copy(b, a)
_ = a.Close()
_ = b.Close()
}()
wg.Wait()
}
+53
View File
@@ -0,0 +1,53 @@
package frp
import (
"context"
"fmt"
"net"
"testing"
"time"
)
func TestAgentTunnelBindsLoopbackAndReleasesPort(t *testing.T) {
probe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := probe.Addr().(*net.TCPAddr).Port
_ = probe.Close()
tunnel := newAgentTunnel(context.Background(), 1, port, SSHInstaller{})
if err := tunnel.Start(); err != nil {
t.Fatal(err)
}
if _, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)); err == nil {
t.Fatal("隧道启动后本地端口应已被占用")
}
state, _ := tunnel.State()
if state != "connected" && state != "reconnecting" {
t.Fatalf("state=%q, want connected 或 reconnecting", state)
}
tunnel.Stop()
if listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)); err != nil {
t.Fatalf("隧道停止后端口未释放: %v", err)
} else {
_ = listener.Close()
}
}
func TestNextTunnelBackoff(t *testing.T) {
cases := []struct {
in time.Duration
want time.Duration
}{
{0, agentTunnelBackoffMin},
{time.Second, 2 * time.Second},
{40 * time.Second, agentTunnelBackoffMax},
{agentTunnelBackoffMax, agentTunnelBackoffMax},
}
for _, tc := range cases {
if got := nextTunnelBackoff(tc.in); got != tc.want {
t.Fatalf("nextTunnelBackoff(%v) = %v, 期望 %v", tc.in, got, tc.want)
}
}
}
@@ -0,0 +1,73 @@
package frp
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestAgentEndpointMissingDetection 锁定旧 Agent 的 404 / 405 兼容提示,且不把其它错误误判为版本过低。
func TestAgentEndpointMissingDetection(t *testing.T) {
cases := []struct {
name string
status int
minVersion string
route bool
wantMissing bool
}{
{"404 判定版本过低", http.StatusNotFound, agentMinVersionInsights, false, true},
{"405 判定版本过低", http.StatusMethodNotAllowed, agentMinVersionRouteVersions, true, true},
{"500 不判定版本过低", http.StatusInternalServerError, agentMinVersionInsights, false, false},
{"400 不判定版本过低", http.StatusBadRequest, agentMinVersionInsights, false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(`{"error":"test"}`))
}))
defer server.Close()
client := NewAgentClient(server.URL, "token")
var err error
if tc.route {
_, err = client.RouteVersions(context.Background(), "example.com")
} else {
_, err = client.Metrics(context.Background())
}
if tc.wantMissing {
if !errors.Is(err, ErrAgentEndpointMissing) {
t.Fatalf("应判定为端点缺失:%v", err)
}
msg := err.Error()
if !strings.Contains(msg, tc.minVersion) || !strings.Contains(msg, "升级 Agent") {
t.Fatalf("错误信息应包含最低版本与升级提示:%q", msg)
}
return
}
if errors.Is(err, ErrAgentEndpointMissing) {
t.Fatalf("不应误判为版本过低:%v", err)
}
if err == nil {
t.Fatal("应返回错误")
}
})
}
}
func TestAgentStatusVersionField(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"agent_version":"0.10.0","listen_addr":"127.0.0.1:7700"}`))
}))
defer server.Close()
client := NewAgentClient(server.URL, "token")
status, err := client.Status(context.Background())
if err != nil {
t.Fatal(err)
}
if status["agent_version"] != "0.10.0" {
t.Fatalf("agent_version 解析失败:%#v", status)
}
}
Binary file not shown.
Binary file not shown.
+10
View File
@@ -87,6 +87,16 @@ type Server struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
AgentURL string `json:"agent_url,omitempty"` AgentURL string `json:"agent_url,omitempty"`
AgentTransport string `json:"agent_transport"`
AgentLocalPort int `json:"agent_local_port,omitempty"`
AgentTLSPin string `json:"agent_tls_pin,omitempty"`
AgentTLSPinPrev string `json:"agent_tls_pin_prev,omitempty"`
AgentTLSPinPrevUntil string `json:"agent_tls_pin_prev_until,omitempty"`
AgentTLSNotAfter string `json:"agent_tls_not_after,omitempty"`
AgentFirewallState string `json:"agent_firewall_state,omitempty"`
AgentListenAddr string `json:"agent_listen_addr,omitempty"`
AgentTransportState string `json:"agent_transport_state"`
AgentTransportError string `json:"agent_transport_error,omitempty"`
AgentConfigured bool `json:"agent_configured"` AgentConfigured bool `json:"agent_configured"`
SSHConfigured bool `json:"ssh_configured"` SSHConfigured bool `json:"ssh_configured"`
MgmtEnabled bool `json:"mgmt_enabled"` MgmtEnabled bool `json:"mgmt_enabled"`
+625 -5
View File
@@ -8,6 +8,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -49,6 +50,9 @@ type Service struct {
agentMu sync.Mutex agentMu sync.Mutex
agentClients map[int64]cachedAgentClient agentClients map[int64]cachedAgentClient
tunnelMu sync.Mutex
tunnels map[int64]*agentTunnel
// 诊断短 TTL 缓存与并发上限,避免列表页轮询叠加外部探测 // 诊断短 TTL 缓存与并发上限,避免列表页轮询叠加外部探测
diagMu sync.Mutex diagMu sync.Mutex
diagCache map[int64]diagnosticsCacheEntry diagCache map[int64]diagnosticsCacheEntry
@@ -79,6 +83,7 @@ func NewService(cfg config.Config, store *Store, secretBox *secret.Box, logger *
trafficClient: newFRPSTrafficClient(), trafficClient: newFRPSTrafficClient(),
trafficLast: map[int64]trafficSample{}, trafficLast: map[int64]trafficSample{},
diagCache: map[int64]diagnosticsCacheEntry{}, diagCache: map[int64]diagnosticsCacheEntry{},
tunnels: map[int64]*agentTunnel{},
} }
return service return service
} }
@@ -356,6 +361,10 @@ func (s *Service) enrichServerRuntime(ctx context.Context, server *Server) {
server.LastDisconnectedAt = runtime.LastDisconnectedAt server.LastDisconnectedAt = runtime.LastDisconnectedAt
server.LastError = runtime.LastError server.LastError = runtime.LastError
server.LastStartedAt = runtime.LastStartedAt server.LastStartedAt = runtime.LastStartedAt
if state, lastError, ok := s.tunnelState(server.ID); ok {
server.AgentTransportState = state
server.AgentTransportError = lastError
}
} }
func (s *Service) setServerRuntime(ctx context.Context, serverID int64, status, lastError string) { func (s *Service) setServerRuntime(ctx context.Context, serverID int64, status, lastError string) {
@@ -629,10 +638,14 @@ func (s *Service) UpdateServer(ctx context.Context, id int64, in ServerInput) (S
if !server.Enabled { if !server.Enabled {
_ = s.managerForServer(id).Stop(ctx) _ = s.managerForServer(id).Stop(ctx)
} }
if !server.MgmtEnabled || server.AgentTransport != "tunnel" {
s.stopAgentTunnel(id)
}
return server, s.persistConfig(ctx) return server, s.persistConfig(ctx)
} }
func (s *Service) DeleteServer(ctx context.Context, id int64) error { func (s *Service) DeleteServer(ctx context.Context, id int64) error {
s.stopAgentTunnel(id)
if err := s.managerForServer(id).Stop(ctx); err != nil { if err := s.managerForServer(id).Stop(ctx); err != nil {
return err return err
} }
@@ -732,10 +745,33 @@ func (s *Service) AgentInstall(ctx context.Context, serverID int64) (Server, err
if err != nil { if err != nil {
return Server{}, err return Server{}, err
} }
// 已是 HTTPS 直连时,升级 Agent 不应把用户的传输方式改回隧道。
if server.AgentTransport == "https-pin" {
return s.keepAgentHTTPSAfterInstall(ctx, server, token, agentToken)
}
localPort, listener, err := s.reserveAgentTunnelPort(ctx, serverID, server.AgentLocalPort)
if err != nil {
return Server{}, err
}
tunnel := newAgentTunnelWithListener(s.tunnelContext(ctx), serverID, localPort, installer, listener)
if err := tunnel.Start(); err != nil {
_ = listener.Close()
return Server{}, err
}
baseURL := fmt.Sprintf("http://127.0.0.1:%d", localPort)
client := NewAgentClient(baseURL, token)
if err := client.Ping(ctx); err != nil {
tunnel.Stop()
rollbackErr := (AgentInstaller{SSH: installer}).RestorePreviousConfig(ctx)
if rollbackErr != nil {
return Server{}, fmt.Errorf("Agent 已安装,但 SSH 隧道验证失败: %w;恢复旧监听配置也失败: %v", err, rollbackErr)
}
return Server{}, fmt.Errorf("Agent 已安装,但 SSH 隧道验证失败,已恢复旧监听配置: %w", err)
}
input := ServerInput{ input := ServerInput{
Name: server.Name, ServerAddr: server.ServerAddr, ServerPort: server.ServerPort, Name: server.Name, ServerAddr: server.ServerAddr, ServerPort: server.ServerPort,
TLS: server.TLS, TLSServerName: server.TLSServerName, Enabled: server.Enabled, TLS: server.TLS, TLSServerName: server.TLSServerName, Enabled: server.Enabled,
Options: server.Options, AgentURL: fmt.Sprintf("http://%s:%d", server.SSHHost, 7700), Options: server.Options, AgentURL: baseURL,
AgentToken: token, SSHHost: server.SSHHost, SSHPort: server.SSHPort, SSHUser: server.SSHUser, AgentToken: token, SSHHost: server.SSHHost, SSHPort: server.SSHPort, SSHUser: server.SSHUser,
SSHAuth: server.SSHAuth, MgmtEnabled: true, SSHAuth: server.SSHAuth, MgmtEnabled: true,
} }
@@ -743,6 +779,508 @@ func (s *Service) AgentInstall(ctx context.Context, serverID int64) (Server, err
if err != nil { if err != nil {
return Server{}, err return Server{}, err
} }
s.tunnelMu.Lock()
if existing := s.tunnels[serverID]; existing != nil {
existing.Stop()
}
s.tunnels[serverID] = tunnel
s.tunnelMu.Unlock()
if err := s.store.SetAgentTransport(ctx, serverID, baseURL, "tunnel", localPort, "", "127.0.0.1:7700", "connected", ""); err != nil {
tunnel.Stop()
_ = (AgentInstaller{SSH: installer}).RestorePreviousConfig(ctx)
return Server{}, err
}
// 旧版升级流程可能残留 fonuagent-https.conf,这里通过隧道顺手关闭,避免 7443 继续暴露。
_ = client.DisableHTTPS(ctx)
s.invalidateAgentClient(serverID)
return updated, nil
}
// keepAgentHTTPSAfterInstall 在 HTTPS 直连模式下完成 Agent 升级:
// 保持 agent_url、pin 与 transport 不变,只更新 Token 密文并重新验证连通。
func (s *Service) keepAgentHTTPSAfterInstall(ctx context.Context, server Server, token, agentToken string) (Server, error) {
baseURL := agentHTTPSBaseURL(server)
pins := agentTransportPins(server)
if len(pins) == 0 {
return Server{}, fmt.Errorf("Agent 已升级,但 HTTPS 直连缺少证书指纹;请重新切换 HTTPS 直连")
}
client := NewAgentClientWithPins(baseURL, token, pins...)
if err := client.Ping(ctx); err != nil {
return Server{}, fmt.Errorf("Agent 已升级,但 HTTPS 直连验证失败: %w", err)
}
input := ServerInput{
Name: server.Name, ServerAddr: server.ServerAddr, ServerPort: server.ServerPort,
TLS: server.TLS, TLSServerName: server.TLSServerName, Enabled: server.Enabled,
Options: server.Options, AgentURL: baseURL,
AgentToken: token, SSHHost: server.SSHHost, SSHPort: server.SSHPort, SSHUser: server.SSHUser,
SSHAuth: server.SSHAuth, MgmtEnabled: true,
}
if _, err := s.store.UpdateServer(ctx, server.ID, input, "", false, agentToken, true, "", false); err != nil {
return Server{}, err
}
listenAddr := strings.TrimSpace(server.AgentListenAddr)
if listenAddr == "" {
listenAddr = fmt.Sprintf("0.0.0.0:%d", listenPort("", 7443))
}
if err := s.store.SetAgentTransport(ctx, server.ID, baseURL, "https-pin", server.AgentLocalPort, server.AgentTLSPin, listenAddr, "connected", ""); err != nil {
return Server{}, err
}
s.invalidateAgentClient(server.ID)
return s.store.GetServer(ctx, server.ID)
}
func (s *Service) tunnelContext(fallback context.Context) context.Context {
s.downloadMu.RLock()
base := s.baseCtx
s.downloadMu.RUnlock()
if base != nil {
return base
}
if fallback != nil {
return fallback
}
return context.Background()
}
func (s *Service) reserveAgentTunnelPort(ctx context.Context, ownerID int64, preferred int) (int, net.Listener, error) {
s.tunnelMu.Lock()
defer s.tunnelMu.Unlock()
servers, err := s.store.ListServers(ctx)
if err != nil {
return 0, nil, err
}
used := map[int]bool{}
for _, server := range servers {
if server.ID != ownerID && server.AgentLocalPort > 0 {
used[server.AgentLocalPort] = true
}
}
if preferred > 0 && !used[preferred] {
if listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", preferred)); err == nil {
return preferred, listener, nil
}
}
for port := 17700; port <= 18700; port++ {
if used[port] {
continue
}
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
continue
}
return port, listener, nil
}
return 0, nil, fmt.Errorf("SSH 隧道本地端口 17700-18700 已耗尽")
}
func (s *Service) ensureAgentTunnel(ctx context.Context, serverID int64) (*agentTunnel, error) {
s.tunnelMu.Lock()
if tunnel := s.tunnels[serverID]; tunnel != nil {
s.tunnelMu.Unlock()
return tunnel, nil
}
s.tunnelMu.Unlock()
server, installer, err := s.sshInstaller(ctx, serverID)
if err != nil {
return nil, err
}
localPort, listener, err := s.reserveAgentTunnelPort(ctx, serverID, server.AgentLocalPort)
if err != nil {
return nil, err
}
tunnel := newAgentTunnelWithListener(s.tunnelContext(ctx), serverID, localPort, installer, listener)
if err := tunnel.Start(); err != nil {
_ = listener.Close()
return nil, err
}
s.tunnelMu.Lock()
if existing := s.tunnels[serverID]; existing != nil {
s.tunnelMu.Unlock()
tunnel.Stop()
return existing, nil
}
s.tunnels[serverID] = tunnel
s.tunnelMu.Unlock()
baseURL := fmt.Sprintf("http://127.0.0.1:%d", localPort)
if err := s.store.SetAgentTransport(ctx, serverID, baseURL, "tunnel", localPort, "", "127.0.0.1:7700", "connected", ""); err != nil {
tunnel.Stop()
return nil, err
}
s.invalidateAgentClient(serverID)
return tunnel, nil
}
// StartAgentTunnels 在应用启动后恢复所有 tunnel transport。
func (s *Service) StartAgentTunnels(ctx context.Context) {
servers, err := s.store.ListServers(ctx)
if err != nil {
s.logger.Warn("恢复 Agent 隧道失败", "error", err.Error())
return
}
for _, server := range servers {
if !server.MgmtEnabled || server.AgentTransport != "tunnel" {
continue
}
if _, err := s.ensureAgentTunnel(ctx, server.ID); err != nil {
s.logger.Warn("Agent 隧道启动失败", "server_id", server.ID, "error", err.Error())
continue
}
s.cleanupLegacyAgentHTTPS(ctx, server.ID)
}
}
// cleanupLegacyAgentHTTPS 清理隧道模式下可能残留的 fonuagent-https.conf,避免 7443 继续暴露。
func (s *Service) cleanupLegacyAgentHTTPS(ctx context.Context, serverID int64) {
client, ok := s.agentClient(ctx, serverID)
if !ok {
return
}
_ = client.DisableHTTPS(ctx)
}
func (s *Service) stopAgentTunnels() {
s.tunnelMu.Lock()
tunnels := make([]*agentTunnel, 0, len(s.tunnels))
for id, tunnel := range s.tunnels {
tunnels = append(tunnels, tunnel)
delete(s.tunnels, id)
}
s.tunnelMu.Unlock()
for _, tunnel := range tunnels {
tunnel.Stop()
}
}
func (s *Service) stopAgentTunnel(serverID int64) {
s.tunnelMu.Lock()
tunnel := s.tunnels[serverID]
delete(s.tunnels, serverID)
s.tunnelMu.Unlock()
if tunnel != nil {
tunnel.Stop()
}
}
func (s *Service) tunnelState(serverID int64) (string, string, bool) {
s.tunnelMu.Lock()
tunnel := s.tunnels[serverID]
s.tunnelMu.Unlock()
if tunnel == nil {
return "", "", false
}
state, lastError := tunnel.State()
return state, lastError, true
}
func (s *Service) RestartAgentTunnel(ctx context.Context, serverID int64) (Server, error) {
current, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
s.stopAgentTunnel(serverID)
if _, err := s.ensureAgentTunnel(ctx, serverID); err != nil {
_ = s.store.SetAgentTransport(ctx, serverID, current.AgentURL, "tunnel", current.AgentLocalPort, current.AgentTLSPin, current.AgentListenAddr, "failed", err.Error())
return Server{}, err
}
s.cleanupLegacyAgentHTTPS(ctx, serverID)
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
s.enrichServerRuntime(ctx, &server)
return server, nil
}
// AgentTransportStatus 返回传输方式的详细状态,供前端与诊断使用。
func (s *Service) AgentTransportStatus(ctx context.Context, serverID int64) (map[string]any, error) {
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return nil, err
}
state := server.AgentTransportState
lastError := server.AgentTransportError
if server.AgentTransport == "tunnel" {
if tunnelState, tunnelError, ok := s.tunnelState(serverID); ok {
state, lastError = tunnelState, tunnelError
}
}
return map[string]any{
"transport": server.AgentTransport,
"state": state,
"agent_url": server.AgentURL,
"local_port": server.AgentLocalPort,
"listen_addr": server.AgentListenAddr,
"tls_pin": server.AgentTLSPin,
"tls_not_after": server.AgentTLSNotAfter,
"pin_prev_until": server.AgentTLSPinPrevUntil,
"firewall_state": server.AgentFirewallState,
"last_error": lastError,
}, nil
}
// AgentFirewallStatus 检测 VPS 主机防火墙,并把结果缓存到服务端记录供 UI 展示。
func (s *Service) AgentFirewallStatus(ctx context.Context, serverID int64, port int) (map[string]any, error) {
if port <= 0 || port > 65535 {
return nil, fmt.Errorf("端口无效")
}
client, ok := s.agentClient(ctx, serverID)
if !ok {
return nil, fmt.Errorf("当前 Agent 连接不可用")
}
status, err := client.FirewallStatus(ctx, port)
if err != nil {
return nil, err
}
_ = s.store.SetAgentFirewallState(ctx, serverID, firewallSummary(status))
return status, nil
}
// AllowAgentFirewallPort 由用户显式触发,Agent 仅对 ufw / firewalld 执行放行。
func (s *Service) AllowAgentFirewallPort(ctx context.Context, serverID int64, port int) (map[string]any, error) {
if port <= 0 || port > 65535 {
return nil, fmt.Errorf("端口无效")
}
client, ok := s.agentClient(ctx, serverID)
if !ok {
return nil, fmt.Errorf("当前 Agent 连接不可用")
}
result, err := client.AllowFirewallPort(ctx, port)
if err != nil {
return nil, err
}
_ = s.store.SetAgentFirewallState(ctx, serverID, firewallSummary(result))
return result, nil
}
func firewallSummary(result map[string]any) string {
status := result
if nested, ok := result["status"].(map[string]any); ok {
status = nested
}
tool, _ := status["tool"].(string)
open, _ := status["port_open"].(bool)
if tool == "" {
return ""
}
if open {
return tool + ":已放行"
}
return tool + ":未放行"
}
func (s *Service) agentToken(ctx context.Context, serverID int64) (string, error) {
encoded, err := s.store.AgentToken(ctx, serverID)
if err != nil || encoded == "" {
return "", fmt.Errorf("Agent Token 未保存")
}
token, err := s.secretBox.Decrypt(encoded)
if err != nil || token == "" {
return "", fmt.Errorf("Agent Token 无法解密")
}
return token, nil
}
func (s *Service) EnableAgentHTTPS(ctx context.Context, serverID int64, port int) (Server, error) {
if port < 0 || port > 65535 {
return Server{}, fmt.Errorf("HTTPS 端口无效")
}
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
if server.AgentTransport == "http" {
return Server{}, fmt.Errorf("请先通过「升级 Agent」迁移到 SSH 隧道,再切换 HTTPS 直连")
}
currentClient, ok := s.agentClient(ctx, serverID)
if !ok {
return Server{}, fmt.Errorf("当前 Agent 连接不可用")
}
token, err := s.agentToken(ctx, serverID)
if err != nil {
return Server{}, err
}
cert, err := generateAgentCertificate(server.SSHHost)
if err != nil {
return Server{}, err
}
result, err := currentClient.ConfigureHTTPS(ctx, cert.CertPEM, cert.KeyPEM, port)
if err != nil {
return Server{}, err
}
actualPort := resultPort(result, port)
if actualPort <= 0 {
return Server{}, fmt.Errorf("Agent 未返回可用的 HTTPS 端口")
}
baseURL := fmt.Sprintf("https://%s:%d", server.SSHHost, actualPort)
pinned := NewAgentClientWithPin(baseURL, token, cert.Pin)
if err := pinned.Ping(ctx); err != nil {
_ = currentClient.DisableHTTPS(ctx)
if result["self_check"] == "ok" {
return Server{}, fmt.Errorf("HTTPS 直连验证失败:Agent 本机 %d 端口自检通过,但公网 %s 返回的证书不一致,请检查该端口是否被其他服务、端口转发或防火墙接管: %w", actualPort, baseURL, err)
}
return Server{}, fmt.Errorf("HTTPS 直连验证失败: %w", err)
}
if err := s.store.SetAgentTransport(ctx, serverID, baseURL, "https-pin", server.AgentLocalPort, cert.Pin, fmt.Sprintf("0.0.0.0:%d", actualPort), "connected", ""); err != nil {
return Server{}, err
}
if err := s.store.SetAgentTLSPinRotation(ctx, serverID, cert.Pin, "", "", cert.NotAfter.UTC().Format(time.RFC3339)); err != nil {
return Server{}, err
}
s.invalidateAgentClient(serverID)
s.stopAgentTunnel(serverID)
updated, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
return updated, nil
}
const (
agentCertRenewBefore = 30 * 24 * time.Hour
agentCertPinGrace = 7 * 24 * time.Hour
)
// StartAgentCertificateRenewal 后台巡检 HTTPS 直连证书,剩余不足 30 天时自动轮换。
func (s *Service) StartAgentCertificateRenewal(ctx context.Context) {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
s.renewAgentCertificates(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.renewAgentCertificates(ctx)
}
}
}
func (s *Service) renewAgentCertificates(ctx context.Context) {
servers, err := s.store.ListServers(ctx)
if err != nil {
s.logger.Warn("巡检 Agent 证书失败", "error", err.Error())
return
}
for _, server := range servers {
if !server.MgmtEnabled || server.AgentTransport != "https-pin" {
continue
}
notAfter, ok := s.agentCertificateExpiry(ctx, server)
if !ok || time.Until(notAfter) > agentCertRenewBefore {
continue
}
if _, err := s.RotateAgentCertificate(ctx, server.ID); err != nil {
s.logger.Warn("Agent 证书自动轮换失败", "server_id", server.ID, "error", err.Error())
}
}
}
// agentCertificateExpiry 优先使用数据库缓存;旧记录缺少到期时间时从 Agent 补齐。
func (s *Service) agentCertificateExpiry(ctx context.Context, server Server) (time.Time, bool) {
if raw := strings.TrimSpace(server.AgentTLSNotAfter); raw != "" {
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
return parsed, true
}
}
client, ok := s.agentClient(ctx, server.ID)
if !ok {
return time.Time{}, false
}
state, err := client.HTTPSState(ctx)
if err != nil {
return time.Time{}, false
}
raw, _ := state["not_after"].(string)
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(raw))
if err != nil {
return time.Time{}, false
}
_ = s.store.SetAgentTLSNotAfter(ctx, server.ID, parsed.UTC().Format(time.RFC3339))
return parsed, true
}
// RotateAgentCertificate 安装新证书;过渡期内客户端同时接受新 pin 与旧 pin。
func (s *Service) RotateAgentCertificate(ctx context.Context, serverID int64) (Server, error) {
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
if server.AgentTransport != "https-pin" {
return Server{}, fmt.Errorf("仅 HTTPS 直连模式支持证书轮换")
}
token, err := s.agentToken(ctx, serverID)
if err != nil {
return Server{}, err
}
cert, err := generateAgentCertificate(server.SSHHost)
if err != nil {
return Server{}, err
}
oldPin := strings.TrimSpace(server.AgentTLSPin)
port := listenPort(server.AgentListenAddr, 7443)
configureClient := NewAgentClientWithPins(agentHTTPSBaseURL(server), token, cert.Pin, oldPin)
result, err := configureClient.ConfigureHTTPS(ctx, cert.CertPEM, cert.KeyPEM, port)
if err != nil {
return Server{}, err
}
actualPort := resultPort(result, port)
baseURL := fmt.Sprintf("https://%s:%d", server.SSHHost, actualPort)
graceUntil := time.Now().Add(agentCertPinGrace).UTC().Format(time.RFC3339)
notAfter := cert.NotAfter.UTC().Format(time.RFC3339)
if err := s.store.SetAgentTransport(ctx, serverID, baseURL, "https-pin", server.AgentLocalPort, cert.Pin, fmt.Sprintf("0.0.0.0:%d", actualPort), "connected", ""); err != nil {
return Server{}, err
}
if err := s.store.SetAgentTLSPinRotation(ctx, serverID, cert.Pin, oldPin, graceUntil, notAfter); err != nil {
return Server{}, err
}
s.invalidateAgentClient(serverID)
dual := NewAgentClientWithPins(baseURL, token, cert.Pin, oldPin)
if err := dual.Ping(ctx); err != nil {
return Server{}, fmt.Errorf("证书轮换后公网校验失败(旧 pin 在过渡期仍可用): %w", err)
}
return s.store.GetServer(ctx, serverID)
}
func resultPort(result map[string]any, fallback int) int {
if raw, ok := result["port"].(float64); ok && raw > 0 && raw <= 65535 {
return int(raw)
}
return fallback
}
func listenPort(listenAddr string, fallback int) int {
if _, rawPort, err := net.SplitHostPort(strings.TrimSpace(listenAddr)); err == nil {
if parsed, err := strconv.Atoi(rawPort); err == nil && parsed > 0 && parsed <= 65535 {
return parsed
}
}
return fallback
}
func (s *Service) SwitchAgentToTunnel(ctx context.Context, serverID int64) (Server, error) {
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
if server.AgentTransport == "https-pin" {
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)
}
}
if _, err := s.ensureAgentTunnel(ctx, serverID); err != nil {
return Server{}, err
}
updated, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
s.enrichServerRuntime(ctx, &updated)
return updated, nil return updated, nil
} }
@@ -751,7 +1289,12 @@ func (s *Service) AgentUninstall(ctx context.Context, serverID int64, keepData b
if err != nil { if err != nil {
return "", err return "", err
} }
return AgentInstaller{SSH: installer}.Uninstall(ctx, keepData) output, err := AgentInstaller{SSH: installer}.Uninstall(ctx, keepData)
if err == nil {
s.stopAgentTunnel(serverID)
_ = s.store.SetAgentTransport(ctx, serverID, "", "tunnel", 0, "", "", "stopped", "")
}
return output, err
} }
func (s *Service) AgentSSHDiagnose(ctx context.Context, serverID int64) (string, error) { func (s *Service) AgentSSHDiagnose(ctx context.Context, serverID int64) (string, error) {
@@ -1305,9 +1848,53 @@ type cachedAgentClient struct {
client *AgentClient client *AgentClient
} }
func (s *Service) invalidateAgentClient(serverID int64) {
s.agentMu.Lock()
delete(s.agentClients, serverID)
s.agentMu.Unlock()
}
func agentHTTPSBaseURL(server Server) string {
current := strings.TrimRight(strings.TrimSpace(server.AgentURL), "/")
if strings.HasPrefix(strings.ToLower(current), "https://") {
return current
}
host := strings.TrimSpace(server.SSHHost)
if host == "" {
return current
}
port := listenPort(server.AgentListenAddr, 7443)
return fmt.Sprintf("https://%s:%d", host, port)
}
// agentTransportPins 返回当前应接受的证书 pin;轮换过渡期内同时包含旧 pin。
func agentTransportPins(server Server) []string {
out := make([]string, 0, 2)
if pin := strings.TrimSpace(server.AgentTLSPin); pin != "" {
out = append(out, pin)
}
prev := strings.TrimSpace(server.AgentTLSPinPrev)
if prev == "" || !pinGraceActive(server.AgentTLSPinPrevUntil) {
return out
}
return append(out, prev)
}
func pinGraceActive(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
deadline, err := time.Parse(time.RFC3339, raw)
if err != nil {
return false
}
return time.Now().Before(deadline)
}
func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient, bool) { func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient, bool) {
server, err := s.store.GetServer(ctx, serverID) server, err := s.store.GetServer(ctx, serverID)
if err != nil || !server.MgmtEnabled || server.AgentURL == "" || !server.AgentConfigured { if err != nil || !server.MgmtEnabled || !server.AgentConfigured {
return nil, false return nil, false
} }
encoded, err := s.store.AgentToken(ctx, serverID) encoded, err := s.store.AgentToken(ctx, serverID)
@@ -1318,8 +1905,35 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient
if err != nil || token == "" { if err != nil || token == "" {
return nil, false return nil, false
} }
baseURL := server.AgentURL
var pins []string
if server.AgentTransport == "tunnel" {
tunnel, err := s.ensureAgentTunnel(ctx, serverID)
if err != nil {
return nil, false
}
baseURL = fmt.Sprintf("http://127.0.0.1:%d", tunnel.localPort)
} else if server.AgentTransport == "https-pin" {
pins = agentTransportPins(server)
if len(pins) == 0 {
return nil, false
}
baseURL = agentHTTPSBaseURL(server)
if !strings.HasPrefix(strings.ToLower(baseURL), "https://") {
return nil, false
}
if baseURL != strings.TrimRight(strings.TrimSpace(server.AgentURL), "/") {
err := s.store.SetAgentTransport(ctx, serverID, baseURL, "https-pin", server.AgentLocalPort,
server.AgentTLSPin, server.AgentListenAddr, server.AgentTransportState, server.AgentTransportError)
if err != nil {
s.logger.Warn("agent https url repair failed", "server_id", serverID, "error", err)
}
}
} else if baseURL == "" {
return nil, false
}
// 复用同一个 http.Client:批量同步 N 条路由时不再每次新建 Transport 与 TLS 握手 // 复用同一个 http.Client:批量同步 N 条路由时不再每次新建 Transport 与 TLS 握手
fingerprint := server.AgentURL + "|" + encoded fingerprint := baseURL + "|" + encoded + "|" + strings.Join(pins, ",")
s.agentMu.Lock() s.agentMu.Lock()
defer s.agentMu.Unlock() defer s.agentMu.Unlock()
if s.agentClients == nil { if s.agentClients == nil {
@@ -1328,7 +1942,12 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient
if cached, ok := s.agentClients[serverID]; ok && cached.fingerprint == fingerprint { if cached, ok := s.agentClients[serverID]; ok && cached.fingerprint == fingerprint {
return cached.client, true return cached.client, true
} }
client := NewAgentClient(server.AgentURL, token) var client *AgentClient
if len(pins) > 0 {
client = NewAgentClientWithPins(baseURL, token, pins...)
} else {
client = NewAgentClient(baseURL, token)
}
s.agentClients[serverID] = cachedAgentClient{fingerprint: fingerprint, client: client} s.agentClients[serverID] = cachedAgentClient{fingerprint: fingerprint, client: client}
return client, true return client, true
} }
@@ -1421,6 +2040,7 @@ func (s *Service) AutoStart(ctx context.Context) error {
} }
func (s *Service) Stop(ctx context.Context) error { func (s *Service) Stop(ctx context.Context) error {
s.stopAgentTunnels()
_ = s.ReconcileRuntime(ctx) _ = s.ReconcileRuntime(ctx)
servers, err := s.store.ListServers(ctx) servers, err := s.store.ListServers(ctx)
if err != nil { if err != nil {
+71 -1
View File
@@ -279,6 +279,72 @@ func (s *Store) AgentToken(ctx context.Context, id int64) (string, error) {
return token, err return token, err
} }
func (s *Store) SetAgentTransport(ctx context.Context, id int64, agentURL, transport string, localPort int, tlsPin, listenAddr, state, lastError string) error {
result, err := s.db.ExecContext(ctx, `
UPDATE frp_servers
SET agent_url = ?, agent_transport = ?, agent_local_port = ?, agent_tls_pin = ?,
agent_listen_addr = ?, agent_transport_state = ?, agent_transport_error = ?,
updated_at = datetime('now')
WHERE id = ?
`, agentURL, transport, localPort, tlsPin, listenAddr, state, lastError, id)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return fmt.Errorf("FRP 服务端不存在")
}
return nil
}
// SetAgentTLSPinRotation 更新当前证书 pin、过渡期旧 pin 与证书到期时间。
func (s *Store) SetAgentTLSPinRotation(ctx context.Context, id int64, pin, prevPin, prevUntil, notAfter string) error {
result, err := s.db.ExecContext(ctx, `
UPDATE frp_servers
SET agent_tls_pin = ?, agent_tls_pin_prev = ?, agent_tls_pin_prev_until = ?, agent_tls_not_after = ?,
updated_at = datetime('now')
WHERE id = ?
`, pin, prevPin, prevUntil, notAfter, id)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return fmt.Errorf("FRP 服务端不存在")
}
return nil
}
// SetAgentFirewallState 记录最近一次主机防火墙检测 / 放行结果,供前端展示。
func (s *Store) SetAgentFirewallState(ctx context.Context, id int64, state string) error {
result, err := s.db.ExecContext(ctx, `
UPDATE frp_servers SET agent_firewall_state = ?, updated_at = datetime('now') WHERE id = ?
`, state, id)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return fmt.Errorf("FRP 服务端不存在")
}
return nil
}
// SetAgentTLSNotAfter 只同步证书到期时间,不改动 pin(用于旧记录补齐)。
func (s *Store) SetAgentTLSNotAfter(ctx context.Context, id int64, notAfter string) error {
result, err := s.db.ExecContext(ctx, `
UPDATE frp_servers SET agent_tls_not_after = ?, updated_at = datetime('now') WHERE id = ?
`, notAfter, id)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return fmt.Errorf("FRP 服务端不存在")
}
return nil
}
func (s *Store) Token(ctx context.Context, id int64) (string, error) { func (s *Store) Token(ctx context.Context, id int64) (string, error) {
var token string var token string
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(auth_token_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&token) err := s.db.QueryRowContext(ctx, `SELECT COALESCE(auth_token_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&token)
@@ -428,6 +494,10 @@ SELECT id, name, server_addr, server_port, tls_enabled, tls_server_name, enabled
COALESCE(agent_url, ''), CASE WHEN COALESCE(agent_token_enc, '') != '' THEN 1 ELSE 0 END, COALESCE(agent_url, ''), CASE WHEN COALESCE(agent_token_enc, '') != '' THEN 1 ELSE 0 END,
COALESCE(ssh_host, ''), COALESCE(ssh_port, 22), COALESCE(ssh_user, ''), COALESCE(ssh_auth, 'key'), COALESCE(ssh_host, ''), COALESCE(ssh_port, 22), COALESCE(ssh_user, ''), COALESCE(ssh_auth, 'key'),
CASE WHEN COALESCE(ssh_secret_enc, '') != '' THEN 1 ELSE 0 END, COALESCE(mgmt_enabled, 0), CASE WHEN COALESCE(ssh_secret_enc, '') != '' THEN 1 ELSE 0 END, COALESCE(mgmt_enabled, 0),
COALESCE(agent_transport, 'http'), COALESCE(agent_local_port, 0), COALESCE(agent_tls_pin, ''),
COALESCE(agent_tls_pin_prev, ''), COALESCE(agent_tls_pin_prev_until, ''), COALESCE(agent_tls_not_after, ''),
COALESCE(agent_firewall_state, ''),
COALESCE(agent_listen_addr, ''), COALESCE(agent_transport_state, 'unknown'), COALESCE(agent_transport_error, ''),
COALESCE(config_json, '{}'), created_at, updated_at COALESCE(config_json, '{}'), created_at, updated_at
FROM frp_servers` FROM frp_servers`
@@ -439,7 +509,7 @@ func scanServer(row interface{ Scan(dest ...any) error }) (Server, error) {
var server Server var server Server
var tls, enabled, token, oidcSecret, dashboardPwd, agentConfigured, sshConfigured, mgmtEnabled int var tls, enabled, token, oidcSecret, dashboardPwd, agentConfigured, sshConfigured, mgmtEnabled int
var configJSON, created, updated string var configJSON, created, updated string
err := row.Scan(&server.ID, &server.Name, &server.ServerAddr, &server.ServerPort, &tls, &server.TLSServerName, &enabled, &token, &oidcSecret, &dashboardPwd, &server.AgentURL, &agentConfigured, &server.SSHHost, &server.SSHPort, &server.SSHUser, &server.SSHAuth, &sshConfigured, &mgmtEnabled, &configJSON, &created, &updated) err := row.Scan(&server.ID, &server.Name, &server.ServerAddr, &server.ServerPort, &tls, &server.TLSServerName, &enabled, &token, &oidcSecret, &dashboardPwd, &server.AgentURL, &agentConfigured, &server.SSHHost, &server.SSHPort, &server.SSHUser, &server.SSHAuth, &sshConfigured, &mgmtEnabled, &server.AgentTransport, &server.AgentLocalPort, &server.AgentTLSPin, &server.AgentTLSPinPrev, &server.AgentTLSPinPrevUntil, &server.AgentTLSNotAfter, &server.AgentFirewallState, &server.AgentListenAddr, &server.AgentTransportState, &server.AgentTransportError, &configJSON, &created, &updated)
if err != nil { if err != nil {
return Server{}, err return Server{}, err
} }
+55 -2
View File
@@ -61,15 +61,62 @@ func Backup(path string) error {
return WriteFileAtomic(path+".bak", data, 0o600) return WriteFileAtomic(path+".bak", data, 0o600)
} }
// VersionsDir 返回某文件的版本目录(<path>.versions),供列表 / 回滚读取。 // VersionsDir 返回某文件的版本目录(.<name>.versions),供列表 / 回滚读取。
// 目录名以点号开头,避免被 nginx 的 include <dir>/* 当成配置文件读取。
func VersionsDir(path string) string { func VersionsDir(path string) string {
return filepath.Join(filepath.Dir(path), "."+filepath.Base(path)+".versions")
}
// legacyVersionsDir 是旧布局(<path>.versions)。它会被 nginx 的 include * 误读为配置文件,
// 因此读取 / 备份前统一迁移到隐藏目录。
func legacyVersionsDir(path string) string {
return path + ".versions" return path + ".versions"
} }
// BackupVersioned 把现有文件留存为 <path>.versions/<时间戳>-<随机>.conf,并只保留最近 keep 份。 // MigrateLegacyVersions 把旧版本目录迁移到隐藏目录。只移动内容,不删除历史版本;
// 迁移后旧目录已空,删除它是为了让 nginx 的 include * 不再命中该目录。
func MigrateLegacyVersions(path string) error {
legacy := legacyVersionsDir(path)
info, err := os.Stat(legacy)
if err != nil || !info.IsDir() {
return nil
}
target := VersionsDir(path)
if _, err := os.Stat(target); os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
return err
}
if err := os.Rename(legacy, target); err == nil {
return nil
}
}
if err := os.MkdirAll(target, 0o700); err != nil {
return err
}
entries, err := os.ReadDir(legacy)
if err != nil {
return err
}
for _, entry := range entries {
src := filepath.Join(legacy, entry.Name())
dst := filepath.Join(target, entry.Name())
if _, err := os.Stat(dst); err == nil {
continue
}
if err := os.Rename(src, dst); err != nil {
return err
}
}
return os.Remove(legacy)
}
// BackupVersioned 把现有文件留存为 .<name>.versions/<时间戳>-<随机>.conf,并只保留最近 keep 份。
// 文件名前缀是 UTC 时间戳(同一秒内用随机后缀避开冲突),因此目录内按文件名倒序就是版本新旧顺序。 // 文件名前缀是 UTC 时间戳(同一秒内用随机后缀避开冲突),因此目录内按文件名倒序就是版本新旧顺序。
// 文件不存在视为无需备份,返回空串;keep <= 0 表示不剪枝。 // 文件不存在视为无需备份,返回空串;keep <= 0 表示不剪枝。
func BackupVersioned(path string, keep int) (string, error) { func BackupVersioned(path string, keep int) (string, error) {
if err := MigrateLegacyVersions(path); err != nil {
return "", err
}
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return "", nil return "", nil
@@ -107,6 +154,9 @@ type VersionEntry struct {
// ListVersions 按新的在前列出版本;版本目录不存在时返回空列表(首次备份前是正常状态)。 // ListVersions 按新的在前列出版本;版本目录不存在时返回空列表(首次备份前是正常状态)。
func ListVersions(path string) ([]VersionEntry, error) { func ListVersions(path string) ([]VersionEntry, error) {
if err := MigrateLegacyVersions(path); err != nil {
return nil, err
}
entries, err := os.ReadDir(VersionsDir(path)) entries, err := os.ReadDir(VersionsDir(path))
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -135,6 +185,9 @@ func ReadVersion(path, name string) ([]byte, error) {
if name == "" || name != filepath.Base(name) || name == "." || name == ".." { if name == "" || name != filepath.Base(name) || name == "." || name == ".." {
return nil, fmt.Errorf("版本名无效") return nil, fmt.Errorf("版本名无效")
} }
if err := MigrateLegacyVersions(path); err != nil {
return nil, err
}
data, err := os.ReadFile(filepath.Join(VersionsDir(path), name)) data, err := os.ReadFile(filepath.Join(VersionsDir(path), name))
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
+31
View File
@@ -198,3 +198,34 @@ func TestReadVersionRejectsTraversal(t *testing.T) {
t.Fatalf("版本目录不存在时应返回空列表且无错误:%v %v", versions, err) t.Fatalf("版本目录不存在时应返回空列表且无错误:%v %v", versions, err)
} }
} }
func TestVersionsDirIsHidden(t *testing.T) {
path := filepath.Join("/etc/nginx/sites-enabled", "fonuagent-https.conf")
base := filepath.Base(VersionsDir(path))
if !strings.HasPrefix(base, ".") {
t.Fatalf("版本目录应以点号开头,避免被 nginx 的 include * 加载:%s", base)
}
}
func TestMigrateLegacyVersions(t *testing.T) {
path := filepath.Join(t.TempDir(), "fonuagent-https.conf")
legacy := path + ".versions"
if err := os.MkdirAll(legacy, 0o700); err != nil {
t.Fatal(err)
}
name := "20260101T000000.000-abcd.conf"
if err := os.WriteFile(filepath.Join(legacy, name), []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
versions, err := ListVersions(path)
if err != nil || len(versions) != 1 {
t.Fatalf("迁移后应保留 1 个版本:%v %v", versions, err)
}
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
t.Fatalf("旧版本目录应被移走,避免 nginx include * 命中:%v", err)
}
data, err := ReadVersion(path, versions[0].Name)
if err != nil || string(data) != "old" {
t.Fatalf("迁移后版本内容应可读:%q %v", data, err)
}
}
+112
View File
@@ -57,6 +57,38 @@ type HealthRecorder interface {
PruneRouteHealth(ctx context.Context) error PruneRouteHealth(ctx context.Context) error
} }
// CloudflareRouteStatus 是 Cloudflare 隧道域名的巡检快照。
type CloudflareRouteStatus struct {
TunnelID int64
Name string
Domain string
State string
Reason string
Detail string
Known bool
}
// CloudflareHealthSample 是 Cloudflare 隧道的状态变更样本。
type CloudflareHealthSample struct {
TunnelID int64
Name string
Domain string
State string
Reason string
Detail string
}
// CloudflareHealthSource 提供 Cloudflare 隧道健康快照。
type CloudflareHealthSource interface {
CloudflareRoutes(ctx context.Context) ([]CloudflareRouteStatus, error)
}
// CloudflareHealthRecorder 持久化 Cloudflare 隧道健康状态。
type CloudflareHealthRecorder interface {
RecordCloudflareHealth(ctx context.Context, samples []CloudflareHealthSample) error
PruneCloudflareHealth(ctx context.Context) error
}
// ServerStatus 是巡检关心的公网服务端状态:只有 DesiredState 表示「用户要它运行」时才判定掉线。 // ServerStatus 是巡检关心的公网服务端状态:只有 DesiredState 表示「用户要它运行」时才判定掉线。
type ServerStatus struct { type ServerStatus struct {
ID int64 ID int64
@@ -120,6 +152,7 @@ type Checker struct {
driftAlerted map[int64]bool driftAlerted map[int64]bool
driftNotified map[int64]time.Time driftNotified map[int64]time.Time
lastHealthPrune time.Time lastHealthPrune time.Time
lastCFHealthPrune time.Time
lastMetricsPrune time.Time lastMetricsPrune time.Time
} }
@@ -151,6 +184,7 @@ func (c *Checker) Run(ctx context.Context) {
c.checkServers(ctx) c.checkServers(ctx)
c.checkAgents(ctx) c.checkAgents(ctx)
c.checkDrift(ctx) c.checkDrift(ctx)
c.checkCloudflare(ctx)
} }
// runHostChecks 一次采样同时喂给「资源阈值告警」与「指标历史」。 // runHostChecks 一次采样同时喂给「资源阈值告警」与「指标历史」。
@@ -343,6 +377,84 @@ func (c *Checker) checkRoutes(ctx context.Context) {
} }
} }
// checkCloudflare 把 Cloudflare 隧道纳入现有巡检、通知与健康记录。
func (c *Checker) checkCloudflare(ctx context.Context) {
source, ok := c.source.(CloudflareHealthSource)
if !ok {
return
}
recordHealth, _ := c.settings.GetBool(ctx, settings.KeyRouteHealthEnabled)
recorder, canRecord := c.source.(CloudflareHealthRecorder)
if !canRecord {
recordHealth = false
}
alertEnabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnTunnelDown)
if !recordHealth && !alertEnabled {
return
}
routes, err := source.CloudflareRoutes(ctx)
if err != nil {
c.logger.Warn("巡检 Cloudflare 隧道状态失败", "module", "MONITOR", "error", err.Error())
return
}
if recordHealth {
samples := make([]CloudflareHealthSample, 0, len(routes))
for _, route := range routes {
if strings.TrimSpace(route.Domain) == "" {
continue
}
samples = append(samples, CloudflareHealthSample{
TunnelID: route.TunnelID,
Name: route.Name,
Domain: route.Domain,
State: route.State,
Reason: route.Reason,
Detail: route.Detail,
})
}
if err := recorder.RecordCloudflareHealth(ctx, samples); err != nil {
c.logger.Warn("记录 Cloudflare 隧道健康失败", "module", "MONITOR", "error", err.Error())
}
c.pruneCloudflareHealthIfDue(ctx, recorder)
}
if !alertEnabled {
return
}
limit, _ := c.settings.GetInt(ctx, settings.KeyNotifyTunnelFailThreshold)
if limit <= 0 {
return
}
for _, route := range routes {
if !route.Known || strings.TrimSpace(route.Domain) == "" {
continue
}
_, event := c.track(fmt.Sprintf("cloudflare:%d:%s", route.TunnelID, route.Domain), route.State == HealthUp, limit)
switch event {
case failureEventDown:
c.notify.Alert(ctx, notify.EventTunnelDown, "Cloudflare 隧道连续不通",
fmt.Sprintf("%s 连续 %d 次巡检未连接:%s", route.Domain, limit, detailOr(route.Detail)))
case failureEventRecovered:
c.notify.Alert(ctx, notify.EventTunnelDown, "Cloudflare 隧道已恢复",
fmt.Sprintf("%s 已重新连接 Cloudflare 边缘", route.Domain))
}
}
}
func (c *Checker) pruneCloudflareHealthIfDue(ctx context.Context, recorder CloudflareHealthRecorder) {
c.mu.Lock()
due := c.lastCFHealthPrune.IsZero() || time.Since(c.lastCFHealthPrune) >= 24*time.Hour
if due {
c.lastCFHealthPrune = time.Now()
}
c.mu.Unlock()
if !due {
return
}
if err := recorder.PruneCloudflareHealth(ctx); err != nil {
c.logger.Warn("清理 Cloudflare 隧道健康记录失败", "module", "MONITOR", "error", err.Error())
}
}
// healthStateOf 把一段状态翻成与 frp 侧一致的结论 + 失败环节 // healthStateOf 把一段状态翻成与 frp 侧一致的结论 + 失败环节
func healthStateOf(route RouteStatus) (string, string) { func healthStateOf(route RouteStatus) (string, string) {
if !route.Known { if !route.Known {
+10 -6
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"github.com/fonu/fonu/internal/config" "github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/nginxtmpl"
"github.com/fonu/fonu/internal/proxy" "github.com/fonu/fonu/internal/proxy"
) )
@@ -171,9 +172,13 @@ func appendSSLServerBlock(b *strings.Builder, cfg config.Config, port int, serve
writeListenDirectives(&block, port, true, rule.ListenIPv4, rule.ListenIPv6) writeListenDirectives(&block, port, true, rule.ListenIPv4, rule.ListenIPv6)
block.WriteString(fmt.Sprintf(" server_name %s;\n\n", serverNames)) block.WriteString(fmt.Sprintf(" server_name %s;\n\n", serverNames))
writeCommentLine(&block, 4, "SSL 证书") writeCommentLine(&block, 4, "SSL 证书")
block.WriteString(fmt.Sprintf(" ssl_certificate %s;\n", absNginxPath(cert.CertPath))) writeTLSProtocolComment(&block, rule.Security)
block.WriteString(fmt.Sprintf(" ssl_certificate_key %s;\n", absNginxPath(cert.KeyPath))) block.WriteString(nginxtmpl.SSLDirectives(nginxtmpl.TLSConfig{
writeTLSProtocols(&block, rule.Security) CertPath: absNginxPath(cert.CertPath),
KeyPath: absNginxPath(cert.KeyPath),
TLS13Only: rule.Security.TLSMin13Only,
}))
block.WriteString("\n")
writeServerSecurityHeaders(&block, rule.Security, true) writeServerSecurityHeaders(&block, rule.Security, true)
writeErrorPages(&block, cfg) writeErrorPages(&block, cfg)
writeLocationWithSecurity(&block, cfg, rule, opts) writeLocationWithSecurity(&block, cfg, rule, opts)
@@ -217,11 +222,10 @@ func writeProxyLocationBody(b *strings.Builder, upstream string, sec proxy.Secur
proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-Proto $scheme; proxy_set_header X-Real-Proto $scheme;
proxy_set_header Upgrade $http_upgrade; %s
proxy_set_header Connection $connection_upgrade;
proxy_buffering off; proxy_buffering off;
proxy_read_timeout 3600s; proxy_read_timeout 3600s;
`, sslBlock, upstream, hostHeader)) `, sslBlock, upstream, hostHeader, nginxtmpl.UpgradeHeaders(" ", "$connection_upgrade")))
} }
func writeListenDirectives(b *strings.Builder, port int, ssl bool, ipv4 bool, ipv6 bool) { func writeListenDirectives(b *strings.Builder, port int, ssl bool, ipv4 bool, ipv6 bool) {
+1 -1
View File
@@ -131,7 +131,7 @@ func (m *Manager) Apply(ctx context.Context, rules []proxy.Rule, certs []CertSou
} }
currentPath := m.cfg.NginxConfigPath() currentPath := m.cfg.NginxConfigPath()
// 覆盖前留退路:.bak 保留最近一次(兼容既有读取方),.versions/ 保留最近 10 份(供版本对比与回滚) // 覆盖前留退路:.bak 保留最近一次(兼容既有读取方),隐藏的 .versions 目录保留最近 10 份(供版本对比与回滚)
if err := fsutil.Backup(currentPath); err != nil { if err := fsutil.Backup(currentPath); err != nil {
_ = os.Remove(tmpPath) _ = os.Remove(tmpPath)
return ApplyResult{}, err return ApplyResult{}, err
+29 -33
View File
@@ -6,6 +6,7 @@ import (
"strings" "strings"
"github.com/fonu/fonu/internal/config" "github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/nginxtmpl"
"github.com/fonu/fonu/internal/proxy" "github.com/fonu/fonu/internal/proxy"
) )
@@ -52,11 +53,11 @@ func writeLimitZones(b *strings.Builder, rules []proxy.Rule) {
writeLimitZoneComments(b, rule) writeLimitZoneComments(b, rule)
} }
if rule.Security.RateLimitEnabled() { if rule.Security.RateLimitEnabled() {
rl := rule.Security.RateLimit zone := fmt.Sprintf("fonu_rule_%d_req", rule.ID)
b.WriteString(fmt.Sprintf(" limit_req_zone $binary_remote_addr zone=fonu_rule_%d_req:10m rate=%dr/s;\n", rule.ID, rl.Rate)) writeIndentedZone(b, nginxtmpl.RateLimitZone(zone, rule.Security.RateLimit.Rate))
} }
if rule.Security.ConnLimitEnabled() { if rule.Security.ConnLimitEnabled() {
b.WriteString(fmt.Sprintf(" limit_conn_zone $binary_remote_addr zone=fonu_rule_%d_conn:10m;\n", rule.ID)) writeIndentedZone(b, nginxtmpl.ConnLimitZone(fmt.Sprintf("fonu_rule_%d_conn", rule.ID)))
} }
} }
if hasLimitZones(rules) { if hasLimitZones(rules) {
@@ -64,6 +65,13 @@ func writeLimitZones(b *strings.Builder, rules []proxy.Rule) {
} }
} }
func writeIndentedZone(b *strings.Builder, line string) {
if strings.TrimSpace(line) == "" {
return
}
b.WriteString(" " + line)
}
func hasLimitZones(rules []proxy.Rule) bool { func hasLimitZones(rules []proxy.Rule) bool {
for _, rule := range rules { for _, rule := range rules {
if rule.Enabled && (rule.Security.RateLimitEnabled() || rule.Security.ConnLimitEnabled()) { if rule.Enabled && (rule.Security.RateLimitEnabled() || rule.Security.ConnLimitEnabled()) {
@@ -135,18 +143,18 @@ func writeChinaOnlyBypassGeo(b *strings.Builder, rules []proxy.Rule, opts Genera
} }
} }
func writeChinaOnlyCheck(b *strings.Builder, rule proxy.Rule, opts GenerateOptions) { func chinaOnlyCheckDirectives(rule proxy.Rule, opts GenerateOptions) string {
sec := rule.Security.Normalize() sec := rule.Security.Normalize()
if !sec.ChinaOnly { if !sec.ChinaOnly {
return return ""
} }
if !opts.ChinaCIDRAvailable { if !opts.ChinaCIDRAvailable {
b.WriteString(` if ($fonu_is_private = 0) { return ` if ($fonu_is_private = 0) {
return 403; return 403;
} }
`) `
return
} }
var b strings.Builder
b.WriteString(` set $fonu_china_block 0; b.WriteString(` set $fonu_china_block 0;
if ($fonu_is_china = 0) { if ($fonu_is_china = 0) {
set $fonu_china_block 1; set $fonu_china_block 1;
@@ -163,6 +171,7 @@ func writeChinaOnlyCheck(b *strings.Builder, rule proxy.Rule, opts GenerateOptio
return 403; return 403;
} }
`) `)
return b.String()
} }
func writeServerSecurityHeaders(b *strings.Builder, sec proxy.SecurityConfig, https bool) { func writeServerSecurityHeaders(b *strings.Builder, sec proxy.SecurityConfig, https bool) {
@@ -170,20 +179,8 @@ func writeServerSecurityHeaders(b *strings.Builder, sec proxy.SecurityConfig, ht
return return
} }
writeSecurityHeadersComment(b) writeSecurityHeadersComment(b)
b.WriteString(` add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; b.WriteString(nginxtmpl.ServerSecurityHeaders(nginxtmpl.SecurityHeaders{Enabled: true, HTTPS: true}))
add_header X-Content-Type-Options "nosniff" always; b.WriteString("\n")
add_header X-Frame-Options "SAMEORIGIN" always;
`)
}
func writeTLSProtocols(b *strings.Builder, sec proxy.SecurityConfig) {
writeTLSProtocolComment(b, sec)
if sec.TLSMin13Only {
b.WriteString(" ssl_protocols TLSv1.3;\n\n")
} else {
b.WriteString(" ssl_protocols TLSv1.2 TLSv1.3;\n\n")
}
} }
func writeErrorPages(b *strings.Builder, cfg config.Config) { func writeErrorPages(b *strings.Builder, cfg config.Config) {
@@ -216,25 +213,24 @@ func writeLocationSecurity(b *strings.Builder, cfg config.Config, rule proxy.Rul
sec := rule.Security.Normalize() sec := rule.Security.Normalize()
writeLocationSecurityComments(b, rule, opts) writeLocationSecurityComments(b, rule, opts)
for _, cidr := range sec.IPBlacklist { acl := nginxtmpl.AccessControl{
b.WriteString(fmt.Sprintf(" deny %s;\n", cidr)) Deny: sec.IPBlacklist,
ChinaDeny: chinaOnlyCheckDirectives(rule, opts),
} }
writeChinaOnlyCheck(b, rule, opts)
if sec.WhitelistEnabled() { if sec.WhitelistEnabled() {
for _, cidr := range sec.IPWhitelist { acl.Allow = sec.IPWhitelist
b.WriteString(fmt.Sprintf(" allow %s;\n", cidr))
}
b.WriteString(" deny all;\n")
} }
b.WriteString(nginxtmpl.AccessDirectives(acl, " "))
limits := nginxtmpl.LimitConfig{Zone: fmt.Sprintf("fonu_rule_%d", rule.ID)}
if sec.ConnLimitEnabled() { if sec.ConnLimitEnabled() {
b.WriteString(fmt.Sprintf(" limit_conn fonu_rule_%d_conn %d;\n", rule.ID, sec.ConnLimit.Max)) limits.Conn = sec.ConnLimit.Max
} }
if sec.RateLimitEnabled() { if sec.RateLimitEnabled() {
b.WriteString(fmt.Sprintf(" limit_req zone=fonu_rule_%d_req burst=%d nodelay;\n", rule.ID, sec.RateLimit.Burst)) limits.Rate = sec.RateLimit.Rate
limits.Burst = sec.RateLimit.Burst
} }
b.WriteString(nginxtmpl.LimitDirectives(limits, " "))
if sec.BasicAuthEnabled() { if sec.BasicAuthEnabled() {
path := absNginxPath(HtpasswdPath(cfg, rule.ID)) path := absNginxPath(HtpasswdPath(cfg, rule.ID))
+112
View File
@@ -0,0 +1,112 @@
package nginx
import (
"context"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/fsutil"
)
func newVersionTestManager(t *testing.T) (*Manager, config.Config) {
t.Helper()
dir := t.TempDir()
cfg := config.Config{
DataDir: dir,
NginxBin: "fonu-nginx-not-installed",
NginxPIDFile: filepath.Join(dir, "nginx", "nginx.pid"),
NginxMimeTypes: filepath.Join(dir, "mime.types"),
}
mgr := NewManager(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err := mgr.EnsureDirs(); err != nil {
t.Fatal(err)
}
return mgr, cfg
}
func TestConfigVersionsListAndRead(t *testing.T) {
mgr, cfg := newVersionTestManager(t)
path := cfg.NginxConfigPath()
if err := os.WriteFile(path, []byte("worker_processes 1;\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := fsutil.BackupVersioned(path, maxConfigVersions); err != nil {
t.Fatal(err)
}
versions, err := mgr.ListConfigVersions()
if err != nil || len(versions) != 1 {
t.Fatalf("应列出 1 个版本:%v %v", versions, err)
}
content, err := mgr.ReadConfigVersion(versions[0].Name)
if err != nil || content != "worker_processes 1;\n" {
t.Fatalf("版本内容不符:%q %v", content, err)
}
}
func TestRollbackConfigVersionRejectsTraversal(t *testing.T) {
mgr, _ := newVersionTestManager(t)
if _, err := mgr.RollbackConfigVersion(context.Background(), "../nginx.conf"); err == nil {
t.Fatal("带路径上跳的版本名应被拒绝")
}
}
func TestRollbackConfigVersionKeepsCurrentOnSyntaxError(t *testing.T) {
mgr, cfg := newVersionTestManager(t)
path := cfg.NginxConfigPath()
if err := os.WriteFile(path, []byte("worker_processes 1;\n"), 0o644); err != nil {
t.Fatal(err)
}
dir := fsutil.VersionsDir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
badName := "20260101T000000.000-bad.conf"
if err := os.WriteFile(filepath.Join(dir, badName), []byte("server {\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := mgr.RollbackConfigVersion(context.Background(), badName); err == nil {
t.Fatal("语法错误的版本应拒绝回滚")
}
current, err := os.ReadFile(path)
if err != nil || string(current) != "worker_processes 1;\n" {
t.Fatalf("校验失败时不应覆盖当前配置:%q %v", current, err)
}
}
func TestRollbackConfigVersionRestoresAndKeepsCurrent(t *testing.T) {
mgr, cfg := newVersionTestManager(t)
path := cfg.NginxConfigPath()
if err := os.WriteFile(path, []byte("worker_processes 1;\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := fsutil.BackupVersioned(path, maxConfigVersions); err != nil {
t.Fatal(err)
}
versions, err := mgr.ListConfigVersions()
if err != nil || len(versions) != 1 {
t.Fatalf("准备版本失败:%v %v", versions, err)
}
if err := os.WriteFile(path, []byte("worker_processes 2;\n"), 0o644); err != nil {
t.Fatal(err)
}
result, err := mgr.RollbackConfigVersion(context.Background(), versions[0].Name)
if err != nil {
t.Fatalf("回滚失败: %v", err)
}
if !strings.Contains(result.Message, "已回滚") {
t.Fatalf("回滚结果缺少提示:%q", result.Message)
}
current, err := os.ReadFile(path)
if err != nil || string(current) != "worker_processes 1;\n" {
t.Fatalf("回滚后内容不符:%q %v", current, err)
}
after, err := mgr.ListConfigVersions()
if err != nil || len(after) < 2 {
t.Fatalf("回滚前的当前配置也应留存为版本:%v %v", after, err)
}
}
+176
View File
@@ -0,0 +1,176 @@
// Package nginxtmpl 提供本机 Nginx 与公网 Agent 共用的纯文本模板函数。
//
// 这里只做字符串渲染,不读文件、不执行进程、不依赖业务模型,
// 因此 internal/nginx 与 internal/agent 都可以安全导入而不产生循环依赖。
package nginxtmpl
import (
"fmt"
"strings"
)
// DefaultIndent 是 Nginx server 块内指令的默认缩进。
const DefaultIndent = " "
// TLSConfig 描述证书与 TLS 协议版本偏好。
type TLSConfig struct {
CertPath string
KeyPath string
TLS13Only bool
}
// SecurityHeaders 描述安全响应头;HTTPS 为 false 时不输出 HSTS。
type SecurityHeaders struct {
Enabled bool
HTTPS bool
HSTSMaxAgeSeconds int
}
// AccessControl 描述 allow / deny 顺序;Allow 非空时末尾自动追加 deny all。
// ChinaDeny 是可选的完整指令片段,插在 deny 黑名单之后、allow 白名单之前,
// 让两端共享顺序结构,同时保留各自的 geo 变量名。
type AccessControl struct {
Allow []string
Deny []string
ChinaDeny string
}
// LimitConfig 描述 location 级限流指令;Zone 是已合法化的 zone 前缀。
type LimitConfig struct {
Zone string
Rate int
Burst int
Conn int
}
// SSLDirectives 生成证书与 TLS 协议指令,缩进固定为 server 块内层级。
func SSLDirectives(cfg TLSConfig) string {
var b strings.Builder
fmt.Fprintf(&b, "%sssl_certificate %s;\n", DefaultIndent, cfg.CertPath)
fmt.Fprintf(&b, "%sssl_certificate_key %s;\n", DefaultIndent, cfg.KeyPath)
if cfg.TLS13Only {
fmt.Fprintf(&b, "%sssl_protocols TLSv1.3;\n", DefaultIndent)
return b.String()
}
fmt.Fprintf(&b, "%sssl_protocols TLSv1.2 TLSv1.3;\n", DefaultIndent)
return b.String()
}
// ServerSecurityHeaders 生成统一的安全响应头;HSTS 只在 HTTPS 且 Enabled 时输出。
func ServerSecurityHeaders(cfg SecurityHeaders) string {
if !cfg.Enabled || !cfg.HTTPS {
return ""
}
maxAge := cfg.HSTSMaxAgeSeconds
if maxAge <= 0 {
maxAge = 31536000
}
var b strings.Builder
fmt.Fprintf(&b, "%sadd_header Strict-Transport-Security \"max-age=%d; includeSubDomains\" always;\n", DefaultIndent, maxAge)
fmt.Fprintf(&b, "%sadd_header X-Content-Type-Options \"nosniff\" always;\n", DefaultIndent)
fmt.Fprintf(&b, "%sadd_header X-Frame-Options \"SAMEORIGIN\" always;\n", DefaultIndent)
fmt.Fprintf(&b, "%sadd_header Referrer-Policy \"strict-origin-when-cross-origin\" always;\n", DefaultIndent)
return b.String()
}
// AccessDirectives 生成 allow / deny:先 deny 黑名单,再 allow 白名单,最后 deny all。
func AccessDirectives(cfg AccessControl, indent string) string {
var b strings.Builder
for _, ip := range cfg.Deny {
if ip = strings.TrimSpace(ip); ip != "" {
fmt.Fprintf(&b, "%sdeny %s;\n", indent, ip)
}
}
b.WriteString(strings.TrimRight(cfg.ChinaDeny, "\n"))
if strings.TrimSpace(cfg.ChinaDeny) != "" {
b.WriteString("\n")
}
allowCount := 0
for _, ip := range cfg.Allow {
if ip = strings.TrimSpace(ip); ip != "" {
fmt.Fprintf(&b, "%sallow %s;\n", indent, ip)
allowCount++
}
}
if allowCount > 0 {
fmt.Fprintf(&b, "%sdeny all;\n", indent)
}
return b.String()
}
// LimitDirectives 生成 location 级限流指令:连接数在前,请求速率在后。
func LimitDirectives(cfg LimitConfig, indent string) string {
zone := strings.TrimSpace(cfg.Zone)
if zone == "" {
return ""
}
var b strings.Builder
if cfg.Conn > 0 {
fmt.Fprintf(&b, "%slimit_conn %s_conn %d;\n", indent, zone, cfg.Conn)
}
if cfg.Rate > 0 {
if cfg.Burst > 0 {
fmt.Fprintf(&b, "%slimit_req zone=%s_req burst=%d nodelay;\n", indent, zone, cfg.Burst)
} else {
fmt.Fprintf(&b, "%slimit_req zone=%s_req nodelay;\n", indent, zone)
}
}
return b.String()
}
// UpgradeHeaders 生成 WebSocket 代理头;connectionExpr 由调用方提供(map 变量或字面量)。
// proxy_http_version 由调用方保留在原有位置,避免改变既有输出顺序。
func UpgradeHeaders(indent, connectionExpr string) string {
connectionExpr = strings.TrimSpace(connectionExpr)
if connectionExpr == "" {
connectionExpr = "$connection_upgrade"
}
var b strings.Builder
fmt.Fprintf(&b, "%sproxy_set_header Upgrade $http_upgrade;\n", indent)
fmt.Fprintf(&b, "%sproxy_set_header Connection %s;\n", indent, connectionExpr)
return b.String()
}
// RateLimitZone 生成 http 上下文的请求速率 zone 定义;zone 需包含完整后缀。
func RateLimitZone(zone string, rate int) string {
zone = strings.TrimSpace(zone)
if zone == "" || rate <= 0 {
return ""
}
return fmt.Sprintf("limit_req_zone $binary_remote_addr zone=%s:10m rate=%dr/s;\n", zone, rate)
}
// ConnLimitZone 生成 http 上下文的连接数 zone 定义;zone 需包含完整后缀。
func ConnLimitZone(zone string) string {
zone = strings.TrimSpace(zone)
if zone == "" {
return ""
}
return fmt.Sprintf("limit_conn_zone $binary_remote_addr zone=%s:10m;\n", zone)
}
// ZoneName 把前缀与片段合法化为 zone 名:只保留小写字母与数字,其余换成下划线。
func ZoneName(prefix string, parts ...string) string {
var b strings.Builder
writeZonePart(&b, prefix)
for _, part := range parts {
if strings.TrimSpace(part) == "" {
continue
}
if b.Len() > 0 {
b.WriteByte('_')
}
writeZonePart(&b, part)
}
return b.String()
}
func writeZonePart(b *strings.Builder, part string) {
for _, r := range strings.ToLower(part) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
continue
}
b.WriteByte('_')
}
}
+104
View File
@@ -0,0 +1,104 @@
package nginxtmpl
import (
"strings"
"testing"
)
func TestSSLDirectives(t *testing.T) {
base := SSLDirectives(TLSConfig{CertPath: "/certs/fullchain.pem", KeyPath: "/certs/privkey.pem"})
for _, want := range []string{
"ssl_certificate /certs/fullchain.pem;",
"ssl_certificate_key /certs/privkey.pem;",
"ssl_protocols TLSv1.2 TLSv1.3;",
} {
if !strings.Contains(base, want) {
t.Fatalf("默认 TLS 缺少 %q:%s", want, base)
}
}
only13 := SSLDirectives(TLSConfig{CertPath: "/c", KeyPath: "/k", TLS13Only: true})
if !strings.Contains(only13, "ssl_protocols TLSv1.3;") || strings.Contains(only13, "TLSv1.2") {
t.Fatalf("TLS 1.3 only 输出不正确:%s", only13)
}
}
func TestServerSecurityHeaders(t *testing.T) {
if got := ServerSecurityHeaders(SecurityHeaders{Enabled: true, HTTPS: false}); got != "" {
t.Fatalf("HTTP 不应输出 HSTS:%s", got)
}
if got := ServerSecurityHeaders(SecurityHeaders{Enabled: false, HTTPS: true}); got != "" {
t.Fatalf("关闭安全头时不应输出:%s", got)
}
got := ServerSecurityHeaders(SecurityHeaders{Enabled: true, HTTPS: true})
for _, want := range []string{
`Strict-Transport-Security "max-age=31536000; includeSubDomains"`,
`X-Content-Type-Options "nosniff"`,
`X-Frame-Options "SAMEORIGIN"`,
`Referrer-Policy "strict-origin-when-cross-origin"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("安全头缺少 %q:%s", want, got)
}
}
}
func TestAccessDirectives(t *testing.T) {
got := AccessDirectives(AccessControl{
Deny: []string{"8.8.8.8", " "},
ChinaDeny: " if ($is_china = 0) { return 403; }\n",
Allow: []string{"192.168.1.0/24"},
}, " ")
want := " deny 8.8.8.8;\n if ($is_china = 0) { return 403; }\n allow 192.168.1.0/24;\n deny all;\n"
if got != want {
t.Fatalf("AccessDirectives() = %q, 期望 %q", got, want)
}
if got := AccessDirectives(AccessControl{}, " "); got != "" {
t.Fatalf("空参数不应输出指令:%q", got)
}
}
func TestLimitDirectives(t *testing.T) {
got := LimitDirectives(LimitConfig{Zone: "fonu_rule_7", Rate: 10, Burst: 20, Conn: 30}, " ")
for _, want := range []string{
"limit_conn fonu_rule_7_conn 30;",
"limit_req zone=fonu_rule_7_req burst=20 nodelay;",
} {
if !strings.Contains(got, want) {
t.Fatalf("限流指令缺少 %q:%s", want, got)
}
}
noBurst := LimitDirectives(LimitConfig{Zone: "z", Rate: 5}, " ")
if !strings.Contains(noBurst, "limit_req zone=z_req nodelay;") {
t.Fatalf("无 burst 时输出不正确:%s", noBurst)
}
}
func TestZoneDirectives(t *testing.T) {
if got := RateLimitZone("fonu_app_req", 10); got != "limit_req_zone $binary_remote_addr zone=fonu_app_req:10m rate=10r/s;\n" {
t.Fatalf("RateLimitZone() = %q", got)
}
if got := ConnLimitZone("fonu_app_conn"); got != "limit_conn_zone $binary_remote_addr zone=fonu_app_conn:10m;\n" {
t.Fatalf("ConnLimitZone() = %q", got)
}
if got := RateLimitZone("", 10); got != "" {
t.Fatalf("空 zone 不应输出:%q", got)
}
}
func TestZoneName(t *testing.T) {
if got := ZoneName("fonu", "App.Example.com"); got != "fonu_app_example_com" {
t.Fatalf("ZoneName() = %q, 期望 fonu_app_example_com", got)
}
}
func TestUpgradeHeaders(t *testing.T) {
got := UpgradeHeaders(" ", "$connection_upgrade")
for _, want := range []string{
"proxy_set_header Upgrade $http_upgrade;",
"proxy_set_header Connection $connection_upgrade;",
} {
if !strings.Contains(got, want) {
t.Fatalf("WebSocket 头缺少 %q:%s", want, got)
}
}
}
+55
View File
@@ -0,0 +1,55 @@
package proxy
import (
"context"
"testing"
)
func TestStorePersistsExitsAndFiltersLocal(t *testing.T) {
store, _ := newTestStore(t)
ctx := context.Background()
rule, err := store.Create(ctx, CreateInput{
Upstream: "http://192.168.1.10:8080",
ListenPort: 8443,
ListenIPv4: true,
Hosts: []string{"cf.example.com"},
Enabled: true,
Exits: []string{ExitCloudflare},
CFTunnelID: 7,
})
if err != nil {
t.Fatal(err)
}
if rule.CFTunnelID != 7 || len(rule.Exits) != 1 || rule.Exits[0] != ExitCloudflare {
t.Fatalf("Cloudflare 出口未持久化:%#v", rule)
}
localRules, err := store.ListEnabledLocal(ctx)
if err != nil {
t.Fatal(err)
}
if len(localRules) != 0 {
t.Fatalf("仅 Cloudflare 规则不应生成本机 Nginx:%#v", localRules)
}
enabled := true
updated, err := store.Update(ctx, rule.ID, UpdateInput{
Exits: &[]string{ExitLocal, ExitCloudflare},
Enabled: &enabled,
CFTunnelID: int64Ptr(7),
})
if err != nil {
t.Fatal(err)
}
if len(updated.Exits) != 2 {
t.Fatalf("本机 + Cloudflare 出口未保存:%#v", updated.Exits)
}
localRules, err = store.ListEnabledLocal(ctx)
if err != nil {
t.Fatal(err)
}
if len(localRules) != 1 || localRules[0].ID != rule.ID {
t.Fatalf("启用本机出口后应进入 Nginx 规则:%#v", localRules)
}
}
func int64Ptr(v int64) *int64 { return &v }
+39
View File
@@ -0,0 +1,39 @@
package proxy
import "testing"
func TestNormalizeExitsDefaultsToLocal(t *testing.T) {
exits, tunnelID, err := normalizeExits(nil, 0)
if err != nil {
t.Fatal(err)
}
if tunnelID != 0 || len(exits) != 1 || exits[0] != ExitLocal {
t.Fatalf("默认出口应为 local:%#v tunnel=%d", exits, tunnelID)
}
}
func TestNormalizeExitsRequiresTunnelForCloudflare(t *testing.T) {
if _, _, err := normalizeExits([]string{ExitCloudflare}, 0); err == nil {
t.Fatal("Cloudflare 出口缺少隧道时应报错")
}
}
func TestNormalizeExitsDeduplicatesAndSorts(t *testing.T) {
exits, tunnelID, err := normalizeExits([]string{ExitCloudflare, ExitLocal, ExitCloudflare}, 12)
if err != nil {
t.Fatal(err)
}
if tunnelID != 12 || len(exits) != 2 || exits[0] != ExitCloudflare || exits[1] != ExitLocal {
t.Fatalf("出口去重排序不符:%#v tunnel=%d", exits, tunnelID)
}
}
func TestRuleUsesExitTreatsEmptyAsLocal(t *testing.T) {
rule := Rule{}
if !rule.UsesExit(ExitLocal) {
t.Fatal("空出口应兼容为本机出口")
}
if rule.UsesExit(ExitCloudflare) {
t.Fatal("空出口不应被视为 Cloudflare")
}
}
+153 -8
View File
@@ -3,14 +3,21 @@ package proxy
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"sort"
"strings" "strings"
"time" "time"
"github.com/fonu/fonu/internal/validate" "github.com/fonu/fonu/internal/validate"
) )
const (
ExitLocal = "local"
ExitCloudflare = "cloudflare"
)
type Host struct { type Host struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
@@ -32,6 +39,9 @@ type Rule struct {
Name string `json:"name"` Name string `json:"name"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
Security SecurityConfig `json:"security"` Security SecurityConfig `json:"security"`
Exits []string `json:"exits"`
CFTunnelID int64 `json:"cf_tunnel_id"`
SyncWarning string `json:"sync_warning,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@@ -98,6 +108,34 @@ func (r Rule) Hostnames() []string {
return names return names
} }
// UsesExit 判断规则是否启用了指定出口;空值按旧数据兼容为本机出口。
func (r Rule) UsesExit(exit string) bool {
if len(r.Exits) == 0 {
return exit == ExitLocal
}
for _, item := range r.Exits {
if item == exit {
return true
}
}
return false
}
// HasExit 判断出口列表是否包含指定出口;空值按旧数据兼容为本机出口。
func HasExit(exits []string, exit string) bool {
return Rule{Exits: exits}.UsesExit(exit)
}
// LocalEnabled 判断规则当前是否需要生成本机 Nginx 配置。
func (r Rule) LocalEnabled() bool {
return r.Enabled && r.UsesExit(ExitLocal)
}
// CloudflareEnabled 判断规则当前是否需要同步到 Cloudflare 隧道。
func (r Rule) CloudflareEnabled() bool {
return r.Enabled && r.UsesExit(ExitCloudflare) && r.CFTunnelID > 0
}
func (r Rule) PortGroups() []PortGroup { func (r Rule) PortGroups() []PortGroup {
groups := map[int][]string{} groups := map[int][]string{}
ports := make([]int, 0) ports := make([]int, 0)
@@ -130,6 +168,8 @@ type CreateInput struct {
Enabled bool Enabled bool
Name string Name string
Security SecurityInput Security SecurityInput
Exits []string
CFTunnelID int64
} }
type UpdateInput struct { type UpdateInput struct {
@@ -143,6 +183,8 @@ type UpdateInput struct {
Enabled *bool Enabled *bool
Name *string Name *string
Security *SecurityInput Security *SecurityInput
Exits *[]string
CFTunnelID *int64
} }
type Store struct { type Store struct {
@@ -175,7 +217,7 @@ func (s *Store) List(ctx context.Context) ([]Rule, error) {
} }
q := s.querier() q := s.querier()
rows, err := q.QueryContext(ctx, ` rows, err := q.QueryContext(ctx, `
SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, created_at, updated_at SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, COALESCE(exits_json, '["local"]'), COALESCE(cf_tunnel_id, 0), created_at, updated_at
FROM proxy_rules FROM proxy_rules
ORDER BY sort_order ASC, id ASC ORDER BY sort_order ASC, id ASC
`) `)
@@ -200,7 +242,7 @@ func (s *Store) List(ctx context.Context) ([]Rule, error) {
func (s *Store) Get(ctx context.Context, id int64) (Rule, error) { func (s *Store) Get(ctx context.Context, id int64) (Rule, error) {
row := s.querier().QueryRowContext(ctx, ` row := s.querier().QueryRowContext(ctx, `
SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, created_at, updated_at SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, COALESCE(exits_json, '["local"]'), COALESCE(cf_tunnel_id, 0), created_at, updated_at
FROM proxy_rules WHERE id = ? FROM proxy_rules WHERE id = ?
`, id) `, id)
rule, err := scanRule(row) rule, err := scanRule(row)
@@ -243,11 +285,19 @@ func (s *Store) Create(ctx context.Context, in CreateInput) (Rule, error) {
if err != nil { if err != nil {
return Rule{}, err return Rule{}, err
} }
exits, cfTunnelID, err := normalizeExits(in.Exits, in.CFTunnelID)
if err != nil {
return Rule{}, err
}
exitsJSON, err := marshalExits(exits)
if err != nil {
return Rule{}, err
}
res, err := s.querier().ExecContext(ctx, ` res, err := s.querier().ExecContext(ctx, `
INSERT INTO proxy_rules(upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, name, sort_order, security_json, updated_at) INSERT INTO proxy_rules(upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, name, sort_order, security_json, exits_json, cf_tunnel_id, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
`, upstream, in.ListenPort, boolInt(in.ListenIPv4), boolInt(in.ListenIPv6), boolInt(in.HTTPSEnabled), boolInt(in.HTTPRedirect), boolInt(in.Enabled), name, sortOrder, securityJSON) `, upstream, in.ListenPort, boolInt(in.ListenIPv4), boolInt(in.ListenIPv6), boolInt(in.HTTPSEnabled), boolInt(in.HTTPRedirect), boolInt(in.Enabled), name, sortOrder, securityJSON, exitsJSON, cfTunnelID)
if err != nil { if err != nil {
return Rule{}, err return Rule{}, err
} }
@@ -278,6 +328,8 @@ func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, err
enabled := current.Enabled enabled := current.Enabled
name := current.Name name := current.Name
hosts := current.Hosts hosts := current.Hosts
exits := current.Exits
cfTunnelID := current.CFTunnelID
if in.Upstream != nil { if in.Upstream != nil {
upstream, err = validate.Upstream(*in.Upstream) upstream, err = validate.Upstream(*in.Upstream)
@@ -315,6 +367,20 @@ func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, err
return Rule{}, err return Rule{}, err
} }
} }
if in.Exits != nil || in.CFTunnelID != nil {
rawExits := exits
if in.Exits != nil {
rawExits = *in.Exits
}
rawTunnelID := cfTunnelID
if in.CFTunnelID != nil {
rawTunnelID = *in.CFTunnelID
}
exits, cfTunnelID, err = normalizeExits(rawExits, rawTunnelID)
if err != nil {
return Rule{}, err
}
}
if in.Hosts != nil { if in.Hosts != nil {
hosts, err = parseHosts(*in.Hosts, listenPort) hosts, err = parseHosts(*in.Hosts, listenPort)
if err != nil { if err != nil {
@@ -336,6 +402,10 @@ func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, err
if err != nil { if err != nil {
return Rule{}, err return Rule{}, err
} }
exitsJSON, err := marshalExits(exits)
if err != nil {
return Rule{}, err
}
if in.ListenPort != nil && *in.ListenPort != oldListenPort && in.Hosts == nil { if in.ListenPort != nil && *in.ListenPort != oldListenPort && in.Hosts == nil {
for _, host := range current.Hosts { for _, host := range current.Hosts {
@@ -350,9 +420,9 @@ func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, err
_, err = s.querier().ExecContext(ctx, ` _, err = s.querier().ExecContext(ctx, `
UPDATE proxy_rules UPDATE proxy_rules
SET upstream = ?, listen_port = ?, listen_ipv4 = ?, listen_ipv6 = ?, https_enabled = ?, http_redirect = ?, enabled = ?, name = ?, security_json = ?, updated_at = datetime('now') SET upstream = ?, listen_port = ?, listen_ipv4 = ?, listen_ipv6 = ?, https_enabled = ?, http_redirect = ?, enabled = ?, name = ?, security_json = ?, exits_json = ?, cf_tunnel_id = ?, updated_at = datetime('now')
WHERE id = ? WHERE id = ?
`, upstream, listenPort, boolInt(listenIPv4), boolInt(listenIPv6), boolInt(httpsEnabled), boolInt(httpRedirect), boolInt(enabled), name, securityJSON, id) `, upstream, listenPort, boolInt(listenIPv4), boolInt(listenIPv6), boolInt(httpsEnabled), boolInt(httpRedirect), boolInt(enabled), name, securityJSON, exitsJSON, cfTunnelID, id)
if err != nil { if err != nil {
return Rule{}, err return Rule{}, err
} }
@@ -423,7 +493,7 @@ func (s *Store) Delete(ctx context.Context, id int64) error {
func (s *Store) ListEnabled(ctx context.Context) ([]Rule, error) { func (s *Store) ListEnabled(ctx context.Context) ([]Rule, error) {
rows, err := s.querier().QueryContext(ctx, ` rows, err := s.querier().QueryContext(ctx, `
SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, created_at, updated_at SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, COALESCE(exits_json, '["local"]'), COALESCE(cf_tunnel_id, 0), created_at, updated_at
FROM proxy_rules WHERE enabled = 1 ORDER BY sort_order ASC, id ASC FROM proxy_rules WHERE enabled = 1 ORDER BY sort_order ASC, id ASC
`) `)
if err != nil { if err != nil {
@@ -445,6 +515,21 @@ func (s *Store) ListEnabled(ctx context.Context) ([]Rule, error) {
return s.attachHosts(ctx, rules) return s.attachHosts(ctx, rules)
} }
// ListEnabledLocal 只返回需要生成本机 Nginx 配置的启用规则。
func (s *Store) ListEnabledLocal(ctx context.Context) ([]Rule, error) {
rules, err := s.ListEnabled(ctx)
if err != nil {
return nil, err
}
out := make([]Rule, 0, len(rules))
for _, rule := range rules {
if rule.UsesExit(ExitLocal) {
out = append(out, rule)
}
}
return out, nil
}
func validateCreateInput(in CreateInput) (string, []Host, error) { func validateCreateInput(in CreateInput) (string, []Host, error) {
upstream, err := validate.Upstream(in.Upstream) upstream, err := validate.Upstream(in.Upstream)
if err != nil { if err != nil {
@@ -654,6 +739,8 @@ func scanRule(row rowScanner) (Rule, error) {
var enabled int var enabled int
var nginxMode string var nginxMode string
var securityJSON string var securityJSON string
var exitsJSON string
var cfTunnelID int64
var createdAt string var createdAt string
var updatedAt string var updatedAt string
if err := row.Scan( if err := row.Scan(
@@ -669,6 +756,8 @@ func scanRule(row rowScanner) (Rule, error) {
&rule.Name, &rule.Name,
&rule.SortOrder, &rule.SortOrder,
&securityJSON, &securityJSON,
&exitsJSON,
&cfTunnelID,
&createdAt, &createdAt,
&updatedAt, &updatedAt,
); err != nil { ); err != nil {
@@ -688,11 +777,67 @@ func scanRule(row rowScanner) (Rule, error) {
return Rule{}, err return Rule{}, err
} }
rule.Security = security rule.Security = security
rule.Exits = parseExits(exitsJSON)
rule.CFTunnelID = cfTunnelID
rule.CreatedAt = parseTime(createdAt) rule.CreatedAt = parseTime(createdAt)
rule.UpdatedAt = parseTime(updatedAt) rule.UpdatedAt = parseTime(updatedAt)
return rule, nil return rule, nil
} }
func normalizeExits(raw []string, cfTunnelID int64) ([]string, int64, error) {
if len(raw) == 0 {
raw = []string{ExitLocal}
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, item := range raw {
exit := strings.ToLower(strings.TrimSpace(item))
switch exit {
case ExitLocal, ExitCloudflare:
default:
return nil, 0, fmt.Errorf("不支持的出口: %s", item)
}
if _, ok := seen[exit]; ok {
continue
}
seen[exit] = struct{}{}
out = append(out, exit)
}
sort.Strings(out)
_, usesCloudflare := seen[ExitCloudflare]
if usesCloudflare && cfTunnelID <= 0 {
return nil, 0, fmt.Errorf("选择 Cloudflare 出口时需要指定隧道")
}
if !usesCloudflare {
cfTunnelID = 0
}
return out, cfTunnelID, nil
}
func marshalExits(exits []string) (string, error) {
normalized, _, err := normalizeExits(exits, 1)
if err != nil {
return "", err
}
data, err := json.Marshal(normalized)
if err != nil {
return "", err
}
return string(data), nil
}
func parseExits(raw string) []string {
var exits []string
if err := json.Unmarshal([]byte(raw), &exits); err != nil {
return []string{ExitLocal}
}
normalized, _, err := normalizeExits(exits, 1)
if err != nil {
return []string{ExitLocal}
}
return normalized
}
func parseTime(v string) time.Time { func parseTime(v string) time.Time {
t, err := time.Parse("2006-01-02 15:04:05", v) t, err := time.Parse("2006-01-02 15:04:05", v)
if err != nil { if err != nil {
+2 -2
View File
@@ -287,7 +287,7 @@ func (s *ProxyService) RollbackGlobalNginx(ctx context.Context, backupName strin
} }
func (s *ProxyService) validateRuleNginxPending(ctx context.Context, rule proxy.Rule, content string) error { func (s *ProxyService) validateRuleNginxPending(ctx context.Context, rule proxy.Rule, content string) error {
rules, err := s.store.ListEnabled(ctx) rules, err := s.store.ListEnabledLocal(ctx)
if err != nil { if err != nil {
return err return err
} }
@@ -315,7 +315,7 @@ func (s *ProxyService) validateRuleNginxPending(ctx context.Context, rule proxy.
} }
func (s *ProxyService) validateGlobalNginxPending(ctx context.Context, content string) error { func (s *ProxyService) validateGlobalNginxPending(ctx context.Context, content string) error {
rules, err := s.store.ListEnabled(ctx) rules, err := s.store.ListEnabledLocal(ctx)
if err != nil { if err != nil {
return err return err
} }
+38 -4
View File
@@ -23,6 +23,12 @@ type ProxyService struct {
settings *settings.Store settings *settings.Store
nginx *nginx.Manager nginx *nginx.Manager
notify *notify.Service notify *notify.Service
exitSync ExitSync
}
// ExitSync 处理服务发布规则变化后的外部出口同步。
type ExitSync interface {
SyncRules(ctx context.Context) error
} }
func NewProxyService(cfg config.Config, db *sql.DB, store *proxy.Store, certStore *certificate.Store, settingsStore *settings.Store, nginxMgr *nginx.Manager) *ProxyService { func NewProxyService(cfg config.Config, db *sql.DB, store *proxy.Store, certStore *certificate.Store, settingsStore *settings.Store, nginxMgr *nginx.Manager) *ProxyService {
@@ -33,6 +39,10 @@ func (s *ProxyService) SetNotify(notifySvc *notify.Service) {
s.notify = notifySvc s.notify = notifySvc
} }
func (s *ProxyService) SetExitSync(sync ExitSync) {
s.exitSync = sync
}
func (s *ProxyService) List(ctx context.Context) ([]proxy.Rule, error) { func (s *ProxyService) List(ctx context.Context) ([]proxy.Rule, error) {
return s.store.List(ctx) return s.store.List(ctx)
} }
@@ -42,9 +52,11 @@ func (s *ProxyService) Get(ctx context.Context, id int64) (proxy.Rule, error) {
} }
func (s *ProxyService) Create(ctx context.Context, in proxy.CreateInput) (proxy.Rule, error) { func (s *ProxyService) Create(ctx context.Context, in proxy.CreateInput) (proxy.Rule, error) {
if proxy.HasExit(in.Exits, proxy.ExitLocal) {
if err := s.validateHTTPSInput(ctx, in.Hosts, in.HTTPSEnabled); err != nil { if err := s.validateHTTPSInput(ctx, in.Hosts, in.HTTPSEnabled); err != nil {
return proxy.Rule{}, err return proxy.Rule{}, err
} }
}
rule, err := s.store.Create(ctx, in) rule, err := s.store.Create(ctx, in)
if err != nil { if err != nil {
@@ -53,6 +65,9 @@ func (s *ProxyService) Create(ctx context.Context, in proxy.CreateInput) (proxy.
if err := s.applyNginx(ctx); err != nil { if err := s.applyNginx(ctx); err != nil {
return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err) return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err)
} }
if err := s.syncExits(ctx); err != nil {
rule.SyncWarning = "规则已保存,但 Cloudflare 同步失败:" + err.Error()
}
return rule, nil return rule, nil
} }
@@ -69,9 +84,15 @@ func (s *ProxyService) Update(ctx context.Context, id int64, in proxy.UpdateInpu
if in.Hosts != nil { if in.Hosts != nil {
hosts = *in.Hosts hosts = *in.Hosts
} }
exits := current.Exits
if in.Exits != nil {
exits = *in.Exits
}
if proxy.HasExit(exits, proxy.ExitLocal) {
if err := s.validateHTTPSInput(ctx, hosts, httpsEnabled); err != nil { if err := s.validateHTTPSInput(ctx, hosts, httpsEnabled); err != nil {
return proxy.Rule{}, err return proxy.Rule{}, err
} }
}
rule, err := s.store.Update(ctx, id, in) rule, err := s.store.Update(ctx, id, in)
if err != nil { if err != nil {
@@ -80,9 +101,19 @@ func (s *ProxyService) Update(ctx context.Context, id int64, in proxy.UpdateInpu
if err := s.applyNginx(ctx); err != nil { if err := s.applyNginx(ctx); err != nil {
return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err) return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err)
} }
if err := s.syncExits(ctx); err != nil {
rule.SyncWarning = "规则已保存,但 Cloudflare 同步失败:" + err.Error()
}
return rule, nil return rule, nil
} }
func (s *ProxyService) syncExits(ctx context.Context) error {
if s.exitSync == nil {
return nil
}
return s.exitSync.SyncRules(ctx)
}
func (s *ProxyService) Reorder(ctx context.Context, ids []int64) error { func (s *ProxyService) Reorder(ctx context.Context, ids []int64) error {
return s.store.Reorder(ctx, ids) return s.store.Reorder(ctx, ids)
} }
@@ -95,6 +126,9 @@ func (s *ProxyService) Delete(ctx context.Context, id int64) error {
if err := s.applyNginx(ctx); err != nil { if err := s.applyNginx(ctx); err != nil {
return fmt.Errorf("规则已删除,但 Nginx 重载失败:%w", err) return fmt.Errorf("规则已删除,但 Nginx 重载失败:%w", err)
} }
if err := s.syncExits(ctx); err != nil {
return fmt.Errorf("规则已删除,但 Cloudflare 同步失败:%w", err)
}
return nil return nil
} }
@@ -103,7 +137,7 @@ func (s *ProxyService) ReloadAll(ctx context.Context) error {
} }
func (s *ProxyService) applyNginx(ctx context.Context) error { func (s *ProxyService) applyNginx(ctx context.Context) error {
rules, err := s.store.ListEnabled(ctx) rules, err := s.store.ListEnabledLocal(ctx)
if err != nil { if err != nil {
return err return err
} }
@@ -124,17 +158,17 @@ func (s *ProxyService) ValidateRule(ctx context.Context, candidate proxy.Rule, e
replaced := false replaced := false
for _, r := range existing { for _, r := range existing {
if r.ID == candidate.ID { if r.ID == candidate.ID {
if candidate.Enabled { if candidate.LocalEnabled() {
rules = append(rules, candidate) rules = append(rules, candidate)
} }
replaced = true replaced = true
continue continue
} }
if r.Enabled { if r.LocalEnabled() {
rules = append(rules, r) rules = append(rules, r)
} }
} }
if !replaced && candidate.Enabled { if !replaced && candidate.LocalEnabled() {
rules = append(rules, candidate) rules = append(rules, candidate)
} }
certs, err := s.loadCertSources(ctx) certs, err := s.loadCertSources(ctx)
+98
View File
@@ -0,0 +1,98 @@
package updater
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"strings"
"time"
)
// Client 通过共享 Unix Socket 调用 fonu-updater sidecar。
type Client struct {
socketPath string
http *http.Client
}
func NewClient(socketPath string) *Client {
socketPath = strings.TrimSpace(socketPath)
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var dialer net.Dialer
return dialer.DialContext(ctx, "unix", socketPath)
},
}
return &Client{
socketPath: socketPath,
http: &http.Client{Transport: transport, Timeout: 15 * time.Second},
}
}
func (c *Client) Enabled() bool {
if c == nil || c.socketPath == "" {
return false
}
info, err := os.Stat(c.socketPath)
return err == nil && !info.IsDir()
}
func (c *Client) Status(ctx context.Context) (Status, error) {
var status Status
if err := c.request(ctx, http.MethodGet, "/status", nil, &status); err != nil {
return Status{}, err
}
return status, nil
}
func (c *Client) Apply(ctx context.Context) (Status, error) {
var status Status
if err := c.request(ctx, http.MethodPost, "/apply", map[string]string{}, &status); err != nil {
return Status{}, err
}
return status, nil
}
func (c *Client) request(ctx context.Context, method, path string, payload any, result any) error {
if !c.Enabled() {
return fmt.Errorf("一键升级服务未启用")
}
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, "http://unix"+path, body)
if err != nil {
return err
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("updater 返回 HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
if result == nil {
return nil
}
if err := json.Unmarshal(data, result); err != nil {
return fmt.Errorf("updater 响应格式无效: %w", err)
}
return nil
}
+95
View File
@@ -0,0 +1,95 @@
package updater
import (
"fmt"
"strconv"
"strings"
)
type Phase string
const (
PhaseIdle Phase = "idle"
PhaseScheduled Phase = "scheduled"
PhaseRunning Phase = "running"
PhaseSuccess Phase = "success"
PhaseFailed Phase = "failed"
)
// Status 是主程序与 fonu-updater sidecar 共享的更新状态。
type Status struct {
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
CurrentVersion string `json:"current_version,omitempty"`
LatestVersion string `json:"latest_version,omitempty"`
UpdateAvailable bool `json:"update_available"`
Busy bool `json:"busy"`
Phase Phase `json:"phase"`
Message string `json:"message"`
Log []string `json:"log,omitempty"`
CheckedAt string `json:"checked_at,omitempty"`
}
func busyPhase(phase Phase) bool {
return phase == PhaseScheduled || phase == PhaseRunning
}
// CompareVersions 比较形如 1.2.3、v1.2.3 的版本号;返回值大于 0 表示 a 更新。
func CompareVersions(a, b string) int {
left := parseVersion(a)
right := parseVersion(b)
length := len(left)
if len(right) > length {
length = len(right)
}
for index := 0; index < length; index++ {
var lv, rv int
if index < len(left) {
lv = left[index]
}
if index < len(right) {
rv = right[index]
}
if lv != rv {
if lv > rv {
return 1
}
return -1
}
}
return 0
}
func parseVersion(value string) []int {
clean := strings.TrimSpace(strings.TrimPrefix(value, "v"))
if index := strings.IndexAny(clean, "-+"); index >= 0 {
clean = clean[:index]
}
parts := strings.Split(clean, ".")
out := make([]int, 0, len(parts))
for _, part := range parts {
number, err := strconv.Atoi(part)
if err != nil {
out = append(out, 0)
continue
}
out = append(out, number)
}
return out
}
func validateSemver(value string) error {
if value == "" {
return fmt.Errorf("版本号为空")
}
parts := strings.Split(strings.TrimPrefix(value, "v"), ".")
if len(parts) != 3 {
return fmt.Errorf("版本号格式无效: %s", value)
}
for _, part := range parts {
if _, err := strconv.Atoi(part); err != nil {
return fmt.Errorf("版本号格式无效: %s", value)
}
}
return nil
}
+33
View File
@@ -0,0 +1,33 @@
package updater
import "testing"
func TestCompareVersions(t *testing.T) {
cases := []struct {
left string
right string
want int
}{
{"1.2.20", "1.2.19", 1},
{"v1.2.19", "1.2.20", -1},
{"1.3.0", "1.3.0", 0},
{"1.2.21-beta.1", "1.2.21", 0},
}
for _, tc := range cases {
got := CompareVersions(tc.left, tc.right)
if got != tc.want {
t.Fatalf("CompareVersions(%q, %q) = %d, want %d", tc.left, tc.right, got, tc.want)
}
}
}
func TestValidateSemver(t *testing.T) {
if err := validateSemver("1.2.21"); err != nil {
t.Fatalf("合法版本被拒绝: %v", err)
}
for _, value := range []string{"", "1.2", "main", "1.2.x"} {
if err := validateSemver(value); err == nil {
t.Fatalf("非法版本未被拒绝: %q", value)
}
}
}
+59
View File
@@ -0,0 +1,59 @@
package updater
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestRemoteVersionURLs(t *testing.T) {
urls, err := remoteVersionURLs("http://example.com/mrlee/-Fonu.git", "main")
if err != nil {
t.Fatal(err)
}
if len(urls) == 0 || !strings.Contains(urls[0], "/api/v1/repos/mrlee/-Fonu/contents/VERSION") {
t.Fatalf("缺少 Gitea API 候选地址: %#v", urls)
}
if !strings.Contains(urls[1], "/mrlee/-Fonu/raw/branch/main/VERSION") {
t.Fatalf("缺少 Gitea raw 候选地址: %#v", urls)
}
}
func TestParseRemoteVersion(t *testing.T) {
if got, err := parseRemoteVersion([]byte("1.3.2\n")); err != nil || got != "1.3.2" {
t.Fatalf("文本版本解析失败: got=%q err=%v", got, err)
}
payload, _ := json.Marshal(map[string]string{
"encoding": "base64",
"content": base64.StdEncoding.EncodeToString([]byte("1.3.2\n")),
})
if got, err := parseRemoteVersion(payload); err != nil || got != "1.3.2" {
t.Fatalf("JSON 版本解析失败: got=%q err=%v", got, err)
}
}
func TestFetchRemoteVersionFromGiteaAPI(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/mrlee/-Fonu/contents/VERSION" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]string{
"encoding": "base64",
"content": base64.StdEncoding.EncodeToString([]byte("1.3.2\n")),
})
}))
defer server.Close()
got, err := fetchRemoteVersion(context.Background(), server.URL+"/mrlee/-Fonu.git", "main")
if err != nil {
t.Fatal(err)
}
if got != "1.3.2" {
t.Fatalf("got %q, want 1.3.2", got)
}
}
+383
View File
@@ -0,0 +1,383 @@
package updater
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
const maxCommandOutput = 64 << 10
func (s *Server) runUpdate(ctx context.Context) error {
s.mu.Lock()
s.state.Phase = PhaseRunning
s.state.Message = "正在准备升级"
s.mu.Unlock()
if err := s.ensureComposeFile(ctx); err != nil {
return err
}
latest, err := s.latestVersion(ctx)
if err != nil {
return err
}
if err := validateSemver(latest); err != nil {
return err
}
current, _ := readCurrentVersion(ctx, s.cfg.AppURL)
if current != "" && CompareVersions(latest, current) <= 0 {
return fmt.Errorf("当前已是最新版本 %s", current)
}
previousImage, _ := s.currentImage()
s.logf("当前容器镜像:%s", previousImage)
s.logf("目标版本:%s", latest)
if s.cfg.Mode == "local" {
if err := s.prepareLocalSource(ctx); err != nil {
return err
}
}
if err := s.build(ctx, latest); err != nil {
return err
}
if err := s.recreate(ctx, latest); err != nil {
return err
}
if err := s.waitHealthy(ctx, latest); err != nil {
s.rollback(previousImage)
return fmt.Errorf("升级后健康检查失败:%w", err)
}
return nil
}
func (s *Server) build(ctx context.Context, version string) error {
s.logf("开始构建 fonu:%s,这一步可能持续几分钟", version)
buildCtx, cancel := context.WithTimeout(ctx, s.cfg.BuildTimeout)
defer cancel()
if _, err := s.compose(buildCtx, version, "build", s.cfg.Service); err != nil {
return fmt.Errorf("docker compose build 失败:%w", err)
}
return nil
}
func (s *Server) recreate(ctx context.Context, version string) error {
s.logf("重建容器 %s", s.cfg.Service)
upCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
if _, err := s.compose(upCtx, version, "up", "-d", "--no-deps", "--force-recreate", s.cfg.Service); err != nil {
return fmt.Errorf("docker compose up 失败:%w", err)
}
return nil
}
func (s *Server) waitHealthy(ctx context.Context, expected string) error {
s.logf("等待服务恢复并返回版本 %s", expected)
deadline := time.Now().Add(s.cfg.HealthTimeout)
for time.Now().Before(deadline) {
checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
version, err := readCurrentVersion(checkCtx, s.cfg.AppURL)
cancel()
if err == nil && version == expected {
s.logf("健康检查通过:%s", version)
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(3 * time.Second):
}
}
return fmt.Errorf("等待 %s 超时", expected)
}
func (s *Server) rollback(previousImage string) {
version := imageTag(previousImage)
if version == "" {
s.logf("无法确定上一个镜像 tag,跳过自动回滚")
return
}
s.logf("发现升级失败,尝试回滚到 fonu:%s", version)
rollbackCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if _, err := s.compose(rollbackCtx, version, "up", "-d", "--no-deps", "--force-recreate", s.cfg.Service); err != nil {
s.logf("自动回滚失败:%v", err)
return
}
s.logf("已切回 fonu:%s", version)
}
func (s *Server) prepareLocalSource(ctx context.Context) error {
if _, err := os.Stat(filepath.Join(s.cfg.ProjectDir, ".git")); err != nil {
s.logf("项目目录不是 Git 仓库,跳过源码拉取")
return nil
}
status, err := runCommand(ctx, s.cfg.ProjectDir, nil, "git", "status", "--porcelain")
if err != nil {
return fmt.Errorf("检查 Git 工作区失败:%w", err)
}
if strings.TrimSpace(status) != "" {
return fmt.Errorf("本地源码存在未提交改动,已拒绝自动拉取")
}
s.logf("拉取远端源码")
if _, err := runCommand(ctx, s.cfg.ProjectDir, nil, "git", "pull", "--ff-only", "origin", s.cfg.Branch); err != nil {
return fmt.Errorf("git pull 失败:%w", err)
}
return nil
}
func (s *Server) latestVersion(ctx context.Context) (string, error) {
repoURL := strings.TrimSpace(s.cfg.RepoURL)
if s.cfg.Mode == "local" {
if remote, err := s.localRemoteURL(ctx); err == nil && strings.TrimSpace(remote) != "" {
repoURL = remote
}
}
localPath := filepath.Join(s.cfg.ProjectDir, "VERSION")
if repoURL != "" {
version, err := fetchRemoteVersion(ctx, repoURL, s.cfg.Branch)
if err == nil {
return version, nil
}
if s.cfg.Mode == "git" {
return "", err
}
s.logf("读取远端版本失败,改用本地 VERSION:%v", err)
}
return readVersionFile(localPath)
}
func (s *Server) localRemoteURL(ctx context.Context) (string, error) {
if _, err := os.Stat(filepath.Join(s.cfg.ProjectDir, ".git")); err != nil {
return "", err
}
checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
output, err := runCommand(checkCtx, s.cfg.ProjectDir, nil, "git", "config", "--get", "remote.origin.url")
if err != nil {
return "", err
}
return strings.TrimSpace(output), nil
}
// fetchRemoteVersion 通过仓库 raw / API 地址读取 VERSION,避免为了检查版本克隆整个仓库。
func fetchRemoteVersion(ctx context.Context, repoURL, branch string) (string, error) {
urls, err := remoteVersionURLs(repoURL, branch)
if err != nil {
return "", err
}
var lastErr error
for _, candidate := range urls {
version, err := fetchVersionURL(ctx, candidate)
if err == nil {
return version, nil
}
lastErr = err
}
return "", fmt.Errorf("读取远端版本失败:%w", lastErr)
}
func remoteVersionURLs(repoURL, branch string) ([]string, error) {
parsed, err := url.Parse(strings.TrimSpace(repoURL))
if err != nil || parsed.Host == "" {
return nil, fmt.Errorf("仓库地址无效:%s", repoURL)
}
branch = strings.TrimSpace(branch)
if branch == "" {
branch = "main"
}
repoPath := strings.Trim(strings.TrimSuffix(parsed.Path, ".git"), "/")
if repoPath == "" {
return nil, fmt.Errorf("仓库地址缺少路径:%s", repoURL)
}
base := parsed.Scheme + "://" + parsed.Host
urls := []string{
fmt.Sprintf("%s/api/v1/repos/%s/contents/VERSION?ref=%s", base, repoPath, url.QueryEscape(branch)),
fmt.Sprintf("%s/%s/raw/branch/%s/VERSION", base, repoPath, url.PathEscape(branch)),
fmt.Sprintf("%s/%s/raw/%s/VERSION", base, repoPath, url.PathEscape(branch)),
}
if strings.EqualFold(parsed.Hostname(), "github.com") {
urls = append([]string{fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/VERSION", repoPath, url.PathEscape(branch))}, urls...)
}
return urls, nil
}
func fetchVersionURL(ctx context.Context, rawURL string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return "", err
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("%s: HTTP %d", rawURL, resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
version, err := parseRemoteVersion(data)
if err != nil {
return "", fmt.Errorf("%s: %w", rawURL, err)
}
return version, nil
}
func parseRemoteVersion(data []byte) (string, error) {
text := strings.TrimSpace(string(data))
if strings.HasPrefix(text, "{") {
var payload struct {
Content string `json:"content"`
Encoding string `json:"encoding"`
DownloadURL string `json:"download_url"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return "", err
}
if payload.Encoding == "base64" && payload.Content != "" {
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(payload.Content))
if err != nil {
return "", err
}
text = strings.TrimSpace(string(decoded))
}
}
if err := validateSemver(text); err != nil {
return "", err
}
return text, nil
}
func readVersionFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
version := strings.TrimSpace(string(data))
if err := validateSemver(version); err != nil {
return "", err
}
return version, nil
}
func readCurrentVersion(ctx context.Context, appURL string) (string, error) {
url := strings.TrimRight(appURL, "/") + "/api/version"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
var payload struct {
Version string `json:"version"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
return "", err
}
return strings.TrimSpace(payload.Version), nil
}
func (s *Server) currentImage() (string, error) {
output, err := runCommand(context.Background(), "", nil, "docker", "inspect", "--format", "{{.Config.Image}}", s.cfg.Service)
if err != nil {
return "", err
}
return strings.TrimSpace(output), nil
}
func imageTag(image string) string {
image = strings.TrimSpace(image)
if image == "" {
return ""
}
parts := strings.Split(image, ":")
if len(parts) < 2 {
return ""
}
return parts[len(parts)-1]
}
func (s *Server) compose(ctx context.Context, version string, args ...string) (string, error) {
plan, err := s.resolveComposePlan(ctx)
if err != nil {
return "", err
}
if plan.helper {
return s.composeViaHelper(ctx, plan, version, args...)
}
fullArgs := append([]string{"compose", "--project-name", plan.projectName, "-f", plan.containerCompose}, args...)
env := []string{"FONU_VERSION=" + version}
return runCommand(ctx, s.cfg.ProjectDir, env, "docker", fullArgs...)
}
func (s *Server) composeViaHelper(ctx context.Context, plan composePlan, version string, args ...string) (string, error) {
image, err := s.selfImage(ctx)
if err != nil {
return "", err
}
helperArgs := []string{
"run", "--rm", "--entrypoint", "docker",
"-v", s.cfg.DockerSocket + ":/var/run/docker.sock",
"-v", plan.hostProjectDir + ":/workspace",
"-w", "/workspace",
"-e", "FONU_VERSION=" + version,
image,
"compose", "--project-name", plan.projectName, "-f", plan.helperComposePath,
}
helperArgs = append(helperArgs, args...)
return runCommand(ctx, "", nil, "docker", helperArgs...)
}
func (s *Server) selfImage(ctx context.Context) (string, error) {
if s.cfg.SelfImage != "" {
return s.cfg.SelfImage, nil
}
hostname, err := os.Hostname()
if err != nil {
return "", err
}
output, err := runCommand(ctx, "", nil, "docker", "inspect", "--format", "{{.Config.Image}}", hostname)
if err != nil {
return "", fmt.Errorf("无法识别 updater 镜像:%w", err)
}
image := strings.TrimSpace(output)
if image == "" {
return "", fmt.Errorf("无法识别 updater 镜像")
}
return image, nil
}
func runCommand(ctx context.Context, dir string, extraEnv []string, name string, args ...string) (string, error) {
command := exec.CommandContext(ctx, name, args...)
command.Dir = dir
if len(extraEnv) > 0 {
command.Env = append(os.Environ(), extraEnv...)
}
output, err := command.CombinedOutput()
text := string(output)
if len(text) > maxCommandOutput {
text = text[len(text)-maxCommandOutput:]
}
if err != nil {
return text, fmt.Errorf("%s: %w: %s", strings.Join(append([]string{name}, args...), " "), err, strings.TrimSpace(text))
}
return text, nil
}
+439
View File
@@ -0,0 +1,439 @@
package updater
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
var ErrBusy = errors.New("升级任务正在进行中")
var serviceNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`)
// Config 是 fonu-updater sidecar 的运行配置。
type Config struct {
SocketPath string
ProjectDir string
ComposeFile string
Service string
Mode string
RepoURL string
Branch string
AppURL string
HostProjectDir string
ProjectName string
SelfImage string
DockerSocket string
BuildTimeout time.Duration
HealthTimeout time.Duration
}
type Server struct {
cfg Config
logger *slog.Logger
composePath string
mu sync.Mutex
state Status
lastChecked time.Time
}
type composePlan struct {
helper bool
projectName string
containerCompose string
hostProjectDir string
hostComposePath string
helperComposePath string
}
// NewServer 校验 sidecar 配置;参数不正确时拒绝启动。
func NewServer(cfg Config, logger *slog.Logger) (*Server, error) {
cfg.SocketPath = strings.TrimSpace(cfg.SocketPath)
if cfg.SocketPath == "" {
cfg.SocketPath = "/run/fonu-updater/updater.sock"
}
if strings.TrimSpace(cfg.ProjectDir) == "" {
cfg.ProjectDir = "/workspace"
}
if strings.TrimSpace(cfg.ComposeFile) == "" {
cfg.ComposeFile = "docker-compose.yml"
}
if strings.TrimSpace(cfg.Service) == "" {
cfg.Service = "fonu"
}
if strings.TrimSpace(cfg.Mode) == "" {
cfg.Mode = "local"
}
if strings.TrimSpace(cfg.Branch) == "" {
cfg.Branch = "main"
}
if strings.TrimSpace(cfg.AppURL) == "" {
cfg.AppURL = "http://fonu:6893"
}
if strings.TrimSpace(cfg.DockerSocket) == "" {
cfg.DockerSocket = "/var/run/docker.sock"
}
cfg.HostProjectDir = strings.TrimSpace(cfg.HostProjectDir)
cfg.ProjectName = strings.TrimSpace(cfg.ProjectName)
cfg.SelfImage = strings.TrimSpace(cfg.SelfImage)
if cfg.BuildTimeout <= 0 {
cfg.BuildTimeout = 30 * time.Minute
}
if cfg.HealthTimeout <= 0 {
cfg.HealthTimeout = 3 * time.Minute
}
if cfg.Mode != "local" && cfg.Mode != "git" {
return nil, fmt.Errorf("FONU_UPDATE_MODE 只支持 local 或 git")
}
if !serviceNamePattern.MatchString(cfg.Service) {
return nil, fmt.Errorf("FONU_UPDATE_SERVICE 含非法字符")
}
composePath, err := resolveComposePath(cfg.ProjectDir, cfg.ComposeFile)
if err != nil {
return nil, err
}
if cfg.Mode == "git" && strings.TrimSpace(cfg.RepoURL) == "" {
return nil, fmt.Errorf("git 模式必须设置 FONU_UPDATE_REPO")
}
if logger == nil {
logger = slog.Default()
}
return &Server{
cfg: cfg,
logger: logger,
composePath: composePath,
state: Status{
Enabled: true,
Mode: cfg.Mode,
Phase: PhaseIdle,
Message: "尚未检查更新",
},
}, nil
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /status", s.handleStatus)
mux.HandleFunc("POST /apply", s.handleApply)
return mux
}
// Serve 在共享 Unix Socket 上提供服务。
func (s *Server) Serve(ctx context.Context) error {
if err := os.MkdirAll(filepath.Dir(s.cfg.SocketPath), 0o750); err != nil {
return err
}
_ = os.Remove(s.cfg.SocketPath)
listener, err := net.Listen("unix", s.cfg.SocketPath)
if err != nil {
return err
}
defer func() {
_ = listener.Close()
_ = os.Remove(s.cfg.SocketPath)
}()
_ = os.Chmod(s.cfg.SocketPath, 0o660)
server := &http.Server{
Handler: s.Handler(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
err = server.Serve(listener)
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.Status(r.Context()))
}
func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
status, err := s.Apply(r.Context())
if errors.Is(err, ErrBusy) {
writeJSON(w, http.StatusConflict, status)
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusAccepted, status)
}
func (s *Server) ensureComposeFile(ctx context.Context) error {
_, err := s.resolveComposePlan(ctx)
return err
}
func (s *Server) resolveComposePlan(ctx context.Context) (composePlan, error) {
projectName, err := s.composeProjectName(ctx)
if err != nil {
return composePlan{}, err
}
info, err := os.Stat(s.composePath)
if err == nil && !info.IsDir() {
return composePlan{projectName: projectName, containerCompose: s.composePath}, nil
}
if err == nil && info.IsDir() {
return composePlan{}, fmt.Errorf("Compose 路径是目录而不是文件:%s", s.composePath)
}
hostDir, hostComposePath, discoverErr := s.discoverHostCompose(ctx)
if discoverErr == nil {
helperPath, pathErr := helperComposePath(hostDir, hostComposePath)
if pathErr == nil {
return composePlan{
helper: true,
projectName: projectName,
hostProjectDir: hostDir,
hostComposePath: hostComposePath,
helperComposePath: helperPath,
}, nil
}
}
return composePlan{}, fmt.Errorf("找不到 Compose 文件 %s,且无法自动定位宿主机目录;请设置 FONU_UPDATE_HOST_DIR", s.composePath)
}
func (s *Server) composeProjectName(ctx context.Context) (string, error) {
projectName, err := s.dockerLabel(ctx, "com.docker.compose.project")
projectName = strings.TrimSpace(projectName)
if projectName != "" {
return projectName, nil
}
if s.cfg.ProjectName != "" {
return s.cfg.ProjectName, nil
}
if err != nil {
return "", fmt.Errorf("无法读取现有容器的 Compose 项目标签:%w", err)
}
return "", fmt.Errorf("现有 fonu 容器不是由 Compose 管理,无法安全一键升级;请按 Compose 方式重新部署")
}
func (s *Server) discoverHostCompose(ctx context.Context) (string, string, error) {
if s.cfg.HostProjectDir != "" && !isContainerPath(s.cfg.HostProjectDir, s.cfg.ProjectDir) {
if !filepath.IsAbs(s.cfg.HostProjectDir) {
return "", "", fmt.Errorf("FONU_UPDATE_HOST_DIR 必须是绝对路径")
}
return s.cfg.HostProjectDir, filepath.Join(s.cfg.HostProjectDir, filepath.Base(s.cfg.ComposeFile)), nil
}
workingDir, _ := s.dockerLabel(ctx, "com.docker.compose.project.working_dir")
configFiles, _ := s.dockerLabel(ctx, "com.docker.compose.project.config_files")
hostDir := strings.TrimSpace(workingDir)
hostCompose := chooseComposeFile(configFiles, filepath.Base(s.cfg.ComposeFile))
if hostCompose == "" {
if hostDir == "" || isContainerPath(hostDir, s.cfg.ProjectDir) {
return "", "", fmt.Errorf("无法从容器标签读取宿主机 Compose 目录")
}
hostCompose = filepath.Join(hostDir, filepath.Base(s.cfg.ComposeFile))
}
if hostDir == "" || isContainerPath(hostDir, s.cfg.ProjectDir) {
hostDir = filepath.Dir(hostCompose)
}
if !filepath.IsAbs(hostDir) || !filepath.IsAbs(hostCompose) {
return "", "", fmt.Errorf("容器标签中的 Compose 路径不是绝对路径")
}
if isContainerPath(hostDir, s.cfg.ProjectDir) {
return "", "", fmt.Errorf("容器标签只提供了容器内路径 %s,无法定位宿主机 Compose 目录", hostDir)
}
return filepath.Clean(hostDir), filepath.Clean(hostCompose), nil
}
func isContainerPath(path, containerDir string) bool {
path = filepath.Clean(strings.TrimSpace(path))
containerDir = filepath.Clean(strings.TrimSpace(containerDir))
if path == "" || containerDir == "" || path == "." {
return false
}
return path == containerDir || strings.HasPrefix(path, containerDir+string(filepath.Separator))
}
func (s *Server) dockerLabel(ctx context.Context, label string) (string, error) {
format := fmt.Sprintf(`{{ index .Config.Labels %q }}`, label)
checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
output, err := runCommand(checkCtx, "", nil, "docker", "inspect", "--format", format, s.cfg.Service)
if err != nil {
return "", err
}
return strings.TrimSpace(output), nil
}
func chooseComposeFile(configFiles, expectedBase string) string {
for _, item := range strings.Split(configFiles, ",") {
path := strings.TrimSpace(item)
if path == "" {
continue
}
if filepath.Base(path) == expectedBase {
return path
}
}
for _, item := range strings.Split(configFiles, ",") {
if path := strings.TrimSpace(item); path != "" {
return path
}
}
return ""
}
func helperComposePath(hostDir, hostCompose string) (string, error) {
rel, err := filepath.Rel(hostDir, hostCompose)
if err != nil {
return "", err
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("Compose 文件不在宿主机项目目录内")
}
return filepath.Join("/workspace", rel), nil
}
// Status 返回当前版本、远端版本和任务状态。
func (s *Server) Status(ctx context.Context) Status {
s.mu.Lock()
if busyPhase(s.state.Phase) {
status := cloneStatus(s.state)
s.mu.Unlock()
return status
}
s.mu.Unlock()
if err := s.ensureComposeFile(ctx); err != nil {
s.mu.Lock()
defer s.mu.Unlock()
s.state.UpdateAvailable = false
s.state.Message = err.Error()
s.state.CheckedAt = time.Now().UTC().Format(time.RFC3339)
return cloneStatus(s.state)
}
s.mu.Lock()
if !s.lastChecked.IsZero() && time.Since(s.lastChecked) < time.Minute {
status := cloneStatus(s.state)
s.mu.Unlock()
return status
}
s.mu.Unlock()
checkCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
current, currentErr := readCurrentVersion(checkCtx, s.cfg.AppURL)
latest, latestErr := s.latestVersion(checkCtx)
s.mu.Lock()
defer s.mu.Unlock()
s.lastChecked = time.Now()
s.state.CurrentVersion = current
s.state.LatestVersion = latest
s.state.UpdateAvailable = latest != "" && current != "" && CompareVersions(latest, current) > 0
s.state.CheckedAt = time.Now().UTC().Format(time.RFC3339)
switch {
case currentErr != nil:
s.state.Message = "无法读取当前运行版本:" + currentErr.Error()
case latestErr != nil:
s.state.Message = "无法读取远端最新版本:" + latestErr.Error()
case s.state.UpdateAvailable:
s.state.Message = fmt.Sprintf("发现新版本 %s,当前 %s", latest, current)
default:
s.state.Message = "当前已是最新版本"
}
return cloneStatus(s.state)
}
// Apply 立即返回排队状态,后台完成构建、重建、健康检查和必要回滚。
func (s *Server) Apply(_ context.Context) (Status, error) {
s.mu.Lock()
if busyPhase(s.state.Phase) {
status := cloneStatus(s.state)
s.mu.Unlock()
return status, ErrBusy
}
s.state.Phase = PhaseScheduled
s.state.Busy = true
s.state.Message = "升级任务已排队,即将重启 Fonu"
s.state.Log = []string{"升级任务已排队"}
status := cloneStatus(s.state)
s.mu.Unlock()
go s.runScheduled()
return status, nil
}
func (s *Server) runScheduled() {
time.Sleep(2 * time.Second)
if err := s.runUpdate(context.Background()); err != nil {
s.logf("升级失败:%v", err)
s.finish(PhaseFailed, err.Error())
return
}
s.finish(PhaseSuccess, "升级完成")
}
func (s *Server) finish(phase Phase, message string) {
s.mu.Lock()
s.state.Phase = phase
s.state.Busy = false
s.state.Message = message
s.lastChecked = time.Time{}
s.mu.Unlock()
}
func (s *Server) logf(format string, args ...any) {
line := fmt.Sprintf(format, args...)
s.logger.Info(line, "module", "UPDATER")
s.mu.Lock()
s.state.Log = append(s.state.Log, line)
if len(s.state.Log) > 200 {
s.state.Log = s.state.Log[len(s.state.Log)-200:]
}
s.mu.Unlock()
}
func cloneStatus(status Status) Status {
status.Log = append([]string(nil), status.Log...)
return status
}
func resolveComposePath(projectDir, composeFile string) (string, error) {
dir, err := filepath.Abs(strings.TrimSpace(projectDir))
if err != nil {
return "", err
}
path := strings.TrimSpace(composeFile)
if !filepath.IsAbs(path) {
path = filepath.Join(dir, path)
}
path = filepath.Clean(path)
rel, err := filepath.Rel(dir, path)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("compose 文件必须位于项目目录内")
}
switch filepath.Base(path) {
case "docker-compose.yml", "docker-compose.hub.yml", "docker-compose.host.yml":
return path, nil
default:
return "", fmt.Errorf("不支持的 compose 文件:%s", filepath.Base(path))
}
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+104
View File
@@ -0,0 +1,104 @@
package updater
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestResolveComposePath(t *testing.T) {
dir := t.TempDir()
got, err := resolveComposePath(dir, "docker-compose.hub.yml")
if err != nil {
t.Fatal(err)
}
want := filepath.Join(dir, "docker-compose.hub.yml")
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
if _, err := resolveComposePath(dir, "../docker-compose.yml"); err == nil {
t.Fatal("路径上跳应被拒绝")
}
if _, err := resolveComposePath(dir, "compose.yml"); err == nil {
t.Fatal("不在白名单中的 compose 文件应被拒绝")
}
}
func TestNewServerDefaultsAndValidation(t *testing.T) {
server, err := NewServer(Config{
ProjectDir: t.TempDir(),
ComposeFile: "docker-compose.yml",
}, nil)
if err != nil {
t.Fatal(err)
}
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 {
t.Fatal("非法 mode 应被拒绝")
}
if _, err := NewServer(Config{ProjectDir: t.TempDir(), Mode: "git"}, 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: "fonu"}, nil)
if err != nil {
t.Fatal(err)
}
if err := server.ensureComposeFile(context.Background()); err == nil {
t.Fatal("缺少 compose 文件时应返回错误")
}
path := filepath.Join(dir, "docker-compose.yml")
if err := os.WriteFile(path, []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := server.ensureComposeFile(context.Background()); err != nil {
t.Fatalf("存在 compose 文件时不应报错: %v", err)
}
}
func TestResolveComposePlanUsesHostProjectDir(t *testing.T) {
containerDir := t.TempDir()
hostDir := t.TempDir()
server, err := NewServer(Config{
ProjectDir: containerDir,
ComposeFile: "docker-compose.hub.yml",
HostProjectDir: hostDir,
ProjectName: "fonu",
}, nil)
if err != nil {
t.Fatal(err)
}
plan, err := server.resolveComposePlan(context.Background())
if err != nil {
t.Fatal(err)
}
if !plan.helper {
t.Fatal("容器内看不到 Compose 文件时应使用 helper 模式")
}
if plan.helperComposePath != filepath.Join("/workspace", "docker-compose.hub.yml") {
t.Fatalf("helper compose 路径不正确: %s", plan.helperComposePath)
}
}
func TestIsContainerPath(t *testing.T) {
cases := []struct {
path string
want bool
}{
{"/workspace", true},
{"/workspace/docker-compose.yml", true},
{"/vol1/1000/Docker/mafrp", false},
{"/vol1/1000/Docker/mafrp/docker-compose.yml", false},
}
for _, tc := range cases {
if got := isContainerPath(tc.path, "/workspace"); got != tc.want {
t.Fatalf("isContainerPath(%q) = %v, want %v", tc.path, got, tc.want)
}
}
}
+7
View File
@@ -0,0 +1,7 @@
-- Agent 传输安全:默认支持 SSH 隧道回环访问,保留 http 作为升级兼容模式。
ALTER TABLE frp_servers ADD COLUMN agent_transport TEXT NOT NULL DEFAULT 'http';
ALTER TABLE frp_servers ADD COLUMN agent_local_port INTEGER NOT NULL DEFAULT 0;
ALTER TABLE frp_servers ADD COLUMN agent_tls_pin TEXT NOT NULL DEFAULT '';
ALTER TABLE frp_servers ADD COLUMN agent_listen_addr TEXT NOT NULL DEFAULT '';
ALTER TABLE frp_servers ADD COLUMN agent_transport_state TEXT NOT NULL DEFAULT 'unknown';
ALTER TABLE frp_servers ADD COLUMN agent_transport_error TEXT NOT NULL DEFAULT '';

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